From ae6cac89e12ea7a844bcf7e11be9d079abbd9365 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 11 Aug 2026 11:52:56 -0400 Subject: [PATCH] G2 support --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/feature_request.yml | 5 +- .github/ISSUE_TEMPLATE/mod_request.yml | 5 +- CONTRIBUTING-mods.md | 119 +- GOLD-OPEN-ITEMS.md | 275 + KANTO-CONTINUE.md | 457 + README.md | 14 +- assets/logo/logo.png | Bin 36071 -> 98569 bytes ...erences-between-gold-silver-and-crystal.md | 615 + docs/gen2-link-design.md | 316 + docs/gold-phase1.md | 188 + docs/gold-walkthrough/asm-walk/README.md | 1374 ++ docs/gold-walkthrough/asm-walk/_TEMPLATE.md | 126 + ...tion-00-chikorita-cyndaquil-or-totodile.md | 535 + ...on-01-cherrygrove-city-and-routes-29-31.md | 1081 + ...ion-02-sprout-tower-and-violet-city-gym.md | 777 + ...section-03-ruins-of-alph-and-union-cave.md | 1280 ++ ...ection-04-slowpoke-well-azalea-town-gym.md | 907 + ...n-05-ilex-forest-and-goldenrod-city-gym.md | 1309 ++ ...n-06-bug-catching-contest-and-sudowoodo.md | 987 + ...n-07-burned-tower-and-ecruteak-city-gym.md | 1227 ++ ...livine-lighthouse-and-cianwood-city-gym.md | 1632 ++ .../asm-walk/section-09-olivine-city-gym.md | 681 + ...-10-the-lake-of-rage-and-shiny-gyarados.md | 1101 + ...am-rocket-hideout-and-mahogany-town-gym.md | 851 + .../section-12-team-rocket-radio-tower.md | 1050 + ...ion-13-ice-path-and-blackthorn-city-gym.md | 1255 ++ .../section-14-ho-oh-gold-and-lugia-silver.md | 1090 + .../section-15-mount-mortar-and-dark-cave.md | 762 + .../section-16-routes-45-46-26-and-27.md | 1078 + .../asm-walk/section-17-victory-road.md | 599 + .../asm-walk/section-18-pok-mon-league.md | 883 + ...tion-19-s-s-aqua-and-vermilion-city-gym.md | 1093 + .../asm-walk/section-20-saffron-city-gym.md | 680 + ...on-21-power-plant-and-cerulean-city-gym.md | 896 + ...ion-22-rock-tunnel-and-celadon-city-gym.md | 1277 ++ ...on-23-routes-11-19-and-fuchsia-city-gym.md | 1508 ++ .../section-24-snorlax-and-pewter-city-gym.md | 919 + .../section-25-lugia-gold-and-ho-oh-silver.md | 987 + .../section-26-mount-moon-and-routes-1-4.md | 893 + ...cinnabar-island-and-seafoam-islands-gym.md | 570 + ...n-28-routes-19-20-and-viridian-city-gym.md | 460 + .../asm-walk/section-29-routes-22-28.md | 785 + .../asm-walk/section-30-lapras.md | 611 + .../section-31-mount-silver-and-red.md | 715 + .../section-32-raikou-entei-and-suicune.md | 823 + ...ion-00-chikorita-cyndaquil-or-totodile.txt | 119 + ...n-01-cherrygrove-city-and-routes-29-31.txt | 369 + ...on-02-sprout-tower-and-violet-city-gym.txt | 150 + ...ection-03-ruins-of-alph-and-union-cave.txt | 312 + ...ction-04-slowpoke-well-azalea-town-gym.txt | 155 + ...-05-ilex-forest-and-goldenrod-city-gym.txt | 395 + ...-06-bug-catching-contest-and-sudowoodo.txt | 292 + ...-07-burned-tower-and-ecruteak-city-gym.txt | 361 + ...ivine-lighthouse-and-cianwood-city-gym.txt | 584 + .../section-09-olivine-city-gym.txt | 81 + ...10-the-lake-of-rage-and-shiny-gyarados.txt | 223 + ...m-rocket-hideout-and-mahogany-town-gym.txt | 334 + .../section-12-team-rocket-radio-tower.txt | 324 + ...on-13-ice-path-and-blackthorn-city-gym.txt | 317 + ...section-14-ho-oh-gold-and-lugia-silver.txt | 193 + .../section-15-mount-mortar-and-dark-cave.txt | 222 + .../section-16-routes-45-46-26-and-27.txt | 394 + .../section-17-victory-road.txt | 99 + .../section-18-pok-mon-league.txt | 296 + ...ion-19-s-s-aqua-and-vermilion-city-gym.txt | 246 + .../section-20-saffron-city-gym.txt | 135 + ...n-21-power-plant-and-cerulean-city-gym.txt | 304 + ...on-22-rock-tunnel-and-celadon-city-gym.txt | 313 + ...n-23-routes-11-19-and-fuchsia-city-gym.txt | 468 + ...section-24-snorlax-and-pewter-city-gym.txt | 192 + ...section-25-lugia-gold-and-ho-oh-silver.txt | 179 + .../section-26-mount-moon-and-routes-1-4.txt | 232 + ...innabar-island-and-seafoam-islands-gym.txt | 133 + ...-28-routes-19-20-and-viridian-city-gym.txt | 134 + .../section-29-routes-22-28.txt | 80 + docs/gold-walkthrough/section-30-lapras.txt | 72 + .../section-31-mount-silver-and-red.txt | 153 + .../section-32-raikou-entei-and-suicune.txt | 61 + docs/launcher.md | 23 +- docs/mod-api-gen2-compat.md | 846 + docs/modding.md | 25 +- docs/modding/reference/registries.md | 1169 + docs/new-features.md | 17 + docs/preparing-your-mod-for-gen2.md | 784 + docs/rfcs/0001-surfing-pikachu-sprite.md | 96 - docs/rfcs/0002-screen-render-visible.md | 54 - main.lua | 84 +- mods/nuzlocke/README.md | 13 - mods/nuzlocke/main.lua | 224 - mods/nuzlocke/manifest.json | 19 - mods/spanish_ui/README.md | 27 - mods/spanish_ui/TRANSLATING.md | 111 - mods/spanish_ui/assets/font/README.md | 11 - mods/spanish_ui/lang/charmap.lua | 10 - mods/spanish_ui/lang/dialogue.lua | 12 - mods/spanish_ui/lang/font.lua | 13 - mods/spanish_ui/lang/item_names.lua | 11 - mods/spanish_ui/lang/move_names.lua | 10 - mods/spanish_ui/lang/naming.lua | 41 - mods/spanish_ui/lang/species_names.lua | 9 - mods/spanish_ui/lang/status_labels.lua | 11 - mods/spanish_ui/lang/strings.lua | 584 - mods/spanish_ui/lang/trainer_names.lua | 7 - mods/spanish_ui/main.lua | 127 - mods/spanish_ui/manifest.json | 17 - scripts/build.sh | 4 +- scripts/build_android.sh | 4 +- scripts/pack_love.sh | 4 +- src/battle/gen2/Ai.lua | 1674 ++ src/battle/gen2/AnimObjects.lua | 1999 ++ src/battle/gen2/AnimRunner.lua | 480 + src/battle/gen2/Battle.lua | 4479 ++++ src/battle/gen2/BattleMusic.lua | 131 + src/battle/gen2/BerryJuice.lua | 60 + src/battle/gen2/BgEffects.lua | 1322 ++ src/battle/gen2/Catching.lua | 401 + src/battle/gen2/Damage.lua | 328 + src/battle/gen2/Effects.lua | 510 + src/battle/gen2/Encounter.lua | 182 + src/battle/gen2/HpBar.lua | 155 + src/battle/gen2/Mon.lua | 480 + src/battle/gen2/Prize.lua | 223 + src/core/ChipAudio.lua | 13 +- src/core/ChipSynth.lua | 370 +- src/core/Game2.lua | 1975 ++ src/core/GameVersion.lua | 44 +- src/core/Input.lua | 19 + src/core/LaunchOptions.lua | 2 + src/core/Music.lua | 20 + src/core/SaveData.lua | 145 +- src/core/Sound.lua | 218 +- src/core/StateStack.lua | 27 +- src/core/TouchControls.lua | 18 +- src/core/gen2/Apricorns.lua | 522 + src/core/gen2/AutoInput.lua | 313 + src/core/gen2/Boxes.lua | 165 + src/core/gen2/Breeding.lua | 1138 + src/core/gen2/BugContest.lua | 834 + src/core/gen2/CatchTutorial.lua | 137 + src/core/gen2/Clock.lua | 131 + src/core/gen2/CoinCase.lua | 70 + src/core/gen2/CommonText.lua | 117 + src/core/gen2/Decorations.lua | 523 + src/core/gen2/Evolution.lua | 510 + src/core/gen2/HallOfFame.lua | 262 + src/core/gen2/Happiness.lua | 288 + src/core/gen2/ItemEffects.lua | 483 + src/core/gen2/MagnetTrain.lua | 361 + src/core/gen2/Mail.lua | 503 + src/core/gen2/MomShopping.lua | 244 + src/core/gen2/Nests.lua | 162 + src/core/gen2/NpcTrade.lua | 165 + src/core/gen2/Phone.lua | 1201 ++ src/core/gen2/PhoneRing.lua | 130 + src/core/gen2/Pokerus.lua | 290 + src/core/gen2/Roamers.lua | 573 + src/core/gen2/Save.lua | 792 + src/core/gen2/TradeAnim.lua | 274 + src/core/gen2/Unown.lua | 328 + src/import/LauncherSettings.lua | 140 +- src/import/LauncherView.lua | 92 +- src/import/Rom.lua | 99 + src/import/RomExtractorGen2.lua | 5905 ++++++ src/import/RomImporter.lua | 134 +- src/inventory/Bag.lua | 71 +- src/link/Fingerprint.lua | 229 +- src/link/Handshake.lua | 73 +- src/link/Protocol.lua | 241 +- src/mods/Builtins.lua | 125 +- src/mods/Gen2Compat.lua | 2140 ++ src/mods/LauncherMods.lua | 104 +- src/mods/Loader.lua | 354 +- src/mods/ManagerState.lua | 110 +- src/mods/Manifest.lua | 36 + src/mods/ModProfile.lua | 79 +- src/mods/ModTargets.lua | 171 + src/mods/Schemas.lua | 872 +- src/pokemon/Sprites.lua | 16 + src/render/Font.lua | 200 +- src/render/GbcPalette.lua | 434 + src/render/SpriteRenderer.lua | 45 +- src/render/TextBox.lua | 56 +- src/render/Tilt.lua | 15 + src/save_convert/GenSave.lua | 12 +- src/save_convert/SaveConvert.lua | 17 + src/script/gen2/CallAsm.lua | 734 + src/script/gen2/Movement.lua | 180 + src/script/gen2/Opcodes.lua | 214 + src/script/gen2/Specials.lua | 2465 +++ src/script/gen2/Vm.lua | 2646 +++ src/ui/IntroMovie.lua | 35 +- src/ui/Screens.lua | 77 +- src/ui/TitleState.lua | 12 +- src/ui/gen2/BankOfMom.lua | 153 + src/ui/gen2/BattleAnimView.lua | 421 + src/ui/gen2/BattleHud.lua | 291 + src/ui/gen2/BattleState.lua | 3150 +++ src/ui/gen2/BattleTransition.lua | 728 + src/ui/gen2/BoxMenu.lua | 785 + src/ui/gen2/CallerBox.lua | 98 + src/ui/gen2/CardFlip.lua | 654 + src/ui/gen2/CenterPcMenu.lua | 325 + src/ui/gen2/Chrome.lua | 374 + src/ui/gen2/ContestMenu.lua | 190 + src/ui/gen2/CopyrightSplash.lua | 118 + src/ui/gen2/Credits.lua | 862 + src/ui/gen2/DayCareMenu.lua | 574 + src/ui/gen2/DecorationMenu.lua | 279 + src/ui/gen2/Diploma.lua | 195 + src/ui/gen2/EggHatchAnim.lua | 456 + src/ui/gen2/ElevatorMenu.lua | 154 + src/ui/gen2/EvolutionAnim.lua | 563 + src/ui/gen2/GameFreakPresents.lua | 385 + src/ui/gen2/GoldSilverIntro.lua | 1053 + src/ui/gen2/HallOfFame.lua | 676 + src/ui/gen2/HeldItemMenu.lua | 399 + src/ui/gen2/InitClock.lua | 364 + src/ui/gen2/ItemPcMenu.lua | 604 + src/ui/gen2/MagnetTrainRide.lua | 333 + src/ui/gen2/MailCompose.lua | 362 + src/ui/gen2/MailMenu.lua | 315 + src/ui/gen2/MailRead.lua | 104 + src/ui/gen2/MailboxMenu.lua | 415 + src/ui/gen2/MainMenu.lua | 229 + src/ui/gen2/MapRadio.lua | 204 + src/ui/gen2/MartMenu.lua | 1019 + src/ui/gen2/MoveDeleter.lua | 113 + src/ui/gen2/NamePick.lua | 231 + src/ui/gen2/NamingScreen.lua | 553 + src/ui/gen2/OakSpeech.lua | 648 + src/ui/gen2/OptionsMenu.lua | 389 + src/ui/gen2/PackGfx.lua | 159 + src/ui/gen2/PackMenu.lua | 899 + src/ui/gen2/PartyMenu.lua | 785 + src/ui/gen2/PcMenu.lua | 340 + src/ui/gen2/PhotoStudio.lua | 216 + src/ui/gen2/PokedexMenu.lua | 1460 ++ src/ui/gen2/Pokegear.lua | 2291 ++ src/ui/gen2/PrizeMenu.lua | 570 + src/ui/gen2/SaveMenu.lua | 229 + src/ui/gen2/ScriptMenu.lua | 303 + src/ui/gen2/SlotMachine.lua | 1078 + src/ui/gen2/SpriteAnims.lua | 938 + src/ui/gen2/StartMenu.lua | 307 + src/ui/gen2/SummaryMenu.lua | 1137 + src/ui/gen2/TileSheet.lua | 106 + src/ui/gen2/TitleState.lua | 283 + src/ui/gen2/TradeAnim.lua | 855 + src/ui/gen2/TradeMenu.lua | 357 + src/ui/gen2/TrainerCard.lua | 452 + src/ui/gen2/UnownPrinter.lua | 205 + src/ui/gen2/UnownPuzzle.lua | 590 + src/ui/kit/Theme.lua | 13 +- src/world/gen2/Bike.lua | 233 + src/world/gen2/BorderFill.lua | 213 + src/world/gen2/CmdQueue.lua | 263 + src/world/gen2/Events.lua | 102 + src/world/gen2/FieldMoves.lua | 810 + src/world/gen2/Follower.lua | 248 + src/world/gen2/HiddenItems.lua | 289 + src/world/gen2/Map.lua | 304 + src/world/gen2/Npc.lua | 740 + src/world/gen2/Palettes.lua | 275 + src/world/gen2/Permissions.lua | 348 + src/world/gen2/Player.lua | 225 + src/world/gen2/StepEvents.lua | 200 + src/world/gen2/TrainerHouse.lua | 122 + src/world/gen2/Trainers.lua | 130 + src/world/gen2/World.lua | 9927 +++++++++ src/world/gen2/WorldAPI.lua | 329 + tests/drivers/gold/adapter.lua | 1024 + tests/drivers/gold/bot.lua | 2428 +++ tests/drivers/gold/flag_names.lua | 1340 ++ tests/drivers/gold/map_regions.lua | 1897 ++ tests/drivers/gold/route.lua | 1263 ++ tests/drivers/gold_battle_anim_shots.lua | 92 + tests/drivers/gold_battle_items.lua | 151 + tests/drivers/gold_battle_pack_probe.lua | 92 + tests/drivers/gold_battle_rules.lua | 294 + tests/drivers/gold_battle_screen_probe.lua | 370 + tests/drivers/gold_battle_smoke.lua | 109 + tests/drivers/gold_boot_smoke.lua | 168 + tests/drivers/gold_bot.lua | 1252 ++ tests/drivers/gold_burned_tower_beasts.lua | 85 + tests/drivers/gold_catch_probe.lua | 171 + tests/drivers/gold_center_pc.lua | 137 + tests/drivers/gold_clock_anchor.lua | 67 + tests/drivers/gold_dig_warp.lua | 146 + tests/drivers/gold_egg_hatch.lua | 131 + tests/drivers/gold_egg_hatch_shots.lua | 148 + tests/drivers/gold_evolution_shots.lua | 99 + tests/drivers/gold_exp_bar.lua | 115 + tests/drivers/gold_extractor_pass.lua | 321 + tests/drivers/gold_field_anim_shots.lua | 180 + tests/drivers/gold_field_items.lua | 251 + tests/drivers/gold_forceshiny_probe.lua | 67 + tests/drivers/gold_frame_seams.lua | 240 + tests/drivers/gold_freeze_other_objects.lua | 162 + tests/drivers/gold_giveitem_box.lua | 99 + tests/drivers/gold_halloffame_shots.lua | 147 + tests/drivers/gold_heal_mart_shots.lua | 152 + tests/drivers/gold_hm07_probe.lua | 84 + tests/drivers/gold_hof_continue.lua | 111 + tests/drivers/gold_icepath_boulder.lua | 152 + tests/drivers/gold_intro_shots.lua | 55 + tests/drivers/gold_item_pickup_box.lua | 109 + tests/drivers/gold_lake_probe.lua | 97 + tests/drivers/gold_link_fingerprint.lua | 261 + tests/drivers/gold_mahogany_lance.lua | 106 + tests/drivers/gold_map_callbacks.lua | 199 + tests/drivers/gold_menu_shots.lua | 269 + tests/drivers/gold_mom_scene.lua | 80 + tests/drivers/gold_move_pack_egg.lua | 144 + tests/drivers/gold_naming_and_trade_item.lua | 169 + tests/drivers/gold_noeffect_anim.lua | 185 + tests/drivers/gold_opaque_surround.lua | 114 + tests/drivers/gold_overworld_npc.lua | 114 + tests/drivers/gold_palette_shots.lua | 70 + tests/drivers/gold_party_held_item.lua | 105 + tests/drivers/gold_party_submenu.lua | 134 + tests/drivers/gold_phone_call.lua | 154 + tests/drivers/gold_phone_caller_box.lua | 128 + tests/drivers/gold_phone_ring.lua | 150 + tests/drivers/gold_pokecenter_stairs.lua | 69 + tests/drivers/gold_pokegear_call_paper.lua | 69 + tests/drivers/gold_radio_persist.lua | 141 + tests/drivers/gold_roamers.lua | 210 + tests/drivers/gold_rock_probe.lua | 81 + tests/drivers/gold_rock_smash_probe.lua | 159 + tests/drivers/gold_rocket_cameras.lua | 107 + tests/drivers/gold_route29_tutorial.lua | 80 + tests/drivers/gold_shiny_shots.lua | 98 + .../drivers/gold_snorlax_footprint_probe.lua | 105 + tests/drivers/gold_spin_clockwise.lua | 85 + tests/drivers/gold_starter_pic.lua | 137 + tests/drivers/gold_sudowoodo_twins.lua | 130 + tests/drivers/gold_summary_shots.lua | 125 + tests/drivers/gold_teacher_scene.lua | 103 + tests/drivers/gold_tilt_shot.lua | 20 + tests/drivers/gold_title_shots.lua | 50 + tests/drivers/gold_touch_controls.lua | 119 + tests/drivers/gold_trainer_smoke.lua | 136 + tests/drivers/gold_transition_shots.lua | 64 + tests/drivers/gold_travel_probe.lua | 181 + tests/drivers/gold_unown_scientist.lua | 103 + .../drivers/gold_variablesprite_identity.lua | 87 + tests/drivers/gold_vf_caller_box2.lua | 125 + tests/drivers/gold_vf_touch_boot.lua | 105 + tests/drivers/gold_vf_touch_play.lua | 118 + tests/drivers/gold_walk_smoke.lua | 116 + tests/drivers/gold_wall_radio.lua | 92 + tests/drivers/gold_warp_scene.lua | 124 + tests/drivers/gold_water_moves.lua | 168 + .../drivers/gold_waterfall_current_probe.lua | 127 + tests/drivers/gold_zoom_shots.lua | 62 + tests/drivers/verify_battle_screen.lua | 249 + tests/engine/gate_gen2_mod_api.lua | 992 + tests/engine/gate_gen2_mod_facade.lua | 640 + tests/engine/gate_meta_coverage.lua | 39 +- tests/engine/gate_strings_coverage.lua | 3 + tests/engine/gen2_content_registries.lua | 369 + tests/engine/gen2_new_seams.lua | 651 + tests/engine/launcher_mods_tests.lua | 211 +- tests/engine/mod_targets_tests.lua | 208 + tests/engine/save_slots.lua | 15 + tests/engine/uwp_baseroms_test.lua | 4 +- tests/gen2_audio_test.lua | 183 + tests/gen2_autoinput_test.lua | 187 + tests/gen2_badge_boosts_test.lua | 241 + tests/gen2_badges_test.lua | 86 + tests/gen2_bank_of_mom_test.lua | 272 + tests/gen2_battle_anims_test.lua | 543 + tests/gen2_battle_end_test.lua | 309 + tests/gen2_battle_items_test.lua | 505 + tests/gen2_battle_lockin_test.lua | 419 + tests/gen2_battle_loss_test.lua | 141 + tests/gen2_battle_pack_test.lua | 449 + tests/gen2_battle_test.lua | 2605 +++ tests/gen2_battle_ui_test.lua | 1281 ++ tests/gen2_berry_juice_test.lua | 94 + tests/gen2_bg_events_test.lua | 90 + tests/gen2_big_object_test.lua | 374 + tests/gen2_border_test.lua | 139 + tests/gen2_boxes_test.lua | 127 + tests/gen2_breeding_test.lua | 1017 + tests/gen2_callasm_test.lua | 423 + tests/gen2_canlose_test.lua | 214 + tests/gen2_catch_tutorial_test.lua | 323 + tests/gen2_charge_lock_test.lua | 147 + tests/gen2_clock_test.lua | 257 + tests/gen2_cmdqueue_test.lua | 179 + tests/gen2_common_text_test.lua | 434 + tests/gen2_contest_test.lua | 1229 ++ tests/gen2_currents_test.lua | 171 + tests/gen2_decorations_test.lua | 315 + tests/gen2_dig_warp_test.lua | 196 + tests/gen2_diploma_test.lua | 285 + tests/gen2_egg_hatch_anim_test.lua | 194 + tests/gen2_events_test.lua | 523 + tests/gen2_evolution_anim_test.lua | 95 + tests/gen2_evolution_test.lua | 543 + tests/gen2_exp_share_test.lua | 216 + tests/gen2_faint_once_test.lua | 101 + tests/gen2_field_anim_test.lua | 225 + tests/gen2_field_items_test.lua | 816 + tests/gen2_fishing_swarm_test.lua | 226 + tests/gen2_fly_map_test.lua | 160 + tests/gen2_font_ui_test.lua | 401 + tests/gen2_forceshiny_test.lua | 197 + tests/gen2_gamecorner_test.lua | 1241 ++ tests/gen2_halloffame_test.lua | 714 + tests/gen2_held_items_test.lua | 270 + tests/gen2_hidden_items_test.lua | 499 + tests/gen2_hof_continue_test.lua | 171 + tests/gen2_ice_pathfind_test.lua | 114 + tests/gen2_intro_test.lua | 321 + tests/gen2_magnet_train_test.lua | 411 + tests/gen2_mail_test.lua | 903 + tests/gen2_map_callbacks_test.lua | 488 + tests/gen2_map_radio_test.lua | 173 + tests/gen2_menus_test.lua | 2367 +++ tests/gen2_move_effects_test.lua | 631 + tests/gen2_nests_test.lua | 109 + tests/gen2_npc_interact_test.lua | 235 + tests/gen2_oak_speech_test.lua | 70 + tests/gen2_obedience_test.lua | 229 + tests/gen2_object_event_test.lua | 141 + tests/gen2_object_hours_test.lua | 180 + tests/gen2_palettes_test.lua | 397 + tests/gen2_party_menu_test.lua | 545 + tests/gen2_pc_screens_test.lua | 680 + tests/gen2_phone_call_test.lua | 382 + tests/gen2_phone_test.lua | 831 + tests/gen2_pokecenter_spawn_test.lua | 139 + tests/gen2_pokecenter_stairs_test.lua | 189 + tests/gen2_pokegear_unlock_test.lua | 454 + tests/gen2_pokerus_test.lua | 424 + tests/gen2_prize_counter_test.lua | 186 + tests/gen2_prize_test.lua | 463 + tests/gen2_repel_test.lua | 194 + tests/gen2_roamers_test.lua | 364 + tests/gen2_rock_smash_test.lua | 229 + tests/gen2_save_convert_cli_test.lua | 102 + tests/gen2_save_export_test.lua | 152 + tests/gen2_save_test.lua | 484 + tests/gen2_screen_layout_test.lua | 220 + tests/gen2_script_order_test.lua | 173 + tests/gen2_sound_alias_test.lua | 161 + tests/gen2_specialty_balls_test.lua | 189 + tests/gen2_sprites_test.lua | 230 + tests/gen2_steps_test.lua | 256 + tests/gen2_struggle_test.lua | 78 + tests/gen2_summary_test.lua | 656 + tests/gen2_swarm_test.lua | 197 + tests/gen2_temp_events_test.lua | 118 + tests/gen2_text_flow_test.lua | 433 + tests/gen2_time_routing_test.lua | 315 + tests/gen2_trade_gfx_test.lua | 434 + tests/gen2_trainerhouse_test.lua | 303 + tests/gen2_trainers_test.lua | 335 + tests/gen2_trap_escape_test.lua | 273 + tests/gen2_unown_printer_test.lua | 344 + tests/gen2_unown_test.lua | 534 + tests/gen2_variable_sprites_test.lua | 86 + tests/gen2_vm_test.lua | 2553 +++ tests/gen2_world_test.lua | 3343 +++ tests/gen2_x_items_test.lua | 201 + tests/gold_flag_names_test.lua | 109 + tests/gold_route_validate_test.lua | 224 + tests/mod_manifest_tests.lua | 18 + tests/mod_world_tests.lua | 64 + tests/modkit/cases/gen2_intro_seams.lua | 239 + tests/modkit/cases/gen2check.lua | 549 + tests/modkit/catalog.lua | 21 + tests/modkit/sdk.lua | 14 +- tests/rom_importer_last_version_test.lua | 6 +- tests/rom_lz3_test.lua | 84 + tests/run_tests.lua | 179 + tools/blockgen.py | 583 + tools/gen_registry_docs.lua | 121 +- tools/goldwalk/flags.lua | 81 + tools/goldwalk/gen_flags.lua | 59 + tools/goldwalk/mapgraph.lua | 672 + tools/make_gold_manifest.py | 1230 ++ tools/modkit.py | 1472 ++ tools/rom_data.py | 2 + tools/rom_manifest_gold.json | 17682 ++++++++++++++++ tools/save_convert/convert.lua | 45 +- 489 files changed, 226677 insertions(+), 1798 deletions(-) create mode 100644 GOLD-OPEN-ITEMS.md create mode 100644 KANTO-CONTINUE.md mode change 100755 => 100644 assets/logo/logo.png create mode 100644 docs/differences-between-gold-silver-and-crystal.md create mode 100644 docs/gen2-link-design.md create mode 100644 docs/gold-phase1.md create mode 100644 docs/gold-walkthrough/asm-walk/README.md create mode 100644 docs/gold-walkthrough/asm-walk/_TEMPLATE.md create mode 100644 docs/gold-walkthrough/asm-walk/section-00-chikorita-cyndaquil-or-totodile.md create mode 100644 docs/gold-walkthrough/asm-walk/section-01-cherrygrove-city-and-routes-29-31.md create mode 100644 docs/gold-walkthrough/asm-walk/section-02-sprout-tower-and-violet-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-03-ruins-of-alph-and-union-cave.md create mode 100644 docs/gold-walkthrough/asm-walk/section-04-slowpoke-well-azalea-town-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-05-ilex-forest-and-goldenrod-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-06-bug-catching-contest-and-sudowoodo.md create mode 100644 docs/gold-walkthrough/asm-walk/section-07-burned-tower-and-ecruteak-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-08-olivine-lighthouse-and-cianwood-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-09-olivine-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-10-the-lake-of-rage-and-shiny-gyarados.md create mode 100644 docs/gold-walkthrough/asm-walk/section-11-team-rocket-hideout-and-mahogany-town-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-12-team-rocket-radio-tower.md create mode 100644 docs/gold-walkthrough/asm-walk/section-13-ice-path-and-blackthorn-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-14-ho-oh-gold-and-lugia-silver.md create mode 100644 docs/gold-walkthrough/asm-walk/section-15-mount-mortar-and-dark-cave.md create mode 100644 docs/gold-walkthrough/asm-walk/section-16-routes-45-46-26-and-27.md create mode 100644 docs/gold-walkthrough/asm-walk/section-17-victory-road.md create mode 100644 docs/gold-walkthrough/asm-walk/section-18-pok-mon-league.md create mode 100644 docs/gold-walkthrough/asm-walk/section-19-s-s-aqua-and-vermilion-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-20-saffron-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-21-power-plant-and-cerulean-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-22-rock-tunnel-and-celadon-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-23-routes-11-19-and-fuchsia-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-24-snorlax-and-pewter-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-25-lugia-gold-and-ho-oh-silver.md create mode 100644 docs/gold-walkthrough/asm-walk/section-26-mount-moon-and-routes-1-4.md create mode 100644 docs/gold-walkthrough/asm-walk/section-27-cinnabar-island-and-seafoam-islands-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-28-routes-19-20-and-viridian-city-gym.md create mode 100644 docs/gold-walkthrough/asm-walk/section-29-routes-22-28.md create mode 100644 docs/gold-walkthrough/asm-walk/section-30-lapras.md create mode 100644 docs/gold-walkthrough/asm-walk/section-31-mount-silver-and-red.md create mode 100644 docs/gold-walkthrough/asm-walk/section-32-raikou-entei-and-suicune.md create mode 100644 docs/gold-walkthrough/section-00-chikorita-cyndaquil-or-totodile.txt create mode 100644 docs/gold-walkthrough/section-01-cherrygrove-city-and-routes-29-31.txt create mode 100644 docs/gold-walkthrough/section-02-sprout-tower-and-violet-city-gym.txt create mode 100644 docs/gold-walkthrough/section-03-ruins-of-alph-and-union-cave.txt create mode 100644 docs/gold-walkthrough/section-04-slowpoke-well-azalea-town-gym.txt create mode 100644 docs/gold-walkthrough/section-05-ilex-forest-and-goldenrod-city-gym.txt create mode 100644 docs/gold-walkthrough/section-06-bug-catching-contest-and-sudowoodo.txt create mode 100644 docs/gold-walkthrough/section-07-burned-tower-and-ecruteak-city-gym.txt create mode 100644 docs/gold-walkthrough/section-08-olivine-lighthouse-and-cianwood-city-gym.txt create mode 100644 docs/gold-walkthrough/section-09-olivine-city-gym.txt create mode 100644 docs/gold-walkthrough/section-10-the-lake-of-rage-and-shiny-gyarados.txt create mode 100644 docs/gold-walkthrough/section-11-team-rocket-hideout-and-mahogany-town-gym.txt create mode 100644 docs/gold-walkthrough/section-12-team-rocket-radio-tower.txt create mode 100644 docs/gold-walkthrough/section-13-ice-path-and-blackthorn-city-gym.txt create mode 100644 docs/gold-walkthrough/section-14-ho-oh-gold-and-lugia-silver.txt create mode 100644 docs/gold-walkthrough/section-15-mount-mortar-and-dark-cave.txt create mode 100644 docs/gold-walkthrough/section-16-routes-45-46-26-and-27.txt create mode 100644 docs/gold-walkthrough/section-17-victory-road.txt create mode 100644 docs/gold-walkthrough/section-18-pok-mon-league.txt create mode 100644 docs/gold-walkthrough/section-19-s-s-aqua-and-vermilion-city-gym.txt create mode 100644 docs/gold-walkthrough/section-20-saffron-city-gym.txt create mode 100644 docs/gold-walkthrough/section-21-power-plant-and-cerulean-city-gym.txt create mode 100644 docs/gold-walkthrough/section-22-rock-tunnel-and-celadon-city-gym.txt create mode 100644 docs/gold-walkthrough/section-23-routes-11-19-and-fuchsia-city-gym.txt create mode 100644 docs/gold-walkthrough/section-24-snorlax-and-pewter-city-gym.txt create mode 100644 docs/gold-walkthrough/section-25-lugia-gold-and-ho-oh-silver.txt create mode 100644 docs/gold-walkthrough/section-26-mount-moon-and-routes-1-4.txt create mode 100644 docs/gold-walkthrough/section-27-cinnabar-island-and-seafoam-islands-gym.txt create mode 100644 docs/gold-walkthrough/section-28-routes-19-20-and-viridian-city-gym.txt create mode 100644 docs/gold-walkthrough/section-29-routes-22-28.txt create mode 100644 docs/gold-walkthrough/section-30-lapras.txt create mode 100644 docs/gold-walkthrough/section-31-mount-silver-and-red.txt create mode 100644 docs/gold-walkthrough/section-32-raikou-entei-and-suicune.txt create mode 100644 docs/mod-api-gen2-compat.md create mode 100644 docs/modding/reference/registries.md create mode 100644 docs/preparing-your-mod-for-gen2.md delete mode 100644 docs/rfcs/0001-surfing-pikachu-sprite.md delete mode 100644 docs/rfcs/0002-screen-render-visible.md delete mode 100644 mods/nuzlocke/README.md delete mode 100644 mods/nuzlocke/main.lua delete mode 100644 mods/nuzlocke/manifest.json delete mode 100644 mods/spanish_ui/README.md delete mode 100644 mods/spanish_ui/TRANSLATING.md delete mode 100644 mods/spanish_ui/assets/font/README.md delete mode 100644 mods/spanish_ui/lang/charmap.lua delete mode 100644 mods/spanish_ui/lang/dialogue.lua delete mode 100644 mods/spanish_ui/lang/font.lua delete mode 100644 mods/spanish_ui/lang/item_names.lua delete mode 100644 mods/spanish_ui/lang/move_names.lua delete mode 100644 mods/spanish_ui/lang/naming.lua delete mode 100644 mods/spanish_ui/lang/species_names.lua delete mode 100644 mods/spanish_ui/lang/status_labels.lua delete mode 100644 mods/spanish_ui/lang/strings.lua delete mode 100644 mods/spanish_ui/lang/trainer_names.lua delete mode 100644 mods/spanish_ui/main.lua delete mode 100644 mods/spanish_ui/manifest.json create mode 100644 src/battle/gen2/Ai.lua create mode 100644 src/battle/gen2/AnimObjects.lua create mode 100644 src/battle/gen2/AnimRunner.lua create mode 100644 src/battle/gen2/Battle.lua create mode 100644 src/battle/gen2/BattleMusic.lua create mode 100644 src/battle/gen2/BerryJuice.lua create mode 100644 src/battle/gen2/BgEffects.lua create mode 100644 src/battle/gen2/Catching.lua create mode 100644 src/battle/gen2/Damage.lua create mode 100644 src/battle/gen2/Effects.lua create mode 100644 src/battle/gen2/Encounter.lua create mode 100644 src/battle/gen2/HpBar.lua create mode 100644 src/battle/gen2/Mon.lua create mode 100644 src/battle/gen2/Prize.lua create mode 100644 src/core/Game2.lua create mode 100644 src/core/gen2/Apricorns.lua create mode 100644 src/core/gen2/AutoInput.lua create mode 100644 src/core/gen2/Boxes.lua create mode 100644 src/core/gen2/Breeding.lua create mode 100644 src/core/gen2/BugContest.lua create mode 100644 src/core/gen2/CatchTutorial.lua create mode 100644 src/core/gen2/Clock.lua create mode 100644 src/core/gen2/CoinCase.lua create mode 100644 src/core/gen2/CommonText.lua create mode 100644 src/core/gen2/Decorations.lua create mode 100644 src/core/gen2/Evolution.lua create mode 100644 src/core/gen2/HallOfFame.lua create mode 100644 src/core/gen2/Happiness.lua create mode 100644 src/core/gen2/ItemEffects.lua create mode 100644 src/core/gen2/MagnetTrain.lua create mode 100644 src/core/gen2/Mail.lua create mode 100644 src/core/gen2/MomShopping.lua create mode 100644 src/core/gen2/Nests.lua create mode 100644 src/core/gen2/NpcTrade.lua create mode 100644 src/core/gen2/Phone.lua create mode 100644 src/core/gen2/PhoneRing.lua create mode 100644 src/core/gen2/Pokerus.lua create mode 100644 src/core/gen2/Roamers.lua create mode 100644 src/core/gen2/Save.lua create mode 100644 src/core/gen2/TradeAnim.lua create mode 100644 src/core/gen2/Unown.lua create mode 100644 src/import/RomExtractorGen2.lua create mode 100644 src/mods/Gen2Compat.lua create mode 100644 src/mods/ModTargets.lua create mode 100644 src/render/GbcPalette.lua create mode 100644 src/script/gen2/CallAsm.lua create mode 100644 src/script/gen2/Movement.lua create mode 100644 src/script/gen2/Opcodes.lua create mode 100644 src/script/gen2/Specials.lua create mode 100644 src/script/gen2/Vm.lua create mode 100644 src/ui/gen2/BankOfMom.lua create mode 100644 src/ui/gen2/BattleAnimView.lua create mode 100644 src/ui/gen2/BattleHud.lua create mode 100644 src/ui/gen2/BattleState.lua create mode 100644 src/ui/gen2/BattleTransition.lua create mode 100644 src/ui/gen2/BoxMenu.lua create mode 100644 src/ui/gen2/CallerBox.lua create mode 100644 src/ui/gen2/CardFlip.lua create mode 100644 src/ui/gen2/CenterPcMenu.lua create mode 100644 src/ui/gen2/Chrome.lua create mode 100644 src/ui/gen2/ContestMenu.lua create mode 100644 src/ui/gen2/CopyrightSplash.lua create mode 100644 src/ui/gen2/Credits.lua create mode 100644 src/ui/gen2/DayCareMenu.lua create mode 100644 src/ui/gen2/DecorationMenu.lua create mode 100644 src/ui/gen2/Diploma.lua create mode 100644 src/ui/gen2/EggHatchAnim.lua create mode 100644 src/ui/gen2/ElevatorMenu.lua create mode 100644 src/ui/gen2/EvolutionAnim.lua create mode 100644 src/ui/gen2/GameFreakPresents.lua create mode 100644 src/ui/gen2/GoldSilverIntro.lua create mode 100644 src/ui/gen2/HallOfFame.lua create mode 100644 src/ui/gen2/HeldItemMenu.lua create mode 100644 src/ui/gen2/InitClock.lua create mode 100644 src/ui/gen2/ItemPcMenu.lua create mode 100644 src/ui/gen2/MagnetTrainRide.lua create mode 100644 src/ui/gen2/MailCompose.lua create mode 100644 src/ui/gen2/MailMenu.lua create mode 100644 src/ui/gen2/MailRead.lua create mode 100644 src/ui/gen2/MailboxMenu.lua create mode 100644 src/ui/gen2/MainMenu.lua create mode 100644 src/ui/gen2/MapRadio.lua create mode 100644 src/ui/gen2/MartMenu.lua create mode 100644 src/ui/gen2/MoveDeleter.lua create mode 100644 src/ui/gen2/NamePick.lua create mode 100644 src/ui/gen2/NamingScreen.lua create mode 100644 src/ui/gen2/OakSpeech.lua create mode 100644 src/ui/gen2/OptionsMenu.lua create mode 100644 src/ui/gen2/PackGfx.lua create mode 100644 src/ui/gen2/PackMenu.lua create mode 100644 src/ui/gen2/PartyMenu.lua create mode 100644 src/ui/gen2/PcMenu.lua create mode 100644 src/ui/gen2/PhotoStudio.lua create mode 100644 src/ui/gen2/PokedexMenu.lua create mode 100644 src/ui/gen2/Pokegear.lua create mode 100644 src/ui/gen2/PrizeMenu.lua create mode 100644 src/ui/gen2/SaveMenu.lua create mode 100644 src/ui/gen2/ScriptMenu.lua create mode 100644 src/ui/gen2/SlotMachine.lua create mode 100644 src/ui/gen2/SpriteAnims.lua create mode 100644 src/ui/gen2/StartMenu.lua create mode 100644 src/ui/gen2/SummaryMenu.lua create mode 100644 src/ui/gen2/TileSheet.lua create mode 100644 src/ui/gen2/TitleState.lua create mode 100644 src/ui/gen2/TradeAnim.lua create mode 100644 src/ui/gen2/TradeMenu.lua create mode 100644 src/ui/gen2/TrainerCard.lua create mode 100644 src/ui/gen2/UnownPrinter.lua create mode 100644 src/ui/gen2/UnownPuzzle.lua create mode 100644 src/world/gen2/Bike.lua create mode 100644 src/world/gen2/BorderFill.lua create mode 100644 src/world/gen2/CmdQueue.lua create mode 100644 src/world/gen2/Events.lua create mode 100644 src/world/gen2/FieldMoves.lua create mode 100644 src/world/gen2/Follower.lua create mode 100644 src/world/gen2/HiddenItems.lua create mode 100644 src/world/gen2/Map.lua create mode 100644 src/world/gen2/Npc.lua create mode 100644 src/world/gen2/Palettes.lua create mode 100644 src/world/gen2/Permissions.lua create mode 100644 src/world/gen2/Player.lua create mode 100644 src/world/gen2/StepEvents.lua create mode 100644 src/world/gen2/TrainerHouse.lua create mode 100644 src/world/gen2/Trainers.lua create mode 100644 src/world/gen2/World.lua create mode 100644 src/world/gen2/WorldAPI.lua create mode 100644 tests/drivers/gold/adapter.lua create mode 100644 tests/drivers/gold/bot.lua create mode 100644 tests/drivers/gold/flag_names.lua create mode 100644 tests/drivers/gold/map_regions.lua create mode 100644 tests/drivers/gold/route.lua create mode 100644 tests/drivers/gold_battle_anim_shots.lua create mode 100644 tests/drivers/gold_battle_items.lua create mode 100644 tests/drivers/gold_battle_pack_probe.lua create mode 100644 tests/drivers/gold_battle_rules.lua create mode 100644 tests/drivers/gold_battle_screen_probe.lua create mode 100644 tests/drivers/gold_battle_smoke.lua create mode 100644 tests/drivers/gold_boot_smoke.lua create mode 100644 tests/drivers/gold_bot.lua create mode 100644 tests/drivers/gold_burned_tower_beasts.lua create mode 100644 tests/drivers/gold_catch_probe.lua create mode 100644 tests/drivers/gold_center_pc.lua create mode 100644 tests/drivers/gold_clock_anchor.lua create mode 100644 tests/drivers/gold_dig_warp.lua create mode 100644 tests/drivers/gold_egg_hatch.lua create mode 100644 tests/drivers/gold_egg_hatch_shots.lua create mode 100644 tests/drivers/gold_evolution_shots.lua create mode 100644 tests/drivers/gold_exp_bar.lua create mode 100644 tests/drivers/gold_extractor_pass.lua create mode 100644 tests/drivers/gold_field_anim_shots.lua create mode 100644 tests/drivers/gold_field_items.lua create mode 100644 tests/drivers/gold_forceshiny_probe.lua create mode 100644 tests/drivers/gold_frame_seams.lua create mode 100644 tests/drivers/gold_freeze_other_objects.lua create mode 100644 tests/drivers/gold_giveitem_box.lua create mode 100644 tests/drivers/gold_halloffame_shots.lua create mode 100644 tests/drivers/gold_heal_mart_shots.lua create mode 100644 tests/drivers/gold_hm07_probe.lua create mode 100644 tests/drivers/gold_hof_continue.lua create mode 100644 tests/drivers/gold_icepath_boulder.lua create mode 100644 tests/drivers/gold_intro_shots.lua create mode 100644 tests/drivers/gold_item_pickup_box.lua create mode 100644 tests/drivers/gold_lake_probe.lua create mode 100644 tests/drivers/gold_link_fingerprint.lua create mode 100644 tests/drivers/gold_mahogany_lance.lua create mode 100644 tests/drivers/gold_map_callbacks.lua create mode 100644 tests/drivers/gold_menu_shots.lua create mode 100644 tests/drivers/gold_mom_scene.lua create mode 100644 tests/drivers/gold_move_pack_egg.lua create mode 100644 tests/drivers/gold_naming_and_trade_item.lua create mode 100644 tests/drivers/gold_noeffect_anim.lua create mode 100644 tests/drivers/gold_opaque_surround.lua create mode 100644 tests/drivers/gold_overworld_npc.lua create mode 100644 tests/drivers/gold_palette_shots.lua create mode 100644 tests/drivers/gold_party_held_item.lua create mode 100644 tests/drivers/gold_party_submenu.lua create mode 100644 tests/drivers/gold_phone_call.lua create mode 100644 tests/drivers/gold_phone_caller_box.lua create mode 100644 tests/drivers/gold_phone_ring.lua create mode 100644 tests/drivers/gold_pokecenter_stairs.lua create mode 100644 tests/drivers/gold_pokegear_call_paper.lua create mode 100644 tests/drivers/gold_radio_persist.lua create mode 100644 tests/drivers/gold_roamers.lua create mode 100644 tests/drivers/gold_rock_probe.lua create mode 100644 tests/drivers/gold_rock_smash_probe.lua create mode 100644 tests/drivers/gold_rocket_cameras.lua create mode 100644 tests/drivers/gold_route29_tutorial.lua create mode 100644 tests/drivers/gold_shiny_shots.lua create mode 100644 tests/drivers/gold_snorlax_footprint_probe.lua create mode 100644 tests/drivers/gold_spin_clockwise.lua create mode 100644 tests/drivers/gold_starter_pic.lua create mode 100644 tests/drivers/gold_sudowoodo_twins.lua create mode 100644 tests/drivers/gold_summary_shots.lua create mode 100644 tests/drivers/gold_teacher_scene.lua create mode 100644 tests/drivers/gold_tilt_shot.lua create mode 100644 tests/drivers/gold_title_shots.lua create mode 100644 tests/drivers/gold_touch_controls.lua create mode 100644 tests/drivers/gold_trainer_smoke.lua create mode 100644 tests/drivers/gold_transition_shots.lua create mode 100644 tests/drivers/gold_travel_probe.lua create mode 100644 tests/drivers/gold_unown_scientist.lua create mode 100644 tests/drivers/gold_variablesprite_identity.lua create mode 100644 tests/drivers/gold_vf_caller_box2.lua create mode 100644 tests/drivers/gold_vf_touch_boot.lua create mode 100644 tests/drivers/gold_vf_touch_play.lua create mode 100644 tests/drivers/gold_walk_smoke.lua create mode 100644 tests/drivers/gold_wall_radio.lua create mode 100644 tests/drivers/gold_warp_scene.lua create mode 100644 tests/drivers/gold_water_moves.lua create mode 100644 tests/drivers/gold_waterfall_current_probe.lua create mode 100644 tests/drivers/gold_zoom_shots.lua create mode 100644 tests/drivers/verify_battle_screen.lua create mode 100644 tests/engine/gate_gen2_mod_api.lua create mode 100644 tests/engine/gate_gen2_mod_facade.lua create mode 100644 tests/engine/gen2_content_registries.lua create mode 100644 tests/engine/gen2_new_seams.lua create mode 100644 tests/engine/mod_targets_tests.lua create mode 100644 tests/gen2_audio_test.lua create mode 100644 tests/gen2_autoinput_test.lua create mode 100644 tests/gen2_badge_boosts_test.lua create mode 100644 tests/gen2_badges_test.lua create mode 100644 tests/gen2_bank_of_mom_test.lua create mode 100644 tests/gen2_battle_anims_test.lua create mode 100644 tests/gen2_battle_end_test.lua create mode 100644 tests/gen2_battle_items_test.lua create mode 100644 tests/gen2_battle_lockin_test.lua create mode 100644 tests/gen2_battle_loss_test.lua create mode 100644 tests/gen2_battle_pack_test.lua create mode 100644 tests/gen2_battle_test.lua create mode 100644 tests/gen2_battle_ui_test.lua create mode 100644 tests/gen2_berry_juice_test.lua create mode 100644 tests/gen2_bg_events_test.lua create mode 100644 tests/gen2_big_object_test.lua create mode 100644 tests/gen2_border_test.lua create mode 100644 tests/gen2_boxes_test.lua create mode 100644 tests/gen2_breeding_test.lua create mode 100644 tests/gen2_callasm_test.lua create mode 100644 tests/gen2_canlose_test.lua create mode 100644 tests/gen2_catch_tutorial_test.lua create mode 100644 tests/gen2_charge_lock_test.lua create mode 100644 tests/gen2_clock_test.lua create mode 100644 tests/gen2_cmdqueue_test.lua create mode 100644 tests/gen2_common_text_test.lua create mode 100644 tests/gen2_contest_test.lua create mode 100644 tests/gen2_currents_test.lua create mode 100644 tests/gen2_decorations_test.lua create mode 100644 tests/gen2_dig_warp_test.lua create mode 100644 tests/gen2_diploma_test.lua create mode 100644 tests/gen2_egg_hatch_anim_test.lua create mode 100644 tests/gen2_events_test.lua create mode 100644 tests/gen2_evolution_anim_test.lua create mode 100644 tests/gen2_evolution_test.lua create mode 100644 tests/gen2_exp_share_test.lua create mode 100644 tests/gen2_faint_once_test.lua create mode 100644 tests/gen2_field_anim_test.lua create mode 100644 tests/gen2_field_items_test.lua create mode 100644 tests/gen2_fishing_swarm_test.lua create mode 100644 tests/gen2_fly_map_test.lua create mode 100644 tests/gen2_font_ui_test.lua create mode 100644 tests/gen2_forceshiny_test.lua create mode 100644 tests/gen2_gamecorner_test.lua create mode 100644 tests/gen2_halloffame_test.lua create mode 100644 tests/gen2_held_items_test.lua create mode 100644 tests/gen2_hidden_items_test.lua create mode 100644 tests/gen2_hof_continue_test.lua create mode 100644 tests/gen2_ice_pathfind_test.lua create mode 100644 tests/gen2_intro_test.lua create mode 100644 tests/gen2_magnet_train_test.lua create mode 100644 tests/gen2_mail_test.lua create mode 100644 tests/gen2_map_callbacks_test.lua create mode 100644 tests/gen2_map_radio_test.lua create mode 100644 tests/gen2_menus_test.lua create mode 100644 tests/gen2_move_effects_test.lua create mode 100644 tests/gen2_nests_test.lua create mode 100644 tests/gen2_npc_interact_test.lua create mode 100644 tests/gen2_oak_speech_test.lua create mode 100644 tests/gen2_obedience_test.lua create mode 100644 tests/gen2_object_event_test.lua create mode 100644 tests/gen2_object_hours_test.lua create mode 100644 tests/gen2_palettes_test.lua create mode 100644 tests/gen2_party_menu_test.lua create mode 100644 tests/gen2_pc_screens_test.lua create mode 100644 tests/gen2_phone_call_test.lua create mode 100644 tests/gen2_phone_test.lua create mode 100644 tests/gen2_pokecenter_spawn_test.lua create mode 100644 tests/gen2_pokecenter_stairs_test.lua create mode 100644 tests/gen2_pokegear_unlock_test.lua create mode 100644 tests/gen2_pokerus_test.lua create mode 100644 tests/gen2_prize_counter_test.lua create mode 100644 tests/gen2_prize_test.lua create mode 100644 tests/gen2_repel_test.lua create mode 100644 tests/gen2_roamers_test.lua create mode 100644 tests/gen2_rock_smash_test.lua create mode 100644 tests/gen2_save_convert_cli_test.lua create mode 100644 tests/gen2_save_export_test.lua create mode 100644 tests/gen2_save_test.lua create mode 100644 tests/gen2_screen_layout_test.lua create mode 100644 tests/gen2_script_order_test.lua create mode 100644 tests/gen2_sound_alias_test.lua create mode 100644 tests/gen2_specialty_balls_test.lua create mode 100644 tests/gen2_sprites_test.lua create mode 100644 tests/gen2_steps_test.lua create mode 100644 tests/gen2_struggle_test.lua create mode 100644 tests/gen2_summary_test.lua create mode 100644 tests/gen2_swarm_test.lua create mode 100644 tests/gen2_temp_events_test.lua create mode 100644 tests/gen2_text_flow_test.lua create mode 100644 tests/gen2_time_routing_test.lua create mode 100644 tests/gen2_trade_gfx_test.lua create mode 100644 tests/gen2_trainerhouse_test.lua create mode 100644 tests/gen2_trainers_test.lua create mode 100644 tests/gen2_trap_escape_test.lua create mode 100644 tests/gen2_unown_printer_test.lua create mode 100644 tests/gen2_unown_test.lua create mode 100644 tests/gen2_variable_sprites_test.lua create mode 100644 tests/gen2_vm_test.lua create mode 100644 tests/gen2_world_test.lua create mode 100644 tests/gen2_x_items_test.lua create mode 100644 tests/gold_flag_names_test.lua create mode 100644 tests/gold_route_validate_test.lua create mode 100644 tests/modkit/cases/gen2_intro_seams.lua create mode 100644 tests/modkit/cases/gen2check.lua create mode 100644 tests/rom_lz3_test.lua create mode 100644 tools/blockgen.py create mode 100644 tools/goldwalk/flags.lua create mode 100644 tools/goldwalk/gen_flags.lua create mode 100644 tools/goldwalk/mapgraph.lua create mode 100644 tools/make_gold_manifest.py create mode 100644 tools/rom_manifest_gold.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2654f5ca..1ffe28fa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -22,12 +22,14 @@ body: id: game attributes: label: Which game were you playing - description: Pick every version you saw the bug in. + description: Pick every version you saw the bug in. Use N/A if it isn't game-specific. multiple: true options: - Red - Blue - Yellow + - Gold + - N/A validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 16cb533d..230d6713 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -30,13 +30,14 @@ body: id: game attributes: label: Which game is this about - description: Pick every version it applies to. + description: Pick every version it applies to. Use N/A if it isn't game-specific. multiple: true options: - Red - Blue - Yellow - - Not version-specific + - Gold + - N/A validations: required: true diff --git a/.github/ISSUE_TEMPLATE/mod_request.yml b/.github/ISSUE_TEMPLATE/mod_request.yml index ed812e01..abf7fa7b 100644 --- a/.github/ISSUE_TEMPLATE/mod_request.yml +++ b/.github/ISSUE_TEMPLATE/mod_request.yml @@ -27,13 +27,14 @@ body: id: game attributes: label: Which game is this for - description: Pick every version the mod should cover. + description: Pick every version the mod should cover. Use N/A if it isn't game-specific. multiple: true options: - Red - Blue - Yellow - - Not version-specific + - Gold + - N/A validations: required: true diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 5f326262..ccd55ea1 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -138,7 +138,111 @@ qualifies only if every record it writes lands in `text`, `strings` or permission. Anything else and it is an ordinary content mod that happens to ship text. -### 4. `mod.card` +### 4. `games` (and the legacy `gen2compat`) + +Pokemon Gold is Gen 2, and it runs its 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 Gold cannot serve all of it +yet, so Gen 2 is opt-in. Say which games the mod is for: + +```json +"games": ["gen1", "gen2"] +``` + +Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), 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. + +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 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. + +Every token is enforced, per game: the loader gates on the same +`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]` +really does not load on Red and the skip line is the launcher's line, `For +Blue, not Red`, and `"games": ["gold"]` alone does not load on Red either. A +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 +today (40 of the 46 registries, 40 event and 43 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. + +Two consequences worth knowing before you claim Gen 2. + +**Dependencies are contagious.** A mod whose hard dependency does not run here +is left out too, with the dependency's own wording (`depends on X, which does +not run here (For Blue, not Red)`). It is reported as a skip, not as a failure, +and neither mod lands on the boot error list, but the mod does not run, so +every hard dependency has to cover the same games. + +**The player can override you.** The claim is yours, and a mod written before +the key existed can never carry one, so the manager's detail pane offers +`TRY HERE ANYWAY` for any mod that does not claim the game being played. It +persists per game in `options.modsGen2[id][version]` and takes effect on the +next boot; forcing a mod onto Red does not force it onto Gold. A forced mod +loads normally and keeps a note saying its author never verified it here. + +**Prefer the API on Gold, but the Gen 1 names still work.** Gen 2 is a +parallel module tree behind `src/core/Game2.lua`. In new code take the live +game from `mod.game` (or the `game.ready` payload, or any `ui.*` hook's first +argument) and the world from `mod.world`; both resolve per generation, and +neither needs `engine_internals`. + +For the mods written before Gold existed, a require made from a mod's own file +is answered on a Gold boot by an adapter presenting the Gen 1 API over Gen 2 +internals. Fifteen names are served -- `src.core.Game`, +`src.world.OverworldController`, `src.world.Map`, `src.world.NPC`, +`src.world.Collision`, `src.world.WorldAPI`, `src.world.PikachuFollower`, +`src.world.FieldDefaults`, `src.pokemon.Boxes`, `src.script.ScriptRunner`, +`src.ui.PartyMenu`, `src.ui.StartMenu`, `src.ui.OptionsMenu`, `src.ui.BoxMenu` +and `src.battle.BattleState`. `src/mods/Gen2Compat.lua` is the full table and +publishes what it covers through `Gen2Compat.coverage(name)`, whose members are +`backed`, `warned` or `absent`. A name with no adapter (`src.script.Commands`, +`src.ui.OptionRows`) is reported against the mod that required it, and a member +an adapter cannot back is absent or logs once rather than answering wrongly. + +Things no adapter can fix, all mod-side: a hardcoded version allow-list +(`GameVersion.get() == "red" or ...`) excludes you from Gold by construction; +Gold's builtin screen ids carry a `Gen2` prefix, so a string match on +`"BoxMenu"` matches nothing there; a write to a field on a live Gen 2 menu +instance is inert; and `map.warpAt` is a table on Gen 1 and a method on Gold, +so indexing it raises. Each has a route that works on both generations, in +`docs/preparing-your-mod-for-gen2.md`. + +Check it statically, then load it headless: + +```sh +python3 tools/modkit.py gen2check mods/my_mod +``` + +```lua +local run = T.sdk.loadMod("mods/my_mod", { generation = 2 }) +T.eq(run.mod and run.mod.state, "loaded", + "runs on gen 2: " .. tostring(run.mod and run.mod.skipReason)) +T.eq(#run.errors, 0, "and loads with no boot errors") +``` + +Assert the state, not only the error count: a gate skip is deliberately not an +error, so `#run.errors == 0` passes for a mod that never ran a line. + +`gen2check` answers `will load`, `will load but degrade` or `will not work`, +with a `MK4xx` finding per site and an `unresolved:` note, with a file and a +line, for every reach a static scan could not follow. Neither substitutes for a +real Gold boot. + +### 5. `mod.card` The manifest is the *engine's* contract: identity, load order, dependencies, permissions, profile. The card is the *human-facing* one: who made this, @@ -159,7 +263,7 @@ Two fields deserve their own note: distributed mod never carries ROM-derived bytes, not even in its preview images. -### 5. Tags +### 6. Tags Lowercase kebab strings, open vocabulary. The showcase generator lowercases and de-dupes. A recommended starting set: `beginner`, @@ -217,12 +321,15 @@ registry or a new schema field lands with its catalog entry in the same PR and the generator runs clean: ```sh -luajit tools/gen_registry_docs.lua # in-repo default -luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout +luajit tools/gen_registry_docs.lua # docs/modding/reference/registries.md +luajit tools/gen_registry_docs.lua ../project.wiki # Reference-Registries.md in a wiki checkout ``` -The prose reference lives in the GitHub wiki; the generated pages are -written into a checkout of it, so they cannot drift from the engine. +With no argument it writes inside the repo, which is the copy `python3 +tools/modkit.py docs` regenerates and `--out` copies from. Pass a directory +(or set `POKEPORT_DOCS_DIR`) to write the wiki's flat page name into a wiki +checkout instead. The prose reference lives in the GitHub wiki; both copies +come off `src/mods/Schemas.lua`, so neither can drift from the engine. ### 5. Deprecation etiquette diff --git a/GOLD-OPEN-ITEMS.md b/GOLD-OPEN-ITEMS.md new file mode 100644 index 00000000..b0970466 --- /dev/null +++ b/GOLD-OPEN-ITEMS.md @@ -0,0 +1,275 @@ +# Gold port: what is still open after the 2026-08-09 asm parity runs + +Two multi-agent runs diffed the Gold port against the `pokegold` disassembly +(`../pokegold`) and fixed what diverged. Round 1 confirmed 51 defects across 14 +paired clusters; round 2 confirmed 41 more across 13, including one crash. A +third round (2026-08-10) closed the mod-API boundary: see "The 2026-08-10 +mod-API round" below for what that opened and closed. + +Every tier is green at the end of all three: `run_engine` 131/131, `run_tests` +ALL TESTS PASSED, `run_modkit` 6/6, `run_save_editor_tests` 650/0, +`run_link_tests` ALL PASSED. + +This file records only what is NOT done, so nobody re-derives it. It is a +working handoff doc in the shape of `KANTO-CONTINUE.md`, not user documentation. + +## Do this first or none of it is visible + +**Re-import the Gold cache.** Seven fixes live in the extractor and the manifest +tool, so they do nothing until the ROM is re-imported. This was verified still +outstanding on 2026-08-10: the default identity's +`gold/data/generated/menu_gfx.lua` has no `trainerPics` key at all, so a trainer +battle still opens on the mon rather than the trainer even though the Lua half +is fixed and correct. + +- trainer frontpics (`TrainerPicPointers` walk in `extractMenuGfx`), which is + the missing trainer intro image at the start of a trainer battle. The Lua + side is now right and re-import is the ONLY thing left: `Trainers.lookup` + returns a numeric `class` (36) beside a `classId` (`BUG_CATCHER`), and both + `menu_gfx.trainerPics` and `palettes.trainers` are keyed by the constant, so + the lookup had to move to `classId`. On a cache that carries the pics the + frontpic renders in the class's own palette. +- egg assets: `battle/front/egg.png` and `menu/egg_hatch.png`, feeding the + hatch cutscene and the summary screen's egg page. A fallback landed in the + meantime (`ICON_EGG` frame 0, which resolves to a real 16x16 frame), so the + egg page draws SOMETHING on a stale cache; `tools/rom_manifest_gold.json` + carries `EggPic` at `[20, 31363]` and that stream decompresses to exactly 400 + bytes (a 5x5-tile pic), so a fresh import takes the real path. +- the Pokegear phone icon. `_LoadFontsExtra` (`engine/gfx/load_font.asm:8-15`) + puts `FontsExtra_SolidBlackAndUpArrowGFX` at `$60`/`$61` and + `PokegearPhoneIconGFX` at `$62`, ON TOP of `FontExtra`'s BOLD_A/B/C. + `src/import/RomExtractorGen2.lua:342-356` reads `FontExtra` straight in and + then blits only `Frames` over `$79-$7E`, so `$62` is still a bold "C" -- which + is what the new caller box draws where the telephone glyph belongs. Fix is two + files: add the two GFX symbols to `tools/rom_manifest_gold.json`, then blit + them at `$60`/`$61`/`$62` in the loop that already handles `Frames`. +- `PREDEFPAL_UNOWN_PUZZLE = 76`, which is why the Ruins of Alph puzzle rendered + grayscale instead of brown +- the tilemap pad `0x7f -> 0x4f` in `readTilemapRLE` / `readFlatTilemap` +- held-item icons (`HeldItemIcons` in the manifest, `out.heldItem` in + `extractIcons`), which is the held-item marker in the party menu +- the NPC-trade rows' `item` field, which is what made the Violet City Onix's + Bitter Berry print as "83" + +## Open: real unknowns, each needs a cache and a driver run + +1. **Magnet train renders a blank field.** Pushing `World:magnetTrain` directly + shows nothing for 90+ frames (see shot `03-magnet-train.png`). The screen's + surround is correct now; what was never investigated is whether + `drawBackground` needs setup that the direct push skips. This cannot be + settled by reading source. + +2. **The NPC that introduces Unown never appears.** Round 1 could not produce a + port ref that diverges from the asm, so it is filed `cannot_locate`, not + fixed. The cart chain is: solve a chamber puzzle, then + `setmapscene RUINS_OF_ALPH_INNER_CHAMBER, SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE` + from `maps/RuinsOfAlphKabutoChamber.asm`. Walk that chain live rather than + statically: the static read came back clean twice. + +3. **Two pic sites still resolve an Unown off the species.** + `src/ui/gen2/PhotoStudio.lua:102` and `src/ui/gen2/EvolutionAnim.lua:460` use + `def.spriteFront` without going through `Unown.formSprite`. For EvolutionAnim + this is almost certainly dead (Unown does not evolve). For PhotoStudio it is + genuinely unclear: the cart's `engine/events/print_photo.asm` does not call + `GetUnownLetter` at all, so the port may already match. Check before changing. + +4. **Unown dex registration on obtain paths.** `engine/pokemon/evolve.asm:310` + runs `GetUnownLetter` then `callfar UpdateUnownDex` after + `SetSeenAndCaughtMon`, guarded by `cp UNOWN`. That is dex bookkeeping, not + rendering, and it was never audited across every path that gives the player a + mon. `GetUnownLetter` has 20 call sites on the cart; the port covers the pic + ones. + +## The 2026-08-10 mod-API round + +Six lanes closed the Gen 1 / Gen 2 mod-API boundary and a batch of reported +gameplay bugs. What that round **closed**, so nobody re-opens it: + +- Fifteen seams that had a Gen 2 site but no entry in the parity gate are now + listed and asserted: the four `intro.oak_speech.*` events plus the + `intro.oak_speech.build` hook, `battle.overlay`, + `battle.low_health_alarm`, `battle.catch_exp`, `pokemon.sprite`, + `input.step`, `input.pointer`, `render.zones`, `render.compose`, + `render.letterbox`, `render.hud`. Five more (`intro.boot.*`) are listed as + Gen 2-only. `tests/engine/gate_gen2_mod_api.lua` is 893/893. +- Two registries un-gated on Gold after their consumer landed: + `battle_sprite_scales` (`src/ui/gen2/BattleState.lua:imageScale`) and + `render_pipelines` (`src/core/Game2.lua:load` installs + `src/render/Pipelines.lua` on the merged dataset after `mods:load`). Both + keep the SHARED Gen 1 target, so one mod record serves both games. 40 of the + 46 registries are now available on Gold. +- `hook:render.zones` came off the `gate_meta_coverage.lua` DEBT ledger, which + is now four entries, all M7/M12 link and give-mon seams. + +What that round **left open**, each verified on 2026-08-10: + +1. **`render_pipelines`' `drawWorld` half is inert on Gold.** Gold's overworld + draws straight to the window rather than into a canvas the way + `src/world/OverworldController.lua` hands one to `Pipelines.drawWorld`, so a + drawWorld-only pipeline renders nothing. It is not left switched on + pretending: `Game2:load` retires a restored level for one and re-applies + Tilt from the option the exclusion just cleared, leaving + `options.pipelines` untouched so the mode returns the day Gold grows a world + canvas. Related: `Pipelines.rows` is read only from + `src/ui/OptionsMenu.lua`, so a pipeline on Gold has a hotkey and no OPTION + row. + +2. **`transitions` stays gated and should.** `src/ui/gen2/BattleTransition.lua` + keys `STYLES` as a boolean SET of the four cart wipes (`spin`, `speckle`, + `zoom`, `sine`), not the `{ frames, draw, sound, flash }` record the + registry carries, and there is no styleDef lookup a mod id could reach -- + a registered style would fail the membership test and fall back to vanilla. + Un-gating it before that changes would be the silent no-op the routing table + exists to prevent. Same for `rulesets`, `field`, `text_pointers`, + `link_fields` and `map_scripts`; `docs/mod-api-gen2-compat.md` carries the + per-registry reason. + +3. **`src/core/Game2.lua:joystickremoved` is half of Gen 1's.** + `src/core/Game.lua:867` does `self:recoverInput("joystickremoved", joystick)` + AND `TouchControls:joystickremoved()`; Game2 does only the pad half, so a + controller unplugged mid-hold leaves Gold's Input state stranded and a held + direction walks forever. It was a total noop before, so this is not a + regression -- it is a seam finished halfway. One line. + +4. **`Game2` noops five joystick callbacks.** `joystickpressed`, + `joystickreleased`, `joystickaxis`, `joystickhat` and `joystickadded` are + assigned `noop`, so a stick with no SDL game-controller-database entry + reaches neither Input nor `GamepadMap.RAW_BUTTON_BINDINGS` on Gold. Gen 1 + handles all four (`src/core/Game.lua:764-797`). A DualSense is + SDL-recognized and takes the gamepad path, so this is not the reported + SELECT bug -- it is the raw-stick fallback. + +5. **`Game2:gamepadpressed` has no SELECT-held guard.** Gen 1 + (`src/core/Game.lua:686-694`) suppresses the shoulder GAME SPEED cycle while + SELECT is held, because Select+L is a display chord on NX. Harmless today + (Gold has no display chord) but the two paths have diverged. + +6. **`hideCallerBox`'s fallback path skips the screen seams.** + `src/script/gen2/CallAsm.lua:217-231` pops through `stack:pop` when the + caller box is top (which it always is on a normal call, since `PhoneRing` + runs `closetext` before `InitCallReceiveDelay`) but falls back to a bare + `table.remove(states, index)` otherwise, which raises neither `exit` nor + `screen.popped`. Only a mod screen pushed over the box can reach it, and + then a listener that saw `screen.pushed` never sees the pop. + +7. **`src/core/gen2/Roamers.lua:338` says the wrong thing.** Its comment says + "the shared `encounter.species` hook still runs downstream and is where a + mod changes what appears". It does not: `World:tryWildEncounter` calls + `startBattle` on a roamer hit and RETURNS before it reaches + `World:rollEncounter`. The doc's partial-coverage list is right and the + comment is wrong; correct the comment, not the doc. + +8. **`src/world/gen2/World.lua:4318` still calls the pack's SEL row something + "this port has not built".** The pack submenu now offers SEL. Stale comment. + +Three partial coverages that are documented and still true, repeated here +because "the hook exists" is not "the hook sees everything": +`encounter.roll` / `encounter.species` are not wired into `World:tryHeadbutt`, +`World:rockMonEncounter` or the roamer path; `src/ui/gen2/BattleState.lua` +builds a flat `opts` for `Catching.attempt` with no `data` in it, so a +mod-registered ball is readable through `Catching.recordFor` but is not +resolved at the real throw site; and no Gen 2 UI file reads +`Battle.statusRecordFor(data, status).hudLabel`, so a mod status shows no label +in the battle HUD, the party menu or the summary page. + +## Open: cleanups declined on purpose + +These were skipped with reasons during the wiring pass. They are listed so the +reasons survive, not because they are pending work. + +1. **`Vm:showRaw(body, stay)`** was proposed and declined. `showRawHeld` in + `Specials.lua` already answers the text lookahead correctly, so this trades + one working shape for another. + +2. **`tests/drivers/gold_egg_hatch.lua` summary push** was declined as + redundant: `gold_egg_hatch_shots.lua` already photographs both the cutscene + and the summary page. + +3. **Two `Battle.lua` divergences** were filed as notes by the file's own owner + rather than fixed, because no reported symptom drives either: text printed + for an already-statused target, and Future Sight taking STAB, type + effectiveness and weather. Both are real divergences from the cart. Fix them + when something actually depends on them. + +## Reported, investigated, and NOT defects: do not re-chase these + +Each of these came in as a bug report and came back disproved against the asm. +Re-opening one costs another full investigation, so the reasoning is kept here. + +- **SFX pointer table misalignment.** The table is aligned end to end. 188 `dba` + rows with no skips or padding, 188 constants, verified through the manifest + scraper, the extractor's `*3` stride, and 8 ids spot-checked against the live + cache. Two things that look like misalignment are how the cart is built: + `SFX_GET_EGG_UNUSED` and `SFX_GET_EGG` both point at `Sfx_GetEgg`, and + `Sfx_ReadText` / `Sfx_ReadText2` share one header address. **Do not repoint, + re-stride, or add an offset to the SFX, music, or cry tables.** + +- **Bind letting you pick other moves.** This is Gen 1 behavior and does not + hold for Gen 2. `BattleCommand_TrapTarget` (`effect_commands.asm:5568-5605`) + writes only the TARGET's wrap count and trapping move. It touches nothing on + the user, so the user is free to switch moves. The port is correct. The real + Gen 2 move locks (Rollout, Thrash, Petal Dance) were genuinely missing and + have been added. + +- **The Slowpoke Tail salesman not blocking progression.** He is Route 32's + `FISHER4` at (7,70), not an Azalea Town object, and the cart's refusal arm + only prints. `_OfferToSellSlowpokeTail` runs `setscene SCENE_ROUTE32_NOOP` + first, so the coord event never fires again. There is no pushback to port. + +- **The rival being named BLUE.** Not reproducible. The whole chain is intact: + special 36 resolves to `H.NameRival`, `NamingScreen:accept` hands the typed + string through, and BLUE is the Gen 1 default that Gold never reaches. A + separate real defect on this seam (the rival being pre-named SILVER before the + officer asks, and the blank-entry fallback) was found and fixed. + +- **Not Very Effective dealing 0 damage**, **status not shown in the battle + UI**, and **the Pokedex missing from the Start Menu** all came back + `already_fixed`: the damage floor is `MIN_DAMAGE` added after the cap and + before the type multiply, the HUD prints the status tag where the level goes, + and the menu row is gated on `save.engineFlags[11]` correctly. + +## Standing risks, not breakage + +- **Four Gold test files are wired into no runner**, so a regression in them + will not turn `run_tests.lua` red: `gold_flag_names_test`, + `gold_route_validate_test`, `gen2_pokegear_unlock_test`, + `gen2_save_export_test`. All four pass standalone. The first two are the + pre-run route validators documented in `KANTO-CONTINUE.md`; the last two need + a `GOLD_CACHE` and are excluded by an in-file comment. This is deliberate, but + it is a coverage hole and worth knowing. + +- **`mods/` must stay clean.** The 2026-08-10 round left temporary probe mods + behind mid-run (`mods/tmp_menus`, `mods/zz_verify_seams`); both are gone now + and `mods/` holds only `example_mew_starter`, `example_silly_oak`, `examples` + and `nuzlocke`. A stray `mods/tmp_*` loads on every Gold boot and changes what + a driver measures. `mods/example_silly_oak` carries only a `.modkitignore` and + warns "manifest.json does not exist" on every boot; that is pre-existing + (27 Jul) and unrelated. + +- **`tests/drivers/gold_opaque_surround.lua` is stale.** It reads + `game.stack._items`; `src/core/StateStack.lua` has only `.states`. That branch + is covered instead by `gold_center_pc` and the letterbox-per-frame count in + `gold_frame_seams`. + +- **`luac` on this machine is Lua 5.5**, so `luac -p` is a weak proxy for the + LuaJIT/5.1 semantics this engine targets. Use `luajit -b /dev/null` for + a real syntax gate. + +- **`docs/rfcs/0001-surfing-pikachu-sprite.md` and `0002-screen-render-visible.md` + are deleted** in the working tree, and both are still referenced by name from + live modkit case files that pass. Those deletions predate both parity runs. + Worth resolving before a commit. + +## The failure mode that actually bit, worth remembering + +Round 1 reported the Yes/No dialogue fix as landed. It was not. Two of its three +lanes wrote their half, and the third left the hook closure at +`World.lua:828` declared as `function(body, onDone)`, so the third argument +`Vm:resume` passes was silently discarded and the entire `stay` implementation +in `World:showText` and `World:askYesNo` was dead code. Lua drops extra +arguments without complaint, so nothing failed and every suite stayed green. + +Round 2 caught it only because an investigator probed the boundary empirically +instead of reading both sides and assuming they met. When work is split across +agents by file, **the seams between the files are where the bugs live**, and a +green suite does not prove a seam is connected. diff --git a/KANTO-CONTINUE.md b/KANTO-CONTINUE.md new file mode 100644 index 00000000..3c37ce44 --- /dev/null +++ b/KANTO-CONTINUE.md @@ -0,0 +1,457 @@ +# KANTO-CONTINUE: executing the never-run half of Gold (sections 19-32) + +Deferred from the 2026-08-09 fix campaign by request. This doc is self-contained: the +audit documents it was distilled from are being retired, so everything a future session +needs is here. + +## What this is + +The Johto main line (walkthrough sections 00-18) is proven: the route bot beats Champion +Lance with every row passing. Kanto (sections 19-32) is extracted and wired but has +NEVER been executed by any bot row, driver, or human. Every entry below reads clean in +static review (cache + code + cart source were cross-checked adversarially); the risk is +purely runtime. The job: make each beat actually run, fix what breaks, and leave +executable evidence (bot rows or assertion drivers) behind. + +## State after the 2026-08-09 campaign (what changed under you) + +- ~80 defects fixed across the port; 91/91 gen2 suites, engine 128/128, T3 all green. +- The post-game handoff WORKS now: post-E4 credits end at the title screen, and + CONTINUE consumes the post-game spawn (HallOfFame.consumePostGameSpawn): after Lance + you continue in NEW_BARK_TOWN, after Red at SILVER_CAVE_OUTSIDE (23,20). Section 19 + is reachable by normal play for the first time. +- The Snorlax wake chain is fully functional (engine-flag bridge unlocks the Pokegear + and EXPN card; the tuned radio song persists after closing the gear; SnorlaxAwake + fires; proven live by tests/drivers/gold_radio_persist.lua). +- Landed systems Kanto beats depend on: phone random/outgoing calls, field item use + (Escape Rope/Dig via save.backupWarp), Repel, swarms, Rock Smash encounters, + waterfall current tiles, roamer scatter on CONTINUE, trap volatiles + FORCESHINY + battle types (beast catching in section 32 is now mechanically possible), badge + boosts, held items, specialty balls (Lure Ball needs battleType 'fish', already + stamped by updateFishing). +- NPC HOUR WINDOWS are now enforced (they were not when the Johto route was authored). + Any Kanto row that talks to a time-gated NPC must pin the clock (POKEPORT_GOLD_HOUR / + POKEPORT_GOLD_DAY) or handle absence. Only 11 hour-gated objects exist on the whole + cart: CELADON_GAME_CORNER 6/7, GOLDENROD_GAME_CORNER 4/5, MOUNT_MOON_GIFT_SHOP 1-4, + and the three Moms in PLAYERS_HOUSE_1F; Celadon and Mt Moon are the Kanto exposures. +- The game clock now anchors on every new game (cart InitClock semantics), so time of + day no longer follows the host wall clock in driver runs; POKEPORT_GOLD_HOUR pins + the anchored base too. Runs are reproducible across day/night; a pre-existing flake + class (night runs catching HOOTHOOT where day runs catch PIDGEY, shifting grind + levels) died with it. +- Battles hit harder both ways since the cart MIN_DAMAGE floor landed; grind budgets + tuned for the old numbers may need a nudge. + +## Known bot weaknesses to expect (verified pre-existing, not campaign regressions) + +- Pathfinding pocket: travel into ILEX_FOREST / SLOWPOKE_WELL bounces (probe + CHERRYGROVE_CITY>ILEX_FOREST reports no route), and route row 16.54 gives up the + walk to VICTORY_ROAD_GATE with the identical TELEPORT signature in every logged run. + Kanto routing should waypoint doors explicitly per the ops recipes above. +- Optional buy rows 04.5d and 18.g4/18.g5 fail whenever the wallet is empty at that + point in the run; they are budgeting gaps in the route, not engine bugs. +- Grind row 08.g (Route 41 water) has almost no margin; with the clock now anchored + its inputs are stable, but a low-level POLIWAG catch can still stretch it. + +## How to run things + +Identity/cache: gold-dev (~/Library/Application Support/LOVE/gold-dev/gold), rebuilt +2026-08-09 with swarm/rock/roam tables. Checkpoints gold-ckpt-NN.lua exist per section; +gold-ckpt-18-pristine.lua is the post-E4 seed. ROM: ../decprep/Pokemon - Gold +Version.gbc. Cart source: ../pokegold (cite it in any hand-ported code). + +Headless love needs a pty on macOS; wrap every run: + perl -e 'alarm 900; exec @ARGV' python3 -c "import pty; pty.spawn(['love','.'])" + +Full bot run: POKEPORT_IDENTITY= POKEPORT_GAME=gold POKEPORT_SPEED=200 \ + POKEPORT_GOLD_CKPT=1 POKEPORT_GOLD_STALL=20000 POKEPORT_GOLD_LOG=/tmp/b.log \ + POKEPORT_DRIVER=tests/drivers/gold_bot.lua love . +Resume a section: POKEPORT_GOLD_RESUME=NN (same identity, prior CKPT=1 run). +Travel probe: POKEPORT_GOLD_PROBE="MAP_A>MAP_B" with tests/drivers/gold_travel_probe.lua. +Map graph: luajit tools/goldwalk/mapgraph.lua path|map|reach|audit; regenerate +tests/drivers/gold/map_regions.lua with `mapgraph.lua graph >` after ANY extractor map +change, then re-check route region indices. +Route validation (before any run): luajit tests/gold_route_validate_test.lua and +luajit tests/gold_flag_names_test.lua. +Suites: GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" luajit +tests/gen2__test.lua; tiers run_engine/run_modkit/run_tests. +Reimport after extractor/manifest changes: python3 tools/make_gold_manifest.py, then +POKEPORT_IMPORT_TRACE=1 POKEPORT_IDENTITY=gold-dev POKEPORT_IMPORT_ROM="../decprep/\ +Pokemon - Gold Version.gbc" POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 \ +POKEPORT_GAME=gold love . + +## Recommended shape of the work + +1. Extend tests/drivers/gold/route.lua with sections 19-32 (the row format, ops and + evidence rules are documented in the file head and bot.lua). Seed runs from + gold-ckpt-18-pristine plus the now-working post-credits CONTINUE. `expect` flags are + the only oracles; never make a row load-bearing on a soft-failing map. +2. Highest-risk multi-map scripted chains first: S.S. Aqua voyage, Machine Part chain, + PASS/Magnet Train chain. Each deserves an assertion driver even if bot rows also + cover it. +3. Time-gated beats (S.S. Aqua sailing days, Indigo rival Mon/Wed, Dragon's Den + Tue/Thu, Clefairy Monday night, TM03 night gate) need POKEPORT_GOLD_DAY/HOUR pins. +4. Johto stragglers are folded in below (lighthouse descent, TM08 flake, and the + optional gift beats); they ride the same route-extension pass. +5. Section 32 (beasts) last: it needs long roaming play; trap volatiles and FORCESHINY + landed this campaign, so Mean Look + Heavy/Fast Ball plans are now viable. + +## Also deferred (deliberate stubs, do not re-flag) + +Time Capsule trading, Trainer House CAL2/Mystery Gift visitor, Silver-version import, +Cable Club link rooms, and Game Boy Printer output are deliberate product-level stubs, +not campaign work. + +## The beats, in walkthrough order + +### Runtime coverage ends at the Hall of Fame: no bot row, driver, or human has run any Kanto beat (sections 19-32) or the section 14 Tin Tower/Whirl Islands content + +walkthrough section(s) 19,20,21,22,23,24,27,28,29,30,31,14 | missing + +One root cause: tests/drivers/gold/route.lua (1264 lines) ends at row 18.20 (settle HALL_OF_FAME, expect EVENT_BEAT_ELITE_FOUR); the campaign covers asm-walk sections 00-18 only and none of the 24/36 gold_* drivers reaches any Kanto map, so sections 19-32 have zero execution proof. Extend route.lua into sections 19-32 and/or add targeted drivers. Per section pair: (s19/20) next rows are the Elm ticket (needs the post-credits spawn fix or a fly detour from Indigo), the ship crossing as far as the docking events, the Surge fight, and a Saffron Gym pad-chain assertion. (s21/22) All 24 route/gym trainers resolve in cache with correct parties: Route 9 six (Edna, Sid, Dean, Sidney, Tim, Heidi); Route 25 seven plus Kevin, whose Nugget-then-battle script 50:488d is hand-shaped (a full bag aborts before the battle, cart behavior); Cerulean's three swimmers; Route 10 South's Jim and Robert (Robert's Quagsire holds a BERRY via TRAINERTYPE_ITEM); Route 8's five; Celadon Gym's five including the twins' shared beat flag. Sight-line engagement is Trainers.sees (proven by the Johto bot); prize money base x level x 4 is Prize.lua; Erika's class item extracts as HYPER_POTION (the walkthrough's 'three Full Restores' is a FAQ error, not a port gap); Gold-only wilds (Mankey line Route 9, Growlithe Routes 7/8) come from the ROM version branch; Route 8's PRZCureBerry fruit tree is a fruittree op (Vm.lua:1218) into Apricorns.lua daily reset. One Kanto smoke-run converts the whole block. (s23/24) Nothing touches Routes 11-19, Fuchsia City/gym, Vermilion Snorlax, Diglett's Cave, Route 2, Pewter; the report's three blocker findings (START menu row, Pokegear card flags, radio persistence) are exactly the UI-plumbing breaks data audits miss: a gold_snorlax_wake.lua that grants the gear and EXPN card the way the game does (setflag 4, setflag 0, setflag 3 through the VM, not by poking save fields), tunes the radio, closes the gear, and talks to the Snorlax would currently fail at three separate steps; gold_cycling_road.lua and gold_fuchsia_gym.lua are the other two cheap proofs (gold_menu_shots.lua:72-73 only ever hand-seeded save fields). (s27/28) Drive first: (1) Cinnabar Blue talk -> disappear -> clearevent 1910 -> Viridian Gym populated -> Blue beaten -> ENGINE_EARTHBADGE set -> VAR_BADGES answers 16 (a break anywhere makes the 16th badge unobtainable and the Mt. Silver gate unpassable); cache chain exists: CINNABAR_ISLAND obj 1 SPRITE_BLUE at (9,6) scriptKey 4e:4985 eventFlag 1909; VIRIDIAN_GYM objs 1-2 gated on 1910; (2) the ROUTE_20 MAPCALLBACK_NEWMAP 4e:4cfa setevent 215 / ROUTE_19 MAPCALLBACK_TILES 4e:4f09 changeblock unseal pair (regression walls off Route 19). Driver: surf Pallet -> Route 21 -> Cinnabar -> talk to Blue -> Route 20 -> beat Blaine -> fly to Viridian -> beat Blue. (s29/30) No rows for Kanto OAKS_LAB, ROUTE_22, ROUTE_28, SILVER_CAVE_OUTSIDE, or the Union Cave B2F Lapras revisit (route.lua's only Union Cave rows are 03.29/04.g); risk concentrates on composites never run together: the 16-badge Oak visit with ProfOaksPCBoot text chain, the double Cut approach to the Route 28 west strip, surf-to-talk on the drifting SWIM_WANDER Lapras (Lake of Rage Gyarados proves the shape via gold_lake_probe.lua), and the daily-flag consume-and-respawn cycle across a save reload; a targeted gold_lapras_probe driver (clockDay=5, walk in, assert object present, catch, walk out, re-enter, assert gone) plus route extension converts inspected to proven. (s31) All six Silver Cave maps decode (warps, item balls flags 1689-1693, hidden items, SILVER_CAVE_OUTSIDE flypoint callback, PALETTE_DARK on Room 1, grass/water tables including Room 2 water); Flash lifts PALETTE_DARK via Palettes.isDarkness + World flashUsed; Surf/Waterfall live in FieldMoves with the badge table; gen2 ledges landed via Permissions.ledgeFacings / World:tryLedgeJump (the asm-walk's 'ledge hop missing' row is stale, GOLD-NEXT-RUN records the fix); the flypoint is FieldMoves.lua:386 flag 75; but no driver has entered Silver Cave, used Waterfall on Room 2's two bands, hopped Room 1's Escape Rope ledge, or talked to Red (closest: gold_halloffame_shots, gold_hm07_probe which proved Waterfall only at Tohjo Falls). (s14) The bot covers sections 10,11,12,13,16,17,18 and never section 14 (Ho-Oh is optional); untested: the 4F-9F ladder/hop mazes (depend on the recently added ledge-hop and one-way-wall support), the Ecruteak entrance sage scene retarget from Morty's setmapscene, the 1F sage unmasking off EVENT_TEAM_ROCKET_DISBANDED (set on the Gold arm of the Radio Tower boss script), the roof save/reset loop, and the entire Gold-side Whirl Islands dive; needs one probe driver carrying a RAINBOW_WING save up the tower and one diving to the Lugia chamber. + +*How to prove it:* Extend tests/drivers/gold/route.lua past row 18.20 into sections 19-32, and/or write the named targeted drivers: gold_snorlax_wake.lua, gold_cycling_road.lua, gold_fuchsia_gym.lua, a gold_lapras_probe (clockDay=5 present/absent cycle), a Cinnabar-to-Viridian 16th-badge driver, a Route 20/19 unseal driver, a Silver Cave/Red driver, a Tin Tower RAINBOW_WING probe, and a Whirl Islands Lugia dive probe. + +*Notes:* Merged from the parent heading plus six 'Also filed as' sub-findings (all one cause). Doc's own ratings: parent missing/minor (s19,20, already in ledger); s21,22 unverified/minor; s23,24 unverified/minor; s27,28 missing/major; s29,30 unverified/minor; s31 unverified/minor; s14 unverified/minor. The adversarial re-check ran on the s27/28 sub-claim, tried to refute and could not; its one correction: tests/drivers/gold/flag_names.lua and map_regions.lua DO name the Kanto maps (map_regions even carries CINNABAR/SEAFOAM/VIRIDIAN region graphs, so bot travel there is data-ready) but they are lookup tables, not executed coverage. 'Implemented statically, executed by nobody' matches the project's Roamers/Events:restore failure mode. GOLD-WALK-HANDOFF sections 1 and 4 already document the Johto-only scope. + + +### Olivine Port pier and Fast Ship boarding gate: the only road into Kanto, fully extracted, never executed + +walkthrough section(s) 19 | unverified + +Coord event at (7,15) (cache 5b:407d) does the full cart sequence: temporary-event bails, first-time bypass on event 48, readvar VAR_WEEKDAY branch, yesorno, checkitem S_S_TICKET (item 68), setevent 1, 7-step applymovement, sjump into gangway script 5b:401e which plays SFX, disappears/appears the sailor, runs special 46 = FadeOutToWhite (specialOrder[46+1], implemented Specials.lua:998), clears the eight eastbound EVENT_BEAT_* rematch flags on repeat trips, sets FAST_SHIP_1F scene 1, and warps to (25,1). Port sprite toggles for the post-Hall-of-Fame sailor (events 1847/1848) are armed by the HOF script. All data extracted, every op has a proven Johto call site; matched op-for-op against pokegold maps/OlivinePort.asm. Needs one live run or driver. + +*How to prove it:* A driver or bot section that reaches OLIVINE_PORT post-HOF with the S_S_TICKET, boards, and asserts the warp to FAST_SHIP_1F (25,1) with scene 1 set. No gold_* driver currently reaches OLIVINE_PORT post-HOF. + +*Notes:* Doc rating: unverified/blocker, new. If this chain fails, the walkthrough stops here (Kanto unreachable). + + +### S.S. Aqua maiden voyage: trainer gauntlet, lazy sailor, granddaughter, Metal Coat, docking; extracted end to end, never run + +walkthrough section(s) 19 | unverified + +Verified in cache, never executed: FastShip1F enter scene 5b:48a8 sdefer 5b:48ad (SFX_BOAT, earthquake 30, blackoutmod to cabin map, clearevent 49, scene 2 on first trip); grandpa bump coord events at (24,6)/(25,6); maiden-trip trainer objects behind flag 1849 with parties matching data/trainers/parties.asm exactly (NOLAND Sandslash31/Golem33, LYLE Koffing28/Flareon31/Koffing28, COLIN Delibird32 holding BERRY, MEG&PEG, FRITZ Mr.Mime/Magmar/Machoke, JEFF 2x Raticate32, DEBRA Seaking33, STANLY Machop31/Machoke33/Psyduck26, all in trainers.lua); B1F blocking-sailor coord scripts 5b:5e0c/5b:5e21 plus shared talk script 5b:5e37 clearing event 1837 to spawn the lazy sailor; FastShipLazySailorScript 5b:4d68 (playmusic, loadtrainer SAILOR 9, reloadmap, special 27 = HealParty, setmapscene B1F NOOP, readvar VAR_FACING walk-out); bed script 5b:521b (HealParty, fades, ReloadSpritesNoPalettes, RestartMapMusic, dock check on events 49/50/48 so sleeping cannot dock the maiden voyage); granddaughter payoff 5b:55c6 (fade, disappear/moveobject/appear teleport, showemote, sjump 5b:5633 = Metal Coat verbosegiveitem plus EVENT_FAST_SHIP_HAS_ARRIVED/FOUND_GIRL); door guard 5b:48d2 with arrived arm 5b:48ec (scall facing arm, setmapscene VERMILION_PORT scene 1, warp to VERMILION_PORT 7,17). Specials 27/46/47/49/50/60 resolve to HealParty/FadeOutToWhite/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/RestartMapMusic, all implemented. Nothing in the chain touches a stubbed special, callasm, or VAR. Matched against pokegold maps/FastShip*.asm. + +*How to prove it:* A bot section or driver for the full crossing (board, fight or dodge the gauntlet, sleep check, granddaughter Metal Coat, dock at VERMILION_PORT 7,17). + +*Notes:* Doc rating: unverified/blocker, new. If any link breaks, arrival in Kanto is impossible. + + +### Machine Part quest chain (Power Plant manager, Cerulean Gym grunt scene, Route 24 grunt, part return, TM07) never executed + +walkthrough section(s) 21,22 | unverified + +The spine of sections 21-22: manager talk sets EVENT_MET_MANAGER_AT_POWER_PLANT 202, clears EVENT_CERULEAN_GYM_ROCKET 1901 and EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM 251, arms CERULEAN_GYM scene 1 and the POWER_PLANT guard phone-call scene; grunt runs out of the gym (sets 203, clears 1900/1902, arms ROUTE_25 scene 1); beat the Route 24 grunt (GRUNTM 31, L30 Golbat); return the part for TM07 Zap Cannon plus EVENT_RESTORED_POWER_TO_KANTO 205 and the Saffron station population flip (clears 1906). Statically verified: cache holds full bytecode for PowerPlantManager 54:4dbd with both branches (54:4deb takeitem MACHINE_PART / 54:4e04 TM give guarded by event 223), gym scene script 54:4332 sdefer 54:4336 with all 34 rows, Route 24 grunt script 50:4407; initial_events.lua seeds flags 251/1900/1901/1902/1903/1906/1907 at new game exactly as InitializeEventsScript does (pokegold engine/events/std_scripts.asm:546-550); World.lua:607 applies the seed while EVENT_INITIALIZED_EVENTS is clear. Engine deps all have call sites: World:trySceneScript (World.lua:5369, called at :6893), setmapscene/setscene (Vm.lua:274-284), specials RestartMapMusic/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/FadeOutMusic (Specials.lua:999-1072). Missing only run-time proof: route bot covers sections 00-18, no Kanto driver exists. + +*How to prove it:* Route extension or a driver walking manager -> gym grunt scene -> Route 24 grunt battle -> part return, asserting events 202/203/205 set and 1900/1901/1902/251/1906 cleared and TM07 in bag. + +*Notes:* Doc rating: unverified/blocker, new. + + +### Route 25 Misty date scene, Cerulean gym population, and Misty fight never executed (and Misty gives no TM, contrary to walkthrough) + +walkthrough section(s) 21 | unverified + +The date cutscene at Route 25 cells (42,6)/(42,7) is what clears EVENT_TRAINERS_IN_CERULEAN_GYM 1903 and populates the gym with Misty, three swimmers, and the guide; without it Misty never appears and the Cascade Badge is unreachable. Cache has both coord events with sceneId=1 (scripts 18305/18369) and full scripts 50:4781/50:47c1 (34-row cutscene: heart emote, boyfriend flees, Misty approach movement, clearevent 1903, setscene 0, special 60=RestartMapMusic), armed by gym grunt script 54:4336 row 27 (setmapscene group=7 map=16 scene=1). Engine deps: World:tryCoordScript (World.lua:5006) filters on the map scene id; mapScenes persist on the save (GOLD-INDEX tier note on World:loadPlayerData). CeruleanGymMistyScript 54:438a is in the cache; MISTY trainer 1 party (L42 Golduck, L42 Quagsire, L44 Lapras, L47 Starmie with full movesets) extracts correctly; baseMoney 25 so the 4700G payout follows from Prize.reward. + +*How to prove it:* Driver: arm the scene via the gym grunt, trigger the date coord event at (42,6)/(42,7), assert 1903 cleared and the gym populated, then fight Misty and assert the Cascade Badge and 4700G. + +*Notes:* Doc rating: unverified/blocker, new. Correction embedded in the doc: Misty gives NO TM on the Gold cart, only the badge plus retroactive swimmer beat flags; the walkthrough and the lead's 'TM from Misty' are wrong about the cart, nothing is missing in the port there. + + +### PASS quest: Copycat lost item and Fan Club doll chain fully extracted, never run + +walkthrough section(s) 22 | unverified + +Copycat (COPYCATS_HOUSE_2F object 1, variable sprite slot 251) talk after the machine part return sets EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM 206; Fan Club Clefairy guy (POKEMON_FAN_CLUB object 3, script 59:437b) then gives LOST_ITEM 130 and disappears the doll (flag 1908, correctly NOT in the initial seed; doc evidence line also cites POKEMON_FAN_CLUB object 5 flag 1908); returning it (Copycat branch 61:528b: takeitem 130, setevent 208, clearevent 1907 to re-show her shelf doll, then PASS 134 via 61:529d) yields the PASS. All bytecode in cache (61:5235/61:5270/61:528b); the mimicry gag's variablesprite slot 11 plus special 93 resolves to LoadUsedSpritesGFX (specialOrder[94], Specials.lua:1033); the Copycat's variable sprite slot is seeded through World.initialSprites/findInitialSprites (World.lua:1530, the fix GOLD-INDEX credits for the Copycat existing at all). Flag 1907 is in the initial seed so the shelf doll is hidden until the return; 1906 is seeded so the Saffron station stays empty until power is restored. + +*How to prove it:* Driver: after machine-part return, talk to Copycat (assert 206), get LOST_ITEM at the Fan Club (assert doll flag 1908 set), return it (assert 208 set, 1907 cleared, PASS 134 in bag). + +*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's 'Rail Pass then Magnet Train Pass' is one item (PASS) on the cart, not a port gap. Merge-with hint: the s20 entry 'Copycat lost doll quest chain toward the rail PASS' (copycat-doll-pass-s20) describes the same quest from the section-20 audit with slightly different event ids cited (210/201 gate checks there vs 206/208 here). + + +### Magnet Train ride wired end to end; arrival behavior needs a human eye, never ridden since + +walkthrough section(s) 22 | unverified + +SaffronMagnetTrainStationOfficerScript (cache 61:4bc2/61:4bd0, rows 11-14) checks EVENT_RESTORED_POWER_TO_KANTO 205 then checkitem PASS 134, does setval 1, special id=35 which resolves to MagnetTrain (specialOrder[36]), then warpcheck and newloadmap 249 (MAPSETUP_TRAIN). All implemented: H.MagnetTrain (Specials.lua:2119) reads scriptVar for direction and blocks on World:magnetTrain (World.lua:2393) which pushes Gen2MagnetTrainRide; Vm handles warpcheck (:1135) and newloadmap (:1177); MAPSETUP_TRAIN defined at World.lua:175. Gating events (205 set by the Power Plant manager, PASS from the Copycat) are this section's other findings. + +*How to prove it:* WHATS-NEXT item 8: a human rides both ways and presses a direction on arrival; the conversation must start on the first step because south is the only legal step out of the doorway cell. Nothing has run this since. + +*Notes:* Doc rating: unverified/major, already named in the ledger. + + +### Silver Wing in Gold: Pewter City gramps script extracted, but Kanto (and the Whirl Islands dive) never run + +walkthrough section(s) 14 | unverified + +For a Gold cart, Lugia needs the SILVER_WING carried in the bag (WhirlIslandLugiaChamberLugiaCallback does checkevent EVENT_FOUGHT_LUGIA then checkitem SILVER_WING), and in Gold the wing comes from the Pewter City gramps (pokegold maps/PewterCity.asm PewterCityGrampsScript: checkver, Silver arm branches away, Gold falls through to verbosegiveitem SILVER_WING + setevent). Verified in cache: PEWTER_CITY object 3 (SPRITE_GRAMPS at 29,17) scriptKey 4d:583e = faceplayer / opentext / checkver / iftrue 4d:585c / checkevent 121 / verbosegiveitem item 71 / setevent 121 / closetext / end, all generic opcodes. The Lugia chamber map, its SPRITE_LUGIA object (scriptKey 47:41a0, eventFlag 1853), and its MAPCALLBACK_OBJECTS callback (47:418c) are in the cache using only implemented machinery. Route bot has no section 14/25 rows (grep 'id = "1[0-9]' shows 10,11,12,13,16,17,18). One mechanical gap on the dive path: the waterfall forced-down item, reported separately in the doc. + +*How to prove it:* Driver or bot rows: reach Pewter in Gold, talk to the gramps (assert SILVER_WING item 71 given, event 121 set), then the full Whirl Islands dive to the Lugia chamber. + +*Notes:* Doc rating: unverified/major, new. The waterfall forced-down item on the dive path is a separate finding elsewhere in the doc, not part of this entry. + + +### Vermilion Port arrival bookkeeping, Vermilion flypoint, and Kanto music never executed + +walkthrough section(s) 19 | unverified + +Arrival scene (VERMILION_PORT scene 1, sdefer 5b:450e) extracted intact: one step up, appear the gangway sailor, setscene 0, end-of-voyage flags (1841/1840 hide grandpa and granddaughter for good, 1849 retires the maiden-trip trainers, clear 1843 to reveal the Olivine passage Pokefan, set 48 EVENT_FAST_SHIP_FIRST_TIME), and blackoutmod VERMILION_CITY. ENGINE_FLYPOINT_VERMILION comes from MAPCALLBACK_NEWMAP on both VERMILION_PORT and VERMILION_CITY (setflag 57); the port's fly table carries the matching row (FieldMoves.lua:392) and World:runMapCallback has a live call site (World.lua:5659 plus the MAPCALLBACK_OBJECTS call in setMap). Fly deliberately stays region-locked exactly like the cart (FieldMoves.flyPoints, FieldMoves.lua:442: Kanto page only after SPAWN_INDIGO, no cross-region fly, verified against pokegold engine/pokegear/pokegear.asm .KantoFlyMap). Kanto music extracted: audio.lua songs has 92 entries, musicOrder includes Music_VermilionCity and Music_ViridianCity (musicOrder[63]=Music_VermilionCity), mapSongs carries all 362 map rows. + +*How to prove it:* Driver: dock at Vermilion, assert flags 1841/1840/1849/48 set and 1843 cleared, flypoint flag 57 set on map entry, and Vermilion/Viridian city music playing. + +*Notes:* Doc rating: unverified/major, new. + + +### Pokemon Fan Club chairman Rare Candy give never run + +walkthrough section(s) 19 | unverified + +Chairman script 59:4340: checkevent 212 (already heard), checkevent 211 (bag-full retry), yesorno where answering No gives nothing (cart behavior), two promptbutton speech pages, verbosegiveitem item 32 = RARE_CANDY, setevent 212. The cart quirk that nothing ever sets the bag-full retry event 211 is faithfully carried in the extracted bytecode. Same map also holds the Clefairy doll object (flag 1908) and Clefairy Guy script 59:437b that the later Copycat quest reads. yesorno, verbosegiveitem, and multi-page writetext all have proven Johto call sites. Matched against pokegold maps/PokemonFanClub.asm; RARE_CANDY present in items.lua. + +*How to prove it:* Covered by any Kanto smoke-run that talks to the chairman and asserts RARE_CANDY given and event 212 set. + +*Notes:* Doc rating: unverified/minor, new. + + +### Lt. Surge gym: trainers, Thunder Badge, AI item use fully extracted, never fought + +walkthrough section(s) 19 | unverified + +Surge script 59:4bfc: checkflag 36 re-fight guard, loadtrainer class 19 member 1, winlosstext, reloadmapafterbattle (whose loss-abort semantics were fixed during the Johto campaign), retroactive setevent of the three gym trainers, setflag 36 = ENGINE_THUNDERBADGE which routes to save.player.kantoBadges.THUNDER. Parties in trainers.lua match data/trainers/parties.asm exactly: Surge is RAICHU44/ELECTRODE40/MAGNETON40/ELECTRODE40/ELECTABUZZ46 with full TRAINERTYPE_MOVES movesets; Vincent, Horton, Gregory all match (GUITARIST 2/JUGGLER 3/GENTLEMAN 3). Trainer AI item use implemented: Ai.ITEM_ORDER/Ai.chooseItem (Ai.lua:1368-1400) dispatched from Battle.lua:1790 (AI_SwitchOrTryItem) off the extracted class attributes. The gym has no puzzle in Gen 2, matching the walkthrough's power-outage line. + +*How to prove it:* Driver or route rows: fight the gym trainers and Surge, assert ENGINE_THUNDERBADGE (flag 36) set into save.player.kantoBadges.THUNDER and the AI HYPER_POTION use path exercised. + +*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's claim that Surge holds a Full Restore is a FAQ error; pokegold data/trainers/attributes.asm line 114 (block 113-117) gives the Lt Surge class HYPER_POTION and the cache carries exactly that (LT_SURGE items=HYPER_POTION, baseMoney 25). + + +### Route 6 Underground Path blocker and Saffron gate guard extracted, never walked + +walkthrough section(s) 20 | unverified + +The walkthrough's 'large man who blocks the entire entrance' is the ROUTE6_POKEFAN_M object at (17,4) whose event flag EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH hides him only when SET (set by the Power Plant script in a later section); the port's object-visibility rule 'flag set hides object' is implemented and documented at Events.lua:1-2, and his script is a bare jumptextfaceplayer about the Power Plant. The Route 6 Saffron gate guard at (0,4) stands off the walking lane and is dialogue only (branches on EVENT_RETURNED_MACHINE_PART). ROUTE_6 grass and water encounter tables (Abra/Magnemite slots) are present in encounters.lua. Nothing gated on engine work; it has just never been walked. + +*How to prove it:* Covered by any Kanto walk through Route 6 before and after the Power Plant script sets the blocker flag. + +*Notes:* Doc rating: unverified/minor, new. + + +### Mr. Psychic's TM29 Psychic give extracted verbatim, Kanto walk to the door unproven + +walkthrough section(s) 20 | unverified + +Cache 61:4b15: checkevent 227, writetext, promptbutton, verbosegiveitem item 221 = TM_PSYCHIC_M, iffalse full-bag fall-through WITHOUT setting the flag (cart behavior, re-talk after making room), setevent 227. TM_PSYCHIC_M is in items.lua with pocket TM_HM and price 2000; the four-pocket bag and TM teaching were exercised in the Johto run (TM08 ROCK SMASH teach passed on merit per GOLD-WALK-HANDOFF). Matched against pokegold maps/MrPsychicsHouse.asm. + +*How to prove it:* Any Kanto run that walks to the house, talks, and asserts TM29 given and event 227 set (plus the full-bag retry arm). + +*Notes:* Doc rating: unverified/minor, new. + + +### Silph Co. 1F Up-Grade give extracted, never run + +walkthrough section(s) 20 | unverified + +Cache 61:4f81: same shape as Mr. Psychic, verbosegiveitem item 172 = UP_GRADE guarded by event 222 with the full-bag fall-through. The officer at (13,1) has event flag -1 so he never moves, and SILPH_CO_1F has only the two street-door warps: there is no upstairs in Gen 2, so the 'man blocking the stairway' never opens, matching the cart (verified against pokegold maps/SilphCo1F.asm). Generic mechanisms only. UP_GRADE present in items.lua. + +*How to prove it:* Any Kanto run that enters Silph Co. 1F and asserts UP_GRADE given behind event 222. + +*Notes:* Doc rating: unverified/minor, new. + + +### Saffron Gym teleporter maze (30 same-map warp pads) never exercised; same-map warp chains untested anywhere + +walkthrough section(s) 20 | unverified + +The gym's whole puzzle is 30 warp pads (cache SAFFRON_GYM warps 3-32) that all target SAFFRON_GYM itself; the only route to Sabrina is entrance pad (11,15) -> ... -> (1,5) -> (11,9). The engine mechanism exists and reads correct: pads are COLL_WARP_PANEL 0x7c, an immediate warp (Permissions.lua:311); World:takeWarp (World.lua:6022-6040) handles a destination equal to the current map through setMap; warpCooldown keyed on the landing cell (World.lua:6135 set, 6217-6222, 6240 suppression check) stops the landing pad from re-firing until the player steps off, exactly what a pad-to-pad maze needs. But the asm-walk's own port-coverage table flags that no driver has ever exercised a same-map warp chain, and no Kanto run exists. If this breaks, Sabrina is unreachable and the 10th badge (and eventually Red's gate) is lost. + +*How to prove it:* Targeted driver: enter the gym, ride pad (11,15), assert arrival at (19,17), then the full 5-hop chain to (11,9). + +*Notes:* Doc rating: unverified/major, new. + + +### Sabrina battle and Marsh Badge fully extracted, never fought + +walkthrough section(s) 20 | unverified + +Sabrina script (cache 61:40cf): checkflag 39 guard, loadtrainer class 35 member 1, reloadmapafterbattle, force-sets the four gym trainer flags (so the trainers are skippable, as the walkthrough implies), setflag 39 = ENGINE_MARSHBADGE routed to save.player.kantoBadges.MARSH. Party matches the cart exactly (trainers.lua SABRINA 1: ESPEON46 Sand-Attack/Quick Attack/Swift/Psychic, MR__MIME46 Barrier/Reflect/Baton Pass/Psychic, ALAKAZAM48 Recover/Future Sight/Psychic/Reflect; class item HYPER_POTION, base reward 25, AI attribute words extracted; attributes.asm:209-213). The four trainers (Rebecca, Franklin, Doris, Jared; MEDIUM 6,7 / PSYCHIC_T 2,11; parties verified including the cart's L35 third Exeggcute that the FAQ gets wrong) are OBJECTTYPE_TRAINER with SPINRANDOM movement, which Npc.lua supports, and sight/battle handoff is the proven Trainers.lua path. Prize money base x last-level x4 is implemented in Prize.lua (matches the walkthrough's 4800G). + +*How to prove it:* Driver through the warp maze to Sabrina; assert ENGINE_MARSHBADGE (flag 39) into save.player.kantoBadges.MARSH, the four trainer flags force-set, and the 4800G payout. + +*Notes:* Doc rating: unverified/major, new. Corrections embedded: the L35 third Exeggcute is cart-correct (the FAQ is wrong); Sabrina's class item is HYPER_POTION. + + +### Copycat lost doll quest chain toward the rail PASS (section-20 filing): all pieces present and generic, never run + +walkthrough section(s) 20 | unverified + +Spans this section's maps (Copycat's house is Saffron warp 8, the doll source is the Vermilion Fan Club) though the walkthrough text defers it to after the Power Plant (EVENT_RETURNED_MACHINE_PART gates the dialogue arms). Copycat's 2F object uses variable sprite 251 (SPRITE_COPYCAT), and the new-game variablesprite seeding that used to leave Copycat despawned was fixed during the Johto campaign (tests/gen2_variable_sprites_test.lua, GOLD-WALK-HANDOFF section 5); her script (cache 61:5235) is extracted bytecode whose only special, LoadUsedSpritesGFX, is implemented (Specials.lua:1033); the Fan Club Clefairy Guy (59:437b) checks events 210/201 and gives LOST_ITEM; the doll object (flag 1907) and the PASS handover (verbosegiveitem PASS, setevent EVENT_GOT_PASS_FROM_COPYCAT per pokegold maps/CopycatsHouse2F.asm:61-73) are ordinary ops; PASS is a KEY_ITEM in items.lua. Nothing engine-shaped is missing. + +*How to prove it:* Same driver as the s22 PASS quest entry: full Copycat/Fan Club round trip asserting LOST_ITEM, doll flags, and PASS. + +*Notes:* Doc rating: unverified/minor, new, named in the leads. Merge-with hint: same quest chain as pass-quest-copycat-fanclub (s22); kept separate because the doc filed them as distinct headings from two section audits. This filing cites Clefairy Guy gate events 210/201 and doll flag 1907; the s22 filing cites 206/208 and doll-disappear flag 1908. + + +### Celadon side items: TM03 Curse night gate, Leftovers trash can, hidden PP Up (plus Berserk Gene, Route 9 Ether, Route 25 Potion) never run + +walkthrough section(s) 22 | unverified + +TM03 Curse: roof house pharmacist script 5e:5083 in cache with checktime mask 4 (NITE) at row 7; the Vm checktime opcode (Vm.lua:788) maps NITE to bit 4 off World:timeOfDayId, so the night-only gate should behave; the give branch 5e:509a is guarded by EVENT_GOT_TM03_CURSE 218. Leftovers: CeladonCafeTrashcan bg event at (7,1) key 5e:648c present, plain giveitem bytecode with the flag only set on success so a full pack retries, exactly the cart. Hidden PP Up at Celadon (37,21): BGEVENT_ITEM row present, handled by HiddenItems (fixed PP_UP; the walkthrough's 'random PP Up' is wrong on the cart too). Berserk Gene at Cerulean (2,12), hidden Ether Route 9 (10,5), and hidden Potion Route 25 (4,5) likewise extract. All generic and statically sound, none ever run. + +*How to prove it:* Kanto run collecting each: night visit for TM03 (checktime NITE), cafe trash can Leftovers with full-pack retry, hidden-item picks via HiddenItems. + +*Notes:* Doc rating: unverified/minor, new. Correction embedded: the PP Up is fixed, not random, on the cart. + + +### Mt. Moon Square Clefairy dance (Monday night) + hidden Moon Stone + Rock Smash rock: all dependencies present, sequence never executed + +walkthrough section(s) 26 | unverified + +Coord event at (7,11) scene 0 runs 5b:676a, whose 45 rows extract completely: checkflag 87 (ENGINE_MT_MOON_SQUARE_CLEFAIRY), readvar var 11 (VAR_WEEKDAY, real in the port) ifnotequal 1 (MONDAY), checktime NITE, then the appear/follow/applymovement/cry/showemote dance, clearevent 236 (the hidden Moon Stone flag) and setflag 87. Both callbacks extracted (type 5 NEWMAP 5b:6763 re-hides the stone on entry; type 2 OBJECTS 5b:6767 hides the rock). The rock object (SPRITE_ROCK at 7,7, scriptKey 5b:67ee = jumpstd SmashRockScript) resolves to the real SmashRock/AskRockSmash bodies GOLD-INDEX.md:377-382 documents; follow/stopfollow are implemented (Vm.lua:1009,1026). Flag semantics match the cart: ENGINE_MT_MOON_SQUARE_CLEFAIRY lives in wDailyFlags2 (pokegold constants/engine_flags.asm:105-106) and the port clears id 87 in Apricorns.DAILY_ENGINE_FLAGS, so the dance is once per day, i.e. once per Monday night. Unproven in-game: (a) whether the appeared rock correctly blocks/receives the A press on the Moon Stone tile at (7,7) and Rock Smash then exposes the hidden item in the same visit, and (b) the full follow-chain animation. The stubbed RockMonEncounter (CallAsm STUB_ROWS) does NOT affect this rock: MOUNT_MOON_SQUARE is not in the cart's RockMonMaps (pokegold data/wild/treemon_maps.asm:43-48), so no encounter roll is owed here. + +*How to prove it:* Driver: force clockDay=MONDAY clockHour=NITE, walk to (7,11), watch the dance, smash the rock, take the Moon Stone. + +*Notes:* Doc rating: unverified/minor, new. + + +### Rival re-encounters after Mt Moon (Indigo Plateau Mon/Wed rematch, Dragon's Den Tue/Thu cameo) never run + +walkthrough section(s) 26 | unverified + +Both beats are pure extracted content keyed on EVENT_BEAT_RIVAL_IN_MT_MOON (set by the implemented Mt Moon script) and VAR_WEEKDAY (real in the port; World:readVar VAR_WEEKDAY at World.lua:1289): the cache carries INDIGO_PLATEAU_POKECENTER_1F's NEWMAP callback 5a:48b6 (23 rows of setmapscene/clearevent resets) plus its two coord events, and DRAGONS_DEN_B1F's callback 47:44e1 (checkevent 793 -> disappear object 4). The daily rematch flag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT (id 92) is in Apricorns.DAILY_ENGINE_FLAGS so it clears on the daily rollover. The rematch parties RIVAL2 members 4-6 (L45-50 with CROBAT) are in trainers.lua. Nothing looks missing, but the coord-event choreography in the Pokecenter (rival walks in as you leave) is the kind of scene worth a driver before trusting; filed unverified rather than implemented for that reason. + +*How to prove it:* Driver: set EVENT_BEAT_RIVAL_IN_MT_MOON via the Mt Moon fight, force weekday Monday/Wednesday, enter the Indigo lobby and assert the rematch triggers; force Tuesday/Thursday and assert the Dragon's Den object 4 state. + +*Notes:* Doc rating: unverified/minor, new. Merge-with hint: overlaps indigo-lobby-rival-rematch (s18), the same Indigo Plateau rematch filed from the section-18 audit; that filing cites World:weekday at World.lua:1247 and scriptKeys 5a:48ff/5a:4940. + + +### Indigo lobby rival rematch (Monday/Wednesday, RIVAL2, post Mt. Moon) armed on tiles the walkthrough crosses, never run + +walkthrough section(s) 18 | unverified + +Not in the walkthrough text but armed on the lobby tiles it crosses: coord events at (16,4)/(17,4) run PlateauRivalBattle1/2 (cache scriptKeys 5a:48ff/5a:4940), which fight the RIVAL2 second-tier party when EVENT_BEAT_RIVAL_IN_MT_MOON is set, ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT is clear, and VAR_WEEKDAY is Monday or Wednesday (pokegold maps/IndigoPlateauPokecenter1F.asm). Every dependency exists: coord events and scripts extracted, VAR_WEEKDAY answers the real weekday (World:weekday, World.lua:1247), the daily engine flag is registered for midnight reset (Apricorns.lua:88, id 92 ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT), and RIVAL2 parties 4-6 (SNEASEL/45, CROBAT/48, MAGNETON/45, GENGAR/46, ALAKAZAM/46, starter/50) are in the trainers cache. But the gate flag EVENT_BEAT_RIVAL_IN_MT_MOON is Kanto content no run has ever set. + +*How to prove it:* Should be picked up when section 26 is audited/run: same driver as rival-rematch-mtmoon, entering the lobby on Monday/Wednesday with the Mt Moon flag set and asserting the coord-event battle fires. + +*Notes:* Doc rating: unverified/minor, new. Merge-with hint: same beat as rival-rematch-mtmoon (s26); kept separate because the doc filed both headings. Line-cite difference between filings: World.lua:1247 (weekday) here vs World.lua:1289 (readVar VAR_WEEKDAY) there. + + +### Lighthouse descent and swim to Cianwood (route rows 08.50/08.51) never walked; collision data matches the cart, blame points at the bot's region graph + +walkthrough section(s) 08 | unverified + +The route bot has never completed the descent from Jasmine to Route 40, so the Surf crossing, all fourteen Route 40/41 swimmers, and Cianwood arrival are engine-unwalked (runs reach section 08+ by teleport; run 28 still failed rows 08.50/08.51). The handoff narrows it to the 3F seven-cell pocket: warps 8/9 at (8,3)/(9,3) 'do not take' when stood on. Verified in the current cache: OLIVINE_LIGHTHOUSE_3F (8,3)/(9,3) decode to COLL_FLOOR 0x00, and pokegold data/tilesets/lighthouse_collision.asm block $27 is FLOOR,FLOOR,FLOOR,FLOOR, so those two warp rows are one-way landing anchors for the 4F pits at (8,3)/(9,3) (which decode to COLL_PIT 0x60) and never fire on the cart either. The pocket's real exit is warp 3, the staircase at (9,5), which decodes to 0x72 and does fire ((13,3) also 0x72). Fix shape: correct the bot's region graph, which treats landing-anchor warp coordinates as exits (the exact trap GOLD-WALK-HANDOFF.md:593 documents), not the engine; a human following the walkthrough (drop beside Connie, fight Terrell, take the Ether, head up the stairs) uses the (9,5) staircase and should be fine. + +*How to prove it:* Something must walk 6F down to CIANWOOD_CITY in the engine (bot rows 08.50/08.51 passing, or a driver doing the descent and the Route 40/41 Surf crossing); until then the second half of section 08 stays a beat-completeness check rather than a proven path. + +*Notes:* Doc rating: unverified/major, already named in the ledger. The doc's own re-check corrected the suspicion of an engine defect: the cache collision matches the cart exactly, so the failing rows indict the bot's region graph, not the engine or the warp data. + + +### TM08 Rock Smash give (route row 06.21) flaky across runs; ledger contradicts itself and the failure is unresolved + +walkthrough section(s) 06 | unverified + +The Route 36 fisher gives TM08 after EVENT_FOUGHT_SUDOWOODO (Route36RockSmashGuyScript, generic extracted bytecode with verbosegiveitem guarded by iffalse .NoRoomForTM). The ledger contradicts itself across runs: GOLD-WALK-HANDOFF section 8 says 'Everything before it now passes on merit: Sudowoodo, TM08, the ROCK SMASH teach', but run 28's failure table lists '06.21 ROUTE_36 EVENT_GOT_TM08_ROCK_SMASH not set' while the Sudowoodo row itself passed. Plausible causes: full TM pocket taking the iffalse arm, or the bot failing to stand adjacent. The script path is generic and the same give shape works everywhere else, so this is most likely route flake, but it is unresolved in the latest continuous run and TM08 gates all Rock Smash content downstream (Burned Tower rocks, Route 40/Cianwood rock items). + +*How to prove it:* Re-run route row 06.21 in a continuous run and assert EVENT_GOT_TM08_ROCK_SMASH gets set; instrument for TM-pocket fullness and adjacency to distinguish the two suspected causes. + +*Notes:* Doc rating: unverified/minor, already named in the ledger. Not given severity unverified-risk because the problem is a reproduced-once run failure (a live flake), not purely never-executed content. + + +### New Bark rival push scene (talk to rival by the lab) unproven; low risk, zero coverage + +walkthrough section(s) 00 | unverified + +Walkthrough: 'If you talk to him, he'll push you out of the way.' NewBarkTownRivalScript is optional, repeatable (sets no flag) and uses follow PLAYER, applymovement with turn_head/step/fix_facing/jump_step, and SFX_TACKLE. Every opcode involved is implemented (follow/stopfollow in the VM, jump_step in Movement.lua which the ledge fixes exercised), and the sibling teacher-drag scene has a dedicated driver (tests/drivers/gold_teacher_scene.lua), but no driver or test touches this specific scene and the route bot never talks to optional NPCs. Cache maps.lua NEW_BARK_TOWN objects include the rival with a scriptKey and eventFlag. + +*How to prove it:* A short driver that talks to the rival by the lab and observes the push movement chain, mirroring gold_teacher_scene.lua. + +*Notes:* Doc rating: unverified/minor, new. + + +### Kiyo battle and Tyrogue gift (Mount Mortar B1F): fully wired, never exercised by any driver or bot run + +walkthrough section(s) 15 | unverified + +The game's only Tyrogue. Cache scripts['46:5eec'] holds all 29 rows of MountMortarB1FKiyoScript (checkevent 97 / 1193, winlosstext, loadtrainer class BLACKBELT_T member 6, startbattle, reloadmapafterbattle, setevent 1193, readvar var=1 VAR_PARTYCOUNT, ifequal 6, givepoke species 236 level 10, setevent 97); trainers cache has BLACKBELT_T[6] KIYO = L34 HITMONLEE + L34 HITMONCHAN (KIYO object at 13,4 with scriptKey 46:5eec); readvar VAR_PARTYCOUNT is a real read (World.lua:1271); givepoke is implemented at Vm.lua:443. The full dependency chain has never run: Surf across the 1F Outside lake, the in-cave Waterfall climb to warp 4 at (17,5), the 2F/1F-inside/B1F warp descent, the battle, and the gift. The waterfall climb mechanic itself is bot-verified only at Tohjo Falls; route.lua contains no MORTAR/KIYO/DARK_CAVE rows. + +*How to prove it:* A Mount Mortar driver: Surf the outside lake, Waterfall to warp 4 (17,5), descend to B1F, beat Kiyo, assert Tyrogue (species 236, L10) given with a party of <6 and events 97/1193 set. + +*Notes:* Doc rating: unverified/major, new. Correction embedded: GOLD-INDEX's claim that only VAR_WEEKDAY and VAR_FACING answer readvar is STALE; World.lua:1265-1330 now answers essentially every VAR_*. + + +### Blackglasses pharmacist in Dark Cave Blackthorn entrance extracted exactly, never run (same for TM13 Snore and Revive balls) + +walkthrough section(s) 15 | unverified + +The script is fully extracted (cache scripts['47:436c'], 13 rows matching pokegold maps/DarkCaveBlackthornEntrance.asm exactly: checkevent / verbosegiveitem / iffalse pack-full arm / setevent), the spinning NPC object (SPRITE_PHARMACIST) is in the cache at (7,3) with that scriptKey, and verbosegiveitem is implemented in the VM. Reaching him also needs Flash plus Surf inside the cave, both implemented. The same unverified state covers the TM13 Snore and Revive item balls beside him (item ball arm is implemented and tested). No driver enters Dark Cave and the bot route bypasses it (grep DARK_CAVE in route.lua is empty). + +*How to prove it:* A Dark Cave driver: Flash + Surf to the Blackthorn entrance, talk to the pharmacist (assert Blackglasses given), pick up TM13 Snore and the Revives. + +*Notes:* Doc rating: unverified/minor, new. + + +### Master Ball from Elm after eighth badge: route rows exist but optional, no run log confirms the event ever set + +walkthrough section(s) 16 | unverified + +ProfElmScript's ElmCheckMasterBall gate is checkflag ENGINE_RISINGBADGE then verbosegiveitem MASTER_BALL then setevent EVENT_GOT_MASTER_BALL_FROM_ELM (pokegold maps/ElmsLab.asm:57-61). All generic extracted bytecode; checkflag over ENGINE_* badges works since the badge-store fix (GOLD-WALK-HANDOFF.md:502, badges routed into save.player.badges). The route bot has rows 16.24/16.24b for this beat but both are marked optional = true, and no run log or handoff note confirms EVENT_GOT_MASTER_BALL_FROM_ELM was ever actually set in a live run (no MASTER hits in GOLD-NEXT-RUN.md run logs). A full bag silently skips the setevent (verbosegiveitem iffalse), same as the cart. + +*How to prove it:* Make route rows 16.24/16.24b non-optional (or run them) and assert EVENT_GOT_MASTER_BALL_FROM_ELM set with bag space available. + +*Notes:* Doc rating: unverified/major, new. + + +### Everstone from Elm by showing a self-hatched Togepi: pieces individually proven, full chain never exercised end to end + +walkthrough section(s) 16 | unverified + +The gating special FindPartyMonThatSpeciesYourTrainerID is a real handler with the own-OT check (Specials.lua:1134), and the phone-call prerequisite is NOT required: pokegold maps/ElmsLab.asm ElmCheckTogepiEgg -> EVENT_TOGEPI_HATCHED -> ElmEggHatchedScript re-runs the same species scan, so a hatched Togepi in the party is sufficient (maps/ElmsLab.asm:62-95, engine/phone/scripts/elm.asm:46). Egg hatching has a driver (tests/drivers/gold_egg_hatch.lua) and PC withdraw exists (BoxMenu.lua / PcMenu.lua). The full chain (hatch with your OT, withdraw, talk, EVENT_SHOWED_TOGEPI_TO_ELM, verbosegiveitem EVERSTONE) has never run end to end. The .egghatched arm of ElmPhoneCalleeScript (sets EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE) lives in the bank $41 callee scripts and only matters for the alternate entry path. No route row exists for the Everstone. + +*How to prove it:* A driver chaining gold_egg_hatch-style hatching (own OT), PC withdraw, and the Elm talk, asserting EVENT_SHOWED_TOGEPI_TO_ELM and EVERSTONE in bag. + +*Notes:* Doc rating: unverified/minor, new. + + +### TM37 Sandstorm happiness gate at the Route 27 house never exercised; gate is first-slot happiness >= 150 + +walkthrough section(s) 16 | unverified + +The granny's script is extracted (cache scripts['60:6352'], 9 rows: checkevent, special id 88, ifgreater, sjump into the loyal/disloyal arms) and special id 88 resolves through constants.specialOrder with the VM's order[id+1] rule (Vm.lua:1773) to specialOrder[89] = GetFirstPokemonHappiness, a real handler at Specials.lua:1506 reading the first non-egg party member. The cart gate is happiness >= 150 on the FIRST party slot (ifgreater 150-1), not 'one of your Pokemon'. Happiness accrues via Happiness.lua stepped from StepEvents. No driver or bot row enters ROUTE_27_SANDSTORM_HOUSE. + +*How to prove it:* Driver: enter ROUTE_27_SANDSTORM_HOUSE with a first-slot mon at happiness >= 150 and assert TM37 given; also assert the disloyal arm below 150. + +*Notes:* Doc rating: unverified/minor, new. + + +### Route 27 whirlpool island (Bird Keeper Jose, TM22 Solarbeam, Rare Candy) and Tohjo Falls Moon Stone never run; whirlpool mechanic only proven on Route 41 + +walkthrough section(s) 16 | unverified + +The whirlpool mechanic is implemented and driver-verified, but on Route 41, not here: tests/drivers/gold_water_moves.lua:105-124 clears a real whirlpool block with GLACIERBADGE gating and asserts the block replacement. The Route 27 island content is all in the cache (TM_SOLARBEAM ball at (60,12) item id 213 with event flag, Rare Candy ball at (53,12), BIRD_KEEPER[14] JOSE = L35 FARFETCH_D with phone contact rows; maps.ROUTE_27 objects rows 6-8) and the item ball arm is implemented, but the bot route crosses Route 27 only via Tohjo Falls and never surfs south past Gilbert (route rows 16.42-16.43 skip the south water), so this specific whirlpool, the Jose fight, and the TM22 pickup have never run. Same for the Tohjo Falls MOON_STONE ball at (2,6) (maps.TOHJO_FALLS object 1), which the walkthrough itself never mentions. + +*How to prove it:* Driver or route rows: surf south past Gilbert, clear the Route 27 whirlpool, fight Jose, collect TM22 and the Rare Candy; separately pick up the Tohjo Falls Moon Stone. + +*Notes:* Doc rating: unverified/minor, new. + + +### Route 26 heal house free heal extracted with all specials real, never entered + +walkthrough section(s) 16 | unverified + +The teacher's script is extracted (cache scripts['60:60e6'], 17 rows) and every special id in it resolves via order[id+1] to a real handler: 47 FadeOutToBlack, 50 ReloadSpritesNoPalettes, 27 HealParty, 49 FadeInFromBlack, 60 RestartMapMusic (Specials.lua:450, :999, :1025, and the fade-in/restart pair at :1059). Unconditional and repeatable like the cart. No driver or bot row ever enters ROUTE_26_HEAL_HOUSE; the bot route's Route 26 leg is a single travel row straight to the gate. + +*How to prove it:* Driver: enter ROUTE_26_HEAL_HOUSE with a damaged party, talk, assert full heal and that the scene is repeatable. + +*Notes:* Doc rating: unverified/minor, new. + + +### Victory Road item pickups (TM26 Earthquake pit, Full Restore shelf, Max Revive, Full Heal, X Special, two hidden items) never exercised + +walkthrough section(s) 17 | unverified + +The walkthrough collects five Poke Ball items (TM26 Earthquake via the one-way pit at (0,11), Full Restore on the ledge shelf via the (17,19) ladder, Max Revive and Full Heal in the entrance region, X Special on the HOP_LEFT column) plus two hidden items (Max Potion at (3,29), Full Heal at (3,65)). All pieces exist: cache maps.VICTORY_ROAD carries 6 objects (5 with itemball item/qty payloads), 2 bgEvents with hiddenItem tables, 10 warps, 2 coord events; World:interact consumes obj.itemball (World.lua:5483-5490, an earlier bot-found fix); HiddenItems handles BGEVENT_ITEM; Permissions models the COLL_PIT one-way warp, the six ladders, ledge hops (isLedge/ledgeFacings, Gold's own direction order where $a0 is HOP_RIGHT), and COLL_UP_WALL side walls (Permissions.lua:170-260 LEDGE_FACINGS/SIDE_BLOCKS/NEIGHBOR_ARM), with a test section at tests/gen2_world_test.lua:2936. But nothing has walked these routes: the bot's section 17 is only travel/walk rows 17.2, 17.11, 17.15 (no pickup rows), so the pit drop, the two HOP exits from the TM pocket, the HOP_DOWN off the shelf, and the X Special ball sitting ON a HOP_LEFT tile (the one placement the asm-walk flags as needing hardware confirmation) are all unproven end to end. + +*How to prove it:* Driver or route rows through Victory Road collecting all five balls and both hidden items, specifically exercising the (0,11) pit drop, the (17,19) ladder shelf with HOP_DOWN exit, the two HOP exits from the TM pocket, and interaction with the X Special ball on its HOP_LEFT tile. + +*Notes:* Doc rating: unverified/minor, new. + diff --git a/README.md b/README.md index ba1fff43..c5bbecff 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,14 @@ supplied by the player. This project does not include a ROM, emulate the Game Boy, transpile assembly, -or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the -only game content input. +or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold 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, and Yellow can all be imported and -played side by side. +do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported +side by side. Gold is Gen 2 Phase 1 (import + launcher; see +`docs/gold-phase1.md`): the Gen 2 engine is still under construction. ## Quick Start @@ -67,12 +68,13 @@ 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 1 MiB US Red, Blue, and Yellow ROMs are accepted. The -importer verifies SHA-1 before creating any game data: +Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are +accepted. The importer verifies SHA-1 before creating any game data: - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` - Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1` +- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94` 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 diff --git a/assets/logo/logo.png b/assets/logo/logo.png old mode 100755 new mode 100644 index 0d5be97bf0e03db1fadfd93b9dacaedc78e2116a..00835fd3f59c842d7d5b51b402dd4f9b299df049 GIT binary patch literal 98569 zcmZ5|1yq&W^ERbm&`6gwA|Xhplr+)}5`uKMgn)D-AX3uOCEbmr)S)?a9ZEt<;M)iF z{{HJbYu$BOC*F7O+0V>8^US=4yi$0HexLY00s;cMw3N6q0s@)@0s`V9+FkID^yZ5X z;D7h*q_i9n5T44w|3h^3aFRzrphS=sf1&D@vNP}Frh1ZkdNcVDw^yVWQv^r+d%!_Q zJvxzgp8g$tBgRn6!t!mK(~I2L8Rwm-lfe0f8P?q<+UiMH&(pkV+j(pBS>hdC=&r@1 zi;lwTU;4;Z$0g;oZ3~csw@BBN_a6s{P~J=0r*K3PlM?-ubi}(cAV4u0A4X#`w82c5 z#Jh3Cw_n3b9`yhLp|ziCMAJW;SXwZ4rbv;#r4*7|kKgeI5$xdh6?6{Y8UkyWxN%i% zwZUq~Ogx&rlycAsUBrF0Z(I{q_x^qn!3{qH4FO?ME9PyHW{8Mb%=|I-EKa4*``N1|L)1g>zxNIi%^R;7#o_xf@Btu%JT0s-w>=& z@OK(kNH?*QO$`!HlrJ1b@K@`^t)(Y{r46NZQc95jybVnC;MTBr1{DbYy@k*i1mBk( zr=^u-RmYqE-o~VYZ$me%oaoc9W* z%OWm+K6w9sRyTIL=bE<(s;f@e|E@skd26IdV2KkWt3Z0Vt;ab3eae>RI|k0fch1zh zfcvwyZP$*C*Gd9w+PNE4r*Oj6A(v*Wcwu^*Pn|)U&)cFvf0t3{$hiS?m|ZP%GJwXJ zNB2L&8sAzg1@ctPm?IR~zkcI$&+xhKTBDxXLTTEneSLigpPN%NP5%D4Q(1Dg}?dae*nJSe2{o%d0F;d6l)!g?>CAe;X%BCKjxjzoYg`ex~ zYn7(ukG+i|2B(ZHnw;P0X}fo2^S`w zuz397K2#RL$z>dreR_iOr`&E#?;n7J0TEa!oB2mo~g7%F21o?(pv$tzz(d`-X>z8$Xst&xIKO z@4lbVZtp8S$o`xwi-)au>fyg1u{{K@hppEC}+jRNLiG3c=c^2Mhi1q7;mg@ga+ z=N-2{|EOimQT<6f>VE-X(dZT)jNqqTu`ZvqOL^G72DV(J{qN!;__t9*d4e*3ZrsQ~ zvs?#YT&3UeF!cXK)t|RlAz%g}M{4Hn|G{WK-<|WG9f5aKL;nA;7mWe8!eXYTF~?{5 zVhvKlaX}b4SCy_h+`?Np|7>E10ym>wIdRwj47i90cbnF15RkN`N#u$(G|rzGbNu^- zNFUNII2zlIZ!ud_>NYU$ATk3Itfq4LiD9n?iPBTXlLk)P|F#EDDbqfc)qr@x3lc=69K>TYrQ=@kv}18IxrRpiDTuk)0af?kbYcGrL5$)53O zkRDCQ{vzJrOG3?qa4JJQ95+`^mwnW8Y{K3;smV%V-@Y3lBa}(g5L-rn>l*h6A1q6s6vYmp6fjKA zmaKn?a1Vrjbu)jg@O|lFDf*C@XF>sGwLEuXR_Nl8#qbPc{(Jt0n6M(s&R(68sLrx6g;aDYCmvlm0|NAo zP!yZDul~}(WaY6{)0>WYIeup@wvp~GcZ6Cux*k(J)Vn9IcDdq*mL(k3G8G${MSDp}uZdV9zkHgYG80d@B8K`0r)-P?R1V?*qg!+M) zMRAyA2J&|8z7ZqXJsUIT5Nc@Ds}ZmMtj&^FM~i3JL`PIt_}K<)I>Zj8BJ$C4fd7t9S1mzC@YDF`L7uC7vMh6{ob2h<65kB&_;M6s%P1!Q$2h=s zH+YFc%k~ONG?tz3l}bDGaM8A7lPM4&Wk1Ph>b#q0*%koKk_nupUG*PcyT}zlRUxJP zU>jr%UfswPnWc+Nj+)sV(AS*1D{_ETV^g^77HovC#F+`@2uvFBY7F8K+1

BZ_U8lqG{gtqo4GF60>zuXQp`DmCA~GyU{}p}nifWgR^h!lQGK&Q5g{LwZnm z9CeWs!jQXrYov?~nH4#-)16;JfnOdCAcJ0Zq(J zJrCywx=D`MdP0~S;|YG)Rb9n3F~P5gQlSGt$%q}u1|hqbW~UkwLkxKPA3m`o#?UT- zCT0>5vxoi7&`w~5i{!FBL1v`mZD>7cUe75oOEhl0D#ToevHYD>( zNpVi8nN2&!13F2I+ciYLL6hKVt(U5i{$@8Qkt(D6WxlP;8$lsh+?bhtDru7+=3TNK zN-xJ8bPp@EOhe7E)N~zPI#wo+LSt)SW#hNk$?r*JKATIDM;q_voUH=u6$YYd$VeV; zo-ee2jD$I$P)D;D@{3nyJD4%Oz@K8wPm2k@y|Eib23kk06l%W`t@b&WLmP!JBQTtG z0F^Q~j+>_DJMlYXchz|FWbceAywx8edBSYtST7(w%7m&>xIZpOwv$?1V*FudQZIAG z-(COOM}Z9qP82vp_po}4vSgfoTRPQa{n;U(D0ZOc{NvBG^Q(4@b_DI%2;pFNhSGmR z-sxk!XXSw4mOSOl{m}JNm9J7UjKzISEhEUO(ZQ}t-<>|&E6B9)Dpj^|T+axe+MC8C zTCa*WYE7jlFcOzP#Xt?I6XKf%*C*qLcGR>+mN+PCTJ)c-Pez~=(rgXlzHmN9<}RDEAG9l;d(9sFctm%WOV?)|Gy zEwv9#+Nw^fIUG@i9@m!Isuzv-4^R2^5}tX{6MVJ4nV7kn5#8l|-m{yWibBnn0cQx} zy*!1(Re=2J@iiGd2=i2Bv!)y=r@INjAN10{^Ma$L<>G>1Grp#9+|{A}7ugdvXnwv& zRi5UbB1nLmlPA37PvPcY1fHghhd0oF%QWjKKh&`Gmq!9az# zGr63puQtS&xqV*BsCykSMq4)ojtWNL<5B7jscS7JNv7DS!j`NYsv@SfwyRH&pE$E`My7u_Cv0S20vbpR1vihL#IGt6k z-kC;J2B3sF-BGHa#Y?sjzXr>%EUCX8TiC|BpwPJyf@N!5m|Lg5#e`elF~lc%UZ1Ch^(`e*y~)z(vm3WmQ@2OO9N0 z27wz+Y2`Ot6lz#I1Do3LQh4&jMp}86!zk!?#N{YRa z>lyTmwRC9l?xkLNNGdjysVnR%k>b@^g|MX(QL0qRj|p7JWZHEw$qe*H47g_&aL-1d z3_UgXA9I$2NMRXGEGMME?#6^RU*GXqUn3U0&SPry7@7p9>o@*~(6ERzP$4(Eyx$Wo zxbIo@_3SJD&>kd{*S3J~7(h8S3nrzB8a1Q=R_amJAkIp`Jdgk>jH^-!Ij@2fgVxld zaw6;4KKUYe;UQKvtI8MVg^+iAk_bymgqRElhrJnbXrN; zP#<*-1IAmdM9u@ff9KM<{}HSCmBDX6N^xRIJ}H4;%Q&n;d0vfx>rQV`-D04%4oxDv zEktYAMe|vMj#`c_6ELd|pYSKPQN7IFi2Fdz&;V|nq*mPC)Am~%!I+&4a=CNpGLJKD zVZlqYwT%H=GAkV$vrTZnc4q|#R3Hl-@B6^YsJmApLN^4YmiO1z8&E!8Wn(j7%?OpS z4${&T+xf;t-;$=~sNZ`PFLXA*iG-!WyuTHLg)RJBm==iLc%aq!Q)ZE|yWYR> z`uf|~M(^6-33G-@>^wZal?%^(r8i(Yl{pPgl?fjM)ca3H5MiLA1w>gt1ivKho;VE` z*}|x^hEQG~>Ach1{n{5T?#}r$(4>_K-YVR40%>$zCuE1@s`ulM#fB9Diz_VTFQPZl z%rS5?6MHL%^0=ZO9_TC^ApAdPw!da$m3mdgJraRF7Bl|BA_=N$3cU)&n#K+K@Ls## zI8WQj6`%U{2OCbiGU0-&AO6%37}R+7F-0PsVhWSbh`?6#9JgNRH5^A3V2t!}YE8zJ zrA-rzu%4>9Eg8+2I6tu;DTYTS9>5!Sqn+Y_(BpPescmot1R4kROH1y}M4w-}umeY| zfLl+(9!l}} z#R%WxnkV>GCyo1KEc+ur)gG(yaTii1XEzN^IJ=*F1_?8KMH2|(b`wy!a7&NIcmA>xaADb~~c#-b0?UC0nB@H5U ziQd^~NXsR`lim%5ZQTtKwoE@8?1lAe5pjm8(qZwmiGmz~N%s*rGOC-@Y|!g&WGz+Z z>GHyE*F!mp75d5dQSjRs)m=Nr4WfHubBAf^4tXVm7Z?2}1N~_>hRrGP+qxitV$}p zuNU9z>cR08h0QpoL4lKTYbpl!?fnTQ8H`B5nPCOmseAVP3&ar%SD)?yr9yJY4!BfO z$5i-^<0R(I^a)}kTt`$|$UjlT9?#>z1b8=L*|7w!MK-JesZfvBf@wd;gcEmxP&-uEw*K zdNy9*3Y$Ycdt4Lk*DGK>gLL?!rnQV%FIlweCoV^`jku zB>;DX*y=aRpYAv)(mCpBC6?$NW~wv0k8de2DwUGz7Drv}l*&Jyn4VE3U@ymF??B(s zg$J@V!58b6rU~mMF~2`#j8KHZ2yZM|4)@-j%af$uO6|Ij5ooM#4*RDOtMUYieIdA91778w0dW4(lTsw z6YD~e_U4p5{N51)cZ#FeA)H69AuA*x1l5&UqWtwjD{+Q)a3c#^YuEZnxK6Ayk-3eP ztf;?{Vb>W&J5j?maRT4v17j=6UzZng>0`gH5=pcQ|Khy3KRuTT#cE;#SA93?dT8$r zVaAdJI2_5*F@<6Skf90GcOXyrezuK*W9Pb=M4jOHxZ%Wz^Y89r45DT;(KS*}94avC z#Z2;g(i&8tp4T!px^GDo8*o;h!|Tu1GdWv2p#N?sa|`w!#TqJBLrYk%_PnY~umS8k zDmJPt?8ELKnv-F2RPSDUdYyh?+R+chCT4jQ(k&W0v-*Qq0|Wq8pMyMY)$0C`wIw6C z2UZza|4>1dO=Xdw`inzm2O+$r<1SuC`<_valJiINk1I3*0Ld>qf==KRj+fLPE+m8j zA%Vw$hL}lx`Mh9m{PQ0bs%JgMTyNfR-fE2O1lCu798)07pgUxV@o5sId7H~6;i)U_ zV6Y|=!Jw+%#mzhuP-|R3BA{w7qOl0OPqQ}m#<@><4w?Cg|JsIqVBZN*p@L;-H`@Oe zE+@S&N$!c8)cL*6<0^V5f;nL`22_6@y&<GS>LQ%wlU6=E27sBHA@d__>`)*yw| z+x(N)aPP>Iorb&R-|m*LRqR+Q6>Yq4x7H)RN!o67$_aaXMaJH=loF8q&4E9a~OlUbpfp05m7F!s;;3eq{>#`PUKYo zRE<6%FzDoj^@s5FYMe(ih%3tf#Dv=bY2u%_1ri|0Y{Oe zL@H5^ZZVh~;D39{&gsJH`yH;pJ1}5@mdBI#XxZ76wFdG&NABECXP&_<-hWmhsSpUa zLKd)s1}KAU4O;Y=5{0%q5ABjp`pF^G6xOPGYlRAl>>VFH5UxC*t*t-2Ngvc z{z*^hyab_TEJM~#M!Rs{Hb_FD`i3Z3o~MvV#cW^<9J|PHI zH7M0Q;Q~ycU6swn(ursa?;^qb1d{?b7QAxphq4$szo($&wF3U}2uZHH?UOqK zzlr%VBSW-GiF8sllSNQ=%DksLE`FWNraqL!3&Hqf`)pb>QEW(;^cE&)0{xiP%K~w8)cQr8P^HKX*LC&B`M}iBzqlS}m(%dtM#4bYj8ll=Y{_rJs0`5uE*NB@%_I zmM6%mw#-sICVLmcK#aguwr69*25&9Q6*{TwXeW2?+NzqA5d&W0SidVEFkd`iscJL^ z`I<~X`lHEd&HwS8wkvqFaX$QQB?dHYI}7_;R5d;+6#KiYmy}3J%cCXOn80jS@P2e_ z>`?B{g_1g=B9z|7KCPEK0s_qI=%M1qgRxwn^j-C|2;QR+Zuq_K#IHYv?>zs(Pg^n9h4KESOd-~BDKe60*425X_m^-QMe z^KJ&3b6G@VcWI<^I6OH^4-S%A3QOGDoy>Xn%OU(HgM#^H{OJXkpMfM}Rz^kXnC8C4 zHD75Np%(;&%&2PEPPW-6Jo@O>dtCp&&`{b%HQyOkg9q8HTyG2!m-6* z!7OIh;gOH#N|P21yZWq9JGrCLUbN$m4G%9@yr8o#KYPm* zw1hnq@L|JT(#*qNH$!P7m?EZctQq`7P%6|ws%XA0oIM$DQ_hnm0vceiuhesV_2m!I zKkXIs;R?z?<6~*%t?-r4I(E9Ts%*O-@dw?H1}4=KuHgRj(qiE5<$^Y#D#xWN-oHV7 z8!w=ZGKFtdpSM<<;!eFfYDU9?!-ylt$r+>2^NRZVFht^2gb}1cg z!7j9Z*t$GG(kUBJbG1U3zOO|Qx$upQ%neQY3Ad8QT{5-h&#M^H{8OJK3S!$asD%k0 zr(R8ePk8O0S}kqiws-03!lNfu-PiXjK*f~_oS<}+VB*4j2N7V6QYrvJamXuaa_~BO z>)2a*G`#Tzvz$dLMcU0w(rJI_!t3mpu`L(ZSKCB=vRmcO@t4O*mf_qKh;+MGH`l&; zL3DVAl{uE&K`Y+SeXkDtn`7DjM`-wjS~Vb9!6Rx|p9_%|W-V5b|E)e^{P8Zrq5u%G zY4WQ<9Xzt7jZi~ujLgk@fE9b%j-F(vs+^TA*^W)foDY+Z7`kr6W9lz8=U+7Vi1zY; zD-$q{Xjf^+nzCrp7%V)e zw)DBBf;rYu4iRlq3`t#hpK-af9RXqEHeH;=+7Pdl$`V!uSeZI|qA_8+EtZ@NT%I{X zTPlGx@|u-N{Q047`*4(}lgxeG@ZJN zfhmP$14yMHkhZRBy4nNbJV=8!3fgPB)PMan2Rqaa2t~!E%T@R1%t4lF@Jn5K;B!xT zoB>Al{bMX|Hxj%ezzy~-e1vie)8Y1>%zxb(2^`C4>;Zy9kTdhUIGkin-1#P0FQ;QG z3q)_{MCRRoseSe5l!G6@u#eh;cT7G!CZ@5rtHue>(hb?LE3tnQ4BbLvZ<|q_dEBw9 z1E`Nk`gLoK9Lv3M%Z@(O&!SL|%Zh}mQgvME_-T;M4&ZBp`_x)1`@RlWQRftHyteOD`sgikN zz4&RE5}NOW@o8~5x>W%cPJ=aDI7l!0sk3;gLYuuCa0!+qEx+Nw5W4@2ggDh>xQUr4 zxb{WOJHA!8%Y+zRm{2mW%Vgf)+w(qFb~V{3_vfO9&;ipU6e464TRS$wayp>tf6~W8 zcwGu~ZBkHt03wx~J^9g1(Day(XYm_YE#K%*lQ1+S-kj;}(f3cwcCw(sGBYgRX^lo% z6BEehhwm6fL#kIK_bbpr)dU#I^Ew(f^Y}Ln$Kdbr(CK{JFK8 z?wEx7w*@n&_#=wA0KK&e-plE1lS%7`6#L#Q zK74wpEZY76tjLN%a0*wMoh(H~)-b-3G)a<*EN`6t?EO z_GT|l?ZE+rc|AwdlrMC#=lU+WiC_U66PT5Ht@_UD=b^a#W-)SWw`amuu zQ5Sk`!b~}K-|x~H45y?L+SYd5UqnI>0A~IGENe_d3{zUhSdh~_UFStiQ!D?qw@i&{ zV1w^k*&xw0c85|h9>zr{D*1B>2ed&YW<_cej@`NNu+PPhOnKR0>n%;LQBV3^lJxPV zz6(xzsio`fZAQdO#D1=0KurOKj|CXJ(9PZsJ>H95xLj`p1cVZm^T~Nhk%-9HeY5H2 zx9}+x{iE7l7=jBZi)7&-+IlTsha+5rfPe{%3S71tv`L($X0kd@khpwfj-Egk^q%I^ zdV2)J`u3D;ocDZD)ko|-`IUj$%HkMnZ5D(?7+Nd`$gZ`xoq5qD0bMS4aVhf74k#WTw*;}`25=iGL*fZ zE}9XRD7e!}CiA_ZOOlN;!a_Lm_~WCdC8v9mYVpmfhsK_Phwx&ZsOYYg0gz@D5`FJI z)ob7_QgThcJo3Mf^1#Z#6YwHKCzE))bKDt+lgix09AgTES~<{zsOo+}5px`!&3QLr zt;EJnp=+jr#Nb=E#ppUco1`g6KD<_3$Rv(D&>?8}u!|er3n@TUT)^C97tAnD2F7YW&n-hYa zF6i65%q;H)f*ZAY11TXYx|=juEsWOMz8)i-Y1Ij_mIt7dt6vTc^A|Nu0^M)`6}}F^ z`n$0Qp@$D-!%ME>ExmL6C^Hz{4Bkw$aLyn40?EKtGuXm(@1DLF-;uzWSzo!@XS^oIqcupq=~)Y%yOqpiG&2Z2k5yY;yn9p2;o z>5X({J)Tqctgr?6Oc+K^%{|0g=@(@x(8ixH=>4%VK?)?!!$&0@eahlXiL5@br*K|g z9Ax^Y42RN7ug7%C{S1R2Gi?wK(X7r7myVw{amtNcYHXS;3V|yr)Svp#%B6>KY9i_( z_(p&rV-L0*C2q`5(?qxBUAGdcpl#iO3p|NTIdg?#a(HeKy=9W^9a$ae&aRFm8UN++ z0fRg}qvo=EVaGsoG^>v{=o_e5o-i#j9>ix=>%+Fojuxl;h?C2Wk2mCh4C*_S>G6v49I>8Y~R$htRj6(FvF(J`;{F-~X6jB@8NK-_}D9D0oafu47q*T^w= zHv}}Am~2xf!VzN+`&K+yNh5sBSSC?nc<_}tV0NPqG_rr6K^yBqi!YEAhPjoLsA1`9 zR+q-dQ^&vQ>Y6BZ`6L@E@vZ3K6NGu^$A#;SGKW9eFMOEt@afFOOmoMOzgf9yg#F`D zaR5^N^Gun04<5h#nDA}u0TVtfO|RRb(h?lJ7ij5J=HEf2Oa_oD%l4r4!OQ3myI@RBe2yBlzup)zQy^+|j|8UpsU;v-EiF^jJP=!F5#lD5V9h zliy3|jsj{>-iAB{%Xbkwsv>a>vh3E0jNmT7)#C}1Gm=0=*1LSbc36ewwyg|}W*K>suD%Vc$Ea>(WUaLTI0H=7G;BWkP*?;s0BQ%Cl>>Bqc zV(O_QN1om0?v{JURqS)01z#Wi(c#`hCTR2T*!HaZ3cQTc_wQahyT6Tk=ihO4t%7W8 z`S4oBzQvYD2q*H>*Q2gaQHsAb!g{V7eGaV7p6x|ZgeVf#;C6jqGQZBKH83-!aHY`E zo|)ii(Tz>BYC7Efbt9PPT6X4S__+;DRNCCmn!(6E)7i2zJ5%I4>G@GEu@1;aiGU)G zPkH!`kv^0x0{T`2kdKq)SRYPVf4^W>N`J?+09I@ffPUu*h*D4VtX0U}ecV0Df%n7p=9_8*^vuj_Yv!ecX^|wDNpT_f&Pzod zmXsY%^Gr3<9*JyY0Gi ztwX0;1DMo`ni}*+C9}dXdYmr;Zh~#?WYPndJ5;Wk0n$HHOD7JJ^I#o(Z}~mn6PR6z z80gn}@6J21&q7TGiSaexh283*t`-N4@(@OPMN~ZD%4hrn7yHbqv8yEN8Md0VL^t~l z;xCwGt(LpDE8Cb{oM9AP;t%!iXQa)L*9n&CL2L(ZqWl?qTo+U6p-+h z=DmU+A@BVbf8>Y8rv0v{^Kx7t;$^p{nOhX*wd>@5KDSRo{Ob~xb0^HQz*cG_B7>(V zIX3%??r8jX?j5G2%?*gK+g_Ac{&n_frm%GELesTI9;WNfX$EMQhHng#Ds0?f3;USc z9bw3UX({=^$?#<-pO=dIaC-Lcz|a_mXtVU1%?Qv%OCXy%zLBM;d~*#GN3Zi*?zpZ zs2|vOK{P9*+&ueA-->;zd#dIvXtSe`V>0+RqjK9sNkDWe))zc-+#K*I$P;3b@EC&_ zkyO_D`91BuwvUO-7BE-k`^Pe`qVML!9XKcb1+ zb)!tdnUEoE_Y=jw*8FB__wzej*0~rxCnbJu9R~HVhOr|kFPC1(Yq9p2xv8Q-e(wUG zuEP@JhBUOn&+LNajR(}-U8&nFj^8ViNpc?zc|_q_Vq!||>pARfj(5=CMe6Y~<#rOx zbs9QCMZIgoq{tc<;8e?UOZv-j^r%+TwM;I5&zOJ#`RqVgOWyGHDoX=5lO)sV^lKD? zjc9aYIaW9QPonfg=a<(V`{6f>TVunG$-9Q3Rt@J^@=V{j89YHR+o=SRrda#ptIeL~+X732 zI-F89B11IF4Xu@D+r>4~P(9d-@L^v|)uN`$fvMx~LZ)tS%QVab@j>F17c8E3Z}LK= zA+n9%x!XMU1pTI~dFV{>$wI<%Kubj@ZNCjmf|KIHx2-J{y;05`VIpCc@4B-$bB`SA z5m+7d7(^ksnZv>o9A?8D)eOcQz5(z$(rK6aqP7mdgt{QUmchW*L1`5ORuUDBLx3Dw zEDYm#)L9;B0G&L!z|nGzKRZ0cQ@l@jb{F*#1u5wzfDxcA}QTgXDzVAL{SIXT_O5bfw^ zC+{20gRZ=9jvx4JF?5K<3SU_-Ct}E*%Jyxg7k~e(!gK6$`epq>8R|j(YUZvZn^h(4^EL z+Zw=qO}1tRyNL5DV}|!9k29yMOMC##o(zsIVn&C8(!^_zRp&V0fS4cEQgykC9RWU~ zpq|xP=UL3MjNy!sqymb1kDRd!9P|Eue*9z1%h7oP6BUS_MIMS-_IQS&-D39P;n+R| z>S=WUeX#UFdgx`|2Kp&QVs8t$?`fDCg4#+8e|@#Kz+|Z zHIVYM@d_mdVgiXPv!MVZ3I$z-bR3$5CV^tRG%WCA2ClEj^)%N0xSyl#LU20y=wd!D zF`u(?o?@D6<4x_QKaO;4kDZBj+c#Wz%u*M;@in@{&;5M{hT_HwXo;To`ahA4tgTiF zpxG7FESODUX}_x|w^>TMLt8{^}E1{+fVJm*2MKz$&I34-9 zCKG4DMWqQ`Dby6>2pcpVipUhGOvusT4;sv|EXS8YRa)t=x1bj&)gTpBSwdl@OobNH zpX}xo>Z|iS`|yj~JD%ORxEMka0Yz{09M9)bB)#W8@2wo;JF+;~q??xpoC@l;Ba4?d zntkXm%^2+@0R35J$ytwyG8!H6~f%iWnS96z6JkHBeNPJeZf;@eGRM{~0%W{O-g*6%-u3W`FlA@4(5z_QN zBZ_4@&yRB6!ngdL$BED87R9xHzEWTWkgZTR^m?BcChM0E&Ce+=94E58^R96m?_q|d zlRI$57OZ|o0X{Y|?e zd!dy;M*-O?TcF9OpKXD%xq})EgQ?4I^hZAKmhG~R{P!SamB74Br+9A*fa8BtMt59NDB+N1Meu?V2HbGds0}skGqU%QUKGx5< z?+T00e|*O!-rOJC$`T|NavojUek}t^Z=kO;20_yHnN>9uHFLV^E{VSuvCU(DuGNIk zk>va5xUh~Hog!&v8aB2ZY@n|7C!5Z;!?jj0pEKq1t`!C}wyK{^o=(vH1v8^MD*J^u zy2=Q;eB2DtbDWgZCUIrEGEQhxkc8tp446Eizszw0>$OEkbC_!r|Af+&gh+qNYBT%_E6R!vq?TJN;fvlvD%F|Y)Mj}pjV?pduA$yVoXB) zjrxsUFwb#O=@s_ z2I?RHVL~0ugUr4Py?XbyOZh50`RZpt_ySMZ?y@zpvG+){1%=>Ag9%b9>3^Zc}^AkUjqSub4Cr3`}*`UYmAns2f|!5mHPS3E1w4j!NG2CMWx?S~~3o-Y992T*bOf35vE~)DXG% zPf1oFQs3B+(833@nSR=?=R;8;!wbD`;c|BzDj;9e*NBsvxHt}v?CHM679)~{hw&%t zEpLCIKh%lJAq&{J$&)=>{`nCJJNCJNz&I0Q(<^ZoF21S{`%aColuLc4CYf>`g`$Ne z`zbHKWiUFPdU!KHm8C}|R8!D3rR_`LOm+_jQ}pQ^@2V|-4iqmedhxyWm_;##gAMty zpYe zX9ovmE7Z@I5q&Z`&%#5_0=;NsJvi(bM5P%W>Q%)3WAz(n1RJ=f!T7OSP#RE~7HPrg zu*VK21;n3KcgcxSLIx$zf~p$4>hJ0YhUF62Ww2nYn9g|W zd%-ze2m)O`-~i6?rMZuM8`0oOiZzRskk$t0==oA$^ociJkFZ;6Vm-NBwr9^-a3&=MCjpJi1x>M=bt!~dRc1|`eM`jc$#xP7<1ewTb*b>cy`TPO^JYrrE(8~p|{E>n9HpWxIz?9-V4#X?92E~%5wRTi0g#%=4cxf zGrTRUui1LaS>ItCpz=`2AuqAkb>0bT@;my^r;UUjKc){o5AANc7 zxMq!DP~CgPYB-1*=&>&KD3hA1@1C!{0)2Xj5?Up-zx=Zsf#HxGe}{Qw8Vc6bjq z_xSlX^RE4U$lJTp+aJdt=f4bbKO~FgW3QWjGp{#PhtbVmF;cG*A`daG$JTvSl^q1| znT0dyOtMSpYT>*-mEC#sz}x#76))v8V78=~hP2ToCq$`F+?T8QzqqXg&i(E46fx1W z4|xm((qbSOVnSr^u3BRcwIl+l=#0<41?FYD2EUTL|NTWI(9{7z{*oQ{0GJ8hpk5OM z-XRGebzg!m7!@#E7KDD}nKlgXronpG`XL23((RJDD`+?Al#3I}NoH1|KG9h7&Ff~3 zQP>;En-GmRWt34#8g5ftINfk@v#;Xqk=yKk2}ikHWr&1;&6v1Pl9^*-ux064R&ft% z#R!K_vwT!jZRcuia;zT~-Cj7WqV~ps-lF>3OudjV;>kFUYbstw4-L*_qgNmd&qQ-y zgdDvcN@gvR8pQ_>dOT;sj@*>m@Kra9j1iF;a#;t>i_hB_tIk;s&Cl(qLac*n zS4n@mP;zs6+TUc??yO4i_B2Jds6j&#l{_?Fki)=G3ZO-viDC37_5SAZk}G?QFEst! zc$u`H70Lt#2N{K%PokL`kcn_H1Rt9>jlD@Rn{USeH~dtRR#r_RC0NTLy{j-(`^)c7 z_uRunPN>CT?Edbh*DEmc1zJ1RWE8zz)~Vc^s4XosAuQe!4#T`%y>jR1qe)*n6=?a8 zb8ur3y5lNIewq^!6)Opk3N9P%%_wdUD6yXY-g{O8#vCkJ1iwb9-l%)gn{BHo>g_v8 z>vN0>8nwsp^df=k3HrTzW4cY)X-2qMF%2Jil_!Ytvc5>g=Pd#o*5Fh7}%U+Qbj5^z%ndWAxw zedNjhb%)Tle zN~L)4y);R0dmq==uejg1HtofskR~7m(`=3$9(bjdwnCzs%Xj}rM8BX%U{KH{t={jh zwlh0vy88!;*sk63%4}1t&@$F}qRJiYoL-Rof6feY5}ye70~~=-aL#J{MM-j^_?VsM z2dQ$NL~W?GA;PwwLs<;6-#3$|zJoc>N?MqRs%_0GBrZOIKU@*{oJ(;=-{A9DM(}x? zH>h4M2pUV0+A#9^dxM+f9vpWhK9PUQIbuqPLr0n#BRt>C&j)-KW$gU}_;7L0wOy9? znQ{(6{aKwv-H?qhn7^s}5`%%`n-xl94OH0^87rBZ@ZS=k^TsrSovF_&keLl04VYAK z&V^ksCmOr3=Qh|&7R4MB8*{Ry?TH~Hi5z@JJ`{#Y;pV8` zvYw4K(EBVg=of>~yCzQ-@pVEly7@Uym~EQY2(IVpUVMcEN0^=B5zLc#5*!DG@IC z6!itceo9`QTFlU08u-)NBce?&ok9^(Ua@R#Nj?8%Ov_hqrHm0p&j^~7Vr!^Q^YO#^ z>_Huy7*mw_F6{HN(fsdJRuCDDQ*i`f+oz4BZi`7^k{6i>k3vY$%+0QK${VJs@9DKZ ztDVp=e0p%#__l??{dWApYv3C~ztW&YIuGn{qaymEvt4MBu!)Eg0Y4CxaWbsc6ih;U zqY2xWKIQS1Ze~P$G*l|pU>RV0zmi;D)B9Y1%XMG&{AY2Hz|rs5VWaK(d9plNE^V+U zOy5HVzSMiP=hllqPuA=NEzTmkh@cwqR4W+!Z1V{@J&n4QXioaIP~;9fys0=j^VO(w zUieaaT~D`$tx2c>QZntl?K~~1Lp3R7-n>cES~mQ7VVCb=bJ-h3_6>=x*pcZw-B-k) z`A$rRpc!OnDdeM!K8d*)d1LLuAM-t;tI4G=Zl)Vf%W{X_*`7h;ixA#pAu-&0f`$Hd z1@He6^_F2#c5U16&>cf}N_TgMbayHx-O?r9AtfPQ(kUR_C1H`$-5^Lvy^HI9zU}+R zhKw`kisRU8t%+vaBOq%28@}|-zNY4`i}glOTcjmwzq#)CB+tRfDPN89cz$_7tWsUX z-Z$R~YgVme`Pab@W@9(eW*X1m6p}&}FGj(&NFmiPPARdsurU>9x!NV>hJLw^esQ6W zN`D)Cx;nk>b^B`|bpLZ0rscjE3WCV*5~D$dt>a}I$h*(eRQH1u_Hv8&gnUFgjp?yE z=SFli+QG@04am~0j#qe{=8f|954Iy@(R*`#7H7fV?p8zU&fd5%o;>ZJuRLWG?#dRU zi}l4=7L(Zwy8nWwwt3BrVcUgsEa0$qaDeA09H_HyDy=T1)9hwF!*U$NQE5PgFm9jG z{{bg^S1IOzzOa0T;X|eDkx#4mnxMkTzFlsTc#U6uNp9Hq`N;kq__eia zZ5<*ok;G>_P|e>hQ=>61k^Bmw;_!VLO8XJBrPT_1DA@2&qwl};s_z%*wo1tvYB&c= zga5vN>*zE{6X5ai)a{fd`oz@ZGA$^492?!c1lMNFMDLk%14$!=LWE5v%O%kE9+G1r z8_<~?=hlg_S<$tkZ&V)1cf--Y+4a~P-_)$~o&J8AL}9sUP#9jvmh19*2G%-_KCiq` zF&&xa@<`&EQM~#Cbqh|l865PXAM0YK$)^BGxe$qRlKj#f64)0*a+w>DR#?t!^>^W; zLsgRio&?-ltLRh;l>vixIIWQcz7vMPn!O!?pD-lkr(0`yUg+LeveoDVh_oxAvsxsV z>}n&?UnCejWHF3tJ`=1;W+>8u4~q54j%BjsUp;@$tDwC;4>UHzHXO}$xH|GLAJNy` zL$$&^gs=L4Dyb!@V{2J7F>H3d4}NcC`;iJclqkZ??&V6jUv>5^IZ9aU>I0+_e5krq z`!?Y;uH`Gl`g-hB`^*S0#(oqw#^j#_eYKq4N`cxw4saIp!rj-8vYOc(~rKIUqG|c`xP)~-3 zG5aFo)n?Q2GVa6;$pKq8B1zQ>Ds~>{u4ui%-*>8Mxrlkxs zS5IwZceO+%1V#+Vp;VEDh2(ACp(sNC97@C@F9#ul$z6pv(-8jDN~kPjZ1i3^U9H;be<}4u` z)CmxHsqpIP4?Z*NN}fMWN%@G@-y<1>C!LLbEpcDFWbJXGJfQ1jU_K6wqzTH`jq}Nb&gTgT8I{472A@%Jj|n?`4Ktn4Lf!YvDqAi1^vG^wF)qWQ zU^)JaPM8DkXza5vm;(pE95lm3ngOkQz z;c~;tZ;A6X*jI!FvCUlnO{nKQZHS^XV<~?_m94OBC;N=`2}dd%Hsi`vU zKbEKLi=uLssG1WJ6Vv|iab2iWXgrI}VjH)u?Mulz1ZzI!*D5HJ7iH*9a7sD1LK5VT zg|#p}$VJQ6VCJ_SDkcLkcnw?}>hQZLuhICh8vLm)(q^=&ko(epu@3nMr#d1?fTt>= z?E0QKl_Fi8q=Uaj3uZ;>R`5v7v9IdbZ223B)r zs9|Z#2pJmJBv1`A<6uO3%6<3jw$*Mz4n_8MsNv)t1S$R`rc2bVIkASW5zQ^F5`iYEJ_BD$;L z9$7%{>HK9H&X1;Arjf<6uw7w-^yWM~KO=Q!(4}^y2Y2v(%;MfxDTsQ{pJ}{DjLrJR zG^m$b+_H4$2LvLR&Mm(~Wy*_uzQKyOUZVsLVf00n1MmC&M*_0a4gu{|o+0bD9x=QW zJdBc>jF=nu;(^0@BWBGj4m84Ae5s0Y1zv<^WRLYLAA&cuY@pOi4P%sjCjLBSb$=U` z6@YO~U=H{9^RNL-L3ABgm=vf2h2@giosA2r4ZAE_J{oMMGGBW^pl;A|ep` zN93XQ^{m>sFwVjmFl6utN->A5*5o9NBf>)}pC7=)wc`Pew(Y}7h(FbYt$@AiYbHqB zJ{!K3m9uBZ7Z}42Gdw6_CVH{+VmD}wdqksn_xI^&JPM$A-W&NcO8j4@E}t zD<#LJIS$N!V@rw{usVw$>5w5J)fZRu?R|jbO9uL43?iKP0xS2|^mzU8sqApO4~woa zkc#@s*M};R%tf>1OId94CQb@6D?0m;nhF2HAgDg%9&&#Y3hZ;h!!dW~4J76-w_g!u z!d>b`_#pNQLrUt(C<&$o87}dVIha*%>)*-^%o4{`4vSC=J2m5f`H<&oP@#v8I+&NL zZb9jrBo9`S;l4c!0dXzuv};E52-V3;b&&+$7erF<9b5Cx{}5rjz23kxt#Xh|olKF) z`IJo0asEy`=~WgPtq8$_S||u%ymQ5gRR9}M*rz>&XiPgD8RM8L#*C)Iq{1w=hGD00 zu@=vd8D8qLxRCq<^hYGRlgw(Uw7oQfj?yYY`rQSqfk_(|vF>C;+hQfbgJp0VF!3&x z?)D_`iqK=|W@cF6jMA8@J|=X2DqanqB%l?S25KHTo5c=sMu>{EqoeC$=gW5=YR9EBpE+^E1sv!O6(ex zDzq<*qJ+LVxc$rL`iS*gemNALIuHy=5xzISLaUJNcO^balEruCWj|k^$9t5BA#xee zha9ZDf>#{FmSUkE{?z@A!j&;JA3?%`)#xL-Oyws9$hK#dHMeI2Htjd0py-)_U+Jrp zVLg)HP-m3zUk+egp8aGt8>U9C%`%SnNxCedixw9;pN!TLd=>2=rR%|Znv~$#f}gcX znj+MIX@{aOv>>-(sK9*{N04nCFKC|m?t3;~u2Vejwwi2rDDdfitRTHC-H*; zg@!gN4Hs$V!szB4tI0n|0$%E761Vq&BaPrr>?z1eM+8UbSb_XaBy%TRWi)>e!G4tr zSdQ|=Dyn`a6h)Yqqk%Yw$>4u#lzWA+;@~)?7#^2NI)BMb{%YI%pvi$CQOgLm8Y^dvVRW&FscU?#c<`+aOkr_9>MY z{AB0(o+fXkQ~c>nx?uyZaHi`VoMKw4(lq6g7@^ zJ`}9Jd(>e28c)#T`$n{4oWv6+?zI|cq3!H?G7FcpGnd75)=-hi6Ag01c#I20GEn3z8kx*ah}g@#k#{0OFg$Ygr!-uT|8CLT(%PMzVpNoFpIORs{nj+t)$PlSHg%}v6F+j^ko>E?&|>x-8_DTRet*=; zuik$H%@zbwU-sy^f^^01!M0h6f^Z!V4XcNElu8m5Ve zJ`3|lQNFBXVXtCT3SCizg5t;RoW0dmz7?pA+9|*ya5{&)E{s52r`3~z+Fh9EB5|mf z>6D;y%Msv^ts@)kcgY8IL!yWyqU+?TSFVBcTQFiIFA4}??EP42ud}+0u)UX-Y zNIFS^XV;#ZN4?Q%Dv?WLs-z;I6flfn5JV2**zTpnn68PNXTq2s$_CsQyOj1-`BXIa zmNuUYz?67+-Q$l5)C>DFycr?v?_kI{Z~x)yQGhhMqEU@>Mi6obCq}SNidHP?_6rYB zUsXyifQO(=@BIN3Mo`C$F;>z_U62NaC3P3xX*+s)S}Yce#U}iBt$n`)D6SArt2Zq@ z$JnriTC-Hz!EXkVTM?9!n6<@KYK%>`((L>NF zsSV=sDh!^h`%!Ki!TrTXO)i9D0wG z<=)t^D%7N@_@h(TsMHP=*-2o0I--J4Z!B|39ifjFF8~McWoO!1YDyosR&j8t3H|8d zmB%4_4_ys8`-pNEO${F*Ma4&sWb)I)QqmX>ECgM;G?8iIMON;z6}JmQZGYZm`VdDE zjiw%n>85V@yYzSCJ$XVr?zEPrkNMY@#wM!HPZkW{`wu9FW4iPq>H6f@j=$I~C5O8= z=rzqUiIV#fyusJoM`$@Nb)+H`EwdeQbAiP()VUP8rXXLWm|Q7iVO(;d+uTS(lKn2? zwZKejq4PjS@O|_z%UHRc!9$2d;nbV^uGd$Q8*leMVL}HM9g+2(yGxKbE~Vbr-t14h z25+zRUyyNyiZSHly}d(3EAB-iEZ^Kpkl46wkCOGM-K$d47GL2orHUOl4!=?Kr4A5e zw#MLRTR|lYf6A zv8s4yt`asAm64+2#I7a8o)A4m$&bjR%TsygeMfLW*&k51gGafQ@gnJLh_-b1O zT2pOv{K4WoE~|0>U(R5$4iBf6I)MYH^aj$$t13F5O+%Gc?;**4sM!bO_?v-NEJb=B zz1O;%DRq?nAd-!;W!Q1zT!azpk1r+COZu9o;Owu8? z`8|J@uR{#|EQFgM_T1a4Qpezl^NS2lRj`}!@C2O7W4<-GGpOb~6;TYB zxW=!2dOOUat&mS5K7Yg{cz-Q7A8g0W;S!tpp&6hK>5BoCd+t)qDw`mN{UMd_%wgC{ zUlwC+5yAlZE$Z(`7Jeka`6WR2;PYpcY*lbW`dy?~_z_KWCPeWYN=su!>JSG1BD#wdk{~hHoi>Rh0INoQ@DqqeG>=o=B;&4vQjgy^HV3*1c_5~Li(ny zEusry0P*EJWXR|YfAsdo3s4PS{EMOu@}Ise}Qs zW%M7D=igsN+afg^lvLG#S&e4eBuUB8n6xW}Osk!$JoMJ-XZ=TwYWarN?~H{t5%s>e zU;sFNmQsgy4<^!Q>*NQuc}njOz2aAq(9@3w!^Sz{>eh*(0}BN`Wn2bDU&eNq6k3=E z4gO-i?DQ7aN5^m80UUcJ5a06|**!MihAg2P3^&|sJQrKY3M?34UeIPclkNZR1uzCBiSS8`ob+kkO5x*@qsV>emP|Yaz8)XN@)Mq!=~;{IB1`&zx)AR4 zopIrQy4RL!#mk*RVJrH4sN7pfSZ&EE$u#|C_zO()ul*5Ao1?1<$O=ygkL$HRlELR) zoac5l)QglO)8qO+gZaEyxTN;%u#cGFAZ1kpDp7JbWJ<2>L@WkACgDHg zIlDw&(2E!1EMsOFVdBKCk&>p<##dNDQ&7g8$ z(eM?Ls0TJ_?V%yVN5%i<(DAoENWOA54!b&qpx$=}Rv^$oF4=WB*4?9hX#;rxZAS#O zy^h*R`X6((z3AgHPPIxm%L}v3FedDEO(*@0PcC{J$8^}dM4%;DkfKzG>LgITi~fe-uN&Q9klun87o69>@GyhqV(r4nF4Eyqb7QU&Bc6u73|Y(2EHH5{ zET<*P_ToNqu0(i!l8hA%kK0c94w-*a>7R(*WPdSGEq6ZN?R~S9o|aLXN0zI;c&-vi zHR#o8-wfEEL1H|vQl-l6yjQ2>Wd|o%VIu5mBkPAQ(Fc=N$u;uGu#o3!{pYssJcl?_ zy2zrx=u@UNGdxa(jwy<`v3A0no_`5@`MXFwdqFsUsPB`7vMwU*>q@+WNmz{xF)${3 z`)`6`b3u<|i4%`thtH1^be_XW?JD#c0)4uN>^|WLV@Uq8d17?S-c?!@REzbyz@C9q z52X~sk~l~oQS=$0LVcl7et&n3W^A;ypP|?T=kPE1i*^IAzm4nQcv9bgW_BR_gD#(2 zH-Wt1rTVDBZJUURZeSGY76?7wr5_W?|t^dB~iiSC} z2AFXZd=fZjQ}wfH{GG^7?L9-}iQ}?r2#Op$ZtR~rjj*L{bf21eRq7&I{x8c=HV3Wd z_6;~OGtSgj?;M!#HnPYt>D-FX(i4PfrTeAaIc~H;1y5X2rzNKlsKDjjhD@I#66`y0 zk|+&Bue<#T_&er0UT}axzX|T~L8!~klY@nZGX}W23G1ESkpuAqRSaKkTlFs9gy<{< zbUT5};1GZ<3(Gff|71&eNk2FN2oV}TkwJXGpYQUF(Exss;l_r65dXf`HRrlP1N%_F zpq!EoMK&B8K(Mm+?LPa561_I+zzeAs)-fZxjJwJkVi$k%@>dg&`E?gWc-gbGZflDK zw#xdZb-u;Oz*DHMVMpjeZabAF$Kz$MvQO~3fB|6Am!8Nm--k0&B*XLdtdILgCd7g3 z@#gr0w;4-#$7crm=NfHyMSQ>ebVCl%HA>~K>*p=CJM{coU*%B;Ql`C6CNHt6nH(N! zeWj=0A?H?2P`E}Q8MY}aGcCL86FFI0s!2^Aqn;~dxWcMFRg<2OLPcAqKBP($yqs^& z-%b-)1$}I-|9Ir={;P^46!u|;5 zXyHr0>tqoOWb9b>&*hf5R#xnYM`CPQJ`c5MdLzLSr4LhucUfekr&Ac!v_5?m!6)IB zDC@~I12miwQ)Nw1kl%=Q)HMp=xgef#XTqVfnL5cehrZ*l zBuA%3lWk~Ym@8wU0bbOlvL?o@sOFt9X~H zN8GQVZ=6zT7oqnn@vGuoA*#!!RfB}f;@K)ol_9FO!!(TykQr+~SIPyhw*X166ufrz z>!_sWiHNj%6n4rkp$kIj7GFr)!03Z?i@t7x(U$6VpuucK|Cf>bCYJXuYx#sO+phfI zU#YB@nojUQ_<@tUf>98C*mqO#Kb4n>1g-giza+5(!Sw572zyXf8sF$oEK^Y;raz9( zT>U?Vq1FNn#eD<_9{y=BU<>jpp$;ze>oIpLz-tYv;@#eC`A!CAr3$r*WGRtgJ>9yl zG3clzNU?S02Aq_$mL&HGNq;?ve2E$qGNouK+Aoy1A#*r4jn+ z;PKNXZ|?;oJZNReqS#M?D1=U0apdT0SpN0QrEMhEO&&ezGGtso=5P)ZXGwV!vAAR$ zh0^HBfgpy^{N11a3?HPqxxPlcLXo|^9^W^ts8AoIPb6RgRmZ2tQ)<-Abu%^6i^|`G zhZ{##fjqd%`&`avW#@GhLvbTDIia(PJeZp){^1yQ&WpaGeL>F$jLx$-%c5IToc>g4 zP8w4ud{FJ*3uR?S98gyVR5Vh_4=c{Sd!nF*&NT4~+2Qi$xY|C36H<*UnxjK3SsHhp;|hW}G=OroJCs zq^=dW`!LV`86L#`hc@ zHsXK=m(<6A?lY8IkZ9MGCViOV!I75WWCoh->7Mk&t10= zDX?Ov)PAP%2%vQ@H_6zNE|t=U4XcGmAQsEd`Jad%`%g9?SeYUAf;b5*@~Z0xOcxFz zs!tcgfVpmYGIY!bg0{rXz=pwC;?Trk`IIzq{tl9!GALQsycyAH`92_VeW0@1)ZqRC zuhXqL`ERd^oi^Eu;63b{98P5EO3(q`@qfXttN%$*2`ETeJPY~)s$h69`WKvg>j->~ zz&q^KT_cZYmlj}~V`p(Bx&ZmPRTU@B@Kq(^hnHbF)ynd;i*%Q~$Xu=+1`F#yEn-qs z6A{|A6GGkJA@&YOocKZ#8(edf*Yz1`oF3y2XFOhiXA%FeHvOjBqhx6YDo2s&y@hgw zZo+Q2A3D(F;AKJx72AK?_45y}Bqo;8GwOVkR=55N#PLzZPf})!Tw}+Vq>4Zn{`3V$ zbpFoQa8fI`Iy(|r5D2SpZ=ZLA?`D!u&s=IeiNbY4(pTMpC?DMw;d?hw_z zPcPhVEHW6U-h||Nkt78?V4LlHiZCkx2@fTDtI5)uCqAqW9OirB>n9WG)5-ZVfN1{Z zUA%9rLsKoS?#r;Qv-k%5qZ;0*Uw0G0L6A)`J%Z;7-))bnc{SMcz=>;z@(afN2d#7* zC{|0Et!wRd6q|Qq2gU#{^oRmz88Iz|LMhuemPm>vaBB*Wo0VG&LuET(zV&r$uY;jj zcT`B>UG;<|EqwxY9B=4UM-HI7( z&x-8%x70K?_Ll;}M%nAmP48l3!?kEnxOV_jF!*3Z>?swULZpq^|G^zweN7EN<{`5T z^>e|RZ@~Q(g&ivxU@azx^PN9fXRN@CLD(ET41sjKdDc)cjkX6{3OqGPlYtuOcsr7W zT~ke9qeYWh$?`68r%Sem5#9sa;zeH294U2e$Y92gCXHW$nCj61Dei~Clem7UMH)`} z7So@Q0{{6xD*>l@AL(uaRT&*H<}?J~1S^QG)>C^gzPVBE*PZpoNCjiLPM*_;3_A4Z zO@#UG&PdT~sJJEon}N5O-Cqcm$;DQca4NjiO&L8fl*O}`>g#8$2LK{%j^V0VgGxG^ zfvCZ6^;EW{H>=hO8`NpbnFsh+D5sm*)kY-;K9m`J0H_q0Gj0f3BIvz2#dDSao(v}N z@1HG;^xDrB+tch3CfF8&4{$gP9Ch+`fAOTI^W&<@3YO19ZKv(7as#-n2rs*sYO_O4 z2KJnI!TQ|Tq_nv(gHdD!GO=DIW#lu<@e(x|_Cx!ouQpB7F^|%Z#vcX+%K|)}jL|H% zI4&cRW*VRY*8qGco%0n8Hd}-9GHC<-OI?gDGb-%^(>7>HSm##V$%gP z%T<%^=O|Gay@5!YkuVt_aR6z1DUmSRzA%oX8=ki0Cv$OA4Sr$;}5+OFH2&OWe|~m^@02>YPw@YJ;_Q^&ZaW zAZOsI0;CFTy%#3OGj=+$1KV!oxPGAk_;es+5JF?=`fM?o{AKC?92@2Z;`%m zByRPTh&t#q+y&C{$T8FW(VyuL4H>7HnT^_S ze+A4Url-0-GGH&C3el2>$PMsfp-lZ$ukr9{(Z1-YE3tvyzX_9j`+wEYbt9qt%ZcG` z!d9%)bU@0R#k7V$qjPwk3Dk%LEJVcFENE7wgHaYVjDK z4g@m0-9>w*QZfW&R*ej%OJ0L=;Dq?NW9ThE@cCkGzyTO0*M3^SNC|5pR{q$ zoRY^`W_Z<>Z^~JglHtJha8f%cymQZbi2!9-1Zrz>TuW(yfa($qeaS?t_5QJ&#OFAF zs3;h37b%2Z|2>7I!njW%`P8{)9M@BDkjL0O$Y>^rB>lwc~# zMYKZj&RUbu*ju0q#H*Q^k^dCA86G$0lyLM$m|?lg`*xCdJwjD&8&riKZh+}_S@Kye?>fyJ8opbxdhG=dH38s6Dva%HmXARGoj`XmB-rn{R<9C!()m2B z*op>)w-Ti0z#Q#3aP|w-y86O7q94YkXl;0EIp!W6HqD-9&z*X>;1eN|vhTeLZ!@m? zy?S@92ey+7jE}O_dbA}nD+H&0N)248TM2}N;c1HoSxskI2y(@;VqGS9y*J8+i~y4tpE*&V6{}8Bkvvx9h_+|Ze2%pa zd1YWp@cc``CQ{hfF(j__k%e3i1*O78oW6u3x5@WdYxcHC6nRE+1hhg_I)c)yebEmR z?7L2^WfO^B7i?``zTAK5`r#aPY(s9vjQZn(G;ye{aQVaDUV?;X}y6RrC!GJ+t|-QB!fhl>{R(tCX`*|Ma`K zqy~FEQ{_QQRKNrFbN;TihXd*-0;%wjqLm~KoUlApoN23?)t0GM@TXB*ckc(*T<|;Pa08 zvjPT+ac)v8=(S|b99sco(!W2m-mZ53UX5M@DHlRN)Nly5Cy3!pp!*hKB>P@}In;nO?Rv=U)eRjxOOVQq;?+}EHlBor$orM{rhrk&gdLI+`(Kxya z9$~zrC~i@nMTI!KakN1DN>AP9mz>|oT4KtTOa{9l$TOMBu;^Nf<*wQ!x@-l+zmi?F zQOK;S!gXqOE|{=FBwN{htJ92H+2$rX?N|Ubh8xGpp7*h^qUSn^&p%qwVX;LhaE1bH z?))xStFT;3A$m+{N<=!v?Ynhh`C2B38l7_7BjymeL?|it{|hh#(kGCN;xavm3*PjZ zR$OEO9N!sUFH+66$355Nr_vhs?pPi}xSFk+B9q7#_!`e5)s~O~6|fT>{zGXyE6 z)gTR5k;doqwr>t5ibl+d7rEa&hv3MXu7C%T3o?7omHfdPB$1~tq{jBSbUDgxi3bba| zfMo)Z)j-lXUbPnsB>o?EdzCXKS z7n;h0a#Q^F#p}D&LDg)o*+L~J781`b$%+IoN&%uMzixtzY*v8q@e$a+Y1u`h7 zjCv-b^G0Gv)<&R^J%5RRJQg1&54pZHKjt&{ty@MT|4u9U46ifz-W8{V8zund)rzY` zuMuOYp8TyVz#~;O`Ho=cS8UUQ{TjH`=nR)}PSP)io z6Gr@D{qoxBWJ1zA>kvvn0VAB-*<%&Kf z`w6Z%oFsb9P2Xr@61Ox4m+5`nE9K`F(>p^IM)9IR5}li=!I0_j`;TY}YmDTxU%$ai z`SXEE04#8!GL1n4qc`C!?R9uSI(VOILJ3sNoAC0L7TGl-f03hB7px_E%X-Ed%rup$ z^Xf=o40Q&rYHD#whClby+IJn5P|!~$$V?+mt13azr7{G2oQI};c-VzL-yhC-;YHiOC)KqhEv1eqUX#OOc$87ds# z-jlyOM(kY?+F8)IO85^|MTvSwGB& z^8&7IQS&FSQv@>i9dz1hvOLXjBS99w|I~O7$MP7=W?i zo?(7p*8%Ef-B$jYC{`R}x!^wmAA_8)u5aJE3CjmNXQDa%ZP8HA{<`#`j~?XwfZ3C7 zh460tUpr$57e={#s%xd4qi$j16~cBI+-Ov!O)_6C3dmA$@q98#qmBVBzmr!aFa6Ks zGvQ>PX7fjuMipgqfORr=1*9gfGV^_}d(!{KhY`OB+I3Djd&JU&3?S)R5NZG6CP6rYC4b@?fxdQ1 zmQ5x7PI;Z)8f!SZELU2cdfo}K-uaAh0Yxrc?yOPyg@*ooSZf1 zmVaIyXPmMl37fWh1GLB7C8c1(t<;QS344rt{R9s{nlj!aX+x^90vCq6i3@TNqouJ z^%jrDaw7+St1*~l5KJXXkd-ed0>D7?t3>yG-81Mj@0QU@w^y0u0n!mWrs-VqFoh52 zYX6L&{r9VII<+^J@228~m|6_pw?{=RG)C3@c?3E~#MdpJ_y2Nm;mv^%2f!A|=X@+j z+Pe{nDf(R>aY46R>HvS=>}jd!1`q?+a1$qAnl9hmUU;LK+@ATDYd+)CuG8iS4-npb zdsi}uI$*w9U$hnWvXx8wo$Iaamq>Yi!X;~S%686+_Sj&jWQP658$9<$CT-4SprTX- zoNcikWUS$_T3-*E8WYGGd*egQU_kbGM*{|Vi|h)sO++}L+teyR6sd_g57bHU#tKx@ zq0iw2{~?N!sz9`r_@O9#o>$IXjX@^TB%rv6rcACL{sHNWm0_BB?Nwn-&|YD)gw#y% zSHK{3c5Ol04cMhX=>Gh?QC$daq)RUwE=MdY)e=t9@Yr!G5d-3sA-}R{gFd?)K=-KnETQ83`(d7SC?)YB9U@%=G)?j5YhNM zoX36pDyz^iM5q5R3&2hmJ@lxo9gzpi6Q<+H=7AmaPI{8=^=y-mY#-aV!8Jk)gt00N$YS!!~5gyyRy&6CA$JwEqVDftR!XyB#@(frcFQw}@} z69Ub@DC+L&kAo8*NV26~f4<2p*Bu8k7vtHhSq6Rd7aD5VwDX>)gfJ3QR~h9^yg6&y zUJscD7w4q&?!q}uAvrH&8*w?N#p49@y9W0K9f2})1`_MykwzoKQuG`?i+fgIlsLz2 z^eh--5p&t?FXPUrUs)8rWjzc75}X#F%NgPDcX7+DSz<{4Ks%Nk$iE6v(tgP8>@@PE zi;Gzw>!&R^xE8cHI(|ij7+-viQ3!N4ZvhAucSaAzF*q_&$9_Zd$qFjIsh~qoz|YvgH1`z4r(Y&q7lW7Md4JuD%zEj z1@t`;VEbXm6W-76zMzGLfUWCb$q-HAqHS7E`%;9RODn1#U4L=MEx%nM7orv_IK6lN z!pkIgYOH~eH!OdsnOeqy;!l@}41yMJk_WeQ0iXdu_KgoQx&^Za&aK3&#wi+eg`UX+ zJ|0!RgK&AT{j6(_F=&Er8po5$pJSTj^paw|nmIhu=6MW>81&#Eo?HS2aj7R4CcthJ z40yn(t@~6bqn_{4qiS~63@uRF_7rTtIZeuAKYM`=k<9m;KydX~c^S^HFStz8%CnWQ zVVj#~w8s>l4gEh~P=WX_l@h-31+kuAk&jo7VqdHNXk1p>xA6)tx{S88+Rqyx0wh^l zjqgJbpYvY~W;0{8#y4JlQ79B4W3b8D@USZ<2y9>n1cVHk2rfDmJ~D)pACHE1SS%GEMb< z>)pR?<#b#5*O!$+7bYtq+<|4P<9@Ex^9&1c9}Y`S{!CPiQ$Gq#HFZ@eq;fBQ9sk z>s%coh}K3v)x3NME`+zOe~TQX4&q;kcf&Xnj-Eyv|LZd+VuK2Rt3PA)ZbXRlOXzei zxYao87vTJh)v#Y+Lw|!z%LZuI1|nv_cL7Ijs>zd*^NNJ>Jy!gizDqMpD{0!{2LDPx zKF<=W)vRw&q@rni_QL#1KmM|OWPYHj8i9iVNs=J4<9gyG5pyc>IF7x8H#E+<9ImF! zzS*<`+1Egj!_vRh^Swg62QA~yGRUVQq};wN7w z)Uk{)i&@wUwp{@y#DmasO!|eCYe>`WE5xN^mut;hV2pGLuvFWB$Wf%UVd6F(U1ig; zR>3a&4!&auz6V59ty)mW#X2oDt3ZRbZXWIOlywZPWy z$d8U3N{-zw{67EJ^-?aF&2p(QcsOU5$3WtwiD#lA@E2u4oXGUQh6oT-UEBAEy}Ksk zvU4l+V^Zd!iC#+u90AAyFyx!5EQs?*#RTG*M#flOA)uwWxgWKBnrrZwjN?>$#8OM- zAc!EwH^BmCmIYWWyD&FwbeYBrAJwAYckqFOzv#c&mB6#YOP$#?YYaXVlZPa6X938U zn*>!+YcNsDKqd#_RahwX8aM&csORnncyS&BnmT-lu6OW|Z%ecc;f0`>cwfT5*%%P2 z!yW|(DOW~JDtJ;QWs^mGecwtx3?nJ)8#i!0ts07$1((hGs3oF7)Lsg_8M(s$MYl3O zuzXx&+vNv?38Wws_E9|4iVcP{?knY{X)0}w6#@_b4vOjHV0`mgHH*J(03xqg=dbr2 zeXOR?X9Y_YF(Bb!X~MgKmoyYvEot`@ZLiC^LNBg<;jfR^6HB#3oqwn39cy0Osj6Bc z8$SZ^JAFhFITaAm$q$8gIq`UT@O;FkD^_U1fH)(`{lkEUL!M7w7w%%iMjrhn<$v~A zhI}oBifBY>LT=+H@e(3{DHSo9C#|H58+4xLMfWax%?li{ag@j(8*gYck)gH9krR;T<8&x)K)#xPkd~p zLrTu}$^E-|)w(;FdUJn-s5U}0wkh-y?#l!4>^>MP(6NOQC4v$c1sC8#9Q%vL?BDd? zT+uoQTf|ci(fCjmXY)DHLd+b~>3u;<{JlU|^neOqFOMgh}DA_ze8sA5pMN>%%nToN==fbL6;W>}YjAT}k zy3SV+lh+N6-DM zUiXr5; zlRCE>zu48|MnFSBjX|ywv`wOu2m7{HkqQ<9g08-1i`HcS?4W9VLmSWYD{#6B0|?*+ z*ULumV#6A>ouLmh&{RUvF4zZVzYZdLAA%+X>9H9M$P}_9#TP?MI>@NWPX&kYBM;bG zskmO}$i|M3IxxIT3UNM7e`TenwB!D&cT*X6T}zD^WR?C}FX|XObe4H2Y=`En^NsPE zp^`At3h$kNQHC2w)Q%^`;|b`c?kp^Z30YDt{J~airJ88B`LsL2Vx+DkCuFsmyBXP4 zNGb2vpCga~H|k@XpHktx0Te?`BQd#|b8r&r=UYZ0V{F>C^FK#CpoMJ#mH&Qyk6Vu- zr(_b9i%h*G8cVR#)%C`Lfd9RQtmKV&-G5MkapHqU5*%3+4R3Q8JdJcO=}R=t*Vc7^ zK>D4d_d~yv1DvrwQTYgAoGV4;%S5@TQW(r17@$VgttJso2QHyw9QW?fQM!w(QGF)& z4NsLEg#AdY%za%S^t%u9p&6N=Y*%YGG44kSw&~zRTbNj057z3b|M2OxJ^%w}ZDJ9f zUeG~KUjpull*Pae#VI2j@ReBHyZAJsfS^J`P^(Z+DT0YO z@Q{~q)PXw>+RW_Yv>y~iALo4lABEz&uPoUlM;L|5ebm`X?)teBipD*s1llX62R#^2@vG>TD}|I9%efo9yZ4 zaF@-gs}I^QfZ860xW*PQeYq>|`Duhc{V=$gzI*yO$x~*O~$|ojMoKP)x}ic z(WG$eZKGAJ!PWHg;3jd@tY{vr z2c!HdXiLm=&$)k;T=4ViBB`yC%WX@KPRJ$8T0^xv#9#dLJrM@RKRXVZ=X@YL%`*U_w)v1TvF*H|BhQv91-LX`kV8e+xYHjbFq&JvTt!YIzHT8q+MfK z5uQI%W)?DLJ}D8xQ{w|#+R!SxeCj|9q#6+aMEuKFhnq>+4_2d~&{ggYLG@+>Nz$a- zE=hl8OX@4Z4j3$((=Dd}7XsD0mE{-Z`XaFi&$8u4yc{6nZ=ihRfAc_p_S8E zDFA!rHtwhSxNHc$seB^tzi5IW5bZ7`{gHz^VWoGB=J0`vBEz8!S8@MZOq~6z8lE)p zoR5iv7pLODpn?(IDJM>aLT_93>aQotASd=t^2~x!le^9`d_16=#7m2$~;9eSGEL-JqI#fol%r&T|>yzRetJ?7arX+Zd@S*WITN_eytYX9aVrK&hJ__6vg7y$4*lgL)>qAav^2QW5<7 zvjeI@?3exgcPLVy;0WgN*<3XR^W3TdKnQ6&zd60p7*`yy^Is3UYw5J=Kk+?gcx|1E6=nHXs3nc zevSOuAn)6sP3tH4X$J|8Sr?cmFx(Hzq?u^py0j5*iz5E#@U`8@FF5}yNWfeeA+%iP7XGuy%1cao}6mFA@qZ{;HU!4_M8jr^PEfuUd`DAwS@;q$Qw*;YJ11$^n6jGNi>s8R9=c_8ef#2g_ z;bpChs^+a8JI#N^p+dufF7P(r9kJ36tmU^{5WTF1(i2B_f6#JOq{&1&|Fz-7=m!@q z6jPsAS07d&f&LNcVHZmaZS|IJ2Yb`qjOuS}4;>^Aai>DWkh`0@!lehO6xbs2p%ZK= ztHRs@HTjiv za`P!fDd`J6xasC8eX3)aP}mOe)Q5gnTT;gvwpRIH3m*3R`qWKOJzy$y`?4*Hii#5q z1Q@{iry`kg=PJ0NA|H6hu#%3D$9DG#nQk!M4XRK1qe@J(oGIoFBpC|$f4-(F+(e(3 zj=Z+aHw!?iN795T-mbSRX18;4&YfO^aP`Dn`L^`WL9{|isQ+rOSjG~qWt4Nn0o;Fm z#gd9JdBH8S4|F}et8D4fC{zqsiW;rZsWmZzg1 zC5v93#*eYmq|ky0=w|hywv6i-PC23P=?Fy;(sV#KQDbPaIwT>-x~urNpXCCE3WiIT zTl_o+VE}!mJyPFF5Xq0szF&;(Uf#V8IMFhAfPcA5)D;qmXI98{*lOB2qmE_;r<y>@Rq2 z$jbqAZK!S88!p|jdUo580$?kph&IK!OIGbd7MjuWUtHxuIp(USWV2&t1Ir{7(j4D# z>R$Elr>oyyb6>pE`y~TbOgGV|AX1T z6^FDhLh3-|c<;-(_~Mz~?nvQzO$7IYxj21#+cSZ?1ETA1;sT)OQvK8ql*zt(`=||j zJ#4I{gI>ceQ1m|?Jj*!6=30Rc345{=%Z z1@$9UN`NyUNCBcZ1Ca4e!htc3Bzcp@mO((x_6~bV@;(4CDHap9;Do^gJobdMq+g5R z&$r)lUo*O7@R~P)LV{t^;F;!G=7q28jT(0UlP)t0vOt76UQOP@pT7%lJ&f|bSJ7L5 z^YFt=&3W%^GqHTqgo&)6r)gQyH({aLMMb;&vk1K-xDflTo9t2>%-bxaaE=!gMC*4JbKY>*odJn&8 zvDBUXVtrax;q^+&?1f&sf|pBH6)CbfaJBi6TSW8%@8+<>knyo*~4^ddtxW#Fobmq5Q)u zgI=#rR!`^rB(Nx{6ap^m4-!hRZEa7pd}s_Y|6p-1?m|?yhtd>t4VYQ+MOcr*>g{kU zYWkv`!y`wj{U~W1ZpD2_?({mFiV@Ur4WUW4)RYz41y!gYH_a&A`Uh_1Q?Wn>V_~7p zshid%--=?q@PPs{ED9)_6u!-b;W5))6{7zbs*2`7Jd zGCAW`F^|i}EYSq!#S1Y17xK&M^AU-PtlLvKQ7vG&iipdU=yFRd;HCF=nr1@!CE7~ z!!$Ba61}XG<{@20ch)HRe9z-bInX2-DYh?2UAy)v=WxLW2$f(34`!Utr@9ePNomv~ zy-2migaR!U-!E^Reb6jOHr%xjXi(U=7_~U}vN}~eqzc3;l2RUE%Y@u8O8-=!@7r>L z^!6@wpLr-2IKCka@n{epPPYQVdLEA;Vhf+r5Bu-XT6((GTQsAdL%OhupP1xSDJJ80 z4e=&tp`KnUU#Mh5eaE~EkvgJmQ*u|i3;`|RAW;o_Lun?rh59~iqTLIm5%%XuPi*gE zJYD>KhO-(j5%H1WIbjN09={i-kjYHpU!^FuI!XfWjED<*=J$(->5G4bBjnmgzy;ZO ztv`R5h7~Q)Zpe2K5s;MZSk7Kph#So;@;S2wnH{8mH`Xb?xcP0k-z{nuA~-WLP~csm zGeiS@e(sQoj56f)ig3)UA)LQ78*7?Ekkevr9bMo!UER4ol~+XZMv`V3`W| zie{T7W;?CKR+`20lKjv`0XH_ZuUo?3C!-u$0s^5)ZGW@MGYsP)Mw{;!jm!EkD#f&o z%2wHQA2sS@fG*(f|QR>atGx8!^AV)l`WSLXqd^(-u%DUz0}A7M4XW9EN?@C^3u0qEDSZQ% z+2A86P^y2j7J-XF0_PdRfYXFuSBMFnmZ~##*dB$7y#Rwkkj&$X^?3|@!9$1%ag)zCyJL4P`!6 zuhL^?4&zx8#)Q*7j>0lN^3TZD5xr9{2(+-X+yM~G3A=9ucv2KF)fA8@aTsV>-~}DS zVFa=zR*Vb)MvOh1#+Q1(UI*3L{J&m+pW;wt508|^EE7q?6RZ5EmPkIH32<>*^a8ai zm-Qjq4WX8lo2Pb>sn|FN*54A(ufs&B!Y)n6k{t6Z1w8z}OTWDoKQyaUINac>wi*h- z5*47M4dPFiV&E-%=D5^j* zhc$s2mhz>z;C(A~Zi^#7_&#~;{jCf!g_OBB7mf;wyRhA;d;7|GzNU`pkpDK;0 zrtgZjdW@^ETi>;MnyvEi13soTgi<7OuVCU8!gkh3`^Gb?exW);fj1w&XvvFf=o<~N z_j`S3DB+>emyV4+?)o%d-qm)l19`KqCm*-U$UbiwLNX295_>wZ9*0R+QKra;s(6#n z;+-FwJ5VmEw`L7BxS*E*ARZu-MD^GC{PB4zp;>xpG9%Uu1G1@egLFwhvb4!0$HGyW zO;m&hT($CvoUcb> z)D@`{aH*K=y0)*bOMa9V3$(~f_-!6VR6WF8JWmu)wNl3}+>!GzVs<6M8t9z#ok z{;Ju&&lQaleCopqLQR891s(pdOdzo#X96=fKy%2<+8L&048COqBQ$IjiR$dlcAlNu zb=bAHoStQ!$qlW=-&}#kC)sD!F#0 ze@tW*Kkn~YD{9BDUtvhIvbC!7#Bc4@+3PwMnNZBp%MoKo{p2=(&WDlx8=4^+aef$+ z%SsIinj7N_&^DfOIsV*g<+hNO)BH3NrlRm=_r8xTF{_mzI+duDWxaKMEc5RXub|*; zQ}BqaIpwPs-tkTD`q^|^e2*q8?DgHH!!!N>Hr5xaEZ9Gxjrq;>nz^nXDg z1s|OLu#Jek-^;Y5QGw|JLrI#>nc(fi^-6n;Eh|fk6>UtV=ECge0kS9IT|12;)J+T5 zjnzxz`zD+IqA=u5ZXafICig#FXk9{TL9JCx^#l(%H$MgPP;X%tgjd~-;H!6jVX2%M zzU~W)GiH!Y`|L>g@+Jg1bN&{+SwD*PU(A9E)M!IcosIu}2#YC0*f;=@ru>xENe0#q za(nJP#lG_F%)jLxqY#NLLXFFsiY9klBSw$A1i)|qr*$2HP2tcBH-AWp<%CaY6df5* z*~rbw40M@uk^aXLMwYyr)B6)$<9Q;&@jM~Ct;aBqcym{#N;J0wR3c?uC_disy>j;< znWzd$;&wCP07N*lG3t~E1olhg2mw@dH;~ihK?=8btZdxzf}u(AJgT-<1@po25IxGY z>3LM+KpX{S2Dc*LqbKkbDpUMFgg{JaD=Ytd8scu7>3zZVOQAT1qIs~ZCG$ohn z4$&WlTK2>R6Lj96g;gByWtVNhdiY|G=HuJi6Jo}B8nq=cnyp;)^Od(o# z=4gS+yx$&SGRUd!+Xna))lsWxga{QaJLRTh6#ubrmvRU|5i@Xlf1;Y9Nh_;+5gnv> zM0udcaLYdoc=~n(h~enZl?ppOuwJaqH*P0Q4-9-1p<1mjq7VuwyFvc399SnqAwKs9rR-OAC|~szsqZI)BWVJVD8QwoG)t z?m$0X;0djjv5hU08`<@EpMcKm4$q?{caW`b#;z7l5#{KFU_b_1u*IQdeWIWTII%TZkVv=F?TF&(9Hi!G}_}U?_Q8@q9KwUE_Oj zEYrRJm3#F}Jh~q&ahG_Zn;)xQZ2fhtS74n@kTF_0I%PXIzC$;k0CK=Sg>2x{GG4$V zw0C5gK1+y?y>TjeNEtDPo@}DZT!Nx%@(KD0GD$c9;Z%rHJB}Jn*;qU!+S_apUr$%d zOUQ&H^M@__D{AmpZcZo8??Ym?BP@&`A!#0l5sP(MNG)Gj_%tpJ zo@d(H{oRa?(>QMH-Co=lY3(Z)f~bg|c)C1KuIwMOI%Tcd9Jm zGO5)OQa*C1WK;+}b4O0g8xB>w0rnk7;r^+T%SZNJNg|!%aoazB!(-u5mE?^^8A?`G zrboSxF%H+wt%GDQ=5at$5mT$sn!5kHC7h$1iyc&0%KNUB(1knij?V13;Spbsdg z$7V|WvUz0K`DiVE^N|FYyRq{>Ok%K2tv}mVX%=FKq1g7}<4CWs%2r{%peYj-Hpn6>k)=#8;s4Y+(x`d7bD9cwS++FYGVja!=O~Mu56q2m7@#KPg?b#cBT~SAR zX2yi}5~G@bf^Dii1NQg>DClHt?tmuK56~UeT9vbMXr*Tyn;R5S3)wLpE z#FI+y-}s!%O^G72__@YZ#jI9J?gWzb3L&feZ2>V03^@m%5=a#45aFay-4&&Nr>PUk zw|Yd`EH(5wb7OYiog$--sP9pw$N;iI!$uUggAGANs|wISkZdv!*JFrDH2V850z;a$StKmC9DzRs&%{z`t*a#>}Jq*u6{> zZoDZ@U5wa?Yh*8d|JD&Vn2h|2yNs8_b^efd_N5fKtQal_0Jz}$Hq~}^4gFy&>^;=D z5Yop#zex^}wOyzMf$D)i3Am5BXHgUO8peOCeLeiPvDx|Rbs0Z8J76g>q^!hiQ487b zh@;Tu)Z6jWHU|v1#T~oxrFt%5EXt5<$6i-{BJBQzIBgzcA0@rGZx;c_XScg_yXoEg zDBoG-L3D)&BjM-@4oyBKls@P)^0_=fEf@}&_xP8HI?JMw<|v_JqCe|bGIl>SfJaVQ zr|eE|=UI1!L&AwdH@#!pBK+d`=`~CZo;F@(VO6bDY|&@J;o%u41+#OP(iO?wWf6rU z;fH9A%_+_jvDP=+$ROikwtv@;ac?VD!Q>)Og>$=)Ls67wA;`&!Iv(P55@g?xD;NBA zk9BFe2yuZp3+NbRE3_D#g+bnkwmHmV66UYmov>#w4@e(!^|R73aTR=I%r72T%|k%I zHpud8$O69giL5Z|H+RO8HbY92Xs+*!?Po3SVcIi$D&a-*lSqEsBr2LIe?RigoE`Mr zqkQH3qA{iLCa|NR#;DiLWeC8`K8*vq3|B!7+f9OJj}Zd z#6{zBb91horPa;Y#K8$ovq8smEhj?klxd;7aoefUiS%h_s1sy{W#!}m>odM6fO1H- zbUd$|ES%pm8j}nE#k3{Ur1p1Uw}Ai&F_Vm8gQKmXE*Wgkv0I0r|JYB8Vok+KP~ld4 z_t(=BqmyHc*TaPovT=aT>Q6$I`b>w?i<+%VA_l?vZH|}wQZ^)NL0=je`M&Z*+5`7I z(i_o8f~P{-w)gQvB^r+85mHf;UH+aHont<&>r~Cw3T|Kj-k`-nKi`u{9d`oend#{7k7d%T6kKu@ zn@J=H6liqQEIF*$RDi-&t{i4pKT${k`J=lxbk(@o?O!`Ve98BBbSvO)wm z;nrakT1)?84f+Iu8?>*lThbotYA~rbI~NCW(myx-Fth-~eH)`jTjBxcFJ!FL{@Y** zt#MyuklyWQ!uBK$DtzYJGD2n95tCofQg@znBnsuMK0jU42f<{C^X1(@;szMpzZA|S zeU9z+Mj*M66h``W!|~o>bx0f0l{>b;h1HX!h5~=0jH>>O>OZD5+*rd4R6c6n-bD@! z_qM|d3MIC&KvMs!K!B%gOim=DS)<4S0$|6Q)6eY%YtNk{>68rUKNd-$uAN4#&1j(0 z_6}SC^G_ogN&!4G)W*ukY&=enqQ7qkPn!zczADK_sXHTCG#BZG=hekQFz4AixZ z_?});~m-8z$FT1g6* zKSuvF6F9e{K^ze+d}DjEHj2(2W?#3UwSNyyjP_fm2%x=PA2_@(b;b3STE@Vh`3&(p zJxp8>a0LVG@V4NS9!GU+&Toq5!qft=n+F8zne?wqibm>qba?>(nFY7fX1c(HyWf?1 zR+aL~##Kj5tcB;|yL2`prE8EMd8m{MzaO{O;l&mGL0OvtSzC8M7i_~4fc~UTc^Pc0 z2Z3D5R!SG2a5NRT=6-XcmV&-ZF@Xa5ARRA_{zX;S<>~idT2TT}um2)K9PDFHY-d1h zQ{_b5jon+8kK}YW(6vesvqy*q;QYECvSH>E{4dPJWewV`)`hYS;+>R??LQH+1OkXU z;);eX1%bq;E`K|!9_x(SI-I}iJM!SZ+p!M|2R{A<+4LJ!$o^{*BluW}6U0G-^Wo7` zKXMzE62B1o8{;!~?HW`n#_i4be*qARL>t?mHSj3~h1!U6Ktc{jY7F z0T)O?V2L>OZu{Ypk@f`86q_Z07UR=z;!vmzvsKLASqMam|5JABEQV`Gq6fJ}M*Qg` zkfx9}aWR*j)U2l6g?cg6q-StrxMWrXP!-ZIDK-EG{A{~V=k=F~062=&$;kQ`|LTpC zmOes&TA+6LIcJr&X%b&jWlIW*l8t}mH}>k0O2Rf*R_F8N;xI-P@!SN)2O3-uA>K=Yz9?USD z6uY2)&!;U<6ER9xzUm-hiPkzxf98tTHM$BMsZ92bgzopWecM}z3CsAso~T?pB%)ma zhNYVmmj}@KYPTcu{zQ?IkrsK0tlh9&?Q9C}3gM>)Z?~-DIh1X#!ZEQ z90Gr`#mL5wO3OE4*Bq$3WOn8^I>paz&uClu8sQd;GvZU6-vug6%TVJac9d$!FC0tR zUKa@ofS2`V_tE{pAncz;PNn=@10pEh%Z2dO&2?S&@}e3AFq!DxWnLJioMz>nG)H3T zTBZfg!KJHy>&n`)Jdr4TY@KkmIKJ6lz(os9QXU6Dw)3J5iJ=JGuR_y~B6Trq8x>&v z^)VpBBem^qqDZzKcAEL(;=U;9bbb_g(TlJU=h{W&EM<{xLh^PEC_A#9Ff*!9x^2~c z#VpW+cnpFy%*bR z>eA$)&QCyqDCqDXW~%M>cS)o7y*j~t1cpW=#gfvIT+s*CqeJ*CR!yow&t zQp(g*{0R?>4K}ZZfh5E|`xCuqGGhsRGTDXj+Sc?bZ)pDx{HYwPS?8yM$_4az7OV3; zpmg}8D<&MG`y!s;*C{v%;ek~AOt;T=!cME=W{s z9J75|xhi0@hDbf>im1~GWq)##&Zw z6X%I!ewTf(6&>0~-eoAWtt=j0b$OohT&GulD{4Qv^nD&ftifa`7ebb0 z&OBDzrs-uZc8S6BaKgDxTrjzt}k@b+px#}xC-)%f`h-ISn|tHnh3F4 zC97!^M2QL6B)(u}7^X>6_lbF{A{uMgj^UA-yC#gp5z2K~vn$#cO8b=E9O5sX-++6u#fqnt~JY*bSsrqXo%MZ{n zLsewosm5NsiCD{M#JE0M5v}B;9U$6~ zTz>?hGdNoDhYTYmNI1{$tj{4G8j!3N;GvK}e~dPkYKeapD5{Msz$eu}9)r=9h*?EDe&D(+ZRMuS2Js z5*E3Y=?8Y^$rK{})~C$WEH-5Eo{-WEuCmZ%B4mFbc*@M;JM=4|xn^n0g!D!c6>xnN zy;^rHy(_*WxQXiIM^(!PAoGY{F1QwC0iu6HsqlXin1$541V*x{DjppvTLpwJ7wi(? zep+>&yufyL=#BHdA2D#7IVIsQ-5iaZP-n-9B9@qVlL9S3i9!R?v=n80l&B@f-*oHC z&mLOE{mNoay!6Ziw?l-axSFwpTy48nh5{&}<-=VXu}LI+5W&VD1=|}URpG+8y{S|- z((3UGv;DFKkqxhjm+bA*If6XIqbUyHWsgsYaiByOO5Am+`9%r0@kTWiCaXdR!OiBw zls%FAFN&OhfixYV$bCNTk7XXh{QJF<#fQl1jJg`%LsxpEW*&l^v?~=-OP&Pi1pU7H zfPfEZ^|TU<6w^q84Q=MbO1wC-_N2Bbo(q(#!zAMa!< z3g9(UUga-uEWUU%*8YDnKiOKlfrnzgxEy$OM(oUe^@y3E)7b+4%2YP_5$<*s_Dd0~XCi z^)d1r^&`lThLwLLW)~(R=R|y1;;UA{cJiuC-bTM3D&(4y6|=+Vq#{Gb`TqG!!k`#F zYt8Whx~|%Y?~BOu_LJ;3(Yi0Lrvzaakf`M38-vY;dd&n6cRad}C&D60mEBiy_ez8T z3YPggvi=U|`+&01@IiB3Pbe;(6)>=pRl1Piqw^K|p{uriwC`v0pyeK)v$g7cavH@8 zBVND_kRF+ynYhetxSOsBqyocff~a(wX_=mR3x0N0;27?C?LBPmd!H{VJ{CtgScyV~=YX4euQ2&rM#eQGerrK5A zNoOBKFBjss#q`|J0KxM;M!&D#?#;1Path~4q(7I_dTrfUjR(58!o|(Sj%7JuF!-3J z+zPM3W+-rfB0Xr5z2BZbhXZ(d6z>87jfdY1rjtDk%=E)&eh`-B@gDl;$z{B*uS<`) zQ6WJRdvLr3%=!qR{s3e^@})z?dhyoKjcz?SBt}H1`3gS*$V_~qPNO-803tzHlkL>4 z#?E42Hdd~H_>rXUL<6GMf8F7jk-V3RKvy9i^cu;zm}#)IJW0R)eYq%OUH_SQ2%l2G zz3pAv?Lk2a)7c!9HBY&YJb=YzzqqS6+T#+xD+S{BnMJ|UQ=kwOQ=q$acB7^o8FutG z2-J_AbsS&k;Wv8V5*=YObRlTVdF?PvZI|h1`S9mcrm9uUvWK?s?&X_QVf9W24 zos|%uch)rv#%^g?Dr4YKaS`f;YsEXlYNxP7AP2;$-ZSG&ZI{3YMA!l#Rek#GtX^G8 z7vn6iJSAf9!K=y?nbpCTwAGE7X#^ z%=H6nsKET(gY@|Ws~*QJ8i6YO$N654VZy>PW-Jl;+YB1Yi?v1WvqZps=OF zv)A=9NXM;827tmJvBq$6-DCm<#~&JKCdO*`i++#^xoa2xa>((_UZ~`+BC{`wV}|0% z*K1*9Uh;WX%JFu)YF=)7i#7nUJs5iN6*VTM|JB^O@bFYI^L0X@U=fXxhNWUoW>$pN zRh9Py*{D|bQt^SV@+xiP7F?uK8oPYNBaq;uens1^>8-sm4?P*(Ap8G%0X$m1Tczc& z;>I!Y!E=9WGHvfj(Le0F#jus=&_SFaT9mrpf6IBmursPsW2;Ed==pJ-L3pOh!x zuQDMJPqJ^m#_Z?XVhEs828o(LLQ(*%b9YqjbI~gJNq^0LiC8!Z_YM(&50h0el6%6s z3eeQb^yXB<1q4AYCPQLvt__P-!cd>EY$mLFHOEuyda@1dB}9dR`0iIUUtO#BUd{R- z{B>tY6kil)@-7?nV<>oB+wY&38>RVb6w#)vX$_k@>M9zWPZr%~jrQYej-nzEp5-Tn zO}Z0qAun@qT?hxugpheuUc&&Y?r|_}9bSjH(ciB?0{M3Lsisg#zsLbuT!UK~>tuy* zNj7{?;2+lq7(?c51w$j3P9GmK85SlUXnSXZ|qQHeb9|o`CpNpMec98aMqL{@LZ{M0Sbw zmppBESy+LV@u6#C*aAA41HG0LIWmP3?+g`=;v_u`$eQ`f^h{x)Ni)%3A|6}qD)C42 zT>O2S<5`nP0pVkuQ9tc>f=L^Wn3M&0O~*C3+e|;nBGO0%ROTQGLb9EN`^lc zNLOqV9KHQB32go~1}TToKKJ3oIS8X8mF zcPKVFdZJ&Ww-~rcZ)4%8>P|oN`U+@wgDw1=QRO;k18INWcu!B7ZpjQHtxJxlwI7B~ z?hoi02XViNvsVc5D*lqS0-Oe2(Mg`lmetwhK*k86w4Iv^8^n)D(GY?LC2ET$d7W&0 z*D$K4OxXnVuDTvM49PT;$^2Hv?{W6-HML&(8VwfW(S5WB-1OvDw(3r?E-)pA#S(!E zVR|oon{UU#{(_5(hEW8}ZS0_dDB?-<__h#kVWx0`-0X-oE7|CTgBBbX*eOhSaZA9(#{;cyX8nv9r%0m{9ZunHT?#9*Sdr|7@?yH?Z;WT!CIL-AMxz%El&Al!=A1xl6mLZt z@!usawHc>+$Ln6!YdpBuQl=Oxa;(;WWxoh)tPY*B@c~DtRUKJ**VsBaaaU(`>T=28 zdL#FuhGRaT95o(Zcuzp=neCb+I*T;h3SSFNhyhM5mf2DdvT+WU!rt0KB@`Udl5MPm z#*A7xK`UJk5G=yo+8R*5iN~_wdf3UdQ2Dbc(?Fy8SC5r80g%FW3y1qj3)dOM9XRLJ zzud6l!WP`rl=h1&kml{z0MxA>R)=8|+PJFnlfl0yGUtH4@Ia*lOuL5(~;s1 zR35gJQ`V@mK4%GJ{QDW#A9n%!YBx{#J8S%Rt59Q=ENn^@p(ZPM(xt)P_bnA0icEZlrtL&J%B-7*sHe z1vu73LOlrH>6CakM8>g5KSQVA0<~*&1UE^jO^Gs^)idcMK9iT9?r@?7=J^LUn--(j1R1c{j zZkm3%qP?Um7w?EGUD3mU=hc4b8=Y>I;PbhbDXBb4+tl)&lB& zw0gai?cNEsJ19hZM>b8xoqhqZm|WPCM$s4JxBA>e0|cpih7}NxV^UO42LK=O6w(KF z>nTlB@Nbb@^JV&obi!Ra79NI7OiA=;0KK@wg;N+z48sD5_ zcC(s%z&s?U$#Y-1Cv#Lrh#l0_TK|UNNuJ?j=Szi7I)xOp{WA|90a^W=YZZT<2;tYG zSY)65q=Z0r!s_tH*f?~Qe-Xzahhl)GW?;fWW*X_6JrE;b_Yc=6kqm`^B(P`*=pC@a zB&&;r{?$dC*>Zc&`eCE$xxQ{b>XHOxmw|YS7|3uCX5HijmTd+0;?*m<^ zKs!li6Eztm*sz|Xzqzfndu141$e-c2lUztL;^|59s;3}8KMjYD(*Tp~OjB-YeKw$| z0yobyn(k*U7C>|h34#qU3!OF!KU||Rh8)4c^bSO_jtGD$ldwrI(T?#s;kVXAShiuEz!O_|*VIB`SNPZ=iFJv*>Ht%=gIx zQ0y;vpTs=9@wHIvyo>q8W0fWIi{D}$9P!I7fuy*!1R$fcl<@`=R6I%eOO^u4quF6I z6w#5n0$-u%K2aWoEdcGvzU`g?6q|&cru#6`KE4w1c0^#%r<}p)(iBE2{;fqLJzI2R)5PqI z!K2fz>-l2Qyc$)G&^YeoD&hk#Qc#KvoI2olgpZ>fSQyt~g>$SzIG?(Uv7{ZIUk?j( z(?FYt2t@*=hM-v@CUuC=z6nxhHm28z{>?ktE%I(I8A0Lz>|Y#t>Z^Tt(c9nSskx|y z5UAEilp=6i%2?A5FRq8%rEYSQEUAb|MtmY&sL&|(dk2$hsNZ~FledcB1tUY3C3%ah zn7)14frig#iq>fQO=f)3c_MLk5;_%PkNjKt<_G!f;o?Y31>1arq~Q}qjd5C}+pp>M zy(|Bfc^Ss7f{8Jk_>hv2Wdapz|XdO^(=l~1E$kImicfc@jrQLw{^tMNI4hUvt z(ze3C#b3h&0Wh(2{he3W4gLLFRe+AW7f&4A#9csXIhMNuDK2fINZ|4|TZ&R^?-EcD zaZutn`DecLxz;r5>0{r%UKCff7NjB@MdW^Aon%Wz^MEiI)p;RdRaMnrcAdy!%Wg67FM%!%6>qdniGV`$sZ<*C(WvnQn*7WS;pw(shOHrD zUS=^(nqMY=y4U>Ds(|}abTzFhOwY#&|6#tEHAS-DpxQhBIciBP&hp?-^79>V5lR59 zX`@Q=1ydGqxW9KzxSm#Q-!Y@U6UYyaAAL^%v;dI4Q-+bHKm`2flJIN!v9iW4D#CyhV$j(YNf@{a3c_^c$dPjUtf4{3ooucKJ#BGvb^?wu`$vdrN)r}!)$a$Nbr^7=v zM6FJ>WvjiR@1jBq;a@EP87iSrrDj>5kRgXTX}py*PeC4p#Qziz zFIGYL^zzhSOKyK5%Y7ZRiwzIGK4v)w`wtY8cNwx((?u8WQ?93cU3)O0kZlo z5sljHCH*fYD5R~Uu^B9f-09zCRG#<3-~ii71&kShGPl9Oo7hv^oo81|$Z$b_vt(yW z!_G+xg!!t|I>a`7(yT^8*&K-XtmEto{#kyT7l^NEsia~&<6!`O1egLgZfROoQI5|} zn0kLH(P!{wW6^`!pePL6a|&g*2Qa|V+AkOOP~BiB`1TD>4t&wLom(7>jWpb0T&c)~ z!l9{MAZ@y=K9o?Pk$_mpAFNCq7UdOYn%?|}+umA^VNkKPK>(uxtg`NPJs=Q1_cvSC z0C-trNvA^Ci5d^HkS_VX)5+H>y^8Xr!{^Lw`J`kho0xBNH)R=vE)6BRun-_0`{}Gj zdf4Ot6Oz6r&HGc{y%C%+fumw;FVa)Q8$Y1Z2ZW(Kv8h^k+e?I)yt5nH>~`rPyNAa_ zStchBRE(?u5wiKi^zz!UD2xM|L!c>8Fd1+)d51 zIZ~=PieTp@rwIg_e0_*n!*QMVh>H-y^aOc$#7II#3D&KGjtQ*ulqZPti^ z`}%~!jsn)#^6&BKGp3ESiJ~ZLLNxVV4cYVHJNSJ+0s@f%1#|ng0Sn-w33S0u|9B3U zFa=0ueP?G&0q?Q;TWW0Qctu}@bBrYgF=VW&HuZ>0xE|TC{jixXt2~>g;uITW+={1S zlrLp+RdQ%)!*N~S!i#F9K~RRx)~zsLOTCY2y=6*c;!Tgg!_TGS2B2WolPUuAbIn^$ zCzs{EH!Ii%y2Xb8SS)@HBRrTxQUOgspv^}U%oS#QQvYFLlp)Fk^iZ{)dc6ys<{>N; zK)}2T619J;S*`mPR}lPHe4GuQ2T#Ldu=@W06l{|J)lCLRDgML)&sd1K^U2Z~YS%Gb zQjg7#aEkRCNSc&-Rjj~4*Dc~u*=w(qS@~*$>~vx|Me8yL3FH4Kv`irxDlrnZrZu1C zdn2;C#Zz)J6yI$C^^ZW(NkRagzsKZvY8qo%`kt>JLZqa-yBq0}mQ*?fgol)rZV>5iq>)g%yQLch=`KOKyWYd+ z`+NU#Etj~s=bo9_vuE$QY2>{m`1&MOaa6bWgDWO7A9%T0&D2E53@%7A8*UWn7Cx0! zG*@07`u4>M9Q{U<=2azj9-l;?V1lnS{+ne1@@j@`9Y=jA$0oQgLk2M$3HdT}o|>l8 zH;iPqG0zWfx=>^h{6(-`?qn?P@KIa3FWTvuC8?%QU%C6gj1+;{>fphKrf&O_6rqm+ zbS*B3sph;>MxNvC(CrE1VViVT<(*&8HwCb8pMtS?zeRWwvud2GhZl3-P9tnYZccCM z!Rt0yh6*TqBu0Y73s0SG_s}_WOzwJMRAku=W{oZGea*vUUPMYd#$|C`GmHZK&$32) ze-m2i0Es~{4q5QzHlT&mYtk_@j%WI_*#}uRf2y^fNx!|mJ3EBF4c@W(P7|GCgY<+)R4}T3Y zqe1PF8?%Z(nv=ZdM;rPFXKo5sAJU_Sew&3-vl&B10pp$V@`RrI!UhJ0--+N zAxi~q;>NR;64=EGGu{J~wJ3BXbf2Y!E-U4XsND1)<(o_IA6Co(*^%c}$Y(m+q^~!A z$eB@3Vb(iDdr9_SEpWg=_k4w9olR+@S{ThTaX1&>PlfOux&;sR>O7Bfx3ty|vhH)~ ziY4_14iXJ0x%zrXLv1yW;nYq~@MzM(tbexk5NRr&8cY_1-OuFiPw3He8IE2}ZPAvl zf6bA@%!2zu$*YNY)#+#0zJK|TQY9ZfCBkTIWA58U=tNXLYA!Aivzo}s?&CunmFI3Xo zAT?B&p4^3vQYHeQ9Dx4djEwGx%zkN}Lh4w?HQgmttLXXQ46kskY5m4tME$9HJ8PX~ zdhq_Q!6z0C0~Da`u-3dpBZ_5#QIb!UzS+gqqzuWQzBf2=&jTd&ad&p4{G|-tAfTr@ z9;`qU)}k^c6SZN{uPhX%zZcaB6kB#)jmTMi3u{PB^;!=8KTxd7pU{fWE*W@(=PgAv z2WIB6-0mXQn&9TI%buPXKtcuBpRco_~jqrPYXl21#lgbV=(; zpIEh>A7)ZZkU7)Gva+3e5(Od79vPzCizt#Ek1^??&Xr#ncA?)T%hT<_z2aA9q^n$b z^@|Pxk}=!|tMjfFY5`gQ1#v>~Z(-0l> zH%s7mvMEBjNvUmN>V~iUyelHUmN^R!;XnbMK{OiYf76DE|4X=mtVY~n4z+11K*M0RT zKOZspc%4HIV~0AW$Rc7_P1CvPHKRLDVbt$nxCe0a_jQS)yq-w_8QtR-V)#lqcd;nn zTKk}7dgQZrO)3bddG!O~R+qI5c~0iylWUPRl`v2aZ6%8eJAYm5nkjG3jNWH-?vJV> zOWV@EH9hF-ET*z2NiO}&p;DrrC$P*9w};Qb?ULuMX}a)Ee4t1RP`|JGhNE$bKSo`( zHN6A@!65n$=uGmp4tubJd9j*Zi%xYb4!Z81#o=yHczOkwqBKwifZNl^*??4Y8|EDx zgP5xjQ!1CrpUi;6H>gn`Nt$arUL~>75cv1kHpcQa;zpgbrzjCni`%Bb`Y>3SfW%Uc zLnHZG{mp9L5C{(d7_@zRjuNw)y^q_|xMW*;IZJJib?*v89u;4H38RxfN-OlO@OBnt z+{uTqt99E`_cxcdK88;h5>|mPL1>tleqxX?Ns`&-4@^1t5opBt_5$|tJLEg|o197U z+glIZcfdqYX`GS6{drB7Ynv39@*bYb3O>MnT%qA#kP+jF%h(MPsH5K{Kc@a9X|zkz zP9=v42#-EXnLE_B^BvwP-GP#i$AXYwOQs#n0m>m#Ry#W#G%=B&H&AnbNLA3l=Eu_Y zp1146u+bIF|0KlNsrdr0C^Nx8F$j1Lw)oq0gvqK4(KIOw-pXC;Dd78f454)Q(Prn^ z0*^am)-PjVSZ_Or0GK}OZ2pq$_ShgN^x5p&@yB(af{P44W4KbJM)G$_$HX4>Qws3D zd9@-9(*+i;SzGNpfa5#$7s(lJIN7?#mva{l7y^Ou=9<6u%#XU5`n!oSa^Iq>-; zn!L)@al#v!$jDkC(SzZA=)Yv(t6Yqzw=^r{nxU@u^0}D-u5!fsBTOj0p!?1z-bO(+ zAURiATmtd{tLH|LlKm=^DJc#k>_53?T^z~?YRx0j0I=!(I53GPeb(ocE-E-FsxFeE z3E$2+Xl&i;jD9lFFH$t+oiYXt5p;w6=_B-@57=`#$i3j7=d&ilQ5*dsHE}zt>a}@t zZ7wcUVg*8}7R6LT4Wlw_YrLr+cpcJ74$*=qrl5aKJp&~M6OR8rr@!g*y@P+*=0gpMI@zlhkORkGi+eajj=B|p}n zNjk&mGZ{!zb$tu{6#V^Dta_~z)q3P$xt8O2b_u7}_{qDrx}CG^k1ucUjlNZ&1R}_= zFAUq0a4Yu0yn%lQUFSzyhc@{`D0Vm7gE#4=&x_O?RCkCmqSXA$`NS^?Xz=6y4rWT! zy_%LhE9OY+h?oA)x9Jefl!mBD`2{G>$WF%U%01AS_cuMr!MJ1=u)xrbO12JyJkri& zrX}#k-@H=DvKlwprRj~M@~FweF-n_M5`u^L)Blq@yZDJs9{q@>Yco z?fPG1S7z_QSHBE9r@Dr~7Nj^+F&Sabb>Qyef8Urg>jp}{LxT`W6>pY~5|xR#296ke zESA#CQ817Fjpi4Q(U+*i-|m1}!$yG0v{L?UgP-YK98)l(rRs?UzZ6PW8whzrP8`>| zTKJOU&P~1G-Hr6cJg>B;t9`oJu)=;0X{RahsCF{N%vl&1+4TekmSYlz*8pj3{(NCr z->E{M^`}bj8?qc+^h=)MkfLtHBx(VOhE&&l1yVX7C_m^V67QcH!$odt--f&<@(mXhS zZ~xZKVO#_T>A<)l{|;Fkz<$-(4K^c9<&H;gGR!ko-}iXwh%{+wCHk_!HvUp;ztsEw7?JMPMKad z*x2M2hsqr&Z+ynoL^FQ;6hu%E#xY$cwDanyRw3Xw&C=hhvz!jY#?_2(lP>niUL9`fR;l}9uAZMUBlQFjAn< z(wAgx6MaM-l&%l^ney4<+6!3$i4^dCe=n?7`wfqTl6Z=@yd&!^^R%X)8*0t%%B!WF zGxOC@N#f8Y`iLH|Jw#0FiEV!m2(*s7M^fBUe~Wyhx9Il2n*o&FYAB|P#OOmtwGGoL z*FaCFJ@D6=cy9?36!G(@Y-u}B!v!}8=1Xg+N=20>T7)+qwh=&qC_GOs+Zh2{15iMf z)Io}NCQ)iZVl=C%!{cj6uOJl29tsR>bUQ^W;V-_=Y<#sUTR6&^3|G){a?-w7Nv{mH z9HXCQJ15*grL2Lz*+vUA6x<8jyErF=g)aW0>B@`2^|&Ff6m6$`{aDPe?44t zimXJsRhhkaEj4QaCY}?{1CD6VR%+}=(pj6K@Yfs|{Uy2tZ|=WEK1Cn1lrSVH;bGum z5MSXNvt2nVgrL_tUF_UC3nD^G_^y>4(VGd#&@5BKPQ540P`tYjqqo`eiokMy;Qa?(A)|)_?B9p0qM8I zM5p)V5A38oZ0f4~N$E|QuOcT@2N=B?V9OYBCkg$x|6CYVfAedw^;?p1fukELhdEcr z1St-U=t%`YuD=#Xt_Uv{@F!sCp-#bcV<75MZ%O+d2z&$Q)WsfAWH}Nd2C%9588;VK zZf>DSGM)Bsp=O@>aUrRU#T75LexyVrwgT_T#aA-@|_tN-YnY@0j=Q@>j1ABsKuq&o= zkx{ylL+{d!jbKi+agj;pjrgYj0MlA-8ph7|7X^f1*LN4;OWSw0S>j3kc=dcf339Fw zN(hE?$e+F-Ye_rm3^WeKhsQWF3Z=P{uU43!vS1*7%eLL!%+3Gk@=+=K><4uO@&)(v z5W$**KfX7ffmCs$|2M7pt4<4f=7W4X_JDSmiu0=-&F_s3de;Vq?Za^&RjC$EFf375 zeb#g#MVdyXx$)%2Ch(1z+)7=B>1@B(A@jMs0J9@_ZhzKa%Yglv()|K-`aOVJ0;&g4 zw15&AGyqK%O!gqX52!4r)7{Ji#}GVqs9wth9B9)8)N|cXHi0jUhFtH}Yn?r*va!YA3ol9Mds5H*p}nB_vN^wx9dYHgV$(g@ z-m8C5-PW(H-nzcu+s55Sf@0j`X4wcN{6-DB-X7`0*sr@y`AJavTX1$$APrE>zq;j5 z?#48rgVqEf)MAr%isykketzQ}MlTJRqXc!knAU&2_9Qy>!*k&g?D#p$Hg-*hr6{^uf-@=jv@!LW_t<7!DJ+!4ARA+}i{1*T6ga^rsf*Af&uM)CHo z_dE=_U=xNrL8SnV4WLQzl51NhHgyB$3D+sfvl3xJ_F6WoNdP5|`s^4(?1F%G+k2otMy$Z@2X!zPQoqf?^@+HA@E0d7X(F2Wnz z6WuaEd*g6sZODTm;%848IAE6N$(|{Hj*@XJWLaEM*I>A4pE|w7Mxd4h6B-IZGvEUF zcNk5ZG}(boO|@bePVq^uT$nJCdS`X#MO9grSZ>^Vn%%*wjIXJo+lPn$Spg{=<0jz{83s5g!5jWoYMBjy+V2-JJPM$Z-RmULKVv zhjwe3Rvgt^%nQK)sEnM$aKuBhuCN@Rpqg5369?dpOVo$vVZGHJzYi;-H9 ze@~tZacwZ<7tsd32RrIz6ME?HU@3i6$mGYj6`3|lAtF;Q&T&gwSvOC;4^O@J4j~?# z|E`oSdy<<*u-j#Ve={7VRHDAqg+K_L@a!Y5{=^tkbgWFgZpMZDetJ0imNkgywbZ-% zr6Am|;7^u=Zoc!CPnOd77WeC^`aZo(wS0eyUW28dMsjg{J3XH0!FRsF8zDpuGGqFdC$gC5USF6tNc;%Y#r)g`F}BCwQhh0BD8N%tdL-;8+19q1LLf$0sG7N z=0_MUAv27+ap4iy!)YbTQ?2u~5nUQRF`nk~PfbX=Ss96-*st z@udUTPps#0!si%kXg6^A8Qv8EwOAjJ%d97!SC0+#JLWy-!oiXttxq}(tX5MO9Jo%MCaeim%&Dzk**wZHBBM1D=X*iW$kV9}>jw|q zj|yqUk{$?8#clRQk1cye%ShR8ei@3o3o@Es^ViyNMrQbQC6?o2xL%kQBX;i?OXg}e z{q8Y15raayCXd+B-7hXEbP4@7WGF(^>csAzk`3IpW{lfPDi3!}$j_r5=cA%%9{9}n zwP>+l(Xu+Z8MB%bt(Svs2k@0}f|V=Vd;x8rqMFlBzdsjUy(J>)$;kW631SY1Xl$52 zsA|2oe{=alY$4;$+pGaKcVrGv_clGRP|y(8xY-=}m;*c>VWZC~&d+!KHZ=b?hX4fTsKUEw2@--YqZkG= z&Ewa%*RUc;0!day&GtlVhZ8GAi zTW7SoIM0j|bBR_wnd%@P(w0N+GzxB|1v#NW!SaP(Mk)_Jx2Cs-MGJkS=0}Y!wDg;d zT8uMDSbBK_P?ou*jhqh875)5k9AnK;f`4aS7{#`@DwIAHCo7K|c-=?}`%A64ba38(jXo9 zp8X5|yoyxSn{sqY&P{4vMv48)SOPOW@|m$!*4O+|;z&OQ?>}B13JXS^4&y)7e^`ps zO)`;7nyRu^sG?z~?8i#Gq4eyII@qA}pX*HDo86>Ah4Vu~5?kdEFlsjMg+ZVTt^VS; zjpoAd+WCdk@3;#kEYdjKO0fcuV|KoYR^f1Zy3|0uRbA`hF=nB^Zn~&`+DCNz-=?-| z>xGn)mR9T0ndlgLIe(OSw1M;Zm@TtN*0Yk!WAbNd4-RFz*sVs)3VuzMK+*zYf zL79uV?mp5?(iQR{kS~{hSdvF^Gbn{9G*X_+DjW3DIOg_E@?o^>u$9+iL5${Ce zH8qXpM*>3f`esCa(W=6k@95i-kkQv!$%(p+ykTc-!UF&xGMUeGZ zkt3Yj&WG|Mhq4ngV=uAlW=~Fo**o~^laNc$Q)D3Nsb{Ce=*(Trl~zUNPZLVn9Qf1f z27gDkOOB)J-#@S})V#$xQk)T_79}Fd*xe3bki%eo%_tWlgSXj(fRWXtiWnOeZT|p4 zql>3XzNkcgIh6vR^dU0_BPadk9?|IC)yB-Bc+{we9l5AGzJWlc#L3y_+`3!kBlQ$E zRU8AS1g)lvdlTz2m6+@|{_6j>_757$GvL`osxkSTK-sr90yIgsCifVIt*>mpp;+ zlHvdC-iCCRxM=4;m8zSqNq6KpV{FkJheRScz_b{@_)-^CI&67C%m0^I8e)vCLm9Oj zj-4Z`fbSI`%MLs|!X7%zNo?0|H$p~d4h#F*Ur#goWD0*ES(rZRP5o=fBqLlxmxi(46~AkReqnUCQL|0VHLJj z(o=merv83?@S(~NrJ>#lvXGhbMX@nS>R2F2Tw88C|ni4@4{>D&S#*4|by+Jzh z45L%tMMixOL)~#kW8vg{s_!%Svli{IFl8AIyH}0!iGJc#BTS3S0-8E5x$3zvw{$4x zpnNyga4(l?sJrH*_9S)h#A3759)?LF>z|KVdk+ZsD} zeNm}lnZ8v^cpo)nSvlI|jX+tlY4Uq08iQ3EUgftSLKn7MH#Ozw4skaA$&Jg5PkV%- z-koU#ukauZb;U+$DEwlKU*~r9h50)z3m6;ugR=*Zu{yupK`xsIu|M1C27KA!fam&=A`4up9CmNY6)Dq=;nEhIPoc zG-rX${T++W-!;8X$)j(F*_rHK8}VeY%gWvUU8CClT6CwCf#%rcbZ6lpNt8!E^XjuipL4^^*Gn9N60DzcG;v@`(z!| z6S{L2DyMbbf0f(hR$lw z`&$RQGf5YSjAeI5*+M^0;)M=RmW|nJY^0u!gDk4Y{a{LoHzG5=|E}{zFu&}maMK(4 z1dNG-`JhmmYXFrwoTY&P$R;-zWvjl8K(jQ{#Q(CTX8h|gMmQ5@{fyTz1CjUlE3|Rqv5<46 zv&7Lc5bNi|d&ND)^qK|sMdoH_LtiOq^3nrPUcF#olxTXon?i1bj??P~$HuM47KZ0? zD40QJ)!{LFN>&ZW3=QmYO*O~jcqBjj_*nX^L~7{D=DgEE%ha;mA75Qf5Zlhp)f2`_ zt=cTb2-n_FIb8I7w`qJMLB+WP*U$s46g=HxSI^JymQDpyqH%OKJ$)P4+&skCXvq+V z3NwZ$9k1`@1n8ele-75rn{dg48BlS)*tO_c$q~r^vsISmYxKUVJe(4RJq}5%uUoPw z89B?>F{{@x z5?~jQtkLGOxpbn3%>HR#?*B9}{Iw^Qcj~k5!7T~Q=MP$4yiZbMEuUWm@36eFkjfx+ z0I{i`wNy0Nh!|UJyIe6%nHPb7DcVt@?wZLOrIeWAwmte32mrF(H2}&cP$_4&X0D@w z?hp*EL2Jb%CR|+>e0Ud|tCqD%xiAV7uI2BQD=+6#09M>7hlYkb_80x8s)v=yAI^(V z-?*4sAAsZIBrXT#B){wt{>l80@lV}ja)pT5k#mu&{c(-iKUFDnZc^?%D4sOr3l2Y% zjcB?{qS9LwF3!-%ncL-AShNj5is;)tRufB>vTg43c^2QN?+nN{p$zmp zCb@0gZ;w^v`mkUjJh%rf47$k?i6OGuG*bVj|9#FfZeqgUgrXqR8muagc2+Ar623wW zDuH}U{1{MjPsQ(mf@i)p%A9t{OwH_?EyA2{eXrF-z3{2_$3|!QCoHNOF4)y3kXFkE zeAYViLi}0A*n%W6u;Z9SN9Kw1nK0KCJtJv1g_RCpfzt2ksmsgF$nEV2J|qCD@p8-d z%85U*0k0BQw_9N{LAL88T`?4+R9Wa0{Khx{uWBYsfR;_@PHl;O#e5Ir0@BHh`SdZ`NOZCvN z>;$5Jc>DW?>y^`1b3igY*dCpjZs0gCT%wjdewYQDdrex*n~CaLd6(FgYH%;8A;V^HRMcJl!z1QA ziwVp88lggL`E6gj?^E9qH;*+<_$=^ObNvD6d~?>l#Obmt1^bOgEQ$tWg&{|Zwd^z8 zK>w4u2*U4M`$d>7mO>S`EC3Y|8?Po{8B@|{KBAas+B2W?JD%&vbIwUf?wT7+En?~n zyaK7KM$Lt&S+v!z7q=W#T;nK_s4!S^OMI8?i5qu$F?3pHBVl?@aTlrqlMmIJa~$zj z@}4yGQ{MY*T}_D*a8y56LG(tdC06xvcqWDj5OQja%}h4K^ZYH!ROM!3T}};~bV*29 zH1?5IUJeAHYm#51dJ^m;3_)u9-2I1hH~E9fNa}}#LJg9Q6Hc_8V`pi5QL?{S_J@n3 z9RV>FNSuc)SopAG_%Q2EgrTGR_kmT=Y7@vw0MCbM3Ys51u#%3!vT*(aXL$JY+&pfK zNW$xt{q8GavR$H-m|}La3c^@b;@vYx+$#MzSoexd$ow!A)XeCgct1@g$F=c`@;w|S z4H0$LFyRG!sH zN~&l+Q2$pbZdLEG>hxWLLDNIw+2hh-p)5nH|9*s_JiW-Ul*WIsLW*R?{l3Auq_2!^-g)rOa}<}4`Y{em=f{0@|rdVSJtAK z{m|ZtNW_M_hM1WnsO6mct0xe|Ha-44B`^?VJ7{WN+35Cf(^ab*qCUOfIxelsiX7IX zg@qI$w?Z~Y96`^wQcxpcQ5)|W(xh&k1_OyMaLySB)maR|k5y^s^R$iS7GZvO`u3#% zX5bN@t&Z+GS!30B```iZ`r2vXh6LhN{r4yy(1u5rr0|@tW25mAS~4o~%-yQD1vBvW zx=Ukk+yWH3skxbG$)fEwX|V98G%&7b6_%-fUKHZVssQ&`=zXHpT4a{{qq;&iBi zT#^DJCnc^H{0@k&OEc$d?sD(Owww4naQM9bVHFd`@ac%>2De2O;wRTkuz{QndUK>Gbc1G zS5%ZcA|1A<&R1%j=|l3^dHFKgI`9T$^YP@pVxq99RpYtakw8!lOj)D-SF7_H;ZJL+ z>5n>nqFa($%*p}#Fh{5!8T~JKQj<>u!J<|!&-0{f#8SiH<{#)LS$0v;6a}tIivzyT@uv+_id??_-oo*( zZ*CAffP>?IRfC)D%2}m64^S6J9IueRV37DFzg2t+kJ!IADYe|DGO5$1*-y?M6CAIv z1e+;A+nEyi=~DDmb6O5FmgVQhjz%Ach^$qhbX?{{i?}g;jJf%4y`VDSqX2J{XIvbH zK5*-0@oJN*o6rI63&C(xqEKaFRs2r!-9mA4lSZ+A1gjBcd(<~4>2^*gAr~?6^GmG+ zAPW^`M90h(rSY+Nq6(mU1PsIPb%y|amZ}x{mdTaj>{qwrzO8{Eg3^h#VCm;{k4?)S zo$pT>6&XGo^dlA5!&-wM=gZ@~_B8@eAALz}jb}=uV+xMxJ?yHxJ|l(s{lP%dRF+(oBz1Nsjc(4y=K`qKrk!1bon~{z13X*mI(eQK5yB z)HD1}t(h;ui{(;5r0nMRnVhOP;9-2JoP=Zl-hm+=BM_bnhJ4S6eu`iHiwJ9)h7dVr zwMvKKjD_P76d@LatF>rd@>)(mw}car8kR%D?Qb(yjfZ^^cDc+TO=_bM&G4UtWnL`M>zg81WFCG_8X~kzP_}WzLX*g0)LKFWQ^g-fY;S z!Efu@wIkOC7oiEdCL@(+;O+=!BZ@GOB=J!k*!|qzwUuhZ7h6Qd-+sU6Bqmnp9UtDo zU*<-$Z_0s|@*+(sS>wi8BII@3dl}2~@Fa?%jt;6XIy)9HIgwM^g4MajMhjy<$b^kl zr9N~`(?N!_7IWHQrX`hkzWKIB+~Q*E7cuR}i^$}QMT^q}3@hdqOH7}9(5&MM3y4e{ z^^xwyB}m#o3sR$Il(3b9LVXSljE`nZbr@RfTZz!>_33vSz|mCcV&1q5)izTX&4VUR zr*C_1UHeMuCKOkS)GCQA!MSB|28FZET=Y6~-*a+z5pvjY>KZ}C6%;0{`;KZpUQnwy zCx?0b+sge-UlsG?@K@q5n=evPNEEws_FWcZ2nDjvxJt*?jzEZ19Yh?-#UZ;1j zpN>%=_M33xk(2(F61DB+JCAN6s#{_<|1!WN4Utz5U)?Cu3_m_|*}uO=n;MnvSDZ>1 zTH##bSN}zUCGiu`@7yk~3gwD%=HdB@g`Yf|EQK)oaW+{du^v8=#~8qBq2mo7Oo1kz zr|Abh#z#$lhP_2u!EZx?nK3E5$CE=%K<@6D+JKEgniQ)4Y#Uy{Vw+>EbQ8-i22 z(J!f(Sxeq>i|B6=VG6z+y;g}AHeoh67pEIq!H^b5HEaTQ+qNuI+BuG~EO}br(P^d_ zDTTVRq>XC_3?uW=e@xLg>Bd>-v@N>WBoz4T+J9sv_Y*fd!0E;SFAyGH)Mb1IwE;$t zJzqj_IO_WMcJodoB@E^ibQ(s%ue zt;genbH|7QJmUYCJMQ4luV<%$nOv>F0dP-$gm1gfrYgs7stP zMYT2iVoJUQ!(WaDzEKiY)~2eDgV~Cv%s?6jL5dQFlJjh@JZ2RW3Dq(7J2}3OJ+-9& z$@q7n-oTdio5Y4R7_a(bqYv&{U3LB}>zOtAZ^6ZPOJ`bLBKwcJsN_jKee{kXQTQLzjS%& zrDf}VZjsxX2s!&%7vX#0TQYnQD==W?qgx|CG}c~y5{+r=L3 zbu(|?W+$e~SQIt-K->1R_PnG96>34FQ+_agyDF^#2(_Bv~K-afv9KNlp8bIjewre^DjuP>Urw$tb{olLCuXfh6 z9gf*{`P-Y^jJdWhm2dkqG81J*CJEoVqDm#<$)>Y%dhB54@G4M&kK4`CSX5E@GazDI zHwbm~*?i$&i_HG7gKqq1o2N|~ZE)zz$5ocw zG$w+etu^`PU`57Yg7b=U8^!*|^-1#+gQroZyi$B&0M%fE{|}#vEoe{?<7f%T_dlQ~ zwAe5Dm1lyoV*Woq$6P|hWpaJ&0vp1@rT%J**y%@AKvdh7Fq|_B~hs< zbrJ1NuXlp$i&{H234?67d+!AB&wUp}>GAQbQenH|QFKbwG}Y;Q`Ctw2m0F+n+spma zGsprM#4%usr7t;hC~#!Ds11sHh}JeL1#)5t+LQU6sdT9Py1T{f;Vc@D)4no5kQyKj zY}YDaB5SwpCg!=Z`Z}9gK0XhTwdgr~{ibkhup|G;D(%q6+@&lK^#^`)VaI%Nzx8`% zXj!9hh6xk$USkrOmTv#rjf4=j&+2M2cd$F2n-n?0|3v|L$=tQOkVLGii8&Ep$6!kC z(afeFcaNLidZn|et&SY#%iFM%y1UKz*eW;Qn_`ewIogn)ev?_Fq^tK~@$BO{bokd}^eW`x*`1WDyX2y<8N(K7qZ znL$o;l+6lFbN!7}P`g)q;VLolG9vn=O+TBiqR_fJ#m{i{iH}0$N~aGd>z&rVTf3IC zTd4z~-xjYw-^|%3YYx#%p>f*!1$MytGlY@TLC1f}#S!Z@@cXSTj}rKW3L+84zgY(p13)_$&+fa^fb41@hg}}2nmSc zd}d+fLJCG-1ey#+8;9240r3EbE%s?CQ$Ocm5KwmTeOF%J%7)eR7Ym3}Let}tQ z_ACDHJ*Ly@2Ae#>g}&v;ML0&tyv{IyBG7+>wgz{8)e#xsakQ@d>r`Zi8^vkBkY;KY9)fgcn z5(oO6+_&9S7}o?)zm5)V3pbk4KnhEBk{-)^O&-hafRNCU%@^0Hs`T65{utQtI7jdM zeWI}e3-Ipwh*G9-=}n_!t+T~L^uPga=nyu0_jw*a33Qr}&=O`#{T#>tnvWgDtftE+ zWUL-*AL`Q5VO=U2(}QU98SP50eR(o*-~+rR3Db8^!p>ok-8pqOSzJHQR^4i$Qc>v+ zxj1cPnvz!ax^1Y_>gOw(fwC{$_it`{d?;1r)0Z(1p7+E;If-U72m^hZxR;y}0R_@s z0U=^!3vF_-N_qH1kEaiG5a!(64i}zL*$`SNre*?yziwZ49f06RkOs6j)p6UH(rYbJ zL7|9VsP=6A(*BZ%{P>%rv?4Ta-LU@tl+kG@?u&@-i>pDci0J1I&xgjfd4gPNQQ3cH z8tP_kFHFJ|9xQ2^ZU# zQo%H-;SCZsQ}Si>;&jCY1w?vcNt)6x=G{YvBRgriIOroT&afP^Q%|o<4|L^k75X$# zb0`ZVOeMO(kXVj(cI4MqGMu>Kw|)F5E(?mE#@@NNb_;LLv;UOa4)M?e8vYrCu#Nh; zgkJSM14!>j{XV-G5d_NT7ogL3P!92Z2;g3oOD7@O=`1j+zi`~+{H zmcY~7K%GYd=S6oHHNFNmCbsbU8YVg4>;f?<;eI4Sa4Rf1v2R2)@&3Up@De;d<3dsS z(`J9sMx%f`EJ?;E^Ky(rkZV5#od0;Nk*wQyeDQK2EV6&n*01#tS33tL->t93Fn9E| z!yd~jK8iMzy?OkFf20f1%Uaz?)sWdC);1eW!%ShZ59j?Wq&lo9#$f<=T!RHeBjbg! zh1Y5MP78W1_i*0zq4EKZFOiQXwYjaE+LLdgtUPILl$r2_lq%X#KnNI@|Gn{q#lQcFnR0i|Ru78UVvnZ6)34 z-(}4A!(ixFA#~lMmJ#x+<38u{ z-_+fuL_tho-`-pjLJTXv%oJ3AWpm=O_B0!aM1LYNKB|`OhX06t;_mrEr6LrmgAgV9 zdSZfG0#6||Fm+Vl{zq#ow!ZRH&k@pe zsyru$32r*w6+&tYZFMgf5Ks81&R8TbFDM7fH|zeHU2+V_*`-4*V(d<|B!%U~05l(i zYS_M_vED~IRh`u?pJ=r`j-O7OPDpich3jnP@=Nj%f$(Ro&Z^RumX$4b&Nu}l);xUU zB+Z3`Fx;YiNi90d$BnsKYWT^b6}~onw@~A&xU<{t$?;3#l*oD)B%7MX-?N?@wxwMh zftTXmD}1|33n4WxPK$)QYGdAg0=;$%=mko#sC>W|nScJ=@m12{`73c-n~ijn9_p79wx{nkR3 zj!VoSWJR24=Fq@Y{b^M$ddM z5omww8G->IAl~&A?7U8+-!s6eL_|>Gp%t`M*Vz|9+HcOHPgY;C7kd~;^nPf)SK8#6 z?igDzjjTQ@R$yfOI*hy*@JzdCSKlj{ZD4-?lLe|@*r%g%O%uKO9b{iV|R!`J8qG#k|AktBG zi(EUx5nM0Yihk8r{Q|({uuOB%zRl|yVSoaqCJ`{mbXp+KjzTZvfI~RL5ZY8d?qTMa zw*jD%Wv~h4xrkLt&R9B=lEZvH0`K`>=g+UR76tlmE%@@eEr&3!?@MCLU@)NYUt4%Q zt%6paJQXZX^+^BfzWo0Ghr_vzZ zAl=<9@DBI)fA7uQ8E16#@;PUpz1LcM?Q=wHJNgqY&{t$}Wx+tSGIGhadRJ}pL-RE}4_ zN5b8+zp1~M@^c5&pZQ_g52B<;oMR>H6ravSVKm7Jllxj=;eq^9ct7t|4*gKRr%gPL zBKiD@phS>z z`bvtSOnrd5dT#AC5S}Z1@FnCoL}1EdM=qOhs1zqkd%0&6UEcXbN z(1sHtjP_|}yKriB@eJ5;(ANiA(z77L^{13pCUU@&?hr4WbBMdt!0;;5aYl1&a zv>SoqgjK{`OWSOo<5>l%uFkyMUbysXH7?lHr}7oe=H$M*4c0<6jXPu?iPB$Wg!1#DO;_htzc(?2Uw%)xCiLl4Cwsu}35gkr$DD17*zNZv3(?Na+=z`Eeg^l99 z<>5iYgfEa~B1)SgRBvj#XCmJ?Jl2kIq6S``7c%O++>(CxS`b(go?R#13wK*G^>^w} zbk&W@{$Z>qEFS6929VePDf8rS(y}+B9C>jd;h66yWhGP{Pqg*d4oITpnW019Hanb` zQY4rL-rd%@OWE?pQv;dn#S7ffcIA0^vujR5Fid1J3LGvkYYmUinFBd@r9KPVp=7MU zd2YemrAczoTmqFU7L5aR(6hWwieB7-Gdav}8f6X$;TX^_q|Gqq&uepkrpK6wNEE>F z2b!T1gSna_@j6l{=3;@LatcrFhjEkQ9hR-31MJ-{;0Ji|0%s|GH?RO-2TXQ(CAVl7 z)x*TJJeX(LEoP#|uMtxt3qBk@_AVdE{@g$|GROD(TxZxTzF>M-660avWW;u?HbeBX ziLnd7OKWpO@Ja5c!?`S9>w9S{iFO7*e9=$8wY+c9wdE)Ug5IVHjmgxSziEFM!770i zlJux8D0;IX7VthqR8f6n1%VpJ=BtRCU#>-M;$+#$cjzXD3Li}ZRlx=1cbx-%IEGm6 z@;8L{deU#69>*qfO+v5VKZ}{gCA`Z~t_uOzj#XDVsr`BR)GAj)nt0of!3d`_MK=sM z;n<=;kbUuzy`LvCObURjtinQx}$4`4jJ!u zU_~Zx@bgng$%w5#pfi1`N_}JhYgHIV3q|a(c|TPXA2HIs;LC3-Zru0R_Jw7C3}9X= z&54dTdS!-c&h3FO`0B4K6|yG^TW$hIMY%pD#FEnjN_@tLHhmHUeHFzPy4dis)bqsB zut!ytM89{T?a4gRb5+hvRF|c-Kj!hn#fq8zZ#SVjuWkfF`li^Y1JvI%H9nw5j|suc z4Fk0#RVn;7dPdU3k1ep(vHm7ko-5zWrN_359O43;;0Kcrr>NHdXsp~19$eb;1W{3! zsD^O!wLBncm?QuQ5iXM1`>O|kApx8YnC0R;TA3>0aX;MjN9h|QDkX`N0rdIzxwm8e z3S}=*BHsj$y3^9ylYsuvQ*azh)?hns01b%UhK=s55)og6qa@1X;o0r~%oYmJN*5*5 zu-4C)kT7Q@&8P!COSC{>hxHG!>u8Z;I5Yewj(Sgp_!$ATz4hvl86vbei~vZ6)pp1Pt{+ieY@-6_%R1AJUf|UNNL5Q$c#*A3w^k5^S+aELZF#TNX*}dF{ zU{xFJiikI8wthoGlA~t7ifd)=Vk;Xpbj*|A_4oX1#m@<;{_vURJfhaVrpY}RWf;7tS@@RC)> z@%#iG6wNZF^aPG$)^|+ywQ_GcevTD?Tcl(gB_^KcovbdIA|L$tlR8eWbU~Mxo|@6Gj#H5W}Hd z6BT_`hxvjCcIc$lnJufi`L7M4f49p_`QOk?+G#2B*SNt5Rp1Bwp8`VSb^!u^jZd9k zu1vFRT-^wS!7UI3=4PuQvZ!-4KT#9D&lun$ci9?hSI*6JvW!V{ z8my4mXsiZ1qh*IZ;<3Q~gB0wKHGan{Z##NcB}C{CJDmVLbj$8%r~+b8-o+64(tIjW zi6??cA3ZgJI<91r#S*f?11>LCq-XE?g2AZp;_1*KnADokzW|`^(>NzwE@~TzQwL?C zAuv)TmDyIqK^?J6yz(Qw4Rk>=-J^Ppqo5K~erdcZ&rW2yc!3K>{^te2&Hnr9Jq8H| z2`^*dd;>EE$;2~G$;T(0XXj8H^WPBj+aYMRDlE}dZC8!^nuL~6`8V5pwn2W)g{M~ zU~_q#(;afv{hn%hd~C&!#&5>DLFF_>5hw&S9+dq|tK*x2J5V6;PSir9mkiliRlk)$ zQ?LO1C#U_?SSN`s0q{(gBM3QF>i z-djj{9#H_=_NHV9=5NdfseVl&z0_U$XSY;ctl^h) zczk@42W-ErUbXMdO=dd`z{gx?sQUA08M-Y}l~AS$#mJn%D;86O+x6XyUPYn4E1Y^z z6CYl(VC|JEoVaVs-6$mk?J$`ir6b5=j;drZjb1BGp!=5$Qjce~J%bsT9)GPWZK2&~ zf~QB^9z!d6fGxHkm-)`NUcGzt756Y<7FDR=YXMnQyAMjgZmYy|8qJ)D7Z z{4fng7rc= zTYHNod%dWzMUrVystC zqXg|JvcLnc`wiF`Zo$yTfo;)h1T)I}?`LCmlzjCF2T6^^b-hS|jq-R_EhxZuf%LGF z6$lL1QIBir7Hv;L%~8WKGLq#*`5=zcWaveAzP=(8p#0(ab$*b;NF0X!FS%#dVbUO7 zP;5+SKqKSf{h@NJZHfHoDEBDPM)$sh1$z`9g5T+4$ESZ^{Y_UA{nQz}%uGp6r}`7c zH~$M(A~DqJ4%G46?=B4bvC;bZqk*kGi4xgn>gB$gFYw)?(v9P#5v?99BxUl#oo<9# zY6pZsW_a^3v$4HCbapGv<p& zje9F?rt=G@7nsVbMAx@%?v!fe8^@AI@fAlaght5zhWaNe=lDMD@6cF0Ejo+q;B6E9 z*Y@Sh#67(gBgmAj^$VLnM}BBI#DUYu?N2HM)hP-jc{dSo5;e|D=8Q-GB(?bqI#ihl zn=95|LdCntF^~9Mn-PGBET^(9GY=hV;9d*F$OJfsNFbYl zzfJ;dASB8PTuJuwYiT)bV9U5k5XXTBFth8<1b2W%im{o^qGE>>DvbAGXU?^!S2-TM zbF_sLRq{yP$v=Pwwc4wSYoo^~Nc;)v+hI){dA!jOe;O@G&w*7+4BR~1JM`t>(ZIBn zHjRc{Rr1dF#Uk~&?Q9?#j9gvuArrCtRng+xBP0yvWBs+Ojv5Us|*0fmqbiLd78%E$^sn z-_gh6L~rl#9!&SCw*5dYrO{w+>62GyOs#8ju3jr~+w%qW7>FIY$Kht&UvG3J=%)(y zS%joP!NFga9OqSiZA}Q9`NX}X>?5WGQb|~pb*3>LIyyipuM|sv~3>Ybku$X z(8{(8Q_4GnHH)!b+xa-Uh4pF+I!vPGVzL0Vcs*d{nQ4PkG#|mbK_=9zC1Fw_5ce4n zk4%>>|6YO~P)u9u9oRehTq5@HQvAW-lCHP=`*!;txv3;MZ;kRFnMki9>V{mZo;nm) zhUY-3jg3lVw`+zZ))SgZN{&J-n4xh0B#w0ouKZveO|oay3yYAvEcW~kT^Gh?ssBQW zfjK0{^&5DA&KYEwd0J+uZ<0%uRYeEF+lWOQ@g!DiPaI~0d)gv2KdbKQX87sUWjEIW zREaTO3@JqjuD0`qRl0aUa_`8LGC0Ei47uRX(XBLfn*=z&_vW~_cfwqr?$^+FM6>3Z zHQ>99^DkT`gA<*ky8Fc+QKEhh$fPPX0by5D1{S@*SAY!&O_cCEhiRo7L0j7XpMZB0eNaV0WYTpn5U}q3e=~U z9A8kd{A>sdwNkzKb)pqVZReOJgVA7I5)ALBV8%$|2PLQj$St?R?A66x1B-*|w(H;;HUdhoXZc!PRksm(dw{S7zC#VYc4ZjS~e zyo6e;V4v*;(=F*JmZl+DVGV?#=9pkDz-I1X5ZsI4E0*`Hroke-$6rAjl?3O!Ze*+< zjML_fE9o|pvNFW-_iqGY<1E?MtQpIi;iD2!^|!M9B=78FcUBikA{XZ;%3{U9u&k>1 z?+lN=oMHD@Kdz0!P+ViPV&Tn$ygoqzKsAc56$m=3rA<7OSF91%heEZO zL!OE!cib4YU`E*w*tkZYb!Yb9aHZ^T?M-vxV2mxt#W4i*4VbgF^%l&~46f7u4kiW+ zk04RA)~Djsl_(YI@C{UT&hN8jZkKz#fr>n4EGtdo8)@FpoLkW@*cDXW`1y3u8%q=R3AlhPpk{;29%1J%qWy}M-ZxS`^iko+_ndm}> zIUnXz^HgINhdg{+K~v!?Gi;6+7AH(5-1zSjE7Eupu>R@PzlOk1Ua`TJQZg-3+5iXS zGv7Wed+Gu^Qb(L?~rxa!2R`Y^F=1F5mG0>7v=c^~&Tm(419u9?*4r984%n z`hKZ5rpTIfy?RzRO2g;`s5;<{d0SEpNAKQ#tHrgR@To&n3A=vUx95_M&Y+V!)J#M) znU$sHV2I1xyAz77owmNmTJ6Nqm%$jB0Zry;QHj`0D!!a!Uvpl_M)t80+Vm7?&jZ7h zuZrw{D0?c=InBscj%EP<9H8e*wMwWDZ1uZ$SDx!VH~+vHqD!TrMUN@r!5#{P*B0^k zba8KH+qgk}c%Z5`nE%UuKpKK+biYVorQmdd=wp8a0kHS4{CwboFa@#-hptW^a74-< zL=*!!&g@(%2<(gsKl?u>qcOZ*t&d0M%L`PCmpo-Conb)J`yvfBXzZClX)wrBJ&oel zfiX-O_ih~YcULEHv;V#IA)sb>f?Q@`zw&5X)CcnitU9$rW2y6Q4!W`}5A=5kEL-ZL z1raZ4_Gt{WB(dz`!l~6x?=2X$e*Myu1-h!DdDAzn;L#+UDbAMmzrPryLrmC}|~T_qyrrllH~N4~^%;U?0I_IdFd*6W{w zp=kF2j1;QFO?})Ufc#c7s2Nbq7G2Y2% z)`4LI={CtRj*RH~SWN|`T%2UIS5UK=7PqOA8!;gW(7fbdaED+T5>+Y@IEg;SCVVC* zb(xQUEtp1mdHLRkGQgGBBSe?7Y2qBoXYsopE^-@%2o(6R_qXv6=>II$P>&lAN1MG? zzxVp~AMCXC@hj-hRh;A2f1w}=E4jmr9?C_%gOt<8g`z-> zBu>NhVz68?5B#S(`kH)S(uiDO+=?RxZlf1~2Bz)s2tG4ULvj?$fJvt_$=qp8_L!jf z31@E|&&E-4^(qPhc-Kg>g(r*^ZW@1(Yz=o$ zwZlMtGr7W&W5LL6hP|$w<%AZpw?%0jA&*+$rB2{#w4Y5Z5T?^wB4}Ikh}_gaQMC=z zCEedY)pk2}%(AF`3pG9Rgo97;=>{(wVb+9bkZ5xc=9V4Gf;7LXN@L@I~=Vn5@-8mE+cBGCvY z2sKtBqCrF5_6!7Nw-wro864tB41N30_5bsiqin`ZzYqmlGNT1ClW8N^-8X|y&v^B<&h=`>ZK4{9jj{u zg)b428*rD2DJ4>%X_%bQiw>o}W#|1Cf#3bm-u!O3i2nH3yj{H_1sPvI34|lS-wTeU zz1lnaNQ#j9i@PHsBZKvq^a1u@*`>hkWnuQeDtaO2O-W#fUw(&WXpU5%LhYLT6Y}O- zLE!*K`xH~VA0+FDTvLhNZld&1JYw8APM}r2;50;>M7b1u2o3Ut{(c>2A+AGRl+pqz&OgXatRc_UviNfb? zlMrRrV(DB^B>+!kD@9YoxP_4>RD>U~MJ^A*GPz_}{~e;!ZV=V5-|kXc;HZ^k&l!EV z5D-TXK+`h|W1|M6wDSvHwXzsC+Z?F+l2{q(4OJzo3Yv~90dD_7+|{E}_! z&Ujd>iDBc?>;_pEJ_Wf|p`+h)LdtPp^g|!MJ)4sHy@He}8(<|8c0rALKydYleaFUd zNc|>9ZE<-R^r$bTq~UZ6SYo)Gq*GV5Io0Z5pfc=#Qx2kJKd6C!NPOp^s!c-uJCs$4 zS+YmjCj(=cIuwB$L4Qv)k)Zfz3*PzDT6Y$t;4f&=0pO+O&(fb}(%XOh1W&m`c+|zs z&>%gt!SLg_b=sFVui4?gRY>f-`I3aqu)pW~r2{&(X@Y!Ynzg-7#L)_>LD5N9daF7? zJmzJLg87E7cdh;HPYrvlmpmI5#T}qBnm9Kbe1Y{|rVOg;(-sPfQpB->L1FsO3hRhsc3TVpuopj=RN1 zBD|AJ1gh*;r5HXLa~oOCKe$|EDRV#mx^cWyms%eE_V@A6Eq+`bV#~WFd=3pjoC@h| z=>bx@Y#KSb{_yry2Dp{hz9yE*J7*88CX{<^&Fhq;3$0JWbTlLtlLU?kVmh#o0p){u za-f)1J6PL;tP|^8rZ>N&9-Jcy_9CVanSn9wpE8rr!O!rJFYm_pyVp)X51Ga8;Z1Qu zF>-*RHKv~^ei=o2wm;yC&VQnGO(}}MB+iAOm%S7OuTHbgGEM|zeR)X(ptm>Qo7uVz z#Cb;PaBBO5uA134YFfppZLN7%6TbOpieEID*-W@Ylwi{AeF?(JZ*W7&p4*O24ea{d z@!!oiXZQ9k@Y{16KR#od^R51Y|L$@;%hg$+mOGt=fMKp@JASh|;o!E0@m)G53k|3?Afo(q-%oAm9~?RC zmBh{d9n~9r8a5KgO<}i;GGKc~J~XMadB;FO;#85(3`HJ_Ib2bBVxH#KcWlZTe1RUd zC@uG>%V|p&AUC=P|DiF^2JTe_^4?vb4fbJEbkzrBSTy`g{?&qEa|JzUq5$j_P!qs( z5kjav5rlhb;{-nam8LUb4_?S!#2+zkT~YxG6AaM|mkh%1Bv$SkH(rNUq=}vY2&}Hw zlDYne=6mT0?YJ-?m@SPF&Fhq8Flh5bO-Ky_=k6AYp62{F#(FBop0U`l2 zo0O}5PB>6sUQ2`!B88e_(YVMRd;!5p=b6AXjZ^2|%l-O07Jua6&RZzBU+59t8~wC& zESFZ%7wQ<_VLIJFrW6s*B6uI@|5Axfb0Iaxwh!OnTYd2y?l0u^cG9%jFVat~8`yNg z%>PRZBBz6ksJ7Ey*Jcf>76KsyNhcs;C_dii`AcoUF-CXj+TN&7mND(xMS zDmo&T+bgMh{n(T~0pNRaJZBQZQ7#{mmQOGF|HU=!h_h-Vb)+5>j=G zfP;UtU{e$AnLqJZit6N`h{!$sQp1${gZl6d6iAI^Ja`~Jz?>H}R6wqp2?5@Nhs70E zX1M=u7rx39iaE9US$bE4jGU`48U`XP8X_!FfDjrE1w(fpdj4q6TY54M``0fg36gCc zkBJH2gXO1{Ary=C@xy}r4NX}~K2}Gedul?@9W43LwY4bygJ_!0Mx zqw{>F#?j-JLqj`05b13tHX9a%BZ9G2P-+34Hj!?~>)2Rz$$dhwc4pt0{|SnCMI?E^9?}j5XX_nY%}Z&IKM%4~qM8zQ(}JvHzKKst&_iDRVPGDZNJU7Hm{Lx(_;R{%ie+INQ89r zGZ`jPNHf+ZiYfkUqUMAU;h3bXVpToJ!(wEitW}C)=c`(Ki^NVeaRC z@}og_d-}scKZ5(zq9ZaqV9+tji(`>&b9TS--Ks**UtoQ`LKlP^ZvqM?@a!>7H~A*C z!A98=**vy&_9Svs5&drcH%3YIS{V_FzrQA#i2Z`v(J20hU=+vzi6Mf8Kf4S@3KqL_ zzful!bIpY2n8@emm+KyIL1j&^{}s=T>HJXyyQWLsV#x}IPKurj-YFtT^L$hcqFxoxS`GtMo({+7$_`rUN?Lj zd!>8=P5ug3e<50+R6xr(GwK{-xqQ-m#)1A@gob>d5+yx_iG2TMVn?vCQezrif&lsA z!=H^Pm{1`EVtXp~#fTu_sK*`V)AzPwR2Uu|qU7;A&MFjvIaEg8F9nU6YQVHQGy#nv zbW|2=(jf3R^TFavX2XHv`pM}n|Ch}tc9|{&UcHex0IhbM7?70tOFoKPQN%n8_ML#2 zOhGsph>i&R9=C%f2C=e3x%!Nvw!)3#>A;&isHpM@db(+fw@+$a6YkXwRkz{Kp%i$D zs4t%wVxa)d>-0nbUm_hxG!jmMC0sQsT}hA`9`pw8IvX??K`7P|l4HD>qn#RFt&KrC z1mV#Qiv0Jx`zc>9Sr%Fs3CL^gd`UzgFZNZ}&yOmwHx)<-%(ajR`+^Gu zB)x7}IWc}I(swi$Ps!J%Ham$6e?%)vmqQ(=`2l2DwrazB;7PNvAw;UONMyhTt-0M( zs6%_inACV#T671I5IeYNq|CM=1v)tq1Ua-`ZN4=^yKVaX*{G<9jzU$uDU5V9!3gj_ zK*Pa-=3``@Huhrb#UM6`O_U1RhAP0E9HW@~912l(8)^5%jkO7x|BQvg_qcK+_Zl-b z!9<`NTr_4@>w{^r3kx+KB*(B6_ZF(_*Vag~&a=i4TC!2KL9OfcVM@M8#HwakqpZs^vv|=Lk|jRK+@s+jzA6x(W68sW>I=Kh9WIU_YNc*{L8Es ztX>k`o;WYQW3>pbN=1WRoKjkMf;z*K4cI63Lyl?~_vXRX^&c2{=pmudf|jEq7|QbADQUC9E~GSjpr#e#xR0f9eNuX`({sOMH;z)%w=V>vez=^+XQ zW49C(TrbvrQ1Az6s7|&7W6L`;-}1h6L(db%EFO~d1zd35-lUH(8AGK>o$4#O1Dj3U zPTz)OE{Grtz2zO`iX_Z z8tnJIOZFdL!O4@IPmz>V>^wj4jrNfY$g_YeY9Y!$g`=!7Bi=~0l` zv?GVI(lQ;b5pH5+zaaVvH+!DlQL=;HZk||_H}*ym)@3<8{jP>i(sIXx(&I|_bEucc zj(9mofZ#@9oy}gtr(Uz0juTfq$x`Y><-~pza}x)tRJjpR2Z^?t3K7F47qM+hkI{I? z8_emNF3!8ABKT!)243wM~=l8?U?;TLb;sGnh$SUz#YOWlccl=HcF z`R_b#@*lWg?JbFWx;=<5d02Z~tt@lLr#@-WSmmWjP+&?tmVwT&v*Xv3CCN(CxL*vI z#gGX}S8d`vV+AT53Gw2!PpbJMt;k}U5er-YuNNQ`^VsF+59ZZc;;1sFHgUp3YxaVd zH^)qM5XR!Mxs}stt07M@>`k^@0cS4D!)ASqKk1{PF7uX)0HIzTqm5f$lA;;^)vur4 z9Yz#*hW`>)EFxq7+>VlCv}53ir>@rg(uNChc(suOCr-V&jkUljf{iM28&!TK%R9BY zH)`r~2;VH?fa8SnrmYlg;cf;+ADr-aJ`QDJWkW6P;kf;1?~VqIuf$S(R8o5f^v}Ba zv$m@Aym$3pHv1 z^9DuShUAt(+x_X^`-KG@`uGahjScT5_K~xLEBk>&_t8RFSa2r`O5w~>wPpCPj@!-D z1^Td}hRnled%pO8w_eST!L|us?>8R&W$T{24CcJq*;nV|6rJN_hQ%ovHo3e6g-#wf zFGekBzVUzb7{pz%MZX_22G5GzjXuJ9ugqO}Ykr#IYr2kRdfr=6ZF0Y;%%Y5roZIVo zEQpSNFmTS#eh$y(ZCP&)9ga7bB#0)REg2YQ!!F(Ojo(t2k4xarkzMS<;9?($@aBy@ zO$3)ZvaCwT_ayrXal$4%5O0Ywy zy`*s-;pFsWe{@ z%+uyF^C?D*G+rVq<0~gu?&VkrhQI0L7S2{n#mBm^kIKSchIyC`{%mB!%pD^%BER6M z8p{=}yz?PWszQ1C4pHo{E3Jyb8aeAfit*_*P!D2+%_)>V_$yQn*jl(oy6uvDu_%@L z0*CKx{21!OLv#rb)>K(7xwhJA%8G|fT;XJ^eJK6;sqULb;=Zj+hFmJ{!-= z{QQd3(wHp(;?!6vh_1aP-=-X^MK#obGA+npwxTgOtR0u1Tv(Y>QE<}gVW;JNs&ndo zl0RTnJ` zf@ryhasq^Yff>9zO~;Mqvx$R9>2z;9=`>rMWZUMKpgjqba^R@if5DlW=Zol>sFE1- zin0?MWpUnhI>2{UEVK|^O3>_enXJ4$(ok}ZvTV-JOd=cpyk&G@nY=~jdBhIum>DD= zR%q!-?{=m*-~b=ubJ83^7(d_l%Z;`YrNnIc*#z1}oj=Y(PYVn_#iM^Z(WQjRtwJiNoxyk|aDd`~fl^BQO znDnK36Z!t(3KoC#S;s%K)^eti(?6DUOm7~Wgx2#NCJ&uRi9P&n{`{9I;9~JufeiBA8b7h_TT)43cX%9`em4@Lo&B1xO zgsN{p+cG$?S#BLAix)3o6pG7NiB;zi^ahltC+B>;p%iI9Tu|KC`|AVyMW&k5ep67VMkClx6l#{Z(Y1--XnnQJA_|lI?$*FPqlHlZW5zM?A$BldA zUntpmHhx9+gq5j&b;xgYo7(j+=72fnU1Y`kgRcQUaUv+G=Z+YR8Z)%+rfX*Ss4b2! zLrPc}d~VHYy#S;ICDn|)^`uFwEggLHFQh#;+GC=SBt!98(qa0eIa6$MJ}-O*Jl7yevrHI=Rs$j1Pa z9ge6?_VgPL@da9uvE~zTdB#jRyS~pC4y+lh%jIKoTn*Kayx6s8etk2lZIx0^0T+gS ztBc5qlBk=O2~M7^*VKk;iFRWJl$mm728#2sa$Y;^KWEJ0`bzq%sV7GIicTxcO zH=Wd^<3)=Vq7a1H@UUH*Ydz*Gs~tth^DkKr*B%7Rls7vLd883M47>z;^Ll9R|B7#x z{3!v>(6<0mS6s4k>jdo5SC_FH0fbKav(ysN-QKe$C(U-ab>9&od02xTZT`!OXaSAw zC(baVTpi9CH1=!~>XiwJcpN#G{XbxWMcnR5*{X0W(H~-_s=EttyS+fUU_=ij_Og30 zeQb!uoMtU&}<`ccW*=R~}@+5Xa(l!XgQN(Sr(^I58v?WOI{EgV~dN6enyIgV~bmPb70s zc*m^%l1EN3DK<9kJEj}gH2?)Ob6n%E*2S0{(ybPTeEm~p}gk||+g)IYC{ zMdT*fe|)7H?$MR~2BL2Fyissht1jO~9JT6|ERVBJqQ0u;)#Us!VXl-6S5`h*c`VWP zI6o4LA_)AD?V<6ZrXweJnjrj5OLciohMTlZUVZsnXNUwsv(O=~#OF<;O4dFC&G^f7 zIjq3B<{UyK+p?v+;j_=eJ&hNe+N~050J>bk-hkwl(2qisi9i0$jdCVDtvv7`NC_D$ zsLQf8if-FI3oJf$`7)c9gw7d{tk=OAWC1Hq%rHogp&5R=DT}6KCf3GTT);WR-$MQs zO3zK(WaNI@L|2hi^2F?+x|);W($i`A$z$lAdKF)TocmAwwO5L4$H^kHe3MEimG{1r zRR-ndWgfSA=H6z_Nsaqy|D~Q3#VUKYr^V)vEYrL#<@f&Y3wlQ=x!fcM=vLOok!1{$ zrNNG5vajhhE&W?E4q}cOp^W{gNY0h(JM~TF$jXeCaJD?mg8~l9!EC3}T50-6nKL)` z_&wVqvSil5Ksk%7_A%pY5)^w=R1>vd@TcaIP4l^H>h)*kB_5?EnH@{rx*F#*2lzS> zWbek?lt0PrTkVZA_&$#Yci`PQx<@TdZ}{dD-l{ZE1AJ?Rjrx8x5G}+7mZH;d^}q|ba1cE96jiva7J6`^QsuKFrhM|j z_SDU@kiHB2YvHALKbY?~8>r>Vb&e;xpqgA)T`|LZ2jJU)Q9o|Ev z6)AgLM?#*d@xU5osLl(``C(3i53hHBF0^q?a0FsSn4DmY?Y|yFaCb1B4ep~drGc|O zY)SxI@lSG6B}!-dRZr6qZ>~!H$bo;0j>qNsebsWu^RD~7`xbh+;wR+dzZFLMO&3d+ zQ_a+h8;gkj-jORDbRvJGDV;-x+Lv2sN8u8-^P2`kQHqf;uPgJ-nHh zTL$cs=N@)A-JDM08^i_Yne31&tdSlK!1>PuWwYHkYb1EeQD;9{E;i~GIoP*7XFVJ+ z5L9oz@S8DY!9+XF*ZScIt}6CP7RTAQj+cyyX2iJ52oC(Fl*5}JamxZ8QkVb7u`(n+Nwj^p)8lF85{z@|@ z$}`qY<1l-69tRgIa!y3$W?~bCzq!lGFLem!r%)?!*XHCZQB3Re{n03+1pus8@+M11@gSKAH#1t^1V;ZmoP!nqR;gF zL9i5A-(_aBnQzwIc5qZyH)n&*pcA91h!)f@YI`bZb-mwxnP`sShaN!Etg@@mh*A=h zo|&D}KEWA{w)2_XZ*hR?>>0XS=71$~@t?^&PS)Je>R#Mz~Z_SzZ3Rp09< z_a;EG-tOZ4;-w*C zZ3>D1XNKj2+u4jp<*);osE}pgNa0YHe7H(Pl{C(}+hRC2U#l@Uc&?O!2u_Ok zZTQ!psZ=-Hv98{;IR9%1CNb((jkG!|%r__hjdq0aE2VB=+j7@gYQ|41@m)aac6ufc z6cjpwZN8hn=K>r!OjS%eZARyOfhZ_{!%MkF1rGpa}Q<0 z;tFdY!Wu7loFg6_qDCLex0+PyG8p#^C82dBT@0s{bfDp@crhI)3(RhP&wvDo^g?BXNx;rvT0U{bRzfyCJDouK>$l*MuJkeBO_kGc#`})W})P( zF?_7|+(|k*Ve%V|BsQn{nS64=%O&(lgXde?Mz3&q7N@bq}nvvA$yUcQsu{Z z*4ZMlpo?ke7WO*-=rXq`Yo?^M+bK<);LsL!9I?0+&7Da=K}L#&`I;*%GG{{%?KWp8 z;WJ&z-H@!sn(wubTZAVAzdw{e{R>8v^*B304RnU^X*4}oo2A0uh-TmZo3~mSSXMVk zB<-uvsIXj$C!IAVai~IYbmy_y!bEnbVgmy&Q-reaFM?$fACDIUriy&gS zi9CB2VgxSs+@tyMlSMs`;`N1A?(}=Tc8=U!bD$%sPym`=DmqHmJy+w`7k|g|)IQfF zU)6>D_9PIC^pU2v04n(A*Wib%!N6rW;RqDYLdiK~T{y^Z`z6~Idw-Vbn8vI9`MO0( ziNaa})W{58t`wY+!U=qBE>kD#xniqC<;{n5Z(TMsao4C1|Nc}E1t(E{P)FULC~wd{ zD4G2?H&U#k0y80$GmLqKdvylVW1<*J8Hz#8maigbCJyiWz19hTm@n!Z)J~K8+f^G6 z3g=s5aVJd_o}){aWuvC}_J2niJa&|iuUv&@m+IW;-Vuoh{?(M5nCiaVl8k!_oHxte zaj#G;9V`bm)XW-b2|-FsUeD(1o8t1NRR2^WviN~FI??)ziz1}#j*{oxKz}9)ds;-;;OE=HvJ8(z-IUx3S8 zCxusCJZ7yRcABF+N2*(sQTg}1U6d8X7PfFW_`SFdc9l9zS`fDVfv*rrA~xr8%M1~n z<4cpbCp;+<Q(=!AzKX3AbpnAv8U9TGC&>6Va272=jhbDOJ978ZWMa zN|_fIx+%AJT|S7UXaev)b0}KdJ-1z4ye>Lo<6MU;S|l_#(~k^Bq3Eh)$`ujDEnUo{ z!h71v?ZH$0Mc|bw!slyQ;U!O}_n}W*rohquGo->v?=$TxxjYGDPMqR)NRrK9Uu|&J z`QFOrgI8&{u1E1VNJQ%@4hloc^jcf`(Fe%;j!!h zHIzz4{hlveU~Lkb$PoI?&v&4%mJ>~(o_3EWI@uv`=`ucX)DfvRj@^!2Iz&8e9T2?c zT>>urmSQ%;2jGe>}%+)K>Eh@K@JTUwV*Y@cs9NB`^ptk5cq0D z)V!V0aIb_MQ2sXe6y&r24BRcjF^)WWUt%emKqsVK7YZz>-rbG|APkmFJ!>owjW5nE z`qdH~OhuzdI_M)nZc#Y2kXsX=mTlITEW~xA@7;rhwP-cBCne7I^S$fuu9z*W#T7Rt z?K_LcT{}(Hyp6$dD1n4W^_1@`6iGvlWDJWL_|~7Z>u=y~-F(NcZxIGH0p>w{?zb1% zZZV3lw1@Dx!uPLU7pDrHYFN#5>fc|?h2e5{|Kzco%RlJqS+dBs5k{yrd?;ED?Yv6? zV)`ZFGKwvQZ$R$m;+Zp6Y;LJu&N3w>_4MDeUO%yMy62_YryjRfESHt=D!zH~61T~R zXw3bt(G)r-qqIj?3Lo+F^tnPCGR}yF`Yl5@Wml`u^FD)p}+==6sMhsyL!f z24T0vlH9{`P|f-vDbG~FkQlMC|Lqb5qkbfXdAEnJ zLgiKQI?nGBA!1@H)1*B;uEDgE#f-xf*1D!_9Jw<`V|(?CR?B3`8>(Cqw}%21+;fM3 zZb`8!j9girkcLS2deqq-7XPf(yqt6H*L=S%K@gT``0Bb@{+d?bd##0f*0DDQoV$(u zNDM;&+sl}nY7S=U$&KIrbs>U91I)X!)QT0V4;L7m?s!8Y3JulLcJsUiG?lL_a(~oS z9_U&*tg{%2yxSO1@XpkDie_^K@y4l;y^CUrk&(rAo!?8Ynq! zoUKuq|Go{~kdY1_xf=dJK;=@3orNw;x3n;DL+p5Yz){Gon(>A(zj1$k!IW%u=`E>` zG>7}4*}*d^5?yjjeK7Z95G>rFgZELk{A2>w3&-{aC#ypJabE%KH$*6~;@Jj_KZqUC zVpI#|j%UAOEq2?FQM@JEwmPx{-6eU*wnD%kMeq7vZzD&MyxW!DzVpCzxC0Qt6TtD( zj*FGH;o1F)q6n)*+zqXyr)Clr451M%Uz5(Qazz9l$>5is+eMz=j2zuiz<;&Wd(|H? z`0nt)NL;F-@mBfVVPA$}9_@FpqnTNI1N_XN*}A-e&JM#?jqC9S(6>=;hHDqZLul~V z*V2~eY#VnwWbU%woXE96FP4le(F+zmZP96?5>TEer%))^Ltg%4ET1i7necOAwWL!0 z#|KHTU&W)@8=4cLi8EL9x^RLv(vS$Kz?X+h+rj9=46 z?W@$nO}U*J(!-e$Cg?5C>PvoWlyR4*Jw^(O3q6At3%Fj4DlP^Ghjsa@^FX0JZ3}bk zK|MmB*vq*&ML!gg_BfxLaBh4&S4K*~h?xj6^%1jaUD~@nip`PXJR|$oAoqMCSYm@{e@vsNaM#pbgH2uHdy_ z0ViDZE(b3a3f<~IulD^;3J3y7=W)gk#jmdTexsb16N4#QfLvdiLj|}aX#b#Du{k*V zoC2P{<85_ORoCuPha!`}L0kJhXU~s+{@gJwDx1(=-El9p@vPH-dDOO;T@~&&?v=z? zSqa&gkHA6Zip8&BeaSNDzB2BSUnndu9sqh0ZSl}j{_VgJix##0VZ7;nz(~o^)5^kxFm+ha&{&Sc!Ciqab*)cK$qdKzK*wGn< zp?N8ZP;?d&<^vhR-Qe7}OJpu04>8J~WAJkU?;FeZ>{qAQ)PC38tzbNgbhw>`3XeSQ zrU)eSaXO@Wc@~G|yE9>GU5!83h^?o`bN+BiYA^ukSa%ni0wAr}E`|@S96dc`I^GLPmN2D&#E&Dz@JKP2Im=Vy z>Es5d0`f~47W%3!rWg~^tM%F)N^}`?tIVTbcXDFcIpjA9L@cfm|JdTFCusiGR#2jr zXhvN+s`S2T(2hO7%tD3G9ozEd_!ps<`C_|xW3|@)%ok~Uq^5_RQ`)SqvTUvY?o}2k zGwpHTw5jPK>XWMmi8K`nO!^Iw78T7IhoX{ei80b4Tv)UxB1=qe_@U0SxJJ zNmOTMT8CcA7q$R?2q))n<}>y>72C+X0G+wY8(tqRB)@P)PS&J)@k#g@C?GTkvyK}4 zuwBzQRg^GxxwGwwIR0_Ktw0G!YE)F9>tSPFIK|1u% zQ-7ZJZQQIQDb3&78MW@=@OomZ*Vt|5ZY)je+jf}h5_mY)bolzX?vcZWwOzUpmz7PjoZh?tB-HqMh}k~g|A$4 z^t$+>Ri^Hu{7JZrz-F(DJ(;^}VF= zm4bcoX^(|EHJoDn4{N3OKYiBIk(a+!UMg(FeY$$~-u#&mESJW*S;soQyzb-<*_qsfDf$8!7DUq6!* zBEpVw#o*&q6DWETZrfoS$MjcbGbwPu|%iMMBSrIV|Ztgy$cXfeJENr1mbP_i2f8=*=JZM!K0$(pc zT$%PTIKu1uB)Fl&8?%aHnFDt7gH9Zn1PPu)TRXBM)WXCCp0h()Y!_dP*B(~`^tBps zNz{pm0u}I2z--vn>BLRy9I1n_0;==MSw-E=9vYNDkT@yC^#=W!IBu-+6khZ(cXYk} zvP}McD#vO$1*5U4;O{);hw6nJUMvM(WWAUbgkT(nUovz`Nx+zD}o$Assda;jufDN zKFmDJ{!?2xXn-;QIUO_(KLCx|&|K@=XHw}AV=JY;};d78Rh!z9n}v=AfS7Cv4Ix+K?D3;(P7p) zy+f_&cVT_CrMH8|-PYK(zgXD6+EoO7*CRW41OyPW~T8Om* zcB{OawnmDC*%`3uyCyrEw;J4<;S96hzLMp-tzo=W6aZ@yq0oNX7tIO;CXD{JZ8owt zsRwL?p*&7=XFErF)hRokL%v~Jmd0fkEX7dA6>TQ47fZ#f?NoE}m`!l>d#AI=;jQxg zPDm0kU1M9`5BwV}bk7Wcwqh8jtGulBRtO9sG>nJF5NReZ?K

dAk})I|=Md?g1ku zvR!b|7DW%okyCTukisSi2keuT1q57xKuTX{6LF6S;iP}o^3o}C`n`@UkdF{}2xUH{ z{9RrRB-$({+y~VnoxF@*UolJ$Z?#5`yedg823wnnc$rV>od&YA1n3}~SIAW$kbVoH zWFd&m>>RN3n&Gr)dIu0t|J0?54sWGuoa}G!y4n`TaS(sp0v7~huIvL1!TH)H_pP zcfe;wkJRS|(T)$Si0^tTy=aEph2vE~VJQwBmy0wwC*=Obr>EP$aIc2r-9r*EE#%?M z39V+U@}3U&XojEi^KrxL5Akf~#1Zhwwy|{2c`FVJ1wVd6Ax6*j!4PKTiGPwDj%U#k zo0;(eQE|Z?q+a|_pSZ0Hw^>^YqcQ;+WisuMh>o$+TYtb32uR6!S7RnErqZO}Lup1+ z-8~8JdTc2cQjpDX6%8?fhp9xj&uySQQb5y}=aaRRp#cje&d#>d3r>`HJ4hK!rZ23K zY1IsuFM+86yYf{YfS-Ep?tYj{L@y0pC=`J5OOj_`smb&^D958U&l8yTNML3r$HipX zd$`41uCw~)9%8BF*U4q&@~SK%mOyn;Si-P5&gUf~4;}sClZ4ZOz+UN+NI?^1tO(Tk z)SCo~yaNcct8g2X>wOTRuKm#97gEs>5^}k=vWS*J?VRLJhp^}q5MarV1fwG;JE{^y zVaiD-kpl1pE>7S=2)g_GJ?BI{4H)z#EsMFm!7F^kAoqkR^?vcSYP|{12YwxAHP+Kt z70ym?~yH&t^mNj|~dWsv2ZSBv^ttaj^TN#{^xIGdvS??dm=mvp=;zs}=9w2_( zzr^5HO^#c;TK-kqGH9>&p7Tu?BsoBTOq;CyVe@3k3Q%`9VX8mUufxEc^26jRGo|g| zR8j{NItY*_SMl0c!!VTLwP|4!#Z16MVV$@HG7FAGz{pL3;=D;zoUaXvU!@3vV}^*z z^PY~qbD2zwz?ZCVD)x0gKHQl9k|exn>s7~U_p0AG5k3VMjG+%r5PB;F4vsV0Guj#m z7f7}u@9BX%kF|^dWh+dg7t6(0*=^Aq>wqKh6P?-bVM&{}^CJ(g%e~31i=iX!I@N` zUu}@j8|tM4`vAoKf;uYx2R3ZZ^sVMr&q+Ov&ZvEUlM1s8eeOEV-w4b~Nzp!*=?)NbeGlmL8A~EEjA~;l3CM@^SAyddc{riDs+{jk zpj<>vtuqoK>mFOC2)ci}xi6*>ZeMJQWD#g02MUB^@(mzW&87V&;OEM=dPBrO^d169 zmv8~Y*3-d30BHgqoD~4V4>UA2VI(AzOjI$)dde8orHa7_>u^O;;Q~Q+0QAWZ-y?4O z`}wy7^2oF}IYo>pkJD;|Sv>rkB|$*KdaAluzGsNQfM$gFvSyJh3%$+d0tATQ0*-jK z*g`1|)zfNrn~Or|v6 zi@RZqOr(M?fbkHGdjl)!x~<*w+nWUVx(Rb?hC-h*$LCX`yLYfh((fC}r9RZEPz zd|z2E`;i)@gY^`j(0ADZgjojmFF2&a+ESkTa054IZUN{z609Zvev#)egUFDG4lepr zt!93|vdsE0OI^2|(0r3-a`wm@d}vny(wv}k7P%r7SU2XfPsU@y!9X@oDbhxm@%w!R zXeSm1B?zzd?$McyNq(T_$QYe;xGB^e-hu67WCOw@7I62U2sJwahikmGr6|;$+6Q0X zfJxBpA5d@pd2zT~lChoVa%{Qse)`yU3J!)q^uUhbozBhSFJ(FfH1p+Dvf;klw4W|lOrMAsU+%Kla9q!K~jo1e?Z9-=NuZlRCH#^q) zPuQdq;GYl%)CZvvo6q?zMFwcKUz>0;y{ST$5;-9{9VPD7Gc9#_dPTQF*h&BkS<3bF zx4aqS2V?pjDL`3N>n6jH&1TqNr3{fr0ZLToi93^jN3xuHEzkhm5@M7M&`Iz$;V4cY zTy-#1x#ozx0fvK?iSyBEAqr{CnNTQA5xgBv`nvMu0Lf)f-hr?!-5T&h1RNg?`7SCh z7!9ogssUY`;N1H+Oq#<_3QTf9{bdEAxCf|^!M`Jw3(Q_`=NBa>zX7U%K>7fa&(6FB zLc{(0tF1f2|At7qQe#dyJGCY5$o#bQ=UA8 z(A;*+r~Fc;o$@bA{yvAORaznufv#MRC!7nuhbNzVeawo;e1vx*_$K((d=H2Lil+Ds z_AB6HD3GW0M7>L#L&m=6pBuo;0-SgUARIP-Vpb6PGEx3dw?Pe>s=*0u#pM#K-&8M? zP70BGffrcWvdCG7bFrssI5H-6^EPMhY7u}Zw0Xj<3$<&7@kmo&)wqgZSogSGVsTb* z!($*E7>YiX?RgTs8h|Ta!lvWCodOl|j}j{P%TeqH_OpG*%hMvTzwk$yO7>FwWiT1C z`SF`BdqC@0yIctedKn9{Nrh%0P2pUe7JvDKiGA=m(V2RpF-cInv6{9I<2~C&eLU{q zww>?18qdx>?t;q@cL~7+>NMnkSFcbArZ#LWFcQMEy)+gGREmQ?K0H_Sgrw4c=T`;4 zr%3r+I@x1r5j7Yz0Hr@=8BeUXV4XHSv{!-iP_+J6j z@C%sJAX`Qqprny|R^0O_kGD%fpQJgwpBP!o+deu-%)&K zW$kg7J3NwzJTZ+BV-x_BYS8m^;%%oD7qvuU7@87r7Y?Ob)>N8X%?>0c>HP z;uS-KjOETk6i-LuPjvnr(>L$|B43`2Ba&Zf32;#3^BpmyqExc@yvz2W?A}xiM$lA$ z8!IUwu2#MwQh21$`4jv&11tWdH=@Bt14IW2$ihF@C4t6&e+25J&-FQXy0d&lImRu2 zE90MJ^+$IXg4%%-eF$E~P^hqp(#Muwnl&U4Jf2_$XUaB3I z$g2H#&eKt~C{>k)LMIKQCStb$yFgL6h z*tu<%;LIexwHfBr95ES8?J&IP=WIth|7D7IVQJ}qP<&x+(%b)tWdXAgY(p|ppM`f% zhVSzqDQZ&l&7w&-=4{GhQd33$T?!%wGVHG^`vhDXHbKS;fJt!YF;S}8jaZg`=Ldtp zr6leN(oe^z#e`@91?CQy9Ni>Fd9ZTApa|>v9WW;1d=`0Tbpm}oA1tQg3ZJljQB3Om zD}uHH?Q6Mb8i%v7!3Ep9p$3#&Gb4fz5-!wUa$IM9@9`TD#FHsA1Mu1aum#~whQG4V z2gbTI{++4UfZ``K#5v-PzJ*&=fWs#WH2#?Q(PS^aQUrdQJb};HK;oXv6;MRX2qHcUx~wYxeQbLCIfQ8YlYf?y|1OQK zj8fTdnQmtfTOlbIacu}<&C=9H!{IEuw2uxqX8mbRf6KgpCX%`R8R$oz0$_vRIU?a$ zcfkF(D$HS4QV#FqZ0yl$SXdX5+f&9Ew{X*8T}Jr(mzF?~M5m~I(5rG9f@q8-N`;78 zzX@9r)GT593HdXI>To5w#^Fr4;|Z^DMZWwuxy z_;N`Qa!`QluMY5?mPFc!cbFCkLV#3ZyY>%3N`khTx<2_jL1w$+-iL~dAy{sX_YK@F z^A%o>=s+L$`zooShZq~!iot2X(U}~yFTYrW8yp!oU?EnZ4n_Kg+&UCka>N$e<<4s0 z9P|ho1^c6ZX;K1MBcSdDbR`4+-M?jnL{Ops@&Esi5TfXs1Bye5REOXL7Q|g=US{{Q ze-NDin|1CvL9L&DZTzovx&qWtiO=O7PWvo^EHbA1yFT1RV^9kuZEE#^Ed+w?nU@6l z$z>Nt7!iOGUM^{ZBPBG1ajkJ@4~RH`|G(S-`JuktEF8?Rf00W#_%BKzPnk;?-9De} zz7?D$e))cAhchV$@%QzpvV2da!w9Jj;FbdIlJJs6PZp&AEWS zYZ}T5_XT$VYkE4y5Kta2|NdX)B+nc;2|smlUI08Uojj&y74IQ6^{tH~OsS!wqgY&J+fEw0aT+zYorZ1fMvZ-U`uo54j&VPnHshSV z$D@V0=3Gytl7b|{N1Tr!5C}n9N=yX=g5UuDzJ!4S{>7FdnF0QTb&%3>27xfh-#=i| zDr6TR5D`dPOjyk$=TE1%rmA`S+Y4kc7A!KD`~^r^h+dVbc_>awSx0&OXPkyDZ@2v7 zOS5=cxMkU|~hD=!S)b+cV0toq#Lb*?p zuBMQlu6~%nw@|^ohRCRWc(_o(#N>8m`|)_;P2f~_k6p_r8+#7iVjv}bjf5re_s4*z z37Lk&dJUz*$h&7gq&uU^KP=p|CcapLg_{iI5dV7y3TmR>hzTpnXq1r-I-#Hm-;TzC z^hp(3Fiu%ZSie3;g8AQ=`4G-FSny$Fnt$Pzz&wPFSO*#id-fZGIwoas``$-_3eHw4 zK>+@GL6Y?=!0tU?0QIplDK?GU2i1oN}gM(&%RtTr^M=ih$ z9F}lV|F`e$|MnebD9NA|)H5x$v`4=9*Ubua&BI|r*UkQ#Q!tJneChdu)#MU-*(LDb zJ2(H02~$90K~aq+%i1!}S&pPTrF43muH;26;e`9&;g3M0Apv_OvH~ASZoy;gy9t5) z-!8!z#vugv5&>i0vwcX(ua@FhPzxIRZ~J>O|Lq_N-9sovtJzQFL=4m6hHsn|YosBL zj~FHL|2-v){qHFi4pAZ4)#D@aYkCwZv3vFZ-_O5WWoJs0YjhI5@T4!H$?78h9UW9Z znMG0&tRy2{q#}wtXbyYp9w7|4r19^`Gtd0UmNpT=&6taU%W*_kX`_ zLH~P`;is@6Y!-28P8a)_p~foczo`Toz33(oH`q+jb`Ze;c?fS@dq4 zqP`K-!a8@1{w4Ov{W}(3Go%g_74O~!_#l0gt?|3Di^~5y;Co}z0I@mo?oql>1s%pf z?ibt^QcEa9C;!YXCw)2O3wMWk*^AyJ0~pXJkoKmN*D(k^{nPFuET7=NBd`2t!bO4l^^K@jv#tPG1~D=5Qmw(~RVb+70@U^FA|7PBrkgAUpZlv=;pFNS zmA@Z2@Uusr?g7t5CSG!U6EN@jzLEpR7%noX)}tQQn#|38JgAp-_>eTueXE3&zN3nm zE~-m+bpsgL4u$5>5OmtJ2%0WAQBQ0+CgIo?&6Nc;?Z^pdl2Q!Vpc%|EQ9UXfrhZ(^ zfvi-qt|bfT8KsAi{vk9Ew5+R<(4uOT4aIkMFb95@j)S)=cDFPQ>eys(T}N`ijpqmK zI1Cm3Fedi1bOdaTd<1OY$%^lm^j>=ZCrCzfW%@8}AXJ9sHwfK@py$r>iQ%uFn| zo|CfXe?=%mUx3j33=E-VDGG(EKQ-a>Aum}M>9gmK+5RqYVr^JTaj{EK16L&}G1ic} z%GFM-Bsy}{5d~Cd2x{Cgf@pxtK}K)Dx7);O4iBAvmrX{ELR#o2mukr5KL>(g%C$G}=mcGOJ0}r=WVm=UR+J$SMZ*GY`gOQ}Br>~U`UaDg zmK#&+^YDe|Dqd^TBnV4XkR{P2MRI^0%;+IW;v7ja2R_q#yVz}&~r80PpV8-RhS z%rN5Py5~MQ)m`%kqae%rZOuE}s39k&t3?l=`N@Mv7nf~c^F14%>n4#`8&1g?Xm2^$ zX}-lu0nT><;)@NuAy@<7wA{yI#e&berX3#AQ)ondp)Ax!`pkM~hVjc-s#l0s_=>2f z-st||2C#*)R1Oj>Y!G&qP*b0JVRIrk7hp?JFMaiwP8Jd7_8(C4dR(s7g6HnI7@1D1$|XnM&~v{0zrLR-(zI_!1aeh zqnO}-5r=Z3J~8AYh&97`ClBm#g2YHrcQ4lUUm6KY&Udw0_XRtd5tNw<*`!uAq4t4d zK8v_io^!QScC+!iSVGk#W3+O&VKK=DfsVo?Kmo*ah$agqUzc`@rhD`6egz#^C<=Y2 zuRidu;>T!irsCr1Q(9wzKd7}(K3I@3<#D!q-h7=n9q6IwCEXeXmJW;zgrlJNd}o_= zQ}gUzm3sp1t+7oTHDIw9@8=l~GK#(1_m3#Phxj+RN5!3 zqz|tAQ;LIa^;{lqWR-uE*%Z4=z7g4TPohEbiZm7(OGP)3vV5x==2nPm6z1HOsWq91 ztZ|zA`{>!}cVUv_a}q9zg5eFXjS-8vCHJdLu0y1%-ngjU!CFZ=ujzkQs&9FPy^dne{ zAd@D9Q3=Z+q`jLfrI#J41^uYrS;C#-iDys4KwhX|VKF5x4kv!RXQMeDW~cJ`^Apr` zw}b}58ffAAxiF@M@PFa576_N=sBArZ3mtdA@Toe{NsnBwvlKM9vl22jP#qJkWli3<4+V|EN&NYaQ`~``oee~7 zj{>jZp%(|LumLTcDzGRFLVtsP0N%4DVs|PY}eml8#B)UM4 zS`?PA12oK-aO>=xHP7YHI=--8(7pqO@$_9|{R`Lkm=MLe!ao@tk9?)ERVj2<447+O zp6LEB0-xyhj6@V?KlZR8p9`OX`V^FW?2g!TTPf?ooK?D&_q@lqC|Te zQcIb!Vgkxn>m9A1yJ-j8fLJ<#XDP1z4EdSOHKXZnEUMkJyo*aEZX}Jf+JT!hd#{~H zv=aa-oFV}@^Nqf3QZpg%?Ty0`9 z%D4_c=4{C~C0AO3F;C$mcC|2|wlLY>y9!IiO~@#Tjhj4!w+TZp{GLAvG58fXh$5*g zHKTmz#N(d6v+hI-`rgo@|0ILe6EKvSv*2ORte5vVyzQ?%9wZqAwqaKAKq`^?g%hLB zgbAxlsl-Hdt%BJyS2IgL^jpeHWloB|$>h*iljM>JJJ0?GP6%{na*K0@WpGhB2b*c_+PeY&fHHMfgQhPVDt6s=97p*= zjptJ&ue_(y@p?*@HQfr9j_`#ED^NMrWaoFlC(!`-YDU2KE@o`QA#cX027A$DF9t^= zRnn^JDJH-Gkn2Dhke65k*NoaU0sa_e>m^TVHl*WdFhs0=%RUkdb5LqvZfn8KPOBPk zGHaVBQkJU!R|ghv>VwVOnSO^qOJrJbe+~ewXW_nca|D@LUk?IUQC`DQiJQ&${S5T7 za7=s15jq3djc)ycPzwk8L!_5((zG?`ZIISOa&&)p%BW1s5A3#xf)jg#N|b3PEt;Io zaw8Nk&QJowQ!%jMK=fNG1cWjQ`mwmh`Dmz^D;sN$9YyjhOUR21=YB&bFQr%$(V}O$ z+M|Jf%i%f;rwg%6h%LadpWLr5!8&2mIlGOoz#bXT;4ScBQZoSue9!HbbZfiDvZH9r zE;famHw=pvteSjBRbX0H5+bkRZ^UA!jyC=$S49P@neQp&+$%OqzE^BClKfuJk|Nd| z*VzsTG@w*24w1#Ij#1{-I;)>Q^Vec7#YLdQM@8;bL(=j)lR63KDWD8|yJrfr`72c| zdy9BV*qSj|F>Jg;T(r7B76V2v?{HHC>tqE?iqG|FHeZD|89ba74EL74D6!LM3P4^; zzw1%5hn4`3&7M#hRG-=vA%QLfubFG&(Wd-D6;0lKVfl_7F=NOr2=J!FCj9I_o$p}A zS@!&i+<^XmoRVZi?F;TtJf5JQ@@Y4-q(M?);5OLHOcWtyj#a?sX zO}6-b?p$3%i}{mY=3-lbUnbr8i{lt#|0Hba7ilaAIBC0C?esrt0S7ZKZ#LT*{RKA& zb^3mC@P;C(?R7smJR4_j;LvdDjsgvkq{^T`@cm=;R#RRbsg1b6OmNC#pp^bho4eoFfbQbG-v zAOWw7c;h_F@%6n^cqkK4VT1{3^VZ)L=}(&M=KSi}L8)N* z*_(rUK^(a*dipB1O1F#GL;T|%Cm;2&wv;KWFn|P+e^EHn2Mdl_3W4yF863B~EwBA=W-oX;0A$@wo@kD3K~ zNWSXT?`NdM0rW2(^){>7BQj${&QJSAQx%_FX=MF~HAzb!fpVfIxM%w3V-hyj!!LYL zKt!EFOtj^h=Oo{;%XD{g%m#EQL<;kBp#d0}HUu9wrxYh!(zAXrgGutG|8F_MZ}XU< zLNh{u>jK5oLgdVGQLU;6IGAzkr7#$nKU$aHX7_3GeyGcAtJL+Z)F zP2FB>q@V@zW@q7bC+RI`PQk=7#j{c3M4bFKio+2l-Z%VdJlrTOl8Wn_ill^@8Q;UR zxLFI*h$R8uilEMEDw4T2Ei(W~ohw2ZW5ai~jx%F|JaB{xpN8{<0y~58q<))_mTozT zA3N?ZC>hS~wp?mV+09XGbkRAd^D-|C^1#pupVWRhZmhU>Y>oMO?H{9@p28uFO0nMQ zwsUiKwkBF+g3J;@2bE#cWW&>FBg-7&2XiOlUj$(VU+2#?+09Sgu1+UptMCLbj$X%s?1vNAXg`g>8b9O z4S?z#nkYvs|m~(%Nj2B3^9GftNr~07D&Oy z3jO=*<)9Lo8L3E^?#dC@;Xy~FV>&_SaJjT=e;eDbP@JGt;CxSEM?#TEr_ZX}8Z#mx zr6vGtOEhVM)ye_`OS*>-%L2?f3-Byl;NuE%3otm5>~_5%ceGJC)2~9Bl|UzEW6N== z)}N0|H}l6rKbGx{P|%4NT)*qkgq@6~E2-r=nc=OTxGg?DBAHlm2v8dhT{xMV{IU*^eX2HH2&BvJ0|V(%QRgq*I2c}Nl~?c=#Tk3q z<&x3m5X46wr+X=372Wru*(jr9atI<(eS7X<78JGO12U}xjw)bGb|z&ASrsw)=h2<( zPjgYEz7-)?)J)-MR|mlh=|*;-RvRz1OYR{HV{}<%_3*q67x9*KZ%AU3sM}iPY((RH zG<&QLCP6>0w4ipc{e^{L-OA2p!*sdYQLHVOeSu<|jOF(pFXw2|w@dm}L+H$}z|e^_ zWoG!uhGA(i3;K$;FqID^g)7DgAa$a#p*q`xscaR{WE4z`wng}?bPAO%QwiY}rV_n`z@ zIcE(~XJUwY@h7f*AXC3|p(?q)AIcBDFC#N$B-f4u*786QRrsYaj|YXsSLqC%n+@N} zu>yCfHLzKDy8VD8ij2zd5rF~YO0Fv^%02^+U0G+^bve(ztffpQCt=AX3Jg?4$J%$#6#OV&iYo;${T_QLtt0iDJ=!#XC63T2waB?n)Nm z+F$DPb4W7K+_zSwNke1UGy&IL2dekabb(s%VN@OalXL-MrUw8I;H)M5mF9fZIDdb+ zxD}UybSEb0XYDABnJ@dt#O$t5>9t{Sxvp2N@8zqFK)`iOUf1deB52SvrWRPimgo%s z$li5G2%?C^eZtqJph4oo-eTrdP1j*JMXS_~b|n zE}9~_RzG8==TStxC)#oSdSpl3NF)kjD*yzG=8 zOLp+E_$kaO7lEKUR5tc^fT8ApL0H}Zx7vEv1h}8(?g^Cs)HwW||0GE6FjvFw^vT;f znkD0xfkQ2)WY*Brx9U!>!4mj|#o4$s>m`RtPi*4T)7Ia=eG2+n_+y(_JYT@Sk>F?+5ihiN>S1oL~Q->g`R-xmMdaE!T5Zt04eF_-}6UfO@vasArD>Do%HuTpcZ zKUh7{f;VB>y0xutDAcFCVNg07)jIw>TxI&X}OZx_4qxtSsk zL@E>m3cTXkOXEm6lo%bReDeT3sV{6IP?!F60$g2Trq*g=c8?l=sueKQlER35-8>;@ z0&u4bgqk)bZkreqRF@98&7J(g0>cHp)a1vuoM^;8yS3V8ZkdMXgQWm|5%242_Y_rf z#_0%6pBEP5%Nmlgwc6ro!@4zeU>H^lslVXqUr}L9`OfqQVV1SxFpP_`8AZ-U?nEvf zVA*;&zK#dgPUpv0uf!OfK7O7HGqm?x+<3HoUSF7mA#qJQS!$ozvJf}FBEO(Ezmf!Y zyimn%&)3KD6)mV%96~1<8?J?a`|bv>2!Zie(I5W9GW%}f8=tqQ!(_GTgLrdy%Kl|-eRLKCt6T9V{brb{!4_cZK)vsoljABfh8|pOA9lOJ z)BkdlvUDDwl{|vk8yBksd0K%ij}<1CSar|WqjF!lwF#6~sw?QsS~&KFTQVn4m5%Bu zuV)<>rWZ3*@B}!aXIu~r22HR|4)+PzYhE7w?pYZWs>V56a*B@Roljtj- z0d!eR-lwwl@>CUWZP2P5cMy;fGier}drs)^vW@v`sDWL^1gKL}YtPV7JrP1kN-Xpd zLMYZ9w67B=!9TeG0g#kM0PJ(o&7nn86pgYf>(QkK!0HE0_6~@W87bqtTG&1h5 z|D{j8a(FMR;-HLg1}1^x2CD^iHTHMbj1o!Y0bWro-G~4oxbb_^mY2Cy9AfpjE~kfx4V{xfH!W7q@z=rv2zfcN_sKkNucxNjodps5sNv z@{R&|65Y7x`LoU5c@-Y6pnq|b0Xl)0P3OvCm6-x7p9pQM#Euniu;gS96I)9U?b zdP}KvE$zgX1|<~Ozb7a;1tOO)#|wZtOjuMl;nZtNct09PG^lbbn#r-MY~Mi(D3Pso z|M)z@qMlkn%y#|O_vA}ZK&ma^{p>gf#XPX-Kw9HX8Qs_e7-&7f8|0#661&-~ z2Z7;&8Lhn2WI(c_&>zkh1=tdsYPlkHw|#lY0Bqkc(NC43V6P=u{OCX4PLyqrN|xVA zW{@E?dlfc6JU$2buXYPvfzRih-aGtj6v!w>xlM9t;wsNzhwN}Kb{;8bjRhq!&DXd6 zYE%88)q^p2iWG?qYb5F}Q<7;IFyG+S30TCu8CP@p6`qlUR^$LIriCVeYRn)C9tFUu z8+*!%$%0F9MN33o`Qw;P@%jmsxY@9Z$eR)Qjmi{QHXvA=`Eu*_UKnRlVkXsl;s`a( z*i6n{(}1Oai6|M$ifxfGmsWOzYR$f7}L><{#k)<+*jJzw}T&{8woOgK)#6KId|GMOjOuB67(`kg~; zh&bn4o7W;U!It=Yt_+0lGDbX9r#Y8uT~Va@4&qD~p{(*d{0M&f^NU6i<>JAYcPWH% zy_1N!E;w4!rZ3eWI8bU(yO*|(jS5jgq)zXU%{u7Emb~b)1#Bycp75C|6h5LkNi%_V zNMDwtiarHI&u0~V(6)>H{=1_Axnm)@T$pb-@OFUKf(x5l7(7ZeW zh^N37iaxcm@{{uxfQMaF1_taH`qj3Gi zk)F)N=VSX=U^28NBT8T6?k*}iV^Rcno^K3+0DiM_|MU$2t@rJ#LAzTAb)2lwmm*;8cmCxuEBb4NMJa?EmWpSQODt!H2Wwjsqx92t7?&oSHc{ z<^CLL7LF6XH!PZb_|vZgVS%sE$#|J#Q&<8QTtr_Hg5E`mYjPjQXN^LbBK>(-SzR<1 zv=e4aIr8&EhYglWD{GFiX@#EKogML|eCsgyL8TUFQ4yzgPvxZ&gwJ^ho2wn>vF_rd z0JU$!%~w`-ZDnfJT3ljj<#fC| z(W2%+4PP+#bKv05vfScYnn`k z74f424-__AVvR8jQEfA2Hz|CH?FbN0?o!jkSY{1on4CGhx}k9o4Guxv`2k=f7*{(F zZjRq8SwO;M(`K=h18K4y$!@d5zt%MXSz}!a0VUmNMo&T|2cez#Ggnb|+-*SzUUn!f z!U)@<%9y`+Zju`DB{ysyqF?L67T%ok02GYiIa$^GH#e2|g~}w>1aM-uwb{8( zVVxQfx7r9ysq(!I*ECvBCgUduThnj6leK6ZN=HAo*u+-7)jq|}9z<}A0Pb#O(*h2v zWQ+?bBE>5)m;8Ord-q|jl;S6;shD1mUp)N$q+nHfc6ji2G$khi{gmWm%RI$_IST%+T7{HT+erP}JLS*AlAMjB4+wcYjxR^L{*7A^V;9>rD_3Jnh^ z-Vq}hl1!WTsaY$2cAZf_3X14$!Zh>h#rAW$w5=)v``MxUX$jUcgXH>kJEd_#qJ!A& zH#PBJe9sR|-k=$gh^xL(z#ww^76l9dWk!?jlFZ4!K{D;Ic3r;3e(#UOkmgNF(}M2E zkZ9g7MvH2I)7W@({bU+#H?z-1Ty~-Ld$>Ev7vw&6ZMa@%9*aC1kokIC{EMG0yI?zs z74;uGe;Tf?-USIFr%c;)FUefszyUaod+~h%X?$*gfAy!yoW>5oittIii?Z>CQ%q2+ zMDPG6_%14SQsKnrX&4uWclBEtv8jQD72p&0-IRS1%<>gM)x9XJh z>*mPmC6F#Q>(16*E;>c1D#$n-86%aS8}6#cc=v)25V{UrNR&f_YlaxZSyAlqJrdYe zKK7`3pB(9+oOFEHUWqq2z9F6lq>;WnHENA*VekH(c>L&%Kw=3~>f0br$XfpE5j1rA zw#;?+w}`Zw9_ee$Gpc@F?4kp4b=k}9Eow5^?l#a5=}l*2Ta#~`YWrczU{-}OTQ<7EWFDfs z)JuUKxg3)V2&=x98<#YUMFb?=h-zAs}#7**e5Qqhb3kinVaAgAr_qHpId6W`F51og6S`RoTT`HAt_e-|q0Ub~A3Qa}H*lQv0TvNGoc_yHtX&N}05X|<4K+)wkNPhCA1eT}& za4hh98h-l8;x^>`lj8{Am#$yD0hH=)VMr+h-?X=IK-1$M0&((i>;1)j#t0EJ!zfzG z{}ghsSc_Pd1bcut7-hFq`i|4*YTR5&lQnJU@h>ksf#2&!ZGzp*P#Vy{0mi=#!e#CW zL)|A|G+tS-ZHR;)*<0{HK3S)thu^gPO_7TxGWI`pDro%VpZ0Cp$2!>?Kq;D(sz!aZ zAwsqGt9Mig0Nx%ZXNZGugnV(ax+~ZMN-!gzd-m2p;BAi{Ef)j)CU<~4{{|ejsJH!V zc+^|s1Ci@hXGXTFKNauQbq(l{8tf``vbq>ts;o5;*inE3(GVAtLa^EKoTm_vjgud= z^t&1E(#-V28)qq5GxSkQi~l_C3kO4Wv;Ycvw9%)d9nk?Qu63BD3wEuGG!OhT`^zd0 z_cjwID;?eAuWWm>zAKIUQ^uo>O-{fjgm7^J8uG>OmS=A|iydmnv>$x>7M2Q3kl|0P z>_2Vz+ScAiKuA^&cKR!gPODBa?S@Le%xI~@1NhK&WESh zv=WU5P$PK*j+1b)_{!yRXLdx^a8UgpIzS(b*}_ajcSjjIlr1DY{{N(FVOLqU@8Nf2 zV92mLvdZL+gpiZ~2z|Rs;E`1dyM%ML5ig$=+UZr>>)>r04!u(TT-iZ|+MYvPW1FN!OT|ChNwf(NG z@I9Oc46kHOE{oLef6V}LQRVWV#I*hEJC!EGzAd?d#=IIB4|^udTq@0L3S7r+h>wAf z0WTbW__!?t&eLoS!n6Iz*qlj+$I}XxJtPF2eB_Da6_-6r+3AIUzWunOscC8!!jv>< z=NPsSH8bv{yTa##*q1pO+N!#eP*yMi07z9CW`%ZxIzuy_54b;yX6a)3e;hmy;gHCa z&8~)oY`j%}c;`I&^J}gzFo(c6N4vSyy zt^x)gCMSYF!R{*#o+30M9I;nT(jX#cE0#>EfgHC8Z8qXHd3z>&U+Ko@hjV`Y@B@%d zFUPthFAY-7*xW)?+xBnHtXtRDL#7D*2NE)j2K*)c-w*6fy0-5v_hBRBDtS7=ro3mf zPJ`xP-`GKu7Iiz$Nc*qwuKa2+b`hSCH~P2q@WM`SbH-o8uqQ;z`z7NQj;;>5w; zK<+J=EQZo8kE(l(2xigTjNHdhkX47y1xJ+kw~`I|!aeM3JBe$rg0OT>C)`?sBKs#_;m6;A$%D+4WV#6s_aiT)vf<|T|EfWzOVIJ z-~b4{&lKKBes(K#*{uEIF*RMoquBTu;ib(|g$SD7cSL^#IX}u{&cBZxl2(~#ISPF# z`l2{X)KKtwGETnZjHZSs3~n`FAr2wY#l&~}5Bl?Vvwc0^6@5rJ}08?_sAl& zaSxS8o1vipchGH|TZry?nAt@_59Oh#i6mEF2Z!T(M5>y;s{disL^8b$c;_T)+Y%Ce*OCTArA5+9!NT6H)CCO1HMFJ@&7B zo_9p}zK09BOY-8yYPhtl2AfzL2~`XDUuCrk6Ke&J#p}i+E5X5jqlqe) z;M-dV6OOZrLPK{ZVJSHwIsl^S{QcUe^>Op!CUJG%F5keJyt|O*#ZBrsf)1mQO4KWF zdz;=7iBYw`mU^4TIY9_MrGJp#cj^TK@TDKMTYuwYTfHtxW)>kqFp&$woPAXHIq!Nt z*Ng14&lvBFWwqL$6r7@0bbwjC%z$bGk7zCKbZz*jQX0i4?x={N zTmW)oode^C)#xjseWYEmXH5HBcQFY?U*H{0nY5D~P?uKGZ*13N;|5c-ADwRMcH_m5 znVn&a{ho2Kd-dBT_7T-ov24j5T$IHGt6@g^^{aK&Op>x4MIgoh;(beQe`brBdxyUF z_MBY3Lbt@cswAF7hbmsT*OTHcF%DPpSnwra;r)!t8{&PBT-{UhvY)Y@vLOw}%4L_G zSz_7h?ERh^#}xk_lx;LoBiu9^2HtUQtrn1BLV#9E&>yxPD7I_WD`c{XVdF}pTXhZM zPi*79=IJ33(v4MU0XZHL=~?@eD0!%%!g^@;L3Q8Q!p@8}k7sp7gQQV^muVo%Fr0E}4DCCJb7NqNd-c_Rk5zs~8ILRD#{T=B?UeuJ=ee8wvjo=v+UsTxRVg*$s+ zfIgRC8J!NT4&J$waS>vl001OR-wgdhpGDs(ogziY#(5yAz!n%NXv7NR_r3`BHK4eXS;{A)u&(mO|XQ- z;XVncEP!~%GtFdy)WyQl*}zULI?~8)878sK?gTK4+ri0S8a)=`ie{q_t4=9^W)rA* zthfZFzh85K&FttrjP=`D@Q~sl5bA~KM#Mt70?;ht-VFrr*xzNv(3?@RVMxj1bo7$a z;;PUXW%G(0Ngz9UUkU!Dc8Dbluy$*$JdtGamvSM;aMT3f;9!+~1d9CyQ9L2jUB(N<1z)KLfH) zG@uQ9z-wolC{u-K{e2a>XmSaBX=Kat#3un?WoEnQyK%i;`b}PFn1U@w_xC)|uCW=6 zZa6Jye)q#B)-D8PO9Db9>S%~)oaX~?VTLckg6Obu9Zn_T0^TdnDfU0E}&i&#f zS@Z1%-ZEC1dw)Q$17Fx4pg_p#B=F(Cm@nlxXkK&6X_sv$x0RT!hQwb0O@}$S`1Sx{ z2!Uu0etwmlBIf4GD;3epxr340@-*?cR>kWWVm^E7l zPp>8_a78vMbkHxKeL6f$Ya2uc9~V(fvD2Yln9{X+Ar=K8uL?ArD66$K8Azl({MEeO zxLkbdK6V!dE6_Zxm&wk@`YM21uxIfed%ey}!IVXk|La>B*?1M}ku%6ujXoY?%Rqa8 zOo{vcw|m3DF~VJ@{SUnmy1(NG0p0GOk0Na-dhQ{3{#ZwlhdRzfBFsv ziSox5U}fq8O;#}0NzgbDqZP!&K&A05#TW>P=s@Qa0a;8T-nU$$MD{TgYusgiN$h@q z^d{iW2qIS5kmzW|QO8$apndQP!@`jZZ6pX@;c{jEEDmw@;9_4!mh?|qWq=!Hy=pRs zY1#1tR}KZ=+mUleZq}!YVdaZWZRuPbB-t{%gNeseF4izpTeTq*tvtsxRbK}d>H2T6 z2c1((6|(?q_50b@k&#zwct%LQGXq z*XCg|=@c!w7~fnM`st+&r;E4v%BVd(0+&l*s|$kdq_Xo}A+)d&kbjy3Pi^mM$8b+oPLG&yG_JM`@?1zy2_IDaERER)U;Wx)7#i+zN;@eg_3fLJ;V z(C0h^0BYYO7EcZN_~mXd_0J2?(S`>QNgXGp!GTRwyc*N1g-zHEDz=kDtBmH31n-NB zZR35+4G2<_YUBXAmAE{7B114DDXI0BH{9}RixO{FJOpH4+R9290W#w28m49-tL9mE z0l0*K$Gq=%eA@#nhqhPr8ojnZJmzwEj?H1s&d&!gNZa1L-3?t8^^;->2p~-zRONHL zAcHe~7;9BS$|cOUBOUHkGxr?qqO2~2uxRm?3P$UzGRC~EQy~7~I=iFZ&Ax~Fv6}w8 zTR|Z!eV}8-Ywq`N3m~q;Pjmh@%E0Et=V^U|fsRO|k_UJoAh6T{?P>S@zSBGYe>DoO zXDKg$ZYVz93UE*9?;|m`sD=CaR1nOhfNE;k|GFN48@KT#jisywtHi}R3!@?+5{?tEUfDgVSGSXA+c)%?U2 zo}QUe5<{TG6~FjX{IT-9$kz1XJ(i#E5Vptaa*=ZT38H>KwK$sFd|*7I)Stdv^?v2n zxoE?_hE-Eo1ROR=wXeZLgi6pCNm+3+D5SA)H^==ec5D(_-^Mv8!LpKEZ!Y=!B?eM! zg%T@SOox`Hg=ct51b$4)w>y4pp8h5{?9$(YjqKS*w)mlcolRyBBZORKo2(83_;TEA zdTf6iEWb$gbOTWuPdH5k!VDj1+vRS;%3F#L6!-==K3Anis%r>{yzltmG0Y%yMLAJR zgsv!+b?$yh`c5!Rn4D21!irwXp|DC z%YD~ldeaiw7o#7$HxOQ8j<1A>YkRczcXyx>kl`X}zCO5+1HC{6CA9Fa3iSGXe2~-rw|I%@-%=*Nju>3lO;v~k4ePMg z%5j0;VxU0Sd@NV*z2P*)ml$9E`AXlrQ`qRc3y2HV~LV1MVenRX-+a_(y;J6DWhzhr5kR z705e6hnv*}?(gOGWB#?j00>Zt%2)>!uzlv!1?5f^bEO|9-hVC(KJ!`D07#0d72JXq z9FyzV*YPw4gl1n_yPEllM6s(DuZ|Wa1Ho%1DbcigWv|9FqEiOEJvafBt((d6)kGP(d`9C&K41&PC@yGaVPO#lXQZ4m4-$lp zB3OO}MGT-@6nfVp;Swmi)Bu^l2unh7Zh4h0vit)AXb!;z3bDn-^5RC?h+ImGdR^3D z4@1h!bQ-6_iw)wf**Vk7%LkCNjcOMI(h(JVyZxV&w%s7Dg5I`2-1(+Jt;Ll|`5_=J z3f>Y_{OuhjQ$8T2m%O@O2?6K7vbGZu)r_K`s4OsJk(*1&Ic&Na0a_djN9}j&f9UrU zo_Q0L#0vxhly@(1U#Xtth6~6B#p1K-Svu11`y!)22U{MH29z^ETT(ZEe0~hkn`c2@it6OYiTKk!*2pEb z)%%Llhra#8YPugg(0&bbzO$Am+@#Co2cvir%pTAm_s%f4WRFU)_@0hFisxnv$zK51 zD`dQ0lF;tZ%&kE%8kbdEOT30Nb0xycp%;I&B*43ckM%PGarrsa`g~A~dKdYop;r|k z34tE%U*xu({_po0DkRWsY?aF!VtXEFwYMeAfU+>!?kVMKy`6$yMqDtvWx0d>J;2-F zI=Q?{RSS9&a~BfD@PgE%3)%*1I*pe<=H#YKQZSSqxX|iKSn5Jf460NTpOQZe4ifAa z`}J3?Lpkv4;!O?sQC?M1n6d#4hSR=|fq*5nkQzHs4k1_vdR|+S;dZCn-Uh831L(kQmgLYKZB%6w`0DNX+1^dB>(RzW)&0UY&^D9Nbmh5WLvvhaxH|B|IY@V|q~q-5Cyc72lJLO=Z;?-+D)iyyRU#g+6H|4eOeU0%-N42oAd81}xLG@VVCRQ7kKXgNY!_zqJl1R|y9M^1G$s{<=p#TQ0Qd%p*G4Zp;b`3hLEPG^{pRm0l_ z(-=E}clyHFBY_HC9!7VhV1mfTSss?kE?NGS&p?ApB{C`zwPaNnl^;Z@#MnuR{WEZ< zsio<=jNIQye z9${H}1iQ#^7lXOdrfO4YHL*fchm-xBqJ3-;3$#^M_t6u+3)7yJvt%BpxEl&2R?oFv zOuuUXY7cT7xdA~cr?XWZvzSqmwxa(2we92hEZ=hto9b*5Tg`##inx;_Vwc*AYar6! z{;-#}j*W8wgw{SCF`_$d+=ek36+(k^alrHRfjKFeOd?X5C8$V%K(Z@m%3)Cf<3HsZXmepA!3ahsawJDpVH~81LXJU?T zxBu4*!2P_JajyMRgjuqZprO;qEvQ<<@_9RQ_O;gCi0A8iCvqwuG?i1ql8ceTaSQsl z_CQ@@ur4Ne+ALrovg)&6pvI?6U%{G~KBH_6OkWpe3_f zfE~E&V0#_2_K{}2pQ;1bBmT1^ef&pMYTp zQywBUVpMh=ydzE4yl*he_B#$)7Za4UTpK5QWua9?ZDVkr$zbaaHX-sG$HCg+&Zw`( zUc{*mAlrMY!U+QZoU!k(jTQ*?l`(>Ig?Ju84P{bY!TC)PYbpQAW!>o&c2bYO+KxkB)GSmZ-A@f;nGuG{OUS24v&Yr*xA80ipRc*SS;OZ4Ri+Ka!9)2^0G6Uf6 z9%}~9#JJLz$JvhLP;IHRG71d6=N^c3YghfJYEMIWr?5FP7Ho~|KTD<6aTN zB=KDlyLylTaIJ4JZH&z-dqr5AJ`9nQ=4@f+8{MI-Evx5>i-C4Ko5w>Pqz>(T0{bx^ zd?KRm85aETVlNdpr;5vwJW%8blRh2i0V2>ODxT-_S}kos^Dir`+lmK zdqn_jtda;_58*gh7)KIvO|->q(z4T?4g<9fZ@z~ zD4_sAW7D444FD=HilrbzQl;+{{taq+yz?PCYOF{tKNpqWRg!>PP~3^91Y-S&;7hQ<5_~BhaV`5?#Fw=TA4I06A5`w%IRD6Ejah7f#rtFjlmBZ z;1E=?XL2!2)0>{x@|~sJ>`*1dJ~9SE_B?Q=H;6Si51Qk3K(nV5iHAL;F+%GihE!{1 zru`LS)dDx|mrArQh9XRg?efkm-ZtY-@ssi5^Bf{E6~p%xB_o=F%9OxfJ&P&{TA23v ze`vbqsJ`F#{cd5|uH{-bmTlX$Y-8EBZPzkZZP~W5Y%lxw>hnFne>$C0r}y(bx32rT zQGA)r;La?6o+CXH-d!aOWlkIb=ZuzpOFeX%!j5?JiEpV>l2A(B2=Na6u`4C2qSS1p z(O#P0erL$3O)fY#wEg60lnTX{L*x6l=d0wRK8Ud-PdJ#|;L_sw3o-j_4e!WoJXz)0 zG6@}SQwa8!kq-#`00pCr9g=JWkg-?yZK`foceWG$LH=pkWH!0Bz&;7d$w@4#d(2es zzFQQB?t}DVQy11Q^6)AMfI%>0mxR5YYNXuz;@(~&{mb2uKxk{hyCb~%jm?@x+K;CM zy@FN=`OwrT9F1^#b;(od+En{v z!m;g3O^ejTBEMEL+)*f`1T*;KlW7%@X-i#PYyX{^cYsvKmD}+sJ$c0}i1lY*dB5Oe zDG9gKkH+o$^b&8)@?6g8fTcF68QboT_mL`Z(@tdNdI`?==Z@9MO>$y@4v4VS zmj${V+dPssG_==|9bFL0%f8Y0%xXHg(vHEVvX+W9Uqx(Rogu)m0~cpG4BjvUFlKF2 znrJrorfVE9=SUrR@gdlnd*0lmrzWcw$2WMO+YTHAC3=*P#}v_9Lf`i7!mhyPpBRE6 zm=0~`9R;-w!*Gc}3!uYe;cwCd!0-)F8n>a%IYo7TY%!$n;Ii9oLjs7Y1SB`s*jIdy zaoXi4a4)7nq0STNFYS879U9t?XW2=%K}bo-iH^21L<9Z05HCsfQYbH@VVwf9r;?P0 z_qjq-U9oNgzPlZb7qyFAsrOeda#be=`v(TVc)BMi->?JwVNVUw@MXI-n%n3ke9i$e+PbO8f|ZPMjpsdD%Y($k42mlgZ^xgGkA_(t!S|*Wz~ThsCK(4kE?elhMU_OxhgUwK|8!ul6bV)5zptel_x@&+FE|bnE{P%>{*$8h&eL?71*I zFVuRt#J7{^utWbn0kj%!#O*q}d6&EO)?o_GJX*wk$gbWs@zyS&fQRr^og7 zHLhN(rMj9`q>k@%HMD;B0Ki18h-6ST;P6jsNij{-?-dK%0kw~jXVEW@l~@1XbJ*;W z8uPn>KB4wRNdbUBU^cqQ!<_%Ue09b8@H?v%pub3Ze>2K$1cY7wl!$2{Cf$NoE&ZzS zG*jdf@s!udDZ6Ba(dk}TCAVu&vDwOSjjKi_2!R780W^#?&G$Y2UIO9ko60}vF%42EuEtct5K=qZ z)Uk4rxDnD6?>ZCdIKj2{tI4rtpgUOWECLYjWMM&Z?x#Niwtk@OAcX?&SA9&ftmO7i zs61MNyuL0jC4%2)wbqLj_2T(AO7H-2VK7@4zH~LodYqmmkhv;JLjF@)ZU*P3`EJ6n z!kf*P@6u^rJftOWFG+$aEIw#=>&0rJ*gOVZS|qI{L7Ova8a}>fR1RqJvu}K+zuFcy z@q5(pPx5)#w9imj*?qpY@87cgh47Kb$HQdF$$F^ZrEENgn0xY+snYtv#X6CSK(ZnO zbX-6-E{Kx<#Mg3RVY9k>|AxG*5@e+j$tkU$x~GV@uz;$U}n zES11laE^_dat)+blH-?dcw;)+{nafT5+0(39nLWi?jW}=Ua3l)a>uaG&b|oKVQ4dr zbs|(i6N|n=yYRIGNa0IS%eFG&*3Vq6Lt=2?WLw^&ase(D%WO6>=o$kKq{H?sB{}pY zRLUEey5AXP_Eq%~^L|qZC{~ znN_X^IS-+u()`d_BZ;8SC>vo`B&Qu3I_s0?^H-o!f;T^G*BMwQ#aU`JyGNE7K`NvsT5f)RE&9<9(ZE-jQ?CzQX&fyPoD7hy8abj28JdoUFjq22O@dBnMK|C< zMWuP2Qhd@?QDWOXPeL-W`08p&3!t5UAR*j91X4~w7!Tu(O4vp*N_C$%zhZ8F&|4q5 zHNMNIPxdFg=&O0p_9c=&jh(;5fxCB7cx|^#K9CP+d)+V=<*XJ!4y(ywF~cJq!yHXITEZwBZtPHAQLkj2t_UZ$`- zAoc240LOQn1WnB^!ypL4v?lqYuaMy&ZAQ0tCH}#ZTJWPURtAUYXbsPnSl?$2*Lu&i z2ravv|CR-_RLieEw^6ZQ0H!&jW~*2_Q4XZ@iyaB#rYoB$h~u9YC-> z{l)?X0Ggx-UM0ZE`U8LiC?gvJfa+s({X>y~GQK1xk)rgkHin$>9Q1TNV2S{*gfOnC zCgdas%{(R&IVedUj*7SdGIy*<$z)9oML4y8+r3j-F(n0auS=ZYSNG;}xkF<(r)Ez; zU@+tbyr6_aitj_Fy`MQECT!h4S}D~p*2!A4zUM_32>KK2`JEuV1r3QQ+kKSYL;_V` zfsHzbKwAMitr8Y%hI}FGV;#0S8m{1p)88dVoSY0 z#aR?oT9X1)Uqf*bMMW|u^@!crJy|_%ZKExFH!!W^WmeKpa(Y#d$EVH4D-oD-vXnjaBGG{Pi4sC93My)7EpgYj2T$-CZE4&Eo43o&uXa^@kOvuFu z_6oNcAPlcUpK}JUf8e7ew+{ooz5vsCwWM1k$(NTH3!yh)?qDe;1SEWy|4xz0vgKtg z!We+Cu;h3NV|joUuw;7KLD>#Q?vQ6A+l}$C896s%F6pqn>#6;_C#<-;)s&8^9cLWa z+Qnf+({$ZlZ$~vB5r~B(uc#A|-A$PN3o&-%7WXYnnT*;Ks84ORa*rqgWxxtvw!aWc z!o{j2)F}cicS&LhPMcoDec|Xm|0+4I&I9OxTtw}X69|F*Kw-C-8hJ0;kFU&-NFWxW z+7%g3$$Q$*LU3g;4j8f3qR~UxA-vf4DTn*@4Z6m#cX~$8PWDNSm1k~jgzTEIW zv{<6vU3uw`F|RPPT}9D4PWaC$JeCCv`{R@Ueq$85#Ipp}os6>c591{+P)V2YBM+oG zRu0n*?#Q7BLm_yzE{fK`xitlyUKuf!p0Ypc{81`=;GiyhMU@*FoBPQd4aX&4fg6-B?pp-w18 z7I0<3v>iye0WC3g?%kL?_x-P6*)xls*+IJ~F0Y2ODAw&ZuPE+G(|w%1gN*O+qIZJXRpkKe6Lg?`UC^G@0S_%SN>44+nY4s##XQ-vAcUMBwG2sUq zG;rv)LIFI$2%umFu#cJ;^+Y-d#2)(dXeH07T-qe6Lb(3QQ?vA^Q+a+yqulUM(T9TF z0dI&&>Q7cDl%jl@q@fkPbys<_3kG$?ph&vviz-=D4Q=szK2r(2+qZN9WYgD==5sAS z0USKgVl`L(^gne4WNNw!F~B9p|D5@pP)Mqia#AG9=|*J^e7$^d_5gP2o%#el1isIm ziGPHR#dDh;mfY&ywKRA3FH)i^7UI?wRm-nEl8Jb_-`v$!umC?5?mY=qUFQCNWlc_n zkAEST$YoPk@@!dvj*I26$bJyLd!Dj5aWA*@FkNeNwM#4*35MTl$-*zLY7vAC?empl zeSO1a0(!B@FW4{xl}5}h2eFl_hwNqTfb}jjj@^cn{GTT3j_mb_nE@zF`yGK&*yEur z;F36e5K0VizQ7`lxuR^2Q%zx=_Y9oXIc2R90CbKv@WU@Ld=woKo}DlhrSPbf_J9!# z#J!@V{ySevn5st{$0Xe;w@Lg;+0ySYx_ji;X=rURqfr(;+flQI0)o{brogNOwSU;AR`FX)NMmm1H zB3X7p5hK406TS)}uc5F`3y6qh8$CQ~WU5Qv4qcz~rWt8)=5-$-Birjjf1yJ+b;)(;1-F_D`+`BRZ7 zj`0w=Y8qP(cXXDKbss8RHl<|~8y*9}euu;JHb;6f--&S|3~7an=0-XqQ|Vp^Q~@2>z$`-B}71_@Ng41lE^-W@4G0qx-88|o!~4pffoo_FWu;GT%WiJL%Je2uZ8f4rV|e-|{8&Z5H>5Do>%gV;TN z(HM=uL0?jbc_pfos7PBFGhGcVtuIRYlFPpWcnerH74iTDbf0`sJO|!hwoeQ1o$XGs zF%=X4UO7bdlCie(VH73FAifL;c-4qO&3>_QHa8Q{8q|aXTht+3U-@C)ejK3bTG&ou zVUm@P`wRC$>EWarLv<26zO!-yPe0R{g;|?yanr- zQ(v#EGQ$SHfe6ss6~miqop;52U-ZI~!Hm16;yEtDQa=s?==a)q!eAIq+~Jj-&F?$@ zugUFi>tEJ0WbM?AFl%kMG*yMY_98N|`2X05g=fk{^}!+=qjSJcQU=IW9XkCxx6D1i zwo)5w&@0cxoN6+o^LgAmliHZ!yM;pp5tRQRZ~0~z^`Z$5@@*){bUjz#Z3g7c>p${> zt@q?)vli|FwXiwgJBrb6w%*fFArkGU+BY5m>q5_Bm}c=PgS`{=t4s-xd(n0;h9z<8 zWaDW!+pb^%NA7N-y?B^&J`IwmI#lI=Tw=J^mG z>Feons^nS`k=n`C0=PvGe_b*q`7lxCl~hzloiAwi&*V4l7gR6bA3&x=eGIX_yzdVJ%t zP&T}9DswL?xFk`nhQ_4kz=;bwee-JCzDI5yw<3IRI`z2!nnEzhu<+hs8lzCo^VMlO zKec80w`ptcz940ZkN-m!Me*TT)UTG~GSh=&KDxhAwB!YqJQ8x@ngS+#k!H51{gdyO ztBqh;S_0c4X6ZwFeUSqWb#2JE2gb1u;6VJ!FkLO_S#ePbvPwHFk$G_W{-h z;2@*Iknu=xiH<_65^o z|4x{j^qY}=o*3C94cB#~LNVFvj&L0NbesiGpznci$J@xVb17Rz*pBW<#;oAAYNP)`@9gq*f1zb%bF-O;AToQl<3s{9WPI2lhW9^- zN?^%Zv_j06_K81oJaNB#y#-^=CQEWx@4>pHW%W^J^-1y7@MErGxiCqox-2(Himt)E*t z_>^gh;ORF8u`9E1hNDp^4WrJ^Q+M-F)XC8K#5xioR;Lk)icHSa{ABX|{W<|?J~68X zyW^iE%q3Sf`4=opLLmOPQnO$Xdut>ak$kpzPcG;ejTpR_whCvZrr59hpFisyCHEKc zOg}U0sS^yqY}h9n;2}FAy9yKLI_6sZK4z^j^EI)oG?@Tvd&1%$WVzs@ch7EnShfLu zKPs_jtE}WilJ|W=9jfkze5mfe(nw$L)MwC=vM~MyrQmzPXW8Xd)0j(ht%o0leuO_f zH_1|ZFjR5+aOlJ4^zbXPJ&oFe8)TiprEUCBU}(UCO4BvT=R-;JEMv&*Xlt(5*%3V^ zrRl!Ds^Ol!tY|D2-lFMhT$mq;NTq0mRl&-bTg&DBSM@jSZ9R#N)HEp1suoH*17n&4}1Qc^`jj&RE1MDI&99-9u*MW;$VBy0Z%pCn!49plEJ8GzF(or1-!IawhKOa2ie^$g zIG7Je97kvRj{EnN_%~8-IZFzf*BObUIr8ipPXGO6mUmE(2z7`+bhY{Y6m<9;o1>qn zNy`E$xF(TYth(P&mUG5LK^dr_^IB)lx$^wgyhI|dynj@8ykO9}jNc?;3i){%FIi?Wq5`coh{*Y|AVNdMT?LIqV!VF(Pdumk<9UTImbZ6*+h_)l%1FeZ zF=6KS@w)qzUM7A7DA{6tGpsyF+|=u{_nmPZeQn?q%QU;i1C3{YhAJzeKYOJfgA!W&jvAUgxjDt}u!+Dq@2~I>X{sSzKE!&2&J`vG^3`J$)Gy z(X^;{dMG%{lszno{Y2bkR2NE~XLP(gNSqp|^P@zsLq+tPD_An--JjOKis<`3>`$^j zX4R8C%5J(t{&RH?FxWZyv9~a&<^DY*<F;{M9iYL=GyfMSg9PBs04BI4e`=Qv&7X;Liou) z)2&t6F?|G**2oIgUt&yI%$Y8QLy%jQ^E%jIq*>DyQjJZp??o32Q8iW8hv?o6fM4NEso?~IHjJFULUsRcEi3v3NyBGAR0hLL{(Sx^2osh0k0_~l8+43%a5 z*gX{9sRaIpz{9)Owe_<+D!bIkH0V|{6Z|jrhiRx@kW)DAU~KQ1>juuP(S&-7v&U#x znr;JVX8&&4F_Xz8wLr6lX}ThYL2Eo%QnZp-AFg7g34{G2_c!~F6%Z9fek_R_LLNG9 zlw0t$j&zXXkxNHAHZ@Zum#l0=(C;F;{hT_fdoKyc2Vhd=3_gqW6vRlx?tO5Ec5b396SL*PAw9i5OWD#@9m$Vsk21}2pWn%q;6c0cE-YW zU3lY3Evc_Hd0PMRj`M3vvXtP1gORhCH0E6t)b);^24rVVgN_^NcfF(CzA-Tjrpo3ve!M;2q1SYXwjUsI4tKNEN*+RhX?z7!zB9 zzDKU1viesBA%GZ#^_FknmEE@jpH2IsrRC3C&TWR3GVWs&gH0)R!vN-aH4`aQ|NXf3 zFKuRAz?P{PhebyCI&cmj3R#MCi#0lrTu$7wG$!s-%Q#X?(B>om$s9j+A`CO!Rp7l| z+?b9#_E(>~D`6;66GX>ngmVot+dsBsTB+y9{+hyq#ot(ymZ!?Mq+%WXu_aOpOjGB^ zGMr`5*lpEUq5X6n<>SKYu&;kwb^K$L?Y=)^3hZ!sAkN|nZ7}ZG}+#E`hVmBfK;kJ~O!KDe87u@Rd zFy|s1(OcF8@R+q166d$OQ0XhySY9Rh;z!jBQT2~ApUIi1;pQ)Nlj}k&m~%zF5fQc_ zAFN({U-yfxtFQDx+H-A8x&u%OX-x*93;~g4*Btz~WnD3^lRrHey06Di-iX}CI*e!# z!ut4zFxa9oz<7S7T4&eLFp2%H={Zbi?uhd;P*$^k{am1iy%kl`{?(Z+koD7Qt=ni0$?hE6$6EhJp*T!ClK}u!{MK> zpys{!KyUsM+hV+-10@?RgDxgxnlAP^`1{ z$Q`bo+KPIjR0-f5t<{}N%(3%FkA1(9+14Aq-hLI;Ck)5Yk5 z&&zeYC&qY&qVTP?XR28`Q(+f9fH>9oe`s7g5yaaodOc7${BastxrF;Uc*jHjXYF|5 zF?IZ6W7a>%pY7w%xLq}@*8jD~3CCE{Tf6a8x2W4>$)<>omTnyI&X>EZ6VsxNyj|3I z^`uT+R^g`27 z3BymRKefr$sJOZfir-t|BvdWk9x1y$w138cc>^5Y%Pk(aV@Ya}*>{AT$8qV0iDv-Z zhc!2}d?3<~UqhHIJMo{Fj*v$f=im;q0Dyeh-Qv=^P4&hf*x2{qY(Fcl6R?0>OGcE| z2%K_!1qAIrS?($c{?LDqwBhN$HiNFTkKd(Fca+L=z9*l7V$8faeSj-u)4Zk|1i0qT zwhKDl47caDpO*}Te9@ggMxP0ME(A|`ouUgq>vjO$>XC~48{b8I_~kA9U9z2TB4q8% z8joSmf8`|#}vENgw!5 z07c}Aha!Z7QR;~_rKH#GrDKEeG(4N^x!7A{SHN*b>K{zPA;YIUR`NpH} zt>hjUo4PHCG(pKYo$2M+P@e2!7yA4srg20*+e-&0Iy1p1-h)->H> zCJ4u;f2P7pv|c~I+~y>{b9YFD)>Bn!u>Qb~(_}A#XwRO0IJprVpIr6!KvAO~etF`J z{!@1%QFwU!;;7EzV{)x4!3mMBb^7>^Z$7}3dOdZ&;Q7^4@xXeZ#6;Z1$!IQur~&!; z&6Dj8U*Awe^B8!(4NiN8oYp$16T8P?;@t8Oc?>?^N}0|d7#@sPdB=i)QSdSP3{FQ`_s{!5p*lw1aMEV2C-K6Q1QmZ}11 z!|PdMkfPE1m3p=GD5_wL)34KlhoY6+xl0?e?fz4a&ZC|j>>%F-^-JY5y(mWuz(e>H z#mAj7W<8R^zKd-X-nB@OH zapt^!yY@*9b=VHQX!pC=Kjekl%-sGV4b9U8jgyu8vKalKcBWaW_Y{2o>f;CmnCU%R zv3r54Q$7-2H$V;WIp06!v|DkEWG!goKZRu0?i4<|jB4Qs)|eqYg;)**N9p#Z&y2;P zG|WG>{}j1Ep}|9U@k)~ee*8gbXz6cMe3_adrm{}Z%+v+3Q*knJ9E-QKp!Z`O|DX%b zPDi+1Gyt{;Y8>yIs4i^$gKOK^pl)5_+^p)!kCAmVAC{`@Jkr(82G(S9tOe=j)~Hz* zwgUN~*N^$mpKk=vNKd2(SmyMmN)G6nKNa;QM|`imZmYvxMK34}n5_1#vBNR)CjUq| z6Sq4KuWDRvL>C}61D1{d5c}@$0yk;6WAp4Jws}ZpUcwBdB6zQnmtyZEbDS5wBN^8% zg>OMy*S220vJXY?YWSS`HWg{u~-!ZbGxq^)!;J}O6 zAJ6FAM34PyY89MRAE6G8N~`+yzC?$kZw0hjn(F?!7!vaWNE4*)oF>gKm0dZel~3R4 z{FbHU2s(xsOzTlNKp#hNM&JuRPIn%s@?2KU z!J5m%tEP9&pI6^DP3H4ZyC?Yd&*#ZJi+@=bzhB@y!{L)c%5w2Uq2&|}m5+78HBWR# zJ+<~~_}p61cKjQDY7piu{x9IRBuxl#eq zelRik4;Rh37K=B1aa7i8)xY9HQmV8;FNDQ}$gR7=28s>~f5r(0!Y}Y!uud@leqRkW ziYsRm$zD|Vje2)?%C79T@7k>o4NbXOB?%-omh`zEJW)Mk1QIWE{Xf!oE=d{5oL&wc z#e_E=q_kd1Jm^37S1#^V;oAJEX)!r)(a5p&!g5%e3!iUD@ClgDf1lu1b|<0^vd+I@ zo>aGjc}|XKPr-aL15&OQD~DVw*mmC#l`7=kO^;ys-QvWL?hcO{{ z*{YIa)>sDK7|#?}O}$Nkq^P}Ek~-zrjU-&Z&9UtiI9dY2T*bP^0v%5zhD&Mfzu(Pl zG1T0R$NHMhm$W(BuZ{zY-o3Klyj*dfLibOe;HrZl@I(V5rQZm6>uy>s9jL=F!Mcg^ z-v8kTEqzjj%f+~+-yvLh7jU2ztNVFEHJ;H~`mb+hXQ^?!G~(|Lo<+}B@8;v2 z4G(E%D>Qh9K5#o9{rL++05H`ox;ec6+rabCPb$zC?nM&H06uJUn`q=f)k4qK^G6fX zD5l%A^KE!#4O9L_-yy9xeXZeNlX+oE;dmiUlgVZ1Rv@GT>Rh?pL{e+?evQN8PP4B& zz`s_r;iTB2*FVyw+~6jbOzemPmES$<4>35OWB;nr(2=2%1e?hP;#JT{fI1|#1^M_} zHhnIv&17jJsCx$HWn=f6ka8zRv~@JRXtnJLXz;iYCUyblbVt6Pi^HF% zp6BNeaVzV_44=|V^t`miHhMY=@vaKSPVFgzp2s47D{FLIItial1j^8POs<94PLk-> z*M+;`yEm9xcueRJJ3oF}+9C!Ca`w#H-F?msEp(mae%>&LjCrVTEb3xR0A%b&>vw1l z3cKv(ygnEcKhiOA=`+24_)r@q&4nI`yIafqXQpHzLhAY2uQt}eX{M9vS+N&HFyx3GRAB4c-h;m9!2&&}XdU6QO z$t7Rp`HtWGvr0Hx8nhLB z>+-fyfy{D(YXbOHA7cA`T+nE|)+z!hl3H9r4LzABR$&%_@;ZCcWhNZwPlH>>T_9PL zh`d?WoT$@e7p|9wC5#7yo?VoGBY9-O~Db~y=Frn@X zCWiA$f&?wVxGm^s=Cbe0CnSV~>!$I@;}t+#aDfu`_#h(tb9C5)vCxJYkHqO{Axds( z#-nE+RQ%BCZZK4iAd__G3$<3)M~~=5c3bo1gs?#-k1JYa*iT)g9+9{L!2r~O6hOQBxLQF>BOc!T$Jm^8K7?1ON@}KtD`4`W__;OE|Wbg35{_@9IjAm|f2Da{}E3 z4(L*HqY9wu|4~DYL`-fL+r2{HBUMftqnS_>3~)M3SZoh-C9b1geG5pPAm!t77ZVHE zL2h*hn^xqP+E>r-(lk{H8c6(J2#u(pcE>B}X|1$|uCEzbpmbsfLJm?o*MIYD5M~TA zKEmm;JLhrix!rLkc?7+c zjgReeN-HQTm+F7#ntDy+Bu{fZ;%$f8h&RyAqMSAA(l1Un@~CCvC}p*z4tT$*R0urC9m0V3H7baS8)7bJ zVzZHP=4p-mcVjalJ|>Ew!?c&&VKBSoW5K*kwHI_d{u}v+t+U+135)FKTmrHs%1?h; zpb>K7$$|Bv-BHA>ZcuN6NK5#hP+~%051)CVv$(6Y=0G5B9*Fn6&oFAg+m`7WNrg8_ zT3??9#$-^hzNL-wMddhTQdoj+i_JkGz(^YMO-@J^m^Q?SP>fLU@ddiGXw1z*hBzC~ z(}#`DR3m=1%-Fd?Lp)?VQLsj|_+rP*FpExj=$@Od&6Z8HmsUQ&11w3jC+=6MBOhlz z?w_tSKuO>cmFB(nraOHCIAmXzpq>Fr1kFJ~ZBb8(Dm)B(MBY0pYQEy~w%meydR^9` zykR=gK!}%w2Gmgpd6;M<-z;=Y&E0Z-UOYV|i*g%71k2tMN>664~E2H<2muiF!w zEc>7PYpu;vdS{`_Hg$?BpV?jEQ$(Rx8rxL?F|&W^5Zdr zF?x!cM#msow-^c{B=VPAQk3Y!drl$W8I38|Jx^M+zNO14vR(-8^+8RFFb%RO4OV!1 zRGfEC^#uktPUar#2c=nJB37hImBai;J+Y(pUd zIJ%{vzuC;Vuz3pm1&=_Q$6$eBj4HTG%WqNR!=C~}!o!uI>Yj`a6C>=!3NmH&tLPCwOgyb$pOz!= zKo0MWp9;a&P5-8K7f9(sRZeICA}ufis~y8`Q9vf2S0~-jv^0mGRsf+;Km-M7opvGk zU1(Ybh**>^lhC8_|9SqQ;Zx=66de^1yPUHRHmyKpqb3#sjg~-X4f63j93i*+38R=) z5}XTG`>PhE9<}nleo5$WENzrCvyW@UkO62H1G9UJRxrN-Jvg}k_hkPT3H*{d_Ot}H z4l47ldIJ~~dbQX;@gT0i=m@9D~%4)o6VN`Vw1xhR_ z0Rm}H5HeD1xqFwHAxPj^QvJjgq|w9wA{s8<8xr}A)7+i3i3^6^a->!?e~c*w{iXs2 z5)ASTcD)qKU=Q_{Ff|;grM3K7F>56*22f}a%rIS@2TGS5Xf?mPK|V@^HNcJa(*Qqw z{wvR<<|lCk>Oi`ro3}fK11V+s;LEo+2pd0AutxMjl@rz9;sp$?Hayj{E zn=NCWf+Pc?iBfbf$kO80PM-SFstN7XU-NMb%4@*@7iJ?ZJi0(&Y(N<&MKU0V`c1U> z&bqP@wObLb*4!Fuvw;&cI>~Q=QdkbTcn%y8Wb9!<6eG8_b_1{ga5RN1>pMl zSC+R!FSeZ7-Ne#{$`xS#46qSeTshND<75p4(9)@ElPkr02YIXc1`PPD0*ooV1IPh{ zd9-#2$zaYk+kWWUWDCUal3}Pa5LP7En*qd2uRWQlTz-Q}>c4gXak1j6DaUX;@(lMI z@;qLHgVfqT2XB$3F+EJ)k#?$NX8hLB@N8Zv2i_2}J1AhbAs3VCnl)2mler+ZO{(Ds z_+|rZ_LE34{&+9~qb()qlEY1Q=6dJTK>||DKiH&{$vS86s^lX)rH-)6dqU}RcuXRO zPLSwul;E%QK;Ywf21p1ZNe1e}LI>jp>m#u2uN3(UiudYAt%4@^TD~nwVB=$nli(`e zF2f!|P9gFMCsL`N#B)goM3V9yi+jx>3EFc0gNvg~J7-Pa{H9tk7IW>W1jJs`mBoPg zgvLc5$^xCd+W6W6C3^Cqv*ck+AjBx8-%rJBfB{VvdF5))O1=ErUeGmnhZ|}XH^?${ zy^4>#Z!iz2KP5W{ZPl%i{PsgqY%;se>@6FjSHts2H41WeHHl7UVqIP&4iQB-2vJ|#{E4;6;i(((?wW6EtGhFQ*OvTs!mK- zvmET#C|oXHa%}gQ6qmqb0v~5SUCDPV8LwSKX={a=KM?UuY~N zFa*fuO2a+P_P6Vw3Z0uq%Y#t8vp=3ZI)1aCU5R@iRc-!Z7*8h53+ZE9)0L@J6zR>s z@?okQD}sLki4pDO;Nii2f~CFV=WP97-zmkK_aW*y07#!K}r-7_PX`UQ~X&~5Fo!FM-BI=W2 zW+dreTrl&iBOjNxMj5A!tyU+60%QGu4xU%Cxe@U&J|i}yTMss{eHR=yl4tkTUDsvh6xmtyB)CcIW^GHY3-n;s@k-Vi$W7|Dx5-P%&|)>S2#^s@rlykA%M)m* zQD0ja3tiH8OhGPjMxBH(G{+dnA36WB2u0678hUC>c6UT^S8uVAOYo&5xx1cWzz41W zovOF_`j9({xvK_sK=g1S8M=Z-#-bxTd=X#$DhajqIkz0V9^YS3BXrIMGSf5~#VI9E zz`qtoEP^$B6yjx*g_V08Q3PK1e~^L{lmP@STmPKS!)eCtX~qEnqzCugqhlDT88Age zL@-HmLJIw}6y>Gd8h=2YeDO`0gc8@@oLC`~l3G^T_f{ zW2U|ek@=!&s}7Xau-6LhhW+)a?PqO^mY$j%%+L4T&Py@h_JVu#}M%7GZqlD8#uTc;9gv|KeXH0l~I#K!*%(bQ$*$? zr>UyIeUbRvTdC%ng18Wmo0P5gb}f zq`$O&)KvZnV~juR4VOn~bdd2xxT_Y5Y-_-?Ovt&so?_i$#5Ltf-4Nu~G`JhxjD&hw?Z z6Fg7YqBR{=sh%SbYTq3| zpPg-5DY9KPG;^8s7n+8C=Lq^TdzU!;c`zZx#uOTlI7aR=Qj*m6j$o--#bh9EbNhSS z!N5WI;ShHk@~mS`C*lyTcw`Kuf(ktYxHqc;*oFjJ1u(&;aObX0j7=qOk^fIqAF`i3 z%$(HvFW6L$Y-gBOLV5%pC~j<*_3Jxp_qTjfXdHulD@(iYj`=qIZ)Cd6?p!l)Z|*as zFN(;`x=+Hv%;i#-ixFEw-v;UxX4sr8MI3dquk5cPw}>xXL7nx=t`uKNp#OueJRtT` z=&UTgq4^Vqj`%=mr0AGL3R*@bx6i{#ZIa{f1ZaGTwTPPU1e8?bM(Iq;Jy0ONqdyoM z9pu+F!FEM32e0a2pf-Q^%jkdY>K)Tc4+CzV58X^(d1UTfU;B$O!gPM;?N|B87?W{D zjY)*X>p1`V`VImmyyD7k$`WUEF>KDk<^&GE-w~N~TP6FxtCZY)t%B75-HbWFpf|{Y zh3-{@DAegP4x-@y>d|GU!P7&C5TyR=f&={3*cb$Yw~!PORQbg3w~tQTL9RK{g4v~? zftmcpVT{Yw>pfPFg&UrZQFaL$0J7Pe?VQjU2%j{JjWND&{80IND!~C5LV!>PJ(IF z9sN{n$`}P~Dn8Aq5@BALaIRr%wEhXf;M!MPX|E{6oVfr7q9Q59c`|lX@|077(Qp3Q zq`4XwMZRoC`f{7MR0l-_-CXW0GfrP}Q>SXUjcXCY#_SWKq>B4V1~>dW1u#_NU(|JE zZN9AaS#Q%iMAXXa3*xBP>P_elN&Z%(7FFw9V+$@yem~-pU{TRtmHD7oLUYo3M^H_> z{J7>K`Fj!Rx>dGC3|_ZLxIY~`sv|ZrOPP9SN~L2XQ0rhtkK?tU>-~0BGgv*z&OUCh z_OIGDzQB+XYXbb`qQySjvy0)B?%uN+av1VQ>9Pa%IC{MDXT3ExfBi1%ar4`NPCc&t zHXWK$hd)}^V=;ns!yD0eySa1BlO;*~DLO`${mCZ8K)Me(S~ZnOZaG{sQe|3_0|T=E z_O@~LqnV8%y|%_$&fVp>2QRIyVO#PIXIes_G0^v+=vk*6o5$7aZc*ErZcEp0MoB@X z@<4o2g@VoaWWA=i2%ioYH<`|wHe~8Fw%{MTjym zl&YJs5b&&X8KuB1C2WwA9a9*d}lfRsKxl#*PSGnaMUFW4QKNv$S(+U1{Ma1QV~PT3AE~H zGZP-g=xlfi8p?)r22RJ!_AHG1{FC0oyg}s3HWJ_?L?^&vReD)C{Ac-3> zb3%dA$@o(1MjL^hsTbAPi^Gf<5777~epG>Hm1^g1GxIAAMJAb*vKdo{&?7dK)?FJ1 zKm-mTf(~FL)P~J;2JB6XLy_Iejh$P(_c9G9N`{N*s}U5*4N0)N}{{2fK zR^bqifiQB=uc(|+4JDr0Z_wZWL|1jK5(_U8p^5=aa7|Z%avjU8wJQiha7<^;ddB@# z{fORrB*MDYJ&g^q0Xz+cwxYYwC&& zyeVtD>1TnVz@TVY{KpYjR%u{{MN|kmDCVI%GDBx6JZ}nashG3rr{qkNkRlig6(XDSMIfmMM5y9nC|d08?ZM z5#fnx)7)S29!!{UE)PKo5ELQcF1H&+`ksD;f(jf2-(^#_(Ha3Jb8I*2_KP5L#IoVc z8jh~Qb`0hhxV$nQZRrP<(myJNF!Z=NX<)>&-SkY4HRGHKilopz1C;B?$B)Nav^BE~ z1RzQpg86%JT38so0HUyAFMjdR?h!|ExaUws4JD>CV&3@>&x%*q3JFR3thqKqx(6Tn zaQOhDQ)$|fJb>ME#ZD}%dBg4sGz~TQMrIA4cSP-&N&*eHCGLUegyaTypwonC8!Gdo z(eEJWyxVxV8vl(G1;T*W1GHx0iX6mBW}T#@VeJY5!G!iLZR;f4q9cXN{Kw3qg}85k zF=qy0mvw{Da01Y@G6pD97!G7|+X@6KyM;O608@{ecs}C{qi_m~1;XRpki15Bhz~EG zSr=L>bAhaxxx;9O$V<5bh)gN50*XyT<6^iPz^6W;vLBVRaD(6kx&O9Gf2OeZN7p1lTPBIq6-V}z4NY-3CT zDWdXM5ZB2!oB5Mdm=;9YLIbUrfZfVjxy(P<@FQZD7UToD98WYYo*;D2gMkJ`28 zU08c$j3Ek01Lfb%TlIXqvXKe|D+odCApbq{@?R^;*(Y4e+eZ+B$bImJCCQ;UFff@n z#yglMu&;krp+iQeujiYfMPVH*i%yn4+U0x`d2wDhCXDp+gwivQ9 zq4&fYwpT%7iBfb6KQjZ>q(=;y!CROOS}ekT?gzQpvX_)LDnvdG@M`618^9@ppn(j} zOn8*Np;uw7ptaYpdE}H4J~${^2*BbEeByECqMa8|Sx94nc8vjjaDss00XHky_%q^I z-P#HQF2`!+QUG8ISMMiuIo!Dhp^K?ML=XcCt=5(Hv&aMLS|Nv6X*Q+x$*T` zK4s|)2X(Nti<(m6W@Uv*7aIJWrT`aUFF2M6Dx(xScvLLT0llxF?lyPAhQJQx{TJQ{ z0L4Kl*-`^|!5wh2Ro8#|w4tDi}MR=P}EuHtaWO|tbS|8tY3kdI7#&OMwT`(y)embIJ()(!6aIe?`u zP*;vCf5UzgYYy9;UpJc1dh@Q$`%oGeus(Thw0GL`4Sk(+*dD10_XA7%ZTnV0bZ{I^ zF*(19TP0^%aC#X76R=eLsIj=c;^Yy3;KXy;UOzvuZzgsZ>@+-m?AwG5hLVnZjzoxk zl<1CVVYJ}u{4nFw@i0^)xeLrjlrPwMej?uL+$lRD=ZfppaV@kooFFsV`T zqZ1^t@Bp)$zw_yBJ?qtH7y<|W`jnPGDhAf*A2s*hk~yv@#<~1YtjntS$~BCy=c97U)$4yE=J zmh4+ffKF!Wl!D~gLv9@Jb}`&nWAr&=A#>7oa)S>%k8)%yeJQP;RNo--anfr^iKPgM zOdE!GS6;-dj1*Jc4QzTg0tZo6UVMH=!{Y|j=`AL=8MmZY^7*ghJ-S8czx`JmZ3+MY diff --git a/docs/differences-between-gold-silver-and-crystal.md b/docs/differences-between-gold-silver-and-crystal.md new file mode 100644 index 00000000..6ff4a0bf --- /dev/null +++ b/docs/differences-between-gold-silver-and-crystal.md @@ -0,0 +1,615 @@ +# Pokémon Gold, Silver, and Crystal: A Systematic Engine, ASM, and Behavioral Comparison + +## Executive summary + +Pokémon Gold and Silver are best understood as **two data-configured builds of one engine**, whereas Pokémon Crystal is an **expanded, reorganized, Color-only derivative of that engine**. The `pret/pokegold` repository reproducibly builds the international Gold and Silver ROMs byte-for-byte, while `pret/pokecrystal` separately builds multiple international Crystal revisions and debug artifacts. This makes the two repositories unusually strong primary evidence: their output hashes correspond to known retail binaries rather than merely reimplementing observed behavior. + +The largest technical conclusion is that Crystal is **not a ground-up engine rewrite**. Core subsystems (the frame-driven sound interpreter, hardware-derived RNG, synchronized link-battle PRNG, wild-encounter probability machinery, party-mon record size, and much of the RTC pipeline) remain recognizably inherited from Gold/Silver. Crystal instead adds new ROM banks, reorganizes routines, introduces Color-specific rendering paths, fills formerly reserved fields in Pokémon records, redesigns SRAM placement, and layers Battle Tower, Mobile System GB, animated sprites, the female protagonist, Buena, Move Tutor, Suicune story logic, and Virtual Console hooks around the inherited core. + +The most important behavioral differences are therefore caused less by wholesale algorithm replacement than by **changed control flow and data**: + +| Area | Gold and Silver | Crystal | Technical consequence | +|---|---|---|---| +| Edition structure | One shared engine with edition-selected encounter, sprite, title, and other data | One enhanced-version build with regional/revision conditionals | Gold-to-Silver ports are mostly data substitutions; Gold/Silver-to-Crystal ports are architectural merges | +| Display target | Runtime distinguishes DMG/SGB/CGB modes | Explicit Color-only path | Crystal code can assume CGB palettes, VRAM banking, and related presentation behavior more aggressively | +| RNG core | Divider-register accumulator plus synchronized link PRNG | Same algorithms | Different outcomes normally arise from different call timing or paths, not a different generator | +| Party records | Two bytes after Pokérus are reserved | Those same bytes become caught-time/gender/level/location metadata | Pokémon records remain structurally compatible despite Crystal gaining metadata | +| Save layout | Main and backup data are fragmented among SRAM sections | Main and backup records become more contiguous; Crystal/Battle Tower/Mobile blocks are added | Whole `.sav` files are not drop-in interchangeable | +| Battles | Standard Gen II battle engine | Inherited engine plus Battle Tower branches and selective bug fixes | Link-compatible behavior is intentionally retained in some otherwise-fixed cases | +| Roaming Pokémon | Raikou, Entei, and Suicune use the roaming system | Only Raikou and Entei roam; Suicune becomes a scripted encounter | Encounter-state conversion requires special handling | +| Graphics | Static Pokémon fronts and DMG/SGB-compatible layouts | Animated fronts, revised sprites, richer Color layouts | Animation introduces new graphics data, decompression, frame, and timing requirements | +| Audio | Shared four-channel Game Boy music interpreter | Same interpreter plus new songs and call sites | Music ports are mostly data/pointer work rather than synthesizer rewrites | +| Major fixed defects | Coin Case arbitrary code execution, first-save Hall of Fame corruption, Lucky Number box limit, and several map/text defects | Corrected | Crystal is safer, but not "bug-fixed Gen II" in the broad sense | +| Persistent defects | Many Gen II battle, capture, item, and badge defects | Many remain | Mechanical compatibility often outweighed cleanup | + +Gold and Silver themselves have almost no meaningful engine-level divergence. Their scientifically interesting comparison is principally **which tables and assets are selected at assembly time**. Crystal is the meaningful engine comparison. + +The report assumes a technically literate audience familiar with low-level programming but not necessarily Game Boy internals. Unless explicitly stated, "Crystal" means the international English code represented by `pret/pokecrystal`; Japanese Mobile System GB and Australian/revision-specific behavior are treated as regional variants. "Scientific" here means reproducible binary-derived comparison, explicit source hierarchy, and separation of direct evidence from inference, not laboratory experimentation involving original development source code, which has not been publicly released. + +## Scope, evidence, and reproducibility + +### Source hierarchy + +The strongest sources are the two pret disassemblies: + +- [`pret/pokegold`](https://github.com/pret/pokegold), which builds exact Gold and Silver international ROM images. +- [`pret/pokecrystal`](https://github.com/pret/pokecrystal), which builds Crystal international v1.0, v1.1, Australian, and debug images. + +The repositories identify exact SHA-1 outputs. Gold builds to `d8b8a3600a465308c9953dfa04f0081c05bdcb94`, Silver to `49b163f7e57702bc939d642a18f591de55d92dae`, Crystal international v1.0 to `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`, and Crystal international v1.1 to `f2f52230b536214ef7c9924f483392993e226cfb`. Those reproducible identities are critical: a label such as `BadgeStatBoosts` is a reverse-engineered name, but the emitted bytes and resulting behavior are those of the retail ROM. + +Secondary sources such as Bulbapedia and Serebii are useful for player-visible cross-checking, release differences, encounter availability, and historical context. The pret bug documents are stronger than ordinary wiki summaries for defects because they identify the responsible instructions and provide corrective diffs. Official Nintendo and Pokémon sources are most valuable for release/Virtual Console behavior and supported transfer paths, but they do not publish the games' assembly architecture. + +### Reproducible comparison method + +A rigorous comparison should operate at four levels: + +| Level | Method | What it establishes | Main limitation | +|---|---|---|---| +| Binary identity | Build each repository with its pinned RGBDS-compatible toolchain and verify SHA-1 | The source corresponds to the target retail ROM | Does not explain why bytes differ | +| Source topology | Compare `main.asm`, `home.asm`, bank sections, include graphs, WRAM/SRAM declarations | Architectural additions, removals, and relocation | File organization can change without behavioral change | +| Routine semantics | Compare labels, branches, register use, calls, data offsets, and side effects | Control-flow and algorithmic differences | Labels and comments are community-derived | +| Behavioral validation | Run controlled emulator tests with trace logging and fixed input timing | Observable consequences and RNG/cycle sensitivity | Emulator accuracy and RTC state must be controlled | + +For a formal experiment, the recommended independent variables are edition, ROM revision, initial SRAM, RTC register state, boot hardware mode, input sequence, and link mode. Dependent variables include RNG bytes, encounter choice, battle result, SRAM writes, audio commands, VRAM/OAM writes, and cycle/frame number. Because the ordinary RNG reads the divider register, deterministic comparisons must start from a controlled boot state and reproduce input timing at frame or cycle precision. + +### Gold versus Silver as a build-configuration comparison + +The fact that `pokegold` emits both Gold and Silver from one repository is itself strong evidence that the two versions share the same broad code architecture. Edition differences are implemented through conditional assembly and alternate data or assets rather than through separate engines. The obvious behavioral results are version-specific wild Pokémon, title graphics, mascot-facing presentation, and selected NPC or table data; battle arithmetic, saves, RTC processing, audio interpretation, and link handling come from the same shared source. + +This distinction matters for analysis. Treating Gold, Silver, and Crystal as three equally separate engines exaggerates Gold/Silver differences and understates Crystal's structural changes. A better model is: + +```mermaid +flowchart TD + GSC[Generation II design and data model] + GS[Gold/Silver shared engine] + GD[Gold-selected data and assets] + SD[Silver-selected data and assets] + C[Crystal derivative engine] + CF[Crystal-only feature modules] + CR[Crystal revisions and regional flags] + + GSC --> GS + GS --> GD + GS --> SD + GS --> C + C --> CF + C --> CR +``` + +## ROM and engine architecture + +### Banked execution model + +The games execute on the Game Boy's LR35902-class CPU and are organized around a fixed home bank plus switchable ROM banks. Calls across banks generally pass through wrappers that save the current bank, select the target bank, call the routine, and restore the original bank. The `BattleRandom` wrapper is a concise example: `_BattleRandom` resides outside the home bank, so the wrapper saves `hROMBank`, uses the bank-switch restart vector, calls the routine, preserves its return value, and restores the previous bank. + +```asm +BattleRandom:: + ldh a, [hROMBank] + push af + ld a, BANK(_BattleRandom) + rst Bankswitch + call _BattleRandom + ... + pop af + rst Bankswitch + ret +``` + +Relevant sources: [Gold/Silver `home/random.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm#L29-L46) and [Crystal `home/random.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm#L29-L46). + +The repositories' top-level `main.asm` files are effectively link manifests for this architecture. Gold/Silver's file groups the battle core, encounters, party menu, RTC, phone, Pokégear, sprite animation, graphics loading, and other systems into named ROM sections. Crystal retains those categories but adds explicitly named feature sections and many additional modules. + +### Crystal as an extension and bank-layout rewrite + +Crystal's `main.asm` introduces a conspicuous `Crystal Features 1` section containing gender initialization, Kris-specific bag handling, Move Tutor, Crystal layouts, Celebi logic, the redesigned main menu, Mobile menu code, owned-Pokémon search, and Buena's menu. Nearby banks add Battle Tower trainer logic, caught-data handling, a revised stats screen, sliding battle intros, battle-scene checks, and the Color-only startup screen. + +That include topology supports three conclusions. + +First, Crystal preserves **vertical subsystem continuity**. The battle engine is still `engine/battle/core.asm`; encounters remain `engine/overworld/wildmons.asm`; Pokémon records still derive from the same party/box macros; and audio is still driven by the same interpreter. + +Second, Crystal adds **horizontal feature coupling**. Battle Tower touches battle setup, stat boosts, experience rules, saving, menus, SRAM, trainers, music, and link-like constraints. The female protagonist touches initialization, sprites, bag graphics, caught metadata, and menu presentation. Mobile support touches interrupts, SRAM, menus, communications, rankings, and audio. + +Third, code and data relocation is substantial enough that raw ROM addresses from Gold/Silver cannot generally be transplanted into Crystal. A symbolic port based on labels and structures is practical; a patch based on absolute offsets is brittle. + +### File and routine comparison + +| Subsystem | Gold/Silver source | Crystal source | ASM-level difference | Porting significance | +|---|---|---|---|---| +| Top-level bank graph | [`main.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/main.asm) | [`main.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/main.asm) | Crystal adds feature, Battle Tower, Mobile, gender, animation, and caught-data modules and relocates shared includes | Port by symbol and feature dependency, not ROM offset | +| Ordinary RNG | [`home/random.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm) | [`home/random.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm) | Essentially identical routine and rejection-sampling helper | RNG-sensitive ports must preserve call timing | +| Battle core | [`engine/battle/core.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/battle/core.asm) | [`engine/battle/core.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/battle/core.asm) | Crystal adds Battle Tower guards and conditional fixes while retaining link behavior | Mechanical changes can desynchronize unmodified peers | +| Encounters | [`engine/overworld/wildmons.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/overworld/wildmons.asm) | [`engine/overworld/wildmons.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/overworld/wildmons.asm) | Same rate/slot pipeline; different swarm state and two rather than three roamers | World-state conversion needs special Suicune handling | +| SRAM declaration | [`ram/sram.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/ram/sram.asm) | [`ram/sram.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/ram/sram.asm) | Crystal consolidates save/backup regions and adds GS Ball, Battle Tower, rankings, and Mobile blocks | Whole-save binary compatibility is lost | +| Pokémon structures | [`pokemon_data_constants.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/constants/pokemon_data_constants.asm#L69-L103) | [`pokemon_data_constants.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/constants/pokemon_data_constants.asm#L69-L108) | Crystal assigns caught metadata to two bytes reserved in Gold/Silver | Record length remains compatible | +| RTC/home time | [`home/time.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/time.asm) | [`home/time.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/time.asm) | Same RTC model; Crystal timer interrupt can dispatch Mobile timing | Emulator ports must emulate RTC and, for Japanese features, mobile timing assumptions | +| Picture loading | [`engine/gfx/load_pics.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/gfx/load_pics.asm) | [`engine/gfx/load_pics.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/gfx/load_pics.asm) | Crystal has additional front-picture and animation-oriented handling | Static sprite replacement is insufficient | +| Sound interpreter | [`audio/engine.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/audio/engine.asm) | [`audio/engine.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/audio/engine.asm) | Core interpreter is structurally the same | New Crystal music is primarily sequenced data and pointers | +| Song table | [`audio/music_pointers.asm`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/audio/music_pointers.asm) | [`audio/music_pointers.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/audio/music_pointers.asm#L97-L108) | Crystal appends ten named songs | Existing song IDs remain stable through the Gold/Silver range | + +The line counts of several Crystal files are larger, but line count alone is not a behavioral metric; comments, label quality, and source refactoring affect it. The stronger evidence is the presence of new branches, fields, tables, and externally observable side effects. + +### Hardware-mode divergence + +Gold/Silver initialization tests whether the console is a Game Boy Color and maintains non-CGB paths, including Super Game Boy initialization. Crystal includes an explicit `gbc_only.asm` module that displays the incompatibility message on non-CGB hardware. That is not merely marketing metadata: it changes what assumptions later rendering code may safely make about palettes, VRAM banking, and Color hardware. + +For a reverse port of Crystal features into Gold/Silver, every CGB-only feature falls into one of three categories: provide a monochrome/SGB fallback, disable it outside CGB mode, or intentionally convert the resulting ROM into a Color-only build. Skipping that decision produces subtle failures rather than one clean compile error: palette attributes, second-bank VRAM data, and tile-upload timing can all be implicated. + +## Save, RNG, clock, and entity state + +### Save architecture + +Gold/Silver divide their main game state into options, three player-data fragments, current-map data, Pokémon data, and a checksum. Backup pieces are distributed among several separately placed SRAM sections. The current PC box is separate, the fourteen inactive boxes occupy exactly two SRAM banks, and mail and Mystery Gift have dedicated regions. + +Crystal instead defines a contiguous `sGameData` consisting of player, map, and Pokémon data, pads the region, and stores a checksum. Its backup is similarly grouped. Crystal then adds a GS Ball flag, `sCrystalData`, Battle Tower progress and recent-trainer state, ranking records, Mobile communication data, offers, credentials, and Japanese Mobile-specific buffers. The source explicitly notes that the international `sCrystalData` location differs from Japanese Crystal. + +| Save component | Gold/Silver | Crystal | +|---|---|---| +| Options and corruption sentinels | Present | Present | +| Main player/map/Pokémon data | Split into labeled player fragments inside the save region | Grouped into one contiguous main-game block | +| Main checksum | 16-bit checksum after game data | 16-bit checksum after padded game-data region | +| Backup | Fragmented among multiple SRAM sections | Consolidated backup game-data block | +| Current box | Separate `curbox` structure | Separate full `box` structure plus padding | +| Inactive boxes | Fourteen boxes over two SRAM banks | Same fourteen-box capacity over two banks | +| Party and mailbox mail | Dedicated primary and backup regions | Same broad model | +| GS Ball state | Absent | Primary and backup flags | +| Battle Tower | Absent | Challenge state, streak, previous teams, reward | +| Mobile/rankings | Absent | Several additional SRAM sections | +| Full-file compatibility | Gold and Silver are closely related but still edition-specific | Not layout-compatible with Gold/Silver | + +Crystal's save routine makes the transactional sequence visible: + +```asm +_SaveGameData: + farcall StageRTCTimeForSave + farcall BackupMysteryGift + call ValidateSave + call SaveOptions + call SavePlayerData + call SavePokemonData + call SaveBox + call SaveChecksum + call ValidateBackupSave + ... + farcall BackupGSBallFlag + farcall SaveRTC +``` + +Source: [Crystal `engine/menus/save.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/menus/save.asm#L255-L284). The routine stages RTC state, writes the primary data and box, computes the primary checksum, writes backup data and checksum, backs up party mail and GS Ball state, persists RTC state, and normalizes a completed Battle Tower reward state. + +The practical conclusion is precise: **do not migrate a Gold/Silver `.sav` to Crystal by copying the file or the main save block**. A converter should parse symbolic fields, validate checksums and sentinels, copy player/map/Pokémon/box/mail data field by field, initialize Crystal-only state, and regenerate Crystal checksums. Gold-to-Silver conversion is less structurally disruptive, but version-dependent event and encounter state should still be treated deliberately. + +### Party and boxed-Pokémon records + +One of Crystal's most elegant compatibility decisions is visible at the structure-definition level. In Gold/Silver, the two bytes after `MON_POKERUS` are simply reserved: + +```asm +DEF MON_HAPPINESS rb +DEF MON_POKERUS rb + rb_skip 2 +DEF MON_LEVEL rb +``` + +In Crystal, the same positions are assigned to a two-byte caught-data field: + +```asm +DEF MON_HAPPINESS rb +DEF MON_POKERUS rb +DEF MON_CAUGHTDATA rw +DEF MON_LEVEL rb +``` + +Sources: [Gold/Silver structure](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/constants/pokemon_data_constants.asm#L83-L101) and [Crystal structure](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/constants/pokemon_data_constants.asm#L83-L107). + +Crystal packs caught time and trainer gender into one byte and caught level and location into another. Because Game Freak repurposed reserved bytes instead of extending the record, `BOXMON_STRUCT_LENGTH` and `PARTYMON_STRUCT_LENGTH` remain compatible. Gold/Silver can carry those bytes without understanding them; Crystal can interpret them when present. This is a major reason Gen II Pokémon can move among Gold, Silver, and Crystal without a record-size translation layer. + +A second structure change appears in the base-species TM/HM compatibility bitset. Gold/Silver size it for `NUM_TM_HM`; Crystal sizes it for `NUM_TM_HM_TUTOR`, accommodating Move Tutor compatibility in the same species-data model. This means a straight copy of Gold/Silver base-stat records into Crystal must account for Crystal's expanded learnability domain even if the visible species stats are unchanged. + +### Item and party handling + +The basic inventory model (separate item pockets, held-item byte in each Pokémon record, party length of six, PC boxes, mail records, and item-dispatch routines) is inherited. Crystal's changes are concentrated in new consumers and UI behavior: Move Tutor eligibility, gender-specific player presentation, caught metadata, Battle Tower restrictions, and additional event items such as GS Ball state. + +Crystal did **not** comprehensively repair the item mechanics. Its documented defects still include Moon Ball not applying its intended multiplier, Love Ball checking the wrong gender relationship, Fast Ball applying to only a few species, three incorrect Heavy Ball weight cases, and status conditions failing to affect capture rate as intended. Those defects live in shared mechanical paths and are important when evaluating a "faithful" engine port: correcting them changes gameplay and may alter deterministic test vectors. + +### Ordinary RNG + +Gold/Silver and Crystal use the same ordinary RNG routine. It samples the hardware divider register and updates two one-byte accumulators, one by addition-with-carry and one by subtraction-with-carry: + +```asm +ldh a, [rDIV] +ld b, a +ldh a, [hRandomAdd] +adc b +ldh [hRandomAdd], a + +ldh a, [rDIV] +ld b, a +ldh a, [hRandomSub] +sbc b +ldh [hRandomSub], a +``` + +Sources: [Gold/Silver](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/home/random.asm#L13-L27) and [Crystal](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/random.asm#L13-L27). The files are effectively identical, including the `RandomRange` rejection-sampling routine used to avoid simple modulo bias. + +This generator is not a self-contained seeded PRNG in the modern sense. Its output depends on divider phase, previous accumulator state, carry state, VBlank updates, and when the routine is reached. Consequently, adding an animation, menu delay, conditional call, or extra random-consuming feature can alter later outcomes even while the RNG instructions remain unchanged. + +### Link-battle PRNG + +Battles route randomness through `BattleRandom`. In non-link play, `_BattleRandom` ultimately uses the ordinary RNG. In linked battles, both systems consume a shared ten-byte random sequence and advance bytes with the recurrence: + +``` +x[n+1] = (5 * x[n] + 1) mod 256 +``` + +The Gold/Silver and Crystal battle cores contain the same broad synchronization mechanism. + +This has a major compatibility consequence: a battle patch can be logically correct in isolation yet break link play if it causes one participant to make a different number or order of RNG calls. That is why Crystal sometimes preserves Gold/Silver behavior specifically in link battles. Synchronization depends on matching control flow, not merely using the same random-number formula. + +### RTC and time-of-day behavior + +Both codebases latch the MBC3-style real-time clock, read seconds, minutes, hours, and the low/high day registers, normalize time, add the player's selected starting offset, and derive the current time-of-day state. Crystal's `UpdateTime` remains a short chain of `GetClock`, `FixDays`, `FixTime`, and `GetTimeOfDay`. + +Crystal's source shows the day counter reduced modulo 140 for the game's weekly/event model, with status flags distinguishing an RTC count beyond 139 days and a hardware day-high overflow beyond 255 days. The displayed game time is formed by adding the new-game start offsets to RTC values with carry through seconds, minutes, hours, and day. + +The notable architectural addition is the Crystal timer interrupt's Mobile dispatch: + +```asm +Timer:: + push af + ldh a, [hMobile] + and a + jr z, .not_mobile + call MobileTimer +.not_mobile + pop af + reti +``` + +Source: [Crystal `home/time.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/home/time.asm#L2-L11). The ordinary RTC logic remains inherited; Japanese/mobile Crystal adds another timing consumer. + +For emulators, flash cartridges, and ports, SRAM alone is insufficient. RTC registers, halt/carry state, elapsed host time, and game offsets must be preserved consistently. A save imported without corresponding RTC state may be structurally valid yet produce incorrect daily events, phone behavior, berries, swarms, day-of-week encounters, or time-dependent evolutions. + +## Battles and encounters + +### Battle-engine inheritance + +The core Gen II battle model is shared: turn selection, speed ordering, accuracy, damage, stat stages, held items, status, volatile effects, experience, capture, AI, and link synchronization follow the same broad engine. Crystal's battle core is larger principally because it adds Battle Tower integration, additional presentation paths, and selective corrections. + +Crystal is therefore mechanically closer to a patched and extended Gold/Silver than to a later-generation ruleset. There are no abilities, natures, modern physical/special move split, or rewritten damage model. Its new content operates within Gen II's existing move and Pokémon structures. + +### Control-flow difference: Battle Tower exclusions + +Gold/Silver's `BadgeStatBoosts` exits for link battles and otherwise applies badge boosts. Crystal adds a second early return when `wInBattleTowerBattle` is nonzero: + +```asm +ld a, [wLinkMode] +and a +ret nz + +ld a, [wInBattleTowerBattle] +and a +ret nz +``` + +Source: [Crystal `BadgeStatBoosts`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/battle/core.asm#L6526-L6532). Gold/Silver have only the link-mode guard at the corresponding point. + +This tiny six-instruction addition is representative of Crystal's design. The stat algorithm was not replaced; Crystal introduced a new battle context and inserted a guard so adventure-only badge advantages do not leak into the standardized Battle Tower. Similar context checks appear around experience and other post-battle handling. + +### Selective fixes constrained by link compatibility + +The pret Crystal bug documentation explicitly states that Crystal fixed Gold/Silver's Reflect/Light Screen overflow and Present damage behavior only where doing so would not break ordinary cross-version link battles. Link-mode behavior retains the compatible path. + +That is an important historical engineering tradeoff. A fully corrected Crystal battle engine would disagree with Gold/Silver on intermediate values and potentially on RNG consumption, damage, fainting, or message sequence. Compatibility with the installed base was treated as a protocol requirement. Consequently, "Crystal fixed the bug" can mean **fixed in single-player but deliberately retained in linked simulation**. + +### Shared and persistent battle defects + +Crystal retains a substantial Gen II defect surface. Documented examples include: + +| Defect | Mechanical effect | Crystal status | +|---|---|---| +| "100%" secondary effects | Fail in 1/256 qualifying cases | Retained | +| Belly Drum | May maximize Attack even when the HP requirement is not properly met | Retained | +| Berserk Gene | Confusion duration can become 256 turns or inherit stale state | Retained | +| Confusion damage | Can receive type-item and Explosion/Self-Destruct modifiers | Retained | +| Beat Up | Can behave incorrectly and can desynchronize link battles | Retained | +| Return/Frustration edge | Can produce zero damage at extreme happiness values | Retained | +| Dragon boosting item | Dragon Scale is checked instead of Dragon Fang | Retained | +| Glacier Badge | Special Defense boost depends incorrectly on overwritten accumulator state | Retained | +| Capture status bonus | Burn, poison, and paralysis do not contribute as intended | Retained | +| Specialty Balls | Multiple Apricorn-ball formulas target the wrong condition or table | Retained | + +These are documented against original Crystal code, not inferred from modern competitive summaries. + +The Glacier Badge defect is especially instructive at ASM level. The routine shifts a badge bitfield through register `b`, calls `BoostStat`, and later reuses register `a` as if it still contained the expected badge value. `BoostStat` can overwrite `a`, so whether Special Defense receives its boost depends on the Special Attack calculation's resulting register state. The bug survived Crystal even though the source gained an explicit comment identifying it. + +### Gold/Silver defects corrected in Crystal + +The `pokegold` bug document separates defects fixed in Crystal from defects still shared with it. It identifies seven clear Gold/Silver corrections: Coin Case arbitrary code execution, Hall of Fame corruption when no prior save exists, Lucky Number failure to inspect boxes 10-14, Present text overflow, surfing onto NPCs, fishing inside Cerulean Gym, and Route 15 capitalization. + +The Coin Case correction is one bytecode-level terminator change: + +```diff + text "Coins:" + line "@" + text_decimal wCoins, 2, 4 +-done ++text_end +``` + +Source: [`pokegold/docs/bugs_and_glitches.md`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/docs/bugs_and_glitches.md#L20-L34). The Gold/Silver terminator permits text-command execution to continue into unintended memory under exploitable conditions; Crystal ends the text stream correctly. + +The Hall of Fame correction adds a saved-at-least-once check and erases/initializes previous-save structures before attempting the Hall of Fame write. The Lucky Number correction changes the loop bound from the Japanese box count constant to the international fourteen-box count. Both are good examples of Crystal correcting localization-sensitive state assumptions rather than changing game design. + +### Encounter-rate algorithm + +Gold/Silver and Crystal use essentially the same high-level encounter pipeline: + +```mermaid +flowchart TD + STEP[Eligible movement step] + RATE[Read morning/day/night or water rate] + MOD1[Apply radio modifier] + MOD2[Apply Cleanse Tag modifier] + ROLL[Call Random and compare with rate] + SLOT[Choose encounter slot] + ROAM[Check roaming Pokémon] + REPEL[Apply Repel level check] + BATTLE[Stage wild battle] + + STEP --> RATE --> MOD1 --> MOD2 --> ROLL + ROLL -->|pass| SLOT + SLOT --> ROAM + ROAM --> REPEL + REPEL --> BATTLE +``` + +In both engines, `TryWildEncounter` gets the map rate, applies Pokémon March/Ruins of Alph doubling or Pokémon Lullaby halving, applies Cleanse Tag halving, draws an RNG byte, chooses a slot from grass or water probability tables, and checks Repel. + +Grass records contain separate morning, day, and night slot blocks; the active block is selected through `wTimeOfDay`. Water uses a separate rate and three-slot table. The structure constants remain seven grass slots and three water slots in both codebases. + +### Encounter-table differences + +Gold and Silver primarily diverge through edition-conditioned tables. Crystal has its own consolidated tables and changes both availability and placement. It makes several former Gold/Silver exclusives obtainable in one edition, while removing some species available in both base versions; Mareep's evolutionary family is a prominent Crystal omission. Crystal also moves species such as Sneasel to different locations. + +The engine/table distinction is important. An encounter may differ because: + +1. the map's encounter-rate byte changed; +2. a morning/day/night slot changed; +3. the slot's level changed; +4. a swarm override changed; +5. a roaming Pokémon was removed from the roaming subsystem; +6. the map itself gained or lost encounter-enabled tiles. + +A robust diff should therefore compare map headers, wild tables, swarm flags, and roaming initialization, not merely produce a Pokédex availability list. + +### Swarms and roaming state + +Gold/Silver's swarm lookup is comparatively generic: it compares the current map against `wSwarmMapGroup` and `wSwarmMapNumber`. Crystal's code has explicit Dunsparce and Yanma swarm flags and corresponding map state. That is a data-model specialization, not just a changed encounter table. + +The roaming difference is even clearer. Gold/Silver initialize three records: + +```asm +ld a, RAIKOU +ld [wRoamMon1Species], a +ld a, ENTEI +ld [wRoamMon2Species], a +ld a, SUICUNE +ld [wRoamMon3Species], a +``` + +Crystal initializes only Raikou and Entei: + +```asm +ld a, RAIKOU +ld [wRoamMon1Species], a +ld a, ENTEI +ld [wRoamMon2Species], a +``` + +Sources: [Gold/Silver `InitRoamMons`](https://github.com/pret/pokegold/blob/a0dad0957ac8a9ffa67e950ee3ab6715a212ded5/engine/overworld/wildmons.asm#L471-L511) and [Crystal `InitRoamMons`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/overworld/wildmons.asm#L476-L506). + +Gold/Silver's subsequent selection logic describes an equal choice among three beasts after the roaming check succeeds; Crystal changes the corresponding comment and index range to two. Suicune's removal is tied to Crystal's expanded Eusine/Suicune plot and scripted Tin Tower encounter. + +For save conversion, a Gold/Silver Suicune roaming record cannot simply remain active in Crystal. The converter must map capture/defeat/event state into Crystal's scripted Suicune flags or deliberately define a hybrid behavior. + +## Graphics and audio + +### Tile, palette, and sprite model + +All three games retain the Game Boy's tile-oriented rendering model: graphics are stored as compact tile data, decompressed or copied into VRAM, arranged through background/window tilemaps, and supplemented by hardware sprites through shadow OAM. Pokémon and many UI images use four-color source palettes, with separate palette data determining their actual colors. The pokecrystal FAQ explicitly notes the four-color paletted-PNG convention and the distinction between image data and palette data. + +Gold/Silver must support original Game Boy-style output and Super Game Boy behavior as well as Game Boy Color enhancements. Crystal's explicit Color-only startup path lets it make stronger use of CGB layout and palette machinery. Its top-level architecture adds `crystal_layouts.asm`, dedicated player graphics, revised map and battle presentation, and other CGB-focused modules. + +### Animated Pokémon sprites + +Crystal's headline renderer change is animated front sprites. Every Pokémon receives an entrance animation, and the status/profile viewer can play a longer animation; several designs, palettes, and back sprites were also revised. Crystal additionally gives the legendary beasts distinct overworld sprites and introduces richer trade-screen presentation. + +At the engine level, this requires more than storing extra frames. A full animation path needs: + +- a base front picture and additional frame or bitmask data; +- frame sequencing and duration state; +- tile-buffer reconstruction or differential tile updates; +- synchronization with battle intro control flow; +- palette and VRAM updates during safe LCD periods; +- fallback behavior for static contexts such as link displays or icons. + +Crystal's `load_pics.asm` is correspondingly larger and is integrated with dedicated animation and battle-intro modules, while Gold/Silver's loader primarily services static front/back pictures. The exact file-size difference should not be treated as a performance measurement, but the added code paths confirm a broader graphics pipeline. + +Animated fronts can also alter RNG-observable timing indirectly. The ordinary RNG is updated from divider timing and VBlank activity; therefore, any test that compares post-animation random outcomes must control whether animations are enabled and how many frames elapsed. This is an inference from the animation and RNG architectures, not evidence that every animation directly invokes `Random`. + +### Color and protagonist handling + +Crystal adds player-gender initialization and Kris-specific bag/player graphics. The Pokémon record's caught-data bits can record whether the catcher was the boy or girl protagonist. This is an example of a feature spanning UI, overworld sprites, menus, Pokémon metadata, and save state rather than existing in one isolated "female player" switch. + +A Crystal-to-Gold/Silver backport must decide how to handle caught-by-girl metadata when Gold/Silver have no female protagonist UI. Structurally the bytes can survive because they were reserved, but Gold/Silver will not natively display or generate the metadata. + +### Sound-engine architecture + +The most striking audio result is how little the interpreter changed. Both repositories' `audio/engine.asm` identify themselves as the entire sound engine, update once per frame, parse music commands, maintain eight software channel structures (four music and four sound-effect channels), and ultimately drive the four Game Boy audio channels. Both implementations handle duty, envelope, frequency, vibrato, noise sampling, channel muting, low-health sound, and fades through the same broad code. + +```mermaid +flowchart LR + SONG[Song bytecode] + PARSE[ParseMusic] + STATE[Per-channel WRAM state] + FX[Vibrato / pitch / envelope / noise] + MIX[Music-SFX priority and routing] + APU[Game Boy audio registers] + + SONG --> PARSE --> STATE --> FX --> MIX --> APU +``` + +Crystal therefore does not introduce a new synthesizer. It extends the music corpus and invokes new songs in new contexts. + +### Crystal's additional music + +Gold/Silver's song pointer table ends at `Music_PostCredits`. Crystal retains that ordering and appends ten entries: + +| Crystal-only pointer entry | Use | +|---|---| +| `Music_Clair` | Clair-related scene | +| `Music_MobileAdapterMenu` | Japanese Mobile menu | +| `Music_MobileAdapter` | Mobile connectivity | +| `Music_BuenasPassword` | Buena's Password | +| `Music_LookMysticalMan` | Eusine encounter | +| `Music_CrystalOpening` | Revised opening | +| `Music_BattleTowerTheme` | Battle Tower battle/context | +| `Music_SuicuneBattle` | Legendary-beast/Suicune battle theme | +| `Music_BattleTowerLobby` | Battle Tower lobby | +| `Music_MobileCenter` | Japanese Mobile Center | + +The pointer table labels these explicitly as "new to Crystal." + +Bulbapedia independently notes that Crystal gives the legendary beasts a unique battle theme and presents it as the first core-series special legendary battle music. + +From a porting perspective, bringing Crystal music into Gold/Silver primarily requires assigning ROM space, importing sequence data, adding pointer/constants entries, and adding selection call sites. Replacing the audio engine is generally unnecessary. However, preserving existing numeric song IDs is wise because map headers, scripts, battle setup, radio state, and fades refer to those constants. + +## Bugs, compatibility, porting, and reverse-engineering timeline + +### Bug and quirk matrix + +| Category | Gold/Silver | Crystal | Engineering interpretation | +|---|---|---|---| +| Coin Case text terminator | Exploitable continuation can permit arbitrary code execution | Correct terminator | Parser/data correction | +| First-save Hall of Fame | Can corrupt PC boxes | Guard and initialization added | Save-state precondition correction | +| Lucky Number boxes | International boxes 10-14 omitted | Uses full box count | Localization constant corrected | +| Surf onto NPC | Possible | Facing-object check added | Collision precondition corrected | +| Cerulean Gym fishing | Enabled by map fish group | Disabled | Map-header data correction | +| Reflect/Light Screen overflow | Incorrect | Fixed outside compatible link path | Context-dependent battle fix | +| Present damage | Incorrect | Fixed outside compatible link path | Protocol-preserving fix | +| Glacier Badge Special Defense | Bugged | Still bugged | Shared inherited defect | +| Apricorn specialty balls | Multiple formula defects | Still defective | Shared inherited mechanics | +| Beat Up synchronization | Vulnerable | Still vulnerable | Shared link-engine defect | +| Secondary-effect 1/256 failure | Present | Present | Shared probability comparison defect | +| Animated-sprite quirks | Not applicable | New animation-specific defects possible | Feature expansion creates new failure surface | + +The Gold/Silver and Crystal bug documents should be read together. The Gold/Silver document intentionally lists only bugs that Crystal fixed; any shared defects are documented in the Crystal repository instead. + +Crystal is thus more polished but not mechanically "corrected" in a comprehensive sense. A modern source port must choose a compatibility target: + +- **Retail-faithful:** preserve all version-specific bugs and timing. +- **Crystal-faithful:** preserve Crystal's selective fixes and its link-mode exceptions. +- **Corrected Gen II:** repair documented defects, accepting that link compatibility and historical RNG traces may change. +- **Hybrid:** gate corrections behind flags or negotiate them between identical modified link peers. + +### ROM revisions and regional code + +The Crystal repository builds international v1.0, v1.1, and Australian releases. Its FAQ states that v1.1 corrected some issues in the initial international release, while the Australian build is based on v1.1 and censors gambling references. Thus "Crystal behavior" is not completely singular even within English-language retail ROMs. + +A serious test report should always state the target hash rather than merely "Pokémon Crystal." Otherwise, an observed difference may be edition-level, v1.0/v1.1-level, Australian localization, Japanese Mobile code, Virtual Console patching, emulator behavior, or an altered ROM. + +### Link and trade compatibility + +Gold, Silver, and Crystal share the Gen II Pokémon record length and battle/link architecture, allowing ordinary same-generation trading and battling. The retained two-byte record size, shared `REDMON_STRUCT_LENGTH` conversion constant for Time Capsule interaction, and synchronized battle RNG are all source-level evidence of compatibility-oriented design. + +However, three distinct notions of compatibility must not be conflated: + +| Compatibility type | Status | +|---|---| +| Pokémon record compatibility | Strong: same record length; Crystal fills reserved bytes | +| Link protocol/battle simulation compatibility | Strong for retail games, partly because Crystal preserves old link behavior | +| Whole-save-file compatibility | Weak: Crystal's SRAM organization and added state differ substantially | +| ROM patch-address compatibility | Weak: bank layout and code placement differ | +| Feature-source portability | Moderate: shared architecture helps, but dependencies are broad | +| Gen I Time Capsule data compatibility | Supported through dedicated conversion structures and restrictions | +| Modern transfer compatibility | Supported from 3DS VC releases through Poké Transporter and Pokémon Bank | + +Nintendo's support documentation lists the Virtual Console releases of Gold, Silver, and Crystal as compatible with Poké Transporter. Transfers proceed into Pokémon Bank and can then move onward to Pokémon HOME, but that modern path is not a raw Gen II save conversion and is effectively one-way at later stages. + +### Virtual Console modifications + +Crystal's save code contains an explicit `vc_hook` after Hall of Fame insertion. The hook sets the primary and backup GS Ball flags to make the GS Ball quest and Celebi encounter available, with compile-time assertions pinning the expected SRAM addresses and flag value. + +```asm +vc_hook Enable_GS_Ball_mobile_event +vc_assert BANK(sGSBallFlag) == $1 +vc_assert BANK(sGSBallFlagBackup) == $1 +``` + +Source: [Crystal `engine/menus/save.asm`](https://github.com/pret/pokecrystal/blob/8e8f7e20052a596371a77022f0392c285e51bbf1/engine/menus/save.asm#L157-L167). + +This is a fascinating compatibility shim: the original international cartridge retained GS Ball-related structures but lacked the original Japanese distribution path, so the Virtual Console wrapper activates the event through a targeted runtime hook. The Pokémon Company confirms that the Virtual Console version permits the Celebi encounter at Ilex Forest's shrine. + +The pret repositories also contain `vc` build material, and community GitHub discussion has addressed preserving or generating Virtual Console patches. A port targeting 3DS VC behavior should therefore compare not only the retail ROM but the external patch/hook layer. + +### Japanese Crystal and mapper/SRAM concerns + +Crystal's SRAM source reserves several Mobile sections and explicitly comments that a Mobile Easy Chat initialization routine uses an "MBC30 bank" available to Japanese Crystal but inaccessible with ordinary MBC3 behavior. This is direct evidence that regional hardware assumptions matter when emulating or reproducing Japanese Mobile features. + +An implementation that supports only the common international MBC3-style SRAM/RTC configuration may run the international game correctly yet fail Japanese Crystal's extended Mobile storage accesses. Conversely, allocating the larger memory blindly does not implement Mobile Adapter protocols, timer behavior, ranking checksums, or server-era workflows. + +### Porting guidance + +The safest migration strategy between the two disassemblies is subsystem-oriented: + +| Port direction | Recommended approach | Main hazard | +|---|---|---| +| Gold <-> Silver data | Preserve shared engine; switch edition tables/assets | Conditional data references and event assumptions | +| Crystal feature -> Gold/Silver | Import feature plus all WRAM/SRAM, graphics, script, audio, and menu dependencies | DMG/SGB fallback and bank-space pressure | +| Gold/Silver behavior -> Crystal | Replace data or selectively restore old branch | Crystal story/event state may expect new semantics | +| Gold/Silver save -> Crystal | Parse and rebuild symbolic fields | Relocated blocks and Crystal-only state | +| Crystal save -> Gold/Silver | Strip Crystal state and preserve common Pokémon/player data | Caught metadata becomes opaque; Suicune/Battle Tower state has no target | +| Retail battle fix | Gate by non-link mode or require identical modified peers | RNG/control-flow desynchronization | +| Crystal graphics -> DMG-compatible engine | Create monochrome layouts or make build CGB-only | Palette attributes and animation timing | +| Japanese Mobile feature port | Emulate mapper, extended SRAM, timer, and communication assumptions | Hardware and defunct-service dependencies | + +Bank space is a concrete constraint. The pokecrystal FAQ describes the international ROM as 2 MiB divided across banks and notes that adding features can overflow fixed bank placement, requiring sections to be moved through the linker script. Crystal's extra features already consume carefully arranged banks, so a port that compiles at the object level can still fail at link time. + +The deepest practical rule is: **port invariants before routines**. Before copying code, define the target's expected record lengths, bank-switch convention, WRAM variables, SRAM addresses, script command set, palette mode, RNG call contract, and link-mode semantics. Once those invariants match, most inherited Gen II routines are straightforward. Without them, a perfectly copied routine can read the wrong field, switch to the wrong bank, write outside a save block, or desynchronize a link battle. + +### Release and reverse-engineering evidence timeline + +```mermaid +timeline + title Gold, Silver, and Crystal technical lineage + 1999-11-21 : Gold and Silver released in Japan + 2000-10-15 : Gold and Silver released in North America + 2000-12-14 : Crystal released in Japan + 2001 : International Crystal releases + : Crystal adds Color-only presentation, animations, Battle Tower, and expanded Suicune story + 2017-09-22 : Gold and Silver released for Nintendo 3DS Virtual Console + 2018-01-26 : Crystal released for Nintendo 3DS Virtual Console + : VC hook enables the GS Ball and Celebi event + 2026 : pret repositories reproducibly build exact retail revisions + : ASM-level bug catalogs and symbolic layouts support controlled comparison +``` + +Gold and Silver launched in Japan on November 21, 1999 and in North America on October 15, 2000; Crystal followed in Japan on December 14, 2000 and internationally in 2001. Gold/Silver reached 3DS Virtual Console in September 2017 and Crystal followed in January 2018. + +The current reverse-engineering record is better described as an evolving evidence base than as a single "discovery date." pret's labels, comments, and bug documentation have accumulated over thousands of repository commits, while the exact-build hashes provide a stable anchor to the retail binaries. Crystal's repository currently has substantially more history and feature documentation than `pokegold`, reflecting its role as the community's principal Gen II hacking platform rather than evidence that Crystal's retail source was inherently better documented. + +### Final technical assessment + +Gold and Silver are functionally sibling configurations of one engine. Their meaningful differences are overwhelmingly table-, asset-, and version-flag-driven. Crystal preserves that engine's defining architecture but turns it into a more specialized platform: Color-only graphics, animated Pokémon, richer event scripting, expanded persistent state, Mobile-era infrastructure, Battle Tower contexts, caught metadata, additional music, and selective defect correction. + +At ASM level, the most consequential changes are not usually exotic algorithms. They are small branches with large semantic reach: + +- a Battle Tower early return suppresses badge boosts; +- two reserved Pokémon bytes acquire caught metadata without changing record size; +- Suicune disappears from `InitRoamMons`; +- save blocks become reorganized and gain new persistent domains; +- a text terminator closes the Coin Case execution path; +- link-mode checks preserve old arithmetic to avoid desynchronization; +- a Virtual Console hook activates an otherwise inaccessible event; +- a Mobile timer branch attaches a new subsystem to the interrupt path. + +That pattern explains both Crystal's compatibility and its porting difficulty. It remains recognizably the Gold/Silver engine, but its new features are woven through banks, state structures, control-flow conditions, graphics timing, SRAM, scripts, and presentation. The code is cousin-shaped, not copy-paste-shaped: the classic reverse-engineering booby trap wearing a tiny Suicune hat. \ No newline at end of file diff --git a/docs/gen2-link-design.md b/docs/gen2-link-design.md new file mode 100644 index 00000000..b1b94584 --- /dev/null +++ b/docs/gen2-link-design.md @@ -0,0 +1,316 @@ +# Gen 2 link play: what it takes + +This is a design document, not a feature announcement. Gold cannot link today +and nothing in this document changes that on its own. What it does is state +honestly what Gen 2 link play requires, where the Gen 1 protocol in `src/link/` +stops working on a Gen 2 record, what the cart itself did, and which pieces of +the work are self-contained enough to have been built already. + +The short version: the transport, the handshake and the fingerprint are +generation-agnostic or nearly so, and those are done. The party wire format is +half done (a codec exists, nothing sends it). The trade session, the trade UI +and the lockstep battle are not started, and each is a real piece of work. + +Read `docs/mod-api-gen2-compat.md` beside this: it is the reference for what a +Gold mod may touch, and the link fingerprint's whole job is to hash exactly that +surface. + +## 1. What Gen 1 link play is + +`src/link/` is nine files and about four thousand lines: + +| File | Job | +| --- | --- | +| `Net.lua` | lua-enet transport, LAN host/join plus a relay for online play. JSON messages, no game types. | +| `CodeEntry.lua` | the online join code widget. | +| `Handshake.lua` | the `hello` both peers exchange and the compatibility verdict drawn from the two of them. | +| `Fingerprint.lua` | a deterministic digest of the link surface: the slice of merged Data whose value decides whether two lockstep simulations stay identical. | +| `Protocol.lua` | mon serialization, the record-subset negotiation, and the trade session state machine. | +| `LinkState.lua` | the LINK menu, pairing, and the trade/battle hand-off. | +| `LinkBattle.lua` | lockstep battle: both machines run `src/battle/BattleState.lua` from mirrored perspectives on a shared seed, exchanging one action per turn and a per-turn state hash. | +| `Tournament.lua` | bracketed online play over the relay. | +| `Json.lua` | the encoder the wire and several unrelated callers share. | + +Only three of those nine are shaped around Gen 1 game content: `Fingerprint`, +`Protocol` and `LinkBattle`. `Net`, `Json`, `CodeEntry` and most of `Handshake` +never look at a Pokemon. + +## 2. What the cart did + +pokegold's link code is `engine/link/link.asm` (2508 lines), and it is worth +being precise about, because several of the port's design questions have a cart +answer. + +**Three rooms, one wire.** `wLinkMode` is `LINK_TIMECAPSULE`, +`LINK_TRADECENTER` or `LINK_COLOSSEUM` (`constants/serial_constants.asm`). +`LinkCommunications` branches once, at the top, on whether the mode is the Time +Capsule: `Gen2ToGen1LinkComms` for the Time Capsule and `Gen2ToGen2LinkComms` +for everything else (`engine/link/link.asm:33`). Everything after that branch is +shared -- the same byte exchange serves trading and battling, and the mode only +decides what is in the buffer and what the game does afterwards. + +**What crosses the wire, in order** (`Gen2ToGen2LinkComms`, +`engine/link/link.asm:202`): + +1. `SERIAL_RN_PREAMBLE_LENGTH + SERIAL_RNS_LENGTH` bytes: the shared battle + RNG state. Seven preamble bytes and ten seeds. +2. `SERIAL_PREAMBLE_LENGTH + NAME_LENGTH + (1 + PARTY_LENGTH + 1) + 2 + + (PARTYMON_STRUCT_LENGTH + NAME_LENGTH * 2) * PARTY_LENGTH + 3` bytes: the + player name, the party count and species list, the **trainer ID**, six party + structs, six OT names and six nicknames. +3. `SERIAL_PATCH_LIST_LENGTH` bytes: the patch list (see below). +4. In the Trade Center only, the mail block: six mail messages then six mail + metadata structs (`Link_PrepPartyData_Gen2`, `engine/link/link.asm:810`). + +**The shared PRNG is the whole trick behind lockstep.** `_BattleRandom` +(`engine/battle/core.asm:6650`) refuses the normal RNG whenever `wLinkMode` is +non-zero and pulls from `wLinkBattleRNs` instead: ten seeds, each advanced by +`a[n+1] = (a[n] * 5 + 1) % 256` when the stream runs out, with the count in +`wLinkBattleRNCount`. Both machines exchange those ten bytes once, before the +battle, and never again -- the external-clock side adopts the internal-clock +side's numbers (`Link_CopyRandomNumbers`, `engine/link/link.asm:1115`). The port +does the same thing with a Park-Miller stream seeded by the host +(`LinkBattle.makeRng`), which is the same idea in a different arithmetic. + +**The patch list exists because the wire has reserved bytes.** +`SERIAL_NO_DATA_BYTE` (`$fe`) may not appear in the payload, so +`FixDataForLinkTransfer` (`engine/link/link.asm:556`) walks the party block, +replaces every `$fe` with `$ff`, and records the offsets it touched in a +200-byte list that ships alongside. Mail gets its own smaller version of this in +`Link_PrepPartyData_Gen2`. The port's wire is JSON over enet and has no reserved +bytes, so this whole mechanism has no analogue and needs none. It is worth +knowing about only so nobody reimplements it by accident. + +**The Time Capsule is the cart's own answer to a cross-generation link.** +`CheckTimeCapsuleCompatibility` (`engine/link/link.asm:1970`) refuses the party +outright for exactly three reasons, and returns which one in `wScriptVar`: + +1. a species at or above `JOHTO_POKEMON` (`constants/pokemon_constants.asm:168`, + the 152 boundary), +2. a move above `STRUGGLE`, i.e. any move Gen 1 does not have, +3. any party member holding mail. + +If the party passes, `Link_PrepPartyData_Gen1` (`engine/link/link.asm:640`) +rewrites every mon into the 44-byte `REDMON_STRUCT_LENGTH` layout: +`ConvertMon_2to1` remaps the species index through `Pokered_MonIndices` +(`engine/link/time_capsule_2.asm:1`), the Special stat is recomputed from +`KantoMonSpecials` because Gen 1 has one Special and Gen 2 has two, and +Magnemite and Magneton are shipped as pure Electric because their typing changed +(`engine/link/link.asm:731`). Coming the other way, `Link_ConvertPartyStruct1to2` +(`engine/link/link.asm:930`) reads the Gen 1 catch rate byte as the held item +slot -- garbage, which is why `TimeCapsule_ReplaceTeruSama` +(`engine/link/link.asm:1078`) maps the handful of catch rates that collide with +real items through `data/items/catch_rate_items.asm` and turns everything else +into a Berry. `ValidateOTTrademon` (`engine/link/time_capsule.asm:1`) then +re-checks the incoming mon's types against the local base data and refuses +anything that does not agree, with Magnemite and Magneton carved out again. + +The lesson the port should take from all of that: a cross-generation link is not +a compatibility mode, it is a lossy conversion with a validator on each end, and +the cart wrote roughly 400 lines for it. Refusing the pairing is the honest +default until somebody wants to write those 400 lines. + +## 3. Where the Gen 1 protocol breaks on a Gen 2 record + +`Protocol.packMon` / `Protocol.unpackMon` are shaped around +`src/pokemon/Pokemon.lua`. Against `src/battle/gen2/Mon.lua`, every one of these +is wrong: + +| Gen 1 assumption | Gen 2 reality | Where | +| --- | --- | --- | +| `mon.exp` | `mon.experience` | `Mon.new`, `Mon.gainExperience` | +| five DVs including `special` | four rolled DVs (`attack`/`defense`/`speed`/`special`) with `hp` **derived** from their low bits | `Mon.hpDV` | +| `special` is one stat | `specialAttack` and `specialDefense` are two stats off one DV | `Mon.stats` | +| `statExp` has five words | five words still, but the fifth feeds both special stats | `Mon.stats` | +| no held item | `mon.item` decides damage, healing and flinching | `src/battle/gen2/Battle.lua` `heldEffect` | +| stats recomputed with `src/pokemon/Stats.lua` | must go through `Mon.stats`, which is a different formula shape | `Mon.stats` | +| exp curve via `src/pokemon/Growth.lua` (code) | curve coefficients are **data**, at `data.pokemon.growthRates` | `Mon.experienceForLevel` | +| `mon.status` is `"SLP"` / `"PSN"` / `"BRN"` / `"PAR"` / `"FRZ"` | `"sleep"` / `"poison"` / `"burn"` / `"paralyze"` / `"freeze"` / `"toxic"` | `src/battle/Status.lua:62` vs `src/battle/gen2/Battle.lua:65` | +| `mv.ppUps` | Gen 2 has no PP Ups modelled yet; moves carry `id`, `pp`, `maxPp` | `Mon.movesAtLevel` | +| nothing else on the mon | `happiness`, `pokerus`, `caughtLevel`, `isEgg`/`eggSteps`, and the derived `shiny` / `gender` / `unownLetter` | `Mon.new` | +| mail does not exist | mail is a **separate** block keyed by party slot, not a mon field | `src/core/gen2/Mail.lua:84`, and the cart agrees (`Link_PrepPartyData_Gen2`) | + +The status spelling is the sharpest of these. `packMon` puts `mon.status` on the +wire verbatim and `unpackMon` keeps it verbatim, so a shared codec would move +`"PSN"` onto a Gold party where nothing recognises it -- a mon that arrives +"poisoned" and never takes poison damage. Any Gen 2 codec has to be a separate +function with the generation baked into it, not a Gen 1 function with extra +optional keys. + +Two things are *not* a problem, and it is worth saying so: + +- **Species and move ids are shared name spaces.** Both generations key + `data.pokemon` and `data.moves` by the same `TOTODILE` / `TACKLE` strings, so + the wire never needs the cart's index remap (`ConvertMon_2to1`). Ids, not + bytes, is what makes the port's protocol simpler than the cable's. +- **The derived fields do not travel.** `shiny`, `gender` and `unownLetter` are + all functions of the DVs (`Mon.isShiny`, `Mon.gender`, `Unown.letterFromDVs`), + so the receiver recomputes them and a tampered packet cannot claim a shiny it + did not roll. Same reasoning as `unpackMon` recomputing stats. + +`LinkBattle.lua` breaks in a larger way. It calls +`BattleState.makeBattler`, `src/battle/TurnOrder.lua` and Gen 1's damage +pipeline directly. Gold's battle is `src/battle/gen2/Battle.lua`, a different +object with a different event shape. The one piece of good news is that +`Battle.new` already takes `opts.random` (`src/battle/gen2/Battle.lua:220`) -- +the injection seam a lockstep battle needs is there and does not have to be cut. + +## 4. The wire format for Gen 2 + +Message types stay the ones `Protocol.lua` already documents (`hello`, +`records`, `party`, `pick`, `confirm`, `action`, `event`, `bye`). Gen 2 adds +fields, never renames -- the same rule the mod seams follow. + +**`hello`** gains one field, `generation` (1 or 2). A peer that omits it is +generation 1 by construction: no released build has ever had Gen 2 link, so +absent means Red/Blue/Yellow. This is implemented. + +**`party`** carries a list of packed mons whose shape is the Gen 2 one: + +``` +{ species, level, experience, hp, status, nickname, + dvs = { attack, defense, speed, special }, -- hp derived + statExp = { hp, attack, defense, speed, special }, + moves = { { id, pp } ... }, + item, happiness, pokerus, caughtLevel, + ot, otId, isEgg, eggSteps, extra } +``` + +and, in a trade, a parallel `mail` array indexed by the same wire position -- +separate exactly as `Link_PrepPartyData_Gen2` keeps it separate, because mail is +SRAM the cart copies out of `sPartyMail`, not part of the party struct. The +codec for the mon half is implemented; the mail half is not. + +**`records`** gains a third map, `heldItems`, beside `pokemon` and `moves`, so a +subset trade can refuse a mon whose held item the other game would rebuild +differently. Implemented. + +**`action` / `event`** for a lockstep Gen 2 battle are not designed here, because +the design is downstream of a decision nobody has made yet: whether +`src/battle/gen2/Battle.lua` grows a "replay these two actions" entry point or +whether the link battle drives it through the same UI path a local battle uses. +Guessing at a message shape before that is exactly the half-built protocol this +document is meant to avoid. + +## 5. The fingerprint on Gold + +The fingerprint is the one part of the design that is fully answerable today, +because it depends only on what a Gold mod can reach, and `src/mods/Schemas.lua` +answers that exhaustively. + +The Gen 1 surface is species, moves, the type chart, statuses, move effects, +constants and mod-declared link fields. The Gen 2 surface is the same idea over +different tables, plus one registry Gen 1 does not have: + +| What | Gen 1 | Gen 2 | Why it is surface | +| --- | --- | --- | --- | +| species | `data.pokemon` | `data.pokemon` (Gen 2 shape) | base stats decide every damage roll; `evolutions` decides what a traded mon becomes | +| exp curves | code (`src/pokemon/Growth.lua`) | `data.pokemon.growthRates` | the level a traded mon's experience buys | +| moves | `data.moves` | `data.moves`, **plus `effectChance`** | Gen 2 stores the secondary-effect odds per move rather than per effect | +| type chart | `data.type_chart` | same, **plus `foresightMatchups`** | Foresight rewrites Ghost's immunities mid-battle | +| statuses | `data.statuses` | `data.gen2Statuses` | `statPenalty`, `cureOnSwitch`, `beforeMovePriority` | +| move effects | `data.move_effects` | `data.gen2MoveEffects` | which effect is primary, secondary, or accuracy-checked | +| held items | -- | `data.gen2HeldItems` | Leftovers, King's Rock, the type boosters: pure battle math, and the item travels with a traded mon | +| link fields | `data.link_fields` | none (gated) | a mod-declared extra mon field; see below | + +Deliberately **not** hashed on either generation, and the reasoning is the same +in both: + +- Names, dex entries, learnsets, TM/HM lists, sprite paths and `source`. None of + them changes a battle turn or a trade rebuild, and sprite paths differ between + two identical installs. +- `catchRate` (#511). A ball thrown in a link battle is refused; hashing it split + Red/Blue from Yellow over two bytes. +- Balls and item effects. Link battles allow no bag items on either cart. +- `data.gen2Constants`. It is the ROM's ordered name lists -- `mapOrder`, + `spriteOrder`, `speciesOrder`, `heldEffectOrder` -- and it is an *index* space. + Every dispatch in the Gen 2 simulation goes by name (`Battle.heldEffect` + compares `record.heldEffect` strings, `moveEffectRecordFor` keys by + `EFFECT_*`), so reordering a constants list moves no battle math. Hashing it + would split two peers over a list neither of them dispatches on. +- Breeding data (`eggGroups`, `eggMoves`, `eggSteps`). The Day-Care is local. + There is no link breeding and an egg's contents are decided before it is + traded. +- Everything in the Gen 2-only registries that is not `held_items`: + `phone_contacts`, `decorations`, `apricorns`, `landmarks`, `radio_channels`. + None of them can be observed from inside a link session. + +`genderRatio` **is** hashed, unlike anything in the breeding block, because Gen 2 +has Attract and a gender disagreement is a battle-math disagreement. + +`link_fields` stays gated on Gen 2 (`Schemas.GEN2`). It is a mod's declaration +that an extra mon field must survive the wire, and it can only be un-gated once +there is a Gen 2 mon wire format with a session behind it. A registry that hashes +into the fingerprint but that no packer reads would be a lie the fingerprint +tells. + +The hook keeps its Gen 1 name and its Gen 1 arity. `link.fingerprint` is called +as `(data, mods)` on both generations, and the generation is captured in the +vanilla closure rather than passed as a third argument -- a Gen 1 mod that wraps +the hook and forwards `nxt(data, mods)` therefore works verbatim on Gold, which +a third argument would have quietly broken. + +## 6. Cross-generation pairing + +`Handshake.checkCompat` refuses a pairing whose two `generation` fields differ, +with reason `generation_mismatch`, before any other check. This is deliberately +a refusal and not a Time Capsule: section 2 costs roughly 400 lines of lossy +conversion plus two validators, and shipping a menu that pairs a Gold game with a +Red one and then rebuilds a Cyndaquil as whatever species index 155 happens to +be in Red would be worse than refusing. + +If somebody does want the Time Capsule later, the cart's own rules are the spec, +and the port has an advantage the cart did not: ids rather than indices, so +`ConvertMon_2to1`'s remap table is unnecessary. What remains is real work: +refuse species above the Kanto 151, refuse moves Gen 1 lacks, refuse held items +and mail, fold `specialAttack`/`specialDefense` back into one Special +(`KantoMonSpecials`, `data/pokemon/gen1_base_special.asm`), and re-validate types +on arrival with Magnemite and Magneton carved out. + +## 7. What is built, and what is left + +**Built (this change).** + +- `Fingerprint.generationOf(data)`: reads `data.type_chart.generation`, then the + presence of the Gen 2-only Data namespaces, so the digest never has to be told + which game it is running in. +- A Gen 2 link surface in `Fingerprint.lua`, tagged `[gen2]` so a Gen 2 digest + can never collide with a Gen 1 one, covering the table in section 5. +- `Fingerprint.records(data, kind, generation)` for `pokemon`, `moves` and + `held_items` on Gold, which is what a subset trade negotiates on. +- `Handshake.hello` carries `generation`; `checkCompat` refuses a cross-generation + pairing; `describe` explains it in the screen's own voice. +- `held_items` counts as link surface for `Handshake.linkModified`, so a Gold mod + that gives Leftovers a different heal is correctly locked out of online play. + `growth_rates` counts too, and for a reason worth stating: a curve is an + `expForLevel` function, so the fingerprint serializes it as `?` and cannot + hash it at all. `Mon.growthFor` prefers the merged registry over the + extractor's coefficient rows, so a mod declaring `affects_link = false` could + otherwise rewrite every curve -- changing what level a traded mon's experience + buys -- and be caught by neither the digest nor the online gate. +- `Protocol.packMon2` / `unpackMon2`: the Gen 2 party-struct codec, with the + receiver recomputing stats, experience, shininess and gender from real species + data the way `unpackMon` does. Nothing sends it yet. +- `Protocol.recordsMessage` and `eligibleParty` understand held items, and + `TradeSession:_negotiate` builds our own side of the comparison by calling + `recordsMessage` rather than open-coding a subset of it. That is not a + refactor: the open-coded version left `heldItems` off our side only, and both + held-item arms of `eligibleParty` are guarded on our own map, so the whole + check was unreachable from the only caller that matters. + +**Not built.** Sized as honestly as I can: + +| Piece | Size | Why it is not here | +| --- | --- | --- | +| Mail on the wire | small (1-2 days) | needs `Mail.state(save).party` packed as a parallel array and the receiver writing it into its own slot; no consumer until the trade session exists | +| Gen 2 trade session | medium (about a week) | `TradeSession` is generic in shape but `apply` calls `src/world/PikachuFollower`, Gen 1 pokedex fields and Gen 1 trade evolutions; Gold needs `src/core/gen2/TradeAnim.lua`, `src/core/gen2/Evolution.lua` (`EVOLVE_TRADE`) and the Gen 2 pokedex | +| Gold LINK menu and trade UI | medium (about a week) | Gold's menus are `src/ui/gen2/`, its text box is Gen 2 chrome, and the Cable Club script needs the receptionist path; `LinkState.lua` draws with Gen 1 `TextBox` throughout | +| Gen 2 lockstep battle | large (several weeks) | needs a deterministic-replay entry point into `src/battle/gen2/Battle.lua`, a Gen 2 action/event encoding, and a state hash over a much larger volatile-status set than Gen 1's; the `opts.random` seam exists, nothing else does | +| Time Capsule | large | section 6 | +| `link_fields` on Gen 2 | small, but blocked | needs the trade session first | + +The order matters: mail, session and UI are the trade half and can ship without +the battle half, exactly as the cart's Trade Center and Colosseum are separate +rooms. A Gold LINK menu that offers only TRADE is a complete feature. A Gold +LINK menu that offers BATTLE before the lockstep battle exists is the failure +this document was written to prevent. diff --git a/docs/gold-phase1.md b/docs/gold-phase1.md new file mode 100644 index 00000000..dfd86910 --- /dev/null +++ b/docs/gold-phase1.md @@ -0,0 +1,188 @@ +# Pokemon Gold: Gen 2 import, colour, menus, saving and battles + +Support for a canonical **Pokemon Gold** ROM +(SHA-1 `d8b8a3600a465308c9953dfa04f0081c05bdcb94`, 2 MiB). Gen 2 extraction +and overworld are separate from Red/Blue/Yellow -- never branched into +`RomExtractor.lua` / Gen 1 `Map.lua`. + +## Pipeline + +| Piece | Role | +| --- | --- | +| `src/import/Rom.lua` (`decompressLz3`) | Gen 2 graphics compression (`home/decompress.asm`) | +| `tools/make_gold_manifest.py` → `tools/rom_manifest_gold.json` | pret/pokegold constants + `pokegold.sym` | +| `src/import/RomExtractorGen2.lua` | Gen 2 extract (dispatched from `RomImporter` when `version == "gold"`) | +| `src/core/GameVersion.lua` | `gold` entry (`gold/` cache, `_gold` saves) | +| `src/world/gen2/` | COLL_* world, NPCs, events, roofs | +| `src/script/gen2/` | Opcodes + talk-oriented VM over extracted cmds | +| `src/core/Game2.lua` | Gold's service owner (the Gen 2 peer of `src/core/Game.lua`): boot cinema, intro menu, world, START menu | +| `src/core/gen2/Save.lua` | `save_gold.lua`, beside the Gen 1 saves | +| `src/ui/gen2/` | Intro menu, naming keyboard, START menu + every submenu, battle screen | +| `src/battle/gen2/` | Damage, stats/experience, HP bar, turn engine, catching, encounters | +| `src/render/GbcPalette.lua` | One shader: a 4-shade sheet through a GBC palette | + +## What extracts + +| Area | Status | +| --- | --- | +| Constants | species/map/tileset/move/type/sprite/environment/palette/fish orders | +| Font | `Font` (1bpp ink-on-transparent), `FontExtra` + `Frames` borders at $79–$7E, `FontBattleExtra` | +| Tilesets | lz3 GFX → PNG; raw Meta (128×16) + Coll (128×4 COLL_* quads); Anim/PalMap pointers | +| Roofs | 5 roof sheets + `MapGroupRoofs` (runtime: outdoor tilesets only) | +| Maps | all 368: blocks, connections, warps, coord/bg/object events (+ `scriptKey`) | +| Pokemon | `BaseData` (32B), names, front/back pics, plus Unown's 26 letter forms from `UnownPicPointers` | +| Items | Names, prices, pockets, held effects, field/battle use, descriptions, TM/HM numbers and the move each teaches | +| Marts | `Marts`' 34 shelves in MART_* order plus `BargainShopData`'s own item/price rows (`marts.lua`) | +| Overworld sprites | `OverworldSprites` → PNG sheets + `sprites.lua` | +| Scripts / text | disassembled cmds + decoded strings (`scripts.lua` / `text.lua`) | +| Initial events | `InitializeEventsScript` → `initial_events.lua` | +| Title | Tilemap + GBC tint; cloud band; Ho-Oh frames 1–5; trail; copyright splash | +| Oak speech | `_OakText1–7`, `PokemonProfPic` / `CalPic`, Marill front; `oak_speech.lua` | +| Pokemon / trainer pics | lz3 + column-major → row (`ImageWriter.columnsToRows`) | +| Audio | banks dumped to `programs.bin`; Gen 2 ChipSynth driver (`runtime = true`) | +| Palettes | `TilesetBGPalette`, `EnvironmentColorsPointers`, `MapObjectPals`, `RoofPals`, mon/trainer/HP-bar/exp-bar palettes, per-tileset `PalMap` | +| Moves | `Moves` + names + descriptions, effect names, real percentages | +| Type chart | `TypeMatchups` (plus the Foresight rows) and `TypeNames` with physical/special | +| Pokemon | Base stats, evolutions and level-up moves (`EvosAttacks`), TM/HM lists, growth rates, egg groups, gender ratio | +| Encounters | Grass (three time-of-day slot lists), water, fishing groups, headbutt tree sets | +| Trainers | Every class's parties with moves/items, class names, attributes, `TrainerEncounterMusic` | +| Pokedex | Entries (kind, height, weight, text) + the New and A-Z orderings | +| Landmarks | Town-map positions and names, plus `SpawnPoints` | +| Icons | Party-menu mon icons and the species → icon map | +| Menu / HUD gfx | Naming-screen chrome, battle HUD border and bar tiles, exp bar | +| Intro movie | The three acts' composed backgrounds, sprite sheets and fire frames | +| Battle anims | all 428 scripts, 188 objects, 185 framesets, 216 OAM sets, 40 object sheets, the six OBJ palettes | +| Field anims | stubs | + +Verified against a real Gold ROM: New Bark Town warps/objects/connections +match `pokegold/maps/NewBarkTown.asm`. Retail EVENT_* numeric ids differ +from pret’s current `const_def` order -- always take flags from the cart. + +## Play + +Launcher → Gold tab → Play opens `Game2` (`src/core/Game2.lua`): + +- Copyright → GameFreak Presents → GS intro stub → title (colored tilemap, + scrolling clouds, Ho-Oh flap, trails) → Oak speech (Marill cry + shrink) + → name pick → `src/world/gen2/World` +- Drivers (`POKEPORT_DRIVER`) skip cinema straight to the map +- New Bark Town with Johto tileset + roof overlay (outdoors only) +- Walk on `COLL_*` permissions; doors / stairs / carpets +- Elm's lab: scene walk-in, starter balls (`givepoke` + cutscene moves), + Elm phone number, aide Potion on exit (`verbosegiveitem`) +- Connected neighbor strips + seamless edge crossings (Route 29 / 27) +- Survey zoom (`-` / `=` / wheel / `4`) +- Chris + map/neighbor NPCs; `SPRITEMOVEDATA_*` walk / spin / stand +- New-game flags hide story NPCs (e.g. lab cop) +- A: talk to facing NPC or read `BGEVENT_READ` signs via Gen 2 VM + TextBox + +Escape is START, not quit: quitting is the START menu's QUIT row and the +intro menu's EXIT GAME. Title / map music and script SFX/cries use the Gen 2 +channel driver. + +## Colour + +Gen 2 is CGB-native, so colour is part of a tile's identity rather than a tint +over a 4-shade image. `engine/gfx/color.asm` LoadMapPals is ported whole in +`src/world/gen2/Palettes.lua`: + +1. the clock hour picks a daytime (`engine/rtc/rtc.asm` TimesOfDay), which a + map's own `PALETTE_*` can override (`ReplaceTimeOfDayPals`) +2. `EnvironmentColorsPointers[environment][daytime]` names eight entries in the + shared `TilesetBGPalette` pool +3. outdoors only, `RoofPals[mapGroup]` overwrites `PAL_BG_ROOF` colours 1-2 -- + one roof tile sheet, a different colour per town +4. each tile's slot comes from its tileset's `PalMap`, and each OW sprite's OBJ + palette from `MapObjectPals[daytime]` plus its own `PAL_OW_*` + +The map bake runs one shader pass per BG slot (eight per map) rather than one +per tile, and re-bakes when the clock rolls into a new daytime. +`POKEPORT_GOLD_HOUR=21` pins the hour for screenshots. + +## Still to do + +About fifty of AI_Smart's seventy per-effect handlers are unwritten, fishing +and headbutt have engine support but no input path, and Mart, the summary +screen, `.sav` interchange, Silver and mod-replaceable Gen 2 screens are all +still open. + +## Verify + +```sh +python3 tools/make_gold_manifest.py +for t in rom_lz3 gen2_world gen2_audio gen2_oak_speech gen2_vm \ + gen2_palettes gen2_battle gen2_menus gen2_save; do + luajit tests/${t}_test.lua +done +POKEPORT_IMPORT_TRACE=1 POKEPORT_IMPORT_ROM=/path/to/pokegold.gbc \ + POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 POKEPORT_GAME=gold love . +POKEPORT_GAME=gold love . +# copyright → GF presents → GS intro → title → CONTINUE/NEW GAME/OPTION +# → Oak → name → the bedroom. START opens the menu; grass starts a battle. + +# Screenshot drivers (need a display): +POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_menu_shots.lua love . +POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_palette_shots.lua love . +POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_smoke.lua love . +POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_transition_shots.lua love . +POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_teacher_scene.lua love . +POKEPORT_GAME=gold POKEPORT_BOOT_CINEMA=1 \ + POKEPORT_DRIVER=tests/drivers/gold_boot_smoke.lua love . +``` + +`POKEPORT_IMPORT_TRACE=1` prints each of the 26 stages as it starts, and a +headless import that fails now says so and exits non-zero rather than sitting +in an error state that looks exactly like a hang. + +A Gold cache without `palettes.lua`, `moves.lua`, `encounters.lua`, +`trainers.lua`, `pokedex.lua`, `landmarks.lua`, `icons.lua`, `menu_gfx.lua`, +`intro.lua`, `std_scripts.lua` or `battle/hud/*.png` needs a re-import -- as +does one whose back pics are still the front pic's size rather than 48x48 (see +below), and one whose trainer classes have no `encounterMusic` (the sixth +pass added `TrainerEncounterMusic` to the manifest, so the manifest has to be +regenerated as well). So does one whose `text.lua` has no `labels` table: that +is the by-name seed for the text no script pointer reaches (the Day-Care and +breeding block, the mart conversation, the Hall of Fame headers), and it needs +a regenerated manifest too, because the seed reads those symbols by name. + +## Traps this port has already fallen into + +Worth knowing before touching the extractor: + +- **Back pics are always 6x6 (48x48).** `BASE_PIC_SIZE`'s low nibble describes + the *front* pic only, so decoding a back at it reads the wrong tile count in + the wrong number of columns and produces garbage. +- **`#`, `` and `` have no font tile.** They are compression bytes + ($54/$24/$4a) that PlaceString expands; the manifest charmap has to expand + them or "#DEX" renders as "DEX". +- **FishGroups has no row for FISHGROUP_NONE**, so a group's row is its id minus + one. Walking the constant list from the top reads every row shifted. +- **Trainer groups have no end marker** -- the next group's label follows + immediately -- so a party scan needs the class's own member list to bound it. +- **Pokedex entries are spread over four banks** and the cart derives the bank + arithmetically; take each species' own symbol instead. +- **`EvosAttacksPointers` is `dw`, not `dba`**: the blobs share the table's bank. +- Item ids past NUM_ITEMS (the TMs and HMs) have no ItemNames row; their name is + their TM number. +- **`TrainerClassAttributes` rows are SEVEN bytes**, not eight: + `NUM_TRAINER_ATTRIBUTES` is `_RS` after three `rb` and two `rw`. An + eight-byte stride walks one byte further off with every class, so the AI + flags come out as noise for everything past the first trainer -- and it + produces plausible values rather than an error, which is how it survived two + passes. The row is {item1, item2, baseMoney, aiLo, aiHi, switchLo, + switchHi}: the base money is byte THREE. +- **`dba_pic` does not store the real bank.** For the three "Pics" sections + that sit above the 8-bit-friendly range it writes `$13`, `$14` or `$1f`, and + `FixPicBank` (engine/gfx/load_pics.asm) maps those back to `$1f`, `$20` and + `$2e`. Any table of pic pointers -- `PokemonPicPointers`, + `UnownPicPointers`, `TrainerPicPointers` -- needs that mapping, or the read + lands in a completely different bank. +- **A pic can run over the top of its bank.** `GetLZByte` bumps the bank and + drops back to `$4000` when the read pointer passes `$8000`, so a compressed + pic near the top of a bank keeps going into the next one; slicing only to + `$8000` stops short of nine of the Unown letters. +- **`BattleAnimObjects` rows are SIX bytes**, because `BATTLEANIMOBJ_LENGTH` is + `_RS - 1` -- the struct's runtime INDEX byte is not in the table. +- **Unown has no `PokemonPicPointers` row of its own**; its twenty-six forms + come out of `UnownPicPointers` instead, and letter A stands in for the + species. diff --git a/docs/gold-walkthrough/asm-walk/README.md b/docs/gold-walkthrough/asm-walk/README.md new file mode 100644 index 00000000..0c50b07d --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/README.md @@ -0,0 +1,1374 @@ +# ASM Walk index + +One document per walkthrough section, each mapping that section's beats onto the +pokegold disassembly: map constants and asm files, verbatim warp / coord / bg / +object event tables, script control flow with the `EVENT_*` and `ENGINE_*` flags +each label reads and writes, trainer party labels, wild encounter tables, the code +that enforces each progress gate, an ordered bot checklist, and a port-coverage +audit against `src/world/gen2/`, `src/script/gen2/`, `src/core/gen2/` and +`src/battle/gen2/`. + +Written against: + +- Walkthrough source: `docs/gold-walkthrough/section-NN-*.txt` +- Disassembly: `../pokegold` (paths in these docs are relative to that checkout root) +- Symbols: `../pokegold-symbols/pokegold.sym` + +Output shape is defined in [_TEMPLATE.md](_TEMPLATE.md). + +## Sections + +### 00. [chikorita cyndaquil or totodile](section-00-chikorita-cyndaquil-or-totodile.md) + +Covers the Pokemon Gold opening: new game, InitClock time set, Oak speech and player naming, the bedroom spawn at SPAWN_HOME, Mom's Pokegear cutscene, New Bark Town and the first rival encounter, and Elm's Lab through the starter choice and the aide's Potion. Documents every warp/coord/bg/object row for the four maps, the scene-id gates that block progress, the starter givepoke data, and where each beat lands (or does not land) in this repo's Gen 2 port. + +**Maps:** `MAP_PLAYERS_HOUSE_2F`, `MAP_PLAYERS_HOUSE_1F`, `MAP_NEW_BARK_TOWN`, `MAP_ELMS_LAB` + +**Gates:** + +- SCENE_ELMSLAB_CANT_LEAVE (scene 1) coord events at ElmsLab (4,6)/(5,6) -> LabTryToLeaveScript pushes the player back up; released only by ElmDirectionsScript's setscene SCENE_ELMSLAB_AIDE_GIVES_POTION +- SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU (scene 0) coord events at NewBarkTown (1,8)/(1,9) -> NewBarkTown_TeacherStopsYouScene1/2 drag the player back east; released by ElmDirectionsScript's setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP +- EVENT_GOT_A_POKEMON_FROM_ELM (constants/event_flags.asm:35) is the section's terminal flag, set by ElmDirectionsScript after any starter is taken +- InitClock (engine/rtc/timeset.asm:4) must be answered plus confirmed twice before the world is created +- MeetMomScript's DST yesorno pair loops back to .SetDayOfWeek on a 'no' confirm - the Pokegear/ENGINE_POKEGEAR flags are not set until it exits +- Aide's POTION is scene-gated (SCENE_ELMSLAB_AIDE_GIVES_POTION at ElmsLab (4,8)/(5,8)), not flag-gated +- EVERSTONE requires EVENT_SHOWED_TOGEPI_TO_ELM (ElmGiveEverstoneScript, maps/ElmsLab.asm:338-347) - unreachable in this section despite the walkthrough listing it + +**Unresolved (6):** + +- Walkthrough lists Everstone as a New Bark Town item; the only local Everstone is ElmGiveEverstoneScript gated on EVENT_SHOWED_TOGEPI_TO_ELM (hatched Togepi), many sections later +- Walkthrough says Oak greets you then asks the time; in engine/menus/intro_menu.asm:494 OakSpeech's first instruction is farcall InitClock, so the clock screen comes first +- Walkthrough treats the Pokegear as a received item; MeetMomScript only does setflag ENGINE_POKEGEAR / ENGINE_PHONE_CARD, nothing enters the bag +- 'the time will keep running even with the GBA off' is an RTC hardware claim with no disassembly counterpart +- data/maps/attributes.asm:121 gives NEW_BARK_TOWN a connection east to ROUTE_27 with no guarding coord event; whether the shoreline in maps/NewBarkTown.blk blocks it pre-Surf was not decoded +- Only (1,8) and (1,9) carry the teacher coord events; whether the west connection strip is reachable at any other y was not verified against collision data + +### 01. [cherrygrove city and routes 29 31](section-01-cherrygrove-city-and-routes-29-31.md) + +Maps the Cherrygrove/Routes 29-31 walkthrough stretch onto pokegold: every warp, coord event, bg event and object event row transcribed verbatim from maps/Route29.asm, CherrygroveCity.asm, Route30.asm, Route31.asm, Route46.asm, MrPokemonsHouse.asm and the surrounding houses/gates, plus map headers, wild tables, trainer parties and the EVENT_/ENGINE_ flags each script reads and writes. Adds a 30-step bot checklist, the enforcing code for every progress gate, and an honest port-coverage table that flags item balls as unimplemented and the MAP CARD flag as not reaching the Pokegear. + +**Milestones:** No badge earned in this section; ENGINE_MAP_CARD (Guide Gent tour, CherrygroveCityGuideGent); ENGINE_POKEDEX (MrPokemonsHouse_OakScript); MYSTERY_EGG obtained then delivered -> EVENT_GAVE_MYSTERY_EGG_TO_ELM; Rival battle 1 won (RIVAL1 id 1/2/3, level 5 starter); ENGINE_FLYPOINT_CHERRYGROVE + blackoutmod CHERRYGROVE_CITY + +**Maps:** `MAP_ROUTE_29`, `MAP_ROUTE_29_ROUTE_46_GATE`, `MAP_ROUTE_46`, `MAP_CHERRYGROVE_CITY`, `MAP_CHERRYGROVE_POKECENTER_1F`, `MAP_CHERRYGROVE_MART`, `MAP_GUIDE_GENTS_HOUSE`, `MAP_CHERRYGROVE_GYM_SPEECH_HOUSE`, `MAP_CHERRYGROVE_EVOLUTION_SPEECH_HOUSE`, `MAP_ROUTE_30`, `MAP_ROUTE_30_BERRY_HOUSE`, `MAP_MR_POKEMONS_HOUSE`, `MAP_ROUTE_31`, `MAP_ROUTE_31_VIOLET_GATE`, `MAP_NEW_BARK_TOWN`, `MAP_ELMS_LAB`, `MAP_PLAYERS_HOUSE_1F`, `MAP_DARK_CAVE_VIOLET_ENTRANCE (mouth only)`, `MAP_VIOLET_CITY (next section, one line)` + +**Gates:** + +- New Bark west exit blocked pre-starter: NewBarkTown.asm coord events (1,8)/(1,9) under SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU; cleared by ElmsLab.asm:ElmDirectionsScript setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP +- Elm's Lab exit blocked pre-starter: LabTryToLeaveScript at coord events (4,6)/(5,6) under SCENE_ELMSLAB_CANT_LEAVE +- Route 30 west fork blocked by the three EVENT_ROUTE_30_BATTLE objects at (5,24)/(5,25)/(5,26); ElmAfterTheftScript sets that event (hiding them) and clears EVENT_ROUTE_30_YOUNGSTER_JOEY (spawning Joey) +- Cherrygrove east exit trip-wire: coord events (33,6)/(33,7) under SCENE_CHERRYGROVECITY_MEET_RIVAL, armed by MrPokemonsHouse_OakScript; BATTLETYPE_CANLOSE so a loss still advances +- Elm's Lab cop scene: coord events (4,5)/(5,5) under SCENE_ELMSLAB_MEET_OFFICER -> CopScript (special NameRival) +- POKE BALLs not sold until EVENT_GAVE_MYSTERY_EGG_TO_ELM: CherrygroveMartClerkScript picks MART_CHERRYGROVE vs MART_CHERRYGROVE_DEX +- Route 29 catch tutorial needs SCENE_ROUTE29_CATCH_TUTORIAL, set only by ElmAfterTheftScript +- Dark Cave unusable: engine/events/overworld.asm FlashFunction/.CheckUseFlash requires ENGINE_ZEPHYRBADGE plus DARKNESS_PALSET +- PINK BOW unobtainable here: Route29TuscanyCallback requires ENGINE_ZEPHYRBADGE and VAR_WEEKDAY == TUESDAY +- Route 31 MAPCALLBACK_NEWMAP fires SPECIALCALL_WORRIED unless EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST is set by MomScript + +**Unresolved (10):** + +- Walkthrough's Route 30 'Antidote' does not exist in maps/Route30.asm - the only ground item is a hidden POTION bg_event at (14,9) +- Walkthrough's Route 31 'Potion left of Dark Cave' is actually itemball ANTIDOTE at (29,5); no POTION object exists on Route 31 +- Youngster Mikey is L2 PIDGEY + L4 RATTATA in parties.asm, not two Rattata +- Hoppip listed for Route 29 is absent from def_grass_wildmons ROUTE_29 +- Zubat and Poliwag listed for Routes 30/31 are not in those grass tables (Poliwag is water/fish, Zubat is Dark Cave) +- Weedle/Kakuna listed for Routes 30-31 are inside the ELIF DEF(_SILVER) arm; a Gold run gets Caterpie/Metapod +- Violet City claims (Onix, TM31, PRZCure Berry) unverified - maps/VioletCity.asm belongs to the next section and was not opened +- Whether the three EVENT_ROUTE_30_BATTLE objects fully seal the west lane depends on maps/Route30.blk collision, which was not decoded +- Port gap: setflag ENGINE_MAP_CARD lands in save.engineFlags while src/ui/gen2/Pokegear.lua reads save.pokegearFlags; no bridge found by grep +- Port gap: OBJECTTYPE_ITEMBALL objects are extracted (obj.itemball) but no runtime code consumes them, so Route 29/31/46 ground items appear un-takeable + +### 02. [sprout tower and violet city gym](section-02-sprout-tower-and-violet-city-gym.md) + +Maps the Sprout Tower climb (three floors of sages, the elder/rival cutscene coord event at 3F (11,9), Sage Li and HM05 Flash) and the Violet City Gym run (Bird Keepers Abe and Rod, Falkner, ZEPHYRBADGE and TM31 Mud-Slap) onto pokegold, with verbatim warp/coord/bg/object tables, map headers, script control flow, trainer parties with verified prize money, and the Sprout Tower 2F/3F wild tables including the morn/day/nite split. Includes a 28-row bot checklist keyed to asm cell coordinates and a port-coverage audit that flags Poke Ball item pickup as the one genuinely missing runtime path. + +**Milestones:** ZEPHYRBADGE (ENGINE_ZEPHYRBADGE, set by VioletGymFalknerScript in maps/VioletGym.asm); HM05 Flash (EVENT_GOT_HM05_FLASH, verbosegiveitem HM_FLASH in SageLiScript, maps/SproutTower3F.asm); TM31 Mud-Slap (EVENT_GOT_TM31_MUD_SLAP, VioletGymFalknerScript.FightDone); Sprout Tower rival cutscene cleared (EVENT_RIVAL_SPROUT_TOWER, scene id -> SCENE_SPROUTTOWER3F_NOOP) + +**Maps:** `MAP_SPROUT_TOWER_1F`, `MAP_SPROUT_TOWER_2F`, `MAP_SPROUT_TOWER_3F`, `MAP_VIOLET_CITY`, `MAP_VIOLET_POKECENTER_1F`, `MAP_VIOLET_GYM` + +**Gates:** + +- Sage Li battle gates HM05 Flash: maps/SproutTower3F.asm:SageLiScript, checkevent EVENT_GOT_HM05_FLASH / iftrue .GotFlash; loadtrainer SAGE, LI then verbosegiveitem HM_FLASH (return NOT checked, a full bag loses the HM) +- Coord event at SPROUT_TOWER_3F (11,9) armed by SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER (=0, macro-generated in maps/SproutTower3F.asm:12) fires SproutTower3FRivalScene; it is a cutscene with no battle and self-disarms via setscene SCENE_SPROUTTOWER3F_NOOP +- Falkner one-shot: maps/VioletGym.asm:VioletGymFalknerScript, checkevent EVENT_BEAT_FALKNER / iftrue .FightDone +- Out-of-battle Flash requires BOTH ENGINE_ZEPHYRBADGE and wTimeOfDayPalset == DARKNESS_PALSET: engine/events/overworld.asm FlashFunction.CheckUseFlash (03:48f1); the .notadarkcave arm fails the move even with the badge +- ZEPHYRBADGE attack boost / obedience mask: engine/battle/core.asm:6566 +- Object visibility polarity: engine/overworld/map_objects_2.asm CheckObjectFlag masks an object when its event flag is SET; Script_disappear sets it (engine/overworld/scripting.asm:887). Applies to EVENT_RIVAL_SPROUT_TOWER and every item ball flag in this section +- Elm's aide in Violet Pokecenter stays hidden all section: EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER is set at new game (engine/events/std_scripts.asm:468) and only cleared by the SPECIALCALL_ASSISTANT phone call queued by beating Falkner (engine/phone/scripts/elm.asm:84) +- No field-move, key-item or NPC-tile blocker exists anywhere in this section; the only hard requirement is winning battles + +**Unresolved (8):** + +- Walkthrough's Sprout Tower item list says X Accuracy; the asm has only itemball X_DEFEND at SPROUT_TOWER_2F (3,1) (SproutTower2FXDefend). The prose 'X Defense' is right, the header list is wrong. +- Walkthrough claims the item right of the monk on 3F is first a Level 3 Rattata and only later an Escape Rope. Nothing supports this: the object at (14,1) is a plain OBJECTTYPE_ITEMBALL whose label SproutTower3FEscapeRope is a bare 'itemball ESCAPE_ROPE'. No wildbattle, no second object, no branch on EVENT_GOT_HM05_FLASH. Reads like a Gen 1 Rocket Hideout Voltorb import. +- Walkthrough lists Bellsprout as a Sprout Tower wild encounter. data/wild/johto_grass.asm has no SPROUT_TOWER_1F entry at all, and 2F/3F are Rattata (morn/day) and Gastly+Rattata (nite). Bellsprout only appears as sage party members. +- Sage Li's party order differs: walkthrough says Bellsprout 7 / Hoothoot 10 / Bellsprout 7; parties.asm SageGroup 'LI' is Bellsprout 7 / Bellsprout 7 / Hoothoot 10. The quoted 320G only works with the asm order (last party level x base 8 x 4). +- Gym trainer walk order (Abe then Rod) is the reverse of the object_const order in maps/VioletGym.asm (VIOLETGYM_YOUNGSTER1 = Rod, YOUNGSTER2 = Abe). Do not index by object const when following the prose. +- Walkthrough's 'you need the 1st badge to use Flash' is incomplete; the darkness-palset half of FlashFunction.CheckUseFlash is unmentioned. +- Per-Pokemon EXP figures quoted by the walkthrough (54, 108, 126, 217, ...) are computed at runtime from base stats, not stored as constants, so they were not pinned to a single asm line. All seven money figures WERE verified as base reward x last party level x 4. +- PORT GAP (not a walkthrough issue, but load-bearing): OBJECTTYPE_ITEMBALL pickup is unimplemented. src/import/RomExtractorGen2.lua:2969 writes obj.itemball and nothing in src/ reads it; World:interact() (src/world/gen2/World.lua:5257) has no itemball branch and CallAsm.lua:550 stubs TryReceiveItem out deliberately. Every Poke Ball in this section (Parlyz Heal, X Defend, Potion, Escape Rope, PP Up, Rare Candy) renders and masks correctly but cannot be picked up. + +### 03. [ruins of alph and union cave](section-03-ruins-of-alph-and-union-cave.md) + +Maps the walkthrough's Violet City egg pickup, Ruins of Alph (Kabuto puzzle, Inner Chamber Unown, Research Center #DEX upgrade), the full Route 32 trainer gauntlet, Union Cave 1F/B1F and Route 33 onto pokegold's map asm, with verbatim warp/coord/bg/object tables, map headers, trainer parties, wild tables and the exact flag checks each script performs. Includes a 39-step bot checklist, a gate table citing the enforcing code, and an honest port-coverage table for this repo's Gen 2 work. + +**Milestones:** No badge is earned in this section; Togepi EGG obtained (EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE); First Unown puzzle solved (EVENT_SOLVED_KABUTO_PUZZLE / ENGINE_UNLOCKED_UNOWNS_A_TO_K); UNOWN #DEX mode unlocked (ENGINE_UNOWN_DEX); MIRACLE_SEED, TM05 ROAR, OLD_ROD, TM39 SWIFT, POISON_BARB (Friday) + +**Maps:** `MAP_VIOLET_POKECENTER_1F`, `MAP_ROUTE_36_RUINS_OF_ALPH_GATE`, `MAP_RUINS_OF_ALPH_OUTSIDE`, `MAP_RUINS_OF_ALPH_KABUTO_CHAMBER`, `MAP_RUINS_OF_ALPH_AERODACTYL_CHAMBER`, `MAP_RUINS_OF_ALPH_INNER_CHAMBER`, `MAP_RUINS_OF_ALPH_RESEARCH_CENTER`, `MAP_ROUTE_32_RUINS_OF_ALPH_GATE`, `MAP_ROUTE_32`, `MAP_ROUTE_32_POKECENTER_1F`, `MAP_UNION_CAVE_1F`, `MAP_UNION_CAVE_B1F`, `MAP_UNION_CAVE_B2F`, `MAP_ROUTE_33` + +**Gates:** + +- Route 32 coord_event 18,8 SCENE_ROUTE32_COOLTRAINER_M_BLOCKS pushes the player back to Violet until VioletPokecenter1F's setmapscene ROUTE_32, SCENE_ROUTE32_OFFER_SLOWPOKETAIL fires on taking the Togepi EGG +- Elm's Aide only exists after maps/VioletGym.asm:38 specialphonecall SPECIALCALL_ASSISTANT lands and engine/phone/scripts/elm.asm:84 clears EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER +- MIRACLE_SEED needs both ENGINE_ZEPHYRBADGE and EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE (Route32CooltrainerMScript) +- Kabuto chamber floor holes closed by RuinsOfAlphKabutoChamberHiddenDoorsCallback until EVENT_SOLVED_KABUTO_PUZZLE +- Unown never spawn until any ENGINE_UNLOCKED_UNOWNS_* bit is set (engine/overworld/wildmons.asm:341-347); letters filtered by CheckUnownLetter (engine/battle/core.asm:6219) +- Unown #DEX scientist requires VAR_UNOWNCOUNT > 2 plus EVENT_MADE_UNOWN_APPEAR_IN_RUINS and ENGINE_UNOWN_DEX clear (RuinsOfAlphOutsideScientistCallback) +- Aerodactyl chamber needs SURF + ENGINE_FOGBADGE (engine/events/overworld.asm:490) +- Union Cave B1F boulder at 7,10 needs STRENGTH + ENGINE_PLAINBADGE (engine/events/overworld.asm:1033 TryStrengthOW) +- Union Cave Lapras and Route 32 Frieda both require VAR_WEEKDAY == FRIDAY; Lapras additionally once-ever via ENGINE_UNION_CAVE_LAPRAS +- FLASH is NOT required: all three Union Cave floors are PALETTE_NITE, not PALETTE_DARK + +**Unresolved (10):** + +- Walkthrough lists a Repel on Route 32; maps/Route32.asm has no REPEL anywhere. Likely conflated with data/items/mom_phone.asm MomItems_2 'momitem 4000, 270, MOM_ITEM, REPEL' +- Walkthrough lists Ekans on Routes 32 and 33; EKANS only appears in the ELIF DEF(_SILVER) branches of data/wild/johto_grass.asm. Gold has RATTATA in that slot +- Walkthrough claims Unown has 28 forms including ? and !; constants/pokemon_constants.asm:308 is NUM_UNOWN EQU 26 and forms stop at UNOWN_Z +- Walkthrough says 'talk to the two people inside' the Kabuto chamber; only one object_event exists (RUINSOFALPHKABUTOCHAMBER_RECEPTIONIST at 5,5) +- RuinsOfAlphOutside declares five object consts but only two object_event rows; FISHER/YOUNGSTER2/YOUNGSTER3 are dead +- Walkthrough's Union Cave species list omits SANDSHREW and ONIX, which are in the Gold table; and Route 32 Wooper is morn/nite only, absent from the day table +- Walkthrough's 'X Attack below Bill' and 'Potion to the far left' do not match the coordinates (Bill 15,15; X Attack 4,17; Potion 4,2) +- Could not locate a SPECIALCALL_* scheduler in this repo's port; without it EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER never clears and Route 32 stays permanently blocked +- Did not confirm src/battle/gen2/Encounter.lua reproduces the wUnlockedUnowns == 0 -> no encounter short-circuit from engine/overworld/wildmons.asm +- Route32Pokecenter1FFishingGuruScript sets EVENT_GOT_OLD_ROD without an iffalse bag-full guard after verbosegiveitem (asm behaviour, not a port bug) + +### 04. [slowpoke well azalea town gym](section-04-slowpoke-well-azalea-town-gym.md) + +Maps the Azalea Town / Slowpoke Well / Azalea Gym stretch onto pokegold: every warp, coord, bg and object row transcribed verbatim from maps/AzaleaTown.asm, maps/KurtsHouse.asm, maps/AzaleaPokecenter1F.asm, maps/SlowpokeWellB1F.asm, maps/SlowpokeWellB2F.asm and maps/AzaleaGym.asm, plus headers, dimensions, connections, all four Rocket grunts and six gym trainers resolved to data/trainers/parties.asm, the wild tables, and the exact EVENT_/ENGINE_ flag chain that Kurt1 and TrainerGruntM1.Script write. Adds derived collision grids decoded from the .blk files so a bot has real walkable routes and proof of the two NPC chokepoints and the Surf-only B2F. + +**Milestones:** HIVEBADGE (ENGINE_HIVEBADGE, set by AzaleaGymBugsyScript in maps/AzaleaGym.asm); EVENT_CLEARED_SLOWPOKE_WELL (set by TrainerGruntM1.Script in maps/SlowpokeWellB1F.asm); EVENT_BEAT_BUGSY; EVENT_GOT_TM49_FURY_CUTTER (TM49 Fury Cutter); EVENT_KURT_GAVE_YOU_LURE_BALL (free Lure Ball); ENGINE_FLYPOINT_AZALEA (AzaleaTownFlypointCallback) + +**Maps:** `MAP_AZALEA_TOWN`, `MAP_KURTS_HOUSE`, `MAP_AZALEA_POKECENTER_1F`, `MAP_SLOWPOKE_WELL_B1F`, `MAP_SLOWPOKE_WELL_B2F`, `MAP_AZALEA_GYM` + +**Gates:** + +- Rocket object const 2 at AzaleaTown 31,9 (EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET) is the only walkable cell reaching the Slowpoke Well ladder at 31,7; cleared by talking to Kurt once (Kurt1 first-visit arm setevent) +- Rocket object const 12 at AzaleaTown 10,16 (EVENT_SLOWPOKE_WELL_ROCKETS) is the only walkable cell adjacent to the Azalea Gym warp at 10,15; cleared by the four disappear calls in TrainerGruntM1.Script +- Field CUT requires ENGINE_HIVEBADGE - engine/events/overworld.asm CutFunction.CheckAble (03:47e1) and TryCutOW (03:5193) - so the west exit to Ilex Forest is badge-gated (HM01 itself is section 05) +- L30 obedience gated on the HIVEBADGE bit, engine/battle/core.asm:6566 +- SLOWPOKE_WELL_B2F warp at B1F 7,11 sits in a chamber whose only outside neighbour is water at 7,14: Surf-only (King's Rock, TM Rain Dance) +- Strength boulder at SlowpokeWellB1F 3,2 (jumpstd StrengthBoulderScript) - out of section, nothing required behind it + +**Unresolved (6):** + +- Walkthrough gives Bug Catcher Al 200G; asm formula (base 4 x L12 x 4, engine/battle/read_trainer_party.asm ComputeTrainerReward + the ld c,4 loop and two .DoubleReward calls at engine/battle/core.asm:2340-2361) yields 192G. All eight other money figures match exactly. +- Walkthrough calls Bugsy 'her'/'she'; AzaleaGymGuideText in maps/AzaleaGym.asm says 'his knowledge of bug #MON'. +- Walkthrough's Liz-the-Picnicker Moo Moo Milk phone call could not be located: LizPhoneCallerScript (engine/phone/scripts/trainers.asm:234-254) is generic greet/rematch/random only, and no MOOMOO string exists under engine/phone/. +- Walkthrough lists TM49 under 'Items found in Azalea Town'; it is verbosegiveitem TM_FURY_CUTTER inside AzaleaGymBugsyScript, i.e. in MAP_AZALEA_GYM. +- Walkthrough's 'up the stairs' / 'down the stairs' inside Slowpoke Well has no LADDER/STAIRCASE collision to match - the only two ladder cells on B1F are the two warps (17,15 and 7,11). The trainer order it describes does match the geometry. +- Surf-only B2F access and the claim that Josh/Al/the twins can be walked past in Azalea Gym are both derived from the .blk collision decode plus object sight fields, not asserted by any asm check (the retroactive setevent block in AzaleaGymBugsyScript.FightDone is the only direct evidence that skipping the gym trainers is an anticipated state). + +### 05. [ilex forest and goldenrod city gym](section-05-ilex-forest-and-goldenrod-city-gym.md) + +Maps the walkthrough's Azalea-rival-through-Whitney stretch onto pokegold: the Azalea Town rival trip-wire, the full ten-position Ilex Forest Farfetch'd herding branch table and the single cuttable block, Route 34 and the Day-Care, then the Goldenrod hub (underground, bike shop, radio tower quiz, dept store, game corner) and the Goldenrod Gym. Every warp, coord, bg and object row is transcribed verbatim from the map asm, with trainer parties, wild tables, badge/field-move gates and a 47-step bot checklist. + +**Milestones:** PLAINBADGE (ENGINE_PLAINBADGE) from Whitney in GOLDENROD_GYM; HM01 Cut (EVENT_GOT_HM01_CUT) from the Ilex Forest charcoal master; TM02 Headbutt (EVENT_GOT_TM02_HEADBUTT); TM12 Sweet Scent (EVENT_GOT_TM12_SWEET_SCENT); TM45 Attract (EVENT_GOT_TM45_ATTRACT); BICYCLE (EVENT_GOT_BICYCLE); COIN_CASE (EVENT_GOLDENROD_UNDERGROUND_COIN_CASE); Radio Card (ENGINE_RADIO_CARD); Rival battle 2 cleared (EVENT_RIVAL_AZALEA_TOWN); ENGINE_FLYPOINT_GOLDENROD / ENGINE_REACHED_GOLDENROD + +**Maps:** `MAP_AZALEA_TOWN`, `MAP_ILEX_FOREST_AZALEA_GATE`, `MAP_ILEX_FOREST`, `MAP_ROUTE_34_ILEX_FOREST_GATE`, `MAP_ROUTE_34`, `MAP_DAY_CARE`, `MAP_GOLDENROD_CITY`, `MAP_GOLDENROD_POKECENTER_1F`, `MAP_GOLDENROD_DEPT_STORE_1F`, `MAP_GOLDENROD_DEPT_STORE_2F`, `MAP_GOLDENROD_DEPT_STORE_3F`, `MAP_GOLDENROD_DEPT_STORE_4F`, `MAP_GOLDENROD_DEPT_STORE_5F`, `MAP_GOLDENROD_DEPT_STORE_6F`, `MAP_BILLS_FAMILYS_HOUSE`, `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`, `MAP_GOLDENROD_UNDERGROUND`, `MAP_GOLDENROD_BIKE_SHOP`, `MAP_GOLDENROD_MAGNET_TRAIN_STATION`, `MAP_RADIO_TOWER_1F`, `MAP_GOLDENROD_GAME_CORNER`, `MAP_GOLDENROD_FLOWER_SHOP`, `MAP_GOLDENROD_GYM` + +**Gates:** + +- Azalea rival coord events at (5,10)/(5,11) require SCENE_AZALEATOWN_RIVAL_BATTLE, armed by maps/SlowpokeWellB1F.asm:58 after clearing Slowpoke Well +- Ilex Forest Farfetch'd quest and apprentice only exist after EVENT_CLEARED_SLOWPOKE_WELL (SlowpokeWellB1F clears EVENT_ILEX_FOREST_FARFETCHD_1 / EVENT_ILEX_FOREST_APPRENTICE) +- Charcoal master (HM01 giver) only spawns after FarfetchdPosition9 fall-through runs `appear 13` + setevent EVENT_CHARCOAL_KILN_BOSS / EVENT_HERDED_FARFETCHD +- Ilex Forest cut tree: block $0f at block (4,12) = map (8-9,24-25); needs a party mon with CUT and ENGINE_HIVEBADGE (engine/events/overworld.asm CutFunction.CheckAble / TryCutOW) +- Whitney withholds PLAINBADGE while EVENT_MADE_WHITNEY_CRY is set; must walk the coord event at GOLDENROD_GYM (8,5) to run WhitneyCriesScript, then talk again +- Strength field move needs ENGINE_PLAINBADGE (engine/events/overworld.asm StrengthFunction.TryStrength) +- Squirtbottle at GOLDENROD_FLOWER_SHOP needs ENGINE_PLAINBADGE (FlowerShopTeacherScript) +- Route 34 beach trainers Irene/Jenn/Kate and SOFT_SAND need SURF + ENGINE_FOGBADGE +- Goldenrod Underground basement door blocked by changeblock 18,6,$3d until BASEMENT_KEY (BasementDoorScript) +- Bill / Eevee gated on EVENT_MET_BILL (set in Ecruteak); only the phone number is reachable now +- Game Corner prizes need COIN_CASE from the underground item ball at (7,25) +- Officer Keith on Route 34 only battles under checktime NITE +- Underground salon / bargain / bitter merchants gated on VAR_WEEKDAY (and MORN for the bargain merchant) + +**Unresolved (11):** + +- Bug Catcher Wayne (L8 Ledyba / L10 Paras) is not in pokegold at all - maps/IlexForest.asm has zero OBJECTTYPE_TRAINER rows; only BIKER DWAYNE on Route 8 matches the grep +- X Attack and Antidote claimed in Ilex Forest do not exist; the map holds only a REVIVE item ball at (20,32) and hidden ETHER (27,1), SUPER_POTION (17,7), FULL_HEAL (9,17) +- Walkthrough lists Weedle in Ilex Forest, but Weedle/Kakuna are the ELIF DEF(_SILVER) arm; Gold gets Caterpie/Metapod +- "Youngster Ian will want to trade phone numbers" contradicts TrainerYoungsterIan.Script, which has no askforphonenumber; only Todd and Gina register numbers on Route 34 +- Rival party order printed as Gastly/starter/Zubat; Rival1Group entries 4-6 are Gastly L12, Zubat L14, starter L16 +- "There's a PC in the corner of the Daycare" - maps/DayCare.asm has no PC bg event or object +- Dept Store 5F "TM02, TM33, TM41, TM48" is conditional: MART_GOLDENROD_5F_1 default is only TM41/TM48/TM33; TM02 needs EVENT_GOT_TM02_HEADBUTT and TM08 needs EVENT_GOT_TM08_ROCK_SMASH +- Whether the Azalea Town rival coord events at (5,10)/(5,11) can be walked around was not verified - requires decoding AzaleaTown.blk collision +- Concrete approach tiles for each Farfetch'd position (turning "do not face

" into a walkable route) were not derived - needs IlexForest.blk collision +- data/wild/fish.asm has no ILEX_FOREST or ROUTE_34 rows; fishing comes from the header FISHGROUP_* and those group tables were not transcribed +- Money/EXP payouts quoted by the walkthrough are runtime-computed and were not verified against any table + +### 06. [bug catching contest and sudowoodo](section-06-bug-catching-contest-and-sudowoodo.md) + +Maps the Goldenrod-to-Violet stretch (Squirtbottle, Route 35, National Park and the Bug-Catching Contest, Routes 36/37 and Sudowoodo) onto pokegold, with verbatim warp/coord/bg/object tables, map headers, wild tables, the full contest rule set from engine/events/bug_contest/, and every trainer party resolved to its data/trainers/parties.asm line. Records the four real progress gates (PLAINBADGE for the Squirtbottle, SQUIRTBOTTLE for Sudowoodo, EVENT_FOUGHT_SUDOWOODO for TM08, weekday for the Contest) plus a Route 35 CUT tree located by parsing Route35.blk against data/collision/field_move_blocks.asm. + +**Milestones:** No badge earned in this section; SQUIRTBOTTLE key item (EVENT_GOT_SQUIRTBOTTLE); EVENT_FOUGHT_SUDOWOODO - opens Route 36 west<->east; TM08 ROCK SMASH (EVENT_GOT_TM08_ROCK_SMASH); Bug-Catching Contest run: SUN_STONE / EVERSTONE / GOLD_BERRY / BERRY, ENGINE_DAILY_BUG_CONTEST; TM04 ROLLOUT, TM28 DIG, QUICK CLAW, HARD STONE (Thursday), KENYA the SPEAROW + FLOWER MAIL + +**Maps:** `MAP_GOLDENROD_FLOWER_SHOP`, `MAP_ROUTE_35_GOLDENROD_GATE`, `MAP_ROUTE_35`, `MAP_ROUTE_35_NATIONAL_PARK_GATE`, `MAP_NATIONAL_PARK`, `MAP_NATIONAL_PARK_BUG_CONTEST`, `MAP_ROUTE_36_NATIONAL_PARK_GATE`, `MAP_ROUTE_36`, `MAP_ROUTE_37` + +**Gates:** + +- ENGINE_PLAINBADGE gates the SQUIRTBOTTLE - maps/GoldenrodFlowerShop.asm:FlowerShopTeacherScript (57:52d1) +- SQUIRTBOTTLE in the PACK gates Sudowoodo - maps/Route36.asm:SudowoodoScript (4b:61aa) checkitem SQUIRTBOTTLE; the object at cell (35,9) is a solid NPC until disappeared +- engine/events/squirtbottle.asm:_Squirtbottle (14:4763) .CheckCanUseSquirtbottle requires map ROUTE_36 and a faced object with SPRITEMOVEDATA_SUDOWOODO +- EVENT_FOUGHT_SUDOWOODO gates TM08 - maps/Route36.asm:Route36RockSmashGuyScript (4b:61f7) +- ENGINE_HIVEBADGE + CUT gates the east half of Route 35 (block $5b at block(8,3) = cells x16-17 y6-7 in maps/Route35.blk; engine/events/overworld.asm:133 CutFunction.CheckAble) +- VAR_WEEKDAY must be TUESDAY/THURSDAY/SATURDAY and ENGINE_DAILY_BUG_CONTEST clear for the Contest - Route35OfficerScriptContest (56:5e0c) / Route36OfficerScriptContest (56:67cc) +- Contest entry also needs a non-EGG, non-fainted lead and party-or-box room (VAR_PARTYCOUNT / VAR_BOXSPACE / CheckFirstMonIsEgg / ContestDropOffMons) +- VAR_PARTYCOUNT < PARTY_LENGTH gates Kenya - maps/Route35GoldenrodGate.asm:RandyScript (56:59ce) +- checktime NITE gates Officer Dirk - maps/Route35.asm:TrainerOfficerDirk +- VAR_WEEKDAY THURSDAY gates Arthur (Route36ArthurCallback 4b:619e); SUNDAY gates Sunny (Route37SunnyCallback) + +**Unresolved (11):** + +- Floria running off to fetch the Squirtbottle after being talked to on Route 36 - no such script exists in pokegold; FlowerShopFloriaScript and Route36LassScript are both text-only and the Squirtbottle is gated on ENGINE_PLAINBADGE alone. Looks like Crystal behaviour +- Contest species/levels: walkthrough says Nincada/Volbeat/Wurmple at lv28-31; data/wild/bug_contest_mons.asm has CATERPIE/WEEDLE/METAPOD/KAKUNA/BUTTERFREE/BEEDRILL/VENONAT/PARAS/SCYTHER/PINSIR at lv7-18 (VENOMOTH 30-40 sits on the unreachable -1 terminator row) +- "20 Sport Balls" - the item is PARK_BALL (constants/item_constants.asm:185), BUG_CONTEST_BALLS = 20 +- "you can win a Shiny Stone" - first place gives SUN_STONE (std_scripts.asm:346); no Shiny Stone exists in Gen 2 +- "a Scyther at full HP may be worth around 342 points" - the formula in ContestScore (04:7cbc) is confirmed but the specific number was not computed +- National Park species list includes Butterfree/Weedle/Kakuna/Beedrill/Paras/Venonat/Scyther/Pinsir; none are in def_grass_wildmons NATIONAL_PARK - they are Contest-only +- The "Routes 36 & 37" item/species lists merge two maps: the apricorns and Ledyba/Spinarak/Pidgeotto are all Route 37, which this section never enters +- "talk to Arnie for HER phone number" - ARNIE1 uses the male phone std scripts +- Liz's Route 32 rematch offer is a phone event on no map in this section; not resolved +- EVENT_FOUGHT_SUDOWOODO is set BEFORE the DRAW check and both branches disappear the tree, so fleeing or fainting it still clears the road and unlocks TM08 - contradicts "catch it or else". A mid-battle black-out was not checked +- maps/NationalParkBugContest.asm warps 2 and 4 both target destination warp 1 (lines 228, 230) rather than 2 - transcribed verbatim, not a typo on my part + +### 07. [burned tower and ecruteak city gym](section-07-burned-tower-and-ecruteak-city-gym.md) + +Maps the walkthrough's Route 37 -> Ecruteak City -> Goldenrod (Bill's Eevee) -> Dance Theater -> Burned Tower -> Ecruteak Gym stretch onto the pokegold disassembly, with verbatim warp/coord/bg/object tables, map headers and dimensions, script control-flow summaries with their EVENT_*/ENGINE_* flags, resolved trainer parties, and the Route 37 and Burned Tower wild tables. It adds derived collision maps for Burned Tower 1F and Ecruteak Gym that pin the two Rock Smash chokepoints and the exact invisible-floor zig-zag, plus a 31-step bot checklist and an honest port-coverage table. + +**Milestones:** FOGBADGE (ENGINE_FOGBADGE, set by EcruteakGymMortyScript after MORTY/MORTY1); HM03 SURF (EVENT_GOT_HM03_SURF, DanceTheaterSurfGuy after all five Kimono Girls); ITEMFINDER (EVENT_GOT_ITEMFINDER, EcruteakItemfinderGuy); TM30 SHADOW BALL (EVENT_GOT_TM30_SHADOW_BALL, EcruteakGymMortyScript.FightDone); EEVEE L20 (EVENT_GOT_EEVEE, BillScript givepoke); TIME CAPSULE (ENGINE_TIME_CAPSULE, Ecruteak Pokecenter Bill cutscene); ENGINE_FLYPOINT_ECRUTEAK (EcruteakCityFlypointCallback); EVENT_RELEASED_THE_BEASTS plus special InitRoamMons (BurnedTowerB1F ReleaseTheBeasts) + +**Maps:** `ROUTE_37`, `ECRUTEAK_CITY`, `ECRUTEAK_POKECENTER_1F`, `DANCE_THEATER`, `ECRUTEAK_ITEMFINDER_HOUSE`, `BILLS_FAMILYS_HOUSE`, `BURNED_TOWER_1F`, `BURNED_TOWER_B1F`, `ECRUTEAK_GYM`, `ECRUTEAK_TIN_TOWER_ENTRANCE` + +**Gates:** + +- EVENT_MET_BILL is set at new game and masks Bill in BILLS_FAMILYS_HOUSE; only the Ecruteak Pokecenter scene-0 cutscene (EcruteakPokcenter1FBillActivatesTimeCapsuleScript) clears it, hard-locking Eevee behind visiting Ecruteak first +- BillScript readvar VAR_PARTYCOUNT / ifequal PARTY_LENGTH -> .NoRoom: a free party slot is required for the Eevee givepoke +- DanceTheaterSurfGuy checks all five EVENT_BEAT_KIMONO_GIRL_{NAOKO,SAYO,ZUKI,KUNI,MIKI} before .GetSurf gives HM_SURF +- Field SURF needs ENGINE_FOGBADGE (engine/events/overworld.asm:340 SurfFunction.TrySurf CheckBadge; :490 TrySurfOW CheckEngineFlag) +- ROCK_SMASH (party move, no badge - AskRockSmashScript / callasm HasRockSmash) is required to smash BurnedTower1F rock at (4,3), the only route to the (10,7) centre pit and therefore to the B1F (9,5) ReleaseTheBeasts coord event +- Second BurnedTower1F rock at (16,13) gates the east loop: Firebreather Ned (16,8) and the BURN_HEAL ball (15,2) +- BURNED_TOWER_B1F has exactly one exit: the LADDER warp at (7,15); warps 1-5 sit on non-warp collisions and cannot be re-entered upward +- Ecruteak Gym invisible floor: 30 warp rows on COLL_PIT tiles all dumping to warp 3 at (4,14); only the exact zig-zag reaches Morty +- EVENT_RIVAL_BURNED_TOWER masks the Burned Tower rival and is set by maps/GoldenrodUndergroundSwitchRoomEntrances.asm:125, which also setmapscenes BURNED_TOWER_1F past the battle - Burned Tower must be done first +- Tin Tower stays sage-blocked until EcruteakGymMortyScript does setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP +- B1F Strength boulder at (17,4) needs STRENGTH plus PLAINBADGE (AskStrengthScript / TryStrengthOW); nothing required here is behind it +- Bike-shop 'keep the bicycle' call needs STATUSFLAGS2_BIKE_SHOP_CALL_F, PLAYER_BIKE, phone service, and wBikeStep >= HIGH(1024) (engine/overworld/events.asm:1267 DoBikeStep) + +**Unresolved (12):** + +- Walkthrough says the rival fight is after 'the spiral'; the asm runs BurnedTower1FRivalBattleScene as scene 0 via sdefer on map load, with the rival at (9,12) three cells from the (9,15) door +- Walkthrough says Rock Smash yields 'the HP Up at the northeastern part of the floor'; the NE 1F ball is BURN_HEAL at (15,2), the HP_UP is B1F (4,3) via the NW pits +- Rock Smash being mandatory to reach the beasts is a flood-fill result derived from BurnedTower1F.blk plus tower_collision.asm, not a line of asm; verify on emulator +- EcruteakGym (6,7) has a warp_event row but FLOOR collision (dead row); (6,4) is a PIT with no warp row (safe to stand on). Both derived from the .blk and look like one transposed source row +- Morty's second mon is L21 HAUNTER in MortyGroup, not the walkthrough's L23, so its 567 EXP figure is suspect +- DanceTheaterSurfGuy.GetSurf has no iffalse after verbosegiveitem HM_SURF, unlike every other give in the section - a failed give would still set EVENT_GOT_HM03_SURF +- Both Route 37 twins share EVENT_BEAT_TWINS_ANN_AND_ANNE; EVENT_BEAT_TWINS_ANN_AND_ANNE2 exists but is unreferenced, so only one battle ever happens +- Route 37 twins are declared SPRITE_WEIRD_TREE (consts ROUTE37_WEIRD_TREE1/2), which the new-game init variablesprites to SPRITE_SUDOWOODO; what actually renders was not verified +- BurnedTower1F item ball one-time flags are crossed: the BURN_HEAL ball carries EVENT_BURNED_TOWER_1F_X_SPEED and vice versa +- Ledge (HOP_*) traversal, which Burned Tower B1F is built on, could not be located in the port (no HOP_ matches under src/world/gen2/) +- EcruteakGymActivateRockets branches on VAR_BADGES == 6/7; unexercised on a linear run but a badge-skipping bot will trip it +- Walkthrough EXP and prize-money figures were not checked against engine/battle/ and are deliberately not reproduced + +### 08. [olivine lighthouse and cianwood city gym](section-08-olivine-lighthouse-and-cianwood-city-gym.md) + +Maps the Route 38 -> Route 39 -> Olivine City -> Olivine Lighthouse -> Route 40 -> Route 41 -> Cianwood City -> Cianwood Gym stretch onto pokegold, with verbatim warp/coord/bg/object tables for all 25 maps, full trainer party data, wild/fish/headbutt/rock-smash tables, and the flag chains behind Moomoo Farm, the Good Rod, HM04 Strength, the Jasmine -> pharmacist Secretpotion gate, Shuckie, the Storm Badge and HM02 Fly. Includes a 71-step bot checklist keyed to raw cell coordinates and an honest port-coverage table for this repo's Gen 2 engine. + +**Milestones:** Storm Badge (ENGINE_STORMBADGE) from CianwoodGymChuckScript, maps/CianwoodGym.asm + +**Maps:** `MAP_ROUTE_38_ECRUTEAK_GATE`, `MAP_ROUTE_38`, `MAP_ROUTE_39`, `MAP_ROUTE_39_BARN`, `MAP_ROUTE_39_FARMHOUSE`, `MAP_OLIVINE_CITY`, `MAP_OLIVINE_POKECENTER_1F`, `MAP_OLIVINE_GOOD_ROD_HOUSE`, `MAP_OLIVINE_CAFE`, `MAP_OLIVINE_MART`, `MAP_OLIVINE_LIGHTHOUSE_1F`, `MAP_OLIVINE_LIGHTHOUSE_2F`, `MAP_OLIVINE_LIGHTHOUSE_3F`, `MAP_OLIVINE_LIGHTHOUSE_4F`, `MAP_OLIVINE_LIGHTHOUSE_5F`, `MAP_OLIVINE_LIGHTHOUSE_6F`, `MAP_ROUTE_40`, `MAP_ROUTE_41`, `MAP_CIANWOOD_CITY`, `MAP_MANIAS_HOUSE`, `MAP_CIANWOOD_PHARMACY`, `MAP_CIANWOOD_PHOTO_STUDIO`, `MAP_CIANWOOD_POKECENTER_1F`, `MAP_CIANWOOD_LUGIA_SPEECH_HOUSE`, `MAP_CIANWOOD_GYM` + +**Gates:** + +- Surf to leave Olivine westward: engine/events/overworld.asm:490 TrySurfOW / :340 SurfFunction.TrySurf require ENGINE_FOGBADGE plus a party mon knowing SURF +- Cianwood Gym boulders (3,7)/(4,7)/(5,7): CianwoodGymBoulder -> jumpstd StrengthBoulderScript -> engine/events/overworld.asm:1038 TryStrengthOW requires ENGINE_PLAINBADGE plus STRENGTH; HM04 comes from OlivineCafeStrengthSailorScript in this section +- Secretpotion: maps/CianwoodPharmacy.asm CianwoodPharmacist checks EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS, which is only set by talking to Jasmine on OLIVINE_LIGHTHOUSE_6F +- HM02 Fly: maps/CianwoodCity.asm CianwoodCityChucksWife checks EVENT_BEAT_CHUCK +- Using Fly in the field: engine/events/overworld.asm:545 FlyFunction.TryFly requires ENGINE_STORMBADGE +- Moomoo berries: maps/Route39Barn.asm MoomooScript refuses berries until EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO; TM13 needs EVENT_HEALED_MOOMOO (7 berries) +- Shuckie: maps/ManiasHouse.asm special GiveShuckle fails on a full party +- Olivine rival cut-scene: coord_event (13,12)/(13,13) armed by SCENE_OLIVINECITY_RIVAL_ENCOUNTER = 0, disarmed by setscene SCENE_OLIVINECITY_NOOP +- Ordering trap: CianwoodGymChuckScript.FightDone sets EVENT_BEAT_BLACKBELT_YOSHI/LAO/NOB/LUNG when you take TM01, so the four blackbelts must be fought before Chuck +- Route 41 whirlpools need ENGINE_GLACIERBADGE (engine/events/overworld.asm:1077) but are NOT required to reach Cianwood + +**Unresolved (12):** + +- Beauty Olivia on Route 38 does not exist in pokegold: no OLIVIA in constants/trainer_constants.asm, no OLIVIA party, and maps/Route38.asm has only five trainer objects +- Voltorb listed as found in Olivine City: the map has no grass table and its water table (data/wild/johto_water.asm:239) is Tentacool/Tentacool/Tentacruel +- Shuckle listed for Route 41: Route 41 is absent from RockMonMaps, has TREEMON_SET_NONE, and its Route41Rock script is marked unreferenced; only Route 40 and Cianwood City carry TREEMON_SET_ROCK (10% Shuckle 15) +- Swimmer Charlie's Tentacool is level 19 in data/trainers/parties.asm, not 21 as the walkthrough says +- Bird Keeper Denis party order is 18 SPEAROW / 20 FEAROW / 18 SPEAROW, not Spearow/Spearow/Fearow +- Swimmer Kaylee (SWIMMERF 3) at ROUTE_41 (17,4) is never mentioned by the walkthrough +- Whether the lighthouse x=16/17 warp pairs are one-way drops or two-way passages cannot be settled from the warp tables (they are symmetric); it depends on TILESET_LIGHTHOUSE block collision and the .blk files, which were not decoded +- Whether Bird Keeper Theo on 3F can actually be avoided depends on the 3F .blk geometry, not the object table +- Route 41's left/right branch split is blk geometry only; no asm data pins the walkthrough's routing prose +- Phone-call beats (Arnie, Liz, Todd, Mom's doll) come from CheckPhoneCall plus data/phone/*.asm and are random; nothing in these maps schedules them +- The 3000G prize from Chuck is computed from data/trainers/attributes.asm, not written in maps/CianwoodGym.asm; arithmetic not verified +- TM23 listed under Olivine City items is Jasmine's gym reward in maps/OlivineGym.asm (section 09), not obtainable on MAP_OLIVINE_CITY + +### 09. [olivine city gym](section-09-olivine-city-gym.md) + +Maps the Olivine Gym stretch of the Gold walkthrough onto pokegold: the optional Goldenrod Underground haircut detour, the second Olivine Lighthouse climb that hands Jasmine the SECRETPOTION, and the Jasmine gym battle that yields MINERALBADGE and TM23 Iron Tail. Every cited map's warp/coord/bg/object tables are transcribed verbatim, the lighthouse ladder-vs-pit navigation graph is decoded from the .blk files against the tileset collision, and the 3500G payout and 571/1470 EXP claims are derived from the actual reward and base-exp data. + +**Milestones:** MINERALBADGE (ENGINE_MINERALBADGE, engine flag id 30); EVENT_BEAT_JASMINE (0x4c1); TM_IRON_TAIL / TM23 (EVENT_GOT_TM23_IRON_TAIL, 0x0d); EVENT_JASMINE_RETURNED_TO_GYM (0x20) - Amphy cured on Lighthouse 6F; 6th-badge trigger: GoldenrodRocketsScript clears EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER + +**Maps:** `MAP_GOLDENROD_CITY`, `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`, `MAP_GOLDENROD_UNDERGROUND`, `MAP_OLIVINE_CITY`, `MAP_OLIVINE_LIGHTHOUSE_1F`, `MAP_OLIVINE_LIGHTHOUSE_2F`, `MAP_OLIVINE_LIGHTHOUSE_3F`, `MAP_OLIVINE_LIGHTHOUSE_4F`, `MAP_OLIVINE_LIGHTHOUSE_5F`, `MAP_OLIVINE_LIGHTHOUSE_6F`, `MAP_OLIVINE_GYM` + +**Gates:** + +- EVENT_OLIVINE_GYM_JASMINE must be CLEAR for Jasmine to exist in the gym; set at new game by InitializeEventsScript (engine/events/std_scripts.asm:511), cleared only by OlivineLighthouseJasmine (maps/OlivineLighthouse6F.asm:69) +- SECRETPOTION key item required: checkitem SECRETPOTION at maps/OlivineLighthouse6F.asm:14, and the yesorno at line 29 must be answered Yes (iffalse .Refused aborts, setting nothing) +- ENGINE_STORMBADGE gates FLY at all (engine/events/overworld.asm:544-547, FlyFunction.TryFly -> CheckBadge), plus ENGINE_FLYPOINT_OLIVINE (id 70) / ENGINE_FLYPOINT_GOLDENROD (id 69) set by each town's MAPCALLBACK_NEWMAP +- Lighthouse 4F: the (13,3) ladder is a dead end - you must step into the pit at 4F (8,3)/(9,3) (block $28, PIT collision) and re-climb via 3F warp 3 at (9,5) +- Haircut is weekday-gated: GoldenrodUndergroundCheckDayOfWeekCallback + readvar VAR_WEEKDAY; the 500 older brother exists only TUE/THU/SAT, and ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT (id 89) allows one cut per day +- TM pocket must have room: verbosegiveitem TM_IRON_TAIL -> iffalse .NoRoomForIronTail leaves EVENT_GOT_TM23_IRON_TAIL unset (retryable by re-talking) + +**Unresolved (5):** + +- The exact walking path between ladders/pits on Lighthouse 3F/4F/5F was not flood-filled; the ladder and pit coordinates are decoded and verified, but the intermediate route in checklist steps 8-14 is geometric inference +- The walkthrough's 'second/middle shop' is ambiguous because GoldenrodUndergroundCheckDayOfWeekCallback hides different merchants each day; the unambiguous identifier is the 500 price (older brother at (7,14)), and the walkthrough never mentions the TUE/THU/SAT restriction +- The walkthrough implies the 500 cut is the better happiness buy; data/events/happiness_probabilities.asm + happiness_changes.asm show the younger brother's 300 cut has a strictly better jackpot row (+10 vs +5) +- The 571 / 1470 EXP figures reproduce from base-exp bytes via the standard base*level/7*3/2 formula, but the EXP-award routine itself was not opened, so the *3/2 trainer multiplier is arithmetic agreement rather than a code citation +- Jasmine's row in data/trainers/attributes.asm was located by its '; Jasmine' comment (the file has no per-class labels); the 3500G derivation depends on that comment matching constants/trainer_constants.asm ordering + +### 10. [the lake of rage and shiny gyarados](section-10-the-lake-of-rage-and-shiny-gyarados.md) + +Maps the Ecruteak -> Route 42 -> Mahogany Town -> Route 43 -> Lake of Rage stretch onto pokegold, with verbatim warp/coord/bg/object tables, the trainer party rows, the Gold/Silver-split wild tables, and the flag state that decides who is actually on each map during this section (Lance, the Lake of Rage trainers and the TM36 officer are all masked at this point). Ends on the Red Gyarados encounter (loadwildmon GYARADOS 30 + BATTLETYPE_FORCESHINY, unguarded giveitem RED_SCALE) and LakeOfRageLanceScript, which sets EVENT_DECIDED_TO_HELP_LANCE and arms SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS for the next section. + +**Milestones:** No badge in this section; ENGINE_FLYPOINT_MAHOGANY (MahoganyTownFlypointCallback); ENGINE_FLYPOINT_LAKE_OF_RAGE (LakeOfRageFlypointCallback); EVENT_LAKE_OF_RAGE_RED_GYARADOS (Red Gyarados caught or beaten); RED_SCALE obtained; EVENT_DECIDED_TO_HELP_LANCE (arms SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS); EVENT_GOT_TM10_HIDDEN_POWER; EVENT_GOT_BLACKBELT_FROM_WESLEY (Wednesdays only) + +**Maps:** `MAP_ECRUTEAK_CITY`, `MAP_ROUTE_42_ECRUTEAK_GATE`, `MAP_ROUTE_42`, `MAP_MAHOGANY_TOWN`, `MAP_MAHOGANY_MART_1F`, `MAP_MAHOGANY_RED_GYARADOS_SPEECH_HOUSE`, `MAP_MAHOGANY_POKECENTER_1F`, `MAP_ROUTE_43_MAHOGANY_GATE`, `MAP_ROUTE_43`, `MAP_ROUTE_43_GATE`, `MAP_LAKE_OF_RAGE`, `MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE`, `MAP_LAKE_OF_RAGE_MAGIKARP_HOUSE` + +**Gates:** + +- SURF across the two Route 42 lakes and onto the Lake of Rage water: engine/events/overworld.asm:322 SurfFunction .TrySurf checks ENGINE_FOGBADGE via CheckBadge (:50) +- CUT for the Route 42 apricorn trees and the Lake of Rage north-west/north-east item paths: engine/events/overworld.asm:117 CutFunction .CheckAble checks ENGINE_HIVEBADGE then CheckMapForSomethingToCut +- Mahogany east exit to Route 44 blocked unconditionally: maps/MahoganyTown.asm coord_event 19,8 and 19,9 on SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR plus the MAHOGANYTOWN_POKEFAN_M object at (19,8); only engine/events/std_scripts.asm:261 RadioTowerRocketsScript lifts it +- Mahogany Gym door (6,13) blocked by the MAHOGANYTOWN_FISHER object at (6,14) while EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM is clear; maps/TeamRocketBaseB2F.asm:303 sets it +- Route 43 gate toll: maps/Route43Gate.asm scene 0 sdefer Route43GateRocketTakeoverScript, re-armed by Route43CheckIfRocketsScene; takemoney YOUR_MONEY, 1000 runs in BOTH the rich and broke branches (soft gate, always passable) +- Lance not talkable until RedGyarados runs appear LAKEOFRAGE_LANCE (EVENT_LAKE_OF_RAGE_LANCE is set by InitializeEventsScript, engine/events/std_scripts.asm:516) +- Wesley / BLACKBELT_I gated on readvar VAR_WEEKDAY == WEDNESDAY in LakeOfRageWesleyCallback +- Lake of Rage trainers (Andre, Raymond, Aaron, Lois) and the Route43Gate TM36 officer masked by EVENT_LAKE_OF_RAGE_CIVILIANS, set at init and cleared only in maps/TeamRocketBaseB2F.asm:305 +- Mahogany Town Lass and Mahogany Mart Granny masked by EVENT_MAHOGANY_MART_OWNERS (set at init) +- Magikarp length contest and its ETHER reward gated behind EVENT_CLEARED_ROCKET_HIDEOUT in MagikarpLengthRaterScript +- RED_SCALE is handed over by an unguarded giveitem in RedGyarados: a full bag loses it silently + +**Unresolved (14):** + +- Walkthrough calls the Lake of Rage item ball TM43 Secret Power; in GS it is itemball TM_DETECT and the add_tm ladder puts DETECT at TM43 +- Walkthrough lists TM16 under 'Items found in Mahogany Town'; no TM16 appears in maps/MahoganyTown.asm (TM16 is ICY_WIND, Pryce's gym reward, a later section) +- Camper Spencer's second mon: walkthrough says L18 Sandslash, data/trainers/parties.asm:2817 says L17 +- Walkthrough places Wesley on Route 43's left path; he is object_event 4,4 on MAP_LAKE_OF_RAGE +- Walkthrough says the Route 43 Max Ether is 'to the right of Picnicker Tiffany'; ball is (12,32), Tiffany is (9,29) +- Which tiles are Cut trees / Surf entry points on Route 42 and Lake of Rage is block data (maps/Route42.blk, maps/LakeOfRage.blk) read by CheckMapForSomethingToCut, not any event table; the claim that Route 43's west grass path bypasses ROUTE_43_GATE is likewise unverified from asm +- Mom's phone call on Route 43 is a generic SPECIALCALL, not anything in maps/Route43.asm; which call fires was not pinned down +- Fisher Marvin's L15 Gyarados EXP is left '?' by the walkthrough and is a derived number, not asm data +- LakeOfRage_MapScripts' two scene scripts are commented '; unusable' and RedGyarados does setscene 0 on a map with no scene variable; a port modelling setscene as a per-map byte write should confirm this is harmless +- Route43Gate warp 4 (5,7) points at ROUTE_43 warp 3, the same as warp 3 (4,7) - transcribed as written +- RedGyarados' ifequal LOSE, .NotBeaten only skips the disappear, so giveitem RED_SCALE and appear LAKEOFRAGE_LANCE run even on a loss; reads like a cart quirk but not observed on hardware +- Port gap found while checking coverage: World.lua:4591 sets opts.battleType but World:startBattle (:4420) never reads it, so BATTLETYPE_FORCESHINY does not make the Red Gyarados shiny (only BATTLETYPE_FORCEITEM is handled, at :4576) +- Port gap: movement byte $4c (teleport_from, Lance's exit) has no case in src/script/gen2/Movement.lua decodeByte and falls through to nop +- Port gap: no tests/drivers/gold_* driver covers this stretch + +### 11. [team rocket hideout and mahogany town gym](section-11-team-rocket-hideout-and-mahogany-town-gym.md) + +Maps the Mahogany Town / Team Rocket Hideout / Mahogany Gym stretch onto pokegold: every warp, coord event, bg event and object event for the six maps, the full B1F trap and camera coordinate tables, the three-floor stairwell topology derived by flood-filling the .blk files, and the decoded Mahogany Gym ice floor with a verified slide-by-slide route to every trainer and to Pryce. Also records the trainer parties, item balls and hidden items, the flag/scene ordering that gates the two password doors and the Electrode room, and an honest port-coverage table flagging BGEVENT_IFNOTSET dispatch and ice sliding as missing. + +**Milestones:** GLACIERBADGE (ENGINE_GLACIERBADGE, badge 7, from PRYCE1 in MahoganyGymPryceScript); TM16 Icy Wind (EVENT_GOT_TM16_ICY_WIND); HM06 Whirlpool (EVENT_GOT_HM06_WHIRLPOOL, from RocketBaseElectrodeScript); EVENT_CLEARED_ROCKET_HIDEOUT + clearflag ENGINE_ROCKET_SIGNAL_ON_CH20; EVENT_LEARNED_SLOWPOKETAIL / EVENT_LEARNED_RATICATE_TAIL / EVENT_LEARNED_HAIL_GIOVANNI + +**Maps:** `MAP_MAHOGANY_TOWN`, `MAP_MAHOGANY_POKECENTER_1F`, `MAP_MAHOGANY_MART_1F`, `MAP_TEAM_ROCKET_BASE_B1F`, `MAP_TEAM_ROCKET_BASE_B2F`, `MAP_TEAM_ROCKET_BASE_B3F`, `MAP_MAHOGANY_GYM` + +**Gates:** + +- Hideout entrance: MahoganyMart1FStaircaseCallback needs EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART; the cutscene needs SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS, set by maps/LakeOfRage.asm in the previous section +- B3F Giovanni's office door (bg_event 10,9 / 11,9, TeamRocketBaseB3FLockedDoor) requires BOTH EVENT_LEARNED_SLOWPOKETAIL (GRUNTF_5 at 21,7, sight 0 so she must be talked to) and EVENT_LEARNED_RATICATE_TAIL (GRUNTM_28 at 5,15) +- B2F transmitter door (bg_event 14,12 / 15,12, TeamRocketBaseB2FLockedDoor) requires EVENT_LEARNED_HAIL_GIOVANNI from the Murkrow object at B3F 7,2 +- B2F ExecutiveF ambush coord events only exist on SCENE_TEAMROCKETBASEB2F_ROCKET_BOSS, reached only by tripping LanceHealsScript1/2 at 5,14 or 4,13 first +- Electrode room exit blocked by RocketBaseCantLeaveScript / RocketBaseLancesSideScript until all three EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_n are set +- Mahogany Gym door blocked by SPRITE_FISHER at 6,14 until EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM is set by RocketBaseElectrodeScript (object event flags MASK when set, per engine/overworld/map_objects_2.asm CheckObjectFlag) +- Whirlpool field use gated on ENGINE_GLACIERBADGE in engine/events/overworld.asm WhirlpoolFunction.TryWhirlpool (03:4da0) and TryWhirlpoolOW (03:4e41) +- Mahogany Gym floor is COLL_ICE ($23) throughout; crossing it requires the STEP_ICE forced-slide rule in engine/overworld/player_movement.asm + +**Unresolved (8):** + +- Walkthrough's floor labels are wrong after the first password: it calls the rival encounter and Giovanni's office 'Negative Floor 1' but both are on TEAM_ROCKET_BASE_B3F (left region, reached via B2F warp 2 at 3,2). Verified by flood-filling the .blk files - B2F and B3F are each split into disconnected rooms. +- Boarder Ronald's party order differs (walkthrough Seel/Seel/Dewgong, asm SEEL 24, DEWGONG 25, SEEL 24); same for GRUNTM_18 (walkthrough Rattata/Rattata/Zubat, asm RATTATA 17, ZUBAT 17, RATTATA 17). +- Walkthrough's hideout item list omits the B3F FULL_HEAL (1,12) and DIRE_HIT (3,12) item balls from its header block, and never mentions the two hidden items: REVIVE at B1F 3,11 and FULL_HEAL at B2F 26,7. +- Walkthrough implies two statues on B1F; there are five bg_event statues (24,1 / 6,1 / 24,5 / 8,15 / 22,15) with eight trigger cells, i.e. up to ten camera-grunt battles. +- EVENT_TEAM_ROCKET_BASE_POPULATION is read by all eight SecurityCamera* scripts and is the shared visibility flag of eleven base NPCs, but a full-tree grep finds nothing that ever sets it - those checkevent arms are dead and the base never empties. +- Lance's three right-hand Electrodes (22,5 / 22,7 / 22,9) are never battled; RocketElectrodeN disappears both members of a mirrored pair and the right-hand objects point at the no-op ObjectEvent script. +- Facing direction needed to read the secret switch at B1F 19,11 was inferred from collision (19,11 is WALL, 19,12 is FLOOR) rather than observed; the event is BGEVENT_READ so any adjacent facing should work. +- The Mahogany Gym slide table was produced by simulating the ice rule against the decoded MahoganyGym.blk with NPCs as blockers. It reproduces every direction string in the walkthrough but was not executed in an emulator. + +### 12. [team rocket radio tower](section-12-team-rocket-radio-tower.md) + +Maps the Team Rocket Radio Tower arc onto pokegold: the 7-badge trigger that spawns the Rockets, all five Radio Tower floors, the Basement Key trip through the Goldenrod Underground into the switch-room shutter puzzle and the Underground Warehouse, and the Card Key return to 3F/4F/5F. Transcribes every warp/coord/bg/object table verbatim, resolves all 27 trainer constants to their party data, derives the shutter puzzle from the wUndergroundSwitchPositions position table, and audits the port for what actually works. + +**Milestones:** No badge earned in this section; BASEMENT_KEY (FakeDirectorScript, RADIO_TOWER_5F); CARD_KEY (GoldenrodUndergroundWarehouseDirectorScript); EVENT_CLEARED_RADIO_TOWER + EVENT_TEAM_ROCKET_DISBANDED; RAINBOW_WING (Gold) / SILVER_WING (Silver) from RadioTower5FRocketBossScript; Rival battle 4 (RIVAL1_4_*) in the Goldenrod Underground + +**Maps:** `MAP_GOLDENROD_CITY`, `MAP_RADIO_TOWER_1F`, `MAP_RADIO_TOWER_2F`, `MAP_RADIO_TOWER_3F`, `MAP_RADIO_TOWER_4F`, `MAP_RADIO_TOWER_5F`, `MAP_GOLDENROD_UNDERGROUND`, `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`, `MAP_GOLDENROD_UNDERGROUND_WAREHOUSE`, `MAP_GOLDENROD_DEPT_STORE_B1F` + +**Gates:** + +- 7th badge: ActivateRockets -> engine/events/std_scripts.asm:255 RadioTowerRocketsScript (readvar VAR_BADGES, ifequal 7) sets ENGINE_ROCKETS_IN_RADIO_TOWER and clears EVENT_RADIO_TOWER_ROCKET_TAKEOVER +- RADIO_TOWER_2F stairs at (0,0): RADIOTOWER2F_BLACK_BELT1 at (0,1), masked by EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS (SET = hidden) +- RADIO_TOWER_3F east shutter: bg_event 14,2 BGEVENT_UP -> CardKeySlotScript, checkitem CARD_KEY, sets EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER; RadioTower3FCardKeyShutterCallback replays the changeblocks +- GOLDENROD_UNDERGROUND locked door at (18,6): BasementDoorScript, checkitem BASEMENT_KEY, sets EVENT_USED_BASEMENT_KEY +- Switch-room shutters: GoldenrodUndergroundSwitchRoomEntrances_UpdateDoors driven by wUndergroundSwitchPositions (01:d6a8); reset to 0 by MAPCALLBACK_NEWMAP on GOLDENROD_UNDERGROUND / GOLDENROD_UNDERGROUND_WAREHOUSE +- RADIO_TOWER_5F boss: coord_event 16,5 only on SCENE_RADIOTOWER5F_ROCKET_BOSS, set by FakeDirectorScript after EXECUTIVEM_3 +- EXECUTIVEM_2 at (14,1) physically guards RADIO_TOWER_4F warp 3 (12,0) to 5F +- GOLDENROD_DEPT_STORE_B1F crate layout: EVENT_RECEIVED_CARD_KEY plus EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1/2/3, frozen until EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF is cleared +- No HM field move is required anywhere in this section + +**Unresolved (8):** + +- Walkthrough says the director gives a Clear Bell; the asm gives RAINBOW_WING (Gold) / SILVER_WING (Silver) via checkver. Clear Bell is a Crystal item. +- Walkthrough claims the shutters remember switch ORDER; wUndergroundSwitchPositions is a plain sum (1+2+3 = 3+2+1 = 6). The real difference is which doors the intermediate positions leave untouched (door 5 at 10,10). Derivation done by hand from the .PositionN arms and should be re-checked on hardware. +- Party orderings differ: walkthrough lists GRUNTF_4 as Ekans/Ekans/Gloom/Oddish (asm: 21 Ekans, 23 Oddish, 21 Ekans, 24 Gloom) and EXECUTIVEF_1 as Arbok/Murkrow/Vileplume (asm: Arbok, Vileplume, Murkrow). +- "the northwesternmost building" resolved by geometry to GoldenrodCity warp 14 at (9,5) plus the UndergroundSignNorth bg_event at (8,6); the walkthrough never names it. +- EXP and money figures in the walkthrough were not verified against the trainer class base-money table. +- EVENT_TEAM_ROCKET_DISBANDED is set only on the Gold branch of RadioTower5FRocketBossScript (line 130), not the Silver branch; its only consumer is the TIN_TOWER_1F sage object mask. Looks intentional but is flagged rather than asserted. +- verbosegiveitem BASEMENT_KEY and verbosegiveitem CARD_KEY have no iffalse bag-full guard. +- Whether (16,6) on RADIO_TOWER_3F is truly unreachable before the shutter opens was not checked against the .blk collision data. + +### 13. [ice path and blackthorn city gym](section-13-ice-path-and-blackthorn-city-gym.md) + +Maps the Route 44 -> Ice Path -> Blackthorn City -> Blackthorn Gym -> Dragon's Den stretch onto the pokegold disassembly, with verbatim warp/coord/bg/object tables for all sixteen maps, both Strength stone tables, every EVENT_*/ENGINE_* flag the route touches, all thirteen trainer parties and the wild/fish tables. It pins down that the Rising Badge is granted in DragonsDenB1F rather than the gym, that the Den crossing needs Whirlpool rather than Waterfall, and that the port's only structural gap is ice-tile sliding. + +**Milestones:** RISINGBADGE (ENGINE_RISINGBADGE, set in maps/DragonsDenB1F.asm DragonsDenB1FDragonFangScript, NOT in the gym); EVENT_BEAT_CLAIR (gym battle won, badge withheld); EVENT_GOT_HM07_WATERFALL (HM07 Waterfall, ICE_PATH_1F (31,7)); EVENT_GOT_TM24_DRAGONBREATH (TM24 Dragonbreath); ENGINE_FLYPOINT_BLACKTHORN (Fly point, BlackthornCityFlypointCallback) + +**Maps:** `MAP_ROUTE_44`, `MAP_ICE_PATH_1F`, `MAP_ICE_PATH_B1F`, `MAP_ICE_PATH_B2F_MAHOGANY_SIDE`, `MAP_ICE_PATH_B3F`, `MAP_ICE_PATH_B2F_BLACKTHORN_SIDE`, `MAP_BLACKTHORN_CITY`, `MAP_BLACKTHORN_POKECENTER_1F`, `MAP_BLACKTHORN_MART`, `MAP_BLACKTHORN_EMYS_HOUSE`, `MAP_MOVE_DELETERS_HOUSE`, `MAP_BLACKTHORN_DRAGON_SPEECH_HOUSE`, `MAP_BLACKTHORN_GYM_1F`, `MAP_BLACKTHORN_GYM_2F`, `MAP_DRAGONS_DEN_1F`, `MAP_DRAGONS_DEN_B1F` + +**Gates:** + +- Blackthorn Gym door (18,11) blocked by BLACKTHORNCITY_SUPER_NERD1 on (18,12) until EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM is set by maps/RadioTower5F.asm:111-112 (previous section) +- Dragon's Den entrance (20,1) blocked by BLACKTHORNCITY_GRAMPS1 on (20,2) until maps/BlackthornGym1F.asm:54-55 runs after EVENT_BEAT_CLAIR +- Ice Path B1F requires HM04 STRENGTH (ENGINE_PLAINBADGE, engine/events/overworld.asm:941 StrengthFunction.TryStrength) plus the four-boulder stone table in IcePathB1FSetUpStoneTableCallback.StoneTable (boulder N onto warp N+2) +- Ice Path B3F Nevermeltice gated by the SMASHABLE_ROCK at (6,6) -> jumpstd SmashRockScript; ROCK SMASH needs the move only, no badge +- Dragon's Den B1F needs SURF (ENGINE_FOGBADGE) and WHIRLPOOL (ENGINE_GLACIERBADGE, engine/events/overworld.asm:1077); single $07 whirlpool block at maps/DragonsDenB1F.blk block (5,10) = walk cells x10-11 y20-21 +- WATERFALL unusable for the whole section: engine/events/overworld.asm:618 checks ENGINE_RISINGBADGE, which this section awards last +- Dragon Fang needs a free bag slot: DragonsDenB1FDragonFangScript giveitem/.BagFullDragonFang aborts the entire badge scene silently +- Master Ball in ElmsLab gated on checkflag ENGINE_RISINGBADGE (maps/ElmsLab.asm:60); specialphonecall SPECIALCALL_MASTERBALL queued in the Den + +**Unresolved (10):** + +- Route 44 'Max Repel' east of the trainer: the asm ball at (30,8) is Route44MaxRevive -> itemball MAX_REVIVE; no Max Repel object exists on the map +- Psychic Phil's Kadabra is db 26, KADABRA in data/trainers/parties.asm PSYCHIC_T (8); walkthrough says Level 24 +- 'Protein' in the Ice Path: no PROTEIN in any of the five Ice Path map files; only IRON (B1F (5,35)) and hidden CARBOS (B2F Mahogany (0,17)) +- Clair's party order: asm ClairGroup is Dragonair, Dragonair, Dragonair, Kingdra; walkthrough puts Kingdra third +- All ice-maze direction strings ('up, left, up, right...') and all boulder push counts are .blk block geometry plus CheckIceTile, not map-asm data; not verified +- Walkthrough says Dragon's Den needs Surf and Waterfall; the asm gate is Surf and Whirlpool, and Waterfall is not even usable until the badge this trip awards +- Money rewards (832G, 1000G, 1200G, ...) are computed at runtime from base money x level; no static table located, none verified +- Route 44 and Ice Path item lists in the walkthrough omit MAX_REVIVE, hidden ELIXER, B2F Mahogany MAX_POTION, hidden MAX_POTION, hidden CARBOS and hidden ICE_HEAL +- Which 1F<->2F warp pair the walkthrough means by 'use the warps to return to the entrance' in Blackthorn Gym is ambiguous +- File is section-13 but its own heading reads '---- 19 > Ice Path and Blackthorn City Gym ----' + +### 14. [ho oh gold and lugia silver](section-14-ho-oh-gold-and-lugia-silver.md) + +Maps the walkthrough's Gold (Tin Tower / Ho-Oh) and Silver (Whirl Islands / Lugia) legendary runs onto pokegold, transcribing every warp, coord, bg and object event row for the Ecruteak Tin Tower entrance corridor, Tin Tower 1F-9F and Roof, and all eight Whirl Islands maps plus Route 41's four island doors. It pins the two one-shot legendary callbacks (RAINBOW_WING / SILVER_WING in bag plus EVENT_FOUGHT_* clear), the checkver level split (L40 in your own version, L70 in the other), every item ball and hidden item with its EVENT_* latch, the Rattata/Gastly and Krabby/Zubat/Seel wild tables, and the real badge checks behind Flash, Surf, Whirlpool, Strength and Waterfall. + +**Milestones:** No badge awarded in this section; EVENT_FOUGHT_HO_OH (Gold, TinTowerRoof:TinTowerHoOh, 5b:6913) - one-shot level 40 Ho-Oh holding SACRED_ASH; EVENT_FOUGHT_LUGIA (Silver, WhirlIslandLugiaChamber:Lugia, 47:41a0) - one-shot level 40 Lugia + +**Maps:** `MAP_ECRUTEAK_TIN_TOWER_ENTRANCE`, `MAP_ECRUTEAK_TIN_TOWER_BACK_ENTRANCE`, `MAP_TIN_TOWER_1F`, `MAP_TIN_TOWER_2F`, `MAP_TIN_TOWER_3F`, `MAP_TIN_TOWER_4F`, `MAP_TIN_TOWER_5F`, `MAP_TIN_TOWER_6F`, `MAP_TIN_TOWER_7F`, `MAP_TIN_TOWER_8F`, `MAP_TIN_TOWER_9F`, `MAP_TIN_TOWER_ROOF`, `MAP_ROUTE_41`, `MAP_WHIRL_ISLAND_NE`, `MAP_WHIRL_ISLAND_NW`, `MAP_WHIRL_ISLAND_SW`, `MAP_WHIRL_ISLAND_SE`, `MAP_WHIRL_ISLAND_CAVE`, `MAP_WHIRL_ISLAND_B1F`, `MAP_WHIRL_ISLAND_B2F`, `MAP_WHIRL_ISLAND_LUGIA_CHAMBER` + +**Gates:** + +- ENGINE_FOGBADGE - Ecruteak Gym runs `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP` (maps/EcruteakGym.asm:34), which disarms the two sage-blocking coord events at (4,7)/(5,7); the sage's own script only changes its text +- EVENT_TEAM_ROCKET_DISBANDED - TINTOWER1F_SAGE stands on warp 3 at (10,2) (maps/TinTower1F.asm:56) and is only masked once the flag is set at maps/RadioTower5F.asm:130, one opcode after verbosegiveitem RAINBOW_WING +- RAINBOW_WING in bag - TinTowerRoofHoOhCallback (checkitem) refuses to appear Ho-Oh otherwise +- SILVER_WING in bag - WhirlIslandLugiaChamberLugiaCallback (checkitem) refuses to appear Lugia otherwise +- ENGINE_FOGBADGE + SURF - engine/events/overworld.asm:322 SurfFunction, required to reach Route 41 at all +- ENGINE_GLACIERBADGE + WHIRLPOOL - engine/events/overworld.asm:1077 / :1171, the whirlpools ringing the Whirl Islands (badge 7, not 8) +- ENGINE_ZEPHYRBADGE + FLASH - engine/events/overworld.asm:271 FlashFunction, every Whirl Islands map is PALETTE_DARK (data/maps/maps.asm:136-143) +- ENGINE_PLAINBADGE + STRENGTH - WhirlIslandB1FBoulder at (23,26) jumpstd StrengthBoulderScript +- ENGINE_RISINGBADGE + WATERFALL - only to climb back UP the B2F waterfall; descending is free via COLL_WATERFALL forcing DOWN in engine/overworld/player_movement.asm .water_table +- EVENT_FOUGHT_HO_OH / EVENT_FOUGHT_LUGIA are set BEFORE startbattle, so fleeing or blacking out burns the encounter permanently - save first + +**Unresolved (9):** + +- Walkthrough says Whirlpool needs eight badges; the asm checks ENGINE_GLACIERBADGE (badge 7, Pryce) in both WhirlpoolFunction.TryWhirlpool and TryWhirlpoolOW, and nothing reads VAR_BADGES +- Walkthrough gives Ho-Oh 'Safeguard, Ancient Power, Punishment, Sacred Fire'; HoOhEvosAttacks makes a level 40 Ho-Oh know Sacred Fire, Safeguard, Gust, Recover. AncientPower is level 88 and Punishment does not exist as a Gen 2 move constant +- Walkthrough's Tin Tower items do not match: it lists a PP Up (asm has SUPER_POTION at TinTower4F (17,14)), a Max Potion and Full Heal on the bridge floor (TIN_TOWER_6F has no objects and no bg events at all), and an HP Up (8F has NUGGET and FULL_RESTORE). No PP_UP or HP_UP appears in any TinTower*.asm +- Walkthrough lists 'Max Revive x2' for the Whirl Islands; the asm has exactly one, on B2F at (6,4). It also omits B1F's Full Restore, Carbos and Nugget, the three B1F hidden items, and B2F's Full Restore and Max Elixer +- Every hop/ledge/bridge step sequence is block-layout geometry in the .blk files (TinTower4F.blk..TinTower9F.blk, WhirlIslandB1F.blk, WhirlIslandB2F.blk); I did not decode block data, so those routes are unverified. Warp coordinates are the verified part +- 'The whirlpool just northwest of Swimmer Kara' could not be pinned to an asm row: whirlpool tiles are COLL_WHIRLPOOL entries inside maps/Route41.blk with no coordinate list in maps/Route41.asm. Kara's position (44,28) is verified +- 'The Ho-Oh in-game music' is not a legendary battle theme: engine/battle/start_battle.asm has no legendary case, so the fight uses MUSIC_JOHTO_WILD_BATTLE. The distinctive track is the map's MUSIC_TIN_TOWER, and Whirl Islands play MUSIC_UNION_CAVE +- The '~2% catch rate' figure is unverified (I derived the rate formula inputs, not the shake check): base catch rate 3, Ultra Ball x2, sleep/freeze +10, and burn/poison/paralysis worth nothing due to the reproduced PokeBallEffect bug +- Port gaps found: no forced-tile movement (COLL_WATERFALL / COLL_CURRENT_*) anywhere in src/world/gen2, so the B2F waterfall descent to Lugia's chamber has no implementation; and src/battle/gen2/Catching.lua sets every Apricorn ball multiplier to a flat 1 with no caller supplying conditions, so the Heavy Ball advantage on Lugia is not reproduced. No tests/drivers/gold_* driver exercises either legendary + +### 15. [mount mortar and dark cave](section-15-mount-mortar-and-dark-cave.md) + +Maps the walkthrough's optional Mt. Mortar and Dark Cave detour onto pokegold, transcribing every warp/coord/bg/object row for the four Mt. Mortar floors and both Dark Cave halves, plus the Kiyo Tyrogue script, the Blackglasses pharmacist script, wild tables and the Flash/Surf/Waterfall/Strength/Rock Smash gates. No badge is won here; the section's only lasting rewards are the free level 10 Tyrogue and the Blackglasses, and nothing in it blocks any later section. + +**Milestones:** No badge in this section; EVENT_BEAT_BLACKBELT_KIYO (Blackbelt Kiyo, BLACKBELT_T/KIYO, L34 Hitmonlee + L34 Hitmonchan); EVENT_GOT_TYROGUE_FROM_KIYO (givepoke TYROGUE, 10 - the only Tyrogue in the game); EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE (verbosegiveitem BLACKGLASSES) + +**Maps:** `MAP_MOUNT_MORTAR_1F_OUTSIDE`, `MAP_MOUNT_MORTAR_1F_INSIDE`, `MAP_MOUNT_MORTAR_2F_INSIDE`, `MAP_MOUNT_MORTAR_B1F`, `MAP_DARK_CAVE_BLACKTHORN_ENTRANCE`, `MAP_DARK_CAVE_VIOLET_ENTRANCE`, `MAP_MAHOGANY_TOWN`, `MAP_ROUTE_42`, `MAP_BLACKTHORN_CITY`, `MAP_ROUTE_45`, `MAP_ROUTE_31`, `MAP_ROUTE_46` + +**Gates:** + +- Darkness: all six maps are PALETTE_DARK (data/maps/maps.asm:127-130,148-149); ReplaceTimeOfDayPals .NeedsFlash (23:43e9) forces DARKNESS_PALSET unless STATUSFLAGS_FLASH_F is set. FlashFunction.CheckUseFlash (03:48f1) needs ENGINE_ZEPHYRBADGE. ResetFlashIfOutOfCave (00:2f1d) clears the bit on any ROUTE/TOWN map, so Flash must be re-cast after every exit and every Fly. +- SURF (ENGINE_FOGBADGE, SurfFunction 03:493b / TrySurfOW 03:4a06): Mt. Mortar middle entrance (ROUTE_42 warp 4 at 28,9), the 1F Outside lake, and both Dark Cave water crossings. +- WATERFALL (ENGINE_RISINGBADGE, WaterfallFunction.TryWaterfall 03:4af6 / TryWaterfallOW 03:4b5f): the only route to MOUNT_MORTAR_1F_OUTSIDE warp 4 at (17,5) -> 2F. CheckMapCanWaterfall requires facing UP with wTileUp a waterfall tile. +- STRENGTH (ENGINE_PLAINBADGE, AskStrengthScript 03:4d4e / TryStrengthOW 03:4d7b): boulders at MOUNT_MORTAR_1F_INSIDE (21,43) and MOUNT_MORTAR_B1F (9,10). BIKEFLAGS_STRENGTH_ACTIVE_F is cleared on every map load, so Strength must be re-activated per floor. +- ROCK SMASH (no badge check; HasRockSmash 03:4f7f): the four SPRITEMOVEDATA_SMASHABLE_ROCK objects in DARK_CAVE_VIOLET_ENTRANCE at (16,14), (27,6), (7,14), (36,31). +- Party count: MountMortarB1FKiyoScript does readvar VAR_PARTYCOUNT / ifequal PARTY_LENGTH, .NoRoom - a full party defers the Tyrogue (recoverable, EVENT_BEAT_BLACKBELT_KIYO stays set). +- FLY (ENGINE_STORMBADGE, engine/events/overworld.asm:545) plus ENGINE_FLYPOINT_MAHOGANY / ENGINE_FLYPOINT_BLACKTHORN for the two hops the walkthrough takes. +- Nothing in this section gates any later section - both caves are fully optional. + +**Unresolved (12):** + +- Walkthrough's Mt. Mortar item list is the HGSS list: Carbos, PP Up, Max Ether and Iron do not exist on any MountMortar*.asm map. TM40 is the right number but the move is TM_DEFENSE_CURL (constants/item_constants.asm:261), not Aerial Ace. +- 'grab the free Iron on the cliff' on 1F Inside has no matching object; nearest are MAX_REVIVE (16,10) and hidden MAX_REPEL (31,9). +- 'surf clockwise around and reach a Hyper Potion and a Max Ether' - no MAX_ETHER in Mt. Mortar. B1F pairs HYPER_POTION (31,17) with FULL_HEAL (4,16); 2F has ELIXER (8,9). Which the author meant is undetermined. +- Kiyo's 816G prize and 1012/1020 EXP figures not verified - money comes from data/trainers/attributes.asm, which was not opened. Party itself is verified. +- 'two items ... Hyper Potion and Escape Rope' in the giant room: MOUNT_MORTAR_1F_INSIDE actually holds three balls plus a hidden MAX_REPEL, though MAX_REVIVE and the hidden item are only reachable from the 2F side. +- 'west entrance and east entrance lead to each other' - all three Route 42 doors land on MOUNT_MORTAR_1F_OUTSIDE; the link runs via MOUNT_MORTAR_1F_INSIDE warp pairs 5/6 and 8/9. The .blk files were not decoded, so only the warp graph is verified, not the walkable path. +- Dark Cave Pokemon list omits DUNSPARCE (slot 7 of DARK_CAVE_VIOLET_ENTRANCE). +- Dark Cave item list places Full Heal (36,22) and Hyper Potion (35,9) on a lobe this route does not pass, omits the hidden ELIXER at (26,3), and omits the POTION (6,8) named in its own prose. +- The rock-smash TREEMON_SET_ROCK table at DARK_CAVE_VIOLET_ENTRANCE (90% Krabby L15, 10% Shuckle L15) is real and unmentioned by the walkthrough. +- MOUNT_MORTAR_1F_OUTSIDE object 2 is itemball GUARD_SPEC but its flag is EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE - verbatim disassembly naming mismatch, not a transcription error. +- Mahogany Town and Blackthorn City interiors were not opened; only their maps.asm header rows and spawn_points.asm entries. +- Port gaps found while writing: OBJECTTYPE_ITEMBALL pickup has no arm in World:interact (all 15 Poke Balls here are unobtainable in the port), ROCK SMASH has no field-move path (CallAsm.lua:526 stubs RockMonEncounter), and ESCAPE_ROPE/DIG are absent from World:useFieldItem. + +### 16. [routes 45 46 26 and 27](section-16-routes-45-46-26-and-27.md) + +Maps the post-eighth-badge stretch from Blackthorn down Routes 45 and 46, back through New Bark Town for Elm's Master Ball and Everstone, then east across Route 27 and Tohjo Falls to Route 26 and the Victory Road gate badge check. Transcribes every warp/coord/bg/object row verbatim, resolves all 20 trainers to their parties.asm entries, records the wild tables with the Gold/Silver splits, and pins each field-move and script gate to its enforcing label. + +**Milestones:** No badge earned in this section; MASTER BALL from Prof. Elm (gated on ENGINE_RISINGBADGE); EVERSTONE from Prof. Elm (gated on EVENT_SHOWED_TOGEPI_TO_ELM); TM37 SANDSTORM (lead-mon happiness >= 150); TM22 SOLARBEAM (Route 27 whirlpool island); MOON STONE (Tohjo Falls); Victory Road Gate eight-badge check passed -> SCENE_VICTORYROADGATE_NOOP + +**Maps:** `MAP_ROUTE_45`, `MAP_ROUTE_46`, `MAP_NEW_BARK_TOWN`, `MAP_ELMS_LAB`, `MAP_ROUTE_27`, `MAP_TOHJO_FALLS`, `MAP_ROUTE_27_SANDSTORM_HOUSE`, `MAP_ROUTE_26`, `MAP_ROUTE_26_HEAL_HOUSE`, `MAP_DAY_OF_WEEK_SIBLINGS_HOUSE`, `MAP_VICTORY_ROAD_GATE`, `MAP_DARK_CAVE_BLACKTHORN_ENTRANCE`, `MAP_DARK_CAVE_VIOLET_ENTRANCE`, `MAP_ROUTE_29_ROUTE_46_GATE` + +**Gates:** + +- SURF + ENGINE_FOGBADGE to cross New Bark Town -> Route 27 and the Route 27 east water (engine/events/overworld.asm TrySurfOW) +- WATERFALL + ENGINE_RISINGBADGE for Tohjo Falls (engine/events/overworld.asm TryWaterfallOW + CheckMapCanWaterfall) +- WHIRLPOOL + ENGINE_GLACIERBADGE for the Route 27 island with TM22 and Bird Keeper Jose (engine/events/overworld.asm TryWhirlpoolOW) +- Master Ball: maps/ElmsLab.asm ElmCheckMasterBall -> checkflag ENGINE_RISINGBADGE, plus a free bag slot for verbosegiveitem +- Everstone: maps/ElmsLab.asm ElmCheckEverstone -> Togepi/Togetic must be IN THE PARTY (special FindPartyMonThatSpeciesYourTrainerID), not in the PC +- TM37 SANDSTORM: maps/Route27SandstormHouse.asm SandstormHouseWoman -> special GetFirstPokemonHappiness / ifgreater 150 - 1 (first non-egg party member only) +- Victory Road Gate: maps/VictoryRoadGate.asm _VictoryRoadGateBadgeCheckScript -> readvar VAR_BADGES / ifgreater NUM_JOHTO_BADGES - 1; soft fail pushes the player one step DOWN +- SCENE_ROUTE27_FIRST_STEP_INTO_KANTO coord events at (18,10) and (19,10) are unskippable on first entry + +**Unresolved (10):** + +- Camper Quentin (27 Fearow / 30 Primeape / 30 Tauros) does not exist anywhere in pokegold; no camper object on Route 45. The unmentioned trainer there is Hiker Michael. +- The 'Nugget' the walkthrough puts on Route 45 does not exist; the only hidden item is hiddenitem PP_UP, EVENT_ROUTE_45_HIDDEN_PP_UP at (13, 80). +- Route 27 Rare Candy is at cell (53, 12), well east of both Tohjo Falls entrances, but the walkthrough places it before entering the falls. Water continuity from the landing point was not verified against Route27.blk. +- Cooltrainer Gaven's second mon is KINGLER in data/trainers/parties.asm, not Krabby. +- Cooltrainer Reena's asm party order is STARMIE / NIDOQUEEN / STARMIE, not STARMIE / STARMIE / NIDOQUEEN. +- Hiker Erik's asm party order is MACHOP / GRAVELER / MACHOP, not Machop / Machop / Graveler. +- Walkthrough wild lists are a Gold/Silver merge: Route 46 also has Jigglypuff, Tohjo Falls also has Zubat/Golbat/Raticate/Goldeen, and Arbok is Silver-only on Routes 26/27 where Gold has Sandslash and Dodrio. +- The Tohjo Falls MOON STONE item ball at (2, 6) is never mentioned by the walkthrough. +- 'Head down the other waterfall' has no engine equivalent; descent is a COLL_CURRENT_DOWN ($3b) tile. Whether the port's World:waterfallStep handles the descend direction was not verified. +- Port gap, not a walkthrough gap: OBJECTTYPE_ITEMBALL objects are extracted (obj.itemball) but nothing in src/world/ consumes them, so all nine item balls in this section are unobtainable in the port today. + +### 17. [victory road](section-17-victory-road.md) + +Documents Gold/Silver Victory Road, which is a single 10x36-block map (20x72 cells) whose "floors" are self-warps, plus the VictoryRoadGate badge check that guards it: full warp / coord / bg / object tables, the rival ambush scripts and their three starter-dependent RIVAL1 parties, all seven items, the Kanto-table wild data, and a decoded per-cell collision map that pins every ladder, pit and one-way ledge. Port coverage is data-driven through RomExtractorGen2 and the Gen 2 VM, but Poke Ball pickup and ledge hops are missing in the port and no driver walks this map. + +**Milestones:** No badge in this section; Final RIVAL1 battle won (EVENT_RIVAL_VICTORY_ROAD set, wVictoryRoadSceneID -> SCENE_VICTORYROAD_NOOP); TM26 Earthquake obtained (EVENT_VICTORY_ROAD_TM_EARTHQUAKE); North exit onto Route 23 reached; Route23FlypointCallback sets ENGINE_FLYPOINT_INDIGO_PLATEAU + +**Maps:** `MAP_VICTORY_ROAD`, `MAP_VICTORY_ROAD_GATE`, `MAP_ROUTE_23 (exit only, next section)` + +**Gates:** + +- 8 Johto badges: VictoryRoadGateBadgeCheckScript / _VictoryRoadGateBadgeCheckScript in maps/VictoryRoadGate.asm reads VAR_BADGES (engine/overworld/variables.asm:80 .CountBadges, CountSetBits over 2 bytes) and requires ifgreater NUM_JOHTO_BADGES - 1; failure applies one step DOWN. Coord event (10,11) is a mandatory chokepoint (row 11 walkable only at x=8, occupied by the officer, and x=10) +- Rival ambush: coord events (12,8) and (13,8) gated on SCENE_VICTORYROAD_RIVAL_BATTLE cover the entire corridor to the exit warp at (13,5) +- TM26 Earthquake at (3,28) is geometry-gated: the y=26..30 pocket has exactly one entrance, the COLL_PIT at (0,11) (warp 8, one-way; warp 9 at (0,27) sits on COLL_FLOOR and never fires) +- Full Restore at (18,29) is geometry-gated: the shelf is entered only via warp 7 (17,19) -> (17,33), and exited only by the one-way HOP_DOWN ledge at y=34, x=16..19 +- Reaching ladder (17,19) on the cart requires the one-way HOP_RIGHT at (8,20)/(8,21) +- No field move gate at all: no cut tree, water, boulder or whirlpool tiles, and the header palette is PALETTE_NITE not PALETTE_DARK, so Flash is not required +- VictoryRoadGate black belts at (7,5) and (12,5) physically plug the only east-west corridor (gate row 5) until EVENT_OPENED_MT_SILVER / EVENT_FOUGHT_SNORLAX (later sections) + +**Unresolved (10):** + +- FAQ says Level 34 Magneton; asm has 35 in RIVAL1 (13) and (14), 34 only in RIVAL1 (15) (the Feraligatr set) +- FAQ lists the rival's starter fourth; asm order is Sneasel, Golbat, Magneton, Haunter, Kadabra, starter last +- FAQ's 2280G prize and per-mon EXP were not verified (trainer class attribute / base money table not opened) +- FAQ wild list omits Onix (L34 and L36) and Gold's Ursaring (L33), which Silver replaces with Donphan +- FAQ item list omits the hidden Max Potion at (3,29) and conflates the itemball Full Heal (15,48) with the hidden Full Heal (3,65) +- FAQ's "stairway" has no COLL_STAIRCASE anywhere in the decoded map; only ladders, one pit and five ledges exist +- FAQ's "head back up the ladder" after TM26 has no ladder in that pocket; the two exits are HOP_RIGHT at (8,28)/(8,29) and HOP_DOWN at (2,30)/(3,30) +- X Special ball at (7,38) sits on a COLL_HOP_LEFT tile walled on both sides and above; reachable by walking north from (7,42) because ledge tiles are LAND_TILE, but worth confirming in an emulator +- Gate black belts as physical blockers is inferred from the decoded gate collision plus NPC solidity, not stated in any script +- Port object masking polarity (hidden when the event flag is SET, per CheckObjectFlag) was not tested against src/world/gen2/Npc.lua + +### 18. [pok mon league](section-18-pok-mon-league.md) + +Maps the Pokémon League stretch (Victory Road exit -> Route 23 -> Indigo Plateau Pokecenter -> Will/Koga/Bruno/Karen/Lance rooms -> Hall of Fame -> credits) onto pokegold, with verbatim warp/coord/bg/object tables, map headers, the door-lock changeblock mechanics, every EVENT_/ENGINE_/SCENE_ flag the run touches, and the five Elite Four parties with class items, DVs and the real GS prize money. Also records the port's coverage (data-driven maps/scripts/battles are in, the post-credits SPAWN_NEW_BARK respawn is coded but has no caller, and no Gold driver walks the League) and flags that the walkthrough's parties, held items, move sets and money figures are HeartGold/SoulSilver values that contradict the asm. + +**Milestones:** No badge awarded in this section; EVENT_BEAT_ELITE_4_WILL; EVENT_BEAT_ELITE_4_KOGA; EVENT_BEAT_ELITE_4_BRUNO; EVENT_BEAT_ELITE_4_KAREN; EVENT_BEAT_CHAMPION_LANCE; EVENT_BEAT_ELITE_FOUR (durable League-cleared flag, set in HallOfFameEnterScript); STATUSFLAGS_HALL_OF_FAME_F (Hall of Fame induction; unlocks Kanto Pokegear map); ENGINE_FLYPOINT_INDIGO_PLATEAU (fly point, also gates the Kanto Fly map) + +**Maps:** `MAP_ROUTE_23`, `MAP_INDIGO_PLATEAU_POKECENTER_1F`, `MAP_WILLS_ROOM`, `MAP_KOGAS_ROOM`, `MAP_BRUNOS_ROOM`, `MAP_KARENS_ROOM`, `MAP_LANCES_ROOM`, `MAP_HALL_OF_FAME` + +**Gates:** + +- 8 Johto badges at maps/VictoryRoadGate.asm:_VictoryRoadGateBadgeCheckScript (readvar VAR_BADGES / ifgreater NUM_JOHTO_BADGES - 1) - precondition set in the previous section +- Each Elite Four room seals its south door on entry: DoorLocksBehindYouScript changeblock 4,14,$2a (Lance: 4,22,$34) + EVENT_*_ROOM_ENTRANCE_CLOSED, re-applied by MAPCALLBACK_TILES - no retreat to the lobby to heal +- Each room's north door needs that member beaten: Script_Battle changeblock 4,2,$16 + EVENT_*_ROOM_EXIT_OPEN +- Lance is only reachable via the coord events at (4,5)/(5,5) gated on SCENE_LANCESROOM_APPROACH_LANCE, which only the entry lock script sets +- IndigoPlateauPokecenter1FPrepareElite4Callback (MAPCALLBACK_NEWMAP) clears all five EVENT_BEAT_ELITE_4_* and re-arms every door-lock scene - re-entering the lobby restarts the gauntlet +- Losing is a full blackout: every fight uses winlosstext , 0 with no BATTLETYPE_CANLOSE +- No HM field move is required anywhere in this section +- Hazard, not a gate: PlateauRivalBattle1/2 at (16,4)/(17,4) fire only with EVENT_BEAT_RIVAL_IN_MT_MOON set, ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT clear, and VAR_WEEKDAY in {MONDAY, WEDNESDAY} + +**Unresolved (13):** + +- Walkthrough says 'Route 32' for the map between Victory Road and the Plateau; it is MAP_ROUTE_23 (constants/map_constants.asm:322) +- Elite Four send-out orders differ: asm Will is Xatu/Jynx/Exeggutor/Slowbro/Xatu, Koga is Ariados/Venomoth/Forretress/Muk/Crobat, Bruno is Hitmontop/Hitmonlee/Hitmonchan/Onix/Machamp - the FAQ lists HGSS orders +- Prize money: FAQ quotes 4200/4400/4600/4700/5000G (100 x level, HGSS). GS pays base reward 25 x last mon level = 1050/1100/1150/1175/1250 (ComputeTrainerReward, engine/battle/read_trainer_party.asm:300) +- Held/AI items: FAQ claims Koga and Lance carry three Full Restores, Karen one, plus Sitrus Berries on Houndoom and a Dragonite. GS parties are TRAINERTYPE_MOVES (no held-item field) and the class rows are Will/Bruno MAX_POTION, Karen FULL_HEAL+MAX_POTION (no Full Restore), Koga/Champion FULL_HEAL+FULL_RESTORE. Sitrus Berry does not exist in Gen 2 +- FAQ's 'Dragon Rush' on Lance's Dragonites is a Gen 4 move; GS movesets are Thunder Wave/Twister/Thunder-or-Blizzard/Hyper Beam and Fire Blast/Safeguard/Outrage/Hyper Beam +- Per-Pokemon EXP figures in the FAQ were not checked against data/pokemon/base_stats/ and are presumed HGSS +- 'Routes 46 and 47' - ROUTE_47 does not exist in pokegold (constants/map_constants.asm has ROUTE_45 and ROUTE_46 only) +- Move Deleter claim: maps/MoveDeletersHouse.asm exists and is reached from maps/BlackthornCity.asm, but its script was not read +- The Indigo Plateau 'Abra' object is declared SPRITE_JYNX (maps/IndigoPlateauPokecenter1F.asm:324) with script AbraScript and cry ABRA; no SPRITE_ABRA exists and no variablesprite rewrites it in std_scripts.asm's init block +- EVENT_LANCES_ROOM_EXIT_OPEN is read by LancesRoomDoorsCallback and cleared by the lobby callback but never set by any script; the champion's door is opened by a bare changeblock 4,0,$0b that does not survive a reload +- LancesRoomLanceScript re-sets EVENT_LANCES_ROOM_ENTRANCE_CLOSED after the battle, where EVENT_LANCES_ROOM_EXIT_OPEN was apparently intended +- PlateauRivalPostBattle's setscene writes 0 over 0 (the map declares one scene), so the rival coord events stay armed; only the engine flag and weekday check actually gate the fight +- Port gap: HallOfFame.consumePostGameSpawn (src/core/gen2/HallOfFame.lua:254) is unit-tested but has no caller in src/, so the post-credits SPAWN_NEW_BARK respawn does not happen in the port + +### 19. [s s aqua and vermilion city gym](section-19-s-s-aqua-and-vermilion-city-gym.md) + +Maps the walkthrough's post-Hall-of-Fame stretch (Elm's S.S. Ticket, the Fast Ship S.S. Aqua crossing from Olivine to Vermilion, and the Vermilion City gym) onto the pokegold disassembly, with verbatim warp/coord/bg/object tables for all fourteen maps involved, the script control flow and EVENT_*/ENGINE_* flags each beat reads and writes, resolved trainer parties, and the wild/fishing tables at both ports. Also records the exact cut-tree block and collision quad in Vermilion City, which contradicts the walkthrough's claim that the tree blocks the gym. + +**Milestones:** THUNDERBADGE (ENGINE_THUNDERBADGE) from LT_SURGE/LT_SURGE1 in VermilionGymSurgeScript; S_S_TICKET from ElmGiveTicketScript (EVENT_GOT_SS_TICKET_FROM_ELM); METAL_COAT from SSAquaMetalCoatAndDocking (EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA); RARE_CANDY from PokemonFanClubChairmanScript (EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT); ENGINE_FLYPOINT_VERMILION via VermilionPortFlypointCallback / VermilionCityFlypointCallback; First Kanto crossing completed: EVENT_FAST_SHIP_FIRST_TIME set by VermilionPortLeaveShipScript + +**Maps:** `MAP_NEW_BARK_TOWN`, `MAP_ELMS_LAB`, `MAP_OLIVINE_CITY`, `MAP_OLIVINE_PORT_PASSAGE`, `MAP_OLIVINE_PORT`, `MAP_FAST_SHIP_1F`, `MAP_FAST_SHIP_CABINS_NNW_NNE_NE`, `MAP_FAST_SHIP_CABINS_SW_SSW_NW`, `MAP_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN`, `MAP_FAST_SHIP_B1F`, `MAP_VERMILION_PORT`, `MAP_VERMILION_PORT_PASSAGE`, `MAP_VERMILION_CITY`, `MAP_POKEMON_FAN_CLUB`, `MAP_VERMILION_GYM` + +**Gates:** + +- EVENT_BEAT_ELITE_FOUR required before ProfElmScript reaches ElmGiveTicketScript (set by maps/HallOfFame.asm:33) +- checkitem S_S_TICKET in OlivinePortWalkUpToShipScript / OlivinePortSailorAfterHOFScript gates boarding +- EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME must be clear for the boarding sailor to exist (HallOfFame.asm:38); before that OlivinePortSailorBeforeHOFScript refuses entry +- VAR_WEEKDAY gate on repeat trips: Monday/Friday from Olivine, Wednesday/Sunday from Vermilion; bypassed on the maiden voyage via EVENT_FAST_SHIP_FIRST_TIME +- FastShipB1F sailor object at (30,6)/(31,6) plus coord events (30,7)/(31,7) under SCENE_FASTSHIPB1F_SAILOR_BLOCKS; cleared by FastShipLazySailorScript setmapscene SCENE_FASTSHIPB1F_NOOP +- EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR set at new game hides the lazy sailor until FastShipB1FSailorScript clears it +- Ship will not dock on the maiden voyage until SSAquaMetalCoatAndDocking sets EVENT_FAST_SHIP_HAS_ARRIVED / EVENT_FAST_SHIP_FOUND_GIRL; FastShip1FSailor1Script checks EVENT_FAST_SHIP_HAS_ARRIVED and FastShipBed.CanArrive needs FOUND_GIRL or FIRST_TIME +- PokemonFanClubChairmanScript yesorno: answering No skips the RARE_CANDY entirely +- ENGINE_THUNDERBADGE checkflag makes VermilionGymSurgeScript one-shot +- ENGINE_HIVEBADGE + CUT required for the Vermilion City cut tree (engine/events/overworld.asm:133/:169) - optional, only exposes the hidden FULL_HEAL +- special SnorlaxAwake blocks VermilionCity warp 10 to DIGLETTS_CAVE (out of scope for this section) + +**Unresolved (10):** + +- Walkthrough says a tree blocks the Vermilion Gym; the only cut tree is TILESET_KANTO block $35 at block (6,9), CUT_TREE quadrant cell (13,18), while the gym door is cell (10,19) with FLOOR tiles south of it - the tree gates nothing but the hidden FULL_HEAL at (12,19) +- "Meal Coat" is a typo for METAL_COAT +- Lt. Surge party order differs: asm is Raichu/Electrode/Magneton/Electrode/Electabuzz, walkthrough lists Raichu/Magneton/Electabuzz/Electrode/Electrode +- Juggler Fritz party order differs: asm is Mr. Mime/Magmar/Machoke, walkthrough says Mr. Mime/Machoke/Magmar +- "Lt. Surge has a Full Restore" could not be confirmed - his party entry is TRAINERTYPE_MOVES and carries no held item; AI item lists in data/trainers/attributes.asm were not opened +- Prize money (1056G, 4600G, etc.) and EXP values are runtime calculations, not table literals; not reproduced +- EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL is read by PokemonFanClubChairmanScript but never set anywhere found by grep +- "New Pokemon music for Kanto" - the Vermilion maps use MUSIC_VERMILION_CITY; not checked against the audio engine +- FastShipB1F prose route order ("head left, then down") was not re-walked tile by tile +- Water/fishing encounter handling at the ports was not verified against src/battle/gen2/Encounter.lua in this pass + +### 20. [saffron city gym](section-20-saffron-city-gym.md) + +Maps the walkthrough's Route 6 -> Saffron City -> Saffron Gym stretch onto the pokegold disassembly, with verbatim warp/bg/object tables for all eleven maps, the full 30-pad Saffron Gym teleporter graph resolved to landing coordinates, and the scripts, flags, items and trainer parties each beat depends on. Ends with a 23-step bot checklist, the gate list (Underground Path NPC body, Magnet Train power event, gym maze), and an honest port-coverage audit against src/world/gen2, src/script/gen2 and src/battle/gen2. + +**Milestones:** MARSHBADGE (ENGINE_MARSHBADGE, constants/engine_flags.asm:52, set by SaffronGymSabrinaScript at maps/SaffronGym.asm:35); ENGINE_FLYPOINT_SAFFRON unlocked by SaffronCityFlypointCallback (MAPCALLBACK_NEWMAP, maps/SaffronCity.asm:17); EVENT_BEAT_SABRINA plus the four gym-trainer flags force-set by the Sabrina script + +**Maps:** `MAP_ROUTE_6`, `MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE`, `MAP_ROUTE_6_SAFFRON_GATE`, `MAP_SAFFRON_CITY`, `MAP_MR_PSYCHICS_HOUSE`, `MAP_SILPH_CO_1F`, `MAP_SAFFRON_MAGNET_TRAIN_STATION`, `MAP_SAFFRON_MART`, `MAP_FIGHTING_DOJO`, `MAP_SAFFRON_GYM`, `MAP_SAFFRON_POKECENTER_1F` + +**Gates:** + +- Underground Path shut: ROUTE6_POKEFAN_M object body at Route 6 (17,4) sits on the door at (17,3); masked only when EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH is set by maps/PowerPlant.asm:164 (CheckObjectFlag, engine/overworld/map_objects_2.asm:32) +- Magnet Train dead: SaffronMagnetTrainStationOfficerScript checks EVENT_RESTORED_POWER_TO_KANTO then checkitem PASS (maps/SaffronMagnetTrainStation.asm:19,30); both false in this section +- Silph Co upper floors permanently blocked: SILPHCO1F_OFFICER at (13,1) with event flag -1 and no stairs warp in SilphCo1F's def_warp_events +- Saffron Gym maze: 30 teleport-panel warps (maps/SaffronGym.asm:296-326); the only pad reaching Sabrina is (1,5) -> (11,9), the only exit is (11,9) -> (1,5). No badge or flag check anywhere in the gym +- MARSHBADGE re-fight guard: checkflag ENGINE_MARSHBADGE / iftrue .FightDone at maps/SaffronGym.asm:17 +- No HM field move is required anywhere in this section + +**Unresolved (7):** + +- Psychic Jared's third Pokemon is L35 EXEGGCUTE in data/trainers/parties.asm:2573, not L32 as the walkthrough claims (asm contradicts walkthrough) +- Per-Pokemon EXP figures (765, 1237, 1149, 735, 672, 720, 1264, 1941, 1339, 1912) were not checked against data/pokemon/base_stats/ +- 'PokeMart in the upper-right corner' - the mart door is warp 3 at (25,11) on a 40x36-cell map, north-of-centre rather than the corner +- 'Silph Co, the multi-story building above the Pokemon Center' - Silph warp is (18,21), Pokecenter (9,29), so north-east not above; SilphCo1F has no stairs warp and no upper floor exists in Gen 2 +- 'You now have 10 badges' - ENGINE_MARSHBADGE is bit 5 of wKantoBadges, the 6th Kanto badge in flag order; the count of 10 depends on the FAQ's chapter order, which nothing in the asm enforces +- The vague mid-maze heal route ('left/right whichever way works till you find Franklin') was not reduced to a pad sequence; the post-heal route it gives does resolve and is recorded +- The 'too weak to move your own boulders' taunt has no counterpart in Route6PokefanMText, which only mentions the Power Plant + +### 21. [power plant and cerulean city gym](section-21-power-plant-and-cerulean-city-gym.md) + +Maps the Kanto Machine Part chain and the Cerulean Gym onto pokegold: Route 5 (Cleanse Tag house) north to Cerulean, east across Route 9 to Route 10 North, Surf to the Power Plant, then the grunt cutscene in Cerulean Gym, the Route 24 Rocket battle, Route 25's six-pack trainers plus Kevin, Bill's grandpa, and finally Misty for CASCADEBADGE. Every warp/coord/bg/object table is transcribed verbatim, with the EVENT_TRAINERS_IN_CERULEAN_GYM / EVENT_CERULEAN_GYM_ROCKET / EVENT_ROUTE_24_ROCKET / EVENT_ROUTE_25_MISTY_BOYFRIEND state machine traced through InitializeEventsScript, PowerPlantManager, CeruleanGymGruntRunsOutScript and Route25MistyDate1Script. + +**Milestones:** CASCADEBADGE (ENGINE_CASCADEBADGE, set by CeruleanGymMistyScript in maps/CeruleanGym.asm); EVENT_BEAT_MISTY; EVENT_MET_MANAGER_AT_POWER_PLANT (starts the Machine Part chain); EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM; EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM (hidden MACHINE_PART obtained at CeruleanGym 3,8); EVENT_CLEARED_NUGGET_BRIDGE (Kevin's Nugget on Route 25); EVENT_GOT_CLEANSE_TAG + +**Maps:** `MAP_ROUTE_5`, `MAP_ROUTE_5_SAFFRON_GATE`, `MAP_ROUTE_5_CLEANSE_TAG_HOUSE`, `MAP_ROUTE_5_UNDERGROUND_PATH_ENTRANCE`, `MAP_CERULEAN_CITY`, `MAP_CERULEAN_GYM`, `MAP_ROUTE_9`, `MAP_ROUTE_10_NORTH`, `MAP_POWER_PLANT`, `MAP_ROUTE_24`, `MAP_ROUTE_25`, `MAP_BILLS_HOUSE` + +**Gates:** + +- CUT at the Route 9 west entrance and the Route 25 tree above Super Nerd Pat: engine/events/overworld.asm:117 CutFunction .CheckAble checks ENGINE_HIVEBADGE then CheckCutCollision (engine/overworld/tile_events.asm:76) +- SURF to reach the Power Plant door at ROUTE_10_NORTH warp 2 (3,9): engine/events/overworld.asm:322 SurfFunction .TrySurf checks ENGINE_FOGBADGE; overworld A-press path TrySurfOW at :469 +- FLY back to Cerulean twice: engine/events/overworld.asm:529 FlyFunction checks ENGINE_STORMBADGE at :545; destination needs ENGINE_FLYPOINT_CERULEAN set by CeruleanCityFlypointCallback +- Cerulean Gym is empty until Route 25: all gym object rows carry EVENT_TRAINERS_IN_CERULEAN_GYM, set by InitializeEventsScript (engine/events/std_scripts.asm:550), cleared only by Route25MistyDate1Script/Route25MistyDate2Script (maps/Route25.asm:48/:74) +- Cerulean Gym Rocket grunt does not exist until PowerPlantManager clears EVENT_CERULEAN_GYM_ROCKET and does setmapscene CERULEAN_GYM, SCENE_CERULEANGYM_GRUNT_RUNS_OUT (maps/PowerPlant.asm:146-148) +- Route 24 grunt does not exist until CeruleanGymGruntRunsOutScript clears EVENT_ROUTE_24_ROCKET (maps/CeruleanGym.asm:47) +- Route 25 Misty date coord events at (42,6)/(42,7) are inert until setmapscene ROUTE_25, SCENE_ROUTE25_MISTYS_DATE (maps/CeruleanGym.asm:50) and EVENT_ROUTE_25_MISTY_BOYFRIEND is cleared +- Hidden MACHINE_PART at CeruleanGym bg_event 3,8 yields nothing until PowerPlantManager clears EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM (maps/PowerPlant.asm:147) +- Route 5 Underground Path door (17,15) blocked by the Pokefan standing on 17,16; only cleared post-section by setevent EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH in PowerPlantManager .FoundMachinePart (maps/PowerPlant.asm:164) +- Bill's grandpa hands over one stone per map load: EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 forces .JustShowedSomething until the player leaves and re-enters + +**Unresolved (9):** + +- Cut tree coordinates on Route 9 and Route 25 are block data in maps/Route9.blk / maps/Route25.blk, not events; no coordinate exists in the asm text to cite +- The walkthrough omits Kevin's battle entirely - TrainerCooltrainermKevin gives the NUGGET then fights with L38 RHYHORN / L35 CHARMELEON / L35 WARTORTLE +- Walkthrough wild lists are abridged: Route 24 also has VENONAT/SUNKERN/ODDISH/VENOMOTH, Route 25 also has PIDGEY/PIDGEOTTO/VENONAT/WEEPINBELL/ODDISH/VENOMOTH, and Route 5's table is not listed at all +- The exact surf path across the ROUTE_9 / ROUTE_10_NORTH connection to the Power Plant door was not traced (block data) +- Per-mon EXP numbers in the walkthrough have no asm source; money values were all reproduced as base reward x last-mon level x 4 and check out +- '11th gym badge' is walkthrough prose - the asm has only the ENGINE_CASCADEBADGE bit, no ordinal +- Route 5 'old Day Care Center' is ROUTE_5_CLEANSE_TAG_HOUSE, whose sign reads 'House for Sale... Nobody lives here'; nothing in Gen 2 calls it a Day Care +- maps/Route5.asm bg_event 10,11 (HouseForSaleSign) shares its tile with warp_event 10,11 into the Cleanse Tag house - bg-event vs warp priority worth checking in the port +- maps/PowerPlant.asm names the fifth NPC POWERPLANT_GYM_GUIDE3 but its script is PowerPlantGymGuide4Script; there is no PowerPlantGymGuide3Script + +### 22. [rock tunnel and celadon city gym](section-22-rock-tunnel-and-celadon-city-gym.md) + +Maps the Rock Tunnel / Celadon Gym stretch of the Gold walkthrough onto pokegold: verbatim warp, coord, bg and object tables for every map from the Power Plant reward through Erika's gym, plus the machine-part -> EXPN card -> LOST_ITEM -> PASS flag chain, all trainer parties, the Rock Tunnel and Route 7/8 wild tables, and the exact field-move badge checks. Ends with a 45-step bot checklist, a port-coverage matrix against src/world/gen2 and src/script/gen2, and ten walkthrough claims that the asm contradicts or does not support. + +**Milestones:** RAINBOWBADGE (ENGINE_RAINBOWBADGE, set by CeladonGymErikaScript in maps/CeladonGym.asm) + +**Maps:** `MAP_POWER_PLANT`, `MAP_ROUTE_10_NORTH`, `MAP_ROCK_TUNNEL_1F`, `MAP_ROCK_TUNNEL_B1F`, `MAP_ROUTE_10_SOUTH`, `MAP_LAVENDER_TOWN`, `MAP_LAV_RADIO_TOWER_1F`, `MAP_SOUL_HOUSE`, `MAP_ROUTE_8`, `MAP_ROUTE_8_SAFFRON_GATE`, `MAP_SAFFRON_CITY`, `MAP_SAFFRON_MAGNET_TRAIN_STATION`, `MAP_COPYCATS_HOUSE_1F`, `MAP_COPYCATS_HOUSE_2F`, `MAP_VERMILION_CITY`, `MAP_POKEMON_FAN_CLUB`, `MAP_ROUTE_7_SAFFRON_GATE`, `MAP_ROUTE_7`, `MAP_CELADON_CITY`, `MAP_CELADON_MANSION_1F`, `MAP_CELADON_MANSION_2F`, `MAP_CELADON_MANSION_3F`, `MAP_CELADON_MANSION_ROOF`, `MAP_CELADON_MANSION_ROOF_HOUSE`, `MAP_CELADON_DEPT_STORE_1F`, `MAP_CELADON_DEPT_STORE_2F`, `MAP_CELADON_DEPT_STORE_3F`, `MAP_CELADON_DEPT_STORE_4F`, `MAP_CELADON_DEPT_STORE_5F`, `MAP_CELADON_DEPT_STORE_6F`, `MAP_CELADON_GAME_CORNER`, `MAP_CELADON_GAME_CORNER_PRIZE_ROOM`, `MAP_CELADON_CAFE`, `MAP_CELADON_GYM` + +**Gates:** + +- Rock Tunnel darkness: PALETTE_DARK in data/maps/maps.asm:156-157 + FlashFunction.CheckUseFlash (engine/events/overworld.asm:271) requires ENGINE_ZEPHYRBADGE and wTimeOfDayPalset == DARKNESS_PALSET +- Surf to the Power Plant / across Route 10 water: SurfFunction badge check ENGINE_FOGBADGE (engine/events/overworld.asm:340) +- Cut generally: CutFunction.CheckAble ENGINE_HIVEBADGE (engine/events/overworld.asm:133) + CheckCutCollision against data/collision/field_move_blocks.asm CutTreeBlockPointers.kanto +- TM07 Zap Cannon: PowerPlantManager requires EVENT_RETURNED_MACHINE_PART or MACHINE_PART in bag; sets EVENT_GOT_TM07_ZAP_CANNON and EVENT_RESTORED_POWER_TO_KANTO +- EXPN CARD: LavRadioTower1FGentlemanScript requires EVENT_RETURNED_MACHINE_PART, sets ENGINE_EXPN_CARD +- LOST_ITEM from PokemonFanClubClefairyGuyScript requires EVENT_RETURNED_MACHINE_PART AND EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM (set by talking to Copycat first) +- PASS from Copycat.ReturnLostItem requires LOST_ITEM in bag; sets EVENT_GOT_PASS_FROM_COPYCAT +- Magnet Train: SaffronMagnetTrainStationOfficerScript requires EVENT_RESTORED_POWER_TO_KANTO then checkitem PASS +- TM03 Curse: CeladonMansionRoofHousePharmacistScript requires checktime NITE +- Game Corner prizes: checkitem COIN_CASE, checkcoins price, and VAR_PARTYCOUNT != PARTY_LENGTH for the mon counter +- Beating Erika retroactively sets EVENT_BEAT_LASS_MICHELLE / TANYA / JULIA / TWINS_JO_AND_ZOE, so gym trainers cannot be fought afterwards +- TM19 Giga Drain is handed out in the same conversation as the badge; a full pack silently skips it (EVENT_GOT_TM19_GIGA_DRAIN stays clear, retry by talking again) + +**Unresolved (10):** + +- Walkthrough says Celadon Gym needs HM01 Cut; maps/CeladonCity.blk has exactly one kanto cut-tree block ($60 at block (14,17) = cell (28,34)), nowhere near the gym door (block $12 at (5,14), approached from walkable $79 at (5,15)). No Cut gate found. +- Walkthrough's Route 7 wild list (Pidgeotto/Vulpix/Meowth) does not match data/wild/kanto_grass.asm def_grass_wildmons ROUTE_7 IF DEF(_GOLD) (Rattata/Spearow/Growlithe/Raticate/Murkrow/Houndour); Meowth+Vulpix are the _SILVER branch. +- Walkthrough omits four Rock Tunnel items present in asm: ELIXER ball at 1F (4,18), hidden X_ACCURACY at 1F (24,4), hidden X_DEFEND at 1F (21,15), hidden MAX_POTION at B1F (4,14). +- Walkthrough says Erika carries three Full Restores; data/trainers/attributes.asm:125 gives the Erika class 'db HYPER_POTION, NO_ITEM'. +- Erika's party order in parties.asm:331 is Tangela/Jumpluff/Victreebel/Bellossom, not the walkthrough's Tangela/Victreebel/Bellossom/Jumpluff. +- Bellossom's Synthesis healing fraction (walkthrough claims half HP per turn) is weather-dependent in engine/battle/effect_commands.asm and was not pinned down. +- Walkthrough describes a 'Rail Pass' then a separate 'Magnet Train Pass'; the asm has one item, PASS, given once by Copycat.GivePass. +- 'The Saffron City guard will check your Pokedex as ID' - Route7SaffronGuardScript has no checkitem, no coord event and no blocking movement; the gate is passable regardless. +- maps/CeladonGameCorner.asm object/bg event rows (individual slot machines) were not transcribed; only its two warps. +- Quoted EXP and prize-money figures (e.g. 1447 EXP, 1120G) were not verified - they are computed at runtime, not stored in a table. + +### 23. [routes 11 19 and fuchsia city gym](section-23-routes-11-19-and-fuchsia-city-gym.md) + +Maps the Kanto south-east loop (Route 12 south from Lavender, the Route 11 spur, Routes 13-15 into Fuchsia, the Route 16/17/18 Cycling Road, and Fuchsia Gym) onto pokegold, with every def_warp_events / def_coord_events / def_bg_events / def_object_events row transcribed verbatim plus map headers, dimensions and connections. Adds cut-tree walk cells derived by scanning each .blk against CutTreeBlockPointers, full trainer party data, the wild/fishing tables, the two BICYCLE gate coord events, and the Janine gym script's flag cascade. + +**Milestones:** ENGINE_SOULBADGE (Kanto badge 5) from FuchsiaGymJanineScript in maps/FuchsiaGym.asm; EVENT_GOT_TM06_TOXIC -> TM_TOXIC (item $c5) from Janine; EVENT_GOT_SUPER_ROD -> SUPER_ROD from Route12SuperRodHouseFishingGuruScript; ENGINE_FLYPOINT_FUCHSIA set by FuchsiaCityFlypointCallback (MAPCALLBACK_NEWMAP) + +**Maps:** `MAP_ROUTE_12`, `MAP_ROUTE_11`, `MAP_ROUTE_12_SUPER_ROD_HOUSE`, `MAP_ROUTE_13`, `MAP_ROUTE_14`, `MAP_ROUTE_15`, `MAP_ROUTE_15_FUCHSIA_GATE`, `MAP_FUCHSIA_CITY`, `MAP_FUCHSIA_POKECENTER_1F`, `MAP_ROUTE_16`, `MAP_ROUTE_16_GATE`, `MAP_ROUTE_16_FUCHSIA_SPEECH_HOUSE`, `MAP_ROUTE_17`, `MAP_ROUTE_17_ROUTE_18_GATE`, `MAP_ROUTE_18`, `MAP_FUCHSIA_GYM` + +**Gates:** + +- CUT (ENGINE_HIVEBADGE) required at walk cells Route12 (7,44)/(7,49), Route13 (44,4), Route14 (5,8)/(11,14)/(3,24), Route16 (15,4), FuchsiaCity (16,11)/(18,19) - enforced by CutFunction.CheckAble (engine/events/overworld.asm:117) and TryCutOW (:1741) +- BICYCLE in bag required at Route16Gate coord events (5,4)/(5,5), scene 0 - Route16GateBicycleCheck (maps/Route16Gate.asm:16) +- BICYCLE in bag required at Route17Route18Gate coord events (5,4)/(5,5), scene 0 - Route17Route18GateBicycleCheck +- ENGINE_ALWAYS_ON_BIKE + ENGINE_DOWNHILL forced on all of Route 17 (Route17AlwaysOnBikeCallback): no dismount, no Surf, idle frames auto-step DOWN +- ENGINE_ALWAYS_ON_BIKE conditionally set on Route 16 unless VAR_YCOORD < 5 or VAR_XCOORD > 13 (Route16AlwaysOnBikeCallback, MAPCALLBACK_NEWMAP only) +- Vermilion Snorlax (EVENT_VERMILION_CITY_SNORLAX, maps/VermilionCity.asm:41) blocks the Route 11 west connection until special SnorlaxAwake returns true - out of scope for this section +- Safari Zone permanently shut: FuchsiaCity warp 7 at (18,3) is commented ; inaccessible and no script or flag ever opens it +- ENGINE_SOULBADGE gates re-fighting Janine (FuchsiaGymJanineScript checkflag), and EVENT_GOT_TM06_TOXIC gates the TM06 handout separately from the badge + +**Unresolved (9):** + +- Route 19 is in the section title but the walkthrough never enters MAP_ROUTE_19 or MAP_ROUTE_19_FUCHSIA_GATE +- Walkthrough says 'cut the tree on your way west' on Route 15, but maps/Route15.blk contains none of the five TILESET_KANTO cut-tree block ids; the nearest real tree is the Route 14 one at cell (3,24) +- Walkthrough includes a 'Strategy VERSUS Espeon' paragraph for Janine; Janine (1) in data/trainers/parties.asm has no Espeon (Crobat, Weezing, Weezing, Ariados, Venomoth) +- Seven trainers' party ORDER in the guide differs from ROM order (Herman, Johnny, Carter, Boris, Charles, Linda, Janine); species and levels always match +- Guide omits Route 12 NUGGET (5,51) and hidden ELIXER (14,13), Route 13 hidden CALCIUM (30,13), Route 17 hidden MAX_ETHER (12,5) and MAX_ELIXER (8,77) +- Guide's Route 17 biker order (Riley, Glenn, Joel, Charles) contradicts the map's y-order (Riley 17, Joel 32, Glenn 53, Charles 80); these are sight-line trainers so y-order is what a bot will hit +- Port: the four-fold prize-money loop (engine/battle/core.asm:2341 'ld c, 4') was not located in src/battle/gen2/Prize.lua during this pass +- Fuchsia City cut trees at cells (16,11) and (18,19) are unmentioned by the guide and no reward was traced behind them +- FuchsiaCity warp 7 at (18,3) is only documented as inaccessible by an asm comment; the block collision itself was not decoded + +### 24. [snorlax and pewter city gym](section-24-snorlax-and-pewter-city-gym.md) + +Maps the FAQ's "Snorlax and Pewter City Gym" chapter onto pokegold: the blocked Route 19 boulder callback, the Poke Flute radio wake-up and forced-Leftovers Snorlax battle in Vermilion City, the three-pocket ladder chain through Diglett's Cave, Route 2's items/cut trees/Bug Catchers, and Pewter City through Brock's BOULDERBADGE. Every warp, coord, bg and object row is transcribed verbatim from the map asm, with the enforcing code cited for each gate and an honest implemented/partial/missing pass over the port's src/world/gen2, src/script/gen2 and src/battle/gen2. + +**Milestones:** BOULDERBADGE (ENGINE_BOULDERBADGE, set by maps/PewterGym.asm:PewterGymBrockScript); EVENT_BEAT_BROCK; EVENT_FOUGHT_SNORLAX (L50 SNORLAX with LEFTOVERS); EVENT_GOT_SILVER_WING (Gold) / EVENT_GOT_RAINBOW_WING + EVENT_TEAM_ROCKET_DISBANDED (Silver); EVENT_GOT_NUGGET_FROM_GUY; ENGINE_FLYPOINT_PEWTER + +**Maps:** `MAP_ROUTE_19`, `MAP_ROUTE_19_FUCHSIA_GATE`, `MAP_ROUTE_20`, `MAP_VERMILION_CITY`, `MAP_VERMILION_POKECENTER_1F`, `MAP_DIGLETTS_CAVE`, `MAP_ROUTE_2`, `MAP_ROUTE_2_NUGGET_HOUSE`, `MAP_ROUTE_2_GATE`, `MAP_PEWTER_CITY`, `MAP_PEWTER_POKECENTER_1F`, `MAP_PEWTER_GYM` + +**Gates:** + +- Route 19 boulders: Route19ClearRocksCallback paints six $7a blocks at (6,6)(8,6)(10,6)(12,8)(4,8)(10,10) while EVENT_CINNABAR_ROCKS_CLEARED is clear; that event is set ONLY by Route20ClearRocksCallback (maps/Route20.asm:13, MAPCALLBACK_NEWMAP), so nothing in this section can open it +- SNORLAX at (34,8) physically covers DIGLETT'S CAVE warp 10 at (34,7); removed by `disappear` in VermilionSnorlax, which runs only after startbattle +- SnorlaxAwake (engine/events/specials.asm:358) needs wMapMusic == MUSIC_POKE_FLUTE_CHANNEL AND the player on (33,8)/(34,10)/(35,10)/(36,8)/(36,9) +- POKe FLUTE radio channel (knob 78 / 20.0, engine/pokegear/pokegear.asm:1487) needs not-Johto plus POKEGEAR_EXPN_CARD_F +- Route 2 cut trees: COLL_CUT_TREE via CheckCutCollision, gated on HM01 CUT + ENGINE_HIVEBADGE (engine/events/overworld.asm:133) +- Diglett's Cave is three disconnected pockets joined only by warp pairs 2<->5 and 6<->4 +- PewterGymBrockScript `checkflag ENGINE_BOULDERBADGE / iftrue .FightDone` makes Brock one-shot +- Pewter Museum has no warp row at all - permanently closed + +**Unresolved (9):** + +- Brock's party order: FAQ says Graveler/Omastar/Rhyhorn/Onix/Kabutops, BrockGroup (data/trainers/parties.asm:270) is Graveler/Rhyhorn/Omastar/Onix/Kabutops +- Every FAQ prize-money figure is exactly 4x the asm's ComputeTrainerReward (base reward x last mon level): Brock 25x42=1050 vs FAQ 4200, Rob 128 vs 512, Doug 136 vs 544, Ed 120 vs 480, Jerry 185 vs 740; the EXP figures likewise are not GS values (probably HGSS) +- FAQ says the Max Potion is 'in Viridian Forest' - no such map exists in GS; it is ROUTE2_POKE_BALL2 at (2,23) on ROUTE_2 +- FAQ's 'lady' in the Pewter Pokemon Center is `Chris`, SPRITE_POKEFAN_M at (7,2) +- Which two of Route 2's five CUT_TREE blocks the FAQ means is inferred from the .blk dump plus item/warp positions, not labelled in asm +- Diglett's Cave three-pocket connectivity was derived by hand from DiglettsCave.blk block ids vs kanto_collision.asm; interior tile collision not fully traced +- FAQ's 'the route is cleared to Diglett's Cave and through Route 8' - nothing in Route11/Route8 asm is gated on the Snorlax; EVENT_FOUGHT_SNORLAX only additionally controls a Black Belt object in maps/VictoryRoadGate.asm:119 +- 'Keep around those four Ultra Balls' is strategy; no ball-count check exists +- Port: the equivalent of BikeFunction.CheckEnvironment (CAVE/GATE permission) was not located in src/world/gen2/Bike.lua, so biking Diglett's Cave in the port is unverified + +### 25. [lugia gold and ho oh silver](section-25-lugia-gold-and-ho-oh-silver.md) + +Section 25 covers the post-Elite-Four hunt for the version-opposite mascot: the Gold branch surfs Route 41 into the Whirl Islands down to Lugia's chamber, and the Silver branch walks Ecruteak's Tin Tower gatehouse up nine floors of warp/jump mazes to Ho-Oh on the roof. The document transcribes every warp/coord/bg/object table for all 20 maps, the two mascot scripts with their checkver level split and pre-battle one-shot flags, the wing-based MAPCALLBACK_OBJECTS spawn checks, the field-move badge gates, wild/fishing tables, and an honest port-coverage pass that flags the missing Heavy Ball weight bonus. + +**Milestones:** No badges awarded in this section; EVENT_FOUGHT_LUGIA set - Lugia encountered at WHIRL_ISLAND_LUGIA_CHAMBER (Lv70 in Gold, Lv40 in Silver); EVENT_FOUGHT_HO_OH set - Ho-Oh encountered at TIN_TOWER_ROOF (Lv70 in Silver, Lv40 in Gold), always holding SACRED_ASH via BATTLETYPE_FORCEITEM + +**Maps:** `MAP_ROUTE_41`, `MAP_WHIRL_ISLAND_NW`, `MAP_WHIRL_ISLAND_NE`, `MAP_WHIRL_ISLAND_SW`, `MAP_WHIRL_ISLAND_SE`, `MAP_WHIRL_ISLAND_CAVE`, `MAP_WHIRL_ISLAND_B1F`, `MAP_WHIRL_ISLAND_B2F`, `MAP_WHIRL_ISLAND_LUGIA_CHAMBER`, `MAP_ECRUTEAK_CITY`, `MAP_ECRUTEAK_TIN_TOWER_ENTRANCE`, `MAP_ECRUTEAK_TIN_TOWER_BACK_ENTRANCE`, `MAP_TIN_TOWER_1F`, `MAP_TIN_TOWER_2F`, `MAP_TIN_TOWER_3F`, `MAP_TIN_TOWER_4F`, `MAP_TIN_TOWER_5F`, `MAP_TIN_TOWER_6F`, `MAP_TIN_TOWER_7F`, `MAP_TIN_TOWER_8F`, `MAP_TIN_TOWER_9F`, `MAP_TIN_TOWER_ROOF` + +**Gates:** + +- SURF requires ENGINE_FOGBADGE (engine/events/overworld.asm:322, badge test :340) to reach Route 41 at all +- WHIRLPOOL requires ENGINE_GLACIERBADGE (overworld.asm:1061/:1077 menu path, :1167/:1171 A-press path) to clear the Route 41 whirlpool blocks (TILESET_JOHTO block $07 -> $36, data/collision/field_move_blocks.asm:48-55) +- FLASH requires ENGINE_ZEPHYRBADGE and a PALETTE_DARK map (overworld.asm:271) - every Whirl Island map is PALETTE_DARK +- WATERFALL requires ENGINE_RISINGBADGE (overworld.asm:611/:618, :683/:687) only for the UP direction; descending the B2F waterfall needs nothing +- Lugia object only appears while checkitem SILVER_WING is true and EVENT_FOUGHT_LUGIA is clear (WhirlIslandLugiaChamberLugiaCallback, MAPCALLBACK_OBJECTS) +- Ho-Oh object only appears while checkitem RAINBOW_WING is true and EVENT_FOUGHT_HO_OH is clear (TinTowerRoofHoOhCallback) +- TIN_TOWER_1F stairs at (10,2) are physically occupied by TINTOWER1F_SAGE, masked only when EVENT_TEAM_ROCKET_DISBANDED is set (maps/RadioTower5F.asm:130 in Gold, maps/PewterCity.asm:51 in Silver) +- ECRUTEAK_TIN_TOWER_ENTRANCE coord_events at (4,7)/(5,7) block the corridor while the map scene is SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS; cleared by maps/EcruteakGym.asm:34 setmapscene on the Fog Badge +- EVENT_FOUGHT_LUGIA / EVENT_FOUGHT_HO_OH are set BEFORE startbattle, so fleeing, fainting or a KO burns the only encounter - save first +- Escape Rope only works in CAVE or DUNGEON environments (engine/events/overworld.asm:724-754); TIN_TOWER_ROOF is ROUTE, so you must descend to 9F first + +**Unresolved (11):** + +- Walkthrough says Whirlpool needs eight badges; the asm gates it on ENGINE_GLACIERBADGE alone (seven badges) +- Walkthrough lists 'Max Revive x2' in the Whirl Islands; only one MAX_REVIVE item ball exists (WhirlIslandB2FMaxRevive at B2F (6,4)). It also omits Full Restore x2, Carbos, Nugget, Max Elixer and three B1F hidden items +- Tin Tower item list is wrong for pokegold: no PP_UP, no HP_UP, no Max Potion item ball (only hidden TinTower4FHiddenMaxPotion at (11,6)), only one Full Heal (3F). It omits Super Potion, Nugget, Full Restore and the 4F/5F hidden items +- Walkthrough places a Max Potion and a Full Heal on the floor matching TIN_TOWER_6F; maps/TinTower6F.asm has empty def_bg_events and def_object_events +- Ho-Oh's claimed moveset (Safeguard, Ancient Power, Punishment, Sacred Fire) is impossible - PUNISHMENT does not exist in Gen 2. Derived from data/pokemon/evos_attacks.asm:3324-3336 plus FillMoves (engine/pokemon/evolve.asm:478), a Lv70 Ho-Oh should have Recover/Fire Blast/Sunny Day/Swift. The derivation is inferred, not a literal table +- Whirl Islands wild list omits GOLBAT (grass slot 5 on every floor) and TENTACOOL/TENTACRUEL (SW and B2F water); Tin Tower list omits GASTLY at night +- Whirlpool positions near Swimmer Kara (44,28) are .blk block data, not events - a bot must scan maps/Route41.blk for TILESET_JOHTO block $07 +- All ledge-hop and jump-platform routing in both dungeons is .blk collision data; only the warp endpoints are asm facts +- 'Bell Tower' is the post-GS rename; pokegold text and signs say TIN TOWER throughout +- Port: Heavy Ball weight bonus is missing (src/battle/gen2/Catching.lua:36 has HEAVY_BALL = 1, no dex-weight lookup), so the FAQ's Lugia Heavy Ball tip does not work in this repo +- Port: could not locate an EscapeRopeFunction equivalent with the CAVE/DUNGEON environment check, so the TIN_TOWER_ROOF refusal is unverified in this repo + +### 26. [mount moon and routes 1 4](section-26-mount-moon-and-routes-1-4.md) + +Maps the walkthrough's Route 3 -> Mt. Moon (rival battle, Mt. Moon Square, gift shop) -> Route 4 -> Viridian City / Trainer House -> Route 1 -> Pallet Town / Oak's Lab stretch onto pokegold, with verbatim warp/coord/bg/object tables, map headers, trainer parties, wild tables and script control flow. Ends by handing off to Route 21 (next section) and records the port's gaps, notably that OBJECTTYPE_ITEMBALL pickup and smashable-rock interaction are unimplemented. + +**Milestones:** No badge is earned in this section; EVENT_BEAT_RIVAL_IN_MT_MOON (last mandatory Silver battle; unlocks the Indigo Plateau and Dragon's Den rival re-encounters); ENGINE_FLYPOINT_VIRIDIAN (Viridian City MAPCALLBACK_NEWMAP); ENGINE_FLYPOINT_PALLET (Pallet Town MAPCALLBACK_NEWMAP); EVENT_TALKED_TO_OAK_IN_KANTO (Oak's Kanto-badge conversation; EVENT_OPENED_MT_SILVER at 16 badges); HP_UP item ball on Route 4 (EVENT_ROUTE_4_HP_UP) + +**Maps:** `MAP_ROUTE_3`, `MAP_MOUNT_MOON`, `MAP_MOUNT_MOON_SQUARE`, `MAP_MOUNT_MOON_GIFT_SHOP`, `MAP_ROUTE_4`, `MAP_VIRIDIAN_CITY`, `MAP_TRAINER_HOUSE_1F`, `MAP_TRAINER_HOUSE_B1F`, `MAP_ROUTE_1`, `MAP_PALLET_TOWN`, `MAP_OAKS_LAB` + +**Gates:** + +- MountMoon scene 0 SCENE_MOUNTMOON_RIVAL_BATTLE -> sdefer MountMoonRivalBattleScript (42:55b0): unavoidable RIVAL2 battle; only setscene SCENE_MOUNTMOON_NOOP at the end of a win clears it +- MountMoonSquare ClefairyDance: requires ENGINE_MT_MOON_SQUARE_CLEFAIRY clear AND VAR_WEEKDAY == MONDAY AND checktime NITE; one-shot via setflag +- EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE is re-set by MountMoonSquareDisappearMoonStoneCallback on every MAPCALLBACK_NEWMAP, so the Moon Stone must be taken in the same visit as the dance +- MtMoonSquareRock -> jumpstd SmashRockScript -> engine/events/overworld.asm:1365 AskRockSmashScript / HasRockSmash: needs ROCK_SMASH in the party (CheckPartyMove), no badge check +- TrainerHouseB1F ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY: one CAL battle per day +- TrainerHouse opponent identity: special TrainerHouse reads sMysteryGiftTrainerHouseFlag (engine/events/specials.asm:454); without Mystery Gift always CAL3 +- Fly: engine/events/overworld.asm:544 FlyFunction .TryFly requires ENGINE_STORMBADGE plus the destination's ENGINE_FLYPOINT_* +- Surf south from Pallet to Route 21 (next section): engine/events/overworld.asm:338 SurfFunction .TrySurf requires ENGINE_FOGBADGE +- Indigo Plateau rival rematch: EVENT_BEAT_RIVAL_IN_MT_MOON + ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT clear + weekday Monday or Wednesday (maps/IndigoPlateauPokecenter1F.asm:45,67) +- Dragon's Den rival sighting: EVENT_BEAT_RIVAL_IN_MT_MOON + weekday Tuesday or Thursday (maps/DragonsDenB1F.asm:12) + +**Unresolved (13):** + +- Rival party ORDER contradicts the walkthrough: parties.asm has the starter evolution LAST at L45, not third (data/trainers/parties.asm:2042-2069) +- Walkthrough's Route 3 and Route 4 wild lists (Ekans/Arbok) are the ELIF DEF(_SILVER) arms; the _GOLD arms have no Ekans or Arbok (data/wild/kanto_grass.asm:314, :369) +- Walkthrough's Mt. Moon wild list omits Gold's L8 SANDSHREW, L10 SANDSLASH and two L8 CLEFAIRY slots (data/wild/kanto_grass.asm:33) +- Walkthrough's Route 1 wild list omits the nite column, which has no Pidgey or Furret at all (data/wild/kanto_grass.asm:250) +- 'Item on Route 1: Bitter Berry' is a fruittree FRUITTREE_ROUTE_1 (daily respawn), not a ground item (data/items/fruit_trees.asm) +- The two Mt. Moon paths described in the walkthrough could not be confirmed against maps/MountMoon.blk collision; only the warp graph was verified +- 'Bring Rock Smash' is supported indirectly (the rock is appeared onto the Moon Stone tile mid-cutscene) but whether it actually blocks the A press was not verified +- Route 4 hidden ULTRA_BALL at (10,3) and Viridian's TM_DREAM_EATER are real but absent from the walkthrough +- Mt. Moon Gift Shop has no NITE clerk: both SPRITE_GRAMPS rows are gated -1, MORN and -1, DAY +- Route 3's only warp is at (52,1) but its Mt. Moon Square sign is at (49,13); odd but verbatim +- PORT GAP: OBJECTTYPE_ITEMBALL pickup is unimplemented. RomExtractorGen2.lua:2968 records obj.itemball but World:interact (src/world/gen2/World.lua:5257-5310) has no arm for it, so the Route 4 HP Up cannot be taken +- PORT GAP: SPRITEMOVEDATA_SMASHABLE_ROCK has no interact arm (World.isStrengthBoulder exists at World.lua:4071, no rock equivalent anywhere in src/) +- PORT FIDELITY: src/script/gen2/Vm.lua:92-93 runs sdefer immediately instead of deferring, so the Mt. Moon rival cutscene starts marginally earlier than on cart + +### 27. [cinnabar island and seafoam islands gym](section-27-cinnabar-island-and-seafoam-islands-gym.md) + +Maps the FAQ's Cinnabar Island / Seafoam Islands Gym chapter onto pokegold: the surf route Pallet -> Route 21 -> Cinnabar Island -> Route 20 -> Seafoam Gym, with verbatim warp/bg/object tables, map headers, connections, the four trainers plus BLAINE, and every EVENT_/ENGINE_ flag those scripts touch. Includes the two non-obvious side effects a bot must not skip - talking to Blue clears EVENT_VIRIDIAN_GYM_BLUE (the only unlock for the Viridian Gym leader) and merely loading Route 20 sets EVENT_CINNABAR_ROCKS_CLEARED (which unseals Route 19). + +**Milestones:** ENGINE_VOLCANOBADGE (VOLCANOBADGE from BLAINE, maps/SeafoamGym.asm:34); EVENT_BEAT_BLAINE (maps/SeafoamGym.asm:29); ENGINE_FLYPOINT_CINNABAR (CinnabarIslandFlypointCallback, maps/CinnabarIsland.asm:11); EVENT_CINNABAR_ROCKS_CLEARED (Route20ClearRocksCallback, maps/Route20.asm:13); EVENT_VIRIDIAN_GYM_BLUE cleared by CinnabarIslandBlue (maps/CinnabarIsland.asm:23) - the Viridian Gym leader unlock; Hidden RARE_CANDY, EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY (maps/CinnabarIsland.asm:36) + +**Maps:** `MAP_ROUTE_21`, `MAP_CINNABAR_ISLAND`, `MAP_CINNABAR_POKECENTER_1F`, `MAP_ROUTE_20`, `MAP_SEAFOAM_GYM` + +**Gates:** + +- SURF over open sea for the whole section: engine/events/overworld.asm:469 TrySurfOW -> ENGINE_FOGBADGE CheckEngineFlag at :490 plus CheckPartyMove SURF (menu path SurfFunction .TrySurf, ENGINE_FOGBADGE at :340) +- Blue absent from Viridian Gym until talked to here: maps/ViridianGym.asm:183-184 object rows carry EVENT_VIRIDIAN_GYM_BLUE, set at engine/events/std_scripts.asm:552, cleared only by CinnabarIslandBlue +- Route 19 sealed by six changeblock $7a rock blocks in maps/Route19.asm:15 Route19ClearRocksCallback while EVENT_CINNABAR_ROCKS_CLEARED is clear; loading Route 20 once sets it +- Blaine re-battle blocked by checkflag ENGINE_VOLCANOBADGE / iftrue .FightDone (maps/SeafoamGym.asm:17) +- Seafoam gym guide does not exist until the win branch runs appear SEAFOAMGYM_GYM_GUIDE (maps/SeafoamGym.asm:26); flag EVENT_SEAFOAM_GYM_GYM_GUIDE set at engine/events/std_scripts.asm:553 +- Fly to Cinnabar requires ENGINE_FLYPOINT_CINNABAR, set only by walking onto the map (data/maps/flypoints.asm:28, spawn_points.asm:24) + +**Unresolved (9):** + +- Blaine's movesets in the walkthrough (Yawn/Recover/Overheat, Flare Blitz, Bounce) do not exist in Gen 2 and contradict data/trainers/parties.asm:2303-2308 (CURSE/SMOG/FLAMETHROWER/ROCK_SLIDE, THUNDERPUNCH/FIRE_PUNCH/SUNNY_DAY/CONFUSE_RAY, QUICK_ATTACK/FIRE_SPIN/FURY_ATTACK/FIRE_BLAST). Species and levels match exactly. +- "Two Full Restores" contradicts data/trainers/attributes.asm:276 db MAX_POTION, FULL_HEAL. +- "You now have 15 badges" has no asm anchor; Kanto gym order is unenforced. +- Route 21 wild list abridged: asm also has MR__MIME in grass (kanto_grass.asm:1005) and TENTACRUEL in water (kanto_water.asm:61); Route 20 also has TENTACRUEL. +- Surf paths ("way left then down" on Route 21, the cave mouth on Route 20) are block data in maps/Route21.blk / maps/Route20.blk and were not decoded; only the trainer/warp coordinates are verified. +- No MAP_SEAFOAM_ISLANDS dungeon exists in Gen 2 - constants/map_constants.asm has only SEAFOAM_GYM; "Seafoam Islands" is LANDMARK_SEAFOAM_ISLANDS (data/maps/landmarks.asm:94). +- Per-mon EXP figures are not stored anywhere in the asm (money figures all reproduce exactly as base reward x last-mon level x 4). +- EVENT_BEAT_BLAINE has no reader anywhere in maps/, data/, engine/, home/ or constants/ - ENGINE_VOLCANOBADGE is the flag the cart branches on. +- Port gap (not a walkthrough issue): setflag ENGINE_VOLCANOBADGE lands on save.engineFlags but nothing writes save.player.kantoBadges, so the badge never reaches the trainer card or VAR_BADGES; BadgeTypeBoosts (FIRE +12.5%) is absent from src/battle/gen2/; movement byte $4c (teleport_from, Blue's exit) decodes to nop in src/script/gen2/Movement.lua. + +### 28. [routes 19 20 and viridian city gym](section-28-routes-19-20-and-viridian-city-gym.md) + +Maps the optional Swimmer sweep across Routes 20 and 19 and the Viridian Gym fight with Blue onto pokegold, with verbatim warp/coord/bg/object tables, map headers, connections, trainer parties, water and fishing tables, and the exact opcode flow of ViridianGymBlueScript. Ends with the real gates (FOGBADGE Surf, the Route 20 rock-clearing callback, EVENT_VIRIDIAN_GYM_BLUE hiding both gym objects, STORMBADGE/flypoint Fly), a literal 18-step bot checklist, and a port-coverage table for src/world/gen2, src/script/gen2 and src/battle/gen2. + +**Milestones:** EARTHBADGE (ENGINE_EARTHBADGE) from Blue in Viridian Gym - the 16th badge; EVENT_BEAT_BLUE; EVENT_BEAT_SWIMMERF_LORI; EVENT_BEAT_SWIMMERF_NICOLE; EVENT_BEAT_SWIMMERM_TUCKER; EVENT_BEAT_SWIMMERF_DAWN; EVENT_BEAT_SWIMMERM_JEROME; TM42 Dream Eater from VIRIDIANCITY_FISHER (optional, not in the FAQ) + +**Maps:** `MAP_ROUTE_20`, `MAP_ROUTE_19`, `MAP_ROUTE_19_FUCHSIA_GATE`, `MAP_VIRIDIAN_CITY`, `MAP_VIRIDIAN_POKECENTER_1F`, `MAP_VIRIDIAN_GYM` + +**Gates:** + +- ENGINE_FOGBADGE + SURF in party to reach Routes 19/20 at all (engine/events/overworld.asm:322 SurfFunction, :469 TrySurfOW) +- EVENT_CINNABAR_ROCKS_CLEARED gates the six changeblock $7a WALL rocks on northern Route 19 (maps/Route19.asm:15); set unconditionally by entering Route 20 (maps/Route20.asm:12 MAPCALLBACK_NEWMAP) +- EVENT_VIRIDIAN_GYM_BLUE must be CLEAR or the gym is empty - both ViridianGym objects carry it; set at new game by engine/events/std_scripts.asm:552, cleared only by maps/CinnabarIsland.asm:23 CinnabarIslandBlue +- Fly to Viridian needs ENGINE_STORMBADGE + FLY + ENGINE_FLYPOINT_VIRIDIAN, which ViridianCityFlypointCallback only sets on a first on-foot visit +- ENGINE_EARTHBADGE checkflag makes the Blue battle one-shot (maps/ViridianGym.asm:13); no rematch or phone entry +- Next section: maps/OaksLab.asm:26 readvar VAR_BADGES / ifequal NUM_BADGES gates EVENT_OPENED_MT_SILVER on this badge + +**Unresolved (6):** + +- The FAQ's 'blocked by boulders south of Fuchsia City' contradicts the asm: Route20ClearRocksCallback sets EVENT_CINNABAR_ROCKS_CLEARED on first entry to Route 20, so the rocks are already gone by the time the walkthrough reaches Route 19 +- Route 19 declares connection north FuchsiaCity (data/maps/attributes.asm:288) as well as a warp to Route19FuchsiaGate; I did not decode Route19.blk to see whether the north edge is actually walkable +- The FAQ says Route 19 has no further trainers, but TrainerSwimmermHarold (13,28) and TrainerSwimmermCameron (12,13, Route 20) both exist with sight range 3 +- Blue's strategy prose cites Air Slash (nonexistent in Gen 2) and Exeggutor's Psychic/Hypnosis; BlueGroup gives Pidgeot WING_ATTACK and Exeggutor SUNNY_DAY/LEECH_SEED/EGG_BOMB/SOLARBEAM +- 'One of only two trainers tougher than Champion Lance' has no asm counterpart +- ViridianGym's maps.asm row sets the phone column TRUE; no phone_call names BLUE, but data/phone/ was not read exhaustively + +### 29. [routes 22 28](section-29-routes-22-28.md) + +Covers the post-Kanto run to Mt. Silver: Fly to Pallet Town, talk to Prof. Oak with 16 badges to set EVENT_OPENED_MT_SILVER, Fly to Viridian and Cut west for TM42 Dream Eater, cross Route 22 to the Victory Road Gate, then west to Route 28, the Mt. Silver Pokecenter, and TM47 Steel Wing. Every map's warp/coord/bg/object tables are transcribed verbatim, and the Cut trees, ledges and NPC blockers were verified cell-by-cell against the .blk block data and the kanto/gate tileset collision tables. + +**Maps:** `MAP_PALLET_TOWN`, `MAP_OAKS_LAB`, `MAP_VIRIDIAN_CITY`, `MAP_ROUTE_22`, `MAP_VICTORY_ROAD_GATE`, `MAP_ROUTE_28`, `MAP_ROUTE_28_STEEL_WING_HOUSE`, `MAP_SILVER_CAVE_OUTSIDE`, `MAP_SILVER_CAVE_POKECENTER_1F` + +**Gates:** + +- EVENT_OPENED_MT_SILVER hides VICTORYROADGATE_BLACK_BELT1 at cell (7,5), the only tile joining the gate's centre corridor to its west vestibule and therefore to the ROUTE_28 warps at (1,7)/(2,7). Oak sets it only when VAR_BADGES == NUM_BADGES (16), via `ifequal NUM_BADGES` in maps/OaksLab.asm:27. This, not the cave mouth, is what gates Mt. Silver. +- EVENT_FOUGHT_SNORLAX hides VICTORYROADGATE_BLACK_BELT2 at (12,5), the equivalent chokepoint to the ROUTE_22 vestibule. Already set by this point, but a flag-cleared test save walls itself into the gate. +- HM01 Cut + ENGINE_HIVEBADGE (engine/events/overworld.asm:1741 TryCutOW): required at ViridianCity cell (8,22) for the TM42 Dream Eater fisher, and at SilverCaveOutside cells (31,24) and (34,23) - BOTH trees - to reach ROUTE_28 (0,3), the Steel Wing house and the hidden Rare Candy. +- HM02 Fly + ENGINE_STORMBADGE (engine/events/overworld.asm:545) plus the ENGINE_FLYPOINT_PALLET / _VIRIDIAN / _SILVER_CAVE bits set by each map's MAPCALLBACK_NEWMAP. +- Victory Road Gate coord event (10,11) needs >= 8 Johto badges, but the Route 22 -> Route 28 path never crosses it. +- Ledge hopping: Route 22 has no ledge-free route to the gate warp - (35,12) and (18,6) are COLL_HOP_DOWN over COLL_WALL. The Route 28 west strip likewise exits only over the y=4 ledge row, one-way. + +**Unresolved (8):** + +- FAQ files TM42 Dream Eater under 'Items on Route 22'; the NPC is actually ViridianCity object (6,23). maps/Route22.asm has an empty def_object_events. +- FAQ says there is a Pokemon Center on Route 28; it is on MAP_SILVER_CAVE_OUTSIDE (warp 1 at (23,19)), one map west. +- FAQ says TM47 Steel Wing is a Route 28 item; it is inside ROUTE_28_STEEL_WING_HOUSE. Route 28's only item is the hidden Rare Candy at (25,2), which the FAQ omits. +- FAQ's Route 28 species list omits Ursaring (Gold's 20% slot where Silver has Donphan) and Doduo/Dodrio, and does not say Sneasel is nite-only. +- FAQ's Route 22 list omits Doduo, Ponyta and Fearow. +- The two-cell ledge jump distance was inferred from the collision permission table ($a0..$a7 = LAND_TILE) plus the fact that every ledge on these maps has COLL_WALL directly beyond it; the DoPlayerMovement ledge branch in engine/overworld/player_movement.asm was not read line by line. All Route 22 / Route 28 waypoints depend on that inference. +- SilverCaveOutside's east edge is also walkable at y=14..18 and y=33..35, mapping to Route 28 y=-4..0 and y=15..17, which are outside every reachable component - assumed filler, not chased. +- Nothing in the asm checks the bag, so the FAQ's 'bring 10 Revives / 50 Ultra Balls' is advice, not a gate. + +### 30. [lapras](section-30-lapras.md) + +Maps the Friday-only static Lapras hunt onto the pokegold disassembly: the Route 32 approach, the surf route down through Union Cave 1F and B1F, and the full Union Cave B2F event tables, with UnionCaveB2FLaprasCallback's weekday/daily-flag logic and UnionCaveLapras's unconditional disappear+setflag transcribed opcode by opcode. Includes verbatim warp/bg/object tables for all three floors, the five trainers' parties and prize-money arithmetic (base x last level x 4, which reproduces the walkthrough's gold figures exactly), the grass and water wild tables, and an honest port-coverage audit that flags item-ball pickup and Gen 2 Escape Rope as missing. + +**Maps:** `MAP_UNION_CAVE_1F`, `MAP_UNION_CAVE_B1F`, `MAP_UNION_CAVE_B2F`, `MAP_ROUTE_32` + +**Gates:** + +- Surf field move: SurfFunction.TrySurf (engine/events/overworld.asm:322) checks ENGINE_FOGBADGE, so the Fog Badge plus a mon knowing SURF is required to reach any of the three floors' water legs, including Lapras at UNION_CAVE_B2F 11,31 +- Friday-only: UnionCaveB2FLaprasCallback (maps/UnionCaveB2F.asm:15, sym 44:575e) runs readvar VAR_WEEKDAY / ifequal FRIDAY under MAPCALLBACK_OBJECTS, re-evaluated on every B2F map load +- Daily one-shot: the same callback's checkflag ENGINE_UNION_CAVE_LAPRAS (constants/engine_flags.asm:107, wDailyFlags2 bit DAILYFLAGS2_UNION_CAVE_LAPRAS_F) hides Lapras once UnionCaveLapras has run; cleared only by CheckDailyResetTimer (engine/overworld/time.asm:88) +- Unconditional consumption: UnionCaveLapras runs disappear + setflag after startbattle with no wBattleResult guard, so a KO, a flee or a blackout burns the encounter for the day - save before pressing A +- Object visibility inversion: EVENT_UNION_CAVE_B2F_LAPRAS SET means hidden (CheckObjectFlag, engine/overworld/map_objects_2.asm:31); appear clears it, disappear sets it +- Single exit: UNION_CAVE_B2F has exactly one warp (5,3 -> UNION_CAVE_B1F warp 5); leaving otherwise needs Escape Rope / Dig +- Port blocker: OBJECTTYPE_ITEMBALL objects have no consumer in this repo (extractor sets obj.itemball at src/import/RomExtractorGen2.lua:2969, World:talk only dispatches scriptKey at src/world/gen2/World.lua:5276), so the B2F Elixer and Hyper Potion cannot be picked up +- Port blocker: no Gen 2 Escape Rope path found under src/core/gen2 or src/world/gen2 (only the Gen 1 route in src/ui/PartyMenu.lua:634) + +**Unresolved (8):** + +- EXP figures quoted per mon (637 Marowak, 975 Kangaskhan, 355/511/924/928/933/786 and two '?' entries) are runtime-computed from base EXP x level, not stored in any trainer table, so none could be pinned to an asm row +- Walkthrough lists Nick as Charmander/Bulbasaur/Squirtle and Gwen as Eevee/Vaporeon/Jolteon/Flareon; data/trainers/parties.asm:742 has Charmander/Squirtle/Bulbasaur and :867 has Eevee/Flareon/Vaporeon/Jolteon - same sets, different order (matters because Gwen's last row, Jolteon L22, sets the prize level) +- 'once you've gotten HM Surf' implies a scripted gate that does not exist; no HM or badge check appears in UnionCaveB2FLaprasCallback or UnionCaveLapras, only reachability via SurfFunction.TrySurf's ENGINE_FOGBADGE test +- In-game NPC hints disagree with the code: PokemaniacLarryAfterBattleText says 'Every Friday' (matches the callback) while FirebreatherBillAfterBattleText says 'On weekends'; the walkthrough follows Larry and is correct +- Identification of 'the guy on the cliff still spinning around' as Firebreather Bill (UNIONCAVE1F_FISHER2 at 15,15, SPRITEMOVEDATA_SPINRANDOM_FAST) is inferred from coordinates and movement data, not stated anywhere; walking order depends on UnionCave1F.blk, which was not decoded +- Which 1F ladder the walkthrough means is inferred: warp 2 at 3,33 -> UNION_CAVE_B1F warp 4 at 3,33, chosen because Andrew stands at B1F 5,32; warp 1 at 5,19 lands nowhere near him +- B2F 'right side' / 'left side' landings are consistent with the object coordinates (12,19 and 17,23 right; 5,13 and 3,28 left) but the actual land/water split comes from UnionCaveB2F.blk, which was not decoded +- Walkthrough omits the B2F Elixer item ball at 16,2 (UnionCaveB2FElixer, EVENT_UNION_CAVE_B2F_ELIXER) entirely - an omission, not a contradiction + +### 31. [mount silver and red](section-31-mount-silver-and-red.md) + +Maps the final walkthrough stretch - Route 28's west edge into Mt. Silver, the three Silver Cave floors, the two-chamber item map, and the Red battle - onto pokegold, with verbatim warp/bg/object tables, the Red script's opcode-by-opcode control flow, RedGroup's party and prize-money maths, and the full johto_grass/johto_water entries with their morn/day/nite and Gold-vs-Silver splits. Gates are traced to enforcing code (VictoryRoadGate badge coord_event, PALETTE_DARK/Flash, Surf/Waterfall badge checks, EVENT_RED_IN_MT_SILVER object masking), and port coverage is marked per beat against src/world/gen2, src/script/gen2 and src/import/RomExtractorGen2.lua. + +**Milestones:** No badge awarded in this section; Red (RED/RED1) defeated in SilverCaveRoom3 - final boss; credits opcode runs, wSpawnAfterChampion = SPAWN_RED, post-credits respawn at SPAWN_MT_SILVER + +**Maps:** `MAP_SILVER_CAVE_OUTSIDE`, `MAP_SILVER_CAVE_POKECENTER_1F`, `MAP_SILVER_CAVE_ROOM_1`, `MAP_SILVER_CAVE_ROOM_2`, `MAP_SILVER_CAVE_ITEM_ROOMS`, `MAP_SILVER_CAVE_ROOM_3`, `MAP_VICTORY_ROAD_GATE` + +**Gates:** + +- VictoryRoadGate coord_event (10,11) SCENE_VICTORYROADGATE_BADGE_CHECK -> VictoryRoadGateBadgeCheckScript: readvar VAR_BADGES / ifgreater NUM_JOHTO_BADGES - 1 +- EVENT_RED_IN_MT_SILVER must be CLEAR for Red's object to appear; cleared only by maps/HallOfFame.asm:36 (Elite Four induction), set by InitializeEventsScript at new game and re-set by disappear after the fight +- SilverCaveRoom1 is PALETTE_DARK -> ReplaceTimeOfDayPals .NeedsFlash; needs STATUSFLAGS_FLASH_F via FlashFunction (ENGINE_ZEPHYRBADGE + HM05) +- ENGINE_FOGBADGE + SURF (SurfFunction.TrySurf) to cross both SilverCaveRoom2 lakes +- ENGINE_RISINGBADGE + WATERFALL (WaterfallFunction.TryWaterfall / CheckMapCanWaterfall) to reach either SilverCaveItemRooms chamber +- Gen 2 ledge hop (.TryJump / HI_NYBBLE_LEDGES on the STANDING tile) required to leave the Escape Rope ledge row in SilverCaveRoom1 + +**Unresolved (9):** + +- Walkthrough lists Protein as a Mt. Silver item; no PROTEIN appears in any Silver Cave map asm +- Walkthrough lists Donphan (#232) - that is the _SILVER column; Gold has Ursaring in those slots +- Walkthrough says Red carries three Full Restores; data/trainers/attributes.asm gives the RED class exactly two item slots +- Red's party order in the walkthrough (Pikachu, Espeon, Blastoise, Snorlax, Charizard, Venusaur) differs from RedGroup (Pikachu, Espeon, Snorlax, Venusaur, Charizard, Blastoise); the quoted per-mon EXP yields are runtime-computed and not in any table +- Walkthrough calls Espeon a dark type immune to ghost moves; data/pokemon/base_stats/espeon.asm:6 is PSYCHIC_TYPE, PSYCHIC_TYPE +- Walkthrough says Red 'flies away'; the Red script only does FadeOutToBlack / disappear / FadeInFromBlack, no applymovement +- Which SilverCaveItemRooms chamber the Surf+Waterfall route reaches was derived by expanding the .blk files through data/tilesets/cave_collision.asm, not stated in asm - route coordinates should be confirmed in-game +- EVENT_OPENED_MT_SILVER (set by maps/OaksLab.asm on 16 badges) has no consumer on the Route 28 / Silver Cave path; its only reader is the hide-flag of a Black Belt in maps/VictoryRoadGate.asm:118, so the commonly-cited 16-badge entry requirement could not be pinned to enforcing code +- WARP_CARPET_DOWN collision cells with no warp_event: (11,27) and (13,27) in SilverCaveRoom1, (11,33) in SilverCaveRoom3 + +### 32. [raikou entei and suicune](section-32-raikou-entei-and-suicune.md) + +Maps the roaming-legendary hunt onto pokegold: the roam_struct WRAM layout, InitRoamMons starting routes (Raikou 42 / Entei 37 / Suicune 38), the verbatim 16-entry RoamMaps graph, the shared-random-byte .Update walk, and the exact map-setup-script table showing that connections and door warps run UpdateRoamMons while Fly/Teleport and Continue run JumpRoamMons. Adds full warp/coord/bg/object transcriptions and wild tables for the five maps the walkthrough names (Violet City, Routes 35/36/37/42), the catch and flee math including two shipped-ROM bugs, and an honest port-coverage audit that lists five concrete gaps. + +**Maps:** `MAP_ROUTE_29`, `MAP_ROUTE_30`, `MAP_ROUTE_31`, `MAP_ROUTE_32`, `MAP_ROUTE_33`, `MAP_ROUTE_34`, `MAP_ROUTE_35`, `MAP_ROUTE_36`, `MAP_ROUTE_37`, `MAP_ROUTE_38`, `MAP_ROUTE_39`, `MAP_ROUTE_42`, `MAP_ROUTE_43`, `MAP_ROUTE_44`, `MAP_ROUTE_45`, `MAP_ROUTE_46`, `MAP_VIOLET_CITY`, `MAP_ROUTE_40`, `MAP_ROUTE_41`, `MAP_BURNED_TOWER_B1F` + +**Gates:** + +- EVENT_RELEASED_THE_BEASTS / special InitRoamMons at maps/BurnedTowerB1F.asm:65 is the only thing that creates the three roam_structs; without it no beast exists anywhere +- CheckEncounterRoamMon's CheckOnWater bail: roamers can never be met while surfing +- RoamMaps (data/wild/roammon_maps.asm) restricts roamers to 16 Johto routes; ROUTE_40 and ROUTE_41 are deliberately absent +- LoadWildMonDataPointer must succeed before CheckEncounterRoamMon runs, so maps with no wild table can never host a beast +- ChooseWildEncounter_BugContest never calls CheckEncounterRoamMon, so no beast can appear during the Bug Catching Contest +- SudowoodoScript on Route 36 (tile x=35,y=9) needs SQUIRTBOTTLE; EVENT_ROUTE_36_SUDOWOODO blocks the Violet/Route 36/Route 37 loop until cleared +- AlwaysFleeMons (data/wild/flee_mons.asm) is exactly RAIKOU/ENTEI/SUICUNE, so TryEnemyFlee ends the battle after one player action unless SUBSTATUS_CANT_RUN, a live wEnemyWrapCount, SLP or FRZ applies +- CheckRepelEffect compares wCurPartyLevel (40) against the lead's level: a lead of level 41 or higher repels the roamer itself, which is why the walkthrough specifies a level 39 Scyther +- FastBallMultiplier bug (engine/items/item_effects.asm:986) means Fast Balls give no bonus on the beasts; ULTRA_BALL x2 or MASTER_BALL only +- Paralysis gives no catch-rate bonus at all (commented-out wEnemyMonStatus reload in .statuscheck); only SLP/FRZ give +10 +- All three beasts know ROAR at level 40; BattleCommand_ForceSwitch's wild branch ends the battle as a DRAW against any lead at level 40 or below + +**Unresolved (7):** + +- Walkthrough says roamers 'show up on the map in Pokegear'; FindNest's only caller is Pokedex_GetArea (engine/pokegear/pokegear.asm:2430, 24:5c7f), i.e. the Pokedex AREA screen drawn with town-map graphics, not the Pokegear map menu +- Walkthrough says tracking becomes possible 'after the initial encounter'; FindNest has no encountered gate, and nothing in maps/BurnedTowerB1F.asm marks the beasts SEEN during ReleaseTheBeasts, so the SEEN requirement is inferred rather than proven by a SetSeenMon call +- Walkthrough advises catching while 'asleep or paralyzed'; the .statuscheck block (engine/items/item_effects.asm:344) gives +10 for SLP/FRZ but nothing for PAR/BRN/PSN because the wEnemyMonStatus reload is commented out +- Walkthrough says roamers 'occasionally jump a few routes'; the only jump in the asm is the 1-in-32 and %00011111 branch into JumpRoamMon, which is a uniformly random one of the 16 entries, not an adjacent hop +- Walkthrough's 'as quickly as every 10 seconds' has no basis in code: roam movement is purely event-driven off map setup scripts and battle ends, with no timer anywhere +- Whether wCurPartyLevel still holds the wild mon's level at BattleCommand_ForceSwitch time after a mid-battle player switch was not traced; the guaranteed-Roar claim is asserted from the code as written +- Walkthrough leans on biking; CanEncounterWildMon (engine/overworld/events.asm:1164) and GetMapEncounterRate have no bicycle term, so the bike affects travel speed only + +## Map -> section lookup + +| Map constant | Sections | +|---|---| +| `MAP_AZALEA_GYM` | 04 | +| `MAP_AZALEA_POKECENTER_1F` | 04 | +| `MAP_AZALEA_TOWN` | 04, 05 | +| `MAP_BILLS_FAMILYS_HOUSE` | 05 | +| `MAP_BILLS_HOUSE` | 21 | +| `MAP_BLACKTHORN_CITY` | 13, 15 | +| `MAP_BLACKTHORN_DRAGON_SPEECH_HOUSE` | 13 | +| `MAP_BLACKTHORN_EMYS_HOUSE` | 13 | +| `MAP_BLACKTHORN_GYM_1F` | 13 | +| `MAP_BLACKTHORN_GYM_2F` | 13 | +| `MAP_BLACKTHORN_MART` | 13 | +| `MAP_BLACKTHORN_POKECENTER_1F` | 13 | +| `MAP_BRUNOS_ROOM` | 18 | +| `MAP_BURNED_TOWER_B1F` | 32 | +| `MAP_CELADON_CAFE` | 22 | +| `MAP_CELADON_CITY` | 22 | +| `MAP_CELADON_DEPT_STORE_1F` | 22 | +| `MAP_CELADON_DEPT_STORE_2F` | 22 | +| `MAP_CELADON_DEPT_STORE_3F` | 22 | +| `MAP_CELADON_DEPT_STORE_4F` | 22 | +| `MAP_CELADON_DEPT_STORE_5F` | 22 | +| `MAP_CELADON_DEPT_STORE_6F` | 22 | +| `MAP_CELADON_GAME_CORNER` | 22 | +| `MAP_CELADON_GAME_CORNER_PRIZE_ROOM` | 22 | +| `MAP_CELADON_GYM` | 22 | +| `MAP_CELADON_MANSION_1F` | 22 | +| `MAP_CELADON_MANSION_2F` | 22 | +| `MAP_CELADON_MANSION_3F` | 22 | +| `MAP_CELADON_MANSION_ROOF` | 22 | +| `MAP_CELADON_MANSION_ROOF_HOUSE` | 22 | +| `MAP_CERULEAN_CITY` | 21 | +| `MAP_CERULEAN_GYM` | 21 | +| `MAP_CHERRYGROVE_CITY` | 01 | +| `MAP_CHERRYGROVE_EVOLUTION_SPEECH_HOUSE` | 01 | +| `MAP_CHERRYGROVE_GYM_SPEECH_HOUSE` | 01 | +| `MAP_CHERRYGROVE_MART` | 01 | +| `MAP_CHERRYGROVE_POKECENTER_1F` | 01 | +| `MAP_CIANWOOD_CITY` | 08 | +| `MAP_CIANWOOD_GYM` | 08 | +| `MAP_CIANWOOD_LUGIA_SPEECH_HOUSE` | 08 | +| `MAP_CIANWOOD_PHARMACY` | 08 | +| `MAP_CIANWOOD_PHOTO_STUDIO` | 08 | +| `MAP_CIANWOOD_POKECENTER_1F` | 08 | +| `MAP_CINNABAR_ISLAND` | 27 | +| `MAP_CINNABAR_POKECENTER_1F` | 27 | +| `MAP_COPYCATS_HOUSE_1F` | 22 | +| `MAP_COPYCATS_HOUSE_2F` | 22 | +| `MAP_DARK_CAVE_BLACKTHORN_ENTRANCE` | 15, 16 | +| `MAP_DARK_CAVE_VIOLET_ENTRANCE` | 01, 15, 16 | +| `MAP_DAY_CARE` | 05 | +| `MAP_DAY_OF_WEEK_SIBLINGS_HOUSE` | 16 | +| `MAP_DIGLETTS_CAVE` | 24 | +| `MAP_DRAGONS_DEN_1F` | 13 | +| `MAP_DRAGONS_DEN_B1F` | 13 | +| `MAP_ECRUTEAK_CITY` | 10, 25 | +| `MAP_ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 14, 25 | +| `MAP_ECRUTEAK_TIN_TOWER_ENTRANCE` | 14, 25 | +| `MAP_ELMS_LAB` | 00, 01, 16, 19 | +| `MAP_FAST_SHIP_1F` | 19 | +| `MAP_FAST_SHIP_B1F` | 19 | +| `MAP_FAST_SHIP_CABINS_NNW_NNE_NE` | 19 | +| `MAP_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN` | 19 | +| `MAP_FAST_SHIP_CABINS_SW_SSW_NW` | 19 | +| `MAP_FIGHTING_DOJO` | 20 | +| `MAP_FUCHSIA_CITY` | 23 | +| `MAP_FUCHSIA_GYM` | 23 | +| `MAP_FUCHSIA_POKECENTER_1F` | 23 | +| `MAP_GOLDENROD_BIKE_SHOP` | 05 | +| `MAP_GOLDENROD_CITY` | 05, 09, 12 | +| `MAP_GOLDENROD_DEPT_STORE_1F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_2F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_3F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_4F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_5F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_6F` | 05 | +| `MAP_GOLDENROD_DEPT_STORE_B1F` | 12 | +| `MAP_GOLDENROD_FLOWER_SHOP` | 05, 06 | +| `MAP_GOLDENROD_GAME_CORNER` | 05 | +| `MAP_GOLDENROD_GYM` | 05 | +| `MAP_GOLDENROD_MAGNET_TRAIN_STATION` | 05 | +| `MAP_GOLDENROD_POKECENTER_1F` | 05 | +| `MAP_GOLDENROD_UNDERGROUND` | 05, 09, 12 | +| `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 05, 09, 12 | +| `MAP_GOLDENROD_UNDERGROUND_WAREHOUSE` | 12 | +| `MAP_GUIDE_GENTS_HOUSE` | 01 | +| `MAP_HALL_OF_FAME` | 18 | +| `MAP_ICE_PATH_1F` | 13 | +| `MAP_ICE_PATH_B1F` | 13 | +| `MAP_ICE_PATH_B2F_BLACKTHORN_SIDE` | 13 | +| `MAP_ICE_PATH_B2F_MAHOGANY_SIDE` | 13 | +| `MAP_ICE_PATH_B3F` | 13 | +| `MAP_ILEX_FOREST` | 05 | +| `MAP_ILEX_FOREST_AZALEA_GATE` | 05 | +| `MAP_INDIGO_PLATEAU_POKECENTER_1F` | 18 | +| `MAP_KARENS_ROOM` | 18 | +| `MAP_KOGAS_ROOM` | 18 | +| `MAP_KURTS_HOUSE` | 04 | +| `MAP_LAKE_OF_RAGE` | 10 | +| `MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE` | 10 | +| `MAP_LAKE_OF_RAGE_MAGIKARP_HOUSE` | 10 | +| `MAP_LANCES_ROOM` | 18 | +| `MAP_LAVENDER_TOWN` | 22 | +| `MAP_LAV_RADIO_TOWER_1F` | 22 | +| `MAP_MAHOGANY_GYM` | 11 | +| `MAP_MAHOGANY_MART_1F` | 10, 11 | +| `MAP_MAHOGANY_POKECENTER_1F` | 10, 11 | +| `MAP_MAHOGANY_RED_GYARADOS_SPEECH_HOUSE` | 10 | +| `MAP_MAHOGANY_TOWN` | 10, 11, 15 | +| `MAP_MANIAS_HOUSE` | 08 | +| `MAP_MOUNT_MOON` | 26 | +| `MAP_MOUNT_MOON_GIFT_SHOP` | 26 | +| `MAP_MOUNT_MOON_SQUARE` | 26 | +| `MAP_MOUNT_MORTAR_1F_INSIDE` | 15 | +| `MAP_MOUNT_MORTAR_1F_OUTSIDE` | 15 | +| `MAP_MOUNT_MORTAR_2F_INSIDE` | 15 | +| `MAP_MOUNT_MORTAR_B1F` | 15 | +| `MAP_MOVE_DELETERS_HOUSE` | 13 | +| `MAP_MR_POKEMONS_HOUSE` | 01 | +| `MAP_MR_PSYCHICS_HOUSE` | 20 | +| `MAP_NATIONAL_PARK` | 06 | +| `MAP_NATIONAL_PARK_BUG_CONTEST` | 06 | +| `MAP_NEW_BARK_TOWN` | 00, 01, 16, 19 | +| `MAP_OAKS_LAB` | 26, 29 | +| `MAP_OLIVINE_CAFE` | 08 | +| `MAP_OLIVINE_CITY` | 08, 09, 19 | +| `MAP_OLIVINE_GOOD_ROD_HOUSE` | 08 | +| `MAP_OLIVINE_GYM` | 09 | +| `MAP_OLIVINE_LIGHTHOUSE_1F` | 08, 09 | +| `MAP_OLIVINE_LIGHTHOUSE_2F` | 08, 09 | +| `MAP_OLIVINE_LIGHTHOUSE_3F` | 08, 09 | +| `MAP_OLIVINE_LIGHTHOUSE_4F` | 08, 09 | +| `MAP_OLIVINE_LIGHTHOUSE_5F` | 08, 09 | +| `MAP_OLIVINE_LIGHTHOUSE_6F` | 08, 09 | +| `MAP_OLIVINE_MART` | 08 | +| `MAP_OLIVINE_POKECENTER_1F` | 08 | +| `MAP_OLIVINE_PORT` | 19 | +| `MAP_OLIVINE_PORT_PASSAGE` | 19 | +| `MAP_PALLET_TOWN` | 26, 29 | +| `MAP_PEWTER_CITY` | 24 | +| `MAP_PEWTER_GYM` | 24 | +| `MAP_PEWTER_POKECENTER_1F` | 24 | +| `MAP_PLAYERS_HOUSE_1F` | 00, 01 | +| `MAP_PLAYERS_HOUSE_2F` | 00 | +| `MAP_POKEMON_FAN_CLUB` | 19, 22 | +| `MAP_POWER_PLANT` | 21, 22 | +| `MAP_RADIO_TOWER_1F` | 05, 12 | +| `MAP_RADIO_TOWER_2F` | 12 | +| `MAP_RADIO_TOWER_3F` | 12 | +| `MAP_RADIO_TOWER_4F` | 12 | +| `MAP_RADIO_TOWER_5F` | 12 | +| `MAP_ROCK_TUNNEL_1F` | 22 | +| `MAP_ROCK_TUNNEL_B1F` | 22 | +| `MAP_ROUTE_1` | 26 | +| `MAP_ROUTE_10_NORTH` | 21, 22 | +| `MAP_ROUTE_10_SOUTH` | 22 | +| `MAP_ROUTE_11` | 23 | +| `MAP_ROUTE_12` | 23 | +| `MAP_ROUTE_12_SUPER_ROD_HOUSE` | 23 | +| `MAP_ROUTE_13` | 23 | +| `MAP_ROUTE_14` | 23 | +| `MAP_ROUTE_15` | 23 | +| `MAP_ROUTE_15_FUCHSIA_GATE` | 23 | +| `MAP_ROUTE_16` | 23 | +| `MAP_ROUTE_16_FUCHSIA_SPEECH_HOUSE` | 23 | +| `MAP_ROUTE_16_GATE` | 23 | +| `MAP_ROUTE_17` | 23 | +| `MAP_ROUTE_17_ROUTE_18_GATE` | 23 | +| `MAP_ROUTE_18` | 23 | +| `MAP_ROUTE_19` | 24, 28 | +| `MAP_ROUTE_19_FUCHSIA_GATE` | 24, 28 | +| `MAP_ROUTE_2` | 24 | +| `MAP_ROUTE_20` | 24, 27, 28 | +| `MAP_ROUTE_21` | 27 | +| `MAP_ROUTE_22` | 29 | +| `MAP_ROUTE_23` | 17, 18 | +| `MAP_ROUTE_24` | 21 | +| `MAP_ROUTE_25` | 21 | +| `MAP_ROUTE_26` | 16 | +| `MAP_ROUTE_26_HEAL_HOUSE` | 16 | +| `MAP_ROUTE_27` | 16 | +| `MAP_ROUTE_27_SANDSTORM_HOUSE` | 16 | +| `MAP_ROUTE_28` | 29 | +| `MAP_ROUTE_28_STEEL_WING_HOUSE` | 29 | +| `MAP_ROUTE_29` | 01, 32 | +| `MAP_ROUTE_29_ROUTE_46_GATE` | 01, 16 | +| `MAP_ROUTE_2_GATE` | 24 | +| `MAP_ROUTE_2_NUGGET_HOUSE` | 24 | +| `MAP_ROUTE_3` | 26 | +| `MAP_ROUTE_30` | 01, 32 | +| `MAP_ROUTE_30_BERRY_HOUSE` | 01 | +| `MAP_ROUTE_31` | 01, 15, 32 | +| `MAP_ROUTE_31_VIOLET_GATE` | 01 | +| `MAP_ROUTE_32` | 03, 30, 32 | +| `MAP_ROUTE_32_POKECENTER_1F` | 03 | +| `MAP_ROUTE_32_RUINS_OF_ALPH_GATE` | 03 | +| `MAP_ROUTE_33` | 03, 32 | +| `MAP_ROUTE_34` | 05, 32 | +| `MAP_ROUTE_34_ILEX_FOREST_GATE` | 05 | +| `MAP_ROUTE_35` | 06, 32 | +| `MAP_ROUTE_35_GOLDENROD_GATE` | 06 | +| `MAP_ROUTE_35_NATIONAL_PARK_GATE` | 06 | +| `MAP_ROUTE_36` | 06, 32 | +| `MAP_ROUTE_36_NATIONAL_PARK_GATE` | 06 | +| `MAP_ROUTE_36_RUINS_OF_ALPH_GATE` | 03 | +| `MAP_ROUTE_37` | 06, 32 | +| `MAP_ROUTE_38` | 08, 32 | +| `MAP_ROUTE_38_ECRUTEAK_GATE` | 08 | +| `MAP_ROUTE_39` | 08, 32 | +| `MAP_ROUTE_39_BARN` | 08 | +| `MAP_ROUTE_39_FARMHOUSE` | 08 | +| `MAP_ROUTE_4` | 26 | +| `MAP_ROUTE_40` | 08, 32 | +| `MAP_ROUTE_41` | 08, 14, 25, 32 | +| `MAP_ROUTE_42` | 10, 15, 32 | +| `MAP_ROUTE_42_ECRUTEAK_GATE` | 10 | +| `MAP_ROUTE_43` | 10, 32 | +| `MAP_ROUTE_43_GATE` | 10 | +| `MAP_ROUTE_43_MAHOGANY_GATE` | 10 | +| `MAP_ROUTE_44` | 13, 32 | +| `MAP_ROUTE_45` | 15, 16, 32 | +| `MAP_ROUTE_46` | 01, 15, 16, 32 | +| `MAP_ROUTE_5` | 21 | +| `MAP_ROUTE_5_CLEANSE_TAG_HOUSE` | 21 | +| `MAP_ROUTE_5_SAFFRON_GATE` | 21 | +| `MAP_ROUTE_5_UNDERGROUND_PATH_ENTRANCE` | 21 | +| `MAP_ROUTE_6` | 20 | +| `MAP_ROUTE_6_SAFFRON_GATE` | 20 | +| `MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE` | 20 | +| `MAP_ROUTE_7` | 22 | +| `MAP_ROUTE_7_SAFFRON_GATE` | 22 | +| `MAP_ROUTE_8` | 22 | +| `MAP_ROUTE_8_SAFFRON_GATE` | 22 | +| `MAP_ROUTE_9` | 21 | +| `MAP_RUINS_OF_ALPH_AERODACTYL_CHAMBER` | 03 | +| `MAP_RUINS_OF_ALPH_INNER_CHAMBER` | 03 | +| `MAP_RUINS_OF_ALPH_KABUTO_CHAMBER` | 03 | +| `MAP_RUINS_OF_ALPH_OUTSIDE` | 03 | +| `MAP_RUINS_OF_ALPH_RESEARCH_CENTER` | 03 | +| `MAP_SAFFRON_CITY` | 20, 22 | +| `MAP_SAFFRON_GYM` | 20 | +| `MAP_SAFFRON_MAGNET_TRAIN_STATION` | 20, 22 | +| `MAP_SAFFRON_MART` | 20 | +| `MAP_SAFFRON_POKECENTER_1F` | 20 | +| `MAP_SEAFOAM_GYM` | 27 | +| `MAP_SILPH_CO_1F` | 20 | +| `MAP_SILVER_CAVE_ITEM_ROOMS` | 31 | +| `MAP_SILVER_CAVE_OUTSIDE` | 29, 31 | +| `MAP_SILVER_CAVE_POKECENTER_1F` | 29, 31 | +| `MAP_SILVER_CAVE_ROOM_1` | 31 | +| `MAP_SILVER_CAVE_ROOM_2` | 31 | +| `MAP_SILVER_CAVE_ROOM_3` | 31 | +| `MAP_SLOWPOKE_WELL_B1F` | 04 | +| `MAP_SLOWPOKE_WELL_B2F` | 04 | +| `MAP_SOUL_HOUSE` | 22 | +| `MAP_SPROUT_TOWER_1F` | 02 | +| `MAP_SPROUT_TOWER_2F` | 02 | +| `MAP_SPROUT_TOWER_3F` | 02 | +| `MAP_TEAM_ROCKET_BASE_B1F` | 11 | +| `MAP_TEAM_ROCKET_BASE_B2F` | 11 | +| `MAP_TEAM_ROCKET_BASE_B3F` | 11 | +| `MAP_TIN_TOWER_1F` | 14, 25 | +| `MAP_TIN_TOWER_2F` | 14, 25 | +| `MAP_TIN_TOWER_3F` | 14, 25 | +| `MAP_TIN_TOWER_4F` | 14, 25 | +| `MAP_TIN_TOWER_5F` | 14, 25 | +| `MAP_TIN_TOWER_6F` | 14, 25 | +| `MAP_TIN_TOWER_7F` | 14, 25 | +| `MAP_TIN_TOWER_8F` | 14, 25 | +| `MAP_TIN_TOWER_9F` | 14, 25 | +| `MAP_TIN_TOWER_ROOF` | 14, 25 | +| `MAP_TOHJO_FALLS` | 16 | +| `MAP_TRAINER_HOUSE_1F` | 26 | +| `MAP_TRAINER_HOUSE_B1F` | 26 | +| `MAP_UNION_CAVE_1F` | 03, 30 | +| `MAP_UNION_CAVE_B1F` | 03, 30 | +| `MAP_UNION_CAVE_B2F` | 03, 30 | +| `MAP_VERMILION_CITY` | 19, 22, 24 | +| `MAP_VERMILION_GYM` | 19 | +| `MAP_VERMILION_POKECENTER_1F` | 24 | +| `MAP_VERMILION_PORT` | 19 | +| `MAP_VERMILION_PORT_PASSAGE` | 19 | +| `MAP_VICTORY_ROAD` | 17 | +| `MAP_VICTORY_ROAD_GATE` | 16, 17, 29, 31 | +| `MAP_VIOLET_CITY` | 01, 02, 32 | +| `MAP_VIOLET_GYM` | 02 | +| `MAP_VIOLET_POKECENTER_1F` | 02, 03 | +| `MAP_VIRIDIAN_CITY` | 26, 28, 29 | +| `MAP_VIRIDIAN_GYM` | 28 | +| `MAP_VIRIDIAN_POKECENTER_1F` | 28 | +| `MAP_WHIRL_ISLAND_B1F` | 14, 25 | +| `MAP_WHIRL_ISLAND_B2F` | 14, 25 | +| `MAP_WHIRL_ISLAND_CAVE` | 14, 25 | +| `MAP_WHIRL_ISLAND_LUGIA_CHAMBER` | 14, 25 | +| `MAP_WHIRL_ISLAND_NE` | 14, 25 | +| `MAP_WHIRL_ISLAND_NW` | 14, 25 | +| `MAP_WHIRL_ISLAND_SE` | 14, 25 | +| `MAP_WHIRL_ISLAND_SW` | 14, 25 | +| `MAP_WILLS_ROOM` | 18 | + diff --git a/docs/gold-walkthrough/asm-walk/_TEMPLATE.md b/docs/gold-walkthrough/asm-walk/_TEMPLATE.md new file mode 100644 index 00000000..34b61843 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/_TEMPLATE.md @@ -0,0 +1,126 @@ +# ASM Walk output format + +Every `section-NN-*.md` in this folder follows the structure below. The goal is a +single place a bot author can look at to answer "for this stretch of the +walkthrough, which maps, warps, objects, scripts, flags, items and trainers are +involved, and where in the pokegold disassembly do they live?" + +Conventions: + +- Disassembly paths are written relative to the pokegold checkout root, e.g. + `maps/NewBarkTown.asm`, `data/maps/maps.asm`, `constants/event_flags.asm`. +- Port paths are written relative to this repo root, e.g. `src/world/gen2/Map.lua`. +- Coordinates are the raw asm values (map-block coordinates as written in the + `warp_event` / `object_event` / `bg_event` rows), not screen pixels. +- Anything not verified by opening the file is omitted rather than guessed. If a + walkthrough claim could not be located in the asm, it goes under + "Unresolved / verify by hand". + +--- + +# Section NN - + +Source: `../section-NN-<slug>.txt` +Maps covered: `MAP_A`, `MAP_B`, ... +Badges / key milestones in this section: ... + +## 1. Route order + +Ordered list of map transitions the walkthrough takes, one row per hop. + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| + +## 2. Maps + +Repeat this block per map. + +### MAP_<NAME> + +- Script: `maps/<Name>.asm` +- Blocks: `maps/<Name>.blk` (if present) +- Header: `data/maps/maps.asm` -> tileset, environment, location, music, phone, palette +- Dimensions / attributes: `constants/map_constants.asm` (`map_const <NAME>, W, H`) +- Connections: north/south/east/west map constants + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| + +**Coord events** (`def_coord_events`) - the scripted trip-wires a bot will hit + +| scene | x | y | script label | effect | +|---|---|---|---|---| + +**BG events** (`def_bg_events`) - signs, hidden items + +| x | y | type | script/item | +|---|---|---|---| + +**Object events** (`def_object_events`) - NPCs, item balls, trainers + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| + +**Scripts of interest** + +For each script label the walkthrough actually depends on: label, a short prose +summary of what the opcodes do, and the flags/items/battles it touches. Cite the +label name so it can be grepped. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| + +Include `EVENT_*` (`constants/event_flags.asm`), `ENGINE_*` +(`constants/engine_flags.asm`), and any `SCENE_*` values +(`constants/map_setup_constants.asm` / the map's own `object_const_def` block). + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| + +**Wild encounters** + +Table entry in `data/wild/johto_grass.asm` / `johto_water.asm` / +`kanto_grass.asm` / `kanto_water.asm`, plus fishing (`data/wild/fish.asm`), +headbutt (`data/wild/treemons.asm`) and rock smash where relevant. Note the +morn/day/nite split. + +## 3. Blockers and gates + +What actually stops forward progress in this section, and the precise check. + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| + +Cover badge checks, HM field-move checks (`engine/overworld/`), key items, +`EVENT_*` guards, and NPC-blocked tiles. + +## 4. Bot checklist + +The machine-actionable version: an ordered list of steps a scripted playthrough +would execute. Each step names the map, the target coordinates or object const, +the input intent (talk / walk / use item / battle), the precondition flag and the +postcondition flag. Keep it literal enough that someone can turn a row into a +driver command without reopening the asm. + +## 5. Port coverage + +Where this section's mechanics land in this repo, and what is missing. + +| Beat | Port file | Status | +|---|---|---| + +## 6. Unresolved / verify by hand + +Anything the walkthrough asserts that could not be pinned to a specific asm +location, plus any contradictions found between the two. diff --git a/docs/gold-walkthrough/asm-walk/section-00-chikorita-cyndaquil-or-totodile.md b/docs/gold-walkthrough/asm-walk/section-00-chikorita-cyndaquil-or-totodile.md new file mode 100644 index 00000000..0984319b --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-00-chikorita-cyndaquil-or-totodile.md @@ -0,0 +1,535 @@ +# Section 00 - Chikorita, Cyndaquil, or Totodile? + +Source: `../section-00-chikorita-cyndaquil-or-totodile.txt` +Maps covered: `MAP_PLAYERS_HOUSE_2F`, `MAP_PLAYERS_HOUSE_1F`, `MAP_NEW_BARK_TOWN`, `MAP_ELMS_LAB` +Badges / key milestones in this section: no badge. Milestones are the new-game +clock set, the #GEAR from Mom (`ENGINE_POKEGEAR`), the first rival encounter, +and the starter (`EVENT_GOT_A_POKEMON_FROM_ELM`), which is what un-gates the +walk west out of New Bark Town. + +Coordinate note: every `warp_event` / `coord_event` / `bg_event` / +`object_event` x,y below is copied verbatim from the map asm. Those are 0-based +walk-grid cells (2 per map block, so a `W`-block-wide map runs x = 0..2W-1), +which is the same grid `src/world/gen2/Map.lua` calls a cell. + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | (no map) | `engine/menus/intro_menu.asm` `NewGame` / `OakSpeech`, `engine/rtc/timeset.asm` `InitClock` | title screen -> NEW GAME | `InitializeWorld` -> `SPAWN_HOME` | "Choose new game and start… Professor Oak will ask you the time… he asks you what your name is" | +| 1 | `MAP_PLAYERS_HOUSE_2F` | `maps/PlayersHouse2F.asm` | spawn `SPAWN_HOME` at (3,3), `data/maps/spawn_points.asm` | warp 1 at (7,0) | "You are in your room at the beginning. Exit the room" | +| 2 | `MAP_PLAYERS_HOUSE_1F` | `maps/PlayersHouse1F.asm` | warp 3 at (9,0) | warp 1 (6,7) or warp 2 (7,7) | "you will meet your mom… You get the Pokegear… input the day of the week" | +| 3 | `MAP_NEW_BARK_TOWN` | `maps/NewBarkTown.asm` | warp 2 at (13,5) | warp 1 at (6,3) | "Leave your house and head left to Professor Elm's lab. Notice the guy standing left of it" (rival at (3,2)) | +| 4 | `MAP_ELMS_LAB` | `maps/ElmsLab.asm` | warp 1 (4,11) / warp 2 (5,11) | warp 1/2 back to New Bark Town | "go into Elm's lab… You get to pick your starter Pokémon" | +| 5 | `MAP_NEW_BARK_TOWN` | `maps/NewBarkTown.asm` | warp 1 at (6,3) | west connection to `ROUTE_29` (`data/maps/attributes.asm`) | section ends here; the walk west to Route 29 / Cherrygrove belongs to section 01 | + +## 2. Maps + +### MAP_PLAYERS_HOUSE_2F + +- Script: `maps/PlayersHouse2F.asm` +- Blocks: `maps/PlayersHouse2F.blk` +- Header (`data/maps/maps.asm:478`): `map PlayersHouse2F, TILESET_PLAYERS_ROOM, INDOOR, LANDMARK_NEW_BARK_TOWN, MUSIC_NEW_BARK_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:443`): `map_const PLAYERS_HOUSE_2F, 4, 3` (4x3 blocks = 8x6 cells) +- Connections: none (indoor) +- Scene var: `wPlayersHouse2FSceneID` is *not* in `data/maps/scenes.asm`; the map has an empty `def_scene_scripts` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 0 | `PLAYERS_HOUSE_1F` | 3 | + +**Coord events** (`def_coord_events`) + +None (the block is empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 1 | `BGEVENT_UP` | `PlayersHousePCScript` | +| 3 | 1 | `BGEVENT_READ` | `PlayersHouseRadioScript` | +| 5 | 1 | `BGEVENT_READ` | `PlayersHouseBookshelfScript` | +| 6 | 0 | `BGEVENT_IFSET` | `PlayersHousePosterScript` (guarded by `EVENT_PLAYERS_ROOM_POSTER`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PLAYERSHOUSE2F_CONSOLE` | `SPRITE_CONSOLE` | 4 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `PlayersHouseGameConsoleScript` | `EVENT_PLAYERS_HOUSE_2F_CONSOLE` | +| `PLAYERSHOUSE2F_DOLL_1` | `SPRITE_DOLL_1` | 4 | 4 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `PlayersHouseDoll1Script` | `EVENT_PLAYERS_HOUSE_2F_DOLL_1` | +| `PLAYERSHOUSE2F_DOLL_2` | `SPRITE_DOLL_2` | 5 | 4 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `PlayersHouseDoll2Script` | `EVENT_PLAYERS_HOUSE_2F_DOLL_2` | +| `PLAYERSHOUSE2F_BIG_DOLL` | `SPRITE_BIG_DOLL` | 0 | 1 | `SPRITEMOVEDATA_BIGDOLL` | `OBJECTTYPE_SCRIPT` | `PlayersHouseBigDollScript` | `EVENT_PLAYERS_HOUSE_2F_BIG_DOLL` | + +**Scripts of interest** + +- `PlayersHouse2FInitializeRoomCallback` (`callback MAPCALLBACK_NEWMAP`): runs + `special ToggleDecorationsVisibility`, sets + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8` (read by `engine/phone/scripts/mom.asm`), + then `checkevent EVENT_INITIALIZED_EVENTS` -> if clear, + `jumpstd InitializeEventsScript`. This is the one-time world seeding for a new + game: `engine/events/std_scripts.asm:438` sets ~70 `EVENT_*` bits including + `EVENT_COP_IN_ELMS_LAB` (which *hides* the officer), `EVENT_RIVAL_CHERRYGROVE_CITY`, + `EVENT_ROUTE_30_YOUNGSTER_JOEY`, and finally `EVENT_INITIALIZED_EVENTS` + (`engine/events/std_scripts.asm:557`) so it never runs again. +- `PlayersHouse2FSetUpTileDecorationsCallback` (`callback MAPCALLBACK_TILES`): + `special ToggleMaptileDecorations`. +- `PlayersHouseRadioScript`: three-branch. Before `EVENT_GOT_A_POKEMON_FROM_ELM` + it plays `MUSIC_POKEMON_TALK`, prints four texts and sets + `EVENT_LISTENED_TO_INITIAL_RADIO`; after that flag it prints only + `PlayersRadioText4`; after `EVENT_GOT_A_POKEMON_FROM_ELM` it becomes the normal + `jumpstd Radio1Script`. Purely flavour, no gate. +- `PlayersHousePCScript`: `special PlayersHousePC`, `iftrue .Warp` -> `warp NONE, 0, 0` + (the "leave the PC by warping in place" reload). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_INITIALIZED_EVENTS` | `constants/event_flags.asm:63` | read by `PlayersHouse2FInitializeRoomCallback`, set by `InitializeEventsScript` | first-load-only world seed; must be clear on a fresh save | +| `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8` | `constants/event_flags.asm` | set here, read by `engine/phone/scripts/mom.asm:13` | "player is at home right now" | +| `EVENT_LISTENED_TO_INITIAL_RADIO` | `constants/event_flags.asm:468` | `PlayersHouseRadioScript` | shortens the radio text on repeat | +| `EVENT_PLAYERS_ROOM_POSTER` | `constants/event_flags.asm:378` | `conditional_event` in `PlayersHousePosterScript` | decoration present | + +**Items**: none on this map. + +**Trainers**: none. + +**Wild encounters**: none (indoor). + +### MAP_PLAYERS_HOUSE_1F + +- Script: `maps/PlayersHouse1F.asm` +- Blocks: `maps/PlayersHouse1F.blk` +- Header (`data/maps/maps.asm:477`): `map PlayersHouse1F, TILESET_PLAYERS_HOUSE, INDOOR, LANDMARK_NEW_BARK_TOWN, MUSIC_NEW_BARK_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:442`): `map_const PLAYERS_HOUSE_1F, 5, 4` (10x8 cells) +- Connections: none (indoor) +- Scene var: `wPlayersHouse1FSceneID` (`data/maps/scenes.asm:30`). Scene ids come + from declaration order in `def_scene_scripts` (`macros/scripts/maps.asm:25`): + `SCENE_PLAYERSHOUSE1F_MEET_MOM` = 0, `SCENE_PLAYERSHOUSE1F_NOOP` = 1. A new game + zeroes the scene, so scene 0 is live on the first visit. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 7 | `NEW_BARK_TOWN` | 2 | +| 2 | 7 | 7 | `NEW_BARK_TOWN` | 2 | +| 3 | 9 | 0 | `PLAYERS_HOUSE_2F` | 1 | + +**Coord events** (`def_coord_events`) + +None. The Mom cutscene is a **scene script**, not a coord event: `scene_script +PlayersHouse1FMeetMomScene, SCENE_PLAYERSHOUSE1F_MEET_MOM` -> `sdefer MeetMomScript`, +so it fires on map entry from the stairs while the scene id is still 0. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `PlayersHouse1FStoveScript` | +| 1 | 1 | `BGEVENT_READ` | `PlayersHouse1FSinkScript` | +| 2 | 1 | `BGEVENT_READ` | `PlayersHouse1FFridgeScript` | +| 4 | 1 | `BGEVENT_READ` | `PlayersHouse1FTVScript` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PLAYERSHOUSE1F_MOM1` | `SPRITE_MOM` | 7 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` (time `-1`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_1` | +| `PLAYERSHOUSE1F_MOM2` | `SPRITE_MOM` | 2 | 2 | `SPRITEMOVEDATA_STANDING_UP` (time `MORN`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | +| `PLAYERSHOUSE1F_MOM3` | `SPRITE_MOM` | 7 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` (time `DAY`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | +| `PLAYERSHOUSE1F_MOM4` | `SPRITE_MOM` | 0 | 2 | `SPRITEMOVEDATA_STANDING_UP` (time `NITE`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | + +Note the object flags are inverted relative to intuition: a *set* event flag hides +the object. Mom1 is the cutscene Mom (visible while `EVENT_PLAYERS_HOUSE_MOM_1` +is clear); `MeetMomScript` ends by setting `EVENT_PLAYERS_HOUSE_MOM_1` and +clearing `EVENT_PLAYERS_HOUSE_MOM_2`, which swaps in the three time-of-day Moms +on the next map load. + +**Scripts of interest** + +- `MeetMomScript` (the whole Pokegear beat, `maps/PlayersHouse1F.asm:21`): + 1. `applymovement PLAYER, PlayersHouseDownstairsMovement` (one `step DOWN`, i.e. off the stair tile). + 2. `playmusic MUSIC_MOM`, `turnobject PLAYERSHOUSE1F_MOM1, UP`, `showemote EMOTE_SHOCK, PLAYERSHOUSE1F_MOM1, 15`. + 3. `applymovement PLAYERSHOUSE1F_MOM1, MomWalksToPlayerMovement` (`slow_step RIGHT, RIGHT, UP`: (7,3) -> (9,2), beside the player at (9,1)). + 4. `writetext ElmsLookingForYouText`, `getstring STRING_BUFFER_4, PokegearName` ("#GEAR"), `scall PlayersHouse1FReceiveItemStd` -> `jumpstd ReceiveItemScript` (`engine/events/std_scripts.asm:656`). + 5. `setflag ENGINE_POKEGEAR`, `setflag ENGINE_PHONE_CARD`, `addcellnum PHONE_MOM`. + 6. `setscene SCENE_PLAYERSHOUSE1F_NOOP`, `setevent EVENT_PLAYERS_HOUSE_MOM_1`, `clearevent EVENT_PLAYERS_HOUSE_MOM_2`. + 7. `special SetDayOfWeek`, then the DST loop: `IsItDSTText` -> `yesorno` -> `special InitialSetDSTFlag` / `special InitialClearDSTFlag`, each confirmed by a second `yesorno` that loops back to `.SetDayOfWeek` on "no". + 8. Phone-instructions branch (`.KnowPhone` / `.ExplainPhone`, both fall into `.FinishPhone`), then `applymovement PLAYERSHOUSE1F_MOM1, MomWalksBackMovement` and `special RestartMapMusic`. + Crucially the #GEAR is **not** an inventory item: it is two engine flags. There + is no `giveitem` in this script. +- `MomScript` (talking to Mom afterwards): checks, in order, + `EVENT_FIRST_TIME_BANKING_WITH_MOM` -> `EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST` + -> `EVENT_GAVE_MYSTERY_EGG_TO_ELM` -> `EVENT_GOT_A_POKEMON_FROM_ELM`; in this + section all four are clear so it prints `HurryUpElmIsWaitingText`. The + `special BankOfMom` path is not reachable yet. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_POKEGEAR` | `constants/engine_flags.asm:8` | set by `MeetMomScript` | Pokegear menu becomes usable | +| `ENGINE_PHONE_CARD` | `constants/engine_flags.asm:6` | set by `MeetMomScript` | Pokegear PHONE card unlocked | +| `EVENT_PLAYERS_HOUSE_MOM_1` | `constants/event_flags.asm:1129` | set by `MeetMomScript` | hides the cutscene Mom | +| `EVENT_PLAYERS_HOUSE_MOM_2` | `constants/event_flags.asm:1130` | cleared by `MeetMomScript` | reveals the time-of-day Moms | +| `SCENE_PLAYERSHOUSE1F_MEET_MOM` (0) / `SCENE_PLAYERSHOUSE1F_NOOP` (1) | `maps/PlayersHouse1F.asm:9-10` | `setscene` at step 6 | scene 0 must be live exactly once | +| `PHONE_MOM` | `constants/phone_constants.asm` (via `addcellnum`) | `MeetMomScript` | Mom's number in the Pokegear | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| POKEGEAR (not an item - `ENGINE_POKEGEAR` + `ENGINE_PHONE_CARD`) | Mom cutscene | `MeetMomScript` | `EVENT_PLAYERS_HOUSE_MOM_1` / `SCENE_PLAYERSHOUSE1F_NOOP` | + +**Trainers**: none. + +**Wild encounters**: none (indoor). + +### MAP_NEW_BARK_TOWN + +- Script: `maps/NewBarkTown.asm` +- Blocks: `maps/NewBarkTown.blk` +- Header (`data/maps/maps.asm:475`): `map NewBarkTown, TILESET_JOHTO, TOWN, LANDMARK_NEW_BARK_TOWN, MUSIC_NEW_BARK_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions (`constants/map_constants.asm:440`): `map_const NEW_BARK_TOWN, 10, 9` (20x18 cells) +- Connections (`data/maps/attributes.asm:119-121`): `map_attributes NewBarkTown, NEW_BARK_TOWN, $05`, `connection west, Route29, ROUTE_29, 0`, `connection east, Route27, ROUTE_27, 0` +- Scene var: `wNewBarkTownSceneID` (`data/maps/scenes.asm:28`). + `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` = 0, `SCENE_NEWBARKTOWN_NOOP` = 1. +- Fly point / spawn: `NewBarkTownFlypointCallback` sets `ENGINE_FLYPOINT_NEW_BARK` + (`constants/engine_flags.asm:79`) on every `MAPCALLBACK_NEWMAP`, and also + `clearevent EVENT_FIRST_TIME_BANKING_WITH_MOM`. `SPAWN_NEW_BARK` is + (13,6) in `data/maps/spawn_points.asm:27`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 3 | `ELMS_LAB` | 1 | +| 2 | 13 | 5 | `PLAYERS_HOUSE_1F` | 1 | +| 3 | 3 | 11 | `PLAYERS_NEIGHBORS_HOUSE` | 1 | +| 4 | 11 | 13 | `ELMS_HOUSE` | 1 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` (0) | 1 | 8 | `NewBarkTown_TeacherStopsYouScene1` | teacher walks 4x LEFT to (2,8), `follow`s the player and drags them back 4x RIGHT | +| `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` (0) | 1 | 9 | `NewBarkTown_TeacherStopsYouScene2` | same, 5 steps, teacher ends at (1,8) facing DOWN | + +These two cells are the entire west-exit gate. There is no coord event on the +east side. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 8 | 8 | `BGEVENT_READ` | `NewBarkTownSign` | +| 11 | 5 | `BGEVENT_READ` | `NewBarkTownPlayersHouseSign` | +| 3 | 3 | `BGEVENT_READ` | `NewBarkTownElmsLabSign` | +| 9 | 13 | `BGEVENT_READ` | `NewBarkTownElmsHouseSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `NEWBARKTOWN_TEACHER` | `SPRITE_TEACHER` | 6 | 8 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `NewBarkTownTeacherScript` | `-1` (always visible) | +| `NEWBARKTOWN_FISHER` | `SPRITE_FISHER` | 12 | 9 | `SPRITEMOVEDATA_WALK_UP_DOWN`, `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `NewBarkTownFisherScript` | `-1` | +| `NEWBARKTOWN_RIVAL` | `SPRITE_RIVAL` | 3 | 2 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `NewBarkTownRivalScript` | `EVENT_RIVAL_NEW_BARK_TOWN` | + +**Scripts of interest** + +- `NewBarkTownRivalScript` (the "he'll push you out of the way" beat): no + `faceplayer`. Prints `NewBarkTownRivalText1` ("So this is the famous ELM #MON + LAB…"), `turnobject NEWBARKTOWN_RIVAL, LEFT`, prints `NewBarkTownRivalText2`, + then `follow PLAYER, NEWBARKTOWN_RIVAL` + + `applymovement PLAYER, NewBarkTown_RivalPushesYouAwayMovement` (`turn_head UP`, + `step DOWN`), `stopfollow`, `turnobject NEWBARKTOWN_RIVAL, DOWN`, + `playsound SFX_TACKLE`, `applymovement PLAYER, NewBarkTown_RivalShovesYouOutMovement` + (`fix_facing`, `jump_step DOWN`), and the rival steps RIGHT back to (3,2). + **It sets no flag** - it is repeatable and skippable. `EVENT_RIVAL_NEW_BARK_TOWN` + is only set much later, at `maps/MrPokemonsHouse.asm:122`. +- `NewBarkTown_TeacherStopsYouScene1/2`: the "It's dangerous to go out without a + #MON!" block. Ends with `special RestartMapMusic` and does **not** change the + scene, so it re-fires every time you step on (1,8)/(1,9) until the scene id is + changed from Elm's Lab. +- `NewBarkTownTeacherScript`: four-way text branch on + `EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST` / `EVENT_GAVE_MYSTERY_EGG_TO_ELM` / + `EVENT_GOT_A_POKEMON_FROM_ELM`; flavour only. +- `NewBarkTownFlypointCallback`: `setflag ENGINE_FLYPOINT_NEW_BARK` on first entry. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` (0) | `maps/NewBarkTown.asm:8` | cleared to `SCENE_NEWBARKTOWN_NOOP` by `ElmDirectionsScript`'s `setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP` | the west-exit gate | +| `EVENT_RIVAL_NEW_BARK_TOWN` | `constants/event_flags.asm:1119` | set at `maps/MrPokemonsHouse.asm:122` | rival object hidden once set; clear (visible) all through this section | +| `ENGINE_FLYPOINT_NEW_BARK` | `constants/engine_flags.asm:79` | `NewBarkTownFlypointCallback` | Fly destination registered | +| `EVENT_FIRST_TIME_BANKING_WITH_MOM` | `constants/event_flags.asm` | cleared by `NewBarkTownFlypointCallback` | not reachable this section | + +**Items**: none. See "Unresolved" for the Everstone. + +**Trainers**: none. The rival encounter here is a conversation, not a battle. + +**Wild encounters** + +- No `def_grass_wildmons NEW_BARK_TOWN` entry exists in `data/wild/johto_grass.asm`. +- Water (`data/wild/johto_water.asm:211`): `def_water_wildmons NEW_BARK_TOWN`, + `db 6 percent`, `db 20, TENTACOOL` / `db 15, TENTACOOL` / `db 20, TENTACRUEL`. + Needs Surf, unreachable this section. +- Fishing: the header's `FISHGROUP_OCEAN` selects + `data/wild/fish.asm` `.Ocean_Old` (Magikarp 10 / Magikarp 10 / Tentacool 10), + `.Ocean_Good`, `.Ocean_Super`. No rod yet. +- Headbutt: `data/wild/treemon_maps.asm:28` `treemon_map NEW_BARK_TOWN, TREEMON_SET_CITY` + -> `data/wild/treemons.asm:18` `TreeMonSet_City` (Venonat/Abra/Venomoth common, + Venonat/Magnemite/Abra/Venomoth rare, all L15). Needs TM02, far later. + +### MAP_ELMS_LAB + +- Script: `maps/ElmsLab.asm` +- Blocks: `maps/ElmsLab.blk` +- Header (`data/maps/maps.asm:476`): `map ElmsLab, TILESET_LAB, INDOOR, LANDMARK_NEW_BARK_TOWN, MUSIC_PROF_ELM, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:441`): `map_const ELMS_LAB, 5, 6` (10x12 cells) +- Connections: none (indoor) +- Scene var: `wElmsLabSceneID` (`data/maps/scenes.asm:29`). Declaration order + gives `SCENE_ELMSLAB_MEET_ELM` = 0, `SCENE_ELMSLAB_CANT_LEAVE` = 1, + `SCENE_ELMSLAB_NOOP` = 2, `SCENE_ELMSLAB_MEET_OFFICER` = 3, + `SCENE_ELMSLAB_UNUSED` = 4, `SCENE_ELMSLAB_AIDE_GIVES_POTION` = 5, + `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` = 6 (the last one is a bare + `scene_const`, no script row). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 11 | `NEW_BARK_TOWN` | 1 | +| 2 | 5 | 11 | `NEW_BARK_TOWN` | 1 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ELMSLAB_CANT_LEAVE` (1) | 4 | 6 | `LabTryToLeaveScript` | Elm turns DOWN, `LabWhereGoingText`, `applymovement PLAYER, ElmsLab_CantLeaveMovement` (one `step UP`) | +| `SCENE_ELMSLAB_CANT_LEAVE` (1) | 5 | 6 | `LabTryToLeaveScript` | same | +| `SCENE_ELMSLAB_MEET_OFFICER` (3) | 4 | 5 | `MeetCopScript` | post-theft, next section | +| `SCENE_ELMSLAB_MEET_OFFICER` (3) | 5 | 5 | `MeetCopScript2` | post-theft, next section | +| `SCENE_ELMSLAB_AIDE_GIVES_POTION` (5) | 4 | 8 | `AideScript_WalkPotion1` | aide walks 2x RIGHT, gives POTION, walks back | +| `SCENE_ELMSLAB_AIDE_GIVES_POTION` (5) | 5 | 8 | `AideScript_WalkPotion2` | aide walks 3x RIGHT, gives POTION, walks back | +| `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` (6) | 4 | 8 | `AideScript_WalkBalls1` | 5x POKE_BALL, next section | +| `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` (6) | 5 | 8 | `AideScript_WalkBalls2` | 5x POKE_BALL, next section | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 1 | `BGEVENT_READ` | `ElmsLabHealingMachine` | +| 6 | 1 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 7 | 1 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 8 | 1 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 9 | 1 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 0 | 7 | `BGEVENT_READ` | `ElmsLabTravelTip1` | +| 1 | 7 | `BGEVENT_READ` | `ElmsLabTravelTip2` | +| 2 | 7 | `BGEVENT_READ` | `ElmsLabTravelTip3` | +| 3 | 7 | `BGEVENT_READ` | `ElmsLabTravelTip4` | +| 6 | 7 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 7 | 7 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 8 | 7 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 9 | 7 | `BGEVENT_READ` | `ElmsLabBookshelf` | +| 9 | 3 | `BGEVENT_READ` | `ElmsLabTrashcan` | +| 5 | 0 | `BGEVENT_READ` | `ElmsLabWindow` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ELMSLAB_ELM` | `SPRITE_ELM` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ProfElmScript` | `-1` | +| `ELMSLAB_ELMS_AIDE` | `SPRITE_SCIENTIST` | 2 | 9 | `SPRITEMOVEDATA_SPINRANDOM_SLOW`, `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `ElmsAideScript` | `EVENT_ELMS_AIDE_IN_LAB` | +| `ELMSLAB_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `CyndaquilPokeBallScript` | `EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB` | +| `ELMSLAB_POKE_BALL2` | `SPRITE_POKE_BALL` | 7 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `TotodilePokeBallScript` | `EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB` | +| `ELMSLAB_POKE_BALL3` | `SPRITE_POKE_BALL` | 8 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `ChikoritaPokeBallScript` | `EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB` | +| `ELMSLAB_OFFICER` | `SPRITE_OFFICER` | 5 | 3 | `SPRITEMOVEDATA_STANDING_UP`, `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `CopScript` | `EVENT_COP_IN_ELMS_LAB` | + +The ball order on screen is left-to-right **Cyndaquil (6,3), Totodile (7,3), +Chikorita (8,3)** - not the Pokedex order the walkthrough's title uses. The +officer is hidden all through this section because `InitializeEventsScript` +*sets* `EVENT_COP_IN_ELMS_LAB` (`engine/events/std_scripts.asm:469`). + +**Scripts of interest** + +- `ElmsLabMeetElmScene` (scene 0) -> `sdefer ElmsLabWalkUpToElmScript`: + `applymovement PLAYER, ElmsLab_WalkUpToElmMovement` (nine `step UP` then + `turn_head RIGHT`, i.e. from the door at (4,11) up to (4,2) beside Elm at + (5,2)), `turnobject ELMSLAB_ELM, LEFT`, `writetext ElmText_Intro` (the Mr. + Pokemon errand speech), then `setscene SCENE_ELMSLAB_CANT_LEAVE`. +- `CyndaquilPokeBallScript` / `TotodilePokeBallScript` / `ChikoritaPokeBallScript`: + identical shape. `checkevent EVENT_GOT_A_POKEMON_FROM_ELM` -> if set, just + `LookAtElmPokeBallScript`. Otherwise `turnobject ELMSLAB_ELM, DOWN`, + `reanchormap`, `pokepic <SPECIES>`, `cry <SPECIES>`, `closepokepic`, + `writetext Take<Species>Text`, `yesorno`; `iffalse DidntChooseStarterScript` + (fully cancellable, nothing is written). On yes: + `disappear ELMSLAB_POKE_BALL<n>`, `setevent EVENT_GOT_<SPECIES>_FROM_ELM`, + `ChoseStarterText`, `getmonname STRING_BUFFER_3`, `ReceivedStarterText`, + `playsound SFX_CAUGHT_MON`, then **`givepoke <SPECIES>, 5, BERRY`** - level 5, + holding a BERRY. Finally an `applymovement` that walks the player to (5,3), + directly below Elm, and `sjump ElmDirectionsScript`: + - Cyndaquil: `readvar VAR_FACING` / `ifequal RIGHT, ElmDirectionsScript` (skips the walk if you took it from the left side), else `AfterCyndaquilMovement` = LEFT, UP. + - Totodile: `AfterTotodileMovement` = LEFT, LEFT, UP. + - Chikorita: `AfterChikoritaMovement` = LEFT, LEFT, LEFT, UP. +- `ElmDirectionsScript` - the one script that opens the section up: + `turnobject PLAYER, UP`, three Elm texts, `addcellnum PHONE_ELM`, + `playsound SFX_REGISTER_PHONE_NUMBER`, then + `setevent EVENT_GOT_A_POKEMON_FROM_ELM`, + `setevent EVENT_RIVAL_CHERRYGROVE_CITY`, + `setscene SCENE_ELMSLAB_AIDE_GIVES_POTION`, + `setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP`. + That last opcode is what removes the teacher gate. +- `AideScript_GivePotion` (reached by the (4,8)/(5,8) coord events once the scene + is 5): `writetext AideText_GiveYouPotion`, `verbosegiveitem POTION`, + `AideText_AlwaysBusy`, `setscene SCENE_ELMSLAB_NOOP`. One-shot, gated by the + scene id rather than an event flag. +- `LabTryToLeaveScript`: the "you cannot walk out yet" push-back described above. +- `ElmsLabHealingMachine` (bg_event at (2,1)): before + `EVENT_GOT_A_POKEMON_FROM_ELM` it just prints text; after, it offers a + `yesorno` -> `special HealParty`, `setval HEALMACHINE_ELMS_LAB`, + `special HealMachineAnim`. Free healing point for a bot from this section on. +- `ProfElmScript`: long check chain. In this section every branch falls through + to `writetext ElmText_LetYourMonBattleIt` (after the starter) or + `ElmDescribesMrPokemonScript`. The `ElmGiveEverstoneScript` arm needs + `EVENT_SHOWED_TOGEPI_TO_ELM`; the Master Ball arm needs `ENGINE_RISINGBADGE`; + the S.S. Ticket arm needs `EVENT_BEAT_ELITE_FOUR`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_A_POKEMON_FROM_ELM` | `constants/event_flags.asm:35` | set by `ElmDirectionsScript`; read by the ball scripts, `MomScript`, `NewBarkTownTeacherScript`, `PlayersHouseRadioScript`, `ElmsLabHealingMachine` | the section's terminal flag | +| `EVENT_GOT_CYNDAQUIL_FROM_ELM` | `constants/event_flags.asm:36` | `CyndaquilPokeBallScript` | which starter (Totodile/Chikorita variants are the next two consts) | +| `EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB` | `constants/event_flags.asm:993` | `disappear ELMSLAB_POKE_BALL1` | ball object removed | +| `EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB` | `constants/event_flags.asm:994` | `disappear ELMSLAB_POKE_BALL2` | ball object removed | +| `EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB` | `constants/event_flags.asm:995` | `disappear ELMSLAB_POKE_BALL3` | ball object removed | +| `EVENT_COP_IN_ELMS_LAB` | `constants/event_flags.asm:1188` | set by `InitializeEventsScript`, cleared at `maps/MrPokemonsHouse.asm:127` | officer hidden this section | +| `EVENT_ELMS_AIDE_IN_LAB` | `constants/event_flags.asm:1187` | cleared at `maps/VioletPokecenter1F.asm:31`, set at `engine/phone/scripts/elm.asm:85` | aide visible (flag clear) this section | +| `EVENT_RIVAL_CHERRYGROVE_CITY` | `constants/event_flags.asm` | set by `InitializeEventsScript` and again by `ElmDirectionsScript` | Cherrygrove rival still hidden | +| `EVENT_GOT_EVERSTONE_FROM_ELM` | `constants/event_flags.asm:95` | `ElmGiveEverstoneScript` | **not reachable in this section** | +| `SCENE_ELMSLAB_*` | `maps/ElmsLab.asm:11-17` | `setscene` / `setmapscene` | drives every coord event on the map | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `POTION` | walk onto (4,8) or (5,8) on the way out after picking a starter | `AideScript_GivePotion` (`verbosegiveitem POTION`) | `SCENE_ELMSLAB_AIDE_GIVES_POTION` -> `SCENE_ELMSLAB_NOOP` | +| starter, L5, holding `BERRY` | `givepoke` in the chosen ball script | `CyndaquilPokeBallScript` / `TotodilePokeBallScript` / `ChikoritaPokeBallScript` | `EVENT_GOT_<SPECIES>_FROM_ELM` + `EVENT_GOT_A_POKEMON_FROM_ELM` | +| `EVERSTONE` | Elm, **after** showing him a hatched Togepi | `ElmGiveEverstoneScript` (`maps/ElmsLab.asm:341`) | `EVENT_GOT_EVERSTONE_FROM_ELM` | +| `POKE_BALL` x5 | aide, after the Mystery Egg hand-off | `AideScript_GiveYouBalls` | `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` -> `SCENE_ELMSLAB_NOOP` | + +**Trainers**: none. No `loadtrainer` / `startbattle` anywhere in +`maps/ElmsLab.asm`, `maps/NewBarkTown.asm`, `maps/PlayersHouse1F.asm` or +`maps/PlayersHouse2F.asm`. + +**Wild encounters**: none (indoor). + +**Starter data** (`data/pokemon/evos_attacks.asm`, checked against the +walkthrough's claims - all three match) + +| species | evolves | L5 known moves | later moves the FAQ names | +|---|---|---|---| +| `CHIKORITA` | `EVOLVE_LEVEL, 16, BAYLEEF`; Bayleef `EVOLVE_LEVEL, 32, MEGANIUM` | TACKLE (1), GROWL (1) | RAZOR_LEAF 8, REFLECT 12, POISONPOWDER 15, SYNTHESIS 22, BODY_SLAM 29, LIGHT_SCREEN 36, SAFEGUARD 43, SOLARBEAM 50 | +| `CYNDAQUIL` | `EVOLVE_LEVEL, 14, QUILAVA`; Quilava `EVOLVE_LEVEL, 36, TYPHLOSION` | TACKLE (1), LEER (1) | SMOKESCREEN 6, EMBER 12, QUICK_ATTACK 19, FLAME_WHEEL 27, SWIFT 36, FLAMETHROWER 46 | +| `TOTODILE` | `EVOLVE_LEVEL, 18, CROCONAW`; Croconaw `EVOLVE_LEVEL, 30, FERALIGATR` | SCRATCH (1), LEER (1) | RAGE 7, WATER_GUN 13, BITE 20, SCARY_FACE 27, SLASH 35, SCREECH 43, HYDRO_PUMP 52 | + +Note the FAQ's move lists are aspirational (they mix level-up moves with HM03 / +HM04). At the moment you receive it the starter knows exactly its two level-1 +moves; Cyndaquil does **not** have Ember yet at L5. + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Clock must be set before the world exists | `engine/menus/intro_menu.asm:494` `OakSpeech` -> `farcall InitClock` (`engine/rtc/timeset.asm:4`); default hour is 10 (`ld a, 10 ; default hour = 10 AM`) | hour then minute then a `YesNoBox` confirm each | answering "yes" to both confirmations | +| Player must be named | `engine/menus/intro_menu.asm` `NamePlayer` -> `ShowPlayerNamingChoices` / `StorePlayerName` | pick a preset or type a name | any accepted name | +| Cannot walk out of Elm's Lab before the errand speech ends | `maps/ElmsLab.asm` coord events (4,6) and (5,6) under `SCENE_ELMSLAB_CANT_LEAVE`, script `LabTryToLeaveScript` | scene id must leave 1 | `ElmDirectionsScript`'s `setscene SCENE_ELMSLAB_AIDE_GIVES_POTION` - i.e. take a starter | +| Cannot leave New Bark Town west toward Route 29 | `maps/NewBarkTown.asm` coord events (1,8) and (1,9) under `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU`, scripts `NewBarkTown_TeacherStopsYouScene1/2` | scene id must leave 0 | `ElmDirectionsScript`'s `setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP` | +| Aide's Potion is scene-gated, not flag-gated | `maps/ElmsLab.asm` coord events (4,8)/(5,8), `AideScript_GivePotion` | be on scene 5 and step on x=4 or 5, y=8 | script itself does `setscene SCENE_ELMSLAB_NOOP` | +| Everstone (listed by the FAQ under New Bark Town) | `maps/ElmsLab.asm:63,338-347` `ElmCheckEverstone` / `ElmGiveEverstoneScript` | `EVENT_SHOWED_TOGEPI_TO_ELM`, which needs a hatched Togepi in the party (`special FindPartyMonThatSpeciesYourTrainerID`) | many sections later | + +No HM field-move gate applies in this section: nothing under +`engine/overworld/` (cut/surf/strength/whirlpool/waterfall/flash/fly) is +consulted between the bedroom and the starter. + +## 4. Bot checklist + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | - (main menu) | NEW GAME | menu select | fresh save | `NewGame` runs `ResetWRAM` | +| 2 | - (`InitClock`) | hour, then minute, then yes/no per field | menu | in `OakSpeech` | RTC base written; default hour 10 | +| 3 | - (`NamePlayer`) | any name | naming screen | after `OakText6` | `wPlayerName` set | +| 4 | `PLAYERS_HOUSE_2F` | spawn (3,3) | - | `SPAWN_HOME` | `MAPCALLBACK_NEWMAP` runs `InitializeEventsScript` once; `EVENT_INITIALIZED_EVENTS` set | +| 5 | `PLAYERS_HOUSE_2F` | warp 1 at (7,0) | walk onto | - | now on `PLAYERS_HOUSE_1F` warp 3 (9,0) | +| 6 | `PLAYERS_HOUSE_1F` | (scene 0 auto-fires) | wait / mash A through `MeetMomScript` | `wPlayersHouse1FSceneID` == 0 | `ENGINE_POKEGEAR`, `ENGINE_PHONE_CARD`, `PHONE_MOM`, `EVENT_PLAYERS_HOUSE_MOM_1`, scene -> 1 | +| 6a | `PLAYERS_HOUSE_1F` | DST `yesorno` pair | answer, then confirm | inside `MeetMomScript` | answering "no" to the confirm loops back to `.SetDayOfWeek` - a bot must confirm, not just answer | +| 7 | `PLAYERS_HOUSE_1F` | warp 1 (6,7) or warp 2 (7,7) | walk onto | - | `NEW_BARK_TOWN` at warp 2 (13,5) | +| 8 | `NEW_BARK_TOWN` | object `NEWBARKTOWN_RIVAL` at (3,2) | talk (optional) | rival visible while `EVENT_RIVAL_NEW_BARK_TOWN` clear | pushed one cell DOWN + a `jump_step DOWN`; **no flag set**, skippable | +| 9 | `NEW_BARK_TOWN` | warp 1 at (6,3) | walk onto | avoid (1,8)/(1,9) until step 12 | `ELMS_LAB` at warp 1 (4,11) | +| 10 | `ELMS_LAB` | (scene 0 auto-fires) | wait / mash A | `wElmsLabSceneID` == 0 | player auto-walked to (4,2); scene -> 1 (`CANT_LEAVE`) | +| 11 | `ELMS_LAB` | ball object: (6,3) Cyndaquil / (7,3) Totodile / (8,3) Chikorita | face + A, answer **yes** to `yesorno` | `EVENT_GOT_A_POKEMON_FROM_ELM` clear | starter L5 w/ BERRY in party; `EVENT_GOT_<SP>_FROM_ELM`; ball disappears; auto-walk to (5,3); `ElmDirectionsScript` runs -> `EVENT_GOT_A_POKEMON_FROM_ELM`, `PHONE_ELM`, `ELMS_LAB` scene -> 5, `NEW_BARK_TOWN` scene -> 1 | +| 12 | `ELMS_LAB` | (4,8) or (5,8) | walk onto on the way out | scene == 5 | `POTION` in bag; scene -> 2 (`NOOP`) | +| 13 | `ELMS_LAB` | bg_event (2,1) | face UP + A -> yes (optional) | `EVENT_GOT_A_POKEMON_FROM_ELM` set | party healed (`special HealParty`) | +| 14 | `ELMS_LAB` | warp 1 (4,11) or warp 2 (5,11) | walk onto | - | `NEW_BARK_TOWN` (6,3) | +| 15 | `NEW_BARK_TOWN` | west edge past (1,8)/(1,9) | walk west | `NEW_BARK_TOWN` scene == 1 | crosses the `connection west, Route29` into `ROUTE_29` - section 01 | + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Title -> NEW GAME -> Oak speech -> name pick -> naming screen -> bedroom | `src/ui/gen2/TitleState.lua`, `src/ui/gen2/MainMenu.lua`, `src/ui/gen2/OakSpeech.lua`, `src/ui/gen2/NamePick.lua`, `src/ui/gen2/NamingScreen.lua`; asserted end-to-end by `tests/drivers/gold_boot_smoke.lua` | implemented | +| `InitClock` (the "what time is it?" / minute screens) | none - `src/ui/gen2/OakSpeech.lua:3` says outright "Gender select / InitClock are Crystal-later" | **missing** (bot step 2 has no screen to drive) | +| `SPAWN_HOME` start at `PLAYERS_HOUSE_2F` (3,3) | `src/world/gen2/World.lua:228-232` (`START_MAP = "PLAYERS_HOUSE_2F"`, cites `data/maps/spawn_points.asm`) | implemented | +| Map load, warps, collision, connections | `src/world/gen2/Map.lua`, `src/world/gen2/World.lua`; `tests/drivers/gold_walk_smoke.lua`, `tests/drivers/gold_warp_scene.lua` (asserts the New Bark door lands on `ELMS_LAB`) | implemented | +| Scene scripts + coord events (the mechanism every gate in this section uses) | `src/world/gen2/World.lua:5011-5035` (`coordEvents` scan filtered by `self:scene()`, `sceneScripts` lookup), extracted by `src/import/RomExtractorGen2.lua:804-977` | implemented | +| `MAPCALLBACK_NEWMAP` / `MAPCALLBACK_TILES`, `ToggleDecorationsVisibility`, `ToggleMaptileDecorations` | `src/script/gen2/Specials.lua:489-505`, `tests/drivers/gold_map_callbacks.lua`, `tests/gen2_map_callbacks_test.lua` | implemented | +| `MeetMomScript` (emote, follow-free walk-in, `yesorno` under a prompt, mid-scene object swap) | driven by the VM; regression driver `tests/drivers/gold_mom_scene.lua` exists specifically for this scene; the Mom object-swap timing is commented at `src/world/gen2/World.lua:558-568,730` | implemented | +| `special SetDayOfWeek` / `InitialSetDSTFlag` / `InitialClearDSTFlag` | `src/script/gen2/Specials.lua:836-860` | partial - the flags/RTC fields are written from the host clock, but no day-of-week picker UI is drawn, so the player never chooses | +| Teacher west-exit gate | `tests/drivers/gold_teacher_scene.lua` (asserts `follow` drags the player back and she stands beside them) | implemented | +| Rival push-out (`follow` + `jump_step` + `SFX_TACKLE`) | VM ops `follow`/`stopfollow` (`src/script/gen2/Opcodes.lua:117-118`), movement in `src/script/gen2/Movement.lua` | partial - opcodes present, no driver covering `NewBarkTownRivalScript` specifically | +| Starter pick: `pokepic`, `cry`, `yesorno`, `disappear`, `givepoke SP, 5, BERRY` | `src/script/gen2/Opcodes.lua:91-92,51`; `src/script/gen2/Vm.lua:386-394` (`pokepic` -> `showPicFn`), `:439-445` (`givepoke` -> `givePokeFn` with species/level/held item) | partial - all opcodes implemented, but no driver or test in `tests/drivers/gold_*.lua` walks the Elm's Lab starter choice end to end | +| `verbosegiveitem POTION` (aide) | `src/script/gen2/Vm.lua:490-510` (received text, SFX, "put in the ITEM POCKET") | implemented | +| `addcellnum PHONE_MOM` / `PHONE_ELM`, Pokegear phone card | `src/script/gen2/Vm.lua:611`, `src/core/gen2/Phone.lua:137,146`, `src/ui/gen2/Pokegear.lua` | implemented | +| `special HealParty` / `HealMachineAnim` (lab healing machine) | `src/script/gen2/Specials.lua:456` | implemented | +| `special PlayersHousePC` + `warp NONE, 0, 0` | `src/script/gen2/Specials.lua:467-487`, `src/ui/gen2/PcMenu.lua` | implemented | +| `special NameRival` (fires next section, from `CopScript`) | `src/script/gen2/Specials.lua:825-828` -> `World:nameRival` (`src/world/gen2/World.lua:4931`) | implemented | +| Event-flag bitfield semantics (set flag = object hidden) | `src/world/gen2/Events.lua` (header comment cites `CheckObjectFlag` in `map_objects_2.asm`) | implemented | + +## 6. Unresolved / verify by hand + +- **Everstone.** The FAQ lists "Everstone" under "Items found in New Bark Town". + There is no Everstone item ball, hidden item or bg_event on `MAP_NEW_BARK_TOWN`, + `MAP_ELMS_LAB`, `MAP_PLAYERS_HOUSE_1F` or `MAP_PLAYERS_HOUSE_2F`. The only New + Bark Town Everstone is `ElmGiveEverstoneScript` (`maps/ElmsLab.asm:338-347`), + which requires `EVENT_SHOWED_TOGEPI_TO_ELM` - a hatched Togepi shown to Elm, + many sections later. Treat the FAQ line as forward-looking, not as an item to + collect here. +- **"Pokegear" as an item.** The FAQ says "You get the Pokegear". In the asm it is + `setflag ENGINE_POKEGEAR` + `setflag ENGINE_PHONE_CARD`, with the item name + faked through `getstring STRING_BUFFER_4, PokegearName` ("#GEAR") into the + standard `ReceiveItemScript` text. Nothing enters the bag - a bot must not + check inventory for it. +- **"Professor Oak will ask you the time. First answer what hour it is and what + minute it is."** `InitClock` (`engine/rtc/timeset.asm`) is reached from + `OakSpeech` *before* any Oak text is printed (`farcall InitClock` is the first + instruction of `OakSpeech`), so the clock screen actually comes first and Oak's + greeting second. The FAQ's ordering is the reverse. +- **"even if you don't have the GBA on, the time will keep running"** - an RTC + hardware claim, nothing in the disassembly to cite; ignore. +- **East exit toward Route 27.** `data/maps/attributes.asm:121` gives New Bark Town + a `connection east, Route27, ROUTE_27, 0`, and unlike the west side there is + *no* coord event guarding it. Whether a bot can actually walk east before + getting Surf depends on the shoreline tiles in `maps/NewBarkTown.blk`, which I + did not decode. Verify by hand before relying on the west gate as the only exit + block. +- **Teacher gate coverage.** Only (1,8) and (1,9) carry coord events. If the map's + west connection strip is reachable at any other y, the gate has a hole. I read + the coord table but not the collision data, so this is unverified. +- **Rival battle.** The FAQ says only that he pushes you; confirmed - there is no + `loadtrainer`/`startbattle` in `NewBarkTownRivalScript`. The first rival battle + is not in this section. diff --git a/docs/gold-walkthrough/asm-walk/section-01-cherrygrove-city-and-routes-29-31.md b/docs/gold-walkthrough/asm-walk/section-01-cherrygrove-city-and-routes-29-31.md new file mode 100644 index 00000000..6d0275d1 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-01-cherrygrove-city-and-routes-29-31.md @@ -0,0 +1,1081 @@ +# Section 01 - Cherrygrove City and Routes 29-31 + +Source: `../section-01-cherrygrove-city-and-routes-29-31.txt` +Maps covered: `MAP_ROUTE_29`, `MAP_ROUTE_29_ROUTE_46_GATE`, `MAP_ROUTE_46`, +`MAP_CHERRYGROVE_CITY`, `MAP_CHERRYGROVE_POKECENTER_1F`, `MAP_CHERRYGROVE_MART`, +`MAP_GUIDE_GENTS_HOUSE`, `MAP_CHERRYGROVE_GYM_SPEECH_HOUSE`, +`MAP_CHERRYGROVE_EVOLUTION_SPEECH_HOUSE`, `MAP_ROUTE_30`, +`MAP_ROUTE_30_BERRY_HOUSE`, `MAP_MR_POKEMONS_HOUSE`, `MAP_ROUTE_31`, +`MAP_ROUTE_31_VIOLET_GATE`, plus the return leg through `MAP_NEW_BARK_TOWN`, +`MAP_ELMS_LAB` and `MAP_PLAYERS_HOUSE_1F` (those three are first visited in the +previous section; only the beats this section's text drives are transcribed +here). + +Badges / key milestones in this section: + +- No badge. The section ends at the Violet City gate. +- `ENGINE_MAP_CARD` (Pokegear MAP card, Guide Gent tour). +- `ENGINE_POKEDEX` (Prof. Oak, Mr. Pokemon's House). +- `MYSTERY_EGG` obtained, then handed to Elm (`EVENT_GAVE_MYSTERY_EGG_TO_ELM`, + the single biggest content switch in the section - it opens Poke Ball sales, + the Route 29 catch tutorial, the Route 30 trainers and the Bank of Mom). +- Rival battle 1 in Cherrygrove City (`RIVAL1`, level 5 starter). +- `ENGINE_FLYPOINT_CHERRYGROVE`, and `blackoutmod CHERRYGROVE_CITY`. + +Coordinate convention: every `warp_event` / `coord_event` / `bg_event` / +`object_event` row below is copied verbatim from the map asm. Those x/y are map +cell coordinates starting at 0 (`macros/scripts/maps.asm` adds the +4 border +offset when it assembles the row, so the asm number is the one a bot wants). +Map width/height in `constants/map_constants.asm` is in **blocks**, i.e. half +the cell count in each axis - `ROUTE_29` at `30, 9` is 60x18 cells, which is why +its object x values run up to 53. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ELMS_LAB` | `maps/ElmsLab.asm` | (already inside, previous section) | warps 1/2 at (4,11)/(5,11) -> `NEW_BARK_TOWN` warp 1 | Aide's `SCENE_ELMSLAB_AIDE_GIVES_POTION` coord event hands over a POTION on the way out | +| 2 | `MAP_NEW_BARK_TOWN` | `maps/NewBarkTown.asm` | lab door | west edge connection | "Head west onto Route 29" | +| 3 | `MAP_ROUTE_29` | `maps/Route29.asm` | east connection from New Bark | west connection to Cherrygrove | grinding, BERRY tree, POTION ball | +| 4 | `MAP_ROUTE_29_ROUTE_46_GATE` | `maps/Route29Route46Gate.asm` | Route 29 warp 1 at (27,1) | gate warps 1/2 -> Route 46 | optional detour "to the north is Route 46" | +| 5 | `MAP_ROUTE_46` | `maps/Route46.asm` | gate warps 1/2 at (7,33)/(8,33) | back through the gate | optional Rattata / Geodude hunting | +| 6 | `MAP_CHERRYGROVE_CITY` | `maps/CherrygroveCity.asm` | east connection from Route 29 | north connection to Route 30 | Guide Gent tour -> MAP CARD; Pokemon Center heal | +| 7 | `MAP_CHERRYGROVE_POKECENTER_1F` | `maps/CherrygrovePokecenter1F.asm` | city warp 2 at (29,3) | pokecenter warps 1/2 | heal | +| 8 | `MAP_ROUTE_30` | `maps/Route30.asm` | south connection from Cherrygrove | warp 2 at (17,5) | north to Mr. Pokemon; berry house; hidden POTION | +| 9 | `MAP_ROUTE_30_BERRY_HOUSE` | `maps/Route30BerryHouse.asm` | Route 30 warp 1 at (7,39) | house warps 1/2 | free BERRY from the Pokefan | +| 10 | `MAP_MR_POKEMONS_HOUSE` | `maps/MrPokemonsHouse.asm` | Route 30 warp 2 at (17,5) | house warps 1/2 | MYSTERY EGG + POKEDEX + Elm's panic call | +| 11 | `MAP_CHERRYGROVE_CITY` (2nd) | `maps/CherrygroveCity.asm` | south connection from Route 30 | east side, past (33,6)/(33,7) | rival battle trip-wire on the way out | +| 12 | `MAP_ROUTE_29` (2nd) | `maps/Route29.asm` | west connection | east connection | walk back to New Bark | +| 13 | `MAP_ELMS_LAB` (2nd) | `maps/ElmsLab.asm` | New Bark warp 1 at (6,3) | lab warps 1/2 | cop names the rival; give Elm the egg; aide gives 5 POKE BALLs | +| 14 | `MAP_PLAYERS_HOUSE_1F` | `maps/PlayersHouse1F.asm` | New Bark warp 2 at (13,5) | house warps 1/2 | Mom starts saving money (`special BankOfMom`) | +| 15 | `MAP_ROUTE_29` (3rd) | `maps/Route29.asm` | east connection | west connection | catch tutorial fires at (53,8)/(53,9) | +| 16 | `MAP_ROUTE_46` (2nd, optional) | `maps/Route46.asm` | via the gate | via the gate | catch a Rattata | +| 17 | `MAP_CHERRYGROVE_CITY` (3rd) | `maps/CherrygroveCity.asm` | east connection | north connection | heal, buy POKE BALLs (mart list now `MART_CHERRYGROVE_DEX`) | +| 18 | `MAP_ROUTE_30` (2nd) | `maps/Route30.asm` | south connection | north connection | Joey, Mikey, Don on the **west** fork | +| 19 | `MAP_ROUTE_31` | `maps/Route31.asm` | south connection from Route 30 | warps 1/2 at (4,6)/(4,7) | Wade, ANTIDOTE, POKE BALL, BITTER BERRY, Dark Cave mouth | +| 20 | `MAP_ROUTE_31_VIOLET_GATE` | `maps/Route31VioletGate.asm` | Route 31 warps 1/2 | gate warps 1/2 -> `VIOLET_CITY` warps 8/9 | "through the house-ish entry, and into Violet City" | + +Spill-over: the text's last paragraph enters `MAP_VIOLET_CITY` +(`maps/VioletCity.asm`, header `data/maps/maps.asm`, connection +`connection east, Route31, ROUTE_31, 9` in `data/maps/attributes.asm`) and points +at Sprout Tower. Violet City belongs to the next section and is not transcribed +here. `MAP_DARK_CAVE_VIOLET_ENTRANCE` is likewise only touched at its mouth (see +the Route 31 block and section 3). + +--- + +## 2. Maps + +### MAP_ROUTE_29 + +- Script: `maps/Route29.asm` +- Blocks: `maps/Route29.blk` +- Header: `data/maps/maps.asm` -> + `map Route29, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_29, MUSIC_ROUTE_29, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm` -> `map_const ROUTE_29, 30, 9` (60x18 cells) +- Connections (`data/maps/attributes.asm`, `map_attributes Route29, ROUTE_29, $05`): + north `Route46` (offset 10), west `CherrygroveCity` (0), east `NewBarkTown` (0) +- Scenes (implicit ordinals from `def_scene_scripts`): `SCENE_ROUTE29_NOOP` = 0, + `SCENE_ROUTE29_CATCH_TUTORIAL` = 1 + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 27 | 1 | `ROUTE_29_ROUTE_46_GATE` | 3 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE29_CATCH_TUTORIAL` | 53 | 8 | `Route29Tutorial1` (`4a:4d98`) | DUDE catch demo, north lane | +| `SCENE_ROUTE29_CATCH_TUTORIAL` | 53 | 9 | `Route29Tutorial2` (`4a:4dce`) | DUDE catch demo, south lane | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 51 | 7 | `BGEVENT_READ` | `Route29Sign1` | +| 3 | 5 | `BGEVENT_READ` | `Route29Sign2` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE29_COOLTRAINER_M1` | `SPRITE_COOLTRAINER_M` | 50 | 12 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `CatchingTutorialDudeScript` (`4a:4e1c`) | -1 | +| `ROUTE29_YOUNGSTER` | `SPRITE_YOUNGSTER` | 27 | 16 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius y 1) | `OBJECTTYPE_SCRIPT` | `Route29YoungsterScript` | -1 | +| `ROUTE29_TEACHER1` | `SPRITE_TEACHER` | 15 | 11 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius x 1) | `OBJECTTYPE_SCRIPT` | `Route29TeacherScript` | -1 | +| `ROUTE29_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 12 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route29FruitTree` (`4a:4eb4`) | -1 | +| `ROUTE29_FISHER` | `SPRITE_FISHER` | 25 | 3 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `Route29FisherScript` | -1 | +| `ROUTE29_COOLTRAINER_M2` | `SPRITE_COOLTRAINER_M` | 13 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route29CooltrainerMScript` | -1 | +| `ROUTE29_TUSCANY` | `SPRITE_TEACHER` | 29 | 12 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `TuscanyScript` (`4a:4e74`) | `EVENT_ROUTE_29_TUSCANY_OF_TUESDAY` | +| `ROUTE29_POKE_BALL` | `SPRITE_POKE_BALL` | 48 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route29Potion` (`4a:4eb6`) | `EVENT_ROUTE_29_POTION` | + +**Scripts of interest** + +- `Route29TuscanyCallback` (`MAPCALLBACK_OBJECTS`): `checkflag ENGINE_ZEPHYRBADGE`; + without the badge it `disappear ROUTE29_TUSCANY` unconditionally. With the badge + it `readvar VAR_WEEKDAY` and only `appear`s her on `TUESDAY`. So the walkthrough's + "Pink Bow" is **not** obtainable in this section - it needs ZEPHYRBADGE first. +- `Route29Tutorial1` / `Route29Tutorial2`: `turnobject`, `showemote EMOTE_SHOCK`, + `applymovement DudeMovementData1a/2a`, `setevent EVENT_DUDE_TALKED_TO_YOU`, + `yesorno`. On yes: `follow ROUTE29_COOLTRAINER_M1, PLAYER`, + `applymovement DudeMovementData1b/2b`, `stopfollow`, `loadwildmon RATTATA, 5`, + `catchtutorial BATTLETYPE_TUTORIAL`, then `setscene SCENE_ROUTE29_NOOP` and + `setevent EVENT_LEARNED_TO_CATCH_POKEMON`. On no (`Script_RefusedTutorial1/2`) + the scene is still cleared, so a bot that declines does not get stuck. +- `CatchingTutorialDudeScript`: repeatable demo. Requires `VAR_BOXSPACE != 0`, + `EVENT_LEARNED_TO_CATCH_POKEMON` clear and `EVENT_GAVE_MYSTERY_EGG_TO_ELM` set. +- `Route29Potion`: `itemball POTION`, one-shot on `EVENT_ROUTE_29_POTION`. +- `Route29FruitTree`: `fruittree FRUITTREE_ROUTE_29` -> `BERRY` + (`data/items/fruit_trees.asm`, first row). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `SCENE_ROUTE29_CATCH_TUTORIAL` | implicit ordinal 1, `maps/Route29.asm` | set by `ElmAfterTheftScript` (`setmapscene ROUTE_29, ...`) | tutorial trip-wire armed only after the egg is delivered | +| `EVENT_DUDE_TALKED_TO_YOU` | `constants/event_flags.asm:74` | `Route29Tutorial1/2` | demo started | +| `EVENT_LEARNED_TO_CATCH_POKEMON` | `constants/event_flags.asm:75` | `Route29Tutorial1/2`, `CatchingTutorialDudeScript` | demo finished | +| `EVENT_ROUTE_29_POTION` | `constants/event_flags.asm:1102` | `itemball` | ball taken (object hidden when set) | +| `EVENT_ROUTE_29_TUSCANY_OF_TUESDAY` | `constants/event_flags.asm:1275` | `Route29TuscanyCallback` | Tuscany visible when **clear** | +| `EVENT_MET_TUSCANY_OF_TUESDAY` / `EVENT_GOT_PINK_BOW_FROM_TUSCANY` | `constants/event_flags.asm:109,110` | `TuscanyScript` | one-shot PINK BOW | +| `ENGINE_ZEPHYRBADGE` | `constants/engine_flags.asm:38` | `Route29TuscanyCallback` | gate on Tuscany appearing at all | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `POTION` | item ball at (48,2) | `Route29Potion` | `EVENT_ROUTE_29_POTION` | +| `BERRY` | fruit tree at (12,2) | `Route29FruitTree` / `FRUITTREE_ROUTE_29` | daily, not an event flag | +| `PINK_BOW` | Tuscany at (29,12) | `TuscanyScript`, `verbosegiveitem PINK_BOW` | `EVENT_GOT_PINK_BOW_FROM_TUSCANY`; needs ZEPHYRBADGE **and** `VAR_WEEKDAY == TUESDAY` | + +**Trainers** + +None (the DUDE is `OBJECTTYPE_SCRIPT`; his battle is `catchtutorial`, not a trainer). + +**Wild encounters** + +`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_29`, rates +`10 percent, 10 percent, 10 percent`: + +- morn / day (identical): 2 PIDGEY, 3 SENTRET, 3 PIDGEY, 2 SENTRET, 4 RATTATA, 4 PIDGEY, 4 PIDGEY +- nite: 2 HOOTHOOT, 3 HOOTHOOT, 3 HOOTHOOT, 2 RATTATA, 4 RATTATA, 4 HOOTHOOT, 4 HOOTHOOT + +No `def_water_wildmons ROUTE_29`. Fishing group `FISHGROUP_SHORE` +(`data/wild/fish.asm` `.Shore_Old/Good/Super`: MAGIKARP/KRABBY). + +### MAP_ROUTE_29_ROUTE_46_GATE + +- Script: `maps/Route29Route46Gate.asm` +- Blocks: shared `maps/NorthSouthGate.blk` (`data/maps/blocks.asm:213`) +- Header: `map Route29Route46Gate, TILESET_GATE, GATE, LANDMARK_ROUTE_29, MUSIC_ROUTE_29, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const ROUTE_29_ROUTE_46_GATE, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_46` | 1 | +| 2 | 5 | 0 | `ROUTE_46` | 2 | +| 3 | 4 | 7 | `ROUTE_29` | 1 | +| 4 | 5 | 7 | `ROUTE_29` | 1 | + +**Coord events** / **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE29ROUTE46GATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route29Route46GateOfficerScript` | -1 | +| `ROUTE29ROUTE46GATE_YOUNGSTER` | `SPRITE_YOUNGSTER` | 6 | 4 | `SPRITEMOVEDATA_WALK_UP_DOWN` | `OBJECTTYPE_SCRIPT` | `Route29Route46GateYoungsterScript` | -1 | + +Both are `jumptextfaceplayer` only - nothing blocks. + +### MAP_ROUTE_46 + +- Script: `maps/Route46.asm` +- Blocks: `maps/Route46.blk` +- Header: `map Route46, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_46, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `map_const ROUTE_46, 10, 18` (20x36 cells) +- Connections: south `Route29` (offset -10), east `Route45` (-36) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 33 | `ROUTE_29_ROUTE_46_GATE` | 1 | +| 2 | 8 | 33 | `ROUTE_29_ROUTE_46_GATE` | 2 | +| 3 | 14 | 5 | `DARK_CAVE_VIOLET_ENTRANCE` | 3 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 27 | `BGEVENT_READ` | `Route46Sign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE46_POKEFAN_M` | `SPRITE_POKEFAN_M` | 12 | 18 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerHikerBailey` | -1 | +| `ROUTE46_YOUNGSTER` | `SPRITE_YOUNGSTER` | 3 | 13 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerCamperTed` | -1 | +| `ROUTE46_LASS` | `SPRITE_LASS` | 1 | 15 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerPicnickerErin1` | -1 | +| `ROUTE46_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 7 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route46FruitTree1` | -1 | +| `ROUTE46_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 8 | 6 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route46FruitTree2` | -1 | +| `ROUTE46_POKE_BALL` | `SPRITE_POKE_BALL` | 0 | 12 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route46DireHit` | `EVENT_ROUTE_46_DIRE_HIT` | + +**Trainers** (all three are far above the walkthrough's level range at this point, +and all three stand at y <= 18, i.e. past the ledges at the north end of the map) + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `BAILEY` | `HIKER` (`$2c`) | 11th in class | `HikerGroup` "BAILEY": 5x L13 GEODUDE | `TrainerHikerBailey` | no | +| `TED` | `CAMPER` (`$36`) | 11th in class | `CamperGroup` "TED": L17 MANKEY | `TrainerCamperTed` | no | +| `ERIN1` | `PICNICKER` (`$35`) | 10th in class | `PicnickerGroup` "ERIN": L16 PONYTA, L16 PONYTA | `TrainerPicnickerErin1` | phone `PHONE_PICNICKER_ERIN`, rematch gated on `EVENT_BEAT_ELITE_FOUR` / `EVENT_RESTORED_POWER_TO_KANTO` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `DIRE_HIT` | item ball at (0,12) | `Route46DireHit` | `EVENT_ROUTE_46_DIRE_HIT` | +| `BERRY` | tree at (7,5) | `FRUITTREE_ROUTE_46_1` | daily | +| `PRZCUREBERRY` | tree at (8,6) | `FRUITTREE_ROUTE_46_2` | daily | + +**Wild encounters** (`def_grass_wildmons ROUTE_46`, 10/10/10 percent) + +- morn / day: 3 GEODUDE, 2 SPEAROW, 2 RATTATA, 2 GEODUDE, 3 SPEAROW, 3 JIGGLYPUFF, 5 JIGGLYPUFF +- nite: 3 GEODUDE, 3 RATTATA, 2 RATTATA, 2 GEODUDE, 4 GEODUDE, 3 JIGGLYPUFF, 5 JIGGLYPUFF + +The walkthrough's "better chance of finding a Rattata" is true only at night +(2 of 7 slots vs 1 of 7 by day); by day the slot is a single level-2 RATTATA. + +### MAP_CHERRYGROVE_CITY + +- Script: `maps/CherrygroveCity.asm` +- Blocks: `maps/CherrygroveCity.blk` +- Header: `map CherrygroveCity, TILESET_JOHTO, TOWN, LANDMARK_CHERRYGROVE_CITY, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `map_const CHERRYGROVE_CITY, 20, 9` (40x18 cells) +- Connections (`map_attributes CherrygroveCity, CHERRYGROVE_CITY, $35`): + north `Route30` (offset 5), east `Route29` (0) +- Spawn: `data/maps/spawn_points.asm` -> `spawn CHERRYGROVE_CITY, 29, 4`; + fly point `data/maps/flypoints.asm` -> `db LANDMARK_CHERRYGROVE_CITY, SPAWN_CHERRYGROVE` +- Scenes: `SCENE_CHERRYGROVECITY_NOOP` = 0, `SCENE_CHERRYGROVECITY_MEET_RIVAL` = 1 + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 3 | `CHERRYGROVE_MART` | 2 | +| 2 | 29 | 3 | `CHERRYGROVE_POKECENTER_1F` | 1 | +| 3 | 17 | 7 | `CHERRYGROVE_GYM_SPEECH_HOUSE` | 1 | +| 4 | 25 | 9 | `GUIDE_GENTS_HOUSE` | 1 | +| 5 | 31 | 11 | `CHERRYGROVE_EVOLUTION_SPEECH_HOUSE` | 1 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_CHERRYGROVECITY_MEET_RIVAL` | 33 | 6 | `CherrygroveRivalSceneNorth` (`48:4479`) | rival walks in and battles | +| `SCENE_CHERRYGROVECITY_MEET_RIVAL` | 33 | 7 | `CherrygroveRivalSceneSouth` (`48:4475`) | same, after `moveobject CHERRYGROVECITY_RIVAL, 39, 7` | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 30 | 8 | `BGEVENT_READ` | `CherrygroveCitySign` | +| 23 | 9 | `BGEVENT_READ` | `GuideGentsHouseSign` | +| 24 | 3 | `BGEVENT_READ` | `CherrygroveCityMartSign` | +| 30 | 3 | `BGEVENT_READ` | `CherrygroveCityPokecenterSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CHERRYGROVECITY_GRAMPS` | `SPRITE_GRAMPS` | 32 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CherrygroveCityGuideGent` (`48:43de`) | `EVENT_GUIDE_GENT_IN_HIS_HOUSE` | +| `CHERRYGROVECITY_RIVAL` | `SPRITE_RIVAL` | 39 | 6 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_CHERRYGROVE_CITY` | +| `CHERRYGROVECITY_TEACHER` | `SPRITE_TEACHER` | 27 | 12 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `CherrygroveTeacherScript` | -1 | +| `CHERRYGROVECITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 23 | 7 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `CherrygroveYoungsterScript` | -1 | +| `CHERRYGROVECITY_FISHER` | `SPRITE_FISHER` | 7 | 12 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MysticWaterGuy` | -1 | + +Object visibility rule (verified in `engine/overworld/map_objects_2.asm` +`CheckObjectFlag` and `engine/overworld/scripting.asm` +`ApplyEventActionAppearDisappear`): an object is **masked when its event flag is +SET**. `appear` clears the flag, `disappear` sets it. + +**Scripts of interest** + +- `CherrygroveCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): + `setflag ENGINE_FLYPOINT_CHERRYGROVE`. Entering the map once is enough. +- `CherrygroveCityGuideGent` (`48:43de`): `yesorno`. On yes it + `playmusic MUSIC_SHOW_ME_AROUND`, `follow CHERRYGROVECITY_GRAMPS, PLAYER`, then + five `applymovement GuideGentMovement1..5` legs (Pokecenter -> Mart -> Route 30 -> + sea -> his house), `getstring STRING_BUFFER_4, "MAP CARD"`, + `jumpstd ReceiveItemScript`, **`setflag ENGINE_MAP_CARD`**, `stopfollow`, + `applymovement GuideGentMovement6`, `disappear CHERRYGROVECITY_GRAMPS` + (-> sets `EVENT_GUIDE_GENT_IN_HIS_HOUSE`) and + `clearevent EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE` (-> makes the indoor gramps + in `GuideGentsHouse.asm` appear). Declining leaves everything unchanged and he + can be re-asked. +- `CherrygroveRivalSceneNorth` / `...South` (`48:4479` / `48:4475`): + `special FadeOutMusic`, `appear CHERRYGROVECITY_RIVAL`, + `applymovement CherrygroveCity_RivalWalksToYou` (5x `step LEFT`), + `playmusic MUSIC_RIVAL_ENCOUNTER`, then the starter fork - + `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> `RIVAL1_1_CHIKORITA`, + `checkevent EVENT_GOT_CHIKORITA_FROM_ELM` -> `RIVAL1_1_CYNDAQUIL`, + otherwise `RIVAL1_1_TOTODILE`. Always + `loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE`, so losing does not black you out. + After either outcome: `applymovement PLAYER, CherrygroveCity_RivalPushesYouOutOfTheWay` + (`big_step DOWN`), `applymovement CHERRYGROVECITY_RIVAL, CherrygroveCity_RivalExitsStageLeft`, + `disappear CHERRYGROVECITY_RIVAL`, `setscene SCENE_CHERRYGROVECITY_NOOP`, + `special HealParty`. +- `MysticWaterGuy` at (7,12): free `MYSTIC_WATER` via `verbosegiveitem`, one-shot on + `EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE`. The walkthrough never mentions it. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_MAP_CARD` | `constants/engine_flags.asm:5` | `CherrygroveCityGuideGent` | Pokegear MAP card; `CherrygroveTeacherScript` reads it | +| `ENGINE_FLYPOINT_CHERRYGROVE` | `constants/engine_flags.asm:80` | map callback | Fly destination unlocked on first entry | +| `ENGINE_POKEDEX` | `constants/engine_flags.asm:20` | set in `MrPokemonsHouse_OakScript`, read by `CherrygroveYoungsterScript` | dex owned | +| `EVENT_GUIDE_GENT_IN_HIS_HOUSE` | `constants/event_flags.asm:1184` | `disappear` at end of tour | outdoor gramps hidden when set | +| `EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE` | `constants/event_flags.asm:1185` | set at new game by `InitializeEventsScript`, cleared at end of tour | indoor gramps hidden while set | +| `EVENT_RIVAL_CHERRYGROVE_CITY` | `constants/event_flags.asm:1120` | set by `InitializeEventsScript` and `ElmDirectionsScript`; cleared by `appear` in the scene | rival object hidden until the scene runs | +| `SCENE_CHERRYGROVECITY_MEET_RIVAL` | ordinal 1, `maps/CherrygroveCity.asm` | armed by `MrPokemonsHouse_OakScript` (`setmapscene CHERRYGROVE_CITY, ...`) | rival trip-wire only after the Pokedex | +| `EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE` | `constants/event_flags.asm:86` | `MysticWaterGuy` | one-shot | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| MAP CARD (`ENGINE_MAP_CARD`, not a bag item) | finish the Guide Gent tour | `CherrygroveCityGuideGent` | engine flag, not an event | +| `MYSTIC_WATER` | talk to the fisher at (7,12) | `MysticWaterGuy` | `EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL1_1_CHIKORITA` / `RIVAL1_1_CYNDAQUIL` / `RIVAL1_1_TOTODILE` | `RIVAL1` (class 9) | 1 / 2 / 3 | `Rival1Group` entries 1-3: a single level 5 starter | `CherrygroveRivalSceneNorth` / `...South` | one-off | + +Prize money: `data/trainers/attributes.asm` "Rival1" `db 15 ; base reward`; +`ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300`) is +`base * level of the last mon`, and `engine/battle/core.asm:2340-2361` pays that +amount **four times** (`ld c, 4` loop across Mom's account and the wallet). +15 * 5 * 4 = 300, which is exactly the walkthrough's "You get: 300G". + +**Wild encounters** + +No `def_grass_wildmons CHERRYGROVE_CITY` and no water table. Surf/fish only via +`FISHGROUP_SHORE`. + +### MAP_CHERRYGROVE_POKECENTER_1F + +- Script: `maps/CherrygrovePokecenter1F.asm` +- Blocks: shared `maps/Pokecenter1F.blk` (`data/maps/blocks.asm:363`) +- Header: `map CherrygrovePokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_CHERRYGROVE_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const CHERRYGROVE_POKECENTER_1F, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `CHERRYGROVE_CITY` | 2 | +| 2 | 4 | 7 | `CHERRYGROVE_CITY` | 2 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CHERRYGROVEPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CherrygrovePokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 | +| `CHERRYGROVEPOKECENTER1F_FISHER` | `SPRITE_FISHER` | 2 | 3 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `CherrygrovePokecenter1FFisherScript` | -1 | +| `CHERRYGROVEPOKECENTER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 8 | 6 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `CherrygrovePokecenter1FGentlemanScript` | -1 | +| `CHERRYGROVEPOKECENTER1F_TEACHER` | `SPRITE_TEACHER` | 1 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `CherrygrovePokecenter1FTeacherScript` | -1 | + +No coord or bg events. Heal = talk to the nurse at (3,1) from (3,2). + +### MAP_CHERRYGROVE_MART + +- Script: `maps/CherrygroveMart.asm` +- Blocks: shared `maps/Mart.blk` (`data/maps/blocks.asm:334`) +- Header: `map CherrygroveMart, TILESET_MART, INDOOR, LANDMARK_CHERRYGROVE_CITY, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const CHERRYGROVE_MART, 6, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `CHERRYGROVE_CITY` | 1 | +| 2 | 3 | 7 | `CHERRYGROVE_CITY` | 1 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CHERRYGROVEMART_CLERK` | `SPRITE_CLERK` | 1 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `CherrygroveMartClerkScript` | -1 | +| `CHERRYGROVEMART_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 7 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `CherrygroveMartCooltrainerMScript` | -1 | +| `CHERRYGROVEMART_YOUNGSTER` | `SPRITE_YOUNGSTER` | 2 | 5 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CherrygroveMartYoungsterScript` | -1 | + +**Scripts of interest** + +`CherrygroveMartClerkScript`: `checkevent EVENT_GAVE_MYSTERY_EGG_TO_ELM`. +False -> `pokemart MARTTYPE_STANDARD, MART_CHERRYGROVE`; true -> +`pokemart MARTTYPE_STANDARD, MART_CHERRYGROVE_DEX`. + +`data/items/marts.asm`: +- `MartCherrygrove`: POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING +- `MartCherrygroveDex`: POKE_BALL, POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING + +This is the asm behind the walkthrough's "Don't bother trying to get Pokeballs +because they aren't being sold just yet." + +### MAP_GUIDE_GENTS_HOUSE + +- Script: `maps/GuideGentsHouse.asm` +- Blocks: shared `maps/House1.blk` (`data/maps/blocks.asm:198-201`) +- Header: `map GuideGentsHouse, TILESET_HOUSE, INDOOR, LANDMARK_CHERRYGROVE_CITY, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const GUIDE_GENTS_HOUSE, 4, 4` + +**Warps**: 1 (2,7) and 2 (3,7), both -> `CHERRYGROVE_CITY` warp 4. + +**BG events**: (0,1) and (1,1) `BGEVENT_READ` `GuideGentsHouseBookshelf`. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GUIDEGENTSHOUSE_GRAMPS` | `SPRITE_GRAMPS` | 2 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `GuideGentsHouseGuideGent` | `EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE` | + +### MAP_CHERRYGROVE_GYM_SPEECH_HOUSE + +- Script: `maps/CherrygroveGymSpeechHouse.asm`; blocks shared `maps/House1.blk` +- Header: `map CherrygroveGymSpeechHouse, TILESET_HOUSE, INDOOR, LANDMARK_CHERRYGROVE_CITY, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const CHERRYGROVE_GYM_SPEECH_HOUSE, 4, 4` +- Warps: 1 (2,7), 2 (3,7) -> `CHERRYGROVE_CITY` warp 3 +- BG: (0,1), (1,1) `PictureBookshelfScript` +- Objects: `CHERRYGROVEGYMSPEECHHOUSE_POKEFAN_M` at (2,3), `..._BUG_CATCHER` at (5,5); both plain `jumptextfaceplayer`, no flags + +### MAP_CHERRYGROVE_EVOLUTION_SPEECH_HOUSE + +- Script: `maps/CherrygroveEvolutionSpeechHouse.asm`; blocks shared `maps/House1.blk` +- Header: `map CherrygroveEvolutionSpeechHouse, TILESET_HOUSE, INDOOR, LANDMARK_CHERRYGROVE_CITY, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const CHERRYGROVE_EVOLUTION_SPEECH_HOUSE, 4, 4` +- Warps: 1 (2,7), 2 (3,7) -> `CHERRYGROVE_CITY` warp 5 +- BG: (0,1), (1,1) `MagazineBookshelfScript` +- Objects: `..._LASS` at (3,5), `..._YOUNGSTER` at (2,5); both `opentext/writetext` + only (note: no `faceplayer`, so they must be talked to from the side they face) + +### MAP_ROUTE_30 + +- Script: `maps/Route30.asm` +- Blocks: `maps/Route30.blk` +- Header: `map Route30, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_30, MUSIC_ROUTE_30, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `map_const ROUTE_30, 10, 27` (20x54 cells; y grows southward, so + Cherrygrove is at high y and Route 31 at y=0) +- Connections (`map_attributes Route30, ROUTE_30, $05`): north `Route31` (-10), + south `CherrygroveCity` (-5) +- Scenes: none (`def_scene_scripts` is empty). No callbacks. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 39 | `ROUTE_30_BERRY_HOUSE` | 1 | +| 2 | 17 | 5 | `MR_POKEMONS_HOUSE` | 1 | + +**Coord events**: none. The "you can't pass that area just yet" block is done +with objects, not a coord event (see gates). + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 43 | `BGEVENT_READ` | `Route30Sign` | +| 13 | 29 | `BGEVENT_READ` | `MrPokemonsHouseDirectionsSign` | +| 15 | 5 | `BGEVENT_READ` | `MrPokemonsHouseSign` | +| 3 | 21 | `BGEVENT_READ` | `Route30TrainerTips` | +| 14 | 9 | `BGEVENT_ITEM` | `Route30HiddenPotion` (`4a:55a2`) = `hiddenitem POTION, EVENT_ROUTE_30_HIDDEN_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE30_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 5 | 26 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `YoungsterJoey_ImportantBattleScript` | `EVENT_ROUTE_30_BATTLE` | +| `ROUTE30_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 6 | 29 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerYoungsterJoey` (`4a:54c5`) | `EVENT_ROUTE_30_YOUNGSTER_JOEY` | +| `ROUTE30_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 5 | 23 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerYoungsterMikey` (`4a:5553`) | -1 | +| `ROUTE30_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 4 | 7 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerBugCatcherDon` (`4a:5567`) | -1 | +| `ROUTE30_YOUNGSTER4` | `SPRITE_YOUNGSTER` | 7 | 31 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route30YoungsterScript` | -1 | +| `ROUTE30_MONSTER1` | `SPRITE_MONSTER` | 5 | 24 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_ROUTE_30_BATTLE` | +| `ROUTE30_MONSTER2` | `SPRITE_MONSTER` | 5 | 25 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_ROUTE_30_BATTLE` | +| `ROUTE30_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 5 | 39 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route30FruitTree1` (`4a:559e`) | -1 | +| `ROUTE30_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 11 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route30FruitTree2` (`4a:55a0`) | -1 | +| `ROUTE30_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 2 | 13 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route30CooltrainerFScript` | -1 | + +**Scripts of interest** + +- `YoungsterJoey_ImportantBattleScript` (the three objects flagged + `EVENT_ROUTE_30_BATTLE` at (5,24), (5,25), (5,26)): a cutscene, not a battle - + `playmusic MUSIC_JOHTO_TRAINER_BATTLE`, two `applymovement` lunges, "Leave me + alone!", `special RestartMapMusic`, `end`. While the flag is clear these three + objects occupy the west lane at x=5, which is what stops the player walking north + on the left fork. +- `TrainerYoungsterJoey`: `trainer YOUNGSTER, JOEY1, EVENT_BEAT_YOUNGSTER_JOEY, ...` + then the phone chain - `checkcellnum PHONE_YOUNGSTER_JOEY`, + `EVENT_JOEY_ASKED_FOR_PHONE_NUMBER`, `askforphonenumber PHONE_YOUNGSTER_JOEY`. + Rematches pick `JOEY2` (`ENGINE_FLYPOINT_GOLDENROD`) or `JOEY3` + (`ENGINE_FLYPOINT_OLIVINE`). +- `TrainerYoungsterMikey`, `TrainerBugCatcherDon`: plain `trainer` + `endifjustbattled`. +- `Route30YoungsterScript` at (7,31): text switches on `EVENT_GAVE_MYSTERY_EGG_TO_ELM`. +- `Route30HiddenPotion`: `hiddenitem POTION, EVENT_ROUTE_30_HIDDEN_POTION` - a + `BGEVENT_ITEM` bg event, so it is picked up by pressing A on (14,9), not by + stepping on it. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_30_BATTLE` | `constants/event_flags.asm:1206` | **set** by `ElmAfterTheftScript` | when set, the three blockers vanish and the west fork opens | +| `EVENT_ROUTE_30_YOUNGSTER_JOEY` | `constants/event_flags.asm:1207` | set at new game by `InitializeEventsScript` (`engine/events/std_scripts.asm:476`), **cleared** by `ElmAfterTheftScript` | Joey the trainer only exists after the egg hand-in | +| `EVENT_BEAT_YOUNGSTER_JOEY` / `..._MIKEY` / `EVENT_BEAT_BUG_CATCHER_DON` | `constants/event_flags.asm:963,964,839` | `trainer` macro | battle already won | +| `EVENT_ROUTE_30_HIDDEN_POTION` | `constants/event_flags.asm:174` | `hiddenitem` | hidden POTION taken | +| `EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE` | `constants/event_flags.asm:48` | `Route30BerryHousePokefanMScript` | free BERRY taken | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `POTION` | hidden, press A facing (14,9) | `Route30HiddenPotion` bg event | `EVENT_ROUTE_30_HIDDEN_POTION` | +| `BERRY` | tree at (5,39) | `FRUITTREE_ROUTE_30_1` | daily | +| `PSNCUREBERRY` | tree at (11,5) | `FRUITTREE_ROUTE_30_2` | daily | +| `BERRY` | Pokefan inside the berry house | `Route30BerryHousePokefanMScript` (`62:455c`) | `EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE` | +| `MYSTERY_EGG`, POKEDEX | Mr. Pokemon's House (below) | | | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `JOEY1` | `YOUNGSTER` (`$10`) | 1 | `YoungsterGroup` (1) "JOEY": L4 RATTATA | `TrainerYoungsterJoey` | yes, `PHONE_YOUNGSTER_JOEY`; `JOEY2` = L15 RATTATA, `JOEY3` = L21 RATICATE (TAIL_WHIP, QUICK_ATTACK, HYPER_FANG, SCARY_FACE) | +| `MIKEY` | `YOUNGSTER` | 2 | `YoungsterGroup` (2) "MIKEY": **L2 PIDGEY, L4 RATTATA** | `TrainerYoungsterMikey` | no | +| `DON` | `BUG_CATCHER` (`$18`) | 1 | `BugCatcherGroup` (1) "DON": L3 CATERPIE, L3 CATERPIE | `TrainerBugCatcherDon` | no | + +Base rewards: YOUNGSTER `db 4`, BUG_CATCHER `db 4` +(`data/trainers/attributes.asm`), paid 4x -> Joey 4*4*4 = 64G, Mikey 4*4*4 = 64G, +Don 4*3*4 = 48G. All three match the walkthrough. + +**Wild encounters** (`def_grass_wildmons ROUTE_30`, 10/10/10 percent; the file is +`IF DEF(_GOLD) ... ELIF DEF(_SILVER)`) + +Gold: +- morn: 2 PIDGEY, 3 CATERPIE, 4 CATERPIE, 4 METAPOD, 4 PIDGEY, 4 PIDGEY, 4 PIDGEY +- day: 2 PIDGEY, 3 CATERPIE, 4 PIDGEY, 4 METAPOD, 4 CATERPIE, 5 METAPOD, 5 METAPOD +- nite: 3 SPINARAK, 3 RATTATA, 4 HOOTHOOT, 4 RATTATA, 4 HOOTHOOT, 4 HOOTHOOT, 4 HOOTHOOT + +Silver (for contrast, since the walkthrough lists both): LEDYBA/WEEDLE/KAKUNA in +the morn/day rows, HOOTHOOT/RATTATA at nite. + +Water (`data/wild/johto_water.asm`, `def_water_wildmons ROUTE_30`, 2 percent): +20 POLIWAG, 15 POLIWAG, 20 POLIWHIRL - surf only, so not reachable in this section. +Fishing group `FISHGROUP_POND` (`.Pond_Old`: MAGIKARP/POLIWAG). + +### MAP_ROUTE_30_BERRY_HOUSE + +- Script: `maps/Route30BerryHouse.asm`; blocks shared `maps/House1.blk` +- Header: `map Route30BerryHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_30, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const ROUTE_30_BERRY_HOUSE, 4, 4` + +**Warps**: 1 (2,7), 2 (3,7) -> `ROUTE_30` warp 1. +**BG events**: (0,1), (1,1) `MagazineBookshelfScript`. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE30BERRYHOUSE_POKEFAN_M` | `SPRITE_POKEFAN_M` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route30BerryHousePokefanMScript` (`62:455c`) | -1 | + +Script: `checkevent EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE`; if clear, +`verbosegiveitem BERRY` then set the event. + +### MAP_MR_POKEMONS_HOUSE + +- Script: `maps/MrPokemonsHouse.asm` +- Blocks: `maps/MrPokemonsHouse.blk` (`data/maps/blocks.asm:940`) +- Header: `map MrPokemonsHouse, TILESET_FACILITY, INDOOR, LANDMARK_ROUTE_30, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const MR_POKEMONS_HOUSE, 4, 4` +- Scenes: `SCENE_MRPOKEMONSHOUSE_MEET_MR_POKEMON` = 0, + `SCENE_MRPOKEMONSHOUSE_NOOP` = 1 + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_30` | 2 | +| 2 | 3 | 7 | `ROUTE_30` | 2 | + +**Coord events**: none - the cutscene is a **scene script** +(`scene_script MrPokemonsHouseMeetMrPokemonScene, SCENE_MRPOKEMONSHOUSE_MEET_MR_POKEMON` +-> `sdefer MrPokemonsHouseMrPokemonEventScript`), so it fires on map load. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `MrPokemonsHouse_ForeignMagazines` | +| 1 | 1 | `BGEVENT_READ` | `MrPokemonsHouse_ForeignMagazines` | +| 6 | 1 | `BGEVENT_READ` | `MrPokemonsHouse_BrokenComputer` | +| 7 | 1 | `BGEVENT_READ` | `MrPokemonsHouse_BrokenComputer` | +| 6 | 4 | `BGEVENT_READ` | `MrPokemonsHouse_StrangeCoins` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MRPOKEMONSHOUSE_GENTLEMAN` | `SPRITE_GENTLEMAN` | 3 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MrPokemonsHouse_MrPokemonScript` | -1 | +| `MRPOKEMONSHOUSE_OAK` | `SPRITE_OAK` | 6 | 5 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_MR_POKEMONS_HOUSE_OAK` | + +**Scripts of interest** + +- `MrPokemonsHouseMrPokemonEventScript` (`62:464e`): `showemote EMOTE_SHOCK`, + `applymovement PLAYER, MrPokemonsHouse_PlayerWalksToMrPokemon` + (`step RIGHT`, `step UP`), `giveitem MYSTERY_EGG`, `itemnotify`, + `setevent EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON`, + **`blackoutmod CHERRYGROVE_CITY`** (respawn point moves), then falls into the Oak script. +- `MrPokemonsHouse_OakScript` (`62:46c1`): `playmusic MUSIC_PROF_OAK`, + `applymovement MRPOKEMONSHOUSE_OAK, MrPokemonsHouse_OakWalksToPlayer`, + **`setflag ENGINE_POKEDEX`**, `disappear MRPOKEMONSHOUSE_OAK`, + `special HealParty` (Mr. Pokemon's free heal the walkthrough mentions), then the + whole post-visit world state: + - `setevent EVENT_RIVAL_NEW_BARK_TOWN` + - `setscene SCENE_MRPOKEMONSHOUSE_NOOP` + - `setmapscene CHERRYGROVE_CITY, SCENE_CHERRYGROVECITY_MEET_RIVAL` + - `setmapscene ELMS_LAB, SCENE_ELMSLAB_MEET_OFFICER` + - `specialphonecall SPECIALCALL_ROBBED` + - `clearevent EVENT_COP_IN_ELMS_LAB` (cop appears) + - starter fork: sets `EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB` / + `EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB` / `EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB` + (which ball is *missing* in the lab afterwards). +- `MrPokemonsHouse_MrPokemonScript`: repeat talk. Also the RED_SCALE -> + `EXP_SHARE` trade, far outside this section. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON` | `constants/event_flags.asm:39` | this scene | egg in bag | +| `ENGINE_POKEDEX` | `constants/engine_flags.asm:20` | `MrPokemonsHouse_OakScript` | dex owned | +| `EVENT_MR_POKEMONS_HOUSE_OAK` | `constants/event_flags.asm:1131` | `disappear MRPOKEMONSHOUSE_OAK` | Oak gone | +| `SPECIALCALL_ROBBED` | `constants/phone_constants.asm:46` | queued here, delivered by `ElmPhoneCallerScript` `.disaster` (`engine/phone/scripts/elm.asm:75`) | that call also does `setevent EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MYSTERY_EGG` | scene on entry | `giveitem MYSTERY_EGG` in `MrPokemonsHouseMrPokemonEventScript` | `EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON` | +| POKEDEX (`ENGINE_POKEDEX`) | same scene | `MrPokemonsHouse_OakScript` | engine flag | + +### MAP_ROUTE_31 + +- Script: `maps/Route31.asm` +- Blocks: `maps/Route31.blk` +- Header: `map Route31, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_31, MUSIC_ROUTE_30, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `map_const ROUTE_31, 20, 9` (40x18 cells) +- Connections: south `Route30` (offset 10), west `VioletCity` (-9) +- Callbacks: `callback MAPCALLBACK_NEWMAP, Route31CheckMomCallCallback` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 6 | `ROUTE_31_VIOLET_GATE` | 3 | +| 2 | 4 | 7 | `ROUTE_31_VIOLET_GATE` | 4 | +| 3 | 34 | 5 | `DARK_CAVE_VIOLET_ENTRANCE` | 1 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 5 | `BGEVENT_READ` | `Route31Sign` | +| 31 | 5 | `BGEVENT_READ` | `DarkCaveSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE31_FISHER` | `SPRITE_FISHER` | 17 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route31MailRecipientScript` | -1 | +| `ROUTE31_YOUNGSTER` | `SPRITE_YOUNGSTER` | 9 | 5 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `Route31YoungsterScript` | -1 | +| `ROUTE31_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 18 | 15 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerBugCatcherWade1` (`4a:5a01`) | -1 | +| `ROUTE31_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 33 | 8 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `Route31CooltrainerMScript` | -1 | +| `ROUTE31_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 16 | 7 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route31FruitTree` (`4a:5b18`) | -1 | +| `ROUTE31_POKE_BALL1` | `SPRITE_POKE_BALL` | 29 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route31Antidote` (`4a:5b1a`) | `EVENT_ROUTE_31_ANTIDOTE` | +| `ROUTE31_POKE_BALL2` | `SPRITE_POKE_BALL` | 21 | 13 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route31PokeBall` (`4a:5b1c`) | `EVENT_ROUTE_31_POKE_BALL` | + +**Scripts of interest** + +- `Route31CheckMomCallCallback` (`MAPCALLBACK_NEWMAP`): + `checkevent EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST`; if **false**, + `specialphonecall SPECIALCALL_WORRIED`. A bot that skips the Bank of Mom talk + gets a scripted call the first time it walks onto Route 31. +- `TrainerBugCatcherWade1`: `trainer BUG_CATCHER, WADE1, EVENT_BEAT_BUG_CATCHER_WADE, ...`, + phone chain on `PHONE_BUG_CATCHER_WADE`, rematches `WADE2` / `WADE3` gated on + `ENGINE_FLYPOINT_GOLDENROD` / `ENGINE_FLYPOINT_MAHOGANY`. +- `Route31MailRecipientScript` at (17,7): the Kenya / SPEAROW-with-mail sidequest + (`EVENT_GOT_KENYA` -> `checkpokemail` -> `verbosegiveitem TM_NIGHTMARE`). Kenya + comes from Goldenrod, so this is dormant in this section. +- `Route31YoungsterScript`: the FALKNER / Violet Gym hint. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_31_ANTIDOTE` | `constants/event_flags.asm:1103` | `itemball` | ball at (29,5) taken | +| `EVENT_ROUTE_31_POKE_BALL` | `constants/event_flags.asm:1104` | `itemball` | ball at (21,13) taken | +| `EVENT_BEAT_BUG_CATCHER_WADE` | `constants/event_flags.asm:842` | `trainer` macro | Wade beaten | +| `EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST` | `constants/event_flags.asm:73` | read by the map callback, set by `MomScript` | suppresses the "worried Mom" call | +| `SPECIALCALL_WORRIED` | `constants/phone_constants.asm:51` | map callback | queued call | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ANTIDOTE` | item ball at (29,5), just west of the Dark Cave mouth | `Route31Antidote` | `EVENT_ROUTE_31_ANTIDOTE` | +| `POKE_BALL` | item ball at (21,13) | `Route31PokeBall` | `EVENT_ROUTE_31_POKE_BALL` | +| `BITTER_BERRY` | tree at (16,7) | `FRUITTREE_ROUTE_31` (`data/items/fruit_trees.asm`) | daily | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `WADE1` | `BUG_CATCHER` (`$18`) | 4 | `BugCatcherGroup` (4) "WADE": L2 CATERPIE, L2 CATERPIE, L3 WEEDLE, L2 CATERPIE | `TrainerBugCatcherWade1` | yes, `PHONE_BUG_CATCHER_WADE` | + +Reward 4 (base) * 2 (last mon level) * 4 = 32G, matching the walkthrough. + +**Wild encounters** (`def_grass_wildmons ROUTE_31`, 10/10/10 percent, Gold arm) + +- morn: 3 PIDGEY, 4 CATERPIE, 3 BELLSPROUT, 5 METAPOD, 5 CATERPIE, 5 METAPOD, 5 METAPOD +- day: 3 PIDGEY, 4 CATERPIE, 3 BELLSPROUT, 5 METAPOD, 5 CATERPIE, 6 METAPOD, 6 METAPOD +- nite: 4 SPINARAK, 4 RATTATA, 3 BELLSPROUT, 5 RATTATA, 5 HOOTHOOT, 5 HOOTHOOT, 5 HOOTHOOT + +Water (`def_water_wildmons ROUTE_31`, 2 percent): 20 POLIWAG, 15 POLIWAG, +20 POLIWHIRL. Fishing group `FISHGROUP_POND`. + +### MAP_ROUTE_31_VIOLET_GATE + +- Script: `maps/Route31VioletGate.asm`; blocks shared `maps/EastWestGate.blk` + (`data/maps/blocks.asm:280`) +- Header: `map Route31VioletGate, TILESET_GATE, GATE, LANDMARK_ROUTE_31, MUSIC_ROUTE_30, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const ROUTE_31_VIOLET_GATE, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `VIOLET_CITY` | 8 | +| 2 | 0 | 5 | `VIOLET_CITY` | 9 | +| 3 | 9 | 4 | `ROUTE_31` | 1 | +| 4 | 9 | 5 | `ROUTE_31` | 2 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE31VIOLETGATE_OFFICER` | `SPRITE_OFFICER` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route31VioletGateOfficerScript` | -1 | +| `ROUTE31VIOLETGATE_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 1 | 2 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `Route31VioletGateCooltrainerFScript` | -1 | + +Nothing blocks; both NPCs are `jumptextfaceplayer`. + +### Return-leg maps + +Only the beats this section's text drives are transcribed. The first visit to all +three belongs to the previous section. + +#### MAP_NEW_BARK_TOWN + +- Script: `maps/NewBarkTown.asm`; blocks `maps/NewBarkTown.blk` +- Header: `map NewBarkTown, TILESET_JOHTO, TOWN, LANDMARK_NEW_BARK_TOWN, MUSIC_NEW_BARK_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `map_const NEW_BARK_TOWN, 10, 9`; connections west `Route29` (0), + east `Route27` (0) +- Callback `NewBarkTownFlypointCallback`: `setflag ENGINE_FLYPOINT_NEW_BARK` and + `clearevent EVENT_FIRST_TIME_BANKING_WITH_MOM` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 3 | `ELMS_LAB` | 1 | +| 2 | 13 | 5 | `PLAYERS_HOUSE_1F` | 1 | +| 3 | 3 | 11 | `PLAYERS_NEIGHBORS_HOUSE` | 1 | +| 4 | 11 | 13 | `ELMS_HOUSE` | 1 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` (=0) | 1 | 8 | `NewBarkTown_TeacherStopsYouScene1` | teacher drags you back (`follow` + `applymovement`) | +| `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` | 1 | 9 | `NewBarkTown_TeacherStopsYouScene2` | same, other lane | + +`ElmDirectionsScript` does `setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP`, so +once a starter is in hand the west exit at x=1 is free. `NEWBARKTOWN_RIVAL` +(object at (3,2), flag `EVENT_RIVAL_NEW_BARK_TOWN`) is un-hidden by +`MrPokemonsHouse_OakScript` - he shoves the player away from the lab window. + +#### MAP_ELMS_LAB + +- Script: `maps/ElmsLab.asm`; blocks `maps/ElmsLab.blk` +- Header: `map ElmsLab, TILESET_LAB, INDOOR, LANDMARK_NEW_BARK_TOWN, MUSIC_PROF_ELM, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const ELMS_LAB, 5, 6` +- Scenes (ordinals): `SCENE_ELMSLAB_MEET_ELM` 0, `SCENE_ELMSLAB_CANT_LEAVE` 1, + `SCENE_ELMSLAB_NOOP` 2, `SCENE_ELMSLAB_MEET_OFFICER` 3, `SCENE_ELMSLAB_UNUSED` 4, + `SCENE_ELMSLAB_AIDE_GIVES_POTION` 5, `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` 6 + (the last is a bare `scene_const` - a scene id with no scene script, only coord + events) + +**Warps**: 1 (4,11), 2 (5,11) -> `NEW_BARK_TOWN` warp 1. + +**Coord events** (the ones this section fires) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ELMSLAB_MEET_OFFICER` | 4 | 5 | `MeetCopScript` | walk-up then `CopScript` | +| `SCENE_ELMSLAB_MEET_OFFICER` | 5 | 5 | `MeetCopScript2` | `step LEFT` first, then the same | +| `SCENE_ELMSLAB_AIDE_GIVES_POTION` | 4 | 8 | `AideScript_WalkPotion1` | `verbosegiveitem POTION` | +| `SCENE_ELMSLAB_AIDE_GIVES_POTION` | 5 | 8 | `AideScript_WalkPotion2` | same | +| `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` | 4 | 8 | `AideScript_WalkBalls1` | `giveitem POKE_BALL, 5` | +| `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS` | 5 | 8 | `AideScript_WalkBalls2` | same | +| `SCENE_ELMSLAB_CANT_LEAVE` | 4/5 | 6 | `LabTryToLeaveScript` | pushes the player back up (pre-starter) | + +**Object events** (relevant rows) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ELMSLAB_ELM` | `SPRITE_ELM` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ProfElmScript` | -1 | +| `ELMSLAB_ELMS_AIDE` | `SPRITE_SCIENTIST` | 2 | 9 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `ElmsAideScript` | `EVENT_ELMS_AIDE_IN_LAB` | +| `ELMSLAB_OFFICER` | `SPRITE_OFFICER` | 5 | 3 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `CopScript` (`60:4366`) | `EVENT_COP_IN_ELMS_LAB` | + +**Scripts of interest** + +- `CopScript` (`60:4366`): `special NameRival` (this is the "name your rival" + prompt), then `applymovement OfficerLeavesMovement`, `disappear ELMSLAB_OFFICER`, + `setscene SCENE_ELMSLAB_NOOP`. +- `ElmAfterTheftScript` (`60:41f1`), reached from `ProfElmScript` when + `EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON` is set: `checkitem MYSTERY_EGG`, + `takeitem MYSTERY_EGG`, `setevent EVENT_GAVE_MYSTERY_EGG_TO_ELM`, + `setmapscene ROUTE_29, SCENE_ROUTE29_CATCH_TUTORIAL`, + `clearevent EVENT_ROUTE_30_YOUNGSTER_JOEY`, `setevent EVENT_ROUTE_30_BATTLE`, + `setscene SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS`. This one script is what turns on + the rest of the section. +- `AideScript_GiveYouBalls` (`60:4317`): `giveitem POKE_BALL, 5`, `itemnotify`, + `setscene SCENE_ELMSLAB_NOOP`. + +#### MAP_PLAYERS_HOUSE_1F + +- Script: `maps/PlayersHouse1F.asm`; blocks `maps/PlayersHouse1F.blk` +- Dimensions: `map_const PLAYERS_HOUSE_1F, 5, 4` +- Warps: 1 (6,7), 2 (7,7) -> `NEW_BARK_TOWN` warp 2; 3 (9,0) -> `PLAYERS_HOUSE_2F` 1 + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PLAYERSHOUSE1F_MOM1` | `SPRITE_MOM` | 7 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `MomScript` (`60:56cb`) | `EVENT_PLAYERS_HOUSE_MOM_1` | +| `PLAYERSHOUSE1F_MOM2` | `SPRITE_MOM` | 2 | 2 | `SPRITEMOVEDATA_STANDING_UP` (hours `-1, MORN`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | +| `PLAYERSHOUSE1F_MOM3` | `SPRITE_MOM` | 7 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` (hours `-1, DAY`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | +| `PLAYERSHOUSE1F_MOM4` | `SPRITE_MOM` | 0 | 2 | `SPRITEMOVEDATA_STANDING_UP` (hours `-1, NITE`) | `OBJECTTYPE_SCRIPT` | `MomScript` | `EVENT_PLAYERS_HOUSE_MOM_2` | + +Mom's position depends on the time of day - a bot must find her, not assume (7,3). + +`MomScript` (`60:56cb`): with `EVENT_GAVE_MYSTERY_EGG_TO_ELM` set it runs +`.GaveMysteryEgg` -> `setevent EVENT_FIRST_TIME_BANKING_WITH_MOM`, +`setevent EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST`, `special BankOfMom`. +That is the walkthrough's "talk to your mom, who will save your money", and it is +also what silences the Route 31 `SPECIALCALL_WORRIED` callback. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Cannot leave New Bark Town westward before a starter | `maps/NewBarkTown.asm:NewBarkTown_TeacherStopsYouScene1/2` at coord events (1,8)/(1,9), scene `SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU` | a starter | `maps/ElmsLab.asm:ElmDirectionsScript` -> `setmapscene NEW_BARK_TOWN, SCENE_NEWBARKTOWN_NOOP` | +| Cannot leave the lab before a starter | `maps/ElmsLab.asm:LabTryToLeaveScript`, coord events (4,6)/(5,6) under `SCENE_ELMSLAB_CANT_LEAVE` | a starter | same `ElmDirectionsScript`, via `setscene SCENE_ELMSLAB_AIDE_GIVES_POTION` | +| Route 30 west fork blocked by "the two boys battling" | `maps/Route30.asm` object rows at (5,24), (5,25), (5,26), all flagged `EVENT_ROUTE_30_BATTLE`; objects are visible while the flag is **clear** (`engine/overworld/map_objects_2.asm:CheckObjectFlag`) | deliver the MYSTERY EGG | `maps/ElmsLab.asm:ElmAfterTheftScript` -> `setevent EVENT_ROUTE_30_BATTLE` (hides them) and `clearevent EVENT_ROUTE_30_YOUNGSTER_JOEY` (spawns Joey the trainer) | +| Rival battle before leaving Cherrygrove east | `maps/CherrygroveCity.asm` coord events (33,6)/(33,7) under `SCENE_CHERRYGROVECITY_MEET_RIVAL` | none - it is `BATTLETYPE_CANLOSE`, so a loss still advances | armed by `maps/MrPokemonsHouse.asm:MrPokemonsHouse_OakScript` (`setmapscene CHERRYGROVE_CITY, ...`); cleared by the scene's own `setscene SCENE_CHERRYGROVECITY_NOOP` | +| Cop cutscene in Elm's Lab | `maps/ElmsLab.asm` coord events (4,5)/(5,5) under `SCENE_ELMSLAB_MEET_OFFICER` -> `CopScript` (`special NameRival`) | walk to y=5 | `setscene SCENE_ELMSLAB_NOOP` at the end of `CopScript` | +| POKE BALLs not for sale | `maps/CherrygroveMart.asm:CherrygroveMartClerkScript` (`checkevent EVENT_GAVE_MYSTERY_EGG_TO_ELM`) -> `MART_CHERRYGROVE` vs `MART_CHERRYGROVE_DEX` in `data/items/marts.asm` | deliver the egg | `ElmAfterTheftScript` | +| Catch tutorial cannot fire yet | `maps/Route29.asm` coord events (53,8)/(53,9) need scene `SCENE_ROUTE29_CATCH_TUTORIAL` | deliver the egg | `ElmAfterTheftScript` -> `setmapscene ROUTE_29, SCENE_ROUTE29_CATCH_TUTORIAL` | +| Dark Cave is unusable (the walkthrough's "wait for Flash") | `engine/events/overworld.asm:FlashFunction/.CheckUseFlash`: `ld de, ENGINE_ZEPHYRBADGE / farcall CheckBadge / jr c, .nozephyrbadge`, then `wTimeOfDayPalset == DARKNESS_PALSET` | ZEPHYRBADGE **and** HM05 Flash in the party | Violet Gym, next sections. The cave itself is enterable (`Route31` warp 3 at (34,5)) - it is just black | +| Tuscany / PINK BOW not obtainable | `maps/Route29.asm:Route29TuscanyCallback` - `checkflag ENGINE_ZEPHYRBADGE`, else `disappear`; then `VAR_WEEKDAY == TUESDAY` | ZEPHYRBADGE + Tuesday | later section | +| Mom's "worried" call | `maps/Route31.asm:Route31CheckMomCallCallback` - fires `SPECIALCALL_WORRIED` unless `EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST` | not a blocker, but an unavoidable interruption | talk to Mom (`MomScript`) before entering Route 31 | + +--- + +## 4. Bot checklist + +Preconditions in brackets, postconditions after the arrow. + +1. `ELMS_LAB` - walk to (4,8) or (5,8). [scene `SCENE_ELMSLAB_AIDE_GIVES_POTION`] + -> aide script runs, POTION in bag, `setscene SCENE_ELMSLAB_NOOP`. +2. `ELMS_LAB` - warp out at (4,11)/(5,11) -> `NEW_BARK_TOWN` warp 1 (6,3). +3. `NEW_BARK_TOWN` - walk west past x=1. [`SCENE_NEWBARKTOWN_NOOP`] -> Route 29 + (west connection). +4. `ROUTE_29` - walk west along y=11..13. Optional: face (12,2) and press A -> + BERRY (`fruittree`, daily); face/step on (48,2) and press A -> POTION, + sets `EVENT_ROUTE_29_POTION`. +5. `ROUTE_29` - grind wild encounters (10% step rate, `def_grass_wildmons ROUTE_29`) + to the walkthrough's level 8. +6. `ROUTE_29` -> `CHERRYGROVE_CITY` (west connection). -> map callback sets + `ENGINE_FLYPOINT_CHERRYGROVE`. +7. `CHERRYGROVE_CITY` - talk to `CHERRYGROVECITY_GRAMPS` at (32,6), answer YES. + [`EVENT_GUIDE_GENT_IN_HIS_HOUSE` clear] -> follow tour, MAP CARD, + `setflag ENGINE_MAP_CARD`, gramps disappears. +8. `CHERRYGROVE_CITY` - warp 2 at (29,3) -> Pokecenter; talk to nurse at (3,1) + from (3,2) -> party healed. Exit warps 1/2 at (3,7)/(4,7). +9. `CHERRYGROVE_CITY` - walk north out of the map (north connection, offset 5) -> + `ROUTE_30`, arriving at high y. +10. `ROUTE_30` - warp 1 at (7,39) -> berry house; talk to the Pokefan at (2,3) + -> BERRY, `EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE`. Exit warps 1/2 at (2,7)/(3,7). +11. `ROUTE_30` - face (11,5) press A -> PSNCUREBERRY (daily). Face (14,9) press A + -> hidden POTION, `EVENT_ROUTE_30_HIDDEN_POTION`. +12. `ROUTE_30` - take the **east** fork north to warp 2 at (17,5) -> + `MR_POKEMONS_HOUSE`. +13. `MR_POKEMONS_HOUSE` - the scene fires on load + (`SCENE_MRPOKEMONSHOUSE_MEET_MR_POKEMON`). Just advance text. + -> `MYSTERY_EGG`, `EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON`, + `blackoutmod CHERRYGROVE_CITY`, `ENGINE_POKEDEX`, party healed, + `SPECIALCALL_ROBBED` queued, `EVENT_COP_IN_ELMS_LAB` cleared, + `SCENE_CHERRYGROVECITY_MEET_RIVAL` and `SCENE_ELMSLAB_MEET_OFFICER` armed. +14. Walk out (warps 1/2 at (2,7)/(3,7)) and take a few overworld steps -> + Elm's phone call (`engine/phone/scripts/elm.asm` `.disaster`) -> + `EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON`. +15. `ROUTE_30` south -> `CHERRYGROVE_CITY`. Heal at the Pokecenter. +16. `CHERRYGROVE_CITY` - walk east through x=33 on row 6 or 7. -> rival scene, + battle `RIVAL1` id 1/2/3 by starter (level 5, `BATTLETYPE_CANLOSE`), then + `special HealParty`, `SCENE_CHERRYGROVECITY_NOOP`. +17. `CHERRYGROVE_CITY` east -> `ROUTE_29` east -> `NEW_BARK_TOWN`. +18. `NEW_BARK_TOWN` - warp 1 at (6,3) -> `ELMS_LAB`. Step onto (4,5) or (5,5) + [`SCENE_ELMSLAB_MEET_OFFICER`] -> `CopScript`, `special NameRival` prompt, + officer leaves, `SCENE_ELMSLAB_NOOP`. +19. `ELMS_LAB` - talk to `ELMSLAB_ELM` at (5,2). [`MYSTERY_EGG` in bag] + -> `ElmAfterTheftScript`: egg taken, `EVENT_GAVE_MYSTERY_EGG_TO_ELM`, + Route 29 tutorial armed, Route 30 blockers removed, Joey spawned, + `SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS`. +20. `ELMS_LAB` - walk to (4,8) or (5,8) -> aide gives 5 POKE BALLs. +21. `NEW_BARK_TOWN` - warp 2 at (13,5) -> `PLAYERS_HOUSE_1F`; talk to Mom + (position depends on time of day: (7,3) by day, (2,2) morn, (0,2) nite) + -> `EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST`, + `EVENT_FIRST_TIME_BANKING_WITH_MOM`, Bank of Mom menu. +22. `ROUTE_29` - step on (53,8) or (53,9) -> catch tutorial. YES runs the demo + (`loadwildmon RATTATA, 5` + `catchtutorial`); NO also clears the scene. +23. Optional `ROUTE_46`: `ROUTE_29` warp 1 at (27,1) -> gate warp 3, gate warps + 1/2 at (4,0)/(5,0) -> Route 46 (7,33)/(8,33). Hunt RATTATA (night has two + RATTATA slots). Grab DIRE_HIT at (0,12). Avoid the three trainers - they are + L13-L17 and far above the party. +24. `CHERRYGROVE_CITY` - heal; optionally buy POKE BALLs (`MART_CHERRYGROVE_DEX` + is live now) via warp 1 at (23,3). +25. `ROUTE_30` - take the **west** fork north. Trainers in walk order: + `TrainerYoungsterJoey` at (6,29) sight 4, `TrainerYoungsterMikey` at (5,23) + sight 1, `TrainerBugCatcherDon` at (4,7) sight 3. + -> `EVENT_BEAT_YOUNGSTER_JOEY`, `EVENT_BEAT_YOUNGSTER_MIKEY`, + `EVENT_BEAT_BUG_CATCHER_DON`. Joey will ask for a phone number. +26. `ROUTE_30` north (y -> 0) -> `ROUTE_31`. -> map callback: if Mom was not + talked to, `SPECIALCALL_WORRIED` is queued. +27. `ROUTE_31` - item ball at (29,5) -> ANTIDOTE. Sign at (31,5) is the Dark Cave + sign; the cave warp is at (34,5) - **skip it**, no Flash. +28. `ROUTE_31` - `TrainerBugCatcherWade1` at (18,15), sight 3 (approach from below). + -> `EVENT_BEAT_BUG_CATCHER_WADE`, phone number offer. +29. `ROUTE_31` - item ball at (21,13) -> POKE BALL. Tree at (16,7) -> BITTER BERRY. +30. `ROUTE_31` - warps 1/2 at (4,6)/(4,7) -> `ROUTE_31_VIOLET_GATE` warps 3/4; + cross to gate warps 1/2 at (0,4)/(0,5) -> `VIOLET_CITY` warps 8/9. + **End of section.** + +--- + +## 5. Port coverage + +The Gen 2 port is data-driven: `src/import/RomExtractorGen2.lua` reads map headers, +warps, coord events, bg events, object events, trainer structs and encounter tables +out of the ROM, and `src/world/gen2/World.lua` + `src/script/gen2/Vm.lua` replay +them. So coverage below is per-mechanic, not per-map. + +| Beat | Port file | Status | +|---|---|---| +| Map headers / warps / connections / coord events / bg events / objects | `src/import/RomExtractorGen2.lua`, `src/world/gen2/Map.lua`, `src/world/gen2/World.lua` (`self.map.def.coordEvents` at World.lua:5013) | implemented | +| Scene scripts + `setscene` / `setmapscene` | `src/script/gen2/Vm.lua` (`setmapscene` at Vm.lua:279), `src/world/gen2/World.lua` (`s.sceneId` match at World.lua:5033) | implemented | +| Object visibility from `EVENT_*` (`appear` / `disappear`, the Route 30 blockers, the rival, the cop) | `src/world/gen2/Events.lua`, `src/world/gen2/World.lua` | implemented | +| `follow` / `stopfollow` (Guide Gent tour, catch-tutorial DUDE, New Bark teacher) | `src/script/gen2/Vm.lua:982` | implemented; driver `tests/drivers/gold_teacher_scene.lua` asserts the follow leg | +| Overworld trainer sight + battle + beat flag | `src/world/gen2/Trainers.lua`, `src/world/gen2/World.lua` | implemented; driver `tests/drivers/gold_trainer_smoke.lua` runs the **Route 30 bug catcher** end to end | +| Trainer phone numbers / rematch chain (Joey, Wade) | `src/core/gen2/Phone.lua`, `src/script/gen2/Vm.lua` (`askforphonenumber`, `checkcellnum`) | implemented (not audited against this section's specific rematch gates) | +| `specialphonecall` queue + Elm's `SPECIALCALL_ROBBED` / Mom's `SPECIALCALL_WORRIED` | `src/script/gen2/Vm.lua:1364`, `src/core/gen2/Phone.lua` | implemented | +| Catch tutorial (`catchtutorial BATTLETYPE_TUTORIAL`) | `src/core/gen2/CatchTutorial.lua`, `src/core/gen2/AutoInput.lua`, `src/script/gen2/Vm.lua` | implemented | +| Fruit trees (Route 29/30/31/46 berries) | `src/script/gen2/Vm.lua:1191` (inlines `FruitTreeScript` because the extractor cannot reach it), `src/core/gen2/Apricorns.lua` | implemented | +| Hidden items (`BGEVENT_ITEM`, Route 30's POTION) | `src/world/gen2/HiddenItems.lua`, dispatch at `src/world/gen2/World.lua:5291-5296` | implemented | +| **Item balls** (`OBJECTTYPE_ITEMBALL`: Route 29 POTION, Route 31 ANTIDOTE + POKE BALL, Route 46 DIRE HIT) | extracted into `obj.itemball` at `src/import/RomExtractorGen2.lua:2968`; **nothing consumes it** - the A-press dispatch at `src/world/gen2/World.lua:5265-5296` only handles trainer, strength boulder, `scriptKey`, then hidden items, and `itemball` appears nowhere else in `src/` | **missing** - every ground item ball in this section is un-takeable | +| `verbosegiveitem` / `giveitem` / `itemnotify` (egg, Poke Balls, berries, MYSTIC WATER) | `src/script/gen2/Vm.lua` | implemented | +| `pokemart` + the `MART_CHERRYGROVE` / `MART_CHERRYGROVE_DEX` switch | `src/ui/gen2/MartMenu.lua`, `src/script/gen2/Vm.lua` | implemented (the switch itself is just a `checkevent`, so it follows for free) | +| `special BankOfMom` (Mom saving money) | `src/ui/gen2/BankOfMom.lua`, `src/script/gen2/Specials.lua` | implemented; driver `tests/drivers/gold_mom_scene.lua` covers the `MeetMomScript` cutscene, not the banking talk | +| `special NameRival` (cop scene) | `src/script/gen2/Specials.lua`, `src/ui/gen2/NamingScreen.lua` | implemented | +| `setflag ENGINE_POKEDEX` / `ENGINE_FLYPOINT_*` / `ENGINE_ZEPHYRBADGE` | `src/script/gen2/Vm.lua:208`, `src/world/gen2/World.lua:1304-1339` (`save.engineFlags`) | implemented | +| **`setflag ENGINE_MAP_CARD` reaching the Pokegear** | written to `save.engineFlags` (World.lua:1325); `src/ui/gen2/Pokegear.lua:935` reads `save.pokegearFlags` with keys `map` / `radio` / `phone`, and no code in the repo writes `pokegearFlags` outside tests and `tests/drivers/gold_menu_shots.lua` | **partial** - the Guide Gent's reward is recorded but the MAP card will not appear on the Pokegear | +| Time-of-day wild tables (morn/day/nite split, per-time rate) | `src/battle/gen2/Encounter.lua` | implemented | +| Fishing groups (`FISHGROUP_SHORE` / `FISHGROUP_POND`) | `src/battle/gen2/Encounter.lua:81-99` | implemented | +| Water encounters (Route 30/31 Poliwag) | `src/battle/gen2/Encounter.lua` | implemented but unreachable in this section (needs Surf) | +| Flash / `ENGINE_ZEPHYRBADGE` gate on Dark Cave | `src/world/gen2/FieldMoves.lua` | implemented | +| `blackoutmod CHERRYGROVE_CITY` | `src/script/gen2/Vm.lua`, `src/world/gen2/World.lua` | implemented | +| Route 29 Tuscany weekday/badge callback | `src/world/gen2/World.lua` map callbacks; asserted by `tests/drivers/gold_map_callbacks.lua:118-158` | implemented | +| New Bark -> Route 29 edge crossing | `tests/drivers/gold_walk_smoke.lua` | implemented (driver walks bedroom -> Route 29) | +| Cherrygrove City itself (guide tour, rival scene, Pokecenter) | no dedicated driver found under `tests/drivers/gold_*.lua` | untested | + +--- + +## 6. Unresolved / verify by hand + +1. **Route 30 "Antidote" does not exist.** The walkthrough says "Just above the + house is an Antidote". `maps/Route30.asm` has no `OBJECTTYPE_ITEMBALL` object + at all; the only ground item is the hidden `POTION` at bg event (14,9) + (`Route30HiddenPotion`). The ANTIDOTE is on Route 31, item ball at (29,5). +2. **Route 31 "Potion" is an Antidote.** The walkthrough says "grab that Potion to + the left of [Dark Cave]". The ball west of the Dark Cave mouth (34,5) is at + (29,5) and is `itemball ANTIDOTE`. There is no POTION object on Route 31. +3. **Youngster Mikey's party.** The walkthrough says "Level 2 Rattata / Level 4 + Rattata". `data/trainers/parties.asm` `YoungsterGroup` (2) "MIKEY" is + `db 2, PIDGEY` / `db 4, RATTATA`. +4. **Hoppip on Route 29.** Listed by the walkthrough (#187), absent from + `def_grass_wildmons ROUTE_29`. Hoppip's first Johto grass appearance is + Route 32. +5. **Zubat and Poliwag on Routes 30/31.** Listed by the walkthrough. Poliwag is + in `data/wild/johto_water.asm` (surf, 2% rate) and in `.Pond_*` fishing, not in + grass. Zubat is not in either route's grass table at any time of day - it is a + Dark Cave mon. +6. **Weedle / Kakuna on Routes 30-31 in a Gold run.** The walkthrough lists them + for both routes; in `data/wild/johto_grass.asm` those slots are inside the + `ELIF DEF(_SILVER)` arm. A Gold cart gets CATERPIE / METAPOD instead. The + walkthrough's own "(Silver only)" / "(Gold only)" notes are right for Ledyba + and Spinarak but not applied to Weedle/Kakuna. +7. **"Pink Bow" listed as a Route 29 item.** Reachable only with + `ENGINE_ZEPHYRBADGE` **and** `VAR_WEEKDAY == TUESDAY` + (`Route29TuscanyCallback`), so it cannot be collected during this section. +8. **Onix in Violet City / TM31 / PRZCure Berry.** Listed in the walkthrough's + Violet City preamble; `maps/VioletCity.asm` was not opened (next section owns + it) so none of it is verified here. +9. **"You get: 300G / 64G / 48G / 32G".** Verified indirectly: reward = + `base_reward * last mon level` (`engine/battle/read_trainer_party.asm:300` + `ComputeTrainerReward`) paid four times by the `ld c, 4` loop in + `engine/battle/core.asm:2340-2361`. All four figures match. Worth a hand-check + in an emulator if a bot budgets money precisely, because the split between + `wMomsMoney` and `wMoney` depends on `wMomSavingMoney`, i.e. on whether the + player has done step 21. +10. **`EVENT_ROUTE_30_BATTLE` as a physical block.** The three objects at + (5,24)/(5,25)/(5,26) are what the walkthrough calls "You can't pass that area + just yet". Confirmed that they exist and vanish on + `ElmAfterTheftScript`, but whether that column of three fully seals the west + lane depends on `maps/Route30.blk` collision, which was not decoded. Verify in + game if a bot pathfinder wants to rely on it. +11. **MAP CARD on the Pokegear in this port.** See section 5 - the flag is stored + under `save.engineFlags` while the Pokegear reads `save.pokegearFlags`. No + bridge was found by grep; if one exists it is somewhere the string + `pokegearFlags` does not appear. diff --git a/docs/gold-walkthrough/asm-walk/section-02-sprout-tower-and-violet-city-gym.md b/docs/gold-walkthrough/asm-walk/section-02-sprout-tower-and-violet-city-gym.md new file mode 100644 index 00000000..2326ae4a --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-02-sprout-tower-and-violet-city-gym.md @@ -0,0 +1,777 @@ +# Section 02 - Sprout Tower and Violet City Gym + +Source: `../section-02-sprout-tower-and-violet-city-gym.txt` +Maps covered: `MAP_SPROUT_TOWER_1F`, `MAP_SPROUT_TOWER_2F`, `MAP_SPROUT_TOWER_3F`, +`MAP_VIOLET_CITY`, `MAP_VIOLET_POKECENTER_1F`, `MAP_VIOLET_GYM` +Badges / key milestones in this section: HM05 Flash (`EVENT_GOT_HM05_FLASH`), +the Sprout Tower rival cutscene (`EVENT_RIVAL_SPROUT_TOWER`), ZEPHYRBADGE +(`ENGINE_ZEPHYRBADGE`, `EVENT_BEAT_FALKNER`), TM31 Mud-Slap +(`EVENT_GOT_TM31_MUD_SLAP`). + +All map constants below use the `MAP_*` spelling that both +`constants/map_constants.asm` and this port's map ids use; the port's +`World:setMap` takes exactly the bare constant name (`"SPROUT_TOWER_1F"`), which +is what `tools/rom_manifest_gold.json` -> `constants.mapGroups` stores. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_SPROUT_TOWER_1F` | `maps/SproutTower1F.asm` | `MAP_VIOLET_CITY` warp 7 at (23,5) -> 1F warp 1 (9,15) | 1F warp 3 (6,4) -> 2F warp 1 | "Head up the stairs to Floor 2." | +| 2 | `MAP_SPROUT_TOWER_2F` | `maps/SproutTower2F.asm` | 2F warp 1 (6,4) | 2F warp 3 (17,3) -> 1F warp 5 | Sage Nico at (14,4), then "down the stairs" | +| 3 | `MAP_SPROUT_TOWER_1F` | `maps/SproutTower1F.asm` | 1F warp 5 (17,3) | 1F warp 4 (2,6) -> 2F warp 2 | Parlyz Heal ball at (16,7), then Sage Chow at (2,1) | +| 4 | `MAP_SPROUT_TOWER_2F` | `maps/SproutTower2F.asm` | 2F warp 2 (2,6) | 2F warp 4 (10,14) -> 3F warp 1 | X Defend ball at (3,1), Sage Edmond at (3,15) | +| 5 | `MAP_SPROUT_TOWER_3F` | `maps/SproutTower3F.asm` | 3F warp 1 (10,14) | 3F warp 1 (10,14) -> 2F warp 4 | Potion, Jin/Neal/Troy, rival cutscene, Sage Li, HM05, Escape Rope | +| 6 | `MAP_SPROUT_TOWER_2F` -> `MAP_SPROUT_TOWER_1F` | as above | 2F warp 4 / 1F warps 3-5 | 1F warp 1 (9,15) or warp 2 (10,15) -> `MAP_VIOLET_CITY` warp 7 | "Get out of the tower" (Escape Rope also legal: the tower is `DUNGEON`) | +| 7 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | city warp 7 (23,5) | city warp 5 (31,25) | "go back to the Pokemon Center of Violet City" | +| 8 | `MAP_VIOLET_POKECENTER_1F` | `maps/VioletPokecenter1F.asm` | PC warp 1 (3,7) | PC warp 1/2 (3,7)/(4,7) | Heal at the nurse before the gym | +| 9 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | city warp 5 (31,25) | city warp 2 (18,17) | "head northwest in the city to the Violet City Gym" | +| 10 | `MAP_VIOLET_GYM` | `maps/VioletGym.asm` | gym warp 1 (4,15) | gym warp 1/2 (4,15)/(5,15) -> `MAP_VIOLET_CITY` warp 2 | Abe, Rod, Falkner, ZEPHYRBADGE + TM31 | +| 11 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | city warp 2 (18,17) | (section ends) | Section closes with the badge in hand | + +Spills into the next section: beating Falkner runs `specialphonecall +SPECIALCALL_ASSISTANT` (`maps/VioletGym.asm`), whose Elm phone script +(`engine/phone/scripts/elm.asm`, `.assistant`) clears +`EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` so the aide appears in +`MAP_VIOLET_POKECENTER_1F` with the Togepi egg, and sets +`SCENE_ROUTE32_OFFER_SLOWPOKETAIL` when the egg is taken. That whole beat +belongs to the next section; it is listed here only because the trigger lives +in this section's gym script. + +## 2. Maps + +### MAP_SPROUT_TOWER_1F + +- Script: `maps/SproutTower1F.asm` +- Blocks: `maps/SproutTower1F.blk` (`data/maps/blocks.asm` -> `SproutTower1F_Blocks`) +- Header: `data/maps/maps.asm:79` -> `TILESET_TOWER`, `DUNGEON`, + `LANDMARK_SPROUT_TOWER`, `MUSIC_SPROUT_TOWER`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions / attributes: `constants/map_constants.asm:66` + (`map_const SPROUT_TOWER_1F, 10, 8`) = 20x16 cells; + `data/maps/attributes.asm:398` (`map_attributes SproutTower1F, SPROUT_TOWER_1F, $00`) +- Connections: none (indoor/dungeon) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `VIOLET_CITY` | 7 | +| 2 | 10 | 15 | `VIOLET_CITY` | 7 | +| 3 | 6 | 4 | `SPROUT_TOWER_2F` | 1 | +| 4 | 2 | 6 | `SPROUT_TOWER_2F` | 2 | +| 5 | 17 | 3 | `SPROUT_TOWER_2F` | 3 | + +**Coord events** (`def_coord_events`) + +None (`def_coord_events` is empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 15 | `BGEVENT_READ` | `SproutTower1FStatue` | +| 12 | 15 | `BGEVENT_READ` | `SproutTower1FStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SPROUTTOWER1F_SAGE1` | `SPRITE_SAGE` | 7 | 4 | `SPRITEMOVEDATA_STANDING_DOWN`, radius 0/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT`, sight 0 | `SproutTower1FSage1Script` | -1 | +| `SPROUTTOWER1F_SAGE2` | `SPRITE_SAGE` | 6 | 7 | `SPRITEMOVEDATA_WANDER`, radius 1/1, pal `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT`, sight 0 | `SproutTower1FSage2Script` | -1 | +| `SPROUTTOWER1F_GRANNY` | `SPRITE_GRANNY` | 11 | 12 | `SPRITEMOVEDATA_STANDING_LEFT`, radius 0/0 | `OBJECTTYPE_SCRIPT`, sight 0 | `SproutTower1FGrannyScript` | -1 | +| `SPROUTTOWER1F_TEACHER` | `SPRITE_TEACHER` | 9 | 9 | `SPRITEMOVEDATA_STANDING_UP`, radius 0/0 | `OBJECTTYPE_SCRIPT`, sight 0 | `SproutTower1FTeacherScript` | -1 | +| `SPROUTTOWER1F_SAGE3` | `SPRITE_SAGE` | 2 | 1 | `SPRITEMOVEDATA_STANDING_RIGHT`, radius 0/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerSageChow` | -1 | +| `SPROUTTOWER1F_POKE_BALL` | `SPRITE_POKE_BALL` | 16 | 7 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL`, sight 0 | `SproutTower1FParlyzHeal` | `EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL` | + +**Scripts of interest** + +- `TrainerSageChow` (`42:400e`): `trainer SAGE, CHOW, EVENT_BEAT_SAGE_CHOW, + SageChowSeenText, SageChowBeatenText, 0, .Script`. Loss text is `0`. The + after-battle arm `.Script` (`42:401a`) is `endifjustbattled` then a single + `writetext SageChowAfterBattleText`. +- `SproutTower1FParlyzHeal` (`42:4022`): a bare `itemball PARLYZ_HEAL` - two raw + bytes, **not** bytecode. Pickup is engine-side (`FindItemInBallScript`), gated + on `EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL`. +- Sage1/Sage2/Granny/Teacher are all `jumptextfaceplayer` one-liners; nothing to + gate on. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SAGE_CHOW` (1041) | `constants/event_flags.asm:518` | `TrainerSageChow` header word | Set once beaten; suppresses re-battle and the eyesight trigger | +| `EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL` (1607) | `constants/event_flags.asm:1000` | object-event flag on `SPROUTTOWER1F_POKE_BALL` | Set = ball is gone (`CheckObjectFlag`, `engine/overworld/map_objects_2.asm`) | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `PARLYZ_HEAL` | Poke Ball object at (16,7) | `SproutTower1FParlyzHeal` (`itemball PARLYZ_HEAL`) | `EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SAGE`, `CHOW` | `SAGE` (38) | `CHOW` (1) | `SageGroup` "CHOW" (`0e:7324` group base): L3 Bellsprout x3, `TRAINERTYPE_NORMAL` | `TrainerSageChow` | none | + +Prize money: `data/trainers/attributes.asm` "Sage" base reward `8`; +`ComputeTrainerReward` x last party level (3) x4 in `WinTrainerBattle` = 96. + +**Wild encounters** + +None. `data/wild/johto_grass.asm` has **no** `def_grass_wildmons SPROUT_TOWER_1F` +entry; only 2F and 3F have tables. 1F has no grass/water/fish/headbutt data. + +--- + +### MAP_SPROUT_TOWER_2F + +- Script: `maps/SproutTower2F.asm` +- Blocks: `maps/SproutTower2F.blk` +- Header: `data/maps/maps.asm:80` -> `TILESET_TOWER`, `DUNGEON`, + `LANDMARK_SPROUT_TOWER`, `MUSIC_SPROUT_TOWER`, `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions / attributes: `constants/map_constants.asm:67` + (`map_const SPROUT_TOWER_2F, 10, 8`) = 20x16 cells; + `data/maps/attributes.asm:399` +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 4 | `SPROUT_TOWER_1F` | 3 | +| 2 | 2 | 6 | `SPROUT_TOWER_1F` | 4 | +| 3 | 17 | 3 | `SPROUT_TOWER_1F` | 5 | +| 4 | 10 | 14 | `SPROUT_TOWER_3F` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 12 | 15 | `BGEVENT_READ` | `SproutTower2FStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SPROUTTOWER2F_SAGE1` | `SPRITE_SAGE` | 14 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST`, radius 0/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerSageNico` | -1 | +| `SPROUTTOWER2F_SAGE2` | `SPRITE_SAGE` | 3 | 15 | `SPRITEMOVEDATA_STANDING_UP`, radius 0/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerSageEdmond` | -1 | +| `SPROUTTOWER2F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 1 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL`, sight 0 | `SproutTower2FXDefend` | `EVENT_SPROUT_TOWER_2F_X_DEFEND` | + +**Scripts of interest** + +- `TrainerSageNico` (`42:4280`) and `TrainerSageEdmond` (`42:4294`): identical + shape to Chow - `trainer` header, `.Script` = `endifjustbattled` + one + `writetext`. +- `SproutTower2FXDefend` (`42:42ab`): `itemball X_DEFEND`. Note the item is + **X Defend**, not X Accuracy. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SAGE_NICO` (1042) | `constants/event_flags.asm:519` | `TrainerSageNico` | Beaten once | +| `EVENT_BEAT_SAGE_EDMOND` (1047) | `constants/event_flags.asm:524` | `TrainerSageEdmond` | Beaten once | +| `EVENT_SPROUT_TOWER_2F_X_DEFEND` (1608) | `constants/event_flags.asm:1001` | object-event flag | Ball taken | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `X_DEFEND` | Poke Ball object at (3,1) | `SproutTower2FXDefend` | `EVENT_SPROUT_TOWER_2F_X_DEFEND` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `SAGE`, `NICO` | `SAGE` | `NICO` (2) | `SageGroup` "NICO": L3 Bellsprout x3 | `TrainerSageNico` | none | +| `SAGE`, `EDMOND` | `SAGE` | `EDMOND` (7) | `SageGroup` "EDMOND": L3 Bellsprout x3 | `TrainerSageEdmond` | none | + +Both pay 8 x 3 x 4 = 96. + +**Wild encounters** + +`data/wild/johto_grass.asm:5` `def_grass_wildmons SPROUT_TOWER_2F`, rates +`2 percent / 2 percent / 2 percent` (morn/day/nite): + +- morn: L3 Rattata, L4 Rattata, L5 Rattata, L3 Rattata, L6 Rattata, L5 Rattata, L5 Rattata +- day: identical to morn +- nite: L3 Gastly, L4 Gastly, L5 Gastly, L3 Rattata, L6 Gastly, L5 Rattata, L5 Rattata + +No water/fish/headbutt entry for this map. + +--- + +### MAP_SPROUT_TOWER_3F + +- Script: `maps/SproutTower3F.asm` +- Blocks: `maps/SproutTower3F.blk` +- Header: `data/maps/maps.asm:81` -> `TILESET_TOWER`, `DUNGEON`, + `LANDMARK_SPROUT_TOWER`, `MUSIC_SPROUT_TOWER`, `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions / attributes: `constants/map_constants.asm:68` + (`map_const SPROUT_TOWER_3F, 10, 8`) = 20x16 cells; + `data/maps/attributes.asm:400` +- Connections: none +- Scene variable: `data/maps/scenes.asm:47` -> `wSproutTower3FSceneID` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 14 | `SPROUT_TOWER_2F` | 4 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER` (0) | 11 | 9 | `SproutTower3FRivalScene` | The elder/rival cutscene; ends with `setscene SCENE_SPROUTTOWER3F_NOOP` | + +The two `SCENE_SPROUTTOWER3F_*` constants are generated inline by the +`scene_script` macro (`macros/scripts/maps.asm`, `scene_const`) in +`maps/SproutTower3F.asm:12-13`: index 0 = `SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER`, +index 1 = `SCENE_SPROUTTOWER3F_NOOP`. Both scene *scripts* are bare `end` +stubs; the scene id exists only to arm/disarm the coord event. The scene starts +at 0 on a new game, so the trip-wire is live on first entry. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 8 | 1 | `BGEVENT_READ` | `SproutTower3FStatue` | +| 11 | 1 | `BGEVENT_READ` | `SproutTower3FStatue` | +| 9 | 0 | `BGEVENT_READ` | `SproutTower3FPainting` | +| 10 | 0 | `BGEVENT_READ` | `SproutTower3FPainting` | +| 5 | 15 | `BGEVENT_READ` | `SproutTower3FStatue` | +| 14 | 15 | `BGEVENT_READ` | `SproutTower3FStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SPROUTTOWER3F_SAGE1` | `SPRITE_SAGE` | 8 | 13 | `SPRITEMOVEDATA_STANDING_RIGHT`, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSageJin` | -1 | +| `SPROUTTOWER3F_SAGE2` | `SPRITE_SAGE` | 8 | 8 | `SPRITEMOVEDATA_STANDING_DOWN`, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerSageTroy` | -1 | +| `SPROUTTOWER3F_SAGE3` | `SPRITE_SAGE` | 10 | 2 | `SPRITEMOVEDATA_STANDING_DOWN`, pal `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT`, sight 0 | `SageLiScript` | -1 | +| `SPROUTTOWER3F_SAGE4` | `SPRITE_SAGE` | 11 | 11 | `SPRITEMOVEDATA_STANDING_LEFT`, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSageNeal` | -1 | +| `SPROUTTOWER3F_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 14 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `SproutTower3FPotion` | `EVENT_SPROUT_TOWER_3F_POTION` | +| `SPROUTTOWER3F_POKE_BALL2` | `SPRITE_POKE_BALL` | 14 | 1 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `SproutTower3FEscapeRope` | `EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE` | +| `SPROUTTOWER3F_RIVAL` | `SPRITE_RIVAL` | 10 | 4 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT`, sight 0 | `ObjectEvent` (the shared ROM0 stub) | `EVENT_RIVAL_SPROUT_TOWER` | + +Object-flag polarity, confirmed in `engine/overworld/map_objects_2.asm` +(`CheckObjectFlag`): the object is **masked when its event flag is SET**. +`EVENT_RIVAL_SPROUT_TOWER` therefore starts *clear* (rival visible) and the +cutscene's `disappear SPROUTTOWER3F_RIVAL` sets it +(`Script_disappear` -> `ApplyEventActionAppearDisappear`, `b = 1`, +`engine/overworld/scripting.asm:887`). + +**Scripts of interest** + +- `SproutTower3FRivalScene` (`42:444b`), fired by the coord event at (11,9): + `turnobject PLAYER, UP`; `showemote EMOTE_SHOCK, PLAYER, 15`; + `special FadeOutMusic`; two rounds of `playsound SFX_TACKLE` + + `playsound SFX_ELEVATOR` + `earthquake 79` + `pause 15`; + `applymovement PLAYER, SproutTower3FPlayerApproachesRivalMovement` (four + `step UP`, so the player ends at (11,5)); + `applymovement SPROUTTOWER3F_RIVAL, SproutTower3FRivalApproachesElderMovement` + (one `step UP`, rival (10,4) -> (10,3)); + `writetext SproutTowerElderLecturesRivalText`; + `showemote EMOTE_SHOCK, SPROUTTOWER3F_RIVAL, 15`; + `turnobject SPROUTTOWER3F_RIVAL, DOWN`; + `applymovement SPROUTTOWER3F_RIVAL, SproutTower3FRivalLeavesElderMovement` + (`step RIGHT`, `step DOWN`); `playmusic MUSIC_RIVAL_ENCOUNTER`; + two more `writetext`s; `playsound SFX_WARP_TO`; `special FadeOutToBlack`; + `special ReloadSpritesNoPalettes`; `disappear SPROUTTOWER3F_RIVAL`; + `waitsfx`; `special FadeInFromBlack`; `setscene SCENE_SPROUTTOWER3F_NOOP`; + `special RestartMapMusic`; `end`. + **No battle.** This is a pure cutscene - the rival is not fought here. +- `SageLiScript` (`42:44aa`): `faceplayer`, `opentext`, + `checkevent EVENT_GOT_HM05_FLASH` / `iftrue .GotFlash`. First-time arm: + `writetext SageLiSeenText`, `winlosstext SageLiBeatenText, 0`, + `loadtrainer SAGE, LI`, `startbattle`, `reloadmapafterbattle`, + `writetext SageLiTakeThisFlashText`, `promptbutton`, + `verbosegiveitem HM_FLASH`, `setevent EVENT_GOT_HM05_FLASH`, + `setevent EVENT_BEAT_SAGE_LI`, `writetext SageLiFlashExplanationText`, `end`. + `.GotFlash` (`42:44d5`) is a single `writetext SageLiAfterBattleText`. + Note the flag order: the HM is given *before* both events are set, and + `verbosegiveitem`'s failure return is **not** checked here (unlike Falkner's + TM), so a full bag at this point loses the HM. +- `TrainerSageJin` (`42:44db`), `TrainerSageTroy` (`42:44ef`), + `TrainerSageNeal` (`42:4503`): standard `trainer` headers + `endifjustbattled` + after-battle text. +- `SproutTower3FPotion` (`42:451d`) = `itemball POTION`; + `SproutTower3FEscapeRope` (`42:451f`) = `itemball ESCAPE_ROPE`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SAGE_JIN` (1043) | `constants/event_flags.asm:520` | `TrainerSageJin` | Beaten | +| `EVENT_BEAT_SAGE_TROY` (1044) | `constants/event_flags.asm:521` | `TrainerSageTroy` | Beaten | +| `EVENT_BEAT_SAGE_NEAL` (1048) | `constants/event_flags.asm:525` | `TrainerSageNeal` | Beaten | +| `EVENT_BEAT_SAGE_LI` (1049) | `constants/event_flags.asm:526` | set by `SageLiScript` after the battle | Set only on the HM-giving pass; Li is an `OBJECTTYPE_SCRIPT`, not a trainer object, so this flag does not gate re-battle - `EVENT_GOT_HM05_FLASH` does | +| `EVENT_GOT_HM05_FLASH` (20) | `constants/event_flags.asm:27` | read+written by `SageLiScript` | The real "tower cleared" flag | +| `EVENT_RIVAL_SPROUT_TOWER` (1732) | `constants/event_flags.asm:1126` | object-event flag; set by `disappear` in the cutscene; also `setevent` in `maps/HallOfFame.asm:35` | Clear = rival still standing at (10,4) | +| `EVENT_SPROUT_TOWER_3F_POTION` (1609) | `constants/event_flags.asm:1002` | object-event flag | Ball taken | +| `EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE` (1610) | `constants/event_flags.asm:1003` | object-event flag | Ball taken | +| `SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER` = 0 | `maps/SproutTower3F.asm:12` (macro-generated) | `wSproutTower3FSceneID` (`data/maps/scenes.asm:47`) | Coord event live | +| `SCENE_SPROUTTOWER3F_NOOP` = 1 | `maps/SproutTower3F.asm:13` | `setscene` at the end of the cutscene; `setmapscene` in `maps/HallOfFame.asm:39` | Coord event dead | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `POTION` | Poke Ball at (6,14) | `SproutTower3FPotion` | `EVENT_SPROUT_TOWER_3F_POTION` | +| `ESCAPE_ROPE` | Poke Ball at (14,1) | `SproutTower3FEscapeRope` | `EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE` | +| `HM_FLASH` (HM05) | `verbosegiveitem` from Sage Li after his battle | `SageLiScript` | `EVENT_GOT_HM05_FLASH` | + +`HM_FLASH` is the fifth `add_hm` in `constants/item_constants.asm` +(CUT, FLY, SURF, STRENGTH, **FLASH**, WHIRLPOOL, WATERFALL), i.e. HM05. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `SAGE`, `JIN` | `SAGE` | `JIN` (3) | `SageGroup` "JIN": L6 Bellsprout | `TrainerSageJin` | none | +| `SAGE`, `TROY` | `SAGE` | `TROY` (4) | `SageGroup` "TROY": L7 Bellsprout, L7 Hoothoot | `TrainerSageTroy` | none | +| `SAGE`, `NEAL` | `SAGE` | `NEAL` (8) | `SageGroup` "NEAL": L6 Bellsprout | `TrainerSageNeal` | none | +| `SAGE`, `LI` | `SAGE` | `LI` (9) | `SageGroup` "LI": L7 Bellsprout, L7 Bellsprout, L10 Hoothoot | `SageLiScript` (`loadtrainer`, not an `OBJECTTYPE_TRAINER`) | none | + +Prize money (base 8 x last party level x 4): Jin 192, Neal 192, Troy 224, +Li 320. All `TRAINERTYPE_NORMAL` (no explicit moves; movesets come from level-up +data). Class DVs `dn 9, 8, 8, 8` (`data/trainers/dvs.asm:61`). + +**Wild encounters** + +`data/wild/johto_grass.asm:33` `def_grass_wildmons SPROUT_TOWER_3F` - byte for +byte identical to the 2F table: rates 2/2/2 percent, morn+day all Rattata L3-L6, +nite Gastly L3-L6 mixed with Rattata L3/L5/L5. No water/fish/headbutt entry. + +--- + +### MAP_VIOLET_CITY + +- Script: `maps/VioletCity.asm` +- Blocks: `maps/VioletCity.blk` (`data/maps/blocks.asm:532`) +- Header: `data/maps/maps.asm:251` -> `TILESET_JOHTO`, `TOWN`, + `LANDMARK_VIOLET_CITY`, `MUSIC_VIOLET_CITY`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_POND` +- Dimensions / attributes: `constants/map_constants.asm:230` + (`map_const VIOLET_CITY, 20, 18`) = 40x36 cells; + `data/maps/attributes.asm:127` (`map_attributes VioletCity, VIOLET_CITY, $05`) +- Connections (`data/maps/attributes.asm:127-130`): + south `Route32`/`ROUTE_32` offset 0, west `Route36`/`ROUTE_36` offset 0, + east `Route31`/`ROUTE_31` offset 9. No north connection. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 17 | `VIOLET_MART` | 2 | +| 2 | 18 | 17 | `VIOLET_GYM` | 1 | +| 3 | 30 | 17 | `EARLS_POKEMON_ACADEMY` | 1 | +| 4 | 3 | 15 | `VIOLET_NICKNAME_SPEECH_HOUSE` | 1 | +| 5 | 31 | 25 | `VIOLET_POKECENTER_1F` | 1 | +| 6 | 21 | 29 | `VIOLET_KYLES_HOUSE` | 1 | +| 7 | 23 | 5 | `SPROUT_TOWER_1F` | 1 | +| 8 | 39 | 24 | `ROUTE_31_VIOLET_GATE` | 1 | +| 9 | 39 | 25 | `ROUTE_31_VIOLET_GATE` | 2 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 24 | 20 | `BGEVENT_READ` | `VioletCitySign` | +| 15 | 17 | `BGEVENT_READ` | `VioletGymSign` | +| 24 | 8 | `BGEVENT_READ` | `SproutTowerSign` | +| 27 | 17 | `BGEVENT_READ` | `EarlsPokemonAcademySign` | +| 32 | 25 | `BGEVENT_READ` | `VioletCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 10 | 17 | `BGEVENT_READ` | `VioletCityMartSign` (`jumpstd MartSignScript`) | +| 37 | 14 | `BGEVENT_ITEM` | `VioletCityHiddenHyperPotion` = `hiddenitem HYPER_POTION, EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIOLETCITY_EARL` | `SPRITE_FISHER` | 13 | 16 | `SPRITEMOVEDATA_SPINRANDOM_SLOW`, pal `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `VioletCityEarlScript` | `EVENT_VIOLET_CITY_EARL` | +| `VIOLETCITY_LASS` | `SPRITE_LASS` | 28 | 28 | `SPRITEMOVEDATA_WANDER`, radius 2/2, pal `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `VioletCityLassScript` | -1 | +| `VIOLETCITY_SUPER_NERD` | `SPRITE_SUPER_NERD` | 24 | 14 | `SPRITEMOVEDATA_WANDER`, radius 1/2, pal `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT` | `VioletCitySuperNerdScript` | -1 | +| `VIOLETCITY_GRAMPS` | `SPRITE_GRAMPS` | 17 | 20 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT`, radius 1/0 | `OBJECTTYPE_SCRIPT` | `VioletCityGrampsScript` | -1 | +| `VIOLETCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 5 | 18 | `SPRITEMOVEDATA_SPINRANDOM_SLOW`, pal `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `VioletCityYoungsterScript` | -1 | +| `VIOLETCITY_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 14 | 29 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `VioletCityFruitTree` = `fruittree FRUITTREE_VIOLET_CITY` | -1 | +| `VIOLETCITY_POKE_BALL1` | `SPRITE_POKE_BALL` | 4 | 1 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VioletCityPPUp` | `EVENT_VIOLET_CITY_PP_UP` | +| `VIOLETCITY_POKE_BALL2` | `SPRITE_POKE_BALL` | 35 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VioletCityRareCandy` | `EVENT_VIOLET_CITY_RARE_CANDY` | + +**Scripts of interest** + +- `VioletCityFlypointCallback` (`48:4c6d`), registered as + `callback MAPCALLBACK_NEWMAP`: a single `setflag ENGINE_FLYPOINT_VIOLET` + then `endcallback`. Entering the map at all unlocks Fly to Violet. +- `VioletCityEarlScript`: spins, `faceplayer`, + `writetext Text_EarlAsksIfYouBeatFalkner`, `yesorno`. Answering **yes** is + the `.PointlessJump` arm (one line of text, no state change). Answering **no** + is `.FollowEarl`: `playmusic MUSIC_SHOW_ME_AROUND`, + `follow VIOLETCITY_EARL, PLAYER`, a long + `VioletCityFollowEarl_MovementData` walk to the academy door, + `stopfollow`, `disappear VIOLETCITY_EARL`, + `clearevent EVENT_EARLS_ACADEMY_EARL`. A bot that does not want to be + dragged across the map should answer **yes**, or simply not talk to him. +- The four remaining townsfolk are `jumptextfaceplayer` one-liners. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_VIOLET` (66) | `constants/engine_flags.asm:81` | `VioletCityFlypointCallback` | Fly destination unlocked on first map load | +| `EVENT_VIOLET_CITY_EARL` (1738) | `constants/event_flags.asm:1132` | object-event flag; set by `disappear` in `.FollowEarl` | Clear = Earl still outside | +| `EVENT_VIOLET_CITY_PP_UP` (1603) | `constants/event_flags.asm:996` | object-event flag | Ball taken | +| `EVENT_VIOLET_CITY_RARE_CANDY` (1604) | `constants/event_flags.asm:997` | object-event flag | Ball taken | +| `EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION` (176) | `constants/event_flags.asm:186` | `hiddenitem` operand at bg_event (37,14) | Hidden item taken | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `PP_UP` | Poke Ball at (4,1) | `VioletCityPPUp` | `EVENT_VIOLET_CITY_PP_UP` | +| `RARE_CANDY` | Poke Ball at (35,5) | `VioletCityRareCandy` | `EVENT_VIOLET_CITY_RARE_CANDY` | +| `HYPER_POTION` | Hidden, A-press or Itemfinder on (37,14) | `VioletCityHiddenHyperPotion` | `EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION` | +| berry | `fruittree FRUITTREE_VIOLET_CITY` at (14,29) | `VioletCityFruitTree` | daily reset, not an `EVENT_*` | + +None of these four are mentioned by the walkthrough section; they are listed +because a bot walking this map will pass them. + +**Trainers** + +None on the overworld map. + +**Wild encounters** + +- Grass: no `def_grass_wildmons VIOLET_CITY` entry in `data/wild/johto_grass.asm`. +- Water (`data/wild/johto_water.asm:225`): rate `2 percent`, L20 Poliwag, + L15 Poliwag, L20 Poliwhirl - Surf only, i.e. not reachable in this section. +- Fishing group `FISHGROUP_POND` (`data/maps/maps.asm:251`), table in + `data/wild/fish.asm`. + +--- + +### MAP_VIOLET_POKECENTER_1F + +- Script: `maps/VioletPokecenter1F.asm` +- Blocks: shared `VioletPokecenter1F_Blocks` label in `data/maps/blocks.asm:351` + (same blockset as the other Pokecenter 1Fs; no dedicated `.blk`) +- Header: `data/maps/maps.asm:256` -> `TILESET_POKECENTER`, `INDOOR`, + `LANDMARK_VIOLET_CITY`, `MUSIC_POKEMON_CENTER`, `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions / attributes: `constants/map_constants.asm:235` + (`map_const VIOLET_POKECENTER_1F, 5, 4`) = 10x8 cells; + `data/maps/attributes.asm:538` +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `VIOLET_CITY` | 5 | +| 2 | 4 | 7 | `VIOLET_CITY` | 5 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events** / **BG events**: both empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIOLETPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VioletPokecenterNurse` (`jumpstd PokecenterNurseScript`) | -1 | +| `VIOLETPOKECENTER1F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 7 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT`, radius 1/0, pal `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FSuperNerdScript` | -1 | +| `VIOLETPOKECENTER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 1 | 4 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FGentlemanScript` | -1 | +| `VIOLETPOKECENTER1F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 8 | 1 | `SPRITEMOVEDATA_STANDING_DOWN`, pal `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FYoungsterScript` | -1 | +| `VIOLETPOKECENTER1F_ELMS_AIDE` | `SPRITE_SCIENTIST` | 4 | 3 | `SPRITEMOVEDATA_STANDING_DOWN`, pal `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1F_ElmsAideScript` | `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` | + +**Scripts of interest** + +- `VioletPokecenterNurse` -> `jumpstd PokecenterNurseScript`: the only beat the + walkthrough uses here ("go back to the Pokemon Center to heal"). +- `VioletPokecenter1F_ElmsAideScript` is **not** reachable during this section: + `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` is set at new game + (`engine/events/std_scripts.asm:468`), which masks the object, and is only + cleared by Elm's `.assistant` phone call + (`engine/phone/scripts/elm.asm:84`) queued by beating Falkner. It gives + `giveegg TOGEPI, EGG_LEVEL` and belongs to the next section. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` (1792) | `constants/event_flags.asm:1186` | set in `engine/events/std_scripts.asm:468`, cleared in `engine/phone/scripts/elm.asm:84` | Set = aide hidden. Stays set for the whole of this section | + +**Items / Trainers / Wild encounters**: none. + +--- + +### MAP_VIOLET_GYM + +- Script: `maps/VioletGym.asm` +- Blocks: `maps/VioletGym.blk` (`data/maps/blocks.asm:734`) +- Header: `data/maps/maps.asm:253` -> `TILESET_ELITE_FOUR_ROOM`, `INDOOR`, + `LANDMARK_VIOLET_CITY`, `MUSIC_GYM`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions / attributes: `constants/map_constants.asm:232` + (`map_const VIOLET_GYM, 5, 8`) = 10x16 cells; + `data/maps/attributes.asm:535` +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 15 | `VIOLET_CITY` | 2 | +| 2 | 5 | 15 | `VIOLET_CITY` | 2 | + +**Coord events** (`def_coord_events`) + +None. There is no scripted trip-wire in the gym; Abe and Rod fire off eyesight +alone. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 13 | `BGEVENT_READ` | `VioletGymStatue` | +| 6 | 13 | `BGEVENT_READ` | `VioletGymStatue` | + +`VioletGymStatue` branches on `checkflag ENGINE_ZEPHYRBADGE`: unbeaten -> +`jumpstd GymStatue1Script`; beaten -> `gettrainername STRING_BUFFER_4, FALKNER, +FALKNER1` + `jumpstd GymStatue2Script`. Reading it is a cheap way for a bot to +confirm the badge landed. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIOLETGYM_FALKNER` | `SPRITE_FALKNER` | 5 | 1 | `SPRITEMOVEDATA_STANDING_DOWN`, radius 0/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT`, sight 0 | `VioletGymFalknerScript` | -1 | +| `VIOLETGYM_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 7 | 6 | `SPRITEMOVEDATA_STANDING_LEFT`, radius 2/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperRod` | -1 | +| `VIOLETGYM_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 2 | 10 | `SPRITEMOVEDATA_STANDING_RIGHT`, radius 2/0, pal `PAL_NPC_BLUE` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperAbe` | -1 | +| `VIOLETGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 13 | `SPRITEMOVEDATA_STANDING_DOWN`, radius 0/0, pal `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT`, sight 0 | `VioletGymGuideScript` | -1 | + +Walking in at (4,15)/(5,15) and heading north, **Abe** (facing right at y=10) is +met before **Rod** (facing left at y=6), which is the order the walkthrough +gives even though it names Abe first and Rod second. + +**Scripts of interest** + +- `VioletGymFalknerScript` (`56:412f`): + `faceplayer`, `opentext`, `checkevent EVENT_BEAT_FALKNER` / + `iftrue .FightDone`. First pass: `writetext FalknerIntroText`, + `winlosstext FalknerWinLossText, 0`, `loadtrainer FALKNER, FALKNER1`, + `startbattle`, `reloadmapafterbattle`, `setevent EVENT_BEAT_FALKNER`, + `writetext ReceivedZephyrBadgeText`, `playsound SFX_GET_BADGE`, `waitsfx`, + **`setflag ENGINE_ZEPHYRBADGE`**, `readvar VAR_BADGES`, + `scall VioletGymActivateRockets`. + Then `.FightDone` (`56:4159`): `checkevent EVENT_GOT_TM31_MUD_SLAP` / + `iftrue .SpeechAfterTM`; otherwise + `setevent EVENT_BEAT_BIRD_KEEPER_ROD`, `setevent EVENT_BEAT_BIRD_KEEPER_ABE` + (the two gym trainers are force-flagged so they never re-trigger), + `setmapscene ELMS_LAB, SCENE_ELMSLAB_NOOP`, + `specialphonecall SPECIALCALL_ASSISTANT`, + `writetext FalknerZephyrBadgeText`, `promptbutton`, + `verbosegiveitem TM_MUD_SLAP`, `iffalse .NoRoomForMudSlap`, + `setevent EVENT_GOT_TM31_MUD_SLAP`, `writetext FalknerTMMudSlapText`. + Unlike Sage Li, this one **does** check `verbosegiveitem`'s return, so a full + bag leaves `EVENT_GOT_TM31_MUD_SLAP` clear and the TM is offered again on the + next talk. +- `VioletGymActivateRockets` (`56:4185`): `ifequal 7, .RadioTowerRockets`, + `ifequal 6, .GoldenrodRockets` against the `VAR_BADGES` count. With one badge + this is a no-op; it exists so that a late Violet clear still arms the Rocket + arcs. +- `TrainerBirdKeeperRod` (`56:4194`), `TrainerBirdKeeperAbe` (`56:41a8`): + standard `trainer` headers + `endifjustbattled` after-battle text. +- `VioletGymGuideScript`: branches on `EVENT_BEAT_FALKNER`; pure text. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_FALKNER` (1213) | `constants/event_flags.asm:706` | read+written by `VioletGymFalknerScript`; read by `VioletGymGuideScript` | The gym-cleared flag | +| `ENGINE_ZEPHYRBADGE` (26) | `constants/engine_flags.asm:38` | `setflag` in `VioletGymFalknerScript`; `checkflag` in `VioletGymStatue`; `CheckBadge` in `engine/events/overworld.asm` | The badge bit. Gates out-of-battle Flash and the Gen 2 attack boost (`engine/battle/core.asm:6566`) | +| `EVENT_GOT_TM31_MUD_SLAP` (8) | `constants/event_flags.asm:14` | `VioletGymFalknerScript` | TM31 collected | +| `EVENT_BEAT_BIRD_KEEPER_ROD` (1019) | `constants/event_flags.asm:494` | `TrainerBirdKeeperRod`; also force-set by `VioletGymFalknerScript.FightDone` | Beaten | +| `EVENT_BEAT_BIRD_KEEPER_ABE` (1020) | `constants/event_flags.asm:495` | `TrainerBirdKeeperAbe`; also force-set by `.FightDone` | Beaten | +| `SCENE_ELMSLAB_NOOP` | `maps/ElmsLab.asm` (macro-generated) | `setmapscene ELMS_LAB, ...` in `.FightDone` | Disarms the lab's remaining scene script | +| `SPECIALCALL_ASSISTANT` | `constants/phone_constants.asm:47` | `specialphonecall` in `.FightDone`; consumed by `ElmPhoneCallerScript` (`engine/phone/scripts/elm.asm:67`) | Queues the Togepi-egg phone call | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| ZEPHYRBADGE | Automatic on beating Falkner | `VioletGymFalknerScript` (`setflag ENGINE_ZEPHYRBADGE`) | `ENGINE_ZEPHYRBADGE` | +| `TM_MUD_SLAP` (TM31) | `verbosegiveitem` after the badge text | `VioletGymFalknerScript.FightDone` | `EVENT_GOT_TM31_MUD_SLAP` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `BIRD_KEEPER`, `ROD` | `BIRD_KEEPER` (18) | `ROD` (1) | `BirdKeeperGroup` "ROD" (`0e:5f05` group base): L7 Pidgey, L7 Pidgey, `TRAINERTYPE_NORMAL` | `TrainerBirdKeeperRod` | none | +| `BIRD_KEEPER`, `ABE` | `BIRD_KEEPER` | `ABE` (2) | `BirdKeeperGroup` "ABE": L9 Spearow, `TRAINERTYPE_NORMAL` | `TrainerBirdKeeperAbe` | none | +| `FALKNER`, `FALKNER1` | `FALKNER` (1) | `FALKNER1` (1) | `FalknerGroup` (`0e:59c2`), `TRAINERTYPE_MOVES`: L7 Pidgey (TACKLE, MUD_SLAP), L9 Pidgeotto (TACKLE, MUD_SLAP, GUST) | `VioletGymFalknerScript` (`loadtrainer`) | listed in `data/trainers/leaders.asm:8` | + +Prize money: Bird Keeper base 6 (`data/trainers/attributes.asm`) -> Rod +6 x 7 x 4 = 168, Abe 6 x 9 x 4 = 216. Falkner base 25 -> 25 x 9 x 4 = 900. +Falkner DVs `dn 9, 10, 7, 7` (`data/trainers/dvs.asm:5`); Bird Keeper +`dn 9, 8, 8, 8` (line 28). + +**Wild encounters** + +None (indoor gym). + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Sprout Tower 3F rival cutscene fires before you can reach Sage Li | `maps/SproutTower3F.asm` `coord_event 11, 9, SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER, SproutTower3FRivalScene` | Standing on (11,9) while `wSproutTower3FSceneID` == 0 | The scene's own `setscene SCENE_SPROUTTOWER3F_NOOP`. Not a hard block - the tile is on the only path north | +| HM05 Flash | `maps/SproutTower3F.asm:SageLiScript` | Beat `SAGE, LI` (`loadtrainer` + `startbattle`) | `verbosegiveitem HM_FLASH` + `setevent EVENT_GOT_HM05_FLASH` | +| Using Flash outside battle | `engine/events/overworld.asm` `FlashFunction.CheckUseFlash` (`03:48f1`): `ld de, ENGINE_ZEPHYRBADGE` / `farcall CheckBadge` / `jr c, .nozephyrbadge` | `ENGINE_ZEPHYRBADGE` set **and** `wTimeOfDayPalset == DARKNESS_PALSET` | Beat Falkner. Note the second half: even with the badge, Flash is refused on any map that is not a dark cave (`.notadarkcave` -> `FieldMoveFailed`) | +| ZEPHYRBADGE attack boost / obedience | `engine/battle/core.asm:6566` (the `and` mask that starts `(1 << ZEPHYRBADGE)`) | badge bit | Beat Falkner | +| Gym trainers Abe and Rod | `maps/VioletGym.asm` object events, `OBJECTTYPE_TRAINER` sight 3 on shared rows | eyesight only; they do **not** physically block the aisle | Beating them, or `EVENT_BEAT_BIRD_KEEPER_*` being force-set by `VioletGymFalknerScript.FightDone` | +| Falkner re-battle | `maps/VioletGym.asm:VioletGymFalknerScript` `checkevent EVENT_BEAT_FALKNER` / `iftrue .FightDone` | none | one-shot | +| Sage Li re-battle | `maps/SproutTower3F.asm:SageLiScript` `checkevent EVENT_GOT_HM05_FLASH` / `iftrue .GotFlash` | none | one-shot, keyed on the HM flag rather than on `EVENT_BEAT_SAGE_LI` | +| Sprout Tower is a `DUNGEON` | `data/maps/maps.asm:79-81` | - | `ESCAPE_ROPE` and Dig work here, which is what the walkthrough's "you may use that to get out of the tower" relies on | + +Nothing in this section is gated on a field move, a key item, or an NPC standing +on a tile. The only hard requirement is winning battles. + +## 4. Bot checklist + +Coordinates are asm map cells. Port driver form: +`world:setMap("<MAP_CONST>", x, y, facing)`, as in +`tests/drivers/gold_walk_smoke.lua:58`. + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `SPROUT_TOWER_1F` | warp 1 (9,15) | enter from `VIOLET_CITY` (23,5) | - | on 1F | +| 2 | `SPROUT_TOWER_1F` | (6,4) | walk onto warp 3 | - | on `SPROUT_TOWER_2F` at (6,4) | +| 3 | `SPROUT_TOWER_2F` | `SPROUTTOWER2F_SAGE1` at (14,4), sight 3 wide facing spin | step into his line, battle | `EVENT_BEAT_SAGE_NICO` clear | `EVENT_BEAT_SAGE_NICO` set | +| 4 | `SPROUT_TOWER_2F` | (17,3) | walk onto warp 3 | - | on `SPROUT_TOWER_1F` at (17,3) | +| 5 | `SPROUT_TOWER_1F` | ball at (16,7) | face + A | `EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL` clear | `PARLYZ_HEAL` in bag, flag set | +| 6 | `SPROUT_TOWER_1F` | `SPROUTTOWER1F_SAGE3` at (2,1), facing RIGHT, sight 1 | step to (3,1), battle | `EVENT_BEAT_SAGE_CHOW` clear | `EVENT_BEAT_SAGE_CHOW` set | +| 7 | `SPROUT_TOWER_1F` | (2,6) | walk onto warp 4 | - | on `SPROUT_TOWER_2F` at (2,6) | +| 8 | `SPROUT_TOWER_2F` | ball at (3,1) | face + A | `EVENT_SPROUT_TOWER_2F_X_DEFEND` clear | `X_DEFEND` in bag, flag set | +| 9 | `SPROUT_TOWER_2F` | `SPROUTTOWER2F_SAGE2` at (3,15), facing UP, sight 4 | approach from above, battle | `EVENT_BEAT_SAGE_EDMOND` clear | `EVENT_BEAT_SAGE_EDMOND` set | +| 10 | `SPROUT_TOWER_2F` | (10,14) | walk onto warp 4 | - | on `SPROUT_TOWER_3F` at (10,14) | +| 11 | `SPROUT_TOWER_3F` | ball at (6,14) | face + A | `EVENT_SPROUT_TOWER_3F_POTION` clear | `POTION` in bag, flag set | +| 12 | `SPROUT_TOWER_3F` | `SPROUTTOWER3F_SAGE1` at (8,13), facing RIGHT, sight 3 | battle | `EVENT_BEAT_SAGE_JIN` clear | `EVENT_BEAT_SAGE_JIN` set | +| 13 | `SPROUT_TOWER_3F` | `SPROUTTOWER3F_SAGE4` at (11,11), facing LEFT, sight 3 | battle | `EVENT_BEAT_SAGE_NEAL` clear | `EVENT_BEAT_SAGE_NEAL` set | +| 14 | `SPROUT_TOWER_3F` | `SPROUTTOWER3F_SAGE2` at (8,8), facing DOWN, sight 2 | battle | `EVENT_BEAT_SAGE_TROY` clear | `EVENT_BEAT_SAGE_TROY` set | +| 15 | `SPROUT_TOWER_3F` | (11,9) | walk onto the coord event | scene id == `SCENE_SPROUTTOWER3F_RIVAL_ENCOUNTER` (0) | cutscene runs; player ends at (11,5); `EVENT_RIVAL_SPROUT_TOWER` set; scene id -> 1 | +| 16 | `SPROUT_TOWER_3F` | `SPROUTTOWER3F_SAGE3` at (10,2) | face + A, battle `SAGE, LI` | `EVENT_GOT_HM05_FLASH` clear; **bag must have room for an HM** | `HM_FLASH`, `EVENT_GOT_HM05_FLASH`, `EVENT_BEAT_SAGE_LI` | +| 17 | `SPROUT_TOWER_3F` | ball at (14,1) | face + A | `EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE` clear | `ESCAPE_ROPE` in bag, flag set | +| 18 | `SPROUT_TOWER_3F` | (10,14) | warp 1, then 2F warp 1/2/3 down to 1F, then 1F warp 1 (9,15) | - | back on `VIOLET_CITY` at (23,5) | +| 19 | `VIOLET_CITY` | (31,25) | walk onto warp 5 | - | on `VIOLET_POKECENTER_1F` at (3,7) | +| 20 | `VIOLET_POKECENTER_1F` | `VIOLETPOKECENTER1F_NURSE` at (3,1) | face + A, accept heal | - | party healed | +| 21 | `VIOLET_POKECENTER_1F` | (3,7) | warp 1 | - | back on `VIOLET_CITY` at (31,25) | +| 22 | `VIOLET_CITY` | (18,17) | walk onto warp 2 | - | on `VIOLET_GYM` at (4,15) | +| 23 | `VIOLET_GYM` | `VIOLETGYM_YOUNGSTER2` at (2,10), facing RIGHT, sight 3 | enter his row, battle Abe | `EVENT_BEAT_BIRD_KEEPER_ABE` clear | flag set | +| 24 | `VIOLET_GYM` | `VIOLETGYM_YOUNGSTER1` at (7,6), facing LEFT, sight 3 | enter his row, battle Rod | `EVENT_BEAT_BIRD_KEEPER_ROD` clear | flag set | +| 25 | `VIOLET_GYM` | (4,15) -> `VIOLET_CITY` -> (31,25) PC | optional re-heal before the leader | - | party healed | +| 26 | `VIOLET_GYM` | `VIOLETGYM_FALKNER` at (5,1) | face + A, battle | `EVENT_BEAT_FALKNER` clear | `EVENT_BEAT_FALKNER`, `ENGINE_ZEPHYRBADGE`, Rod/Abe flags force-set, `SCENE_ELMSLAB_NOOP`, `SPECIALCALL_ASSISTANT` queued | +| 27 | `VIOLET_GYM` | same NPC, second talk if bag was full | face + A | `EVENT_GOT_TM31_MUD_SLAP` clear | `TM_MUD_SLAP`, `EVENT_GOT_TM31_MUD_SLAP` | +| 28 | `VIOLET_GYM` | bg_event (3,13) or (6,13) | face + A (verification only) | - | `GymStatue2Script` text = badge confirmed | + +Optional pickups on the way through, none of which the walkthrough mentions: +`VIOLET_CITY` PP Up (4,1), Rare Candy (35,5), hidden Hyper Potion (37,14), +berry tree (14,29). + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map load, block grid, warps for all six maps (data-driven from the ROM) | `src/world/gen2/Map.lua`, `src/import/RomExtractorGen2.lua` (`readMapGroupEntry`, `mapNameByIds`) | implemented - ids are the same `MAP_*` names, see `tools/rom_manifest_gold.json` -> `constants.mapGroups` | +| `MAPCALLBACK_NEWMAP` -> `setflag ENGINE_FLYPOINT_VIOLET` | `src/world/gen2/World.lua` (callback dispatch), driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| Fly point 66 = Violet | `src/world/gen2/FieldMoves.lua:346` (`{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 }`) | implemented | +| Script VM for every `maps/*.asm` script in this section | `src/script/gen2/Vm.lua`, `src/script/gen2/Opcodes.lua` | implemented - scripts are disassembled from the ROM, not hand-ported, so `SageLiScript` / `VioletGymFalknerScript` run as-is | +| `coord_event` + scene ids (the 3F rival trip-wire) | `src/world/gen2/World.lua:5013` (coord event scan), `:1183` / `:5026` (scene scripts) | implemented | +| Cutscene opcodes used by `SproutTower3FRivalScene` (`showemote`, `earthquake`, `applymovement`, `turnobject`, `disappear`, `setscene`, `playmusic`) | `src/script/gen2/Opcodes.lua:122/125`, `src/world/gen2/World.lua:1006/1528/647`, `src/script/gen2/Movement.lua` | implemented | +| `special FadeOutMusic` / `FadeOutToBlack` / `ReloadSpritesNoPalettes` / `FadeInFromBlack` / `RestartMapMusic` | `src/script/gen2/Specials.lua` | implemented | +| Object masking by event flag (rival, item balls) | `src/world/gen2/Events.lua` (`objectVisible`), documented against `CheckObjectFlag` | implemented | +| Trainer objects: eyesight, approach walk, seen text, battle, beat flag | `src/world/gen2/Trainers.lua`, `src/world/gen2/World.lua:5205-5253`, driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| `loadtrainer` / `startbattle` for Sage Li and Falkner | `src/script/gen2/Vm.lua`, `src/battle/gen2/Battle.lua` | implemented | +| Gym leader battle music | `src/battle/gen2/BattleMusic.lua:24` (`FALKNER = true`) | implemented | +| Prize money (Falkner 900 = 25 x 9 x 4) | `src/battle/gen2/Prize.lua`, `tests/gen2_prize_test.lua` | implemented | +| `setflag ENGINE_ZEPHYRBADGE` (id 26) and `readvar VAR_BADGES` | `src/script/gen2/Vm.lua:670`, `src/world/gen2/World.lua:117/1240`, `tests/gen2_world_test.lua:1783` | implemented | +| Flash badge gate (`FlashFunction.CheckUseFlash`) | `src/world/gen2/FieldMoves.lua:105` (`FLASH = "ZEPHYR"`), `tests/gen2_world_test.lua:755-781` | implemented, including the "refused before the darkness check" ordering | +| `verbosegiveitem` (HM05, TM31) incl. the `iffalse` no-room arm | `src/script/gen2/Vm.lua:490-500` | implemented | +| Hidden item at Violet City (37,14) | `src/world/gen2/HiddenItems.lua`, `src/world/gen2/World.lua:5285` | implemented | +| Berry tree `fruittree FRUITTREE_VIOLET_CITY` | `src/script/gen2/CallAsm.lua:75-77`, `src/core/gen2/Apricorns.lua:363` | implemented | +| `specialphonecall SPECIALCALL_ASSISTANT` | `src/script/gen2/Opcodes.lua:161`, `src/core/gen2/Phone.lua:390` | implemented | +| `giveegg TOGEPI` (Elm's aide, next section's payoff) | `src/script/gen2/Vm.lua:453`, driver `tests/drivers/gold_egg_hatch.lua` | implemented | +| Earl's `follow` / `stopfollow` escort | `src/script/gen2/Vm.lua:982` | implemented | +| **Poke Ball item pickup (`OBJECTTYPE_ITEMBALL`)** - Parlyz Heal, X Defend, Potion, Escape Rope, PP Up, Rare Candy | `src/import/RomExtractorGen2.lua:2969` writes `obj.itemball`; **nothing in `src/` reads it**, and `World:interact()` (`src/world/gen2/World.lua:5257`) has no itemball branch (trainer / strength boulder / `scriptKey` / bg sign / hidden item only). `src/script/gen2/CallAsm.lua:550` stubs `TryReceiveItem` out on purpose | **missing** - the balls render and are masked correctly, but pressing A on one does nothing. Every item ball in this section is unobtainable in the port today | +| Hand-ported per-map Lua for Sprout Tower / Violet Gym | none - by design | n/a (the port runs the ROM's own bytecode) | + +## 6. Unresolved / verify by hand + +1. **"X Accuracy" in the Sprout Tower item list.** The walkthrough's header + lists Escape Rope, HM05, Parlyz Heal, Potion, **X Accuracy**, and the prose + says "grab that X Defense above you". The asm has exactly one such ball, + `SproutTower2FXDefend` = `itemball X_DEFEND` at 2F (3,1). There is no + `X_ACCURACY` anywhere in `maps/SproutTower*.asm`. The header list is wrong; + the prose is right. + +2. **"Go up to the item to the right of the monk. In reality, it is a... + Level 3 Rattata"** and the follow-up "Go back to the Rattata you faced + before, and now it's an Escape Rope." Nothing in `maps/SproutTower3F.asm` + supports this. The object at (14,1) is a plain + `object_event ... OBJECTTYPE_ITEMBALL, 0, SproutTower3FEscapeRope, + EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE` whose script label is a bare + `itemball ESCAPE_ROPE`. There is no `wildbattle`, no `loadwildmon`, no + second object at that cell, and no branch on `EVENT_GOT_HM05_FLASH` anywhere + near it. This looks like the walkthrough importing the Gen 1 Rocket Hideout + Voltorb trick. **Treat the ball at (14,1) as an ordinary Escape Rope.** + +3. **"Pokemon found in Sprout Tower: Rattata, Bellsprout, Gastly."** + `data/wild/johto_grass.asm` gives Rattata (morn/day) and Gastly + Rattata + (nite) for `SPROUT_TOWER_2F` and `SPROUT_TOWER_3F`, and **no table at all** + for `SPROUT_TOWER_1F`. Bellsprout is not a Sprout Tower wild encounter; the + Bellsprouts are the sages' party members. + +4. **Sage Li's party order.** The walkthrough lists L7 Bellsprout, L10 Hoothoot, + L7 Bellsprout. `data/trainers/parties.asm` `SageGroup` "LI" is + `db 7, BELLSPROUT / db 7, BELLSPROUT / db 10, HOOTHOOT` - the Hoothoot is + last, not second. The 320G figure the walkthrough quotes only works with the + asm order, since `ComputeTrainerReward` uses the level of the **last** party + row (8 x 10 x 4 = 320). + +5. **Walkthrough trainer order in the gym.** The text says "Head up to fight + your trainer battles first. Bird Keeper Abe ... Head up the path to the + second trainer. Bird Keeper Rod." Geometrically that is correct (Abe at + y=10 is south of Rod at y=6), but be aware the *object const* order in the + file is Rod first (`VIOLETGYM_YOUNGSTER1`), Abe second + (`VIOLETGYM_YOUNGSTER2`), which is the opposite of the walk order. Do not + index trainers by object const when following the prose. + +6. **"You'll need the 1st badge to use it [Flash]".** True but incomplete: + `FlashFunction.CheckUseFlash` (`engine/events/overworld.asm`, `03:48f1`) + requires `ENGINE_ZEPHYRBADGE` **and** `wTimeOfDayPalset == DARKNESS_PALSET`. + With the badge, on any non-dark map, the move still fails through + `.notadarkcave` -> `FieldMoveFailed`. A bot should not treat "have badge" as + "Flash will work here". + +7. **EXP values.** The walkthrough quotes per-Pokemon EXP figures (54, 108, 126, + 217, ...). Those are computed from base experience and level at runtime + (`engine/battle/experience.asm` / `data/pokemon/base_stats/`), not stored + anywhere as a per-trainer constant, so they were not verified against a + single asm line here. The **money** figures were: all seven quoted values + match `base reward x last party level x 4`. + +8. **"you should be around an hour into the game now"** and the video link - + prose, nothing to resolve. diff --git a/docs/gold-walkthrough/asm-walk/section-03-ruins-of-alph-and-union-cave.md b/docs/gold-walkthrough/asm-walk/section-03-ruins-of-alph-and-union-cave.md new file mode 100644 index 00000000..43de9070 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-03-ruins-of-alph-and-union-cave.md @@ -0,0 +1,1280 @@ +# Section 03 - Ruins of Alph and Union Cave + +Source: `../section-03-ruins-of-alph-and-union-cave.txt` +Maps covered: `MAP_VIOLET_POKECENTER_1F`, `MAP_ROUTE_36_RUINS_OF_ALPH_GATE`, +`MAP_RUINS_OF_ALPH_OUTSIDE`, `MAP_RUINS_OF_ALPH_KABUTO_CHAMBER`, +`MAP_RUINS_OF_ALPH_INNER_CHAMBER`, `MAP_RUINS_OF_ALPH_RESEARCH_CENTER`, +`MAP_RUINS_OF_ALPH_AERODACTYL_CHAMBER` (Surf-gated), `MAP_ROUTE_32_RUINS_OF_ALPH_GATE`, +`MAP_ROUTE_32`, `MAP_ROUTE_32_POKECENTER_1F`, `MAP_UNION_CAVE_1F`, +`MAP_UNION_CAVE_B1F`, `MAP_UNION_CAVE_B2F` (Strength/Surf-gated), `MAP_ROUTE_33` + +Badges / key milestones in this section: + +- Togepi EGG from Elm's Aide (`EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE`) - this is the + real unlock for the section: it flips Route 32's scene off the blocking one. +- First Unown puzzle solved (`EVENT_SOLVED_KABUTO_PUZZLE`, + `ENGINE_UNLOCKED_UNOWNS_A_TO_K`) - makes Unown spawnable at all. +- UNOWN #DEX mode (`ENGINE_UNOWN_DEX`) from the Research Center. +- MIRACLE_SEED, TM05 ROAR, OLD_ROD, POISON_BARB (Friday), TM39 SWIFT. +- No badge is earned in this stretch. The section ends walking west out of + Route 33 into Azalea Town. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | leaving Violet Gym (warp 2 at 18,17) | warp 5 at `31, 25` -> `VIOLET_POKECENTER_1F` 1 | Elm phones about "something important at the Pokemon Center" | +| 2 | `MAP_VIOLET_POKECENTER_1F` | `maps/VioletPokecenter1F.asm` | warp 1/2 at `3, 7` / `4, 7` | same warps back to `VIOLET_CITY` 5 | take the Togepi EGG from Elm's Aide | +| 3 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | back out of the Center | **west connection** -> `Route36` (offset 0) | "head west ... until you enter Route 36" | +| 4 | `MAP_ROUTE_36` | `maps/Route36.asm` | Violet City west connection | warp 3/4 at `47, 13` / `48, 13` -> `ROUTE_36_RUINS_OF_ALPH_GATE` 1/2 | pass through; the Sudowoodo tree at `35, 9` is a later section | +| 5 | `MAP_ROUTE_36_RUINS_OF_ALPH_GATE` | `maps/Route36RuinsOfAlphGate.asm` | warp 1/2 at `4, 0` / `5, 0` | warp 3/4 at `4, 7` / `5, 7` -> `RUINS_OF_ALPH_OUTSIDE` 9 | "the route-changing station" | +| 6 | `MAP_RUINS_OF_ALPH_OUTSIDE` | `maps/RuinsOfAlphOutside.asm` | warp 9 at `7, 5` | warp 2 at `14, 7` -> `RUINS_OF_ALPH_KABUTO_CHAMBER` 1 | "enter the first cave on the right" | +| 7 | `MAP_RUINS_OF_ALPH_KABUTO_CHAMBER` | `maps/RuinsOfAlphKabutoChamber.asm` | warp 1/2 at `3, 9` / `4, 9` | warp 3/4 at `3, 3` / `4, 3` -> `RUINS_OF_ALPH_INNER_CHAMBER` 4/5 (the floor holes) | solve the Kabuto puzzle, fall through | +| 8 | `MAP_RUINS_OF_ALPH_INNER_CHAMBER` | `maps/RuinsOfAlphInnerChamber.asm` | warps 4/5 at `15, 3` / `16, 3` | warp 1 at `10, 13` -> `RUINS_OF_ALPH_OUTSIDE` 5 | catch Unown; "the lower passageway eventually leads up to a ladder" | +| 9 | `MAP_RUINS_OF_ALPH_OUTSIDE` | `maps/RuinsOfAlphOutside.asm` | warp 5 at `10, 13` | coord_event at `11, 14` or `10, 15` -> scientist cutscene -> warp 6 at `17, 11` | 3+ Unown letters caught -> assistant walks you to the lab | +| 10 | `MAP_RUINS_OF_ALPH_RESEARCH_CENTER` | `maps/RuinsOfAlphResearchCenter.asm` | warp 1/2 at `2, 7` / `3, 7` | same warps -> `RUINS_OF_ALPH_OUTSIDE` 6 | UNOWN #DEX upgrade (`ENGINE_UNOWN_DEX`) | +| 11 | `MAP_RUINS_OF_ALPH_OUTSIDE` | `maps/RuinsOfAlphOutside.asm` | warp 6 at `17, 11` | warp 10/11 at `13, 20` / `13, 21` -> `ROUTE_32_RUINS_OF_ALPH_GATE` 1/2 | "go south, and then east ... another route changing house" | +| 12 | `MAP_ROUTE_32_RUINS_OF_ALPH_GATE` | `maps/Route32RuinsOfAlphGate.asm` | warp 1/2 at `0, 4` / `0, 5` | warp 3/4 at `9, 4` / `9, 5` -> `ROUTE_32` 2/3 | "Welcome to Route 32" | +| 13 | `MAP_ROUTE_32` | `maps/Route32.asm` | warp 2/3 at `4, 2` / `4, 3` | warp 4 at `6, 79` -> `UNION_CAVE_1F` 4 | the whole trainer gauntlet; detours north into Violet City (connection) for the PRZCureBerry tree and to warp 1 at `11, 73` for the Pokemon Center | +| 14 | `MAP_ROUTE_32_POKECENTER_1F` | `maps/Route32Pokecenter1F.asm` | warp 1/2 at `3, 7` / `4, 7` | same warps -> `ROUTE_32` 1 | heal; Fishing Guru hands over the OLD_ROD | +| 15 | `MAP_UNION_CAVE_1F` | `maps/UnionCave1F.asm` | warp 4 at `17, 3` | warp 1 at `5, 19` -> `UNION_CAVE_B1F` 3 | "go up and down the ladder" | +| 16 | `MAP_UNION_CAVE_B1F` | `maps/UnionCaveB1F.asm` | warp 3 at `7, 19` | warp 4 at `3, 33` -> `UNION_CAVE_1F` 2 | TM39 SWIFT at `2, 16`, X DEFEND at `17, 23` | +| 17 | `MAP_UNION_CAVE_1F` | `maps/UnionCave1F.asm` | warp 2 at `3, 33` | warp 3 at `17, 31` -> `ROUTE_33` 1 | Awakening at `12, 33`, Firebreather Ray at `16, 31`, out the east door | +| 18 | `MAP_ROUTE_33` | `maps/Route33.asm` | warp 1 at `11, 9` | **west connection** -> `AzaleaTown` (offset 0) | PSNCureBerry tree, Hiker Anthony, then into Azalea Town | + +Deferred re-visits the walkthrough flags but does not do yet: + +- `RUINS_OF_ALPH_AERODACTYL_CHAMBER` (outside warp 4 at `16, 33`) needs Surf across + the outside water. +- `UNION_CAVE_B2F` (B1F warp 5 at `17, 31`) needs the Strength boulder at B1F + `7, 10` and gives the Friday-only Lapras. +- `UNION_CAVE_B1F` warps 1/2 at `3, 3` / `3, 11` lead back out to + `RUINS_OF_ALPH_OUTSIDE` warps 7/8 (`6, 19`, `6, 27`) - "the entrance to more of + the Ruins of Alph" the walkthrough mentions. + +Spill into the next section: Route 33's west connection lands in Azalea Town; stop +there. + +--- + +## 2. Maps + +### MAP_VIOLET_POKECENTER_1F + +Only the EGG beat is in scope here; Violet City itself belongs to section 02. + +- Script: `maps/VioletPokecenter1F.asm` +- Header: `data/maps/maps.asm:256` -> `TILESET_POKECENTER, INDOOR, + LANDMARK_VIOLET_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm` group `VIOLET` (10), id 6, `5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `VIOLET_CITY` | 5 | +| 2 | 4 | 7 | `VIOLET_CITY` | 5 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIOLETPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VioletPokecenterNurse` | -1 | +| `VIOLETPOKECENTER1F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 7 | 6 | `WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FSuperNerdScript` | -1 | +| `VIOLETPOKECENTER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 1 | 4 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FGentlemanScript` | -1 | +| `VIOLETPOKECENTER1F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 8 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1FYoungsterScript` | -1 | +| `VIOLETPOKECENTER1F_ELMS_AIDE` | `SPRITE_SCIENTIST` | 4 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VioletPokecenter1F_ElmsAideScript` | `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` | + +**Scripts of interest** + +- `VioletPokecenter1F_ElmsAideScript` - `faceplayer` / `opentext`; if + `EVENT_REFUSED_TO_TAKE_EGG_FROM_ELMS_AIDE` it re-asks with a different text. + `yesorno`; on yes it reads `VAR_PARTYCOUNT` and bails to `.PartyFull` at + `PARTY_LENGTH`, otherwise `giveegg TOGEPI, EGG_LEVEL`, then + `setevent EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE`, + `clearevent EVENT_ELMS_AIDE_IN_LAB`, `clearevent EVENT_TOGEPI_HATCHED`, and + crucially `setmapscene ROUTE_32, SCENE_ROUTE32_OFFER_SLOWPOKETAIL`. It then + branches on `VAR_FACING` (`UP` -> walk around the player) and `disappear`s. +- The aide only exists because `engine/phone/scripts/elm.asm:84` (`ElmPhoneCallerScript` + `.assistant`) does `clearevent EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` / + `setevent EVENT_ELMS_AIDE_IN_LAB`. That call is armed by + `maps/VioletGym.asm:38` `specialphonecall SPECIALCALL_ASSISTANT` after Falkner. + `engine/events/std_scripts.asm:468` sets `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` + at new game, and a set object event flag means the object is hidden + (`Script_appear` clears, `Script_disappear` sets - `engine/overworld/scripting.asm:879-898`). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` | `constants/event_flags.asm:1186` | set by `InitializeEventsScript`, cleared by Elm's `.assistant` phone call | aide object visible only while CLEAR | +| `EVENT_REFUSED_TO_TAKE_EGG_FROM_ELMS_AIDE` | `constants/event_flags.asm:53` | `VioletPokecenter1F_ElmsAideScript` | second-ask text | +| `EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE` | `constants/event_flags.asm:54` | set here; read by `Route32CooltrainerMScript` | precondition for the Miracle Seed | +| `EVENT_TOGEPI_HATCHED` | `constants/event_flags.asm:93` | cleared here | egg-hatch bookkeeping | +| `SPECIALCALL_ASSISTANT` | `constants/phone_constants.asm:47` | `maps/VioletGym.asm:38` | the phone call that spawns the aide | + +--- + +### MAP_ROUTE_36_RUINS_OF_ALPH_GATE + +- Script: `maps/Route36RuinsOfAlphGate.asm` +- Header: `data/maps/maps.asm:262` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_36, + MUSIC_ROUTE_36, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm` group `VIOLET` (10), id 16, `5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_36` | 3 | +| 2 | 5 | 0 | `ROUTE_36` | 4 | +| 3 | 4 | 7 | `RUINS_OF_ALPH_OUTSIDE` | 9 | +| 4 | 5 | 7 | `RUINS_OF_ALPH_OUTSIDE` | 9 | + +**BG events**: none. **Coord events**: none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE36RUINSOFALPHGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route36RuinsOfAlphGateOfficerScript` | -1 | +| `ROUTE36RUINSOFALPHGATE_GRAMPS` | `SPRITE_GRAMPS` | 7 | 5 | `WANDER` (r 1,2) | `OBJECTTYPE_SCRIPT` | `Route36RuinsOfAlphGateGrampsScript` | -1 | + +Both are `jumptextfaceplayer` only - no flags, no gate. The gate is walk-through. + +--- + +### MAP_RUINS_OF_ALPH_OUTSIDE + +- Script: `maps/RuinsOfAlphOutside.asm` +- Blocks: `maps/RuinsOfAlphOutside.blk` +- Header: `data/maps/maps.asm:100` -> `TILESET_JOHTO, ROUTE, LANDMARK_RUINS_OF_ALPH, + MUSIC_UNION_CAVE, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:87` group `DUNGEONS` (3), id 22, + `map_const RUINS_OF_ALPH_OUTSIDE, 10, 18` (10x18 blocks = 20x36 cells) +- Connections: none (`data/maps/attributes.asm` `map_attributes RuinsOfAlphOutside, + RUINS_OF_ALPH_OUTSIDE, $05` has no `connection` rows). Every exit is a warp. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 17 | `RUINS_OF_ALPH_HO_OH_CHAMBER` | 1 | +| 2 | 14 | 7 | `RUINS_OF_ALPH_KABUTO_CHAMBER` | 1 | +| 3 | 2 | 29 | `RUINS_OF_ALPH_OMANYTE_CHAMBER` | 1 | +| 4 | 16 | 33 | `RUINS_OF_ALPH_AERODACTYL_CHAMBER` | 1 | +| 5 | 10 | 13 | `RUINS_OF_ALPH_INNER_CHAMBER` | 1 | +| 6 | 17 | 11 | `RUINS_OF_ALPH_RESEARCH_CENTER` | 1 | +| 7 | 6 | 19 | `UNION_CAVE_B1F` | 1 | +| 8 | 6 | 27 | `UNION_CAVE_B1F` | 2 | +| 9 | 7 | 5 | `ROUTE_36_RUINS_OF_ALPH_GATE` | 3 | +| 10 | 13 | 20 | `ROUTE_32_RUINS_OF_ALPH_GATE` | 1 | +| 11 | 13 | 21 | `ROUTE_32_RUINS_OF_ALPH_GATE` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_RUINSOFALPHOUTSIDE_GET_UNOWN_DEX` (1) | 11 | 14 | `RuinsOfAlphOutsideScientistScene1` | scientist turns UP, player DOWN, then the shared cutscene | +| `SCENE_RUINSOFALPHOUTSIDE_GET_UNOWN_DEX` (1) | 10 | 15 | `RuinsOfAlphOutsideScientistScene2` | scientist turns LEFT, player RIGHT, then the shared cutscene | + +Scene ids are declared inline by `scene_script` (see `macros/scripts/maps.asm:12-33`): +`SCENE_RUINSOFALPHOUTSIDE_NOOP` = 0, `SCENE_RUINSOFALPHOUTSIDE_GET_UNOWN_DEX` = 1. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 8 | `BGEVENT_READ` | `RuinsOfAlphOutsideMysteryChamberSign` | +| 12 | 16 | `BGEVENT_READ` | `RuinsOfAlphSign` | +| 18 | 12 | `BGEVENT_READ` | `RuinsOfAlphResearchCenterSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RUINSOFALPHOUTSIDE_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 4 | 20 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerPsychicNathan` | -1 | +| `RUINSOFALPHOUTSIDE_SCIENTIST` | `SPRITE_SCIENTIST` | 11 | 15 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphOutsideScientistScript` | `EVENT_RUINS_OF_ALPH_OUTSIDE_SCIENTIST` | + +Note: the `object_const_def` block declares five consts (`YOUNGSTER1`, `SCIENTIST`, +`FISHER`, `YOUNGSTER2`, `YOUNGSTER3`) but only two `object_event` rows exist. The +last three consts are dead in Gold. + +**Scripts of interest** + +- `RuinsOfAlphOutsideScientistCallback` (`callback MAPCALLBACK_OBJECTS`) - the + whole gate for the #DEX upgrade: + `checkflag ENGINE_UNOWN_DEX` -> if true, `.NoScientist`; + `checkevent EVENT_MADE_UNOWN_APPEAR_IN_RUINS` -> if false, `.NoScientist`; + otherwise `readvar VAR_UNOWNCOUNT` / `ifgreater 2, .YesScientist`. + `.YesScientist` does `appear RUINSOFALPHOUTSIDE_SCIENTIST` + + `setscene SCENE_RUINSOFALPHOUTSIDE_GET_UNOWN_DEX`; `.NoScientist` does + `disappear` + `setscene SCENE_RUINSOFALPHOUTSIDE_NOOP`. + **"At least three different Unown" in the walkthrough is exactly `ifgreater 2`.** +- `RuinsOfAlphOutsideScientistSceneContinue` - text, `playmusic MUSIC_SHOW_ME_AROUND`, + `follow RUINSOFALPHOUTSIDE_SCIENTIST, PLAYER`, `applymovement` along + `RuinsOfAlphOutsideScientistWalkToLabMovement` + (`RIGHT RIGHT RIGHT RIGHT UP UP RIGHT RIGHT UP UP`), `disappear`, `stopfollow`, + `applymovement PLAYER, RuinsOfAlphOutsidePlayerEnterLabMovement` (`UP`), + `setmapscene RUINS_OF_ALPH_RESEARCH_CENTER, SCENE_RUINSOFALPHRESEARCHCENTER_GET_UNOWN_DEX`, + `warpcheck`. The player is walked into warp 6 by the script; a bot does not + need to drive that step itself. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RUINS_OF_ALPH_OUTSIDE_SCIENTIST` | `constants/event_flags.asm:1189` | set by `InitializeEventsScript` (`engine/events/std_scripts.asm:470`), toggled by the map callback | scientist hidden while SET | +| `EVENT_MADE_UNOWN_APPEAR_IN_RUINS` | `constants/event_flags.asm:55` | set by `RuinsOfAlphInnerChamberStrangePresenceScript` | precondition for the scientist | +| `ENGINE_UNOWN_DEX` | `constants/engine_flags.asm:21` | set by `RuinsOfAlphResearchCenterGetUnownDexScript` | once set the scientist never reappears | +| `VAR_UNOWNCOUNT` | `constants/script_constants.asm:62` (`0e`) | read here and in the Research Center | distinct Unown forms recorded | +| `SCENE_RUINSOFALPHOUTSIDE_*` | inline `scene_script` rows | callback / coord events | 0 = noop, 1 = run the scientist scene | + +**Wild encounters** + +`data/wild/johto_grass.asm:388` `def_grass_wildmons RUINS_OF_ALPH_OUTSIDE`, rate +`4 percent` for all three windows; morn/day/nite are identical: +`20 NATU, 22 NATU, 18 NATU, 24 NATU, 20 SMEARGLE, 22 SMEARGLE, 22 SMEARGLE`. + +`data/wild/johto_water.asm:5` `def_water_wildmons RUINS_OF_ALPH_OUTSIDE`, rate +`2 percent`: `15 WOOPER, 20 QUAGSIRE, 15 QUAGSIRE`. This is the Wooper the +walkthrough points at, and it needs Surf. + +Fishing group is `FISHGROUP_POND` (`data/wild/fish.asm:15` -> `.Pond_Old`: +`MAGIKARP 10, MAGIKARP 10, POLIWAG 10`). + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `NATHAN` | `PSYCHIC_T` | 1 | `PsychicGroup` `; PSYCHIC_T (1)` (line 2512): `26 GIRAFARIG` | `TrainerPsychicNathan` (`EVENT_BEAT_PSYCHIC_NATHAN`) | none | + +Level 26 at this point in the game - the walkthrough silently skips him, and a bot +routed through `4, 20` will get pulled into a very lopsided fight. Sight range is 1. + +`TrainerSuperNerdEricUnused` is present in the file but marked `; unreferenced`. + +--- + +### MAP_RUINS_OF_ALPH_KABUTO_CHAMBER + +- Script: `maps/RuinsOfAlphKabutoChamber.asm` +- Blocks: `maps/RuinsOfAlphPuzzleChamber.blk` (shared by all four chambers) +- Header: `data/maps/maps.asm:102` -> `TILESET_RUINS_OF_ALPH, DUNGEON, + LANDMARK_RUINS_OF_ALPH, MUSIC_UNION_CAVE, TRUE, PALETTE_DAY, FISHGROUP_SHORE` + (phone service flag TRUE = phone calls suppressed) +- Dimensions: `constants/map_constants.asm:89` group `DUNGEONS` (3), id 24, `4, 5` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `RUINS_OF_ALPH_OUTSIDE` | 2 | +| 2 | 4 | 9 | `RUINS_OF_ALPH_OUTSIDE` | 2 | +| 3 | 3 | 3 | `RUINS_OF_ALPH_INNER_CHAMBER` | 4 | +| 4 | 4 | 3 | `RUINS_OF_ALPH_INNER_CHAMBER` | 5 | + +Warps 3/4 are the holes in the floor; they are only reachable after the puzzle. + +**Coord events**: none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 3 | `BGEVENT_READ` | `RuinsOfAlphKabutoChamberAncientReplica` | +| 5 | 3 | `BGEVENT_READ` | `RuinsOfAlphKabutoChamberAncientReplica` | +| 3 | 2 | `BGEVENT_UP` | `RuinsOfAlphKabutoChamberPuzzle` | +| 4 | 2 | `BGEVENT_UP` | `RuinsOfAlphKabutoChamberDescriptionSign` | + +`BGEVENT_UP` means the player must be standing below the tile and facing UP. The +puzzle panel is the LEFT of the two (`3, 2`) - the walkthrough's "go up to the left +panel" is literal. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RUINSOFALPHKABUTOCHAMBER_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 5 | 5 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphKabutoChamberReceptionistScript` | `EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST` | + +Only ONE NPC is defined here. The walkthrough says "talk to the two people inside"; +the asm has a single receptionist. + +**Scripts of interest** + +- `RuinsOfAlphKabutoChamberHiddenDoorsCallback` (`callback MAPCALLBACK_TILES`) - + `checkevent EVENT_SOLVED_KABUTO_PUZZLE`; if false it patches the floor closed with + `changeblock 2, 2, $01` (left floor) and `changeblock 4, 2, $02` (right floor). + The `.blk` itself already contains the holes. +- `RuinsOfAlphKabutoChamberPuzzle` - `reanchormap`, `setval UNOWNPUZZLE_KABUTO`, + `special UnownPuzzle`, `closetext`, `iftrue .PuzzleComplete`. + `.PuzzleComplete` runs, in order: + `setevent EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS` (this HIDES the three + tourists - a set object flag means hidden), + `setevent EVENT_SOLVED_KABUTO_PUZZLE`, + `setflag ENGINE_UNLOCKED_UNOWNS_A_TO_K`, + `setevent EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST` (hides the receptionist), + `setmapscene RUINS_OF_ALPH_INNER_CHAMBER, SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE`, + `earthquake 30`, `showemote EMOTE_SHOCK, PLAYER, 15`, + `changeblock 2, 2, $18` / `changeblock 4, 2, $19` (the holes), + `refreshmap`, `playsound SFX_STRENGTH`, `earthquake 80`, `warpcheck`. + `warpcheck` is what drops the player through - no manual walk needed. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_SOLVED_KABUTO_PUZZLE` | `constants/event_flags.asm:334` | this script / the tiles callback | floor stays open once set | +| `ENGINE_UNLOCKED_UNOWNS_A_TO_K` | `constants/engine_flags.asm:56` | this script; read by `CheckUnownLetter` (`engine/battle/core.asm:6219`) and `ChooseWildEncounter` (`engine/overworld/wildmons.asm:341`) | letters A-K become catchable (`data/wild/unlocked_unowns.asm` `.Set_A_K`) | +| `EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST` | `constants/event_flags.asm:1264` | this script | receptionist hidden once SET | +| `EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS` | `constants/event_flags.asm:1191` | this script | the three Inner Chamber NPCs hidden once SET | + +Sibling puzzles, for completeness (same shape, different flags): + +| chamber | asm | puzzle const | sets | unlocks | +|---|---|---|---|---| +| Ho-Oh | `maps/RuinsOfAlphHoOhChamber.asm` | `UNOWNPUZZLE_HO_OH` | `EVENT_SOLVED_HO_OH_PUZZLE` | `ENGINE_UNLOCKED_UNOWNS_X_TO_Z` | +| Omanyte | `maps/RuinsOfAlphOmanyteChamber.asm` | `UNOWNPUZZLE_OMANYTE` | `EVENT_SOLVED_OMANYTE_PUZZLE` | `ENGINE_UNLOCKED_UNOWNS_L_TO_R` | +| Aerodactyl | `maps/RuinsOfAlphAerodactylChamber.asm` | `UNOWNPUZZLE_AERODACTYL` | `EVENT_SOLVED_AERODACTYL_PUZZLE` (`constants/event_flags.asm:336`) | `ENGINE_UNLOCKED_UNOWNS_S_TO_W` | + +--- + +### MAP_RUINS_OF_ALPH_AERODACTYL_CHAMBER + +The Surf-gated puzzle the walkthrough defers. Included because it is the one place +the section names a field-move requirement. + +- Script: `maps/RuinsOfAlphAerodactylChamber.asm` +- Header: `data/maps/maps.asm:104` -> `TILESET_RUINS_OF_ALPH, DUNGEON, + LANDMARK_RUINS_OF_ALPH, MUSIC_UNION_CAVE, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:91` group `DUNGEONS` (3), id 26, `4, 5` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `RUINS_OF_ALPH_OUTSIDE` | 4 | +| 2 | 4 | 9 | `RUINS_OF_ALPH_OUTSIDE` | 4 | +| 3 | 3 | 3 | `RUINS_OF_ALPH_INNER_CHAMBER` | 8 | +| 4 | 4 | 3 | `RUINS_OF_ALPH_INNER_CHAMBER` | 9 | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 3 | `BGEVENT_READ` | `RuinsOfAlphAerodactylChamberAncientReplica` | +| 5 | 3 | `BGEVENT_READ` | `RuinsOfAlphAerodactylChamberAncientReplica` | +| 3 | 2 | `BGEVENT_UP` | `RuinsOfAlphAerodactylChamberPuzzle` | +| 4 | 2 | `BGEVENT_UP` | `RuinsOfAlphAerodactylChamberDescriptionSign` | + +**Object events**: `def_object_events` is empty. No receptionist here. + +--- + +### MAP_RUINS_OF_ALPH_INNER_CHAMBER + +- Script: `maps/RuinsOfAlphInnerChamber.asm` +- Blocks: `maps/RuinsOfAlphInnerChamber.blk` +- Header: `data/maps/maps.asm:105` -> `TILESET_RUINS_OF_ALPH, DUNGEON, + LANDMARK_RUINS_OF_ALPH, MUSIC_RUINS_OF_ALPH_INTERIOR, TRUE, PALETTE_DAY, + FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:92` group `DUNGEONS` (3), id 27, `10, 14` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 13 | `RUINS_OF_ALPH_OUTSIDE` | 5 | +| 2 | 3 | 15 | `RUINS_OF_ALPH_HO_OH_CHAMBER` | 3 | +| 3 | 4 | 15 | `RUINS_OF_ALPH_HO_OH_CHAMBER` | 4 | +| 4 | 15 | 3 | `RUINS_OF_ALPH_KABUTO_CHAMBER` | 3 | +| 5 | 16 | 3 | `RUINS_OF_ALPH_KABUTO_CHAMBER` | 4 | +| 6 | 3 | 21 | `RUINS_OF_ALPH_OMANYTE_CHAMBER` | 3 | +| 7 | 4 | 21 | `RUINS_OF_ALPH_OMANYTE_CHAMBER` | 4 | +| 8 | 15 | 24 | `RUINS_OF_ALPH_AERODACTYL_CHAMBER` | 3 | +| 9 | 16 | 24 | `RUINS_OF_ALPH_AERODACTYL_CHAMBER` | 4 | + +Falling through the Kabuto holes lands you at `15, 3` / `16, 3`; the ladder out is +warp 1 at `10, 13`. + +**Coord events**: none. The "strange presence" fires from the scene script, not a +coord event. + +**BG events** (`def_bg_events`) - 26 identical `BGEVENT_READ, +RuinsOfAlphInnerChamberStatue` rows at: +`(2,3) (5,3) (8,3) (11,3) (14,3) (17,3) (2,8) (5,8) (8,8) (11,8) (14,8) (17,8) +(2,13) (17,13) (2,18) (5,18) (8,18) (11,18) (14,18) (17,18) (2,24) (5,24) (8,24) +(11,24) (14,24) (17,24)`. All flavour text; none are items. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RUINSOFALPHINNERCHAMBER_FISHER` | `SPRITE_FISHER` | 3 | 7 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphInnerChamberFisherScript` | `EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS` | +| `RUINSOFALPHINNERCHAMBER_TEACHER` | `SPRITE_TEACHER` | 14 | 13 | `WANDER` (r 1,1) | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphInnerChamberTeacherScript` | `EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS` | +| `RUINSOFALPHINNERCHAMBER_GRAMPS` | `SPRITE_GRAMPS` | 11 | 19 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphInnerChamberGrampsScript` | `EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS` | + +All three share one flag, so they are all present before any puzzle is solved and +all vanish the moment one is. + +**Scripts of interest** + +- `RuinsOfAlphInnerChamberStrangePresenceScene` - `sdefer + RuinsOfAlphInnerChamberStrangePresenceScript`. The deferred script prints + "There is a strange presence here...", then + `setscene SCENE_RUINSOFALPHINNERCHAMBER_NOOP` and + `setevent EVENT_MADE_UNOWN_APPEAR_IN_RUINS`. Scene ids: `NOOP` = 0, + `STRANGE_PRESENCE` = 1. + +**Wild encounters** + +`data/wild/johto_grass.asm:416` `def_grass_wildmons RUINS_OF_ALPH_INNER_CHAMBER`, +rate `6 percent` all windows, all 21 slots `db 5, UNOWN`. The level is always 5, +identical morn/day/nite. + +Two gates sit on top of that table: + +- `ChooseWildEncounter` (`engine/overworld/wildmons.asm:337-347`): after the species + is picked, `cp UNOWN` / `ld a, [wUnlockedUnowns]` / `and a` / `jr z, .nowildbattle` - + with no puzzle solved, `wUnlockedUnowns` is 0 and Unown encounters are silently + cancelled, so the Inner Chamber has zero encounters before a puzzle. +- `CheckUnownLetter` (`engine/battle/core.asm:6219`) re-rolls the form against + `UnlockedUnownLetterSets` (`data/wild/unlocked_unowns.asm`), so with only the + Kabuto puzzle solved you can only ever meet A-K (11 letters), which is more than + the 3 the scientist needs. + +`NUM_UNOWN EQU 26` (`constants/pokemon_constants.asm:308`) - see +"Unresolved" for the walkthrough's "28 forms" claim. + +--- + +### MAP_RUINS_OF_ALPH_RESEARCH_CENTER + +- Script: `maps/RuinsOfAlphResearchCenter.asm` +- Blocks: `maps/RuinsOfAlphResearchCenter.blk` +- Header: `data/maps/maps.asm:106` -> `TILESET_FACILITY, INDOOR, + LANDMARK_RUINS_OF_ALPH, MUSIC_UNION_CAVE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:93` group `DUNGEONS` (3), id 28, `4, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `RUINS_OF_ALPH_OUTSIDE` | 6 | +| 2 | 3 | 7 | `RUINS_OF_ALPH_OUTSIDE` | 6 | + +**Coord events**: none (the cutscene is a `scene_script` + `sdefer`). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 6 | 5 | `BGEVENT_READ` | `RuinsOfAlphResearchCenterBookshelf` | +| 3 | 4 | `BGEVENT_READ` | `RuinsOfAlphResearchCenterComputer` | +| 7 | 1 | `BGEVENT_READ` | `RuinsOfAlphResearchCenterPrinter` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RUINSOFALPHRESEARCHCENTER_SCIENTIST1` | `SPRITE_SCIENTIST` | 4 | 5 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphResearchCenterScientist1Script` | -1 | +| `RUINSOFALPHRESEARCHCENTER_SCIENTIST2` | `SPRITE_SCIENTIST` | 5 | 2 | `WANDER` (r 2,1) | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphResearchCenterScientist2Script` | -1 | +| `RUINSOFALPHRESEARCHCENTER_SCIENTIST3` | `SPRITE_SCIENTIST` | 2 | 5 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RuinsOfAlphResearchCenterScientist3Script` | `EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST` | + +**Scripts of interest** + +- `RuinsOfAlphResearchCenterScientistCallback` (`callback MAPCALLBACK_OBJECTS`) - + `checkscene` / `ifequal SCENE_RUINSOFALPHRESEARCHCENTER_GET_UNOWN_DEX, .ShowScientist`; + `.ShowScientist` does `moveobject RUINSOFALPHRESEARCHCENTER_SCIENTIST3, 3, 7` (onto + the doorway) and `appear`. So scientist 3 is teleported onto the entrance tile for + the cutscene, not spawned at his listed `2, 5`. +- `RuinsOfAlphResearchCenterGetUnownDexScript` (`sdefer` from the scene) - + `applymovement` to the computer (`UP UP LEFT`, `turn_head UP`), a run of + `playsound`/`pause` beats, text, then `setflag ENGINE_UNOWN_DEX`, more text, + `applymovement` away, `setscene SCENE_RUINSOFALPHRESEARCHCENTER_NOOP`, + `special RestartMapMusic`. **No item is added to the bag** - the "Unown Pokedex" + the walkthrough lists is an engine flag, not an inventory item. +- `RuinsOfAlphResearchCenterPrinter` / `...Computer` / `...Scientist3Script` - all + gate their good branch on `readvar VAR_UNOWNCOUNT` / `ifequal NUM_UNOWN`, i.e. all + 26 forms. `EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST` short-circuits the check + (`.SkipChecking`) while scientist 3 is still hidden. +- `RuinsOfAlphResearchCenterScientist1Script` / `...Scientist2Script` - pure text, + branching on `ENGINE_UNOWN_DEX` and `EVENT_MADE_UNOWN_APPEAR_IN_RUINS`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_UNOWN_DEX` | `constants/engine_flags.asm:21` | set here; read by the outside callback and the Pokedex | the section's "Unown Pokedex" reward | +| `EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST` | `constants/event_flags.asm:1190` | set by `InitializeEventsScript` (`engine/events/std_scripts.asm:471`), cleared by `appear` in the callback | scientist 3 hidden while SET | +| `SCENE_RUINSOFALPHRESEARCHCENTER_GET_UNOWN_DEX` | inline `scene_script` (= 1) | set remotely by `RuinsOfAlphOutsideScientistSceneContinue` | arms the cutscene on entry | + +--- + +### MAP_ROUTE_32_RUINS_OF_ALPH_GATE + +- Script: `maps/Route32RuinsOfAlphGate.asm` +- Header: `data/maps/maps.asm:258` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_32, + MUSIC_ROUTE_30, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:237` group `VIOLET` (10), id 12, `5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `RUINS_OF_ALPH_OUTSIDE` | 10 | +| 2 | 0 | 5 | `RUINS_OF_ALPH_OUTSIDE` | 11 | +| 3 | 9 | 4 | `ROUTE_32` | 2 | +| 4 | 9 | 5 | `ROUTE_32` | 3 | + +**Coord events / BG events**: none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE32RUINSOFALPHGATE_OFFICER` | `SPRITE_OFFICER` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route32RuinsOfAlphGateOfficerScript` | -1 | +| `ROUTE32RUINSOFALPHGATE_POKEFAN_M` | `SPRITE_POKEFAN_M` | 8 | 2 | `WALK_UP_DOWN` (r 0,1) | `OBJECTTYPE_SCRIPT` | `Route32RuinsOfAlphGatePokefanMScript` | -1 | +| `ROUTE32RUINSOFALPHGATE_YOUNGSTER` | `SPRITE_YOUNGSTER` | 1 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `Route32RuinsOfAlphGateYoungsterScript` | -1 | + +All `jumptextfaceplayer`. No gate. + +--- + +### MAP_ROUTE_32 + +- Script: `maps/Route32.asm` +- Blocks: `maps/Route32.blk` +- Header: `data/maps/maps.asm:247` -> `TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_32, + MUSIC_ROUTE_30, FALSE, PALETTE_AUTO, FISHGROUP_QWILFISH` +- Dimensions: `constants/map_constants.asm:226` group `VIOLET` (10), id 1, + `map_const ROUTE_32, 10, 45` (10x45 blocks = 20x90 cells - by far the longest map + in this section) +- Connections (`data/maps/attributes.asm`): `connection north, VioletCity, + VIOLET_CITY, 0` and `connection south, Route33, ROUTE_33, 0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 73 | `ROUTE_32_POKECENTER_1F` | 1 | +| 2 | 4 | 2 | `ROUTE_32_RUINS_OF_ALPH_GATE` | 3 | +| 3 | 4 | 3 | `ROUTE_32_RUINS_OF_ALPH_GATE` | 4 | +| 4 | 6 | 79 | `UNION_CAVE_1F` | 4 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE32_COOLTRAINER_M_BLOCKS` (0) | 18 | 8 | `Route32CooltrainerMStopsYouScene` | turns both parties, text, `follow PLAYER, ROUTE32_COOLTRAINER_M`, `applymovement PLAYER, Movement_Route32CooltrainerMPushesYouBackToViolet` (`UP UP`), `stopfollow`, `turnobject PLAYER, DOWN`, `scall Route32CooltrainerMContinueScene`, then resets him with `DOWN` then `RIGHT` | +| `SCENE_ROUTE32_OFFER_SLOWPOKETAIL` (1) | 7 | 71 | `Route32WannaBuyASlowpokeTailScript` | `turnobject ROUTE32_FISHER4, DOWN` / `turnobject PLAYER, UP` then falls into `_OfferToSellSlowpokeTail` | + +Scene ids from the inline `scene_script` rows: `SCENE_ROUTE32_COOLTRAINER_M_BLOCKS` += 0 (the default, since scene bytes start at 0), +`SCENE_ROUTE32_OFFER_SLOWPOKETAIL` = 1, `SCENE_ROUTE32_NOOP` = 2. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 13 | 5 | `BGEVENT_READ` | `Route32Sign` | +| 9 | 1 | `BGEVENT_READ` | `Route32RuinsSign` | +| 10 | 84 | `BGEVENT_READ` | `Route32UnionCaveSign` | +| 12 | 73 | `BGEVENT_READ` | `Route32PokecenterSign` (`jumpstd PokecenterSignScript`) | +| 12 | 67 | `BGEVENT_ITEM` | `Route32HiddenGreatBall` -> `hiddenitem GREAT_BALL, EVENT_ROUTE_32_HIDDEN_GREAT_BALL` | +| 11 | 40 | `BGEVENT_ITEM` | `Route32HiddenSuperPotion` -> `hiddenitem SUPER_POTION, EVENT_ROUTE_32_HIDDEN_SUPER_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE32_FISHER1` | `SPRITE_FISHER` | 8 | 49 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerFisherJustin` | -1 | +| `ROUTE32_FISHER2` | `SPRITE_FISHER` | 12 | 56 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerFisherRalph1` | -1 | +| `ROUTE32_FISHER3` | `SPRITE_FISHER` | 6 | 48 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerFisherHenry` | -1 | +| `ROUTE32_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 13 | 23 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerYoungsterAlbert` | -1 | +| `ROUTE32_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 4 | 65 | `SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerYoungsterGordon` | -1 | +| `ROUTE32_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 1 | 56 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerCamperRoland` | -1 | +| `ROUTE32_LASS1` | `SPRITE_LASS` | 10 | 30 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerPicnickerLiz1` | -1 | +| `ROUTE32_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 19 | 8 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route32CooltrainerMScript` | -1 | +| `ROUTE32_YOUNGSTER4` | `SPRITE_YOUNGSTER` | 11 | 82 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerBirdKeeperPeter` | -1 | +| `ROUTE32_FISHER4` | `SPRITE_FISHER` | 7 | 70 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SlowpokeTailSalesmanScript` | `EVENT_SLOWPOKE_WELL_ROCKETS` | +| `ROUTE32_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 53 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route32GreatBall` (`itemball GREAT_BALL`) | `EVENT_ROUTE_32_GREAT_BALL` | +| `ROUTE32_FISHER5` | `SPRITE_FISHER` | 15 | 13 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route32RoarTMGuyScript` | -1 | +| `ROUTE32_FRIEDA` | `SPRITE_LASS` | 12 | 67 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `FriedaScript` | `EVENT_ROUTE_32_FRIEDA_OF_FRIDAY` | +| `ROUTE32_POKE_BALL2` | `SPRITE_POKE_BALL` | 3 | 30 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route32Potion` (`itemball POTION`) | `EVENT_ROUTE_32_POTION` | + +**Scripts of interest** + +- `Route32FriedaCallback` (`callback MAPCALLBACK_OBJECTS`) - `readvar VAR_WEEKDAY` / + `ifequal FRIDAY, .FriedaAppears`; otherwise `disappear ROUTE32_FRIEDA`. She is a + hard weekday gate, and she stands on the same cell (`12, 67`) as the hidden Great + Ball. +- `FriedaScript` - if `EVENT_GOT_POISON_BARB_FROM_FRIEDA` -> Friday chat; else + `readvar VAR_WEEKDAY` / `ifnotequal FRIDAY, .NotFriday`; else + `setevent EVENT_MET_FRIEDA_OF_FRIDAY`, `verbosegiveitem POISON_BARB`, + `iffalse .Done` (bag full), `setevent EVENT_GOT_POISON_BARB_FROM_FRIEDA`. +- `Route32CooltrainerMScript` / `Route32CooltrainerMContinueScene` - the Miracle Seed + decision tree, in asm order: + `checkevent EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32` -> `.GotMiracleSeed` (thanks text); + `checkflag ENGINE_ZEPHYRBADGE` -> if false `.DontHaveZephyrBadge` ("have you gone + to the GYM"); + `checkevent EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE` -> if true `.GiveMiracleSeed`, + else "the aide is waiting at the #MON CENTER". + `.GiveMiracleSeed` -> `verbosegiveitem MIRACLE_SEED`, `iffalse .BagFull`, + `setevent EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32`. + So the Miracle Seed needs BOTH `ENGINE_ZEPHYRBADGE` and the Togepi egg. +- `Route32RoarTMGuyScript` - `checkevent EVENT_GOT_TM05_ROAR`, + `verbosegiveitem TM_ROAR`, `setevent EVENT_GOT_TM05_ROAR`. Unconditional otherwise. +- `SlowpokeTailSalesmanScript` / `_OfferToSellSlowpokeTail` - first thing it does is + `setscene SCENE_ROUTE32_NOOP`, so the `7, 71` trip-wire fires exactly once. + `yesorno`: "yes" gives the mocking `Text_ThoughtKidsWereLoaded`, nothing is bought + or given either way. There is no purchase path in the asm. +- `TrainerFisherRalph1` / `TrainerPicnickerLiz1` - both are phone-number trainers. + After the battle: `checkevent EVENT_*_READY_FOR_REMATCH` -> `.Rematch`; + `checkcellnum PHONE_FISHER_RALPH` / `PHONE_PICNICKER_LIZ` -> already registered; + `checkevent EVENT_*_ASKED_FOR_PHONE_NUMBER` -> ask again; otherwise + `setevent EVENT_*_ASKED_FOR_PHONE_NUMBER` then + `askforphonenumber PHONE_FISHER_RALPH` with `PHONE_CONTACTS_FULL` / + `PHONE_CONTACT_REFUSED` branches. **You must talk to them a second time after the + battle** to get the number, exactly as the walkthrough says. + Rematch parties escalate: Ralph -> `RALPH2` after `ENGINE_FLYPOINT_ECRUTEAK`, + `RALPH3` after `ENGINE_FLYPOINT_LAKE_OF_RAGE`; Liz -> `LIZ2` after + `ENGINE_FLYPOINT_ECRUTEAK`, `LIZ3` after `EVENT_CLEARED_ROCKET_HIDEOUT`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32` | `constants/event_flags.asm:102` | `Route32CooltrainerMScript` | one-time | +| `ENGINE_ZEPHYRBADGE` | `constants/engine_flags.asm:38` | read by the same script | Miracle Seed precondition | +| `EVENT_GOT_TM05_ROAR` | `constants/event_flags.asm:87` | `Route32RoarTMGuyScript` | one-time | +| `EVENT_MET_FRIEDA_OF_FRIDAY` | `constants/event_flags.asm:107` | `FriedaScript` | intro-text-only | +| `EVENT_GOT_POISON_BARB_FROM_FRIEDA` | `constants/event_flags.asm:108` | `FriedaScript` | one-time | +| `EVENT_ROUTE_32_FRIEDA_OF_FRIDAY` | `constants/event_flags.asm:1274` | `Route32FriedaCallback` | object hidden while SET (non-Friday) | +| `EVENT_ROUTE_32_GREAT_BALL` | `constants/event_flags.asm:1105` | itemball at `6, 53` | one-time | +| `EVENT_ROUTE_32_POTION` | `constants/event_flags.asm:1106` | itemball at `3, 30` | one-time | +| `EVENT_ROUTE_32_HIDDEN_GREAT_BALL` | `constants/event_flags.asm:175` | hidden item at `12, 67` | one-time | +| `EVENT_ROUTE_32_HIDDEN_SUPER_POTION` | `constants/event_flags.asm:176` | hidden item at `11, 40` | one-time | +| `EVENT_SLOWPOKE_WELL_ROCKETS` | `constants/event_flags.asm:1182` | gates `ROUTE32_FISHER4` | salesman disappears once Slowpoke Well is cleared | +| `PHONE_FISHER_RALPH` / `PHONE_PICNICKER_LIZ` | `constants/phone_constants.asm:20-21` | `askforphonenumber` | contact slots 17 / 18 | +| `VAR_WEEKDAY` | `constants/script_constants.asm:59` (`0b`) | Frieda callback and script | `FRIDAY` | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `MIRACLE_SEED` | talk to `ROUTE32_COOLTRAINER_M` at `19, 8` | `Route32CooltrainerMScript` `.GiveMiracleSeed` | `EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32` | +| `TM_ROAR` (TM05) | talk to `ROUTE32_FISHER5` at `15, 13` | `Route32RoarTMGuyScript` | `EVENT_GOT_TM05_ROAR` | +| `POISON_BARB` | talk to Frieda at `12, 67` on a Friday | `FriedaScript` | `EVENT_GOT_POISON_BARB_FROM_FRIEDA` | +| `GREAT_BALL` | itemball at `6, 53` | `Route32GreatBall` | `EVENT_ROUTE_32_GREAT_BALL` | +| `POTION` | itemball at `3, 30` | `Route32Potion` | `EVENT_ROUTE_32_POTION` | +| `GREAT_BALL` (hidden) | hidden at `12, 67` | `Route32HiddenGreatBall` | `EVENT_ROUTE_32_HIDDEN_GREAT_BALL` | +| `SUPER_POTION` (hidden) | hidden at `11, 40` | `Route32HiddenSuperPotion` | `EVENT_ROUTE_32_HIDDEN_SUPER_POTION` | +| `OLD_ROD` | `ROUTE_32_POKECENTER_1F`, see below | `Route32Pokecenter1FFishingGuruScript` | `EVENT_GOT_OLD_ROD` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `ALBERT` | `YOUNGSTER` | 3 | `YoungsterGroup` `; YOUNGSTER (3)` line 351: `6 RATTATA`, `8 ZUBAT` | `TrainerYoungsterAlbert` (`EVENT_BEAT_YOUNGSTER_ALBERT`) | none | +| `LIZ1` | `PICNICKER` | 1 | `PicnickerGroup` `; PICNICKER (1)` line 2584: `9 NIDORAN_F` | `TrainerPicnickerLiz1` (`EVENT_BEAT_PICNICKER_LIZ`) | `PHONE_PICNICKER_LIZ`; `LIZ2` `15 WEEPINBELL / 15 NIDORINA`, `LIZ3` `19 WEEPINBELL / 19 NIDORINO / 21 NIDOQUEEN` | +| `JUSTIN` | `FISHER` | 1 | `FisherGroup` `; FISHER (1)` line 1498: `5 MAGIKARP`, `5 MAGIKARP`, `15 MAGIKARP`, `5 MAGIKARP` | `TrainerFisherJustin` (`EVENT_BEAT_FISHER_JUSTIN`) | none | +| `HENRY` | `FISHER` | 5 | `FisherGroup` `; FISHER (5)` line 1523: `8 POLIWAG`, `8 POLIWAG` | `TrainerFisherHenry` (`EVENT_BEAT_FISHER_HENRY`) | none | +| `RALPH1` | `FISHER` | 2 | `FisherGroup` `; FISHER (2)` line 1506: `10 GOLDEEN` | `TrainerFisherRalph1` (`EVENT_BEAT_FISHER_RALPH`) | `PHONE_FISHER_RALPH`; `RALPH2` `17 GOLDEEN`, `RALPH3` `17 QWILFISH / 19 GOLDEEN` | +| `ROLAND` | `CAMPER` | 1 | `CamperGroup` `; CAMPER (1)` line 2705: `9 NIDORAN_M` | `TrainerCamperRoland` (`EVENT_BEAT_CAMPER_ROLAND`) | none | +| `GORDON` | `YOUNGSTER` | 4 | `YoungsterGroup` `; YOUNGSTER (4)` line 357: `10 WOOPER` | `TrainerYoungsterGordon` (`EVENT_BEAT_YOUNGSTER_GORDON`) | none | +| `PETER` | `BIRD_KEEPER` | 13 | `BirdKeeperGroup` `; BIRD_KEEPER (13)` line 598: `6 PIDGEY`, `6 PIDGEY`, `8 SPEAROW` | `TrainerBirdKeeperPeter` (`EVENT_BEAT_BIRD_KEEPER_PETER`) | none | + +All eight parties are `TRAINERTYPE_NORMAL` (level + species only, no custom moves or +held items). + +**Wild encounters** + +`data/wild/johto_grass.asm:1711` `def_grass_wildmons ROUTE_32`, rate +`10 percent` all windows. **Gold** branch (`IF DEF(_GOLD)`): + +- morn: `6 BELLSPROUT, 4 RATTATA, 6 MAREEP, 6 HOPPIP, 6 RATTATA, 4 WOOPER, 4 ZUBAT` +- day: `6 BELLSPROUT, 4 RATTATA, 6 MAREEP, 6 HOPPIP, 6 RATTATA, 8 RATTATA, 8 RATTATA` +- nite: `6 WOOPER, 4 RATTATA, 6 BELLSPROUT, 6 MAREEP, 8 WOOPER, 8 ZUBAT, 8 ZUBAT` + +Silver swaps the `RATTATA` in slot 2 for `EKANS`. **Wooper is a morn-or-nite mon in +Gold** - it does not appear in the day table at all, which is why the walkthrough +says "Wooper will be a bit harder to find than Bellsprout". + +`data/wild/johto_water.asm:142` `def_water_wildmons ROUTE_32`, rate `6 percent`: +`15 TENTACOOL, 20 QUAGSIRE, 20 TENTACRUEL` (Surf only). + +Fishing group `FISHGROUP_QWILFISH` (`data/wild/fish.asm:22`); `.Qwilfish_Old` +(line 178) is `MAGIKARP 10, MAGIKARP 10, TENTACOOL 10` - the Old Rod here gives no +Qwilfish. + +--- + +### MAP_ROUTE_32_POKECENTER_1F + +- Script: `maps/Route32Pokecenter1F.asm` +- Header: `data/maps/maps.asm:259` -> `TILESET_POKECENTER, INDOOR, + LANDMARK_ROUTE_32, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:238` group `VIOLET` (10), id 13, `5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ROUTE_32` | 1 | +| 2 | 4 | 7 | `ROUTE_32` | 1 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events / BG events**: none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE32POKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route32Pokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 | +| `ROUTE32POKECENTER1F_FISHING_GURU` | `SPRITE_FISHING_GURU` | 1 | 4 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route32Pokecenter1FFishingGuruScript` | -1 | +| `ROUTE32POKECENTER1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 6 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route32Pokecenter1FCooltrainerFScript` | -1 | + +**Scripts of interest** + +- `Route32Pokecenter1FFishingGuruScript` - `checkevent EVENT_GOT_OLD_ROD` -> + `.GotOldRod`; else text, `yesorno`, `iffalse .Refused`, then + `verbosegiveitem OLD_ROD`, text, `setevent EVENT_GOT_OLD_ROD`. + Note: unlike most gift NPCs there is **no `iffalse` bag-full guard after the + `verbosegiveitem`** - the flag is set unconditionally on the yes path. + Answering "no" is recoverable (`.Refused` sets nothing). + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `OLD_ROD` | talk to the Fishing Guru at `1, 4`, answer YES | `Route32Pokecenter1FFishingGuruScript` | `EVENT_GOT_OLD_ROD` (`constants/event_flags.asm:31`) | + +--- + +### MAP_UNION_CAVE_1F + +- Script: `maps/UnionCave1F.asm` +- Blocks: `maps/UnionCave1F.blk` +- Header: `data/maps/maps.asm:107` -> `TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, + MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` + The palette is `PALETTE_NITE`, not `PALETTE_DARK` - **Union Cave is not a dark + cave and never needs FLASH**, despite Firebreather Ray's "if it's light, a cave + isn't scary" line. +- Dimensions: `constants/map_constants.asm:94` group `DUNGEONS` (3), id 29, `10, 18` +- Connections: none (`map_attributes UnionCave1F, UNION_CAVE_1F, $09`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 19 | `UNION_CAVE_B1F` | 3 | +| 2 | 3 | 33 | `UNION_CAVE_B1F` | 4 | +| 3 | 17 | 31 | `ROUTE_33` | 1 | +| 4 | 17 | 3 | `ROUTE_32` | 4 | + +**Coord events**: none. **BG events**: none (`UnionCave1FUnusedSign` exists in the +file but is marked `; unreferenced`). + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVE1F_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 4 | 4 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerHikerDaniel` | -1 | +| `UNIONCAVE1F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 4 | 21 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerPokemaniacLarry` | -1 | +| `UNIONCAVE1F_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 15 | 8 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerHikerRussell` | -1 | +| `UNIONCAVE1F_FISHER1` | `SPRITE_FISHER` | 16 | 31 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerFirebreatherRay` | -1 | +| `UNIONCAVE1F_FISHER2` | `SPRITE_FISHER` | 15 | 15 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerFirebreatherBill` | -1 | +| `UNIONCAVE1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 17 | 21 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FGreatBall` (`itemball GREAT_BALL`) | `EVENT_UNION_CAVE_1F_GREAT_BALL` | +| `UNIONCAVE1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 4 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FPotion` (`itemball POTION`) | `EVENT_UNION_CAVE_1F_POTION` | +| `UNIONCAVE1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 4 | 17 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FXAttack` (`itemball X_ATTACK`) | `EVENT_UNION_CAVE_1F_X_ATTACK` | +| `UNIONCAVE1F_POKE_BALL4` | `SPRITE_POKE_BALL` | 12 | 33 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FAwakening` (`itemball AWAKENING`) | `EVENT_UNION_CAVE_1F_AWAKENING` | + +**Scripts of interest** + +Every trainer script here is the plain `endifjustbattled / opentext / writetext / +waitbutton / closetext / end` shape. No flags beyond the `EVENT_BEAT_*` in the +`trainer` macro, no items, no rematch. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `GREAT_BALL` | itemball at `17, 21` | `UnionCave1FGreatBall` | `EVENT_UNION_CAVE_1F_GREAT_BALL` (`constants/event_flags.asm:1019`) | +| `POTION` | itemball at `4, 2` | `UnionCave1FPotion` | `EVENT_UNION_CAVE_1F_POTION` (:1020) | +| `X_ATTACK` | itemball at `4, 17` | `UnionCave1FXAttack` | `EVENT_UNION_CAVE_1F_X_ATTACK` (:1021) | +| `AWAKENING` | itemball at `12, 33` | `UnionCave1FAwakening` | `EVENT_UNION_CAVE_1F_AWAKENING` (:1022) | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `DANIEL` | `HIKER` | 18 | `HikerGroup` `; HIKER (18)` line 2229: `11 ONIX` | `TrainerHikerDaniel` (`EVENT_BEAT_HIKER_DANIEL`) | none | +| `RUSSELL` | `HIKER` | 2 | `HikerGroup` `; HIKER (2)` line 2123: `4 GEODUDE`, `6 GEODUDE`, `8 GEODUDE` | `TrainerHikerRussell` (`EVENT_BEAT_HIKER_RUSSELL`) | none | +| `BILL` | `FIREBREATHER` | 5 | `FirebreatherGroup` `; FIREBREATHER (5)` line 2359: `6 KOFFING`, `6 KOFFING` | `TrainerFirebreatherBill` (`EVENT_BEAT_FIREBREATHER_BILL`) | none | +| `LARRY` | `POKEMANIAC` | 1 | `PokemaniacGroup` `; POKEMANIAC (1)` line 1081: `10 SLOWPOKE` | `TrainerPokemaniacLarry` (`EVENT_BEAT_POKEMANIAC_LARRY`) | none | +| `RAY` | `FIREBREATHER` | 7 | `FirebreatherGroup` `; FIREBREATHER (7)` line 2371: `9 VULPIX` | `TrainerFirebreatherRay` (`EVENT_BEAT_FIREBREATHER_RAY`) | none | + +**Wild encounters** + +`data/wild/johto_grass.asm:444` `def_grass_wildmons UNION_CAVE_1F`, rate +`6 percent` all windows. **Gold** branch, identical morn/day/nite: +`6 GEODUDE, 6 SANDSHREW, 5 ZUBAT, 4 RATTATA, 7 ZUBAT, 6 ONIX, 6 ONIX`. +(Silver replaces `SANDSHREW` with a second `RATTATA`.) +Note `SANDSHREW` and `ONIX` are in the table but not in the walkthrough's list. + +`data/wild/johto_water.asm:12` `def_water_wildmons UNION_CAVE_1F`, rate +`2 percent`: `15 WOOPER, 20 QUAGSIRE, 15 QUAGSIRE`. + +Fishing group `FISHGROUP_LAKE` (`data/wild/fish.asm:14`); `.Lake_Old` (line 57) +`MAGIKARP 10, MAGIKARP 10, GOLDEEN 10`. + +--- + +### MAP_UNION_CAVE_B1F + +- Script: `maps/UnionCaveB1F.asm` +- Blocks: `maps/UnionCaveB1F.blk` +- Header: `data/maps/maps.asm:108` -> `TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, + MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:95` group `DUNGEONS` (3), id 30, `10, 18` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `RUINS_OF_ALPH_OUTSIDE` | 7 | +| 2 | 3 | 11 | `RUINS_OF_ALPH_OUTSIDE` | 8 | +| 3 | 7 | 19 | `UNION_CAVE_1F` | 1 | +| 4 | 3 | 33 | `UNION_CAVE_1F` | 2 | +| 5 | 17 | 31 | `UNION_CAVE_B2F` | 1 | + +**Coord events / BG events**: none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVEB1F_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 10 | 4 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerHikerPhillip` | -1 | +| `UNIONCAVEB1F_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 17 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerHikerLeonard` | -1 | +| `UNIONCAVEB1F_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 32 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPokemaniacAndrew` | -1 | +| `UNIONCAVEB1F_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 17 | 30 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPokemaniacCalvin` | -1 | +| `UNIONCAVEB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 2 | 16 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB1FTMSwift` (`itemball TM_SWIFT`) | `EVENT_UNION_CAVE_B1F_TM_SWIFT` | +| `UNIONCAVEB1F_BOULDER` | `SPRITE_BOULDER` | 7 | 10 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `UnionCaveB1FBoulder` (`jumpstd StrengthBoulderScript`) | -1 | +| `UNIONCAVEB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 17 | 23 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB1FXDefend` (`itemball X_DEFEND`) | `EVENT_UNION_CAVE_B1F_X_DEFEND` | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_SWIFT` (TM39) | itemball at `2, 16` | `UnionCaveB1FTMSwift` | `EVENT_UNION_CAVE_B1F_TM_SWIFT` (`constants/event_flags.asm:1023`) | +| `X_DEFEND` | itemball at `17, 23` | `UnionCaveB1FXDefend` | `EVENT_UNION_CAVE_B1F_X_DEFEND` (:1024) | + +**Trainers** (all beyond the walkthrough's current route; they sit on the B2F path) + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `PHILLIP` | `HIKER` | 3 | `; HIKER (3)` line 2130: `23 GEODUDE`, `23 GEODUDE`, `23 GRAVELER` | `TrainerHikerPhillip` | none | +| `LEONARD` | `HIKER` | 4 | `; HIKER (4)` line 2137: `23 GEODUDE`, `25 MACHOP` | `TrainerHikerLeonard` | none | +| `ANDREW` | `POKEMANIAC` | 2 | `; POKEMANIAC (2)` line 1086: `24 MAROWAK`, `24 MAROWAK` | `TrainerPokemaniacAndrew` | none | +| `CALVIN` | `POKEMANIAC` | 3 | `; POKEMANIAC (3)` line 1092: `26 KANGASKHAN` | `TrainerPokemaniacCalvin` | none | + +Level 23-26 - a bot at the walkthrough's level 15-17 must NOT wander into these. The +TM Swift / X Defend route (`7, 19` -> `2, 16` -> `17, 23` -> `3, 33`) keeps clear of +Phillip (`10, 4`) and Leonard (`17, 10`) but passes near Calvin (`17, 30`, sight 3) +on the way to X Defend at `17, 23`. + +**Wild encounters** + +`data/wild/johto_grass.asm:499` `def_grass_wildmons UNION_CAVE_B1F`, rate +`6 percent`. Gold branch, identical morn/day/nite: +`8 GEODUDE, 8 SANDSHREW, 7 ZUBAT, 8 ONIX, 9 ZUBAT, 6 RATTATA, 6 RATTATA`. +`data/wild/johto_water.asm:19`, rate `2 percent`: +`15 WOOPER, 20 QUAGSIRE, 15 QUAGSIRE`. + +--- + +### MAP_UNION_CAVE_B2F + +Out of the walkthrough's current route (needs Strength for the B1F boulder), but it +is the map the section explicitly says to come back to. + +- Script: `maps/UnionCaveB2F.asm` +- Blocks: `maps/UnionCaveB2F.blk` +- Header: `data/maps/maps.asm:109` -> `TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, + MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:96` group `DUNGEONS` (3), id 31, `10, 18` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 3 | `UNION_CAVE_B1F` | 5 | + +**Coord events / BG events**: none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVEB2F_ROCKER` | `SPRITE_ROCKER` | 17 | 23 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 5) | `TrainerCooltrainermNick` | -1 | +| `UNIONCAVEB2F_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 5 | 13 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerCooltrainerfGwen` | -1 | +| `UNIONCAVEB2F_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 3 | 28 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerCooltrainerfEmma` | -1 | +| `UNIONCAVEB2F_POKE_BALL1` | `SPRITE_POKE_BALL` | 16 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB2FElixer` | `EVENT_UNION_CAVE_B2F_ELIXER` | +| `UNIONCAVEB2F_POKE_BALL2` | `SPRITE_POKE_BALL` | 12 | 19 | `STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB2FHyperPotion` | `EVENT_UNION_CAVE_B2F_HYPER_POTION` | +| `UNIONCAVEB2F_LAPRAS` | `SPRITE_SURF` | 11 | 31 | `SWIM_WANDER` (r 1,1) | `OBJECTTYPE_SCRIPT` | `UnionCaveLapras` | `EVENT_UNION_CAVE_B2F_LAPRAS` | + +**Scripts of interest** + +- `UnionCaveB2FLaprasCallback` (`callback MAPCALLBACK_OBJECTS`) - + `checkflag ENGINE_UNION_CAVE_LAPRAS` -> if true `.NoAppear`; + `readvar VAR_WEEKDAY` / `ifequal FRIDAY, .Appear`; else `disappear`. +- `UnionCaveLapras` - `faceplayer`, `cry LAPRAS`, `loadwildmon LAPRAS, 20`, + `startbattle`, `disappear`, `setflag ENGINE_UNION_CAVE_LAPRAS`, + `reloadmapafterbattle`. **Level 20, once per week only if you fail to catch it - + no, once ever**: `ENGINE_UNION_CAVE_LAPRAS` (`constants/engine_flags.asm:107`) is + set unconditionally after the battle. The Lapras is on water, so Surf is needed to + reach `11, 31`. + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `NICK` | `COOLTRAINERM` | 1 | `CooltrainerMGroup` `; COOLTRAINERM (1)` line 740, `TRAINERTYPE_MOVES`: `26 CHARMANDER` (EMBER/SMOKESCREEN/RAGE/SCARY_FACE), `26 SQUIRTLE` (WITHDRAW/WATER_GUN/BITE/CURSE), `26 BULBASAUR` (LEECH_SEED/POISONPOWDER/SLEEP_POWDER/RAZOR_LEAF) | `TrainerCooltrainermNick` | +| `GWEN` | `COOLTRAINERF` | 1 | `CooltrainerFGroup` `; COOLTRAINERF (1)` line 865: `26 EEVEE`, `22 FLAREON`, `22 VAPOREON`, `22 JOLTEON` | `TrainerCooltrainerfGwen` | +| `EMMA` | `COOLTRAINERF` | 15 | line 954: `28 POLIWHIRL` | `TrainerCooltrainerfEmma` | + +**Wild encounters** + +`data/wild/johto_grass.asm:554`, rate `4 percent`, identical morn/day/nite: +`22 ZUBAT, 22 RATICATE, 22 GOLBAT, 21 GEODUDE, 20 RATTATA, 23 ONIX, 23 ONIX`. +`data/wild/johto_water.asm:26`, rate `4 percent`: +`15 TENTACOOL, 20 QUAGSIRE, 20 TENTACRUEL`. + +--- + +### MAP_ROUTE_33 + +- Script: `maps/Route33.asm` +- Blocks: `maps/Route33.blk` +- Header: `data/maps/maps.asm:231` -> `TILESET_JOHTO_MODERN, ROUTE, + LANDMARK_ROUTE_33, MUSIC_ROUTE_30, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:212` group `AZALEA` (8), id 6, `10, 9` +- Connections (`data/maps/attributes.asm`): `connection north, Route32, ROUTE_32, 0` + and `connection west, AzaleaTown, AZALEA_TOWN, 0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 9 | `UNION_CAVE_1F` | 3 | + +**Coord events**: none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 11 | `BGEVENT_READ` | `Route33Sign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE33_POKEFAN_M` | `SPRITE_POKEFAN_M` | 6 | 13 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerHikerAnthony` | -1 | +| `ROUTE33_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 14 | 16 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route33FruitTree` (`fruittree FRUITTREE_ROUTE_33`) | -1 | + +**Scripts of interest** + +- `TrainerHikerAnthony` - same phone shape as Ralph/Liz: + `EVENT_ANTHONY_READY_FOR_REMATCH` -> `.Rematch`; + `checkcellnum PHONE_HIKER_ANTHONY`; `EVENT_ANTHONY_ASKED_FOR_PHONE_NUMBER`; + `askforphonenumber PHONE_HIKER_ANTHONY`. Rematch escalates to `ANTHONY1` after + `ENGINE_FLYPOINT_OLIVINE` and `ANTHONY3` after `EVENT_CLEARED_RADIO_TOWER`. +- `Route33FruitTree` - `fruittree FRUITTREE_ROUTE_33` (`constants/script_constants.asm:212`, + index `06`); `data/items/fruit_trees.asm` row 6 is `db PSNCUREBERRY ; ROUTE_33`. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `PSNCUREBERRY` | headbutt-style fruit tree object at `14, 16` | `Route33FruitTree` / `data/items/fruit_trees.asm` `; ROUTE_33` | none - fruit trees respawn on a timer, tracked in `wFruitTreeFlags`, not an `EVENT_*` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `ANTHONY2` | `HIKER` | 5 | `HikerGroup` `; HIKER (5)` line 2143: `11 GEODUDE`, `11 MACHOP` | `TrainerHikerAnthony` (`EVENT_BEAT_HIKER_ANTHONY`) | `PHONE_HIKER_ANTHONY` (slot 19); `ANTHONY1` `16 GEODUDE / 18 MACHAMP`, `ANTHONY3` `25 GRAVELER / 27 GRAVELER / 29 MACHOKE` | + +**Wild encounters** + +`data/wild/johto_grass.asm:1766` `def_grass_wildmons ROUTE_33`, rate `10 percent`. +**Gold** branch: + +- morn: `6 HOPPIP, 7 RATTATA, 6 SPEAROW, 6 RATTATA, 8 HOPPIP, 4 ZUBAT, 4 ZUBAT` +- day: `6 HOPPIP, 7 RATTATA, 6 SPEAROW, 6 RATTATA, 8 HOPPIP, 8 RATTATA, 8 RATTATA` +- nite: `6 ZUBAT, 7 RATTATA, 6 RATTATA, 6 RATTATA, 8 ZUBAT, 8 ZUBAT, 8 ZUBAT` + +Silver replaces the slot-2 `RATTATA` with `EKANS`. No water table. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Route 32 south is soft-blocked at the top | `maps/Route32.asm` `coord_event 18, 8, SCENE_ROUTE32_COOLTRAINER_M_BLOCKS, Route32CooltrainerMStopsYouScene` | scene byte for `ROUTE_32` must not be 0 | `maps/VioletPokecenter1F.asm` `setmapscene ROUTE_32, SCENE_ROUTE32_OFFER_SLOWPOKETAIL` when you take the Togepi EGG. Until then the coord event fires every time and `applymovement PLAYER, Movement_Route32CooltrainerMPushesYouBackToViolet` (`UP UP`) shoves you back toward Violet | +| Elm's Aide does not exist yet | `engine/events/std_scripts.asm:468` sets `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER`; `engine/phone/scripts/elm.asm:84` clears it | Falkner must be beaten so `maps/VioletGym.asm:38` `specialphonecall SPECIALCALL_ASSISTANT` arms the call, then the call must actually land | after the phone call, `VIOLETPOKECENTER1F_ELMS_AIDE` appears at `4, 3` | +| Miracle Seed | `maps/Route32.asm` `Route32CooltrainerMScript` | `ENGINE_ZEPHYRBADGE` AND `EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE` | both true -> `.GiveMiracleSeed` | +| Kabuto chamber floor | `maps/RuinsOfAlphKabutoChamber.asm` `RuinsOfAlphKabutoChamberHiddenDoorsCallback` (`MAPCALLBACK_TILES`) | `EVENT_SOLVED_KABUTO_PUZZLE` | solve `special UnownPuzzle` with `setval UNOWNPUZZLE_KABUTO` from the `BGEVENT_UP` at `3, 2` | +| Unown do not spawn at all | `engine/overworld/wildmons.asm:341-347` `cp UNOWN` / `ld a, [wUnlockedUnowns]` / `and a` / `jr z, .nowildbattle` | at least one `ENGINE_UNLOCKED_UNOWNS_*` bit set | solve any chamber puzzle | +| Only some Unown letters spawn | `engine/battle/core.asm:6219` `CheckUnownLetter` vs `data/wild/unlocked_unowns.asm` | the letter must be in an unlocked set | Kabuto -> A-K, Omanyte -> L-R, Aerodactyl -> S-W, Ho-Oh -> X-Z | +| Unown #DEX scientist | `maps/RuinsOfAlphOutside.asm` `RuinsOfAlphOutsideScientistCallback` | `ENGINE_UNOWN_DEX` clear AND `EVENT_MADE_UNOWN_APPEAR_IN_RUINS` set AND `VAR_UNOWNCOUNT > 2` | catch three distinct Unown forms, then walk onto `11, 14` or `10, 15` | +| Aerodactyl chamber | reachable only across the Ruins Outside water; `engine/events/overworld.asm:490` `ld de, ENGINE_FOGBADGE` / `call CheckEngineFlag`, then `ld d, SURF` / `call CheckPartyMove` | FOGBADGE + a party member knowing SURF | Chuck's badge, later game | +| Union Cave B1F boulder at `7, 10` | `maps/UnionCaveB1F.asm` `UnionCaveB1FBoulder` -> `jumpstd StrengthBoulderScript` -> `engine/events/std_scripts.asm:197` `farsjump AskStrengthScript` -> `engine/events/overworld.asm:1033` `TryStrengthOW`: `ld d, STRENGTH` / `CheckPartyMove`, then `ld de, ENGINE_PLAINBADGE` / `CheckEngineFlag` | PLAINBADGE + STRENGTH in the party | Whitney's badge + HM04 | +| Union Cave Lapras | `maps/UnionCaveB2F.asm` `UnionCaveB2FLaprasCallback` | `VAR_WEEKDAY == FRIDAY` and `ENGINE_UNION_CAVE_LAPRAS` clear | real-world Friday, once ever | +| Frieda / POISON_BARB | `maps/Route32.asm` `Route32FriedaCallback` + `FriedaScript` | `VAR_WEEKDAY == FRIDAY` | real-world Friday | +| Route 36 Sudowoodo | `maps/Route36.asm` `object_event 35, 9, SPRITE_WEIRD_TREE, ... SudowoodoScript, EVENT_ROUTE_36_SUDOWOODO` | out of scope for this section - the walkthrough says "ignore that for now" | next section | +| FLASH | **not required** - `data/maps/maps.asm:107-109` give all three Union Cave floors `PALETTE_NITE`, not `PALETTE_DARK` | none | n/a | + +--- + +## 4. Bot checklist + +Coordinates are `x, y` in the map's own cell space, as written in the asm. + +1. `VIOLET_CITY` -> step onto warp 5 at `31, 25`. + pre: `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` CLEAR (Elm's call landed). + post: on `VIOLET_POKECENTER_1F`. +2. `VIOLET_POKECENTER_1F` -> talk to `VIOLETPOKECENTER1F_ELMS_AIDE` at `4, 3`, + answer YES. + pre: `VAR_PARTYCOUNT < PARTY_LENGTH`. + post: `EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE` set; `ROUTE_32` scene = + `SCENE_ROUTE32_OFFER_SLOWPOKETAIL`. +3. `VIOLET_POKECENTER_1F` -> heal at `VIOLETPOKECENTER1F_NURSE` (`3, 1`), buy balls + at `VIOLET_MART` (Violet City warp 1 at `9, 17`). Then exit west out of Violet + City on the map connection into `ROUTE_36`. +4. `ROUTE_36` -> walk to warp 3/4 at `47, 13` / `48, 13`. + avoid: `object_event 35, 9` Sudowoodo, and trainers at `20, 12` / `31, 14`. + post: `ROUTE_36_RUINS_OF_ALPH_GATE`. +5. `ROUTE_36_RUINS_OF_ALPH_GATE` -> warp 3/4 at `4, 7` / `5, 7`. + post: `RUINS_OF_ALPH_OUTSIDE` at warp 9 (`7, 5`). +6. `RUINS_OF_ALPH_OUTSIDE` -> walk to warp 2 at `14, 7`. + avoid: `TrainerPsychicNathan` at `4, 20` (L26 Girafarig, sight 1) - it is far + south of this path, so no detour is needed. + post: `RUINS_OF_ALPH_KABUTO_CHAMBER`. +7. `RUINS_OF_ALPH_KABUTO_CHAMBER` -> stand at `3, 3` facing UP, press A on the + `BGEVENT_UP` at `3, 2`. Solve the sliding puzzle + (`special UnownPuzzle`, `setval UNOWNPUZZLE_KABUTO`). + post: `EVENT_SOLVED_KABUTO_PUZZLE`, `ENGINE_UNLOCKED_UNOWNS_A_TO_K`, + `EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS`, + `EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST`, `RUINS_OF_ALPH_INNER_CHAMBER` + scene = `SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE`; `warpcheck` drops you + through warp 3/4 into the Inner Chamber at `15, 3` / `16, 3`. +8. `RUINS_OF_ALPH_INNER_CHAMBER` -> the deferred scene prints "strange presence". + post: `EVENT_MADE_UNOWN_APPEAR_IN_RUINS`; scene reset to + `SCENE_RUINSOFALPHINNERCHAMBER_NOOP`. +9. `RUINS_OF_ALPH_INNER_CHAMBER` -> grind grass encounters (rate `6 percent`, + always `5 UNOWN`, letters A-K) and CATCH three distinct letters. + post: `VAR_UNOWNCOUNT >= 3`. +10. `RUINS_OF_ALPH_INNER_CHAMBER` -> warp 1 at `10, 13`. + post: `RUINS_OF_ALPH_OUTSIDE` at `10, 13`; `RuinsOfAlphOutsideScientistCallback` + runs on load and `appear`s the scientist + sets the scene. +11. `RUINS_OF_ALPH_OUTSIDE` -> step to `10, 15` (or `11, 14`). + pre: `VAR_UNOWNCOUNT > 2`, `ENGINE_UNOWN_DEX` clear. + post: cutscene runs; the script `applymovement`s you into the Research Center. + Do not drive movement during this - it uses `follow` / `stopfollow` / `warpcheck`. +12. `RUINS_OF_ALPH_RESEARCH_CENTER` -> the scene fires on entry; press through. + post: `ENGINE_UNOWN_DEX` set; scene reset to + `SCENE_RUINSOFALPHRESEARCHCENTER_NOOP`. +13. `RUINS_OF_ALPH_RESEARCH_CENTER` -> warp 1/2 at `2, 7` / `3, 7` back outside, then + walk south-east to warp 10/11 at `13, 20` / `13, 21`. + post: `ROUTE_32_RUINS_OF_ALPH_GATE`. +14. `ROUTE_32_RUINS_OF_ALPH_GATE` -> warp 3/4 at `9, 4` / `9, 5`. + post: `ROUTE_32` at `4, 2` / `4, 3`. +15. `ROUTE_32` -> optional: north connection into Violet City for the + `VioletCityFruitTree` at Violet City `14, 29` (PRZCUREBERRY, + `data/items/fruit_trees.asm` `; VIOLET_CITY`). +16. `ROUTE_32` -> talk to `ROUTE32_COOLTRAINER_M` at `19, 8`. + pre: `ENGINE_ZEPHYRBADGE` set AND `EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE` set. + post: `MIRACLE_SEED` in bag, `EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32`. +17. `ROUTE_32` -> talk to `ROUTE32_FISHER5` at `15, 13`. + post: `TM_ROAR`, `EVENT_GOT_TM05_ROAR`. +18. `ROUTE_32` -> battle `TrainerYoungsterAlbert` (object at `13, 23`, sight 3). + post: `EVENT_BEAT_YOUNGSTER_ALBERT`. +19. `ROUTE_32` -> pick up the `POTION` itemball at `3, 30`. + post: `EVENT_ROUTE_32_POTION`. +20. `ROUTE_32` -> battle `TrainerPicnickerLiz1` (object at `10, 30`, sight 1), then + TALK TO HER AGAIN. + post: `EVENT_BEAT_PICNICKER_LIZ`, `EVENT_LIZ_ASKED_FOR_PHONE_NUMBER`, then + `PHONE_PICNICKER_LIZ` registered via `askforphonenumber`. +21. `ROUTE_32` -> hidden `SUPER_POTION` at `11, 40` (`BGEVENT_ITEM`, needs the + ITEMFINDER-style bump or a direct A press on the tile). + post: `EVENT_ROUTE_32_HIDDEN_SUPER_POTION`. +22. `ROUTE_32` -> battle `TrainerFisherJustin` (`8, 49`, sight 1), then + `TrainerFisherHenry` (`6, 48`, sight 1), then `TrainerFisherRalph1` + (`12, 56`, sight 3) and talk to Ralph again for `PHONE_FISHER_RALPH`. +23. `ROUTE_32` -> battle `TrainerCamperRoland` (`1, 56`, sight 4). +24. `ROUTE_32` -> pick up the `GREAT_BALL` itemball at `6, 53`. + post: `EVENT_ROUTE_32_GREAT_BALL`. +25. `ROUTE_32` -> battle `TrainerYoungsterGordon` (`4, 65`, `SPINCLOCKWISE`, sight 3). +26. `ROUTE_32` -> hidden `GREAT_BALL` at `12, 67`; on a Friday `ROUTE32_FRIEDA` + stands there, talk to her for `POISON_BARB`. + post: `EVENT_ROUTE_32_HIDDEN_GREAT_BALL`; optionally + `EVENT_GOT_POISON_BARB_FROM_FRIEDA`. +27. `ROUTE_32` -> step onto `7, 71` to fire `Route32WannaBuyASlowpokeTailScript` + (pre: scene == `SCENE_ROUTE32_OFFER_SLOWPOKETAIL`); answer either way. + post: scene = `SCENE_ROUTE32_NOOP`, one-shot done. +28. `ROUTE_32` -> warp 1 at `11, 73` into `ROUTE_32_POKECENTER_1F`; heal at `3, 1`; + talk to the Fishing Guru at `1, 4` and answer YES. + post: `OLD_ROD`, `EVENT_GOT_OLD_ROD`. +29. `ROUTE_32` -> battle `TrainerBirdKeeperPeter` (`11, 82`, sight 3), then warp 4 at + `6, 79`. + post: `UNION_CAVE_1F` at `17, 3`. +30. `UNION_CAVE_1F` -> `POTION` itemball at `4, 2`; battle `TrainerHikerDaniel` + (`4, 4`, `SPINRANDOM_FAST`, sight 1). + post: `EVENT_UNION_CAVE_1F_POTION`, `EVENT_BEAT_HIKER_DANIEL`. +31. `UNION_CAVE_1F` -> battle `TrainerHikerRussell` (`15, 8`, sight 3) and + `TrainerFirebreatherBill` (`15, 15`, sight 2). +32. `UNION_CAVE_1F` -> `X_ATTACK` itemball at `4, 17`; `GREAT_BALL` itemball at + `17, 21`; battle `TrainerPokemaniacLarry` (`4, 21`, sight 2). + post: `EVENT_UNION_CAVE_1F_X_ATTACK`, `EVENT_UNION_CAVE_1F_GREAT_BALL`, + `EVENT_BEAT_POKEMANIAC_LARRY`. +33. `UNION_CAVE_1F` -> warp 1 at `5, 19`. + post: `UNION_CAVE_B1F` at `7, 19`. +34. `UNION_CAVE_B1F` -> `TM_SWIFT` itemball at `2, 16`, then `X_DEFEND` itemball at + `17, 23`. + avoid: `TrainerHikerPhillip` `10, 4`, `TrainerHikerLeonard` `17, 10`, + `TrainerPokemaniacCalvin` `17, 30` (sight 3), `TrainerPokemaniacAndrew` `5, 32` - + all L23-26. + post: `EVENT_UNION_CAVE_B1F_TM_SWIFT`, `EVENT_UNION_CAVE_B1F_X_DEFEND`. +35. `UNION_CAVE_B1F` -> warp 4 at `3, 33`. + post: `UNION_CAVE_1F` at `3, 33`. +36. `UNION_CAVE_1F` -> `AWAKENING` itemball at `12, 33`; battle + `TrainerFirebreatherRay` (`16, 31`, sight 4); warp 3 at `17, 31`. + post: `EVENT_UNION_CAVE_1F_AWAKENING`, `EVENT_BEAT_FIREBREATHER_RAY`, + on `ROUTE_33` at `11, 9`. +37. `ROUTE_33` -> `Route33FruitTree` at `14, 16` for `PSNCUREBERRY`. +38. `ROUTE_33` -> battle `TrainerHikerAnthony` (`6, 13`, `SPINRANDOM_FAST`, sight 2), + then talk again for `PHONE_HIKER_ANTHONY`. + post: `EVENT_BEAT_HIKER_ANTHONY`, `EVENT_ANTHONY_ASKED_FOR_PHONE_NUMBER`. +39. `ROUTE_33` -> walk west across the map connection into `AZALEA_TOWN`. + Section ends here. + +Deferred steps (record as TODO, do not attempt this pass): + +- `RUINS_OF_ALPH_OUTSIDE` warp 4 at `16, 33` (Aerodactyl puzzle) - needs SURF + + `ENGINE_FOGBADGE`. +- `UNION_CAVE_B1F` boulder at `7, 10` -> `UNION_CAVE_B2F` - needs STRENGTH + + `ENGINE_PLAINBADGE`; then the Friday Lapras at B2F `11, 31` (needs SURF). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map data (warps / coord / bg / object events) for every map in this section | `src/import/RomExtractorGen2.lua` (`MapGroupPointers` walk, per-map `def_*` header parse around lines 878-987), consumed by `src/world/gen2/Map.lua` and `src/world/gen2/World.lua` | implemented - maps are extracted generically from the ROM, so nothing here is hand-authored per map | +| Warps / map connections | `src/world/gen2/World.lua`, `src/world/gen2/BorderFill.lua` | implemented | +| Coord-event trip-wires and `scene` bytes | `src/world/gen2/World.lua:5013` (coord event scan), `src/script/gen2/Vm.lua:274-296` (`setscene`, `checkscene`, `setmapscene`, `checkmapscene`) | implemented | +| Map callbacks (`MAPCALLBACK_OBJECTS` / `MAPCALLBACK_TILES`) - Frieda, Lapras, Ruins scientist, Kabuto floor | `src/world/gen2/World.lua:5659-5700` (`runMapCallback`, documented ordering at 5980-5984), `src/script/gen2/Vm.lua:1002` (`changeblock`) | implemented; driver `tests/drivers/gold_map_callbacks.lua` exercises callbacks generally, but not these maps specifically | +| `verbosegiveitem` / `giveitem` (Miracle Seed, TM05, Poison Barb, Old Rod) | `src/script/gen2/Vm.lua:490-498` | implemented | +| Itemballs (`OBJECTTYPE_ITEMBALL`) | `src/world/gen2/World.lua` object handling | implemented | +| Hidden items (`BGEVENT_ITEM` / `hiddenitem`) | `src/world/gen2/HiddenItems.lua`, `src/import/RomExtractorGen2.lua` | implemented | +| `giveegg` (Togepi EGG) + hatching | `src/script/gen2/Vm.lua:453`, `src/core/gen2/Breeding.lua`; driver `tests/drivers/gold_egg_hatch.lua` | implemented | +| Phone: `askforphonenumber` / `checkcellnum`, Ralph/Liz/Anthony contacts | `src/script/gen2/Vm.lua` (op present), `src/core/gen2/Phone.lua:172-179` has `[17] FISHER/RALPH1 ROUTE_32`, `[18] PICNICKER/LIZ1 ROUTE_32`, `[19] HIKER/ANTHONY2 ROUTE_33` | implemented | +| Elm's `SPECIALCALL_ASSISTANT` call that spawns the aide | `src/core/gen2/Phone.lua` | partial - the contact table is present; I did not find a `SPECIALCALL_*` special-call scheduler, so verify by hand that the aide actually appears | +| Unown puzzle (`special UnownPuzzle`, `setval UNOWNPUZZLE_*`) | `src/script/gen2/Specials.lua:919` (`H.UnownPuzzle`), UI in `src/ui/gen2/UnownPuzzle.lua` | implemented | +| Unown letter unlock sets / `wUnlockedUnowns` / letter-from-DVs | `src/core/gen2/Unown.lua` (ports `GetUnownLetter`, `CheckUnownLetter`, `UnlockedUnownLetterSets`, `NUM_UNOWN = 26`, `ENGINE_UNOWN_DEX = 12`) | implemented | +| `ChooseWildEncounter` cancelling Unown when `wUnlockedUnowns == 0` | `src/battle/gen2/Encounter.lua` | partial - `Encounter.lua` handles grass/water/fish tables; I did not verify the Unown short-circuit lives there. Verify by hand | +| Unown #DEX mode / Unown printer | `src/ui/gen2/PokedexMenu.lua`, `src/ui/gen2/UnownPrinter.lua` | implemented | +| Wild encounters, morn/day/nite split, encounter rates | `src/battle/gen2/Encounter.lua` | implemented | +| Fishing (Old Rod, fish groups) | `src/battle/gen2/Encounter.lua:79-111` (`Encounter.fish`, `Encounter.fishSlot`, map `fishGroup`) | implemented | +| Fruit trees (`fruittree FRUITTREE_ROUTE_33` -> PSNCUREBERRY) | `src/script/gen2/Vm.lua` (`fruittree` op), `src/core/gen2/Apricorns.lua:360` (`"PSNCUREBERRY", -- 06 FRUITTREE_ROUTE_33`) | implemented | +| Strength boulder (`jumpstd StrengthBoulderScript` -> `TryStrengthOW`) | `src/world/gen2/FieldMoves.lua:684-689` (documents the 0/1/2 `wScriptVar` result), badge map at :104-110 (`STRENGTH = "PLAIN"`) | implemented; driver `tests/drivers/gold_icepath_boulder.lua` covers a different map | +| Surf / FOGBADGE gate for the Aerodactyl chamber | `src/world/gen2/FieldMoves.lua:106` (`SURF = "FOG"`) | implemented | +| `ENGINE_UNION_CAVE_LAPRAS` flag id | `src/core/gen2/Apricorns.lua:84` (`{ id = 88, name = "ENGINE_UNION_CAVE_LAPRAS" }`) | implemented | +| `VAR_WEEKDAY` gating (Frieda, Lapras) | `src/script/gen2/Specials.lua`, `src/world/gen2/World.lua` | implemented | +| Ruins of Alph radio station (213.5) | `src/ui/gen2/Pokegear.lua:85, 799-802` (`UNOWN_RADIO`, landmark-gated to `LANDMARK_RUINS_OF_ALPH`) | implemented | +| Mom's shopping (Super Potion / Repel) | `src/core/gen2/MomShopping.lua`; asm table `data/items/mom_phone.asm` | implemented | +| End-to-end driver for this section | none | **missing** - `tests/drivers/gold_*.lua` has no Ruins of Alph, Route 32, Union Cave or Route 33 driver. The nearest are `gold_walk_smoke.lua` and `gold_trainer_smoke.lua` | + +--- + +## 6. Unresolved / verify by hand + +1. **"Repel" on Route 32.** The walkthrough lists Repel among Route 32 items and says + "Take the left path and get the Repel". `maps/Route32.asm` has no `REPEL` anywhere. + The only pickups on that fork are the `POTION` itemball at `3, 30` and the hidden + `SUPER_POTION` at `11, 40`. The Repel the player actually ends up with almost + certainly comes from `data/items/mom_phone.asm` `MomItems_2` (`momitem 4000, 270, + MOM_ITEM, REPEL`), which fits the walkthrough's own "Mom will buy something once + your savings reach certain thresholds" paragraph. Treat the Route 32 Repel as a + walkthrough error. + +2. **Ekans on Routes 32 and 33.** The walkthrough lists `#023 Ekans` for both routes. + `data/wild/johto_grass.asm` puts `EKANS` only in the `ELIF DEF(_SILVER)` branches; + the `IF DEF(_GOLD)` branches have `RATTATA` in that slot. This section's encounter + list is Silver's, not Gold's. + +3. **"Unown in its 28 forms ... plus two punctuation marks (? and !)."** + `constants/pokemon_constants.asm:308` is `DEF NUM_UNOWN EQU const_value - 1 ; 26`, + and the form constants stop at `UNOWN_Z`. The `?` and `!` forms are a Gen 3 + addition. The Research Center printer/computer both check `ifequal NUM_UNOWN`, so + the completion target in Gold is 26. + +4. **"Talk to the two people inside" the Kabuto chamber.** + `maps/RuinsOfAlphKabutoChamber.asm` defines exactly one `object_event` + (`RUINSOFALPHKABUTOCHAMBER_RECEPTIONIST` at `5, 5`). There is no second NPC. + +5. **`RuinsOfAlphOutside` declares five object consts but only two objects.** + `RUINSOFALPHOUTSIDE_FISHER`, `_YOUNGSTER2` and `_YOUNGSTER3` have no + `object_event` row. Either dead constants or a Crystal-era leftover; a port that + indexes objects by const must not assume the list is dense. + +6. **Route 32 grass Pokemon the walkthrough omits.** It lists Rattata, Ekans, Zubat, + Bellsprout, Mareep, Hoppip, Wooper. The Gold table also never yields Ekans (see 2), + and Wooper is morn/nite only - the day table has no Wooper at all. Similarly Union + Cave 1F/B1F contain `SANDSHREW` and `ONIX` in Gold, neither of which the + walkthrough's Union Cave list mentions. + +7. **"Some rando fisher will give you an Old Rod" - order.** The walkthrough puts the + Old Rod after Frieda and the Slowpoke-tail salesman. The Fishing Guru is inside + `ROUTE_32_POKECENTER_1F` (`1, 4`), reached by warp 1 at Route 32 `11, 73`, which is + south of Frieda (`12, 67`) and the salesman coord event (`7, 71`). Order is + consistent, but the guru is indoors, not "on the route". + +8. **"X Attack below Bill" / "the item to the far left ... a Potion".** + Firebreather Bill is at `15, 15`; the X Attack is at `4, 17` and the Potion at + `4, 2`. Both are far to the WEST, not directly below/left of Bill. The prose + directions are loose; use the coordinates. + +9. **`SPECIALCALL_*` scheduling in the port.** I could not find the mechanism that + arms and delivers `SPECIALCALL_ASSISTANT` (`maps/VioletGym.asm:38` -> + `engine/phone/scripts/elm.asm` `.assistant`) in this repo. `src/core/gen2/Phone.lua` + has the contact table but the special-call queue was not located. Without it, + `EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER` never clears, the aide never appears, + and Route 32 stays permanently blocked by the `18, 8` coord event - the single + hardest failure mode for a bot in this section. + +10. **The Unown short-circuit in `ChooseWildEncounter`.** `engine/overworld/wildmons.asm:341-347` + cancels the encounter entirely when `wUnlockedUnowns == 0`. I confirmed + `src/core/gen2/Unown.lua` ports the letter sets and `CheckUnownLetter`, but did + not confirm `src/battle/gen2/Encounter.lua` reproduces the "no puzzle solved -> + no encounter at all" branch. If it does not, a bot could meet Unown in the Inner + Chamber before solving any puzzle. + +11. **`Route32Pokecenter1FFishingGuruScript` has no bag-full guard.** Unlike + `Route32CooltrainerMScript` and `FriedaScript`, it does not `iffalse` after + `verbosegiveitem OLD_ROD` before `setevent EVENT_GOT_OLD_ROD`. If the KEY ITEMS + pocket were full the flag would be set without the rod. This is asm behaviour, + not a port bug - flagged so nobody "fixes" it. + +12. **Union Cave Lapras is once-ever, not once-weekly.** `UnionCaveLapras` sets + `ENGINE_UNION_CAVE_LAPRAS` after `startbattle` regardless of outcome, and + `UnionCaveB2FLaprasCallback` checks that flag before the weekday check. The + walkthrough's "only appears on Friday" is right about the weekday but does not + mention that fleeing or fainting loses it permanently. diff --git a/docs/gold-walkthrough/asm-walk/section-04-slowpoke-well-azalea-town-gym.md b/docs/gold-walkthrough/asm-walk/section-04-slowpoke-well-azalea-town-gym.md new file mode 100644 index 00000000..f63be0ca --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-04-slowpoke-well-azalea-town-gym.md @@ -0,0 +1,907 @@ +# Section 04 - Slowpoke Well & Azalea Town Gym + +Source: `../section-04-slowpoke-well-azalea-town-gym.txt` +Maps covered: `MAP_AZALEA_TOWN`, `MAP_KURTS_HOUSE`, `MAP_AZALEA_POKECENTER_1F`, +`MAP_SLOWPOKE_WELL_B1F`, `MAP_SLOWPOKE_WELL_B2F` (off-route, Surf-only), +`MAP_AZALEA_GYM`. `MAP_AZALEA_MART` is referenced for its stock only +(`data/items/marts.asm` `MartAzalea`); its map file was not transcribed. +Badges / key milestones in this section: **HIVEBADGE** (`ENGINE_HIVEBADGE`), +`EVENT_CLEARED_SLOWPOKE_WELL`, `EVENT_BEAT_BUGSY`, `TM_FURY_CUTTER` (TM49), +`LURE_BALL`, `SUPER_POTION`, `WHT_APRICORN`, hidden `FULL_HEAL`. Clearing the +well is what unlocks the Gym door and arms the Azalea rival battle for the next +section. + +Coordinate convention throughout: the raw asm x/y from +`warp_event` / `coord_event` / `bg_event` / `object_event`, i.e. walk cells +(two 8x8 tiles per side, two cells per map block). `macros/scripts/maps.asm` +adds the +4 border internally; the numbers below are the pre-border values as +written in the map file. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_AZALEA_TOWN` | `maps/AzaleaTown.asm` | west edge of Route 33 (`connection east, Route33, ROUTE_33, 0` in `data/maps/attributes.asm`) | warp 4, `warp_event 9, 5, KURTS_HOUSE, 1` | Rocket guard is standing on the well; head west then north to Kurt's house | +| 2 | `MAP_KURTS_HOUSE` | `maps/KurtsHouse.asm` | warp 1 (`3, 7`) | warps 1/2 (`3, 7` / `4, 7`) back to `AZALEA_TOWN` warp 4 | `Kurt1` first-visit arm: Kurt storms off to the well and sets `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET`, which hides the guard | +| 3 | `MAP_AZALEA_POKECENTER_1F` | `maps/AzaleaPokecenter1F.asm` | `AZALEA_TOWN` warp 1 (`15, 9`) | warps 1/2 (`3, 7` / `4, 7`) | "heal at the Pokemon Center" | +| 4 | `MAP_AZALEA_TOWN` | `maps/AzaleaTown.asm` | Pokecenter warp | warp 6, `warp_event 31, 7, SLOWPOKE_WELL_B1F, 1` | walk east to the now-unguarded well | +| 5 | `MAP_SLOWPOKE_WELL_B1F` | `maps/SlowpokeWellB1F.asm` | warp 1 (`17, 15`) | **scripted** `warp KURTS_HOUSE, 3, 3` at the end of `TrainerGruntM1.Script` | four Rocket grunts, Super Potion, tailless Slowpoke | +| 6 | `MAP_KURTS_HOUSE` | `maps/KurtsHouse.asm` | script warp to `3, 3` | warps 1/2 | `Kurt1.ClearedSlowpokeWell` -> free `LURE_BALL` | +| 7 | `MAP_AZALEA_TOWN` | `maps/AzaleaTown.asm` | Kurt's House warp | fruit tree object at `8, 2`, then warp 1 (Pokecenter) | `WHT_APRICORN` from `WhiteApricornTree` / `FRUITTREE_AZALEA_TOWN`, then heal | +| 8 | `MAP_AZALEA_GYM` | `maps/AzaleaGym.asm` | `AZALEA_TOWN` warp 5, `warp_event 10, 15, AZALEA_GYM, 1` | warps 1/2 (`4, 15` / `5, 15`) | five gym trainers + Bugsy -> HIVEBADGE + TM49 | + +Off-route in this section: `MAP_SLOWPOKE_WELL_B2F` (`maps/SlowpokeWellB2F.asm`) +hangs off `SLOWPOKE_WELL_B1F` warp 2 at `7, 11`, but the only land approach to +that ladder is across water (see the derived grid under B1F) - it is a Surf +return trip, not part of this visit, and holds the King's Rock and TM Rain Dance. + +Spills into the next section: `AzaleaTown_MapEvents` has +`coord_event 5, 10` / `coord_event 5, 11` for `SCENE_AZALEATOWN_RIVAL_BATTLE`. +`TrainerGruntM1.Script` arms that scene with +`setmapscene AZALEA_TOWN, SCENE_AZALEATOWN_RIVAL_BATTLE`, so the rival ambush +fires the first time you walk west toward the Ilex Forest gate. That battle and +Ilex Forest belong to section 05. + +## 2. Maps + +### MAP_AZALEA_TOWN + +- Script: `maps/AzaleaTown.asm` +- Blocks: `maps/AzaleaTown.blk` +- Header: `data/maps/maps.asm:232` -> `TILESET_JOHTO_MODERN`, `TOWN`, + `LANDMARK_AZALEA_TOWN`, `MUSIC_AZALEA_TOWN`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:213` -> `map_const AZALEA_TOWN, 20, 9` + (20x9 blocks = 40x18 walk cells) +- Connections (`data/maps/attributes.asm:132`): `map_attributes AzaleaTown, AZALEA_TOWN, $05`, + `connection west, Route34, ROUTE_34, -18`, `connection east, Route33, ROUTE_33, 0` +- Scene var: `data/maps/scenes.asm:37` -> `scene_var AZALEA_TOWN, wAzaleaTownSceneID` +- Spawn / fly landing: `data/maps/spawn_points.asm:31` -> `spawn AZALEA_TOWN, 15, 10`; + flypoint row `data/maps/flypoints.asm:8` -> `db LANDMARK_AZALEA_TOWN, SPAWN_AZALEA` +- Map scripts: `def_scene_scripts` = `AzaleaTownNoop1Scene` (`SCENE_AZALEATOWN_NOOP` = 0), + `AzaleaTownNoop2Scene` (`SCENE_AZALEATOWN_RIVAL_BATTLE` = 1); + `def_callbacks` = `callback MAPCALLBACK_NEWMAP, AzaleaTownFlypointCallback` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 9 | `AZALEA_POKECENTER_1F` | 1 | +| 2 | 21 | 13 | `CHARCOAL_KILN` | 1 | +| 3 | 21 | 5 | `AZALEA_MART` | 2 | +| 4 | 9 | 5 | `KURTS_HOUSE` | 1 | +| 5 | 10 | 15 | `AZALEA_GYM` | 1 | +| 6 | 31 | 7 | `SLOWPOKE_WELL_B1F` | 1 | +| 7 | 2 | 10 | `ILEX_FOREST_AZALEA_GATE` | 3 | +| 8 | 2 | 11 | `ILEX_FOREST_AZALEA_GATE` | 4 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_AZALEATOWN_RIVAL_BATTLE` | 5 | 10 | `AzaleaTownRivalBattleScene1` | rival ambush, approach from the east; next section | +| `SCENE_AZALEATOWN_RIVAL_BATTLE` | 5 | 11 | `AzaleaTownRivalBattleScene2` | same battle, second entry row; next section | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 19 | 9 | `BGEVENT_READ` | `AzaleaTownSign` | +| 10 | 9 | `BGEVENT_READ` | `KurtsHouseSign` | +| 14 | 15 | `BGEVENT_READ` | `AzaleaGymSign` | +| 29 | 7 | `BGEVENT_READ` | `SlowpokeWellSign` | +| 19 | 13 | `BGEVENT_READ` | `CharcoalKilnSign` | +| 16 | 9 | `BGEVENT_READ` | `AzaleaTownPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 22 | 5 | `BGEVENT_READ` | `AzaleaTownMartSign` (`jumpstd MartSignScript`) | +| 3 | 9 | `BGEVENT_READ` | `AzaleaTownIlextForestSign` | +| 31 | 6 | `BGEVENT_ITEM` | `AzaleaTownHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL` | + +**Object events** (`def_object_events`) - `object_const_def` is `const_def 2`, so +the first const is 2 and row N carries const N+1. + +| const | sprite | x | y | movement (radius x,y / hours / pal) | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `AZALEATOWN_AZALEA_ROCKET1` | `SPRITE_AZALEA_ROCKET` | 31 | 9 | `STANDING_DOWN` 0,0 / -1,-1 / 0 | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownRocket1Script` | `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET` | +| 3 `AZALEATOWN_GRAMPS` | `SPRITE_GRAMPS` | 21 | 9 | `WANDER` 1,2 / -1,-1 / 0 | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownGrampsScript` | -1 | +| 4 `AZALEATOWN_TEACHER` | `SPRITE_TEACHER` | 15 | 13 | `WALK_UP_DOWN` 0,2 / -1,-1 / `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownTeacherScript` | -1 | +| 5 `AZALEATOWN_YOUNGSTER` | `SPRITE_YOUNGSTER` | 7 | 9 | `WALK_LEFT_RIGHT` 1,0 / -1,-1 / `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownYoungsterScript` | -1 | +| 6 `AZALEATOWN_SLOWPOKE1` | `SPRITE_SLOWPOKE` | 8 | 17 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownSlowpokeScript` | `EVENT_AZALEA_TOWN_SLOWPOKES` | +| 7 `AZALEATOWN_SLOWPOKE2` | `SPRITE_SLOWPOKE` | 18 | 9 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownSlowpokeScript` | `EVENT_AZALEA_TOWN_SLOWPOKES` | +| 8 `AZALEATOWN_SLOWPOKE3` | `SPRITE_SLOWPOKE` | 29 | 9 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownSlowpokeScript` | `EVENT_AZALEA_TOWN_SLOWPOKES` | +| 9 `AZALEATOWN_SLOWPOKE4` | `SPRITE_SLOWPOKE` | 15 | 15 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownSlowpokeScript` | `EVENT_AZALEA_TOWN_SLOWPOKES` | +| 10 `AZALEATOWN_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 8 | 2 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `WhiteApricornTree` | -1 | +| 11 `AZALEATOWN_RIVAL` | `SPRITE_AZALEA_ROCKET` | 11 | 10 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT`, 0 | `ObjectEvent` | `EVENT_RIVAL_AZALEA_TOWN` | +| 12 `AZALEATOWN_AZALEA_ROCKET3` | `SPRITE_AZALEA_ROCKET` | 10 | 16 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaTownRocket2Script` | `EVENT_SLOWPOKE_WELL_ROCKETS` | + +`SPRITE_AZALEA_ROCKET` (`constants/sprite_constants.asm:156`, id `$f6`) is a +variable sprite. `engine/events/std_scripts.asm:539` seeds it as +`variablesprite SPRITE_AZALEA_ROCKET, SPRITE_ROCKET`; `TrainerGruntM1.Script` +in `maps/SlowpokeWellB1F.asm` re-points it with +`variablesprite SPRITE_AZALEA_ROCKET, SPRITE_RIVAL`, which is why const 11 draws +as the rival even though the row names the Rocket slot. + +**Derived walkability** (from `maps/AzaleaTown.blk` + `data/tilesets/johto_modern_collision.asm` ++ `data/collision/collision_permissions.asm`; `.` land, `#` wall, `h` ledge hop, +`W` warp tile, columns are x, rows are y) + +``` + 0123456789012345678901234567890123456789 + 0 ###############################......... + 1 ######....#####################......... + 2 ######....#####################......... + 3 ######....############################## + 4 ######..################################ + 5 ######..#W###########W######......###### + 6 ######hhh.hh########....####..###.###### + 7 #########.##########....####.##W#.###### + 8 ##...#......######............#.#.###### + 9 ######....#.###W##.#..............###### + 10 ##W.........................hhh.hh###### + 11 ##W.....................#######.######## + 12 ######..######......########......###### + 13 ######..######.....##W######............ + 14 ######..######..........####............ + 15 ######..##W####.........####............ + 16 ######..............#################### + 17 ######..............#################### +``` + +Two chokepoints matter: `31, 9` is the only walkable cell that reaches `31, 8` +and then the well ladder at `31, 7`, and `10, 16` is the only walkable cell +adjacent to the Gym door at `10, 15` (`9, 15`, `11, 15` and `10, 14` are all +wall). The two Rocket objects sit exactly on those cells. + +**Scripts of interest** + +- `AzaleaTownFlypointCallback` - `MAPCALLBACK_NEWMAP`; `setflag ENGINE_FLYPOINT_AZALEA`, + `endcallback`. Entering the map once is enough to register the fly point. +- `AzaleaTownRocket1Script` / `AzaleaTownRocket2Script` - + `jumptextfaceplayer AzaleaTownRocket1Text` / `...Rocket2Text`. Pure flavour; + neither one gates anything by script. They block by standing on tiles. +- `AzaleaTownGrampsScript` - `checkevent EVENT_CLEARED_SLOWPOKE_WELL` / + `iftrue .ClearedWell`, then one of `AzaleaTownGrampsTextBefore` / + `AzaleaTownGrampsTextAfter`. Cheap read-only probe of well state. +- `WhiteApricornTree` - `fruittree FRUITTREE_AZALEA_TOWN`. + `constants/script_constants.asm:226` gives `FRUITTREE_AZALEA_TOWN ; 14` (hex), + and `data/items/fruit_trees.asm:23` is `db WHT_APRICORN ; AZALEA_TOWN`. + Once-per-day, tracked by the fruit-tree daily bit, not by an `EVENT_*`. +- `AzaleaTownHiddenFullHeal` - `hiddenitem FULL_HEAL, EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL`; + reached by facing the wall cell `31, 6` from `31, 7`/`31, 5` neighbours. +- `AzaleaTownRivalBattleScene1` / `...Scene2` / `AzaleaTownRivalBattleScript` - + next section. For completeness they `setevent EVENT_RIVAL_AZALEA_TOWN`, + branch on `EVENT_GOT_TOTODILE_FROM_ELM` / `EVENT_GOT_CHIKORITA_FROM_ELM`, + `loadtrainer RIVAL1, RIVAL1_2_TOTODILE|RIVAL1_2_CHIKORITA|RIVAL1_2_CYNDAQUIL`, + and end with `setscene SCENE_AZALEATOWN_NOOP`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_AZALEA` | `constants/engine_flags.asm:82` | set by `AzaleaTownFlypointCallback` | Fly destination unlocked on first entry | +| `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET` | `constants/event_flags.asm:1180` | set by `Kurt1` (first-visit arm) and read by `KurtsGranddaughter.Lonely` | **set = the well guard at `31, 9` is hidden**; this is the well gate | +| `EVENT_SLOWPOKE_WELL_ROCKETS` | `constants/event_flags.asm:1182` | set by the `disappear` calls in `TrainerGruntM1.Script` | **set = the Rocket at `10, 16` is hidden**; this is the Gym gate | +| `EVENT_AZALEA_TOWN_SLOWPOKES` | `constants/event_flags.asm:1179` | `clearevent` in `TrainerGruntM1.Script` | cleared = the four town Slowpoke appear | +| `EVENT_CLEARED_SLOWPOKE_WELL` | `constants/event_flags.asm:52` | set in `TrainerGruntM1.Script`; read by `AzaleaTownGrampsScript`, `Kurt1`, `KurtsGranddaughter`, `KurtsHouseKurtCallback` | the section-4 milestone flag | +| `EVENT_RIVAL_AZALEA_TOWN` | `constants/event_flags.asm:1121` | set by `AzaleaTownRivalBattleScript` | next section | +| `EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL` | `constants/event_flags.asm:187` | `hiddenitem` | one-shot hidden item | +| `SCENE_AZALEATOWN_NOOP` = 0, `SCENE_AZALEATOWN_RIVAL_BATTLE` = 1 | generated by `scene_script` in `macros/scripts/maps.asm:25` (`scene_const` + `EXPORT`) | `setscene` / `setmapscene` | scene id in `wAzaleaTownSceneID` | + +Object visibility semantics, since everything above depends on it: +`engine/overworld/scripting.asm:879` `Script_appear` calls +`ApplyEventActionAppearDisappear` with `b = 0 ; clear`, and `:887` +`Script_disappear` with `b = 1 ; set`. **An object's `EVENT_*` flag SET means the +object is hidden.** + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `WHT_APRICORN` | talk to the tree object at `8, 2` | `WhiteApricornTree` -> `fruittree FRUITTREE_AZALEA_TOWN`, `data/items/fruit_trees.asm:23` | none (daily respawn) | +| `FULL_HEAL` | hidden, face `31, 6` | `AzaleaTownHiddenFullHeal` bg_event | `EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL` | + +Azalea Mart stock (`data/items/marts.asm:71`, `MartAzalea`, 9 items): +`CHARCOAL`, `POKE_BALL`, `POTION`, `SUPER_POTION`, `ESCAPE_ROPE`, `REPEL`, +`ANTIDOTE`, `PARLYZ_HEAL`, `FLOWER_MAIL`. + +**Trainers** + +None on this map in this section (the rival at const 11 is section 05). + +**Wild encounters** + +`AZALEA_TOWN` has no entry in `data/wild/johto_grass.asm` or +`data/wild/johto_water.asm`. Fishing group is `FISHGROUP_SHORE` +(`data/maps/maps.asm:232`); headbutt set is +`data/wild/treemon_maps.asm:31` -> `treemon_map AZALEA_TOWN, TREEMON_SET_FOREST`. + +--- + +### MAP_KURTS_HOUSE + +- Script: `maps/KurtsHouse.asm` +- Blocks: `maps/KurtsHouse.blk` +- Header: `data/maps/maps.asm:229` -> `TILESET_TRADITIONAL_HOUSE`, `INDOOR`, + `LANDMARK_AZALEA_TOWN`, `MUSIC_AZALEA_TOWN`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:210` -> `map_const KURTS_HOUSE, 8, 4` +- Connections: none (indoor) +- Map scripts: no scene scripts; `callback MAPCALLBACK_OBJECTS, KurtsHouseKurtCallback` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `AZALEA_TOWN` | 4 | +| 2 | 4 | 7 | `AZALEA_TOWN` | 4 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 6 | 1 | `BGEVENT_READ` | `KurtsHouseRadio` (`jumpstd Radio2Script`) | +| 8 | 0 | `BGEVENT_READ` | `KurtsHouseOakPhoto` | +| 9 | 0 | `BGEVENT_READ` | `KurtsHouseOakPhoto` | +| 5 | 1 | `BGEVENT_READ` | `KurtsHouseBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 2 | 1 | `BGEVENT_READ` | `KurtsHouseBookshelf` | +| 3 | 1 | `BGEVENT_READ` | `KurtsHouseBookshelf` | +| 4 | 1 | `BGEVENT_READ` | `KurtsHouseCelebiStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `KURTSHOUSE_KURT1` | `SPRITE_KURT` | 3 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, 0 | `Kurt1` | `EVENT_KURTS_HOUSE_KURT_1` | +| 3 `KURTSHOUSE_TWIN` | `SPRITE_TWIN` | 5 | 3 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT`, 0 | `KurtsGranddaughter` | -1 | +| 4 `KURTSHOUSE_SLOWPOKE` | `SPRITE_SLOWPOKE` | 6 | 3 | `STILL` | `OBJECTTYPE_SCRIPT`, 0 | `KurtsHouseSlowpoke` | `EVENT_KURTS_HOUSE_SLOWPOKE` | +| 5 `KURTSHOUSE_KURT2` | `SPRITE_KURT` | 14 | 3 | `STANDING_UP` | `OBJECTTYPE_SCRIPT`, 0 | `Kurt1` | `EVENT_KURTS_HOUSE_KURT_2` | + +Both Kurt objects run the same `Kurt1` script; the callback picks which one is +visible. + +**Derived walkability** (`maps/KurtsHouse.blk` + `data/tilesets/traditional_house_collision.asm`) + +``` + 0123456789012345 + 0 ################ + 1 #######...###### + 2 #...........#### + 3 .......##...#... + 4 .......##...#... + 5 ................ + 6 ................ + 7 ...WW........... +``` + +The post-well script warp lands you on `3, 3`, one cell south of `KURTSHOUSE_KURT1` +at `3, 2`, who faces down. Talk immediately, no walking required. + +**Scripts of interest** + +- `KurtsHouseKurtCallback` (`MAPCALLBACK_OBJECTS`) - + `checkevent EVENT_CLEARED_SLOWPOKE_WELL` / `iffalse .Done`; + then `checkflag ENGINE_KURT_MAKING_BALLS` / `iftrue .MakingBalls`. + Not making balls: `disappear KURTSHOUSE_KURT2`, `appear KURTSHOUSE_KURT1`. + Making balls: `disappear KURTSHOUSE_KURT1`, `appear KURTSHOUSE_KURT2` + (he moves to the workshop at `14, 3`). +- `Kurt1` - three arms, tested in this order: + 1. `checkevent EVENT_KURT_GAVE_YOU_LURE_BALL` / `iftrue .GotLureBall` + 2. `checkevent EVENT_CLEARED_SLOWPOKE_WELL` / `iftrue .ClearedSlowpokeWell` + 3. fallthrough = **first visit**: `writetext KurtsHouseKurtMakingBallsMustWaitText`, + `special FadeOutMusic`, `setevent EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET`, + `readvar VAR_FACING` / `ifequal UP, .RunAround`, `turnobject PLAYER, DOWN`, + `playsound SFX_FLY`, + `applymovement KURTSHOUSE_KURT1, KurtsHouseKurtExitHouseMovement` + (5x `big_step DOWN`; the `.RunAround` variant is + `KurtsHouseKurtGoAroundPlayerThenExitHouseMovement`, `big_step RIGHT` then + 5x `big_step DOWN`), `disappear KURTSHOUSE_KURT1`, `special RestartMapMusic`. + **This single `setevent` is what removes the well guard.** + - `.ClearedSlowpokeWell`: `writetext KurtsHouseKurtHonoredToMakeBallsText`, + `promptbutton`, `verbosegiveitem LURE_BALL`, `iffalse .NoRoomForBall`, + `setevent EVENT_KURT_GAVE_YOU_LURE_BALL`, then falls into `.GotLureBall`. + - `.GotLureBall`: the apricorn loop. Checks `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2`, + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`, then the seven + `EVENT_GAVE_KURT_<colour>_APRICORN` in order RED, BLU, YLW, GRN, WHT, BLK, PNK; + if none, `checkitem` the seven apricorns, `special SelectApricornForKurt`, + set the matching event, `setevent EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`, + `setflag ENGINE_KURT_MAKING_BALLS`. WHT -> `verbosegiveitem FAST_BALL` + (`.GiveFastBall`) the next day. Only one apricorn is held at a time, which + is exactly the reader comment quoted in the walkthrough. +- `KurtsGranddaughter` - branch order `EVENT_FAST_SHIP_FIRST_TIME`, + `EVENT_CLEARED_SLOWPOKE_WELL`, `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET`. Read-only. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_KURTS_HOUSE_KURT_1` | `constants/event_flags.asm:1248` | `disappear`/`appear` in `Kurt1` + callback | set = house Kurt hidden (he is at the well) | +| `EVENT_KURTS_HOUSE_KURT_2` | `constants/event_flags.asm:1249` | callback; also `setevent` in `engine/events/std_scripts.asm:531` | set = workshop Kurt hidden | +| `EVENT_KURTS_HOUSE_SLOWPOKE` | `constants/event_flags.asm:1183` | `clearevent` in `TrainerGruntM1.Script` | cleared = the family Slowpoke is back | +| `EVENT_KURT_GAVE_YOU_LURE_BALL` | `constants/event_flags.asm:62` | set in `Kurt1.ClearedSlowpokeWell` | free ball already taken | +| `EVENT_GAVE_KURT_WHT_APRICORN` | `constants/event_flags.asm:263` | `Kurt1.Wht` / cleared in `.GiveFastBall` | apricorn currently in Kurt's hands | +| `ENGINE_KURT_MAKING_BALLS` | `constants/engine_flags.asm:97` (`wDailyFlags1`) | `setflag` in `.GaveKurtApricorns`, read by the callback and every `.Give*Ball` arm | the day-long wait; cleared by the daily reset, not by a script | +| `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1` / `_2` | `constants/event_flags.asm:5` / `:6` | `Kurt1` | per-visit conversation state, wiped on map reload | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `LURE_BALL` | talk to Kurt after clearing the well | `Kurt1.ClearedSlowpokeWell` -> `verbosegiveitem LURE_BALL` | `EVENT_KURT_GAVE_YOU_LURE_BALL` | +| `FAST_BALL` | give Kurt the `WHT_APRICORN`, return a day later | `Kurt1.GiveFastBall` -> `verbosegiveitem FAST_BALL` | clears `EVENT_GAVE_KURT_WHT_APRICORN` | + +**Trainers** - none. **Wild encounters** - none (indoor). + +--- + +### MAP_AZALEA_POKECENTER_1F + +- Script: `maps/AzaleaPokecenter1F.asm` +- Blocks: not present in `maps/` (shared Pokecenter layout) +- Header: `data/maps/maps.asm:226` -> `TILESET_POKECENTER`, `INDOOR`, + `LANDMARK_AZALEA_TOWN`, `MUSIC_POKEMON_CENTER`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:207` -> `map_const AZALEA_POKECENTER_1F, 5, 4` +- Map scripts: `def_scene_scripts` = `AzaleaPokecenter1FNoopScene` (marked `; unusable`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `AZALEA_TOWN` | 1 | +| 2 | 4 | 7 | `AZALEA_TOWN` | 1 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events** / **BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `AZALEAPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaPokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 | +| 3 `AZALEAPOKECENTER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 9 | 6 | `WALK_UP_DOWN` 0,1 | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaPokecenter1FGentlemanScript` | -1 | +| 4 `AZALEAPOKECENTER1F_FISHING_GURU` | `SPRITE_FISHING_GURU` | 6 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaPokecenter1FFishingGuruScript` | -1 | +| 5 `AZALEAPOKECENTER1F_POKEFAN_F` | `SPRITE_POKEFAN_F` | 1 | 4 | `WANDER` 1,2 | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaPokecenter1FPokefanFScript` | -1 | + +Healing is `jumpstd PokecenterNurseScript`; no flags are touched. + +--- + +### MAP_SLOWPOKE_WELL_B1F + +- Script: `maps/SlowpokeWellB1F.asm` +- Blocks: `maps/SlowpokeWellB1F.blk` +- Header: `data/maps/maps.asm:110` -> `TILESET_CAVE`, `CAVE`, + `LANDMARK_SLOWPOKE_WELL`, `MUSIC_DARK_CAVE`, phone `TRUE`, `PALETTE_NITE`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:97` -> `map_const SLOWPOKE_WELL_B1F, 10, 9` + (20x18 walk cells) +- Connections: none +- Map scripts: `def_scene_scripts` and `def_callbacks` are both empty + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 15 | `AZALEA_TOWN` | 6 | +| 2 | 7 | 11 | `SLOWPOKE_WELL_B2F` | 1 | + +**Coord events** / **BG events** - none. Nothing trips automatically on this map; +every beat is a trainer sight-line or an A press. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement / pal | type, sight | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `SLOWPOKEWELLB1F_ROCKET1` | `SPRITE_ROCKET` | 14 | 8 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, 1 | `TrainerGruntM29` | `EVENT_SLOWPOKE_WELL_ROCKETS` | +| 3 `SLOWPOKEWELLB1F_ROCKET2` | `SPRITE_ROCKET` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, 1 | `TrainerGruntM1` | `EVENT_SLOWPOKE_WELL_ROCKETS` | +| 4 `SLOWPOKEWELLB1F_ROCKET3` | `SPRITE_ROCKET` | 5 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, 2 | `TrainerGruntM2` | `EVENT_SLOWPOKE_WELL_ROCKETS` | +| 5 `SLOWPOKEWELLB1F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 13 | 2 | `STANDING_DOWN` / `PAL_NPC_RED` | `OBJECTTYPE_TRAINER`, 2 | `TrainerGruntF1` | `EVENT_SLOWPOKE_WELL_ROCKETS` | +| 6 `SLOWPOKEWELLB1F_SLOWPOKE1` | `SPRITE_SLOWPOKE` | 7 | 4 | `STILL` / `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT`, 0 | `SlowpokeWellB1FSlowpokeWithMailScript` | `EVENT_SLOWPOKE_WELL_SLOWPOKES` | +| 7 `SLOWPOKEWELLB1F_SLOWPOKE2` | `SPRITE_SLOWPOKE` | 6 | 2 | `STILL` / `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT`, 0 | `SlowpokeWellB1FTaillessSlowpokeScript` | `EVENT_SLOWPOKE_WELL_SLOWPOKES` | +| 8 `SLOWPOKEWELLB1F_KURT` | `SPRITE_KURT` | 16 | 14 | `STANDING_UP` | `OBJECTTYPE_SCRIPT`, 0 | `SlowpokeWellB1FKurtScript` | `EVENT_SLOWPOKE_WELL_KURT` | +| 9 `SLOWPOKEWELLB1F_BOULDER` | `SPRITE_BOULDER` | 3 | 2 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT`, 0 | `SlowpokeWellB1FBoulder` (`jumpstd StrengthBoulderScript`) | -1 | +| 10 `SLOWPOKEWELLB1F_POKE_BALL` | `SPRITE_POKE_BALL` | 10 | 3 | `STILL` | `OBJECTTYPE_ITEMBALL`, 0 | `SlowpokeWellB1FSuperPotion` (`itemball SUPER_POTION`) | `EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION` | + +**Derived walkability** (`maps/SlowpokeWellB1F.blk` + `data/tilesets/cave_collision.asm` ++ `data/collision/collision_permissions.asm`; `~` = water, `L` = ladder/warp) + +``` + 01234567890123456789 + 1 .################... + 2 .#......#......##... + 3 .#..#...#......##... + 4 .#.#....#......##... + 5 .#.#....###.##.##... + 6 .#..#.......#...#... + 7 .##.##########..#... + 8 .#..#####....#..#... + 9 .#.######....#..#... + 10 .#.##........#..#... + 11 .#.##..L.#####..###. + 12 .#~~#....#~~~~....#. + 13 .#~~###.##~~~~....#. + 14 .#~~~~~~##~~~~....#. + 15 .#~~~~~~##~~~~...L#. +``` + +Route through the well, matching the walkthrough beat for beat: +`17, 15` (entry ladder) -> Kurt at `16, 14` -> north up the `14-15` column to +`14, 8`/`15, 8` where `TrainerGruntM29` sees you -> continue north to the top +room `9..14, 2..4`, where `TrainerGruntF1` at `13, 2` faces down with sight 2 -> +Super Potion ball at `10, 3` -> back east/down through the gap at `11, 5` into +the long row-6 corridor -> west to `TrainerGruntM2` at `5, 6` (faces right, +sight 2) -> Slowpoke with mail at `7, 4`, tailless Slowpoke at `6, 2` -> +`TrainerGruntM1` at `5, 2` (faces down, sight 1). + +The B2F ladder at `7, 11` sits in a chamber walled off along row 7 and reachable +only from `7, 13`, whose only non-wall neighbour outside the chamber is the water +at `7, 14`. **B2F is Surf-only**; the water is also the only route down from the +land at `2, 11`. + +**Scripts of interest** + +- `SlowpokeWellB1FKurtScript` - `jumptextfaceplayer SlowpokeWellB1FKurtText`. + Flavour only; Kurt does not gate anything here. +- `TrainerGruntM29` - `trainer GRUNTM, GRUNTM_29, EVENT_BEAT_ROCKET_GRUNTM_29, GruntM29SeenText, GruntM29BeatenText, 0, .Script`; + `.Script` is `endifjustbattled` + `GruntM29AfterBattleText`. Talking again is + the "they're chopping off Slowpoke tails" line the walkthrough mentions. +- `TrainerGruntF1` - `trainer GRUNTF, GRUNTF_1, EVENT_BEAT_ROCKET_GRUNTF_1, ...`, + same `endifjustbattled` shape. +- `TrainerGruntM2` - `trainer GRUNTM, GRUNTM_2, EVENT_BEAT_ROCKET_GRUNTM_2, ...`, + same shape. +- `TrainerGruntM1` - **the section's payload.** + `trainer GRUNTM, GRUNTM_1, EVENT_BEAT_ROCKET_GRUNTM_1, GruntM1SeenText, GruntM1BeatenText, 0, .Script`. + `.Script` has no `endifjustbattled`, so it runs immediately after the win: + `writetext TrainerGruntM1WhenTalkText`, `special FadeOutToBlack`, + `special ReloadSpritesNoPalettes`, + `disappear` on consts 2/3/4/5 (all four share `EVENT_SLOWPOKE_WELL_ROCKETS`, + so this is the flag that clears the Gym door in town), `pause 15`, + `special FadeInFromBlack`, `disappear SLOWPOKEWELLB1F_KURT`, + `moveobject SLOWPOKEWELLB1F_KURT, 11, 6`, `appear SLOWPOKEWELLB1F_KURT`, + `applymovement SLOWPOKEWELLB1F_KURT, KurtSlowpokeWellVictoryMovementData` + (4x `step LEFT`, `step UP`, 3x `step_sleep 8`, `step LEFT`, 2x `step UP`, + 3x `step_sleep 8`, `turn_head LEFT`), `turnobject PLAYER, RIGHT`, + `writetext KurtLeaveSlowpokeWellText`, then the flag block: + ``` + setevent EVENT_CLEARED_SLOWPOKE_WELL + variablesprite SPRITE_AZALEA_ROCKET, SPRITE_RIVAL + setmapscene AZALEA_TOWN, SCENE_AZALEATOWN_RIVAL_BATTLE + clearevent EVENT_ILEX_FOREST_APPRENTICE + clearevent EVENT_ILEX_FOREST_FARFETCHD_1 + setevent EVENT_CHARCOAL_KILN_FARFETCH_D + setevent EVENT_CHARCOAL_KILN_APPRENTICE + setevent EVENT_SLOWPOKE_WELL_SLOWPOKES + setevent EVENT_SLOWPOKE_WELL_KURT + clearevent EVENT_AZALEA_TOWN_SLOWPOKES + clearevent EVENT_KURTS_HOUSE_SLOWPOKE + clearevent EVENT_KURTS_HOUSE_KURT_1 + ``` + then `special FadeOutToWhite`, `special HealParty`, `pause 15`, + `warp KURTS_HOUSE, 3, 3`, `end`. Note the free party heal, and note that the + Ilex Forest Farfetch'd quest is armed here, not in Ilex Forest. +- `SlowpokeWellB1FSlowpokeWithMailScript` - `faceplayer`, `cry SLOWPOKE`, + `SlowpokeWellB1FSlowpokeWithMailText`, `yesorno` / `iftrue .ReadMail` -> + `SlowpokeWellB1FSlowpokeMailText`. No flag, no item, purely optional. +- `SlowpokeWellB1FSuperPotion` - `itemball SUPER_POTION`, guarded by + `EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION`. +- `SlowpokeWellB1FBoulder` - `jumpstd StrengthBoulderScript`; needs Strength, + which is not obtainable in this section. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_ROCKET_GRUNTM_29` | `constants/event_flags.asm:792` | `TrainerGruntM29` | grunt 1 beaten | +| `EVENT_BEAT_ROCKET_GRUNTF_1` | `constants/event_flags.asm:796` | `TrainerGruntF1` | grunt 2 beaten | +| `EVENT_BEAT_ROCKET_GRUNTM_2` | `constants/event_flags.asm:765` | `TrainerGruntM2` | grunt 3 beaten | +| `EVENT_BEAT_ROCKET_GRUNTM_1` | `constants/event_flags.asm:764` | `TrainerGruntM1` | grunt 4 (leader) beaten | +| `EVENT_SLOWPOKE_WELL_ROCKETS` | `constants/event_flags.asm:1182` | set via the four `disappear` calls | hides the well grunts **and** the Azalea Gym blocker | +| `EVENT_SLOWPOKE_WELL_SLOWPOKES` | `constants/event_flags.asm:1181` | `setevent` in `TrainerGruntM1.Script` | hides the two well Slowpoke | +| `EVENT_SLOWPOKE_WELL_KURT` | `constants/event_flags.asm:1250` | `setevent` in `TrainerGruntM1.Script` | hides well Kurt | +| `EVENT_CLEARED_SLOWPOKE_WELL` | `constants/event_flags.asm:52` | set here | section milestone | +| `EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION` | `constants/event_flags.asm:1027` | itemball | Super Potion taken | +| `EVENT_ILEX_FOREST_APPRENTICE` / `EVENT_ILEX_FOREST_FARFETCHD_1` | `constants/event_flags.asm:1173` / `:1163` | `clearevent` here | Farfetch'd quest NPCs become visible in Ilex Forest (section 05) | +| `EVENT_CHARCOAL_KILN_FARFETCH_D` / `EVENT_CHARCOAL_KILN_APPRENTICE` | `constants/event_flags.asm:1175` / `:1176` | `setevent` here | they leave the Charcoal Kiln | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `SUPER_POTION` | ball at `10, 3` | `SlowpokeWellB1FSuperPotion` | `EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION` | + +**Trainers** + +Money = `base reward` x level of the last enemy mon x 4. The x4 is +`engine/battle/read_trainer_party.asm:300` `ComputeTrainerReward` +(`base * wCurPartyLevel`) followed by the `ld c, 4` add loop and two +unconditional `call .DoubleReward` at `engine/battle/core.asm:2340-2361`. +`GRUNTM` / `GRUNTF` base reward is 10 (`data/trainers/attributes.asm:185`, `:395`). + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `GRUNTM_29` | `GRUNTM` | `constants/trainer_constants.asm:289` | `GruntMGroup` "GRUNT@", `TRAINERTYPE_NORMAL`: L9 `RATTATA`, L9 `RATTATA` | `TrainerGruntM29` | no (360 = 10x9x4) | +| `GRUNTF_1` | `GRUNTF` | `constants/trainer_constants.asm:648` | `GruntFGroup` "GRUNT@", `TRAINERTYPE_NORMAL`: L9 `ZUBAT`, L11 `EKANS` | `TrainerGruntF1` | no (440 = 10x11x4) | +| `GRUNTM_2` | `GRUNTM` | `constants/trainer_constants.asm:262` | `GruntMGroup` "GRUNT@", `TRAINERTYPE_NORMAL`: L7 `RATTATA`, L9 `ZUBAT`, L9 `ZUBAT` | `TrainerGruntM2` | no (360 = 10x9x4) | +| `GRUNTM_1` | `GRUNTM` | `constants/trainer_constants.asm:261` | `GruntMGroup` "GRUNT@", `TRAINERTYPE_NORMAL`: L14 `KOFFING` | `TrainerGruntM1` | no (560 = 10x14x4) | + +**Wild encounters** + +- Grass/cave, `data/wild/johto_grass.asm:582` `def_grass_wildmons SLOWPOKE_WELL_B1F`, + `db 2 percent, 2 percent, 2 percent` - **identical morn/day/nite lists**: + L5 `ZUBAT`, L6 `ZUBAT`, L7 `ZUBAT`, L6 `SLOWPOKE`, L8 `ZUBAT`, L8 `SLOWPOKE`, + L8 `SLOWPOKE`. +- Water, `data/wild/johto_water.asm:33` `def_water_wildmons SLOWPOKE_WELL_B1F`, + `db 2 percent`: L15 `SLOWPOKE`, L20 `SLOWPOKE`, L10 `SLOWPOKE`. +- Fishing: `FISHGROUP_LAKE` (`data/wild/fish.asm:14`) -> + `.Lake_Old` L10 `MAGIKARP`/`MAGIKARP`/`GOLDEEN`, `.Lake_Good` L20 + `MAGIKARP`/`GOLDEEN`/`GOLDEEN` + `time_group 4`, `.Lake_Super` L40 + `GOLDEEN`/`time_group 5`/`MAGIKARP`/`SEAKING`. +- Headbutt: `data/wild/treemon_maps.asm:47` -> `treemon_map SLOWPOKE_WELL_B1F, TREEMON_SET_ROCK` + (rock smash set; no headbutt trees underground). + +--- + +### MAP_SLOWPOKE_WELL_B2F (off-route, Surf-gated) + +- Script: `maps/SlowpokeWellB2F.asm` +- Blocks: `maps/SlowpokeWellB2F.blk` +- Header: `data/maps/maps.asm:111` -> `TILESET_CAVE`, `CAVE`, + `LANDMARK_SLOWPOKE_WELL`, `MUSIC_DARK_CAVE`, phone `TRUE`, `PALETTE_NITE`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:98` -> `map_const SLOWPOKE_WELL_B2F, 10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 11 | `SLOWPOKE_WELL_B1F` | 2 | + +**Coord events** / **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `SLOWPOKEWELLB2F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 5 | 4 | `WANDER` 1,2 | `OBJECTTYPE_SCRIPT`, 1 | `SlowpokeWellB2FGymGuideScript` | -1 | +| 3 `SLOWPOKEWELLB2F_POKE_BALL` | `SPRITE_POKE_BALL` | 15 | 5 | `STILL` | `OBJECTTYPE_ITEMBALL`, 0 | `SlowpokeWellB2FTMRainDance` (`itemball TM_RAIN_DANCE`) | `EVENT_SLOWPOKE_WELL_B2F_TM_RAIN_DANCE` | + +`SlowpokeWellB2FGymGuideScript`: `checkevent EVENT_GOT_KINGS_ROCK_IN_SLOWPOKE_WELL` +/ `iftrue .GotKingsRock`, else `verbosegiveitem KINGS_ROCK`, `iffalse .NoRoom`, +`setevent EVENT_GOT_KINGS_ROCK_IN_SLOWPOKE_WELL` +(`constants/event_flags.asm:124`). + +Wild: `data/wild/johto_grass.asm:610` L19-23 `ZUBAT`/`SLOWPOKE`/`GOLBAT` +(same three time slots); `data/wild/johto_water.asm:40` is `4 percent` in Gold +(`IF DEF(_GOLD)`) / `2 percent` in Silver, L15 `SLOWPOKE`, L20 `SLOWPOKE`, +L20 `SLOWBRO`. + +--- + +### MAP_AZALEA_GYM + +- Script: `maps/AzaleaGym.asm` +- Blocks: `maps/AzaleaGym.blk` +- Header: `data/maps/maps.asm:230` -> `TILESET_ELITE_FOUR_ROOM`, `INDOOR`, + `LANDMARK_AZALEA_TOWN`, `MUSIC_GYM`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:211` -> `map_const AZALEA_GYM, 5, 8` + (10x16 walk cells) +- Map scripts: `def_scene_scripts` and `def_callbacks` are both empty + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 15 | `AZALEA_TOWN` | 5 | +| 2 | 5 | 15 | `AZALEA_TOWN` | 5 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 13 | `BGEVENT_READ` | `AzaleaGymStatue` | +| 6 | 13 | `BGEVENT_READ` | `AzaleaGymStatue` | + +`AzaleaGymStatue`: `checkflag ENGINE_HIVEBADGE` / `iftrue .Beaten`; +unbeaten -> `jumpstd GymStatue1Script`, beaten -> +`gettrainername STRING_BUFFER_4, BUGSY, BUGSY1` + `jumpstd GymStatue2Script`. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement / pal | type, sight | script label | event flag | +|---|---|---|---|---|---|---|---| +| 2 `AZALEAGYM_BUGSY` | `SPRITE_BUGSY` | 5 | 7 | `SPINRANDOM_SLOW` / `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaGymBugsyScript` | -1 | +| 3 `AZALEAGYM_BUG_CATCHER1` | `SPRITE_BUG_CATCHER` | 5 | 3 | `SPINRANDOM_FAST` / `PAL_NPC_BROWN` | `OBJECTTYPE_TRAINER`, 2 | `TrainerBugCatcherBenny` | -1 | +| 4 `AZALEAGYM_BUG_CATCHER2` | `SPRITE_BUG_CATCHER` | 8 | 8 | `STANDING_DOWN` / `PAL_NPC_BROWN` | `OBJECTTYPE_TRAINER`, 3 | `TrainerBugCatcherAl` | -1 | +| 5 `AZALEAGYM_BUG_CATCHER3` | `SPRITE_BUG_CATCHER` | 0 | 2 | `STANDING_DOWN` / `PAL_NPC_BROWN` | `OBJECTTYPE_TRAINER`, 3 | `TrainerBugCatcherJosh` | -1 | +| 6 `AZALEAGYM_TWIN1` | `SPRITE_TWIN` | 4 | 10 | `STANDING_DOWN` / `PAL_NPC_RED` | `OBJECTTYPE_TRAINER`, 1 | `TrainerTwinsAmyandmay1` | -1 | +| 7 `AZALEAGYM_TWIN2` | `SPRITE_TWIN` | 5 | 10 | `STANDING_DOWN` / `PAL_NPC_RED` | `OBJECTTYPE_TRAINER`, 1 | `TrainerTwinsAmyandmay2` | -1 | +| 8 `AZALEAGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 13 | `STANDING_DOWN` / `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT`, 0 | `AzaleaGymGuideScript` | -1 | + +None of the gym objects carry an event flag, so they are always drawn; being +"beaten" is entirely the `trainer` macro's own `EVENT_BEAT_*` check. + +**Derived walkability** (`maps/AzaleaGym.blk` + `data/tilesets/elite_four_room_collision.asm`) + +``` + 0123456789 + 0 ########## + 1 ########## + 2 .......... + 3 .......... + 4 ..##..##.. + 5 ..#....#.. + 6 .#..##..#. + 7 .#......#. + 8 ..#....#.. + 9 ..##..##.. + 10 .......... + 11 .......... + 12 ##.#..#.## + 13 ##.#..#.## + 14 ###.....## + 15 ###.WW..## +``` + +It is a spiral. Entry `4, 15` / `5, 15` -> `4, 14`..`7, 14` -> the two +entry stalks at columns 4-5 and 7 -> the row 10/11 ring -> up either side +column (0-1 or 8-9) -> row 2/3 -> in at `4, 4` / `5, 4` -> `4, 5`..`6, 5` -> +`3, 6` -> `3, 7` -> Bugsy at `5, 7`. + +Derived from the grid plus the sight fields: only the **twins** sit on the +straight-line path (`4, 11` and `5, 11` are their sight cells), and even they +are dodgeable by taking the column-7 stalk from `7, 14` up to `7, 11`. Josh's +sight covers `0, 3`-`0, 5` so the left column is his; Al's covers `8, 9`-`8, 11` +so the column-8 approach is his; column 9 is clear all the way from row 11 to +row 3. Benny spins (`SPINRANDOM_FAST`, sight 2) at `5, 3`, so crossing row 2/3 +near column 5 is a coin flip rather than a guarantee. + +**Scripts of interest** + +- `AzaleaGymBugsyScript` - + `faceplayer`, `opentext`, `checkevent EVENT_BEAT_BUGSY` / `iftrue .FightDone`; + otherwise `BugsyText_INeverLose`, + `winlosstext BugsyText_ResearchIncomplete, 0` (no loss text - losing is a + whiteout), `loadtrainer BUGSY, BUGSY1`, `startbattle`, `reloadmapafterbattle`, + `setevent EVENT_BEAT_BUGSY`, `Text_ReceivedHiveBadge`, + `playsound SFX_GET_BADGE`, `waitsfx`, **`setflag ENGINE_HIVEBADGE`**, + `readvar VAR_BADGES`, `scall AzaleaGymActivateRockets`. + `.FightDone`: `checkevent EVENT_GOT_TM49_FURY_CUTTER` / `iftrue .GotFuryCutter`, + then - and this is the bot-relevant part - + ``` + setevent EVENT_BEAT_TWINS_AMY_AND_MAY + setevent EVENT_BEAT_BUG_CATCHER_BENNY + setevent EVENT_BEAT_BUG_CATCHER_AL + setevent EVENT_BEAT_BUG_CATCHER_JOSH + ``` + so beating Bugsy retroactively marks the five gym trainers beaten. Then + `BugsyText_HiveBadgeSpeech`, `promptbutton`, + `verbosegiveitem TM_FURY_CUTTER`, `iffalse .NoRoomForFuryCutter`, + `setevent EVENT_GOT_TM49_FURY_CUTTER`, `BugsyText_FuryCutterSpeech`. +- `AzaleaGymActivateRockets` - `ifequal 7, .RadioTowerRockets`, + `ifequal 6, .GoldenrodRockets`, `end`. `VAR_BADGES` + (`constants/script_constants.asm:55`, id 7) is the badge count, which is 2 + right after HIVEBADGE, so **this scall is a no-op at Bugsy**; it is the shared + gym-clear hook that only fires at the 6th and 7th badge. +- `TrainerTwinsAmyandmay1` / `TrainerTwinsAmyandmay2` - both use + `EVENT_BEAT_TWINS_AMY_AND_MAY`, so you fight **one** twin, whichever spots you + first, and the other drops to her after-battle line. +- `TrainerBugCatcherBenny` / `...Al` / `...Josh` - plain + `trainer BUG_CATCHER, <id>, EVENT_BEAT_BUG_CATCHER_<id>, ...` with + `endifjustbattled` after-scripts. +- `AzaleaGymGuideScript` - `checkevent EVENT_BEAT_BUGSY`; the pre-battle text is + the "bug Pokemon don't like fire, flying-type moves are super-effective" hint. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BUGSY` | `constants/event_flags.asm:707` | `AzaleaGymBugsyScript` | leader beaten | +| `ENGINE_HIVEBADGE` | `constants/engine_flags.asm:39` (`wJohtoBadges`, `data/events/engine_flags.asm:47`) | `setflag` here; read by `CutFunction.CheckAble`, `TryCutOW`, `AzaleaGymStatue`, `engine/battle/core.asm:6566` (obedience) | **the badge that enables field Cut and L30 obedience** | +| `EVENT_GOT_TM49_FURY_CUTTER` | `constants/event_flags.asm:15` | `AzaleaGymBugsyScript` | TM49 already handed over | +| `EVENT_BEAT_TWINS_AMY_AND_MAY` | `constants/event_flags.asm:608` | both twin objects; force-set by `.FightDone` | one flag, two objects | +| `EVENT_BEAT_BUG_CATCHER_BENNY` | `constants/event_flags.asm:843` | `TrainerBugCatcherBenny`; force-set by `.FightDone` | | +| `EVENT_BEAT_BUG_CATCHER_AL` | `constants/event_flags.asm:844` | `TrainerBugCatcherAl`; force-set by `.FightDone` | | +| `EVENT_BEAT_BUG_CATCHER_JOSH` | `constants/event_flags.asm:845` | `TrainerBugCatcherJosh`; force-set by `.FightDone` | | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_FURY_CUTTER` (TM49) | Bugsy hands it over after the badge speech | `AzaleaGymBugsyScript.FightDone` -> `verbosegiveitem TM_FURY_CUTTER` | `EVENT_GOT_TM49_FURY_CUTTER` | + +**Trainers** + +Base rewards: `BUG_CATCHER` 4 (`data/trainers/attributes.asm:215`), +`TWINS` 5 (`:365`), `BUGSY` 25 (`:17`). Money = base x last-mon level x 4. + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `AMYANDMAY1` | `TWINS` | `constants/trainer_constants.asm:621` | `TwinsGroup` TWINS(1) "AMY & MAY@", `TRAINERTYPE_NORMAL`: L10 `SPINARAK`, L10 `LEDYBA` | `TrainerTwinsAmyandmay1` | no (200 = 5x10x4) | +| `AMYANDMAY2` | `TWINS` | `constants/trainer_constants.asm:624` | `TwinsGroup` TWINS(4) "AMY & MAY@", `TRAINERTYPE_NORMAL`: L10 `LEDYBA`, L10 `SPINARAK` | `TrainerTwinsAmyandmay2` | no (200) | +| `JOSH` | `BUG_CATCHER` | `constants/trainer_constants.asm:319` | `BugCatcherGroup` BUG_CATCHER(7) "JOSH@", `TRAINERTYPE_NORMAL`: L13 `PARAS` | `TrainerBugCatcherJosh` | no (208 = 4x13x4) | +| `BENNY` | `BUG_CATCHER` | `constants/trainer_constants.asm:317` | `BugCatcherGroup` BUG_CATCHER(5) "BENNY@", `TRAINERTYPE_NORMAL`: L7 `WEEDLE`, L9 `KAKUNA`, L12 `BEEDRILL` | `TrainerBugCatcherBenny` | no (192 = 4x12x4) | +| `AL` | `BUG_CATCHER` | `constants/trainer_constants.asm:318` | `BugCatcherGroup` BUG_CATCHER(6) "AL@", `TRAINERTYPE_NORMAL`: L12 `CATERPIE`, L12 `WEEDLE` | `TrainerBugCatcherAl` | no (192 = 4x12x4) | +| `BUGSY1` | `BUGSY` (class 3, `constants/trainer_constants.asm:33`) | `:34` | `BugsyGroup` "BUGSY@", **`TRAINERTYPE_MOVES`**: L14 `METAPOD` (`TACKLE`, `STRING_SHOT`, `HARDEN`), L14 `KAKUNA` (`POISON_STING`, `STRING_SHOT`, `HARDEN`), L16 `SCYTHER` (`QUICK_ATTACK`, `LEER`, `FURY_CUTTER`) | `AzaleaGymBugsyScript` | no (1600 = 25x16x4) | + +Bugsy's AI/items row (`data/trainers/attributes.asm:17`): `NO_ITEM, NO_ITEM`, +`AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | AI_CAUTIOUS | AI_STATUS | AI_RISKY`, +`CONTEXT_USE | SWITCH_SOMETIMES`. Bug Catchers are `AI_BASIC | AI_SETUP | AI_STATUS`; +Twins are `NO_AI` with `SWITCH_OFTEN`. + +**Wild encounters** - none (indoor). + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Rocket standing on the Slowpoke Well approach | `maps/AzaleaTown.asm` object const 2 at `31, 9`, flag `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET`. `31, 9` is the only walkable neighbour of `31, 8`, which is the only walkable neighbour of the ladder at `31, 7` (derived from `maps/AzaleaTown.blk` + `data/tilesets/johto_modern_collision.asm`) | talk to Kurt once | `Kurt1` first-visit arm runs `setevent EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET`, which hides him (`Script_disappear` semantics, `engine/overworld/scripting.asm:887`) | +| Rocket standing on the Azalea Gym door | `maps/AzaleaTown.asm` object const 12 at `10, 16`, flag `EVENT_SLOWPOKE_WELL_ROCKETS`. `10, 16` is the only walkable neighbour of the gym warp at `10, 15` | clear Slowpoke Well | the four `disappear SLOWPOKEWELLB1F_ROCKET*` calls in `maps/SlowpokeWellB1F.asm` `TrainerGruntM1.Script` set that shared flag | +| Slowpoke Well B2F (King's Rock, TM Rain Dance) | `maps/SlowpokeWellB1F.asm` warp 2 at `7, 11`; the chamber's only outside neighbour is water at `7, 14` (derived from `maps/SlowpokeWellB1F.blk` + `data/tilesets/cave_collision.asm` + `data/collision/collision_permissions.asm`) | Surf (`ENGINE_FOGBADGE` + HM03) | out of section - Fog Badge is Morty | +| Boulder at Slowpoke Well `3, 2` | `SlowpokeWellB1FBoulder` -> `jumpstd StrengthBoulderScript` | Strength | out of section; nothing behind it is required | +| Field Cut (leaving Azalea west for Ilex Forest) | `engine/events/overworld.asm` `CutFunction.CheckAble` (`ld de, ENGINE_HIVEBADGE` / `call CheckBadge` / `jr c, .nohivebadge`, sym `03:47e1`) and `TryCutOW` (`CheckPartyMove` with `CUT`, then `ld de, ENGINE_HIVEBADGE` / `CheckEngineFlag`, sym `03:5193`) | HIVEBADGE **and** a party member that knows CUT | HIVEBADGE from `AzaleaGymBugsyScript`; HM01 itself comes from the Charcoal Kiln / Ilex Forest chain in section 05 | +| Pokemon obedience above L30 | `engine/battle/core.asm:6566` masks `(1 << ZEPHYRBADGE) | (1 << HIVEBADGE) | ...` | HIVEBADGE | same | +| Bugsy himself | `AzaleaGymBugsyScript` has no `checkevent` guard before the battle | nothing - the five gym trainers are optional | `.FightDone` force-sets all four of their beat events afterwards | + +## 4. Bot checklist + +Coordinates are `x, y` as written in the asm. "warp N" means the Nth +`warp_event` row of the source map. + +1. `MAP_AZALEA_TOWN`, walk in from the Route 33 east connection. Post: engine + flag `ENGINE_FLYPOINT_AZALEA` set by `AzaleaTownFlypointCallback` + (`MAPCALLBACK_NEWMAP`). No input needed. +2. `MAP_AZALEA_TOWN`, walk to `9, 5` and step on it -> warp 4 -> + `MAP_KURTS_HOUSE` `3, 7`. Pre: none. +3. `MAP_KURTS_HOUSE`, walk to `3, 3`, face UP, press A on object const 2 + (`KURTSHOUSE_KURT1`, `3, 2`). Pre: `EVENT_CLEARED_SLOWPOKE_WELL` clear and + `EVENT_KURT_GAVE_YOU_LURE_BALL` clear. Post: + `EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET` set, `EVENT_KURTS_HOUSE_KURT_1` set + (Kurt walks out). If the player is facing UP when the script starts, Kurt + takes the `.RunAround` movement instead - same result, one extra step. +4. `MAP_KURTS_HOUSE`, step on `3, 7` or `4, 7` -> `MAP_AZALEA_TOWN` warp 4 (`9, 5`). +5. Optional heal: `MAP_AZALEA_TOWN` `15, 9` -> `MAP_AZALEA_POKECENTER_1F`; + talk to object const 2 at `3, 1` (`jumpstd PokecenterNurseScript`); leave via + `3, 7`. +6. `MAP_AZALEA_TOWN`, walk east along row 10 to `31, 10`, north to `31, 9` + (now empty), `31, 8`, then step on `31, 7` -> warp 6 -> `MAP_SLOWPOKE_WELL_B1F` + `17, 15`. Optional: from `31, 7` face UP and press A for the hidden + `FULL_HEAL` at `31, 6` (post: `EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL`). +7. `MAP_SLOWPOKE_WELL_B1F`, optional A on const 8 (`16, 14`, Kurt). +8. Walk north along column 15 to `15, 8`. Trigger: const 2 `TrainerGruntM29` + (sight 1, faces right). Battle `GRUNTM` / `GRUNTM_29` (L9 Rattata x2). + Post: `EVENT_BEAT_ROCKET_GRUNTM_29`. Optional A again for the after-battle text. +9. Continue north column 14 to `14, 4`, then west into the top room. Trigger: + const 5 `TrainerGruntF1` at `13, 2` (sight 2, faces down) from `13, 3` or + `13, 4`. Battle `GRUNTF` / `GRUNTF_1` (L9 Zubat, L11 Ekans). Post: + `EVENT_BEAT_ROCKET_GRUNTF_1`. +10. Walk to `11, 3` -> `10, 3`, press A on const 10 -> `SUPER_POTION`. Post: + `EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION`. +11. Go to `11, 4` -> `11, 5` -> `11, 6`, then west along row 6. Trigger: const 4 + `TrainerGruntM2` at `5, 6` (sight 2, faces right) from `7, 6` or `6, 6`. + Battle `GRUNTM` / `GRUNTM_2` (L7 Rattata, L9 Zubat, L9 Zubat). Post: + `EVENT_BEAT_ROCKET_GRUNTM_2`. +12. Optional: `7, 5` face UP, A on const 6 at `7, 4` (Slowpoke with mail, + `yesorno` -> read). No flag. +13. Walk to `5, 3`. Trigger: const 3 `TrainerGruntM1` at `5, 2` (sight 1, faces + down). Battle `GRUNTM` / `GRUNTM_1` (L14 Koffing). Post: + `EVENT_BEAT_ROCKET_GRUNTM_1`, then `.Script` runs with no `endifjustbattled`, + so the whole cutscene plays automatically and ends with + `special HealParty` + `warp KURTS_HOUSE, 3, 3`. Post-flags to assert: + `EVENT_CLEARED_SLOWPOKE_WELL`, `EVENT_SLOWPOKE_WELL_ROCKETS`, + `EVENT_SLOWPOKE_WELL_SLOWPOKES`, `EVENT_SLOWPOKE_WELL_KURT`, + `EVENT_CHARCOAL_KILN_FARFETCH_D`, `EVENT_CHARCOAL_KILN_APPRENTICE`; + cleared: `EVENT_AZALEA_TOWN_SLOWPOKES`, `EVENT_KURTS_HOUSE_SLOWPOKE`, + `EVENT_KURTS_HOUSE_KURT_1`, `EVENT_ILEX_FOREST_APPRENTICE`, + `EVENT_ILEX_FOREST_FARFETCHD_1`; scene: `AZALEA_TOWN` -> `SCENE_AZALEATOWN_RIVAL_BATTLE`. +14. `MAP_KURTS_HOUSE` at `3, 3`, face UP, A on const 2. Pre: + `EVENT_CLEARED_SLOWPOKE_WELL` set, `EVENT_KURT_GAVE_YOU_LURE_BALL` clear. + Post: `LURE_BALL` in bag, `EVENT_KURT_GAVE_YOU_LURE_BALL` set. The script + then falls straight into the apricorn prompt - answer no unless you want to + hand over the White Apricorn you have not picked yet. +15. Leave via `3, 7`; in `MAP_AZALEA_TOWN` walk to `8, 3`, face UP, A on const 10 + at `8, 2` -> `WHT_APRICORN` (daily). Optional: return to Kurt and give it + (`special SelectApricornForKurt`, post: `EVENT_GAVE_KURT_WHT_APRICORN` + + `ENGINE_KURT_MAKING_BALLS`), then come back a real in-game day later for + `FAST_BALL`. +16. Optional heal at the Pokecenter (`15, 9`). +17. `MAP_AZALEA_TOWN`, walk to `10, 17` -> `10, 16` (now empty) -> step on + `10, 15` -> warp 5 -> `MAP_AZALEA_GYM` `4, 15`. +18. `MAP_AZALEA_GYM`: optional A on const 8 at `7, 13` for the type hint. + Walk `4, 14` -> `4, 13` -> `4, 12` -> `4, 11`. Trigger: const 6 + `TrainerTwinsAmyandmay1` at `4, 10` (sight 1). Battle `TWINS` / `AMYANDMAY1` + (L10 Spinarak, L10 Ledyba). Post: `EVENT_BEAT_TWINS_AMY_AND_MAY`, which also + retires const 7. +19. West along row 11 to `0, 11`, north up column 0 to `0, 5`. Trigger: const 5 + `TrainerBugCatcherJosh` at `0, 2` (sight 3, faces down). Battle + `BUG_CATCHER` / `JOSH` (L13 Paras). Post: `EVENT_BEAT_BUG_CATCHER_JOSH`. +20. North to `0, 3`, east along row 3 toward `4, 3`. Trigger: const 3 + `TrainerBugCatcherBenny` at `5, 3` (sight 2, spins). Battle + `BUG_CATCHER` / `BENNY` (L7 Weedle, L9 Kakuna, L12 Beedrill). Post: + `EVENT_BEAT_BUG_CATCHER_BENNY`. +21. East along row 2/3 to column 8-9, south to `8, 11`. Trigger: const 4 + `TrainerBugCatcherAl` at `8, 8` (sight 3, faces down). Battle + `BUG_CATCHER` / `AL` (L12 Caterpie, L12 Weedle). Post: + `EVENT_BEAT_BUG_CATCHER_AL`. +22. Route into the middle: `4, 3` or `5, 3` row -> `4, 4` -> `4, 5` -> `3, 5` -> + `3, 6` -> `3, 7` -> `4, 7`, face RIGHT, A on const 2 at `5, 7`. Pre: + `EVENT_BEAT_BUGSY` clear. Battle `BUGSY` / `BUGSY1` (L14 Metapod, L14 Kakuna, + L16 Scyther). Post: `EVENT_BEAT_BUGSY`, `ENGINE_HIVEBADGE`, then the same + conversation continues and yields `TM_FURY_CUTTER` + + `EVENT_GOT_TM49_FURY_CUTTER` and force-sets the four gym-trainer events. + A bot that wants the badge fast can skip steps 19-21 entirely. +23. Leave via `4, 15` / `5, 15`. Next section starts when you walk west and hit + `coord_event 5, 10` / `5, 11`. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map data for all six maps (blocks, connections, warps, coord/bg/object events, `scriptKey`) | `src/import/RomExtractorGen2.lua` (`self:write("maps", out)`, line 987), consumed by `src/world/gen2/Map.lua` | implemented - `docs/gold-phase1.md` states all 368 maps extract | +| Collision / walkability (`COLL_*` quads, block -> cell) | `src/world/gen2/Map.lua` `Map:cellCollision`, `src/world/gen2/Permissions.lua` | implemented (quad order `ly*2 + lx` matches `gfx/tilesets.asm` `tilecoll`) | +| Warps between these maps | `src/world/gen2/World.lua` + `Map:warpAt`; driver `tests/drivers/gold_warp_scene.lua` | implemented | +| Map callbacks (`MAPCALLBACK_NEWMAP` flypoint, `MAPCALLBACK_OBJECTS` Kurt swap) | `src/world/gen2/World.lua`, driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| Script VM opcodes used here (`checkevent`/`setevent`/`clearevent`, `checkflag`/`setflag`, `readvar`, `scall`, `jumpstd`, `special`, `warp`, `setscene`/`setmapscene`, `loadtrainer`/`startbattle`/`winlosstext`/`endifjustbattled`, `applymovement`, `moveobject`, `showemote`, `variablesprite`, `verbosegiveitem`, `yesorno`, `checkitem`, `promptbutton`, `fruittree`) | `src/script/gen2/Opcodes.lua`, `src/script/gen2/Vm.lua` | implemented - every opcode in this section's scripts has a row and a VM arm | +| Specials used here (`FadeOutToBlack`, `FadeInFromBlack`, `FadeOutToWhite`, `ReloadSpritesNoPalettes`, `HealParty`, `SelectApricornForKurt`, `FadeOutMusic`/`RestartMapMusic`) | `src/script/gen2/Specials.lua` | implemented (spot-checked `FadeOutToBlack`, `ReloadSpritesNoPalettes`, `HealParty`, `SelectApricornForKurt`) | +| Trainer sight lines / approach walk (the four grunts, the five gym trainers) | `src/world/gen2/Trainers.lua` (`Trainers.sees`, `Trainers.approach`), wired at `src/world/gen2/World.lua:5212-5264`; driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| Trainer parties + class attributes | `src/import/RomExtractorGen2.lua` (trainers), `src/world/gen2/Trainers.lua`, `src/battle/gen2/Battle.lua` (`BUGSY` in the gym-leader set, line 85) | implemented | +| Prize money (base x level x 4, Mom's split) | `src/battle/gen2/Prize.lua` | implemented, and it ports the `ld c, 4` loop and `.DoubleReward` faithfully | +| Badge storage + `VAR_BADGES` count | `src/core/gen2/Save.lua` (`player.badges`, `player.kantoBadges`), `src/world/gen2/World.lua:1241-1244`, `World:engineFlags` (`:1304`) | implemented | +| Cut gate on `ENGINE_HIVEBADGE` | `src/world/gen2/FieldMoves.lua` (`FieldMoves.BADGE.CUT = "HIVE"`, `cutFromMenu`, the overworld ask path) | implemented | +| Fly point `ENGINE_FLYPOINT_AZALEA` | `src/world/gen2/FieldMoves.lua:347` (`LANDMARK_AZALEA_TOWN` / `SPAWN_AZALEA` / flag 67) | implemented | +| Hidden item at `31, 6` (`BGEVENT_ITEM`) | `src/world/gen2/HiddenItems.lua` | implemented (module exists and the extractor carries `hiddenItem = { item, event }`); not exercised by any Azalea driver | +| Kurt / apricorn conversation, `ENGINE_KURT_MAKING_BALLS` day wait, Lure Ball, Fast Ball | `src/core/gen2/Apricorns.lua` | implemented at the model level; the file is written against `maps/KurtsHouse.asm` directly and lists `FRUITTREE_AZALEA_TOWN` -> `WHT_APRICORN` at index `0x14` | +| Item balls (`OBJECTTYPE_ITEMBALL`: Super Potion at B1F `10, 3`, TM Rain Dance at B2F `15, 5`) | extracted by `src/import/RomExtractorGen2.lua:2968`; **no runtime handler found in `src/world/gen2/World.lua`** | **missing** - the object data is there, nothing picks it up | +| `fruittree` at runtime (White Apricorn tree object) | opcode row exists (`src/script/gen2/Opcodes.lua`, `[0x9a]`) and `Vm.lua` references it; the daily-tree store is described in `src/core/gen2/Apricorns.lua` (`save.fruitTrees`) | partial - modelled, not verified end to end in a driver | +| Wild encounters for Slowpoke Well (grass 3-slot time split, water, fishing) | `src/import/RomExtractorGen2.lua` (Encounters), `src/battle/gen2/Encounter.lua` | implemented per `docs/gold-phase1.md`; not verified for this map specifically | +| A driver that walks any part of this section | none - `tests/drivers/gold_*.lua` has nothing matching azalea / slowpoke / kurt / bugsy | **missing** | + +## 6. Unresolved / verify by hand + +- **Bug Catcher Al's prize.** The walkthrough says 200G. The asm gives + 4 (`data/trainers/attributes.asm:215`) x 12 (last mon, L12 Weedle) x 4 = **192G**. + Every other figure in the section matches the formula exactly + (360 / 440 / 360 / 560 / 200 / 208 / 192 / 1600), so this looks like a + walkthrough typo, not a special case. +- **Bugsy's gender.** The walkthrough says "Bugsy and her apprentices" and + "she may have the Bug Pokemon". `AzaleaGymGuideText` in `maps/AzaleaGym.asm` + says "BUGSY's young, but **his** knowledge of bug #MON is for real". The asm + wins. +- **"Liz the Picnicker calls about Moo Moo Milk."** `LizPhoneCallerScript` + (`engine/phone/scripts/trainers.asm:234-254`) only does + `PhoneScript_GreetPhone_Female`, a `checkflag ENGINE_FLYPOINT_GOLDENROD` + rematch gate, `PhoneScript_Random2` and `Phone_GenericCall_Female` / + `Phone_CheckIfUnseenRare_Female`. There is no Moo Moo Milk text in her script, + and no `MOOMOO` string turned up anywhere under `engine/phone/`. The generic + call pool may contain such a line, but it is not attributable to Liz from the + map/phone scripts - unresolved. +- **"TM49" listed under *Items found in Azalea Town*.** TM49 is + `verbosegiveitem TM_FURY_CUTTER` inside `AzaleaGymBugsyScript`, i.e. found in + `MAP_AZALEA_GYM`, not on the town map. Cosmetic, but a bot indexing by map + should not look for it outdoors. +- **Battle order inside Slowpoke Well.** The walkthrough's "move on up the + stairs", "head down the stairs" prose does not correspond to any `LADDER` or + `STAIRCASE` collision inside B1F - the only two ladder cells are the two warps + (`17, 15` and `7, 11`). The trainer order it describes (M29 -> F1 -> M2 -> M1) + does match the geometry, so the "stairs" are just prose for the corridors. +- **`SLOWPOKE_WELL_B2F` reachability.** I derived "Surf only" from + `maps/SlowpokeWellB1F.blk` decoded against `data/tilesets/cave_collision.asm` + and `data/collision/collision_permissions.asm`; no script or engine check + states it. If a future reader wants a stronger citation, it would have to come + from the block data, not from an `EVENT_*`. +- **Gym-trainer skippability.** The claim that Josh, Al and (via the column-7 + stalk) the twins can be walked past is derived from `maps/AzaleaGym.blk` plus + each object's sight field, not asserted anywhere in the asm. The retroactive + `setevent` block in `AzaleaGymBugsyScript.FightDone` is direct asm evidence + that skipping them is an anticipated state, but the exact dodge path should be + confirmed in-game before a driver depends on it. +- **`variablesprite SPRITE_AZALEA_ROCKET, SPRITE_RIVAL`.** This is why + `AZALEATOWN_RIVAL` is declared with `SPRITE_AZALEA_ROCKET` in + `maps/AzaleaTown.asm`. It also means consts 2 and 12 on that map would draw as + the rival if they were still visible after the well is cleared - they are not, + because both are hidden by then. Worth a look if a port ever renders them. diff --git a/docs/gold-walkthrough/asm-walk/section-05-ilex-forest-and-goldenrod-city-gym.md b/docs/gold-walkthrough/asm-walk/section-05-ilex-forest-and-goldenrod-city-gym.md new file mode 100644 index 00000000..9383a40c --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-05-ilex-forest-and-goldenrod-city-gym.md @@ -0,0 +1,1309 @@ +# Section 05 - Ilex Forest and Goldenrod City Gym + +Source: `../section-05-ilex-forest-and-goldenrod-city-gym.txt` + +Maps covered: `AZALEA_TOWN` (west exit only), `ILEX_FOREST_AZALEA_GATE`, +`ILEX_FOREST`, `ROUTE_34_ILEX_FOREST_GATE`, `ROUTE_34`, `DAY_CARE`, +`GOLDENROD_CITY`, `GOLDENROD_POKECENTER_1F`, `GOLDENROD_DEPT_STORE_1F` .. +`GOLDENROD_DEPT_STORE_6F`, `BILLS_FAMILYS_HOUSE`, +`GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`, `GOLDENROD_UNDERGROUND`, +`GOLDENROD_BIKE_SHOP`, `GOLDENROD_MAGNET_TRAIN_STATION`, `RADIO_TOWER_1F`, +`GOLDENROD_GAME_CORNER`, `GOLDENROD_GYM`. + +Badges / key milestones in this section: + +- Rival battle 2 (Azalea Town), `EVENT_RIVAL_AZALEA_TOWN` +- `HM_CUT` (HM01) from the charcoal master, `EVENT_GOT_HM01_CUT` +- `TM_HEADBUTT` (TM02), `TM_SWEET_SCENT` (TM12) +- `BICYCLE`, `COIN_CASE`, `ENGINE_RADIO_CARD` +- PLAINBADGE (`ENGINE_PLAINBADGE`) + `TM_ATTRACT` (TM45) from Whitney + +Conventions used below are the ones in `_TEMPLATE.md`. Disassembly paths are +relative to the pokegold checkout root; port paths are relative to this repo +root. All coordinates are raw asm values (map coordinates, 2 per map block). + +Note on `SCENE_*` names: pokegold has no `constants/map_scenes.asm`. The +`scene_script` macro (`macros/scripts/maps.asm`) defines each scene constant at +the point of use, so `SCENE_AZALEATOWN_RIVAL_BATTLE` is declared by +`maps/AzaleaTown.asm` itself. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `AZALEA_TOWN` | `maps/AzaleaTown.asm` | out of `AZALEA_GYM` (warp 5 at 10,15) | warps 7/8 at (2,10)/(2,11) -> `ILEX_FOREST_AZALEA_GATE` 3/4 | heal, walk west, rival trip-wire at (5,10)/(5,11) | +| 2 | `ILEX_FOREST_AZALEA_GATE` | `maps/IlexForestAzaleaGate.asm` | warps 3/4 at (9,4)/(9,5) | warps 1/2 at (0,4)/(0,5) -> `ILEX_FOREST` 2/3 | gate house, two flavour NPCs | +| 3 | `ILEX_FOREST` | `maps/IlexForest.asm` | warps 2/3 at (3,42)/(3,43) | warp 1 at (1,5) -> `ROUTE_34_ILEX_FOREST_GATE` 3 | herd Farfetch'd, get HM01, cut the tree, TM02, Revive | +| 4 | `ROUTE_34_ILEX_FOREST_GATE` | `maps/Route34IlexForestGate.asm` | warps 3/4 at (4,7)/(5,7) | warps 1/2 at (4,0)/(5,0) -> `ROUTE_34` 1/2 | teacher hands over TM12 Sweet Scent | +| 5 | `ROUTE_34` | `maps/Route34.asm` | warps 1/2 at (13,37)/(14,37) | north map connection -> `GOLDENROD_CITY` (offset -5) | Samuel, Brandon, Ian, Gina, Keith, Todd; Day-Care | +| 6 | `DAY_CARE` | `maps/DayCare.asm` | `ROUTE_34` warps 3/4/5 at (11,14)/(11,15)/(13,15) | warps 1..4 back to `ROUTE_34` | deposit / breed, egg pickup | +| 7 | `GOLDENROD_CITY` | `maps/GoldenrodCity.asm` | south map connection from `ROUTE_34` (offset 5) | see the city's 15 warps | hub for everything below | +| 8 | `GOLDENROD_POKECENTER_1F` | `maps/GoldenrodPokecenter1F.asm` | city warp 7 at (15,27) | warps 1/2 at (3,7)/(4,7) | heal | +| 9 | `GOLDENROD_DEPT_STORE_1F`..`6F` | `maps/GoldenrodDeptStore1F.asm` .. `6F.asm` | city warp 10 at (24,27) | stairs / elevator | shopping, Sunday TM lady on 5F | +| 10 | `BILLS_FAMILYS_HOUSE` | `maps/BillsFamilysHouse.asm` | city warp 4 at (5,25) | warps 1/2 at (2,7)/(3,7) | Bill's phone number from the younger sister | +| 11 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` | city warp 15 at (11,29) -> warp 5 at (4,29) | warp 4 at (5,25) -> `GOLDENROD_UNDERGROUND` 2 | the "house below Bill's" is the south underground stair | +| 12 | `GOLDENROD_UNDERGROUND` | `maps/GoldenrodUnderground.asm` | warp 2 at (3,34) | warp 1 at (3,2) -> switch-room warp 7 at (21,25) | Eric, Issac, Coin Case, salon, Teru, Donald, locked basement door | +| 13 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` (north half) | as above | warp 7 at (21,25) | warps 8/9 at (20,29)/(21,29) -> `GOLDENROD_CITY` 14 at (9,5) | come back up on the north side of town | +| 14 | `GOLDENROD_BIKE_SHOP` | `maps/GoldenrodBikeShop.asm` | city warp 2 at (29,29) | warps 1/2 at (2,7)/(3,7) | borrow the `BICYCLE` | +| 15 | `GOLDENROD_MAGNET_TRAIN_STATION` | `maps/GoldenrodMagnetTrainStation.asm` | city warp 5 at (9,13) | warps 1/2 at (8,17)/(9,17) | conductor flavour text, no train yet | +| 16 | `RADIO_TOWER_1F` | `maps/RadioTower1F.asm` | city warp 12 at (5,15) | warps 1/2 at (2,7)/(3,7) | Lucky Number man + Radio Card quiz | +| 17 | `GOLDENROD_GAME_CORNER` | `maps/GoldenrodGameCorner.asm` | city warp 11 at (14,21) | warps 1/2 at (2,13)/(3,13) | slots, prize corner (needs `COIN_CASE`) | +| 18 | `GOLDENROD_GYM` | `maps/GoldenrodGym.asm` | city warp 1 at (24,7) | warps 1/2 at (2,17)/(3,17) | Victoria, Samantha, Carrie, Bridget, Whitney | + +Spills into the next section: after PLAINBADGE the walkthrough's next hop is +north out of Goldenrod through `ROUTE_35_GOLDENROD_GATE` (city warp 13 at +(19,1)); that map and Route 35 belong to the following section. + +--- + +## 2. Maps + +### MAP_AZALEA_TOWN (west exit / rival battle only) + +- Script: `maps/AzaleaTown.asm` +- Blocks: `maps/AzaleaTown.blk` +- Header: `data/maps/maps.asm:232` -> `TILESET_JOHTO_MODERN, TOWN, LANDMARK_AZALEA_TOWN, MUSIC_AZALEA_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:213` -> `map_const AZALEA_TOWN, 20, 9` +- Connections (`data/maps/attributes.asm:132`): west `Route34` (offset -18), east `Route33` (offset 0) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 9 | `AZALEA_POKECENTER_1F` | 1 | +| 2 | 21 | 13 | `CHARCOAL_KILN` | 1 | +| 3 | 21 | 5 | `AZALEA_MART` | 2 | +| 4 | 9 | 5 | `KURTS_HOUSE` | 1 | +| 5 | 10 | 15 | `AZALEA_GYM` | 1 | +| 6 | 31 | 7 | `SLOWPOKE_WELL_B1F` | 1 | +| 7 | 2 | 10 | `ILEX_FOREST_AZALEA_GATE` | 3 | +| 8 | 2 | 11 | `ILEX_FOREST_AZALEA_GATE` | 4 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_AZALEATOWN_RIVAL_BATTLE` | 5 | 10 | `AzaleaTownRivalBattleScene1` | rival is teleported to (11,11) first, then walks 6 left | +| `SCENE_AZALEATOWN_RIVAL_BATTLE` | 5 | 11 | `AzaleaTownRivalBattleScene2` | rival walks 6 left from his placed position | + +**BG events** (`def_bg_events`) - the ones on the west path + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 9 | `BGEVENT_READ` | `AzaleaTownIlextForestSign` | +| 31 | 6 | `BGEVENT_ITEM` | `AzaleaTownHiddenFullHeal` (`FULL_HEAL`, `EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL`) | + +(The other seven bg events - town sign, Kurt's house, gym, Slowpoke Well, +charcoal kiln, Pokecenter, mart - are listed verbatim in the asm and belong to +the previous section.) + +**Object events** (`def_object_events`) - rival row only + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `AZALEATOWN_RIVAL` | `SPRITE_AZALEA_ROCKET` | 11 | 10 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_AZALEA_TOWN` | + +`SPRITE_AZALEA_ROCKET` is remapped to `SPRITE_RIVAL` by +`variablesprite SPRITE_AZALEA_ROCKET, SPRITE_RIVAL` in +`maps/SlowpokeWellB1F.asm` (line 59), so the object draws as the rival. + +**Scripts of interest** + +- `AzaleaTownRivalBattleScene1` / `AzaleaTownRivalBattleScene2` -> + `AzaleaTownRivalBattleScript`: `playmusic MUSIC_RIVAL_ENCOUNTER`, before-text, + `setevent EVENT_RIVAL_AZALEA_TOWN`, then branches on the starter: + `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> `loadtrainer RIVAL1, RIVAL1_2_CHIKORITA`; + `checkevent EVENT_GOT_CHIKORITA_FROM_ELM` -> `RIVAL1_2_CYNDAQUIL`; + otherwise (player took Cyndaquil) -> `RIVAL1_2_TOTODILE`. + `winlosstext AzaleaTownRivalWinText, AzaleaTownRivalLossText`, + `setlasttalked AZALEATOWN_RIVAL`, `startbattle`, `reloadmapafterbattle`. +- `.AfterBattle`: `MUSIC_RIVAL_AFTER`, after-text, rival walks 3 left, + `disappear AZALEATOWN_RIVAL`, `setscene SCENE_AZALEATOWN_NOOP`. +- Arming: `maps/SlowpokeWellB1F.asm` (the Slowpoke Well clear scene) runs + `setevent EVENT_CLEARED_SLOWPOKE_WELL`, + `setmapscene AZALEA_TOWN, SCENE_AZALEATOWN_RIVAL_BATTLE`, + `clearevent EVENT_ILEX_FOREST_APPRENTICE`, + `clearevent EVENT_ILEX_FOREST_FARFETCHD_1`, + `setevent EVENT_CHARCOAL_KILN_FARFETCH_D`, + `setevent EVENT_CHARCOAL_KILN_APPRENTICE`. So the Ilex Forest Farfetch'd quest + and the Azalea rival battle are both armed by clearing Slowpoke Well, not by + the Hive Badge. +- `AzaleaTownFlypointCallback` (`MAPCALLBACK_NEWMAP`): `setflag ENGINE_FLYPOINT_AZALEA`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `SCENE_AZALEATOWN_RIVAL_BATTLE` | `maps/AzaleaTown.asm:17` (`scene_script`) | set by `maps/SlowpokeWellB1F.asm:58`, cleared to `SCENE_AZALEATOWN_NOOP` by `.AfterBattle` | trip-wire armed / disarmed | +| `EVENT_RIVAL_AZALEA_TOWN` | `constants/event_flags.asm:1121` | set by `AzaleaTownRivalBattleScript` | also the rival object's spawn flag | +| `EVENT_CLEARED_SLOWPOKE_WELL` | `constants/event_flags.asm` | read by `AzaleaTownGrampsScript` | prerequisite for this whole section | +| `ENGINE_FLYPOINT_AZALEA` | `constants/engine_flags.asm:82` | `AzaleaTownFlypointCallback` | Fly destination unlocked | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL1` | `RIVAL1` | `RIVAL1_2_CHIKORITA` / `_CYNDAQUIL` / `_TOTODILE` (`constants/trainer_constants.asm:55-57`) | `Rival1Group` entries 4/5/6: L12 Gastly, L14 Zubat, L16 Bayleef / Quilava / Croconaw | `AzaleaTownRivalBattleScript` | none | + +Note the asm order is Gastly, Zubat, starter; the walkthrough prints Gastly, +starter, Zubat. Trust the asm. + +--- + +### MAP_ILEX_FOREST_AZALEA_GATE + +- Script: `maps/IlexForestAzaleaGate.asm` +- Blocks: none (`maps/IlexForestAzaleaGate.blk` is absent; gate uses the shared gate blockset) +- Header: `data/maps/maps.asm:287` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_34, MUSIC_ROUTE_36, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:265` -> `map_const ILEX_FOREST_AZALEA_GATE, 5, 4` +- Connections: none (indoor gate) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ILEX_FOREST` | 2 | +| 2 | 0 | 5 | `ILEX_FOREST` | 3 | +| 3 | 9 | 4 | `AZALEA_TOWN` | 7 | +| 4 | 9 | 5 | `AZALEA_TOWN` | 8 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ILEXFORESTAZALEAGATE_OFFICER` | `SPRITE_OFFICER` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `IlexForestAzaleaGateOfficerScript` | -1 | +| `ILEXFORESTAZALEAGATE_GRANNY` | `SPRITE_GRANNY` | 1 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `IlexForestAzaleaGateGrannyScript` | -1 | + +Both are `jumptextfaceplayer` only. No gate guard, no badge check. + +--- + +### MAP_ILEX_FOREST + +- Script: `maps/IlexForest.asm` +- Blocks: `maps/IlexForest.blk` (405 bytes = 15 x 27) +- Header: `data/maps/maps.asm:122` -> `TILESET_FOREST, CAVE, LANDMARK_ILEX_FOREST, MUSIC_UNION_CAVE, FALSE, PALETTE_NITE, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:109` -> `map_const ILEX_FOREST, 15, 27` +- Connections: none (`data/maps/attributes.asm:441` has no `connection` rows) +- Environment is `CAVE` and palette is `PALETTE_NITE`, so it renders dark all day; it is *not* a Flash map (no `EVENT_...FLASH` requirement anywhere in the file). + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 1 | 5 | `ROUTE_34_ILEX_FOREST_GATE` | 3 | +| 2 | 3 | 42 | `ILEX_FOREST_AZALEA_GATE` | 1 | +| 3 | 3 | 43 | `ILEX_FOREST_AZALEA_GATE` | 2 | + +**Coord events**: `def_coord_events` is empty. The Farfetch'd chase is driven +entirely by talking to the object, not by stepping on tiles. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 17 | `BGEVENT_READ` | `IlexForestSignpost` | +| 27 | 1 | `BGEVENT_ITEM` | `IlexForestHiddenEther` -> `hiddenitem ETHER, EVENT_ILEX_FOREST_HIDDEN_ETHER` | +| 17 | 7 | `BGEVENT_ITEM` | `IlexForestHiddenSuperPotion` -> `hiddenitem SUPER_POTION, EVENT_ILEX_FOREST_HIDDEN_SUPER_POTION` | +| 9 | 17 | `BGEVENT_ITEM` | `IlexForestHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_ILEX_FOREST_HIDDEN_FULL_HEAL` | +| 8 | 22 | `BGEVENT_READ` | `IlexForestShrineScript` (the shrine the walkthrough calls "Ilex Forest Shrine") | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ILEXFOREST_FARFETCHD1` | `SPRITE_BIRD` | 14 | 31 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition1` | `EVENT_ILEX_FOREST_FARFETCHD_1` | +| `ILEXFOREST_FARFETCHD2` | `SPRITE_BIRD` | 15 | 25 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition2` | `EVENT_ILEX_FOREST_FARFETCHD_2` | +| `ILEXFOREST_FARFETCHD3` | `SPRITE_BIRD` | 20 | 24 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition3` | `EVENT_ILEX_FOREST_FARFETCHD_3` | +| `ILEXFOREST_FARFETCHD4` | `SPRITE_BIRD` | 29 | 22 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition4` | `EVENT_ILEX_FOREST_FARFETCHD_4` | +| `ILEXFOREST_FARFETCHD5` | `SPRITE_BIRD` | 28 | 31 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition5` | `EVENT_ILEX_FOREST_FARFETCHD_5` | +| `ILEXFOREST_FARFETCHD6` | `SPRITE_BIRD` | 24 | 35 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition6` | `EVENT_ILEX_FOREST_FARFETCHD_6` | +| `ILEXFOREST_FARFETCHD7` | `SPRITE_BIRD` | 22 | 31 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition7` | `EVENT_ILEX_FOREST_FARFETCHD_7` | +| `ILEXFOREST_FARFETCHD8` | `SPRITE_BIRD` | 15 | 29 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition8` | `EVENT_ILEX_FOREST_FARFETCHD_8` | +| `ILEXFOREST_FARFETCHD9` | `SPRITE_BIRD` | 10 | 35 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition9` | `EVENT_ILEX_FOREST_FARFETCHD_9` | +| `ILEXFOREST_FARFETCHD10` | `SPRITE_BIRD` | 6 | 28 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FarfetchdPosition10` | `EVENT_ILEX_FOREST_FARFETCHD_10` | +| `ILEXFOREST_YOUNGSTER` | `SPRITE_YOUNGSTER` | 7 | 28 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `IlexForestCharcoalApprenticeScript` | `EVENT_ILEX_FOREST_APPRENTICE` | +| `ILEXFOREST_BLACK_BELT` | `SPRITE_BLACK_BELT` | 5 | 28 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `IlexForestCharcoalMasterScript` | `EVENT_ILEX_FOREST_CHARCOAL_MASTER` | +| `ILEXFOREST_ROCKER` | `SPRITE_ROCKER` | 15 | 14 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `IlexForestHeadbuttGuyScript` | -1 | +| `ILEXFOREST_POKE_BALL` | `SPRITE_POKE_BALL` | 20 | 32 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IlexForestRevive` (`itemball REVIVE`) | `EVENT_ILEX_FOREST_REVIVE` | + +**Scripts of interest** + +- `IlexForestCharcoalApprenticeScript` (object at 7,28): `checkevent EVENT_HERDED_FARFETCHD` + picks the "please help" text vs the "thanks" text. Pure text, sets nothing. +- `FarfetchdPosition1`: no facing check. Talk once and it always runs + `applymovement ILEXFOREST_FARFETCHD1, MovementData_Farfetchd_Pos1_Pos2`, + `appear ILEXFOREST_FARFETCHD2`, `disappear ILEXFOREST_FARFETCHD1`. +- `FarfetchdPosition2`..`9`: each `scall FarfetchdCryAndCheckFacing`, which does + `faceplayer` / cry / `readvar VAR_FACING`, then `ifequal <DIR>, ...` on the + *player's* facing to decide which way the bird flees. The forward chain is the + fall-through branch of each; the listed `ifequal` directions send it backwards. + Concretely (fall-through = progress): + - Pos2 -> Pos3, except facing `DOWN` -> Pos8 + - Pos3 -> Pos4, except `LEFT` -> Pos2 + - Pos4 -> Pos5, except `UP` -> Pos3 + - Pos5 -> Pos6, except `LEFT` -> Pos7, `UP`/`RIGHT` -> Pos4 + - Pos6 -> Pos7, except `RIGHT` -> Pos5 + - Pos7 -> Pos8, except `LEFT` -> Pos6, `DOWN` -> Pos5 + - Pos8 -> Pos9, except `RIGHT` -> Pos7, `UP`/`LEFT` -> Pos2 + - Pos9 -> Pos10 (**terminal**), except `RIGHT`/`DOWN` -> Pos8 +- `FarfetchdPosition9` fall-through additionally runs + `appear 13 ; ILEXFOREST_BLACK_BELT`, `setevent EVENT_CHARCOAL_KILN_BOSS`, + `setevent EVENT_HERDED_FARFETCHD`. That `appear 13` is a raw object index, not + the const - worth mirroring exactly in a port. +- `IlexForestCharcoalMasterScript` (object at 5,28): `checkevent EVENT_GOT_HM01_CUT`, + else `verbosegiveitem HM_CUT`, `setevent EVENT_GOT_HM01_CUT`, then + `setevent EVENT_ILEX_FOREST_FARFETCHD_10`, `setevent EVENT_ILEX_FOREST_APPRENTICE`, + `setevent EVENT_ILEX_FOREST_CHARCOAL_MASTER`, + `clearevent EVENT_CHARCOAL_KILN_FARFETCH_D`, `clearevent EVENT_CHARCOAL_KILN_APPRENTICE`, + `clearevent EVENT_CHARCOAL_KILN_BOSS` - i.e. the trio moves back to the + Charcoal Kiln in Azalea. Note this script has **no** `iffalse` bag-full guard + around `verbosegiveitem HM_CUT`. +- `IlexForestHeadbuttGuyScript` (object at 15,14): `checkevent EVENT_GOT_TM02_HEADBUTT`, + else `verbosegiveitem TM_HEADBUTT`, `iffalse .BagFull`, `setevent EVENT_GOT_TM02_HEADBUTT`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ILEX_FOREST_FARFETCHD_1`..`_10` | `constants/event_flags.asm` (`_10` at :1172) | spawn flags on the ten bird objects; moved by the chase scripts | exactly one is set at a time | +| `EVENT_HERDED_FARFETCHD` | `constants/event_flags.asm:50` | set by `FarfetchdPosition9` fall-through | chase complete | +| `EVENT_CHARCOAL_KILN_BOSS` | `constants/event_flags.asm:1177` | set by `FarfetchdPosition9`, cleared by `IlexForestCharcoalMasterScript` | kiln boss spawn | +| `EVENT_ILEX_FOREST_APPRENTICE` | `constants/event_flags.asm:1173` | cleared by `SlowpokeWellB1F`, set by the master script | apprentice spawn (inverted: set = hidden here) | +| `EVENT_ILEX_FOREST_CHARCOAL_MASTER` | `constants/event_flags.asm:1174` | set by the master script | master spawn | +| `EVENT_GOT_HM01_CUT` | `constants/event_flags.asm:23` | `IlexForestCharcoalMasterScript` | one-shot HM01 | +| `EVENT_GOT_TM02_HEADBUTT` | `constants/event_flags.asm:104` | `IlexForestHeadbuttGuyScript`; also read by `maps/GoldenrodDeptStore5F.asm` | one-shot TM02 and a Dept 5F stock switch | +| `EVENT_ILEX_FOREST_REVIVE` | `constants/event_flags.asm:1042` | item ball | one-shot | +| `EVENT_ILEX_FOREST_HIDDEN_ETHER` / `_SUPER_POTION` / `_FULL_HEAL` | `constants/event_flags.asm:146-148` | hidden items | one-shot | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `HM_CUT` (HM01) | talk to charcoal master at (5,28) after herding | `IlexForestCharcoalMasterScript` | `EVENT_GOT_HM01_CUT` | +| `TM_HEADBUTT` (TM02) | talk to rocker at (15,14) | `IlexForestHeadbuttGuyScript` | `EVENT_GOT_TM02_HEADBUTT` | +| `REVIVE` | item ball at (20,32) | `IlexForestRevive` | `EVENT_ILEX_FOREST_REVIVE` | +| `ETHER` | hidden at (27,1) | `IlexForestHiddenEther` | `EVENT_ILEX_FOREST_HIDDEN_ETHER` | +| `SUPER_POTION` | hidden at (17,7) | `IlexForestHiddenSuperPotion` | `EVENT_ILEX_FOREST_HIDDEN_SUPER_POTION` | +| `FULL_HEAL` | hidden at (9,17) | `IlexForestHiddenFullHeal` | `EVENT_ILEX_FOREST_HIDDEN_FULL_HEAL` | + +**Trainers**: none. `def_object_events` has no `OBJECTTYPE_TRAINER` row. + +**Wild encounters** + +`data/wild/johto_grass.asm`, `def_grass_wildmons ILEX_FOREST`, rates +`4 percent` morn/day/nite: + +| slot | morn (Gold) | day (Gold) | nite (both) | +|---|---|---|---| +| 1 | L5 Caterpie | L5 Caterpie | L5 Oddish | +| 2 | L6 Metapod | L6 Caterpie | L6 Oddish | +| 3 | L6 Caterpie | L5 Metapod | L6 Zubat | +| 4 | L5 Paras | L6 Metapod | L5 Paras | +| 5 | L5 Zubat | L5 Zubat | L5 Zubat | +| 6 | L6 Paras | L6 Paras | L6 Paras | +| 7 | L6 Paras | L6 Paras | L6 Paras | + +Silver swaps Caterpie/Metapod for Weedle/Kakuna in the morn and day blocks +(the `ELIF DEF(_SILVER)` arm). The nite block is shared. + +Water (`data/wild/johto_water.asm:51`, `def_water_wildmons ILEX_FOREST`, +`2 percent`): L15 Psyduck, L10 Psyduck, L15 Golduck. + +Headbutt: `data/wild/treemon_maps.asm:40` -> `treemon_map ILEX_FOREST, TREEMON_SET_FOREST`. +`TreeMonSet_Forest` (Gold) common: 50% Caterpie, 15% Caterpie, 15% Metapod, +10% Exeggcute, 5% Exeggcute, 5% Butterfree - all level 10; rare swaps the two +15% slots for Pineco. + +**The Cut tree** + +`data/collision/field_move_blocks.asm` `.forest` lists exactly one cuttable +block for `TILESET_FOREST`: `db $0f, $17, 0` (facing block `$0f`, replacement +`$17`, animation 0). Scanning `maps/IlexForest.blk` (15 wide) for `$0f` finds a +single occurrence, block (4,12), i.e. **map coordinates x 8-9, y 24-25**. That +is the tree between the Farfetch'd/charcoal area in the south and the shrine +bg_event at (8,22) / the north half of the forest, and it is the only thing +standing between the player and warp 1 at (1,5). + +--- + +### MAP_ROUTE_34_ILEX_FOREST_GATE + +- Script: `maps/Route34IlexForestGate.asm` +- Blocks: none +- Header: `data/maps/maps.asm:288` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_34, MUSIC_ROUTE_36, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:266` -> `map_const ROUTE_34_ILEX_FOREST_GATE, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_34` | 1 | +| 2 | 5 | 0 | `ROUTE_34` | 2 | +| 3 | 4 | 7 | `ILEX_FOREST` | 1 | +| 4 | 5 | 7 | `ILEX_FOREST` | 1 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE34ILEXFORESTGATE_TEACHER` | `SPRITE_TEACHER` | 9 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route34IlexForestGateTeacherScript` | -1 | +| `ROUTE34ILEXFORESTGATE_BUTTERFREE` | `SPRITE_BUTTERFREE` | 9 | 4 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `Route34IlexForestGateButterfreeScript` | -1 | +| `ROUTE34ILEXFORESTGATE_LASS` | `SPRITE_LASS` | 3 | 4 | `WALK_UP_DOWN` | `OBJECTTYPE_SCRIPT` | `Route34IlexForestGateLassScript` | -1 | + +**Scripts of interest** + +- `Route34IlexForestGateTeacherScript`: `checkevent EVENT_GOT_TM12_SWEET_SCENT`, + else `verbosegiveitem TM_SWEET_SCENT`, `iffalse .NoRoom`, + `setevent EVENT_GOT_TM12_SWEET_SCENT`. The walkthrough calls her "the lady + behind the counter"; she is at (9,3), the top-right of the gate. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SWEET_SCENT` (TM12) | talk to teacher at (9,3) | `Route34IlexForestGateTeacherScript` | `EVENT_GOT_TM12_SWEET_SCENT` (`constants/event_flags.asm:131`) | + +--- + +### MAP_ROUTE_34 + +- Script: `maps/Route34.asm` +- Blocks: `maps/Route34.blk` +- Header: `data/maps/maps.asm:268` -> `TILESET_JOHTO_MODERN, ROUTE, LANDMARK_ROUTE_34, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:246` -> `map_const ROUTE_34, 10, 27` +- Connections (`data/maps/attributes.asm:198`): north `GoldenrodCity` (offset -5), east `AzaleaTown` (offset 18) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 37 | `ROUTE_34_ILEX_FOREST_GATE` | 1 | +| 2 | 14 | 37 | `ROUTE_34_ILEX_FOREST_GATE` | 2 | +| 3 | 11 | 14 | `DAY_CARE` | 1 | +| 4 | 11 | 15 | `DAY_CARE` | 2 | +| 5 | 13 | 15 | `DAY_CARE` | 3 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 12 | 6 | `BGEVENT_READ` | `Route34Sign` | +| 13 | 33 | `BGEVENT_READ` | `Route34TrainerTips` | +| 10 | 13 | `BGEVENT_READ` | `DayCareSign` | +| 8 | 32 | `BGEVENT_ITEM` | `Route34HiddenRareCandy` -> `hiddenitem RARE_CANDY, EVENT_ROUTE_34_HIDDEN_RARE_CANDY` | +| 17 | 19 | `BGEVENT_ITEM` | `Route34HiddenSuperPotion` -> `hiddenitem SUPER_POTION, EVENT_ROUTE_34_HIDDEN_SUPER_POTION` | + +(`Route34IlexForestSign` exists in the file but is marked `; unreferenced`.) + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE34_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 13 | 7 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerCamperTodd1` | -1 | +| `ROUTE34_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 15 | 33 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerYoungsterSamuel` | -1 | +| `ROUTE34_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 17 | 22 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerYoungsterIan` | -1 | +| `ROUTE34_LASS` | `SPRITE_LASS` | 10 | 26 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPicnickerGina1` | -1 | +| `ROUTE34_OFFICER` | `SPRITE_OFFICER` | 9 | 11 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `OfficerKeithScript` | -1 | +| `ROUTE34_POKEFAN_M` | `SPRITE_POKEFAN_M` | 19 | 28 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 2 | `TrainerPokefanmBrandon` | -1 | +| `ROUTE34_GRAMPS` | `SPRITE_GRAMPS` | 15 | 16 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `DayCareManScript_Outside` | `EVENT_DAY_CARE_MAN_ON_ROUTE_34` | +| `ROUTE34_DAY_CARE_MON_1` | `SPRITE_DAY_CARE_MON_1` | 14 | 18 | `POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `DayCareMon1Script` | `EVENT_DAY_CARE_MON_1` | +| `ROUTE34_DAY_CARE_MON_2` | `SPRITE_DAY_CARE_MON_2` | 17 | 19 | `POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `DayCareMon2Script` | `EVENT_DAY_CARE_MON_2` | +| `ROUTE34_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 11 | 48 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerCooltrainerfIrene` | -1 | +| `ROUTE34_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 3 | 48 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerCooltrainerfJenn` | -1 | +| `ROUTE34_COOLTRAINER_F3` | `SPRITE_COOLTRAINER_F` | 6 | 51 | `STANDING_UP` | `OBJECTTYPE_TRAINER` | 2 | `TrainerCooltrainerfKate` | -1 | + +The three Cooltrainer F sisters sit at y 48-51, on the beach south-west of the +Ilex gate. That area is water-locked; reaching them needs SURF, which needs the +FOGBADGE. They are not part of this section's path. + +**Scripts of interest** + +- `Route34EggCheckCallback` (`MAPCALLBACK_OBJECTS`): reads + `ENGINE_DAY_CARE_MAN_HAS_EGG` to decide whether the Day-Care man stands + outside (`EVENT_DAY_CARE_MAN_ON_ROUTE_34`) or inside + (`EVENT_DAY_CARE_MAN_IN_DAY_CARE`), then mirrors + `ENGINE_DAY_CARE_MAN_HAS_MON` / `ENGINE_DAY_CARE_LADY_HAS_MON` onto + `EVENT_DAY_CARE_MON_1` / `_2` (which are *hide* flags: set = hidden). +- `DayCareManScript_Outside`: `special DayCareManOutside` (the yes/no egg + handoff), then `ifequal TRUE, .end_fail`, `clearflag ENGINE_DAY_CARE_MAN_HAS_EGG`, + and walks him back inside with one of two movement scripts depending on + `readvar VAR_FACING` (`RIGHT` uses the walk-around-player variant). +- `OfficerKeithScript`: `checktime NITE`, `iffalse .NoFight`. Night only, as the + walkthrough says. Wins set `EVENT_BEAT_OFFICER_KEITH`. It is an + `OBJECTTYPE_SCRIPT`, not a `trainer` row, so there is no sight-line trigger: + the bot must talk to him. +- `TrainerCamperTodd1` / `TrainerPicnickerGina1`: full phone-number flow + (`askforphonenumber PHONE_CAMPER_TODD` / `PHONE_PICNICKER_GINA`), with + rematch parties gated on `ENGINE_FLYPOINT_CIANWOOD` / `ENGINE_FLYPOINT_BLACKTHORN` + (Todd) and `ENGINE_FLYPOINT_MAHOGANY` / `EVENT_CLEARED_RADIO_TOWER` (Gina). +- `TrainerYoungsterSamuel`, `TrainerYoungsterIan`, `TrainerPokefanmBrandon`: + plain `endifjustbattled` + after-text. **No phone number.** +- `TrainerCooltrainerfKate`: `verbosegiveitem SOFT_SAND`, + `setevent EVENT_GOT_SOFT_SAND_FROM_KATE`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_DAY_CARE_MAN_HAS_EGG` | `constants/engine_flags.asm:10` | `Route34EggCheckCallback`, `DayCareEggCheckCallback`, `DayCareManScript_Outside` | egg is waiting; man stands outside at (15,16) | +| `ENGINE_DAY_CARE_MAN_HAS_MON` / `ENGINE_DAY_CARE_LADY_HAS_MON` | `constants/engine_flags.asm:11,13` | both egg-check callbacks | drives the two overworld Day-Care mon sprites | +| `EVENT_DAY_CARE_MAN_ON_ROUTE_34` | `constants/event_flags.asm:1160` | callbacks | outdoor Day-Care man spawn | +| `EVENT_BEAT_OFFICER_KEITH` | `constants/event_flags.asm` | `OfficerKeithScript` | one-shot night battle | +| `EVENT_GOT_SOFT_SAND_FROM_KATE` | `constants/event_flags.asm:121` | `TrainerCooltrainerfKate` | one-shot, needs SURF | +| `EVENT_ROUTE_34_HIDDEN_RARE_CANDY` / `_SUPER_POTION` | `constants/event_flags.asm:177,178` | hidden items | one-shot | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `SOFT_SAND` | beat Cooltrainer F Kate at (6,51) (SURF required) | `TrainerCooltrainerfKate` | `EVENT_GOT_SOFT_SAND_FROM_KATE` | +| `RARE_CANDY` | hidden at (8,32) | `Route34HiddenRareCandy` | `EVENT_ROUTE_34_HIDDEN_RARE_CANDY` | +| `SUPER_POTION` | hidden at (17,19) | `Route34HiddenSuperPotion` | `EVENT_ROUTE_34_HIDDEN_SUPER_POTION` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SAMUEL` | `YOUNGSTER` | YOUNGSTER (5) | L7 Rattata, L10 Sandshrew, L8 Spearow, L8 Spearow | `TrainerYoungsterSamuel` | none | +| `BRANDON` | `POKEFANM` | POKEFANM (7) | L13 Snubbull @ `BERRY` (`TRAINERTYPE_ITEM`) | `TrainerPokefanmBrandon` | none | +| `IAN` | `YOUNGSTER` | YOUNGSTER (6) | L10 Mankey, L12 Diglett | `TrainerYoungsterIan` | none | +| `GINA1` | `PICNICKER` | PICNICKER (2) | L9 Hoppip, L9 Hoppip, L12 Bulbasaur | `TrainerPicnickerGina1` | `PHONE_PICNICKER_GINA`; `GINA2`/`GINA3` rematches | +| `KEITH` | `OFFICER` | OFFICER (1) | L17 Growlithe | `OfficerKeithScript` | none; night only | +| `TODD1` | `CAMPER` | CAMPER (2) | L14 Psyduck | `TrainerCamperTodd1` | `PHONE_CAMPER_TODD`; `TODD2`/`TODD3` rematches | +| `IRENE` | `COOLTRAINERF` | - | see `data/trainers/parties.asm` | `TrainerCooltrainerfIrene` | SURF-gated beach | +| `JENN` | `COOLTRAINERF` | - | see `data/trainers/parties.asm` | `TrainerCooltrainerfJenn` | SURF-gated beach | +| `KATE` | `COOLTRAINERF` | - | see `data/trainers/parties.asm` | `TrainerCooltrainerfKate` | SURF-gated beach; gives `SOFT_SAND` | + +**Wild encounters** + +`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_34`, rates +`10 percent` morn/day/nite; all three time blocks are identical: + +L10 Drowzee, L11 Rattata, L12 Drowzee, L10 Abra, L13 Rattata, L10 Ditto, L10 Ditto. + +Water (`data/wild/johto_water.asm:149`, `6 percent`): L20 Tentacool, +L15 Tentacool, L20 Tentacruel. + +Headbutt: `data/wild/treemon_maps.asm:15` -> `treemon_map ROUTE_34, TREEMON_SET_FOREST` +(same table as Ilex Forest). + +--- + +### MAP_DAY_CARE + +- Script: `maps/DayCare.asm` +- Blocks: `maps/DayCare.blk` +- Header: `data/maps/maps.asm:289` -> `TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_34, MUSIC_AZALEA_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:267` -> `map_const DAY_CARE, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 5 | `ROUTE_34` | 3 | +| 2 | 0 | 6 | `ROUTE_34` | 4 | +| 3 | 2 | 7 | `ROUTE_34` | 5 | +| 4 | 3 | 7 | `ROUTE_34` | 5 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `DayCareBookshelf` | +| 1 | 1 | `BGEVENT_READ` | `DayCareBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DAYCARE_GRAMPS` | `SPRITE_GRAMPS` | 2 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `DayCareManScript_Inside` | `EVENT_DAY_CARE_MAN_IN_DAY_CARE` | +| `DAYCARE_GRANNY` | `SPRITE_GRANNY` | 5 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `DayCareLadyScript` | -1 | + +**Scripts of interest** + +- `DayCareEggCheckCallback` (`MAPCALLBACK_OBJECTS`): mirror of the Route 34 one; + moves the man in/out based on `ENGINE_DAY_CARE_MAN_HAS_EGG`. +- `DayCareManScript_Inside` -> `special DayCareMan`; `DayCareLadyScript` -> + `special DayCareLady`, unless `ENGINE_DAY_CARE_MAN_HAS_EGG` is set, in which + case she just says "Gramps was looking for you". + +There is **no** PC object in `maps/DayCare.asm`. The walkthrough's quoted claim +that "there's a PC in the corner of the Daycare" is not backed by this map. + +--- + +### MAP_GOLDENROD_CITY + +- Script: `maps/GoldenrodCity.asm` +- Blocks: `maps/GoldenrodCity.blk` +- Header: `data/maps/maps.asm:269` -> `TILESET_JOHTO_MODERN, TOWN, LANDMARK_GOLDENROD_CITY, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:247` -> `map_const GOLDENROD_CITY, 20, 18` +- Connections (`data/maps/attributes.asm:139`): north `Route35` (offset 5), south `Route34` (offset 5) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 24 | 7 | `GOLDENROD_GYM` | 1 | +| 2 | 29 | 29 | `GOLDENROD_BIKE_SHOP` | 1 | +| 3 | 31 | 21 | `GOLDENROD_HAPPINESS_RATER` | 1 | +| 4 | 5 | 25 | `BILLS_FAMILYS_HOUSE` | 1 | +| 5 | 9 | 13 | `GOLDENROD_MAGNET_TRAIN_STATION` | 2 | +| 6 | 33 | 5 | `GOLDENROD_FLOWER_SHOP` | 1 | +| 7 | 15 | 27 | `GOLDENROD_POKECENTER_1F` | 1 | +| 8 | 33 | 9 | `GOLDENROD_PP_SPEECH_HOUSE` | 1 | +| 9 | 15 | 7 | `GOLDENROD_NAME_RATER` | 1 | +| 10 | 24 | 27 | `GOLDENROD_DEPT_STORE_1F` | 1 | +| 11 | 14 | 21 | `GOLDENROD_GAME_CORNER` | 1 | +| 12 | 5 | 15 | `RADIO_TOWER_1F` | 1 | +| 13 | 19 | 1 | `ROUTE_35_GOLDENROD_GATE` | 3 | +| 14 | 9 | 5 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 8 | +| 15 | 11 | 29 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 5 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 10 | 14 | `BGEVENT_READ` | `GoldenrodCityStationSign` | +| 4 | 17 | `BGEVENT_READ` | `GoldenrodCityRadioTowerSign` | +| 26 | 27 | `BGEVENT_READ` | `GoldenrodDeptStoreSign` | +| 26 | 9 | `BGEVENT_READ` | `GoldenrodGymSign` | +| 22 | 18 | `BGEVENT_READ` | `GoldenrodCitySign` | +| 28 | 30 | `BGEVENT_READ` | `GoldenrodCityBikeShopSign` | +| 16 | 22 | `BGEVENT_READ` | `GoldenrodCityGameCornerSign` | +| 12 | 7 | `BGEVENT_READ` | `GoldenrodCityNameRaterSign` | +| 8 | 6 | `BGEVENT_READ` | `GoldenrodCityUndergroundSignNorth` | +| 12 | 30 | `BGEVENT_READ` | `GoldenrodCityUndergroundSignSouth` | +| 16 | 27 | `BGEVENT_READ` | `GoldenrodCityPokecenterSign` | + +No hidden items on this map. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODCITY_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 7 | 18 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityPokefanMScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 30 | 17 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `GoldenrodCityYoungster1Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 12 | 16 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityCooltrainerF1Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 20 | 26 | `WANDER` (1,2) | `OBJECTTYPE_SCRIPT` | `GoldenrodCityCooltrainerF2Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 19 | 17 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `GoldenrodCityYoungster2Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_LASS` | `SPRITE_LASS` | 17 | 10 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `GoldenrodCityLassScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_GRAMPS` | `SPRITE_GRAMPS` | 11 | 27 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `GoldenrodCityGrampsScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_ROCKETSCOUT` | `SPRITE_ROCKET` | 4 | 16 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocketScoutScript` | `EVENT_GOLDENROD_CITY_ROCKET_SCOUT` | +| `GOLDENRODCITY_ROCKET1` | `SPRITE_ROCKET` | 28 | 20 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket1Script` | `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET2` | `SPRITE_ROCKET` | 8 | 15 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket2Script` | `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET3` | `SPRITE_ROCKET` | 16 | 23 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket3Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET4` | `SPRITE_ROCKET` | 29 | 20 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket4Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET5` | `SPRITE_ROCKET` | 29 | 7 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket5Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET6` | `SPRITE_ROCKET` | 30 | 10 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `GoldenrodCityRocket6Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | + +**Scripts of interest** + +- `GoldenrodCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): + `setflag ENGINE_FLYPOINT_GOLDENROD`, `setflag ENGINE_REACHED_GOLDENROD`. + Both fire on first entry, no conditions. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_GOLDENROD` | `constants/engine_flags.asm:84` | flypoint callback | Fly unlocked | +| `ENGINE_REACHED_GOLDENROD` | `constants/engine_flags.asm:31` | flypoint callback | gates the Rocket-takeover storyline elsewhere | +| `ENGINE_RADIO_CARD` | `constants/engine_flags.asm:4` | set by `RadioTower1FRadioCardWomanScript`; read by `GoldenrodCityCooltrainerF2Script` | Pokegear radio | +| `EVENT_GOLDENROD_CITY_CIVILIANS` | `constants/event_flags.asm` | spawn flag on the seven civilians | cleared during the Rocket takeover | + +**Trainers**: none on the city map at this point in the game. + +**Wild encounters**: none (`data/wild/treemon_maps.asm:33` -> +`treemon_map GOLDENROD_CITY, TREEMON_SET_NONE`; no `def_grass_wildmons` / +`def_water_wildmons` entry for `GOLDENROD_CITY`). + +--- + +### MAP_BILLS_FAMILYS_HOUSE + +- Script: `maps/BillsFamilysHouse.asm` +- Header: `data/maps/maps.asm:273` -> `TILESET_HOUSE, INDOOR, LANDMARK_GOLDENROD_CITY, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:251` -> `map_const BILLS_FAMILYS_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 4 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `BillsHouseBookshelf2` | +| 1 | 1 | `BGEVENT_READ` | `BillsHouseBookshelf1` | +| 7 | 1 | `BGEVENT_READ` | `BillsHouseRadio` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BILLSFAMILYSHOUSE_BILL` | `SPRITE_BILL` | 2 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `BillScript` | `EVENT_MET_BILL` | +| `BILLSFAMILYSHOUSE_POKEFAN_F` | `SPRITE_POKEFAN_F` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BillsMomScript` | -1 | +| `BILLSFAMILYSHOUSE_TWIN` | `SPRITE_TWIN` | 5 | 4 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `BillsYoungerSisterScript` | -1 | + +**Scripts of interest** + +- `BillsYoungerSisterScript` (the twin at 5,4 - the girl "closer to the door"): + `checkcellnum PHONE_BILL`, else `askforphonenumber PHONE_BILL` / + `addcellnum PHONE_BILL`. This is the only thing available in this section. +- `BillScript`: gives `EEVEE` at level 20 via `givepoke EEVEE, 20` + + `setevent EVENT_GOT_EEVEE`, guarded by `readvar VAR_PARTYCOUNT` / + `ifequal PARTY_LENGTH, .NoRoom`. Bill's object only spawns once + `EVENT_MET_BILL` is set, which happens in Ecruteak - **not in this section**. + `BillsMomScript` reads `EVENT_MET_BILL` too (note the two texts are wired + backwards relative to their names in the asm: `iffalse .HaventMetBill` + prints `BillsMomText_AfterEcruteak`). + +--- + +### MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES + +- Script: `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` +- Blocks: `maps/GoldenrodUndergroundSwitchRoomEntrances.blk` +- Header: `data/maps/maps.asm:124` -> `TILESET_ELITE_FOUR_ROOM, DUNGEON, LANDMARK_GOLDENROD_CITY, MUSIC_UNION_CAVE, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:111` -> `map_const GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES, 15, 18` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 3 | `GOLDENROD_UNDERGROUND` | 6 | +| 2 | 22 | 10 | `GOLDENROD_UNDERGROUND_WAREHOUSE` | 1 | +| 3 | 23 | 10 | `GOLDENROD_UNDERGROUND_WAREHOUSE` | 2 | +| 4 | 5 | 25 | `GOLDENROD_UNDERGROUND` | 2 | +| 5 | 4 | 29 | `GOLDENROD_CITY` | 15 | +| 6 | 5 | 29 | `GOLDENROD_CITY` | 15 | +| 7 | 21 | 25 | `GOLDENROD_UNDERGROUND` | 1 | +| 8 | 20 | 29 | `GOLDENROD_CITY` | 14 | +| 9 | 21 | 29 | `GOLDENROD_CITY` | 14 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_RIVAL_BATTLE` | 19 | 4 | `UndergroundRivalScene1` | later-game rival battle, not armed in this section | +| `SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_RIVAL_BATTLE` | 19 | 5 | `UndergroundRivalScene2` | same | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 1 | `BGEVENT_READ` | `Switch1Script` | +| 10 | 1 | `BGEVENT_READ` | `Switch2Script` | +| 2 | 1 | `BGEVENT_READ` | `Switch3Script` | +| 20 | 11 | `BGEVENT_READ` | `EmergencySwitchScript` | +| 8 | 9 | `BGEVENT_ITEM` | `GoldenrodUndergroundSwitchRoomEntrancesHiddenMaxPotion` | +| 1 | 8 | `BGEVENT_ITEM` | `GoldenrodUndergroundSwitchRoomEntrancesHiddenRevive` | + +**Object events** (all the Rocket/Burglar rows are gated on +`EVENT_RADIO_TOWER_ROCKET_TAKEOVER`, so during this section only the two +stairwell NPCs and the two item balls exist) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._TEACHER` | `SPRITE_TEACHER` | 3 | 27 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `GoldenrodUndergroundSwitchRoomEntrancesTeacherScript` | -1 | +| `..._SUPER_NERD` | `SPRITE_SUPER_NERD` | 19 | 27 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `GoldenrodUndergroundSwitchRoomEntrancesSuperNerdScript` | -1 | +| `..._POKE_BALL1` | `SPRITE_POKE_BALL` | 1 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `..._SmokeBall` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL` | +| `..._POKE_BALL2` | `SPRITE_POKE_BALL` | 14 | 9 | `STILL` | `OBJECTTYPE_ITEMBALL` | `..._FullHeal` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL` | +| `..._PHARMACIST1/2`, `..._ROCKET1/2/3`, `..._ROCKET_GIRL` | - | (9,12) (4,8) (17,2) (11,2) (3,2) (19,12) | - | `OBJECTTYPE_TRAINER` | `TrainerBurglarDuncan`, `TrainerBurglarEddie`, `TrainerGruntM13`, `TrainerGruntM11`, `TrainerGruntM25`, `TrainerGruntF3` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._RIVAL` | `SPRITE_RIVAL` | 23 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_GOLDENROD_UNDERGROUND` | + +The three `ugdoor` switch puzzles and the warehouse are also takeover-era +content. This section only walks through: city warp 15 -> (4,29), north to +warp 4 at (5,25) -> `GOLDENROD_UNDERGROUND`; and back up on the other side via +warp 7 at (21,25) -> warps 8/9 at (20/21,29) -> city warp 14 at (9,5). + +--- + +### MAP_GOLDENROD_UNDERGROUND + +- Script: `maps/GoldenrodUnderground.asm` +- Blocks: `maps/GoldenrodUnderground.blk` +- Header: `data/maps/maps.asm:123` -> `TILESET_GATE, DUNGEON, LANDMARK_GOLDENROD_CITY, MUSIC_UNION_CAVE, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:110` -> `map_const GOLDENROD_UNDERGROUND, 15, 18` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 2 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 7 | +| 2 | 3 | 34 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 4 | +| 3 | 18 | 6 | `GOLDENROD_UNDERGROUND` | 4 | +| 4 | 21 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 5 | 22 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 6 | 22 | 27 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 1 | + +Warp 3 at (18,6) is the basement door; it is blocked until `BASEMENT_KEY`. + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 18 | 6 | `BGEVENT_READ` | `BasementDoorScript` | +| 19 | 6 | `BGEVENT_READ` | `GoldenrodUndergroundNoEntrySign` | +| 6 | 13 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenParlyzHeal` (`PARLYZ_HEAL`, `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL`) | +| 4 | 18 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenSuperPotion` (`SUPER_POTION`, `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION`) | +| 17 | 8 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenAntidote` (`ANTIDOTE`, `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE`) | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `GOLDENRODUNDERGROUND_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 31 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSupernerdEric` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 6 | 9 | `STANDING_UP` | `OBJECTTYPE_TRAINER` | 2 | `TrainerSupernerdTeru` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD3` | `SPRITE_SUPER_NERD` | 3 | 27 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerPokemaniacIssac` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD4` | `SPRITE_SUPER_NERD` | 2 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPokemaniacDonald` | -1 | +| `GOLDENRODUNDERGROUND_POKE_BALL` | `SPRITE_POKE_BALL` | 7 | 25 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `GoldenrodUndergroundCoinCase` (`itemball COIN_CASE`) | `EVENT_GOLDENROD_UNDERGROUND_COIN_CASE` | +| `GOLDENRODUNDERGROUND_GRAMPS` | `SPRITE_GRAMPS` | 7 | 11 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | 0 | `BargainMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRAMPS` | +| `GOLDENRODUNDERGROUND_OLDER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 14 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | 0 | `OlderHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 15 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | 0 | `YoungerHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_GRANNY` | `SPRITE_GRANNY` | 7 | 21 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | 0 | `BitterMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRANNY` | + +**Scripts of interest** + +- `GoldenrodUndergroundResetSwitchesCallback` (`MAPCALLBACK_NEWMAP`): clears + `EVENT_SWITCH_1..3`, `EVENT_EMERGENCY_SWITCH`, `EVENT_DOOR_1_OPEN`..`_11_OPEN` + and zeroes `wUndergroundSwitchPositions`. Runs every entry. +- `GoldenrodUndergroundCheckBasementKeyCallback` (`MAPCALLBACK_TILES`): unless + `EVENT_USED_BASEMENT_KEY`, `changeblock 18, 6, $3d` (locked door). This is the + "door is locked" the walkthrough hits. +- `BasementDoorScript` (bg at 18,6): `checkitem BASEMENT_KEY` -> + `changeblock 18, 6, $2e`, `refreshmap`, `setevent EVENT_USED_BASEMENT_KEY`. + The `BASEMENT_KEY` is not obtainable in this section. +- `GoldenrodUndergroundCheckDayOfWeekCallback` (`MAPCALLBACK_OBJECTS`): the + weekday schedule for the four shop NPCs - + Sunday: younger barber + granny; Monday morn: gramps (bargain merchant); + Tue/Thu: older barber; Wed/Fri: younger barber; Sat: older barber + granny. +- `BargainMerchantScript`: Monday `MORN` only, and only while + `ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED` is clear -> + `pokemart MARTTYPE_BARGAIN, 0`. +- `BitterMerchantScript`: Saturday or Sunday -> `pokemart MARTTYPE_BITTER, MART_UNDERGROUND`. +- `OlderHaircutBrotherScript` / `YoungerHaircutBrotherScript`: 500 / 300 money, + one per day via `ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT`, + `special OlderHaircutBrother` / `YoungerHaircutBrother`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `COIN_CASE` | item ball at (7,25) | `GoldenrodUndergroundCoinCase` | `EVENT_GOLDENROD_UNDERGROUND_COIN_CASE` (`constants/event_flags.asm:1043`) | +| `PARLYZ_HEAL` | hidden at (6,13) | `GoldenrodUndergroundHiddenParlyzHeal` | `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL` | +| `SUPER_POTION` | hidden at (4,18) | `GoldenrodUndergroundHiddenSuperPotion` | `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION` | +| `ANTIDOTE` | hidden at (17,8) | `GoldenrodUndergroundHiddenAntidote` | `EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `ERIC` | `SUPER_NERD` | SUPER_NERD (2) | L11 Grimer, L11 Grimer | `TrainerSupernerdEric` | none | +| `ISSAC` | `POKEMANIAC` | POKEMANIAC (11) | L12 Lickitung (Lick, Supersonic, Cut) `TRAINERTYPE_MOVES` | `TrainerPokemaniacIssac` | none | +| `TERU` | `SUPER_NERD` | SUPER_NERD (10) | L7 Magnemite, L11 Voltorb, L7 Magnemite, L9 Magnemite | `TrainerSupernerdTeru` | none | +| `DONALD` | `POKEMANIAC` | POKEMANIAC (12) | L10 Slowpoke, L10 Slowpoke | `TrainerPokemaniacDonald` | none | + +The walkthrough spells the Pokemaniac "Isaac"; the asm constant and party name +are `ISSAC` (two S). Its event flag is `EVENT_BEAT_POKEMANIAC_ISSAC`. + +**Wild encounters**: none. + +--- + +### MAP_GOLDENROD_BIKE_SHOP + +- Script: `maps/GoldenrodBikeShop.asm` +- Header: `data/maps/maps.asm:271` -> `TILESET_CHAMPIONS_ROOM, INDOOR, LANDMARK_GOLDENROD_CITY, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:249` -> `map_const GOLDENROD_BIKE_SHOP, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 2 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 2 | + +**BG events**: nine `GoldenrodBikeShopBicycle` display signs at +(1,2), (0,3), (1,3), (0,5), (1,5), (0,6), (1,6), (6,6), (7,6). + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODBIKESHOP_CLERK` | `SPRITE_CLERK` | 7 | 2 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `GoldenrodBikeShopClerkScript` | -1 | + +**Scripts of interest** + +- `GoldenrodBikeShopClerkScript`: `checkevent EVENT_GOT_BICYCLE`, else + intro text + `yesorno`, `giveitem BICYCLE`, `itemnotify`, + `setflag ENGINE_BIKE_SHOP_CALL_ENABLED`, `setevent EVENT_GOT_BICYCLE`. + No badge, money or item prerequisite. Note it is a plain `giveitem`, not + `verbosegiveitem`, and there is no bag-full branch. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BICYCLE` | say YES to the clerk at (7,2) | `GoldenrodBikeShopClerkScript` | `EVENT_GOT_BICYCLE` (`constants/event_flags.asm:100`) | + +--- + +### MAP_RADIO_TOWER_1F + +- Script: `maps/RadioTower1F.asm` +- Blocks: `maps/RadioTower1F.blk` +- Header: `data/maps/maps.asm:95` -> `TILESET_RADIO_TOWER, INDOOR, LANDMARK_RADIO_TOWER, RADIO_TOWER_MUSIC | MUSIC_GOLDENROD_CITY, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:82` -> `map_const RADIO_TOWER_1F, 9, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 12 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 12 | +| 3 | 15 | 0 | `RADIO_TOWER_2F` | 2 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 0 | `BGEVENT_READ` | `RadioTower1FDirectory` | +| 13 | 0 | `BGEVENT_READ` | `RadioTower1FLuckyChannelSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 5 | 6 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `RadioTower1FReceptionistScript` | -1 | +| `..._LASS` | `SPRITE_LASS` | 16 | 4 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `RadioTower1FLassScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `..._YOUNGSTER` | `SPRITE_YOUNGSTER` | 15 | 4 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `RadioTower1FYoungsterScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `..._ROCKET` | `SPRITE_ROCKET` | 14 | 1 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerGruntM3` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._GENTLEMAN` | `SPRITE_GENTLEMAN` | 8 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RadioTower1FLuckyNumberManScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `..._COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 12 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RadioTower1FRadioCardWomanScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | + +**Scripts of interest** + +`RadioTower1FRadioCardWomanScript` (object at 12,6 - the woman on the right): +`checkflag ENGINE_RADIO_CARD` short-circuits. Otherwise a five-question +`yesorno` chain; a wrong answer jumps to `.WrongAnswer` and the whole quiz +restarts on the next talk. Required answers, straight off the `iffalse`/`iftrue` +branches: + +| # | question text label | required answer | branch | +|---|---|---|---| +| 1 | `RadioTower1FRadioCardWomanQuestion1Text` (Town Map on Pokegear?) | YES | `iffalse .WrongAnswer` | +| 2 | `...Question2Text` (Nidorina female only?) | YES | `iffalse .WrongAnswer` | +| 3 | `...Question3Text` (Kurt uses Apricorn?) | NO | `iftrue .WrongAnswer` | +| 4 | `...Question4Text` (Magikarp learns no TM?) | YES | `iffalse .WrongAnswer` | +| 5 | `...Question5Text` (is MARIE the co-host?) | NO | `iftrue .WrongAnswer` | + +On success: `getstring STRING_BUFFER_4, .RadioCardText` (`"RADIO CARD@"`), +`scall .ReceiveItem` (`jumpstd ReceiveItemScript`), then +`setflag ENGINE_RADIO_CARD`. The Radio Card is a Pokegear card flag, not a bag +item. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| Radio Card (Pokegear card) | pass the five-question quiz | `RadioTower1FRadioCardWomanScript` | `ENGINE_RADIO_CARD` (`constants/engine_flags.asm:4`) | + +--- + +### MAP_GOLDENROD_GYM + +- Script: `maps/GoldenrodGym.asm` +- Blocks: `maps/GoldenrodGym.blk` +- Header: `data/maps/maps.asm:270` -> `TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_GOLDENROD_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:248` -> `map_const GOLDENROD_GYM, 10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 17 | `GOLDENROD_CITY` | 1 | +| 2 | 3 | 17 | `GOLDENROD_CITY` | 1 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_GOLDENRODGYM_WHITNEY_STOPS_CRYING` | 8 | 5 | `WhitneyCriesScript` | Bridget walks over, `clearevent EVENT_MADE_WHITNEY_CRY`, `setscene SCENE_GOLDENRODGYM_NOOP` | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 15 | `BGEVENT_READ` | `GoldenrodGymStatue` | +| 4 | 15 | `BGEVENT_READ` | `GoldenrodGymStatue` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `GOLDENRODGYM_WHITNEY` | `SPRITE_WHITNEY` | 8 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `GoldenrodGymWhitneyScript` | -1 | +| `GOLDENRODGYM_LASS1` | `SPRITE_LASS` | 9 | 13 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerLassCarrie` | -1 | +| `GOLDENRODGYM_LASS2` | `SPRITE_LASS` | 9 | 6 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerLassBridget` | -1 | +| `GOLDENRODGYM_BEAUTY1` | `SPRITE_BEAUTY` | 0 | 2 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBeautyVictoria` | -1 | +| `GOLDENRODGYM_BEAUTY2` | `SPRITE_BEAUTY` | 19 | 5 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBeautySamantha` | -1 | +| `GOLDENRODGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 5 | 15 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `GoldenrodGymGuideScript` | -1 | + +**Scripts of interest** + +`GoldenrodGymWhitneyScript` is a three-visit script; a bot must run it in order: + +1. First talk (`EVENT_BEAT_WHITNEY` clear): before-text, + `winlosstext WhitneyShouldntBeSoSeriousText, 0`, + `loadtrainer WHITNEY, WHITNEY1`, `startbattle`, `reloadmapafterbattle`. + On win it sets `EVENT_BEAT_WHITNEY`, `EVENT_MADE_WHITNEY_CRY`, + `setscene SCENE_GOLDENRODGYM_WHITNEY_STOPS_CRYING`, and marks all four gym + trainers beaten (`EVENT_BEAT_BEAUTY_VICTORIA`, `EVENT_BEAT_BEAUTY_SAMANTHA`, + `EVENT_BEAT_LASS_CARRIE`, `EVENT_BEAT_LASS_BRIDGET`). +2. Falls into `.FightDone` immediately: `checkevent EVENT_MADE_WHITNEY_CRY` + `iffalse .StoppedCrying`. While the flag is set, Whitney only prints + `WhitneyYouMeanieText` and **gives nothing**. +3. Stepping on the coord event at **(8,5)** runs `WhitneyCriesScript`, which + `clearevent EVENT_MADE_WHITNEY_CRY` and `setscene SCENE_GOLDENRODGYM_NOOP`. + Talk to Whitney again -> `.StoppedCrying`: `setflag ENGINE_PLAINBADGE`, + `readvar VAR_BADGES`, `scall GoldenrodGymActivateRockets`, then + `verbosegiveitem TM_ATTRACT` guarded by `iffalse .NoRoomForAttract` and + `setevent EVENT_GOT_TM45_ATTRACT`. + +`GoldenrodGymActivateRockets`: `ifequal 7, .RadioTowerRockets`, +`ifequal 6, .GoldenrodRockets` on the post-badge count. With PLAINBADGE as the +third badge neither branch fires here. + +`GoldenrodGymStatue`: `checkflag ENGINE_PLAINBADGE` -> `GymStatue2Script` with +`gettrainername STRING_BUFFER_4, WHITNEY, WHITNEY1`, else `GymStatue1Script`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_WHITNEY` | `constants/event_flags.asm:708` | `GoldenrodGymWhitneyScript`, `GoldenrodGymGuideScript` | battle done | +| `EVENT_MADE_WHITNEY_CRY` | `constants/event_flags.asm:49` | set by the win, cleared by `WhitneyCriesScript` | **blocks the badge** while set | +| `SCENE_GOLDENRODGYM_WHITNEY_STOPS_CRYING` | `maps/GoldenrodGym.asm:12` | `setscene` in the win path, `SCENE_GOLDENRODGYM_NOOP` after | arms the (8,5) trip-wire | +| `ENGINE_PLAINBADGE` | `constants/engine_flags.asm:40` | set in `.StoppedCrying`; read by `StrengthFunction.TryStrength` and `FlowerShopTeacherScript` | Strength field move + Squirtbottle | +| `EVENT_GOT_TM45_ATTRACT` | `constants/event_flags.asm:17` | `.StoppedCrying` | one-shot TM45 | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| PLAINBADGE | second talk after the cry scene | `GoldenrodGymWhitneyScript` `.StoppedCrying` | `ENGINE_PLAINBADGE` | +| `TM_ATTRACT` (TM45) | same conversation | `GoldenrodGymWhitneyScript` | `EVENT_GOT_TM45_ATTRACT` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `VICTORIA` | `BEAUTY` | BEAUTY (1) | L9 Sentret, L13 Sentret, L17 Sentret | `TrainerBeautyVictoria` | none | +| `SAMANTHA` | `BEAUTY` | BEAUTY (2) | L16 Meowth (Scratch, Growl, Bite, Pay Day), L16 Meowth (Scratch, Growl, Bite, Slash) | `TrainerBeautySamantha` | none | +| `CARRIE` | `LASS` | LASS (1) | L18 Snubbull (Scary Face, Charm, Bite, Lick) | `TrainerLassCarrie` | none | +| `BRIDGET` | `LASS` | LASS (2) | L15 Jigglypuff x3 | `TrainerLassBridget` | none | +| `WHITNEY1` | `WHITNEY` | WHITNEY (1), `data/trainers/parties.asm:20` | L18 Clefairy (DoubleSlap, Mimic, Encore, Metronome), L20 Miltank (Rollout, Attract, Stomp, Milk Drink) | `GoldenrodGymWhitneyScript` | none | + +--- + +### Supporting Goldenrod interiors (short form) + +**`GOLDENROD_POKECENTER_1F`** (`maps/GoldenrodPokecenter1F.asm`, header +`data/maps/maps.asm:276`, `map_const GOLDENROD_POKECENTER_1F, 5, 4`): +warps (3,7)/(4,7) -> city 7, (0,7) -> `POKECENTER_2F` 1. Nurse at (3,1). + +**`GOLDENROD_DEPT_STORE_1F`** (`maps/GoldenrodDeptStore1F.asm`, header +`data/maps/maps.asm:279`, `map_const GOLDENROD_DEPT_STORE_1F, 8, 4`): +warps (7,7)/(8,7) -> city 10, (15,0) -> 2F warp 2, (2,0) -> elevator warp 1. + +Mart stock (`data/items/marts.asm`): + +| floor | mart const | items | +|---|---|---| +| 2F | `MART_GOLDENROD_2F_1` / `_2` | `MartGoldenrod2F2`: Poke Ball, Great Ball, Escape Rope, Repel, Revive, Full Heal, Poke Doll, Flower Mail (plus the `_1` heal list) | +| 3F | `MART_GOLDENROD_3F` | X Speed, X Special, X Defend, X Attack, Dire Hit, Guard Spec., X Accuracy | +| 4F | `MART_GOLDENROD_4F` | Protein, Iron, Carbos, Calcium, **HP Up** | +| 5F | `MART_GOLDENROD_5F_1..4` | always TM41 ThunderPunch, TM48 Fire Punch, TM33 Ice Punch; **+ TM02 Headbutt once `EVENT_GOT_TM02_HEADBUTT`**, **+ TM08 Rock Smash once `EVENT_GOT_TM08_ROCK_SMASH`** (`GoldenrodDeptStore5FClerkScript` picks 1/2/3/4) | +| 6F | vending machines | `giveitem FRESH_WATER` / `SODA_POP` / `LEMONADE` (`maps/GoldenrodDeptStore6F.asm`) | + +`GoldenrodDeptStore5F` also holds: +- `GoldenrodDeptStore5FCheckIfSundayCallback` (`MAPCALLBACK_OBJECTS`) - + the receptionist at (7,5) only appears on `SUNDAY`. +- `GoldenrodDeptStore5FReceptionistScript`: Sunday only, one-shot via + `ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN`. `special GetFirstPokemonHappiness`, + then `>=150` -> `verbosegiveitem TM_RETURN` (TM27), `>=50` -> nothing, + `<50` -> `verbosegiveitem TM_FRUSTRATION` (TM21). That is the walkthrough's + "Sunday giveaway of TMs" and where TM21/TM27 come from. +- `Mike` at (6,3): NPC trade row 1 in `data/events/npc_trades.asm` - + `npctrade TRADE_DIALOGSET_COLLECTOR, DROWZEE, MACHOP, "MUSCLE", $37, $66, GOLD_BERRY, 37460, "MIKE", TRADE_GENDER_EITHER`, + i.e. give Drowzee, get Machop. That is the walkthrough's Machop-by-trading. + +**`GOLDENROD_GAME_CORNER`** (`maps/GoldenrodGameCorner.asm`, header +`data/maps/maps.asm:286`, `map_const GOLDENROD_GAME_CORNER, 10, 7`): +warps (2,13)/(3,13) -> city 11. Prize prices are `DEF`s at the top of the file: + +| prize | coins | const | +|---|---|---| +| Abra (L10) | 200 | `GOLDENRODGAMECORNER_ABRA_COINS` | +| Ekans (L10) | 700 | `GOLDENRODGAMECORNER_EKANS_COINS` (Gold; Silver sells Sandshrew at `..._SANDSHREW_COINS`, also 700) | +| Dratini (L10) | 2100 | `GOLDENRODGAMECORNER_DRATINI_COINS` | +| TM25 Thunder | 5500 | `GOLDENRODGAMECORNER_TM25_COINS` | +| TM14 Blizzard | 5500 | `GOLDENRODGAMECORNER_TM14_COINS` | +| TM38 Fire Blast | 5500 | `GOLDENRODGAMECORNER_TM38_COINS` | + +Mon prizes check `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH` and run +`special GameCornerPrizeMonCheckDex` before `givepoke`. + +**`GOLDENROD_MAGNET_TRAIN_STATION`** (`maps/GoldenrodMagnetTrainStation.asm`, +header `data/maps/maps.asm:274`, `map_const GOLDENROD_MAGNET_TRAIN_STATION, 10, 9`): +warps (8,17)/(9,17) -> city 5; (6,5)/(11,5) -> `SAFFRON_MAGNET_TRAIN_STATION` +4/3. Coord event at (11,6) for `SCENE_GOLDENRODMAGNETTRAINSTATION_ARRIVE_FROM_SAFFRON`. +The conductor is `GoldenrodMagnetTrainStationOfficerScript` at (9,9). + +**`GOLDENROD_FLOWER_SHOP`** (`maps/GoldenrodFlowerShop.asm`, city warp 6 at +(33,5)): `FlowerShopTeacherScript` gives `SQUIRTBOTTLE` gated on +`checkflag ENGINE_PLAINBADGE` + `EVENT_GOT_SQUIRTBOTTLE`. The walkthrough's +"Squirtwater" is this item, and it becomes available only **after** Whitney. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Azalea rival battle | `maps/AzaleaTown.asm` `def_coord_events` (5,10)/(5,11) + `SCENE_AZALEATOWN_RIVAL_BATTLE` | scene armed by `maps/SlowpokeWellB1F.asm:58` | clear Slowpoke Well; battle sets `EVENT_RIVAL_AZALEA_TOWN` and `setscene SCENE_AZALEATOWN_NOOP` | +| Farfetch'd quest exists at all | `maps/SlowpokeWellB1F.asm:60-63` (`clearevent EVENT_ILEX_FOREST_FARFETCHD_1`, `clearevent EVENT_ILEX_FOREST_APPRENTICE`) | `EVENT_CLEARED_SLOWPOKE_WELL` | same scene | +| Ilex Forest cut tree | block `$0f` at Ilex Forest block (4,12) = map (8-9, 24-25); `data/collision/field_move_blocks.asm` `.forest` | a party mon with `CUT` **and** `ENGINE_HIVEBADGE` | `engine/events/overworld.asm` `CutFunction.CheckAble` (`ld de, ENGINE_HIVEBADGE / call CheckBadge`) and `TryCutOW` (`CheckPartyMove` then `CheckEngineFlag ENGINE_HIVEBADGE`) | +| Getting HM01 in the first place | `maps/IlexForest.asm` `IlexForestCharcoalMasterScript`; the master object only spawns after `FarfetchdPosition9` runs `appear 13` + `setevent EVENT_CHARCOAL_KILN_BOSS` | complete the 9-step Farfetch'd herd | `EVENT_HERDED_FARFETCHD`, then `EVENT_GOT_HM01_CUT` | +| Whitney's badge | `maps/GoldenrodGym.asm` `GoldenrodGymWhitneyScript` `.FightDone` -> `checkevent EVENT_MADE_WHITNEY_CRY` `iffalse .StoppedCrying` | `EVENT_MADE_WHITNEY_CRY` must be **clear** | step on the coord event at (8,5) to run `WhitneyCriesScript` | +| Strength field move | `engine/events/overworld.asm` `StrengthFunction.TryStrength` (`ld de, ENGINE_PLAINBADGE / call CheckBadge`) | PLAINBADGE | beat Whitney and collect the badge | +| Squirtbottle | `maps/GoldenrodFlowerShop.asm` `FlowerShopTeacherScript` (`checkflag ENGINE_PLAINBADGE / iffalse .Lalala`) | PLAINBADGE | after Whitney; needed for Sudowoodo next section | +| Route 34 beach trainers + `SOFT_SAND` | water tiles between (3..11, 44..51); `SurfFunction.TrySurf` needs `ENGINE_FOGBADGE` | SURF + FOGBADGE | not reachable in this section | +| Goldenrod Underground basement door | `maps/GoldenrodUnderground.asm` `GoldenrodUndergroundCheckBasementKeyCallback` (`changeblock 18, 6, $3d`) and `BasementDoorScript` (`checkitem BASEMENT_KEY`) | `BASEMENT_KEY` | Rocket-takeover arc, not this section | +| Bill / Eevee | `maps/BillsFamilysHouse.asm` object flag `EVENT_MET_BILL` on `BILLSFAMILYSHOUSE_BILL` | meet Bill in Ecruteak | not this section - only the phone number is available now | +| Game Corner prizes | `maps/GoldenrodGameCorner.asm` prize vendors + coins | `COIN_CASE` | item ball at Goldenrod Underground (7,25) | +| Underground salon / bargain / bitter shops | `GoldenrodUndergroundCheckDayOfWeekCallback` + each script's `readvar VAR_WEEKDAY` | correct weekday (and `MORN` for the bargain merchant) | real-clock dependent | +| Officer Keith battle | `maps/Route34.asm` `OfficerKeithScript` `checktime NITE` | night | real-clock dependent | +| Fly to Goldenrod | `GoldenrodCityFlypointCallback` | none | fires on first map entry | + +--- + +## 4. Bot checklist + +Each row: map, target, intent, precondition, postcondition. + +1. `AZALEA_TOWN` - walk to (5,10) or (5,11). Intent: walk (trip-wire). + Pre: `SCENE_AZALEATOWN_RIVAL_BATTLE`. Post: rival battle + (`RIVAL1`, id `RIVAL1_2_<not-your-starter>`), `EVENT_RIVAL_AZALEA_TOWN`, + scene back to `SCENE_AZALEATOWN_NOOP`. +2. `AZALEA_TOWN` - warp 7 at (2,10). Intent: walk. Post: `ILEX_FOREST_AZALEA_GATE`. +3. `ILEX_FOREST_AZALEA_GATE` - warp 1 at (0,4). Post: `ILEX_FOREST` at (3,42). +4. `ILEX_FOREST` - walk to the apprentice `ILEXFOREST_YOUNGSTER` at (7,28), + talk. Pre: `EVENT_ILEX_FOREST_APPRENTICE` clear (object visible). Post: none + (text only). +5. `ILEX_FOREST` - talk to `ILEXFOREST_FARFETCHD1` at (14,31). Post: bird moves + to (15,25), `EVENT_ILEX_FOREST_FARFETCHD_2`. +6. `ILEX_FOREST` - repeat: walk to the current bird, **face it from the side the + fall-through branch wants**, talk. Use the Pos2..Pos9 table in the map section + above; the safe rule is "never face the direction listed as a back-branch for + that position". Coordinates: Pos2 (15,25), Pos3 (20,24), Pos4 (29,22), + Pos5 (28,31), Pos6 (24,35), Pos7 (22,31), Pos8 (15,29), Pos9 (10,35). + Post at Pos9 fall-through: `EVENT_HERDED_FARFETCHD`, + `EVENT_CHARCOAL_KILN_BOSS`, bird at (6,28), master appears at (5,28). +7. `ILEX_FOREST` - grab the item ball at (20,32). Intent: walk onto / A. + Post: `REVIVE`, `EVENT_ILEX_FOREST_REVIVE`. +8. `ILEX_FOREST` - talk to `ILEXFOREST_BLACK_BELT` at (5,28). + Pre: `EVENT_HERDED_FARFETCHD`. Post: `HM_CUT`, `EVENT_GOT_HM01_CUT`, + apprentice/master/bird despawn here and respawn in the Charcoal Kiln. +9. Teach `CUT` to a party mon (menu). Pre: `HM_CUT` in bag. +10. `ILEX_FOREST` - face the tree at map (8,24) (block (4,12)) and press A, or + use CUT from the pack. Pre: `ENGINE_HIVEBADGE` + party mon with CUT. + Post: block replaced with `$17`, path north opens. +11. `ILEX_FOREST` - optional: hidden Super Potion at (17,7), hidden Full Heal at + (9,17), hidden Ether at (27,1); read the shrine bg at (8,22). +12. `ILEX_FOREST` - talk to `ILEXFOREST_ROCKER` at (15,14). + Post: `TM_HEADBUTT`, `EVENT_GOT_TM02_HEADBUTT`. +13. `ILEX_FOREST` - warp 1 at (1,5). Post: `ROUTE_34_ILEX_FOREST_GATE` at (4,7). +14. `ROUTE_34_ILEX_FOREST_GATE` - talk to the teacher at (9,3). + Post: `TM_SWEET_SCENT`, `EVENT_GOT_TM12_SWEET_SCENT`. +15. `ROUTE_34_ILEX_FOREST_GATE` - warp 1 at (4,0). Post: `ROUTE_34` at (13,37). +16. `ROUTE_34` - Youngster Samuel at (15,33), sight 2 facing random (spinner); + battle. Post: `EVENT_BEAT_YOUNGSTER_SAMUEL`. +17. `ROUTE_34` - PokeFan M Brandon at (19,28), sight 2 facing LEFT. + Post: `EVENT_BEAT_POKEFANM_BRANDON`. +18. `ROUTE_34` - Youngster Ian at (17,22), sight 3 facing DOWN. + Post: `EVENT_BEAT_YOUNGSTER_IAN`. (No phone number - see Unresolved.) +19. `ROUTE_34` - Picnicker Gina at (10,26), sight 3 facing RIGHT. + Post: `EVENT_BEAT_PICNICKER_GINA`; talk again for + `askforphonenumber PHONE_PICNICKER_GINA`. +20. `ROUTE_34` - warp 3 at (11,14) into `DAY_CARE`; talk to the man at (2,3) + and/or the lady at (5,3) to deposit. Post: `ENGINE_DAY_CARE_MAN_HAS_MON` / + `ENGINE_DAY_CARE_LADY_HAS_MON`. +21. `ROUTE_34` - after walking, if `ENGINE_DAY_CARE_MAN_HAS_EGG`, the man stands + at (15,16); talk and answer YES. Post: egg in party, + `ENGINE_DAY_CARE_MAN_HAS_EGG` cleared. +22. `ROUTE_34` - optional night detour: Officer Keith at (9,11), talk. + Pre: `checktime NITE`. Post: `EVENT_BEAT_OFFICER_KEITH`. +23. `ROUTE_34` - Camper Todd at (13,7), sight 5 facing LEFT. + Post: `EVENT_BEAT_CAMPER_TODD`; talk again for + `askforphonenumber PHONE_CAMPER_TODD`. +24. `ROUTE_34` - walk north off the top of the map. Post: `GOLDENROD_CITY` + (connection offset -5), `ENGINE_FLYPOINT_GOLDENROD`, `ENGINE_REACHED_GOLDENROD`. +25. `GOLDENROD_CITY` - warp 7 at (15,27) to heal. +26. `GOLDENROD_CITY` - warp 10 at (24,27) for the Dept Store (optional). +27. `GOLDENROD_CITY` - warp 4 at (5,25) into `BILLS_FAMILYS_HOUSE`; talk to the + twin at (5,4). Post: `PHONE_BILL` registered. +28. `GOLDENROD_CITY` - warp 15 at (11,29) -> switch-room (4,29); walk north to + warp 4 at (5,25) -> `GOLDENROD_UNDERGROUND` at (3,34). +29. `GOLDENROD_UNDERGROUND` - Super Nerd Eric at (5,31) sight 3 facing LEFT. + Post: `EVENT_BEAT_SUPER_NERD_ERIC`. +30. `GOLDENROD_UNDERGROUND` - Pokemaniac Issac at (3,27), sight 2, spins. + Post: `EVENT_BEAT_POKEMANIAC_ISSAC`. +31. `GOLDENROD_UNDERGROUND` - item ball at (7,25). Post: `COIN_CASE`, + `EVENT_GOLDENROD_UNDERGROUND_COIN_CASE`. +32. `GOLDENROD_UNDERGROUND` - Super Nerd Teru at (6,9), sight 2 facing UP. + Post: `EVENT_BEAT_SUPER_NERD_TERU`. +33. `GOLDENROD_UNDERGROUND` - Pokemaniac Donald at (2,6), sight 3 facing RIGHT. + Post: `EVENT_BEAT_POKEMANIAC_DONALD`. +34. `GOLDENROD_UNDERGROUND` - optional: read the basement door bg at (18,6) + (locked; `BASEMENT_KEY` not available yet). Hidden items at (6,13), (4,18), + (17,8). +35. `GOLDENROD_UNDERGROUND` - warp 1 at (3,2) -> switch-room (21,25) -> + warp 8 at (20,29) -> `GOLDENROD_CITY` (9,5). +36. `GOLDENROD_CITY` - warp 2 at (29,29) into `GOLDENROD_BIKE_SHOP`; talk to the + clerk at (7,2) and answer YES. Post: `BICYCLE`, `EVENT_GOT_BICYCLE`, + `ENGINE_BIKE_SHOP_CALL_ENABLED`. +37. `GOLDENROD_CITY` - warp 5 at (9,13) for the train station (flavour only). +38. `GOLDENROD_CITY` - warp 12 at (5,15) into `RADIO_TOWER_1F`; talk to the + woman at (12,6) and answer YES, YES, NO, YES, NO. + Post: `ENGINE_RADIO_CARD`. +39. `GOLDENROD_CITY` - heal (warp 7), then warp 1 at (24,7) into `GOLDENROD_GYM`. +40. `GOLDENROD_GYM` - Beauty Victoria at (0,2), sight 3 facing DOWN. + Post: `EVENT_BEAT_BEAUTY_VICTORIA`. +41. `GOLDENROD_GYM` - Beauty Samantha at (19,5), sight 3 facing DOWN. + Post: `EVENT_BEAT_BEAUTY_SAMANTHA`. +42. `GOLDENROD_GYM` - Lass Carrie at (9,13), sight 4 facing RIGHT. + Post: `EVENT_BEAT_LASS_CARRIE`. +43. `GOLDENROD_GYM` - Lass Bridget at (9,6), sight 1 facing LEFT. + Post: `EVENT_BEAT_LASS_BRIDGET`. +44. `GOLDENROD_GYM` - talk to Whitney at (8,3). Post: `EVENT_BEAT_WHITNEY`, + `EVENT_MADE_WHITNEY_CRY`, `SCENE_GOLDENRODGYM_WHITNEY_STOPS_CRYING`. +45. `GOLDENROD_GYM` - **walk onto (8,5)**. Intent: walk (trip-wire). + Post: `WhitneyCriesScript`, `EVENT_MADE_WHITNEY_CRY` cleared, + `SCENE_GOLDENRODGYM_NOOP`. +46. `GOLDENROD_GYM` - talk to Whitney at (8,3) again. + Post: `ENGINE_PLAINBADGE`, `TM_ATTRACT`, `EVENT_GOT_TM45_ATTRACT`. +47. Optional after the badge: `GOLDENROD_CITY` warp 6 at (33,5) -> + `GOLDENROD_FLOWER_SHOP`, talk to the teacher. Post: `SQUIRTBOTTLE`, + `EVENT_GOT_SQUIRTBOTTLE` (needed for the next section's Sudowoodo). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers / dimensions / connections for every map above | `src/import/RomExtractorGen2.lua` (`readMapGroupEntry`, `mapNameByIds`), `src/world/gen2/Map.lua` | implemented (generic, ROM-driven; nothing map-specific is hand-ported) | +| `def_warp_events` / `def_coord_events` / `def_bg_events` / `def_object_events` decode | `src/import/RomExtractorGen2.lua:804` (`coordEvents`), `:861`, `:974` | implemented | +| Coord-event trip-wires (Azalea rival, Whitney cries) | `src/world/gen2/World.lua:5013` (iterates `self.map.def.coordEvents`) | implemented | +| Scene ids / `setscene` / `setmapscene` | `src/script/gen2/Opcodes.lua` (`0x11`-`0x14`), `src/script/gen2/Vm.lua` | implemented | +| Script VM opcodes used by this section (`verbosegiveitem`, `loadtrainer`, `startbattle`, `winlosstext`, `setlasttalked`, `applymovement`, `moveobject`, `turnobject`, `appear`/`disappear`, `showemote`, `changeblock`, `refreshmap`, `givepoke`, `giveitem`, `checkcoins`/`takecoins`, `askforphonenumber`, `addcellnum`/`checkcellnum`, `giveegg`, `pokemart`, `fruittree`, `checktime`, `readvar`) | `src/script/gen2/Opcodes.lua`, `src/script/gen2/Vm.lua` | implemented (all present) | +| `readvar VAR_FACING` (the whole Farfetch'd branch logic) | `src/world/gen2/World.lua:95,1219` | implemented | +| Item balls / hidden items | `src/import/RomExtractorGen2.lua:2874,2882` (`readItemBall`, `hiddenitem`), `src/world/gen2/HiddenItems.lua` | implemented | +| Trainer sight lines and `trainer` struct | `src/world/gen2/Trainers.lua:98` (`Trainers.sees`), extractor `:2961` | implemented | +| CUT badge gate + A-press cut (`TryCutOW` / `CutFunction`) | `src/world/gen2/FieldMoves.lua:104` (`CUT = "HIVE"`), `:449`, `:609`; `src/world/gen2/World.lua:4203` (`World:tryCutOW`), `:3965` (`World:runCut`), `:3872` (`CutDownTreeOrGrass`) | implemented | +| STRENGTH badge gate (`PLAINBADGE`) | `src/world/gen2/FieldMoves.lua:108`, `:519` | implemented | +| Bicycle | `src/world/gen2/Bike.lua`, `src/world/gen2/World.lua:1790` | implemented | +| Coin Case / Game Corner slots and prizes | `src/core/gen2/CoinCase.lua`, `src/ui/gen2/SlotMachine.lua`, `src/ui/gen2/PrizeMenu.lua`, `src/script/gen2/Specials.lua:964` (`GameCornerPrizeMonCheckDex`) | implemented | +| Day-Care / breeding / egg handoff (`DayCareMan`, `DayCareLady`, `DayCareManOutside`, `DayCareMon1/2`) | `src/core/gen2/Breeding.lua`, `src/script/gen2/Specials.lua:526-596`, `src/ui/gen2/DayCareMenu.lua`; driver `tests/drivers/gold_egg_hatch.lua` | implemented | +| Phone numbers (Bill, Todd, Gina) | `src/core/gen2/Phone.lua`, `src/script/gen2/Opcodes.lua:156` | implemented | +| Pokegear Radio Card + radio channels | `src/ui/gen2/Pokegear.lua:39` (`{ id = "radio", ..., flag = "radio" }`), `:49` onward | implemented | +| Haircut brothers / weekday NPC schedule | `src/script/gen2/Specials.lua:1661-1662`, `src/world/gen2/World.lua:99` | implemented | +| NPC trade (Mike: Drowzee -> Machop) | `src/core/gen2/NpcTrade.lua`, `src/ui/gen2/TradeMenu.lua`, `src/ui/gen2/TradeAnim.lua` | implemented | +| Happiness -> TM27/TM21 Sunday lady | `src/core/gen2/Happiness.lua`, `src/script/gen2/Specials.lua:1506` (`GetFirstPokemonHappiness`) | implemented | +| Marts (`MARTTYPE_STANDARD` / `_BARGAIN` / `_BITTER`) | `src/ui/gen2/MartMenu.lua` | implemented (verify the bargain/bitter variants by hand - only the standard path has driver coverage) | +| Magnet train (locked here, station map only) | `src/core/gen2/MagnetTrain.lua`, `src/ui/gen2/MagnetTrainRide.lua` | implemented | +| End-to-end driver for this stretch | none - `tests/drivers/gold_*.lua` covers boot, walk, warp, battle, egg hatch, evolution, boulders (`gold_icepath_boulder.lua`), map callbacks | **missing** - there is no Ilex-Forest / Goldenrod driver; the Farfetch'd chase and the Whitney cry-then-badge sequence are unexercised | +| Farfetch'd chase specifically | no dedicated port file; runs on the generic VM | untested (no driver) | +| Whitney badge two-step (`EVENT_MADE_WHITNEY_CRY` + coord event) | generic VM + `World.lua:5013` | untested (no driver) | + +--- + +## 6. Unresolved / verify by hand + +Contradictions between the walkthrough text and `pokegold`: + +1. **"Bug Catcher Wayne" in Ilex Forest does not exist in Gold.** + `grep -rn WAYNE constants/ data/ maps/` returns only `BIKER DWAYNE` on + `maps/Route8.asm`. `maps/IlexForest.asm` has **zero** `OBJECTTYPE_TRAINER` + rows. Wayne (L8 Ledyba / L10 Paras) is Crystal-only content. +2. **"X Attack" and "Antidote" in Ilex Forest do not exist in Gold.** + The only items on the map are the `REVIVE` item ball at (20,32) and hidden + `ETHER` (27,1), `SUPER_POTION` (17,7), `FULL_HEAL` (9,17). The walkthrough's + own item list ("HM01, Revive, TM02, TM12") also omits them, so the prose + contradicts its own header. TM12 is in the gate map, not the forest. +3. **Ilex Forest species list.** The walkthrough lists Weedle (#013). In Gold + the morn/day slots are Caterpie/Metapod (`IF DEF(_GOLD)`); Weedle/Kakuna is + the `ELIF DEF(_SILVER)` arm. Oddish is nite-only. Metapod is Gold-only. +4. **"Youngster Ian will want to trade phone numbers with you."** + `TrainerYoungsterIan.Script` in `maps/Route34.asm` is just + `endifjustbattled` / `writetext YoungsterIanAfterText`. No `askforphonenumber`, + no `PHONE_*` constant. The Route 34 phone trainers are Camper Todd and + Picnicker Gina only. +5. **Rival party order.** The walkthrough prints Gastly / starter / Zubat; the + asm (`Rival1Group` entries 4-6) is Gastly (12), Zubat (14), starter (16). +6. **"There's a PC in the corner of the Daycare."** `maps/DayCare.asm` has two + bookshelf bg events and two NPC objects; no PC bg event or object. +7. **"Level 5: TM02, TM33, TM41, TM48"** is right in spirit but conditional: + `MART_GOLDENROD_5F_1` (the default) is only TM41 ThunderPunch, TM48 Fire + Punch, TM33 Ice Punch. TM02 Headbutt is added only after + `EVENT_GOT_TM02_HEADBUTT`, and TM08 Rock Smash after + `EVENT_GOT_TM08_ROCK_SMASH` (`GoldenrodDeptStore5FClerkScript`). +8. **Game Corner "Ekans/Sandshrew 700"** - the version split is real; Gold's + branch (`.Gold_Ekans`) sells Ekans. `GOLDENRODGAMECORNER_SANDSHREW_COINS` is + the Silver constant, also 700. +9. **"Get Eevee from Bill" is listed under Goldenrod** but `BILLSFAMILYSHOUSE_BILL` + carries the object flag `EVENT_MET_BILL`, which is set in Ecruteak. Only + `BillsYoungerSisterScript` (Bill's phone number) is reachable in this section. +10. **Prose ordering glitch in the walkthrough**: "Now head out of the gym and + we'll hit the east side of town" appears before the gym is ever entered. + Treat the bike shop / radio tower paragraphs as pre-gym. +11. **Whether the Azalea rival coord events at (5,10)/(5,11) can be walked + around** was not verified - it needs a collision read of `maps/AzaleaTown.blk` + plus the tileset collision table, which I did not decode. The two rows are + the only trip-wires on the map, so a bot should assume they can be dodged + and treat `EVENT_RIVAL_AZALEA_TOWN` as the real precondition for later + content rather than relying on the walk-west path. +12. **Exact walkable route through the Farfetch'd chase** (which tile to stand + on so the player's facing lands on the fall-through branch) was not derived; + only the branch table was read from the asm. A bot needs the collision map + of `maps/IlexForest.blk` to turn "face away from `<DIR>`" into concrete + approach tiles. +13. **`data/wild/fish.asm`** was searched for `ILEX_FOREST` / `ROUTE_34` and has + no per-map rows - fishing is selected by the header's `FISHGROUP_*` + (`FISHGROUP_POND` for Ilex Forest, `FISHGROUP_SHORE` for Route 34). The + per-group tables were not transcribed here. +14. **Exp/money numbers** quoted by the walkthrough (e.g. "You get: 960G") were + not verified; they are computed at runtime from base exp and level, not + stored in `data/trainers/parties.asm`. diff --git a/docs/gold-walkthrough/asm-walk/section-06-bug-catching-contest-and-sudowoodo.md b/docs/gold-walkthrough/asm-walk/section-06-bug-catching-contest-and-sudowoodo.md new file mode 100644 index 00000000..67d8205a --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-06-bug-catching-contest-and-sudowoodo.md @@ -0,0 +1,987 @@ +# Section 06 - Bug Catching Contest and Sudowoodo + +Source: `../section-06-bug-catching-contest-and-sudowoodo.txt` +Maps covered: `MAP_GOLDENROD_FLOWER_SHOP`, `MAP_ROUTE_35_GOLDENROD_GATE`, `MAP_ROUTE_35`, +`MAP_ROUTE_35_NATIONAL_PARK_GATE`, `MAP_NATIONAL_PARK`, `MAP_NATIONAL_PARK_BUG_CONTEST`, +`MAP_ROUTE_36_NATIONAL_PARK_GATE`, `MAP_ROUTE_36`, `MAP_ROUTE_37` (listed by the +walkthrough header, never entered by its route) + +Badges / key milestones in this section: + +- SQUIRTBOTTLE (key item) - gated behind `ENGINE_PLAINBADGE`, i.e. Whitney must already + be beaten before this section can be started at all. +- Bug-Catching Contest run (Tue/Thu/Sat only) - SUN STONE / EVERSTONE / GOLD BERRY / BERRY. +- `EVENT_FOUGHT_SUDOWOODO` - the single event that unblocks Route 36 west<->east, which + is the only land route between Goldenrod and Violet through Route 36. +- TM08 ROCK SMASH (`EVENT_GOT_TM08_ROCK_SMASH`), TM04 ROLLOUT, TM28 DIG, QUICK CLAW. +- No badge is earned in this section. + +Everything in this file was transcribed from files that were opened; nothing is +reconstructed from memory. Symbol addresses are `bank:addr` from +`pokegold-symbols/pokegold.sym`. + +--- + +## 1. Route order + +The walkthrough's own prose is out of order in one place: it opens by collecting the +SQUIRTBOTTLE in Goldenrod, then much later describes Floria running off to fetch it. +In pokegold the SQUIRTBOTTLE has no Route 36 precondition at all (see +"Unresolved / verify by hand"), so the opening order is the one a bot should follow. + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_GOLDENROD_FLOWER_SHOP` | `maps/GoldenrodFlowerShop.asm` | `GOLDENROD_CITY` warp 6 @ (33, 5) | warp 1/2 @ (2,7)/(3,7) -> `GOLDENROD_CITY` 6 | Talk to the teacher for the SQUIRTBOTTLE | +| 2 | `MAP_ROUTE_35_GOLDENROD_GATE` | `maps/Route35GoldenrodGate.asm` | `GOLDENROD_CITY` warp 13 @ (19, 1) -> gate warp 3 | warp 1/2 @ (4,0)/(5,0) -> `ROUTE_35` 1/2 | Randy hands over Kenya the SPEAROW with FLOWER MAIL | +| 3 | `MAP_ROUTE_35` | `maps/Route35.asm` | gate warp @ (9,33)/(10,33) | warp 3 @ (3, 5) -> `ROUTE_35_NATIONAL_PARK_GATE` 3 | Six trainers + Officer Dirk, walk north | +| 4 | `MAP_ROUTE_35_NATIONAL_PARK_GATE` | `maps/Route35NationalParkGate.asm` | warp 3/4 @ (3,7)/(4,7) | warp 1/2 @ (3,0)/(4,0) -> `NATIONAL_PARK` 3/4 | Contest officer; optional entry to the Contest | +| 5 | `MAP_NATIONAL_PARK_BUG_CONTEST` | `maps/NationalParkBugContest.asm` | script `warp NATIONAL_PARK_BUG_CONTEST, 10, 47` from the south gate (or `33, 18` from the north gate) | contest timer / balls exhausted, or either gate | Optional Tue/Thu/Sat Contest | +| 6 | `MAP_NATIONAL_PARK` | `maps/NationalPark.asm` | warp 3/4 @ (10,47)/(11,47) | warp 1/2 @ (33,18)/(33,19) -> `ROUTE_36_NATIONAL_PARK_GATE` 1/2 | QUICK CLAW, Beverly, Jack, Krise, William, TM28 DIG | +| 7 | `MAP_ROUTE_36_NATIONAL_PARK_GATE` | `maps/Route36NationalParkGate.asm` | warp 1/2 @ (0,4)/(0,5) | warp 3/4 @ (9,4)/(9,5) -> `ROUTE_36` 1/2 | Contest results / prize holding | +| 8 | `MAP_ROUTE_36` | `maps/Route36.asm` | warp 1/2 @ (18,8)/(18,9) | south connection @ y=17 -> `ROUTE_35`; later east connection -> `VIOLET_CITY` | Ice Berry, Psychic Mark, Schoolboy Alan, Sudowoodo, TM08 | +| 9 | `MAP_ROUTE_35` (again) | `maps/Route35.asm` | north connection from `ROUTE_36` (offset 0) | back north to `ROUTE_36` | Cut the tree, Bug Catcher Arnie, Bird Keeper Bryan, TM04 ROLLOUT | +| 10 | `MAP_ROUTE_36` (again) | `maps/Route36.asm` | north connection from `ROUTE_35` | **east connection -> `VIOLET_CITY`** | Sudowoodo cleared, then east into Violet City | + +Spills into the next section: step 10's east connection +(`connection east, VioletCity, VIOLET_CITY, 0` in `data/maps/attributes.asm`) drops the +player into `MAP_VIOLET_CITY`, which belongs to a neighbouring section. Route 37 +(`MAP_ROUTE_37`, north connection off Route 36 with offset 10) is named in this section's +header but the walkthrough never routes into it; its data is recorded below for +completeness because the item list ("Blk/Blu/Red Apricorn", "Ice Berry") mixes 36 and 37. + +--- + +## 2. Maps + +### MAP_GOLDENROD_FLOWER_SHOP + +- Script: `maps/GoldenrodFlowerShop.asm` +- Blocks: `maps/GoldenrodFlowerShop.blk` +- Header (`data/maps/maps.asm:275`): `TILESET_HOUSE, INDOOR, LANDMARK_GOLDENROD_CITY, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:253` -> `map_const GOLDENROD_FLOWER_SHOP, 4, 4` +- Connections: none (indoor) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 6 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 6 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) - none. (`FlowerShopShelf1`, `FlowerShopShelf2`, +`FlowerShopRadio` exist in the file but are marked `; unreferenced`.) + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODFLOWERSHOP_TEACHER` | `SPRITE_TEACHER` | 2 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `FlowerShopTeacherScript` | -1 | +| `GOLDENRODFLOWERSHOP_FLORIA` | `SPRITE_LASS` | 5 | 6 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `FlowerShopFloriaScript` | -1 | + +**Scripts of interest** + +- `FlowerShopTeacherScript` (`57:52d1`) - `checkevent EVENT_GOT_SQUIRTBOTTLE` / + `iftrue .Lalala`; then `checkflag ENGINE_PLAINBADGE` / `iffalse .Lalala`. Only with the + badge and without the event does it `faceplayer` / `opentext` / + `writetext GoldenrodFlowerShopTeacherBetterThanWhitneyText` / `promptbutton` / + `verbosegiveitem SQUIRTBOTTLE` / `setevent EVENT_GOT_SQUIRTBOTTLE`. **Note the bug in + the cart**: there is no `iffalse` after `verbosegiveitem`, so a full PACK still sets + `EVENT_GOT_SQUIRTBOTTLE` and the item is lost. A bot must have a free KEY ITEM slot. +- `FlowerShopFloriaScript` (`57:52f4`) - pure flavour, branches on `ENGINE_PLAINBADGE`. + Gives nothing. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_SQUIRTBOTTLE` | `constants/event_flags.asm:101` | read+written by `FlowerShopTeacherScript` | one-shot; set even on a full bag | +| `ENGINE_PLAINBADGE` | `constants/engine_flags.asm:40` | read by `FlowerShopTeacherScript`, `FlowerShopFloriaScript` | Whitney's badge is the hard precondition for the whole section | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `SQUIRTBOTTLE` | talk to teacher at (2,4) | `FlowerShopTeacherScript` `verbosegiveitem` | `EVENT_GOT_SQUIRTBOTTLE` | + +**Trainers** - none. + +**Wild encounters** - none (indoor). + +--- + +### MAP_ROUTE_35_GOLDENROD_GATE + +- Script: `maps/Route35GoldenrodGate.asm` +- Blocks: no `.blk` in `maps/` for this map (gate maps of this group share generated + attributes; `data/maps/attributes.asm:542` carries `map_attributes Route35GoldenrodGate, ROUTE_35_GOLDENROD_GATE, $00`) +- Header (`data/maps/maps.asm:260`): `TILESET_GATE, GATE, LANDMARK_ROUTE_35, MUSIC_ROUTE_36, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:239` -> `map_const ROUTE_35_GOLDENROD_GATE, 5, 4` +- Connections: none. `GATE` environment is why the walkthrough's "ride your bike through" + works. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_35` | 1 | +| 2 | 5 | 0 | `ROUTE_35` | 2 | +| 3 | 4 | 7 | `GOLDENROD_CITY` | 13 | +| 4 | 5 | 7 | `GOLDENROD_CITY` | 13 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE35GOLDENRODGATE_RANDY` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `RandyScript` | -1 | +| `ROUTE35GOLDENRODGATE_POKEFAN_F` | `SPRITE_POKEFAN_F` | 6 | 4 | `SPRITEMOVEDATA_WALK_UP_DOWN` (0,1) | `OBJECTTYPE_SCRIPT` | `Route35GoldenrodGatePokefanFScript` | -1 | + +**Scripts of interest** + +- `RandyScript` (`56:59ce`) - four-way branch, checked in this order: + `EVENT_GOT_HP_UP_FROM_RANDY` -> `.gothpup`; `EVENT_GAVE_KENYA` -> `.questcomplete` + (`verbosegiveitem HP_UP`, `setevent EVENT_GOT_HP_UP_FROM_RANDY`); + `EVENT_GOT_KENYA` -> `.alreadyhavekenya`; otherwise the offer. On YES it prints thanks, + then `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH, .partyfull`. Only with a free + slot does it run `givepoke SPEAROW, 10, NO_ITEM, GiftSpearowName, GiftSpearowOTName`, + `givepokemail GiftSpearowMail`, `setevent EVENT_GOT_KENYA`. + - `GiftSpearowMail` = `FLOWER_MAIL`, text `"DARK CAVE leads / to another road"`. + - `GiftSpearowName` = `KENYA`, `GiftSpearowOTName` = `RANDY`. + - The delivery target (`EVENT_GAVE_KENYA`) is on Route 31, i.e. a later section. +- `Route35GoldenrodGatePokefanFScript` - flavour, branches on `EVENT_FOUGHT_SUDOWOODO`; + its pre-Sudowoodo text is the in-game hint that the SQUIRTBOTTLE is what wakes the tree. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_KENYA` | `constants/event_flags.asm:89` | `RandyScript` | Spearow accepted | +| `EVENT_GAVE_KENYA` | `constants/event_flags.asm:90` | read here, set on Route 31 | enables the HP UP reward | +| `EVENT_GOT_HP_UP_FROM_RANDY` | `constants/event_flags.asm:91` | `RandyScript` | reward taken | +| `EVENT_FOUGHT_SUDOWOODO` | `constants/event_flags.asm:51` | read here | flavour only on this map | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| SPEAROW "KENYA" (lv 10) + `FLOWER_MAIL` | say YES to Randy with a party slot free | `RandyScript` `givepoke` / `givepokemail` | `EVENT_GOT_KENYA` | +| `HP_UP` | return after delivering to Route 31 | `RandyScript` `.questcomplete` | `EVENT_GOT_HP_UP_FROM_RANDY` | + +**Trainers** - none. **Wild encounters** - none. + +--- + +### MAP_ROUTE_35 + +- Script: `maps/Route35.asm` +- Blocks: `maps/Route35.blk` (180 bytes = 10 x 18 blocks) +- Header (`data/maps/maps.asm:248`): `TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_35, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:227` -> `map_const ROUTE_35, 10, 18` (so cell + coordinates run 0..19 in x and 0..35 in y) +- Connections (`data/maps/attributes.asm:202`): north `ROUTE_36` offset 0, south + `GOLDENROD_CITY` offset -5 + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 33 | `ROUTE_35_GOLDENROD_GATE` | 1 | +| 2 | 10 | 33 | `ROUTE_35_GOLDENROD_GATE` | 2 | +| 3 | 3 | 5 | `ROUTE_35_NATIONAL_PARK_GATE` | 3 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 7 | `BGEVENT_READ` | `Route35Sign` | +| 11 | 31 | `BGEVENT_READ` | `Route35Sign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE35_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 3 | 19 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` sight 4 | `TrainerCamperIvan` | -1 | +| `ROUTE35_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 8 | 20 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` sight 3 | `TrainerCamperElliot` | -1 | +| `ROUTE35_LASS1` | `SPRITE_LASS` | 7 | 20 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` sight 3 | `TrainerPicnickerBrooke` | -1 | +| `ROUTE35_LASS2` | `SPRITE_LASS` | 11 | 24 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` sight 3 | `TrainerPicnickerKim` | -1 | +| `ROUTE35_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 14 | 28 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` sight 0 | `TrainerBirdKeeperBryan` | -1 | +| `ROUTE35_FISHER` | `SPRITE_FISHER` | 2 | 10 | `SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` sight 2 | `TrainerFirebreatherWalt` | -1 | +| `ROUTE35_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 16 | 7 | `STANDING_DOWN` (radius 2,0) | `OBJECTTYPE_TRAINER` sight 3 | `TrainerBugCatcherArnie` | -1 | +| `ROUTE35_SUPER_NERD` | `SPRITE_SUPER_NERD` | 5 | 10 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` sight 2 | `TrainerJugglerIrwin` | -1 | +| `ROUTE35_OFFICER` | `SPRITE_OFFICER` | 5 | 6 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `TrainerOfficerDirk` | -1 | +| `ROUTE35_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 2 | 25 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route35FruitTree` (`4b:5c07`) | -1 | +| `ROUTE35_POKE_BALL` | `SPRITE_POKE_BALL` | 13 | 16 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route35TMRollout` (`4b:5c05`) | `EVENT_ROUTE_35_TM_ROLLOUT` | + +**Scripts of interest** + +- `TrainerOfficerDirk` - **not** an `OBJECTTYPE_TRAINER` object. It is a plain script that + runs `checktime NITE` / `iffalse .NotNight` first, so Dirk only battles at night. Then + `checkevent EVENT_BEAT_OFFICER_DIRK`, `playmusic MUSIC_OFFICER_ENCOUNTER`, + `winlosstext OfficerDirkBeatenText, 0`, `loadtrainer OFFICER, DIRK`, `startbattle`, + `reloadmapafterbattle`, `setevent EVENT_BEAT_OFFICER_DIRK`. A daytime bot will get + `OfficerDirkPrettyToughText` and no battle. +- `TrainerJugglerIrwin` / `TrainerBugCatcherArnie` - standard phone trainers. After the + win, `checkcellnum PHONE_JUGGLER_IRWIN` / `PHONE_BUG_CATCHER_ARNIE`, then + `setevent EVENT_IRWIN_ASKED_FOR_PHONE_NUMBER` / `EVENT_ARNIE_ASKED_FOR_PHONE_NUMBER` + and `askforphonenumber`. The walkthrough's "head back to Arnie and talk to her for her + phone number" is this second talk. + Rematch tiers: Irwin uses `EVENT_CLEARED_RADIO_TOWER` -> `IRWIN3`, + `ENGINE_FLYPOINT_CIANWOOD` -> `IRWIN2`, else `IRWIN1`. Arnie uses + `ENGINE_FLYPOINT_BLACKTHORN` -> `ARNIE3`, `ENGINE_FLYPOINT_LAKE_OF_RAGE` -> `ARNIE2`, + else `ARNIE1`. +- `Route35TMRollout` -> `itemball TM_ROLLOUT`. `Route35FruitTree` -> `fruittree FRUITTREE_ROUTE_35`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_PICNICKER_KIM` / `_CAMPER_ELLIOT` / `_PICNICKER_BROOKE` / `_CAMPER_IVAN` / `_JUGGLER_IRWIN` / `_FIREBREATHER_WALT` / `_BUG_CATCHER_ARNIE` / `_BIRD_KEEPER_BRYAN` | `constants/event_flags.asm` | `trainer` macro rows | per-trainer defeated bits | +| `EVENT_BEAT_OFFICER_DIRK` | `constants/event_flags.asm` | `TrainerOfficerDirk` | set by hand, not by the `trainer` macro | +| `EVENT_ROUTE_35_TM_ROLLOUT` | `constants/event_flags.asm:1107` | item ball object | TM04 taken | +| `EVENT_IRWIN_ASKED_FOR_PHONE_NUMBER`, `EVENT_IRWIN_READY_FOR_REMATCH` | `constants/event_flags.asm` | Irwin script | phone state machine | +| `EVENT_ARNIE_ASKED_FOR_PHONE_NUMBER`, `EVENT_ARNIE_READY_FOR_REMATCH` | `constants/event_flags.asm` | Arnie script | phone state machine | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_ROLLOUT` (TM04, `constants/item_constants.asm:223`) | item ball at cell (13, 16) | `Route35TMRollout` | `EVENT_ROUTE_35_TM_ROLLOUT` | +| `MYSTERYBERRY` | fruit tree object at (2, 25) | `FRUITTREE_ROUTE_35` -> `data/items/fruit_trees.asm:14` | daily reset (`Apricorns`/`TryResetFruitTrees` equivalent) | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `KIM` | `PICNICKER` (0x35) | 4 | `parties.asm:2601` `KIM` - lv15 VULPIX | `TrainerPicnickerKim` | no | +| `ELLIOT` | `CAMPER` (0x36) | 4 | `parties.asm:2722` `ELLIOT` - lv13 SANDSHREW, lv15 MARILL | `TrainerCamperElliot` | no | +| `BROOKE` | `PICNICKER` (0x35) | 3 | `parties.asm:2596` `BROOKE` `TRAINERTYPE_MOVES` - lv16 PIKACHU (THUNDERSHOCK, GROWL, QUICK_ATTACK, DOUBLE_TEAM) | `TrainerPicnickerBrooke` | no | +| `IVAN` | `CAMPER` (0x36) | 3 | `parties.asm:2715` `IVAN` - lv10 DIGLETT, lv10 ZUBAT, lv14 DIGLETT | `TrainerCamperIvan` | no | +| `IRWIN1` | `JUGGLER` (0x31) | 1 | `parties.asm:2384` `IRWIN` - lv2/6/10/14 VOLTORB | `TrainerJugglerIrwin` | yes (`PHONE_JUGGLER_IRWIN`); IRWIN2 `parties.asm:2407`, IRWIN3 `parties.asm:2415` | +| `WALT` | `FIREBREATHER` (0x30) | 6 | `parties.asm:2365` `WALT` - lv11 MAGMAR, lv13 MAGMAR | `TrainerFirebreatherWalt` | no | +| `DIRK` | `OFFICER` (0x41) | 2 | `parties.asm:3172` `DIRK` - lv14 GROWLITHE, lv14 GROWLITHE | `TrainerOfficerDirk` | night only | +| `ARNIE1` | `BUG_CATCHER` (0x24) | 8 | `parties.asm:1455` `ARNIE` - lv15 VENONAT | `TrainerBugCatcherArnie` | yes (`PHONE_BUG_CATCHER_ARNIE`); ARNIE2 `parties.asm:1487`, ARNIE3 `parties.asm:1492` (lv28 VENOMOTH w/ moves) | +| `BRYAN` | `BIRD_KEEPER` (0x18) | 3 | `parties.asm:534` `BRYAN` - lv12 PIDGEY, lv14 PIDGEOTTO | `TrainerBirdKeeperBryan` | no | + +**Wild encounters** - `data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_35`, +rates 10/10/10 percent (morn/day/nite). Gold table: + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 12 NIDORAN_M | 12 NIDORAN_M | 12 NIDORAN_M | +| 2 | 12 NIDORAN_F | 12 NIDORAN_F | 12 NIDORAN_F | +| 3 | 14 DROWZEE | 14 DROWZEE | 14 DROWZEE | +| 4 | 10 ABRA | 10 ABRA | 10 ABRA | +| 5 | 14 PIDGEY | 14 PIDGEY | 14 HOOTHOOT | +| 6 | 10 DITTO | 10 DITTO | 10 DITTO | +| 7 | 12 YANMA | 12 YANMA | 12 YANMA | + +(Silver swaps slots 1/2 to NIDORAN_F/NIDORAN_M.) + +Water (`data/wild/johto_water.asm:156` `def_water_wildmons ROUTE_35`): 4 percent - +20 PSYDUCK, 15 PSYDUCK, 20 GOLDUCK. Fishing group is `FISHGROUP_POND` +(`data/wild/fish.asm:15`). Headbutt: `data/wild/treemon_maps.asm:16` -> +`TREEMON_SET_FOREST` (`data/wild/treemons.asm:34`; Gold common CATERPIE/METAPOD/ +EXEGGCUTE/BUTTERFREE, rare adds PINECO). Swarm: `data/wild/swarm_grass.asm:6` is the +YANMA swarm table for `ROUTE_35`. + +**Cut tree** - `maps/Route35.blk` has exactly one cuttable block: block id `$5b` at block +(8, 3), i.e. walk cells x 16-17, y 6-7. `data/collision/field_move_blocks.asm:14` gives +`db $5b, $3c, 0` for `TILESET_JOHTO`. That is the tree the walkthrough tells the player to +cut, and Bug Catcher Arnie at (16, 7) sits immediately behind it. Route 36, Route 37 and +National Park have **no** cuttable blocks (National Park's `.park` entry in +`field_move_blocks.asm` is only the two grass ids `$13` and `$03`). + +--- + +### MAP_ROUTE_35_NATIONAL_PARK_GATE + +- Script: `maps/Route35NationalParkGate.asm` +- Blocks: `maps/Route35NationalParkGate.blk` +- Header (`data/maps/maps.asm:261`): `TILESET_GATE, INDOOR, LANDMARK_ROUTE_35, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:240` -> `map_const ROUTE_35_NATIONAL_PARK_GATE, 4, 4` +- Connections: none + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 0 | `NATIONAL_PARK` | 3 | +| 2 | 4 | 0 | `NATIONAL_PARK` | 4 | +| 3 | 3 | 7 | `ROUTE_35` | 3 | +| 4 | 4 | 7 | `ROUTE_35` | 3 | + +**Coord events** - none. The map instead uses scene scripts: + +| scene id | value | script | effect | +|---|---|---|---| +| `SCENE_ROUTE35NATIONALPARKGATE_NOOP` | 0 | `Route35NationalParkGateNoop1Scene` | `end` | +| `SCENE_ROUTE35NATIONALPARKGATE_UNUSED` | 1 | `Route35NationalParkGateNoop2Scene` | `end` | +| `SCENE_ROUTE35NATIONALPARKGATE_LEAVE_CONTEST_EARLY` | 2 | `Route35NationalParkGateLeaveContestEarlyScene` | `sdefer Route35NationalParkGateLeavingContestEarlyScript` | + +(The `SCENE_*` names are generated by the `scene_script` macro, +`macros/scripts/maps.asm:25`, in the order they appear in this file - there is no +separate constants file for them.) + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 0 | `BGEVENT_READ` | `BugCatchingContestExplanationSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE35NATIONALPARKGATE_OFFICER1` | `SPRITE_OFFICER` | 2 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route35OfficerScriptContest` (`56:5e0c`) | `EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY` | +| `ROUTE35NATIONALPARKGATE_YOUNGSTER` | `SPRITE_YOUNGSTER` | 6 | 5 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `Route35NationalParkGateYoungsterScript` | `EVENT_ROUTE_35_NATIONAL_PARK_GATE_YOUNGSTER` | +| `ROUTE35NATIONALPARKGATE_OFFICER2` | `SPRITE_OFFICER` | 0 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route35NationalParkGateOfficerScript` | `EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY` | + +**Callbacks** + +- `MAPCALLBACK_NEWMAP` -> `Route35NationalParkGateCheckIfContestRunningCallback`: + `checkflag ENGINE_BUG_CONTEST_TIMER` -> `setscene SCENE_..._LEAVE_CONTEST_EARLY`, + else `setscene SCENE_..._NOOP`. +- `MAPCALLBACK_OBJECTS` -> `Route35NationalParkGateCheckIfContestAvailableCallback` + (`56:5db8`): `readvar VAR_WEEKDAY`; `TUESDAY`/`THURSDAY`/`SATURDAY` -> + `appear OFFICER1`, `disappear YOUNGSTER`, `disappear OFFICER2`. Otherwise + `checkflag ENGINE_BUG_CONTEST_TIMER` (mid-contest keeps the contest officer), else the + non-contest pair appears. + +**Scripts of interest** + +- `Route35OfficerScriptContest` (`56:5e0c`) - the entry point. Order of checks: + 1. `readvar VAR_WEEKDAY`; SUNDAY/MONDAY/WEDNESDAY/FRIDAY -> `Route35NationalParkGate_NoContestToday`. + 2. `checkflag ENGINE_DAILY_BUG_CONTEST` -> `Route35NationalParkGate_ContestIsOver`. + 3. `yesorno`. NO -> `Route35NationalParkGate_DeclinedToParticipate`. + 4. `readvar VAR_PARTYCOUNT` / `ifgreater 1, Route35NationalParkGate_LeaveTheRestBehind`. + With more than one mon it needs box space (`readvar VAR_BOXSPACE`, `ifequal 0` -> + `_NoRoomInBox`), rejects an EGG lead (`special CheckFirstMonIsEgg`) and rejects a + fainted lead (`special ContestDropOffMons` -> `iftrue _FirstMonIsFainted`), then + `setevent EVENT_LEFT_MONS_WITH_CONTEST_OFFICER`. + 5. `Route35NationalParkGate_OkayToProceed`: `setflag ENGINE_BUG_CONTEST_TIMER`, + `special PlayMapMusic`, park ball text, `special GiveParkBalls`, + `special FadeOutToWhite`, `special SelectRandomBugContestContestants`, + `warp NATIONAL_PARK_BUG_CONTEST, 10, 47`. +- `Route35NationalParkGateLeavingContestEarlyScript` - runs when re-entering this gate + while `ENGINE_BUG_CONTEST_TIMER` is set. `readvar VAR_CONTESTMINUTES` / + `addval 1` / `getnum STRING_BUFFER_3`, `yesorno`. YES -> + `jumpstd BugContestResultsWarpScript`. NO -> `warp NATIONAL_PARK_BUG_CONTEST, 10, 47`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_BUG_CONTEST_TIMER` | `constants/engine_flags.asm:26` | set by the officer, cleared by `BugContestResultsScript` | "contest in progress" | +| `ENGINE_DAILY_BUG_CONTEST` | `constants/engine_flags.asm:98` | set by `BugContestResultsScript` | "already ran today" | +| `EVENT_LEFT_MONS_WITH_CONTEST_OFFICER` | `constants/event_flags.asm:439` | officer / results script | party is being held | +| `EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY` / `_NOT_CONTEST_DAY` / `_YOUNGSTER` | `constants/event_flags.asm:1255-1256` (+ youngster) | `MAPCALLBACK_OBJECTS` | which NPC is visible | +| `VAR_WEEKDAY` | `constants/script_constants.asm` (`; 0b`) | callback + officer | 0=Sunday .. 6=Saturday | +| `VAR_CONTESTMINUTES` | `constants/script_constants.asm:65` (`; 11`) | early-exit script | minutes left | + +**Items** - park balls only (see the Contest map). **Trainers** - none. + +--- + +### MAP_NATIONAL_PARK + +- Script: `maps/NationalPark.asm` +- Blocks: `maps/NationalPark.blk` (540 bytes = 20 x 27 blocks) +- Header (`data/maps/maps.asm:93`): `TILESET_PARK, ROUTE, LANDMARK_NATIONAL_PARK, MUSIC_NATIONAL_PARK, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:80` -> `map_const NATIONAL_PARK, 20, 27` (cells + 0..39 x, 0..53 y) +- Connections: none (`data/maps/attributes.asm:412` has no `connection` rows). Both exits + are warps. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 33 | 18 | `ROUTE_36_NATIONAL_PARK_GATE` | 1 | +| 2 | 33 | 19 | `ROUTE_36_NATIONAL_PARK_GATE` | 2 | +| 3 | 10 | 47 | `ROUTE_35_NATIONAL_PARK_GATE` | 1 | +| 4 | 11 | 47 | `ROUTE_35_NATIONAL_PARK_GATE` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 14 | 44 | `BGEVENT_READ` | `NationalParkRelaxationSquareSign` | +| 27 | 31 | `BGEVENT_READ` | `NationalParkBattleNoticeSign` | +| 6 | 47 | `BGEVENT_ITEM` | `NationalParkHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL` | +| 12 | 4 | `BGEVENT_READ` | `NationalParkTrainerTipsSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `NATIONALPARK_LASS1` | `SPRITE_LASS` | 15 | 24 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `NationalParkLassScript` | -1 | +| `NATIONALPARK_POKEFAN_F1` | `SPRITE_POKEFAN_F` | 14 | 4 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `NationalParkPokefanFScript` | -1 | +| `NATIONALPARK_TEACHER1` | `SPRITE_TEACHER` | 27 | 40 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `NationalParkTeacher1Script` (`43:4008`) | -1 | +| `NATIONALPARK_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 11 | 41 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `NationalParkYoungster1Script` | -1 | +| `NATIONALPARK_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 10 | 41 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `NationalParkYoungster2Script` | -1 | +| `NATIONALPARK_TEACHER2` | `SPRITE_TEACHER` | 17 | 41 | `WANDER` (1,2) | `OBJECTTYPE_SCRIPT` | `NationalParkTeacher2Script` | -1 | +| `NATIONALPARK_PERSIAN` | `SPRITE_GROWLITHE` | 26 | 40 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `NationalParkPersian` | -1 | +| `NATIONALPARK_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 27 | 23 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` sight 3 | `TrainerSchoolboyJack1` | -1 | +| `NATIONALPARK_POKEFAN_F2` | `SPRITE_POKEFAN_F` | 18 | 29 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` sight 2 | `TrainerPokefanfBeverly1` | -1 | +| `NATIONALPARK_POKEFAN_M` | `SPRITE_POKEFAN_M` | 16 | 9 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` sight 2 | `TrainerPokefanmWilliam` | -1 | +| `NATIONALPARK_LASS2` | `SPRITE_LASS` | 8 | 14 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` sight 3 | `TrainerLassKrise` | -1 | +| `NATIONALPARK_POKE_BALL1` | `SPRITE_POKE_BALL` | 35 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `NationalParkParlyzHeal` (`43:418f`) | `EVENT_NATIONAL_PARK_PARLYZ_HEAL` | +| `NATIONALPARK_GAMEBOY_KID` | `SPRITE_GAMEBOY_KID` | 26 | 6 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `NationalParkGameboyKidScript` | -1 | +| `NATIONALPARK_POKE_BALL2` | `SPRITE_POKE_BALL` | 1 | 43 | `STILL` | `OBJECTTYPE_ITEMBALL` | `NationalParkTMDig` (`43:4191`) | `EVENT_NATIONAL_PARK_TM_DIG` | + +**Scripts of interest** + +- `NationalParkTeacher1Script` (`43:4008`) - the walkthrough's "lady on the bench". + `checkevent EVENT_GOT_QUICK_CLAW` / `iftrue .GotQuickClaw`; else text, + `verbosegiveitem QUICK_CLAW`, `iffalse .NoRoom`, `setevent EVENT_GOT_QUICK_CLAW`. + Unlike the flower shop teacher this one **does** guard the `setevent` with `iffalse`. +- `TrainerSchoolboyJack1` - phone trainer (`PHONE_SCHOOLBOY_JACK`). Rematch tiers: + `EVENT_CLEARED_RADIO_TOWER` -> `JACK3`, `ENGINE_FLYPOINT_OLIVINE` -> `JACK2`, else `JACK1`. +- `TrainerPokefanfBeverly1` - phone trainer (`PHONE_POKEFAN_BEVERLY`), female std scripts + (`AskNumber1FScript` etc). Rematch tiers: `EVENT_CLEARED_RADIO_TOWER` -> `BEVERLY3`, + `ENGINE_FLYPOINT_MAHOGANY` -> `BEVERLY2`, else `BEVERLY1`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_QUICK_CLAW` | `constants/event_flags.asm:96` | `NationalParkTeacher1Script` | one-shot gift | +| `EVENT_NATIONAL_PARK_PARLYZ_HEAL` | `constants/event_flags.asm:1017` | item ball | ball taken | +| `EVENT_NATIONAL_PARK_TM_DIG` | `constants/event_flags.asm:1018` | item ball | ball taken | +| `EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL` | `constants/event_flags.asm:142` | `hiddenitem` | hidden item taken | +| `EVENT_BEAT_SCHOOLBOY_JACK`, `EVENT_BEAT_POKEFANF_BEVERLY`, `EVENT_BEAT_POKEFANM_WILLIAM`, `EVENT_BEAT_LASS_KRISE` | `constants/event_flags.asm` | `trainer` rows | defeated bits | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `QUICK_CLAW` | talk to teacher at (27, 40) | `NationalParkTeacher1Script` | `EVENT_GOT_QUICK_CLAW` | +| `PARLYZ_HEAL` | item ball at (35, 12) | `NationalParkParlyzHeal` | `EVENT_NATIONAL_PARK_PARLYZ_HEAL` | +| `TM_DIG` (TM28) | item ball at (1, 43) | `NationalParkTMDig` | `EVENT_NATIONAL_PARK_TM_DIG` | +| `FULL_HEAL` | hidden, bg_event at (6, 47) | `NationalParkHiddenFullHeal` | `EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `BEVERLY1` | `POKEFANF` (0x3e) | 1 | `parties.asm:3115` `BEVERLY` `TRAINERTYPE_ITEM` - lv14 SNUBBULL @ BERRY | `TrainerPokefanfBeverly1` | yes (`PHONE_POKEFAN_BEVERLY`); BEVERLY2 `parties.asm:3125`, BEVERLY3 `parties.asm:3130` | +| `JACK1` | `SCHOOLBOY` (0x17) | 1 | `parties.asm:409` `JACK` - lv12 ODDISH, lv15 VOLTORB | `TrainerSchoolboyJack1` | yes (`PHONE_SCHOOLBOY_JACK`); JACK2 `parties.asm:484`, JACK3 `parties.asm:490` | +| `KRISE` | `LASS` (0x19) | 4 | `parties.asm:658` `KRISE` - lv12 ODDISH, lv15 CUBONE | `TrainerLassKrise` | no | +| `WILLIAM` | `POKEFANM` (0x3b) | 1 | `parties.asm:2961` `WILLIAM` `TRAINERTYPE_ITEM` - lv14 RAICHU @ BERRY | `TrainerPokefanmWilliam` | no | + +**Wild encounters** - `data/wild/johto_grass.asm`, `def_grass_wildmons NATIONAL_PARK`, +rates 10/10/10 percent. Gold table: + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 10 CATERPIE | 10 CATERPIE | 10 HOOTHOOT | +| 2 | 10 METAPOD | 10 METAPOD | 10 HOOTHOOT | +| 3 | 12 CATERPIE | 11 SUNKERN | 12 HOOTHOOT | +| 4 | 12 PIDGEY | 12 PIDGEY | 12 HOOTHOOT | +| 5 | 10 PIDGEY | 13 SUNKERN | 10 HOOTHOOT | +| 6 | 14 PIDGEY | 14 PIDGEY | 14 HOOTHOOT | +| 7 | 14 PIDGEY | 14 PIDGEY | 14 HOOTHOOT | + +(Silver uses WEEDLE/KAKUNA in slots 1-3 morn. The nite block is shared by both versions.) +No `NATIONAL_PARK` entry exists in `data/wild/johto_water.asm`, `data/wild/treemon_maps.asm` +or `data/wild/swarm_grass.asm`. The rare bugs the walkthrough lists (Scyther, Pinsir, +Paras, Venonat, Butterfree, Beedrill) are **Contest-only**; see below. + +--- + +### MAP_NATIONAL_PARK_BUG_CONTEST + +- Script: `maps/NationalParkBugContest.asm` +- Blocks: none of its own; `data/maps/attributes.asm:413` gives + `map_attributes NationalParkBugContest, NATIONAL_PARK_BUG_CONTEST, $00` and the map is a + same-geometry twin of National Park. +- Header (`data/maps/maps.asm:94`): `TILESET_PARK, ROUTE, LANDMARK_NATIONAL_PARK, MUSIC_BUG_CATCHING_CONTEST, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:81` -> `map_const NATIONAL_PARK_BUG_CONTEST, 20, 27` +- Connections: none + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 33 | 18 | `ROUTE_36_NATIONAL_PARK_GATE` | 1 | +| 2 | 33 | 19 | `ROUTE_36_NATIONAL_PARK_GATE` | 1 | +| 3 | 10 | 47 | `ROUTE_35_NATIONAL_PARK_GATE` | 1 | +| 4 | 11 | 47 | `ROUTE_35_NATIONAL_PARK_GATE` | 1 | + +(Warps 2 and 4 point at dest warp 1, not 2 - that is what the file says.) + +**Coord events** - none. + +**BG events** - same four rows as National Park, aimed at the `...BugContest...` copies: +(14,44) Relaxation Square sign, (27,31) Battle Notice sign, (6,47) `BGEVENT_ITEM` +`hiddenitem FULL_HEAL, EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL` (the same flag as the normal +map), (12,4) Trainer Tips sign. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._YOUNGSTER1` | `SPRITE_YOUNGSTER` | 19 | 29 | `WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant1AScript` | `EVENT_BUG_CATCHING_CONTESTANT_1A` | +| `..._YOUNGSTER2` | `SPRITE_YOUNGSTER` | 28 | 22 | `WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant2AScript` | `..._2A` | +| `..._ROCKER` | `SPRITE_ROCKER` | 9 | 18 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant3AScript` | `..._3A` | +| `..._POKEFAN_M` | `SPRITE_POKEFAN_M` | 7 | 13 | `WALK_UP_DOWN` (1,0) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant4AScript` | `..._4A` | +| `..._YOUNGSTER3` | `SPRITE_YOUNGSTER` | 23 | 9 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant5AScript` | `..._5A` | +| `..._YOUNGSTER4` | `SPRITE_YOUNGSTER` | 27 | 13 | `WANDER` (3,3) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant6AScript` | `..._6A` | +| `..._LASS` | `SPRITE_LASS` | 7 | 23 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant7AScript` | `..._7A` | +| `..._YOUNGSTER5` | `SPRITE_YOUNGSTER` | 11 | 27 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant8AScript` | `..._8A` | +| `..._YOUNGSTER6` | `SPRITE_YOUNGSTER` | 16 | 8 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant9AScript` | `..._9A` | +| `..._YOUNGSTER7` | `SPRITE_YOUNGSTER` | 17 | 34 | `WANDER` (3,3) | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant10AScript` | `..._10A` | +| `..._POKE_BALL1` | `SPRITE_POKE_BALL` | 35 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `NationalParkBugContestParlyzHeal` | `EVENT_NATIONAL_PARK_PARLYZ_HEAL` | +| `..._POKE_BALL2` | `SPRITE_POKE_BALL` | 1 | 43 | `STILL` | `OBJECTTYPE_ITEMBALL` | `NationalParkBugContestTMDig` | `EVENT_NATIONAL_PARK_TM_DIG` | + +**Scripts of interest** + +- Every `BugCatchingContestantNAScript` is `faceplayer / opentext / writetext / waitbutton + / closetext / end` - flavour only, no branching. The `B` variants in the north gate are + the ones that branch on `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1` (set by + `BugContestResults_FirstPlace`) to say "you won". +- The battle path is `BugCatchingContestBattleScript::` + (`engine/events/bug_contest/contest.asm:9`): `loadvar VAR_BATTLETYPE, BATTLETYPE_CONTEST`, + `randomwildmon`, `startbattle`, `reloadmapafterbattle`, `readmem wParkBallsRemaining`, + `iffalse BugCatchingContestOutOfBallsScript`. +- `GiveParkBalls` (`engine/events/bug_contest/contest.asm:1`) clears `wContestMon`, writes + `BUG_CONTEST_BALLS` into `wParkBallsRemaining` and `farcall StartBugContestTimer`. + +**Contest constants** (`constants/script_constants.asm`) + +| constant | line | value | +|---|---|---| +| `BUG_CONTEST_BALLS` | 283 | 20 | +| `BUG_CONTEST_MINUTES` | 284 | 20 | +| `BUG_CONTEST_PLAYER` | 286 | 1 | +| `NUM_BUG_CONTESTANTS` | 287 | 10 (not counting the player) | +| `VAR_CONTESTMINUTES` | 65 | var id 0x11 | + +**Contest wild table** - `data/wild/bug_contest_mons.asm`, label `ContestMons` +(`25:7bb8`). This table replaces National Park's grass entirely for the duration: + +| % | species | min lv | max lv | +|---|---|---|---| +| 20 | CATERPIE | 7 | 18 | +| 20 | WEEDLE | 7 | 18 | +| 10 | METAPOD | 9 | 18 | +| 10 | KAKUNA | 9 | 18 | +| 5 | BUTTERFREE | 12 | 15 | +| 5 | BEEDRILL | 12 | 15 | +| 10 | VENONAT | 10 | 16 | +| 10 | PARAS | 10 | 17 | +| 5 | SCYTHER | 13 | 14 | +| 5 | PINSIR | 13 | 14 | +| -1 (terminator row) | VENOMOTH | 30 | 40 | + +**Scoring** - `ContestScore` (`04:7cbc`, `engine/events/bug_contest/judging.asm`) sums: +`MaxHP * 4` (high byte only), then Attack, Defense, Speed, SpclAtk, SpclDef, then a DV +bonus assembled from bit 1 of each DV nibble, then `remaining HP / 8`, then `+1` if the +mon is holding an item. Five of the ten AI contestants are rolled by +`ComputeAIContestantScores`, each picking one of three canned mon/score pairs from +`BugContestantPointers` (`data/events/bug_contest_winners.asm`) with a 0..7 random +perturbation. `DetermineContestWinners` keeps a 3-deep podium. + +**Prizes** - `BugContestResultsScript` (`40:420e`, `engine/events/std_scripts.asm:275`): + +| place | item | no-room fallback flag | +|---|---|---| +| 1st | `SUN_STONE` | `EVENT_CONTEST_OFFICER_HAS_SUN_STONE` | +| 2nd | `EVERSTONE` | `EVENT_CONTEST_OFFICER_HAS_EVERSTONE` | +| 3rd | `GOLD_BERRY` | `EVENT_CONTEST_OFFICER_HAS_GOLD_BERRY` | +| consolation | `BERRY` | `EVENT_CONTEST_OFFICER_HAS_BERRY` | + +First place also sets `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`. The tail of the script clears +`ENGINE_BUG_CONTEST_TIMER`, sets all twenty `EVENT_BUG_CATCHING_CONTESTANT_*` flags, sets +`ENGINE_DAILY_BUG_CONTEST`, and resets both gate scenes to their NOOP. + +--- + +### MAP_ROUTE_36_NATIONAL_PARK_GATE + +- Script: `maps/Route36NationalParkGate.asm` +- Blocks: `maps/Route36NationalParkGate.blk` +- Header (`data/maps/maps.asm:263`): `TILESET_GATE, INDOOR, LANDMARK_ROUTE_36, MUSIC_GOLDENROD_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:242` -> `map_const ROUTE_36_NATIONAL_PARK_GATE, 5, 4` +- Connections: none + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `NATIONAL_PARK` | 1 | +| 2 | 0 | 5 | `NATIONAL_PARK` | 2 | +| 3 | 9 | 4 | `ROUTE_36` | 1 | +| 4 | 9 | 5 | `ROUTE_36` | 2 | + +**Coord events** - none. Scene scripts (same three-slot shape as the south gate): +`SCENE_ROUTE36NATIONALPARKGATE_NOOP` (0), `..._UNUSED` (1), +`..._LEAVE_CONTEST_EARLY` (2) -> `sdefer Route36NationalParkGateLeavingContestEarlyScript`. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 6 | 0 | `BGEVENT_READ` | `BugCatchingContestExplanationSign` (the label defined in `maps/Route35NationalParkGate.asm`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE36NATIONALPARKGATE_OFFICER1` | `SPRITE_OFFICER` | 0 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route36OfficerScriptContest` (`56:67cc`) | `EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY` | +| `..._YOUNGSTER1` | `SPRITE_YOUNGSTER` | 2 | 5 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant1BScript` | `EVENT_BUG_CATCHING_CONTESTANT_1B` | +| `..._YOUNGSTER2` | `SPRITE_YOUNGSTER` | 4 | 5 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant2BScript` | `..._2B` | +| `..._ROCKER` | `SPRITE_ROCKER` | 2 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant3BScript` | `..._3B` | +| `..._POKEFAN_M` | `SPRITE_POKEFAN_M` | 6 | 5 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant4BScript` | `..._4B` | +| `..._YOUNGSTER3` | `SPRITE_YOUNGSTER` | 2 | 7 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant5BScript` | `..._5B` | +| `..._YOUNGSTER4` | `SPRITE_YOUNGSTER` | 5 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant6BScript` | `..._6B` | +| `..._LASS` | `SPRITE_LASS` | 3 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant7BScript` | `..._7B` | +| `..._YOUNGSTER5` | `SPRITE_YOUNGSTER` | 4 | 7 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant8BScript` | `..._8B` | +| `..._YOUNGSTER6` | `SPRITE_YOUNGSTER` | 6 | 7 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant9BScript` | `..._9B` | +| `..._YOUNGSTER7` | `SPRITE_YOUNGSTER` | 6 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BugCatchingContestant10BScript` | `..._10B` | +| `ROUTE36NATIONALPARKGATE_OFFICER2` | `SPRITE_OFFICER` | 3 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route36NationalParkGateOfficerScript` | `EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY` | + +**Callbacks** + +- `MAPCALLBACK_NEWMAP` -> `Route36NationalParkGateCheckIfContestRunningCallback` + (same shape as the south gate). +- `MAPCALLBACK_OBJECTS` -> `Route36NationalParkGateCheckIfContestAvailableCallback` + (`56:6710`): first `checkevent EVENT_WARPED_FROM_ROUTE_35_NATIONAL_PARK_GATE` -> return + unchanged (this is how the awards ceremony keeps the contest officer standing there when + you finished from the *south* gate). Then weekday / `ENGINE_BUG_CONTEST_TIMER`, same as + the south gate. + +**Scripts of interest** + +- `Route36OfficerScriptContest` (`56:67cc`) - same structure as the south gate's officer + but warps to `NATIONAL_PARK_BUG_CONTEST, 33, 18` and, if `ENGINE_DAILY_BUG_CONTEST` is + already set, falls into `Route36Officer_ContestHasConcluded`, which is the **prize + holding desk**: it checks `EVENT_CONTEST_OFFICER_HAS_SUN_STONE`, then `_EVERSTONE`, then + `_GOLD_BERRY`, then `_BERRY`, `verbosegiveitem`s the first one it finds and clears that + event. A bot that won with a full PACK collects here. +- `Route36NationalParkGateLeavingContestEarlyScript` - the finish-now flow. + `special FadeOutToBlack`, `.CopyContestants` (turns each unset `..._NA` flag into an + `appear` of the matching `B` object), `disappear OFFICER1` / `appear OFFICER2`, + `applymovement PLAYER, Route36NationalParkGatePlayerWaitWithContestantsMovement` + (`big_step DOWN`, `big_step RIGHT`, `turn_head UP`), then + `jumpstd BugContestResultsScript`. + +--- + +### MAP_ROUTE_36 + +- Script: `maps/Route36.asm` +- Blocks: `maps/Route36.blk` (270 bytes = 30 x 9 blocks) +- Header (`data/maps/maps.asm:249`): `TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_36, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:228` -> `map_const ROUTE_36, 30, 9` (cells + 0..59 x, 0..17 y) +- Connections (`data/maps/attributes.asm:206`): north `ROUTE_37` offset 10, south + `ROUTE_35` offset 0, east `VIOLET_CITY` offset 0 + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 18 | 8 | `ROUTE_36_NATIONAL_PARK_GATE` | 3 | +| 2 | 18 | 9 | `ROUTE_36_NATIONAL_PARK_GATE` | 4 | +| 3 | 47 | 13 | `ROUTE_36_RUINS_OF_ALPH_GATE` | 1 | +| 4 | 48 | 13 | `ROUTE_36_RUINS_OF_ALPH_GATE` | 2 | + +**Coord events** - none. (Sudowoodo is an object, not a trip-wire.) + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 29 | 1 | `BGEVENT_READ` | `Route36TrainerTips2` | +| 45 | 11 | `BGEVENT_READ` | `RuinsOfAlphNorthSign` | +| 55 | 7 | `BGEVENT_READ` | `Route36Sign` | +| 21 | 7 | `BGEVENT_READ` | `Route36TrainerTips1` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE36_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 20 | 12 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` sight 2 | `TrainerPsychicMark` | -1 | +| `ROUTE36_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 31 | 14 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` sight 5 | `TrainerSchoolboyAlan1` | -1 | +| `ROUTE36_WEIRD_TREE` | `SPRITE_WEIRD_TREE` | 35 | 9 | `SPRITEMOVEDATA_SUDOWOODO` | `OBJECTTYPE_SCRIPT` | `SudowoodoScript` (`4b:61aa`) | `EVENT_ROUTE_36_SUDOWOODO` | +| `ROUTE36_LASS1` | `SPRITE_LASS` | 51 | 8 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `Route36LassScript` (`4b:621e`) | -1 | +| `ROUTE36_FISHER` | `SPRITE_FISHER` | 44 | 9 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route36RockSmashGuyScript` (`4b:61f7`) | -1 | +| `ROUTE36_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 21 | 4 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route36FruitTree` (`4b:631a`) | -1 | +| `ROUTE36_ARTHUR` | `SPRITE_YOUNGSTER` | 46 | 6 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `ArthurScript` (`4b:62d4`) | `EVENT_ROUTE_36_ARTHUR_OF_THURSDAY` | + +**Callbacks** + +- `MAPCALLBACK_OBJECTS` -> `Route36ArthurCallback` (`4b:619e`): `readvar VAR_WEEKDAY`, + `ifequal THURSDAY, .ArthurAppears` (-> `appear ROUTE36_ARTHUR`), otherwise + `disappear ROUTE36_ARTHUR`. + +**Scripts of interest** + +- `SudowoodoScript` (`4b:61aa`) - the gate of this section. + `checkitem SQUIRTBOTTLE` / `iftrue .Fight`. Without it: `waitsfx`, + `playsound SFX_SANDSTORM`, `applymovement ROUTE36_WEIRD_TREE, SudowoodoShakeMovement` + (`tree_shake`), `end` - the tree just wobbles and stays solid. + With it: `writetext UseSquirtbottleText`, `yesorno`, `iffalse DidntUseSquirtbottleScript`. +- `WateredWeirdTreeScript::` (`4b:61c1`) - exported so that using the SQUIRTBOTTLE from the + PACK reaches the same body (`engine/events/squirtbottle.asm` does + `farsjump WateredWeirdTreeScript`). Body: text, shake, `SudowoodoAttackedText`, + **`loadwildmon SUDOWOODO, 20`**, `startbattle`, `setevent EVENT_FOUGHT_SUDOWOODO`, + `ifequal DRAW, DidntCatchSudowoodo`, `disappear ROUTE36_WEIRD_TREE`, + `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN`, `reloadmapafterbattle`. + Note the ordering: `EVENT_FOUGHT_SUDOWOODO` is set **before** the DRAW check, so a run + or a KO still clears the road and still unlocks TM08. +- `DidntCatchSudowoodo` (`4b:61e9`) - `reloadmapafterbattle`, + `applymovement ROUTE36_WEIRD_TREE, WeirdTreeMovement_Flee` (two `fast_jump_step UP`), + `disappear`, `variablesprite`, `special LoadUsedSpritesGFX`. +- `_Squirtbottle` (`14:4763`, `engine/events/squirtbottle.asm`) - the PACK path. + `.CheckCanUseSquirtbottle` requires `wMapGroup == GROUP_ROUTE_36`, + `wMapNumber == MAP_ROUTE_36`, `GetFacingObject` to succeed, and that object's movement + data to be `SPRITEMOVEDATA_SUDOWOODO`. Anything else prints + `_SquirtbottleNothingText`. +- `Route36RockSmashGuyScript` (`4b:61f7`) - `checkevent EVENT_GOT_TM08_ROCK_SMASH` -> + `.AlreadyGotRockSmash`; `checkevent EVENT_FOUGHT_SUDOWOODO` -> `.ClearedSudowoodo` + (`verbosegiveitem TM_ROCK_SMASH`, `iffalse .NoRoomForTM`, + `setevent EVENT_GOT_TM08_ROCK_SMASH`). Before Sudowoodo it just prints + `RockSmashGuyText1`. +- `ArthurScript` (`4b:62d4`) - `checkevent EVENT_GOT_HARD_STONE_FROM_ARTHUR` -> + `.AlreadyGotStone`; `readvar VAR_WEEKDAY` / `ifnotequal THURSDAY, ArthurNotThursdayScript`; + else `setevent EVENT_MET_ARTHUR_OF_THURSDAY`, `verbosegiveitem HARD_STONE`, + `iffalse .BagFull`, `setevent EVENT_GOT_HARD_STONE_FROM_ARTHUR`. +- `Route36LassScript` (`4b:621e`) - flavour, branches on `EVENT_FOUGHT_SUDOWOODO`. This is + the girl at (51, 8). She is **not** Floria and gives nothing. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_36_SUDOWOODO` | `constants/event_flags.asm:1178` | object visibility flag; cleared by `disappear` in `WateredWeirdTreeScript` | tree present on the map | +| `EVENT_FOUGHT_SUDOWOODO` | `constants/event_flags.asm:51` | set by `WateredWeirdTreeScript`; read by the Rock Smash guy, the Route 36 lass, the Route 35 gate pokefan | **the** progress flag of this section | +| `EVENT_GOT_TM08_ROCK_SMASH` | `constants/event_flags.asm:84` | `Route36RockSmashGuyScript` | TM08 taken | +| `EVENT_ROUTE_36_ARTHUR_OF_THURSDAY` | `constants/event_flags.asm:1276` | `Route36ArthurCallback` | Arthur visible | +| `EVENT_MET_ARTHUR_OF_THURSDAY` | `constants/event_flags.asm:111` | `ArthurScript` | first-meeting text | +| `EVENT_GOT_HARD_STONE_FROM_ARTHUR` | `constants/event_flags.asm:112` | `ArthurScript` | HARD STONE taken | +| `EVENT_BEAT_PSYCHIC_MARK`, `EVENT_BEAT_SCHOOLBOY_ALAN` | `constants/event_flags.asm` | `trainer` rows | defeated bits | +| `EVENT_ALAN_ASKED_FOR_PHONE_NUMBER`, `EVENT_ALAN_READY_FOR_REMATCH` | `constants/event_flags.asm` | Alan script | phone state machine | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ICE_BERRY` | fruit tree object at (21, 4) | `FRUITTREE_ROUTE_36` -> `data/items/fruit_trees.asm:16` | daily | +| `TM_ROCK_SMASH` (TM08) | talk to the fisher at (44, 9) after Sudowoodo | `Route36RockSmashGuyScript` | `EVENT_GOT_TM08_ROCK_SMASH` | +| `HARD_STONE` | talk to Arthur at (46, 6) on a Thursday | `ArthurScript` | `EVENT_GOT_HARD_STONE_FROM_ARTHUR` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `MARK` | `PSYCHIC_T` (0x34) | 7 | `parties.asm:2545` `MARK` `TRAINERTYPE_MOVES` - lv13 ABRA (TELEPORT, FLASH), lv13 ABRA (TELEPORT, FLASH), lv15 KADABRA (TELEPORT, KINESIS, CONFUSION) | `TrainerPsychicMark` | no | +| `ALAN1` | `SCHOOLBOY` (0x17) | 3 | `parties.asm:423` `ALAN` - lv16 TANGELA | `TrainerSchoolboyAlan1` | yes (`PHONE_SCHOOLBOY_ALAN`); ALAN2 `parties.asm:496`, ALAN3 `parties.asm:502` | + +**Wild** - `data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_36`, 10/10/10 percent. +Gold: + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 12 NIDORAN_M | 12 NIDORAN_M | 12 NIDORAN_M | +| 2 | 12 NIDORAN_F | 12 NIDORAN_F | 12 NIDORAN_F | +| 3 | 13 PIDGEY | 14 PIDGEY | 13 HOOTHOOT | +| 4 | 13 GROWLITHE | 13 GROWLITHE | 13 GROWLITHE | +| 5 | 13 STANTLER | 13 STANTLER | 13 STANTLER | +| 6 | 15 PIDGEY | 15 GROWLITHE | 15 HOOTHOOT | +| 7 | 15 PIDGEY | 15 GROWLITHE | 15 HOOTHOOT | + +(Silver substitutes VULPIX for GROWLITHE and swaps the NIDORAN order.) No `ROUTE_36` +entry in `johto_water.asm`. Headbutt: `data/wild/treemon_maps.asm:17` -> `TREEMON_SET_FOREST`. +`ROUTE_36` is a roam node in `data/wild/roammon_maps.asm:26` with four exits +(`ROUTE_35, ROUTE_31, ROUTE_32, ROUTE_37`). + +--- + +### MAP_ROUTE_37 (header-listed only; not entered by this walkthrough section) + +- Script: `maps/Route37.asm`, Blocks: `maps/Route37.blk` +- Header (`data/maps/maps.asm:250`): `TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_37, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:229` -> `map_const ROUTE_37, 10, 9` +- Connections (`data/maps/attributes.asm:211`): north `ECRUTEAK_CITY` offset -5, + south `ROUTE_36` offset -10 +- **Warps: `def_warp_events` is empty.** Route 37 is reached only by connection. +- BG events: (5,3) `BGEVENT_READ` `Route37Sign`; (4,2) `BGEVENT_ITEM` + `Route37HiddenEther` -> `hiddenitem ETHER, EVENT_ROUTE_37_HIDDEN_ETHER`. +- Objects: Twins Ann & Anne at (6,12) and (7,12) (`ANNANDANNE1` / `ANNANDANNE2`, both + sharing `EVENT_BEAT_TWINS_ANN_AND_ANNE`), Psychic Greg at (9,6), three fruit trees at + (13,5) `FRUITTREE_ROUTE_37_1` = `RED_APRICORN`, (16,5) `_2` = `BLU_APRICORN`, + (15,7) `_3` = `BLK_APRICORN` (`data/items/fruit_trees.asm:20-22`), and Sunny of Sunday + at (16,8) behind `EVENT_ROUTE_37_SUNNY_OF_SUNDAY` (gift `MAGNET`, + `EVENT_GOT_MAGNET_FROM_SUNNY`). +- Callback: `MAPCALLBACK_OBJECTS` -> `Route37SunnyCallback` (weekday == SUNDAY). +- Wild: `data/wild/johto_grass.asm` `def_grass_wildmons ROUTE_37`, 10/10/10; Gold nite + slot 1/5/6/7 are SPINARAK 13/15/15/15, Silver morn is LEDYBA - this is where the + walkthrough's "Ledyba (Silver only, morning only) / Spinarak (Gold only, night only)" + actually lives. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| SQUIRTBOTTLE not obtainable | `maps/GoldenrodFlowerShop.asm:FlowerShopTeacherScript` (`57:52d1`) - `checkflag ENGINE_PLAINBADGE` / `iffalse .Lalala` | Plain Badge (Whitney) | beat Whitney; talk to the teacher at (2,4) | +| Sudowoodo blocks Route 36 | `maps/Route36.asm:SudowoodoScript` (`4b:61aa`) - the object at cell (35,9) with `SPRITEMOVEDATA_SUDOWOODO` is a solid NPC; the script's `checkitem SQUIRTBOTTLE` / `iftrue .Fight` is the only way past | SQUIRTBOTTLE in the PACK | `EVENT_FOUGHT_SUDOWOODO` set by `WateredWeirdTreeScript` (`4b:61c1`); the `disappear` runs on any outcome except a draw-flee, and the flee path disappears it too | +| SQUIRTBOTTLE from the PACK does nothing elsewhere | `engine/events/squirtbottle.asm:_Squirtbottle` (`14:4763`) `.CheckCanUseSquirtbottle` | map must be `ROUTE_36` and the faced object must carry `SPRITEMOVEDATA_SUDOWOODO` | face the tree first | +| TM08 ROCK SMASH withheld | `maps/Route36.asm:Route36RockSmashGuyScript` (`4b:61f7`) - `checkevent EVENT_FOUGHT_SUDOWOODO` | Sudowoodo dealt with | talk to the fisher at (44, 9) | +| East side of Route 35 (Arnie, Bryan, TM04) | `maps/Route35.blk` block `$5b` at block (8,3) = cells x16-17 y6-7; `data/collision/field_move_blocks.asm:14` | CUT, which needs `ENGINE_HIVEBADGE` (`engine/events/overworld.asm:133` `CutFunction.CheckAble`) and a party mon that knows CUT | Azalea's badge + HM01; or enter from Route 36 via the south connection, which is what the walkthrough does | +| Bug Contest closed | `maps/Route35NationalParkGate.asm:Route35OfficerScriptContest` (`56:5e0c`) and `maps/Route36NationalParkGate.asm:Route36OfficerScriptContest` (`56:67cc`) - `readvar VAR_WEEKDAY`, four `ifequal` bail-outs, plus `checkflag ENGINE_DAILY_BUG_CONTEST` | Tuesday, Thursday or Saturday, and not already run today | set the RTC weekday; `ENGINE_DAILY_BUG_CONTEST` clears on the daily reset | +| Contest entry refused | same scripts - `readvar VAR_PARTYCOUNT` / `VAR_BOXSPACE` / `special CheckFirstMonIsEgg` / `special ContestDropOffMons` | lead mon is not an EGG, is not fainted, and there is party-or-box room for a caught bug | reorder the party, heal, free a box slot | +| Kenya not given | `maps/Route35GoldenrodGate.asm:RandyScript` (`56:59ce`) - `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH, .partyfull` | fewer than 6 party mons | deposit one | +| Arthur / Sunny absent | `Route36ArthurCallback` (`4b:619e`) / `Route37SunnyCallback` - `MAPCALLBACK_OBJECTS` + `VAR_WEEKDAY` | Thursday / Sunday | RTC weekday | +| Officer Dirk will not battle | `maps/Route35.asm:TrainerOfficerDirk` - `checktime NITE` / `iffalse .NotNight` | night time of day | wait, or set the RTC | + +--- + +## 4. Bot checklist + +Coordinates are `(x, y)` walk cells as written in the asm. "talk" means stand adjacent +facing the target and press A. + +1. `MAP_GOLDENROD_CITY` -> warp 6 at (33, 5). Pre: `ENGINE_PLAINBADGE` set. Post: on + `MAP_GOLDENROD_FLOWER_SHOP`. +2. `MAP_GOLDENROD_FLOWER_SHOP`: talk to `GOLDENRODFLOWERSHOP_TEACHER` at (2, 4). + Pre: `!EVENT_GOT_SQUIRTBOTTLE` and a free KEY ITEM slot. Post: + `EVENT_GOT_SQUIRTBOTTLE`, SQUIRTBOTTLE in the PACK. +3. Exit via warp 1 (2, 7) -> `MAP_GOLDENROD_CITY` warp 6. Walk to (19, 1), warp 13 -> + `MAP_ROUTE_35_GOLDENROD_GATE` warp 3. +4. Optional: talk to `ROUTE35GOLDENRODGATE_RANDY` at (0, 4), answer YES. + Pre: `VAR_PARTYCOUNT < 6`. Post: `EVENT_GOT_KENYA`, lv10 SPEAROW "KENYA" with + `FLOWER_MAIL` in the last party slot. +5. Warp 1 at (4, 0) -> `MAP_ROUTE_35` warp 1 (lands at (9, 33)). +6. `MAP_ROUTE_35`, walk north. Battle, in the walkthrough's order: + Kim (11, 24), Elliot (8, 20), Brooke (7, 20), Ivan (3, 19), Irwin (5, 10), + Walt (2, 10). Post: `EVENT_BEAT_*` for each. Sight ranges are 3/3/3/4/2/2. +7. Optional, night only: talk to `ROUTE35_OFFICER` at (5, 6). Pre: `checktime NITE` true + and `!EVENT_BEAT_OFFICER_DIRK`. Post: `EVENT_BEAT_OFFICER_DIRK`. +8. Walk to (3, 5), warp 3 -> `MAP_ROUTE_35_NATIONAL_PARK_GATE` warp 3. +9. Optional Contest: on a Tue/Thu/Sat with `!ENGINE_DAILY_BUG_CONTEST`, talk to + `ROUTE35NATIONALPARKGATE_OFFICER1` at (2, 1) and answer YES. Post: + `ENGINE_BUG_CONTEST_TIMER` set, 20 PARK BALLs, warp to + `MAP_NATIONAL_PARK_BUG_CONTEST` (10, 47). Finish by re-entering either gate and + answering YES, or by running the timer/balls out. Post: `ENGINE_DAILY_BUG_CONTEST`, + prize per placing, `ENGINE_BUG_CONTEST_TIMER` cleared. +10. Warp 1 at (3, 0) -> `MAP_NATIONAL_PARK` warp 3 (lands at (10, 47)). +11. `MAP_NATIONAL_PARK`: talk to `NATIONALPARK_TEACHER1` at (27, 40). + Pre: `!EVENT_GOT_QUICK_CLAW`, PACK room. Post: `EVENT_GOT_QUICK_CLAW`. +12. Optional: item ball at (1, 43) -> `TM_DIG`, flag `EVENT_NATIONAL_PARK_TM_DIG`. + Hidden `FULL_HEAL` at bg (6, 47), flag `EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL`. + Item ball at (35, 12) -> `PARLYZ_HEAL`, flag `EVENT_NATIONAL_PARK_PARLYZ_HEAL`. +13. Battle Beverly (18, 29), Jack (27, 23), Krise (8, 14), William (16, 9). +14. Walk to (33, 18), warp 1 -> `MAP_ROUTE_36_NATIONAL_PARK_GATE` warp 1. + If a prize was held: talk to `ROUTE36NATIONALPARKGATE_OFFICER1` at (0, 3) with + `ENGINE_DAILY_BUG_CONTEST` set to collect it. +15. Warp 3 at (9, 4) -> `MAP_ROUTE_36` warp 1 (lands at (18, 8)). +16. `MAP_ROUTE_36`: fruit tree at (21, 4) -> `ICE_BERRY`. Battle Psychic Mark at (20, 12). +17. Leave south (`ROUTE_36` south connection, offset 0) into `MAP_ROUTE_35`'s north edge. +18. `MAP_ROUTE_35` east side: CUT the tree at block (8, 3) (cells x16-17, y6-7). + Pre: `ENGINE_HIVEBADGE` + a mon knowing CUT. Then battle Arnie at (16, 7) and + Bryan at (14, 28), and take the item ball at (13, 16) -> `TM_ROLLOUT` + (`EVENT_ROUTE_35_TM_ROLLOUT`). Talk to Arnie a second time for + `PHONE_BUG_CATCHER_ARNIE`. +19. Return north to `MAP_ROUTE_36`. Battle Schoolboy Alan at (31, 14); talk again for + `PHONE_SCHOOLBOY_ALAN`. +20. Walk to face `ROUTE36_WEIRD_TREE` at (35, 9) and press A. Pre: SQUIRTBOTTLE in the + PACK (`checkitem`). Answer YES. A lv20 SUDOWOODO wild battle starts + (`loadwildmon SUDOWOODO, 20`). Post: `EVENT_FOUGHT_SUDOWOODO` set regardless of + outcome; `EVENT_ROUTE_36_SUDOWOODO` cleared, sprite swapped to `SPRITE_TWIN`. +21. Talk to `ROUTE36_FISHER` at (44, 9). Pre: `EVENT_FOUGHT_SUDOWOODO`, PACK room. + Post: `EVENT_GOT_TM08_ROCK_SMASH`, TM08 ROCK SMASH. +22. Optional, Thursday only: talk to `ROUTE36_ARTHUR` at (46, 6). Post: + `EVENT_MET_ARTHUR_OF_THURSDAY`, `EVENT_GOT_HARD_STONE_FROM_ARTHUR`, HARD STONE. +23. Continue east past x=59 on the `ROUTE_36` east connection into `MAP_VIOLET_CITY` + (next section). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map geometry, warps, bg/object events for all nine maps | `src/import/RomExtractorGen2.lua` (extracted into the cache), consumed by `src/world/gen2/World.lua` / `Map.lua` | implemented (data-driven; nothing map-specific is hand written) | +| Script bytecode for every label above (`FlowerShopTeacherScript`, `RandyScript`, `SudowoodoScript`, `Route36RockSmashGuyScript`, `ArthurScript`, both contest officers, `BugContestResultsScript`) | `src/script/gen2/Vm.lua` + extracted bytecode | implemented - the opcodes these scripts use (`checkevent`, `checkflag`, `checkitem`, `verbosegiveitem`, `givepoke`, `givepokemail`, `loadwildmon`, `variablesprite`, `appear`/`disappear`, `setscene`, `sdefer`, `askforphonenumber`, `checkcellnum`, `checktime`, `fruittree`) all have branches in `Vm.lua` / `Opcodes.lua` | +| `VAR_WEEKDAY` (Arthur, Sunny, contest days) | `src/world/gen2/World.lua:1201` `World:weekday`, `World.lua:101` `VAR_WEEKDAY = 0x0b` | implemented | +| `MAPCALLBACK_OBJECTS` day-of-week NPC swaps | `src/world/gen2/World.lua:5991` `World:runMapCallback`, called from `World.lua:5700` | implemented; assertion driver `tests/drivers/gold_map_callbacks.lua` exists but does **not** cover Route 36 / Route 37 / either park gate | +| Bug Catching Contest rules, timer, park balls, scoring, AI contestants, prizes | `src/core/gen2/BugContest.lua` (815 lines; transcribed from `bug_contest/judging.asm`, `contest.asm`, `contest_2.asm`, `caught_mon.asm`, `bug_contest_mons.asm`) | implemented - `BugContest.score`, `rollContestant`, `judge`, `prizeFor`, `pickContestants`, `dropOffMons`, `returnMons`, `start/stop/tickTimer`, `chooseWild` | +| Contest comparison / debug UI | `src/ui/gen2/ContestMenu.lua` | implemented (a comparison screen, not the cart's HUD) | +| Contest super-tall-grass doubled encounter rate | `src/world/gen2/Permissions.lua:85` `SUPER_TALL_GRASS`, `BugContest.encounterRate` | implemented | +| Squirtbottle field check | `src/script/gen2/CallAsm.lua:411` `H.CheckCanUseSquirtbottle` (registered as `"14:4786"`), tested in `tests/gen2_callasm_test.lua:224-244` | implemented | +| **Using the SQUIRTBOTTLE from the PACK** | `src/world/gen2/World.lua:3294` `World:useFieldItem` | **missing** - the dispatch has arms only for `ITEMFINDER`, `BICYCLE`, `SACRED_ASH`, repels, trophy boxes and rods. There is no `SQUIRTBOTTLE` arm, so `_Squirtbottle` is unreachable from the PACK. The talk-to-the-tree path (`SudowoodoScript`'s `checkitem`) still works, which is the route the walkthrough takes | +| **Item ball pickup** (TM04 on Route 35, TM28 DIG and PARLYZ HEAL in National Park) | extractor reads them (`src/import/RomExtractorGen2.lua:2969` `obj.itemball = readItemBall(...)`) but `src/world/gen2/World.lua:5257` `World:interact` only dispatches on `npc.def.trainer`, strength boulders, `npc.def.scriptKey`, bg events and hidden items | **missing** - `OBJECTTYPE_ITEMBALL` objects have no `scriptKey` by design, and nothing consumes `obj.itemball`. A bot cannot pick up any of this section's three item balls | +| Hidden item (National Park FULL HEAL, Route 37 ETHER) | `src/world/gen2/HiddenItems.lua`, wired into `World:interact` | implemented | +| Fruit trees (MYSTERYBERRY, ICE BERRY, the Route 37 apricorns) | `src/core/gen2/Apricorns.lua` + the VM's `fruittree` branch | implemented | +| Overworld trainers, sight ranges, `loadtrainer` / `startbattle` | `src/world/gen2/Trainers.lua`, `src/battle/gen2/Battle.lua` | implemented | +| Phone numbers and rematch tiers (Irwin, Arnie, Jack, Beverly, Alan) | `src/core/gen2/Phone.lua` (Jack, Beverly, Irwin, Arnie, Alan all have callee/caller script keys and `..._READY_FOR_REMATCH` event ids) | implemented | +| Kenya the SPEAROW + FLOWER MAIL | `src/core/gen2/Mail.lua` + `Vm.lua:439` `givepoke` / `Vm.lua:462` `givepokemail` | implemented (extractor resolves `cmd.mail`) | +| CUT field move + `ENGINE_HIVEBADGE` gate + the Route 35 tree block | `src/world/gen2/FieldMoves.lua:104` (`CUT = "HIVE"`), `FieldMoves.somethingToCut`, `World:tryCutOW` | implemented | +| Sudowoodo wild battle + DRAW-flee branch | `src/script/gen2/Vm.lua:44` (DRAW), `Vm.lua:836` `loadwildmon`, `World.lua:5067` (`variablesprite` with a raw sprite id) | implemented | +| Driver / regression coverage for this specific stretch | `tests/drivers/gold_*.lua` | **missing** - there is no driver that walks Route 35 -> National Park -> Route 36, runs a contest, or fights Sudowoodo. `tests/gen2_contest_test.lua` covers the contest rules headlessly; `tests/gen2_callasm_test.lua` covers only the Squirtbottle facing check | + +--- + +## 6. Unresolved / verify by hand + +1. **"Talk to Floria... while she goes away to get the Squirtbottle."** No such script + exists in pokegold. `maps/GoldenrodFlowerShop.asm` has a `GOLDENRODFLOWERSHOP_FLORIA` + object at (5, 6) whose `FlowerShopFloriaScript` only prints text, and the girl on + Route 36 at (51, 8) is an anonymous `ROUTE36_LASS1` running `Route36LassScript`, which + also only prints text. The SQUIRTBOTTLE comes from `FlowerShopTeacherScript` gated on + `ENGINE_PLAINBADGE` alone, with no Route 36 precondition. The described sequence looks + like Crystal behaviour that leaked into a Gold/Silver walkthrough. +2. **Contest Pokemon list.** The walkthrough lists "Caterpie/Weedle, Nincada, Scyther, + Pinsir, Volbeat, Wurmple" at "Levels 28-31". `data/wild/bug_contest_mons.asm` has + CATERPIE, WEEDLE, METAPOD, KAKUNA, BUTTERFREE, BEEDRILL, VENONAT, PARAS, SCYTHER, + PINSIR at levels 7-18 (plus VENOMOTH 30-40 on the `-1` terminator row, which is never + selected). NINCADA, VOLBEAT and WURMPLE do not exist in Gen 2 at all. Treat the + walkthrough's list and levels as wrong for this game. +3. **"20 Sport Balls."** The item is `PARK_BALL` (`constants/item_constants.asm:185`), + given by `special GiveParkBalls`, count `BUG_CONTEST_BALLS = 20`. "Sport Ball" is the + Gen 3+ name. +4. **"If you win the contest, you can win a Shiny Stone!"** First place gives `SUN_STONE` + (`BugContestResults_FirstPlace`, `engine/events/std_scripts.asm:346`). The + walkthrough's own item list ("Sun Stone (1st Prize)") agrees; the prose line does not. + There is no Shiny Stone in Gen 2. +5. **"If you catch a Scyther at full HP, it may be worth around 342 points."** Not + verifiable from the asm without a concrete mon: `ContestScore` (`04:7cbc`) is + `MaxHP*4 + Atk + Def + Spe + SpA + SpD + DVbonus + HP/8 + heldItem`, all taken from the + high byte of each stat word. The walkthrough's qualitative claim ("score takes HP into + consideration") is confirmed; the specific number is not checked here. +6. **National Park species list.** The walkthrough lists Butterfree, Weedle, Kakuna, + Beedrill, Paras, Venonat, Scyther and Pinsir as "found in National Park". None of them + appear in `def_grass_wildmons NATIONAL_PARK`; they are Contest-only + (`data/wild/bug_contest_mons.asm`). Sunkern, Caterpie, Metapod, Pidgey and Hoothoot are + the only real National Park grass encounters in Gold. +7. **"Routes 36 & 37" item and species lists are merged.** Everything the walkthrough + attributes to "Routes 36 & 37" except the ICE BERRY, HARD STONE and TM08 actually lives + on Route 37 (`RED_APRICORN`, `BLU_APRICORN`, `BLK_APRICORN`; Ledyba/Spinarak/Pidgeotto), + which this section never enters. Vulpix/Growlithe and Stantler appear on both routes. +8. **"Route 35 & National Park" item list omits the hidden FULL HEAL** at National Park + bg (6, 47) and lists no hidden items at all. Not a contradiction, just incomplete. +9. **"Head back to Arnie and talk to *her*."** `BUG_CATCHER, ARNIE1` uses the male phone + std scripts (`AskNumber1MScript`, `RegisteredNumberMScript`). Cosmetic only. +10. **"Then, we're ready to head into Route 35 after Liz's offer for a rematch on Route + 32."** Liz's rematch is a phone event outside this section's maps; no code on any map + in this section references it, so it was not resolved here. +11. **`EVENT_FOUGHT_SUDOWOODO` is set before the DRAW check** in + `WateredWeirdTreeScript`, and both the win branch and `DidntCatchSudowoodo` run + `disappear ROUTE36_WEIRD_TREE`. So the walkthrough's "catch it or else" is a + preference, not a gate - fainting or fleeing still opens the road and still unlocks + TM08. Worth confirming in-game that a *black-out* mid-battle does not leave a + half-applied state, which the asm alone cannot answer. +12. **`MAP_NATIONAL_PARK_BUG_CONTEST` warps 2 and 4 point at destination warp 1**, not 2, + in `maps/NationalParkBugContest.asm:228,230`. Transcribed verbatim; a bot pathing off + those rows should expect the asymmetry rather than assume a typo. diff --git a/docs/gold-walkthrough/asm-walk/section-07-burned-tower-and-ecruteak-city-gym.md b/docs/gold-walkthrough/asm-walk/section-07-burned-tower-and-ecruteak-city-gym.md new file mode 100644 index 00000000..814e06f1 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-07-burned-tower-and-ecruteak-city-gym.md @@ -0,0 +1,1227 @@ +# Section 07 - Burned Tower and Ecruteak City Gym + +Source: `../section-07-burned-tower-and-ecruteak-city-gym.txt` +Maps covered: `ROUTE_37`, `ECRUTEAK_CITY`, `ECRUTEAK_POKECENTER_1F`, `DANCE_THEATER`, +`ECRUTEAK_ITEMFINDER_HOUSE`, `BILLS_FAMILYS_HOUSE`, `BURNED_TOWER_1F`, +`BURNED_TOWER_B1F`, `ECRUTEAK_GYM` (plus one line on `ECRUTEAK_TIN_TOWER_ENTRANCE`, +which this section only unlocks). +Badges / key milestones in this section: FOGBADGE (`ENGINE_FOGBADGE`), HM03 SURF, +ITEMFINDER, EEVEE, TM30 SHADOW BALL, TIME CAPSULE (`ENGINE_TIME_CAPSULE`), +Ecruteak fly point, the three roaming beasts (`EVENT_RELEASED_THE_BEASTS`). + +Note on naming: pokegold's `map_const` rows carry no `MAP_` prefix, so the +constants below are written exactly as `constants/map_constants.asm` spells them. + +Note on coordinates: every table below is transcribed verbatim from the map asm. +`warp_event` / `coord_event` / `bg_event` / `object_event` all take `x, y` as +their first two arguments (see the macro comments in `macros/scripts/maps.asm`), +even though the macros emit `y` first. Coordinates are 16x16 walk cells, origin +top-left, so a `map_const W, H` map spans `x = 0 .. 2W-1`, `y = 0 .. 2H-1`. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `VIOLET_CITY` | `maps/VioletCity.asm` | (carried in from section 06) | south to `ROUTE_36` | Heal after Sudowoodo; swap Togepi for Sudowoodo. Belongs to section 06. | +| 1 | `ROUTE_36` | `maps/Route36.asm` | west from Violet City | north connection to `ROUTE_37` | Pass-through only in this section; Route 36's own items/trainers are section 06's. | +| 2 | `ROUTE_37` | `maps/Route37.asm` | south connection from `ROUTE_36` | north connection to `ECRUTEAK_CITY` | Twins Ann & Anne, Psychic Greg, three apricorn trees, Sunny (Sundays). | +| 3 | `ECRUTEAK_CITY` | `maps/EcruteakCity.asm` | south connection from `ROUTE_37` | warp 6 (Pokecenter) | First arrival; sets `ENGINE_FLYPOINT_ECRUTEAK`. | +| 4 | `ECRUTEAK_POKECENTER_1F` | `maps/EcruteakPokecenter1F.asm` | Ecruteak warp 6 at (23,27) | same warp | Bill cutscene, Time Capsule, heal, deposit apricorns/berries. | +| 5 | `ROUTE_37` -> `ROUTE_36` -> `NATIONAL_PARK` -> `ROUTE_35` -> `GOLDENROD_CITY` | - | biking back south | - | Backtrack for the apricorn trees and Bill's Eevee. Only Route 37 is owned here. | +| 6 | `BILLS_FAMILYS_HOUSE` | `maps/BillsFamilysHouse.asm` | Goldenrod warp 4 at (5,25) | same warp | `givepoke EEVEE, 20`. | +| 7 | `ECRUTEAK_CITY` | `maps/EcruteakCity.asm` | back north via Route 35/36/37 | warp 8 (Dance Theater) | Return with Eevee. | +| 8 | `DANCE_THEATER` | `maps/DanceTheater.asm` | Ecruteak warp 8 at (23,21) | warps 1/2 at (5,13)/(6,13) | Five Kimono Girls, then HM03 SURF from the gentleman. | +| 9 | `ECRUTEAK_ITEMFINDER_HOUSE` | `maps/EcruteakItemfinderHouse.asm` | Ecruteak warp 11 at (13,27) | warps 1/2 at (3,7)/(4,7) | ITEMFINDER for answering `yesorno` with YES. | +| 10 | `BURNED_TOWER_1F` | `maps/BurnedTower1F.asm` | Ecruteak warp 13 at (5,5) | warps 1/2 at (9,15)/(10,15) | Rival battle on entry; Rock Smash gates; drop to B1F. | +| 11 | `BURNED_TOWER_B1F` | `maps/BurnedTowerB1F.asm` | fall through a 1F pit | ladder at (7,15) | HP UP, TM ENDURE, and the `ReleaseTheBeasts` coord event. | +| 12 | `ECRUTEAK_GYM` | `maps/EcruteakGym.asm` | Ecruteak warp 10 at (6,27) | warps 1/2 at (4,17)/(5,17) | Four ghost trainers on the invisible floor, then Morty. | + +Spill into the next section: after FOGBADGE the walkthrough points at Olivine +Lighthouse and at Union Cave for Lapras. `EcruteakGymMortyScript` also does +`setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP`, +which is what un-blocks Tin Tower - that map is the next section's problem. + +--- + +## 2. Maps + +### ROUTE_37 + +- Script: `maps/Route37.asm` +- Blocks: `maps/Route37.blk` +- Header: `data/maps/maps.asm:250` -> `TILESET_JOHTO`, `ROUTE`, `LANDMARK_ROUTE_37`, + `MUSIC_ROUTE_36`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:229` -> `map_const ROUTE_37, 10, 9` + (walk cells `x 0..19`, `y 0..17`) +- Connections: `data/maps/attributes.asm:211` -> north `EcruteakCity` (offset -5), + south `Route36` (offset -10). No east/west. + +**Warps** (`def_warp_events`) + +Empty. Route 37 is reached only through map connections. + +**Coord events** (`def_coord_events`) + +Empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 3 | `BGEVENT_READ` | `Route37Sign` | +| 4 | 2 | `BGEVENT_ITEM` | `Route37HiddenEther` -> `hiddenitem ETHER, EVENT_ROUTE_37_HIDDEN_ETHER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE37_WEIRD_TREE1` | `SPRITE_WEIRD_TREE` | 6 | 12 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerTwinsAnnandanne1` | -1 | +| `ROUTE37_WEIRD_TREE2` | `SPRITE_WEIRD_TREE` | 7 | 12 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerTwinsAnnandanne2` | -1 | +| `ROUTE37_YOUNGSTER` | `SPRITE_YOUNGSTER` | 9 | 6 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPsychicGreg` | -1 | +| `ROUTE37_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 13 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree1` | -1 | +| `ROUTE37_SUNNY` | `SPRITE_BUG_CATCHER` | 16 | 8 | `WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `SunnyScript` | `EVENT_ROUTE_37_SUNNY_OF_SUNDAY` | +| `ROUTE37_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 16 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree2` | -1 | +| `ROUTE37_FRUIT_TREE3` | `SPRITE_FRUIT_TREE` | 15 | 7 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree3` | -1 | + +The two twin objects really are declared `SPRITE_WEIRD_TREE` with object consts +named `ROUTE37_WEIRD_TREE1/2`; `engine/events/std_scripts.asm` carries +`variablesprite SPRITE_WEIRD_TREE, SPRITE_SUDOWOODO`, so the sprite slot is +shared with Sudowoodo. Flagged under section 6 as well - see "Unresolved". + +**Scripts of interest** + +- `Route37SunnyCallback` (`MAPCALLBACK_OBJECTS`): `readvar VAR_WEEKDAY`, + `ifequal SUNDAY` -> `appear ROUTE37_SUNNY`, otherwise `disappear ROUTE37_SUNNY`. + This is the only reason Sunny exists on the map, so a bot must fake or wait + for Sunday. +- `SunnyScript` (`4c:404d`): `checkevent EVENT_GOT_MAGNET_FROM_SUNNY` -> already + done; else `readvar VAR_WEEKDAY`, `ifnotequal SUNDAY` -> refusal text; else + `setevent EVENT_MET_SUNNY_OF_SUNDAY`, `verbosegiveitem MAGNET`, + `setevent EVENT_GOT_MAGNET_FROM_SUNNY`. +- `TrainerTwinsAnnandanne1` / `TrainerTwinsAnnandanne2`: both use the *same* + `EVENT_BEAT_TWINS_ANN_AND_ANNE` flag, so beating either twin marks both beaten + and the second never battles. `EVENT_BEAT_TWINS_ANN_AND_ANNE2` exists in + `constants/event_flags.asm:610` but nothing on this map references it. +- `Route37FruitTree1/2/3` -> `fruittree FRUITTREE_ROUTE_37_1/2/3`. + `data/items/fruit_trees.asm:20-22` gives `RED_APRICORN`, `BLU_APRICORN`, + `BLK_APRICORN` in that order. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_37_SUNNY_OF_SUNDAY` | `constants/event_flags.asm:1277` | `Route37SunnyCallback` (`appear`/`disappear`) | Set = Sunny hidden. Object masks when the flag is SET (`CheckObjectFlag`, `engine/overworld/map_objects_2.asm:32`). | +| `EVENT_MET_SUNNY_OF_SUNDAY` | `constants/event_flags.asm:113` | `SunnyScript` | First-meeting text guard. | +| `EVENT_GOT_MAGNET_FROM_SUNNY` | `constants/event_flags.asm:114` | `SunnyScript` | One-time MAGNET. | +| `EVENT_ROUTE_37_HIDDEN_ETHER` | `constants/event_flags.asm:179` | `Route37HiddenEther` | Hidden ETHER at (4,2). | +| `EVENT_BEAT_TWINS_ANN_AND_ANNE` | `constants/event_flags.asm:609` | both twin `trainer` rows | One flag, two objects. | +| `EVENT_BEAT_PSYCHIC_GREG` | `constants/event_flags.asm:567` | `TrainerPsychicGreg` | - | +| `ENGINE_BIKE_SHOP_CALL_ENABLED` | `constants/engine_flags.asm:29` | set by `maps/GoldenrodBikeShop.asm:28`, cleared by `engine/phone/scripts/bike_shop.asm:3` | The "keep the bicycle" call the walkthrough hits here. | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `RED_APRICORN` | fruit tree (daily) | object (13,5) -> `Route37FruitTree1` -> `FRUITTREE_ROUTE_37_1` | none (daily reset) | +| `BLU_APRICORN` | fruit tree (daily) | object (16,5) -> `Route37FruitTree2` -> `FRUITTREE_ROUTE_37_2` | none | +| `BLK_APRICORN` | fruit tree (daily) | object (15,7) -> `Route37FruitTree3` -> `FRUITTREE_ROUTE_37_3` | none | +| `ETHER` | hidden | `bg_event 4, 2, BGEVENT_ITEM` | `EVENT_ROUTE_37_HIDDEN_ETHER` | +| `MAGNET` | Sunny, Sundays only | `SunnyScript` `verbosegiveitem MAGNET` | `EVENT_GOT_MAGNET_FROM_SUNNY` | + +The walkthrough's "Hard Stone / Ice Berry / TM08 Rock Smash" for "Route 36 & 37" +are all on `ROUTE_36`, not Route 37. That map belongs to section 06. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `TWINS` / `ANNANDANNE1` | `TWINS` | `ANNANDANNE1` (`TwinsGroup` entry 2) | L16 CLEFAIRY (GROWL, ENCORE, DOUBLESLAP, METRONOME); L16 JIGGLYPUFF (SING, DEFENSE_CURL, POUND, DISABLE) | `TrainerTwinsAnnandanne1` | none | +| `TWINS` / `ANNANDANNE2` | `TWINS` | `ANNANDANNE2` (`TwinsGroup` entry 3) | L16 JIGGLYPUFF; L16 CLEFAIRY (same moves, reversed order) | `TrainerTwinsAnnandanne2` | none | +| `PSYCHIC_T` / `GREG` | `PSYCHIC_T` | `GREG` (`PsychicGroup` entry 5) | L17 DROWZEE (HYPNOSIS, DISABLE, DREAM_EATER) | `TrainerPsychicGreg` | none | + +**Wild encounters** + +`data/wild/johto_grass.asm:1959`, `def_grass_wildmons ROUTE_37`, encounter rate +10% morn / 10% day / 10% nite. + +Gold (`IF DEF(_GOLD)`): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L13 PIDGEY | L13 PIDGEY | L13 SPINARAK | +| 2 | L15 STANTLER | L15 STANTLER | L15 STANTLER | +| 3 | L15 PIDGEY | L15 PIDGEY | L15 HOOTHOOT | +| 4 | L14 GROWLITHE | L14 GROWLITHE | L14 GROWLITHE | +| 5 | L15 PIDGEY | L15 PIDGEOTTO | L15 SPINARAK | +| 6 | L15 PIDGEY | L16 GROWLITHE | L15 SPINARAK | +| 7 | L15 PIDGEY | L16 GROWLITHE | L15 SPINARAK | + +Silver (`ELIF DEF(_SILVER)`) swaps GROWLITHE -> VULPIX and SPINARAK -> LEDYBA +(morn) / HOOTHOOT (nite); slot 1 morn is L13 LEDYBA, slot 1 nite L13 HOOTHOOT. + +Headbutt: `data/wild/treemon_maps.asm:18` -> `treemon_map ROUTE_37, TREEMON_SET_FOREST`. +Roaming beasts: `data/wild/roammon_maps.asm:27` -> `roam_map ROUTE_37, ROUTE_36, ROUTE_38, ROUTE_42`. +No water, no fishing. + +--- + +### ECRUTEAK_CITY + +- Script: `maps/EcruteakCity.asm` +- Blocks: `maps/EcruteakCity.blk` +- Header: `data/maps/maps.asm:173` -> `TILESET_JOHTO`, `TOWN`, `LANDMARK_ECRUTEAK_CITY`, + `MUSIC_ECRUTEAK_CITY`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:159` -> `map_const ECRUTEAK_CITY, 20, 18` + (walk cells `x 0..39`, `y 0..35`) +- Connections: `data/maps/attributes.asm:147` -> south `Route37` (offset 5), + west `Route38` (offset 5), east `Route42` (offset 9) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 35 | 26 | `ROUTE_42_ECRUTEAK_GATE` | 1 | +| 2 | 35 | 27 | `ROUTE_42_ECRUTEAK_GATE` | 2 | +| 3 | 18 | 11 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 1 | +| 4 | 20 | 2 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 1 | +| 5 | 20 | 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 2 | +| 6 | 23 | 27 | `ECRUTEAK_POKECENTER_1F` | 1 | +| 7 | 5 | 21 | `ECRUTEAK_LUGIA_SPEECH_HOUSE` | 1 | +| 8 | 23 | 21 | `DANCE_THEATER` | 1 | +| 9 | 29 | 21 | `ECRUTEAK_MART` | 2 | +| 10 | 6 | 27 | `ECRUTEAK_GYM` | 1 | +| 11 | 13 | 27 | `ECRUTEAK_ITEMFINDER_HOUSE` | 1 | +| 12 | 37 | 7 | `TIN_TOWER_1F` | 1 | +| 13 | 5 | 5 | `BURNED_TOWER_1F` | 1 | +| 14 | 0 | 18 | `ROUTE_38_ECRUTEAK_GATE` | 3 | +| 15 | 0 | 19 | `ROUTE_38_ECRUTEAK_GATE` | 4 | + +**Coord events** (`def_coord_events`) + +Empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 15 | 21 | `BGEVENT_READ` | `EcruteakCitySign` | +| 38 | 10 | `BGEVENT_READ` | `TinTowerSign` | +| 8 | 28 | `BGEVENT_READ` | `EcruteakGymSign` | +| 21 | 21 | `BGEVENT_READ` | `EcruteakDanceTheaterSign` | +| 2 | 10 | `BGEVENT_READ` | `BurnedTowerSign` | +| 24 | 27 | `BGEVENT_READ` | `EcruteakCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 30 | 21 | `BGEVENT_READ` | `EcruteakCityMartSign` (`jumpstd MartSignScript`) | +| 23 | 14 | `BGEVENT_ITEM` | `EcruteakCityHiddenHyperPotion` -> `hiddenitem HYPER_POTION, EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKCITY_GRAMPS1` | `SPRITE_GRAMPS` | 18 | 15 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakCityGramps1Script` | -1 | +| `ECRUTEAKCITY_GRAMPS2` | `SPRITE_GRAMPS` | 20 | 21 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `EcruteakCityGramps2Script` | -1 | +| `ECRUTEAKCITY_LASS1` | `SPRITE_LASS` | 21 | 29 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `EcruteakCityLass1Script` | -1 | +| `ECRUTEAKCITY_LASS2` | `SPRITE_LASS` | 3 | 9 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `EcruteakCityLass2Script` | -1 | +| `ECRUTEAKCITY_FISHER` | `SPRITE_FISHER` | 9 | 22 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `EcruteakCityFisherScript` | -1 | +| `ECRUTEAKCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 10 | 14 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakCityYoungsterScript` | -1 | + +**Scripts of interest** + +- `EcruteakCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): a bare + `setflag ENGINE_FLYPOINT_ECRUTEAK` / `endcallback`. Arriving on the map at all + registers the fly point. +- `EcruteakCityLass2Script`: branches on `EVENT_RELEASED_THE_BEASTS` - a cheap, + in-world way for a bot to confirm the Burned Tower cutscene fired. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_ECRUTEAK` | `constants/engine_flags.asm:86` | `EcruteakCityFlypointCallback` | Fly destination unlocked on first entry. | +| `EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION` | `constants/event_flags.asm:190` | `EcruteakCityHiddenHyperPotion` | Hidden HYPER POTION at (23,14). | +| `EVENT_RELEASED_THE_BEASTS` | `constants/event_flags.asm:132` | read here, written by `ReleaseTheBeasts` in `maps/BurnedTowerB1F.asm:64` | Beasts cutscene done. | +| `EVENT_JASMINE_RETURNED_TO_GYM` | `constants/event_flags.asm` | read by `EcruteakCityFisherScript` | Later-section state; not touched here. | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HYPER_POTION` | hidden, needs ITEMFINDER to find comfortably | `bg_event 23, 14, BGEVENT_ITEM` | `EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION` | + +**Trainers** + +None on the overworld map. + +**Wild encounters** + +Grass: none (`data/wild/johto_grass.asm` has no `ECRUTEAK_CITY` entry). +Water: `data/wild/johto_water.asm:246` -> `def_water_wildmons ECRUTEAK_CITY`, +2% encounter rate, L20 POLIWAG / L15 POLIWAG / L20 POLIWHIRL. Reachable only +after SURF is usable, i.e. after Morty. +Fishing group `FISHGROUP_POND` (`data/maps/maps.asm:173`), see `data/wild/fish.asm`. +Headbutt: `data/wild/treemon_maps.asm:35` -> `TREEMON_SET_CITY`. + +--- + +### ECRUTEAK_POKECENTER_1F + +- Script: `maps/EcruteakPokecenter1F.asm` +- Blocks: none (`data/maps/attributes.asm` uses the shared Pokecenter layout) +- Header: `data/maps/maps.asm:167` -> `TILESET_POKECENTER`, `INDOOR`, + `LANDMARK_ECRUTEAK_CITY`, `MUSIC_POKEMON_CENTER`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:153` -> `map_const ECRUTEAK_POKECENTER_1F, 5, 4` +- Scene var: `data/maps/scenes.asm:43` -> `scene_var ECRUTEAK_POKECENTER_1F, wEcruteakPokecenter1FSceneID` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ECRUTEAK_CITY` | 6 | +| 2 | 4 | 7 | `ECRUTEAK_CITY` | 6 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events / BG events** + +Both empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakPokecenter1FNurseScript` | -1 | +| `ECRUTEAKPOKECENTER1F_POKEFAN_M` | `SPRITE_POKEFAN_M` | 7 | 6 | `SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | `EcruteakPokecenter1FPokefanMScript` | -1 | +| `ECRUTEAKPOKECENTER1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 1 | 4 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `EcruteakPokecenter1FCooltrainerFScript` | -1 | +| `ECRUTEAKPOKECENTER1F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakPokecenter1FGymGuideScript` | -1 | +| `ECRUTEAKPOKECENTER1F_BILL` | `SPRITE_BILL` | 0 | 7 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_ECRUTEAK_POKE_CENTER_BILL` | + +**Scripts of interest** + +- Scene table: `scene_script EcruteakPokecenter1FMeetBillScene, SCENE_ECRUTEAKPOKECENTER1F_MEET_BILL` + (scene id 0, the wram default) then `..._NOOP` (scene id 1). Scene constants are + generated positionally by the `def_scene_scripts` / `scene_script` macros in + `macros/scripts/maps.asm:12-33`; there is no `SCENE_*` constants file. +- `EcruteakPokecenter1FMeetBillScene` -> `sdefer EcruteakPokcenter1FBillActivatesTimeCapsuleScript` + (`52:4299`). This is the "you cannot move for a second" the walkthrough + describes: it fires on map load, not on a coord trip-wire. +- `EcruteakPokcenter1FBillActivatesTimeCapsuleScript`: + `appear ECRUTEAKPOKECENTER1F_BILL` -> + `applymovement BILL` (UP x4, RIGHT x3, `turn_head UP`) and + `applymovement PLAYER` (UP x3) -> two text blocks -> + `applymovement BILL` (RIGHT, DOWN x4) -> `disappear` -> + **`clearevent EVENT_MET_BILL`** -> `setflag ENGINE_TIME_CAPSULE` -> + `setscene SCENE_ECRUTEAKPOKECENTER1F_NOOP`. + +`EVENT_MET_BILL` is *set* by the new-game initialisation block in +`engine/events/std_scripts.asm` (the long `setevent` run around line 514), so +Bill's object in `BILLS_FAMILYS_HOUSE` is masked until this cutscene clears it. +Talking to Bill in Goldenrod is impossible before this scene runs - this is the +hard ordering constraint behind the walkthrough's backtrack. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ECRUTEAK_POKE_CENTER_BILL` | `constants/event_flags.asm:1205` | `appear`/`disappear` in the cutscene | Set = Bill hidden here. Set at new game. | +| `EVENT_MET_BILL` | `constants/event_flags.asm:1204` | cleared here; read by `BillsFamilysHouse` object row and `BillsMomScript` | Cleared = Bill visible in Goldenrod. | +| `ENGINE_TIME_CAPSULE` | `constants/engine_flags.asm:100` | set here | Time Capsule menu option. | + +--- + +### DANCE_THEATER + +- Script: `maps/DanceTheater.asm` +- Blocks: `maps/DanceTheater.blk` +- Header: `data/maps/maps.asm:169` -> `TILESET_TRADITIONAL_HOUSE`, `INDOOR`, + `LANDMARK_ECRUTEAK_CITY`, `MUSIC_DANCING_HALL`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:155` -> `map_const DANCE_THEATER, 6, 7` + (walk cells `x 0..11`, `y 0..13`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 13 | `ECRUTEAK_CITY` | 8 | +| 2 | 6 | 13 | `ECRUTEAK_CITY` | 8 | + +**Coord events** + +Empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 6 | `BGEVENT_UP` | `DanceTheaterFancyPanel` | +| 6 | 6 | `BGEVENT_UP` | `DanceTheaterFancyPanel` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DANCETHEATER_KIMONO_GIRL1` | `SPRITE_KIMONO_GIRL` | 0 | 2 | `SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerKimonoGirlNaoko` | -1 | +| `DANCETHEATER_KIMONO_GIRL2` | `SPRITE_KIMONO_GIRL` | 2 | 1 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerKimonoGirlSayo` | -1 | +| `DANCETHEATER_KIMONO_GIRL3` | `SPRITE_KIMONO_GIRL` | 6 | 2 | `SPINRANDOM_SLOW` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerKimonoGirlZuki` | -1 | +| `DANCETHEATER_KIMONO_GIRL4` | `SPRITE_KIMONO_GIRL` | 9 | 1 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerKimonoGirlKuni` | -1 | +| `DANCETHEATER_KIMONO_GIRL5` | `SPRITE_KIMONO_GIRL` | 11 | 2 | `SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerKimonoGirlMiki` | -1 | +| `DANCETHEATER_GENTLEMAN` | `SPRITE_GENTLEMAN` | 7 | 10 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `DanceTheaterSurfGuy` | -1 | +| `DANCETHEATER_RHYDON` | `SPRITE_RHYDON` | 6 | 8 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `DanceTheaterRhydon` | -1 | +| `DANCETHEATER_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 10 | 10 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `DanceTheaterCooltrainerMScript` | -1 | +| `DANCETHEATER_GRANNY` | `SPRITE_GRANNY` | 3 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `DanceTheaterGrannyScript` | -1 | + +All five Kimono Girls have **sight range 0** - they never initiate. A bot must +walk adjacent and press A on each one. + +**Scripts of interest** + +- `DanceTheaterSurfGuy` (`52:485c`): `faceplayer`, `opentext`, + `writetext SurfGuyNeverLeftAScratchText`, then + `checkevent EVENT_GOT_HM03_SURF` -> `SurfGuyAlreadyGaveSurf`, else five + `checkevent` / `iffalse .KimonoGirlsUndefeated` in the order + NAOKO, SAYO, ZUKI, KUNI, MIKI; all five set -> + `.GetSurf` (`52:488f`): `verbosegiveitem HM_SURF`, + `setevent EVENT_GOT_HM03_SURF`. Note there is no `iffalse` after + `verbosegiveitem HM_SURF`, so a full bag still sets the flag - see "Unresolved". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_KIMONO_GIRL_NAOKO` | `constants/event_flags.asm:744` | `TrainerKimonoGirlNaoko` | gate 1/5 for SURF | +| `EVENT_BEAT_KIMONO_GIRL_SAYO` | `:745` | `TrainerKimonoGirlSayo` | gate 2/5 | +| `EVENT_BEAT_KIMONO_GIRL_ZUKI` | `:746` | `TrainerKimonoGirlZuki` | gate 3/5 | +| `EVENT_BEAT_KIMONO_GIRL_KUNI` | `:747` | `TrainerKimonoGirlKuni` | gate 4/5 | +| `EVENT_BEAT_KIMONO_GIRL_MIKI` | `:748` | `TrainerKimonoGirlMiki` | gate 5/5 | +| `EVENT_GOT_HM03_SURF` | `constants/event_flags.asm:25` | `DanceTheaterSurfGuy` | HM03 in the bag | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HM_SURF` | talk to the gentleman after all five Kimono Girls | `DanceTheaterSurfGuy.GetSurf` | `EVENT_GOT_HM03_SURF` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`, `KimonoGirlGroup`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `KIMONO_GIRL` / `NAOKO` | `KIMONO_GIRL` | entry 2 "NAOKO" | L17 FLAREON (`TRAINERTYPE_NORMAL`) | `TrainerKimonoGirlNaoko` | none | +| `KIMONO_GIRL` / `SAYO` | `KIMONO_GIRL` | entry 3 "SAYO" | L17 ESPEON | `TrainerKimonoGirlSayo` | none | +| `KIMONO_GIRL` / `ZUKI` | `KIMONO_GIRL` | entry 4 "ZUKI" | L17 UMBREON | `TrainerKimonoGirlZuki` | none | +| `KIMONO_GIRL` / `KUNI` | `KIMONO_GIRL` | entry 5 "KUNI" | L17 VAPOREON | `TrainerKimonoGirlKuni` | none | +| `KIMONO_GIRL` / `MIKI` | `KIMONO_GIRL` | entry 6 "MIKI" | L17 JOLTEON | `TrainerKimonoGirlMiki` | none | + +Constants at `constants/trainer_constants.asm:614-618`. All are +`TRAINERTYPE_NORMAL`, i.e. level-up movesets, no custom moves. + +**Wild encounters** + +None (indoor). + +--- + +### ECRUTEAK_ITEMFINDER_HOUSE + +- Script: `maps/EcruteakItemfinderHouse.asm` +- Blocks: none of its own +- Header: `data/maps/maps.asm:172` -> `TILESET_TRADITIONAL_HOUSE`, `INDOOR`, + `LANDMARK_ECRUTEAK_CITY`, `MUSIC_ECRUTEAK_CITY`, phone `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:158` -> `map_const ECRUTEAK_ITEMFINDER_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ECRUTEAK_CITY` | 11 | +| 2 | 4 | 7 | `ECRUTEAK_CITY` | 11 | + +**Coord events**: empty. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 1 | `BGEVENT_READ` | `ItemFinderHouseRadio` (`jumpstd Radio2Script`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKITEMFINDERHOUSE_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 2 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakItemfinderGuy` | -1 | +| `ECRUTEAKITEMFINDERHOUSE_POKEDEX` | `SPRITE_POKEDEX` | 3 | 3 | `STILL` | `OBJECTTYPE_SCRIPT` | `EcruteakHistoryBook` | -1 | + +**Scripts of interest** + +- `EcruteakItemfinderGuy` (`52:588b`): `checkevent EVENT_GOT_ITEMFINDER` -> + already done; else `writetext EcruteakItemfinderAdventureText`, `yesorno`, + `iffalse .no`, then `verbosegiveitem ITEMFINDER`, + `setevent EVENT_GOT_ITEMFINDER`. A bot must answer YES. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ITEMFINDER` | answer YES to the cooltrainer | `EcruteakItemfinderGuy` | `EVENT_GOT_ITEMFINDER` (`constants/event_flags.asm:99`) | + +--- + +### BILLS_FAMILYS_HOUSE + +Not an Ecruteak map - it is in Goldenrod, warp 4 of `GOLDENROD_CITY` at (5,25). +Included because this section's Eevee comes from here and is ordering-locked by +the Ecruteak Pokecenter cutscene. + +- Script: `maps/BillsFamilysHouse.asm` +- Header: `data/maps/maps.asm:273` -> `TILESET_HOUSE`, `INDOOR`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_GOLDENROD_CITY`, phone `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:251` -> `map_const BILLS_FAMILYS_HOUSE, 4, 4` + +Do not confuse with `maps/BillsHouse.asm` (`BILLS_HOUSE`, Kanto Route 25, +`constants/map_constants.asm:197`) - that is Bill's grandfather and the +stone-for-showing-a-mon chain, nothing to do with Eevee. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 4 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 4 | + +**Coord events**: empty. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `BillsHouseBookshelf2` (`jumpstd MagazineBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `BillsHouseBookshelf1` (`jumpstd PictureBookshelfScript`) | +| 7 | 1 | `BGEVENT_READ` | `BillsHouseRadio` (`jumpstd Radio2Script`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BILLSFAMILYSHOUSE_BILL` | `SPRITE_BILL` | 2 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `BillScript` | `EVENT_MET_BILL` | +| `BILLSFAMILYSHOUSE_POKEFAN_F` | `SPRITE_POKEFAN_F` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BillsMomScript` | -1 | +| `BILLSFAMILYSHOUSE_TWIN` | `SPRITE_TWIN` | 5 | 4 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `BillsYoungerSisterScript` | -1 | + +**Scripts of interest** + +- `BillScript` (`57:4bee`): `checkevent EVENT_GOT_EEVEE` -> done; else + `writetext BillTakeThisEeveeText`, `yesorno`, `iffalse .Refused`, + then `readvar VAR_PARTYCOUNT`, **`ifequal PARTY_LENGTH, .NoRoom`** - + this is the walkthrough's "clear one Pokémon spot" - then + `givepoke EEVEE, 20` and `setevent EVENT_GOT_EEVEE`. +- `BillsYoungerSisterScript`: `checkcellnum PHONE_BILL` / `askforphonenumber` / + `addcellnum PHONE_BILL`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_MET_BILL` | `constants/event_flags.asm:1204` | object mask here; cleared by the Ecruteak Pokecenter cutscene | Set (new-game default) = Bill absent. | +| `EVENT_GOT_EEVEE` | `constants/event_flags.asm:88` | `BillScript` | One-time L20 EEVEE. | + +**Items / Pokemon** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| EEVEE (L20) | `givepoke EEVEE, 20` after YES with a free party slot | `BillScript` | `EVENT_GOT_EEVEE` | + +--- + +### BURNED_TOWER_1F + +- Script: `maps/BurnedTower1F.asm` +- Blocks: `maps/BurnedTower1F.blk` (90 bytes = 10x9 blocks) +- Header: `data/maps/maps.asm:91` -> `TILESET_TOWER`, `DUNGEON`, + `LANDMARK_BURNED_TOWER`, `MUSIC_BURNED_TOWER`, phone `FALSE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:78` -> `map_const BURNED_TOWER_1F, 10, 9` + (walk cells `x 0..19`, `y 0..17`) +- Scene var: `data/maps/scenes.asm:48` -> `wBurnedTower1FSceneID` +- Scene ids (positional, from `def_scene_scripts`): 0 = + `SCENE_BURNEDTOWER1F_RIVAL_BATTLE`, 1 = `SCENE_BURNEDTOWER1F_FIREBREATHER_DICK`, + 2 = `SCENE_BURNEDTOWER1F_NOOP` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `ECRUTEAK_CITY` | 13 | +| 2 | 10 | 15 | `ECRUTEAK_CITY` | 13 | +| 3 | 5 | 4 | `BURNED_TOWER_B1F` | 1 | +| 4 | 5 | 5 | `BURNED_TOWER_B1F` | 1 | +| 5 | 5 | 6 | `BURNED_TOWER_B1F` | 1 | +| 6 | 4 | 6 | `BURNED_TOWER_B1F` | 1 | +| 7 | 15 | 4 | `BURNED_TOWER_B1F` | 2 | +| 8 | 15 | 5 | `BURNED_TOWER_B1F` | 2 | +| 9 | 10 | 7 | `BURNED_TOWER_B1F` | 3 | +| 10 | 5 | 14 | `BURNED_TOWER_B1F` | 4 | +| 11 | 4 | 14 | `BURNED_TOWER_B1F` | 4 | +| 12 | 14 | 14 | `BURNED_TOWER_B1F` | 5 | +| 13 | 15 | 14 | `BURNED_TOWER_B1F` | 5 | +| 14 | 7 | 15 | `BURNED_TOWER_B1F` | 6 | + +Warps 1/2 are the `WARP_CARPET_DOWN` doorway. Warps 3-13 are `PIT` tiles (one-way +falls). Warp 14 is the only `LADDER` and the only two-way link to B1F. + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_BURNEDTOWER1F_FIREBREATHER_DICK` (1) | 8 | 1 | `FirebreatherDickFight` | Ambush battle with Firebreather Dick. | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 8 | 7 | `BGEVENT_ITEM` | `BurnedTower1FHiddenEther` -> `hiddenitem ETHER, EVENT_BURNED_TOWER_1F_HIDDEN_ETHER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BURNEDTOWER1F_FIREBREATHER_DICK` | `SPRITE_FISHER` | 8 | 3 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `FirebreatherDickPostBattle` | `EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL` | +| `BURNEDTOWER1F_FIREBREATHER_NED` | `SPRITE_FISHER` | 16 | 8 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerFirebreatherNed` | -1 | +| `BURNEDTOWER1F_ROCK1` | `SPRITE_ROCK` | 4 | 3 | `SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `BurnedTower1FRock` | -1 | +| `BURNEDTOWER1F_ROCK2` | `SPRITE_ROCK` | 16 | 13 | `SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `BurnedTower1FRock` | -1 | +| `BURNEDTOWER1F_RIVAL` | `SPRITE_RIVAL` | 9 | 12 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `ObjectEvent` | `EVENT_RIVAL_BURNED_TOWER` | +| `BURNEDTOWER1F_FIREBREATHER_DICK_ASHES` | `SPRITE_FISHER` | 8 | 2 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `FirebreatherDickPostBattle` | `EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES` | +| `BURNEDTOWER1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 15 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `BurnedTower1FBurnHeal` (`itemball BURN_HEAL, 1`) | `EVENT_BURNED_TOWER_1F_X_SPEED` | +| `BURNEDTOWER1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 7 | 5 | `STILL` | `OBJECTTYPE_ITEMBALL` | `BurnedTower1FXSpeed` (`itemball X_SPEED, 1`) | `EVENT_BURNED_TOWER_1F_BURN_HEAL` | + +The two item ball rows have their one-time flags crossed: the ball that gives +`BURN_HEAL` is masked by `EVENT_BURNED_TOWER_1F_X_SPEED` and vice versa. It is +harmless (each ball still disappears exactly once) but a bot reading flags to +decide "do I already have the Burn Heal" must use the *row's* flag, not the item +name. + +**Derived collision map** (`maps/BurnedTower1F.blk` + `data/tilesets/tower_collision.asm`; +`.` floor, `#` wall, `O` pit/warp, `L` ladder, `v` door) + +``` + 0 1 + 0123456789012345678 + 1 ##........##....### + 2 ###..##...###...### + 3 #.#..#....#.....### + 4 #...O###...##.O#### + 5 #...O#.#....#.O.### + 6 #..OO#.##...#..#### + 7 #....#..#O..#..#.## + 8 #...##.####.#....## + 9 #...##..##..#....## +10 ##.###.....###...## +11 ##..########.#...## +12 ###..........##..## +13 #.............#..## +14 ###OO........OO.### +15 ##....L.vv......### +``` +(column 0 and rows 0/16-17 are solid wall and are trimmed.) + +**Scripts of interest** + +- `BurnedTower1FRivalBattleScene` (scene 0, the wram default) -> + `sdefer BurnedTower1FRivalBattleScript` (`42:4e40`). Scene scripts run at map + load, so **the rival battle fires the moment you first walk in the front door**, + with the player at (9,15) and the rival at (9,12) walking two steps DOWN to + (9,14). It is not at the far end of a spiral. +- `BurnedTower1FRivalBattleScript`: `turnobject PLAYER, UP`, + `showemote EMOTE_SHOCK`, `special FadeOutMusic`, rival turn/pause choreography, + `applymovement BURNEDTOWER1F_RIVAL, BurnedTowerMovement_RivalWalksToPlayer` + (DOWN, DOWN), `playmusic MUSIC_RIVAL_ENCOUNTER`, then the starter branch: + `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> `loadtrainer RIVAL1, RIVAL1_3_CHIKORITA`; + `checkevent EVENT_GOT_CHIKORITA_FROM_ELM` -> `loadtrainer RIVAL1, RIVAL1_3_CYNDAQUIL`; + otherwise (player took Cyndaquil) `loadtrainer RIVAL1, RIVAL1_3_TOTODILE`. + After: `playmusic MUSIC_RIVAL_AFTER`, + `applymovement ... BurnedTowerMovement_RivalLeaves` (RIGHT, DOWN), + `disappear BURNEDTOWER1F_RIVAL`, + `setscene SCENE_BURNEDTOWER1F_FIREBREATHER_DICK`. +- `FirebreatherDickFight` (`42:4ec1`), reached by the (8,1) coord event once the + scene is 1: `showemote`, `applymovement` (Dick steps UP), + `loadtrainer FIREBREATHER, DICK`, `startbattle`; on a win + `disappear ..._DICK` / `appear ..._DICK_ASHES`, + `setevent EVENT_BEAT_FIREBREATHER_DICK`, + `setscene SCENE_BURNEDTOWER1F_NOOP`, + `clearevent EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL`, + `setevent EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES`. +- `BurnedTower1FRock` -> `jumpstd SmashRockScript` + (`engine/events/std_scripts.asm:199`) -> `farsjump AskRockSmashScript` + (`engine/events/overworld.asm:1365`) -> `callasm HasRockSmash` + (`CheckPartyMove ROCK_SMASH`). **No badge is required for Rock Smash**, only a + party member that knows the move. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RIVAL_BURNED_TOWER` | `constants/event_flags.asm:1127` | masks the rival object; **set** by `UndergroundRivalBattleScript` in `maps/GoldenrodUndergroundSwitchRoomEntrances.asm:125` | If you fight the rival in the Goldenrod Underground first, that script also does `setmapscene BURNED_TOWER_1F, SCENE_BURNEDTOWER1F_FIREBREATHER_DICK` and the Burned Tower rival battle never happens. Do this section before the Underground. | +| `EVENT_BEAT_FIREBREATHER_DICK` | `:577` | `FirebreatherDickFight` | - | +| `EVENT_BEAT_FIREBREATHER_NED` | `:578` | `TrainerFirebreatherNed` | - | +| `EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL` | `:1286` | cleared after the fight | Masks the pre-fight Dick at (8,3). | +| `EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES` | `:1287` | set at new game, cleared/set here | Masks the post-fight Dick at (8,2). | +| `EVENT_BURNED_TOWER_1F_HIDDEN_ETHER` | `:138` | `BurnedTower1FHiddenEther` | Hidden ETHER at (8,7). | +| `EVENT_BURNED_TOWER_1F_X_SPEED` | `:1013` | ball at (15,2) that gives BURN_HEAL | Crossed name. | +| `EVENT_BURNED_TOWER_1F_BURN_HEAL` | `:1014` | ball at (7,5) that gives X_SPEED | Crossed name. | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BURN_HEAL` | item ball, NE pocket (15,2) | `BurnedTower1FBurnHeal` | `EVENT_BURNED_TOWER_1F_X_SPEED` | +| `X_SPEED` | item ball, mid-west (7,5) | `BurnedTower1FXSpeed` | `EVENT_BURNED_TOWER_1F_BURN_HEAL` | +| `ETHER` | hidden at (8,7) | `bg_event 8, 7, BGEVENT_ITEM` | `EVENT_BURNED_TOWER_1F_HIDDEN_ETHER` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL1` / `RIVAL1_3_CHIKORITA` | `RIVAL1` | `constants/trainer_constants.asm:56` | `Rival1Group` entry 7: L20 HAUNTER (LICK, SPITE, MEAN_LOOK, CURSE); L18 MAGNEMITE (TACKLE, THUNDERSHOCK, SUPERSONIC, SONICBOOM); L20 ZUBAT (LEECH_LIFE, SUPERSONIC, BITE, CONFUSE_RAY); L22 BAYLEEF (GROWL, REFLECT, RAZOR_LEAF, POISONPOWDER) | `BurnedTower1FRivalBattleScript` | none | +| `RIVAL1` / `RIVAL1_3_CYNDAQUIL` | `RIVAL1` | `:57` | `Rival1Group` entry 8: same first three, L22 QUILAVA (LEER, SMOKESCREEN, EMBER, QUICK_ATTACK) | same | none | +| `RIVAL1` / `RIVAL1_3_TOTODILE` | `RIVAL1` | `:58` | `Rival1Group` entry 9: same first three, L22 CROCONAW (LEER, RAGE, WATER_GUN, BITE) | same | none | +| `FIREBREATHER` / `DICK` | `FIREBREATHER` | `:479` | `FirebreatherGroup` entry 2: L17 CHARMELEON | `FirebreatherDickFight` | none | +| `FIREBREATHER` / `NED` | `FIREBREATHER` | `:480` | `FirebreatherGroup` entry 3: L15 KOFFING, L16 GROWLITHE, L15 KOFFING | `TrainerFirebreatherNed` | none | + +The rival's mon is the starter that beats yours: player Totodile -> rival +Chikorita line, player Chikorita -> rival Cyndaquil line, player Cyndaquil -> +rival Totodile line. + +**Wild encounters** + +`data/wild/johto_grass.asm:285`, `def_grass_wildmons BURNED_TOWER_1F`, +4% / 4% / 4%. Identical morn/day/nite table: +L13 RATTATA, L14 KOFFING, L15 RATTATA, L14 ZUBAT, L16 KOFFING, L15 RATICATE, +L15 RATICATE. (The walkthrough's "Rattata / Zubat / Koffing" is correct as far +as it goes; Raticate is also in the table.) + +--- + +### BURNED_TOWER_B1F + +- Script: `maps/BurnedTowerB1F.asm` +- Blocks: `maps/BurnedTowerB1F.blk` (90 bytes = 10x9 blocks) +- Header: `data/maps/maps.asm:92` -> `TILESET_CAVE`, `CAVE`, + `LANDMARK_BURNED_TOWER`, `MUSIC_BURNED_TOWER`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:79` -> `map_const BURNED_TOWER_B1F, 10, 9` +- Scene var: `data/maps/scenes.asm:49` -> `wBurnedTowerB1FSceneID` +- Scene ids: 0 = `SCENE_BURNEDTOWERB1F_RELEASE_THE_BEASTS`, 1 = `SCENE_BURNEDTOWERB1F_NOOP` + (both scene scripts are `end`; the work is done by the coord event) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `BURNED_TOWER_1F` | 3 | +| 2 | 17 | 7 | `BURNED_TOWER_1F` | 7 | +| 3 | 10 | 8 | `BURNED_TOWER_1F` | 9 | +| 4 | 3 | 13 | `BURNED_TOWER_1F` | 10 | +| 5 | 17 | 14 | `BURNED_TOWER_1F` | 12 | +| 6 | 7 | 15 | `BURNED_TOWER_1F` | 14 | + +Only warp 6 sits on a `LADDER` tile. Warps 1-5 are landing anchors for the 1F +pits; their own tiles are `FLOOR` or ledge tiles, so `CheckWarpCollision` +(`engine/overworld/tile_events.asm:1`) never fires on them and you cannot go back +up through them. **The ladder at (7,15) is the only exit from B1F.** + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_BURNEDTOWERB1F_RELEASE_THE_BEASTS` (0) | 9 | 5 | `ReleaseTheBeasts` | The Raikou/Entei/Suicune cutscene. | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 13 | `BGEVENT_ITEM` | `BurnedTowerB1FHiddenUltraBall` -> `hiddenitem ULTRA_BALL, EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL` | +| 17 | 14 | `BGEVENT_ITEM` | `BurnedTowerB1FHiddenBurnHeal` -> `hiddenitem BURN_HEAL, EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL` | +| 8 | 3 | `BGEVENT_ITEM` | `BurnedTowerB1FHiddenNugget` -> `hiddenitem NUGGET, EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BURNEDTOWERB1F_BOULDER` | `SPRITE_BOULDER` | 17 | 4 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `BurnedTowerB1FBoulder` (`jumpstd StrengthBoulderScript`) | -1 | +| `BURNEDTOWERB1F_RAIKOU1` | `SPRITE_GROWLITHE` | 10 | 3 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_1` | +| `BURNEDTOWERB1F_ENTEI1` | `SPRITE_GROWLITHE` | 8 | 4 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_1` | +| `BURNEDTOWERB1F_SUICUNE1` | `SPRITE_GROWLITHE` | 7 | 2 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_1` | +| `BURNEDTOWERB1F_RAIKOU2` | `SPRITE_GROWLITHE` | 10 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_2` | +| `BURNEDTOWERB1F_ENTEI2` | `SPRITE_GROWLITHE` | 8 | 4 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_2` | +| `BURNEDTOWERB1F_SUICUNE2` | `SPRITE_GROWLITHE` | 7 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_BURNED_TOWER_B1F_BEASTS_2` | +| `BURNEDTOWERB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 4 | 3 | `STILL` | `OBJECTTYPE_ITEMBALL` | `BurnedTowerB1FHPUp` (`itemball HP_UP`) | `EVENT_BURNED_TOWER_B1F_HP_UP` | +| `BURNEDTOWERB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 15 | 3 | `STILL` | `OBJECTTYPE_ITEMBALL` | `BurnedTowerB1FTMEndure` (`itemball TM_ENDURE`) | `EVENT_BURNED_TOWER_B1F_TM_ENDURE` | + +The two `SPRITE_GROWLITHE` triples are the same three beasts drawn twice: the +`..._1` set (`SPRITEMOVEDATA_POKEMON`, coloured `PAL_NPC_BROWN/RED/BLUE`) is the +animated set, the `..._2` set (`STANDING_DOWN`, `PAL_NPC_EMOTE`) is the static +set. `EVENT_BURNED_TOWER_B1F_BEASTS_1` is **set** by the new-game init block in +`engine/events/std_scripts.asm:525`, so the static set is what you first see; +`ReleaseTheBeasts` does `appear`/`disappear` to swap them. + +**Scripts of interest** + +- `ReleaseTheBeasts` (`42:53c4`), triggered by the (9,5) coord event on scene 0: + `playmusic MUSIC_NONE`, three `appear`/`disappear`/`cry` beats for RAIKOU, + ENTEI, SUICUNE, then three `playsound SFX_WARP_FROM` + `applymovement` + (`BurnedTowerRaikouMovement` right, `BurnedTowerEnteiMovement` down, + `BurnedTowerSuicuneMovement` left) + `disappear`, then + `special RestartMapMusic`, `setscene SCENE_BURNEDTOWERB1F_NOOP`, + `setevent EVENT_RELEASED_THE_BEASTS`, **`special InitRoamMons`**. +- `UnusedEnteiScript` is marked `; unreferenced` - a `loadwildmon ENTEI, 40` + encounter that never runs. Do not build a bot around it. +- `BurnedTowerB1FBoulder` -> `jumpstd StrengthBoulderScript` -> + `farsjump AskStrengthScript` (`engine/events/overworld.asm:1001`), which needs + STRENGTH (PLAINBADGE gated) - not obtainable in this section, and not needed + for anything here. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RELEASED_THE_BEASTS` | `constants/event_flags.asm:132` | set by `ReleaseTheBeasts`; read by `EcruteakCityLass2Script` | Cutscene done, roamers live. | +| `EVENT_BURNED_TOWER_B1F_BEASTS_1` | `:1260` | set at new game (`std_scripts.asm:525`), cleared by `appear` | Set = animated beasts hidden. | +| `EVENT_BURNED_TOWER_B1F_BEASTS_2` | `:1261` | set by `disappear` in the cutscene | Set = static beasts hidden. | +| `EVENT_BURNED_TOWER_B1F_HP_UP` | `:1015` | item ball (4,3) | - | +| `EVENT_BURNED_TOWER_B1F_TM_ENDURE` | `:1016` | item ball (15,3) | - | +| `EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL` | `:139` | bg event (2,13) | - | +| `EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL` | `:140` | bg event (17,14) | - | +| `EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET` | `:141` | bg event (8,3) | - | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HP_UP` | item ball at (4,3), right next to the warp-1 landing spot | `BurnedTowerB1FHPUp` | `EVENT_BURNED_TOWER_B1F_HP_UP` | +| `TM_ENDURE` | item ball at (15,3), east ledge pocket | `BurnedTowerB1FTMEndure` | `EVENT_BURNED_TOWER_B1F_TM_ENDURE` | +| `ULTRA_BALL` | hidden at (2,13) | bg event | `EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL` | +| `BURN_HEAL` | hidden at (17,14) | bg event | `EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL` | +| `NUGGET` | hidden at (8,3) | bg event | `EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET` | + +**Trainers** + +None. + +**Wild encounters** + +`data/wild/johto_grass.asm:313`, `def_grass_wildmons BURNED_TOWER_B1F`, +6% / 6% / 6%. + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L14 RATTATA | L14 RATTATA | L14 RATTATA | +| 2 | L14 KOFFING | L14 KOFFING | L14 KOFFING | +| 3 | L16 KOFFING | L16 KOFFING | L16 KOFFING | +| 4 | L16 RATTATA | L16 MAGMAR | L16 RATTATA | +| 5 | L15 ZUBAT | L15 ZUBAT | L15 ZUBAT | +| 6 | L14 MAGMAR | L14 RATTATA | L14 MAGMAR | +| 7 | L14 MAGMAR | L14 RATTATA | L14 MAGMAR | + +**Movement note**: B1F is built on `HOP_*` ledge tiles (`data/tilesets/cave_collision.asm`). +Landing anchors (17,7) and (10,8) are themselves ledge tiles; walking off a ledge +tile in the ledge's direction becomes a two-cell jump +(`.TryJump` / `.ledge_table`, `engine/overworld/player_movement.asm:350-392`). +The route to the beasts is: fall through 1F's centre pit at (10,7) -> land at +B1F (10,8) -> walk UP to (10,7), (10,6) -> LEFT to (9,6) -> UP onto (9,5) -> +`ReleaseTheBeasts`. + +--- + +### ECRUTEAK_GYM + +- Script: `maps/EcruteakGym.asm` +- Blocks: `maps/EcruteakGym.blk` (45 bytes = 5x9 blocks) +- Header: `data/maps/maps.asm:171` -> `TILESET_TOWER`, `INDOOR`, + `LANDMARK_ECRUTEAK_CITY`, `MUSIC_GYM`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:157` -> `map_const ECRUTEAK_GYM, 5, 9` + (walk cells `x 0..9`, `y 0..17`) +- No scene scripts, no coord events. + +**Warps** (`def_warp_events`) - 33 rows, almost all of them the invisible floor + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `ECRUTEAK_CITY` | 10 | +| 2 | 5 | 17 | `ECRUTEAK_CITY` | 10 | +| 3 | 4 | 14 | `ECRUTEAK_GYM` | 4 | +| 4 | 2 | 4 | `ECRUTEAK_GYM` | 3 | +| 5 | 3 | 4 | `ECRUTEAK_GYM` | 3 | +| 6 | 4 | 4 | `ECRUTEAK_GYM` | 3 | +| 7 | 4 | 5 | `ECRUTEAK_GYM` | 3 | +| 8 | 6 | 7 | `ECRUTEAK_GYM` | 3 | +| 9 | 7 | 4 | `ECRUTEAK_GYM` | 3 | +| 10 | 2 | 6 | `ECRUTEAK_GYM` | 3 | +| 11 | 3 | 6 | `ECRUTEAK_GYM` | 3 | +| 12 | 4 | 6 | `ECRUTEAK_GYM` | 3 | +| 13 | 5 | 6 | `ECRUTEAK_GYM` | 3 | +| 14 | 7 | 6 | `ECRUTEAK_GYM` | 3 | +| 15 | 7 | 7 | `ECRUTEAK_GYM` | 3 | +| 16 | 4 | 8 | `ECRUTEAK_GYM` | 3 | +| 17 | 5 | 8 | `ECRUTEAK_GYM` | 3 | +| 18 | 6 | 8 | `ECRUTEAK_GYM` | 3 | +| 19 | 7 | 8 | `ECRUTEAK_GYM` | 3 | +| 20 | 2 | 8 | `ECRUTEAK_GYM` | 3 | +| 21 | 2 | 9 | `ECRUTEAK_GYM` | 3 | +| 22 | 2 | 10 | `ECRUTEAK_GYM` | 3 | +| 23 | 2 | 11 | `ECRUTEAK_GYM` | 3 | +| 24 | 4 | 10 | `ECRUTEAK_GYM` | 3 | +| 25 | 5 | 10 | `ECRUTEAK_GYM` | 3 | +| 26 | 2 | 12 | `ECRUTEAK_GYM` | 3 | +| 27 | 3 | 12 | `ECRUTEAK_GYM` | 3 | +| 28 | 4 | 12 | `ECRUTEAK_GYM` | 3 | +| 29 | 5 | 12 | `ECRUTEAK_GYM` | 3 | +| 30 | 7 | 10 | `ECRUTEAK_GYM` | 3 | +| 31 | 7 | 11 | `ECRUTEAK_GYM` | 3 | +| 32 | 7 | 12 | `ECRUTEAK_GYM` | 3 | +| 33 | 7 | 13 | `ECRUTEAK_GYM` | 3 | + +Every hole (warps 4-33) dumps you at warp 3, i.e. (4,14), just inside the door - +the walkthrough's "fall off the path anywhere and it'll drop you back to the gym +entrance". Warp 3's own destination (warp 4 at (2,4)) is never used, because +(4,14) is a `FLOOR` tile and `CheckWarpCollision` only fires on pit/warp +collisions. + +**Coord events**: empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `EcruteakGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `EcruteakGymStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKGYM_MORTY` | `SPRITE_MORTY` | 5 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakGymMortyScript` | -1 | +| `ECRUTEAKGYM_SAGE1` | `SPRITE_SAGE` | 2 | 7 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerSageJeffrey` | -1 | +| `ECRUTEAKGYM_SAGE2` | `SPRITE_SAGE` | 3 | 13 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSagePing` | -1 | +| `ECRUTEAKGYM_GRANNY1` | `SPRITE_GRANNY` | 7 | 5 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerMediumMartha` | -1 | +| `ECRUTEAKGYM_GRANNY2` | `SPRITE_GRANNY` | 7 | 9 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerMediumGrace` | -1 | +| `ECRUTEAKGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 15 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakGymGuideScript` | -1 | + +**Derived collision map** (`maps/EcruteakGym.blk` + `data/tilesets/tower_collision.asm`; +`.` floor, `#` wall, `O` pit, `v` exit carpet) + +``` + 0123456789 + 0 ##......## + 1 ##..M...## M = Morty (5,1) + 2 ##......## + 3 ##......## + 4 ##OOO.O.## (6,4) is a PIT with NO warp row -> safe to stand on + 5 ##..O..G## G = Medium Martha (7,5) + 6 ##OOOO.O## + 7 ##J...F O## J = Sage Jeffrey (2,7); F = (6,7) FLOOR with a stray warp row + 8 ##O.OOOO## + 9 ##O....G## G = Medium Grace (7,9) +10 ##O.OO.O## +11 ##O....O## +12 ##OOOO.O## +13 ##P....O## P = Sage Ping (3,13) +14 ##......## (4,14) = warp 3, the landing tile for every hole +15 ##.##..##. statues at (3,15) and (6,15) +16 ##........ +17 ####vv#### exit at (4,17)/(5,17) +``` +(rows 15-17 above are schematic for the statue/door area; the pit rows 4-13 are +exact.) + +Two data quirks, both verified by diffing the warp rows against the block +collision: + +- **(6,7) has a warp row but is a `FLOOR` tile**, so it never triggers. This is + what makes the walkthrough's "go right 3" step from (3,7) land safely on (6,7). +- **(6,4) is a `PIT` tile with no warp row at all.** `CheckWarpCollision` sets + carry, `GetDestinationWarpNumber` finds no match, `CheckWarpTile` returns no + carry, so you simply stand on it. This is what makes "move up two squares to + get past the platform" work. It looks like the (6,4) row was mistyped as (6,7). + +**The verified zig-zag path** (every cell below is FLOOR unless noted): + +`(4,17) -> (4,16) -> (4,15) -> (4,14) -> (4,13)` [face LEFT: **Sage Ping** at (3,13)] +`-> (5,13) -> (6,13) -> (6,12) -> (6,11) -> (6,10) -> (6,9)` [face RIGHT: **Medium Grace** at (7,9)] +`-> (5,9) -> (4,9) -> (3,9) -> (3,8) -> (3,7)` [face LEFT: **Sage Jeffrey** at (2,7)] +`-> (4,7) -> (5,7) -> (6,7) -> (6,6) -> (6,5)` [face RIGHT: **Medium Martha** at (7,5)] +`-> (6,4)` (the harmless pit) `-> (6,3) -> (5,3) -> (5,2)` [face UP: **Morty** at (5,1)] + +**Scripts of interest** + +- `EcruteakGymMortyScript` (`52:508f`): `checkevent EVENT_BEAT_MORTY` -> + `.FightDone`; else `writetext MortyIntroText`, + `winlosstext MortyWinLossText, 0`, `loadtrainer MORTY, MORTY1`, `startbattle`, + `reloadmapafterbattle`, `setevent EVENT_BEAT_MORTY`, + `writetext Text_ReceivedFogBadge`, `playsound SFX_GET_BADGE`, + **`setflag ENGINE_FOGBADGE`**, `readvar VAR_BADGES`, + `scall EcruteakGymActivateRockets`, + `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP`. + Then `.FightDone` (`52:50bd`): `checkevent EVENT_GOT_TM30_SHADOW_BALL` -> + `.GotShadowBall`; else it **force-sets all four gym trainer flags** + (`EVENT_BEAT_SAGE_JEFFREY`, `EVENT_BEAT_SAGE_PING`, `EVENT_BEAT_MEDIUM_MARTHA`, + `EVENT_BEAT_MEDIUM_GRACE`), then `verbosegiveitem TM_SHADOW_BALL`, + `iffalse .NoRoomForShadowBall`, `setevent EVENT_GOT_TM30_SHADOW_BALL`. + So TM30 is safely re-obtainable if the bag was full: the flag is only set on a + successful give. +- `EcruteakGymActivateRockets`: `ifequal 7, .RadioTowerRockets` / + `ifequal 6, .GoldenrodRockets` on `VAR_BADGES`. Irrelevant on a linear run + (Fog is badge 4), but a bot that badge-skips can trip it. +- `EcruteakGymStatue`: `checkflag ENGINE_FOGBADGE` -> `GymStatue2Script` with + `gettrainername STRING_BUFFER_4, MORTY, MORTY1`; else `GymStatue1Script`. +- `EcruteakGymGuideScript` branches on `EVENT_BEAT_MORTY`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_MORTY` | `constants/event_flags.asm:709` | `EcruteakGymMortyScript` | Badge fight done. | +| `ENGINE_FOGBADGE` | `constants/engine_flags.asm:41` | set here; read by `SurfFunction.TrySurf` (`engine/events/overworld.asm:340`), `TrySurfOW` (`:490`), `EcruteakGymStatue`, `EcruteakTinTowerEntranceSageScript` | Field SURF, obedience to L50, `data/types/badge_type_boosts.asm:6` GHOST boost. | +| `EVENT_GOT_TM30_SHADOW_BALL` | `constants/event_flags.asm:18` | `EcruteakGymMortyScript` | TM30 given. | +| `EVENT_BEAT_SAGE_JEFFREY` | `:522` | `TrainerSageJeffrey`, force-set by Morty | - | +| `EVENT_BEAT_SAGE_PING` | `:523` | `TrainerSagePing`, force-set by Morty | - | +| `EVENT_BEAT_MEDIUM_MARTHA` | `:930` | `TrainerMediumMartha`, force-set by Morty | - | +| `EVENT_BEAT_MEDIUM_GRACE` | `:931` | `TrainerMediumGrace`, force-set by Morty | - | +| `SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP` | positional (id 1) in `maps/EcruteakTinTowerEntrance.asm` | `setmapscene` from Morty's script | Disables the Tin Tower sage-block coord events at (4,7) and (5,7). | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SHADOW_BALL` (TM30) | beat Morty, then keep talking | `EcruteakGymMortyScript.FightDone` | `EVENT_GOT_TM30_SHADOW_BALL` | +| FOGBADGE | beat Morty | `setflag ENGINE_FOGBADGE` | `EVENT_BEAT_MORTY` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SAGE` / `PING` | `SAGE` (`constants/trainer_constants.asm:579`) | `SageGroup` entry 6 "PING" | 5x L16 GASTLY, `TRAINERTYPE_NORMAL` | `TrainerSagePing` | none | +| `MEDIUM` / `GRACE` | `MEDIUM` (`:586`) | `MediumGroup` entry 2 "GRACE" | L20 HAUNTER, L20 HAUNTER | `TrainerMediumGrace` | none | +| `SAGE` / `JEFFREY` | `SAGE` (`:578`) | `SageGroup` entry 5 "JEFFREY" | L22 HAUNTER | `TrainerSageJeffrey` | none | +| `MEDIUM` / `MARTHA` | `MEDIUM` (`:585`) | `MediumGroup` entry 1 "MARTHA" | L18 GASTLY, L20 HAUNTER, L20 GASTLY | `TrainerMediumMartha` | none | +| `MORTY` / `MORTY1` | `MORTY` (`:36-37`) | `MortyGroup`, `TRAINERTYPE_MOVES` | L21 GASTLY (LICK, SPITE, MEAN_LOOK, CURSE); L21 HAUNTER (HYPNOSIS, MIMIC, CURSE, NIGHT_SHADE); L25 GENGAR (HYPNOSIS, SHADOW_BALL, MEAN_LOOK, DREAM_EATER); L23 HAUNTER (SPITE, MEAN_LOOK, MIMIC, NIGHT_SHADE) | `EcruteakGymMortyScript` | none | + +`MortyGroup` sends the mons in file order: GASTLY 21, HAUNTER 21, GENGAR 25, +HAUNTER 23. The walkthrough's "L23 Haunter, L25 Gengar, L23 Haunter" ordering and +its "Level 23 Haunter" second slot do not match the asm - the second slot is +**level 21**, and Gengar comes third, not last. + +**Wild encounters** + +None (indoor). + +--- + +### ECRUTEAK_TIN_TOWER_ENTRANCE (one line, unlocked here) + +`maps/EcruteakTinTowerEntrance.asm` has +`coord_event 4, 7, SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS, EcruteakTinTowerEntranceSageBlocksLeft` +and `coord_event 5, 7, ..., ...SageBlocksRight`; Morty's `setmapscene` to the +NOOP scene is what stops the sages from side-stepping in front of you. Everything +past that door belongs to the next section. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Bill is not in Goldenrod | `maps/BillsFamilysHouse.asm` object row `EVENT_MET_BILL`; flag set at new game by `engine/events/std_scripts.asm` (init block) | Ecruteak Pokecenter cutscene | `EcruteakPokcenter1FBillActivatesTimeCapsuleScript` does `clearevent EVENT_MET_BILL` | +| No Eevee | `maps/BillsFamilysHouse.asm:BillScript` `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH, .NoRoom` | a free party slot, and answer YES | `setevent EVENT_GOT_EEVEE` | +| No HM03 SURF | `maps/DanceTheater.asm:DanceTheaterSurfGuy` - five `checkevent EVENT_BEAT_KIMONO_GIRL_*` / `iffalse .KimonoGirlsUndefeated` | beat NAOKO, SAYO, ZUKI, KUNI, MIKI | `.GetSurf` -> `verbosegiveitem HM_SURF`, `setevent EVENT_GOT_HM03_SURF` | +| SURF unusable in the field | `engine/events/overworld.asm:340` (`SurfFunction.TrySurf`: `ld de, ENGINE_FOGBADGE` / `CheckBadge`) and `:490` (`TrySurfOW`: `CheckEngineFlag`) | FOGBADGE | `setflag ENGINE_FOGBADGE` in `EcruteakGymMortyScript` | +| Burned Tower 1F north half (Firebreather Dick coord event at (8,1), X SPEED at (7,5), hidden ETHER at (8,7)) and the centre pit at (10,7) that reaches the beasts | rock object at (4,3), `SPRITEMOVEDATA_SMASHABLE_ROCK` -> `BurnedTower1FRock` -> `jumpstd SmashRockScript` -> `AskRockSmashScript` -> `callasm HasRockSmash` (`CheckPartyMove ROCK_SMASH`) | a party mon that knows ROCK_SMASH. **No badge check.** | smash the rock | +| Burned Tower 1F east loop (BURN HEAL at (15,2), Firebreather Ned at (16,8), the (15,4)/(15,5) pits) | rock object at (16,13), same std script | ROCK_SMASH | smash the rock | +| Burned Tower B1F return to 1F | only `warp_event 7, 15` sits on a `LADDER` tile; warps 1-5 are on non-warp collisions so `CheckWarpCollision` (`engine/overworld/tile_events.asm:1`) rejects them | reach (7,15) | walk there | +| B1F boulder at (17,4) | `jumpstd StrengthBoulderScript` -> `AskStrengthScript` (`engine/events/overworld.asm:1001`) -> `callasm TryStrengthOW` | STRENGTH + PLAINBADGE | not obtainable in this section; nothing needed here is behind it | +| Ecruteak Gym invisible floor | 30 `warp_event` rows on `COLL_PIT` tiles, all pointing at warp 3 (4,14) | walk the exact zig-zag above | none - it is pure navigation | +| Rival battle in the Burned Tower | rival object masked by `EVENT_RIVAL_BURNED_TOWER`; `maps/GoldenrodUndergroundSwitchRoomEntrances.asm:125` sets it and `setmapscene BURNED_TOWER_1F, SCENE_BURNEDTOWER1F_FIREBREATHER_DICK` | do Burned Tower **before** the Goldenrod Underground rival fight | ordering only | +| Tin Tower | `maps/EcruteakTinTowerEntrance.asm` coord events (4,7)/(5,7) on `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` | FOGBADGE | `setmapscene ... NOOP` from `EcruteakGymMortyScript` | +| Bike shop "keep the bicycle" call | `engine/overworld/events.asm:1267 DoBikeStep` - needs `STATUSFLAGS2_BIKE_SHOP_CALL_F`, `wPlayerState == PLAYER_BIKE`, `GetMapPhoneService` == 0, and `wBikeStep` high byte >= `HIGH(1024)` | 1024 bike steps in phone-service territory | queues `SPECIALCALL_BIKESHOP`, then `engine/phone/scripts/bike_shop.asm` clears `ENGINE_BIKE_SHOP_CALL_ENABLED` | + +--- + +## 4. Bot checklist + +Preconditions carried in from section 06: Sudowoodo beaten/caught, bicycle owned +(`EVENT_GOT_BICYCLE`), `ENGINE_BIKE_SHOP_CALL_ENABLED` set. + +1. `ROUTE_37`, walk from the `ROUTE_36` south connection. Optional: fight + `TrainerTwinsAnnandanne1` at (6,12) (sight 1) - pre `EVENT_BEAT_TWINS_ANN_AND_ANNE` + clear, post set (this also disables the twin at (7,12)). +2. `ROUTE_37` (9,6): fight `TrainerPsychicGreg` (sight 3). Post + `EVENT_BEAT_PSYCHIC_GREG`. +3. `ROUTE_37`: talk to the fruit trees at (13,5), (16,5), (15,7) -> RED / BLU / + BLK apricorn. Precondition: bag space. No flag; resets daily. +4. `ROUTE_37` (4,2): ITEMFINDER-less hidden `ETHER`, flag + `EVENT_ROUTE_37_HIDDEN_ETHER`. +5. Optional, Sundays only: `ROUTE_37` (16,8) talk `SunnyScript` -> `MAGNET`. + Precondition `VAR_WEEKDAY == SUNDAY`; post `EVENT_GOT_MAGNET_FROM_SUNNY`. +6. Walk north into `ECRUTEAK_CITY`. Postcondition `ENGINE_FLYPOINT_ECRUTEAK` set + by `EcruteakCityFlypointCallback` (`MAPCALLBACK_NEWMAP`). +7. `ECRUTEAK_CITY` warp 6 at (23,27) -> `ECRUTEAK_POKECENTER_1F`. On map load the + scene script fires (scene 0). Do not press anything for the length of the + cutscene. Post: `EVENT_MET_BILL` **cleared**, `ENGINE_TIME_CAPSULE` set, scene + -> 1. Then heal at the nurse (3,1) and deposit apricorns/berries at the PC. +8. Ride back south: `ROUTE_37` -> `ROUTE_36` -> `NATIONAL_PARK` -> `ROUTE_35` -> + `GOLDENROD_CITY`. Somewhere in here the bike-shop call fires + (`DoBikeStep`, 1024 bike steps). +9. `GOLDENROD_CITY` warp 4 at (5,25) -> `BILLS_FAMILYS_HOUSE`. Precondition: + party count < 6. Talk to Bill at (2,3), answer YES. Post `EVENT_GOT_EEVEE`, + L20 EEVEE in party. +10. Optional: talk to the twin at (5,4) to register `PHONE_BILL`. +11. Ride back north to `ECRUTEAK_CITY`. +12. `ECRUTEAK_CITY` warp 8 at (23,21) -> `DANCE_THEATER`. +13. In `DANCE_THEATER`, walk adjacent and press A on each Kimono Girl - they all + have sight 0 and will not start a battle themselves: + (0,2) NAOKO / FLAREON, (2,1) SAYO / ESPEON, (6,2) ZUKI / UMBREON, + (9,1) KUNI / VAPOREON, (11,2) MIKI / JOLTEON. Each posts its own + `EVENT_BEAT_KIMONO_GIRL_*`. +14. `DANCE_THEATER` (7,10): talk to the gentleman. Precondition: all five flags + set and bag space for an HM. Post `EVENT_GOT_HM03_SURF` + `HM_SURF` in bag. + Teach SURF now; it will not work in the field until step 24. +15. Leave via warp 1/2 at (5,13)/(6,13). +16. `ECRUTEAK_CITY` warp 11 at (13,27) -> `ECRUTEAK_ITEMFINDER_HOUSE`. Talk to + (2,3), answer **YES** to the `yesorno`. Post `EVENT_GOT_ITEMFINDER`. +17. Optional: with ITEMFINDER, pick up the hidden `HYPER_POTION` at + `ECRUTEAK_CITY` (23,14). +18. Heal, stock Escape Ropes / Repels / Great Balls, then `ECRUTEAK_CITY` + warp 13 at (5,5) -> `BURNED_TOWER_1F`. +19. **On entry the rival scene fires immediately** (scene 0 -> + `sdefer BurnedTower1FRivalBattleScript`). Precondition: + `EVENT_RIVAL_BURNED_TOWER` **clear** (i.e. the Goldenrod Underground rival + fight has not happened). Battle is `RIVAL1` / + `RIVAL1_3_{CHIKORITA|CYNDAQUIL|TOTODILE}` chosen from + `EVENT_GOT_TOTODILE_FROM_ELM` / `EVENT_GOT_CHIKORITA_FROM_ELM`. + Post: rival disappears, scene -> `SCENE_BURNEDTOWER1F_FIREBREATHER_DICK`. +20. `BURNED_TOWER_1F`: walk (9,15) -> (9,14) -> row 13 west to (2,13), north up + column 2/3 to the (4,3) rock. Use ROCK_SMASH on it (field move or + `AskRockSmashScript` yes-prompt). This opens the whole north half. +21. Step on the (8,1) tile to trigger `FirebreatherDickFight` + (`FIREBREATHER`/`DICK`, L17 CHARMELEON). Post `EVENT_BEAT_FIREBREATHER_DICK`, + scene -> `SCENE_BURNEDTOWER1F_NOOP`. +22. Pick up `X_SPEED` at (7,5) and the hidden `ETHER` at (8,7). Then step onto + the pit at (10,7) -> `BURNED_TOWER_B1F` warp 3 at (10,8). +23. `BURNED_TOWER_B1F`: walk UP to (10,7), (10,6), LEFT to (9,6), UP to (9,5). + Precondition scene 0. This runs `ReleaseTheBeasts`. Post + `EVENT_RELEASED_THE_BEASTS`, scene -> 1, `special InitRoamMons` seeds the + three roamers. +24. Optional B1F loot: `NUGGET` hidden at (8,3). Then leave by the ladder at + (7,15) (the only exit). +25. Optional, back on 1F: smash the (16,13) rock to open the east loop - + `FIREBREATHER`/`NED` at (16,8) and the `BURN_HEAL` ball at (15,2). +26. Optional HP UP: from 1F step on any of the NW pits (5,4), (5,5), (5,6), + (4,6) -> B1F (3,3), grab `HP_UP` at (4,3), hop the ledges south and out via + the (7,15) ladder. +27. Leave `BURNED_TOWER_1F` via warp 1/2 at (9,15)/(10,15). Heal at the + Pokecenter, save. +28. `ECRUTEAK_CITY` warp 10 at (6,27) -> `ECRUTEAK_GYM`. Walk the exact path in + the ECRUTEAK_GYM section above. Fight, in order: + `SAGE`/`PING` at (3,13), `MEDIUM`/`GRACE` at (7,9), `SAGE`/`JEFFREY` at + (2,7), `MEDIUM`/`MARTHA` at (7,5). +29. Talk to Morty at (5,1) from (5,2). Battle `MORTY`/`MORTY1`. Post + `EVENT_BEAT_MORTY`, `ENGINE_FOGBADGE` set, + `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, ...NOOP`. +30. Keep talking to Morty (same script, `.FightDone` arm) with bag space to get + `TM_SHADOW_BALL`. Post `EVENT_GOT_TM30_SHADOW_BALL`. If the bag was full the + flag is not set and the give can be retried. +31. Field SURF is now legal (`TrySurfOW` `CheckEngineFlag ENGINE_FOGBADGE` + passes). The Ecruteak water encounter table (`ECRUTEAK_CITY`, 2%, POLIWAG / + POLIWHIRL) becomes reachable. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map/warp/bg/object tables decoded from the ROM (no hand-authored map data) | `src/import/RomExtractorGen2.lua` (`OBJECTTYPE_ITEMBALL`/`OBJECTTYPE_TRAINER` at :71, `readItemBall` at :2874-2969) | implemented | +| Warps triggered by pit/warp tile collision | `src/world/gen2/Permissions.lua:164` (cites `COLL_PIT` / `COLL_PIT_68` / `HI_NYBBLE_WARPS`, `CheckWarpCollision`) | implemented | +| Coord events gated on the map's scene id | `src/world/gen2/World.lua:5005 World:tryCoordScript` | implemented | +| Scene scripts run on map load; per-map scene ids saved | `src/world/gen2/World.lua:5021 World:trySceneScript`, `:1161 World:scene`, `:740/:746 setScene/setMapScene`, `src/core/gen2/Save.lua` | implemented | +| `sdefer` (the Bill and rival cutscenes) | `src/script/gen2/Vm.lua:92` - explicitly runs it inline instead of deferring past the map settle | partial (documented deviation; cutscene ordering may differ by a frame or two) | +| Object masking by event flag (`appear`/`disappear`, Bill, the beasts, the rival) | `src/script/gen2/Vm.lua` (`variablesprite` :353 etc.), `src/world/gen2/Npc.lua` | implemented | +| Trainer sight lines / walk-up battles | `src/world/gen2/Trainers.lua:98 Trainers.sees` | implemented | +| `loadtrainer` overriding the object's own trainer id (rival branch) | `src/script/gen2/Vm.lua:806` | implemented | +| `startbattle` result plumbing, incl. the `iftrue .next` arm this map uses | `src/script/gen2/Vm.lua:42`, `:602`, and the comment at `:825` which cites `maps/BurnedTower1F.asm` by name | implemented | +| `verbosegiveitem` (HM03, ITEMFINDER, TM30, MAGNET) | `src/script/gen2/Vm.lua:490` | implemented | +| `givepoke EEVEE, 20` | `src/script/gen2/Vm.lua:439` | implemented | +| Item balls | `src/import/RomExtractorGen2.lua:2874/2968` + object dispatch in `src/world/gen2/Events.lua` | implemented | +| Hidden items (`BGEVENT_ITEM` -> `hiddenitem`) and ITEMFINDER sweep | `src/world/gen2/HiddenItems.lua` | implemented | +| Fruit trees / apricorns | `src/script/gen2/Opcodes.lua:160` (`fruittree`), `src/core/gen2/Apricorns.lua` | implemented | +| Rock Smash field move (no badge) | `src/world/gen2/FieldMoves.lua` (badge table at :106-118, `ROCK_SMASH` has no badge entry) | implemented | +| SURF field move gated on FOGBADGE | `src/world/gen2/FieldMoves.lua:106` (`SURF = "FOG"`), `:477 SurfFunction`, `:663 trySurfOW` | implemented | +| Strength / PLAINBADGE (B1F boulder) | `src/world/gen2/FieldMoves.lua:108`, `:695 tryStrengthOW` | implemented | +| Badge flags and the L50 obedience/type-boost effects | `src/world/gen2/FieldMoves.lua:118` badge list; battle side in `src/battle/gen2/` | implemented | +| `special InitRoamMons` + roamer movement/encounters | `src/script/gen2/Specials.lua:1845`, `src/core/gen2/Roamers.lua`, driver `tests/drivers/gold_roamers.lua` | implemented | +| `special FadeOutMusic` / `RestartMapMusic` (both cutscenes) | `src/script/gen2/Specials.lua:1059`, `:1066` | implemented | +| `showemote`, `applymovement`, `turnobject`, `cry` choreography | `src/script/gen2/Vm.lua:961`, `src/script/gen2/Movement.lua` | implemented | +| Bike step counter / bike shop special call | `src/world/gen2/StepEvents.lua:48`, `:104-112`, `src/world/gen2/Bike.lua:4`, `src/world/gen2/World.lua:1992` (`specialphonecall`) | implemented | +| Time Capsule (`ENGINE_TIME_CAPSULE` is set, but the feature itself) | `src/script/gen2/Specials.lua:2215-2220` - `EnterTimeCapsule`, `TimeCapsule`, `CheckTimeCapsuleCompatibility` are all stubbed "link cable: no Time Capsule" | missing (flag set, menu inert) | +| Map callbacks (`MAPCALLBACK_NEWMAP` fly point, `MAPCALLBACK_OBJECTS` Sunny) | `src/world/gen2/World.lua` + driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| A driver that actually walks Ecruteak / Burned Tower / the gym | none - the `tests/drivers/gold_*.lua` set covers boot, walk, warp, battle, roamers, but no Ecruteak route | missing | +| Ledge hops (`HOP_*`) as used all over Burned Tower B1F | not found in `src/world/gen2/Permissions.lua` or `Player.lua` under those names | unverified - grep for `HOP_` returned nothing; needs a hands-on check before a bot relies on B1F traversal | + +--- + +## 6. Unresolved / verify by hand + +1. **"Head left and all the way around the spiral and you will now fight your + rival."** The asm disagrees. `BurnedTower1FRivalBattleScene` is scene 0 (the + wram default) and `sdefer`s the battle script, so it runs on map load; the + rival object sits at (9,12), three cells straight up from the (9,15) door, and + walks two steps DOWN to meet you. The battle happens as soon as you step + inside. +2. **"Equipping the TM Rock Smash will give you the HP Up at the northeastern + part of the floor."** Conflated. The northeast item on 1F is a `BURN_HEAL` + ball at (15,2) behind the (16,13) rock. The `HP_UP` is on B1F at (4,3), + reached by falling through the northwest pits - which the walkthrough gets + right later ("grab a HP Up by dropping down the northwest hole"). +3. **Rock Smash is not optional if you want the beasts.** Flood-filling + `maps/BurnedTower1F.blk` against `data/tilesets/tower_collision.asm` from the + (9,15) door shows that with both rocks intact you can only reach the four + northwest pits, the ladder, and the (4,14)/(5,14)/(14,14)/(15,14) pits - not + the centre pit at (10,7), which is the only route to B1F's (9,5) beast + trigger. This is a derived result, not a line of asm; worth confirming on + hardware/emulator before a bot depends on it. +4. **Ecruteak Gym (6,7)** carries a `warp_event` row but its block collision is + `FLOOR`, so the warp never fires. **(6,4)** is the mirror image: a `PIT` tile + with no `warp_event` row, so you can stand on it. Both are consistent with the + walkthrough's step counts working, and both look like one transposed row in + the source data. Derived from the `.blk`; verify visually. +5. **Morty's party order.** The walkthrough lists "L21 Gastly, L23 Haunter, L25 + Gengar, L23 Haunter". `MortyGroup` in `data/trainers/parties.asm:35` is + L21 GASTLY, **L21** HAUNTER, L25 GENGAR, L23 HAUNTER. The second Haunter is + level 21, and its EXP figure in the walkthrough (567) is therefore suspect. +6. **`DanceTheaterSurfGuy.GetSurf` has no `iffalse` after + `verbosegiveitem HM_SURF`.** Every other give in this section + (`ITEMFINDER`, `TM_SHADOW_BALL`, the Bill's-grandpa stones) checks the return. + If the bag genuinely cannot take an HM the flag would still be set. Whether + the bag can ever be full for the HM pocket in GS is worth checking before a + bot risks it - keep at least one free slot. +7. **Twins Ann & Anne share one flag.** Both objects use + `EVENT_BEAT_TWINS_ANN_AND_ANNE`; `EVENT_BEAT_TWINS_ANN_AND_ANNE2` + (`constants/event_flags.asm:610`) is defined but unreferenced by this map. + A bot that expects two battles on Route 37 will hang. +8. **Route 37's twins are declared `SPRITE_WEIRD_TREE`** with object consts + `ROUTE37_WEIRD_TREE1/2`, and `SPRITE_WEIRD_TREE` is `variablesprite`d to + `SPRITE_SUDOWOODO` by the new-game init block. Transcribed verbatim from + `maps/Route37.asm:237-238`; what actually renders on screen was not verified. +9. **Burned Tower 1F item ball flags are crossed** (`BURN_HEAL` ball carries + `EVENT_BURNED_TOWER_1F_X_SPEED` and vice versa). Verbatim from + `maps/BurnedTower1F.asm:311-312`. Functionally harmless, but do not infer the + item from the flag name. +10. **Ledge (`HOP_*`) traversal on B1F** could not be found in the port. The asm + side is `engine/overworld/player_movement.asm:350-392` (`.TryJump` / + `.ledge_table`, a two-cell jump when leaving a ledge tile in its direction). + Marked "unverified" in the port table above. +11. **`EcruteakGymActivateRockets`** branches on `VAR_BADGES == 6` / `== 7`. On a + linear run Fog is the fourth badge so neither arm fires; a badge-skipping bot + will trigger `GoldenrodRocketsScript` / `RadioTowerRocketsScript` out of + order. Not exercised in this section. +12. **Walkthrough EXP and money figures** were not checked against + `engine/battle/` at all; they are reproduced nowhere in this document for + that reason. diff --git a/docs/gold-walkthrough/asm-walk/section-08-olivine-lighthouse-and-cianwood-city-gym.md b/docs/gold-walkthrough/asm-walk/section-08-olivine-lighthouse-and-cianwood-city-gym.md new file mode 100644 index 00000000..cfebb197 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-08-olivine-lighthouse-and-cianwood-city-gym.md @@ -0,0 +1,1632 @@ +# Section 08 - Olivine Lighthouse and Cianwood City Gym + +Source: `../section-08-olivine-lighthouse-and-cianwood-city-gym.txt` + +Maps covered: `MAP_ROUTE_38_ECRUTEAK_GATE`, `MAP_ROUTE_38`, `MAP_ROUTE_39`, +`MAP_ROUTE_39_BARN`, `MAP_ROUTE_39_FARMHOUSE`, `MAP_OLIVINE_CITY`, +`MAP_OLIVINE_POKECENTER_1F`, `MAP_OLIVINE_GOOD_ROD_HOUSE`, `MAP_OLIVINE_CAFE`, +`MAP_OLIVINE_MART`, `MAP_OLIVINE_LIGHTHOUSE_1F` .. `MAP_OLIVINE_LIGHTHOUSE_6F`, +`MAP_ROUTE_40`, `MAP_ROUTE_41`, `MAP_CIANWOOD_CITY`, `MAP_MANIAS_HOUSE`, +`MAP_CIANWOOD_PHARMACY`, `MAP_CIANWOOD_PHOTO_STUDIO`, +`MAP_CIANWOOD_POKECENTER_1F`, `MAP_CIANWOOD_LUGIA_SPEECH_HOUSE`, +`MAP_CIANWOOD_GYM` + +Badges / key milestones in this section: **Storm Badge** (`ENGINE_STORMBADGE`, +badge 5, from `CianwoodGymChuckScript`). Other milestones, in the order the +walkthrough hits them: `EVENT_GOT_GOOD_ROD`, `EVENT_GOT_HM04_STRENGTH` +(HM04 = the Cianwood Gym boulder puzzle and every later Strength rock), +`EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` (the flag that unlocks the Cianwood +pharmacist), `EVENT_GOT_SHUCKIE`, `EVENT_GOT_SECRETPOTION_FROM_PHARMACY`, +`EVENT_BEAT_CHUCK`, `EVENT_GOT_TM01_DYNAMICPUNCH`, `EVENT_GOT_HM02_FLY`, and +the two fly-point engine flags `ENGINE_FLYPOINT_OLIVINE` / +`ENGINE_FLYPOINT_CIANWOOD`. + +Coordinate note: every `warp_event` / `coord_event` / `bg_event` / +`object_event` x,y below is copied verbatim from the map asm. Those are 0-based +walk-grid cells (2 per map block, so a `W`-block-wide map runs x = 0..2W-1), +the same grid `src/world/gen2/Map.lua` calls a cell. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_38_ECRUTEAK_GATE` | `maps/Route38EcruteakGate.asm` | warp 3/4 at (9,4)/(9,5) from `ECRUTEAK_CITY` warps 14/15 | warp 1/2 at (0,4)/(0,5) | "Well, go through the building into Route 38" | +| 2 | `MAP_ROUTE_38` | `maps/Route38.asm` | warp 1/2 at (35,8)/(35,9) | west connection to `ROUTE_39` (`data/maps/attributes.asm:215`) | "You're heading west. There are trainers" - Toby, Harry, Dana, Valerie, Chad, plus the Berry tree | +| 3 | `MAP_ROUTE_39` | `maps/Route39.asm` | east connection from `ROUTE_38` | south connection to `OLIVINE_CITY` (`data/maps/attributes.asm:219`) | "Welcome to Route 39! It's the home of Moo Moo Farm" - Norman, Derek, Ruth, Eugene, Mint Berry | +| 3a | `MAP_ROUTE_39_BARN` | `maps/Route39Barn.asm` | Route 39 warp 1 at (1,3) | warp 1/2 at (3,7)/(4,7) | "you will first have to feed berries to their Miltank" | +| 3b | `MAP_ROUTE_39_FARMHOUSE` | `maps/Route39Farmhouse.asm` | Route 39 warp 2 at (5,3) | warp 1/2 at (2,7)/(3,7) | Moomoo Milk at 500 gold; TM13 SNORE once the Miltank is healed | +| 4 | `MAP_OLIVINE_CITY` | `maps/OlivineCity.asm` | north connection from `ROUTE_39` (`data/maps/attributes.asm:143`) | warp 9 at (29,27) | "As soon as you enter Olivine City and, when you are about to pass by the gym your rival will appear" - `coord_event` at (13,12)/(13,13) | +| 4a | `MAP_OLIVINE_GOOD_ROD_HOUSE` | `maps/OlivineGoodRodHouse.asm` | Olivine warp 6 at (13,15) | warp 1/2 at (2,7)/(3,7) | "Talk to the fisherman and he will give you a Good Rod" | +| 4b | `MAP_OLIVINE_POKECENTER_1F` | `maps/OlivinePokecenter1F.asm` | Olivine warp 1 at (13,21) | warp 1/2 at (3,7)/(4,7) | "Then heal at the Pokémon Center" | +| 4c | `MAP_OLIVINE_CAFE` | `maps/OlivineCafe.asm` | Olivine warp 7 at (7,21) | warp 1/2 at (2,7)/(3,7) | "talk to the sailor at the table for HM04 Strength!" | +| 5 | `MAP_OLIVINE_LIGHTHOUSE_1F` | `maps/OlivineLighthouse1F.asm` | Olivine warp 9 at (29,27) | warp 3 at (3,11) | "go up the Olivine City Lighthouse in the southeast part of town (not the dock)" | +| 6 | `MAP_OLIVINE_LIGHTHOUSE_2F` | `maps/OlivineLighthouse2F.asm` | warp 1 at (3,11) | warp 2 at (5,3) | Gentleman Alfred, Sailor Huey | +| 7 | `MAP_OLIVINE_LIGHTHOUSE_3F` | `maps/OlivineLighthouse3F.asm` | warp 2 at (5,3) | warp 1 at (13,3) | Bird Keeper Theo, Gentleman Preston, Ether | +| 8 | `MAP_OLIVINE_LIGHTHOUSE_4F` | `maps/OlivineLighthouse4F.asm` | warp 1 at (13,3) | warp 2 at (3,5) | Lass Connie, Sailor Kent, "hole next to Lass Connie" = warps 7/8 at (8,3)/(9,3) | +| 9 | `MAP_OLIVINE_LIGHTHOUSE_5F` | `maps/OlivineLighthouse5F.asm` | warp 2 at (3,5) | warp 1 at (9,15) | TM34 Swagger, Rare Candy, Great Ball, Bird Keeper Denis, Sailor Ernest | +| 10 | `MAP_OLIVINE_LIGHTHOUSE_6F` | `maps/OlivineLighthouse6F.asm` | warp 1 at (9,15) | warps 2/3 at (16,5)/(17,5) - the right-wall drop chain | "Talk to the person on that level, and it is Gym Leader Jasmine" | +| 11 | `MAP_OLIVINE_CITY` | `maps/OlivineCity.asm` | lighthouse 1F warps 1/2 at (10,17)/(11,17) | west connection to `ROUTE_40` (`data/maps/attributes.asm:145`) | heal, restock at the Mart (warp 8 at (19,17)), teach HM03 Surf | +| 12 | `MAP_ROUTE_40` | `maps/Route40.asm` | east connection from `OLIVINE_CITY` | south connection to `ROUTE_41` (`data/maps/attributes.asm:223`) | "activate Surf by clicking A at the water" - Simon, Elaine, Paula, Randall | +| 13 | `MAP_ROUTE_41` | `maps/Route41.asm` | north connection from `ROUTE_40` | west connection to `CIANWOOD_CITY` (`data/maps/attributes.asm:227`) | the nine open-sea swimmers; the four Whirl Islands warps are optional detours | +| 14 | `MAP_CIANWOOD_CITY` | `maps/CianwoodCity.asm` | east connection from `ROUTE_41` (`data/maps/attributes.asm:136`) | warp 2 at (8,43) | "You will reach land after that battle. Welcome to Cianwood City." | +| 14a | `MAP_CIANWOOD_POKECENTER_1F` | `maps/CianwoodPokecenter1F.asm` | Cianwood warp 3 at (23,43) | warp 1/2 at (3,7)/(4,7) | "Heal at the Pokémon Center and clear a space so you can acquire another Pokémon" | +| 14b | `MAP_MANIAS_HOUSE` | `maps/ManiasHouse.asm` | Cianwood warp 1 at (17,41) | warp 1/2 at (2,7)/(3,7) | "a PokeManiac will give you Level 15 Shuckie the Shuckle" | +| 14c | `MAP_CIANWOOD_PHARMACY` | `maps/CianwoodPharmacy.asm` | Cianwood warp 4 at (15,47) | warp 1/2 at (2,7)/(3,7) | "It's the Pharmacy. A guy inside the house, wearing sunglasses will give you some medicine" | +| 14d | `MAP_CIANWOOD_PHOTO_STUDIO` | `maps/CianwoodPhotoStudio.asm` | Cianwood warp 5 at (9,31) | warp 1/2 at (2,7)/(3,7) | "he will offer to take pictures of your Pokémon" (optional) | +| 15 | `MAP_CIANWOOD_GYM` | `maps/CianwoodGym.asm` | Cianwood warp 2 at (8,43) | warp 1/2 at (4,17)/(5,17) | "face Chuck, the fighting gym leader" -> Storm Badge + TM01 | +| 16 | `MAP_CIANWOOD_CITY` | `maps/CianwoodCity.asm` | gym warp 1/2 | (section ends) | "Talk to the lady outside the gym and she will give you HM02 Fly" | + +Spills into the next section: the walkthrough's last line, "You'll want to +return to Olivine now so that you can give the medicine to Jasmine and the +Ampharos", starts the Olivine Gym / `MAP_OLIVINE_GYM` beat. The Secretpotion +hand-off itself is `OlivineLighthouseJasmine` on `MAP_OLIVINE_LIGHTHOUSE_6F` +(documented below, because the flag it clears is what makes Jasmine appear in +the gym), but the gym fight belongs to section 09. Likewise the walkthrough's +"Items found in Olivine City: … TM23" is Jasmine's TM in `maps/OlivineGym.asm`, +not anything reachable on `MAP_OLIVINE_CITY`. + +## 2. Maps + +### MAP_ROUTE_38_ECRUTEAK_GATE + +- Script: `maps/Route38EcruteakGate.asm` +- Blocks: none (no `.blk`; gate maps share `TILESET_GATE` layout) +- Header (`data/maps/maps.asm:58`): `map Route38EcruteakGate, TILESET_GATE, GATE, LANDMARK_ROUTE_38, MUSIC_ROUTE_37, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:47`): `map_const ROUTE_38_ECRUTEAK_GATE, 5, 4` (5x4 blocks = 10x8 cells) +- Attributes (`data/maps/attributes.asm:480`): no connections +- Scene var: none (empty `def_scene_scripts`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ROUTE_38` | 1 | +| 2 | 0 | 5 | `ROUTE_38` | 2 | +| 3 | 9 | 4 | `ECRUTEAK_CITY` | 14 | +| 4 | 9 | 5 | `ECRUTEAK_CITY` | 15 | + +**Coord events** - none. + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE38ECRUTEAKGATE_OFFICER` | `SPRITE_OFFICER` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route38EcruteakGateOfficerScript` | -1 | + +The officer is `jumptextfaceplayer` only. Nothing here blocks passage. + +--- + +### MAP_ROUTE_38 + +- Script: `maps/Route38.asm` +- Blocks: `maps/Route38.blk` +- Header (`data/maps/maps.asm:61`): `map Route38, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_38, MUSIC_ROUTE_37, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:50`): `map_const ROUTE_38, 20, 9` (40x18 cells) +- Connections (`data/maps/attributes.asm:215`): west `Route39` / `ROUTE_39` offset 0; east `EcruteakCity` / `ECRUTEAK_CITY` offset -5 +- Scene var: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 35 | 8 | `ROUTE_38_ECRUTEAK_GATE` | 1 | +| 2 | 35 | 9 | `ROUTE_38_ECRUTEAK_GATE` | 2 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 33 | 7 | `BGEVENT_READ` | `Route38Sign` | +| 5 | 13 | `BGEVENT_READ` | `Route38TrainerTips` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE38_STANDING_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 4 | 1 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSchoolboyChad1` | -1 | +| `ROUTE38_LASS` | `SPRITE_LASS` | 15 | 3 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerLassDana1` | -1 | +| `ROUTE38_STANDING_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 15 | 10 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 2 | `TrainerBirdKeeperToby` | -1 | +| `ROUTE38_BEAUTY` | `SPRITE_BEAUTY` | 9 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 2 | `TrainerBeautyValerie` | -1 | +| `ROUTE38_SAILOR` | `SPRITE_SAILOR` | 25 | 5 | `SPRITEMOVEDATA_SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` | 2 | `TrainerSailorHarry` | -1 | +| `ROUTE38_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 12 | 10 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | 0 | `Route38FruitTree` | -1 | + +**Scripts of interest** + +- `Route38FruitTree`: single opcode `fruittree FRUITTREE_ROUTE_38`. + `data/items/fruit_trees.asm:6` maps that index to `BERRY`. This is the + walkthrough's "Go left and you'll get a Berry". +- `TrainerLassDana1.Script`: after the win it runs the standard phone-number + offer. `checkevent EVENT_DANA_READY_FOR_REMATCH` -> rematch arm; + `checkcellnum PHONE_LASS_DANA` -> already registered; otherwise it prints + `LassDanaMoomooMilkText`, sets `EVENT_DANA_ASKED_FOR_PHONE_NUMBER`, and calls + `askforphonenumber PHONE_LASS_DANA`. The rematch arm picks the party by + progress: `checkevent EVENT_CLEARED_RADIO_TOWER` -> `LASS, DANA3`, else + `checkflag ENGINE_FLYPOINT_CIANWOOD` -> `LASS, DANA2`, else `LASS, DANA1`. + Note that flag: the moment a bot walks into Cianwood in this section, Dana's + rematch upgrades to `DANA2`. +- `TrainerSchoolboyChad1.Script`: same shape, `PHONE_SCHOOLBOY_CHAD`, rematch + tier keyed on `EVENT_CLEARED_RADIO_TOWER` -> `CHAD3`, else + `checkflag ENGINE_FLYPOINT_MAHOGANY` -> `CHAD2`, else `CHAD1`. +- Toby, Valerie and Harry are plain `endifjustbattled / opentext / writetext / + waitbutton / closetext / end` after-battle scripts. No flags beyond their own + `EVENT_BEAT_*`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BIRD_KEEPER_TOBY` | `constants/event_flags.asm` (via `trainer` macro) | `TrainerBirdKeeperToby` | set on win; suppresses the sight-range trigger | +| `EVENT_BEAT_SAILOR_HARRY` | as above | `TrainerSailorHarry` | as above | +| `EVENT_BEAT_LASS_DANA` | as above | `TrainerLassDana1` | as above | +| `EVENT_BEAT_SCHOOLBOY_CHAD` | as above | `TrainerSchoolboyChad1` | as above | +| `EVENT_BEAT_BEAUTY_VALERIE` | as above | `TrainerBeautyValerie` | as above | +| `EVENT_DANA_ASKED_FOR_PHONE_NUMBER` | `constants/event_flags.asm` | `TrainerLassDana1.Script` | second-ask text branch | +| `EVENT_DANA_READY_FOR_REMATCH` | `constants/event_flags.asm` | set by the phone (`src/core/gen2/Phone.lua` mirrors `data/phone/`), cleared by `.DanaRematch` | rematch gate | +| `EVENT_CHAD_ASKED_FOR_PHONE_NUMBER` / `EVENT_CHAD_READY_FOR_REMATCH` | `constants/event_flags.asm` | `TrainerSchoolboyChad1.Script` | as above | +| `ENGINE_FLYPOINT_CIANWOOD` | `constants/engine_flags.asm:83` | set by `CianwoodCityFlypointCallback`, read by `.DanaRematch` | rematch tier switch | +| `ENGINE_FLYPOINT_MAHOGANY` | `constants/engine_flags.asm` | read by `.ChadRematch` | rematch tier switch | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `BERRY` | headbutt-free fruit tree, press A facing it | `Route38FruitTree` / `FRUITTREE_ROUTE_38` (`data/items/fruit_trees.asm:6`) | none - refreshes daily (`engine/events/fruit_trees.asm`) | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `TOBY` | `BIRD_KEEPER` | 5 | `BirdKeeperGroup` "TOBY", `TRAINERTYPE_NORMAL`: 15 DODUO, 16 DODUO, 17 DODUO | `TrainerBirdKeeperToby` | no | +| `HARRY` | `SAILOR` | 10 | `SailorGroup` "HARRY", `TRAINERTYPE_NORMAL`: 19 WOOPER | `TrainerSailorHarry` | no | +| `DANA1` | `LASS` | 10 | `LassGroup` "DANA", `TRAINERTYPE_MOVES`: 18 FLAAFFY (TACKLE, GROWL, THUNDERSHOCK, THUNDER_WAVE), 18 PSYDUCK (SCRATCH, TAIL_WHIP, DISABLE, CONFUSION) | `TrainerLassDana1` | `PHONE_LASS_DANA`; `DANA2` = LASS 14 (21 FLAAFFY / 21 PSYDUCK), `DANA3` = LASS 15 (29 PSYDUCK / 29 AMPHAROS) | +| `VALERIE` | `BEAUTY` | 16 | `BeautyGroup` "VALERIE", `TRAINERTYPE_MOVES`: 17 HOPPIP (SYNTHESIS, TAIL_WHIP, TACKLE, POISONPOWDER), 17 SKIPLOOM (SYNTHESIS, TAIL_WHIP, TACKLE, STUN_SPORE) | `TrainerBeautyValerie` | no | +| `CHAD1` | `SCHOOLBOY` | 10 | `SchoolboyGroup` "CHAD", `TRAINERTYPE_NORMAL`: 19 MR__MIME | `TrainerSchoolboyChad1` | `PHONE_SCHOOLBOY_CHAD`; `CHAD2` = SCHOOLBOY 17, `CHAD3` = SCHOOLBOY 18 | + +**Wild encounters** + +`data/wild/johto_grass.asm:2014`, `def_grass_wildmons ROUTE_38`, rates +`10 percent, 10 percent, 10 percent` (morn/day/nite). Gold build +(`IF DEF(_GOLD)`): + +- morn and day, in slot order: 16 RATTATA, 16 RATICATE, 16 MAGNEMITE, + 16 FARFETCH_D, 13 MILTANK, 13 TAUROS, 13 SNUBBULL +- nite: identical except slot 4 is 16 RATTATA instead of FARFETCH_D + +Silver swaps RATTATA for MEOWTH in slots 1 and (at night) 4. Headbutt: +`data/wild/treemon_maps.asm:19` `treemon_map ROUTE_38, TREEMON_SET_FOREST`. + +--- + +### MAP_ROUTE_39 + +- Script: `maps/Route39.asm` +- Blocks: `maps/Route39.blk` +- Header (`data/maps/maps.asm:62`): `map Route39, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_39, MUSIC_ROUTE_37, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:51`): `map_const ROUTE_39, 10, 18` (20x36 cells) +- Connections (`data/maps/attributes.asm:219`): south `OlivineCity` / `OLIVINE_CITY` offset -5; east `Route38` / `ROUTE_38` offset 0 +- Scene var: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 1 | 3 | `ROUTE_39_BARN` | 1 | +| 2 | 5 | 3 | `ROUTE_39_FARMHOUSE` | 1 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 31 | `BGEVENT_READ` | `Route39TrainerTips` | +| 9 | 5 | `BGEVENT_READ` | `MoomooFarmSign` | +| 15 | 7 | `BGEVENT_READ` | `Route39Sign` | +| 5 | 13 | `BGEVENT_ITEM` | `Route39HiddenNugget` -> `hiddenitem NUGGET, EVENT_ROUTE_39_HIDDEN_NUGGET` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE39_SAILOR` | `SPRITE_SAILOR` | 13 | 29 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerSailorEugene` | -1 | +| `ROUTE39_POKEFAN_M` | `SPRITE_POKEFAN_M` | 11 | 19 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerPokefanmDerek` | -1 | +| `ROUTE39_POKEFAN_F` | `SPRITE_POKEFAN_F` | 13 | 22 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerPokefanfRuth` | -1 | +| `ROUTE39_MILTANK1` | `SPRITE_TAUROS` | 3 | 12 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `Route39Miltank` | -1 | +| `ROUTE39_MILTANK2` | `SPRITE_TAUROS` | 6 | 11 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `Route39Miltank` | -1 | +| `ROUTE39_MILTANK3` | `SPRITE_TAUROS` | 4 | 15 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `Route39Miltank` | -1 | +| `ROUTE39_MILTANK4` | `SPRITE_TAUROS` | 8 | 13 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | 0 | `Route39Miltank` | -1 | +| `ROUTE39_PSYCHIC_NORMAN` | `SPRITE_YOUNGSTER` | 13 | 6 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPsychicNorman` | -1 | +| `ROUTE39_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 9 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | 0 | `Route39FruitTree` | -1 | + +**Scripts of interest** + +- `Route39FruitTree`: `fruittree FRUITTREE_ROUTE_39` -> + `data/items/fruit_trees.asm:18` = `MINT_BERRY`. This is the walkthrough's + "grab the Mint Berry". +- `TrainerPokefanmDerek.Script`: phone registration for + `PHONE_POKEFANM_DEREK`; rematch tier is `EVENT_BEAT_ELITE_FOUR` -> `DEREK3`, + else `checkflag ENGINE_FLYPOINT_LAKE_OF_RAGE` -> `DEREK2`, else `DEREK1`. +- Ruth, Eugene and Norman are plain after-battle scripts. + +**Flags and events** - trainer `EVENT_BEAT_*` bits plus +`EVENT_ROUTE_39_HIDDEN_NUGGET` (`constants/event_flags.asm:180`), +`EVENT_DEREK_ASKED_FOR_PHONE_NUMBER` / `EVENT_DEREK_READY_FOR_REMATCH`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MINT_BERRY` | fruit tree at (9,3) | `Route39FruitTree` | none (daily) | +| `NUGGET` | hidden, stand on/face (5,13) and press A | `Route39HiddenNugget` bg_event | `EVENT_ROUTE_39_HIDDEN_NUGGET` | + +The walkthrough does not mention the Nugget. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `NORMAN` | `PSYCHIC_T` | 6 | `PsychicGroup` "NORMAN", `TRAINERTYPE_MOVES`: 17 SLOWPOKE (TACKLE, GROWL, WATER_GUN, -), 20 SLOWPOKE (CURSE, BODY_SLAM, WATER_GUN, CONFUSION) | `TrainerPsychicNorman` | no | +| `DEREK1` | `POKEFANM` | 2 | `PokefanMGroup` "DEREK", `TRAINERTYPE_ITEM`: 17 PIKACHU holding BERRY | `TrainerPokefanmDerek` | `PHONE_POKEFANM_DEREK`; `DEREK2` = POKEFANM 10 (19 PIKACHU/BERRY), `DEREK3` = POKEFANM 11 (36 PIKACHU/BERRY) | +| `RUTH` | `POKEFANF` | 2 | `PokefanFGroup` "RUTH", `TRAINERTYPE_ITEM`: 17 PIKACHU holding BERRY | `TrainerPokefanfRuth` | no | +| `EUGENE` | `SAILOR` | 1 | `SailorGroup` "EUGENE", `TRAINERTYPE_NORMAL`: 17 POLIWHIRL, 17 RATICATE, 19 KRABBY | `TrainerSailorEugene` | no | + +**Wild encounters** + +`data/wild/johto_grass.asm:2069`, `def_grass_wildmons ROUTE_39`. Gold rates are +`2 percent, 2 percent, 10 percent` (Silver: 2/2/2 - Gold gets a much higher +night rate here). + +- morn and day: 16 RATTATA, 17 RATICATE, 16 MAGNEMITE, 16 FARFETCH_D, + 15 MILTANK, 15 TAUROS, 15 TAUROS +- nite: slot 4 becomes 16 RATTATA + +Headbutt: `data/wild/treemon_maps.asm:20` `TREEMON_SET_FOREST`. + +--- + +### MAP_ROUTE_39_BARN + +- Script: `maps/Route39Barn.asm` +- Blocks: `maps/Route39Barn.blk` +- Header (`data/maps/maps.asm:59`): `map Route39Barn, TILESET_TRADITIONAL_HOUSE, INDOOR, LANDMARK_ROUTE_39, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:48`): `map_const ROUTE_39_BARN, 4, 4` +- Connections (`data/maps/attributes.asm:481`): none + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ROUTE_39` | 1 | +| 2 | 4 | 7 | `ROUTE_39` | 1 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE39BARN_TWIN1` | `SPRITE_TWIN` | 2 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route39BarnTwin1Script` | -1 | +| `ROUTE39BARN_TWIN2` | `SPRITE_TWIN` | 4 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route39BarnTwin2Script` | -1 | +| `ROUTE39BARN_MOOMOO` | `SPRITE_TAUROS` | 3 | 3 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `MoomooScript` | -1 | + +**Scripts of interest** + +- `MoomooScript` (`pokegold.sym` 51:4c80). Control flow, verbatim from the + opcodes: + 1. `checkevent EVENT_HEALED_MOOMOO` -> `.HappyCow` (just a cry) if set. + 2. Otherwise print `MoomooWeakMooText`, `setval MILTANK`, + `special PlaySlowCry`, print `Route39BarnItsCryIsWeakText`. + 3. `checkevent EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO` -> `.GiveBerry`. + **This is the gate.** Until you have talked to + `PokefanM_DairyFarmer` in the farmhouse, the Miltank will not accept a + Berry at all. + 4. `.GiveBerry`: `yesorno`, `checkitem BERRY`, `takeitem BERRY`, + `readmem wMooMooBerries`, `addval 1`, `writemem wMooMooBerries`, then + `ifequal 3` / `ifequal 5` / `ifequal 7` for the three progress texts. + At **7** berries it plays `MUSIC_HEAL` and + `setevent EVENT_HEALED_MOOMOO`. +- `Route39BarnTwin1Script` / `Route39BarnTwin2Script`: text only, branch on + `EVENT_HEALED_MOOMOO`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO` | `constants/event_flags.asm:72` | set by `PokefanM_DairyFarmer`, read by `MoomooScript` | must be set before any Berry is accepted | +| `EVENT_HEALED_MOOMOO` | `constants/event_flags.asm:70` | set by `MoomooScript.SevenBerries` | unlocks Moomoo Milk sales and TM13 | +| `wMooMooBerries` | WRAM counter, written via `writemem` | `MoomooScript` | 0..7; 7 is the finish line | + +--- + +### MAP_ROUTE_39_FARMHOUSE + +- Script: `maps/Route39Farmhouse.asm` +- Blocks: none +- Header (`data/maps/maps.asm:60`): `map Route39Farmhouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_39, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:49`): `map_const ROUTE_39_FARMHOUSE, 4, 4` +- Connections (`data/maps/attributes.asm:482`): none + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_39` | 2 | +| 2 | 3 | 7 | `ROUTE_39` | 2 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `FarmhouseBookshelf` | +| 1 | 1 | `BGEVENT_READ` | `FarmhouseBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE39FARMHOUSE_POKEFAN_M` | `SPRITE_POKEFAN_M` | 3 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokefanM_DairyFarmer` | -1 | +| `ROUTE39FARMHOUSE_POKEFAN_F` | `SPRITE_POKEFAN_F` | 5 | 4 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `PokefanF_SnoreFarmer` | -1 | + +**Scripts of interest** + +- `PokefanM_DairyFarmer`: `checkevent EVENT_HEALED_MOOMOO` -> the Moomoo Milk + shop (`FarmerMScript_SellMilk`). Otherwise prints `FarmerMText_SickCow` and + `setevent EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO`. **Talk to him before going to + the barn.** +- `FarmerMScript_SellMilk`: `checkitem MOOMOO_MILK` -> refuse if you already + hold one; else `yesorno`, `checkmoney YOUR_MONEY, 500` + (`DEF ROUTE39FARMHOUSE_MILK_PRICE EQU 500` at line 1), + `giveitem MOOMOO_MILK`, `takemoney YOUR_MONEY, 500`. One at a time. +- `PokefanF_SnoreFarmer` (`pokegold.sym` 51:4ee3): + `checkevent EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM` -> speech only; + `checkevent EVENT_HEALED_MOOMOO` -> `verbosegiveitem TM_SNORE` then + `setevent EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SNORE` (TM13) | talk to `ROUTE39FARMHOUSE_POKEFAN_F` after `EVENT_HEALED_MOOMOO` | `FarmerFScript_GiveSnore` | `EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM` (`constants/event_flags.asm:71`) | +| `MOOMOO_MILK` | buy for 500 from `ROUTE39FARMHOUSE_POKEFAN_M` after `EVENT_HEALED_MOOMOO` | `FarmerMScript_SellMilk` | repeatable, but only one in the bag at a time | + +--- + +### MAP_OLIVINE_CITY + +- Script: `maps/OlivineCity.asm` +- Blocks: `maps/OlivineCity.blk` +- Header (`data/maps/maps.asm:63`): `map OlivineCity, TILESET_JOHTO, TOWN, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:52`): `map_const OLIVINE_CITY, 20, 18` (40x36 cells) +- Connections (`data/maps/attributes.asm:143`): north `Route39` / `ROUTE_39` offset 5; west `Route40` / `ROUTE_40` offset 9 +- Scene var: `wOlivineCitySceneID` (`data/maps/scenes.asm:40`). Scene ids come + from the map's own `def_scene_scripts` block: `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` + = 0 (the default on a new game), `SCENE_OLIVINECITY_NOOP` = 1. +- Spawn (`data/maps/spawn_points.asm:34`): `spawn OLIVINE_CITY, 13, 22`. + Fly point `LANDMARK_OLIVINE_CITY` -> `SPAWN_OLIVINE` (`data/maps/flypoints.asm:11`). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 21 | `OLIVINE_POKECENTER_1F` | 1 | +| 2 | 10 | 11 | `OLIVINE_GYM` | 1 | +| 3 | 25 | 11 | `OLIVINE_TIMS_HOUSE` | 1 | +| 4 | 0 | 0 | `OLIVINE_HOUSE_BETA` | 1 (marked `; inaccessible` in the asm) | +| 5 | 29 | 11 | `OLIVINE_PUNISHMENT_SPEECH_HOUSE` | 1 | +| 6 | 13 | 15 | `OLIVINE_GOOD_ROD_HOUSE` | 1 | +| 7 | 7 | 21 | `OLIVINE_CAFE` | 1 | +| 8 | 19 | 17 | `OLIVINE_MART` | 2 | +| 9 | 29 | 27 | `OLIVINE_LIGHTHOUSE_1F` | 1 | +| 10 | 19 | 27 | `OLIVINE_PORT_PASSAGE` | 1 | +| 11 | 20 | 27 | `OLIVINE_PORT_PASSAGE` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` (0) | 13 | 12 | `OlivineCityRivalSceneTop` | rival cut-scene, north variant | +| `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` (0) | 13 | 13 | `OlivineCityRivalSceneBottom` | rival cut-scene, south variant | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 11 | `BGEVENT_READ` | `OlivineCitySign` | +| 20 | 24 | `BGEVENT_READ` | `OlivineCityPortSign` | +| 7 | 11 | `BGEVENT_READ` | `OlivineGymSign` | +| 30 | 28 | `BGEVENT_READ` | `OlivineLighthouseSign` | +| 14 | 21 | `BGEVENT_READ` | `OlivineCityPokecenterSign` | +| 20 | 17 | `BGEVENT_READ` | `OlivineCityMartSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINECITY_SAILOR1` | `SPRITE_SAILOR` | 26 | 27 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,1) | `OBJECTTYPE_SCRIPT` | `OlivineCitySailor1Script` | -1 | +| `OLIVINECITY_STANDING_YOUNGSTER` | `SPRITE_YOUNGSTER` | 20 | 13 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | `OlivineCityStandingYoungsterScript` | -1 | +| `OLIVINECITY_SAILOR2` | `SPRITE_SAILOR` | 17 | 21 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `OlivineCitySailor2Script` | -1 | +| `OLIVINECITY_OLIVINE_RIVAL` | `SPRITE_OLIVINE_RIVAL` | 10 | 11 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_OLIVINE_CITY` | + +The rival object sits on the gym door tile and is **masked** while +`EVENT_RIVAL_OLIVINE_CITY` is set (`CheckObjectFlag`, +`engine/overworld/map_objects_2.asm:32` - a set flag hides the object). +`InitializeEventsScript` sets it on a new game +(`engine/events/std_scripts.asm:520`), so the rival is invisible until the +scene's `appear` clears it. + +**Scripts of interest** + +- `OlivineCityFlypointCallback` (`callback MAPCALLBACK_NEWMAP`, + `pokegold.sym` 49:400f): a single `setflag ENGINE_FLYPOINT_OLIVINE`. + Walking into Olivine at all registers the fly point. +- `OlivineCityRivalSceneTop` (`pokegold.sym` 49:4013) and + `OlivineCityRivalSceneBottom` (49:404b). Both: + `turnobject PLAYER, LEFT` / `showemote EMOTE_SHOCK, PLAYER, 15` / + `special FadeOutMusic` / `pause 15` / `playsound SFX_ENTER_DOOR` / + `appear OLIVINECITY_OLIVINE_RIVAL` / + `applymovement OLIVINECITY_OLIVINE_RIVAL, <approach>` / + `playmusic MUSIC_RIVAL_ENCOUNTER` / `writetext OlivineCityRivalText` / + `applymovement PLAYER, <step aside>` / + `applymovement OLIVINECITY_OLIVINE_RIVAL, <leaves>` / + `setscene SCENE_OLIVINECITY_NOOP` / `disappear OLIVINECITY_OLIVINE_RIVAL` / + `special RestartMapMusic` / + `variablesprite SPRITE_OLIVINE_RIVAL, SPRITE_SWIMMER_GUY` / + `special LoadUsedSpritesGFX`. + **There is no battle.** The walkthrough is right: he only talks. + The `variablesprite` at the end is load-bearing - it re-points the + `SPRITE_OLIVINE_RIVAL` slot at `SPRITE_SWIMMER_GUY`, which is why the Route 40 + and Route 41 swimmers use `SPRITE_OLIVINE_RIVAL` in their `object_event` rows. + Movements: top variant approach `DOWN, RIGHT, RIGHT`, player steps + `DOWN` + `turn_head UP`, rival leaves `RIGHT x6, UP x6`. Bottom variant + approach `DOWN, DOWN, RIGHT, RIGHT`, player steps `UP` + `turn_head DOWN`, + rival leaves `RIGHT x6, UP x5`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_OLIVINE` | `constants/engine_flags.asm:85` | `OlivineCityFlypointCallback` | Fly destination unlocked on first entry | +| `EVENT_RIVAL_OLIVINE_CITY` | `constants/event_flags.asm:1125` | set by `InitializeEventsScript`, cleared by `appear`, re-set by `disappear` | hides the rival object outside the scene | +| `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` = 0 | `maps/OlivineCity.asm:9` (`scene_script`) | `wOlivineCitySceneID` | default; arms the two coord events | +| `SCENE_OLIVINECITY_NOOP` = 1 | `maps/OlivineCity.asm:10` | `setscene` in both scene scripts | disarms them permanently | + +**Items** - none on the overworld map itself. The section's three Olivine items +come from interiors: `GOOD_ROD` (Good Rod House), `HM_STRENGTH` (Cafe), and +TM23 from Jasmine in the gym (section 09). + +**Trainers** - none. + +**Wild encounters** - `data/wild/johto_water.asm:239`, +`def_water_wildmons OLIVINE_CITY`, rate `6 percent`: 20 TENTACOOL, +15 TENTACOOL, 20 TENTACRUEL. Fishing group is `FISHGROUP_SHORE` +(`data/maps/maps.asm:63`); with the Good Rod that is +`data/wild/fish.asm` `.Shore_Good`: 35% MAGIKARP 20, 35% KRABBY 20, +20%+1 KRABBY 20, remainder `time_group 0` = CORSOLA 20 by day / STARYU 20 at +night (`data/wild/fish.asm:212`). No grass table exists for Olivine City. + +--- + +### MAP_OLIVINE_GOOD_ROD_HOUSE + +- Script: `maps/OlivineGoodRodHouse.asm` +- Header (`data/maps/maps.asm:55`): `map OlivineGoodRodHouse, TILESET_HOUSE, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:44`): `map_const OLIVINE_GOOD_ROD_HOUSE, 4, 4` +- Attributes (`data/maps/attributes.asm:477`): no connections + +**Warps**: 1 (2,7) -> `OLIVINE_CITY` 6; 2 (3,7) -> `OLIVINE_CITY` 6. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINEGOODRODHOUSE_FISHING_GURU` | `SPRITE_FISHING_GURU` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `GoodRodGuru` | -1 | + +**Scripts of interest** + +- `GoodRodGuru` (`pokegold.sym` 51:46fc): `checkevent EVENT_GOT_GOOD_ROD` -> + already-got text; otherwise `writetext OfferGoodRodText`, `yesorno`, + **iffalse -> refusal, no item**, then `verbosegiveitem GOOD_ROD` and + `setevent EVENT_GOT_GOOD_ROD`. A bot must answer YES. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `GOOD_ROD` | talk, answer YES | `GoodRodGuru` | `EVENT_GOT_GOOD_ROD` (`constants/event_flags.asm:32`) | + +--- + +### MAP_OLIVINE_CAFE + +- Script: `maps/OlivineCafe.asm` +- Blocks: `maps/OlivineCafe.blk` +- Header (`data/maps/maps.asm:56`): `map OlivineCafe, TILESET_GAME_CORNER, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:45`): `map_const OLIVINE_CAFE, 4, 4` +- Attributes (`data/maps/attributes.asm:478`): no connections + +**Warps**: 1 (2,7) -> `OLIVINE_CITY` 7; 2 (3,7) -> `OLIVINE_CITY` 7. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINECAFE_SAILOR` | `SPRITE_SAILOR` | 4 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `OlivineCafeStrengthSailorScript` | -1 | +| `OLIVINECAFE_FISHING_GURU` | `SPRITE_FISHING_GURU` | 1 | 5 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,1) | `OBJECTTYPE_SCRIPT` | `OlivineCafeFishingGuruScript` | -1 | + +**Scripts of interest** + +- `OlivineCafeStrengthSailorScript` (`pokegold.sym` 51:48a2): + `checkevent EVENT_GOT_HM04_STRENGTH` -> already-got text; else + `writetext OlivineCafeStrengthSailorText`, `promptbutton`, + `verbosegiveitem HM_STRENGTH`, `setevent EVENT_GOT_HM04_STRENGTH`. + **No prerequisite at all** - no badge check, no event check. The sailor's own + text is the reminder that using Strength in the field needs Goldenrod's badge, + but the HM itself is unconditional. +- `OlivineCafeFishingGuruScript`: flavour text warning about the Route 41 + whirlpools. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HM_STRENGTH` (HM04) | talk to the sailor at (4,3) | `OlivineCafeStrengthSailorScript` | `EVENT_GOT_HM04_STRENGTH` (`constants/event_flags.asm:26`) | + +--- + +### MAP_OLIVINE_POKECENTER_1F + +- Script: `maps/OlivinePokecenter1F.asm` +- Header (`data/maps/maps.asm:50`): `map OlivinePokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:39`): `map_const OLIVINE_POKECENTER_1F, 5, 4` + +**Warps**: 1 (3,7) -> `OLIVINE_CITY` 1; 2 (4,7) -> `OLIVINE_CITY` 1; +3 (0,7) -> `POKECENTER_2F` 1. + +**Object events** + +| const | sprite | x | y | movement | type | script label | +|---|---|---|---|---|---|---| +| `OLIVINEPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivinePokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | +| `OLIVINEPOKECENTER1F_FISHING_GURU` | `SPRITE_FISHING_GURU` | 8 | 4 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `jumpstd HappinessCheckScript` | +| `OLIVINEPOKECENTER1F_FISHER` | `SPRITE_FISHER` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivinePokecenter1FFisherScript` | +| `OLIVINEPOKECENTER1F_TEACHER` | `SPRITE_TEACHER` | 7 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivinePokecenter1FTeacherScript` | + +The happiness-check guru at (8,4) is worth knowing about: it is the standard +`HappinessCheckScript` and the closest one to Shuckie. + +--- + +### MAP_OLIVINE_MART + +- Script: `maps/OlivineMart.asm` +- Header (`data/maps/maps.asm:57`): `map OlivineMart, TILESET_MART, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:46`): `map_const OLIVINE_MART, 6, 4` +- **Warps**: 1 (2,7) -> `OLIVINE_CITY` 8; 2 (3,7) -> `OLIVINE_CITY` 8. +- `OlivineMartClerkScript`: `pokemart MARTTYPE_STANDARD, MART_OLIVINE`. + `MartOlivine` (`data/items/marts.asm:168`), 9 items: GREAT_BALL, + SUPER_POTION, HYPER_POTION, ANTIDOTE, PARLYZ_HEAL, AWAKENING, ICE_HEAL, + SUPER_REPEL, SURF_MAIL. This is the walkthrough's "restock on items, like + Great Balls". + +--- + +### MAP_OLIVINE_LIGHTHOUSE_1F .. 6F + +All six floors share: + +- Header (`data/maps/maps.asm:112-117`): + `map OlivineLighthouseNF, TILESET_LIGHTHOUSE, DUNGEON, LANDMARK_LIGHTHOUSE, MUSIC_LIGHTHOUSE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` + - **exception**: 6F uses `MUSIC_VIOLET_CITY`, not `MUSIC_LIGHTHOUSE` + (`data/maps/maps.asm:117`). +- Dimensions (`constants/map_constants.asm:99-104`): + `map_const OLIVINE_LIGHTHOUSE_NF, 10, 9` for all six (20x18 cells). +- Attributes (`data/maps/attributes.asm:431-436`): no connections. +- No scene vars, no callbacks, no coord events on any floor. + +Floor-to-floor structure, from the six `def_warp_events` blocks. There are two +vertical routes: the interior stairs (varying coordinates) and a right-wall +column at x = 16/17 that the walkthrough calls "the holes on the right side of +every floor". Each right-wall pair is mirrored at identical coordinates on the +floor it links to. + +| from | at (x,y) | to | dest warp | role | +|---|---|---|---|---| +| 1F w3 | 3,11 | 2F | 1 | stairs up | +| 1F w4/w5 | 16,13 / 17,13 | 2F | 3 / 4 | right-wall pair | +| 2F w2 | 5,3 | 3F | 2 | stairs up | +| 2F w3/w4 | 16,13 / 17,13 | 1F | 4 / 5 | right-wall drop to 1F | +| 2F w5/w6 | 16,11 / 17,11 | 3F | 4 / 5 | right-wall pair | +| 3F w1 | 13,3 | 4F | 1 | stairs up | +| 3F w3 | 9,5 | 4F | 4 | stairs | +| 3F w4/w5 | 16,11 / 17,11 | 2F | 5 / 6 | right-wall drop to 2F | +| 3F w6/w7 | 16,9 / 17,9 | 4F | 5 / 6 | right-wall pair | +| 3F w8/w9 | 8,3 / 9,3 | 4F | 7 / 8 | the pair the 4F "hole next to Connie" lands on | +| 4F w2 | 3,5 | 5F | 2 | stairs up | +| 4F w3 | 9,7 | 5F | 3 | stairs | +| 4F w7/w8 | 8,3 / 9,3 | 3F | 8 / 9 | **the hole beside Lass Connie** - drops next to Sailor Terrell and the Ether | +| 4F w9/w10 | 16,7 / 17,7 | 5F | 4 / 5 | right-wall pair | +| 5F w1 | 9,15 | 6F | 1 | stairs up to Jasmine | +| 5F w4/w5 | 16,7 / 17,7 | 4F | 9 / 10 | right-wall drop to 4F | +| 5F w6/w7 | 16,5 / 17,5 | 6F | 2 / 3 | right-wall pair | +| 6F w2/w3 | 16,5 / 17,5 | 5F | 6 / 7 | start of the descent chain out | + +Descent chain the walkthrough describes, in order: +6F (16,5) -> 5F (16,5); walk to 5F (16,7) -> 4F (16,7); walk to 4F (16,9) -> +3F (16,9); walk to 3F (16,11) -> 2F (16,11); walk to 2F (16,13) -> +1F (16,13); then 1F warp 1/2 at (10,17)/(11,17) back to Olivine. + +#### MAP_OLIVINE_LIGHTHOUSE_1F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 17 | `OLIVINE_CITY` | 9 | +| 2 | 11 | 17 | `OLIVINE_CITY` | 9 | +| 3 | 3 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 1 | +| 4 | 16 | 13 | `OLIVINE_LIGHTHOUSE_2F` | 3 | +| 5 | 17 | 13 | `OLIVINE_LIGHTHOUSE_2F` | 4 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE1F_SAILOR` | `SPRITE_SAILOR` | 8 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouse1FSailorScript` | -1 | +| `OLIVINELIGHTHOUSE1F_POKEFAN_F` | `SPRITE_POKEFAN_F` | 16 | 9 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `OlivineLighthouse1FPokefanFScript` | -1 | + +No trainers, no items, no gate on 1F. + +#### MAP_OLIVINE_LIGHTHOUSE_2F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 11 | `OLIVINE_LIGHTHOUSE_1F` | 3 | +| 2 | 5 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 2 | +| 3 | 16 | 13 | `OLIVINE_LIGHTHOUSE_1F` | 4 | +| 4 | 17 | 13 | `OLIVINE_LIGHTHOUSE_1F` | 5 | +| 5 | 16 | 11 | `OLIVINE_LIGHTHOUSE_3F` | 4 | +| 6 | 17 | 11 | `OLIVINE_LIGHTHOUSE_3F` | 5 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE2F_SAILOR` | `SPRITE_SAILOR` | 9 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSailorHuey` | -1 | +| `OLIVINELIGHTHOUSE2F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 17 | 8 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerGentlemanAlfred` | -1 | + +**Scripts of interest** + +- `TrainerSailorHuey.Script`: phone registration for `PHONE_SAILOR_HUEY` + (`EVENT_HUEY_ASKED_FOR_PHONE_NUMBER`), rematch keyed on + `EVENT_BEAT_ELITE_FOUR` -> `HUEY3`, else `EVENT_CLEARED_RADIO_TOWER` -> + `HUEY2`, else `HUEY1`. The walkthrough's mention of a Liz phone call here is + ambient phone traffic, not this script. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `ALFRED` | `GENTLEMAN` | 5 | `GentlemanGroup` "ALFRED", `TRAINERTYPE_NORMAL`: 20 NOCTOWL | `TrainerGentlemanAlfred` | no | +| `HUEY1` | `SAILOR` | 2 | `SailorGroup` "HUEY", `TRAINERTYPE_NORMAL`: 18 POLIWAG, 18 POLIWHIRL | `TrainerSailorHuey` | `PHONE_SAILOR_HUEY`; `HUEY2` = SAILOR 11 (28 POLIWHIRL x2), `HUEY3` = SAILOR 12 (34 POLIWHIRL, 34 POLIWRATH) | + +#### MAP_OLIVINE_LIGHTHOUSE_3F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 1 | +| 2 | 5 | 3 | `OLIVINE_LIGHTHOUSE_2F` | 2 | +| 3 | 9 | 5 | `OLIVINE_LIGHTHOUSE_4F` | 4 | +| 4 | 16 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 5 | +| 5 | 17 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 6 | +| 6 | 16 | 9 | `OLIVINE_LIGHTHOUSE_4F` | 5 | +| 7 | 17 | 9 | `OLIVINE_LIGHTHOUSE_4F` | 6 | +| 8 | 8 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 7 | +| 9 | 9 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 8 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE3F_SAILOR` | `SPRITE_SAILOR` | 9 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 1 | `TrainerSailorTerrell` | -1 | +| `OLIVINELIGHTHOUSE3F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 13 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerGentlemanPreston` | -1 | +| `OLIVINELIGHTHOUSE3F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 3 | 9 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBirdKeeperTheo` | -1 | +| `OLIVINELIGHTHOUSE3F_POKE_BALL` | `SPRITE_POKE_BALL` | 8 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `OlivineLighthouse3FEther` (`itemball ETHER`) | `EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ETHER` | item ball at (8,2), reached by dropping through 4F (8,3)/(9,3) | `OlivineLighthouse3FEther` | `EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER` (`constants/event_flags.asm:1029`) | + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `THEO` | `BIRD_KEEPER` | 4 | `TRAINERTYPE_NORMAL`: 17 PIDGEY, 15 PIDGEY, 19 PIDGEY, 15 PIDGEY, 15 PIDGEY | `TrainerBirdKeeperTheo` | +| `PRESTON` | `GENTLEMAN` | 1 | `TRAINERTYPE_NORMAL`: 18 GROWLITHE, 18 GROWLITHE | `TrainerGentlemanPreston` | +| `TERRELL` | `SAILOR` | 3 | `TRAINERTYPE_NORMAL`: 20 POLIWHIRL | `TrainerSailorTerrell` | + +Sight range 1 on Terrell is why the walkthrough hits him only after dropping +down beside him. + +#### MAP_OLIVINE_LIGHTHOUSE_4F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 1 | +| 2 | 3 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 2 | +| 3 | 9 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 3 | +| 4 | 9 | 5 | `OLIVINE_LIGHTHOUSE_3F` | 3 | +| 5 | 16 | 9 | `OLIVINE_LIGHTHOUSE_3F` | 6 | +| 6 | 17 | 9 | `OLIVINE_LIGHTHOUSE_3F` | 7 | +| 7 | 8 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 8 | +| 8 | 9 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 9 | +| 9 | 16 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 4 | +| 10 | 17 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 5 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE4F_SAILOR` | `SPRITE_SAILOR` | 7 | 14 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSailorKent` | -1 | +| `OLIVINELIGHTHOUSE4F_LASS` | `SPRITE_LASS` | 11 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 1 | `TrainerLassConnie` | -1 | + +Connie stands at (11,2); the drop-down warps 7/8 at (8,3)/(9,3) are the +"hole next to Lass Connie" in the walkthrough. + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `CONNIE1` | `LASS` | 5 | `TRAINERTYPE_NORMAL`: 21 MARILL | `TrainerLassConnie` | +| `KENT` | `SAILOR` | 4 | `TRAINERTYPE_MOVES`: 18 KRABBY (BUBBLE, LEER, VICEGRIP, HARDEN), 20 KRABBY (BUBBLEBEAM, LEER, VICEGRIP, HARDEN) | `TrainerSailorKent` | + +#### MAP_OLIVINE_LIGHTHOUSE_5F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `OLIVINE_LIGHTHOUSE_6F` | 1 | +| 2 | 3 | 5 | `OLIVINE_LIGHTHOUSE_4F` | 2 | +| 3 | 9 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 3 | +| 4 | 16 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 9 | +| 5 | 17 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 10 | +| 6 | 16 | 5 | `OLIVINE_LIGHTHOUSE_6F` | 2 | +| 7 | 17 | 5 | `OLIVINE_LIGHTHOUSE_6F` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 13 | `BGEVENT_ITEM` | `OlivineLighthouse5FHiddenHyperPotion` -> `hiddenitem HYPER_POTION, EVENT_OLIVINE_LIGHTHOUSE_5F_HIDDEN_HYPER_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE5F_SAILOR` | `SPRITE_SAILOR` | 8 | 11 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSailorErnest` | -1 | +| `OLIVINELIGHTHOUSE5F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 8 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerBirdKeeperDenis` | -1 | +| `OLIVINELIGHTHOUSE5F_POKE_BALL1` | `SPRITE_POKE_BALL` | 15 | 12 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `OlivineLighthouse5FRareCandy` (`itemball RARE_CANDY`) | `EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY` | +| `OLIVINELIGHTHOUSE5F_POKE_BALL2` | `SPRITE_POKE_BALL` | 6 | 15 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `OlivineLighthouse5FGreatBall` (`itemball GREAT_BALL`) | `EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL` | +| `OLIVINELIGHTHOUSE5F_POKE_BALL3` | `SPRITE_POKE_BALL` | 2 | 13 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `OlivineLighthouse5FTMSwagger` (`itemball TM_SWAGGER`, `pokegold.sym` 44:6aea) | `EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SWAGGER` (TM34) | item ball at (2,13) | `OlivineLighthouse5FTMSwagger` | `EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER` (`constants/event_flags.asm:1032`) | +| `RARE_CANDY` | item ball at (15,12) | `OlivineLighthouse5FRareCandy` | `EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY` (:1030) | +| `GREAT_BALL` | item ball at (6,15) | `OlivineLighthouse5FGreatBall` | `EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL` (:1031) | +| `HYPER_POTION` | hidden at (3,13) | `OlivineLighthouse5FHiddenHyperPotion` | `EVENT_OLIVINE_LIGHTHOUSE_5F_HIDDEN_HYPER_POTION` (:143) | + +The hidden Hyper Potion is one cell east of the TM34 ball and is not mentioned +in the walkthrough. + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `DENIS` | `BIRD_KEEPER` | 6 | `TRAINERTYPE_NORMAL`: 18 SPEAROW, 20 FEAROW, 18 SPEAROW | `TrainerBirdKeeperDenis` | +| `ERNEST` | `SAILOR` | 5 | `TRAINERTYPE_NORMAL`: 18 MACHOP, 18 MACHOP, 18 POLIWHIRL | `TrainerSailorErnest` | + +Note the party order: the asm has Denis as SPEAROW / FEAROW / SPEAROW; the +walkthrough lists Spearow, Spearow, Fearow. + +#### MAP_OLIVINE_LIGHTHOUSE_6F + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `OLIVINE_LIGHTHOUSE_5F` | 1 | +| 2 | 16 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 6 | +| 3 | 17 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 7 | + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE6F_JASMINE` | `SPRITE_JASMINE` | 8 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouseJasmine` | `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` | +| `OLIVINELIGHTHOUSE6F_MONSTER` | `SPRITE_MONSTER` | 9 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouseAmphy` | -1 | +| `OLIVINELIGHTHOUSE6F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 4 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse6FSuperPotion` (`itemball SUPER_POTION`) | `EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION` | + +`EVENT_OLIVINE_LIGHTHOUSE_JASMINE` is **not** set by +`InitializeEventsScript`, so Jasmine is visible from the start; the script's +own `disappear` sets it once she leaves. + +**Scripts of interest** + +- `OlivineLighthouseJasmine` (`pokegold.sym` 44:6ccd). Three arms: + 1. `checkitem SECRETPOTION` -> `.BroughtSecretpotion` (the cure scene). + 2. Otherwise `checkevent EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` -> + `.ExplainedSickness` (short reminder text). + 3. First visit: `writetext JasmineCianwoodPharmacyText`, `promptbutton`, + **`setevent EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS`**, then the reminder. + This flag is the whole point of the lighthouse climb: it is what + `CianwoodPharmacist` checks before it will hand over the Secretpotion. + 4. `.BroughtSecretpotion`: `yesorno` (NO -> `.Refused`, nothing lost), then + `takeitem SECRETPOTION`, the healing cut-scene (`playmusic MUSIC_HEAL`, + `cry AMPHAROS`, `special FadeOutToWhite` / `FadeInFromWhite`), then + **`setevent EVENT_JASMINE_RETURNED_TO_GYM`** and + **`clearevent EVENT_OLIVINE_GYM_JASMINE`** (the latter un-masks her object + in `maps/OlivineGym.asm`; `InitializeEventsScript` sets it at + `engine/events/std_scripts.asm:511`). Finally it branches on + `readvar VAR_FACING` (DOWN / RIGHT / other) to pick one of three exit + movement scripts and `disappear OLIVINELIGHTHOUSE6F_JASMINE`. +- `OlivineLighthouseAmphy`: `checkevent EVENT_JASMINE_RETURNED_TO_GYM` picks + the weak cry (`special PlaySlowCry`) or the healthy one. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` | `constants/event_flags.asm:64` | set by `OlivineLighthouseJasmine`, read by `CianwoodPharmacist` | **hard gate** on the Secretpotion | +| `EVENT_JASMINE_RETURNED_TO_GYM` | `constants/event_flags.asm:41` | set after the cure | gates the Olivine Gym fight (section 09) | +| `EVENT_OLIVINE_GYM_JASMINE` | `constants/event_flags.asm:1141` | set by `InitializeEventsScript`, cleared here | while set, Jasmine's gym object is masked | +| `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` | `constants/event_flags.asm:1140` | set by `disappear` at the end | removes her from 6F afterwards | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `SUPER_POTION` | item ball at (3,4), west of Jasmine | `OlivineLighthouse6FSuperPotion` | `EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION` (`constants/event_flags.asm:1033`) | + +--- + +### MAP_ROUTE_40 + +- Script: `maps/Route40.asm` +- Blocks: `maps/Route40.blk` +- Header (`data/maps/maps.asm:442`): `map Route40, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_40, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:409`): `map_const ROUTE_40, 10, 18` (20x36 cells) +- Connections (`data/maps/attributes.asm:223`): south `Route41` / `ROUTE_41` offset -15; east `OlivineCity` / `OLIVINE_CITY` offset -9 +- Scene var: none +- Callback: `callback MAPCALLBACK_OBJECTS, Route40MonicaCallback` + +**Warps** - **none**. Route 40 is reached and left purely by map connections. + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 8 | `BGEVENT_READ` | `Route40Sign` | +| 11 | 7 | `BGEVENT_ITEM` | `Route40HiddenHyperPotion` -> `hiddenitem HYPER_POTION, EVENT_ROUTE_40_HIDDEN_HYPER_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE40_OLIVINE_RIVAL1` | `SPRITE_OLIVINE_RIVAL` | 14 | 15 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSwimmermSimon` | -1 | +| `ROUTE40_OLIVINE_RIVAL2` | `SPRITE_OLIVINE_RIVAL` | 18 | 30 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerSwimmermRandall` | -1 | +| `ROUTE40_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 3 | 19 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSwimmerfElaine` | -1 | +| `ROUTE40_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 10 | 25 | `SPRITEMOVEDATA_SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfPaula` | -1 | +| `ROUTE40_ROCK1` | `SPRITE_ROCK` | 12 | 8 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | 0 | `Route40Rock` (`jumpstd SmashRockScript`) | -1 | +| `ROUTE40_ROCK2` | `SPRITE_ROCK` | 11 | 7 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | 0 | `Route40Rock` | -1 | +| `ROUTE40_ROCK3` | `SPRITE_ROCK` | 13 | 6 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | 0 | `Route40Rock` | -1 | +| `ROUTE40_LASS` | `SPRITE_LASS` | 13 | 10 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `Route40Lass1Script` | -1 | +| `ROUTE40_MONICA` | `SPRITE_BEAUTY` | 10 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | 0 | `MonicaScript` | `EVENT_ROUTE_40_MONICA_OF_MONDAY` | + +Note `ROCK2` and the hidden Hyper Potion share the cell (11,7): smash the rock, +then dig up the item. + +**Scripts of interest** + +- `Route40MonicaCallback` (`pokegold.sym` 4c:4ecf): + `readvar VAR_WEEKDAY / ifequal MONDAY, .MonicaAppears`; otherwise + `disappear ROUTE40_MONICA`. Purely day-of-week. +- `MonicaScript`: `checkevent EVENT_GOT_SHARP_BEAK_FROM_MONICA` -> chat; + else `readvar VAR_WEEKDAY / ifnotequal MONDAY` -> chat; else + `setevent EVENT_MET_MONICA_OF_MONDAY`, `verbosegiveitem SHARP_BEAK`, + `setevent EVENT_GOT_SHARP_BEAK_FROM_MONICA`. +- `Route40Rock`: `jumpstd SmashRockScript` -> + `farsjump AskRockSmashScript` (`engine/events/std_scripts.asm:199`). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HYPER_POTION` | hidden at (11,7) | `Route40HiddenHyperPotion` bg_event | `EVENT_ROUTE_40_HIDDEN_HYPER_POTION` (`constants/event_flags.asm:181`) | +| `SHARP_BEAK` | talk to Monica at (10,6), Mondays only | `MonicaScript` | `EVENT_GOT_SHARP_BEAK_FROM_MONICA` (:120) | + +Neither is in the walkthrough. + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `SIMON` | `SWIMMERM` | 2 | `TRAINERTYPE_NORMAL`: 20 TENTACOOL, 20 TENTACOOL | `TrainerSwimmermSimon` | +| `RANDALL` | `SWIMMERM` | 3 | `TRAINERTYPE_NORMAL`: 18 SHELLDER, 20 WARTORTLE, 18 SHELLDER | `TrainerSwimmermRandall` | +| `ELAINE` | `SWIMMERF` | 1 | `TRAINERTYPE_NORMAL`: 21 STARYU | `TrainerSwimmerfElaine` | +| `PAULA` | `SWIMMERF` | 2 | `TRAINERTYPE_NORMAL`: 19 STARYU, 19 SHELLDER | `TrainerSwimmerfPaula` | + +**Wild encounters** + +- Water (`data/wild/johto_water.asm:163`), `def_water_wildmons ROUTE_40`, + rate `6 percent`: 20 TENTACOOL, 15 TENTACOOL, 20 TENTACRUEL. Same in Gold and + Silver. +- Fishing group `FISHGROUP_SHORE` (`data/maps/maps.asm:442`). +- Rock smash: `data/wild/treemon_maps.asm:45` + `treemon_map ROUTE_40, TREEMON_SET_ROCK` (in the `RockMonMaps` table). + `TreeMonSet_Rock` (`data/wild/treemons.asm:91`): 90 KRABBY 15, 10 SHUCKLE 15. + **This is where the walkthrough's Shuckle for this stretch actually comes + from**, not from a water slot. +- Headbutt: `data/wild/treemon_maps.asm:21` `TREEMON_SET_NONE`. + +--- + +### MAP_ROUTE_41 + +- Script: `maps/Route41.asm` +- Blocks: `maps/Route41.blk` +- Header (`data/maps/maps.asm:443`): `map Route41, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_41, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions (`constants/map_constants.asm:410`): `map_const ROUTE_41, 25, 27` (50x54 cells) +- Connections (`data/maps/attributes.asm:227`): north `Route40` / `ROUTE_40` offset 15; west `CianwoodCity` / `CIANWOOD_CITY` offset 0 +- Scene var: none, no callbacks + +**Warps** (`def_warp_events`) - all four are the Whirl Islands, optional in +this section: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 12 | 17 | `WHIRL_ISLAND_NW` | 1 | +| 2 | 36 | 19 | `WHIRL_ISLAND_NE` | 1 | +| 3 | 12 | 37 | `WHIRL_ISLAND_SW` | 1 | +| 4 | 36 | 45 | `WHIRL_ISLAND_SE` | 1 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 35 | `BGEVENT_ITEM` | `Route41HiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_ROUTE_41_HIDDEN_MAX_ETHER` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE41_OLIVINE_RIVAL1` | `SPRITE_OLIVINE_RIVAL` | 32 | 6 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermCharlie` | -1 | +| `ROUTE41_OLIVINE_RIVAL2` | `SPRITE_OLIVINE_RIVAL` | 46 | 8 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermGeorge` | -1 | +| `ROUTE41_OLIVINE_RIVAL3` | `SPRITE_OLIVINE_RIVAL` | 20 | 26 | `SPRITEMOVEDATA_SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermBerke` | -1 | +| `ROUTE41_OLIVINE_RIVAL4` | `SPRITE_OLIVINE_RIVAL` | 32 | 30 | `SPRITEMOVEDATA_SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermKirk` | -1 | +| `ROUTE41_OLIVINE_RIVAL5` | `SPRITE_OLIVINE_RIVAL` | 19 | 46 | `SPRITEMOVEDATA_SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermMathew` | -1 | +| `ROUTE41_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 17 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfKaylee` | -1 | +| `ROUTE41_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 23 | 19 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfSusie` | -1 | +| `ROUTE41_SWIMMER_GIRL3` | `SPRITE_SWIMMER_GIRL` | 27 | 34 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfDenise` | -1 | +| `ROUTE41_SWIMMER_GIRL4` | `SPRITE_SWIMMER_GIRL` | 44 | 28 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSwimmerfKara` | -1 | +| `ROUTE41_SWIMMER_GIRL5` | `SPRITE_SWIMMER_GIRL` | 9 | 50 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerSwimmerfWendy` | -1 | + +All ten are plain `endifjustbattled` after-battle scripts - no phone numbers, +no rematch tiers on this route. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_ETHER` | hidden at (9,35) | `Route41HiddenMaxEther` | `EVENT_ROUTE_41_HIDDEN_MAX_ETHER` (`constants/event_flags.asm:182`) | + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `CHARLIE` | `SWIMMERM` | 4 | 21 SHELLDER, 19 TENTACOOL, 19 TENTACRUEL | `TrainerSwimmermCharlie` | +| `GEORGE` | `SWIMMERM` | 5 | 16 TENTACOOL, 17 TENTACOOL, 16 TENTACOOL, 19 STARYU, 17 TENTACOOL, 19 REMORAID | `TrainerSwimmermGeorge` | +| `BERKE` | `SWIMMERM` | 6 | 23 QWILFISH | `TrainerSwimmermBerke` | +| `KIRK` | `SWIMMERM` | 7 | 20 GYARADOS, 20 GYARADOS | `TrainerSwimmermKirk` | +| `MATHEW` | `SWIMMERM` | 8 | 23 KRABBY | `TrainerSwimmermMathew` | +| `KAYLEE` | `SWIMMERF` | 3 | 18 GOLDEEN, 20 GOLDEEN, 20 SEAKING | `TrainerSwimmerfKaylee` | +| `SUSIE` | `SWIMMERF` | 4 | `TRAINERTYPE_MOVES`: 20 PSYDUCK (SCRATCH, TAIL_WHIP, DISABLE, CONFUSION), 22 GOLDEEN (PECK, TAIL_WHIP, SUPERSONIC, HORN_ATTACK) | `TrainerSwimmerfSusie` | +| `DENISE` | `SWIMMERF` | 5 | 22 SEEL | `TrainerSwimmerfDenise` | +| `KARA` | `SWIMMERF` | 6 | 20 STARYU, 20 STARMIE | `TrainerSwimmerfKara` | +| `WENDY` | `SWIMMERF` | 7 | `TRAINERTYPE_MOVES`: 21 HORSEA (BUBBLE, SMOKESCREEN, LEER, WATER_GUN), 21 HORSEA (DRAGON_RAGE, SMOKESCREEN, LEER, WATER_GUN) | `TrainerSwimmerfWendy` | + +All except Kaylee are named by the walkthrough. + +**Wild encounters** + +- Water (`data/wild/johto_water.asm:170`), `def_water_wildmons ROUTE_41`, + rate `6 percent`. Gold: 20 TENTACOOL, 20 TENTACRUEL, **20 MANTINE**. + Silver: 20 TENTACOOL, 20 TENTACRUEL, 15 TENTACOOL. This is the one place in + the section where the version split matters for the Pokedex. +- Fishing group `FISHGROUP_OCEAN` (`data/maps/maps.asm:443`). Good Rod + (`.Ocean_Good`, `data/wild/fish.asm:45`): 35% MAGIKARP 20, 35% TENTACOOL 20, + 20%+1 CHINCHOU 20, remainder `time_group 2` = SHELLDER 20 day or night. +- Headbutt: `TREEMON_SET_NONE`. Rock smash: Route 41 is **not** in + `RockMonMaps`, and `Route41Rock` in the map asm is marked `; unreferenced`. + +--- + +### MAP_CIANWOOD_CITY + +- Script: `maps/CianwoodCity.asm` +- Blocks: `maps/CianwoodCity.blk` +- Header (`data/maps/maps.asm:444`): `map CianwoodCity, TILESET_JOHTO, TOWN, LANDMARK_CIANWOOD_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:411`): `map_const CIANWOOD_CITY, 15, 27` (30x54 cells) +- Connections (`data/maps/attributes.asm:136`): east `Route41` / `ROUTE_41` offset 0 +- Scene var: none +- Callback: `callback MAPCALLBACK_NEWMAP, CianwoodCityFlypointCallback` +- Spawn (`data/maps/spawn_points.asm:32`): `spawn CIANWOOD_CITY, 23, 44`. + Fly point `LANDMARK_CIANWOOD_CITY` -> `SPAWN_CIANWOOD` (`data/maps/flypoints.asm:12`). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 41 | `MANIAS_HOUSE` | 1 | +| 2 | 8 | 43 | `CIANWOOD_GYM` | 1 | +| 3 | 23 | 43 | `CIANWOOD_POKECENTER_1F` | 1 | +| 4 | 15 | 47 | `CIANWOOD_PHARMACY` | 1 | +| 5 | 9 | 31 | `CIANWOOD_PHOTO_STUDIO` | 1 | +| 6 | 15 | 37 | `CIANWOOD_LUGIA_SPEECH_HOUSE` | 1 | + +The walkthrough's "To the left of the Pokémon Center is a house where a +PokeManiac..." is Mania's House at (17,41), left of the Pokecenter at (23,43). +"the building below that. It's the Pharmacy" is warp 4 at (15,47). + +**Coord events** - none. Nothing scripted fires on entry. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 20 | 34 | `BGEVENT_READ` | `CianwoodCitySign` | +| 7 | 45 | `BGEVENT_READ` | `CianwoodGymSign` | +| 24 | 43 | `BGEVENT_READ` | `CianwoodPokecenterSign` | +| 19 | 47 | `BGEVENT_READ` | `CianwoodPharmacySign` | +| 8 | 32 | `BGEVENT_READ` | `CianwoodPhotoStudioSign` | +| 8 | 16 | `BGEVENT_ITEM` | `CianwoodCityHiddenRevive` -> `hiddenitem REVIVE, EVENT_CIANWOOD_CITY_HIDDEN_REVIVE` | +| 5 | 29 | `BGEVENT_ITEM` | `CianwoodCityHiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CIANWOODCITY_STANDING_YOUNGSTER` | `SPRITE_YOUNGSTER` | 21 | 37 | `SPRITEMOVEDATA_WANDER` (radius 2,2) | `OBJECTTYPE_SCRIPT` | `CianwoodCityYoungster` | -1 | +| `CIANWOODCITY_POKEFAN_M` | `SPRITE_POKEFAN_M` | 17 | 31 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `CianwoodCityPokefanM` | -1 | +| `CIANWOODCITY_LASS` | `SPRITE_LASS` | 14 | 42 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `CianwoodCityLass` | -1 | +| `CIANWOODCITY_ROCK1` | `SPRITE_ROCK` | 8 | 16 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_ROCK2` | `SPRITE_ROCK` | 11 | 15 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_ROCK3` | `SPRITE_ROCK` | 6 | 24 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_ROCK4` | `SPRITE_ROCK` | 5 | 29 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_ROCK5` | `SPRITE_ROCK` | 10 | 27 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_ROCK6` | `SPRITE_ROCK` | 7 | 17 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `CianwoodCityRock` | -1 | +| `CIANWOODCITY_POKEFAN_F` | `SPRITE_POKEFAN_F` | 10 | 46 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | `CianwoodCityChucksWife` | -1 | + +Note both hidden items sit on rock cells: the Revive shares (8,16) with ROCK1, +the Max Ether shares (5,29) with ROCK4. + +**Scripts of interest** + +- `CianwoodCityFlypointCallback` (`pokegold.sym` 48:58df): + `setflag ENGINE_FLYPOINT_CIANWOOD`. +- `CianwoodCityChucksWife` (`pokegold.sym` 48:58e3): + `checkevent EVENT_GOT_HM02_FLY` -> chat; else print + `ChucksWifeEasierToFlyText`, then `checkevent EVENT_BEAT_CHUCK`: + - not set -> `ChucksWifeBeatChuckText`, end. **She will not hand over Fly + before Chuck is beaten.** + - set -> `verbosegiveitem HM_FLY`, `setevent EVENT_GOT_HM02_FLY`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_CIANWOOD` | `constants/engine_flags.asm:83` | `CianwoodCityFlypointCallback` | set on arrival; also bumps Route 38 Dana's rematch tier | +| `EVENT_GOT_HM02_FLY` | `constants/event_flags.asm:24` | `CianwoodCityChucksWife` | one-time | +| `EVENT_BEAT_CHUCK` | `constants/event_flags.asm:711` | set by `CianwoodGymChuckScript` | precondition for HM02 | +| `EVENT_CIANWOOD_CITY_HIDDEN_REVIVE` | `constants/event_flags.asm:188` | `CianwoodCityHiddenRevive` | one-time | +| `EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER` | `constants/event_flags.asm:189` | `CianwoodCityHiddenMaxEther` | one-time | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HM_FLY` (HM02) | talk to `CIANWOODCITY_POKEFAN_F` at (10,46) after `EVENT_BEAT_CHUCK` | `CianwoodCityChucksWife` | `EVENT_GOT_HM02_FLY` | +| `REVIVE` | hidden at (8,16) | `CianwoodCityHiddenRevive` | `EVENT_CIANWOOD_CITY_HIDDEN_REVIVE` | +| `MAX_ETHER` | hidden at (5,29) | `CianwoodCityHiddenMaxEther` | `EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER` | + +**Wild encounters** + +- Water (`data/wild/johto_water.asm:232`), `def_water_wildmons CIANWOOD_CITY`, + rate `6 percent`: 20 TENTACOOL, 15 TENTACOOL, 20 TENTACRUEL. +- Rock smash: `data/wild/treemon_maps.asm:44` + `treemon_map CIANWOOD_CITY, TREEMON_SET_ROCK`, i.e. the same 90 KRABBY 15 / + 10 SHUCKLE 15 table as Route 40. Six smashable rocks are on the map. +- Headbutt: `TREEMON_SET_NONE` (`data/wild/treemon_maps.asm:32`). +- Fishing group `FISHGROUP_SHORE`. + +--- + +### MAP_MANIAS_HOUSE + +- Script: `maps/ManiasHouse.asm` +- Header (`data/maps/maps.asm:445`): `map ManiasHouse, TILESET_HOUSE, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:412`): `map_const MANIAS_HOUSE, 4, 4` +- Attributes (`data/maps/attributes.asm:651`): no connections + +**Warps**: 1 (2,7) -> `CIANWOOD_CITY` 1; 2 (3,7) -> `CIANWOOD_CITY` 1. + +**Coord events / BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MANIASHOUSE_ROCKER` | `SPRITE_ROCKER` | 2 | 4 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `ManiaScript` | -1 | + +**Scripts of interest** + +- `ManiaScript` (`pokegold.sym` 5d:4f6d): + 1. `checkevent EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM` -> happiness + speech, done forever. + 2. `checkevent EVENT_GOT_SHUCKIE` -> `.alreadyhaveshuckie`, which checks + `checkflag ENGINE_GOT_SHUCKIE_TODAY` (`constants/engine_flags.asm:102`); + if that daily flag is clear he asks for Shuckie back + (`.returnshuckie`). + 3. First time: `writetext ManiaText_AskLookAfterShuckle`, `yesorno`, + **`special GiveShuckle`** (`iffalse .partyfull`), then + `setevent EVENT_GOT_SHUCKIE`. + 4. `.returnshuckie`: `special ReturnShuckie` and a five-way `ifequal` on + `SHUCKIE_WRONG_MON` / `SHUCKIE_REFUSED` / `SHUCKIE_HAPPY` / + `SHUCKIE_FAINTED` (constants in `constants/script_constants.asm`). + `SHUCKIE_HAPPY` is the "it likes you enough, keep it" arm the + walkthrough's Thard_Verad note describes, and it also sets + `EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_SHUCKIE` | `constants/event_flags.asm:78` | `ManiaScript` | Shuckie is in your party | +| `EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM` | `constants/event_flags.asm:79` | `.returnshuckie` arms | the encounter is finished | +| `ENGINE_GOT_SHUCKIE_TODAY` | `constants/engine_flags.asm:102` | daily flag, read by `.alreadyhaveshuckie` | he will not ask for it back on the day you got it | + +**Items** - Shuckie is a party member, not a bag item. A bot must have a free +party slot: `GiveShuckle` returns false and the script prints +`ManiaText_PartyFull` otherwise. This is what "clear a space so you can acquire +another Pokémon" means. + +--- + +### MAP_CIANWOOD_PHARMACY + +- Script: `maps/CianwoodPharmacy.asm` +- Header (`data/maps/maps.asm:448`): `map CianwoodPharmacy, TILESET_HOUSE, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:415`): `map_const CIANWOOD_PHARMACY, 4, 4` +- Attributes (`data/maps/attributes.asm:654`): no connections +- Scene scripts: one entry, `scene_script CianwoodPharmacyNoopScene ; unusable` + (a bare `end`, and no scene var row exists in `data/maps/scenes.asm`) + +**Warps**: 1 (2,7) -> `CIANWOOD_CITY` 4; 2 (3,7) -> `CIANWOOD_CITY` 4. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `CianwoodPharmacyBookshelf` | +| 1 | 1 | `BGEVENT_READ` | `CianwoodPharmacyBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CIANWOODPHARMACY_PHARMACIST` | `SPRITE_PHARMACIST` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CianwoodPharmacist` | -1 | + +**Scripts of interest** + +- `CianwoodPharmacist` (`pokegold.sym` 5d:5a9a): + `checkevent EVENT_GOT_SECRETPOTION_FROM_PHARMACY / iftrue .Mart`, then + **`checkevent EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS / iffalse .Mart`**. + Only with that flag set does it run `giveitem SECRETPOTION`, + `playsound SFX_KEY_ITEM`, `itemnotify`, + `setevent EVENT_GOT_SECRETPOTION_FROM_PHARMACY`. Otherwise it just opens the + shop: `pokemart MARTTYPE_PHARMACY, MART_CIANWOOD` -> `MartCianwood` + (`data/items/marts.asm:84`): POTION, SUPER_POTION, HYPER_POTION, FULL_HEAL, + REVIVE. + + **Gate for a bot**: you must talk to Jasmine on Lighthouse 6F *before* the + pharmacist will give you anything. Skipping the lighthouse and sailing + straight to Cianwood silently gets you a shop menu and no Secretpotion. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `SECRETPOTION` | talk to the pharmacist with `EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` set | `CianwoodPharmacist` | `EVENT_GOT_SECRETPOTION_FROM_PHARMACY` (`constants/event_flags.asm:44`) | + +--- + +### MAP_CIANWOOD_PHOTO_STUDIO + +- Script: `maps/CianwoodPhotoStudio.asm` +- Header (`data/maps/maps.asm:449`): `map CianwoodPhotoStudio, TILESET_HOUSE, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:416`): `map_const CIANWOOD_PHOTO_STUDIO, 4, 4` +- **Warps**: 1 (2,7) -> `CIANWOOD_CITY` 5; 2 (3,7) -> `CIANWOOD_CITY` 5. +- Object: `CIANWOODPHOTOSTUDIO_FISHING_GURU`, `SPRITE_FISHING_GURU`, (2,3), + `SPRITEMOVEDATA_STANDING_DOWN`, `OBJECTTYPE_SCRIPT`, + `CianwoodPhotoStudioFishingGuruScript`. +- `CianwoodPhotoStudioFishingGuruScript`: `yesorno` then `special PhotoStudio`. + Purely optional (Game Boy Printer flavour); no flags, no items. + +--- + +### MAP_CIANWOOD_POKECENTER_1F + +- Script: `maps/CianwoodPokecenter1F.asm` +- Header (`data/maps/maps.asm:447`): `map CianwoodPokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:414`): `map_const CIANWOOD_POKECENTER_1F, 5, 4` + +**Warps**: 1 (3,7) -> `CIANWOOD_CITY` 3; 2 (4,7) -> `CIANWOOD_CITY` 3; +3 (0,7) -> `POKECENTER_2F` 1. + +**Object events** + +| const | sprite | x | y | movement | type | script label | +|---|---|---|---|---|---|---| +| `CIANWOODPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `jumpstd PokecenterNurseScript` | +| `CIANWOODPOKECENTER1F_LASS` | `SPRITE_LASS` | 1 | 5 | `SPRITEMOVEDATA_WALK_UP_DOWN` | `OBJECTTYPE_SCRIPT` | `CianwoodPokecenter1FLassScript` | +| `CIANWOODPOKECENTER1F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 5 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CianwoodGymGuideScript` | + +`CianwoodGymGuideScript` branches on `EVENT_BEAT_CHUCK`. The gym guide lives in +the Pokecenter here, not inside the gym. + +--- + +### MAP_CIANWOOD_LUGIA_SPEECH_HOUSE + +- Script: `maps/CianwoodLugiaSpeechHouse.asm` +- Header (`data/maps/maps.asm:450`): `map CianwoodLugiaSpeechHouse, TILESET_HOUSE, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions (`constants/map_constants.asm:417`): `map_const CIANWOOD_LUGIA_SPEECH_HOUSE, 4, 4` +- **Warps**: 1 (2,7) -> `CIANWOOD_CITY` 6; 2 (3,7) -> `CIANWOOD_CITY` 6. +- Objects: `CIANWOODLUGIASPEECHHOUSE_TEACHER` (`SPRITE_TEACHER`, 2,4), + `..._LASS` (`SPRITE_LASS`, 6,5, `WALK_LEFT_RIGHT`), + `..._TWIN` (`SPRITE_TWIN`, 0,2). BG events: bookshelf at (0,1) and (1,1). + All `jumptextfaceplayer` - Lugia lore, no flags. Optional. + +--- + +### MAP_CIANWOOD_GYM + +- Script: `maps/CianwoodGym.asm` +- Blocks: `maps/CianwoodGym.blk` +- Header (`data/maps/maps.asm:446`): `map CianwoodGym, TILESET_TOWER, INDOOR, LANDMARK_CIANWOOD_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` + (the `TRUE` is the phone-service flag) +- Dimensions (`constants/map_constants.asm:413`): `map_const CIANWOOD_GYM, 5, 9` (10x18 cells) +- Attributes (`data/maps/attributes.asm:652`): no connections +- Scene var: none, no callbacks, no coord events + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `CIANWOOD_CITY` | 2 | +| 2 | 5 | 17 | `CIANWOOD_CITY` | 2 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `CianwoodGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `CianwoodGymStatue` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `CIANWOODGYM_CHUCK` | `SPRITE_CHUCK` | 4 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `CianwoodGymChuckScript` | -1 | +| `CIANWOODGYM_BLACK_BELT1` | `SPRITE_BLACK_BELT` | 2 | 12 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBlackbeltYoshi` | -1 | +| `CIANWOODGYM_BLACK_BELT2` | `SPRITE_BLACK_BELT` | 7 | 12 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBlackbeltLao` | -1 | +| `CIANWOODGYM_BLACK_BELT3` | `SPRITE_BLACK_BELT` | 3 | 9 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 2 | `TrainerBlackbeltNob` | -1 | +| `CIANWOODGYM_BLACK_BELT4` | `SPRITE_BLACK_BELT` | 5 | 5 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerBlackbeltLung` | -1 | +| `CIANWOODGYM_BOULDER1` | `SPRITE_BOULDER` | 5 | 1 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `CianwoodGymBoulder` | -1 | +| `CIANWOODGYM_BOULDER2` | `SPRITE_BOULDER` | 3 | 7 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `CianwoodGymBoulder` | -1 | +| `CIANWOODGYM_BOULDER3` | `SPRITE_BOULDER` | 4 | 7 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `CianwoodGymBoulder` | -1 | +| `CIANWOODGYM_BOULDER4` | `SPRITE_BOULDER` | 5 | 7 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `CianwoodGymBoulder` | -1 | + +The puzzle the walkthrough describes is `BOULDER2`/`BOULDER3`/`BOULDER4`, the +row at y = 7, x = 3/4/5. `BOULDER1` at (5,1) is Chuck's prop: he throws it as +part of his intro. Blackbelt Lung at (5,5) is behind the boulder row - the +walkthrough's "Use Strength to push your way to the last trainer before the gym +leader". + +**Scripts of interest** + +- `CianwoodGymChuckScript` (`pokegold.sym` 5d:5304): + 1. `checkevent EVENT_BEAT_CHUCK / iftrue .FightDone`. + 2. Intro: `writetext ChuckIntroText1`, `turnobject CIANWOODGYM_CHUCK, RIGHT`, + `writetext ChuckIntroText2`, + `applymovement CIANWOODGYM_BOULDER1, CianwoodGymMovement_ChuckChucksBoulder` + (`set_sliding / big_step LEFT / big_step UP / fast_jump_step RIGHT / + remove_sliding`), `playsound SFX_STRENGTH`, `earthquake 80`, + `disappear CIANWOODGYM_BOULDER1`, `writetext ChuckIntroText3`. + 3. `winlosstext ChuckLossText, 0`, `loadtrainer CHUCK, CHUCK1`, + `startbattle`, `reloadmapafterbattle`, `setevent EVENT_BEAT_CHUCK`. + 4. `writetext GetStormBadgeText`, `playsound SFX_GET_BADGE`, + **`setflag ENGINE_STORMBADGE`**, then + `readvar VAR_BADGES / scall CianwoodGymActivateRockets`. + `CianwoodGymActivateRockets`: `ifequal 7, .RadioTowerRockets`, + `ifequal 6, .GoldenrodRockets` (`jumpstd GoldenrodRocketsScript` / + `RadioTowerRocketsScript`). With Storm as your 5th badge neither fires; + it only matters if you took Chuck out of order. + 5. `.FightDone`: `checkevent EVENT_GOT_TM01_DYNAMICPUNCH / iftrue`, else + **`setevent EVENT_BEAT_BLACKBELT_YOSHI` / `..._LAO` / `..._NOB` / + `..._LUNG`**, then `verbosegiveitem TM_DYNAMICPUNCH` and + `setevent EVENT_GOT_TM01_DYNAMICPUNCH`. + + **Bot-critical**: collecting the TM marks all four Blackbelts as beaten. + Fight them *before* Chuck or their EXP and money are gone. +- `CianwoodGymBoulder`: `jumpstd StrengthBoulderScript` -> + `farsjump AskStrengthScript` (`engine/events/std_scripts.asm:196`, + `engine/events/overworld.asm:1001`). That script calls `TryStrengthOW` + (`engine/events/overworld.asm:1038`), which needs a party mon knowing + `STRENGTH` **and** `ENGINE_PLAINBADGE`. +- `CianwoodGymStatue`: `checkflag ENGINE_STORMBADGE` -> `GymStatue2Script` with + `gettrainername STRING_BUFFER_4, CHUCK, CHUCK1`, else `GymStatue1Script`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_CHUCK` | `constants/event_flags.asm:711` | `CianwoodGymChuckScript` | badge earned; unlocks HM02 from Chuck's wife | +| `ENGINE_STORMBADGE` | `constants/engine_flags.asm:43` | `setflag` after the win | Fly's field gate (`engine/events/overworld.asm:545`), obedience up to L70 | +| `EVENT_GOT_TM01_DYNAMICPUNCH` | `constants/event_flags.asm:16` | `.FightDone` | one-time | +| `EVENT_BEAT_BLACKBELT_YOSHI/LAO/NOB/LUNG` | `constants/event_flags.asm` | set en masse by `.FightDone` | see the warning above | +| `ENGINE_PLAINBADGE` | `constants/engine_flags.asm:40` | read by `TryStrengthOW` | needed to shift the three boulders | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_DYNAMICPUNCH` (TM01) | talk to Chuck after the battle | `CianwoodGymChuckScript.FightDone` | `EVENT_GOT_TM01_DYNAMICPUNCH` | + +**Trainers** + +| const | class | id | party | script label | +|---|---|---|---|---| +| `YOSHI` | `BLACKBELT_T` | 2 | `TRAINERTYPE_MOVES`: 27 HITMONLEE (DOUBLE_KICK, MEDITATE, JUMP_KICK, FOCUS_ENERGY) | `TrainerBlackbeltYoshi` | +| `LAO` | `BLACKBELT_T` | 4 | `TRAINERTYPE_MOVES`: 27 HITMONCHAN (COMET_PUNCH, THUNDERPUNCH, ICE_PUNCH, FIRE_PUNCH) | `TrainerBlackbeltLao` | +| `NOB` | `BLACKBELT_T` | 5 | `TRAINERTYPE_MOVES`: 25 MACHOP (LEER, FOCUS_ENERGY, KARATE_CHOP, SEISMIC_TOSS), 25 MACHOKE (LEER, KARATE_CHOP, SEISMIC_TOSS, ROCK_SLIDE) | `TrainerBlackbeltNob` | +| `LUNG` | `BLACKBELT_T` | 7 | `TRAINERTYPE_NORMAL`: 23 MANKEY, 23 MANKEY, 25 PRIMEAPE | `TrainerBlackbeltLung` | +| `CHUCK1` | `CHUCK` | 1 | `ChuckGroup`, `TRAINERTYPE_MOVES`: 27 PRIMEAPE (LEER, RAGE, KARATE_CHOP, FURY_SWIPES), 30 POLIWRATH (HYPNOSIS, MIND_READER, SURF, DYNAMICPUNCH) | `CianwoodGymChuckScript` | + +**Wild encounters** - none (indoor). + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Reaching Route 40's sea and therefore Route 41 / Cianwood | `engine/events/overworld.asm:490` `TrySurfOW` and `:340` `SurfFunction.TrySurf` - `ld de, ENGINE_FOGBADGE / call CheckBadge` | Fog Badge + a party mon that knows `SURF` (`CheckPartyMove`) | Fog Badge is Morty's, earned in section 07. HM03 SURF comes from the Route 42 / Sudowoodo beat | +| Cianwood Gym boulders at (3,7)/(4,7)/(5,7) | `CianwoodGymBoulder` -> `jumpstd StrengthBoulderScript` -> `engine/events/overworld.asm:1001` `AskStrengthScript` / `:1038` `TryStrengthOW` | `ENGINE_PLAINBADGE` **and** a party mon knowing `STRENGTH` | Plain Badge from Whitney (earlier section); HM04 from `OlivineCafeStrengthSailorScript` in this section | +| Secretpotion from the Cianwood pharmacist | `maps/CianwoodPharmacy.asm:CianwoodPharmacist` - `checkevent EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS / iffalse .Mart` | that flag | talk to Jasmine on `MAP_OLIVINE_LIGHTHOUSE_6F` (`OlivineLighthouseJasmine`) | +| HM02 Fly from Chuck's wife | `maps/CianwoodCity.asm:CianwoodCityChucksWife` - `checkevent EVENT_BEAT_CHUCK` | Chuck beaten | `CianwoodGymChuckScript` | +| Using Fly in the field | `engine/events/overworld.asm:545` `FlyFunction.TryFly` - `ld de, ENGINE_STORMBADGE` | Storm Badge | the same gym win | +| Route 41 whirlpool tiles (Whirl Islands access only) | `engine/events/overworld.asm:1077` `WhirlpoolFunction.TryWhirlpool` / `:1171` `TryWhirlpoolOW` - `ld de, ENGINE_GLACIERBADGE` | Glacier Badge + `WHIRLPOOL` | Pryce, much later. **Not** required to reach Cianwood - the walkthrough's route only passes whirlpools, it never crosses one | +| Moomoo Farm berry feeding | `maps/Route39Barn.asm:MoomooScript` - `checkevent EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO` | talk to `PokefanM_DairyFarmer` first | `PokefanM_DairyFarmer` sets it unconditionally | +| TM13 SNORE | `maps/Route39Farmhouse.asm:FarmerFScript_GiveSnore` - `checkevent EVENT_HEALED_MOOMOO` | 7 Berries fed to Moomoo | `MoomooScript.SevenBerries` | +| Shuckie | `maps/ManiasHouse.asm:ManiaScript` - `special GiveShuckle` returns false on a full party | one free party slot | box a mon at the Pokecenter first | +| Olivine rival cut-scene | `coord_event 13,12` / `13,13` gated on `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` (`wOlivineCitySceneID` = 0) | none - it is unavoidable if you walk those cells | `setscene SCENE_OLIVINECITY_NOOP` at the end of either scene script | + +Nothing in this section blocks the *walk* into Olivine City or up the +lighthouse. The only true forward gates are Surf (to leave Olivine westward), +the Jasmine-then-pharmacist flag chain, and Strength for the gym boulders. + +## 4. Bot checklist + +Preconditions carried in from earlier sections: `ENGINE_FOGBADGE`, +`ENGINE_PLAINBADGE`, `ENGINE_HIVEBADGE`, a mon that can learn `SURF`, a mon +that can learn `STRENGTH`, and ideally a stack of `BERRY` for Moomoo. + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `ECRUTEAK_CITY` | warps 14/15 | walk in | - | on `ROUTE_38_ECRUTEAK_GATE` | +| 2 | `ROUTE_38_ECRUTEAK_GATE` | warp 1 (0,4) or 2 (0,5) | walk west | - | on `ROUTE_38` at (35,8)/(35,9) | +| 3 | `ROUTE_38` | `ROUTE38_STANDING_YOUNGSTER2` (15,10) | battle | - | `EVENT_BEAT_BIRD_KEEPER_TOBY` | +| 4 | `ROUTE_38` | `ROUTE38_FRUIT_TREE` (12,10) | press A facing it | - | `BERRY` in bag | +| 5 | `ROUTE_38` | `ROUTE38_SAILOR` (25,5) | battle | - | `EVENT_BEAT_SAILOR_HARRY` | +| 6 | `ROUTE_38` | `ROUTE38_LASS` (15,3) | battle, then talk again, answer YES | - | `EVENT_BEAT_LASS_DANA`, `PHONE_LASS_DANA` registered | +| 7 | `ROUTE_38` | `ROUTE38_BEAUTY` (9,6) | battle | - | `EVENT_BEAT_BEAUTY_VALERIE` | +| 8 | `ROUTE_38` | `ROUTE38_STANDING_YOUNGSTER1` (4,1) | battle, talk again for the number | - | `EVENT_BEAT_SCHOOLBOY_CHAD` | +| 9 | `ROUTE_38` | west edge | walk west (connection) | - | on `ROUTE_39` | +| 10 | `ROUTE_39` | `ROUTE39_PSYCHIC_NORMAN` (13,6) | battle | - | `EVENT_BEAT_PSYCHIC_NORMAN` | +| 11 | `ROUTE_39` | `ROUTE39_FRUIT_TREE` (9,3) | press A | - | `MINT_BERRY` | +| 12 | `ROUTE_39` | (5,13) | press A (hidden) | - | `NUGGET`, `EVENT_ROUTE_39_HIDDEN_NUGGET` | +| 13 | `ROUTE_39` | warp 2 (5,3) -> farmhouse, `ROUTE39FARMHOUSE_POKEFAN_M` (3,2) | talk | - | `EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO` | +| 14 | `ROUTE_39_BARN` (via Route 39 warp 1 at (1,3)) | `ROUTE39BARN_MOOMOO` (3,3) | talk, YES, x7 with a `BERRY` each time | step 13 done, 7 `BERRY` in bag | `wMooMooBerries` = 7, `EVENT_HEALED_MOOMOO` | +| 15 | `ROUTE_39_FARMHOUSE` | `ROUTE39FARMHOUSE_POKEFAN_F` (5,4) | talk | `EVENT_HEALED_MOOMOO` | `TM_SNORE`, `EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM` | +| 16 | `ROUTE_39` | `ROUTE39_POKEFAN_M` (11,19), `ROUTE39_POKEFAN_F` (13,22), `ROUTE39_SAILOR` (13,29) | battle each | - | three `EVENT_BEAT_*` | +| 17 | `ROUTE_39` | south edge | walk south (connection) | - | on `OLIVINE_CITY`, `ENGINE_FLYPOINT_OLIVINE` set by the NEWMAP callback | +| 18 | `OLIVINE_CITY` | (13,12) or (13,13) | walk onto the cell | `wOlivineCitySceneID` == 0 | rival scene runs; scene -> 1; `SPRITE_OLIVINE_RIVAL` re-pointed to `SPRITE_SWIMMER_GUY` | +| 19 | `OLIVINE_CITY` | warp 6 (13,15) -> `OLIVINEGOODRODHOUSE_FISHING_GURU` (2,3) | talk, answer YES | - | `GOOD_ROD`, `EVENT_GOT_GOOD_ROD` | +| 20 | `OLIVINE_CITY` | warp 1 (13,21) | heal | - | party healed; spawn point already (13,22) | +| 21 | `OLIVINE_CITY` | warp 7 (7,21) -> `OLIVINECAFE_SAILOR` (4,3) | talk | - | `HM_STRENGTH`, `EVENT_GOT_HM04_STRENGTH` | +| 22 | (menu) | any eligible mon | teach `STRENGTH` from HM04 | - | Strength usable once `ENGINE_PLAINBADGE` is held | +| 23 | `OLIVINE_CITY` | warp 9 (29,27) | walk in | - | on `OLIVINE_LIGHTHOUSE_1F` | +| 24 | `OLIVINE_LIGHTHOUSE_1F` | warp 3 (3,11) | stairs | - | `OLIVINE_LIGHTHOUSE_2F` (3,11) | +| 25 | `OLIVINE_LIGHTHOUSE_2F` | `..._GENTLEMAN` (17,8), `..._SAILOR` (9,3) | battle both; talk to Huey again for his number | - | `EVENT_BEAT_GENTLEMAN_ALFRED`, `EVENT_BEAT_SAILOR_HUEY` | +| 26 | `OLIVINE_LIGHTHOUSE_2F` | warp 2 (5,3) | stairs | - | `OLIVINE_LIGHTHOUSE_3F` (5,3) | +| 27 | `OLIVINE_LIGHTHOUSE_3F` | `..._YOUNGSTER` (3,9), `..._GENTLEMAN` (13,5) | battle | - | `EVENT_BEAT_BIRD_KEEPER_THEO`, `EVENT_BEAT_GENTLEMAN_PRESTON` | +| 28 | `OLIVINE_LIGHTHOUSE_3F` | warp 1 (13,3) | stairs | - | `OLIVINE_LIGHTHOUSE_4F` (13,3) | +| 29 | `OLIVINE_LIGHTHOUSE_4F` | `..._LASS` (11,2), `..._SAILOR` (7,14) | battle | - | `EVENT_BEAT_LASS_CONNIE`, `EVENT_BEAT_SAILOR_KENT` | +| 30 | `OLIVINE_LIGHTHOUSE_4F` | warp 2 (3,5) | stairs | - | `OLIVINE_LIGHTHOUSE_5F` (3,5) | +| 31 | `OLIVINE_LIGHTHOUSE_5F` | item ball (2,13) | press A | - | `TM_SWAGGER`, `EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER` | +| 32 | `OLIVINE_LIGHTHOUSE_5F` | (3,13) | press A (hidden) | - | `HYPER_POTION` | +| 33 | `OLIVINE_LIGHTHOUSE_5F` | `..._YOUNGSTER` (8,3) | battle | - | `EVENT_BEAT_BIRD_KEEPER_DENIS` | +| 34 | `OLIVINE_LIGHTHOUSE_5F` | item ball (15,12) | press A | - | `RARE_CANDY` | +| 35 | `OLIVINE_LIGHTHOUSE_5F` | warp 4 (16,7) | drop to 4F | - | `OLIVINE_LIGHTHOUSE_4F` (16,7) | +| 36 | `OLIVINE_LIGHTHOUSE_4F` | warp 7 (8,3) or 8 (9,3) | drop to 3F | - | `OLIVINE_LIGHTHOUSE_3F` (8,3)/(9,3) | +| 37 | `OLIVINE_LIGHTHOUSE_3F` | `..._SAILOR` (9,2) | battle | - | `EVENT_BEAT_SAILOR_TERRELL` | +| 38 | `OLIVINE_LIGHTHOUSE_3F` | item ball (8,2) | press A | - | `ETHER` | +| 39 | `OLIVINE_LIGHTHOUSE_3F` | warp 1 (13,3), then 4F warp 2 (3,5) | back up to 5F | - | on 5F | +| 40 | `OLIVINE_LIGHTHOUSE_5F` | `..._SAILOR` (8,11) | battle | - | `EVENT_BEAT_SAILOR_ERNEST` | +| 41 | `OLIVINE_LIGHTHOUSE_5F` | item ball (6,15) | press A | - | `GREAT_BALL` | +| 42 | `OLIVINE_LIGHTHOUSE_5F` | warp 1 (9,15) | stairs | - | `OLIVINE_LIGHTHOUSE_6F` (9,15) | +| 43 | `OLIVINE_LIGHTHOUSE_6F` | `OLIVINELIGHTHOUSE6F_JASMINE` (8,8) | talk | no `SECRETPOTION` in bag | **`EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS`** | +| 44 | `OLIVINE_LIGHTHOUSE_6F` | item ball (3,4) | press A | - | `SUPER_POTION` | +| 45 | `OLIVINE_LIGHTHOUSE_6F` .. `1F` | (16,5) -> (16,7) -> (16,9) -> (16,11) -> (16,13) | walk the right-wall drop chain | - | back on `OLIVINE_LIGHTHOUSE_1F` | +| 46 | `OLIVINE_LIGHTHOUSE_1F` | warp 1 (10,17) | exit | - | `OLIVINE_CITY` (29,27) | +| 47 | `OLIVINE_CITY` | warp 1 (13,21) | heal | - | - | +| 48 | (menu) | a water mon | teach `SURF` (HM03) | HM03 in bag | Surf usable (`ENGINE_FOGBADGE` already held) | +| 49 | `OLIVINE_CITY` | warp 8 (19,17) | buy `GREAT_BALL` etc. | money | - | +| 50 | `OLIVINE_CITY` | west edge | walk west (connection) | - | on `ROUTE_40` | +| 51 | `ROUTE_40` | face the sea, press A / use SURF | start surfing | `ENGINE_FOGBADGE` + `SURF` | `PLAYER_SURF` | +| 52 | `ROUTE_40` | (14,15), (3,19), (10,25), (18,30) | battle Simon, Elaine, Paula, Randall | - | four `EVENT_BEAT_SWIMMER*` | +| 53 | `ROUTE_40` | (11,7) rock then (11,7) hidden | Rock Smash, then press A | `ROCK_SMASH` | `HYPER_POTION`; the rock roll can also yield 10% SHUCKLE 15 | +| 54 | `ROUTE_40` | south edge | swim south (connection) | - | on `ROUTE_41` | +| 55 | `ROUTE_41` | (46,8) George, (44,28) Kara, (32,6) Charlie, (9,50) Wendy, (23,19) Susie, (20,26) Berke, (27,34) Denise, (32,30) Kirk, (19,46) Mathew, (17,4) Kaylee | battle as encountered | - | ten `EVENT_BEAT_SWIMMER*` | +| 56 | `ROUTE_41` | (9,35) | press A (hidden) | - | `MAX_ETHER` | +| 57 | `ROUTE_41` | west edge | swim west (connection) | - | on `CIANWOOD_CITY`; `ENGINE_FLYPOINT_CIANWOOD` set | +| 58 | `CIANWOOD_CITY` | warp 3 (23,43) | heal, deposit a mon to free a party slot | - | party size < 6 | +| 59 | `CIANWOOD_CITY` | warp 1 (17,41) -> `MANIASHOUSE_ROCKER` (2,4) | talk, answer YES | free party slot | `EVENT_GOT_SHUCKIE`, L15 SHUCKLE "SHUCKIE" holding `BERRY`, OT MANIA | +| 60 | `CIANWOOD_CITY` | warp 4 (15,47) -> `CIANWOODPHARMACY_PHARMACIST` (2,3) | talk | **`EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS`** | `SECRETPOTION`, `EVENT_GOT_SECRETPOTION_FROM_PHARMACY` | +| 61 | `CIANWOOD_CITY` | (8,16) and (5,29) | Rock Smash the rock, press A | `ROCK_SMASH` | `REVIVE`, `MAX_ETHER` | +| 62 | `CIANWOOD_CITY` | warp 2 (8,43) | enter gym | - | on `CIANWOOD_GYM` (4,17) | +| 63 | `CIANWOOD_GYM` | (2,12) Yoshi, (7,12) Lao | battle both | - | `EVENT_BEAT_BLACKBELT_YOSHI`, `..._LAO` | +| 64 | `CIANWOOD_GYM` | (3,9) Nob | battle | - | `EVENT_BEAT_BLACKBELT_NOB` | +| 65 | `CIANWOOD_GYM` | boulders at (3,7),(4,7),(5,7) | use Strength, push a lane open | `ENGINE_PLAINBADGE` + `STRENGTH` | path north | +| 66 | `CIANWOOD_GYM` | (5,5) Lung | battle | - | `EVENT_BEAT_BLACKBELT_LUNG` | +| 67 | `CIANWOOD_CITY` | warp 3 | heal, save | - | - | +| 68 | `CIANWOOD_GYM` | `CIANWOODGYM_CHUCK` (4,1) | talk -> battle | all four blackbelts already beaten | `EVENT_BEAT_CHUCK`, `ENGINE_STORMBADGE`, 3000 gold | +| 69 | `CIANWOOD_GYM` | `CIANWOODGYM_CHUCK` | talk again | `EVENT_BEAT_CHUCK` | `TM_DYNAMICPUNCH`, `EVENT_GOT_TM01_DYNAMICPUNCH` | +| 70 | `CIANWOOD_CITY` | `CIANWOODCITY_POKEFAN_F` (10,46) | talk | `EVENT_BEAT_CHUCK` | `HM_FLY`, `EVENT_GOT_HM02_FLY` | +| 71 | (menu) | a flying-capable mon | teach `FLY` | `ENGINE_STORMBADGE` | fly available; section ends, next section flies back to Olivine | + +## 5. Port coverage + +The Gen 2 port is engine-level and data-driven: every map header, `.blk`, +warp/coord/bg/object table and script bytecode in this section is read out of +the ROM by `src/import/RomExtractorGen2.lua` (`readMapEvents` at lines 785-862, +map records written at 973-976) rather than hand-transcribed, so "implemented" +below means the *opcodes and specials these particular scripts use* are +implemented, not that anyone has walked the maps. + +| Beat | Port file | Status | +|---|---|---| +| Map headers, warps, coord events, bg events, object events for all 21 maps | `src/import/RomExtractorGen2.lua`, `src/world/gen2/Map.lua`, `src/world/gen2/World.lua` | implemented (generic, ROM-driven) | +| `MAPCALLBACK_NEWMAP` fly-point callbacks (`OlivineCityFlypointCallback`, `CianwoodCityFlypointCallback`) | `src/world/gen2/World.lua`, driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| `MAPCALLBACK_OBJECTS` weekday callback (`Route40MonicaCallback`) | `src/world/gen2/World.lua` + `readvar VAR_WEEKDAY` in `src/script/gen2/Opcodes.lua` | implemented | +| Scene scripts / `setscene` / coord-event trip-wires (the Olivine rival) | `src/world/gen2/World.lua` (`mapScenes`, `setScene`, `setMapScene`), `src/script/gen2/Vm.lua` | implemented | +| Rival cut-scene opcodes: `showemote`, `applymovement`, `appear`/`disappear`, `variablesprite`, `special LoadUsedSpritesGFX`, `special FadeOutMusic`/`RestartMapMusic` | `src/script/gen2/Opcodes.lua`, `src/script/gen2/Movement.lua`, `src/script/gen2/Specials.lua` | implemented | +| Trainer objects, sight ranges, `loadtrainer`/`startbattle`/`winlosstext` | `src/world/gen2/Trainers.lua`, `src/script/gen2/Vm.lua`, driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| Phone registration and rematch tiers (Dana, Chad, Derek, Huey) | `src/core/gen2/Phone.lua`; `checkcellnum` / `askforphonenumber` in `src/script/gen2/Opcodes.lua` | implemented | +| Item balls (`OBJECTTYPE_ITEMBALL`) and hidden items (`BGEVENT_ITEM`) | `src/world/gen2/World.lua` (bg-event jumptable), `src/world/gen2/HiddenItems.lua` | implemented | +| Fruit trees (`fruittree FRUITTREE_ROUTE_38/39`) | `fruittree` opcode in `src/script/gen2/Opcodes.lua` | implemented | +| Moomoo Farm berry counter (`readmem`/`addval`/`writemem wMooMooBerries`) | `src/script/gen2/Vm.lua:711` and `:1657` (`scriptMem`), persisted by `src/core/gen2/Save.lua:187` | implemented - the port names this exact WRAM slot | +| `verbosegiveitem` / `giveitem` / `takeitem` / `checkitem` / `itemnotify` (Good Rod, HM04, TM13, Secretpotion, HM02, TM01) | `src/script/gen2/Opcodes.lua` | implemented | +| Moomoo Milk purchase (`checkmoney` / `takemoney` / `giveitem`) | `src/script/gen2/Opcodes.lua` | implemented | +| Marts: `MARTTYPE_STANDARD` (`MART_OLIVINE`) and `MARTTYPE_PHARMACY` (`MART_CIANWOOD`) | `src/ui/gen2/MartMenu.lua` (`PHARMACY` table at :212) | implemented | +| Shuckie: `special GiveShuckle` / `special ReturnShuckie`, OT MANIA id 518, happiness arms, `ENGINE_GOT_SHUCKIE_TODAY` | `src/script/gen2/Specials.lua:1191` onward | implemented | +| Cianwood photo studio `special PhotoStudio` | `src/script/gen2/Specials.lua:2122`, `src/ui/gen2/PhotoStudio.lua` | implemented | +| Jasmine cure scene: `special PlaySlowCry`, `special FadeOutToWhite`/`FadeInFromWhite`, `cry AMPHAROS`, `readvar VAR_FACING` branch | `src/script/gen2/Specials.lua` (998, 1000, 1079), `src/script/gen2/Opcodes.lua` | implemented | +| Surf field move + `ENGINE_FOGBADGE` gate | `src/world/gen2/FieldMoves.lua` (`surfFromMenu`, `TrySurfOW` port), driver `tests/drivers/gold_water_moves.lua` (its `WHIRL` fixture is literally `ROUTE_41` (22,11)) | implemented | +| Strength field move + `ENGINE_PLAINBADGE` gate + `STRENGTH_BOULDER` pushing | `src/world/gen2/FieldMoves.lua` (`strengthFromMenu`, the 3-way `TryStrengthOW` result at :689), driver `tests/drivers/gold_icepath_boulder.lua` | implemented | +| Fly field move + `ENGINE_STORMBADGE` gate, fly points | `src/world/gen2/FieldMoves.lua:330` (`flypoints.asm` transcribed), `:504` `flyFromMenu` | implemented | +| Whirlpool + `ENGINE_GLACIERBADGE` gate (Route 41 flavour, Whirl Islands) | `src/world/gen2/FieldMoves.lua:542`, `:625` | implemented | +| Badge award: `setflag ENGINE_STORMBADGE`, `readvar VAR_BADGES`, `scall` into `GoldenrodRocketsScript`/`RadioTowerRocketsScript` | `src/script/gen2/Opcodes.lua` (`setflag`, `checkflag`), `src/script/gen2/Vm.lua` | implemented | +| Chuck's boulder throw: `earthquake 80`, `set_sliding`/`big_step`/`fast_jump_step` | `earthquake` in `src/script/gen2/Opcodes.lua`, movement verbs in `src/script/gen2/Movement.lua` | implemented | +| Water wild encounters for Route 40 / 41 / Olivine / Cianwood, Gold-vs-Silver Mantine split | `src/battle/gen2/Encounter.lua`, tables from `RomExtractorGen2.lua` | implemented | +| Good Rod fishing (`FISHGROUP_SHORE` / `FISHGROUP_OCEAN`, `TimeFishGroups`) | `src/battle/gen2/Encounter.lua` (`Encounter.fish`), extractor `readRod` | implemented | +| Headbutt trees on Route 38/39 (`TREEMON_SET_FOREST`) | `src/battle/gen2/Encounter.lua:114` onward, `RomExtractorGen2.lua:3780` | implemented | +| **Rock Smash wild encounters (`RockMonMaps` -> `TREEMON_SET_ROCK`, i.e. Shuckle/Krabby on Route 40 and Cianwood City)** | `src/script/gen2/CallAsm.lua:526` explicitly stubs `RockMonEncounter` with "ROCK SMASH has no field-move path yet; RockMonMaps is unported"; `RomExtractorGen2.lua` extracts `TreeMonMaps` only | **missing** | +| Roaming / phone-call flavour beats the walkthrough mentions (Arnie, Liz, Todd, Mom's doll) | `src/core/gen2/Phone.lua` (incoming/special call machinery), `src/core/gen2/MomShopping.lua` | implemented (generic); no per-call verification done for this section | +| Any driver that actually walks Route 38 -> Cianwood end to end | - | **missing** - no `tests/drivers/gold_*` covers this stretch. `gold_water_moves.lua` touches `ROUTE_41` (22,11) only as a whirlpool fixture | + +## 6. Unresolved / verify by hand + +1. **"Beauty Olivia" on Route 38.** The walkthrough says "Now move southward + now to face Beauty Olivia." There is no `OLIVIA` in + `constants/trainer_constants.asm` (the `BEAUTY` class runs VICTORIA, + SAMANTHA, JULIE, JACLYN, BRENDA, CASSIE, CAROLINE, CARLENE, JESSICA, + RACHAEL, ANGELICA, KENDRA, VERONICA, JULIA, THERESA, VALERIE), no `OLIVIA` + string in `data/trainers/parties.asm`, and `maps/Route38.asm` has exactly + five trainer objects. This trainer does not exist in pokegold. +2. **"#100 Voltorb" listed under "Pokémon found in Olivine City".** Olivine + City has no grass encounter table at all, and its water table + (`data/wild/johto_water.asm:239`) is TENTACOOL / TENTACOOL / TENTACRUEL. + Voltorb is not reachable on this map by any table I could find. Unverified. +3. **"#213 Shuckle" listed under Route 40 *and* Route 41.** Route 40 is correct + but by Rock Smash, not by surfing: `data/wild/treemon_maps.asm:45` puts it + in `RockMonMaps` with `TREEMON_SET_ROCK` (10% SHUCKLE 15). Route 41 is + **not** in `RockMonMaps`, has `TREEMON_SET_NONE` for headbutt, and its + `Route41Rock` script is marked `; unreferenced` in the asm. Shuckle appears + unobtainable on Route 41. +4. **Swimmer Charlie's second Pokemon.** Walkthrough: "Level 21 Tentacool". + `data/trainers/parties.asm` `SwimmerMGroup` "CHARLIE" (`SWIMMERM` 4) reads + `db 21, SHELLDER / db 19, TENTACOOL / db 19, TENTACRUEL`. Level 19, not 21. +5. **Bird Keeper Denis's party order.** Walkthrough lists Spearow, Spearow, + Fearow; the asm order is `18 SPEAROW / 20 FEAROW / 18 SPEAROW`. +6. **Swimmer Kaylee** (`SWIMMERF` 3, 18 GOLDEEN / 20 GOLDEEN / 20 SEAKING) at + `ROUTE_41` (17,4) is a real trainer the walkthrough never mentions. +7. **Whether the x = 16/17 lighthouse pairs are one-way holes or two-way + passages.** The warp tables are symmetric (every pair points back at the + coordinates it came from), so warp data alone cannot tell you. Whichever it + is lives in the block collision for `TILESET_LIGHTHOUSE` and the `.blk` + files, which I did not decode. The walkthrough treats them as one-way drops + and the geometry is consistent with that, but treat "can I climb back up + through (16,7)?" as unverified. +8. **"You can avoid fighting the next trainer" (Bird Keeper Theo, 3F).** Theo + has sight range 3 at (3,9); whether a walkable lane exists outside that + cone depends on the 3F `.blk`, not the object table. +9. **The exact spot the walkthrough means by "Now, swim south onto Route 41" + and the "left side / right side" split.** Route 41's two branches are + geometry in `maps/Route41.blk`; the object coordinates above are the only + hard data. A bot should path by trainer coordinates rather than by the + prose. +10. **Phone-call timing claims** ("Arnie calls again", "Liz then calls you + about her Nidoran", "Todd will now call", "Mom calls saying she bought you + an adorable doll"). These come from `CheckPhoneCall` in + `engine/overworld/time.asm` plus `data/phone/*.asm`, not from any script in + these maps, and the walkthrough's own quoted note says the caller and topic + are random. Nothing in this section's asm schedules them. +11. **"3000G" from Chuck.** Prize money is computed from the trainer class base + in `data/trainers/attributes.asm` times the last mon's level, not written + anywhere in `maps/CianwoodGym.asm`. I did not verify the arithmetic. +12. **"TM23" under "Items found in Olivine City".** TM23 is Jasmine's gym + reward in `maps/OlivineGym.asm`, which belongs to section 09; nothing on + `MAP_OLIVINE_CITY` itself yields it. diff --git a/docs/gold-walkthrough/asm-walk/section-09-olivine-city-gym.md b/docs/gold-walkthrough/asm-walk/section-09-olivine-city-gym.md new file mode 100644 index 00000000..aeda633e --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-09-olivine-city-gym.md @@ -0,0 +1,681 @@ +# Section 09 - Olivine City Gym + +Source: `../section-09-olivine-city-gym.txt` +Maps covered: `MAP_GOLDENROD_CITY` (fly stop, optional), `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` (transit), `MAP_GOLDENROD_UNDERGROUND`, `MAP_OLIVINE_CITY`, `MAP_OLIVINE_LIGHTHOUSE_1F` .. `MAP_OLIVINE_LIGHTHOUSE_6F`, `MAP_OLIVINE_GYM` +Badges / key milestones in this section: **MINERALBADGE** (`ENGINE_MINERALBADGE`), `EVENT_BEAT_JASMINE`, `TM_IRON_TAIL` (TM23), `EVENT_JASMINE_RETURNED_TO_GYM` (set on Lighthouse 6F, the gate for the gym even existing), the 6th-badge Rocket trigger (`GoldenrodRocketsScript`). + +This section assumes the player already has the SECRETPOTION from Cianwood (section 8) and +`ENGINE_STORMBADGE` (Chuck), which is what makes `FLY` usable at all. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `GOLDENROD_CITY` (optional) | `maps/GoldenrodCity.asm` | Fly (`SPAWN_GOLDENROD`, `ENGINE_FLYPOINT_GOLDENROD`) | warp 15 at (11, 29) -> `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` warp 5 | Only if raising an Eevee: buy the 500 haircut for happiness | +| 2 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` | warp 5 at (4, 29) / warp 6 at (5, 29) | warp 4 at (5, 25) -> `GOLDENROD_UNDERGROUND` warp 2 | Pure transit corridor to the salon | +| 3 | `GOLDENROD_UNDERGROUND` | `maps/GoldenrodUnderground.asm` | warp 2 at (3, 34) | back out the way in, then Fly | Talk to the *older* Haircut Brother at (7, 14), pay 500, happiness bump | +| 4 | `OLIVINE_CITY` | `maps/OlivineCity.asm` | Fly (`SPAWN_OLIVINE`, `ENGINE_FLYPOINT_OLIVINE`) | warp 9 at (29, 27) -> `OLIVINE_LIGHTHOUSE_1F` warp 1 | Head for the lighthouse | +| 5 | `OLIVINE_LIGHTHOUSE_1F` -> `2F` -> `3F` -> `4F` -> `3F` -> `4F` -> `5F` -> `6F` | `maps/OlivineLighthouse[1-6]F.asm` | ladders + one floor pit ("fall to the left of that one lass") | same ladders/pits back down | Hand Jasmine the SECRETPOTION | +| 6 | `OLIVINE_LIGHTHOUSE_6F` | `maps/OlivineLighthouse6F.asm` | warp 1 at (9, 15) from 5F warp 1 | warp 1 at (9, 15) back to 5F | `OlivineLighthouseJasmine` -> `EVENT_JASMINE_RETURNED_TO_GYM`, `clearevent EVENT_OLIVINE_GYM_JASMINE` | +| 7 | `OLIVINE_CITY` | `maps/OlivineCity.asm` | `OLIVINE_LIGHTHOUSE_1F` warps 1/2 at (10, 17)/(11, 17) | warp 2 at (10, 11) -> `OLIVINE_GYM` warp 1 | Walk to the gym door | +| 8 | `OLIVINE_GYM` | `maps/OlivineGym.asm` | warps 1/2 at (4, 15)/(5, 15) | same warps back to `OLIVINE_CITY` warp 2 | Beat Jasmine, take MINERALBADGE + TM23 | + +Spill into the next section: the walkthrough offers "fly to Ecruteak and go through the Lake of +Rage" as an alternative to fighting Jasmine now. `MAP_LAKE_OF_RAGE` and the Route 42/43 chain +belong to the next section; nothing in this section's asm depends on them. + +--- + +## 2. Maps + +### MAP_GOLDENROD_UNDERGROUND + +- Script: `maps/GoldenrodUnderground.asm` (`46:4900 GoldenrodUnderground_MapEvents`, `46:4129 OlderHaircutBrotherScript`) +- Blocks: `maps/GoldenrodUnderground.blk` +- Header: `data/maps/maps.asm:123` -> `map GoldenrodUnderground, TILESET_GATE, DUNGEON, LANDMARK_GOLDENROD_CITY, MUSIC_UNION_CAVE, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:110` -> `map_const GOLDENROD_UNDERGROUND, 15, 18` (map group 1, the `newgroup OLIVINE` group, map id 45 - it is *not* in the Goldenrod group) +- Attributes: `data/maps/attributes.asm:442` -> `map_attributes GoldenrodUnderground, GOLDENROD_UNDERGROUND, $00` (no connections) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 2 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 7 | +| 2 | 3 | 34 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 4 | +| 3 | 18 | 6 | `GOLDENROD_UNDERGROUND` | 4 | +| 4 | 21 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 5 | 22 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 6 | 22 | 27 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 1 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 18 | 6 | `BGEVENT_READ` | `BasementDoorScript` | +| 19 | 6 | `BGEVENT_READ` | `GoldenrodUndergroundNoEntrySign` | +| 6 | 13 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenParlyzHeal` (`hiddenitem PARLYZ_HEAL, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL`) | +| 4 | 18 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenSuperPotion` (`hiddenitem SUPER_POTION, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION`) | +| 17 | 8 | `BGEVENT_ITEM` | `GoldenrodUndergroundHiddenAntidote` (`hiddenitem ANTIDOTE, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODUNDERGROUND_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 31 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSupernerdEric` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 6 | 9 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerSupernerdTeru` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD3` | `SPRITE_SUPER_NERD` | 3 | 27 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerPokemaniacIssac` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD4` | `SPRITE_SUPER_NERD` | 2 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerPokemaniacDonald` | -1 | +| `GOLDENRODUNDERGROUND_POKE_BALL` | `SPRITE_POKE_BALL` | 7 | 25 | `STILL` | `OBJECTTYPE_ITEMBALL` | `GoldenrodUndergroundCoinCase` (`itemball COIN_CASE`) | `EVENT_GOLDENROD_UNDERGROUND_COIN_CASE` | +| `GOLDENRODUNDERGROUND_GRAMPS` | `SPRITE_GRAMPS` | 7 | 11 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `BargainMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRAMPS` | +| `GOLDENRODUNDERGROUND_OLDER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 14 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `OlderHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 15 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `YoungerHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_GRANNY` | `SPRITE_GRANNY` | 7 | 21 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `BitterMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRANNY` | + +Object-event visibility convention (verified in `engine/overworld/scripting.asm:879-898`): `appear` +*clears* the flag and `disappear` *sets* it, so **an object is on the map only while its event flag +is clear.** + +**Scripts of interest** + +- `GoldenrodUndergroundCheckDayOfWeekCallback` (`callback MAPCALLBACK_OBJECTS`): `readvar VAR_WEEKDAY` + and appear/disappear the four merchants. Who is standing there: + - Sunday: younger brother + granny + - Monday: gramps only, and only during `MORN` (`checktime MORN`) + - Tuesday / Thursday: **older brother** + - Wednesday / Friday: younger brother + - Saturday: older brother + granny + + The walkthrough's "500 haircut" therefore only exists on **Tuesday, Thursday or Saturday**. +- `OlderHaircutBrotherScript` (`46:4129`): `readvar VAR_WEEKDAY`, bails to + `GoldenrodUndergroundScript_ShopClosed` unless TUE/THU/SAT. Then + `checkflag ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT` (one cut per day, cleared by the daily + reset), `yesorno`, `checkmoney YOUR_MONEY, 500`, `special OlderHaircutBrother`, then + `takemoney YOUR_MONEY, 500`. `DEF GOLDENRODUNDERGROUND_OLDER_HAIRCUT_PRICE EQU 500` is at the top + of the file. The special returns 0/1 (cancel or Egg) or 2/3/4, which the script mirrors into + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1/2/3` just to pick which of three "looks happier" lines to + print. +- `special OlderHaircutBrother` -> `engine/events/haircut.asm:15` -> `HaircutOrGrooming`, weighted + roll over `HappinessData_OlderHaircutBrother` (`data/events/happiness_probabilities.asm:1`): + 30% -> `HAPPINESS_OLDERCUT1`, 50%+1 -> `HAPPINESS_OLDERCUT2`, remainder -> `HAPPINESS_OLDERCUT3`. + The actual deltas are `data/events/happiness_changes.asm:13-15`: + `+1/+1/+1`, `+3/+3/+1`, `+5/+5/+2` (columns are happiness <100 / <200 / otherwise). + The younger brother (300, SUN/WED/FRI) rolls 60%+1 / 30% / rest for `+1/+1/+1`, `+3/+3/+1`, + `+10/+10/+4` - **its jackpot row is strictly better than the older brother's**, which the + walkthrough does not mention. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT` (id 89) | `constants/engine_flags.asm` | checked+set by both `*HaircutBrotherScript` | one haircut per day, either brother | +| `EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER` (0x754) | `constants/event_flags.asm:1270` | day-of-week callback | clear = older brother is standing at (7, 14) | +| `EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER` (0x755) | `constants/event_flags.asm` | day-of-week callback | clear = younger brother at (7, 15) | +| `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1/2/3` (0x000-0x002) | `constants/event_flags.asm:5-7` | haircut scripts | scratch, do not persist | + +**Items** - nothing the walkthrough takes here; the Coin Case itemball and the three hidden items +belong to earlier sections. + +**Trainers** - the four in the table above are optional and already beaten by this point in a +linear run. + +**Wild encounters** - none (indoor `DUNGEON`, no entry in `data/wild/johto_grass.asm`). + +--- + +### MAP_OLIVINE_CITY + +- Script: `maps/OlivineCity.asm` (`49:4463 OlivineCity_MapEvents`) +- Blocks: `maps/OlivineCity.blk` +- Header: `data/maps/maps.asm:63` -> `map OlivineCity, TILESET_JOHTO, TOWN, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:52` -> `map_const OLIVINE_CITY, 20, 18` (group 1, id 14) +- Connections: `data/maps/attributes.asm:143-145` -> north `Route39` (`ROUTE_39`, offset 5), west `Route40` (`ROUTE_40`, offset 9). No south/east. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 21 | `OLIVINE_POKECENTER_1F` | 1 | +| 2 | 10 | 11 | `OLIVINE_GYM` | 1 | +| 3 | 25 | 11 | `OLIVINE_TIMS_HOUSE` | 1 | +| 4 | 0 | 0 | `OLIVINE_HOUSE_BETA` | 1 (marked `; inaccessible` in the asm) | +| 5 | 29 | 11 | `OLIVINE_PUNISHMENT_SPEECH_HOUSE` | 1 | +| 6 | 13 | 15 | `OLIVINE_GOOD_ROD_HOUSE` | 1 | +| 7 | 7 | 21 | `OLIVINE_CAFE` | 1 | +| 8 | 19 | 17 | `OLIVINE_MART` | 2 | +| 9 | 29 | 27 | `OLIVINE_LIGHTHOUSE_1F` | 1 | +| 10 | 19 | 27 | `OLIVINE_PORT_PASSAGE` | 1 | +| 11 | 20 | 27 | `OLIVINE_PORT_PASSAGE` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` (= 0) | 13 | 12 | `OlivineCityRivalSceneTop` | rival cutscene, ends with `setscene SCENE_OLIVINECITY_NOOP` | +| `SCENE_OLIVINECITY_RIVAL_ENCOUNTER` (= 0) | 13 | 13 | `OlivineCityRivalSceneBottom` | same, mirrored movement | + +Scene ids come from the `scene_script` macro's own `const_def` (`macros/scripts/maps.asm:12-36`): +`SCENE_OLIVINECITY_RIVAL_ENCOUNTER` = 0, `SCENE_OLIVINECITY_NOOP` = 1. By this section the scene is +already 1 (the rival scene fires on the *first* Olivine visit, section 8), so both trip-wires are +inert and a bot may walk x=13 freely. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 11 | `BGEVENT_READ` | `OlivineCitySign` | +| 20 | 24 | `BGEVENT_READ` | `OlivineCityPortSign` | +| 7 | 11 | `BGEVENT_READ` | `OlivineGymSign` | +| 30 | 28 | `BGEVENT_READ` | `OlivineLighthouseSign` | +| 14 | 21 | `BGEVENT_READ` | `OlivineCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 20 | 17 | `BGEVENT_READ` | `OlivineCityMartSign` (`jumpstd MartSignScript`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINECITY_SAILOR1` | `SPRITE_SAILOR` | 26 | 27 | `WALK_UP_DOWN` (0,1) | `OBJECTTYPE_SCRIPT` | `OlivineCitySailor1Script` | -1 | +| `OLIVINECITY_STANDING_YOUNGSTER` | `SPRITE_YOUNGSTER` | 20 | 13 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `OlivineCityStandingYoungsterScript` | -1 | +| `OLIVINECITY_SAILOR2` | `SPRITE_SAILOR` | 17 | 21 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `OlivineCitySailor2Script` | -1 | +| `OLIVINECITY_OLIVINE_RIVAL` | `SPRITE_OLIVINE_RIVAL` | 10 | 11 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_OLIVINE_CITY` | + +Note the rival object sits **on the gym door tile** (10, 11). `InitializeEventsScript` +(`engine/events/std_scripts.asm:520`) sets `EVENT_RIVAL_OLIVINE_CITY` at new game, so he is hidden +except during the cutscene, and the door is walkable. + +**Scripts of interest** + +- `OlivineCityFlypointCallback` (`callback MAPCALLBACK_NEWMAP`): `setflag ENGINE_FLYPOINT_OLIVINE` + (engine flag id 70). This is what makes "fly straight back to Olivine City" legal; it was already + set in section 8. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_OLIVINE` (id 70) | `constants/engine_flags.asm:85` | set by `OlivineCityFlypointCallback` | Olivine selectable on the Fly map | +| `ENGINE_FLYPOINT_GOLDENROD` (id 69) | `constants/engine_flags.asm:84` | set by `maps/GoldenrodCity.asm:24` | Goldenrod selectable on the Fly map | +| `EVENT_RIVAL_OLIVINE_CITY` | `constants/event_flags.asm` | set by `InitializeEventsScript`, `appear`/`disappear` in the scene scripts | clear = rival standing on the gym door | + +**Items** - none taken in this section. + +**Trainers** - none on the overworld map. + +**Wild encounters** - `data/wild/johto_water.asm:239`: + +``` +def_water_wildmons OLIVINE_CITY +db 6 percent ; encounter rate +db 20, TENTACOOL +db 15, TENTACOOL +db 20, TENTACRUEL +``` + +No grass table (`data/wild/johto_grass.asm` has no `OLIVINE` entry). Fish group is +`FISHGROUP_SHORE` per the header row. + +--- + +### MAP_OLIVINE_LIGHTHOUSE_1F .. 6F + +- Scripts: `maps/OlivineLighthouse1F.asm` ... `maps/OlivineLighthouse6F.asm` +- Blocks: `maps/OlivineLighthouse[1-6]F.blk` +- Header: `data/maps/maps.asm:112-117`. 1F-5F are + `TILESET_LIGHTHOUSE, DUNGEON, LANDMARK_LIGHTHOUSE, MUSIC_LIGHTHOUSE, FALSE, PALETTE_DAY, FISHGROUP_SHORE`; + **6F swaps the music to `MUSIC_VIOLET_CITY`** (line 117), which is a cheap way for a bot to + confirm it reached the top. +- Dimensions: `constants/map_constants.asm:99-104` -> every floor is `10, 9` blocks = 20 x 18 tiles + (group 1, ids 34-39). +- Attributes: `data/maps/attributes.asm:436` (6F) and neighbours - `$00`, no connections. + +**Warps** - transcribed verbatim, all six floors. + +`OLIVINE_LIGHTHOUSE_1F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 17 | `OLIVINE_CITY` | 9 | +| 2 | 11 | 17 | `OLIVINE_CITY` | 9 | +| 3 | 3 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 1 | +| 4 | 16 | 13 | `OLIVINE_LIGHTHOUSE_2F` | 3 | +| 5 | 17 | 13 | `OLIVINE_LIGHTHOUSE_2F` | 4 | + +`OLIVINE_LIGHTHOUSE_2F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 11 | `OLIVINE_LIGHTHOUSE_1F` | 3 | +| 2 | 5 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 2 | +| 3 | 16 | 13 | `OLIVINE_LIGHTHOUSE_1F` | 4 | +| 4 | 17 | 13 | `OLIVINE_LIGHTHOUSE_1F` | 5 | +| 5 | 16 | 11 | `OLIVINE_LIGHTHOUSE_3F` | 4 | +| 6 | 17 | 11 | `OLIVINE_LIGHTHOUSE_3F` | 5 | + +`OLIVINE_LIGHTHOUSE_3F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 1 | +| 2 | 5 | 3 | `OLIVINE_LIGHTHOUSE_2F` | 2 | +| 3 | 9 | 5 | `OLIVINE_LIGHTHOUSE_4F` | 4 | +| 4 | 16 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 5 | +| 5 | 17 | 11 | `OLIVINE_LIGHTHOUSE_2F` | 6 | +| 6 | 16 | 9 | `OLIVINE_LIGHTHOUSE_4F` | 5 | +| 7 | 17 | 9 | `OLIVINE_LIGHTHOUSE_4F` | 6 | +| 8 | 8 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 7 | +| 9 | 9 | 3 | `OLIVINE_LIGHTHOUSE_4F` | 8 | + +`OLIVINE_LIGHTHOUSE_4F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 1 | +| 2 | 3 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 2 | +| 3 | 9 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 3 | +| 4 | 9 | 5 | `OLIVINE_LIGHTHOUSE_3F` | 3 | +| 5 | 16 | 9 | `OLIVINE_LIGHTHOUSE_3F` | 6 | +| 6 | 17 | 9 | `OLIVINE_LIGHTHOUSE_3F` | 7 | +| 7 | 8 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 8 | +| 8 | 9 | 3 | `OLIVINE_LIGHTHOUSE_3F` | 9 | +| 9 | 16 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 4 | +| 10 | 17 | 7 | `OLIVINE_LIGHTHOUSE_5F` | 5 | + +`OLIVINE_LIGHTHOUSE_5F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `OLIVINE_LIGHTHOUSE_6F` | 1 | +| 2 | 3 | 5 | `OLIVINE_LIGHTHOUSE_4F` | 2 | +| 3 | 9 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 3 | +| 4 | 16 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 9 | +| 5 | 17 | 7 | `OLIVINE_LIGHTHOUSE_4F` | 10 | +| 6 | 16 | 5 | `OLIVINE_LIGHTHOUSE_6F` | 2 | +| 7 | 17 | 5 | `OLIVINE_LIGHTHOUSE_6F` | 3 | + +`OLIVINE_LIGHTHOUSE_6F`: + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `OLIVINE_LIGHTHOUSE_5F` | 1 | +| 2 | 16 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 6 | +| 3 | 17 | 5 | `OLIVINE_LIGHTHOUSE_5F` | 7 | + +**Which warps are ladders and which are pits.** Derived by decoding each floor's `.blk` against +`data/tilesets/lighthouse_collision.asm`. Block `$3a` and `$31` carry `LADDER` in their bottom-right +tile; block `$28` is `tilecoll FLOOR, FLOOR, PIT, PIT`, i.e. its two bottom tiles drop you a floor. + +| floor | tile | block | kind | goes to | +|---|---|---|---|---| +| 1F | (10, 17), (11, 17) | `$2e` bottom tiles `WARP_CARPET_DOWN` | exit door | `OLIVINE_CITY` | +| 1F | (3, 11) | `$3a` | ladder UP | 2F (3, 11) | +| 2F | (3, 11) | `$31` | ladder DOWN | 1F (3, 11) | +| 2F | (5, 3) | `$3a` | ladder UP | 3F (5, 3) | +| 2F | (16, 13), (17, 13) | `$28` | **PIT** | 1F (16, 13) / (17, 13) | +| 3F | (5, 3) | `$31` | ladder DOWN | 2F (5, 3) | +| 3F | (13, 3) | `$3a` | ladder UP | 4F (13, 3) | +| 3F | (9, 5) | `$3a` | ladder UP | 4F (9, 5) | +| 3F | (16, 11), (17, 11) | `$28` | **PIT** | 2F (16, 11) / (17, 11) | +| 4F | (13, 3) | `$31` | ladder DOWN | 3F (13, 3) | +| 4F | (9, 5) | `$31` | ladder DOWN | 3F (9, 5) | +| 4F | (3, 5) | `$3a` | ladder UP | 5F (3, 5) | +| 4F | (9, 7) | `$3a` | ladder UP | 5F (9, 7) | +| 4F | (8, 3), (9, 3) | `$28` | **PIT** | 3F (8, 3) / (9, 3) | +| 4F | (16, 9), (17, 9) | `$28` | **PIT** | 3F (16, 9) / (17, 9) | +| 5F | (3, 5) | `$31` | ladder DOWN | 4F (3, 5) | +| 5F | (9, 7) | `$31` | ladder DOWN | 4F (9, 7) | +| 5F | (9, 15) | `$3a` | ladder UP | 6F (9, 15) | +| 5F | (16, 7), (17, 7) | `$28` | **PIT** | 4F (16, 7) / (17, 7) | +| 6F | (9, 15) | `$31` | ladder DOWN | 5F (9, 15) | +| 6F | (16, 5), (17, 5) | `$28` | **PIT** | 5F (16, 5) / (17, 5) | + +The pit destination coordinates always match the pit's own coordinates on the floor below - the +warp row on the lower floor exists purely as a landing slot and its own destination is never +triggered (the landing tile is plain `FLOOR`). + +**Coord events** - none on any floor. + +**BG events** - only 5F: `bg_event 3, 13, BGEVENT_ITEM, OlivineLighthouse5FHiddenHyperPotion`. + +**Object events** (trainers a bot will be forced into on the climb) + +| floor | const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| 1F | - | `SPRITE_SAILOR` | 8 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouse1FSailorScript` | -1 | +| 1F | - | `SPRITE_POKEFAN_F` | 16 | 9 | `WALK_UP_DOWN` (0,2) | `OBJECTTYPE_SCRIPT` | `OlivineLighthouse1FPokefanFScript` | -1 | +| 2F | - | `SPRITE_SAILOR` | 9 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSailorHuey` | -1 | +| 2F | - | `SPRITE_GENTLEMAN` | 17 | 8 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerGentlemanAlfred` | -1 | +| 3F | - | `SPRITE_SAILOR` | 9 | 2 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerSailorTerrell` | -1 | +| 3F | - | `SPRITE_GENTLEMAN` | 13 | 5 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerGentlemanPreston` | -1 | +| 3F | - | `SPRITE_YOUNGSTER` | 3 | 9 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperTheo` | -1 | +| 3F | - | `SPRITE_POKE_BALL` | 8 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse3FEther` | `EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER` | +| 4F | - | `SPRITE_SAILOR` | 7 | 14 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSailorKent` | -1 | +| 4F | - | `SPRITE_LASS` | 11 | 2 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerLassConnie` | -1 | +| 5F | - | `SPRITE_SAILOR` | 8 | 11 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSailorErnest` | -1 | +| 5F | - | `SPRITE_YOUNGSTER` | 8 | 3 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerBirdKeeperDenis` | -1 | +| 5F | - | `SPRITE_POKE_BALL` | 15 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse5FRareCandy` | `EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY` | +| 5F | - | `SPRITE_POKE_BALL` | 6 | 15 | `STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse5FGreatBall` | `EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL` | +| 5F | - | `SPRITE_POKE_BALL` | 2 | 13 | `STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse5FTMSwagger` | `EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER` | + +`TrainerLassConnie` at 4F (11, 2) is "that one lass". The pit at 4F (8, 3) / (9, 3) is the tile +pair immediately left-and-below her, which matches the walkthrough's "falling to the left of that +one lass". + +**6F object events** (`def_object_events`, `maps/OlivineLighthouse6F.asm:270-273`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINELIGHTHOUSE6F_JASMINE` | `SPRITE_JASMINE` | 8 | 8 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouseJasmine` | `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` (0x6d2) | +| `OLIVINELIGHTHOUSE6F_MONSTER` | `SPRITE_MONSTER` | 9 | 8 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivineLighthouseAmphy` | -1 | +| `OLIVINELIGHTHOUSE6F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 4 | `STILL` | `OBJECTTYPE_ITEMBALL` | `OlivineLighthouse6FSuperPotion` (`itemball SUPER_POTION`) | `EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION` (0x668) | + +**Scripts of interest** + +- `OlivineLighthouseJasmine` (`44:6ccd`, `maps/OlivineLighthouse6F.asm:11`): + 1. `faceplayer` / `opentext` + 2. `checkitem SECRETPOTION` -> `.BroughtSecretpotion` (`44:6ce7`). Without the potion it falls + into the `EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` (0x37) branch and just repeats the request. + 3. `.BroughtSecretpotion`: `writetext JasmineCureAmphyText`, `yesorno`. **`iffalse .Refused`** - + answering No leaves everything unset and the gym stays empty, so a bot must answer Yes. + 4. `takeitem SECRETPOTION`, a long cutscene (`turnobject`, `playmusic MUSIC_HEAL`, + `special RestartMapMusic`, `cry AMPHAROS`, `special FadeOutToWhite` / `FadeInFromWhite`). + 5. **`setevent EVENT_JASMINE_RETURNED_TO_GYM`** (0x20) and + **`clearevent EVENT_OLIVINE_GYM_JASMINE`** (0x6d3) - this pair is the entire gate on the gym. + 6. `readvar VAR_FACING` picks one of three exit movements + (`OlivineLighthouseJasmineLeaves{Up,Down,Right}Movement`) then `disappear OLIVINELIGHTHOUSE6F_JASMINE`. +- `OlivineLighthouseAmphy`: cosmetic; branches on `EVENT_JASMINE_RETURNED_TO_GYM` for the healthy + cry. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_JASMINE_RETURNED_TO_GYM` (0x20) | `constants/event_flags.asm:41` | set by `OlivineLighthouseJasmine`; read by `OlivineGymGuideScript` and `maps/EcruteakCity.asm:47` | the section's real progress bit | +| `EVENT_OLIVINE_GYM_JASMINE` (0x6d3) | `constants/event_flags.asm:1141` | set at new game by `InitializeEventsScript` (`engine/events/std_scripts.asm:511`), cleared by `OlivineLighthouseJasmine` | clear = Jasmine is standing in her gym | +| `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` (0x6d2) | `constants/event_flags.asm:1140` | `disappear` at the end of the cure | set = she has left the lighthouse | +| `EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS` (0x37) | `constants/event_flags.asm:64` | `OlivineLighthouseJasmine` | first-visit text latch only | + +**Items** - `SECRETPOTION` is consumed (`takeitem SECRETPOTION`). Nothing is given. + +**Wild encounters** - none on any lighthouse floor (indoor `DUNGEON`, no table). + +--- + +### MAP_OLIVINE_GYM + +- Script: `maps/OlivineGym.asm` (`51:410e OlivineGym_MapScripts`, `51:4507 OlivineGym_MapEvents`) +- Blocks: `maps/OlivineGym.blk` (`2b:7c7b OlivineGym_Blocks`) +- Header: `data/maps/maps.asm:51` -> `map OlivineGym, TILESET_CHAMPIONS_ROOM, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:40` -> `map_const OLIVINE_GYM, 5, 8` = 10 x 16 tiles (group 1, id 2) +- Attributes: `data/maps/attributes.asm:473` -> `map_attributes OlivineGym, OLIVINE_GYM, $00`, no connections +- `def_scene_scripts` and `def_callbacks` are both **empty** - no scene variable, no map callbacks. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 15 | `OLIVINE_CITY` | 2 | +| 2 | 5 | 15 | `OLIVINE_CITY` | 2 | + +**Coord events** (`def_coord_events`) - **none**. There is no trip-wire in this gym; every +interaction is a `faceplayer` talk. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 13 | `BGEVENT_READ` | `OlivineGymStatue` | +| 6 | 13 | `BGEVENT_READ` | `OlivineGymStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINEGYM_JASMINE` | `SPRITE_JASMINE` | 5 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_RED` | `OlivineGymJasmineScript` | `EVENT_OLIVINE_GYM_JASMINE` | +| `OLIVINEGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 13 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_RED` | `OlivineGymGuideScript` | -1 | + +The walkthrough's "there are no apprentices of any kind" is literally true in the asm: the object +table has exactly two rows and neither is `OBJECTTYPE_TRAINER`. + +**Walkable shape** (decoded from `maps/OlivineGym.blk` against +`data/tilesets/champions_room_collision.asm`, `.` = FLOOR, `#` = WALL, `W` = `WARP_CARPET_DOWN`, +`U` = `UP_WALL`; x across 0..9, y down 0..15): + +``` + y0 # # # # # # # # # # + y1 # # # # # # # # # # + y2 # # # U U U U # # # + y3 # # # . . . . # # # + y4 # # # . . . . # # # + y5 # # # # . . # # # # + y6 # # # # . . # # # # + y7 # # # . . . . # # # + y8 # # . . . . . . # # + y9 # # # # . . # # # # +y10 # # # . . . . # # # +y11 # # # . . . . # # # +y12 # # . # . . # . # # +y13 # # . # . . # . # # +y14 # . . . . . . . . # +y15 # . . . W W . . . # +``` + +Columns x=4 and x=5 are floor from y=14 straight up to y=3, so a bot can enter at (4, 15) or +(5, 15) and hold UP to reach Jasmine at (5, 3) with no turns. The two statues are the wall tiles at +(3, 13) and (6, 13); the gym guide stands at (7, 13), reachable from (7, 14). + +**Scripts of interest** + +- `OlivineGymJasmineScript` (`51:4110`, `maps/OlivineGym.asm:10`). Opcode-by-opcode: + + ``` + faceplayer / opentext + checkevent EVENT_BEAT_JASMINE -> iftrue .FightDone + writetext Jasmine_SteelTypeIntro / waitbutton / closetext + winlosstext Jasmine_BetterTrainer, 0 + loadtrainer JASMINE, JASMINE1 + startbattle + reloadmapafterbattle + setevent EVENT_BEAT_JASMINE + opentext / writetext Text_ReceivedMineralBadge + playsound SFX_GET_BADGE / waitsfx + setflag ENGINE_MINERALBADGE + readvar VAR_BADGES + scall OlivineGymActivateRockets + .FightDone: + checkevent EVENT_GOT_TM23_IRON_TAIL -> iftrue .GotIronTail + writetext Jasmine_BadgeSpeech / promptbutton + verbosegiveitem TM_IRON_TAIL + iffalse .NoRoomForIronTail + setevent EVENT_GOT_TM23_IRON_TAIL + writetext Jasmine_IronTailSpeech / waitbutton / closetext / end + ``` + + Two bot-relevant consequences: (a) `winlosstext ..., 0` means there is **no loss-warp script**, + losing just blacks you out normally; (b) if the bag has no room in the TM pocket, + `verbosegiveitem` returns false and `EVENT_GOT_TM23_IRON_TAIL` is **not** set - re-talking to + Jasmine retries the give from `.FightDone`. Keep TM room free. + +- `OlivineGymActivateRockets` (`51:4159`): the `scall`ed tail, dispatching on the `readvar + VAR_BADGES` value that was just pushed: + + ``` + ifequal 7, .RadioTowerRockets -> jumpstd RadioTowerRocketsScript + ifequal 6, .GoldenrodRockets -> jumpstd GoldenrodRocketsScript + end + ``` + + `VAR_BADGES` is `CountSetBits` over the two `wBadges` bytes + (`engine/overworld/variables.asm:80-86`), so it counts Johto **and** Kanto badges. Since + `setflag ENGINE_MINERALBADGE` runs first, MINERALBADGE is included in the count. On the + walkthrough's route (Zephyr, Hive, Plain, Fog, Storm, Mineral) the count is **6** and + `GoldenrodRocketsScript` fires: `clearevent EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` + (`engine/events/std_scripts.asm:251-253`), which makes the two Rocket grunts appear in Goldenrod + City at (28, 20) and (8, 15) (`maps/GoldenrodCity.asm:376-377`). Every gym carries the same + `scall` (`maps/VioletGym.asm:31`, `maps/MahoganyGym.asm:34`, ...), so whichever gym hands you + badge 6 is the one that arms the Rockets - beating Jasmine *before* Chuck simply moves the + trigger to Chuck. + +- `OlivineGymGuideScript` (`51:4168`): three-way on `EVENT_BEAT_JASMINE`, then + `EVENT_JASMINE_RETURNED_TO_GYM`. Text only, no flags written. +- `OlivineGymStatue` (`51:418a`): `checkflag ENGINE_MINERALBADGE`, `jumpstd GymStatue1Script` + before, `gettrainername STRING_BUFFER_4, JASMINE, JASMINE1` + `jumpstd GymStatue2Script` after. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_OLIVINE_GYM_JASMINE` (0x6d3) | `constants/event_flags.asm:1141` | set by `InitializeEventsScript`; cleared by `OlivineLighthouseJasmine` | **precondition**: while set, the gym is empty and unbeatable | +| `EVENT_BEAT_JASMINE` (0x4c1) | `constants/event_flags.asm:710` | set by `OlivineGymJasmineScript` | postcondition of the battle | +| `ENGINE_MINERALBADGE` (engine flag id 30) | `constants/engine_flags.asm:42`, `data/events/engine_flags.asm:50` (`engine_flag wJohtoBadges, MINERALBADGE`) | `setflag` here, `checkflag` in `OlivineGymStatue` | the badge bit; `MINERALBADGE` bit index in `wJohtoBadges` is `constants/ram_constants.asm:256` | +| `EVENT_GOT_TM23_IRON_TAIL` (0x0d) | `constants/event_flags.asm:19` | `OlivineGymJasmineScript` | one-time TM guard | +| `EVENT_JASMINE_RETURNED_TO_GYM` (0x20) | `constants/event_flags.asm:41` | read by `OlivineGymGuideScript` | flavour only inside the gym | +| `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` (0x6cd) | `constants/event_flags.asm` | cleared by `GoldenrodRocketsScript` | side effect of reaching 6 badges | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_IRON_TAIL` (TM23, item id `$d6`, `constants/item_constants.asm:243`) | `verbosegiveitem` after the battle | `OlivineGymJasmineScript` | `EVENT_GOT_TM23_IRON_TAIL` | +| 3500 prize money | battle reward | see below | - | + +Prize money check: `TrainerClassAttributes` for Jasmine (`data/trainers/attributes.asm`, the sixth +gym-leader block) is `db HYPER_POTION, NO_ITEM ; items` and `db 25 ; base reward`. +`ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300-317`) sets +`wBattleReward = 25 * wCurPartyLevel` = `25 * 35` = **875**, and the payout loop in +`engine/battle/core.asm:2340-2360` adds `wBattleReward` **four times** (`ld c, 4`). 875 x 4 = +**3500**, which matches the walkthrough exactly. (Note Jasmine's AI holds a `HYPER_POTION`.) + +Badge effect check: `BadgeStatBoosts` (`engine/battle/core.asm:6533-6588`) - the comment block +states "MineralBadge: Defense", and the code deliberately swaps the PlainBadge and MineralBadge +bits before the every-other-badge walk, boosting the stat at `wBattleMonDefense` by 1/8 +(`BoostStat`). Separately `data/types/badge_type_boosts.asm:7` maps MINERALBADGE to `STEEL` for the +type-matching trainer-card/boost table. So the walkthrough's "raises your Pokemon's defense +slightly" is correct. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `JASMINE` / `JASMINE1` | `JASMINE` (trainer class 6, `constants/trainer_constants.asm:42-43`) | 1 | `JasmineGroup` (`0e:5a42`, `data/trainers/parties.asm:53`) | `OlivineGymJasmineScript` | none (gym leaders have no phone row) | + +`JasmineGroup`, verbatim (`TRAINERTYPE_MOVES`): + +``` +db "JASMINE@", TRAINERTYPE_MOVES +db 30, MAGNEMITE, THUNDERBOLT, SUPERSONIC, SONICBOOM, THUNDER_WAVE +db 30, MAGNEMITE, THUNDERBOLT, SUPERSONIC, SONICBOOM, THUNDER_WAVE +db 35, STEELIX, SCREECH, SUNNY_DAY, ROCK_THROW, IRON_TAIL +db -1 ; end +``` + +EXP check against the walkthrough's numbers: trainer EXP is `base * level / 7 * 3 / 2`. +Magnemite base exp 89 (`data/pokemon/base_stats/magnemite.asm:8`) -> `89*30/7 = 381`, `*3/2 = 571`. +Steelix base exp 196 (`data/pokemon/base_stats/steelix.asm:8`) -> `196*35/7 = 980`, `*3/2 = 1470`. +Both match. + +**Wild encounters** - none (`INDOOR`). + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Jasmine is not in her gym | `maps/OlivineGym.asm:212` object row, event flag `EVENT_OLIVINE_GYM_JASMINE`; visibility semantics in `engine/overworld/scripting.asm:879-898` | flag must be **clear** | `clearevent EVENT_OLIVINE_GYM_JASMINE` at `maps/OlivineLighthouse6F.asm:69` | +| Jasmine will not leave the lighthouse | `maps/OlivineLighthouse6F.asm:14` `checkitem SECRETPOTION` | `SECRETPOTION` in the bag (from the Cianwood Pharmacy, section 8) | hand it over and answer **Yes** to `JasmineCureAmphyText` (`yesorno` at line 29; `iffalse .Refused` aborts the whole scene) | +| Cannot Fly to Olivine / Goldenrod | `engine/events/overworld.asm:544-547` (`FlyFunction.TryFly` -> `ld de, ENGINE_STORMBADGE` / `call CheckBadge`), mirrored in the port at `src/world/gen2/FieldMoves.lua:107` (`FLY = "STORM"`), plus `ENGINE_FLYPOINT_OLIVINE` (id 70) / `ENGINE_FLYPOINT_GOLDENROD` (id 69) set by each town's `MAPCALLBACK_NEWMAP` (`maps/OlivineCity.asm:22`, `maps/GoldenrodCity.asm:24`) | `ENGINE_STORMBADGE` + having visited the town on foot | both already true entering this section | +| Haircut unavailable | `OlderHaircutBrotherScript` `readvar VAR_WEEKDAY` + `GoldenrodUndergroundCheckDayOfWeekCallback` | in-game weekday must be TUE/THU/SAT for the 500 brother | wait for the day, or use the younger brother SUN/WED/FRI for 300 | +| Second haircut same day | `checkflag ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT` (engine flag 89) | flag clear | daily reset | +| 4F of the lighthouse is a dead end from the (13, 3) ladder | geometry: 4F pit at (8, 3)/(9, 3), block `$28` in `maps/OlivineLighthouse4F.blk`, warps 7/8 | must step into the pit and re-climb via 3F warp 3 at (9, 5) | no flag, purely navigational | +| No TM pocket room | `verbosegiveitem TM_IRON_TAIL` -> `iffalse .NoRoomForIronTail` (`maps/OlivineGym.asm:35-36`) | free TM slot | drop a TM and re-talk to Jasmine | + +Nothing in this section requires `CUT`, `SURF`, `STRENGTH`, `WHIRLPOOL`, `WATERFALL` or `FLASH`. + +--- + +## 4. Bot checklist + +Optional Eevee-happiness detour (steps 1-5) can be skipped entirely. + +1. **Fly** to `SPAWN_GOLDENROD`. Precondition: `ENGINE_STORMBADGE` set and `ENGINE_FLYPOINT_GOLDENROD` (id 69) set. Postcondition: on `GOLDENROD_CITY`. +2. `GOLDENROD_CITY`: walk to (11, 29), step onto warp 15. Postcondition: on `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` at warp 5 (4, 29). +3. `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`: walk to (5, 25), step onto warp 4. Postcondition: on `GOLDENROD_UNDERGROUND` at warp 2 (3, 34). +4. `GOLDENROD_UNDERGROUND`: precondition `VAR_WEEKDAY in {TUESDAY, THURSDAY, SATURDAY}` and `ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT` clear and money >= 500. Walk to (6, 14) and face RIGHT (the brother stands at (7, 14) facing left); talk. Answer **Yes**, pick the Eevee. Postcondition: `ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT` set, money -500, Eevee happiness +1/+3/+5. +5. Retrace warps out (warp 2 at (3, 34) -> switch-room warp 5/6 at (4, 29)/(5, 29) -> `GOLDENROD_CITY`). +6. **Fly** to `SPAWN_OLIVINE`. Precondition: `ENGINE_FLYPOINT_OLIVINE` (id 70). Postcondition: on `OLIVINE_CITY`. +7. `OLIVINE_CITY`: walk to (29, 27), step onto warp 9. Postcondition: on `OLIVINE_LIGHTHOUSE_1F` at warp 1 (10, 17). +8. `1F`: walk to (3, 11), step on ladder-up. -> `2F` (3, 11). +9. `2F`: walk to (5, 3), ladder-up. -> `3F` (5, 3). (Sailor Huey (9, 3) and Gentleman Alfred (17, 8) are already beaten; their `EVENT_BEAT_*` flags guard re-battle.) +10. `3F`: walk to (13, 3), ladder-up. -> `4F` (13, 3). +11. `4F`: walk to the pit at (8, 3) or (9, 3) - "left of Lass Connie at (11, 2)". Step in. -> `3F` (8, 3)/(9, 3). +12. `3F`: walk to (9, 5), ladder-up. -> `4F` (9, 5). +13. `4F`: walk to (9, 7), ladder-up. -> `5F` (9, 7). (Alternative: 4F (3, 5) -> 5F (3, 5).) +14. `5F`: walk to (9, 15), ladder-up. -> `6F` (9, 15). Confirm arrival by `MUSIC_VIOLET_CITY` playing (6F is the only lighthouse floor with it). +15. `6F`: precondition `SECRETPOTION` in bag and `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` clear. Walk to (8, 9) and face UP (Jasmine at (8, 8) faces down) and talk. Answer **Yes** to `JasmineCureAmphyText`. Sit through ~120 frames of cutscene (`pause 60`, `pause 15`, several `pause 10`). Postcondition: `SECRETPOTION` removed, `EVENT_JASMINE_RETURNED_TO_GYM` set, `EVENT_OLIVINE_GYM_JASMINE` **cleared**, `EVENT_OLIVINE_LIGHTHOUSE_JASMINE` set. +16. Descend: `6F` pit at (16, 5)/(17, 5) -> `5F`; `5F` pit at (16, 7)/(17, 7) -> `4F`; `4F` pit at (16, 9)/(17, 9) -> `3F`; `3F` pit at (16, 11)/(17, 11) -> `2F`; `2F` pit at (16, 13)/(17, 13) -> `1F`. Then `1F` (10, 17)/(11, 17) facing DOWN -> `OLIVINE_CITY` warp 9 at (29, 27). +17. `OLIVINE_CITY`: (optional) heal at the Pokecenter, warp 1 at (13, 21). Save before the gym. +18. `OLIVINE_CITY`: walk to (10, 11), step onto warp 2. Postcondition: on `OLIVINE_GYM` at warp 1 (4, 15). +19. `OLIVINE_GYM`: (optional) talk to the guide by walking to (7, 14) and facing UP. +20. `OLIVINE_GYM`: hold UP along x=5 from (5, 14) to (5, 4), face UP, talk to Jasmine at (5, 3). Precondition: `EVENT_OLIVINE_GYM_JASMINE` clear, `EVENT_BEAT_JASMINE` clear, a free TM slot. Battle `loadtrainer JASMINE, JASMINE1`. +21. Win. Postconditions in order: `EVENT_BEAT_JASMINE` set -> `ENGINE_MINERALBADGE` set -> money +3500 -> `VAR_BADGES` re-read -> if it equals 6, `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` cleared (Rockets appear in Goldenrod) -> `verbosegiveitem TM_IRON_TAIL` -> `EVENT_GOT_TM23_IRON_TAIL` set. +22. Leave via (4, 15)/(5, 15) facing DOWN -> `OLIVINE_CITY` warp 2 (10, 11). + +Battle notes for the driver: lead a Fire or Fighting attacker; both Magnemite carry +`THUNDER_WAVE` (paralysis risk) and `SONICBOOM` (fixed 20 damage, ignores type - it will connect on +a Ground type); Steelix has `IRON_TAIL` and `SUNNY_DAY` (which boosts your own Fire moves too). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers, blocks, warps, coord/bg/object events for all of these maps | `src/import/RomExtractorGen2.lua` (`readItemBall` ~line 2875, object/trainer struct decode ~line 2961) | implemented (data-driven from ROM, no hand-ported Olivine map) | +| Script opcodes used by `OlivineGymJasmineScript` (`faceplayer`, `checkevent`, `winlosstext`, `loadtrainer`, `startbattle`, `reloadmapafterbattle`, `setflag`, `readvar`, `scall`, `verbosegiveitem`, `jumpstd`, `gettrainername`) | `src/script/gen2/Vm.lua` (`faceplayer` L164, `gettrainername` L407, `verbosegiveitem` L490, `jumpstd` L742, `loadtrainer` L806, `winlosstext` L918); opcode table `src/script/gen2/Opcodes.lua` | implemented | +| `VAR_BADGES` (`CountSetBits` over Johto+Kanto) | `src/world/gen2/World.lua:1240-1245` | implemented | +| Fly gate + flypoint engine flags | `src/world/gen2/FieldMoves.lua:103-130`, `FLYPOINTS` L340-368 (`OLIVINE` flag 70, `GOLDENROD` flag 69) | implemented | +| Pit / warp-carpet collision (the lighthouse climb) | `src/world/gen2/Permissions.lua:162-180` (`COLL_PIT` 0x60 / 0x68 and the `$7x` warp nybble; carpet direction table) | implemented | +| Haircut brothers (weighted roll + happiness deltas) | `src/script/gen2/Specials.lua:1577-1615` (`HAIRCUT_TABLES`), `src/core/gen2/Happiness.lua:79-84` | implemented | +| Trainer prize money incl. the x4 payout loop | `src/battle/gen2/Prize.lua` (header comment cites `ld c, 4`) | implemented | +| Gym-leader battle music for `JASMINE` | `src/battle/gen2/BattleMusic.lua:25`, `src/battle/gen2/Battle.lua:86` | implemented | +| Eevee -> Espeon/Umbreon (`EVOLVE_HAPPINESS`, `HAPPINESS_TO_EVOLVE` 220, `TR_MORNDAY`/`TR_NITE`) | `src/core/gen2/Evolution.lua:40-52,143` | implemented | +| Trainer card badge display | `src/ui/gen2/TrainerCard.lua`; leader palette list at `src/import/RomExtractorGen2.lua:4982` | implemented | +| **`BadgeStatBoosts`** - the MINERALBADGE +1/8 Defense (and the PlainBadge/MineralBadge bit swap) | nothing found. `grep -i badge src/battle/gen2/` returns no hits; the only badge-boost code in the repo is Gen 1's (`src/battle/Damage.lua:24`, `src/battle/BattleState.lua:439`) | **missing** | +| **`BadgeTypeBoosts`** (`data/types/badge_type_boosts.asm`, MINERALBADGE -> STEEL) | no hits for `BadgeTypeBoosts` anywhere in `src/` | **missing** | +| A driver that walks the lighthouse or fights a gym leader | `tests/drivers/gold_*.lua` - `gold_trainer_smoke.lua` and `gold_battle_smoke.lua` exist but neither names Olivine or Jasmine | **missing** (no section-9 driver) | + +--- + +## 6. Unresolved / verify by hand + +- **"Climb up the Olivine Lighthouse in the EXACT same way you came up."** The ladder/pit graph in + section 2 is fully decoded from the `.blk` files and `data/tilesets/lighthouse_collision.asm`, but + I did **not** flood-fill each floor to prove the specific walking path between a ladder and the + next pit. Steps 8-14 of the checklist are the geometrically obvious route (and the pit at 4F + (8, 3)/(9, 3) really is left of Lass Connie at (11, 2)), but the intermediate tile-by-tile route + on 3F, 4F and 5F should be confirmed in-game before a driver hard-codes it. +- **"Talk to the man who is running the second/middle shop."** The asm has four merchant objects in + one column at x=7: gramps y=11, older brother y=14, younger brother y=15, granny y=21, and + `GoldenrodUndergroundCheckDayOfWeekCallback` hides most of them on any given day. "Middle" is + therefore day-dependent; the unambiguous identifier is the **500 price** + (`GOLDENRODUNDERGROUND_OLDER_HAIRCUT_PRICE`), i.e. the older brother at (7, 14), available + TUE/THU/SAT only. The walkthrough does not mention the weekday restriction at all. +- **"Eevee's happiness meter should get a nice boost."** The older brother's roll is +1 (30%), + +3 (50%) or +5 (20%) at happiness < 200. The younger brother's top roll is +10. The walkthrough's + implied "500 is the better option" is not supported by + `data/events/happiness_probabilities.asm` / `data/events/happiness_changes.asm`. +- **"Say Yes to the fact that Ampharos will be cured by the medicine."** Confirmed as a hard + requirement (`yesorno` / `iffalse .Refused` at `maps/OlivineLighthouse6F.asm:29-30`), but note the + refusal path is *recoverable*: it sets nothing, so the player can simply talk again. +- **EXP values (571 / 1470)** are reproduced by the standard trainer formula from the base-exp bytes + I read, but I did not open the EXP-award routine itself to confirm the `*3/2` trainer multiplier + in this disassembly; treat those two numbers as arithmetic agreement rather than a code citation. +- **`data/trainers/attributes.asm` Jasmine row**: the file has no per-class labels, only ordered + comment headers. I identified Jasmine's row by the `; Jasmine` comment (sixth block, `db 25 ; + base reward`, `db HYPER_POTION, NO_ITEM`). If that comment ever drifts from the class order in + `constants/trainer_constants.asm`, the 3500 derivation drifts with it. +- The walkthrough's "you can go to the Lake of Rage first" branch is out of scope here; nothing in + the Olivine Gym asm checks for it. diff --git a/docs/gold-walkthrough/asm-walk/section-10-the-lake-of-rage-and-shiny-gyarados.md b/docs/gold-walkthrough/asm-walk/section-10-the-lake-of-rage-and-shiny-gyarados.md new file mode 100644 index 00000000..4a28fe33 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-10-the-lake-of-rage-and-shiny-gyarados.md @@ -0,0 +1,1101 @@ +# Section 10 - The Lake of Rage and Shiny Gyarados + +Source: `../section-10-the-lake-of-rage-and-shiny-gyarados.txt` +Maps covered: `MAP_ECRUTEAK_CITY` (fly-in only), `MAP_ROUTE_42_ECRUTEAK_GATE`, +`MAP_ROUTE_42`, `MAP_MAHOGANY_TOWN`, `MAP_MAHOGANY_MART_1F`, +`MAP_MAHOGANY_RED_GYARADOS_SPEECH_HOUSE`, `MAP_MAHOGANY_POKECENTER_1F`, +`MAP_ROUTE_43_MAHOGANY_GATE`, `MAP_ROUTE_43`, `MAP_ROUTE_43_GATE`, +`MAP_LAKE_OF_RAGE`, `MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE`, +`MAP_LAKE_OF_RAGE_MAGIKARP_HOUSE` + +Badges / key milestones in this section: no badge. The milestones are +`ENGINE_FLYPOINT_MAHOGANY`, `ENGINE_FLYPOINT_LAKE_OF_RAGE`, catching or +defeating the Red Gyarados (`EVENT_LAKE_OF_RAGE_RED_GYARADOS`), the `RED_SCALE`, +and `EVENT_DECIDED_TO_HELP_LANCE`, which arms +`SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` and opens the Team Rocket base in +the next section. + +A note on coordinates: every table below is transcribed verbatim from the map's +`_MapEvents` block. `warp_event`, `coord_event`, `bg_event` and `object_event` +all use the same map coordinate space (the `object_event` macro in +`macros/scripts/maps.asm` adds the +4 border offset itself), so the numbers are +directly comparable. A map declared `map_const NAME, W, H` spans x in +`0 .. 2W-1` and y in `0 .. 2H-1`. + +A note on the `event flag` column of `object_event`: `engine/overworld/map_objects_2.asm` +`CheckObjectFlag` masks (hides) the object when the flag is **set**, and `-1` +means always visible. So "set = gone". + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ECRUTEAK_CITY` | `maps/EcruteakCity.asm` | Fly (`SPAWN_ECRUTEAK`, `data/maps/spawn_points.asm` `spawn ECRUTEAK_CITY, 23, 28`) | warp 1/2 at (35,26)/(35,27) -> `ROUTE_42_ECRUTEAK_GATE` 1/2 | "Fly to Ecruteak City, then head east onto Route 42" | +| 2 | `MAP_ROUTE_42_ECRUTEAK_GATE` | `maps/Route42EcruteakGate.asm` | warps 1/2 at (0,4)/(0,5) | warps 3/4 at (9,4)/(9,5) -> `ROUTE_42` 1/2 | gate hut between Ecruteak and Route 42 | +| 3 | `MAP_ROUTE_42` | `maps/Route42.asm` | warps 1/2 at (0,8)/(0,9) | east map connection -> `MAHOGANY_TOWN` | Ultra Ball, Surf the two lakes, Cut to the apricorn trees, Super Potion, three trainers | +| 4 | `MAP_MAHOGANY_TOWN` | `maps/MahoganyTown.asm` | west map connection from `ROUTE_42` | warp 5 at (9,1) -> `ROUTE_43_MAHOGANY_GATE` 3 | heal, Mahogany Mart, Red Gyarados speech house, blocked east exit | +| 5 | `MAP_ROUTE_43_MAHOGANY_GATE` | `maps/Route43MahoganyGate.asm` | warps 3/4 at (4,7)/(5,7) | warps 1/2 at (4,0)/(5,0) -> `ROUTE_43` 1/2 | gate hut north out of Mahogany | +| 6 | `MAP_ROUTE_43` | `maps/Route43.asm` | warps 1/2 at (9,51)/(10,51) | north map connection -> `LAKE_OF_RAGE` | five trainers, Max Ether, Bitter Berry tree, the Rocket toll gate you are told to walk around | +| 7 | `MAP_ROUTE_43_GATE` | `maps/Route43Gate.asm` | Route 43 warp 3 at (17,35) (southbound) or warps 4/5 at (17,31)/(18,31) (northbound) | mirrored warps | the 1000-yen Rocket shakedown the walkthrough tells you to skip | +| 8 | `MAP_LAKE_OF_RAGE` | `maps/LakeOfRage.asm` | south map connection from `ROUTE_43` | Fly out (`ENGINE_FLYPOINT_LAKE_OF_RAGE`) once Lance is talked to | Red Gyarados, Red Scale, Lance, Wesley of Wednesday, Max Ether, TM43 | +| 9 | `MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE` | `maps/LakeOfRageHiddenPowerHouse.asm` | Lake of Rage warp 1 at (7,3) | warps 1/2 at (2,7)/(3,7) | TM10 Hidden Power | +| 10 | `MAP_LAKE_OF_RAGE_MAGIKARP_HOUSE` | `maps/LakeOfRageMagikarpHouse.asm` | Lake of Rage warp 2 at (27,31) | warps 1/2 at (2,7)/(3,7) | Fishing Guru flavour; the Magikarp length contest only opens after `EVENT_CLEARED_ROCKET_HIDEOUT` | + +Spill into the next section: after Lance is helped, `LakeOfRageLanceScript` does +`setmapscene MAHOGANY_MART_1F, SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS`, and +the walkthrough ends with "fly back to Mahogany Town". `MAP_MAHOGANY_MART_1F`'s +staircase scene, `MAP_TEAM_ROCKET_BASE_B1F..B3F` and `MAP_MAHOGANY_GYM` belong to +the next section; only the pieces of Mahogany Mart reachable *before* that are +documented here. + +Also out of scope but named by the walkthrough: `maps/MrPokemonsHouse.asm` +`MrPokemonsHouse_MrPokemonScript.RedScale` is the `RED_SCALE` -> `EXP_SHARE` +trade (`checkitem RED_SCALE` / `verbosegiveitem EXP_SHARE` / `takeitem RED_SCALE`). +Mt. Mortar (`MOUNT_MORTAR_1F_OUTSIDE`, reached from Route 42 warps 3/4/5) is +explicitly deferred by the walkthrough. + +--- + +## 2. Maps + +### MAP_ECRUTEAK_CITY + +Only the eastern exit matters here. + +- Script: `maps/EcruteakCity.asm` +- Blocks: `maps/EcruteakCity.blk` +- Header: `data/maps/maps.asm:173` -> `map EcruteakCity, TILESET_JOHTO, TOWN, LANDMARK_ECRUTEAK_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:159` -> `map_const ECRUTEAK_CITY, 20, 18` +- Connections (`data/maps/attributes.asm:147`): south `ROUTE_37` (+5), west `ROUTE_38` (+5), east `ROUTE_42` (+9) +- Fly spawn: `data/maps/spawn_points.asm:35` -> `spawn ECRUTEAK_CITY, 23, 28` + +**Warps** (the two this section uses, `maps/EcruteakCity.asm:229`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 35 | 26 | `ROUTE_42_ECRUTEAK_GATE` | 1 | +| 2 | 35 | 27 | `ROUTE_42_ECRUTEAK_GATE` | 2 | + +--- + +### MAP_ROUTE_42_ECRUTEAK_GATE + +- Script: `maps/Route42EcruteakGate.asm` +- Blocks: shared, `data/maps/blocks.asm` (no dedicated `.blk`) +- Header: `data/maps/maps.asm:71` -> `map Route42EcruteakGate, TILESET_GATE, GATE, LANDMARK_ROUTE_42, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:59` -> `map_const ROUTE_42_ECRUTEAK_GATE, 5, 4` +- Connections: none (indoor gate) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ECRUTEAK_CITY` | 1 | +| 2 | 0 | 5 | `ECRUTEAK_CITY` | 2 | +| 3 | 9 | 4 | `ROUTE_42` | 1 | +| 4 | 9 | 5 | `ROUTE_42` | 2 | + +**Coord events** - none. +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| (unnamed, index 2) | `SPRITE_OFFICER` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route42EcruteakGateOfficerScript` | -1 | + +No gate check here: the officer is a `jumptextfaceplayer` only. This gate is +always open. + +--- + +### MAP_ROUTE_42 + +- Script: `maps/Route42.asm` (symbol `Route42_MapEvents` = `4c:5e86`, `pokegold.sym:23813`) +- Blocks: `maps/Route42.blk` +- Header: `data/maps/maps.asm:72` -> `map Route42, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_42, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:60` -> `map_const ROUTE_42, 30, 9` (x 0..59, y 0..17) +- Connections (`data/maps/attributes.asm:231`): west `ECRUTEAK_CITY` (-9), east `MAHOGANY_TOWN` (0) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 8 | `ROUTE_42_ECRUTEAK_GATE` | 3 | +| 2 | 0 | 9 | `ROUTE_42_ECRUTEAK_GATE` | 4 | +| 3 | 10 | 5 | `MOUNT_MORTAR_1F_OUTSIDE` | 1 | +| 4 | 28 | 9 | `MOUNT_MORTAR_1F_OUTSIDE` | 2 | +| 5 | 46 | 7 | `MOUNT_MORTAR_1F_OUTSIDE` | 3 | + +**Coord events** - `def_coord_events` is empty. Nothing on Route 42 trips a scene. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 10 | `BGEVENT_READ` | `Route42Sign1` | +| 7 | 5 | `BGEVENT_READ` | `MtMortarSign1` | +| 45 | 9 | `BGEVENT_READ` | `MtMortarSign2` | +| 54 | 8 | `BGEVENT_READ` | `Route42Sign2` | +| 16 | 11 | `BGEVENT_ITEM` | `Route42HiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_ROUTE_42_HIDDEN_MAX_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE42_FISHER` | `SPRITE_FISHER` | 40 | 10 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerFisherChris` | -1 | +| `ROUTE42_POKEFAN_M` | `SPRITE_POKEFAN_M` | 51 | 9 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerHikerBenjamin` | -1 | +| `ROUTE42_SUPER_NERD` | `SPRITE_SUPER_NERD` | 47 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPokemaniacShane` | -1 | +| `ROUTE42_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 27 | 16 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree1` -> `fruittree FRUITTREE_ROUTE_42_1` | -1 | +| `ROUTE42_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 28 | 16 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree2` -> `fruittree FRUITTREE_ROUTE_42_2` | -1 | +| `ROUTE42_FRUIT_TREE3` | `SPRITE_FRUIT_TREE` | 29 | 16 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree3` -> `fruittree FRUITTREE_ROUTE_42_3` | -1 | +| `ROUTE42_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 4 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route42UltraBall` -> `itemball ULTRA_BALL` | `EVENT_ROUTE_42_ULTRA_BALL` | +| `ROUTE42_POKE_BALL2` | `SPRITE_POKE_BALL` | 33 | 8 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route42SuperPotion` -> `itemball SUPER_POTION` | `EVENT_ROUTE_42_SUPER_POTION` | + +**Scripts of interest** + +- `TrainerFisherChris` - `trainer FISHER, CHRIS1, EVENT_BEAT_FISHER_CHRIS, ...`. + After-battle branch is the standard phone-number ladder: `checkevent + EVENT_CHRIS_READY_FOR_REMATCH` -> rematch; else `checkcellnum + PHONE_FISHER_CHRIS`; else `setevent EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER` and + `askforphonenumber PHONE_FISHER_CHRIS`. The rematch arm picks `CHRIS2` if + `EVENT_CLEARED_ROCKET_HIDEOUT` and `CHRIS3` if `EVENT_BEAT_ELITE_FOUR`; neither + applies in this section. +- `TrainerPokemaniacShane`, `TrainerHikerBenjamin` - plain + `endifjustbattled` / `writetext` / `end`, no flags beyond their + `EVENT_BEAT_*`. +- `Route42UltraBall` / `Route42SuperPotion` - `itemball`; the one-time flag is + the object's own event flag. +- `Route42HiddenMaxPotion` - `hiddenitem MAX_POTION, EVENT_ROUTE_42_HIDDEN_MAX_POTION` + at (16,11). The walkthrough never mentions it. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_42_ULTRA_BALL` | `constants/event_flags.asm:1108` | object row / `itemball` | set = ball at (6,4) already taken | +| `EVENT_ROUTE_42_SUPER_POTION` | `constants/event_flags.asm:1109` | object row / `itemball` | set = ball at (33,8) already taken | +| `EVENT_ROUTE_42_HIDDEN_MAX_POTION` | `constants/event_flags.asm:183` | `Route42HiddenMaxPotion` | set = hidden Max Potion consumed | +| `EVENT_BEAT_FISHER_CHRIS` | `constants/event_flags.asm:591` | `TrainerFisherChris` | set = Chris beaten | +| `EVENT_BEAT_POKEMANIAC_SHANE` | `constants/event_flags.asm:753` | `TrainerPokemaniacShane` | set = Shane beaten | +| `EVENT_BEAT_HIKER_BENJAMIN` | `constants/event_flags.asm:823` | `TrainerHikerBenjamin` | set = Benjamin beaten | +| `EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER` / `EVENT_CHRIS_READY_FOR_REMATCH` | `constants/event_flags.asm` | `TrainerFisherChris.Script` | phone bookkeeping only | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `ULTRA_BALL` | item ball at (6,4) | `Route42UltraBall` | `EVENT_ROUTE_42_ULTRA_BALL` | +| `SUPER_POTION` | item ball at (33,8) | `Route42SuperPotion` | `EVENT_ROUTE_42_SUPER_POTION` | +| `MAX_POTION` | hidden at (16,11) | `bg_event 16, 11, BGEVENT_ITEM` | `EVENT_ROUTE_42_HIDDEN_MAX_POTION` | +| `PNK_APRICORN` | fruit tree (27,16) | `FRUITTREE_ROUTE_42_1`, `data/items/fruit_trees.asm:24` | daily reset, not an event flag | +| `GRN_APRICORN` | fruit tree (28,16) | `FRUITTREE_ROUTE_42_2`, `data/items/fruit_trees.asm:25` | daily reset | +| `YLW_APRICORN` | fruit tree (29,16) | `FRUITTREE_ROUTE_42_3`, `data/items/fruit_trees.asm:26` | daily reset | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `CHRIS1` | `FISHER` (0x25) | 7 | `FisherGroup` entry `; FISHER (7)` at `parties.asm:1537`: L18 `QWILFISH` | `TrainerFisherChris` | yes, `PHONE_FISHER_CHRIS`; `CHRIS2` (L23 Qwilfish) after `EVENT_CLEARED_ROCKET_HIDEOUT`, `CHRIS3` after `EVENT_BEAT_ELITE_FOUR` | +| `SHANE` | `POKEMANIAC` (0x1e) | 4 | `parties.asm:1097`: L16 `NIDORINA`, L16 `NIDORINO` | `TrainerPokemaniacShane` | no | +| `BENJAMIN` | `HIKER` (0x2c) | 6 | `parties.asm:2149`: L14 `DIGLETT`, L14 `GEODUDE`, L16 `DUGTRIO` | `TrainerHikerBenjamin` | no | + +**Wild encounters** + +`data/wild/johto_grass.asm:2125` `def_grass_wildmons ROUTE_42`, rates 10%/10%/10% +morn/day/nite. The table is `IF DEF(_GOLD)` / `ELIF DEF(_SILVER)` split, so the +walkthrough's "Mankey (Gold only)" is literally true: + +| slot | morn (Gold) | day (Gold) | nite (Gold) | +|---|---|---|---| +| 1 | L15 `MANKEY` | L15 `MANKEY` | L15 `MANKEY` | +| 2 | L13 `MAREEP` | L13 `MAREEP` | L13 `MAREEP` | +| 3 | L14 `SPEAROW` | L14 `SPEAROW` | L14 `ZUBAT` | +| 4 | L16 `SPEAROW` | L16 `SPEAROW` | L16 `ZUBAT` | +| 5 | L15 `FLAAFFY` | L15 `FLAAFFY` | L15 `FLAAFFY` | +| 6 | L17 `FLAAFFY` | L17 `FLAAFFY` | L17 `FLAAFFY` | +| 7 | L17 `FLAAFFY` | L17 `FLAAFFY` | L17 `FLAAFFY` | + +Silver swaps slot 1 `MANKEY` for L15 `MAREEP` and reorders; the rest is +identical. + +Water (`data/wild/johto_water.asm:183`), 4% rate: L20 `GOLDEEN`, L15 `GOLDEEN`, +L20 `SEAKING`. + +Fishing: header says `FISHGROUP_LAKE` -> `data/wild/fish.asm` `.Lake_Old` +(Magikarp/Magikarp/Goldeen L10), `.Lake_Good` (Magikarp/Goldeen/Goldeen L20 + +`time_group 4`), `.Lake_Super` (Goldeen L40 + `time_group 5` + Magikarp L40 + +Seaking L40). + +Headbutt: `data/wild/treemon_maps.asm:23` -> `treemon_map ROUTE_42, TREEMON_SET_CANYON`. + +Roamers: `data/wild/roammon_maps.asm:30` -> `roam_map ROUTE_42, ROUTE_43, ROUTE_44, +ROUTE_37, ROUTE_38`. A roaming beast can be on Route 42 or Route 43 while the +bot walks this section. + +--- + +### MAP_MAHOGANY_TOWN + +- Script: `maps/MahoganyTown.asm` (symbol `MahoganyTown_MapEvents` = `49:4e95`, `pokegold.sym:22738`) +- Blocks: `maps/MahoganyTown.blk` +- Header: `data/maps/maps.asm:74` -> `map MahoganyTown, TILESET_JOHTO, TOWN, LANDMARK_MAHOGANY_TOWN, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:62` -> `map_const MAHOGANY_TOWN, 10, 9` (x 0..19, y 0..17) +- Connections (`data/maps/attributes.asm:152`): north `ROUTE_43` (0), west `ROUTE_42` (0), east `ROUTE_44` (0) +- Fly spawn: `data/maps/spawn_points.asm:36` -> `spawn MAHOGANY_TOWN, 15, 14` +- Callback: `callback MAPCALLBACK_NEWMAP, MahoganyTownFlypointCallback` -> `setflag ENGINE_FLYPOINT_MAHOGANY` + +**Scene scripts** (`def_scene_scripts`, ids assigned in order) + +| id | constant | script | +|---|---|---| +| 0 | `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` | `MahoganyTownNoop1Scene` (`end`) | +| 1 | `SCENE_MAHOGANYTOWN_NOOP` | `MahoganyTownNoop2Scene` (`end`) | + +Scene 0 is the new-game default and is what arms the two coord events below. +Only `RadioTowerRocketsScript` (`engine/events/std_scripts.asm:262`) ever moves +it to 1 - which is much later than this section. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 7 | `MAHOGANY_MART_1F` | 1 | +| 2 | 17 | 7 | `MAHOGANY_RED_GYARADOS_SPEECH_HOUSE` | 1 | +| 3 | 6 | 13 | `MAHOGANY_GYM` | 1 | +| 4 | 15 | 13 | `MAHOGANY_POKECENTER_1F` | 1 | +| 5 | 9 | 1 | `ROUTE_43_MAHOGANY_GATE` | 3 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` | 19 | 8 | `MahoganyTownTryARageCandyBarScript` | RageCandyBar sales pitch, then you are shoved one step west | +| `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` | 19 | 9 | `MahoganyTownTryARageCandyBarScript` | same | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 5 | `BGEVENT_READ` | `MahoganyTownSign` | +| 9 | 7 | `BGEVENT_READ` | `MahoganyTownRagecandybarSign` | +| 3 | 13 | `BGEVENT_READ` | `MahoganyGymSign` | +| 16 | 13 | `BGEVENT_READ` | `MahoganyTownPokecenterSign` (`jumpstd PokecenterSignScript`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MAHOGANYTOWN_POKEFAN_M` | `SPRITE_POKEFAN_M` | 19 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownPokefanMScript` | `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` | +| `MAHOGANYTOWN_GRAMPS` | `SPRITE_GRAMPS` | 6 | 9 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius x=1) | `OBJECTTYPE_SCRIPT` | `MahoganyTownGrampsScript` | -1 | +| `MAHOGANYTOWN_FISHER` | `SPRITE_FISHER` | 6 | 14 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownFisherScript` | `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` | +| `MAHOGANYTOWN_LASS` | `SPRITE_LASS` | 12 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownLassScript` | `EVENT_MAHOGANY_MART_OWNERS` | + +State during this section: `EVENT_MAHOGANY_MART_OWNERS` is **set** by +`InitializeEventsScript` (`engine/events/std_scripts.asm:501`), so the Lass at +(12,8) is not on the map yet - she only appears after +`maps/RadioTower5F.asm:107` / `maps/BlackthornGym1F.asm:53` clear it. +`EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` and +`EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` are **clear** at new game (nothing in +`InitializeEventsScript` sets them), so both the merchant at (19,8) and the +fisher at (6,14) are physically standing in the way. + +**Scripts of interest** + +- `MahoganyTownTryARageCandyBarScript` (symbol `49:4ac8`, `pokegold.sym:22705`) - + `showemote EMOTE_SHOCK` on the merchant, `applymovement MAHOGANYTOWN_POKEFAN_M, + MahoganyTownRageCandyBarMerchantBlocksYouMovement` (`step RIGHT`, `step DOWN`, + `turn_head LEFT`), `follow PLAYER, MAHOGANYTOWN_POKEFAN_M`, `applymovement + PLAYER, MahoganyTownPlayerStepLeftMovement` (`step LEFT`), `stopfollow`, + `turnobject PLAYER, RIGHT`, `scall RageCandyBarMerchantScript`, then + `MahoganyTownRageCandyBarMerchantReturnsMovement` (`step UP`, `turn_head + DOWN`). It never calls `setscene`, so it fires **every** time the bot steps on + (19,8)/(19,9). Buying does not unlock the east exit; only + `RadioTowerRocketsScript`'s `setevent EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` + + `setmapscene MAHOGANY_TOWN, SCENE_MAHOGANYTOWN_NOOP` does. +- `RageCandyBarMerchantScript` - `checkevent EVENT_CLEARED_ROCKET_HIDEOUT` -> + "sold out"; otherwise `writetext RageCandyBarMerchantTryOneText`, `special + PlaceMoneyTopRight`, `yesorno`, `checkmoney YOUR_MONEY, + MAHOGANYTOWN_RAGECANDYBAR_PRICE`, `giveitem RAGECANDYBAR`, `takemoney + YOUR_MONEY, 300`. `DEF MAHOGANYTOWN_RAGECANDYBAR_PRICE EQU 300` at + `maps/MahoganyTown.asm:1`. +- `MahoganyTownGrampsScript` / `MahoganyTownFisherScript` - text only. The Gramps + branches on `EVENT_CLEARED_ROCKET_HIDEOUT`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_MAHOGANY` | `constants/engine_flags.asm:87` | `MahoganyTownFlypointCallback` | set on first map load; Fly destination unlocked | +| `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` | `constants/event_flags.asm:1272` | object row; set by `engine/events/std_scripts.asm:261` `RadioTowerRocketsScript` | clear = merchant stands at (19,8); the east exit to Route 44 is closed for this whole section | +| `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` | `constants/event_flags.asm:1273` | object row; set by `maps/TeamRocketBaseB2F.asm:303` | clear = fisher at (6,14) blocks the Gym door at (6,13). Mahogany Gym is not enterable in this section | +| `EVENT_MAHOGANY_MART_OWNERS` | `constants/event_flags.asm:1240` | object rows; set at init, cleared in `RadioTower5F` / `BlackthornGym1F` | set = Lass and Mart Granny absent | +| `EVENT_CLEARED_ROCKET_HIDEOUT` | `constants/event_flags.asm:43` | read all over this section | clear for the whole of section 10 | +| `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` (=0) / `SCENE_MAHOGANYTOWN_NOOP` (=1) | exported by the `scene_script` macro in `macros/scripts/maps.asm` | `def_scene_scripts`, `setmapscene` | 0 for this section | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `RAGECANDYBAR` | bought for 300 from the merchant | `RageCandyBarMerchantScript.SellRageCandyBars` | none, repeatable until `EVENT_CLEARED_ROCKET_HIDEOUT` | + +**Trainers** - none. + +**Wild encounters** - none on the town map. `FISHGROUP_SHORE` per the header if +the bot fishes off the town's water. + +--- + +### MAP_MAHOGANY_MART_1F (the "Grandma's shop" the walkthrough calls a house) + +Only the pre-Lance state is in scope. + +- Script: `maps/MahoganyMart1F.asm` +- Header: `data/maps/maps.asm:118` -> `map MahoganyMart1F, TILESET_TRADITIONAL_HOUSE, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_MAHOGANY_MART, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:105` -> `map_const MAHOGANY_MART_1F, 4, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `MAHOGANY_TOWN` | 1 | +| 2 | 4 | 7 | `MAHOGANY_TOWN` | 1 | +| 3 | 7 | 3 | `TEAM_ROCKET_BASE_B1F` | 1 | + +Warp 3 is the hidden staircase: `MahoganyMart1FStaircaseCallback` +(`MAPCALLBACK_TILES`) only does `changeblock 6, 2, $1e` once +`EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` is set, which is next section. + +**Coord events** - none. **BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MAHOGANYMART1F_PHARMACIST` | `SPRITE_PHARMACIST` | 4 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FPharmacistScript` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `MAHOGANYMART1F_BLACK_BELT` | `SPRITE_BLACK_BELT` | 1 | 6 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FBlackBeltScript` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `MAHOGANYMART1F_LANCE` | `SPRITE_LANCE` | 4 | 6 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` | +| `MAHOGANYMART1F_DRAGONITE` | `SPRITE_DRAGON` | 3 | 6 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` | +| `MAHOGANYMART1F_GRANNY` | `SPRITE_GRANNY` | 1 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FGrannyScript` | `EVENT_MAHOGANY_MART_OWNERS` | + +`EVENT_TEAM_ROCKET_BASE_POPULATION` is clear at new game, so the Pharmacist runs +`pokemart MARTTYPE_STANDARD, MART_MAHOGANY_1`. `MartMahogany1` +(`data/items/marts.asm:195`): `TINYMUSHROOM`, `SLOWPOKETAIL`, `POKE_BALL`, +`POTION`. `SLOWPOKETAIL`'s price is 9800 (`data/items/attributes.asm:215`, +`item_attribute 9800, HELD_NONE, ...`) - this is the walkthrough's "9800G +Slowpoketails". `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` is **set** by +`InitializeEventsScript`, so Lance and the Dragonite are not here yet; +`LakeOfRageLanceScript` clears it at the end of this section. + +--- + +### MAP_MAHOGANY_RED_GYARADOS_SPEECH_HOUSE + +- Script: `maps/MahoganyRedGyaradosSpeechHouse.asm` +- Header: `data/maps/maps.asm:68` -> `map MahoganyRedGyaradosSpeechHouse, TILESET_HOUSE, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:56` -> `map_const MAHOGANY_RED_GYARADOS_SPEECH_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `MAHOGANY_TOWN` | 2 | +| 2 | 3 | 7 | `MAHOGANY_TOWN` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MAHOGANYREDGYARADOSSPEECHHOUSE_BLACK_BELT` | `SPRITE_BLACK_BELT` | 2 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MahoganyRedGyaradosSpeechHouseBlackBeltScript` | -1 | +| `MAHOGANYREDGYARADOSSPEECHHOUSE_TEACHER` | `SPRITE_TEACHER` | 6 | 5 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius y=1) | `OBJECTTYPE_SCRIPT` | `MahoganyRedGyaradosSpeechHouseTeacherScript` | -1 | + +Text only. The teacher branches on `checkflag ENGINE_ROCKETS_IN_RADIO_TOWER`. + +--- + +### MAP_MAHOGANY_POKECENTER_1F + +- Script: `maps/MahoganyPokecenter1F.asm` +- Header: `data/maps/maps.asm:70` -> `map MahoganyPokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:58` -> `map_const MAHOGANY_POKECENTER_1F, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `MAHOGANY_TOWN` | 4 | +| 2 | 4 | 7 | `MAHOGANY_TOWN` | 4 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| (index 2) | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyPokecenter1FNurseScript` | -1 | +| (index 3) | `SPRITE_POKEFAN_M` | 7 | 2 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `MahoganyPokecenter1FPokefanMScript` | -1 | +| (index 4) | `SPRITE_YOUNGSTER` | 1 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MahoganyPokecenter1FYoungsterScript` | -1 | +| (index 5) | `SPRITE_COOLTRAINER_F` | 2 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `MahoganyPokecenter1FCooltrainerFScript` | -1 | + +Heal target for a bot: talk to the nurse at (3,1) from (3,2). + +--- + +### MAP_ROUTE_43_MAHOGANY_GATE + +- Script: `maps/Route43MahoganyGate.asm` +- Header: `data/maps/maps.asm:239` -> `map Route43MahoganyGate, TILESET_GATE, GATE, LANDMARK_ROUTE_43, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:219` -> `map_const ROUTE_43_MAHOGANY_GATE, 5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_43` | 1 | +| 2 | 5 | 0 | `ROUTE_43` | 2 | +| 3 | 4 | 7 | `MAHOGANY_TOWN` | 5 | +| 4 | 5 | 7 | `MAHOGANY_TOWN` | 5 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE43MAHOGANYGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route43MahoganyGateOfficer` | -1 | + +`Route43MahoganyGateOfficer` branches on `EVENT_CLEARED_ROCKET_HIDEOUT` for text +only. This gate never blocks. + +--- + +### MAP_ROUTE_43 + +- Script: `maps/Route43.asm` (symbol `Route43_MapEvents` = `4d:4680`, `pokegold.sym:23879`) +- Blocks: `maps/Route43.blk` +- Header: `data/maps/maps.asm:241` -> `map Route43, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_43, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:221` -> `map_const ROUTE_43, 10, 27` (x 0..19, y 0..53) +- Connections (`data/maps/attributes.asm:235`): north `LAKE_OF_RAGE` (-5), south `MAHOGANY_TOWN` (0) +- Callback: `callback MAPCALLBACK_NEWMAP, Route43CheckIfRocketsScene` + +`Route43CheckIfRocketsScene`: `checkevent EVENT_CLEARED_ROCKET_HIDEOUT`; if +clear, `setmapscene ROUTE_43_GATE, SCENE_ROUTE43GATE_ROCKET_SHAKEDOWN`, else +`setmapscene ROUTE_43_GATE, SCENE_ROUTE43GATE_NOOP`. In this section the toll +scene is always armed. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 51 | `ROUTE_43_MAHOGANY_GATE` | 1 | +| 2 | 10 | 51 | `ROUTE_43_MAHOGANY_GATE` | 2 | +| 3 | 17 | 35 | `ROUTE_43_GATE` | 3 | +| 4 | 17 | 31 | `ROUTE_43_GATE` | 1 | +| 5 | 18 | 31 | `ROUTE_43_GATE` | 2 | + +**Coord events** - `def_coord_events` is empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 13 | 3 | `BGEVENT_READ` | `Route43Sign1` | +| 11 | 49 | `BGEVENT_READ` | `Route43Sign2` | +| 16 | 38 | `BGEVENT_READ` | `Route43TrainerTips` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE43_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 14 | 6 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPokemaniacBen` | -1 | +| `ROUTE43_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 13 | 20 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPokemaniacBrent` | -1 | +| `ROUTE43_SUPER_NERD3` | `SPRITE_SUPER_NERD` | 13 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerPokemaniacRon` | -1 | +| `ROUTE43_FISHER` | `SPRITE_FISHER` | 4 | 16 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerFisherMarvin` | -1 | +| `ROUTE43_LASS` | `SPRITE_LASS` | 9 | 29 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerPicnickerTiffany` | -1 | +| `ROUTE43_YOUNGSTER` | `SPRITE_YOUNGSTER` | 15 | 43 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 5) | `TrainerCamperSpencer` | -1 | +| `ROUTE43_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 1 | 26 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route43FruitTree` -> `fruittree FRUITTREE_ROUTE_43` | -1 | +| `ROUTE43_POKE_BALL` | `SPRITE_POKE_BALL` | 12 | 32 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route43MaxEther` -> `itemball MAX_ETHER` | `EVENT_ROUTE_43_MAX_ETHER` | + +Walking north from the Mahogany gate the y coordinates decrease, which gives the +walkthrough's order exactly: Spencer (y=43), Tiffany (y=29), Brent (y=20), Ron +(y=7), Ben (y=6). Marvin at (4,16) is the far-west detour. + +**Scripts of interest** + +- `TrainerPicnickerTiffany` - `trainer PICNICKER, TIFFANY3, EVENT_BEAT_PICNICKER_TIFFANY, ...`. + Phone ladder on `PHONE_PICNICKER_TIFFANY`; rematch loads `TIFFANY1` after + `EVENT_CLEARED_RADIO_TOWER` and `TIFFANY2` after `EVENT_BEAT_ELITE_FOUR`. + Note the inversion: `TIFFANY3` (L20) is the *first* fight. +- `TrainerPokemaniacBrent` - `BRENT1` first; `BRENT2` after + `EVENT_CLEARED_ROCKET_HIDEOUT`, `BRENT3` after `EVENT_BEAT_ELITE_FOUR`. Phone + on `PHONE_POKEMANIAC_BRENT`. +- `TrainerCamperSpencer`, `TrainerPokemaniacBen`, `TrainerPokemaniacRon`, + `TrainerFisherMarvin` - plain, no phone, no rematch. +- `Route43CheckIfRocketsScene` - the callback above; this is the only thing that + arms the toll. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_43_MAX_ETHER` | `constants/event_flags.asm:1110` | object row | set = ball at (12,32) taken | +| `EVENT_BEAT_CAMPER_SPENCER` | `constants/event_flags.asm:546` | `TrainerCamperSpencer` | | +| `EVENT_BEAT_PICNICKER_TIFFANY` | `constants/event_flags.asm:655` | `TrainerPicnickerTiffany` | | +| `EVENT_BEAT_POKEMANIAC_BRENT` | `constants/event_flags.asm:755` | `TrainerPokemaniacBrent` | | +| `EVENT_BEAT_POKEMANIAC_RON` | `constants/event_flags.asm:756` | `TrainerPokemaniacRon` | | +| `EVENT_BEAT_POKEMANIAC_BEN` | `constants/event_flags.asm:754` | `TrainerPokemaniacBen` | | +| `EVENT_BEAT_FISHER_MARVIN` | `constants/event_flags.asm:590` | `TrainerFisherMarvin` | | +| `EVENT_CLEARED_ROCKET_HIDEOUT` | `constants/event_flags.asm:43` | `Route43CheckIfRocketsScene` | clear here, so the gate toll is armed | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `MAX_ETHER` | item ball at (12,32) | `Route43MaxEther` | `EVENT_ROUTE_43_MAX_ETHER` | +| `BITTER_BERRY` | fruit tree at (1,26) | `FRUITTREE_ROUTE_43`, `data/items/fruit_trees.asm:11` | daily reset | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SPENCER` | `CAMPER` (0x36) | 19 | `; CAMPER (19)` at `parties.asm:2817`: L17 `SANDSHREW`, L17 `SANDSLASH`, L19 `ZUBAT` | `TrainerCamperSpencer` | no | +| `TIFFANY3` | `PICNICKER` (0x35) | 20 | `; PICNICKER (20)` at `parties.asm:2693`, `TRAINERTYPE_MOVES`: L20 `CLEFAIRY` - `ENCORE`, `SING`, `DOUBLESLAP`, `MINIMIZE` | `TrainerPicnickerTiffany` | yes, `PHONE_PICNICKER_TIFFANY` | +| `BRENT1` | `POKEMANIAC` (0x1e) | 6 | `; POKEMANIAC (6)` at `parties.asm:1108`: L19 `LICKITUNG` | `TrainerPokemaniacBrent` | yes, `PHONE_POKEMANIAC_BRENT` | +| `RON` | `POKEMANIAC` | 7 | `parties.asm:1113`: L19 `NIDOKING` | `TrainerPokemaniacRon` | no | +| `BEN` | `POKEMANIAC` | 5 | `parties.asm:1103`: L19 `SLOWBRO` | `TrainerPokemaniacBen` | no | +| `MARVIN` | `FISHER` (0x25) | 6 | `; FISHER (6)` at `parties.asm:1529`: L10 `MAGIKARP`, L10 `GYARADOS`, L15 `MAGIKARP`, L15 `GYARADOS` | `TrainerFisherMarvin` | no | + +**Wild encounters** + +`data/wild/johto_grass.asm:2180` `def_grass_wildmons ROUTE_43`, rates 10%/10%/10%. +No version split. + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L15 `FLAAFFY` | L15 `FLAAFFY` | L15 `FLAAFFY` | +| 2 | L15 `GIRAFARIG` | L15 `GIRAFARIG` | L15 `GIRAFARIG` | +| 3 | L17 `PIDGEOTTO` | L17 `PIDGEOTTO` | L17 `NOCTOWL` | +| 4 | L15 `MAREEP` | L15 `MAREEP` | L16 `VENONAT` | +| 5 | L16 `VENONAT` | L17 `FLAAFFY` | L15 `MAREEP` | +| 6 | L17 `PIDGEOTTO` | L17 `FLAAFFY` | L16 `VENONAT` | +| 7 | L17 `PIDGEOTTO` | L17 `FLAAFFY` | L16 `VENONAT` | + +Water (`data/wild/johto_water.asm:190`), 2% rate: L20/L15/L10 `MAGIKARP`. +Fishing: `FISHGROUP_POND` -> `.Pond_Old` Magikarp/Magikarp/Poliwag L10, +`.Pond_Good` Magikarp/Poliwag/Poliwag L20 + `time_group 6`. +Headbutt: `data/wild/treemon_maps.asm:24` -> `TREEMON_SET_CANYON`. +Roamers: `data/wild/roammon_maps.asm:31` -> `roam_map ROUTE_43, ROUTE_42, ROUTE_44`. + +--- + +### MAP_ROUTE_43_GATE (the Rocket toll booth) + +- Script: `maps/Route43Gate.asm` (`Route43GateRocketTakeoverScript` = `53:5bb0`, `pokegold.sym:25377`) +- Blocks: shared, `data/maps/blocks.asm:206` `Route43Gate_Blocks` +- Header: `data/maps/maps.asm:240` -> `map Route43Gate, TILESET_GATE, GATE, LANDMARK_ROUTE_43, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:220` -> `map_const ROUTE_43_GATE, 5, 4` +- Callback: `callback MAPCALLBACK_NEWMAP, Route43GateCheckIfRocketsCallback` + +**Scene scripts** + +| id | constant | script | +|---|---|---| +| 0 | `SCENE_ROUTE43GATE_ROCKET_SHAKEDOWN` | `Route43GateRocketShakedownScene` -> `sdefer Route43GateRocketTakeoverScript` | +| 1 | `SCENE_ROUTE43GATE_NOOP` | `Route43GateNoopScene` | + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_43` | 4 | +| 2 | 5 | 0 | `ROUTE_43` | 5 | +| 3 | 4 | 7 | `ROUTE_43` | 3 | +| 4 | 5 | 7 | `ROUTE_43` | 3 | + +(Warp 4 also lands on Route 43 warp 3, not a separate one - transcribed as written.) + +**Coord events** - none. **BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE43GATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `OfficerScript_GuardWithSludgeBomb` | `EVENT_LAKE_OF_RAGE_CIVILIANS` | +| `ROUTE43GATE_ROCKET1` | `SPRITE_ROCKET` | 2 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `RocketScript_MakingABundle` | `EVENT_ROUTE_43_GATE_ROCKETS` | +| `ROUTE43GATE_ROCKET2` | `SPRITE_ROCKET` | 7 | 4 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `RocketScript_MakingABundle` | `EVENT_ROUTE_43_GATE_ROCKETS` | + +`EVENT_LAKE_OF_RAGE_CIVILIANS` is **set** at new game +(`engine/events/std_scripts.asm:500`), so the officer with TM36 is **not here +during this section**; he only appears once `maps/TeamRocketBaseB2F.asm:305` +clears it. `EVENT_ROUTE_43_GATE_ROCKETS` is clear, so the two grunts are here; +`TeamRocketBaseB2F.asm:302` sets it later to remove them. + +**Scripts of interest** + +- `Route43GateRocketTakeoverScript` - `playmusic MUSIC_ROCKET_ENCOUNTER`, + `readvar VAR_FACING`, `ifequal DOWN, RocketScript_Southbound`, `ifequal UP, + RocketScript_Northbound`, otherwise `setscene SCENE_ROUTE43GATE_NOOP` and end. +- `RocketScript_Northbound` / `RocketScript_Southbound` - two grunts step in, + `writetext RocketText_TollFee`, then `checkmoney YOUR_MONEY, + ROUTE43GATE_TOLL - 1`; `HAVE_MORE` goes to `RocketScript_Toll*`, otherwise + `RocketScript_YoureBroke*`. **Both arms run `takemoney YOUR_MONEY, + ROUTE43GATE_TOLL`** - a bot with less than 1000 loses everything it has and + still passes. `DEF ROUTE43GATE_TOLL EQU 1000` at `maps/Route43Gate.asm:1`. + Both arms then `setscene SCENE_ROUTE43GATE_NOOP`, so the toll is charged once + per `Route43CheckIfRocketsScene` re-arm (i.e. once per Route 43 map load). +- `OfficerScript_GuardWithSludgeBomb` - `verbosegiveitem TM_SLUDGE_BOMB` guarded + by `EVENT_GOT_TM36_SLUDGE_BOMB`. Out of reach this section. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_SLUDGE_BOMB` (TM36) | talk to the officer | `OfficerScript_GuardWithSludgeBomb` | `EVENT_GOT_TM36_SLUDGE_BOMB` - **not obtainable in this section**, the officer is masked by `EVENT_LAKE_OF_RAGE_CIVILIANS` | + +--- + +### MAP_LAKE_OF_RAGE + +- Script: `maps/LakeOfRage.asm` (`LakeOfRage_MapEvents` = `49:5825`, `pokegold.sym:22807`; `LakeOfRageLanceScript` = `49:4f2e`; `RedGyarados` = `49:4f6f`; `WesleyScript` = `49:501a`) +- Blocks: `maps/LakeOfRage.blk` +- Header: `data/maps/maps.asm:242` -> `map LakeOfRage, TILESET_JOHTO, TOWN, LANDMARK_LAKE_OF_RAGE, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_AUTO, FISHGROUP_GYARADOS` +- Dimensions: `constants/map_constants.asm:222` -> `map_const LAKE_OF_RAGE, 20, 18` (x 0..39, y 0..35) +- Connections (`data/maps/attributes.asm:157`): south `ROUTE_43` (-5) +- Fly spawn: `data/maps/spawn_points.asm:37` -> `spawn LAKE_OF_RAGE, 21, 29` +- Callbacks: `MAPCALLBACK_NEWMAP` -> `LakeOfRageFlypointCallback` (`setflag + ENGINE_FLYPOINT_LAKE_OF_RAGE`); `MAPCALLBACK_OBJECTS` -> `LakeOfRageWesleyCallback` + +**Scene scripts** - `LakeOfRageNoop1Scene` / `LakeOfRageNoop2Scene`, both marked +`; unusable` in the source. Lake of Rage effectively has no scene variable; +`RedGyarados`'s `setscene 0` carries the comment saying so. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 3 | `LAKE_OF_RAGE_HIDDEN_POWER_HOUSE` | 1 | +| 2 | 27 | 31 | `LAKE_OF_RAGE_MAGIKARP_HOUSE` | 1 | + +**Coord events** - `def_coord_events` is empty. Nothing here is a trip-wire; the +Gyarados and Lance are both talk-to objects. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 21 | 27 | `BGEVENT_READ` | `LakeOfRageSign` (this is the sign Lance is standing at) | +| 25 | 31 | `BGEVENT_READ` | `MagikarpHouseSignScript` | +| 11 | 28 | `BGEVENT_ITEM` | `LakeOfRageHiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE` | +| 4 | 4 | `BGEVENT_ITEM` | `LakeOfRageHiddenRareCandy` -> `hiddenitem RARE_CANDY, EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY` | +| 35 | 5 | `BGEVENT_ITEM` | `LakeOfRageHiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION` | + +The hidden Rare Candy at (4,4) is on the exact tile Wesley stands on +(`object_event 4, 4, SPRITE_SUPER_NERD ... WesleyScript`). On a Wednesday the NPC +is in the way of the Itemfinder tile. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LAKEOFRAGE_LANCE` | `SPRITE_LANCE` | 21 | 28 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `LakeOfRageLanceScript` | `EVENT_LAKE_OF_RAGE_LANCE` | +| `LAKEOFRAGE_GRAMPS` | `SPRITE_GRAMPS` | 20 | 26 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `LakeOfRageGrampsScript` | -1 | +| `LAKEOFRAGE_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 36 | 13 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `LakeOfRageSuperNerdScript` | -1 | +| `LAKEOFRAGE_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 25 | 29 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius x=1) | `OBJECTTYPE_SCRIPT` | `LakeOfRageCooltrainerFScript` | -1 | +| `LAKEOFRAGE_FISHER1` | `SPRITE_FISHER` | 30 | 23 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerFisherAndre` | `EVENT_LAKE_OF_RAGE_CIVILIANS` | +| `LAKEOFRAGE_FISHER2` | `SPRITE_FISHER` | 24 | 26 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerFisherRaymond` | `EVENT_LAKE_OF_RAGE_CIVILIANS` | +| `LAKEOFRAGE_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 4 | 15 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerCooltrainermAaron` | `EVENT_LAKE_OF_RAGE_CIVILIANS` | +| `LAKEOFRAGE_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 36 | 7 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerCooltrainerfLois` | `EVENT_LAKE_OF_RAGE_CIVILIANS` | +| `LAKEOFRAGE_GYARADOS` | `SPRITE_GYARADOS` | 18 | 22 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `RedGyarados` | `EVENT_LAKE_OF_RAGE_RED_GYARADOS` | +| `LAKEOFRAGE_WESLEY` | `SPRITE_SUPER_NERD` | 4 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `WesleyScript` | `EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY` | +| `LAKEOFRAGE_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 10 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `LakeOfRageMaxEther` -> `itemball MAX_ETHER` | `EVENT_LAKE_OF_RAGE_MAX_ETHER` | +| `LAKEOFRAGE_POKE_BALL2` | `SPRITE_POKE_BALL` | 35 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `LakeOfRageTMDetect` -> `itemball TM_DETECT` | `EVENT_LAKE_OF_RAGE_TM_DETECT` | + +Visibility during this section: `EVENT_LAKE_OF_RAGE_LANCE` is **set** at init +(`engine/events/std_scripts.asm:516`) so Lance is absent until the Gyarados +script's `appear LAKEOFRAGE_LANCE`. `EVENT_LAKE_OF_RAGE_CIVILIANS` is **set** at +init (`:500`), so **Andre, Raymond, Aaron and Lois are not on the map yet** - +they only turn up after the Rocket hideout. `EVENT_LAKE_OF_RAGE_RED_GYARADOS` +and `EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY` are clear at init, so the Gyarados +is present and Wesley is governed purely by the weekday callback. + +**Scripts of interest** + +- `LakeOfRageWesleyCallback` (`MAPCALLBACK_OBJECTS`) - `readvar VAR_WEEKDAY`, + `ifequal WEDNESDAY, .WesleyAppears` -> `appear LAKEOFRAGE_WESLEY`; otherwise + `disappear LAKEOFRAGE_WESLEY`. +- `RedGyarados` - the whole encounter: + `opentext` / `writetext LakeOfRageGyaradosCryText` / `pause 15` / + `cry GYARADOS` / `closetext`, then + `loadwildmon GYARADOS, 30`, + `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCESHINY`, + `startbattle`, + `ifequal LOSE, .NotBeaten` (skip the disappear), + `disappear LAKEOFRAGE_GYARADOS`, + `reloadmapafterbattle`, + `opentext` / **`giveitem RED_SCALE`** / `waitsfx` / + `writetext LakeOfRageGotRedScaleText` / `playsound SFX_ITEM` / `waitsfx` / + `itemnotify` / `closetext`, + `setscene 0`, `appear LAKEOFRAGE_LANCE`, `end`. + Two things a bot must know: the `giveitem RED_SCALE` has **no `iffalse` + guard**, so a full bag silently eats the Red Scale; and the Red Scale and Lance + arrive even on a loss, because the `.NotBeaten` label only skips the + `disappear`. +- `LakeOfRageLanceScript` - `checkevent EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE` + -> `.AskAgainForHelp`. First pass: `writetext LakeOfRageLanceForcedToEvolveText`, + `promptbutton`, `faceplayer`, `writetext LakeOfRageLanceIntroText`, `yesorno`. + `iffalse .RefusedToHelp` -> `setevent EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE` + and stop. `.AgreedToHelp` -> + `writetext LakeOfRageLanceRadioSignalText`, `playsound SFX_WARP_TO`, + `applymovement LAKEOFRAGE_LANCE, LakeOfRageLanceTeleportIntoSkyMovement` + (`teleport_from` = `$4c`, `macros/scripts/movement.asm:148`), + `disappear LAKEOFRAGE_LANCE`, + `clearevent EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE`, + `setevent EVENT_DECIDED_TO_HELP_LANCE`, + `setmapscene MAHOGANY_MART_1F, SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS`. + **This is the section's terminal state change.** Saying no is recoverable - the + `.AskAgainForHelp` arm re-asks. +- `WesleyScript` - `checkevent EVENT_GOT_BLACKBELT_FROM_WESLEY` -> + `WesleyWednesdayScript`; `readvar VAR_WEEKDAY` / `ifnotequal WEDNESDAY` -> + `WesleyNotWednesdayScript`; else `setevent EVENT_MET_WESLEY_OF_WEDNESDAY`, + `verbosegiveitem BLACKBELT_I`, `iffalse WesleyDoneScript`, `setevent + EVENT_GOT_BLACKBELT_FROM_WESLEY`. Guarded, so a full bag is safe here. +- `MagikarpHouseSignScript` - `writetext FishingGurusHouseSignText`, then + `checkevent EVENT_CLEARED_ROCKET_HIDEOUT` -> `special MagikarpHouseSign` + (the record board). Only text in this section. +- `LakeOfRageGrampsScript`, `LakeOfRageSuperNerdScript`, + `LakeOfRageCooltrainerFScript` - the "something strange is going on" NPCs the + walkthrough mentions; all branch on `EVENT_CLEARED_ROCKET_HIDEOUT` at most. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_LAKE_OF_RAGE` | `constants/engine_flags.asm:88` | `LakeOfRageFlypointCallback` | set on first entry | +| `EVENT_LAKE_OF_RAGE_RED_GYARADOS` | `constants/event_flags.asm:1267` | `RedGyarados` (`disappear`) | clear = the Gyarados object is on the water at (18,22) | +| `EVENT_LAKE_OF_RAGE_LANCE` | `constants/event_flags.asm:1142` | set by `InitializeEventsScript`; cleared by `RedGyarados`'s `appear`; set again by `LakeOfRageLanceScript`'s `disappear` | Lance is only talkable in the window between beating the Gyarados and agreeing to help | +| `EVENT_LAKE_OF_RAGE_CIVILIANS` | `constants/event_flags.asm:1239` | set at init; cleared in `maps/TeamRocketBaseB2F.asm:305` | set for this whole section: no Lake of Rage trainers | +| `EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY` | `constants/event_flags.asm:1278` | `LakeOfRageWesleyCallback` | driven by `VAR_WEEKDAY` on every map load | +| `EVENT_MET_WESLEY_OF_WEDNESDAY` | `constants/event_flags.asm:115` | `WesleyScript` | first-meeting text seen | +| `EVENT_GOT_BLACKBELT_FROM_WESLEY` | `constants/event_flags.asm:116` | `WesleyScript` | gift taken | +| `EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE` | `constants/event_flags.asm:47` | `LakeOfRageLanceScript` | you said no; the re-ask arm | +| `EVENT_DECIDED_TO_HELP_LANCE` | `constants/event_flags.asm:105` | set by `LakeOfRageLanceScript`; read by `maps/MahoganyMart1F.asm:35,50` | **section complete** | +| `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` | `constants/event_flags.asm:1143` | set at init, cleared by `LakeOfRageLanceScript` | clear = Lance and Dragonite now stand in Mahogany Mart | +| `EVENT_LAKE_OF_RAGE_MAX_ETHER` | `constants/event_flags.asm:998` | object row | ball at (7,10) | +| `EVENT_LAKE_OF_RAGE_TM_DETECT` | `constants/event_flags.asm:999` | object row | ball at (35,2) | +| `EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE` | `constants/event_flags.asm:191` | `LakeOfRageHiddenFullRestore` | hidden at (11,28) | +| `EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY` | `constants/event_flags.asm:192` | `LakeOfRageHiddenRareCandy` | hidden at (4,4) | +| `EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION` | `constants/event_flags.asm:193` | `LakeOfRageHiddenMaxPotion` | hidden at (35,5) | +| `SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` | `maps/MahoganyMart1F.asm` `def_scene_scripts` (id 1) | `setmapscene` in `LakeOfRageLanceScript` | arms the next section | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `RED_SCALE` | `giveitem` after the Gyarados battle | `RedGyarados` | none of its own; gated by `EVENT_LAKE_OF_RAGE_RED_GYARADOS` | +| `BLACKBELT_I` | Wesley, Wednesdays only | `WesleyScript` (`verbosegiveitem BLACKBELT_I`) | `EVENT_GOT_BLACKBELT_FROM_WESLEY` | +| `MAX_ETHER` | item ball at (7,10) | `LakeOfRageMaxEther` | `EVENT_LAKE_OF_RAGE_MAX_ETHER` | +| `TM_DETECT` (TM43) | item ball at (35,2) | `LakeOfRageTMDetect` | `EVENT_LAKE_OF_RAGE_TM_DETECT` | +| `FULL_RESTORE` | hidden at (11,28) | `bg_event 11, 28, BGEVENT_ITEM` | `EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE` | +| `RARE_CANDY` | hidden at (4,4) | `bg_event 4, 4, BGEVENT_ITEM` | `EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY` | +| `MAX_POTION` | hidden at (35,5) | `bg_event 35, 5, BGEVENT_ITEM` | `EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION` | + +TM numbering, from the `add_tm` ladder in `constants/item_constants.asm:218-264`: +`TM_HIDDEN_POWER` is TM10 and `TM_DETECT` is TM43 (`__tmhm_value__` skips +`ITEM_C3` and `ITEM_DC`, which is why the item ids and TM numbers do not line up +one-for-one). + +**Trainers** - none reachable in this section (all four are masked by +`EVENT_LAKE_OF_RAGE_CIVILIANS`). For completeness, once unmasked: +`FISHER ANDRE` (id 8, `parties.asm:1542`, L27 `GYARADOS`), +`FISHER RAYMOND` (id 9, `parties.asm:1547`, four L22 `MAGIKARP`), +`COOLTRAINERM AARON` (id 2, `parties.asm:749`, L24 `IVYSAUR`/`CHARMELEON`/`WARTORTLE`), +`COOLTRAINERF LOIS` (id 2, `parties.asm:875`, `TRAINERTYPE_MOVES`, L25 `SKIPLOOM` +and L25 `NINETALES`). + +**Wild encounters** + +There is **no** `def_grass_wildmons LAKE_OF_RAGE` entry in +`data/wild/johto_grass.asm` - the grass here is empty. + +Water (`data/wild/johto_water.asm:253`): 4% in Gold, 6% in Silver; +L15 `MAGIKARP`, L10 `MAGIKARP`, L15 `GYARADOS`. + +Fishing: header says `FISHGROUP_GYARADOS` -> `data/wild/fish.asm:132` +`.Gyarados_Old` / `.Gyarados_Good` / `.Gyarados_Super` are Magikarp in every +slot (the `time_group 14` / `time_group 15` slots aside). This is the "lake full +of Gyarados but nothing else" that Lance narrates. + +Headbutt: `data/wild/treemon_maps.asm:37` -> `treemon_map LAKE_OF_RAGE, TREEMON_SET_FOREST`. + +The Red Gyarados itself is **not** a wild-table roll: `loadwildmon GYARADOS, 30` +plus `BATTLETYPE_FORCESHINY`. + +--- + +### MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE + +- Script: `maps/LakeOfRageHiddenPowerHouse.asm` (`HiddenPowerGuy` = `53:54fd`, `pokegold.sym:25334`) +- Blocks: shared, `data/maps/blocks.asm:165` +- Header: `data/maps/maps.asm:237` -> `map LakeOfRageHiddenPowerHouse, TILESET_HOUSE, INDOOR, LANDMARK_LAKE_OF_RAGE, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:217` -> `map_const LAKE_OF_RAGE_HIDDEN_POWER_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `LAKE_OF_RAGE` | 1 | +| 2 | 3 | 7 | `LAKE_OF_RAGE` | 1 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `HiddenPowerHouseBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `HiddenPowerHouseBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LAKEOFRAGEHIDDENPOWERHOUSE_FISHER` | `SPRITE_FISHER` | 2 | 3 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `HiddenPowerGuy` | -1 | + +`HiddenPowerGuy`: `checkevent EVENT_GOT_TM10_HIDDEN_POWER` -> already-got text; +else `verbosegiveitem TM_HIDDEN_POWER`, `iffalse .Done`, `setevent +EVENT_GOT_TM10_HIDDEN_POWER`. Guarded, so a full bag is safe. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_HIDDEN_POWER` (TM10) | talk to the fisher at (2,3) | `HiddenPowerGuy` | `EVENT_GOT_TM10_HIDDEN_POWER` (`constants/event_flags.asm:97`) | + +--- + +### MAP_LAKE_OF_RAGE_MAGIKARP_HOUSE + +- Script: `maps/LakeOfRageMagikarpHouse.asm` +- Blocks: shared, `data/maps/blocks.asm:166` +- Header: `data/maps/maps.asm:238` -> `map LakeOfRageMagikarpHouse, TILESET_HOUSE, INDOOR, LANDMARK_LAKE_OF_RAGE, MUSIC_LAKE_OF_RAGE, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:218` -> `map_const LAKE_OF_RAGE_MAGIKARP_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `LAKE_OF_RAGE` | 2 | +| 2 | 3 | 7 | `LAKE_OF_RAGE` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `MagikarpHouseBookshelf` | +| 1 | 1 | `BGEVENT_READ` | `MagikarpHouseBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LAKEOFRAGEMAGIKARPHOUSE_FISHING_GURU` | `SPRITE_FISHING_GURU` | 2 | 3 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `MagikarpLengthRaterScript` | -1 | + +`MagikarpLengthRaterScript` in this section takes the +`EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP` path only: first talk sets that +flag and tells the lake's history, second talk gives the "men in black" line. +The measuring contest (`special CheckMagikarpLength`, `verbosegiveitem ETHER`) +sits behind `checkevent EVENT_CLEARED_ROCKET_HIDEOUT`, which is the next section +onwards. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Route 42's two lakes | `engine/events/overworld.asm:322` `SurfFunction` -> `.TrySurf`: `ld de, ENGINE_FOGBADGE` / `call CheckBadge` (`:50`), then `GetTilePermission` must be `WATER_TILE` | FOG BADGE + a party mon that knows SURF (`CheckPartyMove`, `:64`) | Morty, section 8 | +| The Route 42 cut trees in front of the apricorn trees | `engine/events/overworld.asm:117` `CutFunction` -> `.CheckAble`: `ld de, ENGINE_HIVEBADGE` / `CheckBadge`, then `CheckMapForSomethingToCut` | HIVE BADGE + a party mon that knows CUT | Bugsy, section 5. Tile positions are in `maps/Route42.blk`, not in any event table | +| Reaching the Red Gyarados at (18,22) | it is a water tile in the middle of `maps/LakeOfRage.blk`; no script gate | SURF | same as above | +| Mahogany east exit (to Route 44) | `maps/MahoganyTown.asm` `coord_event 19, 8` / `coord_event 19, 9` on `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR`, plus the `MAHOGANYTOWN_POKEFAN_M` object at (19,8) with `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` clear | none - it is unconditional | `engine/events/std_scripts.asm:261` `RadioTowerRocketsScript` (`setevent` + `setmapscene MAHOGANY_TOWN, SCENE_MAHOGANYTOWN_NOOP`). Far beyond this section. Buying a RageCandyBar does **not** help | +| Mahogany Gym door at (6,13) | `maps/MahoganyTown.asm` `object_event 6, 14, SPRITE_FISHER ... EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` (clear = NPC present, standing on the approach tile) | none | `maps/TeamRocketBaseB2F.asm:303` sets the flag after the hideout is cleared | +| Route 43 gate toll | `maps/Route43Gate.asm` scene 0 `Route43GateRocketShakedownScene` -> `sdefer Route43GateRocketTakeoverScript`, re-armed on every Route 43 load by `Route43CheckIfRocketsScene` | 1000 yen, or everything you have (`takemoney` runs in both branches) | not a hard gate - you always pass. `maps/TeamRocketBaseB2F.asm:302` removes the grunts permanently | +| Lance at (21,28) | `object_event ... EVENT_LAKE_OF_RAGE_LANCE`, set by `InitializeEventsScript` (`engine/events/std_scripts.asm:516`) | beat or catch the Red Gyarados | `RedGyarados`'s `appear LAKEOFRAGE_LANCE` | +| Wesley / `BLACKBELT_I` | `LakeOfRageWesleyCallback` (`MAPCALLBACK_OBJECTS`): `readvar VAR_WEEKDAY` / `ifequal WEDNESDAY` | the in-game day must be Wednesday | real clock; no in-game unlock | +| Lake of Rage trainers and the TM36 officer | `EVENT_LAKE_OF_RAGE_CIVILIANS`, set by `InitializeEventsScript:500` | - | `maps/TeamRocketBaseB2F.asm:305` `clearevent`. Not this section | +| Magikarp length contest / `ETHER` | `MagikarpLengthRaterScript`'s `checkevent EVENT_CLEARED_ROCKET_HIDEOUT` | Rocket hideout cleared | next section | +| Team Rocket base entrance | `MahoganyMart1FStaircaseCallback`'s `checkevent EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` -> `changeblock 6, 2, $1e` | Lance uncovers it | armed here by `setmapscene MAHOGANY_MART_1F, SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` | + +--- + +## 4. Bot checklist + +Coordinates are map coordinates. "Talk" means face the tile and press A. + +1. `MAP_ECRUTEAK_CITY` - Fly to `SPAWN_ECRUTEAK` (lands at 23,28). + Pre: `ENGINE_FLYPOINT_ECRUTEAK`. Party must contain SURF and CUT users. +2. `MAP_ECRUTEAK_CITY` - walk to warp 1 (35,26) or 2 (35,27). Post: on + `MAP_ROUTE_42_ECRUTEAK_GATE`. +3. `MAP_ROUTE_42_ECRUTEAK_GATE` - walk east to warp 3 (9,4) / 4 (9,5). Post: on + `MAP_ROUTE_42` at (0,8)/(0,9). +4. `MAP_ROUTE_42` - walk to (6,4), talk to `ROUTE42_POKE_BALL1`. + Pre: `EVENT_ROUTE_42_ULTRA_BALL` clear. Post: it is set, `ULTRA_BALL` in bag. +5. `MAP_ROUTE_42` - do **not** enter warps 3/4/5 (Mt. Mortar); the walkthrough + defers them to the Waterfall trip. +6. `MAP_ROUTE_42` - use SURF to cross the first lake (badge: `ENGINE_FOGBADGE`). +7. `MAP_ROUTE_42` - use CUT on the tree(s) south of the lake (badge: + `ENGINE_HIVEBADGE`), then talk to the fruit trees at (27,16), (28,16), + (29,16). Post: `PNK_APRICORN`, `GRN_APRICORN`, `YLW_APRICORN`; each tree is + bare until the next daily reset. +8. `MAP_ROUTE_42` - optional: Itemfinder / step on (16,11) for the hidden + `MAX_POTION`. Post: `EVENT_ROUTE_42_HIDDEN_MAX_POTION`. +9. `MAP_ROUTE_42` - walk to (33,8), talk to `ROUTE42_POKE_BALL2`. + Post: `EVENT_ROUTE_42_SUPER_POTION`, `SUPER_POTION` in bag. +10. `MAP_ROUTE_42` - SURF the second lake, then battle `ROUTE42_FISHER` at + (40,10) (`FISHER CHRIS1`, L18 Qwilfish). Post: `EVENT_BEAT_FISHER_CHRIS`; + optionally accept `PHONE_FISHER_CHRIS`. +11. `MAP_ROUTE_42` - battle `ROUTE42_SUPER_NERD` at (47,8) (`POKEMANIAC SHANE`). + Post: `EVENT_BEAT_POKEMANIAC_SHANE`. +12. `MAP_ROUTE_42` - battle `ROUTE42_POKEFAN_M` at (51,9) (`HIKER BENJAMIN`). + Post: `EVENT_BEAT_HIKER_BENJAMIN`. +13. `MAP_ROUTE_42` - walk east off the map edge (x > 59) into + `MAP_MAHOGANY_TOWN`. Post: `ENGINE_FLYPOINT_MAHOGANY` set by + `MahoganyTownFlypointCallback`. +14. `MAP_MAHOGANY_TOWN` - warp 4 (15,13) -> Pokecenter; talk to the nurse at + (3,1) from (3,2); leave by warp 1/2. +15. `MAP_MAHOGANY_TOWN` - optional flavour: warp 1 (11,7) -> Mahogany Mart, talk + to `MAHOGANYMART1F_PHARMACIST` at (4,3) for `MART_MAHOGANY_1` (the 9800-yen + `SLOWPOKETAIL`). Warp 2 (17,7) -> Red Gyarados speech house. +16. `MAP_MAHOGANY_TOWN` - **avoid** (19,8) and (19,9) unless you want the + RageCandyBar scene; it costs 300 and never opens the east exit. Do not try + the Gym at (6,13): `MAHOGANYTOWN_FISHER` at (6,14) is in the way. +17. `MAP_MAHOGANY_TOWN` - walk to warp 5 (9,1). Post: on + `MAP_ROUTE_43_MAHOGANY_GATE` at (4,7)/(5,7). +18. `MAP_ROUTE_43_MAHOGANY_GATE` - walk north to warp 1 (4,0) / 2 (5,0). Post: + on `MAP_ROUTE_43` at (9,51)/(10,51). Loading Route 43 runs + `Route43CheckIfRocketsScene`, which arms `SCENE_ROUTE43GATE_ROCKET_SHAKEDOWN`. +19. `MAP_ROUTE_43` - battle `ROUTE43_YOUNGSTER` at (15,43) (`CAMPER SPENCER`). + Post: `EVENT_BEAT_CAMPER_SPENCER`. +20. `MAP_ROUTE_43` - do **not** enter warp 3 (17,35) or warps 4/5 + (17,31)/(18,31): those are the toll booth. Take the west/grass path instead. +21. `MAP_ROUTE_43` - optional: talk to the fruit tree at (1,26) for + `BITTER_BERRY`; battle `ROUTE43_FISHER` at (4,16) (`FISHER MARVIN`). Post: + `EVENT_BEAT_FISHER_MARVIN`. +22. `MAP_ROUTE_43` - battle `ROUTE43_LASS` at (9,29) (`PICNICKER TIFFANY3`, L20 + Clefairy). Post: `EVENT_BEAT_PICNICKER_TIFFANY`; optional phone. +23. `MAP_ROUTE_43` - talk to `ROUTE43_POKE_BALL` at (12,32). Post: + `EVENT_ROUTE_43_MAX_ETHER`, `MAX_ETHER` in bag. +24. `MAP_ROUTE_43` - battle `ROUTE43_SUPER_NERD2` at (13,20) (`POKEMANIAC + BRENT1`). Post: `EVENT_BEAT_POKEMANIAC_BRENT`; optional phone. +25. `MAP_ROUTE_43` - battle `ROUTE43_SUPER_NERD3` at (13,7) (`POKEMANIAC RON`) + and `ROUTE43_SUPER_NERD1` at (14,6) (`POKEMANIAC BEN`). Post: + `EVENT_BEAT_POKEMANIAC_RON`, `EVENT_BEAT_POKEMANIAC_BEN`. +26. `MAP_ROUTE_43` - walk north off the map edge (y < 0) into + `MAP_LAKE_OF_RAGE`. Post: `ENGINE_FLYPOINT_LAKE_OF_RAGE`. +27. `MAP_LAKE_OF_RAGE` - CUT your way northwest; talk to + `LAKEOFRAGE_POKE_BALL1` at (7,10). Post: `EVENT_LAKE_OF_RAGE_MAX_ETHER`. +28. `MAP_LAKE_OF_RAGE` - if `VAR_WEEKDAY == WEDNESDAY`, talk to + `LAKEOFRAGE_WESLEY` at (4,4). Post: `EVENT_MET_WESLEY_OF_WEDNESDAY`, + `EVENT_GOT_BLACKBELT_FROM_WESLEY`, `BLACKBELT_I` in bag. Then step on (4,4) + for the hidden `RARE_CANDY`. +29. `MAP_LAKE_OF_RAGE` - warp 1 at (7,3) -> `MAP_LAKE_OF_RAGE_HIDDEN_POWER_HOUSE`; + talk to the fisher at (2,3). Pre: `EVENT_GOT_TM10_HIDDEN_POWER` clear. + Post: it is set, `TM_HIDDEN_POWER` in bag. Leave by (2,7)/(3,7). +30. `MAP_LAKE_OF_RAGE` - CUT northeast; talk to `LAKEOFRAGE_POKE_BALL2` at + (35,2). Post: `EVENT_LAKE_OF_RAGE_TM_DETECT`, `TM_DETECT` (TM43) in bag. + Optional hidden `MAX_POTION` at (35,5). +31. `MAP_LAKE_OF_RAGE` - optional hidden `FULL_RESTORE` at (11,28). +32. **Save.** Make sure the bag has a free slot: `RedGyarados`'s `giveitem + RED_SCALE` is unguarded. +33. `MAP_LAKE_OF_RAGE` - SURF onto the lake and face `LAKEOFRAGE_GYARADOS` at + (18,22); press A. Pre: `EVENT_LAKE_OF_RAGE_RED_GYARADOS` clear. The script + runs `loadwildmon GYARADOS, 30` + `loadvar VAR_BATTLETYPE, + BATTLETYPE_FORCESHINY`. Throw Great Balls; do not KO it if you want it. + Post: `EVENT_LAKE_OF_RAGE_RED_GYARADOS` set (on a win or a catch), + `RED_SCALE` in bag, `EVENT_LAKE_OF_RAGE_LANCE` cleared (Lance appears). +34. `MAP_LAKE_OF_RAGE` - walk to (21,29) and face north to talk to + `LAKEOFRAGE_LANCE` at (21,28). Answer **YES** to `yesorno`. + Post: `EVENT_DECIDED_TO_HELP_LANCE` set, + `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` cleared, `MAHOGANY_MART_1F` scene + set to `SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS`, Lance disappears. + Answering NO sets `EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE` and is + recoverable by talking again. +35. Fly to `SPAWN_MAHOGANY` (lands at 15,14). Section complete; the next section + starts at `MAP_MAHOGANY_MART_1F` warp 1 (11,7). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map geometry, warps, coord/bg/object events for every map in this section | `src/import/RomExtractorGen2.lua:804-975` (`coordEvents`/`bgEvents`/`objects`), `src/world/gen2/Map.lua`, `src/world/gen2/World.lua:5013` (coord events), `:5148` (bg events) | implemented (data-driven from the ROM; nothing is hand-transcribed, so these tables come across as-is) | +| Map header fields (tileset/environment/landmark/music/palette/fish group) | `src/import/RomExtractorGen2.lua:741,965` | implemented | +| `MAPCALLBACK_NEWMAP` / `MAPCALLBACK_OBJECTS` (the two flypoint callbacks, `Route43CheckIfRocketsScene`, `LakeOfRageWesleyCallback`) | `src/script/gen2/Vm.lua` `runCallback`, `tests/gen2_map_callbacks_test.lua`, `tests/drivers/gold_map_callbacks.lua` | implemented | +| `ENGINE_FLYPOINT_MAHOGANY` / `ENGINE_FLYPOINT_LAKE_OF_RAGE` and Fly | `src/world/gen2/FieldMoves.lua:352-353` (`SPAWN_MAHOGANY` flag 72, `SPAWN_LAKE_OF_RAGE` flag 73) | implemented | +| `readvar VAR_WEEKDAY` (Wesley of Wednesday) | `src/world/gen2/World.lua` `World:weekday`, `src/script/gen2/Vm.lua` `readvar` | implemented | +| Fruit trees (`FRUITTREE_ROUTE_42_1/2/3`, `FRUITTREE_ROUTE_43`) with the daily reset | `src/core/gen2/Apricorns.lua:362` (the `BITTER_BERRY` row is `FRUITTREE_ROUTE_43`), `World:fruitTreeItem` / `fruitTreePick` | implemented | +| `itemball` / `hiddenitem` one-time flags | `src/script/gen2/CallAsm.lua`, `src/world/gen2/HiddenItems.lua` | implemented | +| Trainer objects, sight range, `loadtrainer` / `startbattle` | `src/world/gen2/Trainers.lua`, `src/script/gen2/Vm.lua` | implemented | +| Phone contacts for `FISHER CHRIS1`, `POKEMANIAC BRENT1`, `PICNICKER TIFFANY3` | `src/core/gen2/Phone.lua:201` (Chris, `ROUTE_42`), `:204` (Brent, `ROUTE_43`), `:207` (Tiffany, `ROUTE_43`) | implemented | +| Roaming beasts crossing Route 42/43 | `src/core/gen2/Roamers.lua:70-72` | implemented | +| RageCandyBar merchant scene (`showemote`, `applymovement`, `follow`/`stopfollow`, `checkmoney`/`takemoney`, `special PlaceMoneyTopRight`) | `src/script/gen2/Vm.lua` (all five opcodes present), `src/script/gen2/Specials.lua:1301` | implemented | +| Route 43 gate toll scene (`sdefer`, `readvar VAR_FACING`, `big_step` movement, `special RestartMapMusic`, `setscene`) | `src/script/gen2/Vm.lua`, `src/script/gen2/Specials.lua:1059` | implemented | +| Magikarp length rater (`special CheckMagikarpLength`, `MagikarpHouseSign`, `FindPartyMonThatSpecies`) | `src/script/gen2/Specials.lua:409,443,1124` | implemented (though the contest arm is gated behind `EVENT_CLEARED_ROCKET_HIDEOUT`, i.e. the next section) | +| Red Gyarados encounter plumbing (`loadwildmon`, `cry`, `startbattle`, `giveitem`, `itemnotify`, `appear`/`disappear`) | `src/script/gen2/Vm.lua`, `src/world/gen2/World.lua:4420` | implemented | +| **`BATTLETYPE_FORCESHINY` actually making the Red Gyarados shiny** | `src/world/gen2/World.lua:4591` sets `opts.battleType`, but `World:startBattle` (`:4420`) never reads it, and the wild mon is built by `Mon.new(data, id, wild.level or 5)` at `:4572` with `Mon.randomDVs()` | **missing** - the Lake of Rage Gyarados will be an ordinary blue L30 Gyarados except by 1-in-8192 luck. `World.lua:4576` handles `BATTLETYPE_FORCEITEM` only. The shiny *rendering* path is proven by `tests/drivers/gold_shiny_shots.lua`, which builds the mon with the `$EA`/`$AA` DVs by hand; nothing wires those DVs to the script opcode | +| `teleport_from` (`$4c`) - Lance's warp-into-the-sky animation | `src/script/gen2/Movement.lua` `decodeByte` has no case for `$4c`; it falls through to `{ kind = "nop" }` | partial - the script still runs and Lance still `disappear`s, so the flags are correct; only the animation is absent | +| Mahogany Mart `pokemart MARTTYPE_STANDARD, MART_MAHOGANY_1` | `src/ui/gen2/MartMenu.lua` + the extracted mart tables | implemented (not exercised by any `gold_*` driver for this map) | +| Any driver that plays this stretch | `tests/drivers/` has no `gold_lake_of_rage.lua` / `gold_route43.lua` | **missing** - no scripted run covers section 10 | + +--- + +## 6. Unresolved / verify by hand + +1. **"TM43 Secret Power"** - the walkthrough uses the HGSS move name. In + Gold/Silver `LakeOfRageTMDetect` is `itemball TM_DETECT`, and the `add_tm` + ladder in `constants/item_constants.asm:264` puts `DETECT` at TM43. The item + is right, the move name in the FAQ is wrong. +2. **"Items found in Mahogany Town: TM16"** - there is no TM16 anywhere in + `maps/MahoganyTown.asm`. TM16 is `ICY_WIND` (`constants/item_constants.asm:234`), + which is Pryce's Gym reward - a different section, and unreachable here + because `MAHOGANYTOWN_FISHER` blocks the Gym door. +3. **Camper Spencer's party** - the walkthrough says L17 Sandshrew / **L18** + Sandslash / L19 Zubat. `data/trainers/parties.asm:2817` says L17 `SANDSHREW`, + **L17** `SANDSLASH`, L19 `ZUBAT`. Trust the asm. +4. **"Wesley ... on the left path of Route 43"** - Wesley is not on Route 43 at + all. `object_event 4, 4, SPRITE_SUPER_NERD ... WesleyScript` is on + `MAP_LAKE_OF_RAGE`, in the far northwest corner behind the cut trees. +5. **"Max Ether ... to the right of Picnicker Tiffany"** - the ball is at + (12,32) and Tiffany is at (9,29), so it is right *and* three rows south of + her. Close but not the same row. +6. **Where the Cut trees are on Route 42 and Lake of Rage** - not in any event + table. They are block ids inside `maps/Route42.blk` and `maps/LakeOfRage.blk`, + read by `CheckMapForSomethingToCut` (`engine/events/overworld.asm`, called from + `CutFunction.CheckAble`). A bot needs the block data, not the asm, to know + which tiles to cut. Same for the exact water tiles a Surf entry point needs + on Route 42 and Lake of Rage, and for the walkthrough's claim that Route 43's + west grass path bypasses `ROUTE_43_GATE`. +7. **"Your Mom calls you"** on Route 43 - this is the generic + `SPECIALCALL_*` / Mom-shopping call, not anything in `maps/Route43.asm`. + There is no `specialphonecall` in the Route 43 script. Which call fires and + when lives in `data/events/special_phone_calls.asm` and + `src/core/gen2/MomShopping.lua`; not pinned down for this section. +8. **Fisher Marvin's fourth mon EXP** - the walkthrough leaves the L15 Gyarados + EXP as "?". Nothing in the asm to resolve; it is a derived number. +9. **`LakeOfRage_MapScripts`'s two scene scripts are commented `; unusable`** and + `RedGyarados` does `setscene 0` with the comment "Lake of Rage does not have a + scene variable". Harmless on the cart, but a port that models `setscene` as a + write to a per-map byte should confirm it does not corrupt a neighbouring + map's scene. +10. **`Route43Gate` warp 4 (5,7) points at `ROUTE_43` warp 3**, the same as warp + 3 (4,7). Transcribed as written; if a bot walks out of the gate's + bottom-right tile it lands on the same Route 43 warp as the bottom-left one. +11. **Gyarados loss path** - `RedGyarados`'s `ifequal LOSE, .NotBeaten` only + skips the `disappear`; the `giveitem RED_SCALE` and `appear LAKEOFRAGE_LANCE` + run either way. That reads like a cart quirk rather than a transcription + error, but it has not been observed on hardware for this document. diff --git a/docs/gold-walkthrough/asm-walk/section-11-team-rocket-hideout-and-mahogany-town-gym.md b/docs/gold-walkthrough/asm-walk/section-11-team-rocket-hideout-and-mahogany-town-gym.md new file mode 100644 index 00000000..57e640f7 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-11-team-rocket-hideout-and-mahogany-town-gym.md @@ -0,0 +1,851 @@ +# Section 11 - Team Rocket Hideout and Mahogany Town Gym + +Source: `../section-11-team-rocket-hideout-and-mahogany-town-gym.txt` +(the FAQ's own heading is "17 > Team Rocket Hideout and Mahogany Town Gym") + +Maps covered: `MAP_MAHOGANY_TOWN`, `MAP_MAHOGANY_POKECENTER_1F`, `MAP_MAHOGANY_MART_1F`, +`MAP_TEAM_ROCKET_BASE_B1F`, `MAP_TEAM_ROCKET_BASE_B2F`, `MAP_TEAM_ROCKET_BASE_B3F`, +`MAP_MAHOGANY_GYM` + +Badges / key milestones in this section: + +- `EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` - the hideout entrance exists +- `EVENT_LEARNED_SLOWPOKETAIL`, `EVENT_LEARNED_RATICATE_TAIL` - Giovanni's-office door +- `EVENT_LEARNED_HAIL_GIOVANNI` - transmitter-room door +- `EVENT_GOT_HM06_WHIRLPOOL` (HM06 = `HM_WHIRLPOOL`) +- `EVENT_CLEARED_ROCKET_HIDEOUT` + `clearflag ENGINE_ROCKET_SIGNAL_ON_CH20` +- `ENGINE_GLACIERBADGE` (GLACIERBADGE, badge 7) + `EVENT_GOT_TM16_ICY_WIND` (TM16 = `TM_ICY_WIND`) + +Entry precondition, set in the previous section: `maps/LakeOfRage.asm` +`LakeOfRageLanceScript.AgreedToHelp` runs +`clearevent EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE`, +`setevent EVENT_DECIDED_TO_HELP_LANCE`, +`setmapscene MAHOGANY_MART_1F, SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS`. +Without that scene set, the mart is an ordinary shop and there is no staircase. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_MAHOGANY_TOWN` | `maps/MahoganyTown.asm` | from Route 43 gate (warp 5, `9,1`) or fly point `ENGINE_FLYPOINT_MAHOGANY` | warp 4 `15,13` -> Pokecenter, warp 1 `11,7` -> Mart | "Heal at the Pokemon Center and withdraw your Gyarados" | +| 2 | `MAP_MAHOGANY_POKECENTER_1F` | `maps/MahoganyPokecenter1F.asm` | warp 1/2 `3,7` / `4,7` | same | heal / PC withdraw | +| 3 | `MAP_MAHOGANY_MART_1F` | `maps/MahoganyMart1F.asm` | warp 1/2 `3,7` / `4,7` | warp 3 `7,3` | Lance's Dragonite Hyper Beams the shop open, uncovers the staircase | +| 4 | `MAP_TEAM_ROCKET_BASE_B1F` | `maps/TeamRocketBaseB1F.asm` | warp 1 `27,2` | warp 2 `3,14` | security cameras, GruntM16, Scientist Jed, secret switch, 3 item balls | +| 5 | `MAP_TEAM_ROCKET_BASE_B2F` (bottom region) | `maps/TeamRocketBaseB2F.asm` | warp 1 `3,14` | warp 5 `27,14` | Lance heals you; GruntM19 then GruntM17 | +| 6 | `MAP_TEAM_ROCKET_BASE_B3F` (right region) | `maps/TeamRocketBaseB3F.asm` | warp 4 `27,14` | warp 2 `27,2` | Lance's two-password speech, 4 item balls, Ross / GruntF5 / Mitch / GruntM28, both passwords | +| 7 | `MAP_TEAM_ROCKET_BASE_B2F` (top region) | `maps/TeamRocketBaseB2F.asm` | warp 3 `27,2` | warp 2 `3,2` | walk left past GruntM18 | +| 8 | `MAP_TEAM_ROCKET_BASE_B3F` (left region) | `maps/TeamRocketBaseB3F.asm` | warp 1 `3,2` | warp 3 `3,6` | rival cutscene, password door, ExecutiveM 4, Murkrow gives HAIL GIOVANNI | +| 9 | `MAP_TEAM_ROCKET_BASE_B2F` (TM room) | `maps/TeamRocketBaseB2F.asm` | warp 4 `3,6` | warp 4 `3,6` | "the second stairs that you did NOT come out of" - TM46 Thief at `3,10` | +| 10 | back through 8 -> 7 -> 6 -> 5 | | | | "Head back the way you came, past the spinning Team Rocket member" | +| 11 | `MAP_TEAM_ROCKET_BASE_B2F` (transmitter room) | `maps/TeamRocketBaseB2F.asm` | password door at `14,12` / `15,12` | warp 1 `3,14` (then B1F warp panel `5,15`) | ExecutiveF 2 ambush, 3 Electrode, HM06 Whirlpool from Lance | +| 12 | `MAP_MAHOGANY_TOWN` | `maps/MahoganyTown.asm` | Mart warps 1/2 | warp 3 `6,13` | heal, then the gym | +| 13 | `MAP_MAHOGANY_GYM` | `maps/MahoganyGym.asm` | warp 1/2 `4,17` / `5,17` | same | five ice-puzzle trainers, then Pryce -> GLACIERBADGE + TM16 | + +Spill into the next section: the last paragraph ("You can now use Whirlpool, once you +find that in a dark cavern") points at `MAP_ICE_PATH_1F` / `MAP_ICE_PATH_B2F_MAHOGANY_SIDE`. +Not covered here. + +--- + +## 2. Maps + +### MAP_MAHOGANY_TOWN + +- Script: `maps/MahoganyTown.asm` +- Blocks: `maps/MahoganyTown.blk` +- Header: `data/maps/maps.asm:74` -> `map MahoganyTown, TILESET_JOHTO, TOWN, LANDMARK_MAHOGANY_TOWN, MUSIC_CHERRYGROVE_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm` -> `map_const MAHOGANY_TOWN, 10, 9` (group `MAHOGANY` = 2, map id 7); 10x9 blocks = 20x18 map cells +- Connections: `data/maps/attributes.asm:152-155` -> north `Route43`, west `Route42`, east `Route44` +- Scene ids (declared inline by `scene_script`): `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` = 0, `SCENE_MAHOGANYTOWN_NOOP` = 1 +- Callback: `MAPCALLBACK_NEWMAP, MahoganyTownFlypointCallback` -> `setflag ENGINE_FLYPOINT_MAHOGANY` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 7 | `MAHOGANY_MART_1F` | 1 | +| 2 | 17 | 7 | `MAHOGANY_RED_GYARADOS_SPEECH_HOUSE` | 1 | +| 3 | 6 | 13 | `MAHOGANY_GYM` | 1 | +| 4 | 15 | 13 | `MAHOGANY_POKECENTER_1F` | 1 | +| 5 | 9 | 1 | `ROUTE_43_MAHOGANY_GATE` | 3 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` (0) | 19 | 8 | `MahoganyTownTryARageCandyBarScript` | merchant steps in front of you and pitches a RAGECANDYBAR | +| `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` (0) | 19 | 9 | `MahoganyTownTryARageCandyBarScript` | same | + +The script never calls `setscene`, so the pitch re-fires every time you cross x=19 +until `RadioTowerRocketsScript` (`engine/events/std_scripts.asm:263`) runs +`setmapscene MAHOGANY_TOWN, SCENE_MAHOGANYTOWN_NOOP`. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 5 | `BGEVENT_READ` | `MahoganyTownSign` | +| 9 | 7 | `BGEVENT_READ` | `MahoganyTownRagecandybarSign` | +| 3 | 13 | `BGEVENT_READ` | `MahoganyGymSign` | +| 16 | 13 | `BGEVENT_READ` | `MahoganyTownPokecenterSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MAHOGANYTOWN_POKEFAN_M` | `SPRITE_POKEFAN_M` | 19 | 8 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownPokefanMScript` | `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST` | +| `MAHOGANYTOWN_GRAMPS` | `SPRITE_GRAMPS` | 6 | 9 | `WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | `MahoganyTownGrampsScript` | -1 | +| `MAHOGANYTOWN_FISHER` | `SPRITE_FISHER` | 6 | 14 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownFisherScript` | `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` | +| `MAHOGANYTOWN_LASS` | `SPRITE_LASS` | 12 | 8 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyTownLassScript` | `EVENT_MAHOGANY_MART_OWNERS` | + +Object-event flag polarity (verified in `engine/overworld/map_objects_2.asm` +`CheckObjectFlag`): the flag being **set** masks (hides) the object; `-1` means +always visible. So `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` clear = the fisher +stands on `6,14`, one cell south of the gym warp at `6,13`, blocking it. That flag +is set by `RocketBaseElectrodeScript` at the end of the hideout, which is the real +gate on the Mahogany gym (see section 3). + +### MAP_MAHOGANY_POKECENTER_1F + +- Script: `maps/MahoganyPokecenter1F.asm` +- Header: `data/maps/maps.asm:70` -> `TILESET_POKECENTER, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const MAHOGANY_POKECENTER_1F, 5, 4` (group 2, map id 3) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `MAHOGANY_TOWN` | 4 | +| 2 | 4 | 7 | `MAHOGANY_TOWN` | 4 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +No coord events, no bg events. Nurse is `MAHOGANYPOKECENTER1F_NURSE` +`SPRITE_NURSE` at `3,1`, script `MahoganyPokecenter1FNurseScript` -> +`jumpstd PokecenterNurseScript`. + +### MAP_MAHOGANY_MART_1F + +- Script: `maps/MahoganyMart1F.asm` (`45:4000 MahoganyMart1F_MapScripts`) +- Blocks: shares `maps/GiftShop.blk` (`data/maps/blocks.asm:700` -> `MahoganyMart1F_Blocks: MountMoonGiftShop_Blocks: INCBIN "maps/GiftShop.blk"`) +- Header: `data/maps/maps.asm:118` -> `TILESET_TRADITIONAL_HOUSE, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_MAHOGANY_MART, TRUE (no phone), PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const MAHOGANY_MART_1F, 4, 4` (group `DUNGEONS` = 3, map id 40) +- Scene ids: `SCENE_MAHOGANYMART1F_NOOP` = 0, `SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` = 1 +- Callback: `MAPCALLBACK_TILES, MahoganyMart1FStaircaseCallback` - if `EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` is set, `changeblock 6, 2, $1e ; stairs` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `MAHOGANY_TOWN` | 1 | +| 2 | 4 | 7 | `MAHOGANY_TOWN` | 1 | +| 3 | 7 | 3 | `TEAM_ROCKET_BASE_B1F` | 1 | + +`def_coord_events` and `def_bg_events` are both empty. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MAHOGANYMART1F_PHARMACIST` | `SPRITE_PHARMACIST` | 4 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FPharmacistScript` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `MAHOGANYMART1F_BLACK_BELT` | `SPRITE_BLACK_BELT` | 1 | 6 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FBlackBeltScript` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `MAHOGANYMART1F_LANCE` | `SPRITE_LANCE` | 4 | 6 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` | +| `MAHOGANYMART1F_DRAGONITE` | `SPRITE_DRAGON` | 3 | 6 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` | +| `MAHOGANYMART1F_GRANNY` | `SPRITE_GRANNY` | 1 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `MahoganyMart1FGrannyScript` | `EVENT_MAHOGANY_MART_OWNERS` | + +**Scripts of interest** + +`MahoganyMart1FLanceUncoversStaircaseScript` (`45:4046`), reached as +`scene_script ... SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` via `sdefer`, so it +fires on map entry without any player input: + +1. Dragonite `big_step LEFT / big_step RIGHT` into the Black Belt, `disappear MAHOGANYMART1F_DRAGONITE` +2. Lance walks over (`MahoganyMart1FLanceApproachPlayerMovement`), `follow MAHOGANYMART1F_LANCE, PLAYER`, shoves the pharmacist aside +3. `changeblock 6, 2, $1e` + `refreshmap` + `setevent EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` +4. `disappear MAHOGANYMART1F_LANCE`, `setscene SCENE_MAHOGANYMART1F_NOOP` + +`changeblock`'s operands are **map cell coordinates**, not block coordinates +(`Script_changeblock` at `engine/overworld/scripting.asm:2031` adds 4 to each and +calls `GetBlockLocation`, which halves them - `home/map.asm:2099`). `6,2` is +therefore block (3,1), covering cells (6..7, 2..3); block `$1e` in +`data/tilesets/traditional_house_collision.asm` is `FLOOR, FLOOR, FLOOR, LADDER`, +putting the LADDER on cell `7,3` - exactly the warp row above. + +Marts: `MahoganyMart1FPharmacistScript` -> `pokemart MARTTYPE_STANDARD, MART_MAHOGANY_1` +(closed once `EVENT_DECIDED_TO_HELP_LANCE` is set), +`MahoganyMart1FGrannyScript` -> `pokemart MARTTYPE_STANDARD, MART_MAHOGANY_2`. + +### MAP_TEAM_ROCKET_BASE_B1F + +- Script: `maps/TeamRocketBaseB1F.asm` (`45:430c TeamRocketBaseB1F_MapScripts`) +- Blocks: `maps/TeamRocketBaseB1F.blk` +- Header: `data/maps/maps.asm:119` -> `TILESET_UNDERGROUND, DUNGEON, LANDMARK_MAHOGANY_TOWN, MUSIC_ROCKET_HIDEOUT, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const TEAM_ROCKET_BASE_B1F, 15, 9` (group 3, map id 41) = 30x18 cells +- Scene ids: `SCENE_TEAMROCKETBASEB1F_TRAPS` = 0 (the only one) +- Callback: `MAPCALLBACK_OBJECTS, TeamRocketBaseB1FHideSecurityGruntCallback` -> `disappear TEAMROCKETBASEB1F_ROCKET1` (the camera grunt is parked off-screen at `0,0` and teleported in by `moveobject`) + +The whole floor is one connected walkable region (flood-filled from +`maps/TeamRocketBaseB1F.blk` against `data/tilesets/underground_collision.asm`). + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 27 | 2 | `MAHOGANY_MART_1F` | 3 | +| 2 | 3 | 14 | `TEAM_ROCKET_BASE_B2F` | 1 | +| 3 | 5 | 15 | `TEAM_ROCKET_BASE_B1F` | 4 | +| 4 | 25 | 2 | `TEAM_ROCKET_BASE_B1F` | 3 | + +Warps 3 and 4 are the warp panel pair Scientist Jed describes: cell `5,15` has +collision `WARP_PANEL` and dumps you at `25,2`, two cells from the exit ladder. + +**Coord events** (`def_coord_events`) - all on scene `SCENE_TEAMROCKETBASEB1F_TRAPS` (0) + +Security cameras (each triggers `GRUNTM_20` then `GRUNTM_21`): + +| x | y | script label | guarding statue (bg_event) | +|---|---|---|---| +| 24 | 2 | `SecurityCamera1a` | `24,1` | +| 24 | 3 | `SecurityCamera1b` | `24,1` | +| 6 | 2 | `SecurityCamera2a` | `6,1` | +| 6 | 3 | `SecurityCamera2b` | `6,1` | +| 24 | 6 | `SecurityCamera3a` | `24,5` | +| 24 | 7 | `SecurityCamera3b` | `24,5` | +| 22 | 16 | `SecurityCamera4` | `22,15` | +| 8 | 16 | `SecurityCamera5` | `8,15` | + +Exploding-trap tiles (each is a one-shot wild battle with +`loadvar VAR_BATTLETYPE, BATTLETYPE_TRAP`): + +| x | y | script | mon | +|---|---|---|---| +| 2 | 7 | `ExplodingTrap1` | `KoffingExplodingTrap` - KOFFING L21 | +| 3 | 7 | `ExplodingTrap2` | `VoltorbExplodingTrap` - VOLTORB L23 | +| 4 | 7 | `ExplodingTrap3` | `GeodudeExplodingTrap` - GEODUDE L21 | +| 1 | 8 | `ExplodingTrap4` | VOLTORB L23 | +| 3 | 8 | `ExplodingTrap5` | GEODUDE L21 | +| 5 | 8 | `ExplodingTrap6` | KOFFING L21 | +| 3 | 9 | `ExplodingTrap7` | VOLTORB L23 | +| 4 | 9 | `ExplodingTrap8` | KOFFING L21 | +| 1 | 10 | `ExplodingTrap9` | KOFFING L21 | +| 2 | 10 | `ExplodingTrap10` | VOLTORB L23 | +| 3 | 10 | `ExplodingTrap11` | GEODUDE L21 | +| 5 | 10 | `ExplodingTrap12` | GEODUDE L21 | +| 2 | 11 | `ExplodingTrap13` | GEODUDE L21 | +| 4 | 11 | `ExplodingTrap14` | KOFFING L21 | +| 1 | 12 | `ExplodingTrap15` | VOLTORB L23 | +| 2 | 12 | `ExplodingTrap16` | KOFFING L21 | +| 4 | 12 | `ExplodingTrap17` | VOLTORB L23 | +| 5 | 12 | `ExplodingTrap18` | GEODUDE L21 | +| 1 | 13 | `ExplodingTrap19` | GEODUDE L21 | +| 3 | 13 | `ExplodingTrap20` | VOLTORB L23 | +| 4 | 13 | `ExplodingTrap21` | KOFFING L21 | +| 5 | 13 | `ExplodingTrap22` | VOLTORB L23 | + +Safe cells inside the 5x7 trap field (x 1..5, y 7..13) are exactly those with no +row above: `(1,7)`, `(5,7)`, `(2,8)`, `(4,8)`, `(1,9)`, `(2,9)`, `(5,9)`, `(4,10)`, +`(1,11)`, `(3,11)`, `(5,11)`, `(3,12)`, `(2,13)`. `(3,11)` is also the hidden REVIVE. +That is the "zig-zag" the walkthrough describes. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 19 | 11 | `BGEVENT_READ` | `TeamRocketBaseB1FSecretSwitch` | +| 24 | 1 | `BGEVENT_UP` | `TeamRocketBaseB1FSecurityCamera` | +| 6 | 1 | `BGEVENT_UP` | `TeamRocketBaseB1FSecurityCamera` | +| 8 | 15 | `BGEVENT_UP` | `TeamRocketBaseB1FSecurityCamera` | +| 22 | 15 | `BGEVENT_UP` | `TeamRocketBaseB1FSecurityCamera` | +| 24 | 5 | `BGEVENT_UP` | `TeamRocketBaseB1FSecurityCamera` | +| 20 | 11 | `BGEVENT_READ` | `TeamRocketBaseB1FBookshelf` (`jumpstd TeamRocketOathScript`) | +| 21 | 11 | `BGEVENT_READ` | `TeamRocketBaseB1FBookshelf` | +| 3 | 11 | `BGEVENT_ITEM` | `TeamRocketBaseB1FHiddenRevive` -> `hiddenitem REVIVE, EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `TEAMROCKETBASEB1F_ROCKET1` | `SPRITE_ROCKET` | 0 | 0 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_SECURITY_GRUNTS` | +| - | `SPRITE_ROCKET` | 2 | 4 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerGruntM16` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| - | `SPRITE_SCIENTIST` | 18 | 12 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerScientistJed` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 27 | 6 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `TeamRocketBaseB1FHyperPotion` | `EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION` | +| `TEAMROCKETBASEB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 14 | 15 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `TeamRocketBaseB1FNugget` | `EVENT_TEAM_ROCKET_BASE_B1F_NUGGET` | +| `TEAMROCKETBASEB1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 21 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `TeamRocketBaseB1FXAccuracy` | `EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY` | + +**Scripts of interest** + +- `SecurityCamera1a` .. `SecurityCamera5`: `checkevent EVENT_SECURITY_CAMERA_n` -> bail; + `PlaySecurityCameraSounds` (6x `SFX_LICK`); `checkevent EVENT_TEAM_ROCKET_BASE_POPULATION` + -> bail (never true, see below); `showemote EMOTE_SHOCK, PLAYER`; + `playmusic MUSIC_ROCKET_ENCOUNTER`; `moveobject TEAMROCKETBASEB1F_ROCKET1, <x>, <y>`; + `appear`; `applymovement SecurityCameraMovement*`; `scall TrainerCameraGrunt1`; + repeat for `TrainerCameraGrunt2`; `setevent EVENT_SECURITY_CAMERA_n`. + Both grunt scripts use `loadtrainer` + `startbattle` directly (no `trainer` macro, + so no per-trainer beat flag) - the same two teams are re-fought at every camera. +- `TeamRocketBaseB1FSecretSwitch` (`45:4757`): if `EVENT_TURNED_OFF_SECURITY_CAMERAS` + is already set, prints "the switch is turned off"; otherwise + `playsound SFX_TALLY` and sets + `EVENT_TURNED_OFF_SECURITY_CAMERAS` + `EVENT_SECURITY_CAMERA_1..5` in one go. + Cell `19,11` is a wall, read from `19,12` facing up. +- `TrainerScientistJed` -> `.Script` after-battle text tells you about the warp panel. +- `TrainerGruntM16` -> after-battle text about the traps. + +**Flags and events** (B1F) + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_SECURITY_CAMERA_1` .. `_5` | `constants/event_flags.asm:403-407` | set by each camera script and by the switch | camera n is spent | +| `EVENT_TURNED_OFF_SECURITY_CAMERAS` | `event_flags.asm:402` | `TeamRocketBaseB1FSecretSwitch`, `RocketBaseElectrodeScript` | switch state only | +| `EVENT_TEAM_ROCKET_BASE_SECURITY_GRUNTS` | `event_flags.asm:1147` | `disappear`/`appear` of `TEAMROCKETBASEB1F_ROCKET1` | ambush grunt visibility | +| `EVENT_TEAM_ROCKET_BASE_POPULATION` | `event_flags.asm:1148` | read by 8 camera scripts, shared visibility flag of 11 base NPCs | **never set anywhere in the ROM** - grep confirms no `setevent`/`disappear` on it, so those `checkevent` arms are dead and the base NPCs never vanish | +| `EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION` / `_NUGGET` / `_X_ACCURACY` | `event_flags.asm:1034-1036` | `itemball` | item ball taken | +| `EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE` | `event_flags.asm:144` | `hiddenitem` | hidden REVIVE at `3,11` taken | +| `EVENT_EXPLODING_TRAP_1..22` | `constants/event_flags.asm` | each `ExplodingTrapN` | trap spent | + +**Items** (B1F) + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HYPER_POTION` | item ball at `27,6` | `TeamRocketBaseB1FHyperPotion` | `EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION` | +| `NUGGET` | item ball at `14,15` | `TeamRocketBaseB1FNugget` | `EVENT_TEAM_ROCKET_BASE_B1F_NUGGET` | +| `X_ACCURACY` | item ball at `21,12` | `TeamRocketBaseB1FXAccuracy` | `EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY` | +| `REVIVE` | hidden, face cell `3,11` | `bg_event 3, 11, BGEVENT_ITEM` | `EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE` | + +**Trainers** (B1F) + +| const | class | id | party (`data/trainers/parties.asm`) | script label | notes | +|---|---|---|---|---|---| +| `GRUNTM`, `GRUNTM_16` | `GRUNTM` (`$1f`) | 16 | RATTATA 16, RATTATA 16, RATTATA 16, RATTATA 16 | `TrainerGruntM16` | `EVENT_BEAT_ROCKET_GRUNTM_16`, sight 3 | +| `SCIENTIST`, `JED` | `SCIENTIST` (`$0e`) | 3 | MAGNEMITE 20 x3 | `TrainerScientistJed` | `EVENT_BEAT_SCIENTIST_JED`, sight 3 | +| `GRUNTM`, `GRUNTM_20` | `GRUNTM` | 20 | DROWZEE 17, ZUBAT 19 | `TrainerCameraGrunt1` | no beat flag, repeats per camera | +| `GRUNTM`, `GRUNTM_21` | `GRUNTM` | 21 | ZUBAT 16, GRIMER 17, RATTATA 18 | `TrainerCameraGrunt2` | no beat flag, repeats per camera | + +**Wild encounters**: none. `TEAM_ROCKET_BASE_B1F` has no entry in +`data/wild/johto_grass.asm`, `johto_water.asm`, `fish.asm` or `treemons.asm`. The +only wild battles are the `loadwildmon` traps above and the three Electrode on B2F. + +### MAP_TEAM_ROCKET_BASE_B2F + +- Script: `maps/TeamRocketBaseB2F.asm` (`45:4c2b TeamRocketBaseB2F_MapScripts`) +- Blocks: `maps/TeamRocketBaseB2F.blk` +- Header: `data/maps/maps.asm:120` -> `TILESET_FACILITY, DUNGEON, LANDMARK_MAHOGANY_TOWN, MUSIC_ROCKET_HIDEOUT, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const TEAM_ROCKET_BASE_B2F, 15, 9` (group 3, map id 42) = 30x18 cells +- Scene ids: `SCENE_TEAMROCKETBASEB2F_LANCE_HEALS` = 0, `..._ROCKET_BOSS` = 1, `..._ELECTRODES` = 2, `..._NOOP` = 3 +- Callback: `MAPCALLBACK_TILES, TeamRocketBaseB2FTransmitterDoorCallback` - if `EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER`, `changeblock 14, 12, $07 ; floor` + +Walkable regions (flood fill of `maps/TeamRocketBaseB2F.blk` against +`data/tilesets/facility_collision.asm`) - this floor is four disconnected rooms: + +| region | contents | +|---|---| +| top corridor | warp 2 `3,2`, warp 3 `27,2`, GruntM18 `2,1` | +| transmitter room | boss coord `14,11`/`15,11`, Electrodes `7,5`/`7,7`/`7,9` and `22,5`/`22,7`/`22,9`; sealed until the password door opens | +| TM room | warp 4 `3,6`, TM_THIEF item ball `3,10` | +| bottom corridor | warp 1 `3,14` (to B1F), warp 5 `27,14`, Lance heal coords, GruntM19 `21,14`, GruntM17 `25,13` | + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 14 | `TEAM_ROCKET_BASE_B1F` | 2 | +| 2 | 3 | 2 | `TEAM_ROCKET_BASE_B3F` | 1 | +| 3 | 27 | 2 | `TEAM_ROCKET_BASE_B3F` | 2 | +| 4 | 3 | 6 | `TEAM_ROCKET_BASE_B3F` | 3 | +| 5 | 27 | 14 | `TEAM_ROCKET_BASE_B3F` | 4 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `LANCE_HEALS` (0) | 5 | 14 | `LanceHealsScript1` | face UP, then `LanceHealsCommon` | +| `LANCE_HEALS` (0) | 4 | 13 | `LanceHealsScript2` | face RIGHT, then `LanceHealsCommon` | +| `ROCKET_BOSS` (1) | 14 | 11 | `RocketBaseBossFLeft` | Executive F ambush (Lance spawns at `9,13`) | +| `ROCKET_BOSS` (1) | 15 | 11 | `RocketBaseBossFRight` | same, actors nudged one cell right | +| `ELECTRODES` (2) | 14 | 12 | `RocketBaseCantLeaveScript` | shoves you back into the room | +| `ELECTRODES` (2) | 15 | 12 | `RocketBaseCantLeaveScript` | same | +| `ELECTRODES` (2) | 12 | 3 | `RocketBaseLancesSideScript` | "Leave this side to me", step LEFT | +| `ELECTRODES` (2) | 12 | 10 | `RocketBaseLancesSideScript` | same | +| `ELECTRODES` (2) | 12 | 11 | `RocketBaseLancesSideScript` | same | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 14 | 12 | `BGEVENT_IFNOTSET` | `conditional_event EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER, TeamRocketBaseB2FLockedDoor.Script` | +| 15 | 12 | `BGEVENT_IFNOTSET` | same | +| 12..17, 4 and 9; 12, 5..8; 17, 5..8 (20 cells) | | `BGEVENT_READ` | `TeamRocketBaseB2FTransmitterScript` | +| 26 | 7 | `BGEVENT_ITEM` | `TeamRocketBaseB2FHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_TEAM_ROCKET_BASE_B2F_HIDDEN_FULL_HEAL` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `TEAMROCKETBASEB2F_ROCKET1` | `SPRITE_ROCKET` | 20 | 16 | `STANDING_UP` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_GRUNT_WITH_EXECUTIVE` | +| `TEAMROCKETBASEB2F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 20 | 16 | `STANDING_UP` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_EXECUTIVE` | +| `TEAMROCKETBASEB2F_LANCE` | `SPRITE_LANCE` | 5 | 13 | `STANDING_DOWN` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_LANCE` | +| `TEAMROCKETBASEB2F_DRAGON` | `SPRITE_DRAGON` | 9 | 13 | `STANDING_RIGHT` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_DRAGONITE` | +| `TEAMROCKETBASEB2F_ELECTRODE1` | `SPRITE_VOLTORB` | 7 | 5 | `POKEMON` | `SCRIPT` | 0 | `RocketElectrode1` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_1` | +| `TEAMROCKETBASEB2F_ELECTRODE2` | `SPRITE_VOLTORB` | 7 | 7 | `POKEMON` | `SCRIPT` | 0 | `RocketElectrode2` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_2` | +| `TEAMROCKETBASEB2F_ELECTRODE3` | `SPRITE_VOLTORB` | 7 | 9 | `POKEMON` | `SCRIPT` | 0 | `RocketElectrode3` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_3` | +| `TEAMROCKETBASEB2F_ELECTRODE4` | `SPRITE_VOLTORB` | 22 | 5 | `POKEMON` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_1` | +| `TEAMROCKETBASEB2F_ELECTRODE5` | `SPRITE_VOLTORB` | 22 | 7 | `POKEMON` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_2` | +| `TEAMROCKETBASEB2F_ELECTRODE6` | `SPRITE_VOLTORB` | 22 | 9 | `POKEMON` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_3` | +| `TEAMROCKETBASEB2F_ROCKET2` | `SPRITE_ROCKET` | 25 | 13 | `STANDING_DOWN` | `TRAINER` | 3 | `TrainerGruntM17` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB2F_ROCKET3` | `SPRITE_ROCKET` | 2 | 1 | `STANDING_RIGHT` | `TRAINER` | 3 | `TrainerGruntM18` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB2F_ROCKET4` | `SPRITE_ROCKET` | 21 | 14 | `STANDING_LEFT` | `TRAINER` | 4 | `TrainerGruntM19` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB2F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 10 | `STILL` | `ITEMBALL` | 0 | `TeamRocketBaseB2FTMThief` | `EVENT_TEAM_ROCKET_BASE_B2F_TM_THIEF` | + +The Electrodes on the right (`22,5/7/9`) mirror the left three and share their flags; +`RocketElectrodeN` disappears both of a pair, so Lance's three "count" without ever +being battled. + +**Scripts of interest** + +- `LanceHealsCommon` (`45:4d4c`): `special FadeOutToWhite`, `playsound SFX_FULL_HEAL`, + `special HealParty`, `special FadeInFromWhite`, + `setscene SCENE_TEAMROCKETBASEB2F_ROCKET_BOSS`, + `setevent EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE`, then `readvar VAR_FACING` + picks one of two exit movements and `disappear TEAMROCKETBASEB2F_LANCE`. + **This is a hard ordering dependency**: the boss ambush coord events only exist + on scene 1, so the transmitter room cannot be entered until Lance has healed you. +- `TeamRocketBaseB2FLockedDoor` (`45:4e8f`): `conditional_event` on + `EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER`; `.Script` checks + `EVENT_LEARNED_HAIL_GIOVANNI`. If known: `playsound SFX_ENTER_DOOR`, + `changeblock 14, 12, $07`, `refreshmap`, `setevent EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER`. +- `RocketBaseBossFScript` (`45:4c67`): cutscene, then + `loadtrainer EXECUTIVEF, EXECUTIVEF_2` + `startbattle`; + sets `EVENT_TEAM_ROCKET_BASE_B2F_EXECUTIVE`, `..._GRUNT_WITH_EXECUTIVE`, + `..._LANCE`, `EVENT_BEAT_ROCKET_EXECUTIVEF_2`; then + `setscene SCENE_TEAMROCKETBASEB2F_ELECTRODES`, `clearevent EVENT_TEAM_ROCKET_BASE_B2F_LANCE` + (re-showing Lance), the Electrode briefing, `disappear TEAMROCKETBASEB2F_LANCE`. +- `RocketElectrode1/2/3`: `cry ELECTRODE`, `loadwildmon ELECTRODE, 23`, `startbattle`. + `iftrue TeamRocketBaseB2FReloadMap` covers the "you lost/fled" path. After all three + events are set, the player is auto-walked out by + `RocketBasePlayerLeavesElectrodesMovement1/2/3` into `RocketBaseElectrodeScript`. + No `BATTLETYPE_*` is armed here, so these are catchable ordinary wild battles. +- `RocketBaseElectrodeScript` (`45:4e3c`) - the section's payoff: + `verbosegiveitem HM_WHIRLPOOL`, `setevent EVENT_GOT_HM06_WHIRLPOOL`, then + `setevent EVENT_CLEARED_ROCKET_HIDEOUT`, + `clearflag ENGINE_ROCKET_SIGNAL_ON_CH20`, + `setevent EVENT_ROUTE_43_GATE_ROCKETS`, + `setevent EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` (hides the fisher -> gym opens), + `setscene SCENE_TEAMROCKETBASEB2F_NOOP`, + `clearevent EVENT_LAKE_OF_RAGE_CIVILIANS`, + `setevent EVENT_TURNED_OFF_SECURITY_CAMERAS` + `EVENT_SECURITY_CAMERA_1..5`. + +**Items** (B2F) + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_THIEF` (TM46) | item ball at `3,10` (TM room) | `TeamRocketBaseB2FTMThief` | `EVENT_TEAM_ROCKET_BASE_B2F_TM_THIEF` | +| `FULL_HEAL` | hidden, face cell `26,7` | `bg_event 26, 7, BGEVENT_ITEM` | `EVENT_TEAM_ROCKET_BASE_B2F_HIDDEN_FULL_HEAL` | +| `HM_WHIRLPOOL` (HM06) | `verbosegiveitem` from Lance | `RocketBaseElectrodeScript` | `EVENT_GOT_HM06_WHIRLPOOL` | + +TM numbering check: `constants/item_constants.asm` `add_tm THIEF ; ee` is the 46th +`add_tm` (the `const ITEM_C3` / `const ITEM_DC` holes do not consume TM numbers), so +TM46 = Thief, matching the walkthrough. `add_tm ICY_WIND ; cf` is the 16th = TM16. + +**Trainers** (B2F) + +| const | class | id | party | script label | sight | +|---|---|---|---|---|---| +| `GRUNTM_17` | `GRUNTM` | 17 | GOLBAT 18 | `TrainerGruntM17` | 3 | +| `GRUNTM_18` | `GRUNTM` | 18 | RATTATA 17, ZUBAT 17, RATTATA 17 | `TrainerGruntM18` | 3 | +| `GRUNTM_19` | `GRUNTM` | 19 | VENONAT 18, VENONAT 18 | `TrainerGruntM19` | 4 | +| `EXECUTIVEF_2` | `EXECUTIVEF` (`$37`) | 2 | ARBOK 23 (WRAP/LEER/POISON_STING/BITE), GLOOM 23 (ABSORB/SWEET_SCENT/SLEEP_POWDER/ACID), MURKROW 25 (PECK/PURSUIT/HAZE/-) | `RocketBaseBossFScript` | cutscene | + +**Wild encounters**: none in the tables; only `loadwildmon ELECTRODE, 23` x3. + +### MAP_TEAM_ROCKET_BASE_B3F + +- Script: `maps/TeamRocketBaseB3F.asm` (`45:5c8c TeamRocketBaseB3F_MapScripts`) +- Blocks: `maps/TeamRocketBaseB3F.blk` +- Header: `data/maps/maps.asm:121` -> `TILESET_FACILITY, DUNGEON, LANDMARK_MAHOGANY_TOWN, MUSIC_ROCKET_HIDEOUT, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const TEAM_ROCKET_BASE_B3F, 15, 9` (group 3, map id 43) = 30x18 cells +- Scene ids: `SCENE_TEAMROCKETBASEB3F_LANCE_GETS_PASSWORD` = 0, `..._RIVAL_ENCOUNTER` = 1, `..._ROCKET_BOSS` = 2, `..._NOOP` = 3 +- Callback: `MAPCALLBACK_TILES, TeamRocketBaseB3FCheckGiovanniDoorCallback` - if `EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE`, `changeblock 10, 8, $07 ; floor` + +Walkable regions (same flood-fill method): + +| region | contents | +|---|---| +| left | warp 1 `3,2`, warp 3 `3,6`, rival coord `8,10` | +| Giovanni's office | boss coords `10,8`/`11,8`, ExecutiveM `8,3`, Murkrow `7,2`; sealed until both passwords | +| right / main | warp 2 `27,2`, warp 4 `27,14`, Lance `25,14`, GruntF5 `21,7`, GruntM28 `5,15`, Ross `25,12`, Mitch `14,15`, all four item balls | + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 2 | `TEAM_ROCKET_BASE_B2F` | 2 | +| 2 | 27 | 2 | `TEAM_ROCKET_BASE_B2F` | 3 | +| 3 | 3 | 6 | `TEAM_ROCKET_BASE_B2F` | 4 | +| 4 | 27 | 14 | `TEAM_ROCKET_BASE_B2F` | 5 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `ROCKET_BOSS` (2) | 10 | 8 | `RocketBaseBossLeft` | auto-walk `UP UP UP LEFT LEFT`, then `RocketBaseBoss` | +| `ROCKET_BOSS` (2) | 11 | 8 | `RocketBaseBossRight` | auto-walk `UP UP LEFT UP LEFT LEFT`, then `RocketBaseBoss` | +| `RIVAL_ENCOUNTER` (1) | 8 | 10 | `RocketBaseRival` | rival cutscene, no battle | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 10 | 9 | `BGEVENT_IFNOTSET` | `conditional_event EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE, TeamRocketBaseB3FLockedDoor.Script` | +| 11 | 9 | `BGEVENT_IFNOTSET` | same | +| 10..13, 1 | | `BGEVENT_READ` | `TeamRocketBaseB3FOathScript` (`jumpstd TeamRocketOathScript`) | +| 4..7, 13 | | `BGEVENT_READ` | `TeamRocketBaseB3FOathScript` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `TEAMROCKETBASEB3F_LANCE` | `SPRITE_LANCE` | 25 | 14 | `STANDING_DOWN` | `SCRIPT` | 0 | `LanceGetPasswordScript` | `EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS` | +| `TEAMROCKETBASEB3F_ROCKET1` | `SPRITE_ROCKET` | 8 | 3 | `STANDING_UP` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_TEAM_ROCKET_BASE_B3F_EXECUTIVE` | +| `TEAMROCKETBASEB3F_MOLTRES` | `SPRITE_MOLTRES` | 7 | 2 | `POKEMON` | `SCRIPT` | 0 | `RocketBaseMurkrow` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 21 | 7 | `STANDING_UP` | `TRAINER` | **0** | `SlowpokeTailGrunt` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_ROCKET2` | `SPRITE_ROCKET` | 5 | 15 | `SPINRANDOM_FAST` | `TRAINER` | 3 | `RaticateTailGrunt` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_SCIENTIST1` | `SPRITE_SCIENTIST` | 25 | 12 | `STANDING_LEFT` | `TRAINER` | 4 | `TrainerScientistRoss` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_SCIENTIST2` | `SPRITE_SCIENTIST` | 14 | 15 | `STANDING_UP` | `TRAINER` | 3 | `TrainerScientistMitch` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_ROCKET3` | `SPRITE_ROCKET` | 24 | 14 | `STANDING_DOWN` | `SCRIPT` | 0 | `TeamRocketBaseB3FRocketScript` | `EVENT_TEAM_ROCKET_BASE_POPULATION` | +| `TEAMROCKETBASEB3F_RIVAL` | `SPRITE_RIVAL` | 4 | 5 | `STANDING_DOWN` | `SCRIPT` | 0 | `ObjectEvent` | `EVENT_RIVAL_TEAM_ROCKET_BASE` | +| `TEAMROCKETBASEB3F_POKE_BALL1` | `SPRITE_POKE_BALL` | 1 | 12 | `STILL` | `ITEMBALL` | 0 | `TeamRocketBaseB3FFullHeal` | `EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL` | +| `TEAMROCKETBASEB3F_POKE_BALL2` | `SPRITE_POKE_BALL` | 3 | 12 | `STILL` | `ITEMBALL` | 0 | `TeamRocketBaseB3FDireHit` | `EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT` | +| `TEAMROCKETBASEB3F_POKE_BALL3` | `SPRITE_POKE_BALL` | 28 | 9 | `STILL` | `ITEMBALL` | 0 | `TeamRocketBaseB3FProtein` | `EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN` | +| `TEAMROCKETBASEB3F_POKE_BALL4` | `SPRITE_POKE_BALL` | 17 | 2 | `STILL` | `ITEMBALL` | 0 | `TeamRocketBaseB3FIceHeal` | `EVENT_TEAM_ROCKET_BASE_B3F_ICE_HEAL` | + +The Murkrow the walkthrough talks to is the object using `SPRITE_MOLTRES` at `7,2`; +its script is `RocketBaseMurkrow` and its text says MURKROW. The sprite constant is +the generic large-bird overworld sprite, not a Moltres encounter. + +**Scripts of interest** + +- `LanceGetPasswordScript` (`45:5cb4`): both the `sdefer` scene script for scene 0 + and the Lance object's own script. Runs on entry to the right region, prints + `LanceGetPasswordText`, `disappear TEAMROCKETBASEB3F_LANCE` (which sets + `EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS`), `setscene SCENE_TEAMROCKETBASEB3F_RIVAL_ENCOUNTER`. +- `SlowpokeTailGrunt` -> `trainer GRUNTF, GRUNTF_5, EVENT_BEAT_ROCKET_GRUNTF_5, ...`; + `GruntF5Script` (the after-battle arm) does `setevent EVENT_LEARNED_SLOWPOKETAIL`. + **Sight range 0** - she never initiates; the bot must walk up and talk, then talk + a second time for the password. +- `RaticateTailGrunt` -> `trainer GRUNTM, GRUNTM_28, EVENT_BEAT_ROCKET_GRUNTM_28, ...`; + `GruntM28Script` does `setevent EVENT_LEARNED_RATICATE_TAIL`. Sight 3, + `SPINRANDOM_FAST` (the "spinning Team Rocket member"). +- `TeamRocketBaseB3FLockedDoor` (`45:5da9`): `.Script` requires + `EVENT_LEARNED_SLOWPOKETAIL` **and** `EVENT_LEARNED_RATICATE_TAIL`; on success + `changeblock 10, 8, $07`, `refreshmap`, `setevent EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE`. +- `RocketBaseRival` (`45:5cd1`): `appear TEAMROCKETBASEB3F_RIVAL`, walk-in, dialogue, + `applymovement PLAYER, RocketBaseRivalShovesPlayerMovement` (a `big_step RIGHT` + with `fix_facing`), `disappear`, `setscene SCENE_TEAMROCKETBASEB3F_ROCKET_BOSS`. + No battle - matches the walkthrough ("He can't battle you though"). +- `RocketBaseBoss` (`45:5d0e`): `loadtrainer EXECUTIVEM, EXECUTIVEM_4`, `startbattle`, + `setevent EVENT_BEAT_ROCKET_EXECUTIVEM_4`, long exit movement, + `disappear TEAMROCKETBASEB3F_ROCKET1`, `setscene SCENE_TEAMROCKETBASEB3F_NOOP`. +- `RocketBaseMurkrow` (`45:5d49`): text, then `setevent EVENT_LEARNED_HAIL_GIOVANNI`. + +**Items** (B3F) + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_HEAL` | item ball `1,12` | `TeamRocketBaseB3FFullHeal` | `EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL` | +| `DIRE_HIT` | item ball `3,12` | `TeamRocketBaseB3FDireHit` | `EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT` | +| `PROTEIN` | item ball `28,9` | `TeamRocketBaseB3FProtein` | `EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN` | +| `ICE_HEAL` | item ball `17,2` | `TeamRocketBaseB3FIceHeal` | `EVENT_TEAM_ROCKET_BASE_B3F_ICE_HEAL` | + +**Trainers** (B3F) + +| const | class | id | party | script label | sight | +|---|---|---|---|---|---| +| `SCIENTIST`, `ROSS` | `SCIENTIST` (`$0e`) | 1 | KOFFING 22, KOFFING 22 | `TrainerScientistRoss` | 4 | +| `SCIENTIST`, `MITCH` | `SCIENTIST` | 2 | DITTO 24 | `TrainerScientistMitch` | 3 | +| `GRUNTF`, `GRUNTF_5` | `GRUNTF` (`$42`) | 5 | EKANS 18 (WRAP/LEER/POISON_STING/BITE), GLOOM 18 (ABSORB/SWEET_SCENT/STUN_SPORE/SLEEP_POWDER) | `SlowpokeTailGrunt` | 0 | +| `GRUNTM`, `GRUNTM_28` | `GRUNTM` (`$1f`) | 28 | RATICATE 19 | `RaticateTailGrunt` | 3 | +| `EXECUTIVEM`, `EXECUTIVEM_4` | `EXECUTIVEM` (`$33`) | 4 | ZUBAT 22, RATICATE 24, KOFFING 22 | `RocketBaseBoss` | cutscene | + +**Wild encounters**: none. + +### MAP_MAHOGANY_GYM + +- Script: `maps/MahoganyGym.asm` (`51:536c MahoganyGym_MapScripts`) +- Blocks: `maps/MahoganyGym.blk` +- Header: `data/maps/maps.asm:69` -> `map MahoganyGym, TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_MAHOGANY_TOWN, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `map_const MAHOGANY_GYM, 5, 9` (group 2, map id 2) = 10x18 cells +- `def_scene_scripts` and `def_callbacks` are both **empty** + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `MAHOGANY_TOWN` | 3 | +| 2 | 5 | 17 | `MAHOGANY_TOWN` | 3 | + +`def_coord_events` is empty. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `MahoganyGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `MahoganyGymStatue` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `MAHOGANYGYM_PRYCE` | `SPRITE_PRYCE` | 5 | 3 | `STANDING_DOWN` | `SCRIPT` | 0 | `MahoganyGymPryceScript` | -1 | +| `MAHOGANYGYM_BEAUTY1` | `SPRITE_BEAUTY` | 4 | 6 | `STANDING_DOWN` | `TRAINER` | 1 | `TrainerSkierRoxanne` | -1 | +| `MAHOGANYGYM_ROCKER1` | `SPRITE_ROCKER` | 0 | 17 | `STANDING_UP` | `TRAINER` | 1 | `TrainerBoarderRonald` | -1 | +| `MAHOGANYGYM_BEAUTY2` | `SPRITE_BEAUTY` | 9 | 17 | `STANDING_UP` | `TRAINER` | 1 | `TrainerSkierClarissa` | -1 | +| `MAHOGANYGYM_ROCKER2` | `SPRITE_ROCKER` | 5 | 9 | `STANDING_DOWN` | `TRAINER` | 1 | `TrainerBoarderBrad` | -1 | +| `MAHOGANYGYM_ROCKER3` | `SPRITE_ROCKER` | 2 | 4 | `SPINRANDOM_FAST` | `TRAINER` | 1 | `TrainerBoarderDouglas` | -1 | +| `MAHOGANYGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 15 | `STANDING_DOWN` | `SCRIPT` | 0 | `MahoganyGymGuideScript` | -1 | + +**Ice-floor map**, decoded from `maps/MahoganyGym.blk` against +`data/tilesets/elite_four_room_collision.asm`. `I` = `COLL_ICE` (`$23`), +`.` = walkable floor, `#` = wall, `v` = warp carpet: + +``` + x0123456789 + y0 ########## + y1 ########## + y2 .IIIII.I#I + y3 IIIIIIII#I + y4 I#I.I.IIII + y5 I#IIII.III + y6 I#IIIIIIII + y7 I#II.IIII. + y8 .IIIIIII#I + y9 IIIIIIII#I +y10 I#.II.III. +y11 I#IIIII.II +y12 I#IIIIII#I +y13 I#I.II.I#I +y14 I#.#..#.#I +y15 I#.#..#.#I +y16 .I......I. +y17 II..vv..II +``` + +Sliding rule (`engine/overworld/player_movement.asm`): `CheckIceTile` on the tile +you land on -> `STEP_ICE`; `.CheckForced` / `CheckStandingOnIce` re-injects the same +d-pad direction, so a single press slides until you land on a non-ice tile, hit a +wall, or hit an NPC. + +Simulating that rule against the grid above (NPCs treated as blockers) reproduces +the walkthrough's directions exactly: + +| from | press | lands on | who | +|---|---|---|---| +| `4,17` (warp) | `UP` | `4,16` | - | +| `4,16` | `LEFT`, `LEFT`, `LEFT` | `3,16` -> `2,16` -> `0,16` | faces Boarder Ronald `0,17` | +| `0,16` | `UP`,`UP`,`RIGHT`,`DOWN`,`LEFT` | `0,8` -> `0,2` -> `6,2` -> `6,5` -> `2,5` | faces Boarder Douglas `2,4` | +| `2,5` | `DOWN`,`RIGHT` | `2,10` -> `5,10` | faces Boarder Brad `5,9` | +| `5,10` | `RIGHT`,`DOWN` | `9,10` -> `9,16` | faces Skier Clarissa `9,17` | +| `9,16` | `UP`,`UP`,`LEFT` | `9,10` -> `9,7` -> `4,7` | faces Skier Roxanne `4,6` | +| `4,7` | `DOWN` | `4,14` | back toward the exit | +| `2,14` ("just left of the left pillar", pillar block at `3,14`/`3,15`) | `UP`,`UP`,`RIGHT`,`DOWN`,`LEFT`,`UP`,`RIGHT` | `2,10` -> `2,5` -> `6,5` -> `6,13` -> `3,13` -> `3,4` -> `5,4` | standing directly below Pryce `5,3` | + +Reaching `2,14` from the entrance: `UP` (to `4,16`), `LEFT` (`3,16`), `LEFT` +(`2,16`), `UP` (`2,15`), `UP` (`2,14`) - `2,15`/`2,16`/`2,14` are ordinary floor, +not ice, so those are single steps. + +**Scripts of interest** + +`MahoganyGymPryceScript` (`51:536e`): + +1. `checkevent EVENT_BEAT_PRYCE` -> `.FightDone` +2. `writetext PryceText_Intro`; `winlosstext PryceText_Impressed, 0`; + `loadtrainer PRYCE, PRYCE1`; `startbattle`; `reloadmapafterbattle` +3. `setevent EVENT_BEAT_PRYCE`; `Text_ReceivedGlacierBadge`; + `playsound SFX_GET_BADGE`; `setflag ENGINE_GLACIERBADGE` +4. `readvar VAR_BADGES`; `scall MahoganyGymActivateRockets` - + `ifequal 7, .RadioTowerRockets` / `ifequal 6, .GoldenrodRockets`. + Badge count is read **after** GLACIERBADGE is set, so the normal route + (Glacier as the 7th badge) takes `jumpstd RadioTowerRocketsScript` + (`engine/events/std_scripts.asm:255`), which sets + `ENGINE_ROCKETS_IN_RADIO_TOWER`, `EVENT_GOLDENROD_CITY_CIVILIANS`, + `EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS`, + `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST`, + `specialphonecall SPECIALCALL_WEIRDBROADCAST`, and + `setmapscene MAHOGANY_TOWN, SCENE_MAHOGANYTOWN_NOOP`. + Skipping Jasmine first (Glacier as the 6th) takes + `GoldenrodRocketsScript` instead, which only clears + `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER`. +5. `.FightDone`: if `EVENT_GOT_TM16_ICY_WIND` is set -> farewell text. Otherwise + `setevent EVENT_BEAT_SKIER_ROXANNE / _CLARISSA / _BOARDER_RONALD / _BRAD / _DOUGLAS` + (so the five gym trainers never battle again), `PryceText_GlacierBadgeSpeech`, + `verbosegiveitem TM_ICY_WIND`, `setevent EVENT_GOT_TM16_ICY_WIND`. + +`MahoganyGymStatue`: `checkflag ENGINE_GLACIERBADGE`; before the badge +`jumpstd GymStatue1Script`, after it `gettrainername STRING_BUFFER_4, PRYCE, PRYCE1` ++ `jumpstd GymStatue2Script`. + +**Flags and events** (gym) + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_PRYCE` | `constants/event_flags.asm:712` | `MahoganyGymPryceScript` | leader beaten | +| `EVENT_GOT_TM16_ICY_WIND` | `event_flags.asm:20` | `MahoganyGymPryceScript` | TM handed over | +| `ENGINE_GLACIERBADGE` | `constants/engine_flags.asm:44` | set here; read by `WhirlpoolFunction` and `MahoganyGymStatue` | badge 7 | +| `EVENT_BEAT_SKIER_ROXANNE`, `_CLARISSA`, `EVENT_BEAT_BOARDER_RONALD`, `_BRAD`, `_DOUGLAS` | `constants/event_flags.asm` | the five `trainer` macros, force-set by Pryce | gym trainer beaten | + +**Trainers** (gym) + +| const | class | id | party (`data/trainers/parties.asm`) | script label | +|---|---|---|---|---| +| `BOARDER`, `RONALD` | `BOARDER` (`$3a`) | 1 | SEEL 24, DEWGONG 25, SEEL 24 | `TrainerBoarderRonald` | +| `BOARDER`, `BRAD` | `BOARDER` | 2 | SWINUB 26, SWINUB 26 | `TrainerBoarderBrad` | +| `BOARDER`, `DOUGLAS` | `BOARDER` | 3 | SHELLDER 24, CLOYSTER 25, SHELLDER 24 | `TrainerBoarderDouglas` | +| `SKIER`, `ROXANNE` | `SKIER` (`$21`) | 1 | JYNX 28 | `TrainerSkierRoxanne` | +| `SKIER`, `CLARISSA` | `SKIER` | 2 | DEWGONG 28 | `TrainerSkierClarissa` | +| `PRYCE`, `PRYCE1` | `PRYCE` (`$05`) | 1 | SEEL 27 (HEADBUTT/ICY_WIND/AURORA_BEAM/REST), DEWGONG 29 (HEADBUTT/ICY_WIND/AURORA_BEAM/REST), PILOSWINE 31 (ICY_WIND/FURY_ATTACK/MIST/BLIZZARD) | `MahoganyGymPryceScript` | + +**Wild encounters**: none. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Hideout entrance does not exist | `maps/MahoganyMart1F.asm:MahoganyMart1FStaircaseCallback` + `MahoganyMart1FLanceUncoversStaircaseScript` (`45:4046`) | `EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART` for the block; the cutscene needs `SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS` | `maps/LakeOfRage.asm:LakeOfRageLanceScript.AgreedToHelp` (previous section) | +| Camera grunt ambushes | `maps/TeamRocketBaseB1F.asm:SecurityCamera1a..5` | `EVENT_SECURITY_CAMERA_n` clear | beat both grunts at that camera, or press the switch at `19,11` (`TeamRocketBaseB1FSecretSwitch`, `45:4757`) | +| Exploding floor traps | `maps/TeamRocketBaseB1F.asm:ExplodingTrap1..22` | `EVENT_EXPLODING_TRAP_n` clear | step on the tile once (or route around; safe cells listed above) | +| B2F transmitter room sealed (block) | `maps/TeamRocketBaseB2F.asm:TeamRocketBaseB2FLockedDoor` (`45:4e8f`) + `TeamRocketBaseB2FTransmitterDoorCallback` | `EVENT_LEARNED_HAIL_GIOVANNI` | talk to the Murkrow object at B3F `7,2` (`RocketBaseMurkrow`, `45:5d49`) after beating ExecutiveM 4 | +| B2F boss ambush will not fire | `maps/TeamRocketBaseB2F.asm` coord events on `SCENE_TEAMROCKETBASEB2F_ROCKET_BOSS` | map scene must be 1 | trip `LanceHealsScript1/2` at `5,14` or `4,13` first | +| B3F Giovanni's office sealed | `maps/TeamRocketBaseB3F.asm:TeamRocketBaseB3FLockedDoor` (`45:5da9`) + `TeamRocketBaseB3FCheckGiovanniDoorCallback` | `EVENT_LEARNED_SLOWPOKETAIL` **and** `EVENT_LEARNED_RATICATE_TAIL` | beat `GRUNTF_5` at `21,7` then talk again; beat `GRUNTM_28` at `5,15` then talk again | +| Cannot leave the Electrode room | `maps/TeamRocketBaseB2F.asm:RocketBaseCantLeaveScript` / `RocketBaseLancesSideScript` | scene `SCENE_TEAMROCKETBASEB2F_ELECTRODES` | faint all three left-hand Electrodes | +| Mahogany Gym door blocked | `maps/MahoganyTown.asm` object `SPRITE_FISHER` at `6,14` gated on `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM`; visibility rule in `engine/overworld/map_objects_2.asm:CheckObjectFlag` | flag set = NPC hidden | `RocketBaseElectrodeScript` (`45:4e3c`) sets it when the hideout is cleared | +| Whirlpool unusable in the field | `engine/events/overworld.asm:WhirlpoolFunction.TryWhirlpool` (`03:4da0`, `ld de, ENGINE_GLACIERBADGE / call CheckBadge`) and `TryWhirlpoolOW` (`03:4e41`, `CheckPartyMove WHIRLPOOL` then `CheckEngineFlag ENGINE_GLACIERBADGE`) | GLACIERBADGE + a party member that knows WHIRLPOOL | beat Pryce | +| Rage Candy Bar merchant trip-wire | `maps/MahoganyTown.asm` coord events `19,8`/`19,9` on scene 0 | scene `SCENE_MAHOGANYTOWN_TRY_RAGECANDYBAR` | only cleared later, by `RadioTowerRocketsScript` `setmapscene MAHOGANY_TOWN, SCENE_MAHOGANYTOWN_NOOP` | + +--- + +## 4. Bot checklist + +Coordinates are `(x, y)` map cells as written in the asm. "talk" = face the target +and press A. + +1. `MAHOGANY_TOWN`: warp 4 at `(15,13)` -> `MAHOGANY_POKECENTER_1F`; talk to nurse at `(3,1)`. Pre: none. Post: party healed. +2. `MAHOGANY_TOWN`: warp 1 at `(11,7)` -> `MAHOGANY_MART_1F`. Pre: `EVENT_DECIDED_TO_HELP_LANCE` set and map scene = 1. Post: `MahoganyMart1FLanceUncoversStaircaseScript` runs unattended; wait for `EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART`. +3. `MAHOGANY_MART_1F`: walk to `(7,3)` -> `TEAM_ROCKET_BASE_B1F` warp 1, arriving at `(27,2)`. +4. B1F: walk west along y=2. Crossing `(24,2)` or `(24,3)` fires `SecurityCamera1a/1b` -> battle `GRUNTM_20` then `GRUNTM_21`. Post: `EVENT_SECURITY_CAMERA_1`. +5. B1F: item ball at `(27,6)` = HYPER_POTION. Post: `EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION`. +6. B1F: continue west; `(6,2)`/`(6,3)` fires camera 2, `(24,6)`/`(24,7)` camera 3, `(22,16)` camera 4, `(8,16)` camera 5. Each is the same two grunts. +7. B1F: talk to `GRUNTM_16` at `(2,4)` (sight 3, faces RIGHT - approaching from the east triggers him). Post: `EVENT_BEAT_ROCKET_GRUNTM_16`. +8. B1F: cross the trap field x1..5 / y7..13 using only the safe cells listed in section 2, or accept the one-shot `BATTLETYPE_TRAP` battles. Optional: hidden REVIVE by facing `(3,11)`. +9. B1F: battle `SCIENTIST JED` at `(18,12)` (sight 3, faces LEFT). Post: `EVENT_BEAT_SCIENTIST_JED`. Talk again for the warp-panel hint. +10. B1F: stand at `(19,12)`, face UP, press A on the bg event at `(19,11)`. Post: `EVENT_TURNED_OFF_SECURITY_CAMERAS` + `EVENT_SECURITY_CAMERA_1..5`. +11. B1F: item balls `(21,12)` = X_ACCURACY, `(14,15)` = NUGGET. +12. B1F: walk to `(3,14)` -> `TEAM_ROCKET_BASE_B2F` arriving at `(3,14)`. +13. B2F: step on `(5,14)` or `(4,13)`. Pre: map scene 0. Post: party healed, `EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE`, scene -> 1. +14. B2F: battle `GRUNTM_19` at `(21,14)` (sight 4, faces LEFT), then `GRUNTM_17` at `(25,13)` (sight 3, faces DOWN). +15. B2F: walk to `(27,14)` -> `TEAM_ROCKET_BASE_B3F` arriving at `(27,14)`. Pre: none. Post: `LanceGetPasswordScript` auto-runs, scene -> 1, `EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS`. +16. B3F: item balls `(28,9)` = PROTEIN, `(17,2)` = ICE_HEAL. +17. B3F: battle `SCIENTIST ROSS` at `(25,12)` (sight 4, faces LEFT). +18. B3F: walk to `(21,8)` and talk UP to `GRUNTF_5` at `(21,7)` - **sight 0, she will not initiate**. Post: `EVENT_BEAT_ROCKET_GRUNTF_5`; talk again -> `EVENT_LEARNED_SLOWPOKETAIL`. +19. B3F: battle `SCIENTIST MITCH` at `(14,15)` (sight 3, faces UP). +20. B3F: battle `GRUNTM_28` at `(5,15)` (sight 3, spinning). Post: `EVENT_BEAT_ROCKET_GRUNTM_28`; talk again -> `EVENT_LEARNED_RATICATE_TAIL`. +21. B3F: item balls `(3,12)` = DIRE_HIT, `(1,12)` = FULL_HEAL. +22. B3F: walk to `(27,2)` -> `TEAM_ROCKET_BASE_B2F` arriving at `(27,2)`. +23. B2F top corridor: walk west along y=1..2; battle `GRUNTM_18` at `(2,1)` (sight 3, faces RIGHT). +24. B2F: walk to `(3,2)` -> `TEAM_ROCKET_BASE_B3F` arriving at `(3,2)`. +25. B3F left region: walk to `(8,10)`. Pre: scene 1. Post: rival cutscene, no battle, scene -> 2, `EVENT_RIVAL_TEAM_ROCKET_BASE` set by `disappear`. +26. B3F: stand at `(10,10)` or `(11,10)`, face UP, press A on the bg event at `(10,9)`/`(11,9)`. Pre: both password events. Post: `EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE`, block at `(10,8)` becomes floor. +27. B3F: step onto `(10,8)` or `(11,8)`. Pre: scene 2. Post: auto-walk, battle `EXECUTIVEM_4`, `EVENT_BEAT_ROCKET_EXECUTIVEM_4`, scene -> 3. +28. B3F: talk to the bird object at `(7,2)`. Post: `EVENT_LEARNED_HAIL_GIOVANNI`. +29. B3F: walk to `(3,6)` -> `TEAM_ROCKET_BASE_B2F` arriving at `(3,6)`; item ball `(3,10)` = TM_THIEF (TM46). Return via `(3,6)`. +30. Back-track: B3F `(3,2)` -> B2F `(3,2)`; B2F top corridor east to `(27,2)` -> B3F `(27,2)`; B3F right region south to `(27,14)` -> B2F `(27,14)`; walk west to the door. +31. B2F: stand at `(14,13)`/`(15,13)`, face UP, press A on `(14,12)`/`(15,12)`. Pre: `EVENT_LEARNED_HAIL_GIOVANNI`. Post: `EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER`, block becomes floor. +32. B2F: step onto `(14,11)` or `(15,11)`. Pre: scene 1. Post: `EXECUTIVEF_2` battle, `EVENT_BEAT_ROCKET_EXECUTIVEF_2`, scene -> 2, Lance briefing. +33. B2F: talk to the three left Electrodes at `(7,5)`, `(7,7)`, `(7,9)`. Each is a wild ELECTRODE L23 that may Selfdestruct. Do **not** walk east past x=12 (`RocketBaseLancesSideScript` bounces you back). Post: three `EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_n`. +34. After the third, `RocketBaseElectrodeScript` auto-runs: accept `HM_WHIRLPOOL`. Post: `EVENT_GOT_HM06_WHIRLPOOL`, `EVENT_CLEARED_ROCKET_HIDEOUT`, `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM`, `clearflag ENGINE_ROCKET_SIGNAL_ON_CH20`, scene -> 3. +35. Exit: B2F `(3,14)` -> B1F `(3,14)`; B1F warp panel `(5,15)` -> `(25,2)`; `(27,2)` -> mart; mart `(3,7)`/`(4,7)` -> town. +36. `MAHOGANY_TOWN`: heal at the Pokecenter, then warp 3 at `(6,13)` -> `MAHOGANY_GYM`, arriving at `(4,17)`/`(5,17)`. Pre: `EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM` set (otherwise the fisher stands on `(6,14)`). +37. Gym: `UP`, `LEFT`, `LEFT`, `LEFT` -> `(0,16)`, Boarder Ronald spots you from `(0,17)`. +38. Gym: `UP`,`UP`,`RIGHT`,`DOWN`,`LEFT` -> `(2,5)`, Boarder Douglas at `(2,4)`. Talk again for the Pryce-waterfall line. +39. Gym: `DOWN`,`RIGHT` -> `(5,10)`, Boarder Brad at `(5,9)`. +40. Gym: `RIGHT`,`DOWN` -> `(9,16)`, Skier Clarissa at `(9,17)`. +41. Gym: `UP`,`UP`,`LEFT` -> `(4,7)`, Skier Roxanne at `(4,6)`. +42. Gym: `DOWN` -> `(4,14)`, then walk to `(2,14)`. +43. Gym: from `(2,14)`: `UP`,`UP`,`RIGHT`,`DOWN`,`LEFT`,`UP`,`RIGHT` -> `(5,4)`. Talk UP to Pryce at `(5,3)`. +44. Battle `PRYCE1`. Post: `EVENT_BEAT_PRYCE`, `ENGINE_GLACIERBADGE`, the five gym-trainer beat flags, `EVENT_GOT_TM16_ICY_WIND`, and (at 7 badges) `RadioTowerRocketsScript`. +45. Gym: leave via `(4,17)`/`(5,17)`. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers / warps / coord events / bg events / objects for all six maps | `src/import/RomExtractorGen2.lua`, `src/world/gen2/Map.lua` | implemented (generic extraction, nothing map-specific needed) | +| Map scenes + `sdefer` scene scripts | `src/world/gen2/World.lua` (`World:scene`, `mapScenes`), `src/script/gen2/Vm.lua` (`sdefer`, `setscene`, `setmapscene`) | implemented | +| Coord-event trip-wires | `src/world/gen2/World.lua` (coord event dispatch) | implemented | +| `changeblock` (mart staircase, both password doors) | `src/script/gen2/Vm.lua:1006` - the comment cites `MahoganyMart1F`'s `changeblock 6, 2, $1e` and cell `(7,3)` by name | implemented | +| `BGEVENT_IFNOTSET` / `conditional_event` (both password doors) | `src/world/gen2/World.lua:5147 World:bgEventAt` only matches `(ev.kind or 0) == 0`; `src/world/gen2/HiddenItems.lua` handles kind 7 | **missing** - pressing A on the transmitter door or Giovanni's door does nothing in the port | +| Ice sliding (`STEP_ICE`, `CheckStandingOnIce` forced d-pad) | `src/world/gen2/Permissions.lua:128 ICE` exists but is only consumed by `FieldMoves.canEncounterWildMon`; `World:movePlayer` picks step frames from `Bike.stepFrames` only | **missing** - Mahogany Gym floor behaves as ordinary floor, so the puzzle and the trainer sight lines are wrong | +| Trainer sight range / approach | `src/world/gen2/Trainers.lua` (`Trainers.sees`, `Trainers.approach`) | implemented (`sight 0` on GruntF5 falls out naturally) | +| `itemball` / `hiddenitem` | `src/script/gen2/CallAsm.lua`, `src/world/gen2/HiddenItems.lua` | implemented | +| `verbosegiveitem HM_WHIRLPOOL` | `src/script/gen2/Vm.lua`, `src/script/gen2/Opcodes.lua` | implemented | +| Whirlpool field move + GLACIERBADGE gate | `src/world/gen2/FieldMoves.lua:109` (`WHIRLPOOL = "GLACIER"`), `.somethingToWhirlpool`, `.whirlpoolFromMenu`, `.tryWhirlpoolOW`, `WHIRLPOOL_BLOCKS` | implemented | +| `loadwildmon` + `BATTLETYPE_TRAP` | `src/world/gen2/World.lua:4587` (names "TRAP 9 (the Rocket base)"), `src/script/gen2/Vm.lua:699` | implemented | +| `special HealParty` / `FadeOutToWhite` / `FadeInFromWhite` / `PlayMapMusic` / `ReloadSpritesNoPalettes` / `FadeOutToBlack` / `FadeInFromBlack` / `RestartMapMusic` / `FadeOutMusic` | `src/script/gen2/Specials.lua:450, 992-1066` | implemented | +| `moveobject` / `appear` / `disappear` / `applymovement` / `follow` / `showemote` | `src/script/gen2/Vm.lua`, `src/script/gen2/Movement.lua`, `src/world/gen2/World.lua` | implemented | +| `jumpstd` (`TeamRocketOathScript`, `GymStatue1/2Script`, `PokecenterSignScript`, `RadioTowerRocketsScript`) | `src/script/gen2/Vm.lua:742`, `data/generated/std_scripts.lua` via `World.lua:635` | implemented (generic) | +| `readvar VAR_BADGES` for `MahoganyGymActivateRockets` | `src/world/gen2/World.lua:117,1240` | implemented | +| `specialphonecall SPECIALCALL_WEIRDBROADCAST` | `src/script/gen2/Vm.lua:1364` stores the id; `src/core/gen2/Phone.lua` delivers | implemented | +| `ENGINE_ROCKET_SIGNAL_ON_CH20` radio behaviour | `src/ui/gen2/Pokegear.lua:816-821, 1097` | implemented | +| Driver coverage for this stretch | `tests/drivers/gold_*.lua` - none touches Mahogany, the hideout or Pryce (`gold_icepath_boulder.lua` is the only Mahogany-adjacent one, and it is Ice Path boulders) | **missing** | + +The two "missing" rows are the blocking ones: without `BGEVENT_IFNOTSET` the +hideout cannot be completed, and without ice sliding the gym cannot be crossed. + +--- + +## 6. Unresolved / verify by hand + +- **Floor labels in the walkthrough are wrong past the first password.** It says + "go up the stairs at the upper-right part of the floor, then head left on Negative + Floor 2 ... Head up the stairs to Negative Floor 1. Head right and then before you + reach the locked door, your rival will come." The rival coord event + (`8,10`) and Giovanni's office are both on **B3F**, not B1F. The route is + B3F(right) -> B2F(top) -> B3F(left). Verified by flood-filling + `maps/TeamRocketBaseB2F.blk` / `maps/TeamRocketBaseB3F.blk`: the four B2F warps + and four B3F warps sit in disconnected rooms, and B2F warp 2 `(3,2)` reaches + B3F `(3,2)` which is the region containing `8,10`. +- **Boarder Ronald's party order.** Walkthrough lists Seel 24 / Seel 24 / Dewgong 25; + `data/trainers/parties.asm` `BOARDER (1)` is SEEL 24, DEWGONG 25, SEEL 24. + Same three mon, different lead order. Same class of discrepancy for + `GRUNTM_18` (walkthrough Rattata/Rattata/Zubat, asm Rattata/Zubat/Rattata). +- **Item list is incomplete.** The walkthrough's "Items in the Team Rocket Hideout" + block lists six; the asm has ten: the six named plus `FULL_HEAL` and `DIRE_HIT` + item balls on B3F (mentioned later in the prose) and two hidden items the + walkthrough never mentions - `REVIVE` at B1F `(3,11)` and `FULL_HEAL` at B2F + `(26,7)`. +- **"Every time you pass one of those statues, two Team Rocket grunts will fight + you"** is right, but the walkthrough implies only two statues on the first floor. + There are five (`bg_event` at `24,1`, `6,1`, `24,5`, `8,15`, `22,15`) with eight + trigger cells, i.e. up to ten grunt battles if none are skipped. +- **`EVENT_TEAM_ROCKET_BASE_POPULATION` is dead.** It is the shared visibility flag + of eleven base NPCs and is read by all eight `SecurityCamera*` scripts, but a + full-tree grep finds no `setevent`, `clearevent` or `disappear` that writes it. + Those `checkevent ... iftrue NoSecurityCamera` arms therefore never take, and the + Rocket NPCs stay on the maps forever - including after the hideout is cleared. + Flagged in case a bot author expects the base to empty out. +- **Lance's three right-hand Electrodes are never battled.** `RocketElectrodeN` + disappears both members of a mirrored pair, and the objects at `22,5`/`22,7`/`22,9` + point at the no-op `ObjectEvent` script. The walkthrough's "Lance runs to the right + to take the Electrodes on the right" is flavour; the game just hides them. +- **Exact reachability of `19,11` (the secret switch)** was checked by collision + (`19,11` is a WALL, `19,12` is FLOOR and in the single B1F region), but the + in-game facing direction for the `BGEVENT_READ` was not observed running - the + bg event type is `BGEVENT_READ`, not `BGEVENT_UP`, so any adjacent facing should + work; worth confirming on hardware if a driver misses it. +- The gym slide table in section 2 was produced by simulating + `engine/overworld/player_movement.asm`'s ice rule against the decoded + `maps/MahoganyGym.blk`, treating NPCs as blockers. It reproduces every direction + string in the walkthrough, but it has not been executed in an emulator. diff --git a/docs/gold-walkthrough/asm-walk/section-12-team-rocket-radio-tower.md b/docs/gold-walkthrough/asm-walk/section-12-team-rocket-radio-tower.md new file mode 100644 index 00000000..6af7b91f --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-12-team-rocket-radio-tower.md @@ -0,0 +1,1050 @@ +# Section 12 - Team Rocket Radio Tower + +Source: `../section-12-team-rocket-radio-tower.txt` (the FAQ's own heading is +"18 > Team Rocket Radio Tower") +Maps covered: `MAP_GOLDENROD_CITY`, `MAP_RADIO_TOWER_1F`, `MAP_RADIO_TOWER_2F`, +`MAP_RADIO_TOWER_3F`, `MAP_RADIO_TOWER_4F`, `MAP_RADIO_TOWER_5F`, +`MAP_GOLDENROD_UNDERGROUND`, `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES`, +`MAP_GOLDENROD_UNDERGROUND_WAREHOUSE`, `MAP_GOLDENROD_DEPT_STORE_B1F` + +Badges / key milestones in this section: + +- No badge. The section is bracketed by two key items and one story flag: + `BASEMENT_KEY` (from the fake director, `RADIO_TOWER_5F`), `CARD_KEY` (from the + real director, `GOLDENROD_UNDERGROUND_WAREHOUSE`), and + `EVENT_CLEARED_RADIO_TOWER` + `RAINBOW_WING` (Gold) / `SILVER_WING` (Silver) + from `RadioTower5FRocketBossScript`. +- The rival's fourth battle (`RIVAL1_4_*`) happens in the middle of it. +- Entry condition is the 7th badge, not any item: see section 3. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_GOLDENROD_CITY` | `maps/GoldenrodCity.asm` | Fly (`ENGINE_FLYPOINT_GOLDENROD`, set by `GoldenrodCityFlypointCallback`) | warp 12 at (5,15) | Elm's phone call; city is full of Rockets | +| 2 | `MAP_RADIO_TOWER_1F` | `maps/RadioTower1F.asm` | warps 1/2 at (2,7)/(3,7) | warp 3 at (15,0) | first Grunt (`TrainerGruntM3`) | +| 3 | `MAP_RADIO_TOWER_2F` | `maps/RadioTower2F.asm` | warp 2 at (15,0) | warp 1 at (0,0) | four Grunts, head left | +| 4 | `MAP_RADIO_TOWER_3F` | `maps/RadioTower3F.asm` | warp 1 at (0,0) | warp 2 at (7,0) | two Grunts + Scientist Marc | +| 5 | `MAP_RADIO_TOWER_4F` | `maps/RadioTower4F.asm` | warp 2 at (9,0) | warp 1 at (0,0) | `TrainerGruntM10`, `TrainerScientistRich` | +| 6 | `MAP_RADIO_TOWER_5F` | `maps/RadioTower5F.asm` | warp 1 at (0,0) | warp 1 at (0,0) | `coord_event 0, 3` -> `FakeDirectorScript`, `EXECUTIVEM_3`, `BASEMENT_KEY` | +| 7 | `MAP_GOLDENROD_CITY` | `maps/GoldenrodCity.asm` | radio tower warp | warp 14 at (9,5) | "northwesternmost building" = north Underground entrance | +| 8 | `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` | warp 8 at (20,29) | warp 7 at (21,25) | just the stairwell room on this pass | +| 9 | `MAP_GOLDENROD_UNDERGROUND` | `maps/GoldenrodUnderground.asm` | warp 1 at (3,2) | warp 3 at (18,6) | Pokemon Salon (haircut brothers); the locked door | +| 10 | `MAP_GOLDENROD_UNDERGROUND` (south half) | same file | warp 4 at (21,31) | warp 6 at (22,27) | the basement door is a same-map warp pair | +| 11 | `MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` | warp 1 at (23,3) | warps 2/3 at (22,10)/(23,10) | rival battle, three switch Grunts, two Burglars, the shutter puzzle | +| 12 | `MAP_GOLDENROD_UNDERGROUND_WAREHOUSE` | `maps/GoldenrodUndergroundWarehouse.asm` | warps 1/2 at (2,12)/(3,12) | warp 3 at (17,2) | three Grunts, Max Ether, TM35, the Director + `CARD_KEY` | +| 13 | `MAP_GOLDENROD_DEPT_STORE_B1F` | `maps/GoldenrodDeptStoreB1F.asm` | warp 1 at (17,2) | warp 1 back, or elevator warps 2/3 at (9,4)/(10,4) | Amulet Coin at (14,2) | +| 14 | `MAP_RADIO_TOWER_3F` | `maps/RadioTower3F.asm` | 2F warp 1 at (0,0) | warp 3 at (17,0) | `bg_event 14, 2, BGEVENT_UP, CardKeySlotScript`; `TrainerGruntM9` | +| 15 | `MAP_RADIO_TOWER_4F` | `maps/RadioTower4F.asm` | warp 4 at (17,0) | warp 3 at (12,0) | `TrainerGruntF4`, `TrainerExecutivem2` | +| 16 | `MAP_RADIO_TOWER_5F` | `maps/RadioTower5F.asm` | warp 2 at (12,0) | warp 2 at (12,0) | `TrainerExecutivef1`, then `coord_event 16, 5` -> `RadioTower5FRocketBossScript` | + +Spill into the next section: the walkthrough's closing party-level lists and the +video link's mention of Route 44 belong to whatever section follows; nothing +after `RadioTower5FRocketBossScript` is covered here. + +--- + +## 2. Maps + +### MAP_GOLDENROD_CITY + +- Script: `maps/GoldenrodCity.asm` +- Blocks: `maps/GoldenrodCity.blk` +- Header: `data/maps/maps.asm:269` -> `TILESET_JOHTO_MODERN`, `TOWN`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_GOLDENROD_CITY`, phone `FALSE`, + `PALETTE_AUTO`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:247` -> `map_const GOLDENROD_CITY, 20, 18` +- Connections: `data/maps/attributes.asm:139` -> north `ROUTE_35` (offset 5), + south `ROUTE_34` (offset 5). No east/west. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 24 | 7 | `GOLDENROD_GYM` | 1 | +| 2 | 29 | 29 | `GOLDENROD_BIKE_SHOP` | 1 | +| 3 | 31 | 21 | `GOLDENROD_HAPPINESS_RATER` | 1 | +| 4 | 5 | 25 | `BILLS_FAMILYS_HOUSE` | 1 | +| 5 | 9 | 13 | `GOLDENROD_MAGNET_TRAIN_STATION` | 2 | +| 6 | 33 | 5 | `GOLDENROD_FLOWER_SHOP` | 1 | +| 7 | 15 | 27 | `GOLDENROD_POKECENTER_1F` | 1 | +| 8 | 33 | 9 | `GOLDENROD_PP_SPEECH_HOUSE` | 1 | +| 9 | 15 | 7 | `GOLDENROD_NAME_RATER` | 1 | +| 10 | 24 | 27 | `GOLDENROD_DEPT_STORE_1F` | 1 | +| 11 | 14 | 21 | `GOLDENROD_GAME_CORNER` | 1 | +| 12 | 5 | 15 | `RADIO_TOWER_1F` | 1 | +| 13 | 19 | 1 | `ROUTE_35_GOLDENROD_GATE` | 3 | +| 14 | 9 | 5 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 8 | +| 15 | 11 | 29 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 5 | + +Warp 14 is the "northwesternmost building" the walkthrough sends you to for the +Basement Key trip; warp 15 is the southern Underground entrance. + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 10 | 14 | `BGEVENT_READ` | `GoldenrodCityStationSign` | +| 4 | 17 | `BGEVENT_READ` | `GoldenrodCityRadioTowerSign` | +| 26 | 27 | `BGEVENT_READ` | `GoldenrodDeptStoreSign` | +| 26 | 9 | `BGEVENT_READ` | `GoldenrodGymSign` | +| 22 | 18 | `BGEVENT_READ` | `GoldenrodCitySign` | +| 28 | 30 | `BGEVENT_READ` | `GoldenrodCityBikeShopSign` | +| 16 | 22 | `BGEVENT_READ` | `GoldenrodCityGameCornerSign` | +| 12 | 7 | `BGEVENT_READ` | `GoldenrodCityNameRaterSign` | +| 8 | 6 | `BGEVENT_READ` | `GoldenrodCityUndergroundSignNorth` | +| 12 | 30 | `BGEVENT_READ` | `GoldenrodCityUndergroundSignSouth` | +| 16 | 27 | `BGEVENT_READ` | `GoldenrodCityPokecenterSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODCITY_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 7 | 18 | `STANDING_UP` | `SCRIPT` | `GoldenrodCityPokefanMScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 30 | 17 | `WANDER` | `SCRIPT` | `GoldenrodCityYoungster1Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 12 | 16 | `STANDING_LEFT` | `SCRIPT` | `GoldenrodCityCooltrainerF1Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 20 | 26 | `WANDER` | `SCRIPT` | `GoldenrodCityCooltrainerF2Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 19 | 17 | `WANDER` | `SCRIPT` | `GoldenrodCityYoungster2Script` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_LASS` | `SPRITE_LASS` | 17 | 10 | `WALK_LEFT_RIGHT` | `SCRIPT` | `GoldenrodCityLassScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_GRAMPS` | `SPRITE_GRAMPS` | 11 | 27 | `WALK_LEFT_RIGHT` | `SCRIPT` | `GoldenrodCityGrampsScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `GOLDENRODCITY_ROCKETSCOUT` | `SPRITE_ROCKET` | 4 | 16 | `STANDING_UP` | `SCRIPT` | `GoldenrodCityRocketScoutScript` | `EVENT_GOLDENROD_CITY_ROCKET_SCOUT` | +| `GOLDENRODCITY_ROCKET1` | `SPRITE_ROCKET` | 28 | 20 | `STANDING_UP` | `SCRIPT` | `GoldenrodCityRocket1Script` | `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET2` | `SPRITE_ROCKET` | 8 | 15 | `STANDING_DOWN` | `SCRIPT` | `GoldenrodCityRocket2Script` | `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET3` | `SPRITE_ROCKET` | 16 | 23 | `STANDING_RIGHT` | `SCRIPT` | `GoldenrodCityRocket3Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET4` | `SPRITE_ROCKET` | 29 | 20 | `STANDING_UP` | `SCRIPT` | `GoldenrodCityRocket4Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET5` | `SPRITE_ROCKET` | 29 | 7 | `STANDING_DOWN` | `SCRIPT` | `GoldenrodCityRocket5Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `GOLDENRODCITY_ROCKET6` | `SPRITE_ROCKET` | 30 | 10 | `STANDING_LEFT` | `SCRIPT` | `GoldenrodCityRocket6Script` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | + +None of the city Rockets are trainers - they are all `jumptextfaceplayer`. + +**Scripts of interest** + +- `GoldenrodCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): `setflag + ENGINE_FLYPOINT_GOLDENROD` / `setflag ENGINE_REACHED_GOLDENROD`. This is why + the walkthrough can say "fly to the city". + +--- + +### MAP_RADIO_TOWER_1F + +- Script: `maps/RadioTower1F.asm` +- Blocks: `maps/RadioTower1F.blk` +- Header: `data/maps/maps.asm:95` -> `TILESET_RADIO_TOWER`, `INDOOR`, + `LANDMARK_RADIO_TOWER`, `RADIO_TOWER_MUSIC | MUSIC_GOLDENROD_CITY`, phone + `TRUE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:82` -> `map_const RADIO_TOWER_1F, 9, 4` +- Connections: none (`data/maps/attributes.asm:414`, environment byte `$00`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `GOLDENROD_CITY` | 12 | +| 2 | 3 | 7 | `GOLDENROD_CITY` | 12 | +| 3 | 15 | 0 | `RADIO_TOWER_2F` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 0 | `BGEVENT_READ` | `RadioTower1FDirectory` | +| 13 | 0 | `BGEVENT_READ` | `RadioTower1FLuckyChannelSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RADIOTOWER1F_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 5 | 6 | `STANDING_LEFT` | `SCRIPT` | `RadioTower1FReceptionistScript` | -1 | +| `RADIOTOWER1F_LASS` | `SPRITE_LASS` | 16 | 4 | `STANDING_LEFT` | `SCRIPT` | `RadioTower1FLassScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `RADIOTOWER1F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 15 | 4 | `STANDING_RIGHT` | `SCRIPT` | `RadioTower1FYoungsterScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `RADIOTOWER1F_ROCKET` | `SPRITE_ROCKET` | 14 | 1 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM3` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER1F_LUCKYNUMBERMAN` | `SPRITE_GENTLEMAN` | 8 | 6 | `STANDING_UP` | `SCRIPT` | `RadioTower1FLuckyNumberManScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `RADIOTOWER1F_CARD_WOMAN` | `SPRITE_COOLTRAINER_F` | 12 | 6 | `STANDING_UP` | `SCRIPT` | `RadioTower1FRadioCardWomanScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | + +**Scripts of interest** + +- `RadioTower1FReceptionistScript` - `checkflag ENGINE_ROCKETS_IN_RADIO_TOWER`; + if set, the "no tours today" line. Pure flavour, no gate. +- `TrainerGruntM3` - `trainer GRUNTM, GRUNTM_3, EVENT_BEAT_ROCKET_GRUNTM_3, ...`. + Sight range 3 facing down from (14,1): he intercepts the walk to warp 3 at + (15,0). +- `RadioTower1FRadioCardWomanScript` / `RadioTower1FLuckyNumberManScript` are the + Radio Card quiz and the Lucky Number Show. Both are hidden while + `EVENT_GOLDENROD_CITY_CIVILIANS` is set, i.e. for the whole of this section. + +--- + +### MAP_RADIO_TOWER_2F + +- Script: `maps/RadioTower2F.asm` +- Blocks: `maps/RadioTower2F.blk` +- Header: `data/maps/maps.asm:96` -> same row shape as 1F +- Dimensions: `constants/map_constants.asm:83` -> `map_const RADIO_TOWER_2F, 9, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `RADIO_TOWER_3F` | 1 | +| 2 | 15 | 0 | `RADIO_TOWER_1F` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 0 | `BGEVENT_READ` | `RadioTower2FSalesSign` | +| 5 | 0 | `BGEVENT_READ` | `RadioTower2FOaksPKMNTalkSign` | +| 9 | 1 | `BGEVENT_READ` | `RadioTower2FBookshelf` | +| 10 | 1 | `BGEVENT_READ` | `RadioTower2FBookshelf` | +| 11 | 1 | `BGEVENT_READ` | `RadioTower2FBookshelf` | +| 13 | 0 | `BGEVENT_READ` | `RadioTower2FPokemonRadioSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RADIOTOWER2F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 5 | 6 | `WALK_LEFT_RIGHT` | `SCRIPT` | `RadioTower2FSuperNerdScript` | `EVENT_GOLDENROD_CITY_CIVILIANS` | +| `RADIOTOWER2F_TEACHER` | `SPRITE_TEACHER` | 13 | 2 | `WALK_LEFT_RIGHT` | `SCRIPT` | `RadioTower2FTeacherScript` | -1 | +| `RADIOTOWER2F_ROCKET1` | `SPRITE_ROCKET` | 1 | 4 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerGruntM4` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER2F_ROCKET2` | `SPRITE_ROCKET` | 8 | 4 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM5` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER2F_ROCKET3` | `SPRITE_ROCKET` | 4 | 1 | `STANDING_DOWN` | `TRAINER` (sight 2) | `TrainerGruntM6` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER2F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 10 | 5 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerGruntF2` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER2F_BLACK_BELT1` | `SPRITE_BLACK_BELT` | 0 | 1 | `STANDING_DOWN` | `SCRIPT` | `RadioTower2FBlackBelt1Script` | `EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS` | +| `RADIOTOWER2F_BLACK_BELT2` | `SPRITE_BLACK_BELT` | 1 | 1 | `STANDING_DOWN` | `SCRIPT` | `RadioTower2FBlackBelt2Script` | `EVENT_RADIO_TOWER_CIVILIANS_AFTER` | +| `RADIOTOWER2F_JIGGLYPUFF` | `SPRITE_JIGGLYPUFF` | 12 | 1 | `POKEMON` | `SCRIPT` | `RadioTowerJigglypuff` | -1 | + +**Scripts of interest** + +- `RadioTower2FBlackBelt1Script` at (0,1) is the pre-takeover stairs blocker + ("Authorized personnel only beyond this point"). `RadioTowerRocketsScript` + (`engine/events/std_scripts.asm:255`) does `setevent + EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS`, and a SET object event flag MASKS + the object (`CheckObjectFlag`, `engine/overworld/map_objects_2.asm:32`), so + during the takeover he is gone and warp 1 at (0,0) is walkable. +- Walkthrough order on this floor is `TrainerGruntF2` (10,5) -> `TrainerGruntM5` + (8,4) -> `TrainerGruntM6` (4,1) -> `TrainerGruntM4` (1,4). + +--- + +### MAP_RADIO_TOWER_3F + +- Script: `maps/RadioTower3F.asm` +- Blocks: `maps/RadioTower3F.blk` +- Header: `data/maps/maps.asm:97` +- Dimensions: `constants/map_constants.asm:84` -> `map_const RADIO_TOWER_3F, 9, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `RADIO_TOWER_2F` | 1 | +| 2 | 7 | 0 | `RADIO_TOWER_4F` | 2 | +| 3 | 17 | 0 | `RADIO_TOWER_4F` | 4 | + +Warp 3 is only reachable once the Card Key shutter is open. + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 0 | `BGEVENT_READ` | `RadioTower3FPersonnelSign` | +| 9 | 0 | `BGEVENT_READ` | `RadioTower3FPokemonMusicSign` | +| 14 | 2 | `BGEVENT_UP` | `CardKeySlotScript` | + +`BGEVENT_UP` means the player must be FACING UP on the tile below (14,2) for the +press to register (`engine/overworld/events.asm:644` `.up` -> `.checkdir` +compares `wPlayerDirection & %1100` against `OW_UP`). + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RADIOTOWER3F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 7 | 4 | `STANDING_UP` | `SCRIPT` | `RadioTower3FSuperNerdScript` | `EVENT_RADIO_TOWER_CIVILIANS_AFTER` | +| `RADIOTOWER3F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 3 | 4 | `SPINRANDOM_FAST` | `SCRIPT` | `RadioTower3FGymGuideScript` | -1 | +| `RADIOTOWER3F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 11 | 3 | `WANDER` | `SCRIPT` | `RadioTower3FCooltrainerFScript` | -1 | +| `RADIOTOWER3F_ROCKET1` | `SPRITE_ROCKET` | 5 | 1 | `STANDING_RIGHT` | `TRAINER` (sight 2) | `TrainerGruntM7` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER3F_ROCKET2` | `SPRITE_ROCKET` | 6 | 2 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM8` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER3F_ROCKET3` | `SPRITE_ROCKET` | 16 | 6 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerGruntM9` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER3F_SCIENTIST` | `SPRITE_SCIENTIST` | 9 | 6 | `STANDING_UP` | `TRAINER` (sight 5) | `TrainerScientistMarc` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | + +`TrainerGruntM9` at (16,6) sits behind the shutter: he is only reachable on the +second pass. + +**Scripts of interest** + +- `RadioTower3FCardKeyShutterCallback` (`callback MAPCALLBACK_TILES`, label at + `43:5be1`): `checkevent EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER`; if true, + `changeblock 14, 2, $2a` (open shutter) and `changeblock 14, 4, $01` (floor). + This is what makes the opening persist across map loads. +- `CardKeySlotScript` (`43:5c91`, exported with `::` so other maps can reach it): + writes `RadioTower3FCardKeySlotText`, then `checkevent + EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER` (already used -> just close), + else `checkitem CARD_KEY`. With the key: `InsertedTheCardKeyText`, `setevent + EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER`, `playsound SFX_ENTER_DOOR`, the + same two `changeblock`s, `refreshmap`. +- `RadioTower3FCooltrainerFScript` - after `EVENT_CLEARED_RADIO_TOWER`, + `verbosegiveitem TM_SUNNY_DAY` and `setevent + EVENT_GOT_SUNNY_DAY_FROM_RADIO_TOWER`. The walkthrough does not mention this + reward. + +--- + +### MAP_RADIO_TOWER_4F + +- Script: `maps/RadioTower4F.asm` +- Blocks: `maps/RadioTower4F.blk` +- Header: `data/maps/maps.asm:98` +- Dimensions: `constants/map_constants.asm:85` -> `map_const RADIO_TOWER_4F, 9, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `RADIO_TOWER_5F` | 1 | +| 2 | 9 | 0 | `RADIO_TOWER_3F` | 2 | +| 3 | 12 | 0 | `RADIO_TOWER_5F` | 2 | +| 4 | 17 | 0 | `RADIO_TOWER_3F` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 0 | `BGEVENT_READ` | `RadioTower4FProductionSign` | +| 15 | 0 | `BGEVENT_READ` | `RadioTower4FStudio2Sign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RADIOTOWER4F_FISHER` | `SPRITE_FISHER` | 6 | 4 | `STANDING_UP` | `SCRIPT` | `RadioTower4FFisherScript` | `EVENT_RADIO_TOWER_CIVILIANS_AFTER` | +| `RADIOTOWER4F_TEACHER` | `SPRITE_TEACHER` | 14 | 6 | `SPINRANDOM_SLOW` | `SCRIPT` | `RadioTower4FDJMaryScript` | -1 | +| `RADIOTOWER4F_GROWLITHE` | `SPRITE_GROWLITHE` | 12 | 7 | `POKEMON` | `SCRIPT` | `RadioTowerMeowth` | -1 | +| `RADIOTOWER4F_ROCKET1` | `SPRITE_ROCKET` | 5 | 6 | `SPINCLOCKWISE` | `TRAINER` (sight 3) | `TrainerGruntM10` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER4F_ROCKET2` | `SPRITE_ROCKET` | 14 | 1 | `STANDING_LEFT` (radius 2,0) | `TRAINER` (sight 2) | `TrainerExecutivem2` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER4F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 12 | 4 | `STANDING_RIGHT` | `TRAINER` (sight 1) | `TrainerGruntF4` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER4F_SCIENTIST` | `SPRITE_SCIENTIST` | 4 | 2 | `STANDING_LEFT` | `TRAINER` (sight 4) | `TrainerScientistRich` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | + +**Scripts of interest** + +- `TrainerExecutivem2` at (14,1) guards warp 3 (12,0), the second route to 5F. +- `RadioTower4FDJMaryScript` - after `EVENT_CLEARED_RADIO_TOWER`, + `verbosegiveitem PINK_BOW` and `setevent EVENT_GOT_PINK_BOW_FROM_MARY`. Another + reward the walkthrough skips. + +--- + +### MAP_RADIO_TOWER_5F + +- Script: `maps/RadioTower5F.asm` +- Blocks: `maps/RadioTower5F.blk` +- Header: `data/maps/maps.asm:99` +- Dimensions: `constants/map_constants.asm:86` -> `map_const RADIO_TOWER_5F, 9, 4` + +Scene ids come from this map's own `def_scene_scripts` block (`scene_script` +allocates them in order, `macros/scripts/maps.asm:25`): +`SCENE_RADIOTOWER5F_FAKE_DIRECTOR` = 0, `SCENE_RADIOTOWER5F_ROCKET_BOSS` = 1, +`SCENE_RADIOTOWER5F_NOOP` = 2. All three scene bodies are `end`; the work is +entirely in the coord events. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `RADIO_TOWER_4F` | 1 | +| 2 | 12 | 0 | `RADIO_TOWER_4F` | 3 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_RADIOTOWER5F_FAKE_DIRECTOR` (0) | 0 | 3 | `FakeDirectorScript` | fake director battle, `BASEMENT_KEY` | +| `SCENE_RADIOTOWER5F_ROCKET_BOSS` (1) | 16 | 5 | `RadioTower5FRocketBossScript` | final Executive, tower cleared | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 0 | `BGEVENT_READ` | `RadioTower5FDirectorsOfficeSign` | +| 11 | 0 | `BGEVENT_READ` | `RadioTower5FStudio1Sign` | +| 15 | 0 | `BGEVENT_READ` | `RadioTower5FStudio1Sign` | +| 16 | 1 | `BGEVENT_READ` | `RadioTower5FBookshelf` | +| 17 | 1 | `BGEVENT_READ` | `RadioTower5FBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `RADIOTOWER5F_DIRECTOR` | `SPRITE_GENTLEMAN` | 3 | 6 | `SPINRANDOM_SLOW` | `SCRIPT` | `Director` | -1 | +| `RADIOTOWER5F_ROCKET` | `SPRITE_ROCKET` | 13 | 5 | `STANDING_LEFT` | `SCRIPT` | `ObjectEvent` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER5F_ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 17 | 2 | `STANDING_LEFT` | `TRAINER` (sight 1) | `TrainerExecutivef1` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `RADIOTOWER5F_ROCKER` | `SPRITE_ROCKER` | 13 | 5 | `STANDING_LEFT` | `SCRIPT` | `Ben` | `EVENT_RADIO_TOWER_CIVILIANS_AFTER` | + +Note the deliberate coordinate collision at (13,5): the Rocket boss and DJ Ben +occupy the same cell, masked by complementary flags. + +**Scripts of interest** + +- `FakeDirectorScript` (`43:6748`) - `turnobject RADIOTOWER5F_DIRECTOR, UP`, + `showemote EMOTE_SHOCK`, text, `applymovement RADIOTOWER5F_DIRECTOR, + FakeDirectorMovement` (LEFT x3, UP x2, from (3,6) to (0,4)), `playmusic + MUSIC_ROCKET_ENCOUNTER`, `winlosstext FakeDirectorWinText, 0`, `setlasttalked + RADIOTOWER5F_DIRECTOR`, `loadtrainer EXECUTIVEM, EXECUTIVEM_3`, `startbattle`, + `reloadmapafterbattle`, `verbosegiveitem BASEMENT_KEY`, `setscene + SCENE_RADIOTOWER5F_ROCKET_BOSS`, `setevent EVENT_BEAT_ROCKET_EXECUTIVEM_3`. + Note the `verbosegiveitem` result is not checked - there is no bag-full arm. +- `RadioTower5FRocketBossScript` (`43:67a5`) - `applymovement PLAYER` two steps + LEFT (from (16,5) to (14,5)), `playmusic MUSIC_ROCKET_ENCOUNTER`, `turnobject + RADIOTOWER5F_ROCKET, RIGHT`, `loadtrainer EXECUTIVEM, EXECUTIVEM_1`, + `startbattle`, `reloadmapafterbattle`. Afterwards it fades out, disappears both + Rockets, then does the whole flag cascade: + + ``` + setevent EVENT_BEAT_ROCKET_EXECUTIVEM_1 + setevent EVENT_CLEARED_RADIO_TOWER + clearflag ENGINE_ROCKETS_IN_RADIO_TOWER + setevent EVENT_GOLDENROD_CITY_ROCKET_SCOUT + setevent EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER + setevent EVENT_RADIO_TOWER_ROCKET_TAKEOVER + clearevent EVENT_MAHOGANY_MART_OWNERS + clearflag ENGINE_ROCKETS_IN_MAHOGANY + clearevent EVENT_GOLDENROD_CITY_CIVILIANS + clearevent EVENT_RADIO_TOWER_CIVILIANS_AFTER + setevent EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM + clearevent EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM + ``` + + Then `moveobject RADIOTOWER5F_DIRECTOR, 12, 0` / `appear` / `applymovement + RadioTower5FDirectorWalksIn`, `checkver`: Silver takes `.SilverWing` + (`verbosegiveitem SILVER_WING`, `setevent EVENT_GOT_SILVER_WING`), Gold falls + through (`verbosegiveitem RAINBOW_WING`, `setevent EVENT_GOT_RAINBOW_WING`, + `setevent EVENT_TEAM_ROCKET_DISBANDED`). Both arms `setscene + SCENE_RADIOTOWER5F_NOOP` and end with `RadioTower5FDirectorWalksOut` + + `disappear`. +- `Director` (the talk-to script) branches on `EVENT_CLEARED_RADIO_TOWER`. + +--- + +### MAP_GOLDENROD_UNDERGROUND + +- Script: `maps/GoldenrodUnderground.asm` +- Blocks: `maps/GoldenrodUnderground.blk` +- Header: `data/maps/maps.asm:123` -> `TILESET_GATE`, `DUNGEON`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:110` -> `map_const GOLDENROD_UNDERGROUND, 15, 18` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 2 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 7 | +| 2 | 3 | 34 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 4 | +| 3 | 18 | 6 | `GOLDENROD_UNDERGROUND` | 4 | +| 4 | 21 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 5 | 22 | 31 | `GOLDENROD_UNDERGROUND` | 3 | +| 6 | 22 | 27 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 1 | + +Warps 3/4/5 are a same-map pair: the Basement Key door at (18,6) drops you at +(21,31), and (22,27) is the stairs into the switch room proper. + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 18 | 6 | `BGEVENT_READ` | `BasementDoorScript` | +| 19 | 6 | `BGEVENT_READ` | `GoldenrodUndergroundNoEntrySign` | +| 6 | 13 | `BGEVENT_ITEM` | `hiddenitem PARLYZ_HEAL, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL` | +| 4 | 18 | `BGEVENT_ITEM` | `hiddenitem SUPER_POTION, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION` | +| 17 | 8 | `BGEVENT_ITEM` | `hiddenitem ANTIDOTE, EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `GOLDENRODUNDERGROUND_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 31 | `STANDING_LEFT` | `TRAINER` (sight 3) | `TrainerSupernerdEric` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 6 | 9 | `STANDING_UP` | `TRAINER` (sight 2) | `TrainerSupernerdTeru` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD3` | `SPRITE_SUPER_NERD` | 3 | 27 | `SPINRANDOM_FAST` | `TRAINER` (sight 2) | `TrainerPokemaniacIssac` | -1 | +| `GOLDENRODUNDERGROUND_SUPER_NERD4` | `SPRITE_SUPER_NERD` | 2 | 6 | `STANDING_RIGHT` | `TRAINER` (sight 3) | `TrainerPokemaniacDonald` | -1 | +| `GOLDENRODUNDERGROUND_POKE_BALL` | `SPRITE_POKE_BALL` | 7 | 25 | `STILL` | `ITEMBALL` | `itemball COIN_CASE` | `EVENT_GOLDENROD_UNDERGROUND_COIN_CASE` | +| `GOLDENRODUNDERGROUND_GRAMPS` | `SPRITE_GRAMPS` | 7 | 11 | `STANDING_LEFT` | `SCRIPT` | `BargainMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRAMPS` | +| `GOLDENRODUNDERGROUND_OLDER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 14 | `STANDING_LEFT` | `SCRIPT` | `OlderHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | `SPRITE_SUPER_NERD` | 7 | 15 | `STANDING_LEFT` | `SCRIPT` | `YoungerHaircutBrotherScript` | `EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER` | +| `GOLDENRODUNDERGROUND_GRANNY` | `SPRITE_GRANNY` | 7 | 21 | `STANDING_LEFT` | `SCRIPT` | `BitterMerchantScript` | `EVENT_GOLDENROD_UNDERGROUND_GRANNY` | + +**Scripts of interest** + +- `BasementDoorScript` (`46:429e`, exported `::`) - `checkevent + EVENT_USED_BASEMENT_KEY` (already open -> "The door is open"), else `checkitem + BASEMENT_KEY`; with the key: `playsound SFX_TRANSACTION`, `changeblock 18, 6, + $2e` (unlocked door), `refreshmap`, `setevent EVENT_USED_BASEMENT_KEY`. Without + it: "The door's locked..." +- `GoldenrodUndergroundCheckBasementKeyCallback` (`MAPCALLBACK_TILES`) re-locks + the block on every map load until `EVENT_USED_BASEMENT_KEY` is set: + `changeblock 18, 6, $3d`. +- `GoldenrodUndergroundResetSwitchesCallback` (`MAPCALLBACK_NEWMAP`) - clears + `EVENT_SWITCH_1..3`, `EVENT_EMERGENCY_SWITCH`, `EVENT_DOOR_1_OPEN .. + EVENT_DOOR_11_OPEN`, and `setval 0` / `writemem wUndergroundSwitchPositions` + (`01:d6a8`). This is the "exit to the stairs then come back up so the switches + are reset" step the walkthrough relies on. The warehouse map has an identical + callback (`GoldenrodUndergroundWarehouseResetSwitchesCallback`). +- `OlderHaircutBrotherScript` / `YoungerHaircutBrotherScript` - the "Pokemon + Salon" happiness boost. Day-gated by + `GoldenrodUndergroundCheckDayOfWeekCallback` (`MAPCALLBACK_OBJECTS`): older + brother TUE/THU/SAT (500), younger brother SUN/WED/FRI (300). Once per day via + `ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT`. + +--- + +### MAP_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES + +- Script: `maps/GoldenrodUndergroundSwitchRoomEntrances.asm` +- Blocks: `maps/GoldenrodUndergroundSwitchRoomEntrances.blk` +- Header: `data/maps/maps.asm:124` -> `TILESET_ELITE_FOUR_ROOM`, `DUNGEON`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:111` -> `map_const GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES, 15, 18` + +Scene ids from this map's own block: +`SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_RIVAL_BATTLE` = 0, +`..._NOOP` = 1. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 3 | `GOLDENROD_UNDERGROUND` | 6 | +| 2 | 22 | 10 | `GOLDENROD_UNDERGROUND_WAREHOUSE` | 1 | +| 3 | 23 | 10 | `GOLDENROD_UNDERGROUND_WAREHOUSE` | 2 | +| 4 | 5 | 25 | `GOLDENROD_UNDERGROUND` | 2 | +| 5 | 4 | 29 | `GOLDENROD_CITY` | 15 | +| 6 | 5 | 29 | `GOLDENROD_CITY` | 15 | +| 7 | 21 | 25 | `GOLDENROD_UNDERGROUND` | 1 | +| 8 | 20 | 29 | `GOLDENROD_CITY` | 14 | +| 9 | 21 | 29 | `GOLDENROD_CITY` | 14 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `..._RIVAL_BATTLE` (0) | 19 | 4 | `UndergroundRivalScene1` | rival appears from (23,3), battle | +| `..._RIVAL_BATTLE` (0) | 19 | 5 | `UndergroundRivalScene2` | same, one row lower | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 1 | `BGEVENT_READ` | `Switch1Script` | +| 10 | 1 | `BGEVENT_READ` | `Switch2Script` | +| 2 | 1 | `BGEVENT_READ` | `Switch3Script` | +| 20 | 11 | `BGEVENT_READ` | `EmergencySwitchScript` | +| 8 | 9 | `BGEVENT_ITEM` | `hiddenitem MAX_POTION, EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_MAX_POTION` | +| 1 | 8 | `BGEVENT_ITEM` | `hiddenitem REVIVE, EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_REVIVE` | + +The two hidden items are not mentioned by the walkthrough. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._PHARMACIST1` | `SPRITE_PHARMACIST` | 9 | 12 | `STANDING_RIGHT` | `TRAINER` (sight 2) | `TrainerBurglarDuncan` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._PHARMACIST2` | `SPRITE_PHARMACIST` | 4 | 8 | `STANDING_LEFT` | `TRAINER` (sight 2) | `TrainerBurglarEddie` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET1` | `SPRITE_ROCKET` | 17 | 2 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM13` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET2` | `SPRITE_ROCKET` | 11 | 2 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM11` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET3` | `SPRITE_ROCKET` | 3 | 2 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerGruntM25` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET_GIRL` | `SPRITE_ROCKET_GIRL` | 19 | 12 | `STANDING_DOWN` | `TRAINER` (sight 1) | `TrainerGruntF3` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._TEACHER` | `SPRITE_TEACHER` | 3 | 27 | `STANDING_DOWN` | `SCRIPT` | `GoldenrodUndergroundSwitchRoomEntrancesTeacherScript` | -1 | +| `..._SUPER_NERD` | `SPRITE_SUPER_NERD` | 19 | 27 | `STANDING_DOWN` | `SCRIPT` | `GoldenrodUndergroundSwitchRoomEntrancesSuperNerdScript` | -1 | +| `..._POKE_BALL1` | `SPRITE_POKE_BALL` | 1 | 12 | `STILL` | `ITEMBALL` | `itemball SMOKE_BALL` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL` | +| `..._POKE_BALL2` | `SPRITE_POKE_BALL` | 14 | 9 | `STILL` | `ITEMBALL` | `itemball FULL_HEAL` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL` | +| `..._RIVAL` | `SPRITE_RIVAL` | 23 | 3 | `STANDING_DOWN` | `SCRIPT` | `ObjectEvent` | `EVENT_RIVAL_GOLDENROD_UNDERGROUND` | + +**Scripts of interest - the shutter puzzle** + +This is the load-bearing part of the section, and the asm behaves differently +from how the walkthrough describes it. + +The eleven shutters are declared at the top of the file by the local +`ugdoor_def` macro (coordinates are `changeblock` coordinates, i.e. the same +cell units the script uses): + +| door | x, y | closed block | open block | second half (x, y, closed, open) | +|---|---|---|---|---| +| 1 | 16, 6 | `$3e` | `$2d` | - | +| 2 | 10, 6 | `$3e` | `$2d` | - | +| 3 | 2, 6 | `$3e` | `$2d` | - | +| 4 | 2, 10 | `$3e` | `$2d` | - | +| 5 | 10, 10 | `$3e` | `$2d` | - | +| 6 | 16, 10 | `$3e` | `$2d` | - | +| 7 | 12, 6 | `$3f` | `$2a` | 12, 8, `$3d`, `$2d` | +| 8 | 6, 6 | `$3f` | `$2a` | 6, 8, `$3d`, `$2d` | +| 9 | 12, 10 | `$3f` | `$2a` | 12, 12, `$3d`, `$2d` | +| 10 | 6, 10 | `$3f` | `$2a` | 6, 12, `$3d`, `$2d` | +| 11 | 18, 10 | `$3f` | `$2a` | 18, 12, `$3d`, `$2d` | + +`Switch1Script` / `Switch2Script` / `Switch3Script` each `readmem +wUndergroundSwitchPositions` (`01:d6a8`), `addval +N` when turning ON or +`addval -N` when turning OFF (N = 1, 2, 3 respectively), `writemem` it back, +set/clear `EVENT_SWITCH_N`, then `sjump +GoldenrodUndergroundSwitchRoomEntrances_UpdateDoors` (`46:4c57`). + +`EmergencySwitchScript` at (20,11) is different: ON does `setval 7` (not an +add), sets `EVENT_EMERGENCY_SWITCH` and all three `EVENT_SWITCH_*`; OFF does +`setval 0` and clears all four. + +`..._UpdateDoors` dispatches on the stored byte. Crucially each position only +touches SOME doors - the rest keep whatever state the previous position left +them in, which is the entire reason order matters: + +| value | opens | closes | untouched | +|---|---|---|---| +| 0 | - | 1..11 | - | +| 1 | 1, 7, 10 | 6, 8, 9, 11 | 2, 3, 4, 5 | +| 2 | 2, 8, 9 | 5, 7, 10, 11 | 1, 3, 4, 6 | +| 3 | 3, 7, 10 | 4, 8, 9, 11 | 1, 2, 5, 6 | +| 4 | 4, 8, 9 | 3, 7, 10, 11 | 1, 2, 5, 6 | +| 5 | 5, 7, 10 | 2, 8, 9, 11 | 1, 3, 4, 6 | +| 6 | 6, 8, 9, 11 | 1, 7, 10 | 2, 3, 4, 5 | +| 7 (emergency) | 3, 5, 6, 8, 9, 11 | 1, 2, 4, 7, 10 | - | + +The emergency arm ends with `setval 6` / `writemem`, so the stored byte becomes +6 afterwards even though the door layout is the emergency one. + +Consequences a bot needs (derived by tracing the table, from the reset state +where the stored byte is 0 and all doors closed): + +- Flip 1, then 2, then 3 (values 1 -> 3 -> 6). Final open doors: 3, 6, 8, 9, 11. + Door 8 at (6,6)/(6,8) is what lets you drop south from the switch-3 alcove to + `TrainerBurglarEddie` at (4,8). +- From there flip 3 off then 2 off (values 6 -> 3 -> 1). Final open doors: 1, 3, + 7, 10. Door 7 at (12,6)/(12,8) is what opens the way to the Full Heal ball at + (14,9). +- Reset (leave via a warp so the `MAPCALLBACK_NEWMAP` reset runs) and flip 3, + then 2, then 1 (values 3 -> 5 -> 6). Final open doors: 3, 5, 6, 8, 9, 11 - + the same set as 1/2/3 PLUS door 5 at (10,10). Door 5 is the one that opens the + route south to `TrainerBurglarDuncan` (9,12), `TrainerGruntF3` (19,12) and the + warehouse warps at (22,10)/(23,10). + +So the walkthrough's "turn them on in the opposite direction" is correct in +effect, but the mechanism is not "order is remembered": the stored byte is a +plain SUM (1+2+3 = 3+2+1 = 6), and what differs is which doors the intermediate +positions left untouched. + +`UndergroundRivalBattleScript` (`46:4a9e`): + +``` +checkevent EVENT_RIVAL_BURNED_TOWER +iftrue .Continue +setevent EVENT_RIVAL_BURNED_TOWER +setmapscene BURNED_TOWER_1F, SCENE_BURNEDTOWER1F_FIREBREATHER_DICK +.Continue: +playmusic MUSIC_RIVAL_ENCOUNTER +... setevent EVENT_RIVAL_GOLDENROD_UNDERGROUND +checkevent EVENT_GOT_TOTODILE_FROM_ELM -> loadtrainer RIVAL1, RIVAL1_4_CHIKORITA +checkevent EVENT_GOT_CHIKORITA_FROM_ELM -> loadtrainer RIVAL1, RIVAL1_4_CYNDAQUIL +else -> loadtrainer RIVAL1, RIVAL1_4_TOTODILE +``` + +i.e. the rival's starter is the one with type advantage over yours, as usual. +`winlosstext UndergroundRivalWinText, UndergroundRivalLossText` - this battle is +LOSABLE (a real loss text exists), unlike the Executive fights. + +--- + +### MAP_GOLDENROD_UNDERGROUND_WAREHOUSE + +- Script: `maps/GoldenrodUndergroundWarehouse.asm` +- Blocks: `maps/GoldenrodUndergroundWarehouse.blk` +- Header: `data/maps/maps.asm:126` -> `TILESET_UNDERGROUND`, `DUNGEON`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DAY`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:113` -> `map_const GOLDENROD_UNDERGROUND_WAREHOUSE, 10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 12 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 2 | +| 2 | 3 | 12 | `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` | 3 | +| 3 | 17 | 2 | `GOLDENROD_DEPT_STORE_B1F` | 1 | + +**Coord events** - none. +**BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._ROCKET1` | `SPRITE_ROCKET` | 9 | 8 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerGruntM24` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET2` | `SPRITE_ROCKET` | 8 | 15 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerGruntM14` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._ROCKET3` | `SPRITE_ROCKET` | 14 | 3 | `STANDING_RIGHT` | `TRAINER` (sight 4) | `TrainerGruntM15` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._GENTLEMAN` | `SPRITE_GENTLEMAN` | 12 | 8 | `SPINRANDOM_SLOW` | `SCRIPT` | `GoldenrodUndergroundWarehouseDirectorScript` | `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | +| `..._POKE_BALL1` | `SPRITE_POKE_BALL` | 18 | 15 | `STILL` | `ITEMBALL` | `itemball MAX_ETHER` | `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_MAX_ETHER` | +| `..._POKE_BALL2` | `SPRITE_POKE_BALL` | 13 | 9 | `STILL` | `ITEMBALL` | `itemball TM_SLEEP_TALK` | `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_TM_SLEEP_TALK` | + +Note the Director object is masked by `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` the +same way the Grunts are: once the tower is cleared he is gone from here. + +**Scripts of interest** + +- `GoldenrodUndergroundWarehouseDirectorScript` (`46:598a`) - `checkevent + EVENT_RECEIVED_CARD_KEY`; if not: `DirectorIntroText`, `verbosegiveitem + CARD_KEY`, `setevent EVENT_RECEIVED_CARD_KEY`, `setevent + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1`, `clearevent ..._LAYOUT_2`, + `clearevent ..._LAYOUT_3`, `DirectorCardKeyText` ("Use that to open the + shutters on 3F"), then `DirectorAfterText`. The `verbosegiveitem` result is + again unchecked. + +--- + +### MAP_GOLDENROD_DEPT_STORE_B1F + +- Script: `maps/GoldenrodDeptStoreB1F.asm` +- Blocks: `maps/GoldenrodDeptStoreB1F.blk` +- Header: `data/maps/maps.asm:125` -> `TILESET_UNDERGROUND`, `DUNGEON`, + `LANDMARK_GOLDENROD_CITY`, `MUSIC_GOLDENROD_CITY`, phone `TRUE`, + `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:112` -> `map_const GOLDENROD_DEPT_STORE_B1F, 10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 2 | `GOLDENROD_UNDERGROUND_WAREHOUSE` | 3 | +| 2 | 9 | 4 | `GOLDENROD_DEPT_STORE_ELEVATOR` | 1 | +| 3 | 10 | 4 | `GOLDENROD_DEPT_STORE_ELEVATOR` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._POKE_BALL1` | `SPRITE_POKE_BALL` | 10 | 15 | `STILL` | `ITEMBALL` | `itemball ETHER` | `EVENT_GOLDENROD_DEPT_STORE_B1F_ETHER` | +| `..._POKE_BALL2` | `SPRITE_POKE_BALL` | 14 | 2 | `STILL` | `ITEMBALL` | `itemball AMULET_COIN` | `EVENT_GOLDENROD_DEPT_STORE_B1F_AMULET_COIN` | +| `..._POKE_BALL3` | `SPRITE_POKE_BALL` | 6 | 3 | `STILL` | `ITEMBALL` | `itemball BURN_HEAL` | `EVENT_GOLDENROD_DEPT_STORE_B1F_BURN_HEAL` | +| `..._POKE_BALL4` | `SPRITE_POKE_BALL` | 15 | 15 | `STILL` | `ITEMBALL` | `itemball ULTRA_BALL` | `EVENT_GOLDENROD_DEPT_STORE_B1F_ULTRA_BALL` | +| `..._BLACK_BELT1` | `SPRITE_BLACK_BELT` | 9 | 10 | `WALK_UP_DOWN` | `SCRIPT` | `GoldenrodDeptStoreB1FBlackBelt1Script` | -1 | +| `..._BLACK_BELT2` | `SPRITE_BLACK_BELT` | 4 | 8 | `SPINRANDOM_SLOW` | `SCRIPT` | `GoldenrodDeptStoreB1FBlackBelt2Script` | -1 | +| `..._BLACK_BELT3` | `SPRITE_BLACK_BELT` | 6 | 13 | `WALK_LEFT_RIGHT` | `SCRIPT` | `GoldenrodDeptStoreB1FBlackBelt3Script` | -1 | +| `..._MACHOP` | `SPRITE_MACHOP` | 7 | 7 | `POKEMON` | `SCRIPT` | `GoldenrodDeptStoreB1FMachopScript` | -1 | + +**Scripts of interest** + +- `GoldenRodDeptStoreB1FClearBoxesCallback` (`MAPCALLBACK_TILES`) - `checkevent + EVENT_RECEIVED_CARD_KEY` -> `changeblock 16, 4, $0d` (floor). Then the crate + layout: `EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_2` -> `changeblock 4, 10, $0d`; + `..._LAYOUT_3` -> `changeblock 10, 12, $0d`; default (LAYOUT_1) -> + `changeblock 10, 8, $0d`. +- `GoldenRodDeptStoreUnblockCallback` (`MAPCALLBACK_NEWMAP`) - `clearevent + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF`. That event starts SET + (`InitializeEventsScript`, `engine/events/std_scripts.asm:518`) and is what + `GoldenrodDeptStoreElevatorScript` (`maps/GoldenrodDeptStoreElevator.asm:15`) + checks before rotating the crate layout - so the layout only ever changes + once you have entered B1F on foot from the warehouse. + +**Flags and events (whole section)** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_ROCKETS_IN_RADIO_TOWER` | `constants/engine_flags.asm:28` | set by `RadioTowerRocketsScript`; cleared by `RadioTower5FRocketBossScript`; read by `RadioTower1FReceptionistScript`, `RadioTower2FTeacherScript`, `MahoganyRedGyaradosSpeechHouse`, `engine/phone/scripts/trainers.asm` | "the takeover arc is live"; also switches the radio programming | +| `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` | `constants/event_flags.asm:1136` | CLEARED by `RadioTowerRocketsScript`, SET again by `RadioTower5FRocketBossScript` | object mask for every Rocket trainer in this section; SET = hidden | +| `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER` | `:1135` | cleared by `GoldenrodRocketsScript` (6 badges), set by `RadioTower5FRocketBossScript` | the two earliest city Rockets | +| `EVENT_GOLDENROD_CITY_ROCKET_SCOUT` | `:1134` | set by `RadioTower5FRocketBossScript` | hides the scout at (4,16) once cleared | +| `EVENT_GOLDENROD_CITY_CIVILIANS` | `:1137` | set by `RadioTowerRocketsScript`, cleared by `RadioTower5FRocketBossScript` | SET hides the city + 1F/2F civilians for the whole section | +| `EVENT_RADIO_TOWER_CIVILIANS_AFTER` | `:1138` | set at `InitializeEventsScript`, cleared by `RadioTower5FRocketBossScript` | the "after" NPCs (Ben, 3F super nerd, 4F fisher, 2F black belt 2) | +| `EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS` | `:1139` | set by `RadioTowerRocketsScript` | SET removes the 2F stairs blocker, i.e. the takeover is what opens 3F | +| `EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER` | `:46` | cleared by `RadioTowerRocketsScript`, set by `CardKeySlotScript`, read by `RadioTower3FCardKeyShutterCallback` and `RadioTower3FCooltrainerFScript` | the 3F shutter | +| `EVENT_CLEARED_RADIO_TOWER` | `:42` | set by `RadioTower5FRocketBossScript` | section-complete flag; gates the TM Sunny Day / Pink Bow rewards | +| `EVENT_USED_BASEMENT_KEY` | `:82` | set by `BasementDoorScript`, read by `GoldenrodUndergroundCheckBasementKeyCallback` | the Underground locked door | +| `EVENT_RECEIVED_CARD_KEY` | `:83` | set by `GoldenrodUndergroundWarehouseDirectorScript`, read by `GoldenRodDeptStoreB1FClearBoxesCallback` | one-shot on the Card Key | +| `EVENT_SWITCH_1/2/3`, `EVENT_EMERGENCY_SWITCH` | `:386-389` | the four switch scripts; cleared by both reset callbacks | which switch is lit | +| `EVENT_DOOR_1_OPEN .. EVENT_DOOR_11_OPEN` | `:390-400` | `..._UpdateDoors` `.OpenDoorN` / `.CloseDoorN`; replayed by `..._UpdateDoorPositionsCallback` (`MAPCALLBACK_TILES`) | per-shutter persistent state | +| `EVENT_RIVAL_GOLDENROD_UNDERGROUND` | `:1123` | set inside `UndergroundRivalBattleScript` | masks the rival object afterwards | +| `EVENT_RIVAL_BURNED_TOWER` | grep `constants/event_flags.asm` | set by `UndergroundRivalBattleScript` if not already | side effect: sets `BURNED_TOWER_1F` scene to `SCENE_BURNEDTOWER1F_FIREBREATHER_DICK` | +| `EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1/2/3` | `:435-437` | director script, `GoldenrodDeptStoreElevatorScript` | which crate row is open on B1F | +| `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF` | `:438` | set by `InitializeEventsScript`, cleared by `GoldenRodDeptStoreUnblockCallback` | freezes the crate rotation until you have walked B1F | +| `EVENT_TEAM_ROCKET_DISBANDED` | `:1283` | set by `RadioTower5FRocketBossScript` **only on the Gold branch**; also set in `maps/PewterCity.asm:51` | masks the `TIN_TOWER_1F` sage at (10,2) | +| `EVENT_GOT_RAINBOW_WING` / `EVENT_GOT_SILVER_WING` | `:129-130` | `RadioTower5FRocketBossScript` `checkver` arms | Gold vs Silver reward | +| `SCENE_RADIOTOWER5F_FAKE_DIRECTOR` = 0, `_ROCKET_BOSS` = 1, `_NOOP` = 2 | `maps/RadioTower5F.asm:9-11` | `setscene` in both coord scripts | which 5F cutscene is armed | +| `SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_RIVAL_BATTLE` = 0, `_NOOP` = 1 | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm:57-58` | `setscene` at the end of both rival scenes | one-shot rival trip-wire | +| `SPECIALCALL_WEIRDBROADCAST` = 4 | `constants/phone_constants.asm:48` | queued by `RadioTowerRocketsScript`; row 4 of `data/phone/special_calls.asm` is `SpecialCallOnlyWhenOutside, PHONECONTACT_ELM, ElmPhoneCallerScript`; consumed at `engine/phone/scripts/elm.asm:68` `.rocket` | Elm's call the walkthrough opens with. Only rings OUTDOORS. | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `BASEMENT_KEY` | `verbosegiveitem` after `EXECUTIVEM_3` | `FakeDirectorScript`, `maps/RadioTower5F.asm` | `EVENT_BEAT_ROCKET_EXECUTIVEM_3` (scene also advances) | +| `SMOKE_BALL` | ground ball at (1,12) | `GoldenrodUndergroundSwitchRoomEntrancesSmokeBall` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL` | +| `FULL_HEAL` | ground ball at (14,9) | `GoldenrodUndergroundSwitchRoomEntrancesFullHeal` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL` | +| `MAX_POTION` | hidden at (8,9) | `bg_event 8, 9, BGEVENT_ITEM` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_MAX_POTION` | +| `REVIVE` | hidden at (1,8) | `bg_event 1, 8, BGEVENT_ITEM` | `EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_REVIVE` | +| `MAX_ETHER` | ground ball at (18,15) | `GoldenrodUndergroundWarehouseMaxEther` | `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_MAX_ETHER` | +| `TM_SLEEP_TALK` (TM35) | ground ball at (13,9) | `GoldenrodUndergroundWarehouseTMSleepTalk` | `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_TM_SLEEP_TALK` | +| `CARD_KEY` | talk to Director at (12,8) | `GoldenrodUndergroundWarehouseDirectorScript` | `EVENT_RECEIVED_CARD_KEY` | +| `AMULET_COIN` | ground ball at (14,2) on B1F | `GoldenrodDeptStoreB1FAmuletCoin` | `EVENT_GOLDENROD_DEPT_STORE_B1F_AMULET_COIN` | +| `RAINBOW_WING` (Gold) / `SILVER_WING` (Silver) | director cutscene | `RadioTower5FRocketBossScript` `checkver` | `EVENT_GOT_RAINBOW_WING` / `EVENT_GOT_SILVER_WING` | +| `TM_SUNNY_DAY` (TM11) | talk 3F cooltrainer_f (11,3) after clearing | `RadioTower3FCooltrainerFScript` | `EVENT_GOT_SUNNY_DAY_FROM_RADIO_TOWER` | +| `PINK_BOW` | talk DJ Mary (14,6) after clearing | `RadioTower4FDJMaryScript` | `EVENT_GOT_PINK_BOW_FROM_MARY` | + +TM numbering checked against `constants/item_constants.asm:219` (`TM01` = +`DYNAMICPUNCH`): `SUNNY_DAY` is TM11 and `SLEEP_TALK` is TM35. + +**Trainers** + +All parties from `data/trainers/parties.asm`; class group labels named in the +"party" column. Constants from `constants/trainer_constants.asm`. + +| const | class | id | party (`data/trainers/parties.asm`) | script label | map | +|---|---|---|---|---|---| +| `GRUNTM_3` | `GRUNTM` | 3 | `GruntMGroup` "; GRUNTM (3)": 24 `RATICATE`, 24 `RATICATE` | `TrainerGruntM3` | `RADIO_TOWER_1F` (14,1) | +| `GRUNTF_2` | `GRUNTF` | 2 | `GruntFGroup` (2): 26 `ARBOK` | `TrainerGruntF2` | `RADIO_TOWER_2F` (10,5) | +| `GRUNTM_5` | `GRUNTM` | 5 | (5): 21 `RATTATA`, 21 `RATTATA`, 23 `RATTATA`, 23 `RATTATA`, 23 `RATTATA` | `TrainerGruntM5` | `RADIO_TOWER_2F` (8,4) | +| `GRUNTM_6` | `GRUNTM` | 6 | (6): 26 `ZUBAT`, 26 `ZUBAT` | `TrainerGruntM6` | `RADIO_TOWER_2F` (4,1) | +| `GRUNTM_4` | `GRUNTM` | 4 | (4): 23 `GRIMER`, 23 `GRIMER`, 25 `MUK` | `TrainerGruntM4` | `RADIO_TOWER_2F` (1,4) | +| `GRUNTM_8` | `GRUNTM` | 8 | (8): 26 `WEEZING` | `TrainerGruntM8` | `RADIO_TOWER_3F` (6,2) | +| `GRUNTM_7` | `GRUNTM` | 7 | (7): 23 `KOFFING`, 23 `GRIMER`, 23 `ZUBAT`, 23 `RATTATA` | `TrainerGruntM7` | `RADIO_TOWER_3F` (5,1) | +| `MARC` | `SCIENTIST` | 4 | `ScientistGroup` (4) "MARC": 27 `MAGNEMITE` x3 | `TrainerScientistMarc` | `RADIO_TOWER_3F` (9,6) | +| `GRUNTM_10` | `GRUNTM` | 10 | (10): 22 `ZUBAT`, 24 `GOLBAT`, 22 `GRIMER` | `TrainerGruntM10` | `RADIO_TOWER_4F` (5,6) | +| `RICH` | `SCIENTIST` | 5 | `ScientistGroup` (5) "RICH", `TRAINERTYPE_MOVES`: 30 `PORYGON` (Conversion, Conversion2, Recover, Tri Attack) | `TrainerScientistRich` | `RADIO_TOWER_4F` (4,2) | +| `EXECUTIVEM_3` | `EXECUTIVEM` | 3 | `ExecutiveMGroup` (3), `TRAINERTYPE_MOVES`: 30 `KOFFING` x3 (Tackle/Selfdestruct/Sludge/Smokescreen), 32 `WEEZING` (Tackle/Explosion/Sludge/Smokescreen), 30 `KOFFING`, 30 `KOFFING` (Tackle/Smog/Sludge/Smokescreen) | `FakeDirectorScript` (`loadtrainer`) | `RADIO_TOWER_5F` coord (0,3) | +| `RIVAL1_4_CHIKORITA` | `RIVAL1` | 10 | `Rival1Group` (10): 30 `GOLBAT`, 28 `MAGNEMITE`, 30 `HAUNTER`, 32 `SNEASEL`, 32 `MEGANIUM` | `UndergroundRivalBattleScript` | switch room coord (19,4)/(19,5) | +| `RIVAL1_4_CYNDAQUIL` | `RIVAL1` | 11 | (11): same first four, 32 `QUILAVA` | as above | as above | +| `RIVAL1_4_TOTODILE` | `RIVAL1` | 12 | (12): same first four, 32 `FERALIGATR` | as above | as above | +| `GRUNTM_13` | `GRUNTM` | 13 | (13): 27 `RATTATA` | `TrainerGruntM13` | switch room (17,2) | +| `GRUNTM_11` | `GRUNTM` | 11 | (11): 23 `MUK`, 23 `KOFFING`, 25 `RATTATA` | `TrainerGruntM11` | switch room (11,2) | +| `GRUNTM_25` | `GRUNTM` | 25 | (25): 24 `KOFFING`, 24 `MUK` | `TrainerGruntM25` | switch room (3,2) | +| `EDDIE` | `BURGLAR` | 2 | `BurglarGroup` (2) "EDDIE", `TRAINERTYPE_MOVES`: 26 `GROWLITHE` (Roar/Ember/Leer/Take Down), 24 `KOFFING` (Tackle/Smog/Sludge/Smokescreen) | `TrainerBurglarEddie` | switch room (4,8) | +| `DUNCAN` | `BURGLAR` | 1 | `BurglarGroup` (1) "DUNCAN": 23 `KOFFING`, 25 `MAGMAR`, 23 `KOFFING` | `TrainerBurglarDuncan` | switch room (9,12) | +| `GRUNTF_3` | `GRUNTF` | 3 | (3): 25 `GLOOM`, 25 `GLOOM` | `TrainerGruntF3` | switch room (19,12) | +| `GRUNTM_14` | `GRUNTM` | 14 | (14): 24 `RATICATE`, 24 `GOLBAT` | `TrainerGruntM14` | warehouse (8,15) | +| `GRUNTM_15` | `GRUNTM` | 15 | (15): 26 `GRIMER`, 23 `WEEZING` | `TrainerGruntM15` | warehouse (14,3) | +| `GRUNTM_24` | `GRUNTM` | 24 | (24): 25 `KOFFING`, 25 `KOFFING` | `TrainerGruntM24` | warehouse (9,8) | +| `GRUNTM_9` | `GRUNTM` | 9 | (9): 24 `RATICATE`, 26 `KOFFING` | `TrainerGruntM9` | `RADIO_TOWER_3F` (16,6), behind the shutter | +| `GRUNTF_4` | `GRUNTF` | 4 | (4): 21 `EKANS`, 23 `ODDISH`, 21 `EKANS`, 24 `GLOOM` | `TrainerGruntF4` | `RADIO_TOWER_4F` (12,4) | +| `EXECUTIVEM_2` | `EXECUTIVEM` | 2 | `ExecutiveMGroup` (2), `TRAINERTYPE_MOVES`: 36 `GOLBAT` (Leech Life/Bite/Confuse Ray/Wing Attack) | `TrainerExecutivem2` | `RADIO_TOWER_4F` (14,1) | +| `EXECUTIVEF_1` | `EXECUTIVEF` | 1 | `ExecutiveFGroup` (1), `TRAINERTYPE_MOVES`: 32 `ARBOK` (Wrap/Poison Sting/Bite/Glare), 32 `VILEPLUME` (Absorb/Sweet Scent/Sleep Powder/Acid), 32 `MURKROW` (Peck/Pursuit/Haze/Night Shade) | `TrainerExecutivef1` | `RADIO_TOWER_5F` (17,2) | +| `EXECUTIVEM_1` | `EXECUTIVEM` | 1 | `ExecutiveMGroup` (1), `TRAINERTYPE_MOVES`: 33 `HOUNDOUR` (Ember/Roar/Bite/Faint Attack), 33 `KOFFING` (Tackle/Sludge/Smokescreen/Haze), 35 `HOUNDOOM` (Ember/Smog/Bite/Faint Attack) | `RadioTower5FRocketBossScript` (`loadtrainer`) | `RADIO_TOWER_5F` coord (16,5) | + +Also present but optional in this stretch (`MAP_GOLDENROD_UNDERGROUND`, all +`event flag -1` so always visible): `TrainerSupernerdEric` (`SUPER_NERD`, +`ERIC`), `TrainerSupernerdTeru` (`SUPER_NERD`, `TERU`), +`TrainerPokemaniacIssac`, `TrainerPokemaniacDonald`. + +Neither Executive fight nor the fake director fight has a loss text +(`winlosstext ..., 0`), so a whiteout there is a normal blackout, not a scripted +loss. The rival fight DOES have one (`UndergroundRivalLossText`). + +**Wild encounters** + +None. Grepping `data/wild/johto_grass.asm`, `johto_water.asm`, `fish.asm` and +`treemons.asm` for `RADIO_TOWER` or `GOLDENROD` returns nothing: every map in +this section is `INDOOR` or `DUNGEON` with no wild table. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| The whole arc does not exist yet | `maps/*Gym.asm` `<Gym>ActivateRockets` -> `engine/events/std_scripts.asm:255 RadioTowerRocketsScript` (`40:41dc`) | `readvar VAR_BADGES` equals 7 immediately after a badge is set | Earn the 7th badge (normally Pryce's Glacierbadge). That script sets `ENGINE_ROCKETS_IN_RADIO_TOWER`, clears `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` (spawns every Grunt) and queues `SPECIALCALL_WEIRDBROADCAST`. `ifequal 6` instead runs `GoldenrodRocketsScript`, which only clears `EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER`. | +| Elm's call never rings | `data/phone/special_calls.asm` row 4 = `SpecialCallOnlyWhenOutside` | Player must be on an outdoor map | Walk outside; the call is cosmetic, not a gate on anything | +| 2F -> 3F stairs (pre-takeover only) | `maps/RadioTower2F.asm` object `RADIOTOWER2F_BLACK_BELT1` at (0,1), masked by `EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS` | Flag must be SET for him to vanish (`CheckObjectFlag`, `engine/overworld/map_objects_2.asm`) | `RadioTowerRocketsScript` sets it | +| 3F -> 4F east stairs (warp 3 at (17,0)) and `TrainerGruntM9` | `maps/RadioTower3F.asm:127 CardKeySlotScript` (`43:5c91`) + `RadioTower3FCardKeyShutterCallback` (`43:5be1`) | `checkitem CARD_KEY`, and you must press A **facing up** on the tile below (14,2) because the bg event is `BGEVENT_UP` | `EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER`, which rewrites blocks (14,2) and (14,4) | +| 5F Rocket boss unreachable on the first visit | `maps/RadioTower5F.asm` scene: `setscene SCENE_RADIOTOWER5F_ROCKET_BOSS` only at the end of `FakeDirectorScript` | Beat `EXECUTIVEM_3` | The (16,5) coord event only fires on scene 1 | +| Underground locked door at (18,6) | `maps/GoldenrodUnderground.asm:372 BasementDoorScript` (`46:429e`) + `GoldenrodUndergroundCheckBasementKeyCallback` | `checkitem BASEMENT_KEY` | `EVENT_USED_BASEMENT_KEY`; `changeblock 18, 6, $2e` | +| The eleven shutters in the switch room | `maps/GoldenrodUndergroundSwitchRoomEntrances.asm:346 ..._UpdateDoors` (`46:4c57`), driven by `wUndergroundSwitchPositions` (`01:d6a8`) | No item. A pure state puzzle over a single byte | See the position table in section 2. Resets to 0 on any `MAPCALLBACK_NEWMAP` of `GOLDENROD_UNDERGROUND` or `GOLDENROD_UNDERGROUND_WAREHOUSE`; the emergency switch at (20,11) forces the "everything useful open" layout | +| Rival ambush | `coord_event 19, 4 / 19, 5` on scene 0 | Walking onto either cell | Scene set to `..._NOOP` at the end of `UndergroundRivalScene1/2` | +| Dept store B1F crate layout | `GoldenRodDeptStoreB1FClearBoxesCallback` | `EVENT_RECEIVED_CARD_KEY` opens block (16,4); layout events pick one of three crate gaps | Card Key from the Director; the elevator only rotates the layout once `EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF` is cleared by walking into B1F | + +No HM field move is required anywhere in this section - no Cut, Surf, Strength, +Whirlpool, Waterfall or Flash check appears in any of these maps. + +--- + +## 4. Bot checklist + +Coordinates are asm cell coordinates. "talk" means face the target and press A. + +Pass 1 - Radio Tower up to the Basement Key: + +1. `MAP_GOLDENROD_CITY`: precondition `ENGINE_ROCKETS_IN_RADIO_TOWER` set (7 + badges). Walk to (5,15), warp 12 -> `RADIO_TOWER_1F` (2,7). +2. `RADIO_TOWER_1F`: walk toward (15,0). `TrainerGruntM3` at (14,1) sights you + (range 3, facing down). Battle `GRUNTM_3`. Post: `EVENT_BEAT_ROCKET_GRUNTM_3`. +3. Warp 3 at (15,0) -> `RADIO_TOWER_2F` (15,0). +4. `RADIO_TOWER_2F`: walk left. Fight in this order: `TrainerGruntF2` (10,5), + `TrainerGruntM5` (8,4), `TrainerGruntM6` (4,1), `TrainerGruntM4` (1,4). + Precondition for all: `EVENT_RADIO_TOWER_ROCKET_TAKEOVER` clear. +5. Warp 1 at (0,0) -> `RADIO_TOWER_3F` (0,0). +6. `RADIO_TOWER_3F`: `TrainerGruntM8` (6,2), `TrainerGruntM7` (5,1), + `TrainerScientistMarc` (9,6, sight 5). Do NOT try (14,2) yet - no Card Key. +7. Warp 2 at (7,0) -> `RADIO_TOWER_4F` (9,0). +8. `RADIO_TOWER_4F`: `TrainerGruntM10` (5,6), `TrainerScientistRich` (4,2). +9. Warp 1 at (0,0) -> `RADIO_TOWER_5F` (0,0). Walk DOWN to (0,3): coord event + fires `FakeDirectorScript` on scene 0. Battle `EXECUTIVEM_3` (6 mons, four of + them know Selfdestruct/Explosion). Post: `BASEMENT_KEY` in bag, + `EVENT_BEAT_ROCKET_EXECUTIVEM_3`, 5F scene = 1. +10. Retrace: 5F warp 1 -> 4F warp 1 -> 4F warp 2 (9,0) -> 3F warp 2 -> 3F warp 1 + (0,0) -> 2F warp 1 -> 2F warp 2 (15,0) -> 1F warp 3 -> 1F warps 1/2 (2,7) -> + `GOLDENROD_CITY` (5,15). + +Pass 2 - Underground to the Card Key: + +11. `GOLDENROD_CITY`: heal at warp 7 (15,27) if wanted. Walk to (9,5), warp 14 -> + `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` (20,29). +12. Walk to (21,25), warp 7 -> `GOLDENROD_UNDERGROUND` (3,2). +13. Optional: haircut brothers at (7,14)/(7,15) (day-gated); Coin Case ball at + (7,25) if not taken. +14. Walk to the tile below/beside (18,6) and A-press it: `BasementDoorScript` + with `BASEMENT_KEY` -> `EVENT_USED_BASEMENT_KEY`. Then step onto (18,6) -> + warp 3 -> `GOLDENROD_UNDERGROUND` (21,31). +15. Walk to (22,27), warp 6 -> `GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES` + (23,3). SAVE HERE (the walkthrough's advice; the rival battle is losable). +16. Walk west to (19,4) or (19,5): `UndergroundRivalScene1/2` fires on scene 0. + Battle `RIVAL1`, member `RIVAL1_4_CHIKORITA` / `_CYNDAQUIL` / `_TOTODILE` + picked by which starter you took. Post: `EVENT_RIVAL_GOLDENROD_UNDERGROUND`, + scene -> 1. (Side effect: `EVENT_RIVAL_BURNED_TOWER` + Burned Tower scene.) +17. Fight `TrainerGruntM13` (17,2), `TrainerGruntM11` (11,2), `TrainerGruntM25` + (3,2) - west along the top corridor. +18. Switch byte starts at 0. A-press `Switch1Script` at (16,1) -> ON (byte 1), + then `Switch2Script` at (10,1) -> ON (byte 3), then `Switch3Script` at (2,1) + -> ON (byte 6). Doors 3, 6, 8, 9, 11 now open. +19. Go south through door 8 at (6,6)/(6,8) to `TrainerBurglarEddie` at (4,8). + Battle `BURGLAR EDDIE`. +20. A-press Switch3 (2,1) -> OFF (byte 3), then Switch2 (10,1) -> OFF (byte 1). + Doors 1, 3, 7, 10 open. Take the `FULL_HEAL` ball at (14,9). +21. Leave and re-enter the map so `..._ResetSwitchesCallback` zeroes everything + (e.g. warp 1 at (23,3) into `GOLDENROD_UNDERGROUND` and back through (22,27)). +22. Now A-press Switch3 (2,1) ON (byte 3), Switch2 (10,1) ON (byte 5), Switch1 + (16,1) ON (byte 6). Doors 3, 5, 6, 8, 9, 11 open - door 5 at (10,10) is the + new one. +23. Go south through door 5 to `TrainerBurglarDuncan` (9,12), then east to + `TrainerGruntF3` (19,12). Optionally the `SMOKE_BALL` ball at (1,12) and the + two hidden items at (8,9) and (1,8). +24. Warps 2/3 at (22,10)/(23,10) -> `GOLDENROD_UNDERGROUND_WAREHOUSE` (2,12). +25. Warehouse: `TrainerGruntM14` (8,15), `MAX_ETHER` ball at (18,15), + `TrainerGruntM15` (14,3). Optional side trip: warp 3 at (17,2) -> + `GOLDENROD_DEPT_STORE_B1F` (17,2), take `AMULET_COIN` at (14,2), warp back. +26. `TrainerGruntM24` (9,8). Take `TM_SLEEP_TALK` (TM35) at (13,9). Talk to the + Director at (12,8): `verbosegiveitem CARD_KEY`, post + `EVENT_RECEIVED_CARD_KEY` + `EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1`. +27. Exit back through warps 1/2 (2,12)/(3,12). If a shutter blocks the way out, + A-press `EmergencySwitchScript` at (20,11) (forces doors 3,5,6,8,9,11 open). + Then warp 1 at (23,3) or warps 8/9 at (20,29)/(21,29) to leave. + +Pass 3 - retake the tower: + +28. `GOLDENROD_CITY` -> `RADIO_TOWER_1F` (warp 12) -> 2F -> 3F as in steps 2-5. +29. `RADIO_TOWER_3F`: stand at (14,3) FACING UP and press A on (14,2) + (`BGEVENT_UP`). `CardKeySlotScript` -> `EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER`, + blocks (14,2)/(14,4) rewritten. +30. Walk east/south to `TrainerGruntM9` at (16,6). Battle `GRUNTM_9`. +31. Warp 3 at (17,0) -> `RADIO_TOWER_4F` (17,0). Battle `TrainerGruntF4` (12,4), + then `TrainerExecutivem2` (14,1) who guards warp 3. +32. Warp 3 at (12,0) -> `RADIO_TOWER_5F` (12,0). Battle `TrainerExecutivef1` + (17,2). +33. Walk to (16,5): coord event on scene 1 fires `RadioTower5FRocketBossScript`. + The script moves the PLAYER two cells left first. Battle `EXECUTIVEM_1`. +34. Post-conditions: `EVENT_CLEARED_RADIO_TOWER`, `ENGINE_ROCKETS_IN_RADIO_TOWER` + cleared, all Rocket objects masked, civilians restored, Blackthorn gym + blocker swapped, `RAINBOW_WING` (Gold) or `SILVER_WING` (Silver) received, 5F + scene -> 2. +35. Optional pickups now unlocked: `TM_SUNNY_DAY` from the 3F cooltrainer at + (11,3); `PINK_BOW` from DJ Mary at (14,6) on 4F. + +--- + +## 5. Port coverage + +The Gen 2 side of this repo is data-driven: maps, warps, coord events, bg events, +object events and scripts all come out of `src/import/RomExtractorGen2.lua` and +run through `src/script/gen2/Vm.lua`, so "implemented" below means the generic +machinery this section needs exists, not that anyone has walked the section. + +| Beat | Port file | Status | +|---|---|---| +| Map/warp/object/bg/coord tables for all nine maps | `src/import/RomExtractorGen2.lua` (`def_*` readers), `src/world/gen2/Map.lua` | implemented (generic extractor; not spot-checked against these maps) | +| Coord-event trip-wires gated by scene id | `src/world/gen2/World.lua:5011` (`coordEvents` scan against `World:scene()`), `:5025` scene scripts | implemented | +| `setscene` / `checkmapscene` / `setmapscene`, scene persistence in the save | `src/script/gen2/Vm.lua:274`, `src/world/gen2/World.lua:740-748`, `src/core/gen2/Save.lua` `mapScenes` | implemented | +| Trainer sight range + approach + `startbattle` | `src/world/gen2/Trainers.lua`, `src/world/gen2/World.lua` `SEEN_BY_TRAINER_SCRIPT`, `src/script/gen2/Vm.lua:806/817` | implemented | +| `loadtrainer` for the three scripted Executive/rival fights | `src/script/gen2/Vm.lua:806` | implemented | +| Rival starter branch (`checkevent EVENT_GOT_*_FROM_ELM`) | `src/script/gen2/Vm.lua` `checkevent`/`iftrue` | implemented | +| Switch puzzle byte (`readmem`/`addval`/`writemem` on `wUndergroundSwitchPositions`) | `src/script/gen2/Vm.lua:706-740` (sparse WRAM store, explicitly documented for this map), persisted as `scriptMem` in `src/core/gen2/Save.lua:186` | implemented | +| Switch reset on map entry (`MAPCALLBACK_NEWMAP`) | `src/world/gen2/World.lua:5659` + assertion driver `tests/drivers/gold_map_callbacks.lua:59` which loads `GOLDENROD_UNDERGROUND` for real and checks all 15 events clear | implemented and covered by a driver | +| `changeblock` + `refreshmap` (shutters, basement door, dept store crates) | `src/script/gen2/Vm.lua:1002`, `:887`; `MAPCALLBACK_TILES` at `src/world/gen2/World.lua:5664` | implemented | +| `checkitem BASEMENT_KEY` / `checkitem CARD_KEY` | `src/script/gen2/Vm.lua:523` | implemented | +| **Card Key slot (`bg_event ... BGEVENT_UP`)** | `src/world/gen2/World.lua:5147` `World:bgEventAt` matches only `(ev.kind or 0) == 0`, i.e. `BGEVENT_READ` | **missing** - the directional bg-event arms (`BGEVENT_UP/DOWN/RIGHT/LEFT`, and `BGEVENT_IFSET`/`IFNOTSET`/`COPY`) are not dispatched, so `CardKeySlotScript` can never fire and the 3F shutter cannot be opened. This blocks pass 3 entirely. | +| **Ground item balls (`OBJECTTYPE_ITEMBALL`)** | extracted as `obj.itemball` (`src/import/RomExtractorGen2.lua:2968`) but nothing consumes it: `World:interact` (`src/world/gen2/World.lua:5257`) has arms for trainers, strength boulders, `scriptKey` NPCs, `BGEVENT_READ` and `BGEVENT_ITEM` only | **missing** - Smoke Ball, Full Heal, Max Ether, TM35 Sleep Talk and Amulet Coin are all unobtainable. `src/script/gen2/CallAsm.lua:547` stubs `TryReceiveItem` on the assumption this path exists. | +| Hidden items (`BGEVENT_ITEM`: Max Potion, Revive, and the three in `GOLDENROD_UNDERGROUND`) | `src/world/gen2/HiddenItems.lua`, wired at `src/world/gen2/World.lua:5285` | implemented | +| `verbosegiveitem` (Basement Key, Card Key, wings, TM Sunny Day, Pink Bow) | `src/script/gen2/Vm.lua:490` | implemented | +| `checkver` Gold/Silver split on the wing | `src/script/gen2/Vm.lua:774` | implemented | +| `disappear` / `appear` / `moveobject` / `applymovement` / `turnobject` / `showemote` for the 5F director cutscene | `src/script/gen2/Vm.lua:297-380` | implemented | +| `special FadeOutToBlack` / `FadeInFromBlack` / `ReloadSpritesNoPalettes` / `PlayMapMusic` / `FadeOutMusic` | `src/script/gen2/Specials.lua:999-1066` | implemented | +| Object masking by event flag (every Rocket in this section) | `src/world/gen2/Npc.lua` + the extracted `eventFlag` field | implemented (semantics: SET = hidden, per `CheckObjectFlag`) | +| Elm's `SPECIALCALL_WEIRDBROADCAST` call | `src/core/gen2/Phone.lua:392` (`[4] = { name = "SPECIALCALL_WEIRDBROADCAST", condition = "outside" }`), `src/script/gen2/Vm.lua:1364` `specialphonecall` | implemented | +| Radio programming change while `ENGINE_ROCKETS_IN_RADIO_TOWER` is set | `src/ui/gen2/Pokegear.lua:348`, `:1249` | implemented | +| Dept store elevator (`elevator` opcode + crate layout rotation) | `src/script/gen2/Vm.lua:1302`, `src/ui/gen2/ElevatorMenu.lua` | implemented | +| Haircut brothers / bargain + bitter merchants (day gating) | `src/script/gen2/Specials.lua` (`OlderHaircutBrother` / `YoungerHaircutBrother`), `src/ui/gen2/MartMenu.lua:170` (`MARTTYPE_BITTER`) | implemented | +| A scripted driver that walks this section | none (`tests/drivers/gold_*.lua` has no radio tower / underground runner) | missing | + +--- + +## 6. Unresolved / verify by hand + +1. **"He gives you a Clear Bell."** The asm does not give a Clear Bell here. + `RadioTower5FRocketBossScript` (`maps/RadioTower5F.asm:122-139`) branches on + `checkver`: Silver gets `SILVER_WING`, Gold gets `RAINBOW_WING`. The Clear + Bell is a Crystal item and does not appear in this script. Treat the + walkthrough line as wrong for Gold/Silver. +2. **Switch ordering explanation.** The walkthrough says the shutters remember + the ORDER you flipped the switches in. `wUndergroundSwitchPositions` is a + single byte that switches 1/2/3 add 1/2/3 to, so 1-2-3 and 3-2-1 both end at + 6. The observable difference comes from which doors the INTERMEDIATE positions + left untouched (position 3 vs position 5 differ on door 5 at (10,10)). The + end result the walkthrough describes is correct; the stated reason is not. + The derivation above was done by hand from the `.PositionN` arms - worth + re-checking on hardware or an emulator before a bot depends on it. +3. **Party orderings.** The walkthrough lists `GRUNTF_4` as + Ekans/Ekans/Gloom/Oddish and `EXECUTIVEF_1` as Arbok/Murkrow/Vileplume. The + asm orders are `21 EKANS, 23 ODDISH, 21 EKANS, 24 GLOOM` and + `32 ARBOK, 32 VILEPLUME, 32 MURKROW`. Same mons, different send-out order; a + bot that hard-codes lead expectations should use the asm order. +4. **"the northwesternmost building"** - resolved to `warp_event 9, 5, + GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES, 8` and the adjacent + `bg_event 8, 6 GoldenrodCityUndergroundSignNorth`, but the walkthrough never + names it, so this is an inference from the map geometry rather than a quoted + label. +5. **EXP and money figures** ("595 EXP", "960G", etc.) were not verified. Money + is `base money x level` from the trainer class table, which was not opened; + EXP depends on the player's party. Nothing in this document depends on them. +6. **`EVENT_TEAM_ROCKET_DISBANDED` is set only on the Gold branch** of + `RadioTower5FRocketBossScript` (line 130), not on the Silver branch. Its only + other consumers are `maps/PewterCity.asm:51` (also `setevent`) and the + `TIN_TOWER_1F` sage object mask at `maps/TinTower1F.asm:56`. This looks + intentional (Gold gets the Rainbow Wing and therefore Ho-Oh access), but it is + an asymmetry a port could easily get wrong, so it is flagged rather than + asserted. +7. **`verbosegiveitem` with no `iffalse`** in `FakeDirectorScript` (Basement Key) + and `GoldenrodUndergroundWarehouseDirectorScript` (Card Key). If the Key + Items pocket were full the scripts would continue as if the item had been + given. Gen 2's Key Items pocket is large enough that this is presumably + unreachable, but the guard genuinely is not there. +8. **Radio Tower 3F `TrainerGruntM9`** reacts with "Why did the shutter open?" and + his after-battle text mentions the Underground, which confirms he is meant for + the post-Card-Key pass; but nothing in the asm prevents reaching (16,6) some + other way if the block edit were bypassed. Not verified against the `.blk` + collision data. diff --git a/docs/gold-walkthrough/asm-walk/section-13-ice-path-and-blackthorn-city-gym.md b/docs/gold-walkthrough/asm-walk/section-13-ice-path-and-blackthorn-city-gym.md new file mode 100644 index 00000000..b322b69e --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-13-ice-path-and-blackthorn-city-gym.md @@ -0,0 +1,1255 @@ +# Section 13 - Ice Path and Blackthorn City Gym + +Source: `../section-13-ice-path-and-blackthorn-city-gym.txt` +(the FAQ's own internal heading for this block is `---- 19 > Ice Path and Blackthorn City Gym ----`) + +Maps covered: `MAP_ROUTE_44`, `MAP_ICE_PATH_1F`, `MAP_ICE_PATH_B1F`, +`MAP_ICE_PATH_B2F_MAHOGANY_SIDE`, `MAP_ICE_PATH_B3F`, +`MAP_ICE_PATH_B2F_BLACKTHORN_SIDE`, `MAP_BLACKTHORN_CITY`, +`MAP_BLACKTHORN_POKECENTER_1F`, `MAP_BLACKTHORN_MART`, `MAP_BLACKTHORN_EMYS_HOUSE`, +`MAP_MOVE_DELETERS_HOUSE`, `MAP_BLACKTHORN_DRAGON_SPEECH_HOUSE`, +`MAP_BLACKTHORN_GYM_1F`, `MAP_BLACKTHORN_GYM_2F`, `MAP_DRAGONS_DEN_1F`, +`MAP_DRAGONS_DEN_B1F` + +Badges / key milestones in this section: + +- `EVENT_GOT_HM07_WATERFALL` (HM07 on `MAP_ICE_PATH_1F`) +- `EVENT_BEAT_CLAIR` (the battle is won, but **no badge yet**) +- `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG` -> `ENGINE_RISINGBADGE` + `EVENT_GOT_TM24_DRAGONBREATH` +- `specialphonecall SPECIALCALL_MASTERBALL` queued (Elm's Master Ball call, redeemed next section) + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_MAHOGANY_TOWN` | (previous section) | Fly | east map connection | "fly back to Mahogany Town, heal, head east onto Route 44" | +| 1 | `MAP_ROUTE_44` | `maps/Route44.asm` | west connection from `MAHOGANY_TOWN` | warp 1 at (56, 7) | six trainers, Burnt Berry tree, two item balls | +| 2 | `MAP_ICE_PATH_1F` | `maps/IcePath1F.asm` | warp 1 (4, 19) from Route 44 | warp 3 (37, 5) | first ice maze, HM07 Waterfall | +| 3 | `MAP_ICE_PATH_B1F` | `maps/IcePathB1F.asm` | warp 1 (3, 15) | warp 2 (17, 3) | Strength boulder-into-hole puzzle | +| 4 | `MAP_ICE_PATH_B2F_MAHOGANY_SIDE` | `maps/IcePathB2FMahoganySide.asm` | warp 1 (17, 1), or drop through holes | warp 2 (9, 11) | Full Heal, Max Potion, hidden Carbos | +| 5 | `MAP_ICE_PATH_B3F` | `maps/IcePathB3F.asm` | warp 1 (3, 5) | warp 2 (15, 5) | Rock Smash the rock, Nevermeltice | +| 6 | `MAP_ICE_PATH_B2F_BLACKTHORN_SIDE` | `maps/IcePathB2FBlackthornSide.asm` | warp 2 (3, 3) | warp 1 (3, 15) | TM44 Rest, hidden Ice Heal | +| 7 | `MAP_ICE_PATH_B1F` (south region) | `maps/IcePathB1F.asm` | warp 8 (11, 27) | warp 7 (5, 25) | Iron at (5, 35), hidden Max Potion | +| 8 | `MAP_ICE_PATH_1F` (south region) | `maps/IcePath1F.asm` | warp 4 (37, 13) | warp 2 (36, 27) | PP Up at (32, 23), then out | +| 9 | `MAP_BLACKTHORN_CITY` | `maps/BlackthornCity.asm` | warp 7 (36, 9) | see below | heal, restock, Move Deleter, Emy's trade | +| 10 | `MAP_BLACKTHORN_POKECENTER_1F` | `maps/BlackthornPokecenter1F.asm` | city warp 5 (21, 29) | warp 1/2 | heal | +| 11 | `MAP_BLACKTHORN_MART` | `maps/BlackthornMart.asm` | city warp 4 (15, 29) | warp 1/2 | restock Ultra Balls | +| 12 | `MAP_BLACKTHORN_EMYS_HOUSE` | `maps/BlackthornEmysHouse.asm` | city warp 3 (29, 23) | warp 1/2 | Dragonair <-> Rhydon trade ("northeast house") | +| 13 | `MAP_MOVE_DELETERS_HOUSE` | `maps/MoveDeletersHouse.asm` | city warp 6 (9, 31) | warp 1/2 | delete HM moves ("house in the south") | +| 14 | `MAP_BLACKTHORN_GYM_1F` | `maps/BlackthornGym1F.asm` | city warp 1 (18, 11) | warp 1/2 (4/5, 17) | Paul, Mike, Lola, Clair | +| 15 | `MAP_BLACKTHORN_GYM_2F` | `maps/BlackthornGym2F.asm` | 1F warp 3 (1, 7) / warp 4 (7, 9) | holes and stairs | Cody, Fran, six-boulder puzzle | +| 16 | `MAP_DRAGONS_DEN_1F` | `maps/DragonsDen1F.asm` | city warp 8 (20, 1) | warp 3 (5, 15) | corridor + internal ladder pair | +| 17 | `MAP_DRAGONS_DEN_B1F` | `maps/DragonsDenB1F.asm` | warp 1 (20, 3) | same warp | Surf + Whirlpool to the Dragon Fang at (35, 16); Clair hands over `ENGINE_RISINGBADGE` | + +Not covered here (next section): the walkthrough's last paragraph sends the player +back to `MAP_ELMS_LAB` for the Master Ball (`ElmGiveMasterBallScript`, +`maps/ElmsLab.asm`) and on toward the Pokemon League. The video-link header also +names Dark Cave, but the prose never enters `MAP_DARK_CAVE_BLACKTHORN_ENTRANCE`. + +`MAP_BLACKTHORN_DRAGON_SPEECH_HOUSE` (city warp 2, (13, 21)) is on the map but the +walkthrough never enters it; it is documented below for completeness. + +## 2. Maps + +### MAP_ROUTE_44 + +- Script: `maps/Route44.asm` (`Route44_MapEvents` = `4d:4dd9` in `pokegold.sym`) +- Blocks: `maps/Route44.blk` +- Header: `data/maps/maps.asm:73` -> `TILESET_JOHTO`, `ROUTE`, `LANDMARK_ROUTE_44`, + `MUSIC_LAKE_OF_RAGE`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_REMORAID` +- Dimensions: `constants/map_constants.asm:61` -> `map_const ROUTE_44, 30, 9` + (so x in 0..59, y in 0..17) +- Connections: `data/maps/attributes.asm:239` -> west `MAHOGANY_TOWN` (offset 0), + east `BLACKTHORN_CITY` (offset -9) +- Scene scripts: none (`def_scene_scripts` empty). Callbacks: none. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 56 | 7 | `ICE_PATH_1F` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 53 | 7 | `BGEVENT_READ` | `Route44Sign1` ("ROUTE 44 / ICE PATH AHEAD") | +| 6 | 10 | `BGEVENT_READ` | `Route44Sign2` ("MAHOGANY TOWN - BLACKTHORN CITY") | +| 32 | 9 | `BGEVENT_ITEM` | `Route44HiddenElixer` -> `hiddenitem ELIXER, EVENT_ROUTE_44_HIDDEN_ELIXER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE44_FISHER1` | `SPRITE_FISHER` | 42 | 5 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerFisherWilton1` | -1 | +| `ROUTE44_FISHER2` | `SPRITE_FISHER` | 19 | 13 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerFisherEdgar` | -1 | +| `ROUTE44_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 10 | 9 | `SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPsychicPhil` | -1 | +| `ROUTE44_SUPER_NERD` | `SPRITE_SUPER_NERD` | 35 | 2 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerPokemaniacZach` | -1 | +| `ROUTE44_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 50 | 7 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBirdKeeperVance1` | -1 | +| `ROUTE44_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 41 | 15 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerCooltrainermAllen` | -1 | +| `ROUTE44_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 31 | 14 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerCooltrainerfCybil` | -1 | +| `ROUTE44_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 9 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | 0 | `Route44FruitTree` | -1 | +| `ROUTE44_POKE_BALL1` | `SPRITE_POKE_BALL` | 30 | 8 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `Route44MaxRevive` | `EVENT_ROUTE_44_MAX_REVIVE` | +| `ROUTE44_POKE_BALL2` | `SPRITE_POKE_BALL` | 43 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | 0 | `Route44UltraBall` | `EVENT_ROUTE_44_ULTRA_BALL` | + +**Scripts of interest** + +- `TrainerBirdKeeperVance1` / `.Script` - after the fight this is the phone-number + script. `checkevent EVENT_VANCE_READY_FOR_REMATCH` -> rematch branch; + `checkcellnum PHONE_BIRDKEEPER_VANCE` -> already registered; otherwise sets + `EVENT_VANCE_ASKED_FOR_PHONE_NUMBER` and runs + `askforphonenumber PHONE_BIRDKEEPER_VANCE`. The rematch branch picks + `VANCE2` if `EVENT_BEAT_ELITE_FOUR` and `VANCE3` if + `EVENT_RESTORED_POWER_TO_KANTO`, and clears `EVENT_VANCE_READY_FOR_REMATCH`. +- `TrainerFisherWilton1` / `.Script` - same shape with `PHONE_FISHER_WILTON`, + `EVENT_WILTON_ASKED_FOR_PHONE_NUMBER`, `EVENT_WILTON_READY_FOR_REMATCH`, + and `WILTON2` / `WILTON3` for the later fights. `FisherWiltonHugePoliwagText` + is the "you made me lose a POLIWAG" line the walkthrough quotes. +- `Route44FruitTree` -> `fruittree FRUITTREE_ROUTE_44`. `FRUITTREE_ROUTE_44` is + index 10 (`constants/script_constants.asm:222`) and + `data/items/fruit_trees.asm:19` gives `BURNT_BERRY`. +- `Route44MaxRevive` -> `itemball MAX_REVIVE`, `Route44UltraBall` -> `itemball ULTRA_BALL`. +- The four one-line trainers (`TrainerPsychicPhil`, `TrainerFisherEdgar`, + `TrainerCooltrainerfCybil`, `TrainerPokemaniacZach`, `TrainerCooltrainermAllen`) + are `endifjustbattled / opentext / writetext ... / waitbutton / closetext / end` + with no flag side effects beyond the `trainer` macro's own beaten flag. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BIRD_KEEPER_VANCE` | `constants/event_flags.asm` | `trainer` macro row in `TrainerBirdKeeperVance1` | set once Vance is beaten | +| `EVENT_BEAT_PSYCHIC_PHIL` | same | `TrainerPsychicPhil` | " | +| `EVENT_BEAT_FISHER_WILTON` | same | `TrainerFisherWilton1` | " | +| `EVENT_BEAT_FISHER_EDGAR` | same | `TrainerFisherEdgar` | " | +| `EVENT_BEAT_COOLTRAINERF_CYBIL` | same | `TrainerCooltrainerfCybil` | " | +| `EVENT_BEAT_POKEMANIAC_ZACH` | same | `TrainerPokemaniacZach` | " | +| `EVENT_BEAT_COOLTRAINERM_ALLEN` | same | `TrainerCooltrainermAllen` | " | +| `EVENT_ROUTE_44_MAX_REVIVE` | `constants/event_flags.asm:1111` | itemball object | set -> ball gone | +| `EVENT_ROUTE_44_ULTRA_BALL` | `constants/event_flags.asm:1112` | itemball object | set -> ball gone | +| `EVENT_ROUTE_44_HIDDEN_ELIXER` | `constants/event_flags.asm:184` | `hiddenitem` bg event | one-shot hidden item | +| `EVENT_VANCE_ASKED_FOR_PHONE_NUMBER` / `EVENT_WILTON_ASKED_FOR_PHONE_NUMBER` | `constants/event_flags.asm` | trainer scripts | second-ask dialogue branch | +| `EVENT_VANCE_READY_FOR_REMATCH` / `EVENT_WILTON_READY_FOR_REMATCH` | same | phone system | rematch pending | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `BURNT_BERRY` | headbutt-free fruit tree at (9, 5) | `Route44FruitTree` / `FRUITTREE_ROUTE_44` | daily, not an event flag | +| `MAX_REVIVE` | item ball at (30, 8) | `Route44MaxRevive` | `EVENT_ROUTE_44_MAX_REVIVE` | +| `ULTRA_BALL` | item ball at (43, 2) | `Route44UltraBall` | `EVENT_ROUTE_44_ULTRA_BALL` | +| `ELIXER` | hidden, bg_event (32, 9) | `Route44HiddenElixer` | `EVENT_ROUTE_44_HIDDEN_ELIXER` | + +**Trainers** + +Party data all from `data/trainers/parties.asm`; classes from +`constants/trainer_constants.asm`. + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `PSYCHIC_T`, `PHIL` | `PsychicGroup` | PSYCHIC_T (8) | L24 `NATU` (Leer / Night Shade / Future Sight / Confuse Ray), **L26** `KADABRA` (Disable / Psybeam / Recover / Future Sight); `TRAINERTYPE_MOVES` | `TrainerPsychicPhil` | none | +| `FISHER`, `EDGAR` | `FisherGroup` | FISHER (11) | L25 `REMORAID` x2 (Lock-On / Psybeam / Aurora Beam / Bubblebeam); `TRAINERTYPE_MOVES` | `TrainerFisherEdgar` | none | +| `COOLTRAINERF`, `CYBIL` | `CooltrainerFGroup` | COOLTRAINERF (16) | L25 `BUTTERFREE` (Confusion / Sleep Powder / Whirlwind / Gust), L25 `BELLOSSOM` (Absorb / Stun Spore / Acid / SolarBeam); `TRAINERTYPE_MOVES` | `TrainerCooltrainerfCybil` | none | +| `COOLTRAINERM`, `ALLEN` | `CooltrainerMGroup` | COOLTRAINERM (19) | L27 `CHARMELEON` (Ember / Smokescreen / Rage / Scary Face); `TRAINERTYPE_MOVES` | `TrainerCooltrainermAllen` | none | +| `POKEMANIAC`, `ZACH` | `PokemaniacGroup` | POKEMANIAC (13) | L27 `RHYHORN`; `TRAINERTYPE_NORMAL` | `TrainerPokemaniacZach` | none | +| `FISHER`, `WILTON1` | `FisherGroup` | FISHER (10) | L23 `GOLDEEN`, L23 `GOLDEEN`, L25 `SEAKING`; `TRAINERTYPE_NORMAL` | `TrainerFisherWilton1` | `PHONE_FISHER_WILTON`; `WILTON2`/`WILTON3` post-game | +| `BIRD_KEEPER`, `VANCE1` | `BirdKeeperGroup` | BIRD_KEEPER (7) | L25 `PIDGEOTTO` x2; `TRAINERTYPE_NORMAL` | `TrainerBirdKeeperVance1` | `PHONE_BIRDKEEPER_VANCE`; `VANCE2`/`VANCE3` post-game | + +**Wild encounters** + +- Grass, `data/wild/johto_grass.asm:2208` `def_grass_wildmons ROUTE_44`, rates + 10/10/10 percent, and morn == day == nite: + L23 `TANGELA`, L22 `WEEPINBELL`, L22 `BELLSPROUT`, L24 `LICKITUNG`, + L24 `WEEPINBELL`, L26 `LICKITUNG`, L26 `LICKITUNG`. +- Water, `data/wild/johto_water.asm:197` `def_water_wildmons ROUTE_44`, 2 percent: + L25 `POLIWAG`, L20 `POLIWAG`, L25 `POLIWHIRL`. This is the Poliwag the + walkthrough's "Pokemon on Route 44" list means; it is Surf-only, not grass. +- Fishing group `FISHGROUP_REMORAID` (`data/maps/maps.asm:73`), + `data/wild/fish.asm:195` `.Remoraid_Old/.Remoraid_Good/.Remoraid_Super`: + Old = Magikarp/Magikarp/Poliwag L10; Good = Magikarp L20, Poliwag L20 x2, + `time_group 6` (Poliwag L20); Super = Poliwag L40, `time_group 7` + (Poliwag L40), Magikarp L40, `REMORAID` L40. + +--- + +### MAP_ICE_PATH_1F + +- Script: `maps/IcePath1F.asm` (`IcePath1F_MapEvents` = `46:613f`) +- Blocks: `maps/IcePath1F.blk` +- Header: `data/maps/maps.asm:131` -> `TILESET_ICE_PATH`, `CAVE`, + `LANDMARK_ICE_PATH`, `MUSIC_DARK_CAVE`, phone `TRUE`, `PALETTE_NITE`, + `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:118` -> `map_const ICE_PATH_1F, 20, 18` +- Attributes: `data/maps/attributes.asm:450`, no connections +- Scene scripts and callbacks: both empty + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 19 | `ROUTE_44` | 1 | +| 2 | 36 | 27 | `BLACKTHORN_CITY` | 7 | +| 3 | 37 | 5 | `ICE_PATH_B1F` | 1 | +| 4 | 37 | 13 | `ICE_PATH_B1F` | 7 | + +**Coord events / BG events** + +None. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ICEPATH1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 31 | 7 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePath1FHMWaterfall` | `EVENT_GOT_HM07_WATERFALL` | +| `ICEPATH1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 32 | 23 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePath1FPPUp` | `EVENT_ICE_PATH_1F_PP_UP` | + +**Scripts of interest** + +- `IcePath1FHMWaterfall` -> `itemball HM_WATERFALL`. Nothing gates it beyond + reaching the tile; the badge check that makes the move usable is separate + (see section 3). +- `IcePath1FPPUp` -> `itemball PP_UP`. This ball is in the *southern* region of + 1F, reached on the way back up from B1F warp 7, not on the way in. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HM_WATERFALL` (HM07) | item ball at (31, 7) | `IcePath1FHMWaterfall` | `EVENT_GOT_HM07_WATERFALL` (`constants/event_flags.asm:1065`) | +| `PP_UP` | item ball at (32, 23) | `IcePath1FPPUp` | `EVENT_ICE_PATH_1F_PP_UP` (`:1066`) | + +**Wild encounters** + +`data/wild/johto_grass.asm:798` `def_grass_wildmons ICE_PATH_1F`, rates 2/2/2 percent. +Gold build (`IF DEF(_GOLD)`): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L21 `SWINUB` | L21 `SWINUB` | L21 `SWINUB` | +| 2 | L22 `GOLBAT` | L22 `GOLBAT` | L22 `GOLBAT` | +| 3 | L22 `ZUBAT` | L22 `ZUBAT` | L22 `ZUBAT` | +| 4 | L23 `SWINUB` | L23 `SWINUB` | L23 `SWINUB` | +| 5 | L22 `ZUBAT` | L22 `JYNX` | L22 `ZUBAT` | +| 6 | L22 `JYNX` | L20 `JYNX` | L22 `JYNX` | +| 7 | L22 `JYNX` | L20 `JYNX` | L22 `JYNX` | + +Silver (`ELIF DEF(_SILVER)`) replaces slot 3 with L22 `DELIBIRD` in all three +time bands - that is the "Delibird (Silver only)" line in the walkthrough, and +it is a *slot swap*, not an extra entry. + +--- + +### MAP_ICE_PATH_B1F + +- Script: `maps/IcePathB1F.asm` (`IcePathB1F_MapEvents` = `46:61e7`, + `IcePathB1FSetUpStoneTableCallback` = `46:6178`) +- Blocks: `maps/IcePathB1F.blk` +- Header: `data/maps/maps.asm:132` -> `TILESET_ICE_PATH`, `CAVE`, + `LANDMARK_ICE_PATH`, `MUSIC_DARK_CAVE`, `TRUE`, `PALETTE_NITE`, `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:119` -> `map_const ICE_PATH_B1F, 10, 18` +- Callbacks: `callback MAPCALLBACK_CMDQUEUE, IcePathB1FSetUpStoneTableCallback` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | note | +|---|---|---|---|---|---| +| 1 | 3 | 15 | `ICE_PATH_1F` | 3 | up to the north 1F region | +| 2 | 17 | 3 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 1 | stairs down | +| 3 | 11 | 2 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 3 | hole | +| 4 | 4 | 7 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 4 | hole | +| 5 | 5 | 12 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 5 | hole | +| 6 | 12 | 13 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 6 | hole | +| 7 | 5 | 25 | `ICE_PATH_1F` | 4 | up to the south 1F region | +| 8 | 11 | 27 | `ICE_PATH_B2F_BLACKTHORN_SIDE` | 1 | | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 15 | 31 | `BGEVENT_ITEM` | `IcePathB1FHiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ICEPATHB1F_BOULDER1` | `SPRITE_BOULDER` | 11 | 7 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `IcePathB1FBoulder` | `EVENT_BOULDER_IN_ICE_PATH_1` | +| `ICEPATHB1F_BOULDER2` | `SPRITE_BOULDER` | 7 | 8 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `IcePathB1FBoulder` | `EVENT_BOULDER_IN_ICE_PATH_2` | +| `ICEPATHB1F_BOULDER3` | `SPRITE_BOULDER` | 8 | 9 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `IcePathB1FBoulder` | `EVENT_BOULDER_IN_ICE_PATH_3` | +| `ICEPATHB1F_BOULDER4` | `SPRITE_BOULDER` | 17 | 7 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `IcePathB1FBoulder` | `EVENT_BOULDER_IN_ICE_PATH_4` | +| `ICEPATHB1F_POKE_BALL` | `SPRITE_POKE_BALL` | 5 | 35 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePathB1FIron` | `EVENT_ICE_PATH_B1F_IRON` | + +**Scripts of interest** + +- `IcePathB1FSetUpStoneTableCallback` writes a command queue with one + `cmdqueue CMDQUEUE_STONETABLE, .StoneTable` entry. `.StoneTable` is the + warp-id -> boulder-object -> script mapping: + + ``` + stonetable 3, ICEPATHB1F_BOULDER1, .Boulder1 + stonetable 4, ICEPATHB1F_BOULDER2, .Boulder2 + stonetable 5, ICEPATHB1F_BOULDER3, .Boulder3 + stonetable 6, ICEPATHB1F_BOULDER4, .Boulder4 + db -1 + ``` + + So boulder N must be pushed onto warp N+2 - i.e. boulder 1 onto the hole at + (11, 2), boulder 2 onto (4, 7), boulder 3 onto (5, 12), boulder 4 onto (12, 13). +- Each `.BoulderN` arm does `disappear ICEPATHB1F_BOULDERN` (which sets + `EVENT_BOULDER_IN_ICE_PATH_N`) and `clearevent EVENT_BOULDER_IN_ICE_PATH_NA` + (which makes the twin boulder object on `ICE_PATH_B2F_MAHOGANY_SIDE` + *appear*), then falls into `.FinishBoulder`: `pause 30`, + `scall .BoulderFallsThrough` (`playsound SFX_STRENGTH` + `earthquake 80`), + then `IcePathBoulderFellThroughText` ("The boulder fell / through."). +- `IcePathB1FBoulder` -> `jumpstd StrengthBoulderScript` (the "may be able to + push this with STRENGTH" prompt). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BOULDER_IN_ICE_PATH_1..4` | `constants/event_flags.asm:1195-1198` | `.BoulderN` `disappear` | set -> that B1F boulder is gone (fell) | +| `EVENT_BOULDER_IN_ICE_PATH_1A..4A` | `constants/event_flags.asm:1199-1202` | `.BoulderN` `clearevent`; **set at new game** by the init list in `engine/events/std_scripts.asm` | clear -> the fallen boulder is now visible on B2F Mahogany Side | +| `EVENT_ICE_PATH_B1F_IRON` | `:1067` | itemball object | | +| `EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION` | `:158` | hidden bg event | | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `IRON` | item ball at (5, 35) | `IcePathB1FIron` | `EVENT_ICE_PATH_B1F_IRON` | +| `MAX_POTION` | hidden at (15, 31) | `IcePathB1FHiddenMaxPotion` | `EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION` | + +**Wild encounters** + +`data/wild/johto_grass.asm:853` `def_grass_wildmons ICE_PATH_B1F`, 2/2/2 percent - +byte-for-byte the same table as `ICE_PATH_1F` above (same levels, same Silver +Delibird swap). + +--- + +### MAP_ICE_PATH_B2F_MAHOGANY_SIDE + +- Script: `maps/IcePathB2FMahoganySide.asm` (`..._MapEvents` = `46:6287`) +- Blocks: `maps/IcePathB2FMahoganySide.blk` +- Header: `data/maps/maps.asm:133` -> `TILESET_ICE_PATH`, `CAVE`, + `LANDMARK_ICE_PATH`, `MUSIC_DARK_CAVE`, `TRUE`, `PALETTE_NITE`, `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:120` -> `map_const ICE_PATH_B2F_MAHOGANY_SIDE, 10, 9` +- Scene scripts and callbacks: both empty + +**Warps** + +| idx | x | y | destination map | dest warp | note | +|---|---|---|---|---|---| +| 1 | 17 | 1 | `ICE_PATH_B1F` | 2 | stairs back up | +| 2 | 9 | 11 | `ICE_PATH_B3F` | 1 | ladder down | +| 3 | 11 | 4 | `ICE_PATH_B1F` | 3 | landing under hole 1 | +| 4 | 4 | 6 | `ICE_PATH_B1F` | 4 | landing under hole 2 | +| 5 | 4 | 12 | `ICE_PATH_B1F` | 5 | landing under hole 3 | +| 6 | 12 | 12 | `ICE_PATH_B1F` | 6 | landing under hole 4 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 17 | `BGEVENT_ITEM` | `IcePathB2FMahoganySideHiddenCarbos` -> `hiddenitem CARBOS, EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_HIDDEN_CARBOS` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ICEPATHB2FMAHOGANYSIDE_BOULDER1` | `SPRITE_BOULDER` | 11 | 3 | `STILL` | `OBJECTTYPE_SCRIPT` | `IcePathB2FMahoganySideBoulder` | `EVENT_BOULDER_IN_ICE_PATH_1A` | +| `ICEPATHB2FMAHOGANYSIDE_BOULDER2` | `SPRITE_BOULDER` | 4 | 7 | `STILL` | `OBJECTTYPE_SCRIPT` | same | `EVENT_BOULDER_IN_ICE_PATH_2A` | +| `ICEPATHB2FMAHOGANYSIDE_BOULDER3` | `SPRITE_BOULDER` | 3 | 12 | `STILL` | `OBJECTTYPE_SCRIPT` | same | `EVENT_BOULDER_IN_ICE_PATH_3A` | +| `ICEPATHB2FMAHOGANYSIDE_BOULDER4` | `SPRITE_BOULDER` | 12 | 13 | `STILL` | `OBJECTTYPE_SCRIPT` | same | `EVENT_BOULDER_IN_ICE_PATH_4A` | +| `ICEPATHB2FMAHOGANYSIDE_POKE_BALL1` | `SPRITE_POKE_BALL` | 8 | 9 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePathB2FMahoganySideFullHeal` | `EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_FULL_HEAL` | +| `ICEPATHB2FMAHOGANYSIDE_POKE_BALL2` | `SPRITE_POKE_BALL` | 0 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePathB2FMahoganySideMaxPotion` | `EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_MAX_POTION` | + +**Scripts of interest** + +- `IcePathB2FMahoganySideBoulder` -> `jumptext IcePathB2FMahoganySideBoulderText` + ("It's immovably / imbedded in ice."). These boulders are `SPRITEMOVEDATA_STILL`, + not `STRENGTH_BOULDER` - they are scenery once they land, and they are the + reason the four B1F boulders are one-way. +- `IcePathB2FMahoganySideFullHeal` -> `itemball FULL_HEAL`. This is the Full Heal + the walkthrough says to grab after dropping through a hole. +- `IcePathB2FMahoganySideMaxPotion` -> `itemball MAX_POTION` (the walkthrough's + item list omits it). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_HEAL` | item ball at (8, 9) | `IcePathB2FMahoganySideFullHeal` | `EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_FULL_HEAL` (`:1068`) | +| `MAX_POTION` | item ball at (0, 2) | `IcePathB2FMahoganySideMaxPotion` | `EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_MAX_POTION` (`:1069`) | +| `CARBOS` | hidden at (0, 17) | `IcePathB2FMahoganySideHiddenCarbos` | `EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_HIDDEN_CARBOS` (`:159`) | + +**Wild encounters** + +`data/wild/johto_grass.asm:908`, 2/2/2 percent, one level higher than B1F: +morn L22 `SWINUB` / L23 `GOLBAT` / L23 `ZUBAT` / L24 `SWINUB` / L23 `ZUBAT` / +L23 `JYNX` / L23 `JYNX`; day swaps slots 5-7 to L23 `JYNX` / L21 `JYNX` / L21 `JYNX`; +nite matches morn. Silver replaces slot 3 with `DELIBIRD` at the same level. + +--- + +### MAP_ICE_PATH_B3F + +- Script: `maps/IcePathB3F.asm` (`IcePathB3F_MapEvents` = `46:632e`) +- Blocks: `maps/IcePathB3F.blk` +- Header: `data/maps/maps.asm:135` -> `TILESET_ICE_PATH`, `CAVE`, + `LANDMARK_ICE_PATH`, `MUSIC_DARK_CAVE`, `TRUE`, `PALETTE_NITE`, `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:122` -> `map_const ICE_PATH_B3F, 10, 9` +- Scene scripts and callbacks: both empty + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 5 | `ICE_PATH_B2F_MAHOGANY_SIDE` | 2 | +| 2 | 15 | 5 | `ICE_PATH_B2F_BLACKTHORN_SIDE` | 2 | + +**Coord events / BG events** + +None. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ICEPATHB3F_POKE_BALL` | `SPRITE_POKE_BALL` | 5 | 7 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePathB3FNevermeltice` | `EVENT_ICE_PATH_B3F_NEVERMELTICE` | +| `ICEPATHB3F_ROCK` | `SPRITE_ROCK` | 6 | 6 | `SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `IcePathB3FRock` | -1 | + +**Scripts of interest** + +- `IcePathB3FRock` -> `jumpstd SmashRockScript`. The rock at (6, 6) sits between + the ladder at (3, 5) and the Nevermeltice at (5, 7); no badge is required for + ROCK SMASH, only the move in the party (`HasRockSmash` in + `engine/events/overworld.asm` / `AskRockSmashScript`). +- `IcePathB3FNevermeltice` -> `itemball NEVERMELTICE`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `NEVERMELTICE` | item ball at (5, 7) | `IcePathB3FNevermeltice` | `EVENT_ICE_PATH_B3F_NEVERMELTICE` (`:1071`) | + +**Wild encounters** + +`data/wild/johto_grass.asm:1018`, 2/2/2 percent, one level above B2F: +morn/nite L23 `SWINUB` / L24 `GOLBAT` / L24 `ZUBAT` / L25 `SWINUB` / L24 `ZUBAT` / +L24 `JYNX` / L24 `JYNX`; day swaps slots 5-7 to `JYNX` L24/L22/L22. + +--- + +### MAP_ICE_PATH_B2F_BLACKTHORN_SIDE + +- Script: `maps/IcePathB2FBlackthornSide.asm` (`..._MapEvents` = `46:6305`) +- Blocks: `maps/IcePathB2FBlackthornSide.blk` +- Header: `data/maps/maps.asm:134` -> `TILESET_ICE_PATH`, `CAVE`, + `LANDMARK_ICE_PATH`, `MUSIC_DARK_CAVE`, `TRUE`, `PALETTE_NITE`, `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:121` -> `map_const ICE_PATH_B2F_BLACKTHORN_SIDE, 5, 9` +- Scene scripts and callbacks: both empty + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 15 | `ICE_PATH_B1F` | 8 | +| 2 | 3 | 3 | `ICE_PATH_B3F` | 2 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 10 | `BGEVENT_ITEM` | `IcePathB2FBlackthornSideHiddenIceHeal` -> `hiddenitem ICE_HEAL, EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_HIDDEN_ICE_HEAL` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ICEPATHB2FBLACKTHORNSIDE_POKE_BALL` | `SPRITE_POKE_BALL` | 8 | 16 | `STILL` | `OBJECTTYPE_ITEMBALL` | `IcePathB2FBlackthornSideTMRest` | `EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_REST` (TM44) | item ball at (8, 16) | `IcePathB2FBlackthornSideTMRest` | `EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST` (`:1070`) | +| `ICE_HEAL` | hidden at (2, 10) | `IcePathB2FBlackthornSideHiddenIceHeal` | `EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_HIDDEN_ICE_HEAL` (`:160`) | + +**Wild encounters** + +`data/wild/johto_grass.asm:963`, identical to `ICE_PATH_B2F_MAHOGANY_SIDE`. + +--- + +### MAP_BLACKTHORN_CITY + +- Script: `maps/BlackthornCity.asm` (`BlackthornCity_MapEvents` = `49:5e4f`, + `BlackthornCityFlypointCallback` = `49:58f2`) +- Blocks: `maps/BlackthornCity.blk` +- Header: `data/maps/maps.asm:187` -> `TILESET_JOHTO`, `TOWN`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_AZALEA_TOWN`, phone `FALSE`, + `PALETTE_AUTO`, `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:172` -> `map_const BLACKTHORN_CITY, 20, 18` +- Connections: `data/maps/attributes.asm:160` -> south `ROUTE_45` (offset 0), + west `ROUTE_44` (offset 9) +- Callbacks: + - `MAPCALLBACK_NEWMAP, BlackthornCityFlypointCallback` -> `setflag ENGINE_FLYPOINT_BLACKTHORN` + - `MAPCALLBACK_OBJECTS, BlackthornCitySantosCallback` -> `readvar VAR_WEEKDAY`, + `appear`/`disappear BLACKTHORNCITY_SANTOS` on `SATURDAY` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 18 | 11 | `BLACKTHORN_GYM_1F` | 1 | +| 2 | 13 | 21 | `BLACKTHORN_DRAGON_SPEECH_HOUSE` | 1 | +| 3 | 29 | 23 | `BLACKTHORN_EMYS_HOUSE` | 1 | +| 4 | 15 | 29 | `BLACKTHORN_MART` | 2 | +| 5 | 21 | 29 | `BLACKTHORN_POKECENTER_1F` | 1 | +| 6 | 9 | 31 | `MOVE_DELETERS_HOUSE` | 1 | +| 7 | 36 | 9 | `ICE_PATH_1F` | 2 | +| 8 | 20 | 1 | `DRAGONS_DEN_1F` | 1 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 34 | 24 | `BGEVENT_READ` | `BlackthornCitySign` | +| 17 | 13 | `BGEVENT_READ` | `BlackthornGymSign` | +| 7 | 31 | `BGEVENT_READ` | `MoveDeletersHouseSign` | +| 21 | 3 | `BGEVENT_READ` | `DragonDensSign` | +| 5 | 25 | `BGEVENT_READ` | `BlackthornCityTrainerTips` | +| 16 | 29 | `BGEVENT_READ` | `BlackthornCityMartSign` -> `jumpstd MartSignScript` | +| 22 | 29 | `BGEVENT_READ` | `BlackthornCityPokecenterSign` -> `jumpstd PokecenterSignScript` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BLACKTHORNCITY_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 18 | 12 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornSuperNerdScript` | `EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM` | +| `BLACKTHORNCITY_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 19 | 12 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornSuperNerdScript` | `EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM` | +| `BLACKTHORNCITY_GRAMPS1` | `SPRITE_GRAMPS` | 20 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornGramps1Script` | `EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN` | +| `BLACKTHORNCITY_GRAMPS2` | `SPRITE_GRAMPS` | 21 | 2 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `BlackthornGramps2Script` | `EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN` | +| `BLACKTHORNCITY_BLACK_BELT` | `SPRITE_BLACK_BELT` | 24 | 31 | `WALK_LEFT_RIGHT` (radius 1) | `OBJECTTYPE_SCRIPT` | `BlackthornBlackBeltScript` | -1 | +| `BLACKTHORNCITY_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 9 | 25 | `WALK_LEFT_RIGHT` (radius 2) | `OBJECTTYPE_SCRIPT` | `BlackthornCooltrainerF1Script` | -1 | +| `BLACKTHORNCITY_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 13 | 15 | `WALK_LEFT_RIGHT` (radius 1) | `OBJECTTYPE_SCRIPT` | `BlackthornYoungsterScript` | -1 | +| `BLACKTHORNCITY_SANTOS` | `SPRITE_YOUNGSTER` | 22 | 20 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SantosScript` | `EVENT_BLACKTHORN_CITY_SANTOS_OF_SATURDAY` | +| `BLACKTHORNCITY_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 35 | 19 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `BlackthornCooltrainerF2Script` | -1 | + +**Scripts of interest** + +- `BlackthornCityFlypointCallback` - `setflag ENGINE_FLYPOINT_BLACKTHORN` + (`constants/engine_flags.asm:89`). Fires on `MAPCALLBACK_NEWMAP`, i.e. the + first time the player walks out of the Ice Path. +- `BlackthornSuperNerdScript` - `checkevent EVENT_BEAT_CLAIR` -> `Text_ClairIsBeaten`; + else `checkevent EVENT_CLEARED_RADIO_TOWER` -> `Text_ClairIsIn`; else + `Text_ClairIsOut` ("Our GYM LEADER is training in the cave behind here"). + Both nerd objects share this script. +- `BlackthornGramps1Script` -> `BlackthornGrampsRefusesEntryText` + ("Only dragon users are permitted to train here"). `BlackthornGramps2Script` + -> `BlackthornGrampsGrantsEntryText` ("Since CLAIR ... has allowed it"). + These are the Dragon's Den doorman, one object per state. +- `SantosScript` - Saturday-only NPC, `verbosegiveitem SPELL_TAG`, guarded by + `EVENT_MET_SANTOS_OF_SATURDAY` / `EVENT_GOT_SPELL_TAG_FROM_SANTOS`. + Not mentioned by the walkthrough; free item for a bot that lands on Saturday. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_BLACKTHORN` | `constants/engine_flags.asm:89` | `BlackthornCityFlypointCallback` | Fly destination unlocked | +| `EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM` | `:1157` | `maps/RadioTower5F.asm:111` `setevent` | **set -> that object is hidden.** Set once the Radio Tower is cleared, which is what removes the blocker standing on (18, 12), the tile below the gym door | +| `EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM` | `:1158` | set at new game (`engine/events/std_scripts.asm:460`), cleared by `maps/RadioTower5F.asm:112` | clear -> the non-blocking nerd stands beside the door at (19, 12) | +| `EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN` | `:1262` | `maps/BlackthornGym1F.asm:54` `setevent` after beating Clair | set -> blocker at (20, 2) hidden | +| `EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN` | `:1263` | set at new game (`std_scripts.asm:512`), cleared by `BlackthornGym1F.asm:55` | clear -> permissive gramps visible at (21, 2), path to (20, 1) open | +| `EVENT_CLEARED_RADIO_TOWER` | `:42` | read by `BlackthornSuperNerdScript` and `BlackthornBlackBeltScript` | | +| `EVENT_BLACKTHORN_CITY_SANTOS_OF_SATURDAY` | `:1279` | `BlackthornCitySantosCallback` | weekday visibility | + +**Items** + +None on the overworld map itself. + +**Trainers** + +None. + +**Wild encounters** + +Water only: `data/wild/johto_water.asm:264` `def_water_wildmons BLACKTHORN_CITY`, +4 percent: L15 `MAGIKARP`, L10 `MAGIKARP`, L5 `MAGIKARP`. +Fishing group `FISHGROUP_POND` (`data/wild/fish.asm:72`): +Old = Magikarp/Magikarp/Poliwag L10; Good = Magikarp L20, Poliwag L20 x2, +`time_group 6`; Super = Poliwag L40, `time_group 7`, Magikarp L40, Poliwag L40. + +--- + +### MAP_BLACKTHORN_POKECENTER_1F + +- Script: `maps/BlackthornPokecenter1F.asm` +- Header: `data/maps/maps.asm:183` -> `TILESET_POKECENTER`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_POKEMON_CENTER`, `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:168` -> `map_const BLACKTHORN_POKECENTER_1F, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `BLACKTHORN_CITY` | 5 | +| 2 | 4 | 7 | `BLACKTHORN_CITY` | 5 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BLACKTHORNPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornPokecenter1FNurseScript` -> `jumpstd PokecenterNurseScript` | -1 | +| `BLACKTHORNPOKECENTER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornPokecenter1FGentlemanScript` | -1 | +| `BLACKTHORNPOKECENTER1F_TWIN` | `SPRITE_TWIN` | 1 | 4 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `BlackthornPokecenter1FTwinScript` | -1 | +| `BLACKTHORNPOKECENTER1F_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 7 | 6 | `WALK_LEFT_RIGHT` (radius 1) | `OBJECTTYPE_SCRIPT` | `BlackthornPokecenter1FCooltrainerMScript` -> `jumpstd HappinessCheckScript` | -1 | + +No bg events, no coord events. + +--- + +### MAP_BLACKTHORN_MART + +- Script: `maps/BlackthornMart.asm` +- Header: `data/maps/maps.asm:182` -> `TILESET_MART`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_AZALEA_TOWN`, `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:167` -> `map_const BLACKTHORN_MART, 6, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `BLACKTHORN_CITY` | 4 | +| 2 | 3 | 7 | `BLACKTHORN_CITY` | 4 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BLACKTHORNMART_CLERK` | `SPRITE_CLERK` | 1 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `BlackthornMartClerkScript` | -1 | +| `BLACKTHORNMART_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 7 | 6 | `WALK_LEFT_RIGHT` (radius 2) | `OBJECTTYPE_SCRIPT` | `BlackthornMartCooltrainerMScript` | -1 | +| `BLACKTHORNMART_BLACK_BELT` | `SPRITE_BLACK_BELT` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BlackthornMartBlackBeltScript` | -1 | + +**Scripts of interest** + +`BlackthornMartClerkScript` -> `pokemart MARTTYPE_STANDARD, MART_BLACKTHORN`. +Inventory, `data/items/marts.asm:216` `MartBlackthorn` (`05:63d0`), 9 items: +`GREAT_BALL`, `ULTRA_BALL`, `HYPER_POTION`, `MAX_POTION`, `FULL_HEAL`, +`REVIVE`, `MAX_REPEL`, `X_DEFEND`, `X_ATTACK`. +This is where the walkthrough's "restock Ultra Balls" and "have a few Revives, +Full Heals, and Hyper Potions" shopping list is actually purchasable. + +--- + +### MAP_BLACKTHORN_EMYS_HOUSE + +- Script: `maps/BlackthornEmysHouse.asm` +- Header: `data/maps/maps.asm:181` -> `TILESET_HOUSE`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_AZALEA_TOWN`, `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:166` -> `map_const BLACKTHORN_EMYS_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `BLACKTHORN_CITY` | 3 | +| 2 | 3 | 7 | `BLACKTHORN_CITY` | 3 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `EmysHouseBookshelf` -> `jumpstd MagazineBookshelfScript` | +| 1 | 1 | `BGEVENT_READ` | same | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BLACKTHORNEMYSHOUSE_EMY` | `SPRITE_LASS` | 2 | 3 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `Emy` | -1 | + +**Scripts of interest** + +`Emy` -> `faceplayer / opentext / trade NPC_TRADE_EMY / waitbutton / closetext / end`. +`NPC_TRADE_EMY` is index 3 (`constants/npc_trade_constants.asm:20`) and its row in +`data/events/npc_trades.asm` is: + +``` +npctrade TRADE_DIALOGSET_NEWBIE, DRAGONAIR, RHYDON, "DON", $77, $66, BITTER_BERRY, 00283, "EMY", TRADE_GENDER_FEMALE +``` + +So: wants a **female** `DRAGONAIR`, gives `RHYDON` nicknamed "DON", +DVs `$77`/`$66`, holding a `BITTER_BERRY`, OT "EMY" ID 00283. +The walkthrough's "lady that would trade her Rhydon for a Dragonair" omits the +gender requirement, which is load-bearing for a bot. + +--- + +### MAP_MOVE_DELETERS_HOUSE + +- Script: `maps/MoveDeletersHouse.asm` +- Header: `data/maps/maps.asm:184` -> `TILESET_HOUSE`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_AZALEA_TOWN`, `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:169` -> `map_const MOVE_DELETERS_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `BLACKTHORN_CITY` | 6 | +| 2 | 3 | 7 | `BLACKTHORN_CITY` | 6 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `MoveDeletersHouseBookshelf` -> `jumpstd DifficultBookshelfScript` | +| 1 | 1 | `BGEVENT_READ` | same | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOVEDELETERSHOUSE_SUPER_NERD` | `SPRITE_SUPER_NERD` | 2 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `MoveDeleter` | -1 | + +**Scripts of interest** + +`MoveDeleter` -> `faceplayer / opentext / special MoveDeletion / waitbutton / +closetext / end`. The whole flow lives in `engine/events/move_deleter.asm`; the +script never writes `wScriptVar`. + +--- + +### MAP_BLACKTHORN_DRAGON_SPEECH_HOUSE + +Not visited by the walkthrough; included because it is city warp 2. + +- Script: `maps/BlackthornDragonSpeechHouse.asm` +- Header: `data/maps/maps.asm:180` -> `TILESET_HOUSE`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_AZALEA_TOWN`, `FALSE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:165` -> `map_const BLACKTHORN_DRAGON_SPEECH_HOUSE, 4, 4` + +**Warps**: 1 = (2, 7) -> `BLACKTHORN_CITY` 2; 2 = (3, 7) -> `BLACKTHORN_CITY` 2. + +**Object events**: `BLACKTHORNDRAGONSPEECHHOUSE_GRANNY` `SPRITE_GRANNY` at (2, 3), +`SPINRANDOM_SLOW`, `BlackthornDragonSpeechHouseGrannyScript` (CLAIR and LANCE +trained here); `BLACKTHORNDRAGONSPEECHHOUSE_EKANS` `SPRITE_EKANS` at (5, 5), +`SPRITEMOVEDATA_POKEMON`, `BlackthornDragonSpeechHouseDratiniScript` +(`cry DRATINI` - the sprite is EKANS but it is a Dratini). + +--- + +### MAP_BLACKTHORN_GYM_1F + +- Script: `maps/BlackthornGym1F.asm` (`BlackthornGym1F_MapEvents` = `53:4964`, + `BlackthornGym1FBouldersCallback` = `53:4005`, `BlackthornGymClairScript` = `53:4024`) +- Blocks: `maps/BlackthornGym1F.blk` +- Header: `data/maps/maps.asm:178` -> `TILESET_ELITE_FOUR_ROOM`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_GYM`, phone `TRUE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:163` -> `map_const BLACKTHORN_GYM_1F, 5, 9` +- Callbacks: `callback MAPCALLBACK_TILES, BlackthornGym1FBouldersCallback` + +**Warps** + +| idx | x | y | destination map | dest warp | note | +|---|---|---|---|---|---| +| 1 | 4 | 17 | `BLACKTHORN_CITY` | 1 | door | +| 2 | 5 | 17 | `BLACKTHORN_CITY` | 1 | door | +| 3 | 1 | 7 | `BLACKTHORN_GYM_2F` | 1 | stairs | +| 4 | 7 | 9 | `BLACKTHORN_GYM_2F` | 2 | stairs | +| 5 | 2 | 6 | `BLACKTHORN_GYM_2F` | 3 | landing under 2F hole at (2, 5) | +| 6 | 7 | 7 | `BLACKTHORN_GYM_2F` | 4 | landing under 2F hole at (8, 7) | +| 7 | 7 | 6 | `BLACKTHORN_GYM_2F` | 5 | landing under 2F hole at (8, 3) | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `BlackthornGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `BlackthornGymStatue` | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `BLACKTHORNGYM1F_CLAIR` | `SPRITE_CLAIR` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymClairScript` | -1 | +| `BLACKTHORNGYM1F_COOLTRAINER_M1` | `SPRITE_COOLTRAINER_M` | 6 | 6 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerCooltrainermMike` | -1 | +| `BLACKTHORNGYM1F_COOLTRAINER_M2` | `SPRITE_COOLTRAINER_M` | 1 | 14 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerCooltrainermPaul` | -1 | +| `BLACKTHORNGYM1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 9 | 2 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 1 | `TrainerCooltrainerfLola` | -1 | +| `BLACKTHORNGYM1F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 15 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymGuideScript` | -1 | + +**Scripts of interest** + +- `BlackthornGym1FBouldersCallback` (`MAPCALLBACK_TILES`) - paints the fallen + boulders that came through the 2F holes: + + ``` + checkevent EVENT_BOULDER_IN_BLACKTHORN_GYM_1 -> changeblock 8, 2, $3b + checkevent EVENT_BOULDER_IN_BLACKTHORN_GYM_2 -> changeblock 2, 4, $3a + checkevent EVENT_BOULDER_IN_BLACKTHORN_GYM_3 -> changeblock 8, 6, $3b + ``` + + (`Script_changeblock` adds 4 to both operands and then divides down to a block + index, so these are block coordinates (4, 1), (1, 2) and (4, 3).) +- `BlackthornGymClairScript` (`53:4024`) - the single most important script in + this section. Control flow: + 1. `checkflag ENGINE_RISINGBADGE` -> `.AlreadyGotBadge` (`53:4074`). + 2. `checkevent EVENT_BEAT_CLAIR` -> `.FightDone` (`53:4063`). + 3. Otherwise: `ClairIntroText`, `winlosstext ClairWinText, 0`, + `loadtrainer CLAIR, CLAIR1`, `startbattle`, `reloadmapafterbattle`, + `setevent EVENT_BEAT_CLAIR`, `ClairText_GoToDragonsDen`, then a block of + bookkeeping: + `setevent EVENT_BEAT_COOLTRAINERM_PAUL`, `..._CODY`, `..._MIKE`, + `setevent EVENT_BEAT_COOLTRAINERF_FRAN`, `..._LOLA` + (the five gym trainers are force-marked beaten), + `clearevent EVENT_MAHOGANY_MART_OWNERS`, + `setevent EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN`, + `clearevent EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN` + (this pair is what opens Dragon's Den). **No badge and no TM are given here.** + 4. `.FightDone`: `checkitem DRAGON_FANG` -> `.HasDragonFang` + (`BlackthornGymClairText_Cheat`, "You did not get that at DRAGON'S DEN") ; + else `ClairText_WhatsTheMatter`. + 5. `.AlreadyGotBadge`: `checkevent EVENT_GOT_TM24_DRAGONBREATH` -> `.GotTM24` + (`BlackthornGymClairText_League`); else `verbosegiveitem TM_DRAGONBREATH`, + `setevent EVENT_GOT_TM24_DRAGONBREATH`. This is the fallback path for a + player whose bag was full in the Den. +- `BlackthornGymStatue` - `checkflag ENGINE_RISINGBADGE` -> `jumpstd GymStatue2Script` + with `gettrainername STRING_BUFFER_4, CLAIR, CLAIR1`; else `jumpstd GymStatue1Script`. +- `BlackthornGymGuideScript` - branches on `EVENT_BEAT_CLAIR`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_CLAIR` | `constants/event_flags.asm:713` | set by `BlackthornGymClairScript` | battle won; badge still pending | +| `ENGINE_RISINGBADGE` | `constants/engine_flags.asm:45` | **set in `maps/DragonsDenB1F.asm`, not here** | the actual 8th badge | +| `EVENT_GOT_TM24_DRAGONBREATH` | `constants/event_flags.asm:21` | Den script, or the `.AlreadyGotBadge` fallback here | | +| `EVENT_BOULDER_IN_BLACKTHORN_GYM_1..3` | `:1192-1194` | 2F stone table `disappear`; read by the 1F tiles callback | set -> boulder has fallen; 1F block repainted | +| `EVENT_BEAT_COOLTRAINERM_PAUL` / `_CODY` / `_MIKE` | `:859-861` | `trainer` rows and the Clair script's bulk `setevent` | | +| `EVENT_BEAT_COOLTRAINERF_FRAN` / `_LOLA` | `:879-880` | same | | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_DRAGONBREATH` (TM24) | Clair, only on the `.AlreadyGotBadge` re-talk path | `BlackthornGymClairScript.AlreadyGotBadge` | `EVENT_GOT_TM24_DRAGONBREATH` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `COOLTRAINERM`, `PAUL` | `CooltrainerMGroup` | COOLTRAINERM (3) | L34 `DRATINI` x3; `TRAINERTYPE_NORMAL` | `TrainerCooltrainermPaul` | none | +| `COOLTRAINERM`, `MIKE` | same | COOLTRAINERM (5) | L37 `DRAGONAIR`; `TRAINERTYPE_NORMAL` | `TrainerCooltrainermMike` | none | +| `COOLTRAINERF`, `LOLA` | `CooltrainerFGroup` | COOLTRAINERF (4) | L34 `DRATINI`, L36 `DRAGONAIR`; `TRAINERTYPE_NORMAL` | `TrainerCooltrainerfLola` | none | +| `CLAIR`, `CLAIR1` | `ClairGroup` (`data/trainers/parties.asm:67`) | CLAIR (1); `TRAINERTYPE_MOVES` | L37 `DRAGONAIR` (Thunder Wave / Surf / Slam / Dragonbreath), L37 `DRAGONAIR` (Thunder Wave / Thunderbolt / Slam / Dragonbreath), L37 `DRAGONAIR` (Thunder Wave / Ice Beam / Slam / Dragonbreath), L40 `KINGDRA` (Smokescreen / Surf / Hyper Beam / Dragonbreath) | `BlackthornGymClairScript` | none | + +Note the party **order**: the asm sends out three Dragonair first and Kingdra +last. The walkthrough lists Kingdra third. + +**Wild encounters** + +None (indoor). + +--- + +### MAP_BLACKTHORN_GYM_2F + +- Script: `maps/BlackthornGym2F.asm` (`BlackthornGym2F_MapEvents` = `53:4bc8`, + `BlackthornGym2FSetUpStoneTableCallback` = `53:49dd`) +- Blocks: `maps/BlackthornGym2F.blk` +- Header: `data/maps/maps.asm:179` -> `TILESET_ELITE_FOUR_ROOM`, `INDOOR`, + `LANDMARK_BLACKTHORN_CITY`, `MUSIC_GYM`, `TRUE`, `PALETTE_DAY` +- Dimensions: `constants/map_constants.asm:164` -> `map_const BLACKTHORN_GYM_2F, 5, 9` +- Callbacks: `callback MAPCALLBACK_CMDQUEUE, BlackthornGym2FSetUpStoneTableCallback` + +**Warps** + +| idx | x | y | destination map | dest warp | note | +|---|---|---|---|---|---| +| 1 | 1 | 7 | `BLACKTHORN_GYM_1F` | 3 | stairs | +| 2 | 7 | 9 | `BLACKTHORN_GYM_1F` | 4 | stairs | +| 3 | 2 | 5 | `BLACKTHORN_GYM_1F` | 5 | hole | +| 4 | 8 | 7 | `BLACKTHORN_GYM_1F` | 6 | hole | +| 5 | 8 | 3 | `BLACKTHORN_GYM_1F` | 7 | hole | + +**Coord events / BG events** + +None. + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `BLACKTHORNGYM2F_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 4 | 1 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerCooltrainermCody` | -1 | +| `BLACKTHORNGYM2F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 4 | 11 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerCooltrainerfFran` | -1 | +| `BLACKTHORNGYM2F_BOULDER1` | `SPRITE_BOULDER` | 8 | 2 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | `EVENT_BOULDER_IN_BLACKTHORN_GYM_1` | +| `BLACKTHORNGYM2F_BOULDER2` | `SPRITE_BOULDER` | 2 | 3 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | `EVENT_BOULDER_IN_BLACKTHORN_GYM_2` | +| `BLACKTHORNGYM2F_BOULDER3` | `SPRITE_BOULDER` | 6 | 16 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | `EVENT_BOULDER_IN_BLACKTHORN_GYM_3` | +| `BLACKTHORNGYM2F_BOULDER4` | `SPRITE_BOULDER` | 3 | 3 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | -1 | +| `BLACKTHORNGYM2F_BOULDER5` | `SPRITE_BOULDER` | 6 | 1 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | -1 | +| `BLACKTHORNGYM2F_BOULDER6` | `SPRITE_BOULDER` | 8 | 14 | `STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | 0 | `BlackthornGymBoulder` | -1 | + +Three of the six boulders (4, 5, 6) carry **no** event flag and therefore never +disappear - they are the bridge-building boulders, not the hole-filling ones. + +**Scripts of interest** + +- `BlackthornGym2FSetUpStoneTableCallback` `.StoneTable`: + + ``` + stonetable 5, BLACKTHORNGYM2F_BOULDER1, .Boulder1 ; warp 5 = (8, 3) + stonetable 3, BLACKTHORNGYM2F_BOULDER2, .Boulder2 ; warp 3 = (2, 5) + stonetable 4, BLACKTHORNGYM2F_BOULDER3, .Boulder3 ; warp 4 = (8, 7) + db -1 + ``` + + Note the deliberately scrambled warp order: boulder 1 belongs in warp 5, + boulder 2 in warp 3, boulder 3 in warp 4. +- Each arm does `disappear BLACKTHORNGYM2F_BOULDERn` (setting the matching + `EVENT_BOULDER_IN_BLACKTHORN_GYM_n`) then `.Fall`: `pause 30`, `scall .FX` + (`playsound SFX_STRENGTH`, `earthquake 80`), `BlackthornGym2FBoulderFellText` + ("The boulder fell / through!"). +- `BlackthornGymBoulder` -> `jumpstd StrengthBoulderScript` for all six. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `COOLTRAINERM`, `CODY` | `CooltrainerMGroup` | COOLTRAINERM (4) | L34 `HORSEA`, L36 `SEADRA`; `TRAINERTYPE_NORMAL` | `TrainerCooltrainermCody` | none | +| `COOLTRAINERF`, `FRAN` | `CooltrainerFGroup` | COOLTRAINERF (3) | L37 `SEADRA`; `TRAINERTYPE_NORMAL` | `TrainerCooltrainerfFran` | none | + +--- + +### MAP_DRAGONS_DEN_1F + +- Script: `maps/DragonsDen1F.asm` (`DragonsDen1F_MapEvents` = `47:44c2`) +- Blocks: `maps/DragonsDen1F.blk` +- Header: `data/maps/maps.asm:150` -> `TILESET_CAVE`, `CAVE`, + `LANDMARK_DRAGONS_DEN`, `MUSIC_DRAGONS_DEN`, `TRUE`, `PALETTE_NITE`, + `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:137` -> `map_const DRAGONS_DEN_1F, 5, 9` +- Scene scripts, callbacks, coord events, bg events, object events: **all empty** + +**Warps** + +| idx | x | y | destination map | dest warp | note | +|---|---|---|---|---|---| +| 1 | 3 | 5 | `BLACKTHORN_CITY` | 8 | cave mouth | +| 2 | 3 | 3 | `DRAGONS_DEN_1F` | 4 | internal ladder, pairs with warp 4 | +| 3 | 5 | 15 | `DRAGONS_DEN_B1F` | 1 | the door down to the Den proper | +| 4 | 5 | 13 | `DRAGONS_DEN_1F` | 2 | internal ladder, pairs with warp 2 | + +The "bodyguard in front of it" the walkthrough mentions is on +`MAP_BLACKTHORN_CITY`, not here - `BLACKTHORNCITY_GRAMPS1` at (20, 2). + +--- + +### MAP_DRAGONS_DEN_B1F + +- Script: `maps/DragonsDenB1F.asm` (`DragonsDenB1F_MapEvents` = `47:49da`, + `DragonsDenB1FDragonFangScript` = `47:44fa`) +- Blocks: `maps/DragonsDenB1F.blk` +- Header: `data/maps/maps.asm:151` -> `TILESET_JOHTO`, `CAVE`, + `LANDMARK_DRAGONS_DEN`, `MUSIC_DRAGONS_DEN`, phone `TRUE`, `PALETTE_NITE`, + `FISHGROUP_DRATINI` +- Dimensions: `constants/map_constants.asm:138` -> `map_const DRAGONS_DEN_B1F, 20, 18` +- Attributes: `data/maps/attributes.asm:470`, no connections +- Callbacks: `callback MAPCALLBACK_NEWMAP, DragonsDenB1FCheckRivalCallback` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 20 | 3 | `DRAGONS_DEN_1F` | 3 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 18 | 24 | `BGEVENT_READ` | `DragonShrineSignpost` | +| 31 | 4 | `BGEVENT_ITEM` | `DragonsDenB1FHiddenRevive` -> `hiddenitem REVIVE, EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE` | +| 21 | 17 | `BGEVENT_ITEM` | `DragonsDenB1FHiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_POTION` | +| 31 | 15 | `BGEVENT_ITEM` | `DragonsDenB1FHiddenMaxElixer` -> `hiddenitem MAX_ELIXER, EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_ELIXER` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DRAGONSDENB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 35 | 16 | `STILL` | `OBJECTTYPE_SCRIPT` | `DragonsDenB1FDragonFangScript` | `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG` | +| `DRAGONSDENB1F_CLAIR` | `SPRITE_CLAIR` | 35 | 22 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_DRAGONS_DEN_CLAIR` | +| `DRAGONSDENB1F_RIVAL` | `SPRITE_RIVAL` | 20 | 23 | `WANDER` (radius 2, 2) | `OBJECTTYPE_SCRIPT` | `DragonsDenB1FRivalScript` | `EVENT_RIVAL_DRAGONS_DEN` | + +Note the Dragon Fang object is `OBJECTTYPE_SCRIPT`, **not** `OBJECTTYPE_ITEMBALL` - +it runs a full script, so a bot cannot treat it as a plain pickup. + +**Scripts of interest** + +- `DragonsDenB1FDragonFangScript` (`47:44fa`) - the badge handoff: + 1. `giveitem DRAGON_FANG`; `iffalse .BagFullDragonFang` (`47:4558`, which prints + `Text_FoundDragonFang` + `Text_NoRoomForDragonFang` and ends **without** + setting anything - a full bag here loses the whole scene until you return). + 2. `disappear DRAGONSDENB1F_POKE_BALL1` (sets `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG`), + `Text_FoundDragonFang`, `playsound SFX_ITEM`, `itemnotify`. + 3. `readvar VAR_FACING`; `ifequal RIGHT, .next` -> `moveobject DRAGONSDENB1F_CLAIR, 34, 21` + (Clair spawns one cell over when the player grabbed the Fang facing right). + 4. `appear DRAGONSDENB1F_CLAIR` (clears `EVENT_DRAGONS_DEN_CLAIR`), + `applymovement DRAGONSDENB1F_CLAIR, MovementDragonsDen_ClairWalksToYou` + (four `step UP`), `turnobject PLAYER, DOWN`. + 5. `ClairText_GiveDragonbreathDragonDen`, + `DragonShrinePlayerReceivedRisingBadgeText`, `playsound SFX_GET_BADGE`, + **`setflag ENGINE_RISINGBADGE`**, `specialphonecall SPECIALCALL_MASTERBALL`, + `DragonShrineRisingBadgeExplanationText`, + `verbosegiveitem TM_DRAGONBREATH, 1`, `setevent EVENT_GOT_TM24_DRAGONBREATH`, + `ClairText_CollectedAllBadges` (the "go to NEW BARK TOWN then SURF east" + directions), then `applymovement ... MovementDragonsDen_ClairWalksAway` + (four `step DOWN`) and `disappear DRAGONSDENB1F_CLAIR`. +- `DragonsDenB1FCheckRivalCallback` - `checkevent EVENT_BEAT_RIVAL_IN_MT_MOON`, + then `readvar VAR_WEEKDAY` and appears the rival only on `TUESDAY` or + `THURSDAY`. Mt. Moon is post-Elite-Four, so the rival is **never** present + during this section; `DragonsDenB1FRivalScript` is a talk-only scene guarded by + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`. +- `DragonShrineSignpost` - Gold/Silver has no separate Dragon Shrine map; the + shrine is only this signpost at (18, 24). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG` | `constants/event_flags.asm:1097` | `disappear` in the Fang script | set -> Fang taken, scene done | +| `EVENT_DRAGONS_DEN_CLAIR` | `:1146` | set at new game (`std_scripts.asm`), cleared by `appear`, re-set by the closing `disappear` | Clair's cutscene object | +| `ENGINE_RISINGBADGE` | `constants/engine_flags.asm:45` | `setflag` here | 8th badge; also the Waterfall gate | +| `EVENT_GOT_TM24_DRAGONBREATH` | `constants/event_flags.asm:21` | `setevent` here | | +| `SPECIALCALL_MASTERBALL` | `constants/phone_constants.asm:52` | `specialphonecall` here; consumed by `ElmPhoneCallerScript` (`engine/phone/scripts/elm.asm:70` `.gift`) | queues Elm's call; the ball itself is `ElmGiveMasterBallScript` in `maps/ElmsLab.asm`, gated on `checkflag ENGINE_RISINGBADGE` | +| `EVENT_RIVAL_DRAGONS_DEN` | `:1128` | callback | rival hidden until post-Mt.-Moon Tue/Thu | +| `EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE` / `_MAX_POTION` / `_MAX_ELIXER` | `:170-172` | hidden bg events | | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `DRAGON_FANG` | `giveitem` from the object at (35, 16) | `DragonsDenB1FDragonFangScript` | `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG` | +| `TM_DRAGONBREATH` (TM24) | `verbosegiveitem` from Clair right after | same | `EVENT_GOT_TM24_DRAGONBREATH` | +| `REVIVE` | hidden at (31, 4) | `DragonsDenB1FHiddenRevive` | `EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE` | +| `MAX_POTION` | hidden at (21, 17) | `DragonsDenB1FHiddenMaxPotion` | `EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_POTION` | +| `MAX_ELIXER` | hidden at (31, 15) | `DragonsDenB1FHiddenMaxElixer` | `EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_ELIXER` | + +**Wild encounters** + +- Water: `data/wild/johto_water.asm:121` `def_water_wildmons DRAGONS_DEN_B1F`, + 4 percent: L15 `MAGIKARP`, L10 `MAGIKARP`, L10 `DRATINI`. There is no grass + table for this map; the walkthrough's "Magikarp / Dratini" list is Surf-only. +- Fishing group `FISHGROUP_DRATINI` (`data/maps/maps.asm:151`), + `data/wild/fish.asm:87`: Old = Magikarp L10 x3; Good = Magikarp L20 x3 + + `time_group 8` (Dratini L20); Super = Magikarp L40, `time_group 9` + (Dratini L40), Magikarp L40, `DRAGONAIR` L40. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Blackthorn Gym door at (18, 11) | `maps/BlackthornCity.asm` object `BLACKTHORNCITY_SUPER_NERD1` standing on (18, 12) with flag `EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM` | that event **set** (object hidden) | `maps/RadioTower5F.asm:111-112` sets `EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM` and clears `..._DOES_NOT_BLOCK_GYM` when the Radio Tower is cleared. Previous section's work; a bot arriving here without it is stuck at the gym door | +| Dragon's Den entrance at (20, 1) | `maps/BlackthornCity.asm` object `BLACKTHORNCITY_GRAMPS1` standing on (20, 2) with flag `EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN` | that event **set** (object hidden) | `maps/BlackthornGym1F.asm:54-55` inside `BlackthornGymClairScript`, immediately after `setevent EVENT_BEAT_CLAIR` | +| Ice Path B1F -> Blackthorn side | `maps/IcePathB1F.asm` `IcePathB1FSetUpStoneTableCallback.StoneTable`; the four `SPRITEMOVEDATA_STRENGTH_BOULDER` objects | HM04 STRENGTH usable = `ENGINE_PLAINBADGE` (`engine/events/overworld.asm:941` `StrengthFunction.TryStrength` -> `CheckBadge`) plus a party member that knows the move | push boulder N onto warp N+2 (see the map block above). No script check gates the route - it is the `.blk` geometry plus the four boulders | +| Ice Path B3F Nevermeltice at (5, 7) | `maps/IcePathB3F.asm` object `ICEPATHB3F_ROCK` at (6, 6), `SPRITEMOVEDATA_SMASHABLE_ROCK`, `jumpstd SmashRockScript` | ROCK SMASH in the party; **no badge check** (`HasRockSmash` only calls `CheckPartyMove`) | smash it | +| Dragon's Den B1F, reaching the Dragon Fang | whirlpool block in `maps/DragonsDenB1F.blk`; `data/collision/field_move_blocks.asm:52` `WhirlpoolBlockPointers.johto` = facing block `$07` -> replacement `$36` | SURF (`ENGINE_FOGBADGE`, `engine/events/overworld.asm:340`) and WHIRLPOOL (`ENGINE_GLACIERBADGE`, `:1077` `WhirlpoolFunction.TryWhirlpool`) | the one `$07` block on this map is block (5, 10) = walk cells x 10-11, y 20-21 | +| Waterfall as a field move | `engine/events/overworld.asm:611` `WaterfallFunction` -> `:618 ld de, ENGINE_RISINGBADGE / farcall CheckBadge` | `ENGINE_RISINGBADGE` | granted by `DragonsDenB1FDragonFangScript`, i.e. HM07 picked up in the Ice Path is unusable for the whole of this section | +| Rising Badge itself | `maps/BlackthornGym1F.asm` `BlackthornGymClairScript` deliberately does **not** `setflag ENGINE_RISINGBADGE` | beating Clair is not sufficient | `maps/DragonsDenB1F.asm` `DragonsDenB1FDragonFangScript` | +| Master Ball from Elm | `maps/ElmsLab.asm:60` `checkflag ENGINE_RISINGBADGE / iftrue ElmGiveMasterBallScript` | `ENGINE_RISINGBADGE` | `specialphonecall SPECIALCALL_MASTERBALL` from the Den queues the phone call; the item is picked up in Elm's Lab (next section) | +| Dragon Fang bag space | `DragonsDenB1FDragonFangScript` `giveitem DRAGON_FANG / iffalse .BagFullDragonFang` | free bag slot | a full bag silently aborts the entire badge scene; the walkthrough's "deposit any extraneous items" advice is load bearing here | + +## 4. Bot checklist + +Coordinates are asm map coordinates (`object_event` / `warp_event` values). +"Pre" = precondition flag, "Post" = postcondition flag. + +1. `MAP_MAHOGANY_TOWN` - Fly, heal, walk east across the map connection to + `MAP_ROUTE_44`. Pre: `EVENT_CLEARED_RADIO_TOWER` (else the Blackthorn gym stays + blocked later). +2. `MAP_ROUTE_44` - battle `TrainerPsychicPhil` at (10, 9), sight 3. + Post: `EVENT_BEAT_PSYCHIC_PHIL`. +3. `MAP_ROUTE_44` - walk to (9, 5) and talk to `ROUTE44_FRUIT_TREE` + -> `BURNT_BERRY`. +4. `MAP_ROUTE_44` - walk to (30, 8), pick up `Route44MaxRevive` + (`MAX_REVIVE`, not Max Repel). Post: `EVENT_ROUTE_44_MAX_REVIVE`. +5. `MAP_ROUTE_44` - battle `TrainerFisherEdgar` at (19, 13), + `TrainerCooltrainerfCybil` at (31, 14), `TrainerCooltrainermAllen` at (41, 15). +6. `MAP_ROUTE_44` - pick up `Route44UltraBall` at (43, 2). + Post: `EVENT_ROUTE_44_ULTRA_BALL`. Optional: hidden `ELIXER` at (32, 9). +7. `MAP_ROUTE_44` - battle `TrainerPokemaniacZach` at (35, 2), + `TrainerFisherWilton1` at (42, 5) (register `PHONE_FISHER_WILTON`), + `TrainerBirdKeeperVance1` at (50, 7) (register `PHONE_BIRDKEEPER_VANCE`). +8. Party check before the cave: needs STRENGTH (`ENGINE_PLAINBADGE` already held) + and ROCK SMASH. All five Ice Path maps are `PALETTE_NITE` + (`data/maps/maps.asm:131-135`), not `PALETTE_DARK` + (`constants/map_data_constants.asm:38`, which is what + `DarkCaveBlackthornEntrance` uses at `data/maps/maps.asm:149`), so FLASH is + not required. +9. `MAP_ROUTE_44` (56, 7) -> warp 1 -> `MAP_ICE_PATH_1F` (4, 19). +10. `MAP_ICE_PATH_1F` - slide to (31, 7), take `IcePath1FHMWaterfall`. + Post: `EVENT_GOT_HM07_WATERFALL`. The move is unusable until step 30. +11. `MAP_ICE_PATH_1F` (37, 5) -> warp 3 -> `MAP_ICE_PATH_B1F` (3, 15). +12. `MAP_ICE_PATH_B1F` - use STRENGTH, push `ICEPATHB1F_BOULDER1` from (11, 7) + onto warp 3 at (11, 2). Post: `EVENT_BOULDER_IN_ICE_PATH_1` set, + `EVENT_BOULDER_IN_ICE_PATH_1A` cleared. +13. Push `ICEPATHB1F_BOULDER2` from (7, 8) onto warp 4 at (4, 7). + Post: `EVENT_BOULDER_IN_ICE_PATH_2` / `_2A`. +14. Push `ICEPATHB1F_BOULDER3` from (8, 9) onto warp 5 at (5, 12). + Post: `EVENT_BOULDER_IN_ICE_PATH_3` / `_3A`. +15. Push `ICEPATHB1F_BOULDER4` from (17, 7) onto warp 6 at (12, 13). + Post: `EVENT_BOULDER_IN_ICE_PATH_4` / `_4A`. + (STRENGTH must be re-activated after every map load - `ResetBikeFlags`.) +16. Drop through any hole, or take warp 2 at (17, 3) -> + `MAP_ICE_PATH_B2F_MAHOGANY_SIDE` (17, 1). +17. `MAP_ICE_PATH_B2F_MAHOGANY_SIDE` - pick up `FULL_HEAL` at (8, 9) and + `MAX_POTION` at (0, 2); optional hidden `CARBOS` at (0, 17). +18. Warp 2 at (9, 11) -> `MAP_ICE_PATH_B3F` (3, 5). +19. `MAP_ICE_PATH_B3F` - ROCK SMASH `ICEPATHB3F_ROCK` at (6, 6), take + `NEVERMELTICE` at (5, 7). Post: `EVENT_ICE_PATH_B3F_NEVERMELTICE`. +20. Warp 2 at (15, 5) -> `MAP_ICE_PATH_B2F_BLACKTHORN_SIDE` (3, 3). +21. Take `TM_REST` (TM44) at (8, 16). Post: + `EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST`. Optional hidden `ICE_HEAL` at (2, 10). +22. Warp 1 at (3, 15) -> `MAP_ICE_PATH_B1F` (11, 27) (south region). +23. Take `IRON` at (5, 35). Post: `EVENT_ICE_PATH_B1F_IRON`. Optional hidden + `MAX_POTION` at (15, 31). +24. Warp 7 at (5, 25) -> `MAP_ICE_PATH_1F` (37, 13) (south region). +25. Take `PP_UP` at (32, 23). Post: `EVENT_ICE_PATH_1F_PP_UP`. +26. Warp 2 at (36, 27) -> `MAP_BLACKTHORN_CITY` (36, 9). + Post: `ENGINE_FLYPOINT_BLACKTHORN` (via `MAPCALLBACK_NEWMAP`). +27. `MAP_BLACKTHORN_CITY` (21, 29) -> Pokecenter, heal. (15, 29) -> Mart, + buy from `MartBlackthorn`. Optional (29, 23) Emy trade (needs a **female** + Dragonair), (9, 31) Move Deleter. +28. `MAP_BLACKTHORN_CITY` (18, 11) -> `MAP_BLACKTHORN_GYM_1F` (4, 17). + Pre: `EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM` set. +29. Gym: battle `TrainerCooltrainermPaul` (1, 14), take warp 3 at (1, 7) to 2F; + battle `TrainerCooltrainerfFran` (4, 11) and `TrainerCooltrainermCody` (4, 1); + push `BLACKTHORNGYM2F_BOULDER2` (2, 3) onto warp 3 (2, 5), + `BLACKTHORNGYM2F_BOULDER3` (6, 16) onto warp 4 (8, 7), + `BLACKTHORNGYM2F_BOULDER1` (8, 2) onto warp 5 (8, 3); use boulders 4, 5, 6 + ((3, 3), (6, 1), (8, 14)) as bridge pieces. Back on 1F battle + `TrainerCooltrainermMike` (6, 6) and `TrainerCooltrainerfLola` (9, 2). + All five are force-set by the Clair script anyway, so a bot may skip them. +30. Talk to `BLACKTHORNGYM1F_CLAIR` at (5, 3) -> `loadtrainer CLAIR, CLAIR1`. + Post: `EVENT_BEAT_CLAIR`, plus `EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN` + set and `..._NOT_BLOCKING_DRAGONS_DEN` cleared. **No badge yet.** +31. Bag check: at least one free slot (see the `.BagFullDragonFang` trap). +32. `MAP_BLACKTHORN_CITY` (20, 1) -> `MAP_DRAGONS_DEN_1F` (3, 5). + Ladder (3, 3) -> (5, 13), then (5, 15) -> `MAP_DRAGONS_DEN_B1F` (20, 3). +33. `MAP_DRAGONS_DEN_B1F` - SURF, dissolve the whirlpool at walk cells + (10-11, 20-21), surf east to (35, 16) and take the Dragon Fang. + Post: `EVENT_DRAGONS_DEN_B1F_DRAGON_FANG`, `ENGINE_RISINGBADGE`, + `EVENT_GOT_TM24_DRAGONBREATH`, `SPECIALCALL_MASTERBALL` queued. + Optional hidden items at (31, 4), (21, 17), (31, 15). +34. Exit via warp 1 at (20, 3). WATERFALL is now usable. The Elm phone call + fires outside; the Master Ball pickup in `MAP_ELMS_LAB` is the next section. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers, dimensions, warps/coord/bg/object event tables for every map above | `src/import/RomExtractorGen2.lua` (`warps`, `coordEvents`, `bgEvents`, `objects` at lines 787-862, consumed at 973-975) | implemented (data-driven from the ROM, so no per-map porting is needed) | +| Map script execution (`checkevent`, `setevent`, `giveitem`, `verbosegiveitem`, `loadtrainer`, `startbattle`, `applymovement`, `moveobject`, `changeblock`, `earthquake`, `writecmdqueue`, `trade`, `specialphonecall`) | `src/script/gen2/Vm.lua` (490, 806, 1002, 1044, 1292, 1364, 1445) | implemented | +| Ice Path B1F stone table (boulder -> hole -> fall) | `src/world/gen2/CmdQueue.lua:212-222` (`ICE_PATH_B1F`, warps 2-5 zero-based against objects 2-5, events 1805-1808) | implemented; covered by `tests/drivers/gold_icepath_boulder.lua` and `tests/drivers/gold_map_callbacks.lua:167` | +| Blackthorn Gym 2F stone table | `src/world/gen2/CmdQueue.lua:227-230` (warps 5/3/4 -> objects 4/5/6, matching the scrambled asm order) | implemented; no dedicated driver | +| Blackthorn Gym 1F `MAPCALLBACK_TILES` fallen-boulder `changeblock` | `src/script/gen2/Vm.lua:1002` | implemented (generic `changeblock`) | +| STRENGTH badge gate + `ResetBikeFlags` clearing STRENGTH on every map load | `src/world/gen2/FieldMoves.lua:108` (`STRENGTH = "PLAIN"`), `src/world/gen2/World.lua:5610-5613` | implemented | +| WHIRLPOOL badge gate + block replacement | `src/world/gen2/FieldMoves.lua:109,247,540-556,624` | implemented | +| WATERFALL badge gate (`ENGINE_RISINGBADGE`) | `src/world/gen2/FieldMoves.lua:110,253-266,528-538` | implemented | +| ROCK SMASH (`HasRockSmash` inversion) | `src/script/gen2/CallAsm.lua:344-350` | implemented | +| **Ice tile sliding (`STEP_ICE`)** | `src/world/gen2/Permissions.lua:128,142` defines `ICE = {0x23, 0x2b}` but the only caller is `src/world/gen2/FieldMoves.lua:169`; there is no `STEP_ICE` arm in `World:movePlayer` (`src/world/gen2/World.lua:5876`) and no `CheckStandingOnIce` equivalent anywhere in `src/` | **missing** - this is the single biggest gap for this section, because every Ice Path floor is an ice-slide maze. A bot can push boulders but cannot traverse the mazes as the cart does | +| `ENGINE_FLYPOINT_BLACKTHORN` / Fly to Blackthorn | `src/world/gen2/FieldMoves.lua:354` (`LANDMARK_BLACKTHORN_CITY`, `SPAWN_BLACKTHORN`, flag 74) | implemented | +| Emy's NPC trade (`NPC_TRADE_EMY`, female Dragonair) | `src/core/gen2/NpcTrade.lua` (gender check at :91-93), `src/script/gen2/Vm.lua:1292` `trade` opcode | implemented | +| Move Deleter (`special MoveDeletion`) | `src/script/gen2/Specials.lua:637` `H.MoveDeletion`, `src/ui/gen2/MoveDeleter.lua` | implemented | +| Blackthorn Mart (`pokemart MARTTYPE_STANDARD, MART_BLACKTHORN`) | `src/ui/gen2/MartMenu.lua` + extractor mart tables | implemented (generic) | +| Vance / Wilton phone registration and rematch | `src/core/gen2/Phone.lua:210-215` (ids 32 and 33), rematch flags at `:451-452` | implemented | +| `specialphonecall SPECIALCALL_MASTERBALL` -> Elm's call | `src/script/gen2/Vm.lua:1364-1371`, `src/core/gen2/Phone.lua:402-403` (`[8] = SPECIALCALL_MASTERBALL`, condition `outside`, `ElmPhoneCallerScript`) | implemented | +| Rising Badge on the trainer card / badge list | `src/ui/gen2/TrainerCard.lua:64,74`, `src/world/gen2/FieldMoves.lua:118` | implemented | +| Clair battle, gym leader music / party | generic battle path `src/battle/gen2/Battle.lua` + extracted `data/trainers/parties.asm` | implemented (generic); no Blackthorn-specific driver exists under `tests/drivers/gold_*.lua` | +| End-to-end scripted run of this section | - | **missing** - the only relevant driver is `tests/drivers/gold_icepath_boulder.lua`, which teleports directly to `ICE_PATH_B1F` (9, 2) and tests one boulder | + +## 6. Unresolved / verify by hand + +- **"Max Repel" on Route 44.** The walkthrough says "then the item east of that + trainer (Max Repel)". The asm item ball at (30, 8) is + `Route44MaxRevive: itemball MAX_REVIVE`. There is no Max Repel object on + `MAP_ROUTE_44`; Max Repel is a Blackthorn Mart stock item + (`data/items/marts.asm:224`). Treat the walkthrough as wrong. +- **Route 44 item list.** The section header lists only "Burnt Berry, Ultra Ball" + but the map also has the Max Revive ball and a hidden Elixer at (32, 9). +- **Psychic Phil's Kadabra level.** Walkthrough says Level 24; `data/trainers/parties.asm` + PSYCHIC_T (8) says `db 26, KADABRA`. The Natu is L24 in both. +- **"Protein" in the Ice Path.** The walkthrough's final Ice Path paragraph says + "Go up even more steps to get the Protein, then slide down at the very left to + grab a PP Up." No `PROTEIN` appears in any of the five Ice Path map files, and + the walkthrough's own item list for the Ice Path does not include it. The only + vitamins in the Ice Path are `IRON` (B1F, (5, 35)) and the hidden `CARBOS` + (B2F Mahogany Side, (0, 17)). Unresolved. +- **Ice Path item list omissions.** The asm additionally has `MAX_POTION` (item + ball, B2F Mahogany Side (0, 2)), hidden `MAX_POTION` (B1F (15, 31)), hidden + `CARBOS` (B2F Mahogany (0, 17)) and hidden `ICE_HEAL` (B2F Blackthorn (2, 10)). +- **Ice maze direction strings.** Every "right, down, right, down" style route in + the walkthrough is a property of the `.blk` block layout and the ice-tile + collision values, not of any table in the map asm. None of them were verified; + a bot must derive them from `maps/IcePath*.blk` plus `CheckIceTile` + (`engine/overworld/player_movement.asm:274`). +- **Boulder push counts.** Likewise, "move the bottom boulder right, then 6 + squares down..." for both the Ice Path B1F and Blackthorn Gym 2F puzzles is + block-geometry, not script data. What *is* pinned down is the required + destination warp for each boulder (see the two stone tables above); the paths + between were not verified. +- **Dragon's Den whirlpool.** The walkthrough says "Make sure you have ... Surf + and Waterfall". The Den needs SURF and **WHIRLPOOL**; there is no waterfall + block on `MAP_DRAGONS_DEN_B1F` and Waterfall is not even usable until the badge + this trip awards. The single whirlpool block (`$07` in `TILESET_JOHTO`) is at + block (5, 10) of `maps/DragonsDenB1F.blk`; whether that is the only crossing + the route needs was not verified beyond the block scan. +- **Clair's party order.** Walkthrough lists Dragonair, Dragonair, Kingdra, + Dragonair. `ClairGroup` in `data/trainers/parties.asm:67` is Dragonair, + Dragonair, Dragonair, Kingdra. +- **Fisher Edgar's Remoraid levels.** Walkthrough says two Level 25 Remoraid; + the asm agrees, but note both carry `LOCK_ON / PSYBEAM / AURORA_BEAM / + BUBBLEBEAM` rather than default moves. +- **"use the warps to return to the entrance".** The 1F <-> 2F warp pairs + (1F 3/4 <-> 2F 1/2, 1F 5/6/7 <-> 2F 3/4/5) are transcribed above, but which + combination the walkthrough means by "use the warps" is ambiguous. +- **Money rewards.** The G values in the walkthrough (832G, 1000G, 1200G, ...) + come from `base money x level`, computed at runtime in + `engine/battle/`; no static table was located, so none were verified. +- **Section numbering.** The file is `section-13-*` but its own heading reads + `---- 19 > Ice Path and Blackthorn City Gym ----`. Assumed to be the FAQ's + internal chapter number, not a different section. diff --git a/docs/gold-walkthrough/asm-walk/section-14-ho-oh-gold-and-lugia-silver.md b/docs/gold-walkthrough/asm-walk/section-14-ho-oh-gold-and-lugia-silver.md new file mode 100644 index 00000000..6db982be --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-14-ho-oh-gold-and-lugia-silver.md @@ -0,0 +1,1090 @@ +# Section 14 - Ho-Oh (Gold) and Lugia (Silver) + +Source: `../section-14-ho-oh-gold-and-lugia-silver.txt` +Maps covered: `ECRUTEAK_TIN_TOWER_ENTRANCE`, `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE`, +`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`, `TIN_TOWER_ROOF`, +`ROUTE_41` (transit only), `WHIRL_ISLAND_NE`, `WHIRL_ISLAND_NW`, `WHIRL_ISLAND_SW`, +`WHIRL_ISLAND_SE`, `WHIRL_ISLAND_CAVE`, `WHIRL_ISLAND_B1F`, `WHIRL_ISLAND_B2F`, +`WHIRL_ISLAND_LUGIA_CHAMBER` + +Badges / key milestones in this section: no badge is awarded here. The milestones +are `EVENT_FOUGHT_HO_OH` (Gold, Tin Tower Roof) and `EVENT_FOUGHT_LUGIA` (Silver, +Whirl Island Lugia Chamber). Both are one-shot: the callback that spawns the +legendary refuses to re-spawn it once the flag is set. + +Version split, straight from the asm (both legendaries exist in both carts, only +the wing you own differs): + +- `maps/RadioTower5F.asm` after the Rocket takeover runs `checkver` / + `iftrue .SilverWing`. Gold (`checkver` false) gets `verbosegiveitem RAINBOW_WING` + plus `setevent EVENT_GOT_RAINBOW_WING` and `setevent EVENT_TEAM_ROCKET_DISBANDED`; + Silver gets `verbosegiveitem SILVER_WING` / `setevent EVENT_GOT_SILVER_WING`. +- `maps/PewterCity.asm` (`PewterCityGrampsScript`) is the mirror image and hands + out the *other* wing in Kanto: Gold gets `SILVER_WING`, Silver gets + `RAINBOW_WING` (and Silver's branch is the one that sets + `EVENT_TEAM_ROCKET_DISBANDED` there). + +So in Gold this section is Tin Tower / Ho-Oh; in Silver it is Whirl Islands / +Lugia; the other one is a Kanto-era return trip. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `ECRUTEAK_CITY` | `maps/EcruteakCity.asm` | fly / Route 42 | warp 3 at (18,11) | Gold branch starts here, "go back to Ecruteak City" | +| 2 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | `maps/EcruteakTinTowerEntrance.asm` | warp 1 (4,17) / warp 2 (5,17) | warp 3 (5,3) -> in-map warp 4 (17,15); then warp 5 (17,3) | "go into the Bell Tower entry house. Talk to the man and he'll let you through" | +| 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | `maps/EcruteakTinTowerBackEntrance.asm` | warp 3 (2,4) | warp 1 (7,4) / warp 2 (7,5) -> `ECRUTEAK_CITY` warps 4/5 at (20,2)/(20,3) | "follow up the trail to the tower itself" | +| 4 | `ECRUTEAK_CITY` (north yard) | `maps/EcruteakCity.asm` | warps 4/5 | warp 12 at (37,7) | walk east to the tower door | +| 5 | `TIN_TOWER_1F` | `maps/TinTower1F.asm` | warp 1 (9,15) / warp 2 (10,15) | warp 3 (10,2) | "another bald man will let you through because you have the beautiful Rainbow Wing" | +| 6 | `TIN_TOWER_2F` | `maps/TinTower2F.asm` | warp 2 (10,2) | warp 1 (10,14) | "wild Rattatas will start converging on you like zombies" | +| 7 | `TIN_TOWER_3F` | `maps/TinTower3F.asm` | warp 1 (10,14) | warp 2 (16,2) | Full Heal grab | +| 8 | `TIN_TOWER_4F` | `maps/TinTower4F.asm` | warp 2 (16,2) | warp 1 (2,4), warp 3 (2,14) or warp 4 (17,15) | Ultra Ball / Escape Rope hop puzzle | +| 9 | `TIN_TOWER_5F` | `maps/TinTower5F.asm` | warps 2/3/4 | warp 1 (11,15) | Rare Candy | +| 10 | `TIN_TOWER_6F` | `maps/TinTower6F.asm` | warp 2 (11,15) | warp 1 (3,9) | bridge floor | +| 11 | `TIN_TOWER_7F` | `maps/TinTower7F.asm` | warp 1 (3,9) | warp 2 (10,15) (also in-map pair warp 3 (12,7) <-> warp 4 (8,3), and warp 5 (6,9) -> 9F) | Max Revive | +| 12 | `TIN_TOWER_8F` | `maps/TinTower8F.asm` | warp 1 (2,5) | warps 2-6 -> 9F | Max Elixer / Nugget / Full Restore | +| 13 | `TIN_TOWER_9F` | `maps/TinTower9F.asm` | warps 1/2/3/6/7 | warp 4 (7,9) | "go up the ladder, cross the planks down, then go up the ladder" | +| 14 | `TIN_TOWER_ROOF` | `maps/TinTowerRoof.asm` | warp 1 (9,13) | warp 1 back to 9F, or Escape Rope | level 40 Ho-Oh | +| 15 | `ROUTE_41` | `maps/Route41.asm` | surf south from Route 40 | warp 2 (36,19) | Silver branch: "surf southwest and west until you reach the northeast cave" | +| 16 | `WHIRL_ISLAND_NE` | `maps/WhirlIslandNE.asm` | warp 1 (3,13) | warp 2 (17,3) or warp 3 (13,11) | Flash, Ultra Ball | +| 17 | `WHIRL_ISLAND_B1F` | `maps/WhirlIslandB1F.asm` | warps 2/3 from NE | warp 7 (25,21) / warp 8 (13,27) to B2F | Escape Rope, Calcium, boulder | +| 18 | `WHIRL_ISLAND_NW` | `maps/WhirlIslandNW.asm` | warp 2 (5,3) from B1F warp 1 | warp 1 (5,7) to Route 41, warp 3 (3,15) to SW, warp 4 (7,15) to Cave | "head out the door to breathe" | +| 19 | `WHIRL_ISLAND_CAVE` | `maps/WhirlIslandCave.asm` | warp 2 (3,13) from NW | warp 1 (7,5) to B1F | short connector | +| 20 | `WHIRL_ISLAND_SW` | `maps/WhirlIslandSW.asm` | warps 2/3 from B1F, warp 4 from NW | warp 5 (17,15) to B2F, warp 1 (5,7) to Route 41 | Guard Spec | +| 21 | `WHIRL_ISLAND_SE` | `maps/WhirlIslandSE.asm` | warp 2 (5,3) from B1F warp 6 | warp 1 (5,13) to Route 41 | "go down out the door" | +| 22 | `WHIRL_ISLAND_B2F` | `maps/WhirlIslandB2F.asm` | warps 1/2 from B1F, warp 4 from SW | warp 3 (7,25) | Max Revive, waterfall descent | +| 23 | `WHIRL_ISLAND_LUGIA_CHAMBER` | `maps/WhirlIslandLugiaChamber.asm` | warp 1 (9,13) | warp 1 back to B2F, or Escape Rope | "surf north to Lugia" | + +Spill-over: the walkthrough's Silver branch opens on Route 41 with Swimmer Kara +and the whirlpool tiles. Route 41 itself (its ten swimmers, the hidden Max Ether +at (9,35), the Olivine/Cianwood crossing) belongs to the earlier Olivine -> +Cianwood section; only its four island warps are treated here. The closing lines +("fly back to Blackthorn City" / "fly back to Mahogany Town") also belong to the +next section. + +--- + +## 2. Maps + +### MAP_ECRUTEAK_TIN_TOWER_ENTRANCE + +- Script: `maps/EcruteakTinTowerEntrance.asm` (sym `52:4000 EcruteakTinTowerEntrance_MapScripts`, `52:4210 EcruteakTinTowerEntrance_MapEvents`) +- Blocks: `maps/EcruteakTinTowerEntrance.blk` (sym `37:452c EcruteakTinTowerEntrance_Blocks`) +- Header: `data/maps/maps.asm:165` -> `TILESET_TOWER, INDOOR, LANDMARK_ECRUTEAK_CITY, MUSIC_ECRUTEAK_CITY, phone FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:151` -> `map_const ECRUTEAK_TIN_TOWER_ENTRANCE, 10, 9` +- Connections: none (`data/maps/attributes.asm:495`, border block `$00`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `ECRUTEAK_CITY` | 3 | +| 2 | 5 | 17 | `ECRUTEAK_CITY` | 3 | +| 3 | 5 | 3 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 4 | +| 4 | 17 | 15 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 3 | +| 5 | 17 | 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 3 | + +Warps 3 and 4 are an in-map teleport pair: the map is one walled corridor drawn +in two halves, so the "trail" the walkthrough mentions is a warp, not a walk. + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` (=0) | 4 | 7 | `EcruteakTinTowerEntranceSageBlocksLeft` (`52:400c`) | if `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT` already set, no-op; else `applymovement SAGE2` one step LEFT, `moveobject SAGE1, 4, 6`, `appear SAGE1`, `disappear SAGE2` | +| `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` (=0) | 5 | 7 | `EcruteakTinTowerEntranceSageBlocksRight` (`52:4021`) | mirror image: `applymovement SAGE1` one step RIGHT, `moveobject SAGE2, 5, 6`, `appear SAGE2`, `disappear SAGE1` | + +Scene ids are defined inline by the `def_scene_scripts` / `scene_script` macro +(`macros/scripts/maps.asm:12-35`), so `SAGE_BLOCKS` = 0 and `NOOP` = 1. + +**BG events** (`def_bg_events`) + +None. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKTINTOWERENTRANCE_SAGE1` | `SPRITE_SAGE` | 4 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceSageScript` | `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT` | +| `ECRUTEAKTINTOWERENTRANCE_SAGE2` | `SPRITE_SAGE` | 5 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceSageScript` | `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT` | +| `ECRUTEAKTINTOWERENTRANCE_SAGE3` | `SPRITE_SAGE` | 6 | 9 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceWanderingSageScript` | -1 | +| `ECRUTEAKTINTOWERENTRANCE_GRAMPS` | `SPRITE_GRAMPS` | 3 | 11 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceGrampsScript` | -1 | + +Object visibility semantics (`engine/overworld/map_objects_2.asm:31 CheckObjectFlag`): +the object is masked (hidden) when its event flag is **set**, and `-1` means +"always appear". + +**Scripts of interest** + +- `EcruteakTinTowerEntranceSageScript` (`52:4037`): `faceplayer` / `opentext` / + `checkflag ENGINE_FOGBADGE`. With the badge it prints + `EcruteakTinTowerEntranceSageText_GotFogBadge` ("Please, go right through"); + without it, `EcruteakTinTowerEntranceSageText` ("TIN TOWER is off limits"). + Important: the sage script itself never opens the path. The physical block is + the pair of sage objects standing on (4,6)/(5,6) plus the two coord events at + y=7 that shuffle one of them into your lane. +- `EcruteakGymMortyScript` in `maps/EcruteakGym.asm:34` is what actually opens + it: on receiving `ENGINE_FOGBADGE` it runs + `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP`, + which retargets the coord events at (4,7)/(5,7) to a scene whose script is + `end`. `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT` is additionally set at + new-game init (`engine/events/std_scripts.asm:533`), so SAGE1 is hidden from + the start and only SAGE2 exists until the coord events run. +- `EcruteakTinTowerEntranceWanderingSageScript`: `checkevent EVENT_GOT_RAINBOW_WING` + swaps his line to "The TIN TOWER shook! A #MON must have returned to the top!" + Pure flavour, useful as a bot assertion that the wing is in hand. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FOGBADGE` | `constants/engine_flags.asm:41` | read by `EcruteakTinTowerEntranceSageScript`; set by `maps/EcruteakGym.asm` | gates the sage dialogue and (via `setmapscene`) the blocking scene | +| `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` / `_NOOP` | inline in `maps/EcruteakTinTowerEntrance.asm:9-10` | `setmapscene` from Ecruteak Gym | 0 = sages step into your path, 1 = free passage | +| `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT` | `constants/event_flags.asm:1288` | set by `engine/events/std_scripts.asm:533`, read by `SageBlocksRight` | when set, SAGE1 is hidden | +| `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT` | `constants/event_flags.asm:1289` | read by `SageBlocksLeft` | when set, SAGE2 is hidden | +| `EVENT_GOT_RAINBOW_WING` | `constants/event_flags.asm:129` | read here, set in `maps/RadioTower5F.asm:129` (Gold) / `maps/PewterCity.asm:50` (Silver) | wing possession | + +**Items / Trainers / Wild encounters** + +None on this map (no `def_grass_wildmons ECRUTEAK_TIN_TOWER_ENTRANCE` entry). + +--- + +### MAP_ECRUTEAK_TIN_TOWER_BACK_ENTRANCE + +- Script: `maps/EcruteakTinTowerBackEntrance.asm` +- Header: `data/maps/maps.asm:166` -> `TILESET_TRADITIONAL_HOUSE, INDOOR, LANDMARK_ECRUTEAK_CITY, MUSIC_ECRUTEAK_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:152` -> `map_const ECRUTEAK_TIN_TOWER_BACK_ENTRANCE, 4, 4` +- Connections: none (`data/maps/attributes.asm:496`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 4 | `ECRUTEAK_CITY` | 4 | +| 2 | 7 | 5 | `ECRUTEAK_CITY` | 5 | +| 3 | 2 | 4 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 5 | + +No coord events, bg events, object events, items, trainers or wild data. + +--- + +### MAP_TIN_TOWER_1F + +- Script: `maps/TinTower1F.asm` (sym `42:4b1b TinTower1F_MapScripts`, `42:4c6a TinTower1F_MapEvents`) +- Blocks: `maps/TinTower1F.blk` +- Header: `data/maps/maps.asm:82` -> `TILESET_TOWER, DUNGEON, LANDMARK_TIN_TOWER, MUSIC_TIN_TOWER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:69` -> `map_const TIN_TOWER_1F, 10, 9` +- Connections: none (`data/maps/attributes.asm:401`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `ECRUTEAK_CITY` | 12 | +| 2 | 10 | 15 | `ECRUTEAK_CITY` | 12 | +| 3 | 10 | 2 | `TIN_TOWER_2F` | 2 | + +**Coord events / BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER1F_SAGE` | `SPRITE_SAGE` | 10 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `TinTowerSageScript` (`42:4b1d`) | `EVENT_TEAM_ROCKET_DISBANDED` | + +**Scripts of interest** + +- `TinTowerSageScript` is only `jumptextfaceplayer TinTowerSageText` ("if I had + what the #MON has... A RAINBOW WING!"). The gate is geometric, not scripted: + the sage object sits exactly on warp 3 at (10,2), so while he is visible the + stairs are unreachable. He is hidden the moment `EVENT_TEAM_ROCKET_DISBANDED` + is set, which `maps/RadioTower5F.asm:130` does one opcode after + `setevent EVENT_GOT_RAINBOW_WING`. That is the whole of the walkthrough's + "another bald man will let you through because you have the Rainbow Wing". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_TEAM_ROCKET_DISBANDED` | `constants/event_flags.asm:1283` | set by `maps/RadioTower5F.asm:130` (Gold) and `maps/PewterCity.asm:51` (Silver); read as the sage's object flag | while clear, the 1F stairs are body-blocked | + +**Wild encounters**: none. `data/wild/johto_grass.asm` has no +`def_grass_wildmons TIN_TOWER_1F` (the block before `TIN_TOWER_2F` at line 61 is +`SPROUT_TOWER_3F`). + +--- + +### MAP_TIN_TOWER_2F + +- Script: `maps/TinTower2F.asm` (sym `42:4c8c`, events `42:4c8e`) +- Header: `data/maps/maps.asm:83` (same tileset/music/palette as 1F) +- Dimensions: `constants/map_constants.asm:70` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 14 | `TIN_TOWER_3F` | 1 | +| 2 | 10 | 2 | `TIN_TOWER_1F` | 3 | + +No coord events, bg events or object events. + +**Wild encounters** (`data/wild/johto_grass.asm:61`, `def_grass_wildmons TIN_TOWER_2F`, +rates `2%/2%/2%` morn/day/nite): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L20 Rattata | L20 Rattata | L20 Gastly | +| 2 | L21 Rattata | L21 Rattata | L21 Gastly | +| 3 | L22 Rattata | L22 Rattata | L22 Gastly | +| 4 | L22 Rattata | L22 Rattata | L22 Rattata | +| 5 | L23 Rattata | L23 Rattata | L23 Rattata | +| 6 | L24 Rattata | L24 Rattata | L24 Rattata | +| 7 | L24 Rattata | L24 Rattata | L24 Rattata | + +`TIN_TOWER_3F` through `TIN_TOWER_9F` (`data/wild/johto_grass.asm:89, 117, 145, +173, 201, 229, 257`) all repeat this identical table, so the whole tower is +Rattata by day and Rattata/Gastly at night at a 2% step rate. That is the +walkthrough's "zombies", and a Max Repel genuinely suppresses all of it (nothing +in the tower is above L24). + +--- + +### MAP_TIN_TOWER_3F + +- Script: `maps/TinTower3F.asm` (sym `42:4c9e`, events `42:4ca2`) +- Header: `data/maps/maps.asm:84`; Dimensions `constants/map_constants.asm:71` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 14 | `TIN_TOWER_2F` | 1 | +| 2 | 16 | 2 | `TIN_TOWER_4F` | 2 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER3F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 14 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower3FFullHeal` (`itemball FULL_HEAL`) | `EVENT_TIN_TOWER_3F_FULL_HEAL` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_HEAL` | walk onto ball at (3,14) | `TinTower3FFullHeal` | `EVENT_TIN_TOWER_3F_FULL_HEAL` (`constants/event_flags.asm:1004`) | + +--- + +### MAP_TIN_TOWER_4F + +- Script: `maps/TinTower4F.asm` (sym `42:4cbf`, events `42:4cca`) +- Header: `data/maps/maps.asm:85`; Dimensions `constants/map_constants.asm:72` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 4 | `TIN_TOWER_5F` | 2 | +| 2 | 16 | 2 | `TIN_TOWER_3F` | 2 | +| 3 | 2 | 14 | `TIN_TOWER_5F` | 3 | +| 4 | 17 | 15 | `TIN_TOWER_5F` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 6 | `BGEVENT_ITEM` | `TinTower4FHiddenMaxPotion` (`42:4cc7`) -> `hiddenitem MAX_POTION, EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER4F_POKE_BALL1` | `SPRITE_POKE_BALL` | 14 | 10 | `STILL` | `ITEMBALL` | `TinTower4FUltraBall` (`itemball ULTRA_BALL`) | `EVENT_TIN_TOWER_4F_ULTRA_BALL` | +| `TINTOWER4F_POKE_BALL2` | `SPRITE_POKE_BALL` | 17 | 14 | `STILL` | `ITEMBALL` | `TinTower4FSuperPotion` (`itemball SUPER_POTION`) | `EVENT_TIN_TOWER_4F_SUPER_POTION` | +| `TINTOWER4F_POKE_BALL3` | `SPRITE_POKE_BALL` | 2 | 12 | `STILL` | `ITEMBALL` | `TinTower4FEscapeRope` (`itemball ESCAPE_ROPE`) | `EVENT_TIN_TOWER_4F_ESCAPE_ROPE` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ULTRA_BALL` | ball at (14,10) | `TinTower4FUltraBall` | `EVENT_TIN_TOWER_4F_ULTRA_BALL` (`event_flags.asm:1005`) | +| `SUPER_POTION` | ball at (17,14) | `TinTower4FSuperPotion` | `EVENT_TIN_TOWER_4F_SUPER_POTION` (`:1006`) | +| `ESCAPE_ROPE` | ball at (2,12) | `TinTower4FEscapeRope` | `EVENT_TIN_TOWER_4F_ESCAPE_ROPE` (`:1007`) | +| `MAX_POTION` | hidden, press A at (11,6) | bg_event -> `TinTower4FHiddenMaxPotion` | `EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION` (`:135`) | + +--- + +### MAP_TIN_TOWER_5F + +- Script: `maps/TinTower5F.asm` (sym `42:4d10`, events `42:4d1a`) +- Header: `data/maps/maps.asm:86`; Dimensions `constants/map_constants.asm:73` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 15 | `TIN_TOWER_6F` | 2 | +| 2 | 2 | 4 | `TIN_TOWER_4F` | 1 | +| 3 | 2 | 14 | `TIN_TOWER_4F` | 3 | +| 4 | 17 | 15 | `TIN_TOWER_4F` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 14 | `BGEVENT_ITEM` | `TinTower5FHiddenFullRestore` (`42:4d14`) -> `hiddenitem FULL_RESTORE, EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE` | +| 3 | 15 | `BGEVENT_ITEM` | `TinTower5FHiddenCarbos` (`42:4d17`) -> `hiddenitem CARBOS, EVENT_TIN_TOWER_5F_HIDDEN_CARBOS` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER5F_POKE_BALL` | `SPRITE_POKE_BALL` | 9 | 9 | `STILL` | `ITEMBALL` | `TinTower5FRareCandy` (`itemball RARE_CANDY`) | `EVENT_TIN_TOWER_5F_RARE_CANDY` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `RARE_CANDY` | ball at (9,9) | `TinTower5FRareCandy` | `EVENT_TIN_TOWER_5F_RARE_CANDY` (`:1008`) | +| `FULL_RESTORE` | hidden at (16,14) | bg_event | `EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE` (`:136`) | +| `CARBOS` | hidden at (3,15) | bg_event | `EVENT_TIN_TOWER_5F_HIDDEN_CARBOS` (`:137`) | + +--- + +### MAP_TIN_TOWER_6F + +- Script: `maps/TinTower6F.asm` (sym `42:4d4b`, events `42:4d4d`) +- Header: `data/maps/maps.asm:87`; Dimensions `constants/map_constants.asm:74` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `TIN_TOWER_7F` | 1 | +| 2 | 11 | 15 | `TIN_TOWER_5F` | 1 | + +No coord events, bg events or object events. In particular there is **no** item +on this floor, contrary to the walkthrough (see section 6). + +--- + +### MAP_TIN_TOWER_7F + +- Script: `maps/TinTower7F.asm` (sym `42:4d5d`, events `42:4d61`) +- Header: `data/maps/maps.asm:88`; Dimensions `constants/map_constants.asm:75` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `TIN_TOWER_6F` | 1 | +| 2 | 10 | 15 | `TIN_TOWER_8F` | 1 | +| 3 | 12 | 7 | `TIN_TOWER_7F` | 4 | +| 4 | 8 | 3 | `TIN_TOWER_7F` | 3 | +| 5 | 6 | 9 | `TIN_TOWER_9F` | 5 | + +Warps 3/4 are another in-map teleport pair (the walkthrough's "go through the +next two warps"). + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER7F_POKE_BALL` | `SPRITE_POKE_BALL` | 16 | 1 | `STILL` | `ITEMBALL` | `TinTower7FMaxRevive` (`itemball MAX_REVIVE`) | `EVENT_TIN_TOWER_7F_MAX_REVIVE` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_REVIVE` | ball at (16,1) | `TinTower7FMaxRevive` | `EVENT_TIN_TOWER_7F_MAX_REVIVE` (`:1009`) | + +--- + +### MAP_TIN_TOWER_8F + +- Script: `maps/TinTower8F.asm` (sym `42:4d8d`, events `42:4d95`) +- Header: `data/maps/maps.asm:89`; Dimensions `constants/map_constants.asm:76` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 5 | `TIN_TOWER_7F` | 2 | +| 2 | 2 | 11 | `TIN_TOWER_9F` | 1 | +| 3 | 16 | 7 | `TIN_TOWER_9F` | 2 | +| 4 | 10 | 3 | `TIN_TOWER_9F` | 3 | +| 5 | 14 | 15 | `TIN_TOWER_9F` | 6 | +| 6 | 6 | 9 | `TIN_TOWER_9F` | 7 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER8F_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 13 | `STILL` | `ITEMBALL` | `TinTower8FNugget` (`itemball NUGGET`) | `EVENT_TIN_TOWER_8F_NUGGET` | +| `TINTOWER8F_POKE_BALL2` | `SPRITE_POKE_BALL` | 11 | 6 | `STILL` | `ITEMBALL` | `TinTower8FMaxElixer` (`itemball MAX_ELIXER`) | `EVENT_TIN_TOWER_8F_MAX_ELIXER` | +| `TINTOWER8F_POKE_BALL3` | `SPRITE_POKE_BALL` | 3 | 1 | `STILL` | `ITEMBALL` | `TinTower8FFullRestore` (`itemball FULL_RESTORE`) | `EVENT_TIN_TOWER_8F_FULL_RESTORE` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `NUGGET` | ball at (7,13) | `TinTower8FNugget` | `EVENT_TIN_TOWER_8F_NUGGET` (`:1010`) | +| `MAX_ELIXER` | ball at (11,6) | `TinTower8FMaxElixer` | `EVENT_TIN_TOWER_8F_MAX_ELIXER` (`:1011`) | +| `FULL_RESTORE` | ball at (3,1) | `TinTower8FFullRestore` | `EVENT_TIN_TOWER_8F_FULL_RESTORE` (`:1012`) | + +--- + +### MAP_TIN_TOWER_9F + +- Script: `maps/TinTower9F.asm` (sym `42:4de0`, events `42:4e03`) +- Header: `data/maps/maps.asm:90`; Dimensions `constants/map_constants.asm:77` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 12 | 3 | `TIN_TOWER_8F` | 2 | +| 2 | 2 | 5 | `TIN_TOWER_8F` | 3 | +| 3 | 12 | 7 | `TIN_TOWER_8F` | 4 | +| 4 | 7 | 9 | `TIN_TOWER_ROOF` | 1 | +| 5 | 16 | 7 | `TIN_TOWER_7F` | 5 | +| 6 | 6 | 13 | `TIN_TOWER_8F` | 5 | +| 7 | 8 | 13 | `TIN_TOWER_8F` | 6 | + +No coord events, bg events or object events. The file carries two unreferenced +strings, `TinTower9FUnusedHoOhText` (`42:4de2`) and `TinTower9FUnusedLugiaText` +(`42:4df3`), leftovers from when the legendary stood on 9F. + +--- + +### MAP_TIN_TOWER_ROOF + +- Script: `maps/TinTowerRoof.asm` (sym `5b:68fa TinTowerRoof_MapScripts`, `5b:6945 TinTowerRoof_MapEvents`) +- Blocks: `maps/TinTowerRoof.blk` (sym `2b:62b9 TinTowerRoof_Blocks`) +- Header: `data/maps/maps.asm:344` -> `TILESET_TOWER, ROUTE, LANDMARK_TIN_TOWER, MUSIC_TIN_TOWER, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:318` -> `map_const TIN_TOWER_ROOF, 10, 9`. + Note it lives in `newgroup FAST_SHIP` (group 15, map 12), **not** in the + `newgroup DUNGEONS` block that holds `TIN_TOWER_1F..9F` (group 3, maps 4-12). + A bot resolving map ids by group must not assume the roof follows 9F. +- Connections: none (`data/maps/attributes.asm:597`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 13 | `TIN_TOWER_9F` | 4 | + +**Coord events / BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWERROOF_HO_OH` | `SPRITE_HO_OH` | 9 | 5 | `SPRITEMOVEDATA_POKEMON`, palette `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT` | `TinTowerHoOh` (`5b:6913`) | `EVENT_TIN_TOWER_ROOF_HO_OH` | + +**Scripts of interest** + +- `TinTowerRoofHoOhCallback` (`5b:68ff`), registered as + `callback MAPCALLBACK_OBJECTS`: + + ``` + checkevent EVENT_FOUGHT_HO_OH + iftrue .NoAppear + checkitem RAINBOW_WING + iftrue .Appear + sjump .NoAppear + ``` + + So Ho-Oh spawns only while the `RAINBOW_WING` is **in the bag** and + `EVENT_FOUGHT_HO_OH` is clear. A bot that stores the wing in the PC, or that + has already triggered the encounter once, will find an empty roof. +- `TinTowerHoOh` (`5b:6913`): `faceplayer` / `opentext` / `writetext HoOhText` + ("Shaoooh!") / `cry HO_OH` / `pause 15` / `closetext` / + `setevent EVENT_FOUGHT_HO_OH` / `checkver` / `iftrue .Silver`. + - Gold branch: `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM`, + `loadwildmon HO_OH, 40`, `startbattle`, `disappear TINTOWERROOF_HO_OH`, + `reloadmapafterbattle`. + - `.Silver` (`5b:6930`): identical but `loadwildmon HO_OH, 70`. + + Note that `setevent EVENT_FOUGHT_HO_OH` fires **before** the battle, so + fleeing or blacking out still burns the encounter. This is exactly why the + walkthrough insists on saving first: the only recovery is a reset. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_FOUGHT_HO_OH` | `constants/event_flags.asm:454` | read by the callback, set by `TinTowerHoOh` before `startbattle` | one-shot latch on the encounter | +| `EVENT_TIN_TOWER_ROOF_HO_OH` | `constants/event_flags.asm:1246` | the object's own visibility flag, toggled by `appear`/`disappear` in the callback | runtime spawn state | +| `RAINBOW_WING` (item, not a flag) | `constants/item_constants.asm` | `checkitem` in the callback | must be carried in the bag | +| `BATTLETYPE_FORCEITEM` | `constants/battle_constants.asm:101` | `loadvar VAR_BATTLETYPE`; consumed at `engine/battle/core.asm:5771` | guarantees Ho-Oh holds its `SACRED_ASH` | + +**Battle data** + +- `data/pokemon/base_stats/ho_oh.asm`: 106/130/90/90/110/154, FIRE/FLYING, + **catch rate 3**, base exp 220, `db SACRED_ASH, SACRED_ASH ; items`, + `GENDER_UNKNOWN`, `GROWTH_SLOW`. +- Level-up set (`data/pokemon/evos_attacks.asm:3324 HoOhEvosAttacks`): + 1 Sacred Fire, 11 Safeguard, 22 Gust, 33 Recover, 44 Fire Blast, 55 Sunny Day, + 66 Swift, 77 Whirlwind, 88 AncientPower, 99 Future Sight. + A **level 40** Ho-Oh therefore knows **Sacred Fire, Safeguard, Gust, Recover**. +- Battle music: `engine/battle/start_battle.asm` has no legendary case, so this + is `MUSIC_JOHTO_WILD_BATTLE` (or `..._NIGHT`). The music the walkthrough is + praising is the map's own `MUSIC_TIN_TOWER`. + +**Wild encounters**: none on the roof. + +--- + +### MAP_ROUTE_41 (transit only) + +- Script: `maps/Route41.asm` +- Header: `data/maps/maps.asm` (Route 41 row); attributes `data/maps/attributes.asm:227` -> border `$35`, `connection north, Route40, ROUTE_40, 15`, `connection west, CianwoodCity, CIANWOOD_CITY, 0` +- Dimensions: `constants/map_constants.asm:410` -> `map_const ROUTE_41, 25, 27` + +**Warps** (`def_warp_events`) - the four island doors + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 12 | 17 | `WHIRL_ISLAND_NW` | 1 | +| 2 | 36 | 19 | `WHIRL_ISLAND_NE` | 1 | +| 3 | 12 | 37 | `WHIRL_ISLAND_SW` | 1 | +| 4 | 36 | 45 | `WHIRL_ISLAND_SE` | 1 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 35 | `BGEVENT_ITEM` | `Route41HiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_ROUTE_41_HIDDEN_MAX_ETHER` | + +**Object events** (all `OBJECTTYPE_TRAINER`; sight range is the numeric column) + +| const | sprite | x | y | movement | sight | script label | +|---|---|---|---|---|---|---| +| `ROUTE41_OLIVINE_RIVAL1` | `SPRITE_OLIVINE_RIVAL` | 32 | 6 | `SPINRANDOM_FAST` | 3 | `TrainerSwimmermCharlie` | +| `ROUTE41_OLIVINE_RIVAL2` | `SPRITE_OLIVINE_RIVAL` | 46 | 8 | `SPINRANDOM_FAST` | 3 | `TrainerSwimmermGeorge` | +| `ROUTE41_OLIVINE_RIVAL3` | `SPRITE_OLIVINE_RIVAL` | 20 | 26 | `SPINCOUNTERCLOCKWISE` | 3 | `TrainerSwimmermBerke` | +| `ROUTE41_OLIVINE_RIVAL4` | `SPRITE_OLIVINE_RIVAL` | 32 | 30 | `SPINCLOCKWISE` | 3 | `TrainerSwimmermKirk` | +| `ROUTE41_OLIVINE_RIVAL5` | `SPRITE_OLIVINE_RIVAL` | 19 | 46 | `SPINCOUNTERCLOCKWISE` | 3 | `TrainerSwimmermMathew` | +| `ROUTE41_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 17 | 4 | `SPINRANDOM_FAST` | 3 | `TrainerSwimmerfKaylee` | +| `ROUTE41_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 23 | 19 | `STANDING_UP` | 3 | `TrainerSwimmerfSusie` | +| `ROUTE41_SWIMMER_GIRL3` | `SPRITE_SWIMMER_GIRL` | 27 | 34 | `STANDING_LEFT` | 3 | `TrainerSwimmerfDenise` | +| `ROUTE41_SWIMMER_GIRL4` | `SPRITE_SWIMMER_GIRL` | 44 | 28 | `STANDING_RIGHT` | 4 | `TrainerSwimmerfKara` | +| `ROUTE41_SWIMMER_GIRL5` | `SPRITE_SWIMMER_GIRL` | 9 | 50 | `SPINRANDOM_FAST` | 2 | `TrainerSwimmerfWendy` | + +**Trainers** (the one the walkthrough uses as a landmark) + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERF, KARA` | `SWIMMERF` (class 27, `constants/trainer_constants.asm:375`) | `KARA` (`:381`, 6th in class) | `SwimmerFGroup` entry 6 (`data/trainers/parties.asm:1811`): `TRAINERTYPE_NORMAL`, L20 STARYU, L20 STARMIE | `TrainerSwimmerfKara`, flag `EVENT_BEAT_SWIMMERF_KARA` | none | + +"The whirlpool is just northwest of where you fought Swimmer Kara" therefore +means: northwest of Route 41 (44,28), i.e. the whirlpool guarding warp 2 at +(36,19) into `WHIRL_ISLAND_NE`. + +**Wild encounters**: `data/wild/johto_water.asm:170` `def_water_wildmons ROUTE_41`, +6% encounter rate; Gold slots are L20 Tentacool / L20 Tentacruel / L20 Mantine +(Silver's third slot differs). Fish group is `FISHGROUP_WHIRL_ISLANDS` only +inside the islands, not on Route 41 itself. + +--- + +### MAP_WHIRL_ISLAND_NE + +- Script: `maps/WhirlIslandNE.asm` (sym `47:401c WhirlIslandNE_MapScripts`, `47:4020 WhirlIslandNE_MapEvents`) +- Blocks: `maps/WhirlIslandNE.blk` +- Header: `data/maps/maps.asm:137` -> `TILESET_DARK_CAVE, CAVE, LANDMARK_WHIRL_ISLANDS, MUSIC_UNION_CAVE, phone TRUE, PALETTE_DARK, FISHGROUP_WHIRL_ISLANDS` +- Dimensions: `constants/map_constants.asm:124` -> `map_const WHIRL_ISLAND_NE, 10, 9` +- Connections: none; border block `$09` (`data/maps/attributes.asm:456`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 13 | `ROUTE_41` | 2 | +| 2 | 17 | 3 | `WHIRL_ISLAND_B1F` | 2 | +| 3 | 13 | 11 | `WHIRL_ISLAND_B1F` | 3 | + +**Coord events / BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDNE_POKE_BALL` | `SPRITE_POKE_BALL` | 11 | 11 | `STILL` | `ITEMBALL` | `WhirlIslandNEUltraBall` (`itemball ULTRA_BALL`) | `EVENT_WHIRL_ISLAND_NE_ULTRA_BALL` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ULTRA_BALL` | ball at (11,11) | `WhirlIslandNEUltraBall` | `EVENT_WHIRL_ISLAND_NE_ULTRA_BALL` (`event_flags.asm:1072`) | + +**Wild encounters** (`data/wild/johto_grass.asm:1101`, 6%/6%/6%, identical morn/day/nite): +L22 Krabby, L23 Zubat, L24 Krabby, L22 Seel, L23 Golbat, L24 Seel, L24 Seel. +`WHIRL_ISLAND_NW` (`:1073`), `SW` (`:1129`), `CAVE` (`:1157`) and `SE` (`:1185`) +use the exact same table. + +--- + +### MAP_WHIRL_ISLAND_NW + +- Script: `maps/WhirlIslandNW.asm` (sym `47:4000`, events `47:4002`) +- Header: `data/maps/maps.asm:136` (same as NE) +- Dimensions: `constants/map_constants.asm:123` -> `5, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 7 | `ROUTE_41` | 1 | +| 2 | 5 | 3 | `WHIRL_ISLAND_B1F` | 1 | +| 3 | 3 | 15 | `WHIRL_ISLAND_SW` | 4 | +| 4 | 7 | 15 | `WHIRL_ISLAND_CAVE` | 2 | + +No coord events, bg events, object events or items. + +**Wild encounters**: `data/wild/johto_grass.asm:1073`, same L22-24 +Krabby/Zubat/Seel/Golbat table as NE. + +--- + +### MAP_WHIRL_ISLAND_CAVE + +- Script: `maps/WhirlIslandCave.asm` (sym `47:4072`, events `47:4074`) +- Header: `data/maps/maps.asm:139` +- Dimensions: `constants/map_constants.asm:126` -> `5, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 5 | `WHIRL_ISLAND_B1F` | 9 | +| 2 | 3 | 13 | `WHIRL_ISLAND_NW` | 4 | + +No coord events, bg events, object events or items. Wild table +`data/wild/johto_grass.asm:1157`, same as NE. + +--- + +### MAP_WHIRL_ISLAND_SW + +- Script: `maps/WhirlIslandSW.asm` (sym `47:4042`, events `47:4046`) +- Header: `data/maps/maps.asm:138` +- Dimensions: `constants/map_constants.asm:125` -> `10, 9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 7 | `ROUTE_41` | 3 | +| 2 | 17 | 3 | `WHIRL_ISLAND_B1F` | 5 | +| 3 | 3 | 3 | `WHIRL_ISLAND_B1F` | 4 | +| 4 | 3 | 15 | `WHIRL_ISLAND_NW` | 3 | +| 5 | 17 | 15 | `WHIRL_ISLAND_B2F` | 4 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDSW_POKE_BALL` | `SPRITE_POKE_BALL` | 15 | 2 | `STILL` | `ITEMBALL` | `WhirlIslandSWGuardSpec` (`itemball GUARD_SPEC`) | `EVENT_WHIRL_ISLAND_SW_GUARD_SPEC` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `GUARD_SPEC` | ball at (15,2) | `WhirlIslandSWGuardSpec` | `EVENT_WHIRL_ISLAND_SW_GUARD_SPEC` (`:1073`) | + +**Wild encounters**: grass `data/wild/johto_grass.asm:1129` (as NE); water +`data/wild/johto_water.asm:79` `def_water_wildmons WHIRL_ISLAND_SW`, 4% rate, +L20 Tentacool / L15 Horsea / L20 Tentacruel. + +--- + +### MAP_WHIRL_ISLAND_SE + +- Script: `maps/WhirlIslandSE.asm` (sym `47:4084`, events `47:4086`) +- Header: `data/maps/maps.asm:140` +- Dimensions: `constants/map_constants.asm:127` -> `5, 9`; border block `$0f` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 13 | `ROUTE_41` | 4 | +| 2 | 5 | 3 | `WHIRL_ISLAND_B1F` | 6 | + +No coord events, bg events, object events or items. Wild table +`data/wild/johto_grass.asm:1185`, same as NE. + +--- + +### MAP_WHIRL_ISLAND_B1F + +- Script: `maps/WhirlIslandB1F.asm` (sym `47:4096 WhirlIslandB1F_MapScripts`, `47:40ae WhirlIslandB1F_MapEvents`) +- Blocks: `maps/WhirlIslandB1F.blk` +- Header: `data/maps/maps.asm:141` -> `TILESET_DARK_CAVE, CAVE, LANDMARK_WHIRL_ISLANDS, MUSIC_UNION_CAVE, TRUE, PALETTE_DARK, FISHGROUP_WHIRL_ISLANDS` +- Dimensions: `constants/map_constants.asm:128` -> `map_const WHIRL_ISLAND_B1F, 20, 18` (the big one) +- Connections: none; border block `$09` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `WHIRL_ISLAND_NW` | 2 | +| 2 | 35 | 3 | `WHIRL_ISLAND_NE` | 2 | +| 3 | 29 | 9 | `WHIRL_ISLAND_NE` | 3 | +| 4 | 9 | 31 | `WHIRL_ISLAND_SW` | 3 | +| 5 | 23 | 31 | `WHIRL_ISLAND_SW` | 2 | +| 6 | 31 | 29 | `WHIRL_ISLAND_SE` | 2 | +| 7 | 25 | 21 | `WHIRL_ISLAND_B2F` | 1 | +| 8 | 13 | 27 | `WHIRL_ISLAND_B2F` | 2 | +| 9 | 17 | 21 | `WHIRL_ISLAND_CAVE` | 1 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 30 | 4 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenRareCandy` (`47:40a5`) -> `hiddenitem RARE_CANDY, EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY` | +| 36 | 18 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenUltraBall` (`47:40a8`) -> `hiddenitem ULTRA_BALL, EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL` | +| 2 | 23 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenFullRestore` (`47:40ab`) -> `hiddenitem FULL_RESTORE, EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 13 | `STILL` | `ITEMBALL` | `WhirlIslandB1FFullRestore` | `EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE` | +| `WHIRLISLANDB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 2 | 18 | `STILL` | `ITEMBALL` | `WhirlIslandB1FCarbos` | `EVENT_WHIRL_ISLAND_B1F_CARBOS` | +| `WHIRLISLANDB1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 33 | 23 | `STILL` | `ITEMBALL` | `WhirlIslandB1FCalcium` | `EVENT_WHIRL_ISLAND_B1F_CALCIUM` | +| `WHIRLISLANDB1F_POKE_BALL4` | `SPRITE_POKE_BALL` | 17 | 8 | `STILL` | `ITEMBALL` | `WhirlIslandB1FNugget` | `EVENT_WHIRL_ISLAND_B1F_NUGGET` | +| `WHIRLISLANDB1F_POKE_BALL5` | `SPRITE_POKE_BALL` | 19 | 26 | `STILL` | `ITEMBALL` | `WhirlIslandB1FEscapeRope` | `EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE` | +| `WHIRLISLANDB1F_BOULDER` | `SPRITE_BOULDER` | 23 | 26 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `WhirlIslandB1FBoulder` (`47:40a2`, `jumpstd StrengthBoulderScript`) | -1 | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_RESTORE` | ball at (7,13) | `WhirlIslandB1FFullRestore` | `EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE` (`:1074`) | +| `CARBOS` | ball at (2,18) | `WhirlIslandB1FCarbos` | `EVENT_WHIRL_ISLAND_B1F_CARBOS` (`:1075`) | +| `CALCIUM` | ball at (33,23) | `WhirlIslandB1FCalcium` | `EVENT_WHIRL_ISLAND_B1F_CALCIUM` (`:1076`) | +| `NUGGET` | ball at (17,8) | `WhirlIslandB1FNugget` | `EVENT_WHIRL_ISLAND_B1F_NUGGET` (`:1077`) | +| `ESCAPE_ROPE` | ball at (19,26) | `WhirlIslandB1FEscapeRope` | `EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE` (`:1078`) | +| `RARE_CANDY` | hidden at (30,4) | bg_event | `EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY` (`:161`) | +| `ULTRA_BALL` | hidden at (36,18) | bg_event | `EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL` (`:162`) | +| `FULL_RESTORE` | hidden at (2,23) | bg_event | `EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE` (`:163`) | + +**Wild encounters**: `data/wild/johto_grass.asm:1213`, 6%: L23 Krabby, L24 Zubat, +L25 Krabby, L23 Seel, L24 Golbat, L25 Seel, L25 Seel (no morn/day/nite split). +No water table for B1F. + +--- + +### MAP_WHIRL_ISLAND_B2F + +- Script: `maps/WhirlIslandB2F.asm` (sym `47:413e WhirlIslandB2F_MapScripts`, `47:4146 WhirlIslandB2F_MapEvents`) +- Blocks: `maps/WhirlIslandB2F.blk` +- Header: `data/maps/maps.asm:142` (same as B1F) +- Dimensions: `constants/map_constants.asm:129` -> `map_const WHIRL_ISLAND_B2F, 10, 18` +- Connections: none; border block `$2e` (`data/maps/attributes.asm:461`) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 5 | `WHIRL_ISLAND_B1F` | 7 | +| 2 | 7 | 11 | `WHIRL_ISLAND_B1F` | 8 | +| 3 | 7 | 25 | `WHIRL_ISLAND_LUGIA_CHAMBER` | 1 | +| 4 | 13 | 31 | `WHIRL_ISLAND_SW` | 5 | + +**Coord events / BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDB2F_POKE_BALL1` | `SPRITE_POKE_BALL` | 10 | 11 | `STILL` | `ITEMBALL` | `WhirlIslandB2FFullRestore` | `EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE` | +| `WHIRLISLANDB2F_POKE_BALL2` | `SPRITE_POKE_BALL` | 6 | 4 | `STILL` | `ITEMBALL` | `WhirlIslandB2FMaxRevive` | `EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE` | +| `WHIRLISLANDB2F_POKE_BALL3` | `SPRITE_POKE_BALL` | 5 | 12 | `STILL` | `ITEMBALL` | `WhirlIslandB2FMaxElixer` | `EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_RESTORE` | ball at (10,11) | `WhirlIslandB2FFullRestore` | `EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE` (`:1079`) | +| `MAX_REVIVE` | ball at (6,4) | `WhirlIslandB2FMaxRevive` | `EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE` (`:1080`) | +| `MAX_ELIXER` | ball at (5,12) | `WhirlIslandB2FMaxElixer` | `EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER` (`:1081`) | + +**Wild encounters**: grass `data/wild/johto_grass.asm:1241` (L23-25 +Krabby/Zubat/Seel/Golbat, as B1F); water `data/wild/johto_water.asm:86`, 4%: +L15 Horsea / L20 Horsea / L20 Tentacruel. + +The waterfall the walkthrough rides down is a `COLL_WATERFALL` ($33) tile in +`maps/WhirlIslandB2F.blk`, not a warp row: `engine/overworld/player_movement.asm` +`.CheckTile` / `.water_table` forces `DOWN` on that collision, so descending +needs no HM and no badge. Climbing back up does +(`WaterfallFunction.TryWaterfall`, `ENGINE_RISINGBADGE`). + +--- + +### MAP_WHIRL_ISLAND_LUGIA_CHAMBER + +- Script: `maps/WhirlIslandLugiaChamber.asm` (sym `47:4187 WhirlIslandLugiaChamber_MapScripts`, `47:41d1 WhirlIslandLugiaChamber_MapEvents`) +- Blocks: `maps/WhirlIslandLugiaChamber.blk` (sym `2b:717d WhirlIslandLugiaChamber_Blocks`) +- Header: `data/maps/maps.asm:143` -> `TILESET_DARK_CAVE, CAVE, LANDMARK_WHIRL_ISLANDS, MUSIC_UNION_CAVE, TRUE, PALETTE_DARK, FISHGROUP_WHIRL_ISLANDS` +- Dimensions: `constants/map_constants.asm:130` -> `map_const WHIRL_ISLAND_LUGIA_CHAMBER, 10, 9` +- Connections: none; border block `$0f` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 13 | `WHIRL_ISLAND_B2F` | 3 | + +**Coord events / BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDLUGIACHAMBER_LUGIA` | `SPRITE_LUGIA` | 9 | 5 | `SPRITEMOVEDATA_POKEMON`, palette `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `Lugia` (`47:41a0`) | `EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA` | + +**Scripts of interest** + +- `WhirlIslandLugiaChamberLugiaCallback` (`47:418c`), `callback MAPCALLBACK_OBJECTS`: + `checkevent EVENT_FOUGHT_LUGIA` / `iftrue .NoAppear` / `checkitem SILVER_WING` / + `iftrue .Appear` / `sjump .NoAppear`. Same shape as Ho-Oh's: the + `SILVER_WING` must be in the bag. +- `Lugia` (`47:41a0`): `faceplayer` / `opentext` / `writetext LugiaText` + ("Gyaaas!") / `cry LUGIA` / `pause 15` / `closetext` / + `setevent EVENT_FOUGHT_LUGIA` / `checkver` / `iftrue .Silver`. + - Gold branch: `BATTLETYPE_FORCEITEM`, `loadwildmon LUGIA, 70`. + - `.Silver` branch: `BATTLETYPE_FORCEITEM`, `loadwildmon LUGIA, 40`. + + Note the level pairing is the inverse of Ho-Oh's: the legendary you meet + *early* in your own version is level 40, the cross-version one is level 70. + Again `setevent` precedes `startbattle`, so the encounter is spent the moment + the text closes. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_FOUGHT_LUGIA` | `constants/event_flags.asm:455` | read by the callback, set by `Lugia` before `startbattle` | one-shot latch | +| `EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA` | `constants/event_flags.asm:1247` | object visibility, toggled by `appear`/`disappear` | runtime spawn state | +| `SILVER_WING` (item) | `constants/item_constants.asm` | `checkitem` in the callback | must be carried | +| `EVENT_GOT_SILVER_WING` | `constants/event_flags.asm:130` | set in `maps/RadioTower5F.asm:139` (Silver) / `maps/PewterCity.asm:34` (Gold) | wing acquisition | + +**Battle data** + +- `data/pokemon/base_stats/lugia.asm`: 106/90/130/110/90/154, PSYCHIC/FLYING, + **catch rate 3**, base exp 220, `db NO_ITEM, NO_ITEM ; items` (so + `BATTLETYPE_FORCEITEM` forces nothing here), `GENDER_UNKNOWN`, `GROWTH_SLOW`. +- Level-up set (`data/pokemon/evos_attacks.asm:3310 LugiaEvosAttacks`): + 1 Aeroblast, 11 Safeguard, 22 Gust, 33 Recover, 44 Hydro Pump, 55 Rain Dance, + 66 Swift, 77 Whirlwind, 88 AncientPower, 99 Future Sight. + A **level 40** Lugia (Silver) knows **Aeroblast, Safeguard, Gust, Recover**. + +**Wild encounters**: grass `data/wild/johto_grass.asm:1269`, 6%: L24 Krabby, +L25 Zubat, L26 Krabby, L24 Seel, L25 Golbat, L26 Seel, L26 Seel. Water +`data/wild/johto_water.asm:93`, 4%: L20 Horsea / L20 Tentacruel / L20 Seadra +(the Seadra the walkthrough lists). + +**Fishing** (any Whirl Islands map, `FISHGROUP_WHIRL_ISLANDS`, +`data/wild/fish.asm:21` / `:162`): + +| rod | slots | +|---|---| +| Old (`.WhirlIslands_Old`) | Magikarp L10 x2, Krabby L10 | +| Good (`.WhirlIslands_Good`) | Magikarp L20, Krabby L20 x2, `time_group 18` | +| Super (`.WhirlIslands_Super`) | Krabby L40, `time_group 19`, Kingler L40, Seadra L40 | + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Tin Tower path (Ecruteak entrance house) | `maps/EcruteakTinTowerEntrance.asm` coord events (4,7)/(5,7) under `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS`; sage objects on (4,6)/(5,6) | Fog Badge | `maps/EcruteakGym.asm:34` runs `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP` when `ENGINE_FOGBADGE` is granted | +| Tin Tower stairs (1F) | `maps/TinTower1F.asm:56` - the `TINTOWER1F_SAGE` object literally occupies warp 3 at (10,2) | `EVENT_TEAM_ROCKET_DISBANDED` clear = sage present | `maps/RadioTower5F.asm:130` sets it right after `verbosegiveitem RAINBOW_WING` | +| Ho-Oh spawn | `maps/TinTowerRoof.asm:TinTowerRoofHoOhCallback` | `RAINBOW_WING` in bag AND `EVENT_FOUGHT_HO_OH` clear | Gold: Radio Tower director. Silver: Pewter City gramps | +| Lugia spawn | `maps/WhirlIslandLugiaChamber.asm:WhirlIslandLugiaChamberLugiaCallback` | `SILVER_WING` in bag AND `EVENT_FOUGHT_LUGIA` clear | Silver: Radio Tower director. Gold: Pewter City gramps | +| Reaching Route 41 at all | `engine/events/overworld.asm:322 SurfFunction`, `.TrySurf` at `:339` (`ld de, ENGINE_FOGBADGE / call CheckBadge`); overworld path `:490` | SURF in party + `ENGINE_FOGBADGE` | beat Morty | +| Whirlpools around the islands | `engine/events/overworld.asm:1061 WhirlpoolFunction .TryWhirlpool` and `:1167 TryWhirlpoolOW` (`ld de, ENGINE_GLACIERBADGE / call CheckBadge` / `CheckEngineFlag`) | WHIRLPOOL in party + `ENGINE_GLACIERBADGE` (badge **7**, Pryce) | beat Pryce in Mahogany. Refusal line is `Script_MightyWhirlpool` | +| Darkness inside every Whirl Islands map | `engine/events/overworld.asm:271 FlashFunction .CheckUseFlash`: `ld de, ENGINE_ZEPHYRBADGE / farcall CheckBadge`, then `ld a, [wTimeOfDayPalset] / cp DARKNESS_PALSET` | FLASH in party + `ENGINE_ZEPHYRBADGE`; map header must be `PALETTE_DARK` (`data/maps/maps.asm:136-143`) | Falkner's badge. FLASH is refused outright on a lit map | +| Whirl Island B1F boulder | `maps/WhirlIslandB1F.asm:WhirlIslandB1FBoulder` -> `jumpstd StrengthBoulderScript` | STRENGTH usable | Olivine Cafe / Plain Badge chain (`ENGINE_PLAINBADGE`, `engine/events/overworld.asm:934 StrengthFunction`, badge check at `:941`) | +| Climbing back up the B2F waterfall | `engine/events/overworld.asm:611 WaterfallFunction .TryWaterfall` (`:618`) / `:683 TryWaterfallOW` (`:687`) (`ENGINE_RISINGBADGE`) | WATERFALL + badge 8 | Clair. Descending needs neither (`player_movement.asm` `.water_table` forces DOWN on `COLL_WATERFALL`) | + +Nothing in this section gates on `VAR_BADGES` count, so "you'll need eight +badges for Whirlpool" is not what the code says (see section 6). + +--- + +## 4. Bot checklist + +Preconditions for the whole section: `ENGINE_FOGBADGE` set (Surf + Tin Tower +entrance), and for the Silver branch also `ENGINE_ZEPHYRBADGE` (Flash), +`ENGINE_GLACIERBADGE` (Whirlpool), `ENGINE_PLAINBADGE` (Strength). A party +member with SURF, one with FLASH, one with WHIRLPOOL, one with STRENGTH. Stock +about 30 `ULTRA_BALL` plus `REVIVE`/`HYPER_POTION`. + +**Gold: Tin Tower / Ho-Oh** + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `ECRUTEAK_CITY` | warp 3 (18,11) | walk | `ENGINE_FOGBADGE`, `EVENT_GOT_RAINBOW_WING` | in `ECRUTEAK_TIN_TOWER_ENTRANCE` | +| 2 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | (4,7) or (5,7) | walk north | map scene == `_NOOP` (1) | no sage cutscene fires | +| 3 | same | warp 3 (5,3) | walk | - | teleported to (17,15) | +| 4 | same | warp 5 (17,3) | walk | - | in `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | +| 5 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | warp 1 (7,4) | walk south | - | back on `ECRUTEAK_CITY` at (20,2) | +| 6 | `ECRUTEAK_CITY` | warp 12 (37,7) | walk east | - | in `TIN_TOWER_1F` | +| 7 | `TIN_TOWER_1F` | warp 3 (10,2) | walk | `EVENT_TEAM_ROCKET_DISBANDED` set (sage gone) | `TIN_TOWER_2F` | +| 8 | `TIN_TOWER_2F` | warp 1 (10,14) | walk (Max Repel active) | - | `TIN_TOWER_3F` | +| 9 | `TIN_TOWER_3F` | ball (3,14) | walk onto | `EVENT_TIN_TOWER_3F_FULL_HEAL` clear | `FULL_HEAL`, flag set | +| 10 | `TIN_TOWER_3F` | warp 2 (16,2) | walk | - | `TIN_TOWER_4F` | +| 11 | `TIN_TOWER_4F` | balls (14,10), (17,14), (2,12); hidden (11,6) | walk on / press A | respective flags clear | `ULTRA_BALL`, `SUPER_POTION`, `ESCAPE_ROPE`, `MAX_POTION` | +| 12 | `TIN_TOWER_4F` | warp 1 (2,4) | walk | - | `TIN_TOWER_5F` | +| 13 | `TIN_TOWER_5F` | ball (9,9); hidden (16,14), (3,15) | walk on / press A | flags clear | `RARE_CANDY`, `FULL_RESTORE`, `CARBOS` | +| 14 | `TIN_TOWER_5F` | warp 1 (11,15) | walk | - | `TIN_TOWER_6F` | +| 15 | `TIN_TOWER_6F` | warp 1 (3,9) | walk | - | `TIN_TOWER_7F` | +| 16 | `TIN_TOWER_7F` | ball (16,1) | walk on | flag clear | `MAX_REVIVE` | +| 17 | `TIN_TOWER_7F` | warp 2 (10,15) | walk | - | `TIN_TOWER_8F` at (2,5) | +| 18 | `TIN_TOWER_8F` | balls (7,13), (11,6), (3,1) | walk on | flags clear | `NUGGET`, `MAX_ELIXER`, `FULL_RESTORE` | +| 19 | `TIN_TOWER_8F` | any of warps 2-6 | walk | - | `TIN_TOWER_9F` | +| 20 | `TIN_TOWER_9F` | warp 4 (7,9) | walk | - | `TIN_TOWER_ROOF` at (9,13) | +| 21 | `TIN_TOWER_ROOF` | - | **save** | - | restore point before the one-shot | +| 22 | `TIN_TOWER_ROOF` | `TINTOWERROOF_HO_OH` at (9,5) | talk (face north from (9,6)) | `RAINBOW_WING` in bag, `EVENT_FOUGHT_HO_OH` clear | `EVENT_FOUGHT_HO_OH` set, wild L40 Ho-Oh battle, holds `SACRED_ASH` | +| 23 | battle | throw `ULTRA_BALL` | weaken to red, prefer sleep/freeze | see catch math below | caught or reset to step 21 | +| 24 | `TIN_TOWER_ROOF` | `ESCAPE_ROPE` | use item | tileset is `TILESET_TOWER` | back outside | + +**Silver: Whirl Islands / Lugia** + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `OLIVINE_CITY` | - | fly | `ENGINE_FLYPOINT_OLIVINE` | at Olivine | +| 2 | `ROUTE_40`/`ROUTE_41` | south then west | surf | SURF + `ENGINE_FOGBADGE` | on Route 41 | +| 3 | `ROUTE_41` | whirlpool tile NW of (44,28) | press A -> `TryWhirlpoolOW` | WHIRLPOOL + `ENGINE_GLACIERBADGE` | whirlpool block replaced (`DisappearWhirlpool`) | +| 4 | `ROUTE_41` | warp 2 (36,19) | walk/surf onto | - | `WHIRL_ISLAND_NE` at (3,13) | +| 5 | `WHIRL_ISLAND_NE` | - | use FLASH | FLASH + `ENGINE_ZEPHYRBADGE`, map is `PALETTE_DARK` | cave lit | +| 6 | `WHIRL_ISLAND_NE` | ball (11,11) | walk on | flag clear | `ULTRA_BALL` | +| 7 | `WHIRL_ISLAND_NE` | warp 2 (17,3) or warp 3 (13,11) | walk | - | `WHIRL_ISLAND_B1F` at (35,3)/(29,9) | +| 8 | `WHIRL_ISLAND_B1F` | balls (17,8), (33,23), (19,26), (7,13), (2,18); hidden (30,4), (36,18), (2,23) | walk on / press A | flags clear | Nugget, Calcium, Escape Rope, Full Restore, Carbos, Rare Candy, Ultra Ball, Full Restore | +| 9 | `WHIRL_ISLAND_B1F` | boulder at (23,26) | push with STRENGTH | STRENGTH enabled | path opened | +| 10 | `WHIRL_ISLAND_SW` (via B1F warp 4/5) | ball (15,2) | walk on | flag clear | `GUARD_SPEC` | +| 11 | `WHIRL_ISLAND_B1F` | warp 8 (13,27) | walk | - | `WHIRL_ISLAND_B2F` at (7,11) | +| 12 | `WHIRL_ISLAND_B2F` | balls (6,4), (10,11), (5,12) | walk on | flags clear | `MAX_REVIVE`, `FULL_RESTORE`, `MAX_ELIXER` | +| 13 | `WHIRL_ISLAND_B2F` | surf south onto the `COLL_WATERFALL` tile | walk down | SURF only, no badge needed | carried to the lower pool | +| 14 | `WHIRL_ISLAND_B2F` | warp 3 (7,25) | walk | - | `WHIRL_ISLAND_LUGIA_CHAMBER` at (9,13) | +| 15 | `WHIRL_ISLAND_LUGIA_CHAMBER` | - | **save** | - | restore point | +| 16 | same | surf north to face `WHIRLISLANDLUGIACHAMBER_LUGIA` at (9,5) | talk | `SILVER_WING` in bag, `EVENT_FOUGHT_LUGIA` clear | `EVENT_FOUGHT_LUGIA` set, wild L40 Lugia battle | +| 17 | battle | throw `ULTRA_BALL` or `HEAVY_BALL` | weaken, prefer sleep/freeze | see catch math | caught or reset to step 15 | +| 18 | same | `ESCAPE_ROPE` | use item | `TILESET_DARK_CAVE` | back outside | + +**Catch math a bot can plan against** (`engine/items/item_effects.asm PokeBallEffect`): + +- Both legendaries have base catch rate 3. +- `UltraBallMultiplier` doubles it -> 6. +- Status bonus is added after the HP term, but only sleep and freeze actually + apply: the `and` that tests for them leaves burn/poison/paralysis at 0 + (a documented cart bug the port reproduces, see + `src/battle/gen2/Catching.lua`). So paralysis is worth nothing here; sleep or + freeze is worth +10, which is larger than the entire ball-adjusted rate. +- `HeavyBallMultiplier` works off the Pokedex weight word, converted in-place to + tenths of a kilogram. Lugia is `dw 1701, 4760` (476.0 lb -> 215.8 kg), which + lands in the 204.8-307.2 kg bucket for **+20**, giving 23 versus the Ultra + Ball's 6. Ho-Oh is `dw 1206, 4390` (439.0 lb -> 199.0 kg), which is above the + 102.4 kg light threshold but under 204.8 kg, so its bucket boost is **+0**: + Heavy Ball leaves Ho-Oh at 3 and the Ultra Ball is strictly better. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers / dimensions / warps / coord events / bg events / object events for every map here | `src/import/RomExtractorGen2.lua:804-862, :974` (generic per-map-group walk, `MAP_GROUP_COUNT = 26`) | implemented (data-driven, nothing map-specific needed) | +| `MAPCALLBACK_OBJECTS` (what spawns Ho-Oh / Lugia) | `src/world/gen2/World.lua:5694-5700`, `:5983` | implemented | +| `checkitem` / `appear` / `disappear` / `checkevent` used by both legendary callbacks | `src/script/gen2/Vm.lua:315, :323, :523`; `src/script/gen2/Opcodes.lua:0x21/0x6d/0x6e` | implemented | +| `checkver` version split (L40 vs L70) | `src/script/gen2/Vm.lua:774-778` (the comment there cites `WhirlIslandLugiaChamber` by name) | implemented | +| `loadwildmon` + `startbattle` legendary encounter | `src/script/gen2/Vm.lua:817, :836-856` | implemented | +| `BATTLETYPE_FORCEITEM` forcing Ho-Oh's `SACRED_ASH` | `src/world/gen2/World.lua:103-110, :4586` | implemented | +| `cry` / `pause` / `faceplayer` / `reloadmapafterbattle` in the encounter scripts | `src/script/gen2/Vm.lua:621, :886`; `src/script/gen2/Opcodes.lua:0x83, 0x8a, 0x6a, 0x5f` | implemented | +| Item balls (`OBJECTTYPE_ITEMBALL`) and hidden items (`BGEVENT_ITEM`) | `src/world/gen2/HiddenItems.lua`; `src/world/gen2/World.lua:1373, :5147-5280` | implemented | +| Whirlpool field move + `ENGINE_GLACIERBADGE` gate + block replacement | `src/world/gen2/FieldMoves.lua:109, :219, :247, :540-556, :626-641`; `src/world/gen2/Permissions.lua:129 isWhirlpool` | implemented | +| Flash + `ENGINE_ZEPHYRBADGE` + `PALETTE_DARK` / `DARKNESS_PALSET` | `src/world/gen2/FieldMoves.lua:105, :465-474`; `src/world/gen2/Palettes.lua:75-103` | implemented | +| Surf / Fly / Waterfall badge gates | `src/world/gen2/FieldMoves.lua:101-109` (`SURF = FOG`, `FLY = STORM`, `WATERFALL` via `World.lua:4219`) | implemented | +| Strength boulder (`jumpstd StrengthBoulderScript`, Whirl Island B1F) | `src/world/gen2/World.lua:4071-4088, :4234` | implemented | +| Scene scripts / `setmapscene` (the Ecruteak sage block) | `src/script/gen2/Opcodes.lua:0x12 setmapscene`; `src/world/gen2/World.lua:5013` (coord event scan) | implemented | +| **Forced-tile movement (`COLL_WATERFALL` riding down, `COLL_CURRENT_*`)** | no implementation found: `Permissions.lua:135` defines `isWaterfall` but nothing mirrors `player_movement.asm` `.CheckTile` / `.water_table`; a grep for `HI_NYBBLE_CURRENT` / forced direction over `src/world/gen2/*.lua` returns only `Bike.forcedDirection` | **missing** - the Whirl Island B2F waterfall descent to the Lugia Chamber has no forced-down step | +| **Apricorn ball multipliers (Heavy / Level / Lure / Fast / Moon / Love / Friend)** | `src/battle/gen2/Catching.lua:28-43` sets all of them to a flat `1`, with a comment that they "key off conditions the caller supplies"; no caller supplies them (grep for `HEAVY_BALL` outside that table finds nothing) | **missing** - the Heavy Ball advantage on Lugia is not reproduced, and the three documented ball bugs are not modelled either way | +| Ultra/Great ball multipliers and the two catch-rate cart bugs | `src/battle/gen2/Catching.lua:1-48, :60-80` | implemented (bugs deliberately preserved, `fixBugs` opt-out) | +| Headless driver for either legendary | `tests/drivers/gold_*.lua` has `gold_roamers.lua`, `gold_icepath_boulder.lua`, `gold_battle_smoke.lua` etc., but **no** Tin Tower or Whirl Islands driver | **missing** - nothing exercises the wing-in-bag callback path end to end | + +--- + +## 6. Unresolved / verify by hand + +1. **"You'll need eight badges in order to use Whirlpool."** The asm disagrees: + `engine/events/overworld.asm:1077` (`WhirlpoolFunction.TryWhirlpool`) and + `:1171` (`TryWhirlpoolOW`) both check `ENGINE_GLACIERBADGE`, which is badge 7 + (Pryce, Mahogany), not badge 8. Nothing reads `VAR_BADGES` for Whirlpool. +2. **Ho-Oh's moves.** The walkthrough says "Safeguard and Ancient Power (damage + w/ stat boosts), Punishment, and Sacred Fire". `HoOhEvosAttacks` + (`data/pokemon/evos_attacks.asm:3324`) gives a level 40 Ho-Oh + **Sacred Fire, Safeguard, Gust, Recover**. AncientPower is learned at 88, and + Punishment does not exist in Generation 2 (`constants/move_constants.asm` has + no such constant). This reads like a Heart Gold / Soul Silver moveset. +3. **Tin Tower item list.** The walkthrough calls out a **PP Up** on the + Ultra Ball floor, a **Max Potion then a Full Heal** on the bridge floor, and + an **HP Up** near the end. The asm has, in those positions, `SUPER_POTION` + (`TinTower4F.asm`, (17,14)), nothing at all on `TIN_TOWER_6F` (the file has an + empty `def_object_events` and no bg events), and `NUGGET` / `FULL_RESTORE` + on `TIN_TOWER_8F`. The `MAX_POTION` that does exist is the **hidden** item at + (11,6) on 4F. No `PP_UP` or `HP_UP` appears in any `TinTower*.asm`. Treat the + walkthrough's floor-by-floor item order as unreliable and use the tables in + section 2. +4. **Whirl Islands item list.** The walkthrough lists "Max Revive x2"; the asm + has exactly one `MAX_REVIVE`, on B2F at (6,4) + (`EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE`). It also omits the B1F Full Restore, + Carbos and Nugget, the three B1F hidden items, and the B2F Full Restore and + Max Elixer, all of which are real. +5. **Hop / ledge / bridge routing.** Every "jump left x5, down, right x2" + instruction in the walkthrough is block-layout geometry that lives in the + `.blk` files (`maps/TinTower4F.blk` .. `TinTower9F.blk`, + `maps/WhirlIslandB1F.blk`, `WhirlIslandB2F.blk`), not in any asm text row. I + did not decode the block data, so those step sequences are unverified. The + warp tables in section 2 are the verified part; a bot should path to warp + coordinates and let a collision-aware pathfinder handle the ledges. +6. **Whirlpool tile coordinates on Route 41.** "The whirlpool is just northwest + of where you fought Swimmer Kara" could not be pinned to an asm row: the + whirlpool tiles are `COLL_WHIRLPOOL` ($24) entries inside `maps/Route41.blk`, + with no coordinate list anywhere in `maps/Route41.asm`. Kara's own position, + (44,28), is verified. +7. **"The Ho-Oh in-game music."** There is no dedicated legendary battle theme in + Gold/Silver: `engine/battle/start_battle.asm` selects + `MUSIC_JOHTO_WILD_BATTLE` / `..._NIGHT` for a wild encounter and has no case + for Ho-Oh or Lugia. The distinctive track is the map music + `MUSIC_TIN_TOWER` (`data/maps/maps.asm:344`), which stops when the battle + starts. Whirl Islands play `MUSIC_UNION_CAVE` (`:136-143`), so Lugia gets no + special music at all. +8. **"About a 2% catch rate at low health."** I did not re-derive the shake + check, only the rate formula; the numeric claim is unverified. The inputs are + verified: base catch rate 3, Ultra Ball x2, sleep/freeze +10, and the two + reproduced cart bugs in `PokeBallEffect`. +9. **Thard_Verad's Apriball note** checks out against the source, with one + correction worth recording. `FastBallMultiplier` + (`engine/items/item_effects.asm:986 FastBallMultiplier`) is documented in-file as buggy: it only + matches the **first three** entries of `FleeMons`, which + `data/wild/flee_mons.asm:4` shows are `MAGNEMITE`, `GRIMER`, `TANGELA` - so + the correct species list is those three, and Raikou/Entei/Suicune (in + `AlwaysFleeMons`) indeed get nothing. `LoveBallMultiplier` (`:921`) is + commented "for the intended effect, this should be `ret z`", i.e. same gender + rather than opposite. `MoonBallMultiplier` (`:875`) compares against + `MOON_STONE_RED`, which is `BURN_HEAL` in Gen 2, so it never fires. + `LevelBallMultiplier`, `LureBallMultiplier`, `HeavyBallMultiplier` and the + Friend Ball happiness path (`:559`, `:622`) carry no bug comments. diff --git a/docs/gold-walkthrough/asm-walk/section-15-mount-mortar-and-dark-cave.md b/docs/gold-walkthrough/asm-walk/section-15-mount-mortar-and-dark-cave.md new file mode 100644 index 00000000..a95ff62a --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-15-mount-mortar-and-dark-cave.md @@ -0,0 +1,762 @@ +# Section 15 - Mount Mortar and Dark Cave + +Source: `../section-15-mount-mortar-and-dark-cave.txt` +Maps covered: `MAP_MOUNT_MORTAR_1F_OUTSIDE`, `MAP_MOUNT_MORTAR_1F_INSIDE`, +`MAP_MOUNT_MORTAR_2F_INSIDE`, `MAP_MOUNT_MORTAR_B1F`, +`MAP_DARK_CAVE_BLACKTHORN_ENTRANCE`, `MAP_DARK_CAVE_VIOLET_ENTRANCE`. +Touched in passing (owned by neighbouring sections): `MAP_MAHOGANY_TOWN`, +`MAP_ROUTE_42`, `MAP_BLACKTHORN_CITY`, `MAP_ROUTE_45`, `MAP_ROUTE_31`, +`MAP_ROUTE_46`. + +Badges / key milestones in this section: **no badge**. The milestones are +`EVENT_BEAT_BLACKBELT_KIYO` + `EVENT_GOT_TYROGUE_FROM_KIYO` (a free level 10 +`TYROGUE`, the only one in the game) and `EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE`. +Everything else here is optional loot. Nothing in this section gates any later +section. + +A note on coordinates: every table below is transcribed verbatim from the map's +`_MapEvents` block in the named `maps/*.asm` file. `warp_event`, `coord_event`, +`bg_event` and `object_event` all share one coordinate space (the +`object_event` macro in `macros/scripts/maps.asm` applies the +4 border offset +itself), so the numbers are directly comparable. A map declared +`map_const NAME, W, H` spans x in `0 .. 2W-1` and y in `0 .. 2H-1`. + +A note on the `event flag` column of `object_event`: +`engine/overworld/map_objects_2.asm` `CheckObjectFlag` **hides** the object when +the flag is set, and `-1` means always visible. So for a Poke Ball, "flag set = +already taken = gone". + +A note on darkness: all six maps in this section are `PALETTE_DARK` in +`data/maps/maps.asm`. `engine/tilesets/timeofday_pals.asm` +`ReplaceTimeOfDayPals` (`23:43e9`) sends `PALETTE_DARK` down `.NeedsFlash`, +which writes `DARKNESS_PALSET` unless `STATUSFLAGS_FLASH_F` is set in +`wStatusFlags`. That bit is set by `BlindingFlash` (`engine/events/field_moves.asm`) +and cleared by `ResetFlashIfOutOfCave` (`home/flag.asm`, `00:2f1d`) on any map +whose `wEnvironment` is `ROUTE` or `TOWN`. **A bot must re-cast Flash every time +it re-enters, including after every Fly.** + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_MAHOGANY_TOWN` | `data/maps/maps.asm:74`, `data/maps/spawn_points.asm` `spawn MAHOGANY_TOWN, 15, 14` | Fly (`ENGINE_FLYPOINT_MAHOGANY`) | west map connection -> `ROUTE_42` | heal, buy Max Repels / Escape Ropes, drop to 5 party members so Tyrogue fits | +| 2 | `MAP_ROUTE_42` | `maps/Route42.asm` | east connection from Mahogany, or warps 1/2 at (0,8)/(0,9) from the Ecruteak gate | warp 3 (10,5) west entrance, warp 4 (28,9) middle entrance, warp 5 (46,7) east entrance -> `MOUNT_MORTAR_1F_OUTSIDE` warps 1/2/3 | "three cave entrances to Mt. Mortar" | +| 3 | `MAP_MOUNT_MORTAR_1F_OUTSIDE` | `maps/MountMortar1FOutside.asm` | warps 1/2/3 at (3,33)/(17,33)/(37,33) | warp 4 (17,5) -> 2F after Waterfall; warp 7 (17,29) -> B1F | all three Route 42 doors land on this one map; the Surf lake and the waterfall live here | +| 4 | `MAP_MOUNT_MORTAR_1F_INSIDE` | `maps/MountMortar1FInside.asm` | 1F Outside warps 5/6 (11,21)/(29,21) or 8/9 (7,13)/(33,13) | warp 5 (3,19) -> B1F; warp 6 (9,9) -> 2F | the "giant room" with the Strength boulder, Escape Rope and Hyper Potion | +| 5 | `MAP_MOUNT_MORTAR_2F_INSIDE` | `maps/MountMortar2FInside.asm` | warp 1 (17,33) from 1F Outside warp 4 (top of the waterfall) | warp 2 (3,5) -> `MOUNT_MORTAR_1F_INSIDE` warp 6 | Rare Candy, Max Potion, TM40, Dragon Scale, Elixer, Escape Rope, hidden Full Restore | +| 6 | `MAP_MOUNT_MORTAR_B1F` | `maps/MountMortarB1F.asm` | warp 1 (3,3) from 1F Inside warp 5 | Escape Rope, or Strength the boulder at (9,10) and take warp 2 (19,29) -> 1F Outside warp 7 | **Blackbelt Kiyo, then the free Tyrogue** | +| 7 | `MAP_BLACKTHORN_CITY` | `data/maps/maps.asm:187`, `data/maps/spawn_points.asm` `spawn BLACKTHORN_CITY, 21, 30` | Fly (`ENGINE_FLYPOINT_BLACKTHORN`) | south map connection (`data/maps/attributes.asm:161` `connection south, Route45, ROUTE_45, 0`) | heal, then head south | +| 8 | `MAP_ROUTE_45` | `maps/Route45.asm` | north connection from Blackthorn | warp 1 (2,5) -> `DARK_CAVE_BLACKTHORN_ENTRANCE` warp 1 | "enter the Dark Cave to the left before there are even any jumps" | +| 9 | `MAP_DARK_CAVE_BLACKTHORN_ENTRANCE` | `maps/DarkCaveBlackthornEntrance.asm` | warp 1 (23,3) | warp 2 (3,25) -> `DARK_CAVE_VIOLET_ENTRANCE` warp 2 | Blackglasses NPC, Revive, TM13 Snore, Wobbuffet | +| 10 | `MAP_DARK_CAVE_VIOLET_ENTRANCE` | `maps/DarkCaveVioletEntrance.asm` | warp 2 (17,1) | warp 1 (3,15) -> `ROUTE_31` warp 3 | "surf down, rock smash the rock, grab the Potion, head out onto Route 31" | +| 11 | `MAP_ROUTE_31` | `maps/Route31.asm` | warp 3 (34,5) | Fly back to Blackthorn | walkthrough exit | + +Spill into the next section: the walkthrough's video links name Blackthorn Gym, +Route 45/46, Tohjo Falls, Routes 26/27, Victory Road and the Elite Four, but its +**prose** stops at "head back south to actually take Route 45". `MAP_ROUTE_45` +proper, `MAP_ROUTE_46`, `MAP_BLACKTHORN_GYM` and everything past them belong to +the next section; only the Route 45 warp that reaches Dark Cave is documented +here. + +`MAP_DARK_CAVE_VIOLET_ENTRANCE` is also reachable from `ROUTE_46` warp 3 at +(14,5) (-> Dark Cave warp 3 at (35,33)) and from `ROUTE_31` warp 3; the earlier +sections that visit Route 31 already touch the north end of this map. This +section only walks it top-to-bottom-left, from the Blackthorn side out to +Route 31. + +--- + +## 2. Maps + +### MAP_MOUNT_MORTAR_1F_OUTSIDE + +- Script: `maps/MountMortar1FOutside.asm` +- Blocks: `maps/MountMortar1FOutside.blk` +- Header: `data/maps/maps.asm:127` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_MT_MORTAR`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:114` `map_const MOUNT_MORTAR_1F_OUTSIDE, 20, 18` + (x 0..39, y 0..35), map id 49, group 3 +- Attributes: `data/maps/attributes.asm:446`, border block `$09`, **no connections** +- Map events block: `MountMortar1FOutside_MapEvents` = `46:5db9` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 33 | `ROUTE_42` | 3 | +| 2 | 17 | 33 | `ROUTE_42` | 4 | +| 3 | 37 | 33 | `ROUTE_42` | 5 | +| 4 | 17 | 5 | `MOUNT_MORTAR_2F_INSIDE` | 1 | +| 5 | 11 | 21 | `MOUNT_MORTAR_1F_INSIDE` | 1 | +| 6 | 29 | 21 | `MOUNT_MORTAR_1F_INSIDE` | 2 | +| 7 | 17 | 29 | `MOUNT_MORTAR_B1F` | 2 | +| 8 | 7 | 13 | `MOUNT_MORTAR_1F_INSIDE` | 3 | +| 9 | 33 | 13 | `MOUNT_MORTAR_1F_INSIDE` | 4 | + +**Coord events** (`def_coord_events`) + +`def_coord_events` is **empty**. No scripted trip-wires anywhere in Mt. Mortar. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 25 | 22 | `BGEVENT_ITEM` | `MountMortar1FOutsideHiddenHyperPotion` -> `hiddenitem HYPER_POTION, EVENT_MOUNT_MORTAR_1F_OUTSIDE_HIDDEN_HYPER_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMORTAR1FOUTSIDE_POKE_BALL1` | `SPRITE_POKE_BALL` | 13 | 15 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar1FOutsideEther` (`itemball ETHER`) | `EVENT_MOUNT_MORTAR_1F_OUTSIDE_ETHER` | +| `MOUNTMORTAR1FOUTSIDE_POKE_BALL2` | `SPRITE_POKE_BALL` | 31 | 18 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar1FOutsideGuardSpec` (`itemball GUARD_SPEC`) | `EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE` | + +Note the flag/item mismatch on the second ball: the item is `GUARD_SPEC` but the +flag is named `..._REVIVE`. That is verbatim from the disassembly - the label +name is cosmetic, the flag bit is what matters. + +**Scripts of interest** + +- `MountMortar1FOutsideEther` / `MountMortar1FOutsideGuardSpec` - bare + `itemball ITEM` rows (two bytes, no bytecode). Picked up by the shared + `FindItemInBallScript` path, not by a per-map script. +- `MountMortar1FOutsideHiddenHyperPotion` - `hiddenitem` data behind a + `BGEVENT_ITEM` row; A-pressed on (25,22) or found by the ITEMFINDER. + +**Wild encounters** + +`data/wild/johto_grass.asm:686` `def_grass_wildmons MOUNT_MORTAR_1F_OUTSIDE`, +encounter rate `6 percent` for all three of morn/day/nite, and **all three +slot lists are identical**: + +| slot | lvl | species | +|---|---|---| +| 1 | 13 | ZUBAT | +| 2 | 15 | ZUBAT | +| 3 | 14 | MACHOP | +| 4 | 14 | RATTATA | +| 5 | 14 | GEODUDE | +| 6 | 16 | RATTATA | +| 7 | 15 | MARILL | + +`data/wild/johto_water.asm:58` `def_water_wildmons MOUNT_MORTAR_1F_OUTSIDE`, +rate `4 percent`: 20 GOLDEEN / 15 GOLDEEN / 20 SEAKING. + +Fishing: `FISHGROUP_LAKE`, `data/wild/fish.asm:57` - Old rod MAGIKARP 10 / +GOLDEEN 10; Good rod MAGIKARP 20 / GOLDEEN 20 / `time_group 4`; Super rod +GOLDEEN 40 / `time_group 5` / MAGIKARP 40 / SEAKING 40. + +No headbutt or rock-smash table (`data/wild/treemon_maps.asm` does not list this +map). + +--- + +### MAP_MOUNT_MORTAR_1F_INSIDE + +- Script: `maps/MountMortar1FInside.asm` +- Blocks: `maps/MountMortar1FInside.blk` +- Header: `data/maps/maps.asm:128` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_MT_MORTAR`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:115` `map_const MOUNT_MORTAR_1F_INSIDE, 20, 27` + (x 0..39, y 0..53), map id 50, group 3 +- Attributes: `data/maps/attributes.asm:447`, border block `$09`, **no connections** +- Map events block: `MountMortar1FInside_MapEvents` = `46:5e19` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 47 | `MOUNT_MORTAR_1F_OUTSIDE` | 5 | +| 2 | 29 | 47 | `MOUNT_MORTAR_1F_OUTSIDE` | 6 | +| 3 | 5 | 39 | `MOUNT_MORTAR_1F_OUTSIDE` | 8 | +| 4 | 33 | 41 | `MOUNT_MORTAR_1F_OUTSIDE` | 9 | +| 5 | 3 | 19 | `MOUNT_MORTAR_B1F` | 1 | +| 6 | 9 | 9 | `MOUNT_MORTAR_2F_INSIDE` | 2 | + +**Coord events** - empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 31 | 9 | `BGEVENT_ITEM` | `MountMortar1FInsideHiddenMaxRepel` -> `hiddenitem MAX_REPEL, EVENT_MOUNT_MORTAR_1F_INSIDE_HIDDEN_MAX_REPEL` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMORTAR1FINSIDE_BOULDER` | `SPRITE_BOULDER` | 21 | 43 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `MountMortar1FBoulder` (`jumpstd StrengthBoulderScript`) | `-1` | +| `MOUNTMORTAR1FINSIDE_POKE_BALL1` | `SPRITE_POKE_BALL` | 33 | 22 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar1FInsideEscapeRope` (`itemball ESCAPE_ROPE`) | `EVENT_MOUNT_MORTAR_1F_INSIDE_ESCAPE_ROPE` | +| `MOUNTMORTAR1FINSIDE_POKE_BALL2` | `SPRITE_POKE_BALL` | 16 | 10 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar1FInsideMaxRevive` (`itemball MAX_REVIVE`) | `EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_REVIVE` | +| `MOUNTMORTAR1FINSIDE_POKE_BALL3` | `SPRITE_POKE_BALL` | 12 | 21 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar1FInsideHyperPotion` (`itemball HYPER_POTION`) | `EVENT_MOUNT_MORTAR_1F_INSIDE_HYPER_POTION` | + +**Scripts of interest** + +- `MountMortar1FBoulder` - one line: `jumpstd StrengthBoulderScript`. + `engine/events/std_scripts.asm:196` `StrengthBoulderScript: farsjump AskStrengthScript` + (`03:4d4e`). See section 3 for the gate. + +**Wild encounters** + +`data/wild/johto_grass.asm:714` `def_grass_wildmons MOUNT_MORTAR_1F_INSIDE`, +rate `6 percent` morn/day/nite, all three lists identical: + +| slot | lvl | species | +|---|---|---| +| 1 | 13 | GEODUDE | +| 2 | 13 | MACHOP | +| 3 | 15 | GEODUDE | +| 4 | 14 | RATTATA | +| 5 | 15 | MACHOP | +| 6 | 14 | ZUBAT | +| 7 | 14 | ZUBAT | + +**There is no `def_water_wildmons MOUNT_MORTAR_1F_INSIDE`** in +`data/wild/johto_water.asm` - only 1F Outside, 2F Inside and B1F have water +tables. Fishing group is still `FISHGROUP_LAKE`. + +--- + +### MAP_MOUNT_MORTAR_2F_INSIDE + +- Script: `maps/MountMortar2FInside.asm` +- Blocks: `maps/MountMortar2FInside.blk` +- Header: `data/maps/maps.asm:129` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_MT_MORTAR`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:116` `map_const MOUNT_MORTAR_2F_INSIDE, 20, 18` + (x 0..39, y 0..35), map id 51, group 3 +- Attributes: `data/maps/attributes.asm:448`, border block `$09`, **no connections** +- Map events block: `MountMortar2FInside_MapEvents` = `46:5e87` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 33 | `MOUNT_MORTAR_1F_OUTSIDE` | 4 | +| 2 | 3 | 5 | `MOUNT_MORTAR_1F_INSIDE` | 6 | + +**Coord events** - empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 19 | 6 | `BGEVENT_ITEM` | `MountMortar2FInsideHiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_MOUNT_MORTAR_2F_INSIDE_HIDDEN_FULL_RESTORE` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMORTAR2FINSIDE_POKE_BALL1` | `SPRITE_POKE_BALL` | 31 | 23 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideMaxPotion` (`itemball MAX_POTION`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_MAX_POTION` | +| `MOUNTMORTAR2FINSIDE_POKE_BALL2` | `SPRITE_POKE_BALL` | 2 | 24 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideRareCandy` (`itemball RARE_CANDY`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_RARE_CANDY` | +| `MOUNTMORTAR2FINSIDE_POKE_BALL3` | `SPRITE_POKE_BALL` | 19 | 17 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideTMDefenseCurl` (`itemball TM_DEFENSE_CURL`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_TM_DEFENSE_CURL` | +| `MOUNTMORTAR2FINSIDE_POKE_BALL4` | `SPRITE_POKE_BALL` | 14 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideDragonScale` (`itemball DRAGON_SCALE`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_DRAGON_SCALE` | +| `MOUNTMORTAR2FINSIDE_POKE_BALL5` | `SPRITE_POKE_BALL` | 8 | 9 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideElixer` (`itemball ELIXER`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_ELIXER` | +| `MOUNTMORTAR2FINSIDE_POKE_BALL6` | `SPRITE_POKE_BALL` | 28 | 5 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortar2FInsideEscapeRope` (`itemball ESCAPE_ROPE`) | `EVENT_MOUNT_MORTAR_2F_INSIDE_ESCAPE_ROPE` | + +`TM_DEFENSE_CURL` is **TM40** by the `add_tm` ordering in +`constants/item_constants.asm:261`. The walkthrough calls it "TM40 Aerial Ace", +which is the HGSS remake's TM40 - see section 6. + +**Wild encounters** + +`data/wild/johto_grass.asm:742` `def_grass_wildmons MOUNT_MORTAR_2F_INSIDE`, +rate `6 percent` morn/day/nite, all three lists identical - this is the floor +the walkthrough's "Raticate / Machoke / Graveler" list is drawn from: + +| slot | lvl | species | +|---|---|---| +| 1 | 31 | GRAVELER | +| 2 | 32 | MACHOKE | +| 3 | 31 | GEODUDE | +| 4 | 30 | RATICATE | +| 5 | 28 | MACHOP | +| 6 | 30 | GOLBAT | +| 7 | 30 | GOLBAT | + +`data/wild/johto_water.asm:65` `def_water_wildmons MOUNT_MORTAR_2F_INSIDE`, +rate `2 percent`: 20 GOLDEEN / 25 GOLDEEN / 25 SEAKING. + +--- + +### MAP_MOUNT_MORTAR_B1F + +- Script: `maps/MountMortarB1F.asm` +- Blocks: `maps/MountMortarB1F.blk` +- Header: `data/maps/maps.asm:130` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_MT_MORTAR`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:117` `map_const MOUNT_MORTAR_B1F, 20, 18` + (x 0..39, y 0..35), map id 52, group 3 +- Attributes: `data/maps/attributes.asm:449`, border block `$09`, **no connections** +- Map events block: `MountMortarB1F_MapEvents` = `46:60f0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `MOUNT_MORTAR_1F_INSIDE` | 5 | +| 2 | 19 | 29 | `MOUNT_MORTAR_1F_OUTSIDE` | 7 | + +**Coord events** - empty. Kiyo is a talk-to trigger, not a trip-wire. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 6 | `BGEVENT_ITEM` | `MountMortarB1FHiddenMaxRevive` -> `hiddenitem MAX_REVIVE, EVENT_MOUNT_MORTAR_B1F_HIDDEN_MAX_REVIVE` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMORTARB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 31 | 17 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortarB1FHyperPotion` (`itemball HYPER_POTION`) | `EVENT_MOUNT_MORTAR_B1F_HYPER_POTION` | +| `MOUNTMORTARB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 4 | 16 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `MountMortarB1FFullHeal` (`itemball FULL_HEAL`) | `EVENT_MOUNT_MORTAR_B1F_FULL_HEAL` | +| `MOUNTMORTARB1F_BOULDER` | `SPRITE_BOULDER` | 9 | 10 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `MountMortarB1FBoulder` (`jumpstd StrengthBoulderScript`) | `-1` | +| `MOUNTMORTARB1F_KIYO` | `SPRITE_BLACK_BELT` | 13 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, palette `PAL_NPC_BROWN` | `MountMortarB1FKiyoScript` | `-1` | + +**Scripts of interest** + +`MountMortarB1FKiyoScript` (`46:5eec`) - the whole point of this section. Note +it is `OBJECTTYPE_SCRIPT`, **not** `OBJECTTYPE_TRAINER`: Kiyo has no `trainer` +header, no sight range, and never walks up to you. He must be talked to. + +``` +faceplayer +opentext +checkevent EVENT_GOT_TYROGUE_FROM_KIYO ; iftrue -> .GotTyrogue (46:5f27) +checkevent EVENT_BEAT_BLACKBELT_KIYO ; iftrue -> .BeatKiyo (46:5f0d) +writetext MountMortarB1FKiyoIntroText +waitbutton +closetext +winlosstext MountMortarB1FKiyoWinText, 0 +loadtrainer BLACKBELT_T, KIYO +startbattle +reloadmapafterbattle +setevent EVENT_BEAT_BLACKBELT_KIYO +opentext +.BeatKiyo: +writetext MountMortarB1FTyrogueRewardText +promptbutton +waitsfx +readvar VAR_PARTYCOUNT +ifequal PARTY_LENGTH, .NoRoom ; 46:5f2d +writetext MountMortarB1FReceiveMonText +playsound SFX_CAUGHT_MON +waitsfx +givepoke TYROGUE, 10 +setevent EVENT_GOT_TYROGUE_FROM_KIYO +.GotTyrogue: +writetext MountMortarB1FKiyoGotTyrogueText +waitbutton +closetext +end +``` + +Bot-relevant control flow: + +- The battle is unconditional on first talk (no `checkevent` guard before + `loadtrainer`), and the win-flag write is **after** `startbattle`, so a + blackout means the whole script re-runs from the top. +- `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH` is the "you have no room" + branch. `PARTY_LENGTH` is 6. **If the party is full, `EVENT_BEAT_BLACKBELT_KIYO` + is already set but `EVENT_GOT_TYROGUE_FROM_KIYO` is not**, so re-talking + re-enters at `.BeatKiyo` and offers the Tyrogue again. This is why the + walkthrough tells you to free a slot in advance - but it is recoverable, not + missable. +- `givepoke TYROGUE, 10` - level 10, no held item, no nickname prompt in this + form of the opcode. + +`MountMortarB1FBoulder` - `jumpstd StrengthBoulderScript`, same as 1F Inside. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BLACKBELT_KIYO` | `constants/event_flags.asm:684` | set by `MountMortarB1FKiyoScript` after `startbattle` | Kiyo will not fight again | +| `EVENT_GOT_TYROGUE_FROM_KIYO` | `constants/event_flags.asm:106` | set by `MountMortarB1FKiyoScript` after `givepoke` | the Tyrogue is banked; the script becomes pure flavour | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `KIYO` (`constants/trainer_constants.asm:500`) | `BLACKBELT_T` | 6th entry of `BlackbeltGroup` | `data/trainers/parties.asm:2455` `; BLACKBELT_T (6)` `db "KIYO@", TRAINERTYPE_NORMAL` / `db 34, HITMONLEE` / `db 34, HITMONCHAN` | `MountMortarB1FKiyoScript` | none - no `trainer` header, so no phone number, no rematch | + +`TRAINERTYPE_NORMAL` means **no custom moves and no held items**: both mons use +their level-34 learnset defaults. The walkthrough's "816G" prize and the +1012/1020 EXP figures were not verified against `data/trainers/attributes.asm` - +see section 6. + +**Wild encounters** + +`data/wild/johto_grass.asm:770` `def_grass_wildmons MOUNT_MORTAR_B1F`, rate +`6 percent` morn/day/nite, all three lists identical: + +| slot | lvl | species | +|---|---|---| +| 1 | 15 | ZUBAT | +| 2 | 17 | ZUBAT | +| 3 | 16 | RATTATA | +| 4 | 16 | MACHOP | +| 5 | 16 | GEODUDE | +| 6 | 16 | RATICATE | +| 7 | 16 | RATICATE | + +`data/wild/johto_water.asm:72` `def_water_wildmons MOUNT_MORTAR_B1F`, rate +`2 percent`: 20 GOLDEEN / 15 GOLDEEN / 20 SEAKING. + +--- + +### MAP_DARK_CAVE_BLACKTHORN_ENTRANCE + +- Script: `maps/DarkCaveBlackthornEntrance.asm` +- Blocks: `maps/DarkCaveBlackthornEntrance.blk` +- Header: `data/maps/maps.asm:149` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_DARK_CAVE`, `MUSIC_DARK_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:136` `map_const DARK_CAVE_BLACKTHORN_ENTRANCE, 15, 18` + (x 0..29, y 0..35), map id 71, group 3 +- Attributes: `data/maps/attributes.asm:468`, border block `$09`, **no connections** +- Map events block: `DarkCaveBlackthornEntrance_MapEvents` = `47:4489` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 3 | `ROUTE_45` | 1 | +| 2 | 3 | 25 | `DARK_CAVE_VIOLET_ENTRANCE` | 2 | + +**Coord events** - empty. + +**BG events** - `def_bg_events` is **empty**. No signs, no hidden items. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DARKCAVEBLACKTHORNENTRANCE_PHARMACIST` | `SPRITE_PHARMACIST` | 7 | 3 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `DarkCaveBlackthornEntrancePharmacistScript` | `-1` | +| `DARKCAVEBLACKTHORNENTRANCE_POKE_BALL1` | `SPRITE_POKE_BALL` | 21 | 24 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `DarkCaveBlackthornEntranceRevive` (`itemball REVIVE`) | `EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_REVIVE` | +| `DARKCAVEBLACKTHORNENTRANCE_POKE_BALL2` | `SPRITE_POKE_BALL` | 7 | 22 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `DarkCaveBlackthornEntranceTMSnore` (`itemball TM_SNORE`) | `EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_TM_SNORE` | + +`TM_SNORE` is **TM13** by the `add_tm` ordering in +`constants/item_constants.asm:233`, which matches the walkthrough's "TM 13". + +**Scripts of interest** + +`DarkCaveBlackthornEntrancePharmacistScript` (`47:436c`): + +``` +faceplayer +opentext +checkevent EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE ; iftrue -> .GotBlackglasses (47:4381) +writetext DarkCaveBlackthornEntrancePharmacistText1 +promptbutton +verbosegiveitem BLACKGLASSES +iffalse .PackFull ; 47:4385 +setevent EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE +.GotBlackglasses: +writetext DarkCaveBlackthornEntrancePharmacistText2 +waitbutton +.PackFull: +closetext +end +``` + +Bot-relevant: `verbosegiveitem` returns 0 when the pack is full, and the +`setevent` is **after** the give, so a full item pocket leaves the flag clear +and the NPC re-offers. Not missable. The NPC spins +(`SPRITEMOVEDATA_SPINRANDOM_SLOW`), so the bot must stand adjacent and press A; +`faceplayer` handles the turn. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE` | `constants/event_flags.asm:123` | `DarkCaveBlackthornEntrancePharmacistScript` | BLACKGLASSES banked; NPC becomes flavour text | +| `EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_REVIVE` | `constants/event_flags.asm:1090` | object visibility mask | set = ball already taken | +| `EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_TM_SNORE` | `constants/event_flags.asm:1091` | object visibility mask | set = TM13 already taken | + +**Wild encounters** + +`data/wild/johto_grass.asm:1545` `def_grass_wildmons DARK_CAVE_BLACKTHORN_ENTRANCE`, +rate `4 percent` morn/day/nite, all three lists identical - this is the +walkthrough's Wobbuffet spot: + +| slot | lvl | species | +|---|---|---| +| 1 | 23 | GEODUDE | +| 2 | 23 | ZUBAT | +| 3 | 25 | GRAVELER | +| 4 | 20 | WOBBUFFET | +| 5 | 25 | WOBBUFFET | +| 6 | 23 | GOLBAT | +| 7 | 23 | GOLBAT | + +`data/wild/johto_water.asm:114` `def_water_wildmons DARK_CAVE_BLACKTHORN_ENTRANCE`, +rate `2 percent`: 15 MAGIKARP / 10 MAGIKARP / 5 MAGIKARP. + +No `treemon_map` row for this map - **rock smash here yields nothing**, and +there are no smashable rock objects on it either. + +--- + +### MAP_DARK_CAVE_VIOLET_ENTRANCE + +- Script: `maps/DarkCaveVioletEntrance.asm` +- Blocks: `maps/DarkCaveVioletEntrance.blk` +- Header: `data/maps/maps.asm:148` -> `TILESET_DARK_CAVE`, `CAVE`, + `LANDMARK_DARK_CAVE`, `MUSIC_DARK_CAVE`, phone `TRUE`, `PALETTE_DARK`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:135` `map_const DARK_CAVE_VIOLET_ENTRANCE, 20, 18` + (x 0..39, y 0..35), map id 70, group 3 +- Attributes: `data/maps/attributes.asm:467`, border block `$09`, **no connections** +- Map events block: `DarkCaveVioletEntrance_MapEvents` = `47:42f5` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 15 | `ROUTE_31` | 3 | +| 2 | 17 | 1 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | 2 | +| 3 | 35 | 33 | `ROUTE_46` | 3 | + +**Coord events** - empty. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 26 | 3 | `BGEVENT_ITEM` | `DarkCaveVioletEntranceHiddenElixer` -> `hiddenitem ELIXER, EVENT_DARK_CAVE_VIOLET_ENTRANCE_HIDDEN_ELIXER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DARKCAVEVIOLETENTRANCE_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 8 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `DarkCaveVioletEntrancePotion` (`itemball POTION`) | `EVENT_DARK_CAVE_VIOLET_ENTRANCE_POTION` | +| `DARKCAVEVIOLETENTRANCE_ROCK1` | `SPRITE_ROCK` | 16 | 14 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `DarkCaveVioletEntranceRock` (`jumpstd SmashRockScript`) | `-1` | +| `DARKCAVEVIOLETENTRANCE_ROCK2` | `SPRITE_ROCK` | 27 | 6 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `DarkCaveVioletEntranceRock` | `-1` | +| `DARKCAVEVIOLETENTRANCE_ROCK3` | `SPRITE_ROCK` | 7 | 14 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `DarkCaveVioletEntranceRock` | `-1` | +| `DARKCAVEVIOLETENTRANCE_ROCK4` | `SPRITE_ROCK` | 36 | 31 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT` | `DarkCaveVioletEntranceRock` | `-1` | +| `DARKCAVEVIOLETENTRANCE_POKE_BALL2` | `SPRITE_POKE_BALL` | 36 | 22 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `DarkCaveVioletEntranceFullHeal` (`itemball FULL_HEAL`) | `EVENT_DARK_CAVE_VIOLET_ENTRANCE_FULL_HEAL` | +| `DARKCAVEVIOLETENTRANCE_POKE_BALL3` | `SPRITE_POKE_BALL` | 35 | 9 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `DarkCaveVioletEntranceHyperPotion` (`itemball HYPER_POTION`) | `EVENT_DARK_CAVE_VIOLET_ENTRANCE_HYPER_POTION` | + +Note the four rocks all share one script label; the smash `disappear LAST_TALKED` +in `RockSmashScript` acts on `hLastTalked`, so they are independent objects +despite the shared pointer. Rocks have **no event flag** (`-1`) - they come back +on every map reload. + +**Scripts of interest** + +`DarkCaveVioletEntranceRock` - `jumpstd SmashRockScript` +(`engine/events/std_scripts.asm:199` -> `farsjump AskRockSmashScript`, `03:4f60`). +`AskRockSmashScript` calls `HasRockSmash` (`03:4f7f`), which is **inverted** +(`wScriptVar = 1` when the party does NOT know ROCK SMASH); on 0 it prompts +yes/no and falls into `RockSmashScript` (`03:4f35`): +`playsound SFX_STRENGTH` / `earthquake 84` / `applymovementlasttalked MovementData_RockSmash` +/ `disappear LAST_TALKED` / `callasm RockMonEncounter` / `randomwildmon` / +`startbattle`. + +**Wild encounters** + +`data/wild/johto_grass.asm:1517` `def_grass_wildmons DARK_CAVE_VIOLET_ENTRANCE`, +rate `4 percent` morn/day/nite, all three lists identical: + +| slot | lvl | species | +|---|---|---| +| 1 | 3 | GEODUDE | +| 2 | 2 | ZUBAT | +| 3 | 2 | GEODUDE | +| 4 | 4 | GEODUDE | +| 5 | 3 | ZUBAT | +| 6 | 4 | ZUBAT | +| 7 | 4 | DUNSPARCE | + +`data/wild/johto_water.asm:107` `def_water_wildmons DARK_CAVE_VIOLET_ENTRANCE`, +rate `2 percent`: 15 MAGIKARP / 10 MAGIKARP / 5 MAGIKARP. + +**Rock smash encounters** - this map IS in `data/wild/treemon_maps.asm:46` +`RockMonMaps`: `treemon_map DARK_CAVE_VIOLET_ENTRANCE, TREEMON_SET_ROCK`. +`RockMonEncounter` (`2e:63a1`) rolls `RandomRange 10 < 4` (a 40% chance) and then +`SelectTreeMon` over `data/wild/treemons.asm:91` `TreeMonSet_Rock`: +90% KRABBY lvl 15, 10% SHUCKLE lvl 15. **This is the only Shuckle source in +Johto reachable in this section**, and the walkthrough does not mention it. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Darkness on all six maps | `engine/tilesets/timeofday_pals.asm` `ReplaceTimeOfDayPals` (`23:43e9`) `.NeedsFlash` -> `DARKNESS_PALSET`, because `data/maps/maps.asm` lines 127-130 / 148-149 all say `PALETTE_DARK` | FLASH cast, i.e. `STATUSFLAGS_FLASH_F` set in `wStatusFlags` | `engine/events/overworld.asm` `FlashFunction.CheckUseFlash` (`03:48f1`): `ENGINE_ZEPHYRBADGE` **and** `wTimeOfDayPalset == DARKNESS_PALSET`. The bit is cleared by `home/flag.asm` `ResetFlashIfOutOfCave` (`00:2f1d`) on any `ROUTE`/`TOWN` map, so **re-cast after every exit and every Fly** | +| Mt. Mortar middle entrance (`ROUTE_42` warp 4 at (28,9)) | terrain: the tile is across water on Route 42 | SURF | `engine/events/overworld.asm` `SurfFunction` (`03:493b`), `.TrySurf` arm / `TrySurfOW` (`03:4a06`): `ENGINE_FOGBADGE` + a party member knowing SURF | +| The lake and the waterfall between 1F Outside and 2F | terrain on `MountMortar1FOutside.blk`; the only route to warp 4 at (17,5) | SURF, then WATERFALL | `WaterfallFunction.TryWaterfall` (`03:4af6`) / `TryWaterfallOW` (`03:4b5f`): `ENGINE_RISINGBADGE`, then `CheckMapCanWaterfall` - **player must be facing UP and the tile ABOVE (`wTileUp`) must be a waterfall tile** | +| `MOUNTMORTAR1FINSIDE_BOULDER` at (21,43) | `MountMortar1FBoulder` -> `jumpstd StrengthBoulderScript` -> `AskStrengthScript` (`03:4d4e`) | STRENGTH toggled on | `TryStrengthOW` (`03:4d7b`): party knows STRENGTH **and** `ENGINE_PLAINBADGE` **and** `BIKEFLAGS_STRENGTH_ACTIVE_F` set. `StrengthFunction.TryStrength` (`03:4cf1`) only checks the badge. Note `ResetBikeFlags` clears the active bit on **every map load** - Strength must be re-activated per floor | +| `MOUNTMORTARB1F_BOULDER` at (9,10) | `MountMortarB1FBoulder`, same std script | STRENGTH | as above. This is the walkthrough's "move the rock with HM Strength to get out"; the alternative is an Escape Rope | +| Kiyo's Tyrogue | `MountMortarB1FKiyoScript` `readvar VAR_PARTYCOUNT` / `ifequal PARTY_LENGTH, .NoRoom` | 5 or fewer party members | free a slot and re-talk; `EVENT_BEAT_BLACKBELT_KIYO` stays set so the battle is not repeated | +| Dark Cave water crossings (both entrances) | terrain | SURF | `ENGINE_FOGBADGE` as above | +| `DARKCAVEVIOLETENTRANCE_ROCK1..4` | `DarkCaveVioletEntranceRock` -> `jumpstd SmashRockScript` -> `AskRockSmashScript` (`03:4f60`) | a party member knowing ROCK SMASH (TM08) | `HasRockSmash` (`03:4f7f`) - **no badge check at all** for Rock Smash | +| Fly between Mahogany and Blackthorn | `engine/events/overworld.asm:545` (the `.TryFly` arm of the Fly jumptable) `ld de, ENGINE_STORMBADGE` | `ENGINE_STORMBADGE` + the destination's `ENGINE_FLYPOINT_*` (`constants/engine_flags.asm:87`, `:89`) | both flypoints are set by visiting the towns in earlier sections | + +Badge constants used above: `ENGINE_ZEPHYRBADGE` (`constants/engine_flags.asm:38`), +`ENGINE_PLAINBADGE` (`:40`), `ENGINE_FOGBADGE` (`:41`), `ENGINE_STORMBADGE` +(`:43`), `ENGINE_RISINGBADGE` (`:45`). All are checked through +`engine/events/overworld.asm` `CheckBadge` (line 50), which prints +"Badge required" and returns carry. + +**Nothing in this section gates anything else.** Both Mt. Mortar and Dark Cave +are entirely optional; a bot that skips them loses only items, the Tyrogue and +the Blackglasses. + +--- + +## 4. Bot checklist + +Preconditions for the whole section: `ENGINE_ZEPHYRBADGE`, `ENGINE_FOGBADGE`, +`ENGINE_PLAINBADGE`, `ENGINE_RISINGBADGE`, `ENGINE_STORMBADGE`; party members +knowing FLASH, SURF, STRENGTH, WATERFALL (and ROCK SMASH for step 26); party +count <= 5 before step 20. + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `MAHOGANY_TOWN` | spawn (15,14) | Fly in, heal, buy MAX_REPEL / ESCAPE_ROPE | `ENGINE_FLYPOINT_MAHOGANY` | party healthy, bag stocked | +| 2 | `MAHOGANY_TOWN` | west map connection | walk west | - | on `ROUTE_42` | +| 3 | `ROUTE_42` | warp 3 at (10,5) | step onto warp (west entrance) | - | on `MOUNT_MORTAR_1F_OUTSIDE` at (3,33) | +| 4 | `MOUNT_MORTAR_1F_OUTSIDE` | - | use FLASH from the PACK/party menu | `ENGINE_ZEPHYRBADGE`, map palset is `DARKNESS_PALSET` | `STATUSFLAGS_FLASH_F` set | +| 5 | `MOUNT_MORTAR_1F_OUTSIDE` | - | use MAX_REPEL | bag has one | encounters suppressed | +| 6 | `MOUNT_MORTAR_1F_OUTSIDE` | object at (13,15) | walk onto / A-press the Poke Ball | `EVENT_MOUNT_MORTAR_1F_OUTSIDE_ETHER` clear | ETHER in bag, flag set | +| 7 | `MOUNT_MORTAR_1F_OUTSIDE` | object at (31,18) | pick up Poke Ball | `EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE` clear | GUARD_SPEC in bag, flag set | +| 8 | `MOUNT_MORTAR_1F_OUTSIDE` | bg event (25,22) | face the tile, press A | `EVENT_MOUNT_MORTAR_1F_OUTSIDE_HIDDEN_HYPER_POTION` clear | HYPER_POTION in bag, flag set | +| 9 | `MOUNT_MORTAR_1F_OUTSIDE` | warp 5 at (11,21) | step on warp | - | on `MOUNT_MORTAR_1F_INSIDE` at (11,47) | +| 10 | `MOUNT_MORTAR_1F_INSIDE` | - | re-cast FLASH only if it was lost (it is not - cave to cave keeps the bit) | - | - | +| 11 | `MOUNT_MORTAR_1F_INSIDE` | object at (33,22) | pick up Poke Ball | `EVENT_MOUNT_MORTAR_1F_INSIDE_ESCAPE_ROPE` clear | ESCAPE_ROPE in bag | +| 12 | `MOUNT_MORTAR_1F_INSIDE` | object at (12,21) | pick up Poke Ball | `EVENT_MOUNT_MORTAR_1F_INSIDE_HYPER_POTION` clear | HYPER_POTION in bag | +| 13 | `MOUNT_MORTAR_1F_INSIDE` | boulder at (21,43) | activate STRENGTH from the party menu, then push | `ENGINE_PLAINBADGE`, party knows STRENGTH | `BIKEFLAGS_STRENGTH_ACTIVE_F` set, boulder movable | +| 14 | `MOUNT_MORTAR_1F_INSIDE` | warp 1 at (11,47) | back out | - | on `MOUNT_MORTAR_1F_OUTSIDE` at (11,21) | +| 15 | `MOUNT_MORTAR_1F_OUTSIDE` | the lake | walk into water / A-press facing water -> SURF | `ENGINE_FOGBADGE` | `wPlayerState = PLAYER_SURF` | +| 16 | `MOUNT_MORTAR_1F_OUTSIDE` | the waterfall tile | face **UP** at the waterfall, press A -> WATERFALL | `ENGINE_RISINGBADGE`, `CheckMapCanWaterfall` passes | climbed | +| 17 | `MOUNT_MORTAR_1F_OUTSIDE` | warp 4 at (17,5) | step on warp | - | on `MOUNT_MORTAR_2F_INSIDE` at (17,33) | +| 18 | `MOUNT_MORTAR_2F_INSIDE` | objects at (31,23), (2,24), (19,17), (14,5), (8,9), (28,5) | pick up all six Poke Balls | matching `EVENT_MOUNT_MORTAR_2F_INSIDE_*` clear | MAX_POTION, RARE_CANDY, TM_DEFENSE_CURL (TM40), DRAGON_SCALE, ELIXER, ESCAPE_ROPE | +| 19 | `MOUNT_MORTAR_2F_INSIDE` | bg event (19,6) | face, press A | `EVENT_MOUNT_MORTAR_2F_INSIDE_HIDDEN_FULL_RESTORE` clear | FULL_RESTORE in bag | +| 20 | `MOUNT_MORTAR_2F_INSIDE` | warp 2 at (3,5) | step on warp | - | on `MOUNT_MORTAR_1F_INSIDE` at (9,9) | +| 21 | `MOUNT_MORTAR_1F_INSIDE` | object at (16,10) | pick up Poke Ball | `EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_REVIVE` clear | MAX_REVIVE in bag | +| 22 | `MOUNT_MORTAR_1F_INSIDE` | bg event (31,9) | face, press A | `EVENT_MOUNT_MORTAR_1F_INSIDE_HIDDEN_MAX_REPEL` clear | MAX_REPEL in bag | +| 23 | `MOUNT_MORTAR_1F_INSIDE` | warp 5 at (3,19) | step on warp | - | on `MOUNT_MORTAR_B1F` at (3,3) | +| 24 | `MOUNT_MORTAR_B1F` | bg (4,6), objects (4,16) and (31,17) | pick up hidden MAX_REVIVE, FULL_HEAL, HYPER_POTION | matching `EVENT_MOUNT_MORTAR_B1F_*` clear | three items | +| 25 | `MOUNT_MORTAR_B1F` | `MOUNTMORTARB1F_KIYO` at (13,4) | stand adjacent, press A | party count <= 5, `EVENT_GOT_TYROGUE_FROM_KIYO` clear | battle `BLACKBELT_T`/`KIYO` (L34 HITMONLEE, L34 HITMONCHAN) -> `EVENT_BEAT_BLACKBELT_KIYO` | +| 26 | `MOUNT_MORTAR_B1F` | Kiyo (same object, script continues) | press A through the reward text | party count <= 5 | `givepoke TYROGUE, 10`, `EVENT_GOT_TYROGUE_FROM_KIYO` set | +| 27 | `MOUNT_MORTAR_B1F` | - | use ESCAPE_ROPE (or STRENGTH the boulder at (9,10) then warp 2 at (19,29)) | ESCAPE_ROPE in bag | out of the cave | +| 28 | anywhere outdoors | `SPAWN_BLACKTHORN` | Fly to Blackthorn, heal | `ENGINE_STORMBADGE`, `ENGINE_FLYPOINT_BLACKTHORN` | at Blackthorn spawn (21,30); **`STATUSFLAGS_FLASH_F` is now cleared** | +| 29 | `BLACKTHORN_CITY` | south map connection | walk south | - | on `ROUTE_45` | +| 30 | `ROUTE_45` | warp 1 at (2,5) | step on warp | - | on `DARK_CAVE_BLACKTHORN_ENTRANCE` at (23,3) | +| 31 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | - | use FLASH again | `ENGINE_ZEPHYRBADGE` | `STATUSFLAGS_FLASH_F` set | +| 32 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | water in the middle of the map | SURF | `ENGINE_FOGBADGE` | crossing possible | +| 33 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | `DARKCAVEBLACKTHORNENTRANCE_PHARMACIST` at (7,3) | stand adjacent, press A | `EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE` clear, item pocket has room | BLACKGLASSES, flag set | +| 34 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | objects at (7,22) and (21,24) | pick up both Poke Balls | matching events clear | TM_SNORE (TM13), REVIVE | +| 35 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | warp 2 at (3,25) | step on warp | - | on `DARK_CAVE_VIOLET_ENTRANCE` at (17,1) | +| 36 | `DARK_CAVE_VIOLET_ENTRANCE` | water below (17,1) | SURF down | `ENGINE_FOGBADGE` | - | +| 37 | `DARK_CAVE_VIOLET_ENTRANCE` | rock at (16,14) or (7,14) | face rock, press A, answer yes | party knows ROCK_SMASH | rock gone; 40% chance of a KRABBY/SHUCKLE L15 battle | +| 38 | `DARK_CAVE_VIOLET_ENTRANCE` | object at (6,8) | pick up Poke Ball | `EVENT_DARK_CAVE_VIOLET_ENTRANCE_POTION` clear | POTION in bag | +| 39 | `DARK_CAVE_VIOLET_ENTRANCE` | warp 1 at (3,15) | step on warp | - | on `ROUTE_31` at (34,5); **FLASH bit cleared** | +| 40 | `ROUTE_31` | - | Fly back to Blackthorn, heal | - | section complete | + +Optional extras this route skips, with their coordinates if a completionist bot +wants them: `DARK_CAVE_VIOLET_ENTRANCE` objects at (36,22) FULL_HEAL and (35,9) +HYPER_POTION plus the hidden ELIXER bg at (26,3) - all three sit on the +north-east lobe of the map that the earlier Route 31 section already reaches. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers / dimensions for all six maps | `tools/rom_manifest_gold.json` (e.g. `MOUNT_MORTAR_1F_OUTSIDE` 20x18 group 3 map 49, `DARK_CAVE_BLACKTHORN_ENTRANCE` 15x18 group 3 map 71), read by `src/import/RomExtractorGen2.lua` | implemented (extracted from ROM, no hand-ported map file needed) | +| Warp / coord / bg / object event tables | `src/import/RomExtractorGen2.lua` (`warps`, `coordEvents`, `bgEvents`, `objects` around lines 787-862) -> `src/world/gen2/Map.lua` | implemented | +| `PALETTE_DARK` -> `DARKNESS_PALSET` and the FLASH override | `src/world/gen2/Palettes.lua`, asserted by `tests/gen2_palettes_test.lua:118-129` (`dark cave` / `dark cave with flash`) | implemented | +| `ResetFlashIfOutOfCave` (FLASH dies on ROUTE/TOWN) | `src/world/gen2/World.lua:5622-5625` | implemented | +| FLASH / SURF / WATERFALL / STRENGTH badge gates | `src/world/gen2/FieldMoves.lua:105-110` (`FLASH=ZEPHYR`, `SURF=FOG`, `STRENGTH=PLAIN`, `WATERFALL=RISING`), `FieldMoves.flashFromMenu` / `surfFromMenu` / `waterfallFromMenu` / `strengthFromMenu` | implemented | +| `CheckMapCanWaterfall` (facing UP, `wTileUp`) and the climb loop | `src/world/gen2/FieldMoves.lua:253-266`, `src/world/gen2/World.lua:4030-4046` `runWaterfall` / `waterfallStep`, `World:tryWaterfallOW` (4217) | implemented | +| Strength boulder push (`StrengthBoulderScript` -> `AskStrengthScript`) | `src/world/gen2/World.lua:5268-5274` (the boulder arm of `World:interact`), `World:tryStrengthOW`, driver `tests/drivers/gold_icepath_boulder.lua` | implemented | +| `BIKEFLAGS_STRENGTH_ACTIVE_F` cleared on map load | `src/world/gen2/World.lua:5611-5613` | implemented | +| Hidden items (`BGEVENT_ITEM` / `hiddenitem`) | `src/world/gen2/HiddenItems.lua`, wired at `src/world/gen2/World.lua:5290-5296` | implemented | +| `verbosegiveitem BLACKGLASSES` (the Dark Cave pharmacist) | `src/script/gen2/Vm.lua:490-510`, hook `World.lua:831` `giveItem` | implemented | +| `givepoke TYROGUE, 10` (Kiyo's reward) | `src/script/gen2/Vm.lua:439-445`, hook `World.lua:812` `givePoke` | implemented | +| `readvar VAR_PARTYCOUNT` (Kiyo's "no room" branch) | `src/world/gen2/World.lua:112` `VAR_PARTYCOUNT = 0x01`, read at `World.lua:1225` | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `reloadmapafterbattle` | `src/script/gen2/Vm.lua:806`, `:817`, `:886`, `:918`; `src/world/gen2/Trainers.lua`; driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| Tyrogue's three-way stat evolution (what the reward mon becomes) | `src/core/gen2/Evolution.lua:71`, asserted by `tests/gen2_evolution_test.lua:347-357` | implemented | +| Repel / Max Repel step counter | `src/world/gen2/World.lua:3382` `World:useRepel`, `src/ui/gen2/PackMenu.lua:199-206` | implemented | +| **Picking up a Poke Ball (`OBJECTTYPE_ITEMBALL`)** | extracted into `obj.itemball` by `src/import/RomExtractorGen2.lua:2968-2969`, but `World:interact` (`src/world/gen2/World.lua:5256-5310`) has arms for trainer, boulder, `scriptKey`, `BGEVENT_READ` and `BGEVENT_ITEM` **and no itemball arm**; `CallAsm.lua:551` stubs `TryReceiveItem` out | **missing** - all 15 Poke Balls in this section are unobtainable in the port today | +| **ROCK SMASH as a field move** | `src/script/gen2/CallAsm.lua:346-350` implements `HasRockSmash`, but line 526 stubs `RockMonEncounter` with the note "ROCK SMASH has no field-move path yet; RockMonMaps is unported"; `World:interact`'s tile-event chain (`World.lua:5301-5308`) lists cut / whirlpool / waterfall / headbutt / surf and **not** rock smash, and nothing in `src/` references `SPRITEMOVEDATA_SMASHABLE_ROCK` | **missing** - the four Dark Cave Violet rocks cannot be broken, and the Krabby/Shuckle table is unreachable | +| **ESCAPE_ROPE / DIG as a field item** | `src/world/gen2/World.lua:3293-3302` `World:useFieldItem` handles ITEMFINDER, BICYCLE, SACRED_ASH, repels, trophy boxes and rods only. (Gen 1's path exists at `src/ui/BagMenu.lua:231-240`, but that is the Red/Blue engine, not the Gen 2 one.) | **missing** - the walkthrough's "use one of the Escape Ropes to get out" has no port path; the Strength-boulder exit at B1F (9,10) does work | +| Fly to Mahogany / Blackthorn | `src/world/gen2/FieldMoves.lua:394-440` `hasVisitedSpawn` / `flyPoints`, `flyFromMenu` (504) | implemented | +| Wild encounter tables (grass + water + fishing) for these maps | extracted; `src/world/gen2/FieldMoves.lua:162-177` `canEncounterWildMon` / `encounterTable` | implemented (not separately verified against these six maps) | + +No driver in `tests/drivers/gold_*.lua` visits Mt. Mortar or Dark Cave. + +--- + +## 6. Unresolved / verify by hand + +1. **The walkthrough's Mt. Mortar item list is the HGSS list, not the GS list.** + It names Carbos, Escape Rope x2, Full Restore, Hyper Potion x2, Max Ether, + Max Potion, Max Revive, PP Up, Rare Candy, Dragon Scale and "TM40 Aerial + Ace". What is actually in `maps/MountMortar*.asm` is: ETHER, GUARD_SPEC, + hidden HYPER_POTION (1F Outside); ESCAPE_ROPE, MAX_REVIVE, HYPER_POTION, + hidden MAX_REPEL (1F Inside); MAX_POTION, RARE_CANDY, TM_DEFENSE_CURL, + DRAGON_SCALE, ELIXER, ESCAPE_ROPE, hidden FULL_RESTORE (2F Inside); + HYPER_POTION, FULL_HEAL, hidden MAX_REVIVE (B1F). **No CARBOS, no PP_UP, no + MAX_ETHER, no IRON anywhere in Mt. Mortar.** TM40 is the right *number* but + the move is DEFENSE_CURL (`constants/item_constants.asm:261`); Aerial Ace is + a Gen 3 move that does not exist in this disassembly. +2. **"grab the free Iron on the cliff" (1F Inside, after coming down from 2F).** + There is no IRON object on any Mt. Mortar map. The nearest items to warp 6 at + (9,9) are the MAX_REVIVE ball at (16,10) and the hidden MAX_REPEL bg event at + (31,9). Treat "Iron" as an HGSS-only pickup. +3. **"surf clockwise around and reach a Hyper Potion and a Max Ether."** There is + no MAX_ETHER in Mt. Mortar. If the intended floor is B1F (the ladder from the + central entrance) the pair is HYPER_POTION (31,17) + FULL_HEAL (4,16); if 2F, + the closest analogue is ELIXER (8,9). Which one the author meant could not be + determined from the asm. +4. **Kiyo's prize money and EXP yields.** The walkthrough gives 816G and + 1012/1020 EXP. `data/trainers/parties.asm` only carries the class, name, type + byte and party; the money formula lives in the battle engine's + `data/trainers/attributes.asm` base-money table times level, which was not + opened for this document. The party itself is verified: L34 HITMONLEE, L34 + HITMONCHAN, `TRAINERTYPE_NORMAL` (default moves, no held items). +5. **"a giant room where you can zig zag around ... and get two items, to include + a Hyper Potion and Escape Rope."** `MOUNT_MORTAR_1F_INSIDE` actually holds + *three* balls (ESCAPE_ROPE (33,22), MAX_REVIVE (16,10), HYPER_POTION (12,21)) + plus a hidden MAX_REPEL - but MAX_REVIVE and the hidden item sit on the + section of the map only reachable from 2F, which is presumably why the + walkthrough counts two. +6. **"The west entrance and east entrance lead to each other."** All three Route + 42 doors land on the same map, `MOUNT_MORTAR_1F_OUTSIDE`; the west/east link + runs through `MOUNT_MORTAR_1F_INSIDE` via warp pairs 5/6 (at y=21) and 8/9 + (at y=13). The blk files were not decoded, so the exact walkable path between + any two of these warps is not verified here - only the warp graph is. +7. **Dark Cave Pokemon list.** The walkthrough lists Zubat, Golbat, Geodude, + Graveler, Magikarp and Wobbuffet - that is the `DARK_CAVE_BLACKTHORN_ENTRANCE` + table exactly. It omits DUNSPARCE, which is slot 7 of + `DARK_CAVE_VIOLET_ENTRANCE` and is on the map the route exits through. +8. **Dark Cave item list.** The walkthrough lists Blackglasses, Full Heal, Hyper + Potion, Revive and TM13. The Full Heal (36,22) and Hyper Potion (35,9) are on + `DARK_CAVE_VIOLET_ENTRANCE`'s north-east lobe, which this route does not pass + through; the hidden ELIXER at (26,3) is not mentioned at all. The POTION at + (6,8) is named in the prose but missing from the list. +9. **The Shuckle/Krabby rock-smash table** (`data/wild/treemon_maps.asm:46`, + `data/wild/treemons.asm:91`) is real and reachable at + `DARK_CAVE_VIOLET_ENTRANCE`, but the walkthrough never mentions it. Worth + flagging to a bot author as a cheap Shuckle source. +10. **`MOUNT_MORTAR_1F_OUTSIDE` object 2 flag naming.** The item is + `GUARD_SPEC` but the event flag is `EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE` + (`constants/event_flags.asm:1053`). Verbatim from the disassembly; not a + transcription error here, but it will look like one to anyone grepping by + item name. +11. **Mahogany Town and Blackthorn City interiors** (Poke Center, Mart, gym) were + not opened for this document - only their `data/maps/maps.asm` header rows + and `data/maps/spawn_points.asm` entries. Those maps belong to neighbouring + sections. diff --git a/docs/gold-walkthrough/asm-walk/section-16-routes-45-46-26-and-27.md b/docs/gold-walkthrough/asm-walk/section-16-routes-45-46-26-and-27.md new file mode 100644 index 00000000..8af16be0 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-16-routes-45-46-26-and-27.md @@ -0,0 +1,1078 @@ +# Section 16 - Routes 45, 46, 26 and 27 + +Source: `../section-16-routes-45-46-26-and-27.txt` +Maps covered: `MAP_ROUTE_45`, `MAP_ROUTE_46`, `MAP_ROUTE_27`, `MAP_TOHJO_FALLS`, +`MAP_ROUTE_27_SANDSTORM_HOUSE`, `MAP_ROUTE_26`, `MAP_ROUTE_26_HEAL_HOUSE`, +`MAP_DAY_OF_WEEK_SIBLINGS_HOUSE`, `MAP_VICTORY_ROAD_GATE` (gate only) +Badges / key milestones in this section: no badge. The milestones are the +MASTER BALL and EVERSTONE from Prof. Elm, TM37 SANDSTORM, TM22 SOLARBEAM, the +Tohjo Falls MOON STONE, and the eight-badge check at the Victory Road gate that +opens the Pokemon League. + +Coordinate convention reminder: `warp_event` / `bg_event` / `object_event` rows +are in map **cells** (16 px walk grid). `map_const NAME, W, H` in +`constants/map_constants.asm` is in **blocks** (32 px), so a map is `W*2` by +`H*2` cells. Route 45 is `10, 45` blocks = 20 x 90 cells, which is why object +rows there run to y = 82. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_45` | `maps/Route45.asm` | south connection from `BLACKTHORN_CITY` (`data/maps/attributes.asm`: `connection north, BlackthornCity, BLACKTHORN_CITY, 0`) | west connection to `ROUTE_46` (offset 36); optional warp 1 to `DARK_CAVE_BLACKTHORN_ENTRANCE` | Descend the mountain road, three forks, six trainers, four item balls | +| 2 | `MAP_ROUTE_46` | `maps/Route46.asm` | east connection from `ROUTE_45` (offset -36) | south connection to `ROUTE_29` (offset -10) or warps 1/2 to `ROUTE_29_ROUTE_46_GATE`; warp 3 to `DARK_CAVE_VIOLET_ENTRANCE` | Camper Ted, Picnicker Erin, Hiker Bailey, Dire Hit, two fruit trees | +| 3 | `MAP_NEW_BARK_TOWN` | `maps/NewBarkTown.asm` | FLY | warp 1 to `ELMS_LAB`, warp 2 to `PLAYERS_HOUSE_1F`; east connection to `ROUTE_27` | Elm hands the MASTER BALL (needs RISINGBADGE) and the EVERSTONE (needs Togepi shown). Map tables for New Bark Town belong to section 00; only the two Elm scripts are transcribed below. | +| 4 | `MAP_ROUTE_27` | `maps/Route27.asm` | west connection from `NEW_BARK_TOWN` (offset 0) - the crossing itself is water, so SURF | warp 2 / warp 3 into `TOHJO_FALLS`; warp 1 into `ROUTE_27_SANDSTORM_HOUSE`; east connection to `ROUTE_26` (offset -45) | "First step into KANTO" coord event, Rare Candy, then the eastern trainer gauntlet | +| 5 | `MAP_TOHJO_FALLS` | `maps/TohjoFalls.asm` | `Route27` warp 2 (26, 5) -> Tohjo warp 1 | Tohjo warp 2 (25, 15) -> `Route27` warp 3 | Cross the falls; MOON STONE item ball the walkthrough does not mention | +| 6 | `MAP_ROUTE_27` | `maps/Route27.asm` | Tohjo warp 2 | east connection to `ROUTE_26` | Megan ambush on exit, Sandstorm house, Blake/Brian/Gilbert, whirlpool island (Jose + TM22), Reena | +| 7 | `MAP_ROUTE_27_SANDSTORM_HOUSE` | `maps/Route27SandstormHouse.asm` | `Route27` warp 1 (33, 7) | warps 1/2 back to `Route27` warp 1 | Happiness check -> TM37 SANDSTORM | +| 8 | `MAP_ROUTE_26` | `maps/Route26.asm` | west connection from `ROUTE_27` (offset 45) | warp 1 (7, 5) to `VICTORY_ROAD_GATE` warp 3 | Scott, Richard, heal house, Joyce, Ice Berry, Gaven, Jake, Max Elixer, Beth | +| 9 | `MAP_ROUTE_26_HEAL_HOUSE` | `maps/Route26HealHouse.asm` | `Route26` warp 2 (15, 57) | warps 1/2 back to `Route26` warp 2 | Free full heal, repeatable | +| 10 | `MAP_DAY_OF_WEEK_SIBLINGS_HOUSE` | `maps/DayOfWeekSiblingsHouse.asm` | `Route26` warp 3 (5, 71) | warps 1/2 back to `Route26` warp 3 | Monica's journal (the day-of-week sibling roster) | +| 11 | `MAP_VICTORY_ROAD_GATE` | `maps/VictoryRoadGate.asm` | `Route26` warp 1 | warps 5/6 to `VICTORY_ROAD` | Eight-badge check. Victory Road itself belongs to section 17; only the gate's badge check is transcribed here because it is this section's terminal gate. | + +Spill-over note: `DARK_CAVE_BLACKTHORN_ENTRANCE` / `DARK_CAVE_VIOLET_ENTRANCE` +are the "left path" the walkthrough points at from Routes 45 and 46; they are +section 15's maps. Their return warps are `DarkCaveBlackthornEntrance.asm` +`warp_event 23, 3, ROUTE_45, 1` and `DarkCaveVioletEntrance.asm` +`warp_event 35, 33, ROUTE_46, 3`. + +--- + +## 2. Maps + +### MAP_ROUTE_45 + +- Script: `maps/Route45.asm` +- Blocks: `maps/Route45.blk` +- Header: `data/maps/maps.asm:185` -> `map Route45, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_45, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_DRATINI_2` +- Dimensions: `constants/map_constants.asm:170` -> `map_const ROUTE_45, 10, 45` (20 x 90 cells) +- Connections (`data/maps/attributes.asm:243`): `connection north, BlackthornCity, BLACKTHORN_CITY, 0`; `connection west, Route46, ROUTE_46, 36` +- Symbols: `4d:4e75 Route45_MapScripts`, `4d:54ad Route45_MapEvents` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 5 | `DARK_CAVE_BLACKTHORN_ENTRANCE` | 1 | + +**Coord events** (`def_coord_events`) + +None. Route 45 has no trip-wires; every scripted beat is an A press. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 10 | 4 | `BGEVENT_READ` | `Route45Sign` ("ROUTE 45 / MOUNTAIN RD. AHEAD") | +| 13 | 80 | `BGEVENT_ITEM` | `Route45HiddenPpUp` -> `hiddenitem PP_UP, EVENT_ROUTE_45_HIDDEN_PP_UP` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE45_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 10 | 16 | `STANDING_RIGHT` | `TRAINER` (sight 1) | `TrainerHikerErik` | -1 | +| `ROUTE45_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 15 | 64 | `STANDING_RIGHT` | `TRAINER` (sight 2) | `TrainerHikerMichael` | -1 | +| `ROUTE45_POKEFAN_M3` | `SPRITE_POKEFAN_M` | 5 | 28 | `STANDING_LEFT` | `TRAINER` (sight 3) | `TrainerHikerParry` | -1 | +| `ROUTE45_POKEFAN_M4` | `SPRITE_POKEFAN_M` | 9 | 64 | `STANDING_LEFT` | `TRAINER` (sight 1) | `TrainerHikerTimothy` | -1 | +| `ROUTE45_BLACK_BELT` | `SPRITE_BLACK_BELT` | 11 | 50 | `SPINRANDOM_FAST` | `TRAINER` (sight 2) | `TrainerBlackbeltKenji` | -1 | +| `ROUTE45_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 17 | 18 | `STANDING_LEFT` | `TRAINER` (sight 1) | `TrainerCooltrainermRyan` | -1 | +| `ROUTE45_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 4 | 36 | `STANDING_RIGHT` | `TRAINER` (sight 3) | `TrainerCooltrainerfKelly` | -1 | +| `ROUTE45_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 16 | 82 | `STILL` | `SCRIPT` | `Route45FruitTree` (`fruittree FRUITTREE_ROUTE_45`) | -1 | +| `ROUTE45_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 51 | `STILL` | `ITEMBALL` | `Route45XSpecial` (`itemball X_SPECIAL`) | `EVENT_ROUTE_45_X_SPECIAL` | +| `ROUTE45_POKE_BALL2` | `SPRITE_POKE_BALL` | 6 | 66 | `STILL` | `ITEMBALL` | `Route45Revive` (`itemball REVIVE`) | `EVENT_ROUTE_45_REVIVE` | +| `ROUTE45_POKE_BALL3` | `SPRITE_POKE_BALL` | 4 | 21 | `STILL` | `ITEMBALL` | `Route45Elixer` (`itemball ELIXER`) | `EVENT_ROUTE_45_ELIXER` | +| `ROUTE45_POKE_BALL4` | `SPRITE_POKE_BALL` | 8 | 33 | `STILL` | `ITEMBALL` | `Route45MaxPotion` (`itemball MAX_POTION`) | `EVENT_ROUTE_45_MAX_POTION` | + +**Scripts of interest** + +- `TrainerBlackbeltKenji` (`4d:4e77`). Header is + `trainer BLACKBELT_T, KENJI3, EVENT_BEAT_BLACKBELT_KENJI, BlackbeltKenjiSeenText, BlackbeltKenjiBeatenText, 0, .Script`. + Post-battle `.Script`: `endifjustbattled` / `opentext` / + `checkevent EVENT_KENJI_READY_FOR_REMATCH` (rematch branch) / + `checkcellnum PHONE_BLACKBELT_KENJI` (already registered -> jump to the + "number accepted" std) / `checkevent EVENT_KENJI_ASKED_FOR_PHONE_NUMBER`. + First pass sets `EVENT_KENJI_ASKED_FOR_PHONE_NUMBER` and calls + `askforphonenumber PHONE_BLACKBELT_KENJI`; the return value is compared + against `PHONE_CONTACTS_FULL` and `PHONE_CONTACT_REFUSED`. This is the + walkthrough's "talk to him again and he will give you his phone number" - + the number is offered on the **second** A press, not the first. + `.WantsBattle` picks the rematch party by + `checkevent EVENT_RESTORED_POWER_TO_KANTO` -> `KENJI2`, else + `checkevent EVENT_BEAT_ELITE_FOUR` -> `KENJI1`, else `KENJI3`; then + `loadtrainer` / `startbattle` / `reloadmapafterbattle` / + `clearevent EVENT_KENJI_READY_FOR_REMATCH`. +- `TrainerHikerParry` (`4d:4f2d`) is the same shape with + `PHONE_HIKER_PARRY`, `EVENT_PARRY_ASKED_FOR_PHONE_NUMBER`, + `EVENT_PARRY_READY_FOR_REMATCH`, and `PARRY3` as the first-meeting party + (`PARRY1` after the Elite Four, `PARRY2` after Kanto's power is restored). + Note `gettrainername STRING_BUFFER_3, HIKER, PARRY1` - the displayed name is + read off `PARRY1` even though the fight is `PARRY3`. +- `TrainerHikerErik`, `TrainerHikerMichael`, `TrainerHikerTimothy`, + `TrainerCooltrainermRyan`, `TrainerCooltrainerfKelly`: plain + `endifjustbattled` / `opentext` / `writetext ...AfterBattleText` / + `waitbutton` / `closetext` / `end`. No flags beyond the `trainer` header's + own beat flag. +- `Route45FruitTree` = `fruittree FRUITTREE_ROUTE_45`. Index 12 in + `constants/script_constants.asm:218`; `data/items/fruit_trees.asm` row 12 is + `MYSTERYBERRY` - the walkthrough's "Mystery Berry". +- `Route45HiddenPpUp` = `hiddenitem PP_UP, EVENT_ROUTE_45_HIDDEN_PP_UP`, at + cell (13, 80). Not mentioned by the walkthrough at all. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_HIKER_ERIK` | `constants/event_flags.asm:824` | `trainer` header | set on win, hides the "!" re-challenge | +| `EVENT_BEAT_HIKER_MICHAEL` | `:825` | `trainer` header | as above | +| `EVENT_BEAT_HIKER_PARRY` | `:826` | `trainer` header | as above | +| `EVENT_BEAT_HIKER_TIMOTHY` | `:827` | `trainer` header | as above | +| `EVENT_BEAT_BLACKBELT_KENJI` | `:686` | `trainer` header | as above | +| `EVENT_BEAT_COOLTRAINERM_RYAN` | `:864` | `trainer` header | as above | +| `EVENT_BEAT_COOLTRAINERF_KELLY` | `:883` | `trainer` header | as above | +| `EVENT_KENJI_ASKED_FOR_PHONE_NUMBER` | `constants/event_flags.asm` | `TrainerBlackbeltKenji.Script` | gates the "ask again" wording | +| `EVENT_KENJI_READY_FOR_REMATCH` | `constants/event_flags.asm` | phone script sets, map script clears | rematch pending | +| `EVENT_PARRY_ASKED_FOR_PHONE_NUMBER` / `EVENT_PARRY_READY_FOR_REMATCH` | same | `TrainerHikerParry.Script` | same pair for Parry | +| `EVENT_RESTORED_POWER_TO_KANTO` | `:204` | read by both rematch scripts | selects the hardest rematch party | +| `EVENT_BEAT_ELITE_FOUR` | `:77` | read by both rematch scripts | selects the middle rematch party | +| `EVENT_ROUTE_45_X_SPECIAL` | `:1113` | item ball object | one-time pickup | +| `EVENT_ROUTE_45_REVIVE` | `:1114` | item ball object | one-time pickup | +| `EVENT_ROUTE_45_ELIXER` | `:1115` | item ball object | one-time pickup | +| `EVENT_ROUTE_45_MAX_POTION` | `:1116` | item ball object | one-time pickup | +| `EVENT_ROUTE_45_HIDDEN_PP_UP` | `:185` | `hiddenitem` | one-time hidden pickup | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `X_SPECIAL` | item ball at (6, 51) | `Route45XSpecial` | `EVENT_ROUTE_45_X_SPECIAL` | +| `REVIVE` | item ball at (6, 66) | `Route45Revive` | `EVENT_ROUTE_45_REVIVE` | +| `ELIXER` | item ball at (4, 21) | `Route45Elixer` | `EVENT_ROUTE_45_ELIXER` | +| `MAX_POTION` | item ball at (8, 33) | `Route45MaxPotion` | `EVENT_ROUTE_45_MAX_POTION` | +| `MYSTERYBERRY` | fruit tree at (16, 82) | `Route45FruitTree` / `FRUITTREE_ROUTE_45` | daily fruit-tree flag, not an `EVENT_*` | +| `PP_UP` | hidden, bg event (13, 80) | `Route45HiddenPpUp` | `EVENT_ROUTE_45_HIDDEN_PP_UP` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `HIKER, ERIK` | `HIKER` (`$2c`) | 7 | `HikerGroup` "ERIK": 24 MACHOP, 27 GRAVELER, 27 MACHOP | `TrainerHikerErik` | no | +| `HIKER, MICHAEL` | `HIKER` | 8 | "MICHAEL": 25 GEODUDE, 25 GRAVELER, 25 GOLEM | `TrainerHikerMichael` | no | +| `HIKER, PARRY3` | `HIKER` | 20 | "PARRY": 29 ONIX | `TrainerHikerParry` | `PHONE_HIKER_PARRY`; `PARRY1` = 34 ONIX, `PARRY2` = 38 ONIX | +| `HIKER, TIMOTHY` | `HIKER` | 10 | "TIMOTHY" (`TRAINERTYPE_MOVES`): 27 DIGLETT (MAGNITUDE/DIG/SAND_ATTACK/SLASH), 27 DUGTRIO (same) | `TrainerHikerTimothy` | no | +| `BLACKBELT_T, KENJI3` | `BLACKBELT_T` (`$32`) | 8 | `BlackbeltGroup` "KENJI": 28 MACHOKE | `TrainerBlackbeltKenji` | `PHONE_BLACKBELT_KENJI` | +| `COOLTRAINERM, RYAN` | `COOLTRAINERM` (`$1b`) | 8 | "RYAN" (`TRAINERTYPE_MOVES`): 25 PIDGEOT (SAND_ATTACK/QUICK_ATTACK/WHIRLWIND/WING_ATTACK), 27 ELECTABUZZ (THUNDERPUNCH/LIGHT_SCREEN/SWIFT/SCREECH) | `TrainerCooltrainermRyan` | no | +| `COOLTRAINERF, KELLY` | `COOLTRAINERF` (`$1c`) | 7 | "KELLY": 27 MARILL, 24 WARTORTLE, 24 WARTORTLE | `TrainerCooltrainerfKelly` | no | + +**Wild encounters** + +`data/wild/johto_grass.asm:2236` `def_grass_wildmons ROUTE_45`, rates +`10 percent` morn/day/nite (identical slot lists across all three times): + +- Gold: 23 GEODUDE, 23 GRAVELER, 24 GLIGAR, 20 TEDDIURSA, 25 GRAVELER, + 27 GRAVELER, 27 GRAVELER. +- Silver: 23 GEODUDE, 23 GRAVELER, 24 GRAVELER, 20 PHANPY, 25 GRAVELER, + 27 SKARMORY, 27 SKARMORY. + +`data/wild/johto_water.asm:204` `def_water_wildmons ROUTE_45`, 2 percent: +20 MAGIKARP, 15 MAGIKARP, 5 MAGIKARP. + +Fishing group `FISHGROUP_DRATINI_2` (`data/wild/fish.asm` `FishGroups` row 9). +Headbutt: `data/wild/treemon_maps.asm:26` -> `TREEMON_SET_CANYON` +(`data/wild/treemons.asm:73`: SPEAROW/AIPOM at level 10). + +Roaming beast: `data/wild/roammon_maps.asm:33` `roam_map ROUTE_45, ROUTE_44, ROUTE_46` +- Route 45 is a roam map, so Entei/Raikou can appear in its grass. + +--- + +### MAP_ROUTE_46 + +- Script: `maps/Route46.asm` +- Blocks: `maps/Route46.blk` +- Header: `data/maps/maps.asm:186` -> `map Route46, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_46, MUSIC_ROUTE_36, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:171` -> `map_const ROUTE_46, 10, 18` (20 x 36 cells) +- Connections (`data/maps/attributes.asm:247`): `connection south, Route29, ROUTE_29, -10`; `connection east, Route45, ROUTE_45, -36` +- Symbols: `4d:555e Route46_MapScripts`, `4d:57c7 Route46_MapEvents` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 33 | `ROUTE_29_ROUTE_46_GATE` | 1 | +| 2 | 8 | 33 | `ROUTE_29_ROUTE_46_GATE` | 2 | +| 3 | 14 | 5 | `DARK_CAVE_VIOLET_ENTRANCE` | 3 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 27 | `BGEVENT_READ` | `Route46Sign` ("ROUTE 46 / MOUNTAIN RD. AHEAD") | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE46_POKEFAN_M` | `SPRITE_POKEFAN_M` | 12 | 18 | `STANDING_LEFT` | `TRAINER` (sight 2) | `TrainerHikerBailey` | -1 | +| `ROUTE46_YOUNGSTER` | `SPRITE_YOUNGSTER` | 3 | 13 | `STANDING_RIGHT` | `TRAINER` (sight 4) | `TrainerCamperTed` | -1 | +| `ROUTE46_LASS` | `SPRITE_LASS` | 1 | 15 | `STANDING_RIGHT` | `TRAINER` (sight 4) | `TrainerPicnickerErin1` | -1 | +| `ROUTE46_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 7 | 5 | `STILL` | `SCRIPT` | `Route46FruitTree1` (`fruittree FRUITTREE_ROUTE_46_1`) | -1 | +| `ROUTE46_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 8 | 6 | `STILL` | `SCRIPT` | `Route46FruitTree2` (`fruittree FRUITTREE_ROUTE_46_2`) | -1 | +| `ROUTE46_POKE_BALL` | `SPRITE_POKE_BALL` | 0 | 12 | `STILL` | `ITEMBALL` | `Route46DireHit` (`itemball DIRE_HIT`) | `EVENT_ROUTE_46_DIRE_HIT` | + +**Scripts of interest** + +- `TrainerPicnickerErin1` (`4d:5574`), header + `trainer PICNICKER, ERIN1, EVENT_BEAT_PICNICKER_ERIN, ...`. Same phone shape + as Kenji/Parry: `checkcellnum PHONE_PICNICKER_ERIN`, + `EVENT_ERIN_ASKED_FOR_PHONE_NUMBER`, `askforphonenumber PHONE_PICNICKER_ERIN`, + `EVENT_ERIN_READY_FOR_REMATCH`; rematch parties `ERIN2` (after Elite Four) + and `ERIN3` (after Kanto power restored). +- `TrainerCamperTed` (`4d:5560`) and `TrainerHikerBailey` (`4d:5602`) are plain + after-battle text scripts. +- `Route46FruitTree1` = `FRUITTREE_ROUTE_46_1` = index 4 = + `BERRY`; `Route46FruitTree2` = `FRUITTREE_ROUTE_46_2` = index 10 = + `PRZCUREBERRY` (`data/items/fruit_trees.asm`). Those are the walkthrough's + "Berry and PrzCure Berry". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_HIKER_BAILEY` | `constants/event_flags.asm:828` | `trainer` header | set on win | +| `EVENT_BEAT_CAMPER_TED` | `:538` | `trainer` header | set on win | +| `EVENT_BEAT_PICNICKER_ERIN` | `:645` | `trainer` header | set on win | +| `EVENT_ERIN_ASKED_FOR_PHONE_NUMBER` / `EVENT_ERIN_READY_FOR_REMATCH` | `constants/event_flags.asm` | `TrainerPicnickerErin1.Script` | phone / rematch pair | +| `EVENT_ROUTE_46_DIRE_HIT` | `:1117` | item ball object | one-time pickup | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `DIRE_HIT` | item ball at (0, 12) | `Route46DireHit` | `EVENT_ROUTE_46_DIRE_HIT` | +| `BERRY` | fruit tree at (7, 5) | `Route46FruitTree1` | daily fruit-tree flag | +| `PRZCUREBERRY` | fruit tree at (8, 6) | `Route46FruitTree2` | daily fruit-tree flag | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `HIKER, BAILEY` | `HIKER` | 11 | `HikerGroup` "BAILEY": five 13 GEODUDE | `TrainerHikerBailey` | no | +| `CAMPER, TED` | `CAMPER` (`$36`) | 11 | `CamperGroup` "TED": 17 MANKEY | `TrainerCamperTed` | no | +| `PICNICKER, ERIN1` | `PICNICKER` (`$35`) | 10 | `PicnickerGroup` "ERIN": 16 PONYTA, 16 PONYTA | `TrainerPicnickerErin1` | `PHONE_PICNICKER_ERIN` | + +**Wild encounters** + +`data/wild/johto_grass.asm:2291` `def_grass_wildmons ROUTE_46`, `10 percent` +morn/day/nite, same for Gold and Silver: + +- morn/day: 3 GEODUDE, 2 SPEAROW, 2 RATTATA, 2 GEODUDE, 3 SPEAROW, + 3 JIGGLYPUFF, 5 JIGGLYPUFF. +- nite: 3 GEODUDE, 3 RATTATA, 2 RATTATA, 2 GEODUDE, 4 GEODUDE, 3 JIGGLYPUFF, + 5 JIGGLYPUFF. + +No water table. Fishing group `FISHGROUP_SHORE`. Headbutt: +`data/wild/treemon_maps.asm:27` -> `TREEMON_SET_CANYON`. +Roaming beast: `data/wild/roammon_maps.asm:34` `roam_map ROUTE_46, ROUTE_45, ROUTE_29`. + +--- + +### MAP_NEW_BARK_TOWN (Elm's Lab beats only) + +The New Bark Town / Elm's Lab map tables belong to section 00. What this +section needs is the two `ProfElmScript` branches the walkthrough triggers. + +- `maps/ElmsLab.asm:50` `ProfElmScript`: + `faceplayer` / `opentext` / `checkevent EVENT_GOT_SS_TICKET_FROM_ELM` + (-> `ElmCheckMasterBall`) / `checkevent EVENT_BEAT_ELITE_FOUR` + (-> `ElmGiveTicketScript`). +- `ElmCheckMasterBall`: `checkevent EVENT_GOT_MASTER_BALL_FROM_ELM` / + `checkflag ENGINE_RISINGBADGE` -> `ElmGiveMasterBallScript`. **This is the + actual gate on the Master Ball: the eighth Johto badge, not "eight badges" + counted.** `constants/engine_flags.asm:45` is `ENGINE_RISINGBADGE`. +- `ElmGiveMasterBallScript` (`60:4268`): `verbosegiveitem MASTER_BALL` / + `iffalse .notdone` / `setevent EVENT_GOT_MASTER_BALL_FROM_ELM`. A full bag + means the flag is never set, so a bot must have a free slot. +- `ElmCheckEverstone`: `checkevent EVENT_GOT_EVERSTONE_FROM_ELM` / + `checkevent EVENT_SHOWED_TOGEPI_TO_ELM` -> `ElmGiveEverstoneScript` / + `checkevent EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE`, then + `setval TOGEPI` + `special FindPartyMonThatSpeciesYourTrainerID`, and the + same for `TOGETIC`. That special is why the walkthrough tells you to + **withdraw Togepi into the party first** - the check is a party scan, and it + additionally requires the mon to be your own OT. +- `ShowElmTogepiScript` sets `EVENT_SHOWED_TOGEPI_TO_ELM` and falls into + `ElmGiveEverstoneScript` (`60:424f`): `verbosegiveitem EVERSTONE` / + `iffalse ElmScript_NoRoomForEverstone` / `setevent EVENT_GOT_EVERSTONE_FROM_ELM`. +- Mom's savings: `maps/PlayersHouse1F.asm:85` `MomScript`, `.BankOfMom` at + line 115 runs `special BankOfMom`. + +--- + +### MAP_ROUTE_27 + +- Script: `maps/Route27.asm` +- Blocks: `maps/Route27.blk` +- Header: `data/maps/maps.asm:473` -> `map Route27, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_27, MUSIC_ROUTE_26, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:438` -> `map_const ROUTE_27, 40, 9` (80 x 18 cells) +- Connections (`data/maps/attributes.asm:170`): `connection west, NewBarkTown, NEW_BARK_TOWN, 0`; `connection east, Route26, ROUTE_26, -45` +- Scene var: `data/maps/scenes.asm:27` `scene_var ROUTE_27, wRoute27SceneID` +- Symbols: `4a:46ba Route27_MapScripts`, `4a:4cac Route27_MapEvents` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 33 | 7 | `ROUTE_27_SANDSTORM_HOUSE` | 1 | +| 2 | 26 | 5 | `TOHJO_FALLS` | 1 | +| 3 | 36 | 5 | `TOHJO_FALLS` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE27_FIRST_STEP_INTO_KANTO` (0) | 18 | 10 | `FirstStepIntoKantoLeftScene` | fisher turns LEFT, `showemote EMOTE_SHOCK`, `applymovement ROUTE27_FISHER, Route27FisherStepLeftTwiceMovement` (two `step LEFT`) | +| `SCENE_ROUTE27_FIRST_STEP_INTO_KANTO` (0) | 19 | 10 | `FirstStepIntoKantoRightScene` | same but one `step LEFT` | + +Both fall into `FirstStepIntoKantoScene_Continue`: `turnobject PLAYER, RIGHT`, +`writetext Route27FisherHeyText`, `writetext Route27FisherText` ("You've taken +your first step into KANTO"), then `setscene SCENE_ROUTE27_NOOP` (1) so it never +fires again. The scene constants are generated by the `def_scene_scripts` / +`scene_script` macros in `macros/scripts/maps.asm`; the sym table confirms +`SCENE_ROUTE27_FIRST_STEP_INTO_KANTO = 00` and `SCENE_ROUTE27_NOOP = 01`. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 25 | 7 | `BGEVENT_READ` | `TohjoFallsSign` ("TOHJO FALLS / The Link Between KANTO and JOHTO") | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE27_COOLTRAINER_M1` | `SPRITE_COOLTRAINER_M` | 49 | 7 | `STANDING_UP` | `TRAINER` (sight 3) | `TrainerCooltrainermBlake` | -1 | +| `ROUTE27_COOLTRAINER_M2` | `SPRITE_COOLTRAINER_M` | 58 | 6 | `STANDING_LEFT` | `TRAINER` (sight 5) | `TrainerCooltrainermBrian` | -1 | +| `ROUTE27_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 72 | 11 | `STANDING_UP` | `TRAINER` (sight 5) | `TrainerCooltrainerfReena` | -1 | +| `ROUTE27_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 37 | 6 | `SPINCLOCKWISE` | `TRAINER` (sight 2) | `TrainerCooltrainerfMegan` | -1 | +| `ROUTE27_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 64 | 7 | `STANDING_LEFT` | `TRAINER` (sight 5) | `TrainerPsychicGilbert` | -1 | +| `ROUTE27_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 58 | 13 | `STANDING_RIGHT` | `TRAINER` (sight 3) | `TrainerBirdKeeperJose2` | -1 | +| `ROUTE27_POKE_BALL1` | `SPRITE_POKE_BALL` | 60 | 12 | `STILL` | `ITEMBALL` | `Route27TMSolarbeam` (`itemball TM_SOLARBEAM`) | `EVENT_ROUTE_27_TM_SOLARBEAM` | +| `ROUTE27_POKE_BALL2` | `SPRITE_POKE_BALL` | 53 | 12 | `STILL` | `ITEMBALL` | `Route27RareCandy` (`itemball RARE_CANDY`) | `EVENT_ROUTE_27_RARE_CANDY` | +| `ROUTE27_FISHER` | `SPRITE_FISHER` | 21 | 10 | `SPINRANDOM_SLOW` | `SCRIPT` (sight 3) | `Route27FisherScript` (`jumptextfaceplayer Route27FisherText`) | -1 | + +**Scripts of interest** + +- `FirstStepIntoKantoLeftScene` (`4a:46c6`) / `FirstStepIntoKantoRightScene` + (`4a:46d4`) - the only forced cutscene on this route, described above. It is + a coord event on the two cells (18, 10) and (19, 10), so a bot that lands on + Route 27 by surfing will cross it. +- `TrainerBirdKeeperJose2` (`4a:4706`), `trainer BIRD_KEEPER, JOSE2, EVENT_BEAT_BIRD_KEEPER_JOSE2, ...`, + phone contact `PHONE_BIRDKEEPER_JOSE`, `EVENT_JOSE_ASKED_FOR_PHONE_NUMBER`, + `EVENT_JOSE_READY_FOR_REMATCH`; rematch parties `JOSE1` (after Elite Four) + and `JOSE3` (after Kanto power restored). +- `TrainerCooltrainerfReena` (`4a:47bc`), `trainer COOLTRAINERF, REENA1, ...`, + phone contact `PHONE_COOLTRAINERF_REENA`; rematch parties `REENA2` / `REENA3`. +- `TrainerCooltrainermBlake` (`4a:4794`), `TrainerCooltrainermBrian` + (`4a:47a8`), `TrainerPsychicGilbert` (`4a:46f2`), + `TrainerCooltrainerfMegan` (`4a:484a`) - plain after-battle text. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `SCENE_ROUTE27_FIRST_STEP_INTO_KANTO` = 0 | generated in `maps/Route27.asm` (`def_scene_scripts`) | coord events; cleared by `setscene SCENE_ROUTE27_NOOP` | the Kanto welcome fires exactly once | +| `SCENE_ROUTE27_NOOP` = 1 | same | `FirstStepIntoKantoScene_Continue` | scene after the cutscene | +| `EVENT_BEAT_COOLTRAINERM_BLAKE` | `constants/event_flags.asm:867` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERM_BRIAN` | `:868` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERF_REENA` | `:886` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERF_MEGAN` | `:887` | `trainer` header | set on win | +| `EVENT_BEAT_PSYCHIC_GILBERT` | `:572` | `trainer` header | set on win | +| `EVENT_BEAT_BIRD_KEEPER_JOSE2` | `:507` | `trainer` header | set on win | +| `EVENT_ROUTE_27_TM_SOLARBEAM` | `:1100` | item ball object | one-time pickup | +| `EVENT_ROUTE_27_RARE_CANDY` | `:1101` | item ball object | one-time pickup | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SOLARBEAM` (TM22) | item ball at (60, 12), on the island past the whirlpool | `Route27TMSolarbeam` | `EVENT_ROUTE_27_TM_SOLARBEAM` | +| `RARE_CANDY` | item ball at (53, 12), in the southern water | `Route27RareCandy` | `EVENT_ROUTE_27_RARE_CANDY` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `COOLTRAINERF, MEGAN` | `COOLTRAINERF` | 11 | "MEGAN" (`TRAINERTYPE_MOVES`): 32 BULBASAUR (GROWL/LEECH_SEED/POISONPOWDER/RAZOR_LEAF), 32 IVYSAUR (same), 32 VENUSAUR (BODY_SLAM/SLEEP_POWDER/RAZOR_LEAF/SWEET_SCENT) | `TrainerCooltrainerfMegan` | no | +| `COOLTRAINERM, BLAKE` | `COOLTRAINERM` | 11 | "BLAKE" (`TRAINERTYPE_MOVES`): 33 MAGNETON (THUNDERBOLT/SUPERSONIC/SWIFT/SCREECH), 31 QUAGSIRE (WATER_GUN/SLAM/AMNESIA/EARTHQUAKE), 31 EXEGGCUTE (LEECH_SEED/CONFUSION/SLEEP_POWDER/SOLARBEAM) | `TrainerCooltrainermBlake` | no | +| `COOLTRAINERM, BRIAN` | `COOLTRAINERM` | 12 | "BRIAN" (`TRAINERTYPE_MOVES`): 35 SANDSLASH (SAND_ATTACK/POISON_STING/SLASH/SWIFT) | `TrainerCooltrainermBrian` | no | +| `PSYCHIC_T, GILBERT` | `PSYCHIC_T` (`$34`) | 10 | "GILBERT": 30 STARMIE, 30 EXEGGCUTE, 34 GIRAFARIG | `TrainerPsychicGilbert` | no | +| `BIRD_KEEPER, JOSE2` | `BIRD_KEEPER` (`$18`) | 14 | "JOSE": 35 FARFETCH_D | `TrainerBirdKeeperJose2` | `PHONE_BIRDKEEPER_JOSE` | +| `COOLTRAINERF, REENA1` | `COOLTRAINERF` | 10 | "REENA": 31 STARMIE, 33 NIDOQUEEN, 31 STARMIE | `TrainerCooltrainerfReena` | `PHONE_COOLTRAINERF_REENA` | + +Note the party order for Reena is STARMIE / NIDOQUEEN / STARMIE in the asm; the +walkthrough lists it STARMIE / STARMIE / NIDOQUEEN. + +**Wild encounters** + +`data/wild/kanto_grass.asm:1172` `def_grass_wildmons ROUTE_27`, `10 percent`: + +- Gold morn: 28 DODUO, 28 RATICATE, 30 DODUO, 28 QUAGSIRE, 32 PONYTA, + 30 SANDSLASH, 30 SANDSLASH. +- Gold day: 28 DODUO, 28 RATICATE, 30 DODUO, 30 RATICATE, 32 PONYTA, + 30 SANDSLASH, 30 SANDSLASH. +- Gold nite: 28 QUAGSIRE, 28 RATICATE, 30 QUAGSIRE, 30 RATICATE, 32 PONYTA, + 30 SANDSLASH, 30 SANDSLASH. +- Silver swaps DODRIO/ARBOK in for some of those; see the `ELIF DEF(_SILVER)` + block at the same label. + +`data/wild/kanto_water.asm:96` `def_water_wildmons ROUTE_27`, 6 percent: +20 TENTACOOL, 15 TENTACOOL, 20 TENTACRUEL. + +Fishing group `FISHGROUP_OCEAN`. Headbutt: +`data/wild/treemon_maps.asm:8` -> `TREEMON_SET_FOREST` (Gold: CATERPIE / +METAPOD / EXEGGCUTE / BUTTERFREE common, PINECO in the rare table). + +The walkthrough's Route 27 list omits Doduo's Gold-only DODRIO slots and does +not mention that ARBOK is Silver-only. + +--- + +### MAP_TOHJO_FALLS + +- Script: `maps/TohjoFalls.asm` +- Blocks: `maps/TohjoFalls.blk` +- Header: `data/maps/maps.asm:152` -> `map TohjoFalls, TILESET_CAVE, CAVE, LANDMARK_TOHJO_FALLS, MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` + (`TRUE` in the phone slot = no phone calls inside) +- Dimensions: `constants/map_constants.asm:139` -> `map_const TOHJO_FALLS, 15, 9` (30 x 18 cells) +- Connections: none (`data/maps/attributes.asm:471` is a bare `map_attributes` row) +- Symbols: `47:4a20 TohjoFalls_MapScripts`, `47:4a24 TohjoFalls_MapEvents` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 15 | `ROUTE_27` | 2 | +| 2 | 25 | 15 | `ROUTE_27` | 3 | + +**Coord events** / **BG events** + +Both empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TOHJOFALLS_POKE_BALL` | `SPRITE_POKE_BALL` | 2 | 6 | `STILL` | `ITEMBALL` | `TohjoFallsMoonStone` (`itemball MOON_STONE`) | `EVENT_TOHJO_FALLS_MOON_STONE` | + +**Scripts of interest** + +`TohjoFallsMoonStone` (`47:4a22`) is the only script on the map. There is no +`def_scene_scripts` body and no callbacks - the whole map is terrain. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_TOHJO_FALLS_MOON_STONE` | `constants/event_flags.asm:1098` | item ball object | one-time pickup | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MOON_STONE` | item ball at (2, 6) - across the water, west end | `TohjoFallsMoonStone` | `EVENT_TOHJO_FALLS_MOON_STONE` | + +**Trainers** + +None. + +**Wild encounters** + +`data/wild/kanto_grass.asm:199` `def_grass_wildmons TOHJO_FALLS`, encounter +rate 4 percent in Gold / 6 percent in Silver, identical morn/day/nite: +22 ZUBAT, 22 RATICATE, 22 GOLBAT, 21 SLOWPOKE, 20 RATTATA, 23 SLOWPOKE, +23 SLOWPOKE. + +`data/wild/kanto_water.asm:103` `def_water_wildmons TOHJO_FALLS`, 4 percent: +20 GOLDEEN, 20 SLOWPOKE, 20 SEAKING. + +Fishing group `FISHGROUP_LAKE`. No treemon map entry (it is a cave). + +The walkthrough's list (Rattata / Slowpoke / Seaking) is a subset - ZUBAT, +GOLBAT, RATICATE and GOLDEEN are also here. + +--- + +### MAP_ROUTE_27_SANDSTORM_HOUSE + +- Script: `maps/Route27SandstormHouse.asm` +- Blocks: none (indoor, uses the shared house layout) +- Header: `data/maps/maps.asm:483` -> `map Route27SandstormHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_27, MUSIC_AZALEA_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:448` -> `map_const ROUTE_27_SANDSTORM_HOUSE, 4, 4` (8 x 8 cells) +- Symbols: `60:6352 SandstormHouseWoman` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_27` | 1 | +| 2 | 3 | 7 | `ROUTE_27` | 1 | + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `SandstormHouseBookshelf` (`jumpstd MagazineBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `SandstormHouseBookshelf` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE27SANDSTORMHOUSE_GRANNY` | `SPRITE_GRANNY` | 2 | 4 | `STANDING_DOWN` | `SCRIPT` | `SandstormHouseWoman` | -1 | + +**Scripts of interest** + +`SandstormHouseWoman` (`60:6352`): + +``` +faceplayer / opentext +checkevent EVENT_GOT_TM37_SANDSTORM ; iftrue .AlreadyGotItem +special GetFirstPokemonHappiness +writetext SandstormHouseWomanText1 / promptbutton +ifgreater 150 - 1, .Loyal ; i.e. happiness >= 150 +sjump .Disloyal +.Loyal: writetext SandstormHouseWomanLoyalText / promptbutton + verbosegiveitem TM_SANDSTORM / iffalse .Done + setevent EVENT_GOT_TM37_SANDSTORM +``` + +The precise gate is **happiness of the first non-egg party member >= 150** +(`ifgreater 150 - 1`), not "one of your Pokemon is happy" as the walkthrough +puts it - only the first party slot is read, by +`special GetFirstPokemonHappiness`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_TM37_SANDSTORM` | `constants/event_flags.asm:126` | `SandstormHouseWoman` | one-time; also short-circuits straight to the TM description text | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_SANDSTORM` (TM37) | talk to the granny with a >= 150 happiness lead mon | `SandstormHouseWoman.Loyal` | `EVENT_GOT_TM37_SANDSTORM` | + +--- + +### MAP_ROUTE_26 + +- Script: `maps/Route26.asm` +- Blocks: `maps/Route26.blk` +- Header: `data/maps/maps.asm:472` -> `map Route26, TILESET_JOHTO, ROUTE, LANDMARK_ROUTE_26, MUSIC_ROUTE_26, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:437` -> `map_const ROUTE_26, 10, 54` (20 x 108 cells) +- Connections (`data/maps/attributes.asm:167`): `connection west, Route27, ROUTE_27, 45` (west only - the north end is the Victory Road gate warp, not a connection) +- Symbols: `4a:4000 Route26_MapScripts`, `4a:4638 Route26_MapEvents` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 5 | `VICTORY_ROAD_GATE` | 3 | +| 2 | 15 | 57 | `ROUTE_26_HEAL_HOUSE` | 1 | +| 3 | 5 | 71 | `DAY_OF_WEEK_SIBLINGS_HOUSE` | 1 | + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 8 | 6 | `BGEVENT_READ` | `Route26Sign` ("ROUTE 26 / #MON LEAGUE RECEPTION GATE") | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE26_COOLTRAINER_M1` | `SPRITE_COOLTRAINER_M` | 11 | 16 | `STANDING_RIGHT` | `TRAINER` (sight 2) | `TrainerCooltrainermJake` | -1 | +| `ROUTE26_COOLTRAINER_M2` | `SPRITE_COOLTRAINER_M` | 9 | 38 | `STANDING_LEFT` | `TRAINER` (sight 5) | `TrainerCooltrainermGaven3` | -1 | +| `ROUTE26_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 10 | 56 | `SPINRANDOM_FAST` | `TRAINER` (sight 3) | `TrainerCooltrainerfJoyce` | -1 | +| `ROUTE26_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 5 | 8 | `STANDING_RIGHT` | `TRAINER` (sight 4) | `TrainerCooltrainerfBeth1` | -1 | +| `ROUTE26_YOUNGSTER` | `SPRITE_YOUNGSTER` | 13 | 79 | `STANDING_RIGHT` | `TRAINER` (sight 4) | `TrainerPsychicRichard` | -1 | +| `ROUTE26_FISHER` | `SPRITE_FISHER` | 10 | 92 | `STANDING_DOWN` | `TRAINER` (sight 3) | `TrainerFisherScott` | -1 | +| `ROUTE26_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 14 | 54 | `STILL` | `SCRIPT` | `Route26FruitTree` (`fruittree FRUITTREE_ROUTE_26`) | -1 | +| `ROUTE26_POKE_BALL` | `SPRITE_POKE_BALL` | 9 | 15 | `STILL` | `ITEMBALL` | `Route26MaxElixer` (`itemball MAX_ELIXER`) | `EVENT_ROUTE_26_MAX_ELIXER` | + +The y coordinates run south (high) to north (low), which matches the +walkthrough's order exactly: Scott (92) -> Richard (79) -> Day-of-Week house +(71) -> heal house (57) -> Joyce (56) -> Ice Berry tree (54) -> Gaven (38) -> +Jake (16) -> Max Elixer (15) -> Beth (8) -> gate warp (5). + +**Scripts of interest** + +- `TrainerCooltrainermGaven3` (`4a:4016`), header + `trainer COOLTRAINERM, GAVEN3, EVENT_BEAT_COOLTRAINERM_GAVEN, ...`. Phone + contact `PHONE_COOLTRAINERM_GAVEN`; rematch parties `GAVEN1` (after Elite + Four) and `GAVEN2` (after Kanto power restored). His after-battle text is the + one that names Victory Road as the way to the League. +- `TrainerCooltrainerfBeth1` (`4a:40b8`), phone `PHONE_COOLTRAINERF_BETH`; + rematch parties `BETH2` / `BETH3`. +- `TrainerCooltrainermJake` (`4a:4002`), `TrainerCooltrainerfJoyce` + (`4a:40a4`), `TrainerPsychicRichard` (`4a:4146`), `TrainerFisherScott` + (`4a:415a`) - plain after-battle text. +- `Route26FruitTree` = `FRUITTREE_ROUTE_26` = index 14 = `ICE_BERRY`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_FISHER_SCOTT` | `constants/event_flags.asm:605` | `trainer` header | set on win | +| `EVENT_BEAT_PSYCHIC_RICHARD` | `:571` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERF_JOYCE` | `:884` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERM_GAVEN` | `:866` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERM_JAKE` | `:865` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERF_BETH` | `:885` | `trainer` header | set on win | +| `EVENT_GAVEN_ASKED_FOR_PHONE_NUMBER` / `EVENT_GAVEN_READY_FOR_REMATCH` | `constants/event_flags.asm` | `TrainerCooltrainermGaven3.Script` | phone / rematch pair | +| `EVENT_BETH_ASKED_FOR_PHONE_NUMBER` / `EVENT_BETH_READY_FOR_REMATCH` | `constants/event_flags.asm` | `TrainerCooltrainerfBeth1.Script` | phone / rematch pair | +| `EVENT_ROUTE_26_MAX_ELIXER` | `:1099` | item ball object | one-time pickup | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_ELIXER` | item ball at (9, 15) | `Route26MaxElixer` | `EVENT_ROUTE_26_MAX_ELIXER` | +| `ICE_BERRY` | fruit tree at (14, 54) | `Route26FruitTree` / `FRUITTREE_ROUTE_26` | daily fruit-tree flag | + +There is exactly **one** Max Elixer on Route 26. The walkthrough mentions it +twice ("grab the Max Elixir" then "you can also pick up an Max Elixir if you +take the jumps back to heal") - both refer to the same ball at (9, 15). + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `FISHER, SCOTT` | `FISHER` (`$25`) | 21 | `FisherGroup` "SCOTT": 30 QWILFISH, 30 QWILFISH, 34 SEAKING | `TrainerFisherScott` | no | +| `PSYCHIC_T, RICHARD` | `PSYCHIC_T` | 9 | "RICHARD": 36 ESPEON | `TrainerPsychicRichard` | no | +| `COOLTRAINERF, JOYCE` | `COOLTRAINERF` | 8 | "JOYCE" (`TRAINERTYPE_MOVES`): 36 PIKACHU (QUICK_ATTACK/DOUBLE_TEAM/THUNDERBOLT/THUNDER), 32 BLASTOISE (BITE/CURSE/SURF/RAIN_DANCE) | `TrainerCooltrainerfJoyce` | no | +| `COOLTRAINERM, GAVEN3` | `COOLTRAINERM` | 10 | "GAVEN" (`TRAINERTYPE_MOVES`): 32 VICTREEBEL (WRAP/TOXIC/ACID/RAZOR_LEAF), 32 **KINGLER** (BUBBLEBEAM/STOMP/GUILLOTINE/PROTECT), 32 FLAREON (SAND_ATTACK/QUICK_ATTACK/BITE/FIRE_SPIN) | `TrainerCooltrainermGaven3` | `PHONE_COOLTRAINERM_GAVEN` | +| `COOLTRAINERM, JAKE` | `COOLTRAINERM` | 9 | "JAKE" (`TRAINERTYPE_MOVES`): 33 PARASECT (LEECH_LIFE/SPORE/SLASH/SWORDS_DANCE), 35 GOLDUCK (CONFUSION/SCREECH/PSYCH_UP/FURY_SWIPES) | `TrainerCooltrainermJake` | no | +| `COOLTRAINERF, BETH1` | `COOLTRAINERF` | 9 | "BETH" (`TRAINERTYPE_MOVES`): 36 RAPIDASH (STOMP/FIRE_SPIN/FURY_ATTACK/AGILITY) | `TrainerCooltrainerfBeth1` | `PHONE_COOLTRAINERF_BETH` | + +**Wild encounters** + +`data/wild/kanto_grass.asm:1117` `def_grass_wildmons ROUTE_26`, `10 percent`: + +- Gold morn: 28 DODUO, 28 SANDSLASH, 32 PONYTA, 30 DODUO, 30 DODRIO, + 30 RATICATE, 30 QUAGSIRE. +- Gold day: 28 DODUO, 28 SANDSLASH, 32 PONYTA, 30 DODUO, 30 RATICATE, + 30 DODRIO, 30 DODRIO. +- Gold nite: 28 RATICATE, 28 SANDSLASH, 32 PONYTA, 30 RATICATE, 30 QUAGSIRE, + 32 QUAGSIRE, 32 QUAGSIRE. +- Silver replaces SANDSLASH with RATICATE and DODRIO with ARBOK; see the + `ELIF DEF(_SILVER)` block at the same label. + +`data/wild/kanto_water.asm:89` `def_water_wildmons ROUTE_26`, 6 percent: +30 TENTACOOL, 25 TENTACOOL, 30 TENTACRUEL. + +Fishing group `FISHGROUP_OCEAN`. Headbutt: +`data/wild/treemon_maps.asm:7` -> `TREEMON_SET_FOREST`. + +Note SANDSLASH is Gold-only on Route 26; the walkthrough lists ARBOK, which is +the Silver slot. + +--- + +### MAP_ROUTE_26_HEAL_HOUSE + +- Script: `maps/Route26HealHouse.asm` +- Header: `data/maps/maps.asm:481` -> `map Route26HealHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_26, MUSIC_AZALEA_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:446` -> `map_const ROUTE_26_HEAL_HOUSE, 4, 4` +- Symbols: `60:60e6 Route26HealHouseTeacherScript` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_26` | 2 | +| 2 | 3 | 7 | `ROUTE_26` | 2 | + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `Route26HealHouseBookshelf` (`jumpstd PictureBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `Route26HealHouseBookshelf` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE26HEALHOUSE_TEACHER` | `SPRITE_TEACHER` | 2 | 3 | `STANDING_DOWN` | `SCRIPT` | `Route26HealHouseTeacherScript` | -1 | + +**Scripts of interest** + +`Route26HealHouseTeacherScript` (`60:60e6`): +`faceplayer` / `opentext` / `writetext Route26HealHouseRestAWhileText` / +`waitbutton` / `closetext` / `special FadeOutToBlack` / +`special ReloadSpritesNoPalettes` / `playmusic MUSIC_HEAL` / +`special HealParty` / `pause 60` / `special FadeInFromBlack` / +`special RestartMapMusic` / `writetext Route26HealHouseKeepAtItText` / `end`. + +**No flag guards this at all** - it is unconditional and repeatable, which is +what makes it a free Pokemon Center for a bot grinding Route 26. + +--- + +### MAP_DAY_OF_WEEK_SIBLINGS_HOUSE + +- Script: `maps/DayOfWeekSiblingsHouse.asm` +- Header: `data/maps/maps.asm:482` -> `map DayOfWeekSiblingsHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_26, MUSIC_AZALEA_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:447` -> `map_const DAY_OF_WEEK_SIBLINGS_HOUSE, 4, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_26` | 3 | +| 2 | 3 | 7 | `ROUTE_26` | 3 | + +**Coord events** / **BG events** + +Both empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DAYOFWEEKSIBLINGSHOUSE_POKEDEX` | `SPRITE_POKEDEX` | 3 | 3 | `STILL` | `SCRIPT` | `DayOfWeekSiblingsHousePokedexScript` | -1 | + +**Scripts of interest** + +`DayOfWeekSiblingsHousePokedexScript` is two nested `yesorno` prompts over +`DayOfWeekSiblingsHousePokedexText1/2/3`. Text 3 is Monica's roster, and it +matches the walkthrough exactly: Monday MONICA / ROUTE 40, Tuesday TUSCANY / +ROUTE 29, Wednesday WESLEY / LAKE OF RAGE, Thursday ARTHUR / ROUTE 36, and so +on. Pure text - no flags, no items. + +--- + +### MAP_VICTORY_ROAD_GATE (badge check only) + +Victory Road itself is section 17. Transcribed here because the gate is this +section's terminal blocker. + +- Script: `maps/VictoryRoadGate.asm` +- Header: `data/maps/maps.asm:467` -> `map VictoryRoadGate, TILESET_GATE, GATE, LANDMARK_ROUTE_26, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:433` -> `map_const VICTORY_ROAD_GATE, 10, 9` +- Symbols: `5f:4fdc VictoryRoadGateBadgeCheckScript`, `5f:4ff8 _VictoryRoadGateBadgeCheckScript.AllEightBadges` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 7 | `ROUTE_22` | 1 | +| 2 | 18 | 7 | `ROUTE_22` | 1 | +| 3 | 9 | 17 | `ROUTE_26` | 1 | +| 4 | 10 | 17 | `ROUTE_26` | 1 | +| 5 | 9 | 0 | `VICTORY_ROAD` | 1 | +| 6 | 10 | 0 | `VICTORY_ROAD` | 1 | +| 7 | 1 | 7 | `ROUTE_28` | 2 | +| 8 | 2 | 7 | `ROUTE_28` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_VICTORYROADGATE_BADGE_CHECK` (0) | 10 | 11 | `VictoryRoadGateBadgeCheckScript` | `turnobject PLAYER, LEFT` then the shared badge check | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VICTORYROADGATE_OFFICER` | `SPRITE_OFFICER` | 8 | 11 | `STANDING_RIGHT` | `SCRIPT` | `VictoryRoadGateOfficerScript` | -1 | +| `VICTORYROADGATE_BLACK_BELT1` | `SPRITE_BLACK_BELT` | 7 | 5 | `STANDING_RIGHT` | `SCRIPT` | `VictoryRoadGateLeftBlackBeltScript` | `EVENT_OPENED_MT_SILVER` | +| `VICTORYROADGATE_BLACK_BELT2` | `SPRITE_BLACK_BELT` | 12 | 5 | `STANDING_LEFT` | `SCRIPT` | `VictoryRoadGateRightBlackBeltScript` | `EVENT_FOUGHT_SNORLAX` | + +**Scripts of interest** + +`_VictoryRoadGateBadgeCheckScript`: + +``` +opentext / writetext VictoryRoadGateOfficerText / promptbutton +readvar VAR_BADGES +ifgreater NUM_JOHTO_BADGES - 1, .AllEightBadges ; badges > 7 +writetext VictoryRoadGateNotEnoughBadgesText / waitbutton / closetext +applymovement PLAYER, VictoryRoadGateStepDownMovement ; one step DOWN +end +.AllEightBadges: +writetext VictoryRoadGateEightBadgesText / waitbutton / closetext +setscene SCENE_VICTORYROADGATE_NOOP +end +``` + +`readvar VAR_BADGES` is a **popcount of the Johto badge byte**, not a badge +index. `setscene SCENE_VICTORYROADGATE_NOOP` (1) permanently disarms the coord +event once you pass. The two black belts are hidden until +`EVENT_OPENED_MT_SILVER` / `EVENT_FOUGHT_SNORLAX` +(`constants/event_flags.asm:1265`, `:1266`), so on a first pass neither is +present. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| New Bark Town -> Route 27 crossing | `engine/events/overworld.asm` `TrySurfOW` (`ld de, ENGINE_FOGBADGE` / `ld d, SURF` / `CheckPartyMove`) | SURF in the party **and** FOGBADGE | Beat Morty (section 07) | +| Route 27 east-half water (Blake onward, Rare Candy) | same `TrySurfOW` | SURF + FOGBADGE | as above | +| Tohjo Falls falls | `engine/events/overworld.asm` `TryWaterfallOW` (`ld d, WATERFALL` / `CheckPartyMove` then `ld de, ENGINE_RISINGBADGE` / `CheckEngineFlag`) plus `CheckMapCanWaterfall` (must face UP and the tile above must pass `CheckWaterfallTile`, `home/map_objects.asm:185`, i.e. `COLL_WATERFALL` $33 or `COLL_CURRENT_DOWN` $3b) | WATERFALL + RISINGBADGE | Beat Clair (section 13) | +| Route 27 whirlpool (island with TM22 + Jose) | `engine/events/overworld.asm` `TryWhirlpoolOW` (`ld d, WHIRLPOOL` / `CheckPartyMove`, then `ld de, ENGINE_GLACIERBADGE` / `CheckEngineFlag`, then `TryWhirlpoolMenu`) | WHIRLPOOL + GLACIERBADGE | Beat Pryce (section 11) | +| Master Ball from Elm | `maps/ElmsLab.asm` `ElmCheckMasterBall` -> `checkflag ENGINE_RISINGBADGE` | RISINGBADGE set, `EVENT_GOT_MASTER_BALL_FROM_ELM` clear, and a free bag slot (`verbosegiveitem` `iffalse`) | Beat Clair | +| Everstone from Elm | `maps/ElmsLab.asm` `ElmCheckEverstone` -> `EVENT_SHOWED_TOGEPI_TO_ELM`, otherwise `setval TOGEPI` / `special FindPartyMonThatSpeciesYourTrainerID` (and `TOGETIC`), gated on `EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE` | Togepi or Togetic **in the party**, hatched by you (OT check) | Withdraw it from the PC before talking | +| TM37 SANDSTORM | `maps/Route27SandstormHouse.asm` `SandstormHouseWoman` -> `special GetFirstPokemonHappiness` / `ifgreater 150 - 1` | first non-egg party member's happiness >= 150 | Walk/level/haircut the lead mon | +| Victory Road Gate | `maps/VictoryRoadGate.asm` `_VictoryRoadGateBadgeCheckScript` -> `readvar VAR_BADGES` / `ifgreater NUM_JOHTO_BADGES - 1` | all eight Johto badges | Fails soft: you get pushed one step DOWN by `VictoryRoadGateStepDownMovement`, the gate is not warp-locked | +| Route 45 / 46 Dark Cave side trips | plain warps, no check | - | none; the Dark Cave interior gates are section 15's | + +Nothing on Routes 45, 46 or 26 is gated by an `EVENT_*` or a badge. Route 27 is +the only map in this section where a field move blocks forward progress. + +--- + +## 4. Bot checklist + +Preconditions for the whole section: eight Johto badges, SURF + WATERFALL + +WHIRLPOOL in the party, at least a few free bag slots. + +Route 45 (entered from Blackthorn's south edge): + +1. `MAP_ROUTE_45` - walk south from the north connection. Optional: warp 1 at + (2, 5) into Dark Cave Blackthorn Entrance. +2. Item ball `ROUTE45_POKE_BALL3` at (4, 21) -> ELIXER. Post: `EVENT_ROUTE_45_ELIXER`. +3. Trainer `ROUTE45_POKEFAN_M3` at (5, 28), sight 3 -> Hiker Parry (`PARRY3`, + 29 ONIX). Post: `EVENT_BEAT_HIKER_PARRY`. Talk again twice for + `PHONE_HIKER_PARRY`. +4. Trainer `ROUTE45_POKEFAN_M1` at (10, 16), sight 1 -> Hiker Erik. + Post: `EVENT_BEAT_HIKER_ERIK`. +5. Trainer `ROUTE45_COOLTRAINER_M` at (17, 18), sight 1 -> Cooltrainer Ryan. + Post: `EVENT_BEAT_COOLTRAINERM_RYAN`. +6. Item ball `ROUTE45_POKE_BALL4` at (8, 33) -> MAX_POTION. + Post: `EVENT_ROUTE_45_MAX_POTION`. +7. Trainer `ROUTE45_COOLTRAINER_F` at (4, 36), sight 3 -> Cooltrainer Kelly. + Post: `EVENT_BEAT_COOLTRAINERF_KELLY`. +8. Trainer `ROUTE45_BLACK_BELT` at (11, 50), sight 2, spins -> Blackbelt Kenji. + Post: `EVENT_BEAT_BLACKBELT_KENJI`. Second talk sets + `EVENT_KENJI_ASKED_FOR_PHONE_NUMBER` and offers `PHONE_BLACKBELT_KENJI`. +9. Item ball `ROUTE45_POKE_BALL1` at (6, 51) -> X_SPECIAL. + Post: `EVENT_ROUTE_45_X_SPECIAL`. +10. Trainer `ROUTE45_POKEFAN_M4` at (9, 64), sight 1 -> Hiker Timothy. + Post: `EVENT_BEAT_HIKER_TIMOTHY`. +11. Trainer `ROUTE45_POKEFAN_M2` at (15, 64), sight 2 -> Hiker Michael. + Post: `EVENT_BEAT_HIKER_MICHAEL`. (The walkthrough never names him.) +12. Item ball `ROUTE45_POKE_BALL2` at (6, 66) -> REVIVE. + Post: `EVENT_ROUTE_45_REVIVE`. +13. Hidden item: face the bg event at (13, 80), press A -> PP_UP. + Post: `EVENT_ROUTE_45_HIDDEN_PP_UP`. +14. Fruit tree `ROUTE45_FRUIT_TREE` at (16, 82) -> MYSTERYBERRY (daily). +15. Cross the west connection into `MAP_ROUTE_46` (block offset 36). + +Route 46: + +16. Trainer `ROUTE46_POKEFAN_M` at (12, 18), sight 2 -> Hiker Bailey (five + 13 GEODUDE). Post: `EVENT_BEAT_HIKER_BAILEY`. +17. Trainer `ROUTE46_YOUNGSTER` at (3, 13), sight 4 -> Camper Ted. + Post: `EVENT_BEAT_CAMPER_TED`. +18. Item ball `ROUTE46_POKE_BALL` at (0, 12) -> DIRE_HIT. + Post: `EVENT_ROUTE_46_DIRE_HIT`. +19. Trainer `ROUTE46_LASS` at (1, 15), sight 4 -> Picnicker Erin. + Post: `EVENT_BEAT_PICNICKER_ERIN`. Second talk offers `PHONE_PICNICKER_ERIN`. +20. Fruit trees at (7, 5) -> BERRY and (8, 6) -> PRZCUREBERRY (daily). +21. Optional warp 3 at (14, 5) -> Dark Cave Violet Entrance. +22. Exit south via warps 1/2 at (7, 33) / (8, 33) into + `MAP_ROUTE_29_ROUTE_46_GATE`, or the south connection to Route 29. + +New Bark Town: + +23. FLY to `MAP_NEW_BARK_TOWN`; warp 1 at (6, 3) -> `MAP_ELMS_LAB`. +24. Talk to `ELMSLAB_ELM` at (5, 2). Pre: `ENGINE_RISINGBADGE` set, + `EVENT_GOT_MASTER_BALL_FROM_ELM` clear, free bag slot. + Post: `EVENT_GOT_MASTER_BALL_FROM_ELM`, MASTER_BALL in bag. +25. Withdraw Togepi/Togetic into the party at a PC, return to Elm, talk again. + Post: `EVENT_SHOWED_TOGEPI_TO_ELM`, `EVENT_GOT_EVERSTONE_FROM_ELM`, + EVERSTONE in bag. +26. Optional: `MAP_PLAYERS_HOUSE_1F` warp 2 at (13, 5), talk to Mom -> + `MomScript.BankOfMom` -> `special BankOfMom` to withdraw savings. +27. Leave New Bark Town east; the connection to `MAP_ROUTE_27` is water. Use + SURF (FOGBADGE required). + +Route 27, first pass: + +28. Walking onto cell (18, 10) or (19, 10) fires the coord event + `FirstStepIntoKantoLeftScene` / `...RightScene`. It is unskippable; wait it + out. Post: map scene = `SCENE_ROUTE27_NOOP`. +29. Optional: item ball `ROUTE27_POKE_BALL2` at (53, 12) -> RARE_CANDY, in the + southern water. Post: `EVENT_ROUTE_27_RARE_CANDY`. (See section 6 - the + walkthrough puts this before Tohjo Falls; the coordinate is well east of + the west cave mouth.) +30. Warp 2 at (26, 5) -> `MAP_TOHJO_FALLS` warp 1. + +Tohjo Falls: + +31. Surf west, use WATERFALL facing UP where the tile above is `COLL_WATERFALL`. + Requires RISINGBADGE. +32. Item ball `TOHJOFALLS_POKE_BALL` at (2, 6) -> MOON_STONE. + Post: `EVENT_TOHJO_FALLS_MOON_STONE`. +33. Exit via warp 2 at (25, 15) -> `MAP_ROUTE_27` warp 3 (36, 5). + +Route 27, second pass: + +34. Trainer `ROUTE27_COOLTRAINER_F2` at (37, 6), sight 2, spins -> Cooltrainer + Megan. Post: `EVENT_BEAT_COOLTRAINERF_MEGAN`. +35. Warp 1 at (33, 7) -> `MAP_ROUTE_27_SANDSTORM_HOUSE`. Talk to + `ROUTE27SANDSTORMHOUSE_GRANNY` at (2, 4). Pre: lead party mon happiness + >= 150 and a free bag slot. Post: `EVENT_GOT_TM37_SANDSTORM`, TM37 in bag. +36. Surf east. Trainer `ROUTE27_COOLTRAINER_M1` at (49, 7), sight 3 -> + Cooltrainer Blake. Post: `EVENT_BEAT_COOLTRAINERM_BLAKE`. +37. Trainer `ROUTE27_COOLTRAINER_M2` at (58, 6), sight 5 -> Cooltrainer Brian. + Post: `EVENT_BEAT_COOLTRAINERM_BRIAN`. +38. Trainer `ROUTE27_YOUNGSTER1` at (64, 7), sight 5 -> Psychic Gilbert. + Post: `EVENT_BEAT_PSYCHIC_GILBERT`. +39. Surf south, use WHIRLPOOL on the whirlpool block (GLACIERBADGE required). +40. Trainer `ROUTE27_YOUNGSTER2` at (58, 13), sight 3 -> Bird Keeper Jose. + Post: `EVENT_BEAT_BIRD_KEEPER_JOSE2`. Second talk offers + `PHONE_BIRDKEEPER_JOSE`. +41. Item ball `ROUTE27_POKE_BALL1` at (60, 12) -> TM_SOLARBEAM. + Post: `EVENT_ROUTE_27_TM_SOLARBEAM`. +42. Trainer `ROUTE27_COOLTRAINER_F1` at (72, 11), sight 5 -> Cooltrainer Reena. + Post: `EVENT_BEAT_COOLTRAINERF_REENA`. Second talk offers + `PHONE_COOLTRAINERF_REENA`. +43. East connection into `MAP_ROUTE_26` (block offset -45). + +Route 26 (walk north, y decreasing): + +44. Trainer `ROUTE26_FISHER` at (10, 92), sight 3 -> Fisher Scott. + Post: `EVENT_BEAT_FISHER_SCOTT`. +45. Trainer `ROUTE26_YOUNGSTER` at (13, 79), sight 4 -> Psychic Richard. + Post: `EVENT_BEAT_PSYCHIC_RICHARD`. +46. Optional: warp 3 at (5, 71) -> `MAP_DAY_OF_WEEK_SIBLINGS_HOUSE`, read the + `SPRITE_POKEDEX` object at (3, 3) for Monica's roster. No flags. +47. Warp 2 at (15, 57) -> `MAP_ROUTE_26_HEAL_HOUSE`, talk to + `ROUTE26HEALHOUSE_TEACHER` at (2, 3) for a free full heal. Repeatable, no + flag. +48. Trainer `ROUTE26_COOLTRAINER_F1` at (10, 56), sight 3, spins -> Cooltrainer + Joyce. Post: `EVENT_BEAT_COOLTRAINERF_JOYCE`. +49. Fruit tree `ROUTE26_FRUIT_TREE` at (14, 54) -> ICE_BERRY (daily). +50. Trainer `ROUTE26_COOLTRAINER_M2` at (9, 38), sight 5 -> Cooltrainer Gaven. + Post: `EVENT_BEAT_COOLTRAINERM_GAVEN`. Second talk offers + `PHONE_COOLTRAINERM_GAVEN`. +51. Trainer `ROUTE26_COOLTRAINER_M1` at (11, 16), sight 2 -> Cooltrainer Jake. + Post: `EVENT_BEAT_COOLTRAINERM_JAKE`. +52. Item ball `ROUTE26_POKE_BALL` at (9, 15) -> MAX_ELIXER. + Post: `EVENT_ROUTE_26_MAX_ELIXER`. +53. Trainer `ROUTE26_COOLTRAINER_F2` at (5, 8), sight 4 -> Cooltrainer Beth. + Post: `EVENT_BEAT_COOLTRAINERF_BETH`. Second talk offers + `PHONE_COOLTRAINERF_BETH`. +54. Warp 1 at (7, 5) -> `MAP_VICTORY_ROAD_GATE` warp 3. +55. In the gate, step onto (10, 11). Pre: `VAR_BADGES` >= 8. On pass the map + scene becomes `SCENE_VICTORYROADGATE_NOOP` and warps 5/6 at (9, 0) / + (10, 0) lead into `MAP_VICTORY_ROAD` (section 17). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map blocks, warps, coord events, bg events, object events for every map here | `src/import/RomExtractorGen2.lua:787-980` (`warps` / `coordEvents` / `bgEvents` / `objects` readers), `src/world/gen2/Map.lua`, `src/world/gen2/World.lua:5013` (coord events), `:5257` (`World:interact`) | implemented, data-driven - nothing per-map is hand-written, so these routes come for free once the ROM is imported | +| Map connections (Blackthorn -> R45 -> R46, New Bark -> R27 -> R26) | `src/world/gen2/World.lua:407-430` | implemented | +| Overworld trainer struct, eyesight, battle, beat flag | `src/world/gen2/Trainers.lua`, `src/import/RomExtractorGen2.lua:2963-2968` (reads `OBJECTTYPE_TRAINER` headers), driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| Trainer parties incl. `TRAINERTYPE_MOVES` rows (Timothy, Ryan, Blake, Brian, Joyce, Gaven, Jake, Beth, Megan) | `src/world/gen2/Trainers.lua` party build over the extracted trainer table | implemented | +| Phone registration + rematch flags for Kenji, Parry, Erin, Jose, Reena, Gaven, Beth | `src/core/gen2/Phone.lua:427-455` (`Phone.REMATCH_EVENTS`, contacts 11/12/13/14/34/35/36 present) | implemented | +| `askforphonenumber` / `checkcellnum` opcodes | `src/script/gen2/Opcodes.lua:156`, `:47` | implemented | +| Fruit trees (Mystery Berry, Berry, PrzCure Berry, Ice Berry) | `src/script/gen2/Opcodes.lua:160` (`fruittree`), `src/core/gen2/Apricorns.lua:351-460` (item lookup, picked flag, daily reset) | implemented | +| Hidden item PP UP at Route 45 (13, 80) | `src/world/gen2/HiddenItems.lua`, consumed at `src/world/gen2/World.lua:5285` | implemented | +| **Item ball pickup** (all nine `OBJECTTYPE_ITEMBALL` objects in this section) | extractor stores `obj.itemball` (`src/import/RomExtractorGen2.lua:2968`) but nothing in `src/world/` reads it - `World:interact` only dispatches on `npc.def.trainer`, strength boulders, `npc.def.scriptKey` and `HiddenItems` | **missing** - walking up to a Poke Ball object and pressing A does nothing. This blocks ELIXER, MAX_POTION, X_SPECIAL, REVIVE, DIRE_HIT, RARE_CANDY, TM_SOLARBEAM, MOON_STONE, MAX_ELIXER | +| SURF gate (FOGBADGE) for the New Bark -> Route 27 crossing | `src/world/gen2/FieldMoves.lua:103-110` (`FieldMoves.BADGE.SURF = "FOG"`), driver `tests/drivers/gold_water_moves.lua` | implemented | +| WHIRLPOOL gate (GLACIERBADGE) + block replacement for the Route 27 island | `src/world/gen2/FieldMoves.lua:219-251`, `World:tryWhirlpoolOW`, driver `tests/drivers/gold_water_moves.lua` | implemented | +| WATERFALL climb (RISINGBADGE) in Tohjo Falls | `src/world/gen2/FieldMoves.lua:258-266`, `src/world/gen2/World.lua:4037-4046` (`runWaterfall` / `waterfallStep`), `:4217` (`tryWaterfallOW`), `src/world/gen2/Permissions.lua:136` (`COLL_WATERFALL` $33 + `COLL_CURRENT_DOWN` $3b) | implemented; **no driver** covers it (`gold_water_moves.lua` is SURF + WHIRLPOOL only) | +| Route 27 "first step into Kanto" coord event + scene | `src/world/gen2/World.lua:5013` (coord event scan), `:1161-1183` (`World:scene`), `src/script/gen2/Opcodes.lua:25` (`setscene`), `:122-123` (`showemote`, `turnobject`), `:110` (`applymovement`) | implemented | +| Pokegear region flip to Kanto after that scene | `src/ui/gen2/Pokegear.lua:1070`, `:1658` (region follows the player's landmark, `cp KANTO_LANDMARK`) | implemented | +| Sandstorm house happiness check | `src/script/gen2/Specials.lua:1506` (`GetFirstPokemonHappiness`), `src/script/gen2/Opcodes.lua:163` (`verbosegiveitem`), `src/core/gen2/Happiness.lua` | implemented | +| Route 26 heal house | `src/script/gen2/Specials.lua:450` (`HealParty`), `:999` (`FadeOutToBlack`), `:1025` (`ReloadSpritesNoPalettes`), `:1059` (`RestartMapMusic`) | implemented | +| Elm Master Ball / Everstone (`checkflag ENGINE_RISINGBADGE`, `FindPartyMonThatSpeciesYourTrainerID`) | `src/script/gen2/Specials.lua:1134`, `src/script/gen2/Opcodes.lua:163` | implemented | +| Mom's savings (`special BankOfMom`) | `src/ui/gen2/BankOfMom.lua` | implemented | +| Victory Road gate badge check (`readvar VAR_BADGES`) | `src/world/gen2/World.lua:117` (`VAR_BADGES = 0x07`), `:1240` (popcount read) | implemented | +| Roaming beasts on Routes 45/46 | `src/core/gen2/Roamers.lua`, driver `tests/drivers/gold_roamers.lua` | implemented | +| Wild encounter tables (grass morn/day/nite, water, fish, treemons) | `src/battle/gen2/Encounter.lua` over the extracted encounter tables | implemented | + +--- + +## 6. Unresolved / verify by hand + +1. **"Camper Quentin" does not exist in pokegold.** The walkthrough puts a + Camper Quentin on Route 45 with 27 FEAROW / 30 PRIMEAPE / 30 TAUROS. There + is no `QUENTIN` constant anywhere in the checkout + (`grep -rn QUENTIN /Users/bryanbassett/Documents/development/pokegold` is + empty), no camper object on `maps/Route45.asm`, and the only TAUROS line in + `data/trainers/parties.asm` is a level 35 one in `CooltrainerMGroup` "SEAN". + The Route 45 trainer the walkthrough is probably confusing this with is + Hiker Michael (25 GEODUDE / 25 GRAVELER / 25 GOLEM), whom it never mentions. +2. **"A Revive and a Nugget" on Route 45.** There is no NUGGET on Route 45. + The four item balls are X_SPECIAL, REVIVE, ELIXER and MAX_POTION, and the + only hidden item is `hiddenitem PP_UP, EVENT_ROUTE_45_HIDDEN_PP_UP` at + (13, 80). Treat "Nugget" as an error and pick up the PP UP instead. +3. **Route 27 Rare Candy placement.** The walkthrough says to surf "southeast + of the cave entrance" *before* entering Tohjo Falls. The item ball is at + cell (53, 12), which is east of the *eastern* Tohjo exit (36, 5) and far + east of the western entrance (26, 5). Whether the southern water is + continuous from the landing point (~x 18-21) all the way to x 53 was not + verified - it would need reading `maps/Route27.blk` against the + `TILESET_JOHTO` collision table. A bot should simply grab it on the way to + Blake rather than trusting the stated ordering. +4. **Cooltrainer Gaven's second mon.** The walkthrough says Krabby; the asm + says `db 32, KINGLER` in `CooltrainerMGroup` entry 10. +5. **Cooltrainer Reena's party order.** Walkthrough: STARMIE, STARMIE, + NIDOQUEEN. Asm (`CooltrainerFGroup` entry 10): 31 STARMIE, 33 NIDOQUEEN, + 31 STARMIE. +6. **Hiker Erik's party order.** Walkthrough: Machop, Machop, Graveler. Asm + (`HikerGroup` entry 7): 24 MACHOP, 27 GRAVELER, 27 MACHOP. +7. **Wild lists are incomplete in the walkthrough.** Route 46 also has + JIGGLYPUFF (3 and 5); Tohjo Falls also has ZUBAT, GOLBAT, RATICATE and + GOLDEEN; Route 26/27's ARBOK entries are Silver-only while SANDSLASH and + DODRIO are the Gold slots. The walkthrough's per-route lists appear to be a + Gold/Silver merge. +8. **Tohjo Falls MOON STONE is never mentioned.** `TohjoFallsMoonStone` at + (2, 6) is the only object on the map. +9. **"Head down the other waterfall".** There is no downward WATERFALL move in + the engine. Descending is a `COLL_CURRENT_DOWN` ($3b) tile that carries the + surfing player; `CheckWaterfallTile` (`home/map_objects.asm:185`) accepts + both `COLL_WATERFALL` and `COLL_CURRENT_DOWN` precisely so the climb loop + keeps running. Whether the port's `World:waterfallStep` handles the descend + direction as well as the climb was not verified. +10. **Kenji's phone number timing.** The walkthrough says "talk to him again + and he will give you his phone number", which matches - but the asm needs + *two* post-battle talks: the first sets + `EVENT_KENJI_ASKED_FOR_PHONE_NUMBER` and runs `AskNumber1MScript`, and + `askforphonenumber` only runs after that `scall` returns. Bots should + budget two A-press cycles per phone trainer, not one. +11. The walkthrough's Route 45 "LEFT PATH ... Elixir ... one trainer (Hiker + Parry)" reads as if the Elixir is inside Dark Cave. Both the Elixir + (4, 21) and Parry (5, 28) are on Route 45 itself. There *is* a separate + hidden Elixer inside Dark Cave (`maps/DarkCaveVioletEntrance.asm` + `bg_event 26, 3, BGEVENT_ITEM, DarkCaveVioletEntranceHiddenElixer`), which + belongs to section 15. diff --git a/docs/gold-walkthrough/asm-walk/section-17-victory-road.md b/docs/gold-walkthrough/asm-walk/section-17-victory-road.md new file mode 100644 index 00000000..b7bbe653 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-17-victory-road.md @@ -0,0 +1,599 @@ +# Section 17 - Victory Road + +Source: `../section-17-victory-road.txt` (the FAQ numbers this chapter "23 > Victory Road") +Maps covered: `MAP_VICTORY_ROAD`, `MAP_VICTORY_ROAD_GATE` (entry gate, badge check) +Badges / key milestones in this section: no badge. Milestones are the fifth and +final `RIVAL1` battle (`EVENT_RIVAL_VICTORY_ROAD`), TM26 Earthquake, and the +north exit onto Route 23 / Indigo Plateau. + +Important structural fact the walkthrough hides: **Gold/Silver Victory Road is a +single 10x36-block map**, not three floors. What the FAQ calls "first / second / +third floor" are three vertically stacked regions of the same map joined by +`warp_event` rows whose destination map is `VICTORY_ROAD` itself. Every "ladder" +and the "hole" are self-warps inside `maps/VictoryRoad.asm`. + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_VICTORY_ROAD_GATE` | `maps/VictoryRoadGate.asm` | gate warps 3/4 at (9,17)/(10,17), arriving from Route 26 (`maps/Route26.asm:397` warps to `VICTORY_ROAD_GATE, 3`) | gate warps 5/6 at (9,0)/(10,0) -> `VICTORY_ROAD` warp 1 | 8-badge check at coord event (10,11); belongs to the previous section but is the only door in | +| 1 | `MAP_VICTORY_ROAD` (entrance region, y 48..71) | `maps/VictoryRoad.asm` | warp 1 at (9,67) | warp 2, the ladder at (1,49) | "Head up the stairway and up the cliff"; Full Heal (15,48) and Max Revive (12,48) sit here, plus the hidden Full Heal at (3,65) | +| 2 | `MAP_VICTORY_ROAD` (mid region, y 32..43) | same | warp 3 at (1,35) | warp 4, the ladder at (13,31) | "second floor" - X Special at (7,38), then "up along the path ... another ladder" | +| 3 | `MAP_VICTORY_ROAD` (top region, y 5..21) | same | warp 5 at (13,17) | warp 7, the ladder at (17,19) | "Save when you get to the third floor. Go up the cliff to your left, go down the ladder" | +| 4 | `MAP_VICTORY_ROAD` (Full Restore shelf, y 28..34) | same | warp 6 at (17,33) | one-way `HOP_DOWN` ledge row y=34, x=16..19, landing on y=36 | "grab the Full Restore, and jump down" | +| 5 | `MAP_VICTORY_ROAD` (mid region again) | same | ledge landing (x,36) | warp 4 at (13,31) | "Head back up the ladder to the third floor" | +| 6 | `MAP_VICTORY_ROAD` (top region) | same | warp 5 at (13,17) | coord events at (12,8) / (13,8) | rival ambush, `VictoryRoadRivalLeft` / `VictoryRoadRivalRight` | +| 7 | `MAP_VICTORY_ROAD` (TM pocket, y 26..30) | same | warp 8, the `COLL_PIT` at (0,11) | warp 8's landing is warp 9 at (0,27); leave by `HOP_RIGHT` at (8,28)->(10,28) or `HOP_DOWN` at (2,30)/(3,30)->y=32 | "go left instead of out the door, and fall down the hole to get TM26 Earthquake" | +| 8 | `MAP_VICTORY_ROAD` exit | same | corridor x=12..13 at y=7..8, x=11..14 at y=6 | warp 10 at (13,5) -> `ROUTE_23` warp 3 | leave the dungeon | +| 9 | `MAP_ROUTE_23` | `maps/Route23.asm` | warp 3 at (9,13) | - | **next section** (Indigo Plateau / Pokemon League). `Route23FlypointCallback` sets `ENGINE_FLYPOINT_INDIGO_PLATEAU` on arrival. Stop here. | + +## 2. Maps + +### MAP_VICTORY_ROAD + +- Script: `maps/VictoryRoad.asm` (included from `data/maps/scripts.asm:24`) +- Blocks: `maps/VictoryRoad.blk` (360 bytes; `data/maps/blocks.asm:962`) +- Header: `data/maps/maps.asm:160` + `map VictoryRoad, TILESET_CAVE, CAVE, LANDMARK_VICTORY_ROAD, MUSIC_VICTORY_ROAD, TRUE, PALETTE_NITE, FISHGROUP_SHORE` + (per the `map` macro at the head of `data/maps/maps.asm`: tileset, environment, + location, music, phone-service flag TRUE = phone calls suppressed, time-of-day + palette, fishing group.) +- Attributes: `data/maps/attributes.asm:494` `map_attributes VictoryRoad, VICTORY_ROAD, $1d` - border block `$1d`, and **no `connection` rows follow**, so the map has no overworld connections. +- Dimensions: `constants/map_constants.asm:147` `map_const VICTORY_ROAD, 10, 36` -> 10x36 blocks = **20x72 walk cells**. Group `DUNGEONS` (group 3), map id 82. +- Scene variable: `data/maps/scenes.asm:60` `scene_var VICTORY_ROAD, wVictoryRoadSceneID` (sym `01:d6eb`) +- Callbacks: `def_callbacks` is empty - no `MAPCALLBACK_*` for this map. +- Connections: none. + +**Warps** (`def_warp_events`, transcribed from `maps/VictoryRoad.asm:242-252`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 67 | `VICTORY_ROAD_GATE` | 5 | +| 2 | 1 | 49 | `VICTORY_ROAD` | 3 | +| 3 | 1 | 35 | `VICTORY_ROAD` | 2 | +| 4 | 13 | 31 | `VICTORY_ROAD` | 5 | +| 5 | 13 | 17 | `VICTORY_ROAD` | 4 | +| 6 | 17 | 33 | `VICTORY_ROAD` | 7 | +| 7 | 17 | 19 | `VICTORY_ROAD` | 6 | +| 8 | 0 | 11 | `VICTORY_ROAD` | 9 | +| 9 | 0 | 27 | `VICTORY_ROAD` | 8 | +| 10 | 13 | 5 | `ROUTE_23` | 3 | + +Self-warp pairs, as a bot should read them: 2<->3, 4<->5, 6<->7, 8->9. +Warp 8's tile is `COLL_PIT` and warp 9's tile is plain `COLL_FLOOR`, so **warp 9 +never fires**: the hole at (0,11) is one-way down to (0,27). (Tile kinds verified +below.) + +**Coord events** (`def_coord_events`, `maps/VictoryRoad.asm:254-256`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_VICTORYROAD_RIVAL_BATTLE` (0) | 12 | 8 | `VictoryRoadRivalLeft` (sym `42:59ba`) | rival is teleported to (18,11), walks LEFTx6 + UPx2 to (12,9), battle, then leaves DOWNx2 + RIGHTx6 | +| `SCENE_VICTORYROAD_RIVAL_BATTLE` (0) | 13 | 8 | `VictoryRoadRivalRight` (sym `42:59dd`) | rival walks from (18,13) UPx2 + LEFTx5 + UPx2 to (13,9), battle, then leaves DOWNx2 + RIGHTx5 + DOWNx2 | + +These two tiles are the entire width of the corridor that leads to the exit +(y=8 is walkable only at x=6,7 and x=12,13), so the ambush is unavoidable. + +**BG events** (`def_bg_events`, `maps/VictoryRoad.asm:258-260`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 29 | `BGEVENT_ITEM` | `VictoryRoadHiddenMaxPotion` = `hiddenitem MAX_POTION, EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION` | +| 3 | 65 | `BGEVENT_ITEM` | `VictoryRoadHiddenFullHeal` = `hiddenitem FULL_HEAL, EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL` | + +Both target tiles are `COLL_WALL`, i.e. the player stands next to them and +presses A into the wall. (3,29) is faced from (2,29), (4,29) or (3,28); +(3,65) is faced from (2,65), (4,65), (3,64) or (3,66). + +**Object events** (`def_object_events`, `maps/VictoryRoad.asm:262-268`) + +Column order follows the `object_event` macro in `macros/scripts/maps.asm:113`: +x, y, sprite, movement, radius x, radius y, hour1, hour2, palette, type, sight, +script, event flag. + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VICTORYROAD_RIVAL` | `SPRITE_RIVAL` | 18 | 13 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` (shared ROM0 stub, sym `00:2812`) | `EVENT_RIVAL_VICTORY_ROAD` | +| `VICTORYROAD_POKE_BALL1` | `SPRITE_POKE_BALL` | 3 | 28 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VictoryRoadTMEarthquake` = `itemball TM_EARTHQUAKE` | `EVENT_VICTORY_ROAD_TM_EARTHQUAKE` | +| `VICTORYROAD_POKE_BALL2` | `SPRITE_POKE_BALL` | 12 | 48 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VictoryRoadMaxRevive` = `itemball MAX_REVIVE` | `EVENT_VICTORY_ROAD_MAX_REVIVE` | +| `VICTORYROAD_POKE_BALL3` | `SPRITE_POKE_BALL` | 18 | 29 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VictoryRoadFullRestore` = `itemball FULL_RESTORE` | `EVENT_VICTORY_ROAD_FULL_RESTORE` | +| `VICTORYROAD_POKE_BALL4` | `SPRITE_POKE_BALL` | 15 | 48 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VictoryRoadFullHeal` = `itemball FULL_HEAL` | `EVENT_VICTORY_ROAD_FULL_HEAL` | +| `VICTORYROAD_POKE_BALL5` | `SPRITE_POKE_BALL` | 7 | 38 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `VictoryRoadXSpecial` = `itemball X_SPECIAL` | `EVENT_VICTORY_ROAD_X_SPECIAL` | + +All rows use radius 0,0, hours -1,-1 (always present), palette 0, sight 0. + +Object-flag polarity (this bites bot authors): `CheckObjectFlag` in +`engine/overworld/map_objects_2.asm:32` masks an object when its event flag is +**set**, and shows it when the flag is clear or is `-1`. `Script_appear` clears +the flag, `Script_disappear` sets it (`engine/overworld/scripting.asm:879-897`). +`InitializeEventsScript` in `engine/events/std_scripts.asm:521` sets +`EVENT_RIVAL_VICTORY_ROAD` at new game, so the rival object starts hidden and is +only revealed by the `appear` inside the ambush script. + +**Derived collision map** (not verbatim asm - built by decoding +`maps/VictoryRoad.blk` through `data/tilesets/cave_collision.asm` and +`data/collision/collision_permissions.asm`, with the quadrant index +`(x & 1) + 2 * (y & 1)` taken from `GetCoordTileCollision` in +`home/map.asm:2065`). It is included because Victory Road is the one map in this +section where "which tile can I stand on" is the whole problem. + +Verification: all ten `warp_event` tiles land on the expected collision +(`WARP_CARPET_DOWN`, six `LADDER`, `PIT`, `FLOOR`, `CAVE`) and all six +`object_event` tiles land on walkable tiles, so the decode is aligned. + +Legend: `.` floor, `#` wall (impassable), `^` `COLL_UP_WALL` (walkable, but you +cannot step *down* into it and cannot step *up* off it), `L` ladder, `P` pit, +`C` cave exit, `W` warp carpet (needs a DOWN press), `<` `HOP_LEFT`, +`>` `HOP_RIGHT`, `v` `HOP_DOWN` (all three are walkable land; pressing the +matching direction while standing on one jumps two tiles - see `.TryJump` and +`.ledge_table` in `engine/overworld/player_movement.asm:354-391`, which reads the +tile the player is **standing on**, not the tile ahead). + +``` + 01234567890123456789 + 0 .................... + 1 .................... + 2 .................... + 3 .................... + 4 .................... + 5 #############C###### + 6 ###########....##### + 7 ############..###### + 8 ######..####..###### + 9 #####....##....##### + 10 ......##...........# + 11 P....####........... + 12 ...######^^^^^^^^#.. + 13 #########........#.. + 14 #^^^^^^##........#.. + 15 #......##..#####.#.. + 16 #......##..#........ + 17 ###.#..##..#.L..#### + 18 #...#......#####..## + 19 #...##########...L## + 20 ##......>#.......### + 21 ###.....>#.....##### + 22 #################### + 23 #################### + 24 #################### + 25 #################### + 26 #...################ + 27 .......############# + 28 ........>#...####### + 29 ...#....>#....####.# + 30 ##vv..#####...####.. + 31 ###########..L###... + 32 ##..########..##.... + 33 ....########..##.L.. + 34 ..#^^^^^^^^#...#vvvv + 35 .L#........#...##### + 36 ..#........#........ + 37 ..###.###..##....... + 38 ......#<#...^^^^^#.. + 39 #..#..#<#........#.. + 40 ##....#<#........#.. + 41 ####..#<#####.####.. + 42 #######............. + 43 ##########........## + 44 #################### + 45 #################### + 46 #################### + 47 #################### + 48 ....########.##..### + 49 .L..########.###..## + 50 #.....####........## + 51 #......#####..#....# + 52 #...#^^^^^^^^^^^^#.. + 53 #...#............#.. + 54 ##..#............#.. + 55 ##..###.###..##.##.. + 56 ##........#..##..... + 57 ##........#..##....# + 58 ##.##^^####..####### + 59 ##..#..####..####### + 60 ##vv#...^^...####### + 61 #####........####### + 62 ##..#........######^ + 63 ##..###.###########. + 64 #....#..##.....####. + 65 #..#....#......####. + 66 ##............###^.. + 67 ######...W##.####... + 68 #######^^#^^^^^^.... + 69 #######............. + 70 ^^^^^^.............. + 71 .................... +``` + +One-way transitions a bot must respect (all derived from the grid above): + +- (0,11) `PIT` -> (0,27). No route back up from (0,27); the return is a ledge. +- (8,28) or (8,29) `HOP_RIGHT` + RIGHT -> (10,28) / (10,29), which reaches the (13,31) ladder. This is the short way out of the TM pocket. +- (2,30) or (3,30) `HOP_DOWN` + DOWN -> (2,32) / (3,32), which drops into the pocket that holds the (1,35) ladder. This is the long way out of the TM pocket. +- (16..19, 34) `HOP_DOWN` + DOWN -> (16..19, 36). This is the only exit from the Full Restore shelf, and it is why the FAQ says "jump down". +- (8,20) or (8,21) `HOP_RIGHT` + RIGHT -> (10,20) / (10,21) in the top region. +- Rows of `^` (`COLL_UP_WALL`) are walkable dead strips: you may enter them from the side or from below and leave sideways or downward, never upward, and you can never step down onto one. + +**Scripts of interest** + +- `VictoryRoadRivalLeft` (`maps/VictoryRoad.asm:22`, sym `42:59ba`) + `moveobject VICTORYROAD_RIVAL, 18, 11` / `turnobject PLAYER, DOWN` / + `showemote EMOTE_SHOCK, PLAYER, 15` / `special FadeOutMusic` / `pause 15` / + `appear VICTORYROAD_RIVAL` / `applymovement` `VictoryRoadRivalBattleApproachMovement1` + (LEFT x6, UP x2) / `scall VictoryRoadRivalNext` / + `applymovement VictoryRoadRivalBattleExitMovement1` (DOWN x2, RIGHT x6) / + `disappear VICTORYROAD_RIVAL` / `setscene SCENE_VICTORYROAD_NOOP` / + `playmapmusic` / `end`. +- `VictoryRoadRivalRight` (`maps/VictoryRoad.asm:37`, sym `42:59dd`) - identical + except there is no `moveobject` (he starts at his object_event position 18,13), + the approach is `VictoryRoadRivalBattleApproachMovement2` (UP x2, LEFT x5, + UP x2) and the exit is `VictoryRoadRivalBattleExitMovement2` (DOWN x2, + RIGHT x5, DOWN x2). +- `VictoryRoadRivalNext` (`maps/VictoryRoad.asm:51`, sym `42:59fc`) - the shared + body. `turnobject PLAYER, DOWN`, `playmusic MUSIC_RIVAL_ENCOUNTER`, text + `VictoryRoadRivalBeforeText`, then `setevent EVENT_RIVAL_VICTORY_ROAD` + (**before** the battle, so a reset or blackout mid-battle still leaves the + object hidden), then a three-way starter branch: + - `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> `.GotTotodile` -> `loadtrainer RIVAL1, RIVAL1_5_CHIKORITA` + - `checkevent EVENT_GOT_CHIKORITA_FROM_ELM` -> `.GotChikorita` -> `loadtrainer RIVAL1, RIVAL1_5_CYNDAQUIL` + - fall-through (player picked Cyndaquil) -> `loadtrainer RIVAL1, RIVAL1_5_TOTODILE` + + Each arm runs `winlosstext VictoryRoadRivalDefeatText, VictoryRoadRivalVictoryText`, + `setlasttalked VICTORYROAD_RIVAL`, `startbattle`, `dontrestartmapmusic`, + `reloadmapafterbattle`, then `.AfterBattle`: `playmusic MUSIC_RIVAL_AFTER` and + `VictoryRoadRivalAfterText`. +- `VictoryRoadTMEarthquake` / `MaxRevive` / `FullRestore` / `FullHeal` / + `XSpecial` (`maps/VictoryRoad.asm:97-110`) - each is a bare + `itemball <ITEM>` (two bytes: item, quantity 1; `macros/scripts/maps.asm:155`), + not bytecode. Do not try to disassemble them. +- `VictoryRoadHiddenMaxPotion` / `VictoryRoadHiddenFullHeal` + (`maps/VictoryRoad.asm:112-116`) - `hiddenitem item, flag`, three bytes laid + down as `dwb flag, item` (`macros/scripts/maps.asm:165`). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RIVAL_VICTORY_ROAD` | `constants/event_flags.asm:1124` | set by `InitializeEventsScript` (`engine/events/std_scripts.asm:521`) and again by `VictoryRoadRivalNext`; cleared by the `appear` in the ambush scripts | set = rival object masked. Postcondition of the ambush. | +| `EVENT_VICTORY_ROAD_TM_EARTHQUAKE` | `constants/event_flags.asm:1092` | itemball object flag | set once TM26 is taken | +| `EVENT_VICTORY_ROAD_MAX_REVIVE` | `constants/event_flags.asm:1093` | itemball object flag | set once Max Revive is taken | +| `EVENT_VICTORY_ROAD_FULL_RESTORE` | `constants/event_flags.asm:1094` | itemball object flag | set once Full Restore is taken | +| `EVENT_VICTORY_ROAD_FULL_HEAL` | `constants/event_flags.asm:1095` | itemball object flag | set once Full Heal is taken | +| `EVENT_VICTORY_ROAD_X_SPECIAL` | `constants/event_flags.asm:1096` | itemball object flag | set once X Special is taken | +| `EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION` | `constants/event_flags.asm:168` | `hiddenitem` operand at bg (3,29) | set once dug up | +| `EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL` | `constants/event_flags.asm:169` | `hiddenitem` operand at bg (3,65) | set once dug up | +| `EVENT_GOT_TOTODILE_FROM_ELM` (`:37`), `EVENT_GOT_CHIKORITA_FROM_ELM` (`:38`) | `constants/event_flags.asm` | read by `VictoryRoadRivalNext` | selects which of the three rival parties loads. `EVENT_GOT_CYNDAQUIL_FROM_ELM` (`:36`) is never read here; Cyndaquil is the fall-through case | +| `SCENE_VICTORYROAD_RIVAL_BATTLE` = 0 | defined inline by the `scene_script` macro (`macros/scripts/maps.asm:25`, `const_def` starting at 0) via `maps/VictoryRoad.asm:11` | compared by both coord events | the scene the map starts a new game on | +| `SCENE_VICTORYROAD_NOOP` = 1 | same, `maps/VictoryRoad.asm:12` | written by `setscene` at the end of both ambush scripts | rival ambush disarmed | + +Both scene scripts (`VictoryRoadNoop1Scene`, `VictoryRoadNoop2Scene`) are a bare +`end`; the scene id only gates the coord events. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_EARTHQUAKE` (TM26 - counted off the `add_tm` list in `constants/item_constants.asm:218-246`) | Poke Ball at (3,28), only reachable through the pit at (0,11) | `VictoryRoadTMEarthquake` | `EVENT_VICTORY_ROAD_TM_EARTHQUAKE` | +| `MAX_REVIVE` | Poke Ball at (12,48) | `VictoryRoadMaxRevive` | `EVENT_VICTORY_ROAD_MAX_REVIVE` | +| `FULL_RESTORE` | Poke Ball at (18,29), reached by ladder warp 7 -> (17,33) then north | `VictoryRoadFullRestore` | `EVENT_VICTORY_ROAD_FULL_RESTORE` | +| `FULL_HEAL` | Poke Ball at (15,48) | `VictoryRoadFullHeal` | `EVENT_VICTORY_ROAD_FULL_HEAL` | +| `X_SPECIAL` | Poke Ball at (7,38), standing on the `HOP_LEFT` column; walk north from (7,42) | `VictoryRoadXSpecial` | `EVENT_VICTORY_ROAD_X_SPECIAL` | +| `MAX_POTION` | hidden, face the wall at (3,29) | `bg_event 3, 29, BGEVENT_ITEM` | `EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION` | +| `FULL_HEAL` (second one) | hidden, face the wall at (3,65) | `bg_event 3, 65, BGEVENT_ITEM` | `EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL` | + +**Trainers** + +There are no `OBJECTTYPE_TRAINER` objects on this map. The single battle is a +scripted `loadtrainer` with no `EVENT_BEAT_*` flag of its own. + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL1_5_CHIKORITA` | `RIVAL1` (trainer class 9, `constants/trainer_constants.asm:51`) | 13th member of the class | `Rival1Group` "RIVAL1 (13)" (`data/trainers/parties.asm:164`, sym `0e:5a92` for the group) | `VictoryRoadRivalNext.GotTotodile` (sym `42:5a27`) | none | +| `RIVAL1_5_CYNDAQUIL` | `RIVAL1` | 14th | `Rival1Group` "RIVAL1 (14)" (`data/trainers/parties.asm:174`) | `VictoryRoadRivalNext.GotChikorita` (sym `42:5a37`) | none | +| `RIVAL1_5_TOTODILE` | `RIVAL1` | 15th | `Rival1Group` "RIVAL1 (15)" (`data/trainers/parties.asm:184`) | fall-through arm of `VictoryRoadRivalNext` | none | + +Parties, verbatim (`TRAINERTYPE_MOVES`, name string `"?@"`): + +RIVAL1 (13) - loaded when the player chose Totodile: + +``` +db 34, SNEASEL, QUICK_ATTACK, SCREECH, FAINT_ATTACK, FURY_CUTTER +db 36, GOLBAT, LEECH_LIFE, BITE, CONFUSE_RAY, WING_ATTACK +db 35, MAGNETON, THUNDERSHOCK, SONICBOOM, THUNDER_WAVE, SWIFT +db 35, HAUNTER, MEAN_LOOK, CURSE, SHADOW_BALL, CONFUSE_RAY +db 35, KADABRA, DISABLE, PSYBEAM, RECOVER, FUTURE_SIGHT +db 38, MEGANIUM, REFLECT, RAZOR_LEAF, POISONPOWDER, BODY_SLAM +``` + +RIVAL1 (14) - loaded when the player chose Chikorita: + +``` +db 34, SNEASEL, QUICK_ATTACK, SCREECH, FAINT_ATTACK, FURY_CUTTER +db 36, GOLBAT, LEECH_LIFE, BITE, CONFUSE_RAY, WING_ATTACK +db 35, MAGNETON, THUNDERSHOCK, SONICBOOM, THUNDER_WAVE, SWIFT +db 35, HAUNTER, MEAN_LOOK, CURSE, SHADOW_BALL, CONFUSE_RAY +db 35, KADABRA, DISABLE, PSYBEAM, RECOVER, FUTURE_SIGHT +db 38, TYPHLOSION, SMOKESCREEN, EMBER, QUICK_ATTACK, FLAME_WHEEL +``` + +RIVAL1 (15) - loaded when the player chose Cyndaquil: + +``` +db 34, SNEASEL, QUICK_ATTACK, SCREECH, FAINT_ATTACK, FURY_CUTTER +db 36, GOLBAT, LEECH_LIFE, BITE, CONFUSE_RAY, WING_ATTACK +db 34, MAGNETON, THUNDERSHOCK, SONICBOOM, THUNDER_WAVE, SWIFT +db 35, HAUNTER, MEAN_LOOK, CURSE, SHADOW_BALL, CONFUSE_RAY +db 35, KADABRA, DISABLE, PSYBEAM, RECOVER, FUTURE_SIGHT +db 38, FERALIGATR, RAGE, WATER_GUN, SCARY_FACE, SLASH +``` + +Note the send-out order: Sneasel, Golbat, Magneton, Haunter, Kadabra, starter. +The FAQ lists the starter fourth; the asm puts it last. + +**Wild encounters** + +`data/wild/kanto_grass.asm:144` `def_grass_wildmons VICTORY_ROAD` (Victory Road +lives in the Kanto grass table even though it is Johto-side). + +Encounter rates: `db 6 percent, 6 percent, 6 percent` (morn / day / nite - all +equal). Slot weights come from `GrassMonProbTable` +(`data/wild/probabilities.asm:6`): 30 / 30 / 20 / 10 / 5 / 4 / 1 percent. + +Gold (`IF DEF(_GOLD)`), identical for morn, day and nite: + +| slot | % | level | species | +|---|---|---|---| +| 1 | 30 | 32 | `GRAVELER` | +| 2 | 30 | 32 | `GOLBAT` | +| 3 | 20 | 33 | `URSARING` | +| 4 | 10 | 34 | `ONIX` | +| 5 | 5 | 36 | `ONIX` | +| 6 | 4 | 35 | `RHYHORN` | +| 7 | 1 | 35 | `RHYHORN` | + +Silver (`ELIF DEF(_SILVER)`) is the same table with slot 3 replaced by +`db 33, DONPHAN`. + +No water, fishing, headbutt or rock-smash data: there is no `VICTORY_ROAD` row in +`data/wild/kanto_water.asm`, `data/wild/johto_water.asm`, `data/wild/fish.asm` or +`data/wild/treemons.asm`, and the decoded collision map contains no water tiles. +The `FISHGROUP_SHORE` in the header is therefore unreachable. No roamer either +(`VICTORY_ROAD` is absent from `data/wild/roammon_maps.asm`). + +### MAP_VICTORY_ROAD_GATE + +Included here because it is the only entrance and it carries the badge gate. The +NPC beats themselves belong to the previous / later sections. + +- Script: `maps/VictoryRoadGate.asm` (`data/maps/scripts.asm:452`) +- Blocks: `maps/VictoryRoadGate.blk` (`data/maps/blocks.asm:943`) +- Header: `data/maps/maps.asm:467` + `map VictoryRoadGate, TILESET_GATE, GATE, LANDMARK_ROUTE_26, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Attributes: `data/maps/attributes.asm:666`, border block `$00`, no connections. +- Dimensions: `constants/map_constants.asm:433` `map_const VICTORY_ROAD_GATE, 10, 9` -> 20x18 cells. Group `VIRIDIAN` (group 23), map id 13. +- Scene variable: `data/maps/scenes.asm:16` `scene_var VICTORY_ROAD_GATE, wVictoryRoadGateSceneID` (sym `01:d6bf`) + +**Warps** (`maps/VictoryRoadGate.asm:101-109`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 7 | `ROUTE_22` | 1 | +| 2 | 18 | 7 | `ROUTE_22` | 1 | +| 3 | 9 | 17 | `ROUTE_26` | 1 | +| 4 | 10 | 17 | `ROUTE_26` | 1 | +| 5 | 9 | 0 | `VICTORY_ROAD` | 1 | +| 6 | 10 | 0 | `VICTORY_ROAD` | 1 | +| 7 | 1 | 7 | `ROUTE_28` | 2 | +| 8 | 2 | 7 | `ROUTE_28` | 2 | + +Cross-checked from the other side: `maps/Route22.asm:20` warps to +`VICTORY_ROAD_GATE, 1`, `maps/Route26.asm:397` warps to `VICTORY_ROAD_GATE, 3`, +`maps/Route28.asm:21` warps to `VICTORY_ROAD_GATE, 7`. + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_VICTORYROADGATE_BADGE_CHECK` (0) | 10 | 11 | `VictoryRoadGateBadgeCheckScript` (sym `5f:4fdc`) | turns the player LEFT and falls into `_VictoryRoadGateBadgeCheckScript` | + +**BG events**: none. + +**Object events** (`maps/VictoryRoadGate.asm:116-119`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VICTORYROADGATE_OFFICER` | `SPRITE_OFFICER` | 8 | 11 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateOfficerScript` | `-1` (always present) | +| `VICTORYROADGATE_BLACK_BELT1` | `SPRITE_BLACK_BELT` | 7 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateLeftBlackBeltScript` | `EVENT_OPENED_MT_SILVER` | +| `VICTORYROADGATE_BLACK_BELT2` | `SPRITE_BLACK_BELT` | 12 | 5 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateRightBlackBeltScript` | `EVENT_FOUGHT_SNORLAX` | + +**Derived collision map** (same method, `data/tilesets/gate_collision.asm`; +`c` = `COLL_COUNTER`, a wall): + +``` + 01234567890123456789 + 0 #########DD######### + 1 ########....######## + 2 ########....######## + 3 ########....######## + 4 ########....######## + 5 .................... + 6 ....####....####.... + 7 .WW.####....####.WW. + 8 ########....######## + 9 ########....######## + 10 ########cc..######## + 11 ########.c.c######## + 12 ########.c..######## + 13 ########cc..######## + 14 ########....######## + 15 ########....######## + 16 ########....######## + 17 ########.WW.######## +``` + +Row 11 is walkable only at x=8 (where the officer stands) and x=10, so +**(10,11) is a mandatory chokepoint**: the badge check cannot be walked around. + +**Scripts of interest** + +- `_VictoryRoadGateBadgeCheckScript` (`maps/VictoryRoadGate.asm:25`, sym `5f:4fe3`): + `writetext VictoryRoadGateOfficerText`, `promptbutton`, `readvar VAR_BADGES`, + `ifgreater NUM_JOHTO_BADGES - 1, .AllEightBadges`. On failure it prints + `VictoryRoadGateNotEnoughBadgesText` and `applymovement PLAYER, + VictoryRoadGateStepDownMovement` (one `step DOWN`, pushing the player back + south). On success it prints `VictoryRoadGateEightBadgesText` and + `setscene SCENE_VICTORYROADGATE_NOOP`, permanently disarming the coord event. +- `VictoryRoadGateOfficerScript` (`maps/VictoryRoadGate.asm:23`) - `faceplayer` + then the same body, so talking to the officer also opens the gate. + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Entry to Victory Road | `maps/VictoryRoadGate.asm:19` `VictoryRoadGateBadgeCheckScript` / `_VictoryRoadGateBadgeCheckScript` | `readvar VAR_BADGES` then `ifgreater NUM_JOHTO_BADGES - 1` (that is, badge count must be >= 8). `NUM_JOHTO_BADGES` = 8 (`constants/ram_constants.asm:260`). `VAR_BADGES` resolves to `.CountBadges` in `engine/overworld/variables.asm:80`, which is `CountSetBits` over **two** bytes (`wBadges`, b = 2), i.e. Johto **and** Kanto badges together | own all eight Johto badges. The script then sets `SCENE_VICTORYROADGATE_NOOP`, so the check runs at most once | +| Physical block west to Route 28 | `maps/VictoryRoadGate.asm:118` object `VICTORYROADGATE_BLACK_BELT1` at (7,5), masked when `EVENT_OPENED_MT_SILVER` is set | row 5 is the only east-west corridor in the gate and he stands in it | out of scope here (Mt. Silver, later section) | +| Physical block east to Route 22 | `maps/VictoryRoadGate.asm:119` object `VICTORYROADGATE_BLACK_BELT2` at (12,5), masked when `EVENT_FOUGHT_SNORLAX` is set | same corridor | out of scope here (Kanto, later section) | +| Rival ambush before the exit | `maps/VictoryRoad.asm:255-256` coord events at (12,8) and (13,8), gated on `SCENE_VICTORYROAD_RIVAL_BATTLE` | those two tiles are the whole corridor to the exit at (13,5) | win the battle; the script then runs `setscene SCENE_VICTORYROAD_NOOP` | +| TM26 Earthquake | geometry only, no flag: (3,28) is inside the y=26..30 pocket whose only entrance is the `COLL_PIT` at (0,11) | reach the top region first (so: beat the ladders, not the rival) | none | +| Full Restore | geometry only: (18,29) is on a shelf whose only entrance is warp 7 (17,19) -> (17,33) | reach the top region | none | + +Field moves: **none required**. There is no `COLL_CUT_TREE`, no water, no +strength boulder and no whirlpool anywhere in the decoded map, and the header +palette is `PALETTE_NITE`, not `PALETTE_DARK`, so **Flash is not needed** either. +The FAQ's "no puzzles to solve now" is accurate. + +## 4. Bot checklist + +Coordinates are map cells `(x, y)` on the named map. "step" means walk onto the +tile; ladders and the pit fire on arrival (`Permissions.isImmediateWarp` +equivalent: `COLL_LADDER` = `$72` and `COLL_PIT` = `$60` are warp collisions), +while the entrance carpet at (9,67) fires on a DOWN press. + +1. `MAP_VICTORY_ROAD_GATE`: from the Route 26 door at (9,17)/(10,17) walk north along x=10. Precondition: 8 Johto badges. Step (10,11) -> `VictoryRoadGateBadgeCheckScript` fires. Postcondition: `wVictoryRoadGateSceneID` = `SCENE_VICTORYROADGATE_NOOP`. If badges < 8 the script pushes you one step DOWN and you cannot pass. +2. `MAP_VICTORY_ROAD_GATE`: step (9,0) or (10,0) -> warp to `MAP_VICTORY_ROAD` (9,67). +3. `MAP_VICTORY_ROAD` entrance region. Waypoints that were checked against the + collision map: (9,66) -> west along y=66 to (7,66) -> north x=7 through + y=65,64 to (7,63) -> (7,62) -> east along y=62 to (11,62)/(12,62) -> north + x=11/12 through y=61..55 -> (11,54) -> the long y=53/54 corridor (x=5..16). +4. Max Revive and Full Heal: from the y=53/54 corridor go east to (15,54) -> + (15,55) -> (15,56) -> east along y=56 to (18,56) -> north along x=18 through + y=55,54,53,52 to (18,51) -> west along y=51 to (15,51) -> (15,50) -> (16,50) + -> (16,49) -> (16,48), press A facing LEFT + into (15,48) -> Full Heal (`EVENT_VICTORY_ROAD_FULL_HEAL`). Then west along + y=50 to (12,50) -> (12,49) -> press A facing UP into (12,48) -> Max Revive + (`EVENT_VICTORY_ROAD_MAX_REVIVE`). +5. Optional hidden Full Heal: face the wall at (3,65) from (2,65), (4,65), + (3,64) or (3,66) and press A. Postcondition + `EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL`. +6. Back along the y=53/54 corridor to its west end, (3,53) -> (2,52) -> (2,51) + -> (2,50) -> (1,49); the ladder fires on arrival -> warp 2 -> (1,35). +7. X Special. Verified chain from the ladder landing: (1,35) -> (1,36) -> (1,37) + -> (1,38) -> east along y=38 to (5,38) -> (5,37) -> (5,36) -> east along y=36 + to (9,36)/(10,36) -> (9,37)/(10,37) -> (9,38)/(10,38) -> south through + y=39,40 (x=9..16 is open) -> (13,41) -> (13,42) -> west along y=42 to (7,42) + -> north along x=7 (the `HOP_LEFT` column is walkable) through y=41,40,39 to + (7,38). Press A -> X Special. Postcondition `EVENT_VICTORY_ROAD_X_SPECIAL`. + Do **not** press LEFT while on that column (it triggers a two-tile jump into + a wall column). +8. To the (13,31) ladder: back to (7,42) -> east along y=42 to (18,42) -> north + along x=18 through y=41,40,39,38 to (18,37) -> west along y=37 then y=36 to + (13,36) -> north (13,35), (13,34), (13,33), (13,32) -> step (13,31) -> warp 4 + -> arrive (13,17). Note you cannot climb x=13 straight up from y=41: (13,38) + is `COLL_UP_WALL` and blocks the step up out of it, and the x=11 column walls + off the y=34..36 strip on its west side. +9. Optional Full Restore detour. The verified chain from the ladder to (17,19) is: + (13,17) -> (13,16) -> east along y=16 to (16,16) -> (16,15) -> (16,14) -> + west along y=14 to (9,14)/(10,14) -> south x=9/10 through y=15,16,17 to + (9,18)/(10,18) -> west along y=18 to (5,18) -> (5,17) -> (5,16) -> west along + y=16 to (3,16) -> (3,17) -> (3,18) -> (3,19) -> (3,20) -> east along y=20 to + (8,20) -> **press RIGHT to hop the ledge**, landing (10,20) -> east/north to + (16,19) -> (17,19). On the cart that `HOP_RIGHT` is the only way into the + (17,19) pocket. Step (17,19) -> warp 7 -> arrive (17,33). Walk north along + x=17..19 to (18,29), press A -> Full Restore. Postcondition + `EVENT_VICTORY_ROAD_FULL_RESTORE`. Return to y=34 (x=16..19) and press DOWN to + hop the ledge, landing on y=36 - which is already the y=36 corridor, so go + west to (13,36) and north to (13,31), warp 4, back to (13,17). +10. From (13,17): (13,16) -> east along y=16 to (18,16) -> north along x=18 + through (18,15), (18,14), (18,13) (the rival's masked object tile), (18,12) + to (18,11) -> west along y=11 to (13,11) -> north (13,10), (13,9) to (13,8). +11. Step (12,8) or (13,8) -> rival ambush. Precondition `wVictoryRoadSceneID` = 0. Battle `RIVAL1` member 13/14/15 depending on the starter events. Postconditions: `EVENT_RIVAL_VICTORY_ROAD` set, `wVictoryRoadSceneID` = `SCENE_VICTORYROAD_NOOP`. +12. TM26 detour. The x=6,7 wall pair on y=10 and the x=5..8 wall run on y=11 mean + you cannot walk straight west; the verified chain is (9,11) -> (9,10) -> + (8,10) -> (8,9) -> west along y=9 to (5,9) -> (5,10) -> west along y=10 to + (0,10) -> (0,11), the `COLL_PIT`, which fires on arrival -> land at (0,27). + Then (0,28) -> east along y=28 to (2,28), press A facing RIGHT into the ball + object at (3,28) -> TM26 Earthquake (`EVENT_VICTORY_ROAD_TM_EARTHQUAKE`). + Optionally step to (2,29) and press A facing RIGHT into the wall at (3,29) + -> hidden Max Potion (`EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION`). +13. Leave the pocket: east along y=28 to (7,28) -> step onto (8,28), the + `HOP_RIGHT` tile -> press RIGHT to hop to (10,28) -> (10,29) -> east to + (12,29) -> (12,30) -> (12,31) -> step (13,31) -> warp 4 -> (13,17). + (Alternative, longer: stand on (2,30) or (3,30) and press DOWN, landing at + y=32, which drops you back in the (1,35) ladder pocket and forces a full + re-cross of the middle region via steps 7 and 8.) +14. Repeat step 10 to return to the exit corridor. The coord events no longer fire. +15. Walk (13,7) -> (13,6) -> step (13,5) (`COLL_CAVE`) -> warp 10 -> `MAP_ROUTE_23` (9,13). End of section. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map header, dimensions, warps, coord events, bg events, object events for any Gold map | `src/import/RomExtractorGen2.lua` (map event reader at lines 785-865, `extractMaps` from 866) | implemented - data driven off the ROM, so `VICTORY_ROAD` needs no hand port | +| `OBJECTTYPE_ITEMBALL` item data extraction | `src/import/RomExtractorGen2.lua:2968` (`obj.itemball = readItemBall(...)`) | implemented (extraction only) | +| **Picking up a Poke Ball object** (all five Victory Road items) | `src/world/gen2/World.lua:5257` `World:interact` | **missing** - the A-press path handles trainers, strength boulders, `scriptKey` NPCs, `BGEVENT_READ` signs, `BGEVENT_ITEM` hidden items and the tile-collision field moves. Nothing consumes `obj.itemball`, and an itemball object has no `scriptKey`, so the press falls through | +| Hidden items (`BGEVENT_ITEM`, the two here) | `src/world/gen2/HiddenItems.lua`, used at `src/world/gen2/World.lua:5290` | implemented | +| Ladder / cave / pit warps | `src/world/gen2/Permissions.lua:162` `isWarpCollision` (covers `$60` PIT and the `$7x` block) and `isImmediateWarp` | implemented - the (0,11) hole and all six ladders work | +| Warp carpet needing a direction press (the (9,67) entrance) | `src/world/gen2/Permissions.lua:170` `CARPET_DIR` | implemented | +| Walkability from `CollisionPermissionTable` | `src/world/gen2/Permissions.lua:11` (256-entry table), `src/world/gen2/Map.lua:59` | implemented | +| **Ledge hops** (`COLL_HOP_LEFT/RIGHT/DOWN`, five of them in this map) | - | **missing** - no `$a0..$a7` handling anywhere under `src/world/gen2/`. The tiles are LAND in the port's permission table, so a bot can walk over them freely. Net effect: the one-way ledges become two-way, and the Full Restore shelf / TM pocket exits behave differently from the cart | +| **Directional side walls** (`COLL_UP_WALL`, heavily used here) | `src/world/gen2/FieldMoves.lua:313` `BLOCKED_BY` | partial - the `$b0..$b7` table exists but is only consulted by `FieldMoves.directionBlocked` for surf/field-move refusals. There is no port equivalent of `GetMovementPermissions` (`home/map.asm:1868`), so `UP_WALL` does not restrict ordinary walking | +| Coord events gated on the map scene | `src/world/gen2/World.lua:5007` `World:tryCoordScript`, scene storage at `World.lua:740` / `World:scene` at 1161 | implemented | +| Scene scripts / `setscene` persistence across save | `src/world/gen2/World.lua:558-584`, `src/core/gen2/Save.lua` | implemented | +| Rival ambush opcodes (`moveobject`, `turnobject`, `showemote`, `appear`, `disappear`, `applymovement`, `scall`, `setscene`, `playmapmusic`, `playmusic`, `winlosstext`, `setlasttalked`, `loadtrainer`, `startbattle`, `dontrestartmapmusic`, `reloadmapafterbattle`) | `src/script/gen2/Opcodes.lua` (table), `src/script/gen2/Vm.lua` (`moveobject` 337, `showemote` 961, `loadtrainer` 806, `winlosstext` 918, `reloadmapafterbattle` 886) | implemented | +| `special FadeOutMusic` | `src/script/gen2/Specials.lua:1066` | implemented | +| `VAR_BADGES` (the gate check) | `src/world/gen2/World.lua:1240` - counts `player.badges` + `player.kantoBadges`, matching `CountSetBits` over two bytes | implemented | +| Object masking by event flag | `src/import/RomExtractorGen2.lua` object `eventFlag` field; `src/world/gen2/Npc.lua` | partial - the flag is extracted, but confirm the mask polarity (hidden when **set**) matches `CheckObjectFlag`; not verified in this pass | +| Wild encounters for this map | `src/import/RomExtractorGen2.lua:3720` (`JohtoGrassWildMons` / `KantoGrassWildMons`), `src/battle/gen2/Encounter.lua` | implemented | +| Battle music landmark split | `src/battle/gen2/BattleMusic.lua:45-56` explicitly names the Victory Road landmark boundary | implemented | +| A driver that walks Victory Road | - | **missing** - no `tests/drivers/gold_*.lua` mentions Victory Road | + +## 6. Unresolved / verify by hand + +- **Rival Magneton level.** The FAQ says "Level 34 Magneton". The asm has 35 in + RIVAL1 (13) and RIVAL1 (14) and 34 only in RIVAL1 (15), the Feraligatr set + loaded when the player picked Cyndaquil. The FAQ appears to have been written + from one playthrough. +- **Rival party order.** The FAQ lists the starter evolution fourth. The asm + order is Sneasel, Golbat, Magneton, Haunter, Kadabra, starter (starter last). +- **"You get: 2280G"** and the per-mon EXP figures were not verified; base money + for the `RIVAL1` class lives in the trainer class attribute table, which was + not opened for this pass. +- **Wild list.** The FAQ lists only Golbat, Graveler, Rhyhorn and "Donphan + (Silver only)". The asm table also carries Onix (two slots, L34 and L36) and, + in Gold, Ursaring at L33 in the slot Silver gives to Donphan. +- **Item list.** The FAQ's item list omits the hidden Max Potion at (3,29) and + does not distinguish the itemball Full Heal at (15,48) from the hidden Full + Heal at (3,65). +- **"Head up the stairway"** - there is no `COLL_STAIRCASE` in the decoded + Victory Road map. What the FAQ calls stairways and cliffs are ordinary floor + plus the `COLL_UP_WALL` strips; the only vertical transitions are the six + ladders, the pit and the five ledges. +- **"Head back up the ladder to get back to the third floor"** (after TM26). + There is no ladder inside the TM pocket. The two ways out are the `HOP_RIGHT` + at (8,28)/(8,29) into the x=10..13 corridor that reaches the (13,31) ladder, + and the `HOP_DOWN` at (2,30)/(3,30) into the (1,35) pocket. The checklist above + prefers the first; which one the FAQ author meant is not recoverable from the + text. +- **X Special tile.** The Poke Ball at (7,38) sits on a `COLL_HOP_LEFT` tile with + `COLL_WALL` on both sides and above. Ledge tiles are `LAND_TILE` in + `CollisionPermissionTable`, so the column x=7, y=38..41 is walkable from + (7,42) upward and the ball is reachable; but this should be confirmed on + hardware or in an emulator, because it is the one placement in this map that + looks like a mistake rather than a design. +- **The gate's black belts as physical blockers.** The claim that + `VICTORYROADGATE_BLACK_BELT1` at (7,5) and `BLACK_BELT2` at (12,5) block the + only east-west corridor is inferred from the decoded gate collision (row 5 is + the sole corridor and rows 4/6 are walls beside it) plus the fact that NPCs are + solid. It is not stated in any script, and it belongs to later sections + (Route 28 / Route 22) rather than this one. +- **Object flag polarity in the port** was not tested; see the Port coverage row. diff --git a/docs/gold-walkthrough/asm-walk/section-18-pok-mon-league.md b/docs/gold-walkthrough/asm-walk/section-18-pok-mon-league.md new file mode 100644 index 00000000..7cbe69bb --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-18-pok-mon-league.md @@ -0,0 +1,883 @@ +# Section 18 - Pokémon League + +Source: `../section-18-pok-mon-league.txt` +Maps covered: `MAP_ROUTE_23`, `MAP_INDIGO_PLATEAU_POKECENTER_1F`, `MAP_WILLS_ROOM`, +`MAP_KOGAS_ROOM`, `MAP_BRUNOS_ROOM`, `MAP_KARENS_ROOM`, `MAP_LANCES_ROOM`, +`MAP_HALL_OF_FAME` +Badges / key milestones in this section: no badge. `ENGINE_FLYPOINT_INDIGO_PLATEAU` +(fly point), `EVENT_BEAT_ELITE_4_WILL/KOGA/BRUNO/KAREN`, +`EVENT_BEAT_CHAMPION_LANCE`, `EVENT_BEAT_ELITE_FOUR`, `STATUSFLAGS_HALL_OF_FAME_F` +(Hall of Fame induction -> credits -> post-game respawn at `SPAWN_NEW_BARK`). + +All eight maps live in map group `INDIGO` (group 16, `constants/map_constants.asm:321`). + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_VICTORY_ROAD` | `maps/VictoryRoad.asm` | (previous section) | warp 10 at `(13, 5)` -> `ROUTE_23` warp 3 | Victory Road's north exit; the walkthrough opens already standing on the Plateau approach | +| 1 | `MAP_ROUTE_23` | `maps/Route23.asm` | warps 3/4 at `(9, 13)`/`(10, 13)` from Victory Road | warps 1/2 at `(9, 5)`/`(10, 5)` -> `INDIGO_PLATEAU_POKECENTER_1F` warps 1/2 | "Route 32 is very short, and you'll immediately be at the Pokémon League building" (the FAQ means Route **23**). `MAPCALLBACK_NEWMAP` registers the fly point here | +| 2 | `MAP_INDIGO_PLATEAU_POKECENTER_1F` | `maps/IndigoPlateauPokecenter1F.asm` | warps 1/2 at `(5, 13)`/`(6, 13)` | warp 4 at `(14, 3)` -> `WILLS_ROOM` warp 1 | Heal (nurse, left), buy (clerk, right), optional Abra teleport home, optional Mon/Wed rival ambush, then through the north door | +| 3 | `MAP_WILLS_ROOM` | `maps/WillsRoom.asm` | warp 1 at `(5, 17)` | warps 2/3 at `(4, 2)`/`(5, 2)` -> `KOGAS_ROOM` warps 1/2 | Elite Four 1 - Will | +| 4 | `MAP_KOGAS_ROOM` | `maps/KogasRoom.asm` | warps 1/2 at `(4, 17)`/`(5, 17)` | warps 3/4 at `(4, 2)`/`(5, 2)` -> `BRUNOS_ROOM` warps 1/2 | Elite Four 2 - Koga | +| 5 | `MAP_BRUNOS_ROOM` | `maps/BrunosRoom.asm` | warps 1/2 at `(4, 17)`/`(5, 17)` | warps 3/4 at `(4, 2)`/`(5, 2)` -> `KARENS_ROOM` warps 1/2 | Elite Four 3 - Bruno | +| 6 | `MAP_KARENS_ROOM` | `maps/KarensRoom.asm` | warps 1/2 at `(4, 17)`/`(5, 17)` | warps 3/4 at `(4, 2)`/`(5, 2)` -> `LANCES_ROOM` warps 1/2 | Elite Four 4 - Karen | +| 7 | `MAP_LANCES_ROOM` | `maps/LancesRoom.asm` | warps 1/2 at `(4, 23)`/`(5, 23)` | scripted `warp HALL_OF_FAME, 4, 13` at the tail of `LancesRoomLanceScript` (warps 3/4 at `(4, 0)`/`(5, 0)` are the same door) | Champion Lance, then the Mary / Prof. Oak cutscene | +| 8 | `MAP_HALL_OF_FAME` | `maps/HallOfFame.asm` | scripted warp to `(4, 13)` | `halloffame` -> `Credits` -> title screen | Induction, credits, "The End" | + +Post-credits: `engine/menus/intro_menu.asm` `.SpawnAfterE4` sends the next CONTINUE +to `SPAWN_NEW_BARK`, not to the saved position. That is the start of the Kanto +half and belongs to the next section. + +--- + +## 2. Maps + +### MAP_ROUTE_23 + +- Script: `maps/Route23.asm` +- Blocks: `maps/Route23.blk` +- Header (`data/maps/maps.asm:349`): `map Route23, TILESET_KANTO, TOWN, LANDMARK_ROUTE_23, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:322` -> `map_const ROUTE_23, 10, 9` (10 x 9 blocks = 20 x 18 tiles) +- Attributes: `data/maps/attributes.asm:397` -> `map_attributes Route23, ROUTE_23, $0f`, **no connections** (despite `TOWN` environment) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 5 | `INDIGO_PLATEAU_POKECENTER_1F` | 1 | +| 2 | 10 | 5 | `INDIGO_PLATEAU_POKECENTER_1F` | 2 | +| 3 | 9 | 13 | `VICTORY_ROAD` | 10 | +| 4 | 10 | 13 | `VICTORY_ROAD` | 10 | + +**Coord events** - none (`def_coord_events` is empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 7 | `BGEVENT_READ` | `IndigoPlateauSign` (`IndigoPlateauSignText`) | + +**Object events** - none (`def_object_events` is empty). + +**Scripts of interest** + +- `Route23FlypointCallback` (`50:5888`), registered as `callback MAPCALLBACK_NEWMAP`. + Body is two opcodes: `setflag ENGINE_FLYPOINT_INDIGO_PLATEAU` / `endcallback`. + This is the whole of the walkthrough's "You'll now be able to fly to this + location directly", and it is also the gate on the Kanto half of the Fly map + (see section 5 - Blockers). +- `IndigoPlateauSign` -> `jumptext IndigoPlateauSignText`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_INDIGO_PLATEAU` | `constants/engine_flags.asm:78` | set by `Route23FlypointCallback` | after one step onto Route 23, Fly to Indigo Plateau is available; also unlocks the Kanto Fly map | + +**Items** - none. + +**Trainers** - none. + +**Wild encounters** - none. `ROUTE_23` has no entry in `data/wild/johto_grass.asm`, +`data/wild/kanto_grass.asm`, `data/wild/johto_water.asm` or +`data/wild/kanto_water.asm` (grepped; `kanto_grass.asm` jumps +`ROUTE_22` -> `ROUTE_24`). The header's `FISHGROUP_SHORE` is the header default and +there is no water to fish from. + +--- + +### MAP_INDIGO_PLATEAU_POKECENTER_1F + +- Script: `maps/IndigoPlateauPokecenter1F.asm` +- Blocks: `maps/IndigoPlateauPokecenter1F.blk` +- Header (`data/maps/maps.asm:350`): `map IndigoPlateauPokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:323` -> `map_const INDIGO_PLATEAU_POKECENTER_1F, 9, 7` (18 x 14 tiles) +- Attributes: `data/maps/attributes.asm:598`, border `$00`, no connections + +`object_const_def` order (the ids `moveobject` / `applymovement` / `appear` take): + +``` +0 INDIGOPLATEAUPOKECENTER1F_NURSE +1 INDIGOPLATEAUPOKECENTER1F_CLERK +2 INDIGOPLATEAUPOKECENTER1F_COOLTRAINER_M +3 INDIGOPLATEAUPOKECENTER1F_RIVAL +4 INDIGOPLATEAUPOKECENTER1F_GRAMPS +5 INDIGOPLATEAUPOKECENTER1F_ABRA +``` + +(the macro is `const_def 2`, so the emitted numbers start at 2) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 13 | `ROUTE_23` | 1 | +| 2 | 6 | 13 | `ROUTE_23` | 2 | +| 3 | 0 | 13 | `POKECENTER_2F` | 1 | +| 4 | 14 | 3 | `WILLS_ROOM` | 1 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE` (= 0) | 16 | 4 | `PlateauRivalBattle1` (`5a:48ff`) | rival walks up from the left column and blocks you | +| `SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE` (= 0) | 17 | 4 | `PlateauRivalBattle2` (`5a:4940`) | same fight, approached from the right column | + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `..._NURSE` | `SPRITE_NURSE` | 3 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `IndigoPlateauPokecenter1FNurseScript` | -1 | +| `..._CLERK` | `SPRITE_CLERK` | 11 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `IndigoPlateauPokecenter1FClerkScript` | -1 | +| `..._COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 11 | 11 | `SPRITEMOVEDATA_WANDER` (radius 2,2) | `OBJECTTYPE_SCRIPT` | `IndigoPlateauPokecenter1FCooltrainerMScript` | -1 | +| `..._RIVAL` | `SPRITE_RIVAL` | 16 | 9 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL` | +| `..._GRAMPS` | `SPRITE_GRAMPS` (`PAL_NPC_BLUE`) | 1 | 9 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `TeleportGuyScript` | `EVENT_TELEPORT_GUY` | +| `..._ABRA` | `SPRITE_JYNX` (`PAL_NPC_BROWN`) | 0 | 9 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `AbraScript` | `EVENT_TELEPORT_GUY` | + +Note the last row: the "Abra" the walkthrough names is declared with +`SPRITE_JYNX` (`constants/sprite_constants.asm:122`; `data/sprites/sprite_mons.asm` +maps that slot to `JYNX`). Its script is `AbraScript`, which does +`cry ABRA`. Verbatim from the file, not a typo of mine. + +An object's event flag hides it when the flag is **set** (`Script_disappear` +sets, `Script_appear` clears - `engine/overworld/scripting.asm:879-898`). +`EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL` is set on New Game by +`engine/events/std_scripts.asm:556` (the `EVENT_INITIALIZED_EVENTS` block), so +the rival starts hidden. `EVENT_TELEPORT_GUY` is not in that block, so the old +man and his Pokémon are visible from the first visit. + +**Scripts of interest** + +- `IndigoPlateauPokecenter1FPrepareElite4Callback` (`5a:48b6`), a + `callback MAPCALLBACK_NEWMAP`. **This is the single most important script in + the section for a bot**: every time you set foot in the lobby it resets the + whole Elite Four run. + + ``` + setmapscene WILLS_ROOM, SCENE_WILLSROOM_LOCK_DOOR + setmapscene KOGAS_ROOM, SCENE_KOGASROOM_LOCK_DOOR + setmapscene BRUNOS_ROOM, SCENE_BRUNOSROOM_LOCK_DOOR + setmapscene KARENS_ROOM, SCENE_KARENSROOM_LOCK_DOOR + setmapscene LANCES_ROOM, SCENE_LANCESROOM_LOCK_DOOR + setmapscene HALL_OF_FAME, SCENE_HALLOFFAME_ENTER + clearevent EVENT_{WILLS,KOGAS,BRUNOS,KARENS,LANCES}_ROOM_ENTRANCE_CLOSED + clearevent EVENT_{WILLS,KOGAS,BRUNOS,KARENS,LANCES}_ROOM_EXIT_OPEN + clearevent EVENT_BEAT_ELITE_4_WILL / _KOGA / _BRUNO / _KAREN + clearevent EVENT_BEAT_CHAMPION_LANCE + setevent EVENT_LANCES_ROOM_OAK_AND_MARY ; hide Oak + Mary again + endcallback + ``` + + Consequences: the five `EVENT_BEAT_ELITE_4_*` flags are **not** durable + progress markers - they only survive within one uninterrupted run. The durable + "I beat the League" flag is `EVENT_BEAT_ELITE_FOUR`, set in `HallOfFameEnterScript`. +- `PlateauRivalBattle1` / `PlateauRivalBattle2` -> `PlateauRivalBattleCommon`. + Guards, in order: + 1. `checkevent EVENT_BEAT_RIVAL_IN_MT_MOON` / `iffalse` -> done (so this only + ever fires post-game, after the Mt. Moon rematch), + 2. `checkflag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` / `iftrue` -> done (once a day), + 3. `readvar VAR_WEEKDAY` with `ifequal SUNDAY / TUESDAY / THURSDAY / FRIDAY / + SATURDAY` -> done. **Only Monday and Wednesday.** + + Then `moveobject`/`appear` the rival, `showemote EMOTE_SHOCK`, + `special FadeOutMusic`, a 5-step `applymovement`, `playmusic MUSIC_RIVAL_ENCOUNTER`, + `setevent EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL`, then a starter-dependent + `loadtrainer RIVAL2, RIVAL2_2_*` + `startbattle`. Afterwards + `PlateauRivalPostBattle` plays `MUSIC_RIVAL_AFTER`, walks him off with + `disappear`, and `setflag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT`. +- `IndigoPlateauPokecenter1FNurseScript` -> `jumpstd PokecenterNurseScript` (free heal). +- `IndigoPlateauPokecenter1FClerkScript` -> `pokemart MARTTYPE_STANDARD, MART_INDIGO_PLATEAU`. +- `TeleportGuyScript` (`5a:49e5`): `yesorno`; on yes, `playsound SFX_WARP_TO`, + `special FadeOutToWhite`, `warp NEW_BARK_TOWN, 13, 6`. The walkthrough's "Abra + will teleport you back" lands you in **New Bark Town**, specifically, not + "back to Johto" generically. +- `AbraScript`: text + `cry ABRA`, nothing else. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL` | `constants/event_flags.asm:1309` | set by `std_scripts.asm:556` on New Game and by `PlateauRivalBattleCommon`; cleared by `appear` | hides the rival object | +| `EVENT_TELEPORT_GUY` | `constants/event_flags.asm:1310` | set by `HallOfFameEnterScript` | after the Hall of Fame the old man **and** his Pokémon vanish; the ride home is a pre-champion service only | +| `EVENT_BEAT_RIVAL_IN_MT_MOON` | `constants/event_flags.asm:456` | read by both `PlateauRivalBattle*` | precondition for the lobby ambush | +| `ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` | `constants/engine_flags.asm:111` (in the `wDailyFlags2` block) | read/set by `PlateauRivalBattle*` | daily one-shot; cleared with the rest of the daily flags at midnight | +| `SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE` | the map's own `def_scene_scripts` (value 0) | coord events | see the quirk note below | + +Scene quirk worth knowing: this map declares exactly one scene script, so +`SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE == 0`, which is also the default +value of `wMapScenes` for the map. `PlateauRivalPostBattle`'s +`setscene SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE` therefore writes 0 over +0 and the two coord events stay armed forever. The real one-shot guards are +`ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` and the weekday check. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `ULTRA_BALL`, `MAX_REPEL`, `HYPER_POTION`, `MAX_POTION`, `FULL_RESTORE`, `REVIVE`, `FULL_HEAL` | bought | `MartIndigoPlateau` (`data/items/marts.asm:384`, symbol `05:645a`), opened by `IndigoPlateauPokecenter1FClerkScript` | n/a | + +This is where the walkthrough's "ten Hyper Potions, ten Revives, ten Full Heals" +shopping list is actually satisfiable - the mart carries all three plus Full +Restore. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL2` | `RIVAL2` (`$2a`) | `RIVAL2_2_CHIKORITA` / `RIVAL2_2_CYNDAQUIL` / `RIVAL2_2_TOTODILE` (chosen by which starter the player took: `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> Chikorita team, `EVENT_GOT_CHIKORITA_FROM_ELM` -> Cyndaquil team, else Totodile team) | `Rival2Group` entries 4/5/6 | `PlateauRivalBattleCommon` | daily, Mon/Wed only, post Mt. Moon | + +`Rival2Group` (4)-(6), all `TRAINERTYPE_MOVES`: +`45 SNEASEL`, `48 CROBAT`, `45 MAGNETON`, `46 GENGAR`, `46 ALAKAZAM`, and then +`50 MEGANIUM` / `50 TYPHLOSION` / `50 FERALIGATR` respectively. The walkthrough +does not mention this fight at all - a bot that walks the lobby on a Monday +will hit it whether or not it planned to. + +**Wild encounters** - none (indoor). + +--- + +### MAP_WILLS_ROOM + +- Script: `maps/WillsRoom.asm` +- Blocks: `maps/WillsRoom.blk` +- Header (`data/maps/maps.asm:351`): `map WillsRoom, TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, TRUE, PALETTE_DAY, FISHGROUP_SHORE` (the `TRUE` is the phone-service flag: **no phone calls** in any Elite Four room) +- Dimensions: `constants/map_constants.asm:324` -> `map_const WILLS_ROOM, 5, 9` (10 x 18 tiles) +- Attributes: `data/maps/attributes.asm:599`, border `$00`, no connections + +`object_const_def`: `WILLSROOM_WILL` (emitted id 2). + +Scene ids from the map's own `def_scene_scripts`: +`SCENE_WILLSROOM_LOCK_DOOR = 0`, `SCENE_WILLSROOM_NOOP = 1`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 17 | `INDIGO_PLATEAU_POKECENTER_1F` | 4 | +| 2 | 4 | 2 | `KOGAS_ROOM` | 1 | +| 3 | 5 | 2 | `KOGAS_ROOM` | 2 | + +**Coord events** - none. The door-lock cutscene is a **scene script**, not a +coord event: `scene_script WillsRoomLockDoorScene, SCENE_WILLSROOM_LOCK_DOOR` +fires on map load via `sdefer`. + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WILLSROOM_WILL` | `SPRITE_WILL` (`PAL_NPC_RED`) | 5 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `WillScript_Battle` | -1 | + +**Scripts of interest** + +- `WillsRoomDoorLocksBehindYouScript` (`5a:4d8c`), deferred by the scene script: + `applymovement PLAYER, WillsRoom_EnterMovement` (4 x `step UP`, so you enter at + `(5, 17)` and end at `(5, 13)`), `reanchormap $85`, `playsound SFX_STRENGTH`, + `earthquake 80`, `changeblock 4, 14, $2a` (wall), `refreshmap`, + `setscene SCENE_WILLSROOM_NOOP`, `setevent EVENT_WILLS_ROOM_ENTRANCE_CLOSED`. + `changeblock` operands are in the same tile space as warps but are halved + internally (`Script_changeblock` adds 4 then `GetBlockLocation` shifts right, + `home/map.asm:2099`), so `4, 14` is the block covering tiles `(4..5, 14..15)` - + the corridor square immediately below where the walk-in leaves you. That is + what makes the walkthrough's "You can't go back to the Pokémon Center to heal + between battles" literally true. +- `WillsRoomDoorsCallback`, `callback MAPCALLBACK_TILES`: re-applies + `changeblock 4, 14, $2a` while `EVENT_WILLS_ROOM_ENTRANCE_CLOSED` is set and + `changeblock 4, 2, $16` (open door) while `EVENT_WILLS_ROOM_EXIT_OPEN` is set, + so the room's geometry survives a battle reload. +- `WillScript_Battle` (`5a:4da4`): `faceplayer`, `opentext`, + `checkevent EVENT_BEAT_ELITE_4_WILL` / `iftrue WillScript_AfterBattle`, + `writetext WillScript_WillBeforeText`, `winlosstext WillScript_WillBeatenText, 0`, + `loadtrainer WILL, WILL1`, `startbattle`, `reloadmapafterbattle`, + `setevent EVENT_BEAT_ELITE_4_WILL`, defeat text, `playsound SFX_ENTER_DOOR`, + `changeblock 4, 2, $16` (open the north door), `refreshmap`, + `setevent EVENT_WILLS_ROOM_EXIT_OPEN`. + + Note there is **no `winlosstext` loss branch** (the second operand is `0`) and + no `BATTLETYPE_CANLOSE`: losing to any Elite Four member is an ordinary + blackout, which is why the FAQ says "If you lose, you have to start all over". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_WILLS_ROOM_ENTRANCE_CLOSED` | `constants/event_flags.asm:440` | set by `WillsRoomDoorLocksBehindYouScript`, read by `WillsRoomDoorsCallback`, cleared by the lobby callback | south door sealed | +| `EVENT_WILLS_ROOM_EXIT_OPEN` | `constants/event_flags.asm:441` | set by `WillScript_Battle`, read by `WillsRoomDoorsCallback`, cleared by the lobby callback | north door open -> `(4, 2)` / `(5, 2)` are walkable warps | +| `EVENT_BEAT_ELITE_4_WILL` | `constants/event_flags.asm:980` | set by `WillScript_Battle`, cleared by the lobby callback | run-local "Will is done" | +| `SCENE_WILLSROOM_LOCK_DOOR` / `SCENE_WILLSROOM_NOOP` | this map's `def_scene_scripts` (0 / 1) | `setmapscene` from the lobby; `setscene` in the lock script | 0 = play the seal-in cutscene on entry | + +**Items** - none. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `WILL1` | `WILL` (`$b`) | 1 | `WillGroup` (`data/trainers/parties.asm:197`, symbol `0e:5bf4`) | `WillScript_Battle` | none | + +`WillGroup`, `TRAINERTYPE_MOVES`, in **send-out order**: + +| # | lvl | species | moves | +|---|---|---|---| +| 1 | 40 | `XATU` | Quick Attack, Future Sight, Confuse Ray, Psychic | +| 2 | 41 | `JYNX` | DoubleSlap, Lovely Kiss, Ice Punch, Psychic | +| 3 | 41 | `EXEGGUTOR` | Reflect, Leech Seed, Egg Bomb, Psychic | +| 4 | 41 | `SLOWBRO` | Curse, Amnesia, Body Slam, Psychic | +| 5 | 42 | `XATU` | Quick Attack, Future Sight, Confuse Ray, Psychic | + +Class attributes (`data/trainers/attributes.asm`, `; Will` block): +items `MAX_POTION, NO_ITEM`; base reward 25; DVs `atk 13 / def 12 / spd 13 / spc 13` +(`data/trainers/dvs.asm:15`). Money = base reward x level of the last enemy mon +(`ComputeTrainerReward`, `engine/battle/read_trainer_party.asm:300`) = **25 x 42 = 1050**. + +**Wild encounters** - none. + +--- + +### MAP_KOGAS_ROOM + +- Script: `maps/KogasRoom.asm` +- Blocks: `maps/KogasRoom.blk` +- Header (`data/maps/maps.asm:352`): `map KogasRoom, TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:325` -> `map_const KOGAS_ROOM, 5, 9` +- Attributes: `data/maps/attributes.asm:600`, border `$00` + +`object_const_def`: `KOGASROOM_KOGA`. +Scenes: `SCENE_KOGASROOM_LOCK_DOOR = 0`, `SCENE_KOGASROOM_NOOP = 1`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `WILLS_ROOM` | 2 | +| 2 | 5 | 17 | `WILLS_ROOM` | 3 | +| 3 | 4 | 2 | `BRUNOS_ROOM` | 1 | +| 4 | 5 | 2 | `BRUNOS_ROOM` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `KOGASROOM_KOGA` | `SPRITE_KOGA` (`PAL_NPC_BLUE`) | 5 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `KogaScript_Battle` | -1 | + +**Scripts of interest** + +Structurally identical to Will's room: `KogasRoomDoorLocksBehindYouScript` +(4 x `step UP`, `changeblock 4, 14, $2a`, `setevent EVENT_KOGAS_ROOM_ENTRANCE_CLOSED`, +`setscene SCENE_KOGASROOM_NOOP`), `KogasRoomDoorsCallback` (`MAPCALLBACK_TILES`), +and `KogaScript_Battle` (`5a:5006`) -> `loadtrainer KOGA, KOGA1` / +`setevent EVENT_BEAT_ELITE_4_KOGA` / `changeblock 4, 2, $16` / +`setevent EVENT_KOGAS_ROOM_EXIT_OPEN`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_KOGAS_ROOM_ENTRANCE_CLOSED` | `constants/event_flags.asm:442` | lock script / tiles callback | south door sealed | +| `EVENT_KOGAS_ROOM_EXIT_OPEN` | `constants/event_flags.asm:443` | `KogaScript_Battle` / tiles callback | north door open | +| `EVENT_BEAT_ELITE_4_KOGA` | `constants/event_flags.asm:981` | `KogaScript_Battle` | run-local | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `KOGA1` | `KOGA` (`$f`) | 1 | `KogaGroup` (`data/trainers/parties.asm:249`, symbol `0e:5c89`) | `KogaScript_Battle` | none | + +| # | lvl | species | moves | +|---|---|---|---| +| 1 | 40 | `ARIADOS` | Double Team, Spider Web, Baton Pass, Giga Drain | +| 2 | 41 | `VENOMOTH` | Supersonic, Gust, Psychic, Toxic | +| 3 | 43 | `FORRETRESS` | Protect, Swift, Explosion, Spikes | +| 4 | 42 | `MUK` | Minimize, Acid Armor, Sludge Bomb, Toxic | +| 5 | 44 | `CROBAT` | Double Team, Quick Attack, Wing Attack, Toxic | + +Class attributes (`; Koga`): items `FULL_HEAL, FULL_RESTORE` (the walkthrough's +"three Full Restores" is HGSS; in GS the AI holds **one** Full Heal and **one** +Full Restore per class-attribute slot); base reward 25; DVs `13/12/13/13` +(`data/trainers/dvs.asm:19`). Money = 25 x 44 = **1100**. + +**Wild encounters** - none. + +--- + +### MAP_BRUNOS_ROOM + +- Script: `maps/BrunosRoom.asm` +- Blocks: `maps/BrunosRoom.blk` +- Header (`data/maps/maps.asm:353`): `map BrunosRoom, TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:326` -> `map_const BRUNOS_ROOM, 5, 9` +- Attributes: `data/maps/attributes.asm:601`, border `$00` + +`object_const_def`: `BRUNOSROOM_BRUNO`. +Scenes: `SCENE_BRUNOSROOM_LOCK_DOOR = 0`, `SCENE_BRUNOSROOM_NOOP = 1`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `KOGAS_ROOM` | 3 | +| 2 | 5 | 17 | `KOGAS_ROOM` | 4 | +| 3 | 4 | 2 | `KARENS_ROOM` | 1 | +| 4 | 5 | 2 | `KARENS_ROOM` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BRUNOSROOM_BRUNO` | `SPRITE_BRUNO` (`PAL_NPC_BROWN`) | 5 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `BrunoScript_Battle` | -1 | + +**Scripts of interest** - `BrunosRoomDoorLocksBehindYouScript`, +`BrunosRoomDoorsCallback`, `BrunoScript_Battle` (`5a:5271`); same shape as Will's +room, with `loadtrainer BRUNO, BRUNO1` and +`setevent EVENT_BEAT_ELITE_4_BRUNO` / `EVENT_BRUNOS_ROOM_EXIT_OPEN`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED` | `constants/event_flags.asm:444` | lock script / tiles callback | south door sealed | +| `EVENT_BRUNOS_ROOM_EXIT_OPEN` | `constants/event_flags.asm:445` | `BrunoScript_Battle` / tiles callback | north door open | +| `EVENT_BEAT_ELITE_4_BRUNO` | `constants/event_flags.asm:982` | `BrunoScript_Battle` | run-local | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `BRUNO1` | `BRUNO` (`$d`) | 1 | `BrunoGroup` (`data/trainers/parties.asm:229`, symbol `0e:5c3d`) | `BrunoScript_Battle` | none | + +| # | lvl | species | moves | +|---|---|---|---| +| 1 | 42 | `HITMONTOP` | Pursuit, Quick Attack, Dig, Detect | +| 2 | 42 | `HITMONLEE` | Swagger, Double Kick, Hi Jump Kick, Foresight | +| 3 | 42 | `HITMONCHAN` | ThunderPunch, Ice Punch, Fire Punch, Mach Punch | +| 4 | 43 | `ONIX` | Bind, Earthquake, Sandstorm, Rock Slide | +| 5 | 46 | `MACHAMP` | Rock Slide, Foresight, Vital Throw, Cross Chop | + +Class attributes (`; Bruno`): items `MAX_POTION, NO_ITEM`; base reward 25; DVs +`13/12/13/13` (`data/trainers/dvs.asm:17`). Money = 25 x 46 = **1150**. + +**Wild encounters** - none. + +--- + +### MAP_KARENS_ROOM + +- Script: `maps/KarensRoom.asm` +- Blocks: `maps/KarensRoom.blk` +- Header (`data/maps/maps.asm:354`): `map KarensRoom, TILESET_ELITE_FOUR_ROOM, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:327` -> `map_const KARENS_ROOM, 5, 9` +- Attributes: `data/maps/attributes.asm:602`, border `$00` + +`object_const_def`: `KARENSROOM_KAREN`. +Scenes: `SCENE_KARENSROOM_LOCK_DOOR = 0`, `SCENE_KARENSROOM_NOOP = 1`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `BRUNOS_ROOM` | 3 | +| 2 | 5 | 17 | `BRUNOS_ROOM` | 4 | +| 3 | 4 | 2 | `LANCES_ROOM` | 1 | +| 4 | 5 | 2 | `LANCES_ROOM` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `KARENSROOM_KAREN` | `SPRITE_KAREN` (`PAL_NPC_RED`) | 5 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `KarenScript_Battle` | -1 | + +**Scripts of interest** - `KarensRoomDoorLocksBehindYouScript`, +`KarensRoomDoorsCallback`, `KarenScript_Battle` (`5a:549a`) with +`loadtrainer KAREN, KAREN1`, `setevent EVENT_BEAT_ELITE_4_KAREN`, +`changeblock 4, 2, $16`, `setevent EVENT_KARENS_ROOM_EXIT_OPEN`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_KARENS_ROOM_ENTRANCE_CLOSED` | `constants/event_flags.asm:446` | lock script / tiles callback | south door sealed | +| `EVENT_KARENS_ROOM_EXIT_OPEN` | `constants/event_flags.asm:447` | `KarenScript_Battle` / tiles callback | north door open | +| `EVENT_BEAT_ELITE_4_KAREN` | `constants/event_flags.asm:983` | `KarenScript_Battle` | run-local | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `KAREN1` | `KAREN` (`$e`) | 1 | `KarenGroup` (`data/trainers/parties.asm:239`, symbol `0e:5c63`) | `KarenScript_Battle` | none | + +| # | lvl | species | moves | +|---|---|---|---| +| 1 | 42 | `UMBREON` | Sand-Attack, Confuse Ray, Faint Attack, Mean Look | +| 2 | 42 | `VILEPLUME` | Stun Spore, Acid, Moonlight, Petal Dance | +| 3 | 45 | `GENGAR` | Lick, Spite, Curse, Destiny Bond | +| 4 | 44 | `MURKROW` | Quick Attack, Whirlwind, Pursuit, Faint Attack | +| 5 | 47 | `HOUNDOOM` | Roar, Pursuit, Flamethrower, Crunch | + +Class attributes (`; Karen`): items `FULL_HEAL, MAX_POTION` - **no Full Restore**, +contra the walkthrough; base reward 25; DVs `atk 7 / def 15 / spd 13 / spc 15` +(`data/trainers/dvs.asm:18`, the only Elite Four member with a different DV row). +Money = 25 x 47 = **1175**. + +**Wild encounters** - none. + +--- + +### MAP_LANCES_ROOM + +- Script: `maps/LancesRoom.asm` +- Blocks: `maps/LancesRoom.blk` +- Header (`data/maps/maps.asm:355`): `map LancesRoom, TILESET_CHAMPIONS_ROOM, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_INDIGO_PLATEAU, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:328` -> `map_const LANCES_ROOM, 5, 12` (10 x 24 tiles - the tallest room in the run) +- Attributes: `data/maps/attributes.asm:603`, border `$00` + +`object_const_def`: `LANCESROOM_LANCE`, `LANCESROOM_MARY`, `LANCESROOM_OAK`. +Scenes: `SCENE_LANCESROOM_LOCK_DOOR = 0`, `SCENE_LANCESROOM_APPROACH_LANCE = 1`. +Note this map's second scene is **not** a noop-by-name: it is the state in which +the two coord events are live. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 23 | `KARENS_ROOM` | 3 | +| 2 | 5 | 23 | `KARENS_ROOM` | 4 | +| 3 | 4 | 0 | `HALL_OF_FAME` | 1 | +| 4 | 5 | 0 | `HALL_OF_FAME` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_LANCESROOM_APPROACH_LANCE` (= 1) | 4 | 5 | `Script_ApproachLanceFromLeft` (`5a:5716`) | `FadeOutMusic`, walk `UP UP UP` + face right, fall into `LancesRoomLanceScript` | +| `SCENE_LANCESROOM_APPROACH_LANCE` (= 1) | 5 | 5 | `Script_ApproachLanceFromRight` (`5a:5720`) | `FadeOutMusic`, walk `UP LEFT UP UP` + face right, then the same | + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LANCESROOM_LANCE` | `SPRITE_LANCE` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `LancesRoomLanceScript` | -1 | +| `LANCESROOM_MARY` | `SPRITE_TEACHER` (`PAL_NPC_GREEN`) | 4 | 7 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_LANCES_ROOM_OAK_AND_MARY` | +| `LANCESROOM_OAK` | `SPRITE_OAK` | 4 | 7 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_LANCES_ROOM_OAK_AND_MARY` | + +Mary and Oak share the same spawn tile `(4, 7)` and the same hide flag; both are +`appear`ed one at a time during the cutscene. + +**Scripts of interest** + +- `LancesRoomDoorLocksBehindYouScript` (`5a:56ff`): 4 x `step UP` from + `(4|5, 23)` to y = 19, `reanchormap $85`, `SFX_STRENGTH`, `earthquake 80`, + `changeblock 4, 22, $34` (wall - the champion's-room tileset uses a different + wall block id than the Elite Four rooms' `$2a`), `refreshmap`, + `setscene SCENE_LANCESROOM_APPROACH_LANCE`, + `setevent EVENT_LANCES_ROOM_ENTRANCE_CLOSED`. +- `LancesRoomLanceScript` (`5a:5727`), the section's climax: + `turnobject LANCESROOM_LANCE, LEFT`, intro text, + `winlosstext LanceBattleWinText, 0`, `setlasttalked LANCESROOM_LANCE`, + `loadtrainer CHAMPION, LANCE`, `startbattle`, `dontrestartmapmusic`, + `reloadmapafterbattle`, `setevent EVENT_BEAT_CHAMPION_LANCE`, after-battle + text, `SFX_ENTER_DOOR`, `changeblock 4, 0, $0b` (open the north door), + `setevent EVENT_LANCES_ROOM_ENTRANCE_CLOSED` (again - the script re-sets the + entrance flag here, it does not clear it), then + `musicfadeout MUSIC_BEAUTY_ENCOUNTER, 16` and the cutscene: + `appear LANCESROOM_MARY` -> `LancesRoomMovementData_MaryRushesIn` -> + `appear LANCESROOM_OAK` -> `LancesRoomMovementData_OakWalksIn` -> + `follow`/`stopfollow` pairs -> Mary's interview attempt -> Lance leads the + player north (`follow LANCESROOM_LANCE, PLAYER`, + `LancesRoomMovementData_LanceLeadsPlayerToHallOfFame`), `disappear` both, then + `special FadeOutToWhite` and `warp HALL_OF_FAME, 4, 13`. + + This is the only exit that matters: warps 3/4 exist but the script drives the + transition itself. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_LANCES_ROOM_ENTRANCE_CLOSED` | `constants/event_flags.asm:448` | lock script + `LancesRoomLanceScript`; read by `LancesRoomDoorsCallback` | south door sealed | +| `EVENT_LANCES_ROOM_EXIT_OPEN` | `constants/event_flags.asm:449` | read by `LancesRoomDoorsCallback`; cleared by the lobby callback | **never set by this map** - the north door is opened by a direct `changeblock 4, 0, $0b` in the battle script, and the flag only matters if you re-enter the map | +| `EVENT_BEAT_CHAMPION_LANCE` | `constants/event_flags.asm:984` | set by `LancesRoomLanceScript`, cleared by the lobby callback | run-local | +| `EVENT_LANCES_ROOM_OAK_AND_MARY` | `constants/event_flags.asm:1281` | set on New Game (`std_scripts.asm:523`) and by the lobby callback; cleared by `appear` during the cutscene | hides Mary and Oak until the champion falls | +| `SCENE_LANCESROOM_APPROACH_LANCE` | this map's `def_scene_scripts` (= 1) | `setscene` at the end of the lock script | arms the two coord events at y = 5 | + +**Items** - none. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `LANCE` | `CHAMPION` (`$10`) | 1 | `ChampionGroup` (`data/trainers/parties.asm:259`, symbol `0e:5cae`) | `LancesRoomLanceScript` | none | + +| # | lvl | species | moves | +|---|---|---|---| +| 1 | 44 | `GYARADOS` | Flail, Rain Dance, Surf, Hyper Beam | +| 2 | 47 | `DRAGONITE` | Thunder Wave, Twister, Thunder, Hyper Beam | +| 3 | 47 | `DRAGONITE` | Thunder Wave, Twister, Blizzard, Hyper Beam | +| 4 | 46 | `AERODACTYL` | Wing Attack, AncientPower, Rock Slide, Hyper Beam | +| 5 | 46 | `CHARIZARD` | Flamethrower, Wing Attack, Slash, Hyper Beam | +| 6 | 50 | `DRAGONITE` | Fire Blast, Safeguard, Outrage, Hyper Beam | + +Class attributes (`; Champion`): items `FULL_HEAL, FULL_RESTORE`; base reward 25; +DVs `13/12/13/13` (`data/trainers/dvs.asm:20`). Money = 25 x 50 = **1250**. +`data/trainers/leaders.asm:20` lists `CHAMPION`, but the file's own comment notes +`CHAMPION` and `RED` are unused for the leader battle-music check. + +**Wild encounters** - none. + +--- + +### MAP_HALL_OF_FAME + +- Script: `maps/HallOfFame.asm` +- Blocks: `maps/HallOfFame.blk` +- Header (`data/maps/maps.asm:356`): `map HallOfFame, TILESET_ICE_PATH, INDOOR, LANDMARK_INDIGO_PLATEAU, MUSIC_NEW_BARK_TOWN, TRUE, PALETTE_DAY, FISHGROUP_SHORE` (yes: `TILESET_ICE_PATH` and the New Bark Town theme) +- Dimensions: `constants/map_constants.asm:329` -> `map_const HALL_OF_FAME, 5, 7` (10 x 14 tiles) +- Attributes: `data/maps/attributes.asm:604`, border `$00` + +`object_const_def`: `HALLOFFAME_LANCE`. +Scenes: `SCENE_HALLOFFAME_ENTER = 0`, `SCENE_HALLOFFAME_NOOP = 1`. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 13 | `LANCES_ROOM` | 3 | +| 2 | 5 | 13 | `LANCES_ROOM` | 4 | + +**Coord events** - none (the induction is a scene script). **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `HALLOFFAME_LANCE` | `SPRITE_LANCE` | 4 | 12 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | -1 | + +**Scripts of interest** + +- `HallOfFameEnterScript` (`5a:5cff`), deferred from + `scene_script HallOfFameEnterScene, SCENE_HALLOFFAME_ENTER`. In order: + `follow HALLOFFAME_LANCE, PLAYER`, + `applymovement HALLOFFAME_LANCE, HallOfFame_WalkUpWithLance` (8 x `step UP`, + 1 x `step RIGHT`, `turn_head LEFT`), `stopfollow`, Lance's speech, + `applymovement PLAYER, HallOfFame_SlowlyApproachMachine` (one `slow_step UP`), + `setscene SCENE_HALLOFFAME_NOOP`, then the state writes: + + ``` + setval HEALMACHINE_HALL_OF_FAME ; constants/script_constants.asm:295 + special HealMachineAnim + setevent EVENT_BEAT_ELITE_FOUR ; constants/event_flags.asm:77 + setevent EVENT_TELEPORT_GUY ; removes the Abra ride home + setevent EVENT_RIVAL_SPROUT_TOWER + clearevent EVENT_RED_IN_MT_SILVER ; Red becomes available later + setevent EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME + clearevent EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME + setmapscene SPROUT_TOWER_3F, SCENE_SPROUTTOWER3F_NOOP + special HealParty + checkevent EVENT_GOT_SS_TICKET_FROM_ELM / iftrue .SkipPhoneCall + specialphonecall SPECIALCALL_SSTICKET ; constants/phone_constants.asm:49 + halloffame + ``` + + So the S.S. Ticket call from Elm is queued **here**, and the Kanto half of the + game is switched on by this one script. +- `halloffame` ($9f) -> `Script_halloffame` (`engine/overworld/scripting.asm:2207`) + -> `HallOfFame` (`21:640a`, `engine/events/halloffame.asm:3`): + fade out music, `wSpawnAfterChampion = SPAWN_LANCE`, + `set STATUSFLAGS_HALL_OF_FAME_F`, bump `wHallOfFameCount` (capped at + `HOF_MASTER_COUNT = 200`), `farcall SaveGameData`, `GetHallOfFameParty`, + `AddHallOfFameEntry`, `AnimateHallOfFame`, then `jp Credits` with the + **pre-set** copy of `wStatusFlags` (which is why a first-time champion cannot + hold B to skip the roll). +- After the credits, `ReturnFromCredits` (`engine/overworld/scripting.asm:2216`) + ends the script and returns `MAPSTATUS_DONE`; the game goes to the title + screen. On the next CONTINUE, `engine/menus/intro_menu.asm:296` `.SpawnAfterE4` + sets `wDefaultSpawnpoint = SPAWN_NEW_BARK` and enters via `MAPSETUP_WARP` - + the walkthrough's "the game isn't actually over". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_ELITE_FOUR` | `constants/event_flags.asm:77` | set by `HallOfFameEnterScript` | the durable "League cleared" flag; the five `EVENT_BEAT_ELITE_4_*` flags are wiped by the lobby callback and must not be used for this | +| `EVENT_TELEPORT_GUY` | `constants/event_flags.asm:1310` | set here | Abra ride home disappears | +| `EVENT_RED_IN_MT_SILVER` | `constants/event_flags.asm:1284` | set on New Game (`std_scripts.asm:526`), **cleared** here | Red can now exist at Mt. Silver once the rest of his gate is met | +| `EVENT_RIVAL_SPROUT_TOWER` / `SCENE_SPROUTTOWER3F_NOOP` | `constants/event_flags.asm:1126` / Sprout Tower 3F's scenes | set here | post-game Sprout Tower state | +| `EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME` / `_AFTER_HALL_OF_FAME` | `constants/event_flags.asm:1241-1242` | flipped here | Olivine Port repopulates for the Fast Ship | +| `EVENT_GOT_SS_TICKET_FROM_ELM` | `constants/event_flags.asm:45` | read here | if unset, `specialphonecall SPECIALCALL_SSTICKET` queues Elm's call (`engine/phone/scripts/elm.asm:69`) | +| `STATUSFLAGS_HALL_OF_FAME_F` | `constants/ram_constants.asm` | set by `HallOfFame::` | unlocks the Kanto Pokégear map, gates credit skipping | +| `SCENE_HALLOFFAME_ENTER` / `_NOOP` | this map's `def_scene_scripts` (0 / 1) | `setmapscene` from the lobby callback; `setscene` mid-script | 0 = play the induction | + +**Items** - none. **Trainers** - none. **Wild encounters** - none. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Entry to Victory Road (and therefore to this whole section) | `maps/VictoryRoadGate.asm:_VictoryRoadGateBadgeCheckScript` - `readvar VAR_BADGES` / `ifgreater NUM_JOHTO_BADGES - 1, .AllEightBadges` | all 8 Johto badges | `.AllEightBadges` does `setscene SCENE_VICTORYROADGATE_NOOP`; otherwise you get `applymovement PLAYER, VictoryRoadGateStepDownMovement` and are pushed back. **This gate belongs to the previous section**, listed here only because it is the precondition for everything below | +| Fly to Indigo Plateau | `maps/Route23.asm:Route23FlypointCallback` | one `MAPCALLBACK_NEWMAP` on Route 23 | `setflag ENGINE_FLYPOINT_INDIGO_PLATEAU` | +| No retreat once inside an Elite Four room | `maps/WillsRoom.asm:WillsRoomDoorLocksBehindYouScript` (and the Koga/Bruno/Karen/Lance twins) - `changeblock 4, 14, $2a` (Lance: `changeblock 4, 22, $34`) plus `EVENT_*_ROOM_ENTRANCE_CLOSED` re-applied by each map's `MAPCALLBACK_TILES` | none - it is unconditional on entry | never, within a run. The lobby's `MAPCALLBACK_NEWMAP` clears the flags, but you can only reach the lobby by finishing or blacking out | +| Each room's north door | `WillScript_Battle` / `KogaScript_Battle` / `BrunoScript_Battle` / `KarenScript_Battle` - `changeblock 4, 2, $16` + `setevent EVENT_*_ROOM_EXIT_OPEN`, replayed by the map's `MAPCALLBACK_TILES` | beat that member | door block becomes `$16` (open) and warps at `(4, 2)`/`(5, 2)` become reachable | +| Lance's north door | `maps/LancesRoom.asm:LancesRoomLanceScript` - `changeblock 4, 0, $0b` | beat Lance | opened, but the script warps you through immediately anyway | +| Approaching Lance at all | `def_coord_events` at `(4, 5)` / `(5, 5)` gated on `SCENE_LANCESROOM_APPROACH_LANCE` | that scene is only set at the end of `LancesRoomDoorLocksBehindYouScript` | walking in through warp 1/2 always sets it; a bot teleported straight to y < 5 would skip the fight | +| Lobby rival ambush | `maps/IndigoPlateauPokecenter1F.asm:PlateauRivalBattle1/2` | `EVENT_BEAT_RIVAL_IN_MT_MOON` set, `ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` clear, `VAR_WEEKDAY` in {MONDAY, WEDNESDAY} | not a progress gate - a hazard. Avoid tiles `(16, 4)` / `(17, 4)` if the run cannot afford a level-45-50 six-mon fight | +| No HM field move is required anywhere in this section | grep: no `Route23`/Elite Four map references any `engine/overworld/` field-move script; the walkthrough's "you do NOT need to have every HM" is correct | - | - | + +--- + +## 4. Bot checklist + +Coordinates are the raw asm map coordinates (tile grid, origin top-left, before +the +4 border offset the object/warp readers apply). + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `VICTORY_ROAD` | warp at `(13, 5)` | walk onto warp | 8 Johto badges (already spent at `VictoryRoadGate`) | on `ROUTE_23` at warp 3 `(9, 13)` | +| 2 | `ROUTE_23` | any tile (map load) | step | - | `ENGINE_FLYPOINT_INDIGO_PLATEAU` set | +| 3 | `ROUTE_23` | `(9, 5)` or `(10, 5)` | walk onto warp | - | in `INDIGO_PLATEAU_POKECENTER_1F` at `(5, 13)`/`(6, 13)`; the `MAPCALLBACK_NEWMAP` resets all Elite Four scenes/flags | +| 4 | `INDIGO_PLATEAU_POKECENTER_1F` | `INDIGOPLATEAUPOKECENTER1F_NURSE` at `(3, 7)` | talk (A, facing up from `(3, 8)`) | - | party healed (`jumpstd PokecenterNurseScript`) | +| 5 | `INDIGO_PLATEAU_POKECENTER_1F` | `INDIGOPLATEAUPOKECENTER1F_CLERK` at `(11, 7)` | talk, buy | money | stock Hyper Potion / Full Heal / Revive / Full Restore from `MartIndigoPlateau` | +| 6 | `INDIGO_PLATEAU_POKECENTER_1F` | avoid `(16, 4)` and `(17, 4)` | pathing constraint | only matters if `EVENT_BEAT_RIVAL_IN_MT_MOON` is set and today is Mon/Wed | skipping avoids `RIVAL2_2_*` | +| 7 | `INDIGO_PLATEAU_POKECENTER_1F` | warp at `(14, 3)` | walk onto warp | - | in `WILLS_ROOM` at `(5, 17)`; scene 0 fires `WillsRoomDoorLocksBehindYouScript` | +| 8 | `WILLS_ROOM` | (automatic) | wait out `applymovement` | - | player at `(5, 13)`, `EVENT_WILLS_ROOM_ENTRANCE_CLOSED` set, scene = 1 | +| 9 | `WILLS_ROOM` | `WILLSROOM_WILL` at `(5, 7)` | walk to `(5, 8)`, face up, A | - | win -> `EVENT_BEAT_ELITE_4_WILL`, `EVENT_WILLS_ROOM_EXIT_OPEN` | +| 10 | `WILLS_ROOM` | `(4, 2)` or `(5, 2)` | walk onto warp | `EVENT_WILLS_ROOM_EXIT_OPEN` | in `KOGAS_ROOM` at `(4, 17)`/`(5, 17)` | +| 11 | `KOGAS_ROOM` | `KOGASROOM_KOGA` at `(5, 7)` | walk to `(5, 8)`, face up, A | door-lock cutscene done (player at `(4|5, 13)`) | `EVENT_BEAT_ELITE_4_KOGA`, `EVENT_KOGAS_ROOM_EXIT_OPEN` | +| 12 | `KOGAS_ROOM` | `(4, 2)` / `(5, 2)` | walk onto warp | exit open | in `BRUNOS_ROOM` at `(4, 17)`/`(5, 17)` | +| 13 | `BRUNOS_ROOM` | `BRUNOSROOM_BRUNO` at `(5, 7)` | talk | cutscene done | `EVENT_BEAT_ELITE_4_BRUNO`, `EVENT_BRUNOS_ROOM_EXIT_OPEN` | +| 14 | `BRUNOS_ROOM` | `(4, 2)` / `(5, 2)` | walk onto warp | exit open | in `KARENS_ROOM` at `(4, 17)`/`(5, 17)` | +| 15 | `KARENS_ROOM` | `KARENSROOM_KAREN` at `(5, 7)` | talk | cutscene done | `EVENT_BEAT_ELITE_4_KAREN`, `EVENT_KARENS_ROOM_EXIT_OPEN` | +| 16 | `KARENS_ROOM` | `(4, 2)` / `(5, 2)` | walk onto warp | exit open | in `LANCES_ROOM` at `(4, 23)`/`(5, 23)` | +| 17 | `LANCES_ROOM` | (automatic) | wait out `applymovement` | - | player at `(4|5, 19)`, `EVENT_LANCES_ROOM_ENTRANCE_CLOSED` set, scene = `SCENE_LANCESROOM_APPROACH_LANCE` | +| 18 | `LANCES_ROOM` | walk to `(4, 5)` (or `(5, 5)`) | walk onto coord event | scene = 1 | `Script_ApproachLanceFromLeft/Right` -> Champion battle vs `CHAMPION`/`LANCE` | +| 19 | `LANCES_ROOM` | (automatic) | hold A through the Mary / Oak cutscene | won | `EVENT_BEAT_CHAMPION_LANCE`, then `warp HALL_OF_FAME, 4, 13` | +| 20 | `HALL_OF_FAME` | (automatic) | hold A through Lance's speech | scene = `SCENE_HALLOFFAME_ENTER` | `EVENT_BEAT_ELITE_FOUR`, `EVENT_TELEPORT_GUY`, S.S. Ticket call queued if needed, `halloffame` runs | +| 21 | (screens) | Hall of Fame roster -> credits -> "The End" | wait; B skips **only** if `STATUSFLAGS_HALL_OF_FAME_F` was already set before this induction | - | back to title; save has been written by `SaveGameData` inside the ceremony | +| 22 | (title) | CONTINUE | select | `wSpawnAfterChampion == SPAWN_LANCE` | game resumes at `SPAWN_NEW_BARK`, not at Indigo Plateau | + +Bot notes: + +- Do **not** re-enter `INDIGO_PLATEAU_POKECENTER_1F` mid-run to heal. You cannot + (the doors are sealed), and if some other path got you there, its + `MAPCALLBACK_NEWMAP` wipes `EVENT_BEAT_ELITE_4_*` and re-arms all five + door-lock scenes, restarting the gauntlet from Will. +- Every fight uses `winlosstext <win>, 0` - no loss branch, no + `BATTLETYPE_CANLOSE` - so a loss is a full blackout to the last Pokémon Center. +- After each `startbattle` the scripts run `reloadmapafterbattle`, which re-runs + the map's `MAPCALLBACK_TILES` and therefore restores both the sealed south door + and (once its flag is set) the opened north door. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map/warp/coord/object tables for all 8 maps | extracted generically by `src/import/RomExtractorGen2.lua` into the Gold cache and read by `src/world/gen2/World.lua` + `src/world/gen2/Map.lua` | implemented (data-driven; nothing map-specific is hand-written) | +| `MAPCALLBACK_NEWMAP` / `MAPCALLBACK_TILES` dispatch (the lobby reset, the door callbacks) | `src/world/gen2/World.lua`, asserted end-to-end by `tests/drivers/gold_map_callbacks.lua` | implemented | +| `changeblock` / `refreshmap` door swaps | `src/world/gen2/World.lua:1454` (`World:changeBlock`), `src/script/gen2/Opcodes.lua` | implemented | +| `reanchormap` | `src/script/gen2/Vm.lua:166` - grouped with `opentext`/`closetext` as a no-op | partial (the camera re-anchor the cart does after the wall drops is not reproduced; cosmetic, but the seal-in frame may differ) | +| `earthquake 80` shake | `src/script/gen2/Vm.lua:1044` -> `earthquakeFn`, `World:earthquake` (`src/world/gen2/World.lua:1537`) | implemented | +| Scene scripts / `sdefer` (all five door locks, the Hall of Fame induction) | `src/script/gen2/Vm.lua:92` - `sdefer` is executed **immediately** rather than deferred until the map settles | partial (ordering deviation, called out in the source comment) | +| `setmapscene` / `setscene` | `src/script/gen2/Opcodes.lua`, `World:mapSceneOf` (`src/world/gen2/World.lua:1179`) | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `reloadmapafterbattle` | `src/script/gen2/Vm.lua`, `src/battle/gen2/Battle.lua`, `src/world/gen2/Trainers.lua` | implemented | +| Trainer party data (`WillGroup` … `ChampionGroup`), class DVs, class items | extracted by `src/import/RomExtractorGen2.lua` (`TrainerGroups`, ~line 3871) | implemented (data-driven) | +| AI item use (Koga/Lance Full Restore, Will/Bruno Max Potion, Karen Full Heal) | `src/battle/gen2/Ai.lua:1369-1375`, `src/battle/gen2/Battle.lua:1747` | implemented | +| Prize money `base x last level` | `src/battle/gen2/Prize.lua` (`Prize.rewardLevel`), used at `src/battle/gen2/Battle.lua:1438` | implemented | +| `pokemart MARTTYPE_STANDARD, MART_INDIGO_PLATEAU` | `src/script/gen2/Vm.lua:597`, `src/ui/gen2/MartMenu.lua`, mart tables extracted at `src/import/RomExtractorGen2.lua:2582` | implemented | +| `jumpstd PokecenterNurseScript` (lobby heal) | `src/script/gen2/Vm.lua` `jumpstd` + std-script table | implemented | +| `TeleportGuyScript` warp to New Bark Town, `AbraScript` `cry ABRA` | generic `warp`/`yesorno`/`cry` opcodes | implemented (data-driven) | +| `halloffame` opcode -> induction bookkeeping | `src/script/gen2/Opcodes.lua:171`, `src/script/gen2/Vm.lua:1382`, `src/world/gen2/World.lua:2374` (`World:hallOfFame`), `src/core/gen2/HallOfFame.lua` (`induct`, `bumpCount`, `buildParty`) | implemented | +| Hall of Fame roster screen + credits, first-time-champion skip lockout | `src/ui/gen2/HallOfFame.lua`, `src/ui/gen2/Credits.lua`; eyeball driver `tests/drivers/gold_halloffame_shots.lua` | implemented | +| `wSpawnAfterChampion` -> post-credits respawn at `SPAWN_NEW_BARK` | `src/core/gen2/HallOfFame.lua:51` (`POST_CREDITS_SPAWN`) and `:254` (`consumePostGameSpawn`) | **partial** - the logic exists and is unit-tested (`tests/gen2_halloffame_test.lua:164`), but grepping `src/` finds **no caller**: nothing on the CONTINUE path consumes it, so a port save resumes where it stood rather than at New Bark Town | +| `specialphonecall SPECIALCALL_SSTICKET` from the induction | `src/script/gen2/Vm.lua` (`specialphonecall`), `src/core/gen2/Phone.lua:394` (`SPECIALCALL_SSTICKET`, id 5) | implemented | +| `ENGINE_FLYPOINT_INDIGO_PLATEAU` -> Fly menu + Kanto map unlock | `src/world/gen2/FieldMoves.lua:368` (`SPAWN_INDIGO`, flag 63), `:414` (Kanto half gate) | implemented | +| `ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` daily reset | `src/core/gen2/Apricorns.lua:88` (`DAILY_ENGINE_FLAGS`, id 92) | implemented | +| A Gold-side driver that actually walks Route 23 -> Hall of Fame | none found (`tests/drivers/gold_*.lua` has boot/walk/battle/HOF-screen drivers but no League run; `tests/drivers/bot_route.lua` and `tests/parity_lance.lua` are the **Gen 1** Kanto Elite Four) | missing | + +--- + +## 6. Unresolved / verify by hand + +1. **"Route 32 is very short, and you'll immediately be at the Pokémon League + building."** The map between Victory Road and the Plateau is `MAP_ROUTE_23` + (`constants/map_constants.asm:322`), not Route 32 (which is south of Violet + City). Walkthrough typo. +2. **The walkthrough is describing HeartGold/SoulSilver parties, not Gold.** Four + concrete divergences, all verified against `data/trainers/parties.asm`: + - Will's send-out order is Xatu, Jynx, **Exeggutor, Slowbro**, Xatu; the FAQ + lists Slowbro third and Exeggutor fifth. + - Koga's order is Ariados, **Venomoth, Forretress**, Muk, Crobat; the FAQ + swaps 2 and 3. + - Bruno's order is Hitmontop, **Hitmonlee, Hitmonchan**, Onix, Machamp; the + FAQ swaps 2 and 3. + - Karen and Lance match in order and level. +3. **Prize money.** The FAQ's 4200 / 4400 / 4600 / 4700 / 5000 G are 100 x the + last mon's level (HGSS). GS pays `base reward x level` + (`ComputeTrainerReward`, `engine/battle/read_trainer_party.asm:300`) with a + base reward of 25 for every one of these classes + (`data/trainers/attributes.asm`), i.e. **1050 / 1100 / 1150 / 1175 / 1250 G**. + The EXP figures quoted per Pokémon were not checked against + `data/pokemon/base_stats/` and should be treated as HGSS numbers too. +4. **Held items and AI items.** "Koga has three Full Restores", "Karen has one + Full Restore", "Lance has three Full Restores", "Houndoom holding a Sitrus + Berry", "One of the Dragonites may be holding a Sitrus Berry". In GS these + parties are `TRAINERTYPE_MOVES`, which carries **no held item field at all**, + and the AI's item pool is the two-byte class row: Will `MAX_POTION, NO_ITEM`; + Bruno `MAX_POTION, NO_ITEM`; Karen `FULL_HEAL, MAX_POTION` (no Full Restore); + Koga `FULL_HEAL, FULL_RESTORE`; Champion `FULL_HEAL, FULL_RESTORE`. Sitrus + Berry does not exist in Gen 2. +5. **Move names.** "Dragon Rush" (Lance's Dragonites) is a Gen 4 move; the GS + Dragonites run Thunder Wave / Twister / Thunder or Blizzard / Hyper Beam, and + the level-50 one runs Fire Blast / Safeguard / Outrage / Hyper Beam. Lugia's + "Aero Blast" and Donphan/Sudowoodo suggestions in the sample lineup are fine + as species, but none of that is asm-checkable. +6. **"Routes 46 and 47 aren't bad, either."** `ROUTE_47` does not exist in + pokegold (`constants/map_constants.asm` has `ROUTE_45` and `ROUTE_46` and then + moves on). Route 47/48 are HGSS additions. +7. **"You can go back to the Move Deleter in Blackthorn City."** There is a + `maps/MoveDeletersHouse.asm` reached from `maps/BlackthornCity.asm`; I did not + read its script, so the claim is plausible but unverified here. +8. **"If you trade a Red Scale to Mr. Pokémon, he will give you EXP Share."** + `maps/MrPokemonsHouse.asm` does `checkitem RED_SCALE` (line 53), + `verbosegiveitem EXP_SHARE` (line 72), `takeitem RED_SCALE` (line 74), so the + claim holds; the surrounding flag guards were not read (out of scope). +9. **The "Abra" sprite.** `maps/IndigoPlateauPokecenter1F.asm:324` declares + `SPRITE_JYNX` for the object whose script is `AbraScript` and whose cry is + `cry ABRA`. There is no `SPRITE_ABRA` in `constants/sprite_constants.asm`. + Whether this renders as a Jynx on hardware, or whether some `variablesprite` + elsewhere rewrites it, was not chased down - no `variablesprite` targeting + `SPRITE_JYNX` appears in `engine/events/std_scripts.asm`'s init block. +10. **`EVENT_LANCES_ROOM_EXIT_OPEN` is never set.** `LancesRoomDoorsCallback` + reads it, the lobby callback clears it, but no script in the tree sets it + (grepped `maps/`). The champion's north door is opened by a bare + `changeblock 4, 0, $0b`, which does not survive a map reload. Harmless in + practice because the script warps you out immediately, but a bot that + somehow re-enters Lance's room post-battle will find the door shut again. +11. **`setevent EVENT_LANCES_ROOM_ENTRANCE_CLOSED` appears twice**: once in + `LancesRoomDoorLocksBehindYouScript` (correct) and again immediately after + the champion's after-battle text in `LancesRoomLanceScript`. The second one + looks like it was meant to be the `EXIT_OPEN` set in item 10; recorded as + observed, not corrected. +12. **The lobby's redundant `setscene`.** `PlateauRivalPostBattle` sets the scene + to `SCENE_INDIGOPLATEAUPOKECENTER1F_RIVAL_BATTLE`, which is 0, which is the + map's only scene and its default. Whether this was intended to disarm the + coord events (it does not) is a judgement call; the effective guards are the + engine flag and the weekday check. +13. **"Fully heal your Pokémon and save after every trainer battle."** There is + no heal source inside the sealed rooms - no nurse object, no + `special HealParty` until `HallOfFameEnterScript`. The advice is about bag + items, and the asm agrees that nothing else is available. diff --git a/docs/gold-walkthrough/asm-walk/section-19-s-s-aqua-and-vermilion-city-gym.md b/docs/gold-walkthrough/asm-walk/section-19-s-s-aqua-and-vermilion-city-gym.md new file mode 100644 index 00000000..591f5f4b --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-19-s-s-aqua-and-vermilion-city-gym.md @@ -0,0 +1,1093 @@ +# Section 19 - S.S. Aqua and Vermilion City Gym + +Source: `../section-19-s-s-aqua-and-vermilion-city-gym.txt` +Maps covered: `NEW_BARK_TOWN`, `ELMS_LAB`, `OLIVINE_PORT_PASSAGE`, `OLIVINE_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`, `VERMILION_PORT`, +`VERMILION_PORT_PASSAGE`, `VERMILION_CITY`, `POKEMON_FAN_CLUB`, `VERMILION_GYM` +Badges / key milestones in this section: S.S. TICKET from Prof. Elm, first Kanto +crossing on the Fast Ship, METAL COAT from the grandpa on the S.S. Aqua, +`ENGINE_FLYPOINT_VERMILION`, RARE CANDY from the Fan Club Chairman, +**THUNDERBADGE** (`ENGINE_THUNDERBADGE`) from Lt. Surge. + +This section begins immediately after the Hall of Fame. `maps/HallOfFame.asm` +`HallOfFameEnterScript` is what arms it: it sets `EVENT_BEAT_ELITE_FOUR`, sets +`EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME` (hiding the "no entry" sailor +pair), clears `EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME` (revealing the +boarding sailor), and, if `EVENT_GOT_SS_TICKET_FROM_ELM` is still clear, queues +`specialphonecall SPECIALCALL_SSTICKET` (the Elm call the walkthrough mentions). + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `NEW_BARK_TOWN` | `maps/NewBarkTown.asm` | post-credits respawn | warp 1 `(6,3)` -> `ELMS_LAB` 1 | Elm phones you to come back | +| 2 | `ELMS_LAB` | `maps/ElmsLab.asm` | warp 1/2 `(4,11)`/`(5,11)` | same warps back to `NEW_BARK_TOWN` 1 | talk to `ProfElmScript` -> `ElmGiveTicketScript` -> S.S. TICKET | +| 3 | `OLIVINE_CITY` | `maps/OlivineCity.asm` | Fly (`ENGINE_FLYPOINT_OLIVINE`) | warp 10 `(19,27)` / warp 11 `(20,27)` -> `OLIVINE_PORT_PASSAGE` 1/2 | "go south from the Pokemon Center" to the port entrance | +| 4 | `OLIVINE_PORT_PASSAGE` | `maps/OlivinePortPassage.asm` | warps 1/2 `(15,0)`/`(16,0)` | warp 3 `(15,4)` -> warp 4 `(3,2)`, then warp 5 `(3,14)` -> `OLIVINE_PORT` 1 | "take the stairs down twice" | +| 5 | `OLIVINE_PORT` | `maps/OlivinePort.asm` | warp 1 `(11,7)` | coord_event `(7,15)` -> `OlivinePortWalkUpToShipScript` -> `warp FAST_SHIP_1F, 25, 1` | show the S.S. TICKET, board | +| 6 | `FAST_SHIP_1F` | `maps/FastShip1F.asm` | scripted warp to `(25,1)` | 12 warps (see below) | ship deck hub; grandpa bumps you at `(24,6)`/`(25,6)` | +| 7 | `FAST_SHIP_CABINS_NNW_NNE_NE` | `maps/FastShipCabins_NNW_NNE_NE.asm` | 1F warps 2/3/4 (`(27,8)`, `(23,8)`, `(19,8)`) | same | Hiker Noland; later the lazy Sailor Stanly | +| 8 | `FAST_SHIP_CABINS_SW_SSW_NW` | `maps/FastShipCabins_SW_SSW_NW.asm` | 1F warps 5/6/7 (`(15,8)`, `(15,15)`, `(19,15)`) | same | your cabin + healing bed; Firebreather Lyle | +| 9 | `FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN` | `maps/FastShipCabins_SE_SSE_CaptainsCabin.asm` | 1F warps 8/9/10 (`(23,15)`, `(27,15)`, `(3,13)`) | same | PoKeFan Colin + Twins; grandpa; captain's cabin + granddaughter | +| 10 | `FAST_SHIP_B1F` | `maps/FastShipB1F.asm` | 1F warps 11/12 (`(6,12)`, `(30,14)`) | same | on-duty sailor blocks; Fritz, Jeff, Debra | +| 11 | `VERMILION_PORT` | `maps/VermilionPort.asm` | `FastShip1FSailor1Script` -> `warp VERMILION_PORT, 7, 17` | warp 1 `(9,5)` -> `VERMILION_PORT_PASSAGE` 5 | ship docks in Kanto | +| 12 | `VERMILION_PORT_PASSAGE` | `maps/VermilionPortPassage.asm` | warp 5 `(3,14)` | warp 4 `(3,2)` -> warp 3 `(15,4)`, then warps 1/2 `(15,0)`/`(16,0)` -> `VERMILION_CITY` 8/9 | "down the stairs, then up the stairs and out the door" | +| 13 | `VERMILION_CITY` | `maps/VermilionCity.asm` | warps 8/9 `(19,31)`/`(20,31)` | warp 3 `(7,13)`, warp 7 `(10,19)` | first look at Kanto | +| 14 | `POKEMON_FAN_CLUB` | `maps/PokemonFanClub.asm` | warp 1/2 `(2,7)`/`(3,7)` | same | Chairman's speech -> RARE CANDY | +| 15 | `VERMILION_GYM` | `maps/VermilionGym.asm` | warps 1/2 `(4,17)`/`(5,17)` | same | Vincent, Horton, Gregory, then Lt. Surge -> THUNDERBADGE | + +Spill-over note: the walkthrough's closing line ("Congrats on your first badge in +Kanto") ends the section; Saffron City / Route 6 are handled by the next +section's agent. `VERMILION_CITY` connects `north Route6 (+5)` and +`east Route11 (0)` (`data/maps/attributes.asm:342-344`), and warp 10 `(34,7)` to +`DIGLETTS_CAVE` sits behind the sleeping Snorlax; none of those are used here. + +--- + +## 2. Maps + +### MAP_ELMS_LAB + +- Script: `maps/ElmsLab.asm` +- Blocks: `maps/ElmsLab.blk` +- Header: `data/maps/maps.asm:476` -> `map ElmsLab, TILESET_LAB, INDOOR, LANDMARK_NEW_BARK_TOWN, MUSIC_PROF_ELM, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:441` `map_const ELMS_LAB, 5, 6` (10 x 12 cells) +- Connections: none (indoor) + +Only the S.S. TICKET beat is in scope. Full warp/object tables for the early-game +scenes belong to section 1; the rows below are the ones this section touches. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 11 | `NEW_BARK_TOWN` | 1 | +| 2 | 5 | 11 | `NEW_BARK_TOWN` | 1 | + +**Object events** (`def_object_events`) - relevant row only + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ELMSLAB_ELM` | `SPRITE_ELM` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ProfElmScript` | `-1` | + +**Scripts of interest** + +- `ProfElmScript` (`maps/ElmsLab.asm:50`, sym `60:427b` for `ElmGiveTicketScript`). + First two opcodes after `faceplayer`/`opentext` are the ticket gate: + `checkevent EVENT_GOT_SS_TICKET_FROM_ELM` / `iftrue ElmCheckMasterBall`, then + `checkevent EVENT_BEAT_ELITE_FOUR` / `iftrue ElmGiveTicketScript`. So the ticket + is handed out on the first post-Hall-of-Fame conversation and never again. +- `ElmGiveTicketScript`: `writetext ElmGiveTicketText1` -> + `verbosegiveitem S_S_TICKET` -> `setevent EVENT_GOT_SS_TICKET_FROM_ELM` -> + `writetext ElmGiveTicketText2` -> `closetext` / `end`. + Note there is **no** `iffalse` bag-full guard on this `verbosegiveitem`, unlike + every other Elm gift in the same file - the flag is set unconditionally. +- `engine/phone/scripts/elm.asm:69` `ElmPhoneCallerScript` routes + `SPECIALCALL_SSTICKET` to `.gift`, which prints `ElmPhoneGiftText` and clears + the pending call with `specialphonecall SPECIALCALL_NONE`. The call is only the + nudge; it does not give the item. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_ELITE_FOUR` | `constants/event_flags.asm:77` | set by `maps/HallOfFame.asm:33`; read by `ProfElmScript` | precondition for the ticket | +| `EVENT_GOT_SS_TICKET_FROM_ELM` | `constants/event_flags.asm:45` | set by `ElmGiveTicketScript`; read by `ProfElmScript` and `HallOfFame.asm:41` | one-time ticket guard | +| `SPECIALCALL_SSTICKET` | `constants/phone_constants.asm:49` | queued by `HallOfFame.asm:43`, consumed in `engine/phone/scripts/elm.asm` | the phone nudge; ignorable | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `S_S_TICKET` | talk to Elm | `ElmGiveTicketScript` `verbosegiveitem S_S_TICKET` | `EVENT_GOT_SS_TICKET_FROM_ELM` | + +### MAP_OLIVINE_PORT_PASSAGE + +- Script: `maps/OlivinePortPassage.asm` +- Blocks: `maps/PortPassage.blk` (shared with `VermilionPortPassage`; there is no + `OlivinePortPassage.blk`) +- Header: `data/maps/maps.asm:340` -> `map OlivinePortPassage, TILESET_UNDERGROUND, INDOOR, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:314` `map_const OLIVINE_PORT_PASSAGE, 10, 9` (20 x 18 cells) +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 0 | `OLIVINE_CITY` | 10 | +| 2 | 16 | 0 | `OLIVINE_CITY` | 11 | +| 3 | 15 | 4 | `OLIVINE_PORT_PASSAGE` | 4 | +| 4 | 3 | 2 | `OLIVINE_PORT_PASSAGE` | 3 | +| 5 | 3 | 14 | `OLIVINE_PORT` | 1 | + +**Coord events** - none. + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINEPORTPASSAGE_POKEFAN_M` | `SPRITE_POKEFAN_M` | 17 | 1 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `OlivinePortPassagePokefanMScript` | `EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M` | + +The Pokefan is hidden at new game (`InitializeEventsScript`, +`engine/events/std_scripts.asm:530` sets the flag) and revealed by +`VermilionPortLeaveShipScript` (`clearevent EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M`) +once you have made the crossing once. His text is the Monday/Friday schedule. + +### MAP_OLIVINE_PORT + +- Script: `maps/OlivinePort.asm` +- Blocks: `maps/OlivinePort.blk` +- Header: `data/maps/maps.asm:333` -> `map OlivinePort, TILESET_PORT, ROUTE, LANDMARK_OLIVINE_CITY, MUSIC_VIOLET_CITY, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:307` `map_const OLIVINE_PORT, 10, 18` (20 x 36 cells) +- Attributes: `data/maps/attributes.asm:586` `map_attributes OlivinePort, OLIVINE_PORT, $0a` - no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 7 | `OLIVINE_PORT_PASSAGE` | 5 | +| 2 | 7 | 23 | `FAST_SHIP_1F` | 1 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_OLIVINEPORT_ASK_ENTER_SHIP` (0) | 7 | 15 | `OlivinePortWalkUpToShipScript` (sym `5b:407d`) | boarding prompt + ticket check | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 22 | `BGEVENT_ITEM` | `OlivinePortHiddenProtein` -> `hiddenitem PROTEIN, EVENT_OLIVINE_PORT_HIDDEN_PROTEIN` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OLIVINEPORT_SAILOR1` | `SPRITE_SAILOR` | 7 | 23 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `OlivinePortSailorAtGangwayScript` | `EVENT_OLIVINE_PORT_SAILOR_AT_GANGWAY` | +| `OLIVINEPORT_SAILOR2` | `SPRITE_SAILOR` | 7 | 15 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `OlivinePortSailorBeforeHOFScript` | `EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME` | +| `OLIVINEPORT_SAILOR3` | `SPRITE_SAILOR` | 6 | 15 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `OlivinePortSailorAfterHOFScript` | `EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME` | +| `OLIVINEPORT_FISHING_GURU1` | `SPRITE_FISHING_GURU` | 4 | 14 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `OlivinePortFishingGuru1Script` | `EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME` | +| `OLIVINEPORT_FISHING_GURU2` | `SPRITE_FISHING_GURU` | 13 | 14 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `OlivinePortFishingGuru2Script` | `EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME` | +| `OLIVINEPORT_YOUNGSTER` | `SPRITE_YOUNGSTER` | 4 | 15 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivinePortYoungsterScript` | `EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME` | +| `OLIVINEPORT_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 11 | 15 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `OlivinePortCooltrainerFScript` | `EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME` | + +Object event flags hide when **set**. Post-Hall-of-Fame, `SAILOR2` and the two +fishing gurus are gone and `SAILOR3` / youngster / cooltrainer are present. + +**Scripts of interest** + +- `OlivinePortWalkUpToShipScript` (coord event at `(7,15)`): turns `SAILOR3` + right, bails via `.skip` if either `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1` or + `_2` is set, else asks `OlivinePortAskBoardText` with `yesorno`. + If `EVENT_FAST_SHIP_FIRST_TIME` is **clear** it jumps straight past the + weekday gate (`.FirstTime`) - the maiden voyage sails any day. + Otherwise `readvar VAR_WEEKDAY`: `SUNDAY`/`SATURDAY` -> `.NextShipMonday`, + `TUESDAY`/`WEDNESDAY`/`THURSDAY` -> `.NextShipFriday`. So from Olivine the ship + sails **Monday and Friday**. + Then `checkitem S_S_TICKET`; `iffalse .NoTicket`. On success it sets + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2`, walks the player 7 steps down + (`OlivinePortApproachFastShipFirstTimeMovement`) and `sjump`s into + `OlivinePortSailorAtGangwayScript`. +- `OlivinePortSailorAtGangwayScript`: `disappear OLIVINEPORT_SAILOR1`, one step + down, `special FadeOutToWhite`, then the passenger-roster bookkeeping. + On repeat trips only (`checkevent EVENT_FAST_SHIP_FIRST_TIME` / `iffalse + .FirstTime`) it does `clearevent EVENT_FAST_SHIP_PASSENGERS_EASTBOUND`, + `setevent EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` and clears the eight + eastbound `EVENT_BEAT_*` rematch flags. Always: + `clearevent EVENT_FAST_SHIP_DESTINATION_OLIVINE`, + `appear OLIVINEPORT_SAILOR1`, + `setmapscene FAST_SHIP_1F, SCENE_FASTSHIP1F_ENTER_SHIP`, + `warp FAST_SHIP_1F, 25, 1`. +- `OlivinePortLeaveShipScript` (scene `SCENE_OLIVINEPORT_LEAVE_SHIP`, 1): the + arrival cutscene for the return trip - one step up, `appear` the sailor, + `setscene SCENE_OLIVINEPORT_ASK_ENTER_SHIP`, + `setevent EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`, `blackoutmod OLIVINE_CITY`. + +**Wild encounters** + +`data/wild/johto_water.asm:278` `def_water_wildmons OLIVINE_PORT`, encounter rate +`2 percent`: `20 TENTACOOL / 15 TENTACOOL / 20 TENTACRUEL`. +Fishing group `FISHGROUP_OCEAN` (`data/wild/fish.asm:13`, `.Ocean_*` at lines +42-55). No grass, no headbutt table. + +### MAP_FAST_SHIP_1F + +- Script: `maps/FastShip1F.asm` +- Blocks: `maps/FastShip1F.blk` +- Header: `data/maps/maps.asm:335` -> `map FastShip1F, TILESET_LIGHTHOUSE, INDOOR, LANDMARK_FAST_SHIP, MUSIC_SS_AQUA, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:309` `map_const FAST_SHIP_1F, 16, 9` (32 x 18 cells) +- Connections: none + +Scene ids are declared inline by `scene_script` (`macros/scripts/maps.asm:25`): +`SCENE_FASTSHIP1F_NOOP` = 0, `SCENE_FASTSHIP1F_ENTER_SHIP` = 1, +`SCENE_FASTSHIP1F_MEET_GRANDPA` = 2. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 25 | 1 | `FAST_SHIP_1F` | -1 (arrival tile only) | +| 2 | 27 | 8 | `FAST_SHIP_CABINS_NNW_NNE_NE` | 1 | +| 3 | 23 | 8 | `FAST_SHIP_CABINS_NNW_NNE_NE` | 2 | +| 4 | 19 | 8 | `FAST_SHIP_CABINS_NNW_NNE_NE` | 3 | +| 5 | 15 | 8 | `FAST_SHIP_CABINS_SW_SSW_NW` | 1 | +| 6 | 15 | 15 | `FAST_SHIP_CABINS_SW_SSW_NW` | 2 | +| 7 | 19 | 15 | `FAST_SHIP_CABINS_SW_SSW_NW` | 4 | +| 8 | 23 | 15 | `FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN` | 1 | +| 9 | 27 | 15 | `FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN` | 3 | +| 10 | 3 | 13 | `FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN` | 5 | +| 11 | 6 | 12 | `FAST_SHIP_B1F` | 1 | +| 12 | 30 | 14 | `FAST_SHIP_B1F` | 2 | + +Warp 5 `(15,8)` is "your cabin" (the one with the healing bed). Warp 10 `(3,13)` +is the staircase to the captain's cabin. + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_FASTSHIP1F_MEET_GRANDPA` (2) | 24 | 6 | `WorriedGrandpaSceneLeft` (sym `5b:495d`) | grandpa runs in and bumps you | +| `SCENE_FASTSHIP1F_MEET_GRANDPA` (2) | 25 | 6 | `WorriedGrandpaSceneRight` | `moveobject FASTSHIP1F_GENTLEMAN, 20, 6` then falls through into `WorriedGrandpaSceneLeft` | + +**BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `FASTSHIP1F_SAILOR1` | `SPRITE_SAILOR` | 25 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `FastShip1FSailor1Script` | `-1` | +| `FASTSHIP1F_SAILOR2` | `SPRITE_SAILOR` | 14 | 7 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `FastShip1FSailor2Script` | `-1` | +| `FASTSHIP1F_SAILOR3` | `SPRITE_SAILOR` | 22 | 17 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `FastShip1FSailor3Script` | `-1` | +| `FASTSHIP1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 19 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | `EVENT_FAST_SHIP_1F_GENTLEMAN` | + +**Scripts of interest** + +- `FastShip1FEnterShipScript` (sym `5b:48ad`, scene 1, run via `sdefer`): sailor + steps aside, player walks 2 down, sailor steps back and blocks the door, + `playsound SFX_BOAT`, `earthquake 30`, + `blackoutmod FAST_SHIP_CABINS_SW_SSW_NW` (your blackout respawn becomes your + cabin), `clearevent EVENT_FAST_SHIP_HAS_ARRIVED`. Then + `checkevent EVENT_FAST_SHIP_FIRST_TIME` / `iftrue .SkipGrandpa`: on the first + crossing it sets `SCENE_FASTSHIP1F_MEET_GRANDPA`, otherwise + `SCENE_FASTSHIP1F_NOOP`. +- `WorriedGrandpaSceneLeft`: `appear FASTSHIP1F_GENTLEMAN`, four `big_step RIGHT`, + `playsound SFX_TACKLE`, knocks the player one step right, + `writetext FastShip1FGrandpaText` ("My granddaughter is missing!"), then the + gentleman runs off, `disappear`s and the scene is reset to + `SCENE_FASTSHIP1F_NOOP`. Purely informational - it sets no quest flag. +- `FastShip1FSailor1Script` (the door guard at `(25,2)`): if + `EVENT_FAST_SHIP_HAS_ARRIVED` is clear it just says "en route", branching on + `EVENT_FAST_SHIP_DESTINATION_OLIVINE` for the destination name. Once arrived it + runs `.LetThePlayerOut` (`readvar VAR_FACING`, two step-up variants), then + `setevent EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY` (note: **set** hides that + object), `setmapscene VERMILION_PORT, SCENE_VERMILIONPORT_LEAVE_SHIP` and + `warp VERMILION_PORT, 7, 17`. The Olivine-bound mirror is `._Olivine` -> + `warp OLIVINE_PORT, 7, 23`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_FAST_SHIP_FIRST_TIME` | `constants/event_flags.asm:57` | set by `VermilionPortLeaveShipScript`; read by both port scripts, `FastShip1FEnterShipScript`, `FastShip1FSailor2Script`, `SSAquaCaptain`, `FastShipBed`, `FastShipB1FSailorScript` | "the maiden crossing is finished"; gates the weekday schedule and the grandpa quest | +| `EVENT_FAST_SHIP_HAS_ARRIVED` | `constants/event_flags.asm:58` | cleared on boarding, set by `SSAquaMetalCoatAndDocking` / `FastShipBed.CanArrive`; read by `FastShip1FSailor1Script` | the door guard will only let you off once this is set | +| `EVENT_FAST_SHIP_DESTINATION_OLIVINE` | `constants/event_flags.asm:56` | cleared at Olivine, set at Vermilion | which port the ship is heading for | +| `EVENT_FAST_SHIP_1F_GENTLEMAN` | `constants/event_flags.asm:1230` | set at new game (`std_scripts.asm:497`), `appear`/`disappear` in `WorriedGrandpaSceneLeft` | grandpa sprite visibility | + +### MAP_FAST_SHIP_CABINS_NNW_NNE_NE + +- Script: `maps/FastShipCabins_NNW_NNE_NE.asm` +- Blocks: `maps/FastShipCabins_NNW_NNE_NE.blk` +- Header: `data/maps/maps.asm:336` -> `map FastShipCabins_NNW_NNE_NE, TILESET_LIGHTHOUSE, INDOOR, LANDMARK_FAST_SHIP, MUSIC_SS_AQUA, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:310` `map_const FAST_SHIP_CABINS_NNW_NNE_NE, 4, 16` (8 x 32 cells) +- Layout: three stacked sub-rooms, `y` 0-11, 12-23, 24-31. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 0 | `FAST_SHIP_1F` | 2 | +| 2 | 2 | 12 | `FAST_SHIP_1F` | 3 | +| 3 | 2 | 24 | `FAST_SHIP_1F` | 4 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 6 | 13 | `BGEVENT_READ` | `FastShipCabins_NNW_NNE_NETrashcan` (`jumpstd TrashCanScript`) | +| 7 | 19 | `BGEVENT_READ` | `FastShipCabins_NNW_NNE_NETrashcan` | +| 7 | 31 | `BGEVENT_READ` | `FastShipCabins_NNW_NNE_NETrashcan` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `..._COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 4 | 3 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerCooltrainermSean` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `..._COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 1 | 5 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerCooltrainerfCarol` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `..._SUPER_NERD` | `SPRITE_SUPER_NERD` | 1 | 5 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPokemaniacEthan` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `..._POKEFAN_M` | `SPRITE_POKEFAN_M` | 4 | 17 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerHikerNoland` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `..._SAILOR` | `SPRITE_SAILOR` | 4 | 26 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | 0 | `FastShipLazySailorScript` | `EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR` | +| `..._GENTLEMAN` | `SPRITE_GENTLEMAN` | 7 | 30 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_TRAINER` | 1 | `TrainerGentlemanEdward` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `..._PHARMACIST` | `SPRITE_PHARMACIST` | 2 | 30 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 4 | `TrainerBurglarCorey` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | + +On the maiden voyage only `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` is clear, so +Hiker Noland is the only trainer visible on this map - exactly what the +walkthrough describes. + +**Scripts of interest** + +- `FastShipLazySailorScript` (sym `5b:4d68`): not a `trainer` object, a scripted + battle. `playmusic MUSIC_HIKER_ENCOUNTER` -> `SailorStanlySeenText` -> + `winlosstext SailorStanlyBeatenText, 0` -> `loadtrainer SAILOR, STANLY` -> + `startbattle` -> `reloadmap` -> `special HealParty` -> + `setevent EVENT_BEAT_SAILOR_STANLY` -> after-battle text -> + `setevent EVENT_FAST_SHIP_LAZY_SAILOR` -> + `setmapscene FAST_SHIP_B1F, SCENE_FASTSHIPB1F_NOOP` (this is what unblocks + B1F) -> `readvar VAR_FACING`, walk him out, `disappear`. + Note `special HealParty` fires unconditionally after the fight. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | notes | +|---|---|---|---|---|---| +| `HIKER, NOLAND` | `HIKER` (`$2c`) | 14 | `; HIKER (14) db "NOLAND@", TRAINERTYPE_NORMAL` -> L31 `SANDSLASH`, L33 `GOLEM` | `TrainerHikerNoland` | flag `EVENT_BEAT_HIKER_NOLAND` | +| `SAILOR, STANLY` | `SAILOR` (`$28`) | 9 | `; SAILOR (9) db "STANLY@", TRAINERTYPE_NORMAL` -> L31 `MACHOP`, L33 `MACHOKE`, L26 `PSYDUCK` | `FastShipLazySailorScript` | scripted `loadtrainer`, not an `OBJECTTYPE_TRAINER` | + +### MAP_FAST_SHIP_CABINS_SW_SSW_NW + +- Script: `maps/FastShipCabins_SW_SSW_NW.asm` +- Blocks: `maps/FastShipCabins_SW_SSW_NW.blk` +- Header: `data/maps/maps.asm:337` -> `map FastShipCabins_SW_SSW_NW, TILESET_LIGHTHOUSE, INDOOR, LANDMARK_FAST_SHIP, MUSIC_SS_AQUA, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:311` `map_const FAST_SHIP_CABINS_SW_SSW_NW, 4, 16` (8 x 32 cells) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 0 | `FAST_SHIP_1F` | 5 | +| 2 | 2 | 19 | `FAST_SHIP_1F` | 6 | +| 3 | 3 | 19 | `FAST_SHIP_1F` | 6 | +| 4 | 2 | 31 | `FAST_SHIP_1F` | 7 | +| 5 | 3 | 31 | `FAST_SHIP_1F` | 7 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 1 | `BGEVENT_READ` | `FastShipBed` (sym `5b:521b`) | +| 7 | 2 | `BGEVENT_READ` | `FastShipBed` | +| 7 | 7 | `BGEVENT_READ` | `FastShipCabinsNorthwestCabinTrashcan` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `..._FISHER` | `SPRITE_FISHER` | 1 | 15 | `SPRITEMOVEDATA_SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` | 2 | `TrainerFirebreatherLyle` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `..._BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 6 | 15 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerBugCatcherKen` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `..._BEAUTY` | `SPRITE_BEAUTY` | 1 | 26 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBeautyCassie` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `..._ROCKER` | `SPRITE_ROCKER` | 3 | 28 | `SPRITEMOVEDATA_SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` | 2 | `TrainerGuitaristClyde` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | + +**Scripts of interest** + +- `FastShipBed` (bg_event at `(7,1)` / `(7,2)`, warp-1 sub-room = your cabin): + text, `FadeOutToBlack`, `ReloadSpritesNoPalettes`, `special HealParty`, + `playmusic MUSIC_HEAL`, `pause 60`, `RestartMapMusic`, `FadeInFromBlack`. + Then a docking check: skip if `EVENT_FAST_SHIP_HAS_ARRIVED`; otherwise + `checkevent EVENT_FAST_SHIP_FOUND_GIRL` **or** + `checkevent EVENT_FAST_SHIP_FIRST_TIME` -> `.CanArrive`, which plays + `SFX_ELEVATOR_END`, prints the arrival text and + `setevent EVENT_FAST_SHIP_HAS_ARRIVED`. + **Bot-relevant**: on the maiden voyage, sleeping in the bed does *not* dock the + ship, because both `EVENT_FAST_SHIP_FOUND_GIRL` and `EVENT_FAST_SHIP_FIRST_TIME` + are still clear. You must finish the granddaughter errand. + +**Trainers** + +| const | class | id | party | script label | notes | +|---|---|---|---|---|---| +| `FIREBREATHER, LYLE` | `FIREBREATHER` (`$30`) | 8 | `; FIREBREATHER (8) db "LYLE@", TRAINERTYPE_NORMAL` -> L28 `KOFFING`, L31 `FLAREON`, L28 `KOFFING` | `TrainerFirebreatherLyle` | flag `EVENT_BEAT_FIREBREATHER_LYLE` | + +### MAP_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN + +- Script: `maps/FastShipCabins_SE_SSE_CaptainsCabin.asm` +- Blocks: `maps/FastShipCabins_SE_SSE_CaptainsCabin.blk` +- Header: `data/maps/maps.asm:338` -> `map FastShipCabins_SE_SSE_CaptainsCabin, TILESET_LIGHTHOUSE, INDOOR, LANDMARK_FAST_SHIP, MUSIC_SS_AQUA, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:312` `map_const FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN, 5, 17` (10 x 34 cells) +- Layout: sub-room 1 `y` 0-11 (Colin + Twins), sub-room 2 `y` 12-23 (grandpa), + sub-room 3 `y` 24-33 (captain's cabin). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `FAST_SHIP_1F` | 8 | +| 2 | 3 | 7 | `FAST_SHIP_1F` | 8 | +| 3 | 2 | 19 | `FAST_SHIP_1F` | 9 | +| 4 | 3 | 19 | `FAST_SHIP_1F` | 9 | +| 5 | 2 | 33 | `FAST_SHIP_1F` | 10 | +| 6 | 3 | 33 | `FAST_SHIP_1F` | 10 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 25 | `BGEVENT_READ` | `FastShipCaptainsCabinTrashcan` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `..._CAPTAIN` | `SPRITE_CAPTAIN` | 3 | 25 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `SSAquaCaptain` | `-1` | +| `..._GENTLEMAN` | `SPRITE_GENTLEMAN` | 2 | 17 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | 0 | `SSAquaGrandpa` (sym `5b:561b`) | `EVENT_FAST_SHIP_CABINS_SE_SSE_GENTLEMAN` | +| `..._TWIN1` | `SPRITE_TWIN` | 3 | 17 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | 0 | `SSAquaGranddaughterAfter` | `EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_1` | +| `..._TWIN2` | `SPRITE_TWIN` | 2 | 25 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | 0 | `SSAquaGranddaughterBefore` | `EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2` | +| `..._POKEFAN_M1` | `SPRITE_POKEFAN_M` | 5 | 6 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerPokefanmColin` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `..._TWIN3` | `SPRITE_TWIN` | 2 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 1 | `TrainerTwinsMegandpeg1` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `..._TWIN4` | `SPRITE_TWIN` | 3 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 1 | `TrainerTwinsMegandpeg2` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `..._SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 5 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 5 | `TrainerPsychicRodney` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `..._POKEFAN_M2` | `SPRITE_POKEFAN_M` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPokefanmJeremy` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `..._POKEFAN_F` | `SPRITE_POKEFAN_F` | 5 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerPokefanfGeorgia` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `..._SUPER_NERD2` | `SPRITE_SUPER_NERD` | 1 | 15 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_TRAINER` | 2 | `TrainerSupernerdShawn` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | + +**Scripts of interest** + +- `SSAquaGrandpa` (talk to `..._GENTLEMAN` at `(2,17)`): + `checkevent EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA` -> `SSAquaGotMetalCoat`; + `checkevent EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2` (i.e. the + granddaughter object is *hidden* = already found) -> `SSAquaFoundGranddaughter`; + otherwise the "I can't find my granddaughter" text plus + `setmapscene FAST_SHIP_1F, SCENE_FASTSHIP1F_NOOP`. +- `SSAquaGranddaughterBefore` (talk to `..._TWIN2` at `(2,25)` in the captain's + cabin) - the payoff scene: `FadeOutToBlack`, `disappear` TWIN2, teleport the + player with `SSAquaCaptainsCabinWarpsToGrandpasCabinMovement` + (`big_step RIGHT` + six `big_step UP`), `moveobject TWIN1, 3, 19` + `appear`, + `showemote EMOTE_SHOCK` on the gentleman, dialogue, then + `setevent EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY`, + `setmapscene FAST_SHIP_1F, SCENE_FASTSHIP1F_NOOP`, + `sjump SSAquaMetalCoatAndDocking`. +- `SSAquaMetalCoatAndDocking` (sym `5b:5633`): `verbosegiveitem METAL_COAT`, + `iffalse .NoRoom`, `setevent EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA`, + then `playsound SFX_ELEVATOR_END`, `pause 30`, arrival text, + `setevent EVENT_FAST_SHIP_HAS_ARRIVED`, `setevent EVENT_FAST_SHIP_FOUND_GIRL`. + **This is the docking trigger for the maiden voyage.** A full bag skips the + item but still docks the ship. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `METAL_COAT` | finish the granddaughter errand | `SSAquaMetalCoatAndDocking` / `SSAquaFoundGranddaughter` `verbosegiveitem METAL_COAT` | `EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA` | + +**Trainers** + +| const | class | id | party | script label | notes | +|---|---|---|---|---|---| +| `POKEFANM, COLIN` | `POKEFANM` (`$3b`) | 9 | `; POKEFANM (9) db "COLIN@", TRAINERTYPE_ITEM` -> L32 `DELIBIRD` holding `BERRY` | `TrainerPokefanmColin` | `EVENT_BEAT_POKEFANM_COLIN` | +| `TWINS, MEGANDPEG1` | `TWINS` (`$3d`) | 7 | `; TWINS (7) db "MEG & PEG@", TRAINERTYPE_NORMAL` -> L31 `TEDDIURSA`, L31 `PHANPY` | `TrainerTwinsMegandpeg1` | shares flag `EVENT_BEAT_TWINS_MEG_AND_PEG` | +| `TWINS, MEGANDPEG2` | `TWINS` (`$3d`) | 8 | `; TWINS (8)` -> L31 `PHANPY`, L31 `TEDDIURSA` | `TrainerTwinsMegandpeg2` | same flag - beating either removes both | + +### MAP_FAST_SHIP_B1F + +- Script: `maps/FastShipB1F.asm` +- Blocks: `maps/FastShipB1F.blk` +- Header: `data/maps/maps.asm:339` -> `map FastShipB1F, TILESET_LIGHTHOUSE, INDOOR, LANDMARK_FAST_SHIP, MUSIC_SS_AQUA, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:313` `map_const FAST_SHIP_B1F, 16, 8` (32 x 16 cells) + +Scene ids: `SCENE_FASTSHIPB1F_SAILOR_BLOCKS` = 0, `SCENE_FASTSHIPB1F_NOOP` = 1. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 11 | `FAST_SHIP_1F` | 11 | +| 2 | 31 | 13 | `FAST_SHIP_1F` | 12 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_FASTSHIPB1F_SAILOR_BLOCKS` (0) | 30 | 7 | `FastShipB1FSailorBlocksLeft` | if `EVENT_FAST_SHIP_B1F_SAILOR_RIGHT` set -> no-op; else move sailor2 left, `moveobject FASTSHIPB1F_SAILOR1, 30, 6` + `appear`, `disappear` sailor2 | +| `SCENE_FASTSHIPB1F_SAILOR_BLOCKS` (0) | 31 | 7 | `FastShipB1FSailorBlocksRight` | mirror: `moveobject FASTSHIPB1F_SAILOR2, 31, 6` + `appear`, `disappear` sailor1 | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 27 | 9 | `BGEVENT_READ` | `FastShipB1FTrashcan` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `FASTSHIPB1F_SAILOR1` | `SPRITE_SAILOR` | 30 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `FastShipB1FSailorScript` | `EVENT_FAST_SHIP_B1F_SAILOR_LEFT` | +| `FASTSHIPB1F_SAILOR2` | `SPRITE_SAILOR` | 31 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `FastShipB1FSailorScript` | `EVENT_FAST_SHIP_B1F_SAILOR_RIGHT` | +| `FASTSHIPB1F_SAILOR3` | `SPRITE_SAILOR` | 9 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSailorJeff` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `FASTSHIPB1F_LASS` | `SPRITE_LASS` | 6 | 4 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 1 | `TrainerPicnickerDebra` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `FASTSHIPB1F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 26 | 9 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 1 | `TrainerJugglerFritz` | `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` | +| `FASTSHIPB1F_SAILOR4` | `SPRITE_SAILOR` | 17 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSailorGarrett` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `FASTSHIPB1F_FISHER` | `SPRITE_FISHER` | 25 | 8 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerFisherJonah` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `FASTSHIPB1F_BLACK_BELT` | `SPRITE_BLACK_BELT` | 15 | 11 | `SPRITEMOVEDATA_SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` | 3 | `TrainerBlackbeltWai` | `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND` | +| `FASTSHIPB1F_SAILOR5` | `SPRITE_SAILOR` | 23 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSailorKenneth` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `FASTSHIPB1F_TEACHER` | `SPRITE_TEACHER` | 9 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 3 | `TrainerTeacherShirley` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `FASTSHIPB1F_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 14 | 9 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_TRAINER` | 1 | `TrainerSchoolboyNate` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | +| `FASTSHIPB1F_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 14 | 11 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 1 | `TrainerSchoolboyRicky` | `EVENT_FAST_SHIP_PASSENGERS_WESTBOUND` | + +**Scripts of interest** + +- `FastShipB1FSailorScript` (sym `5b:5e37`), shared by both blocking sailors: + - `checkevent EVENT_FAST_SHIP_FIRST_TIME` / `iftrue .FirstTime` -> on repeat + trips he just gives directions and does not block the story. + - `checkevent EVENT_FAST_SHIP_LAZY_SAILOR` / `iftrue .LazySailor` -> thanks you; + if `EVENT_FAST_SHIP_FOUND_GIRL` is still clear he also prints + `FastShipB1FOnDutySailorSawLittleGirlText` (the "a little girl went by" hint). + - `checkevent EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR` / `iftrue + .AlreadyInformed`. + - First talk: prints the request, `setevent + EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR` and + `clearevent EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR` - **this is what spawns + the lazy sailor** in the NNW/NNE/NE cabins map. +- The physical block is the sailor object standing on `(30,6)` or `(31,6)`; the + coord events at `(30,7)`/`(31,7)` shuffle whichever sailor is active onto the + tile you approached. Once `FastShipLazySailorScript` runs + `setmapscene FAST_SHIP_B1F, SCENE_FASTSHIPB1F_NOOP`, the coord events stop + firing and only one of the two tiles stays occupied. + +**Trainers** + +| const | class | id | party | script label | notes | +|---|---|---|---|---|---| +| `SAILOR, JEFF` | `SAILOR` (`$28`) | 6 | `; SAILOR (6) db "JEFF@", TRAINERTYPE_NORMAL` -> L32 `RATICATE`, L32 `RATICATE` | `TrainerSailorJeff` | `EVENT_BEAT_SAILOR_JEFF` | +| `PICNICKER, DEBRA` | `PICNICKER` (`$35`) | 8 | `; PICNICKER (8) db "DEBRA@", TRAINERTYPE_NORMAL` -> L33 `SEAKING` | `TrainerPicnickerDebra` | `EVENT_BEAT_PICNICKER_DEBRA` | +| `JUGGLER, FRITZ` | `JUGGLER` (`$31`) | 2 | `; JUGGLER (2) db "FRITZ@", TRAINERTYPE_NORMAL` -> L29 `MR__MIME`, L29 `MAGMAR`, L29 `MACHOKE` | `TrainerJugglerFritz` | `EVENT_BEAT_JUGGLER_FRITZ` | + +Note the party order in the asm is Mr. Mime, **Magmar**, Machoke - the +walkthrough lists Mr. Mime, Machoke, Magmar. + +### MAP_VERMILION_PORT + +- Script: `maps/VermilionPort.asm` +- Blocks: `maps/VermilionPort.blk` +- Header: `data/maps/maps.asm:334` -> `map VermilionPort, TILESET_PORT, ROUTE, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:308` `map_const VERMILION_PORT, 10, 18` (20 x 36 cells) +- Attributes: `data/maps/attributes.asm:587` `map_attributes VermilionPort, VERMILION_PORT, $0a` - no connections +- Callback: `callback MAPCALLBACK_NEWMAP, VermilionPortFlypointCallback` -> + `setflag ENGINE_FLYPOINT_VERMILION` + +Scene ids: `SCENE_VERMILIONPORT_ASK_ENTER_SHIP` = 0, +`SCENE_VERMILIONPORT_LEAVE_SHIP` = 1. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 5 | `VERMILION_PORT_PASSAGE` | 5 | +| 2 | 7 | 17 | `FAST_SHIP_1F` | 1 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_VERMILIONPORT_ASK_ENTER_SHIP` (0) | 7 | 11 | `VermilionPortWalkUpToShipScript` | boarding prompt on the way back to Johto | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 13 | `BGEVENT_ITEM` | `VermilionPortHiddenIron` -> `hiddenitem IRON, EVENT_VERMILION_PORT_HIDDEN_IRON` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VERMILIONPORT_SAILOR1` | `SPRITE_SAILOR` | 7 | 17 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `VermilionPortSailorAtGangwayScript` | `EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY` | +| `VERMILIONPORT_SAILOR2` | `SPRITE_SAILOR` | 6 | 11 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VermilionPortSailorScript` | `-1` | +| `VERMILIONPORT_SUPER_NERD` | `SPRITE_SUPER_NERD` | 11 | 11 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `VermilionPortSuperNerdScript` | `-1` | + +**Scripts of interest** + +- `VermilionPortLeaveShipScript` (sym `5b:450e`, scene 1, `sdefer`): the arrival + cutscene. One `step UP`, `appear VERMILIONPORT_SAILOR1`, + `setscene SCENE_VERMILIONPORT_ASK_ENTER_SHIP`, then the end-of-voyage + bookkeeping: + `setevent EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_1`, + `setevent EVENT_FAST_SHIP_CABINS_SE_SSE_GENTLEMAN` (grandpa and granddaughter + vanish from the ship for good), + `setevent EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP` (the eight maiden-voyage + trainers never appear again), + `clearevent EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M`, + `setevent EVENT_FAST_SHIP_FIRST_TIME`, + `setevent EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1`, + `blackoutmod VERMILION_CITY`. +- `VermilionPortWalkUpToShipScript` / `VermilionPortSailorScript`: + `readvar VAR_WEEKDAY`; `MONDAY`/`TUESDAY` -> `.NextShipWednesday`, + `THURSDAY`/`FRIDAY`/`SATURDAY` -> `.NextShipSunday`. So from Vermilion the ship + sails **Wednesday and Sunday**, matching the walkthrough. There is no + first-time bypass on this side. Boarding sets + `EVENT_FAST_SHIP_PASSENGERS_EASTBOUND`, clears `..._WESTBOUND`, clears the ten + westbound `EVENT_BEAT_*` flags, sets + `EVENT_FAST_SHIP_DESTINATION_OLIVINE`, `setmapscene FAST_SHIP_1F, + SCENE_FASTSHIP1F_ENTER_SHIP`, `warp FAST_SHIP_1F, 25, 1`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `IRON` | hidden, face `(16,13)` | `VermilionPortHiddenIron` | `EVENT_VERMILION_PORT_HIDDEN_IRON` | + +**Wild encounters** + +`data/wild/kanto_water.asm:166` `def_water_wildmons VERMILION_PORT`, rate +`2 percent`: `35 TENTACOOL / 30 TENTACOOL / 35 TENTACRUEL`. Fishing +`FISHGROUP_OCEAN`. + +### MAP_VERMILION_PORT_PASSAGE + +- Script: `maps/VermilionPortPassage.asm` +- Blocks: `maps/PortPassage.blk` (shared) +- Header: `data/maps/maps.asm:341` -> `map VermilionPortPassage, TILESET_UNDERGROUND, INDOOR, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:315` `map_const VERMILION_PORT_PASSAGE, 10, 9` (20 x 18 cells) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 0 | `VERMILION_CITY` | 8 | +| 2 | 16 | 0 | `VERMILION_CITY` | 9 | +| 3 | 15 | 4 | `VERMILION_PORT_PASSAGE` | 4 | +| 4 | 3 | 2 | `VERMILION_PORT_PASSAGE` | 3 | +| 5 | 3 | 14 | `VERMILION_PORT` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VERMILIONPORTPASSAGE_TEACHER` | `SPRITE_TEACHER` | 17 | 1 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `VermilionPortPassageTeacherScript` | `-1` | + +### MAP_VERMILION_CITY + +- Script: `maps/VermilionCity.asm` +- Blocks: `maps/VermilionCity.blk` +- Header: `data/maps/maps.asm:296` -> `map VermilionCity, TILESET_KANTO, TOWN, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:273` `map_const VERMILION_CITY, 20, 18` (40 x 36 cells) +- Attributes: `data/maps/attributes.asm:342` `map_attributes VermilionCity, VERMILION_CITY, $43` +- Connections: `connection north, Route6, ROUTE_6, 5`; `connection east, Route11, ROUTE_11, 0` +- Callback: `callback MAPCALLBACK_NEWMAP, VermilionCityFlypointCallback` -> + `setflag ENGINE_FLYPOINT_VERMILION` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `VERMILION_FISHING_SPEECH_HOUSE` | 1 | +| 2 | 9 | 5 | `VERMILION_POKECENTER_1F` | 1 | +| 3 | 7 | 13 | `POKEMON_FAN_CLUB` | 1 | +| 4 | 13 | 13 | `VERMILION_MAGNET_TRAIN_SPEECH_HOUSE` | 1 | +| 5 | 21 | 13 | `VERMILION_MART` | 2 | +| 6 | 21 | 17 | `VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE` | 1 | +| 7 | 10 | 19 | `VERMILION_GYM` | 1 | +| 8 | 19 | 31 | `VERMILION_PORT_PASSAGE` | 1 | +| 9 | 20 | 31 | `VERMILION_PORT_PASSAGE` | 2 | +| 10 | 34 | 7 | `DIGLETTS_CAVE` | 1 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 25 | 3 | `BGEVENT_READ` | `VermilionCitySign` | +| 5 | 19 | `BGEVENT_READ` | `VermilionGymSign` | +| 5 | 13 | `BGEVENT_READ` | `PokemonFanClubSign` | +| 33 | 9 | `BGEVENT_READ` | `VermilionCityDiglettsCaveSign` | +| 27 | 15 | `BGEVENT_READ` | `VermilionCityPortSign` | +| 10 | 5 | `BGEVENT_READ` | `VermilionCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 22 | 13 | `BGEVENT_READ` | `VermilionCityMartSign` (`jumpstd MartSignScript`) | +| 12 | 19 | `BGEVENT_ITEM` | `VermilionCityHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VERMILIONCITY_TEACHER` | `SPRITE_TEACHER` | 18 | 9 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `VermilionCityTeacherScript` | `-1` | +| `VERMILIONCITY_GRAMPS` | `SPRITE_GRAMPS` | 23 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VermilionMachopOwner` | `-1` | +| `VERMILIONCITY_MACHOP` | `SPRITE_MACHOP` | 26 | 7 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `VermilionMachop` | `-1` | +| `VERMILIONCITY_SUPER_NERD` | `SPRITE_SUPER_NERD` | 14 | 16 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `VermilionCitySuperNerdScript` | `-1` | +| `VERMILIONCITY_BIG_SNORLAX` | `SPRITE_BIG_SNORLAX` | 34 | 8 | `SPRITEMOVEDATA_BIGDOLLSYM` | `OBJECTTYPE_SCRIPT` | `VermilionSnorlax` | `EVENT_VERMILION_CITY_SNORLAX` | +| `VERMILIONCITY_POKEFAN_M` | `SPRITE_POKEFAN_M` | 31 | 12 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VermilionGymBadgeGuy` | `-1` | + +**Scripts of interest** + +- `VermilionGymBadgeGuy` at `(31,12)`: `readvar VAR_BADGES`, + `ifequal NUM_BADGES, .AllBadges` (16 badges -> `verbosegiveitem HP_UP`, + `EVENT_GOT_HP_UP_FROM_VERMILION_GUY`), `ifgreater 13`, `ifgreater 9` for the + intermediate lines. Not reachable this section. +- `VermilionSnorlax` at `(34,8)`: `special SnorlaxAwake`; asleep unless the + Poke Flute radio channel is playing. Awake -> `loadvar VAR_BATTLETYPE, + BATTLETYPE_FORCEITEM`, `loadwildmon SNORLAX, 50`, `startbattle`, + `setevent EVENT_FOUGHT_SNORLAX`. Gates warp 10 to `DIGLETTS_CAVE`; out of scope + here but it is the reason the east side is closed. +- The **cut tree**: `maps/VermilionCity.blk` block index `(6,9)` holds + `TILESET_KANTO` block `$35`, whose collision quad is + `tilecoll FLOOR, CUT_TREE, WALL, FLOOR` (`data/tilesets/kanto_collision.asm` + row `$35`). That puts the cuttable tile at cell **`(13,18)`**, replacement + block `$4c`, animation `0` (`data/collision/field_move_blocks.asm:31`). It is + the only cut tree on the map. The hidden Full Heal bg_event at `(12,19)` is the + `WALL` quadrant of the same block. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `FULL_HEAL` | hidden, face `(12,19)` | `VermilionCityHiddenFullHeal` | `EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL` | +| `HP_UP` | all 16 badges (not this section) | `VermilionGymBadgeGuy` `.AllBadges` | `EVENT_GOT_HP_UP_FROM_VERMILION_GUY` | + +**Wild encounters** + +`data/wild/kanto_water.asm:138` `def_water_wildmons VERMILION_CITY`, rate +`6 percent`: `35 TENTACOOL / 30 TENTACOOL / 35 TENTACRUEL`. Fishing +`FISHGROUP_OCEAN` (`data/wild/fish.asm` `.Ocean_Old/Good/Super`, lines 42-55). +No entry in `kanto_grass.asm` and no `treemons.asm` row. + +### MAP_POKEMON_FAN_CLUB + +- Script: `maps/PokemonFanClub.asm` +- Blocks: `maps/PokemonFanClub.blk` +- Header: `data/maps/maps.asm:300` -> `map PokemonFanClub, TILESET_HOUSE, INDOOR, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:277` `map_const POKEMON_FAN_CLUB, 5, 4` (10 x 8 cells) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `VERMILION_CITY` | 3 | +| 2 | 3 | 7 | `VERMILION_CITY` | 3 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 0 | `BGEVENT_READ` | `PokemonFanClubListenSign` | +| 9 | 0 | `BGEVENT_READ` | `PokemonFanClubBraggingSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `POKEMONFANCLUB_CHAIRMAN` | `SPRITE_GENTLEMAN` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubChairmanScript` (sym `59:4340`) | `-1` | +| `POKEMONFANCLUB_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 4 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubReceptionistScript` | `-1` | +| `POKEMONFANCLUB_CLEFAIRY_GUY` | `SPRITE_FISHER` | 2 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubClefairyGuyScript` | `-1` | +| `POKEMONFANCLUB_TEACHER` | `SPRITE_TEACHER` | 7 | 2 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubTeacherScript` | `-1` | +| `POKEMONFANCLUB_FAIRY` | `SPRITE_FAIRY` | 2 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubClefairyDollScript` | `EVENT_VERMILION_FAN_CLUB_DOLL` | +| `POKEMONFANCLUB_ODDISH` | `SPRITE_ODDISH` | 7 | 3 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubBayleefScript` | `-1` | + +**Scripts of interest** + +- `PokemonFanClubChairmanScript`: `checkevent + EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT` -> `.HeardSpeech`; + `checkevent EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL` -> + `.HeardSpeechButBagFull` (skips the speech, retries the gift). Otherwise + `yesorno` on `...DidYouVisitToHearAboutMyMonText`; **answering No** goes to + `.NotListening` and gives nothing. Yes -> the Rapidash speech -> + `verbosegiveitem RARE_CANDY`, `iffalse .BagFull`, + `setevent EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT`. + (Nothing in this file ever *sets* + `EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL`; it is only read.) +- `PokemonFanClubClefairyGuyScript` handles the `LOST_ITEM` / Copycat doll chain + (`EVENT_RETURNED_MACHINE_PART`, `EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM`, + `EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB`) - a later section. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `RARE_CANDY` | answer "Yes" and sit through the speech | `PokemonFanClubChairmanScript` `verbosegiveitem RARE_CANDY` | `EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT` | + +### MAP_VERMILION_GYM + +- Script: `maps/VermilionGym.asm` +- Blocks: `maps/VermilionGym.blk` +- Header: `data/maps/maps.asm:304` -> `map VermilionGym, TILESET_GAME_CORNER, INDOOR, LANDMARK_VERMILION_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:281` `map_const VERMILION_GYM, 5, 9` (10 x 18 cells) +- No scene scripts, no callbacks, no coord events. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `VERMILION_CITY` | 7 | +| 2 | 5 | 17 | `VERMILION_CITY` | 7 | + +**Coord events** - none. (The Gen 1 trash-can/switch puzzle is gone - the guide +NPC even lampshades it in `VermilionGymGuideText`.) + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 7 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 3 | 7 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 5 | 7 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 7 | 7 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 9 | 7 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 1 | 9 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 3 | 9 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 5 | 9 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 7 | 9 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 9 | 9 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 1 | 11 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 3 | 11 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 5 | 11 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 7 | 11 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 9 | 11 | `BGEVENT_READ` | `VermilionGymTrashCan` | +| 3 | 15 | `BGEVENT_READ` | `VermilionGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `VermilionGymStatue` | + +`VermilionGymStatue`: `checkflag ENGINE_THUNDERBADGE` -> if set, +`gettrainername STRING_BUFFER_4, LT_SURGE, LT_SURGE1` + `jumpstd GymStatue2Script`, +else `jumpstd GymStatue1Script`. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `VERMILIONGYM_SURGE` | `SPRITE_SURGE` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `VermilionGymSurgeScript` (sym `59:4bfc`) | `-1` | +| `VERMILIONGYM_GENTLEMAN` | `SPRITE_GENTLEMAN` | 8 | 8 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerGentlemanGregory` | `-1` | +| `VERMILIONGYM_ROCKER` | `SPRITE_ROCKER` | 4 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` (radius x 3) | `OBJECTTYPE_TRAINER` | 3 | `TrainerGuitaristVincent` | `-1` | +| `VERMILIONGYM_SUPER_NERD` | `SPRITE_SUPER_NERD` | 0 | 10 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerJugglerHorton` | `-1` | +| `VERMILIONGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 15 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 1 | `VermilionGymGuideScript` | `-1` | + +**Scripts of interest** + +- `VermilionGymSurgeScript`: `checkflag ENGINE_THUNDERBADGE` / `iftrue + .FightDone`; else `LtSurgeIntroText`, `winlosstext LtSurgeWinLossText, 0`, + `loadtrainer LT_SURGE, LT_SURGE1`, `startbattle`, `reloadmapafterbattle`, + then `setevent EVENT_BEAT_LTSURGE` **and** + `setevent EVENT_BEAT_GENTLEMAN_GREGORY`, + `setevent EVENT_BEAT_GUITARIST_VINCENT`, + `setevent EVENT_BEAT_JUGGLER_HORTON` - beating Surge retroactively clears the + three gym trainers, so a bot may legally skip them. Then + `ReceivedThunderBadgeText`, `playsound SFX_GET_BADGE`, `waitsfx`, + `setflag ENGINE_THUNDERBADGE`, `LtSurgeThunderBadgeText`. + There is no TM award in this gym script. +- `VermilionGymGuideScript` branches on `EVENT_BEAT_LTSURGE`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_THUNDERBADGE` | `constants/engine_flags.asm:49` | set by `VermilionGymSurgeScript`; read there and by `VermilionGymStatue` | the section's terminal milestone | +| `EVENT_BEAT_LTSURGE` | `constants/event_flags.asm:717` | set by `VermilionGymSurgeScript`; read by `VermilionGymGuideScript` | post-win dialogue switch | +| `EVENT_BEAT_GUITARIST_VINCENT` / `EVENT_BEAT_JUGGLER_HORTON` / `EVENT_BEAT_GENTLEMAN_GREGORY` | `constants/event_flags.asm` | set by each `trainer` macro and also by Surge's script | trainer already-beaten guards | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `GUITARIST, VINCENT` | `GUITARIST` (`$2b`) | 2 | `; GUITARIST (2) db "VINCENT@", TRAINERTYPE_NORMAL` -> L27 `MAGNEMITE`, L33 `VOLTORB`, L32 `MAGNEMITE`, L32 `MAGNEMITE` | `TrainerGuitaristVincent` | none | +| `JUGGLER, HORTON` | `JUGGLER` (`$31`) | 3 | `; JUGGLER (3) db "HORTON@", TRAINERTYPE_NORMAL` -> 4x L33 `ELECTRODE` | `TrainerJugglerHorton` | none | +| `GENTLEMAN, GREGORY` | `GENTLEMAN` (`$20`) | 3 | `; GENTLEMAN (3) db "GREGORY@", TRAINERTYPE_NORMAL` -> L37 `PIKACHU`, L33 `FLAAFFY` | `TrainerGentlemanGregory` | none | +| `LT_SURGE, LT_SURGE1` | `LT_SURGE` (`$13`) | 1 | `data/trainers/parties.asm:289-296`, `TRAINERTYPE_MOVES`: L44 `RAICHU` (Thunder Wave / Quick Attack / Thunderbolt / Thunder); L40 `ELECTRODE` (Screech / Double Team / Swift / Explosion); L40 `MAGNETON` (Lock-On / Double Team / Swift / Zap Cannon); L40 `ELECTRODE` (same set); L46 `ELECTABUZZ` (Quick Attack / ThunderPunch / Light Screen / Thunder) | `VermilionGymSurgeScript` | none | + +Party **order** is Raichu, Electrode, Magneton, Electrode, Electabuzz - the +walkthrough lists Raichu, Magneton, Electabuzz, Electrode, Electrode. + +**Wild encounters** - none (indoor). + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Elm will not hand over the ticket | `maps/ElmsLab.asm` `ProfElmScript` `checkevent EVENT_BEAT_ELITE_FOUR` | Hall of Fame done | `maps/HallOfFame.asm:33` `setevent EVENT_BEAT_ELITE_FOUR` | +| Cannot board the Fast Ship | `maps/OlivinePort.asm` `OlivinePortWalkUpToShipScript` / `OlivinePortSailorAfterHOFScript` `checkitem S_S_TICKET`, `iffalse .NoTicket` | `S_S_TICKET` in the bag | `ElmGiveTicketScript` | +| Boarding sailor absent before HOF | `maps/OlivinePort.asm` object rows with `EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME`; the pre-HOF sailor's `OlivinePortSailorBeforeHOFScript` says "you're not allowed in" | Hall of Fame done | `HallOfFame.asm:38` `clearevent EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME` | +| Ship only sails on certain days (repeat trips) | `OlivinePortWalkUpToShipScript` `readvar VAR_WEEKDAY` (Mon/Fri from Olivine); `VermilionPortWalkUpToShipScript` (Wed/Sun from Vermilion) | correct weekday | maiden voyage bypasses via `checkevent EVENT_FAST_SHIP_FIRST_TIME` / `iffalse .FirstTime` | +| Sailor blocks the B1F corridor | `maps/FastShipB1F.asm` coord events `(30,7)`/`(31,7)` -> `FastShipB1FSailorBlocksLeft/Right` plus the physical object at `(30,6)`/`(31,6)`; `FastShipB1FSailorScript` | find and beat the lazy sailor | `FastShipLazySailorScript` -> `setevent EVENT_FAST_SHIP_LAZY_SAILOR` + `setmapscene FAST_SHIP_B1F, SCENE_FASTSHIPB1F_NOOP` | +| Lazy sailor does not exist yet | object flag `EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR`, set at new game (`engine/events/std_scripts.asm:499`) | talk to the on-duty sailor first | `FastShipB1FSailorScript` `clearevent EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR` | +| Ship will not dock (maiden voyage) | `maps/FastShip1F.asm` `FastShip1FSailor1Script` `checkevent EVENT_FAST_SHIP_HAS_ARRIVED`; `FastShipBed` `.CanArrive` needs `EVENT_FAST_SHIP_FOUND_GIRL` or `EVENT_FAST_SHIP_FIRST_TIME` | finish the granddaughter errand | `SSAquaMetalCoatAndDocking` `setevent EVENT_FAST_SHIP_HAS_ARRIVED` / `EVENT_FAST_SHIP_FOUND_GIRL` | +| Chairman's Rare Candy | `maps/PokemonFanClub.asm` `PokemonFanClubChairmanScript` `yesorno` -> `.NotListening` | answer **Yes** | `setevent EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT` | +| Cut tree in Vermilion City | tile collision `CUT_TREE` at cell `(13,18)` + `data/collision/field_move_blocks.asm:31` `db $35, $4c, 0`; `engine/events/overworld.asm:133` `CheckAble` requires `ENGINE_HIVEBADGE`, `:169` `CheckMapForSomethingToCut` | HM01 CUT + HIVEBADGE | already held long before Kanto. **Does not gate the gym** - see section 6 | +| Snorlax blocks `DIGLETTS_CAVE` (warp 10, `(34,7)`) | `maps/VermilionCity.asm` `VermilionSnorlax` `special SnorlaxAwake` | Poke Flute radio channel | out of scope for this section | +| Lt. Surge re-battle refused | `VermilionGymSurgeScript` `checkflag ENGINE_THUNDERBADGE` -> `.FightDone` | - | one-shot badge | + +--- + +## 4. Bot checklist + +1. `NEW_BARK_TOWN` -> walk to `(6,3)`, warp 1, enter `ELMS_LAB`. + Pre: `EVENT_BEAT_ELITE_FOUR` set. (An `SPECIALCALL_SSTICKET` phone call may + fire on the way; answering it is optional.) +2. `ELMS_LAB` -> walk to `(5,3)` and face up to talk to `ELMSLAB_ELM` at `(5,2)`. + Intent: talk. Pre: `EVENT_BEAT_ELITE_FOUR`, `!EVENT_GOT_SS_TICKET_FROM_ELM`. + Post: `EVENT_GOT_SS_TICKET_FROM_ELM`, bag holds `S_S_TICKET`. +3. Fly to `OLIVINE_CITY`; walk to warp 10 `(19,27)` -> `OLIVINE_PORT_PASSAGE`. +4. `OLIVINE_PORT_PASSAGE`: warp 3 `(15,4)` -> lands at warp 4 `(3,2)`; then walk + to warp 5 `(3,14)` -> `OLIVINE_PORT` warp 1 at `(11,7)`. +5. `OLIVINE_PORT`: walk onto `(7,15)`. Intent: walk (coord event). + Post: yes/no prompt -> answer **Yes**; `checkitem S_S_TICKET` passes; the + script auto-walks you 7 south and warps you to `FAST_SHIP_1F (25,1)`. + Post-flags: `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2`, + `!EVENT_FAST_SHIP_DESTINATION_OLIVINE`, scene `FAST_SHIP_1F` = 1. + (Alternative: talk to `OLIVINEPORT_SAILOR3` at `(6,15)` for the same flow.) +6. `FAST_SHIP_1F`: the boarding cutscene runs on entry (scene 1). Then walk south + to `(24,6)` or `(25,6)`. Intent: walk (coord event `WorriedGrandpaSceneLeft`). + Post: scene -> `SCENE_FASTSHIP1F_NOOP`; nothing else changes. +7. `FAST_SHIP_1F` warp 3 at `(23,8)` -> `FAST_SHIP_CABINS_NNW_NNE_NE (2,12)`. + Battle `TrainerHikerNoland` at `(4,17)` (sight 3, faces up). + Post: `EVENT_BEAT_HIKER_NOLAND`. +8. Back to 1F; warp 5 at `(15,8)` -> `FAST_SHIP_CABINS_SW_SSW_NW (2,0)`; face the + bed bg_event at `(7,1)`/`(7,2)` to heal (`FastShipBed` -> `special HealParty`). + Do **not** expect the ship to dock yet. +9. 1F warp 6 at `(15,15)` -> `..._SW_SSW_NW (2,19)`. Battle + `TrainerFirebreatherLyle` at `(1,15)`. Post: `EVENT_BEAT_FIREBREATHER_LYLE`. +10. 1F warp 8 at `(23,15)` -> `..._SE_SSE (2,7)`. Battle `TrainerPokefanmColin` + at `(5,6)` and the twins at `(2,4)`/`(3,4)`. + Post: `EVENT_BEAT_POKEFANM_COLIN`, `EVENT_BEAT_TWINS_MEG_AND_PEG`. +11. 1F warp 9 at `(27,15)` -> `..._SE_SSE (2,19)`. Talk to `SSAquaGrandpa` at + `(2,17)`. Post: `setmapscene FAST_SHIP_1F, SCENE_FASTSHIP1F_NOOP` (flavour + only; the errand is not flagged here). +12. 1F warp 12 at `(30,14)` -> `FAST_SHIP_B1F (31,13)`. Walk north onto `(31,7)` + (or `(30,7)`). Intent: walk (coord event) -> a sailor moves in front of you. + Talk to him. Post: `EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR` set, + `EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR` cleared. +13. Back to 1F; warp 4 at `(19,8)` -> `..._NNW_NNE_NE (2,24)`. Talk to the sailor + at `(4,26)`. Intent: talk -> forced battle `SAILOR STANLY`. + Post: `EVENT_BEAT_SAILOR_STANLY`, `EVENT_FAST_SHIP_LAZY_SAILOR`, + `FAST_SHIP_B1F` scene -> `SCENE_FASTSHIPB1F_NOOP`, party healed. +14. `FAST_SHIP_B1F` again (1F warp 12 -> `(31,13)`): the corridor is now passable. + Battle `TrainerJugglerFritz` at `(26,9)`, `TrainerSailorJeff` at `(9,11)`, + `TrainerPicnickerDebra` at `(6,4)`. +15. `FAST_SHIP_B1F` warp 1 at `(5,11)` -> `FAST_SHIP_1F (6,12)`; then 1F warp 10 + at `(3,13)` -> `..._SE_SSE (2,33)`, the captain's cabin. +16. Talk to the granddaughter (`..._TWIN2`) at `(2,25)`. Intent: talk. + Pre: `!EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2`. + Post: teleport cutscene, `verbosegiveitem METAL_COAT`, + `EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA`, + `EVENT_FAST_SHIP_HAS_ARRIVED`, `EVENT_FAST_SHIP_FOUND_GIRL`, + `EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY`, 1F scene -> NOOP. + You end up standing in the grandpa's sub-room; leave via warp 3/4 `(2,19)`/`(3,19)`. +17. `FAST_SHIP_1F`: walk to `(25,3)` and talk to `FASTSHIP1F_SAILOR1` at `(25,2)`. + Pre: `EVENT_FAST_SHIP_HAS_ARRIVED`. + Post: `warp VERMILION_PORT, 7, 17`, `VERMILION_PORT` scene -> + `SCENE_VERMILIONPORT_LEAVE_SHIP`. +18. `VERMILION_PORT`: the arrival scene runs (one step up). Post-flags include + `EVENT_FAST_SHIP_FIRST_TIME`, `EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP`, + `blackoutmod VERMILION_CITY`, `ENGINE_FLYPOINT_VERMILION` (map callback). + Optional: face `(16,13)` for the hidden `IRON`. +19. Walk to warp 1 `(9,5)` -> `VERMILION_PORT_PASSAGE (3,14)`; warp 4 `(3,2)` -> + warp 3 `(15,4)`; walk to warps 1/2 `(15,0)`/`(16,0)` -> `VERMILION_CITY` + `(19,31)`/`(20,31)`. +20. `VERMILION_CITY` -> warp 3 at `(7,13)` -> `POKEMON_FAN_CLUB (2,7)`. Talk to + the chairman at `(3,1)`; answer **Yes**. + Post: `EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT`, bag holds `RARE_CANDY`. +21. Optional: `VERMILION_POKECENTER_1F` via warp 2 `(9,5)` to heal. +22. Optional: face cell `(13,18)` and use CUT (needs `ENGINE_HIVEBADGE`), then + face `(12,19)` for the hidden `FULL_HEAL`. +23. `VERMILION_CITY` warp 7 at `(10,19)` -> `VERMILION_GYM (4,17)`. +24. Gym: `TrainerGuitaristVincent` at `(4,7)` (sight 3, faces down), + `TrainerJugglerHorton` at `(0,10)` (sight 4, faces right), + `TrainerGentlemanGregory` at `(8,8)` (sight 4, faces left). + All three are optional - Surge's win script sets their flags. +25. Walk to `(5,3)` and talk to `VERMILIONGYM_SURGE` at `(5,2)`. + Pre: `!ENGINE_THUNDERBADGE`. Intent: talk -> battle `LT_SURGE, LT_SURGE1`. + Post: `EVENT_BEAT_LTSURGE`, `EVENT_BEAT_GENTLEMAN_GREGORY`, + `EVENT_BEAT_GUITARIST_VINCENT`, `EVENT_BEAT_JUGGLER_HORTON`, + `ENGINE_THUNDERBADGE`. **Section complete.** + +--- + +## 5. Port coverage + +Everything in this section is data-driven: the Gen 2 side of this repo extracts +map headers, blocks, warps, coord/bg/object events and scene scripts generically +(`src/import/RomExtractorGen2.lua:804-977`) and runs the scripts through +`src/script/gen2/Vm.lua`. There is no per-map hand-port for any of these maps, so +"implemented" below means the generic mechanism exists and the ROM data drives +it, not that the beat has been played through. + +| Beat | Port file | Status | +|---|---|---| +| Map load: blocks, collision quads, warps by cell | `src/world/gen2/Map.lua` | implemented (generic) | +| Warp / coord / bg event dispatch | `src/world/gen2/World.lua:5013` (coord), `:5148` (bg), `:1183` + `:5026` (scene scripts) | implemented (generic) | +| `verbosegiveitem` (S.S. TICKET, METAL COAT, RARE CANDY) | `src/script/gen2/Vm.lua:490-522` | implemented | +| `checkitem S_S_TICKET` boarding gate | `src/script/gen2/Vm.lua:523` | implemented | +| `loadtrainer` / `startbattle` (Stanly, Lt. Surge) | `src/script/gen2/Vm.lua:806`, `:918` (`winlosstext`) | implemented | +| `OBJECTTYPE_TRAINER` sight lines (ship + gym trainers) | `src/world/gen2/Trainers.lua:98` `Trainers.sees` | implemented | +| `TRAINERTYPE_MOVES` parties (Lt. Surge) and `TRAINERTYPE_ITEM` (Colin) | `src/import/RomExtractorGen2.lua:3900-3968` | implemented | +| `setmapscene` / `sdefer` scene plumbing (ports, ship, B1F) | `src/script/gen2/Vm.lua:92`, `:279` | implemented | +| `moveobject` + `appear` sailor-shuffle on B1F | `src/script/gen2/Vm.lua:337-350` (the comment calls out exactly this pattern) | implemented | +| `showemote` (grandpa's `!`) and `earthquake` (boarding) | `src/script/gen2/Vm.lua:961`, `:1799` | implemented | +| `blackoutmod` (cabin / Vermilion respawn) | `src/script/gen2/Vm.lua:1136` | implemented | +| `special HealParty` (bed, post-Stanly), `FadeOutToWhite/Black`, `ReloadSpritesNoPalettes`, `RestartMapMusic` | `src/script/gen2/Specials.lua:450`, `:998`, `:1025`, `:1059` | implemented | +| `specialphonecall SPECIALCALL_SSTICKET` | `src/script/gen2/Vm.lua:1364`; roster entry `src/core/gen2/Phone.lua:395` (`[5] = SPECIALCALL_SSTICKET`, condition `anywhere`) | implemented | +| CUT on the Vermilion tree (`TILESET_KANTO` `$35` -> `$4c`, anim 0) | `src/world/gen2/FieldMoves.lua:191-208`, `:242` | implemented | +| Hidden items (`PROTEIN`, `IRON`, `FULL_HEAL`) | `src/world/gen2/HiddenItems.lua:64`, `:112` | implemented | +| `ENGINE_FLYPOINT_VERMILION` from the map callback | `src/world/gen2/FieldMoves.lua:361` (`LANDMARK_VERMILION_CITY` -> `SPAWN_VERMILION`, flag 57) | implemented | +| `readvar VAR_BADGES` (Vermilion badge guy) | `src/world/gen2/World.lua:117`, `:1240` | implemented | +| `special SnorlaxAwake` | `src/script/gen2/Specials.lua:1562` | implemented | +| Water encounters / `FISHGROUP_OCEAN` at the ports | `src/battle/gen2/Encounter.lua` | not verified in this pass - no port-specific check was run | +| A driver that exercises the crossing end to end | `tests/drivers/gold_*.lua` | **missing** - no `gold_fastship_*`, `gold_vermilion_*` or `gold_surge_*` driver exists | +| Any hand-ported script for these 14 maps | - | **missing by design**; all behaviour comes from the extracted ROM script bytes | + +Note: the many `Vermilion*` hits under `src/world/OverworldController.lua` and +`src/world/FieldDefaults.lua` are the **Gen 1** (pokered) Vermilion Gym trash-can +puzzle. That puzzle does not exist in Gold, and none of that code is on the Gen 2 +path. + +--- + +## 6. Unresolved / verify by hand + +- **"cut down the tree blocking the gym" is wrong.** `maps/VermilionCity.blk` has + exactly one cuttable block, `$35` at block `(6,9)`, whose `CUT_TREE` quadrant is + cell `(13,18)`. The gym door is the `DOOR` quadrant of block `$12` at + `(5,9)` = cell `(10,19)`, and the tiles south of it (`(10,20)`, `(11,20)`, + `(12,20)`) are all `FLOOR`. Walking a straight path from the port entrance to + the gym door never touches the tree. Cutting it only opens cell `(13,18)`, which + is beside the hidden `FULL_HEAL` at `(12,19)`. Verified against + `data/tilesets/kanto_collision.asm` rows `$12`, `$35`, `$4c`, `$31`. +- **"Meal Coat"** in the walkthrough's item list is a typo for `METAL_COAT` + (`SSAquaMetalCoatAndDocking`). No `MEAL_*` item exists. +- **Lt. Surge's party order** in the walkthrough (Raichu, Magneton, Electabuzz, + Electrode, Electrode) does not match `data/trainers/parties.asm:289-296` + (Raichu, Electrode, Magneton, Electrode, Electabuzz). +- **Juggler Fritz's party order**: asm is Mr. Mime / Magmar / Machoke; the + walkthrough says Mr. Mime / Machoke / Magmar. +- **"Lt. Surge has a Full Restore"** - the party entry is `TRAINERTYPE_MOVES`, + which carries **no** held item. Gym-leader item use is AI behaviour + (`data/trainers/attributes.asm` / the trainer-class AI item list), which was not + opened in this pass. Verify before relying on it. +- **Prize money figures** ("You get: 1056G", "4600G", "???G" for Colin) are not + stored anywhere in the asm as literals - Gen 2 computes them from base money x + level. Not reproduced here. +- **EXP values** quoted per Pokemon are party- and level-dependent runtime + calculations, not table data. Not reproduced here. +- **`EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL`** is read by + `PokemonFanClubChairmanScript` but is never `setevent`'d anywhere in + `maps/PokemonFanClub.asm`. Either it is set elsewhere (nothing found by grep) or + it is dead in this revision; a full-bag player appears to have to sit through + the Rapidash speech again. +- **"Note the new Pokemon music for Kanto"**: the Vermilion maps use + `MUSIC_VERMILION_CITY` (`data/maps/maps.asm:296`). Whether that is the "Kanto + remix" the walkthrough means was not checked against the audio engine. +- The walkthrough's cabin-by-cabin narration ("two cabins to the right", "the + cabin next to yours") was reconciled against the 1F warp `x` positions + (north row `x` = 27/23/19/15, south row `x` = 15/19/23/27) and is consistent, + but the prose ordering of trainers within `FAST_SHIP_B1F` ("head left, then + down") was not re-walked tile by tile. diff --git a/docs/gold-walkthrough/asm-walk/section-20-saffron-city-gym.md b/docs/gold-walkthrough/asm-walk/section-20-saffron-city-gym.md new file mode 100644 index 00000000..086d72f8 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-20-saffron-city-gym.md @@ -0,0 +1,680 @@ +# Section 20 - Saffron City Gym + +Source: `../section-20-saffron-city-gym.txt` (the FAQ numbers this chapter "26 > Saffron City Gym") +Maps covered: `MAP_ROUTE_6`, `MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE`, `MAP_ROUTE_6_SAFFRON_GATE`, `MAP_SAFFRON_CITY`, `MAP_MR_PSYCHICS_HOUSE`, `MAP_SILPH_CO_1F`, `MAP_SAFFRON_MAGNET_TRAIN_STATION`, `MAP_SAFFRON_MART`, `MAP_FIGHTING_DOJO`, `MAP_SAFFRON_GYM`, `MAP_SAFFRON_POKECENTER_1F` +Badges / key milestones in this section: MARSHBADGE (`ENGINE_MARSHBADGE`), TM29 Psychic, Up-Grade, Focus Band, `ENGINE_FLYPOINT_SAFFRON` unlocked on first entry to Saffron City. + +Map group / id pairs a bot needs (from `constants/map_constants.asm`): + +| Map constant | group | id | +|---|---|---| +| `MAP_ROUTE_6` | 12 (`VERMILION`) | 1 | +| `MAP_ROUTE_6_SAFFRON_GATE` | 12 | 12 | +| `MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE` | 12 | 13 | +| `MAP_SAFFRON_CITY` | 25 (`SAFFRON`) | 2 | +| `MAP_FIGHTING_DOJO` | 25 | 3 | +| `MAP_SAFFRON_GYM` | 25 | 4 | +| `MAP_SAFFRON_MART` | 25 | 5 | +| `MAP_SAFFRON_POKECENTER_1F` | 25 | 6 | +| `MAP_MR_PSYCHICS_HOUSE` | 25 | 8 | +| `MAP_SAFFRON_MAGNET_TRAIN_STATION` | 25 | 9 | +| `MAP_SILPH_CO_1F` | 25 | 10 | + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_VERMILION_CITY` | `maps/VermilionCity.asm` (previous section) | - | north map connection | "Heal at the Pokémon Center, then head north onto Route 6" - Vermilion belongs to the previous section | +| 1 | `MAP_ROUTE_6` | `maps/Route6.asm` | south connection from Vermilion City | warp 2 `(6, 1)` -> `ROUTE_6_SAFFRON_GATE` warp 3 | "barren wasteland"; the Underground Path door is blocked | +| 1a | `MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE` | `maps/Route6UndergroundPathEntrance.asm` | warp 1 `(17, 3)` on Route 6 | - | Walkthrough only notes it is blocked; do not enter this section | +| 2 | `MAP_ROUTE_6_SAFFRON_GATE` | `maps/Route6SaffronGate.asm` | warp 3/4 `(4, 7)` / `(5, 7)` | warp 1/2 `(4, 0)` / `(5, 0)` -> `SAFFRON_CITY` warps 12 / 13 | "make your way all the way up through the checkpoint house" | +| 3 | `MAP_SAFFRON_CITY` | `maps/SaffronCity.asm` | warp 12 `(16, 33)` / warp 13 `(17, 33)` | see below | Hub for the rest of the section; `MAPCALLBACK_NEWMAP` sets the fly point | +| 4 | `MAP_MR_PSYCHICS_HOUSE` | `maps/MrPsychicsHouse.asm` | Saffron warp 5 `(27, 29)` | warp 1/2 `(2, 7)` / `(3, 7)` -> Saffron warp 5 | "Mr. Psychic ... gets you TM29 Psychic" | +| 5 | `MAP_SILPH_CO_1F` | `maps/SilphCo1F.asm` | Saffron warp 7 `(18, 21)` | warp 1/2 `(2, 7)` / `(3, 7)` -> Saffron warp 7 | "Talk to the man blocking the pathway ... he'll give you Up-Grade" | +| 6 | `MAP_SAFFRON_MAGNET_TRAIN_STATION` | `maps/SaffronMagnetTrainStation.asm` | Saffron warp 6 `(8, 3)` | warp 1/2 `(8, 17)` / `(9, 17)` -> Saffron warp 6 | "On the northwest side of town is the Magnet Train ... currently closed" | +| 7 | `MAP_SAFFRON_MART` | `maps/SaffronMart.asm` | Saffron warp 3 `(25, 11)` | warp 1/2 `(2, 7)` / `(3, 7)` -> Saffron warp 3 | "There's a PokeMart if you want to buy or sell items" | +| 8 | `MAP_FIGHTING_DOJO` | `maps/FightingDojo.asm` | Saffron warp 1 `(26, 3)` | warp 1/2 `(4, 11)` / `(5, 11)` -> Saffron warp 1 | "The former fighting gym has a Focus Band you can pick up" | +| 9 | `MAP_SAFFRON_GYM` | `maps/SaffronGym.asm` | Saffron warp 2 `(34, 3)` | warp 1/2 `(8, 17)` / `(9, 17)` -> Saffron warp 2 | "head to the gym on the right ... Sabrina" -> MARSHBADGE | +| 9a | `MAP_SAFFRON_POKECENTER_1F` | `maps/SaffronPokecenter1F.asm` | Saffron warp 4 `(9, 29)` | warp 1/2 `(3, 7)` / `(4, 7)` -> Saffron warp 4 | Optional mid-gym heal trip described in the walkthrough | + +Spillover: the walkthrough's opening line ("Heal at the Pokémon Center") refers to Vermilion City, and its closing text ends inside the Saffron Gym exit - nothing past Saffron is described. `MAP_COPYCATS_HOUSE_1F` (Saffron warp 8, `(9, 11)`) exists on this map but the walkthrough never enters it in this section. + +## 2. Maps + +### MAP_ROUTE_6 + +- Script: `maps/Route6.asm` +- Blocks: `maps/Route6.blk` (`data/maps/blocks.asm:562` `Route6_Blocks`) +- Header: `data/maps/maps.asm:294` -> `TILESET_KANTO`, `ROUTE`, `LANDMARK_ROUTE_6`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:271` `map_const ROUTE_6, 10, 9` (10x9 blocks = 20x18 cells) +- Connections: `data/maps/attributes.asm:346-348` - north `SaffronCity` (offset -5), south `VermilionCity` (offset -5). No east/west. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 3 | `ROUTE_6_UNDERGROUND_PATH_ENTRANCE` | 1 | +| 2 | 6 | 1 | `ROUTE_6_SAFFRON_GATE` | 3 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 19 | 5 | `BGEVENT_READ` | `Route6UndergroundPathSign` (`Route6UndergroundPathSignText`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE6_POKEFAN_M` | `SPRITE_POKEFAN_M` | 17 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` (sight field 2, unused) | `Route6PokefanMScript` (`4f:581a`) | `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` | + +**Scripts of interest** + +- `Route6PokefanMScript` (`maps/Route6.asm:9`, sym `4f:581a`): a bare `jumptextfaceplayer Route6PokefanMText` - "The road is closed until the problem at the POWER PLANT is solved." No flag reads. He stands at `(17, 4)`, directly south of the Underground Path door at `(17, 3)`, so his body is the block, not a script check. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` | `constants/event_flags.asm:1299` | set by `maps/PowerPlant.asm:164`; consumed as the object's event flag on `maps/Route6.asm:43` and `maps/Route5.asm:56` | Flag **set** hides the object (`CheckObjectFlag`, `engine/overworld/map_objects_2.asm:32`). Clear (default) = the man is standing there = Underground Path shut | + +**Items** - none on this map. + +**Trainers** - none on this map. + +**Wild encounters** + +`data/wild/kanto_grass.asm:480` `def_grass_wildmons ROUTE_6`, encounter rates 10/10/10 percent (morn/day/nite). Gold block (`IF DEF(_GOLD)`, lines 483-506): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L13 `PIDGEY` | L13 `PIDGEY` | L13 `ODDISH` | +| 2 | L13 `BELLSPROUT` | L13 `BELLSPROUT` | L14 `ODDISH` | +| 3 | L14 `PIDGEY` | L14 `PIDGEY` | L13 `BELLSPROUT` | +| 4 | L15 `MAGNEMITE` | L15 `MAGNEMITE` | L15 `MAGNEMITE` | +| 5 | L12 `ABRA` | L12 `ABRA` | L12 `ABRA` | +| 6 | L14 `ABRA` | L14 `ABRA` | L14 `ABRA` | +| 7 | L14 `ABRA` | L14 `ABRA` | L14 `ABRA` | + +Silver (`ELIF DEF(_SILVER)`, lines 508-531) swaps the third slot for `MEOWTH` (L14) morn/day and slots 2/3 for `MEOWTH`/`BELLSPROUT` at nite. + +Water: `data/wild/kanto_water.asm:12` `def_water_wildmons ROUTE_6`, 2 percent - L10 `PSYDUCK`, L5 `PSYDUCK`, L10 `GOLDUCK`. + +Fishing group is `FISHGROUP_POND` (`data/maps/maps.asm:294`). + +### MAP_ROUTE_6_UNDERGROUND_PATH_ENTRANCE + +- Script: `maps/Route6UndergroundPathEntrance.asm` +- Blocks: shared `maps/UndergroundPathEntrance.blk` (`data/maps/blocks.asm:466`) +- Header: `data/maps/maps.asm:306` -> `TILESET_GATE`, `GATE`, `LANDMARK_ROUTE_6`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:283` `map_const ROUTE_6_UNDERGROUND_PATH_ENTRANCE, 4, 4` +- Connections: none (indoor) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ROUTE_6` | 1 | +| 2 | 4 | 7 | `ROUTE_6` | 1 | +| 3 | 4 | 3 | `UNDERGROUND_PATH` | 2 | + +No coord events, no bg events, no object events. Nothing gates warp 3 from the inside - the only block is the NPC body on Route 6. + +### MAP_ROUTE_6_SAFFRON_GATE + +- Script: `maps/Route6SaffronGate.asm` +- Blocks: shared `maps/NorthSouthGate.blk` (`data/maps/blocks.asm:210,215`) +- Header: `data/maps/maps.asm:305` -> `TILESET_GATE`, `GATE`, `LANDMARK_ROUTE_6`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:282` `map_const ROUTE_6_SAFFRON_GATE, 5, 4` +- Scene scripts: one entry, `scene_script Route6SaffronGateNoopScene` with no scene-id argument (`maps/Route6SaffronGate.asm:6`), so no `SCENE_*` constant is generated and the scene is a bare `end`. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `SAFFRON_CITY` | 12 | +| 2 | 5 | 0 | `SAFFRON_CITY` | 13 | +| 3 | 4 | 7 | `ROUTE_6` | 2 | +| 4 | 5 | 7 | `ROUTE_6` | 2 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE6SAFFRONGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route6SaffronGuardScript` (`59:523c`) | -1 (always) | + +**Scripts of interest** + +- `Route6SaffronGuardScript` (`maps/Route6SaffronGate.asm:13`, sym `59:523c`): `faceplayer` / `opentext` / `checkevent EVENT_RETURNED_MACHINE_PART` / `iftrue .ReturnedPart`. Before the Power Plant is fixed he explains the Magnet Train is dead; after, a one-liner. He stands at `(0, 4)`, off the walking lane - he never blocks passage. + +### MAP_SAFFRON_CITY + +- Script: `maps/SaffronCity.asm` +- Blocks: `maps/SaffronCity.blk` (`data/maps/blocks.asm:24`) +- Header: `data/maps/maps.asm:490` -> `TILESET_KANTO`, `TOWN`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:454` `map_const SAFFRON_CITY, 20, 18` (40x36 cells) +- Connections: `data/maps/attributes.asm:350-354` - north `Route5` (+5), south `Route6` (+5), west `Route7` (+9), east `Route8` (+9) +- Spawn / fly: `data/maps/spawn_points.asm:21` `spawn SAFFRON_CITY, 9, 30`; `data/maps/flypoints.asm:26` `db LANDMARK_SAFFRON_CITY, SPAWN_SAFFRON` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 26 | 3 | `FIGHTING_DOJO` | 1 | +| 2 | 34 | 3 | `SAFFRON_GYM` | 1 | +| 3 | 25 | 11 | `SAFFRON_MART` | 2 | +| 4 | 9 | 29 | `SAFFRON_POKECENTER_1F` | 1 | +| 5 | 27 | 29 | `MR_PSYCHICS_HOUSE` | 1 | +| 6 | 8 | 3 | `SAFFRON_MAGNET_TRAIN_STATION` | 2 | +| 7 | 18 | 21 | `SILPH_CO_1F` | 1 | +| 8 | 9 | 11 | `COPYCATS_HOUSE_1F` | 1 | +| 9 | 18 | 3 | `ROUTE_5_SAFFRON_GATE` | 3 | +| 10 | 0 | 24 | `ROUTE_7_SAFFRON_GATE` | 3 | +| 11 | 0 | 25 | `ROUTE_7_SAFFRON_GATE` | 4 | +| 12 | 16 | 33 | `ROUTE_6_SAFFRON_GATE` | 1 | +| 13 | 17 | 33 | `ROUTE_6_SAFFRON_GATE` | 2 | +| 14 | 39 | 22 | `ROUTE_8_SAFFRON_GATE` | 1 | +| 15 | 39 | 23 | `ROUTE_8_SAFFRON_GATE` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 21 | 5 | `BGEVENT_READ` | `SaffronCitySign` | +| 33 | 5 | `BGEVENT_READ` | `SaffronGymSign` | +| 25 | 5 | `BGEVENT_READ` | `FightingDojoSign` | +| 15 | 21 | `BGEVENT_READ` | `SilphCoSign` | +| 25 | 29 | `BGEVENT_READ` | `MrPsychicsHouseSign` | +| 11 | 5 | `BGEVENT_READ` | `SaffronCityMagnetTrainStationSign` | +| 10 | 29 | `BGEVENT_READ` | `SaffronCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 26 | 11 | `BGEVENT_READ` | `SaffronCityMartSign` (`jumpstd MartSignScript`) | + +No hidden items on this map. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SAFFRONCITY_LASS1` | `SPRITE_LASS` | 7 | 14 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (rx 2) | `OBJECTTYPE_SCRIPT` | `SaffronCityLass1Script` | -1 | +| `SAFFRONCITY_POKEFAN_M` | `SPRITE_POKEFAN_M` | 19 | 30 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (rx 2) | `OBJECTTYPE_SCRIPT` | `SaffronCityPokefanMScript` | -1 | +| `SAFFRONCITY_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 32 | 7 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (rx 1) | `OBJECTTYPE_SCRIPT` | `SaffronCityCooltrainerMScript` | -1 | +| `SAFFRONCITY_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 20 | 24 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (rx 2) | `OBJECTTYPE_SCRIPT` | `SaffronCityCooltrainerFScript` | -1 | +| `SAFFRONCITY_FISHER` | `SPRITE_FISHER` | 27 | 12 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SaffronCityFisherScript` | -1 | +| `SAFFRONCITY_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 15 | 19 | `SPRITEMOVEDATA_WALK_UP_DOWN` (ry 1) | `OBJECTTYPE_SCRIPT` | `SaffronCityYoungster1Script` | -1 | +| `SAFFRONCITY_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 35 | 22 | `SPRITEMOVEDATA_WANDER` (rx 1, ry 1) | `OBJECTTYPE_SCRIPT` | `SaffronCityYoungster2Script` | -1 | +| `SAFFRONCITY_LASS2` | `SPRITE_LASS` | 19 | 8 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `SaffronCityLass2Script` | -1 | + +Every Saffron City NPC is `OBJECTTYPE_SCRIPT`; none of them blocks a warp tile. + +**Scripts of interest** + +- `SaffronCityFlypointCallback` (`maps/SaffronCity.asm:17`), registered as `callback MAPCALLBACK_NEWMAP`: `setflag ENGINE_FLYPOINT_SAFFRON` / `endcallback`. Walking in from the Route 6 gate is what unlocks Fly to Saffron - there is no NPC or item involved. +- `SaffronCityLass1Script`, `SaffronCityPokefanMScript`, `SaffronCityFisherScript` all branch on `checkevent EVENT_RETURNED_MACHINE_PART`. Purely dialogue; useful only as a state probe. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_SAFFRON` | `constants/engine_flags.asm:74` | set by `SaffronCityFlypointCallback` | Fly destination unlocked; also the `SPAWN_SAFFRON` blackout target once the player has been here | +| `EVENT_RETURNED_MACHINE_PART` | `constants/event_flags.asm:200` | set in `maps/PowerPlant.asm:161`; read by four Saffron scripts and `Route6SaffronGuardScript` | Not set in this section. Its absence is what keeps the Magnet Train shut | + +**Items** - none directly on the city map. + +**Trainers** - none. + +**Wild encounters** - none. `SAFFRON_CITY` has no entry in `data/wild/kanto_grass.asm`, `kanto_water.asm`, `fish.asm` or `treemons.asm` (grep over `data/wild/` for `SAFFRON` returns nothing). + +### MAP_MR_PSYCHICS_HOUSE + +- Script: `maps/MrPsychicsHouse.asm` +- Blocks: shared `maps/House1.blk` (`data/maps/blocks.asm:195,201`) +- Header: `data/maps/maps.asm:496` -> `TILESET_HOUSE`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:460` `map_const MR_PSYCHICS_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `SAFFRON_CITY` | 5 | +| 2 | 3 | 7 | `SAFFRON_CITY` | 5 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `MrPsychicsHouseBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `MrPsychicsHouseBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MRPSYCHICSHOUSE_FISHING_GURU` | `SPRITE_FISHING_GURU` | 5 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `MrPsychic` (`61:4b15`) | -1 | + +**Scripts of interest** + +- `MrPsychic` (`maps/MrPsychicsHouse.asm:9`, sym `61:4b15`): + `faceplayer` / `opentext` / `checkevent EVENT_GOT_TM29_PSYCHIC` / `iftrue .AlreadyGotItem` / `writetext MrPsychicText1` / `promptbutton` / `verbosegiveitem TM_PSYCHIC_M` / `iffalse .Done` / `setevent EVENT_GOT_TM29_PSYCHIC` / `.AlreadyGotItem: writetext MrPsychicText2` / `waitbutton` / `.Done: closetext` / `end`. + One `verbosegiveitem`; a full bag drops through `.Done` **without** setting the flag, so a bot must re-talk after making room. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_TM29_PSYCHIC` | `constants/event_flags.asm:226` | read+set by `MrPsychic` | One-shot guard on TM29 | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_PSYCHIC_M` (TM29, `constants/item_constants.asm:250` `add_tm PSYCHIC_M ; dd`) | talk to `MRPSYCHICSHOUSE_FISHING_GURU` at `(5, 3)` | `MrPsychic`, `verbosegiveitem` | `EVENT_GOT_TM29_PSYCHIC` | + +### MAP_SILPH_CO_1F + +- Script: `maps/SilphCo1F.asm` +- Blocks: `maps/SilphCo1F.blk` (`data/maps/blocks.asm:874`) +- Header: `data/maps/maps.asm:498` -> `TILESET_FACILITY`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:462` `map_const SILPH_CO_1F, 8, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `SAFFRON_CITY` | 7 | +| 2 | 3 | 7 | `SAFFRON_CITY` | 7 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILPHCO1F_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 4 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SilphCoReceptionistScript` | -1 | +| `SILPHCO1F_OFFICER` | `SPRITE_OFFICER` | 13 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SilphCoOfficerScript` (`61:4f81`) | -1 | + +**Scripts of interest** + +- `SilphCoOfficerScript` (`maps/SilphCo1F.asm:13`, sym `61:4f81`): `faceplayer` / `opentext` / `checkevent EVENT_GOT_UP_GRADE` / `iftrue .GotUpGrade` / `writetext SilphCoOfficerText` / `promptbutton` / `verbosegiveitem UP_GRADE` / `iffalse .NoRoom` / `setevent EVENT_GOT_UP_GRADE` / `.GotUpGrade: writetext ...` / `waitbutton` / `.NoRoom: closetext` / `end`. Same full-bag shape as Mr. Psychic. There is no upstairs on this map at all - the "man blocking the stairway" is `(13, 1)`, standing on the only path north; nothing scripted opens it. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_UP_GRADE` | `constants/event_flags.asm:221` | read+set by `SilphCoOfficerScript` | One-shot guard on Up-Grade | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `UP_GRADE` (`constants/item_constants.asm:180`, `$ac`) | talk to `SILPHCO1F_OFFICER` at `(13, 1)` | `SilphCoOfficerScript`, `verbosegiveitem` | `EVENT_GOT_UP_GRADE` | + +### MAP_SAFFRON_MAGNET_TRAIN_STATION + +- Script: `maps/SaffronMagnetTrainStation.asm` +- Blocks: `maps/SaffronMagnetTrainStation.blk` +- Header: `data/maps/maps.asm:497` -> `TILESET_TRAIN_STATION`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:461` `map_const SAFFRON_MAGNET_TRAIN_STATION, 10, 9` +- Scene variable: `data/maps/scenes.asm:17` `scene_var SAFFRON_MAGNET_TRAIN_STATION, wSaffronMagnetTrainStationSceneID` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 8 | 17 | `SAFFRON_CITY` | 6 | +| 2 | 9 | 17 | `SAFFRON_CITY` | 6 | +| 3 | 6 | 5 | `GOLDENROD_MAGNET_TRAIN_STATION` | 4 | +| 4 | 11 | 5 | `GOLDENROD_MAGNET_TRAIN_STATION` | 3 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_SAFFRONMAGNETTRAINSTATION_ARRIVE_FROM_GOLDENROD` (= 0, generated by the `scene_script` macro at `maps/SaffronMagnetTrainStation.asm:9`) | 11 | 6 | `Script_ArriveFromGoldenrod` | Arrival cutscene when riding in from Goldenrod; not reachable in this section | + +**BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SAFFRONMAGNETTRAINSTATION_OFFICER` | `SPRITE_OFFICER` | 9 | 9 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SaffronMagnetTrainStationOfficerScript` (`61:4bc2`) | -1 | +| `SAFFRONMAGNETTRAINSTATION_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 10 | 14 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `SaffronMagnetTrainStationGymGuideScript` | -1 | +| `SAFFRONMAGNETTRAINSTATION_TEACHER` | `SPRITE_TEACHER` | 6 | 11 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `SaffronMagnetTrainStationTeacherScript` | `EVENT_SAFFRON_TRAIN_STATION_POPULATION` | +| `SAFFRONMAGNETTRAINSTATION_LASS` | `SPRITE_LASS` | 6 | 10 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `SaffronMagnetTrainStationLassScript` | `EVENT_SAFFRON_TRAIN_STATION_POPULATION` | + +**Scripts of interest** + +- `SaffronMagnetTrainStationOfficerScript` (`maps/SaffronMagnetTrainStation.asm:16`, sym `61:4bc2`): `checkevent EVENT_RESTORED_POWER_TO_KANTO` / `iftrue .MagnetTrainToGoldenrod`, else "the MAGNET TRAIN isn't operating now" and `end`. The ride arm additionally runs `yesorno`, `checkitem PASS`, two `applymovement`s, `setval TRUE` / `special MagnetTrain` / `warpcheck` / `newloadmap MAPSETUP_TRAIN`. In this section both `EVENT_RESTORED_POWER_TO_KANTO` and `PASS` are missing, so the officer is a dead end. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RESTORED_POWER_TO_KANTO` | `constants/event_flags.asm:204` | set at `maps/PowerPlant.asm:165`; read by the officer script | Gate on the Magnet Train being operable at all | +| `EVENT_SAFFRON_TRAIN_STATION_POPULATION` | `constants/event_flags.asm:1300` | cleared at `maps/PowerPlant.asm:160` | While **set** (the state in this section) the Teacher and Lass are hidden - the station looks empty on purpose | + +### MAP_SAFFRON_MART + +- Script: `maps/SaffronMart.asm` +- Blocks: shared `maps/Mart.blk` (`data/maps/blocks.asm:333,335`) +- Header: `data/maps/maps.asm:493` -> `TILESET_MART`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:457` `map_const SAFFRON_MART, 6, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `SAFFRON_CITY` | 3 | +| 2 | 3 | 7 | `SAFFRON_CITY` | 3 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SAFFRONMART_CLERK` | `SPRITE_CLERK` | 1 | 3 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `SaffronMartClerkScript` | -1 | +| `SAFFRONMART_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 7 | 2 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `SaffronMartCooltrainerMScript` | -1 | +| `SAFFRONMART_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 7 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (rx 1) | `OBJECTTYPE_SCRIPT` | `SaffronMartCooltrainerFScript` | -1 | + +**Scripts of interest** + +- `SaffronMartClerkScript`: `opentext` / `pokemart MARTTYPE_STANDARD, MART_SAFFRON` / `closetext` / `end`. + +**Items** - shop stock, `data/items/marts.asm:362` `MartSaffron`: `GREAT_BALL`, `ULTRA_BALL`, `HYPER_POTION`, `MAX_POTION`, `FULL_HEAL`, `X_ATTACK`, `X_DEFEND`, `FLOWER_MAIL`. + +### MAP_FIGHTING_DOJO + +- Script: `maps/FightingDojo.asm` +- Blocks: `maps/FightingDojo.blk` (`data/maps/blocks.asm:871`) +- Header: `data/maps/maps.asm:491` -> `TILESET_TRAIN_STATION`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:455` `map_const FIGHTING_DOJO, 5, 6` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 11 | `SAFFRON_CITY` | 1 | +| 2 | 5 | 11 | `SAFFRON_CITY` | 1 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 0 | `BGEVENT_READ` | `FightingDojoSign1` | +| 5 | 0 | `BGEVENT_READ` | `FightingDojoSign2` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `FIGHTINGDOJO_BLACK_BELT` | `SPRITE_BLACK_BELT` | 4 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `FightingDojoBlackBelt` | -1 | +| `FIGHTINGDOJO_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 1 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `FightingDojoFocusBand` (`61:400b`) | `EVENT_PICKED_UP_FOCUS_BAND` | + +**Scripts of interest** + +- `FightingDojoFocusBand` (`maps/FightingDojo.asm:19`, sym `61:400b`): the two-byte `itemball FOCUS_BAND` struct (macro at `macros/scripts/maps.asm:154`, quantity defaults to 1), not a script. The engine's own item-ball path runs it. +- There are **no trainers** in the Fighting Dojo in Gen 2. `FightingDojoBlackBeltText` explains the Karate King is away in a Johto cave (Rock Tunnel-era content elsewhere), so nothing here fights back. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `FOCUS_BAND` (`constants/item_constants.asm:127`, `$77`) | walk onto / press A at `(3, 1)` | `FightingDojoFocusBand` (`OBJECTTYPE_ITEMBALL`) | `EVENT_PICKED_UP_FOCUS_BAND` (`constants/event_flags.asm:1312`) - once set the ball object is masked | + +### MAP_SAFFRON_GYM + +- Script: `maps/SaffronGym.asm` +- Blocks: `maps/SaffronGym.blk` (`data/maps/blocks.asm:861`) +- Header: `data/maps/maps.asm:492` -> `TILESET_UNDERGROUND`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_GYM`, phone `TRUE` (phone calls suppressed), `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:456` `map_const SAFFRON_GYM, 10, 9` (20x18 cells) +- Connections: none + +**Warps** (`def_warp_events`, `maps/SaffronGym.asm:294-326`) - warps 1-2 are the street doors; warps 3-32 are the 30 teleport pads. Every pad's destination is `SAFFRON_GYM` and the player lands on the destination warp's own tile. + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 8 | 17 | `SAFFRON_CITY` | 2 | +| 2 | 9 | 17 | `SAFFRON_CITY` | 2 | +| 3 | 11 | 15 | `SAFFRON_GYM` | 18 | +| 4 | 19 | 15 | `SAFFRON_GYM` | 19 | +| 5 | 19 | 11 | `SAFFRON_GYM` | 20 | +| 6 | 1 | 11 | `SAFFRON_GYM` | 21 | +| 7 | 5 | 3 | `SAFFRON_GYM` | 22 | +| 8 | 11 | 5 | `SAFFRON_GYM` | 23 | +| 9 | 1 | 15 | `SAFFRON_GYM` | 24 | +| 10 | 19 | 3 | `SAFFRON_GYM` | 25 | +| 11 | 15 | 17 | `SAFFRON_GYM` | 26 | +| 12 | 5 | 17 | `SAFFRON_GYM` | 27 | +| 13 | 5 | 9 | `SAFFRON_GYM` | 28 | +| 14 | 9 | 3 | `SAFFRON_GYM` | 29 | +| 15 | 15 | 9 | `SAFFRON_GYM` | 30 | +| 16 | 15 | 5 | `SAFFRON_GYM` | 31 | +| 17 | 1 | 5 | `SAFFRON_GYM` | 32 | +| 18 | 19 | 17 | `SAFFRON_GYM` | 3 | +| 19 | 19 | 9 | `SAFFRON_GYM` | 4 | +| 20 | 1 | 9 | `SAFFRON_GYM` | 5 | +| 21 | 5 | 5 | `SAFFRON_GYM` | 6 | +| 22 | 11 | 3 | `SAFFRON_GYM` | 7 | +| 23 | 1 | 17 | `SAFFRON_GYM` | 8 | +| 24 | 19 | 5 | `SAFFRON_GYM` | 9 | +| 25 | 15 | 15 | `SAFFRON_GYM` | 10 | +| 26 | 5 | 15 | `SAFFRON_GYM` | 11 | +| 27 | 5 | 11 | `SAFFRON_GYM` | 12 | +| 28 | 9 | 5 | `SAFFRON_GYM` | 13 | +| 29 | 15 | 11 | `SAFFRON_GYM` | 14 | +| 30 | 15 | 3 | `SAFFRON_GYM` | 15 | +| 31 | 1 | 3 | `SAFFRON_GYM` | 16 | +| 32 | 11 | 9 | `SAFFRON_GYM` | 17 | + +**Teleporter graph, resolved to landing coordinates.** Every pair is mutual, so the graph is 15 undirected edges. Room labels are derived from the pad clusters and the object coordinates; they are a reading aid, the coordinates are the ground truth. + +| room (occupant) | pads (x, y) | +|---|---| +| ENTRANCE (doors `(8,17)`/`(9,17)`, guide `(9,14)`, statue `(8,15)`) | `(11,15)` | +| BL (Psychic Franklin `(3,16)`) | `(1,15)` `(5,15)` `(1,17)` `(5,17)` | +| BR (Medium Rebecca `(17,16)`) | `(15,15)` `(19,15)` `(15,17)` `(19,17)` | +| ML | `(1,9)` `(5,9)` `(1,11)` `(5,11)` | +| CENTER (Sabrina `(9,8)`) | `(11,9)` | +| MR | `(15,9)` `(19,9)` `(15,11)` `(19,11)` | +| TL (Medium Doris `(3,4)`) | `(1,3)` `(5,3)` `(1,5)` `(5,5)` | +| TC | `(9,3)` `(11,3)` `(9,5)` `(11,5)` | +| TR (Psychic Jared `(17,4)`) | `(15,3)` `(19,3)` `(15,5)` `(19,5)` | + +| step on pad | warp idx | lands at | room reached | +|---|---|---|---| +| `(11,15)` ENTRANCE | 3 | `(19,17)` | BR | +| `(19,17)` BR | 18 | `(11,15)` | ENTRANCE | +| `(19,15)` BR | 4 | `(19,9)` | MR | +| `(19,9)` MR | 19 | `(19,15)` | BR | +| `(15,17)` BR | 11 | `(5,15)` | BL | +| `(5,15)` BL | 26 | `(15,17)` | BR | +| `(15,15)` BR | 25 | `(19,3)` | TR | +| `(19,3)` TR | 10 | `(15,15)` | BR | +| `(1,15)` BL | 9 | `(19,5)` | TR | +| `(19,5)` TR | 24 | `(1,15)` | BL | +| `(1,17)` BL | 23 | `(11,5)` | TC | +| `(11,5)` TC | 8 | `(1,17)` | BL | +| `(5,17)` BL | 12 | `(5,11)` | ML | +| `(5,11)` ML | 27 | `(5,17)` | BL | +| `(1,9)` ML | 20 | `(19,11)` | MR | +| `(19,11)` MR | 5 | `(1,9)` | ML | +| `(5,9)` ML | 13 | `(9,5)` | TC | +| `(9,5)` TC | 28 | `(5,9)` | ML | +| `(1,11)` ML | 6 | `(5,5)` | TL | +| `(5,5)` TL | 21 | `(1,11)` | ML | +| `(15,9)` MR | 15 | `(15,3)` | TR | +| `(15,3)` TR | 30 | `(15,9)` | MR | +| `(15,11)` MR | 29 | `(9,3)` | TC | +| `(9,3)` TC | 14 | `(15,11)` | MR | +| `(5,3)` TL | 7 | `(11,3)` | TC | +| `(11,3)` TC | 22 | `(5,3)` | TL | +| `(1,3)` TL | 31 | `(15,5)` | TR | +| `(15,5)` TR | 16 | `(1,3)` | TL | +| **`(1,5)` TL** | **17** | **`(11,9)`** | **CENTER (Sabrina)** | +| **`(11,9)` CENTER** | **32** | **`(1,5)`** | **TL** | + +`(1,5)` in the top-left room is the only pad that reaches Sabrina, and `(11,9)` is the only way out of her chamber. This matches the walkthrough's "go to the bottom left teleporter and you'll go straight to the gym leader". + +**Coord events** - none. The gym has no trip-wires; every transition is a warp tile. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 8 | 15 | `BGEVENT_READ` | `SaffronGymStatue` (`61:4170`) | + +**Object events** + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `SAFFRONGYM_SABRINA` | `SPRITE_SABRINA` | 9 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `SaffronGymSabrinaScript` (`61:40cf`) | -1 | +| `SAFFRONGYM_GRANNY1` | `SPRITE_GRANNY` | 17 | 16 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerMediumRebecca` (`61:410c`) | -1 | +| `SAFFRONGYM_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 3 | 16 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerPsychicFranklin` (`61:4120`) | -1 | +| `SAFFRONGYM_GRANNY2` | `SPRITE_GRANNY` | 3 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerMediumDoris` (`61:4134`) | -1 | +| `SAFFRONGYM_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 17 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 2 | `TrainerPsychicJared` (`61:4148`) | -1 | +| `SAFFRONGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 9 | 14 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `SaffronGymGuideScript` | -1 | + +All four trainers spin (`SPINRANDOM_FAST`), so their line of sight rotates - a bot cannot assume a fixed approach lane; walking into the room will trigger them. + +**Scripts of interest** + +- `SaffronGymSabrinaScript` (`maps/SaffronGym.asm:14`, sym `61:40cf`): + `faceplayer` / `opentext` / `checkflag ENGINE_MARSHBADGE` / `iftrue .FightDone` / `writetext SabrinaIntroText` / `waitbutton` / `closetext` / `winlosstext SabrinaWinLossText, 0` / `loadtrainer SABRINA, SABRINA1` / `startbattle` / `reloadmapafterbattle` / then a burst of five `setevent`s: `EVENT_BEAT_SABRINA`, `EVENT_BEAT_MEDIUM_REBECCA`, `EVENT_BEAT_MEDIUM_DORIS`, `EVENT_BEAT_PSYCHIC_FRANKLIN`, `EVENT_BEAT_PSYCHIC_JARED` / `opentext` / `writetext ReceivedMarshBadgeText` / `playsound SFX_GET_BADGE` / `waitsfx` / `setflag ENGINE_MARSHBADGE` / `writetext SabrinaMarshBadgeText` / `waitbutton` / `closetext` / `end`. + Two consequences a bot should exploit: **the four gym trainers are auto-cleared when Sabrina falls**, so they are entirely skippable; and the badge is a `setflag` on `ENGINE_MARSHBADGE`, which is also the re-entry guard (`.FightDone`). +- `TrainerMediumRebecca` / `TrainerPsychicFranklin` / `TrainerMediumDoris` / `TrainerPsychicJared` (`maps/SaffronGym.asm:47,58,69,80`): each is a `trainer CLASS, ID, EVENT_BEAT_*, SeenText, BeatenText, 0, .Script` struct (`macros/scripts/maps.asm:142`; field order is group, id, flag, seen, win, loss, after) followed by an `endifjustbattled` / `opentext` / `writetext ...AfterBattleText` / `waitbutton` / `closetext` / `end` after-battle script. +- `SaffronGymStatue` (`maps/SaffronGym.asm:107`, sym `61:4170`): `checkflag ENGINE_MARSHBADGE` / `iftrue .Beaten` / `jumpstd GymStatue1Script`; the beaten arm does `gettrainername STRING_BUFFER_4, SABRINA, SABRINA1` then `jumpstd GymStatue2Script`. Handy as a cheap post-badge assertion. +- `SaffronGymGuideScript` (`maps/SaffronGym.asm:91`): branches on `checkevent EVENT_BEAT_SABRINA`; no items, no flags written. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_MARSHBADGE` | `constants/engine_flags.asm:52` (bit 5 of `wKantoBadges`; Kanto order is Boulder, Cascade, Thunder, Rainbow, Soul, **Marsh**, Volcano, Earth) | `checkflag` in `SaffronGymSabrinaScript` and `SaffronGymStatue`; `setflag` after the win | The section's terminal postcondition | +| `EVENT_BEAT_SABRINA` | `constants/event_flags.asm:720` | set by `SaffronGymSabrinaScript`; read by `SaffronGymGuideScript` | Leader defeated | +| `EVENT_BEAT_MEDIUM_REBECCA` | `constants/event_flags.asm:935` | trainer struct flag; also force-set by the Sabrina script | Rebecca cleared | +| `EVENT_BEAT_MEDIUM_DORIS` | `constants/event_flags.asm:936` | as above | Doris cleared | +| `EVENT_BEAT_PSYCHIC_FRANKLIN` | `constants/event_flags.asm:564` | as above | Franklin cleared | +| `EVENT_BEAT_PSYCHIC_JARED` | `constants/event_flags.asm:573` | as above | Jared cleared | + +**Items** - none in the gym. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SABRINA` / `SABRINA1` | `SABRINA` (`constants/trainer_constants.asm:309`, class 23) | 1 | `SabrinaGroup` `; SABRINA (1)` (`data/trainers/parties.asm:1400-1406`), `TRAINERTYPE_MOVES`: L46 `ESPEON` (Sand-Attack, Quick Attack, Swift, Psychic), L46 `MR__MIME` (Barrier, Reflect, Baton Pass, Psychic), L48 `ALAKAZAM` (Recover, Future Sight, Psychic, Reflect) | `SaffronGymSabrinaScript` | none; leader (`data/trainers/leaders.asm:29`) | +| `MEDIUM` / `REBECCA` | `MEDIUM` | 6 (`constants/trainer_constants.asm:590`) | `MediumGroup` `; MEDIUM (6)`: L35 `DROWZEE`, L35 `HYPNO` | `TrainerMediumRebecca` | none | +| `PSYCHIC_T` / `FRANKLIN` | `PSYCHIC_T` (class 34) | 2 (`constants/trainer_constants.asm:513`) | `PsychicGroup` `; PSYCHIC_T (2)` (`data/trainers/parties.asm:2516`): L37 `KADABRA` | `TrainerPsychicFranklin` | none | +| `MEDIUM` / `DORIS` | `MEDIUM` | 7 (`constants/trainer_constants.asm:591`) | `MediumGroup` `; MEDIUM (7)`: L34 `SLOWPOKE`, L36 `SLOWBRO` | `TrainerMediumDoris` | none | +| `PSYCHIC_T` / `JARED` | `PSYCHIC_T` | 11 (`constants/trainer_constants.asm:522`) | `PsychicGroup` `; PSYCHIC_T (11)` (`data/trainers/parties.asm:2569`): L32 `MR__MIME`, L32 `EXEGGCUTE`, **L35** `EXEGGCUTE` | `TrainerPsychicJared` | none | + +Prize money: `ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300`) is `base reward x last mon's level`, and `engine/battle/core.asm:2344-2358` pays that quarter out **four times**, so the visible payout is `4 x base x level`. Base rewards: Sabrina 25 (`data/trainers/attributes.asm:211`), Medium 10 (`:343`), Psychic T 8 (`:313`). That reproduces the walkthrough's 4800G / 1400G / 1440G / 1184G / 1120G exactly. Sabrina also carries a `HYPER_POTION` as an AI item (`data/trainers/attributes.asm:210`). + +**Wild encounters** - none (indoor gym). + +### MAP_SAFFRON_POKECENTER_1F + +- Script: `maps/SaffronPokecenter1F.asm` +- Blocks: shared `maps/Pokecenter1F.blk` (`data/maps/blocks.asm:362,364`) +- Header: `data/maps/maps.asm:494` -> `TILESET_POKECENTER`, `INDOOR`, `LANDMARK_SAFFRON_CITY`, `MUSIC_POKEMON_CENTER`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:458` `map_const SAFFRON_POKECENTER_1F, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `SAFFRON_CITY` | 4 | +| 2 | 4 | 7 | `SAFFRON_CITY` | 4 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** (nurse only listed; the rest are flavour) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SAFFRONPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SaffronPokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 | +| `SAFFRONPOKECENTER1F_TEACHER` | `SPRITE_TEACHER` | 7 | 2 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `SaffronPokecenter1FTeacherScript` | -1 | +| `SAFFRONPOKECENTER1F_FISHER` | `SPRITE_FISHER` | 8 | 6 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `SaffronPokecenter1FFisherScript` | -1 | +| `SAFFRONPOKECENTER1F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 1 | 4 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `SaffronPokecenter1FYoungsterScript` | -1 | + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Underground Path (Route 6 warp 1 at `(17, 3)`) | `maps/Route6.asm:43` - `ROUTE6_POKEFAN_M` object standing at `(17, 4)` with event flag `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH`; visibility decided by `CheckObjectFlag`, `engine/overworld/map_objects_2.asm:32` | Nothing the player can do in this section | `maps/PowerPlant.asm:164` `setevent EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` (flag set = object masked), which is part of returning the `MACHINE_PART` | +| Magnet Train Saffron -> Goldenrod | `maps/SaffronMagnetTrainStation.asm:19` `checkevent EVENT_RESTORED_POWER_TO_KANTO`, then `checkitem PASS` at `:30` | `EVENT_RESTORED_POWER_TO_KANTO` **and** `PASS` in bag | `maps/PowerPlant.asm:165` sets the event; the `PASS` comes from elsewhere. Both false here, so the officer only prints "isn't operating now" | +| Silph Co. upper floors | `maps/SilphCo1F.asm:69` - `SILPHCO1F_OFFICER` object at `(13, 1)` with event flag `-1` (never hides). No stairs warp exists on `SilphCo1F` at all | Permanently blocked in Gen 2 | Never; this is not a gate that opens | +| Saffron Gym maze | The 30 pad warps at `maps/SaffronGym.asm:296-326`. Pads are teleport panels (`COLL_WARP_PANEL`-class tiles), so they fire on landing, not on a directional press | Navigation only - no badge, item or flag check anywhere in `SaffronGym.asm` | n/a. The single route to Sabrina is `(11,15) -> (19,17) -> ... -> (1,5) -> (11,9)`; see the pad table | +| MARSHBADGE re-fight guard | `maps/SaffronGym.asm:17` `checkflag ENGINE_MARSHBADGE` / `iftrue .FightDone` | Badge not yet held | Beating Sabrina once; afterwards she only talks | +| Gym trainers | Each `trainer` struct's `EVENT_BEAT_*` flag; the trainer only challenges while its flag is clear | Walking into their sight cone (sight 3 for Rebecca/Franklin, 2 for Doris/Jared) | Beating them, **or** beating Sabrina, which sets all four flags at `maps/SaffronGym.asm:27-30` | + +No HM field move is required anywhere in this section - no `engine/overworld/cut.asm`, `surf.asm`, `strength.asm` or `whirlpool.asm` check is reachable from these maps. The Bicycle the walkthrough recommends is convenience only. + +## 4. Bot checklist + +Coordinates are asm cell coordinates on the named map. "Warp N" means the `def_warp_events` index in that map's table. + +1. `MAP_ROUTE_6` - walk north along the route to warp 2 at `(6, 1)`. Precondition: none. Postcondition: on `MAP_ROUTE_6_SAFFRON_GATE` warp 3 `(4, 7)`. + - Optional: read the sign at `(19, 5)`. Do **not** try warp 1 at `(17, 3)`; `ROUTE6_POKEFAN_M` occupies `(17, 4)` while `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` is clear. +2. `MAP_ROUTE_6_SAFFRON_GATE` - walk from `(4, 7)` to warp 1 at `(4, 0)`. Postcondition: on `MAP_SAFFRON_CITY` warp 12 `(16, 33)`; `MAPCALLBACK_NEWMAP` fires and `ENGINE_FLYPOINT_SAFFRON` becomes set. +3. `MAP_SAFFRON_CITY` - walk to warp 5 at `(27, 29)`. Enter `MAP_MR_PSYCHICS_HOUSE`. +4. `MAP_MR_PSYCHICS_HOUSE` - talk to `MRPSYCHICSHOUSE_FISHING_GURU` at `(5, 3)` (approach from `(4, 3)` facing right, he faces left). Precondition: `EVENT_GOT_TM29_PSYCHIC` clear and TM pocket has room. Postcondition: `TM_PSYCHIC_M` in bag, `EVENT_GOT_TM29_PSYCHIC` set. Exit warp 1 `(2, 7)`. +5. `MAP_SAFFRON_CITY` - walk to warp 7 at `(18, 21)`. Enter `MAP_SILPH_CO_1F`. +6. `MAP_SILPH_CO_1F` - talk to `SILPHCO1F_OFFICER` at `(13, 1)`. Precondition: `EVENT_GOT_UP_GRADE` clear, item pocket has room. Postcondition: `UP_GRADE` in bag, `EVENT_GOT_UP_GRADE` set. Exit warp 1 `(2, 7)`. +7. (Optional, dialogue only) `MAP_SAFFRON_CITY` warp 6 at `(8, 3)` -> `MAP_SAFFRON_MAGNET_TRAIN_STATION`; talk to `SAFFRONMAGNETTRAINSTATION_OFFICER` at `(9, 9)`. Expect the "isn't operating now" arm because `EVENT_RESTORED_POWER_TO_KANTO` is clear. Exit warp 1 `(8, 17)`. +8. (Optional) `MAP_SAFFRON_CITY` warp 3 at `(25, 11)` -> `MAP_SAFFRON_MART`; talk to `SAFFRONMART_CLERK` at `(1, 3)` for `MART_SAFFRON`. Exit warp 1 `(2, 7)`. +9. `MAP_SAFFRON_CITY` - warp 1 at `(26, 3)` -> `MAP_FIGHTING_DOJO`. Walk onto / face `FIGHTINGDOJO_POKE_BALL` at `(3, 1)`. Precondition: `EVENT_PICKED_UP_FOCUS_BAND` clear. Postcondition: `FOCUS_BAND` in bag, `EVENT_PICKED_UP_FOCUS_BAND` set (ball object masked from then on). Exit warp 1 `(4, 11)`. +10. (Optional heal) `MAP_SAFFRON_CITY` warp 4 at `(9, 29)` -> `MAP_SAFFRON_POKECENTER_1F`; talk to nurse at `(3, 1)`. Exit warp 1 `(3, 7)`. +11. `MAP_SAFFRON_CITY` - warp 2 at `(34, 3)` -> `MAP_SAFFRON_GYM`, landing on warp 1 `(8, 17)`. +12. `MAP_SAFFRON_GYM` - walk to the pad at `(11, 15)`. Warps to `(19, 17)`. +13. `MAP_SAFFRON_GYM` BR room - battle `SAFFRONGYM_GRANNY1` (Medium Rebecca) at `(17, 16)`, sight 3. Precondition: `EVENT_BEAT_MEDIUM_REBECCA` clear. Postcondition: that flag set. (Skippable: her flag is force-set by the Sabrina script.) +14. `MAP_SAFFRON_GYM` - step on pad `(15, 17)`. Warps to `(5, 15)`. +15. `MAP_SAFFRON_GYM` BL room - battle `SAFFRONGYM_YOUNGSTER1` (Psychic Franklin) at `(3, 16)`, sight 3. Postcondition: `EVENT_BEAT_PSYCHIC_FRANKLIN` set. +16. `MAP_SAFFRON_GYM` - step on pad `(1, 15)`. Warps to `(19, 5)`. +17. `MAP_SAFFRON_GYM` TR room - battle `SAFFRONGYM_YOUNGSTER2` (Psychic Jared) at `(17, 4)`, sight 2. Postcondition: `EVENT_BEAT_PSYCHIC_JARED` set. +18. `MAP_SAFFRON_GYM` - step on pad `(15, 5)`. Warps to `(1, 3)`. +19. `MAP_SAFFRON_GYM` TL room - battle `SAFFRONGYM_GRANNY2` (Medium Doris) at `(3, 4)`, sight 2. Postcondition: `EVENT_BEAT_MEDIUM_DORIS` set. +20. `MAP_SAFFRON_GYM` - step on pad `(1, 5)`. Warps to `(11, 9)`, Sabrina's chamber. +21. `MAP_SAFFRON_GYM` - talk to `SAFFRONGYM_SABRINA` at `(9, 8)` (stand at `(9, 9)` facing up, or `(10, 8)` facing left). Precondition: `ENGINE_MARSHBADGE` clear. Battle `SABRINA`/`SABRINA1`. Postcondition: `EVENT_BEAT_SABRINA`, the four trainer flags, and `ENGINE_MARSHBADGE` all set; +4800G. +22. `MAP_SAFFRON_GYM` exit - `(11, 9)` -> `(1, 5)`; walk right to `(5, 5)` -> `(1, 11)`; walk right to `(5, 11)` -> `(5, 17)`; walk up to `(5, 15)` -> `(15, 17)`; walk right to `(19, 17)` -> `(11, 15)`; walk down to warp 1/2 at `(8, 17)`/`(9, 17)` -> `MAP_SAFFRON_CITY` warp 2 `(34, 3)`. +23. Mid-gym heal-and-return (if step 21 needs a fresh party): exit via step 22, heal at `MAP_SAFFRON_POKECENTER_1F`, re-enter, then `(11,15) -> (19,17)`, up to `(19,15) -> (19,9)`, left to `(15,9) -> (15,3)`, down to `(15,5) -> (1,3)`, down to `(1,5) -> (11,9)`. This is the walkthrough's "1st teleporter, then up, left, down, down" and it checks out against the pad table. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map load, block grid, `COLL_*` collision, connections | `src/world/gen2/Map.lua`, `src/world/gen2/Permissions.lua`, `src/world/gen2/World.lua` | Implemented (generic; Gold maps come from `src/import/RomExtractorGen2.lua`, not hand-ported) | +| Warp panels / teleport pads (the whole gym maze) | `src/world/gen2/Permissions.lua:195` `COLL_WARP_PANEL = 0x7c` in `WARP_FACING_DOWN`, `Permissions.warpFacesDown`; warp taking in `src/world/gen2/World.lua:1566` `World:warpTo` | Implemented (panels warp on landing, which is what all 30 gym pads are) | +| Map-to-self warps (every gym pad targets `SAFFRON_GYM`) | `src/world/gen2/World.lua` warp handling | Implemented, but untested for this map - no driver exercises a same-map warp chain | +| `MAPCALLBACK_NEWMAP` -> `setflag ENGINE_FLYPOINT_SAFFRON` | `src/world/gen2/World.lua:5659` `self:runMapCallback("MAPCALLBACK_NEWMAP")`; fly-point row at `src/world/gen2/FieldMoves.lua:365` (`LANDMARK_SAFFRON_CITY` / `SPAWN_SAFFRON` / flag 59) | Implemented | +| Script opcodes used by every script in this section (`checkevent`, `iftrue`, `iffalse`, `setevent`, `checkflag`, `setflag`, `verbosegiveitem`, `promptbutton`, `writetext`, `waitbutton`, `jumptext`, `jumptextfaceplayer`, `jumpstd`, `gettrainername`, `pokemart`, `yesorno`, `checkitem`, `applymovement`, `special`, `warpcheck`, `newloadmap`, `loadtrainer`, `startbattle`, `reloadmapafterbattle`, `winlosstext`, `endifjustbattled`, `playsound`, `waitsfx`) | `src/script/gen2/Opcodes.lua`, `src/script/gen2/Vm.lua` (`checkflag` :195, `verbosegiveitem` :490, `pokemart` :597, `jumpstd` :742, `loadtrainer` :806, `startbattle` :817, `winlosstext` :918) | Implemented | +| Object visibility from event flags (the Route 6 blocker, the station's hidden Teacher/Lass, the picked-up Focus Band ball) | `src/world/gen2/Events.lua:1-2` documents and implements "flag SET -> object hidden", matching `CheckObjectFlag` | Implemented | +| Overworld trainer sight + battle handoff (Rebecca, Franklin, Doris, Jared) | `src/world/gen2/Trainers.lua` (eyesight from `home/trainers.asm`, party build) | Implemented | +| Gym leader battle, badge grant, prize money x4 | `src/battle/gen2/Battle.lua`; `src/battle/gen2/Prize.lua:82` `Prize.reward(baseMoney, level)` and `:181` `local quarter = ...` (the four-way payout) | Implemented | +| Item ball pickup (`FOCUS_BAND` in the Fighting Dojo) | Extracted at `src/import/RomExtractorGen2.lua:2968-2969` into `obj.itemball`, but **nothing reads `obj.itemball` at runtime** (grep for `.itemball` in `src/` hits only the extractor). `src/script/gen2/CallAsm.lua:551` explicitly stubs `TryReceiveItem` | **Missing** - the ball object will exist and be masked correctly by its flag, but stepping on it grants nothing | +| Magnet Train ride (`special MagnetTrain`) | `src/core/gen2/MagnetTrain.lua`, `src/ui/gen2/MagnetTrainRide.lua` | Implemented (not exercised in this section - the officer's gate is false here) | +| Mart (`pokemart MARTTYPE_STANDARD, MART_SAFFRON`) | `src/ui/gen2/MartMenu.lua`, `Vm.lua:597` | Implemented | +| Pokecenter heal (`jumpstd PokecenterNurseScript`) | std scripts extracted (`src/import/RomExtractorGen2.lua:2901`), heal hook `src/script/gen2/Specials.lua:451` | Implemented | +| Bicycle | `src/world/gen2/Bike.lua` | Implemented | +| Any Saffron-specific hand-ported content | none | **Missing by design** - the Gen 2 port is data-driven off the ROM; there are no `SAFFRON` symbols anywhere under `src/` except Gen 1 save-conversion tables (`src/save_convert/`), which are unrelated | +| Driver coverage for this section | `tests/drivers/gold_*.lua` (25 drivers: boot, walk, warp, trainer, battle, map callbacks, ...) | **Missing** - no driver reaches Kanto or Saffron; `gold_warp_scene.lua` / `gold_walk_smoke.lua` are Johto-early smoke tests | + +## 6. Unresolved / verify by hand + +- **Psychic Jared's third Pokemon.** The walkthrough lists "Level 32 Exeggcute (672 EXP)" twice. `data/trainers/parties.asm:2569-2574` gives `PSYCHIC_T (11) JARED` as L32 `MR__MIME`, L32 `EXEGGCUTE`, **L35** `EXEGGCUTE`. The asm wins; the walkthrough's second Exeggcute level and its EXP figure are both wrong. +- **EXP values.** None of the per-Pokemon EXP numbers in the walkthrough (765, 1237, 1149, 735, 672, 720, 1264, 1941, 1339, 1912) were checked against `data/pokemon/base_stats/` - they are outside what a bot needs and were not verified. +- **"PokeMart ... upper-right corner of Saffron City".** The mart door is warp 3 at `(25, 11)` on a 40x36-cell map, i.e. north-of-centre and east of the middle, not the corner. The two gyms it says are "further north" are at `(26, 3)` and `(34, 3)`, which is correct. +- **"Silph Co ... the multi-story building above the Pokémon Center".** Silph is warp 7 at `(18, 21)`; the Pokecenter is warp 4 at `(9, 29)`. Silph is north-**east** of it, and `SilphCo1F` has no stairs warp - only the two street doors. There is no upper floor in Gen 2. +- **"You now have 10 badges!"** `ENGINE_MARSHBADGE` is bit 5 of `wKantoBadges` (`constants/engine_flags.asm:52`), so it is the 6th Kanto badge in flag order, not the 10th badge overall. The count of 10 only holds for a player who took Vermilion before Saffron, as this FAQ's chapter order does. Nothing in the asm enforces that order. +- **The walkthrough's mid-maze "left/right (whichever way works) till you find that one trainer again (Franklin)" heal route** is deliberately vague in the source text and was not reduced to a single pad sequence. The precise post-heal route it gives afterwards ("1st teleporter, then up, left, down, down") *does* resolve cleanly and is recorded in bot checklist step 23. +- **The "large man who blocks the entire entrance to the Underground Path"** is described as taunting the player about moving boulders. `Route6PokefanMText` (`maps/Route6.asm:15`) contains only the Power Plant line; no boulder or strength text exists on this map. Treat the boulder remark as FAQ colour. diff --git a/docs/gold-walkthrough/asm-walk/section-21-power-plant-and-cerulean-city-gym.md b/docs/gold-walkthrough/asm-walk/section-21-power-plant-and-cerulean-city-gym.md new file mode 100644 index 00000000..1e59e55c --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-21-power-plant-and-cerulean-city-gym.md @@ -0,0 +1,896 @@ +# Section 21 - Power Plant and Cerulean City Gym + +Source: `../section-21-power-plant-and-cerulean-city-gym.txt` +Maps covered: `MAP_ROUTE_5`, `MAP_ROUTE_5_SAFFRON_GATE`, `MAP_ROUTE_5_CLEANSE_TAG_HOUSE`, +`MAP_ROUTE_5_UNDERGROUND_PATH_ENTRANCE`, `MAP_CERULEAN_CITY`, `MAP_CERULEAN_GYM`, +`MAP_ROUTE_9`, `MAP_ROUTE_10_NORTH`, `MAP_POWER_PLANT`, `MAP_ROUTE_24`, +`MAP_ROUTE_25`, `MAP_BILLS_HOUSE` +Badges / key milestones in this section: CASCADEBADGE (`ENGINE_CASCADEBADGE`) from Misty; +the whole Machine Part chain (`EVENT_MET_MANAGER_AT_POWER_PLANT` -> +`EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM` -> Route 24 grunt battle -> +`EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM`); Misty's date on Route 25 clears +`EVENT_TRAINERS_IN_CERULEAN_GYM` and is what actually populates the gym. + +Coordinate note: `warp_event` / `bg_event` / `object_event` / `coord_event` x,y are in +*map cells* (walk tiles), while `map_const NAME, W, H` in `constants/map_constants.asm` +is in *blocks*. Cells = 2x blocks, so `map_const ROUTE_9, 30, 9` is a 60x18 cell map and +`warp_event 48, 15` is inside it. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_5_SAFFRON_GATE` | `maps/Route5SaffronGate.asm` | from `SAFFRON_CITY` warp 9 into gate warps 3/4 (`4,7` / `5,7`) | gate warps 1/2 (`4,0` / `5,0`) -> `ROUTE_5` warps 2/3 | "head north through the route-changing station" | +| 2 | `MAP_ROUTE_5` | `maps/Route5.asm` | gate warps | north connection -> `CERULEAN_CITY` | walk north; optional detour to the old Day Care building | +| 3 | `MAP_ROUTE_5_CLEANSE_TAG_HOUSE` | `maps/Route5CleanseTagHouse.asm` | `ROUTE_5` warp 4 at `10,11` | warps 1/2 at `2,7` / `3,7` -> `ROUTE_5` warp 4 | granny gives CLEANSE_TAG | +| 4 | `MAP_CERULEAN_CITY` | `maps/CeruleanCity.asm` | south connection from `ROUTE_5` | warp 5 at `30,23` -> `CERULEAN_GYM` | first (empty) gym visit | +| 5 | `MAP_CERULEAN_GYM` | `maps/CeruleanGym.asm` | `CERULEAN_CITY` warp 5 | warps 1/2 at `4,15` / `5,15` | gym is empty: `EVENT_TRAINERS_IN_CERULEAN_GYM` is still set from `InitializeEventsScript` | +| 6 | `MAP_ROUTE_9` | `maps/Route9.asm` | east connection from `CERULEAN_CITY` | south connection -> `ROUTE_10_NORTH` | six trainers; needs CUT at the west entrance | +| 7 | `MAP_ROUTE_10_NORTH` | `maps/Route10North.asm` | north connection from `ROUTE_9` | warp 2 at `3,9` -> `POWER_PLANT` | Pokemon Center at warp 1 (`11,1`); Surf the river to the plant | +| 8 | `MAP_POWER_PLANT` | `maps/PowerPlant.asm` | `ROUTE_10_NORTH` warp 2 | warps 1/2 at `2,17` / `3,17` | talk to the MANAGER, then trip the `5,12` coord event on the way out | +| 9 | `MAP_CERULEAN_CITY` | `maps/CeruleanCity.asm` | FLY (`SPAWN_CERULEAN`, `data/maps/spawn_points.asm:17`) | warp 5 -> `CERULEAN_GYM` | "Fly back to Cerulean City" | +| 10 | `MAP_CERULEAN_GYM` | `maps/CeruleanGym.asm` | `CERULEAN_CITY` warp 5 | warps 1/2 | `SCENE_CERULEANGYM_GRUNT_RUNS_OUT` fires on entry | +| 11 | `MAP_ROUTE_24` | `maps/Route24.asm` | north connection from `CERULEAN_CITY` | north connection -> `ROUTE_25` | Rocket grunt on the old Nugget Bridge | +| 12 | `MAP_ROUTE_25` | `maps/Route25.asm` | south connection from `ROUTE_24` | warp 1 at `47,5` -> `BILLS_HOUSE` | six-pack trainers, Kevin's Nugget, Protein, Misty's date coord events | +| 13 | `MAP_BILLS_HOUSE` | `maps/BillsHouse.asm` | `ROUTE_25` warp 1 | warps 1/2 at `2,7` / `3,7` | Bill's grandpa evolution-stone chain | +| 14 | `MAP_CERULEAN_CITY` -> `MAP_CERULEAN_GYM` | `maps/CeruleanGym.asm` | FLY, then warp 5 | - | three Swimmers, hidden MACHINE_PART at `3,8`, Misty | + +Spills into the next section: `maps/Route9.asm` warp 1 at `48,15` leads to `ROCK_TUNNEL_1F`, +and the Machine Part is *returned* to `PowerPlantManager` (which is what unlocks the Magnet +Train and the Underground Path). The walkthrough text stops at Misty; Rock Tunnel / Lavender +and the return trip belong to the neighbouring sections. + +## 2. Maps + +### MAP_ROUTE_5 + +- Script: `maps/Route5.asm` +- Blocks: `maps/Route5.blk` +- Header: `data/maps/maps.asm:489` -> `map Route5, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_5, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:453` -> `map_const ROUTE_5, 10, 9` (group `SAFFRON` = 25, id 1) +- Connections (`data/maps/attributes.asm:356`): north `CERULEAN_CITY` (offset -5), south `SAFFRON_CITY` (offset -5) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 15 | `ROUTE_5_UNDERGROUND_PATH_ENTRANCE` | 1 | +| 2 | 8 | 17 | `ROUTE_5_SAFFRON_GATE` | 1 | +| 3 | 9 | 17 | `ROUTE_5_SAFFRON_GATE` | 2 | +| 4 | 10 | 11 | `ROUTE_5_CLEANSE_TAG_HOUSE` | 1 | + +**Coord events** - none (`def_coord_events` is empty). + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 17 | `BGEVENT_READ` | `Route5UndergroundPathSign` | +| 10 | 11 | `BGEVENT_READ` | `HouseForSaleSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE5_POKEFAN_M` | `SPRITE_POKEFAN_M` | 17 | 16 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route5PokefanMScript` | `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` | + +**Scripts of interest** + +- `Route5PokefanMScript` - one `jumptextfaceplayer Route5PokefanMText`: "The road is closed + until the problem at the POWER PLANT is solved." He stands at `17,16`, directly below the + Underground Path door at `17,15`, so the tile is physically blocked. His object row carries + `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH`; object events are hidden when their + flag is *set*, and that flag is not in `InitializeEventsScript`, so at this point in the + game it is clear and he is present. `PowerPlantManager` `setevent`s it after the part is + returned (`maps/PowerPlant.asm:164`), removing him. +- `Route5UndergroundPathSign` / `HouseForSaleSign` - plain `jumptext`. Note the "House for + Sale" sign shares the exact tile `10,11` with warp 4 into the Cleanse Tag house. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` | `constants/event_flags.asm:1299` | object row `maps/Route5.asm:56` and `maps/Route6.asm:43`; `setevent` at `maps/PowerPlant.asm:164` | clear = NPC present and Underground Path door unreachable; set = NPC gone | + +**Items** - none on the route proper. + +**Wild encounters** - `data/wild/kanto_grass.asm:425` `def_grass_wildmons ROUTE_5`, +10/10/10 percent. Gold morn+day: L13 PIDGEY, L13 BELLSPROUT, L14 PIDGEY, L15 PIDGEY, +L12 ABRA, L14 ABRA, L14 ABRA. Gold nite: L13 ODDISH, L14 ODDISH, L13 BELLSPROUT, +L15 GLOOM, L12 ABRA, L14 ABRA, L14 ABRA. No water table. Fishing group `FISHGROUP_SHORE` +(`data/wild/fish.asm` `.Shore_*`). + +--- + +### MAP_ROUTE_5_SAFFRON_GATE + +- Script: `maps/Route5SaffronGate.asm` +- Blocks: shared, `data/maps/blocks.asm` +- Header: `data/maps/maps.asm:502` -> `map Route5SaffronGate, TILESET_GATE, GATE, LANDMARK_ROUTE_5, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:466` -> `map_const ROUTE_5_SAFFRON_GATE, 5, 4` (group 25, id 14) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_5` | 2 | +| 2 | 5 | 0 | `ROUTE_5` | 3 | +| 3 | 4 | 7 | `SAFFRON_CITY` | 9 | +| 4 | 5 | 7 | `SAFFRON_CITY` | 9 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE5SAFFRONGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route5SaffronGateOfficerScript` | -1 | + +Nothing here gates progress: the officer is a single `jumptextfaceplayer`, and there are no +coord or bg events. + +--- + +### MAP_ROUTE_5_CLEANSE_TAG_HOUSE + +- Script: `maps/Route5CleanseTagHouse.asm` +- Blocks: shared, `data/maps/blocks.asm:196` (`Route5CleanseTagHouse_Blocks`) +- Header: `data/maps/maps.asm:503` -> `map Route5CleanseTagHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_5, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:467` -> `map_const ROUTE_5_CLEANSE_TAG_HOUSE, 4, 4` (group 25, id 15) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_5` | 4 | +| 2 | 3 | 7 | `ROUTE_5` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `HouseForSaleBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `HouseForSaleBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE5CLEANSETAGHOUSE_GRANNY` | `SPRITE_GRANNY` | 2 | 5 | `SPRITEMOVEDATA_SPINCLOCKWISE` | `OBJECTTYPE_SCRIPT` | `Route5CleanseTagHouseGrannyScript` | -1 | +| `ROUTE5CLEANSETAGHOUSE_TEACHER` | `SPRITE_TEACHER` | 5 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route5CleanseTagHouseTeacherScript` | -1 | + +**Scripts of interest** + +- `Route5CleanseTagHouseGrannyScript` (`pokegold.sym` `61:57de`) - + `faceplayer` / `opentext` / `checkevent EVENT_GOT_CLEANSE_TAG` / `iftrue .GotCleanseTag`; + otherwise `writetext Route5CleanseTagHouseGrannyText1`, `promptbutton`, + `verbosegiveitem CLEANSE_TAG`, `iffalse .NoRoom`, `setevent EVENT_GOT_CLEANSE_TAG`, + then the "you are protected now" line. Bag-full drops straight to `.NoRoom` without + setting the flag, so a bot must have a free pocket slot or repeat the talk. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `CLEANSE_TAG` | talk to `ROUTE5CLEANSETAGHOUSE_GRANNY` at `2,5` | `Route5CleanseTagHouseGrannyScript`, `verbosegiveitem CLEANSE_TAG` | `EVENT_GOT_CLEANSE_TAG` (`constants/event_flags.asm:218`) | + +--- + +### MAP_ROUTE_5_UNDERGROUND_PATH_ENTRANCE + +- Script: `maps/Route5UndergroundPathEntrance.asm` +- Header: `data/maps/maps.asm:501` -> `map Route5UndergroundPathEntrance, TILESET_GATE, GATE, LANDMARK_ROUTE_5, MUSIC_ROUTE_3, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:465` -> `map_const ROUTE_5_UNDERGROUND_PATH_ENTRANCE, 4, 4` (group 25, id 13) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `ROUTE_5` | 1 | +| 2 | 4 | 7 | `ROUTE_5` | 1 | +| 3 | 4 | 3 | `UNDERGROUND_PATH` | 1 | + +Only reachable once the Route 5 Pokefan is gone. Listed here because the walkthrough calls +him out; nothing in this section actually enters it. + +--- + +### MAP_CERULEAN_CITY + +- Script: `maps/CeruleanCity.asm` +- Blocks: `maps/CeruleanCity.blk` +- Header: `data/maps/maps.asm:221` -> `map CeruleanCity, TILESET_KANTO, TOWN, LANDMARK_CERULEAN_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:203` -> `map_const CERULEAN_CITY, 20, 18` (group `CERULEAN` = 7, id 17) +- Connections (`data/maps/attributes.asm:360`): north `ROUTE_24` (+6), south `ROUTE_5` (+5), west `ROUTE_4` (+5), east `ROUTE_9` (+9) +- Fly / spawn: `data/maps/spawn_points.asm:17` `spawn CERULEAN_CITY, 19, 22`; + `data/maps/flypoints.asm:21` `db LANDMARK_CERULEAN_CITY, SPAWN_CERULEAN` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 15 | `CERULEAN_GYM_BADGE_SPEECH_HOUSE` | 1 | +| 2 | 28 | 17 | `CERULEAN_POLICE_STATION` | 1 | +| 3 | 13 | 19 | `CERULEAN_TRADE_SPEECH_HOUSE` | 1 | +| 4 | 19 | 21 | `CERULEAN_POKECENTER_1F` | 1 | +| 5 | 30 | 23 | `CERULEAN_GYM` | 1 | +| 6 | 25 | 29 | `CERULEAN_MART` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 23 | 23 | `BGEVENT_READ` | `CeruleanCitySign` | +| 27 | 25 | `BGEVENT_READ` | `CeruleanGymSign` | +| 11 | 29 | `BGEVENT_READ` | `CeruleanBikeShopSign` ("The BIKE SHOP has moved to GOLDENROD") | +| 25 | 17 | `BGEVENT_READ` | `CeruleanPoliceSign` | +| 23 | 7 | `BGEVENT_READ` | `CeruleanCapeSign` | +| 14 | 29 | `BGEVENT_READ` | `CeruleanLockedDoor` | +| 20 | 21 | `BGEVENT_READ` | `CeruleanCityPokecenterSign` | +| 26 | 29 | `BGEVENT_READ` | `CeruleanCityMartSign` | +| 2 | 12 | `BGEVENT_ITEM` | `CeruleanCityHiddenBerserkGene` -> `hiddenitem BERSERK_GENE, EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY` (`pokegold.sym` `4f:60ad`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CERULEANCITY_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 15 | 23 | `SPRITEMOVEDATA_WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `CeruleanCityCooltrainerMScript` | -1 | +| `CERULEANCITY_SUPER_NERD` | `SPRITE_SUPER_NERD` | 23 | 15 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `CeruleanCitySuperNerdScript` | -1 | +| `CERULEANCITY_SLOWPOKE` | `SPRITE_SLOWPOKE` | 20 | 24 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `CeruleanCitySlowbro` | -1 | +| `CERULEANCITY_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 21 | 24 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `CeruleanCityCooltrainerFScript` | -1 | +| `CERULEANCITY_FISHER` | `SPRITE_FISHER` | 30 | 26 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `CeruleanCityFisherScript` | -1 | +| `CERULEANCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 6 | 12 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` (1,0) | `OBJECTTYPE_SCRIPT` | `CeruleanCityYoungsterScript` | -1 | + +**Scripts of interest** + +- `CeruleanCityFlypointCallback` - `MAPCALLBACK_NEWMAP`, `setflag ENGINE_FLYPOINT_CERULEAN` + then `endcallback`. Simply entering the city registers the Fly destination the walkthrough + relies on twice. +- `CeruleanCityCooltrainerMScript` - `checkevent EVENT_RETURNED_MACHINE_PART`; before the + part is returned he is the hint "KANTO's POWER PLANT is toward the end of ROUTE 9". +- `CeruleanCityFisherScript` - `checkevent EVENT_RETURNED_MACHINE_PART` (iftrue -> generic + line), else `checkevent EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM` -> "I saw this shady guy + go off toward CERULEAN's CAPE", i.e. the pointer to Route 24/25. +- `CeruleanCityYoungsterScript` - after his first line, if + `EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY` is clear he plays the ITEMFINDER ping loop + (`SFX_SECOND_PART_OF_ITEMFINDER` / `SFX_TRANSACTION` x4) and `showemote EMOTE_SHOCK`. + That is the in-game hint for the hidden BERSERK_GENE at `2,12`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_CERULEAN` | `constants/engine_flags.asm:70` | `CeruleanCityFlypointCallback` | Fly target unlocked on first entry | +| `EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM` | `constants/event_flags.asm:202` | set by `CeruleanGymGruntRunsOutScript`; read by `CeruleanCityFisherScript` | grunt cutscene has played | +| `EVENT_RETURNED_MACHINE_PART` | `constants/event_flags.asm:200` | set by `PowerPlantManager`; read here and in ~10 other maps | post-section state | +| `EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY` | `constants/event_flags.asm:250` | `CeruleanCityHiddenBerserkGene`, `CeruleanCityYoungsterScript` | hidden item consumed | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BERSERK_GENE` | face the tile and press A (hidden) | `bg_event 2, 12, BGEVENT_ITEM, CeruleanCityHiddenBerserkGene` | `EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY` | + +**Wild encounters** - no grass table. Water: `data/wild/kanto_water.asm:131` +`def_water_wildmons CERULEAN_CITY`, 4 percent: L10 GOLDEEN, L5 GOLDEEN, L10 SEAKING. +Fishing group `FISHGROUP_LAKE`. + +--- + +### MAP_CERULEAN_GYM + +- Script: `maps/CeruleanGym.asm` +- Blocks: `maps/CeruleanGym.blk` +- Header: `data/maps/maps.asm:210` -> `map CeruleanGym, TILESET_PORT, INDOOR, LANDMARK_CERULEAN_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` + (the `data/maps/maps.asm:209` comment notes the known bug that you can fish in the gym pool) +- Dimensions: `constants/map_constants.asm:192` -> `map_const CERULEAN_GYM, 5, 8` (group 7, id 6) +- Scenes (`def_scene_scripts`, index order from `macros/scripts/maps.asm` `def_scene_scripts`/`scene_const`): + `SCENE_CERULEANGYM_NOOP` = 0, `SCENE_CERULEANGYM_GRUNT_RUNS_OUT` = 1. + Scene var: `data/maps/scenes.asm:13` `scene_var CERULEAN_GYM, wCeruleanGymSceneID`. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 15 | `CERULEAN_CITY` | 5 | +| 2 | 5 | 15 | `CERULEAN_CITY` | 5 | + +**Coord events** - none. The grunt cutscene runs from the *scene script* +(`CeruleanGymGruntRunsOutScene` -> `sdefer CeruleanGymGruntRunsOutScript`), which fires on +map load while `wCeruleanGymSceneID == SCENE_CERULEANGYM_GRUNT_RUNS_OUT`, not from a tile. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 8 | `BGEVENT_ITEM` | `CeruleanGymHiddenMachinePart` -> `hiddenitem MACHINE_PART, EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` (`pokegold.sym` `54:440e`) | +| 2 | 13 | `BGEVENT_READ` | `CeruleanGymStatue1` | +| 6 | 13 | `BGEVENT_READ` | `CeruleanGymStatue2` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CERULEANGYM_ROCKET` | `SPRITE_ROCKET` | 4 | 10 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` (inert; driven by the scene script) | `EVENT_CERULEAN_GYM_ROCKET` | +| `CERULEANGYM_MISTY` | `SPRITE_MISTY` | 5 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CeruleanGymMistyScript` | `EVENT_TRAINERS_IN_CERULEAN_GYM` | +| `CERULEANGYM_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 4 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSwimmerfDiana` | `EVENT_TRAINERS_IN_CERULEAN_GYM` | +| `CERULEANGYM_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 1 | 9 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerSwimmerfBriana` | `EVENT_TRAINERS_IN_CERULEAN_GYM` | +| `CERULEANGYM_SWIMMER_GUY` | `SPRITE_SWIMMER_GUY` | 8 | 9 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSwimmermParker` | `EVENT_TRAINERS_IN_CERULEAN_GYM` | +| `CERULEANGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 13 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CeruleanGymGuideScript` | `EVENT_TRAINERS_IN_CERULEAN_GYM` | + +**Scripts of interest** + +- `CeruleanGymGruntRunsOutScript` (`pokegold.sym` `54:4336`) - the grunt runs down four + `big_step DOWN`, `playsound SFX_TACKLE`, jumps into the player + (`CeruleanGymGruntRunsIntoYouMovement`: `fix_facing` / `set_sliding` / `jump_step UP`), + `playmusic MUSIC_ROCKET_ENCOUNTER`, three text boxes with a `showemote EMOTE_SHOCK` + between them, then `CeruleanGymGruntRunsOutMovement` (`big_step RIGHT`, `big_step DOWN`), + `playsound SFX_EXIT_BUILDING`, `disappear CERULEANGYM_ROCKET`. State changes, in order: + `setevent EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM`, + `clearevent EVENT_ROUTE_24_ROCKET` (spawns the grunt on Route 24), + `clearevent EVENT_ROUTE_25_MISTY_BOYFRIEND` (spawns Misty + her date on Route 25), + `setscene SCENE_CERULEANGYM_NOOP`, + `setmapscene ROUTE_25, SCENE_ROUTE25_MISTYS_DATE`, + `setmapscene POWER_PLANT, SCENE_POWERPLANT_NOOP`, then + `special RestartMapMusic` and `turnobject PLAYER, DOWN`. +- `CeruleanGymMistyScript` (`pokegold.sym` `54:438a`) - `checkflag ENGINE_CASCADEBADGE`, + `iftrue .FightDone`; else `MistyIntroText`, `winlosstext MistyWinLossText, 0`, + `loadtrainer MISTY, MISTY1`, `startbattle`, `reloadmapafterbattle`, then + `setevent EVENT_BEAT_MISTY` **and** `setevent EVENT_BEAT_SWIMMERF_DIANA`, + `EVENT_BEAT_SWIMMERF_BRIANA`, `EVENT_BEAT_SWIMMERM_PARKER` (beating Misty retroactively + marks the three gym trainers beaten), `ReceivedCascadeBadgeText`, `playsound SFX_GET_BADGE`, + `setflag ENGINE_CASCADEBADGE`. +- `CeruleanGymStatue1` / `CeruleanGymStatue2` - `checkevent EVENT_TRAINERS_IN_CERULEAN_GYM`; + if *false* (trainers present) they fall through to the shared `CeruleanGymStatue` + (`jumpstd GymStatue1Script`, or `GymStatue2Script` with `gettrainername STRING_BUFFER_4, MISTY, MISTY1` + once `ENGINE_CASCADEBADGE` is set). If true (gym empty) they print the "Sorry, I'll be out + for a while - MISTY" / "Since MISTY's out, we'll be away too" notes. +- `CeruleanGymGuideScript` - `checkevent EVENT_BEAT_MISTY` for the post-win line. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_TRAINERS_IN_CERULEAN_GYM` | `constants/event_flags.asm:1297` | set in `engine/events/std_scripts.asm:550` (`InitializeEventsScript`); cleared by `Route25MistyDate1Script` / `Route25MistyDate2Script` | set = Misty, three Swimmers and the guide are all hidden. This is why the first gym visit is empty. | +| `EVENT_CERULEAN_GYM_ROCKET` | `constants/event_flags.asm:1295` | set at `std_scripts.asm:547`; cleared by `PowerPlantManager` (`maps/PowerPlant.asm:146`) | clear = grunt object exists so the scene can run | +| `EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` | `constants/event_flags.asm:251` | set at `std_scripts.asm:546`; cleared by `PowerPlantManager` (`maps/PowerPlant.asm:147`) | set = the hidden item at `3,8` yields nothing. Talking to the MANAGER is what arms it. | +| `EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM` | `constants/event_flags.asm:202` | `CeruleanGymGruntRunsOutScript` | cutscene done | +| `EVENT_BEAT_MISTY` | `constants/event_flags.asm:716` | `CeruleanGymMistyScript` | gym cleared | +| `ENGINE_CASCADEBADGE` | `constants/engine_flags.asm:48` | `CeruleanGymMistyScript` `checkflag` / `setflag` | the badge itself | +| `SCENE_CERULEANGYM_GRUNT_RUNS_OUT` (= 1) | `maps/CeruleanGym.asm:12` via `scene_const` | `setmapscene` at `maps/PowerPlant.asm:148`; cleared to `SCENE_CERULEANGYM_NOOP` at `maps/CeruleanGym.asm:49` | entering the gym with this scene id plays the grunt scene | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MACHINE_PART` | stand at the pool and press A into `3,8` | `bg_event 3, 8, BGEVENT_ITEM, CeruleanGymHiddenMachinePart` | `EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` (must be cleared first by the MANAGER) | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERF, BRIANA` | `SWIMMERF` | 19 (`parties.asm:1885`) | L35 SEAKING, L35 SEAKING | `TrainerSwimmerfBriana` | none | +| `SWIMMERM, PARKER` | `SWIMMERM` | 21 (`parties.asm:1775`) | L32 HORSEA, L32 HORSEA, L35 SEADRA | `TrainerSwimmermParker` | none | +| `SWIMMERF, DIANA` | `SWIMMERF` | 18 (`parties.asm:1880`) | L37 GOLDUCK | `TrainerSwimmerfDiana` | none | +| `MISTY, MISTY1` | `MISTY` | 1 (`parties.asm:281`, `TRAINERTYPE_MOVES`) | L42 GOLDUCK (SURF, DISABLE, PSYCH_UP, PSYCHIC_M); L42 QUAGSIRE (SURF, AMNESIA, EARTHQUAKE, RAIN_DANCE); L44 LAPRAS (SURF, PERISH_SONG, BLIZZARD, RAIN_DANCE); L47 STARMIE (SURF, CONFUSE_RAY, RECOVER, ICE_BEAM) | `CeruleanGymMistyScript` | leader, no phone | + +Base rewards (`data/trainers/attributes.asm`): SWIMMERF 5, SWIMMERM 2, MISTY 25. Payout is +`ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300`) = base x last mon level, +added four times by the `ld c, 4` loop in `engine/battle/core.asm:2340`, i.e. base x level x 4. +That reproduces every number in the walkthrough (Misty 25 x 47 x 4 = 4700). + +--- + +### MAP_ROUTE_9 + +- Script: `maps/Route9.asm` +- Blocks: `maps/Route9.blk` +- Header: `data/maps/maps.asm:217` -> `map Route9, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_9, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:199` -> `map_const ROUTE_9, 30, 9` (group 7, id 13) +- Connections (`data/maps/attributes.asm:366`): south `ROUTE_10_NORTH` (+20), west `CERULEAN_CITY` (-9) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 48 | 15 | `ROCK_TUNNEL_1F` | 1 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 15 | 7 | `BGEVENT_READ` | `Route9Sign` | +| 10 | 5 | `BGEVENT_ITEM` | `Route9HiddenEther` -> `hiddenitem ETHER, EVENT_ROUTE_9_HIDDEN_ETHER` (`pokegold.sym` `50:407d`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE9_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 23 | 11 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerCamperDean` | -1 | +| `ROUTE9_LASS1` | `SPRITE_LASS` | 35 | 8 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerPicnickerHeidi` | -1 | +| `ROUTE9_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 11 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerCamperSid` | -1 | +| `ROUTE9_LASS2` | `SPRITE_LASS` | 9 | 10 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerPicnickerEdna` | -1 | +| `ROUTE9_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 32 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerHikerTim` | -1 | +| `ROUTE9_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 33 | 15 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerHikerSidney` | -1 | + +**Scripts of interest** - all six are the plain `trainer CLASS, ID, EVENT_BEAT_*, seen, beaten, 0, .Script` +form with an `endifjustbattled` + one after-battle text. No flags beyond `EVENT_BEAT_*`. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ETHER` | hidden, face `10,5` | `bg_event 10, 5, BGEVENT_ITEM, Route9HiddenEther` | `EVENT_ROUTE_9_HIDDEN_ETHER` (`constants/event_flags.asm:242`) | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `PICNICKER, EDNA` | `PICNICKER` | 14 (`parties.asm:2659`) | L30 NIDORINA, L34 RAICHU | `TrainerPicnickerEdna` | none | +| `CAMPER, SID` | `CAMPER` | 8 (`parties.asm:2744`) | L32 DUGTRIO, L29 PRIMEAPE, L29 POLIWRATH | `TrainerCamperSid` | none | +| `CAMPER, DEAN` | `CAMPER` | 7 (`parties.asm:2738`) | L33 GOLDUCK, L31 SANDSLASH | `TrainerCamperDean` | none | +| `HIKER, SIDNEY` | `HIKER` | 15 (`parties.asm:2210`) | L34 DUGTRIO, L32 ONIX | `TrainerHikerSidney` | none | +| `HIKER, TIM` | `HIKER` | 13 (`parties.asm:2197`) | L31 GRAVELER x3 | `TrainerHikerTim` | none | +| `PICNICKER, HEIDI` | `PICNICKER` | 13 (`parties.asm:2653`) | L32 SKIPLOOM, L32 SKIPLOOM | `TrainerPicnickerHeidi` | none | + +Base rewards: PICNICKER 5, CAMPER 5, HIKER 8 (`data/trainers/attributes.asm`). + +**Wild encounters** - `data/wild/kanto_grass.asm:645` `def_grass_wildmons ROUTE_9`, 10/10/10 percent, +version-split with `IF DEF(_GOLD)`. Gold morn/day: L13 MANKEY, L15 RATTATA, L13 SPEAROW, +L15 RATICATE, L15 FEAROW, L15 PRIMEAPE, L15 PRIMEAPE. Gold nite: L13 MANKEY, L15 RATTATA, +L15 RATICATE, L13 RATTATA, L15 RATICATE, L15 PRIMEAPE, L15 PRIMEAPE. (Silver swaps MANKEY / +PRIMEAPE for RATTATA / RATICATE, which is exactly the "Gold only" note in the walkthrough.) +Water: `data/wild/kanto_water.asm:19`, 4 percent: L15 GOLDEEN, L10 GOLDEEN, L15 SEAKING. +Fishing group `FISHGROUP_LAKE`. No `treemon_maps.asm` row, so no headbutt table. + +--- + +### MAP_ROUTE_10_NORTH + +- Script: `maps/Route10North.asm` +- Blocks: `maps/Route10North.blk` +- Header: `data/maps/maps.asm:218` -> `map Route10North, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_10, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:200` -> `map_const ROUTE_10_NORTH, 10, 9` (group 7, id 14) +- Connections (`data/maps/attributes.asm:389`): north `ROUTE_9` (-20), south `ROUTE_10_SOUTH` (0) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 1 | `ROUTE_10_POKECENTER_1F` | 1 | +| 2 | 3 | 9 | `POWER_PLANT` | 1 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 11 | `BGEVENT_READ` | `PowerPlantSign` ("KANTO POWER PLANT") | +| 12 | 1 | `BGEVENT_READ` | `Route10PokecenterSign` (`jumpstd PokecenterSignScript`) | + +**Object events** - `def_object_events` is empty. No trainers on this map. + +**Wild encounters** - `data/wild/kanto_grass.asm:700` `def_grass_wildmons ROUTE_10_NORTH`, +10/10/10 percent, no version split. +morn: L16 SPEAROW, L17 VOLTORB, L16 RATICATE, L18 FEAROW, L16 QUAGSIRE, L15 ELECTABUZZ, L15 ELECTABUZZ. +day: L16 SPEAROW, L17 VOLTORB, L17 RATICATE, L18 FEAROW, L15 ELECTABUZZ, L17 ELECTABUZZ, L17 ELECTABUZZ. +nite: L16 QUAGSIRE, L17 VOLTORB, L16 RATICATE, L17 QUAGSIRE, L18 RATICATE, L15 ELECTABUZZ, L15 ELECTABUZZ. +Water: `data/wild/kanto_water.asm:26`, 4 percent: L15 GOLDEEN, L10 GOLDEEN, L15 SEAKING. +Fishing group `FISHGROUP_LAKE`. + +--- + +### MAP_POWER_PLANT + +- Script: `maps/PowerPlant.asm` +- Blocks: `maps/PowerPlant.blk` +- Header: `data/maps/maps.asm:214` -> `map PowerPlant, TILESET_FACILITY, INDOOR, LANDMARK_POWER_PLANT, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:196` -> `map_const POWER_PLANT, 10, 9` (group 7, id 10) +- Scenes: `SCENE_POWERPLANT_NOOP` = 0, `SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL` = 1 + (`maps/PowerPlant.asm:11-12`); scene var `data/maps/scenes.asm:12` + `scene_var POWER_PLANT, wPowerPlantSceneID` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 17 | `ROUTE_10_NORTH` | 2 | +| 2 | 3 | 17 | `ROUTE_10_NORTH` | 2 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL` (1) | 5 | 12 | `PowerPlantGuardPhoneScript` | the "shady character in CERULEAN" cutscene; ends with `setscene SCENE_POWERPLANT_NOOP` | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `PowerPlantBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `PowerPlantBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `POWERPLANT_OFFICER1` | `SPRITE_OFFICER` | 4 | 14 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PowerPlantOfficerScript` | -1 | +| `POWERPLANT_GYM_GUIDE1` | `SPRITE_GYM_GUIDE` | 2 | 9 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide1Script` | -1 | +| `POWERPLANT_GYM_GUIDE2` | `SPRITE_GYM_GUIDE` | 6 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide2Script` | -1 | +| `POWERPLANT_OFFICER2` | `SPRITE_OFFICER` | 9 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `PowerPlantOfficer2Script` | -1 | +| `POWERPLANT_GYM_GUIDE3` | `SPRITE_GYM_GUIDE` | 7 | 2 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide4Script` | -1 | +| `POWERPLANT_MANAGER` | `SPRITE_FISHER` | 14 | 10 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `PowerPlantManager` | -1 | + +(The object const list names the fifth guide `POWERPLANT_GYM_GUIDE3` while its script is +called `PowerPlantGymGuide4Script`; that mismatch is in the source, not a transcription slip.) + +**Scripts of interest** + +- `PowerPlantManager` (`pokegold.sym` `54:4dbd`) - the spine of the whole section: + 1. `checkevent EVENT_RETURNED_MACHINE_PART` -> `.ReturnedMachinePart` + 2. `checkitem MACHINE_PART` -> `.FoundMachinePart` + 3. `checkevent EVENT_MET_MANAGER_AT_POWER_PLANT` -> `.MetManager` + 4. first talk: `PowerPlantManagerWhoWouldRuinMyGeneratorText`, then + `setevent EVENT_MET_MANAGER_AT_POWER_PLANT`, + `clearevent EVENT_CERULEAN_GYM_ROCKET`, + `clearevent EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM`, + `setmapscene CERULEAN_GYM, SCENE_CERULEANGYM_GRUNT_RUNS_OUT`, + `setscene SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL`. + 5. `.FoundMachinePart` (out of scope for this walkthrough section, listed because it is the + payoff): `takeitem MACHINE_PART`, `setevent EVENT_RETURNED_MACHINE_PART`, + `clearevent EVENT_SAFFRON_TRAIN_STATION_POPULATION`, + `setevent EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH`, + `setevent EVENT_ROUTE_24_ROCKET`, `setevent EVENT_RESTORED_POWER_TO_KANTO`, + `clearevent EVENT_GOLDENROD_TRAIN_STATION_GENTLEMAN`, then + `verbosegiveitem TM_ZAP_CANNON` guarded by `EVENT_GOT_TM07_ZAP_CANNON`. +- `PowerPlantGuardPhoneScript` - `playsound SFX_CALL`, `showemote EMOTE_SHOCK` on + `POWERPLANT_OFFICER1`, `applymovement POWERPLANT_OFFICER1 PowerPlantOfficer1ApproachGymGuide2Movement` + (RIGHT, RIGHT, UP, UP), two text boxes, then `PowerPlantOfficer1ReturnToPostMovement` + (DOWN, DOWN, LEFT, LEFT, turn_head DOWN) and `setscene SCENE_POWERPLANT_NOOP`. +- The four flavour NPCs all branch only on `EVENT_RETURNED_MACHINE_PART` + (`PowerPlantOfficerScript` additionally on `EVENT_MET_MANAGER_AT_POWER_PLANT`). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_MET_MANAGER_AT_POWER_PLANT` | `constants/event_flags.asm:201` | `PowerPlantManager` | gate for everything downstream | +| `EVENT_CERULEAN_GYM_ROCKET` | `constants/event_flags.asm:1295` | cleared by `PowerPlantManager` | grunt object now exists in the gym | +| `EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` | `constants/event_flags.asm:251` | cleared by `PowerPlantManager` | the hidden item is now live | +| `SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL` (1) | `maps/PowerPlant.asm:12` | `setscene`, then the `5,12` coord event | forces the exit cutscene once | +| `EVENT_RETURNED_MACHINE_PART` / `EVENT_RESTORED_POWER_TO_KANTO` | `constants/event_flags.asm:200` / `:204` | `.FoundMachinePart` | later-section state (Magnet Train, Underground Path, roaming beasts checks in `maps/Route26.asm` etc.) | +| `EVENT_GOT_TM07_ZAP_CANNON` | `constants/event_flags.asm:222` | `.ReturnedMachinePart` | TM07 reward | + +**Items** - `TM_ZAP_CANNON` from the MANAGER, but only on the *return* trip, which the +walkthrough section does not cover. + +**Trainers / wild encounters** - none. + +--- + +### MAP_ROUTE_24 + +- Script: `maps/Route24.asm` +- Blocks: `maps/Route24.blk` +- Header: `data/maps/maps.asm:219` -> `map Route24, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_24, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:201` -> `map_const ROUTE_24, 10, 9` (group 7, id 15) +- Connections (`data/maps/attributes.asm:370`): north `ROUTE_25` (0), south `CERULEAN_CITY` (-6) + +**Warps** - `def_warp_events` is empty. +**Coord events** - empty. +**BG events** - empty. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE24_ROCKET` | `SPRITE_ROCKET` | 8 | 7 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `Route24RocketScript` | `EVENT_ROUTE_24_ROCKET` | + +**Scripts of interest** + +- `Route24RocketScript` (`pokegold.sym` `50:4407`) - `faceplayer`, + `playmusic MUSIC_ROCKET_ENCOUNTER`, `Route24RocketSeenText`, + `winlosstext Route24RocketBeatenText, -1`, `loadtrainer GRUNTM, GRUNTM_31`, `startbattle`, + `dontrestartmapmusic`, `reloadmapafterbattle`, then `Route24RocketAfterBattleText` + (the "MACHINE PART ... hide it I did in GYM of the CERULEAN ... Inside water put it I did" + hint), `special FadeOutMusic`, `Route24RocketDisappearsText`, `special FadeOutToBlack`, + `special ReloadSpritesNoPalettes`, `disappear ROUTE24_ROCKET`, `pause 25`, + `special FadeInFromBlack`, `playmapmusic`. + Note: he is *talked to*, not a sight-line trainer, and there is no `EVENT_BEAT_*` - + `disappear` sets `EVENT_ROUTE_24_ROCKET` and that is the only record of the win. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_24_ROCKET` | `constants/event_flags.asm:1294` | set at `engine/events/std_scripts.asm:548`; cleared by `CeruleanGymGruntRunsOutScript`; set again by `disappear` and by `PowerPlantManager:165` | clear = grunt is standing on the bridge at `8,7` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `GRUNTM, GRUNTM_31` | `GRUNTM` | 31 (`data/trainers/parties.asm:1340`) | L30 GOLBAT | `Route24RocketScript` | none | + +**Wild encounters** - `data/wild/kanto_grass.asm:1061` `def_grass_wildmons ROUTE_24`, 10/10/10 percent. +morn: L8 BELLSPROUT, L10 BELLSPROUT, L9 ABRA, L12 WEEPINBELL, L8 VENONAT, L14 WEEPINBELL, L14 WEEPINBELL. +day: L8 BELLSPROUT, L10 SUNKERN, L9 ABRA, L12 WEEPINBELL, L10 BELLSPROUT, L14 WEEPINBELL, L14 WEEPINBELL. +nite: L8 VENONAT, L10 ODDISH, L9 ABRA, L13 WEEPINBELL, L10 BELLSPROUT, L10 VENOMOTH, L10 VENOMOTH. +Water: `data/wild/kanto_water.asm:75`, 4 percent: L10 GOLDEEN, L5 GOLDEEN, L10 SEAKING. + +--- + +### MAP_ROUTE_25 + +- Script: `maps/Route25.asm` +- Blocks: `maps/Route25.blk` +- Header: `data/maps/maps.asm:220` -> `map Route25, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_25, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:202` -> `map_const ROUTE_25, 30, 9` (group 7, id 16) +- Connections (`data/maps/attributes.asm:374`): south `ROUTE_24` (0). No other connection - the + route is a dead end at Bill's house. +- Scenes: `SCENE_ROUTE25_NOOP` = 0, `SCENE_ROUTE25_MISTYS_DATE` = 1 (`maps/Route25.asm:16-17`); + scene var `data/maps/scenes.asm:14` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 47 | 5 | `BILLS_HOUSE` | 1 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE25_MISTYS_DATE` (1) | 42 | 6 | `Route25MistyDate1Script` (`pokegold.sym` `50:4781`) | the date cutscene, upper approach | +| `SCENE_ROUTE25_MISTYS_DATE` (1) | 42 | 7 | `Route25MistyDate2Script` | same cutscene, lower approach | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 45 | 5 | `BGEVENT_READ` | `BillsHouseSign` ("SEA COTTAGE / BILL'S HOUSE") | +| 4 | 5 | `BGEVENT_ITEM` | `Route25HiddenPotion` -> `hiddenitem POTION, EVENT_ROUTE_25_HIDDEN_POTION` (`pokegold.sym` `50:48c6`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE25_MISTY` | `SPRITE_MISTY` | 46 | 9 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` (inert) | `EVENT_ROUTE_25_MISTY_BOYFRIEND` | +| `ROUTE25_COOLTRAINER_M1` | `SPRITE_COOLTRAINER_M` | 46 | 10 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` (inert) | `EVENT_ROUTE_25_MISTY_BOYFRIEND` | +| `ROUTE25_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 12 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSchoolboyDudley` | -1 | +| `ROUTE25_LASS1` | `SPRITE_LASS` | 16 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerLassEllen` | -1 | +| `ROUTE25_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 21 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerSchoolboyJoe` | -1 | +| `ROUTE25_LASS2` | `SPRITE_LASS` | 22 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerLassLaura` | -1 | +| `ROUTE25_YOUNGSTER3` | `SPRITE_YOUNGSTER` | 25 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerCamperLloyd` | -1 | +| `ROUTE25_LASS3` | `SPRITE_LASS` | 28 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerLassShannon` | -1 | +| `ROUTE25_SUPER_NERD` | `SPRITE_SUPER_NERD` | 31 | 7 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER` (sight 1) | `TrainerSupernerdPat` | -1 | +| `ROUTE25_COOLTRAINER_M2` | `SPRITE_COOLTRAINER_M` | 37 | 8 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `TrainerCooltrainermKevin` | -1 | +| `ROUTE25_POKE_BALL` | `SPRITE_POKE_BALL` | 32 | 4 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route25Protein` (`itemball PROTEIN`, `pokegold.sym` `50:48c4`) | `EVENT_ROUTE_25_PROTEIN` | + +**Scripts of interest** + +- `Route25MistyDate1Script` / `Route25MistyDate2Script` - identical except for the approach + movement. `showemote EMOTE_HEART` on Misty, `pause 30`, `showemote EMOTE_SHOCK` on the + boyfriend, `applymovement ROUTE25_COOLTRAINER_M1` (one or two `big_step DOWN`), + `disappear ROUTE25_COOLTRAINER_M1`, `playmusic MUSIC_BEAUTY_ENCOUNTER`, Misty walks over + (`Route25MistyApproachesPlayerMovement1` = UP,UP,UP,LEFT,LEFT,LEFT; variant 2 drops one UP), + `writetext Route25MistyDateText` (the "pest" speech that ends with "Come to CERULEAN GYM"), + Misty leaves via `Route25MistyLeavesMovement` (LEFT x5), `disappear ROUTE25_MISTY`, + **`clearevent EVENT_TRAINERS_IN_CERULEAN_GYM`**, `setscene SCENE_ROUTE25_NOOP`, + `special RestartMapMusic`. +- `TrainerCooltrainermKevin` - not a `trainer` macro; a hand-written script. + `checkevent EVENT_BEAT_COOLTRAINERM_KEVIN` -> after-battle text; + `checkevent EVENT_CLEARED_NUGGET_BRIDGE` -> skip the prize; + otherwise `CooltrainermKevinNuggetText`, `verbosegiveitem NUGGET`, `iffalse .NoRoomForNugget`, + `setevent EVENT_CLEARED_NUGGET_BRIDGE`, then `winlosstext CooltrainermKevinBeatenText, 0`, + `loadtrainer COOLTRAINERM, KEVIN`, `startbattle`, `reloadmapafterbattle`, + `setevent EVENT_BEAT_COOLTRAINERM_KEVIN`. + A full bag at the Nugget step aborts before the battle. +- The six "six-pack" trainers are plain `trainer` macros with `EVENT_BEAT_*` flags only. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_25_MISTY_BOYFRIEND` | `constants/event_flags.asm:1296` | set at `engine/events/std_scripts.asm:549`; cleared by `CeruleanGymGruntRunsOutScript` | clear = the pair are standing at `46,9` / `46,10` | +| `SCENE_ROUTE25_MISTYS_DATE` (1) | `maps/Route25.asm:17` | `setmapscene` from `maps/CeruleanGym.asm:50` | arms the two coord events | +| `EVENT_TRAINERS_IN_CERULEAN_GYM` | `constants/event_flags.asm:1297` | cleared here | the gym becomes populated | +| `EVENT_CLEARED_NUGGET_BRIDGE` | `constants/event_flags.asm:215` | `TrainerCooltrainermKevin` | Nugget already handed over | +| `EVENT_BEAT_COOLTRAINERM_KEVIN` | `constants/event_flags.asm:873` | `TrainerCooltrainermKevin` | Kevin battle done | +| `EVENT_ROUTE_25_HIDDEN_POTION` | `constants/event_flags.asm:248` | `Route25HiddenPotion` | hidden Potion taken | +| `EVENT_ROUTE_25_PROTEIN` | `constants/event_flags.asm:1326` | item ball object row | Protein ball taken | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `POTION` | hidden, face `4,5` | `bg_event 4, 5, BGEVENT_ITEM, Route25HiddenPotion` | `EVENT_ROUTE_25_HIDDEN_POTION` | +| `NUGGET` | talk to Kevin at `37,8` | `TrainerCooltrainermKevin`, `verbosegiveitem NUGGET` | `EVENT_CLEARED_NUGGET_BRIDGE` | +| `PROTEIN` | item ball at `32,4` | `object_event ... OBJECTTYPE_ITEMBALL, 0, Route25Protein` | `EVENT_ROUTE_25_PROTEIN` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `SCHOOLBOY, DUDLEY` | `SCHOOLBOY` | 7 (`parties.asm:448`) | L35 ODDISH | `TrainerSchoolboyDudley` | none | +| `LASS, ELLEN` | `LASS` | 11 (`parties.asm:703`) | L30 WIGGLYTUFF, L34 GRANBULL | `TrainerLassEllen` | none | +| `SCHOOLBOY, JOE` | `SCHOOLBOY` | 8 (`parties.asm:453`) | L33 TANGELA, L33 VAPOREON | `TrainerSchoolboyJoe` | none | +| `LASS, LAURA` | `LASS` | 7 (`parties.asm:676`) | L28 GLOOM, L31 PIDGEOTTO, L31 BELLOSSOM | `TrainerLassLaura` | none | +| `CAMPER, LLOYD` | `CAMPER` | 6 (`parties.asm:2733`) | L34 NIDOKING | `TrainerCamperLloyd` | none | +| `LASS, SHANNON` | `LASS` | 8 (`parties.asm:683`) | L29 PARAS, L29 PARAS, L32 PARASECT | `TrainerLassShannon` | none | +| `SUPER_NERD, PAT` | `SUPER_NERD` | 8 (`parties.asm:2009`) | L36 PORYGON | `TrainerSupernerdPat` | none | +| `COOLTRAINERM, KEVIN` | `COOLTRAINERM` | 17 (`parties.asm:847`) | L38 RHYHORN, L35 CHARMELEON, L35 WARTORTLE | `TrainerCooltrainermKevin` | none | + +Base rewards: SCHOOLBOY 8, LASS 6, CAMPER 5, SUPER_NERD 8, COOLTRAINERM 12. + +**Wild encounters** - `data/wild/kanto_grass.asm:1089` `def_grass_wildmons ROUTE_25`, 10/10/10 percent. +morn: L8 PIDGEY, L10 BELLSPROUT, L8 VENONAT, L9 ABRA, L10 PIDGEOTTO, L14 WEEPINBELL, L14 WEEPINBELL. +day: L8 PIDGEY, L10 BELLSPROUT, L10 PIDGEY, L9 ABRA, L12 PIDGEOTTO, L14 WEEPINBELL, L14 WEEPINBELL. +nite: L8 VENONAT, L10 ODDISH, L10 VENOMOTH, L9 ABRA, L14 WEEPINBELL, L10 BELLSPROUT, L10 BELLSPROUT. +Water: `data/wild/kanto_water.asm:82`, 4 percent: L10 GOLDEEN, L5 GOLDEEN, L10 SEAKING. + +--- + +### MAP_BILLS_HOUSE + +- Script: `maps/BillsHouse.asm` +- Blocks: shared, `data/maps/blocks.asm:163` (`BillsHouse_Blocks`) +- Header: `data/maps/maps.asm:215` -> `map BillsHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_25, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:197` -> `map_const BILLS_HOUSE, 4, 4` (group 7, id 11) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_25` | 1 | +| 2 | 3 | 7 | `ROUTE_25` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `BILLSHOUSE_GRAMPS` | `SPRITE_GRAMPS` | 2 | 3 | `SPRITEMOVEDATA_STANDING_UP` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `BillsGrandpa` (`pokegold.sym` `54:547c`) | -1 | + +**Scripts of interest** + +- `BillsGrandpa` - a five-step chain, each step gated on the previous. Order in the asm: + 1. LICKITUNG -> `EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA` -> `verbosegiveitem EVERSTONE` + (`EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA`) + 2. ODDISH -> `EVENT_SHOWED_ODDISH_TO_BILLS_GRANDPA` -> `verbosegiveitem LEAF_STONE` + (`EVENT_GOT_LEAF_STONE_FROM_BILLS_GRANDPA`) + 3. STARYU -> `EVENT_SHOWED_STARYU_TO_BILLS_GRANDPA` -> `verbosegiveitem WATER_STONE` + (`EVENT_GOT_WATER_STONE_FROM_BILLS_GRANDPA`) + 4. GROWLITHE (Gold) / VULPIX (Silver, selected by `checkver` / `iftrue .AskVulpix`) -> + `EVENT_SHOWED_GROWLITHE_VULPIX_TO_BILLS_GRANDPA` -> `verbosegiveitem FIRE_STONE` + (`EVENT_GOT_FIRE_STONE_FROM_BILLS_GRANDPA`) + 5. PICHU -> `EVENT_SHOWED_PICHU_TO_BILLS_GRANDPA` -> `verbosegiveitem THUNDERSTONE` + (`EVENT_GOT_THUNDERSTONE_FROM_BILLS_GRANDPA`, which also ends the chain) + Mechanics: `writetext ... AskToSeeMonText`, `yesorno`, `scall .ExcitedToSee`, + `special BillsGrandfather` (party pick; `iffalse` = cancelled), `ifnotequal <SPECIES>, .WrongPokemon`. + `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1` (`constants/event_flags.asm:5`) is set after each + successful hand-over, so he only accepts **one mon per visit** - a bot must leave and + re-enter the house between stones. +- First talk sets `EVENT_MET_BILLS_GRANDPA` (`constants/event_flags.asm:457`). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `EVERSTONE` | show LICKITUNG | `BillsGrandpa` | `EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA` | +| `LEAF_STONE` | show ODDISH | `BillsGrandpa` | `EVENT_GOT_LEAF_STONE_FROM_BILLS_GRANDPA` | +| `WATER_STONE` | show STARYU | `BillsGrandpa` | `EVENT_GOT_WATER_STONE_FROM_BILLS_GRANDPA` | +| `FIRE_STONE` | show GROWLITHE (Gold) / VULPIX (Silver) | `BillsGrandpa` | `EVENT_GOT_FIRE_STONE_FROM_BILLS_GRANDPA` | +| `THUNDERSTONE` | show PICHU | `BillsGrandpa` | `EVENT_GOT_THUNDERSTONE_FROM_BILLS_GRANDPA` (`:467`) | + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Cut tree at the Route 9 west entrance (and the tree above Super Nerd Pat on Route 25) | `engine/events/overworld.asm:117` `CutFunction` -> `.CheckAble` (`ld de, ENGINE_HIVEBADGE` / `CheckBadge`, then `CheckMapForSomethingToCut` -> `engine/overworld/tile_events.asm:76` `CheckCutCollision`). The trees themselves are block data in `maps/Route9.blk` / `maps/Route25.blk`, not object events. | HIVEBADGE + a party mon with CUT | already held (Johto badge 2) | +| Surfing the Route 9 / Route 10 river to reach the Power Plant door at `3,9` | `engine/events/overworld.asm:322` `SurfFunction` `.TrySurf` (`ld de, ENGINE_FOGBADGE` / `CheckBadge`, then `GetTilePermission` == `WATER_TILE`); the overworld A-press path is `engine/events/overworld.asm:469` `TrySurfOW` with the same `ENGINE_FOGBADGE` `CheckEngineFlag` | FOGBADGE + SURF | already held (Johto badge 6) | +| "Fly back to Cerulean City" | `engine/events/overworld.asm:529` `FlyFunction` (`ld de, ENGINE_STORMBADGE` at `:545`), destination row `data/maps/flypoints.asm:21` | STORMBADGE + FLY + `ENGINE_FLYPOINT_CERULEAN` set by `CeruleanCityFlypointCallback` | flypoint flag set on first entry to Cerulean | +| Cerulean Gym is empty on the first visit | `maps/CeruleanGym.asm:379-383` object rows all carry `EVENT_TRAINERS_IN_CERULEAN_GYM`, set at `engine/events/std_scripts.asm:550` | - | `clearevent` in `Route25MistyDate1Script` / `Route25MistyDate2Script` (`maps/Route25.asm:48` / `:74`) | +| Rocket grunt does not exist in the gym | `maps/CeruleanGym.asm:378` object row `EVENT_CERULEAN_GYM_ROCKET`, set at `std_scripts.asm:547`; the scene is `SCENE_CERULEANGYM_NOOP` by default | - | first talk to `PowerPlantManager` (`clearevent` + `setmapscene CERULEAN_GYM, SCENE_CERULEANGYM_GRUNT_RUNS_OUT`, `maps/PowerPlant.asm:146-148`) | +| Rocket grunt does not exist on Route 24 | `maps/Route24.asm:129` object row `EVENT_ROUTE_24_ROCKET`, set at `std_scripts.asm:548` | - | `clearevent` in `CeruleanGymGruntRunsOutScript` (`maps/CeruleanGym.asm:47`) | +| Misty + date do not exist on Route 25, and the two coord events are inert | `maps/Route25.asm:445-446` object rows `EVENT_ROUTE_25_MISTY_BOYFRIEND`; coord rows require `SCENE_ROUTE25_MISTYS_DATE` | - | `clearevent` + `setmapscene ROUTE_25, SCENE_ROUTE25_MISTYS_DATE` in `CeruleanGymGruntRunsOutScript` (`maps/CeruleanGym.asm:48`, `:50`) | +| Hidden MACHINE_PART at Cerulean Gym `3,8` yields nothing | `maps/CeruleanGym.asm:373` -> `CeruleanGymHiddenMachinePart`, flag set at `std_scripts.asm:546` | - | `clearevent EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` in `PowerPlantManager` (`maps/PowerPlant.asm:147`) | +| Route 5 Underground Path door at `17,15` physically blocked | `maps/Route5.asm:56` NPC standing on `17,16` | - | `setevent EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH` in `PowerPlantManager` `.FoundMachinePart` (`maps/PowerPlant.asm:164`) - i.e. **after** this section | +| Bill's grandpa gives one stone per visit | `maps/BillsHouse.asm` `.JustShowedSomething` on `EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1` | - | leave and re-enter the map | + +## 4. Bot checklist + +Ordered, machine-actionable. `(g,m)` is `(map group, map id)` from `constants/map_constants.asm`. + +1. `ROUTE_5_SAFFRON_GATE (25,14)`: walk onto warp 1/2 (`4,0` / `5,0`) -> `ROUTE_5`. Pre: none. Post: none. +2. `ROUTE_5 (25,1)`: optional - walk to warp 4 (`10,11`), enter `ROUTE_5_CLEANSE_TAG_HOUSE (25,15)`, talk to `ROUTE5CLEANSETAGHOUSE_GRANNY` at `2,5`. Pre: `EVENT_GOT_CLEANSE_TAG` clear + free bag slot. Post: `EVENT_GOT_CLEANSE_TAG` set, CLEANSE_TAG in bag. Exit via warp 1/2 (`2,7`/`3,7`). +3. `ROUTE_5`: walk north across the connection into `CERULEAN_CITY (7,17)`. Post: `ENGINE_FLYPOINT_CERULEAN` set by `MAPCALLBACK_NEWMAP`. +4. `CERULEAN_CITY`: optional - face `2,12` and press A for BERSERK_GENE. Pre: `EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY` clear. +5. `CERULEAN_CITY`: heal at warp 4 (`19,21`, `CERULEAN_POKECENTER_1F`). Optional but the walkthrough does it twice. +6. `CERULEAN_CITY`: warp 5 (`30,23`) -> `CERULEAN_GYM (7,6)`. Expect an empty room (`EVENT_TRAINERS_IN_CERULEAN_GYM` set). Read `2,13` / `6,13` for the two notes. Exit via `4,15`/`5,15`. +7. `CERULEAN_CITY`: exit east into `ROUTE_9 (7,13)`. Pre: HIVEBADGE + CUT for the entrance tree. +8. `ROUTE_9`: battle in walkthrough order - `TrainerPicnickerEdna` (`9,10`), `TrainerCamperSid` (`11,2`), `TrainerCamperDean` (`23,11`), `TrainerHikerSidney` (`33,15`), `TrainerHikerTim` (`32,3`), `TrainerPicnickerHeidi` (`35,8`). Post: `EVENT_BEAT_*` per trainer. Optional hidden ETHER at `10,5`. +9. `ROUTE_9`: cross the south connection into `ROUTE_10_NORTH (7,14)`. Optional heal at warp 1 (`11,1`). +10. `ROUTE_10_NORTH`: Surf (pre: FOGBADGE + SURF) down the river and step onto warp 2 (`3,9`) -> `POWER_PLANT (7,10)`. +11. `POWER_PLANT`: walk to `POWERPLANT_MANAGER` at `14,10` and talk. Pre: `EVENT_MET_MANAGER_AT_POWER_PLANT` clear. Post: `EVENT_MET_MANAGER_AT_POWER_PLANT` set, `EVENT_CERULEAN_GYM_ROCKET` cleared, `EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` cleared, `CERULEAN_GYM` scene = 1, `POWER_PLANT` scene = 1. +12. `POWER_PLANT`: walk over `5,12` on the way to the exit. Post: `PowerPlantGuardPhoneScript` runs, `POWER_PLANT` scene back to 0. Then exit via warp 1/2 (`2,17`/`3,17`). +13. FLY to `CERULEAN_CITY` (`SPAWN_CERULEAN` = `19,22`). Pre: STORMBADGE + FLY + `ENGINE_FLYPOINT_CERULEAN`. +14. `CERULEAN_CITY`: warp 5 -> `CERULEAN_GYM`. On load the scene script fires; hold through the movement/text (four A presses: intro, big-mistake, bye, plus the emote pauses). Post: `EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM` set, `EVENT_ROUTE_24_ROCKET` cleared, `EVENT_ROUTE_25_MISTY_BOYFRIEND` cleared, `ROUTE_25` scene = 1, `CERULEAN_GYM` scene = 0. +15. `CERULEAN_CITY`: exit north into `ROUTE_24 (7,15)`. Talk to `ROUTE24_ROCKET` at `8,7`. Battle `GRUNTM, GRUNTM_31` (L30 GOLBAT). Post: `disappear` sets `EVENT_ROUTE_24_ROCKET`. +16. `ROUTE_24`: north connection into `ROUTE_25 (7,16)`. +17. `ROUTE_25`: optional hidden POTION - face `4,5`, press A. Pre: `EVENT_ROUTE_25_HIDDEN_POTION` clear. +18. `ROUTE_25`: battle `TrainerSchoolboyDudley` (`12,8`), `TrainerLassEllen` (`16,11`), `TrainerSchoolboyJoe` (`21,8`), `TrainerLassLaura` (`22,6`), `TrainerCamperLloyd` (`25,4`), `TrainerLassShannon` (`28,11`), `TrainerSupernerdPat` (`31,7`). +19. `ROUTE_25`: talk to `ROUTE25_COOLTRAINER_M2` at `37,8`. Pre: free bag slot. Post: NUGGET, `EVENT_CLEARED_NUGGET_BRIDGE`, then battle `COOLTRAINERM, KEVIN` and `EVENT_BEAT_COOLTRAINERM_KEVIN`. +20. `ROUTE_25`: CUT the tree, walk onto the item ball at `32,4` for PROTEIN. Post: `EVENT_ROUTE_25_PROTEIN`. +21. `ROUTE_25`: walk east onto `42,6` (or `42,7`). Pre: `ROUTE_25` scene = `SCENE_ROUTE25_MISTYS_DATE`. Post: date cutscene, `EVENT_TRAINERS_IN_CERULEAN_GYM` cleared, `ROUTE_25` scene = 0. +22. `ROUTE_25`: warp 1 (`47,5`) -> `BILLS_HOUSE (7,11)`. Talk to `BILLSHOUSE_GRAMPS` at `2,3`. One stone per map load; loop enter/talk/exit until the wanted stones are collected. Post: `EVENT_MET_BILLS_GRANDPA`, then the `EVENT_GOT_*_FROM_BILLS_GRANDPA` chain. +23. FLY to `CERULEAN_CITY`, heal at warp 4, then warp 5 -> `CERULEAN_GYM`. +24. `CERULEAN_GYM`: battle `TrainerSwimmerfBriana` (`1,9`), then face `3,8` and press A for MACHINE_PART. Pre: `EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM` clear + free bag slot. Post: flag set, MACHINE_PART in bag. +25. `CERULEAN_GYM`: battle `TrainerSwimmermParker` (`8,9`), `TrainerSwimmerfDiana` (`4,6`). +26. `CERULEAN_GYM`: talk to `CERULEANGYM_MISTY` at `5,3`. Pre: `ENGINE_CASCADEBADGE` clear. Post: `EVENT_BEAT_MISTY`, `EVENT_BEAT_SWIMMERF_DIANA`, `EVENT_BEAT_SWIMMERF_BRIANA`, `EVENT_BEAT_SWIMMERM_PARKER`, `ENGINE_CASCADEBADGE` set, +4700 money. + +Follow-up (next section): carry MACHINE_PART back to `POWERPLANT_MANAGER` at `14,10` to set +`EVENT_RETURNED_MACHINE_PART` / `EVENT_RESTORED_POWER_TO_KANTO` and collect TM07 ZAP CANNON. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Kanto map headers, warps, coord/bg/object event tables extracted from ROM | `src/import/RomExtractorGen2.lua` (`OBJECTTYPE_ITEMBALL, OBJECTTYPE_TRAINER = 1, 2` at :71; branch at :2960-2968) | implemented - generic, all map groups, nothing Kanto-specific needed | +| Coord-event trip-wires (Power Plant `5,12`, Route 25 `42,6`/`42,7`) with the scene id filter | `src/world/gen2/World.lua:5006` `World:tryCoordScript` | implemented | +| Scene scripts on map load + `sdefer` (`CeruleanGymGruntRunsOutScene`) | `src/world/gen2/World.lua` `World:trySceneScript`; `src/script/gen2/Vm.lua:92` | partial - `sdefer` is run immediately rather than queued until the map settles (comment at `Vm.lua:93` says so). Cosmetic ordering risk on the gym grunt scene. | +| `setscene` / `setmapscene` / `checkmapscene` | `src/script/gen2/Vm.lua:274`, `:279`, `:284` | implemented, including the `0xff` "no scene var" sentinel | +| `checkevent` / `setevent` / `clearevent` and `checkflag` / `setflag` (`ENGINE_CASCADEBADGE`, `ENGINE_FLYPOINT_CERULEAN`) | `src/script/gen2/Vm.lua:198-229`, `engineFlags` store at `:1661` | implemented - generic id-keyed stores, no per-flag work needed | +| Hidden items (`BGEVENT_ITEM`): MACHINE_PART, ETHER, POTION, BERSERK_GENE | `src/world/gen2/HiddenItems.lua` | implemented (the header notes this was previously unreachable because `World:bgEventAt` only answered `BGEVENT_READ`) | +| Item ball (`Route25Protein`) | `src/import/RomExtractorGen2.lua:2968` (`OBJECTTYPE_ITEMBALL`) | implemented | +| Trainer sight lines / `OBJECTTYPE_TRAINER` sight radius | `src/world/gen2/Trainers.lua:98` `Trainers.sees` | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `reloadmapafterbattle` / `dontrestartmapmusic` / `endifjustbattled` | `src/script/gen2/Vm.lua` | implemented | +| Prize money = base x level x 4 (Misty 4700G etc.) | `src/battle/gen2/Prize.lua:12` (documents the `ld c, 4` loop), `Prize.reward` at `:82` | implemented, incl. Amulet Coin and Mom's savings split | +| CUT gate on HIVEBADGE + cut-tree collision | `src/world/gen2/FieldMoves.lua:440` `badgeGate` / `:452`; `src/world/gen2/Permissions.lua:130` `CUT_TREE`, `:152` `CheckCutCollision` set | implemented | +| SURF gate on FOGBADGE | `src/world/gen2/FieldMoves.lua:482` | implemented | +| FLY gate on STORMBADGE + flypoint table | `src/world/gen2/FieldMoves.lua:342` `FieldMoves.FLYPOINTS`, `:501`; `src/world/gen2/World.lua:4284` (`_FlyMap`) | implemented | +| `special BillsGrandfather` (party pick for the stone chain) | `src/script/gen2/Specials.lua:1267` | implemented | +| `special FadeOutMusic` / `FadeOutToBlack` / `FadeInFromBlack` / `ReloadSpritesNoPalettes` / `RestartMapMusic` (Route 24 grunt exit, gym grunt scene) | `src/script/gen2/Specials.lua:999`, `:1001`, `:1025`, `:1059`, `:1066` | implemented | +| `showemote`, `applymovement` (incl. `big_step`, `jump_step`, `fix_facing`, `set_sliding`, `slow_step`), `turnobject`, `disappear`/`appear` | `src/script/gen2/Vm.lua`, `src/script/gen2/Movement.lua` | implemented | +| Kanto Gen 2 wild tables (`kanto_grass`/`kanto_water`, Gold/Silver split on Route 9) | extracted by `src/import/RomExtractorGen2.lua`; consumed by `src/battle/gen2/Encounter.lua` | implemented - version split comes from the ROM, so it is correct by construction | +| End-to-end driver coverage for this stretch | `tests/drivers/gold_*.lua` (25 drivers: boot, walk, warp, trainer, battle, roamers, ice path, etc.) | missing - no Kanto / Cerulean / Power Plant driver exists | + +## 6. Unresolved / verify by hand + +- **Cut tree positions.** The walkthrough says "Cut the tree at the entrance" (Route 9) and + "Cut the tree above Pat" (Route 25). Cut trees are block data in `maps/Route9.blk` / + `maps/Route25.blk`, not events, so no coordinate for them appears anywhere in the asm text. + A bot needs the decoded block map (or the port's `Permissions.isCutTree` over the loaded + collision) to find them. The Protein ball at `32,4` sitting two cells above Pat at `31,7` + is consistent with the walkthrough's claim, but the tree cell itself is unverified here. +- **Kevin's battle is omitted from the walkthrough.** The text says only "that guy will give + you a Nugget". `TrainerCooltrainermKevin` gives the NUGGET and then immediately battles you + with L38 RHYHORN / L35 CHARMELEON / L35 WARTORTLE. Treat the walkthrough as incomplete here, + not the asm. +- **Wild lists are abridged in the walkthrough.** Route 24 also has VENONAT / SUNKERN / + ODDISH / VENOMOTH, and Route 25 also has PIDGEY / PIDGEOTTO / VENONAT / WEEPINBELL / + ODDISH / VENOMOTH, none of which the FAQ lists. Route 5's own table (PIDGEY / BELLSPROUT / + ABRA / ODDISH / GLOOM) is not listed at all. +- **"Surf down the river ... and you'll be at the Power Plant."** The Power Plant door is + `warp_event 3, 9` on `ROUTE_10_NORTH`, and the water table for both `ROUTE_9` and + `ROUTE_10_NORTH` exists, so surfing is clearly the route - but the exact water path across + the Route 9 / Route 10 North connection boundary is block data and was not traced. +- **EXP values.** Every per-mon EXP number in the walkthrough (e.g. "Level 42 Golduck + (1566 EXP)") depends on the player's own level and participation; nothing in + `data/trainers/parties.asm` carries them. Money values were all reproduced from + `data/trainers/attributes.asm` base reward x last-mon level x 4 and check out. +- **"the 11th gym badge".** The asm has no badge counter tied to this; `ENGINE_CASCADEBADGE` + is just one bit in `constants/engine_flags.asm:48`. The ordinal is walkthrough prose. +- **Route 5 "Day Care Center from Red, Blue and Yellow".** The building is + `ROUTE_5_CLEANSE_TAG_HOUSE` and its sign at `10,11` reads "House for Sale... Nobody lives + here." Nothing in Gen 2 calls it a Day Care; that identification is the FAQ's own history + note. Also note the sign and the door share the tile `10,11`, so an A-press there may hit + either depending on facing - worth verifying against the port's bg-event-vs-warp priority. +- **`POWERPLANT_GYM_GUIDE3` / `PowerPlantGymGuide4Script` naming mismatch** in + `maps/PowerPlant.asm` (there is no `PowerPlantGymGuide3Script`). Harmless, but a + label-driven bot should not assume the const and script numbers line up. diff --git a/docs/gold-walkthrough/asm-walk/section-22-rock-tunnel-and-celadon-city-gym.md b/docs/gold-walkthrough/asm-walk/section-22-rock-tunnel-and-celadon-city-gym.md new file mode 100644 index 00000000..3e1d035f --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-22-rock-tunnel-and-celadon-city-gym.md @@ -0,0 +1,1277 @@ +# Section 22 - Rock Tunnel and Celadon City Gym + +Source: `../section-22-rock-tunnel-and-celadon-city-gym.txt` +(the FAQ numbers this chapter "28 > Rock Tunnel and Celadon City Gym"; the file +index is 22) + +Maps covered: `MAP_POWER_PLANT`, `MAP_ROCK_TUNNEL_1F`, `MAP_ROCK_TUNNEL_B1F`, +`MAP_ROUTE_10_SOUTH`, `MAP_LAVENDER_TOWN`, `MAP_LAV_RADIO_TOWER_1F`, +`MAP_SOUL_HOUSE`, `MAP_ROUTE_8`, `MAP_ROUTE_8_SAFFRON_GATE`, +`MAP_SAFFRON_CITY`, `MAP_COPYCATS_HOUSE_1F`, `MAP_COPYCATS_HOUSE_2F`, +`MAP_VERMILION_CITY`, `MAP_POKEMON_FAN_CLUB`, `MAP_SAFFRON_MAGNET_TRAIN_STATION`, +`MAP_ROUTE_7_SAFFRON_GATE`, `MAP_ROUTE_7`, `MAP_CELADON_CITY`, +`MAP_CELADON_MANSION_1F`, `MAP_CELADON_MANSION_2F`, `MAP_CELADON_MANSION_3F`, +`MAP_CELADON_MANSION_ROOF`, `MAP_CELADON_MANSION_ROOF_HOUSE`, +`MAP_CELADON_DEPT_STORE_1F`..`6F`, `MAP_CELADON_GAME_CORNER`, +`MAP_CELADON_GAME_CORNER_PRIZE_ROOM`, `MAP_CELADON_CAFE`, `MAP_CELADON_GYM` + +Badges / key milestones in this section: + +- TM07 Zap Cannon from the Power Plant MANAGER (reward for `EVENT_RETURNED_MACHINE_PART`) +- EXPN CARD from the Lavender radio director (`ENGINE_EXPN_CARD`) +- LOST_ITEM -> PASS chain (Copycat / Pokemon Fan Club), unlocking the Magnet Train +- TM03 Curse (Celadon Mansion roof house, night only) +- Leftovers (Celadon Cafe trash can) +- **RAINBOWBADGE** + TM19 Giga Drain from ERIKA (`ENGINE_RAINBOWBADGE`) + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `ROUTE_9` / `ROUTE_10_NORTH` | `maps/Route10North.asm` | Route 9 south connection (`data/maps/attributes.asm` `connection south, Route10North, ROUTE_10_NORTH, 20`) | warp 2 at (3, 9) -> `POWER_PLANT` 1 | Surf east from Route 9 to the Power Plant. **Route 9 / Route 10 North belong to the previous section; only the hop is listed here.** | +| 1 | `POWER_PLANT` | `maps/PowerPlant.asm` | warp 1/2 at (2,17)/(3,17) from `ROUTE_10_NORTH` 2 | same warps back | Talk to `PowerPlantManager` (object at 14,10) for **TM07 Zap Cannon** | +| 2 | `ROUTE_10_NORTH` | `maps/Route10North.asm` | Power Plant warp | south connection to `ROUTE_10_SOUTH`, or `ROCK_TUNNEL_1F` via Route 9 warp 1 | Walk back to the Rock Tunnel mouth | +| 3 | `ROCK_TUNNEL_1F` | `maps/RockTunnel1F.asm` | warp 1 at (15,3) from `ROUTE_9` 1 | warp 2 at (11,25) -> `ROUTE_10_SOUTH` 1 | Flash through the tunnel; TM47 Steel Wing + Elixer here | +| 4 | `ROCK_TUNNEL_B1F` | `maps/RockTunnelB1F.asm` | 1F warps 3/4/5/6 | back up to 1F | Iron, PP Up, Revive, hidden Max Potion | +| 5 | `ROUTE_10_SOUTH` | `maps/Route10South.asm` | warp 1 at (6,1) from `ROCK_TUNNEL_1F` 2 | south connection to `LAVENDER_TOWN` | Hiker Jim + Pokefanm Robert | +| 6 | `LAVENDER_TOWN` | `maps/LavenderTown.asm` | north connection from `ROUTE_10_SOUTH` | warp 7 at (14,5) -> `LAV_RADIO_TOWER_1F` 1; west connection to `ROUTE_8` | Sets `ENGINE_FLYPOINT_LAVENDER`; radio tower / Soul House / Name Rater | +| 7 | `LAV_RADIO_TOWER_1F` | `maps/LavRadioTower1F.asm` | Lavender warp 7 | warps 1/2 at (2,7)/(3,7) back | Director (`LAVRADIOTOWER1F_GENTLEMAN`) gives the **EXPN CARD** | +| 8 | `ROUTE_8` | `maps/Route8.asm` | east connection from `LAVENDER_TOWN` | warps 1/2 at (4,4)/(4,5) -> `ROUTE_8_SAFFRON_GATE` 3/4 | Five trainers (2 Super Nerds, 3 Bikers) + PRZCUREBERRY tree | +| 9 | `SAFFRON_CITY` | `maps/SaffronCity.asm` | `ROUTE_8_SAFFRON_GATE` warps 1/2 | warp 8 at (9,11) -> `COPYCATS_HOUSE_1F` 1 | Heal, then visit the Copycat | +| 10 | `COPYCATS_HOUSE_1F` -> `2F` | `maps/CopycatsHouse1F.asm`, `maps/CopycatsHouse2F.asm` | 1F warp 3 at (2,0) -> `COPYCATS_HOUSE_2F` 1 | 2F warp 1 at (3,0) back | First `Copycat` talk sets `EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM` | +| 11 | `VERMILION_CITY` | `maps/VermilionCity.asm` | Fly | warp 3 at (7,13) -> `POKEMON_FAN_CLUB` 1 | The CLEFAIRY-doll guy hands over `LOST_ITEM` | +| 12 | `POKEMON_FAN_CLUB` | `maps/PokemonFanClub.asm` | Vermilion warp 3 | warps 1/2 at (2,7)/(3,7) back | `PokemonFanClubClefairyGuyScript` -> `giveitem LOST_ITEM` | +| 13 | `SAFFRON_CITY` -> `COPYCATS_HOUSE_2F` | as above | Fly + warps | - | Return `LOST_ITEM`, receive `PASS` | +| 14 | `ROUTE_7_SAFFRON_GATE` | `maps/Route7SaffronGate.asm` | `SAFFRON_CITY` warps 10/11 at (0,24)/(0,25) | warps 1/2 at (0,4)/(0,5) -> `ROUTE_7` 1/2 | The "Pokedex as ID" guard | +| 15 | `ROUTE_7` | `maps/Route7.asm` | gate warps 1/2 at (15,6)/(15,7) | west connection to `CELADON_CITY` | No trainers; sealed Underground Path signs | +| 16 | `CELADON_CITY` | `maps/CeladonCity.asm` | east connection from `ROUTE_7` (`connection west, CeladonCity, CELADON_CITY, -5`) | nine warps, see below | Sets `ENGINE_FLYPOINT_CELADON` | +| 17 | `CELADON_MANSION_1F/2F/3F/ROOF/ROOF_HOUSE` | `maps/CeladonMansion*.asm` | city warps 2 (16,9) and 3/4 (16,3)/(17,3) | roof house warps | **TM03 Curse** (night), Game Freak dev room | +| 18 | `CELADON_DEPT_STORE_1F`..`6F` | `maps/CeladonDeptStore*.asm` | city warp 1 at (4,9) | store warps / elevator | Mart inventories | +| 19 | `CELADON_GAME_CORNER` / `..._PRIZE_ROOM` | `maps/CeladonGameCorner.asm`, `maps/CeladonGameCornerPrizeRoom.asm` | city warps 6 (18,19) and 7 (23,19) | back to city | Slots, then TM/mon prize counters | +| 20 | `CELADON_CAFE` | `maps/CeladonCafe.asm` | city warp 9 at (25,29) | warps 1/2 at (6,7)/(7,7) back | **Leftovers** from `bg_event 7, 1` trash can | +| 21 | `CELADON_GYM` | `maps/CeladonGym.asm` | city warp 8 at (10,29) | warps 1/2 at (4,17)/(5,17) back | Four trainers then **ERIKA** | + +Spillover: the walkthrough's next beat (leaving Celadon westward toward Cycling +Road / Fuchsia) belongs to the following section and is not covered here. + +--- + +## 2. Maps + +### MAP_POWER_PLANT + +- Script: `maps/PowerPlant.asm` +- Blocks: `maps/PowerPlant.blk` +- Header: `data/maps/maps.asm:214` -> `TILESET_FACILITY, INDOOR, LANDMARK_POWER_PLANT, MUSIC_VIRIDIAN_CITY, FALSE (no phone), PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:196` `map_const POWER_PLANT, 10, 9` (group `CERULEAN`, id 10) +- Attributes / border: `data/maps/attributes.asm:523` `map_attributes PowerPlant, POWER_PLANT, $00`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 17 | `ROUTE_10_NORTH` | 2 | +| 2 | 3 | 17 | `ROUTE_10_NORTH` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL` | 5 | 12 | `PowerPlantGuardPhoneScript` | Officer 1 gets the Cerulean call cutscene, then `setscene SCENE_POWERPLANT_NOOP` | + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `PowerPlantBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `PowerPlantBookshelf` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `POWERPLANT_OFFICER1` | `SPRITE_OFFICER` | 4 | 14 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PowerPlantOfficerScript` | -1 | +| `POWERPLANT_GYM_GUIDE1` | `SPRITE_GYM_GUIDE` | 2 | 9 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide1Script` | -1 | +| `POWERPLANT_GYM_GUIDE2` | `SPRITE_GYM_GUIDE` | 6 | 11 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide2Script` | -1 | +| `POWERPLANT_OFFICER2` | `SPRITE_OFFICER` | 9 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `PowerPlantOfficer2Script` | -1 | +| `POWERPLANT_GYM_GUIDE3` | `SPRITE_GYM_GUIDE` | 7 | 2 | `WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | `PowerPlantGymGuide4Script` | -1 | +| `POWERPLANT_MANAGER` | `SPRITE_FISHER` | 14 | 10 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `PowerPlantManager` | -1 | + +**Scripts of interest** + +- `PowerPlantManager` (`54:4dbd`). Branch order is: `checkevent EVENT_RETURNED_MACHINE_PART` -> `.ReturnedMachinePart`; else `checkitem MACHINE_PART` -> `.FoundMachinePart`; else `checkevent EVENT_MET_MANAGER_AT_POWER_PLANT` -> `.MetManager`; else the first-meeting text which does `setevent EVENT_MET_MANAGER_AT_POWER_PLANT`, `clearevent EVENT_CERULEAN_GYM_ROCKET`, `clearevent EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM`, `setmapscene CERULEAN_GYM, SCENE_CERULEANGYM_GRUNT_RUNS_OUT`, `setscene SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL`. + `.FoundMachinePart` does `takeitem MACHINE_PART`, `setevent EVENT_RETURNED_MACHINE_PART`, `clearevent EVENT_SAFFRON_TRAIN_STATION_POPULATION`, `setevent EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH`, `setevent EVENT_ROUTE_24_ROCKET`, `setevent EVENT_RESTORED_POWER_TO_KANTO`, `clearevent EVENT_GOLDENROD_TRAIN_STATION_GENTLEMAN`, then falls through to `.ReturnedMachinePart`. + `.ReturnedMachinePart` gates on `EVENT_GOT_TM07_ZAP_CANNON`; otherwise `verbosegiveitem TM_ZAP_CANNON` and `setevent EVENT_GOT_TM07_ZAP_CANNON`. + + **Bot note:** the section as written assumes `EVENT_RETURNED_MACHINE_PART` is + already set from the previous section. If it is, the TM is one `A` press on + the manager. If the part is still in the bag, the same talk both takes the + part and awards the TM in one conversation. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RETURNED_MACHINE_PART` | `constants/event_flags.asm:200` | set by `PowerPlantManager.FoundMachinePart`; read by every other NPC on this map, `LavRadioTower1FGentlemanScript`, `Copycat`, `CopycatsHouse1FPokefanFScript`, `PokemonFanClubClefairyGuyScript`, `Route7SaffronGuardScript`, `SaffronMagnetTrainStationGymGuideScript` | the master gate for this whole chapter | +| `EVENT_MET_MANAGER_AT_POWER_PLANT` | `constants/event_flags.asm:201` | set/read by `PowerPlantManager` | first-meeting bookkeeping | +| `EVENT_RESTORED_POWER_TO_KANTO` | `constants/event_flags.asm:204` | set by `PowerPlantManager.FoundMachinePart`; read by `SaffronMagnetTrainStationOfficerScript` | Magnet Train power gate | +| `EVENT_GOT_TM07_ZAP_CANNON` | `constants/event_flags.asm:222` | `PowerPlantManager` | one-time TM | +| `SCENE_POWERPLANT_NOOP` / `SCENE_POWERPLANT_GUARD_GETS_PHONE_CALL` | scene ids in `PowerPlant_MapScripts` `def_scene_scripts` | `setscene` in `PowerPlantManager`, coord event at (5,12) | scene 1 makes the (5,12) trip-wire live | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_ZAP_CANNON` (TM07) | talk to `POWERPLANT_MANAGER` at (14,10) | `PowerPlantManager` -> `verbosegiveitem TM_ZAP_CANNON` | `EVENT_GOT_TM07_ZAP_CANNON` | + +**Trainers** - none. + +**Wild encounters** - none (INDOOR, no `def_grass_wildmons` entry). + +--- + +### MAP_ROCK_TUNNEL_1F + +- Script: `maps/RockTunnel1F.asm` +- Blocks: `maps/RockTunnel1F.blk` +- Header: `data/maps/maps.asm:156` -> `TILESET_DARK_CAVE, CAVE, LANDMARK_ROCK_TUNNEL, MUSIC_MT_MOON, TRUE (phone/"can be called from"), PALETTE_DARK, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:143` `map_const ROCK_TUNNEL_1F, 15, 18` (group `DUNGEONS`, id 78) -> 30 x 36 cells +- Attributes: `data/maps/attributes.asm:490` `map_attributes RockTunnel1F, ROCK_TUNNEL_1F, $09`; no connections +- `PALETTE_DARK` is what makes `FlashFunction` legal here (see section 3) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 3 | `ROUTE_9` | 1 | +| 2 | 11 | 25 | `ROUTE_10_SOUTH` | 1 | +| 3 | 5 | 3 | `ROCK_TUNNEL_B1F` | 3 | +| 4 | 15 | 9 | `ROCK_TUNNEL_B1F` | 2 | +| 5 | 27 | 3 | `ROCK_TUNNEL_B1F` | 4 | +| 6 | 27 | 13 | `ROCK_TUNNEL_B1F` | 1 | + +**Coord events** - none (`def_coord_events` empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 24 | 4 | `BGEVENT_ITEM` | `RockTunnel1FHiddenXAccuracy` -> `hiddenitem X_ACCURACY, EVENT_ROCK_TUNNEL_1F_HIDDEN_X_ACCURACY` | +| 21 | 15 | `BGEVENT_ITEM` | `RockTunnel1FHiddenXDefend` -> `hiddenitem X_DEFEND, EVENT_ROCK_TUNNEL_1F_HIDDEN_X_DEFEND` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROCKTUNNEL1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 4 | 18 | `STILL` | `OBJECTTYPE_ITEMBALL` | `RockTunnel1FElixer` (`itemball ELIXER`) | `EVENT_ROCK_TUNNEL_1F_ELIXER` | +| `ROCKTUNNEL1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 10 | 15 | `STILL` | `OBJECTTYPE_ITEMBALL` | `RockTunnel1FTMSteelWing` (`itemball TM_STEEL_WING`, `42:58df`) | `EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING` | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `ELIXER` | ball at (4,18) | `RockTunnel1FElixer` | `EVENT_ROCK_TUNNEL_1F_ELIXER` | +| `TM_STEEL_WING` (TM47) | ball at (10,15) | `RockTunnel1FTMSteelWing` | `EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING` | +| `X_ACCURACY` | hidden, face (24,4) | `bg_event` `BGEVENT_ITEM` | `EVENT_ROCK_TUNNEL_1F_HIDDEN_X_ACCURACY` | +| `X_DEFEND` | hidden, face (21,15) | `bg_event` `BGEVENT_ITEM` | `EVENT_ROCK_TUNNEL_1F_HIDDEN_X_DEFEND` | + +**Trainers** - none. + +**Wild encounters** - `data/wild/kanto_grass.asm`, `def_grass_wildmons ROCK_TUNNEL_1F`, +rates `6 percent, 6 percent, 6 percent` (morn/day/nite). All three time slots are +identical: + +``` +db 10, CUBONE +db 10, GEODUDE +db 12, MACHOP +db 8, ZUBAT +db 14, MACHOKE +db 13, CUBONE +db 13, CUBONE +``` + +--- + +### MAP_ROCK_TUNNEL_B1F + +- Script: `maps/RockTunnelB1F.asm` +- Blocks: `maps/RockTunnelB1F.blk` +- Header: `data/maps/maps.asm:157` -> `TILESET_DARK_CAVE, CAVE, LANDMARK_ROCK_TUNNEL, MUSIC_MT_MOON, TRUE, PALETTE_DARK, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:144` `map_const ROCK_TUNNEL_B1F, 15, 18` +- Attributes: `data/maps/attributes.asm:491` `map_attributes RockTunnelB1F, ROCK_TUNNEL_B1F, $09`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `ROCK_TUNNEL_1F` | 6 | +| 2 | 17 | 9 | `ROCK_TUNNEL_1F` | 4 | +| 3 | 23 | 3 | `ROCK_TUNNEL_1F` | 3 | +| 4 | 25 | 23 | `ROCK_TUNNEL_1F` | 5 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 14 | `BGEVENT_ITEM` | `RockTunnelB1FHiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_ROCK_TUNNEL_B1F_HIDDEN_MAX_POTION` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROCKTUNNELB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 25 | `STILL` | `OBJECTTYPE_ITEMBALL` | `RockTunnelB1FIron` (`42:5931`) | `EVENT_ROCK_TUNNEL_B1F_IRON` | +| `ROCKTUNNELB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 6 | 17 | `STILL` | `OBJECTTYPE_ITEMBALL` | `RockTunnelB1FPPUp` (`42:5933`) | `EVENT_ROCK_TUNNEL_B1F_PP_UP` | +| `ROCKTUNNELB1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 15 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `RockTunnelB1FRevive` (`42:5935`) | `EVENT_ROCK_TUNNEL_B1F_REVIVE` | + +**Wild encounters** - `data/wild/kanto_grass.asm`, `def_grass_wildmons ROCK_TUNNEL_B1F`, +rates `6 percent` in all three slots, and all three slots identical: + +``` +db 12, CUBONE +db 12, GEODUDE +db 16, ONIX +db 10, ZUBAT +db 14, MAROWAK +db 14, KANGASKHAN +db 14, KANGASKHAN +``` + +(Kangaskhan is a B1F-only slot 6/7 encounter - the walkthrough lists it under +"Rock Tunnel" generally.) + +--- + +### MAP_ROUTE_10_SOUTH + +- Script: `maps/Route10South.asm` +- Blocks: `maps/Route10South.blk` +- Header: `data/maps/maps.asm:380` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_10, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:351` `map_const ROUTE_10_SOUTH, 10, 9` (group `LAVENDER`, id 3) +- Connections (`data/maps/attributes.asm`, `map_attributes Route10South, ROUTE_10_SOUTH, $2c`): + `connection north, Route10North, ROUTE_10_NORTH, 0`, `connection south, LavenderTown, LAVENDER_TOWN, 0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 1 | `ROCK_TUNNEL_1F` | 2 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 3 | `BGEVENT_READ` | `Route10Sign` (`jumptext Route10SignText`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE10SOUTH_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 17 | 3 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerHikerJim` (`50:574b`) | -1 | +| `ROUTE10SOUTH_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 4 | 10 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerPokefanmRobert` (`50:575f`) | -1 | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `HIKER`, `JIM` | Hiker (`constants/trainer_constants.asm:453`) | JIM | `"JIM@", TRAINERTYPE_NORMAL` / `db 35, MACHAMP` (line 110) | `TrainerHikerJim`, flag `EVENT_BEAT_HIKER_JIM` (`constants/event_flags.asm:834`) | no | +| `POKEFANM`, `ROBERT` | Pokefanm (`:601`) | ROBERT | `"ROBERT@", TRAINERTYPE_ITEM` / `db 33, QUAGSIRE, BERRY` (line 2971) | `TrainerPokefanmRobert`, flag `EVENT_BEAT_POKEFANM_ROBERT` (`:726`) | no | + +Class base rewards: Hiker `db 8`, Pokefanm `db 20` (`data/trainers/attributes.asm`). + +**Wild encounters** - **none**. There is no `def_grass_wildmons ROUTE_10_SOUTH` +entry in `data/wild/kanto_grass.asm` (the grass patch is on `ROUTE_10_NORTH`). + +--- + +### MAP_LAVENDER_TOWN + +- Script: `maps/LavenderTown.asm` +- Blocks: `maps/LavenderTown.blk` +- Header: `data/maps/maps.asm:381` -> `TILESET_KANTO, TOWN, LANDMARK_LAVENDER_TOWN, MUSIC_LAVENDER_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:352` `map_const LAVENDER_TOWN, 10, 9` +- Connections (`map_attributes LavenderTown, LAVENDER_TOWN, $2c`): + `connection north, Route10South, ROUTE_10_SOUTH, 0`, + `connection south, Route12, ROUTE_12, 0`, + `connection west, Route8, ROUTE_8, 0` + +**Callbacks**: `callback MAPCALLBACK_NEWMAP, LavenderTownFlypointCallback` -> +`setflag ENGINE_FLYPOINT_LAVENDER` (`constants/engine_flags.asm:73`). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `LAVENDER_POKECENTER_1F` | 1 | +| 2 | 5 | 9 | `MR_FUJIS_HOUSE` | 1 | +| 3 | 3 | 13 | `LAVENDER_SPEECH_HOUSE` | 1 | +| 4 | 7 | 13 | `LAVENDER_NAME_RATER` | 1 | +| 5 | 1 | 5 | `LAVENDER_MART` | 2 | +| 6 | 13 | 11 | `SOUL_HOUSE` | 1 | +| 7 | 14 | 5 | `LAV_RADIO_TOWER_1F` | 1 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 3 | `BGEVENT_READ` | `LavenderTownSign` | +| 15 | 7 | `BGEVENT_READ` | `KantoRadioStationSign` | +| 3 | 9 | `BGEVENT_READ` | `VolunteerPokemonHouseSign` | +| 15 | 13 | `BGEVENT_READ` | `SoulHouseSign` | +| 6 | 5 | `BGEVENT_READ` | `LavenderPokecenterSignText` (`jumpstd PokecenterSignScript`) | +| 2 | 5 | `BGEVENT_READ` | `LavenderMartSignText` (`jumpstd MartSignScript`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LAVENDERTOWN_POKEFAN_M` | `SPRITE_POKEFAN_M` | 12 | 7 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `LavenderTownPokefanMScript` | -1 | +| `LAVENDERTOWN_TEACHER` | `SPRITE_TEACHER` | 2 | 15 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `LavenderTownTeacherScript` | -1 | +| `LAVENDERTOWN_GRAMPS` | `SPRITE_GRAMPS` | 14 | 12 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `LavenderTownGrampsScript` | -1 | +| `LAVENDERTOWN_YOUNGSTER` | `SPRITE_YOUNGSTER` | 6 | 11 | `WALK_LEFT_RIGHT` (1,0) | `OBJECTTYPE_SCRIPT` | `LavenderTownYoungsterScript` | -1 | + +**Notes for the walkthrough's Lavender claims** + +- Mr. Fuji is `MrFuji`, `object_event 4, 2, SPRITE_GRAMPS` in `maps/SoulHouse.asm:85` + (warp 6 of Lavender Town), so the walkthrough is right that he is in the Soul + House. `MR_FUJIS_HOUSE` (warp 2, `maps/MrFujisHouse.asm`) is the *Volunteer + Pokemon House* and contains no Fuji object. +- Name Rater is warp 4 -> `LAVENDER_NAME_RATER` (`maps/LavenderNameRater.asm`). + +**Wild encounters** - none in town. + +--- + +### MAP_LAV_RADIO_TOWER_1F + +- Script: `maps/LavRadioTower1F.asm` +- Header: `data/maps/maps.asm:389` -> `TILESET_RADIO_TOWER, INDOOR, LANDMARK_LAV_RADIO_TOWER, MUSIC_LAVENDER_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:360` `map_const LAV_RADIO_TOWER_1F, 10, 4` +- Attributes: `data/maps/attributes.asm:620` `map_attributes LavRadioTower1F, LAV_RADIO_TOWER_1F, $00` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `LAVENDER_TOWN` | 7 | +| 2 | 3 | 7 | `LAVENDER_TOWN` | 7 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 0 | `BGEVENT_READ` | `LavRadioTower1FDirectory` | +| 5 | 0 | `BGEVENT_READ` | `LavRadioTower1FPokeFluteSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `LAVRADIOTOWER1F_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 6 | 6 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `LavRadioTower1FReceptionistScript` | -1 | +| `LAVRADIOTOWER1F_OFFICER` | `SPRITE_OFFICER` | 15 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `LavRadioTower1FOfficerScript` | -1 | +| `LAVRADIOTOWER1F_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 1 | 3 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `LavRadioTower1FSuperNerd1Script` | -1 | +| `LAVRADIOTOWER1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 9 | 1 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `LavRadioTower1FGentlemanScript` (`5d:479f`) | -1 | +| `LAVRADIOTOWER1F_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 14 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `LavRadioTower1FSuperNerd2Script` | -1 | + +**Scripts of interest** + +- `LavRadioTower1FGentlemanScript`: `checkflag ENGINE_EXPN_CARD` -> already got + it; else `checkevent EVENT_RETURNED_MACHINE_PART` -> `.ReturnedMachinePart`, + which prints the thank-you, `getstring STRING_BUFFER_4, .expncardname` + (`db "EXPN CARD@"`), `scall .receiveitem` (`jumpstd ReceiveItemScript`) and + `setflag ENGINE_EXPN_CARD`. Note this is a **flag**, not a bag item - nothing + enters the inventory. +- The upstairs floors are blocked by dialogue only (`LavRadioTower1FOfficerScript` + is flavour text); the map has no warp to 2F. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_EXPN_CARD` | `constants/engine_flags.asm:7` | set by `LavRadioTower1FGentlemanScript`; read by the Pokegear radio | Kanto radio channels unlock | +| `EVENT_RETURNED_MACHINE_PART` | `constants/event_flags.asm:200` | read here | precondition for the gift | + +--- + +### MAP_ROUTE_8 + +- Script: `maps/Route8.asm` +- Blocks: `maps/Route8.blk` +- Header: `data/maps/maps.asm:378` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_8, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:349` `map_const ROUTE_8, 20, 9` +- Connections (`map_attributes Route8, ROUTE_8, $2c`): + `connection west, SaffronCity, SAFFRON_CITY, -9`, `connection east, LavenderTown, LAVENDER_TOWN, 0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 4 | `ROUTE_8_SAFFRON_GATE` | 3 | +| 2 | 4 | 5 | `ROUTE_8_SAFFRON_GATE` | 4 | + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 7 | `BGEVENT_READ` | `Route8UndergroundPathSign` | +| 10 | 5 | `BGEVENT_READ` | `Route8LockedDoor` ("It's locked...") | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE8_BIKER1` | `SPRITE_BIKER` | 10 | 8 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 5 | `TrainerBikerDwayne` | -1 | +| `ROUTE8_BIKER2` | `SPRITE_BIKER` | 10 | 9 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 5 | `TrainerBikerHarris` | -1 | +| `ROUTE8_BIKER3` | `SPRITE_BIKER` | 10 | 10 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 5 | `TrainerBikerZeke` | -1 | +| `ROUTE8_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 20 | 6 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerSupernerdSam` | -1 | +| `ROUTE8_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 27 | 9 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerSupernerdTom` | -1 | +| `ROUTE8_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 33 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route8FruitTree` (`fruittree FRUITTREE_ROUTE_8`, `50:53ed`) | -1 | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `PRZCUREBERRY` | face the tree object at (33,5) and press A | `fruittree FRUITTREE_ROUTE_8` (`constants/script_constants.asm:233`), item from `data/items/fruit_trees.asm:30` `db PRZCUREBERRY ; ROUTE_8` | daily fruit-tree flag (`TryResetFruitTrees`), **not** a one-shot event flag | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `SUPER_NERD`, `TOM` | Super Nerd (`:417`) | TOM | `db 32, MAGNEMITE` x3 (parties.asm:2002) | `TrainerSupernerdTom`, `EVENT_BEAT_SUPER_NERD_TOM` (`:925`) | no | +| `SUPER_NERD`, `SAM` | Super Nerd (`:416`) | SAM | `db 34, GRIMER` / `db 34, MUK` (parties.asm:1996) | `TrainerSupernerdSam`, `EVENT_BEAT_SUPER_NERD_SAM` (`:924`) | no | +| `BIKER`, `ZEKE` | Biker (`:463`) | ZEKE | `db 32, KOFFING` x2 (parties.asm:2272) | `TrainerBikerZeke`, `EVENT_BEAT_BIKER_ZEKE` (`:557`) | no | +| `BIKER`, `HARRIS` | Biker (`:462`) | HARRIS | `db 34, FLAREON` (parties.asm:2267) | `TrainerBikerHarris`, `EVENT_BEAT_BIKER_HARRIS` (`:556`) | no | +| `BIKER`, `DWAYNE` | Biker (`:461`) | DWAYNE | `db 27/28/29/30, KOFFING` (parties.asm:2259) | `TrainerBikerDwayne`, `EVENT_BEAT_BIKER_DWAYNE` (`:555`) | no | + +Class base rewards: Super Nerd `db 8`, Biker `db 8` (`data/trainers/attributes.asm`). + +**Wild encounters** - `data/wild/kanto_grass.asm`, `def_grass_wildmons ROUTE_8`, +rates `10 percent` in all slots. Gold branch (`IF DEF(_GOLD)`): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 17 PIDGEOTTO | 17 PIDGEOTTO | 17 NOCTOWL | +| 2 | 19 PIDGEOTTO | 19 PIDGEOTTO | 20 HAUNTER | +| 3 | 15 ABRA | 15 ABRA | 15 ABRA | +| 4 | 18 GROWLITHE | 18 GROWLITHE | 19 NOCTOWL | +| 5 | 17 PIDGEOTTO | 17 PIDGEOTTO | 18 GROWLITHE | +| 6 | 15 KADABRA | 15 KADABRA | 15 KADABRA | +| 7 | 15 KADABRA | 15 KADABRA | 15 KADABRA | + +--- + +### MAP_SAFFRON_CITY (transit + Copycat) + +- Script: `maps/SaffronCity.asm` +- Header: `data/maps/maps.asm:490` -> `TILESET_KANTO, TOWN, LANDMARK_SAFFRON_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:454` `map_const SAFFRON_CITY, 20, 18` +- Connections: `connection north, Route5`, `connection south, Route6`, + `connection west, Route7, ROUTE_7, 9`, `connection east, Route8, ROUTE_8, 9` + +**Warps used by this section** (full table in `maps/SaffronCity.asm:269`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 6 | 8 | 3 | `SAFFRON_MAGNET_TRAIN_STATION` | 2 | +| 8 | 9 | 11 | `COPYCATS_HOUSE_1F` | 1 | +| 10 | 0 | 24 | `ROUTE_7_SAFFRON_GATE` | 3 | +| 11 | 0 | 25 | `ROUTE_7_SAFFRON_GATE` | 4 | +| 14 | 39 | 22 | `ROUTE_8_SAFFRON_GATE` | 1 | +| 15 | 39 | 23 | `ROUTE_8_SAFFRON_GATE` | 2 | + +**Magnet train gate** (`maps/SaffronMagnetTrainStation.asm`, +`SaffronMagnetTrainStationOfficerScript`): `checkevent EVENT_RESTORED_POWER_TO_KANTO` +-> if clear, "train isn't operating"; if set, yes/no then `checkitem PASS` -> +if absent, "you don't have a PASS"; if present, movement + `setval TRUE` + +`special MagnetTrain` + `newloadmap MAPSETUP_TRAIN`. + +--- + +### MAP_COPYCATS_HOUSE_1F + +- Script: `maps/CopycatsHouse1F.asm` +- Header: `data/maps/maps.asm:499` -> `TILESET_PLAYERS_HOUSE, INDOOR, LANDMARK_SAFFRON_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:463` `map_const COPYCATS_HOUSE_1F, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `SAFFRON_CITY` | 8 | +| 2 | 3 | 7 | `SAFFRON_CITY` | 8 | +| 3 | 2 | 0 | `COPYCATS_HOUSE_2F` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `COPYCATSHOUSE1F_POKEFAN_M` | `SPRITE_POKEFAN_M` | 2 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse1FPokefanMScript` | -1 | +| `COPYCATSHOUSE1F_POKEFAN_F` | `SPRITE_POKEFAN_F` | 5 | 4 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse1FPokefanFScript` (branches on `EVENT_RETURNED_MACHINE_PART`) | -1 | +| `COPYCATSHOUSE1F_CLEFAIRY` | `SPRITE_CLEFAIRY` | 6 | 6 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse1FBlisseyScript` | -1 | + +--- + +### MAP_COPYCATS_HOUSE_2F + +- Script: `maps/CopycatsHouse2F.asm` +- Header: `data/maps/maps.asm:500` -> `TILESET_PLAYERS_HOUSE, INDOOR, LANDMARK_SAFFRON_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:464` `map_const COPYCATS_HOUSE_2F, 5, 3` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 0 | `COPYCATS_HOUSE_1F` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `CopycatsHouse2FBookshelf` (`jumpstd PictureBookshelfScript`) | +| 1 | 1 | `BGEVENT_READ` | `CopycatsHouse2FBookshelf` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `COPYCATSHOUSE2F_COPYCAT` | `SPRITE_COPYCAT` | 4 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Copycat` (`61:5235`) | -1 | +| `COPYCATSHOUSE2F_DODRIO` | `SPRITE_MOLTRES` | 6 | 4 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `CopycatsDodrio` | -1 | +| `COPYCATSHOUSE2F_FAIRYDOLL` | `SPRITE_FAIRY` | 6 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse2FDoll` | `EVENT_COPYCATS_HOUSE_2F_DOLL` | +| `COPYCATSHOUSE2F_MONSTERDOLL` | `SPRITE_MONSTER` | 2 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse2FDoll` | -1 | +| `COPYCATSHOUSE2F_BIRDDOLL` | `SPRITE_BIRD` | 7 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CopycatsHouse2FDoll` | -1 | + +**Scripts of interest** - `Copycat`, in check order: + +1. `checkevent EVENT_GOT_PASS_FROM_COPYCAT` -> `.GotPass` (post-quest chatter) +2. `checkevent EVENT_RETURNED_LOST_ITEM_TO_COPYCAT` -> `.TryGivePassAgain` + (the bag-was-full retry path) +3. `checkitem LOST_ITEM` -> `.ReturnLostItem`: `takeitem LOST_ITEM`, + `setevent EVENT_RETURNED_LOST_ITEM_TO_COPYCAT`, + `clearevent EVENT_COPYCATS_HOUSE_2F_DOLL` (the doll object reappears on her + shelf), then falls into `.GivePass` +4. `.GivePass`: `verbosegiveitem PASS`, `iffalse .Cancel`, + `setevent EVENT_GOT_PASS_FROM_COPYCAT` +5. Otherwise: spin-around movement, `variablesprite SPRITE_COPYCAT, SPRITE_CHRIS` + + `special LoadUsedSpritesGFX` (the mimicry gag), then + `checkevent EVENT_RETURNED_MACHINE_PART` -> `.TalkAboutLostItem`, which sets + **`EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM`** + +Step 5 is mandatory before the Fan Club will hand over the doll. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM` | `constants/event_flags.asm:206` | set by `Copycat.TalkAboutLostItem`; read by `PokemonFanClubClefairyGuyScript.MetCopycat` | unlocks the doll in Vermilion | +| `EVENT_RETURNED_LOST_ITEM_TO_COPYCAT` | `:207` | `Copycat` | doll handed back | +| `EVENT_GOT_PASS_FROM_COPYCAT` | `:208` | `Copycat` | PASS awarded | +| `EVENT_COPYCATS_HOUSE_2F_DOLL` | `:1301` | cleared by `Copycat.ReturnLostItem` | object visibility flag for the FAIRY doll at (6,1) | + +--- + +### MAP_POKEMON_FAN_CLUB + +- Script: `maps/PokemonFanClub.asm` +- Header: `data/maps/maps.asm:300` -> `TILESET_HOUSE, INDOOR, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:277` `map_const POKEMON_FAN_CLUB, 5, 4` (group `VERMILION`, id 7) +- Attributes: `data/maps/attributes.asm:569` `map_attributes PokemonFanClub, POKEMON_FAN_CLUB, $00` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `VERMILION_CITY` | 3 | +| 2 | 3 | 7 | `VERMILION_CITY` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 0 | `BGEVENT_READ` | `PokemonFanClubListenSign` | +| 9 | 0 | `BGEVENT_READ` | `PokemonFanClubBraggingSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `POKEMONFANCLUB_CHAIRMAN` | `SPRITE_GENTLEMAN` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubChairmanScript` (RARE_CANDY) | -1 | +| `POKEMONFANCLUB_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 4 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubReceptionistScript` | -1 | +| `POKEMONFANCLUB_CLEFAIRY_GUY` | `SPRITE_FISHER` | 2 | 3 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubClefairyGuyScript` (`59:437b`) | -1 | +| `POKEMONFANCLUB_TEACHER` | `SPRITE_TEACHER` | 7 | 2 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubTeacherScript` | -1 | +| `POKEMONFANCLUB_FAIRY` | `SPRITE_FAIRY` | 2 | 4 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubClefairyDollScript` | `EVENT_VERMILION_FAN_CLUB_DOLL` | +| `POKEMONFANCLUB_ODDISH` | `SPRITE_ODDISH` | 7 | 3 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `PokemonFanClubBayleefScript` | -1 | + +**Scripts of interest** - `PokemonFanClubClefairyGuyScript` (`59:437b`): +`checkevent EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB` -> done; else +`checkevent EVENT_RETURNED_MACHINE_PART` -> `.FoundClefairyDoll`, which then +`checkevent EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM` -> `.MetCopycat` +(`59:439b`): `giveitem LOST_ITEM`, `iffalse .NoRoom`, +`disappear POKEMONFANCLUB_FAIRY`, `itemnotify`, +`setevent EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB`. + +Note this uses `giveitem`, not `verbosegiveitem`; the "received # DOLL" line is +written by the script itself (`PokemonFanClubPlayerReceivedDollText`). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `LOST_ITEM` | talk to `POKEMONFANCLUB_CLEFAIRY_GUY` at (2,3) | `PokemonFanClubClefairyGuyScript.MetCopycat` | `EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB` (`constants/event_flags.asm:209`) | +| `RARE_CANDY` | listen to the CHAIRMAN at (3,1), answer YES | `PokemonFanClubChairmanScript` | `EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT` (`:211`) | + +--- + +### MAP_ROUTE_7_SAFFRON_GATE + +- Script: `maps/Route7SaffronGate.asm` +- Header: `data/maps/maps.asm:436` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_7, MUSIC_ROUTE_3, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:404` `map_const ROUTE_7_SAFFRON_GATE, 5, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ROUTE_7` | 1 | +| 2 | 0 | 5 | `ROUTE_7` | 2 | +| 3 | 9 | 4 | `SAFFRON_CITY` | 10 | +| 4 | 9 | 5 | `SAFFRON_CITY` | 11 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE7SAFFRONGATE_OFFICER` | `SPRITE_OFFICER` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route7SaffronGuardScript` | -1 | + +**Scripts of interest** - `Route7SaffronGuardScript` is `faceplayer` + a +`checkevent EVENT_RETURNED_MACHINE_PART` two-way text switch. The "checks your +Pokedex as ID" line is `Route7SaffronGuardSeriousText`, flavour only. **There is +no coord event and no blocking movement here** - the guard does not gate the +gate. + +--- + +### MAP_ROUTE_7 + +- Script: `maps/Route7.asm` +- Blocks: `maps/Route7.blk` +- Header: `data/maps/maps.asm:412` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_7, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:380` `map_const ROUTE_7, 10, 9` (group `CELADON`, id 1) +- Connections (`map_attributes Route7, ROUTE_7, $0f`): + `connection west, CeladonCity, CELADON_CITY, -5`, `connection east, SaffronCity, SAFFRON_CITY, -9` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 6 | `ROUTE_7_SAFFRON_GATE` | 1 | +| 2 | 15 | 7 | `ROUTE_7_SAFFRON_GATE` | 2 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 11 | `BGEVENT_READ` | `Route7UndergroundPathSign` (the "sealed indefinitely" flyer) | +| 6 | 9 | `BGEVENT_READ` | `Route7LockedDoor` | + +**Object events** - `def_object_events` is empty. The walkthrough is correct +that Route 7 has no trainers. + +**Wild encounters** - `data/wild/kanto_grass.asm`, `def_grass_wildmons ROUTE_7`, +rates `10 percent`. Gold branch: + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 17 RATTATA | 17 RATTATA | 17 RATTATA | +| 2 | 17 SPEAROW | 17 SPEAROW | 17 MURKROW | +| 3 | 18 GROWLITHE | 18 GROWLITHE | 18 GROWLITHE | +| 4 | 19 RATICATE | 19 RATICATE | 19 RATICATE | +| 5 | 19 RATTATA | 19 RATTATA | 19 MURKROW | +| 6 | 15 RATTATA | 15 GROWLITHE | 15 HOUNDOUR | +| 7 | 15 RATTATA | 15 GROWLITHE | 15 HOUNDOUR | + +(The Silver branch under `ELIF DEF(_SILVER)` is MEOWTH / VULPIX / PERSIAN.) + +--- + +### MAP_CELADON_CITY + +- Script: `maps/CeladonCity.asm` +- Blocks: `maps/CeladonCity.blk` (20 x 18 blocks, 360 bytes) +- Header: `data/maps/maps.asm:415` -> `TILESET_KANTO, TOWN, LANDMARK_CELADON_CITY, MUSIC_CELADON_CITY, FALSE, PALETTE_AUTO, FISHGROUP_NONE` +- Dimensions: `constants/map_constants.asm:383` `map_const CELADON_CITY, 20, 18` -> 40 x 36 cells +- Connections (`map_attributes CeladonCity, CELADON_CITY, $0f`): + `connection west, Route16, ROUTE_16, 9`, `connection east, Route7, ROUTE_7, 5` + +**Callbacks**: `callback MAPCALLBACK_NEWMAP, CeladonCityFlypointCallback` -> +`setflag ENGINE_FLYPOINT_CELADON` (`constants/engine_flags.asm:75`). + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 9 | `CELADON_DEPT_STORE_1F` | 1 | +| 2 | 16 | 9 | `CELADON_MANSION_1F` | 1 | +| 3 | 16 | 3 | `CELADON_MANSION_1F` | 3 | +| 4 | 17 | 3 | `CELADON_MANSION_1F` | 3 | +| 5 | 29 | 9 | `CELADON_POKECENTER_1F` | 1 | +| 6 | 18 | 19 | `CELADON_GAME_CORNER` | 1 | +| 7 | 23 | 19 | `CELADON_GAME_CORNER_PRIZE_ROOM` | 1 | +| 8 | 10 | 29 | `CELADON_GYM` | 1 | +| 9 | 25 | 29 | `CELADON_CAFE` | 1 | + +Warps 3/4 at y=3 are the "hidden back door" the Youngster at (18,13) mentions. + +**Coord events** - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 23 | 21 | `BGEVENT_READ` | `CeladonCitySign` | +| 11 | 31 | `BGEVENT_READ` | `CeladonGymSign` (ERIKA, "The Nature-Loving Princess") | +| 6 | 9 | `BGEVENT_READ` | `CeladonCityDeptStoreSign` | +| 13 | 9 | `BGEVENT_READ` | `CeladonCityMansionSign` | +| 19 | 21 | `BGEVENT_READ` | `CeladonCityGameCornerSign` | +| 29 | 21 | `BGEVENT_READ` | `CeladonCityTrainerTips` | +| 30 | 9 | `BGEVENT_READ` | `CeladonCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 37 | 21 | `BGEVENT_ITEM` | `CeladonCityHiddenPpUp` (`4e:5b37`) -> `hiddenitem PP_UP, EVENT_CELADON_CITY_HIDDEN_PP_UP` | + +The walkthrough's "head right to a dead end, press A and you'll get a random PP +Up" is this bg event at (37, 21). It is a fixed `PP_UP`, not random. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONCITY_FISHER` | `SPRITE_FISHER` | 26 | 11 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `CeladonCityFisherScript` | -1 | +| `CELADONCITY_POLIWAG` | `SPRITE_POLIWAG` | 27 | 11 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `CeladonCityPoliwrath` | -1 | +| `CELADONCITY_TEACHER1` | `SPRITE_TEACHER` | 20 | 24 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `CeladonCityTeacher1Script` | -1 | +| `CELADONCITY_GRAMPS1` | `SPRITE_GRAMPS` | 14 | 16 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CeladonCityGramps1Script` | -1 | +| `CELADONCITY_GRAMPS2` | `SPRITE_GRAMPS` | 8 | 31 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `CeladonCityGramps2Script` ("Only girls are allowed here!") | -1 | +| `CELADONCITY_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 18 | 13 | `WALK_LEFT_RIGHT` (2,0) | `OBJECTTYPE_SCRIPT` | `CeladonCityYoungster1Script` | -1 | +| `CELADONCITY_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 24 | 33 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `CeladonCityYoungster2Script` | -1 | +| `CELADONCITY_TEACHER2` | `SPRITE_TEACHER` | 6 | 14 | `WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `CeladonCityTeacher2Script` | -1 | +| `CELADONCITY_LASS` | `SPRITE_LASS` | 7 | 22 | `WALK_UP_DOWN` (0,2) | `OBJECTTYPE_SCRIPT` | `CeladonCityLassScript` | -1 | + +The old man outside the gym is `CELADONCITY_GRAMPS2` at (8, 31). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `PP_UP` | face (37,21), press A | `CeladonCityHiddenPpUp` bg event | `EVENT_CELADON_CITY_HIDDEN_PP_UP` (`constants/event_flags.asm:253`) | + +**Wild encounters** - none (`FISHGROUP_NONE`, no grass entry). + +--- + +### MAP_CELADON_MANSION_1F / 2F / 3F / ROOF / ROOF_HOUSE + +- Scripts: `maps/CeladonMansion1F.asm`, `2F`, `3F`, `maps/CeladonMansionRoof.asm`, `maps/CeladonMansionRoofHouse.asm` +- Headers: `data/maps/maps.asm:423-427` -> all `TILESET_MANSION` except the roof + house (`TILESET_HOUSE`), all `INDOOR, LANDMARK_CELADON_CITY, MUSIC_CELADON_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:391-395` -> 1F/2F/3F/ROOF `4, 5`; ROOF_HOUSE `4, 4` +- Attributes: `data/maps/attributes.asm:636-640` (roof is `$01`, the rest `$00`) + +**Warps - 1F** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 9 | `CELADON_CITY` | 2 | +| 2 | 7 | 9 | `CELADON_CITY` | 2 | +| 3 | 3 | 0 | `CELADON_CITY` | 3 | +| 4 | 0 | 0 | `CELADON_MANSION_2F` | 1 | +| 5 | 7 | 0 | `CELADON_MANSION_2F` | 4 | + +**Warps - 2F** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `CELADON_MANSION_1F` | 4 | +| 2 | 1 | 0 | `CELADON_MANSION_3F` | 2 | +| 3 | 6 | 0 | `CELADON_MANSION_3F` | 3 | +| 4 | 7 | 0 | `CELADON_MANSION_1F` | 5 | + +**Warps - 3F** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 0 | `CELADON_MANSION_ROOF` | 1 | +| 2 | 1 | 0 | `CELADON_MANSION_2F` | 2 | +| 3 | 6 | 0 | `CELADON_MANSION_2F` | 3 | +| 4 | 7 | 0 | `CELADON_MANSION_ROOF` | 2 | + +**Warps - ROOF** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 1 | 1 | `CELADON_MANSION_3F` | 1 | +| 2 | 6 | 1 | `CELADON_MANSION_3F` | 4 | +| 3 | 2 | 5 | `CELADON_MANSION_ROOF_HOUSE` | 1 | + +**Warps - ROOF_HOUSE** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `CELADON_MANSION_ROOF` | 3 | +| 2 | 3 | 7 | `CELADON_MANSION_ROOF` | 3 | + +**Object events - ROOF_HOUSE** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONMANSIONROOFHOUSE_PHARMACIST` | `SPRITE_PHARMACIST` | 3 | 2 | `STANDING_DOWN` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `CeladonMansionRoofHousePharmacistScript` (`5e:5083`) | -1 | + +**Scripts of interest** - `CeladonMansionRoofHousePharmacistScript`: +`checkevent EVENT_GOT_TM03_CURSE` -> `.GotCurse`; else intro text, then +**`checktime NITE`**; if false, "come back after sunset" and end; if true, +the Cycling Road story, `verbosegiveitem TM_CURSE`, `setevent EVENT_GOT_TM03_CURSE`. + +**Object events - 3F** (the Game Freak dev room the walkthrough mentions): + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONMANSION3F_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 3 | 6 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `GameFreakGameDesignerScript` (diploma at full dex, `setevent EVENT_ENABLE_DIPLOMA_PRINTING`) | -1 | +| `CELADONMANSION3F_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 3 | 4 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GameFreakGraphicArtistScript` | -1 | +| `CELADONMANSION3F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 0 | 7 | `STANDING_UP` | `OBJECTTYPE_SCRIPT` | `GameFreakProgrammerScript` | -1 | +| `CELADONMANSION3F_FISHER` | `SPRITE_FISHER` | 0 | 4 | `STANDING_UP` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `GameFreakCharacterDesignerScript` | -1 | + +**Object events - 1F** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONMANSION1F_GRANNY` | `SPRITE_GRANNY` | 1 | 5 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `CeladonMansionManager` | -1 | +| `CELADONMANSION1F_GROWLITHE1` | `SPRITE_GROWLITHE` | 2 | 6 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `CeladonMansion1FMeowth` | -1 | +| `CELADONMANSION1F_CLEFAIRY` | `SPRITE_CLEFAIRY` | 3 | 4 | `POKEMON` | `OBJECTTYPE_SCRIPT` | `CeladonMansion1FClefairy` | -1 | +| `CELADONMANSION1F_GROWLITHE2` | `SPRITE_GROWLITHE` | 4 | 4 | `POKEMON` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `CeladonMansion1FNidoranF` | -1 | + +2F has `def_object_events` empty; its three bg events (PC at (0,3), meeting-room +sign at (5,8), bookshelf at (2,3)) are the whole floor. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_CURSE` (TM03) | talk to the pharmacist at (3,2) in `CELADON_MANSION_ROOF_HOUSE` **at night** | `CeladonMansionRoofHousePharmacistScript` | `EVENT_GOT_TM03_CURSE` (`constants/event_flags.asm:217`) | + +--- + +### MAP_CELADON_DEPT_STORE_1F..6F + +- Scripts: `maps/CeladonDeptStore1F.asm` .. `6F.asm`, `maps/CeladonDeptStoreElevator.asm` +- Headers: `data/maps/maps.asm:416-422` -> `TILESET_MART, INDOOR, LANDMARK_CELADON_CITY, MUSIC_CELADON_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:384-390` -> each floor `8, 4`; elevator `2, 2` + +**Warps - 1F** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 7 | `CELADON_CITY` | 1 | +| 2 | 8 | 7 | `CELADON_CITY` | 1 | +| 3 | 15 | 0 | `CELADON_DEPT_STORE_2F` | 2 | +| 4 | 2 | 0 | `CELADON_DEPT_STORE_ELEVATOR` | 1 | + +**Marts** (`data/items/marts.asm`, referenced by `pokemart MARTTYPE_STANDARD, MART_*`) + +| floor | mart label | contents | +|---|---|---| +| 2F clerk A | `MartCeladon2F1` (line 290) | POTION, SUPER_POTION, HYPER_POTION, MAX_POTION, REVIVE, SUPER_REPEL, MAX_REPEL | +| 2F clerk B | `MartCeladon2F2` (line 301) | POKE_BALL, GREAT_BALL, ULTRA_BALL, ESCAPE_ROPE, FULL_HEAL, ANTIDOTE, BURN_HEAL, ICE_HEAL, AWAKENING, PARLYZ_HEAL | +| 3F | `MartCeladon3F` (line 315) | TM_HIDDEN_POWER (TM10), TM_SUNNY_DAY (TM11), TM_PROTECT (TM17), TM_RAIN_DANCE (TM18), TM_SANDSTORM (TM37) | +| 4F | `MartCeladon4F` (line 324) | POKE_DOLL, LOVELY_MAIL, SURF_MAIL | +| 5F clerk A | `MartCeladon5F1` (line 331) | HP_UP, PROTEIN, IRON, CARBOS, CALCIUM | +| 5F clerk B | `MartCeladon5F2` (line 344) | X_ACCURACY, GUARD_SPEC, DIRE_HIT, X_ATTACK, X_DEFEND, X_SPEED, X_SPECIAL | +| 6F | none | four `BGEVENT_UP` vending machines at (8,1), (9,1), (10,1), (11,1) -> `CeladonDeptStore6FVendingMachine` | + +This matches the walkthrough's floor list exactly. + +--- + +### MAP_CELADON_GAME_CORNER / MAP_CELADON_GAME_CORNER_PRIZE_ROOM + +- Scripts: `maps/CeladonGameCorner.asm`, `maps/CeladonGameCornerPrizeRoom.asm` +- Headers: `data/maps/maps.asm:430-431` -> `TILESET_GAME_CORNER, INDOOR, LANDMARK_CELADON_CITY`; Game Corner uses `MUSIC_GAME_CORNER`, the prize room `MUSIC_CELADON_CITY` +- Dimensions: `constants/map_constants.asm:398-399` -> `CELADON_GAME_CORNER 10, 7`; `CELADON_GAME_CORNER_PRIZE_ROOM 3, 3` + +**Warps - Game Corner** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 14 | 13 | `CELADON_CITY` | 6 | +| 2 | 15 | 13 | `CELADON_CITY` | 6 | + +**Warps - Prize Room** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 5 | `CELADON_CITY` | 7 | +| 2 | 3 | 5 | `CELADON_CITY` | 7 | + +**BG events - Prize Room** + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 1 | `BGEVENT_READ` | `CeladonGameCornerPrizeRoomTMVendor` | +| 4 | 1 | `BGEVENT_READ` | `CeladonGameCornerPrizeRoomPokemonVendor` | + +**Object events - Prize Room** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONGAMECORNERPRIZEROOM_GENTLEMAN` | `SPRITE_GENTLEMAN` | 0 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CeladonGameCornerPrizeRoomGentlemanScript` | -1 | +| `CELADONGAMECORNERPRIZEROOM_PHARMACIST` | `SPRITE_PHARMACIST` | 4 | 4 | `WALK_UP_DOWN` (0,1) | `OBJECTTYPE_SCRIPT` | `CeladonGameCornerPrizeRoomPharmacistScript` | -1 | + +**Prize prices** - `EQU` constants at the top of `maps/CeladonGameCornerPrizeRoom.asm`: + +| prize | constant | coins | +|---|---|---| +| `TM_DOUBLE_TEAM` (TM32) | `CELADONGAMECORNERPRIZEROOM_TM32_COINS` | 1500 | +| `TM_PSYCHIC_M` (TM29) | `CELADONGAMECORNERPRIZEROOM_TM29_COINS` | 3500 | +| `TM_HYPER_BEAM` (TM15) | `CELADONGAMECORNERPRIZEROOM_TM15_COINS` | 7500 | +| `MR__MIME` @ L15 | `CELADONGAMECORNERPRIZEROOM_MR_MIME_COINS` | 3333 | +| `EEVEE` @ L15 | `CELADONGAMECORNERPRIZEROOM_EEVEE_COINS` | 6666 | +| `PORYGON` @ L20 | `CELADONGAMECORNERPRIZEROOM_PORYGON_COINS` | 9999 | + +Both vendors open with `checkitem COIN_CASE` / `iffalse CeladonPrizeRoom_NoCoinCase`. +The mon counter additionally does `readvar VAR_PARTYCOUNT` / +`ifequal PARTY_LENGTH, CeladonPrizeRoom_notenoughroom` before the purchase, then +`setval <species>` + `special GameCornerPrizeMonCheckDex` + `givepoke`. + +--- + +### MAP_CELADON_CAFE + +- Script: `maps/CeladonCafe.asm` +- Header: `data/maps/maps.asm:433` -> `TILESET_GAME_CORNER, INDOOR, LANDMARK_CELADON_CITY, MUSIC_CELADON_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:401` `map_const CELADON_CAFE, 6, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 6 | 7 | `CELADON_CITY` | 9 | +| 2 | 7 | 7 | `CELADON_CITY` | 9 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 0 | `BGEVENT_READ` | `EatathonContestPoster` | +| 7 | 1 | `BGEVENT_READ` | `CeladonCafeTrashcan` (`5e:648c`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONCAFE_SUPER_NERD` | `SPRITE_SUPER_NERD` | 9 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `CeladonCafeChef` | -1 | +| `CELADONCAFE_FISHER1` | `SPRITE_FISHER` | 4 | 6 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `CeladonCafeFisher1` | -1 | +| `CELADONCAFE_FISHER2` | `SPRITE_FISHER` | 1 | 7 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `CeladonCafeFisher2` | -1 | +| `CELADONCAFE_FISHER3` | `SPRITE_FISHER` | 1 | 2 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `CeladonCafeFisher3` | -1 | +| `CELADONCAFE_TEACHER` | `SPRITE_TEACHER` | 4 | 3 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `CeladonCafeTeacher` (`checkitem COIN_CASE` branch) | -1 | + +**Scripts of interest** - `CeladonCafeTrashcan` (`maps/CeladonCafe.asm:91`): +`checkevent EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE` -> `.TrashEmpty` +(`jumpstd TrashCanScript`); else `giveitem LEFTOVERS`, `iffalse .PackFull`, +`getitemname STRING_BUFFER_3, LEFTOVERS`, `writetext FoundLeftoversText`, +`itemnotify`, `setevent EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE`. Note the flag is +only set on success, so a full pack is retryable. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `LEFTOVERS` | face (7,1) and press A | `CeladonCafeTrashcan` bg event | `EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE` (`constants/event_flags.asm:249`) | + +--- + +### MAP_CELADON_GYM + +- Script: `maps/CeladonGym.asm` +- Blocks: `maps/CeladonGym.blk` +- Header: `data/maps/maps.asm:432` -> `TILESET_TRAIN_STATION, INDOOR, LANDMARK_CELADON_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:400` `map_const CELADON_GYM, 5, 9` -> 10 x 18 cells +- Attributes: `data/maps/attributes.asm:645` `map_attributes CeladonGym, CELADON_GYM, $00`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `CELADON_CITY` | 8 | +| 2 | 5 | 17 | `CELADON_CITY` | 8 | + +**Coord events** - none (`def_coord_events` empty). There is no scripted +lock-in; the maze is pure geometry. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `CeladonGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `CeladonGymStatue` | + +`CeladonGymStatue` is `checkflag ENGINE_RAINBOWBADGE` -> `.Beaten` +(`gettrainername STRING_BUFFER_4, ERIKA, ERIKA1` + `jumpstd GymStatue2Script`), +else `jumpstd GymStatue1Script`. A cheap post-badge assertion for a bot. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CELADONGYM_ERIKA` | `SPRITE_ERIKA` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CeladonGymErikaScript` (`5e:5e0b`) | -1 | +| `CELADONGYM_LASS1` | `SPRITE_LASS` | 7 | 8 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerLassMichelle` | -1 | +| `CELADONGYM_LASS2` | `SPRITE_LASS` | 2 | 8 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerPicnickerTanya` | -1 | +| `CELADONGYM_BEAUTY` | `SPRITE_BEAUTY` | 3 | 5 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerBeautyJulia` | -1 | +| `CELADONGYM_TWIN1` | `SPRITE_TWIN` | 4 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerTwinsJoAndZoe1` | -1 | +| `CELADONGYM_TWIN2` | `SPRITE_TWIN` | 5 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerTwinsJoAndZoe2` | -1 | + +**Scripts of interest** - `CeladonGymErikaScript`: + +``` +faceplayer / opentext +checkflag ENGINE_RAINBOWBADGE -> iftrue .FightDone +writetext ErikaBeforeBattleText / waitbutton / closetext +winlosstext ErikaBeatenText, 0 +loadtrainer ERIKA, ERIKA1 +startbattle +reloadmapafterbattle +setevent EVENT_BEAT_ERIKA +setevent EVENT_BEAT_LASS_MICHELLE +setevent EVENT_BEAT_PICNICKER_TANYA +setevent EVENT_BEAT_BEAUTY_JULIA +setevent EVENT_BEAT_TWINS_JO_AND_ZOE +writetext PlayerReceivedRainbowBadgeText / playsound SFX_GET_BADGE / waitsfx +setflag ENGINE_RAINBOWBADGE +.FightDone: +checkevent EVENT_GOT_TM19_GIGA_DRAIN -> iftrue .GotGigaDrain +writetext ErikaExplainTMText / promptbutton +verbosegiveitem TM_GIGA_DRAIN / iffalse .GotGigaDrain +setevent EVENT_GOT_TM19_GIGA_DRAIN +``` + +Two things a bot must know: beating Erika **retroactively sets all four minor +trainers' beaten flags**, so a rematch sweep is impossible after the leader; and +the TM is handed out in the same conversation, so a full bag silently skips it +and you must talk to her again. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_RAINBOWBADGE` | `constants/engine_flags.asm:50` | set by `CeladonGymErikaScript`; read by `CeladonGymStatue` | the badge itself | +| `EVENT_BEAT_ERIKA` | `constants/event_flags.asm:718` | set by `CeladonGymErikaScript` | leader cleared | +| `EVENT_BEAT_LASS_MICHELLE` | `:810` | `TrainerLassMichelle` / Erika script | gym trainer | +| `EVENT_BEAT_PICNICKER_TANYA` | `:654` | `TrainerPicnickerTanya` / Erika script | gym trainer | +| `EVENT_BEAT_BEAUTY_JULIA` | `:702` | `TrainerBeautyJulia` / Erika script | gym trainer | +| `EVENT_BEAT_TWINS_JO_AND_ZOE` | `:612` | both twin objects share this one flag | beating either twin clears both | +| `EVENT_GOT_TM19_GIGA_DRAIN` | `:219` | `CeladonGymErikaScript` | one-time TM | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_GIGA_DRAIN` (TM19) | talk to ERIKA after the battle | `CeladonGymErikaScript` -> `verbosegiveitem TM_GIGA_DRAIN` | `EVENT_GOT_TM19_GIGA_DRAIN` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `TWINS`, `JOANDZOE1` | Twins (`:625`) | JOANDZOE1 | `"JO & ZOE@", TRAINERTYPE_NORMAL` / `35 VICTREEBEL`, `35 VILEPLUME` (line 3090) | `TrainerTwinsJoAndZoe1` | no | +| `TWINS`, `JOANDZOE2` | Twins (`:626`) | JOANDZOE2 | `"JO & ZOE@", TRAINERTYPE_NORMAL` / `35 VILEPLUME`, `35 VICTREEBEL` (line 3096) | `TrainerTwinsJoAndZoe2` | no | +| `PICNICKER`, `TANYA` | Picnicker (`:544`) | TANYA | `37 EXEGGUTOR` (line 2688) | `TrainerPicnickerTanya` | no | +| `LASS`, `MICHELLE` | Lass (`:173`) | MICHELLE | `32 SKIPLOOM`, `33 HOPPIP`, `34 JUMPLUFF` (line 690) | `TrainerLassMichelle` | no | +| `BEAUTY`, `JULIA` | Beauty (`:241`) | JULIA | `32 PARAS`, `32 EXEGGCUTE`, `35 PARASECT` (line 1062) | `TrainerBeautyJulia` | no | +| `ERIKA`, `ERIKA1` | Erika (`:107`) | ERIKA1 | `TRAINERTYPE_MOVES` (line 331): `42 TANGELA` VINE_WHIP/BIND/GIGA_DRAIN/SLEEP_POWDER; `41 JUMPLUFF` MEGA_DRAIN/LEECH_SEED/COTTON_SPORE/GIGA_DRAIN; `46 VICTREEBEL` SUNNY_DAY/SYNTHESIS/ACID/RAZOR_LEAF; `46 BELLOSSOM` SUNNY_DAY/SYNTHESIS/PETAL_DANCE/SOLARBEAM | `CeladonGymErikaScript` | leader, no rematch here | + +Erika's DVs: `data/trainers/dvs.asm:25` `dn 7, 8, 8, 8`. +Erika's class attributes (`data/trainers/attributes.asm:125`): +`db HYPER_POTION, NO_ITEM ; items`, `db 25 ; base reward`, +`AI_BASIC|AI_SETUP|AI_SMART|AI_AGGRESSIVE|AI_CAUTIOUS|AI_STATUS|AI_RISKY`, +`CONTEXT_USE | SWITCH_SOMETIMES`. + +**Party order note**: `loadtrainer` sends mons out in the order written in +`parties.asm` - **Tangela, Jumpluff, Victreebel, Bellossom**. The walkthrough +lists Tangela / Victreebel / Bellossom / Jumpluff, which is not the lead order. + +**Wild encounters** - none. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Rock Tunnel is pitch black | `data/maps/maps.asm:156-157` (`PALETTE_DARK`) + `engine/events/overworld.asm:271` `FlashFunction.CheckUseFlash` | `ENGINE_ZEPHYRBADGE` **and** `wTimeOfDayPalset == DARKNESS_PALSET` | use HM05 Flash from the party menu inside the tunnel; without the badge the routine returns `JUMPTABLE_EXIT` and nothing happens | +| Surf to reach the Power Plant / cross Route 10 water | `engine/events/overworld.asm:322` `SurfFunction`, badge check at `:340` `ld de, ENGINE_FOGBADGE` | `ENGINE_FOGBADGE` + a party mon that knows Surf | already held by this point in the walkthrough | +| Cut (any cut tree) | `engine/events/overworld.asm:133` `ld de, ENGINE_HIVEBADGE` in `CutFunction.CheckAble`, then `CheckMapForSomethingToCut` -> `CheckCutCollision` against `data/collision/field_move_blocks.asm` `CutTreeBlockPointers.kanto` (`$32/$33/$34/$35/$60` trees, `$0b` grass) | `ENGINE_HIVEBADGE` + Cut | not actually required in Celadon City - see below | +| TM07 Zap Cannon | `maps/PowerPlant.asm` `PowerPlantManager` | `EVENT_RETURNED_MACHINE_PART` (or `MACHINE_PART` in the bag) | return the part | +| EXPN CARD | `maps/LavRadioTower1F.asm` `LavRadioTower1FGentlemanScript` | `EVENT_RETURNED_MACHINE_PART` | same | +| `LOST_ITEM` from the Fan Club | `maps/PokemonFanClub.asm` `PokemonFanClubClefairyGuyScript.MetCopycat` | `EVENT_RETURNED_MACHINE_PART` **and** `EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM` | talk to the Copycat once after returning the machine part | +| `PASS` from the Copycat | `maps/CopycatsHouse2F.asm` `Copycat.ReturnLostItem` | `LOST_ITEM` in the bag | hand it over | +| Magnet Train ride | `maps/SaffronMagnetTrainStation.asm` `SaffronMagnetTrainStationOfficerScript` | `EVENT_RESTORED_POWER_TO_KANTO` then `checkitem PASS` | both set by the machine-part / Copycat chains | +| TM03 Curse | `maps/CeladonMansionRoofHouse.asm` `CeladonMansionRoofHousePharmacistScript` | `checktime NITE` | come back after sunset | +| Game Corner prizes | `maps/CeladonGameCornerPrizeRoom.asm` both vendor scripts | `checkitem COIN_CASE`, then `checkcoins <price>`; mon counter also `readvar VAR_PARTYCOUNT != PARTY_LENGTH` | buy/win coins, keep a party slot free | +| RAINBOWBADGE | `maps/CeladonGym.asm` `CeladonGymErikaScript` | none beyond reaching Erika at (5,3) | win the battle | + +**Celadon Gym is NOT Cut-gated in pokegold.** `maps/CeladonCity.blk` contains a +single kanto cut-tree block (`$60`) at block (14,17) = cell (28,34), in the +bottom-right of the map, nowhere near the gym. The gym door is the `DOOR` +quadrant of block `$12` at block (5,14) = cell (10,29) +(`data/tilesets/kanto_collision.asm` `tilecoll WALL, WALL, DOOR, WALL ; 12`), +and the cell directly below it, block (5,15) = `$79` +(`tilecoll FLOOR, FLOOR, FLOOR, WALL ; 79`), is walkable floor. Nothing between +the city and the gym requires Cut. + +--- + +## 4. Bot checklist + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `ROUTE_10_NORTH` | cell (3,9) | walk onto warp 2 | Surf usable (`ENGINE_FOGBADGE`) | in `POWER_PLANT` | +| 2 | `POWER_PLANT` | object `POWERPLANT_MANAGER` (14,10) | talk (face UP from (14,11)) | `EVENT_RETURNED_MACHINE_PART` or `MACHINE_PART` held | `EVENT_GOT_TM07_ZAP_CANNON`, `EVENT_RESTORED_POWER_TO_KANTO` | +| 3 | `POWER_PLANT` | cell (2,17) | walk onto warp 1 | - | in `ROUTE_10_NORTH` | +| 4 | `ROCK_TUNNEL_1F` | party menu | use FLASH | `ENGINE_ZEPHYRBADGE`, mon knows Flash | dark palette lifted | +| 5 | `ROCK_TUNNEL_1F` | cell (10,15) | walk onto item ball | `EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING` clear | TM47 in bag, flag set | +| 6 | `ROCK_TUNNEL_1F` | cell (4,18) | walk onto item ball | `EVENT_ROCK_TUNNEL_1F_ELIXER` clear | ELIXER in bag | +| 7 | `ROCK_TUNNEL_1F` | cell (15,9) / (5,3) / (27,3) / (27,13) | warps 4/3/5/6 into B1F | - | in `ROCK_TUNNEL_B1F` | +| 8 | `ROCK_TUNNEL_B1F` | cells (7,25), (6,17), (15,2) | walk onto item balls | respective flags clear | IRON, PP_UP, REVIVE | +| 9 | `ROCK_TUNNEL_B1F` | cell (4,14) | face and press A | `EVENT_ROCK_TUNNEL_B1F_HIDDEN_MAX_POTION` clear | MAX_POTION | +| 10 | `ROCK_TUNNEL_1F` | cell (11,25) | warp 2 | - | in `ROUTE_10_SOUTH` at warp 1 (6,1) | +| 11 | `ROUTE_10_SOUTH` | object at (17,3) | battle `TrainerHikerJim` (sight 4, faces LEFT) | `EVENT_BEAT_HIKER_JIM` clear | flag set | +| 12 | `ROUTE_10_SOUTH` | object at (4,10) | battle `TrainerPokefanmRobert` (sight 2, faces LEFT) | `EVENT_BEAT_POKEFANM_ROBERT` clear | flag set | +| 13 | `ROUTE_10_SOUTH` | south edge | walk south | - | in `LAVENDER_TOWN`, `ENGINE_FLYPOINT_LAVENDER` set | +| 14 | `LAVENDER_TOWN` | cell (14,5) | warp 7 | - | in `LAV_RADIO_TOWER_1F` | +| 15 | `LAV_RADIO_TOWER_1F` | object at (9,1) | talk | `EVENT_RETURNED_MACHINE_PART` set, `ENGINE_EXPN_CARD` clear | `ENGINE_EXPN_CARD` set | +| 16 | `LAVENDER_TOWN` | west edge | walk west | - | in `ROUTE_8` | +| 17 | `ROUTE_8` | (27,9), (20,6), (10,10), (10,9), (10,8) | battle Tom, Sam, Zeke, Harris, Dwayne | respective `EVENT_BEAT_*` clear | flags set | +| 18 | `ROUTE_8` | object at (33,5) | face and press A | daily tree flag clear | `PRZCUREBERRY` | +| 19 | `ROUTE_8` | cell (4,4) | warp 1 | - | `ROUTE_8_SAFFRON_GATE` -> `SAFFRON_CITY` | +| 20 | `SAFFRON_CITY` | cell (9,11) | warp 8 | - | `COPYCATS_HOUSE_1F` | +| 21 | `COPYCATS_HOUSE_1F` | cell (2,0) | warp 3 | - | `COPYCATS_HOUSE_2F` | +| 22 | `COPYCATS_HOUSE_2F` | object `COPYCATSHOUSE2F_COPYCAT` (4,3) | talk | `EVENT_RETURNED_MACHINE_PART` set | `EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM` | +| 23 | - | Fly to Vermilion | fly | `ENGINE_FLYPOINT_VERMILION` | in `VERMILION_CITY` | +| 24 | `VERMILION_CITY` | cell (7,13) | warp 3 | - | `POKEMON_FAN_CLUB` | +| 25 | `POKEMON_FAN_CLUB` | object at (2,3) | talk (face RIGHT from (1,3)) | step 22 done, bag has room | `LOST_ITEM`, `EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB`, FAIRY object disappears | +| 26 | - | Fly to Saffron, re-enter Copycat 2F | fly + warps | - | back at (4,3) | +| 27 | `COPYCATS_HOUSE_2F` | Copycat | talk | `LOST_ITEM` held | `PASS`, `EVENT_GOT_PASS_FROM_COPYCAT`, `EVENT_RETURNED_LOST_ITEM_TO_COPYCAT` | +| 28 | `SAFFRON_CITY` | cell (0,24) | warp 10 | - | `ROUTE_7_SAFFRON_GATE` | +| 29 | `ROUTE_7_SAFFRON_GATE` | cell (0,4) | warp 1 | - | `ROUTE_7` (guard is not a blocker) | +| 30 | `ROUTE_7` | west edge | walk west | - | `CELADON_CITY`, `ENGINE_FLYPOINT_CELADON` set | +| 31 | `CELADON_CITY` | cell (16,3) | warp 3 (mansion back door) | - | `CELADON_MANSION_1F` at warp 3 | +| 32 | `CELADON_MANSION_1F/2F/3F` | warps 4 -> 2 -> 1 | climb to the roof | - | `CELADON_MANSION_ROOF` | +| 33 | `CELADON_MANSION_ROOF` | cell (2,5) | warp 3 | - | `CELADON_MANSION_ROOF_HOUSE` | +| 34 | `CELADON_MANSION_ROOF_HOUSE` | object at (3,2) | talk | `checktime NITE` true, `EVENT_GOT_TM03_CURSE` clear | TM03, flag set | +| 35 | `CELADON_CITY` | cell (25,29) | warp 9 | - | `CELADON_CAFE` | +| 36 | `CELADON_CAFE` | cell (7,1) | face UP and press A | `EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE` clear, bag room | `LEFTOVERS`, flag set | +| 37 | `CELADON_CITY` | cell (37,21) | face and press A | `EVENT_CELADON_CITY_HIDDEN_PP_UP` clear | `PP_UP` | +| 38 | `CELADON_CITY` | cell (10,29) | warp 8 | no field move needed | `CELADON_GYM` | +| 39 | `CELADON_GYM` | (4,10) and (5,10) | battle both twins | `EVENT_BEAT_TWINS_JO_AND_ZOE` clear (shared) | flag set after the first | +| 40 | `CELADON_GYM` | (2,8) | battle `TrainerPicnickerTanya` | `EVENT_BEAT_PICNICKER_TANYA` clear | flag set | +| 41 | `CELADON_GYM` | (7,8) | battle `TrainerLassMichelle` | `EVENT_BEAT_LASS_MICHELLE` clear | flag set | +| 42 | `CELADON_GYM` | (3,5) | battle `TrainerBeautyJulia` | `EVENT_BEAT_BEAUTY_JULIA` clear | flag set | +| 43 | `CELADON_GYM` | object `CELADONGYM_ERIKA` (5,3) | talk from (5,4) facing UP | `ENGINE_RAINBOWBADGE` clear | battle, then `ENGINE_RAINBOWBADGE`, `EVENT_BEAT_ERIKA`, all four minor flags | +| 44 | `CELADON_GYM` | Erika again if the bag was full | talk | `EVENT_GOT_TM19_GIGA_DRAIN` clear | `TM_GIGA_DRAIN` | +| 45 | `CELADON_GYM` | cell (4,17) | warp 1 | - | back in `CELADON_CITY` | + +--- + +## 5. Port coverage + +The Gen 2 port is data-driven: `src/import/RomExtractorGen2.lua` reads every map +header, `def_warp_events` / `def_coord_events` / `def_bg_events` / +`def_object_events` table and script pointer straight out of the ROM +(`RomExtractorGen2:readMapEvents`, line 782), so per-map coverage is not a +per-map Lua file. What follows is coverage of the *mechanics* this section +needs. + +| Beat | Port file | Status | +|---|---|---| +| Map blocks, collision, warps, connections | `src/world/gen2/Map.lua`, `src/world/gen2/Permissions.lua` | implemented (generic) | +| Warp / object / bg / coord event extraction | `src/import/RomExtractorGen2.lua:782` `readMapEvents` | implemented | +| Script VM (`checkevent`, `setevent`, `checkflag`, `setflag`, `verbosegiveitem`, `giveitem`, `takeitem`, `loadtrainer`, `startbattle`, `winlosstext`, `applymovement`, `checktime`, `showemote`, `variablesprite`, `gettrainername`) | `src/script/gen2/Opcodes.lua`, `src/script/gen2/Vm.lua` | implemented | +| Item balls (`OBJECTTYPE_ITEMBALL`) | `src/world/gen2/Events.lua`, `src/world/gen2/Npc.lua` | implemented | +| Hidden items (`BGEVENT_ITEM` -> `hiddenitem`) | `src/world/gen2/HiddenItems.lua` | implemented (the header notes `World:bgEventAt` previously only answered `BGEVENT_READ`) | +| Fruit trees (`fruittree FRUITTREE_ROUTE_8`) | `src/core/gen2/Apricorns.lua` (`tryResetFruitTrees`, `treePicked`), VM `fruittree` branch `src/script/gen2/Vm.lua:1191` | implemented | +| Overworld trainers: eyesight, `trainer` struct, party build | `src/world/gen2/Trainers.lua` | implemented | +| Field moves Cut / Flash / Surf, badge order, `CutTreeBlockPointers` | `src/world/gen2/FieldMoves.lua` (`CUT = "HIVE"`, `FLASH = "ZEPHYR"`, `SURF = "FOG"`, `cutFromMenu`, `flashFromMenu`, `surfFromMenu`) | implemented | +| Badge storage incl. RAINBOWBADGE | `src/inventory/Badges.lua:12`, `src/ui/gen2/TrainerCard.lua:67` | implemented | +| Game Corner prize counters (TM + mon, coin case, party-full check) | `src/ui/gen2/PrizeMenu.lua`, `special GameCornerPrizeMonCheckDex` in `src/script/gen2/Specials.lua:964` | implemented | +| Slot machine (Celadon Game Corner floor) | `src/ui/gen2/SlotMachine.lua` | implemented | +| Magnet Train ride | `src/core/gen2/MagnetTrain.lua`, `src/ui/gen2/MagnetTrainRide.lua` | implemented | +| Pokegear radio / EXPN card channel gating | `src/ui/gen2/Pokegear.lua:810`, `:1095` | implemented | +| Marts (`pokemart MARTTYPE_STANDARD`) | `src/ui/gen2/MartMenu.lua` | implemented | +| Elevator (dept store) | `src/ui/gen2/ElevatorMenu.lua` | implemented | +| `special LoadUsedSpritesGFX` (the Copycat mimicry sprite swap) | `src/script/gen2/Specials.lua:1033` | implemented | +| `special Diploma` (mansion 3F) | `src/ui/gen2/Diploma.lua`, `src/script/gen2/Specials.lua:1853` | implemented; `special PrintDiploma` is a stub | +| Headless driver coverage for this stretch | `tests/drivers/gold_*.lua` | **missing** - all 25 gold drivers are Johto-side (`gold_walk_smoke.lua` runs New Bark -> Route 29); nothing exercises Rock Tunnel, Route 8/7, Celadon, or the Erika fight | + +--- + +## 6. Unresolved / verify by hand + +1. **"Head to the southwest part of town with a Pokemon that can use the HM01 + Cut and you'll arrive at Erika's gym."** Not supported by the asm. There is + exactly one kanto cut-tree block in `maps/CeladonCity.blk` (`$60` at block + (14,17) = cell (28,34)) and it is not on the path to the gym. The gym door is + block `$12` at (5,14), approached from walkable block `$79` at (5,15). This + looks like a carry-over from Red/Blue. Verify in-game before writing a Cut + step into a driver. +2. **Route 7 wild list.** The walkthrough gives Pidgeotto / Vulpix / Meowth. + `data/wild/kanto_grass.asm` `def_grass_wildmons ROUTE_7` in the `IF DEF(_GOLD)` + branch is Rattata / Spearow / Growlithe / Raticate / Murkrow / Houndour; + Meowth and Vulpix only appear in the `_SILVER` branch, and Pidgeotto appears + on Route 8, not Route 7. Treat the FAQ list as Silver-flavoured. +3. **Rock Tunnel item list.** The walkthrough lists Iron, PP Up, Revive and TM47 + and omits the `ELIXER` ball at `ROCK_TUNNEL_1F` (4,18), the hidden + `X_ACCURACY` at 1F (24,4), the hidden `X_DEFEND` at 1F (21,15) and the hidden + `MAX_POTION` at B1F (4,14). +4. **Erika's held items.** The walkthrough says "three Full Restores". + `data/trainers/attributes.asm:125` gives the Erika class + `db HYPER_POTION, NO_ITEM`. The count of AI item uses is engine behaviour, not + a party field; the item is a Hyper Potion, not a Full Restore. +5. **Erika's lead order.** `parties.asm:331` orders Tangela, Jumpluff, + Victreebel, Bellossom; the walkthrough's strategy section is ordered Tangela, + Victreebel, Bellossom, Jumpluff. +6. **Bellossom's Synthesis "heals half its health every turn".** `Synthesis`'s + actual healing fraction is weather-dependent in the move effect + (`engine/battle/effect_commands.asm`); not pinned down here. +7. **"Rail Pass" vs "Magnet Train Pass".** The walkthrough describes two + separate rewards ("Rail Pass" first, then "Magnet Train Pass"). In the asm + there is one item, `PASS`, given once by `Copycat.GivePass`; the "rail PASS" + wording is just `CopycatText_Male_2` / `CopycatText_Male_3` flavour. +8. **"The Saffron City guard will check your Pokedex as ID."** + `Route7SaffronGuardScript` has no `checkitem`, no coord event and no blocking + movement - it is a two-branch text switch on `EVENT_RETURNED_MACHINE_PART`. + The gate is passable regardless. +9. **"You can play slots in the middle of town"** - the slot machines' + `def_object_events` / `def_bg_events` rows in `maps/CeladonGameCorner.asm` + were not transcribed here (only its two warps); if a bot needs to sit at a + specific machine, read that file. +10. **Exact experience and prize-money numbers** quoted by the walkthrough + (e.g. "1447 EXP", "1120G") were not verified; they are computed at runtime + from base stats and `data/trainers/attributes.asm` base rewards, not stored + as table values. diff --git a/docs/gold-walkthrough/asm-walk/section-23-routes-11-19-and-fuchsia-city-gym.md b/docs/gold-walkthrough/asm-walk/section-23-routes-11-19-and-fuchsia-city-gym.md new file mode 100644 index 00000000..35b5b495 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-23-routes-11-19-and-fuchsia-city-gym.md @@ -0,0 +1,1508 @@ +# Section 23 - Routes 11-19 and Fuchsia City Gym + +Source: `../section-23-routes-11-19-and-fuchsia-city-gym.txt` +Maps covered: `MAP_ROUTE_12`, `MAP_ROUTE_11`, `MAP_ROUTE_12_SUPER_ROD_HOUSE`, +`MAP_ROUTE_13`, `MAP_ROUTE_14`, `MAP_ROUTE_15`, `MAP_ROUTE_15_FUCHSIA_GATE`, +`MAP_FUCHSIA_CITY`, `MAP_FUCHSIA_POKECENTER_1F`, `MAP_ROUTE_16`, +`MAP_ROUTE_16_GATE`, `MAP_ROUTE_16_FUCHSIA_SPEECH_HOUSE`, `MAP_ROUTE_17`, +`MAP_ROUTE_17_ROUTE_18_GATE`, `MAP_ROUTE_18`, `MAP_FUCHSIA_GYM` + +Badges / key milestones in this section: + +- `ENGINE_SOULBADGE` (Kanto badge 5) from `FuchsiaGymJanineScript` in `maps/FuchsiaGym.asm` +- `EVENT_GOT_TM06_TOXIC` -> `TM_TOXIC` (item id `$c5`, `constants/item_constants.asm:226`) +- `EVENT_GOT_SUPER_ROD` -> `SUPER_ROD` from `maps/Route12SuperRodHouse.asm` +- `ENGINE_FLYPOINT_FUCHSIA` armed on first entry to `MAP_FUCHSIA_CITY` +- Cycling Road forced-bike state (`ENGINE_ALWAYS_ON_BIKE`, `ENGINE_DOWNHILL`) + +Despite the section title, the walkthrough text never enters `MAP_ROUTE_19` +(see "Unresolved"). + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_12` | `maps/Route12.asm` | Fly to Lavender Town, walk south (`LavenderTown` south connection, `data/maps/attributes.asm:339`) | west connection to `ROUTE_11` (offset 9) | Fisher Kyle, Fisher Martin | +| 2 | `MAP_ROUTE_11` | `maps/Route11.asm` | east connection from `ROUTE_12` (offset -9) | back east to `ROUTE_12` | Herman / Owen / Jason / Fidel, Berry + hidden Revive; Snorlax dead end at the west connection | +| 3 | `MAP_ROUTE_12` | `maps/Route12.asm` | east connection from `ROUTE_11` | warp 1 at (11, 33) | Fisher Stephen, then the Super Rod house | +| 4 | `MAP_ROUTE_12_SUPER_ROD_HOUSE` | `maps/Route12SuperRodHouse.asm` | `Route12` warp 1 | warps 1/2 back to `ROUTE_12` warp 1 | Super Rod | +| 5 | `MAP_ROUTE_12` | `maps/Route12.asm` | house warp | south connection to `ROUTE_13` (offset -20) | Fisher Barney, cut tree, Calcium ball | +| 6 | `MAP_ROUTE_13` | `maps/Route13.asm` | north connection from `ROUTE_12` (offset 20) | south connection to `ROUTE_14` (offset 0) | Perry, Bret, Joshua, Alex, Kenny | +| 7 | `MAP_ROUTE_14` | `maps/Route14.asm` | north connection from `ROUTE_13` | west connection to `ROUTE_15` (offset 9) | Trevor, Kim's trade, Carter, Roy; cut trees | +| 8 | `MAP_ROUTE_15` | `maps/Route15.asm` | east connection from `ROUTE_14` (offset -9) | warps 1/2 at (2, 4)/(2, 5) | PP Up, then six schoolboys/teachers | +| 9 | `MAP_ROUTE_15_FUCHSIA_GATE` | `maps/Route15FuchsiaGate.asm` | `Route15` warps 1/2 | warps 1/2 to `FUCHSIA_CITY` warps 8/9 | pass-through gate, no check | +| 10 | `MAP_FUCHSIA_CITY` | `maps/FuchsiaCity.asm` | gate warps 8/9 at (37, 22)/(37, 23) | Fly out (`ENGINE_FLYPOINT_FUCHSIA` now set) | Burnt Berry tree, closed Safari Zone, Pokecenter | +| 11 | `MAP_ROUTE_16` | `maps/Route16.asm` | Fly to Celadon, west connection (offset 9 from `CeladonCity`) | warps 2/3 at (14, 6)/(14, 7) | optional Cut tree; Cycling Road entrance | +| 12 | `MAP_ROUTE_16_GATE` | `maps/Route16Gate.asm` | `Route16` warps 2/3 | warps 1/2 at (0, 4)/(0, 5) -> `ROUTE_16` warps 4/5 | BICYCLE check coord event | +| 13 | `MAP_ROUTE_17` | `maps/Route17.asm` | `Route16` north connection (offset 0) | warps 1/2 at (17, 82)/(17, 83) | Riley, Glenn, Joel, Charles; forced downhill bike | +| 14 | `MAP_ROUTE_17_ROUTE_18_GATE` | `maps/Route17Route18Gate.asm` | `Route17` warps 1/2 | warps 3/4 at (9, 4)/(9, 5) -> `ROUTE_18` warps 1/2 | BICYCLE check coord event | +| 15 | `MAP_ROUTE_18` | `maps/Route18.asm` | gate warps 3/4 | east connection to `FUCHSIA_CITY` (offset -7) | Bird Keeper Bob, Bird Keeper Boris | +| 16 | `MAP_FUCHSIA_CITY` | `maps/FuchsiaCity.asm` | west connection from `ROUTE_18` (offset 7) | warp 5 at (19, 27), then warp 3 at (8, 27) | heal, then gym | +| 17 | `MAP_FUCHSIA_GYM` | `maps/FuchsiaGym.asm` | `FuchsiaCity` warp 3 | warps 1/2 at (4, 17)/(5, 17) | four Janine impostors + Janine, SOULBADGE, TM06 | + +Spill-over note: the walkthrough's own Route 12 pass reaches the west connection +of `MAP_ROUTE_11` and stops at the Vermilion Snorlax. `MAP_VERMILION_CITY` and +the Snorlax event belong to a different section; only the blocker is documented +here (section 3). + +--- + +## 2. Maps + +### MAP_ROUTE_12 + +- Script: `maps/Route12.asm` +- Blocks: `maps/Route12.blk` +- Header: `data/maps/maps.asm:379` -> `map Route12, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_12, MUSIC_ROUTE_12, FALSE, PALETTE_AUTO, FISHGROUP_QWILFISH_NO_SWARM` +- Dimensions: `constants/map_constants.asm:350` -> `map_const ROUTE_12, 10, 27` (10x27 blocks = 20x54 walk cells) +- Attributes/connections: `data/maps/attributes.asm:328-331` -> `map_attributes Route12, ROUTE_12, $43`; north `LavenderTown` (0), south `Route13` (-20), west `Route11` (9) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 33 | `ROUTE_12_SUPER_ROD_HOUSE` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 27 | `BGEVENT_READ` | `Route12Sign` | +| 13 | 9 | `BGEVENT_READ` | `FishingSpotSign` | +| 14 | 13 | `BGEVENT_ITEM` | `Route12HiddenElixer` -> `hiddenitem ELIXER, EVENT_ROUTE_12_HIDDEN_ELIXER` | + +**Object events** (`def_object_events`) - `object_const_def` starts at 2 (`macros/scripts/maps.asm:9`), so `ROUTE12_FISHER1` = 2 ... `ROUTE12_POKE_BALL2` = 7 + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE12_FISHER1` (2) | `SPRITE_FISHER` | 5 | 15 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerFisherMartin` | -1 | +| `ROUTE12_FISHER2` (3) | `SPRITE_FISHER` | 15 | 28 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerFisherStephen` | -1 | +| `ROUTE12_FISHER3` (4) | `SPRITE_FISHER` | 13 | 39 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerFisherBarney` | -1 | +| `ROUTE12_FISHER4` (5) | `SPRITE_FISHER` | 6 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerFisherKyle` | -1 | +| `ROUTE12_POKE_BALL1` (6) | `SPRITE_POKE_BALL` | 5 | 43 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route12Calcium` (`itemball CALCIUM`) | `EVENT_ROUTE_12_CALCIUM` | +| `ROUTE12_POKE_BALL2` (7) | `SPRITE_POKE_BALL` | 5 | 51 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route12Nugget` (`itemball NUGGET`) | `EVENT_ROUTE_12_NUGGET` | + +**Cut trees** (block-level, not objects) + +Scanned `maps/Route12.blk` against the `TILESET_KANTO` rows of +`data/collision/field_move_blocks.asm` (`CutTreeBlockPointers.kanto`), quadrant +taken from `data/tilesets/kanto_collision.asm`: + +| block (bx, by) | block id | cut quadrant | walk cell to face | +|---|---|---|---| +| (3, 22) | `$35` | top-right | (7, 44) | +| (3, 24) | `$33` | bottom-right | (7, 49) | + +The (7, 44) tree is the one guarding the Calcium ball at (5, 43). + +**Scripts of interest** + +- `TrainerFisherKyle` / `TrainerFisherMartin` / `TrainerFisherStephen` / + `TrainerFisherBarney` - each is a bare `trainer FISHER, <NAME>, EVENT_BEAT_*, + <seen>, <beaten>, 0, .Script`; `.Script` is `endifjustbattled / opentext / + writetext <after> / waitbutton / closetext / end`. No flags beyond the beat + event, no items, no movement. +- `Route12Calcium` / `Route12Nugget` - `itemball` rows; the object's own event + flag is what makes them one-shot. +- `Route12HiddenElixer` - `hiddenitem ELIXER, EVENT_ROUTE_12_HIDDEN_ELIXER`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_FISHER_KYLE` | `constants/event_flags.asm` | `TrainerFisherKyle` | trainer already beaten | +| `EVENT_BEAT_FISHER_MARTIN` | `constants/event_flags.asm` | `TrainerFisherMartin` | trainer already beaten | +| `EVENT_BEAT_FISHER_STEPHEN` | `constants/event_flags.asm` | `TrainerFisherStephen` | trainer already beaten | +| `EVENT_BEAT_FISHER_BARNEY` | `constants/event_flags.asm` | `TrainerFisherBarney` | trainer already beaten | +| `EVENT_ROUTE_12_CALCIUM` | `constants/event_flags.asm:1323` | itemball object | ball consumed | +| `EVENT_ROUTE_12_NUGGET` | `constants/event_flags.asm:1324` | itemball object | ball consumed | +| `EVENT_ROUTE_12_HIDDEN_ELIXER` | `constants/event_flags.asm:243` | `Route12HiddenElixer` | hidden item taken | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `CALCIUM` | itemball behind the (7, 44) cut tree | `Route12Calcium` at object (5, 43) | `EVENT_ROUTE_12_CALCIUM` | +| `NUGGET` | itemball at (5, 51) | `Route12Nugget` | `EVENT_ROUTE_12_NUGGET` | +| `ELIXER` | hidden, face (14, 13) | `bg_event 14, 13, BGEVENT_ITEM` | `EVENT_ROUTE_12_HIDDEN_ELIXER` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `KYLE` | `FISHER` | Fisher (4) | L28 Seaking, L31 Poliwhirl, L31 Seaking | `TrainerFisherKyle` | none | +| `MARTIN` | `FISHER` | Fisher (13) | L32 Remoraid, L32 Remoraid | `TrainerFisherMartin` | none | +| `STEPHEN` | `FISHER` | Fisher (14) | L25 Magikarp, L25 Magikarp, L31 Qwilfish, L31 Tentacruel | `TrainerFisherStephen` | none | +| `BARNEY` | `FISHER` | Fisher (15) | L30 Gyarados x3 | `TrainerFisherBarney` | none | + +All four are `TRAINERTYPE_NORMAL` (no custom moves/items). `FISHER` base reward +is 10 (`data/trainers/attributes.asm:223`). + +**Wild encounters** + +- Grass: **none**. `ROUTE_12` has no `def_grass_wildmons` row in + `data/wild/kanto_grass.asm`. +- Water (`data/wild/kanto_water.asm:33`): `def_water_wildmons ROUTE_12`, + 6% rate - L25 Tentacool, L25 Quagsire, L25 Tentacruel (no time split; water + tables are single-slot-set). +- Fishing group `FISHGROUP_QWILFISH_NO_SWARM` (`data/wild/fish.asm:177-193`, + the `.Qwilfish_NoSwarm_*` labels alias `.Qwilfish_*`): + - Old: Magikarp 10 (70%+1), Magikarp 10 (85%+1), Tentacool 10 + - Good: Magikarp 20 (35%), Tentacool 20 (70%), Tentacool 20 (90%+1), `time_group 20` = Tentacool 20 day / Tentacool 20 nite + - Super: Tentacool 40 (40%), `time_group 21` = Tentacool 40, Magikarp 40 (90%+1), Qwilfish 40 + +--- + +### MAP_ROUTE_11 + +- Script: `maps/Route11.asm` +- Blocks: `maps/Route11.blk` +- Header: `data/maps/maps.asm:295` -> `map Route11, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_11, MUSIC_ROUTE_12, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:272` -> `map_const ROUTE_11, 20, 9` (40x18 cells) +- Attributes/connections: `data/maps/attributes.asm:333-336` -> `$0f`; west `VermilionCity` (0), east `Route12` (-9) + +**Warps** (`def_warp_events`) + +None. + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 7 | `BGEVENT_READ` | `Route11Sign` | +| 32 | 5 | `BGEVENT_ITEM` | `Route11HiddenRevive` -> `hiddenitem REVIVE, EVENT_ROUTE_11_HIDDEN_REVIVE` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE11_YOUNGSTER1` (2) | `SPRITE_YOUNGSTER` | 22 | 14 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerYoungsterOwen` | -1 | +| `ROUTE11_YOUNGSTER2` (3) | `SPRITE_YOUNGSTER` | 15 | 9 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerYoungsterJason` | -1 | +| `ROUTE11_YOUNGSTER3` (4) | `SPRITE_YOUNGSTER` | 29 | 7 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerPsychicHerman` | -1 | +| `ROUTE11_YOUNGSTER4` (5) | `SPRITE_YOUNGSTER` | 7 | 4 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerPsychicFidel` | -1 | +| `ROUTE11_FRUIT_TREE` (6) | `SPRITE_FRUIT_TREE` | 32 | 2 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route11FruitTree` (`fruittree FRUITTREE_ROUTE_11`) | -1 | + +Note the two `PSYCHIC_T` trainers wear `SPRITE_YOUNGSTER`, not a psychic sprite. + +**Scripts of interest** + +- `Route11FruitTree` - single `fruittree FRUITTREE_ROUTE_11`. + `data/items/fruit_trees.asm` gives index 24 (`$18`) = `BERRY`. Fruit trees + are day-tracked, not one-shot events. +- `Route11HiddenRevive` - `hiddenitem REVIVE, EVENT_ROUTE_11_HIDDEN_REVIVE`, + faced from cell (32, 5), directly below the fruit tree. +- The four trainer scripts are the plain `endifjustbattled` pattern. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_YOUNGSTER_OWEN` | `constants/event_flags.asm` | `TrainerYoungsterOwen` | trainer beaten | +| `EVENT_BEAT_YOUNGSTER_JASON` | `constants/event_flags.asm` | `TrainerYoungsterJason` | trainer beaten | +| `EVENT_BEAT_PSYCHIC_HERMAN` | `constants/event_flags.asm` | `TrainerPsychicHerman` | trainer beaten | +| `EVENT_BEAT_PSYCHIC_FIDEL` | `constants/event_flags.asm` | `TrainerPsychicFidel` | trainer beaten | +| `EVENT_ROUTE_11_HIDDEN_REVIVE` | `constants/event_flags.asm:245` | `Route11HiddenRevive` | hidden item taken | +| `FRUITTREE_ROUTE_11` | `constants/script_constants.asm:230` | `Route11FruitTree` | fruit tree slot 24 | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BERRY` | talk to the tree object at (32, 2) | `Route11FruitTree` / `data/items/fruit_trees.asm` | none (daily tree, not an event flag) | +| `REVIVE` | hidden, face (32, 5) | `bg_event 32, 5, BGEVENT_ITEM` | `EVENT_ROUTE_11_HIDDEN_REVIVE` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `HERMAN` | `PSYCHIC_T` | Psychic (3) | L30 Exeggcute, L30 Exeggcute, L30 Exeggutor | `TrainerPsychicHerman` | none | +| `OWEN` | `YOUNGSTER` | Youngster (11) | L35 Growlithe | `TrainerYoungsterOwen` | none | +| `JASON` | `YOUNGSTER` | Youngster (12) | L33 Sandslash, L33 Crobat | `TrainerYoungsterJason` | none | +| `FIDEL` | `PSYCHIC_T` | Psychic (4) | L34 Xatu | `TrainerPsychicFidel` | none | + +Base rewards: `YOUNGSTER` 4 (`attributes.asm:133`), `PSYCHIC_T` 8 (`attributes.asm:313`). + +**Wild encounters** + +`data/wild/kanto_grass.asm:728` `def_grass_wildmons ROUTE_11`, 10%/10%/10%. +Morn, day and nite are the same seven slots: +L14 Drowzee, L15 Rattata, L15 Magnemite, L16 Drowzee, L16 Hypno, L16 Hypno, L16 Hypno. + +No water table, no `def_grass_wildmons` time split. The walkthrough's species +list omits Magnemite. + +--- + +### MAP_ROUTE_12_SUPER_ROD_HOUSE + +- Script: `maps/Route12SuperRodHouse.asm` +- Header: `data/maps/maps.asm:391` -> `map Route12SuperRodHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_12, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:362` -> `map_const ROUTE_12_SUPER_ROD_HOUSE, 4, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_12` | 1 | +| 2 | 3 | 7 | `ROUTE_12` | 1 | + +**Coord events** / **BG events** + +None. (`SuperRodHouseBookshelf` exists in the file but is marked +`; unreferenced` - no bg_event points at it.) + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE12SUPERRODHOUSE_FISHING_GURU` (2) | `SPRITE_FISHING_GURU` | 5 | 3 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route12SuperRodHouseFishingGuruScript` | -1 | + +**Scripts of interest** + +`Route12SuperRodHouseFishingGuruScript` (`5d:4dc7` in +`pokegold-symbols/pokegold.sym`): + +``` +faceplayer / opentext +checkevent EVENT_GOT_SUPER_ROD +iftrue .GotSuperRod +writetext OfferSuperRodText +yesorno +iffalse .Refused ; "No" -> DontWantSuperRodText, closetext +writetext GiveSuperRodText +promptbutton +verbosegiveitem SUPER_ROD +iffalse .NoRoom ; bag full -> closetext, nothing set +setevent EVENT_GOT_SUPER_ROD +.GotSuperRod: writetext GaveSuperRodText / waitbutton / closetext / end +``` + +Bot-relevant: the `yesorno` must be answered **Yes**, and the KEY ITEMS pocket +must have room, or `EVENT_GOT_SUPER_ROD` never gets set and the script is +re-runnable. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_SUPER_ROD` | `constants/event_flags.asm:33` | `Route12SuperRodHouseFishingGuruScript` | Super Rod already handed over | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `SUPER_ROD` | talk to guru, answer Yes | `Route12SuperRodHouseFishingGuruScript` | `EVENT_GOT_SUPER_ROD` | + +**Trainers** / **Wild encounters** + +None. + +--- + +### MAP_ROUTE_13 + +- Script: `maps/Route13.asm` +- Blocks: `maps/Route13.blk` +- Header: `data/maps/maps.asm:361` -> `map Route13, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_13, MUSIC_ROUTE_12, FALSE, PALETTE_AUTO, FISHGROUP_QWILFISH_NO_SWARM` +- Dimensions: `constants/map_constants.asm:333` -> `map_const ROUTE_13, 30, 9` (60x18 cells) +- Attributes/connections: `data/maps/attributes.asm:324-326` -> `$43`; north `Route12` (20), south `Route14` (0) + +**Warps** / **Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 29 | 13 | `BGEVENT_READ` | `Route13TrainerTips` ("Look! Right there, at the left side of the post.") | +| 41 | 11 | `BGEVENT_READ` | `Route13Sign` | +| 17 | 13 | `BGEVENT_READ` | `Route13DirectionsSign` | +| 30 | 13 | `BGEVENT_ITEM` | `Route13HiddenCalcium` -> `hiddenitem CALCIUM, EVENT_ROUTE_13_HIDDEN_CALCIUM` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE13_YOUNGSTER1` (2) | `SPRITE_YOUNGSTER` | 42 | 6 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerBirdKeeperPerry` | -1 | +| `ROUTE13_YOUNGSTER2` (3) | `SPRITE_YOUNGSTER` | 43 | 6 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerBirdKeeperBret` | -1 | +| `ROUTE13_POKEFAN_M1` (4) | `SPRITE_POKEFAN_M` | 32 | 8 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerPokefanmJoshua` | -1 | +| `ROUTE13_POKEFAN_M2` (5) | `SPRITE_POKEFAN_M` | 14 | 10 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerHikerKenny` | -1 | +| `ROUTE13_POKEFAN_M3` (6) | `SPRITE_POKEFAN_M` | 25 | 6 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerPokefanmAlex` | -1 | + +Kenny is class `HIKER` but wears `SPRITE_POKEFAN_M`. + +**Cut trees** + +`maps/Route13.blk` block (22, 2) = `$34` (`CUT_TREE` in the top-left quadrant) +-> face walk cell (44, 4). Not mentioned by the walkthrough. + +**Scripts of interest** + +All five trainers use the plain `trainer <CLASS>, <NAME>, EVENT_BEAT_*, ...` + +`endifjustbattled` pattern. `Route13HiddenCalcium` is the only item. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BIRD_KEEPER_PERRY` | `constants/event_flags.asm` | `TrainerBirdKeeperPerry` | trainer beaten | +| `EVENT_BEAT_BIRD_KEEPER_BRET` | `constants/event_flags.asm` | `TrainerBirdKeeperBret` | trainer beaten | +| `EVENT_BEAT_POKEFANM_JOSHUA` | `constants/event_flags.asm` | `TrainerPokefanmJoshua` | trainer beaten | +| `EVENT_BEAT_POKEFANM_ALEX` | `constants/event_flags.asm` | `TrainerPokefanmAlex` | trainer beaten | +| `EVENT_BEAT_HIKER_KENNY` | `constants/event_flags.asm` | `TrainerHikerKenny` | trainer beaten | +| `EVENT_ROUTE_13_HIDDEN_CALCIUM` | `constants/event_flags.asm:244` | `Route13HiddenCalcium` | hidden item taken | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `CALCIUM` | hidden, face (30, 13) (the sign at (29, 13) points at it) | `bg_event 30, 13, BGEVENT_ITEM` | `EVENT_ROUTE_13_HIDDEN_CALCIUM` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `PERRY` | `BIRD_KEEPER` | BirdKeeper (15) | L34 Farfetch'd | `TrainerBirdKeeperPerry` | none | +| `BRET` | `BIRD_KEEPER` | BirdKeeper (16) | L32 Pidgeotto, L32 Fearow | `TrainerBirdKeeperBret` | none | +| `JOSHUA` | `POKEFANM` | PokefanM (4), `TRAINERTYPE_ITEM` | L23 Pikachu x6, each holding `BERRY` | `TrainerPokefanmJoshua` | none | +| `ALEX` | `POKEFANM` | PokefanM (12), `TRAINERTYPE_ITEM` | L29 Nidoking/`BERRY`, L29 Slowking/`BERRY`, L29 Seaking/`BERRY` | `TrainerPokefanmAlex` | none | +| `KENNY` | `HIKER` | Hiker (16) | L27 Sandslash, L29 Graveler, L31 Golem, L29 Graveler | `TrainerHikerKenny` | none | + +Base rewards: `BIRD_KEEPER` 6, `POKEFANM` 20, `HIKER` 8. + +**Wild encounters** + +`data/wild/kanto_grass.asm:756` `def_grass_wildmons ROUTE_13`, 10%/10%/10%, +`_GOLD` arm: + +- morn: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L22 Hoppip, L24 Hoppip, L22 Quagsire, L25 Chansey +- day: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L22 Hoppip, L24 Hoppip, L24 Hoppip, L25 Chansey +- nite: L23 Nidorina, L23 Nidorino, L25 Noctowl, L22 Quagsire, L24 Quagsire, L24 Quagsire, L25 Chansey + +(`_SILVER` swaps the Nidorina/Nidorino order.) + +Water: `data/wild/kanto_water.asm:40` `def_water_wildmons ROUTE_13`, 6% - +L25 Tentacool, L25 Quagsire, L25 Tentacruel. Fishing group +`FISHGROUP_QWILFISH_NO_SWARM` (same table as Route 12). + +--- + +### MAP_ROUTE_14 + +- Script: `maps/Route14.asm` +- Blocks: `maps/Route14.blk` +- Header: `data/maps/maps.asm:362` -> `map Route14, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_14, MUSIC_ROUTE_12, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:334` -> `map_const ROUTE_14, 10, 18` (20x36 cells) +- Attributes/connections: `data/maps/attributes.asm:320-322` -> `$43`; north `Route13` (0), west `Route15` (9) + +**Warps** / **Coord events** / **BG events** + +None (all three tables are empty). + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE14_POKEFAN_M1` (2) | `SPRITE_POKEFAN_M` | 12 | 14 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerPokefanmCarter` | -1 | +| `ROUTE14_YOUNGSTER` (3) | `SPRITE_YOUNGSTER` | 11 | 27 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperRoy` | -1 | +| `ROUTE14_POKEFAN_M2` (4) | `SPRITE_POKEFAN_M` | 5 | 9 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerPokefanmTrevor` | -1 | +| `ROUTE14_KIM` (5) | `SPRITE_TEACHER` | 7 | 5 | `WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT`, sight 4 | `Kim` | -1 | + +**Cut trees** + +From `maps/Route14.blk` against `CutTreeBlockPointers.kanto`: + +| block (bx, by) | block id | cut quadrant | walk cell to face | +|---|---|---|---| +| (2, 4) | `$35` | top-right | (5, 8) | +| (5, 7) | `$35` | top-right | (11, 14) | +| (1, 12) | `$35` | top-right | (3, 24) | + +(5, 8) is directly above Trevor at (5, 9) and opens the pocket that holds Kim. +(11, 14) sits beside Carter at (12, 14). (3, 24) is the tree on the way to the +west connection to Route 15. + +**Scripts of interest** + +- `Kim` - `faceplayer / opentext / trade NPC_TRADE_KIM / waitbutton / closetext / + end`. `NPC_TRADE_KIM` is 5 (`constants/npc_trade_constants.asm:22`); + `data/events/npc_trades.asm:19` -> + `npctrade TRADE_DIALOGSET_NEWBIE, CHANSEY, AERODACTYL, "AEROY", $96, $66, GOLD_BERRY, 26491, "KIM", TRADE_GENDER_EITHER`. + You hand over **Chansey**, you receive **Aerodactyl** nicknamed AEROY holding + `GOLD_BERRY`, OT KIM / ID 26491, DVs `$96`/`$66`. One-shot, tracked in + `wTradeFlags` by trade id, not by an `EVENT_*`. +- The three trainer scripts are the plain pattern. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_POKEFANM_CARTER` | `constants/event_flags.asm` | `TrainerPokefanmCarter` | trainer beaten | +| `EVENT_BEAT_BIRD_KEEPER_ROY` | `constants/event_flags.asm` | `TrainerBirdKeeperRoy` | trainer beaten | +| `EVENT_BEAT_POKEFANM_TREVOR` | `constants/event_flags.asm` | `TrainerPokefanmTrevor` | trainer beaten | +| `NPC_TRADE_KIM` | `constants/npc_trade_constants.asm:22` | `Kim` | trade slot 5, stored in `wTradeFlags` | + +**Items** + +None on this map. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `TREVOR` | `POKEFANM` | PokefanM (6), `TRAINERTYPE_ITEM` | L33 Psyduck/`BERRY` | `TrainerPokefanmTrevor` | none | +| `CARTER` | `POKEFANM` | PokefanM (5), `TRAINERTYPE_ITEM` | L29 Bulbasaur/`BERRY`, L29 Charmander/`BERRY`, L29 Squirtle/`BERRY` | `TrainerPokefanmCarter` | none | +| `ROY` | `BIRD_KEEPER` | BirdKeeper (9) | L29 Fearow, L35 Fearow | `TrainerBirdKeeperRoy` | none | + +**Wild encounters** + +`data/wild/kanto_grass.asm:811` `def_grass_wildmons ROUTE_14`, 10%/10%/10%, +`_GOLD` arm: + +- morn: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L24 Hoppip, L26 Skiploom, L22 Quagsire, L25 Chansey +- day: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L24 Hoppip, L26 Skiploom, L26 Skiploom, L25 Chansey +- nite: L23 Nidorina, L23 Nidorino, L25 Noctowl, L22 Quagsire, L24 Quagsire, L24 Quagsire, L25 Chansey + +No water table for `ROUTE_14`. Fishing group `FISHGROUP_SHORE`. + +--- + +### MAP_ROUTE_15 + +- Script: `maps/Route15.asm` +- Blocks: `maps/Route15.blk` +- Header: `data/maps/maps.asm:363` -> `map Route15, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_15, MUSIC_ROUTE_12, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:335` -> `map_const ROUTE_15, 20, 9` (40x18 cells) +- Attributes/connections: `data/maps/attributes.asm:316-318` -> `$0f`; west `FuchsiaCity` (-9), east `Route14` (-9) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 4 | `ROUTE_15_FUCHSIA_GATE` | 3 | +| 2 | 2 | 5 | `ROUTE_15_FUCHSIA_GATE` | 4 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 19 | 9 | `BGEVENT_READ` | `Route15Sign` (documented lowercase-"Route 15" bug, `maps/Route15.asm:196`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE15_YOUNGSTER1` (2) | `SPRITE_YOUNGSTER` | 11 | 10 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerSchoolboyKipp` | -1 | +| `ROUTE15_YOUNGSTER2` (3) | `SPRITE_YOUNGSTER` | 11 | 11 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerSchoolboyTommy` | -1 | +| `ROUTE15_YOUNGSTER3` (4) | `SPRITE_YOUNGSTER` | 33 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSchoolboyJohnny` | -1 | +| `ROUTE15_YOUNGSTER4` (5) | `SPRITE_YOUNGSTER` | 27 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSchoolboyBilly` | -1 | +| `ROUTE15_TEACHER1` (6) | `SPRITE_TEACHER` | 30 | 12 | `STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerTeacherColette` | -1 | +| `ROUTE15_TEACHER2` (7) | `SPRITE_TEACHER` | 16 | 10 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerTeacherHillary` | -1 | +| `ROUTE15_POKE_BALL` (8) | `SPRITE_POKE_BALL` | 12 | 5 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route15PPUp` (`itemball PP_UP`) | `EVENT_ROUTE_15_PP_UP` | + +**Cut trees** + +None. `maps/Route15.blk` contains no `TILESET_KANTO` cut-tree block +(`$32`/`$33`/`$34`/`$35`/`$60`). See "Unresolved". + +**Scripts of interest** + +All six trainers are the plain pattern. `Route15PPUp` is `itemball PP_UP`. + +Walkthrough fight order maps to: Johnny (33, 10) -> Colette (30, 12) -> +Billy (27, 10) -> Hillary (16, 10) -> Tommy (11, 11) -> Kipp (11, 10), +i.e. east to west along row y=10. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SCHOOLBOY_JOHNNY` | `constants/event_flags.asm` | `TrainerSchoolboyJohnny` | trainer beaten | +| `EVENT_BEAT_TEACHER_COLETTE` | `constants/event_flags.asm` | `TrainerTeacherColette` | trainer beaten | +| `EVENT_BEAT_SCHOOLBOY_BILLY` | `constants/event_flags.asm` | `TrainerSchoolboyBilly` | trainer beaten | +| `EVENT_BEAT_TEACHER_HILLARY` | `constants/event_flags.asm` | `TrainerTeacherHillary` | trainer beaten | +| `EVENT_BEAT_SCHOOLBOY_TOMMY` | `constants/event_flags.asm` | `TrainerSchoolboyTommy` | trainer beaten | +| `EVENT_BEAT_SCHOOLBOY_KIP` | `constants/event_flags.asm` | `TrainerSchoolboyKipp` | trainer beaten (note the flag drops the second "p") | +| `EVENT_ROUTE_15_PP_UP` | `constants/event_flags.asm:1325` | itemball object | ball consumed | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `PP_UP` | itemball at (12, 5) | `Route15PPUp` | `EVENT_ROUTE_15_PP_UP` | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `JOHNNY` | `SCHOOLBOY` | Schoolboy (4) | L29 Bellsprout, L31 Weepinbell, L33 Victreebel | `TrainerSchoolboyJohnny` | none | +| `COLETTE` | `TEACHER` | Teacher (1) | L36 Clefairy | `TrainerTeacherColette` | none | +| `BILLY` | `SCHOOLBOY` | Schoolboy (9) | L27 Paras, L27 Paras, L27 Poliwhirl, L35 Ditto | `TrainerSchoolboyBilly` | none | +| `HILLARY` | `TEACHER` | Teacher (2) | L32 Aipom, L36 Cubone | `TrainerTeacherHillary` | none | +| `TOMMY` | `SCHOOLBOY` | Schoolboy (6) | L32 Xatu, L34 Alakazam | `TrainerSchoolboyTommy` | none | +| `KIPP` | `SCHOOLBOY` | Schoolboy (2) | L27 Voltorb, L27 Magnemite, L31 Voltorb, L31 Magneton | `TrainerSchoolboyKipp` | none | + +Base rewards: `SCHOOLBOY` 8, `TEACHER` 18. + +**Wild encounters** + +`data/wild/kanto_grass.asm:866` `def_grass_wildmons ROUTE_15`, 10%/10%/10%, +`_GOLD` arm: + +- morn: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L22 Hoppip, L24 Hoppip, L22 Quagsire, L25 Chansey +- day: L23 Nidorina, L23 Nidorino, L25 Pidgeotto, L22 Hoppip, L24 Hoppip, L24 Hoppip, L25 Chansey +- nite: L23 Nidorina, L23 Nidorino, L25 Noctowl, L22 Quagsire, L24 Quagsire, L24 Quagsire, L25 Chansey + +--- + +### MAP_ROUTE_15_FUCHSIA_GATE + +- Script: `maps/Route15FuchsiaGate.asm` +- Header: `data/maps/maps.asm:373` -> `map Route15FuchsiaGate, TILESET_GATE, GATE, LANDMARK_ROUTE_15, MUSIC_ROUTE_12, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:345` -> `map_const ROUTE_15_FUCHSIA_GATE, 5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `FUCHSIA_CITY` | 8 | +| 2 | 0 | 5 | `FUCHSIA_CITY` | 9 | +| 3 | 9 | 4 | `ROUTE_15` | 1 | +| 4 | 9 | 5 | `ROUTE_15` | 2 | + +**Coord events** / **BG events** + +None. This gate has **no** bicycle or badge check - it is free passage. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE15FUCHSIAGATE_OFFICER` (2) | `SPRITE_OFFICER` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route15FuchsiaGateOfficerScript` (`jumptextfaceplayer`) | -1 | + +--- + +### MAP_FUCHSIA_CITY + +- Script: `maps/FuchsiaCity.asm` +- Blocks: `maps/FuchsiaCity.blk` +- Header: `data/maps/maps.asm:365` -> `map FuchsiaCity, TILESET_KANTO, TOWN, LANDMARK_FUCHSIA_CITY, MUSIC_CELADON_CITY, FALSE, PALETTE_AUTO, FISHGROUP_GYARADOS` +- Dimensions: `constants/map_constants.asm:337` -> `map_const FUCHSIA_CITY, 20, 18` (40x36 cells) +- Attributes/connections: `data/maps/attributes.asm:291-294` -> `$0f`; south `Route19` (0), west `Route18` (7), east `Route15` (9) +- Fly spawn: `data/maps/spawn_points.asm:23` -> `spawn FUCHSIA_CITY, 19, 28`; `data/maps/flypoints.asm:27` -> `db LANDMARK_FUCHSIA_CITY, SPAWN_FUCHSIA` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 13 | `FUCHSIA_MART` | 2 | +| 2 | 22 | 13 | `SAFARI_ZONE_MAIN_OFFICE` | 1 | +| 3 | 8 | 27 | `FUCHSIA_GYM` | 1 | +| 4 | 11 | 27 | `BILLS_OLDER_SISTERS_HOUSE` | 1 | +| 5 | 19 | 27 | `FUCHSIA_POKECENTER_1F` | 1 | +| 6 | 27 | 27 | `SAFARI_ZONE_WARDENS_HOME` | 1 | +| 7 | 18 | 3 | `SAFARI_ZONE_FUCHSIA_GATE_BETA` | 3 (commented `; inaccessible` in the asm) | +| 8 | 37 | 22 | `ROUTE_15_FUCHSIA_GATE` | 1 | +| 9 | 37 | 23 | `ROUTE_15_FUCHSIA_GATE` | 2 | +| 10 | 7 | 35 | `ROUTE_19_FUCHSIA_GATE` | 1 | +| 11 | 8 | 35 | `ROUTE_19_FUCHSIA_GATE` | 2 | + +Warp 7 is the "the door is gone" Safari Zone entrance: the warp row exists but +the block behind it is not walkable, which is why the walkthrough says the door +is missing. + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 21 | 15 | `BGEVENT_READ` | `FuchsiaCitySign` | +| 5 | 29 | `BGEVENT_READ` | `FuchsiaGymSign` | +| 25 | 15 | `BGEVENT_READ` | `SafariZoneOfficeSign` | +| 27 | 29 | `BGEVENT_READ` | `WardensHomeSign` | +| 17 | 5 | `BGEVENT_READ` | `SafariZoneClosedSign` | +| 13 | 15 | `BGEVENT_READ` | `NoLitteringSign` | +| 20 | 27 | `BGEVENT_READ` | `FuchsiaCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 6 | 13 | `BGEVENT_READ` | `FuchsiaCityMartSign` (`jumpstd MartSignScript`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `FUCHSIACITY_YOUNGSTER` (2) | `SPRITE_YOUNGSTER` | 23 | 18 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `FuchsiaCityYoungster` | -1 | +| `FUCHSIACITY_POKEFAN_M` (3) | `SPRITE_POKEFAN_M` | 13 | 8 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `FuchsiaCityPokefanM` | -1 | +| `FUCHSIACITY_TEACHER` (4) | `SPRITE_TEACHER` | 16 | 14 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `FuchsiaCityTeacher` | -1 | +| `FUCHSIACITY_FRUIT_TREE` (5) | `SPRITE_FRUIT_TREE` | 8 | 1 | `STILL` | `OBJECTTYPE_SCRIPT` | `FuchsiaCityFruitTree` (`fruittree FRUITTREE_FUCHSIA_CITY`) | -1 | + +**Cut trees** + +`maps/FuchsiaCity.blk`: block (8, 5) and block (9, 9), both `$60` (`CUT_TREE` +in the bottom-left quadrant) -> face walk cells (16, 11) and (18, 19). Neither +is required by this section. + +**Scripts of interest** + +- `FuchsiaCityFlypointCallback` (`4e:53c4`) - registered as + `callback MAPCALLBACK_NEWMAP`; body is `setflag ENGINE_FLYPOINT_FUCHSIA / + endcallback`. Runs on the first map load, so simply arriving unlocks Fly here. +- `FuchsiaCityFruitTree` - `fruittree FRUITTREE_FUCHSIA_CITY`. + `data/items/fruit_trees.asm` slot 30 (`$1e`) = `BURNT_BERRY`, which is the + walkthrough's "Burnt Berry in the northwest part of town" - the tree object at + (8, 1) is the north-west corner of the map. +- `SafariZoneClosedSign` / `SafariZoneOfficeSign` - flavour text only; there is + no event flag or script that ever reopens the Safari Zone. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_FUCHSIA` | `constants/engine_flags.asm:76` | `FuchsiaCityFlypointCallback` | Fuchsia becomes a Fly destination | +| `FRUITTREE_FUCHSIA_CITY` | `constants/script_constants.asm:236` | `FuchsiaCityFruitTree` | fruit tree slot 30 | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BURNT_BERRY` | talk to the tree object at (8, 1) | `FuchsiaCityFruitTree` / `data/items/fruit_trees.asm` | none (daily tree) | + +**Trainers** + +None on the overworld map. + +**Wild encounters** + +No grass table. Water: `data/wild/kanto_water.asm:152` +`def_water_wildmons FUCHSIA_CITY`, 2% - L20 Magikarp, L15 Magikarp, L10 Magikarp. +Fishing group `FISHGROUP_GYARADOS` (`data/wild/fish.asm:132-145`): +Old rod Magikarp 10 x3; Good rod Magikarp 20 x3 + `time_group 14` (Gyarados 20); +Super rod Magikarp 40 (40%), `time_group 15` (Gyarados 40), Magikarp 40, Magikarp 40. + +--- + +### MAP_FUCHSIA_POKECENTER_1F + +- Script: `maps/FuchsiaPokecenter1F.asm` +- Header: `data/maps/maps.asm:370` -> `map FuchsiaPokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_FUCHSIA_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:342` -> `map_const FUCHSIA_POKECENTER_1F, 5, 4` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `FUCHSIA_CITY` | 5 | +| 2 | 4 | 7 | `FUCHSIA_CITY` | 5 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +Healing is `FuchsiaPokecenter1FNurseScript` -> `jumpstd PokecenterNurseScript` +(object const `FUCHSIAPOKECENTER1F_NURSE` = 2). The map also contains a +`FUCHSIAPOKECENTER1F_JANINE_IMPERSONATOR` NPC that spins and `variablesprite`s +itself into `SPRITE_JANINE` - flavour, no flags. + +--- + +### MAP_ROUTE_16 + +- Script: `maps/Route16.asm` +- Blocks: `maps/Route16.blk` +- Header: `data/maps/maps.asm:413` -> `map Route16, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_16, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:381` -> `map_const ROUTE_16, 10, 9` (20x18 cells) +- Attributes/connections: `data/maps/attributes.asm:304-306` -> `$0f`; south `Route17` (0), east `CeladonCity` (-9) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 1 | `ROUTE_16_FUCHSIA_SPEECH_HOUSE` | 1 | +| 2 | 14 | 6 | `ROUTE_16_GATE` | 3 | +| 3 | 14 | 7 | `ROUTE_16_GATE` | 4 | +| 4 | 9 | 6 | `ROUTE_16_GATE` | 1 | +| 5 | 9 | 7 | `ROUTE_16_GATE` | 2 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 5 | `BGEVENT_READ` | `CyclingRoadSign` | + +**Object events** + +None (`def_object_events` is empty). + +**Cut trees** + +`maps/Route16.blk` block (7, 2) = `$32` (`CUT_TREE` top-right) -> face walk +cell (15, 4). This is the tree the walkthrough calls "north of the +route-changing building". Cut trees are **not** persisted: leaving the map (for +example into `ROUTE_16_FUCHSIA_SPEECH_HOUSE`) reloads the block from +`Route16.blk` and the tree is back, which is exactly the behaviour the +walkthrough describes. + +**Scripts of interest** + +`Route16AlwaysOnBikeCallback` (`4e:5a8f`), a `MAPCALLBACK_NEWMAP` callback: + +``` +readvar VAR_YCOORD +ifless 5, .CanWalk +readvar VAR_XCOORD +ifgreater 13, .CanWalk +setflag ENGINE_ALWAYS_ON_BIKE +endcallback +.CanWalk: +clearflag ENGINE_ALWAYS_ON_BIKE +endcallback +``` + +So the north-west corner of Route 16 (`y < 5`, or `x > 13`) is walkable; the +rest of the map forces the bike. Because this is a `MAPCALLBACK_NEWMAP`, it only +re-evaluates on a map load, not while walking. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_ALWAYS_ON_BIKE` | `constants/engine_flags.asm:35` | `Route16AlwaysOnBikeCallback` | dismount, Surf and (via `BIKEFLAGS_ALWAYS_ON_BIKE_F`) some menu actions are refused | + +**Wild encounters** + +`data/wild/kanto_grass.asm:921` `def_grass_wildmons ROUTE_16`, 10%/10%/10%: + +- morn/day (identical): L26 Grimer, L27 Fearow, L28 Grimer, L29 Fearow, L27 Slugma, L30 Muk, L30 Muk +- nite: L26 Grimer, L27 Grimer, L28 Grimer, L28 Murkrow, L27 Slugma, L30 Muk, L30 Muk + +The walkthrough's "Fearow / Grimer" summary is the morn/day view; at night +Fearow is replaced by Grimer and Murkrow. + +--- + +### MAP_ROUTE_16_GATE + +- Script: `maps/Route16Gate.asm` +- Header: `data/maps/maps.asm:435` -> `map Route16Gate, TILESET_GATE, GATE, LANDMARK_ROUTE_16, MUSIC_ROUTE_3, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:403` -> `map_const ROUTE_16_GATE, 5, 4` +- Scene variable: `data/maps/scenes.asm:18` -> `scene_var ROUTE_16_GATE, wRoute16GateSceneID` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ROUTE_16` | 4 | +| 2 | 0 | 5 | `ROUTE_16` | 5 | +| 3 | 9 | 4 | `ROUTE_16` | 2 | +| 4 | 9 | 5 | `ROUTE_16` | 3 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE16GATE_BICYCLE_CHECK` (= 0) | 5 | 4 | `Route16GateBicycleCheck` | `checkitem BICYCLE`; on failure, shove the player one step RIGHT | +| `SCENE_ROUTE16GATE_BICYCLE_CHECK` (= 0) | 5 | 5 | `Route16GateBicycleCheck` | same | + +`SCENE_ROUTE16GATE_BICYCLE_CHECK` is generated by the `scene_script` macro +(`macros/scripts/maps.asm:25`, which emits `scene_const` into a `const_def` +starting at 0), so its value is 0. Nothing anywhere calls `setmapscene` on this +map, so the scene never changes and the coord event fires on **every** pass. + +**BG events** + +None. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE16GATE_OFFICER` (2) | `SPRITE_OFFICER` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route16GateOfficerScript` | -1 | + +**Scripts of interest** + +`Route16GateBicycleCheck` (`5e:67cb`): + +``` +checkitem BICYCLE +iffalse .NoBicycle +end +.NoBicycle: +showemote EMOTE_SHOCK, ROUTE16GATE_OFFICER, 15 +turnobject PLAYER, UP +opentext / writetext Route16GateCannotPassText / waitbutton / closetext +applymovement PLAYER, Route16GateCannotPassMovement ; step RIGHT, turn_head LEFT +end +``` + +`checkitem` is a **bag** check, not a "is the bike out" check: the BICYCLE must +be in the pack, but the player does not have to be riding it to pass. + +--- + +### MAP_ROUTE_16_FUCHSIA_SPEECH_HOUSE + +- Script: `maps/Route16FuchsiaSpeechHouse.asm` +- Header: `data/maps/maps.asm:434` -> `map Route16FuchsiaSpeechHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_16, MUSIC_CELADON_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:402` -> `map_const ROUTE_16_FUCHSIA_SPEECH_HOUSE, 4, 4` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_16` | 1 | +| 2 | 3 | 7 | `ROUTE_16` | 1 | + +**BG events**: (0, 1) and (1, 1), both `BGEVENT_READ` -> +`Route16FuchsiaSpeechHouseBookshelf` (`jumpstd PictureBookshelfScript`). + +**Object events**: `ROUTE16FUCHSIASPEECHHOUSE_SUPER_NERD` (2), +`SPRITE_SUPER_NERD` at (2, 3), `STANDING_DOWN`, `OBJECTTYPE_SCRIPT`, +`Route16FuchsiaSpeechHouseSuperNerdScript` (text only). + +This is the building whose entry/exit regrows the Route 16 cut tree. + +--- + +### MAP_ROUTE_17 + +- Script: `maps/Route17.asm` +- Blocks: `maps/Route17.blk` +- Header: `data/maps/maps.asm:414` -> `map Route17, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_17, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_NONE` +- Dimensions: `constants/map_constants.asm:382` -> `map_const ROUTE_17, 10, 45` (20x90 cells) +- Attributes/connections: `data/maps/attributes.asm:300-302` -> `$43`; north `Route16` (0), east `Route18` (38) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 82 | `ROUTE_17_ROUTE_18_GATE` | 1 | +| 2 | 17 | 83 | `ROUTE_17_ROUTE_18_GATE` | 2 | + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 12 | 5 | `BGEVENT_ITEM` | `Route17HiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_ROUTE_17_HIDDEN_MAX_ETHER` | +| 8 | 77 | `BGEVENT_ITEM` | `Route17HiddenMaxElixer` -> `hiddenitem MAX_ELIXER, EVENT_ROUTE_17_HIDDEN_MAX_ELIXER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE17_BIKER1` (2) | `SPRITE_BIKER` | 4 | 17 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerBikerRiley` | -1 | +| `ROUTE17_BIKER2` (3) | `SPRITE_BIKER` | 16 | 32 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBikerJoel` | -1 | +| `ROUTE17_BIKER3` (4) | `SPRITE_BIKER` | 3 | 53 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBikerGlenn` | -1 | +| `ROUTE17_BIKER4` (5) | `SPRITE_BIKER` | 6 | 80 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerBikerCharles` | -1 | + +Note the y-order: Riley (17) -> Joel (32) -> Glenn (53) -> Charles (80). The +walkthrough fights Riley, Glenn, Joel, Charles; Joel at (16, 32) is on the right +bank (the walkthrough's "bridge across the river to the right") and Glenn at +(3, 53) is further down the left, so the asm ordering by y is +Riley -> Joel -> Glenn -> Charles. + +**Scripts of interest** + +`Route17AlwaysOnBikeCallback` (`4e:5822`), a `MAPCALLBACK_NEWMAP` callback: + +``` +setflag ENGINE_ALWAYS_ON_BIKE +setflag ENGINE_DOWNHILL +endcallback +``` + +Unconditional for the whole map. `ENGINE_DOWNHILL` is what +`engine/overworld/player_movement.asm:13` `.GetDPad` reads (the `bit +BIKEFLAGS_DOWNHILL_F, [hl]` at line 20): on a downhill map +a frame with **no** d-pad held is rewritten to `PAD_DOWN`, i.e. the bike coasts +south on its own; and `player_movement.asm:277-292` downgrades any non-DOWN bike +step from `STEP_BIKE` to `STEP_WALK`, which is the "uphill is slower" behaviour. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_ALWAYS_ON_BIKE` | `constants/engine_flags.asm:35` | `Route17AlwaysOnBikeCallback` | cannot dismount, cannot Surf | +| `ENGINE_DOWNHILL` | `constants/engine_flags.asm:36` | `Route17AlwaysOnBikeCallback` | idle frames auto-step DOWN | +| `EVENT_BEAT_BIKER_RILEY` / `_JOEL` / `_GLENN` / `_CHARLES` | `constants/event_flags.asm` | the four trainer scripts | trainers beaten | +| `EVENT_ROUTE_17_HIDDEN_MAX_ETHER` | `constants/event_flags.asm:246` | `Route17HiddenMaxEther` | hidden item taken | +| `EVENT_ROUTE_17_HIDDEN_MAX_ELIXER` | `constants/event_flags.asm:247` | `Route17HiddenMaxElixer` | hidden item taken | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_ETHER` | hidden, face (12, 5) | `bg_event 12, 5, BGEVENT_ITEM` | `EVENT_ROUTE_17_HIDDEN_MAX_ETHER` | +| `MAX_ELIXER` | hidden, face (8, 77) | `bg_event 8, 77, BGEVENT_ITEM` | `EVENT_ROUTE_17_HIDDEN_MAX_ELIXER` | + +Neither is mentioned by the walkthrough. + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `RILEY` | `BIKER` | Biker (7) | L34 Weezing | `TrainerBikerRiley` | none | +| `GLENN` | `BIKER` | Biker (9) | L28 Koffing, L30 Magmar, L32 Weezing | `TrainerBikerGlenn` | none | +| `JOEL` | `BIKER` | Biker (8) | L32 Magmar, L32 Magmar | `TrainerBikerJoel` | none | +| `CHARLES` | `BIKER` | Biker (6) | L30 Koffing, L30 Charmeleon, L30 Weezing | `TrainerBikerCharles` | none | + +`BIKER` base reward 8 (`attributes.asm:271`). + +**Wild encounters** + +`data/wild/kanto_grass.asm:949` `def_grass_wildmons ROUTE_17`, 10%/10%/10% +(this route's three time bands genuinely differ): + +- morn: L28 Fearow, L27 Grimer, L29 Grimer, L30 Fearow, L29 Slugma, L32 Muk, L32 Muk +- day: L28 Fearow, L27 Slugma, L29 Grimer, L30 Fearow, L25 Slugma, L32 Muk, L32 Muk +- nite: L28 Grimer, L27 Grimer, L29 Grimer, L30 Muk, L29 Slugma, L32 Muk, L32 Muk + +Fishing group is `FISHGROUP_NONE`. + +--- + +### MAP_ROUTE_17_ROUTE_18_GATE + +- Script: `maps/Route17Route18Gate.asm` +- Header: `data/maps/maps.asm:437` -> `map Route17Route18Gate, TILESET_GATE, GATE, LANDMARK_ROUTE_17, MUSIC_ROUTE_3, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:405` -> `map_const ROUTE_17_ROUTE_18_GATE, 5, 4` +- Scene variable: `data/maps/scenes.asm:19` -> `scene_var ROUTE_17_ROUTE_18_GATE, wRoute17Route18GateSceneID` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 4 | `ROUTE_17` | 1 | +| 2 | 0 | 5 | `ROUTE_17` | 2 | +| 3 | 9 | 4 | `ROUTE_18` | 1 | +| 4 | 9 | 5 | `ROUTE_18` | 2 | + +**Coord events** (`def_coord_events`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ROUTE17ROUTE18GATE_BICYCLE_CHECK` (= 0) | 5 | 4 | `Route17Route18GateBicycleCheck` | `checkitem BICYCLE`; on failure, shove one step RIGHT | +| `SCENE_ROUTE17ROUTE18GATE_BICYCLE_CHECK` (= 0) | 5 | 5 | `Route17Route18GateBicycleCheck` | same | + +**BG events** + +None. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE17ROUTE18GATE_OFFICER` (2) | `SPRITE_OFFICER` | 5 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Route17Route18GateOfficerScript` | -1 | + +`Route17Route18GateBicycleCheck` (`5e:69ef`) is byte-for-byte the same shape as +the Route 16 one, with `Route17Route18GateCannotPassText`. + +Important asymmetry for a bot: the shove movement is `step RIGHT`, so the check +only actually blocks the **west-to-east** direction (walking off Route 17 into +the gate at x=5). Coming from Route 18 you enter at x=9 and the coord event at +x=5 still fires, but the shove pushes you further right, back the way you came. + +--- + +### MAP_ROUTE_18 + +- Script: `maps/Route18.asm` +- Blocks: `maps/Route18.blk` +- Header: `data/maps/maps.asm:364` -> `map Route18, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_18, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:336` -> `map_const ROUTE_18, 10, 9` (20x18 cells) +- Attributes/connections: `data/maps/attributes.asm:296-298` -> `$43`; west `Route17` (-38), east `FuchsiaCity` (-7) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 6 | `ROUTE_17_ROUTE_18_GATE` | 3 | +| 2 | 2 | 7 | `ROUTE_17_ROUTE_18_GATE` | 4 | + +**Coord events** + +None. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 5 | `BGEVENT_READ` | `Route18Sign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE18_YOUNGSTER1` (2) | `SPRITE_YOUNGSTER` | 9 | 12 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperBoris` | -1 | +| `ROUTE18_YOUNGSTER2` (3) | `SPRITE_YOUNGSTER` | 13 | 6 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperBob` | -1 | + +**Trainers** + +| const | class | id | party | script label | rematch/phone | +|---|---|---|---|---|---| +| `BOB` | `BIRD_KEEPER` | BirdKeeper (11) | L34 Noctowl | `TrainerBirdKeeperBob` | none | +| `BORIS` | `BIRD_KEEPER` | BirdKeeper (10) | L30 Doduo, L28 Doduo, L32 Dodrio | `TrainerBirdKeeperBoris` | none | + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BIRD_KEEPER_BOB` | `constants/event_flags.asm` | `TrainerBirdKeeperBob` | trainer beaten | +| `EVENT_BEAT_BIRD_KEEPER_BORIS` | `constants/event_flags.asm` | `TrainerBirdKeeperBoris` | trainer beaten | + +**Items** + +None. + +**Wild encounters** + +`data/wild/kanto_grass.asm:977` `def_grass_wildmons ROUTE_18`, 10%/10%/10%: + +- morn/day (identical): L26 Grimer, L27 Fearow, L28 Grimer, L29 Fearow, L27 Slugma, L30 Muk, L30 Muk +- nite: L26 Grimer, L27 Grimer, L28 Grimer, L28 Muk, L27 Slugma, L30 Muk, L30 Muk + +--- + +### MAP_FUCHSIA_GYM + +- Script: `maps/FuchsiaGym.asm` +- Blocks: `maps/FuchsiaGym.blk` +- Header: `data/maps/maps.asm:368` -> `map FuchsiaGym, TILESET_LAB, INDOOR, LANDMARK_FUCHSIA_CITY, MUSIC_GYM, TRUE, PALETTE_DAY, FISHGROUP_SHORE` (the `TRUE` is the phone-service flag: phone calls are suppressed here) +- Dimensions: `constants/map_constants.asm:340` -> `map_const FUCHSIA_GYM, 5, 9` (10x18 cells) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `FUCHSIA_CITY` | 3 | +| 2 | 5 | 17 | `FUCHSIA_CITY` | 3 | + +**Coord events** + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 15 | `BGEVENT_READ` | `FuchsiaGymStatue` | +| 6 | 15 | `BGEVENT_READ` | `FuchsiaGymStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `FUCHSIAGYM_JANINE` (2) | `SPRITE_JANINE` | 1 | 10 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `FuchsiaGymJanineScript` | -1 | +| `FUCHSIAGYM_FUCHSIA_GYM_1` (3) | `SPRITE_FUCHSIA_GYM_1` | 5 | 7 | `SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | `LassAliceScript` | -1 | +| `FUCHSIAGYM_FUCHSIA_GYM_2` (4) | `SPRITE_FUCHSIA_GYM_2` | 5 | 11 | `SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | `LassLindaScript` | -1 | +| `FUCHSIAGYM_FUCHSIA_GYM_3` (5) | `SPRITE_FUCHSIA_GYM_3` | 9 | 4 | `SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | `PicnickerCindyScript` | -1 | +| `FUCHSIAGYM_FUCHSIA_GYM_4` (6) | `SPRITE_FUCHSIA_GYM_4` | 4 | 2 | `SPINRANDOM_FAST` | `OBJECTTYPE_SCRIPT` | `CamperBarryScript` | -1 | +| `FUCHSIAGYM_GYM_GUIDE` (7) | `SPRITE_GYM_GUIDE` | 7 | 15 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `FuchsiaGymGuideScript` | -1 | + +Every gym trainer here is `OBJECTTYPE_SCRIPT`, **not** `OBJECTTYPE_TRAINER` - +there are no sight lines, so a bot must walk up and press A. The walkthrough's +route order matches: Linda (5, 11) -> Cindy (9, 4) -> Barry (4, 2) -> +Alice (5, 7) -> Janine (1, 10). + +**Scripts of interest** + +`LassAliceScript` / `LassLindaScript` / `PicnickerCindyScript` / +`CamperBarryScript` share one shape: + +``` +checkevent EVENT_BEAT_<NAME> +iftrue .Unmasked +applymovement <OBJ>, Movement_NinjaSpin ; 13 turn_head steps +faceplayer +variablesprite SPRITE_FUCHSIA_GYM_n, SPRITE_LASS|SPRITE_YOUNGSTER +special LoadUsedSpritesGFX +.Unmasked: +faceplayer / opentext +checkevent EVENT_BEAT_<NAME> +iftrue .AfterScript +writetext <before> / waitbutton / closetext +winlosstext <beaten>, 0 +loadtrainer <CLASS>, <NAME> +startbattle +iftrue .BecomesJanine ; wScriptVar != WIN +reloadmapafterbattle +setevent EVENT_BEAT_<NAME> +end +.BecomesJanine: +variablesprite SPRITE_FUCHSIA_GYM_n, SPRITE_JANINE +reloadmapafterbattle +end +``` + +`Script_startbattle` (`engine/overworld/scripting.asm:1065`) copies +`wBattleResult & ~BATTLERESULT_BITMASK` into `wScriptVar`, so `iftrue` after +`startbattle` means "did not win". Losing to an impostor leaves its beat-event +clear and repaints it as `SPRITE_JANINE`. + +`FuchsiaGymJanineScript` (`5c:40d3`): + +``` +checkflag ENGINE_SOULBADGE +iftrue .FightDone +applymovement FUCHSIAGYM_JANINE, Movement_NinjaSpin +faceplayer / opentext +writetext JanineText_DisappointYou / waitbutton / closetext +winlosstext JanineText_ToughOne, 0 +loadtrainer JANINE, JANINE1 +startbattle +reloadmapafterbattle +setevent EVENT_BEAT_JANINE +setevent EVENT_BEAT_LASS_ALICE +setevent EVENT_BEAT_LASS_LINDA +setevent EVENT_BEAT_PICNICKER_CINDY +setevent EVENT_BEAT_CAMPER_BARRY +variablesprite SPRITE_FUCHSIA_GYM_1..3 -> SPRITE_LASS, _4 -> SPRITE_YOUNGSTER +special LoadUsedSpritesGFX +opentext / writetext Text_ReceivedSoulBadge / playsound SFX_GET_BADGE / waitsfx +setflag ENGINE_SOULBADGE +sjump .AfterBattle +.FightDone: faceplayer / opentext +.AfterBattle: +checkevent EVENT_GOT_TM06_TOXIC +iftrue .AfterTM +writetext JanineText_ToxicSpeech / promptbutton +verbosegiveitem TM_TOXIC +iffalse .AfterTM ; bag full -> no event set +setevent EVENT_GOT_TM06_TOXIC +.AfterTM: writetext JanineText_ApplyMyself / waitbutton / closetext / end +``` + +Three bot-critical facts: + +1. There is **no** `iftrue` after Janine's `startbattle`. The badge, the four + impostor beat-events and `ENGINE_SOULBADGE` are set unconditionally on the + line after `reloadmapafterbattle`. Losing to Janine routes through + `Script_reloadmapafterbattle` -> `Script_BattleWhiteout` + (`engine/overworld/scripting.asm:1080-1090`) and the script never reaches + those lines, so the flags are only ever set on a win. +2. Beating Janine retroactively sets `EVENT_BEAT_LASS_ALICE`, + `EVENT_BEAT_LASS_LINDA`, `EVENT_BEAT_PICNICKER_CINDY` and + `EVENT_BEAT_CAMPER_BARRY`, so the four impostors can be skipped entirely. +3. `TM_TOXIC` is handed out on a **separate** `checkevent` from the badge. If + the TM pocket is full the badge is still awarded and the TM is retried the + next time you talk to her. + +`FuchsiaGymStatue` - `checkflag ENGINE_SOULBADGE / iftrue .Beaten / +jumpstd GymStatue1Script`, `.Beaten` does `gettrainername STRING_BUFFER_4, +JANINE, JANINE1 / jumpstd GymStatue2Script`. + +`FuchsiaGymGuideScript` - `checkevent EVENT_BEAT_JANINE` picks between the two +guide texts. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_SOULBADGE` | `constants/engine_flags.asm:51`; storage `data/events/engine_flags.asm:59` (`engine_flag wKantoBadges, SOULBADGE`) | `FuchsiaGymJanineScript`, `FuchsiaGymStatue` | Kanto badge 5; also the "already beat this gym" gate | +| `EVENT_BEAT_JANINE` | `constants/event_flags.asm:719` | `FuchsiaGymJanineScript`, `FuchsiaGymGuideScript` | leader beaten | +| `EVENT_BEAT_LASS_ALICE` | `constants/event_flags.asm:804` | `LassAliceScript`, `FuchsiaGymJanineScript` | impostor beaten (or skipped via Janine) | +| `EVENT_BEAT_LASS_LINDA` | `constants/event_flags.asm:807` | `LassLindaScript`, `FuchsiaGymJanineScript` | impostor beaten | +| `EVENT_BEAT_PICNICKER_CINDY` | `constants/event_flags.asm:640` | `PicnickerCindyScript`, `FuchsiaGymJanineScript` | impostor beaten | +| `EVENT_BEAT_CAMPER_BARRY` | `constants/event_flags.asm:532` | `CamperBarryScript`, `FuchsiaGymJanineScript` | impostor beaten | +| `EVENT_GOT_TM06_TOXIC` | `constants/event_flags.asm:220` | `FuchsiaGymJanineScript` | TM06 already collected | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_TOXIC` (TM06, item `$c5`) | `verbosegiveitem` after beating Janine | `FuchsiaGymJanineScript` | `EVENT_GOT_TM06_TOXIC` | +| SOULBADGE | `setflag ENGINE_SOULBADGE` | `FuchsiaGymJanineScript` | `ENGINE_SOULBADGE` itself | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `LINDA` | `LASS` | Lass (6) | L30 Bulbasaur, L32 Ivysaur, L34 Venusaur | `LassLindaScript` | none | +| `CINDY` | `PICNICKER` | Picnicker (5) | L36 Nidoqueen | `PicnickerCindyScript` | none | +| `BARRY` | `CAMPER` | Camper (5) | L36 Nidoking | `CamperBarryScript` | none | +| `ALICE` | `LASS` | Lass (3) | L30 Gloom, L34 Arbok, L30 Gloom | `LassAliceScript` | none | +| `JANINE1` | `JANINE` | Janine (1), `TRAINERTYPE_MOVES` | see below | `FuchsiaGymJanineScript` | none (`KantoGymLeaders` entry, `data/trainers/leaders.asm`) | + +Janine's party in **ROM order** (`data/trainers/parties.asm`, Janine (1)): + +| # | level | species | moves | +|---|---|---|---| +| 1 | 36 | Crobat | Screech, Supersonic, Confuse Ray, Wing Attack | +| 2 | 36 | Weezing | Smog, Sludge Bomb, Toxic, Explosion | +| 3 | 36 | Weezing | Smog, Sludge Bomb, Toxic, Explosion | +| 4 | 33 | Ariados | Scary Face, Giga Drain, String Shot, Night Shade | +| 5 | 39 | Venomoth | Foresight, Double Team, Gust, Psychic | + +Base reward 25 (`data/trainers/attributes.asm:157`), last mon level 39 -> +4 x 25 x 39 = 3900G, matching the walkthrough. + +**Wild encounters** + +None (indoor). + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Cut trees on Routes 12 (cells (7,44), (7,49)), 13 ((44,4)), 14 ((5,8), (11,14), (3,24)), 16 ((15,4)), Fuchsia ((16,11), (18,19)) | `engine/events/overworld.asm:117` `CutFunction.CheckAble` (menu use, `03:47e1`) and `engine/events/overworld.asm:1741` `TryCutOW` (walk-into prompt, `03:5193`) | `CheckBadge ENGINE_HIVEBADGE` **and** a party member knowing `CUT` **and** `CheckMapForSomethingToCut` (facing collision `COLL_CUT_TREE` + block id in `CutTreeBlockPointers`) | Hive Badge, already held long before this section. Cut is not persisted: the block reverts on map reload. | +| Route 16 Gate, west-to-east | `maps/Route16Gate.asm:16` `Route16GateBicycleCheck` (`5e:67cb`), coord events at (5,4)/(5,5), scene 0 | `checkitem BICYCLE` (bag, not "riding") | Own the BICYCLE. There is no `setmapscene` for this map, so the check is permanent, not a one-shot cutscene. | +| Route 17/18 Gate | `maps/Route17Route18Gate.asm:16` `Route17Route18GateBicycleCheck` (`5e:69ef`), coord events at (5,4)/(5,5), scene 0 | `checkitem BICYCLE` | Own the BICYCLE | +| Route 17 forced bike / auto-scroll south | `maps/Route17.asm:13` `Route17AlwaysOnBikeCallback` sets `ENGINE_ALWAYS_ON_BIKE` + `ENGINE_DOWNHILL`; enforced by `engine/overworld/map_setup.asm:112` `.CheckForcedBiking` (forces `PLAYER_BIKE`), `engine/events/overworld.asm:1634` `.GetOffBike` (refuses dismount), `engine/events/overworld.asm:343` / `:498` (refuses Surf), `engine/overworld/player_movement.asm:13` `.GetDPad` (idle frame -> `PAD_DOWN`) | none - it is unconditional on this map | Leave the map | +| Route 16 partial forced bike | `maps/Route16.asm:7` `Route16AlwaysOnBikeCallback` | `VAR_YCOORD < 5` or `VAR_XCOORD > 13` clears the flag; anywhere else sets it | Only re-evaluated on `MAPCALLBACK_NEWMAP` | +| Snorlax at the Route 11 west connection | `maps/VermilionCity.asm:41` `VermilionSnorlax`, object at Vermilion City (34, 8), `EVENT_VERMILION_CITY_SNORLAX` | `special SnorlaxAwake` must return true (Pokegear radio Expn card tuned to the Poke Flute channel) before the L50 Snorlax battle triggers; otherwise only `VermilionCitySnorlaxSleepingText` | `EVENT_FOUGHT_SNORLAX` + `disappear VERMILIONCITY_BIG_SNORLAX`. Belongs to a later section; nothing in section 23 clears it. | +| Safari Zone | none - `maps/FuchsiaCity.asm` warp 7 at (18, 3) is commented `; inaccessible` and no script or flag ever opens it | n/a | never opens in Gold/Silver | + +Nothing in this section gates on `ENGINE_SOULBADGE` itself; the badge is the +output, not a prerequisite. + +--- + +## 4. Bot checklist + +Coordinates are walk cells, the same units the asm tables use. "Object N" means +the `object_const_def` index in that map (starting at 2). + +1. `MAP_LAVENDER_TOWN` -> Fly / walk south into `MAP_ROUTE_12` (north connection, + offset 0). Pre: `ENGINE_FLYPOINT_LAVENDER`. Post: none. +2. `MAP_ROUTE_12`, walk to (6, 6) area, talk to object 5 (`TrainerFisherKyle`). + Pre: `EVENT_BEAT_FISHER_KYLE` clear. Post: `EVENT_BEAT_FISHER_KYLE`. +3. `MAP_ROUTE_12`, object 2 at (5, 15) (`TrainerFisherMartin`), sight range 3 + facing DOWN - a bot walking south past x=5 will be challenged automatically. + Post: `EVENT_BEAT_FISHER_MARTIN`. +4. Walk west off `MAP_ROUTE_12` -> `MAP_ROUTE_11` (offset 9). +5. `MAP_ROUTE_11`, object 4 at (29, 7) (`TrainerPsychicHerman`), sight 1. + Post: `EVENT_BEAT_PSYCHIC_HERMAN`. +6. `MAP_ROUTE_11`, object 2 at (22, 14) (`TrainerYoungsterOwen`), sight 3, + `SPINRANDOM_FAST`. Post: `EVENT_BEAT_YOUNGSTER_OWEN`. +7. `MAP_ROUTE_11`, object 3 at (15, 9) (`TrainerYoungsterJason`), sight 2. + Post: `EVENT_BEAT_YOUNGSTER_JASON`. +8. `MAP_ROUTE_11`, talk to object 6 at (32, 2) -> `BERRY`. Pre: bag room. + Post: fruit tree slot 24 marked used for the day. +9. `MAP_ROUTE_11`, stand at (32, 6) facing UP (or use ITEMFINDER) and press A on + (32, 5) -> `REVIVE`. Post: `EVENT_ROUTE_11_HIDDEN_REVIVE`. +10. `MAP_ROUTE_11`, object 5 at (7, 4) (`TrainerPsychicFidel`), sight 3 facing + LEFT. Post: `EVENT_BEAT_PSYCHIC_FIDEL`. +11. Do **not** continue west - the Vermilion Snorlax blocks it. Walk back east + into `MAP_ROUTE_12`. +12. `MAP_ROUTE_12`, object 3 at (15, 28) (`TrainerFisherStephen`), sight 1 + facing UP. Post: `EVENT_BEAT_FISHER_STEPHEN`. +13. `MAP_ROUTE_12`, step on warp 1 at (11, 33) -> `MAP_ROUTE_12_SUPER_ROD_HOUSE`. +14. `MAP_ROUTE_12_SUPER_ROD_HOUSE`, talk to object 2 at (5, 3), answer **Yes** + to the `yesorno`. Pre: `EVENT_GOT_SUPER_ROD` clear, KEY ITEMS room. + Post: `EVENT_GOT_SUPER_ROD`, `SUPER_ROD` in bag. +15. Exit via warp 1/2 at (2, 7)/(3, 7) -> `MAP_ROUTE_12` warp 1. +16. `MAP_ROUTE_12`, object 4 at (13, 39) (`TrainerFisherBarney`), sight 3 facing + LEFT. Post: `EVENT_BEAT_FISHER_BARNEY`. +17. `MAP_ROUTE_12`, face walk cell (7, 44) and use CUT. Pre: `ENGINE_HIVEBADGE` + + party CUT. Post: block `$35` at block (3, 22) swapped for `$4c` (not + persisted). +18. `MAP_ROUTE_12`, walk onto object 6 at (5, 43) -> `CALCIUM`. + Post: `EVENT_ROUTE_12_CALCIUM`. +19. Optional: object 7 at (5, 51) -> `NUGGET` (`EVENT_ROUTE_12_NUGGET`); + hidden `ELIXER` facing (14, 13) (`EVENT_ROUTE_12_HIDDEN_ELIXER`). +20. Walk south off `MAP_ROUTE_12` -> `MAP_ROUTE_13` (offset -20). +21. `MAP_ROUTE_13`, object 2 at (42, 6) (`TrainerBirdKeeperPerry`), sight 2. + Post: `EVENT_BEAT_BIRD_KEEPER_PERRY`. +22. `MAP_ROUTE_13`, object 3 at (43, 6) (`TrainerBirdKeeperBret`), sight 2. + Post: `EVENT_BEAT_BIRD_KEEPER_BRET`. +23. `MAP_ROUTE_13`, object 4 at (32, 8) (`TrainerPokefanmJoshua`), sight 3 + facing LEFT (six L23 Pikachu). Post: `EVENT_BEAT_POKEFANM_JOSHUA`. +24. Optional: hidden `CALCIUM` facing (30, 13) (`EVENT_ROUTE_13_HIDDEN_CALCIUM`). +25. `MAP_ROUTE_13`, object 6 at (25, 6) (`TrainerPokefanmAlex`), sight 4 facing + RIGHT. Post: `EVENT_BEAT_POKEFANM_ALEX`. +26. `MAP_ROUTE_13`, object 5 at (14, 10) (`TrainerHikerKenny`), sight 4 facing + LEFT. Post: `EVENT_BEAT_HIKER_KENNY`. +27. Walk south off `MAP_ROUTE_13` -> `MAP_ROUTE_14` (offset 0). +28. `MAP_ROUTE_14`, face (5, 8) and use CUT, then talk to object 4 at (5, 9) + (`TrainerPokefanmTrevor`), sight 4 facing RIGHT. + Post: `EVENT_BEAT_POKEFANM_TREVOR`. +29. `MAP_ROUTE_14`, talk to object 5 (`Kim`, patrols around (7, 5)). + Pre: a Chansey in the party, trade 5 unset in `wTradeFlags`. + Post: Chansey -> Aerodactyl "AEROY" holding `GOLD_BERRY`, OT KIM / 26491. +30. `MAP_ROUTE_14`, face (11, 14) and CUT, then object 2 at (12, 14) + (`TrainerPokefanmCarter`), sight 4 facing RIGHT. + Post: `EVENT_BEAT_POKEFANM_CARTER`. +31. `MAP_ROUTE_14`, object 3 at (11, 27) (`TrainerBirdKeeperRoy`), sight 3. + Post: `EVENT_BEAT_BIRD_KEEPER_ROY`. +32. `MAP_ROUTE_14`, face (3, 24) and CUT, then walk west off the map -> + `MAP_ROUTE_15` (offset 9). +33. `MAP_ROUTE_15`, walk onto object 8 at (12, 5) -> `PP_UP`. + Post: `EVENT_ROUTE_15_PP_UP`. +34. `MAP_ROUTE_15`, fight east-to-west along y=10: + object 4 (33, 10) Johnny, object 6 (30, 12) Colette, object 5 (27, 10) + Billy, object 7 (16, 10) Hillary, object 3 (11, 11) Tommy, + object 2 (11, 10) Kipp. Post: the six `EVENT_BEAT_*` flags. +35. `MAP_ROUTE_15`, warp 1/2 at (2, 4)/(2, 5) -> `MAP_ROUTE_15_FUCHSIA_GATE` + warps 3/4; then gate warps 1/2 at (0, 4)/(0, 5) -> `MAP_FUCHSIA_CITY` + warps 8/9 at (37, 22)/(37, 23). Post (on map load): + `ENGINE_FLYPOINT_FUCHSIA` set by `FuchsiaCityFlypointCallback`. +36. `MAP_FUCHSIA_CITY`, talk to object 5 at (8, 1) -> `BURNT_BERRY`. +37. `MAP_FUCHSIA_CITY`, warp 5 at (19, 27) -> `MAP_FUCHSIA_POKECENTER_1F`, talk + to object 2 to heal, exit via warp 1/2. +38. Optional Cycling Road detour. Fly to Celadon, walk west into `MAP_ROUTE_16`. + Pre: `BICYCLE` in bag. `ENGINE_ALWAYS_ON_BIKE` is set on load unless the + landing cell has `y < 5` or `x > 13`. +39. `MAP_ROUTE_16`, optional: face (15, 4) and CUT (regrows on any map reload). +40. `MAP_ROUTE_16`, warp 2/3 at (14, 6)/(14, 7) -> `MAP_ROUTE_16_GATE` warps 3/4. + Walking left across (5, 4)/(5, 5) fires `Route16GateBicycleCheck`; with no + BICYCLE the player is shoved one step RIGHT. Exit warps 1/2 at (0, 4)/(0, 5). +41. `MAP_ROUTE_16` -> walk south into `MAP_ROUTE_17` (offset 0). + Post (on load): `ENGINE_ALWAYS_ON_BIKE` + `ENGINE_DOWNHILL`. Releasing the + d-pad now auto-steps DOWN each frame. +42. `MAP_ROUTE_17`, object 2 at (4, 17) (`TrainerBikerRiley`), sight 4. + Post: `EVENT_BEAT_BIKER_RILEY`. +43. `MAP_ROUTE_17`, object 3 at (16, 32) (`TrainerBikerJoel`), sight 3 - the + right-bank bridge. Post: `EVENT_BEAT_BIKER_JOEL`. +44. `MAP_ROUTE_17`, object 4 at (3, 53) (`TrainerBikerGlenn`), sight 3. + Post: `EVENT_BEAT_BIKER_GLENN`. +45. `MAP_ROUTE_17`, object 5 at (6, 80) (`TrainerBikerCharles`), sight 4 facing + RIGHT. Post: `EVENT_BEAT_BIKER_CHARLES`. +46. Optional hidden items: face (12, 5) -> `MAX_ETHER`; face (8, 77) -> + `MAX_ELIXER`. +47. `MAP_ROUTE_17`, warp 1/2 at (17, 82)/(17, 83) -> + `MAP_ROUTE_17_ROUTE_18_GATE` warps 1/2, exit warps 3/4 at (9, 4)/(9, 5) -> + `MAP_ROUTE_18` warps 1/2 at (2, 6)/(2, 7). +48. `MAP_ROUTE_18`, object 3 at (13, 6) (`TrainerBirdKeeperBob`), sight 3. + Post: `EVENT_BEAT_BIRD_KEEPER_BOB`. +49. `MAP_ROUTE_18`, object 2 at (9, 12) (`TrainerBirdKeeperBoris`), sight 3. + Post: `EVENT_BEAT_BIRD_KEEPER_BORIS`. +50. Walk east off `MAP_ROUTE_18` -> `MAP_FUCHSIA_CITY` (offset -7). +51. `MAP_FUCHSIA_CITY`, heal (warp 5), then warp 3 at (8, 27) -> + `MAP_FUCHSIA_GYM` (enter at warps 1/2, (4, 17)/(5, 17)). +52. `MAP_FUCHSIA_GYM`, talk to object 4 at (5, 11) (`LassLindaScript`). + Pre: `EVENT_BEAT_LASS_LINDA` clear. Post: `EVENT_BEAT_LASS_LINDA` on a win; + on a loss the sprite becomes `SPRITE_JANINE` and the flag stays clear. +53. `MAP_FUCHSIA_GYM`, object 5 at (9, 4) (`PicnickerCindyScript`). + Post: `EVENT_BEAT_PICNICKER_CINDY`. +54. `MAP_FUCHSIA_GYM`, object 6 at (4, 2) (`CamperBarryScript`). + Post: `EVENT_BEAT_CAMPER_BARRY`. +55. `MAP_FUCHSIA_GYM`, object 3 at (5, 7) (`LassAliceScript`). + Post: `EVENT_BEAT_LASS_ALICE`. +56. Steps 52-55 are **optional**: step 57 sets all four flags anyway. +57. `MAP_FUCHSIA_GYM`, talk to object 2 at (1, 10) (`FuchsiaGymJanineScript`). + Pre: `ENGINE_SOULBADGE` clear. Post on win: `EVENT_BEAT_JANINE`, + `EVENT_BEAT_LASS_ALICE`, `EVENT_BEAT_LASS_LINDA`, + `EVENT_BEAT_PICNICKER_CINDY`, `EVENT_BEAT_CAMPER_BARRY`, + `ENGINE_SOULBADGE`, then `verbosegiveitem TM_TOXIC` -> + `EVENT_GOT_TM06_TOXIC` (only if the TM pocket has room; otherwise re-talk). +58. Exit via warps 1/2 at (4, 17)/(5, 17) -> `MAP_FUCHSIA_CITY` warp 3. + +Prize money for every trainer above is +`4 x <class base reward> x <level of the trainer's LAST party mon>` +(`ComputeTrainerReward` in `engine/battle/read_trainer_party.asm:300` gives +`base x level`, and `engine/battle/core.asm:2341` adds that amount **four** +times via the `ld c, 4` loop). This reproduces every G figure in the +walkthrough exactly. + +--- + +## 5. Port coverage + +The Gen 2 side of this repo is data-driven: `src/import/RomExtractorGen2.lua` +reads each map's `def_warp_events` / `def_coord_events` / `def_bg_events` / +`def_object_events` tables and the script bytecode straight out of the ROM +(`readMapEvents`, `src/import/RomExtractorGen2.lua:787-980`), and +`src/script/gen2/Vm.lua` executes the original opcodes. So there is no +per-map hand-port to audit for this section; what matters is whether the +mechanics each beat needs are implemented. + +| Beat | Port file | Status | +|---|---|---| +| Map geometry, warps, connections | `src/world/gen2/Map.lua`, `src/import/RomExtractorGen2.lua:787` | implemented (generic, all maps) | +| Coord-event trip-wires (both bike gates) | `src/world/gen2/World.lua:5006` `World:tryCoordScript` (matches x/y + `sceneId`) | implemented | +| `checkitem BICYCLE` | `src/script/gen2/Opcodes.lua:38` (`0x21 checkitem`), handled in `src/script/gen2/Vm.lua` | implemented | +| `showemote` / `turnobject` / `applymovement` refusal choreography | `src/script/gen2/Vm.lua:961`, `:302` | implemented | +| `ENGINE_ALWAYS_ON_BIKE` / `ENGINE_DOWNHILL` (Routes 16, 17) | `src/world/gen2/Bike.lua:30-31`, `src/world/gen2/World.lua:3437-3444`, `:5620` | implemented; `World:alwaysOnBike()` / `World:downhill()` read the same engine-flag store the map callbacks write | +| Downhill auto-step / slow uphill bike step | `src/world/gen2/Bike.lua:136-146` | implemented | +| `readvar VAR_XCOORD` / `VAR_YCOORD` (the Route 16 partial-bike callback) | `src/world/gen2/World.lua:1275-1280`, wired through `src/script/gen2/Vm.lua:670` | implemented | +| `MAPCALLBACK_NEWMAP` dispatch (Fuchsia flypoint, both bike callbacks) | `src/world/gen2/World.lua:5659` `runMapCallback("MAPCALLBACK_NEWMAP")` | implemented | +| CUT field move on `TILESET_KANTO` trees | `src/world/gen2/FieldMoves.lua:191-210` (`CUT_BLOCKS.TILESET_KANTO` transcribes all five kanto rows), badge gate at `:104` (`CUT = "HIVE"`) | implemented | +| Item balls (`OBJECTTYPE_ITEMBALL`) | `src/import/RomExtractorGen2.lua:2968` `readItemBall` | implemented | +| Hidden items (`BGEVENT_ITEM` / `hiddenitem`) | `src/world/gen2/HiddenItems.lua` | implemented | +| Fruit trees (Route 11 Berry, Fuchsia Burnt Berry) | `src/core/gen2/Apricorns.lua:378` (slot 24 `BERRY`), `:384` (slot 30 `BURNT_BERRY`); `fruittree` opcode at `src/script/gen2/Vm.lua:1191` | implemented | +| Kim's Chansey/Aerodactyl trade | `src/core/gen2/NpcTrade.lua` + `src/ui/gen2/TradeMenu.lua`; `trade` opcode at `src/script/gen2/Vm.lua:1292` | implemented (trade rows come from the extracted `data/generated/events.lua` `trades` table) | +| `verbosegiveitem` (Super Rod, TM06) | `src/script/gen2/Vm.lua:490-498` | implemented | +| Super Rod fishing tables | `src/world/gen2/World.lua:311` `ROD_INDEX`, `src/battle/gen2/Encounter.lua:110` | implemented | +| Trainer objects, sight lines, `loadtrainer` / `startbattle` | `src/world/gen2/Trainers.lua`, `src/script/gen2/Vm.lua:806`, `:817`, `src/world/gen2/World.lua:4529` | implemented | +| `variablesprite` + `special LoadUsedSpritesGFX` (the Janine impostor reveal) | `src/script/gen2/Vm.lua:353`, `src/script/gen2/Specials.lua:1033` | implemented | +| Badge award (`setflag ENGINE_SOULBADGE` into `wKantoBadges`) | `src/world/gen2/World.lua` engine-flag store (`save.engineFlags`), Kanto badges counted at `:1240-1245` | implemented (generic engine-flag path; not exercised by any Gold driver) | +| Prize money x4 loop | not located in `src/battle/gen2/Prize.lua` during this pass | unverified - see Unresolved | +| Any driver that walks this section | `tests/drivers/gold_*.lua` (24 files; none touches Routes 11-19 or Fuchsia) | missing | + +Summary: every mechanic this section needs already exists in the port, but +nothing in `tests/drivers/` exercises Kanto's south-east loop, the Cycling Road +forced-bike state or the Fuchsia Gym impostor script. A `gold_cycling_road.lua` +and a `gold_fuchsia_gym.lua` driver would be the cheapest way to prove it. + +--- + +## 6. Unresolved / verify by hand + +1. **Route 19 is never visited.** The section is titled "Routes 11-19" but the + text stops at Fuchsia City after Route 18. `MAP_ROUTE_19` and + `MAP_ROUTE_19_FUCHSIA_GATE` exist (`constants/map_constants.asm:178-180`, + `data/maps/attributes.asm:287`) and Fuchsia warps 10/11 at (7, 35)/(8, 35) + lead there, but no walkthrough beat uses them. +2. **"Cut the tree on your way west" on Route 15.** `maps/Route15.blk` contains + none of the five `TILESET_KANTO` cut-tree block ids. The nearest real cut + tree on that path is the Route 14 one at walk cell (3, 24). The Route 15 + sentence is most likely a duplicate reference to it. +3. **Janine "Strategy VERSUS Espeon".** The walkthrough includes an Espeon + strategy paragraph. Janine (1) in `data/trainers/parties.asm` has no Espeon: + the five mons are Crobat, Weezing, Weezing, Ariados, Venomoth. The paragraph + appears to be copy-paste residue from another gym. +4. **Party ordering differences.** The walkthrough lists several parties in a + different order than the ROM. Confirmed divergences: + Psychic Herman (asm: Exeggcute, Exeggcute, Exeggutor; guide: Exeggcute, + Exeggutor, Exeggcute); Schoolboy Johnny (asm: Bellsprout, Weepinbell, + Victreebel; guide: Bellsprout, Victreebel, Weepinbell); PokeFan Carter (asm: + Bulbasaur, Charmander, Squirtle; guide: Bulbasaur, Squirtle, Charmander); + Bird Keeper Boris (asm: L30 Doduo, L28 Doduo, L32 Dodrio; guide: L30 Doduo, + L32 Dodrio, L28 Doduo); Biker Charles (asm: Koffing, Charmeleon, Weezing; + guide: Koffing, Weezing, Charmeleon); Lass Linda (asm: Bulbasaur, Ivysaur, + Venusaur; guide: Bulbasaur, Venusaur, Ivysaur); Janine (asm order given + above). Levels and species sets always match; only the order differs, so + lead-mon predictions from the guide are unreliable. +5. **Route 12 items.** The walkthrough lists only Calcium and the Super Rod. + The map also has a `NUGGET` itemball at (5, 51) and a hidden `ELIXER` at + (14, 13). Route 13's hidden `CALCIUM` at (30, 13) and Route 17's hidden + `MAX_ETHER` / `MAX_ELIXER` are likewise unlisted. +6. **Route 17 fight order.** The guide's order (Riley, Glenn, Joel, Charles) + does not match the map's y-order (Riley 17, Joel 32, Glenn 53, Charles 80). + Since these are `OBJECTTYPE_TRAINER` objects with sight lines rather than + scripted encounters, a bot coasting south will hit them in y-order, not the + guide's order. +7. **Prize-money x4 in the port.** The disassembly's four-fold payout + (`engine/battle/core.asm:2341`, the `ld c, 4` add loop) was not located in + `src/battle/gen2/Prize.lua` during this pass. Worth a direct read before + trusting any money-based bot assertion. +8. **Fuchsia City cut trees.** Blocks (8, 5) and (9, 9) of + `maps/FuchsiaCity.blk` are `$60` cut trees (walk cells (16, 11) and + (18, 19)). Neither is mentioned in the walkthrough and neither was traced to + a reward; they may simply be scenery on the Safari Zone approach. +9. **Fuchsia warp 7 walkability.** `maps/FuchsiaCity.asm:135` marks the Safari + Zone gate warp `; inaccessible` in a comment; the block data behind (18, 3) + was not decoded to confirm the tile is impassable. The walkthrough's "even + the door is gone" matches the comment, but the collision itself is unverified. diff --git a/docs/gold-walkthrough/asm-walk/section-24-snorlax-and-pewter-city-gym.md b/docs/gold-walkthrough/asm-walk/section-24-snorlax-and-pewter-city-gym.md new file mode 100644 index 00000000..2a16802d --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-24-snorlax-and-pewter-city-gym.md @@ -0,0 +1,919 @@ +# Section 24 - Snorlax and Pewter City Gym + +Source: `../section-24-snorlax-and-pewter-city-gym.txt` +(the FAQ numbers this chapter "30 > Snorlax and Pewter City Gym"; the file index +is 24) + +Maps covered: `MAP_ROUTE_19`, `MAP_ROUTE_19_FUCHSIA_GATE`, `MAP_VERMILION_CITY`, +`MAP_VERMILION_POKECENTER_1F`, `MAP_DIGLETTS_CAVE`, `MAP_ROUTE_2`, +`MAP_ROUTE_2_NUGGET_HOUSE`, `MAP_ROUTE_2_GATE`, `MAP_PEWTER_CITY`, +`MAP_PEWTER_POKECENTER_1F`, `MAP_PEWTER_GYM` + +Badges / key milestones in this section: + +- Level 50 **SNORLAX** caught in Vermilion City, holding LEFTOVERS + (`BATTLETYPE_FORCEITEM`); clearing it opens the DIGLETT'S CAVE door +- **SILVER_WING** (Gold) / **RAINBOW_WING** (Silver) from the Pewter City gramps +- **NUGGET** from the Route 2 house +- CARBOS, DIRE_HIT, ELIXER, MAX_POTION item balls on Route 2 +- **BOULDERBADGE** from BROCK (`ENGINE_BOULDERBADGE`) + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `FUCHSIA_CITY` | - | previous section | south connection, or `ROUTE_19_FUCHSIA_GATE` warps 1/2 at (4,0)/(5,0) | **Belongs to the previous section**; only the hop south is listed | +| 1 | `ROUTE_19_FUCHSIA_GATE` | `maps/Route19FuchsiaGate.asm` | warps 1/2 at (4,0)/(5,0) from `FUCHSIA_CITY` 10/11 | warps 3/4 at (4,7)/(5,7) -> `ROUTE_19` 1 | Officer explains why Route 19 is shut | +| 2 | `ROUTE_19` | `maps/Route19.asm` | warp 1 at (7,3) from the gate | dead end - back through warp 1 | **Blocked.** `Route19ClearRocksCallback` paints six boulders while `EVENT_CINNABAR_ROCKS_CLEARED` is clear | +| 3 | `VERMILION_CITY` | `maps/VermilionCity.asm` | FLY (`ENGINE_FLYPOINT_VERMILION`, set by `VermilionCityFlypointCallback`) | warp 10 at (34,7) -> `DIGLETTS_CAVE` 1 | Wake and battle the SNORLAX at (34,8) with the POKe FLUTE radio channel | +| 4 | `VERMILION_POKECENTER_1F` | `maps/VermilionPokecenter1F.asm` | city warp 2 at (9,5) | warps 1/2 at (3,7)/(4,7) back | Heal and withdraw the caught SNORLAX | +| 5 | `DIGLETTS_CAVE` | `maps/DiglettsCave.asm` | warp 1 at (3,33) from `VERMILION_CITY` 10 | warp 3 at (15,5) -> `ROUTE_2` 5 | Ladder chain 2->5, long cave, ladder 6->4, then the door | +| 6 | `ROUTE_2` | `maps/Route2.asm` | warp 5 at (12,7) from `DIGLETTS_CAVE` 3 | north connection to `PEWTER_CITY` | Carbos, Nugget house, Elixer, three Bug Catchers, two cut trees | +| 7 | `ROUTE_2_NUGGET_HOUSE` | `maps/Route2NuggetHouse.asm` | Route 2 warp 1 at (15,15) | warps 1/2 at (2,7)/(3,7) back | `Route2NuggetHouseFisherScript` -> `verbosegiveitem NUGGET` | +| 8 | `ROUTE_2_GATE` | `maps/Route2Gate.asm` | Route 2 warps 3/4 at (16,27)/(17,27) | warps 3/4 at (4,7)/(5,7) -> `ROUTE_2` 2 at (15,31) | The "route-changing house" that drops you further south on the same route | +| 9 | `PEWTER_CITY` | `maps/PewterCity.asm` | south connection from `ROUTE_2` (`connection north, PewterCity, PEWTER_CITY, -5`) | five warps, see below | Sets `ENGINE_FLYPOINT_PEWTER`; gramps hands over the wing | +| 10 | `PEWTER_POKECENTER_1F` | `maps/PewterPokecenter1F.asm` | city warp 4 at (13,25) | warps 1/2 at (3,7)/(4,7) back | Heal; `Chris` runs `trade NPC_TRADE_CHRIS` (GLOOM -> RAPIDASH) | +| 11 | `PEWTER_GYM` | `maps/PewterGym.asm` | city warp 2 at (16,17) | warps 1/2 at (4,13)/(5,13) back | Camper Jerry, then **BROCK** for the BOULDERBADGE | + +Spillover: the FAQ's video-link header also names Tin Tower, Ho-Oh, Routes 1-4 +and Pallet Town. Nothing in the prose of this section enters those maps - the +Routes 3/4 and Pallet beats belong to the following section and are not covered +here. + +--- + +## 2. Maps + +### MAP_ROUTE_19 + +- Script: `maps/Route19.asm` +- Blocks: `maps/Route19.blk` +- Header: `data/maps/maps.asm:196` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_19, MUSIC_ROUTE_3, FALSE (phone allowed), PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:180` `map_const ROUTE_19, 10, 18` (group `CINNABAR`, id 5) = 20 x 36 cells +- Attributes / border: `data/maps/attributes.asm:287` `map_attributes Route19, ROUTE_19, $43` +- Connections: `connection north, FuchsiaCity, FUCHSIA_CITY, 0`; `connection west, Route20, ROUTE_20, 9` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 3 | `ROUTE_19_FUCHSIA_GATE` | 3 | + +**Coord events** (`def_coord_events`) + +none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 13 | `BGEVENT_READ` | `Route19Sign` | +| 11 | 1 | `BGEVENT_READ` | `CarefulSwimmingSign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE19_SWIMMER_GIRL` | `SPRITE_SWIMMER_GIRL` | 9 | 23 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerSwimmerfDawn` | -1 | +| `ROUTE19_SWIMMER_GUY1` | `SPRITE_SWIMMER_GUY` | 13 | 28 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSwimmermHarold` | -1 | +| `ROUTE19_SWIMMER_GUY2` | `SPRITE_SWIMMER_GUY` | 11 | 17 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSwimmermJerome` | -1 | +| `ROUTE19_SWIMMER_GUY3` | `SPRITE_SWIMMER_GUY` | 8 | 23 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` (sight 0) | `TrainerSwimmermTucker` | -1 | +| `ROUTE19_FISHER1` | `SPRITE_FISHER` | 9 | 5 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` (sight 1) | `Route19Fisher1Script` | -1 | +| `ROUTE19_FISHER2` | `SPRITE_FISHER` | 11 | 5 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` (sight 1) | `Route19Fisher2Script` | -1 | + +**Scripts of interest** + +- `Route19ClearRocksCallback` (`callback MAPCALLBACK_TILES`). `checkevent + EVENT_CINNABAR_ROCKS_CLEARED` / `iftrue .Done`; otherwise it runs six + `changeblock` writes that lay a rock block (`$7a`) over block coordinates + `(6,6) (8,6) (10,6) (12,8) (4,8) (10,10)`. This is the wall the FAQ hits. + Block coordinates, so the blocked cells are `x = 2*bx .. 2*bx+1`, + `y = 2*by .. 2*by+1`. +- `Route19Fisher1Script` / `Route19Fisher2Script` both branch on + `EVENT_CINNABAR_ROCKS_CLEARED` for flavour text only. Neither one clears the + rocks - see "Blockers and gates". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_CINNABAR_ROCKS_CLEARED` | `constants/event_flags.asm:214` | read by `Route19ClearRocksCallback`, `Route19Fisher1Script`, `Route19Fisher2Script`, `Route19FuchsiaGateOfficerScript`; **set** by `Route20ClearRocksCallback` (`maps/Route20.asm:13`, `MAPCALLBACK_NEWMAP`) | Route 19 stays blocked until the player has stood on Route 20, which is reached from the Cinnabar side (Pallet -> Route 21 -> Cinnabar -> Route 20) | + +**Items** - none on this map. + +**Trainers** - four Swimmers, all reachable only by Surf and all beyond the +boulders in the FAQ's play order; not part of this section's critical path. + +--- + +### MAP_ROUTE_19_FUCHSIA_GATE + +- Script: `maps/Route19FuchsiaGate.asm` +- Header: `data/maps/maps.asm:194` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_19, MUSIC_ROUTE_3, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:178` `map_const ROUTE_19_FUCHSIA_GATE, 5, 4` (group `CINNABAR`, id 3) +- Attributes: `data/maps/attributes.asm:512` `map_attributes Route19FuchsiaGate, ROUTE_19_FUCHSIA_GATE, $00`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `FUCHSIA_CITY` | 10 | +| 2 | 5 | 0 | `FUCHSIA_CITY` | 11 | +| 3 | 4 | 7 | `ROUTE_19` | 1 | +| 4 | 5 | 7 | `ROUTE_19` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE19FUCHSIAGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route19FuchsiaGateOfficerScript` | -1 | + +**Scripts of interest** + +- `Route19FuchsiaGateOfficerScript`: `checkevent EVENT_CINNABAR_ROCKS_CLEARED`, + two text branches. Pure flavour; the officer does not gate the warp. + +--- + +### MAP_VERMILION_CITY + +- Script: `maps/VermilionCity.asm` +- Blocks: `maps/VermilionCity.blk` +- Header: `data/maps/maps.asm:296` -> `TILESET_KANTO, TOWN, LANDMARK_VERMILION_CITY, MUSIC_VERMILION_CITY, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:273` `map_const VERMILION_CITY, 20, 18` (group `VERMILION`, id 3) = 40 x 36 cells +- Attributes: `data/maps/attributes.asm:342` `map_attributes VermilionCity, VERMILION_CITY, $43` +- Connections: `connection north, Route6, ROUTE_6, 5`; `connection east, Route11, ROUTE_11, 0` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `VERMILION_FISHING_SPEECH_HOUSE` | 1 | +| 2 | 9 | 5 | `VERMILION_POKECENTER_1F` | 1 | +| 3 | 7 | 13 | `POKEMON_FAN_CLUB` | 1 | +| 4 | 13 | 13 | `VERMILION_MAGNET_TRAIN_SPEECH_HOUSE` | 1 | +| 5 | 21 | 13 | `VERMILION_MART` | 2 | +| 6 | 21 | 17 | `VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE` | 1 | +| 7 | 10 | 19 | `VERMILION_GYM` | 1 | +| 8 | 19 | 31 | `VERMILION_PORT_PASSAGE` | 1 | +| 9 | 20 | 31 | `VERMILION_PORT_PASSAGE` | 2 | +| 10 | 34 | 7 | `DIGLETTS_CAVE` | 1 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 25 | 3 | `BGEVENT_READ` | `VermilionCitySign` | +| 5 | 19 | `BGEVENT_READ` | `VermilionGymSign` | +| 5 | 13 | `BGEVENT_READ` | `PokemonFanClubSign` | +| 33 | 9 | `BGEVENT_READ` | `VermilionCityDiglettsCaveSign` | +| 27 | 15 | `BGEVENT_READ` | `VermilionCityPortSign` | +| 10 | 5 | `BGEVENT_READ` | `VermilionCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 22 | 13 | `BGEVENT_READ` | `VermilionCityMartSign` (`jumpstd MartSignScript`) | +| 12 | 19 | `BGEVENT_ITEM` | `VermilionCityHiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VERMILIONCITY_TEACHER` | `SPRITE_TEACHER` | 18 | 9 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `VermilionCityTeacherScript` | -1 | +| `VERMILIONCITY_GRAMPS` | `SPRITE_GRAMPS` | 23 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VermilionMachopOwner` | -1 | +| `VERMILIONCITY_MACHOP` | `SPRITE_MACHOP` | 26 | 7 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `VermilionMachop` | -1 | +| `VERMILIONCITY_SUPER_NERD` | `SPRITE_SUPER_NERD` | 14 | 16 | `SPRITEMOVEDATA_WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `VermilionCitySuperNerdScript` | -1 | +| `VERMILIONCITY_BIG_SNORLAX` | `SPRITE_BIG_SNORLAX` | 34 | 8 | `SPRITEMOVEDATA_BIGDOLLSYM` | `OBJECTTYPE_SCRIPT` | `VermilionSnorlax` | `EVENT_VERMILION_CITY_SNORLAX` | +| `VERMILIONCITY_POKEFAN_M` | `SPRITE_POKEFAN_M` | 31 | 12 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `VermilionGymBadgeGuy` | -1 | + +Note the object-flag polarity (`CheckObjectFlag`, +`engine/overworld/map_objects_2.asm:32`): an object with an `EVENT_*` flag is +**masked when the flag is set**. So the SNORLAX is present only while +`EVENT_VERMILION_CITY_SNORLAX` is clear, and `disappear` is what sets it. + +**Scripts of interest** + +- `VermilionSnorlax` (`4f:5291`). `opentext` -> `special SnorlaxAwake` -> + `iftrue .Awake`. Asleep branch writes `VermilionCitySnorlaxSleepingText` and + ends. `.Awake` writes `VermilionCityRadioNearSnorlaxText`, `pause 15`, + `cry SNORLAX`, then + `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM` / + `loadwildmon SNORLAX, 50` / `startbattle` / + `disappear VERMILIONCITY_BIG_SNORLAX` / `setevent EVENT_FOUGHT_SNORLAX` / + `reloadmapafterbattle`. + The `disappear` and `setevent` run **unconditionally after the battle**, so a + fled-from or fainted Snorlax is still gone: catching it is a one-shot. +- `SnorlaxAwake` (`engine/events/specials.asm:358`, symbol `03:45d6`). Returns + `TRUE` only when *both* hold: + 1. `[wMapMusic] == MUSIC_POKE_FLUTE_CHANNEL`; + 2. the player stands on one of `.ProximityCoords` + (`engine/events/specials.asm:399`): `(33,8) (34,10) (35,10) (36,8) (36,9)`. + The POKe FLUTE channel is the Pokegear radio at knob 78 / "20.0" + (`engine/pokegear/pokegear.asm:1447`, `.PokeFluteRadio` at 1487): it needs + **not-Johto** and `POKEGEAR_EXPN_CARD_F` in `wPokegearFlags`. Tuning the radio + and closing the Pokegear leaves that song as `wMapMusic`. +- `VermilionGymBadgeGuy`: `checkevent EVENT_GOT_HP_UP_FROM_VERMILION_GUY`, then + `readvar VAR_BADGES` with `ifequal NUM_BADGES` / `ifgreater 13` / + `ifgreater 9`. `verbosegiveitem HP_UP` only at all 16 badges. Not this + section's business, but a bot walking Vermilion will trip the text. +- `VermilionCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): + `setflag ENGINE_FLYPOINT_VERMILION`. This is what makes the FAQ's "fly back to + Vermilion City" legal - it must already have been visited on foot. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_VERMILION_CITY_SNORLAX` | `constants/event_flags.asm:1298` | object flag on `VERMILIONCITY_BIG_SNORLAX`; set by `disappear` in `VermilionSnorlax` | clear = Snorlax on the map; set = gone, cave door walkable | +| `EVENT_FOUGHT_SNORLAX` | `constants/event_flags.asm:1266` | set by `VermilionSnorlax`; read by `VermilionPokecenter1FFishingGuruScript` (`maps/VermilionPokecenter1F.asm:18`) and used as the object flag of the right Black Belt in `maps/VictoryRoadGate.asm:119` | "the Snorlax encounter has happened" | +| `ENGINE_FLYPOINT_VERMILION` | `constants/engine_flags.asm:72` | `VermilionCityFlypointCallback` | Fly destination unlocked | +| `POKEGEAR_EXPN_CARD_F` | `constants/ram_constants.asm:280` | read by `.PokeFluteRadio` | without the EXPN CARD the flute channel is static and `SnorlaxAwake` can never be true | +| `EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL` | `constants/event_flags.asm:252` | `VermilionCityHiddenFullHeal` | hidden FULL_HEAL at (12,19) | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `LEFTOVERS` | attached to the wild SNORLAX | `data/pokemon/base_stats/snorlax.asm:9` `db LEFTOVERS, LEFTOVERS` forced by `BATTLETYPE_FORCEITEM` (`engine/battle/core.asm:5771`) | - | +| `FULL_HEAL` | hidden | `bg_event 12, 19, BGEVENT_ITEM, VermilionCityHiddenFullHeal` | `EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL` | + +**Wild encounters** - the scripted SNORLAX is not a table entry; it is +`loadwildmon SNORLAX, 50`. Level 50 learnset from +`data/pokemon/evos_attacks.asm:1932`: the four most recent are SNORE (36), +REST (36), BODY_SLAM (43), ROLLOUT (50) - which is exactly the FAQ's "Rest" and +"Rollout". Catch rate 25, base exp 154 (`data/pokemon/base_stats/snorlax.asm`). + +--- + +### MAP_VERMILION_POKECENTER_1F + +- Script: `maps/VermilionPokecenter1F.asm` +- Header: `data/maps/maps.asm` (`VERMILION` group); dimensions + `constants/map_constants.asm:275` `map_const VERMILION_POKECENTER_1F, 5, 4` + +Only the one script matters here: + +- `VermilionPokecenter1FFishingGuruScript`: `checkevent EVENT_FOUGHT_SNORLAX`, + two text branches ("A sleeping #MON is lying in front of DIGLETT'S CAVE" -> + after-text). It is a read-only confirmation that the Snorlax beat is done; the + PC withdraw the FAQ asks for is the standard `POKECENTER_2F` / PC menu, not a + map script. + +--- + +### MAP_DIGLETTS_CAVE + +- Script: `maps/DiglettsCave.asm` +- Blocks: `maps/DiglettsCave.blk` (180 bytes = 10 x 18 blocks) +- Header: `data/maps/maps.asm:153` -> `TILESET_CAVE, CAVE, LANDMARK_DIGLETTS_CAVE, MUSIC_MT_MOON, TRUE (no phone), PALETTE_NITE, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:140` `map_const DIGLETTS_CAVE, 10, 18` (group `DUNGEONS`, id 75) = 20 x 36 cells +- Attributes: `data/maps/attributes.asm:487` `map_attributes DiglettsCave, DIGLETTS_CAVE, $09`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 33 | `VERMILION_CITY` | 10 | +| 2 | 5 | 31 | `DIGLETTS_CAVE` | 5 | +| 3 | 15 | 5 | `ROUTE_2` | 5 | +| 4 | 17 | 3 | `DIGLETTS_CAVE` | 6 | +| 5 | 17 | 33 | `DIGLETTS_CAVE` | 2 | +| 6 | 3 | 3 | `DIGLETTS_CAVE` | 4 | + +Warps 2<->5 and 4<->6 are the two internal ladders. Reading `DiglettsCave.blk` +against `data/tilesets/kanto_collision.asm` (block `$1f`/`$1b` = ladder, `$24` = +door) the map is three disconnected pockets: + +- pocket A (bottom-left, blocks (1,15)-(2,17)): warp 1 (VERMILION door) + warp 2 + (ladder); +- pocket B (the long cave): warp 5 at (17,33) up to warp 6 at (3,3); +- pocket C (top-right, blocks (7,1)-(8,3)): warp 4 (ladder) + warp 3 (ROUTE_2 + door). + +So the traversal is: `(3,33)` -> ladder `(5,31)` -> emerge `(17,33)` -> walk the +long cave to `(3,3)` -> ladder -> emerge `(17,3)` -> door `(15,5)` -> Route 2 +`(12,7)`. That is exactly the FAQ's "down the ladder, ride up, up that ladder, +exit the door". + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 6 | 11 | `BGEVENT_ITEM` | `DiglettsCaveHiddenMaxRevive` -> `hiddenitem MAX_REVIVE, EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `DIGLETTSCAVE_POKEFAN_M` | `SPRITE_POKEFAN_M` | 3 | 31 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `DiglettsCavePokefanMScript` | -1 | + +**Trainers** - none, matching the FAQ. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_REVIVE` | hidden (ITEMFINDER / bump) | `bg_event 6, 11, BGEVENT_ITEM` | `EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE` (`constants/event_flags.asm:228`) | + +**Wild encounters** - `data/wild/kanto_grass.asm:5` `def_grass_wildmons +DIGLETTS_CAVE`, rates `4 / 2 / 8` percent (morn/day/nite). All three time slots +carry the same seven rows: + +| slot | level | species | +|---|---|---| +| 1 | 15 | DIGLETT | +| 2 | 17 | DIGLETT | +| 3 | 19 | DIGLETT | +| 4 | 13 | DIGLETT | +| 5 | 19 | DUGTRIO | +| 6 | 24 | DUGTRIO | +| 7 | 29 | DUGTRIO | + +The FAQ's "capture both Diglett and Dugtrio here" is correct, and night is the +best time (8% vs 2% by day). + +The FAQ's "ride your bike" is legal here: `BikeFunction`'s `.CheckEnvironment` +(`engine/events/overworld.asm:1665`) allows outdoor maps, `CAVE` and `GATE`. + +--- + +### MAP_ROUTE_2 + +- Script: `maps/Route2.asm` +- Blocks: `maps/Route2.blk` (270 bytes = 10 x 27 blocks) +- Header: `data/maps/maps.asm:455` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_2, MUSIC_ROUTE_2, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:421` `map_const ROUTE_2, 10, 27` (group `VIRIDIAN`, id 1) = 20 x 54 cells +- Attributes: `data/maps/attributes.asm:255` `map_attributes Route2, ROUTE_2, $0f` +- Connections: `connection north, PewterCity, PEWTER_CITY, -5`; + `connection south, ViridianCity, VIRIDIAN_CITY, -5` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 15 | 15 | `ROUTE_2_NUGGET_HOUSE` | 1 | +| 2 | 15 | 31 | `ROUTE_2_GATE` | 3 | +| 3 | 16 | 27 | `ROUTE_2_GATE` | 1 | +| 4 | 17 | 27 | `ROUTE_2_GATE` | 2 | +| 5 | 12 | 7 | `DIGLETTS_CAVE` | 3 | + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 51 | `BGEVENT_READ` | `Route2Sign` | +| 11 | 9 | `BGEVENT_READ` | `Route2DiglettsCaveSign` | +| 7 | 23 | `BGEVENT_ITEM` | `Route2HiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_ROUTE_2_HIDDEN_MAX_ETHER` | +| 4 | 14 | `BGEVENT_ITEM` | `Route2HiddenFullHeal` -> `hiddenitem FULL_HEAL, EVENT_ROUTE_2_HIDDEN_FULL_HEAL` | +| 4 | 27 | `BGEVENT_ITEM` | `Route2HiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_ROUTE_2_HIDDEN_FULL_RESTORE` | +| 11 | 30 | `BGEVENT_ITEM` | `Route2HiddenRevive` -> `hiddenitem REVIVE, EVENT_ROUTE_2_HIDDEN_REVIVE` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE2_BUG_CATCHER1` | `SPRITE_BUG_CATCHER` | 10 | 45 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 5) | `TrainerBugCatcherRob` | -1 | +| `ROUTE2_BUG_CATCHER2` | `SPRITE_BUG_CATCHER` | 5 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` (radius 1,0) | `OBJECTTYPE_TRAINER` (sight 4) | `TrainerBugCatcherEd` | -1 | +| `ROUTE2_BUG_CATCHER3` | `SPRITE_BUG_CATCHER` | 4 | 43 | `SPRITEMOVEDATA_STANDING_UP` (radius 1,0) | `OBJECTTYPE_TRAINER` (sight 5) | `TrainerBugCatcherDoug` | -1 | +| `ROUTE2_POKE_BALL1` | `SPRITE_POKE_BALL` | 0 | 29 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route2DireHit` (`itemball DIRE_HIT`) | `EVENT_ROUTE_2_DIRE_HIT` | +| `ROUTE2_POKE_BALL2` | `SPRITE_POKE_BALL` | 2 | 23 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route2MaxPotion` (`itemball MAX_POTION`) | `EVENT_ROUTE_2_MAX_POTION` | +| `ROUTE2_POKE_BALL3` | `SPRITE_POKE_BALL` | 19 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route2Carbos` (`itemball CARBOS`) | `EVENT_ROUTE_2_CARBOS` | +| `ROUTE2_POKE_BALL4` | `SPRITE_POKE_BALL` | 14 | 50 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route2Elixer` (`itemball ELIXER`) | `EVENT_ROUTE_2_ELIXER` | +| `ROUTE2_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 10 | 14 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route2FruitTree` (`fruittree FRUITTREE_ROUTE_2`) | -1 | + +**Cut trees** (not asm rows - decoded from `maps/Route2.blk` against the +`CUT_TREE` entries of `data/tilesets/kanto_collision.asm`, which are blocks +`$00 $32 $33 $34 $35 $60`) + +| block (bx,by) | block id | covered cells (x, y) | which FAQ beat | +|---|---|---|---| +| (2, 4) | `$32` | 4-5, 8-9 | near the Diglett's Cave door, not used by the FAQ | +| (7, 9) | `$32` | 14-15, 18-19 | "cut the tree to the south" (just below the Nugget house at (15,15)) | +| (5, 20) | `$34` | 10-11, 40-41 | not named by the FAQ | +| (6, 23) | `$34` | 12-13, 46-47 | not named by the FAQ | +| (6, 25) | `$34` | 12-13, 50-51 | "cut the tree just left of it" (immediately left of the Elixer at (14,50)) | + +**Scripts of interest** + +- `TrainerBugCatcherRob` / `TrainerBugCatcherEd` / `TrainerBugCatcherDoug` + (`4d:5bc2`, `4d:5bd6`, `4d:5bea`): each is the plain `trainer CLASS, MEMBER, + EVENT_*, seen, beaten, 0, .Script` header with an `endifjustbattled` + + after-battle text body. No items, no flags beyond the beaten event. +- `Route2FruitTree`: `fruittree FRUITTREE_ROUTE_2` -> + `data/items/fruit_trees.asm:28` `db PSNCUREBERRY`. This is the FAQ's + "PSNCure Berry". + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_2_DIRE_HIT` | `constants/event_flags.asm:1318` | item ball object flag | clear = ball present | +| `EVENT_ROUTE_2_MAX_POTION` | `constants/event_flags.asm:1319` | item ball object flag | clear = ball present | +| `EVENT_ROUTE_2_CARBOS` | `constants/event_flags.asm:1320` | item ball object flag | clear = ball present | +| `EVENT_ROUTE_2_ELIXER` | `constants/event_flags.asm:1321` | item ball object flag | clear = ball present | +| `EVENT_ROUTE_2_HIDDEN_MAX_ETHER` | `constants/event_flags.asm:237` | `Route2HiddenMaxEther` | hidden pickup | +| `EVENT_ROUTE_2_HIDDEN_FULL_HEAL` | `constants/event_flags.asm:238` | `Route2HiddenFullHeal` | hidden pickup | +| `EVENT_ROUTE_2_HIDDEN_FULL_RESTORE` | `constants/event_flags.asm:239` | `Route2HiddenFullRestore` | hidden pickup | +| `EVENT_ROUTE_2_HIDDEN_REVIVE` | `constants/event_flags.asm:240` | `Route2HiddenRevive` | hidden pickup | +| `EVENT_BEAT_BUG_CATCHER_ROB` | `constants/event_flags.asm:840` | `TrainerBugCatcherRob` | trainer beaten | +| `EVENT_BEAT_BUG_CATCHER_ED` | `constants/event_flags.asm:841` | `TrainerBugCatcherEd` | trainer beaten | +| `EVENT_BEAT_BUG_CATCHER_DOUG` | `constants/event_flags.asm:850` | `TrainerBugCatcherDoug` | trainer beaten | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `CARBOS` | item ball at (19,2) | `Route2Carbos` | `EVENT_ROUTE_2_CARBOS` | +| `MAX_POTION` | item ball at (2,23) | `Route2MaxPotion` | `EVENT_ROUTE_2_MAX_POTION` | +| `DIRE_HIT` | item ball at (0,29) | `Route2DireHit` | `EVENT_ROUTE_2_DIRE_HIT` | +| `ELIXER` | item ball at (14,50) | `Route2Elixer` | `EVENT_ROUTE_2_ELIXER` | +| `PSNCUREBERRY` | fruit tree object at (10,14) | `FRUITTREE_ROUTE_2`, `data/items/fruit_trees.asm:28` | daily reset, not an event flag | +| `MAX_ETHER` | hidden at (7,23) | bg_event | `EVENT_ROUTE_2_HIDDEN_MAX_ETHER` | +| `FULL_HEAL` | hidden at (4,14) | bg_event | `EVENT_ROUTE_2_HIDDEN_FULL_HEAL` | +| `FULL_RESTORE` | hidden at (4,27) | bg_event | `EVENT_ROUTE_2_HIDDEN_FULL_RESTORE` | +| `REVIVE` | hidden at (11,30) | bg_event | `EVENT_ROUTE_2_HIDDEN_REVIVE` | +| `NUGGET` | Route 2 Nugget House NPC | `Route2NuggetHouseFisherScript` | `EVENT_GOT_NUGGET_FROM_GUY` | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `ROB` (`constants/trainer_constants.asm:314`) | `BUG_CATCHER` | 2 | `BugCatcherGroup` "ROB" (`data/trainers/parties.asm:1416`): L32 BEEDRILL, L32 BUTTERFREE, `TRAINERTYPE_NORMAL` | `TrainerBugCatcherRob` | no | +| `DOUG` (`constants/trainer_constants.asm:324`) | `BUG_CATCHER` | 12 | `BugCatcherGroup` "DOUG" (`data/trainers/parties.asm:1482`): L34 ARIADOS | `TrainerBugCatcherDoug` | no | +| `ED` (`constants/trainer_constants.asm:315`) | `BUG_CATCHER` | 3 | `BugCatcherGroup` "ED" (`data/trainers/parties.asm:1422`): L30 BEEDRILL x3 | `TrainerBugCatcherEd` | no | + +`BUG_CATCHER` base reward 4 (`data/trainers/attributes.asm:215`); prize = +base x last mon level (`ComputeTrainerReward`, +`engine/battle/read_trainer_party.asm:300`). + +**Wild encounters** - `data/wild/kanto_grass.asm:259` `def_grass_wildmons +ROUTE_2`, rates `10 / 10 / 10`. Gold (`IF DEF(_GOLD)`): + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | L3 CATERPIE | L3 CATERPIE | L3 HOOTHOOT | +| 2 | L3 PIDGEY | L3 PIDGEY | L3 SPINARAK | +| 3 | L5 METAPOD | L5 METAPOD | L5 HOOTHOOT | +| 4 | L7 BUTTERFREE | L7 PIDGEY | L7 NOCTOWL | +| 5 | L7 PIDGEOTTO | L7 PIDGEOTTO | L7 ARIADOS | +| 6 | L4 PIKACHU | L4 PIKACHU | L4 PIKACHU | +| 7 | L4 PIKACHU | L4 PIKACHU | L4 PIKACHU | + +(The Silver table under `ELIF DEF(_SILVER)` swaps the Caterpie line for +Weedle/Kakuna/Beedrill and Spinarak for Ledyba.) + +--- + +### MAP_ROUTE_2_NUGGET_HOUSE + +- Script: `maps/Route2NuggetHouse.asm` +- Header: `data/maps/maps.asm:465` -> `TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_2, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:431` `map_const ROUTE_2_NUGGET_HOUSE, 4, 4` +- Attributes: `data/maps/attributes.asm:664`; no connections + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_2` | 1 | +| 2 | 3 | 7 | `ROUTE_2` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE2NUGGETHOUSE_FISHER` | `SPRITE_FISHER` | 2 | 4 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `Route2NuggetHouseFisherScript` | -1 | + +**Scripts of interest** + +- `Route2NuggetHouseFisherScript` (`5f:4e26`): `faceplayer` / `opentext` / + `checkevent EVENT_GOT_NUGGET_FROM_GUY` -> `.GotNugget`; otherwise text, + `promptbutton`, `verbosegiveitem NUGGET`, `iffalse .NoRoom`, + `setevent EVENT_GOT_NUGGET_FROM_GUY`. A full bag skips the flag, so the gift + is re-offerable. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_NUGGET_FROM_GUY` | `constants/event_flags.asm:199` | `Route2NuggetHouseFisherScript` | one-time NUGGET | + +--- + +### MAP_ROUTE_2_GATE + +- Script: `maps/Route2Gate.asm` +- Header: `data/maps/maps.asm:466` -> `TILESET_GATE, GATE, LANDMARK_ROUTE_2, MUSIC_ROUTE_2, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:432` `map_const ROUTE_2_GATE, 5, 4` +- Attributes: `data/maps/attributes.asm:665`; no connections + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `ROUTE_2` | 3 | +| 2 | 5 | 0 | `ROUTE_2` | 4 | +| 3 | 4 | 7 | `ROUTE_2` | 2 | +| 4 | 5 | 7 | `ROUTE_2` | 2 | + +Both sides land back on Route 2 - this is the FAQ's "route-changing house... +You'll still be in Route 2". North side (y=0) <-> Route 2 (16,27)/(17,27); +south side (y=7) <-> Route 2 (15,31). + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE2GATE_SCIENTIST` | `SPRITE_SCIENTIST` | 6 | 4 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,2) | `OBJECTTYPE_SCRIPT` | `Route2GateScientistScript` | -1 | + +`Route2GateScientistScript` is `jumptextfaceplayer` only (Oak's aide flavour). + +--- + +### MAP_PEWTER_CITY + +- Script: `maps/PewterCity.asm` +- Blocks: `maps/PewterCity.blk` +- Header: `data/maps/maps.asm:322` -> `TILESET_KANTO, TOWN, LANDMARK_PEWTER_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:297` `map_const PEWTER_CITY, 20, 18` (group `PEWTER`, id 2) = 40 x 36 cells +- Attributes: `data/maps/attributes.asm:251` `map_attributes PewterCity, PEWTER_CITY, $0f` +- Connections: `connection south, Route2, ROUTE_2, 5`; `connection east, Route3, ROUTE_3, 5` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 29 | 13 | `PEWTER_NIDORAN_SPEECH_HOUSE` | 1 | +| 2 | 16 | 17 | `PEWTER_GYM` | 1 | +| 3 | 23 | 17 | `PEWTER_MART` | 2 | +| 4 | 13 | 25 | `PEWTER_POKECENTER_1F` | 1 | +| 5 | 7 | 29 | `PEWTER_SNOOZE_SPEECH_HOUSE` | 1 | + +There is **no museum warp**: the FAQ's "they even filled the doorway in" is +literally true in the asm - the museum exists only as a sign. + +**Coord events** - none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 25 | 23 | `BGEVENT_READ` | `PewterCitySign` | +| 11 | 17 | `BGEVENT_READ` | `PewterGymSign` | +| 15 | 9 | `BGEVENT_READ` | `PewterMuseumSign` ("closed for renovations") | +| 33 | 19 | `BGEVENT_READ` | `PewterCityMtMoonGiftShopSign` | +| 19 | 29 | `BGEVENT_READ` | `PewterCityWelcomeSign` | +| 14 | 25 | `BGEVENT_READ` | `PewterCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 24 | 17 | `BGEVENT_READ` | `PewterCityMartSign` (`jumpstd MartSignScript`) | + +No hidden items in Pewter City. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PEWTERCITY_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 19 | 11 | `SPRITEMOVEDATA_WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `PewterCityCooltrainerFScript` | -1 | +| `PEWTERCITY_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 14 | 29 | `SPRITEMOVEDATA_WANDER` (2,2) | `OBJECTTYPE_SCRIPT` | `PewterCityBugCatcherScript` | -1 | +| `PEWTERCITY_GRAMPS` | `SPRITE_GRAMPS` | 29 | 17 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `PewterCityGrampsScript` | -1 | +| `PEWTERCITY_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 32 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `PewterCityFruitTree1` (`fruittree FRUITTREE_PEWTER_CITY_1`) | -1 | +| `PEWTERCITY_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 30 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `PewterCityFruitTree2` (`fruittree FRUITTREE_PEWTER_CITY_2`) | -1 | + +**Scripts of interest** + +- `PewterCityGrampsScript` (`4d:583e`). `faceplayer` / `opentext` / `checkver` + / `iftrue .RainbowWing`. `checkver` leaves `GS_VERSION` in `wScriptVar` + (0 = Gold, 1 = Silver), so: + - **Gold**: `checkevent EVENT_GOT_SILVER_WING` -> `.GotSilverWing`; otherwise + text, `promptbutton`, `verbosegiveitem SILVER_WING`, + `setevent EVENT_GOT_SILVER_WING`. Note there is **no `iffalse` guard** on + this `verbosegiveitem`: the flag is set whether or not the bag had room. + - **Silver**: `checkevent EVENT_GOT_RAINBOW_WING` -> `.GotSilverWing`; + otherwise `verbosegiveitem RAINBOW_WING`, `setevent EVENT_GOT_RAINBOW_WING`, + `setevent EVENT_TEAM_ROCKET_DISBANDED`. + This gramps is the *second* source for the wing pair: the Radio Tower director + (`maps/RadioTower5F.asm:120-140`) gives the opposite one on the same + `checkver` split. +- `PewterCityFlypointCallback` (`MAPCALLBACK_NEWMAP`): + `setflag ENGINE_FLYPOINT_PEWTER`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_GOT_SILVER_WING` | `constants/event_flags.asm:130` | Gold branch of `PewterCityGrampsScript`; also `maps/RadioTower5F.asm:139` (Silver) | one-time | +| `EVENT_GOT_RAINBOW_WING` | `constants/event_flags.asm:129` | Silver branch of `PewterCityGrampsScript`; also `maps/RadioTower5F.asm:129` (Gold); read by `maps/EcruteakTinTowerEntrance.asm:62` | one-time; gates Tin Tower entry | +| `EVENT_TEAM_ROCKET_DISBANDED` | `constants/event_flags.asm:1283` | set by the Silver branch here | side effect of the Rainbow Wing gift | +| `ENGINE_FLYPOINT_PEWTER` | `constants/engine_flags.asm:69` | `PewterCityFlypointCallback` | Fly destination | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `SILVER_WING` (Gold) / `RAINBOW_WING` (Silver) | talk to `PEWTERCITY_GRAMPS` at (29,17) | `PewterCityGrampsScript` | `EVENT_GOT_SILVER_WING` / `EVENT_GOT_RAINBOW_WING` | +| `ICE_BERRY` | fruit tree at (32,3) | `FRUITTREE_PEWTER_CITY_1`, `data/items/fruit_trees.asm:31` | daily | +| `MINT_BERRY` | fruit tree at (30,3) | `FRUITTREE_PEWTER_CITY_2`, `data/items/fruit_trees.asm:32` | daily | + +**Wild encounters** - none (TOWN, no grass table). + +--- + +### MAP_PEWTER_POKECENTER_1F + +- Script: `maps/PewterPokecenter1F.asm` +- Header: `data/maps/maps.asm:326` -> `TILESET_POKECENTER, INDOOR, LANDMARK_PEWTER_CITY, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:301` `map_const PEWTER_POKECENTER_1F, 5, 4` +- Attributes: `data/maps/attributes.asm:583` + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `PEWTER_CITY` | 4 | +| 2 | 4 | 7 | `PEWTER_CITY` | 4 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PEWTERPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PewterPokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 | +| `PEWTERPOKECENTER1F_TEACHER` | `SPRITE_TEACHER` | 8 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PewterPokecenter1FTeacherScript` | -1 | +| `PEWTERPOKECENTER1F_JIGGLYPUFF` | `SPRITE_JIGGLYPUFF` | 1 | 3 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT` | `PewterJigglypuff` | -1 | +| `PEWTERPOKECENTER1F_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 2 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PewterPokecenter1FBugCatcherScript` | -1 | +| `PEWTERPOKECENTER1F_CHRIS` | `SPRITE_POKEFAN_M` | 7 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Chris` | -1 | + +**Scripts of interest** + +- `Chris` (`5a:46f5`): `faceplayer` / `opentext` / `trade NPC_TRADE_CHRIS` / + `waitbutton` / `closetext` / `end`. The trade row is + `data/events/npc_trades.asm:18`: + `npctrade TRADE_DIALOGSET_HAPPY, GLOOM, RAPIDASH, "RUNNY", $96, $66, + BURNT_BERRY, 15616, "CHRIS", TRADE_GENDER_EITHER` - the player gives GLOOM and + receives a RAPIDASH nicknamed RUNNY, DVs `$96/$66`, holding a BURNT_BERRY, OT + "CHRIS" ID 15616. The FAQ's "trade a Gloom for a Rapidash" is exact. Note the + NPC sprite is `SPRITE_POKEFAN_M`, not a lady. + +--- + +### MAP_PEWTER_GYM + +- Script: `maps/PewterGym.asm` +- Blocks: `maps/PewterGym.blk` +- Header: `data/maps/maps.asm:324` -> `TILESET_TOWER, INDOOR, LANDMARK_PEWTER_CITY, MUSIC_GYM, TRUE (no phone), PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:299` `map_const PEWTER_GYM, 5, 7` (group `PEWTER`, id 4) = 10 x 14 cells +- Attributes: `data/maps/attributes.asm:581` `map_attributes PewterGym, PEWTER_GYM, $00`; no connections + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 13 | `PEWTER_CITY` | 2 | +| 2 | 5 | 13 | `PEWTER_CITY` | 2 | + +**Coord events** - none. There is no lock-in trip-wire in this gym. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 2 | 11 | `BGEVENT_READ` | `PewterGymStatue` | +| 7 | 11 | `BGEVENT_READ` | `PewterGymStatue` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PEWTERGYM_BROCK` | `SPRITE_BROCK` | 5 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `PewterGymBrockScript` | -1 | +| `PEWTERGYM_YOUNGSTER` | `SPRITE_YOUNGSTER` | 2 | 5 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerCamperJerry` | -1 | +| `PEWTERGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 6 | 11 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` (sight 1) | `PewterGymGuideScript` | -1 | + +**Scripts of interest** + +- `PewterGymBrockScript` (`5a:405f`). `faceplayer` / `opentext` / + `checkflag ENGINE_BOULDERBADGE` / `iftrue .FightDone`. Otherwise: + `writetext BrockIntroText`, `waitbutton`, `closetext`, + `winlosstext BrockWinLossText, 0`, `loadtrainer BROCK, BROCK1`, + `startbattle`, `reloadmapafterbattle`, `setevent EVENT_BEAT_BROCK`, + **`setevent EVENT_BEAT_CAMPER_JERRY`**, then `opentext` / + `writetext ReceivedBoulderBadgeText` / `playsound SFX_GET_BADGE` / `waitsfx` / + `setflag ENGINE_BOULDERBADGE` / `writetext BrockBoulderBadgeText` / + `waitbutton` / `closetext` / `end`. + Two consequences for a bot: (1) Camper Jerry is optional - beating Brock marks + him beaten; (2) the badge is a `setflag` on the ENGINE namespace, not an + `EVENT_*`. +- `TrainerCamperJerry` (`5a:4093`): standard `trainer CAMPER, JERRY, + EVENT_BEAT_CAMPER_JERRY, ...` header, sight range 3 facing right from (2,5). +- `PewterGymGuideScript`: `checkevent EVENT_BEAT_BROCK`, two text branches. +- `PewterGymStatue`: `checkflag ENGINE_BOULDERBADGE`; unbeaten -> `jumpstd + GymStatue1Script`, beaten -> `gettrainername STRING_BUFFER_4, BROCK, BROCK1` + then `jumpstd GymStatue2Script`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_BOULDERBADGE` | `constants/engine_flags.asm:47` (row `data/events/engine_flags.asm`, backed by `wKantoBadges`) | checked and set by `PewterGymBrockScript`; checked by `PewterGymStatue` | the gym's own re-entry guard, and the ROCK-type damage boost via `BadgeTypeBoosts` | +| `EVENT_BEAT_BROCK` | `constants/event_flags.asm:715` | set by `PewterGymBrockScript`; read by `PewterGymGuideScript` | gym cleared | +| `EVENT_BEAT_CAMPER_JERRY` | `constants/event_flags.asm:545` | `TrainerCamperJerry`, and also set by `PewterGymBrockScript` | trainer beaten / skipped | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `JERRY` (`constants/trainer_constants.asm:566`) | `CAMPER` | 18 | `CamperGroup` "JERRY" (`data/trainers/parties.asm:2811`): L37 SANDSLASH, `TRAINERTYPE_NORMAL` | `TrainerCamperJerry` | no | +| `BROCK1` (`constants/trainer_constants.asm:91`, class `BROCK` = 11 at line 90) | `BROCK` | 1 | `BrockGroup` (`data/trainers/parties.asm:270`, symbol `0e:5cda`), `TRAINERTYPE_MOVES` | `PewterGymBrockScript` | no phone | + +`BrockGroup` party, in asm order: + +| # | level | species | moves | +|---|---|---|---| +| 1 | 41 | GRAVELER | DEFENSE_CURL, ROCK_SLIDE, ROLLOUT, EARTHQUAKE | +| 2 | 41 | RHYHORN | FURY_ATTACK, SCARY_FACE, EARTHQUAKE, HORN_DRILL | +| 3 | 42 | OMASTAR | BITE, SURF, PROTECT, SPIKE_CANNON | +| 4 | 44 | ONIX | BIND, ROCK_SLIDE, BIDE, SANDSTORM | +| 5 | 42 | KABUTOPS | SLASH, SURF, ENDURE, GIGA_DRAIN | + +Class attributes (`data/trainers/attributes.asm:101`): item1 `HYPER_POTION`, +base reward 25, AI `AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | +AI_CAUTIOUS | AI_STATUS | AI_RISKY`, `CONTEXT_USE | SWITCH_SOMETIMES`. Camper +base reward 5 (`data/trainers/attributes.asm:323`). + +**Wild encounters** - none. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Route 19 boulders | `maps/Route19.asm:Route19ClearRocksCallback` (`4e:4f09`), six `changeblock ... $7a` writes under `callback MAPCALLBACK_TILES` | `EVENT_CINNABAR_ROCKS_CLEARED` | Set only by `maps/Route20.asm:Route20ClearRocksCallback` (`MAPCALLBACK_NEWMAP`) - i.e. by reaching Route 20 from the Cinnabar side. Nothing in this section can clear it; the FAQ is right that you must turn around. | +| SNORLAX blocking the DIGLETT'S CAVE door | `maps/VermilionCity.asm` object at (34,8) with a `SPRITEMOVEDATA_BIGDOLLSYM` 2x2 footprint sitting directly under warp 10 at (34,7) | `EVENT_VERMILION_CITY_SNORLAX` clear = present | `disappear VERMILIONCITY_BIG_SNORLAX` at the end of `VermilionSnorlax`, which only runs after `startbattle` | +| SNORLAX will not wake | `engine/events/specials.asm:358 SnorlaxAwake` | `wMapMusic == MUSIC_POKE_FLUTE_CHANNEL` **and** player on one of (33,8), (34,10), (35,10), (36,8), (36,9) | Pokegear radio tuned to knob 78 / "20.0" | +| POKe FLUTE radio channel is static | `engine/pokegear/pokegear.asm:1487 .PokeFluteRadio` | not in Johto (`.InJohto`, landmark >= `KANTO_LANDMARK`) **and** `POKEGEAR_EXPN_CARD_F` set in `wPokegearFlags` | EXPN CARD, obtained earlier (Lavender radio director) | +| Route 2 cut trees | tileset collision `COLL_CUT_TREE` (`constants/collision_constants.asm:16`), tested by `CheckCutCollision` (`engine/overworld/tile_events.asm:76`) | HM01 CUT + `ENGINE_HIVEBADGE` (`engine/events/overworld.asm:133 CutFunction.CheckAble`) | already held long before this section | +| Diglett's Cave traversal | `maps/DiglettsCave.blk` geometry - the three pockets are joined only by warps 2<->5 and 6<->4 | none beyond walking | - | +| Pewter Gym re-fight | `maps/PewterGym.asm:PewterGymBrockScript` `checkflag ENGINE_BOULDERBADGE / iftrue .FightDone` | badge not yet held | one-shot | +| Pewter museum | no warp exists in `PewterCity_MapEvents`; only `bg_event 15, 9 PewterMuseumSign` | - | permanently closed in GS | + +--- + +## 4. Bot checklist + +Coordinates are asm cell coordinates. "clear" / "set" refer to event flags. + +1. `ROUTE_19_FUCHSIA_GATE` -> `ROUTE_19`: walk warp 3/4 at (4,7)/(5,7). + Precondition: none. Expect to be stopped; `EVENT_CINNABAR_ROCKS_CLEARED` is + clear, so blocks `(6,6) (8,6) (10,6) (12,8) (4,8) (10,10)` are rock. Turn + around through warp 1 at (7,3). +2. FLY to `VERMILION_CITY`. Precondition: `ENGINE_FLYPOINT_VERMILION` set. +3. Open the Pokegear, RADIO card, tune the knob to 78 ("20.0"). Precondition: + `POKEGEAR_EXPN_CARD_F` set and the landmark is Kanto. Postcondition: + `wMapMusic == MUSIC_POKE_FLUTE_CHANNEL`. +4. Close the Pokegear and walk to one of (33,8) / (34,10) / (35,10) / (36,8) / + (36,9). Face `VERMILIONCITY_BIG_SNORLAX` at (34,8) and press A. + Precondition: `EVENT_VERMILION_CITY_SNORLAX` clear. + Effect: `SnorlaxAwake` returns TRUE -> wild L50 SNORLAX with LEFTOVERS forced. +5. Battle: catch or defeat. Either way, postcondition + `EVENT_VERMILION_CITY_SNORLAX` set (object removed) and + `EVENT_FOUGHT_SNORLAX` set. **Save before step 4** - a failed catch is not + repeatable. +6. Optional: `VERMILION_CITY` warp 2 at (9,5) -> `VERMILION_POKECENTER_1F`, heal + and withdraw SNORLAX; back out warps 1/2 at (3,7)/(4,7). +7. `VERMILION_CITY` warp 10 at (34,7) -> `DIGLETTS_CAVE` warp 1 at (3,33). +8. `DIGLETTS_CAVE`: walk (3,33) -> (5,31) (warp 2, ladder). Land at (17,33). +9. Walk the long cave (17,33) -> (3,3) (warp 6, ladder). Land at (17,3). + Optional en route: hidden `MAX_REVIVE` at (6,11), + flag `EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE`. +10. Walk (17,3) -> (15,5) (warp 3, door) -> `ROUTE_2` warp 5 at (12,7). +11. `ROUTE_2`: item ball `ROUTE2_POKE_BALL3` at (19,2) = CARBOS. + Precondition `EVENT_ROUTE_2_CARBOS` clear; postcondition set. +12. Warp 1 at (15,15) -> `ROUTE_2_NUGGET_HOUSE`. Talk to + `ROUTE2NUGGETHOUSE_FISHER` at (2,4). Precondition + `EVENT_GOT_NUGGET_FROM_GUY` clear and >=1 bag slot; postcondition set, + NUGGET in bag. Exit warps 1/2 at (2,7)/(3,7). +13. Optional: fruit tree `ROUTE2_FRUIT_TREE` at (10,14) = PSNCUREBERRY (daily). +14. CUT the tree covering cells (14-15, 18-19). Precondition: HM01 in bag and + `ENGINE_HIVEBADGE` set. +15. Warp 3/4 at (16,27)/(17,27) -> `ROUTE_2_GATE` -> warps 3/4 at (4,7)/(5,7) -> + `ROUTE_2` warp 2 at (15,31). Still Route 2. +16. Item ball `ROUTE2_POKE_BALL4` at (14,50) = ELIXER. + Flag `EVENT_ROUTE_2_ELIXER`. +17. CUT the tree covering cells (12-13, 50-51), then head north. +18. Battle `TrainerBugCatcherRob` - object at (10,45), facing left, sight 5. + Party L32 BEEDRILL / L32 BUTTERFREE. Postcondition + `EVENT_BEAT_BUG_CATCHER_ROB`. +19. Battle `TrainerBugCatcherDoug` - object at (4,43), facing up, sight 5. + Party L34 ARIADOS. Postcondition `EVENT_BEAT_BUG_CATCHER_DOUG`. +20. Optional pickups on the way north: item ball at (0,29) = DIRE_HIT + (`EVENT_ROUTE_2_DIRE_HIT`); item ball at (2,23) = MAX_POTION + (`EVENT_ROUTE_2_MAX_POTION`); hidden MAX_ETHER at (7,23), hidden FULL_HEAL + at (4,14), hidden FULL_RESTORE at (4,27), hidden REVIVE at (11,30). +21. Battle `TrainerBugCatcherEd` - object at (5,5), facing right, sight 4. + Party L30 BEEDRILL x3. Postcondition `EVENT_BEAT_BUG_CATCHER_ED`. +22. Walk off the north edge of `ROUTE_2` into `PEWTER_CITY` + (`connection north, PewterCity, PEWTER_CITY, -5`). Postcondition + `ENGINE_FLYPOINT_PEWTER` set by `PewterCityFlypointCallback`. +23. Talk to `PEWTERCITY_GRAMPS` at (29,17) (he paces x 27..31 on y=17). + Precondition `EVENT_GOT_SILVER_WING` clear (Gold). + Postcondition: SILVER_WING in bag, `EVENT_GOT_SILVER_WING` set. + On Silver: RAINBOW_WING, `EVENT_GOT_RAINBOW_WING` and + `EVENT_TEAM_ROCKET_DISBANDED` set. +24. Optional: warp 4 at (13,25) -> `PEWTER_POKECENTER_1F`. Heal at + `PEWTERPOKECENTER1F_NURSE` (3,1). Talk to `Chris` at (7,2) to trade a GLOOM + for RAPIDASH "RUNNY". Optional: fruit trees at (32,3) = ICE_BERRY and + (30,3) = MINT_BERRY. +25. `PEWTER_CITY` warp 2 at (16,17) -> `PEWTER_GYM` (arrive at warp 1, (4,13)). +26. Optional: battle `TrainerCamperJerry`, object at (2,5), facing right, + sight 3. L37 SANDSLASH. +27. Talk to `PEWTERGYM_BROCK` at (5,1). Precondition `ENGINE_BOULDERBADGE` + clear. Battle `loadtrainer BROCK, BROCK1` (5 mon, L41-44). + Postconditions: `EVENT_BEAT_BROCK`, `EVENT_BEAT_CAMPER_JERRY`, + `ENGINE_BOULDERBADGE`. +28. Exit warps 1/2 at (4,13)/(5,13) -> `PEWTER_CITY` warp 2; heal. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| All maps / warps / objects / bg events for this section | `src/import/RomExtractorGen2.lua` (`MAP_GROUP_COUNT = 26`, line 47; the group walk at line 527) | implemented - every Kanto group is extracted, so these maps are data, not hand-ported code | +| `MAPCALLBACK_TILES` + `changeblock` (Route 19 boulders) | `src/script/gen2/Opcodes.lua:127` (`changeblock`), driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| `special SnorlaxAwake` | `src/script/gen2/Specials.lua:1562` `H.SnorlaxAwake`, with `Specials.SNORLAX_PROXIMITY` transcribing `.ProximityCoords` and `Specials.POKE_FLUTE_SONG = "Music_PokeFluteChannel"`; hooks `currentMusic` / `playerCell` at `src/world/gen2/World.lua:2712` and `:2723` | implemented | +| POKe FLUTE radio channel (knob 78 / 20.0) + EXPN CARD gate | `src/ui/gen2/Pokegear.lua:812-815` (the `knob = 78` row, `ctx.inJohto` / `ctx.expnCard`) | implemented | +| `BATTLETYPE_FORCEITEM` -> Snorlax holds LEFTOVERS | `src/world/gen2/World.lua:111` and `:4576` | implemented | +| `loadwildmon` / `startbattle` / `reloadmapafterbattle` / `disappear` | `src/script/gen2/Vm.lua:806-841` | implemented | +| Catching a wild mon (Ultra Balls on Snorlax) | `src/battle/gen2/Catching.lua` | implemented | +| Trainer battles from `OBJECTTYPE_TRAINER` rows (Rob / Doug / Ed / Jerry) | `src/world/gen2/Trainers.lua`, `src/script/gen2/Vm.lua:806` (`loadtrainer`) | implemented | +| Gym leader script shape (`checkflag`/`winlosstext`/`setflag ENGINE_BOULDERBADGE`) | `src/script/gen2/Vm.lua:208` (`setflag`/`clearflag`), `src/world/gen2/World.lua:1296-1340` (`engineFlags`) | implemented | +| `BadgeTypeBoosts` - BOULDERBADGE boosting ROCK moves (`data/types/badge_type_boosts.asm`, `engine/battle/misc.asm:146 DoBadgeTypeBoosts`) | no match anywhere in `src/battle/gen2/` | **missing** (the Gen 1 port has its own badge table in `src/battle/Damage.lua:28`, but nothing in the Gen 2 battle path reads badges) | +| Item ball pickup (Route 2 CARBOS / DIRE_HIT / ELIXER / MAX_POTION) | extracted into `obj.itemball` at `src/import/RomExtractorGen2.lua:2874` and `:2968`, but no consumer exists in `src/world/gen2/`; `src/script/gen2/CallAsm.lua:550` stubs `TryReceiveItem` with "wItemBallItemID / wItemBallQuantity are set by a script path this port does not run" | **missing** | +| Hidden items (Diglett's Cave MAX_REVIVE, the four Route 2 hidden items, Vermilion FULL_HEAL) | `src/world/gen2/HiddenItems.lua`, wired at `src/world/gen2/World.lua:5291` and the ITEMFINDER path at `:3423` | implemented | +| `verbosegiveitem` (NUGGET, SILVER_WING/RAINBOW_WING) | `src/script/gen2/Opcodes.lua:163`, `src/script/gen2/Vm.lua:501` / `:1201` | implemented | +| `fruittree` (PSNCUREBERRY, ICE_BERRY, MINT_BERRY) | `src/script/gen2/Opcodes.lua:160`, `src/world/gen2/World.lua:1058` (`fruitTreeItem`) | implemented | +| `trade NPC_TRADE_CHRIS` (GLOOM -> RAPIDASH) | `src/core/gen2/NpcTrade.lua`, `src/script/gen2/Opcodes.lua:155` | implemented | +| `checkver` Gold/Silver split in `PewterCityGrampsScript` | `src/script/gen2/Vm.lua:774`, `src/world/gen2/World.lua:1287` (`gsVersion`) | implemented | +| `setflag ENGINE_FLYPOINT_PEWTER` / `_VERMILION` and Fly | `src/world/gen2/World.lua:5628` | implemented | +| CUT field move + HIVEBADGE check | `src/world/gen2/FieldMoves.lua` (badge table at lines 100-101) | implemented | +| Bike allowed in `CAVE` (Diglett's Cave) | `src/world/gen2/Bike.lua` | partial - the file exists and comments the `hiddenitem`/BICYCLE interaction, but the `.CheckEnvironment` outdoor/CAVE/GATE test was not located; verify before relying on it | +| `gettrainername` for the beaten-gym statue | `src/script/gen2/Vm.lua:407` | implemented | + +--- + +## 6. Unresolved / verify by hand + +- **Brock's party order.** The FAQ lists Graveler, Omastar, Rhyhorn, Onix, + Kabutops. `data/trainers/parties.asm:270` `BrockGroup` is Graveler (41), + **Rhyhorn (41), Omastar (42)**, Onix (44), Kabutops (42) - slots 2 and 3 are + swapped relative to the FAQ. Levels and species set match. +- **All the FAQ's prize money figures are exactly 4x the asm value.** + `ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300`) is + `base reward x last mon level`, with no multiplier. Brock 25 x 42 = 1050 (FAQ + says 4,200); Rob 4 x 32 = 128 (FAQ 512); Doug 4 x 34 = 136 (FAQ 544); Ed + 4 x 30 = 120 (FAQ 480); Jerry 5 x 37 = 185 (FAQ 740). The FAQ's EXP figures + likewise do not come from GS. Treat the money/EXP numbers in this section as + belonging to a different game (most likely HeartGold/SoulSilver, which the + text references) and ignore them for a GS bot. +- **"Viridian Forest".** The FAQ says the Max Potion is "in Viridian Forest". + There is no Viridian Forest map in `constants/map_constants.asm`; the + MAX_POTION is `ROUTE2_POKE_BALL2` at (2,23) on `ROUTE_2`, in the area the + forest used to occupy. +- **"the man just to the right of the Pokemart".** `PEWTERCITY_GRAMPS` spawns at + (29,17) with a `WALK_LEFT_RIGHT` radius of 2 (so x 27..31); the mart door is + warp 3 at (23,17). He is to the right of the mart but not adjacent. +- **"The Pokemon Center has a lady".** The trade NPC is `Chris`, sprite + `SPRITE_POKEFAN_M` at (7,2). Male sprite, name CHRIS. +- **Which cut tree the FAQ means.** The two trees named in step 14 and step 17 + above are inferred from the block dump of `maps/Route2.blk` plus the item/warp + positions; they are not labelled anywhere in asm. Three other `CUT_TREE` + blocks exist on Route 2 (see the cut-tree table) and one of those may be the + intended route instead. Verify with a real walk. +- **Diglett's Cave pocket connectivity.** The three-pocket reading is derived by + hand from `maps/DiglettsCave.blk` block ids against + `data/tilesets/kanto_collision.asm`; the per-tile collision of the "long cave" + interior was not fully traced, only that every warp block is reachable within + its pocket. The FAQ's described route is consistent with it. +- **"the route is cleared to Diglett's Cave and through Route 8".** Nothing in + `maps/VermilionCity.asm` or `maps/Route11.asm` gates Route 11 / Route 8 on the + Snorlax; the only thing `EVENT_FOUGHT_SNORLAX` unlocks outside Vermilion is the + right-hand Black Belt object in `maps/VictoryRoadGate.asm:119`. The Snorlax + blocks only the Diglett's Cave door tile at (34,7). +- **"Keep around those four Ultra Balls"** and the Rest/Thunder Wave catch + advice are strategy, not asm; nothing enforces a ball count. +- **`src/world/gen2/Bike.lua` CAVE permission.** Marked partial in section 5 - + the port file was read but the equivalent of `BikeFunction.CheckEnvironment` + (`engine/events/overworld.asm:1665`) was not located, so "you can bike through + Diglett's Cave in the port" is unverified. diff --git a/docs/gold-walkthrough/asm-walk/section-25-lugia-gold-and-ho-oh-silver.md b/docs/gold-walkthrough/asm-walk/section-25-lugia-gold-and-ho-oh-silver.md new file mode 100644 index 00000000..6ac04beb --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-25-lugia-gold-and-ho-oh-silver.md @@ -0,0 +1,987 @@ +# Section 25 - Lugia (Gold) and Ho-Oh (Silver) + +Source: `../section-25-lugia-gold-and-ho-oh-silver.txt` +Maps covered: `ROUTE_41`, `WHIRL_ISLAND_NE`, `WHIRL_ISLAND_NW`, `WHIRL_ISLAND_SW`, +`WHIRL_ISLAND_SE`, `WHIRL_ISLAND_CAVE`, `WHIRL_ISLAND_B1F`, `WHIRL_ISLAND_B2F`, +`WHIRL_ISLAND_LUGIA_CHAMBER`, `ECRUTEAK_CITY`, `ECRUTEAK_TIN_TOWER_ENTRANCE`, +`ECRUTEAK_TIN_TOWER_BACK_ENTRANCE`, `TIN_TOWER_1F` .. `TIN_TOWER_9F`, +`TIN_TOWER_ROOF` +Badges / key milestones in this section: no badges. The milestone is catching the +*other* version mascot post-Elite-Four - `EVENT_FOUGHT_LUGIA` / +`EVENT_FOUGHT_HO_OH`, armed by the wing the Pewter City gramps hands over +(`maps/PewterCity.asm`, `PewterCityGrampsScript`). + +This section is two mutually exclusive branches. The walkthrough labels them +"Whirl Islands - Gold Version" and "Tin Tower - Silver Version", and the asm +agrees: `Script_checkver` (`engine/overworld/scripting.asm:1471`) returns +`GS_VERSION` (`constants/misc_constants.asm:21-23`: 0 = Gold, 1 = Silver), so on +each mascot the `checkver / iftrue .Silver` pair picks the level. In Gold, Lugia +is **level 70** and Ho-Oh was already caught at level 40; in Silver it is the +mirror image. + +--- + +## 1. Route order + +The walkthrough opens on the Magnet Train back to Johto - that hop belongs to the +neighbouring Kanto section, and the only thing this section needs from it is that +the player already holds the version-opposite wing. + +**Gold branch (Lugia, Whirl Islands)** + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `OLIVINE_CITY` | (fly target) | Fly | west connection to `ROUTE_40` (`data/maps/attributes.asm:145`) | "Fly to Olivine City" | +| 2 | `ROUTE_40` | `data/maps/attributes.asm:223` | east connection from Olivine | south connection to `ROUTE_41` (`attributes.asm:224`) | surf southwest | +| 3 | `ROUTE_41` | `maps/Route41.asm` | north connection from Route 40 | `warp_event 36, 19` (warp 2) | "surf southwest and west until you reach the northeast cave", clearing whirlpools | +| 4 | `WHIRL_ISLAND_NE` | `maps/WhirlIslandNE.asm` | warp 1 `(3, 13)` | warp 2 `(17, 3)` or warp 3 `(13, 11)` | Flash, ledges, `ULTRA_BALL` at `(11, 11)` | +| 5 | `WHIRL_ISLAND_B1F` | `maps/WhirlIslandB1F.asm` | warp 2 `(35, 3)` / warp 3 `(29, 9)` | warp 7 `(25, 21)` or warp 8 `(13, 27)` | ledge chain, `ESCAPE_ROPE` at `(19, 26)`, `CALCIUM` at `(33, 23)` | +| 6 | `WHIRL_ISLAND_CAVE` | `maps/WhirlIslandCave.asm` | B1F warp 9 `(17, 21)` -> Cave warp 1 `(7, 5)` | Cave warp 2 `(3, 13)` -> `WHIRL_ISLAND_NW` | "head out the door to breathe, but that's pointless" | +| 7 | `WHIRL_ISLAND_SW` | `maps/WhirlIslandSW.asm` | B1F warp 4 `(9, 31)` / warp 5 `(23, 31)` | warp 5 `(17, 15)` -> B2F warp 4 | `GUARD_SPEC` at `(15, 2)` | +| 8 | `WHIRL_ISLAND_B2F` | `maps/WhirlIslandB2F.asm` | warp 1 `(11, 5)` from B1F warp 7 | warp 3 `(7, 25)` | `MAX_REVIVE` `(6, 4)`, waterfall descent, "head left and through the door" | +| 9 | `WHIRL_ISLAND_LUGIA_CHAMBER` | `maps/WhirlIslandLugiaChamber.asm` | warp 1 `(9, 13)` | Escape Rope (environment is `CAVE`) | surf north to Lugia at `(9, 5)` | + +**Silver branch (Ho-Oh, Tin Tower)** + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `ECRUTEAK_CITY` | `maps/EcruteakCity.asm` | Fly | warp 3 `(18, 11)` | "Go back to Ecruteak City" | +| 2 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | `maps/EcruteakTinTowerEntrance.asm` | warp 1 `(4, 17)` / warp 2 `(5, 17)` | warp 3 `(5, 3)` -> warp 4 `(17, 15)`, then warp 5 `(17, 3)` | "the Bell Tower entry house. Talk to the man" | +| 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | `maps/EcruteakTinTowerBackEntrance.asm` | warp 3 `(2, 4)` | warp 1 `(7, 4)` / warp 2 `(7, 5)` -> `ECRUTEAK_CITY` warps 4/5 `(20, 2)` `(20, 3)` | "follow up the trail to the tower itself" | +| 4 | `ECRUTEAK_CITY` (north strip) | `maps/EcruteakCity.asm` | warps 4/5 | warp 12 `(37, 7)` | walk east along the fenced strip to Tin Tower | +| 5 | `TIN_TOWER_1F` | `maps/TinTower1F.asm` | warp 1 `(9, 15)` / warp 2 `(10, 15)` | warp 3 `(10, 2)` | "another bald man will let you through" - the sage occupies the stair tile | +| 6 | `TIN_TOWER_2F` | `maps/TinTower2F.asm` | warp 2 `(10, 2)` | warp 1 `(10, 14)` | "wild Rattatas will start converging on you" | +| 7 | `TIN_TOWER_3F` | `maps/TinTower3F.asm` | warp 1 `(10, 14)` | warp 2 `(16, 2)` | `FULL_HEAL` at `(3, 14)`, jump-ledge puzzle | +| 8 | `TIN_TOWER_4F` | `maps/TinTower4F.asm` | warp 2 `(16, 2)` | warps 1 `(2, 4)`, 3 `(2, 14)`, 4 `(17, 15)` | `ULTRA_BALL`, `SUPER_POTION`, `ESCAPE_ROPE` | +| 9 | `TIN_TOWER_5F` | `maps/TinTower5F.asm` | warps 2/3/4 | warp 1 `(11, 15)` | `RARE_CANDY` at `(9, 9)` | +| 10 | `TIN_TOWER_6F` | `maps/TinTower6F.asm` | warp 2 `(11, 15)` | warp 1 `(3, 9)` | "go up and you'll get a Max Potion" (see Unresolved) | +| 11 | `TIN_TOWER_7F` | `maps/TinTower7F.asm` | warp 1 `(3, 9)` | warp 2 `(10, 15)`, plus in-floor warp pair 3/4 and warp 5 to 9F | `MAX_REVIVE` at `(16, 1)`; "go through the next two warps" | +| 12 | `TIN_TOWER_8F` | `maps/TinTower8F.asm` | warp 1 `(2, 5)` | warps 2-6 into 9F | `NUGGET`, `MAX_ELIXER`, `FULL_RESTORE` | +| 13 | `TIN_TOWER_9F` | `maps/TinTower9F.asm` | warps 1/2/3/5/6/7 from 7F+8F | warp 4 `(7, 9)` | "cross the planks down, then go up the ladder" | +| 14 | `TIN_TOWER_ROOF` | `maps/TinTowerRoof.asm` | warp 1 `(9, 13)` | back down warp 1 then Escape Rope | level-70 Ho-Oh at `(9, 5)` | + +The walkthrough's closing line ("ride the Magnet Train back to Kanto and fly back +to Pewter City") hands off to the next section; nothing on that hop is resolved +here. + +--- + +## 2. Maps + +### ROUTE_41 + +- Script: `maps/Route41.asm` +- Blocks: `maps/Route41.blk` +- Header: `data/maps/maps.asm:443` -> `TILESET_JOHTO`, `ROUTE`, `LANDMARK_ROUTE_41`, `MUSIC_ROUTE_36`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:410` -> `map_const ROUTE_41, 25, 27` (group 22 `CIANWOOD`, map 2) +- Connections (`data/maps/attributes.asm:227-229`): north `Route40`, west `CianwoodCity` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 12 | 17 | `WHIRL_ISLAND_NW` | 1 | +| 2 | 36 | 19 | `WHIRL_ISLAND_NE` | 1 | +| 3 | 12 | 37 | `WHIRL_ISLAND_SW` | 1 | +| 4 | 36 | 45 | `WHIRL_ISLAND_SE` | 1 | + +**Coord events**: none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 9 | 35 | `BGEVENT_ITEM` | `Route41HiddenMaxEther` -> `hiddenitem MAX_ETHER, EVENT_ROUTE_41_HIDDEN_MAX_ETHER` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE41_OLIVINE_RIVAL1` | `SPRITE_OLIVINE_RIVAL` | 32 | 6 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (sight 3) | `TrainerSwimmermCharlie` | -1 | +| `ROUTE41_OLIVINE_RIVAL2` | `SPRITE_OLIVINE_RIVAL` | 46 | 8 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmermGeorge` | -1 | +| `ROUTE41_OLIVINE_RIVAL3` | `SPRITE_OLIVINE_RIVAL` | 20 | 26 | `SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmermBerke` | -1 | +| `ROUTE41_OLIVINE_RIVAL4` | `SPRITE_OLIVINE_RIVAL` | 32 | 30 | `SPINCLOCKWISE` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmermKirk` | -1 | +| `ROUTE41_OLIVINE_RIVAL5` | `SPRITE_OLIVINE_RIVAL` | 19 | 46 | `SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmermMathew` | -1 | +| `ROUTE41_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 17 | 4 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmerfKaylee` | -1 | +| `ROUTE41_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 23 | 19 | `STANDING_UP` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmerfSusie` | -1 | +| `ROUTE41_SWIMMER_GIRL3` | `SPRITE_SWIMMER_GIRL` | 27 | 34 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (3) | `TrainerSwimmerfDenise` | -1 | +| `ROUTE41_SWIMMER_GIRL4` | `SPRITE_SWIMMER_GIRL` | 44 | 28 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (4) | `TrainerSwimmerfKara` | -1 | +| `ROUTE41_SWIMMER_GIRL5` | `SPRITE_SWIMMER_GIRL` | 9 | 50 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (2) | `TrainerSwimmerfWendy` | -1 | + +Note the sight ranges: Kara is `4`, the widest on the route, which is why the +walkthrough uses her as the landmark for the northeast whirlpool ("the whirlpool +is just northwest of where you fought Swimmer Kara"). Her object sits at +`(44, 28)`; the trainer flag is `EVENT_BEAT_SWIMMERF_KARA`, so once beaten she is +still standing there (`event flag` on the row is `-1`, i.e. never masked) and +still walks into your path - the walkthrough's joke about swimmer stamina is +literally the object table. + +**Scripts of interest** + +- `TrainerSwimmerfKara` - `trainer SWIMMERF, KARA, EVENT_BEAT_SWIMMERF_KARA, ...`. + Post-battle it only prints `SwimmerfKaraAfterBattleText` ("I heard roars from + deep inside the ISLANDS"). No flag beyond the beat flag, no gate. + +**Whirlpools**: they are *map blocks*, not events. `data/collision/field_move_blocks.asm:48-55` +gives `WhirlpoolBlockPointers` -> `TILESET_JOHTO` -> `db $07, $36, 0` (facing +block `$07`, replacement `$36`, animation type 0). A bot that wants the whirlpool +coordinates has to scan `maps/Route41.blk` for block id `$07`; the collision +value under it is `COLL_WHIRLPOOL` `$24` (`constants/collision_constants.asm:27`, +tested by `CheckWhirlpoolTile`, `home/map_objects.asm:176-183`). + +**Wild encounters** + +- Surfing, `data/wild/johto_water.asm:170-181`, `def_water_wildmons ROUTE_41`, + encounter rate `6 percent`. Gold: `20 TENTACOOL`, `20 TENTACRUEL`, `20 MANTINE`. + Silver: `20 TENTACOOL`, `20 TENTACRUEL`, `15 TENTACOOL`. +- Fishing: header fish group is `FISHGROUP_OCEAN` (`data/wild/fish.asm:13`, + `.Ocean_Old/.Ocean_Good/.Ocean_Super`). +- The walkthrough's Max Repel advice is aimed squarely at this 6% table. + +--- + +### WHIRL_ISLAND_NE + +- Script: `maps/WhirlIslandNE.asm` +- Blocks: `maps/WhirlIslandNE.blk` +- Header: `data/maps/maps.asm:137` -> `TILESET_DARK_CAVE`, `CAVE`, `LANDMARK_WHIRL_ISLANDS`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, `FISHGROUP_WHIRL_ISLANDS` +- Dimensions: `constants/map_constants.asm:124` -> `map_const WHIRL_ISLAND_NE, 10, 9` (group 3 `DUNGEONS`, map 59) +- Connections: none (island interior). + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 13 | `ROUTE_41` | 2 | +| 2 | 17 | 3 | `WHIRL_ISLAND_B1F` | 2 | +| 3 | 13 | 11 | `WHIRL_ISLAND_B1F` | 3 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDNE_POKE_BALL` | `SPRITE_POKE_BALL` | 11 | 11 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandNEUltraBall` (`47:401e`) | `EVENT_WHIRL_ISLAND_NE_ULTRA_BALL` | + +`WhirlIslandNEUltraBall` is a bare `itemball ULTRA_BALL`. + +--- + +### WHIRL_ISLAND_NW + +- Script: `maps/WhirlIslandNW.asm` - no scripts at all, warps only +- Blocks: `maps/WhirlIslandNW.blk` +- Header: `data/maps/maps.asm:136`, same row shape as NE +- Dimensions: `constants/map_constants.asm:123` -> `5, 9` (group 3, map 58) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 7 | `ROUTE_41` | 1 | +| 2 | 5 | 3 | `WHIRL_ISLAND_B1F` | 1 | +| 3 | 3 | 15 | `WHIRL_ISLAND_SW` | 4 | +| 4 | 7 | 15 | `WHIRL_ISLAND_CAVE` | 2 | + +No coord events, bg events or objects. + +--- + +### WHIRL_ISLAND_SW + +- Script: `maps/WhirlIslandSW.asm` +- Blocks: `maps/WhirlIslandSW.blk` +- Header: `data/maps/maps.asm:138` +- Dimensions: `constants/map_constants.asm:125` -> `10, 9` (group 3, map 60) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 7 | `ROUTE_41` | 3 | +| 2 | 17 | 3 | `WHIRL_ISLAND_B1F` | 5 | +| 3 | 3 | 3 | `WHIRL_ISLAND_B1F` | 4 | +| 4 | 3 | 15 | `WHIRL_ISLAND_NW` | 3 | +| 5 | 17 | 15 | `WHIRL_ISLAND_B2F` | 4 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDSW_POKE_BALL` | `SPRITE_POKE_BALL` | 15 | 2 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandSWGuardSpec` (`47:4044`) | `EVENT_WHIRL_ISLAND_SW_GUARD_SPEC` | + +`itemball GUARD_SPEC` - the walkthrough's "Guard Specs". + +**Wild encounters**: surfing, `data/wild/johto_water.asm:79-84`, +`def_water_wildmons WHIRL_ISLAND_SW`, rate `4 percent`: `20 TENTACOOL`, +`15 HORSEA`, `20 TENTACRUEL`. + +--- + +### WHIRL_ISLAND_SE + +- Script: `maps/WhirlIslandSE.asm` - warps only +- Blocks: `maps/WhirlIslandSE.blk` +- Header: `data/maps/maps.asm:140` +- Dimensions: `constants/map_constants.asm:127` -> `5, 9` (group 3, map 62) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 13 | `ROUTE_41` | 4 | +| 2 | 5 | 3 | `WHIRL_ISLAND_B1F` | 6 | + +No coord events, bg events or objects. No water encounter table. + +--- + +### WHIRL_ISLAND_CAVE + +- Script: `maps/WhirlIslandCave.asm` - warps only +- Blocks: `maps/WhirlIslandCave.blk` +- Header: `data/maps/maps.asm:139` +- Dimensions: `constants/map_constants.asm:126` -> `5, 9` (group 3, map 61) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 5 | `WHIRL_ISLAND_B1F` | 9 | +| 2 | 3 | 13 | `WHIRL_ISLAND_NW` | 4 | + +This is the "you can now head out the door to breathe, but that's pointless" room: +it is a pure corridor between B1F and the NW island. + +--- + +### WHIRL_ISLAND_B1F + +- Script: `maps/WhirlIslandB1F.asm` (`47:4096` `WhirlIslandB1F_MapScripts`) +- Blocks: `maps/WhirlIslandB1F.blk` (`2b:6f61 WhirlIslandB1F_Blocks`) +- Header: `data/maps/maps.asm:141` +- Dimensions: `constants/map_constants.asm:128` -> `20, 18` (group 3, map 63) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `WHIRL_ISLAND_NW` | 2 | +| 2 | 35 | 3 | `WHIRL_ISLAND_NE` | 2 | +| 3 | 29 | 9 | `WHIRL_ISLAND_NE` | 3 | +| 4 | 9 | 31 | `WHIRL_ISLAND_SW` | 3 | +| 5 | 23 | 31 | `WHIRL_ISLAND_SW` | 2 | +| 6 | 31 | 29 | `WHIRL_ISLAND_SE` | 2 | +| 7 | 25 | 21 | `WHIRL_ISLAND_B2F` | 1 | +| 8 | 13 | 27 | `WHIRL_ISLAND_B2F` | 2 | +| 9 | 17 | 21 | `WHIRL_ISLAND_CAVE` | 1 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 30 | 4 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenRareCandy` -> `hiddenitem RARE_CANDY, EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY` | +| 36 | 18 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenUltraBall` -> `hiddenitem ULTRA_BALL, EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL` | +| 2 | 23 | `BGEVENT_ITEM` | `WhirlIslandB1FHiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 13 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB1FFullRestore` | `EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE` | +| `WHIRLISLANDB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 2 | 18 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB1FCarbos` | `EVENT_WHIRL_ISLAND_B1F_CARBOS` | +| `WHIRLISLANDB1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 33 | 23 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB1FCalcium` | `EVENT_WHIRL_ISLAND_B1F_CALCIUM` | +| `WHIRLISLANDB1F_POKE_BALL4` | `SPRITE_POKE_BALL` | 17 | 8 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB1FNugget` | `EVENT_WHIRL_ISLAND_B1F_NUGGET` | +| `WHIRLISLANDB1F_POKE_BALL5` | `SPRITE_POKE_BALL` | 19 | 26 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB1FEscapeRope` | `EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE` | +| `WHIRLISLANDB1F_BOULDER` | `SPRITE_BOULDER` | 23 | 26 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `WhirlIslandB1FBoulder` (`jumpstd StrengthBoulderScript`) | -1 | + +The boulder at `(23, 26)` sits two tiles east of the Escape Rope ball and is the +only Strength object in the dungeon. The walkthrough never mentions it. + +**Wild encounters**: grass/cave-floor, `data/wild/johto_grass.asm:1213-1239`, +`def_grass_wildmons WHIRL_ISLAND_B1F`, rates `6 percent` for all three times of +day, and the morn/day/nite slot lists are identical: +`23 KRABBY`, `24 ZUBAT`, `25 KRABBY`, `23 SEEL`, `24 GOLBAT`, `25 SEEL`, `25 SEEL`. +No water table for B1F. + +--- + +### WHIRL_ISLAND_B2F + +- Script: `maps/WhirlIslandB2F.asm` +- Blocks: `maps/WhirlIslandB2F.blk` +- Header: `data/maps/maps.asm:142` +- Dimensions: `constants/map_constants.asm:129` -> `10, 18` (group 3, map 64) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 5 | `WHIRL_ISLAND_B1F` | 7 | +| 2 | 7 | 11 | `WHIRL_ISLAND_B1F` | 8 | +| 3 | 7 | 25 | `WHIRL_ISLAND_LUGIA_CHAMBER` | 1 | +| 4 | 13 | 31 | `WHIRL_ISLAND_SW` | 5 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDB2F_POKE_BALL1` | `SPRITE_POKE_BALL` | 10 | 11 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB2FFullRestore` | `EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE` | +| `WHIRLISLANDB2F_POKE_BALL2` | `SPRITE_POKE_BALL` | 6 | 4 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB2FMaxRevive` | `EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE` | +| `WHIRLISLANDB2F_POKE_BALL3` | `SPRITE_POKE_BALL` | 5 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `WhirlIslandB2FMaxElixer` | `EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER` | + +`(6, 4)` is the single `MAX_REVIVE` in the whole Whirl Islands (see Unresolved +for the walkthrough's "Max Revive x2"). + +**Wild encounters** + +- Floor: `data/wild/johto_grass.asm:1241-1267`, rate `6 percent`, identical to B1F + (`23 KRABBY / 24 ZUBAT / 25 KRABBY / 23 SEEL / 24 GOLBAT / 25 SEEL / 25 SEEL`). +- Water: `data/wild/johto_water.asm:86-91`, rate `4 percent`: `15 HORSEA`, + `20 HORSEA`, `20 TENTACRUEL`. + +The "two items on the cliff but you can't reach those" line matches the two balls +on the west shelf (`(5, 12)` Max Elixer and `(10, 11)` Full Restore are reachable +from the other side of the floor; exactly which two the FAQ means is a `.blk` +question, see Unresolved). + +--- + +### WHIRL_ISLAND_LUGIA_CHAMBER + +- Script: `maps/WhirlIslandLugiaChamber.asm` (`47:418c` callback, `47:41a0 Lugia`) +- Blocks: `maps/WhirlIslandLugiaChamber.blk` (`2b:717d`) +- Header: `data/maps/maps.asm:143` -> `TILESET_DARK_CAVE`, `CAVE`, `LANDMARK_WHIRL_ISLANDS`, `MUSIC_UNION_CAVE`, phone `TRUE`, `PALETTE_DARK`, `FISHGROUP_WHIRL_ISLANDS` +- Dimensions: `constants/map_constants.asm:130` -> `10, 9` (group 3, map 65) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 13 | `WHIRL_ISLAND_B2F` | 3 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `WHIRLISLANDLUGIACHAMBER_LUGIA` | `SPRITE_LUGIA` | 9 | 5 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT`, palette `PAL_NPC_BLUE` | `Lugia` | `EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA` | + +**Scripts of interest** + +- `WhirlIslandLugiaChamberLugiaCallback` - `callback MAPCALLBACK_OBJECTS`. + `checkevent EVENT_FOUGHT_LUGIA / iftrue .NoAppear`, then + `checkitem SILVER_WING / iftrue .Appear`, else `.NoAppear`. So Lugia is on the + map **only while the SILVER_WING is in the bag and the fight has not happened**. + The wing is a `checkitem`, not an event - selling or storing it makes Lugia + vanish again. +- `Lugia` (`47:41a0`) - `faceplayer`, `opentext`, `writetext LugiaText` + ("Gyaaas!"), `cry LUGIA`, `pause 15`, `closetext`, + **`setevent EVENT_FOUGHT_LUGIA`**, `checkver`, `iftrue .Silver`. + - Gold arm: `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM`, + `loadwildmon LUGIA, 70`, `startbattle`, + `disappear WHIRLISLANDLUGIACHAMBER_LUGIA`, `reloadmapafterbattle`, `end`. + - `.Silver`: identical but `loadwildmon LUGIA, 40`. + - `EVENT_FOUGHT_LUGIA` is set **before** the battle, so fleeing, fainting or + knocking it out all consume the encounter. This is exactly why the + walkthrough insists on saving first: there is no re-arm path in the asm. + - `BATTLETYPE_FORCEITEM` (`constants/battle_constants.asm:101`) makes + `InitEnemyMon`'s `.WildItem` arm hand over `wBaseItem1` unconditionally + (`engine/battle/core.asm:5765-5773`). Lugia's `data/pokemon/base_stats/lugia.asm` + row is `db NO_ITEM, NO_ITEM`, so it holds nothing - the forced-item type is + inherited boilerplate here. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_FOUGHT_LUGIA` | `constants/event_flags.asm:455` | read by the callback, set by `Lugia` before `startbattle` | one-shot. Once set, Lugia never reappears | +| `EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA` | `constants/event_flags.asm:1247` | the object row's mask flag, driven by `appear`/`disappear` | object visibility only | +| `EVENT_GOT_SILVER_WING` | `constants/event_flags.asm:130` | set by `PewterCityGrampsScript` (Gold) / `RadioTower5F` (Silver) | prerequisite for holding `SILVER_WING` | + +**Wild encounters** + +- Floor: `data/wild/johto_grass.asm:1269-1295`, rate `6 percent`: + `24 KRABBY`, `25 ZUBAT`, `26 KRABBY`, `24 SEEL`, `25 GOLBAT`, `26 SEEL`, `26 SEEL`. +- Water: `data/wild/johto_water.asm:93-98`, rate `4 percent`: `20 HORSEA`, + `20 TENTACRUEL`, `20 SEADRA`. This is the only Seadra slot in the dungeon, and + it is on the water directly in front of Lugia. + +**Catch math** (`data/pokemon/base_stats/lugia.asm`): `db 3 ; catch rate`. +`UltraBallMultiplier` doubles it to 6; `HeavyBallMultiplier` +(`engine/items/item_effects.asm:752`) adds a flat offset by dex weight, and +Lugia's dex weight is `4760` tenths of a pound = 216 kg +(`data/pokemon/dex_entries/gold/lugia.asm`), which lands in the `+20` bracket -> +effective 23. Thard_Verad's tip in the FAQ is correct and is worth ~4x the Ultra +Ball rate. + +--- + +### ECRUTEAK_CITY (transit only) + +- Script: `maps/EcruteakCity.asm` +- Header: `data/maps/maps.asm:173` -> `TILESET_JOHTO`, `TOWN`, `LANDMARK_ECRUTEAK_CITY`, `MUSIC_ECRUTEAK_CITY`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:159` -> `20, 18` (group 4, map 9) + +**Warps** (only the rows this section uses) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 3 | 18 | 11 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 1 | +| 4 | 20 | 2 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 1 | +| 5 | 20 | 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 2 | +| 12 | 37 | 7 | `TIN_TOWER_1F` | 1 | +| 13 | 5 | 5 | `BURNED_TOWER_1F` | 1 | + +There is a `bg_event 38, 10, BGEVENT_READ, TinTowerSign` next to the tower door. + +--- + +### ECRUTEAK_TIN_TOWER_ENTRANCE + +- Script: `maps/EcruteakTinTowerEntrance.asm` +- Blocks: `maps/EcruteakTinTowerEntrance.blk` +- Header: `data/maps/maps.asm:165` -> `TILESET_TOWER`, `INDOOR`, `LANDMARK_ECRUTEAK_CITY`, `MUSIC_ECRUTEAK_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:151` -> `10, 9` (group 4, map 1) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `ECRUTEAK_CITY` | 3 | +| 2 | 5 | 17 | `ECRUTEAK_CITY` | 3 | +| 3 | 5 | 3 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 4 | +| 4 | 17 | 15 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 3 | +| 5 | 17 | 3 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | 3 | + +Warps 3 and 4 are a self-referential pair: the map holds two disconnected +corridors and the "walk up the stairs" is a same-map teleport from `(5, 3)` to +`(17, 15)`. + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` (0) | 4 | 7 | `EcruteakTinTowerEntranceSageBlocksLeft` (`52:400c`) | sage 2 side-steps LEFT, sage 1 is `moveobject`d to `(4, 6)` and `appear`ed, sage 2 `disappear`s | +| `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` (0) | 5 | 7 | `EcruteakTinTowerEntranceSageBlocksRight` (`52:4021`) | mirror image, sage 2 ends at `(5, 6)` | + +Scene ids come from the `scene_script` macro's inline `const` block +(`macros/scripts/maps.asm:12-33`): `SAGE_BLOCKS` = 0, `NOOP` = 1. Both scene +bodies are `end`; the scene id exists only to arm/disarm the two coord events. + +**BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ECRUTEAKTINTOWERENTRANCE_SAGE1` | `SPRITE_SAGE` | 4 | 6 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceSageScript` (`52:4037`) | `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT` | +| `ECRUTEAKTINTOWERENTRANCE_SAGE2` | `SPRITE_SAGE` | 5 | 6 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceSageScript` | `EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT` | +| `ECRUTEAKTINTOWERENTRANCE_SAGE3` | `SPRITE_SAGE` | 6 | 9 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceWanderingSageScript` (`52:404b`) | -1 | +| `ECRUTEAKTINTOWERENTRANCE_GRAMPS` | `SPRITE_GRAMPS` | 3 | 11 | `WANDER` (1,1) | `OBJECTTYPE_SCRIPT` | `EcruteakTinTowerEntranceGrampsScript` | -1 | + +Object rows are masked when their flag is **set** (`CheckObjectFlag`, +`engine/overworld/map_objects_2.asm:32-61`: `EventFlagAction`/`CHECK_FLAG` +non-zero -> `.masked`). `InitializeEventsScript` +(`engine/events/std_scripts.asm:438`, line 533) does +`setevent EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT` at new game, so on a fresh +save only SAGE2 at `(5, 6)` is standing there. + +**Scripts of interest** + +- `EcruteakTinTowerEntranceSageScript` - `faceplayer`, `opentext`, + `checkflag ENGINE_FOGBADGE`, `iftrue .BlockPassage_GotFogBadge`. Both arms are + pure text (`EcruteakTinTowerEntranceSageText` vs `..._GotFogBadge`); **the + script never opens the path**. +- The actual unblock is `maps/EcruteakGym.asm:34`: + `setmapscene ECRUTEAK_TIN_TOWER_ENTRANCE, SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP`, + run in the same block as `setflag ENGINE_FOGBADGE` after beating Morty. With + the scene at `NOOP` the two coord events no longer match and the sage never + steps into the doorway. By section 25 the player has all 16 badges, so this is + already open - the walkthrough's "talk to the man and he'll let you through" is + flavour text, not a required action. +- `EcruteakTinTowerEntranceWanderingSageScript` - `checkevent EVENT_GOT_RAINBOW_WING`; + the post-wing line is "The TIN TOWER shook! A #MON must have returned to the + top!". This is the only in-map confirmation that Ho-Oh is now spawnable. + +--- + +### ECRUTEAK_TIN_TOWER_BACK_ENTRANCE + +- Script: `maps/EcruteakTinTowerBackEntrance.asm` - warps only, no scripts +- Blocks: `maps/EcruteakTinTowerBackEntrance.blk` +- Header: `data/maps/maps.asm:166` -> `TILESET_TRADITIONAL_HOUSE`, `INDOOR`, `LANDMARK_ECRUTEAK_CITY`, `MUSIC_ECRUTEAK_CITY`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:152` -> `4, 4` (group 4, map 2) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 4 | `ECRUTEAK_CITY` | 4 | +| 2 | 7 | 5 | `ECRUTEAK_CITY` | 5 | +| 3 | 2 | 4 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | 5 | + +--- + +### TIN_TOWER_1F + +- Script: `maps/TinTower1F.asm` (`42:4b1d TinTowerSageScript`) +- Blocks: `maps/TinTower1F.blk` +- Header: `data/maps/maps.asm:82` -> `TILESET_TOWER`, `DUNGEON`, `LANDMARK_TIN_TOWER`, `MUSIC_TIN_TOWER`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:69` -> `10, 9` (group 3, map 4) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 15 | `ECRUTEAK_CITY` | 12 | +| 2 | 10 | 15 | `ECRUTEAK_CITY` | 12 | +| 3 | 10 | 2 | `TIN_TOWER_2F` | 2 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER1F_SAGE` | `SPRITE_SAGE` | 10 | 2 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `TinTowerSageScript` | `EVENT_TEAM_ROCKET_DISBANDED` | + +**This is the real gate for the whole Silver branch.** The sage's coordinates are +`(10, 2)`, byte-for-byte the coordinates of warp 3. While the object is visible +the stair tile is occupied and 2F is unreachable. The object is masked - i.e. the +sage disappears - when `EVENT_TEAM_ROCKET_DISBANDED` +(`constants/event_flags.asm:1283`) is **set**, and that flag is only ever set by +the two Rainbow Wing hand-offs: + +- `maps/RadioTower5F.asm:124-130` (Gold): `verbosegiveitem RAINBOW_WING` (124), + `setevent EVENT_GOT_RAINBOW_WING` (129), `setevent EVENT_TEAM_ROCKET_DISBANDED` (130). +- `maps/PewterCity.asm:42-51` `.RainbowWing` (Silver): the gramps gives + `RAINBOW_WING` (49), `setevent EVENT_GOT_RAINBOW_WING` (50), + `setevent EVENT_TEAM_ROCKET_DISBANDED` (51). + +So the walkthrough's "another bald man will let you through because you have the +beautiful Rainbow Wing" is literally true, but the mechanism is a masked object +keyed on `EVENT_TEAM_ROCKET_DISBANDED`, not a `checkitem RAINBOW_WING`. + +**Wild encounters**: none - `TIN_TOWER_1F` has no `def_grass_wildmons` row +(`data/wild/johto_grass.asm` starts the tower at `TIN_TOWER_2F`, line 61). + +--- + +### TIN_TOWER_2F + +- Script: `maps/TinTower2F.asm` - warps only +- Blocks: `maps/TinTower2F.blk` +- Header: `data/maps/maps.asm:83`; Dimensions `constants/map_constants.asm:70` -> `10, 9` (group 3, map 5) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 14 | `TIN_TOWER_3F` | 1 | +| 2 | 10 | 2 | `TIN_TOWER_1F` | 3 | + +**Wild encounters**: `data/wild/johto_grass.asm:61-87`, +`def_grass_wildmons TIN_TOWER_2F`, rates `2 percent` morn/day/nite. + +- morn/day: `20 RATTATA`, `21 RATTATA`, `22 RATTATA`, `22 RATTATA`, `23 RATTATA`, `24 RATTATA`, `24 RATTATA` +- nite: `20 GASTLY`, `21 GASTLY`, `22 GASTLY`, `22 RATTATA`, `23 RATTATA`, `24 RATTATA`, `24 RATTATA` + +Every floor 2F..9F carries the identical table (checked 2F at line 61 and 9F at +line 257). The walkthrough only lists Rattata; Gastly at night is real. + +--- + +### TIN_TOWER_3F + +- Script: `maps/TinTower3F.asm`; Blocks `maps/TinTower3F.blk` +- Header: `data/maps/maps.asm:84`; Dimensions `constants/map_constants.asm:71` -> `10, 9` (group 3, map 6) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 10 | 14 | `TIN_TOWER_2F` | 1 | +| 2 | 16 | 2 | `TIN_TOWER_4F` | 2 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER3F_POKE_BALL` | `SPRITE_POKE_BALL` | 3 | 14 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower3FFullHeal` | `EVENT_TIN_TOWER_3F_FULL_HEAL` | + +Matches the walkthrough's "hop left for a Full Heal". + +--- + +### TIN_TOWER_4F + +- Script: `maps/TinTower4F.asm`; Blocks `maps/TinTower4F.blk` +- Header: `data/maps/maps.asm:85`; Dimensions `constants/map_constants.asm:72` -> `10, 9` (group 3, map 7) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 4 | `TIN_TOWER_5F` | 2 | +| 2 | 16 | 2 | `TIN_TOWER_3F` | 2 | +| 3 | 2 | 14 | `TIN_TOWER_5F` | 3 | +| 4 | 17 | 15 | `TIN_TOWER_5F` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 6 | `BGEVENT_ITEM` | `TinTower4FHiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER4F_POKE_BALL1` | `SPRITE_POKE_BALL` | 14 | 10 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower4FUltraBall` (`42:4cc1`) | `EVENT_TIN_TOWER_4F_ULTRA_BALL` | +| `TINTOWER4F_POKE_BALL2` | `SPRITE_POKE_BALL` | 17 | 14 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower4FSuperPotion` | `EVENT_TIN_TOWER_4F_SUPER_POTION` | +| `TINTOWER4F_POKE_BALL3` | `SPRITE_POKE_BALL` | 2 | 12 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower4FEscapeRope` | `EVENT_TIN_TOWER_4F_ESCAPE_ROPE` | + +--- + +### TIN_TOWER_5F + +- Script: `maps/TinTower5F.asm`; Blocks `maps/TinTower5F.blk` +- Header: `data/maps/maps.asm:86`; Dimensions `constants/map_constants.asm:73` -> `10, 9` (group 3, map 8) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 15 | `TIN_TOWER_6F` | 2 | +| 2 | 2 | 4 | `TIN_TOWER_4F` | 1 | +| 3 | 2 | 14 | `TIN_TOWER_4F` | 3 | +| 4 | 17 | 15 | `TIN_TOWER_4F` | 4 | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 14 | `BGEVENT_ITEM` | `TinTower5FHiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE` | +| 3 | 15 | `BGEVENT_ITEM` | `TinTower5FHiddenCarbos` -> `hiddenitem CARBOS, EVENT_TIN_TOWER_5F_HIDDEN_CARBOS` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER5F_POKE_BALL` | `SPRITE_POKE_BALL` | 9 | 9 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower5FRareCandy` (`42:4d12`) | `EVENT_TIN_TOWER_5F_RARE_CANDY` | + +--- + +### TIN_TOWER_6F + +- Script: `maps/TinTower6F.asm` - warps only, no items, no objects +- Blocks: `maps/TinTower6F.blk` +- Header: `data/maps/maps.asm:87`; Dimensions `constants/map_constants.asm:74` -> `10, 9` (group 3, map 9) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `TIN_TOWER_7F` | 1 | +| 2 | 11 | 15 | `TIN_TOWER_5F` | 1 | + +The walkthrough's "go up and you'll get a Max Potion ... left across the upper +bridge to get a Full Heal" floor has **no item balls and no bg events at all** in +pokegold. See Unresolved. + +--- + +### TIN_TOWER_7F + +- Script: `maps/TinTower7F.asm`; Blocks `maps/TinTower7F.blk` +- Header: `data/maps/maps.asm:88`; Dimensions `constants/map_constants.asm:75` -> `10, 9` (group 3, map 10) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 9 | `TIN_TOWER_6F` | 1 | +| 2 | 10 | 15 | `TIN_TOWER_8F` | 1 | +| 3 | 12 | 7 | `TIN_TOWER_7F` | 4 | +| 4 | 8 | 3 | `TIN_TOWER_7F` | 3 | +| 5 | 6 | 9 | `TIN_TOWER_9F` | 5 | + +Warps 3/4 are the in-floor pair the walkthrough calls "go through the next two +warps". + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER7F_POKE_BALL` | `SPRITE_POKE_BALL` | 16 | 1 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower7FMaxRevive` | `EVENT_TIN_TOWER_7F_MAX_REVIVE` | + +--- + +### TIN_TOWER_8F + +- Script: `maps/TinTower8F.asm`; Blocks `maps/TinTower8F.blk` +- Header: `data/maps/maps.asm:89`; Dimensions `constants/map_constants.asm:76` -> `10, 9` (group 3, map 11) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 5 | `TIN_TOWER_7F` | 2 | +| 2 | 2 | 11 | `TIN_TOWER_9F` | 1 | +| 3 | 16 | 7 | `TIN_TOWER_9F` | 2 | +| 4 | 10 | 3 | `TIN_TOWER_9F` | 3 | +| 5 | 14 | 15 | `TIN_TOWER_9F` | 6 | +| 6 | 6 | 9 | `TIN_TOWER_9F` | 7 | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWER8F_POKE_BALL1` | `SPRITE_POKE_BALL` | 7 | 13 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower8FNugget` | `EVENT_TIN_TOWER_8F_NUGGET` | +| `TINTOWER8F_POKE_BALL2` | `SPRITE_POKE_BALL` | 11 | 6 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower8FMaxElixer` | `EVENT_TIN_TOWER_8F_MAX_ELIXER` | +| `TINTOWER8F_POKE_BALL3` | `SPRITE_POKE_BALL` | 3 | 1 | `STILL` | `OBJECTTYPE_ITEMBALL` | `TinTower8FFullRestore` | `EVENT_TIN_TOWER_8F_FULL_RESTORE` | + +--- + +### TIN_TOWER_9F + +- Script: `maps/TinTower9F.asm` (`42:4de0 TinTower9F_MapScripts`) - no callbacks, + no objects; carries two unreferenced texts `TinTower9FUnusedHoOhText` / + `TinTower9FUnusedLugiaText` +- Blocks: `maps/TinTower9F.blk` (`2b:625f`) +- Header: `data/maps/maps.asm:90`; Dimensions `constants/map_constants.asm:77` -> `10, 9` (group 3, map 12) + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 12 | 3 | `TIN_TOWER_8F` | 2 | +| 2 | 2 | 5 | `TIN_TOWER_8F` | 3 | +| 3 | 12 | 7 | `TIN_TOWER_8F` | 4 | +| 4 | 7 | 9 | `TIN_TOWER_ROOF` | 1 | +| 5 | 16 | 7 | `TIN_TOWER_7F` | 5 | +| 6 | 6 | 13 | `TIN_TOWER_8F` | 5 | +| 7 | 8 | 13 | `TIN_TOWER_8F` | 6 | + +Warp 4 at `(7, 9)` is the only exit to the roof. + +--- + +### TIN_TOWER_ROOF + +- Script: `maps/TinTowerRoof.asm` (`5b:68ff` callback, `5b:6913 TinTowerHoOh`) +- Blocks: `maps/TinTowerRoof.blk` (`2b:62b9`) +- Header: `data/maps/maps.asm:344` -> `TILESET_TOWER`, **`ROUTE`**, `LANDMARK_TIN_TOWER`, `MUSIC_TIN_TOWER`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:318` -> `10, 9`, and note the group: + `TIN_TOWER_ROOF` is map 12 of **group 15 (`FAST_SHIP`)**, not group 3 with the + rest of the tower. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 13 | `TIN_TOWER_9F` | 4 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TINTOWERROOF_HO_OH` | `SPRITE_HO_OH` | 9 | 5 | `SPRITEMOVEDATA_POKEMON` | `OBJECTTYPE_SCRIPT`, palette `PAL_NPC_RED` | `TinTowerHoOh` | `EVENT_TIN_TOWER_ROOF_HO_OH` | + +**Scripts of interest** + +- `TinTowerRoofHoOhCallback` - `callback MAPCALLBACK_OBJECTS`: + `checkevent EVENT_FOUGHT_HO_OH / iftrue .NoAppear`, then + `checkitem RAINBOW_WING / iftrue .Appear`. Same shape as Lugia's: the wing must + be **in the bag**, not merely once-received. +- `TinTowerHoOh` (`5b:6913`) - text "Shaoooh!", `cry HO_OH`, `pause 15`, + `setevent EVENT_FOUGHT_HO_OH` **before** the fight, `checkver / iftrue .Silver`. + - Gold arm: `loadwildmon HO_OH, 40`. + - `.Silver`: `loadwildmon HO_OH, 70` - the level the walkthrough quotes. + - Both arms run `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM` first, and + Ho-Oh's base row is `db SACRED_ASH, SACRED_ASH` + (`data/pokemon/base_stats/ho_oh.asm:9`), so **Ho-Oh always holds a SACRED_ASH**. + That is a guaranteed item a bot should strip before/after the catch. + +**Escape Rope caveat**: `EscapeRopeFunction` -> `.CheckCanDig` +(`engine/events/overworld.asm:724-754`) only succeeds when +`GetMapEnvironment` is `CAVE` or `DUNGEON`. `TIN_TOWER_ROOF` is `ROUTE`, so the +walkthrough is right that you must go back down to 9F first. Every Whirl Island +map including `WHIRL_ISLAND_LUGIA_CHAMBER` is `CAVE`, so the rope works on the +spot there. + +**Catch math**: Ho-Oh's catch rate is also `3` +(`data/pokemon/base_stats/ho_oh.asm:7`). Its dex weight is `4390` +(`data/pokemon/dex_entries/gold/ho_oh.asm`) = 199 kg, which falls in the +`HeavyBallMultiplier` "add 0" bracket - so unlike Lugia, a Heavy Ball is *worse* +than an Ultra Ball on Ho-Oh. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Reaching Route 41 at all | `SurfFunction.TrySurf`, `engine/events/overworld.asm:322` (badge test at `:340`) | `ENGINE_FOGBADGE` + a party member that knows `SURF` | Fog Badge (Morty) | +| Whirlpools guarding the Whirl Islands | `WhirlpoolFunction` `engine/events/overworld.asm:1061` (badge test at `:1077`) and `TryWhirlpoolOW` `:1167` (badge test at `:1171`) | `ENGINE_GLACIERBADGE` + `WHIRLPOOL` in the party | Glacier Badge (Pryce, badge 7) - **not** eight badges, see Unresolved | +| Darkness inside every Whirl Island map | `FlashFunction.CheckUseFlash`, `engine/events/overworld.asm:271` | `ENGINE_ZEPHYRBADGE` + `FLASH`, and `wTimeOfDayPalset == DARKNESS_PALSET` (the map header's `PALETTE_DARK`) | Zephyr Badge. Flash is *refused* on any non-dark map, so a bot must only try it inside | +| Climbing back up the B2F waterfall | `WaterfallFunction` `engine/events/overworld.asm:611` (badge test at `:618`) and `TryWaterfallOW` `:683` (badge test at `:687`); direction check `CheckMapCanWaterfall` (must face UP, `wTileUp` is a waterfall tile) | `ENGINE_RISINGBADGE` + `WATERFALL` | Rising Badge (Clair). Descending needs nothing - only the UP direction is gated | +| B1F boulder at `(23, 26)` | `WhirlIslandB1FBoulder` -> `jumpstd StrengthBoulderScript` | `STRENGTH` + `ENGINE_PLAINBADGE` | optional; not on the Lugia path | +| Lugia not on the map | `WhirlIslandLugiaChamberLugiaCallback` | `checkitem SILVER_WING` true **and** `EVENT_FOUGHT_LUGIA` clear | `EVENT_GOT_SILVER_WING` via `PewterCityGrampsScript` (Gold) or `RadioTower5F` (Silver) - and keep the wing in the bag | +| Ho-Oh not on the map | `TinTowerRoofHoOhCallback` | `checkitem RAINBOW_WING` true **and** `EVENT_FOUGHT_HO_OH` clear | `EVENT_GOT_RAINBOW_WING` via `RadioTower5F` (Gold) or `PewterCityGrampsScript` `.RainbowWing` (Silver) | +| Tin Tower stairs to 2F | `TinTower1F` object row: sage stands on `(10, 2)`, masked by `EVENT_TEAM_ROCKET_DISBANDED` | flag must be **set** | `setevent EVENT_TEAM_ROCKET_DISBANDED` in `maps/RadioTower5F.asm:130` or `maps/PewterCity.asm:51` | +| Tin Tower entrance corridor | `coord_event 4, 7` / `5, 7` under `SCENE_ECRUTEAKTINTOWERENTRANCE_SAGE_BLOCKS` | scene must be `NOOP` | `maps/EcruteakGym.asm:34` `setmapscene ... SCENE_ECRUTEAKTINTOWERENTRANCE_NOOP` on the Fog Badge | +| One-shot mascot | `Lugia` / `TinTowerHoOh` set `EVENT_FOUGHT_LUGIA` / `EVENT_FOUGHT_HO_OH` **before** `startbattle` | n/a | none. Save-before-fight is the only recovery | + +--- + +## 4. Bot checklist + +**Preconditions for either branch**: 8 Johto badges (Zephyr, Fog, Plain, Glacier, +Rising in particular), party moves `SURF`, `WHIRLPOOL`, `FLASH`, `WATERFALL` +(`STRENGTH` optional), the version-opposite wing in the bag, a stack of +`ULTRA_BALL` (`HEAVY_BALL` for Lugia), and a save immediately before the mascot. + +**Gold branch - Lugia** + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | any | Olivine City | fly | `ENGINE_FLYPOINT_OLIVINE` | on `OLIVINE_CITY` | +| 2 | `OLIVINE_CITY` | west edge | walk, then surf | `ENGINE_FOGBADGE` + `SURF` | on `ROUTE_40` | +| 3 | `ROUTE_40` | south edge | surf | - | on `ROUTE_41` | +| 4 | `ROUTE_41` | any block id `$07` (`TILESET_JOHTO` whirlpool) | face it, press A -> `TryWhirlpoolOW` | `ENGINE_GLACIERBADGE` + `WHIRLPOOL` | block replaced with `$36` for this map session only (`DisappearWhirlpool` writes the map buffer, not the save) | +| 5 | `ROUTE_41` | `(36, 19)` warp 2 | walk onto | whirlpools cleared | on `WHIRL_ISLAND_NE` at `(3, 13)` | +| 6 | `WHIRL_ISLAND_NE` | menu | use `FLASH` | `ENGINE_ZEPHYRBADGE`, map palette `PALETTE_DARK` | cave lit | +| 7 | `WHIRL_ISLAND_NE` | object `WHIRLISLANDNE_POKE_BALL` `(11, 11)` | walk onto / A | `EVENT_WHIRL_ISLAND_NE_ULTRA_BALL` clear | `ULTRA_BALL` in bag, flag set | +| 8 | `WHIRL_ISLAND_NE` | warp 2 `(17, 3)` (or warp 3 `(13, 11)`) | walk onto | - | `WHIRL_ISLAND_B1F` `(35, 3)` / `(29, 9)` | +| 9 | `WHIRL_ISLAND_B1F` | `(19, 26)` | pick up | `EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE` clear | `ESCAPE_ROPE` in bag | +| 10 | `WHIRL_ISLAND_B1F` | `(33, 23)` | pick up | `EVENT_WHIRL_ISLAND_B1F_CALCIUM` clear | `CALCIUM` in bag | +| 11 | `WHIRL_ISLAND_B1F` | optional `(30, 4)`, `(36, 18)`, `(2, 23)` | face + A (hidden) | matching `EVENT_..._HIDDEN_*` clear | Rare Candy / Ultra Ball / Full Restore | +| 12 | `WHIRL_ISLAND_B1F` | warp 4 `(9, 31)` or warp 5 `(23, 31)` | walk onto | - | `WHIRL_ISLAND_SW` | +| 13 | `WHIRL_ISLAND_SW` | `(15, 2)` | pick up | `EVENT_WHIRL_ISLAND_SW_GUARD_SPEC` clear | `GUARD_SPEC` in bag | +| 14 | `WHIRL_ISLAND_B1F` | warp 7 `(25, 21)` | walk onto | - | `WHIRL_ISLAND_B2F` `(11, 5)` | +| 15 | `WHIRL_ISLAND_B2F` | `(6, 4)` | pick up | `EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE` clear | `MAX_REVIVE` in bag | +| 16 | `WHIRL_ISLAND_B2F` | waterfall block, moving DOWN | surf south over it | `SURF` only (no badge for descent) | lower B2F pool | +| 17 | `WHIRL_ISLAND_B2F` | warp 3 `(7, 25)` | walk onto | - | `WHIRL_ISLAND_LUGIA_CHAMBER` `(9, 13)` | +| 18 | `WHIRL_ISLAND_LUGIA_CHAMBER` | - | **SAVE** | - | recoverable | +| 19 | `WHIRL_ISLAND_LUGIA_CHAMBER` | object at `(9, 5)` | surf north, A | `SILVER_WING` in bag, `EVENT_FOUGHT_LUGIA` clear | `EVENT_FOUGHT_LUGIA` set, wild Lugia Lv70 (Gold) / Lv40 (Silver) | +| 20 | battle | - | weaken, status, throw balls | catch rate 3 (Ultra 6, Heavy 23) | Lugia caught, `disappear` + `reloadmapafterbattle` | +| 21 | `WHIRL_ISLAND_LUGIA_CHAMBER` | bag | use `ESCAPE_ROPE` | environment `CAVE` | back at last Pokecenter spawn | + +**Silver branch - Ho-Oh** + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | any | Ecruteak City | fly | `ENGINE_FLYPOINT_ECRUTEAK` | on `ECRUTEAK_CITY` | +| 2 | `ECRUTEAK_CITY` | warp 3 `(18, 11)` | walk onto | - | `ECRUTEAK_TIN_TOWER_ENTRANCE` `(4, 17)` | +| 3 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | `(4, 7)` / `(5, 7)` | walk through | map scene must be `NOOP` (Fog Badge) | no coord event fires | +| 4 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | warp 3 `(5, 3)` | walk onto | - | same map, `(17, 15)` | +| 5 | `ECRUTEAK_TIN_TOWER_ENTRANCE` | warp 5 `(17, 3)` | walk onto | - | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` `(2, 4)` | +| 6 | `ECRUTEAK_TIN_TOWER_BACK_ENTRANCE` | warp 1 `(7, 4)` | walk onto | - | `ECRUTEAK_CITY` `(20, 2)` | +| 7 | `ECRUTEAK_CITY` | warp 12 `(37, 7)` | walk east then onto | - | `TIN_TOWER_1F` `(9, 15)` | +| 8 | `TIN_TOWER_1F` | warp 3 `(10, 2)` | walk onto | `EVENT_TEAM_ROCKET_DISBANDED` **set** (sage masked) | `TIN_TOWER_2F` `(10, 2)` | +| 9 | `TIN_TOWER_2F` | warp 1 `(10, 14)` | walk onto | - | `TIN_TOWER_3F` | +| 10 | `TIN_TOWER_3F` | `(3, 14)` then warp 2 `(16, 2)` | pick up, walk onto | `EVENT_TIN_TOWER_3F_FULL_HEAL` clear | `FULL_HEAL`; `TIN_TOWER_4F` | +| 11 | `TIN_TOWER_4F` | `(14, 10)`, `(17, 14)`, `(2, 12)`, hidden `(11, 6)` | pick up | matching flags clear | Ultra Ball, Super Potion, Escape Rope, hidden Max Potion | +| 12 | `TIN_TOWER_4F` | warp 1 `(2, 4)` / 3 `(2, 14)` / 4 `(17, 15)` | walk onto | - | `TIN_TOWER_5F` | +| 13 | `TIN_TOWER_5F` | `(9, 9)`, hidden `(16, 14)` and `(3, 15)` | pick up | matching flags clear | Rare Candy, Full Restore, Carbos | +| 14 | `TIN_TOWER_5F` | warp 1 `(11, 15)` | walk onto | - | `TIN_TOWER_6F` `(11, 15)` | +| 15 | `TIN_TOWER_6F` | warp 1 `(3, 9)` | walk onto | - | `TIN_TOWER_7F` `(3, 9)` | +| 16 | `TIN_TOWER_7F` | `(16, 1)`; warps 3 `(12, 7)` / 4 `(8, 3)` | pick up, hop the pair | `EVENT_TIN_TOWER_7F_MAX_REVIVE` clear | `MAX_REVIVE` | +| 17 | `TIN_TOWER_7F` | warp 2 `(10, 15)` | walk onto | - | `TIN_TOWER_8F` `(2, 5)` | +| 18 | `TIN_TOWER_8F` | `(7, 13)`, `(11, 6)`, `(3, 1)` | pick up | matching flags clear | Nugget, Max Elixer, Full Restore | +| 19 | `TIN_TOWER_8F` | warp 2 `(2, 11)` (or 3/4/5/6) | walk onto | - | `TIN_TOWER_9F` | +| 20 | `TIN_TOWER_9F` | warp 4 `(7, 9)` | walk onto | - | `TIN_TOWER_ROOF` `(9, 13)` | +| 21 | `TIN_TOWER_ROOF` | - | **SAVE** | - | recoverable | +| 22 | `TIN_TOWER_ROOF` | object at `(9, 5)` | walk up, A | `RAINBOW_WING` in bag, `EVENT_FOUGHT_HO_OH` clear | `EVENT_FOUGHT_HO_OH` set, wild Ho-Oh Lv70 (Silver) / Lv40 (Gold) holding `SACRED_ASH` | +| 23 | battle | - | weaken, status, Ultra Balls | catch rate 3; Heavy Ball gives no bonus here | Ho-Oh caught | +| 24 | `TIN_TOWER_9F` | warp 1 on the roof, then bag | descend, use `ESCAPE_ROPE` | roof is `ROUTE` environment - rope fails there | back at spawn | + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers, warps, coord/bg/object events for all of these maps | `src/import/RomExtractorGen2.lua` (walks all 26 map groups, `MAP_GROUP_COUNT` at line 47), consumed by `src/world/gen2/Map.lua` / `World.lua` | implemented (generic - no map-specific code, so these maps come in with everything else) | +| `MAPCALLBACK_OBJECTS` (the Lugia / Ho-Oh appear-check) | `src/world/gen2/World.lua:5700` `runMapCallback("MAPCALLBACK_OBJECTS")`; regression driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| Object masking by event flag (`CheckObjectFlag`) - the Tin Tower 1F sage, every item ball | `src/world/gen2/World.lua:5106,5122` `events:objectVisible(obj.eventFlag)` | implemented | +| `checkver` version split (Lv70 vs Lv40 mascot) | `src/script/gen2/Vm.lua:774-783`; opcode table `src/script/gen2/Opcodes.lua:29` | implemented (comments even cite `WhirlIslandLugiaChamber` by name) | +| `loadwildmon` / `startbattle` / `reloadmapafterbattle` | `src/script/gen2/Opcodes.lua:98-101`, `src/script/gen2/Vm.lua:817-896` | implemented | +| `BATTLETYPE_FORCEITEM` -> Ho-Oh's guaranteed `SACRED_ASH` | `src/world/gen2/World.lua:107-111` and `:4571-4579` | implemented | +| Whirlpool field move, badge gate and block replacement | `src/world/gen2/FieldMoves.lua:109` (`WHIRLPOOL = "GLACIER"`), `:219` `WHIRLPOOL_BLOCKS`, `:247` `somethingToWhirlpool`, `:542` `whirlpoolFromMenu`, `:626` `tryWhirlpoolOW` | implemented | +| Flash + `PALETTE_DARK` darkness | `src/world/gen2/FieldMoves.lua:105,468`; `src/world/gen2/Palettes.lua:53,75-103` | implemented | +| Waterfall (up-only gate, `RISINGBADGE`) | `src/world/gen2/FieldMoves.lua:110,258,646` | implemented | +| Hidden items (`hiddenitem` bg events) | `src/world/gen2/HiddenItems.lua`, `src/world/gen2/World.lua:1373,5286` | implemented | +| Catch rate formula incl. the two cart bugs | `src/battle/gen2/Catching.lua:1-90` | implemented | +| **Heavy Ball weight bonus** (the FAQ's Lugia tip) | `src/battle/gen2/Catching.lua:36` - `HEAVY_BALL = 1`, a flat multiplier with a comment saying the specialty balls "key off conditions the caller supplies", and no dex-weight lookup anywhere | **missing** - a Heavy Ball currently behaves as a plain Poke Ball on Lugia | +| Magnet Train hop that opens the section | `src/core/gen2/MagnetTrain.lua` | implemented (not re-verified for this section) | +| Escape Rope environment check (`CAVE`/`DUNGEON` only, roof excluded) | not located in `src/world/gen2/` or `src/battle/gen2/` by grep | **unverified** - could not find an `EscapeRopeFunction` equivalent; a bot should not assume the roof refusal is modelled | +| Hand-ported scripts for these specific maps | none - grep for `whirl`/`tintower`/`lugia`/`hooh` across `src/` returns only battle-anim and title-screen hits | not applicable (all script bodies are extracted, run through `src/script/gen2/Vm.lua`) | + +--- + +## 6. Unresolved / verify by hand + +1. **"You'll need eight badges in order to use Whirlpool."** The asm gates + Whirlpool on `ENGINE_GLACIERBADGE` alone (`engine/events/overworld.asm:1077` + and `:1171`), i.e. seven badges. Eight is wrong, though harmless in practice + for a post-game section. +2. **Whirl Islands item list.** The FAQ lists Calcium, Escape Rope, Guard Specs, + Max Revive x2, Ultra Ball. The asm has *one* Max Revive in the entire dungeon + (`WhirlIslandB2FMaxRevive` at B2F `(6, 4)`) and additionally a `FULL_RESTORE`, + `CARBOS`, `NUGGET` on B1F, a second `FULL_RESTORE` and a `MAX_ELIXER` on B2F, + plus three hidden items on B1F that the FAQ never mentions. The walkthrough's + two "grab the Max Revive" beats cannot both be the B2F ball; the first one is + unlocatable. +3. **Tin Tower item list.** The FAQ names a PP Up, an HP Up, a Max Potion item + ball, and a second Full Heal. None exist in pokegold: there is no `PP_UP` or + `HP_UP` anywhere in `maps/TinTower*.asm`, the only Max Potion is the *hidden* + `TinTower4FHiddenMaxPotion` at `(11, 6)`, and the only Full Heal is + `TinTower3FFullHeal`. Conversely the FAQ omits `SUPER_POTION` (4F), + `NUGGET`/`FULL_RESTORE` (8F) and the three hidden items on 4F/5F. The list + looks like it was written against a different version of the game. +4. **"On the next level, go up and you'll get a Max Potion ... Full Heal"** places + two items on the 6F-shaped floor; `maps/TinTower6F.asm` has an empty + `def_bg_events` and an empty `def_object_events`. Not locatable. +5. **Ho-Oh's moveset.** The FAQ says "Safeguard and Ancient Power ..., Punishment, + and Sacred Fire". `PUNISHMENT` does not exist in Gen 2 at all + (`data/pokemon/evos_attacks.asm:3324-3336` is the whole learnset). Derived + from that table plus `FillMoves` (`engine/pokemon/evolve.asm:478`, which keeps + the last four moves learnable at or below the level), a level-70 Ho-Oh should + have `RECOVER`, `FIRE_BLAST`, `SUNNY_DAY`, `SWIFT`, and a level-40 one + `SACRED_FIRE`, `SAFEGUARD`, `GUST`, `RECOVER`. Worth confirming in-engine + before a bot plans around Sacred Fire; the `FillMoves` derivation is mine, not + a literal table in the asm. +6. **Lugia's Pokemon list at the Whirl Islands.** The FAQ lists Zubat, Seel, + Krabby, Horsea, Seadra and omits `GOLBAT` (in every grass slot 5), + `TENTACOOL` / `TENTACRUEL` (SW and B2F water). Seadra only appears in + `WHIRL_ISLAND_LUGIA_CHAMBER`'s water table. +7. **"Swimmer Kara" as a whirlpool landmark.** Her object is at `(44, 28)` on + Route 41; the whirlpool "just northwest" of her is a `.blk` block, not an + event, so the exact coordinate could not be pinned from the asm text. A bot + must scan `maps/Route41.blk` for `TILESET_JOHTO` block `$07`. +8. **The Whirl Islands ledge/jump routing** ("leap the ledge to the right on that + bike", "two items on the cliff but you can't reach those") is entirely block + and collision data in `maps/WhirlIsland*.blk`. None of it is expressible from + the event tables; a bot needs the decoded block/collision grid. +9. **The Tin Tower jump-platform routing** (the long "left x5, down, right x2 ..." + sequences) is likewise `.blk` data. Only the warp endpoints above are asm + facts. +10. **"go into the Bell Tower entry house"** - the map, the sign and every text + string in pokegold call it TIN TOWER (`TinTowerSign`, + `EcruteakTinTowerEntranceSageText`). "Bell Tower" is the post-GS rename. +11. **Escape Rope on the port.** Could not find the port's equivalent of + `EscapeRopeFunction`'s `CAVE`/`DUNGEON` environment check, so I cannot say + whether the port correctly refuses the rope on `TIN_TOWER_ROOF`. diff --git a/docs/gold-walkthrough/asm-walk/section-26-mount-moon-and-routes-1-4.md b/docs/gold-walkthrough/asm-walk/section-26-mount-moon-and-routes-1-4.md new file mode 100644 index 00000000..f4992f70 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-26-mount-moon-and-routes-1-4.md @@ -0,0 +1,893 @@ +# Section 26 - Mount Moon and Routes 1-4 + +Source: `../section-26-mount-moon-and-routes-1-4.txt` (walkthrough heading "32 > Mount Moon and Routes 1-4") +Maps covered: `MAP_ROUTE_3`, `MAP_MOUNT_MOON`, `MAP_MOUNT_MOON_SQUARE`, `MAP_MOUNT_MOON_GIFT_SHOP`, `MAP_ROUTE_4`, `MAP_VIRIDIAN_CITY`, `MAP_TRAINER_HOUSE_1F`, `MAP_TRAINER_HOUSE_B1F`, `MAP_ROUTE_1`, `MAP_PALLET_TOWN`, `MAP_OAKS_LAB` +Badges / key milestones in this section: no badge. The milestones are `EVENT_BEAT_RIVAL_IN_MT_MOON` (the last mandatory Silver fight, and the unlock for both rival re-encounters), `ENGINE_FLYPOINT_VIRIDIAN` and `ENGINE_FLYPOINT_PALLET` (Fly targets), `EVENT_TALKED_TO_OAK_IN_KANTO` (Oak's Kanto-badge conversation, which later opens Mt. Silver via `EVENT_OPENED_MT_SILVER`), plus the `HP_UP` item ball on Route 4. + +Note on paths: everything under `maps/`, `data/`, `constants/`, `engine/`, `macros/` is relative to the pokegold checkout at `/Users/bryanbassett/Documents/development/pokegold`. Everything under `src/` and `tests/` is relative to this repo root. Addresses are `bank:addr` from `/Users/bryanbassett/Documents/development/pokegold-symbols/pokegold.sym`. + +--- + +## 1. Route order + +The section starts with the player already standing on Route 3 (arriving from Pewter City, which belongs to the previous section) and ends by handing off to Route 21 (next section). + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_3` | `maps/Route3.asm` | west connection from `PEWTER_CITY` (`data/maps/attributes.asm:377`) | warp 1 at (52, 1) -> `MOUNT_MOON` warp 1 | four trainers east: Warren, Jimmy, Otis, Burt | +| 2 | `MAP_ROUTE_3` -> Pewter and back | - | - | - | walkthrough tells the player to heal at the Pewter Pokemon Center and save before Mt. Moon (no asm gate; pure advice) | +| 3 | `MAP_MOUNT_MOON` | `maps/MountMoon.asm` | warp 1 at (3, 3) | see below | mandatory rival battle, fired by `scene_script MountMoonRivalEncounterScene, SCENE_MOUNTMOON_RIVAL_BATTLE` | +| 4 | `MAP_MOUNT_MOON` (north lane) | `maps/MountMoon.asm` | warp 3 at (13, 3) -> warp 7 at (25, 3) | warp 5 at (25, 5) -> `MOUNT_MOON_SQUARE` warp 1 | "go right from your Rival battle, up the ladder, and then out" | +| 5 | `MAP_MOUNT_MOON_SQUARE` | `maps/MountMoonSquare.asm` | warp 1 at (20, 5) | warp 3 at (13, 7) -> gift shop; warp 2 at (22, 11) -> `MOUNT_MOON` warp 6 | the outdoor shop and the Monday-night Clefairy dance | +| 6 | `MAP_MOUNT_MOON_GIFT_SHOP` | `maps/MountMoonGiftShop.asm` | warp 1 (3, 7) / warp 2 (4, 7) | either warp -> `MOUNT_MOON_SQUARE` warp 3 | "a shop outside that sells Fresh Water, Soda Pop, and Lemonade" (`MART_MT_MOON`) | +| 7 | `MAP_MOUNT_MOON` (south lane) | `maps/MountMoon.asm` | warp 6 at (25, 15) | warp 8 at (25, 13) -> warp 4 at (15, 11), then warp 2 at (15, 15) | "go down into another cave entrance and down the ladder, you'll make your way through" | +| 8 | `MAP_ROUTE_4` | `maps/Route4.asm` | warp 1 at (2, 5) from `MOUNT_MOON` warp 2 | east connection to `CERULEAN_CITY` (not taken here) | three trainers: Hope, Hank, Sharon; `HP_UP` item ball at (26, 3) | +| 9 | `MAP_VIRIDIAN_CITY` | `maps/ViridianCity.asm` | walkthrough routes Fly -> Pewter -> `ROUTE_2` -> `VIRIDIAN_FOREST` -> Viridian (those two maps belong to a neighbouring section) | warp 3 at (23, 15) -> `TRAINER_HOUSE_1F` | Trainer House daily battle | +| 10 | `MAP_TRAINER_HOUSE_1F` | `maps/TrainerHouse1F.asm` | warp 1/2 at (2, 13)/(3, 13) | warp 3 at (8, 2) -> `TRAINER_HOUSE_B1F` warp 1 | "simply go downstairs in that house" | +| 11 | `MAP_TRAINER_HOUSE_B1F` | `maps/TrainerHouseB1F.asm` | warp 1 at (9, 4) | same warp back up | coord_event at (7, 3) runs `TrainerHouseReceptionistScript`, the once-a-day CAL battle | +| 12 | `MAP_ROUTE_1` | `maps/Route1.asm` | north connection from `VIRIDIAN_CITY` (`data/maps/attributes.asm:267`) | south connection to `PALLET_TOWN` | Bitter Berry fruit tree, Schoolboy Danny, Cooltrainerf Quinn | +| 13 | `MAP_PALLET_TOWN` | `maps/PalletTown.asm` | north connection from `ROUTE_1` | warp 3 at (12, 11) -> `OAKS_LAB` warp 1 | Oak's badge conversation; sets `ENGINE_FLYPOINT_PALLET` | +| 14 | `MAP_OAKS_LAB` | `maps/OaksLab.asm` | warp 1 at (4, 11) / warp 2 at (5, 11) | either warp -> `PALLET_TOWN` warp 3 | "Talk with Professor Oak in his lab" | +| 15 | hand-off | - | - | south connection from `PALLET_TOWN` to `ROUTE_21` (`data/maps/attributes.asm:273`) | "Now, surf south onto Route 21" - Route 21 belongs to the next section; stopping here. | + +`ROUTE_2` and `VIRIDIAN_FOREST` are named in the walkthrough only as the walking route from Pewter back to Viridian; they are not covered here. + +--- + +## 2. Maps + +### MAP_ROUTE_3 + +- Script: `maps/Route3.asm` (`Route3_MapEvents` = `50:514b`) +- Blocks: `maps/Route3.blk` +- Header: `data/maps/maps.asm:321` -> `map Route3, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_3, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:296` -> `map_const ROUTE_3, 30, 9` (30x9 blocks = 60x18 walk cells), group `PEWTER` +- Connections: `data/maps/attributes.asm:377` -> west `PewterCity` (offset -5), east `Route4` (offset 0). No north/south. +- Scene scripts: none (`def_scene_scripts` is empty). Callbacks: none. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 52 | 1 | `MOUNT_MOON` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 49 | 13 | `BGEVENT_READ` | `Route3MtMoonSquareSign` -> `Route3MtMoonSquareSignText` ("MT.MOON SQUARE / Just go up the stairs.") | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE3_FISHER1` | `SPRITE_FISHER` | 26 | 12 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerFirebreatherOtis` (`50:4f26`) | -1 | +| `ROUTE3_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 11 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerYoungsterWarren` (`50:4f3a`) | -1 | +| `ROUTE3_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 20 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerYoungsterJimmy` (`50:4f4e`) | -1 | +| `ROUTE3_FISHER2` | `SPRITE_FISHER` | 49 | 5 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerFirebreatherBurt` (`50:4f62`) | -1 | + +Column order comes from `macros/scripts/maps.asm:113` (`object_event x, y, sprite, movement, radius_x, radius_y, hour1, hour2, palette, type, sight_range, script, event_flag`). All four here have radius 0/0 and hour limits `-1, -1` (always present). + +**Scripts of interest** + +- `TrainerFirebreatherOtis` / `TrainerYoungsterWarren` / `TrainerYoungsterJimmy` / `TrainerFirebreatherBurt`: each is a plain `trainer CLASS, ID, EVENT_BEAT_*, SeenText, BeatenText, 0, .Script` header. `.Script` is `endifjustbattled / opentext / writetext <After>Text / waitbutton / closetext / end`. No items, no flags beyond the trainer's own beaten flag, no warps. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_FIREBREATHER_OTIS` | `constants/event_flags.asm:576` | `trainer` header | set on win; trainer will not re-challenge | +| `EVENT_BEAT_FIREBREATHER_BURT` | `constants/event_flags.asm:579` | `trainer` header | same | +| `EVENT_BEAT_YOUNGSTER_WARREN` | `constants/event_flags.asm:971` | `trainer` header | same | +| `EVENT_BEAT_YOUNGSTER_JIMMY` | `constants/event_flags.asm:972` | `trainer` header | same | + +**Items** + +None on this map. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `WARREN` | `YOUNGSTER` | YOUNGSTER (9), line 386 | L35 FEAROW (`TRAINERTYPE_NORMAL`) | `TrainerYoungsterWarren` | none | +| `JIMMY` | `YOUNGSTER` | YOUNGSTER (10), line 391 | L33 RATICATE, L33 ARBOK | `TrainerYoungsterJimmy` | none | +| `OTIS` | `FIREBREATHER` | FIREBREATHER (1), line 2334 | L29 MAGMAR, L32 WEEZING, L29 MAGMAR | `TrainerFirebreatherOtis` | none | +| `BURT` | `FIREBREATHER` | FIREBREATHER (4), line 2353 | L32 KOFFING, L32 SLUGMA | `TrainerFirebreatherBurt` | none | + +All four are `TRAINERTYPE_NORMAL`, i.e. level+species only, moves come from the learnset. + +**Wild encounters** + +`data/wild/kanto_grass.asm:314` `def_grass_wildmons ROUTE_3`, rates `10 percent, 10 percent, 10 percent` (morn/day/nite). The table is version-split: + +- `IF DEF(_GOLD)` - morn/day: L5 SPEAROW, L5 RATTATA, L8 SPEAROW, L6 JIGGLYPUFF, L10 RATTATA, L8 SPEAROW, L8 SPEAROW; nite: L5 RATTATA, L5 ZUBAT, L8 RATTATA, L6 JIGGLYPUFF, L10 RATTATA, L8 RATTATA, L8 RATTATA. +- `ELIF DEF(_SILVER)` - morn/day: L5 SPEAROW, L5 RATTATA, L8 EKANS, L6 JIGGLYPUFF, L10 ARBOK, L8 SPEAROW, L8 SPEAROW; nite: L5 RATTATA, L5 ZUBAT, L8 EKANS, L6 JIGGLYPUFF, L10 ARBOK, L8 RATTATA, L8 RATTATA. + +The walkthrough's "Rattata / Ekans / Arbok" is the **Silver** column. On Gold there is no Ekans or Arbok on Route 3. See section 6. + +No water/fishing/headbutt entry for `ROUTE_3` in `data/wild/kanto_water.asm` or `data/wild/treemons.asm`. + +--- + +### MAP_MOUNT_MOON + +- Script: `maps/MountMoon.asm` (`MountMoon_MapEvents` = `42:587e`, `MountMoonRivalBattleScript` = `42:55b0`) +- Blocks: `maps/MountMoon.blk` +- Header: `data/maps/maps.asm:154` -> `map MountMoon, TILESET_CAVE, CAVE, LANDMARK_MT_MOON, MUSIC_MT_MOON, TRUE, PALETTE_NITE, FISHGROUP_SHORE` (the `TRUE` column is the phone/"requires flash-style" fixed palette flag position in the `map` macro; palette is `PALETTE_NITE`, so the cave is dark-toned regardless of clock) +- Dimensions: `constants/map_constants.asm:141` -> `map_const MOUNT_MOON, 15, 9` (15x9 blocks = 30x18 walk cells), group `CERULEAN`-adjacent index 76 in its group listing +- Attributes: `data/maps/attributes.asm:488` -> `map_attributes MountMoon, MOUNT_MOON, $09`. **No connections** - the map is entered and left only through warps. +- Scene var: `data/maps/scenes.asm:46` -> `wMountMoonSceneID` + +**Scene scripts** (`def_scene_scripts`, ids allocated from 0 by `macros/scripts/maps.asm:12`) + +| scene id | constant | script | +|---|---|---| +| 0 | `SCENE_MOUNTMOON_RIVAL_BATTLE` | `MountMoonRivalEncounterScene` -> `sdefer MountMoonRivalBattleScript` | +| 1 | `SCENE_MOUNTMOON_NOOP` | `MountMoonNoopScene` -> `end` | + +Nothing in the game sets scene 0; it is the power-on default, so the very first entry to Mt. Moon fires the rival cutscene. `RunSceneScript` is polled in `engine/overworld/events.asm:256`, after `CheckTrainerEvent` and `CheckTileEvent`, so it lands on the first player-event poll after the warp completes. + +**Callbacks**: none. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `ROUTE_3` | 1 | +| 2 | 15 | 15 | `ROUTE_4` | 1 | +| 3 | 13 | 3 | `MOUNT_MOON` | 7 | +| 4 | 15 | 11 | `MOUNT_MOON` | 8 | +| 5 | 25 | 5 | `MOUNT_MOON_SQUARE` | 1 | +| 6 | 25 | 15 | `MOUNT_MOON_SQUARE` | 2 | +| 7 | 25 | 3 | `MOUNT_MOON` | 3 | +| 8 | 25 | 13 | `MOUNT_MOON` | 4 | + +Warps 3<->7 and 4<->8 are the two internal ladder pairs. Read as a graph: +`ROUTE_3 -(1)- (3,3) ... (13,3) -(3/7)- (25,3) ... (25,5) -(5)- SQUARE` +and `SQUARE -(2/6)- (25,15) ... (25,13) -(8/4)- (15,11) ... (15,15) -(2)- ROUTE_4`. + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMOON_RIVAL` | `SPRITE_RIVAL` | 7 | 3 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT`, sight 0 | `ObjectEvent` (the shared ROM0 stub) | `EVENT_MT_MOON_RIVAL` | + +Flag semantics (`engine/overworld/scripting.asm:879-898`): `appear` clears the object's event flag, `disappear` sets it. `EVENT_MT_MOON_RIVAL` therefore starts **clear** = rival visible, and `disappear MOUNTMOON_RIVAL` at the end of the cutscene sets it = rival gone forever. + +**Scripts of interest** + +`MountMoonRivalBattleScript` (`42:55b0`), reached through `sdefer` from scene 0: + +1. `turnobject PLAYER, RIGHT`, `showemote EMOTE_SHOCK, PLAYER, 15`, `special FadeOutMusic`, `pause 15`. +2. `applymovement MOUNTMOON_RIVAL, MountMoonRivalMovementBefore` = three `step LEFT` (rival walks from (7,3) toward the player at the (3,3) entrance). +3. `playmusic MUSIC_RIVAL_ENCOUNTER`, `opentext`, `writetext MountMoonRivalTextBefore`, `waitbutton`, `closetext`. +4. Starter branch: + - `checkevent EVENT_GOT_TOTODILE_FROM_ELM` -> `.Totodile` -> `loadtrainer RIVAL2, RIVAL2_1_CHIKORITA` + - `checkevent EVENT_GOT_CHIKORITA_FROM_ELM` -> `.Chikorita` -> `loadtrainer RIVAL2, RIVAL2_1_CYNDAQUIL` + - fallthrough (player took Cyndaquil) -> `loadtrainer RIVAL2, RIVAL2_1_TOTODILE` + Note the label names describe the *player's* starter; the constant names describe the *rival's*. Each arm does `winlosstext MountMoonRivalTextWin, MountMoonRivalTextLoss`, `setlasttalked MOUNTMOON_RIVAL`, `startbattle`, `dontrestartmapmusic`, `reloadmapafterbattle`. +5. `.FinishBattle`: `playmusic MUSIC_RIVAL_AFTER`, after-text, `turnobject PLAYER, UP`, `turnobject PLAYER, RIGHT`, `applymovement MOUNTMOON_RIVAL, MountMoonRivalMovementAfter` (`RIGHT, RIGHT, DOWN x5`), `disappear MOUNTMOON_RIVAL`, `setscene SCENE_MOUNTMOON_NOOP`, `setevent EVENT_BEAT_RIVAL_IN_MT_MOON`, `playmapmusic`, `end`. + +The battle is unavoidable: there is no `iftrue` skip and no branch that reaches the rest of the map without running it, because the scene id is 0 until `setscene` runs at the very end of the script. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_MT_MOON_RIVAL` | `constants/event_flags.asm:1308` | object row; `disappear` in `MountMoonRivalBattleScript` | clear = rival object on the map | +| `EVENT_BEAT_RIVAL_IN_MT_MOON` | `constants/event_flags.asm:456` | set here; read by `maps/DragonsDenB1F.asm:13` and `maps/IndigoPlateauPokecenter1F.asm:45,67` | unlocks both post-game rival re-encounters | +| `EVENT_GOT_TOTODILE_FROM_ELM` | `constants/event_flags.asm:37` | read here | selects rival party | +| `EVENT_GOT_CHIKORITA_FROM_ELM` | `constants/event_flags.asm:38` | read here | selects rival party | +| `SCENE_MOUNTMOON_RIVAL_BATTLE` = 0 | `maps/MountMoon.asm:6` (allocated by `def_scene_scripts`) | `wMountMoonSceneID` | 0 = cutscene armed | +| `SCENE_MOUNTMOON_NOOP` = 1 | `maps/MountMoon.asm:7` | `setscene` at end of script | 1 = cutscene spent | + +**Items**: none on this map. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `RIVAL2_1_CHIKORITA` | `RIVAL2` (`constants/trainer_constants.asm:424`) | RIVAL2 (1), line 2042 | L41 SNEASEL (Quick Attack, Screech, Faint Attack, Fury Cutter); L42 GOLBAT (Leech Life, Bite, Confuse Ray, Wing Attack); L41 MAGNETON (ThunderShock, SonicBoom, Thunder Wave, Swift); L43 GENGAR (Mean Look, Curse, Shadow Ball, Confuse Ray); L43 ALAKAZAM (Disable, Recover, Future Sight, Psychic); **L45 MEGANIUM** (Razor Leaf, PoisonPowder, Body Slam, Light Screen) | `MountMoonRivalBattleScript` `.Totodile` | rematch at Indigo Plateau / Dragon's Den, see gates | +| `RIVAL2_1_CYNDAQUIL` | `RIVAL2` | RIVAL2 (2), line 2052 | same first five, **L45 TYPHLOSION** (SmokeScreen, Quick Attack, Flame Wheel, Swift) | `.Chikorita` | same | +| `RIVAL2_1_TOTODILE` | `RIVAL2` | RIVAL2 (3), line 2062 | same first five, **L45 FERALIGATR** (Rage, Water Gun, Scary Face, Slash) | fallthrough | same | + +`TRAINERTYPE_MOVES`, so the four move slots above are exactly what the rival fields. `RIVAL2_2_*` (rows 4-6, levels 45-50 with Crobat) are the Indigo Plateau rematch parties, not this fight. + +**Wild encounters** + +`data/wild/kanto_grass.asm:33` `def_grass_wildmons MOUNT_MOON`, rates `6 percent, 6 percent, 6 percent`. + +- `IF DEF(_GOLD)` - identical morn/day/nite: L6 ZUBAT, L8 GEODUDE, L8 SANDSHREW, L12 PARAS, L10 SANDSLASH, L8 CLEFAIRY, L8 CLEFAIRY. +- `ELIF DEF(_SILVER)` - L6 ZUBAT, L8 GEODUDE, L8 ZUBAT, L12 PARAS, L10 GEODUDE, L8 CLEFAIRY, L8 CLEFAIRY. + +The walkthrough's "Zubat / Parus [sic, Paras] / Geodude" is a subset of both columns. Gold additionally has Sandshrew and Sandslash, and both versions have a 2-slot Clefairy line the walkthrough does not mention. + +--- + +### MAP_MOUNT_MOON_SQUARE + +- Script: `maps/MountMoonSquare.asm` (`MountMoonSquare_MapEvents` = `5b:6827`, `ClefairyDance` = `5b:676a`) +- Blocks: `maps/MountMoonSquare.blk` +- Header: `data/maps/maps.asm:342` -> `map MountMoonSquare, TILESET_KANTO, ROUTE, LANDMARK_MT_MOON, MUSIC_MT_MOON_SQUARE, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:316` -> `map_const MOUNT_MOON_SQUARE, 15, 9` (30x18 walk cells), group `FAST_SHIP` +- Attributes: `data/maps/attributes.asm:595` -> `$2d`, no connections +- Scene var: `data/maps/scenes.asm:66` -> `wMountMoonSquareSceneID` + +**Scene scripts** + +| scene id | constant | script | +|---|---|---| +| 0 | `SCENE_MOUNTMOONSQUARE_CLEFAIRY_DANCE` | `MountMoonSquareNoopScene` -> `end` (the constant only exists to name the coord_event's scene, the scene script itself is a no-op and the id is never changed) | + +**Callbacks** + +- `MAPCALLBACK_NEWMAP` -> `MountMoonSquareDisappearMoonStoneCallback`: `setevent EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` / `endcallback`. Every fresh entry re-hides the Moon Stone. +- `MAPCALLBACK_OBJECTS` -> `MountMoonSquareDisappearRockCallback`: `disappear MOUNTMOONSQUARE_ROCK` / `endcallback`. The smashable rock is hidden on every object refresh; it exists on-screen only during the dance cutscene, which `appear`s it mid-script. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 20 | 5 | `MOUNT_MOON` | 5 | +| 2 | 22 | 11 | `MOUNT_MOON` | 6 | +| 3 | 13 | 7 | `MOUNT_MOON_GIFT_SHOP` | 1 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_MOUNTMOONSQUARE_CLEFAIRY_DANCE` (0) | 7 | 11 | `ClefairyDance` (`5b:676a`) | the Monday-night Clefairy cutscene; always armed because the scene id is never advanced | + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 7 | `BGEVENT_ITEM` | `MountMoonSquareHiddenMoonStone` (`5b:67e8`) = `hiddenitem MOON_STONE, EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` | +| 17 | 7 | `BGEVENT_READ` | `DontLitterSign` -> `DontLitterSignText` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMOONSQUARE_FAIRY1` | `SPRITE_FAIRY` | 6 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, sight 0 | `ObjectEvent` | `EVENT_MT_MOON_SQUARE_CLEFAIRY` | +| `MOUNTMOONSQUARE_FAIRY2` | `SPRITE_FAIRY` | 7 | 6 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, sight 0 | `ObjectEvent` | `EVENT_MT_MOON_SQUARE_CLEFAIRY` | +| `MOUNTMOONSQUARE_ROCK` | `SPRITE_ROCK` | 7 | 7 | `SPRITEMOVEDATA_SMASHABLE_ROCK` | `OBJECTTYPE_SCRIPT`, sight 0 | `MtMoonSquareRock` (`5b:67ee`) = `jumpstd SmashRockScript` | `EVENT_MT_MOON_SQUARE_ROCK` | + +**Scripts of interest** + +`ClefairyDance` - the whole cutscene is guarded by three conditions in a row, all of which must pass: + +``` +checkflag ENGINE_MT_MOON_SQUARE_CLEFAIRY ; iftrue .NoDancing (once per save) +readvar VAR_WEEKDAY / ifnotequal MONDAY, .NoDancing +checktime NITE / iffalse .NoDancing +``` + +Then: `appear` both fairies, `applymovement PLAYER, PlayerWalksUpToDancingClefairies` (one `step UP`, so the player ends on (7, 10)), `pause 15`, `appear MOUNTMOONSQUARE_ROCK`, a long `follow`/`applymovement`/`cry CLEFAIRY` dance across `ClefairyDanceStep1..7`, `showemote EMOTE_SHOCK` when the Clefairy notice the player, `applymovement ... ClefairyFleeMovement`, `disappear` both fairies, `stopfollow`, then the payoff: + +``` +clearevent EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE +setflag ENGINE_MT_MOON_SQUARE_CLEFAIRY +``` + +So the Moon Stone at bg_event (7, 7) becomes takeable, but the rock object was `appear`ed onto that exact cell during the cutscene and is only cleared by `MAPCALLBACK_OBJECTS` on the next map load. That is the asm behind Thard_Verad's "Bring Rock Smash": you either smash the rock at (7, 7) in the same visit, or leave and come back (the `MAPCALLBACK_NEWMAP` callback then re-sets the Moon Stone flag and re-hides the item, so leaving loses it). See section 6. + +`MtMoonSquareRock` -> `jumpstd SmashRockScript` (`engine/events/std_scripts.asm:199`) -> `farsjump AskRockSmashScript` (`engine/events/overworld.asm:1365`), which is `callasm HasRockSmash` (`engine/events/overworld.asm:1386`: `ld d, ROCK_SMASH / call CheckPartyMove`) then a yes/no prompt. **No badge check** - Rock Smash in Gen 2 is TM08, not an HM. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` | `constants/event_flags.asm:236` | set by `MAPCALLBACK_NEWMAP`; cleared at the end of `ClefairyDance`; consumed by `hiddenitem` | set = Moon Stone not present | +| `EVENT_MT_MOON_SQUARE_CLEFAIRY` | `constants/event_flags.asm:1307` | object rows; `appear`/`disappear` in `ClefairyDance` | clear = the two Clefairy are on the map (only during the cutscene) | +| `EVENT_MT_MOON_SQUARE_ROCK` | `constants/event_flags.asm:1306` | object row; `disappear` in the OBJECTS callback, `appear` mid-cutscene | clear = rock is standing on (7, 7) | +| `ENGINE_MT_MOON_SQUARE_CLEFAIRY` | `constants/engine_flags.asm:106` | `checkflag`/`setflag` in `ClefairyDance` | set = the dance has been seen once and will never replay | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MOON_STONE` | hidden item on (7, 7), only after the Clefairy dance clears its flag | `bg_event 7, 7, BGEVENT_ITEM, MountMoonSquareHiddenMoonStone` | `EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` | + +**Trainers**: none. **Wild encounters**: no `MOUNT_MOON_SQUARE` entry in `data/wild/kanto_grass.asm` or `kanto_water.asm`. + +--- + +### MAP_MOUNT_MOON_GIFT_SHOP + +- Script: `maps/MountMoonGiftShop.asm` (`MountMoonGiftShop_MapEvents` = `5b:68b6`, `MountMoonGiftShopClerkScript` = `5b:6877`) +- Blocks: `data/maps/blocks.asm:701` -> `INCBIN "maps/GiftShop.blk"` (shared blocks file, no `MountMoonGiftShop.blk`) +- Header: `data/maps/maps.asm:343` -> `map MountMoonGiftShop, TILESET_TRADITIONAL_HOUSE, INDOOR, LANDMARK_MT_MOON, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:317` -> `map_const MOUNT_MOON_GIFT_SHOP, 4, 4` (8x8 walk cells) +- Attributes: `data/maps/attributes.asm:596` -> `$00`, no connections +- No scene scripts, no callbacks. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `MOUNT_MOON_SQUARE` | 3 | +| 2 | 4 | 7 | `MOUNT_MOON_SQUARE` | 3 | + +**Coord events**: none. **BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `MOUNTMOONGIFTSHOP_GRAMPS1` | `SPRITE_GRAMPS` | 4 | 3 | `SPRITEMOVEDATA_STANDING_DOWN`, hours `-1, MORN` | `OBJECTTYPE_SCRIPT` | `MountMoonGiftShopClerkScript` | -1 | +| `MOUNTMOONGIFTSHOP_GRAMPS2` | `SPRITE_GRAMPS` | 1 | 2 | `SPRITEMOVEDATA_STANDING_RIGHT`, hours `-1, DAY` | `OBJECTTYPE_SCRIPT` | `MountMoonGiftShopClerkScript` | -1 | +| `MOUNTMOONGIFTSHOP_LASS1` | `SPRITE_LASS` | 1 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0), hours `-1, MORN` | `OBJECTTYPE_SCRIPT` | `MountMoonGiftShopLassScript` | -1 | +| `MOUNTMOONGIFTSHOP_LASS2` | `SPRITE_LASS` | 5 | 4 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,1), hours `-1, DAY` | `OBJECTTYPE_SCRIPT` | `MountMoonGiftShopLassScript` | -1 | + +The `hour1 = -1` form means `hour2` is a time-of-day mask (`macros/scripts/maps.asm:123`). **There is no NITE clerk**: at night the shop is unattended, which the walkthrough does not mention. + +**Scripts of interest** + +`MountMoonGiftShopClerkScript`: `faceplayer / opentext / pokemart MARTTYPE_STANDARD, MART_MT_MOON / closetext / end`. + +**Items** (`MartMtMoon`, `data/items/marts.asm:374`, 6 items) + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `POKE_DOLL` | bought | `MartMtMoon` | - | +| `FRESH_WATER` | bought | `MartMtMoon` | - | +| `SODA_POP` | bought | `MartMtMoon` | - | +| `LEMONADE` | bought | `MartMtMoon` | - | +| `REPEL` | bought | `MartMtMoon` | - | +| `PORTRAITMAIL` | bought | `MartMtMoon` | - | + +The walkthrough names only Fresh Water / Soda Pop / Lemonade; the other three are real and in the same list. + +**Trainers**: none. **Wild encounters**: none. + +--- + +### MAP_ROUTE_4 + +- Script: `maps/Route4.asm` (`Route4_MapEvents` = `50:5338`) +- Blocks: `maps/Route4.blk` +- Header: `data/maps/maps.asm:216` -> `map Route4, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_4, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:198` -> `map_const ROUTE_4, 20, 9` (40x18 walk cells), group `CERULEAN` +- Connections: `data/maps/attributes.asm:381` -> west `Route3` (offset 0), east `CeruleanCity` (offset -5) +- No scene scripts, no callbacks. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 5 | `MOUNT_MOON` | 2 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 7 | `BGEVENT_READ` | `MtMoonSquareSign` -> `MtMoonSquareSignText` | +| 10 | 3 | `BGEVENT_ITEM` | `Route4HiddenUltraBall` (`50:51d2`) = `hiddenitem ULTRA_BALL, EVENT_ROUTE_4_HIDDEN_ULTRA_BALL` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE4_YOUNGSTER` | `SPRITE_YOUNGSTER` | 17 | 9 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerBirdKeeperHank` (`50:5191`) | -1 | +| `ROUTE4_LASS1` | `SPRITE_LASS` | 10 | 8 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 5 | `TrainerPicnickerHope` (`50:51a5`) | -1 | +| `ROUTE4_LASS2` | `SPRITE_LASS` | 21 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerPicnickerSharon` (`50:51b9`) | -1 | +| `ROUTE4_POKE_BALL` | `SPRITE_POKE_BALL` | 26 | 3 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `Route4HPUp` (`50:51d0`) = `itemball HP_UP` | `EVENT_ROUTE_4_HP_UP` | + +**Scripts of interest** + +All three trainer scripts are the standard `endifjustbattled / opentext / writetext <After>Text / waitbutton / closetext / end`. `Route4HPUp` is not bytecode at all - `itemball HP_UP` is two raw bytes (`macros/scripts/maps.asm`, `dwb`-style), read by the engine's item-ball path rather than the script VM. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_BIRD_KEEPER_HANK` | `constants/event_flags.asm:501` | `trainer` header | set on win | +| `EVENT_BEAT_PICNICKER_HOPE` | `constants/event_flags.asm:641` | `trainer` header | set on win | +| `EVENT_BEAT_PICNICKER_SHARON` | `constants/event_flags.asm:642` | `trainer` header | set on win | +| `EVENT_ROUTE_4_HP_UP` | `constants/event_flags.asm:1322` | item-ball object row | set = ball already taken (object hidden) | +| `EVENT_ROUTE_4_HIDDEN_ULTRA_BALL` | `constants/event_flags.asm:241` | `hiddenitem` | set = Ultra Ball already found | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `HP_UP` | walk onto / face and A the Poke Ball object at (26, 3) | `object_event ... OBJECTTYPE_ITEMBALL, 0, Route4HPUp` | `EVENT_ROUTE_4_HP_UP` | +| `ULTRA_BALL` | hidden, face (10, 3) and press A, or Itemfinder | `bg_event 10, 3, BGEVENT_ITEM, Route4HiddenUltraBall` | `EVENT_ROUTE_4_HIDDEN_ULTRA_BALL` | + +The walkthrough lists only the HP Up. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `HOPE` | `PICNICKER` | PICNICKER (6), line 2611 | L34 FLAAFFY | `TrainerPicnickerHope` | none | +| `HANK` | `BIRD_KEEPER` | BIRD_KEEPER (8), line 569 | L12 PIDGEY, L34 PIDGEOT | `TrainerBirdKeeperHank` | none | +| `SHARON` | `PICNICKER` | PICNICKER (7), line 2616 | L31 FURRET, L33 RAPIDASH | `TrainerPicnickerSharon` | none | + +All `TRAINERTYPE_NORMAL`. The L12 Pidgey in Hank's party is not a typo in the walkthrough; it really is level 12. + +**Wild encounters** + +- Grass: `data/wild/kanto_grass.asm:369` `def_grass_wildmons ROUTE_4`, rates `10/10/10 percent`. + - `IF DEF(_GOLD)` - morn/day: L5 SPEAROW, L5 RATTATA, L8 SPEAROW, L6 JIGGLYPUFF, L10 RATTATA, L8 SPEAROW, L8 SPEAROW; nite: L5 SPEAROW, L5 ZUBAT, L8 RATTATA, L6 JIGGLYPUFF, L10 RATTATA, L8 RATTATA, L8 RATTATA. + - `ELIF DEF(_SILVER)` - morn/day: L5 SPEAROW, L5 RATTATA, L8 EKANS, L6 JIGGLYPUFF, L10 ARBOK, L8 SPEAROW, L8 SPEAROW; nite: L5 RATTATA, L5 ZUBAT, L8 EKANS, L6 JIGGLYPUFF, L10 ARBOK, L8 RATTATA, L8 RATTATA. + The walkthrough's "Rattata / Spearow / Arbok" is the Silver column again. +- Water: `data/wild/kanto_water.asm:5` `def_water_wildmons ROUTE_4`, 4 percent: L10 GOLDEEN, L5 GOLDEEN, L10 SEAKING. +- Fishing group `FISHGROUP_LAKE` from the map header. + +--- + +### MAP_VIRIDIAN_CITY + +- Script: `maps/ViridianCity.asm` (`ViridianCity_MapEvents` = `4e:4486`) +- Blocks: `maps/ViridianCity.blk` +- Header: `data/maps/maps.asm:457` -> `map ViridianCity, TILESET_KANTO, TOWN, LANDMARK_VIRIDIAN_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:423` -> `map_const VIRIDIAN_CITY, 20, 18` (40x36 walk cells), group `VIRIDIAN` +- Connections: `data/maps/attributes.asm:259` -> north `Route2` (offset 5), south `Route1` (offset 10), west `Route22` (offset 4) +- Spawn: `data/maps/spawn_points.asm:15` -> `spawn VIRIDIAN_CITY, 23, 26`; flypoint `data/maps/flypoints.asm:19` -> `db LANDMARK_VIRIDIAN_CITY, SPAWN_VIRIDIAN` + +**Scene scripts**: none. **Callbacks**: `MAPCALLBACK_NEWMAP` -> `ViridianCityFlypointCallback` = `setflag ENGINE_FLYPOINT_VIRIDIAN / endcallback`. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 32 | 7 | `VIRIDIAN_GYM` | 1 | +| 2 | 21 | 9 | `VIRIDIAN_NICKNAME_SPEECH_HOUSE` | 1 | +| 3 | 23 | 15 | `TRAINER_HOUSE_1F` | 1 | +| 4 | 29 | 19 | `VIRIDIAN_MART` | 2 | +| 5 | 23 | 25 | `VIRIDIAN_POKECENTER_1F` | 1 | + +The Trainer House (23, 15) is exactly 10 cells due north of the Pokemon Center (23, 25), which is the walkthrough's "north from the Pokemon Center". + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 17 | `BGEVENT_READ` | `ViridianCitySign` | +| 27 | 7 | `BGEVENT_READ` | `ViridianGymSign` | +| 19 | 1 | `BGEVENT_READ` | `ViridianCityWelcomeSign` | +| 21 | 15 | `BGEVENT_READ` | `TrainerHouseSign` | +| 24 | 25 | `BGEVENT_READ` | `ViridianCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 30 | 19 | `BGEVENT_READ` | `ViridianCityMartSign` (`jumpstd MartSignScript`) | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIRIDIANCITY_GRAMPS1` | `SPRITE_GRAMPS` | 18 | 5 | `SPRITEMOVEDATA_WANDER` (radius 2,2) | `OBJECTTYPE_SCRIPT` | `ViridianCityCoffeeGramps` | -1 | +| `VIRIDIANCITY_GRAMPS2` | `SPRITE_GRAMPS` | 30 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ViridianCityGrampsNearGym` | -1 | +| `VIRIDIANCITY_FISHER` | `SPRITE_FISHER` | 6 | 23 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ViridianCityDreamEaterFisher` (`4e:4032`) | -1 | +| `VIRIDIANCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 17 | 21 | `SPRITEMOVEDATA_WANDER` (radius 3,3) | `OBJECTTYPE_SCRIPT` | `ViridianCityYoungsterScript` | -1 | + +**Scripts of interest** + +- `ViridianCityFlypointCallback` - runs on `MAPCALLBACK_NEWMAP`, so simply *entering* Viridian City registers it as a Fly destination. +- `ViridianCityDreamEaterFisher` (`4e:4032`) - `faceplayer / opentext / checkevent EVENT_GOT_TM42_DREAM_EATER / iftrue .GotDreamEater / writetext ... / promptbutton / verbosegiveitem TM_DREAM_EATER / iffalse .NoRoomForDreamEater / setevent EVENT_GOT_TM42_DREAM_EATER`. A free TM42 the walkthrough never mentions. +- `ViridianCityGrampsNearGym` - branches on `EVENT_BLUE_IN_CINNABAR`; flavour only. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_VIRIDIAN` | `constants/engine_flags.asm:68` | set by `ViridianCityFlypointCallback` | Fly target unlocked on first visit | +| `EVENT_GOT_TM42_DREAM_EATER` | `constants/event_flags.asm:223` | `ViridianCityDreamEaterFisher` | one-time TM | +| `EVENT_BLUE_IN_CINNABAR` | `constants/event_flags.asm:1303` | read only, here | selects the gym gramps' text | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `TM_DREAM_EATER` (TM42) | talk to `VIRIDIANCITY_FISHER` at (6, 23) | `ViridianCityDreamEaterFisher`, `verbosegiveitem` | `EVENT_GOT_TM42_DREAM_EATER` | + +**Trainers**: none on the overworld map. + +**Wild encounters**: `data/wild/kanto_water.asm:124` `def_water_wildmons VIRIDIAN_CITY`, 2 percent (surf only). No grass entry for `VIRIDIAN_CITY`. + +--- + +### MAP_TRAINER_HOUSE_1F + +- Script: `maps/TrainerHouse1F.asm` (`TrainerHouse1F_MapEvents` = `5f:48f9`) +- Blocks: `maps/TrainerHouse1F.blk` +- Header: `data/maps/maps.asm:460` -> `map TrainerHouse1F, TILESET_HOUSE, INDOOR, LANDMARK_VIRIDIAN_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:425` -> `map_const TRAINER_HOUSE_1F, 5, 7` (10x14 walk cells) +- Attributes: `data/maps/attributes.asm:659` -> `$00`, no connections +- No scene scripts, no callbacks. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 13 | `VIRIDIAN_CITY` | 3 | +| 2 | 3 | 13 | `VIRIDIAN_CITY` | 3 | +| 3 | 8 | 2 | `TRAINER_HOUSE_B1F` | 1 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 0 | `BGEVENT_READ` | `TrainerHouseSign1` | +| 7 | 0 | `BGEVENT_READ` | `TrainerHouseSign2` | +| 7 | 10 | `BGEVENT_READ` | `TrainerHouseIllegibleBook` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TRAINERHOUSE1F_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 0 | 11 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `TrainerHouse1FReceptionistScript` | -1 | +| `TRAINERHOUSE1F_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 7 | 11 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | `TrainerHouse1FCooltrainerMScript` | -1 | +| `TRAINERHOUSE1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 6 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `TrainerHouse1FCooltrainerFScript` | -1 | +| `TRAINERHOUSE1F_YOUNGSTER` | `SPRITE_YOUNGSTER` | 4 | 8 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `TrainerHouse1FYoungsterScript` | -1 | +| `TRAINERHOUSE1F_GENTLEMAN` | `SPRITE_GENTLEMAN` | 2 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `TrainerHouse1FGentlemanScript` | -1 | + +Every 1F NPC is `jumptextfaceplayer` flavour. Nothing here gates the basement. + +--- + +### MAP_TRAINER_HOUSE_B1F + +- Script: `maps/TrainerHouseB1F.asm` (`TrainerHouseB1F_MapEvents` = `5f:4b97`, `TrainerHouseReceptionistScript` = `5f:4965`) +- Blocks: `maps/TrainerHouseB1F.blk` +- Header: `data/maps/maps.asm:461` -> `map TrainerHouseB1F, TILESET_FACILITY, INDOOR, LANDMARK_VIRIDIAN_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:426` -> `map_const TRAINER_HOUSE_B1F, 5, 8` (10x16 walk cells) +- Attributes: `data/maps/attributes.asm:660` -> `$00`, no connections +- Scene var: `data/maps/scenes.asm:15` -> `wTrainerHouseB1FSceneID` + +**Scene scripts** + +| scene id | constant | script | +|---|---|---| +| 0 | `SCENE_TRAINERHOUSEB1F_ASK_BATTLE` | `TrainerHouseB1FNoopScene` -> `end` (the id only exists to arm the coord_event; it is never changed) | + +**Callbacks**: none. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 4 | `TRAINER_HOUSE_1F` | 3 | + +**Coord events** + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_TRAINERHOUSEB1F_ASK_BATTLE` (0) | 7 | 3 | `TrainerHouseReceptionistScript` (`5f:4965`) | the once-a-day CAL battle | + +The bot walks in at (9, 4) and must reach (7, 3) - two cells left and one up. That is the walkthrough's "head left after you go downstairs and the lady will talk to you". + +**BG events**: none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `TRAINERHOUSEB1F_RECEPTIONIST` | `SPRITE_RECEPTIONIST` | 7 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` (stub - she is only reachable through the coord_event) | -1 | +| `TRAINERHOUSEB1F_CHRIS` | `SPRITE_CHRIS` | 6 | 11 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `ObjectEvent` | -1 | + +`TRAINERHOUSEB1F_CHRIS` is the opponent sprite in the battle room; `setlasttalked TRAINERHOUSEB1F_CHRIS` points the battle at it. + +**Scripts of interest** + +`TrainerHouseReceptionistScript`: + +1. `turnobject PLAYER, UP`, `opentext`. +2. `checkflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` -> `iftrue .FoughtTooManyTimes` (refusal text, `applymovement PLAYER, Movement_TrainerHouseTurnBack`). +3. `special TrainerHouse` (`engine/events/specials.asm:454`: reads `sMysteryGiftTrainerHouseFlag` into `wScriptVar`). True -> name from `gettrainername STRING_BUFFER_3, CAL, CAL2`; false -> `CAL, CAL3`. +4. `yesorno`. No -> `.Declined`. Yes -> `setflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY`, text, `applymovement PLAYER, Movement_EnterTrainerHouseBattleRoom` (`LEFT x3, DOWN x8, LEFT, turn_head RIGHT`). +5. `special TrainerHouse` again. True -> `loadtrainer CAL, CAL2`; false -> `.NoSpecialBattle` -> `loadtrainer CAL, CAL3`. Both arms `winlosstext TrainerHouseB1FCalBeatenText, 0`, `setlasttalked TRAINERHOUSEB1F_CHRIS`, `startbattle`, `reloadmapafterbattle`. +6. `.End`: `applymovement PLAYER, Movement_ExitTrainerHouseBattleRoom`. + +`CAL, CAL2` is the only trainer in the game whose party is **not** read from `data/trainers/parties.asm`: `engine/battle/read_trainer_party.asm:17-22` branches to `.cal2` and reads `sMysteryGiftTrainer` out of SRAM, and `GetTrainerName` (same file, line 326) reads `sMysteryGiftPartnerName`. With no Mystery Gift ever performed the flag is 0 and the game always fights `CAL3`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` | `constants/engine_flags.asm:104` | `checkflag`/`setflag` here | set = already battled today; cleared by the daily reset | +| `SCENE_TRAINERHOUSEB1F_ASK_BATTLE` = 0 | `maps/TrainerHouseB1F.asm:7` | `wTrainerHouseB1FSceneID` | always 0, so the coord_event is always armed | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `CAL3` | `CAL` (`constants/trainer_constants.asm:73`) | CAL (3), line 222 | L50 MEGANIUM, L50 TYPHLOSION, L50 FERALIGATR (`TRAINERTYPE_NORMAL`) | `TrainerHouseReceptionistScript` `.NoSpecialBattle` | once per day | +| `CAL2` | `CAL` | CAL (2), line 214 - **dead data** | table row is L30 BAYLEEF/QUILAVA/CROCONAW, but `ReadTrainerParty` never reaches it; the real party comes from `sMysteryGiftTrainer` | `TrainerHouseReceptionistScript` main arm | Mystery Gift only | + +The walkthrough's "Level 50 Meganium / Typhlosion / Feraligatr" is `CAL3`, and its Thard_Verad note ("Cal is a placeholder; if you Mystery Gift with anyone, they can show up") is exactly the `CAL2` branch. + +--- + +### MAP_ROUTE_1 + +- Script: `maps/Route1.asm` (`Route1_MapEvents` = `4e:4675`) +- Blocks: `maps/Route1.blk` +- Header: `data/maps/maps.asm:311` -> `map Route1, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_1, MUSIC_ROUTE_1, FALSE, PALETTE_AUTO, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:287` -> `map_const ROUTE_1, 10, 18` (20x36 walk cells), group `PALLET` +- Connections: `data/maps/attributes.asm:267` -> north `ViridianCity` (offset -10), south `PalletTown` (offset 0) +- No scene scripts, no callbacks, **no warps** (connections only). + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 27 | `BGEVENT_READ` | `Route1Sign` -> `Route1SignText` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE1_YOUNGSTER` | `SPRITE_YOUNGSTER` | 7 | 17 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerSchoolboyDanny` (`4e:4531`) | -1 | +| `ROUTE1_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 3 | 26 | `SPRITEMOVEDATA_STANDING_RIGHT` (radius 1,0) | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerCooltrainerfQuinn` (`4e:4545`) | -1 | +| `ROUTE1_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 3 | 7 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_SCRIPT` | `Route1FruitTree` (`4e:455c`) = `fruittree FRUITTREE_ROUTE_1` | -1 | + +**Scripts of interest** + +`Route1FruitTree` is one command: `fruittree FRUITTREE_ROUTE_1` (`constants/script_constants.asm:232`). The item comes from `FruitTreeItems` (`data/items/fruit_trees.asm`), whose `ROUTE_1` row is `db BITTER_BERRY`. This is the walkthrough's "Item on Route 1: Bitter Berry" - it is a **fruit tree**, not a ground item, so it is a daily respawn rather than a one-time pickup (`engine/events/fruit_trees.asm`, `TryResetFruitTrees`). + +Both trainer scripts are the standard `endifjustbattled` after-battle text. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SCHOOLBOY_DANNY` | `constants/event_flags.asm:621` | `trainer` header | set on win | +| `EVENT_BEAT_COOLTRAINERF_QUINN` | `constants/event_flags.asm:890` | `trainer` header | set on win | + +The fruit tree's picked state is **not** an `EVENT_*` - it is a per-tree bit in the fruit-tree flag array with a daily reset (see `engine/events/fruit_trees.asm`). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `BITTER_BERRY` | face the tree object at (3, 7) and press A | `Route1FruitTree` -> `FruitTreeItems` row `ROUTE_1` | daily per-tree flag, not an event flag | + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `DANNY` | `SCHOOLBOY` | SCHOOLBOY (5), line 435 | L31 JYNX, L31 ELECTABUZZ, L31 MAGMAR | `TrainerSchoolboyDanny` | none | +| `QUINN` | `COOLTRAINERF` | COOLTRAINERF (14), line 948 | L38 IVYSAUR, L38 STARMIE | `TrainerCooltrainerfQuinn` | none | + +**Wild encounters** + +`data/wild/kanto_grass.asm:231` `def_grass_wildmons ROUTE_1`, rates `10/10/10 percent`. **No version split.** + +- morn/day: L2 PIDGEY, L2 RATTATA, L3 SENTRET, L3 PIDGEY, L6 FURRET, L4 PIDGEY, L4 PIDGEY. +- nite: L2 HOOTHOOT, L2 RATTATA, L3 RATTATA, L3 HOOTHOOT, L6 RATTATA, L4 HOOTHOOT, L4 HOOTHOOT. + +The walkthrough's "Pidgey / Furret" is the morn/day column minus Rattata and Sentret; at night there is no Pidgey or Furret at all. + +--- + +### MAP_PALLET_TOWN + +- Script: `maps/PalletTown.asm` (`PalletTown_MapEvents` = `4e:47aa`) +- Blocks: `maps/PalletTown.blk` +- Header: `data/maps/maps.asm:312` -> `map PalletTown, TILESET_KANTO, TOWN, LANDMARK_PALLET_TOWN, MUSIC_PALLET_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:288` -> `map_const PALLET_TOWN, 10, 9` (20x18 walk cells), group `PALLET` +- Connections: `data/maps/attributes.asm:271` -> north `Route1` (offset 0), south `Route21` (offset 0) +- Spawn: `data/maps/spawn_points.asm:14` -> `spawn PALLET_TOWN, 5, 6`; flypoint `data/maps/flypoints.asm:18` -> `db LANDMARK_PALLET_TOWN, SPAWN_PALLET` + +**Scene scripts**: none. **Callbacks**: `MAPCALLBACK_NEWMAP` -> `PalletTownFlypointCallback` = `setflag ENGINE_FLYPOINT_PALLET / endcallback`. This is the asm behind "You can now fly to Pallet Town" - it fires the moment the map loads, before any NPC is talked to. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `REDS_HOUSE_1F` | 1 | +| 2 | 13 | 5 | `BLUES_HOUSE` | 1 | +| 3 | 12 | 11 | `OAKS_LAB` | 1 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 9 | `BGEVENT_READ` | `PalletTownSign` | +| 3 | 5 | `BGEVENT_READ` | `RedsHouseSign` | +| 13 | 13 | `BGEVENT_READ` | `OaksLabSign` | +| 11 | 5 | `BGEVENT_READ` | `BluesHouseSign` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PALLETTOWN_TEACHER` | `SPRITE_TEACHER` | 3 | 8 | `SPRITEMOVEDATA_WANDER` (radius 2,2) | `OBJECTTYPE_SCRIPT` | `PalletTownTeacherScript` | -1 | +| `PALLETTOWN_FISHER` | `SPRITE_FISHER` | 12 | 14 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 2,0) | `OBJECTTYPE_SCRIPT` | `PalletTownFisherScript` | -1 | + +Both are `jumptextfaceplayer` flavour. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_PALLET` | `constants/engine_flags.asm:67` | set by `PalletTownFlypointCallback` | Fly target unlocked on first visit | + +**Items**: none. **Trainers**: none. + +**Wild encounters**: `data/wild/kanto_water.asm:117` `def_water_wildmons PALLET_TOWN`, 6 percent: L35 TENTACOOL, L30 TENTACOOL, L35 TENTACRUEL. No grass entry. Fishing group `FISHGROUP_OCEAN`. + +--- + +### MAP_OAKS_LAB + +- Script: `maps/OaksLab.asm` (`OaksLab_MapEvents` = `59:5f33`, `Oak` = `59:58c3`) +- Blocks: `maps/OaksLab.blk` +- Header: `data/maps/maps.asm:316` -> `map OaksLab, TILESET_LAB, INDOOR, LANDMARK_PALLET_TOWN, MUSIC_POKEMON_TALK, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:291` -> `map_const OAKS_LAB, 5, 6` (10x12 walk cells) +- No connections. `OaksLabNoopScene` exists but is marked `; unreferenced`; `def_scene_scripts` is empty and there are no callbacks. + +**Warps** + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 11 | `PALLET_TOWN` | 3 | +| 2 | 5 | 11 | `PALLET_TOWN` | 3 | + +**Coord events**: none. + +**BG events** + +| x | y | type | script/item | +|---|---|---|---| +| 6,7,8,9 | 1 | `BGEVENT_READ` | `OaksLabBookshelf` (`jumpstd DifficultBookshelfScript`) | +| 0,1,2,3 | 7 | `BGEVENT_READ` | `OaksLabBookshelf` | +| 6,7,8,9 | 7 | `BGEVENT_READ` | `OaksLabBookshelf` | +| 4 | 0 | `BGEVENT_READ` | `OaksLabPoster1` | +| 5 | 0 | `BGEVENT_READ` | `OaksLabPoster2` | +| 9 | 3 | `BGEVENT_READ` | `OaksLabTrashcan` | +| 0 | 1 | `BGEVENT_READ` | `OaksLabPC` | + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OAKSLAB_OAK` | `SPRITE_OAK` | 4 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Oak` (`59:58c3`) | -1 | +| `OAKSLAB_SCIENTIST1` | `SPRITE_SCIENTIST` | 1 | 8 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` | `OaksAssistant1Script` | -1 | +| `OAKSLAB_SCIENTIST2` | `SPRITE_SCIENTIST` | 8 | 9 | `SPRITEMOVEDATA_WALK_UP_DOWN` (radius 0,1) | `OBJECTTYPE_SCRIPT` | `OaksAssistant2Script` | -1 | +| `OAKSLAB_SCIENTIST3` | `SPRITE_SCIENTIST` | 1 | 4 | `SPRITEMOVEDATA_WANDER` (radius 1,1) | `OBJECTTYPE_SCRIPT` | `OaksAssistant3Script` | -1 | + +**Scripts of interest** + +`Oak` (`59:58c3`): + +``` +faceplayer / opentext +checkevent EVENT_OPENED_MT_SILVER ; iftrue .CheckPokedex +checkevent EVENT_TALKED_TO_OAK_IN_KANTO ; iftrue .CheckBadges +writetext OakWelcomeKantoText / promptbutton / setevent EVENT_TALKED_TO_OAK_IN_KANTO +.CheckBadges: + readvar VAR_BADGES + ifequal NUM_BADGES, .OpenMtSilver ; 16 + ifequal NUM_JOHTO_BADGES, .Complain ; 8 (i.e. zero Kanto badges) + sjump .AhGood ; 9..15 badges +.OpenMtSilver: writetext OakOpenMtSilverText / promptbutton / setevent EVENT_OPENED_MT_SILVER / sjump .CheckPokedex +.Complain: writetext OakNoKantoBadgesText / promptbutton / sjump .CheckPokedex +.AhGood: writetext OakYesKantoBadgesText / promptbutton / sjump .CheckPokedex +.CheckPokedex: writetext OakLabDexCheckText / waitbutton / special ProfOaksPCBoot / writetext OakLabGoodbyeText / waitbutton / closetext / end +``` + +`VAR_BADGES` = `constants/script_constants.asm:55`. `NUM_JOHTO_BADGES`, `NUM_KANTO_BADGES` and `NUM_BADGES = NUM_JOHTO_BADGES + NUM_KANTO_BADGES` are at `constants/ram_constants.asm:260,272,273`. The walkthrough's "He notices your Kanto badges. He tells you that he'll give you a prize if you get all the badges" is the `.AhGood` arm; the actual prize is the Mt. Silver unlock in `.OpenMtSilver`, which needs all 16. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_TALKED_TO_OAK_IN_KANTO` | `constants/event_flags.asm:224` | set on the first `Oak` conversation | skips the welcome text afterwards | +| `EVENT_OPENED_MT_SILVER` | `constants/event_flags.asm:1265` | set when `VAR_BADGES == NUM_BADGES` | Mt. Silver / the Red fight opens | + +**Items**: none. **Trainers**: none. **Wild encounters**: none. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Mt. Moon rival battle is unavoidable | `maps/MountMoon.asm` `MountMoonRivalEncounterScene` / `MountMoonRivalBattleScript` (`42:55b0`), armed by `wMountMoonSceneID == SCENE_MOUNTMOON_RIVAL_BATTLE` (0) | be able to win a 6-mon L41-45 fight | `setscene SCENE_MOUNTMOON_NOOP` + `setevent EVENT_BEAT_RIVAL_IN_MT_MOON` at the end of the script. Losing does not set the flag; the scene id is unchanged so the fight re-arms on re-entry. | +| Mt. Moon Square Moon Stone | `maps/MountMoonSquare.asm` `MountMoonSquareDisappearMoonStoneCallback` (sets `EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` on every `MAPCALLBACK_NEWMAP`) | see the Clefairy dance | `ClefairyDance` `clearevent EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE`. Leaving the map re-sets it. | +| Clefairy dance itself | `maps/MountMoonSquare.asm` `ClefairyDance` head | `ENGINE_MT_MOON_SQUARE_CLEFAIRY` clear **and** `VAR_WEEKDAY == MONDAY` **and** `checktime NITE` | one-shot: the script `setflag ENGINE_MT_MOON_SQUARE_CLEFAIRY` at the end | +| Rock on the Moon Stone tile | `maps/MountMoonSquare.asm` `MtMoonSquareRock` -> `engine/events/std_scripts.asm:199 SmashRockScript` -> `engine/events/overworld.asm:1365 AskRockSmashScript` / `HasRockSmash` (line 1386) | `ROCK_SMASH` on a party member (`CheckPartyMove`) - **no badge check** | teach TM08 to anything | +| Trainer House CAL, once per day | `maps/TrainerHouseB1F.asm` `TrainerHouseReceptionistScript` `checkflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` | flag clear | the game's daily flag reset clears `ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` | +| Trainer House opponent identity | `engine/events/specials.asm:454 TrainerHouse` reading `sMysteryGiftTrainerHouseFlag`; party via `engine/battle/read_trainer_party.asm:17` | Mystery Gift performed | not reachable without a second cartridge; falls back to `CAL3` | +| Fly back to Pewter/Pallet/Viridian | `engine/events/overworld.asm:544 FlyFunction .TryFly` -> `ld de, ENGINE_STORMBADGE / call CheckBadge` **plus** the destination's own `ENGINE_FLYPOINT_*` | Storm Badge + having visited the town | `ViridianCityFlypointCallback` / `PalletTownFlypointCallback` set their flags on `MAPCALLBACK_NEWMAP` | +| Surf south from Pallet to Route 21 (next section) | `engine/events/overworld.asm:338 SurfFunction .TrySurf` -> `ld de, ENGINE_FOGBADGE / call CheckBadge` | Fog Badge + a party member that knows Surf | out of scope here | +| Indigo Plateau rival re-encounter | `maps/IndigoPlateauPokecenter1F.asm:45,67` `PlateauRivalBattle1/2` | `EVENT_BEAT_RIVAL_IN_MT_MOON` set, `ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT` clear, `VAR_WEEKDAY` not SUNDAY/TUESDAY/THURSDAY/FRIDAY/SATURDAY (so Monday or Wednesday) | matches the walkthrough exactly | +| Dragon's Den rival sighting | `maps/DragonsDenB1F.asm:12` `DragonsDenB1FCheckRivalCallback` | `EVENT_BEAT_RIVAL_IN_MT_MOON` set and `VAR_WEEKDAY == TUESDAY` or `THURSDAY` | matches the walkthrough exactly | + +Nothing in this section requires a badge, an HM or a key item to pass through. The only hard stop is the Mt. Moon rival battle. + +--- + +## 4. Bot checklist + +Coordinates are asm walk cells `(x, y)` on the named map. + +1. `ROUTE_3` - enter from the west connection from `PEWTER_CITY`. Walk east. +2. `ROUTE_3` - trainer `ROUTE3_YOUNGSTER1` at (11, 2), sight 3 downward-ish (`STANDING_DOWN`, range 2). Precondition: `EVENT_BEAT_YOUNGSTER_WARREN` clear. Intent: battle (L35 Fearow). Postcondition: `EVENT_BEAT_YOUNGSTER_WARREN` set. +3. `ROUTE_3` - trainer `ROUTE3_YOUNGSTER2` at (20, 4), `STANDING_RIGHT`, sight 3. Battle (L33 Raticate, L33 Arbok). Post: `EVENT_BEAT_YOUNGSTER_JIMMY`. +4. `ROUTE_3` - trainer `ROUTE3_FISHER1` at (26, 12), `STANDING_UP`, sight 2. Battle (L29 Magmar, L32 Weezing, L29 Magmar). Post: `EVENT_BEAT_FIREBREATHER_OTIS`. +5. `ROUTE_3` - trainer `ROUTE3_FISHER2` at (49, 5), `SPINRANDOM_FAST`, sight 3. Battle (L32 Koffing, L32 Slugma). Post: `EVENT_BEAT_FIREBREATHER_BURT`. +6. `ROUTE_3` - optional: read the sign at (49, 13). Then heal (walkthrough advice, no flag) and save. +7. `ROUTE_3` - step on warp 1 at (52, 1). Postcondition: player on `MOUNT_MOON` at (3, 3). +8. `MOUNT_MOON` - do nothing; the scene script fires. Precondition: `wMountMoonSceneID == 0`, `EVENT_MT_MOON_RIVAL` clear. Intent: battle `RIVAL2` with the party chosen by `EVENT_GOT_TOTODILE_FROM_ELM` / `EVENT_GOT_CHIKORITA_FROM_ELM`. Postcondition: `EVENT_BEAT_RIVAL_IN_MT_MOON` set, `EVENT_MT_MOON_RIVAL` set, `wMountMoonSceneID = 1`. +9. Optional detour: `MOUNT_MOON` (13, 3) warp 3 -> lands at (25, 3); walk to (25, 5) warp 5 -> `MOUNT_MOON_SQUARE` (20, 5). +10. `MOUNT_MOON_SQUARE` - (13, 7) warp 3 -> `MOUNT_MOON_GIFT_SHOP`. Talk to the clerk (`(4, 3)` in MORN, `(1, 2)` in DAY - **no clerk at NITE**) to open `MART_MT_MOON`. Exit at (3, 7) or (4, 7). +11. `MOUNT_MOON_SQUARE` optional, Monday night only: walk onto (7, 11) to trip `ClefairyDance`. Preconditions: `ENGINE_MT_MOON_SQUARE_CLEFAIRY` clear, `VAR_WEEKDAY == MONDAY`, `checktime NITE` true. Postcondition: `ENGINE_MT_MOON_SQUARE_CLEFAIRY` set, `EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE` cleared. Then, **without leaving the map**, face (7, 7); if the rock object is there, use Rock Smash (requires `ROCK_SMASH` in the party), then press A on (7, 7) to take `MOON_STONE`. +12. `MOUNT_MOON_SQUARE` - (22, 11) warp 2 -> `MOUNT_MOON` (25, 15). Walk to (25, 13) warp 8 -> lands at (15, 11). Walk to (15, 15) warp 2 -> `ROUTE_4` (2, 5). +13. `ROUTE_4` - trainer `ROUTE4_LASS1` at (10, 8), `STANDING_LEFT`, sight 5. Battle (L34 Flaaffy). Post: `EVENT_BEAT_PICNICKER_HOPE`. +14. `ROUTE_4` - trainer `ROUTE4_YOUNGSTER` at (17, 9), `STANDING_DOWN`, sight 3. Battle (L12 Pidgey, L34 Pidgeot). Post: `EVENT_BEAT_BIRD_KEEPER_HANK`. +15. `ROUTE_4` - trainer `ROUTE4_LASS2` at (21, 6), `STANDING_RIGHT`, sight 4. Battle (L31 Furret, L33 Rapidash). Post: `EVENT_BEAT_PICNICKER_SHARON`. +16. `ROUTE_4` - item ball `ROUTE4_POKE_BALL` at (26, 3). Intent: take `HP_UP`. Precondition: `EVENT_ROUTE_4_HP_UP` clear. Postcondition: set. +17. `ROUTE_4` - optional hidden item: face (10, 3), press A -> `ULTRA_BALL`. Post: `EVENT_ROUTE_4_HIDDEN_ULTRA_BALL`. +18. Travel to `VIRIDIAN_CITY` (Fly needs `ENGINE_STORMBADGE` and the destination flypoint; the walkthrough's route is Fly to Pewter, then `ROUTE_2` south through `VIRIDIAN_FOREST`). Entering Viridian sets `ENGINE_FLYPOINT_VIRIDIAN`. +19. `VIRIDIAN_CITY` - optional: talk to `VIRIDIANCITY_FISHER` at (6, 23) for `TM_DREAM_EATER`. Precondition: `EVENT_GOT_TM42_DREAM_EATER` clear and pack room. Post: set. +20. `VIRIDIAN_CITY` - warp 3 at (23, 15) -> `TRAINER_HOUSE_1F` (2, 13). Then warp 3 at (8, 2) -> `TRAINER_HOUSE_B1F` (9, 4). +21. `TRAINER_HOUSE_B1F` - walk to (7, 3) to trip the coord_event. Precondition: `ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` clear. Answer YES. Intent: battle `CAL, CAL3` (L50 Meganium / Typhlosion / Feraligatr). Postcondition: `ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY` set. The script walks the player in and out with `applymovement`; do not fight the movement. +22. Return to `VIRIDIAN_CITY` and cross the south connection into `ROUTE_1`. +23. `ROUTE_1` - fruit tree object at (3, 7). Face it, press A -> `BITTER_BERRY`. Daily respawn. +24. `ROUTE_1` - trainer `ROUTE1_YOUNGSTER` at (7, 17), `SPINRANDOM_FAST`, sight 3. Battle (L31 Jynx, L31 Electabuzz, L31 Magmar). Post: `EVENT_BEAT_SCHOOLBOY_DANNY`. +25. `ROUTE_1` - trainer `ROUTE1_COOLTRAINER_F` at (3, 26), `STANDING_RIGHT`, sight 4. Battle (L38 Ivysaur, L38 Starmie). Post: `EVENT_BEAT_COOLTRAINERF_QUINN`. +26. `ROUTE_1` - south connection into `PALLET_TOWN`. Postcondition on map load: `ENGINE_FLYPOINT_PALLET` set. +27. `PALLET_TOWN` - warp 3 at (12, 11) -> `OAKS_LAB` (4, 11). +28. `OAKS_LAB` - talk to `OAKSLAB_OAK` at (4, 2). Postcondition: `EVENT_TALKED_TO_OAK_IN_KANTO` set; if `VAR_BADGES == 16`, also `EVENT_OPENED_MT_SILVER`. +29. Exit to `PALLET_TOWN` and head south to the `ROUTE_21` connection (Surf, `ENGINE_FOGBADGE`) - next section. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Kanto map geometry, warps, connections, bg/coord/object rows | `src/import/RomExtractorGen2.lua` (extracts all map events), `src/world/gen2/Map.lua`, `src/world/gen2/World.lua` | implemented (generic - every map in the ROM comes through the same path, no per-map Kanto code needed) | +| Scene scripts / `wMapSceneID` (Mt. Moon rival, Trainer House coord_event) | `src/world/gen2/World.lua:1175-1183` (`GetMapSceneID` port), `:5014-5040` (coord event and scene script lookup) | implemented | +| `sdefer` timing for the Mt. Moon rival cutscene | `src/script/gen2/Vm.lua:92-93` | partial - the comment says "sdefer queues after the map settles in retail; run immediately here", so the rival scene starts a frame or two earlier than the cart. Cosmetic for a bot, visible in a screenshot diff. | +| Map callbacks (`MAPCALLBACK_NEWMAP` / `MAPCALLBACK_OBJECTS`) - the flypoint setters and the Mt. Moon Square rock/moon-stone callbacks | `src/world/gen2/World.lua:5969`, driver `tests/drivers/gold_map_callbacks.lua` | implemented, with an assertion driver | +| `appear` / `disappear` object visibility via the event bitfield | `src/world/gen2/Events.lua` (header comment cites `CheckObjectFlag`) | implemented | +| Overworld trainers: `trainer` struct, eyesight, party build | `src/world/gen2/Trainers.lua`, `src/world/gen2/World.lua:5257-5264`, driver `tests/drivers/gold_trainer_smoke.lua` | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `setlasttalked` (the rival and CAL fights) | `src/script/gen2/Opcodes.lua:99,100,105,109`, `src/script/gen2/Vm.lua:806` | implemented | +| Trainer House daily CAL battle + Mystery Gift fallback | `src/world/gen2/TrainerHouse.lua` | implemented (deliberately answers `CAL3`; Mystery Gift is out of scope, documented in the file header) | +| Cutscene opcodes used by `ClefairyDance` and the rival scene: `showemote`, `follow`, `stopfollow`, `cry`, `moveobject`, `applymovement` | `src/script/gen2/Opcodes.lua:117-137`, `src/script/gen2/Vm.lua:961`, `src/script/gen2/Movement.lua` | implemented | +| `checktime` / `VAR_WEEKDAY` (the Monday-night Clefairy guard) | `src/script/gen2/Vm.lua:784`, `src/core/gen2/BugContest.lua:112` (`weekday`) | implemented | +| Hidden items (`BGEVENT_ITEM`: Mt. Moon Square Moon Stone, Route 4 Ultra Ball) | `src/world/gen2/HiddenItems.lua`, wired at `src/world/gen2/World.lua:5291` | implemented | +| Item balls (`OBJECTTYPE_ITEMBALL`: the Route 4 HP Up) | extracted at `src/import/RomExtractorGen2.lua:2968-2969` as `obj.itemball`; **nothing reads it** - `World:interact` (`src/world/gen2/World.lua:5257`) only dispatches `npc.def.trainer`, strength boulders, `npc.def.scriptKey`, bg events and hidden items | **missing** - a bot cannot pick up the Route 4 HP Up in this port today | +| Fruit trees (`fruittree FRUITTREE_ROUTE_1` -> Bitter Berry) | `src/core/gen2/Apricorns.lua` (`FruitTreeItems` table, per-tree flag, `tryResetFruitTrees`), `src/script/gen2/Vm.lua:1191` | implemented | +| `pokemart` / `MART_MT_MOON` | `src/script/gen2/Vm.lua:597`, `src/ui/gen2/MartMenu.lua:336-348` | implemented (list comes from the extracted `data/generated/marts.lua`) | +| `verbosegiveitem` (Viridian TM42) | `src/script/gen2/Vm.lua:490-498` | implemented | +| Rock Smash on the Mt. Moon Square rock | `src/world/gen2/FieldMoves.lua` (comment at :588 lists `ROCK_SMASH` among the field moves); the strength-boulder equivalent is special-cased in `World:interact` | partial - `jumpstd SmashRockScript` is a `farsjump` into ASM, so there is no bytecode for the extractor to find. `World:interact` has an explicit arm for strength boulders (`World.isStrengthBoulder`) but **no equivalent arm for `SPRITEMOVEDATA_SMASHABLE_ROCK`**, so pressing A on the Mt. Moon Square rock reaches a nil `scriptKey`. Verify by hand. | +| Fly / flypoints (`ENGINE_FLYPOINT_PALLET` = 52, `ENGINE_FLYPOINT_VIRIDIAN` = 53) | `src/world/gen2/FieldMoves.lua:342-370` (both Kanto rows present) | implemented | +| Kanto wild tables (Gold vs Silver split) | extracted by `src/import/RomExtractorGen2.lua` from the ROM, so whichever cart was imported is what you get | implemented (no version conditional needed - the ROM already picked) | + +--- + +## 6. Unresolved / verify by hand + +1. **Rival party order contradicts the walkthrough.** The walkthrough lists Sneasel, Golbat, *Starter Evolution*, Magneton, Gengar, Alakazam. `data/trainers/parties.asm:2042-2069` lists Sneasel(41), Golbat(42), Magneton(41), Gengar(43), Alakazam(43), **starter last at level 45**. The walkthrough's per-mon strategy text is still correct; only the ordering is wrong. Levels and species otherwise match. +2. **Route 3 and Route 4 wild lists in the walkthrough are the Silver tables.** `data/wild/kanto_grass.asm:314` and `:369` both have `IF DEF(_GOLD) / ELIF DEF(_SILVER)` arms. Ekans and Arbok only exist in the `_SILVER` arm. On Gold, Route 3 is Spearow/Rattata/Jigglypuff (+Zubat at nite) and Route 4 is the same. The walkthrough is titled for Gold but quoted the Silver data. +3. **Mt. Moon wild list is incomplete in the walkthrough.** Gold also has L8 SANDSHREW, L10 SANDSLASH and two L8 CLEFAIRY slots (`data/wild/kanto_grass.asm:33`). The Clefairy slots mean Clefairy can be caught in the cave at any time, independent of the Monday-night dance. +4. **Route 1 wild list is incomplete and time-dependent.** The walkthrough lists Pidgey and Furret; the nite column (`data/wild/kanto_grass.asm:250`) has neither - it is Hoothoot and Rattata only. +5. **"Bitter Berry" on Route 1 is a fruit tree, not a ground item.** `Route1FruitTree` = `fruittree FRUITTREE_ROUTE_1`, and `data/items/fruit_trees.asm` row `ROUTE_1` is `BITTER_BERRY`. It respawns daily rather than being a one-time pickup, which the walkthrough's "Item on Route 1" phrasing implies. +6. **The two Mt. Moon routes described in the walkthrough could not be verified against the collision map.** The warp graph in section 2 is exact, but which of warps 2/3/4 are reachable from the (3, 3) entrance without passing through the Square depends on `maps/MountMoon.blk` collision, which was not decoded here. The walkthrough's "either way, you end up in the same spot" is consistent with warps 4<->8 and 6 meeting on the (25, 13)/(25, 15) side, but confirm by walking it. +7. **"Bring Rock Smash" for the Clefairy Moon Stone.** The asm supports the claim indirectly: `MountMoonSquareDisappearRockCallback` hides `MOUNTMOONSQUARE_ROCK` on every `MAPCALLBACK_OBJECTS`, `ClefairyDance` `appear`s it at (7, 7) mid-cutscene, and (7, 7) is exactly the `BGEVENT_ITEM` tile the dance unlocks. Whether the rock actually blocks the tile-facing A press (as opposed to being walkable) was not verified; the item could also be reachable by facing (7, 7) from an adjacent cell. Verify in-game. +8. **`ULTRA_BALL` at Route 4 (10, 3) and `TM_DREAM_EATER` in Viridian are absent from the walkthrough.** Both are real (`maps/Route4.asm` bg_event, `maps/ViridianCity.asm` `ViridianCityDreamEaterFisher`). Not a contradiction, just an omission worth carrying into a completionist route. +9. **Mt. Moon Gift Shop has no NITE clerk.** Both `SPRITE_GRAMPS` rows are gated `-1, MORN` and `-1, DAY`. A bot that arrives at night finds no shop, which the walkthrough does not warn about. +10. **`ROUTE_3` warp position vs. its sign.** The only warp is at (52, 1) but the "MT.MOON SQUARE / Just go up the stairs" sign is at (49, 13), twelve cells south. That is what the file says; it reads oddly and is worth an eyeball on the rendered map. +11. **Port: `OBJECTTYPE_ITEMBALL` pickup is unimplemented** (see section 5). The Route 4 HP Up is the first item ball this section needs, and `World:interact` has no arm for it. Confirmed by reading `src/world/gen2/World.lua:5257-5310` and grepping the whole `src/` tree for `itemball` (only the extractor and one comment in `src/script/gen2/CallAsm.lua:547` mention it). +12. **Port: smashable-rock objects have no interact arm** (see section 5). Same class of gap as the item ball, but lower stakes because the only rock in this section is optional. diff --git a/docs/gold-walkthrough/asm-walk/section-27-cinnabar-island-and-seafoam-islands-gym.md b/docs/gold-walkthrough/asm-walk/section-27-cinnabar-island-and-seafoam-islands-gym.md new file mode 100644 index 00000000..863b84a5 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-27-cinnabar-island-and-seafoam-islands-gym.md @@ -0,0 +1,570 @@ +# Section 27 - Cinnabar Island and Seafoam Islands Gym + +Source: `../section-27-cinnabar-island-and-seafoam-islands-gym.txt` +(the FAQ numbers this chapter "33 > Cinnabar Island and Seafoam Islands Gym"; the +file index is 27) + +Maps covered: `MAP_ROUTE_21`, `MAP_CINNABAR_ISLAND`, `MAP_CINNABAR_POKECENTER_1F`, +`MAP_ROUTE_20`, `MAP_SEAFOAM_GYM` + +Badges / key milestones in this section: + +- `ENGINE_FLYPOINT_CINNABAR` (set on first entry to Cinnabar Island) +- Blue's one-off speech on Cinnabar Island, which is what unlocks him in Viridian Gym + (`clearevent EVENT_VIRIDIAN_GYM_BLUE`) +- Hidden RARE_CANDY on Cinnabar Island +- `EVENT_CINNABAR_ROCKS_CLEARED` (set silently by walking onto Route 20; this is what + clears the boulders that seal Route 19 to the east) +- **VOLCANOBADGE** from BLAINE in the Seafoam Islands cave (`ENGINE_VOLCANOBADGE`, + `EVENT_BEAT_BLAINE`) + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `PALLET_TOWN` | - | - | south connection to `ROUTE_21` (`data/maps/attributes.asm:270-272`) | **Pallet Town belongs to the previous section; only the hop south is listed here.** | +| 1 | `ROUTE_21` | `maps/Route21.asm` | north connection from `PALLET_TOWN` (`data/maps/attributes.asm:274-275`) | south connection to `CINNABAR_ISLAND` (`attributes.asm:277`) | Surf south. Three trainers: Nikki, Arnold, Seth | +| 2 | `CINNABAR_ISLAND` | `maps/CinnabarIsland.asm` | north connection from `ROUTE_21` (`attributes.asm:279-280`) | warp 1 at (11,11) -> `CINNABAR_POKECENTER_1F` 1; east connection to `ROUTE_20` (`attributes.asm:281`) | Sets `ENGINE_FLYPOINT_CINNABAR`; heal; talk to Blue; hidden RARE_CANDY | +| 3 | `CINNABAR_POKECENTER_1F` | `maps/CinnabarPokecenter1F.asm` | Cinnabar warp 1 | warps 1/2 at (3,7)/(4,7) back to `CINNABAR_ISLAND` 1 | Heal; the two flavour NPCs the walkthrough quotes ("a year since the volcano erupted", "BLAINE lives alone in the SEAFOAM ISLANDS cave") | +| 4 | `ROUTE_20` | `maps/Route20.asm` | west connection from `CINNABAR_ISLAND` (`attributes.asm:283-284`) | warp 1 at (38,7) -> `SEAFOAM_GYM` 1 | `MAPCALLBACK_NEWMAP` sets `EVENT_CINNABAR_ROCKS_CLEARED`; Swimmer Cameron at (12,13) | +| 5 | `SEAFOAM_GYM` | `maps/SeafoamGym.asm` | Route 20 warp 1 | warp 1 at (5,5) back to `ROUTE_20` 1 | BLAINE -> **VOLCANOBADGE** | + +Spillover: Route 20 continues east past the gym to Swimmer Lori (45,13) and Swimmer +Nicole (52,8) and then over the `connection east, Route19, ROUTE_19, -9` boundary into +Fuchsia territory; the walkthrough does not visit them here and they belong to the +following section. + +--- + +## 2. Maps + +### MAP_ROUTE_21 + +- Script: `maps/Route21.asm` +- Blocks: `maps/Route21.blk` +- Header: `data/maps/maps.asm:198` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_21, MUSIC_ROUTE_3, FALSE (phone calls allowed), PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:182` `map_const ROUTE_21, 10, 18` (group `CINNABAR` = 6, map id 7). 10x18 blocks = 20x36 tile coordinates. +- Attributes / border: `data/maps/attributes.asm:274` `map_attributes Route21, ROUTE_21, $43` +- Connections: north `PALLET_TOWN` (offset 0), south `CINNABAR_ISLAND` (offset 0) +- Landmark position: `data/maps/landmarks.asm:96` `landmark 52, 120, Route21Name` + +**Warps** (`def_warp_events`) + +None (`def_warp_events` at `maps/Route21.asm:95` is empty). + +**Coord events** (`def_coord_events`) + +None (`maps/Route21.asm:97`). + +**BG events** (`def_bg_events`) + +None (`maps/Route21.asm:99`). + +**Object events** (`def_object_events`, `maps/Route21.asm:101-104`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE21_SWIMMER_GIRL` | `SPRITE_SWIMMER_GIRL` | 11 | 16 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfNikki` | -1 (always) | +| `ROUTE21_SWIMMER_GUY` | `SPRITE_SWIMMER_GUY` | 2 | 30 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_TRAINER` | 4 | `TrainerSwimmermSeth` | -1 (always) | +| `ROUTE21_FISHER` | `SPRITE_FISHER` | 14 | 22 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER` | 1 | `TrainerFisherArnold` | -1 (always) | + +The walkthrough's order (Nikki, then the fisherman, then "head way left then down") is +exactly the y ordering 16 -> 22 -> 30, with Seth in the far south-west corner at x=2. + +**Scripts of interest** + +- `TrainerSwimmerfNikki` (`maps/Route21.asm:22`, sym `4e:4803`) - + `trainer SWIMMERF, NIKKI, EVENT_BEAT_SWIMMERF_NIKKI, SwimmerfNikkiSeenText, SwimmerfNikkiBeatenText, 0, .Script`. + The `0` is the loss-text slot (no whiteout text). `.Script` is `endifjustbattled` + + one after-battle line; no items, no flags beyond the beat flag. +- `TrainerFisherArnold` (`maps/Route21.asm:33`, sym `4e:4817`) - same shape, + `EVENT_BEAT_FISHER_ARNOLD`. +- `TrainerSwimmermSeth` (`maps/Route21.asm:11`, sym `4e:47ef`) - same shape, + `EVENT_BEAT_SWIMMERM_SETH`. + +There are no scene scripts and no callbacks on this map (`def_scene_scripts` / +`def_callbacks` at `maps/Route21.asm:7-9` are both empty). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_SWIMMERF_NIKKI` | `constants/event_flags.asm:490` | `trainer` macro row, `maps/Route21.asm:23` | set once Nikki is beaten; suppresses re-battle | +| `EVENT_BEAT_FISHER_ARNOLD` | `constants/event_flags.asm:587` | `maps/Route21.asm:34` | as above | +| `EVENT_BEAT_SWIMMERM_SETH` | `constants/event_flags.asm:958` | `maps/Route21.asm:12` | as above | + +**Items** + +None on this map. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERF, NIKKI` | SWIMMERF ($27) | 17 | `; SWIMMERF (17)` at `parties.asm:1872`: L28 SEEL, L28 SEEL, L28 SEEL, L28 DEWGONG (`TRAINERTYPE_NORMAL`) | `TrainerSwimmerfNikki` | no | +| `FISHER, ARNOLD` | FISHER ($25) | 3 | `; FISHER (3)` at `parties.asm:1511`: L34 TENTACRUEL | `TrainerFisherArnold` | no | +| `SWIMMERM, SETH` | SWIMMERM ($26) | 18 | `; SWIMMERM (18)` at `parties.asm:1754`: L29 QUAGSIRE, L29 OCTILLERY, L32 QUAGSIRE | `TrainerSwimmermSeth` | no | + +Payouts (base reward x last-mon level x 4, see section 3 for the citation): +Nikki 5 x 28 x 4 = **560**, Arnold 10 x 34 x 4 = **1360**, Seth 2 x 32 x 4 = **256**. +All three match the walkthrough exactly. +Base rewards: `data/trainers/attributes.asm:223` (Fisher, 10), `:229` (Swimmerm, 2), +`:235` (Swimmerf, 5). + +**Wild encounters** + +- Water (`data/wild/kanto_water.asm:61-66`), 6% rate: L35 TENTACOOL / L30 TENTACOOL / L35 TENTACRUEL. +- Grass (`data/wild/kanto_grass.asm:1005-1031`), 6/6/6% morn/day/nite. There *is* a + grass table, on the small land tiles: + - morn: 30 TANGELA, 25 TANGELA, 35 TANGELA, 20 TANGELA, 30 TANGELA, 28 MR__MIME, 28 MR__MIME + - day: 30 TANGELA, 25 TANGELA, 35 TANGELA, 20 TANGELA, 28 MR__MIME, 30 MR__MIME, 30 MR__MIME + - nite: 30 TANGELA, 25 TANGELA, 35 TANGELA, 20 TANGELA, 30 TANGELA, 28 MR__MIME, 28 MR__MIME +- Fishing: `FISHGROUP_OCEAN` (`data/wild/fish.asm:13`). Old rod `.Ocean_Old` (`:42`) + MAGIKARP/MAGIKARP/TENTACOOL L10; Good rod `.Ocean_Good` (`:46`) MAGIKARP L20 / + TENTACOOL L20 / CHINCHOU L20 / `time_group 2` = SHELLDER L20 day and nite + (`fish.asm:214`); Super rod `.Ocean_Super` (`:51`) CHINCHOU L40 / `time_group 3` = + SHELLDER L40 (`fish.asm:215`) / TENTACRUEL L40 / LANTURN L40. +- No headbutt tree rows and no rock smash on this map. + +--- + +### MAP_CINNABAR_ISLAND + +- Script: `maps/CinnabarIsland.asm` +- Blocks: `maps/CinnabarIsland.blk` +- Header: `data/maps/maps.asm:199` -> `TILESET_KANTO, TOWN, LANDMARK_CINNABAR_ISLAND, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:183` `map_const CINNABAR_ISLAND, 10, 9` (group `CINNABAR` = 6, map id 8). 20x18 tile coordinates. +- Attributes / border: `data/maps/attributes.asm:279` `map_attributes CinnabarIsland, CINNABAR_ISLAND, $43` +- Connections: north `ROUTE_21` (offset 0), east `ROUTE_20` (offset 0) +- Landmark: `data/maps/landmarks.asm:95` `landmark 52, 132, CinnabarIslandName` +- Fly / respawn point: `data/maps/flypoints.asm:28` `db LANDMARK_CINNABAR_ISLAND, SPAWN_CINNABAR`; + `data/maps/spawn_points.asm:24` `spawn CINNABAR_ISLAND, 11, 12` + +**Warps** (`def_warp_events`, `maps/CinnabarIsland.asm:131-132`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 11 | 11 | `CINNABAR_POKECENTER_1F` | 1 | + +**Coord events** (`def_coord_events`) + +None (`maps/CinnabarIsland.asm:134`). Blue is a talk-to NPC, not a trip-wire. + +**BG events** (`def_bg_events`, `maps/CinnabarIsland.asm:136-140`) + +| x | y | type | script/item | +|---|---|---|---| +| 12 | 11 | `BGEVENT_READ` | `CinnabarIslandPokecenterSign` -> `jumpstd PokecenterSignScript` | +| 9 | 11 | `BGEVENT_READ` | `CinnabarIslandGymSign` -> "CINNABAR GYM has relocated to SEAFOAM ISLANDS. BLAINE" | +| 7 | 7 | `BGEVENT_READ` | `CinnabarIslandSign` -> town sign | +| 9 | 1 | `BGEVENT_ITEM` | `CinnabarIslandHiddenRareCandy` -> `hiddenitem RARE_CANDY, EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY` (sym `4e:49a2`) | + +**Object events** (`def_object_events`, `maps/CinnabarIsland.asm:142-143`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `CINNABARISLAND_BLUE` | `SPRITE_BLUE` | 9 | 6 | `SPRITEMOVEDATA_SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | 0 | `CinnabarIslandBlue` | `EVENT_BLUE_IN_CINNABAR` | + +**Scripts of interest** + +- `CinnabarIslandFlypointCallback` (`maps/CinnabarIsland.asm:10`, sym `4e:4981`), wired + as `callback MAPCALLBACK_NEWMAP` at `:8`. Body is exactly + `setflag ENGINE_FLYPOINT_CINNABAR` / `endcallback`. Walking in once is the whole + unlock for flying here. +- `CinnabarIslandBlue` (`maps/CinnabarIsland.asm:14`, sym `4e:4985`). Control flow, in + order: `faceplayer`, `opentext`, `writetext CinnabarIslandBlueText`, `waitbutton`, + `closetext`, `playsound SFX_WARP_TO`, + `applymovement CINNABARISLAND_BLUE, CinnabarIslandBlueTeleport`, + `disappear CINNABARISLAND_BLUE`, `clearevent EVENT_VIRIDIAN_GYM_BLUE`, `end`. + - There is **no guard flag and no `iftrue`** at the top: the script is one-shot only + because `disappear` sets the object's own `EVENT_BLUE_IN_CINNABAR` (see + `Script_disappear` at `engine/overworld/scripting.asm:887`, `ld b, 1 ; set`). + - `CinnabarIslandBlueTeleport` (`:38`) is a two-byte movement stream: + `teleport_from` (`$4c`, `macros/scripts/movement.asm:148`) then `step_end`. That is + the "levitates and flies off" the walkthrough describes. + - `clearevent EVENT_VIRIDIAN_GYM_BLUE` is the load-bearing side effect: that flag is + set at boot by `InitializeEventsScript` (`engine/events/std_scripts.asm:552`) and it + hides both `VIRIDIANGYM_BLUE` and the Viridian gym guide + (`maps/ViridianGym.asm:183-184`). **Talking to Blue here is the only thing that + puts Blue in Viridian Gym.** +- `CinnabarIslandHiddenRareCandy` (`maps/CinnabarIsland.asm:35`) - a `hiddenitem` + operand, not a script body: `dwb EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY, RARE_CANDY`. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_CINNABAR` | `constants/engine_flags.asm:77` | set by `CinnabarIslandFlypointCallback` | Fly destination unlocked; also the wVisitedSpawns bit for respawn | +| `EVENT_BLUE_IN_CINNABAR` | `constants/event_flags.asm:1303` | *set* implicitly by `disappear CINNABARISLAND_BLUE`; read by `maps/ViridianCity.asm:37` and `maps/ViridianPokecenter1F.asm:18` for "Blue returned" dialogue | clear = Blue is standing on Cinnabar; set = you have heard the speech | +| `EVENT_VIRIDIAN_GYM_BLUE` | `constants/event_flags.asm:1304` | set at `engine/events/std_scripts.asm:552`; cleared by `CinnabarIslandBlue` | while set, Viridian Gym is empty | +| `EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY` | `constants/event_flags.asm:254` | the `hiddenitem` at `maps/CinnabarIsland.asm:36` | one-shot guard on the hidden candy | + +Note: `EVENT_BLUE_IN_CINNABAR` is **not** in the `InitializeEventsScript` list, so it +starts clear and Blue is visible from the first time you reach the island. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `RARE_CANDY` | face tile (9,1) and press A (or ITEMFINDER) | `bg_event 9, 1, BGEVENT_ITEM, CinnabarIslandHiddenRareCandy` | `EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY` | + +The walkthrough never mentions this candy. + +**Trainers** + +None. + +**Wild encounters** + +No `def_grass_wildmons`/`def_water_wildmons` row for `CINNABAR_ISLAND` in +`data/wild/kanto_grass.asm` or `data/wild/kanto_water.asm`. The surrounding water belongs +to Route 20 / Route 21. Fishing on the map's own water uses `FISHGROUP_OCEAN` +(`data/maps/maps.asm:199`). + +--- + +### MAP_CINNABAR_POKECENTER_1F + +- Script: `maps/CinnabarPokecenter1F.asm` +- Header: `data/maps/maps.asm:192` -> `TILESET_POKECENTER, INDOOR, LANDMARK_CINNABAR_ISLAND, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:176` `map_const CINNABAR_POKECENTER_1F, 5, 4` (group `CINNABAR` = 6, map id 1) +- Attributes: `data/maps/attributes.asm:510` `map_attributes CinnabarPokecenter1F, CINNABAR_POKECENTER_1F, $00`; no connections + +**Warps** (`def_warp_events`, `maps/CinnabarPokecenter1F.asm:38-41`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `CINNABAR_ISLAND` | 1 | +| 2 | 4 | 7 | `CINNABAR_ISLAND` | 1 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events / BG events** + +None (`maps/CinnabarPokecenter1F.asm:43`, `:45`). + +**Object events** (`maps/CinnabarPokecenter1F.asm:47-50`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `CINNABARPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CinnabarPokecenter1FNurseScript` -> `jumpstd PokecenterNurseScript` | -1 | +| `CINNABARPOKECENTER1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 7 | 6 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius x 2) | `OBJECTTYPE_SCRIPT` | `CinnabarPokecenter1FCooltrainerFScript` (BLAINE lives in the Seafoam cave) | -1 | +| `CINNABARPOKECENTER1F_FISHER` | `SPRITE_FISHER` | 2 | 4 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `CinnabarPokecenter1FFisherScript` ("It's been a year since the volcano erupted.") | -1 | + +Both flavour NPCs are unconditional `jumptextfaceplayer` - no flags, no branches. These +are the two people the walkthrough tells you to talk to. + +--- + +### MAP_ROUTE_20 + +- Script: `maps/Route20.asm` +- Blocks: `maps/Route20.blk` +- Header: `data/maps/maps.asm:197` -> `TILESET_KANTO, ROUTE, LANDMARK_ROUTE_20, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:181` `map_const ROUTE_20, 30, 9` (group `CINNABAR` = 6, map id 6). 60x18 tile coordinates. +- Attributes / border: `data/maps/attributes.asm:283` `map_attributes Route20, ROUTE_20, $43` +- Connections: west `CINNABAR_ISLAND` (offset 0), east `ROUTE_19` (offset -9) +- Landmarks in view: `data/maps/landmarks.asm:93` Route 20, `:94` `SeafoamIslandsName` + +**Warps** (`def_warp_events`, `maps/Route20.asm:116-117`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 38 | 7 | `SEAFOAM_GYM` | 1 | + +**Coord events** + +None (`maps/Route20.asm:119`). + +**BG events** (`maps/Route20.asm:121-122`) + +| x | y | type | script/item | +|---|---|---|---| +| 37 | 11 | `BGEVENT_READ` | `CinnabarGymSign` -> "CINNABAR GYM / LEADER: BLAINE" | + +**Object events** (`maps/Route20.asm:124-127`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `ROUTE20_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 52 | 8 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfNicole` | -1 | +| `ROUTE20_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 45 | 13 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmerfLori` | -1 | +| `ROUTE20_SWIMMER_GUY` | `SPRITE_SWIMMER_GUY` | 12 | 13 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` | 3 | `TrainerSwimmermCameron` | -1 | + +Cameron at x=12 is the only one west of the gym door at x=38, which is why the +walkthrough sees "one trainer" on the way in. Lori (45) and Nicole (52) are east of it. + +**Scripts of interest** + +- `Route20ClearRocksCallback` (`maps/Route20.asm:12`, sym `4e:4cfa`), wired as + `callback MAPCALLBACK_NEWMAP` at `:10`. Body is `setevent EVENT_CINNABAR_ROCKS_CLEARED` + / `endcallback`. It fires silently the first time the map loads. + Consumers: `Route19ClearRocksCallback` (`maps/Route19.asm:15`, sym `4e:4f09`, a + `MAPCALLBACK_TILES` callback) draws six `changeblock ... $7a ; rock` blocks at + (6,6), (8,6), (10,6), (12,8), (4,8), (10,10) **while the flag is clear**; the officer + in `maps/Route19FuchsiaGate.asm:12` and the two fishers in `maps/Route19.asm:74`/`:90` + swap dialogue on it. +- `TrainerSwimmermCameron` (`maps/Route20.asm:38`, sym `4e:4d26`) - plain trainer, + `EVENT_BEAT_SWIMMERM_CAMERON`, `endifjustbattled` + one line. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_CINNABAR_ROCKS_CLEARED` | `constants/event_flags.asm:214` | set by `Route20ClearRocksCallback`; read by `maps/Route19.asm:16`, `:74`, `:90` and `maps/Route19FuchsiaGate.asm:12` | while clear, six rock blocks seal the Route 19 water path from the Fuchsia side | +| `EVENT_BEAT_SWIMMERM_CAMERON` | `constants/event_flags.asm:957` | `maps/Route20.asm:39` | beat flag | +| `EVENT_BEAT_SWIMMERF_NICOLE` | `constants/event_flags.asm:487` | `maps/Route20.asm:17` | beat flag (next section) | +| `EVENT_BEAT_SWIMMERF_LORI` | `constants/event_flags.asm:488` | `maps/Route20.asm:28` | beat flag (next section) | + +**Items** + +None on this map. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERM, CAMERON` | SWIMMERM ($26) | 17 | `; SWIMMERM (17)` at `parties.asm:1749`: L34 MARILL | `TrainerSwimmermCameron` | no | +| `SWIMMERF, NICOLE` | SWIMMERF ($27) | 14 | `; SWIMMERF (14)` at `parties.asm:1854`: L29 MARILL, L29 MARILL, L32 LAPRAS | `TrainerSwimmerfNicole` | no | +| `SWIMMERF, LORI` | SWIMMERF ($27) | 15 | `; SWIMMERF (15)` at `parties.asm:1861`: L32 STARMIE, L32 STARMIE | `TrainerSwimmerfLori` | no | + +Cameron pays 2 x 34 x 4 = **272**, matching the walkthrough. + +**Wild encounters** + +- Water (`data/wild/kanto_water.asm:54-59`), 6% rate: L35 TENTACOOL / L30 TENTACOOL / L35 TENTACRUEL. +- No `def_grass_wildmons ROUTE_20` row exists in `data/wild/kanto_grass.asm` - there is + no land grass on this route. +- Fishing: `FISHGROUP_OCEAN`, same three rod tables as Route 21 above. + +--- + +### MAP_SEAFOAM_GYM + +- Script: `maps/SeafoamGym.asm` +- Blocks: `maps/SeafoamGym.blk` +- Header: `data/maps/maps.asm:195` -> `TILESET_CAVE, INDOOR, LANDMARK_SEAFOAM_ISLANDS, MUSIC_GYM, TRUE (phone calls blocked), PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:179` `map_const SEAFOAM_GYM, 5, 4` (group `CINNABAR` = 6, map id 4). 10x8 tile coordinates - smaller than the 20x18 screen, which is the walkthrough's "this gym doesn't even fit the entire screen". +- Attributes / border: `data/maps/attributes.asm:513` `map_attributes SeafoamGym, SEAFOAM_GYM, $09`; no connections +- There is **no** `MAP_SEAFOAM_ISLANDS` dungeon in Gen 2 - `constants/map_constants.asm` + has only `SEAFOAM_GYM` in the whole Seafoam family. + +**Warps** (`def_warp_events`, `maps/SeafoamGym.asm:161-162`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `ROUTE_20` | 1 | + +**Coord events** + +None (`maps/SeafoamGym.asm:164`). The map does declare one scene script, +`scene_script SeafoamGymNoopScene` (`:7`), and the asm itself annotates it +`; unusable` - the body at `:11` is a bare `end`. + +**BG events** + +None (`maps/SeafoamGym.asm:166`). + +**Object events** (`maps/SeafoamGym.asm:168-170`) + +| const | sprite | x | y | movement | type | sight | script label | event flag | +|---|---|---|---|---|---|---|---|---| +| `SEAFOAMGYM_BLAINE` | `SPRITE_BLAINE` | 5 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | 0 | `SeafoamGymBlaineScript` | -1 (always present) | +| `SEAFOAMGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 6 | 5 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT` | 0 | `SeafoamGymGuideScript` | `EVENT_SEAFOAM_GYM_GYM_GUIDE` | + +**Scripts of interest** + +- `SeafoamGymBlaineScript` (`maps/SeafoamGym.asm:14`, sym `53:516d`). Control flow: + 1. `faceplayer`, `opentext` + 2. `checkflag ENGINE_VOLCANOBADGE` / `iftrue .FightDone` - the entire re-talk guard is + the badge engine flag, **not** an event flag. + 3. `writetext BlaineIntroText`, `waitbutton`, `closetext` + 4. `winlosstext BlaineWinLossText, 0` (no loss text) + 5. `loadtrainer BLAINE, BLAINE1`, `startbattle` + 6. `iftrue .ReturnAfterBattle` - i.e. if the battle result is non-zero (you lost / + drew) it *skips* `appear SEAFOAMGYM_GYM_GUIDE` + 7. `appear SEAFOAMGYM_GYM_GUIDE` - clears `EVENT_SEAFOAM_GYM_GYM_GUIDE`, which is how + the guide "comes in late" + 8. `.ReturnAfterBattle`: `reloadmapafterbattle`, `setevent EVENT_BEAT_BLAINE`, + `opentext`, `writetext ReceivedVolcanoBadgeText`, `playsound SFX_GET_BADGE`, + `waitsfx`, **`setflag ENGINE_VOLCANOBADGE`**, `writetext BlaineAfterBattleText`, + `waitbutton`, `closetext`, `end` + - No `verbosegiveitem` anywhere: **Blaine gives no TM**, only the badge. + - Note the ordering quirk: `setevent EVENT_BEAT_BLAINE` / `setflag ENGINE_VOLCANOBADGE` + are on the shared `.ReturnAfterBattle` path, reached whether or not you won, but + `startbattle` does not return to the script on a whiteout, so in practice this is + only reached after a win. +- `SeafoamGymGuideScript` (`maps/SeafoamGym.asm:46`, sym `53:51a3`) - branches on + `EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE`, first line is the apology + ("CINNABAR GYM was gone, so I didn't know where to find you"), then a second line + forever after. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_VOLCANOBADGE` | `constants/engine_flags.asm:53` (Kanto badge 7; `wKantoBadges` bit 6 per `constants/ram_constants.asm:270`) | checked at `maps/SeafoamGym.asm:17`, set at `:34` | the badge itself; also gates the re-talk branch | +| `EVENT_BEAT_BLAINE` | `constants/event_flags.asm:721` | set at `maps/SeafoamGym.asm:29` | not read by any other map in the checkout; it is the record, not the gate | +| `EVENT_SEAFOAM_GYM_GYM_GUIDE` | `constants/event_flags.asm:1305` | set at boot (`engine/events/std_scripts.asm:553`), cleared by `appear` at `maps/SeafoamGym.asm:26` | while set, the guide object does not exist | +| `EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE` | `constants/event_flags.asm:212` | `maps/SeafoamGym.asm:49`, `:54` | first-talk vs repeat text | + +What VOLCANOBADGE actually does mechanically: `data/types/badge_type_boosts.asm:19` +`db FIRE ; VOLCANOBADGE`, consumed by `DoBadgeTypeBoosts` (`engine/battle/misc.asm:146`, +called from `engine/battle/effect_commands.asm:1261`) - a +12.5% damage boost on the +player's FIRE-type moves. It grants no field move. + +**Items** + +None. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm`) | script label | rematch/phone | +|---|---|---|---|---|---| +| `BLAINE, BLAINE1` | BLAINE ($2e), `constants/trainer_constants.asm:469-470` | 1 | `BlaineGroup` (sym `0e:6e70`) at `parties.asm:2303`, `TRAINERTYPE_MOVES`: **L45 MAGCARGO** (CURSE, SMOG, FLAMETHROWER, ROCK_SLIDE), **L45 MAGMAR** (THUNDERPUNCH, FIRE_PUNCH, SUNNY_DAY, CONFUSE_RAY), **L50 RAPIDASH** (QUICK_ATTACK, FIRE_SPIN, FURY_ATTACK, FIRE_BLAST) | `SeafoamGymBlaineScript` | no rematch, no phone | + +Class attributes (`data/trainers/attributes.asm:275-279`): AI items are +**`MAX_POTION, FULL_HEAL`** (not two Full Restores); base reward 25; AI mask +`AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | AI_CAUTIOUS | AI_STATUS | AI_RISKY`, +`CONTEXT_USE | SWITCH_SOMETIMES`. Payout 25 x 50 x 4 = **5000**, matching the walkthrough. +`BLAINE` is in `KantoGymLeaders` (`data/trainers/leaders.asm`), so the battle uses the +gym-leader music and awards `HAPPINESS_GYMBATTLE`. + +**Wild encounters** + +None. The map is `INDOOR` with `TILESET_CAVE`; its `FISHGROUP_SHORE` header entry is the +default and has no reachable water. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Everything in this section is across open sea (Pallet -> Route 21 -> Cinnabar -> Route 20 -> gym door) | `engine/events/overworld.asm:469` `TrySurfOW` (walk-into-water path): `ld de, ENGINE_FOGBADGE` / `CheckEngineFlag` at `:490`, then `ld d, SURF` / `CheckPartyMove`. Menu path is `SurfFunction` `.TrySurf` at `:340` with the same `ENGINE_FOGBADGE`. `CheckBadge` itself is at `:50`. | FOGBADGE + a party mon that knows SURF | already held long before Kanto (Johto badge 4) | +| Fly to Cinnabar Island | `data/maps/flypoints.asm:28`; the flag is `ENGINE_FLYPOINT_CINNABAR`, set by `CinnabarIslandFlypointCallback` (`maps/CinnabarIsland.asm:10`) | STORMBADGE + FLY + having stood on Cinnabar Island once | walk in once | +| Blue is not in Viridian Gym | `maps/ViridianGym.asm:183-184` object rows carry `EVENT_VIRIDIAN_GYM_BLUE`, set at `engine/events/std_scripts.asm:552` | - | `clearevent EVENT_VIRIDIAN_GYM_BLUE` at the tail of `CinnabarIslandBlue` (`maps/CinnabarIsland.asm:23`) - **this section is a hard prerequisite for the Viridian Gym leader** | +| Route 19 (east of Route 20) is sealed by six boulder blocks | `maps/Route19.asm:15` `Route19ClearRocksCallback`, a `MAPCALLBACK_TILES` callback that lays `changeblock` rock blocks while `EVENT_CINNABAR_ROCKS_CLEARED` is clear | - | `setevent EVENT_CINNABAR_ROCKS_CLEARED` in `Route20ClearRocksCallback` (`maps/Route20.asm:13`), i.e. simply loading Route 20 once from the Cinnabar side | +| Blaine will not re-battle | `maps/SeafoamGym.asm:17` `checkflag ENGINE_VOLCANOBADGE` / `iftrue .FightDone` | - | none in vanilla; `.FightDone` is terminal | +| The gym guide does not exist until Blaine is beaten | object row event flag `EVENT_SEAFOAM_GYM_GYM_GUIDE` (`maps/SeafoamGym.asm:170`), set at `engine/events/std_scripts.asm:553` | beat Blaine | `appear SEAFOAMGYM_GYM_GUIDE` (`maps/SeafoamGym.asm:26`), only on the win branch | + +Nothing in this section needs CUT, STRENGTH, WHIRLPOOL, WATERFALL, FLASH or a key item. + +## 4. Bot checklist + +Ordered, machine-actionable. `(g,m)` is `(map group, map id)` from +`constants/map_constants.asm`. All coordinates are the raw asm map-event values. + +1. `PALLET_TOWN`: SURF south across the connection into `ROUTE_21 (6,7)`. + Pre: FOGBADGE + SURF in the party. Post: none. +2. `ROUTE_21`: battle `TrainerSwimmerfNikki` at (11,16) - sight radius 3, so she will + trigger on approach. Party L28 SEEL x3 + L28 DEWGONG. Post: `EVENT_BEAT_SWIMMERF_NIKKI`, +560 money. +3. `ROUTE_21`: battle `TrainerFisherArnold` at (14,22) - sight radius 1, faces up. + Party L34 TENTACRUEL. Post: `EVENT_BEAT_FISHER_ARNOLD`, +1360 money. +4. `ROUTE_21`: surf west then south to `TrainerSwimmermSeth` at (2,30) - sight radius 4, + faces right. Party L29 QUAGSIRE / L29 OCTILLERY / L32 QUAGSIRE. + Post: `EVENT_BEAT_SWIMMERM_SETH`, +256 money. + (Both trainers are avoidable - all three are `OBJECTTYPE_TRAINER` with a finite sight + radius, none are on a coord event.) +5. `ROUTE_21`: continue south across the connection into `CINNABAR_ISLAND (6,8)`. + Post: `MAPCALLBACK_NEWMAP` sets `ENGINE_FLYPOINT_CINNABAR`; respawn point becomes + `SPAWN_CINNABAR` = (11,12). +6. `CINNABAR_ISLAND`: step on warp 1 at (11,11) -> `CINNABAR_POKECENTER_1F (6,1)`. + Talk to `CINNABARPOKECENTER1F_NURSE` at (3,1) to heal. Optional: the two flavour NPCs + at (7,6) and (2,4). Exit via warp 1/2 at (3,7)/(4,7). +7. `CINNABAR_ISLAND`: optional - face (9,1) and press A for the hidden **RARE_CANDY**. + Pre: `EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY` clear + a free bag slot. + Post: flag set. (Not in the walkthrough.) +8. `CINNABAR_ISLAND`: walk to `CINNABARISLAND_BLUE` at (9,6) and talk. + Pre: `EVENT_BLUE_IN_CINNABAR` clear. Expect a long `writetext` (about 13 pages of + `para` breaks - budget A presses accordingly), then `SFX_WARP_TO` + a `teleport_from` + movement, then the object vanishes. + Post: `EVENT_BLUE_IN_CINNABAR` set (via `disappear`), `EVENT_VIRIDIAN_GYM_BLUE` + **cleared**. Do not skip this step: it is the only unlock for the Viridian Gym leader. +9. `CINNABAR_ISLAND`: optional signs at (12,11), (9,11), (7,7). +10. `CINNABAR_ISLAND`: SURF east across the connection into `ROUTE_20 (6,6)`. + Post: `MAPCALLBACK_NEWMAP` sets `EVENT_CINNABAR_ROCKS_CLEARED` (silent; unseals + Route 19 for the next section). +11. `ROUTE_20`: battle `TrainerSwimmermCameron` at (12,13) - sight radius 3. + Party L34 MARILL. Post: `EVENT_BEAT_SWIMMERM_CAMERON`, +272 money. +12. `ROUTE_20`: optional sign at (37,11). Then step onto warp 1 at (38,7) -> + `SEAFOAM_GYM (6,4)`. +13. `SEAFOAM_GYM`: walk to `SEAFOAMGYM_BLAINE` at (5,2) and talk. + Pre: `ENGINE_VOLCANOBADGE` clear (otherwise you get `.FightDone` only). + Battle: `loadtrainer BLAINE, BLAINE1` - L45 MAGCARGO / L45 MAGMAR / L50 RAPIDASH, + AI holds MAX_POTION + FULL_HEAL. + Post: `EVENT_BEAT_BLAINE` set, `EVENT_SEAFOAM_GYM_GYM_GUIDE` cleared (guide appears + at (6,5)), **`ENGINE_VOLCANOBADGE` set**, +5000 money. No TM, no item. +14. `SEAFOAM_GYM`: optional - talk to `SEAFOAMGYM_GYM_GUIDE` at (6,5). + Post: `EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE`. +15. `SEAFOAM_GYM`: exit via warp 1 at (5,5) back to `ROUTE_20` warp 1. + +Follow-up (next section): Route 20 continues east past Lori (45,13) and Nicole (52,8) +into `ROUTE_19`, whose rock blocks are now gone because step 10 set +`EVENT_CINNABAR_ROCKS_CLEARED`. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Kanto map headers, dimensions, connections, warp / coord / bg / object tables | `src/import/RomExtractorGen2.lua` (all 26 map groups, `MAP_GROUP_COUNT = 26` at `:47`, `MapGroupPointers` walk at `:728`) | implemented - fully data-driven, nothing Cinnabar-specific needed | +| `MAPCALLBACK_NEWMAP` (`CinnabarIslandFlypointCallback`, `Route20ClearRocksCallback`) | `src/world/gen2/World.lua:5659` `self:runMapCallback("MAPCALLBACK_NEWMAP")`, in `HandleNewMap` order (`:5646`) | implemented; asserted end-to-end by `tests/drivers/gold_map_callbacks.lua` | +| `MAPCALLBACK_TILES` + `changeblock` (the Route 19 rocks that this section unseals) | `src/world/gen2/World.lua:5662`; `changeblock` opcode at `src/script/gen2/Opcodes.lua:127` | implemented | +| `setflag ENGINE_FLYPOINT_CINNABAR` -> flyable | `src/world/gen2/FieldMoves.lua:367` (`LANDMARK_CINNABAR_ISLAND` / `SPAWN_CINNABAR` / flag 62), `World:setEngineFlag` at `src/world/gen2/World.lua:1326` | implemented | +| `checkevent` / `setevent` / `clearevent`, `checkflag` / `setflag` | `src/script/gen2/Vm.lua:187-229` | implemented - id-keyed stores, `engineFlags` kept separate from `wEventFlags` | +| `appear` / `disappear` writing the object's event flag (Blue vanishing, the guide arriving) | `src/script/gen2/Opcodes.lua:115-116`, handled in `src/script/gen2/Vm.lua` | implemented | +| Hidden item `BGEVENT_ITEM` (Cinnabar RARE_CANDY at 9,1) | `src/world/gen2/HiddenItems.lua` (`BGEVENT_ITEM = 7` at `:22`), extractor branch at `src/import/RomExtractorGen2.lua:2986` | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `reloadmapafterbattle` / `endifjustbattled` (the whole Blaine script) | `src/script/gen2/Vm.lua:806`, `:817`, `:886`, `:918` | implemented, incl. the `iftrue` on `wBattleResult` that guards `appear SEAFOAMGYM_GYM_GUIDE` | +| Gym-leader battle music / victory music for BLAINE | `src/battle/gen2/BattleMusic.lua:21`, `src/battle/gen2/Battle.lua:82` (both list `BLAINE = true`) | implemented | +| Prize money = base x last-mon level x 4 (560 / 1360 / 256 / 272 / 5000) | `src/battle/gen2/Prize.lua` (header documents `ComputeTrainerReward` and the `ld c, 4` loop; `Prize.reward`) | implemented, incl. the Mom's-savings split | +| Trainer sight radii (3 / 4 / 1 on Route 21) | `src/world/gen2/Trainers.lua` | implemented | +| SURF gate on FOGBADGE + party move | `src/world/gen2/FieldMoves.lua:106` (`SURF = "FOG"`), `:481` `surfFromMenu`, `:678` overworld path | implemented; driven by `tests/drivers/gold_water_moves.lua` | +| Ocean wild/fish tables for Routes 20/21 | extracted by `src/import/RomExtractorGen2.lua`, consumed by `src/battle/gen2/Encounter.lua` | implemented - correct by construction from the ROM | +| Radio "Place & Name" un-hiding BLAINE after the Kanto badge sweep | `src/ui/gen2/Pokegear.lua:172` (`PNP_HIDDEN`), `:178` `PNP_HIDDEN_BEAT_KANTO`, `:1265` | implemented | +| `setflag ENGINE_VOLCANOBADGE` reaching the trainer card / `VAR_BADGES` count | `src/script/gen2/Vm.lua:208-229` writes `save.engineFlags[53]`; but `VAR_BADGES` (`src/world/gen2/World.lua:1243-1244`) counts `save.player.badges` + `save.player.kantoBadges`, and `src/ui/gen2/TrainerCard.lua:395` reads `player.badges`. Nothing in `src/` ever writes `player.kantoBadges` (only initialised at `src/core/gen2/Save.lua:119`). | **partial** - Blaine's own re-talk guard works (same `engineFlags` id is read back by `checkflag`), but the badge does not show on the trainer card and does not increment `VAR_BADGES` | +| `BadgeTypeBoosts` (VOLCANOBADGE = +12.5% FIRE damage) | no Gen 2 implementation - `grep -rn "badge" src/battle/gen2/` returns nothing; the only badge-boost code is the Gen 1 `src/battle/Damage.lua:35` | **missing** | +| `teleport_from` (`$4c`) movement for Blue's exit | `src/script/gen2/Movement.lua:22` `decodeByte` - `$4c` matches no family branch and falls through to `{ kind = "nop" }` | **partial** - Blue disappears with the sound but without the rise-and-vanish animation | +| End-to-end driver coverage for this stretch | `tests/drivers/gold_*.lua` (25 drivers, all Johto: boot, walk, warp, trainer, ice path, roamers, ...) | **missing** - no Cinnabar / Route 20 / Route 21 / Seafoam Gym driver exists | + +## 6. Unresolved / verify by hand + +- **Blaine's movesets in the walkthrough are not the pokegold movesets.** The FAQ + describes Magcargo with Yawn / Recover / Overheat, Magmar with Confuse Ray, and + Rapidash with Flare Blitz / Bounce. `data/trainers/parties.asm:2303-2308` gives + MAGCARGO (CURSE, SMOG, FLAMETHROWER, ROCK_SLIDE), MAGMAR (THUNDERPUNCH, FIRE_PUNCH, + SUNNY_DAY, CONFUSE_RAY) and RAPIDASH (QUICK_ATTACK, FIRE_SPIN, FURY_ATTACK, + FIRE_BLAST). Only CONFUSE_RAY overlaps; Overheat, Flare Blitz, Bounce and Yawn do not + exist in `constants/move_constants.asm` at all for this generation. **Species and + levels match exactly** (L45 / L45 / L50), so treat the strategy prose as written + against a different (modernised) build and the asm as authoritative. +- **"Blaine will have two Full Restores available."** `data/trainers/attributes.asm:276` + is `db MAX_POTION, FULL_HEAL`. Two different items, neither of them a Full Restore. + The count of *uses* is AI-driven, not a fixed two. +- **"You now have 15 badges!"** Nothing in the asm counts badges as an ordinal; Kanto + gyms have no enforced order (`ENGINE_VOLCANOBADGE` is one bit, + `constants/engine_flags.asm:53`). The 15 is walkthrough prose that assumes a specific + visiting order. +- **Route 21 wild list is abridged in the walkthrough.** The FAQ lists Tentacool and + Tangela. `data/wild/kanto_grass.asm:1005` also has **MR__MIME** in all three + time slots, and `data/wild/kanto_water.asm:61` also has **TENTACRUEL**. Route 20's + list is likewise incomplete (TENTACRUEL is there too). +- **"Surf south on Route 21 ... head way left then down."** The trainer coordinates + confirm the shape of the route (16 -> 22 -> 30, then far west), but the actual walkable + water path is block data in `maps/Route21.blk` and was not decoded here. +- **"You should see a small cave at the Seafoam Islands."** The only asm anchor is + `warp_event 38, 7` on `maps/Route20.asm:117` plus `bg_event 37, 11` for the gym sign. + The cave mouth graphic itself is block data in `maps/Route20.blk`; there is no + Seafoam Islands dungeon map in Gen 2 (`constants/map_constants.asm` has only + `SEAFOAM_GYM`), so the walkthrough's "Seafoam Islands" is a landmark name + (`data/maps/landmarks.asm:94` `LANDMARK_SEAFOAM_ISLANDS`) rather than a walkable + dungeon. +- **EXP figures.** The per-mon EXP numbers in the FAQ (600 for a L28 Seel, 1056 for a + L28 Dewgong, 1492 for a L34 Tentacruel) are consistent with the vanilla + `base_exp * level / 7` times the 1.5x trainer-battle bonus, but nothing in + `data/trainers/parties.asm` stores them and they still vary with participation, so + they are not reproducible from the asm as written. The **money** figures all reproduce + exactly and are recorded above. +- **`EVENT_BEAT_BLAINE` has no reader.** Grepping `maps/`, `data/`, `engine/`, `home/` + and `constants/` finds it only at its definition (`constants/event_flags.asm:721`) and + its single `setevent` (`maps/SeafoamGym.asm:29`). If the port needs a "Blaine beaten" + predicate for anything, `ENGINE_VOLCANOBADGE` is the flag the cart actually branches on. diff --git a/docs/gold-walkthrough/asm-walk/section-28-routes-19-20-and-viridian-city-gym.md b/docs/gold-walkthrough/asm-walk/section-28-routes-19-20-and-viridian-city-gym.md new file mode 100644 index 00000000..f7f5e4bf --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-28-routes-19-20-and-viridian-city-gym.md @@ -0,0 +1,460 @@ +# Section 28 - Routes 19-20 and Viridian City Gym + +Source: `../section-28-routes-19-20-and-viridian-city-gym.txt` (the FAQ numbers this chapter "34 > Routes 19-20 and Viridian City Gym") +Maps covered: `MAP_ROUTE_20`, `MAP_ROUTE_19`, `MAP_ROUTE_19_FUCHSIA_GATE`, `MAP_VIRIDIAN_CITY`, `MAP_VIRIDIAN_POKECENTER_1F`, `MAP_VIRIDIAN_GYM` +Badges / key milestones in this section: EARTHBADGE (`ENGINE_EARTHBADGE`) from Blue in Viridian Gym, `EVENT_BEAT_BLUE`, five optional Swimmer battles on Routes 19/20. This is the 16th badge, which is what `maps/OaksLab.asm` reads to open Mt. Silver in a later section. + +Map group / id pairs a bot needs (from `constants/map_constants.asm`): + +| Map constant | group | id | +|---|---|---| +| `MAP_ROUTE_19_FUCHSIA_GATE` | 6 (`CINNABAR`, `constants/map_constants.asm:175`) | 3 | +| `MAP_ROUTE_19` | 6 | 5 | +| `MAP_ROUTE_20` | 6 | 6 | +| `MAP_VIRIDIAN_CITY` | 23 (`VIRIDIAN`, `constants/map_constants.asm:420`) | 3 | +| `MAP_VIRIDIAN_GYM` | 23 | 4 | +| `MAP_VIRIDIAN_POKECENTER_1F` | 23 | 9 | + +Coordinate convention reminder: `warp_event` / `coord_event` / `bg_event` / `object_event` all take `x, y` in map cell coordinates, 0-based from the top-left (`macros/scripts/maps.asm:63`, `:79`, `:97`, `:113`). A map that is `W, H` blocks is `2W, 2H` cells. `changeblock` also takes cell coordinates and halves them internally (`engine/overworld/scripting.asm:2030` `Script_changeblock`, `home/map.asm:2099` `GetBlockLocation`). + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_SEAFOAM_GYM` | `maps/SeafoamGym.asm` (previous section) | - | its exit warp back to Route 20 warp 1 | Blaine's gym sits on Route 20; the previous section ends there | +| 1 | `MAP_ROUTE_20` | `maps/Route20.asm` | Seafoam Gym exit -> Route 20 warp 1 `(38, 7)`, or the west connection from Cinnabar Island | east map connection to Route 19 (`data/maps/attributes.asm:285`, offset -9) | "East on Route 20 and to Route 19 are optional ... Surf down and right to face the swimmer in the water" (Lori, then Nicole) | +| 2 | `MAP_ROUTE_19` | `maps/Route19.asm` | west map connection from Route 20 (`data/maps/attributes.asm:289`, offset 9) | Fly (walkthrough), or warp 1 `(7, 3)` -> `ROUTE_19_FUCHSIA_GATE` warp 3, or the north connection to Fuchsia City | "You'll find a drowning guy next to a girl" (Tucker, Dawn, then Jerome). "Fly back to Viridian City instead" | +| 2a | `MAP_ROUTE_19_FUCHSIA_GATE` | `maps/Route19FuchsiaGate.asm` | Route 19 warp 1 | warps 1/2 `(4, 0)` / `(5, 0)` -> `FUCHSIA_CITY` warps 10 / 11 | Only referenced by the walkthrough's "blocked ... south of Fuchsia City" line; see section 6 | +| 3 | `MAP_VIRIDIAN_CITY` | `maps/ViridianCity.asm` | Fly (`ENGINE_FLYPOINT_VIRIDIAN`, spawn `(23, 26)`) | warp 5 `(23, 25)` -> Pokecenter; warp 1 `(32, 7)` -> gym | "fly back to Viridian City ... First, heal at the Pokémon Center" | +| 4 | `MAP_VIRIDIAN_POKECENTER_1F` | `maps/ViridianPokecenter1F.asm` | Viridian warp 5 | warps 1/2 `(3, 7)` / `(4, 7)` -> Viridian warp 5 | Heal before and after Blue | +| 5 | `MAP_VIRIDIAN_GYM` | `maps/ViridianGym.asm` | Viridian warp 1 `(32, 7)` | warps 1/2 `(4, 17)` / `(5, 17)` -> Viridian warp 1 | "GYM LEADER BLUE ... You get: Earth Badge, 5,800G" | + +Spillover: the closing line "There are still a few things you can do before you clear Kanto" points at `maps/OaksLab.asm:26` (`readvar VAR_BADGES` / `ifequal NUM_BADGES, .OpenMtSilver`), which belongs to the next section. Nothing past the Viridian Gym exit is described here. + +## 2. Maps + +### MAP_ROUTE_20 + +- Script: `maps/Route20.asm` +- Blocks: `maps/Route20.blk` (`data/maps/blocks.asm:118` `Route20_Blocks`) +- Header: `data/maps/maps.asm:197` -> `TILESET_KANTO`, `ROUTE`, `LANDMARK_ROUTE_20`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_OCEAN` +- Dimensions: `constants/map_constants.asm:181` `map_const ROUTE_20, 30, 9` (30x9 blocks = 60x18 cells) +- Connections: `data/maps/attributes.asm:283-285` - west `CinnabarIsland` (`CINNABAR_ISLAND`, offset 0), east `Route19` (`ROUTE_19`, offset -9). No north/south. +- Map events block: `Route20_MapEvents`, sym `4e:4ecd` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 38 | 7 | `SEAFOAM_GYM` | 1 | + +**Coord events** (`def_coord_events`) - none (`def_coord_events` is empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 37 | 11 | `BGEVENT_READ` | `CinnabarGymSign` (sym `4e:4d3a`) -> `CinnabarGymSignText` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE20_SWIMMER_GIRL1` | `SPRITE_SWIMMER_GIRL` | 52 | 8 | `SPRITEMOVEDATA_SPINRANDOM_FAST` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight 3, `PAL_NPC_GREEN` | `TrainerSwimmerfNicole` (sym `4e:4cfe`) | -1 (always present) | +| `ROUTE20_SWIMMER_GIRL2` | `SPRITE_SWIMMER_GIRL` | 45 | 13 | `SPRITEMOVEDATA_SPINRANDOM_FAST` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight 3, `PAL_NPC_GREEN` | `TrainerSwimmerfLori` (sym `4e:4d12`) | -1 | +| `ROUTE20_SWIMMER_GUY` | `SPRITE_SWIMMER_GUY` | 12 | 13 | `SPRITEMOVEDATA_SPINRANDOM_FAST` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight 3, `PAL_NPC_RED` | `TrainerSwimmermCameron` (sym `4e:4d26`) | -1 | + +**Scripts of interest** + +- `Route20_MapScripts` (`maps/Route20.asm:6`): no scene scripts; one callback, `callback MAPCALLBACK_NEWMAP, Route20ClearRocksCallback`. +- `Route20ClearRocksCallback` (`maps/Route20.asm:12`, sym `4e:4cfa`): two opcodes, `setevent EVENT_CINNABAR_ROCKS_CLEARED` then `endcallback`. This is the single most load-bearing fact on this map: merely *entering* Route 20 for the first time permanently clears the Route 19 boulders. There is no other setter of that flag anywhere in `maps/`. +- The three trainer scripts are the stock pattern: `trainer <CLASS>, <ID>, <EVENT>, SeenText, BeatenText, 0, .Script`, and `.Script` is `endifjustbattled / opentext / writetext <AfterBattleText> / waitbutton / closetext / end`. No items, no flags beyond the trainer's own beat flag. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_CINNABAR_ROCKS_CLEARED` | `constants/event_flags.asm:214` | set by `Route20ClearRocksCallback`; read by `Route19ClearRocksCallback`, `Route19Fisher1Script`, `Route19Fisher2Script`, `Route19FuchsiaGateOfficerScript` | Once set, the six Route 19 boulder blocks are never placed | +| `EVENT_BEAT_SWIMMERF_NICOLE` | `constants/event_flags.asm:487` | `TrainerSwimmerfNicole` | One-time battle | +| `EVENT_BEAT_SWIMMERF_LORI` | `constants/event_flags.asm:488` | `TrainerSwimmerfLori` | One-time battle | +| `EVENT_BEAT_SWIMMERM_CAMERON` | `constants/event_flags.asm:957` | `TrainerSwimmermCameron` | One-time battle; the walkthrough never mentions him | + +**Items** - none on this map (the only `bg_event` is a readable sign; no `hiddenitem`, no item balls). + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERF` / `LORI` | 27 (`constants/trainer_constants.asm:375`) | LORI = `SwimmerFGroup` entry 15 | `data/trainers/parties.asm:1781` `SwimmerFGroup` (sym `0e:69c7`), `TRAINERTYPE_NORMAL`: L32 STARMIE, L32 STARMIE | `TrainerSwimmerfLori` | none (no `phone_call` / rematch entry) | +| `SWIMMERF` / `NICOLE` | 27 | NICOLE = `SwimmerFGroup` entry 14 | `TRAINERTYPE_NORMAL`: L29 MARILL, L29 MARILL, L32 LAPRAS | `TrainerSwimmerfNicole` | none | +| `SWIMMERM` / `CAMERON` | 26 (`constants/trainer_constants.asm:352`) | CAMERON = `SwimmerMGroup` entry 17 | `data/trainers/parties.asm:1640` `SwimmerMGroup` (sym `0e:68a8`), `TRAINERTYPE_NORMAL`: L34 MARILL | `TrainerSwimmermCameron` | none | + +Prize money: `data/trainers/attributes.asm:227` gives class Swimmerm base reward 2, `:233` gives Swimmerf base reward 5, no held items. `engine/battle/read_trainer_party.asm:300` `ComputeTrainerReward` = base x level of the last mon, and `engine/battle/core.asm:2340-2362` pays that quarter out four times, so the wallet figure is `4 x base x level`: Lori and Nicole `4 x 5 x 32 = 640` (matches the FAQ), Cameron `4 x 2 x 34 = 272`. + +**Wild encounters** + +- Surfing: `data/wild/kanto_water.asm:54` `def_water_wildmons ROUTE_20`, encounter rate `6 percent`: L35 TENTACOOL, L30 TENTACOOL, L35 TENTACRUEL. No morn/day/nite split (water tables have a single slot list). +- No entry in `data/wild/kanto_grass.asm` (the map has no grass), none in `data/wild/treemons.asm`. +- Fishing: header fish group is `FISHGROUP_OCEAN` (`data/maps/maps.asm:197`), `data/wild/fish.asm` `.Ocean_Old` / `.Ocean_Good` / `.Ocean_Super` (lines 42-55): Old = MAGIKARP/MAGIKARP/TENTACOOL L10; Good = MAGIKARP L20, TENTACOOL L20, CHINCHOU L20, time group 2; Super = CHINCHOU L40, time group 3, TENTACRUEL L40, LANTURN L40. + +### MAP_ROUTE_19 + +- Script: `maps/Route19.asm` +- Blocks: `maps/Route19.blk` (`data/maps/blocks.asm:48` `Route19_Blocks`) +- Header: `data/maps/maps.asm:196` -> `TILESET_KANTO`, `ROUTE`, `LANDMARK_ROUTE_19`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:180` `map_const ROUTE_19, 10, 18` (10x18 blocks = 20x36 cells) +- Connections: `data/maps/attributes.asm:287-289` - north `FuchsiaCity` (`FUCHSIA_CITY`, offset 0), west `Route20` (`ROUTE_20`, offset 9). No south/east. +- Map events block: `Route19_MapEvents`, sym `4e:535c` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 3 | `ROUTE_19_FUCHSIA_GATE` | 3 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 11 | 13 | `BGEVENT_READ` | `Route19Sign` (sym `4e:4fa0`) -> `Route19SignText` | +| 11 | 1 | `BGEVENT_READ` | `CarefulSwimmingSign` (sym `4e:4fa3`) -> `CarefulSwimmingSignText` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE19_SWIMMER_GIRL` | `SPRITE_SWIMMER_GIRL` | 9 | 23 | `SPRITEMOVEDATA_STANDING_LEFT` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight **0**, `PAL_NPC_GREEN` | `TrainerSwimmerfDawn` (sym `4e:4f28`) | -1 | +| `ROUTE19_SWIMMER_GUY1` | `SPRITE_SWIMMER_GUY` | 13 | 28 | `SPRITEMOVEDATA_SPINRANDOM_FAST` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight 3, `PAL_NPC_RED` | `TrainerSwimmermHarold` (sym `4e:4f3c`) | -1 | +| `ROUTE19_SWIMMER_GUY2` | `SPRITE_SWIMMER_GUY` | 11 | 17 | `SPRITEMOVEDATA_SPINRANDOM_FAST` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight 3, `PAL_NPC_RED` | `TrainerSwimmermJerome` (sym `4e:4f50`) | -1 | +| `ROUTE19_SWIMMER_GUY3` | `SPRITE_SWIMMER_GUY` | 8 | 23 | `SPRITEMOVEDATA_STANDING_UP` (radius 0,0) | `OBJECTTYPE_TRAINER`, sight **0**, `PAL_NPC_RED` | `TrainerSwimmermTucker` (sym `4e:4f64`) | -1 | +| `ROUTE19_FISHER1` | `SPRITE_FISHER` | 9 | 5 | `SPRITEMOVEDATA_STANDING_DOWN` (radius 0,0) | `OBJECTTYPE_SCRIPT` (sight field 1, unused) | `Route19Fisher1Script` (sym `4e:4f78`) | -1 | +| `ROUTE19_FISHER2` | `SPRITE_FISHER` | 11 | 5 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT` (sight field 1, unused) | `Route19Fisher2Script` (sym `4e:4f8c`) | -1 | + +Note for a bot: Tucker `(8, 23)` and Dawn `(9, 23)` both have sight range 0, so they never initiate a battle by line of sight. They must be talked to, which is exactly the walkthrough's "Talk to the guy for a battle" / "talk to the girl to battle her". Harold and Jerome have sight 3 and will pull the player in. + +**Scripts of interest** + +- `Route19_MapScripts` (`maps/Route19.asm:9`): no scene scripts; one callback, `callback MAPCALLBACK_TILES, Route19ClearRocksCallback`. +- `Route19ClearRocksCallback` (`maps/Route19.asm:15`, sym `4e:4f09`): `checkevent EVENT_CINNABAR_ROCKS_CLEARED` / `iftrue .Done`, otherwise six `changeblock` calls writing block `$7a` (a rock) at cells `(6, 6)`, `(8, 6)`, `(10, 6)`, `(12, 8)`, `(4, 8)`, `(10, 10)` - i.e. blocks `(3, 3)`, `(4, 3)`, `(5, 3)`, `(6, 4)`, `(2, 4)`, `(5, 5)`. Block `$7a` in `data/tilesets/kanto_collision.asm:123` is `tilecoll WALL, WALL, WALL, WALL`, so all four quadrants are solid; these are impassable terrain, not Strength boulders. Because this is `MAPCALLBACK_TILES` it re-runs on every map load, and because `EVENT_CINNABAR_ROCKS_CLEARED` is set by simply walking onto Route 20, by the time this section runs the rocks are already gone. +- `Route19Fisher1Script` / `Route19Fisher2Script` (`maps/Route19.asm:71` / `:87`): `faceplayer / opentext / checkevent EVENT_CINNABAR_ROCKS_CLEARED / iftrue .RocksCleared`. Flavor only, no flags written, no items. +- The four trainer scripts follow the same `endifjustbattled` pattern as Route 20's. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_CINNABAR_ROCKS_CLEARED` | `constants/event_flags.asm:214` | read by `Route19ClearRocksCallback` and both Fisher scripts | Gate on whether the north half of Route 19 is walkable | +| `EVENT_BEAT_SWIMMERF_DAWN` | `constants/event_flags.asm:485` | `TrainerSwimmerfDawn` | One-time battle | +| `EVENT_BEAT_SWIMMERM_HAROLD` | `constants/event_flags.asm:941` | `TrainerSwimmermHarold` | One-time battle; not mentioned by the walkthrough | +| `EVENT_BEAT_SWIMMERM_JEROME` | `constants/event_flags.asm:954` | `TrainerSwimmermJerome` | One-time battle | +| `EVENT_BEAT_SWIMMERM_TUCKER` | `constants/event_flags.asm:955` | `TrainerSwimmermTucker` | One-time battle | + +**Items** - none. Both `bg_event`s are `BGEVENT_READ` signs; there is no `hiddenitem` and no item ball on this map. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `SWIMMERM` / `TUCKER` | 26 | `SwimmerMGroup` entry 15 | `TRAINERTYPE_NORMAL`: L30 SHELLDER, L34 CLOYSTER | `TrainerSwimmermTucker` | none | +| `SWIMMERF` / `DAWN` | 27 | `SwimmerFGroup` entry 12 | `TRAINERTYPE_NORMAL`: L34 SEAKING | `TrainerSwimmerfDawn` | none | +| `SWIMMERM` / `JEROME` | 26 | `SwimmerMGroup` entry 14 | `TRAINERTYPE_NORMAL`: L26 SEADRA, L28 TENTACOOL, L30 TENTACRUEL, L28 GOLDEEN | `TrainerSwimmermJerome` | none | +| `SWIMMERM` / `HAROLD` | 26 | `SwimmerMGroup` entry 1 | `TRAINERTYPE_NORMAL`: L32 REMORAID, L30 SEADRA | `TrainerSwimmermHarold` | none | + +Prize money using the same `4 x base x last-mon level` rule: Tucker `4 x 2 x 34 = 272`, Dawn `4 x 5 x 34 = 680`, Jerome `4 x 2 x 28 = 224`, Harold `4 x 2 x 30 = 240`. The first three match the FAQ exactly. + +**Wild encounters** + +- Surfing: `data/wild/kanto_water.asm:47` `def_water_wildmons ROUTE_19`, encounter rate `6 percent`: L35 TENTACOOL, L30 TENTACOOL, L35 TENTACRUEL. +- No grass table, no headbutt table. +- Fishing: `FISHGROUP_SHORE` (`data/maps/maps.asm:196`), `data/wild/fish.asm` `.Shore_Old` / `.Shore_Good` / `.Shore_Super` (lines 28-40): Old = MAGIKARP/MAGIKARP/KRABBY L10; Good = MAGIKARP L20, KRABBY L20 (x2), time group 0; Super = KRABBY L40, time group 1, KRABBY L40, KINGLER L40. + +### MAP_ROUTE_19_FUCHSIA_GATE + +Included because the walkthrough's "blocked by those boulders south of Fuchsia City" line points at it. The walkthrough never actually enters it. + +- Script: `maps/Route19FuchsiaGate.asm` +- Blocks: `maps/Route19FuchsiaGate.blk` (`data/maps/blocks.asm:204` `Route19FuchsiaGate_Blocks`) +- Header: `data/maps/maps.asm:194` -> `TILESET_GATE`, `GATE`, `LANDMARK_ROUTE_19`, `MUSIC_ROUTE_3`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:178` `map_const ROUTE_19_FUCHSIA_GATE, 5, 4` (10x8 cells) +- Connections: none (`data/maps/attributes.asm:512` has no connection rows) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 0 | `FUCHSIA_CITY` | 10 | +| 2 | 5 | 0 | `FUCHSIA_CITY` | 11 | +| 3 | 4 | 7 | `ROUTE_19` | 1 | +| 4 | 5 | 7 | `ROUTE_19` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE19FUCHSIAGATE_OFFICER` | `SPRITE_OFFICER` | 0 | 4 | `SPRITEMOVEDATA_STANDING_RIGHT` (radius 0,0) | `OBJECTTYPE_SCRIPT`, `PAL_NPC_BLUE` | `Route19FuchsiaGateOfficerScript` | -1 | + +**Scripts of interest** + +- `Route19FuchsiaGateOfficerScript` (`maps/Route19FuchsiaGate.asm:9`): `faceplayer / opentext / checkevent EVENT_CINNABAR_ROCKS_CLEARED / iftrue .RocksCleared`, then one of two texts. He is at `(0, 4)`, off to the side of the `(4, ...)` / `(5, ...)` walkway, so he blocks nothing. There is no coord event and no movement-blocking script in this gate: the officer is flavor, and the actual road closure is the `changeblock` rocks on Route 19 itself. + +### MAP_VIRIDIAN_CITY + +- Script: `maps/ViridianCity.asm` +- Blocks: `maps/ViridianCity.blk` (`data/maps/blocks.asm:235` `ViridianCity_Blocks`) +- Header: `data/maps/maps.asm:457` -> `TILESET_KANTO`, `TOWN`, `LANDMARK_VIRIDIAN_CITY`, `MUSIC_VIRIDIAN_CITY`, phone `FALSE`, `PALETTE_AUTO`, `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:423` `map_const VIRIDIAN_CITY, 20, 18` (40x36 cells) +- Connections: `data/maps/attributes.asm:259-261` - north `Route2` (`ROUTE_2`, offset 5), south `Route1` (`ROUTE_1`, offset 10), west `Route22` (`ROUTE_22`, offset 4). No east. +- Map events block: `ViridianCity_MapEvents`, sym `4e:4486` +- Fly target: `data/maps/flypoints.asm:19` `db LANDMARK_VIRIDIAN_CITY, SPAWN_VIRIDIAN`; `data/maps/spawn_points.asm:15` `spawn VIRIDIAN_CITY, 23, 26` (the Pokecenter doorstep) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 32 | 7 | `VIRIDIAN_GYM` | 1 | +| 2 | 21 | 9 | `VIRIDIAN_NICKNAME_SPEECH_HOUSE` | 1 | +| 3 | 23 | 15 | `TRAINER_HOUSE_1F` | 1 | +| 4 | 29 | 19 | `VIRIDIAN_MART` | 2 | +| 5 | 23 | 25 | `VIRIDIAN_POKECENTER_1F` | 1 | + +**Coord events** (`def_coord_events`) - none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 17 | `BGEVENT_READ` | `ViridianCitySign` | +| 27 | 7 | `BGEVENT_READ` | `ViridianGymSign` (sym `4e:4053`) - "LEADER: ... The rest of the text is illegible" | +| 19 | 1 | `BGEVENT_READ` | `ViridianCityWelcomeSign` | +| 21 | 15 | `BGEVENT_READ` | `TrainerHouseSign` | +| 24 | 25 | `BGEVENT_READ` | `ViridianCityPokecenterSign` (`jumpstd PokecenterSignScript`) | +| 30 | 19 | `BGEVENT_READ` | `ViridianCityMartSign` (`jumpstd MartSignScript`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIRIDIANCITY_GRAMPS1` | `SPRITE_GRAMPS` | 18 | 5 | `SPRITEMOVEDATA_WANDER` (radius 2,2) | `OBJECTTYPE_SCRIPT` | `ViridianCityCoffeeGramps` | -1 | +| `VIRIDIANCITY_GRAMPS2` | `SPRITE_GRAMPS` | 30 | 8 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_BLUE` | `ViridianCityGrampsNearGym` (sym `4e:401e`) | -1 | +| `VIRIDIANCITY_FISHER` | `SPRITE_FISHER` | 6 | 23 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_RED` | `ViridianCityDreamEaterFisher` (sym `4e:4032`) | -1 | +| `VIRIDIANCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 17 | 21 | `SPRITEMOVEDATA_WANDER` (radius 3,3) | `OBJECTTYPE_SCRIPT`, `PAL_NPC_GREEN` | `ViridianCityYoungsterScript` | -1 | + +**Scripts of interest** + +- `ViridianCityFlypointCallback` (`maps/ViridianCity.asm:13`, sym `4e:4005`), hooked as `callback MAPCALLBACK_NEWMAP`: `setflag ENGINE_FLYPOINT_VIRIDIAN / endcallback`. A bot cannot Fly here until it has physically entered Viridian City once. +- `ViridianCityGrampsNearGym` (`maps/ViridianCity.asm:34`): `checkevent EVENT_BLUE_IN_CINNABAR`; if true, the "Are you going to battle the LEADER?" line. This is a free read-out of whether Blue has been moved into the gym - see the gate table. +- `ViridianCityDreamEaterFisher` (`maps/ViridianCity.asm:50`): `checkevent EVENT_GOT_TM42_DREAM_EATER / iftrue .GotDreamEater`, else `writetext ... / promptbutton / verbosegiveitem TM_DREAM_EATER / iffalse .NoRoomForDreamEater / setevent EVENT_GOT_TM42_DREAM_EATER`. The walkthrough never mentions this; it is a free TM on the map the walkthrough tells you to fly to. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_VIRIDIAN` | `constants/engine_flags.asm:68` | set by `ViridianCityFlypointCallback` | Precondition for the walkthrough's "fly back to Viridian City" | +| `EVENT_BLUE_IN_CINNABAR` | `constants/event_flags.asm:1303` | read by `ViridianCityGrampsNearGym` and `ViridianPokecenter1FCooltrainerMScript`; it is also `CINNABARISLAND_BLUE`'s object event flag | While clear, Blue is standing on Cinnabar Island and has not yet moved to the gym | +| `EVENT_GOT_TM42_DREAM_EATER` | `constants/event_flags.asm:223` | `ViridianCityDreamEaterFisher` | One-time TM42 | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_DREAM_EATER` (TM42) | Talk to `VIRIDIANCITY_FISHER` at `(6, 23)` | `ViridianCityDreamEaterFisher` `verbosegiveitem` | `EVENT_GOT_TM42_DREAM_EATER` | + +**Trainers** - none on the overworld map. + +**Wild encounters** - no `kanto_grass.asm` or `kanto_water.asm` entry for `VIRIDIAN_CITY`. Fishing group is `FISHGROUP_POND` (`data/maps/maps.asm:457`). + +### MAP_VIRIDIAN_POKECENTER_1F + +- Script: `maps/ViridianPokecenter1F.asm` +- Blocks: `data/maps/blocks.asm:361` `ViridianPokecenter1F_Blocks` +- Header: `data/maps/maps.asm:463` -> `TILESET_POKECENTER`, `INDOOR`, `LANDMARK_VIRIDIAN_CITY`, `MUSIC_POKEMON_CENTER`, phone `FALSE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:429` `map_const VIRIDIAN_POKECENTER_1F, 5, 4` (10x8 cells) +- Connections: none +- Respawn point: `data/maps/spawn_points.asm:12` `spawn VIRIDIAN_POKECENTER_1F, 5, 3` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `VIRIDIAN_CITY` | 5 | +| 2 | 4 | 7 | `VIRIDIAN_CITY` | 5 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events** - none. **BG events** - none. + +**Object events** + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIRIDIANPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `ViridianPokecenter1FNurseScript` | -1 | +| `VIRIDIANPOKECENTER1F_COOLTRAINER_M` | `SPRITE_COOLTRAINER_M` | 8 | 4 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (radius 1,0) | `OBJECTTYPE_SCRIPT`, `PAL_NPC_RED` | `ViridianPokecenter1FCooltrainerMScript` | -1 | +| `VIRIDIANPOKECENTER1F_COOLTRAINER_F` | `SPRITE_COOLTRAINER_F` | 5 | 3 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_BLUE` | `ViridianPokecenter1FCooltrainerFScript` | -1 | +| `VIRIDIANPOKECENTER1F_BUG_CATCHER` | `SPRITE_BUG_CATCHER` | 1 | 6 | `SPRITEMOVEDATA_STANDING_RIGHT` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_GREEN` | `ViridianPokecenter1FBugCatcherScript` | -1 | + +**Scripts of interest** + +- `ViridianPokecenter1FNurseScript` (`maps/ViridianPokecenter1F.asm:12`): `jumpstd PokecenterNurseScript` (`engine/events/std_scripts.asm:54`). This is the "heal at the Pokémon Center" beat, both before and after Blue. +- `ViridianPokecenter1FCooltrainerMScript` (`maps/ViridianPokecenter1F.asm:15`): another `checkevent EVENT_BLUE_IN_CINNABAR` read-out ("There are no GYM TRAINERS at the VIRIDIAN GYM"), which corroborates the walkthrough's "There aren't any other trainers in this gym". + +### MAP_VIRIDIAN_GYM + +- Script: `maps/ViridianGym.asm` +- Blocks: `maps/ViridianGym.blk` (`data/maps/blocks.asm:877` `ViridianGym_Blocks`) +- Header: `data/maps/maps.asm:458` -> `TILESET_TRAIN_STATION`, `INDOOR`, `LANDMARK_VIRIDIAN_CITY`, `MUSIC_GYM`, phone `TRUE`, `PALETTE_DAY`, `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:424` `map_const VIRIDIAN_GYM, 5, 9` (10x18 cells) +- Connections: none (`data/maps/attributes.asm:657`) +- Map events block: `ViridianGym_MapEvents`, sym `5f:43de` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 17 | `VIRIDIAN_CITY` | 1 | +| 2 | 5 | 17 | `VIRIDIAN_CITY` | 1 | + +**Coord events** (`def_coord_events`) - none. There is no trip-wire in this gym; Blue is a pure talk-to trigger. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 3 | 13 | `BGEVENT_READ` | `ViridianGymStatue` (sym `5f:4047`) | +| 6 | 13 | `BGEVENT_READ` | `ViridianGymStatue` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIRIDIANGYM_BLUE` | `SPRITE_BLUE` | 5 | 3 | `SPRITEMOVEDATA_STANDING_DOWN` (radius 0,0) | `OBJECTTYPE_SCRIPT`, palette 0 | `ViridianGymBlueScript` (sym `5f:4002`) | `EVENT_VIRIDIAN_GYM_BLUE` | +| `VIRIDIANGYM_GYM_GUIDE` | `SPRITE_GYM_GUIDE` | 7 | 13 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT`, `PAL_NPC_BLUE` | `ViridianGymGuideScript` (sym `5f:4033`) | `EVENT_VIRIDIAN_GYM_BLUE` | + +Both objects carry `EVENT_VIRIDIAN_GYM_BLUE` as their event flag, so while that flag is **set** the gym is completely empty. `EVENT_VIRIDIAN_GYM_BLUE` is set by `InitializeEventsScript` at new game (`engine/events/std_scripts.asm:438`, the `setevent` on line 552) and is cleared only by `CinnabarIslandBlue`. + +**Scripts of interest** + +- `ViridianGymBlueScript` (`maps/ViridianGym.asm:10`, sym `5f:4002`), the whole badge beat, in order: + 1. `faceplayer` / `opentext` + 2. `checkflag ENGINE_EARTHBADGE` / `iftrue .FightDone` (sym `5f:402d`) - repeat visits get `LeaderBlueEpilogueText` only + 3. `writetext LeaderBlueBeforeText` / `waitbutton` / `closetext` + 4. `winlosstext LeaderBlueWinText, 0` (loss text pointer 0 = use the default) + 5. `loadtrainer BLUE, BLUE1` / `startbattle` / `reloadmapafterbattle` + 6. `setevent EVENT_BEAT_BLUE` + 7. `opentext` / `writetext Text_ReceivedEarthBadge` / `playsound SFX_GET_BADGE` / `waitsfx` + 8. `setflag ENGINE_EARTHBADGE` + 9. `writetext LeaderBlueAfterText` / `waitbutton` / `closetext` / `end` + + No `verbosegiveitem`: unlike Johto leaders, the Kanto leaders in GSC hand out no TM. The badge itself is the only reward besides money. +- `ViridianGymGuideScript` (`maps/ViridianGym.asm:39`, sym `5f:4033`): `checkevent EVENT_BEAT_BLUE / iftrue .ViridianGymGuideWinScript`. Pure flavor, no `learnmove`, no items. +- `ViridianGymStatue` (`maps/ViridianGym.asm:55`, sym `5f:4047`): `checkflag ENGINE_EARTHBADGE / iftrue .Beaten`; before the badge, `jumpstd GymStatue1Script` (`engine/events/std_scripts.asm:638`), after it `gettrainername STRING_BUFFER_4, BLUE, BLUE1` then `jumpstd GymStatue2Script` (`:646`). A cheap read-back a bot can use to confirm the badge landed. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_EARTHBADGE` | `constants/engine_flags.asm:54` (in `wKantoBadges`) | read by `ViridianGymBlueScript` and `ViridianGymStatue`; set by `ViridianGymBlueScript` | The 16th badge. Post-condition of this section | +| `EVENT_BEAT_BLUE` | `constants/event_flags.asm:722` | set by `ViridianGymBlueScript`, read by `ViridianGymGuideScript` | Set *before* the badge flag, between `reloadmapafterbattle` and `playsound` | +| `EVENT_VIRIDIAN_GYM_BLUE` | `constants/event_flags.asm:1304` | set by `InitializeEventsScript` (`engine/events/std_scripts.asm:552`); cleared by `CinnabarIslandBlue` (`maps/CinnabarIsland.asm:23`) | While set, both gym objects are absent. This is the real gate on the whole section | +| `EVENT_BLUE_IN_CINNABAR` | `constants/event_flags.asm:1303` | object flag of `CINNABARISLAND_BLUE` (`maps/CinnabarIsland.asm:143`), set by that script's `disappear` | Once set, Blue is gone from Cinnabar and (necessarily) present in the gym | + +**Items** - none. No item balls, no `hiddenitem`, no TM reward. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `BLUE` / `BLUE1` | 40 (`constants/trainer_constants.asm:640`) | `BLUE1` (`:641`) | `data/trainers/parties.asm:3154` `BlueGroup` (sym `0e:75ff`), `TRAINERTYPE_MOVES` | `ViridianGymBlueScript` | none (no phone entry; the map header's phone column is `TRUE` but no `phone_call` targets him) | + +`BlueGroup` party, verbatim: + +| # | level | species | moves | +|---|---|---|---| +| 1 | 56 | PIDGEOT | QUICK_ATTACK, WHIRLWIND, WING_ATTACK, MIRROR_MOVE | +| 2 | 54 | ALAKAZAM | DISABLE, RECOVER, PSYCHIC_M, REFLECT | +| 3 | 56 | RHYDON | FURY_ATTACK, SANDSTORM, ROCK_SLIDE, EARTHQUAKE | +| 4 | 58 | GYARADOS | TWISTER, HYDRO_PUMP, RAIN_DANCE, HYPER_BEAM | +| 5 | 58 | EXEGGUTOR | SUNNY_DAY, LEECH_SEED, EGG_BOMB, SOLARBEAM | +| 6 | 58 | ARCANINE | ROAR, SWIFT, FLAMETHROWER, EXTREMESPEED | + +Class attributes (`data/trainers/attributes.asm:383`): items `FULL_RESTORE, FULL_RESTORE` (the AI will use two Full Restores), base reward 25, AI `AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | AI_CAUTIOUS | AI_STATUS | AI_RISKY`, `CONTEXT_USE | SWITCH_SOMETIMES`. Payout `4 x 25 x 58 = 5800`, matching the FAQ. `BLUE` is listed in `KantoGymLeaders` (`data/trainers/leaders.asm:31`), which is what selects the gym-leader battle/victory music and awards `HAPPINESS_GYMBATTLE`. + +Walkthrough claims worth flagging against the party data: Pidgeot's flying move is `WING_ATTACK`, not Air Slash (which does not exist in Gen 2); Exeggutor's moves are `SUNNY_DAY, LEECH_SEED, EGG_BOMB, SOLARBEAM`, so it has neither Psychic nor Hypnosis; Arcanine's switch move is `ROAR`, Pidgeot's is `WHIRLWIND`. + +**Wild encounters** - none (indoor map). + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Reaching Routes 19/20 at all | `engine/events/overworld.asm:322` `SurfFunction` -> `.TrySurf` (`ld de, ENGINE_FOGBADGE / call CheckBadge`), and the tile-facing path `engine/events/overworld.asm:469` `TrySurfOW` (`ENGINE_FOGBADGE` via `CheckEngineFlag`, then `CheckPartyMove` for `SURF`) | FOGBADGE + a party member knowing SURF | Both routes are open water; without Surf there is no approach | +| Boulders across northern Route 19 | `maps/Route19.asm:15` `Route19ClearRocksCallback`, six `changeblock ..., $7a` under `checkevent EVENT_CINNABAR_ROCKS_CLEARED`; block `$7a` is `WALL` on all four quadrants (`data/tilesets/kanto_collision.asm:123`) | `EVENT_CINNABAR_ROCKS_CLEARED` | Set unconditionally by `maps/Route20.asm:12` `Route20ClearRocksCallback` on `MAPCALLBACK_NEWMAP`, i.e. by setting foot on Route 20 | +| Blue is not in the Viridian Gym | Object event flag `EVENT_VIRIDIAN_GYM_BLUE` on both `maps/ViridianGym.asm:183-184` objects; set at new game by `engine/events/std_scripts.asm:552` inside `InitializeEventsScript` | Flag must be **clear** | `maps/CinnabarIsland.asm:14` `CinnabarIslandBlue`: talk to Blue on Cinnabar Island; the script runs `disappear CINNABARISLAND_BLUE` (setting `EVENT_BLUE_IN_CINNABAR`) then `clearevent EVENT_VIRIDIAN_GYM_BLUE`. This happens in an earlier section, but a bot that skipped it will find an empty gym | +| Flying to Viridian City | `maps/ViridianCity.asm:13` `ViridianCityFlypointCallback` (`setflag ENGINE_FLYPOINT_VIRIDIAN`), plus `engine/events/overworld.asm:529` `FlyFunction` (`ld de, ENGINE_STORMBADGE`) and the landmark row `data/maps/flypoints.asm:19` | STORMBADGE + a party member knowing FLY + Viridian visited once on foot | Walk into Viridian City once (Route 1 north, Route 2 south, or Route 22 east). Otherwise reach the gym overland | +| Re-fighting Blue | `maps/ViridianGym.asm:13` `checkflag ENGINE_EARTHBADGE / iftrue .FightDone` | - | Not possible; the battle is one-shot and there is no rematch or phone entry | +| (Next section) Mt. Silver | `maps/OaksLab.asm:26` `readvar VAR_BADGES / ifequal NUM_BADGES, .OpenMtSilver` -> `setevent EVENT_OPENED_MT_SILVER` | All 16 badges, EARTHBADGE last | Beating Blue here is what satisfies it | + +Note on the Route 19 approach: the officer in `maps/Route19FuchsiaGate.asm` never blocks a tile, and `maps/Route19.asm` has no coord events. There is no NPC blocker anywhere in this section - every obstacle is either terrain (`changeblock`) or a hidden object (`EVENT_VIRIDIAN_GYM_BLUE`). + +## 4. Bot checklist + +Preconditions for the whole section: `ENGINE_FOGBADGE` + SURF in party, `EVENT_VIRIDIAN_GYM_BLUE` clear, `ENGINE_FLYPOINT_VIRIDIAN` set (or an overland plan to Viridian). + +| # | Map | Target | Input intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_20` | anywhere on the map | enter the map (warp from Seafoam Gym warp 1 at `(38, 7)`, or the Cinnabar Island east connection) | - | `EVENT_CINNABAR_ROCKS_CLEARED` set by `Route20ClearRocksCallback` | +| 2 | `MAP_ROUTE_20` | `ROUTE20_SWIMMER_GIRL2` at `(45, 13)` | surf into her sight cone (range 3) or talk | `EVENT_BEAT_SWIMMERF_LORI` clear | battle Lori (2x L32 STARMIE); `EVENT_BEAT_SWIMMERF_LORI` set, +640 | +| 3 | `MAP_ROUTE_20` | `ROUTE20_SWIMMER_GIRL1` at `(52, 8)` | surf into sight (range 3) or talk | `EVENT_BEAT_SWIMMERF_NICOLE` clear | battle Nicole (L29 MARILL, L29 MARILL, L32 LAPRAS); flag set, +640 | +| 3b | `MAP_ROUTE_20` | `ROUTE20_SWIMMER_GUY` at `(12, 13)` | optional, west end, skipped by the FAQ | `EVENT_BEAT_SWIMMERM_CAMERON` clear | battle Cameron (L34 MARILL); flag set, +272 | +| 4 | `MAP_ROUTE_20` -> `MAP_ROUTE_19` | east map edge | surf east across the connection (offset -9) | - | now on `MAP_ROUTE_19` | +| 5 | `MAP_ROUTE_19` | `ROUTE19_SWIMMER_GUY3` at `(8, 23)` | **talk** (sight 0, will not initiate) | `EVENT_BEAT_SWIMMERM_TUCKER` clear | battle Tucker (L30 SHELLDER, L34 CLOYSTER); flag set, +272 | +| 6 | `MAP_ROUTE_19` | `ROUTE19_SWIMMER_GUY3` again | talk | `EVENT_BEAT_SWIMMERM_TUCKER` set | `SwimmermTuckerAfterBattleText` (the "drowning" line) | +| 7 | `MAP_ROUTE_19` | `ROUTE19_SWIMMER_GIRL` at `(9, 23)` | **talk** (sight 0) | `EVENT_BEAT_SWIMMERF_DAWN` clear | battle Dawn (L34 SEAKING); flag set, +680 | +| 8 | `MAP_ROUTE_19` | `ROUTE19_SWIMMER_GUY2` at `(11, 17)` | surf north into sight (range 3) or talk | `EVENT_BEAT_SWIMMERM_JEROME` clear | battle Jerome (L26 SEADRA, L28 TENTACOOL, L30 TENTACRUEL, L28 GOLDEEN); flag set, +224 | +| 8b | `MAP_ROUTE_19` | `ROUTE19_SWIMMER_GUY1` at `(13, 28)` | optional, south-east, not in the FAQ | `EVENT_BEAT_SWIMMERM_HAROLD` clear | battle Harold (L32 REMORAID, L30 SEADRA); flag set, +240 | +| 9 | `MAP_ROUTE_19` -> `MAP_VIRIDIAN_CITY` | Pokegear map -> Fly -> Viridian | use FLY | `ENGINE_STORMBADGE`, FLY in party, `ENGINE_FLYPOINT_VIRIDIAN` | player at `VIRIDIAN_CITY (23, 26)` (`data/maps/spawn_points.asm:15`) | +| 10 | `MAP_VIRIDIAN_CITY` | warp 5 at `(23, 25)` | step onto the warp | - | in `MAP_VIRIDIAN_POKECENTER_1F` | +| 11 | `MAP_VIRIDIAN_POKECENTER_1F` | `VIRIDIANPOKECENTER1F_NURSE` at `(3, 1)` | talk, accept heal | - | party healed (`PokecenterNurseScript`) | +| 11b | `MAP_VIRIDIAN_CITY` | `VIRIDIANCITY_FISHER` at `(6, 23)` | talk (optional, not in the FAQ) | `EVENT_GOT_TM42_DREAM_EATER` clear, bag space | receive `TM_DREAM_EATER`; flag set | +| 12 | `MAP_VIRIDIAN_CITY` | warp 1 at `(32, 7)` | step onto the warp | - | in `MAP_VIRIDIAN_GYM` at `(4, 17)` / `(5, 17)` | +| 13 | `MAP_VIRIDIAN_GYM` | `VIRIDIANGYM_GYM_GUIDE` at `(7, 13)` | talk (optional) | `EVENT_VIRIDIAN_GYM_BLUE` clear | advice text only | +| 14 | `MAP_VIRIDIAN_GYM` | save | save the game (the FAQ says so explicitly) | - | - | +| 15 | `MAP_VIRIDIAN_GYM` | `VIRIDIANGYM_BLUE` at `(5, 3)`, approach from below | talk | `EVENT_VIRIDIAN_GYM_BLUE` clear, `ENGINE_EARTHBADGE` clear | battle `BLUE / BLUE1` (6 mons, L54-58, 2 Full Restores); on win `EVENT_BEAT_BLUE` set, `ENGINE_EARTHBADGE` set, +5800 | +| 16 | `MAP_VIRIDIAN_GYM` | `VIRIDIANGYM_BLUE` again | talk | `ENGINE_EARTHBADGE` set | `LeaderBlueEpilogueText` ("You'd better not lose until I beat you") | +| 17 | `MAP_VIRIDIAN_GYM` | `bg_event (3, 13)` or `(6, 13)` | read the statue | `ENGINE_EARTHBADGE` set | `GymStatue2Script` with Blue's name - a cheap badge assertion | +| 18 | `MAP_VIRIDIAN_GYM` -> `MAP_VIRIDIAN_CITY` -> Pokecenter | warp 1/2 `(4, 17)` / `(5, 17)`, then Viridian warp 5 | walk, warp, heal | - | 16 badges, section complete | + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map header, blocks, warps, coord/bg/object events for all six maps | `src/import/RomExtractorGen2.lua:782` `readMapEvents`, `:899` (map assembly) | implemented - the tables are read out of the ROM generically, so no per-map data is hand-written | +| `MAPCALLBACK_NEWMAP` / `MAPCALLBACK_TILES` dispatch (needed for both rock callbacks and the Viridian fly point) | `src/world/gen2/World.lua:5659`, `:5664` | implemented | +| `changeblock` (Route 19 boulders) | `src/script/gen2/Vm.lua:1002` (halves x/y to block coords, calls `changeBlockFn`) | implemented | +| `setevent` / `clearevent` / `checkevent`, object-event hide flags | `src/world/gen2/Events.lua`, `src/script/gen2/Vm.lua` | implemented | +| `setflag` / `checkflag` on `ENGINE_*` (EARTHBADGE, FLYPOINT_VIRIDIAN) | `src/script/gen2/Vm.lua:212-223`, `:1584` | implemented | +| Surf field move + FOGBADGE check | `src/world/gen2/FieldMoves.lua:101` (badge table), `:482` (`SurfFunction` port) | implemented | +| Fly field move + STORMBADGE + flypoint gating | `src/world/gen2/FieldMoves.lua:501-505` | implemented | +| Trainer sight cones / `OBJECTTYPE_TRAINER` (sight 3 swimmers, sight 0 Tucker and Dawn) | `src/world/gen2/Trainers.lua:98` `Trainers.sees` | implemented | +| `SPRITEMOVEDATA_SPINRANDOM_FAST` swimmers | `src/world/gen2/Npc.lua:23`, `:37`, `:68` | implemented | +| `loadtrainer` / `startbattle` / `winlosstext` / `reloadmapafterbattle` / `endifjustbattled` | `src/script/gen2/Vm.lua`, `src/script/gen2/Opcodes.lua` | implemented | +| Prize money, including the 4x split and Bank of Mom | `src/battle/gen2/Prize.lua:82` `Prize.reward`, `:181` (four quarters) | implemented | +| Gym-leader battle/victory music via `GymLeaders` / `KantoGymLeaders` | `src/battle/gen2/BattleMusic.lua:15-33` | implemented | +| Kanto badge page on the trainer card (EARTHBADGE displayed) | `src/ui/gen2/TrainerCard.lua:90`, `:161` | implemented | +| `verbosegiveitem` (TM42 in Viridian City) | `src/script/gen2/Vm.lua` (`verbosegiveitem`) | implemented | +| A driver that actually plays this stretch (Routes 19/20 surf trainers, Blue) | none - `tests/drivers/` has `gold_trainer_smoke.lua`, `gold_water_moves.lua`, `gold_map_callbacks.lua`, but nothing that reaches Kanto Route 19/20 or Viridian Gym | missing | +| Section-specific regression coverage for `EVENT_CINNABAR_ROCKS_CLEARED` sequencing | not found by grep in `tests/` | missing | + +Every "implemented" row above is a generic engine capability, not a hand-port of these maps. Nothing in `src/` names `ROUTE_19`, `ROUTE_20` or `VIRIDIAN_GYM` (the only grep hits are `src/save_convert/data/event_flags_yellow.lua`, which is Gen 1 save conversion data and unrelated). So this section should work if the extractor and VM work, but it has never been exercised end to end. + +## 6. Unresolved / verify by hand + +1. **The walkthrough's boulder claim contradicts the asm.** "You can't go further north because you'll be blocked by those boulders south of Fuchsia City" is only true while `EVENT_CINNABAR_ROCKS_CLEARED` is clear. `maps/Route20.asm:12` `Route20ClearRocksCallback` sets that event on `MAPCALLBACK_NEWMAP` with no condition at all, and the walkthrough routes the player through Route 20 to reach Route 19. By the time the player is standing on Route 19 the six `changeblock` rocks are never placed, so northern Route 19 and the Fuchsia gate at `(7, 3)` are open. Treat the FAQ line as stale prose, not a gate. (What is genuinely true is that the FAQ's suggested Fly is faster than swimming back.) +2. **Route 19's north connection vs. the gate warp.** `data/maps/attributes.asm:288` declares `connection north, FuchsiaCity, FUCHSIA_CITY, 0`, and there is *also* a warp to `ROUTE_19_FUCHSIA_GATE`. I did not decode `maps/Route19.blk` to determine whether the top block row is actually walkable, so I cannot say whether the north connection is reachable or purely decorative. Verify by hand before a bot relies on either route out. +3. **Two trainers the walkthrough omits.** `TrainerSwimmermCameron` on Route 20 at `(12, 13)` (L34 MARILL) and `TrainerSwimmermHarold` on Route 19 at `(13, 28)` (L32 REMORAID, L30 SEADRA) both exist with sight range 3. The FAQ says "that's all the trainers" for Route 19, which is wrong. A bot surfing the full map will get pulled into both. +4. **Blue's move set does not match the FAQ's strategy text.** The FAQ names Air Slash (Pidgeot) and Psychic + Hypnosis (Exeggutor); `data/trainers/parties.asm:3154` gives Pidgeot `QUICK_ATTACK, WHIRLWIND, WING_ATTACK, MIRROR_MOVE` and Exeggutor `SUNNY_DAY, LEECH_SEED, EGG_BOMB, SOLARBEAM`. Air Slash does not exist in Gen 2. Do not build AI expectations from the FAQ prose here. +5. **The FAQ's "Blue is one of only two trainers tougher than Champion Lance"** is a subjective claim with no asm counterpart; nothing in `data/trainers/` ranks trainers. +6. **`EVENT_VIRIDIAN_GYM_BLUE` is set in a prior section.** The walkthrough never mentions meeting Blue on Cinnabar Island, but `maps/CinnabarIsland.asm:23` is the only `clearevent` for that flag anywhere in the tree. If an earlier section's document does not cover `CinnabarIslandBlue`, this is an orphaned precondition and a bot will find an empty Viridian Gym with no in-game hint beyond `ViridianCityGrampsNearGym`. +7. **Blue's map header sets the phone column to `TRUE`** (`data/maps/maps.asm:458`, the `ViridianGym` row). I found no `phone_call` or rematch entry naming `BLUE`, so I believe this is inert, but I did not exhaustively read `data/phone/`. diff --git a/docs/gold-walkthrough/asm-walk/section-29-routes-22-28.md b/docs/gold-walkthrough/asm-walk/section-29-routes-22-28.md new file mode 100644 index 00000000..501009d6 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-29-routes-22-28.md @@ -0,0 +1,785 @@ +# Section 29 - Routes 22-28 + +Source: `../section-29-routes-22-28.txt` (the FAQ numbers this chapter "35 > Routes 22-28") +Maps covered: `MAP_PALLET_TOWN`, `MAP_OAKS_LAB`, `MAP_VIRIDIAN_CITY`, `MAP_ROUTE_22`, +`MAP_VICTORY_ROAD_GATE`, `MAP_ROUTE_28`, `MAP_ROUTE_28_STEEL_WING_HOUSE`, and the +east edge of `MAP_SILVER_CAVE_OUTSIDE` (+ `MAP_SILVER_CAVE_POKECENTER_1F`). +Badges / key milestones in this section: no badge. The milestone is +`EVENT_OPENED_MT_SILVER`, set by Prof. Oak once `VAR_BADGES == NUM_BADGES` (16). +Items: TM42 Dream Eater, TM47 Steel Wing, hidden Rare Candy. + +Two structural facts the walkthrough hides, both verified against the block data +and both load-bearing for a bot: + +1. **Oak's line is not flavour - it is the gate.** `EVENT_OPENED_MT_SILVER` + *removes* the black belt standing at `VICTORY_ROAD_GATE` (7,5). That NPC + occupies the only tile joining the gate's central corridor to its western + vestibule, i.e. to the two `ROUTE_28` warps. Until Oak sets the flag, Route 28 + and Mt. Silver are physically unreachable. See section 3. +2. **`object_event`'s trailing `EVENT_*` HIDES the object when the flag is SET.** + `CheckObjectFlag` (`engine/overworld/map_objects_2.asm:32-61`) jumps to + `.masked` when `EventFlagAction`/`CHECK_FLAG` returns non-zero. The macro + comment in `macros/scripts/maps.asm:132` ("event flag: an `EVENT_*` constant, + or -1 to always appear") does not state the polarity and is easy to read + backwards. The port already has this right + (`src/world/gen2/Events.lua:40-44`). + +Coordinates below are walk cells (the raw numbers in the `warp_event` / +`bg_event` / `object_event` rows), origin top-left, x east, y south. + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_PALLET_TOWN` | `maps/PalletTown.asm` | Fly, `SPAWN_PALLET` = (5,6) (`data/maps/spawn_points.asm:14`) | warp 3 at (12,11) -> `OAKS_LAB` warp 1 | "fly back to Pallet Town" | +| 1 | `MAP_OAKS_LAB` | `maps/OaksLab.asm` | warp 1 at (4,11) (warp 2 at (5,11) is the twin tile) | same warps back to `PALLET_TOWN` warp 3 | talk to `Oak` at (4,2); with 16 badges this runs `.OpenMtSilver` and sets `EVENT_OPENED_MT_SILVER` | +| 2 | `MAP_VIRIDIAN_CITY` | `maps/ViridianCity.asm` | Fly, `SPAWN_VIRIDIAN` = (23,26) (`data/maps/spawn_points.asm:15`), which is the Pokecenter warp tile | west map edge, cells y=14..17 at x=0 | "fly back to Viridian City, and then head west to Route 22" | +| 2a | `MAP_VIRIDIAN_CITY` (Cut pocket) | same | Cut the tree at (8,22), approached from (9,22) facing LEFT | the same tile back east | the TM42 Dream Eater fisher at (6,23). The FAQ files this under "Items on Route 22"; it is **not** on Route 22 | +| 3 | `MAP_ROUTE_22` | `maps/Route22.asm` | east map edge at x=39, y=6..9 (`connection east, ViridianCity, VIRIDIAN_CITY, -4`) | warp 1 at (13,5) -> `VICTORY_ROAD_GATE` warp 1 | "keep heading north, west, then north" | +| 4 | `MAP_VICTORY_ROAD_GATE` | `maps/VictoryRoadGate.asm` | warp 1 at (17,7) (warp 2 at (18,7) is the twin) | warp 7 at (1,7) / warp 8 at (2,7) -> `ROUTE_28` warp 2 | "heading west instead of north" | +| 5 | `MAP_ROUTE_28` (main body) | `maps/Route28.asm` | warp 2 at (33,5) | west map edge at x=0, y=12..13 (`connection west, SilverCaveOutside, SILVER_CAVE_OUTSIDE, -9`) | "head west towards Mount Silver"; the Route 28 sign is read from (30,5) | +| 6 | `MAP_SILVER_CAVE_OUTSIDE` | `maps/SilverCaveOutside.asm` | east map edge at x=39, y=30..31 | warp 1 at (23,19) -> `SILVER_CAVE_POKECENTER_1F` warp 1 | "There's also a Pokémon Center here". Sets `ENGINE_FLYPOINT_SILVER_CAVE` on entry | +| 7 | `MAP_SILVER_CAVE_POKECENTER_1F` | `maps/SilverCavePokecenter1F.asm` | warp 1 at (3,7) (twin at (4,7)) | same | heal; the last Pokecenter before Mt. Silver | +| 8 | `MAP_SILVER_CAVE_OUTSIDE` (east shelf) | same | Cut (31,24) then Cut (34,23) | east map edge at x=39, y=21 | "cut the trees east" | +| 9 | `MAP_ROUTE_28` (isolated west strip) | `maps/Route28.asm` | west map edge at x=0, y=3 | warp 1 at (7,3) -> `ROUTE_28_STEEL_WING_HOUSE` warp 1 | the strip that holds the Steel Wing house door and the hidden Rare Candy | +| 10 | `MAP_ROUTE_28_STEEL_WING_HOUSE` | `maps/Route28SteelWingHouse.asm` | warp 1 at (2,7) (twin at (3,7)) | same | `Celebrity` at (2,3) gives TM47 Steel Wing | +| 11 | `MAP_SILVER_CAVE_OUTSIDE` warp 2 at (18,11) -> `SILVER_CAVE_ROOM_1` | `maps/SilverCaveRoom1.asm` | - | - | **next section** (Mt. Silver / Red). Stop here. | + +The FAQ's chapter title says "Routes 22-28" but the prose never enters Route 23, +24, 25, 26 or 27. Routes 26/27 are section 16's, Route 23 is section 17/18's. + +## 2. Maps + +### MAP_PALLET_TOWN + +- Script: `maps/PalletTown.asm` (included from `data/maps/scripts.asm:171`) +- Blocks: `maps/PalletTown.blk` (`data/maps/blocks.asm:88`) +- Header: `data/maps/maps.asm:312` + `map PalletTown, TILESET_KANTO, TOWN, LANDMARK_PALLET_TOWN, MUSIC_PALLET_TOWN, FALSE, PALETTE_AUTO, FISHGROUP_OCEAN` +- Attributes: `data/maps/attributes.asm:271` `map_attributes PalletTown, PALLET_TOWN, $0f` +- Dimensions: `constants/map_constants.asm:288` `map_const PALLET_TOWN, 10, 9` -> 10x9 blocks = 20x18 cells. Group `PALLET` (13), map id 2. +- Connections: `connection north, Route1, ROUTE_1, 0`; `connection south, Route21, ROUTE_21, 0` +- Callbacks: `callback MAPCALLBACK_NEWMAP, PalletTownFlypointCallback` (sym `4e:46ac`) -> `setflag ENGINE_FLYPOINT_PALLET` + +**Warps** (`def_warp_events`, `maps/PalletTown.asm:73-76`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 5 | `REDS_HOUSE_1F` | 1 | +| 2 | 13 | 5 | `BLUES_HOUSE` | 1 | +| 3 | 12 | 11 | `OAKS_LAB` | 1 | + +**Coord events** - `def_coord_events` is empty. + +**BG events** (`def_bg_events`, `maps/PalletTown.asm:80-84`) + +| x | y | type | script/item | +|---|---|---|---| +| 7 | 9 | `BGEVENT_READ` | `PalletTownSign` (sym `4e:46b6`) | +| 3 | 5 | `BGEVENT_READ` | `RedsHouseSign` | +| 13 | 13 | `BGEVENT_READ` | `OaksLabSign` (sym `4e:46bc`) | +| 11 | 5 | `BGEVENT_READ` | `BluesHouseSign` | + +**Object events** (`def_object_events`, `maps/PalletTown.asm:86-88`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `PALLETTOWN_TEACHER` | `SPRITE_TEACHER` | 3 | 8 | `SPRITEMOVEDATA_WANDER` (r 2,2) | `OBJECTTYPE_SCRIPT` | `PalletTownTeacherScript` | -1 | +| `PALLETTOWN_FISHER` | `SPRITE_FISHER` | 12 | 14 | `SPRITEMOVEDATA_WALK_LEFT_RIGHT` (r 2,0) | `OBJECTTYPE_SCRIPT` | `PalletTownFisherScript` | -1 | + +**Route from the Fly landing to the lab door** (BFS over `PalletTown.blk` + +`data/tilesets/kanto_collision.asm`): (5,6) -> (9,6) -> (9,12) -> (12,12) -> +(12,11). No ledges, no field moves. + +--- + +### MAP_OAKS_LAB + +- Script: `maps/OaksLab.asm` (`data/maps/scripts.asm:341`) +- Blocks: `maps/OaksLab.blk` (`data/maps/blocks.asm:892`) +- Header: `data/maps/maps.asm:316` + `map OaksLab, TILESET_LAB, INDOOR, LANDMARK_PALLET_TOWN, MUSIC_POKEMON_TALK, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Attributes: `data/maps/attributes.asm:579` `map_attributes OaksLab, OAKS_LAB, $00`, no connections +- Dimensions: `constants/map_constants.asm:292` `map_const OAKS_LAB, 5, 6` -> 5x6 blocks = 10x12 cells. Group `PALLET` (13), map id 6. +- Callbacks / scene scripts: both empty (`OaksLabNoopScene` is marked unreferenced). + +**Warps** (`maps/OaksLab.asm:260-262`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 4 | 11 | `PALLET_TOWN` | 3 | +| 2 | 5 | 11 | `PALLET_TOWN` | 3 | + +**Coord events** - none. + +**BG events** (`maps/OaksLab.asm:266-282`) + +| x | y | type | script/item | +|---|---|---|---| +| 6,7,8,9 | 1 | `BGEVENT_READ` | `OaksLabBookshelf` -> `jumpstd DifficultBookshelfScript` | +| 0,1,2,3 | 7 | `BGEVENT_READ` | `OaksLabBookshelf` | +| 6,7,8,9 | 7 | `BGEVENT_READ` | `OaksLabBookshelf` | +| 4 | 0 | `BGEVENT_READ` | `OaksLabPoster1` | +| 5 | 0 | `BGEVENT_READ` | `OaksLabPoster2` | +| 9 | 3 | `BGEVENT_READ` | `OaksLabTrashcan` | +| 0 | 1 | `BGEVENT_READ` | `OaksLabPC` | + +**Object events** (`maps/OaksLab.asm:284-288`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `OAKSLAB_OAK` | `SPRITE_OAK` | 4 | 2 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `Oak` (sym `59:58c3`) | -1 | +| `OAKSLAB_SCIENTIST1` | `SPRITE_SCIENTIST` | 1 | 8 | `WALK_LEFT_RIGHT` (r 1,0), `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `OaksAssistant1Script` (sym `59:590a`) | -1 | +| `OAKSLAB_SCIENTIST2` | `SPRITE_SCIENTIST` | 8 | 9 | `WALK_UP_DOWN` (r 0,1), `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `OaksAssistant2Script` | -1 | +| `OAKSLAB_SCIENTIST3` | `SPRITE_SCIENTIST` | 1 | 4 | `WANDER` (r 1,1), `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `OaksAssistant3Script` | -1 | + +Oak stands at (4,2); the player talks to him from (4,3) facing UP. Cell (4,3) is +`COLL_FLOOR` in `data/tilesets/lab_collision.asm`, and (4,11)/(5,11) are the +`WARP_CARPET_DOWN` pair, so the approach is a straight walk up the middle of the +room. + +**Scripts of interest** + +`Oak` (`maps/OaksLab.asm:15-54`, sym `59:58c3`) - the only script in this section +that writes a story flag: + +``` +faceplayer / opentext +checkevent EVENT_OPENED_MT_SILVER ; already done -> .CheckPokedex +iftrue .CheckPokedex +checkevent EVENT_TALKED_TO_OAK_IN_KANTO +iftrue .CheckBadges +writetext OakWelcomeKantoText / promptbutton +setevent EVENT_TALKED_TO_OAK_IN_KANTO +.CheckBadges: +readvar VAR_BADGES +ifequal NUM_BADGES, .OpenMtSilver ; 16 +ifequal NUM_JOHTO_BADGES, .Complain ; 8 -> "you're not collecting KANTO BADGES" +sjump .AhGood ; 9..15 -> "come see me when you get them all" +.OpenMtSilver: +writetext OakOpenMtSilverText / promptbutton +setevent EVENT_OPENED_MT_SILVER +sjump .CheckPokedex +.CheckPokedex: +writetext OakLabDexCheckText / waitbutton +special ProfOaksPCBoot +writetext OakLabGoodbyeText / waitbutton / closetext / end +``` + +Bot-relevant: the branch is `ifequal`, not `ifgreater`, and `VAR_BADGES` is the +**popcount of both badge bytes** (`engine/overworld/variables.asm` -> +`CountSetBits` over `wJohtoBadges` + `wKantoBadges`; the port mirrors this at +`src/world/gen2/World.lua:1240-1245`). So the flag is only set on a visit with +exactly 16 badges; 9-15 badges gives `.AhGood` and no flag. Every arm falls +through to `special ProfOaksPCBoot`, which prints the Pokedex rating - budget +several textbox advances for it. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_OPENED_MT_SILVER` | `constants/event_flags.asm:1265` | set by `Oak`; read by `Oak` and by the `VICTORYROADGATE_BLACK_BELT1` object row | **the section's gate.** While clear, the black belt at `VICTORY_ROAD_GATE` (7,5) exists and blocks the ROUTE_28 warps | +| `EVENT_TALKED_TO_OAK_IN_KANTO` | `constants/event_flags.asm:224` | set/read by `Oak` | first-visit greeting only, no gameplay effect | + +--- + +### MAP_VIRIDIAN_CITY + +- Script: `maps/ViridianCity.asm` (`data/maps/scripts.asm:168`) +- Blocks: `maps/ViridianCity.blk` (`data/maps/blocks.asm:235`) +- Header: `data/maps/maps.asm:457` + `map ViridianCity, TILESET_KANTO, TOWN, LANDMARK_VIRIDIAN_CITY, MUSIC_VIRIDIAN_CITY, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Attributes: `data/maps/attributes.asm:259` `map_attributes ViridianCity, VIRIDIAN_CITY, $0f` +- Dimensions: `constants/map_constants.asm:423` `map_const VIRIDIAN_CITY, 20, 18` -> 20x18 blocks = 40x36 cells. Group `VIRIDIAN` (23), map id 3. +- Connections: `connection north, Route2, ROUTE_2, 5`; `connection south, Route1, ROUTE_1, 10`; `connection west, Route22, ROUTE_22, 4` +- Callbacks: `callback MAPCALLBACK_NEWMAP, ViridianCityFlypointCallback` (sym `4e:4005`) -> `setflag ENGINE_FLYPOINT_VIRIDIAN` + +**Warps** (`maps/ViridianCity.asm:219-224`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 32 | 7 | `VIRIDIAN_GYM` | 1 | +| 2 | 21 | 9 | `VIRIDIAN_NICKNAME_SPEECH_HOUSE` | 1 | +| 3 | 23 | 15 | `TRAINER_HOUSE_1F` | 1 | +| 4 | 29 | 19 | `VIRIDIAN_MART` | 2 | +| 5 | 23 | 25 | `VIRIDIAN_POKECENTER_1F` | 1 | + +**Coord events** - `def_coord_events` is empty. + +**BG events** (`maps/ViridianCity.asm:228-234`) + +| x | y | type | script/item | +|---|---|---|---| +| 17 | 17 | `BGEVENT_READ` | `ViridianCitySign` | +| 27 | 7 | `BGEVENT_READ` | `ViridianGymSign` | +| 19 | 1 | `BGEVENT_READ` | `ViridianCityWelcomeSign` | +| 21 | 15 | `BGEVENT_READ` | `TrainerHouseSign` | +| 24 | 25 | `BGEVENT_READ` | `ViridianCityPokecenterSign` -> `jumpstd PokecenterSignScript` | +| 30 | 19 | `BGEVENT_READ` | `ViridianCityMartSign` -> `jumpstd MartSignScript` | + +**Object events** (`maps/ViridianCity.asm:236-240`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VIRIDIANCITY_GRAMPS1` | `SPRITE_GRAMPS` | 18 | 5 | `WANDER` (r 2,2) | `OBJECTTYPE_SCRIPT` | `ViridianCityCoffeeGramps` (sym `4e:4009`) | -1 | +| `VIRIDIANCITY_GRAMPS2` | `SPRITE_GRAMPS` | 30 | 8 | `STANDING_DOWN`, `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `ViridianCityGrampsNearGym` (sym `4e:401e`) | -1 | +| `VIRIDIANCITY_FISHER` | `SPRITE_FISHER` | 6 | 23 | `STANDING_DOWN`, `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT` | `ViridianCityDreamEaterFisher` (sym `4e:4032`) | -1 | +| `VIRIDIANCITY_YOUNGSTER` | `SPRITE_YOUNGSTER` | 17 | 21 | `WANDER` (r 3,3), `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `ViridianCityYoungsterScript` (sym `4e:404d`) | -1 | + +**Scripts of interest** + +`ViridianCityDreamEaterFisher` (`maps/ViridianCity.asm:50-65`, sym `4e:4032`): + +``` +faceplayer / opentext +checkevent EVENT_GOT_TM42_DREAM_EATER +iftrue .GotDreamEater +writetext ViridianCityDreamEaterFisherText / promptbutton +verbosegiveitem TM_DREAM_EATER +iffalse .NoRoomForDreamEater ; bag full -> flag NOT set, retryable +setevent EVENT_GOT_TM42_DREAM_EATER +.GotDreamEater: writetext ...GotDreamEaterText / waitbutton +.NoRoomForDreamEater: closetext / end +``` + +`TM_DREAM_EATER` is item `$ea` (`constants/item_constants.asm:263`, the 42nd +`add_tm` after `DEF TM01 EQU const_value` at line 219 - the two bare `const` +rows `ITEM_C3` and `ITEM_DC` do not consume TM numbers, which is why TM42 is +Dream Eater and TM47 is Steel Wing). + +**Cut gate (verified from block data)** + +`ViridianCity.blk` block (4,11) is `$34`, whose `tilecoll` row is +`CUT_TREE, FLOOR, WALL, WALL` (`data/tilesets/kanto_collision.asm`, block `34`), +so the `COLL_CUT_TREE` quadrant is the top-left = **cell (8,22)**. A BFS over the +collision grid says the fisher's pocket (x=4..7, y=22..26) has exactly one +non-ledge entrance and it is that tile. Route: + +- Pokecenter door (23,25) -> step down to (23,26) +- (23,26) -> (14,26) -> (14,23) -> (10,23) -> (10,22) -> (9,22) +- face LEFT at (9,22), Cut (8,22) +- (8,22) -> (7,22) -> (6,22) -> (6,23) faces the fisher at... he *is* at (6,23); + stand at (6,22) and face DOWN, or (7,23) and face LEFT. + +The pocket's south edge (x=4..7, y=26) is `COLL_HOP_DOWN`, i.e. one-way out. + +The only other `$34`/`$60`/`$32`/`$33`/`$35` block on this map is (7,2) = +cell (14,4), which is not on any route in this section. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| TM42 Dream Eater | talk to the fisher at (6,23) after Cutting (8,22) | `ViridianCityDreamEaterFisher` | `EVENT_GOT_TM42_DREAM_EATER` (`constants/event_flags.asm:223`) | + +**Trainers** - none on this map. + +**Wild encounters** - `FISHGROUP_POND` per the header; no `def_grass_wildmons +VIRIDIAN_CITY` row is needed by this section. + +--- + +### MAP_ROUTE_22 + +- Script: `maps/Route22.asm` (`data/maps/scripts.asm:169`) - 28 lines, one sign and one warp, nothing else +- Blocks: `maps/Route22.blk` (`data/maps/blocks.asm:310`, 180 bytes) +- Header: `data/maps/maps.asm:456` + `map Route22, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_22, MUSIC_ROUTE_3, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Attributes: `data/maps/attributes.asm:264` `map_attributes Route22, ROUTE_22, $2c` +- Dimensions: `constants/map_constants.asm:422` `map_const ROUTE_22, 20, 9` -> 20x9 blocks = 40x18 cells. Group `VIRIDIAN` (23), map id 2. +- Connections: `connection east, ViridianCity, VIRIDIAN_CITY, -4` (only one). Crossing east from `(39, y)` lands on Viridian City `(0, y + 8)`; crossing west from Viridian `(0, y)` lands on Route 22 `(39, y - 8)`. +- Scene scripts / callbacks: both empty. + +**Warps** (`maps/Route22.asm:19-20`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 5 | `VICTORY_ROAD_GATE` | 1 | + +**Coord events** - none. + +**BG events** (`maps/Route22.asm:24-25`) + +| x | y | type | script/item | +|---|---|---|---| +| 15 | 7 | `BGEVENT_READ` | `VictoryRoadEntranceSign` (sym `4e:44f9`) - read from (14,7) facing RIGHT | + +**Object events** - `def_object_events` is empty. **There are no trainers and no +item balls on Route 22**, which matches the FAQ. The FAQ's "Items on Route 22: +TM42 Dream Eater" is wrong; that TM is a Viridian City NPC gift (above). + +**Traversal (verified by BFS over `Route22.blk` + `kanto_collision.asm`)** + +The map is a set of shelves joined by one-way ledges. Entering from Viridian at +`(39, 6..9)`, the *only* path to the gate warp is: + +``` +(39,7) -> (35,7) -> (35,11) +(35,12) is COLL_HOP_DOWN -> hop to (35,14) [ledge, one-way] +(35,14) -> (33,14) -> (33,13) -> (33,12) [the one non-ledge gap] +(33,12) -> (33,11) grass -> (31,8) -> (31,7) -> (31,5) [the y=4..5 plaza] +(31,5) -> (18,5) +(18,6) is COLL_HOP_DOWN -> hop to (18,8) [ledge, one-way] +(18,8) grass -> (18,12) -> (14,12) -> (14,8) -> (13,7) -> (13,6) -> (13,5) warp 1 +``` + +Ledge semantics: a `COLL_HOP_*` tile is `LAND_TILE` and can be *walked onto*; +pressing the matching direction while standing on it jumps **two** cells, over +the wall beneath. There is no ledge-free route: `(18,7)` and `(35,13)` are +`COLL_WALL`. + +Reachable tall grass in that component: `(18..21, 8..11)` and `(30..33, 8..11)`. +Surfable water adjacent to it: `(22..25, 6)` from the plaza row y=5, and +`(22..25, 9)` from the (22..25,10..11) shelf. + +**Wild encounters** - `data/wild/kanto_grass.asm:1033`, `def_grass_wildmons ROUTE_22`, +encounter rate `10 percent` in all three time slots. Slot probabilities are +30/30/20/10/5/4/1 (`data/wild/probabilities.asm:6-15`). + +| slot | morn | day | nite | +|---|---|---|---| +| 1 (30%) | L3 RATTATA | L3 RATTATA | L3 RATTATA | +| 2 (30%) | L3 SPEAROW | L3 SPEAROW | L3 RATTATA | +| 3 (20%) | L5 SPEAROW | L5 SPEAROW | L5 RATTATA | +| 4 (10%) | L4 DODUO | L4 DODUO | L4 RATTATA | +| 5 (5%) | L6 PONYTA | L6 PONYTA | L6 PONYTA | +| 6 (4%) | L7 FEAROW | L7 FEAROW | L7 RATTATA | +| 7 (1%) | L7 FEAROW | L7 FEAROW | L7 RATTATA | + +Water (`data/wild/kanto_water.asm:68`, `def_water_wildmons ROUTE_22`, rate +`2 percent`, slot probabilities 60/30/10): L10 POLIWAG, L5 POLIWAG, L10 POLIWHIRL. +The FAQ's "#060 Poliwag (surfing)" is right; it omits Doduo, Ponyta and Fearow +from the grass list. + +Fishing: `FISHGROUP_POND` (`data/wild/fish.asm:72-85`) - Old rod MAGIKARP/POLIWAG, +Good rod MAGIKARP/POLIWAG + a time group, Super rod POLIWAG/MAGIKARP + a time group. + +Headbutt: Route 22 has **no** row in `data/wild/treemon_maps.asm`. + +--- + +### MAP_VICTORY_ROAD_GATE + +- Script: `maps/VictoryRoadGate.asm` (`data/maps/scripts.asm:452`) +- Blocks: `maps/VictoryRoadGate.blk` (`data/maps/blocks.asm:943`, 90 bytes) +- Header: `data/maps/maps.asm:467` + `map VictoryRoadGate, TILESET_GATE, GATE, LANDMARK_ROUTE_26, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_DAY, FISHGROUP_SHORE` + (note the landmark is `LANDMARK_ROUTE_26`, not Route 22 or 28 - relevant if a + bot keys anything off the Pokegear location readout) +- Attributes: `data/maps/attributes.asm:666` `map_attributes VictoryRoadGate, VICTORY_ROAD_GATE, $00`, no connections +- Dimensions: `constants/map_constants.asm:433` `map_const VICTORY_ROAD_GATE, 10, 9` -> 10x9 blocks = 20x18 cells. Group `VIRIDIAN` (23), map id 13. +- Scene variable: `data/maps/scenes.asm:16` `scene_var VICTORY_ROAD_GATE, wVictoryRoadGateSceneID` (sym `01:d6bf`) +- Scene scripts (`maps/VictoryRoadGate.asm:7-9`): `SCENE_VICTORYROADGATE_BADGE_CHECK` = 0 -> `VictoryRoadGateNoop1Scene` (bare `end`); `SCENE_VICTORYROADGATE_NOOP` = 1 -> `VictoryRoadGateNoop2Scene` (bare `end`). Both constants are minted by the `scene_script` macro's `scene_const` (`macros/scripts/maps.asm:12-33`), which is why grepping `constants/` for them finds nothing. + +**Warps** (`maps/VictoryRoadGate.asm:101-109`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 7 | `ROUTE_22` | 1 | +| 2 | 18 | 7 | `ROUTE_22` | 1 | +| 3 | 9 | 17 | `ROUTE_26` | 1 | +| 4 | 10 | 17 | `ROUTE_26` | 1 | +| 5 | 9 | 0 | `VICTORY_ROAD` | 1 | +| 6 | 10 | 0 | `VICTORY_ROAD` | 1 | +| 7 | 1 | 7 | `ROUTE_28` | 2 | +| 8 | 2 | 7 | `ROUTE_28` | 2 | + +**Coord events** (`maps/VictoryRoadGate.asm:111-112`) + +| scene | x | y | script label | effect | +|---|---|---|---|---| +| `SCENE_VICTORYROADGATE_BADGE_CHECK` (0) | 10 | 11 | `VictoryRoadGateBadgeCheckScript` (sym `5f:4fdc`) | `turnobject PLAYER, LEFT` then the officer's speech; `< 8` Johto badges -> `applymovement PLAYER, VictoryRoadGateStepDownMovement` (one step DOWN); 8 badges -> `setscene SCENE_VICTORYROADGATE_NOOP` and the trip-wire never fires again | + +A bot arriving from Route 22 (warp 1/2) never crosses (10,11): the east-west hall +is at y=5 and the badge check sits in the southern stub from Route 26. The check +is therefore *not* a gate on this section's route. + +**BG events** - `def_bg_events` is empty. + +**Object events** (`maps/VictoryRoadGate.asm:116-119`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `VICTORYROADGATE_OFFICER` | `SPRITE_OFFICER` | 8 | 11 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateOfficerScript` (sym `5f:4fe2`) | -1 | +| `VICTORYROADGATE_BLACK_BELT1` | `SPRITE_BLACK_BELT` | 7 | 5 | `STANDING_RIGHT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateLeftBlackBeltScript` (sym `5f:5000`) | `EVENT_OPENED_MT_SILVER` | +| `VICTORYROADGATE_BLACK_BELT2` | `SPRITE_BLACK_BELT` | 12 | 5 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `VictoryRoadGateRightBlackBeltScript` (sym `5f:5003`) | `EVENT_FOUGHT_SNORLAX` | + +**Geometry (from `VictoryRoadGate.blk` + `data/tilesets/gate_collision.asm`)** + +The building is a plus: one 20-wide, **one-cell-tall** hall at y=5, a north stub +(x=8..11, y=0..4) to Victory Road, a south stub (x=8..11, y=6..17) to Route 26 +with the officer's counter at y=10..13, and two 4-wide vestibules at y=6..7 - +west (x=0..3, the Route 28 warps) and east (x=16..19, the Route 22 warps). +Cells (7,5) and (12,5) are the only tiles joining the centre to the west and east +vestibules respectively, and each is exactly where a black belt stands. + +BFS from the Route 22 warp (17,7), toggling the two NPCs: + +| NPCs present | reach `ROUTE_28` warp (1,7) | reach Victory Road door (9,0) | reach badge coord (10,11) | +|---|---|---|---| +| neither | yes | yes | yes | +| left only (`EVENT_OPENED_MT_SILVER` clear) | **no** | yes | yes | +| right only (`EVENT_FOUGHT_SNORLAX` clear) | no | no | no | +| both | no | no | no | + +**Trainers** - none. + +--- + +### MAP_ROUTE_28 + +- Script: `maps/Route28.asm` (`data/maps/scripts.asm:131`) - one sign, one hidden item, no NPCs +- Blocks: `maps/Route28.blk` (`data/maps/blocks.asm:15`, 180 bytes) +- Header: `data/maps/maps.asm:396` + `map Route28, TILESET_KANTO, ROUTE, LANDMARK_ROUTE_28, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Attributes: `data/maps/attributes.asm:174` `map_attributes Route28, ROUTE_28, $2c` +- Dimensions: `constants/map_constants.asm:366` `map_const ROUTE_28, 20, 9` -> 20x9 blocks = 40x18 cells. Group `SILVER` (19), map id 1. +- Connections: `connection west, SilverCaveOutside, SILVER_CAVE_OUTSIDE, -9`. Crossing west from `(0, y)` lands on Silver Cave Outside `(39, y + 18)`; crossing east from Silver Cave Outside `(39, y)` lands on Route 28 `(0, y - 18)`. +- Scene scripts / callbacks: both empty. **Route 28 has no flypoint callback** - the Fly destination near Mt. Silver is `SILVER_CAVE_OUTSIDE`. + +**Warps** (`maps/Route28.asm:19-21`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 7 | 3 | `ROUTE_28_STEEL_WING_HOUSE` | 1 | +| 2 | 33 | 5 | `VICTORY_ROAD_GATE` | 7 | + +**Coord events** - none. + +**BG events** (`maps/Route28.asm:25-27`) + +| x | y | type | script/item | +|---|---|---|---| +| 31 | 5 | `BGEVENT_READ` | `Route28Sign` (sym `4a:4d4d`) - read from (30,5) facing RIGHT | +| 25 | 2 | `BGEVENT_ITEM` | `Route28HiddenRareCandy` (sym `4a:4d50`) = `hiddenitem RARE_CANDY, EVENT_ROUTE_28_HIDDEN_RARE_CANDY` | + +`RARE_CANDY` is item `$20` (`constants/item_constants.asm:40`). The hidden-item +row is three bytes (`dwb flag, item`), not a script; the port carries it on the +bg event as `hiddenItem = { item, event }` (`src/world/gen2/HiddenItems.lua:7-12`). + +**Object events** - `def_object_events` is empty. No trainers, no item balls. + +**Geometry: Route 28 is two disconnected halves** + +BFS over `Route28.blk` from the Victory Road Gate landing (33,6): + +- the **main body** reaches the sign approach (30,5) and the west edge only at + y=12..13 -> Silver Cave Outside (39, 30..31); +- it does **not** reach (7,3), (25,3) or any of y=3..4. + +BFS from the west edge at (0,3) - the **isolated north-west strip** - +reaches the house door (7,3) and the hidden-item approach (25,3), and then falls +one-way into the main body over the `COLL_HOP_DOWN` row at y=4 (x=4..9, landing +in the grass at y=6). So: + +- entering the strip: only from `SILVER_CAVE_OUTSIDE (39,21)` -> `ROUTE_28 (0,3)`, + which itself needs two Cuts (see below); +- inside the strip: (0,3) -> (5,3) -> (5,4) -> (7,4) -> north into the door (7,3); + and (7,4) -> (21,4) -> (21,3) -> (25,3), then face UP for the hidden Rare Candy + at (25,2); +- leaving the strip: hop any of the y=4 ledges (x=4..9) down into the main body, + which is one-way. Re-entering means going round through Silver Cave Outside again. + +Main-body route from the gate to Silver Cave Outside: (33,6) -> (28,6) -> (28,8), +hop (27,8)->(27,10), west along y=10 to (18,10) -> (18,9) -> (10,9) -> (10,10) -> +(8,10), hop (8,10)->(8,12), west along y=12 to (0,12). + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| Rare Candy | hidden, face (25,2) from (25,3) and press A, or use the Itemfinder | `Route28HiddenRareCandy` bg_event | `EVENT_ROUTE_28_HIDDEN_RARE_CANDY` (`constants/event_flags.asm:173`) | + +The FAQ lists "Items: TM47 Steel Wing" for Route 28 and never mentions the Rare +Candy. TM47 is in the house, not on the route. + +**Wild encounters** - `data/wild/kanto_grass.asm:1227`, `def_grass_wildmons ROUTE_28`, +rate `10 percent` all day. The table is `IF DEF(_GOLD) / ELIF DEF(_SILVER)`: + +| slot | Gold morn/day | Gold nite | Silver morn/day | Silver nite | +|---|---|---|---|---| +| 1 (30%) | L39 TANGELA | L39 TANGELA | L39 TANGELA | L39 TANGELA | +| 2 (30%) | L40 PONYTA | L40 PONYTA | L40 PONYTA | L40 PONYTA | +| 3 (20%) | L40 URSARING | L40 URSARING | L40 DONPHAN | L40 DONPHAN | +| 4 (10%) | L42 RAPIDASH | L40 SNEASEL | L42 RAPIDASH | L40 SNEASEL | +| 5 (5%) | L41 DODUO | L42 RAPIDASH | L41 DODUO | L42 RAPIDASH | +| 6 (4%) | L43 DODRIO | L42 RAPIDASH | L43 DODRIO | L42 RAPIDASH | +| 7 (1%) | L43 DODRIO | L42 RAPIDASH | L43 DODRIO | L42 RAPIDASH | + +So on **Gold** the FAQ's Donphan does not appear (Ursaring takes that slot), and +**Sneasel is nite-only**. Tangela is the 30% lead slot at every time of day, +which makes the FAQ's "consider catching a Tangela" cheap to satisfy. + +Water (`data/wild/kanto_water.asm:110`, rate `2 percent`): L40 POLIWAG, L35 +POLIWAG, L40 POLIWHIRL. Fishing: `FISHGROUP_POND`, same table as Route 22. +Headbutt: `data/wild/treemon_maps.asm:9` `treemon_map ROUTE_28, TREEMON_SET_NONE` +- headbutting a Route 28 tree yields nothing. + +--- + +### MAP_ROUTE_28_STEEL_WING_HOUSE + +- Script: `maps/Route28SteelWingHouse.asm` (`data/maps/scripts.asm:388`) +- Blocks: `data/maps/blocks.asm:181` (`Route28SteelWingHouse_Blocks`) +- Header: `data/maps/maps.asm:399` + `map Route28SteelWingHouse, TILESET_HOUSE, INDOOR, LANDMARK_ROUTE_28, MUSIC_AZALEA_TOWN, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Attributes: `data/maps/attributes.asm:624`, no connections +- Dimensions: `constants/map_constants.asm:369` `map_const ROUTE_28_STEEL_WING_HOUSE, 4, 4` -> 8x8 cells. Group `SILVER` (19), map id 4. +- Scene scripts: one `scene_script Route28SteelWingHouseNoopScene` marked "unusable" in the source; body is a bare `end`. + +**Warps** (`maps/Route28SteelWingHouse.asm:74-76`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 2 | 7 | `ROUTE_28` | 1 | +| 2 | 3 | 7 | `ROUTE_28` | 1 | + +**Coord events** - none. + +**BG events** (`maps/Route28SteelWingHouse.asm:80-82`) + +| x | y | type | script/item | +|---|---|---|---| +| 0 | 1 | `BGEVENT_READ` | `CelebrityHouseBookshelf` -> `jumpstd MagazineBookshelfScript` | +| 1 | 1 | `BGEVENT_READ` | `CelebrityHouseBookshelf` | + +**Object events** (`maps/Route28SteelWingHouse.asm:84-86`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE28STEELWINGHOUSE_CELEBRITY` | `SPRITE_COOLTRAINER_F` | 2 | 3 | `STANDING_DOWN`, `PAL_NPC_GREEN` | `OBJECTTYPE_SCRIPT` | `Celebrity` (sym `5c:4be9`) | -1 | +| `ROUTE28STEELWINGHOUSE_FEAROW` | `SPRITE_MOLTRES` | 6 | 5 | `SPRITEMOVEDATA_POKEMON`, `PAL_NPC_BROWN` | `OBJECTTYPE_SCRIPT` | `CelebritysFearow` (sym `5c:4c06`) | -1 | + +The pet is a Fearow but reuses `SPRITE_MOLTRES` recoloured with `PAL_NPC_BROWN`; +`CelebritysFearow` does `cry FEAROW`. Worth knowing if a bot asserts on sprite ids. + +**Scripts of interest** + +`Celebrity` (`maps/Route28SteelWingHouse.asm:14-31`, sym `5c:4be9`): + +``` +faceplayer / opentext +checkevent EVENT_GOT_TM47_STEEL_WING +iftrue .AlreadyGotItem +writetext CelebrityText1 / promptbutton +verbosegiveitem TM_STEEL_WING +iffalse .Done ; bag full -> flag NOT set, retryable +setevent EVENT_GOT_TM47_STEEL_WING +.Done: closetext / end +``` + +Note the shape differs from the Dream Eater fisher: there is no "thanks" text +after a successful give, the script just closes. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| TM47 Steel Wing (`$ef`) | talk to `Celebrity` at (2,3) from (2,4) facing UP | `Celebrity` | `EVENT_GOT_TM47_STEEL_WING` (`constants/event_flags.asm:125`) | + +--- + +### MAP_SILVER_CAVE_OUTSIDE (east edge only - the rest is the next section) + +- Script: `maps/SilverCaveOutside.asm` (`data/maps/scripts.asm:124`) +- Blocks: `maps/SilverCaveOutside.blk` (`data/maps/blocks.asm:559`, 360 bytes) +- Header: `data/maps/maps.asm:397` + `map SilverCaveOutside, TILESET_KANTO, TOWN, LANDMARK_SILVER_CAVE, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_AUTO, FISHGROUP_POND` +- Attributes: `data/maps/attributes.asm:164` `map_attributes SilverCaveOutside, SILVER_CAVE_OUTSIDE, $2c` +- Dimensions: `constants/map_constants.asm:367` `map_const SILVER_CAVE_OUTSIDE, 20, 18` -> 40x36 cells. Group `SILVER` (19), map id 2. +- Connections: `connection east, Route28, ROUTE_28, 9` +- Callbacks: `callback MAPCALLBACK_NEWMAP, SilverCaveOutsideFlypointCallback` (sym `49:5f1a`) -> `setflag ENGINE_FLYPOINT_SILVER_CAVE`. Spawn `SPAWN_MT_SILVER` = (23,20) (`data/maps/spawn_points.asm:39`), i.e. the tile below the Pokecenter door - this is the "fly back to the Pokémon Center on Route 28" the FAQ means. + +**Warps** (`maps/SilverCaveOutside.asm:27-29`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 19 | `SILVER_CAVE_POKECENTER_1F` | 1 | +| 2 | 18 | 11 | `SILVER_CAVE_ROOM_1` | 1 | + +**BG events** (`maps/SilverCaveOutside.asm:33-36`) + +| x | y | type | script/item | +|---|---|---|---| +| 24 | 19 | `BGEVENT_READ` | `MtSilverPokecenterSign` (sym `49:5f1e`) | +| 17 | 13 | `BGEVENT_READ` | `MtSilverSign` (sym `49:5f21`) | +| 9 | 25 | `BGEVENT_ITEM` | `SilverCaveOutsideHiddenFullRestore` (sym `49:5f24`) = `hiddenitem FULL_RESTORE, EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE` (`constants/event_flags.asm:194`) - **next section's**, and not reachable from the east side without Surf | + +**Coord events / object events** - both empty. + +**The two Cut trees (verified from block data)** + +`data/collision/field_move_blocks.asm:24-31`, `CutTreeBlockPointers.kanto`, lists +the cuttable blocks for `TILESET_KANTO`: `$0b -> $0a` (grass, animation 1) and +`$32 -> $6d`, `$33 -> $6c`, `$34 -> $6f`, `$35 -> $4c`, `$60 -> $6e` (trees, +animation 0). `SilverCaveOutside.blk` contains exactly two of them: + +| block coord | block id | `tilecoll` row | `COLL_CUT_TREE` cell | replacement block | +|---|---|---|---|---| +| (17,11) | `$60` | `FLOOR, WALL, CUT_TREE, WALL` | **(34,23)** | `$6e` | +| (15,12) | `$35` | `FLOOR, CUT_TREE, WALL, FLOOR` | **(31,24)** | `$4c` | + +BFS from the Route 28 landing (39,30): + +- with no cuts: the Pokecenter door (23,19), the cave mouth (18,11) and the + approach tile (30,24) are reachable; (39,21) is **not**; +- after cutting (31,24) only: (34,24) becomes reachable, (39,21) still not; +- after cutting both: (39,21) is reachable -> `ROUTE_28 (0,3)`. + +Route from the Pokecenter door to the house, waypoint by waypoint: + +``` +(23,19) door -> (23,20) -> (25,20) -> (25,22) -> (30,22) -> (30,24) +face RIGHT, Cut (31,24) +(31,24) -> (31,25) -> (34,25) -> (34,24) +face UP, Cut (34,23) +(34,23) -> (34,21) -> (39,21) -> cross east -> ROUTE_28 (0,3) +(0,3) -> (5,3) -> (5,4) -> (7,4) -> face UP into warp 1 at (7,3) +``` + +--- + +### MAP_SILVER_CAVE_POKECENTER_1F + +- Script: `maps/SilverCavePokecenter1F.asm` (`data/maps/scripts.asm:387`) +- Header: `data/maps/maps.asm:398` + `map SilverCavePokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_SILVER_CAVE, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Attributes: `data/maps/attributes.asm:623` +- Dimensions: `constants/map_constants.asm:368` `map_const SILVER_CAVE_POKECENTER_1F, 5, 4` -> 10x8 cells. Group `SILVER` (19), map id 3. + +**Warps** (`maps/SilverCavePokecenter1F.asm:33-36`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `SILVER_CAVE_OUTSIDE` | 1 | +| 2 | 4 | 7 | `SILVER_CAVE_OUTSIDE` | 1 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Object events** (`maps/SilverCavePokecenter1F.asm:42-44`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILVERCAVEPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `SilverCavePokecenter1FNurseScript` (sym `5c:4b2b`) -> `jumpstd PokecenterNurseScript` | -1 | +| `SILVERCAVEPOKECENTER1F_GRANNY` | `SPRITE_GRANNY` | 1 | 5 | `STANDING_LEFT` (r 2,1) | `OBJECTTYPE_SCRIPT` | `SilverCavePokecenter1FGrannyScript` (sym `5c:4b2e`) | -1 | + +No mart clerk on this map - the FAQ's "fly to Mahogany Town's Pokemart to buy +some" is correct precisely because there is nothing to buy here. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Route 28 / Mt. Silver | `maps/VictoryRoadGate.asm:118` object row `VICTORYROADGATE_BLACK_BELT1` at (7,5) + `engine/overworld/map_objects_2.asm:32` `CheckObjectFlag` | `EVENT_OPENED_MT_SILVER` set, which hides the NPC and frees the only tile between the gate's centre and its west vestibule | talk to `Oak` (`maps/OaksLab.asm:15`) with `VAR_BADGES == NUM_BADGES` (16; `constants/ram_constants.asm:273`) | +| Route 22 from the gate | `maps/VictoryRoadGate.asm:119` object row `VICTORYROADGATE_BLACK_BELT2` at (12,5) | `EVENT_FOUGHT_SNORLAX` (`constants/event_flags.asm:1266`) | set long before this section (Kanto). Listed because it uses the same mechanism and a bot that clears flags for testing will wall itself in | +| Victory Road Gate south corridor | `maps/VictoryRoadGate.asm:112` coord event (10,11) -> `VictoryRoadGateBadgeCheckScript` | `readvar VAR_BADGES` `ifgreater NUM_JOHTO_BADGES - 1` i.e. >= 8 badges | already satisfied; and the Route 22 -> Route 28 path never touches (10,11) | +| Dream Eater fisher (Viridian) | `data/collision/field_move_blocks.asm:24` `.kanto` block `$34` at Viridian block (4,11) -> `COLL_CUT_TREE` at cell (8,22) | HM01 Cut in the party **and** `ENGINE_HIVEBADGE` | `engine/events/overworld.asm:1741` `TryCutOW`: `CheckPartyMove CUT`, then `ld de, ENGINE_HIVEBADGE / call CheckEngineFlag`; failure runs `CantCutScript` | +| Steel Wing house / Route 28 west strip | two `COLL_CUT_TREE` cells on `SILVER_CAVE_OUTSIDE`: (31,24) (block `$35`) and (34,23) (block `$60`) | same Cut + `ENGINE_HIVEBADGE` check | as above. Both trees are required - cutting only (31,24) does not open (39,21) | +| Fly to Pallet / Viridian / Mt. Silver | `engine/events/overworld.asm:543-545` `FlyFunction.TryFly`: `ld de, ENGINE_STORMBADGE / call CheckBadge`, then `GetMapEnvironment` / `CheckOutdoorMap` | STORMBADGE + HM02 Fly + the destination's `ENGINE_FLYPOINT_*` bit | `ENGINE_FLYPOINT_PALLET` (`constants/engine_flags.asm:67`), `ENGINE_FLYPOINT_VIRIDIAN` (68), `ENGINE_FLYPOINT_SILVER_CAVE` (90), each set by that map's `MAPCALLBACK_NEWMAP` the first time you walk in | +| Surf (Route 22 / Route 28 water) | `engine/events/overworld.asm:340` and `:490`, `ld de, ENGINE_FOGBADGE` | FOGBADGE + HM03 Surf | optional in this section | +| Route 22 traversal | `Route22.blk` collision: `(35,12)` and `(18,6)` are `COLL_HOP_DOWN` with `COLL_WALL` beneath | ledge hopping (walk onto the ledge tile, then press the matching direction to jump two cells) | no item needed, but there is **no** ledge-free route to the gate warp | +| Route 28 west strip re-entry | `Route28.blk` y=4 ledge row (x=4..9) | one-way; falling off it drops you into the main body | re-enter via `SILVER_CAVE_OUTSIDE (39,21)` | + +## 4. Bot checklist + +Preconditions for the whole section: 16 badges, HM01 Cut + HIVEBADGE, HM02 Fly + +STORMBADGE, `EVENT_FOUGHT_SNORLAX` set, `ENGINE_FLYPOINT_PALLET` and +`ENGINE_FLYPOINT_VIRIDIAN` already visited. + +| # | Map | Target | Intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | anywhere outdoor | `SPAWN_PALLET` | Fly | STORMBADGE, `ENGINE_FLYPOINT_PALLET` | player at `PALLET_TOWN` (5,6) | +| 2 | `PALLET_TOWN` | (12,11) | walk (5,6)->(9,6)->(9,12)->(12,12)->(12,11) | - | warp to `OAKS_LAB` (4,11) | +| 3 | `OAKS_LAB` | (4,3) facing UP | walk, then talk to `OAKSLAB_OAK` | `VAR_BADGES == 16` | `EVENT_OPENED_MT_SILVER` set; expect `EVENT_TALKED_TO_OAK_IN_KANTO` on the first visit and a `ProfOaksPCBoot` dex-rating textbox chain | +| 4 | `OAKS_LAB` | (4,11) | walk into warp 1 | - | back on `PALLET_TOWN` (12,11) | +| 5 | anywhere outdoor | `SPAWN_VIRIDIAN` | Fly | `ENGINE_FLYPOINT_VIRIDIAN` | player at `VIRIDIAN_CITY` (23,26) | +| 6 (optional) | `VIRIDIAN_CITY` | (9,22) facing LEFT | walk (23,26)->(14,26)->(14,23)->(10,23)->(10,22)->(9,22), then Cut | Cut + HIVEBADGE, `EVENT_GOT_TM42_DREAM_EATER` clear | block at (8,22) replaced with `$6f` | +| 7 (optional) | `VIRIDIAN_CITY` | `VIRIDIANCITY_FISHER` at (6,23), talk from (6,22) facing DOWN | talk | bag has room | TM42 Dream Eater; `EVENT_GOT_TM42_DREAM_EATER` set | +| 8 | `VIRIDIAN_CITY` | west edge, x=0 at y=15 | walk (0,17)->(0,15) then step LEFT | - | map change to `ROUTE_22` (39,7) | +| 9 | `ROUTE_22` | (35,12) | walk (39,7)->(35,7)->(35,11)->(35,12) | - | standing on a `COLL_HOP_DOWN` tile | +| 10 | `ROUTE_22` | press DOWN | ledge hop | - | player at (35,14) | +| 11 | `ROUTE_22` | (33,12) | walk (35,14)->(33,14)->(33,13)->(33,12) | - | - | +| 12 | `ROUTE_22` | (31,5) | walk (33,12)->(33,11)->(31,11)->(31,8)->(31,7)->(31,6)->(31,5) - crosses the tall grass at (30..33, 8..11) | - | expect wild encounters, 10% per step in grass | +| 13 | `ROUTE_22` | (18,6) | walk west along y=5 to (18,5), then DOWN onto (18,6) | - | standing on a ledge tile | +| 14 | `ROUTE_22` | press DOWN | ledge hop | - | player at (18,8), in the west grass patch | +| 15 | `ROUTE_22` | (13,5) | walk (18,8)->(18,12)->(14,12)->(14,8)->(13,8)->(13,7)->(13,6)->(13,5) | - | warp to `VICTORY_ROAD_GATE` (17,7) | +| 16 | `VICTORY_ROAD_GATE` | (1,7) | walk (17,7)->(17,5)->west along y=5->(1,5)->(1,7) | `EVENT_OPENED_MT_SILVER` **and** `EVENT_FOUGHT_SNORLAX` set (both black belts hidden) | warp to `ROUTE_28` (33,5) | +| 17 | `ROUTE_28` | (0,12) | walk (33,5)->(33,6)->(28,6)->(28,8), hop (27,8)->(27,10), west y=10 to (18,10)->(18,9)->(10,9)->(10,10)->(8,10), hop ->(8,12), west to (0,12) | - | map change to `SILVER_CAVE_OUTSIDE` (39,30) | +| 18 | `SILVER_CAVE_OUTSIDE` | (23,20) | walk north-west to the Pokecenter door | - | `ENGINE_FLYPOINT_SILVER_CAVE` set by `MAPCALLBACK_NEWMAP` | +| 19 | `SILVER_CAVE_OUTSIDE` | (23,19) | step UP into warp 1 | - | `SILVER_CAVE_POKECENTER_1F` (3,7) | +| 20 | `SILVER_CAVE_POKECENTER_1F` | nurse at (3,1), talk from (3,2) facing UP | heal | - | party restored | +| 21 | `SILVER_CAVE_POKECENTER_1F` | (3,7) | step DOWN into warp 1 | - | `SILVER_CAVE_OUTSIDE` (23,19) | +| 22 | `SILVER_CAVE_OUTSIDE` | (30,24) facing RIGHT | walk (23,20)->(25,20)->(25,22)->(30,22)->(30,24), then Cut | Cut + HIVEBADGE | (31,24) replaced with `$4c` | +| 23 | `SILVER_CAVE_OUTSIDE` | (34,24) facing UP | walk (31,24)->(31,25)->(34,25)->(34,24), then Cut | Cut + HIVEBADGE | (34,23) replaced with `$6e` | +| 24 | `SILVER_CAVE_OUTSIDE` | (39,21) | walk (34,23)->(34,21)->(39,21), step RIGHT | - | map change to `ROUTE_28` (0,3) | +| 25 | `ROUTE_28` | (25,3) facing UP | walk (0,3)->(5,3)->(5,4)->(7,4)->(21,4)->(21,3)->(25,3), press A | `EVENT_ROUTE_28_HIDDEN_RARE_CANDY` clear | Rare Candy; flag set | +| 26 | `ROUTE_28` | (7,3) | walk back west along y=4 to (7,4), step UP | - | warp to `ROUTE_28_STEEL_WING_HOUSE` (2,7) | +| 27 | `ROUTE_28_STEEL_WING_HOUSE` | `Celebrity` at (2,3), talk from (2,4) facing UP | talk | bag has room, `EVENT_GOT_TM47_STEEL_WING` clear | TM47 Steel Wing; flag set | +| 28 | `ROUTE_28_STEEL_WING_HOUSE` | (2,7) | step DOWN into warp 1 | - | `ROUTE_28` (7,3) | +| 29 | `ROUTE_28` | any y=4 ledge (x=4..9) | walk to it and press DOWN | - | one-way drop into the Route 28 main body (grass at y=6) - the strip cannot be re-entered from here | +| 30 | - | shopping run | Fly to Mahogany, buy, Fly back to `SPAWN_MT_SILVER` | `ENGINE_FLYPOINT_SILVER_CAVE` set in step 18 | ready for `SILVER_CAVE_OUTSIDE` warp 2 at (18,11) - next section | + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers, dimensions, warps / coord / bg / object event tables for all of these maps | `src/import/RomExtractorGen2.lua:787-862` (`warps`, `coordEvents`, `bgEvents`, `objects`), consumed by `src/world/gen2/Map.lua` | implemented, and generic - no per-map work needed | +| Map connections and the offset landing maths | `src/world/gen2/Map.lua:71-92` `Map.connectionLanding` | implemented (same block-offset maths this doc's route uses) | +| `object_event` event-flag masking (the black-belt gate) | `src/world/gen2/Events.lua:40-44` `Events:objectVisible`, called from `src/world/gen2/World.lua:5106` and `:5122` | implemented, with the correct "flag set -> hidden" polarity | +| `Oak`'s opcode chain (`checkevent` / `readvar` / `setevent` / `special`) | `src/script/gen2/Vm.lua:187` (`checkevent`), `:670` (`readvar`), `src/world/gen2/World.lua:1240-1245` (`VAR_BADGES` = popcount of both badge bytes) | implemented | +| `special ProfOaksPCBoot` (the dex rating after every Oak conversation) | `src/script/gen2/Specials.lua:1778` `H.ProfOaksPCBoot` | implemented, including the `OakRatings` table and the fanfare wait | +| `verbosegiveitem` for TM42 / TM47, including the bag-full `iffalse` arm | `src/script/gen2/Vm.lua:490-498` | implemented | +| `hiddenitem` (Route 28 Rare Candy) via `BGEVENT_ITEM` | `src/world/gen2/HiddenItems.lua` (whole file), wired at `src/world/gen2/World.lua:30` and `:3423` | implemented | +| `jumpstd` for `PokecenterNurseScript` / `MagazineBookshelfScript` / `DifficultBookshelfScript` / `PokecenterSignScript` / `MartSignScript` | `src/script/gen2/Vm.lua:742-747` | implemented (std scripts are extracted and run through the same VM) | +| Cut: badge + party-move check, `CutTreeBlockPointers` lookup, block replacement | `src/world/gen2/FieldMoves.lua:101-107` (badge map), `:183-237` (`field_move_blocks.asm` transcribed), `:454`, `:605-619` | implemented | +| Fly: STORMBADGE check, flypoint table, `ENGINE_FLYPOINT_*` bits | `src/world/gen2/FieldMoves.lua:336-380` (`FLYPOINTS`, incl. `SPAWN_PALLET` 52, `SPAWN_VIRIDIAN` 53, `SPAWN_MT_SILVER` 75), `src/ui/TownMap.lua:146-208`, `src/world/gen2/World.lua:4292` | implemented | +| Scene scripts / `setscene` / coord-event dispatch (Victory Road Gate badge check) | `src/world/gen2/World.lua:470` (`mapScenes`), `:5013-5033` | implemented | +| `cry FEAROW` on the celebrity's pet | `src/script/gen2/Vm.lua:621` | implemented | +| **Ledge hopping (`COLL_HOP_DOWN` / `HOP_LEFT` / `HOP_RIGHT` / `HOP_DOWN_LEFT` / `HOP_DOWN_RIGHT`)** | `src/world/gen2/Player.lua:50-83` `Player:tryMove` - plain single-cell step, no `CheckLedge` equivalent; `src/world/gen2/Permissions.lua` maps `$a0..$a7` to `LAND` so the tiles are walkable but never jump | **missing.** Route 22 cannot be crossed without it: `(35,12)` and `(18,6)` are ledges over `COLL_WALL`, and there is no alternative route to the gate warp. Route 28's west strip also exits only by ledge | +| Route 22 / Route 28 / Silver Cave wild tables | generic (`data/wild/*` extracted by `RomExtractorGen2.lua`) | implemented as data; not separately verified for these three maps | +| A driver that walks any of this | `tests/drivers/gold_*.lua` (25 drivers; none touches Kanto or Mt. Silver) | missing | + +## 6. Unresolved / verify by hand + +- **"Items on Route 22: TM42 Dream Eater"** - the asm puts the Dream Eater NPC on + `MAP_VIRIDIAN_CITY` at (6,23) (`maps/ViridianCity.asm:239`). `maps/Route22.asm` + has an empty `def_object_events` and no item bg event at all. The FAQ's prose + ("If you cut the tree to the west of Viridian City") is right; its item header + is wrong. +- **"There's also a Pokémon Center here" (Route 28)** - there is no Pokecenter on + `MAP_ROUTE_28`. The nearest one is `MAP_SILVER_CAVE_POKECENTER_1F`, entered from + `MAP_SILVER_CAVE_OUTSIDE` warp 1 at (23,19), one map west. +- **"Items [Route 28]: TM47 Steel Wing"** - TM47 is inside + `MAP_ROUTE_28_STEEL_WING_HOUSE`. Route 28 itself has one item, the hidden Rare + Candy at (25,2), which the FAQ never mentions. +- **"#232 Donphan (Silver only)"** - correct as far as it goes, but the FAQ omits + what Gold puts in that slot (Ursaring, 20%) and omits Doduo/Dodrio entirely. + It also does not say Sneasel is nite-only. +- **Route 22 Pokemon list** - the FAQ lists only Spearow, Rattata and Poliwag. + `data/wild/kanto_grass.asm:1033` also has Doduo (10%), Ponyta (5%) and Fearow + (4%+1%) in the morn/day tables. +- **The FAQ's "keep heading north, west, then north"** is a fair prose summary but + omits the two mandatory ledge hops. The waypoint list in section 4 was derived + by BFS over `Route22.blk` against `data/tilesets/kanto_collision.asm` with the + ledge rule "step onto a `COLL_HOP_*` tile, then a matching press moves two + cells". That rule was inferred from the collision permission table + (`$a0..$a7` are `LAND_TILE`) and from the fact that every ledge on these maps + has `COLL_WALL` directly beyond it; the exact `DoPlayerMovement` ledge branch + in `engine/overworld/player_movement.asm` was **not** read line by line. + Worth confirming before a driver depends on the two-cell jump distance. +- **Route 28's "unreachable" rows** - `SILVER_CAVE_OUTSIDE`'s east edge is also + walkable at y=14..18 and y=33..35, which map to `ROUTE_28` y=-4..0 and y=15..17. + Those Route 28 rows are outside the component reachable from either the gate or + the west strip, so they look like filler; not chased further. +- **Mt. Silver entry itself** - `maps/SilverCaveOutside.asm` has no coord event, + no callback check and no NPC guarding warp 2 at (18,11), and + `EVENT_OPENED_MT_SILVER` is referenced in exactly two places + (`maps/OaksLab.asm:18,43` and `maps/VictoryRoadGate.asm:118`). So the flag gates + the *corridor*, not the cave mouth. If a save is hacked past the gate NPC, the + cave is open. +- **Party/level requirements** - the FAQ's "at least 10 Revives, 10 Hyper Potions, + 5 Escape Ropes, 5 Max Repels, and 50 Ultra Balls" is advice, not a coded gate. + Nothing in the asm checks the bag here. diff --git a/docs/gold-walkthrough/asm-walk/section-30-lapras.md b/docs/gold-walkthrough/asm-walk/section-30-lapras.md new file mode 100644 index 00000000..bc41d5f6 --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-30-lapras.md @@ -0,0 +1,611 @@ +# Section 30 - Lapras + +Source: `../section-30-lapras.txt` +Maps covered: `MAP_UNION_CAVE_1F`, `MAP_UNION_CAVE_B1F`, `MAP_UNION_CAVE_B2F` +(approach only: `MAP_ROUTE_32`, whose warp into the cave is cited but which +belongs to section 03) +Badges / key milestones in this section: no badge. The milestone is the **static +level-20 Lapras** on Union Cave B2F, which only exists on a Friday and only once +per in-game day (`ENGINE_UNION_CAVE_LAPRAS`). Five optional trainers +(Andrew, Calvin, Nick, Gwen, Emma) and two item balls sit on the way in. + +This is a *revisit* section: Union Cave 1F/B1F were first walked in section 03. +Everything here is reachable only with Surf, i.e. after the Fog Badge. + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_32` | `maps/Route32.asm` | walked south from Violet City (`connection south, Route32, ROUTE_32, 0` in `data/maps/attributes.asm`) | `warp_event 6, 79, UNION_CAVE_1F, 4` | "head to Violet City and then go south through Route 32 into Union Cave" | +| 2 | `MAP_UNION_CAVE_1F` | `maps/UnionCave1F.asm` | warp 4 at `17, 3` (from Route 32) | `warp_event 3, 33, UNION_CAVE_B1F, 4` | walk past Daniel / Russell / Bill, surf the south-west lake, take the bottom-left ladder | +| 3 | `MAP_UNION_CAVE_B1F` | `maps/UnionCaveB1F.asm` | warp 4 at `3, 33` | `warp_event 17, 31, UNION_CAVE_B2F, 1` | Pokemaniac Andrew stands at `5, 32` right next to the arrival ladder; surf east to Calvin at `17, 30`, then the ladder beside him | +| 4 | `MAP_UNION_CAVE_B2F` | `maps/UnionCaveB2F.asm` | warp 1 at `5, 3` | same warp back up, or Escape Rope / Dig | Hyper Potion, Nick, Gwen, Emma, then the Lapras object at `11, 31` | + +The walkthrough ends inside Union Cave B2F ("You can then use Escape Rope to +leave Union Cave"); it does not spill into a neighbouring section's map. + +## 2. Maps + +### MAP_UNION_CAVE_1F + +- Script: `maps/UnionCave1F.asm` +- Blocks: `maps/UnionCave1F.blk` +- Header: `data/maps/maps.asm:107` -> `map UnionCave1F, TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` + (the `TRUE` field is the phone-service flag: **no phone calls in Union Cave**) +- Dimensions: `constants/map_constants.asm:94` -> `map_const UNION_CAVE_1F, 10, 18` (10x18 blocks = 20x36 walk cells) +- Attributes: `data/maps/attributes.asm:426` -> `map_attributes UnionCave1F, UNION_CAVE_1F, $09` +- Connections: none (no `connection` rows follow the attributes line) +- Map group: `MapGroup_Dungeons` (`data/maps/maps.asm:77`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 19 | `UNION_CAVE_B1F` | 3 | +| 2 | 3 | 33 | `UNION_CAVE_B1F` | 4 | +| 3 | 17 | 31 | `ROUTE_33` | 1 | +| 4 | 17 | 3 | `ROUTE_32` | 4 | + +**Coord events** (`def_coord_events`) + +None - the block is empty. + +**BG events** (`def_bg_events`) + +None. (`UnionCave1FUnusedSign` / `UnionCave1FUnusedSignText` exist in the file +but are marked `; unreferenced` and are not in any `bg_event` row.) + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVE1F_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 4 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerHikerDaniel` | -1 | +| `UNIONCAVE1F_SUPER_NERD` | `SPRITE_SUPER_NERD` | 4 | 21 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerPokemaniacLarry` | -1 | +| `UNIONCAVE1F_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 15 | 8 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerHikerRussell` | -1 | +| `UNIONCAVE1F_FISHER1` | `SPRITE_FISHER` | 16 | 31 | `SPRITEMOVEDATA_STANDING_UP` | `OBJECTTYPE_TRAINER`, sight 4 | `TrainerFirebreatherRay` | -1 | +| `UNIONCAVE1F_FISHER2` | `SPRITE_FISHER` | 15 | 15 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 2 | `TrainerFirebreatherBill` | -1 | +| `UNIONCAVE1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 17 | 21 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FGreatBall` | `EVENT_UNION_CAVE_1F_GREAT_BALL` | +| `UNIONCAVE1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 4 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FPotion` | `EVENT_UNION_CAVE_1F_POTION` | +| `UNIONCAVE1F_POKE_BALL3` | `SPRITE_POKE_BALL` | 4 | 17 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FXAttack` | `EVENT_UNION_CAVE_1F_X_ATTACK` | +| `UNIONCAVE1F_POKE_BALL4` | `SPRITE_POKE_BALL` | 12 | 33 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCave1FAwakening` | `EVENT_UNION_CAVE_1F_AWAKENING` | + +**Scripts of interest** + +- `UnionCave1F_MapScripts` - `def_scene_scripts` and `def_callbacks` are both + empty. Nothing on 1F is time- or flag-gated; the floor is pure walking. +- `PokemaniacLarryAfterBattleText` and `FirebreatherBillAfterBattleText` are the + in-game hints the walkthrough is acting on: *"Every Friday, you can hear + #MON roars from deep inside the cave."* and *"On weekends, you can hear + strange roars from deep in the cave."* Only Larry's Friday line matches the + code (see B2F callback below). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_UNION_CAVE_1F_GREAT_BALL` | `constants/event_flags.asm` | itemball object row | set once the ball is taken; masks the object | +| `EVENT_UNION_CAVE_1F_POTION` | `constants/event_flags.asm` | itemball object row | same | +| `EVENT_UNION_CAVE_1F_X_ATTACK` | `constants/event_flags.asm` | itemball object row | same | +| `EVENT_UNION_CAVE_1F_AWAKENING` | `constants/event_flags.asm` | itemball object row | same | +| `EVENT_BEAT_HIKER_DANIEL`, `EVENT_BEAT_HIKER_RUSSELL`, `EVENT_BEAT_POKEMANIAC_LARRY`, `EVENT_BEAT_FIREBREATHER_BILL`, `EVENT_BEAT_FIREBREATHER_RAY` | `constants/event_flags.asm` | 3rd argument of each `trainer` macro row | set = already beaten, no eyesight trigger | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `GREAT_BALL` | item ball at 17,21 | `UnionCave1FGreatBall` (`itemball GREAT_BALL`) | `EVENT_UNION_CAVE_1F_GREAT_BALL` | +| `POTION` | item ball at 4,2 | `UnionCave1FPotion` | `EVENT_UNION_CAVE_1F_POTION` | +| `X_ATTACK` | item ball at 4,17 | `UnionCave1FXAttack` | `EVENT_UNION_CAVE_1F_X_ATTACK` | +| `AWAKENING` | item ball at 12,33 | `UnionCave1FAwakening` | `EVENT_UNION_CAVE_1F_AWAKENING` | + +**Trainers** (all optional on this pass; the walkthrough only mentions walking past them) + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `HIKER`, `DANIEL` | `HIKER` | `DANIEL` | `"DANIEL@"` | `TrainerHikerDaniel` | phone service disabled on this map (`TRUE` in the header) | +| `HIKER`, `RUSSELL` | `HIKER` | `RUSSELL` | `"RUSSELL@"` | `TrainerHikerRussell` | as above | +| `POKEMANIAC`, `LARRY` | `POKEMANIAC` | `LARRY` | `"LARRY@"` | `TrainerPokemaniacLarry` | as above | +| `FIREBREATHER`, `BILL` | `FIREBREATHER` | `BILL` | `"BILL@"` | `TrainerFirebreatherBill` | as above | +| `FIREBREATHER`, `RAY` | `FIREBREATHER` | `RAY` | `"RAY@"` | `TrainerFirebreatherRay` | as above | + +(Party rows for these five were not transcribed - they belong to section 03 and +the walkthrough tells the bot to walk past them, not fight them.) + +**Wild encounters** + +Grass/cave, `data/wild/johto_grass.asm:444` `def_grass_wildmons UNION_CAVE_1F`, +rate `6 percent` for morn/day/nite alike. Gold list (identical across all three +time slots): + +| slot | level | species | +|---|---|---| +| 1 | 6 | `GEODUDE` | +| 2 | 6 | `SANDSHREW` (Silver: `RATTATA`) | +| 3 | 5 | `ZUBAT` | +| 4 | 4 | `RATTATA` | +| 5 | 7 | `ZUBAT` | +| 6 | 6 | `ONIX` | +| 7 | 6 | `ONIX` | + +Water, `data/wild/johto_water.asm:12` `def_water_wildmons UNION_CAVE_1F`, rate +`2 percent`: `15 WOOPER`, `20 QUAGSIRE`, `15 QUAGSIRE`. + +Fishing group is `FISHGROUP_LAKE` (header row). + +### MAP_UNION_CAVE_B1F + +- Script: `maps/UnionCaveB1F.asm` +- Blocks: `maps/UnionCaveB1F.blk` +- Header: `data/maps/maps.asm:108` -> `map UnionCaveB1F, TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:95` -> `map_const UNION_CAVE_B1F, 10, 18` +- Attributes: `data/maps/attributes.asm:427` -> `map_attributes UnionCaveB1F, UNION_CAVE_B1F, $09` +- Connections: none +- Symbols: `44:5415 UnionCaveB1F_MapScripts`, `44:56df UnionCaveB1F_MapEvents` (`pokegold.sym`) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 3 | `RUINS_OF_ALPH_OUTSIDE` | 7 | +| 2 | 3 | 11 | `RUINS_OF_ALPH_OUTSIDE` | 8 | +| 3 | 7 | 19 | `UNION_CAVE_1F` | 1 | +| 4 | 3 | 33 | `UNION_CAVE_1F` | 2 | +| 5 | 17 | 31 | `UNION_CAVE_B2F` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +None. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVEB1F_POKEFAN_M1` | `SPRITE_POKEFAN_M` | 10 | 4 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 1 | `TrainerHikerPhillip` | -1 | +| `UNIONCAVEB1F_POKEFAN_M2` | `SPRITE_POKEFAN_M` | 17 | 10 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerHikerLeonard` | -1 | +| `UNIONCAVEB1F_SUPER_NERD1` | `SPRITE_SUPER_NERD` | 5 | 32 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerPokemaniacAndrew` | -1 | +| `UNIONCAVEB1F_SUPER_NERD2` | `SPRITE_SUPER_NERD` | 17 | 30 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 3 | `TrainerPokemaniacCalvin` | -1 | +| `UNIONCAVEB1F_POKE_BALL1` | `SPRITE_POKE_BALL` | 2 | 16 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB1FTMSwift` | `EVENT_UNION_CAVE_B1F_TM_SWIFT` | +| `UNIONCAVEB1F_BOULDER` | `SPRITE_BOULDER` | 7 | 10 | `SPRITEMOVEDATA_STRENGTH_BOULDER` | `OBJECTTYPE_SCRIPT` | `UnionCaveB1FBoulder` | -1 | +| `UNIONCAVEB1F_POKE_BALL2` | `SPRITE_POKE_BALL` | 17 | 23 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB1FXDefend` | `EVENT_UNION_CAVE_B1F_X_DEFEND` | + +**Scripts of interest** + +- `UnionCaveB1F_MapScripts` - both `def_scene_scripts` and `def_callbacks` are + empty. Nothing on B1F is gated. +- `TrainerPokemaniacAndrew` (`44:5417`) - + `trainer POKEMANIAC, ANDREW, EVENT_BEAT_POKEMANIAC_ANDREW, PokemaniacAndrewSeenText, PokemaniacAndrewBeatenText, 0, .Script`. + `.Script` is the boilerplate after-battle path: + `endifjustbattled / opentext / writetext PokemaniacAndrewAfterBattleText / waitbutton / closetext / end`. + No flags beyond the beaten flag, no items, no warps. +- `TrainerPokemaniacCalvin` (`44:542b`) - identical shape with + `EVENT_BEAT_POKEMANIAC_CALVIN` and `PokemaniacCalvinAfterBattleText` + ("You demonstrated on me!" is `PokemaniacCalvinBeatenText`, matching the + walkthrough quote). +- `UnionCaveB1FBoulder` - `jumpstd StrengthBoulderScript`. Not on the + walkthrough's path to Lapras (it sits at `7, 10`, up near the Ruins of Alph + ladders), but it is the one Strength-gated object on the floor. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_BEAT_POKEMANIAC_ANDREW` | `constants/event_flags.asm:751` | `TrainerPokemaniacAndrew` | set after the win; suppresses the eyesight trigger | +| `EVENT_BEAT_POKEMANIAC_CALVIN` | `constants/event_flags.asm:752` | `TrainerPokemaniacCalvin` | same | +| `EVENT_BEAT_HIKER_PHILLIP`, `EVENT_BEAT_HIKER_LEONARD` | `constants/event_flags.asm` | the two Hiker `trainer` rows | not on the Lapras path | +| `EVENT_UNION_CAVE_B1F_TM_SWIFT` | `constants/event_flags.asm` | itemball object row | one-time TM_SWIFT | +| `EVENT_UNION_CAVE_B1F_X_DEFEND` | `constants/event_flags.asm` | itemball object row | one-time X_DEFEND | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `TM_SWIFT` | item ball at 2,16 | `UnionCaveB1FTMSwift` | `EVENT_UNION_CAVE_B1F_TM_SWIFT` | +| `X_DEFEND` | item ball at 17,23 | `UnionCaveB1FXDefend` | `EVENT_UNION_CAVE_B1F_X_DEFEND` | + +Neither is mentioned by the walkthrough; both are on the way to the B2F ladder. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `POKEMANIAC`, `ANDREW` | `POKEMANIAC` (`$1e`, `constants/trainer_constants.asm:245`) | `ANDREW` (id 2) | `"ANDREW@", TRAINERTYPE_NORMAL` at `data/trainers/parties.asm:1086`: L24 `MAROWAK`, L24 `MAROWAK` | `TrainerPokemaniacAndrew` | no phone (map header phone flag `TRUE`) | +| `POKEMANIAC`, `CALVIN` | `POKEMANIAC` | `CALVIN` (id 3) | `"CALVIN@", TRAINERTYPE_NORMAL` at `data/trainers/parties.asm:1092`: L26 `KANGASKHAN` | `TrainerPokemaniacCalvin` | no phone | +| `HIKER`, `PHILLIP` | `HIKER` | `PHILLIP` | `"PHILLIP@"` | `TrainerHikerPhillip` | off-path | +| `HIKER`, `LEONARD` | `HIKER` | `LEONARD` | `"LEONARD@"` | `TrainerHikerLeonard` | off-path | + +`TRAINERTYPE_NORMAL` means level+species only: no custom moves, no held items. + +Prize money: `POKEMANIAC` base reward is `db 15` (`data/trainers/attributes.asm:179` +`; Pokemaniac`). `ComputeTrainerReward` (`engine/battle/read_trainer_party.asm:300`) +computes `base * wCurPartyLevel` where the level is the **last** party row, and +`WinTrainerBattle` (`engine/battle/core.asm:2292`) pays that out four times +(`ld c, 4` at the `.okay` loop). So Andrew = 15*24*4 = **1440**, Calvin = +15*26*4 = **1560** - both match the walkthrough exactly. + +**Wild encounters** + +Grass/cave, `data/wild/johto_grass.asm:499` `def_grass_wildmons UNION_CAVE_B1F`, +rate `6 percent` morn/day/nite. Gold list (same in all three slots): + +| slot | level | species | +|---|---|---| +| 1 | 8 | `GEODUDE` | +| 2 | 8 | `SANDSHREW` (Silver: `RATTATA`) | +| 3 | 7 | `ZUBAT` | +| 4 | 8 | `ONIX` | +| 5 | 9 | `ZUBAT` | +| 6 | 6 | `RATTATA` | +| 7 | 6 | `RATTATA` | + +Water, `data/wild/johto_water.asm:19`, rate `2 percent`: `15 WOOPER`, +`20 QUAGSIRE`, `15 QUAGSIRE`. + +### MAP_UNION_CAVE_B2F + +This is the section's real content. + +- Script: `maps/UnionCaveB2F.asm` +- Blocks: `maps/UnionCaveB2F.blk` (`2b:466d UnionCaveB2F_Blocks`) +- Header: `data/maps/maps.asm:109` -> `map UnionCaveB2F, TILESET_CAVE, CAVE, LANDMARK_UNION_CAVE, MUSIC_UNION_CAVE, TRUE, PALETTE_NITE, FISHGROUP_SHORE` + (note: **`FISHGROUP_SHORE` here, not `FISHGROUP_LAKE` like the two floors above**) +- Dimensions: `constants/map_constants.asm:96` -> `map_const UNION_CAVE_B2F, 10, 18` (20x36 walk cells) +- Attributes: `data/maps/attributes.asm:428` -> `map_attributes UnionCaveB2F, UNION_CAVE_B2F, $09` +- Connections: none +- Symbols: `44:5759 UnionCaveB2F_MapScripts`, `44:575e UnionCaveB2FLaprasCallback`, `44:5770 UnionCaveLapras`, `44:59cb UnionCaveB2F_MapEvents`, `25:595d UnionCaveB2F_MapAttributes` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 5 | 3 | `UNION_CAVE_B1F` | 5 | + +One warp. There is no second exit: leaving is either back up this ladder, or +Escape Rope / Dig (the walkthrough's suggestion). + +**Coord events** (`def_coord_events`) + +None - the block is empty. Nothing on B2F is a trip-wire; every event is an +object you walk into the sight line of or press A on. + +**BG events** (`def_bg_events`) + +None. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `UNIONCAVEB2F_ROCKER` | `SPRITE_ROCKER` | 17 | 23 | `SPRITEMOVEDATA_STANDING_LEFT` | `OBJECTTYPE_TRAINER`, sight 5, `PAL_NPC_RED` | `TrainerCooltrainermNick` | -1 | +| `UNIONCAVEB2F_COOLTRAINER_F1` | `SPRITE_COOLTRAINER_F` | 5 | 13 | `SPRITEMOVEDATA_SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER`, sight 1, `PAL_NPC_RED` | `TrainerCooltrainerfGwen` | -1 | +| `UNIONCAVEB2F_COOLTRAINER_F2` | `SPRITE_COOLTRAINER_F` | 3 | 28 | `SPRITEMOVEDATA_STANDING_DOWN` | `OBJECTTYPE_TRAINER`, sight 2, `PAL_NPC_RED` | `TrainerCooltrainerfEmma` | -1 | +| `UNIONCAVEB2F_POKE_BALL1` | `SPRITE_POKE_BALL` | 16 | 2 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB2FElixer` | `EVENT_UNION_CAVE_B2F_ELIXER` | +| `UNIONCAVEB2F_POKE_BALL2` | `SPRITE_POKE_BALL` | 12 | 19 | `SPRITEMOVEDATA_STILL` | `OBJECTTYPE_ITEMBALL` | `UnionCaveB2FHyperPotion` | `EVENT_UNION_CAVE_B2F_HYPER_POTION` | +| `UNIONCAVEB2F_LAPRAS` | `SPRITE_SURF` | 11 | 31 | `SPRITEMOVEDATA_SWIM_WANDER`, radius x=1 y=1, `PAL_NPC_BLUE` | `OBJECTTYPE_SCRIPT` | `UnionCaveLapras` | `EVENT_UNION_CAVE_B2F_LAPRAS` | + +Notes a bot needs: + +- Lapras uses `SPRITE_SURF` (`constants/sprite_constants.asm:87`, id 53) - the + same overworld sprite the player rides. It is not a Lapras sprite. +- `SPRITEMOVEDATA_SWIM_WANDER` with radius 1,1 means the object **drifts within + one cell of `11, 31`**. A driver must not hard-code an exact tile to face; it + should walk to the neighbourhood and press A at whichever adjacent water cell + the sprite is occupying. +- Its event flag is `EVENT_UNION_CAVE_B2F_LAPRAS`. `CheckObjectFlag` + (`engine/overworld/map_objects_2.asm:31`) masks an object when the flag is + **set**, so "Lapras present" = flag clear. + +**Scripts of interest** + +`UnionCaveB2F_MapScripts` (`44:5759`) + +``` + def_scene_scripts ; empty + def_callbacks + callback MAPCALLBACK_OBJECTS, UnionCaveB2FLaprasCallback +``` + +`UnionCaveB2FLaprasCallback` (`44:575e`) - the whole Friday rule, six opcodes: + +``` + checkflag ENGINE_UNION_CAVE_LAPRAS + iftrue .NoAppear + readvar VAR_WEEKDAY + ifequal FRIDAY, .Appear +.NoAppear: + disappear UNIONCAVEB2F_LAPRAS + endcallback +.Appear: + appear UNIONCAVEB2F_LAPRAS + endcallback +``` + +Control flow, spelled out: + +1. `checkflag ENGINE_UNION_CAVE_LAPRAS` - a **daily** engine flag + (`constants/engine_flags.asm:107`, in the `wDailyFlags2` block; bit + `DAILYFLAGS2_UNION_CAVE_LAPRAS_F` = 1, `constants/ram_constants.asm:315`). + Set means "already caught/fought it today" -> `.NoAppear`. +2. `readvar VAR_WEEKDAY` (`constants/script_constants.asm:59`, id `$0b`) then + `ifequal FRIDAY` (`constants/ram_constants.asm:218`, `FRIDAY` = 5, Sunday-based). + Only Friday reaches `.Appear`. +3. `appear` clears the object's event flag (`Script_appear`, + `engine/overworld/scripting.asm:879`, `ld b, 0 ; clear`); `disappear` sets it + (`Script_disappear`, `:887`, `ld b, 1 ; set`). + +Because the callback is `MAPCALLBACK_OBJECTS`, it re-runs on **every map load** +of B2F (`engine/overworld/map_setup.asm:81` `farcall LoadObjectMasks` follows +it). So a bot that walks out and back in on a non-Friday will find Lapras gone. + +`UnionCaveLapras` (`44:5770`) - the encounter itself: + +``` + faceplayer + cry LAPRAS + loadwildmon LAPRAS, 20 + startbattle + disappear UNIONCAVEB2F_LAPRAS + setflag ENGINE_UNION_CAVE_LAPRAS + reloadmapafterbattle + end +``` + +Load-bearing details: + +- `loadwildmon LAPRAS, 20` - a level **20 wild** Lapras, matching the + walkthrough. There is no `writevar VAR_BATTLETYPE` here, so unlike Ho-Oh / + Lugia / the Red Gyarados this is an **ordinary wild battle**: no forced item, + no forced shiny, no `BATTLETYPE_CANLOSE` mercy. +- `disappear` and `setflag` run **unconditionally after the battle**, with no + `iffalse` / `checkflag` guard on the outcome. So Lapras is consumed for the + day whether you caught it, KO'd it, ran, or blacked out mid-battle. That is + exactly why the walkthrough says to save first - a soft reset is the only + recovery. +- `setflag ENGINE_UNION_CAVE_LAPRAS` is the daily flag, not a permanent event. + `CheckDailyResetTimer` (`engine/overworld/time.asm:88`) zeroes `wDailyFlags1` + and `wDailyFlags2` whole once a day has elapsed, so it comes back next Friday. + +`TrainerCooltrainermNick` (`44:577f`), `TrainerCooltrainerfGwen` (`44:5793`), +`TrainerCooltrainerfEmma` (`44:57a7`) - all three are the plain +`trainer <class>, <id>, EVENT_BEAT_*, <seen>, <beaten>, 0, .Script` shape with +an `endifjustbattled / opentext / writetext ...AfterBattleText / waitbutton / +closetext / end` follow-up. No items, no warps, no flags beyond their own +`EVENT_BEAT_*`. + +`CooltrainerfEmmaAfterBattleText` is the in-map hint for the whole section: +*"Just once a week, a #MON comes to the water's edge. / I wanted to see that +#MON..."* + +`UnionCaveB2FElixer` = `itemball ELIXER`; `UnionCaveB2FHyperPotion` = +`itemball HYPER_POTION` (`macros/scripts/maps.asm:155`, two raw bytes +`db item, quantity`, not bytecode). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_UNION_CAVE_LAPRAS` | `constants/engine_flags.asm:107` (backed by `data/events/engine_flags.asm:117` -> `wDailyFlags2` bit `DAILYFLAGS2_UNION_CAVE_LAPRAS_F`) | read by `UnionCaveB2FLaprasCallback`, set by `UnionCaveLapras` | "Lapras already used up today". Cleared wholesale by `CheckDailyResetTimer` (`engine/overworld/time.asm:88-97`) | +| `EVENT_UNION_CAVE_B2F_LAPRAS` | `constants/event_flags.asm:1282` | written by the callback's `appear`/`disappear` | object visibility mask. **Clear = visible** (`engine/overworld/map_objects_2.asm:31` `CheckObjectFlag`) | +| `VAR_WEEKDAY` | `constants/script_constants.asm:59` (`$0b`) | `readvar` in the callback | `wCurDay`, 0 = `SUNDAY` .. 6 = `SATURDAY`; `FRIDAY` = 5 (`constants/ram_constants.asm:218`) | +| `EVENT_BEAT_COOLTRAINERM_NICK` | `constants/event_flags.asm:857` | `TrainerCooltrainermNick` | beaten flag | +| `EVENT_BEAT_COOLTRAINERF_GWEN` | `constants/event_flags.asm:877` | `TrainerCooltrainerfGwen` | beaten flag | +| `EVENT_BEAT_COOLTRAINERF_EMMA` | `constants/event_flags.asm:891` | `TrainerCooltrainerfEmma` | beaten flag | +| `EVENT_UNION_CAVE_B2F_ELIXER` | `constants/event_flags.asm:1025` | itemball object row | one-time | +| `EVENT_UNION_CAVE_B2F_HYPER_POTION` | `constants/event_flags.asm:1026` | itemball object row | one-time | + +There are no `SCENE_*` values on this map: `def_scene_scripts` is empty, so +`wMapScene` is never consulted here. + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `ELIXER` | item ball at 16,2 | `UnionCaveB2FElixer` (`itemball ELIXER`) | `EVENT_UNION_CAVE_B2F_ELIXER` | +| `HYPER_POTION` | item ball at 12,19 | `UnionCaveB2FHyperPotion` (`itemball HYPER_POTION`) | `EVENT_UNION_CAVE_B2F_HYPER_POTION` | + +The walkthrough names only the Hyper Potion ("Land on the right side to grab a +Hyper Potion"). The Elixer at `16, 2` sits near the arrival ladder at `5, 3` and +is never mentioned - free pickup for a bot on the way in. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `COOLTRAINERM`, `NICK` | `COOLTRAINERM` (`$1b`, `constants/trainer_constants.asm:184`) | `NICK` (id 1) | `"NICK@", TRAINERTYPE_MOVES` at `data/trainers/parties.asm:742` | `TrainerCooltrainermNick` | no phone (map phone flag `TRUE`) | +| `COOLTRAINERF`, `GWEN` | `COOLTRAINERF` (`$1c`, `:205`) | `GWEN` (id 1) | `"GWEN@", TRAINERTYPE_NORMAL` at `:867` | `TrainerCooltrainerfGwen` | no phone | +| `COOLTRAINERF`, `EMMA` | `COOLTRAINERF` | `EMMA` (id 15) | `"EMMA@", TRAINERTYPE_NORMAL` at `:954` | `TrainerCooltrainerfEmma` | no phone | + +Parties verbatim: + +`NICK` (`TRAINERTYPE_MOVES` - custom movesets, no items): + +| # | level | species | moves | +|---|---|---|---| +| 1 | 26 | `CHARMANDER` | `EMBER`, `SMOKESCREEN`, `RAGE`, `SCARY_FACE` | +| 2 | 26 | `SQUIRTLE` | `WITHDRAW`, `WATER_GUN`, `BITE`, `CURSE` | +| 3 | 26 | `BULBASAUR` | `LEECH_SEED`, `POISONPOWDER`, `SLEEP_POWDER`, `RAZOR_LEAF` | + +`GWEN` (`TRAINERTYPE_NORMAL`): + +| # | level | species | +|---|---|---| +| 1 | 26 | `EEVEE` | +| 2 | 22 | `FLAREON` | +| 3 | 22 | `VAPOREON` | +| 4 | 22 | `JOLTEON` | + +`EMMA` (`TRAINERTYPE_NORMAL`): L28 `POLIWHIRL`, single mon. + +Prize money (`base * last-party-level * 4`, see the B1F note): `COOLTRAINERM` +and `COOLTRAINERF` both have `db 12 ; base reward` +(`data/trainers/attributes.asm:161` `; Cooltrainerm`, `:167` `; Cooltrainerf`). +Nick = 12*26*4 = **1248**, Gwen = 12*22*4 = **1056** (last row is the L22 +Jolteon), Emma = 12*28*4 = **1344**. All three match the walkthrough. + +AI: both Cooltrainer classes carry the full +`AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | AI_CAUTIOUS | AI_STATUS | AI_RISKY` +set and `SWITCH_SOMETIMES`; Pokemaniac is the weaker +`AI_BASIC | AI_SETUP | AI_OFFENSIVE | AI_AGGRESSIVE | AI_STATUS`. None of the +five hold items (`db NO_ITEM, NO_ITEM`). + +**Wild encounters** + +Grass/cave, `data/wild/johto_grass.asm:554` `def_grass_wildmons UNION_CAVE_B2F`, +rate `4 percent`. This table is **not** split by version, and all three +time-of-day slots are identical: + +| slot | level | species | +|---|---|---| +| 1 | 22 | `ZUBAT` | +| 2 | 22 | `RATICATE` | +| 3 | 22 | `GOLBAT` | +| 4 | 21 | `GEODUDE` | +| 5 | 20 | `RATTATA` | +| 6 | 23 | `ONIX` | +| 7 | 23 | `ONIX` | + +Water (the surf legs the walkthrough spends most of its time on), +`data/wild/johto_water.asm:26` `def_water_wildmons UNION_CAVE_B2F`, rate +`4 percent` (double the 1F/B1F water rate): + +| slot | level | species | +|---|---|---| +| 1 | 15 | `TENTACOOL` | +| 2 | 20 | `QUAGSIRE` | +| 3 | 20 | `TENTACRUEL` | + +Fishing group is `FISHGROUP_SHORE` (`data/maps/maps.asm:109`). No headbutt or +rock-smash tables apply - it is a cave with no trees. + +The walkthrough's Max Repel advice: `REPEL_STEPS` in the port mirrors +`RepelEffect / SuperRepelEffect / MaxRepelEffect`; MAX_REPEL is 250 steps. With +the B2F encounters capped at level 23 and the lead mon typically above that, +Repel suppresses essentially all of the grass rolls. + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Cannot cross any of the water on 1F / B1F / B2F | `engine/events/overworld.asm:322` `SurfFunction.TrySurf` (`ld de, ENGINE_FOGBADGE / call CheckBadge / jr c, .nofogbadge`), and the walk-into-water path `TrySurfOW` at `:469` | **Fog Badge** plus a party mon that knows `SURF` | beat Morty in Ecruteak Gym (section 07) | +| Lapras object is absent 6 days out of 7 | `maps/UnionCaveB2F.asm:15` `UnionCaveB2FLaprasCallback` -> `readvar VAR_WEEKDAY / ifequal FRIDAY` | in-game weekday must equal `FRIDAY` (`constants/ram_constants.asm:218`, value 5) | set the GB clock / wait for Friday. Re-checked on every B2F map load (`MAPCALLBACK_OBJECTS`) | +| Lapras is absent again after one encounter | same callback, first two opcodes: `checkflag ENGINE_UNION_CAVE_LAPRAS / iftrue .NoAppear`; the flag is set by `UnionCaveLapras` | flag must be clear | `CheckDailyResetTimer` (`engine/overworld/time.asm:88`) zeroes `wDailyFlags1`+`wDailyFlags2` once a real day has passed | +| Lapras is consumed even on a failed attempt | `UnionCaveLapras`: `startbattle` is followed unconditionally by `disappear` + `setflag` - no `iffalse` on `wBattleResult` | none - this is the failure mode, not a gate | save before pressing A; soft-reset on a bad outcome | +| Only one way out of B2F | `maps/UnionCaveB2F.asm` `def_warp_events` has exactly one row (`5, 3 -> UNION_CAVE_B1F, 5`) | - | Escape Rope / Dig short-circuits the return walk (B2F's tileset is `TILESET_CAVE`) | +| Boulder at B1F `7, 10` | `UnionCaveB1FBoulder` -> `jumpstd StrengthBoulderScript` | Strength / Plain Badge | not on the Lapras path; noted for completeness | + +Nothing in this section checks a badge count, a key item, or an `EVENT_*` +story flag. The only two real gates are **Surf** and **Friday**. + +## 4. Bot checklist + +Preconditions for the whole section: Fog Badge, a party mon with `SURF`, the +in-game weekday equal to `FRIDAY`, `ENGINE_UNION_CAVE_LAPRAS` clear, several +Ultra Balls, a status move (Sleep/Paralysis) and a **save written immediately +before the Lapras press**. + +| # | Map | Target | Input intent | Precondition | Postcondition | +|---|---|---|---|---|---| +| 1 | `MAP_ROUTE_32` | warp at `6, 79` | walk onto the warp | on Route 32 | now in `MAP_UNION_CAVE_1F` at `17, 3` (warp 4) | +| 2 | `MAP_UNION_CAVE_1F` | - | use `MAX_REPEL` from the pack | have one | 250 steps of encounter suppression | +| 3 | `MAP_UNION_CAVE_1F` | avoid sight lines of `UNIONCAVE1F_POKEFAN_M2` (`15, 8`, sight 3, facing left) and `UNIONCAVE1F_FISHER2` (`15, 15`, sight 2, spinner) | walk south-west | - | `EVENT_BEAT_*` untouched if avoided; the spinner is not reliably avoidable, budget for the battle | +| 4 | `MAP_UNION_CAVE_1F` | south-west water | press A facing water, or walk into it (`TrySurfOW`) | Fog Badge + `SURF` | `wPlayerState = PLAYER_SURF` | +| 5 | `MAP_UNION_CAVE_1F` | warp 2 at `3, 33` | step onto it | - | now in `MAP_UNION_CAVE_B1F` at `3, 33` (warp 4) | +| 6 | `MAP_UNION_CAVE_B1F` | `UNIONCAVEB1F_SUPER_NERD1` at `5, 32` (facing left, sight 3) | walk into the sight line / talk | - | battle Pokemaniac Andrew (2x L24 Marowak); sets `EVENT_BEAT_POKEMANIAC_ANDREW`, +1440 | +| 7 | `MAP_UNION_CAVE_B1F` | surf east along the y~30 water | walk east on water | surfing | reach the eastern shelf | +| 8 | `MAP_UNION_CAVE_B1F` | `UNIONCAVEB1F_SUPER_NERD2` at `17, 30` (facing left, sight 3) | walk into the sight line | - | battle Pokemaniac Calvin (L26 Kangaskhan); sets `EVENT_BEAT_POKEMANIAC_CALVIN`, +1560 | +| 9 | `MAP_UNION_CAVE_B1F` | optional item ball `17, 23` | press A | `EVENT_UNION_CAVE_B1F_X_DEFEND` clear | `X_DEFEND` in bag, flag set | +| 10 | `MAP_UNION_CAVE_B1F` | warp 5 at `17, 31` | step onto it | - | now in `MAP_UNION_CAVE_B2F` at `5, 3`; `MAPCALLBACK_OBJECTS` runs `UnionCaveB2FLaprasCallback` | +| 11 | `MAP_UNION_CAVE_B2F` | **assert** `UNIONCAVEB2F_LAPRAS` is present | read the object list | weekday == FRIDAY and `ENGINE_UNION_CAVE_LAPRAS` clear | if absent, abort the run - nothing later will fix it | +| 12 | `MAP_UNION_CAVE_B2F` | item ball `16, 2` | press A | `EVENT_UNION_CAVE_B2F_ELIXER` clear | `ELIXER` in bag (walkthrough omits this one) | +| 13 | `MAP_UNION_CAVE_B2F` | surf south from the entrance shelf | walk south on water | surfing | approach the y~19-23 shelves | +| 14 | `MAP_UNION_CAVE_B2F` | item ball `12, 19` (right-hand shore) | land, press A | `EVENT_UNION_CAVE_B2F_HYPER_POTION` clear | `HYPER_POTION` in bag | +| 15 | `MAP_UNION_CAVE_B2F` | `UNIONCAVEB2F_ROCKER` at `17, 23` (facing left, sight 5 - the longest on the floor) | walk into the sight line | - | battle Cooltrainer Nick (L26 Charmander/Squirtle/Bulbasaur, custom moves); sets `EVENT_BEAT_COOLTRAINERM_NICK`, +1248 | +| 16 | `MAP_UNION_CAVE_B2F` | cross to the left shore, head north to `UNIONCAVEB2F_COOLTRAINER_F1` at `5, 13` (spinner, sight 1) | walk adjacent | - | battle Cooltrainer Gwen (L26 Eevee, L22 Flareon/Vaporeon/Jolteon); sets `EVENT_BEAT_COOLTRAINERF_GWEN`, +1056 | +| 17 | `MAP_UNION_CAVE_B2F` | walk the left path south to `UNIONCAVEB2F_COOLTRAINER_F2` at `3, 28` (facing down, sight 2) | walk into the sight line | - | battle Cooltrainer Emma (L28 Poliwhirl); sets `EVENT_BEAT_COOLTRAINERF_EMMA`, +1344 | +| 18 | `MAP_UNION_CAVE_B2F` | **save** | open menu -> SAVE | Lapras still present | recoverable checkpoint | +| 19 | `MAP_UNION_CAVE_B2F` | surf east/south-east from `3, 28` toward `11, 31` | walk on water | surfing | the Lapras object drifts within +/-1 cell of `11, 31` (`SWIM_WANDER`, radius 1,1) - re-scan its live position each step | +| 20 | `MAP_UNION_CAVE_B2F` | press A facing `UNIONCAVEB2F_LAPRAS` | talk | object present | `UnionCaveLapras` runs: `cry LAPRAS`, `loadwildmon LAPRAS, 20`, `startbattle` | +| 21 | battle | L20 wild Lapras | inflict sleep/paralysis, chip to yellow/red, throw `ULTRA_BALL`s | - | on any exit: `disappear` + `setflag ENGINE_UNION_CAVE_LAPRAS`, then `reloadmapafterbattle` | +| 22 | - | verify Lapras in party/box | read party | - | if not caught, **reload the step-18 save**; do not walk out and back in, the callback will not re-place it | +| 23 | `MAP_UNION_CAVE_B2F` | use `ESCAPE_ROPE` | pack -> use | in a cave tileset | back at the last Pokemon Center door | + +Sight-line arithmetic for steps 6/8/15/16/17: `Trainers.sees` in the port +(`src/world/gen2/Trainers.lua:98`) is the transcription of `home/trainers.asm` - +the NPC must be facing the player's direction and the Chebyshev-free straight +distance must be `<= sight`. + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Union Cave 1F/B1F/B2F map data (headers, blocks, warps, objects) | `src/import/RomExtractorGen2.lua` (generic map/manifest extraction; per-map entries land in the private `data/generated/` cache) | implemented - data-driven, no per-map code | +| `MAPCALLBACK_OBJECTS` dispatch on map load | `src/world/gen2/World.lua:5700` (`self:runMapCallback("MAPCALLBACK_OBJECTS")`, deliberately *before* the sprite rebuild) | implemented | +| `readvar VAR_WEEKDAY` | `src/world/gen2/World.lua:101` (`VAR_WEEKDAY = 0x0b`) and `:1222` (`if varId == VAR_WEEKDAY then return self:weekday() end`) | implemented | +| `checkflag` / `setflag` on `ENGINE_*` | `src/script/gen2/Vm.lua:195` / `:208` | implemented | +| `ENGINE_UNION_CAVE_LAPRAS` as a daily flag | `src/core/gen2/Apricorns.lua:84` (`{ id = 88, name = "ENGINE_UNION_CAVE_LAPRAS" }` in the wDailyFlags1/2 id list) and `:344` (`save.dailyFlags = {}` on the daily wipe) | implemented | +| `appear` / `disappear` object masking | `src/script/gen2/Opcodes.lua:115-116`, consumed by the World object rebuild | implemented | +| `cry` | `src/script/gen2/Vm.lua:621` | implemented | +| `loadwildmon` + `startbattle` + `reloadmapafterbattle` (the whole static-encounter shape) | `src/script/gen2/Vm.lua:836`, `:817`, `:886` | implemented | +| Wild catch rate / Ultra Ball / status bonus | `src/battle/gen2/Catching.lua` (transcribes `PokeBallEffect`, including the two cart bugs) | implemented | +| Water encounter tables while surfing | `src/world/gen2/FieldMoves.lua:175` ("standing in a cave rolls the grass list; surfing rolls the water" ...) + `src/battle/gen2/Encounter.lua` | implemented | +| Surf gate on `ENGINE_FOGBADGE` | `src/world/gen2/FieldMoves.lua:106` (`SURF = "FOG"`) and `FieldMoves.surfFromMenu` at `:481` | implemented | +| Trainer eyesight approach + battle | `src/world/gen2/Trainers.lua:98` (`Trainers.sees`), `src/world/gen2/World.lua:5212` / `:5227` / `:5242` | implemented | +| Trainer prize money (base x level x 4, Mom's split) | `src/battle/gen2/Prize.lua:82` (`Prize.reward`), `:92` (`Prize.rewardLevel`) | implemented - and it already documents the `ld c, 4` that makes the walkthrough's figures right | +| Repel step counting (the Max Repel advice) | `src/world/gen2/World.lua:317` (`REPEL_STEPS = { REPEL = 100, SUPER_REPEL = 200, MAX_REPEL = 250 }`), `:3366` | implemented | +| **Item ball pickup** (`OBJECTTYPE_ITEMBALL`, i.e. the B2F Elixer / Hyper Potion and every ball on 1F/B1F) | extractor stores the item at `src/import/RomExtractorGen2.lua:2969` (`obj.itemball = readItemBall(...)`) and deliberately assigns **no** `scriptKey`; `World:talk` only dispatches on `npc.def.scriptKey` (`src/world/gen2/World.lua:5276`) | **missing** - no consumer of `obj.itemball` exists anywhere under `src/`. Pressing A on a Poke Ball object currently does nothing | +| Escape Rope from a cave | `src/ui/PartyMenu.lua:634` is the Gen 1 DIG/ESCAPE_ROPE path; no Gen 2 equivalent found under `src/core/gen2/` or `src/world/gen2/` | **unverified / likely missing for Gen 2** - a driver should warp out manually | +| A driver exercising the weekday-gated `MAPCALLBACK_OBJECTS` shape | `tests/drivers/gold_map_callbacks.lua:117-156` covers the same pattern on Route 29 (Tuscany, Tuesday) | implemented for the pattern, **not** for Union Cave B2F specifically - no `tests/drivers/gold_*` driver mentions Union Cave or Lapras | + +Honest summary: everything the Lapras encounter itself needs is in place +(callback dispatch, weekday var, daily flags, `loadwildmon`/`startbattle`, +catching). The two gaps that would bite a bot on this route are **item balls +are not pickable** and **no Gen 2 Escape Rope**, neither of which blocks Lapras. + +## 6. Unresolved / verify by hand + +- **"Lapras only appears on Friday" vs. the 1F NPC hints.** The code checks + exactly `FRIDAY` (`UnionCaveB2FLaprasCallback`). But + `FirebreatherBillAfterBattleText` on 1F says *"On weekends, you can hear + strange roars from deep in the cave"* while `PokemaniacLarryAfterBattleText` + says *"Every Friday"*. Larry is right, Bill's line is flavour that contradicts + the callback. The walkthrough follows Larry and is correct. +- **"once you've gotten HM Surf".** The asm imposes no HM/badge check on the + Lapras callback or on `UnionCaveLapras`; Surf is a *reachability* requirement + (B2F's Lapras sits at `11, 31` surrounded by water and the approach legs on + 1F/B1F cross water), enforced only by `SurfFunction.TrySurf`'s + `ENGINE_FOGBADGE` check. The walkthrough's phrasing implies a scripted gate + that does not exist. +- **EXP figures.** The walkthrough quotes per-mon EXP (637 for a L24 Marowak, + 975 for a L26 Kangaskhan, 355/511/924/928/933/786, and two "?" entries). EXP + is computed at runtime from base EXP x level, not stored per trainer, so none + of these could be pinned to a table row. They are plausible but unverified + here; `data/pokemon/base_stats/*.asm` would be the place to check. +- **Party ordering.** The walkthrough lists Nick as Charmander / Bulbasaur / + Squirtle and Gwen as Eevee / Vaporeon / Jolteon / Flareon. `parties.asm` has + Nick as Charmander / **Squirtle** / **Bulbasaur** (`:742`) and Gwen as Eevee / + **Flareon** / **Vaporeon** / **Jolteon** (`:867`). The sets match; the order + does not. A bot planning lead matchups should use the asm order (it is also + what decides the prize level: Gwen's last row is Jolteon L22). +- **"Head almost all the way through till you see the guy on the cliff still + spinning around, which is the third trainer that you spot."** Read against the + 1F object table this is `UNIONCAVE1F_FISHER2` (Firebreather Bill, + `15, 15`, `SPRITEMOVEDATA_SPINRANDOM_FAST`), with Hiker Daniel (`4, 4`) and + Hiker Russell (`15, 8`) as the first two. That reading is an inference from + coordinates and movement data, not something the asm states; the exact walking + order depends on the `.blk` layout, which was not decoded here. +- **Which 1F ladder the walkthrough means.** "Down and to the left is a lake. + Surf past it and head down the ladder" is read as 1F warp 2 (`3, 33` -> + `UNION_CAVE_B1F` warp 4 at `3, 33`), because Andrew stands at B1F `5, 32`, + immediately beside that arrival point. 1F warp 1 (`5, 19` -> B1F warp 3 at + `7, 19`) would land nowhere near him. Verify against the block layout if a + driver's pathing disagrees. +- **B2F "right side" / "left side" landings.** The prose distinguishes a right + and a left shore; the object coordinates (`12, 19` Hyper Potion and `17, 23` + Nick on the right, `5, 13` Gwen and `3, 28` Emma on the left) are consistent + with that, but the actual water/land split comes from `UnionCaveB2F.blk`, + which was not decoded. +- **Elixer at B2F `16, 2`.** Present in the asm, absent from the walkthrough. + Not a contradiction, just an omission worth picking up. diff --git a/docs/gold-walkthrough/asm-walk/section-31-mount-silver-and-red.md b/docs/gold-walkthrough/asm-walk/section-31-mount-silver-and-red.md new file mode 100644 index 00000000..b04fe29d --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-31-mount-silver-and-red.md @@ -0,0 +1,715 @@ +# Section 31 - Mount Silver and Red + +Source: `../section-31-mount-silver-and-red.txt` +Maps covered: `MAP_SILVER_CAVE_OUTSIDE`, `MAP_SILVER_CAVE_POKECENTER_1F`, +`MAP_SILVER_CAVE_ROOM_1`, `MAP_SILVER_CAVE_ROOM_2`, `MAP_SILVER_CAVE_ITEM_ROOMS`, +`MAP_SILVER_CAVE_ROOM_3` (plus `MAP_VICTORY_ROAD_GATE`, cited only as the gate +that lets you reach Route 28 in the first place). +Badges / key milestones in this section: no badge. The milestone is the `RED` +trainer battle in `maps/SilverCaveRoom3.asm` and the `credits` opcode it ends +on - the last scripted event in the game. + +Conventions reminder: disassembly paths are relative to the pokegold checkout +root; port paths are relative to this repo root; coordinates are the raw asm +values, i.e. **map cell coordinates**, 0-based from the map's top-left, the same +space `warp_event` / `bg_event` / `coord_event` use. `object_event` rows are +written in that same space (the `+ 4` in the `object_event` macro, +`macros/scripts/maps.asm` line 130, is the internal RAM border offset, not +something you add when reading the file). + +--- + +## 1. Route order + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_ROUTE_28` | `maps/Route28.asm` (previous section) | `VictoryRoadGate` warps 7/8 at (1,7)/(2,7) | west map connection | "Head west and up from there and you'll be on Mt. Silver." Route 28 itself belongs to the previous section; only the hop off its west edge is in scope here. | +| 1 | `MAP_SILVER_CAVE_OUTSIDE` | `maps/SilverCaveOutside.asm` | east connection from `Route28` (`data/maps/attributes.asm:165`, `connection east, Route28, ROUTE_28, 9`) | warp 2 at (18,11) | Arrive on Mt. Silver; `MAPCALLBACK_NEWMAP` registers the fly point. Optional detour into the Pokémon Center. | +| 2 | `MAP_SILVER_CAVE_POKECENTER_1F` | `maps/SilverCavePokecenter1F.asm` | `SilverCaveOutside` warp 1 at (23,19) | warps 1/2 at (3,7)/(4,7) | Heal before the climb; the granny is pure flavour. | +| 3 | `MAP_SILVER_CAVE_ROOM_1` | `maps/SilverCaveRoom1.asm` | `SilverCaveOutside` warp 2 at (18,11) | warp 2 at (15,1) | "When you get in, use HM Flash." Escape Rope, X Accuracy, Max Elixer, two hidden items. | +| 4 | `MAP_SILVER_CAVE_ROOM_2` | `maps/SilverCaveRoom2.asm` | `SilverCaveRoom1` warp 2 at (15,1) -> lands on warp 1 at (17,31) | warp 2 at (11,5) | The Surf + Waterfall floor. Two side warps into `SilverCaveItemRooms`. | +| 5 | `MAP_SILVER_CAVE_ITEM_ROOMS` | `maps/SilverCaveItemRooms.asm` | `SilverCaveRoom2` warp 3 at (13,21) or warp 4 at (23,3) | warps 1/2 at (13,3)/(7,15) | "use Surf on the lake, and then use Waterfall, you can go through an entrance to grab a Full Restore." Two *disconnected* rooms in one map. | +| 6 | `MAP_SILVER_CAVE_ROOM_3` | `maps/SilverCaveRoom3.asm` | `SilverCaveRoom2` warp 2 at (11,5) -> lands on warp 1 at (9,33) | (no exit warp other than back to Room 2) | "keep going up, and you'll find a trainer." Red at (9,10); beating him runs `credits`. | + +After `credits` the game does not return to the overworld normally: `Script_credits` +(`engine/overworld/scripting.asm:2215`) farcalls `RedCredits` +(`engine/events/halloffame.asm:34`), which sets `wSpawnAfterChampion = SPAWN_RED` +and jumps to `Credits`. `FinishContinueFunction` (`engine/menus/intro_menu.asm`) +sees `SPAWN_RED` on the way back out and calls `SpawnAfterRed`, which sets +`wDefaultSpawnpoint = SPAWN_MT_SILVER` and `hMapEntryMethod = MAPSETUP_WARP`, i.e. +you respawn at `data/maps/spawn_points.asm` -> `spawn SILVER_CAVE_OUTSIDE, 23, 20`, +the cell directly below the Pokémon Center door. + +--- + +## 2. Maps + +### MAP_SILVER_CAVE_OUTSIDE + +- Script: `maps/SilverCaveOutside.asm` +- Blocks: `maps/SilverCaveOutside.blk` (20 x 18 blocks = 40 x 36 cells) +- Header: `data/maps/maps.asm:397` -> + `map SilverCaveOutside, TILESET_KANTO, TOWN, LANDMARK_SILVER_CAVE, MUSIC_INDIGO_PLATEAU, FALSE, PALETTE_AUTO, FISHGROUP_POND` + (fields per the `map` macro at `data/maps/maps.asm:1`: tileset, environment, + location, music, phone-service flag, time-of-day palette, fishing group) +- Dimensions / attributes: `constants/map_constants.asm:367` + (`map_const SILVER_CAVE_OUTSIDE, 20, 18`), group `SILVER` = 19, map id 2 +- Connections: `data/maps/attributes.asm:164-165` - + `map_attributes SilverCaveOutside, SILVER_CAVE_OUTSIDE, $2c` / + `connection east, Route28, ROUTE_28, 9`. No north/south/west connection. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 23 | 19 | `SILVER_CAVE_POKECENTER_1F` | 1 | +| 2 | 18 | 11 | `SILVER_CAVE_ROOM_1` | 1 | + +**Coord events** (`def_coord_events`) + +None (the `def_coord_events` block is empty). + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 24 | 19 | `BGEVENT_READ` | `MtSilverPokecenterSign` -> `jumpstd PokecenterSignScript` | +| 17 | 13 | `BGEVENT_READ` | `MtSilverSign` -> `MtSilverSignText` ("MT.SILVER") | +| 9 | 25 | `BGEVENT_ITEM` | `SilverCaveOutsideHiddenFullRestore` -> `hiddenitem FULL_RESTORE, EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE` | + +**Object events** (`def_object_events`) + +None. `def_object_events` is the last line of the file with no rows after it - +there is not a single NPC or item ball on the outdoor map. + +**Scripts of interest** + +- `SilverCaveOutside_MapScripts` (`47`-bank sibling; symbol `49:5f15`) has an + empty `def_scene_scripts` and one callback: + `callback MAPCALLBACK_NEWMAP, SilverCaveOutsideFlypointCallback`. +- `SilverCaveOutsideFlypointCallback` (`49:5f1a`) is two opcodes: + `setflag ENGINE_FLYPOINT_SILVER_CAVE` / `endcallback`. Simply *entering* the + map unlocks Fly to Mt. Silver; there is no NPC or sign to talk to. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `ENGINE_FLYPOINT_SILVER_CAVE` | `constants/engine_flags.asm:90` | set by `SilverCaveOutsideFlypointCallback` | Fly destination `LANDMARK_SILVER_CAVE` (`data/maps/flypoints.asm:16` -> `SPAWN_MT_SILVER`) becomes selectable. Postcondition of first entry. | +| `EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE` | `constants/event_flags.asm:194` | `hiddenitem` at bg_event (9,25) | One-shot guard on the hidden Full Restore. | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `FULL_RESTORE` | hidden, needs a face-and-A or ITEMFINDER sweep at (9,25) | `bg_event 9, 25, BGEVENT_ITEM, SilverCaveOutsideHiddenFullRestore` | `EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE` | + +**Trainers** + +None. + +**Wild encounters** + +`data/wild/johto_grass.asm:2319`, `def_grass_wildmons SILVER_CAVE_OUTSIDE`, +encounter rate `10 percent` for morn/day/nite alike. Gold column +(`IF DEF(_GOLD)`); the Silver column swaps `URSARING` -> `DONPHAN` and is +otherwise identical. + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 41 TANGELA | 41 TANGELA | 41 TANGELA | +| 2 | 42 PONYTA | 42 PONYTA | 42 PONYTA | +| 3 | 42 URSARING (Silver: DONPHAN) | 42 URSARING (Silver: DONPHAN) | 42 URSARING (Silver: DONPHAN) | +| 4 | 44 RAPIDASH | 44 RAPIDASH | 44 RAPIDASH | +| 5 | 41 DODUO | 41 DODUO | 38 SNEASEL | +| 6 | 43 DODRIO | 43 DODRIO | 42 SNEASEL | +| 7 | 43 DODRIO | 43 DODRIO | 42 SNEASEL | + +Water (`data/wild/johto_water.asm:271`, `def_water_wildmons SILVER_CAVE_OUTSIDE`, +`2 percent`): 35 POLIWHIRL / 40 POLIWHIRL / 35 POLIWAG. +Fishing group `FISHGROUP_POND` (`data/wild/fish.asm:15`). Headbutt: +`data/wild/treemon_maps.asm:39` -> `TREEMON_SET_NONE`, so no headbutt table. + +### MAP_SILVER_CAVE_POKECENTER_1F + +- Script: `maps/SilverCavePokecenter1F.asm` +- Blocks: `maps/SilverCavePokecenter1F.blk` (symbol `2b:42e8 SilverCavePokecenter1F_Blocks`) +- Header: `data/maps/maps.asm:398` -> + `map SilverCavePokecenter1F, TILESET_POKECENTER, INDOOR, LANDMARK_SILVER_CAVE, MUSIC_POKEMON_CENTER, FALSE, PALETTE_DAY, FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:368` (`map_const SILVER_CAVE_POKECENTER_1F, 5, 4`), group `SILVER` = 19, map id 3 +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 3 | 7 | `SILVER_CAVE_OUTSIDE` | 1 | +| 2 | 4 | 7 | `SILVER_CAVE_OUTSIDE` | 1 | +| 3 | 0 | 7 | `POKECENTER_2F` | 1 | + +**Coord events** / **BG events**: both blocks are empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILVERCAVEPOKECENTER1F_NURSE` | `SPRITE_NURSE` | 3 | 1 | `SPRITEMOVEDATA_STANDING_DOWN`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_SCRIPT` | `SilverCavePokecenter1FNurseScript` (`jumpstd PokecenterNurseScript`) | -1 (always) | +| `SILVERCAVEPOKECENTER1F_GRANNY` | `SPRITE_GRANNY` | 1 | 5 | `SPRITEMOVEDATA_STANDING_LEFT`, radius 2/1, hours -1/-1, pal 0 | `OBJECTTYPE_SCRIPT` | `SilverCavePokecenter1FGrannyScript` (`jumptextfaceplayer`) | -1 (always) | + +**Flags and events / Items / Trainers**: none. **Wild encounters**: none. + +### MAP_SILVER_CAVE_ROOM_1 + +- Script: `maps/SilverCaveRoom1.asm` (symbols `47:41e9 SilverCaveRoom1_MapScripts`, `47:41f7 SilverCaveRoom1_MapEvents`) +- Blocks: `maps/SilverCaveRoom1.blk` (10 x 18 blocks = 20 x 36 cells) +- Header: `data/maps/maps.asm:144` -> + `map SilverCaveRoom1, TILESET_DARK_CAVE, CAVE, LANDMARK_SILVER_CAVE, MUSIC_LIGHTHOUSE, TRUE, PALETTE_DARK, FISHGROUP_LAKE` + (the `TRUE` is the phone-service flag: no phone calls in here) +- Dimensions: `constants/map_constants.asm:131` (`map_const SILVER_CAVE_ROOM_1, 10, 18`), group `DUNGEONS` = 3, map id 66 +- Connections: none (dungeon interior) + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 33 | `SILVER_CAVE_OUTSIDE` | 2 | +| 2 | 15 | 1 | `SILVER_CAVE_ROOM_2` | 1 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 16 | 23 | `BGEVENT_ITEM` | `SilverCaveRoom1HiddenDireHit` -> `hiddenitem DIRE_HIT, EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT` | +| 17 | 12 | `BGEVENT_ITEM` | `SilverCaveRoom1HiddenUltraBall` -> `hiddenitem ULTRA_BALL, EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILVERCAVEROOM1_POKE_BALL1` | `SPRITE_POKE_BALL` | 4 | 9 | `SPRITEMOVEDATA_STILL`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_ITEMBALL` | `SilverCaveRoom1MaxElixer` (`itemball MAX_ELIXER`) | `EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER` | +| `SILVERCAVEROOM1_POKE_BALL2` | `SPRITE_POKE_BALL` | 15 | 29 | `SPRITEMOVEDATA_STILL`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_ITEMBALL` | `SilverCaveRoom1XAccuracy` (`itemball X_ACCURACY`) | `EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY` | +| `SILVERCAVEROOM1_POKE_BALL3` | `SPRITE_POKE_BALL` | 5 | 30 | `SPRITEMOVEDATA_STILL`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_ITEMBALL` | `SilverCaveRoom1EscapeRope` (`itemball ESCAPE_ROPE`) | `EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE` | + +**Scripts of interest** + +`SilverCaveRoom1_MapScripts` has an empty `def_scene_scripts` and an empty +`def_callbacks`. Nothing scripted happens on this floor; the three labels above +are two-byte `itemball` payloads, not scripts (the `itemball`/`hiddenitem` +macros lay down data that the object/bg dispatcher reads, they are not `ScriptVM` +entry points). + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER` | `constants/event_flags.asm:1082` | object_event row (hide-when-set) | Set once the ball at (4,9) is taken. | +| `EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY` | `constants/event_flags.asm:1083` | object_event row | Ball at (15,29). | +| `EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE` | `constants/event_flags.asm:1084` | object_event row | Ball at (5,30). | +| `EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT` | `constants/event_flags.asm:164` | `hiddenitem` | Hidden Dire Hit at (16,23). | +| `EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL` | `constants/event_flags.asm:165` | `hiddenitem` | Hidden Ultra Ball at (17,12). | + +Polarity note that matters for every object row in this section: +`CheckObjectFlag` (`engine/overworld/map_objects_2.asm:31-58`) masks (hides) the +object when its `MAPOBJECT_EVENT_FLAG` is **set**, and `Script_disappear` +(`engine/overworld/scripting.asm:887`) is exactly "set that flag". So a set flag +means gone, a clear flag means present. + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_ELIXER` | item ball at (4,9) | `SilverCaveRoom1MaxElixer` | `EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER` | +| `X_ACCURACY` | item ball at (15,29) | `SilverCaveRoom1XAccuracy` | `EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY` | +| `ESCAPE_ROPE` | item ball at (5,30) | `SilverCaveRoom1EscapeRope` | `EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE` | +| `DIRE_HIT` | hidden at (16,23) | bg_event | `EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT` | +| `ULTRA_BALL` | hidden at (17,12) | bg_event | `EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL` | + +**Trainers**: none. + +**Wild encounters** + +`data/wild/johto_grass.asm:1297`, `def_grass_wildmons SILVER_CAVE_ROOM_1`, +`6 percent` morn/day/nite. All three time slices are identical here. + +| slot | all times (Gold) | Silver difference | +|---|---|---| +| 1 | 42 ONIX | - | +| 2 | 44 URSARING | 44 DONPHAN | +| 3 | 43 GRAVELER | - | +| 4 | 43 GRAVELER | - | +| 5 | 45 GOLBAT | - | +| 6 | 20 LARVITAR | - | +| 7 | 15 LARVITAR | - | + +No water table for Room 1. Fishing group `FISHGROUP_LAKE`. + +**Terrain notes** (derived by expanding `maps/SilverCaveRoom1.blk` through +`data/tilesets/cave_collision.asm`; `TilesetDarkCaveColl` and `TilesetCaveColl` +are the same label pair in `gfx/tilesets.asm:106-107`, so the dark-cave floor +uses the ordinary cave collision table) + +- Entry from outside lands on (9,33); a short corridor runs up to the long + east-west floor strip at y=32 spanning x=2..16. +- The Escape Rope at (5,30) sits **on a ledge cell**. Gen 2 ledges are not + "hop over the tile in front"; `.TryJump` + (`engine/overworld/player_movement.asm:354`) reads `wPlayerTileCollision`, the + tile the player is *standing on*, so you walk onto the ledge row, grab the + ball, then press the ledge's direction (`DOWN` for `COLL_HOP_DOWN`) to jump + two cells down onto y=32. That is exactly the walkthrough's "drop down the + ledge to get the Escape Rope. Jump back down." +- (11,27) and (13,27) carry `WARP_CARPET_DOWN` collision but there is **no** + `warp_event` at either coordinate. Treat them as decoration, not exits. + +### MAP_SILVER_CAVE_ROOM_2 + +- Script: `maps/SilverCaveRoom2.asm` (symbols `47:4238`, `47:423d`) +- Blocks: `maps/SilverCaveRoom2.blk` (15 x 18 blocks = 30 x 36 cells) +- Header: `data/maps/maps.asm:145` -> + `map SilverCaveRoom2, TILESET_CAVE, CAVE, LANDMARK_SILVER_CAVE, MUSIC_LIGHTHOUSE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:132` (`map_const SILVER_CAVE_ROOM_2, 15, 18`), group `DUNGEONS` = 3, map id 67 +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 17 | 31 | `SILVER_CAVE_ROOM_1` | 2 | +| 2 | 11 | 5 | `SILVER_CAVE_ROOM_3` | 1 | +| 3 | 13 | 21 | `SILVER_CAVE_ITEM_ROOMS` | 1 | +| 4 | 23 | 3 | `SILVER_CAVE_ITEM_ROOMS` | 2 | + +**Coord events** (`def_coord_events`) + +None. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 14 | 31 | `BGEVENT_ITEM` | `SilverCaveRoom2HiddenMaxPotion` -> `hiddenitem MAX_POTION, EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION` | + +**Object events**: none (`def_object_events` is the last line, no rows). + +**Scripts of interest** + +Empty `def_scene_scripts`, empty `def_callbacks`. Room 2 is pure terrain. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION` | `constants/event_flags.asm:166` | `hiddenitem` at (14,31) | One-shot; the tile is right next to the Room 1 entrance warp at (17,31). | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_POTION` | hidden at (14,31) | bg_event | `EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION` | + +**Trainers**: none. + +**Wild encounters** + +Grass: `data/wild/johto_grass.asm:1352`, `def_grass_wildmons SILVER_CAVE_ROOM_2`, +`6 percent` morn/day/nite. + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 45 QUAGSIRE | 45 QUAGSIRE | 45 QUAGSIRE | +| 2 | 48 GOLDUCK | 48 GOLDUCK | 48 GOLDUCK | +| 3 | 47 URSARING (Silver: DONPHAN) | 47 URSARING (Silver: DONPHAN) | 47 URSARING (Silver: DONPHAN) | +| 4 | 45 QUAGSIRE | 45 QUAGSIRE | **45 MISDREAVUS** | +| 5 | 48 GOLBAT | 48 GOLBAT | 48 GOLBAT | +| 6 | 20 LARVITAR | 20 LARVITAR | 20 LARVITAR | +| 7 | 15 LARVITAR | 15 LARVITAR | 15 LARVITAR | + +Water: `data/wild/johto_water.asm:100`, `def_water_wildmons SILVER_CAVE_ROOM_2`, +`2 percent`: 35 SEAKING / 40 SEAKING / 35 GOLDEEN. Fishing group `FISHGROUP_LAKE`. + +**Terrain notes** (expanded from `maps/SilverCaveRoom2.blk` + +`data/tilesets/cave_collision.asm`; both item-room warps are behind water) + +- You arrive at (17,31) and stand on the south floor shelf, y=30..31, x=14..24. +- **South lake / Max Revive branch.** Surf west from (14,30) onto the water at + x=4..13, y=30..31. The waterfall band is x=6..11, y=28..29; Waterfall up from + it reaches the water pocket at y=22..27. Come ashore at (11,25), walk north to + the floor pocket x=9..14, y=22..24, and step onto the warp at **(13,21)** -> + `SILVER_CAVE_ITEM_ROOMS` warp 1 -> `MAX_REVIVE`. +- **North lake / Full Restore branch.** From the mid-map floor at (11,11) step + down onto the water at (11,12); the lake runs x=10..13 y=12..15 and x=12..21 + y=14..15. The second waterfall band is x=18..21, y=10..13; Waterfall up lands + in the pool at x=18..21, y=8..9. Come ashore at (21,7), walk up through the + x=19..24 chamber to (23,4), and step onto the warp at **(23,3)** -> + `SILVER_CAVE_ITEM_ROOMS` warp 2 -> `FULL_RESTORE`. This is the walkthrough's + "use Surf on the lake, and then use Waterfall ... an entrance to grab a Full + Restore." +- The Room 3 entrance is the `CAVE` cell at (11,5), reached on foot from the + same north-central floor. +- The waterfall band at x=4..7, y=0..5 in the north-west corner has water below + it (y=6..7) that is walled off on every side. It looks decorative; nothing in + the asm routes through it. + +### MAP_SILVER_CAVE_ITEM_ROOMS + +- Script: `maps/SilverCaveItemRooms.asm` (symbols `47:42b7`, `47:42bd`) +- Blocks: `maps/SilverCaveItemRooms.blk` (10 x 9 blocks = 20 x 18 cells) +- Header: `data/maps/maps.asm:147` -> + `map SilverCaveItemRooms, TILESET_CAVE, CAVE, LANDMARK_SILVER_CAVE, MUSIC_LIGHTHOUSE, TRUE, PALETTE_NITE, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:134` (`map_const SILVER_CAVE_ITEM_ROOMS, 10, 9`), group `DUNGEONS` = 3, map id 69 +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 13 | 3 | `SILVER_CAVE_ROOM_2` | 3 | +| 2 | 7 | 15 | `SILVER_CAVE_ROOM_2` | 4 | + +**Coord events** / **BG events**: both empty. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILVERCAVEITEMROOMS_POKE_BALL1` | `SPRITE_POKE_BALL` | 6 | 3 | `SPRITEMOVEDATA_STILL`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_ITEMBALL` | `SilverCaveItemRoomsMaxRevive` (`itemball MAX_REVIVE`) | `EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE` | +| `SILVERCAVEITEMROOMS_POKE_BALL2` | `SPRITE_POKE_BALL` | 15 | 11 | `SPRITEMOVEDATA_STILL`, radius 0/0, hours -1/-1, pal 0 | `OBJECTTYPE_ITEMBALL` | `SilverCaveItemRoomsFullRestore` (`itemball FULL_RESTORE`) | `EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE` | + +**Scripts of interest** + +Empty scene scripts and callbacks; the two labels are `itemball` payloads. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE` | `constants/event_flags.asm:1085` | object_event row | Ball at (6,3), north room. | +| `EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE` | `constants/event_flags.asm:1086` | object_event row | Ball at (15,11), south room. | + +**Items** + +| item | how obtained | source | one-time flag | +|---|---|---|---| +| `MAX_REVIVE` | item ball at (6,3) | `SilverCaveItemRoomsMaxRevive` | `EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE` | +| `FULL_RESTORE` | item ball at (15,11) | `SilverCaveItemRoomsFullRestore` | `EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE` | + +**Trainers**: none. + +**Wild encounters** + +`data/wild/johto_grass.asm:1462`, `def_grass_wildmons SILVER_CAVE_ITEM_ROOMS`, +`6 percent`. Same shape as Room 2 but the night swap lands in slot 1 instead of +slot 4: morn/day slot 1 is 45 QUAGSIRE, nite slot 1 is **45 MISDREAVUS**; slot 4 +stays 45 QUAGSIRE at all times. Slots 2/3/5/6/7 are 48 GOLDUCK / 47 URSARING +(Silver: DONPHAN) / 48 GOLBAT / 20 LARVITAR / 15 LARVITAR. No water table. + +**Terrain notes** + +The map is two disconnected chambers in one 20 x 18 grid, which is why both of +its warps go back to Room 2: + +- North chamber, floor y=2..3 spanning x=4..15, warp 1 at (13,3), Max Revive at (6,3). +- South chamber, floor y=10..15 spanning roughly x=4..17, warp 2 at (7,15), Full + Restore at (15,11). + +### MAP_SILVER_CAVE_ROOM_3 + +- Script: `maps/SilverCaveRoom3.asm` (symbols `47:425c SilverCaveRoom3_MapScripts`, `47:425e Red`, `47:429f SilverCaveRoom3_MapEvents`) +- Blocks: `maps/SilverCaveRoom3.blk` (10 x 18 blocks = 20 x 36 cells) +- Header: `data/maps/maps.asm:146` -> + `map SilverCaveRoom3, TILESET_CAVE, CAVE, LANDMARK_SILVER_CAVE, MUSIC_LIGHTHOUSE, TRUE, PALETTE_DAY, FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:133` (`map_const SILVER_CAVE_ROOM_3, 10, 18`), group `DUNGEONS` = 3, map id 68 +- Connections: none + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 33 | `SILVER_CAVE_ROOM_2` | 2 | + +**Coord events** (`def_coord_events`) + +None. Red is *not* a trip-wire; you have to walk into him. + +**BG events** (`def_bg_events`) + +None. + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `SILVERCAVEROOM3_RED` | `SPRITE_RED` | 9 | 10 | `SPRITEMOVEDATA_STANDING_UP`, radius 0/0, hours -1/-1, pal `PAL_NPC_RED` | `OBJECTTYPE_SCRIPT` (sight range 0) | `Red` | `EVENT_RED_IN_MT_SILVER` | + +Note the type: Red is `OBJECTTYPE_SCRIPT`, **not** `OBJECTTYPE_TRAINER`. He has +no sight cone, no `loadtrainer` in his object row, and no "trainer spotted" +exclamation. You must stand next to him and press A. + +**Scripts of interest** + +`Red` (`maps/SilverCaveRoom3.asm:9`, symbol `47:425e`), in order: + +1. `special FadeOutMusic` +2. `faceplayer` / `opentext` / `writetext RedSeenText` ("…" / "…") / `waitbutton` / `closetext` +3. `winlosstext RedWinLossText, RedWinLossText` - the same "…" text for both outcomes +4. `loadtrainer RED, RED1` +5. `startbattle` +6. `dontrestartmapmusic` +7. `reloadmapafterbattle` +8. `special FadeOutMusic` +9. `opentext` / `writetext RedLeavesText` / `waitbutton` / `closetext` +10. `special FadeOutToBlack` / `special ReloadSpritesNoPalettes` +11. `disappear SILVERCAVEROOM3_RED` - sets `EVENT_RED_IN_MT_SILVER` +12. `pause 15` / `special FadeInFromBlack` / `pause 30` +13. `special HealParty` +14. `reanchormap` +15. `credits` +16. `end` + +There is no `checkevent`/`iftrue` guard anywhere in the script: the object flag +is the only gate, and the script is unconditional once you talk to him. There is +also no `applymovement` - "He flies away" in the walkthrough is prose; the asm +just fades to black and `disappear`s him. + +`credits` is opcode `$a0` (`engine/overworld/scripting.asm:227`). +`Script_credits` (`engine/overworld/scripting.asm:2215`) farcalls `RedCredits` +(`engine/events/halloffame.asm:34`), which fades to white, sets +`wSpawnAfterChampion = SPAWN_RED`, and jumps into `Credits`; on return the +script is force-ended and `MAPSTATUS_DONE` is loaded. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_RED_IN_MT_SILVER` | `constants/event_flags.asm:1284` | **set** by `InitializeEventsScript` (`engine/events/std_scripts.asm:526`) at new game; **cleared** by `maps/HallOfFame.asm:36`; **set** again by `disappear SILVERCAVEROOM3_RED` inside `Red` | Set = Red hidden (see the `CheckObjectFlag` polarity note above). So: absent on a fresh save, present after your first Hall of Fame induction, gone once beaten, and present again after every subsequent Hall of Fame. That is the asm behind "If you want to fight Red again, beat the Elite Four again and then he'll be back at Mt. Silver." | + +No `SCENE_*` values: `def_scene_scripts` is empty for this map (`wSilverCaveRoom3SceneID` exists in WRAM at `01:d6ea` but nothing writes a non-zero scene to it). + +**Items**: none. + +**Trainers** + +| const | class | id | party (`data/trainers/parties.asm` label) | script label | rematch/phone | +|---|---|---|---|---|---| +| `RED1` | `RED` (`trainerclass RED ; 3f` = 63, `constants/trainer_constants.asm:637`) | 1 | `RedGroup` (`data/trainers/parties.asm:3143`, symbol `0e:75d5`) | `Red` | No phone number, no rematch table; he re-appears only via `EVENT_RED_IN_MT_SILVER` being cleared at the Hall of Fame | + +`RedGroup`, `db "RED@", TRAINERTYPE_MOVES` - four explicit moves per mon, in +send-out order: + +| # | level | species | moves | +|---|---|---|---| +| 1 | 81 | PIKACHU | CHARM, QUICK_ATTACK, THUNDERBOLT, THUNDER | +| 2 | 73 | ESPEON | MUD_SLAP, REFLECT, SWIFT, PSYCHIC_M | +| 3 | 75 | SNORLAX | AMNESIA, SNORE, REST, BODY_SLAM | +| 4 | 77 | VENUSAUR | SUNNY_DAY, GIGA_DRAIN, SYNTHESIS, SOLARBEAM | +| 5 | 77 | CHARIZARD | FLAMETHROWER, WING_ATTACK, SLASH, FIRE_SPIN | +| 6 | 77 | BLASTOISE | RAIN_DANCE, SURF, BLIZZARD, WHIRLPOOL | + +Class attributes (`data/trainers/attributes.asm:377-381`, the `; Red` block): + +- items: `FULL_RESTORE, FULL_RESTORE` - **two** slots, not three +- base reward: `25` +- AI: `AI_BASIC | AI_SETUP | AI_SMART | AI_AGGRESSIVE | AI_CAUTIOUS | AI_STATUS | AI_RISKY` +- item/switch: `CONTEXT_USE | SWITCH_SOMETIMES` + +Prize money: `ComputeTrainerReward` +(`engine/battle/read_trainer_party.asm:300`) computes +`wBattleReward = base_reward * wCurPartyLevel`, and `wCurPartyLevel` at that +point is the level of the **last** party row, i.e. Blastoise's 77. So +`25 * 77 = 1925`. `WinTrainerBattle` (`engine/battle/core.asm:2292`) then pays it +out with `ld c, 4` - four additions - giving **7700**, which matches the +walkthrough's "You get: 7700G" exactly. (Amulet Coin doubles it via +`.DoubleReward` before the split; Mom's savings take a share of the four +quarters.) + +**Wild encounters** + +`data/wild/johto_grass.asm:1407`, `def_grass_wildmons SILVER_CAVE_ROOM_3`, +`6 percent`, identical across morn/day/nite: + +| slot | species | +|---|---| +| 1 | 51 GOLBAT | +| 2 | 48 ONIX | +| 3 | 48 GOLBAT | +| 4 | 50 URSARING (Silver: DONPHAN) | +| 5 | 51 GOLDUCK | +| 6 | 20 LARVITAR | +| 7 | 15 LARVITAR | + +No water table for Room 3. + +**Terrain notes** + +A single long north-south corridor. From the entrance warp at (9,33) the path +runs straight up the x=9..10 column to the wide chamber at y=9..16, then to the +top room; Red stands at (9,10), squarely in the corridor mouth, so you cannot +pass him without triggering the fight. (11,33) also carries `WARP_CARPET_DOWN` +collision but has no `warp_event`. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| Reaching Route 28 at all (the north passage of the gate) | `maps/VictoryRoadGate.asm:19` `VictoryRoadGateBadgeCheckScript` / `VictoryRoadGateOfficerScript` (symbols `5f:4fdc` / `5f:4fe2`), fired by `coord_event 10, 11, SCENE_VICTORYROADGATE_BADGE_CHECK, VictoryRoadGateBadgeCheckScript` | `readvar VAR_BADGES` / `ifgreater NUM_JOHTO_BADGES - 1` - strictly more than 7 Johto badges | On pass: `setscene SCENE_VICTORYROADGATE_NOOP`, so the check never fires again. On fail: `applymovement PLAYER, VictoryRoadGateStepDownMovement` shoves you back one step. Note the west warps to Route 28 at (1,7)/(2,7) are *below* the trip-wire row; the badge scene guards the north corridor. | +| Mt. Silver "opened" flavour | `maps/OaksLab.asm:18-45` `Oak` -> `.CheckBadges` / `.OpenMtSilver` | `readvar VAR_BADGES` / `ifequal NUM_BADGES` (all 16) when you talk to Prof. Oak | `setevent EVENT_OPENED_MT_SILVER` (`constants/event_flags.asm:1265`). The only other reader is `maps/VictoryRoadGate.asm:118`, where it is the hide-flag of the left Black Belt at (7,5). **No script in any Mt. Silver map reads it**, and it does not appear in a `checkevent` on any warp. | +| Room 1 darkness | `engine/tilesets/timeofday_pals.asm:113` `ReplaceTimeOfDayPals` -> `.NeedsFlash`, driven by `PALETTE_DARK` in `data/maps/maps.asm:144` | `STATUSFLAGS_FLASH_F` in `wStatusFlags`, set by `BlindingFlash` (`engine/events/field_moves.asm:12`) | Use HM05 Flash. `FlashFunction` (`engine/events/overworld.asm:271`) needs `ENGINE_ZEPHYRBADGE` **and** `wTimeOfDayPalset == DARKNESS_PALSET`. Rooms 2/3 and the Item Rooms are `PALETTE_NITE`/`PALETTE_DAY`, so Flash is only needed for Room 1. It is a visibility gate, not a movement gate. | +| Room 2 lakes | `SurfFunction.TrySurf` (`engine/events/overworld.asm:322-345`) | `ENGINE_FOGBADGE` + a party mon with SURF + facing a `WATER_TILE` | Fog Badge (Morty). Blocks both Item Rooms entrances and everything past y<30 on the west lake. | +| Room 2 waterfalls | `WaterfallFunction.TryWaterfall` (`engine/events/overworld.asm:611`) and `TryWaterfallOW` (`engine/events/overworld.asm:687`), gated by `CheckMapCanWaterfall` | `ENGINE_RISINGBADGE`, party mon with WATERFALL, player facing UP, and `wTileUp` a waterfall tile | Rising Badge (Clair). Without it neither item room is reachable: the north branch (Full Restore at (23,3)) and the south branch (Max Revive at (13,21)) both sit above a waterfall band. | +| Red himself | `maps/SilverCaveRoom3.asm:62` object row's event flag | `EVENT_RED_IN_MT_SILVER` must be **clear** | Cleared by `maps/HallOfFame.asm:36`, i.e. by completing the Elite Four / Lance and being inducted. Before that the object is masked by `CheckObjectFlag` and Room 3 is an empty corridor. | + +Not a gate, worth knowing: nothing in this section checks Strength, Whirlpool, +Cut or Rock Smash. The walkthrough's "bring all the HMs" is advice, not a +requirement - Flash, Surf and Waterfall are the only three the asm cares about, +and Flash only for comfort. + +--- + +## 4. Bot checklist + +Preconditions for the whole section: `EVENT_BEAT_ELITE_FOUR` set (Hall of Fame +done, which is what clears `EVENT_RED_IN_MT_SILVER`), 8 Johto badges for the +Victory Road Gate scene, `ENGINE_FOGBADGE` + `ENGINE_RISINGBADGE` for the item +rooms, `ENGINE_ZEPHYRBADGE` + Flash for Room 1 visibility. + +1. `MAP_VICTORY_ROAD_GATE` - walk onto (10,11) if the scene is still + `SCENE_VICTORYROADGATE_BADGE_CHECK`; talk through + `VictoryRoadGateOfficerScript`. Pre: `VAR_BADGES > 7`. Post: scene becomes + `SCENE_VICTORYROADGATE_NOOP`. Then take warp 7 or 8 at (1,7)/(2,7) to + `MAP_ROUTE_28`. +2. `MAP_ROUTE_28` - cross west onto the map connection. Post: now on + `MAP_SILVER_CAVE_OUTSIDE`. (Route 28 body belongs to the previous section.) +3. `MAP_SILVER_CAVE_OUTSIDE` - entering fires + `SilverCaveOutsideFlypointCallback`. Post: `ENGINE_FLYPOINT_SILVER_CAVE` set; + Fly to `LANDMARK_SILVER_CAVE` now works, and the respawn point is (23,20). +4. `MAP_SILVER_CAVE_OUTSIDE` - optional: face (9,25) and press A (or run the + ITEMFINDER) for the hidden `FULL_RESTORE`. Pre: + `EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE` clear. Post: it is set. +5. `MAP_SILVER_CAVE_OUTSIDE` - optional heal: warp 1 at (23,19) -> + `MAP_SILVER_CAVE_POKECENTER_1F`, talk to `SILVERCAVEPOKECENTER1F_NURSE` at + (3,1), leave by warp 1 at (3,7). +6. `MAP_SILVER_CAVE_OUTSIDE` - step on warp 2 at (18,11). Post: on + `MAP_SILVER_CAVE_ROOM_1` at (9,33). +7. `MAP_SILVER_CAVE_ROOM_1` - use Flash from the party menu (`FlashFunction`). + Pre: `ENGINE_ZEPHYRBADGE`, HM05 on a party mon, `wTimeOfDayPalset == + DARKNESS_PALSET`. Post: `STATUSFLAGS_FLASH_F` set. +8. `MAP_SILVER_CAVE_ROOM_1` - walk to `SILVERCAVEROOM1_POKE_BALL3` at (5,30) and + press A (approach along the ledge row from above, x=2..6 at y=30). Post: + `ESCAPE_ROPE` in the bag, `EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE` set. Then + press DOWN while standing on the ledge to hop to y=32. +9. `MAP_SILVER_CAVE_ROOM_1` - `SILVERCAVEROOM1_POKE_BALL2` at (15,29) -> + `X_ACCURACY`, flag `EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY`. +10. `MAP_SILVER_CAVE_ROOM_1` - `SILVERCAVEROOM1_POKE_BALL1` at (4,9) -> + `MAX_ELIXER`, flag `EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER`. +11. `MAP_SILVER_CAVE_ROOM_1` - optional hidden items: (17,12) `ULTRA_BALL`, + (16,23) `DIRE_HIT`. +12. `MAP_SILVER_CAVE_ROOM_1` - step on warp 2 at (15,1). Post: on + `MAP_SILVER_CAVE_ROOM_2` at (17,31). +13. `MAP_SILVER_CAVE_ROOM_2` - optional hidden `MAX_POTION` at (14,31), two + cells west of where you land. +14. `MAP_SILVER_CAVE_ROOM_2` (Full Restore branch) - walk to (11,11), face down, + Surf onto (11,12). Follow the lake east to x=18..21 at y=14..15, face UP on + y=10 and use Waterfall. Come ashore at (21,7), walk to (23,4), step up onto + the warp at (23,3). Post: on `MAP_SILVER_CAVE_ITEM_ROOMS` at (7,15). +15. `MAP_SILVER_CAVE_ITEM_ROOMS` - `SILVERCAVEITEMROOMS_POKE_BALL2` at (15,11) + -> `FULL_RESTORE`, flag `EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE`. Leave + by warp 2 at (7,15). +16. `MAP_SILVER_CAVE_ROOM_2` (Max Revive branch, optional, not in the + walkthrough) - from the landing shelf walk to (14,30), Surf west, Waterfall + up the x=6..11 band at y=28..29, come ashore at (11,25), walk to (13,22) and + step up onto the warp at (13,21). Take + `SILVERCAVEITEMROOMS_POKE_BALL1` at (6,3) -> `MAX_REVIVE`. Leave by warp 1 + at (13,3). +17. `MAP_SILVER_CAVE_ROOM_2` - step on warp 2 at (11,5). Post: on + `MAP_SILVER_CAVE_ROOM_3` at (9,33). +18. `MAP_SILVER_CAVE_ROOM_3` - walk north up the x=9..10 corridor to (9,11), + face UP, press A on `SILVERCAVEROOM3_RED` at (9,10). Pre: + `EVENT_RED_IN_MT_SILVER` clear. This runs `Red`. +19. Battle `RED` / `RED1`. Six mons, levels 81/73/75/77/77/77, two Full Restores + on the AI. Post on victory: 7700 money (before Amulet Coin / Mom split), + `EVENT_RED_IN_MT_SILVER` set by `disappear`, party healed by + `special HealParty`, then `credits`. +20. After the credits the game re-enters at `SPAWN_MT_SILVER` = + `SILVER_CAVE_OUTSIDE` (23,20) with `hMapEntryMethod = MAPSETUP_WARP`. Save + here (the walkthrough's "After the credits roll, save"). + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| Map headers / warps / bg events / object events for all six maps | `src/import/RomExtractorGen2.lua` (warp + coord + bg + object decode around lines 785-861 and 2959-2990) | implemented - the maps are decoded generically from the ROM, so no Silver Cave specific data is hand-written anywhere | +| `itemball` object rows (Escape Rope, X Accuracy, Max Elixer, Max Revive, Full Restore) | `src/import/RomExtractorGen2.lua:2874`, `:2968` (`readItemBall`) | implemented | +| `hiddenitem` bg rows (Full Restore, Dire Hit, Ultra Ball, Max Potion) | `src/world/gen2/HiddenItems.lua` | implemented | +| `MAPCALLBACK_NEWMAP` flypoint callback | `src/world/gen2/Map.lua` + driver `tests/drivers/gold_map_callbacks.lua` | implemented | +| `ENGINE_FLYPOINT_SILVER_CAVE` -> Fly destination | `src/world/gen2/FieldMoves.lua:355` (`LANDMARK_SILVER_CAVE` / `SPAWN_MT_SILVER` / flag 75), `src/ui/gen2/Pokegear.lua:1478` | implemented | +| Flash (dark-cave palette gate) | `src/world/gen2/FieldMoves.lua:465-475` (`flashFromMenu`), badge table at `:105` | implemented | +| Surf | `src/world/gen2/FieldMoves.lua:477-500`, `src/world/gen2/Permissions.lua` | implemented | +| Waterfall (`CheckMapCanWaterfall`, `.CheckContinueWaterfall`) | `src/world/gen2/FieldMoves.lua:253-266`, `:528-540` | implemented | +| Gen 2 ledge hop (`.TryJump` / `HI_NYBBLE_LEDGES`) - needed for the Escape Rope drop in Room 1 | no gen2 implementation found; `src/world/gen2/Player.lua` is a plain grid stepper and `src/world/gen2/Permissions.lua` only models LAND/WATER/WALL. `src/world/OverworldController.lua:1330` `checkLedgeHop` is the **Gen 1** model (`data/tilesets/ledge_tiles.asm`, standing-tile + front-tile pair) and does not match Gen 2's standing-tile-nybble rule | missing | +| `loadtrainer` / `startbattle` / `winlosstext` | `src/script/gen2/Vm.lua:806`, `:817`; opcodes in `src/script/gen2/Opcodes.lua:99-105` | implemented | +| `disappear` | `src/script/gen2/Opcodes.lua:115`, handled in `src/script/gen2/Vm.lua` | implemented | +| `special FadeOutMusic` / `FadeOutToBlack` / `FadeInFromBlack` / `ReloadSpritesNoPalettes` / `HealParty` | `src/script/gen2/Specials.lua:450`, `:999`, `:1001`, `:1025`, `:1066` | implemented | +| `credits` opcode as a terminal script op | `src/script/gen2/Opcodes.lua:172`, `:194` | implemented | +| Red-credits path and post-credits respawn at Mt. Silver | `src/core/gen2/HallOfFame.lua:48-53`, `:239-259` (`markRedCredits`, `POST_CREDITS_SPAWN.SPAWN_RED = "SPAWN_MT_SILVER"`), test `tests/gen2_halloffame_test.lua:171` | implemented | +| Credits reel itself | `src/ui/Credits.lua`, driver `tests/drivers/gold_halloffame_shots.lua` | implemented (shared with the Hall of Fame credits) | +| Prize money (base reward x last level, x4, Mom split) | `src/battle/gen2/Prize.lua` | implemented - the `ld c, 4` factor is modelled explicitly, which is what makes Red's 7700 come out right | +| Trainer AI item use (Red's two Full Restores) | `src/battle/gen2/Ai.lua:1294`, `:1369-1375` | implemented | +| Wild tables incl. the morn/day/nite split | `src/import/RomExtractorGen2.lua:3720-3845` (`JohtoGrassWildMons` / `JohtoWaterWildMons` / `FishGroups`) | implemented | +| Trainer parties incl. `RedGroup` | `src/import/RomExtractorGen2.lua:3859-4001` (`TrainerGroups`) | implemented | +| A driver that actually reaches Red | none - the closest are `tests/drivers/gold_trainer_smoke.lua` and `tests/drivers/gold_halloffame_shots.lua` | missing | + +--- + +## 6. Unresolved / verify by hand + +- **"Items in Mount Silver: Escape Rope, Full Restore, Protein."** There is no + `PROTEIN` anywhere in `maps/SilverCaveOutside.asm`, `SilverCaveRoom1.asm`, + `SilverCaveRoom2.asm`, `SilverCaveRoom3.asm` or `SilverCaveItemRooms.asm`. The + full real list is Max Elixer, X Accuracy, Escape Rope, Max Revive, Full Restore + (balls) plus hidden Full Restore, Dire Hit, Ultra Ball, Max Potion. The + walkthrough's summary list is also missing the X Accuracy and Max Elixir it + later tells you to pick up. +- **"Pokémon in Mount Silver: ... #232 Donphan"** - Donphan is the *Silver* + column of every one of these `def_grass_wildmons` blocks. In Gold the same + slots are Ursaring. Same file, `IF DEF(_GOLD)` / `ELIF DEF(_SILVER)`. +- **"Red will have three Full Restores"** - `data/trainers/attributes.asm:378` + gives the RED class exactly two item slots, both `FULL_RESTORE`. Could not find + a third anywhere. +- **Red's party order.** The walkthrough lists Pikachu, Espeon, Blastoise, + Snorlax, Charizard, Venusaur. `RedGroup` orders them Pikachu, Espeon, Snorlax, + Venusaur, Charizard, Blastoise. Only the lead matches. The listed EXP yields + (1422 / 3081 / 3465 / 2475 / 3448 / 3432) are not stored anywhere as data - + they are computed from base experience and level at runtime - so they could not + be confirmed against a table. +- **"Espeon is a dark type that cannot be hit with ghost moves."** Espeon is + Psychic in `data/pokemon/base_stats/espeon.asm`; the ghost-immunity claim + describes Umbreon/Dark. Strategy prose, not asm, but a bot following it would + make bad move choices. +- **"He flies away."** The `Red` script has no `applymovement` and no fly + animation - `special FadeOutToBlack`, `disappear`, `special FadeInFromBlack`. + Cosmetic discrepancy only. +- **Which of the two Item Rooms the walkthrough means.** The text mentions only + the Full Restore, which I traced to `SilverCaveRoom2` warp 4 at (23,3) -> + `SilverCaveItemRooms` warp 2 at (7,15). That mapping is derived from expanding + `maps/SilverCaveRoom2.blk` and `maps/SilverCaveItemRooms.blk` through + `data/tilesets/cave_collision.asm`, not from an explicit statement in any asm + file. The Surf/Waterfall requirement on both branches comes from the same + derivation. Worth confirming in-game before a bot commits to the route. +- **`EVENT_OPENED_MT_SILVER` does nothing on these maps.** The walkthrough (and + common knowledge) says you need all 16 badges and a word with Prof. Oak to + enter Mt. Silver. `maps/OaksLab.asm` does set the flag on 16 badges, but the + only consumer in the whole tree is the hide-flag of a Black Belt in + `maps/VictoryRoadGate.asm:118`. I could not find a `checkevent` on that flag + guarding any warp, coord event or NPC on the Route 28 / Silver Cave path. If + the 16-badge requirement is real it is enforced somewhere I did not locate - + verify by hand before assuming a bot with 8 Johto badges can walk in. +- **Two `WARP_CARPET_DOWN` cells with no `warp_event`**: (11,27) and (13,27) in + Room 1, and (11,33) in Room 3. Collision says "warp", the event table says + nothing. Assumed decorative; not tested. diff --git a/docs/gold-walkthrough/asm-walk/section-32-raikou-entei-and-suicune.md b/docs/gold-walkthrough/asm-walk/section-32-raikou-entei-and-suicune.md new file mode 100644 index 00000000..b25771dc --- /dev/null +++ b/docs/gold-walkthrough/asm-walk/section-32-raikou-entei-and-suicune.md @@ -0,0 +1,823 @@ +# Section 32 - Raikou, Entei, and Suicune + +Source: `../section-32-raikou-entei-and-suicune.txt` +Maps covered: `MAP_ROUTE_29`, `MAP_ROUTE_30`, `MAP_ROUTE_31`, `MAP_ROUTE_32`, +`MAP_ROUTE_33`, `MAP_ROUTE_34`, `MAP_ROUTE_35`, `MAP_ROUTE_36`, `MAP_ROUTE_37`, +`MAP_ROUTE_38`, `MAP_ROUTE_39`, `MAP_ROUTE_42`, `MAP_ROUTE_43`, `MAP_ROUTE_44`, +`MAP_ROUTE_45`, `MAP_ROUTE_46` (the sixteen `RoamMaps` entries), plus +`MAP_VIOLET_CITY` as the Fly anchor the walkthrough names. +Badges / key milestones in this section: none. The milestone is three catches: +`RAIKOU`, `ENTEI`, `SUICUNE`. Every one of them clears its own roam slot +permanently. + +This section is a *mechanic*, not a walk. There is no scripted trigger anywhere +on these sixteen maps that has anything to do with the beasts; the entire +feature lives in `engine/overworld/wildmons.asm`, +`engine/battle/core.asm` and `data/wild/roammon_maps.asm`. Section 2 therefore +opens with a "roam engine" block before the per-map blocks, because that block +is the part a bot actually has to implement. + +--- + +## 1. Route order + +The walkthrough gives a policy, not an itinerary: cross route boundaries as fast +as possible, because every crossing is one `UpdateRoamMons` call. The named +concrete loop is the Violet City / Route 36 hub. + +| # | Map constant | asm file | Enter via | Leaves via | Why (walkthrough beat) | +|---|---|---|---|---|---| +| 0 | `MAP_BURNED_TOWER_B1F` | `maps/BurnedTowerB1F.asm` | (earlier section) | (earlier section) | `special InitRoamMons` at `ReleaseTheBeasts` is the only thing that ever creates the three roamers. Out of scope here; owned by the Burned Tower section. | +| 1 | `MAP_VIOLET_CITY` | `maps/VioletCity.asm` | Fly (`MAPSETUP_TELEPORT`) | west connection | "fly to Violet City, then stay on a bike and keep switching between Routes 35, 36, and 37". Note the Fly itself runs `JumpRoamMons`, i.e. it *scatters* the beasts before the loop starts. | +| 2 | `MAP_ROUTE_36` | `maps/Route36.asm` | west connection from Violet City | south / north connections | Route 36 is the four-way roam junction (`ROUTE_35`, `ROUTE_31`, `ROUTE_32`, `ROUTE_37`) and the only map with a direct edge to Violet City. | +| 3 | `MAP_ROUTE_35` | `maps/Route35.asm` | south connection from Route 36 | north connection back | Half of the "left right left right" oscillation. Each crossing = one `UpdateRoamMons`. | +| 4 | `MAP_ROUTE_37` | `maps/Route37.asm` | north connection from Route 36 | south connection back | Other half of the oscillation, and Entei's starting route. | +| 5 | `MAP_ROUTE_42` | `maps/Route42.asm` | west connection from Ecruteak City | east connection to Mahogany Town | "go to Route 42 with the Max Repel". Raikou's starting route, the other four-way roam junction, and it carries a free `ULTRA_BALL` item ball. | +| - | `MAP_SILVER_CAVE_OUTSIDE` / `MAP_SILVER_CAVE_ROOM_*` | `maps/SilverCaveOutside.asm` etc. | - | - | "sharing EXP at Mount Silver" to raise the False Swipe Scyther. Mount Silver belongs to a later section; only the level ceiling (see section 3) matters here. | + +Spill-over note: the walkthrough's Master Ball aside points at the Elm / +Radio Tower reward, and its Scyther training points at Mount Silver. Both are +other sections' maps; nothing about them is transcribed here. + +--- + +## 2. Maps + +### The roam engine (not a map) + +This is the part of the section that is code rather than map data. Everything +below was read out of the files named. + +**State: `roam_struct`** (`macros/ram.asm`, 7 bytes each, three of them) + +| field | symbol (Raikou slot) | address | meaning | +|---|---|---|---| +| Species | `wRoamMon1Species` | `01:dd1a` | `0` once caught or defeated, which is what retires the slot forever | +| Level | `wRoamMon1Level` | `01:dd1b` | always 40, never changes (no EXP in the struct) | +| MapGroup | `wRoamMon1MapGroup` | `01:dd1c` | `GROUP_N_A` when retired | +| MapNumber | `wRoamMon1MapNumber` | `01:dd1d` | `MAP_N_A` when retired | +| HP | `wRoamMon1HP` | `01:dd1e` | **one byte**; `0` means "stats not rolled yet" | +| DVs | `wRoamMon1DVs` | `01:dd1f` | rolled once, on the first encounter, then kept | + +Entei is slot 2 (`wRoamMon2Species` `01:dd21`), Suicune slot 3 +(`wRoamMon3Species` `01:dd28`). Slot order is load bearing: +`CheckEncounterRoamMon` indexes the structs by a random 0..2 and +`GetRoamMonHP` walks them by species. + +Player-position backup: `wRoamMons_CurMapGroup` `01:dd30`, +`wRoamMons_LastMapGroup` `01:dd32` (`_BackUpMapIndices`, `0a:6942`). + +**Creation: `InitRoamMons`** (`engine/overworld/wildmons.asm:488`, `0a:67d7`) + +Called exactly once, from `maps/BurnedTowerB1F.asm:65`, at the tail of +`ReleaseTheBeasts` right after `setscene SCENE_BURNEDTOWERB1F_NOOP` and +`setevent EVENT_RELEASED_THE_BEASTS`. It writes: + +| slot | species | level | starting map | +|---|---|---|---| +| 1 | `RAIKOU` | 40 | `MAP_ROUTE_42` (`GROUP_ROUTE_42`) | +| 2 | `ENTEI` | 40 | `MAP_ROUTE_37` (`GROUP_ROUTE_37`) | +| 3 | `SUICUNE` | 40 | `MAP_ROUTE_38` (`GROUP_ROUTE_38`) | + +HP is written as `0` (`xor a ; generate new stats`). + +**The map graph: `RoamMaps`** (`data/wild/roammon_maps.asm`, `0a:695b`, +`NUM_ROAMMON_MAPS` = 16). Transcribed verbatim: + +| start map | reachable maps | +|---|---| +| `ROUTE_29` | `ROUTE_30`, `ROUTE_46` | +| `ROUTE_30` | `ROUTE_29`, `ROUTE_31` | +| `ROUTE_31` | `ROUTE_30`, `ROUTE_32`, `ROUTE_36` | +| `ROUTE_32` | `ROUTE_36`, `ROUTE_31`, `ROUTE_33` | +| `ROUTE_33` | `ROUTE_32`, `ROUTE_34` | +| `ROUTE_34` | `ROUTE_33`, `ROUTE_35` | +| `ROUTE_35` | `ROUTE_34`, `ROUTE_36` | +| `ROUTE_36` | `ROUTE_35`, `ROUTE_31`, `ROUTE_32`, `ROUTE_37` | +| `ROUTE_37` | `ROUTE_36`, `ROUTE_38`, `ROUTE_42` | +| `ROUTE_38` | `ROUTE_37`, `ROUTE_39`, `ROUTE_42` | +| `ROUTE_39` | `ROUTE_38` | +| `ROUTE_42` | `ROUTE_43`, `ROUTE_44`, `ROUTE_37`, `ROUTE_38` | +| `ROUTE_43` | `ROUTE_42`, `ROUTE_44` | +| `ROUTE_44` | `ROUTE_42`, `ROUTE_43`, `ROUTE_45` | +| `ROUTE_45` | `ROUTE_44`, `ROUTE_46` | +| `ROUTE_46` | `ROUTE_45`, `ROUTE_29` | + +The file's own comment: "Notably missing are Route 40 and Route 41, which are +water routes." That is the walkthrough's "the exceptions are Routes 40 and 41". + +**Movement: `UpdateRoamMons`** (`engine/overworld/wildmons.asm:578`, `0a:6859`) + +`.Update` per live beast, then `_BackUpMapIndices`. One random byte does double +duty: + +- `and %00011111`; if the result is zero (1 in 32) it calls `JumpRoamMon` + instead, i.e. a completely random `RoamMaps` entry. +- otherwise `and %11` of that same masked value is the connection index. An + index `>=` the entry's connection count re-rolls, and so does a candidate + equal to `wRoamMons_LastMapGroup`/`wRoamMons_LastMapNumber`, which is the map + the player was on *before* the one they are on now. That last check is what + stops a beast shadowing a player who paces one boundary. + +Because the jump test and the index share one byte, they are not independent +rolls. + +**When `UpdateRoamMons` runs** (`data/maps/setup_scripts.asm`) + +| map setup script | contains `UpdateRoamMons`? | reached by | +|---|---|---| +| `MapSetupScript_Connection` | yes (line 93) | walking across a map connection, `engine/overworld/events.asm:1013 reloadend MAPSETUP_CONNECTION` | +| `MapSetupScript_Train` | yes (line 121) | magnet train, and everything that falls into it | +| `MapSetupScript_Door` | yes, by falling through into `_Train` | normal door/stair warps, `engine/overworld/events.asm:993`; Dig / Escape Rope, `engine/events/overworld.asm:836` | +| `MapSetupScript_Fall` | yes, by falling through `_Door` into `_Train` | hole falls | +| `MapSetupScript_Warp` | **no** | `warp` script opcode (`engine/overworld/scripting.asm:1958`), whiteout (`engine/events/whiteout.asm:21`), new game | +| `MapSetupScript_ReloadMap` | no | `reloadmap` | +| `MapSetupScript_Teleport` | no, runs `JumpRoamMons` instead (line 29) | Fly (`engine/events/overworld.asm:604`), Teleport (`:921`) | + +So the walkthrough's "they change route when you change route or enter/exit a +city, cave, or building" is exactly right: connections and door warps both +update. A scripted `warp` does not. + +**Scattering: `JumpRoamMons`** (`:677`, `0a:68e0`) / `JumpRoamMon` (`:710`, +`0a:6919`). Picks a uniformly random `RoamMaps` entry, re-rolling only while it +lands on the player's own current map. Two callers: + +- `MapSetupScript_Teleport` (Fly / Teleport). This is the walkthrough's "Flying + will reset the roaming legendary's location". +- `engine/menus/intro_menu.asm:283`, `farcall JumpRoamMons`, on **Continue**. + Loading a save scatters all three. + +**Meeting one: `CheckEncounterRoamMon`** (`:531`, `0a:681a`), called from the +very top of `ChooseWildEncounter` (`0a:66ab`), which itself is called from +`TryWildEncounter` (`0a:6643`) *after* the map's encounter-rate roll has already +passed. Order of gates: + +1. `CheckOnWater` -> on water, no roamer. (Suicune cannot be met while surfing.) +2. One random byte, `cp 100` -> 100/256 pass. +3. `and %00000011`, `jr z` -> 3/4 of those pass. Running total 75/256, about + 29.3%, which the asm comments itself. +4. `dec a` -> slot 0, 1 or 2, evenly. +5. That one slot's stored map group/number must equal the player's. There is + **no re-roll onto another beast**, so two beasts on your route still get one + roll each. +6. On success: `wTempWildMonSpecies` = the beast, `wCurPartyLevel` = 40, + `wBattleType` = `BATTLETYPE_ROAMING`, and `ChooseWildEncounter` returns carry + straight to `.startwildbattle`, skipping the map's own slot table entirely. A + roamer *replaces* the normal encounter. + +**Sweet Scent is a separate, better door.** `engine/events/sweet_scent.asm:31` +`SweetScentEncounter` runs `CanEncounterWildMon`, then only checks that +`GetMapEncounterRate` is **nonzero** (`ld a, b / and a / jr z`), then calls +`ChooseWildEncounter` directly. It never performs the percentage roll and never +reaches `CheckRepelEffect`. So every Sweet Scent use in grass on a roam route is +a guaranteed encounter with a flat ~9.8% chance (1/3 of 75/256) of being the +specific beast standing there. The walkthrough does not mention this. + +**Battle behaviour** (`engine/battle/core.asm`) + +- DVs, `:5832`-`:5870`: if the struct's HP byte is nonzero the stored DVs are + reused, otherwise fresh DVs are rolled and written back. A beast you chase all + game is one individual. +- HP, `:6068`-`:6090`: same test. Zero takes `.InitRoamHP`, which writes the + mon's full HP into the struct on the *first* encounter. Non-zero loads the + stored value into `wEnemyMonHP + 1`. This is the walkthrough's "once you've + weakened a legendary once, it will remain at that HP". The comment notes only + the low byte is stored because Raikou and Entei are under 256 HP at level 40. +- `TryEnemyFlee` (`:711`, `0f:44fa`), called at the head of the enemy's half of + the turn in both orders (`Battle_EnemyFirst:823`, `Battle_PlayerFirst:885`). + It bails (beast stays) only if: trainer battle, `SUBSTATUS_CANT_RUN` on the + player (Mean Look / Spider Web), a live `wEnemyWrapCount`, or the enemy is + `FRZ` or asleep. Otherwise `AlwaysFleeMons` (`data/wild/flee_mons.asm`, + `0f:4568`) is exactly `RAIKOU, ENTEI, SUICUNE` and it flees with no roll at + all. This is "the roaming legendaries will all run after one attack or one + ball throw": a failed ball sets `wBattlePlayerAction = BATTLEPLAYERACTION_USEITEM`, + `DetermineMoveOrder` sends it to `.player_first`, and `Battle_PlayerFirst` + still reaches `call TryEnemyFlee`. + Note **paralysis does not stop the flee**; only sleep and freeze do. +- `BattleEnd_HandleRoamMons` (`:8237`, `0f:76ac`): on `WIN` (which covers a + catch) it zeroes HP, sets the map pair to `GROUP_N_A` / `MAP_N_A` and zeroes + the species, retiring the slot. On anything else it banks `wEnemyMonHP + 1` + into the struct and calls `UpdateRoamMons`. Its `.not_roaming` tail is the + other half: after **any** other wild battle, `BattleRandom and $f` gives a + 1-in-16 chance the beasts move anyway. + +**Tracking: `FindNest`** (`engine/overworld/wildmons.asm:28`, `0a:6560`). Its +`.RoamMon1/.RoamMon2/.RoamMon3` tails append the roamer's current map to the +nest list whenever `wNamedObjectIndex` matches the slot's species, and only for +the Johto pass (`ld a, e / and a / jr nz, .kanto` returns before them). The one +caller is `engine/pokegear/pokegear.asm:2430`, inside `Pokedex_GetArea` +(`24:5c7f`), i.e. the **Pokedex AREA screen**, which is drawn with the Pokegear +town-map engine and headed `<MON>'S NEST`. See section 6 for the wording +mismatch with the walkthrough. + +**Catch rate** (`engine/items/item_effects.asm`, `PokeBallEffect` `03:6926`) + +- All three beasts have `db 3 ; catch rate` + (`data/pokemon/base_stats/raikou.asm`, `entei.asm`, `suicune.asm`). +- `MASTER_BALL` short-circuits to `.catch_without_fail` before any multiplier. +- `UltraBallMultiplier` (`03:6c92`) is `sla b`, a flat x2 -> effective 6. +- `FastBallMultiplier` (`03:6dbc`) carries a documented bug: it is *intended* to + x4 anything in the three `FleeMons` tables, but the `jr nz, .next` where the + author meant `jr nz, .loop` limits it to the **first three entries of + `SometimesFleeMons`** (Magnemite, Grimer, Tangela). Fast Balls are worthless + on the beasts in Gold/Silver. +- Status: the `.statuscheck` block is also buggy. `+10` for `FRZ`/`SLP` works; + the `BRN`/`PSN`/`PAR` `+5` branch is unreachable because the `ld a, + [wEnemyMonStatus]` reload is commented out. **Paralysis gives no catch bonus + at all**, which contradicts the walkthrough's "asleep or paralyzed". + +--- + +### Roam map set (the other twelve) + +These twelve maps carry no roamer-specific script or object; they matter only as +graph nodes and as places to stand in grass. Header rows are from +`data/maps/maps.asm`, dimensions from `constants/map_constants.asm`. + +| Map constant | asm file | group | W x H (blocks) | tileset / environment / music | header line | +|---|---|---|---|---|---| +| `MAP_ROUTE_29` | `maps/Route29.asm` | `NEW_BARK` | 30 x 9 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_29` | `data/maps/maps.asm:474` | +| `MAP_ROUTE_30` | `maps/Route30.asm` | `CHERRYGROVE` | 10 x 27 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_30` | `:508` | +| `MAP_ROUTE_31` | `maps/Route31.asm` | `CHERRYGROVE` | 20 x 9 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_30` | `:509` | +| `MAP_ROUTE_32` | `maps/Route32.asm` | `VIOLET` | 10 x 45 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_30` | `:247` | +| `MAP_ROUTE_33` | `maps/Route33.asm` | `AZALEA` | 10 x 9 | `TILESET_JOHTO_MODERN` / `ROUTE` / `MUSIC_ROUTE_30` | `:231` | +| `MAP_ROUTE_34` | `maps/Route34.asm` | `GOLDENROD` | 10 x 27 | `TILESET_JOHTO_MODERN` / `ROUTE` / `MUSIC_ROUTE_36` | `:268` | +| `MAP_ROUTE_38` | `maps/Route38.asm` | `OLIVINE` | 20 x 9 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_37` | `:61` | +| `MAP_ROUTE_39` | `maps/Route39.asm` | `OLIVINE` | 10 x 18 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_37` | `:62` | +| `MAP_ROUTE_43` | `maps/Route43.asm` | `LAKE_OF_RAGE` | 10 x 27 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_LAKE_OF_RAGE` | `:241` | +| `MAP_ROUTE_44` | `maps/Route44.asm` | `MAHOGANY` | 30 x 9 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_LAKE_OF_RAGE` | `:73` | +| `MAP_ROUTE_45` | `maps/Route45.asm` | `BLACKTHORN` | 10 x 45 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_36` | `:185` | +| `MAP_ROUTE_46` | `maps/Route46.asm` | `BLACKTHORN` | 10 x 18 | `TILESET_JOHTO` / `ROUTE` / `MUSIC_ROUTE_36` | `:186` | + +Excluded on purpose, and confirmed absent from `RoamMaps`: +`MAP_ROUTE_40` (`maps/Route40.asm`, `CIANWOOD`, 10 x 18, +`data/maps/maps.asm:442`) and `MAP_ROUTE_41` (`maps/Route41.asm`, `CIANWOOD`, +25 x 27, `:443`). + +--- + +### MAP_VIOLET_CITY + +- Script: `maps/VioletCity.asm` +- Header: `data/maps/maps.asm:251` -> `TILESET_JOHTO`, `TOWN`, + `LANDMARK_VIOLET_CITY`, `MUSIC_VIOLET_CITY`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:230`, `map_const VIOLET_CITY, 20, 18` +- Connections (`data/maps/attributes.asm:127`): south `Route32` (`ROUTE_32`, 0), + **west `Route36` (`ROUTE_36`, 0)**, east `Route31` (`ROUTE_31`, 9) + +Included only because it is the walkthrough's Fly anchor and the one city with a +direct edge onto a roam route. It is not itself a roam map, so arriving here +never gives a beast encounter; it only ticks `UpdateRoamMons` on the way in and +out. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 17 | `VIOLET_MART` | 2 | +| 2 | 18 | 17 | `VIOLET_GYM` | 1 | +| 3 | 30 | 17 | `EARLS_POKEMON_ACADEMY` | 1 | +| 4 | 3 | 15 | `VIOLET_NICKNAME_SPEECH_HOUSE` | 1 | +| 5 | 31 | 25 | `VIOLET_POKECENTER_1F` | 1 | +| 6 | 21 | 29 | `VIOLET_KYLES_HOUSE` | 1 | +| 7 | 23 | 5 | `SPROUT_TOWER_1F` | 1 | +| 8 | 39 | 24 | `ROUTE_31_VIOLET_GATE` | 1 | +| 9 | 39 | 25 | `ROUTE_31_VIOLET_GATE` | 2 | + +Every one of these nine is a door warp, i.e. `MAPSETUP_DOOR`, i.e. one +`UpdateRoamMons` in and one on the way out. Stepping in and out of the Pokecenter +is a legal, very short roam tick. + +**Coord events** (`def_coord_events`): none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 24 | 20 | `BGEVENT_READ` | `VioletCitySign` | +| 15 | 17 | `BGEVENT_READ` | `VioletGymSign` | +| 24 | 8 | `BGEVENT_READ` | `SproutTowerSign` | +| 27 | 17 | `BGEVENT_READ` | `EarlsPokemonAcademySign` | +| 32 | 25 | `BGEVENT_READ` | `VioletCityPokecenterSign` | +| 10 | 17 | `BGEVENT_READ` | `VioletCityMartSign` | +| 37 | 14 | `BGEVENT_ITEM` | `VioletCityHiddenHyperPotion` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| (1) | `SPRITE_FISHER` | 13 | 16 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `VioletCityEarlScript` | `EVENT_VIOLET_CITY_EARL` | +| (2) | `SPRITE_LASS` | 28 | 28 | `WANDER` | `OBJECTTYPE_SCRIPT` | `VioletCityLassScript` | -1 | +| (3) | `SPRITE_SUPER_NERD` | 24 | 14 | `WANDER` | `OBJECTTYPE_SCRIPT` | `VioletCitySuperNerdScript` | -1 | +| (4) | `SPRITE_GRAMPS` | 17 | 20 | `WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `VioletCityGrampsScript` | -1 | +| (5) | `SPRITE_YOUNGSTER` | 5 | 18 | `SPINRANDOM_SLOW` | `OBJECTTYPE_SCRIPT` | `VioletCityYoungsterScript` | -1 | +| (6) | `SPRITE_FRUIT_TREE` | 14 | 29 | `STILL` | `OBJECTTYPE_SCRIPT` | `VioletCityFruitTree` | -1 | +| (7) | `SPRITE_POKE_BALL` | 4 | 1 | `STILL` | `OBJECTTYPE_ITEMBALL` | `VioletCityPPUp` | `EVENT_VIOLET_CITY_PP_UP` | +| (8) | `SPRITE_POKE_BALL` | 35 | 5 | `STILL` | `OBJECTTYPE_ITEMBALL` | `VioletCityRareCandy` | `EVENT_VIOLET_CITY_RARE_CANDY` | + +**Wild encounters**: none relevant (town, no grass on the walked path). + +--- + +### MAP_ROUTE_36 + +- Script: `maps/Route36.asm` +- Header: `data/maps/maps.asm:249` -> `TILESET_JOHTO`, `ROUTE`, + `LANDMARK_ROUTE_36`, `MUSIC_ROUTE_36`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:228`, `map_const ROUTE_36, 30, 9` +- Connections (`data/maps/attributes.asm:206`): north `Route37` (`ROUTE_37`, 10), + south `Route35` (`ROUTE_35`, 0), east `VioletCity` (`VIOLET_CITY`, 0) +- Roam edges (`RoamMaps`): `ROUTE_35`, `ROUTE_31`, `ROUTE_32`, `ROUTE_37`. One + of the two four-way junctions, so a beast standing here has the widest spread. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 18 | 8 | `ROUTE_36_NATIONAL_PARK_GATE` | 3 | +| 2 | 18 | 9 | `ROUTE_36_NATIONAL_PARK_GATE` | 4 | +| 3 | 47 | 13 | `ROUTE_36_RUINS_OF_ALPH_GATE` | 1 | +| 4 | 48 | 13 | `ROUTE_36_RUINS_OF_ALPH_GATE` | 2 | + +**Coord events** (`def_coord_events`): none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 29 | 1 | `BGEVENT_READ` | `Route36TrainerTips2` | +| 45 | 11 | `BGEVENT_READ` | `RuinsOfAlphNorthSign` | +| 55 | 7 | `BGEVENT_READ` | `Route36Sign` | +| 21 | 7 | `BGEVENT_READ` | `Route36TrainerTips1` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE36_YOUNGSTER1` | `SPRITE_YOUNGSTER` | 20 | 12 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (sight 2) | `TrainerPsychicMark` | -1 | +| `ROUTE36_YOUNGSTER2` | `SPRITE_YOUNGSTER` | 31 | 14 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (sight 5) | `TrainerSchoolboyAlan1` | -1 | +| `ROUTE36_WEIRD_TREE` | `SPRITE_WEIRD_TREE` | 35 | 9 | `SUDOWOODO` | `OBJECTTYPE_SCRIPT` | `SudowoodoScript` | `EVENT_ROUTE_36_SUDOWOODO` | +| `ROUTE36_LASS1` | `SPRITE_LASS` | 51 | 8 | `WALK_LEFT_RIGHT` | `OBJECTTYPE_SCRIPT` | `Route36LassScript` | -1 | +| `ROUTE36_FISHER` | `SPRITE_FISHER` | 44 | 9 | `STANDING_LEFT` | `OBJECTTYPE_SCRIPT` | `Route36RockSmashGuyScript` | -1 | +| `ROUTE36_FRUIT_TREE` | `SPRITE_FRUIT_TREE` | 21 | 4 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route36FruitTree` (`FRUITTREE_ROUTE_36`) | -1 | +| `ROUTE36_ARTHUR` | `SPRITE_YOUNGSTER` | 46 | 6 | `WANDER` | `OBJECTTYPE_SCRIPT` | `ArthurScript` | `EVENT_ROUTE_36_ARTHUR_OF_THURSDAY` | + +**Scripts of interest** + +- `Route36ArthurCallback` (`MAPCALLBACK_OBJECTS`): `readvar VAR_WEEKDAY`, + `ifequal THURSDAY` -> `appear ROUTE36_ARTHUR`, else `disappear`. Irrelevant to + the beasts but it is the map's only callback, so a bot that emulates map + callbacks must run it. +- `SudowoodoScript`: the one thing on this map that can physically block the + Route 36 loop. `checkitem SQUIRTBOTTLE`; without it the tree just shakes and + the tile stays solid. With it: `loadwildmon SUDOWOODO, 20`, `startbattle`, + `setevent EVENT_FOUGHT_SUDOWOODO`, then `disappear ROUTE36_WEIRD_TREE` on + either branch (`DidntCatchSudowoodo` also disappears it). Once + `EVENT_ROUTE_36_SUDOWOODO` is cleared the tile is free for good. + +**Flags and events** + +| constant | defined in | read/written by | meaning for a bot | +|---|---|---|---| +| `EVENT_ROUTE_36_SUDOWOODO` | `constants/event_flags.asm:1178` | `object_event` visibility; cleared by `disappear` in `SudowoodoScript` | while set, x=35,y=9 is impassable | +| `EVENT_FOUGHT_SUDOWOODO` | `constants/event_flags.asm` | set in `WateredWeirdTreeScript` | gates the Rock Smash gift and the lass's line | +| `EVENT_ROUTE_36_ARTHUR_OF_THURSDAY` | `constants/event_flags.asm` | `Route36ArthurCallback` | day-of-week NPC | + +**Wild encounters** (`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_36`, +rates `10 percent` morn/day/nite, Gold column) + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 12 `NIDORAN_M` | 12 `NIDORAN_M` | 12 `NIDORAN_M` | +| 2 | 12 `NIDORAN_F` | 12 `NIDORAN_F` | 12 `NIDORAN_F` | +| 3 | 13 `PIDGEY` | 14 `PIDGEY` | 13 `HOOTHOOT` | +| 4 | 13 `GROWLITHE` | 13 `GROWLITHE` | 13 `GROWLITHE` | +| 5 | 13 `STANTLER` | 13 `STANTLER` | 13 `STANTLER` | +| 6 | 15 `PIDGEY` | 15 `GROWLITHE` | 15 `HOOTHOOT` | +| 7 | 15 `PIDGEY` | 15 `GROWLITHE` | 15 `HOOTHOOT` | + +(Silver swaps `GROWLITHE` for `VULPIX` and reverses the Nidoran pair.) Max wild +level 15, which matters for the Max Repel plan in section 3. + +--- + +### MAP_ROUTE_35 + +- Script: `maps/Route35.asm` +- Header: `data/maps/maps.asm:248` -> `TILESET_JOHTO`, `ROUTE`, + `LANDMARK_ROUTE_35`, `MUSIC_ROUTE_36`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_POND` +- Dimensions: `constants/map_constants.asm:227`, `map_const ROUTE_35, 10, 18` +- Connections (`data/maps/attributes.asm:202`): north `Route36` (`ROUTE_36`, 0), + south `GoldenrodCity` (`GOLDENROD_CITY`, -5) +- Roam edges: `ROUTE_34`, `ROUTE_35` -> `ROUTE_34`, `ROUTE_36` + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 9 | 33 | `ROUTE_35_GOLDENROD_GATE` | 1 | +| 2 | 10 | 33 | `ROUTE_35_GOLDENROD_GATE` | 2 | +| 3 | 3 | 5 | `ROUTE_35_NATIONAL_PARK_GATE` | 3 | + +**Coord events** (`def_coord_events`): none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 1 | 7 | `BGEVENT_READ` | `Route35Sign` | +| 11 | 31 | `BGEVENT_READ` | `Route35Sign` | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| (1) | `SPRITE_YOUNGSTER` | 3 | 19 | `STANDING_RIGHT` | `OBJECTTYPE_TRAINER` (4) | `TrainerCamperIvan` | -1 | +| (2) | `SPRITE_YOUNGSTER` | 8 | 20 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (3) | `TrainerCamperElliot` | -1 | +| (3) | `SPRITE_LASS` | 7 | 20 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (3) | `TrainerPicnickerBrooke` | -1 | +| (4) | `SPRITE_LASS` | 11 | 24 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (3) | `TrainerPicnickerKim` | -1 | +| (5) | `SPRITE_YOUNGSTER` | 14 | 28 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (0) | `TrainerBirdKeeperBryan` | -1 | +| (6) | `SPRITE_FISHER` | 2 | 10 | `SPINCOUNTERCLOCKWISE` | `OBJECTTYPE_TRAINER` (2) | `TrainerFirebreatherWalt` | -1 | +| (7) | `SPRITE_BUG_CATCHER` | 16 | 7 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (3) | `TrainerBugCatcherArnie` | -1 | +| (8) | `SPRITE_SUPER_NERD` | 5 | 10 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (2) | `TrainerJugglerIrwin` | -1 | +| (9) | `SPRITE_OFFICER` | 5 | 6 | `STANDING_DOWN` | `OBJECTTYPE_SCRIPT` | `TrainerOfficerDirk` | -1 | +| (10) | `SPRITE_FRUIT_TREE` | 2 | 25 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route35FruitTree` (`FRUITTREE_ROUTE_35`) | -1 | +| (11) | `SPRITE_POKE_BALL` | 13 | 16 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route35TMRollout` (`TM_ROLLOUT`) | `EVENT_ROUTE_35_TM_ROLLOUT` | + +**Scripts of interest** + +- `TrainerOfficerDirk` (`maps/Route35.asm:223`): an `OBJECTTYPE_SCRIPT`, not a + sight trainer. `checktime NITE` -> only battles at night, guarded by + `EVENT_BEAT_OFFICER_DIRK`, `loadtrainer OFFICER, DIRK`. A bot oscillating + Route 35 / Route 36 at night will be pulled into this battle once. Note that + a normal wild-or-trainer battle is *not* wasted time: `BattleEnd_HandleRoamMons`'s + `.not_roaming` tail moves the beasts 1 time in 16. + +**Wild encounters** (`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_35`, +rates `10 percent` all three, Gold column) + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 12 `NIDORAN_M` | 12 `NIDORAN_M` | 12 `NIDORAN_M` | +| 2 | 12 `NIDORAN_F` | 12 `NIDORAN_F` | 12 `NIDORAN_F` | +| 3 | 14 `DROWZEE` | 14 `DROWZEE` | 14 `DROWZEE` | +| 4 | 10 `ABRA` | 10 `ABRA` | 10 `ABRA` | +| 5 | 14 `PIDGEY` | 14 `PIDGEY` | 14 `HOOTHOOT` | +| 6 | 10 `DITTO` | 10 `DITTO` | 10 `DITTO` | +| 7 | 12 `YANMA` | 12 `YANMA` | 12 `YANMA` | + +Max wild level 14. + +--- + +### MAP_ROUTE_37 + +- Script: `maps/Route37.asm` +- Header: `data/maps/maps.asm:250` -> `TILESET_JOHTO`, `ROUTE`, + `LANDMARK_ROUTE_37`, `MUSIC_ROUTE_36`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_SHORE` +- Dimensions: `constants/map_constants.asm:229`, `map_const ROUTE_37, 10, 9` +- Connections (`data/maps/attributes.asm:211`): north `EcruteakCity` + (`ECRUTEAK_CITY`, -5), south `Route36` (`ROUTE_36`, -10) +- Roam edges: `ROUTE_36`, `ROUTE_38`, `ROUTE_42`. Entei's starting map. + +**Warps** (`def_warp_events`): **none**. Route 37 is reached only by connection, +which makes it the cheapest possible `UpdateRoamMons` tick in the loop: one step +across the boundary and one step back. + +**Coord events** (`def_coord_events`): none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 5 | 3 | `BGEVENT_READ` | `Route37Sign` | +| 4 | 2 | `BGEVENT_ITEM` | `Route37HiddenEther` (`hiddenitem ETHER, EVENT_ROUTE_37_HIDDEN_ETHER`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| `ROUTE37_WEIRD_TREE1` | `SPRITE_WEIRD_TREE` | 6 | 12 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (1) | `TrainerTwinsAnnandanne1` | -1 | +| `ROUTE37_WEIRD_TREE2` | `SPRITE_WEIRD_TREE` | 7 | 12 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (1) | `TrainerTwinsAnnandanne2` | -1 | +| `ROUTE37_YOUNGSTER` | `SPRITE_YOUNGSTER` | 9 | 6 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (3) | `TrainerPsychicGreg` | -1 | +| `ROUTE37_FRUIT_TREE1` | `SPRITE_FRUIT_TREE` | 13 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree1` (`FRUITTREE_ROUTE_37_1`) | -1 | +| `ROUTE37_SUNNY` | `SPRITE_BUG_CATCHER` | 16 | 8 | `WANDER` | `OBJECTTYPE_SCRIPT` | `SunnyScript` | `EVENT_ROUTE_37_SUNNY_OF_SUNDAY` | +| `ROUTE37_FRUIT_TREE2` | `SPRITE_FRUIT_TREE` | 16 | 5 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree2` (`FRUITTREE_ROUTE_37_2`) | -1 | +| `ROUTE37_FRUIT_TREE3` | `SPRITE_FRUIT_TREE` | 15 | 7 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route37FruitTree3` (`FRUITTREE_ROUTE_37_3`) | -1 | + +**Scripts of interest** + +- `Route37SunnyCallback` (`MAPCALLBACK_OBJECTS`): `readvar VAR_WEEKDAY`, + `ifequal SUNDAY` -> `appear ROUTE37_SUNNY`, else `disappear`. +- `SunnyScript`: `verbosegiveitem MAGNET` guarded by + `EVENT_GOT_MAGNET_FROM_SUNNY` and `EVENT_MET_SUNNY_OF_SUNDAY`. + +**Wild encounters** (`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_37`, +rates `10 percent` all three, Gold column) + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 13 `PIDGEY` | 13 `PIDGEY` | 13 `SPINARAK` | +| 2 | 15 `STANTLER` | 15 `STANTLER` | 15 `STANTLER` | +| 3 | 15 `PIDGEY` | 15 `PIDGEY` | 15 `HOOTHOOT` | +| 4 | 14 `GROWLITHE` | 14 `GROWLITHE` | 14 `GROWLITHE` | +| 5 | 15 `PIDGEY` | 15 `PIDGEOTTO` | 15 `SPINARAK` | +| 6 | 15 `PIDGEY` | 16 `GROWLITHE` | 15 `SPINARAK` | +| 7 | 15 `PIDGEY` | 16 `GROWLITHE` | 15 `SPINARAK` | + +Max wild level 16. + +--- + +### MAP_ROUTE_42 + +- Script: `maps/Route42.asm` +- Header: `data/maps/maps.asm:72` -> `TILESET_JOHTO`, `ROUTE`, + `LANDMARK_ROUTE_42`, `MUSIC_LAKE_OF_RAGE`, phone `FALSE`, `PALETTE_AUTO`, + `FISHGROUP_LAKE` +- Dimensions: `constants/map_constants.asm:60`, `map_const ROUTE_42, 30, 9` +- Connections (`data/maps/attributes.asm:231`): west `EcruteakCity` + (`ECRUTEAK_CITY`, -9), east `MahoganyTown` (`MAHOGANY_TOWN`, 0) +- Roam edges: `ROUTE_43`, `ROUTE_44`, `ROUTE_37`, `ROUTE_38`. The other four-way + junction, and Raikou's starting map. This is the walkthrough's Max Repel spot. + +**Warps** (`def_warp_events`) + +| idx | x | y | destination map | dest warp | +|---|---|---|---|---| +| 1 | 0 | 8 | `ROUTE_42_ECRUTEAK_GATE` | 3 | +| 2 | 0 | 9 | `ROUTE_42_ECRUTEAK_GATE` | 4 | +| 3 | 10 | 5 | `MOUNT_MORTAR_1F_OUTSIDE` | 1 | +| 4 | 28 | 9 | `MOUNT_MORTAR_1F_OUTSIDE` | 2 | +| 5 | 46 | 7 | `MOUNT_MORTAR_1F_OUTSIDE` | 3 | + +**Coord events** (`def_coord_events`): none. + +**BG events** (`def_bg_events`) + +| x | y | type | script/item | +|---|---|---|---| +| 4 | 10 | `BGEVENT_READ` | `Route42Sign1` | +| 7 | 5 | `BGEVENT_READ` | `MtMortarSign1` | +| 45 | 9 | `BGEVENT_READ` | `MtMortarSign2` | +| 54 | 8 | `BGEVENT_READ` | `Route42Sign2` | +| 16 | 11 | `BGEVENT_ITEM` | `Route42HiddenMaxPotion` (`hiddenitem MAX_POTION, EVENT_ROUTE_42_HIDDEN_MAX_POTION`) | + +**Object events** (`def_object_events`) + +| const | sprite | x | y | movement | type | script label | event flag | +|---|---|---|---|---|---|---|---| +| (1) | `SPRITE_FISHER` | 40 | 10 | `STANDING_LEFT` | `OBJECTTYPE_TRAINER` (1) | `TrainerFisherChris` | -1 | +| (2) | `SPRITE_POKEFAN_M` | 51 | 9 | `SPINRANDOM_FAST` | `OBJECTTYPE_TRAINER` (3) | `TrainerHikerBenjamin` | -1 | +| (3) | `SPRITE_SUPER_NERD` | 47 | 8 | `STANDING_DOWN` | `OBJECTTYPE_TRAINER` (3) | `TrainerPokemaniacShane` | -1 | +| (4) | `SPRITE_FRUIT_TREE` | 27 | 16 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree1` (`FRUITTREE_ROUTE_42_1`) | -1 | +| (5) | `SPRITE_FRUIT_TREE` | 28 | 16 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree2` (`FRUITTREE_ROUTE_42_2`) | -1 | +| (6) | `SPRITE_FRUIT_TREE` | 29 | 16 | `STILL` | `OBJECTTYPE_SCRIPT` | `Route42FruitTree3` (`FRUITTREE_ROUTE_42_3`) | -1 | +| (7) | `SPRITE_POKE_BALL` | 6 | 4 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route42UltraBall` (`itemball ULTRA_BALL`) | `EVENT_ROUTE_42_ULTRA_BALL` | +| (8) | `SPRITE_POKE_BALL` | 33 | 8 | `STILL` | `OBJECTTYPE_ITEMBALL` | `Route42SuperPotion` (`itemball SUPER_POTION`) | `EVENT_ROUTE_42_SUPER_POTION` | + +**Items** + +| item | how obtained | source (label / bg_event / hidden) | one-time flag | +|---|---|---|---| +| `ULTRA_BALL` | item ball at x=6, y=4 | `Route42UltraBall` | `EVENT_ROUTE_42_ULTRA_BALL` | +| `SUPER_POTION` | item ball at x=33, y=8 | `Route42SuperPotion` | `EVENT_ROUTE_42_SUPER_POTION` | +| `MAX_POTION` | hidden, x=16, y=11 | `Route42HiddenMaxPotion` | `EVENT_ROUTE_42_HIDDEN_MAX_POTION` | + +**Wild encounters** (`data/wild/johto_grass.asm`, `def_grass_wildmons ROUTE_42`, +rates `10 percent` all three, Gold column) + +| slot | morn | day | nite | +|---|---|---|---| +| 1 | 15 `MANKEY` | 15 `MANKEY` | 15 `MANKEY` | +| 2 | 13 `MAREEP` | 13 `MAREEP` | 13 `MAREEP` | +| 3 | 14 `SPEAROW` | 14 `SPEAROW` | 14 `ZUBAT` | +| 4 | 16 `SPEAROW` | 16 `SPEAROW` | 16 `ZUBAT` | +| 5 | 15 `FLAAFFY` | 15 `FLAAFFY` | 15 `FLAAFFY` | +| 6 | 17 `FLAAFFY` | 17 `FLAAFFY` | 17 `FLAAFFY` | +| 7 | 17 `FLAAFFY` | 17 `FLAAFFY` | 17 `FLAAFFY` | + +Max wild level 17. Every grass slot on this map is below 39, which is precisely +why the walkthrough's Max Repel plan works here (section 3). + +--- + +### Trainers on the four detailed maps + +Constants from `constants/trainer_constants.asm`, parties from +`data/trainers/parties.asm` (group and index in the file's own comment). + +| const | class | id | party (`data/trainers/parties.asm`) | script label | map | +|---|---|---|---|---|---| +| `MARK` | `PSYCHIC_T` | `PSYCHIC_T (7)` | 13 `ABRA` (Teleport, Flash), 13 `ABRA` (Teleport, Flash), 15 `KADABRA` (Teleport, Kinesis, Confusion) | `TrainerPsychicMark` | Route 36 | +| `ALAN1` | `SCHOOLBOY` | `SCHOOLBOY (3)` | 16 `TANGELA` | `TrainerSchoolboyAlan1` | Route 36 | +| `IVAN` | `CAMPER` | `CAMPER (3)` | 10 `DIGLETT`, 10 `ZUBAT`, 14 `DIGLETT` | `TrainerCamperIvan` | Route 35 | +| `ELLIOT` | `CAMPER` | `CAMPER (4)` | 13 `SANDSHREW`, 15 `MARILL` | `TrainerCamperElliot` | Route 35 | +| `BROOKE` | `PICNICKER` | `PICNICKER (3)` | 16 `PIKACHU` (ThunderShock, Growl, Quick Attack, Double Team) | `TrainerPicnickerBrooke` | Route 35 | +| `KIM` | `PICNICKER` | `PICNICKER (4)` | 15 `VULPIX` | `TrainerPicnickerKim` | Route 35 | +| `BRYAN` | `BIRD_KEEPER` | `BIRD_KEEPER (3)` | 12 `PIDGEY`, 14 `PIDGEOTTO` | `TrainerBirdKeeperBryan` | Route 35 | +| `WALT` | `FIREBREATHER` | `FIREBREATHER (6)` | 11 `MAGMAR`, 13 `MAGMAR` | `TrainerFirebreatherWalt` | Route 35 | +| `ARNIE1` | `BUG_CATCHER` | `BUG_CATCHER (8)` | 15 `VENONAT` | `TrainerBugCatcherArnie` | Route 35 | +| `IRWIN1` | `JUGGLER` | `JUGGLER (1)` | 2 / 6 / 10 / 14 `VOLTORB` | `TrainerJugglerIrwin` | Route 35 | +| `DIRK` | `OFFICER` | (see `OfficerGroup`) | night only | `TrainerOfficerDirk` | Route 35 | +| `ANNANDANNE1` | `TWINS` | `TWINS (2)` | 16 `CLEFAIRY` (Growl, Encore, DoubleSlap, Metronome), 16 `JIGGLYPUFF` (Sing, Defense Curl, Pound, Disable) | `TrainerTwinsAnnandanne1` | Route 37 | +| `ANNANDANNE2` | `TWINS` | `TWINS (3)` | same two, order swapped | `TrainerTwinsAnnandanne2` | Route 37 | +| `GREG` | `PSYCHIC_T` | `PSYCHIC_T (5)` | 17 `DROWZEE` (Hypnosis, Disable, Dream Eater) | `TrainerPsychicGreg` | Route 37 | +| `CHRIS1` | `FISHER` | `FISHER (7)` | 18 `QWILFISH` | `TrainerFisherChris` | Route 42 | +| `BENJAMIN` | `HIKER` | `HIKER (6)` | 14 `DIGLETT`, 14 `GEODUDE`, 16 `DUGTRIO` | `TrainerHikerBenjamin` | Route 42 | +| `SHANE` | `POKEMANIAC` | `POKEMANIAC (4)` | 16 `NIDORINA`, 16 `NIDORINO` | `TrainerPokemaniacShane` | Route 42 | + +All of these are one-and-done (`EVENT_BEAT_*` in the `trainer` macro row). By +this point in the game they are already beaten and are not obstacles. + +--- + +### The beasts as battle opponents + +`data/pokemon/base_stats/{raikou,entei,suicune}.asm`, level from +`InitRoamMons`, moves from `data/pokemon/evos_attacks.asm` (last four learned at +or before 40). + +| species | types | base HP/Atk/Def/Spe/SpA/SpD | catch rate | level 40 moveset | +|---|---|---|---|---| +| `RAIKOU` | Electric | 90 / 85 / 75 / 115 / 115 / 100 | 3 | `LEER`, `THUNDERSHOCK`, `ROAR`, `QUICK_ATTACK` | +| `ENTEI` | Fire | 115 / 115 / 85 / 100 / 90 / 75 | 3 | `LEER`, `EMBER`, `ROAR`, `FIRE_SPIN` | +| `SUICUNE` | Water | 100 / 75 / 115 / 85 / 90 / 115 | 3 | `LEER`, `WATER_GUN`, `ROAR`, `GUST` | + +All three know **Roar**. `BattleCommand_ForceSwitch`'s wild-target branch +(`engine/battle/effect_commands.asm:4913`, `.force_player_switch` at `:5010` +-> `.wild_succeed_playeristarget` at `:5041`) compares +`wCurPartyLevel` against `wBattleMonLevel` and, when the wild level is greater +or equal, succeeds unconditionally: `wForcedSwitch` = TRUE and `wBattleResult` = +DRAW, ending the battle on the spot. Against a lead at level 40 or below that is +a guaranteed battle-ender if the beast picks Roar. The DRAW result then routes +through `BattleEnd_HandleRoamMons`'s non-WIN arm, so the beast keeps its damage +and moves. + +--- + +## 3. Blockers and gates + +| Gate | Enforced by (file:label) | Requirement | Unlock condition | +|---|---|---|---| +| The beasts do not exist at all | `maps/BurnedTowerB1F.asm:65` `special InitRoamMons`, guarded by `coord_event 9, 5, SCENE_BURNEDTOWERB1F_RELEASE_THE_BEASTS, ReleaseTheBeasts` | walk the Burned Tower B1F trigger | `EVENT_RELEASED_THE_BEASTS` set, `SCENE_BURNEDTOWERB1F_NOOP` | +| No roamer on water | `engine/overworld/wildmons.asm:534` `CheckEncounterRoamMon` `call CheckOnWater / jr z, .DontEncounterRoamMon` | be on a land tile | none, this is absolute | +| No roamer off the sixteen routes | `data/wild/roammon_maps.asm` `RoamMaps` + the map compare in `CheckEncounterRoamMon:546` | stand on the beast's stored map | none | +| No roamer where there is no wild table | `engine/overworld/wildmons.asm:270` `LoadWildMonDataPointer` returning nc -> `.nowildbattle` before `CheckEncounterRoamMon` runs | map must have grass/water data | none | +| No roamer in the Bug Contest | `engine/overworld/events.asm:1194` `ChooseWildEncounter_BugContest` never calls `CheckEncounterRoamMon` | leave the contest | none | +| Route 36 tile x=35, y=9 blocked | `maps/Route36.asm` `SudowoodoScript`, `checkitem SQUIRTBOTTLE` | `SQUIRTBOTTLE` in bag, then win/lose/flee the level 20 Sudowoodo | `EVENT_ROUTE_36_SUDOWOODO` cleared by `disappear` | +| Beast escapes after one player action | `engine/battle/core.asm:711` `TryEnemyFlee` + `data/wild/flee_mons.asm` `AlwaysFleeMons` | trap it: `SUBSTATUS_CANT_RUN` (Mean Look / Spider Web), a live `wEnemyWrapCount`, or inflict `SLP` / `FRZ` | any one of those four makes `TryEnemyFlee` return no-carry | +| Repel filters the *wild*, not the beast | `engine/overworld/wildmons.asm:366` `CheckRepelEffect`: `ld a, [wCurPartyLevel] / cp [hl] / jr nc, .encounter` where `hl` is the first non-fainted party mon's level | roamer level is 40, so the lead must be **level 40 or lower** or the roamer is repelled too | Route 42 grass tops out at level 17, so a level 39 lead plus `MAX_REPEL` (250 steps, `engine/items/item_effects.asm:2058`) blocks every normal encounter and passes the beast. A level 41+ lead blocks the beast as well. | +| Fast Ball does nothing here | `engine/items/item_effects.asm:986` `FastBallMultiplier`, documented bug (`jr nz, .next` instead of `jr nz, .loop`) | use `ULTRA_BALL` (x2, `03:6c92`) or `MASTER_BALL` (short-circuit) | none, the bug is in the shipped ROM | +| Paralysis gives no catch bonus | `engine/items/item_effects.asm:344` `.statuscheck`, the `ld a, [wEnemyMonStatus]` reload is commented out | `SLP` or `FRZ` only (+10 to the rate) | none | +| Ultra Ball / Max Repel supply | `data/items/marts.asm:216 MartBlackthorn`, `:384 MartIndigoPlateau` | money | both marts stock `ULTRA_BALL` and `MAX_REPEL` | + +Nothing in this section is badge- or HM-gated on its own. The HMs needed to +reach individual roam routes (Surf for Route 41's neighbours, Whirlpool, etc.) +belong to their own sections; all sixteen roam maps are walkable by the time the +beasts are loose. + +--- + +## 4. Bot checklist + +Preconditions for the whole section: `EVENT_RELEASED_THE_BEASTS` set, +`save.roamers` (port) / the three `roam_struct`s (cart) populated, `BICYCLE` in +the bag, `SQUIRTBOTTLE` already used on Route 36. + +1. **Locate.** Map `MAP_*` any, action: open Pokedex -> select `RAIKOU` / + `ENTEI` / `SUICUNE` -> AREA. Precondition: species is in the *seen* dex + (set by the first encounter, not by the Burned Tower cutscene). + Postcondition: the blinking nest icon is the beast's current map + (`FindNest.RoamMon1/2/3`). If the species is retired the icon is absent. +2. **Anchor.** Fly to `MAP_VIOLET_CITY`. Warning: the Fly itself runs + `JumpRoamMons`, so any location read from step 1 is stale afterwards. Do step + 1 *after* the Fly, not before. +3. **Oscillate (cheap ticks).** From Violet City walk west onto `MAP_ROUTE_36` + (connection, `data/maps/attributes.asm:209`). Then repeat: step north onto + `MAP_ROUTE_37` and back, step south onto `MAP_ROUTE_35` and back. Each single + boundary crossing is one `MapSetupScript_Connection` and therefore one + `UpdateRoamMons`. Route 37 has zero warps, so the boundary step is the only + thing that happens there. +4. **Alternative tick.** Any door warp works too: on `MAP_VIOLET_CITY` step onto + warp 5 (x=31, y=25, `VIOLET_POKECENTER_1F`) and back out. That is + `MAPSETUP_DOOR` -> `MapSetupScript_Train` -> `UpdateRoamMons`, and it also + heals. +5. **Fish for the encounter.** Stand in grass on the map the beast is on and + walk. Each step that passes `CanEncounterWildMon` and the map's 10% rate roll + then has a 75/256 roamer roll, of which 1/3 selects your beast. + Postcondition: `wBattleType = BATTLETYPE_ROAMING`. +6. **Better: Sweet Scent.** Same tile, use `SWEET_SCENT`. It skips the 10% roll + and the repel check entirely (`engine/events/sweet_scent.asm:31`), so every + use is an encounter and ~9.8% of uses are the specific beast. This is the + highest-throughput option and the walkthrough does not mention it. +7. **Repel filter (optional).** Lead a party mon of level 39 or 40 (a raised + Scyther works; it learns `FALSE_SWIPE` at level 18 per + `data/pokemon/evos_attacks.asm:1674`, and at 39 its auto-generated four are + False Swipe / Agility / Wing Attack / Slash). Use `MAX_REPEL` (250 steps). + Precondition: lead level <= 40. Postcondition: only the level 40 beast can + still trigger. +8. **Save.** Before every encounter. Battle outcomes are irreversible: a `WIN` + retires the slot for the rest of the file. +9. **In battle, turn 1 only.** Options, in order of value: + - `MASTER_BALL` -> guaranteed, but there is only one and there are three + beasts. + - Trap first (`MEAN_LOOK` / `SPIDER_WEB`, or a wrap move), which makes + `TryEnemyFlee` return no-carry and buys unlimited turns. This is the only + way to legitimately chip HP and land status. + - Otherwise: one `FALSE_SWIPE` (leaves 1 HP, banked into the struct for next + time) **or** one `ULTRA_BALL`. Either way the beast is gone at the end of + the turn. + - Do not count on `Roar`: if the beast picks it against a lead of level 40 or + lower the battle ends immediately as a DRAW. +10. **After the battle.** `BattleEnd_HandleRoamMons` has already banked HP and + moved the beast. Return to step 1. +11. **Repeat per beast.** Three independent slots. Retiring one does not affect + the other two; their `RoamMaps` walk is unchanged. + +--- + +## 5. Port coverage + +| Beat | Port file | Status | +|---|---|---| +| `roam_struct` on the save (species/level/map/hp/dvs, three slots in Raikou/Entei/Suicune order) | `src/core/gen2/Roamers.lua:41-47`, `:184-217` | implemented | +| `InitRoamMons` reachable through the real special dispatch | `src/script/gen2/Specials.lua:1843-1848`, `src/core/gen2/Roamers.lua:196` | implemented | +| `RoamMaps` graph, all 16 rows, Routes 40/41 excluded | `src/core/gen2/Roamers.lua:58-75` | implemented, but hardcoded: `Roamers.mapTable` looks for `encounters.roamMaps` and the extractor does not emit it yet (`:77-85`) | +| `.Update` shared-byte jump/index roll, last-map re-roll | `src/core/gen2/Roamers.lua:142-158` | implemented | +| `JumpRoamMon` random entry, re-roll off the player's map | `src/core/gen2/Roamers.lua:117-126` | implemented | +| `_BackUpMapIndices` | `src/core/gen2/Roamers.lua:223-234` | implemented | +| `UpdateRoamMons` on `CONNECTION` / `DOOR` / `FALL` / `TRAIN` | `src/world/gen2/World.lua:211-215` (`MAPSETUP_ROAM_UPDATE`), `:2626-2633` | implemented, and the set matches `data/maps/setup_scripts.asm` exactly | +| `JumpRoamMons` on `TELEPORT` (Fly / Teleport) | `src/world/gen2/World.lua:215`, `:2617-2624` | implemented | +| `JumpRoamMons` on **Continue** (`engine/menus/intro_menu.asm:283`) | - | **missing**: `Roamers.jumpAll` has exactly one caller, `roamMonsBeforeLoad`, which is keyed on `MAPSETUP_TELEPORT`. Loading a save does not scatter the beasts. | +| `CheckEncounterRoamMon` (water gate, 100/256, `and %11`, slot map compare, no re-roll) | `src/core/gen2/Roamers.lua:288-301`; called from `src/world/gen2/World.lua:3043` and `:3790` | implemented | +| Roamer check ordered **after** the map's encounter-rate roll | `src/world/gen2/World.lua:3043` vs `:3053-3060` | **partial**: the port runs `Roamers.checkEncounter` *before* `Encounter.triggers`, so a beast can appear on a step whose 10% roll would have failed. The asm order is `TryWildEncounter.EncounterRate` -> `ChooseWildEncounter` -> `CheckEncounterRoamMon`. Beast encounter rate is inflated by roughly 1/rate. | +| Sweet Scent path also consults the roamers, and skips the rate roll | `src/world/gen2/World.lua:3785-3796` | implemented, and the "nonzero rate only" check at `:3786` matches `engine/events/sweet_scent.asm:38` | +| `CheckRepelEffect` (lead level vs `wCurPartyLevel`) | - | **missing**: `save.repelSteps` counts down in `src/world/gen2/StepEvents.lua:98-101` and prints the wore-off text, but nothing in `World:tryWildEncounter` consults it. The walkthrough's Max Repel plan is a no-op in the port today. | +| DV roll on first encounter, DVs kept afterwards | `src/core/gen2/Roamers.lua:311-328` | implemented | +| HP banked in a single byte, reloaded on the next encounter | `src/core/gen2/Roamers.lua:182`, `:311-328`, `:341-354` | implemented | +| `BattleEnd_HandleRoamMons` WIN/caught retires the slot | `src/core/gen2/Roamers.lua:345-350`, wired at `src/world/gen2/World.lua:4475-4478` | implemented | +| `.not_roaming` 1-in-16 drift after any other wild battle | `src/core/gen2/Roamers.lua:359-363` | implemented | +| `AlwaysFleeMons` / `OftenFleeMons` / `SometimesFleeMons` and the shared roll byte | `src/core/gen2/Roamers.lua:370-380`, `src/battle/gen2/Battle.lua:1639-1651` | implemented | +| Sleep / freeze suppress the flee | `src/battle/gen2/Battle.lua:1641-1642` | implemented | +| `SUBSTATUS_CANT_RUN` (Mean Look) and `wEnemyWrapCount` suppress the flee | - | **missing**, and the port says so itself at `src/battle/gen2/Battle.lua:1632-1635`. There is currently no way to trap a beast, which removes the only legitimate multi-turn strategy. | +| Failed ball still costs the turn, so the beast flees | `src/ui/gen2/BattleState.lua:900-903` -> `Battle:takeTurn{kind="item"}` -> `Battle:tryEnemyFlee` (`:1877`) | implemented | +| `MASTER_BALL` never fails; `ULTRA_BALL` x2 | `src/battle/gen2/Catching.lua:29-30`, `:62-63` | implemented | +| `FastBallMultiplier` bug (x4 only for Magnemite/Grimer/Tangela) | `src/battle/gen2/Catching.lua:39` (`FAST_BALL = 1`) | **partial**: the port is correct for the beasts (flat x1) but drops the x4 the three buggy species do get | +| Pokedex AREA screen shows the roamer's current map (`FindNest.RoamMon1/2/3`) | `src/ui/TownMap.lua:167-198` | **missing**: nest mode scans only the map wild-slot tables for the species; there is no roamer branch, so a located beast never appears on the AREA map | +| `BattleCommand_ForceSwitch` (Roar from a wild beast ends the battle as a DRAW) | - | **missing**: `EFFECT_FORCE_SWITCH` exists only as an AI scoring hook (`src/battle/gen2/Ai.lua:559`); there is no effect handler, so a roamer's Roar does not end the battle | +| Headless coverage | `tests/gen2_world_test.lua:2203-2271`, `tests/gen2_roamers_test.lua`, `tests/drivers/gold_roamers.lua` | implemented: the driver runs the real `InitRoamMons` special, asserts the three starting routes, and exercises `MAPSETUP_DOOR` ticks | + +--- + +## 6. Unresolved / verify by hand + +1. **"They will show up on the map in Pokegear."** The only caller of `FindNest` + is `engine/pokegear/pokegear.asm:2430`, inside `Pokedex_GetArea` (`24:5c7f`), + whose header string is `'S NEST` (`.PlaceString_MonsNest`). That is the + **Pokedex AREA** screen, which is *drawn* with the Pokegear town map code but + is not reached from the Pokegear menu. Nothing in `_TownMap` + (`engine/pokegear/pokegear.asm:1709`) plots roamers. Treat the walkthrough's + wording as loose. +2. **"After the initial encounter ... they will show up."** `FindNest` itself has + no "encountered" gate; it only compares `wNamedObjectIndex` with the slot's + species. The real gate is that you must be able to select the species in the + Pokedex at all, i.e. it must be SEEN. I could not find anything in + `maps/BurnedTowerB1F.asm` that marks the three as seen during + `ReleaseTheBeasts` (it only `appear`s sprites and plays cries), so in practice + the first battle is what unlocks the AREA view. Not proven by a single + `SetSeenMon` call; worth a hardware check. +3. **"asleep or paralyzed."** Sleep and freeze are worth `+10` to the catch rate + (`engine/items/item_effects.asm:344`). Paralysis is worth **nothing** because + of the commented-out `ld a, [wEnemyMonStatus]` reload in the same block, which + the file itself flags as a bug. The advice is half wrong on the cart. +4. **"they occasionally will jump a few routes."** The asm's only "jump" is the + 1-in-32 `and %00011111 / jr z, JumpRoamMon` branch inside `.Update`, which is + not "a few routes" but a uniformly random one of the sixteen. The observed + behaviour matches loosely; the mechanism does not. +5. **"as quickly as every 10 seconds."** No timer anywhere in the roam code. Roam + movement is purely event-driven (map setup scripts and battle ends). The ten + seconds is a human estimate of how long two boundary crossings take. +6. **Roar vs the level-39 Scyther plan.** `BattleCommand_ForceSwitch`'s wild + branch reads `wCurPartyLevel` for the *wild* mon's level and compares it with + `wBattleMonLevel`; at 40 vs 39 it takes `.wild_succeed_playeristarget` + unconditionally. I did not trace whether `wCurPartyLevel` is still the wild + level at that point after a mid-battle player switch (several routines write + that variable), so the "guaranteed Roar" claim is asserted from the code as + written and should be confirmed on hardware before a bot relies on it. +7. **Bike and encounter rate.** The walkthrough leans on biking. `CanEncounterWildMon` + (`engine/overworld/events.asm:1164`) and `GetMapEncounterRate` have no bicycle + term, so the bike changes travel speed only, not encounter odds. Stated here + because the opposite is widely believed. +8. **Suicune's starting route vs the walkthrough's framing.** `InitRoamMons` + places Suicune on `ROUTE_38`, not at the Tin Tower or Route 42. The section + text never states a starting route, so there is no contradiction, but a bot + that assumes "Suicune is the one you chase last" has no asm basis for it. +9. **Route dimensions vs event coordinates.** `map_const` widths/heights are in + blocks; `warp_event` / `object_event` / `bg_event` coordinates are in the + 2x-finer walk grid (e.g. `ROUTE_35` is 10 x 18 blocks and carries a warp at + y=33). Every coordinate in this document is the raw asm value, unconverted. diff --git a/docs/gold-walkthrough/section-00-chikorita-cyndaquil-or-totodile.txt b/docs/gold-walkthrough/section-00-chikorita-cyndaquil-or-totodile.txt new file mode 100644 index 00000000..9d4b6322 --- /dev/null +++ b/docs/gold-walkthrough/section-00-chikorita-cyndaquil-or-totodile.txt @@ -0,0 +1,119 @@ +============================================================================== +============================================================================== +============================================================================== + +(c06) +------------------------------------------------ +---- 6 > Chikorita, Cyndaquil, or Totodile? ---- +------------------------------------------------ + +Video Playthrough Link (Introduction through Violet City Gym): +https://www.youtube.com/watch?v=dqJtkx8DcE0 + +|-------------| +|New Bark Town| +|-------------| + +Pokémon found in New Bark Town: + +#152 Chikorita +#155 Cyndaquil +#158 Totodile + +Items found in New Bark Town: + +Everstone +Pokegear + +When you first start, you will be in New Bark Town. Choose new game and start. +Afterwards, Professor Oak will ask you the time. First answer what hour it is +and what minute it is, so you are setting the game's time. + +There is a default time, which is 10:00 AM, but you may set the time for +whatever you'd like it to be. Preferably, it should be the time that it is where +you live, because then it'd make more sense. They have it well-programmed on +Gold/Silver that even if you don't have the GBA on, the time will keep running. + +Then, Professor Oak will welcome you to the world of Pokémon like the older +versions of Pokémon and introduces you to Professor Elm. Basically Pokémon +cannot be lived without in the worlds of Johto and Kanto. Then he asks you what +your name is, so input that. Choose something as your name and your Pokémon +Gold/Silver adventure finally unfolds! + +You are in your room at the beginning. Exit the room, and you will meet your +mom. She tells you that your Pokegear is back from the repair shop. You get the +Pokegear. Pokegear is used to call people on a phone and also see the day and +time of the day. Then, when she asks you to input the day of the week, put that +in. You can even put in Daylight Savings Time. How cool! Remember, that's the +cycle from April to October. The other half of the year is not. The Phone is +part of the Pokegear, so simply go there and phone whoever. Kinda cool, +actually. She'll want you to call from time to time. + +Leave your house and head left to Professor Elm's lab. Notice the guy standing +left of it in that little corner. That will be your rival. If you talk to him, +he'll push you out of the way. So rude. Anyway, go into Elm's lab. He'll want +you to do him a favor by raising up a Pokémon. Win-win situation, no? He rants +on about Mr. Pokémon's real discovery which wouldn't make any sense yet. You get +to pick your starter Pokémon. Which Pokémon should you take? + +|------------| +|THE STARTERS| +|------------| + +As I mentioned before, your starter Pokémon choice really matters. You can't get +the other two starters short of restarting your game save, getting a second game +(like the other version), or trading someone else for one. Which Pokémon should +YOU choose? Let's analyze all three starters. Thard_Verad notes that the +evolution levels vary in this game: + +"Chikorita -16-> Bayleef -32-> Meganium +Cyndaquil -14-> Quilava -36-> Typhlosion +Totodile -18-> Croconaw -30-> Feraligatr + +As you can see, Cyndaquil is first to evolve into its intermediate form, but +slowest to reach its final form. Totodile is the other way +around, and Chikorita is in the middle." + +Here are additional thoughts below on each type. + +CHIKORITA: Grass Type. + +Chikorita is the hardest starter to start with and will struggle against the +first two gyms. Chikorita will also struggle a bit against the fourthgym. And +Team Rocket likes using poison types, which have a type advantage against grass +types. Expert trainers should use Chikorita if they want a challenge. + +Moves to Keep: + +Tackle (35 PWR) => Body Slam (85 PWR) +Growl => Poison Powder => HM04 Strength (80 PWR) +Razor Leaf (55 PWR) => Solar Beam (120 PWR) +Reflect => Synthesis + +CYNDAQUIL: Fire Type. + +Cyndaquil evolves to its second form faster than the other two starters and has +a powerful Flamethrower move in its final form. Cyndaquil is the best starter +for the first two gyms, but slows down a bit after that because Cyndaquil only +really has fire and normal type moves. + +Moves to Keep: + +Tackle (35 PWR) => Quick Attack (40 PWR) +Leer => Swift (60 PWR) => HM04 Strength (80 PWR) +SmokeScreen => Flamethrower (95 PWR) +Ember (40 PWR) => Flame Wheel (60 PWR) + +TOTODILE: Water Type. + +Totodile is a great choice for speedrunning. When evolved, it can make use of +two powerful HMs: Surf AND Strength. Totodile also eventually gets Bite, a dark +type move, for balance. + +Moves to Keep: + +Scratch (40 PWR) => Slash (70 PWR) => Hydro Pump (120 PWR) +Leer => Bite (60 PWR) +Rage (20 PWR) => HM04 Strength (80 PWR) +Water Gun (40 PWR) => HM03 Surf (95 PWR) + diff --git a/docs/gold-walkthrough/section-01-cherrygrove-city-and-routes-29-31.txt b/docs/gold-walkthrough/section-01-cherrygrove-city-and-routes-29-31.txt new file mode 100644 index 00000000..ac5d4fe8 --- /dev/null +++ b/docs/gold-walkthrough/section-01-cherrygrove-city-and-routes-29-31.txt @@ -0,0 +1,369 @@ +============================================================================== +============================================================================== +============================================================================== + +(c07) +----------------------------------------------- +---- 7 > Cherrygrove City and Routes 29-31 ---- +----------------------------------------------- + +Video Playthrough Link (Introduction through Violet City Gym): +https://www.youtube.com/watch?v=dqJtkx8DcE0 + +After you get your starting Pokémon, He says that Mr. Pokémon lives somewhere +past Cherrygrove, the next city. Then you get Elm's phone number. He'll call you +a few times over the course of the game. And just like in the Red, Blue, and +Yellow games, the professor's assistant gives you something. In this case, it's +a free potion. Potions can be used to heal your Pokémon up to 20 HP. Now leave +the lab! + +|-------------| +|New Bark Town| +|-------------| + +After choosing your Pokémon carefully, it seems your stupid rival's still +outside the lab. Head west onto Route 29, where your first battles with wild +Pokémon will begin. + +|--------| +|Route 29| +|--------| + +Pokémon found on Route 29: + +#016 Pidgey +#019 Rattata +#161 Sentret +#163 Hoothoot +#187 Hoppip + +Items found on Route 29: + +Berry +Pink Bow +Potion + +|-----------|-----------|--------|------------------| +| Chikorita | Cyndaquil |Totodile| THE | +|-----------|-----------|--------| STARTING | +| Tackle | Tackle | Scratch| Pokémon | +| Growl | Leer | Leer | TYPES | +|-----------|-----------|--------|------------------| +|Move Type PP Power Description | +|---------------------------------------------------| +|Tackle Normal 35 35 A full-body charge attack. | +|Scratch Normal 35 40 Scratches with sharp claw. | +|Growl Normal 40 -- Reduces the foe's attack. | +|Leer Normal 30 -- Reduces the foe's defense. | +|---------------------------------------------------| + +Here you will fight all different varieties of Pokémon, depending what time of +day it is, or what day it is. Note that the battle system looks all cool now +because it looks far more obvious how much HP you have, and how much EXP you +need your Pokémon to get before they advance to the next level. The EXP meter is +below the HP meter. Also note the new gender differences in Pokémon. + +In any case, you'll want your starting Pokémon to be at Level 8 by the time you +reach Cherrygrove City, so keep training. If you're new to the Pokémon world, +you might also want to talk to the trainers on the path, as they give good +advice. When you're on Route 29, to the north is Route 46. You can go there and +visit if you'd like, but it'll be a pain to defeat any Pokémon there because you +only have normal moves. The berry is in the tree in the northwest, the potion is +in the northeast. + +Thard_Verad adds, "Berry and Apricorn trees regenerate every day so don't be +afraid to pick them. And go back tomorrow and pick them again!" + +Remember to have your starting Pokémon at Level 8 before you pass through +Cherrygrove City. You really need the experience. Keep heading west until you +arrive in... + +|----------------| +|Cherrygrove City| +|----------------| + +Items found in Cherrygrove City: + +Map Card + +See that man at the entrance to the city? Talk to him and he will show you +around the city and some key Pokémon concepts, as well as places, like the +Pokémon Center, the PoKeMart, the routes, the sea, and his house. Most +importantly, you get the Map Card when he gives you it at the end. Then go into +the Pokémon Center, and heal your starting Pokémon by talking to the nurse. +Don't bother trying to get Pokeballs because they aren't being sold just yet. + +Head west then north up Route 30 when you're at Level 8. If you're not, go back +to Route 29, fight till your starter is about to faint, heal again at the +Pokémon Center in Cherrygrove City , and then head up Route 30, since you can't +buy anything from the PoKeMart yet. + +|--------| +|Route 30| +|--------| + +Pokémon found on Route 30: + +#010 Caterpie +#011 Metapod +#016 Pidgey +#019 Rattata +#165 Ledyba (Silver only, morning only) + +Items found on Route 30: + +Antidote +Berry (2X) +Mystery Egg +PoKeDeX +PSNCure Berry + +Welcome to Route 30! Reading the sign, you'll notice this is the midpoint route +between Cherrygrove City and Violet City. On this route, your starter Pokémon +should reach Level 10 by the time you get back to Cherrygrove City. Go a bit up +from the sign and you'll find a house with a tree next to it. Grab the berry +next to it and then enter the house for another berry. + +Don't actually use them, though. Potions are better most of the time. Just above +the house is an Antidote. Take that and head up the right fork. On the left +fork, you'd see a guy that will tell you that he trains his Pokémon, as well as +two boys battling on the path. You can't pass that area just yet. Going up the +right path, you'll eventually spot a house. To the left is a tree containing a +PSNCure Berry. Take that, then enter the house. + +You'll find Mr. Pokémon and Professor Oak inside. Mr. Pokémon gives you the +Mystery Egg, which Professor Elm wants to see. Apparently, the Daycare Service +gave Mr. Pokémon that Mystery Egg. This is the reason you must go back to +Professor Elm later, because he knows the evolutionary stuff even better than +Professor Oak! Wow, where'd he come from? Professor Oak wants you to help him +track down more Pokémon, and gives you the Pokedex. How dependable you are! It's +basically an encyclopedia, dictionary, and recorder all in one, if that makes +any sense. Professor Oak has to go for a radio show in Goldenrod, but Mr. +Pokémon will heal your Pokémon before you leave. + +Right when you leave the door, Professor Elm will give you a call on your cell +phone. He tells you that something terrible has happened in the lab. Now that +you're Professor Elm's 911 phone contact, you'll want to quickly head back to +New Bark Town. Consider training up your Pokémon to Level 10 before you even +exit Cherrygrove City onto Route 29. Keep training and heal at Cherrygrove's +Pokémon Center if you need to first. + +|----------------| +|Cherrygrove City| +|----------------| + +Heal at the Pokémon Center. Do not forget this. After your Level 10 Pokémon is +prepared for combat, exit Cherrygrove City to the east. Something stops you. You +notice that it's your rival. He is currently named ???. I've never met anyone +with that name before. I wonder who named him that? He calls you a wimp, but I +think he's the wimp around here. Your Level 10 Starter should completely destroy +his Level 5 one. XD + +|------------------------| +|Rival | +|Level 5 Starter (84 EXP)| +|You get: 300G | +|------------------------| + +Well, that was an easy battle. You probably lost less than 10HP. Now that you've +beaten him, he gets past you and now you can exit to Route 29 again. No way +he'll be the world's best Pokémon trainer. + +Moving forward, you will want to be cognizant of starter type weaknesses. Think +of it like paper-rock-scissors. Water beats fire. Fire beats grass. And grass +beats water. Your rival will always have the starter that can use super +effective moves against your starter. Keep that in mind as you play through the +game. + +|--------| +|Route 29| +|--------| + +Pokémon found on Route 29: + +#016 Pidgey +#019 Rattata +#161 Sentret +#163 Hoothoot +#187 Hoppip + +Keep heading east to New Bark Town. Keep training, too. Your starter should be +Level 11 by the time you reach New Bark, go back through Route 29, and back to +Cherrygrove. + +|-------------| +|New Bark Town| +|-------------| + +Go back to Professor Elm's laboratory and talk with the police officer in his +lab. The police officer says he has talked to Professor Elm, and when you note +that you've seen this person, you get to name your rival, the one who you just +battled and had the Pokémon stolen from Professor Elm. Name him whatever name +you feel like naming him. That's supposed to help the investigation, apparently. +He goes by the name Silver in the Pokémon community, though. + +Talk to Professor Elm and give him the discovery from Mr. Pokémon, the Mystery +Egg. Professor Elm is very surprised at you receiving a PoKeDeX. Only the +trainers with the best potential get one, after all. You're amazing already! +Professor Elm informs you that he thinks you have what it takes to try to get +the eight badges for the Pokémon Johto League. + +When you try to leave the lab, Professor Elm's aide will come up to you again +and give you five Pokeballs to begin your journey. Excellent. Now go right to +your home and talk to your mom, who will save your money for you. Now your +journey truly begins. Head west back onto Route 29. + +|--------| +|Route 29| +|--------| + +Pokémon found on Route 29: + +#016 Pidgey +#019 Rattata +#161 Sentret +#163 Hoothoot +#187 Hoppip + +Start heading west back to Cherrygrove City again. Before you can even go +anywhere, the first trainer will come up to you and teach you how to catch +Pokémon, so if you need the tutorial, by all means let him teach you. It's a +demonstration of him catching a Level 5 Rattata with a Pokeball. Nothing +special, really. Catch both a Hoothoot and Rattata on this route or Route 46. +Rattata's Super Fang can be a useful move early on. And Hoothoot can use the +ater HM Fly. + +In the Items pocket, note that there are now 4 sections. The 2nd one contains +the Pokeballs. Before you even fight a Pidgey, reach Level 11. Then, you are +able to do more damage and keep the Level 3 Pidgey low on HP. Capture it. Don't +go to Cherrygrove City yet. First, make a visit north onto Route 46. + +|--------| +|Route 46| +|--------| + +I know it doesn't look like much, but here there's a better chance of finding a +Rattata. Don't use your Level 11 Starter to catch it, you'll just kill it in a +hit. Instead, use Pidgey to weaken it, then capture it. An advanced strategy +would be to keep leveling up your starter Pokémon and have an imbalanced Pokémon +lineup. However, if you want to use a more balanced lineup, this could be your +2nd Pokémon that you use in your game playthrough, as its super fang is super +useful. Weaken it to the point where Pidgey is also weakened, then capture it. + +Head back onto Route 29, then west to Cherrygrove City. + +|----------------| +|Cherrygrove City| +|----------------| + +Heal at the Pokémon Center, then head north onto Route 30! If you want a more +balanced Pokémon lineup where you level up more than one Pokémon, move that +Pokémon to the #1 spot in your lineup. That way, Rattata will appear in battle +first. You'll want to switch to your starter on turn 1, and then your starter +will defeat the other Pokémon. Then both your Pokémon get equal experience and +the Pokémon you are leveling up will grow quickly. Do this even in trainer +battles. + +|--------| +|Route 30| +|--------| + +Pokémon found on Route 30: +#010 Caterpie +#011 Metapod +#013 Weedle +#014 Kakuna +#016 Pidgey +#042 Zubat +#06? Poliwag +#163 Hoot Hoot +#165 Ledyba (Silver only, morning only) +#167 Spinarak (Gold only, night only) + +OK, this time, you'll want to steer away from the right path but rather to the +left path. On the left path, you'll meet some resistance. The two boys that were +fighting earlier will both challenge you to battles, since they've already +battled each other. + +Youngster Joey +Level 4 Rattata (48 EXP) +You get: 64G + +Note that now some of your money goes to your mom for safekeeping now. Move up +to the next trainer for the next battle! + +Youngster Mikey +Level 2 Rattata (24 EXP) +Level 4 Rattata (48 EXP) +You get: 64G + +Go pass the lady to the third trainer to fight! + +Bug Catcher Don +Level 3 Caterpie (33 EXP) +Level 3 Caterpie (33 EXP) +You get: 48G + +Advance north onto Route 31. + +|--------| +|Route 31| +|--------| + +Pokémon found on Route 31: + +#010 Caterpie +#011 Metapod +#013 Weedle +#014 Kakuna +#016 Pidgey +#042 Zubat +#06? Poliwag +#069 Bellsprout +#163 Hoot Hoot +#165 Ledyba (Silver only, morning only) +#167 Spinarak (Gold only, night only) + +Items found on Route 31: + +Antidote +Bitter Berry +Pokeball +Potion + +You'll find the entrance to Dark Cave at the beginning of Route 31, as well as a +Potion. The Dark Cave won't really do you any good till you have the HM Flash, +so wait it out and grab that Potion to the left of it. When you see two +trainers, head south to where the item is and fight! + +Bug Catcher Wade +Level 2 Caterpie (20 EXP) +Level 2 Caterpie (20 EXP) +Level 3 Weedle (33 EXP) +Level 2 Caterpie (20 EXP) +You get: 32G + +I'm not really clear on how these Bug Catchers are catching the bugs. Are they +using their nets, or Pokeballs? Grab the Pokeball below the trainer, head back +up to grab the Bitter Berry from the tree, then head west, through the house-ish +entry, and into Violet City. + +|-----------| +|Violet City| +|-----------| + +Pokémon found in Violet City: + +#95 Onix + +Items found in Violet City: + +PRZCure Berry +TM 31 + +Heal your Pokémon. There is a GYM there in Violet City that uses Flying type +Pokémon. Don't go there just yet, head over far north to a tower called the +Sprout Tower. The experience you get up there will be very useful for the gym. + +(c08) +----------------------------------------------- diff --git a/docs/gold-walkthrough/section-02-sprout-tower-and-violet-city-gym.txt b/docs/gold-walkthrough/section-02-sprout-tower-and-violet-city-gym.txt new file mode 100644 index 00000000..1e573d23 --- /dev/null +++ b/docs/gold-walkthrough/section-02-sprout-tower-and-violet-city-gym.txt @@ -0,0 +1,150 @@ +---- 8 > Sprout Tower and Violet City Gym ---- +----------------------------------------------- + +Video Playthrough Link (Introduction through Violet City Gym): +https://www.youtube.com/watch?v=dqJtkx8DcE0 + +|------------| +|Sprout Tower| +|------------| + +Pokémon found in Sprout Tower: + +#019 Rattata +#069 Bellsprout +#092 Gastly + +Items found in Sprout Tower: + +Escape Rope +HM05 +Parlyz Heal +Potion +X Accuracy + +This tower is called Sprout Tower, and it's obviously named that because the +sages inside mostly use Bellsprouts. I wonder what rituals the sages go through +with those Bellsprouts. It should give you plenty of experience. Head up the +stairs to Floor 2. Continue up the path and you'll see Sage Nico, and he'll +challenge you to a battle. + +Sage Nico +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +You get: 96G + +Continue along the path and down the stairs. Grab the item below you. It is +Parlyz Heal. Head back up and left along the path to fight Sage Chow. + +Sage Chow +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +You get: 96G + +Go up the stairs and along the path till you see YET another sage, after you +grab that X Defense above you. + +Sage Edmond +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +Level 3 Bellsprout (54 EXP) +You get: 96G + +Climb up the stairs, grab the item near you (a Potion), then fight every sage up +in turn. + +Sage Jin +Level 6 Bellsprout (108 EXP) +You get: 192G + +Next up is another sage. Use your starter for backing. + +Sage Neal +Level 6 Bellsprout (108 EXP) +You get: 192G + +Move up to the third sage on the floor and fight him. + +Sage Troy +Level 7 Bellsprout (126 EXP) +Level 7 Hoothoot (106 EXP) +You get: 224G + +When you move up, you'll see some sort of shaking up above you. Your rival is +there, after beating the main sage of the tower. He has been given the HM Flash +but the sage criticizes him for treating his Pokémon too harshly and he gets +enraged. He escapes with the Escape Rope. Go up to the item to the right of the +monk. In reality, it is a... + +Level 3 Rattata (24 EXP) + +Now move up to take on the leader of the sages itself. The final battle of the +Sprout Tower challenge commences. + +Sage Li +Level 7 Bellsprout (126 EXP) +Level 10 Hoothoot (122 EXP) +Level 7 Bellsprout (126 EXP) +You get: 320G + +For beating the main guy of the tower, you get your reward, the Flash HM (HM05). +Flash is a flashlight, it's not something else. Get your mind out of the gutter. +You'll need the 1st badge to use it, however. Go back to the Rattata you faced +before, and now it's an Escape Rope. You may use that to get out of the tower or +by climbing down it. Get out of the tower and go back to the Pokémon Center of +Violet City. + +|-----------| +|Violet City| +|-----------| + +Well, you should be around an hour into the game now. Now, you'll want to head +northwest in the city to the Violet City Gym. After all that training, things +are gonna look awfully ridiculously easy in here, so you'll know you're doing +the right thing. And best of all, you didn't even need to run on a treadmill at +the school gym. Head up to fight your trainer battles first. + +Bird Keeper Abe +Level 9 Spearow (111 EXP) +You get: 216G + +Head up the path to the second trainer. + +Bird Keeper Rod +Level 7 Pidgey (82 EXP) +Level 7 Pidgey (82 EXP) +You get: 168G + +Go back to the Pokémon Center to heal, then you're ready to battle, so head up +the zig-zag pathway to Falkner. Falkner is ready to show the magnificent power +of bird Pokémon. I'm not convinced. If you chose Cyndaquil or Totodile, this +should be pretty easy. If you have Chikorita, I would suggest evolving it first +or using a non-grass Pokémon (like a Rattata) take this gym. + +--- +GYM LEADER FALKNER +Level 7 Pidgey (82 EXP) +Level 9 Pidgeotto (217 EXP) +You get: Zephyr Badge, $900, & TM31 (Mud Slap) +--- + +Strategy VERSUS Pidgey: Use your highest-powered move from your starter Pokémon, +or consider using Rattata's quick attacks if you have Chikorita and haven't +evolved it yet. + +Strategy VERSUS Pidgeotto: Use your highest-powered move from your starter +Pokémon, or consider using Rattata's quick attacks if you have Chikorita and +haven't evolved it yet. Pidgeotto will have a Gust attack that does a bit more +damage. + +The Zephyr Badge gives your Pokémon a slight attack boost. That's cool and you +have one badge now. + +============================================================================== +============================================================================== +============================================================================== + +(c09) +------------------------------------------ diff --git a/docs/gold-walkthrough/section-03-ruins-of-alph-and-union-cave.txt b/docs/gold-walkthrough/section-03-ruins-of-alph-and-union-cave.txt new file mode 100644 index 00000000..26208627 --- /dev/null +++ b/docs/gold-walkthrough/section-03-ruins-of-alph-and-union-cave.txt @@ -0,0 +1,312 @@ +---- 9 > Ruins of Alph and Union Cave ---- +------------------------------------------ + +Video Playthrough Link (Violet City, Ruins Alph, Route 32, Union Cave, to Azalea +Town): https://www.youtube.com/watch?v=7v-n0Z0CQsg + +Video Playthrough Link (Slowpoke Well, Azalea Town Gym, Ilex Forest, Goldenrod +City Gym): https://www.youtube.com/watch?v=LSEriFoAxC0 + +|-----------| +|Violet City| +|-----------| + +Right after you leave the gym, you should receive a phone call from Professor +Elm. He tells you to pick up something important at the Pokémon Center from his +aide. Apparently you are now doing volunteer work for the Professor for no extra +pay. When you get to the Pokémon Center, you will see Professor Elm's aide, the +guy by the counter. He will give you a Pokémon egg. The egg will hatch in about +1,000 steps. Leave the Violet City Gym and go to the Pokémon Center. It is +Togepi in egg form. There is one way to not keep Togepi on your team while it's +in egg form. Just put it in one of the Pokémon boxes (while it's an egg form) +and keep it there. + +Heal, buy at least 10 Pokeballs, then head west from the Violet City Gym. +Continue on the path north and then west until you enter Route 36. + +|--------| +|Route 36| +|--------| + +Talking to the two people on the route, you'll find out that a supposed tree is +blocking the way. Ignore that for now and head south past the house to the Ruins +of Alph. + +|-------------| +|Ruins of Alph| +|-------------| + +Pokémon found in or outside the Ruins of Alph: + +#177 Natu +#194 Wooper (Surfing) +#201 Unown +#235 Smeargle + +Item found in the Ruins of Alph: + +Unown Pokedex + +After you go through the route-changing station, enter the first cave on the +right that you see. Talk to the two people inside, go up to the left panel, and +then solve the Kabuto puzzle. The corner pieces should be obvious, so start from +there and work to the middle. It will look circular, but the eyes point +out...remember that. You are then dropped to the next floor, and you will find +yourself in another cave you could enter on the left side. + +This will allow you to catch a new Pokémon, #201 Unown, in its 28 forms. The +forms include all 26 letters of the English alphabet, plus two punctuation marks +(? and !). The lower passageway eventually leads up to a ladder that you can +climb up. But you might have fun catching some of the Unown alphabet while +you're here. + +Thanks to Thard_Verad for correcting the typing for Unown, who also comments: +"It's also a terrible addition to one's party. Bad stats and can only learn +Hidden Power." + +If you catch at least three different Unown letters in the Ruins of Alph when +you exit the cave at the bottom with the ladder, a professor assistant will +greet you and give you an Unown Pokedex add-on. + +For when you later have access to radio stations, Thard_Verad comments, "There's +a mysterious radio signal in the Ruins of Alph. It's on frequency 213.5 if you +want to listen." + +There's another puzzle you can do here once you have HM03 Surf. When you do, +surf across the narrow lake south of where you were (where you can catch a +Wooper, by the way) to the other side. Enter that part of the Ruins of Alph and +you can solve an Aerodactyl puzzle. It'll be easier to the corner and side +pieces first because it will come with that white background. + +After exiting the cave, go south, and then east. You will come up to another +route changing house. Welcome to Route 32. + +|--------| +|Route 32| +|--------| + +Pokémon found in Route 32: + +#019 Rattata +#023 Ekans +#041 Zubat +#069 Bellsprout +#179 Mareep +#187 Hoppip +#194 Wooper + +Items found in Route 32: + +Great Ball +Miracle Seed +Poison Barb +Potion +Old Rod +Repel +TM05 + +After exiting the house, go a little east and then up into Violet City to grab +that PRZCure Berry, then head back south onto Route 32. Talk to the first guy +you see and you will get Miracle Seed. Grass moves will then be increased in +power by 20%. Keep heading south and getting easy experience points fighting +silly Pokémon like Ekans. + +Depending on what starter you have, you may want to catch both a Bellsprout and +a Wooper (starting with Cyndaquil), just Wooper (starter being Chikorita), or +just Bellsprout (starter being Totodile), for the HMs Cut and Surf later on. Use +quick attack from a non-starter Pokémon to a yellow or HP range, then catch +those Pokémon. Wooper will be a bit harder to find than Bellsprout. Then keep +going down till you see the first trainer. + +Youngster Albert +Level 6 Rattata (72 EXP) +Level 8 Zubat (91 EXP) +You get: 128G + +Now there's a fork. Take the left path and get the Repel, then head back to the +right to talk to the girl who will fight you while being on the phone. So +talkie. + +Picnicker Liz +Level 9 Nidoran (F) (110 EXP) +You get: 180G + +Talk to her again and you'll even get her phone number and vice versa. Anyway, +head down and take the grassy eastern route along the lake. Go down and talk to +the first fisherman you reach for a nice battle. Easy battle versus Magikarps +who can barely splash, except for the Level 15 one. + +Fisher Justin +Level 5 Magikarp (21 EXP) +Level 5 Magikarp (21 EXP) +Level 15 Magikarp (63 EXP) +Level 5 Magikarp (21 EXP) +You get: 200G + +Talk to the fisherman to the left of Justin and you'll have an actual fight. + +Fisher Henry +Level 8 Poliwag (128 EXP) +Level 8 Poliwag (132 EXP) +You get: 320G + +Keep going south till that next fisherman spots you. + +Fisher Ralph +Level 10 Goldeen (237 EXP) +You get: 400G + +Exhausted with all the battles now? Take your trip back north to that PRZCure +Berry you saw earlier, after your Mom gives you that call. She apparently buys +something with your money. You can check that at the Pokémon Center. + +Thard_Verad adds, "Mom will buy something once your savings reach certain +thresholds. The items are pretty forgettable, but the dolls (AFAIK) are unique +to her shopping trips." + +Anyway, heal, withdraw the item (a Super Potion), then come back down the same +route, but take the LEFT path instead and immediately you see a trainer that +wants to fight you. Oh no! + +Camper Roland +Level 9 Nidoran (M) (115 EXP) +You get: 180G + +Go south then back up to grab the Great Ball, then head south once more to fight +the trainer below you. + +Youngster Gordon +Level 10 Wooper (111 EXP) +You get: 160G + +Go back up the right/fishing route to talk to Fisher Ralph to exchange phone +numbers. Keep going down the route and someone wants a million dollars for a +Slowpoke tail. Go up to the girl and she'll give you a Poison Barb. Now heal at +that Pokémon Center in the middle of nowhere. Some rando fisher will give you an +Old Rod. Keep heading south and there's yet another trainer. Finally a trainer +that knows you have one badge! + +Bird Keeper Peter +Level 6 Pidgey (70 EXP) +Level 6 Pidgey (70 EXP) +Level 8 Spearow (99 EXP) +To get: 192G + +Well, that was easy. Now that you're at the Union Cave entrance, you should +enter Union Cave. + +|----------| +|Union Cave| +|----------| + +Pokémon in Union Cave: + +#019 Rattata +#041 Zubat +#074 Geodude + +Items in Union Cave: + +Potion +X Attack +TM39 Swift + +Now that you're finished with Route 32, it's time to bring the fighting into +Union Cave. There isn't that much to do in Union Cave, however. + +Your lineup (if you are balancing out a starter and Rattata) should now be: + +Level 3 Pidgey +Level 4 Wooper +Level 5 Mystery Egg +Level 7 Bellsprout +Level 15 Rattata +Level 15 Starter + +Keep heading along the path westward and you'll see a lot of silly, silly +Geodudes. Take the Great Ball, and go south to fight your first trainer battle +in Union Cave. + +Hiker Daniel +Level 11 Onix (252 EXP) +You get: 352G + +Head east to the next trainer, who will ask if you are truly ready for Azalea's +gym. Show that you are! + +Hiker Russell +Level 4 Geodude (82 EXP) +Level 6 Geodude (109 EXP) +Level 8 Geodude (147 EXP) +You get: 256G + +Firebreather Bill +Level 6 Koffing (145 EXP) +Level 6 Koffing (145 EXP) +You get: 288G + +Grab the X Attack below Bill, then head back up to Bill and go left. Don't miss +the item to the far left. It's a Potion. Go south and then up the ledge. + +PoKeManiac Larry +Level 10 Slowpoke (211 EXP) +You get: 600G + +Now go up and down the ladder. Head left to get TM39 Swift, then back east past +the ledge you went down, to get the item X Defend. Head back thus the way you +came, head down the ledge, and grab the Awakening Item to the southeast. Then go +east to fight the last trainer. + +Firebreather Ray +Level 9 Vulpix (121 EXP) +You get: 432G + +Afterwards, you can go right and down and out the door to Route 33. + +Note that when you have obtained HMs Strength and Surf you should return back to +Union Cave and explore the lower levels. If you do you will find a Lapras, who +only appears on Friday, plus the entrance to more of the Ruins of Alph. + +|--------| +|Route 33| +|--------| + +Pokémon found in Route 33: + +#019 Rattata +#021 Spearow +#023 Ekans +#041 Zubat +#187 Hoppip + +Items found in Route 33: + +PSNCure Berry + +After exiting the Union Cave, go a little down to grab the PSNCure Berry from +the tree, then head west. You'll meet just one trainer in the way: Hiker +Anthony. He says he's got plenty of energy left to battle you. + +Hiker Anthony +Level 11 Geodude (202 EXP) +Level 11 Machop (207 EXP) +You get: 352G + +Now that he is defeated, head west into Azalea Town. + +If you chose to switch leveling up your starter and Rattata, your lineup should +be around: + +Level 17 Starter +Level 16 Rattata +Level 8 Bellsprout +Level 5 Mystery Egg +Level 4 Wooper +Level 3 Pidgey + +============================================================================== +============================================================================== +============================================================================== + +(c10) +---------------------------------------------- diff --git a/docs/gold-walkthrough/section-04-slowpoke-well-azalea-town-gym.txt b/docs/gold-walkthrough/section-04-slowpoke-well-azalea-town-gym.txt new file mode 100644 index 00000000..54dd1d60 --- /dev/null +++ b/docs/gold-walkthrough/section-04-slowpoke-well-azalea-town-gym.txt @@ -0,0 +1,155 @@ +---- 10 > Slowpoke Well & Azalea Town Gym ---- +---------------------------------------------- + +Video Playthrough Link (Slowpoke Well, Azalea Town Gym, Ilex Forest, Goldenrod +City Gym): https://www.youtube.com/watch?v=LSEriFoAxC0 + +|-----------| +|Azalea Town| +|-----------| + +Items found in Azalea Town: + +Lure Ball +TM49 +Wht Apricorn + +When you enter the town, you should notice that one Team Rocket member is +guarding a well-like structure. Ignore this for now, but remember where it is. +At about this time, you might get a call from Liz the Picnicker and she tells +you that Moo Moo Milk can be bought at Moo Moo Farm for good health and beauty. +Anyway, keep rolling up to the northernmost house in town by heading west then +north in the city. He is Kurt and he informs you about the disbanding and return +of Team Rocket. Kurt goes to "give them a lesson in pain". Anyway, go back to +the well-like structure after you heal at the Pokémon Center and you'll see that +the Team Rocket guard has gone, or more like, demolished by Kurt. Now enter the +Slowpoke Well! + +|-------------| +|Slowpoke Well| +|-------------| + +You'll notice Kurt took a tumble down the well, so it's up to you now. Head up +to start the war on Team Rocket and its members, especially the guy Kurt had a +shouting match with. + +Rocket Grunt (M) +Level 9 Rattata (109 EXP) +Level 9 Rattata (109 EXP) +You get: 360G + +Talk to him again and you'll learn that they're chopping off Slowpoke tails for +cash. Move on up the stairs for Battle #2. + +Rocket Grunt (F) +Level 9 Zubat (103 EXP) +Level 11 Ekans (145 EXP) +You get: 440G + +Move left to grab the super potion to the left of her, then head down the stairs +into another battle. + +Rocket Grunt (M) +Level 7 Rattata (85 EXP) +Level 9 Zubat (103 EXP) +Level 9 Zubat (103 EXP) +You get: 360G + +Talk to the Slowpoke above you. It had its tail cut off. Poor thing, and there's +mail on it? Anyway, move left to face the fourth Team Rocket member. + +Rocket Grunt (M) +Level 14 Koffing (342 EXP) +You get: 560G + +He, the leader of this band of Team Rocket members, tells you that they were +working underground for the past three years. Looks true, doesn't it? Team +Rocket leaves and Kurt talks to you. He returns you to his house in Azalea Town. + +|-----------| +|Azalea Town| +|-----------| + +Items found in Azalea Town: + +Lure Ball +TM49 +Wht Apricorn + +Talk to Kurt again and he'll give you a Lure Ball for free. Whee! Go right above +his house and grab the Wht Apricorn, then heal at the Pokémon Center. + +Thard_Verad comments, "Kurt only accepts ONE Apricorn at a time. Unfortunately +you can't give him multiples of the same kind." + +After you heal, head to the Azalea Town Gym, and the home of the Bug types. By +now, you're 2 to 2 1/2 hours into the game. Anyway, now's the time to enter the +gym and face Bugsy and her apprentices. Rattata will be a MAJOR help here. Go up +and Amy (the left girl) and May (the right girl) will challenge you. Remember, +Quilava is extra-effective here if you have him. Otherwise, Rattata will still +stun through this gym. + +Twins Amy & May +Level 10 Spinarak (115 EXP) +Level 10 Ledyba (115 EXP) +You get: 200G + +Head up the left side to meet your next opponent. Someone knows you saved the +Slowpokes. + +Bug Catcher Josh +Level 13 Paras (195 EXP) +You get: 208G + +Continue around the circular center to the top center person. Good luck! + +Bug Catcher Benny +Level 7 Weedle (78 EXP) +Level 9 Kakuna (136 EXP) +Level 12 Beedrill (408 EXP) +You get: 192G + +Continue around the circle, clockwise, to the final one of the five trainers +lying around the gym leader's circle. Time to beat Al! + +Bug Catcher Al +Level 12 Caterpie (135 EXP) +Level 12 Weedle (133 EXP) +You get: 200G + +Depending on whether Rattata and have your starter have the HP, you can either +go back and heal or just go straight to fighting Gym Leader Bugsy. In any case, +put Rattata on the front line, then your starter as backup. Yay for Bugsy. She +may have the Bug Pokémon, but you have the counters! + +--- +GYM LEADER BUGSY +Level 14 Metapod (215 EXP) +Level 14 Kakuna (213 EXP) +Level 16 Scyther (640 EXP) +You get: Hive Badge, 1,600G, & TM49 (Fury Cutter) +--- + +Strategy VERSUS Metapod: Use the highest-powered attack on your highest level +Pokémon. Metapod's easy. If you have Cyndaquil, use Ember, as fire type moves +are super effective against bug types. + +Strategy VERSUS Kakuna: Use the highest-powered attack on your highest level +Pokémon. But watch out for Kakuna's poison sting. You might want to use an +Antidote. If you have Cyndaquil, use Ember, as fire type moves are super +effective against bug types. + +Strategy VERSUS Scyther: Use the highest-powered attack on your highest level +Pokémon. If you have Cyndaquil, use Ember, as fire type moves are super +effective against bug types. Scyther may take multiple hits to take down. + +So now, you have the second badge which lets all Pokémon up to Level 30 obey you +regardless of whatever, and you can use the HM01 Cut. You also get TM49, Fury +Cutter. This lets Pokémon have stronger attacks over time. Whee. + +============================================================================== +============================================================================== +============================================================================== + +(c11) +------------------------------------------------- diff --git a/docs/gold-walkthrough/section-05-ilex-forest-and-goldenrod-city-gym.txt b/docs/gold-walkthrough/section-05-ilex-forest-and-goldenrod-city-gym.txt new file mode 100644 index 00000000..e52dae31 --- /dev/null +++ b/docs/gold-walkthrough/section-05-ilex-forest-and-goldenrod-city-gym.txt @@ -0,0 +1,395 @@ +---- 11 > Ilex Forest and Goldenrod City Gym ---- +------------------------------------------------- + +Video Playthrough Link (Slowpoke Well, Azalea Town Gym, Ilex Forest, Goldenrod +City Gym): https://www.youtube.com/watch?v=LSEriFoAxC0 + +|-----------| +|Azalea Town| +|-----------| + +Now leave the gym and heal your Pokémon at the Pokémon Center. Go west and try +to exit the town and you'll run into your rival. Yay for you! He doesn't believe +you beat Team Rocket, but let the battle begin. + +|--------------------------| +|Rival | +|Level 12 Gastly (240 EXP) | +|Level 16 Starter (489 EXP)| +|Level 14 Zubat (162 EXP) | +|You get: 960G | +|--------------------------| + +Pokémon VERSUS Gastly: Use the highest-powered attack on your highest level +Pokémon that is NOT a normal type move, as ghost types do not get damaged by +normal moves. You can't use normal moves with Gastly, but you can use grass, +fire, or water. + +Pokémon VERSUS Starter: Use the highest-powered attack that aren't the type of +your starter. + +Pokémon VERSUS Zubat: Use the highest-powered attack on your highest level +Pokémon. + +Well, at least he admits, "I only lost because my Pokémon were weak." He talks +tougher than his Pokémon really are. Uncle Ralph calls again about taking care +of Pokémon and taking out a Zubat. Anyway, heal again, then continue going west +into Ilex Forest! + +Defeat your rival and head the way you were going, west. You'll enter Ilex +Forest. + +|-----------| +|Ilex Forest| +|-----------| + +Pokémon found in Ilex Forest: + +#010 Caterpie +#011 Metapod +#013 Weedle +#041 Zubat +#043 Oddish +#046 Paras + +Items found in Ilex Forest: + +HM01 (Cut) +Revive +TM02 (Headbutt) +TM12 (Sweet Scent) + +Keep heading north up the path, then follow the path eastward. Talk to the first +guy you see. He is standing next to a bush that you can cut. He tells you that +his boss's Farfetch'd got away. He's sooo irresponsible. Keep following the path +and you'll find that scary Farfetch'd. XD + +Anyway, if you talk to the Farfetch'd, it'll run away. Far, far away. Keep on +following it and talking to it. Eventually, it'll turn back the opposite +direction so make sure you're "pushing" it back the way you came. Grab the +Revive you see while you're at it. It will then lead you to the man and his +boss. + +Anyway, talk to the boss and he will give you HM01 Cut! You'll want to equip Cut +on Bellsprout right away, and that is in the fourth section of your backpack. +Now use HM01 Cut on that bush to keep moving up the path. A new feature is that +you can just go up to the bush, press A, and it will get cut down if you have a +Pokémon with Cut in your lineup. You can do the same later with other HMs like +Strength (with boulders), Surf (with water), and Whirlpool (with, well, +whirlpools). + +Now you enter the Ilex Forest Shrine, and the Ilex Forest Maze. Keep heading +along the path and grab the X Attack you see, and then the Antidote you spot +later on the path going east. To the northeast is an Ether, but go right first. +Talk to the trainer and you'll get TM02 Headbutt, which allows you to headbutt +other people. Just kidding. It's for your Pokémon and I am a serious person. +Headbutt lets you shake the little trees that are everywhere in the game, +sometimes revealing sleeping Pokémon. Then, head back left from there and talk +to that other trainer for a battle! + +Bug Catcher Wayne +Level 8 Ledyba (122 EXP) +Level 10 Paras (150 EXP) +You get: 160G + +Keep going along the path and into the building to the north. Talk to the lady +behind the counter and you'll get TM12 Sweet Scent. Walk through and you'll get +on Route 34! + +|--------| +|Route 34| +|--------| + +Pokémon found in Route 34: + +#019 Rattata +#063 Abra +#096 Drowzee +#132 Ditto + +Items found in Route 34: + +Soft Sand + +Welcome to Route 34. Here, you'll fight even more trainers. Head right up and +you'll enter a match right away. + +Youngster Samuel +Level 7 Rattata (85 EXP) +Level 10 Sandshrew (198 EXP) +Level 8 Spearow (99 EXP) +Level 8 Spearow (99 EXP) +You get: 128G + +Keep heading up the path to face the guy who sent his Pokémon to the Daycare +Center! + +PoKeFan Brandon +Level 13 Snubbull (175 EXP) +You get: 1040G + +Continue along the path to face that Youngster, who says he's the best in his +class at Pokémon. + +Youngster Ian +Level 10 Mankey (157 EXP) +Level 12 Diglett (207 EXP) +You get: 192G + +Youngster Ian will want to trade phone numbers with you. Now head down through +some of the wild grass to battle Picnicker Gina. + +Picnicker Gina +Level 9 Hoppip (142 EXP) +Level 9 Hoppip (142 EXP) +Level 12 Bulbasaur (163 EXP) +You get: 240G + +Head up from Youngster Ian and you'll find this building to the right. Enter it +and talk. Thard_Verad notes correctly that only in the Pokémon Crystal Version +does the Daycare have a free egg and that "there's a PC in the corner of the +Daycare" if you need it. + +Inside the Daycare/Breeding Center with the old man and woman, you can talk to +the man and he will take one of your Pokémon to level up. Talking to the woman +will do the same thing. It will cost 100G to take them out, and 100G per level +up, but their stats won't be as good as if you trained them yourself, obviously. +To breed Pokémon you must give the old man a Male Pokémon and give the old woman +a Female Pokémon (or vice-versa) that have the same little icon or sprite. Or +they must be compatible at breeding. + +Then exit the building and go walk around for a little while. Come back when you +see the man standing outside with your two Pokémon. Talk to him and answer yes, +he will give you an Egg. If you put in Pokémon of different #'s, for example +Pidgey and Hoothoot, the resulting egg will be the same species as the female +Pokémon and with an Attack that the Male had. Just think of the strange +combinations. + +Head back out as you can't really do much here yet. If it's night time, you can +fight the officers. Thanks to Thard_Verad for pointing out that the Police +Officers will only battle you at night. + +Officer Keith +Level 17 Growlithe (331 EXP) +You get: 680G + +Move up to take on the final trainer on the route. + +Camper Todd +Level 14 Psyduck (240 EXP) +You get: 280G + +Great! Now, talk to the same guy again and you'll exchange phone numbers. He's +great. He'll call you later about his awesome looking Psyduck and that he beat +some tough Rattata. + +Then, let's go into Goldenrod City, the LARGEST city in the game, or something +like that. + +If you focused on just leveling up your starter, your lineup might look like +this: + +Level 25 Starter Evolution +Level 6 Bellsprout +Level 6 Wooper +Level 5 Rattata +Level 5 Unown Egg + +If you balanced leveling up your Starter and Raticate: + +Level 22 Starter Evolution +Level 20 Raticate +Level 8 Bellsprout +Level 5 Mystery Egg +Level 5 Pidgey +Level 4 Wooper + +|--------------| +|Goldenrod City| +|--------------| + +Pokémon found in Goldenrod City: + +#66 Machop (Through Trading) +#133 Eevee (Get from Bill) + +Pokémon/Items in Goldenrod's Game Corner: (Coins) + +Abra 200 TM 25 5500 +Ekans/Sandshrew 700 TM 14 5500 +Dratini 2100 TM 38 5500 + +Items found in Goldenrod City: + +Bicycle +Coin Case +HP Up +Radio Card +TM 21 +TM 27 +TM 45 +Squirtwater + +Goldenrod City has got to be the biggest city in the game. There's lots to do in +the city. Heal at the Pokémon Center, then cross the road to the right to the +Goldenrod City Department Store. There's a beautiful assortment of items on +every floor: + +Level 2: Potions, Heals, Great Balls, Escape Ropes, Repels +Level 3: X Items (temporary stat boosts) +Level 4: Stat increase items +Level 5: TM02, TM33, TM41, TM48 (includes Drownzee-Machop trade and Sunday +giveaway of TMs) +Level 6: Vending Machine heal items + +Thard_Verad adds, "Super Potions are a scam. As soon as you reach Goldenrod, you +can go to the top of the Dept. Store and replace all your Potions with Fresh +Water (same price, 2.5x stronger) and Super Potions with Lemonade (half price, +60% stronger) If only you could buy in bulk...." + +Additionally, Thard_Verad comments, "After you get TM08 Rock Smash, it will be +added to the Dept. Store's lineup." + +Next, you'll want to head to Bill's house. Go through the path just above the +Pokémon Center and it should be the first house you see. Talk to the girl that's +closer to the door and you'll get Bill's phone number. Calling it will tell you +how many Pokémon you have. + +After that, we'll go down under the city for some street fighting. Go to the +house below Bill's, and it turns out to be the entrance to the underground. Go +downstairs. Go up and you'll face a Game Corner cheater. + +Super Nerd Eric +Level 11 Grimer (211 EXP) +Level 11 Grimer (211 EXP) +You get: 352G + +Head up to face PoKeManiac Isaac. + +PoKeManiac Isaac +Level 12 Lickitung (325 EXP) +You get: 720 + +Grab the Coin Case above him. You'll need it for the Game Corner because +apparently, nothing else can carry their special coins. Pass by the three +strange shops, including a Pokémon Salon, and stop in front of the guy that's +there. + +Super Nerd Teru +Level 7 Magnemite (133 EXP) +Level 11 Voltorb (241 EXP) +Level 7 Magnemite (133 EXP) +Level 9 Magnemite (171 EXP) +You get: 288G + +Head up to face another trainer. + +PoKeManiac Donald +Level 10 Slowpoke (211 EXP) +Level 10 Slowpoke (211 EXP) +You get: 600G + +Head to the right and you'll see the door is locked, so come back and go up the +stairs above Donald. Come back out and go to the Pokémon Center to heal. + +Now head out of the gym and we'll hit the east side of town (below the train +tracks) for a Bike. Keep heading down that path till you hit a dead end and +there's a house. It's the Bike Shop. Talk to the manager inside and he tells you +that you can borrow a bike if you advertise for him. You can access the bike +from your backpack. This makes no sense - is it a foldable bike? Eh, whatever. +It's in the 3rd section of your backpack, so start using it to cruise through +areas. Once you get out, go to your bicycle and press Sel, which means the item +will be equipped for use on the Select Button. Now you can simply just press +Select and you'll be on your bike. + +To the west is a train station. The train conductor stands there and says no +train is coming, but they're still paying him to be there. Now, you'll want to +visit the Radio Tower in the western side of town. It is a black building just +south of the train tracks. Talk to the middle man for possible ID matches, then +talk to the woman on the right for the Radio Quiz. + +Question 1: Can the Town Map be displayed on a Pokegear? (YES) + +Question 2: Can Nidorina be female only? (YES) + +Question 3: Does Kurt, the Pokeball crafts-man, use Aprikorn? (NO) + +Question 4: Maikarp won't learn any TM move? (YES) + +Question 5: Prof. Oak's Pokémon Talk is a very popular program? Is Marie the +co-host of the show? (NO, it's Mary) + +Now you'll get the Radio Card for your Pokegear, and can listen to the radio +anytime. There are three radio channels, including Pokémon Music 7.5 with DJ Ben +and the 8.5 Lucky Channel. Thard_Verad notes correctly that the Buena meeting +and Blue Card are only available in the Pokémon Crystal Version. Head back out +now. + +Now, let's head for the gym for that 3rd badge, shall we? We'll go back to the +PoKeMart for supplies later. The gym is in the northeast part of town. This +gym's trainer all use Normal Pokémon. + +Enter the gym and head up to face Beauty Victoria. + +Beauty Victoria +Level 9 Sentret (109 EXP) +Level 13 Sentret (157 EXP) +Level 17 Sentret (207 EXP) +You get: 1496G + +Easy battle. Go around her and to the right side of the gym to face the other +Beauty now. + +Beauty Samantha +Level 16 Meowth (235 EXP) +Level 16 Meowth (235 EXP) +You get: 1408G + +Start heading left along the path towards the center, for another battle. + +Lass Carrie +Level 18 Snubbull (243 EXP) +You get: 432G + +Go up to fight the last trainer before the gym leader, Lass Bridget. She loves +to use Jigglypuff and has 3 of them, so expect to get hit by sleep spells. + +Lass Bridget +Level 15 Jigglypuff (243 EXP) +Level 15 Jigglypuff (243 EXP) +Level 15 Jigglypuff (243 EXP) +You get: 360G + +Now, me being me, I'd just go straight into the battle against the gym leader +without items or healing, but I'd recommend going back to the Pokémon Center to +heal before the battle. + +--- +GYM LEADER WHITNEY +Level 18 Clefairy (261 EXP) +Level 20 Miltank (856 EXP) +You get: Plain Badge, 2,000G, & TM45 (Attract) +--- + +Strategy VERSUS Clefairy: Use your highest powered attack from your starter. + +Strategy VERSUS Miltank: The most troublesome thing about facing a Miltank is +that it heals with milk and it has an attack that makes your Pokémon get woozy. +Your Pokémon are then unable to attack the Miltank for a few turns and Miltank +just slams you with Stomps and Rollouts. Use your highest powered attacks from +your highest level Pokémon. Flying type moves are also super effective against +Miltank, and rock Pokémon tend to absorb moves from Miltank better than the +rest. + +Whitney won't give you the Plain Badge at first because she will be upset that +you've been "so serious". Anyway, she refuses to give you your badge. The +trainer below you will talk to you. Then talk to Whitney again. She will now +give you the Plain Badge. It will let your Pokémon use Strength outside of +battle and it boosts your Pokémon's Speed. She'll also give you TM45 Attract. + +============================================================================== +============================================================================== +============================================================================== + +(c12) +------------------------------------------------- diff --git a/docs/gold-walkthrough/section-06-bug-catching-contest-and-sudowoodo.txt b/docs/gold-walkthrough/section-06-bug-catching-contest-and-sudowoodo.txt new file mode 100644 index 00000000..067b4ae0 --- /dev/null +++ b/docs/gold-walkthrough/section-06-bug-catching-contest-and-sudowoodo.txt @@ -0,0 +1,292 @@ +---- 12 > Bug Catching Contest and Sudowoodo ---- +------------------------------------------------- + +Video Playthrough Link (Goldenrod City, Routes 35-37, to Ecruteak City): +https://www.youtube.com/watch?v=x0E8PnSbA5o + +Head to the house just to the right of the gym. Talk to the girl you talked to +before and it turns out her sister is right in the same house. Talk to her +sister and you'll get the Squirtbottle. You'll need that for a later part of the +story. No, you won't use it on a Squirtle. What would that even do, anyway? + +Then, we're ready to head into Route 35 after Liz's offer for a rematch on Route +32. Ride your bike north through the building. It's cool how you can just ride a +bike through, but isn't that a safety hazard? + +In the route changing house towards Route 35, if you have a Pokémon lineup spot +open, a man can give you a Spearow named Kenya. You're supposed to get the mail +that Spearow is carrying to Route 31. Or you can just keep the Spearow with the +mail and not do anything with the mail. It's your life. You do you. + +Thard_Verad adds, "You can keep Kenya the Spearow and still complete the side +quest. Go catch a spare Spearow and move the Mail from Kenya to your own bird." + +|------------------------| +|Route 35 & National Park| +|------------------------| + +Pokémon found in Route 35: + +#016 Pidgey +#029 Nidoran (F) +#032 Nidoran (M) +#063 Abra +#096 Drowzee +#132 Ditto +#163 Hoothoot +#193 Yanma + +Items found in Route 35: + +Mystery Berry +TM04 Rollout + +The short Route 35 is home to a few trainers to fight along with. This is a +great place to train up your Pokémon. Thard_Verad adds, "If you farm up Mystery +Berries, they can supplement any Ethers you bring to the Elite Four." + +Picnicker Kim +Level 15 Vulpix (202 EXP) +You get: 300G + +Next up is Camper Elliot. He thinks he's amazing because he has a girlfriend. + +Camper Elliot +Level 13 Sandshrew (258 EXP) +Level 15 Marill (186 EXP) +You get: 300G + +Then you talk to Picnicker Brooke and she's less enthused that her boyfriend's +Pokémon are so bad. "My boyfriend's weak, so I can't rely on him." XD + +Picnicker Brooke +Level 16 Pikachu (280 EXP) +You get: 320G + +Move up to the next trainer! + +Camper Ivan +Level 10 Diglett (172 EXP) +Level 10 Zubat (115 EXP) +Level 14 Diglett (243 EXP) +You get: 280G + +Keep moving up the short Route 35. Irwin will have ridiculously easy Voltorbs to +beat. + +Juggler Irwin +Level 2 Voltorb (43 EXP) +Level 6 Voltorb (132 EXP) +Level 10 Voltorb (220 EXP) +Level 14 Voltorb (309 EXP) +You get: 560G + +Now go up to the trainer at the left. + +Firebreather Walt +Level 11 Magmar (393 EXP) +Level 13 Magmar (465 EXP) +You get: 624G + +There's also an officer just outside the checking station. Show him that you +mean business. + +Officer Dirk +Level 14 Growlithe (273 EXP) +Level 14 Growlithe (273 EXP) +You get: 560G + +Head up through the checking station on Route 35 and you'll be in National Park. + +|-------------| +|National Park| +|-------------| + +Pokémon found in National Park: + +#010 Caterpie +#011 Metapod +#012 Butterfree +#013 Weedle +#014 Kakuna +#015 Beedrill +#016 Pidgey +#046 Paras +#048 Venonat +#123 Scyther +#127 Pinsir +#163 Hoot Hoot +#191 Sunkern + +Items found in National Park: + +Berry (Consolation Prize) +Everstone (2nd Prize) +Gold Berry (3rd Prize) +Parlyz Heal +Quick Claw +Sun Stone (1st Prize) +TM28 + +There are some rare Pokémon here that makes the detour worthwhile. Scyther and +Pinsir are rare, and Nincada and Scyther can learn False Swipe. You'll also have +your version's bug type (Caterpie or Weedle). However these Pokémon are only +found during the Bug Catching Contest, and the contest only happens on Tuesdays, +Thursdays, and Saturdays. + +Pokémon at the Bug Catching Contest: + +Caterpie/Weedle +Nincada +Scyther +Pinsir +Volbeat +Wurmple + +You can decide which Pokémon that you use in the contest, but you must place it +in the first spot in your Pokémon lineup. The Pokémon here are generally around +Levels 28-31. You will get 20 Sport Balls and 20 minutes. In order to win the +contest, you will have to catch a rare Pokémon. Scyther and Pinsir are worth the +most points. If you catch a Scyther at full HP, it may be worth around 342 +points. If you win the contest, you can win a Shiny Stone! + +Thard_Verad adds: "During the Bug Catching Contest, your score also takes the +bug's HP into consideration. The best plan of attack is +to not attack. Bring a Pokemon with Sleep Powder and try to catch Scyther/Pinsir +while it's asleep but at full health." + +You can stay for the Bug-Catching Contest if you'd like and it is the right day. +Otherwise or afterwards, talk to the lady on the bench for Quick Claw, then head +up past the first part of the National Park and fight Pokefan Beverly. + +PoKeFan Beverly +Level 14 Snubbull (189 EXP) +You get: 1120G + +Head northeast to fight Schoolboy Jack! + +Schoolboy Jack +Level 12 Oddish (199 EXP) +Level 15 Voltorb (330 EXP) +You get: 480G + +Here in the park you'll also notice that there is high grass. This gives you +a bigger chance of encountering wild Pokémon. Head back to the left of +Beverly, then go northwest to fight the Lass. + +Lass Krise +Level 12 Oddish (199 EXP) +Level 15 Cubone (279 EXP) +You get: 360G + +If you go up and to the right, you can fight the last Pokefan in the National +Park. + +PokeFan William +Level 14 Raichu (366 EXP) +You get: 1120G + +How did these Pokefans get so rich? Next, head east and a little south to reach +Route 36. Have a Repel ready, then head onto Route 36. + +|-------------------| +|Route 36 & Route 37| +|-------------------| + +Pokémon found on Routes 36 & 37: + +#016 Pidgey +#017 Pidgeotto +#029 Nidoran (F) +#032 Nidoran (M) +#037 Vulpix (Silver only) +#058 Growlithe (Gold only) +#163 Hoothoot +#165 Ledyba (Silver only, morning only) +#167 Spinarak (Gold only, night only) +#234 Stantler + +Items found on Routes 36 & 37: + +Blk Apricorn +Blu Apricorn +Hard Stone +Ice Berry +Red Apricorn +TM08 Rock Smash + +Routes 36 & 37 are home to even more trainers. Grab the Ice Berry from the tree +you see right away, then head down to face Psychic Mark. + +Psychic Mark +Level 13 Abra (202 EXP) +Level 13 Abra (202 EXP) +Level 15 Kadabra (465 EXP) +You get: 480G + +Head down from Mark through the small path into Route 35 once again. + +|--------| +|Route 35| +|--------| + +Cut the tree blocking your way with A, then fight the trainer you see right +away. + +Bug Catcher Arnie +Level 15 Venonat (240 EXP) +You get: 240G + +Keep going down the grassy route till you reach another trainer. Fight him. + +Bird Keeper Bryan +Level 12 Pidgey (141 EXP) +Level 14 Pidgeotto (339 EXP) +You get: 336G + +Drive up to the left side and grab the item you saw earlier, TM04 Rollout. Then, +head back to Arnie and talk to her for her phone number. Afterwards, you can cut +the bush and head back onto Route 36. + +|--------| +|Route 36| +|--------| + +Welcome back, I guess. You'll want to head east now and take on the next +trainers. + +Schoolboy Alan +Level 16 Tangela (566 EXP) +You get: 512G + +Talk to Floria, the girl nearby, now. While she goes away to get the +Squirtbottle, you'll want to talk to Alan for his phone number before you run +after her back to Goldenrod City. Arnie also gives you a call at about that time +with Pidgey. Not sure why it's so important that you learn about Pidgey. Keep +heading northeast and you'll get to the tree Pokémon, Sudowoodo. + +Now that you're at the strange looking tree, SAVE! You'll want to catch it even +though you never once dreamed of catching something that looks like a lamp post. +Now that you have the Squirtbottle, approach the tree and use the Squirtbottle +on it. The tree will then attack and you'll learn it's a Level 20 Sudowoodo. + +It's the only one in the game, so catch it or else. Or else what, you may ask? +Well, I'm not going to inflict physical torture on you. You'll just have to live +with the grim reality that you did not catch Sudowoodo. Use same-type moves on +your starter Pokémon to send it down into the red HP range, then use a Great +Ball. You do not want to knock it out. You're here to capture it. + +Sudowoodo is a good addition to your team. Once you have captured Sudowoodo, +talk to the guy to the east. He will give you TM08, Rock Smash +for getting Sudowoodo out of the way. Now head east from Sudowoodo back into +Violet City. + +Also note that on Thursday, Arthur of Thursday will appear. He will give you the +Hard Stone item just for talking to him. + +============================================================================== +============================================================================== +============================================================================== + +(c13) +------------------------------------------------- diff --git a/docs/gold-walkthrough/section-07-burned-tower-and-ecruteak-city-gym.txt b/docs/gold-walkthrough/section-07-burned-tower-and-ecruteak-city-gym.txt new file mode 100644 index 00000000..36c5fe1b --- /dev/null +++ b/docs/gold-walkthrough/section-07-burned-tower-and-ecruteak-city-gym.txt @@ -0,0 +1,361 @@ +---- 13 > Burned Tower and Ecruteak City Gym ---- +------------------------------------------------- + +Video Playthrough Link (Goldenrod City, Routes 35-37, to Ecruteak City): +https://www.youtube.com/watch?v=x0E8PnSbA5o + +Video Playthrough Link (Burned Tower, Ecruteak City Gym, & Olivine City +Lighthouse): https://www.youtube.com/watch?v=W0IMjwjGPO8 + +|-----------| +|Violet City| +|-----------| + +Now that Sudowoodo has damaged your Pokémon, heal at the Pokémon Center. You can +also now deposit Togepi and withdraw Sudowoodo to have it on your team. + +If you focused on just leveling up your starter, your lineup might look like +this: + +Level 31 Feraligatr +Level 20 Sudowoodo +Level 14 Drowzee +Level 12 Nidoran +Level 10 Spearow +Level 6 Bellsprout + +If you balanced leveling up your Starter and Raticate: + +Level 25 Raticate +Level 25 Starter Evolution +Level 20 Sudowoodo +Level 20 Eevee +Level 8 Bellsprout +Odd Egg + +Now, head back west onto Route 36, then go northwest onto Route 37. + +|--------| +|Route 37| +|--------| + +Pokémon found in Route 36 & 37: + +#016 Pidgey +#017 Pidgeotto +#029 Nidoran (F) +#032 Nidoran (M) +#037 Vulpix (Silver only) +#058 Growlithe (Gold only) +#163 Hoothoot +#165 Ledyba (Silver only, morning only) +#167 Spinarak (Gold only, night only) +#234 Stantler + +Items found in Route 36 & 37: + +Blk Apricorn +Blu Apricorn +Hard Stone +Ice Berry +Red Apricorn +TM08 Rock Smash + +First, you're battling some twins. If you start using your Sudowoodo here, Rock +Throw is the best move, but Low Kick can be a good alternative fighting move. + +Twins Ann & Anne +Level 16 Jigglypuff (259 EXP) +Level 16 Clefairy (232 EXP) +You get: 320G + +The bike shop calls again. Apparently, you can keep the bicycle because you +raised sales. Look at you, you're such an amazing salesman. We salute your +marketing efforts. Now, head up to fight the Psychic. + +Psychic Greg +Level 17 Drowzee (370 EXP) +You get: 544G + +Head back south of the Twins, then go east to the three apricorn trees. You may +want to clear your items at the Ecruteak City Pokémon Center before you grab +those items, so let's head to sunny Ecruteak City. + +If it's Sunday, you can also find Sunny of Sunday here and she'll give you a +Magnet item. + +|-------------| +|Ecruteak City| +|-------------| + +Enter the Pokémon Center. You will be unable to move for a second, making you +wonder if you have managed to freeze time. Alas, you do not have that ability +yet. Sad times. It's actually because Bill is getting his Pokémon healed. He's +finished adjustments on his Time Capsule, allowing you to trade Pokémon between +the Red/Blue/Yellow and Gold/Silver/Crystal games. But you'll need to have +caught something like 151 Pokémon first in Gold/Silver/Crystal before you can +actually make the trade. + +Heal at the Pokémon Center, and empty your small backpack of all your berries, +apricorns, and unused TMs into the PC. Make sure you CLEAR one Pokémon spot in +your team so that you can accept a free new Pokémon. Now, bike your way back +down to Route 37. Grab the apricorns you didn't grab earlier (Red, Black, and +Blue Apricorns). Then, keep biking down Route 36, through the National Park, and +through Route 35 back to Goldenrod City. With your newfound biking skills, I +think you should be ready for the Tour de France in no time. + +|--------------| +|Goldenrod City| +|--------------| + +Head to the southwest portion of the city and enter Bill's house. Now that +you've babysat a Togepi, he will give you an Eevee to take care of. I personally +think Eevee should be named Eevily. What a fantastic name, if I do say so +myself. Eevee can evolve into five other Pokémon: Jolteon (with a thunder +stone), Flareon (with a fire stone), Vaporeon (with a water stone), and Espeon +and Umbreon (based on high friendship and time of day - Espeon in the day, +Umbreon at night). Now, head back north onto Route 35. + +If you focused on just leveling up your starter, your lineup might look like +this: + +Level 31 Feraligatr +Level 20 Eevee +Level 20 Sudowoodo +Level 14 Drowzee +Level 10 Spearow +Level 6 Bellsprout + +If you balanced leveling up your Starter and Raticate: + +Level 25 Raticate +Level 25 Starter Evolution +Level 20 Sudowoodo +Level 20 Eevee +Level 8 Bellsprout +Odd Egg + +If you are building a balanced team, you could have Sudowoodo and Eevee exchange +experience points for a while from now on, until the fourth gym. In any case, +ride all the way back up to Ecruteak City, and don't get lost. Use the Pokegear +if you need to. There isn't a GPS in this game. + +|-------------| +|Ecruteak City| +|-------------| + +Items found in Ecruteak City: + +HM03 Surf +Itemfinder +TM30 + +You've reached Ecruteak City with an Eevee this time! Congratulations! Actually, +this isn't a prize booth, as much as you want it to be one. With your new Eevee +with you, enter the Dance Theater just above the Pokémon Center. Talk to the +creepy old man near the door and he will tell you that if you defeat all five +Kimono Girls, you will get a gift. I mean, he's creepy, but the challenge is +still worth your while. + +Head up the left side of the stage and battle the girl in green. It's time to +take on every Eevee evolution, one by one. The first is Flareon, a fire type. + +Kimono Girl Naoko +Level 17 Flareon (720 EXP) +You get: 1224G + +The girl in blue has Espeon, the psychic evolution to Eevee. + +Kimono Girl Sayo +Level 17 Espeon (717 EXP) +You get: 1224G + +Next is the girl in red, who has Umbreon, the dark evolution to Eevee. + +Kimono Girl Zuki +Level 17 Umbreon (717 EXP) +You get: 1224G + +The one that's next to the right in blue has Vaporeon, the water evolution to +Eevee. + +Kimono Girl Kuni +Level 17 Vaporeon (714 EXP) +You get: 1224G + +The final Kimono Girl has Jolteon, the electric evolution to Eevee. + +Kimono Girl Miki +Level 17 Jolteon (717 EXP) +You get: 1224G + +Now, talk to the same old man again. You'll get HM03 Surf for your efforts. You +can have almost any water type Pokémon learn it, plus a few others. But not +Magikarp. That fish just flops all over the place. Teach HM03 Surf to any +Pokémon that can learn it, because it's one of the best water moves for battle +and you can use it in the overworld. + +Leave the house and then head southwest to the gym. Once you see it, go to the +building to the right of it and talk to the man inside. He will give you +Itemfinder if you answer Yes to his question. It's amazing how many items you +can get in this game just by hearing people blather on about themselves and just +say Yes. + +Heal at the Pokémon Center, then head to the northwest part of Ecruteak City and +into the Burned Tower. I suggest bringing at least 3 Escape Ropes, 3 Repels, and +10 Great Balls with you. + +|------------| +|Burned Tower| +|------------| + +Pokémon at the Burning Tower: + +#019 Rattata +#041 Zubat +#109 Koffing + +Equipping the TM Rock Smash will give you the HP Up at the northeastern part of +the floor, but is it really worth it to use up that TM this early? Think about +your future. Head left and all the way around the spiral and you will now fight +your rival in a battle. He's gonna get pwned again by your superior lineup. He +has a Magnemite now, but you have two extra Level 21+ Pokémon. + +|-------------------------------------------| +|Rival | +|Level 20 Haunter (540 EXP) | +|Level 18 Magnemite (342 EXP) | +|Level 20 Zubat (231 EXP) | +|Level 22 Bayleef/Quilava/Croconaw (673 EXP)| +|You get: 1,320G | +|-------------------------------------------| + +Use anything other than normal and fighting moves against Haunter. Magnemite's a +steel type, so fire moves are good, while normal moves are bad. Fighting moves +like low kick help. Rock throws are great against Zubat. Against his starter +evolution, you can use the highest-powered move from your highest level Pokémon +that isn't the type of your Pokémon. Piece of cake. I love cake. + +Your rival says, after losing to you, “There’s no challenge in it.” After your +victory, you can grab a HP Up by dropping down the northwest hole. + +|-------------| +|Ecruteak City| +|-------------| + +Heal your Pokémon just before entering the Ecruteak City Gym. You'll probably be +around 4 1/4 to 5 hours into the game. Pat yourself on the back for your +commitment to fighting with fictitious pixelated creatures. Good job! + +If you're focusing on only leveling up your starter, your lineup should look +something like this: + +Level 33 Starter Evolution +Level 20 Sudowoodo +Level 20 Eevee + +If you're building up a more balanced lineup, it may look something like this: + +Level 21 Sudowoodo +Level 21 Eevee +Level 25 Starter Evolution +Level 26 Raticate + +Let's enter the gym now. Go straight up to fight Trainer 1. Every trainer will +use Ghost type Pokémon, meaning that you can't use any normal or fighting moves +from your Raticate or Eevee. So, you'll have to lean into using your starter +evolution and potentially Sudowoodo (rock throws). Your Pokémon will get licked +by ghosts. I hope your Pokémon likes the experience because it will get licked a +bunch of times. + +Sage Ping +Level 16 Gastly (325 EXP) +Level 16 Gastly (325 EXP) +Level 16 Gastly (325 EXP) +Level 16 Gastly (325 EXP) +Level 16 Gastly (325 EXP) +You get: 512G + +Now head exactly TWO steps right, then FOUR steps up to fight Trainer 2. Doing +just about anything else will make you fall off the platform. Keep those rock +throws going against Grace. + +Medium Grace +Level 20 Haunter (540 EXP) +Level 20 Haunter (540 EXP) +You get: 800G + +Talk to Grace again and basically, the path is the zig-zag. You must face every +trainer before you face the gym leader. Go three left, 2 up to Trainer 3. + +Sage Jeffrey +Level 22 Haunter (594 EXP) +You get: 704G + +Time to take on the final trainer before Morty. Go right 3, then up 2. As a test +of what's to come, this is almost the same lineup as the gym leader, except +Morty has higher levelled and stronger Pokémon. + +Medium Martha +Level 18 Gastly (360 EXP) +Level 20 Haunter (540 EXP) +Level 20 Gastly (406 EXP) +You get: 800G + +You can move up two squares to get past the platform to get to the gym leader. +But consider healing at the Pokémon Center before fighting the gym leader. I +guess you don't have to, but you're reading this guide and if you're not +following suggestions, why are you here? Oh, sorry, I digress. To get back, fall +off the path anywhere and it'll drop you back to the gym entrance. + +Heal at the Pokémon Center and then return to the gym, then take the zig-zag +path back to Morty. Save before you fight Morty in case you lose and want to +re-try the battle quickly. Talk to Morty, and he'll reference some kind of +vision of legendaries. I don't know what he was talking about, as I wasn't +really listening. He's kind of boring. + +Battle time - let's go! You'll want to beware of Pokémon that can use Hypnosis +to put your Pokémon to sleep, and then eat its dreams. If you picked Chikorita, +this could be a more challenging fight where you might need some assistance from +your Sudowoodo or other Pokémon. + +--- +GYM LEADER MORTY +Level 21 Gastly (427 EXP) +Level 23 Haunter (567 EXP) +Level 25 Gengar (1017 EXP) +Level 23 Haunter (621 EXP) +You get: Fog Badge, 2,300G, & TM30 (Shadow Ball) +--- + +Strategy VERSUS Gastly: You can always use your same-type attack from your +starter evolution. Alternatively, a single rock throw from Sudowoodo should and +would down Gastly. + +Strategy VERSUS Haunters: Use the same-type attack from your starter evolution +or use two rock throws from Sudowoodo. + +Strategy VERSUS Gengar: Gengar is really nasty because it has Hypnosis and then +Dream Eater to cap on it, but do as many rock throws from Sudowoodo as you can +before you pull out your starter evolution. Use the same-type attack from your +starter evolution to finish off Gengar. + +Phew, you won. That's pretty good, considering how many times you got cursed. +Half of the League has fallen to you. All Level 50 and below Pokémon obey you, +and now you can use HM03 Surf out of battle. TM30 Shadow Ball is a really good +ghost move you can equip on a ghost Pokémon, but keep in mind that it's a +one-use item. + +Now that you can equip and use HM03 Surf, you should equip it on every water +Pokémon that can use it. It's a 95 power move, making it one of the best attacks +you can use during the game playthrough. Surf is also a great move to use in the +overworld to get across bodies of water. And once you have HM03 Surf, you can +also go back to Union Cave on a Friday to catch a Level 20 Lapras (see Section +36 of this guide). + +============================================================================== +============================================================================== +============================================================================== + +(c14) +------------------------------------------------------- diff --git a/docs/gold-walkthrough/section-08-olivine-lighthouse-and-cianwood-city-gym.txt b/docs/gold-walkthrough/section-08-olivine-lighthouse-and-cianwood-city-gym.txt new file mode 100644 index 00000000..bd8322e4 --- /dev/null +++ b/docs/gold-walkthrough/section-08-olivine-lighthouse-and-cianwood-city-gym.txt @@ -0,0 +1,584 @@ +---- 14 > Olivine Lighthouse and Cianwood City Gym ---- +------------------------------------------------------- + +Video Playthrough Link (Burned Tower, Ecruteak City Gym, & Olivine City +Lighthouse): https://www.youtube.com/watch?v=W0IMjwjGPO8 + +Video Playthrough Link (Routes 40-43, Cianwood City Gym, Olivine City Gym, Lake +of Rage): https://www.youtube.com/watch?v=HQoKpSr3VaE + +Heal, then head west onto Route 38. Arnie calls again. I'm pretty sure these +trainers keep calling randomly because they're extroverts and they're lonely. + +Thard_Verad adds, "So far as I can tell, it's roughly every 20-30 minutes, but +the trainer who calls and what they call about is random. Sometimes it's just to +chat, others want a rematch, a few might tell you about an outbreak of a +particular Pokemon. (Personally I'm waiting for news of a Remoraid outbreak)." + +Thard_Verad also notes that trainers will only call about gifts in the Pokémon +Crystal Version. + +Well, go through the building into Route 38. + +|--------| +|Route 38| +|--------| + +Pokémon found in Route 38: + +#019 Rattata +#020 Raticate +#052 Meowth (Silver only) +#081 Magnemite +#082 Farfetch'd +#128 Tauros +#241 Miltank + +Items found in Route 38: + +Berry + +You're heading west. There are trainers, like on almost every route. I know, +that part is repetitive. Beep beep boop. + +Bird Keeper Toby +Level 15 Doduo (307 EXP) +Level 16 Doduo (328 EXP) +Level 17 Doduo (349 EXP) +You get: 408G + +Go left and you'll get a Berry, then return to just before the checkpoint house. +Instead of going west, go northwest and you'll face a loner. + +Sailor Harry +Level 19 Wooper (211 EXP) +You get: 760G + +Head left through the grass to face another trainer. She says, "If you're really +good, I want you to teach me things." That, I can do! + +Lass Dana +Level 18 Flaaffy (450 EXP) +Level 18 Psyduck (307 EXP) +You get: 432G + +Talk to Dana again and you will be able to switch phone numbers with her. Well, +she seems popular and fairly technologically savvy. I mean, who even has a cell +phone in this region? She'll call you from time to time to tell you that her +Flaaffy is in good shape. + +Now head left to face the 4th trainer on this route. + +Beauty Valerie +Level 17 Hoppip (268 EXP) +Level 17 Skiploom (495 EXP) +You get: 1496G + +Go northwest and you'll run into: + +Schoolboy Chad +Level 19 Mr. Mime (553 EXP) +You get: 608G + +Mom calls saying she bought you an adorable doll. But is it adorable? That's for +you to decide. I just hope it's not a creepy doll that she thinks is adorable. +Now move southward now to face Beauty Olivia. Now head west onto Route 39. + +|--------| +|Route 39| +|--------| + +Pokémon found in Route 39: + +#019 Rattata +#020 Raticate +#052 Meowth (Silver only) +#081 Magnemite +#082 Farfetch'd +#128 Tauros +#241 Miltank + +Items found in Route 39: + +Mint Berry +TM13 + +Welcome to Route 39! It's the home of Moo Moo Farm. Here, moles pop out of the +road randomly and you'll fall off your bike. Oh wait, that's Mario Kart 64. +Sorry, my bad. It can be hard to keep track sometimes. Anyway, there are four +trainers ready to lose you to here on Route 39. + +Psychic Norman +Level 17 Slowpoke (360 EXP) +Level 20 Slowpoke (423 EXP) +You get: 640G + +Check out the Moo Moo Farm to the west of him. You can get Moo Moo Milk there +for 500G. It's a little expensive, but cheaper than a +Super Potion and more effective. But before you can buy milk, you will first +have to feed berries to their Miltank so that it gets better. Once you do that, +you'll get TM13! For now, grab the Mint Berry, remember this place exists, and +come back later with those berries. Head southward. + +PoKeFan Derek +Level 17 Pikachu (298 EXP) +You get: 1360G + +Continue down... + +PoKeFan Ruth +Level 17 Pikachu (298 EXP) +You get: 1360G + +Continue southward to yet another trainer... + +Sailor Eugene +Level 17 Poliwhirl (477 EXP) +Level 17 Raticate (421 EXP) +Level 19 Krabby (468 EXP) +You get: 760G + +Head southward into... + +|------------| +|Olivine City| +|------------| + +Pokémon found in Olivine City: + +#100 Voltorb + +Items found in Olivine City: + +Good Rod +HM04 Strength +TM23 + +As soon as you enter Olivine City and, when you are about to pass by the gym +your rival will appear. He will not challenge you to a match (because he knows +he's going to lose ^_^). Instead, he will tell you that Jasmine the Gym Leader +is currently at the lighthouse tending to a sick Pokémon. He thinks the gym +leader is a weakling because he has always favored strong Pokémon. + +As you zig zag your way down Olivine City, go into the house to the southeast of +the gym. You can apparently do that everywhere because no one locks their doors +in this foreign land. Talk to the fisherman and he will give you a Good Rod, +which can catch more than just Magikarps that can only splash. + +Then heal at the Pokémon Center and talk to the sailor at the table for HM04 +Strength! All that biking you did isn't going to help you move boulders. That's +mostly cardio and strong legs, man. But now you can move boulders! + +Now that you have and can use HM04 Strength, it is beneficial to equip HM04 +Strength on any Pokémon at this stage that you can, because it's an 80-power +move that's worth using. Strength is also a great move to use in the overworld +to get past boulders in dungeons. You'll need to go to a Move Deleter to remove +it later if you want to improve your post-game moves. However, HM04 Strength is +all around stellar, and it's going to be better than most moves that Pokémon +naturally learn or can learn from TMs during your game playthrough. + +After you get Strength (HM04) for free, go up the Olivine City Lighthouse in the +southeast part of town (not the dock). You'll spot it when you see it. It's +basically the only skyscraper in the city because no one can afford to build +bigger buildings in Johto. Let's go in and fight! + +|-----------------------| +|Olivine City Lighthouse| +|-----------------------| + +Items found in Olivine City Lighthouse: + +Ether +Great Ball +Rare Candy +Super Potion +TM34 Swagger + +OK, you're going to have to climb up a few floors to even meet Jasmine. You'll +need to switch back and forth between going clockwise and counterclockwise on +each floor. Wait, do people still know what analog clocks work anymore? Think +about going around in a circle in one direction and then back in the other +direction. + +Follow the path up to the second floor. You might get a call from Liz about guys +like Falkner. I don't know, people call about the weirdest things. I guess the +real reason here is not to give strangers your phone number. Keep going till you +see another trainer. + +Gentleman Alfred +Level 20 Noctowl (693 EXP) +You get: 1440G + +Keep advancing and move along the path to face Trainer 2. + +Sailor Huey +Level 18 Poliwag (297 EXP) +Level 18 Poliwhirl (504 EXP) +You get: 720G + +Go up to the next floor. You can avoid fighting the next trainer, but you should +probably pick up the EXP. + +Bird Keeper Theo +Level 17 Pidgey (199 EXP) +Level 15 Pidgey (175 EXP) +Level 19 Pidgey (223 EXP) +Level 15 Pidgey (175 EXP) +Level 15 Pidgey (175 EXP) +You get: 360G + +You'll probably want to heal Eevee up a bit now. Keep following the path to the +4th trainer battle! + +Gentleman Preston +Level 18 Growlithe (351 EXP) +Level 18 Growlithe (351 EXP) +You get: 1296G + +Head up the stairs to the 4th floor and head directly left for trainer battle 5. +She seems to be twitchy about things with Jasmine, almost like she wants to find +Jasmine and kidnap her. + +Lass Connie +Level 21 Marill (261 EXP) +You get: 504G + +If you talk to Connie again, it sounds like she actually cares about Connie. +Phew! Head south now, around on the path to the next trainer. + +Sailor Kent +8Level 18 Krabby (442 EXP) +Level 20 Krabby (492 EXP) +You get: 800G + +Todd will now call and say that Goldenrod finally has cheap sales. Woot! Head up +the stairs to fifth floor, or whatever it is. I'm not writing this guide because +I'm good at counting. I didn't promise that anywhere. After you get up, head +downward to grab TM34 Swagger, then go up to fight another trainer. + +Bird Keeper Denis +Level 18 Spearow (223 EXP) +Level 18 Spearow (223 EXP) +Level 20 Fearow (693 EXP) +You get: 432G + +Keep going and grab the Rare Candy, then go back down to Floor 4. There's a hole +next to Lass Connie. This would be a lawsuit anywhere else, but it's Pokémon and +your main character can jump down floors unscathed because he's the hero of the +story. Then, you'll find another trainer. + +Sailor Terrell +Level 20 Poliwhirl (561 EXP) +You get: 800G + +Grab the Ether right next to him, head up the stairs, and head up even more +stairs. Fight the trainer below you. + +Sailor Ernest +Level 18 Machop (339 EXP) +Level 18 Machop (339 EXP) +Level 18 Poliwhirl (504 EXP) +You get: 720G + +Go down and get the Great Ball, then climb the stairs again. We are finally on +the top level, Level 6. Talk to the person on that level, and it is Gym Leader +Jasmine. She asks you to get medicine for Ampharos for her. Afterwards, grab the +Super Potion left of Jasmine, then go to the right and jump down the holes on +the right side of every floor to drop back down to the bottom of the +lighthouses. + +|------------| +|Olivine City| +|------------| + +Heal at the Pokémon Center. Make sure you have a water Pokémon that can learn +HM03 Surf. If you don't have one, you'll have to catch one with a Good Rod on +the following route, Route 40. + +Teach HM03 Surf to any Pokémon that can learn it, which will primarily be water +type Pokémon, because it's one of the best water moves for battle and you can +use it in the overworld. You'll need to go to a Move Deleter to remove it later +if you want to improve your post-game moves. However, HM03 Surf is all around +stellar, and it's going to be better than most moves that Pokémon naturally +learn or can learn from TMs during your game playthrough. Note that Magikarp +can't learn Surf. That fish just flops all over the place. But Krabby can. How +does a trainer ride a tiny Krabby? No one really knows. + +Now exit, and enter the PoKeMart to restock on items, like Great Balls. Now head +west from the Pokémon Center onto Route 40! + +|--------| +|Route 40| +|--------| + +Pokémon found in Route 40: + +#072 Tentacool +#073 Tentacruel +#098 Krabby +#213 Shuckle +#226 Mantine (Gold only) + +Head to about where you see the swimmer in the water, then activate Surf by +clicking A at the water. It's time for the sea battles to begin! Talk to the +swimmer and let's begin a fight! + +Swimmer Simon +Level 20 Tentacool (450 EXP) +Level 20 Tentacool (450 EXP) +You get: 160G + +Swim southwest till you see a swimmer that's a girl. Face her next. + +Swimmer Elaine +Level 21 Staryu (477 EXP) +You get: 420G + +Head down till you see another girl a little southeast. Let's take another water +battle! + +Swimmer Paula +Level 19 Staryu (430 EXP) +Level 19 Shellder (394 EXP) +You get: 380G + +Head southeast again to encounter another guy there, the final swimmer on the +route. + +Swimmer Randall +Level 18 Shellder (373 EXP) +Level 20 Wartotle (612 EXP) +Level 18 Shellder (373 EXP) +You get: 144G + +Now, swim south onto Route 41. + +|--------| +|Route 41| +|--------| + +Pokémon found in Route 41: + +#072 Tentacool +#073 Tentacruel +#098 Krabby +#213 Shuckle +#226 Mantine (Gold only) + +Route 41 should give your Pokémon (like Eevee) lots of training for experience. + +If you go down the right side: + +You'll see a boy swimming around, or more like, spinning around. I feel like +spinning in the water like that should be in the Summer Olympics. He seems to be +the first trainer you've met to even have six Pokémon. + +Swimmer George +Level 16 Tentacool (360 EXP) +Level 17 Tentacool (382 EXP) +Level 16 Tentacool (360 EXP) +Level 19 Staryu (430 EXP) +Level 17 Tentacool (382 EXP) +Level 19 Remoraid (316 EXP) +You get: 152G + +Liz then calls you about her Nidoran pwning a Rattata. Yay! Continue going south +from George and you'll spot another swimmer. Either that, or she spots you. +Those are really the only two options. There isn't a third. + +Swimmer Kara +Level 20 Staryu (453 EXP) +Level 20 Starmie (886 EXP) +You get: 400G + +Keep heading south along the smaller and smaller path. + +If you go down the left side: + +You'll be going between some reefs and whirlpools. Keep going down to fight more +swimmers. + + +Swimmer Charlie +Level 21 Shellder (436 EXP) +Level 21 Tentacool (427 EXP) +Level 19 Tentacruel (834 EXP) +You get: 152G + +If you go west from here: + +Swimmer Wendy +Level 21 Horsea (373 EXP) +Level 21 Horsea (373 EXP) +You get: 420G + +If you go south/southwest instead of west: + +Swimmer Susie +Level 20 Psyduck (342 EXP) +Level 22 Goldeen (522 EXP) +You get: 440G + +Swimmer Berke +Level 23 Qwilfish (492 EXP) +You get: 184G + +Swimmer Denise +Level 22 Seel (471 EXP) +You get: 440G + +Head to the east of her. + +Swimmer Kirk +Level 20 Gyarados (916 EXP) +Level 20 Gyarados (916 EXP) +You get: 160G + +The two sides will eventually merge. You will EVENTUALLY spot a swimmer. Fight +him, the guy who talks of the Whirl Islands. The Krabby could do some damage +against lower-level Pokémon. +Swimmer Mathew +Level 23 Krabby (565 EXP) +You get: 184G + +You will reach land after that battle. Welcome to Cianwood City. + +|-------------| +|Cianwood City| +|-------------| + +Pokémon found in Cianwood City: + +#213 Shuckle (Get for free) + +Items found in Cianwood City: + +HM02 Fly +Secret Potion +TM01 + +Cianwood is pretty small, and you will not be spending much time here. Heal at +the Pokémon Center and clear a space so you can acquire another Pokémon. This +may be a good opportunity to see which of your Pokémon can learn HMs Surf and +Strength. You'll still want to have one Pokémon that can use HM01 Cut. + +To the left of the Pokémon Center is a house where a PokeManiac will give you +Level 15 Shuckie the Shuckle if you talk to him so that your thief rival won't +steal that too. You can return it later if you'd like. You're good at taking +care of Pokémon, right? I assume you are. You probably were playing with +Tomagachis before you turned this game on. Remember to feed your pets and give +your pets water! + +Thard_Verad adds, "You can keep Shuckie the Shuckle if it likes you enough when +you try to return it. Let any Shuckle hold a Berry as you run around. Eventually +it will turn into Berry Juice. (Restores 20 HP) Berry Juice is nice if you do a +Little Cup battle." + +Now head to the building below that. It's the Pharmacy. A guy inside the house, +wearing sunglasses will give you some medicine, a.k.a. Secretpotion. You will +give this back to Jasmine in Olivine for Ampharos to light back the lighthouse. +If you visit the home in the top part of town, he will offer to take pictures of +your Pokémon. You can then print them out if you have the Gameboy Printer. + +Anyways, it's now time to battle in the Cianwood City Gym and face Chuck, the +fighting gym leader. Now enter the gym. + +If you're only leveling up your starter, here's what your lineup might look +like: + +Level 40 Starter Evolution +Level 21 Sudowoodo +Level 21 Tentacool +Level 20 Eevee +Level 15 Shuckle +Level 10 Spearow "Kenya" + +Your lineup should now look like this: + +Level 27 Starter Evolution +Level 27 Sudowoodo +Level 28 Raticate +Level 29 Eevee +Level 15 Shuckle + +Let's go! Head up and you'll fight all the Blackbelts. Fighting type moves are +super effective against normal and rock types, so try not to use them where +possible against Chuck. Your evolved starter will do well. If you've been +leveling up one Pokémon, you can just repeat your strongest move and it should +sweep the gym. If you have a more balanced lineup, it may be tougher to do as +much damage. + +Blackbelt Yoshi +Level 27 Hitmonlee (804 EXP) +You get: 648G + +Before you can react, the other Blackbelt fights you. You got double teamed. + +Blackbelt Lao +Level 27 Hitmonchan (810 EXP) +You get: 648G + +Keep moving up. + +Blackbelt Nob +Level 25 Machop (471 EXP) +Level 25 Machoke (565 EXP) +You get: 600G + +Use Strength to push your way to the last trainer before the gym leader. + +Blackbelt Lung +Level 23 Mankrey (364 EXP) +Level 23 Mankey (364 EXP) +Level 25 Primeape (798 EXP) +You get: 600G + +Now that you've fought every trainer, leave the gym, heal at the Pokémon Center, +and then return to the gym. To get past the boulders, you need a Pokémon with HM +Strength. Then push the left and right boulders each up one, and move the center +boulder left or right. + +Save before the battle. If you aren't regularly saving while playing this game, +I'm not sure what to tell you. You must have a lot of confidence in your +batteries or in your abilities to win battles without resetting. Congrats on +your confidence. I'm sure that it will be worth something someday. + +--- +GYM LEADER CHUCK +Level 27 Primeape (861 EXP) +Level 30 Poliwrath (1188 EXP) +You get: Storm Badge, 3000G, & TM01 (Dynamic-Punch) +--- + +Strategy VERSUS Primeape: If you're just leveling up your starter, just use the +strongest move. Flying Pokémon could also be effective because flying moves beat +fighting. Raticate's hyper fangs can also work well. + +Strategy VERSUS Poliwrath: Use the strongest move if you're using your starter. +If you're using a more balanced team, you may need to use a variety of +high-power moves, like low kicks from Sudowoodo or hyper fangs from Raticate. A +combination of Pokémon unleashing damage on Poliwrath should be able to take it +down. It's a takedown. + +With the victory over Chuck, you have FIVE badges, another TM (Dynamic-Punch), +and more moolah. Woot. You're also within easy range for a 6th badge. Level 70 +and under Pokémon all obey you and now you can use HM02 Fly, which you'll be +getting in just a second. Dynamic Punch is the deadly punch Poliwrath gave you +earlier, but it has crappy aim and will only work half the time. + +Talk to the lady outside the gym and she will give you HM02 Fly after she +notices your fifth badge. Apparently, she's Chuck's wife and wants him to stop +being so chubby. I think that's more a matter of diet than training at this +point. Chuck is probably eating too many chips and drinking too much soda. + +Teach HM02 Fly to all your bird Pokémon (flying or otherwise) as it's a good +70-power move in a playthrough that can also get you quickly around the map. +You'll want to return to Olivine now so that you can give the medicine to +Jasmine and the Ampharos. + +============================================================================== +============================================================================== +============================================================================== + +(c15) +------------------------------- diff --git a/docs/gold-walkthrough/section-09-olivine-city-gym.txt b/docs/gold-walkthrough/section-09-olivine-city-gym.txt new file mode 100644 index 00000000..27818174 --- /dev/null +++ b/docs/gold-walkthrough/section-09-olivine-city-gym.txt @@ -0,0 +1,81 @@ +---- 15 > Olivine City Gym ---- +------------------------------- + +Video Playthrough Link (Routes 40-43, Cianwood City Gym, Olivine City Gym, Lake +of Rage): https://www.youtube.com/watch?v=HQoKpSr3VaE + +Make sure you've got a bird Pokémon using HM02 Fly. If you are leveling up an +Eevee to try to evolve it into an Espeon or Umbreon, fly to Goldenrod City. +Otherwise, fly straight back to Olivine City and skip the next paragraph. + +|--------------| +|Goldenrod City| +|--------------| + +Welcome back. Here, we'll want to go into the Goldenrod City underground, so +head to the southwestern portion of town and do so. Go in and you'll find the +three shops again. Talk to the man who is running the second/middle shop. Pay +the 500G for Eevee to get groomed. You want Eevee happy for evolution, right? =) +Eevee's happiness meter should get a nice boost. Now get out of there and fly +back to Olivine City, then enter the Olivine City Lighthouse. + +|------------------| +|Olivine Lighthouse| +|------------------| + +Climb up the Olivine Lighthouse in the EXACT same way you came up. This means +falling to the left of that one lass. Once you get all the way up, talk to +Jasmine and say Yes to the fact that Ampharos will be cured by the medicine. Now +Jasmine will return to her gym, so that's good. Now drop down out of the +Lighthouse like you did before and re-enter Olivine City. + +|------------| +|Olivine City| +|------------| + +You have two choices here. You can fight the gym, or try to gain more experience +first by flying to Ecruteak City and go through the Lake of Rage and then come +back (which is in the next parts of this guide). I'm not your mom, who keeps +buying random items with your money. You do have choices in life. If you want to +get a nice shiny Pokémon to assist you in your next fight first, take that. If +you want to see how you fare now, you can go straight into the Olivine City Gym +to take on Jasmine. + +Enter the Olivine City Gym. This is a steel-based gym with electric elements. +Your starter should do okay even if there's a type disadvantage. Sudowoodo and +Raticate will do well too, although the Gyarados that you can get at the Lake of +Rage will fare the best with Dragon Rage. + +Keep moving up in the gym. You will find there are no apprentices of any kind. +Jasmine does not take apprentices. She's too good for that. Jasmine remembers +your help and thanks you, then remembers that she's a gym leader and that she +needs to be serious. Save before the battle, then put either your starter +Pokémon, Raticate, or Gyarados in the front position when you start fighting. + +--- +GYM LEADER JASMINE +Level 30 Magnemite (571 EXP) +Level 30 Magnemite (571 EXP) +Level 35 Steelix (1470 EXP) +You get: Mineral Badge, 3,500G, & TM23 (Iron Tail) +--- + +Strategy VERSUS Magnemites: Use the strongest starter move, fire, or fighting. +Beware of thunder waves that can paralyze your Pokémon. Magnemite actually does +pretty decent damage with Sonicboom. + +Strategy VERSUS Steelix: Use the strongest starter move, fire, or fighting. +Beware of thunder waves that can paralyze your Pokémon. Iron Tail can do a +decent amount of damage. + +Woot! That's your sixth badge! This gym leader also thinks you're better now +(some gym leaders are arrogant), and with the Mineral Badge your Pokémon's +defense is raised slightly. You also get TM23, Iron Tail, the attack Steelix +used. + +============================================================================== +============================================================================== +============================================================================== + +(c16) +-------------------------------------------------- diff --git a/docs/gold-walkthrough/section-10-the-lake-of-rage-and-shiny-gyarados.txt b/docs/gold-walkthrough/section-10-the-lake-of-rage-and-shiny-gyarados.txt new file mode 100644 index 00000000..238da328 --- /dev/null +++ b/docs/gold-walkthrough/section-10-the-lake-of-rage-and-shiny-gyarados.txt @@ -0,0 +1,223 @@ +---- 16 > The Lake of Rage and Shiny Gyarados ---- +-------------------------------------------------- + +Video Playthrough Link (Routes 40-43, Cianwood City Gym, Olivine City Gym, Lake +of Rage): https://www.youtube.com/watch?v=HQoKpSr3VaE + +Video Playthrough Link (Mahogany Town & Lake of Rage): +https://www.youtube.com/watch?v=z5_tkLJ0fvw + +|-------------| +|Ecruteak City| +|-------------| + +Fly to Ecruteak City, then head east onto Route 42. Bring Pokémon that can use +HMs Surf and Cut. + +|--------| +|Route 42| +|--------| + +Pokémon found in Route 42: + +#021 Spearow +#041 Zubat +#056 Mankey (Gold only) +#179 Mareep +#180 Flaaffy + +Items found in Route 42: + +Grn Apricorn +Pnk Apricorn +Super Potion +Ultra Ball +Ylw Apricorn + +Grab the Ultra Ball you see right after you enter the route. Head a little east. +I suggest waiting to go into Mt. Mortar until you've gotten HM07 Waterfall so +you can have the full Mt. Mortar experience. It's like going for fast food +rather than your luxury dining experience. I'd suggest taking the luxury dining +experience here. + +Instead, head east and surf across that lake now, on a Tentacool, Wooper, +Krabby, or whatever. I'm not exactly sure how you fit on any of these Pokémon, +given that you are larger than each Pokémon. But you fit like it's a small boat +custom built for your comfort. Then, use Cut below and grab the three Apricorns +(Green, Pink, and Yellow). Keep going and then grab the Super Potion. + +|--------| +|Route 42| +|--------| + +Surf across the second lake and talk to the fisherman for a battle. + +Fisher Chris +Level 18 Qwilfish (385 EXP) +You get: 720G + +You'll see two more trainers. Fight them both. + +PoKeManiac Shane +Level 16 Nidorina (400 EXP) +Level 16 Nidorino (403 EXP) +You get: 960G + +I'm not sure why the PoKeManiac needed to be so xenophobic, but he was easy to +beat. Now fight the final trainer on the route. + +Hiker Benjamin +Level 14 Diglett (243 EXP) +Level 14 Geodude (258 EXP) +Level 16 Dugtrio (523 EXP) +You get: 512G + +Now ride east into Mahogany Town. + +|-------------| +|Mahogany Town| +|-------------| + +Items found in Mahogany Town: + +TM16 + +Heal at the Pokémon Center, then explore Mahogany Town. It's small in size, but +big in swindling. The guy in the house in the north is trying to sell you +Slowpoketails for 9800G. Suspicious. Then if you try to go east, the guy wants +you to head up to the Lake of Rage and tries to sell you Ragecandybars. If you +buy one, that's cool, but he still won't let you pass. So rude. + +Well, I guess it's time to head north onto Route 43. Make sure you have at least +15 Great Balls before you head up, because there are a lot of catching +opportunities on Route 43 and at the Lake of Rage. + +|---------------------------| +|Route 43 & The Lake of Rage| +|---------------------------| + +Pokémon found in Route 43: + +#017 Pidgeotto +#048 Venonat +#164 Noctowl +#179 Mareep +#180 Flaaffy +#230 Girafarig + +Items found in Route 43: + +Bitter Berry +Max Ether + +Head up the path to fight some five trainers. Let's commence. + +Camper Spencer +Level 17 Sandshrew (337 EXP) +Level 18 Sandslash (592 EXP) +Level 19 Zubat (219 EXP) +You get: 380G + +As you go through Route 43 you will see a building blocking the right side of +the road. Don't go through it. Team Rocket will charge you 1,000G to go through. +You must be frugal! You've gotten rich taking money from random trainers on +shady routes, but you won't stay rich if you give Team Rocket your hard earned +money. + +Instead, you should go the long way up to the Lake of Rage. As you near the end +of Route 43 you will find two routes to the Lake of Rage. The left path will +bring you two some items (be sure to have a Pokémon with HM01 Cut), and you will +also find Wesley on Wednesday. He will give you the Blackbelt item, which powers +up Fighting attacks. The right path will lead to the Lake of Rage. Head up for +more battling. Remember to grab the Max Ether, too, which is to the right of +Picnicker Tiffany. + +Picnicker Tiffany +Level 20 Clefairy (291 EXP) +You get: 400G + +Keep moving up. + +PoKeManiac Brent +Level 19 Lickitung (516 EXP) +You get: 1140G + +You're trying to save civilization from a dangerous threat on the Lake of Rage, +but naturally, this is the time your Mom calls you. Mom's bought another item +that you apparently might like. Keep moving up the path. Two more trainers to +go. Haha, the next guy mentions your rival making fun of his Pokémon. He's not +just a criminal, he's also mean! Shocking. + +PoKeManiac Ron +Level 19 Nidoking (793 EXP) +You get: 1140G + +Keep going north! + +PoKeManiac Ben +Level 19 Slowbro (667 EXP) +You get: 1140G + +Bike down the left side past a few of the first trainers, then go as far left as +you can. You'll come across another tr`ainer: + +Fisher Marvin +Lv 10 Magikarp (42 EXP) +Lv 10 Gyarados (457 EXP) +Lv 15 Magikarp (63 EXP) +Lv 15 Gyarados (? EXP) +You get: 600G + +Now, ride your bike up the west side through the forest. You can use HM01 Cut on +various trees to get Max Ether and TM43 (far northeast). If you go to the far +northwest through the forest, you'll find a house in the middle of nowhere. The +person there will give you TM10 Hidden Power. Now, head back the way that you +came, then north to the Lake of Rage. + +|------------| +|Lake of Rage| +|------------| + +Items found at the Lake of Rage: + +Blackbelt +Max Ether +Red Scale +TM10 Hidden Power +TM43 Secret Power + +If you talk to the people around, they will tell you that something strange has +been going on. If you surf into the Lake of Rage you will find a Gyarados, but +this is no ordinary Gyarados. This is a Level 30 red Gyarados. It is special +because it is an off-brand color "shiny" Pokémon. Color variations of any +Pokémon are typically rare, and therefore cool and desirable. If only people +felt the same way about other people and things that look different in our +society. + +You should save before the battle and make sure you catch it without knocking it +out. If you are using your best Pokémon, you might not want to use its best +moves. Use Great Balls to try to catch it. Once you've caught it, you've now +become a shiny Pokémon trainer. Congrats on this elusive accomplishment. + +After you capture the Red Gyarados you will receive the Red Scale. If you bring +that item to Mr. Pokémon he will give you the EXP Share item. When you reach the +Lake of Rage, talk to someone with a cape on him. He's looking at a sign. When +someone is looking at a sign, it is a sign you should interrupt his train of +thought and talk to him. + +It turns out that he is Lance, the dragon trainer from Blue/Red/Yellow. He's the +fourth Elite Four member, remember? What do you mean you haven't played the +original games? Go and play one of them after you've beaten this game! Anyway, +talk to him, and agree to help him investigate the matter. I have no idea what +this entails but people like when you say Yes, and he did say that you were a +trainer of "considerable skill." You should be quite proud. + +It seems like there's now a problem in Mahogany Town. You can fly back to +Mahogany Town. + +============================================================================== +============================================================================== +============================================================================== + +(c17) +-------------------------------------------------------- diff --git a/docs/gold-walkthrough/section-11-team-rocket-hideout-and-mahogany-town-gym.txt b/docs/gold-walkthrough/section-11-team-rocket-hideout-and-mahogany-town-gym.txt new file mode 100644 index 00000000..d464b3c3 --- /dev/null +++ b/docs/gold-walkthrough/section-11-team-rocket-hideout-and-mahogany-town-gym.txt @@ -0,0 +1,334 @@ +---- 17 > Team Rocket Hideout and Mahogany Town Gym ---- +-------------------------------------------------------- + +Video Playthrough Link (Mahogany Town & Lake of Rage): +https://www.youtube.com/watch?v=z5_tkLJ0fvw + +Video Playthrough Link (Mahogany Town Gym): +https://www.youtube.com/watch?v=CoPpbons5bY + +|-------------| +|Mahogany Town| +|-------------| + +Heal at the Pokémon Center and withdraw your Gyarados so that it's in your +lineup. If you head to the middle of the town where a guy is selling things, +you'll find Lance inside basically forcing his way through that house. He'll +show you the secret stairway to Team Rocket's Hideout. + +If you're just leveling up your Starter, your lineup should look something like +this: + +Level 44 Starter Evolution +Level 30 Gyarados +Level 21 Sudowoodo +Level 20 Eevee +Level 21 Tentacool +Level 10 Kenya the Spearow + +If a more balanced lineup, it should now look something like this: + +Level 27 Starter Evolution +Level 28 Sudowoodo +Level 30 Eevee +Level 30 Gyarados +Level 31 Raticate +Level 5 Pidgey + +|-------------------| +|Team Rocket Hideout| +|-------------------| + +Items in the Team Rocket Hideout: + +Ice Heal +Hyper Potion +Nugget +Protein +X Accuracy +TM46 + +You and Lance must now stop Team Rocket's operations, but you're still left on +your own. Head downstairs and left after you do so. But you have been spotted. +You intruder! + +Rocket Grunt (M) +Level 17 Drowzee (368 EXP) +Level 19 Zubat (219 EXP) +You get: 760G + +A second Rocket Grunt runs out to greet you. Hooray. You see, every time you +pass one of those statues, two Team Rocket grunts will fight you. + +Rocket Grunt (M) +Level 16 Zubat (184 EXP) +Level 17 Grimer (327 EXP) +Level 18 Rattata (219 EXP) +You get: 720G + +See? You really should read the dialogue. I'm not saying this as a hypocrite who +doesn't read some of the dialogue himself. Anyway, you'll spot another statute +while heading left. If you pass by that statue, you'll fight the same two Grunts +again, with the Drowzee and Zubat, and the Zubat, Grimer, and Rattata. If you go +down further, you'll fight a Rocket Grunt with many Rattatas. You could say that +he's a Rattata connoisseur. + +Rocket Grunt (M) +Level 16 Rattata (195 EXP) +Level 16 Rattata (195 EXP) +Level 16 Rattata (195 EXP) +Level 16 Rattata (195 EXP) +You get: 640G + +Does Team Rocket have an undercover Zubat, Grimer, and Rattata breeding +operation? I mean, probably. No one wants to admit that publicly, because that's +embarrassing. Anyway, if you head back past that statute and go down, you'll see +a pattern of zig-zags that avoids the statutes and leads to a scientist with +glasses. We know he's smart because he's wearing glasses. Let's fight him and +get him to give us some answer. + +Scientist Jed +Level 20 Magnemite (381 EXP) +Level 20 Magnemite (381 EXP) +Level 20 Magnemite (381 EXP) +You get: 2000G + +Talk to the scientist again. He'll tell you about Warp Panels that can take you +back to the entrance. I told you he was smart. Then go to the computer next to +him and press A to turn off the switch for the statues. Good thing the scientist +isn't a security expert. Now, no more Team Rocket Grunts will swarm you every +time you reach a statue. + +You can now roam around the floor like you own the place. Pick up the X +Accuracy, Hyper Potion, and Nugget. Then make your way around to the +southwest part of the room and take the staircase down to the next floor +(Negative Floor 2). Lance, who is not only a Pokémon champion but also a healer, +will meet with you and heal all your Pokémon. What a nice, kind-hearted guy. +Then head to the right and beat both Rocket Grunts. + +Rocket Grunt (M) +Level 18 Venonat (288 EXP) +Level 18 Venonat (288 EXP) +You get: 720G + +Now head to the right. + +Rocket Grunt (M) +Level 18 Golbat (658 EXP) +You get: 720G + +Head downstairs to Negative Floor 3. Lance says we need two passwords to unlock +the doors on this level. Unfortunately, one of the passwords is not 123456. But +if you beat the right Rocket Grunts, they'll spill the beans. Fortunately for +you, the hero, they are both on this level. Go up and left and you can pick up a +Protein and an Ice Heal. Then loop all the way back down and to the right, go +past the trainer that Lance beat up for more intel, and go up to fight the +scientist. + +Scientist Ross +Level 22 Koffing (537 EXP) +Level 22 Koffing (537 EXP) +You get: 2200G + +Head up to fight the Team Rocket Grunt above that. She says she MIGHT have the +password. She does! You've got to give that to the Team Rocket Grunts. They +aren't liars. They are, in fact, people of integrity. + +Rocket Grunt (F) +Level 18 Ekans (238 EXP) +Level 18 Gloom (508 EXP) +You get: 720G + +Now that you've beat her, you have the password after you talk to her. It's +SLOWPOKETAIL. I know, it's shocking. Then head left for your next battle. + +Scientist Mitch +Level 24 Ditto (313 EXP) +You get: 2400G + +Head left to the final trainer to the left of the table. Fight him. + +Rocket Grunt (M) +Level 19 Raticate (471 EXP) +You get: 760G + +Talk to him again now that you've beat him, and he'll give you his password +(RATICATE TAIL). Get the items (Dire Hit and Full Heal), go up the stairs at the +upperright part of the floor, then head left on Negative Floor 2. Fight the Team +Rocket member that spots you...or that you reach. + +Rocket Grunt (M) +Level 17 Rattata (207 EXP) +Level 17 Rattata (207 EXP) +Level 17 Zubat (196 EXP) +You get: 680G + +Head up the stairs to Negative Floor 1. Head right and then before you reach the +locked door, your rival will come and talk to you. He can't battle you though, +cause Lance just destroyed him. "He said I don't love and trust my Pokémon +enough." Press A at the door to open it and go into Giovanni's office. No idea +where Giovanni is, but there's a Rocket Executive that you can square off with. + +Rocket Executive (M) +Level 22 Zubat (253 EXP) +Level 24 Raticate (595 EXP) +Level 22 Koffing (537 EXP) +You get: 1584G + +He quickly runs off after he loses. Talk to the Murkrow next to the Rocket +Executive and it'll give you the final password, HAIL GIOVANNI. Now head to the +second stairs that you did NOT come out of, and grab TM46 from there, then go +back down to the same floor as where you just beat the executive. + +Head back the way you came, past the spinning Team Rocket member, and go back +upstairs. Go back down to the floor with the gate blocking you from entering the +area with machines inside. We're going ALL the way back to where you met Lance +for the second time within the Team Rocket hideout. It's the second floor where +Lance healed your Pokémon. + +You'll find a passworded door right there. Open it, and you'll quickly get +surrounded by two Team Rocket executives. Now you'd have to take both +executives, but Lance comes just in time. This time, you're fighting the female +Team Rocket boss while Giovanni's apprentice takes on Lance. Spreading the fun? +The battle isn't too difficult though, so just defeat her with any combo. + +Rocket Executive (F) +Level 23 Arbok (724 EXP) +Level 23 Gloom (649 EXP) +Level 25 Murkrow (573 EXP) +You get: 1800G + +She later says that you would be powerful enough to be a Rocket Executive. Well, +not quite. You're strong enough to run all of Team Rocket. But you are a kind +soul. You're here to do good for the world. After the Team Rocket Executives +lose, they run away like the losers that they are. Lance will then talk to you +and he'll want you to help him destroy the Electrodes supplying the base with +electricity. + +Lance runs to the right to take the Electrodes on the right, so you'll take the +left. The Electrodes will typically try to Self Destruct, so move your Sudowoodo +into the first spot temporarily as Sudowoodo can absorb a lot of damage. If +you're lucky enough to have any Electrode not use Self Destruct, you can try to +catch it with a Great Ball. + +Level 23 Electrode (492 EXP) + +Level 23 Electrode (492 EXP) + +Level 23 Electrode (492 EXP) + +The Lake is saved, and Lance thinks you're the hero. Why thank you, Lance. You +are the hero that the world deserves! Lance gives you HM06 Waterpool, an item he +just found lying around in the hideout. Excellent. You'll need the 7th badge +from the Mahogany City Gym to use it, though. Follow Lance's way out to the +stairs, then use the warp next to it to head out of the hideout. You know your +way out now. + +|-------------| +|Mahogany Town| +|-------------| + +Heal at the Pokémon Center, then enter the Mahogany Town Gym. I hope all those +trips to the ice rink as a kid pay off, because you'll be sliding all over the +place. This gym will actually have lower-level Pokémon than Jasmine. Fire and +grass moves will be effective. It's an ice gym! + +Skate to the left to fight the very first trainer. + +Boarder Ronald +Level 24 Seel (513 EXP) +Level 24 Seel (513 EXP) +Level 25 Dewgong (942 EXP) +You get: 1728G + +Slide up twice, then right one, then down one, then left one to fight another +trainer. + +Boarder Douglas +Level 24 Shellder (498 EXP) +Level 25 Cloyster (1087 EXP) +Level 24 Shellder (498 EXP) +You get: 1728G + +Talk to the trainer again to get Gym Leader Pyrce's secret. He meditates under a +waterfall every day. When you're listening to music as you meditate, it's +actually a recording from Pryce's special waterfall. Now slide down, then to the +right. + +Boarder Brad +Level 26 Swinub (433 EXP) +Level 26 Swinub (433 EXP) +You get: 1872G + +Slide once to the right, then down to face another trainer. + +Skier Clarissa +Level 28 Dewgong (1056 EXP) +You get: 2016G + +Head up twice, then slide left to the final trainer. + +Skier Roxanne +Level 28 Jynx (822 EXP) +You get: 2016G + +Slide down and out of the gym to heal at the Pokémon Center before you return to +fight the gym leader. Go to just left of the left pillar then go up, up, right, +down, left, up and finally right. You will be standing in front of the gym +leader in your ice skates. Your time falling down at the ice rink as a kid did +pay off after all! + +Good luck against Pryce. This will be a piece of cake after fighting Jasmine. He +thinks he's amazing because he has been fighting since before you were born. But +you're an underage kid who's been biking around the world on a rental bike. How +could you be stopped? + +If you are focusing on leveling up your starter, your lineup should be around: + +Level 47 Starter Evolution +Level 30 Gyarados +Level 22 Sudowoodo +Level 21 Tentacool +Level 20 Eevee +Level 10 Kenya the Spearow + +If you have a balanced lineup, your lineup should be around: + +Level 31 Raticate +Level 31 Gyarados +Level 31 Bayleef or Quilava, or Croconaw +Level 31 Eevee +Level 31 Sudowoodo +Level 5 Pidgey + +--- +GYM LEADER PRYCE +Level 27 Seel (577 EXP) +Level 29 Dewgong (1093 EXP) +Level 31 Piloswine (1062 EXP) +You get: Glacier Badge, 3,100G, & TM16 (Icy Wind) +--- + +Strategy VERSUS Seel: Use the highest-powered attack from your highest level +Pokémon that is not a fire or water type. Grass moves are ideal. Finally, a gym +that somewhat favors anyone who started with Chikorita! + +Strategy VERSUS Dewgong: Use the highest-powered attack from your highest level +Pokémon that is not a fire or water type. Grass moves are still ideal. + +Strategy VERSUS Piloswine: Pilowsine is a bit tougher. It is higher level and +more defensive. But it is also slow. Use the highest-powered attack from your +highest level Pokémon that is not a fire or water type. Grass moves are still +ideal. + +You're worthy of the badge, and you get the SEVENTH badge. W00t! This raises +your Pokémon's special stats, you get more mooooney, and you have TM16 Icy Wind. +You can now use Whirlpool, once you find that in a dark cavern. Skate your way +to the bottom of the gym, then exit. One more badge to go! + +============================================================================== +============================================================================== +============================================================================== + +(c18) +-------------------------------------- diff --git a/docs/gold-walkthrough/section-12-team-rocket-radio-tower.txt b/docs/gold-walkthrough/section-12-team-rocket-radio-tower.txt new file mode 100644 index 00000000..131255a2 --- /dev/null +++ b/docs/gold-walkthrough/section-12-team-rocket-radio-tower.txt @@ -0,0 +1,324 @@ +---- 18 > Team Rocket Radio Tower ---- +-------------------------------------- + +Video Playthrough Link (Team Rocket in Goldenrod City, & Route 44): +https://www.youtube.com/watch?v=UBCRvlP4Vqw + +You will now get a phone call by Elm. This is informing you that Goldenrod City +has been overrun by Team Rocket! Wow! How could Team Rocket invade two cities at +once? It's truly a spectacular accomplishment for the villains of our story. +Heal at the Pokémon Center, then fly to the city on whatever bird Pokémon you've +decided can carry the weight of yourself and your bike. + +|--------------| +|Goldenrod City| +|--------------| + +You will now see that Team Rocket has pretty much overrun the whole city. You're +now 10 years old, so you're totally capable of retaking the city. Go inside the +Radio Tower. This is where all of the Team Rocket trouble is going on. Head +inside and towards the stairs to fight your first Rocket Grunt foe. + +Rocket Grunt (M) +Level 24 Raticate (595 EXP) +Level 24 Raticate (595 EXP) +You get: 960G + +Easy. Keep heading up the stairs to the second floor and head left. + +Rocket Grunt (F) +Level 26 Arbok (819 EXP) +You get: 1040G + +Talk to the male Rocket Grunt next to her for the third battle. + +Rocket Grunt (M) +Level 21 Rattata (256 EXP) +Level 21 Rattata (256 EXP) +Level 23 Rattata (280 EXP) +Level 23 Rattata (280 EXP) +Level 23 Rattata (280 EXP) +You get: 920G + +Keep moving left. You'll meet yet another Rocket Grunt that's supposed to be +guarding the place. But I don't see him moving his hands around. If he can't do +that, he definitely can't stop Yao Ming from dunking the ball. + +Rocket Grunt (M) +Level 26 Zubat (300 EXP) +Level 26 Zubat (300 EXP) +You get: 1040G + +He claims Team Rocket's comeback is nearing. Keep heading left. + +Rocket Grunt (M) +Level 23 Grimer (442 EXP) +Level 23 Grimer (442 EXP) +Level 25 Muk (840 EXP) +You get: 1000G + +Head up to the third floor. There are four trainers left to fight. Keep moving. +You like to move it move it. + +Rocket Grunt (M) +Level 26 Weezing (963 EXP) +You get: 1040G + +Head up towards the fourth floor. + +Rocket Grunt (M) +Level 23 Koffing (561 EXP) +Level 23 Grimer (442 EXP) +Level 23 Zubat (265 EXP) +Level 23 Rattata (280 EXP) +You get: 920G + +If you talk to him again, he notes that he failed his duties and will be docked +pay for this. Take that! Keep going to the right and you'll encounter a +scientist named Marc. + +Scientist Marc +Level 27 Magnemite (514 EXP) +Level 27 Magnemite (514 EXP) +Level 27 Magnemite (514 EXP) +You get: 2700G + +Two more trainers. Go up to the fourth floor. Keep going to another Rocket +Grunt. + +Rocket Grunt (M) +Level 22 Zubat (253 EXP) +Level 24 Golbat (879 EXP) +Level 22 Grimer (423 EXP) +You get: 880G + +Now go up to the scientist to fight him. + +Scientist Rich +Level 30 Porygon (835 EXP) +You get: 3000G + +Now, head up to the fifth floor. The battle against the mini-boss will begin +now. + +Rocket Executive (M) +Level 30 Koffing (732 EXP) +Level 30 Koffing (732 EXP) +Level 30 Koffing (732 EXP) +Level 32 Weezing (1185 EXP) +Level 30 Koffing (732 EXP) +Level 30 Koffing (732 EXP) +You get: 2160G + +You beat Team Rocket here, but it's not over yet. The Rocket Executive gives you +the Basement Key. The director is now in the Underground Warehouse now. He's +been kidnapped or something. Leave the Radio Tower and then heal at the Pokémon +Center. Then head to the northwestern part of town and enter the +northwesternmost building. This is the other Underground entrance. + +Head down the stairs. While you're in the Underground, you can stop by the +Pokémon Salon to give Eevee a Happiness boost. Now, head down the hallway that +had the locked door you saw WAY back. Enter it now that you have the Basement +Key, then head down the stairs. Save now. + +Try going left and your rival will meet you in another battle. He thinks he can +take care of Team Rocket alone and tries to get rid of you. + +|------------------------------------------| +|Rival | +|Level 30 Golbat (1092 EXP) | +|Level 32 Starter Evolution (1425-1440 EXP)| +|Level 28 Magnemite (534 EXP) | +|Level 30 Haunter (810 EXP) | +|Level 32 Sneasel (904 EXP) | +|You get: 1920G | +|------------------------------------------| + +If you're just leveling up your starter, any high-powered attack should do, +other than not using your same-type attack on the starter evolution and instead +picking a normal type move. + +If you're using a balanced team, Sudowoodo's rock slides would work well against +Golbat and Magnemite. Gyarados would do well against the fire or water starters. +Use your starter's same-type attacks against Haunter. Against Sneasel, you can +use the strongest power moves or consider Sudowoodo's low kicks. + +Your rival has no clue why he lost. He's not the sharpest tool in the shed. He +thinks of Lance's earlier comments and doubts himself. Yay! He still has his +dream that will never succeed. Now, go back and heal at the Pokémon Center, then +take on the Rocket Grunts. Believe me, there are so many Rocket Grunts that +you'll want to. I hope that by this point, you've learned to trust me. I am a +trustworthy person. After you heal, return and take on the first Rocket Grunt. + +Rocket Grunt (M) +Level 27 Rattata (328 EXP) +You get: 1080G + +Move on left. + +Rocket Grunt (M) +Level 23 Muk (772 EXP) +Level 23 Koffing (560 EXP) +Level 25 Rattata (304 EXP) +You get: 1000G + +Move on left... + +Rocket Grunt (M) +Level 24 Koffing (585 EXP) +Level 24 Muk (807 EXP) +You get: 960G + +Now, see the three switches next to each Rocket member you defeated? Go back to +the 1st trainer, and switch the one next to him, then the one next to the 2nd +trainer, then the one to the 3rd trainer. So, you should have turned on the +switches in the same order you beat the trainers. You can now go down from the +third trainer to fight an unexpected foe. + +Burglar Eddie +Level 26 Growlithe (500 EXP) +Level 24 Koffing (585 EXP) +You get: 2112G + +Now turn off switches 3 and 2 (the last two you did), and then snag the item you +see. It is a Full Heal. Exit to the stairs then come back up so the switches are +reset, and turn them on in the opposite direction (3, 2, then 1) from the left +to the right. This will FINALLY clear a route. Head down along the route and +you'll fight another burglar. You might ask, how did the burglars trap +themselves in the middle of a Team Rocket hideout? I don't know, I'm not a mind +reader! I can't read burglars' minds! + +Thard_Verad observed, "How did the burglars get trapped in the Radio Tower +basement? Maybe they were sneaking around when a Grunt flipped the switches? :P" + +Burglar Duncan +Level 23 Koffing (561 EXP) +Level 23 Koffing (561 EXP) +Level 25 Magmar (894 EXP) +You get: 2024G + +Head right to fight the other Rocket Grunt. + +Rocket Grunt (F) +Level 25 Gloom (706 EXP) +Level 25 Gloom (706 EXP) +You get: 1000G + +OK, now go through the door, then keep going on the beaten path and eventually a +Rocket Grunt will spot you. + +Rocket Grunt (M) +Level 24 Raticate (594 EXP) +Level 24 Golbat (879 EXP) +You get: 960G + +Grab the Max Ether to the right and continue on the path up. Next battle! + +Rocket Grunt (M) +Level 26 Grimer (501 EXP) +Level 23 Weezing (852 EXP) +You get: 920G + +Now, head up the stairs to grab the Amulet Coin, which after equipped will be +able to DOUBLE the amount of gold you get from each battle on a Pokémon. You'll +finally be a rich 10-year old! Head back down the stairs and then follow the +path until you reach the boss of this freak place. + +Rocket Grunt (M) +Level 25 Koffing (610 EXP) +Level 25 Koffing (610 EXP) +You get: 1000G + +Talk to him again and he'll tell you they're basically holding on until the +return of Giovanni. Carry on and take TM35, which is next to the Director. Talk +to the Director who is now freed thanks to you. You receive a Card Key now. + +Remember the part of the Radio Tower that hasn't been liberated by you yet? Now +it's time to retake that area. So your job is to save the Pokémon worldwide. +Exit by the way you came. When you get blocked, use the switch. Heal at the +Pokémon Center. Access the PC in here and deposit a few items from your first +pocket of your pack, cause it's obvious you'll be getting even more items. +Deposit EVERYTHING you won't be using IN any battles. + +Then, return to the Radio Tower. Head up to Floor 2, then Floor 3. Head to the +very right part of the room where you'll spot a shutter thing. Go to the switch +and put in the key. Head right to face the first Rocket. + +Rocket Grunt (M) +Level 24 Raticate (595 EXP) +Level 26 Koffing (634 EXP) +You get: 1040G + +Go up the stairs to Floor 4, then fight the next Grunt. + +Rocket Grunt (F) +Level 21 Ekans (279 EXP) +Level 21 Ekans (279 EXP) +Level 24 Gloom (678 EXP) +Level 23 Oddish (384 EXP) +You get: 960G + +Attempt to go up to the fifth floor and you'll have your third battle. No one +apparently has a name in Team Rocket. They're all anonymous. They must remove +people's names at the time of initiation. Sad times. + +Rocket Executive (M) +Level 36 Golbat (1318 EXP) +You get: 2592G + +And so goes the Team Rocket fortress. Whee! Advance on to Floor 5 and fight the +same boss you fought before. It's amazing how easy Team Rocket is to beat, +right? Aww, they're such softies inside. ^_^. Here, you'll want to use primarily +the strongest attacks from your starter Pokémon, unless it's grass, in which +case you should use normal. + +Rocket Executive (F) +Level 32 Arbok (1008 EXP) +Level 32 Murkrow (733 EXP) +Level 32 Vileplume (1261 EXP) +You get: 2304G + +She still wishes you were part of Team Rocket. Why wouldn't you use your +strength to do bad things instead? But we all want things we can't have. + +Go up to the other Executive. You may recall that Lance beat him last time. He +wants to call back Giovanni by taking over the Radio Tower. You'd think that +Team Rocket leaders could afford cell phones, but they're not the tech savvy +times. You can stop him with water, ground, rock, or fighting moves. Sudowoodo's +rock slides can be super effective. + +Rocket Executive (M) +Level 33 Houndour (805 EXP) +Level 33 Koffing (805 EXP) +Level 35 Houndoom (1530 EXP) +You get: 2520G, Demise of Team Rocket + +Like Giovanni, the Rocket Executive disbands Team Rocket. Well, good job! There +goes the menace of Team Rocket. The director will come up to greet and thank +you. He gives you a Clear Bell. Now you can exit the Radio Tower. + +If you're just leveling up your Starter, your lineup should look something like +this: + +Level 53 Starter Evolution +Level 30 Gyarados +Level 21 Sudowoodo +Level 20 Eevee +Level 21 Tentacool +Level 10 Kenya the Spearow + +If a more balanced lineup, it should now look something like this: + +Level 35 Starter Evolution +Level 34 Sudowoodo +Level 33 Gyarados +Level 33 Raticate +Level 32 Eevee +Level 9 Pidgey + +============================================================================== +============================================================================== +============================================================================== + +(c19) +----------------------------------------------- diff --git a/docs/gold-walkthrough/section-13-ice-path-and-blackthorn-city-gym.txt b/docs/gold-walkthrough/section-13-ice-path-and-blackthorn-city-gym.txt new file mode 100644 index 00000000..4d602247 --- /dev/null +++ b/docs/gold-walkthrough/section-13-ice-path-and-blackthorn-city-gym.txt @@ -0,0 +1,317 @@ +---- 19 > Ice Path and Blackthorn City Gym ---- +----------------------------------------------- + +Video Playthrough Link (Team Rocket in Goldenrod City, & Route 44): +https://www.youtube.com/watch?v=UBCRvlP4Vqw + +Video Playthrough Link (Ice Path): https://www.youtube.com/watch?v=evrwXY9s1uo + +Video Playthrough Link (Blackthorn City Gym & Dark Cave): +https://www.youtube.com/watch?v=pWBRqXMdut8 + +|--------| +|Route 44| +|--------| + +Pokémon on Route 44: + +Poliwag (water) + +Items on Route 44: + +Burnt Berry +Ultra Ball + +Take the elevator and stairs back down, leave the building, fly back to Mahogany +Town, heal at the Pokemon Center, and head east onto Route 44. + +Psychic Phil +Level 24 Natu (565 EXP) +Level 24 Kadabra (807 EXP) +You get: 832G + +Head up to grab the Burnt Berry from the tree, then the item east of that +trainer (Max Repel). Continue heading east and talk to the Fisherman. Also, you +get another call from your Mom. Should you really have trusted my advice at the +beginning of this walkthrough to give your mom access to your bank funds? No +comment. + +Fisher Edgar +Level 25 Remoraid (417 EXP) +Level 25 Remoraid (417 EXP) +You get: 1000G + +Keep heading east... + +Cooltrainer Cybil +Level 25 Butterfree (854 EXP) +Level 25 Bellossom (985 EXP) +You get: 1200G + +Keep heading east along this strange route after you beat her... + +Cooltrainer Allen +Level 27 Chameleon (820 EXP) +You get: 1296G + +Wow, that's two straight trainers that give you respect. You got game. Go pat +yourself on the back. Continue along the path, and then when you can go up, grab +the Ultra Ball. Then head along the northern route around the lake and you'll +find more trainers. + +PoKeManiac Zach +Level 27 Rhyhorn (780 EXP) +You get: 1620G + +Then talk to the fisherman to the west. He screams at you for losing a Poliwag +that he was trying to catch. Kind of sounds like it was his fault for not paying +attention, am I right? + +Fisher Wilton +Level 23 Goldeen (546 EXP) +Level 23 Goldeen (546 EXP) +Level 25 Seaking (910 EXP) +You get: 1000G + +Now head back east... + +Bird Keeper Vance +Level 25 Pidgeotto (604 EXP) +Level 25 Pidgeotto (604 EXP) +You get: 600G + +Now, you'll be heading into the Ice Path. Good luck with the puzzles! You got +this! By this, I mean a puzzle game in the middle of a role-playing game. Fly +back to Mahogany Town to heal at the Pokémon Center. Make sure you have a +Pokémon with HM Strength, because your 10-year old character isn't buff enough +to move boulders physically or with his mind. Then ride all the way east back +down to the route to a cave called the Ice Path. + +|--------| +|Ice Path| +|--------| + +Pokémon found in the Ice Path: + +#042 Golbat +#124 Jynx +#220 Swinub +#225 Delibird (Silver only) + +Items found in the Ice Path: + +Full Heal +Iron +Nevermeltice +PP Up +HM07 Waterfall +TM44 Rest + +The Ice Path has several puzzles. Follow the ice path. The first one's simple: +right, down, right, down. Then, go up the stairs, follow the route, go down the +stairs, follow the route, and you'll hit your first ice maze. Go up, left, up, +right, up, right, down, left, up, left, down, right, down, right, up, and then +right. Also, get ambushed by Golbats and Swinubs. Aren't those Swinubs so cute? +Maybe it's not so bad to get ambushed by them after all. + +When you complete this first ice maze, get HM07 Waterfall. To do so, head right +(from the bottom square), up, right, left, down, left, and right to take the HM. +Afterwards, just go left, down, and left to get out of the area. HM07 Waterfall +is a good move that you can teach to water types later. Head up the stairway and +along the icy path. Head down the stairs and then down the ladder. + +The next puzzle is in the next room. There are four boulders and four holes that +the boulders can fall into. You'll want a Pokémon with the HM Strength. + +Let's get the first boulder down the southwest hole. Move the bottom boulder +right, then 6 squares down, 3 squares to the left, 1 square up, 1 square to the +left, and 2 squares up. There goes one boulder. + +Now let's get the second boulder. Go all the way to the right and then up. Move +that boulder 1 square to the left, 4 squares down, 1 square to the right, 2 +squares down, and then 5 squares down. + +The bottom path has another boulder. You've moved the right one down, now let's +move the left one up. Push this boulder two up, then one left, and two up. +Simply push it two to the right, then up, and then left to smack it in. Fall +through the hole below it and then go left, up, and right to snag the item, Full +Heal! Head down the ladder then rock smash the rock near you. Grab the +Nevermeltice, then head right and go up, down the stairs, and with the ladder +going down, to the next stage. + +Finally, there's the remaining one. Move that one two to the left, then three +up, then push that one back down one. Then push it left one and down one. + +Head to the right side, and then you'll want to slide down to the part before +you fall off. Find the stairway, then go the opposite direction and slide down. +Slide right and you have the TM for Rest, TM44. Now go left and up, then go back +around. This time, go up the stairway and down the other one and you'll reach an +island of solid ice in which you can go up the ladder. + +Move towards the right and down the stairway. Then go left, down, left, and then +down. Simply head left on the weird slippery ice path to get that Iron. Once you +head back out, go right, up, right, down, left, up, left, up, and then left. +Woot. You're done. Climb up the steps and go up the ladder. + +Go up even more steps to get the Protein, then slide down at the very left to +grab a PP Up. Slide back up and drop anywhere in the middle of the area and then +go down from there. Walk out of the cave and finally, into Blackthorn City! + +|---------------| +|Blackthorn City| +|---------------| + +Head southwest and heal your Pokémon at the Pokémon Center. In the northeast +house, there's a lady that would trade her Rhydon for a Dragonair. In the house +in the south, the Move Deleter can delete HM moves from your Pokémon. + + +|---------------| +|Blackthorn City| +|---------------| + +When you reach Blackthorn City, make sure you restock on Ultra Balls so you're +back up to 30 Ultra Balls. Then heal your Pokémon at the Pokémon Center and +deposit any extraneous items. In the northeast house, there's a lady that would +trade her Rhydon for a Dragonair. In the house in the south, the Move Deleter +can delete HM moves from your Pokémon. + +Head up to the top of the city to reach the gym. This is a dragon-based gym, the +first of its kind! + +If you're just leveling up your Starter, your lineup should look something like +this: + +Level 57 Starter Evolution +Level 30 Gyarados +Level 22 Sudowoodo +Level 21 Tentacool +Level 10 Kenya the Spearow +Level 6 Bellsprout + +If you have a balanced lineup, your team might look like this: + +Level 36 Gyarados +Level 35 Raticate +Level 35 Quilava +Level 15 Dratini +Level 4 Wooper + +Head left to fight your first trainer battle. If you are just using your starter +Pokémon, the HM move Strength should take out every trainer in the gym, +including the gym leader. The advice below would be for a more balanced lineup. +If you're doing that, Gyarados should do pretty good damage with that Dragon +Rage. + +Cooltrainer Paul +Level 34 Dratini (487 EXP) +Level 34 Dratini (487 EXP) +Level 34 Dratini (487 EXP) +You get: 1632G + +Move up above him and head up the stairs. You'll find out you need to move even +more boulders. Use any Pokémon that you have that has Strength. For the two +above you, move the right one up two, and the left one down two and into the +hole. Now head down to fight... + +Cooltrainer Fran +Level 37 Seadra (1228 EXP) +You get: 1776G + +You'll see two more boulders nearby. Push the right one down three, and the left +one up nine, then to the right two to make that fall. Head up to fight the last +dragon trainer on this level. + +Cooltrainer Cody +Level 34 Horsea (604 EXP) +Level 36 Seadra (1195 EXP) +You get: 1728G + +Push the boulder right next to you three to the right, then move the other one +down one. Easy. Now, head down the stairs you saw earlier next to the second +hole you encountered. Head two up, then to the left to fight that trainer. + +Cooltrainer Mike +Level 37 Dragonair (1141 EXP) +You get: 1776G + +Head across the boulder path you just created and then to the final trainer +before Clair. + +Cooltrainer Lola +Level 34 Dratini (487 EXP) +Level 36 Dragonair (1110 EXP) +You get: 1728G + +Now that you've finished beating everyone, use the warps to return to the +entrance to heal at the Pokémon Center, if needed. + +Save before you battle, and make sure you have a few Revives, Full Heals, and +Hyper Potions in case you need it. Clair says she can hold her own against the +Elite Four as the dragon master. We'll see about that, my good friend! + +--- +GYM LEADER CLAIR +Level 37 Dragonair (1141 EXP) +Level 37 Dragonair (1141 EXP) +Level 40 Kingdra (1773 EXP) +Level 37 Dragonair (1141 EXP) +You get: Rising Badge, 4,000G, TM24 (Dragon Breath) +--- + +Strategy VERSUS Dragonairs: Use HM Strength to do a lot of damage, or Body Slam +if you have that on a Meganium. Try not to use grass, fire, or water moves, as +the Dragonairs resist those moves. Beware: Dragonairs can use Fire Blast, which +can do a decent amount of damage against a Meganium, and Thunder Wave, which can +paralyze your Pokémon from being able to attack. + +Strategy VERSUS Kingdra: Kingdra is a water Pokémon that can use dragon moves +like Dragon Pulse. Grass and normal moves are ideal here from your starter. + +This would be your 8th badge, but Clair won't give you it just yet. Apparently, +she's a sore loser and she won't give you the badge until you get the Dragon +Fang in the Dragon's Den. Now, leave the gym. + +|------------| +|Dragon's Den| +|------------| + +Pokémon in Dragon's Den: + +#129 Magikarp +#147 Dratini + +Items in Dragon's Den: + +TM24 Dragonbreath + +The Dragon's Den is located across the sea above the gym. Make sure you have in +your lineup a water Pokémon that can use the HMs Surf and Waterfall. Heal at the +Pokémon Center, then head in talking to the bodyguard in front of it. Does that +guy just stand out there every day? That's a pretty boring work shift. Go in, +head down the ladder, and go through the door, and then you'll get to battle. + +Surf around counterclockwise from the left side and you'll spot a Whirlpool. +Remove it, then keep surfing eastward. Eventually, you'll find Dragon Fang just +lying on the ground. Clair will then come and give you the Rising Badge and TM24 +(Dragon Breath). She insists that it's not her own breath. Maybe it's the breath +one of our Dragonairs. No one will ever know. It's a state secret. + +Clair then gives you instructions to go to the Pokémon League, by heading from +New Bark Town and surfing east to the Pokémon League. She tells you that she'll +feel even worse if you lost at the Pokémon League because she lost to you so you +must win. + +Now head back out the way you came and when you leave the cave (at the +entrance), Professor Elm will give you a call. If you swing by the lab, you'll +get something really awesome. However, Elm still has his last starter Pokémon +just sitting out there, waiting to be stolen by your thief Rival again. Elm +doesn't really know how to properly store his valuables. Head to the Pokémon +Center and heal your Pokémon. + +============================================================================== +============================================================================== +============================================================================== + +(c20) +---------------------------------------------- diff --git a/docs/gold-walkthrough/section-14-ho-oh-gold-and-lugia-silver.txt b/docs/gold-walkthrough/section-14-ho-oh-gold-and-lugia-silver.txt new file mode 100644 index 00000000..a1720a16 --- /dev/null +++ b/docs/gold-walkthrough/section-14-ho-oh-gold-and-lugia-silver.txt @@ -0,0 +1,193 @@ +---- 20 > Ho-Oh (Gold) and Lugia (Silver) ---- +---------------------------------------------- + +Video Playthrough Link (Whirl Islands to Lugia): +https://www.youtube.com/watch?v=cfCBHjF5hFU + +Make sure you buy Ultra Balls at the Pokemart so you have at least 30 Ultra +Balls. You will need them to catch a rare legendary Pokémon and they have very +low catch rates. You should also consider having a few Revives and Hyper +Potions, too. Now, you can either head to Ecruteak City (if you have Gold +version) or Olivine City (Silver version). I will cover both below. + +At this stage, you can only get Ho-oh or Lugia depending on what version you +have. You'll have a chance to catch the other later in the game. + +Gold = Ho-oh +Silver = Lugia + +Thard_Verad adds, "Apriballs - Three of them are broken: Fast Ball only works on +three species (NOT Raikou, Entei, or Suicune); Love Ball works on same gender, +not opposite; Moon Ball checks the wrong item and therefore never gets any +boosted catch rate. Level, Lure, Heavy, and Friend all work as intended." + +|------------------------| +|Tin Tower - Gold Version| +|------------------------| + +Pokémon in Bell Tower: + +Rattata + +Go back to Ecruteak City and then go into the Bell Tower entry house. Talk to +the man and he'll let you through because you have the Ecruteak City Badge. Then +follow up the trail to the tower itself. + +Go up and to the left and another bald man will let you through because you have +the beautiful Rainbow Wing. You can then go up the ladder. Keep going and wild +Rattatas will start converging on you like zombies. Consider a Max Repel or two +to drive away all the zombies. Then go up the ladder, hop left for a Full Heal, +then hop all the right. Then hop all the way left, then keep hopping right. On +the middle platform, you might get stuck a bit because you don't see the next +jump easily, but it's the second square from the top. You'll make it to the +northeast side and you can go up the ladder. You are now a true bunny. Are you a +bad bunny? + +On the next level, jump down, left, down, left, down, down to get to the Ultra +Ball. Jump right, down, left, down, down, and you'll get to a PP Up. If you go +up the ladder, it won't get you anywhere, so no need to do that. You can +continue with jumps left (x5), down, right (x2), down, left (x2), down, left +(x2) to get an Escape Rope. You'll be next to another ladder. That ladder will +also take you nowhere. Jump right, right, left, left, and you'll be on the +northwest side where you do want to take that ladder up. + +On the next level, jump right twice, go all the way to the right and down, jump +down, left, left, and you'll get a Rare Candy. You can then return to the +previous level's bottom right corner and go all the way back up to the top left +corner to get back up to the beginning of the level. Then jump right and right +again, then down, left, down (x2), and take the right down (x4) up to the next +level (Left down takes you back to the bottom left ladder from the previous +level). + +On the next level, go up and you'll get a Max Potion. Then go right across the +bridge, up, and then left across the upper (not middle) bridge to get a Full +Heal. Then hop down twice to get up the next ladder. + +On the next level, go up, go through the next two warps, jump on the top side +left (x8), down (x3), right (x3), and through that warp to get to a Max Elixir. +Go back through the warp and jump right (x4) to return to the start. Warp back +through the top right warp and then back through the other warp you went +through. You'll be back to the very start of this paragraph. Hop to the right +(x4) to get a Max Revive. Then hop down (x5), go left and down, warp, cross the +bridge down, and warp again. + +Go left and you'll get a HP Up. Keep going left and down and warp again. Then +cross the bridge up left and take the warp. Go up the ladder, cross the planks +down, then go up the ladder and you'll be in front of a Level 40 Ho-Oh. I +suggest that you save before you fight Ho-Oh because this is the only chance +you'll be able to get Ho-oh in the entire game. If you knock it out on a +critical hit, lose all your Pokémon and black out, or run out of balls to throw +at it, you would want to return to your save and retry. + +The Ho-Oh in-game music gives off great vibes. As we all know, the vibes are +what matters. We must have the vibes. Ho-Oh is a fire/flying type Pokemon with +Safeguard and Ancient Power (damage w/ stat boosts), Punishment, and Sacred +Fire. Use your highest level Pokémon to whittle down Ho-Oh's HP first without +knocking it out, then try to throw as many Ultra Balls as you can at it. + +Because the catch rate is only about 2% at low health, even with Ultra Balls, +this may involve a dozen or more resets. Ultimately, you'll want to maximize +your chances by throwing many Ultra Balls at it. You'll want to do so when it is +lower on HP (yellow or red on the HP meter), and ideally when it is asleep. Use +Revives and Hyper Potions as is helpful to keep your Pokémon alive to keep +throwing balls at it. However, all things even, focus on using Ultra Balls when +Ho-Oh is low on health, even when it could knock out your Pokémon, to increase +the number of chances yo have. You can slightly increase your catch chances if +you put Ho-Oh to sleep or paralyze it. + +After you catch Ho-Oh, go back down the stairs and then you can use an Escape +Rope to leave. You can then fly back to Blackthorn City. Be sure to also +withdraw Ho-Oh at a Pokémon Center so that you have Ho-Oh as part of your +lineup. + +|------------------------------| +|Whirl Islands - Silver Version| +|------------------------------| + +Fly to Olivine City. Make sure you have a Pokémon on your team that has Flash, +like Bellsprout. Also make sure you teach HM06 Whirlpool to a Pokémon. I suggest +Tentacool or Krabby, who can use Strength, Surf, Whirlpool, and Waterfall. +You'll need eight badges in order to use Whirlpool. + +Pokémon at the Whirl Islands: + +#041 Zubat +#086 Seel +#098 Krabby +#116 Horsea +#117 Seadra + +Items at the Whirl Islands: + +Calcium +Escape Rope +Guard Specs +Max Revive x2 +Ultra Ball + +Surf southwest and west until you reach the northeast cave of the Whirl Islands. +I suggest using a Max Repel to reduce the number of Tentacool encounters on the +way there. The whirlpool is just northwest of where you fought Swimmer Kara. You +can press A on any whirlpool to remove it temporarily from the map. It's +incredible how much stamina these swimmers have, just staying in the water days +after you last fought them. + +Once you find the northeast cave entrance, enter it and you'll see darkness. Use +HM Flash, then head up and leap the ledge to the right on that bike. You'll see +a Ultra Ball on the cliff. To get to it and continue on your journey, leap down +another ledge to the right, go up the steps, grab the Max Revive, and head down +the ladder. Head south, then jump to the left. Keep jumping down and you'll get +an Escape Rope. Woo! + +Now go down and then left, then up the ladder. You can now head out the door to +breathe, but that's pointless. Why would you do that? Instead, surf east, then +grab the Guard Specs. Then head down the ladder, head up along the path, head +down, and then up the cliff. It's like a roller coaster, I know. + +There's yet another item here. It's a Calcium. Now, on that cliff, head down to +the ladder and take it. Now, go down out the door. That's most of the items in +the Whirl Islands. + +Now that you've gone from the northeast to the southeast portion of the Whirl +Islands, go counterclockwise and surf your way southwest out of the reefs and +then Whirlpool out. Head east, then north, and you'll find Swimmer Kara again. +Take that Whirlpool out and head back into the entrance that you originally came +from. + +Use Flash again. Drop down the first cliff again, but this time, drop down the +top cliff rather than the bottom cliff. Then head down the ladder. Continue +along the path south and head down that ladder. Grab the Max Revive to the left +of you, then surf south. There are two items on the cliff but you can't reach +those. + +Instead, surf down the waterfall, then head left and through the door. Head in, +and surf north to Lugia. Save before you interact with Lugia. I suggest that you +save before you fight him because this is the only chance you'll be able to get +Lugia in the entire game. If you knock it out on a critical hit, lose all your +Pokémon and black out, or run out of balls to throw at it, you would want to +return to your save and retry. + +Because the catch rate is only about 2% at low health, even with Ultra Balls, +this may involve a dozen or more resets. Ultimately, you'll want to maximize +your chances by throwing many Ultra Balls at it. You'll want to do so when it is +lower on HP (yellow or red on the HP meter), and ideally when it is asleep. + +Focus on using Ultra Balls when Lugia is low on health, even when it could knock +out your Pokémon. Use Revives and Hyper Potions as is helpful to keep your +Pokémon alive to keep throwing balls at it. You can slightly increase your catch +chances if you put Lugia to sleep or paralyze it. + +Thard_Verad adds, "Lugia is just heavy enough for Heavy Balls to have higher +catch rates than Ultra Balls. Give those a try." + +After you catch Lugia, you can use an Escape Rope to leave. You can then fly +back to Mahogany Town. Be sure to also withdraw Lugia at the Pokémon Center so +that you have Lugia as part of your lineup. + + +============================================================================== +============================================================================== +============================================================================== + +(c21) +----------------------------------------- diff --git a/docs/gold-walkthrough/section-15-mount-mortar-and-dark-cave.txt b/docs/gold-walkthrough/section-15-mount-mortar-and-dark-cave.txt new file mode 100644 index 00000000..cea5c41f --- /dev/null +++ b/docs/gold-walkthrough/section-15-mount-mortar-and-dark-cave.txt @@ -0,0 +1,222 @@ +---- 21 > Mount Mortar and Dark Cave ---- +----------------------------------------- + +Video Playthrough Link (Mt. Mortar): https://www.youtube.com/watch?v=eOMK3CvQT8Q + +Video Playthrough Link (Blackthorn City Gym & Dark Cave): +https://www.youtube.com/watch?v=pWBRqXMdut8 + +Video Playthrough Link (Route 45 Part 1): +https://www.youtube.com/watch?v=y-5IXC6iwII + +Video Playthrough Link (Route 45, 46, & Tohjo Falls): +https://www.youtube.com/watch?v=VGAzyc8aM8o + +Video Playthrough Link (Route 27 Part 1): +https://www.youtube.com/watch?v=eL4FDqyquSY + +Video Playthrough Link (Route 27 Part 2): +https://www.youtube.com/watch?v=dwPL_w2_xE0 + +Video Playthrough Link Route 26 & Victory Road): +https://www.youtube.com/watch?v=Jign264AzYI + +Video Playthrough Link (Elite Four & Champion Battles): +https://www.youtube.com/watch?v=VcFLopf6A28 + +|-------------| +|Mahogany Town| +|-------------| + +Head to the Pokémon Center and heal your Pokémon. Make sure you have Pokémon +that can use the HMs Fly, Flash, Surf, and Waterfall (and Strength if you want +to do the optional areas which can get you additional items). You'll also want +to clear up one spot (so you only have 5 Pokémon) so that you have space to +accept a Pokémon in Mt. Mortar. Also carry a few Max Repels and Escape Ropes, +which you can get from the Pokemart. Then, take Route 42 westward. Alternative, +fly to Ecruteak City, then take Route 42 eastward. Either way, you'll come +across three cave entrances to Mt. Mortar. + +|----------| +|Mt. Mortar| +|----------| + +Pokémon in Mt. Mortar: + +#020 Raticate +#067 Machoke +#075 Graveler +#118 Goldeen +#119 Seaking +#236 Tyrogue (only one!) + +Items in Mt. Mortar: + +Carbos +Dragon Scale +Escape Rope x2 +Full Restore +Hyper Potion x2 +Max Ether +Max Potion +Max Revive +PP Up +Rare Candy +TM40 Aerial Ace + +First, Mt. Mortar has three entrances: a west entrance, a middle entrance (Surf +required), and an east entrance. Regardless of which entrance you use, you'll +want to use HM Flash, or you won't be able to see anything. + +The west entrance and east entrance lead to each other, as well as a giant room +where you can zig zag around, use Strength, and get two items, to include a +Hyper Potion and Escape Rope. I'd suggest using a Max Repel to get through this +section if you want to check it out, because there are a lot of random Pokémon +encounters. + +Where you really want to go is through the central entrance, which requires your +surfing east from either the west entrance (easier), or by surfing west from the +east entrance. Even if you want to catch some of the Pokémon in Mount Mortar, +I'd still suggest using at least one Max Repel to avoid some of the numerous +random Pokémon encounters. + +If you head up and down the ladder, then go down the steps, you can surf +clockwise around and reach a Hyper Potion and a Max Ether. + +The path where you can get to a new Pokémon is in fact through the central +entrance, but instead of going up and down the ladder, you take one step to the +side, go as far left/right as you can, and then Surf upward. You'll spot a big +waterfall. Use Waterfall on it, then surf all the way to the top, get back on +land, and then head through the door. + +Head back down the cliff, then go right. You'll hit a dead end, so surf to the +right. There's two routes. The left one takes you to a dead end but to a Max +Potion. Go all the way back to the entrance of this cave area, then surf left to +a Rare Candy. Go back to the lake where you can land to the left or to the right +and take the right path this time. Surf across that lake, go on the path all the +way to the left, then surf up and land again to the right. + +Keep heading right. There's two routes. Take the bottom one to get TM40 first, +then go to the top one to continue. Head all the way up and surf up. Grab the +Escape Rope, then continue along the path around the bend to the left. When you +see an item, go around to grab the Dragon Scale, then continue heading left. +Head left all the way up the ladder...phew, that's FINALLY two floors. Two more +to go. + +Run/bike down the steps, then head around and grab the free Iron on the cliff. +Head back to just before the steps and head east. Go along the path and take the +left intersection down the cliff. You'll see another item. Get the Max Revive, +then go back up and continue left. At the end of the path you can head down the +ladder. + +Now that you're back on an isolated area of Floor 2, head east and talk to the +Karate King. If you beat him, you'll get to return to your beloved babysitting +duties with the award of a new Pokémon! + +Blackbelt Kiyo +Level 34 Hitmonlee (1012 EXP) +Level 34 Hitmonchan (1020 EXP) +You get: 816G + +After your victory, you will be given a free Tyrogue. This is the baby version +of Hitmontop, Hitmonlee, and Hitmonchan. If you go down the stairs from the +ladder you came through, you can move the rock with HM Strength to get out. But +make life easier and use one of the Escape Ropes you brought to get out of this +place. + +|---------------| +|Blackthorn City| +|---------------| + +Fly back to Blackthorn City. You'll now be heading down to the Dark Cave, which, +is, well, dark. Bring a Pokémon that can use HM05 Flash, then use it so you can +actually see what's in Dark Cave. Or just stumble around blindly. I guess it's +your prerogative. + +Also, bring a Pokémon that can use HM03 Surf because your main character can't +swim across water. I guess at the young age of 10 years old, s/he just didn't +have the chance to take any swimming lessons. When you're ready, you'll want to +go south towards Route 45. + +If you're just leveling up your Starter, your lineup should look something like +this: + +Level 57 Starter Evolution +Level 30 Gyarados +Level 22 Sudowoodo +Level 21 Tentacool +Level 10 Kenya the Spearow +Level 6 Bellsprout + +If you have a balanced lineup, your team might look like this: + +Level 36 Gyarados +Level 35 Raticate +Level 35 Quilava +Level 15 Dratini +Level 4 Wooper + +|---------| +|Dark Cave| +|---------| + +Pokémon found in the Dark Cave: + +#041 Zubat +#042 Golbat +#074 Geodude +#075 Graveler (it explodes and goes BOOM) +#129 Magikarp (only on water, it can't flop onto land) +#202 Wobbuffet + +Items found in the Dark Cave: + +Blackglasses +Full Heal +Hyper Potion +Revive +TM 13 + +Okay, instead of taking a Route 45 path, enter the Dark Cave to the left before +there are even any jumps. Also, bring a Pokémon that can use HM03 Surf because +your main character can't swim across water. I guess at the young age of 10 +years old, s/he just didn't have the chance to take any swimming lessons. +Consider using a Max Repel at some point in Dark Cave. There are cool Pokémon to +catch, such as the rarer and wonderful Wobbuffett, but there are only so many +Geodudes you can look at. + +Follow the route down to the sea, then surf down, then go up. Go left and down +the steps, then head left and up to that guy. He'll give you +Blackglasses because he wants you to forget that you saw him. Dark-type moves +are increased in power with it. + +Now, you can use your best Tony Hawk skateboarding skills by jumping off ledges. +Jump all the way south and then head out the door to another part of the cave. +Surf down, then rock smash the rock next to you. Grab the Potion you see, then +head out the door onto Route 31. Fly back to Blackthorn City, heal at the +Pokemon Center, then head back south to actually take Route 45. + +If your lineup leans on your starter, it might look something like this: + +Level 57 Starter Evolution +Level 30 Gyarados +Level 22 Sudowoodo +Level 21 Tentacool +Level 10 Kenya the Spearow +Level 6 Bellsprout + +If you're going for a balanced lineup, it might look something like this: + +Level 15 Dratini +Level 5 Togepi +Level 10 Wooper +Level 36 Eevee +Level 36 Raticate +Level 36 Typhlosion + +============================================================================== +============================================================================== +============================================================================== + +(c22) +---------------------------------------- diff --git a/docs/gold-walkthrough/section-16-routes-45-46-26-and-27.txt b/docs/gold-walkthrough/section-16-routes-45-46-26-and-27.txt new file mode 100644 index 00000000..ac989a59 --- /dev/null +++ b/docs/gold-walkthrough/section-16-routes-45-46-26-and-27.txt @@ -0,0 +1,394 @@ +---- 22 > Routes 45, 46, 26, and 27 ---- +---------------------------------------- + + +Video Playthrough Link (Route 45 Part 1): +https://www.youtube.com/watch?v=y-5IXC6iwII + +Video Playthrough Link (Route 45, 46, & Tohjo Falls): +https://www.youtube.com/watch?v=VGAzyc8aM8o + +Video Playthrough Link (Route 27 Part 1): +https://www.youtube.com/watch?v=eL4FDqyquSY + +Video Playthrough Link (Route 27 Part 2): +https://www.youtube.com/watch?v=dwPL_w2_xE0 + +Video Playthrough Link Route 26 & Victory Road): +https://www.youtube.com/watch?v=Jign264AzYI + +Video Playthrough Link (Elite Four & Champion Battles): +https://www.youtube.com/watch?v=VcFLopf6A28 + +|--------| +|Route 45| +|--------| + +Pokémon found in Route 45: + +#074 Geodude +#075 Graveler +#207 Gligar (Gold only) +#216 Teddiursa (Gold only) +#227 Skarmory (Silver only, legendary catch rate) +#231 Phanpy (Silver only) + +Items found in Route 45 & 46: + +Berry +Dire Hit +Elixer +Max Potion +Mystery Berry +PRZCure Berry +Revive +X Special + +There are two roads diverged in a yellow wood. If you take the one less traveled +by, it will make all the difference. + +Haha, just kidding. But Routes 45 and 46 do have forks in the road. If you want +to fight every trainer and grab every item, you can fly back to Blackthorn City +to go down the other forks in the road. + +LEFT PATH: + +The left path is an entrance to the Dark Cave. You can keep going down for an +Elixir. There's also one trainer you can battle. + +Hiker Parry +Level 29 Onix (670 EXP) +You get: 1856G + +MIDDLE PATH: + +The right path splits into both a middle path and a right path. + +Hiker Erik +Level 24 Machop (385 EXP) +Level 24 Machop (385 EXP) +Level 27 Graveler (774 EXP) +You get: 1728G + +RIGHT PATH: + +Cooltrainer Ryan +Level 25 Pidgeot (920 EXP) +Level 27 Electrabuzz (901 EXP) +You get: 1296G + +There's a Max Potion south of Ryan. There's one more trainer: + +Cooltrainer Kelly +Level 27 Marill (920 EXP) +Level 24 Warortle (735 EXP) +Level 24 Warortle (735 EXP) +You get: 1152G + +COMBINED PATH: + +The path combines after Cooltrainer Kelly, then splits off again. The left path +has nothing, so take the right path. + +RIGHT PATH: + +Blackbelt Kenji +Level 28 Machoke (776 EXP) +You get: 672G + +Talk to him again and he will give you his phone number. Look at you, getting +everyone's digits just like that. You're so cool. The path splits off again here +into a left and a right path. + +LEFT PATH: + +You can grab a Revive and a Nugget. Nom nom. Oh wait, these are gold nuggets, +not chicken nuggets. Please save them for resale instead. There are also two +trainers here. + +Hiker Timothy +Level 27 Diglett (468 EXP) +Level 27 Dugtrio (885 EXP) +You get: 864G + +Why do I do things? Because they're there. I guess it's a thing to do. + +Camper Quentin +Level 27 Fearow (1041 EXP) +Level 30 Primeape (957 EXP) +Level 30 Tauros (1350 EXP) +You get: 600G + +You can drop down the right side to grab the Mystery Berry. + +RIGHT PATH: + +Taking the right path eventually leads you to Trainer Bailey. + +|--------| +|Route 46| +|--------| + +Pokémon found in Route 46: + +#019 Rattata +#021 Spearow +#074 Geodude + +There are, again, the two paths. Upon reaching Route 46 you will notice an +entrance to a cave. This is the Dark Cave you saw WAY back in Route 31. + +LEFT PATH: + +Camper Ted +Level 17 Mankey +You get: 680G + +You can pick up a Dire Hit to the left of Ted. You can go down to fight a +Picnicker. + +Picnicker Erin +Level 16 Ponyta (520 EXP) +Level 16 Ponyta (520 EXP) +You get: 640G + +Grab the Berry and PrzCure Berry to the left of it, then drop down just below +the cave. + +RIGHT PATH: + +You'll fight Hiker Bailey, a Geodude hoarder. + +Hiker Bailey +Level 13 Geodude (238 EXP) +Level 13 Geodude (238 EXP) +Level 13 Geodude (238 EXP) +Level 13 Geodude (238 EXP) +Level 13 Geodude (238 EXP) +You get: 582G + +Here, you can fly back up to Blackthorn City, heal at the Pokémon Center, and +take different paths down Routes 45 and 46 so that you can also face the other +trainers and get the other items. If you don't care about that or you've already +done that, you can just fly to New Bark Town to skip having to bike Route 29. +This is, by the way, how people stop getting exercise. They end up taking better +forms of transportation that make them lazy and they suddenly don't do cardio +anymore. Sad times. + +|-------------| +|New Bark Town| +|-------------| + +Talk to Professor Elm in his lab. He'll give you a Master Ball. You are the +lucky winner of the Master Ball sweepstakes. Since you have gotten eight badges, +you can now enter the Pokémon League. Yay! Now go into your own house and if you +talk to your mom, you can withdraw money (which is a whole fifth of what you've +gotten over time). Visit your room if you'd like. You've been gone from a long +time from home. + +Now, fly back to Cherrygrove City and withdraw Togepi temporarily. Fly back to +New Bark Town, talk to Elm again, and you'll get an Everstone. Now fly back to +Cherrygrove City and put your original Pokémon back in the lineup. Also deposit +any items that you don't need in your PC. Head to the east of the town, then +surf eastward onto Route 27. + +|--------| +|Route 27| +|--------| + +Pokémon found on Route 27: + +#020 Raticate +#024 Arbok +#028 Sandslash +#072 Tentacool (in water) +#077 Ponyta +#084 Doduo +#085 Dodrio +#195 Quagsire + +Items found on Route 27: + +TM22 Solar Beam +TM37 Sandstorm + +Are you finished building up your Pokémon and with doing everything else you +want to do in Johto, at least for now? If so, it's time to head towards the +Elite Four. Bring the team you want that also has all the HMs you need. + +Now surf eastward from New Bark Town. When you land on the other side, the first +guy you see will tell you that you've entered Kanto. If you've played the +original games (Red, Blue, or Yellow), you'll recognize that the land looks very +familiar. If you access your Pokegear, it will show that you are now in Kanto! + +You'll see a cave entrance. Don't go in yet, because caves are scary! Instead, +surf southeast of the cave entrance and you'll find a Rare Candy. I'm not really +sure that you should be feeding your Pokémon candy off the shore of a lake. It +doesn't seem sanitary, but it's rare so I guess that makes it okay. I tend to +save my Rare Candies but they level up your Pokémon by one level. Surf back to +shore and then enter the cave into Tohjo Falls. + +|-----------| +|Tohjo Falls| +|-----------| + +Pokémon found in Tohjo Falls: + +#019 Rattata +#079 Slowpoke +#119 Seaking + +You'll notice a Waterfall. Head along the sea route and down the other +waterfall. Those are the falls. Did you expect more? I know, the name sounded +more grand than it really was. Now head out the door you see back to Route 27. + +|--------| +|Route 27| +|--------| + +A trainer will immediately battle you right after you exit the Tohjo Falls. + +Cooltrainer Megan +Level 32 Bulbasaur (438 EXP) +Level 32 Ivysaur (966 EXP) +Level 32 Venusaur (1425 EXP) +You get: 1536G + +Enter the house, and speak to the lady there. If one of your Pokémon is happy +she will give you TM37 Sandstorm. Then head east and surf across the lake. + +Cooltrainer Blake +Level 33 Magneton (1138 EXP) +Level 31 Exeggcute (651 EXP) +Level 31 Quagsire (909 EXP) +You get: 1488G + +Keeping head east and you'll battle a few more trainers. + +Cooltrainer Brian +Level 35 Sandslash (1222 EXP) +You get: 1680G + +Psychic Gilbert +Level 30 Starmie (1330 EXP) +Level 30 Exeggcute (630 EXP) +Level 34 Girafarig (1084 EXP) +You get: 1088G + +Surf down from this trainer along the Coral till you find a Whirlpool. Remove +the Whirlpool then land on the island with the item. + +Bird Keeper Jose +Level 35 Farfetch'd (705 EXP) +You get: 816G + +You could exchange phone numbers with him, but you'll be out of room on your +list. Grab the item above him (TM22 Solar Beam), then surf back to the bridge. +Then keep heading along the path and you'll fight the final trainer of the +route. + +Cooltrainer Reena +Level 31 Starmie (1374 EXP) +Level 31 Starmie (1374 EXP) +Level 33 Nidoqueen (1371 EXP) +You get: 1488G + +After this battle, you can head east to Route 26. + +|--------| +|Route 26| +|--------| + +Pokémon found on Route 26: + +#020 Raticate +#024 Arbok +#028 Sandslash +#077 Ponyta +#084 Doduo +#085 Dodrio +#195 Quagsire + +Items found on Route 26: + +Ice Berry +Max Elixir + +Welcome to Route 26. Your first battle is against a fisherman. His name is +Scott. + +Fisher Scott +Level 30 Qwilfish (642 EXP) +Level 30 Qwilfish (642 EXP) +Level 34 Seaking (1237 EXP) +You get: 1360G + +Keep moving up the right path to fight more trainers. + +Psychic Richard +Level 36 Espeon (1519 EXP) +You get: 1152G + +Keep heading up till you find a house. Talk to the woman in there and she will +heal all your Pokémon for free. She's basically a one-person Pokémon Center. +Don't you just love free veterinarian health care? If you've ever had to pay vet +bills, you'll come to appreciate this lady very much. Give her a hearty round of +applause. + +Cooltrainer Joyce +Level 36 Pikachu (631 EXP) +Level 32 Blastoise (1440 EXP) +You get: 1536G + +Head up and you can grab the Ice Berry. You can move up further and then fight +another trainer who thinks he's cool. I think I'm pretty cool, but I don't go +around telling people that I am. + +If you go down the left path at this point, you will find a house with Monica's +journal. Why are you snooping around? Okay, fine, I won't judge. It's a list! +Lists are amazing. It's all the trainers you can get swag from, along with the +days and locations that they will be: + +Monday: Monica, Route 40 +Tuesday: Tuscany, Route 29 +Wednesday: Wesley, Lake of Rage +Thursday: Arthur, Route 36 +Friday: Frieda, Route 32 +Saturday: Santos, Blackthorn City +Sunday: Sunny, Route 37 + +Once you get back to where you were, the right path leads you to more trainers. + +Cooltrainer Gaven +Level 32 Victreebel (1309 EXP) +Level 32 Krabby (1411 EXP) +Level 32 Flareon (1357 EXP) +You get: 1536G + +Cooltrainer Jake +Level 33 Parasect (902 EXP) +Level 35 Golduck (1305 EXP) +You get: 1680G + +You can go up, drop down the left side, and do your best BMX bike tricks, and +grab the Max Elixir. You are using your bike, right? You're singularly +responsible for that bike shop's business, so get on it! Afterwards, bike back +down and get your Pokémon healed again with the free health care lady. Then go +back up the right side past Jake and you'll encounter one more trainer. + +Cooltrainer Beth +Level 36 Rapidash (1478 EXP) +You get: 1728G + +If you were to talk to her again you could switch numbers, but it's probably out +of room. Keep heading up, and you will eventually find yourself in the checking +station to the Elite Four. The guard will check your badges and if you have all +eight will let you pass, head in the door and into Victory Road. You can also +pick up an Max Elixir if you take the jumps back to heal. + +============================================================================== +============================================================================== +============================================================================== + +(c23) +--------------------------- diff --git a/docs/gold-walkthrough/section-17-victory-road.txt b/docs/gold-walkthrough/section-17-victory-road.txt new file mode 100644 index 00000000..c2798ee6 --- /dev/null +++ b/docs/gold-walkthrough/section-17-victory-road.txt @@ -0,0 +1,99 @@ +---- 23 > Victory Road ---- +--------------------------- + +Video Playthrough Link Route 26 & Victory Road): +https://www.youtube.com/watch?v=Jign264AzYI + +Video Playthrough Link (Elite Four & Champion Battles): +https://www.youtube.com/watch?v=VcFLopf6A28 + +|------------| +|Victory Road| +|------------| + +Pokémon at Victory Road: + +#042 Golbat +#075 Graveler +#111 Rhyhorn +#232 Donphan (Silver only) + +Items found in Victory Road: + +Full Heal +Full Restore +Max Revive +TM26 Earthquake +X Special + +Victory Road is nothing like it used to be. This eastern half of Victory Road is +now much easier now. There are no puzzles to solve now. There are also no +trainers stalking you as you roam around the dungeon. Head up the stairway and +up the cliff. If you go right, you can pick up a Full Heal and Max Revive and +then return to the cliff. If you go left, you can keep going and then go up the +ladder. Have you ever wondered why the cliffs have guardrails and why dungeons +have ladders? It has been a mystery to me. + +On the second floor, go right up the cliff, then head down. Going left takes you +to a X Special. Head right again and then up along the path, and that'll lead +you up to another ladder. + +Save when you get to the third floor. Go up the cliff to your left, go down the +ladder, grab the Full Restore, and jump down. Head back up the ladder to the +third floor. + +Now, take the path to the right. You'll spot a door to leave the cave. Before +exiting the cave, your Rival and challenge you to a rematch. He overconfidently +exclaims that you're so much weaker than he is even though he has never beaten +you in his life. + +|------------------------------------------| +|Rival | +|Level 34 Sneasel (961 EXP) | +|Level 36 Golbat (1318 EXP) | +|Level 34 Magneton (1207 EXP) | +|Level 38 Starter Evolution (1693-1710 EXP)| +|Level 35 Haunter (945 EXP) | +|Level 35 Kadabra (1087 EXP) | +|You get: 2280G | +|------------------------------------------| + +Strategy VERSUS Sneasel: If you're using your starter, use your highest power +move. If you're sticking with a balanced lineup, use Eevee and its quick attacks +to finish it off. + +Strategy VERSUS Golbat: If you're using your starter, use your highest power +move. If you're sticking with a balanced lineup, use Raticate's +hyper fangs and a quick attack. + +Strategy VERSUS Magneton: Magneton is a steel type, so it will absorb normal +moves. If you have Typhlosion, fire wheel is effective. Otherwise, stick with +the strongest move you've got. + +Strategy VERSUS Starter Evolution: If you're using your starter, use your +highest power move that isn't an attack from your starter's type. If you're +using a balanced lineup, use Raticate and have it hit with hyper fans. + +Strategy VERSUS Haunter: Use your highest power move that isn't a normal or +fighting attack, as those will be ineffective. + +Strategy VERSUS Kadabra: Use your highest power move that isn't a fighting move. + +Woop woop! Oh, sorry, I was imitating the victory celebration from a Wooper. +Congrats on beating your Rival one more time. Your rival finally understands he +sucks and all, but he hasn't given up hope. It's commendable, truly. That +arrogance really keeps him going. It's a beautiful story of a thief trying to +live his dreams. He retreats, and now you can advance out of the cave. + +But before you do that, go left instead of out the door, and fall down the hole +to get TM26 Earthquake. Earthquake is one of the best ground moves in the game. +Then drop down the ledge and head back up the ladder to get back to the third +floor. Go back through the third floor to leave the dungeon with an extra TM in +your pocket. + +============================================================================== +============================================================================== +============================================================================== + +(c24) +----------------------------- diff --git a/docs/gold-walkthrough/section-18-pok-mon-league.txt b/docs/gold-walkthrough/section-18-pok-mon-league.txt new file mode 100644 index 00000000..ec774e81 --- /dev/null +++ b/docs/gold-walkthrough/section-18-pok-mon-league.txt @@ -0,0 +1,296 @@ +---- 24 > Pokémon League ---- +----------------------------- + +Video Playthrough Link Route 26 & Victory Road): +https://www.youtube.com/watch?v=Jign264AzYI + +Video Playthrough Link (Elite Four & Champion Battles): +https://www.youtube.com/watch?v=VcFLopf6A28 + +|--------------| +|POKEMON LEAGUE| +|--------------| + +Route 32 is very short, and you'll immediately be at the Pokémon League building +at the Indigo Plateau. You'll now be able to fly to this location directly. The +left side of the building is the Pokémon Center, while the right side is the +Pokemart. If you want to head back to Johto, you can talk to the old man with +the Abra next to the Pokémon Center portion of Indigo Plateau's HQ and Abra will +teleport you back. + +Now, your goal is to take on five powerful trainers in a row. You mostly want to +have high-power moves of different types, plus priority moves (like Quick +Attack), status moves (like paralysis or sleep), and recovery moves (like +Recover or Leech Seed) in your lineup. Note that you do NOT need to have every +HM in your lineup in these fights, so you can leave Cut and Whirlpool off the +lineup for now. + +You don't need any Pokémon that can use HMs. However, certain HMs are great to +teach to your Pokémon because they are also strong attacks. Surf is 95 Power, +Strength is 80 Power, Waterfall is 80 Power, and Fly is 80 Power. Consider +teaching these moves to every Pokémon in your six-Pokémon lineup if they can +learn them. You can go back to the Move Deleter in Blackthorn City later in the +game if you want to replace the moves later. + +Here's a potential lineup in which you're just leveling up your starters, along +with their power levels: + +Feraligatr - SURF (95), STRENGTH (80), Hydro Pump (120), Bite (60) +Lugia - Aero Blast (100/5 PP), SURF (95), FLY (80), Recover +Donphan - STRENGTH (80), Fury Attack (15x2-5), Rollout (30), Flail +Gyarados - Surf (95), WATERFALL (80), Strength (80), Bite (60 flinch) +Raticate - Quick Attack (40), Hyper Fang (80 flinch), STRENGTH (80), CUT (50) +Sudowoodo - Strength (80), Rock Throw (50), Low Kick (50 flinch), Flail + +If you want a balanced lineup, your Pokémon will all need to be at least Level +40 or higher, because your next opponents will all have Pokemon from Level 40 +through Level 50. The best training ground would be back in Victory Road, +although Routes 46 and 47 aren't bad, either. If you trade a Red Scale to Mr. +Pokémon, he will give you EXP Share, which will share all EXP that you gain with +every Pokémon in your lineup so you don't have to keep switching Pokémon back +and forth. + +If you're leveling up an Eevee, put Eevee at the start of your lineup to +increase its happiness towards an Espeon/Umbreon evolution. Eevee can evolve to +Espeon (a psychic type) in the daytime, and an Umbreon (dark type) in the night +time. This can take a while as you can only the requisite happiness through +leveling up, walking around a lot, and visiting the Goldenrod City salon guy. +Goldenrod City also has extra TMs you can buy, so you can buy Thunder Punch and +Fire Punch and use it on Pokémon and still be able to reuse those TMs in the +future. + +If you have a balanced lineup, your lineup to the Elite Four should be at least +something like the following: + +Level 47 Espeon +Level 41 Dragonair +Level 40 Gyarados +Level 40 Sudowoodo +Level 40 Typhlosion +Level 38 Raticate + +When you do feel ready for the ultimate challenge, make sure you have at least +ten Hyper Potions, ten Revives, and ten Full Heals. You can sell any Potions, +Super Potions, and Paralyz Heal/Awakenings etc. to make room for the more +versatile Hyper Potions and Full Heals. + +You can't go back to the Pokémon Center to heal between battles, and no one will +heal your Pokémon, so having items on hand will be helpful for HP loss, KOs, or +paralysis situations. You've traveled across the land, searching far and wide. +Now it's time. + +Fully heal your Pokémon and save after every trainer battle at the Elite Four. +Keep in mind that you cannot readily replenish your PP, as ethers are single +use. You'll want to balance out your PP so that you don't run out of any move +too early through the battles. Use type-advantaged moves were possible, and if +you can use a lower-powered move to knock out a Pokémon, do that. It is a battle +of endurance, and it is a grind. Allocate an hour to fighting the Pokémon +League. The trainers will sap your energy. + +The first Elite Four member, Will, uses psychic Pokémon. Avoid fighting moves. +Dark moves will be effective. + +|-----------------------------| +|Elite Four Will | +|Level 40 Xatu (1465 EXP) | +|Level 41 Jynx (1203 EXP) | +|Level 41 Slowbro (1440 EXP) | +|Level 42 Xatu (1539 EXP) | +|Level 41 Exeggutor (1861 EXP)| +|You get: 4200G | +|-----------------------------| + +Strategy VERSUS Xatus: Use the most high-powered move that you've got that's not +a fighting move. + +Strategy VERSUS Jynx: Use the most high-powered move that you've got. Jynx is +also an ice type, so you can also use fire moves. + +Strategy VERSUS Slowbro: Use the most high-powered move that you've got. Slowbro +is also a water type, so you can also use a strong electric type move. + +Strategy VERSUS Exeggutor: Use the most high-powered move that you've got. +Exeggutor is also a grass type, so you can also use fire moves. + +Next up is Elite Four Koga. He was the poison type Fuchsia City Gym Leader in +Red, Blue, and Yellow, and now he's become an Elite Four member. Poison types +are super effective against grass types, so don't put a Meganium or other grass +type in. Instead, focus on using ground and psychic type moves. + +|------------------------------| +|Elite Four Koga | +|Level 40 Ariados (1147 EXP) | +|Level 43 Forretress (1086 EXP)| +|Level 41 Venomoth (1212 EXP) | +|Level 42 Muk (1413 EXP) | +|Level 44 Crobat (1923 EXP) | +|You get: 4400G | +|------------------------------| + +Koga has three Full Restores that he may use. + +Strategy VERSUS Ariados - Ariados is both a poison and bug type. This means you +can fry Ariados with fire or flying type moves alongside the typical psychic +moves. + +Strategy VERSUS Forretress - Use the most high-powered move that you've got +that's not a grass move. Psychic moves are super effective. + +Strategy VERSUS Venomoth - Use the most high-powered move that you've got that's +not a grass move. Psychic moves are super effective. Since it's also a flying +type, you can also use electric or ice moves to take it down. Beware of +Venomoth's Confuse Ray. Not that you could stop it even if I told you it was +coming. + +Strategy VERSUS Muk - Use the most high-powered move that you've got that's not +a grass move. Psychic moves are super effective. Muk has Toxic, so Muk can +poison your Pokémon. + +Strategy VERSUS Crobat - Use the most high-powered move that you've got that's +not a grass move. Psychic moves are super effective. Since it's also a flying +type, you can also use electric or ice moves to take it down. + +The third Elite Four member is Bruno. He has mostly fighting type Pokémon but +threw in a rock type in his lineup just for funsies. Fighting types are super +effective against normal, ice, rock, dark, and steel types. The best moves to +use against them are flying and psychic type moves. Avoid using dark moves +against fighting types. + +|------------------------------| +|Elite Four Bruno | +|Level 42 Hitmontop (1242 EXP) | +|Level 42 Hitmonchan (1260 EXP)| +|Level 42 Hitmonlee (1242 EXP) | +|Level 43 Onix (994 EXP) | +|Level 46 Machamp (1902 EXP) | +|You get: 4600G | +|------------------------------| + +Strategy VERSUS Hitmontop: The good news about battling fighting types is that +you can use pretty much any move against them. Use your +highest power move from your best Pokémon against it. Flying and psychic moves +also work well. + +Strategy VERSUS Hitmonchan: Hitmonchan is still a fighting type, but it can be +more of a challenge to fight if it gets off its electric, fire, or ice punches. +The strategy otherwise holds. Use your highest power move from your best Pokémon +against it. Flying and psychic moves also work well. + +Strategy VERSUS Hitmonlee: Hitmonlee's similar to Hitmontop. Use your highest +power move from your best Pokémon against it. Flying and psychic moves also work +well. Watch out for Swagger, which will confuse your Pokémon. + +Strategy VERSUS Onix: Onix is the one rock type. Use water, grass, fighting, +ground, or steel moves to beat it. Use your highest power move from your best +Pokémon that isn't a normal move. + +Strategy VERSUS Machamp: Machamp is the same as the other fighting types. Use +your highest power move from your best Pokémon against it. Flying and psychic +moves also work well. + +You've got one more Elite Four member to face. She wants to be entertained by +you. I think you can handle that. Your Pokémon are quite entertaining and they +can entertain by winning the battle! + +|-----------------------------| +|Elite Four Karen | +|Level 42 Umbreon (1773 EXP) | +|Level 42 Vileplume (1656 EXP)| +|Level 45 Gengar (1831 EXP) | +|Level 44 Murkrow (1008 EXP) | +|Level 47 Houndoom (2053 EXP) | +|You get: 4700G | +|-----------------------------| + +Karen has one Full Restore that she can use. + +Strategy VERSUS Umbreon: Umbreon is a dark type. It is weak to fighting and bug +moves and is immune to psychic moves. You should be able to just use your +highest-powered attack to beat Umbreon. Beware of Umbreon's Confuse Ray, which +will confuse your Pokémon. + +Strategy VERSUS Vileplume: Vileplume is a grass and poison type. It is weak to +fire and psychic moves. You should be able to just use your highest-powered +attack to beat Vileplume, which may be flame wheels from Typhlosion if that was +your starter. + +Strategy VERSUS Gengar: Gengar's a ghost type that's weak against ghost and dark +moves. Don't use any normal attacks on it because it has no effect. Otherwise, +use your highest-powered attack that's not a normal, poison, or bug move against +Gengar. + +Strategy VERSUS Murkrow: Murkrow's a dark and flying type. Like Umbreon, it is +weak to flying and bug moves and immune to psychic moves. As a flying type, +Murkrow is also immune to ground moves. Otherwise, you can use your +highest-powered attack against Murkrow. Consider using ice or electric moves as +they are super effective. + +Strategy VERSUS Houndoom: Houndoom's a fire and dark type who will be holding a +Sitrus Berry. You should be able to just use your highest-powered attack if it's +not a psychic, fire, or grass move. + +Congrats on beating the Elite Four. Guess you're now more elite than them now! +Now, heal all your Pokémon. The final challenge for Pokémon League champion +begins. Three of Champion Lance's Pokémon are actual dragon types, while the +rest are essentially dragons but not dragon types. + +|------------------------------| +|Champion Lance | +|Level 44 Gyarados (2017 EXP) | +|Level 47 Dragonite (2194 EXP) | +|Level 47 Dragonite (2194 EXP) | +|Level 46 Aerodactyl (1990 EXP)| +|Level 46 Charizard (2059 EXP) | +|Level 50 Dragonite (2335 EXP) | +|You get: 5000G | +|------------------------------| + +Lance has three Full Restores on hand for the battle. + +Strategy VERSUS Gyarados: Gyarados is two levels higher than in the original +game. Gyarados is a water and flying type, not a dragon type. Avoid using fire, +water, or ground moves against it. Use the highest powered move from your +highest level Pokémon otherwise. Electric and rock moves would be super +effective. + +Strategy VERSUS Dragonites: Do not use fire, water, grass, or ground type moves +due to Dragonite's dual dragon/flying typing. You can otherwise use the highest +powered move of any other type from your highest level Pokémon. Ice and rock +types are particularly useful moves against the Dragonites, especially because +dragon and flying types are both weak against ice moves. + +The Dragonites are strong. They have a powerful Hyper Beam move (although it +must recharge), it can paralyze your Pokémon with Thunder Wave, and it also has +strong Dragon Rush and Thunder attacks. The Level 50 Dragonite has Safeguard and +Outrage instead. Have Full Heals on hand +for the fight. One of the Dragonites may be holding a Sitrus Berry. + +Strategy VERSUS Aerodactyl: Aerodactyl is a rock and flying type, not a dragon +type. Use the highest powered move from your highest level Pokémon otherwise. +Ice and water moves are ideal. + +Strategy VERSUS Charizard: Charizard's weaknesses as a Fire/Flying type are +Water, Electric, and Rock, especially Water and Rock. + +Congratulations! You have defeated the Elite Four! Lance doesn't seem angry that +he lost but rather, happy that you were the one to become the new champ. He +congratulates you. + +After defeating the Johto League Elite Four members, you've beaten the first +half of the game. A news reporter named Mary wants to interview you, but Lance +shoos you into the other room to be inducted into the Hall of Fame. Guess Mary +won't get to interview you after all! Professor Oak also arrives just to hang +out. Lance will then enter your entire Pokémon lineup into the Hall of Fame, +even the ones that sat there on the bench and watched your Lv 66 starter do all +the hard work. + +The credits will roll, while playing some snappy music, and finally the game +will say "The End". Don't turn your DS off yet, because the game isn't actually +over. + +============================================================================== +============================================================================== +============================================================================== + +(c25) +----------------------------------------------- diff --git a/docs/gold-walkthrough/section-19-s-s-aqua-and-vermilion-city-gym.txt b/docs/gold-walkthrough/section-19-s-s-aqua-and-vermilion-city-gym.txt new file mode 100644 index 00000000..d790214f --- /dev/null +++ b/docs/gold-walkthrough/section-19-s-s-aqua-and-vermilion-city-gym.txt @@ -0,0 +1,246 @@ +---- 25 > S.S. Aqua and Vermilion City Gym ---- +----------------------------------------------- + +Video Playthrough Link (Vermilion City & Saffron City Gyms): +https://www.youtube.com/watch?v=5Hsa1kt1CyM + +|-------------| +|New Bark Town| +|-------------| + +After the credits finish rolling, you will arrive back in New Bark Town. Go into +Prof. Elm's lab because he wants to show you something. He'll even waste +previous cell phone minutes to call you. Now that's dedication! Go to his lab, +where someone could still go and steal the last starter Pokémon that he hasn't +given to another 10-year old trainer. Prof. Elm will give you a S.S. Ticket! + +|----------| +|Saint Aqua| +|----------| + +Item on Saint Aqua: + +Meal Coat + +Welcome to the second half of the game. Now that you've beaten the Pokémon +League, you can go to Kanto, the world of the original Pokémon Red, Blue, and +Yellow games. You can visit all the cities again, and fight most of the gym +leaders again, too! Kanto is not particularly linear. We'll take a route that is +about minimizing travel fatigue and avoiding underleveled teams. + +You have a decision point going into Kanto that you should be aware of. You'll +be relatively overleveled through most of Kanto. This means you can skip most of +the trainer battles, if you wanted to speedrun or get to your next game in the +backlog. However, there is a significant level spike at the end of the game. +Therefore, I would still suggest that you fight most or all the trainers, or the +end of the game could be a challenging one for you. + +A balanced lineup that you could take to Kanto is: + +Level 49 Espeon +Level 42 Typhlosion +Level 41 Dragonair +Level 41 Sudowoodo +Level 40 Gyarados +Level 38 Raticate + +Fly to Olivine City. Go south from the Pokémon Center to a docking pay called +the Olivine Port Fast Ship Pier. Go through the door, take the stairs down +twice. Then keep going down and talk to the guy at the entrance to board the +Fast Ship S.S. Aqua. As with the S.S. Anne from the Red, Blue, and Yellow games, +you'll fight Pokémon trainers bored with sea travel while the ship sails. + +The ship will begin sailing to Kanto. Go down a few steps and an old man will +bump into you. He tells you that his granddaughter is missing. Head left into +the room you see, and you'll start fighting trainers. + +Hiker Noland +Level 31 Sandslash (1081 EXP) +Level 33 Golem (1251 EXP) +You get: 1056G + +You'll see three other cabins on the top part of the ship. The cabin on the far +left is yours, and you can go to that cabin's bed heal all your Pokémon. No +other cabin on the north side currently has trainers. You can head to the bottom +half of the ship and down to the left side to fight another trainer. + +Firebreather Lyle +Level 28 Koffing (684 EXP) +Level 31 Flareon (1314 EXP) +Level 28 Koffing (684 EXP) +You get: 1344G + +If you head two cabins to the right, you'll find another trainer to battle. + +PoKeFan Colin +Level 32 Delibird (1254 EXP) +You get: ???G + +The same cabin also has twin trainers. + +Twins Meg & Peg +Level 31 Teddiursa (823 EXP) +Level 31 Phanpy (823 EXP) +You get: 620G + +Head to the cabin to the right and you'll speak to the old man again. Go +downstairs (next to his room) and a sailor will block you like he's blocking you +from dunking on him. Talk to him and you'll learn that there's a sailor who's +not pulling his weight at work. Go back down towards your cabin but into the +cabin that's next to yours. Apparently a sailor has been napping on the job. +That's wage theft! + +Sailor Stanly +Level 31 Machop (583 EXP) +Level 33 Machoke (1032 EXP) +Level 26 Psyduck (445 EXP) +You get: 1040G + +Defeat the sailor and he'll return to work. Then go back downstairs and the +sailor boss will let you pass. He also tells you that a little girl passed by +there as if it wasn't his responsibility to make sure little children don't run +around the whole ship. Head left, then down, and you'll face another trainer. + +Juggler Fritz +Level 29 Mr. Mime (844 EXP) +Level 29 Machoke (906 EXP) +Level 29 Magmar (1036 EXP) +You get: 1160G + +Head up, left, and then go down into the next "room." + +Sailor Jeff +Level 32 Raticate (795 EXP) +Level 32 Raticate (795 EXP) +You get: 1280G + +Now head up and left and talk to the girl that's just staring at a wall for some +reason. She tells you that she's soooo bored that she'll battle you. + +Picnicker Debra +Level 33 Seaking (1201 EXP) +You get: 660G + +Head down from Debra and up the stairs. Then go through the door and talk to the +girl. She'll find out that her grandpa is worried about her. Then she teleports +back to her grandfather's cabin and the grateful grandfather gives you a Metal +Coat. Okay, that wasn't really teleporting, but time travel is just as +wonderful. Now, the ship will arrive in the Kanto. Go back up to where you +entered and the guy will stop blocking the door. + +|--------------| +|Vermilion City| +|--------------| + +Item in Vermilion City: + +Rare Candy + +Now that you get off the S.S. Aqua, and you find yourself at Vermilion City! Go +down the stairs, then up the stairs and out the door. You'll see your first +glimpse of the Vermilion City three years later. Follow the route north and +you're out of the Vermilion Port. Note the new Pokémon music for Kanto. It's an +interesting remix. The Fast Ship sails Wednesday and Sunday if you want to +return for another cruise. + +North of the gym is the Pokémon Club house. As with the original games, the +Chairman of the Pokémon Fan Club is there. If you listen to him, he'll babble on +forever about his wonderful Pokémon. Eventually, he'll give you a Rare Candy as +compensation for listening to him express his feelings. + +The northwest part of town has the Pokémon Center. Heal, make sure you have a +Pokémon with Cut, and cut down the tree blocking the gym. Look around and you'll +see that the gym looks pretty much the same and other features from Red, Blue, +and Yellow are still here. + +Anyway, there's a power outage or something, because his usual puzzle from +Red/Blue/Yellow is gone. There are three trainers before Lt. Surge. This is a +gym with mostly electric Pokémon where you can usually just use the most +high-powered moves, although the Magnemites and Magnetons are also steel types. +Fire and fighting moves are preferred against them. + +Guitarist Vincent +Level 27 Magnemite (514 EXP) +Level 33 Voltorb (727 EXP) +Level 32 Magnemite (609 EXP) +Level 32 Magnemite (609 EXP) +You get: 1024G + +Before you can even move, you'll face an Electrode-hoarding trainer who likes +his Electrodes to go boom boom. You can either knock out all the Electrodes in +one hit before they explode, or use a rock type that can withstand the +self-destructing nature of these ball-shaped Pokémon. + +Juggler Horton +Level 33 Electrode (1060 EXP) +Level 33 Electrode (1060 EXP) +Level 33 Electrode (1060 EXP) +Level 33 Electrode (1060 EXP) +You get: 1320G + +Head right, then up to face the next trainer. + +Gentleman Gregory +Level 37 Pikachu (649 EXP) +Level 33 Flaaffy (826 EXP) +You get: 2376G + +You can heal before your gym battle or go straight into it, but save first. He +says he'll zap you like he did in war against his enemies. Every Pokémon can +confuse or paralyze your Pokémon, so try to avoid using two-turn moves that are +doubly affected by that if paralysis or confusion is in effect. + +--- +GYM LEADER LT. SURGE +Level 44 Raichu (1149 EXP) +Level 40 Magneton (1380 EXP) +Level 46 Electrabuzz (1537 EXP) +Level 40 Electrode (1285 EXP) +Level 40 Electrode (1285 EXP) +You get: Thunder Badge, 4600G +--- + +Lt. Surge has a Full Restore that he can use to fully restore the health and +status of one of his Pokémon. + +Strategy VERSUS Raichu: Use the most high-powered move from your highest level +Pokémon that. Alternatively, use rock or ground moves which are super effective +against electric Pokémon. Ground Pokémon will be immune to its electric attacks. +Raichu has Thunder Wave, and will likely use it to paralyze your Pokémon. If +your Pokémon is paralyzed, you may want to avoid using a two-turn move because +the chances of that connecting are significantly reduced. + +Strategy VERSUS Magneton: Use the most high-powered moves from your highest +level Pokémon. Ground Pokémon will be immune to its electric attacks. Magneton +is also a steel type, so fire and fighting moves are ideal. Magneton can use +Supersonic, which can confuse your Pokémon. If your Pokémon is paralyzed or +confused, you may want to avoid using a two-turn move because the chances of +that connecting are significantly reduced. + +Strategy VERSUS Electrabuzz: Use the most high-powered move from your highest +level Pokémon that. Alternatively, use rock or ground moves which are super +effective against electric Pokémon. Ground Pokémon will be immune to its +electric attacks. Electabuzz has Thunder Wave, and will likely use it to +paralyze your Pokémon. If your Pokémon is paralyzed, you may want to avoid using +a two-turn move because the chances of that connecting are significantly +reduced. + +Strategy VERSUS Electrodes: Use the most high-powered move from your highest +level Pokémon that. Alternatively, use rock or ground moves which are super +effective against electric Pokémon. Ground Pokémon will be immune to its +electric attacks. Electrode has Thunder Wave, and will likely use it to paralyze +your Pokémon. Electrode may use Double Team, an annoying move that increases +evasion. If your Pokémon is paralyzed, you may want to avoid using a two-turn +move because the chances of that connecting are significantly reduced, +especially if Electrode has already used Double Team. + +Lt. Surge will give you the Thunder Badge and 4,600G. Thunder Badge increases +Pokémon speed. Yay! Congrats on your first badge in Kanto. You are the hero of +your own story. + +============================================================================== +============================================================================== +============================================================================== + +(c26) +------------------------------- diff --git a/docs/gold-walkthrough/section-20-saffron-city-gym.txt b/docs/gold-walkthrough/section-20-saffron-city-gym.txt new file mode 100644 index 00000000..e4353869 --- /dev/null +++ b/docs/gold-walkthrough/section-20-saffron-city-gym.txt @@ -0,0 +1,135 @@ +---- 26 > Saffron City Gym ---- +------------------------------- + +Video Playthrough Link (Vermilion City & Saffron City Gyms): +https://www.youtube.com/watch?v=5Hsa1kt1CyM + +|-------| +|Route 6| +|-------| + +Heal at the Pokémon Center, then head north onto Route 6. Route 6 is kind of a +barren wasteland. There's a large man who blocks the entire entrance to the +Underground Path. He looks like he can take you down because you're too weak to +move your own boulders. He says that the Power Plant has issues right now, so +you can't take the Magnet Train between Saffron City to Goldenrod City yet. So +make your way all the way up through the checkpoint house and you'll be into +Saffron City. + +|------------| +|Saffron City| +|------------| + +Items in Saffron City: + +Focus Band +TM29 Psychic + +Ride your trusty bike to the southeast side of Saffron City, which is the +Pokémon version of Tokyo. There's a house labeled Mr. Psychic's House. Mr. +Psychic is there. He does some magic tricks and gets you TM29 Psychic, one of +the best Psychic moooves in the game. + +Next, enter Silph Co. This is the multi-story building above the Pokémon Center +where the main character battles Team Rocket in Red, Blue, and Yellow. Talk to +the man blocking the pathway up to the first floor and he'll give you Up-Grade. +It's amazing how many people are employed in this land that just block doorways +and stairways. + +On the northwest side of town is the Magnet Train, which you can take between +Johto and Kanto. It's currently closed because the Power Plant is down. + +Now, head out and go to the upper-right corner of Saffron City. There's a +PokeMart if you want to buy or sell items. You're probably low on item space at +this point, and your bike takes up the same amount of room as a tiny potion in +your backpack. Ah, such is life. Further north, you'll spot two gyms. The former +fighting gym has a Focus Band you can pick up and attach to a Pokémon for +battle. + +Afterwards, head to the gym on the right. It's Saffron City's official gym. +You'll want to avoid using fighting moves there, while high-powered and dark +moves are going to be your friends. Your playable character doesn't really have +friends other than those trainers who call you out of the blue to tell you about +how they've dressed up their Nidoran in pretty clothing, so you take what you +can get. The teleporter puzzles are back from the Red, Blue, and Yellow games. + +Medium Rebecca +Level 35 Drowzee (765 EXP) +Level 35 Hypno (1237 EXP) +You get: 1400G + +Head left through the teleporter, then fight Psychic Franklin. + +Psychic Franklin +Level 37 Kadabra (1149 EXP) +You get: 1184G + +Head left through the teleporter and fight your next Psychic. + +Psychic Jared +Level 32 Mr. Mime (735 EXP) +Level 32 Exeggcute (672 EXP) +Level 32 Exeggcute (672 EXP) +You get: 1120G + +Head left through the teleporter, then right to another medium. She can see into +your soul, because that's the kind of thing you tell strangers the first time +you talk to them. + +Medium Doris +Level 34 Slowpoke (720 EXP) +Level 36 Slowbro (1264 EXP) +You get: 1440G + +So, you've got two choices here. You can go back and heal, or not go back and +heal. I know, so many choices. If you want to go straight into the fight, just +go to the bottom left teleporter and you'll go straight to the gym leader. + +If you want to go back to the Pokémon Center to heal first, embrace the maze +life. Head right through the teleporter, then left, then left/right (whichever +way works) till you find that one trainer again (Franklin), then go the opposite +teleporter direction to outside the gym to heal at the Center, then head back +in. I know, I got confused even trying to explain it. After healing, you'd head +through the 1st teleporter, then go up, left, down, and then down. + +If you have a balanced lineup, it might look something like this: + +Level 49 Espeon +Level 43 Sudowoodo +Level 43 Typhlosion +Level 42 Raticate +Level 41 Dragonair +Level 41 Gyarados + +You're now at Sabrina. She says, “I knew you were coming. Three years ago I had +a vision of your arrival.” That's pretty cool. Maybe she can read you your +fortune without you having to pay for another tarot card reader. Those readings +sure don't come cheap. + +--- +GYM LEADER SABRINA +Level 46 Espeon (1941 EXP) +Level 46 Mr. Mime (1339 EXP) +Level 48 Alakazam (1912 EXP) +You get: Marsh Badge, 4800G +--- + +Strategy VERSUS Espeon: Use your highest powered move, ideally with your +starter. You could also use a Pokémon that bites. Nom nom. + +Strategy VERSUS Mr. Mime: Use your highest powered move, ideally with your +starter. + +Strategy VERSUS Alakazam: Use your highest powered move, ideally with your +starter. + +Sabrina says that your power far exceeds what I foresaw. You now have 10 badges! +She'll give you a Marsh Badge and 4,800G. Now to get out of the gym, head out +the only teleporter, then go right, right, up, and right. + +============================================================================== +============================================================================== +============================================================================== + +(c27) +------------------------------------------------ diff --git a/docs/gold-walkthrough/section-21-power-plant-and-cerulean-city-gym.txt b/docs/gold-walkthrough/section-21-power-plant-and-cerulean-city-gym.txt new file mode 100644 index 00000000..3c793aec --- /dev/null +++ b/docs/gold-walkthrough/section-21-power-plant-and-cerulean-city-gym.txt @@ -0,0 +1,304 @@ +---- 27 > Power Plant and Cerulean City Gym ---- +------------------------------------------------ + +Video Playthrough Link (Routes 9-10 & 24, Rock Tunnel, & Kanto Power Plant): +https://www.youtube.com/watch?v=Gr2hFv0JgM8 + +Video Playthrough Link (Route 25, Cerulean City Gym, Lavender Town, & Celadon +City Gym): https://www.youtube.com/watch?v=bxbOG0kR4QE + +|-------| +|Route 5| +|-------| + +Items on Route 5: + +Cleanse Tag + +Heal at the Pokémon Center, then head north through the route-changing station +and north onto Route 5. There are no trainers here, just that man who's blocking +the entire door to the Underground Path. If you bike all the way up to Cerulean +City, you can hop down some ledges on your BMX bike to the old Daycare Center +from Red, Blue, and Yellow. Inside is a lady who will give you a Cleanse Tag to +ward off shadows. Now, actually head north into Cerulean City. + +|-------------| +|Cerulean City| +|-------------| + +You can talk to the people in Cerulean City but no one will give you any items +here. The Bike Shop from Red, Blue, and Yellow has moved to Goldenrod City. +That's why you have to show off your bike in Kanto for the bike sales of a shop +owner that you don't really care about, but you like getting free things. + +Enter the Cerulean City Gym and go up and you'll find that no one is there. +Where's Misty? Make sure you have Pokémon that can use Cut and Surf, then head +east onto Route 9 through the northeast part of town. + +|-------| +|Route 9| +|-------| + +Pokémon found on Route 9: + +#019 Rattata +#020 Raticate +#021 Spearow +#022 Fearow +#056 Mankey (Gold only) +#057 Primeape (Gold only) + +Cut the tree at the entrance. Keep heading east and you'll fight some trainers. + +Picnicker Edna +Level 30 Nidorina (751 EXP) +Level 34 Raichu (888 EXP) +You get: 680G + +Go above her and you'll find another trainer. + +Camper Sid +Level 32 Dugtrio (1048 EXP) +Level 29 Poliwrath (1149 EXP) +Level 29 Primeape (925 EXP) +You get: 580G + +Head back down and right to another trainer. + +Camper Dean +Level 33 Golduck (1230 EXP) +Level 31 Sandslash (1081 EXP) +You get: 620G + +Head right on the green, bottom path for a bit and you'll face a hiker. + +Hiker Sidney +Level 34 Dugtrio (1114 EXP) +Level 32 Onix (739 EXP) +You get: 1024G + +Head back up to Camper Dean, and keep going up and to the right to another +hiker. + +Hiker Tim +Level 31 Graveler (889 EXP) +Level 31 Graveler (889 EXP) +Level 31 Graveler (889 EXP) +You get: 992G + +Head east for your final battle on the route. + +Picnicker Heidi +Level 32 Skiploom (931 EXP) +Level 32 Skiploom (931 EXP) +You get: 640G + +Head further east and you'll end up on the northern part of Route 10. + +|--------| +|Route 10| +|--------| + +Pokémon found on Route 10: +#020 Raticate +#021 Spearow +#022 Fearow +#100 Voltorb +#118 Goldeen (Water) +#119 Seaking (Water) +#125 Electabuzz +#195 Quagsire + +There's a Pokémon Center in the middle of nowhere. The Rock Tunnel is to your +left, but don't enter just yet. Instead, head back up and surf down the river +until you hit a dead end and you'll be at the Power Plant. + +|-----------------| +|Kanto Power Plant| +|-----------------| + +Talk to the power plant employees, including the manager on the right side. +Someone stole the Machine Part powering the Magnet Plan. Some are depressed, and +others are furious. Thievery!! As you try to leave, they'll learn that a +mysterious man in Cerulean that may have done the thievery. Fly back to Cerulean +City. + +|-------------| +|Cerulean City| +|-------------| + +Head into the Cerulean City gym and a Team Rocket member will run into you. +He'll speak in broken English. It's pretty funny. He'll leave the gym. Head up +to Route 24. + +|--------| +|Route 24| +|--------| + +Pokémon on Route 24: + +#063 Abra +#069 Bellsprout +#070 Weepinbell + +You'll find the Team Rocket guy, with his strange grammar, on what was the +Nugget Bridge. He's pretty easy to beat, but what a fun guy. + +Rocket Grunt +Level 30 Golbat (1098 EXP) +You get: 1200G + +After that ridiculously easy victory, he'll tell you to look for the Machine +Part in the middle of the Cerulean Gym. You could go to the gym now, but it +makes more sense to keep walking up to Route 25. + +|--------| +|Route 25| +|--------| + +Pokémon on Route 25: + +#063 Abra +#069 Bellsprout + +Items on Route 25: + +Nugget +Potion +Protein + +If you take the northwest path, you'll find a Potion under a bush. Can you +really trust medication from a random bush? No harm, no foul, I guess. You can +then head east where six schoolyard bullies want to beat you up under the guise +of giving you a prize. + +Schoolboy Dudley +Level 35 Oddish (585 EXP) +You get: 1120G + +Lass Ellen +Level 30 Wigglytuff (700 EXP) +Level 34 Granbull (1296 EXP) +You get: 816G + +Schoolboy Joe +Level 33 Tangela (1173 EXP) +Level 33 Vaporeon (1386 EXP) +You get: 1056G + +Lass Laura +Level 28 Gloom (792 EXP) +Level 31 Pidgeotto (750 EXP) +Level 31 Bellossom (1221 EXP) +You get: 744G + +Before you can even move, there comes the fifth trainer, pouncing on his prey. +Just kidding, he's actually the prey. + +Camper Lloyd +Level 34 Nidoking (1420 EXP) +You get: 680G + +Lass Shannon +Level 29 Paras (435 EXP) +Level 29 Paras (435 EXP) +Level 32 Parasect (877 EXP) +You get: 768G + +Where's the prize? Did they lie to you? That's really mean. Keep going right and +you'll fight some random person who was just waiting for your Pokémon to get +weakened before fighting you. + +Super Nerd Pat +Level 36 Porygon (1002 EXP) +You get: 1152G + +After you beat the Super Nerd, keep going right and that guy will give you a +Nugget. They sell for 5,000G at the Pokemarts. + +Cut the tree above Pat and then grab a free Protein. Head on east to what was +Bill's place in Red, Blue, and Yellow. You'll see that Misty is on a date. Ooh +la la. The guy quickly runs away when he sees you. You could outrun him on a +bike but you're a kind soul who wouldn't do such a thing. But Misty is pissed. +You're a pest now. But she does spot your shiny Johto badges and say that she +would battle you. + +Now, enter Bill's House and you'll find his grandfather there. We don't know +where Bill is, but at least he didn't turn into a Pokémon. If you show his +grandfather certain Pokémon like Growlithe, Lickitung, Oddish, Pichu, or Staryu, +he'll give you the respective rewards of a Fire Stone, an Everstone, a Leaf +Stone, a Thunder Stone, and a Water Stone. + +Now, fly back to Cerulean City. + +|-------------| +|Cerulean City| +|-------------| + +Item in Cerulean City Gym: + +Machine Part + +Heal at the Pokémon Center. Then head into the Cerulean City Gym. This is a +water type gym, so grass and electric type moves work well. Or you can just use +your highest-powered moves throughout the gym. Head on the left path to fight +the first swimmer. + +Swimmer Briana +Level 35 Seaking (1275 EXP) +Level 35 Seaking (1275 EXP) +You get: 700G + +You'll be next to a lake that is four squares (2x2). Press A on the top left +water square and you'll get the Machine Part that the Team Rocket Grunt +previously hid. Now, head on the right path to face another swimmer. + +Swimmer Parker +Level 32 Horsea (568 EXP) +Level 32 Horsea (568 EXP) +Level 35 Seadra (1162 EXP) +You get: 280G + +Move up to take on the final trainer before Misty. + +Swimmer Diana +Level 37 Golduck (1378 EXP) +You get: 740G + +If you need to heal at the Pokémon Center, do so. In any case, you can now fight +Misty for the 11th gym badge, you pest. + +--- +GYM LEADER MISTY +Level 42 Golduck (1566 EXP) +Level 42 Quagsire (1233 EXP) +Level 44 Lapras (2064 EXP) +Level 47 Starmie (2083 EXP) +You get: Cascade Badge, 4,700G +--- + +Strategy VERSUS Golduck: It seems that Misty has finally evolved her Psyduck. +Golduck has a move that can disable one of your Pokémon's four moves. Use your +highest powered move, ideally with your starter. You can also use grass or +electric moves. Don't use fire or water moves. + +Strategy VERSUS Lapras: Use your highest powered move, ideally with your +starter. You can also use grass or electric moves. Don't use fire or water +moves. + +Strategy VERSUS Starmie: Use your highest powered move, ideally with your +starter. You can also use grass, electric, or dark moves, as Starmie is also a +psychic move. Don't use fire, water, or fighting moves. + +Strategy VERSUS Quagsire: Use your highest powered move, ideally with your +starter. You can also use grass or electric moves. Don't use fire or water +moves. Beware of Quagsire's Earthquake if you have Typhlosion. + +After Misty falls, you get your 11th badge and 4,700G. Nice! + +============================================================================== +============================================================================== +============================================================================== + +(c28) +----------------------------------------------- diff --git a/docs/gold-walkthrough/section-22-rock-tunnel-and-celadon-city-gym.txt b/docs/gold-walkthrough/section-22-rock-tunnel-and-celadon-city-gym.txt new file mode 100644 index 00000000..cf7ac10d --- /dev/null +++ b/docs/gold-walkthrough/section-22-rock-tunnel-and-celadon-city-gym.txt @@ -0,0 +1,313 @@ +---- 28 > Rock Tunnel and Celadon City Gym ---- +----------------------------------------------- + +Video Playthrough Link (Route 25, Cerulean City Gym, Lavender Town, & Celadon +City Gym): https://www.youtube.com/watch?v=bxbOG0kR4QE + +|-----------| +|Power Plant| +|-----------| + +Item in Power Plant: + +TM07 Zap Cannon + +Heal at the Pokémon Center and make sure you have a Pokémon in your lineup that +has the HMs Cut, Surf, and Flash. Head east on Route 9 and surf to the Power +Plant again. Head in and talk to the Manager, and he'll give you a reward for +the Machine Part: TM07 Zap Cannon! Now surf back up to Route 10 and enter Rock +Tunnel this time. + +|-----------| +|Rock Tunnel| +|-----------| + +Pokémon in Rock Tunnel: + +Cubone +Geodude +Kangaskhan +Zubat + +Items in Rock Tunnel: + +Iron +PP Up +Revive +TM47 Steel Wing + +You'll want to use HM Flash to get through the Rock Tunnel. Head along the path +to the right and go down the ladder. Then head left. Keep heading left at the +intersection to get an Iron, then head back and up and to the right on the curvy +path. + +Head up the ladder, then head down and right down another ladder. Head up from +there to grab the Revive, then come back down and go left and down for a PP Up. +Head up the ladder at the leftmost part of that section of the "tunnel". Move +down and left and you can grab TM47 Steel Wing from the cliff. Then bike down +and out of the cave and you'll be on the south side of Route 10. + +|--------| +|Route 10| +|--------| + +Welcome to the other side of Route 10. There are two trainers. + +Hiker Jim +Level 35 Machamp (1447 EXP) +You get: 1120G + +Go down and to the left and you'll find one more trainer to battle. + +PoKeFan Robert +Level 33 Quagsire (967 EXP) +You get: 2640G + +Keep going down and you'll be in Lavender Town. + +|-------------| +|Lavender Town| +|-------------| + +Lavender Town is still creepy, but a bit less so. They used to have a Pokémon +Tower in the Red, Blue, and Yellow games on the northeast part of town that's +now the Kanto Radio Tower. You can walk in and talk to the director who thanks +you for saving his job. You're so amazing that you can take out international +criminal organizations and save people's jobs! He will thank you by giving you +an EXPN Card for the Pokegear Radio and you can now tune into Kanto channels. + +You'll also find Mr. Fuji in the Soul House to the south of the Kanto Radio +Tower, and the Name Rater in the southern house. + +|-------| +|Route 8| +|-------| + +Item on Route 8: + +PRZCure Berry + +There are five trainers here. You can pick up a PRZCure Berry when you enter the +route. Otherwise, you can fight a bunch of trainers on your way west. + +Super Nerd Tom +Level 32 Magnemite (609 EXP) +Level 32 Magnemite (609 EXP) +Level 32 Magnemite (609 EXP) +You get: 1024G + +Super Nerd Sam +Level 34 Grimer (655 EXP) +Level 34 Muk (1143 EXP) +You get: 1088G + +You'll now encounter three bikers by the Kanto Pokémon Federation. They sound +scarier than they really are. First up is Biker Zeke, who collects ball-shaped +Pokémon that do chain smoking. + +Biker Zeke +Level 32 Koffing (781 EXP) +Level 32 Koffing (781 EXP) +You get: 1024G + +Biker Harris +Level 34 Flareon (1441 EXP) +You get: 1088G + +Talk to him again and he says "Wiped out by some punk from Johto." Now fight the +next trainer, who has a whole collection of Koffings. You could say that he's a +true Koffing connoisseur. + +Biker Dwayne +Level 27 Koffing (658 EXP) +Level 28 Koffing (684 EXP) +Level 29 Koffing (708 EXP) +Level 30 Koffing (732 EXP) +You get: 960G + +The mighty Kanto Pokémon Federation has quickly gone down. Yay! Now head to +Saffron City and heal at the Pokémon Center. + +|------------| +|Saffron City| +|------------| + +Head to the northwest of town, to the left of Silph Co. Now barge inside that +family's home and walk up the stairs. You'll see Mimic Girl from Red, Blue, and +Yellow. Talk to her and she'll imitate you, then ask for a PokeDoll. If you can +give her the one she wants, you'll get the Rail Pass, which will make travel +back to Johto nearly instant. + +|--------------| +|Vermilion City| +|--------------| + +Now, fly back to Vermilion City. Enter the Pokémon Lovers Club in the west part +of the city. in this city. It's the same one in where you got the Rare Candy. +Instead, talk to the guy near the Clefairy. Turns out the PokeDoll IS the +Clefairy. He'll just give you the doll, no questions asked. What a trusting +person. Now, fly back to Saffron. + +|------------| +|Saffron City| +|------------| + +Re-enter the Mimic Girl's house and up to her room/floor. Talk to her and give +it back, and she'll give you a Magnet Train Pass. How does she even have a +Magnet Train Pass to hand over to you? She's just a little girl! It's for +unlimited use on the Magnet Train that is at the northwest corner of the city, +to the left of that route-changing house. + +|-------| +|Route 7| +|-------| + +Pokémon on Route 7: + +#017 Pidgeotto +#037 Vulpix +#052 Meowth + +Head west from Saffron City to Celadon City by taking Route 7. The Saffron City +guard will check your Pokedex as ID. That's pretty weird, man. Anyway, there are +no trainers on this route, so just walk into Celadon City. There are no guards +on the Celadon City side. They don't hold prejudices against Saffron City +residents like that. Apparently, the Underground Path is also sealed due to all +the hooligan battles. + +|------------| +|Celadon City| +|------------| + +Items in Celadon City: + +Leftovers +TM03 Curse + +Heal at the Pokémon Center. To your left is a building with a front entrance and +a back entrance. It's the Celadon Mansion. It's where you can get Eevee in Red, +Blue, and Yellow. Head up three stairs, then go into the room. Talk to the guy +and he will tell you a scary story. We know it's scary because some biker rolls +down Cycling Road and most importantly, the guy tells you that it is. At the +end, he'll give you TM03 Curse. Now head back down the few stairs and out the +door. If you go through the front entrance, you'll meet the game designers. They +put pixelated versions of themselves in the game. Groovy! + +The Celadon Department Store is in the northwest side of town. Some guy says it +has a similar layout as the Goldenrod City Department Store, but he's not an +observant person, unlike you. You'll find that there are different TMs and some +things are on different floors here. + +Level 2: Potions, Heals, Ultra Balls, Escape Ropes, Repels +Level 3: TM10, TM11, TM17, TM18, TM37 Sandstorm +Level 4: Pokedolls, Mail +Level 5: Stat increase & X Items (temporary stat boosts) +Level 6: Vending Machine heal items + +You can play slots in the middle of town, because it's important to start +teaching 10-year olds how to start gambling. The Celadon Game Corner allows for +a coin exchange for prizes in the building to the right. There are people there, +but you'd want to talk to the two counters to exchange coins for prizes, which +can be TMs or Pokémon: + +TM32 1500 +TM29 3500 +TM15 7500 + +Mr. Mime 3333 +Eevee 6666 +Porygon 9999 + +The Celadon City Restaurant is on the south-central portion of the city. Like +all the best items in Pokémon, you should look in the trash can. You'll get +Leftovers, an excellent item which heals your Pokémon every turn. If you then go +out and head right to a dead end, press A and you'll get a random PP Up! Yay! + +Head to the southwest part of town with a Pokémon that can use the HM01 Cut and +you'll arrive at Erika's gym. An old man lurks outside Erika's gym saying, "This +gym is great! Only girls are allowed here!" He's totally not creepy at all. +You'll face a few trainers in Erika's gym. High-power, fire, and ice type moves +are good, while grass and water types will not be effective. + +Twins Jo & Zoe +Level 35 Victreebel (1432 EXP) +Level 35 Vileplume (1380 EXP) +You get: 700G + +Picnicker Tanya +Level 37 Exeggutor (1680 EXP) +You get: 740G + +"Do you think a girls-only gym is rare? Only girls are allowed here!" + +Lass Michelle +Level 32 Skiploom (931 EXP) +Level 34 Jumpluff (1281 EXP) +Level 33 Hoppip (522 EXP) +You get: 816G + +Head up to face the final minor trainer of the gym. + +Beauty Julia +Level 32 Paras (480 EXP) +Level 32 Exeggcute (672 EXP) +Level 35 Parasect (960 EXP) +You get: 3080G + +The lineup (from Elite 4) that you would have at this point is: + +Level 44 Sudowoodo +Level 45 Dragonair +Level 45 Raticate +Level 47 Typhlosion +Level 47 Gyarados +Level 49 Espeon + +Leave the gym, fly to the Pokémon Center (it's faster) and heal, return to +Julia, then go up to Erika. Save, then it's time to battle! + +--- +GYM LEADER ERIKA +Level 42 Tangela (1494 EXP) +Level 46 Victreebel (1882 EXP) +Level 46 Bellossom (1813 EXP) +Level 41 Jumpluff (1545 EXP) +You get: Rainbow Badge, 4,600G, TM19 Giga Drain +--- + +Erika will have three Full Restores that she can use in battle to heal her +Pokémon back to full health and normal status. + +Strategy VERSUS Tangela: Use your highest-powered attack from your starter. If +you have a high enough level Pokémon with fire or ice attacks, like Typhlosion +or Ho-Oh, you can utilize them. + +Strategy VERSUS Victreebel: Use your highest-powered attack from your starter. +If you have a high enough level Pokémon with fire or ice attacks, like +Typhlosion or Ho-Oh, you can utilize them. + +Strategy VERSUS Bellossom: Bellossom looks like it will happily dance for you, +but it has Synthesis, so it can heal half of its health every turn for quite a +while. It can be pretty annoying. It also has SolarBeam, which is a solid +two-turn grass move that could really damage your Water Pokémon. Use your +highest-powered attack from your starter. If you have a high enough level +Pokémon with fire or ice attacks, like Typhlosion or Ho-Oh, you can utilize +them. + +Strategy VERSUS Jumpluff: Yes, as much as it looks like a water type, it's a +grass type. Use your highest-powered attack from your starter. If you have a +high enough level Pokémon with fire or ice attacks, like Typhlosion or Ho-Oh, +you can utilize them. + +Erika congratulates you and gives you your 12th badge. Yay! You're amazing! Give +yourself a pat on the back, or do a hair flip. TM19 Giga Drain is a good move, +but unfortunately, it's only a 5 PP move. I am deeply saddened by this. I know +you are, too, because how could it not be sad? It's a travesty. Anyway, now walk +out the convoluted maze out the gym. + +============================================================================== +============================================================================== +============================================================================== + +(c29) +------------------------------------------------ diff --git a/docs/gold-walkthrough/section-23-routes-11-19-and-fuchsia-city-gym.txt b/docs/gold-walkthrough/section-23-routes-11-19-and-fuchsia-city-gym.txt new file mode 100644 index 00000000..7c29dc7e --- /dev/null +++ b/docs/gold-walkthrough/section-23-routes-11-19-and-fuchsia-city-gym.txt @@ -0,0 +1,468 @@ +---- 29 > Routes 11-19 and Fuchsia City Gym ---- +------------------------------------------------ + +Video Playthrough Link (Routes 11-13): +https://www.youtube.com/watch?v=bZQxBI2v0As + +Video Playthrough Link (Routes 14-15): +https://www.youtube.com/watch?v=-zeVjtH0UWI + +Video Playthrough Link (Routes 16-19 & Fuchsia City Gym): +https://www.youtube.com/watch?v=YYq_0WosMX4 + +|--------| +|Route 12| +|--------| + +Pokémon on Route 12: + +#072 Tentacool (water) +#129 Magikarp (water) + +Fly to Lavender Town, then go south. If you talk to the fishermen, you'll +distract them from their fishing and they'll fight you with their catches from +their fishing rods. + +Fisher Kyle +Level 28 Seaking (1020 EXP) +Level 31 Poliwhirl (870 EXP) +Level 31 Seaking (1128 EXP) +You get: 1240G + +Fisher Martin +Level 32 Remoraid (534 EXP) +Level 32 Remoraid (534 EXP) +You get: 1280G + +After fighting Fisher Martin, you'll have a chance to take the route westward. +Do so. + +|--------| +|Route 11| +|--------| + +Pokémon on Route 11: + +#019 Rattata +#096 Drowzee +#097 Hypno + +Items on Route 11: + +Berry +Revive + +Follow the curvy road and you'll fight four trainers. Why did the road have to +be so curvy? No idea. You could have at least gotten a roller coaster experience +out of it or something. + +Psychic Herman +Level 30 Exeggcute (630 EXP) +Level 30 Exeggutor (1362 EXP) +Level 30 Exeggcute (630 EXP) +You get: 960G + +Keep going left and you'll run into your old classmate Owen. Did you forget who +he was? Yeah, me too. + +Youngster Owen +Level 35 Growlithe (682 EXP) +You get: 560G + +Young trainer, follow the route up and left and you will find salvation. Or at +least another youngster just like yourself. + +Youngster Jason +Level 33 Sandslash (1152 EXP) +Level 33 Crobat (1441 EXP) +You get: 528G + +If you take the path up and then right, you can grab a Berry and also a Revive +from the bush. Your parents probably told you not to pick up wild berries, and +yet, here we are, disobeying our parents. Nice work. Now, head back towards +Youngster Jason and go further west and you'll face one more trainer. + +Psychic Fidel +Level 34 Xatu (1245 EXP) +You get: 1088G + +Head west and you'll find that a Snorlax is blocking you from entering Diglett's +Cave or reaching Vermilion. Remember this. You'll come back to that later, my +dear friend. Now, head back east to Route 12. + +|--------| +|Route 12| +|--------| + +Items on Route 12: + +Calcium +Super Rod + +Head down, then talk to the fisherman. + +Fisher Stephen +Level 25 Magikarp (106 EXP) +Level 25 Magikarp (106 EXP) +Level 31 Qwilfish (663 EXP) +Level 31 Tentacruel (1360 EXP) +You get: 1240G + +Head south and go into the house. Talk to the guy who is the Fishing Guru's +younger brother. Answer Yes to his question and you'll get a Super Rod! Woot. +Anyway, keep heading down Route 12. + +Fisher Barney +Level 30 Gyarados (1375 EXP) +Level 30 Gyarados (1375 EXP) +Level 30 Gyarados (1375 EXP) +You get: 1200G + +Head down the path and you'll see an item that is blocked by a tree. Cut down +the tree so you can get a single item: Calcium. I'm sure that was worth all the +environmental damage you just caused. Now, head south onto Route 13. + +|--------| +|Route 13| +|--------| + +Pokémon on Route 13: + +#017 Pidgeotto +#030 Nidorina +#187 Hoppip + +Head south. Fight the left one first. + +Bird Keeper Perry +Level 34 Farfetch'd (684 EXP) +You get: 816G + +Then the right guy. + +Bird Keeper Bret +Level 32 Pidgeotto (774 EXP) +Level 32 Fearow (1110 EXP) +You get: 768G + +Now the left guy. It's like a dance. Left, right, left. It takes two to tango. +This third trainer hoards mice. + +PoKeFan Joshua +Level 23 Pikachu (403 EXP) +Level 23 Pikachu (403 EXP) +Level 23 Pikachu (403 EXP) +Level 23 Pikachu (403 EXP) +Level 23 Pikachu (403 EXP) +Level 23 Pikachu (403 EXP) +You get: 1840G + +Head around that trainer, then head left. + +PoKeFan Alex +Level 29 Nidoking (1210 EXP) +Level 29 Slowking (1018 EXP) +Level 29 Seaking (1056 EXP) +You get: 2320G + +Head left again in the weird maze to face a hiker. + +Hiker Kenny +Level 27 Sandslash (942 EXP) +Level 29 Graveler (832 EXP) +Level 31 Golem (1174 EXP) +Level 29 Graveler (832 EXP) +You get: 928G + +|--------| +|Route 14| +|--------| + +Pokémon on Route 14: + +#030 Nidorina +#195 Quagsire + +Welcome to Route 14. If you need to heal on HP and PP, feel free to fly back to +Lavender Town, heal at the Pokemon Center, and then bike back down. Otherwise, +you can keep going. You'll spot a trainer as you ride down. Just above him, +there's a tree that can be cut. Cut it and go left for a fight. + +PoKeFan Trevor +Level 33 Psyduck (565 EXP) +You get: 2640G + +Cut the tree above him and you have a chance to trade a Chansey for an +Aerodactyl with the female trainer there. You can also try to catch wild Pokémon +in this small patch of grass. Otherwise, head right to face the trainer you +skipped. + +PoKeFan Carter +Level 29 Bulbasaur (397 EXP) +Level 29 Squirtle (409 EXP) +Level 29 Charmander (403 EXP) +You get: 2320G + +Head down to face... + +Bird Keeper Roy +Level 29 Fearow (1006 EXP) +Level 35 Fearow (1215 EXP) +You get: 840G + +Head left and cut the tree, onto... + +|--------| +|Route 15| +|--------| + +Pokémon on Route 15: + +#017 Pidgeotto +#030 Nidorina +#033 Nidorino + +Cut the tree on your way west. Grab the PP Up as you ride west, then bike back +to where you cut the tree, go down, and then go west to fight six straight +trainers. You discovered a whole line of teachers and schoolboys on a field +trip. They're way too excited to get into schoolyard fights with you. + +Schoolboy Johnny +Level 29 Bellsprout (522 EXP) +Level 33 Victreebel (1350 EXP) +Level 31 Weepinbell (1002 EXP) +You get: 1056G + +Teacher Colette +Level 36 Clefairy (523 EXP) +You get: 2592G + +Schoolboy Billy +Level 27 Paras (405 EXP) +Level 27 Paras (405 EXP) +Level 27 Poliwhirl (757 EXP) +Level 35 Ditto (457 EXP) +You get: 1120G + +Teacher Hillary +Level 32 Aipom (643 EXP) +Level 36 Cubone (670 EXP) +You get: 2592G + +Schoolboy Tommy +Level 32 Xatu (1171 EXP) +Level 34 Alakazam (1354 EXP) +You get: 1088G + +Schoolboy Kipp +Level 27 Voltorb (595 EXP) +Level 27 Magnemite (514 EXP) +Level 31 Voltorb (684 EXP) +Level 31 Magneton (1069 EXP) +You get: 992G + +|------------| +|Fuchsia City| +|------------| + +Item in Fuchsia City: + +Burnt Berry + +You'll learn from various people that the Safari Zone in Fuchsia City has closed +since three years ago when Red, Blue, and Yellow took place. The warden fled the +premises. If you go to the building, even the door is gone. What happened to it? +You can still pick up a Burnt Berry as a consolation prize in the northwest part +of town. + +You can heal at the Pokémon Center and go fight the gym. Or you can heal and +then get more experience first on Routes 16, 17 (Cycling Road), and 18. The +choice is up to you. I'm not your teacher. Well, I guess I might be, if you're +reading this guide. I'd suggest checking out Routes 16-18 to check it out, if +nothing else. The new Kanto region is worth exploring. + +|--------| +|Route 16| +|--------| + +Pokémon on Route 16: + +#022 Fearow +#088 Grimer + +Fly to Celadon City and go west. You can use Cut on the tree to reach it; it's +north of the route-changing building. If you enter the one building there and +come back out, the tree will grow back and you'll have to cut it again. Guess +that environmental destruction doesn't actually last that long in this world. + +|--------| +|Route 17| +|--------| + +Pokémon on Route 17: + +#022 Fearow +#088 Grimer + +You could skip Route 16 and go straight through the route-changing building to +Route 17. This is Cycling Road, so get on your bike. Remember you need to +advertise for your friend, the Goldenrod City Bike Shop dude. It's still +possible to bike uphill but you're better off biking downhill. Bike down and +you'll run into your first biker. Possibly literally. It's okay, because +trainers in this world can get hit by a bike and hold you back like they're a +wall. + +Biker Riley +Level 34 Weezing (1260 EXP) +You get: 1088G + +After this point, there will be branching left and right paths. Head down the +left route and you'll fight your next biker. + +Biker Glenn +Level 28 Koffing (684 EXP) +Level 30 Magmar (1072 EXP) +Level 32 Weezing (1185 EXP) +You get: 1024G + +You'll get another chance to stay left or go right. When you see a bridge across +the river to the right, cross it and fight the next biker. + +Biker Joel +Level 32 Magmar (1144 EXP) +Level 32 Magmar (1144 EXP) +You get: 1024G + +If you talk to him afterwards, he says, "I look cool, but I'm weak, so I'm not +really cool." I'm not sure that's how the coolness factor works but I have no +reason to question him. Now, keep rolling down until you run into the last +trainer. + +Biker Charles +Level 30 Koffing (732 EXP) +Level 30 Weezing (1111 EXP) +Level 30 Charmeleon (912 EXP) +You get: 960G + +Roll down to the end of Cycling Road and you'll be on Route 18. + +|--------| +|Route 18| +|--------| + +Pokémon on Route 18: + +#022 Fearow +#088 Grimer + +Head east and you'll face some guy that...hey, he almost has my name! + +Bird Keeper Bob +Level 34 Noctowl (1179 EXP) +You get: 816G + +He tells you that the shortcut to Celadon is the uphill Cycling Road. That +doesn't sound wise. Now head down into the grass to fight the last trainer on +the road to Fuchsia City. + +Bird Keeper Boris +Level 30 Doduo (616 EXP) +Level 32 Dodrio (1083 EXP) +Level 28 Doduo (576 EXP) +You get: 768G + +Now head east into Fuchsia City! + +|------------| +|Fuchsia City| +|------------| + +Fuchsia is such a hard color to spell. I always think the s is before the c. +Anyway, you can go to the southwest part of town again to heal at the Pokémon +Center, and then enter the gym. Head in and you'll find that everyone looks the +same. They're all imitating Janine. Janine's located in the middle-left side of +the gym. But it's good to battle everyone for the experience and we can sort of +see the walls. + +High-powered moves and psychic moves are good for Janine's gym. Head up and talk +to the first trainer. + +Lass Linda +Level 30 Bulbasaur (411 EXP) +Level 34 Venusaur (1515 EXP) +Level 32 Ivysaur (966 EXP) +You get: 816G + +Now head to the gym entrance (but don't exit the gym) and head to the far right +and up. Then, fight that trainer. Ignore the fact that she says she's Janine. +She isn't. + +Picnicker Cindy +Level 36 Nidoqueen (1495 EXP) +You get: 720G + +Head up then left, then talk to that trainer. It should be obvious by now that +the real Janine wouldn't be spinning around. :P + +Camper Barry +Level 36 Nidoking (1503 EXP) +You get: 720G + +Now head down and face the fourth trainer. + +Lass Alice +Level 30 Gloom (847 EXP) +Level 34 Arbok (1071 EXP) +Level 30 Gloom (847 EXP) +You get: 720G + +At this point, you'll see the real Janine. Save, and heal if you need to. Janine +is Koga's daughter, and has taken over for Dad after he joined the Elite Four. +Janine fights like a weaker version of Koga. + +--- +GYM LEADER JANINE +Level 36 Crobat (1573 EXP) +Level 33 Ariados (946 EXP) +Level 39 Venomoth (1152 EXP) +Level 36 Weezing (1333 EXP) +Level 36 Weezing (1333 EXP) +You get: Soul Badge, 3,900G, TM06 (Toxic) +--- + +Strategy VERSUS Crobat: Use the highest-powered move from your starter, keeping +in mind that Meganium will be weak against poison moves in this gym. Also +acceptable are psychic moves which have a type advantage against poison. +Crobat's also a flying type, so you can also use ice and electric moves like +Blizzard or Thunder. + +Strategy VERSUS Espeon: Unleash psychic from Espeon. Use the highest-powered +move from your starter. Also acceptable are psychic moves which have a type +advantage against poison. + +Strategy VERSUS Ariados: Use the highest-powered move from your starter, keeping +in mind that Meganium will be weak against poison moves in this gym. Also +acceptable are psychic moves which have a type advantage against poison. Ariados +is also a bug, so fire moves from a Pokémon like Ho-Oh (e.g., Sacred Fire) or +Typhlosion (e.g., Flame Wheel) will be super effective. + +Strategy VERSUS Venomoth: Use the highest-powered move from your starter, +keeping in mind that Meganium will be weak against poison moves in this gym. +Also acceptable are psychic moves which have a type advantage against poison. +Venomoth is also a bug, so fire moves from a Pokémon like Ho-Oh (e.g., Sacred +Fire) or Typhlosion (e.g., Flame Wheel) will be super effective. + +Strategy VERSUS Weezings: Use the highest-powered move from your starter, +keeping in mind that Meganium will be weak against poison moves in this gym. +Also acceptable are psychic moves which have a type advantage against poison. + +Thirteen gyms down, three to go. You have defeated Janine. Congrats! She'll even +give you TM06 Toxic, which is a poison that zaps enemy HP. She hopes to become +better than Koga and you. + +============================================================================== +============================================================================== +============================================================================== + +(c30) +------------------------------------------ diff --git a/docs/gold-walkthrough/section-24-snorlax-and-pewter-city-gym.txt b/docs/gold-walkthrough/section-24-snorlax-and-pewter-city-gym.txt new file mode 100644 index 00000000..5bcf4f96 --- /dev/null +++ b/docs/gold-walkthrough/section-24-snorlax-and-pewter-city-gym.txt @@ -0,0 +1,192 @@ +---- 30 > Snorlax and Pewter City Gym ---- +------------------------------------------ + +Video Playthrough Link (Snorlax, Route 2, Pewter City, & Tin Tower): +https://youtu.be/l9CjbBMD7Uk + +Video Playthrough Link (Ho-Oh, Pewter City Gym, Routes 1-4, & Pallet Town): +https://www.youtube.com/watch?v=pFgyFZqai_I + +Anyway, head south onto Route 19... + +|--------| +|Route 19| +|--------| + +You'll find your way blocked. What happened? Well, boulders have washed ashore +in a recent event. I'll tell you more about it later, it's a spoiler kinda. Now +fly back to Vermilion City. + +|-------| +|Snorlax| +|-------| + +Using your upgraded radio, go east to Diglett's Cave. Keep around those four +Ultra Balls from before, you'll need them. When you're up to the Snorlax, save +(wow, I haven't said that for ages). You'll find out that Snorlax is sleeping. +Wake it up by going into your Pokegear option and going to your radio. Turn the +radio up to 20, the maximum frequency, then exit the Pokegear menu. Then press A +and talk to Snorlax. That maximum frequency is the PoKeFlute, so the Snorlax +will wake up in an angry mood. + +Keep it down at red HP or low yellow HP and throw as many Ultra Balls at it. If +you can use a status move like paralysis on it (e.g. Dragonair's Thunder Wave), +that helps, or catch Snorlax while it's still asleep from Rest. But it might +take a few resets. + +Snorlax is a great Pokémon to catch. It is one of the best Pokémon in the game, +and it's Level 50. You'll be able to add this to your team immediately. It is +highly defensive and bulky, and it can sleep for two turns using Rest to heal +all of its HP and status effects. Snorlax also comes with Leftovers, an amazing +item that heals a bit of HP after every attack. You can replace its Rollout move +with HM Strength to inflict extra damage. + +After the battle with Snorlax, the route is cleared to Diglett's Cave and +through Route 8. Go back to the Pokémon Center in Vermilion City to withdraw the +Snorlax so that it is part of your lineup. It already has a Leftovers attached! +Now, enter the cave entrance that Snorlax blocked. This is Diglett's Cave. + +|--------------| +|Diglett's Cave| +|--------------| + +Pokémon in Diglett's Cave: + +#050 Diglett +#051 Dugtrio + +There are no trainers in here, but you can capture both Diglett and Dugtrio here +if you'd like. Head down the ladder, then ride your bike up and then up that +ladder. Exit the door and you'll be on Route 2. + +|-------| +|Route 2| +|-------| + +Items on Route 2: + +Carbos +Dire Hit +Elixir +Nugget +PSNCure Berry + +Welcome to the western part of Kanto. Head up and right to grab the Carbos. +Then, go down to the house and talk to the guy inside for a Nugget for being a +visitor. Cut the tree to the south and shift past the route-changing house. +You'll still be in Route 2. Keep heading down and you'll get an Elixir. Then, +cut the tree just left of it and head north and you'll face a trainer. + +Bug Catcher Rob +Level 32 Beedrill (1089 EXP) +Level 32 Butterfree (1096 EXP) +You get: 512G + +Keep moving up. + +Bug Catcher Doug +Level 34 Ariados (975 EXP) +You get: 544G + +Continue moving up and you can get Dire Hit, a PSNCure Berry, and even a Max +Potion in Viridian Forest. Keep moving up. + +Bug Catcher Ed +Level 30 Beedrill (1021 EXP) +Level 30 Beedrill (1021 EXP) +Level 30 Beedrill (1021 EXP) +You get: 480G + +If you keep heading north, you'll end up in Pewter City. + +|-----------| +|Pewter City| +|-----------| + +Items in Pewter City: + +Ice Berry +Mint Berry +Rainbow/Silver Wing + +Welcome to Pewter City! Talk to the man just to the right of the Pokemart and +you'll get the Silver Wing, which will allow you to go back to +Johto and catch the other Pokémon (Lugia or Ho-Oh) that you haven't already +caught. Now heal and deposit any items you'd like. The Pokémon Center has a lady +that allows you to trade a Gloom for a Rapidash. The northeast side of town has +an Ice Berry and a Mint Berry. And the northwest side has the former Museum of +Science which is now closed. They even filled the doorway in. So sad. + +If you have a team that focuses on leveling up your starter, you might have +something like this: + +Level 77 Feraligatr +Level 50 Snorlax +Level 40 Lugia +Level 33 Donphan (Silver) +Level 30 Gyarados +Level 28 Raticate + +If you're fielding more of a balanced lineup, you might have a lineup like this: + +Level 51 Espeon +Level 50 Snorlax +Level 48 Starter Evolution +Level 47 Gyarados +Level 46 Dragonair +Level 46 Sudowoodo + +For this gym, you'll ideally want to use your starter (if Meganium or +Feraligatr) for this gym, and use their highest-powered grass or water moves. + +Camper Jerry +Level 37 Sandslash (1291 EXP) +You get: 740G + +Save, heal at the Pokemon Center and come back, then talk to Brock to fight him +for the 14th gym badge. Rock types may have a lot of defense, but not against +grass or water moves. If you have Typhlosion as your starter and a lower-level +Lugia (from SoulSilver), Brock may take you a bit longer to beat. All of Brock's +Pokémon are 10-11 levels higher than in the original. + +--- +GYM LEADER BROCK +Level 41 Graveler (1176 EXP) +Level 42 Omastar (1791 EXP) +Level 41 Rhyhorn (1185 EXP) +Level 44 Onix (1017 EXP) +Level 42 Kabutops (1809 EXP) +You get: Boulder Badge, 4,200G +--- + +Strategy VERSUS Graveler: Use your highest-powered move from your highest level +Pokémon. Ideally, this would be a grass or water move (Razor Leaf/SolarBeam or +Surf/Hydro Pump) rather than a fire or normal move. + +Strategy VERSUS Omastar: Use your highest-powered move from your highest level +Pokémon. Ideally this is a grass move, not a fire or normal move. Omastar is a +water and rock type, but a water move will still do regular damage against it. + +Strategy VERSUS Rhyhorn: Use your highest-powered move from your highest level +Pokémon. Ideally, this would be a grass or water move (Razor Leaf/SolarBeam or +Surf/Hydro Pump) rather than a fire or normal move. + +Strategy VERSUS Onix: Use your highest-powered move from your highest level +Pokémon. Ideally, this would be a grass or water move (Razor Leaf/SolarBeam or +Surf/Hydro Pump) rather than a fire or normal move. + +Strategy VERSUS Kabutops: Use your highest-powered move from your highest level +Pokémon. Ideally this is a grass move, not a fire or normal move. Omastar is a +water and rock type, but a water move will still do regular damage against it. + +There you have it: 14 badges! You are then given the Boulder Badge and some +money. The badge makes your Pokémon even more powerful. Like the other gym +leaders, Brock is a little upset that you beat his "hard-rock defense, but happy +to have battled you. Now exit the gym and heal at the Pokémon Center. + +============================================================================== +============================================================================== +============================================================================== + +(c31) +---------------------------------------------- diff --git a/docs/gold-walkthrough/section-25-lugia-gold-and-ho-oh-silver.txt b/docs/gold-walkthrough/section-25-lugia-gold-and-ho-oh-silver.txt new file mode 100644 index 00000000..56263043 --- /dev/null +++ b/docs/gold-walkthrough/section-25-lugia-gold-and-ho-oh-silver.txt @@ -0,0 +1,179 @@ +---- 31 > Lugia (Gold) and Ho-Oh (Silver) ---- +---------------------------------------------- + +Video Playthrough Link (Ho-Oh, Pewter City Gym, Routes 1-4, & Pallet Town): +https://www.youtube.com/watch?v=pFgyFZqai_I + +Video Playthrough Link (Seafoam Islands Gym, Routes 19-22, 28, & Viridian City +Gym): https://youtu.be/wgql5WCnx4g + +Now, you have a chance to snatch the other Pokémon you missed earlier...Lugia or +Ho-oh. Head back through with the Magnet Train back to Johto, then go to +wherever you need to go. + +|----------------------------| +|Whirl Islands - Gold Version| +|----------------------------| + +Fly to Olivine City. Make sure you have a Pokémon on your team that has Flash, +like Bellsprout. Also make sure you teach HM06 Whirlpool to a Pokémon. I suggest +Tentacool or Krabby, who can use Strength, Surf, Whirlpool, and Waterfall. +You'll need eight badges in order to use Whirlpool. + +Pokémon at the Whirl Islands: + +#041 Zubat +#086 Seel +#098 Krabby +#116 Horsea +#117 Seadra + +Items at the Whirl Islands: + +Calcium +Escape Rope +Guard Specs +Max Revive x2 +Ultra Ball + +Surf southwest and west until you reach the northeast cave of the Whirl Islands. +I suggest using a Max Repel to reduce the number of Tentacool encounters on the +way there. The whirlpool is just northwest of where you fought Swimmer Kara. You +can press A on any whirlpool to remove it temporarily from the map. It's +incredible how much stamina these swimmers have, just staying in the water days +after you last fought them. + +Once you find the northeast cave entrance, enter it and you'll see darkness. Use +HM Flash, then head up and leap the ledge to the right on that bike. You'll see +a Ultra Ball on the cliff. To get to it and continue on your journey, leap down +another ledge to the right, go up the steps, grab the Max Revive, and head down +the ladder. Head south, then jump to the left. Keep jumping down and you'll get +an Escape Rope. Woo! + +Now go down and then left, then up the ladder. You can now head out the door to +breathe, but that's pointless. Why would you do that? Instead, surf east, then +grab the Guard Specs. Then head down the ladder, head up along the path, head +down, and then up the cliff. It's like a roller coaster, I know. + +There's yet another item here. It's a Calcium. Now, on that cliff, head down to +the ladder and take it. Now, go down out the door. That's most of the items in +the Whirl Islands. + +Now that you've gone from the northeast to the southeast portion of the Whirl +Islands, go counterclockwise and surf your way southwest out of the reefs and +then Whirlpool out. Head east, then north, and you'll find Swimmer Kara again. +Take that Whirlpool out and head back into the entrance that you originally came +from. + +Use Flash again. Drop down the first cliff again, but this time, drop down the +top cliff rather than the bottom cliff. Then head down the ladder. Continue +along the path south and head down that ladder. Grab the Max Revive to the left +of you, then surf south. There are two items on the cliff but you can't reach +those. + +Instead, surf down the waterfall, then head left and through the door. Head in, +and surf north to Lugia. Save before you interact with Lugia. I suggest that you +save before you fight him because this is the only chance you'll be able to get +Lugia in the entire game. If you knock it out on a critical hit, lose all your +Pokémon and black out, or run out of balls to throw at it, you would want to +return to your save and retry. + +Because the catch rate is only about 2% at low health, even with Ultra Balls, +this may involve a dozen or more resets. Ultimately, you'll want to maximize +your chances by throwing many Ultra Balls at it. You'll want to do so when it is +lower on HP (yellow or red on the HP meter), and ideally when it is asleep. + +Focus on using Ultra Balls when Lugia is low on health, even when it could knock +out your Pokémon. Use Revives and Hyper Potions as is helpful to keep your +Pokémon alive to keep throwing balls at it. You can slightly increase your catch +chances if you put Lugia to sleep or paralyze it. + +Thard_Verad adds, "Lugia is just heavy enough for Heavy Balls to have higher +catch rates than Ultra Balls. Give those a try." + +Catch it, then use the Escape Rope you grabbed earlier to get out of here. + +|--------------------------| +|Tin Tower - Silver Version| +|--------------------------| + +Pokémon in Tin Tower: + +#019 Rattata + +Go back to Ecruteak City and then go into the Bell Tower entry house. Talk to +the man and he'll let you through because you have the Ecruteak City Badge. Then +follow up the trail to the tower itself. + +Go up and to the left and another bald man will let you through because you have +the beautiful Rainbow Wing. You can then go up the ladder. Keep going and wild +Rattatas will start converging on you like zombies. Consider a Max Repel or two +to drive away all the zombies. Then go up the ladder, hop left for a Full Heal, +then hop all the right. Then hop all the way left, then keep hopping right. On +the middle platform, you might get stuck a bit because you don't see the next +jump easily, but it's the second square from the top. You'll make it to the +northeast side and you can go up the ladder. You are now a true bunny. Are you a +bad bunny? + +On the next level, jump down, left, down, left, down, down to get to the Ultra +Ball. Jump right, down, left, down, down, and you'll get to a PP Up. If you go +up the ladder, it won't get you anywhere, so no need to do that. You can +continue with jumps left (x5), down, right (x2), down, left (x2), down, left +(x2) to get an Escape Rope. You'll be next to another ladder. That ladder will +also take you nowhere. Jump right, right, left, left, and you'll be on the +northwest side where you do want to take that ladder up. + +On the next level, jump right twice, go all the way to the right and down, jump +down, left, left, and you'll get a Rare Candy. You can then return to the +previous level's bottom right corner and go all the way back up to the top left +corner to get back up to the beginning of the level. Then jump right and right +again, then down, left, down (x2), and take the right down (x4) up to the next +level (Left down takes you back to the bottom left ladder from the previous +level). + +On the next level, go up and you'll get a Max Potion. Then go right across the +bridge, up, and then left across the upper (not middle) bridge to get a Full +Heal. Then hop down twice to get up the next ladder. + +On the next level, go up, go through the next two warps, jump on the top side +left (x8), down (x3), right (x3), and through that warp to get to a Max Elixir. +Go back through the warp and jump right (x4) to return to the start. Warp back +through the top right warp and then back through the other warp you went +through. You'll be back to the very start of this paragraph. Hop to the right +(x4) to get a Max Revive. Then hop down (x5), go left and down, warp, cross the +bridge down, and warp again. + +Go left and you'll get a HP Up. Keep going left and down and warp again. Then +cross the bridge up left and take the warp. Go up the ladder, cross the planks +down, then go up the ladder and you'll be in front of a Level 70 Ho-Oh. I +suggest that you save before you fight Ho-Oh because this is the only chance +you'll be able to get Ho-oh in the entire game. If you knock it out on a +critical hit, lose all your Pokémon and black out, or run out of balls to throw +at it, you would want to return to your save and retry. + +The Ho-Oh in-game music gives off great vibes. As we all know, the vibes are +what matters. We must have the vibes. Ho-Oh is a fire/flying type Pokemon with +Safeguard and Ancient Power (damage w/ stat boosts), Punishment, and Sacred +Fire. Use your highest level Pokémon to whittle down Ho-Oh's HP first without +knocking it out, then try to throw as many Ultra Balls as you can at it. + +Because the catch rate is only about 2% at low health, even with Ultra Balls, +this may involve a dozen or more resets. Ultimately, you'll want to maximize +your chances by throwing many Ultra Balls at it. You'll want to do so when it is +lower on HP (yellow or red on the HP meter), and ideally when it is asleep. Use +Revives and Hyper Potions as is helpful to keep your Pokémon alive to keep +throwing balls at it. However, all things even, focus on using Ultra Balls when +Ho-Oh is low on health, even when it could knock out your Pokémon, to increase +the number of chances yo have. You can slightly increase your catch chances if +you put Ho-Oh to sleep or paralyze it. + +After you catch Ho-Oh, go back down the stairs and then you can use an Escape +Rope to leave. Then, ride the Magnet Train back to Kanto and fly back to Pewter +City. + +============================================================================== +============================================================================== +============================================================================== + +(c32) +---------------------------------------- diff --git a/docs/gold-walkthrough/section-26-mount-moon-and-routes-1-4.txt b/docs/gold-walkthrough/section-26-mount-moon-and-routes-1-4.txt new file mode 100644 index 00000000..481cb91f --- /dev/null +++ b/docs/gold-walkthrough/section-26-mount-moon-and-routes-1-4.txt @@ -0,0 +1,232 @@ +---- 32 > Mount Moon and Routes 1-4 ---- +---------------------------------------- + +|-------| +|Route 3| +|-------| + +Pokémon on Route 3: + +#019 Rattata +#023 Ekans +#024 Arbok + +As you follow the path east on Route 3, you'll face four trainers. + +Youngster Warren +Level 35 Fearow (1215 EXP) +You get: 560G + +Head up, then right to... + +Youngster Jimmy +Level 33 Raticate (819 EXP) +Level 33 Arbok (1039 EXP) +You get: 528G + +Head on right to... + +Firebreather Otis +Level 29 Magmar (1036 EXP) +Level 32 Weezing (1185 EXP) +Level 29 Magmar (1036 EXP) +You get: 1392G + +Head right and along the path, then up to... + +Firebreather Burt +Level 32 Koffing (781 EXP) +Level 32 Slugma (534 EXP) +You get: 1536G + +Before you head into Mount Moon, heal back at the Pewter City Pokémon Center, +come back, and then save, because you're about to have another major battle. + +|----------| +|Mount Moon| +|----------| + +Pokémon at Mt. Moon: + +#041 Zubat +#046 Parus +#074 Geodude + +When you get to Mt. Moon, you will encounter your rival for the first time in a +long time. This may or may not be the final time you battle him, but he has +levelled up his Pokémon and finally has six Pokémon. At this point, you can use +any high-powered moves from your highest-level Pokémon that the Rival's Pokémon +doesn't resist, and you'll be fine. + +|------------------------------------------| +|Rival | +|Level 41 Sneasel (1159 EXP) | +|Level 42 Golbat (1539 EXP) | +|Level 45 Starter Evolution (2005-2025 EXP)| +|Level 41 Magneton (1414 EXP) | +|Level 43 Gengar (1750 EXP) | +|Level 43 Alakazam (1713 EXP) | +|You get: 4500G | +|------------------------------------------| + +Strategy VERSUS Sneasel: Sneasel is a dark type, so use the highest-powered +attack on your highest-level Pokémon that isn't a psychic move. + +Strategy VERSUS Golbat: Golbat is a poison and flying type. Use the +highest-powered attack on your highest-level Pokémon that isn't a ground move. +Golbat is a flying type that resists ground moves. Electric moves will be super +effective. Beware of Confuse Ray, as that will confuse your Pokémon and take +away attack opportunities. + +Strategy VERSUS Starter Evolution: The starter evolution will have the type +advantage on your starter, so don't use same-type attacks from your own starter. +Use the most high-powered attack on your highest-level Pokémon (likely Strength +or Body Slam). Or you can utilize Lugia/Ho-Oh to do the requisite water damage +against Typhlosion or fire damage against Meganium. + +Strategy VERSUS Magneton: Use the most high-powered attack on your highest-level +Pokémon. Magneton does resist most types of moves due to its steel typing, but +fire and fighting moves would be strong against it. + +Strategy VERSUS Gengar: Gengar is a ghost and poison type. Use the most +high-powered attack on your highest-level Pokémon that isn't a normal or +fighting move, as Gengar will completely resist those attacks as a ghost type. + +Strategy VERSUS Alakazam: Alakazam is a pure psychic type. Use the most +high-powered attack on your highest-level Pokémon that isn't a fighting or +psychic move. + +Your rival's not nearly as confident as he was at the beginning of the game, +after losing to you over and over. Save after you beat the Rival, and heal back +at a Pokémon Center if that's useful. There are two ways past Mount Moon. If you +go right from your Rival battle, up the ladder, and then out, you can go to a +shop outside that sells Fresh Water, Soda Pop, and Lemonade. If you then go down +into another cave entrance and down the ladder, you'll make your way through. + +Alternatively, if you go down from the Rival battle, then to the right, and go +out that door, you'll end up in the same spot as going the path above. Either +way, you can face up to three trainers on your east on Route 4. + +Thard_Verad adds, "Monday nights at Mt. Moon, you might catch Clefairy dancing +at the lake. Bring Rock Smash." Also, "After you defeat Silver at Mt. Moon, you +can challenge him at Indigo Plateau once a day on Monday and Wednesday. You can +also visit Dragon Den on Tuesday or Thursday and see him training." + +|-------| +|Route 4| +|-------| + +Pokémon on Route 4: + +#019 Rattata +#021 Spearow +#024 Arbok + +Item on Route 4: + +HP Up + +Route 4 has three trainer battles as you head east. + +Picnicker Hope +Level 34 Flaaffy (852 EXP) +You get: 680G + +Bird Keeper Hank +Level 12 Pidgey (141 EXP) +Level 34 Pidgeot (1252 EXP) +You get: 816G + +Picnicker Sharon +Level 31 Furret (769 EXP) +Level 33 Rapidash (1357 EXP) +You get: 660G + +Head east, then up to grab the HP Up. Now, you can fly back to Pewter City to +heal at the Pokémon Center, head south onto Route 2 and south through Viridian +Forest, and you'll be in Viridian City. + +|-------------| +|Viridian City| +|-------------| + +North from the Pokémon Center is the One-on-One Trainer House. This is probably +your easiest way of getting experience every day. You can go in here every day +to fight Trainer Cal, or a placeholder for Cal. Simply go downstairs in that +house to do so. Head left after you go downstairs and the lady will talk to you. +Say Yes and you can battle Cal after you go down. He will have the final +evolutions of the three starters. + +Thard_Verad adds, "Pokemon Trainer Cal is a placeholder. If you Mystery Gift +with anyone, they can show up at the Trainer House instead." + +Pokémon Trainer Cal +Level 50 Meganium (2227 EXP) +Level 50 Typhlosion (2238 EXP) +Level 50 Feraligatr (2250 EXP) +You get: 5000G + +Strategy VERSUS Meganium: Meganium is a grass type. Avoid using grass or water +moves. Use fire or flying moves from Typhlosion, Ho-Oh, or Lugia against +Meganium. Keep in mind that Meganium has Toxic, and Toxic can inflict poison +damage on your Pokémon every turn. + +Strategy VERSUS Typhlosion: Typhlosion is a fire type and its best move is +Flamethrower, although its fighting-move Focus Blast also packs a punch. Avoid +using fire or grass moves. Use water moves, like Surf, Waterfall, Hydro Pump +from Feraligatr, Lugia, or Gyarados. Rock moves like Ho-Oh's AncientPower can +also be effective. + +Strategy VERSUS Feraligatr: Feraligatr is a strong water type. Avoid using fire +or water moves. Use grass or electric moves against Feraligatr. Keep in mind +that Feraligatr can use Ice Punch against Meganium and all the flying +legendaries (Ho-Oh, Lugia, and others that you catch later). Additionally, +Feraligatrs water moves can still be effective against electric type Pokémon +despite being weak to electric attacks. + +Then head south onto Route 1 towards Pallet Town. + +|-------| +|Route 1| +|-------| + +Pokémon on Route 1: + +#016 Pidgey +#162 Furret + +Item on Route 1: + +Bitter Berry + +Unlike in Red, Blue, and Yellow, there are two Pokémon trainers. You can grab +the Bitter Berry on the way down to fight the two trainers. + +Schoolboy Danny +Level 31 Jynx (909 EXP) +Level 31 Electrabuzz (1035 EXP) +Level 31 Magmar (1108 EXP) +You get: 992G + +Cooltrainer Quinn +Level 38 Ivysaur (1147 EXP) +Level 38 Starmie (1684 EXP) +You get: 1824G + +Now head south into Pallet Town! + +|-----------| +|Pallet Town| +|-----------| + +Talk with Professor Oak in his lab (the biggest building). He notices your Kanto +badges. He tells you that he'll give you a prize if you get all the badges, so +remember that. The houses for Red and Blue are here. You can now fly to Pallet +Town. Now, surf south onto Route 21. + +============================================================================== +============================================================================== +============================================================================== + +(c33) +------------------------------------------------------ diff --git a/docs/gold-walkthrough/section-27-cinnabar-island-and-seafoam-islands-gym.txt b/docs/gold-walkthrough/section-27-cinnabar-island-and-seafoam-islands-gym.txt new file mode 100644 index 00000000..53be13e0 --- /dev/null +++ b/docs/gold-walkthrough/section-27-cinnabar-island-and-seafoam-islands-gym.txt @@ -0,0 +1,133 @@ +---- 33 > Cinnabar Island and Seafoam Islands Gym ---- +------------------------------------------------------ + +|--------| +|Route 21| +|--------| + +Pokémon on Route 21: + +#072 Tentacool +#114 Tangela + +Surf south on Route 21. There are a few trainers. Nikki, an avid Seel +enthusiast, wants you to put sunblock on her. + +Swimmer Nikki +Level 28 Seel (600 EXP) +Level 28 Seel (600 EXP) +Level 28 Seel (600 EXP) +Level 28 Dewgong (1056 EXP) +You get: 560G + +There's a fisherman here. He used his Super Rod to catch you, and you didn't +even notice. Please be more aware of your surroundings. + +Fisher Arnold +Level 34 Tentacruel (1492 EXP) +You get: 1360G + +Head way left then down to... + +Swimmer Seth +Level 29 Quagsire (850 EXP) +Level 29 Octillery (1018 EXP) +Level 32 Quagsire (939 EXP) +You get: 256G + +Head down from him and you'll get to Cinnabar Island. + +|---------------| +|Cinnabar Island| +|---------------| + +Cinnabar Island is in a sad state of affairs for its former residents, who fled +the premises. The volcano has done very well for itself. So you were on Team +Volcano, you're a monster, but also, you probably won a lot of money in Team +Rocket's black market gambling scene. You can now enter the Pokémon Center and +heal your Pokémon. Talk to the two people there and they'll tell you that the +volcano erupted a year ago (two years after Red, Blue, and Yellow). + +You'll notice someone looking at what's left of it. It's Blue! He was the +Champion of the Pokémon League until Red beat him. He knows you want a challenge +but he's not in the mood for one yet. He laments, "The volcano erupts, and just +like that, a whole town disappears." I never thought of Blue as a sentimental +guy, but here we are. Blue then levitates and flies off into space, presumably +with Alakazam. Then, surf east to Route 20. + +|--------| +|Route 20| +|--------| + +Pokémon on Route 20: + +#072 Tentacool + +Head east and you'll come across one trainer. + +Swimmer Cameron +Level 34 Marill (421 EXP) +You get: 272G + +Now head on east to the Seafoam Islands. + +|-------------------| +|Seafoam Islands Gym| +|-------------------| + +Since Cinnabar Island's volcano erupted and destroyed Blaine's gym, it's moved +to the Seafoam Islands. It's now in the middle of nowhere. You should see a +small cave at the Seafoam Islands. Go inside and it's Blaine's remote, makeshift +gym. This gym doesn't even fit the entire screen. The poor guy's become a hermit +and a caveman. I just heard him grunting and working on his cave painting. + +--- +GYM LEADER BLAINE +Level 45 Magcargo (1485 EXP) +Level 45 Magmar (1609 EXP) +Level 50 Rapidash (2056 EXP) +You get: Volcano Badge, 5,000G +--- + +Blaine will have two Full Restores available to use on his Pokémon. + +Strategy VERSUS Magcargo: Magcargo has Yawn to put your Pokémon asleep, Recover +to get back half its HP in one turn, and Overheat, a pretty strong fire move +that can do significant damage to a starter Pokémon like Meganium and also cause +burn damage. Don't use Meganium, because it takes damage faster than it can deal +to Magcargo. You can use a strong normal move from your strongest Pokémon that +isn't Meganium, like HM Strength, against Magcargo. Additionally, Ho-Oh's +AncientPower is a super-effective rock move against fire types which also raises +Ho-Oh's special stats. Or you can use Lugia's Hydro Pump/Surf, as water moves +are super effective against Magcargo. Feraligatr's water type moves will be +super effective against all Pokémon in this gym. + +Strategy VERSUS Magmar: Magmar has a Confuse Ray move that can prevent your +Pokémon from attacking. Don't use Meganium, because it takes damage faster than +it can deal to Magmar. You can use a strong normal move from your strongest +Pokémon that isn't Meganium, like HM Strength, against Magcargo. Additionally, +Ho-Oh's AncientPower is a super-effective rock move against fire types which +also raises Ho-Oh's special stats. Or you can use Lugia's Hydro Pump/Surf, as +water moves are super effective against Magmar. + +Strategy VERSUS Rapidash: Rapidash has the moves Flare Blitz (which also speeds +Rapidash up) and Bounce (which is a two-turn Fly type move). Don't use Meganium, +because it takes damage faster than it can deal to Magmar. Additionally, avoid +using two-turn moves, because Bounce can result in your low-PP moves missing +entirely. You can use a strong normal move from your strongest Pokémon that +isn't Meganium, like HM Strength, against Magcargo. Additionally, Ho-Oh's +AncientPower is a super-effective rock move against fire types which also raises +Ho-Oh's special stats. Or you can use Lugia's Hydro Pump/Surf, as water moves +are super effective against Magmar. + +Blaine will give you the Volcano Badge and 5,000G. You now have 15 badges! You +can talk to the guy that comes in, but he's late. Word doesn't travel quickly +across the sea. Now exit the cave. + + +============================================================================== +============================================================================== +============================================================================== + +(c34) +------------------------------------------------- diff --git a/docs/gold-walkthrough/section-28-routes-19-20-and-viridian-city-gym.txt b/docs/gold-walkthrough/section-28-routes-19-20-and-viridian-city-gym.txt new file mode 100644 index 00000000..12f62b9e --- /dev/null +++ b/docs/gold-walkthrough/section-28-routes-19-20-and-viridian-city-gym.txt @@ -0,0 +1,134 @@ +---- 34 > Routes 19-20 and Viridian City Gym ---- +------------------------------------------------- + +Video Playthrough Link (Seafoam Islands Gym, Routes 19-22, 28, & Viridian City +Gym): https://youtu.be/wgql5WCnx4g + +|--------| +|Route 20| +|--------| + +East on Route 20 and to Route 19 are optional, but if you take that route, +you'll get to fight a few more trainers. Surf down and right to face the swimmer +in the water. + +Swimmer Lori +Level 32 Starmie (1419 EXP) +Level 32 Starmie (1419 EXP) +You get: 640G + +Head right, then up, to one more trainer. + +Swimmer Nicole +Level 29 Marill (360 EXP) +Level 29 Marill (360 EXP) +Level 32 Lapras (1501 EXP) +You get: 640G + +Head right and up to Route 19! + +|--------| +|Route 19| +|--------| + +You'll find a drowning guy next to a girl. Talk to the guy for a battle. + +Swimmer Tucker +Level 30 Shellder (622 EXP) +Level 34 Cloyster (1479 EXP) +You get: 272G + +Talk to him again. Yep, seems like he is drowning. Now. talk to the girl to +battle her. She's apparently disgusted by wimpy people like her boyfriend. + +Swimmer Dawn +Level 34 Seaking (1237 EXP) +You get: 680G + +There's one more trainer. Head up, then right a little. + +Swimmer Jerome +Level 26 Seadra (862 EXP) +Level 28 Tentacool (630 EXP) +Level 30 Tentacruel (1317 EXP) +Level 28 Goldeen (666 EXP) +You get: 224G + +And that's all the trainers! You can't go further north because you'll be +blocked by those boulders south of Fuchsia City. So, fly back to Viridian City +instead. + +|-------------| +|Viridian City| +|-------------| + +First, heal at the Pokémon Center. Blue is no pushover. He's not a one-trick +pony. He has more than one type of Pokémon. He's one of only two trainers that +are tougher than Champion Lance in the whole game. There aren't any other +trainers in this gym, though, so Blue appears to have filled the whole gym with +Legos. + +Save before you talk to Blue. He doesn't think that highly of you conquering +Johto. He may have a point, as his Pokemon are much higher level than the Johto +gym leaders and even the other Kanto gym leaders. But you can prove him wrong +right here. You'll want to make sure you're hitting with high powered attacks +for the most part, but also familiar with type strengths and weaknesses. + +--- +GYM LEADER BLUE +Level 56 Pidgeot (2064 EXP) +Level 54 Alakazam (2151 EXP) +Level 56 Rhydon (2448 EXP) +Level 58 Gyarados (2659 EXP) +Level 58 Exeggutor (2634 EXP) +Level 58 Arcanine (2646 EXP) +You get: Earth Badge, 5,800G +--- + +Strategy VERSUS Pidgeot: Pidgeot is a normal and flying type which will use Air +Slash as one of its flying type moves. Pidgeot can be hit with rocks or +electrocuted. Okay, that sure sounds violent when I put it that way. Like +Arcanine, Pidgeot also has a switch-out move named Whirlwind, which can scramble +your lineup. Use your highest-powered attack from your highest level Pokémon +that is not a ground type move. + +Strategy VERSUS Alakazam: Alakazam's a psychic type, so don't use fighting moves +against it. Otherwise, you can use your highest powered attack (e.g., HM +Strength), which is likely coming from your starter evolution. + +Strategy VERSUS Rhydon: Rhydon is a rock and ground type. Use your +highest-powered attack from your highest level Pokémon that is not a normal +move. Good moves to use against Rhydon include any of Meganium's grass moves, +water moves like Hydro Pump or HM Surf from Lugia or Feraligatr, as well as ice +and fighting moves. + +Strategy VERSUS Gyarados: Gyarados is a water and flying type, which is news to +everyone, because it is a dragon type and can't fly. Use your highest-powered +attack from your highest level Pokémon that is not a ground type move. If one of +your Pokémon has TM Thunder (e.g., Ho-Oh), you can use it to great effect here. + +Strategy VERSUS Exeggutor: Two of Exeggutor's favorite moves are Psychic and +Hypnosis. Beware of Exeggutor's Hypnosis, which will put your Pokémon to sleep. +Use your highest-powered attack from your highest level Pokémon that isn't a +grass or psychic type move, like Body Slam or Strength. Fire moves work well +against this grass and psychic type. Water types like Feraligatr could run into +a bit more trouble due to the type weakness. + +Strategy VERSUS Arcanine: Arcanine is a fire type, so don't use grass or fire +type moves. Water moves like HM Surf work well, plus any high-powered move of +other types generally. + +With Blue defeated, you now have all 16 badges. Blue is completely surprised but +gives you the badge. He says he'll beat you back one day. Talk to him again and +he says you must not lose until he beats you. + +Head out of the gym and heal at the Pokémon Center. The game still isn't over! +There are no credits yet. There are still a few things you can do before you +clear Kanto. + +============================================================================== +============================================================================== +============================================================================== + +(c35) +--------------------------- diff --git a/docs/gold-walkthrough/section-29-routes-22-28.txt b/docs/gold-walkthrough/section-29-routes-22-28.txt new file mode 100644 index 00000000..a511e3ea --- /dev/null +++ b/docs/gold-walkthrough/section-29-routes-22-28.txt @@ -0,0 +1,80 @@ +---- 35 > Routes 22-28 ---- +--------------------------- + +Video Playthrough Link (Mount Silver & Boss Battle vs. Red): +https://www.youtube.com/watch?v=ubjgTa41WxU + +Now, fly back to Pallet Town. + +|-----------| +|Pallet Town| +|-----------| + +Again, it's time to enter Professor Oak's lab. Talk to him and he'll be +impressed and make arrangements for you to head to Mount Silver. Only the best +trainers are allowed in because of the ferocity of wild Pokémon. Now, fly back +to Viridian City, and then head west to Route 22. + +|--------| +|Route 22| +|--------| + +Pokémon on Route 22: + +#021 Spearow +#022 Rattata +#060 Poliwag (surfing) + +Items on Route 22: + +TM42 Dream Eater + +We're making our journey west now. If you cut the tree to the west of Viridian +City, you can talk to a guy that gives you TM42 Dream Eater. +There are no Pokémon trainers on this route, so just keep heading north, west, +then north. Head through that gate you went through earlier going to Indigo +Plateau, but this time, you will be heading west instead of north. Do so and +enter Route 28. + +|--------| +|Route 28| +|--------| + +Pokémon on Route 28: + +#077 Ponyta +#078 Rapidash +#114 Tangela +#215 Sneasel +#232 Donphan (Silver only) + +Items: + +TM47 Steel Wing + +As you head west towards Mount Silver, you can catch a few high-level Pokémon, +although they aren't as high level as the last few legendaries you just caught. +Consider catching a Tangela. It can learn Stun Spore which helps with Pokémon +catches, plus it can use HM02 Cut and HM05 Flash. + +There's also a Pokémon Center here that you should heal at before moving on. +It's the last one you can get to before Mount Silver. From there, you can cut +the trees east and go into a house to get TM47 Steel Wing from the lady there. + +|-------------------------| +|Final Dungeon Preparation| +|-------------------------| + +Before you enter Mount Silver, this would be a good opportunity to do any final +things before you challenge the final dungeons and boss. Make sure you've got at +least 10 Revives, 10 Hyper Potions, 5 Escape Ropes, 5 Max Repels, and 50 Ultra +Balls. You can fly to Mahogany Town's Pokemart to buy some if you need more. You +can fly back to the Pokémon Center on Route 28 just outside of Mount Silver when +you're ready. + +============================================================================== +============================================================================== +============================================================================== + +(c36) +--------------------- diff --git a/docs/gold-walkthrough/section-30-lapras.txt b/docs/gold-walkthrough/section-30-lapras.txt new file mode 100644 index 00000000..3ffdbf0e --- /dev/null +++ b/docs/gold-walkthrough/section-30-lapras.txt @@ -0,0 +1,72 @@ +---- 36 > Lapras ---- +--------------------- + +|------| +|Lapras| +|------| + +We already have five of the nine special Pokémon of the game (two of five +legendaries): Sudowoodo, Snorlax, Gyarados (Shiny Red), Lugia, and Ho-oh. What +now? Thard_Verad comments, "Lapras only appears on Friday." If it's a Friday, +head to Violet City and then go south through Route 32 into Union Cave. Level 20 +Lapras becomes available on a Friday once you've gotten HM Surf. + +In Union Cave, use a Max Repel to reduce the number of wild encounters as you +navigate towards Lapras. Head along the path and past the first two trainers. +Head almost all the way through till you see the guy on the cliff still spinning +around, which is the third trainer that you spot. Down and to the left is a +lake. Surf past it and head down the ladder, then fight the guy next to the +lake. He should be very easy to beat. + +PoKeManiac Andrew +Level 24 Marowak (637 EXP) +Level 24 Marowak (637 EXP) +You get: 1440G + +With Andrew defeated, surf east and you'll fight another trainer. + +PoKeManiac Calvin +Level 26 Kangaskhan (975 EXP) +You get: 1560G + +After you beat him, he exclaims, "You demonstrated on me!" Head down the ladder, +then surf southward. Land on the right side to grab a Hyper Potion and fight a +trainer. + +Cooltrainer Nick +Level 26 Charmander (? EXP) +Level 26 Bulbasaur (355 EXP) +Level 26 Squirtle (? EXP) +You get: 1248G + +Then land on the left side. Head up to fight another spinning trainer. + +Cooltrainer Gwen +Level 26 Eevee (511 EXP) +Level 22 Vaporeon (924 EXP) +Level 22 Jolteon (928 EXP) +Level 22 Flareon (933 EXP) +You get: 1056G + +Head down the path a long way and you'll fight one more trainer. + +Cooltrainer Emma +Level 28 Poliwhirl (786 EXP) +You get: 1344G + +Then surf east from Emma and you'll encounter a Level 20 Lapras. Lapras isn't a +legendary. But treat it like it is one when you're trying to catch it by saving +before you interact with Lapras. If you knock it out on a critical hit, lose all +your Pokémon and black out, or run out of balls to throw at it, you would want +to return to your save and retry. Ultimately, you'll want to maximize your +chances by throwing many Ultra Balls at it. You'll want to do so when it is +lower on HP (yellow or red on the HP meter), and ideally when it is asleep or +paralyzed by a move like Stun Spore. You can then use Escape Rope to leave Union +Cave. + +============================================================================== +============================================================================== +============================================================================== + +(c37) +----------------------------------- diff --git a/docs/gold-walkthrough/section-31-mount-silver-and-red.txt b/docs/gold-walkthrough/section-31-mount-silver-and-red.txt new file mode 100644 index 00000000..85229a3d --- /dev/null +++ b/docs/gold-walkthrough/section-31-mount-silver-and-red.txt @@ -0,0 +1,153 @@ +---- 37 > Mount Silver and Red ---- +----------------------------------- + +|------------| +|Mount Silver| +|------------| + +Pokémon in Mount Silver: + +#042 Golbat +#055 Golduck +#075 Graveler +#232 Donphan +#246 Larvitar + +Items in Mount Silver: + +Escape Rope +Full Restore +Protein + +Head west and up from there and you'll be on Mt. Silver. Bring your best team +with all the HMs (particularly Surf, Waterfall, and Flash). Bring 30 Ultra Balls +so you can catch the strongest wild Pokémon in any dungeon. Consider having a +few Max Repels ready in case you get frustrated with the numerous wild Pokémon +encounters here. I'd suggest trying to catch many of the Pokémon in here, +though, as they are high-level Pokémon and a few are great to catch. + +Larvitar would be a good Pokémon to catch because its most-evolved form, +Tyranitar, is an excellent Pokémon. Golduck may also be good if you want a +higher-level water Pokémon that can also learn HMs Surf, Strength, Waterfall, +and Whirlpool. + +When you get in, use HM Flash. Head up, then go left and drop down the ledge to +get the Escape Rope. Jump back down and go right instead of up. Go up the steps +to the cliff and grab the X Accuracy. + +Head back down the cliff, and go up the middle. This time, go right at the +intersection. Head a bit to the right, go up the steps, and then up the cliff. + +Go along the path, head on right, then up another cliff to the north. Continue +along the cliff route, and then when there's an intersection to the left and +right, take the left, drop down and get the Max Elixir. Drop down the ledges all +the way back to ground level, then again, and then go back up the cliff. + +Next, go, left, up, and right. If you go down, use Surf on the lake, and then +use Waterfall, you can go through an entrance to grab a Full Restore. If you +then return across the lake, you can go through the cave door, keep going up, +and you'll find a trainer. + +Your final lineup, if you've been leveling up your starter only, might look like +this: + +Level 82 Starter Evolution +Level 70 Ho-Oh/Lugia +Level 50 Snorlax +Level 48 Golduck +Level 40 Ho-Oh/Lugia +Level 39 Tangela + +Your final lineup, if balanced, might look like this: + +Level 60 Lugia +Level 54 Espeon +Level 53 Dragonair +Level 53 Sudowoodo +Level 53 Starter Evolution +Level 50 Gyarados + +You're about to face by far the strongest trainer in the game. Red is the final +boss. Make sure you have HMs Surf and Strength equipped on any high-level +Pokémon that can make use of them. Both HMs can be used an unlimited number of +times and even now, they likely do more damage than almost any move that a +Pokémon can learn during your game playthrough. You can go back to the Move +Deleter in Blackthorn City later in the game if you want to replace the moves +later. + +|-----------------------------| +|Red | +|Level 81 Pikachu (1422 EXP) | +|Level 73 Espeon (3081 EXP) | +|Level 77 Blastoise (3465 EXP)| +|Level 75 Snorlax (2475 EXP) | +|Level 77 Charizard (3448 EXP)| +|Level 77 Venusaur (3432 EXP) | +|You get: 7700G | +|-----------------------------| + +Red will have three Full Restores that he can use. You will likely need to use +several Hyper Potions and Revives to keep your Level 70+ Pokémon in the fight. + +Strategy VERSUS Pikachu: Pikachu is an electric type Pokémon that cannot damage +rock or ground type Pokémon. Pikachu is super effective against water type +Pokémon like Feraligatr. However, despite being the highest level Pokémon that +Red has, it is pretty frail. You can use your highest powered attack from your +starter evolution (even if it is a water move) or the highest powered attack +from Lugia or Ho-Oh, whichever Pokémon has the higher level. + +Strategy VERSUS Espeon: Espeon is a dark type that cannot be hit with ghost +moves. You can use your highest powered attack from your starter evolution or +the highest powered attack from Lugia or Ho-Oh, whichever Pokémon has the higher +level. + +Strategy VERSUS Blastoise: Blastoise is a water type Pokémon, and the most +evolved form of Squirtle. Blastoise is strong against fire type Pokémon like +Typhlosion and Ho-Oh. Blastoise is technically weak against grass type Pokémon, +but it also has Blizzard, a very strong ice type move, that is super effective +both against Meganium as well as flying types like Ho-Oh and Lugia. The best +moves to use against Blastoise are your highest-level grass moves (e.g. from +Meganium) or your highest-level electric moves that can be taught to a Pokémon +(like Ho-Oh). If you don't have those as Level 70+ Pokémon, use the highest +powered attack from your highest-level Pokémon that isn't Typhlosion or Ho-Oh. + +Strategy VERSUS Charizard: Charizard is a fire and flying type Pokémon, and the +most evolved form of Charmander. Charizard is strong against grass type Pokémon +like Meganium, so don't use Meganium. Charizard can still take damage from fire +type attacks from Typhlosion and Ho-Oh. Avoid using grass and fire type attacks +against Charizard. The best moves to use against Charizard are your +highest-level water type move (Hydro Pump or Surf) from your best Pokémon that +can use it (Feraligatr or Lugia), or your highest-level electric move that can +be taught to a Pokémon (like Ho-Oh). If you don't have those as Level 70+ +Pokémon, use the highest powered attack from your highest-level Pokémon that +isn't Meganium (e.g. Typhlosion or Ho-Oh). + +Strategy VERSUS Snorlax: Snorlax is Ash's strongest Pokémon, and takes the +longest to defeat. Snorlax is bulky and will keep using Rest to restore its +health. A rock, ground, or steel type Pokémon might take a bit more damage. Use +the highest powered attack from your starter evolution or the highest powered +attack from Lugia or Ho-Oh, whichever Pokémon has the higher level. + +Strategy VERSUS Venusaur: Venusaur is a grass and poison type Pokémon, and the +most evolved form of Bulbasaur. Venusaur is a slow and bulky Pokémon that can +use Synthesis to heal half its HP. Venusaur will be strong against water types +like Feraligatr, and will take damage well from grass attacks from Meganium. +Avoid using grass and water attacks against Venusaur. The best moves to use +against Venusaur will be your fire, flying, or psychic moves that you can have +Typhlosion, Ho-Oh, or Lugia utilize. + +Red is down. You're the best trainer in the Johto and Kanto worlds combined, at +this time. After defeating Red, he'll give you the money and then the silent +treatment. He flies away, and then the credits start rolling. After the credits +roll, save. + +The game is over, for the most part. You can still catch the Legendary Dogs at +this time if you haven't already. If you want to fight Red again, beat the Elite +Four again and then he'll be back at Mt. Silver, for experience or whatever. + +============================================================================== +============================================================================== +============================================================================== + +(c38) +----------------------------------------- diff --git a/docs/gold-walkthrough/section-32-raikou-entei-and-suicune.txt b/docs/gold-walkthrough/section-32-raikou-entei-and-suicune.txt new file mode 100644 index 00000000..d4cd61a7 --- /dev/null +++ b/docs/gold-walkthrough/section-32-raikou-entei-and-suicune.txt @@ -0,0 +1,61 @@ +---- 38 > RAIKOU, ENTEI, AND SUICUNE ---- +----------------------------------------- + +They will only be in grasses, which means it'll never be in water, caves, trees, +Ilex Forest, or National Park. The exceptions are Routes 40 and 41. They change +route when you change route or enter/exit a city, cave, or building. Get on a +bike and start biking everywhere. Do this on every route you run across. Run +across all Johto. Check everywhere. Be a free spirit! Every time you enter a new +city or switch routes, the legendaries move one route, although they +occasionally will jump a few routes. + +There are advantages to training up a Scyther. It has False Swipe, which allows +you to knock a Pokémon's HP down to 1 HP without knocking it out. You can do so +by having your Elite Four Pokémon doing the fighting and sharing EXP at Mount +Silver. Train till Scyther is Level 39, then go to Route 42 with the Max Repel. + +Once you've had your original encounter with a roaming legendary, it becomes +possible to track where it is. After the initial encounter with a roaming +legendary Pokémon, they will show up on the map in Pokegear. Flying will reset +the roaming legendary's location, which is only really good if you are far from +the roaming legendary. + +It's better to keep switching between routes. BebeDuckie gives this advice: "You +go between the same city and route like a few steps left right left right in the +same spot, and eventually get the roaming Pokémon near you without having to fly +everywhere." + +In Johto, it is helpful to fly to Violet City, then stay on a bike and keep +switching between Routes 35, 36, and 37 so that the legendaries can quick reset +their locations, as quickly as every 10 seconds. You can get a lot of repeat +encounters quickly once you've located the legendary Pokémon before and keep +switching quickly back and forth between the same routes. + +Save every now and then when you get close to a roaming legendary. If you knock +it out, you can go back to the save file and try to catch it again. The roaming +legendaries will all run after one attack or one ball throw. This can be +frustrating, but go in with the expectation that you'll have to encounter it a +dozen or more times before you can catch it. The good news is that once you've +weakened a legendary once, it will remain at that HP the next time you encounter +it. +You could consider a Master Ball here, but keep in mind that there are three +roaming legendaries, so you can only use a Master Ball on one of them. Because +the catch rate is only about a few percent at low health, even with Ultra Balls, +this may involve many encounters or even resets. Ultimately, you'll want to +maximize your chances by throwing many Ultra Balls at it. You'll want to do so +when it is lower on HP (low yellow or ideally red on the HP meter), and ideally +when it is asleep or paralyzed although it's hard to get these moves in prior to +the roaming legendary running off. + +As long as you are persistent, you will eventually catch each roaming legendary. +If you knock it out a bunch of times or run out of Ultra Balls, you can go back +to your save and try again. After you've caught each roaming legendary, remember +to save! + + +============================================================================== +============================================================================== +============================================================================== + +(c39) +------------------------------------------- diff --git a/docs/launcher.md b/docs/launcher.md index c9a43616..2a0e2ab6 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -24,9 +24,10 @@ for the launcher to acknowledge on refocus. `RomImporter` then imports on refocus / Choose: -- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a - version that is **not** yet ready counts as pending. A leftover - `picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). +- **ROMs** via `findPendingRom`: only a 1 MiB (Red/Blue/Yellow) or 2 MiB + (Gold) `.gb`/`.gbc` whose SHA-1 maps to a version that is **not** yet ready + counts as pending. A leftover `picked_rom.gb` from Red therefore cannot + block Blue's Choose (issue #167). - **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any other `.zip` at the save-dir root (USB copy). - **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any @@ -41,13 +42,15 @@ same system picker and install the chosen archive on return. ## Tab structure -`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar -draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every -frame so `mousepressed` can dispatch clicks; switching tabs mid-import is -allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows). -On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1 -matches the open game tab, so a shared `imports/` folder with Red+Yellow -cannot jump Yellow → Red. +`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"mods"`, +`"find"`. The tab bar draws one chip per game plus MODS / FIND chips and +rebuilds `self.tabRects` every frame so `mousepressed` can dispatch clicks; +switching tabs mid-import is allowed (a dropped ROM still routes by SHA-1 +regardless of which tab shows). Yellow uses the bright gold rail colour; +Gold (Gen 2) uses a deeper amber so the two stay distinct. On **NX**, +**Scan again** is stricter: it only starts an import whose SHA-1 matches the +open game tab, so a shared `imports/` folder with Red+Yellow cannot jump +Yellow → Red. - A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the Play button, and the SAVE SLOT card in a responsive two-column grid (see diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md new file mode 100644 index 00000000..3dd8fe0e --- /dev/null +++ b/docs/mod-api-gen2-compat.md @@ -0,0 +1,846 @@ +# Mods and Gen 2 (Gold) + +The mod API is one API across both generations. Hook names, event names, +registry names and the `mod.*` facade are shared on purpose: a mod that runs on +Red should be able to run on Gold without learning a second vocabulary. + +What differs is how much of it Gold can actually serve, and that is why Gen 2 +support is something a mod **declares** rather than something it inherits. + +## What you can rely on today + +The short version, for an author deciding what to write: + +- **Every registry name, hook name and event name means the same thing in both + games.** Nothing is prefixed, renamed or repurposed per generation. Where Gen + 2 genuinely carries more, the record or the payload gains a *field*. +- **40 of the 46 registries are available on Gold.** 17 keep their Gen 1 target + outright (`commands`, `tokens`, `growth_rates`, `battle_sprite_scales` and + `render_pipelines` among them), 16 route to a Gen 2 table under the same + name, 6 are Gen 2-only systems Red has no counterpart for, and `migrations` + is a code registry with no data target in either game. The other 6 are gated, + and are listed below with the consumer change each one still needs. +- **A registry with no home in a generation is reported, never silently + merged.** The write is taken, dropped, and named once per mod in the same + error feed the mod manager shows -- in both directions, so a Red boot writing + to `decorations` is told exactly as a Gold boot writing to `map_scripts` is. +- **40 event names and 43 hook names have a call site in both generations**, so + one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua` + reads those names back out of the source and fails if a site is renamed or + deleted on either side, and fails again if a new shared site appears without + being listed here. +- **24 further names are Gen 2-only** (friendship, breeding, the Pokegear, the + radio, Pokerus, the roamers, Kurt, the Bug Contest, the Unown puzzle, mail, + held items, shininess, gender, and the five cards of the GS boot cinema). + They are plain names, not a `gen2.` namespace, so if Red ever grows the + system the name is already right. +- **Every Gen 2 seam is guarded** by `Runtime.wants` / `Runtime.wantsHook`, so + a boot with no mod subscribed allocates nothing at any of them. +- **A mod is loaded on Gold only if it says so.** See `gen2compat` below. + +`src/mods/Schemas.lua` is authoritative for routing; +`tests/engine/gate_gen2_mod_api.lua` holds this document to it. + +## Declaring which games a mod is for + +```json +{ + "id": "my_mod", + "name": "My Mod", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["gen1", "gen2"] +} +``` + +`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`, +`"gold"`), 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. +`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. + +Nothing moves on disk for any of this. A mod is installed once, into +`mods/<id>/`, and that directory serves every game: there is no `mods/gen1/` +and no per-generation copy. Targeting is declared, not filed. + +`"gen2compat": true` is the legacy spelling and is still accepted. It is purely +additive -- it *adds* the Gen 2 games to whatever `games` says -- so no shipped +manifest can lose a game it already ran on. A manifest with neither key is Gen +1 only, which is exactly what it always meant. An unknown token warns and is +dropped under `api` 1 and refuses the manifest under `api` 2; a `games` array +that names no game this engine knows falls back to the default rather than +orphaning the mod; a non-array `games` is a hard error. + +Every token is enforced, per game. `Loader:_gateGeneration` gates on +`ModTargets.supports(manifest, version, generation)`, the same call both mod +surfaces make, so `"games": ["blue"]` really does not load on Red and the +loader's skip line is the launcher's line, `For Blue, not Red`. A manifest with +no `games` and no `gen2compat` still covers every Gen 1 game, so nothing +written before the key existed changes behavior. + +On a Gold boot, a mod claiming no Gen 2 game is **not loaded at all**: no +registrations, no subscriptions, no entry chunk. The manager still lists it, +showing `ENABLED (NOT THIS GAME)` and the reason, and the player's enable flag +is left alone so it comes straight back on Red. + +Both mod surfaces derive what they show from `ModTargets` rather than from +their own copy of the rule. The launcher's mod panel carries a `Show for:` game +chip row and a per-mod tag (`GEN 1`, `GEN 1+2`, `RED/GOLD`), greyed with `Not +for this game` and the detail `For Gen 1, not Gold` when the mod does not run +on the selected game; the in-game manager shows the same verdict as +`ENABLED (NOT THIS GAME)` plus an inert `FOR GEN 1+2` row on the detail screen. +The launcher asks the same question of a mod's dependencies: one whose hard +dependency does not run on the selected game reads `Needs <id> (not for Gold)`, +matching the loader's contagious skip. + +A separate overlay, `options.modsByVersion[version][id]`, is where a per-game +enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`, +so `SaveData.modScope` answers nil for every caller and the launcher panel, the +in-game manager and the loader all read *and* write the one shared +`options.mods` flag. Nothing consults the overlay for enablement until that +flips, which is deliberate: the overlay is plantable from an imported +`.g1rmodlist`, and a reader scoped differently from the writers would show a +mod set no boot would honour. + +That is deliberate. Gold reimplements the battle engine, the overworld, the +script VM and the save format, so a Gen 1 mod dropped into a Gold boot would +find a small fraction of its call sites live. A mod that half-applies reads to +a player as a broken mod. Not running is the honest state, and naming a Gen 2 +game is the author saying "I have tested this there". + +Adding a Gen 2 game does not opt out of anything on Gen 1, because `games` is a +union: `["gen1", "gen2"]` covers everything it covered before. What does change +is that the gate now runs on a Gen 1 boot too, so a manifest that names *only* +Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both +generations if you want both. + +Two riders. **A hard dependency that does not run here takes the dependent down +with it**, as a skip rather than a failure and carrying the dependency's own +wording (`depends on X, which does not run here (For Blue, not Red)`), so the +whole chain has to cover the same games. And **the claim is yours, not the last +word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod +whose author never opted in, which is the only route for a mod written before +the field existed. The override is per game -- `options.modsGen2[id]` is a +`{ [version] = true }` table, so forcing a mod onto Red does not force it onto +Gold, and a legacy `options.modsGen2[id] = true` reads as "the Gen 2 games", +the only set it could ever have affected. It applies on the next boot; a forced +mod loads normally and keeps a note saying it was never verified here. Where +the choice cannot be persisted the manager says `COULD NOT SAVE` instead of +promising a restart. + +If you are writing new code, still prefer the API: take the live game from +`mod.game` (or the `game.ready` payload, or a `ui.*` hook's first argument) and +the world from `mod.world`. Those are the names that mean the same thing in +both games. What follows is for the mods that were written before Gold existed +and reach past it. + +## Gen 1 module facades + +A mod with `engine_internals` reaches engine modules by name, and under Gold +those names used to resolve to Gen 1 modules nothing instantiates -- so the +patch landed on dead code and the mod was inert with no symptom but silence. + +On a Gen 2 boot, **a require made from a mod's own chunk is answered by an +adapter**: the Gen 1 API, backed by Gen 2 internals. `src/mods/Gen2Compat.lua` +is the table, `src/mods/Loader.lua`'s require shim is where the swap happens, +and `tests/engine/gate_gen2_mod_facade.lua` holds both to it. Engine code is +not affected -- the shim only substitutes when the calling chunk is outside the +engine tree, so `src/render/PaletteFX.lua` still gets the real Gen 1 module on +both generations. + +Fifteen names are served. **alias** means the adapter *is* the Gen 2 module, so +a monkey-patch, a `rawset` sentinel and a `getmetatable(x) == M` check all land +on the table Gold runs; **facade** means a translating wrapper over it. + +| the Gen 1 name a mod requires | kind | what it gets on Gold | +| --- | --- | --- | +| `src.core.Game` | facade | a live proxy onto the Game2 instance | +| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua`; `World:step` / `:interact` / `:interactBody` dispatch through it | +| `src.world.Map` | alias | `src/world/gen2/Map.lua`, grown Gen 1's statics and instance methods | +| `src.world.NPC` | alias | `src/world/gen2/Npc.lua`; `NPC.new` sniffs the Gen 1 argument order | +| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua`, plus Gen 1's `COUNT` / `CAPACITY` / `ensure` / `active` / `deposit` | +| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua`, write-through | +| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua`, write-through | +| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | +| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | +| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | +| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua`, write-through | +| `src.world.FieldDefaults` | facade | the `playerSprites` answer, and a named refusal for the rest | +| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` | +| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua`, write-through | +| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` | + +Two entries in that table are not the pairing they look like. +`src.ui.BoxMenu` resolves to `PcMenu`, not to `src/ui/gen2/BoxMenu.lua`: Gen 1's +`BoxMenu` is Bill's PC *top menu*, whose Gold counterpart is `PcMenu`, while +Gold's `BoxMenu` is the withdraw/deposit *list* Gen 1 builds inline. And +`src.script.ScriptRunner` is served narrowly rather than fully: `scanLabels` +and `validate` forward verbatim, with the default verb lookup swapped to +`game.data.commands` so a script of Gen 1 built-ins cannot validate clean and +then run as nothing, while the lifecycle half is a thin handle onto the one +`world.vm` with `resume` and `update` refused rather than double-driving it. +The `script.started` / `script.ended` / `script.command` seams are the +supported route and already work on Gold. + +`src.script.Commands` and `src.ui.OptionRows` have **no** adapter and are the +two names a require of which still lands in the boot error feed the manager +shows, with the module named. Both load fine under Gold and both are traps: the +first hands back 61 Gen 1 verbs none of which Gold can run, the second paints +Red's four-box options chrome over Gold's single 18x16 one. + +`docs/preparing-your-mod-for-gen2.md` is the migration guide for an author +working through this, and `python3 tools/modkit.py gen2check <id>` reports a +mod's own findings against the coverage table below. + +Three rules the adapters keep, because a plausible wrong answer is worse than +the module being missing: + +- **Live, never a snapshot.** A mod captures `require("src.core.Game")` at file + scope, before a save or a world exists. The facade is a proxy that reads the + live instance on every touch, so `Game.save` is nil during the entry chunk + and correct forever after. It aliases the two names Gold spells differently + (`Game.overworld` is `Game2.world`, `Game.writeOptions` is + `Game2:persistOptions`) and the one data table that was renamed + (`game.data.sprites` is `data.gen2Sprites`). +- **A member with no backing says so.** `game.data.field` does not exist on + Gold, so it reads nil *and* logs once, naming the mods holding the facade. + `BattleState.newWild` is absent rather than invented, because a `newWild` + that took a species and a level would be a lie about what Gold's battle + screen is. +- **One stable table for the run.** Where the Gen 2 arm can serve the name + outright the adapter *is* that module, so a mod's monkey-patch, its + `rawset` sentinel and its `==` idempotency check all land on the table Gold + actually runs. + +### What the adapter says it covers + +The adapter publishes its own coverage, versioned by +`Gen2Compat.COVERAGE_VERSION` (1), and `modkit gen2check` consumes that table +rather than a second copy of the same knowledge: + +```lua +Gen2Compat.modules() -- the 15 names, sorted +Gen2Compat.serves(name) -- boolean +Gen2Compat.memberStatus(name, member) -- "backed" | "warned" | "absent" | nil +Gen2Compat.coverage(name) -- a fresh table per call: +-- { module, kind = "facade"|"alias", target, members = { [name] = status }, +-- notes = { [name-or-topic] = "one line" } } +``` + +The status vocabulary is frozen at three values, and a member listed as both +resolves to the weaker claim: + +| status | means | +| --- | --- | +| `backed` | present, and it does the Gen 1 job on Gold | +| `warned` | present, answers nil or degrades, and names itself once with the mod attributed | +| `absent` | deliberately not served; a nil read is the honest failure | + +Today that is 288 backed, 32 warned and 161 absent across the fifteen modules. +`notes` keys are documentation topics rather than a member list -- dotted paths +(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and +bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is +the authoritative set, and a member it does not record is not a promise either +way: on an alias it resolves to whatever the Gen 2 module has, on a +write-through facade it falls to the Gen 2 class, on the `src.core.Game` facade +it reads nil and says so, and on the `src.world.OverworldController` facade it +reads nil silently. + +**The follower.** Gold's cart has no trailing companion at all, so +`src/world/gen2/Follower.lua` is new Gen 2 code rather than a facade: the +entity, the trail loop, and a `shouldSpawn` a mod replaces. `World:step` calls +`Follower.update(game, world)` once per logic frame after the body, and +`World:setMap` calls `Follower.onMapEntered` before it emits `map.entered` -- +the same two call sites `src/world/OverworldController.lua` gives the Gen 1 +arm, which is what makes a Gen 1 follower mod's wrappers tick. + +Vanilla never spawns one: `shouldSpawn` answers false until something replaces +it. `Follower.setShouldSpawn(fn)` is the supported way, and it writes the same +file-local the Gen 1 mods reach through `debug.setupvalue` on the upvalue named +`shouldSpawn`, so the two cannot disagree. + +Two Gen 2 engine changes came with it, both general rather than follower-only: +an entity with `passable` set never blocks a step (the Gen 1 name and meaning, +`src/world/Collision.lua`), and `World:rebuildPeople` now preserves **guests** -- +anything in the people list it did not put there. A rebuild runs on every zoom +and every time-of-day roll, so without that a follower vanished at the top of +the hour. + +**What the facades cannot fix.** A mod that allow-lists version strings +(`GameVersion.get() == "red" or ...`) excludes itself from Gold by construction, +and no adapter should special-case it. Neither is a Gen 1 screen id: Gold's +builtins carry a `Gen2` prefix, so a mod matching `id == "BoxMenu"` matches +nothing. A write to a field on a live Gen 2 menu instance is inert where Gen 1 +read it back (`menu.onSwitch`, `menu.swapFrom`, `StartMenu`'s box geometry), +and `map.warpAt` is a name collision rather than a rename -- Gen 1's is a table +keyed by cell, Gold's is a method, so indexing or iterating it raises. All of +these are mod-side edits, each with a route that works on both generations; +`docs/preparing-your-mod-for-gen2.md` walks through them. + +## What works on Gold today + +**Screens.** The `screens` registry serves both generations. Gold's screens +are registered under `Gen2`-prefixed ids so a mod that replaces Gold's party +menu does not also replace Red's; `Screens.GEN2_IDS` in `src/ui/Screens.lua` +is the full list. Every screen Gold opens goes through an id, including the +boot cinema and the START menu. + +**Asset overrides.** `overrides/` shadowing and asset transforms work +unchanged: Gold's screens load art through `src/render/Assets.lua`, the same +choke point Gen 1 uses. + +**Content registries at the shared path.** `pokemon`, `moves`, `items`, +`type_chart`, `strings`, `font`, `screens`, `commands`, `tokens`, +`growth_rates`, `battle_sprite_scales`, `render_pipelines`, and the audio +family (`audio`, `music`, `sfx`, `cries`, `map_songs`). These keep their Gen 1 +target path, so one mod source targets both generations. + +The last two are the newest and each carries one caveat worth stating before +you write against it: + +- **`battle_sprite_scales`.** `src/ui/gen2/BattleState.lua:imageScale` walks + the merged table for a record whose `path` matches the pic being drawn, + skipping the registry's own `_owners` row, and `picScale` falls through to + the species record's `battleScaleFront` / `battleScaleBack` after it -- the + same image-then-species-then-default order Gen 1 resolves in. Because the key + is the asset path it also reaches the pics that are nobody's species: the + player's trainer back, the DUDE's, an opponent's frontpic. The **default** + differs and is not a registry record either side: Red's 32x32 back pics draw + at 2x, Gold's 48x48 ones fill their 6x6 box at 1x, so a scale that looks + right on Red is twice as large on Gold. At any scale the pic stays centred in + its box and standing on the same ground line. +- **`render_pipelines`.** `src/core/Game2.lua:load` installs + `src/render/Pipelines.lua` on Gold's dataset *after* `mods:load`, so the + merged table is the one it walks, and `Game2:draw` composites the + whole-frame half through `Pipelines.wantsPresent` / `Pipelines.present` with + the Gen 1 ctx keys (`width`, `height`, `scale`, `dpi`, `dpiX`, `dpiY`). The + **`drawWorld` half is inert on Gold**: its overworld draws straight to the + window rather than into a canvas the way `src/world/OverworldController.lua` + hands one to `Pipelines.drawWorld`. A drawWorld-only pipeline is not left + switched on and drawing nothing -- `Game2:load` retires a restored level for + one, leaving `options.pipelines` untouched so the mode comes back the day + Gold grows a world canvas. Gold also has no OPTION row for a pipeline + (`Pipelines.rows` is read only from `src/ui/OptionsMenu.lua`), so a Gold + player reaches one by its `hotkey`. + +**Content registries at a Gen 2 path.** `maps`, `tilesets`, `sprites`, `text`, +`encounters`, `trainers`, `palettes`, `icons`, `battle_anims`, `constants`, +`statuses`, `move_effects`, `item_effects`, `balls`, `ai_classes` and +`evolution_methods`. Same registry name, same verbs, a Gen 2 table underneath +(`data.gen2Maps`, `data.gen2Encounters`, `data.gen2Statuses`, ...). +`src/core/Game2.lua` loads the extracted ones into `game.data` before it +calls `mods:load`, and every consumer takes them by reference and never +copies, so what a mod merges is what the game walks: a registered map is a map +Gold can warp into, a patched tileset is the one `Map.new` reads, a patched +encounter table is the one the grass rolls. + +The battle-rule six are the newer half and work slightly differently: there is +no table on disk for them at all. They come into existence *as* the merge, and +each consumer reads a record through a lookup that falls back to its own module +records when no loader ran, so a mod-free Gold boot behaves identically: + +| registry | who reads it | +| --- | --- | +| `statuses` | `Battle.statusRecordFor` / `statusPenaltyFor`, `Catching.statusBonus`, `ItemEffects.healClassOf` | +| `move_effects` | `Battle.moveEffectRecordFor` (`useMove`'s dispatch) | +| `balls` | `Catching.recordFor` | +| `ai_classes` | `Ai.layersFor` (the ten `scoring.asm` passes, plus mod layers) | +| `evolution_methods` | `Evolution.methodFor` | +| `item_effects` | `ItemEffects.recordFor` / `partyAction` | + +`src/mods/Builtins.lua` seeds those six with **Gold's** records under Gen 2 +rather than Red's. It has to: both games call it `GREAT_BALL`, and Red's record +carries no `multiplier`, so seeding Red's would leave Gold's x1.5 reading nil. + +**Content registries that exist because Gold does.** Six systems Red has no +counterpart for, so there is no Gen 1 table to share and none of these carries +a Gen 1 target at all. The routed Gen 2 path is their only home, and +`Schemas.GEN1` gates them on a Red boot the way `Schemas.GEN2` gates +`map_scripts` on a Gold one -- reported, not silently merged. + +| registry | id space | who reads it | +| --- | --- | --- | +| `held_items` | item ids | `ItemEffects.heldItemFor`; the merged rows are written back onto `data.items` for `Battle:itemDef` | +| `phone_contacts` | `PHONE_*` (`data.gen2Constants.phoneContactOrder`) | `Phone.useRegistry`, folded onto the contact table | +| `decorations` | `"deco:<n>"` | `Decorations.attributes`, the single read point for an attribute row | +| `apricorns` | apricorn item ids | `Apricorns.useRegistry`, which rebuilds all three lookups and Kurt's menu order | +| `landmarks` | `LANDMARK_*` | `Nests.landmarkId` / `Nests.landmark`, which resolve a map header's landmark byte | +| `radio_channels` | station ids | `MapRadio.channelRecord`, which puts a registered station on the dial | + +`Game2:load` calls `Phone.useRegistry`, `Decorations.useRegistry`, +`Apricorns.useRegistry` and `ItemEffects.applyHeldItems` immediately after +`mods:load`, so the merge is live before the first frame. `landmarks` and +`radio_channels` need no such call: their consumers take `data` at call time. + +`landmarks` merges onto the cache's own `gen2Landmarks.landmarks` and +`held_items` onto the view `Game2` builds from `data.items`, so both fold +against the vanilla row -- a `register` for an existing id collides, a +`patch` stacks. The other four come into existence as the merge, seeded from +their module's literals by `src/mods/Builtins.lua`. + +Four honest limits on that surface: + +- `held_items` reaches the battle by being written back onto `data.items`, so a + held row for an id with no `data.items` record lands nowhere. To invent a + held item, register the `items` record too. The write-back is a diff against + a pre-merge snapshot, which is what lets `items` and `held_items` compose + instead of one reverting the other. +- `decorations` ids are `"deco:<n>"`, not `DECO_*` names: the cart's decoration + constants are a bare `const_def` block with no name table behind them, so + there is nothing in the ROM to spell them by. `battle_anims` addresses its + unnamed rows the same way. `n` is the attribute row's index, which is + `wMenuSelection`. +- `phone_contacts` does not register the four `PHONE_UNUSED` `const_skip` holes + (contact bytes 8, 9, 10 and 25). The manifest gives all four the same id, and + one id cannot key four rows. They stay copies of the wrong-number filler, + which is what the cart does with them. +- `radio_channels` and `phone_contacts` register *content*, not new UI: a + registered station gets a dial position and a name, and a registered contact + gets a row the Pokegear indexes, but neither invents a screen. + +**Record shapes.** A registry whose Gen 2 records genuinely differ carries a +Gen 2 schema beside its Gen 1 one (`gen2Fields` / `gen2Keys` / `gen2Write` in +`src/mods/Schemas.lua`, resolved by `Schemas.shapeFor`). The registry name, the +verbs and wherever possible the ids stay shared; only the record changes. The +differences an author meets: + +- **`pokemon`.** Gen 2 splits `special` into `specialAttack` / + `specialDefense`, names the level-up table `levelMoves` and the pic size + `picSize`, has no separate `level1Moves`, and points an evolution at `into` + rather than `species`. It also carries the breeding block (`eggGroups`, + `eggMoves`, `eggSteps`, `genderRatio`) and the wild held-item pair. +- **`encounters`.** The id is the encounter *kind*, not the map: + `mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } })`. + A map's row carries a `rates` set per time of day and one slot list. + `fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest` and `roamMaps` are + ids of their own. +- **`trainers`.** The id is the trainer *class*, and the record is + `{ name, index, attributes, baseMoney, encounterMusic, trainers, items }`, + with one entry per named trainer of the class. The registry writes one level + in, into `data.gen2Trainers.classes`, so the call shape is unchanged. +- **`icons`.** Two id forms in one registry, routed by the `ICON_` prefix a + sheet name carries: a species id names an assignment (a string, the sheet's + name), an `ICON_*` id names a sheet. +- **`palettes`, `battle_anims`, `constants`.** The id is a subtable of the + target: `pokemon` / `trainers` / `bg` / `objects` / `roofs` for palettes, + `scripts` / `moves` / `objects` / `framesets` / `oamsets` / `gfx` for + battle_anims, and one of Gold's 42 ordered ROM name lists (plus `mapGroups`, + `trainerClassMembers`, `types`) for constants. Those lists are ordered and + position *is* the id a script byte resolves through, so they replace rather + than append. + +Four more id-space notes, because the records at those paths came out of a +Gen 2 ROM: + +- Gold's `text` ids are ROM pointer strings such as `"55:4067"`, not the + `TEXT_*` names Red uses. `override` them by pointer; there is no name table. +- A Gen 2 tileset carries its walkability as `collision` where Gen 1 says + `walkable`. Both fields validate; only `collision` is read on Gold. +- A Gen 2 warp row carries `destGroup` / `destMapNum` beside the `destMap` / + `destWarp` pair Gen 1 also has. Both are optional in the shared schema, so a + Gen 1 warp row and a Gen 2 one both validate, and patching one of Gold's own + maps does not mean restating the ROM's map-group numbers. +- Gold writes `"burn"` / `"sleep"` into `mon.status` where Red writes `BRN` / + `SLP`. The `statuses` registry is the same registry; only the ids differ, and + they have to. + +**`mod.commands`.** Works on Gold. `src/script/gen2/Vm.lua` runs the cart's own +bytecode, so there is no opcode byte to hand a mod -- the seam is a row the +cart cannot write. `Opcodes.MOD_COMMAND` (`"modcommand"`) is an op *name* with +no byte behind it, and the VM dispatches it through the same merged +`data.commands` table Gen 1's runner resolves by name. Two row shapes reach it: + +```lua +{ op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native +{ "mymod:shake", 4, 2 } -- Gen 1 row +``` + +The second is the Gen 1 row shape verbatim, so one row list can serve both +games as long as every row in it is the mod's own verb. The handler is called +`fn(ctx, unpack(args))` with `ctx.vm` where Gen 1 has `ctx.runner`; it may +block on `ctx.vm:showText` / `:waitFrames`, and its return value speaks Gen 1's +control vocabulary (`"end"`, a row number, or nil). A missing or raising verb +is warned once per name and the rest of the list still runs. The engine's own +Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would +find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone. + +**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.** +Generation-agnostic; nothing to adapt. + +**`mod.world`.** Same method set, resolved against Gold's world +(`src/world/gen2/WorldAPI.lua`). Two differences show through and are +documented on the module: Gold's world is not a stack state, and Gen 2 event +flags are numeric ids into `wEventFlags` rather than string keys. +`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the +Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like +an extracted one and survives a map reload; it is not serialized, so a mod +respawns on `map.entered`. `queueScript` takes a small allowlist of verbs Gold +has its own entry points for (`start_battle "wild" species level`, `warp`, +`text`, `setflag`, `clearflag`) and refuses a list containing anything else +**by name, before the first row runs**, so a mod never gets a half-run queue. +`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no +byte for it) and returns `nil, reason` rather than approximating one. + +**Hooks and events that fire on Gold.** Every name below is the Gen 1 name +carrying the Gen 1 payload keys, because Gold's call sites reuse them rather +than defining a parallel vocabulary; where Gen 2 carries more, the payload +gains a field instead of the name gaining a prefix. + +- *Engine-wide, from the shared modules:* `game.ready`, `screen.pushed`, + `screen.popped`, `screen.render_visible`, `music.started`, `music.stopped`, + `music.select`, `music.volume`, `sound.played`, `zoom.range`, + `assets.transformed`, `mods.loaded`, `mod.options_changed`. +- *Overworld (`src/world/gen2/`):* `map.entered`, `map.exited`, + `map.reloaded`, `player.warped`, `world.stepped`, `world.interacted`, + `world.npc_spawned`, `world.trainer_engaged`, `world.blacked_out`, + `world.block_replaced`, `world.boulder_moved`, `world.tod_changed`, + `world.object_toggled`, `flag.changed`; hooks `warp.destination`, + `movement.collision`, `movement.speed`, `encounter.roll`, + `encounter.species`, `encounter.fishing`, `world.tod`, `map.palette`, + `fieldmove.eligibility`. `flag.changed` carries the numeric `wEventFlags` + id under Gen 1's `name` key, which is the one payload difference the + numeric flag space forces. +- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`, + `ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`, + `ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script + menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title + menus draw with does not raise it yet, so those two are composed through + their own hooks only. +- *The Oak speech (`src/ui/gen2/OakSpeech.lua`):* `intro.oak_speech.started`, + `intro.oak_speech.step`, `intro.oak_speech.answered`, + `intro.oak_speech.finished`, and the `intro.oak_speech.build` hook. Gold has + a real Oak speech, so it is the same extension point rather than a second + one: same names, same payload keys, same moments in the sequence. The beats + are a data table with the same step vocabulary (`say` / `pic` / `name` / + `choice` / `yesno` / `shrink` / `fn`, plus Gold's own `initclock` and + `demo`), and the step *ids* match Gen 1's wherever the moment is the same -- + `oak_welcome`, `demo_mon`, `world_spiel`, `ask_player_name`, `name_player`, + `legend`, `shrink` -- so `ModUI.insertStepBefore(steps, "name_player", ...)` + lands in the right place in both games. The two ids with no Gen 1 + counterpart are Gold's own beats, `init_clock` (the `farcall InitClock` the + speech opens with) and `oak_study` (the return to Oak for `_OakText5`). Gold + has no rival-naming or name-confirmation beats, so it raises no anchors for + them: the rival is named by `CopScript` in `maps/ElmsLab.asm`, hours later. +- *Battle (`src/battle/gen2/`):* `battle.started`, `battle.ended`, + `battle.turn_started`, `battle.turn_ended`, `battle.move_used`, + `battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`, + `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, + `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, + `battle.crit`, `battle.accuracy`, `battle.turn_order`, + `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, + `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm` + and `battle.catch_exp`. One payload difference: Gen 1's vanilla + `battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle + screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the + Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through + `ctx.battle.data` instead gets nil on Gold. +- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook + `evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits + `pokemon.caught` once the mon is in the party or the box, and + `src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps + each row's decision in `evolution.check`. The hook passes `data` where Gen 1 + passes `game`; positions 2-4 (mon, row, trigger) match. +- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, + `render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits + at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it + -- the logic tick before the pad is read, a pointer the touch overlay gets + first refusal on, the palette zone list handed to the present pass, the + letterbox, and the finished playfield rect -- and carries the same payload. + `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and + menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` + compute the same number. `render.zones` is handed `nil` in GBC mode (Gold + computes no zone of its own there) and the engine's own one-rect list in + CLASSIC mode; a rect that clamps to nothing is skipped rather than throwing, + which is what `src/render/Renderer.lua:scissorClamped` does on the Gen 1 side. +- *Sprites (`src/pokemon/Sprites.lua`, shared):* `pokemon.sprite`, + `pokemon.icon` and `player.sprite`. `pokemon.icon` is reached from + `src/ui/gen2/PartyMenu.lua` through the shared module, so it is one call site + serving both games. `player.sprite` is raised by `Sprites.playerPic`, which + Gold's battle back pic (`src/ui/gen2/BattleState.lua`), Hall of Fame and + intro call with an already-resolved path: Gold's trainer art is not in + `field.playerPics`, so the path is found first and the hook raised over it, + with the Gen 1 `ctx` keys (`side`, `kind`, `demo`, `battle`, `data`) + unchanged. The Gen 2 trainer card is the one player-art read still outside + it: its portrait is a tile sheet that also carries the frame tiles, not a + swappable pic. + `pokemon.sprite` has a second site of its own in + `src/ui/gen2/BattleState.lua`, which adds `letter` (Unown) and `shiny` to the + Gen 1 ctx keys -- both concepts Red does not have. +- *Save and the script VM:* `save.created`, `save.loaded`, `save.loading`, + `save.writing`; hooks `save.write`, `save.new_game`, `script.command`, and + the `script.started` / `script.ended` pair off `src/script/gen2/Vm.lua`. + `script.command` reports a mod's own row under the name `"modcommand"` with + the row's real operands, and may rewrite them, on the same path it wraps a + cart row. + +## New in Gen 2 + +These have no Gen 1 analogue -- Red has no friendship byte, no day care egg, +no Pokegear, no radio, no held items -- so they are the only places a new name +is justified. They are **live**, guarded by `Runtime.wants` / +`Runtime.wantsHook`, and each is driven through a real bus by +`tests/engine/gen2_new_seams.lua`. + +### Events + +| event | raised from | payload | +| --- | --- | --- | +| `happiness.changed` | `Happiness` (`ChangeHappiness`, `StepHappiness`) | `mon`, `event`, `reason` (`"event"` / `"step"`), `delta`, `from`, `to` | +| `breeding.egg_created` | `Breeding` (`DayCare_InitBreeding`) | `egg`, `mother`, `father`, `compatibility`, `stepsToEgg` | +| `egg.hatched` | `Breeding` | `mon`, `egg`, `slot`, `species`, `nickname` | +| `phone.call_received` | `PhoneRing.script` | `call`, `contact`, `name`, `className`, `special`, `scriptKey` | +| `clock.day_changed` | `Clock` | `day`, `previous`, `reason` | +| `pokerus.infected` | `Pokerus` | `party`, `slot`, `mon`, `strain`, `days`, `source` | +| `roamer.moved` | `Roamers` | `index`, `slot`, `species`, `from`, `to`, `reason` | +| `roamer.encountered` | `Roamers` | `index`, `slot`, `species`, `level`, `mapId` | +| `apricorn.converted` | `Apricorns` (Kurt) | `apricorn`, `ball`, `event` | +| `bug_contest.scored` | `BugContest` | `mon`, `score`, `place`, `results` | +| `unown.unlocked` | `Unown` (`UpdateUnownDex`) | `letter`, `name`, `word`, `count` | +| `radio.channel` | `MapRadio` | `station`, `channel`, `name`, `source` | +| `mail.written` | `Mail` | `entry`, `slot`, `mon`, `message`, `author`, `source` | +| `mail.read` | `Mail` | `entry`, `message`, `author`, `top`, `bottom` | +| `intro.boot.copyright` | `CopyrightSplash:enter` | `screen`, `game` | +| `intro.boot.gamefreak` | `GameFreakPresents:enter` | `screen`, `game` | +| `intro.boot.movie` | `GoldSilverIntro:enter` | `screen`, `game` | +| `intro.boot.movie_ended` | `GoldSilverIntro:finish` | `screen`, `game`, `skipped`, `frames` | +| `intro.boot.title` | `TitleState:enter` | `screen`, `game` | + +The four `intro.boot.*` cards are the GS boot cinema, and they are the one part +of Gold's intro with no Gen 1 moment to share a name with: Red boots into +`IntroMovie` with no copyright card, no GAME FREAK splash and no attract movie. +The Oak speech immediately after them is the opposite case and reuses +`intro.oak_speech.*` verbatim (see the shared table above). + +Each card raises its name the frame it comes up, because that is the moment a +mod can act on. Only the movie has an `_ended` name, and only because it +carries a fact nothing downstream does -- `skipped` is the difference between a +player who watched all 2335 frames and one who pressed START. The other three +cards chain straight into the next card, whose own event is their end. + +`delta` on `happiness.changed` is `to - from`, not the table's column, because +the 0 and $ff carry clamps are part of what the cart applied: a mon at 254 +gaining "5" gained 1. + +`clock.day_changed` compares against a process-local latch, so the first read +after a boot has nothing to compare against and raises nothing. That is by +design; it is a day *change*, not a day report. + +`unown.unlocked` is raised from `UpdateUnownDex` -- a form first entering the +`#DEX` list -- not from the four `ENGINE_UNLOCKED_UNOWNS_*` puzzle flags. Those +flags are written by the cart's own `setflag`, so there is no Lua transition at +the puzzle solve to hang a second event on yet. + +`mail.read` rides `Mail.lines` with a per-struct latch, because the read page +redraws every frame. The latch is re-armed by `Mail.get` / `Mail.mailbox`, +which is how both readers pick the letter they are about to open, so reopening +the same letter raises a second event. + +### Hooks + +| hook | wraps | ctx | vanilla answer | +| --- | --- | --- | --- | +| `held_item.trigger` | `Battle:heldEffect` | `battle`, `mon`, `item`, `def`, `effect`, `parameter`, `trigger` | `ctx.effect, ctx.parameter` | +| `breeding.compatibility` | `Breeding.compatibility` | `data`, `mon1`, `mon2`, `dayCare` | the vanilla byte | +| `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list | +| `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean | +| `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender | + +`held_item.trigger` is one hook over eight call sites, because on the cart +those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and +c, and the caller comparing b against the `HELD_*` it cares about). `trigger` +says which comparison is about to happen: `"priority"` (Quick Claw), +`"damage"` (Scope Lens and the type-boost family), `"endure"` (Focus Band), +`"flinch"` (King's Rock), `"accuracy"` (BrightPowder), `"confuse"`, +`"residual"` (the end-of-turn Leftovers / Berry / cure arm), and `"check"` for +any other read. Return nil to make the item do nothing at that trigger, or +another `HELD_*` name to substitute one -- every call site compares against a +name, so substitution is the whole mechanism. + +`held_item.trigger` wraps the *read*, so a mod can suppress or substitute an +effect from any item. Defining a **new** held item is the `held_items` +registry's job, and the two compose: register the row, then steer it from the +hook. + +`phone.contact_list` refuses an answer of the wrong length or with an unknown +contact id (unknown ids blank to 0 on purpose, so the Pokegear never indexes a +nil). It reorders and blanks the ten save slots; registering a contact id the +game does not know is `phone_contacts`' job. + +`shiny.roll` does not override a forced-shiny battle (`opts.shiny`), which is +how the cart's own scripted shiny Gyarados stays shiny. + +## Registries with no Gen 2 home + +Writing to one of these while Gold is running takes the write, drops it, and +reports it once per mod into the same error feed the manager shows. It is not +fatal: a mod that supports both generations registers its Gen 1 content +unconditionally and still loads the half that applies. The report is worded +from the boot's own generation, because the gating runs both ways. + +`rulesets`, `transitions`, `field`, `text_pointers`, `link_fields`, +`map_scripts`. + +`Schemas.GEN2` in `src/mods/Schemas.lua` is the authoritative table, and +`tests/engine/gate_gen2_mod_api.lua` holds it to the catalog. + +The list used to have three causes behind it and now has one. "No Data path +exists" closed when the overworld tables stopped loading off disk into World +fields. "The shape differs" closed when a registry gained the option of +carrying a Gen 2 record schema beside its Gen 1 one. What is left is one cause: + +**Gold reimplements the system without reading a registry.** The Gen 1 target +is still built and merged into, but nothing in a Gold boot ever looks at it, so +routing the registry would be a merge into a table with no reader -- exactly +the silent no-op the gate exists to prevent. Closing one of these is a consumer +change in the Gen 2 module first and a routing row second: + +- `rulesets`: no Gen 2 ruleset dispatch exists. +- `transitions`: Gold draws its own battle intro + (`src/ui/gen2/BattleTransition.lua`), and its `STYLES` is a boolean *set* of + the four cart wipes (`spin`, `speckle`, `zoom`, `sine`) rather than the + `{ frames, draw, sound, flash }` record this registry carries. There is no + styleDef lookup for a registered id to reach, so a mod style would fail the + `STYLES` membership test and fall back to vanilla -- routing it would be the + silent no-op, not the fix. +- `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in + `data.gen2Maps` and the VM's own tables. +- `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers. +- `link_fields`: link play is Gen 1 only. +- `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM + pointer, and a Lua row list merged into it is not something + `src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in + `World`, not just the verb table `mod.commands` already has. The + `script.started` / `script.ended` / `script.command` seams do fire, so a mod + observes and can veto a script it cannot yet author whole. + +Four of this list closed after it was written, and how they closed is the +pattern for the rest: + +- **`growth_rates`** now routes to the SHARED Gen 1 target. Gold's curves are + coefficient rows in the extracted `pokemon.lua`, so `src/mods/Builtins.lua`'s + Gen 2 registrant wraps each as the `{ expForLevel }` record Gen 1's registry + uses, and `src/battle/gen2/Mon.lua:growthFor` is the one accessor all six + readers go through (`Mon` twice, `BattleState`, `SummaryMenu`, `Breeding`, + `ItemEffects`). One record shape, one id space, one mod source for both + games. Because it is routed, the `pokemon` schema's `growthRate` reference is + now checked rather than skipped, and it resolves: both sides say + `GROWTH_MEDIUM_SLOW`. +- **`tokens`** was on the list by mistake rather than by cause. `TextBox.new` + runs `TextBox.substitute` on every box in both generations and `substitute` + reads `game.data.tokens`, so the shared target was live on Gold the whole + time. A `{NAME}` a mod registers expands in the world, the menus and the VM's + pages alike. +- **`battle_sprite_scales`** closed consumer-first, the `growth_rates` way: + `src/ui/gen2/BattleState.lua` grew `imageScale` / `picScale`, a faithful + mirror of Gen 1's `BattleState.imageBattleScale` / `resolveBattleScale` down + to skipping `_owners` and the image-then-species-then-default order, so the + registry now routes to the SHARED Gen 1 path and one record serves both + games. Only the default is generation-specific, and neither side reads that + from the registry. +- **`render_pipelines`** closed because the reader moved, not the registry: + `src/core/Game2.lua:load` installs `src/render/Pipelines.lua` on Gold's + merged dataset after `mods:load` and `Game2:draw` composites `present`. The + `drawWorld` half is still inert, which is why this one is worth reading the + caveat above for -- it is routed on the strength of the half that works, and + Gold retires a drawWorld-only level rather than pretending. + +## Hooks and events Gold does not raise yet + +Gold has its own draw path, intro, evolution and sprite lookups, so the call +sites in those Gen 1 modules are not on Gold's path. The names are not taken +and not reserved for Gen 1: when a Gen 2 call site lands it uses the existing +name and the existing payload, plus fields where Gen 2 genuinely carries more +(the split special stats, held items on a trainer roster). + +The list is much shorter than it was. What is outstanding, in descending value: + +- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its + own yet. +- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The + Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a + Gen 2 surface and a cross-generation pairing is refused by name), but nothing + in `src/ui/gen2/` opens onto the protocol, so these raise nowhere. + +Four groups that used to sit here have since landed and moved to the shared +table above: the frame seams (`render.compose` / `render.hud` / +`render.letterbox` / `render.zones`, `input.step` / `input.pointer`), the three +battle seams (`battle.overlay`, `battle.low_health_alarm`, +`battle.catch_exp`), the two sprite lookups (`pokemon.sprite`, +`pokemon.icon`), and the catch/evolution trio (`pokemon.caught`, +`pokemon.evolved`, `evolution.check` -- `src/ui/gen2/BattleState.lua` emits +`pokemon.caught` from `pushCaught` once the mon is in the party or the box, and +`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps +each row's decision in `evolution.check`). + +Three partial coverages worth knowing about, because "the hook exists" is not +the same as "the hook sees everything": + +- `encounter.roll` / `encounter.species` are wired into the grass/water step, + `randomwildmon`, the Bug Contest and SWEET SCENT, but **not** into + `World:tryHeadbutt`, `World:rockMonEncounter` or `Roamers.checkEncounter`. + Those three read row shapes that are not `{ species, level }` slot lists, so + a mod that reskins encounters misses headbutt trees, rock smash and the + roamers. +- `src/ui/gen2/BattleState.lua` builds a flat `opts` for `Catching.attempt` + with no `data` in it, so a mod-registered ball is readable through + `Catching.recordFor` but is not yet resolved at the real throw site. +- Three Gold UI files carry their own copy of the status HUD labels the merged + `statuses` records now hold as `hudLabel`, so a mod status shows no label in + the battle HUD, the party menu or the summary page until they read + `Battle.statusRecordFor(data, status).hudLabel`. The values are identical + today, so nothing vanilla is affected. + +## Gen 2 tables with no registry + +`Game2:load` assigns 24 `data.gen2*` tables and 12 of them are registry-backed, +so twelve sit in `game.data` on a Gold boot with no registry pointing at them: +`gen2Marts`, `gen2Roofs`, `gen2StdScripts`, `gen2EventTables` (the phone book, +in-game trades, elevator labels, decoration descriptions), `gen2InitialEvents`, +`gen2Pokedex`, `gen2MenuGfx`, `gen2Intro`, `gen2Credits`, `gen2Diploma`, +`gen2Trade`, and `gen2Scripts` (which the `map_scripts` registry does reach, so +it is the one of the twelve that is not out of reach). Naming registries for the +rest is new API surface rather than a routing change, so it is deliberately not +done yet. + +## Testing a Gen 2 mod + +Static first. `gen2check` reads the manifest, scans every `.lua` the package +carries and cross-references what it finds against the coverage table above: + +```sh +python3 tools/modkit.py gen2check my_mod # or a path +python3 tools/modkit.py gen2check my_mod --notes # + the caveat on each backed member +``` + +It reports one of `will load`, `will load but degrade` or `will not work`, with +a `MK4xx` finding per site and an `unresolved:` note, carrying a file and a +line, for every reach a static scan could not follow. Exit 0 clean, 1 on a +fatal finding (or any finding under `--strict`), 2 on usage; `--json` emits the +whole batch as one document, and `--quiet` prints the findings alone, so a +clean mod prints nothing and the exit code is the answer. The rule ladder is +`MK400`-`MK410` and is listed in `tools/modkit.py`'s section header. + +Then the headless harness, which takes the generation without booting Gold: + +```lua +local run = T.sdk.loadMod("mods/my_mod", { generation = 2 }) +T.eq(run.mod and run.mod.state, "loaded", + "runs on gen 2: " .. tostring(run.mod and run.mod.skipReason)) +T.eq(#run.errors, 0, "and loads with no boot errors") +``` + +Everything else is the production path: same loader, same validate, same +topological sort, same merge. Assert the state as well as the error count: a +gate skip is deliberately not an error, so `#run.errors == 0` passes for a mod +that never ran a line. + +Neither substitutes for a real Gold boot, and the two output channels there are +not the same. The adapter's own warnings (`Gen2Compat.warnOnce`) go to the log +only, each attributed to the mod holding the facade. The boot error feed the +manager shows is `loader.errors`: a failed mod, a duplicate id, a registry with +no Gen 2 target, a cross-validation problem, and a require for a Gen 1 module +the adapter does not serve. A skipped mod and a degraded member are on neither +list, by design. diff --git a/docs/modding.md b/docs/modding.md index b13fb048..2d9a348b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -12,12 +12,35 @@ The modding book lives on the - [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries) — every registry, generated from `src/mods/Schemas.lua`. -Regenerate the reference straight into a wiki checkout: +Regenerate the reference. With no argument it writes in-repo, to +`docs/modding/reference/registries.md`; name a wiki checkout to write the +wiki's own page name into it instead: ```sh +luajit tools/gen_registry_docs.lua luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Mods and Gold (Gen 2) + +The mod API is one API across both generations, but Gold runs its own battle +engine, overworld, script VM and save format, so a mod says which games it is +for and Gold serves a declared subset of the surface. + +- [`docs/preparing-your-mod-for-gen2.md`](preparing-your-mod-for-gen2.md) + the migration guide: what breaks, the `games` manifest key, the module + adapter, the patterns no adapter can fix, and a worked before/after. +- [`docs/mod-api-gen2-compat.md`](mod-api-gen2-compat.md) + the reference: every registry, hook and event, whether Gold serves it, and + the record-shape differences where it does. + +Start with the checker, which reads your manifest and scans your Lua against +the adapter's own coverage table: + +```sh +python3 tools/modkit.py gen2check mods/my_mod +``` + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md new file mode 100644 index 00000000..58fbc831 --- /dev/null +++ b/docs/modding/reference/registries.md @@ -0,0 +1,1169 @@ +<!-- Generated by tools/gen_registry_docs.lua from src/mods/Schemas.lua. + Do not edit by hand; regenerate after any schema change. --> + +# Registry reference + +One section per registry: merge semantics, the `Data` table the merge +writes, and the value schema. Where Gold differs -- a different table, a +different record, or no home at all -- the registry carries a Gen 2 +subsection built from the same catalog entry. Concepts and verbs: +[Concepts: Registries](Concepts-Registries). + +## ai_classes + +- semantics: `record` +- target: `Data.ai_classes` + +| field | type | required | +|---|---|---| +| `brain` | function | no | +| `chance` | integer 0..256 | no | +| `choose` | function | no | +| `hpBelow` | integer >= 1 | no | +| `item` | items id | no | +| `kind` | one of "class" | "layer" | "brain" | no | +| `onStatus` | boolean | no | +| `score` | function | no | +| `switch` | boolean | no | +| `switchBelow` | integer >= 1 | no | +| `switchChance` | integer 0..256 | no | +| `uses` | integer >= 0 | no | + +```lua +mod.content.ai_classes:patch("OPP_BROCK", { uses = 9 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2AiClasses` + +## apricorns + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `apricorn` | items id | yes | +| `ball` | items id | yes | +| `event` | integer >= 0 | yes | +| `index` | integer >= 1 | yes | + +```lua +mod.content.apricorns:override("RED_APRICORN", { apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, index = 1 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Apricorns` + +## audio + +- semantics: `record` +- target: `Data.audio` +- **deprecated** -- use sfx / cries / map_songs / music +- value: any value + +```lua +mod.content.audio:override("mapSongs", { ... }) +``` + +## balls + +- semantics: `record` +- target: `Data.balls` + +| field | type | required | +|---|---|---| +| `attempt` | function | no | +| `autoCatch` | boolean | no | +| `flicker` | boolean | no | +| `hpFactor` | integer >= 1 | no | +| `randMax` | integer 0..255 | yes | +| `tossAnim` | string | no | +| `wobbleFactor` | integer >= 1 | no | + +```lua +mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Balls` + +## battle_anims + +- semantics: `record` +- target: `Data.battle_anims` +- value: {seq, source?} | {blocks, type?} | {height, path, source?, tiles, width} + +```lua +mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2BattleAnims` + +The record differs; the registry name, the verbs and the id space +do not. + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `bank` | integer >= 0 | +| `framesets` | map of string -> list of list of any value | +| `generation` | integer >= 1 | +| `gfx` | map of string -> {image, tiles, wide} | +| `ids` | map of string -> string | +| `moves` | map of string -> string | +| `oamsets` | map of string -> {sprites, vtile} | +| `objects` | map of string -> {fixY?, flags?, frameset, func, gfx, palette} | +| `scriptOrder` | list of string | +| `scripts` | map of string -> list of list of any value | +| `source` | string | + +```lua +mod.content.battle_anims:patch("moves", { SHADOW_BALL = "5e86" }) +``` + +## battle_sprite_scales + +- semantics: `record` +- target: `Data.battle_sprite_scales` + +| field | type | required | +|---|---|---| +| `path` | file path | yes | +| `scale` | number 0.25..4 | yes | + +```lua +mod.content.battle_sprite_scales:register("abra_back", { + path = "assets/generated/battle/back/abrab.png", + scale = 1.5, +}) +``` + +Scales one battle pic by its asset path, overriding the species-level +`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that +image. The only way to scale a pic that is not species-keyed, like the +player's trainer back sprite. Resolution order at draw time: +image-level, then species-level, then the defaults (1x front, 2x back). +The pic stays grounded at any scale: player feet stay flush on the +text-box top, the enemy pic stays bottom-pinned in its slot, and the +scale composes with the send-out grow animation. + +## commands + +- semantics: `record` +- target: `Data.commands` +- value: function | {blocking?, fn, foreground?} + +```lua +mod.content.commands:register("shake_screen", function(ctx, frames) ... end) +``` + +## constants + +- semantics: `deep` +- target: `Data.constants` + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `badges` | list of {icon?, id, item?, name?} | +| `bagSize` | integer >= 1 | +| `boxCount` | integer >= 1 | +| `boxSize` | integer >= 1 | +| `coinCap` | integer >= 0 | +| `dexDigits` | integer >= 1 | +| `dexSize` | integer >= 1 | +| `encounterBuckets` | list of integer 1..256 | +| `hmMoves` | list of moves id | +| `levelCap` | integer >= 1 | +| `moneyCap` | integer >= 0 | +| `moveMax` | integer >= 1 | +| `partyMax` | integer >= 1 | + +```lua +mod.content.constants:patch("levelCap", 80) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Constants` + +The record differs; the registry name, the verbs and the id space +do not. + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `battleAnimBgPaletteOrder` | list of string | +| `battleAnimFramesetOrder` | list of string | +| `battleAnimFuncOrder` | list of string | +| `battleAnimGfxOrder` | list of string | +| `battleAnimOamsetOrder` | list of string | +| `battleAnimObPaletteOrder` | list of string | +| `battleAnimObjectOrder` | list of string | +| `battleBgEffectOrder` | list of string | +| `cmdQueueOrder` | list of string | +| `decoDescOrder` | list of string | +| `eggGroupOrder` | list of string | +| `environmentOrder` | list of string | +| `evolveMethodOrder` | list of string | +| `fishGroupOrder` | list of string | +| `floorOrder` | list of string | +| `generation` | integer >= 1 | +| `growthRateOrder` | list of string | +| `heldEffectOrder` | list of string | +| `iconOrder` | list of string | +| `itemMenuOrder` | list of string | +| `itemNameCount` | integer >= 0 | +| `itemOrder` | list of string | +| `landmarkOrder` | list of string | +| `mapCallbackOrder` | list of string | +| `mapGroups` | list of {group, height, map, name, width} | +| `mapOrder` | list of string | +| `moveEffectOrder` | list of string | +| `moveOrder` | list of string | +| `musicOrder` | list of string | +| `numOverworldSprites` | integer >= 0 | +| `paletteOrder` | list of string | +| `phoneContactOrder` | list of string | +| `pocketOrder` | list of string | +| `sfxOrder` | list of string | +| `source` | string | +| `spawnOrder` | list of string | +| `specialCallOrder` | list of string | +| `specialOrder` | list of string | +| `speciesOrder` | list of string | +| `spriteOrder` | list of string | +| `spritePokemon` | integer >= 0 | +| `stdScriptOrder` | list of string | +| `tilesetOrder` | list of string | +| `tradeDialogOrder` | list of string | +| `tradeGenderOrder` | list of string | +| `trainerClassMembers` | map of string -> list of string | +| `trainerClassOrder` | list of string | +| `trainerTypeOrder` | list of string | +| `treeMonSetOrder` | list of string | +| `types` | map of string -> integer >= 0 | + +```lua +mod.content.constants:patch("speciesOrder", { [252] = "MODMON" }) +``` + +## cries + +- semantics: `record` +- target: `Data.audio.cries` +- value: {header, length, pitch} | {file} | {base, length?, pitch?} | {chip, length?, pitch?} + +```lua +mod.content.cries:patch("PIKACHU", { pitch = 200 }) +``` + +## decorations + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `action` | string | no | +| `flag` | integer >= 0 | yes | +| `name` | string | yes | +| `sprite` | integer 0..255 | yes | +| `type` | integer 1..6 | yes | + +```lua +mod.content.decorations:patch("deco:2", { name = "COZY" }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Decorations` + +## encounters + +- semantics: `record` +- target: `Data.encounters` + +| field | type | required | +|---|---|---| +| `grass` | {rate, slots} | no | +| `id` | string | no | +| `water` | {rate, slots} | no | + +```lua +mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Encounters` + +The record differs; the registry name, the verbs and the id space +do not. + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `bugContest` | list of {chance, max, min, species} | +| `fishGroups` | map of string -> {chance, good, id?, index?, old, super} | +| `generation` | integer >= 1 | +| `grass` | map of string -> {map?, rates, slots} | +| `roamMaps` | list of {map, to} | +| `rocks` | map of string -> string | +| `source` | string | +| `swarmGrass` | map of string -> {map?, rates, slots} | +| `swarmWater` | map of string -> {map?, rate, slots} | +| `treeSets` | map of string -> {common, rare} | +| `trees` | map of string -> string | +| `water` | map of string -> {map?, rate, slots} | + +```lua +mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } }) +``` + +## evolution_methods + +- semantics: `record` +- target: `Data.evolution_methods` + +| field | type | required | +|---|---|---| +| `check` | function | yes | +| `describe` | function | no | + +```lua +mod.content.evolution_methods:register("FRIENDSHIP", { check = fn }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2EvolutionMethods` + +## field + +- semantics: `deep` +- target: `Data.field` + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `badgeGates` | map of string -> {badge?, coords?, failText?, guards?, passText?, passedFlag?, text?} | +| `boot` | {lastHeal?, namePresets?, playerName?, rivalName?, screens?, startFacing?, startMap?, startMoney?, startX?, startY?, starterScript?, title?} | +| `flyOrder` | list of string | +| `hiddenItems` | map of string -> list of {item, x, y} | +| `ledges` | list of {facing, input, ledgeTile, standingTile, tileset?} | +| `playerPics` | {back?, demoBack?, front?, oakBack?} | +| `townMap` | {background?, cursorOrder?, gridPixelSize?, locations?, nest?} | + +```lua +mod.content.field:patch("boot", { startMap = "SABLE_COVE" }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## font + +- semantics: `record` +- target: `Data.font` +- value: {advance?, base, charmap?, glyphsPerRow?, image} | {code, seq} | {bold?, file?, size?, spacing?, tiles?, yOffset?} + +```lua +mod.content.font:register("charmap:hiragana_a", { seq = "あ", code = 256 }) +``` + +## growth_rates + +- semantics: `record` +- target: `Data.growth_rates` + +| field | type | required | +|---|---|---| +| `expForLevel` | function | yes | + +```lua +mod.content.growth_rates:register("ERRATIC", { expForLevel = fn }) +``` + +## held_items + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `heldEffect` | string | yes | +| `heldParameter` | integer 0..255 | no | + +```lua +mod.content.held_items:override("LEFTOVERS", { heldEffect = "HELD_LEFTOVERS", heldParameter = 0 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2HeldItems` + +## icons + +- semantics: `record` +- target: `Data.icons.bySpecies` +- value: string | {frames?, image} + +```lua +mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 } +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Icons` + +The record differs; the registry name, the verbs and the id space +do not. +- value: string | {frames, height, id?, image, index?, width} + +```lua +mod.content.icons:override("TOTODILE", "ICON_MONSTER") +``` + +## item_effects + +- semantics: `record` +- target: `Data.item_effects` + +| field | type | required | +|---|---|---| +| `battle` | boolean | no | +| `field` | boolean | no | +| `needsTarget` | boolean | no | +| `use` | function | yes | + +```lua +mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2ItemEffects` + +## items + +- semantics: `record` +- target: `Data.items` + +| field | type | required | +|---|---|---| +| `ball` | balls id | no | +| `effect` | item_effects id | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `machine` | {kind, move, number} | no | +| `name` | string | yes | +| `needsTarget` | boolean | no | +| `price` | integer >= 0 | yes | +| `tossable` | boolean | no | + +```lua +mod.content.items:patch("POTION", { price = 100 }) +``` + +## landmarks + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `id` | string | no | +| `index` | integer >= 0 | yes | +| `name` | string | yes | +| `x` | integer >= 0 | yes | +| `y` | integer >= 0 | yes | + +```lua +mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Landmarks.landmarks` + +## link_fields + +- semantics: `record` +- target: `Data.link_fields` + +| field | type | required | +|---|---|---| +| `pack` | function | no | +| `rev` | integer >= 0 | string | yes | +| `unpack` | function | no | + +```lua +mod.content.link_fields:register("held_item", { rev = 1, pack = fn, unpack = fn }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## map_scripts + +- semantics: `compose` +- target: `Data.map_scripts` +- value: {onBoulderMoved?, onEnter?, onInteract?, onStep?, onVictory?, priority?, scripts?, snorlaxWake?, talk?} + +```lua +mod.content.map_scripts:register("PALLET_TOWN", { talk = { ... } }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## map_songs + +- semantics: `record` +- target: `Data.audio.mapSongs` +- value: music id + +```lua +mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1") +``` + +## maps + +- semantics: `record` +- target: `Data.maps` + +| field | type | required | +|---|---|---| +| `blocks` | list of integer 0..255 | yes | +| `borderBlock` | integer 0..255 | no | +| `connections` | map of one of "north" | "south" | "east" | "west" -> any value | no | +| `height` | integer >= 1 | yes | +| `id` | string | yes | +| `index` | integer >= 0 | no | +| `label` | string | no | +| `objects` | list of any value | no | +| `palette` | string | no | +| `signs` | list of any value | no | +| `tileset` | tilesets id | yes | +| `warps` | list of {destGroup?, destMap, destMapNum?, destWarp, x, y} | no | +| `width` | integer >= 1 | yes | + +```lua +mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Maps` + +## migrations + +- semantics: `compose` +- target: none +- value: {run, since} + +```lua +mod.content.migrations:register("my_mod", { since = "1.0.0", run = fn }) +``` + +## move_effects + +- semantics: `record` +- target: `Data.move_effects` + +| field | type | required | +|---|---|---| +| `accuracyChecked` | boolean | no | +| `kind` | one of "primary" | "secondary" | "full" | yes | +| `run` | function | no | + +```lua +mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = fn }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2MoveEffects` + +## moves + +- semantics: `record` +- target: `Data.moves` + +| field | type | required | +|---|---|---| +| `accuracy` | integer 0..100 | yes | +| `anim` | any value | no | +| `category` | one of "physical" | "special" | "status" | no | +| `chargeText` | string | no | +| `counterable` | boolean | no | +| `effect` | move_effects id | yes | +| `fixedDamage` | integer >= 1 | function | no | +| `highCrit` | boolean | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `multiHit` | integer >= 1 | list of integer >= 1 | no | +| `name` | string | yes | +| `power` | integer 0..255 | yes | +| `pp` | integer 0..64 | yes | +| `priority` | integer -7..7 | no | +| `semiInvulnerable` | boolean | no | +| `type` | type_chart id | yes | + +```lua +mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) +``` + +## music + +- semantics: `record` +- target: `Data.audio.songs` +- value: {address, bank, engine?} | {file, intro?, loopFile?, loopSeconds?, seconds?} | {channels, drums?, program, waves?} | {chip} + +```lua +mod.content.music:register("MOD_SONG", { file = "song.ogg" }) +``` + +## palettes + +- semantics: `record` +- target: `Data.palettes.palettes` +- value: list of list of integer 0..255 | {colors} + +```lua +mod.content.palettes:override("MEWMON", { {255,255,255}, ... }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Palettes` + +The record differs; the registry name, the verbs and the id space +do not. + +Id = a top-level key of the target table. Keys not listed here are +accepted and merged as-is. + +| key | type | +|---|---| +| `battleObjects` | map of string -> list of list of integer 0..255 | +| `bg` | list of list of list of integer 0..255 | +| `daytimes` | list of string | +| `environments` | map of string -> map of one of "MORN" | "DAY" | "NITE" | "DARK" -> list of integer >= 0 | +| `expBar` | list of list of integer 0..255 | +| `generation` | integer >= 1 | +| `hpBar` | map of one of "green" | "yellow" | "red" | "blue" -> list of list of integer 0..255 | +| `objects` | map of one of "MORN" | "DAY" | "NITE" | "DARK" -> list of list of list of integer 0..255 | +| `partyMenu` | list of list of list of integer 0..255 | +| `pokemon` | map of string -> {normal, shiny} | +| `roofSlot` | integer 0..7 | +| `roofs` | map of integer >= 0 -> {mornDay, nite} | +| `slotNames` | list of string | +| `source` | string | +| `trainers` | map of string -> list of list of integer 0..255 | + +```lua +mod.content.palettes:patch("pokemon", { TOTODILE = { shiny = { {255,255,255}, {255,0,0} } } }) +``` + +## phone_contacts + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `callee` | string | no | +| `calleeKey` | string | no | +| `calleeTime` | integer 0..7 | no | +| `caller` | string | no | +| `callerKey` | string | no | +| `callerTime` | integer 0..7 | no | +| `class` | string | no | +| `index` | integer >= 0 | yes | +| `map` | maps id | no | +| `member` | string | no | +| `number` | integer 0..255 | no | + +```lua +mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2PhoneContacts` + +## pokemon + +- semantics: `record` +- target: `Data.pokemon` + +| field | type | required | +|---|---|---| +| `baseExp` | integer 0..255 | yes | +| `baseStats` | {attack, defense, hp, special, speed} | yes | +| `battleScaleBack` | number 0.25..4 | no | +| `battleScaleFront` | number 0.25..4 | no | +| `catchRate` | integer 0..255 | yes | +| `cry` | cries id | no | +| `dex` | integer >= 1 | yes | +| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no | +| `evolutions` | list of {item?, level?, method, species} | yes | +| `frontSize` | integer 1..7 | yes | +| `growthRate` | growth_rates id | yes | +| `icon` | string | {frames?, image} | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `learnset` | list of {level, move} | yes | +| `level1Moves` | list of moves id | yes | +| `name` | string | yes | +| `palette` | palettes id | no | +| `spriteBack` | file path | yes | +| `spriteFront` | file path | yes | +| `tmhm` | list of moves id | no | +| `trueColor` | boolean | no | +| `types` | list of type_chart id | yes | + +```lua +mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.pokemon` + +The record differs; the registry name, the verbs and the id space +do not. + +| field | type | required | +|---|---|---| +| `baseExp` | integer 0..255 | yes | +| `baseStats` | {attack, defense, hp, specialAttack, specialDefense, speed} | yes | +| `battleScaleBack` | number 0.25..4 | no | +| `battleScaleFront` | number 0.25..4 | no | +| `catchRate` | integer 0..255 | yes | +| `cry` | cries id | no | +| `dex` | integer >= 1 | yes | +| `eggGroups` | list of string | no | +| `eggGroupsRaw` | integer 0..255 | no | +| `eggMoves` | list of moves id | no | +| `eggSteps` | integer >= 0 | no | +| `evolutions` | list of {comparison?, into, item?, level?, method, time?} | yes | +| `genderRatio` | integer 0..255 | no | +| `growthRate` | growth_rates id | yes | +| `growthRateId` | integer 0..255 | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `items` | list of items id | no | +| `levelMoves` | list of {level, move} | yes | +| `name` | string | yes | +| `picSize` | integer 1..7 | yes | +| `source` | string | no | +| `spriteBack` | file path | yes | +| `spriteFront` | file path | yes | +| `tmhm` | list of moves id | no | +| `tmhmRaw` | list of integer 0..255 | no | +| `trueColor` | boolean | no | +| `types` | list of type_chart id | yes | + +```lua +mod.content.pokemon:patch("TOTODILE", { baseStats = { specialAttack = 80 } }) +``` + +## radio_channels + +- semantics: `record` +- target: none + +Gen 2 only: Red, Blue and Yellow have no such system, so there is no +Gen 1 table to merge into and a write here on a Gen 1 boot is dropped +and reported. See the Gold subsection below for where it does land. + +| field | type | required | +|---|---|---| +| `channel` | integer 0..255 | yes | +| `name` | string | no | + +```lua +mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9, name = "PIRATE RADIO" }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2RadioChannels` + +## render_pipelines + +- semantics: `record` +- target: `Data.render_pipelines` + +| field | type | required | +|---|---|---| +| `available` | function | no | +| `drawWorld` | function | no | +| `gate` | function | no | +| `hotkey` | string | no | +| `invalidate` | function | no | +| `label` | string | yes | +| `levels` | list of string | no | +| `present` | function | no | +| `priority` | number | no | +| `update` | function | no | +| `worldPresent` | function | no | + +```lua +mod.content.render_pipelines:register("voxel", { label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn }) +``` + +## rulesets + +- semantics: `record` +- target: `Data.rulesets` + +| field | type | required | +|---|---|---| +| `name` | string | yes | + +```lua +mod.content.rulesets:register("no_crits", { name = "no crits", critRate = 0 }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## screens + +- semantics: `record` +- target: `Data.screens` +- value: function | {new} + +```lua +mod.content.screens:register("QuestLog", { new = function(game) ... end }) +``` + +## sfx + +- semantics: `record` +- target: `Data.audio.sfx` +- value: string | {address, bank, engine?} | {file} | {chip} + +```lua +mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" }) +``` + +## sprites + +- semantics: `record` +- target: `Data.sprites` + +| field | type | required | +|---|---|---| +| `frames` | integer >= 1 | yes | +| `id` | string | no | +| `image` | file path | yes | +| `paletteSource` | string | no | +| `trueColor` | boolean | no | +| `walker` | boolean | no | + +```lua +mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Sprites` + +The record differs; the registry name, the verbs and the id space +do not. + +| field | type | required | +|---|---|---| +| `frames` | integer >= 1 | yes | +| `icon` | string | no | +| `id` | string | no | +| `image` | file path | yes | +| `palette` | string | no | +| `paletteId` | integer 0..7 | no | +| `paletteSource` | string | no | +| `source` | string | no | +| `species` | pokemon id | no | +| `spriteType` | one of "WALKING_SPRITE" | "STANDING_SPRITE" | "STILL_SPRITE" | "POKEMON_SPRITE" | no | +| `trueColor` | boolean | no | +| `walker` | boolean | no | + +```lua +mod.content.sprites:patch("SPRITE_BEAUTY", { palette = "PAL_OW_RED", paletteId = 0 }) +``` + +## statuses + +- semantics: `record` +- target: `Data.statuses` + +| field | type | required | +|---|---|---| +| `beforeMove` | function | no | +| `beforeMovePriority` | integer >= 0 | no | +| `canInflict` | function | no | +| `catchBonus` | integer 0..255 | no | +| `cureOnSwitch` | boolean | no | +| `hudLabel` | string | no | +| `id` | string | no | +| `label` | string | yes | +| `onInflict` | function | no | +| `residual` | function | no | +| `shakeBonus` | integer 0..255 | no | +| `statPenalty` | {div, stat} | no | + +```lua +mod.content.statuses:patch("BRN", { catchBonus = 12 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Statuses` + +## strings + +- semantics: `record` +- target: `Data.strings` +- value: string + +```lua +mod.content.strings:override("But, it failed!", "Echec !") +``` + +## text + +- semantics: `record` +- target: `Data.text` +- value: string + +```lua +mod.content.text:override("_PalletTownText1", "HELLO!") +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Text` + +## text_pointers + +- semantics: `deep` +- target: `Data.text_pointers` + +Id = a top-level key of the target table; every key carries the same +shape. + +- value: map of string -> {asm?, cableClub?, label?, mart?, nurse?, pc?, text?} + +```lua +mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## tilesets + +- semantics: `record` +- target: `Data.tilesets` + +| field | type | required | +|---|---|---| +| `animation` | string | no | +| `blocks` | list of any value | yes | +| `counterTiles` | any value | no | +| `doorTiles` | any value | no | +| `id` | string | no | +| `image` | file path | yes | +| `imageHeight` | integer >= 1 | no | +| `imageWidth` | integer >= 1 | no | +| `tilesPerRow` | integer >= 1 | no | +| `trueColor` | boolean | no | +| `walkable` | any value | no | +| `warpTiles` | any value | no | + +```lua +mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Tilesets` + +## tokens + +- semantics: `record` +- target: `Data.tokens` +- value: function + +```lua +mod.content.tokens:register("CLOCK", function(game) return "12" end) +``` + +## trainers + +- semantics: `record` +- target: `Data.trainers` + +| field | type | required | +|---|---|---| +| `aiClass` | ai_classes id | no | +| `aiMods` | any value | no | +| `baseMoney` | integer >= 0 | no | +| `basePic` | trainers id | no | +| `battleTheme` | music id | no | +| `brain` | function | no | +| `id` | string | yes | +| `index` | integer 0..255 | no | +| `name` | string | yes | +| `paletteSource` | string | no | +| `parties` | list of list of {level, species} | yes | +| `pic` | file path | no | + +```lua +mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 }) +``` + +### On Gold (Gen 2) + +- semantics: `record` +- target: `Data.gen2Trainers` + +The record differs; the registry name, the verbs and the id space +do not. + +| field | type | required | +|---|---|---| +| `attributes` | list of integer 0..255 | no | +| `baseMoney` | integer >= 0 | no | +| `encounterMusic` | music id | no | +| `id` | string | no | +| `index` | integer 0..255 | no | +| `items` | list of items id | no | +| `name` | string | yes | +| `trainers` | list of {id?, index?, name, party, trainerType?} | yes | + +```lua +mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) +``` + +## transitions + +- semantics: `record` +- target: `Data.transitions` + +| field | type | required | +|---|---|---| +| `draw` | function | no | +| `flash` | boolean | no | +| `frames` | integer >= 1 | yes | +| `sound` | string | no | + +```lua +mod.content.transitions:register("dissolve", { frames = 30, draw = fn }) +``` + +### On Gold (Gen 2) + +No Gen 2 home: Gold reimplements this system without reading the +registry, so a write here is dropped and reported on a Gold boot. +`docs/mod-api-gen2-compat.md` in the engine repo lists what is left +to do for each one. + +## type_chart + +- semantics: `record` +- target: `Data.type_chart` +- value: {multiplier} | {category, index?, name?} + +```lua +mod.content.type_chart:register("BUG>PSYCHIC_TYPE", { multiplier = 20 }) +``` + +## v1 aliases + +| alias | canonical | +|---|---| +| `scripts` | `map_scripts` | +| `ui` | `screens` | diff --git a/docs/new-features.md b/docs/new-features.md index 93c6d23e..4870e740 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -23,4 +23,21 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Direct-launch options** for shortcuts, Steam entries, and handheld frontends * **Custom boot branding** +## Pokémon Gold (Gen 2) + +A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge: + +* **Separate Gold save file** beside the Gen 1 ones +* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1 +* **UI that stays fixed while the overworld zooms** +* **Border-block surrounds** for maps smaller than the screen +* **Gold-specific launcher options** +* **Optional widescreen battle layout** +* **Skippable trade animation** with B or START +* **QUIT and EXIT GAME** from the menus +* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` +* **Followers** for mods, plus Gen 2-only registries and hooks +* **On-screen touch pad** and controller SELECT for registered items + Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`. + diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md new file mode 100644 index 00000000..81c8e9ad --- /dev/null +++ b/docs/preparing-your-mod-for-gen2.md @@ -0,0 +1,784 @@ +# Preparing your mod for Gen 2 (Gold) + +You have a mod that works on Red, Blue or Yellow, and you want it to work on +Gold. This is the migration guide: what breaks, what the engine papers over +for you, what it refuses to paper over, and the order to do the work in. + +`docs/mod-api-gen2-compat.md` is the reference for *what Gold serves*. This +document is the procedure for *getting your mod there*. Read that one when you +need to know whether a registry or a hook exists; read this one first. + +## What actually breaks, and why + +Gold is not a skin over the Gen 1 engine. It is a second engine living beside +the first one: `src/core/Game2.lua` owns the boot, `src/world/gen2/World.lua` +is the overworld, `src/battle/gen2/Battle.lua` is the battle, and +`src/script/gen2/Vm.lua` runs the cart's own bytecode instead of a Lua row +list. A Gold boot never loads `src/core/Game.lua`, +`src/world/OverworldController.lua` or `src/battle/BattleState.lua` at all. +The mod API on top is deliberately one API -- the same registry names, the +same hook names, the same event names, the same `mod.*` facade -- so a mod +that stays on that surface mostly moves across unchanged. What does not move +is everything underneath it. + +The failure that motivated all of this is quiet, which is what makes it worth +a whole document. A mod with `engine_internals` writes +`local Game = require("src.core.Game")` and patches a method on it. Under Gold +that require used to succeed: the file is on disk, `require` finds it, hands +back a perfectly good module table, and your patch lands on it. Nothing ever +instantiates that table, so the patch runs zero times and the only symptom is +that your mod does nothing. No error, no warning, no crash to bisect. Two +things fixed that. First, a mod is not loaded on a Gold boot unless it says it +is for Gold, so the default outcome is "not running" rather than "running +wrong". Second, when it does say so, a require made from your own file is +answered by an adapter (`src/mods/Gen2Compat.lua`) that presents the Gen 1 API +over Gold's internals, and a member the adapter cannot honestly back reads nil +instead of reading plausibly-wrong. + +## Step 1: run the checker before you change anything + +`modkit gen2check` reads your manifest, statically scans every `.lua` the +package carries, and cross-references what it finds against the adapter's own +coverage table. Run it first, because it tells you the size of the job in a +few seconds. + +```sh +python3 tools/modkit.py gen2check <id-or-path> [<id-or-path>...] +``` + +Real output, against a follower mod written for Yellow: + +``` +-- PokePCFollowers_VoxelMerge: api 1, profile content, no games declared, permissions engine_internals, 0 dependencies, game_version unset +MK400 ERROR manifest.json: no Gen 2 game in "games" (and no gen2compat), so a Gen 2 boot skips this mod; the rest of this report is what it would hit once it claims one +MK404 ERROR main.lua:575: BattleState.newWild has no Gen 2 backing: Gold has no factory that returns an unpushed battle, and World:startBattle constructs and pushes in one call. A mod that wraps newWild to rewrite the species must be pointed at the encounter.species hook, which Gold raises with the same name and shape (World:rollEncounter); this reads nil +MK404 ERROR main.lua:576: BattleState.newWild has no Gen 2 backing: ... ; nothing on a Gen 2 boot reads this write +MK409 WARN main.lua:13: allow-lists a Gen 1 version string, which excludes this mod from a Gen 2 game by construction; test for the capability the code needs instead of the version +MK409 WARN main.lua:424: ... (same, a second allow-list) +MK409 WARN main.lua:565: ... (and a third) +modkit: unresolved: 1 site: requires whose result is neither bound to a name nor indexed here, so where the module goes is not followed (main.lua:221) +modkit: unresolved: 5 debug upvalue calls whose target function this scan could not tie to an engine module, so the local they reach could not be resolved (main.lua:279, main.lua:285, main.lua:288, main.lua:321 and 1 more) +modkit: src.world.PikachuFollower.onMapEntered closes over 'shouldSpawn' on a Gen 2 boot, so the upvalue surgery at main.lua:325 lands as it does on Gen 1 +FAIL PokePCFollowers_VoxelMerge on gen 2: will not work (3 errors, 3 warnings) +``` + +Three kinds of line, and the difference matters: + +- **`MK4xx ERROR` / `MK4xx WARN`** are findings with a file and a line. Errors + set the exit code; warnings do not unless you pass `--strict`. +- **`modkit:` notes** are things the tool derived rather than found, or could + not decide at all. They never change the exit code. The `shouldSpawn` note + above is the tool resolving that member through the adapter on a Gen 2 boot, + enumerating the function's real upvalues, and confirming the surgery lands; + the `unresolved:` notes are the tool naming, with file and line, every reach + it saw and could not follow. +- **The verdict**: `will load`, `will load but degrade`, or `will not work`. + +The rule ladder: + +| rule | what it means | +| --- | --- | +| `MK400` | the manifest claims no Gen 2 game, so a Gen 2 boot skips the mod | +| `MK401` | a dependency claims no Gen 2 game, which takes you down with it | +| `MK402` | you require a Gen 1-only module the adapter does not serve | +| `MK403` | a Gen 2 boot runs a `gen2/` sibling of the module instead | +| `MK404` | a member you touch has no Gen 2 backing (the adapter's own reason is quoted) | +| `MK405` | a member you touch degrades and says so once | +| `MK406` | the signature moved under an alias | +| `MK407` | `debug` upvalue surgery the Gen 2 arm cannot take: the member is not a function there, or the function does not close over that local | +| `MK408` | upvalue surgery the scan could not resolve either way | +| `MK409` | a version allow-list, or a Gen 1 screen id | +| `MK410` | the entry chunk reads a member of a game that is not up yet | + +Flags: `--strict` promotes warnings to failures, `--notes` prints the adapter's +note for every *backed* member you touch (worth reading once per mod, because +several backed members are backed with a caveat), `--json` emits one document +for the whole batch, `--quiet` drops everything except the findings -- no +header, no notes, no verdict line, so a clean mod prints nothing at all and the +exit code is the whole answer. Exit code is 0 clean, 1 on a fatal finding, 2 on +usage. + +Name several mods in one invocation and they are read as one install set, so a +mod and its dependencies can answer each other's `MK401`. + +**What the checker cannot see, and now says so.** It is a static scan, not a +run. It follows more than it used to -- a require made through your own +`tryRequire`-style wrapper, `local ok, M = pcall(require, "...")`, an inline +`require("src.world.Map").waterTiles(...)`, a bracket index `M["member"]`, a +local hop `local F = M` -- so reaches that used to be invisible now produce +real findings, and a mod that passed before can fail now. + +Two places where it used to answer confidently and wrongly now do not. +`local A, B = require("src.world.Map")` is read as binding `A`, which is what +Lua does; it used to take the name nearest the `=` and pin the module on `B`, +so every reach off `A` went unchecked and every reach off `B` was checked +against a module that was never there. And a helper of your own is only read as +upvalue surgery when the scan can see it forward its own `(function, name)` +pair into the `debug` call; a helper that merely mentions `upvalue`, or that +finds the slot by walking `debug.getupvalue`, no longer has its call sites +read as naming an engine local, because they do not. + +What it still cannot follow it names instead of ignoring. Every unfollowed +reach comes back as an `unresolved:` note carrying a file and a line. The scan +side raises one for: + +- a require name built at runtime, whether handed in whole or concatenated + (`require("src.world." .. name)` is as unfollowable as `require(name)`); +- an engine module name handed to a call the scan does not follow; +- an engine module name spelled in a literal with no require attached; +- a require whose result is neither bound to a name nor indexed on the spot; +- a require in a multiple assignment whose value it cannot pair to a name; +- a name bound to a *member* of a module rather than the module; +- an engine module indexed with a computed key; +- `rawget` or `rawset` on a bound module: that goes straight to the table the + require shim hands back, so on a Gen 2 boot it reads or writes the + Gen2Compat facade and not the module behind it; +- an engine module read as a value rather than indexed, so where it goes from + there (a table field, a call argument, a metatable's `__index`) is not + followed; +- a `debug` upvalue call whose target function could not be tied to a module; +- a call through one of your own upvalue helpers that the scan could not + confirm carries an upvalue name through to the `debug` call. + +Four more come from the coverage side rather than the scan: a dependency that +is not installed beside your mod, a required name that is neither an adapter +nor a module in this checkout, a module with no coverage row at all, and a Gen +1 member the coverage table does not classify. + +The practical consequence is worth stating plainly: an empty finding list +*plus* no `unresolved:` notes now means the scan followed everything it saw, +and an empty finding list on its own does not. + +It is still silent on any member the adapter's coverage table does not record: +the table lists 481 members across the 15 served modules, which is a large +majority of what real mods touch and is not the whole Gen 1 API. A clean +`gen2check` means "nothing known-broken was found", not "this works". Boot it. + +## Step 2: declare which games the mod is for + +Nothing moves on disk. A mod is installed once, into `mods/<id>/`, and that one +directory serves every game. There is no `mods/gen1/`, no `mods/gen2/`, and no +per-generation copy: targeting is something the manifest *declares*, not +something the filesystem encodes. + +```json +{ + "id": "my_mod", + "name": "My Mod", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["gen1", "gen2"] +} +``` + +`games` is an optional array. Each entry is one of: + +| token | means | +| --- | --- | +| `"red"`, `"blue"`, `"yellow"`, `"gold"` | 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 | + +`src/mods/ModTargets.lua` is the one place those tokens are resolved, and it +derives the list from `GameVersion.ORDER` rather than restating it, so a game +added later needs no edit there. The scaffold writes the key for you: + +```sh +python3 tools/modkit.py scaffold my_mod --games gen1,gen2 +``` + +**Omitting `games` keeps the old meaning exactly.** No `games` key means Gen 1 +only, plus Gen 2 if the legacy `"gen2compat": true` flag is set. Every manifest +written before the key existed means precisely what it always meant. +`gen2compat` is still accepted and is purely additive: it *adds* the Gen 2 +games to whatever `games` says, so no manifest can lose a game it already ran +on. `Manifest.validate` (`src/mods/Manifest.lua:210-224`) resolves the two +into one ORDER-sorted `manifest.games` array and derives `manifest.gen2compat` +from it, which is why `"games": ["gen2"]` is honoured by the loader's gate +today with no other change. + +An unknown token warns and is dropped under `api` 1 and refuses the manifest +under `api` 2 (the normal `violation()` rule). A `games` array that names no +game this engine knows falls back to the default rather than orphaning the mod. +A non-array `games` is a hard error. + +### What you are claiming + +Adding a game to `games` is you saying *I have run this there*. It is not a +request for best-effort support and the loader does not treat it as one: a mod +that claims a game is loaded on that boot in full, with its registrations, its +subscriptions and its entry chunk, exactly like a mod written for it. If it is +half-working, the player sees a broken mod, not a partially-supported one. That +is the whole reason the key exists rather than being inferred. + +**Every token is enforced, per game.** `Loader:_gateGeneration` +(`src/mods/Loader.lua:447`) gates on `ModTargets.supports(manifest, version, +generation)` -- the same call both mod surfaces make -- so `"games": ["blue"]` +really does not load on Red, and the skip line is the launcher's line, `For +Blue, not Red`. `"games": ["gold"]` alone no longer loads on Red either: it +names one game, and that game is Gold. A manifest with no `games` and no +`gen2compat` still covers every Gen 1 game, so nothing written before the key +existed changes behavior; what changed is that a version-id token is now a +statement the boot keeps rather than a label the UIs draw. If you want a mod +everywhere, say so: `["gen1", "gen2"]` or `["all"]`. + +**Dependencies are contagious.** A mod whose hard dependency does not run here +is left out too, carrying the dependency's own wording (`depends on X, which +does not run here (For Blue, not Red)`). It is reported as a skip rather than a +failure and neither mod lands on the boot error list, but the mod does not run. +Every hard dependency in the chain has to cover the same games; `MK401` is the +checker's version of this question for the Gen 2 half of it. + +**The player can overrule you, in one direction only.** The in-game mod +manager offers `TRY HERE ANYWAY` on the detail pane for any mod that does not +claim *this* game (`src/mods/ManagerState.lua:386`), which now includes a Gen 1 +boot: a Blue-only mod is genuinely skipped on Red, so that row is the only way +to run it there. The choice is **per game**: `options.modsGen2[id]` is a +`{ [version] = true }` table, so forcing a mod onto Red does not force it onto +Gold. A stored legacy `options.modsGen2[id] = true` from before the key was +per-game reads as "the Gen 2 games", which is the only set it could ever have +affected, and it is expanded in place the next time the player answers. A +forced mod loads normally and keeps a note saying its author never verified it +here; the launcher shows it as `Forced onto Gold by you (untested)`. If the +override cannot be persisted the manager says `COULD NOT SAVE` rather than +promising a restart that would change nothing. + +### What the player sees + +All three surfaces read the same derivation -- the two UIs and the loader -- +so they cannot disagree about your mod. The launcher's mod panel carries a +`Show for:` chip row (All games / Red / Gold / ...) and a per-mod tag from +`ModTargets.chip` -- `GEN 1`, `GEN 1+2`, `RED/GOLD`, `BLUE` -- greyed out when +the mod does not run on the selected game, with the line `Not for this game` +(`src/import/LauncherView.lua:320`) and the detail from `ModTargets.detail`, +`For Gen 1, not Gold`. The in-game manager shows the same thing as +`ENABLED (NOT THIS GAME)` with the skipped glyph, plus an inert `FOR GEN 1+2` +row on the detail screen. The launcher's dependency verdict asks the same +question of your dependencies: a mod whose hard dependency does not run on the +selected game reads `Needs <id> (not for Gold)` rather than `Ready`. + +### One limit worth knowing + +**Per-game enable flags are still a preview.** The overlay +`options.modsByVersion[version][id]` exists and every surface goes through +`SaveData.modEnabled` / `SaveData.setModEnabled`, but +`SaveData.PER_VERSION_MODS` is `false` +(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers +nil for every caller, so the launcher panel, the in-game manager *and* the +loader all read and write the one shared `options.mods` flag and the overlay is +not consulted for enablement anywhere. That matters because the overlay is +plantable from an imported `.g1rmodlist`: keeping every reader on the same +scope as every writer is what stops a stored per-game flag from showing a mod +set no boot would honour. Nothing about this affects a mod author; it affects +what a player can currently express. + +Targeting is a different question from enablement and *is* enforced per game, +as above. The two do not share a switch. + +## Step 3: prefer the API over the modules + +Before doing any adapter work, check whether you need the modules at all. In +new code, take the live game from `mod.game` and the world from `mod.world`. +Both resolve per generation inside the loader (`src/mods/Loader.lua:1021`): +`mod.game` is `src/core/Game.lua`'s singleton under Gen 1 and the `Game2` +*instance* Gold injected under Gen 2, read on every touch rather than cached; +`mod.world` is `src/world/WorldAPI.lua` or `src/world/gen2/WorldAPI.lua` behind +one method set. Neither needs `engine_internals`. The `game.ready` payload and +every `ui.*` hook's first argument carry the same live game. + +Anything you can express as a registry write, a hook or an event subscription +is generation-agnostic already and needs nothing from this document. The +adapter exists for the code that was written before Gold did, and for the small +number of things the API genuinely does not reach. + +## Step 4: the adapter, module by module + +On a Gen 2 boot with mods present, `require` is interposed +(`Loader:_installDevShim`, `src/mods/Loader.lua:184`) and a require *made from +a mod's own chunk* for one of fifteen Gen 1 names is answered by +`src/mods/Gen2Compat.lua`. Engine code is unaffected: the shim compares the +caller's chunk name against the engine tree, so `src/render/PaletteFX.lua` +requiring `src.core.Game` still gets the real Gen 1 module on both generations. +This is not a dev-mode feature; it installs on any Gold boot that has mods. + +| the name you require | kind | what you get | backed / warned / absent | +| --- | --- | --- | --- | +| `src.core.Game` | facade | a live proxy onto the `Game2` instance | 70 / 9 / 12 | +| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua` | 56 / 5 / 68 | +| `src.world.Map` | alias | `src/world/gen2/Map.lua` | 28 / 2 / 9 | +| `src.world.NPC` | alias | `src/world/gen2/Npc.lua` | 27 / 0 / 1 | +| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 | +| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 | +| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 | +| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 | +| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 | +| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 | +| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 | +| `src.world.FieldDefaults` | facade | the `playerSprites` answer and named refusals | 5 / 2 / 3 | +| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` | 4 / 1 / 0 | +| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua` | 4 / 0 / 0 | +| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` | 1 / 0 / 0 | + +**Alias means the adapter *is* the Gen 2 module.** Your monkey-patch, your +`rawset` sentinel and your `==` idempotency check all land on the table Gold +actually runs, and `getmetatable(npc) == NPC` is true. Five names are aliases +because nothing less would work: mods set their own trailer's metatable to +`src.world.NPC`, a mod is handed `world.map` rather than building one, the +loader builds every `mod.world` out of `src.world.WorldAPI` so a copy would +give two, `src.world.PikachuFollower` is reached with `debug.setupvalue` on a +file-local, and `Screens` caches `src.ui.BoxMenu` for `"Gen2PcMenu"` so a +`.new` patch has to land there. + +Note that `src.ui.BoxMenu` points at `src/ui/gen2/PcMenu.lua`, not at +`src/ui/gen2/BoxMenu.lua`. Gen 1's `BoxMenu` is Bill's PC *top menu*, whose +Gold counterpart is `PcMenu`; Gold's `BoxMenu` is the withdraw/deposit *list* +that Gen 1 builds inline. + +**Facade means a translating wrapper.** `.overworld` resolves `Game2.world`, +`writeOptions` resolves `Game2:persistOptions`, `game.data.sprites` resolves +`data.gen2Sprites`, `NPC.new(data, mapId, objDef)` is sniffed apart from +`NPC.new(mapId, objDef, spriteDef)` and the movement vocabulary is translated +with it. The four UI facades (`PartyMenu`, `StartMenu`, `OptionsMenu`, +`BattleState`) are write-through: reads fall to the Gen 2 class and **writes go +to the Gen 2 class**, so `PartyMenu.update = wrapper` still patches the live +class Gold pushes. Your write also *reads back as your own value* -- after +`PartyMenu.new = wrapper`, `PartyMenu.new` is `wrapper` and nothing else, so +`rawequal` holds and an idempotency check works. That is what makes the ordinary +capture-and-chain idiom safe: a wrapper that calls the value it captured reaches +Gold's real constructor rather than re-entering the facade's own override. +Writing `nil` clears the member instead of re-exposing the override underneath. + +The `src.world.OverworldController` facade is a facade over the live `World`, +not over a class, so seven of its fields (`map`, `player`, `npcs`, `entities`, +`ghosts`, `npcPool`, `camera`) read **and write** through to the running world: +Gen 1's module *is* the singleton, so a write has to land somewhere real. A +write made before a world exists is dropped with a warning rather than +shadowing the world it would have applied to. + +### backed, warned, absent + +The adapter publishes what it covers, and the checker consumes that same table +rather than a copy of it. Exactly three statuses, and a member listed as both +resolves to the weaker one: + +- **`backed`** -- present, and it does the Gen 1 job on Gold. Read the note + anyway where there is one: several backed members are backed with a caveat + (`Boxes.COUNT` is 14 on Gold and not 12; `BattleState.say` ignores + `sayAuto`'s delay because Gold's messages always auto-advance; + `Collision.DELTA` is Gold's live table, so adding a key mutates Gold's own + movement). +- **`warned`** -- present, answers nil or degrades, and names itself once in + the log with your mod attributed. `Game.renderer`, `Game.load`, + `Game.step`, `game.data.field`, `game.data.constants`, + `ScriptRunner.resume` / `.update` / `.parallel`, `PartyMenu.tmhm` and + `OverworldController.neighbors` / `.npcByIndex` are here. `neighbors` is the + shape of the whole category: Gold's rows are `{ id, ox, oy, image }` where + Gen 1's are `{ map = mapDef, ox, oy }`, so the field warns and answers nil + rather than handing back a list whose `nb.map` is nil on every row. +- **`absent`** -- deliberately not on the table. It reads nil, which is the + honest failure. `BattleState.newWild`, `OverworldController.rollEncounter`, + `Map.warpPadOrHoleAt`, `PikachuFollower.shouldSpawn` and 157 others are + here. (`shouldSpawn` is absent as a *module member* on both generations: it + is a file-local, reached through `setShouldSpawn` or the upvalue of that + name, and the coverage table says so rather than implying a field exists.) + +"Absent" means *not served*, not *wrong*. Every one of them was left off for a +stated reason, and the reason is in the coverage note. `BattleState.newWild` is +the clearest case: Gold has no factory that returns an unpushed battle, because +`World:startBattle` constructs and pushes in one call, so a `newWild` taking a +species and a level would be a lie about what Gold's battle screen is. The +route for the thing you were actually doing (rewriting the species of a wild +encounter) is the `encounter.species` hook, which Gold raises under the same +name with the same shape. + +A member the table does not record is not a guarantee of anything. What it does +depends on the adapter: an alias hands you the Gen 2 module's own member, +whatever that is; a write-through facade falls to the Gen 2 class; the +`src.core.Game` facade names it in the log and reads nil; the +`src.world.OverworldController` facade reads nil silently. The checker is +silent about it too. + +### Reading the coverage yourself + +The table is queryable, and it is the same query the checker makes: + +```lua +local Gen2Compat = require("src.mods.Gen2Compat") + +Gen2Compat.modules() -- the 15 served names, sorted +Gen2Compat.serves("src.world.Map") -- true +Gen2Compat.memberStatus("src.battle.BattleState", "newWild") -- "absent" + +local c = Gen2Compat.coverage("src.world.Map") +-- { module, kind = "facade"|"alias", target, members = { [name] = status }, +-- notes = { [name-or-topic] = "one line" } } +``` + +`Gen2Compat.COVERAGE_VERSION` is 1 and `Gen2Compat.STATUS` carries the three +status strings. `notes` keys are documentation topics, not a member list: +dotted paths (`save.money`), field names (`warpAt`), hook names +(`hook ui.pc.items`) and bare topics (`identity`, `iteration`, `rawset`) all +appear there. `members` is the authoritative set. + +To dump the lot for one module: + +```sh +luajit -e 'package.path="./?.lua;"..package.path +local G=require("src.mods.Gen2Compat") +local c=G.coverage("src.world.OverworldController") +for m,s in pairs(c.members) do print(s,m) end +for k,v in pairs(c.notes) do print("note",k,v) end' +``` + +## The patterns no adapter can fix + +Five shapes come up in nearly every real Gen 1 mod, and none of them can be +fixed on the engine side without lying to you. Each one has a route that works +on both generations. + +### 1. A hardcoded version allow-list + +```lua +local v = GameVersion.get() +if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end +``` + +This excludes you from Gold by construction, and it does so *after* everything +else in your mod has been made to work, which is why it produces the most +confusing possible outcome: the adapter resolves, your patches land, and the +feature still never appears. `MK409` catches it. + +**Instead**, test for the thing the branch actually depends on. If it is there +because a member might be missing, test the member: + +```lua +local Follower = require("src.world.PikachuFollower") +if Follower.setShouldSpawn then ... end -- present on Gold, absent on Gen 1 +``` + +If it is there because a piece of per-cart content might be missing, test the +content -- `mod.find` and the merged data tables answer that in both games. +Version tests stay legitimate for genuinely per-cart *content*, which is what +Yellow's starter rename is; they are never right as a gate on a whole feature. + +### 2. String-matching a screen id + +```lua +if id == "BoxMenu" then ... end +``` + +Gold's builtin screens are registered under `Gen2`-prefixed ids, so this +matches nothing there. `Screens.GEN2_IDS` in `src/ui/Screens.lua` is the full +list, 51 ids: `Gen2BoxMenu`, `Gen2PartyMenu`, `Gen2NamingScreen`, +`Gen2Credits` and 47 more. `MK409` catches this exact line: it keys off the +string literal itself, not off a screen-shaped word elsewhere on the line, so +`if id == "BoxMenu" then` is flagged where it used to slip through. The price +of that is deliberate breadth -- any literal equal to a Gen 1 screen id with a +`Gen2` twin is warned about, wherever it appears -- so the message states what +is true of the literal rather than guessing what the surrounding code meant. +It is a warn, and reading past a false one costs you nothing. + +**Instead**, either match both ids, or stop matching ids and take the seam the +screen offers. Most screens a mod wants to decorate raise a hook whose name is +shared across both generations -- `ui.start_menu.items`, `ui.options.rows`, +`ui.party.submenu`, `ui.pc.items`, `ui.naming.grid`, `ui.list_menu` -- and a +hook subscription needs no id at all. Where you genuinely must key off the id: + +```lua +local BOX_IDS = { BoxMenu = true, Gen2PcMenu = true } +if BOX_IDS[id] then ... end +``` + +Watch the pairing. `ui.pc.items` has the same name on both sides but a +different menu behind it: Gen 1 raises it over the WHICH-PC list, Gold over +Bill's PC's own rows. And Gen 1's `BoxMenu` pairs with `Gen2PcMenu`, not with +`Gen2BoxMenu`. + +### 3. `debug.setupvalue` on an engine local + +```lua +local idx = findUpvalue(PikachuFollower.update, "shouldSpawn") +debug.setupvalue(PikachuFollower.update, idx, myPredicate) +``` + +This only ever worked because the Gen 1 file happened to hold that predicate in +a file-local of that name. Nothing about the engine promises it, and on the Gen +2 side the local has to exist under the same name and hold the same thing for +the surgery to land. Today it does: `src/world/gen2/Follower.lua:23` declares +`local shouldSpawn` for exactly this reason, so follower mods reaching for it +work unchanged on Gold. That is a deliberate courtesy, not a contract. + +`MK407` fires in the two cases where the surgery cannot land: when a Gen 2 boot +resolves the member to something that is not a function (so `debug.setupvalue` +raises), and when the function it does resolve to does not close over that +name, in which case the message quotes the upvalues it *does* close over. The +check resolves the member through the adapter exactly as the loader does and +enumerates the resolved function's real upvalues, so a local that merely +appears somewhere in the Gen 2 file is never mistaken for one -- that used to +be the check, and it blessed surgery that landed on nothing. `MK408` fires when +the scan could not resolve the member either way, which is what you get when +`luajit` is not on `PATH`: the check degrades to an honest warn, never to a +reassuring note. + +**Instead**, use the named seam when there is one, and fall back only when +there is not: + +```lua +if Follower.setShouldSpawn then + Follower.setShouldSpawn(myPredicate) -- Gen 2, and any future Gen 1 arm +else + patchUpvalue(Follower.update, "shouldSpawn", myPredicate) -- Gen 1 today +end +``` + +`Follower.setShouldSpawn` writes the same cell `debug.setupvalue` reaches, so +the two cannot disagree. Note the presence test is doing real work: +`src/world/PikachuFollower.lua` has no `setShouldSpawn`, so this is not a +rename you can apply blindly. Note also that the predicate is called +`(game, world)` on Gold where Gen 1 passes `(game, ow)` -- the same object under +a different name, so a predicate reading `ow.player` or `ow.map` is unchanged. + +### 4. Capturing state off `src.core.Game` at file scope + +```lua +local Game = require("src.core.Game") +local save = Game.save -- nil forever +local party = Game.save.party -- error at load +``` + +The module require itself is fine and is meant to be: the Gen 2 `src.core.Game` +is a proxy that reads the live `Game2` instance on *every* touch, precisely so +that a mod capturing it at file scope, before a save or a world exists, keeps +working once they do. What does not survive is capturing a *field* off it at +file scope, which snapshots nil. This is true on Gen 1 as well; Gold just makes +it bite more often because the entry chunk runs earlier relative to the world. +`MK410` catches the file-scope read of a member the Gen 1 module only ever +writes as `self.<name>`. + +**Instead**, read through the facade at the moment you need the value, or take +the live game from the `game.ready` payload: + +```lua +local Game = require("src.core.Game") +mod.events:on("game.ready", function(ev) + local game = ev.game -- the real Game2 instance + local party = Game.save.party -- read now, not at file scope +end) +``` + +Three further properties of the proxy that a Gen 1 mod can trip over, all +recorded in the coverage notes: + +- **Identity.** The proxy can never compare equal to the `Game2` instance the + `game.ready` payload carries. Lua 5.1 fires `__eq` only when both operands + share a metatable, so `Game == ev.game` is false on Gold. Do not use it as + an idempotency check. +- **Iteration.** `pairs`, `next` and `rawget` see an *empty* table, because the + proxy holds nothing of its own. Enumerate the `game.ready` payload instead. +- **`rawset`.** `rawset(Game, k, v)` lands on the proxy, reads back correctly + through the same facade, and is completely invisible to the engine. That + read-back is what hides it. Use a plain assignment, which writes through to + the live instance. + +The save layout moved too, and those fields are absent rather than aliased so +that a wrong read is loud rather than silent: `save.money` is +`save.player.money`, `save.player.map` / `.x` / `.y` / `.facing` are +`save.position.*`, and `save.player.rival` is `save.rival.name`. `save` itself +is a straight pass-through on purpose. + +### 5. Monkey-patching a class, and the two ways it goes wrong + +Patching a shared class method is *supported*, and this is worth stating +plainly because it is the thing most authors expect to have to rewrite. The +four UI facades write through: `__newindex` forwards to the Gen 2 class, so + +```lua +local PartyMenu = require("src.ui.PartyMenu") +local origUpdate = PartyMenu.update +function PartyMenu.update(self, dt) ... return origUpdate(self, dt) end +``` + +lands on the class Gold actually pushes. Aliases are the class, so the same +holds there. + +Two variants do not work, and neither can be made to. + +**Patching a member the Gen 2 class does not have.** The write succeeds, reads +back as your own function, and nothing ever calls it. `BattleState.newWild = +wrapper` is the canonical case: the assignment is taken, and no Gold code path +reads that name. This is the one place the read-back works against you, which +is why `MK404` reports the write site separately from the read site. + +**Patching a field on a live instance.** `menu.onSwitch = fn` writes a field +Gen 2 never reads -- Gold takes it as `onChoose` at construction. Same for +`menu.swapFrom` (renamed `switchFrom`) and for `StartMenu`'s `tx` / `ty` / `tw` +/ `th` / `anchor` / `maxVisible`, which do not exist on Gold at all because the +box is fixed at `Chrome.box(10, 0, 10, h)`. A write to any of them is inert. +Pass what you need to `.new` instead: `PartyMenu.new(game, { onSwitch = f })` +with no `battle`, `pickOnly` or `forceSwitch` opens the plain list and calls +`onSwitch(mon, menu)` on A, which is the Gen 1 behavior the facade reproduces. + +A close relative worth calling out because it errors rather than no-ops: +`map.warpAt` is a name collision, not a rename. Gen 1's is a *table* keyed by +cell; Gold's `Map:warpAt` is a *method* of the same name. `map.warpAt[cell]` +and `pairs(map.warpAt)` both raise, which is loud but points at your mod. +Enumerate `map.warps`, which Gold carries as an ordered array. + +## A worked migration + +Here is one real one, start to finish. The mod is a follower pack written for +Red/Blue/Yellow. `gen2check` reports `MK400` on the manifest, `MK404` twice on +`BattleState.newWild` and `MK409` on a version allow-list, plus a note +confirming its `shouldSpawn` surgery lands. + +**Before.** Three separate problems in about twenty lines. + +```lua +local BattleState = require("src.battle.BattleState") +local PikachuFollower = require("src.world.PikachuFollower") +local GameVersion = require("src.core.GameVersion") + +return function(mod) + -- (1) rewrite the starter encounter's species + local origNewWild = BattleState.newWild + BattleState.newWild = function(game, species, level, ...) + if species == "PIKACHU" and level == 5 then species = "CHARMANDER" end + return origNewWild(game, species, level, ...) + end + + -- (2) decide whether a follower spawns + local newShouldSpawn = function(game, ow) + local v = GameVersion.get() + if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end + return packSize(game) > 0 + end + + -- (3) install it + patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn) + patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn) +end +``` + +On Gold: (1) assigns onto a name nothing reads, so the species rewrite never +happens. (2) returns false for every Gold boot, so no follower ever spawns. +(3) actually works, and works on a predicate that has already decided to do +nothing. Two silent failures and one correct mechanism pointed at them. + +**After.** The manifest gains `"games": ["gen1", "gen2"]`, and: + +```lua +local PikachuFollower = require("src.world.PikachuFollower") + +return function(mod) + -- (1) the species of a wild encounter is a hook on both generations + mod.hooks:wrap("encounter.species", function(next, enc, ctx) + local rolled = next(enc, ctx) + if rolled and rolled.species == "PIKACHU" and rolled.level == 5 then + rolled.species = "CHARMANDER" + end + return rolled + end) + + -- (2) no cart check: whether there is a pack to walk is the whole question + local newShouldSpawn = function(game, ow) + return packSize(game) > 0 + end + + -- (3) the named seam where there is one, the upvalue where there is not + if PikachuFollower.setShouldSpawn then + PikachuFollower.setShouldSpawn(newShouldSpawn) + else + patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn) + patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn) + end +end +``` + +`gen2check` now reports clean, and the mod is shorter than it was on Gen 1 +alone. That is the usual shape of this work: two of the three fixes replace +engine surgery with an API that existed the whole time, and only the third +needs a generation branch. + +The one change that is *not* a simplification is the hook's contract. A wrapper +takes `(next, ...)` and must call `next` with the arguments it was handed, where +the monkey-patch could do as it liked with them. `encounter.species` transforms +a rolled `{ species, level }` and gets a `ctx` beside it: Gen 1 fills in +`mapId`, `terrain` and `rng`, and Gold adds `daytime`, `environment`, `kind` +(`"wild"` / `"contest"` / `"script"` / `"sweet_scent"`), `tables` and `data`. +So the same +subscription serves both games, and a Gold-only refinement is a field test +rather than a second hook. That is the trade: a narrower seam that both engines +raise, in exchange for not owning a function neither engine promised you. + +## Testing + +**Headless, without a Gold cache.** The SDK harness takes the generation +directly, and everything after that is the production path -- same loader, same +validate, same topological sort, same merge: + +```lua +local run = T.sdk.loadMod("mods/my_mod", { generation = 2 }) +T.eq(run.mod and run.mod.state, "loaded", + "runs on gen 2: " .. tostring(run.mod and run.mod.skipReason)) +T.eq(#run.errors, 0, "and loads with no boot errors") +run.release() +``` + +**Assert the state, not just the error count.** A gate skip is deliberately not +an error: `Loader:_skip` sets `mod.state` and `mod.skipReason` and stays off +`loader.errors`, because neither the mod nor its dependency has a bug. So +`T.eq(#run.errors, 0)` on its own passes for a mod that never ran a line, which +is the one result you were testing to rule out. `run.mod.state` is `"loaded"` +when the entry chunk ran and `"wrong_generation"` when the gate or the +dependency contagion took it, with `run.mod.skipReason` carrying the sentence +the manager would show. Keep the error assertion too: it is what catches a +registry with no Gen 2 home and a require the adapter does not serve, both of +which *do* land on `loader.errors`. + +**On a real Gold boot.** Nothing above substitutes for running it. Import Gold +in the launcher, enable your mod, and play the part your mod touches. Be +precise about where the adapter talks to you, because the two channels are not +the same: + +- **The log** carries the adapter's own warnings, each attributed to the mod + holding the facade (`[my_mod] Game.renderer has no Gen 2 backing: ...`), so a + member that degraded tells you which one and why. `Gen2Compat.warnOnce` goes + to `Logger.warn` and nowhere else -- these do **not** appear in the manager. +- **The manager's error feed** (`loader.errors`) is a shorter list: a mod that + failed validation, a duplicate mod id, a registry with no Gen 2 target, a + cross-validation problem, and the one adapter-adjacent case, a require for a + Gen 1 module the adapter does not serve. A skipped mod is not on it, and + neither is a degraded member. + +So: read the log for coverage problems, and the manager for load problems. + +`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean +profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload. + +## What this guide does not promise + +- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served + out of a much larger engine, and within those 15 the coverage table records + 288 backed members against 32 warned and 161 absent. The absent ones are not + a backlog; most are absent because there is no honest Gen 2 answer, and each + one carries its reason. The counts move as the adapter learns something: a + member that turns out to answer nil is demoted from backed to warned or + absent rather than left flattering the table. +- **Absent is not broken, it is not-served.** A nil read is the designed + outcome. If you would rather have an error, test for the member before you + use it. +- **The checker is a static scan.** It cannot follow a require built at + runtime, cannot tie every `debug` call to a module, and says nothing at all + about a member the coverage table does not record. What it *can* do is admit + each of those individually, with a file and a line, as an `unresolved:` note. + Read the notes as part of the report: a clean finding list with notes under + it means "nothing known-broken was found in the part I could follow", and + only a clean finding list with no notes means the scan followed everything. +- **A backed member can still surprise you.** `backed` means the adapter took + responsibility for the Gen 1 call shape, not that Gold behaves identically. + Run `gen2check --notes` once and read the caveats on the members you touch. +- **The adapter is not a compatibility layer for new code.** It exists so mods + written before Gold existed keep working. If you are writing something now, + `mod.game`, `mod.world`, the registries and the hooks mean the same thing in + both games and need none of this. diff --git a/docs/rfcs/0001-surfing-pikachu-sprite.md b/docs/rfcs/0001-surfing-pikachu-sprite.md deleted file mode 100644 index afa7d351..00000000 --- a/docs/rfcs/0001-surfing-pikachu-sprite.md +++ /dev/null @@ -1,96 +0,0 @@ -# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite - -## Status - -Proposed. Engine: `Player.lua`, `FieldDefaults.lua`, -`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools: -`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`, -`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`, -`mod_world_tests.lua`. - -**Regeneration required.** The manifest and sprite sheet update by -re-running `make_yellow_manifest.py` against a `pret/pokeyellow` -checkout, then re-importing the Yellow ROM. - -## Motivation - -Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2` -(`home/map_objects.asm`, `home/overworld.asm`) swap the player's -overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/ -surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets) -when the party mon that knows SURF is a Pikachu. The recomp misses this -in two places: - -1. **Extraction.** `SurfingPikachuSprite` is not in - `SpriteSheetPointerTable` — loaded by its own `ld de,` like - `RedBikeSprite`. The extractor never sees it, and the symbol is not - in the Yellow manifest. -2. **Engine rule.** `field.playerSprites.surf` is one static - (`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon - is a Pikachu." - -## The decision it extends - -No prior D-number. Extends the surf-field-move port in -`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port) -with the player-sprite swap vanilla runs alongside it. - -## The exact API delta - -Backward-compatible, additive-only. - -### `field.playerSprites.surfPikachu` - -New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to -`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction -lands the ride keeps the Seel — no plain on-water Pikachu. - -### `Player.surfPikachuSprite` - -`Player.new` builds a second `SpriteRenderer` when the field resolves. -`pose()` picks it when `surfing and surfingPikachu`. - -### `Player.surfingPikachu` (runtime) - -Runtime-only boolean (not persisted); re-derived so a party change -between save and load is honored. - -### `OverworldState:syncSurfingPikachu()` - -Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every -surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut, -warpToHealPoint, forced-surf tile, setMap boot-restore. - -### Importer — `SPRITE_SURFING_PIKACHU` - -`make_yellow_manifest.py` adds `SurfingPikachuSprite` to -`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()` -gains a `surfPikachu` entry (guarded, so Red/Blue unchanged). -`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py` -each gain a parallel extract mirroring `RedBikeSprite`. - -### `PaletteFX.spriteObp` - -`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index -special case, wearing the player's OBP palette so it colors in GBC mode. - -## Migration note for existing mods - -**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu` -only resolves on a Yellow import after regeneration. No manifest or -`mod.save` shape changes. An eligibility hook that swaps a rental -SURF-mon still drives the sprite pick via `partyKnows`. - -## Parity tests - -- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`, - `surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check - `world & maps v2` suite stays green. -- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu` - + `Player:pose` across four party shapes (12/12). The existing - `parity_cinnabar_east_surf.lua` (24/24) stays green. - -## Deprecation etiquette - -Nothing deprecated. Additive: a new `field.playerSprites` key, a new -runtime flag, a new engine method, a new sprite id. \ No newline at end of file diff --git a/docs/rfcs/0002-screen-render-visible.md b/docs/rfcs/0002-screen-render-visible.md deleted file mode 100644 index da546bde..00000000 --- a/docs/rfcs/0002-screen-render-visible.md +++ /dev/null @@ -1,54 +0,0 @@ -# RFC 0002 — Let mods hide an active screen state from the main render - -## Status - -Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests: -`screen_render_visible.lua`. - -## Motivation - -A mod can render a native menu on a companion display through -`render.compose`, but it cannot remove that menu from the main display without -also popping it. Popping transfers update and input ownership and forces the -mod to reimplement native menu behavior. - -## The decision it extends - -No prior D-number. Extends the render-hook plan in `docs/modding.md` and the -state-stack rendering contract in `docs/architecture.md`. - -## The exact API delta - -Backward-compatible, additive-only. - -### `screen.render_visible` - -New hook called with `(state) -> boolean` through the public wrapper signature -`(next, state)`. Its vanilla result is `true`. - -Returning `false` excludes the state from the main draw, from opaque-base -selection and from palette-zone ownership. It does not remove the state or -change update, input, push or pop behavior. The call sites are -`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette -walks in `Game:draw`. - -The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates -nothing. It is a pure render predicate and may be evaluated more than once per -frame. - -## Migration note for existing mods - -**Nothing.** With no subscriber every state remains visible, and the existing -state-stack, event and hook behavior is unchanged. - -## Parity tests - -- **No-mod:** the topmost opaque state still owns drawing and palette zones, - and `Runtime.wantsHook("screen.render_visible")` stays false. -- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one - opaque state and proves the state beneath draws and owns the palette while - the hidden state remains topmost and continues updating. - -## Deprecation etiquette - -Nothing deprecated. This is one additive hook with a `true` vanilla default. diff --git a/main.lua b/main.lua index f8c349e8..11a2c003 100644 --- a/main.lua +++ b/main.lua @@ -75,6 +75,15 @@ end local editorHost, editorVersion, editorWindow local closeEditor -- forward declaration: openEditor hands it to the editor +-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row +-- carries fields its MonOps and panels have no idea about (dvs, statExp, +-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table +-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua +-- then has to quarantine on the next boot. Refuse by name, the same way the +-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so +-- Red/Blue/Yellow slots are untouched. +local GEN2_NO_EDITOR = { gold = "Pokemon Gold" } + -- The editor's modules use flat names (require("Kit"), require("Party")), so -- their directories have to be on the require path. It must be -- love.filesystem's path, not package.path: in a packaged build these files @@ -122,14 +131,20 @@ end -- mounted before the editor's Data:load runs, or a Blue save would be edited -- against Red's species/item tables. local function openEditor(version, slotId) + local function refuse(text) + if not Importer then return end + Importer.saveNotice = Importer.saveNotice or {} + Importer.saveNotice[version] = { ok = false, text = text } + end + local gen2Name = GEN2_NO_EDITOR[version] + if gen2Name then + refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.") + return + end local SaveData = require("src.core.SaveData") local path = SaveData.slotDiskPath(version, slotId) if not path then - if Importer then - Importer.saveNotice = Importer.saveNotice or {} - Importer.saveNotice[version] = - { ok = false, text = "Could not resolve that save slot on disk." } - end + refuse("Could not resolve that save slot on disk.") return end local GameVersion = require("src.core.GameVersion") @@ -203,15 +218,16 @@ function closeTouchControlsEditor() end local function bootGame(version) - -- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and - -- headless runs fall back to POKEPORT_VERSION, then Red. Set the active - -- version and overlay its extracted cache BEFORE anything requires generated - -- data, so data/generated + assets/generated resolve to that version's files. + -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); + -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. + -- Set the active version and overlay its extracted cache BEFORE anything + -- requires generated data, so data/generated + assets/generated resolve + -- to that version's files. local GameVersion = require("src.core.GameVersion") GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red") local CacheFs = require("src.import.CacheFs") -- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load - -- (Blue/Yellow caches live under blue/ / yellow/). + -- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/). CacheFs.prefix = GameVersion.cachePrefix() CacheFs.mountVersion(GameVersion.get()) -- NX: always write nx-asset-probe.log so Yellow/Blue art failures are @@ -224,10 +240,19 @@ local function bootGame(version) love.window.setTitle(Version.title( GameVersion.info().displayName .. " (Gen 1 Recompilation Project)")) end - Game = require("src.core.Game") - Game:load() - if os.getenv("POKEPORT_AUTOPILOT") then - autopilot = require("tests.autopilot") + -- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated + -- tables, save shape and screen registry -- so Gold boots its own service + -- owner, which mounts src/world/gen2 (walk / warps / connections) and the + -- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring. + if GameVersion.isGold() then + Game = require("src.core.Game2").new() + Game:load() + else + Game = require("src.core.Game") + Game:load() + if os.getenv("POKEPORT_AUTOPILOT") then + autopilot = require("tests.autopilot") + end end local driverPath = os.getenv("POKEPORT_DRIVER") if driverPath then @@ -289,6 +314,14 @@ function love.load(args) -- cache has to be mounted before the editor's Data:load. if editorMode then local version = os.getenv("POKEPORT_VERSION") or "red" + local gen2Name = GEN2_NO_EDITOR[version] + if gen2Name then + -- No launcher behind this run to carry a notice, so say it and stop + -- rather than open a Gen 2 slot on Gen 1 panels. + print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.") + love.event.quit(1) + return + end require("src.core.GameVersion").set(version) require("src.import.CacheFs").mountVersion(version) addEditorRequirePath() @@ -300,9 +333,12 @@ function love.load(args) local RomImporter = require("src.import.RomImporter") local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" local importPath = os.getenv("POKEPORT_IMPORT_ROM") - -- Scripted / headless runs pick their game from POKEPORT_VERSION (default - -- Red); the launcher's per-column choice does not apply to them. - local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red" + -- Scripted / headless runs pick their game from POKEPORT_VERSION, then + -- POKEPORT_GAME / --game= (LaunchOptions), then Red. Drivers for Gold + -- must honor POKEPORT_GAME=gold the same way a desktop shortcut does. + local scriptedVersion = os.getenv("POKEPORT_VERSION") + or LaunchOptions.resolve(arg) + or "red" local ready = RomImporter.isReady(scriptedVersion) -- Scripted / headless runs have to reach the game with no human pressing -- Play: an autopilot, a frame driver, an import-only build step, or an @@ -343,7 +379,7 @@ function love.load(args) end -- LAUNCH OPTIONS: skip the launcher and boot a game directly. - -- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH) + -- --game red|blue|yellow|gold (or POKEPORT_GAME / POKEPORT_LAUNCH) -- --slot <id> optional; picks the save slot to load -- --launcher force the launcher even if a game is set -- This is what a desktop shortcut, a Steam entry, or a frontend like @@ -371,11 +407,12 @@ function love.load(args) LaunchOptions.pendingTab = launchGame end - -- Interactive: the launcher always runs. Red, Blue, and Yellow are each - -- live: a column shows Play when that game's ROM is already imported, or - -- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its - -- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a - -- save row opens the bundled editor on that slot (openEditor). + -- Interactive: the launcher always runs. Red, Blue, Yellow, and Gold are + -- each live: a column shows Play when that game's ROM is already imported, + -- or Choose ROM / drag-drop when it is not. Any dropped .gb/.gbc is routed + -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold + -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). + -- Edit on a save row opens the bundled editor on that slot (openEditor). Importer = RomImporter.new(function(version) Importer = nil bootGame(version) @@ -438,6 +475,7 @@ function love.draw() if editorMode then return EditorApp.draw() end if TouchEditor then return TouchEditor.draw() end if Importer then return Importer:draw() end + if not Game then return end Game:draw() -- frame capture requested by a driver diff --git a/mods/nuzlocke/README.md b/mods/nuzlocke/README.md deleted file mode 100644 index 25dd15d5..00000000 --- a/mods/nuzlocke/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Nuzlocke - -An enforced Gen 1 Nuzlocke. - -Oak configures Slow Start, whether duplicate evolutionary families are skipped -or consume an area's encounter, and whether Safari maps are separate areas. - -After Slow Start, the mod enforces mandatory nicknames for starters, gifts, -and catches; one capture per area; no duplicate evolutionary families; and -permanent death. A party Pokémon says that it died and is removed immediately. -If the final party member dies, the game runs the credits to THE END and then -deletes the active save. - diff --git a/mods/nuzlocke/main.lua b/mods/nuzlocke/main.lua deleted file mode 100644 index e4d0dacf..00000000 --- a/mods/nuzlocke/main.lua +++ /dev/null @@ -1,224 +0,0 @@ --- Nuzlocke rules. Uses the current engine's internal seams while the --- equivalent public API hooks are being added. -return function(mod) - mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech) - steps = next(steps, speech) - mod.ui.insertStepAfter(steps, "oak_welcome", { - id = "nuzlocke_intro", kind = "say", pic = "oak", - text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.", - }) - mod.ui.insertStepAfter(steps, "nuzlocke_intro", { - id = "nuzlocke_slow_start", kind = "yesno", pic = "oak", - saveKey = "slow_start", defaultNo = true, - text = "Use SLOW START?\nRules start with\nPOKé BALLS.", - }) - mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", { - id = "nuzlocke_dupes", kind = "choice", pic = "oak", - saveKey = "dupes_mode", text = "When you meet a\nknown family?", - choices = { "SKIP", "LOSE" }, values = { "skip", "strict" }, - }) - mod.ui.insertStepAfter(steps, "nuzlocke_dupes", { - id = "nuzlocke_safari", kind = "yesno", pic = "oak", - saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?", - }) - mod.ui.insertStepAfter(steps, "nuzlocke_safari", { - id = "nuzlocke_close", kind = "say", pic = "oak", - text = "Give every friend\na name. Keep them\nsafe. Good luck!", - }) - return steps - end) - - mod.events:on("intro.oak_speech.answered", function(ev) - if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end - end) - - local function active(game, battle) - if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end - if not mod.save:get("slow_start", false) then return true end - if mod.save:get("balls_unlocked", false) then return true end - for id, count in pairs(game.save.inventory or {}) do - if count > 0 and game.data.items[id] and game.data.items[id].ball then - mod.save:set("balls_unlocked", true) - return true - end - end - return false - end - - local function areaKey(game, battle) - if battle and battle.safari and not mod.save:get("safari_sectors", false) then - return "SAFARI_ZONE" - end - return (game.overworld and game.overworld.map and game.overworld.map.id) - or (game.save.player and game.save.player.map) or "UNKNOWN" - end - - local function family(data, species) - local found, pending = {}, { species } - while #pending > 0 do - local id = table.remove(pending) - if not found[id] then - found[id] = true - for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end - for parent, def in pairs(data.pokemon or {}) do - for _, evo in ipairs(def.evolutions or {}) do - if evo.species == id then pending[#pending + 1] = parent end - end - end - end - end - return found - end - - local function ownsFamily(game, species) - local members = family(game.data, species) - local function owns(mon) return mon and members[mon.species] end - for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end - for _, box in ipairs(game.save.boxes or {}) do - for _, mon in ipairs(box) do if owns(mon) then return true end end - end - return false - end - - local function caughtAreas() - local areas = mod.save:get("caught_areas") - if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end - return areas - end - - local function denied(game, battle, species) - if not active(game, battle) then return nil end - if caughtAreas()[areaKey(game, battle)] then return "area" end - if ownsFamily(game, species) then return "dupes" end - end - - mod.events:on("pokemon.caught", function(ev) - -- A successful capture proves that Slow Start has ended even when it was - -- the last ball in the bag. - if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end - if active(ev.game, ev.battle) then - caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species - mod.save:set("caught_areas", caughtAreas()) - end - end) - - mod.events:on("game.ready", function() - local BattleState = require("src.battle.BattleState") - local Commands = require("src.script.Commands") - local Party = require("src.pokemon.Party") - local Boxes = require("src.pokemon.Boxes") - local Pokemon = require("src.pokemon.Pokemon") - local Runtime = require("src.mods.Runtime") - local Screens = require("src.ui.Screens") - local Strings = require("src.core.Strings") - local SaveData = require("src.core.SaveData") - local GameVersion = require("src.core.GameVersion") - local Bag = require("src.inventory.Bag") - - BattleState.askNicknameUI = function(self, mon) - self.lockedBall, self.blankForAskName = nil, false - return self:buildScreen("NamingScreen", { - title = Strings("NICKNAME?"), maxLen = 10, - onDone = function(name) mon.nickname = name or "A" end, - }) - end - - -- Gifts and starters use the same mandatory naming screen. - Commands.give_pokemon = function(ctx, species, level) - local gift = { ctx = ctx, species = species, level = level } - if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end - local mon = Pokemon.new(ctx.game.data, gift.species, gift.level) - ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species - BattleState.stampOT(ctx.save, mon) - local inParty = Party.add(ctx.save.party, mon) - local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon) - if not inParty and not boxNum then ctx.lastCheck = false; return end - if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end - ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum - if ctx.runner then - Screens.push(ctx.game, "NamingScreen", { - title = Strings("NICKNAME?"), maxLen = 10, - onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end, - }) - ctx.runner:yield() - else mon.nickname = "A" end - if boxNum then - ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum) - if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end - end - end - - local vanillaThrowBall = BattleState.throwBall - BattleState.throwBall = function(self, ball) - local reason = denied(self.game, self, self.enemy and self.enemy.mon.species) - if reason then - if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then - caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST" - mod.save:set("caught_areas", caughtAreas()) - end - Bag.add(self.game.save, ball, 1, self.game.data) - self:say(reason == "area" and "This area already\nhas a captured POKéMON!" - or "You already have\nthis POKéMON family!") - return - end - return vanillaThrowBall(self, ball) - end - - local vanillaOnFaint = BattleState.onFaint - BattleState.onFaint = function(self, battler) - if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end - if battler.faintQueued then return end - battler.faintQueued = true - if self.participants then self.participants[battler.mon] = nil end - Runtime.emit("battle.fainted", { battle = self, battler = battler }) - for i, mon in ipairs(self.game.save.party) do - if mon == battler.mon then table.remove(self.game.save.party, i); break end - end - self:actNext(function() - battler.fainted = true - require("src.core.Sound").playCry(self.data, battler.mon.species) - require("src.core.Sound").play(self.data, "Faint_Fall") - self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 } - end) - self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { wait = 30 }) - self:sayNext(Strings("%s\ndied!", battler.name)) - self:act(function() self:playerMonFainted() end) - end - - local vanillaPlayerFainted = BattleState.playerMonFainted - BattleState.playerMonFainted = function(self) - if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then - self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish" - self:sayNext(Strings("All of your\nPOKéMON are dead...")) - return - end - return vanillaPlayerFainted(self) - end - - local vanillaFinish = BattleState.finish - BattleState.finish = function(self) - if not self.nuzlockeGameOver then return vanillaFinish(self) end - self.nuzlockeGameOver = nil - self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" }) - -- Game over has no victory lap: delete the slot, then use Credits only - -- as its existing THE END renderer / A-or-B wait screen. - local version = GameVersion.get() - local slot = SaveData.activeSlot(version) - if slot then SaveData.deleteSlot(version, slot) - elseif love and love.filesystem then - local main = SaveData.saveFilename(version) - love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp") - end - local ending = Screens.push(self.game, "Credits", function() - require("src.core.Music").stop() - while self.game.stack:top() do self.game.stack:pop() end - Screens.push(self.game, "IntroMovie", function() - if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end - end) - end) - ending.phase, ending.timer = "end_wait", 0 - end - end) -end diff --git a/mods/nuzlocke/manifest.json b/mods/nuzlocke/manifest.json deleted file mode 100644 index 4f8b073c..00000000 --- a/mods/nuzlocke/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "nuzlocke", - "name": "Nuzlocke", - "version": "1.0.0", - "api": 2, - "entry": "main.lua", - "profile": "content", - "category": "GAMEPLAY", - "game_version": ">=0.0.0-dev <1.0.0", - "priority": 100, - "permissions": [ - "engine_internals" - ], - "dependencies": [], - "optional_dependencies": [], - "conflicts": [], - "description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.", - "github": "bryanthaboi/nuzlocke" -} diff --git a/mods/spanish_ui/README.md b/mods/spanish_ui/README.md deleted file mode 100644 index b844ac80..00000000 --- a/mods/spanish_ui/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# spanish_ui - -A Espanol translation of the game. - -Generated with `python3 tools/modkit.py translation spanish_ui`. See -`TRANSLATING.md` for how to work on it. - -## Status - -Nothing is translated yet: 601 strings are waiting in `lang/`. - -| Catalog | Entries | -|---|---| -| `lang/dialogue.lua` | 6 | -| `lang/strings.lua` | 577 | -| `lang/species_names.lua` | 3 | -| `lang/move_names.lua` | 4 | -| `lang/item_names.lua` | 5 | -| `lang/trainer_names.lua` | 1 | -| `lang/status_labels.lua` | 5 | - -## Layout - -- `manifest.json` - identity and the engine version range -- `main.lua` - registers whatever is filled in and skips whatever is not -- `lang/` - the catalogs; this is the whole job -- `assets/font/` - your glyph sheet diff --git a/mods/spanish_ui/TRANSLATING.md b/mods/spanish_ui/TRANSLATING.md deleted file mode 100644 index f1cca9bc..00000000 --- a/mods/spanish_ui/TRANSLATING.md +++ /dev/null @@ -1,111 +0,0 @@ -# Translating into Espanol - -Everything the player can read is one of two kinds of string, and they live -in different places for a reason. - -| lang/ file | What it is | Key | -|---|---|---| -| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` | -| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string | -| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id | -| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id | -| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below | -| `naming.lua` | The letter grid for entering names | - | - -Fill in a value and it takes effect. Leave it `""` and that string stays in -English, so the game is playable at every point along the way. - -## Where the English is - -The catalogs hold keys and *your* text, never the original English. The -English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per -catalog: - -``` -"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?" -``` - -That directory is deliberately outside the mod. Extracted script text and -the vanilla names are ROM content, and `modkit pack` zips everything under -the mod directory, so a worksheet kept inside would end up in your release -whatever a `.gitignore` said. Keep it beside the mod, never in it. - -`lang/strings.lua` is the exception: those sources are the engine's own Lua -rather than anything out of the ROM, so there the key *is* the English and -you can translate straight from it. - -## Start with the font, not the text - -The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap -saying which byte sequence draws which cell. The vanilla pages sit at `$60` -and `$80`. Anything from `0x100` up is free, so a new alphabet is added -rather than swapped in: - -```lua --- lang/font.lua -return { - spanish_ui = { - image = "assets/font/spanish_ui.png", - base = 0x100, -- first code this page owns - glyphsPerRow = 16, - -- advance = 8, -- set this if your glyphs are not 8px wide - }, -} -``` - -```lua --- lang/charmap.lua: sequence -> code, in the same order as the sheet -return { - ["A"] = 0x100, - ["B"] = 0x101, -} -``` - -The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8, -black on white like `assets/generated/font.png`. Codes run left to right, -top to bottom from `base`. - -Sequences are matched **longest first**, so a multi-byte character and a -multi-character ligature both work and neither shadows the other: - -```lua -["\u{3042}"] = 0x120, -- one 3-byte character, one glyph -["ch"] = 0x121, -- two ASCII letters, one glyph -``` - -## Line length is counted in glyphs - -The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character -costs one column, and the engine will never cut a character in half. Your -own `\n` line breaks are respected exactly as written, so break lines where -they read best rather than where they fit English. - -If your glyphs are not 8px wide, set `advance` on the page and the box -re-measures. - -## Format directives must survive - -Some sources carry `%s` or `%d`: - -```lua -["Wild %s\nappeared!"] = "...", -``` - -Keep every directive, in a count that matches. Word order is yours to -change; the engine substitutes in the order the directives appear, so if -your language needs the name last, write the sentence with the `%s` last. -A translation whose directive count does not match the English is refused -at runtime and the English is drawn instead, with a line in the log saying -so - it will not crash a battle. - -## Checking your work - -```sh -python3 tools/modkit.py validate spanish_ui --base imported -python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings -POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/ -``` - -`--refresh` rewrites the catalogs from the current engine, keeping every -translation you have already written and reporting what changed. Run it -after pulling a new engine version. diff --git a/mods/spanish_ui/assets/font/README.md b/mods/spanish_ui/assets/font/README.md deleted file mode 100644 index 8f0f5890..00000000 --- a/mods/spanish_ui/assets/font/README.md +++ /dev/null @@ -1,11 +0,0 @@ -Put your glyph sheet here. - -A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes -run left to right and top to bottom starting at the page's `base`, so the -first cell is `base`, the second `base + 1`, and so on. - -`assets/generated/font.png` in the player's cache is the vanilla sheet at -the same scale; open it alongside yours to match weight and baseline. - -Declare the sheet in `lang/font.lua` and map sequences to codes in -`lang/charmap.lua`. diff --git a/mods/spanish_ui/lang/charmap.lua b/mods/spanish_ui/lang/charmap.lua deleted file mode 100644 index ca0e3140..00000000 --- a/mods/spanish_ui/lang/charmap.lua +++ /dev/null @@ -1,10 +0,0 @@ --- Which byte sequence draws which glyph code. --- --- Sequences are matched longest-first, so a multi-byte character and a --- multi-character ligature both work: "ch" can be one glyph even though --- "c" is also mapped. Codes here must land inside a page declared in --- lang/font.lua. -return { - -- ["A"] = 0x100, - -- ["B"] = 0x101, -} diff --git a/mods/spanish_ui/lang/dialogue.lua b/mods/spanish_ui/lang/dialogue.lua deleted file mode 100644 index 8d3c16f3..00000000 --- a/mods/spanish_ui/lang/dialogue.lua +++ /dev/null @@ -1,12 +0,0 @@ --- Script text --- --- Keyed by the original text label. The English is in the comment. - -return { - ["_FixMartText"] = "", - ["_FixRouteTrainerAfterText"] = "", - ["_FixRouteTrainerBattleText"] = "", - ["_FixRouteTrainerEndText"] = "", - ["_FixTownGreeterText"] = "", - ["_FixTownSignText"] = "", -} diff --git a/mods/spanish_ui/lang/font.lua b/mods/spanish_ui/lang/font.lua deleted file mode 100644 index 4b878079..00000000 --- a/mods/spanish_ui/lang/font.lua +++ /dev/null @@ -1,13 +0,0 @@ --- Glyph pages this translation adds. Delete the entry if the vanilla --- alphabet already covers your language. --- --- base is the first glyph code the page owns. 0x100 and up is free space --- above the vanilla $60/$80 pages, so this adds an alphabet rather than --- replacing one. Set `advance` if your glyphs are not 8px wide. -return { - -- spanish_ui = { - -- image = "assets/font/spanish_ui.png", - -- base = 0x100, - -- glyphsPerRow = 16, - -- }, -} diff --git a/mods/spanish_ui/lang/item_names.lua b/mods/spanish_ui/lang/item_names.lua deleted file mode 100644 index 2798d181..00000000 --- a/mods/spanish_ui/lang/item_names.lua +++ /dev/null @@ -1,11 +0,0 @@ --- Item names --- --- Item names for Espanol. - -return { - ["FIX_BADGE_1"] = "", - ["FIX_BADGE_2"] = "", - ["FIX_BALL"] = "", - ["FIX_POTION"] = "", - ["FIX_TM"] = "", -} diff --git a/mods/spanish_ui/lang/move_names.lua b/mods/spanish_ui/lang/move_names.lua deleted file mode 100644 index 80abf686..00000000 --- a/mods/spanish_ui/lang/move_names.lua +++ /dev/null @@ -1,10 +0,0 @@ --- Move names --- --- Move names for Espanol. - -return { - ["FIX_CUT"] = "", - ["FIX_EMBERISH"] = "", - ["FIX_SCRATCH"] = "", - ["FIX_TACKLE"] = "", -} diff --git a/mods/spanish_ui/lang/naming.lua b/mods/spanish_ui/lang/naming.lua deleted file mode 100644 index adb0666b..00000000 --- a/mods/spanish_ui/lang/naming.lua +++ /dev/null @@ -1,41 +0,0 @@ --- The naming screen's letter grid. Return an empty table to keep the --- English alphabet. --- --- Each entry is a row of cells; a cell is whatever sequence your charmap --- maps, so a multi-byte character is one cell. The row holding a single --- "lower case" / "UPPER CASE" cell is the case switch, and the cell --- spelled "ED" is the confirm. --- --- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at --- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**: --- a 10th column lands at x=160 and a 7th row at y=144, both off screen. --- That leaves 44 usable cells, exactly what vanilla uses, so Spanish --- letters have to displace something rather than being added. --- --- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a --- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in --- Spanish -- and here it sits in its alphabetical place after N, which is --- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept. --- --- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É, --- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they --- do not, so main.lua checks the running game's charmap first and keeps the --- English grid rather than drawing blank cells. -return { - upper = { - { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, - { "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" }, - { "R", "S", "T", "U", "V", "W", "X", "Y", "Z" }, - { "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" }, - { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, - { "lower case" }, - }, - lower = { - { "a", "b", "c", "d", "e", "f", "g", "h", "i" }, - { "j", "k", "l", "m", "n", "ñ", "o", "p", "q" }, - { "r", "s", "t", "u", "v", "w", "x", "y", "z" }, - { "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" }, - { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, - { "UPPER CASE" }, - }, -} diff --git a/mods/spanish_ui/lang/species_names.lua b/mods/spanish_ui/lang/species_names.lua deleted file mode 100644 index 7a4e5645..00000000 --- a/mods/spanish_ui/lang/species_names.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Species names --- --- Species names for Espanol. - -return { - ["FIXMON_A"] = "", - ["FIXMON_B"] = "", - ["FIXMON_C"] = "", -} diff --git a/mods/spanish_ui/lang/status_labels.lua b/mods/spanish_ui/lang/status_labels.lua deleted file mode 100644 index b3cd3365..00000000 --- a/mods/spanish_ui/lang/status_labels.lua +++ /dev/null @@ -1,11 +0,0 @@ --- Status labels --- --- Short enough for the battle HUD: the vanilla ones are three glyphs. - -return { - ["BRN"] = "", - ["FRZ"] = "", - ["PAR"] = "", - ["PSN"] = "", - ["SLP"] = "", -} diff --git a/mods/spanish_ui/lang/strings.lua b/mods/spanish_ui/lang/strings.lua deleted file mode 100644 index c0858d3c..00000000 --- a/mods/spanish_ui/lang/strings.lua +++ /dev/null @@ -1,584 +0,0 @@ --- Engine text --- --- Keyed by the English source, which is also what draws if you leave --- an entry empty. Keep any %s / %d directives. - -return { - ["%s\nflew up high!"] = "¡%s\nvoló muy alto!", - ["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!", - ["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!", - ["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!", - ["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!", - ["%s\nis glowing!"] = "¡%s\nestá brillando!", - ["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!", - ["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!", - ["%s wants\nto fight!"] = "¡%s\nquiere luchar!", - ["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!", - ["Go! %s!"] = "¡Ve, %s!", - ["Do it! %s!"] = "¡Hazlo, %s!", - ["Get'm! %s!"] = "¡A por él, %s!", - ["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!", - ["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!", - ["%s blacked\nout!"] = "¡%s se\ndebilitó!", - ["%s sent\nout %s!"] = "¡%s envió\na %s!", - ["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!", - ["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!", - ["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!", - ["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!", - ["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!", - ["But, it failed!"] = "¡Pero falló!", - ["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!", - ["POKé BALL"] = "POKé BALL", - ["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!", - ["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!", - ["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...", - ["%s with-\ndrew %s!"] = "¡%s retiró\na %s!", - ["%s\nmust recharge!"] = "¡%s debe\nrecargarse!", - ["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!", - ["%s\nis confused!"] = "¡%s está\nconfuso!", - ["%s\nwoke up!"] = "¡%s se\ndespertó!", - ["%s\nis frozen solid!"] = "¡%s está\ncongelado!", - ["%s\ncan't move!"] = "¡%s no\npuede moverse!", - ["%s\nflinched!"] = "¡%s se\namedrentó!", - ["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!", - ["%s\nused %s!"] = "¡%s usó\n%s!", - ["%s\nis charging up!"] = "¡%s está\ncargando energía!", - ["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!", - ["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!", - ["%s\nis storing energy!"] = "¡%s está\nacumulando energía!", - ["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!", - ["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!", - ["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!", - ["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!", - ["%s\nfainted!"] = "¡%s se\ndebilitó!", - ["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!", - ["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!", - ["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!", - ["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!", - ["%s is\nabout to use"] = "%s va a usar", - ["%s!"] = "¡%s!", - ["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?", - ["%s defeated\n%s!"] = "¡%s venció\na %s!", - ["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!", - ["%s learned\n%s!"] = "¡%s aprendió\n%s!", - ["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?", - ["Use next POKéMON?"] = "¿Sacar al siguiente?", - ["Got away safely!"] = "¡Escapaste!", - ["Can't escape!"] = "¡No puedes escapar!", - ["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!", - ["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!", - ["%s threw some\nBAIT."] = "%s echó\nCEBO.", - ["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.", - ["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!", - ["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!", - ["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!", - ["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!", - ["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!", - ["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!", - ["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!", - ["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!", - ["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?", - ["NICKNAME?"] = "MOTE?", - ["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!", - ["someone's PC"] = "el PC de alguien", - ["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!", - ["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!", - ["%s used\n%s!"] = "¡%s usó\n%s!", - ["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!", - ["Don't be a thief!"] = "¡No seas ladrón!", - ["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!", - ["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!", - ["%s is\nalready out!"] = "¡%s ya\nestá fuera!", - ["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!", - ["FIGHT"] = "LUCHAR", - ["ITEM"] = "OBJETO", - ["RUN"] = "HUIR", - ["BALLx"] = "BALLx", - ["BAIT"] = "CEBO", - ["THROW ROCK"] = "TIRAR PIEDRA", - ["disabled!"] = "¡anulado!", - ["TYPE/"] = "TIPO/", - ["It doesn't affect\n%s!"] = "¡No afecta a\n%s!", - ["Critical hit!"] = "¡Golpe crítico!", - ["One-hit KO!"] = "¡KO en un golpe!", - ["It's super\neffective!"] = "¡Es muy eficaz!", - ["It's not very\neffective..."] = "¡No es muy\neficaz...", - ["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!", - ["Hit %d times!"] = "¡Golpeó %d veces!", - ["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!", - ["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!", - ["Nothing happened!"] = "¡No pasó nada!", - ["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!", - ["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!", - ["%s's\n%s fell!"] = "¡%s bajó\nsu %s!", - ["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!", - ["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!", - ["%s\nbecame confused!"] = "¡%s se\nconfundió!", - ["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!", - ["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!", - ["%s\nregained health!"] = "¡%s recuperó\nsalud!", - ["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!", - ["%s\ngained armor!"] = "¡%s ganó\narmadura!", - ["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!", - ["%s's\ngetting pumped!"] = "¡%s se\nestá animando!", - ["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!", - ["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!", - ["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!", - ["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!", - ["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!", - ["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!", - ["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!", - ["No effect!"] = "¡Sin efecto!", - ["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!", - ["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!", - ["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!", - ["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!", - ["%s\nran away scared!"] = "¡%s huyó\nasustado!", - ["%s\nwas blown away!"] = "¡%s salió\nvolando!", - ["%s\nran from battle!"] = "¡%s huyó\ndel combate!", - ["It didn't affect\n%s!"] = "¡No afectó a\n%s!", - ["%s\nis unaffected!"] = "¡%s no se\nvio afectado!", - ["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!", - ["%s\nfell asleep!"] = "¡%s se\nquedó dormido!", - ["%s\nwas frozen solid!"] = "¡%s se\ncongeló!", - ["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!", - ["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!", - ["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!", - ["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!", - ["%s\nwas burned!"] = "¡%s se\nquemó!", - ["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!", - ["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!", - ["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!", - ["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!", - ["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!", - ["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!", - ["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!", - ["What will"] = "¿Qué va a hacer", - [" do?"] = "?", - ["You can't get off\nhere."] = "No puedes bajarte\naquí.", - ["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.", - ["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!", - ["No cycling\nallowed here."] = "No se puede montar\naquí.", - ["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.", - ["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!", - ["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.", - ["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.", - ["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.", - ["Booted up a TM!"] = "¡Se activó una MT!", - ["It contained\n%s!"] = "¡Contenía\n%s!", - ["USE"] = "USAR", - ["TOSS"] = "TIRAR", - ["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!", - ["Threw away\n%s."] = "Tiraste\n%s.", - ["PRESS A BUTTON"] = "PULSA UN BOTON", - ["ESC TO CANCEL"] = "ESC PARA CANCELAR", - ["%s :L%d"] = "%s :N%d", - ["STATS"] = "DATOS", - ["CANCEL"] = "CANCELAR", - ["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!", - ["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.", - ["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)", - ["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.", - ["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!", - ["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.", - ["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!", - ["BOX %d is full!"] = "¡La CAJA %d está\nllena!", - ["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.", - ["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)", - ["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?", - ["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!", - ["%sBOX %2d"] = "%sCAJA %2d", - ["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?", - ["What?"] = "¿Qué?", - ["BOX No."] = "CAJA No.", - ["BOX No.%d"] = "CAJA No.%d", - ["Empty."] = "Vacía.", - [":L%d No.%03d"] = ":N%d No.%03d", - ["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.", - ["Printer error!\n%s"] = "¡Error de impresión!\n%s", - ["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>", - ["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>", - ["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>", - ["CHANGE BOX"] = "CAMBIAR CAJA", - ["PRINT BOX"] = "IMPRIMIR CAJA", - ["SEE YA!"] = "HASTA LUEGO!", - ["YES"] = "SI", - ["NO"] = "NO", - ["GAME FREAK"] = "", - ["Nintendo"] = "", - ["Creatures inc."] = "", - ["GAME FREAK inc."] = "", - ["T H E E N D"] = "F I N", - ["HT %d′%02d″"] = "AL %d′%02d″", - ["WT %.1flb"] = "PE %.1flb", - ["Data unknown."] = "Datos desconocidos.", - ["<Diploma>"] = "<Diploma>", - ["Player"] = "Jugador", - ["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!", - ["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", - ["evolving!"] = "evolucionando", - ["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}", - ["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}", - ["Keep it up!"] = "¡Sigue así!", - ["LEVEL/"] = "NIVEL/", - ["TYPE1/"] = "TIPO1/", - ["TYPE2/"] = "TIPO2/", - ["HALL OF FAME"] = "SALON DE LA FAMA", - ["PLAY TIME"] = "TIEMPO", - ["MONEY"] = "DINERO", - ["bois club games"] = "bois club games", - ["GENGAR VS NIDORINO"] = "", - ["bois club"] = "bois club", - ["Nothing here."] = "Aquí no hay nada.", - ["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f", - ["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?", - ["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!", - ["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?", - ["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!", - ["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!", - ["Which move should"] = "¿Qué movimiento", - ["be forgotten?"] = "hay que olvidar?", - ["YOUR NAME?"] = "TU NOMBRE?", - ["NEW NAME"] = "NUEVO NOMBRE", - ["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.", - ["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!", - ["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.", - ["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!", - ["First, what is\nyour name?"] = "¿Cómo te llamas?", - ["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?", - ["HIS NAME?"] = "SU NOMBRE?", - ["_OakSpeechText2A"] = "", - ["TEXT SPEED"] = "VEL TEXTO", - ["BATTLE ANIMATION"] = "ANIMACIONES", - ["OFF"] = "NO", - ["ON"] = "SI", - ["BATTLE STYLE"] = "ESTILO COMBATE", - ["SET"] = "FIJO", - ["SHIFT"] = "CAMBIO", - ["BATTLE LAYOUT"] = "DISENO COMBATE", - ["WIDE"] = "ANCHO", - ["OG"] = "OG", - ["RULESET"] = "REGLAS", - ["MUSIC VOL"] = "VOL MUSICA", - ["SFX VOL"] = "VOL SONIDO", - ["PIKACHU VOL"] = "VOL PIKACHU", - ["MUSIC FILTER"] = "FILTRO MUSICA", - ["COLORS"] = "COLORES", - ["TILT"] = "INCLINACION", - ["GBC FX"] = "EFECTO GBC", - ["ZOOM"] = "ZOOM", - ["VOID FILL"] = "RELLENO VACIO", - ["VIDEO MODE"] = "MODO VIDEO", - ["MAX FPS"] = "FPS MAXIMO", - ["GAME SPEED"] = "VELOCIDAD JUEGO", - ["MODS"] = "MODS", - ["%d INSTALLED"] = "%d INSTALADOS", - ["CONTROLS"] = "CONTROLES", - ["TOUCH PAD"] = "CONTROL TACTIL", - ["SURE? AGAIN"] = "SEGURO? OTRA VEZ", - ["AUTO HIDE PAD"] = "OCULTAR AUTO", - ["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!", - ["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!", - ["Nothing to CUT!"] = "¡Nada que CORTAR!", - ["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.", - ["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.", - ["It won't have\nany effect."] = "No tendrá ningún\nefecto.", - ["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!", - ["SWITCH"] = "CAMBIAR", - ["FLY"] = "VUELO", - ["FLASH"] = "DESTELLO", - ["CUT"] = "CORTE", - ["SURF"] = "SURF", - ["STRENGTH"] = "FUERZA", - ["SOFTBOILED"] = "HUEVO SUERTE", - ["TELEPORT"] = "TELETRANSPORTE", - ["DIG"] = "EXCAVAR", - ["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?", - ["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?", - ["Choose a POKéMON."] = "Elige un POKéMON.", - ["No POKéMON!"] = "¡Ningún POKéMON!", - ["ABLE"] = "PUEDE", - ["NOT ABLE"] = "NO PUEDE", - ["FNT"] = "DEB", - ["Move to where?"] = "¿Mover a dónde?", - ["Use on which one?"] = "¿Usar en cuál?", - ["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.", - ["Withdrew\n%s."] = "Retirado\n%s.", - ["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.", - ["%s was\nstored via PC."] = "%s se\nguardó en el PC.", - ["Toss %s?"] = "¿Tirar %s?", - ["Threw away %s."] = "Tiraste %s.", - ["WITHDRAW ITEM"] = "RETIRAR OBJETO", - ["DEPOSIT ITEM"] = "GUARDAR OBJETO", - ["TOSS ITEM"] = "TIRAR OBJETO", - ["LOG OFF"] = "SALIR", - ["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d", - ["DATA"] = "DATOS", - ["CRY"] = "VOZ", - ["AREA"] = "ZONA", - ["PRNT"] = "IMPR", - ["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.", - ["QUIT"] = "SALIR", - ["%s (%s)"] = "%s (%s)", - ["%s x%d"] = "%s x%d", - ["%s to box %d"] = "%s a caja %d", - ["LOAD REPORT"] = "CARGAR PARTIDA", - ["A:CONTINUE"] = "A:CONTINUAR", - ["You don't have\nenough money."] = "No tienes dinero\nsuficiente.", - ["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?", - ["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!", - ["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.", - ["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.", - ["BUY"] = "COMPRAR", - ["SELL"] = "VENDER", - ["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!", - ["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!", - ["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!", - ["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS", - ["COINS %4d"] = "FICHAS %4d", - ["POKéDEX"] = "POKéDEX", - ["POKéMON"] = "POKéMON", - ["SAVE"] = "GUARDAR", - ["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d", - ["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?", - ["Now saving..."] = "Guardando...", - ["%s saved\nthe game!"] = "¡%s guardó\nla partida!", - ["OPTION"] = "OPCION", - ["LINK"] = "LINK", - ["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?", - ["BALL"] = "BALL", - ["STATUS/"] = "ESTADO/", - ["OT/"] = "EO/", - ["EXP POINTS"] = "P. EXP.", - ["LEVEL UP"] = "SUBE NIVEL", - ["PP"] = "PP", - ["SCORE %d"] = "PUNTOS %d", - ["New record!"] = "¡Nuevo récord!", - ["HI %d"] = "MAX %d", - ["A: done"] = "A: listo", - ["PLAYER"] = "JUGADOR", - ["BADGES"] = "MEDALLAS", - ["TIME"] = "TIEMPO", - ["CONTINUE"] = "CONTINUAR", - ["NEW GAME"] = "NUEVA PARTIDA", - ["EXIT GAME"] = "SALIR DEL JUEGO", - ["POKéMON RED"] = "", - ["2026 bois club games"] = "", - ["OT/%s"] = "EO/%s", - ["NAME/%s"] = "NOMBRE/%s", - ["In battle"] = "En combate", - ["Wild battle"] = "Combate salvaje", - ["Trainer battle"] = "Combate entrenador", - ["Link battle"] = "Combate link", - ["Title screen"] = "Pantalla de título", - ["Level %d"] = "Nivel %d", - ["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!", - ["Not even a nibble!"] = "¡Ni un mordisco!", - ["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!", - ["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.", - ["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!", - ["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!", - ["An elevator!"] = "¡Un ascensor!", - ["INDIGO PLATEAU"] = "MESETA ANIL", - ["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON", - ["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!", - ["%s found\n%s!"] = "¡%s encontró\n%s!", - ["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!", - ["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.", - ["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.", - ["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.", - ["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!", - ["You don't have\nany coins!"] = "¡No tienes fichas!", - ["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}", - ["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.", - ["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!", - ["Bingo!"] = "¡Bingo!", - ["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!", - ["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!", - ["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!", - ["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!", - ["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.", - ["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!", - ["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!", - ["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!", - ["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!", - ["Gyaoo!"] = "¡Gyaoo!", - ["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?", - ["SOMEONE'S PC"] = "EL PC DE ALGUIEN", - ["PROF.OAK's PC"] = "EL PC DEL PROF.OAK", - ["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:", - ["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!", - ["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!", - ["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?", - ["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.", - ["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!", - ["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!", - ["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.", - ["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.", - ["Please come\nagain!"] = "¡Vuelve pronto!", - ["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!", - ["%s received\nthe %s!"] = "¡%s recibió\nel %s!", - ["%s received\n%s!"] = "¡%s recibió\n%s!", - ["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.", - ["Go right ahead!"] = "¡Adelante!", - ["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!", - ["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!", - ["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!", - ["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!", - ["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!", - ["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!", - ["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!", - ["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!", - ["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!", - ["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!", - ["contribution is not a table"] = "", - [" [%s %s.%s]"] = " [%s %s.%s]", - ["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.", - ["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.", - ["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)", - ["%s wants\nto battle!"] = "¡%s quiere\nluchar!", - ["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?", - ["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!", - ["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!", - ["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!", - ["%s left the\nbattle."] = "%s dejó el\ncombate.", - ["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!", - ["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.", - ["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.", - ["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)", - ["%s vs %s!"] = "¡%s contra %s!", - ["Link error:\n%s"] = "Error de link:\n%s", - ["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?", - ["The link was\nbroken."] = "Se ha perdido el\nlink.", - ["Link battle\ncan't start."] = "El combate link no\npuede empezar.", - ["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.", - ["The trade was\ncancelled."] = "El intercambio se\nha cancelado.", - ["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!", - ["LINK CABLE (LAN)"] = "CABLE LINK (LAN)", - ["ONLINE MATCH"] = "PARTIDA EN LINEA", - ["TOURNAMENT"] = "TORNEO", - ["HOST A GAME"] = "CREAR PARTIDA", - ["JOIN A GAME"] = "UNIRSE A PARTIDA", - ["UDP port %s"] = "Puerto UDP %s", - ["HOST ONLINE"] = "CREAR EN LINEA", - ["JOIN ONLINE"] = "UNIRSE EN LINEA", - ["Tell your friend"] = "Dile a tu amigo", - ["the code:"] = "el código:", - ["Waiting for join..."] = "Esperando...", - ["A: connect B: back"] = "A: conectar B: atrás", - ["Calling..."] = "Llamando...", - ["Friend joins at:"] = "Tu amigo entra en:", - ["Port: %s"] = "Puerto: %s", - ["TRADE"] = "INTERCAMBIO", - ["BATTLE"] = "COMBATE", - ["LEVELS:"] = "NIVELES:", - ["A: continue B: back"] = "A: seguir B: atrás", - ["Checking the"] = "Comprobando el", - ["other game..."] = "otro juego...", - ["Waiting for the"] = "Esperando a que", - ["host to choose..."] = "el anfitrión elija...", - ["A: trade anyway"] = "A: intercambiar igual", - ["YOURS"] = "TUYO", - ["THEIRS"] = "SUYO", - ["X: not on theirs"] = "X: no en el suyo", - ["A: trade B: cancel"] = "A: cambiar B: cancelar", - ["Exchanging data..."] = "Intercambiando...", - ["can't reach relay %s:%d\n(%s)"] = "", - ["That code wasn't\nfound."] = "Ese código no se\nha encontrado.", - ["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.", - ["That code has\nexpired."] = "Ese código ha\ncaducado.", - ["Couldn't join:\n%s"] = "No se pudo unir:\n%s", - ["no answer from\n%s"] = "", - ["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.", - ["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.", - ["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.", - ["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.", - ["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.", - ["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.", - ["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.", - ["Can't watch this\nmatch."] = "No se puede ver\neste combate.", - ["HOST"] = "CREAR", - ["JOIN"] = "UNIRSE", - ["START: create"] = "START: crear", - ["A: join B: back"] = "A: unirse B: atrás", - ["B: cancel"] = "B: cancelar", - ["TOURNAMENT %s"] = "TORNEO %s", - ["ROUND %d"] = "RONDA %d", - ["%s (bye)"] = "%s (pasa)", - ["%s%s vs %s%s"] = "%s%s contra %s%s", - ["(organizing --"] = "(organizando --", - ["not playing)"] = "no juega)", - ["Waiting for"] = "Esperando a que", - ["players to join:"] = "entren jugadores:", - ["A: START B: cancel"] = "A: START B: cancelar", - ["%s is the"] = "¡%s es el", - ["champion!"] = "campeón!", - ["A: continue"] = "A: continuar", - ["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.", - ["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!", - ["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.", - ["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!", - ["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!", - ["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!", - ["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!", - ["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!", - ["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!", - ["%s\nis refusing!"] = "¡%s se\nniega!", - ["%s's %s\nrose!"] = "¡El %s de %s\nsubió!", - ["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!", - ["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!", - ["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!", - ["Coin count:\n%d"] = "Fichas:\n%d", - ["NO MODS INSTALLED"] = "NO HAY MODS", - ["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..", - ["OPTIONS.."] = "OPCIONES..", - ["PERMISSIONS.."] = "PERMISOS..", - ["VIEW ERROR.."] = "VER ERROR..", - ["BACK"] = "ATRAS", - ["APPLY & RESTART"] = "APLICAR Y REINICIAR", - ["DISCARD CHANGES"] = "DESCARTAR CAMBIOS", - ["DATA & API ONLY"] = "SOLO DATOS Y API", - ["DISABLE BOTH?"] = "DESACTIVAR AMBOS?", - ["PROFILE NAME?"] = "NOMBRE DEL PERFIL?", - ["RENAME?"] = "RENOMBRAR?", - ["RESET DEFAULTS"] = "VALORES POR DEFECTO", - ["NO CHANGES"] = "SIN CAMBIOS", - ["A:OK"] = "A:OK", - ["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)", - ["MOD MANAGER"] = "GESTOR DE MODS", - ["Choose a mod .zip"] = "Elige un .zip de mod", - ["Choose a .sav save file"] = "Elige un archivo .sav", - ["An update is available"] = "Hay una actualización", - ["Name save slot"] = "Nombra la ranura", - ["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra", - ["Add a mod index"] = "Añadir un índice de mods", - ["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.", - ["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar", - ["Import a ROM to play"] = "Importa una ROM para jugar", - ["RED"] = "ROJO", - ["BLUE"] = "AZUL", - ["YELLOW"] = "AMARILLO", - ["FIND MODS"] = "BUSCAR MODS", - ["%d of 3 ready"] = "%d de 3 listos", - ["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.", - ["ROM imported"] = "ROM importada", - ["That ROM could not be imported."] = "No se pudo importar esa ROM.", - ["Open folder"] = "Abrir carpeta", - ["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados", - ["%d of %d enabled"] = "%d de %d activados", - ["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.", - ["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.", - ["Refreshed - %d mods listed"] = "Actualizado - %d mods listados", - ["Added %s"] = "Añadido %s", - ["Index removed"] = "Índice eliminado", - ["Downloading %s..."] = "Descargando %s...", - ["Installed %s %s"] = "Instalado %s %s", - ["%d mods listed"] = "%d mods listados", - ["%d of %d mods"] = "%d de %d mods", - ["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.", - ["No mod index added"] = "No hay índice de mods", - ["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.", - ["Search mods"] = "Buscar mods", - ["This index lists no mods yet."] = "Este índice aún no lista mods.", - ["No mods match that search."] = "Ningún mod coincide con esa búsqueda.", -} diff --git a/mods/spanish_ui/lang/trainer_names.lua b/mods/spanish_ui/lang/trainer_names.lua deleted file mode 100644 index c6537732..00000000 --- a/mods/spanish_ui/lang/trainer_names.lua +++ /dev/null @@ -1,7 +0,0 @@ --- Trainer class names --- --- Trainer class names for Espanol. - -return { - ["OPP_FIX_YOUNGSTER"] = "", -} diff --git a/mods/spanish_ui/main.lua b/mods/spanish_ui/main.lua deleted file mode 100644 index ada371d1..00000000 --- a/mods/spanish_ui/main.lua +++ /dev/null @@ -1,127 +0,0 @@ --- spanish_ui: a translation of the game into Espanol. --- --- Nothing here is translated yet. Every table under lang/ starts with --- empty strings; fill one in and it takes effect on the next boot, and --- anything still empty keeps rendering in English. That means a --- half-finished translation is always playable, so you can ship early and --- fill the long tail in later. --- --- Read TRANSLATING.md before the first edit; the font is the part people --- get wrong. -return function(mod) - -- mod:read is the supported way into your own directory; the catalogs are - -- plain Lua tables, so read and run them rather than require()ing them. - local function catalog(name) - local rel = "lang/" .. name .. ".lua" - local body = mod:read(rel) - if not body then return {} end - local chunk, err = loadstring(body, rel) - if not chunk then - mod.log:warn("%s has a syntax error: %s", rel, tostring(err)) - return {} - end - local ok, table_ = pcall(chunk) - if not ok or type(table_) ~= "table" then - mod.log:warn("%s did not return a table: %s", rel, tostring(table_)) - return {} - end - return table_ - end - - -- An empty value means "not translated yet", never "translate to blank". - local function each(name, apply) - local n = 0 - for key, value in pairs(catalog(name)) do - if type(value) == "string" and value ~= "" then - apply(key, value) - n = n + 1 - end - end - return n - end - - -- ---- glyphs ------------------------------------------------------- - -- Register the sheet BEFORE anything asks for a glyph on it. base is - -- the first code the page owns; 0x100 and up is free space above the - -- vanilla pages, so a new alphabet never collides with them. - for id, page in pairs(catalog("font")) do - mod.content.font:register(id, page) - end - -- charmap: which byte sequence draws which code - for seq, code in pairs(catalog("charmap")) do - mod.content.font:register("charmap:" .. seq, { seq = seq, code = code }) - end - - -- ---- text --------------------------------------------------------- - local counts = {} - counts.dialogue = each("dialogue", function(id, value) - mod.content.text:override(id, value) - end) - counts.strings = each("strings", function(source, value) - mod.content.strings:override(source, value) - end) - counts.species = each("species_names", function(id, value) - mod.content.pokemon:patch(id, { name = value }) - end) - counts.moves = each("move_names", function(id, value) - mod.content.moves:patch(id, { name = value }) - end) - counts.items = each("item_names", function(id, value) - mod.content.items:patch(id, { name = value }) - end) - counts.trainers = each("trainer_names", function(id, value) - mod.content.trainers:patch(id, { name = value }) - end) - counts.statuses = each("status_labels", function(id, value) - mod.content.statuses:patch(id, { label = value }) - end) - - -- ---- name entry --------------------------------------------------- - -- The naming screen's letter grid. Leave lang/naming.lua returning nil - -- to keep the English alphabet. - local grid = catalog("naming") - if grid.upper then - -- Only offer the accented cells when the running cartridge can actually - -- draw them. A Spanish ROM has Ñ and the accented vowels in its font - -- and the manifest maps them; an English one does not, and an - -- unmappable cell renders blank -- a naming screen with six empty keys - -- is worse than an English one. So check the charmap and fall back. - local function drawable(cells, ctx) - local font = ((ctx.game or {}).data or {}).font - local charmap = font and font.charmap - if not charmap then return false end - local have = {} - for _, entry in ipairs(charmap) do have[entry.seq] = true end - for _, row in ipairs(cells) do - for _, cell in ipairs(row) do - -- Only the non-ASCII cells are at risk; A-Z and punctuation are - -- on every page. - if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then - return false - end - end - end - return true - end - local warned = false - mod.hooks:on("ui.naming.grid", function(base, ctx) - local want = ctx.lower and grid.lower or grid.upper - if not want then return base end - if not drawable(want, ctx) then - if not warned then - warned = true - mod.log:info("naming grid: this ROM has no accented glyphs, " - .. "keeping the English alphabet") - end - return base - end - return want - end) - end - - mod.events:on("game.ready", function() - local total = 0 - for _, n in pairs(counts) do total = total + n end - mod.log:info("Espanol: %d strings translated", total) - end) -end diff --git a/mods/spanish_ui/manifest.json b/mods/spanish_ui/manifest.json deleted file mode 100644 index b3e45f04..00000000 --- a/mods/spanish_ui/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "spanish_ui", - "name": "Espanol (interfaz)", - "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "content", - "game_version": ">=0.0.0-dev <1.0.0", - "category": "LANGUAGE", - "priority": 100, - "dependencies": [], - "optional_dependencies": [], - "conflicts": [], - "incompatible": [], - "experimental": false, - "description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus." -} \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 5ae82de6..2828ac83 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -70,7 +70,7 @@ rm -f "$LOVE_FILE" (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ 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_yellow.json tools/rom_manifest_gold.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') # Materialize the listing once and grep the file: piping unzip straight into # grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a @@ -90,7 +90,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ tools/save-editor/panels/Party.lua \ src/ui/kit/Kit.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ - tools/rom_manifest_yellow.json; do + tools/rom_manifest_yellow.json tools/rom_manifest_gold.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 df49f4c3..1607c476 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -207,7 +207,7 @@ pack_game_love() { (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ 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_yellow.json tools/rom_manifest_gold.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 @@ -227,6 +227,8 @@ pack_game_love() { || fail "game.love is missing the save editor (Edit on a save row would crash)" grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \ || fail "game.love is missing the Yellow ROM import manifest" + grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \ + || fail "game.love is missing the Gold 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/pack_love.sh b/scripts/pack_love.sh index 1a94a830..f22b9b74 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -49,7 +49,7 @@ rm -f "$OUTPUT" (cd "$ROOT" && zip -q -9 -r "$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_yellow.json tools/rom_manifest_gold.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') if [ -n "$BUILD_INFO" ]; then @@ -87,7 +87,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ tools/save-editor/PadInput.lua \ tools/save-editor/panels/Party.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ - tools/rom_manifest_yellow.json \ + tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ src/ui/kit/Kit.lua \ src/import/LauncherView.lua; do grep -qxF "$required" "$LISTING" \ diff --git a/src/battle/gen2/Ai.lua b/src/battle/gen2/Ai.lua new file mode 100644 index 00000000..f5e5d774 --- /dev/null +++ b/src/battle/gen2/Ai.lua @@ -0,0 +1,1674 @@ +-- Gen 2 trainer AI (engine/battle/ai/scoring.asm + engine/battle/ai/move.asm). +-- +-- The cart scores every move the enemy knows, then picks the *lowest* score: +-- each scoring layer walks the move list and either `dec [hl]` to encourage a +-- move or `inc [hl]` to discourage it. Which layers run is a per-class bit +-- field, TRNATTR_AI_MOVE_WEIGHTS, which the extractor already carries on each +-- trainer class record as `attributes` -- bytes 4 and 5, little-endian. +-- +-- A class with no flags (and every wild mon) simply picks at random, which is +-- what AIChooseMove does when wEnemyTrainerAIFlags is zero. That is also the +-- honest fallback for a layer this file does not model: it never scores, so it +-- never scores wrongly. + +local Damage = require("src.battle.gen2.Damage") + +local Ai = {} + +-- constants/trainer_data_constants.asm, shift_const order. +Ai.FLAGS = { + BASIC = 0x0001, + SETUP = 0x0002, + TYPES = 0x0004, + OFFENSIVE = 0x0008, + SMART = 0x0010, + OPPORTUNIST = 0x0020, + AGGRESSIVE = 0x0040, + CAUTIOUS = 0x0080, + STATUS = 0x0100, + RISKY = 0x0200, +} + +-- AIChooseMove seeds every slot with this before the layers run. +Ai.BASE_SCORE = 20 + +-- BASE_AI_SWITCH_SCORE: CheckPlayerMoveTypeMatchups starts here and walks the +-- score down for every super-effective move the player has shown. +Ai.BASE_SWITCH_SCORE = 10 + +-- TrainerClassAttributes is {item1, item2, baseMoney, aiLo, aiHi, switchLo, +-- switchHi, pad}; the AI word is bytes 4 and 5, little-endian. +function Ai.flagsOf(attributes) + if type(attributes) ~= "table" then return 0 end + return (attributes[4] or 0) + (attributes[5] or 0) * 256 +end + +function Ai.has(flags, name) + local bit = Ai.FLAGS[name] + if not bit then return false end + return math.floor((flags or 0) / bit) % 2 == 1 +end + +-- data/battle/ai/stall_moves.asm and residual_moves.asm, keyed by effect: the +-- moves AI_Opportunist stops using when it is nearly dead and the ones +-- AI_Cautious stops using after its first turn. +Ai.STALL_EFFECTS = { + EFFECT_HEAL = true, EFFECT_TOXIC = true, EFFECT_LEECH_SEED = true, + EFFECT_LIGHT_SCREEN = true, EFFECT_REFLECT = true, EFFECT_SAFEGUARD = true, + EFFECT_MIST = true, EFFECT_SUBSTITUTE = true, EFFECT_PERISH_SONG = true, + EFFECT_MEAN_LOOK = true, EFFECT_SPIKES = true, EFFECT_ATTRACT = true, + EFFECT_CONFUSE = true, EFFECT_DISABLE = true, EFFECT_ENCORE = true, + EFFECT_RAIN_DANCE = true, EFFECT_SUNNY_DAY = true, EFFECT_SANDSTORM = true, + EFFECT_MORNING_SUN = true, EFFECT_SYNTHESIS = true, EFFECT_MOONLIGHT = true, +} + +Ai.RESIDUAL_EFFECTS = { + EFFECT_TOXIC = true, EFFECT_LEECH_SEED = true, EFFECT_NIGHTMARE = true, + EFFECT_CURSE = true, EFFECT_SPIKES = true, EFFECT_PERISH_SONG = true, + EFFECT_MEAN_LOOK = true, EFFECT_ATTRACT = true, EFFECT_ENCORE = true, + EFFECT_DISABLE = true, EFFECT_LIGHT_SCREEN = true, EFFECT_REFLECT = true, + EFFECT_SAFEGUARD = true, EFFECT_MIST = true, +} + +-- The two blocks AI_Setup keys off: EFFECT_ATTACK_UP..EFFECT_EVASION_UP and +-- their _2 forms raise the user, the _DOWN forms lower the target. +Ai.STAT_UP_EFFECTS = {} +Ai.STAT_DOWN_EFFECTS = {} +for _, stat in ipairs({ "ATTACK", "DEFENSE", "SPEED", "SP_ATK", "SP_DEF", + "ACCURACY", "EVASION" }) do + Ai.STAT_UP_EFFECTS["EFFECT_" .. stat .. "_UP"] = true + Ai.STAT_UP_EFFECTS["EFFECT_" .. stat .. "_UP_2"] = true + Ai.STAT_DOWN_EFFECTS["EFFECT_" .. stat .. "_DOWN"] = true + Ai.STAT_DOWN_EFFECTS["EFFECT_" .. stat .. "_DOWN_2"] = true +end + +-- Effects that do nothing but inflict a major status, which is what the +-- BASIC and STATUS layers care about. +Ai.STATUS_EFFECTS = { + EFFECT_SLEEP = "sleep", EFFECT_POISON = "poison", EFFECT_TOXIC = "toxic", + EFFECT_PARALYZE = "paralyze", EFFECT_BURN = "burn", + EFFECT_FREEZE = "freeze", EFFECT_CONFUSE = "confuse", +} + +-- The type matchup a move would get, x10 (10 = neutral, 0 = immune). +local function matchupOf(context, def, defender) + local chart = context.typeChart or {} + return Damage.typeMultiplier(def.type, defender.types or {}, chart.matchups) +end + +-- A rough expected damage, used only to rank moves against each other: the +-- real roll's randomness would make the AI's own choice non-deterministic, +-- which is not what the cart does (AI_Aggressive compares wCurDamage from a +-- no-random pass). +local function expectedDamage(context, attacker, defender, def) + if (def.power or 0) <= 0 then return 0 end + local damage = Damage.calc({ + level = attacker.level or 1, + power = def.power, + moveType = def.type, + attacker = { + attack = (attacker.stats or {}).attack, + specialAttack = (attacker.stats or {}).specialAttack, + types = attacker.types, + stages = context.attackerStages, + }, + defender = { + defense = (defender.stats or {}).defense, + specialDefense = (defender.stats or {}).specialDefense, + types = defender.types, + stages = context.defenderStages, + }, + types = (context.typeChart or {}).types, + matchups = (context.typeChart or {}).matchups, + -- AIDamageCalc runs BattleCommand_DamageCalc itself (scoring.asm:3002-3016), + -- so the enemy's estimate takes the `srl c` too (effect_commands.asm:2904-2909). + defenseHalved = def.effect == "EFFECT_SELFDESTRUCT", + critical = false, + random = function() return 0 end, + }) + return damage +end + +-------------------------------------------------------------------------- +-- AI_Smart (engine/battle/ai/scoring.asm) +-------------------------------------------------------------------------- +-- +-- The heaviest layer: a 70-entry table of per-EFFECT handlers, each one a few +-- lines of "look at the HP, the speed and the statuses, then encourage or +-- discourage". `dec [hl]` encourages (a LOWER score wins) and `inc [hl]` +-- discourages; AIDiscourageMove adds ten, which is what "dismiss" means. +-- +-- The handlers below are transcribed one for one. An effect with no handler +-- is simply not scored by this layer, which is exactly what the cart does with +-- an effect that is not in its table. + +-- AIDiscourageMove. +local DISMISS = 10 + +-- The two coin flips the scoring layers use. `cp 20 percent - 1` succeeds +-- (carry set, meaning "return without scoring") on the LOW roll, so the +-- helpers below read as "does the encouragement happen". +local function chance(random, percent) + return (random(100) + 1) <= percent +end + +-- state (all optional; a missing field simply never fires its branch): +-- enemyHp / enemyMaxHp / playerHp / playerMaxHp +-- enemyFaster AICompareSpeed +-- enemyTurns / playerTurns how many turns each mon has been out +-- playerStatus / enemyStatus +-- playerToxic, playerLeechSeed, playerCharged, playerFlying +-- enemyRage, enemyProtectCount, enemyFuryCutter +-- stages (the enemy's) / playerStages +-- knownEffects the effects the enemy's own move list carries +-- enemyMoveIds the move IDS the enemy knows (AIHasMoveInArray) +-- enemyTypes / playerTypes both slots, IN ORDER: the weather and Curse +-- handlers read slot 1 before slot 2 and a swapped +-- pair scores differently +-- playerSpecialType either player type is on the special side of +-- constants/type_constants.asm (`cp SPECIAL`) +-- playerMatchupScore CheckPlayerMoveTypeMatchups' wEnemyAISwitchScore +-- playerLastMovePp / playerLastMoveMatchup / playerLastMoveSpecial +-- playerSpecialMoves the special twin of playerPhysicalMoves +-- playerUsedEffects the EFFECTS behind wPlayerUsedMoves +-- playerFuryCutter / playerRollout the player's own ramp +-- playerFlyingUp / playerUnderground SUBSTATUS_FLYING and _UNDERGROUND +-- split apart, where playerFlying is the mask +-- playerLastMon AICheckLastPlayerMon +-- enemyToxic, enemyLeechSeed, enemySpikes, enemyPartyStatus +-- enemyPerishCount, enemySleepTurns, enemyHasBench +-- enemyInaccurateEffectiveMove AI_Smart_LockOn's `.checkmove` verdict, +-- explicitly false when the loop found nothing +-- playerLockOn SUBSTATUS_LOCK_ON on the player, which is the +-- enemy's own Lock-On having landed (the cart sets +-- the bit on the TARGET); it also drives +-- Ai.lockOnPostPass +-- hiddenPowerPower / hiddenPowerMatchup HiddenPowerDamage's d and matchup +-- weather wBattleWeather, "sun" / "rain" / "sandstorm" +-- +-- These are read but never produced, because the port models no such +-- volatile yet; their branches are dead until it does: +-- playerTrapped, playerInLove, playerIdentified, playerNightmare, +-- playerCursed, playerMinimized, enemyWrapped, conversion2Matchup +local function fraction(value, max, part) + if not (value and max and max > 0) then return nil end + return value >= max * part +end + +Ai.SMART = {} +local S = Ai.SMART + +-- Greatly encourage a sleep move when the enemy can follow it with Dream +-- Eater or Nightmare; a coin flip otherwise. +S.EFFECT_SLEEP = function(ctx, st) + local combo = st.knownEffects + and (st.knownEffects.EFFECT_DREAM_EATER or st.knownEffects.EFFECT_NIGHTMARE) + if not combo then return 0 end + if not chance(ctx.random, 50) then return 0 end + return -2 +end + +-- Dream Eater: 90% chance to greatly encourage. AI_Basic is what keeps it +-- off an awake target. +S.EFFECT_DREAM_EATER = function(ctx) + if chance(ctx.random, 10) then return 0 end + return -3 +end + +-- Absorb and friends: discouraged when resisted, encouraged when the enemy is +-- hurt and the matchup is at least neutral. +S.EFFECT_LEECH_HIT = function(ctx, st, matchup) + if (matchup or 10) < 10 then + if chance(ctx.random, 39) then return 0 end + return 1 + end + if (matchup or 10) == 10 then return 0 end + if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end + if chance(ctx.random, 20) then return 0 end + return -1 +end + +-- Toxic and Leech Seed (AI_Smart_Toxic, which AI_Smart_LeechSeed shares): +-- pointless once the target is already low, since the residual will not get +-- the turns to matter. AICheckPlayerHalfHP sets carry when the player is +-- ABOVE half and the routine is `ret c`, so the discouragement lands BELOW +-- half; S.EFFECT_OHKO reads the same idiom the same way. +local function halfHpDiscourage(_, st) + if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end + return 1 +end +S.EFFECT_TOXIC = halfHpDiscourage +S.EFFECT_LEECH_SEED = halfHpDiscourage + +-- Light Screen / Reflect: only worth it at full HP. +local function fullHpOnly(ctx, st) + if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end + if chance(ctx.random, 8) then return 0 end + return 1 +end +S.EFFECT_LIGHT_SCREEN = fullHpOnly +S.EFFECT_REFLECT = fullHpOnly + +-- Evasion up: dismissed at the cap, greatly encouraged at full HP (and +-- especially against a badly poisoned target), discouraged when nearly dead. +S.EFFECT_EVASION_UP = function(ctx, st) + if (st.stages and st.stages.evasion or 0) >= 6 then return DISMISS end + if fraction(st.enemyHp, st.enemyMaxHp, 1) then + if st.playerToxic then return -2 end + if chance(ctx.random, 70) then return -2 end + return 0 + end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 3 end + if chance(ctx.random, 4) then return -2 end + return 0 +end + +-- Swift and friends: worth it once accuracy or evasion has moved three stages. +S.EFFECT_ALWAYS_HIT = function(ctx, st) + local accDown = (st.stages and st.stages.accuracy or 0) <= -3 + local evaUp = (st.playerStages and st.playerStages.evasion or 0) >= 3 + if not (accDown or evaUp) then return 0 end + if chance(ctx.random, 20) then return 0 end + return -2 +end + +-- OHKO: dismissed against a higher-level target, discouraged once the target +-- is below half. +S.EFFECT_OHKO = function(_, st) + if (st.playerLevel or 1) > (st.enemyLevel or 1) then return DISMISS end + if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end + return 1 +end + +-- Confusion: worth less the lower the target already is. +S.EFFECT_CONFUSE = function(ctx, st) + local score = 0 + if not fraction(st.playerHp, st.playerMaxHp, 0.5) then + if not chance(ctx.random, 10) then score = score + 1 end + if not fraction(st.playerHp, st.playerMaxHp, 0.25) then + score = score + 1 + end + end + return score +end + +-- Paralysis: greatly encouraged when the enemy is the SLOWER one, discouraged +-- against a nearly-dead target. +S.EFFECT_PARALYZE = function(ctx, st) + if not fraction(st.playerHp, st.playerMaxHp, 0.25) then + if chance(ctx.random, 50) then return 0 end + return 1 + end + if st.enemyFaster then return 0 end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + if chance(ctx.random, 20) then return 0 end + return -2 +end + +-- Substitute: dismissed below half HP. +S.EFFECT_SUBSTITUTE = function(_, st) + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end + return DISMISS +end + +-- Hyper Beam: a finisher, not an opener. +S.EFFECT_HYPER_BEAM = function(ctx, st) + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then + if chance(ctx.random, 35) then return 0 end + local score = 1 + if not chance(ctx.random, 50) then score = score + 1 end + return score + end + if fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + if chance(ctx.random, 50) then return 0 end + return -1 +end + +-- Reversal and Skull Bash both want the enemy nearly dead. +local function needsLowHp(_, st) + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + return 1 +end +S.EFFECT_REVERSAL = needsLowHp +S.EFFECT_SKULL_BASH = needsLowHp + +-- Belly Drum: full HP or nothing. +S.EFFECT_BELLY_DRUM = function(_, st) + if (st.stages and st.stages.attack or 0) >= 3 then return 5 end + if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end + return 5 +end + +-- Attract: an opener. +S.EFFECT_ATTRACT = function(ctx, st) + if (st.playerTurns or 0) == 0 then + if chance(ctx.random, 79) then return -1 end + return 0 + end + if chance(ctx.random, 20) then return 0 end + return 1 +end + +-- Quick Attack and friends: only when the enemy is already slower, dismissed +-- against something off the field, encouraged when it would finish the job. +S.EFFECT_PRIORITY_HIT = function(_, st, _, damage) + if st.enemyFaster then return 0 end + if st.playerFlying then return DISMISS end + if damage and st.playerHp and damage >= st.playerHp then return -1 end + return 0 +end + +-- Protect: never twice running, and worth it against a charging or poisoned +-- target. +S.EFFECT_PROTECT = function(_, st) + if (st.enemyProtectCount or 0) > 0 then return 2 end + if st.playerLockOn then return 1 end + if (st.playerFuryCutter or 0) >= 3 then return -1 end + if st.playerCharged or st.playerToxic or st.playerLeechSeed then return -1 end + return 0 +end + +-- Endure: for a Reversal follow-up, and never at high HP. +S.EFFECT_ENDURE = function(ctx, st) + if (st.enemyProtectCount or 0) > 0 then return 2 end + if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 2 end + if fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 1 end + if st.knownEffects and st.knownEffects.EFFECT_REVERSAL then + if chance(ctx.random, 20) then return 0 end + return -3 + end + return 0 +end + +-- Rollout and Fury Cutter: the ramp is only worth starting when nothing is +-- going to interrupt it. +local function rollout(ctx, st) + local risky = st.enemyStatus == "paralyze" or st.enemyConfused + or st.enemyInLove + or not fraction(st.enemyHp, st.enemyMaxHp, 0.25) + or (st.stages and st.stages.accuracy or 0) < 0 + or (st.playerStages and st.playerStages.evasion or 0) >= 1 + if not risky then return 0 end + if chance(ctx.random, 20) then return 0 end + return 1 +end +S.EFFECT_ROLLOUT = rollout + +-- Fury Cutter adds its own ramp bonus and then falls through to Rollout's +-- check, which is literally what the ASM does. +S.EFFECT_FURY_CUTTER = function(ctx, st) + local count = st.enemyFuryCutterCount or 0 + local score = 0 + if count >= 1 then score = score - 1 end + if count >= 2 then score = score - 2 end + if count >= 3 then score = score - 3 end + return score + rollout(ctx, st) +end + +-- Rage: worth continuing once it is building. +S.EFFECT_RAGE = function(ctx, st) + if not st.enemyRage then return 0 end + if chance(ctx.random, 50) then return -1 end + return -1 - math.min(3, st.enemyRageCount or 0) +end + +-- Encore: only from ahead, and only against a weak or resisted move. +S.EFFECT_ENCORE = function(_, st, _, _, playerLastPower) + if not st.enemyFaster then return 1 end + if not st.playerLastMove then return DISMISS end + if (playerLastPower or 0) == 0 then return -1 end + return 0 +end + +-- Counter: worth it when the player's known moves are mostly physical. +S.EFFECT_COUNTER = function(_, st) + if (st.playerPhysicalMoves or 0) == 0 then return 1 end + return -1 +end + +-------------------------------------------------------------------------- +-- The rest of AI_Smart_EffectHandlers, in the jumptable's own order. +-------------------------------------------------------------------------- +-- +-- Several entries in the table point at ONE cart body carrying two or more +-- labels; those share a Lua local here rather than being copied, and the +-- comment names every label that sits on it. Where a shared body is reached +-- by an ASM fallthrough (`.greatly_discourage` dropping into `.discourage`) +-- the two deltas are added together into a single return, because the cart +-- really does run both. + +-- Selfdestruct and Explosion (AI_Smart_Selfdestruct): a last resort. Greatly +-- discouraged above half HP, left alone at or below a quarter (nothing left to +-- lose), and greatly discouraged 92% of the time in between. `.discourage` is +-- reached from both ends, which is why the same +3 appears twice. +S.EFFECT_SELFDESTRUCT = function(ctx, st) + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 3 end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + if chance(ctx.random, 8) then return 0 end + return 3 +end + +-- data/battle/ai/useful_moves.asm: the nineteen moves AI_Smart_MirrorMove, +-- AI_Smart_Mimic and AI_Smart_Disable test the player's last move against. +-- The keys are the cache's own move ids, so PSYCHIC is PSYCHIC_M. +local USEFUL_MOVES = { + DOUBLE_EDGE = true, SING = true, FLAMETHROWER = true, HYDRO_PUMP = true, + SURF = true, ICE_BEAM = true, BLIZZARD = true, HYPER_BEAM = true, + SLEEP_POWDER = true, THUNDERBOLT = true, THUNDER = true, EARTHQUAKE = true, + TOXIC = true, PSYCHIC_M = true, HYPNOSIS = true, RECOVER = true, + FIRE_BLAST = true, SOFTBOILED = true, SUPER_FANG = true, +} + +-- Mirror Move (AI_Smart_MirrorMove). With nothing to copy it is dismissed +-- only when the enemy is FASTER, because a faster enemy moves before the +-- player and would copy nothing; from behind the player will have moved by +-- then, so the cart says nothing. With a useful move on the table it is a +-- coin flip to encourage, and a faster enemy encourages again. +S.EFFECT_MIRROR_MOVE = function(ctx, st) + if not st.playerLastMove then + if not st.enemyFaster then return 0 end + return DISMISS + end + if not USEFUL_MOVES[st.playerLastMove] then return 0 end + if chance(ctx.random, 50) then return 0 end + if not st.enemyFaster then return -1 end + if chance(ctx.random, 10) then return -1 end + return -2 +end + +-- Sand-Attack and friends (AI_Smart_AccuracyDown). The HP block picks one +-- bonus: a full-health player facing a healthy enemy is a big encouragement, a +-- nearly dead player a big discouragement. The tail then re-reads the board +-- (badly poisoned, seeded, accuracy already under the enemy's evasion, mid +-- ramp) and can cancel it, which the cart's own comments admit to. +S.EFFECT_ACCURACY_DOWN = function(ctx, st) + local score = 0 + if fraction(st.playerHp, st.playerMaxHp, 1) + and fraction(st.enemyHp, st.enemyMaxHp, 0.5) then + if st.playerToxic then return -2 end + if chance(ctx.random, 70) then return -2 end + elseif not fraction(st.playerHp, st.playerMaxHp, 0.25) then + score = 2 + elseif chance(ctx.random, 4) then + return -2 + elseif fraction(st.playerHp, st.playerMaxHp, 0.5) then + if chance(ctx.random, 20) then return -2 end + elseif not chance(ctx.random, 50) then + -- `.hp_mismatch_3`'s 50% miss falls THROUGH into `.hp_mismatch_2`, so the + -- move is at +2 before the tail below ever runs. + score = 2 + end + -- .not_encouraged, which `.hp_mismatch_2` also falls into: a move already at + -- +2 can still be pulled back down here. + if st.playerToxic then + if chance(ctx.random, 31) then return score end + return score - 2 + end + if st.playerLeechSeed then + if chance(ctx.random, 50) then return score end + return score - 1 + end + local enemyEva = st.stages and st.stages.evasion or 0 + local playerAcc = st.playerStages and st.playerStages.accuracy or 0 + if playerAcc < enemyEva then return score + 1 end + if (st.playerFuryCutter or 0) > 0 then return score - 2 end + if st.playerRollout then return score - 2 end + return score + 1 +end + +-- wPlayerStatLevels / wEnemyStatLevels order, in Battle.newStages' names. +-- AI_Smart_ResetStats' loop counter is NUM_LEVEL_STATS (8) but it decrements +-- BEFORE every read, so it walks these seven and stops short of the ABILITY +-- pseudo-stat BattleCommand_Curse uses. +local STAGE_KEYS = { "attack", "defense", "speed", "specialAttack", + "specialDefense", "accuracy", "evasion" } + +-- Haze (AI_Smart_ResetStats): worth it once the board has turned, meaning any +-- of the enemy's own stages sits at -3 or worse or any of the player's at +3 or +-- better. 84% to encourage then, a flat discouragement when neither is true. +-- The cart bails out of the enemy loop the moment it finds a low stage and only +-- then walks the player's, which is the same answer as one combined pass. +S.EFFECT_RESET_STATS = function(ctx, st) + local worth = false + for _, key in ipairs(STAGE_KEYS) do + if (st.stages and st.stages[key] or 0) <= -3 then worth = true end + if (st.playerStages and st.playerStages[key] or 0) >= 3 then worth = true end + end + if not worth then return 1 end + if chance(ctx.random, 16) then return 0 end + return -1 +end + +-- Bide (AI_Smart_Bide): full HP or nothing. The same shape as Light Screen's +-- check, but the cart rolls 10% here where fullHpOnly rolls 8%, so the two +-- cannot share a body. +S.EFFECT_BIDE = function(ctx, st) + if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end + if chance(ctx.random, 10) then return 0 end + return 1 +end + +-- Whirlwind and Roar (AI_Smart_ForceSwitch), which AI_Smart_BatonPass repeats +-- instruction for instruction: only worth blowing the player away once it HAS +-- shown a super-effective move, which CheckPlayerMoveTypeMatchups reports as a +-- switch score below BASE_AI_SWITCH_SCORE. +local function switchMatchup(_, st) + local score = st.playerMatchupScore + if score == nil then return 0 end + if score < Ai.BASE_SWITCH_SCORE then return 0 end + return 1 +end +S.EFFECT_FORCE_SWITCH = switchMatchup + +-- Recover, Rest, Softboiled (AI_Smart_Heal): 90% to greatly encourage below a +-- quarter HP, discouraged above half, nothing in between. AI_Smart_MorningSun, +-- AI_Smart_Synthesis and AI_Smart_Moonlight are three more labels on this one +-- body, so all four effects share it. +local function healSelf(ctx, st) + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then + if chance(ctx.random, 10) then return 0 end + return -2 + end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end + return 1 +end +S.EFFECT_HEAL = healSelf + +-- Razor Wind (AI_Smart_RazorWind, shared with AI_Smart_Unused2B). A two turn +-- move, so the cart drops it while a Perish count is running out, hits it with +-- a flat +6 (deliberately NOT AIDiscourageMove's ten) if the player has ever +-- shown Protect, and discourages it four times in five while the enemy is +-- confused or at or below half HP. The confused case falls straight into the +-- 79% roll without ever testing HP. +local function razorWind(ctx, st) + if st.enemyPerishCount and st.enemyPerishCount < 3 then return 1 end + if st.playerUsedEffects and st.playerUsedEffects.EFFECT_PROTECT then + return 6 + end + if not st.enemyConfused then + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end + end + if chance(ctx.random, 79) then return 0 end + return 1 +end +S.EFFECT_RAZOR_WIND = razorWind + +-- Super Fang (AI_Smart_SuperFang) halves what is left, so the only thing the +-- cart checks is whether there is enough left to halve. +S.EFFECT_SUPER_FANG = function(_, st) + if fraction(st.playerHp, st.playerMaxHp, 0.25) then return 0 end + return 1 +end + +-- Bind, Wrap, Fire Spin, Clamp (AI_Smart_TrapTarget): half the time greatly +-- encouraged against a target that is already suffering (badly poisoned, in +-- love, identified, mid Rollout, having a Nightmare) or still on its first +-- turn, and half the time discouraged otherwise or while the trap is already +-- running. The encourage side also wants the enemy above a quarter HP to +-- survive the lock. +S.EFFECT_TRAP_TARGET = function(ctx, st) + local encourage = false + if not st.playerTrapped then + encourage = st.playerToxic or st.playerInLove or st.playerRollout + or st.playerIdentified or st.playerNightmare + or (st.playerTurns or 0) == 0 + end + if not encourage then + if chance(ctx.random, 50) then return 0 end + return 1 + end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + if chance(ctx.random, 50) then return 0 end + return -2 +end + +-- AI_Smart_Unused2B is the second label on AI_Smart_RazorWind's body. +S.EFFECT_UNUSED_2B = razorWind + +-- Amnesia (AI_Smart_SpDefenseUp2): discouraged below half HP or once Sp.Def is +-- already at +4, ignored from +2 up, and 80% to greatly encourage below that +-- when the player carries a special type. The cart reuses the value already in +-- `a` for the second compare, so the +2 gate reads the same Sp.Def stage it +-- just tested against +4. +S.EFFECT_SP_DEF_UP_2 = function(ctx, st) + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end + local stage = st.stages and st.stages.specialDefense or 0 + if stage >= 4 then return 1 end + if stage >= 2 then return 0 end + if not st.playerSpecialType then return 0 end + if chance(ctx.random, 20) then return 0 end + return -2 +end + +-- Icy Wind, and ONLY Icy Wind (AI_Smart_SpeedDownHit): the cart gates on +-- wEnemyMoveStruct + MOVE_ANIM, so Bubble, Bubblebeam and Constrict share the +-- effect but never reach the body. Almost 90% to greatly encourage on the +-- player's first turn, while the player is the faster one and the enemy is +-- still above a quarter HP. +S.EFFECT_SPEED_DOWN_HIT = function(ctx, st) + if ctx.moveId ~= "ICY_WIND" then return 0 end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end + if (st.playerTurns or 0) ~= 0 then return 0 end + if st.enemyFaster then return 0 end + if chance(ctx.random, 12) then return 0 end + return -2 +end + +-- Mimic (AI_Smart_Mimic): with nothing to copy it is dismissed from ahead and +-- merely discouraged from behind, because `.dismiss` falls through into +-- `.discourage`. Otherwise it wants the enemy above half HP and a copied move +-- that is at least neutral coming back at its owner: the cart sets hBattleTurn +-- to 1, so BattleCheckTypeMatchup defends with the PLAYER's types. +S.EFFECT_MIMIC = function(ctx, st) + if not st.playerLastMove then + if st.enemyFaster then return DISMISS end + return 1 + end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end + local copied = st.playerLastMoveMatchup + if copied == nil then return 0 end + if copied < 10 then return 1 end + local score = 0 + if copied > 10 and not chance(ctx.random, 50) then score = -1 end + if not USEFUL_MOVES[st.playerLastMove] then return score end + if chance(ctx.random, 50) then return score end + return score - 1 +end + +-- AI_Smart_Disable. Only worth it from ahead: the slower enemy skips straight +-- to the discourage. From ahead, a 61% encourage when the player's last move +-- is one of UsefulMoves. The "does my own move have power" test on the way out +-- reads wEnemyMoveStruct + MOVE_POWER, which is 0 for every stock +-- EFFECT_DISABLE move, so on an unmodded cart a boring last move always falls +-- through into `.discourage`; the `damage` argument stands in for it so a +-- modded Disable with real power keeps the branch. +S.EFFECT_DISABLE = function(ctx, st, _, damage) + if st.enemyFaster then + if USEFUL_MOVES[st.playerLastMove] then + if chance(ctx.random, 39) then return 0 end + return -1 + end + if (damage or 0) > 0 then return 0 end + end + if chance(ctx.random, 8) then return 0 end + return 1 +end + +-- AI_Smart_PainSplit: discourage while doubling the enemy's HP would still +-- overshoot the player's, since the split would then hand HP away. The cart +-- does this as one 16 bit compare of [player HP] against [enemy HP * 2]; its +-- own comment states the test backwards. +S.EFFECT_PAIN_SPLIT = function(_, st) + if not (st.enemyHp and st.playerHp) then return 0 end + if st.playerHp >= st.enemyHp * 2 then return 0 end + return 1 +end + +-- AI_Smart_Snore, shared verbatim with AI_Smart_SleepTalk (one label falls +-- into the other). The cart tests the sleep counter against 1, so it +-- discourages only on the last sleeping turn and greatly encourages everything +-- else, an AWAKE enemy included; AI_Redundant is what keeps these off an awake +-- mon. +local function snoreOrSleepTalk(_, st) + local count = st.enemySleepTurns + if count == nil then return 0 end + if count == 1 then return 3 end + return -3 +end +S.EFFECT_SNORE = snoreOrSleepTalk + +-- AI_Smart_Conversion2. CART BUG (docs/bugs_and_glitches.md): the guard reads +-- `ld a, [wLastPlayerMove] / and a / jr nz, .discourage`, so it discourages +-- once the player HAS moved and takes the matchup path only on turn one, where +-- the move index it looks up is 0 - 1 = $ff, past the end of Moves. The +-- inverted test is kept; the garbage read becomes st.conversion2Matchup, which +-- the port leaves nil so that path scores nothing. +S.EFFECT_CONVERSION2 = function(ctx, st) + if not st.playerLastMove then + local matchup = st.conversion2Matchup + if matchup ~= nil then + if matchup > 10 then + if chance(ctx.random, 50) then return 0 end + return -1 + end + if matchup == 10 then return 0 end + else + return 0 + end + end + -- .discourage + if chance(ctx.random, 10) then return 0 end + return 1 +end + +-- AI_Smart_LockOn. Pointless when the player is already locked on, worthless +-- when nearly dead, and only from ahead once past half HP. It then wants a +-- reason: the player's evasion up three, the enemy's accuracy down three, or +-- failing both, at least one shaky-but-effective move to aim. +-- The dismissal is only half of `.player_locked_on`: the branch also walks the +-- enemy's OWN move list and encourages every shaky move by two, which is a +-- score edit on OTHER moves and so cannot live in a per-move handler. That +-- half is Ai.lockOnPostPass, run from Ai.choose once the table is done. +S.EFFECT_LOCK_ON = function(ctx, st) + if st.playerLockOn then return DISMISS end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 1 end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) and not st.enemyFaster then + return 1 + end + local evasion = st.playerStages and st.playerStages.evasion or 0 + if evasion >= 3 then + if chance(ctx.random, 50) then return 0 end + return -2 + end + if evasion >= 1 then return 0 end + local accuracy = st.stages and st.stages.accuracy or 0 + if accuracy <= -3 then + if chance(ctx.random, 50) then return 0 end + return -2 + end + if accuracy < 0 then return 0 end + -- .checkmove: the loop reaches .discourage only when no move qualified. + if st.enemyInaccurateEffectiveMove == false then return 1 end + return 0 +end + +-- `71 percent - 1`, the raw ($ff-scaled) accuracy AI_Smart_LockOn calls shaky. +Ai.LOCK_ON_ACCURACY = 0xb4 + +-- AI_Smart_LockOn's `.player_locked_on` half, as a post-pass over the finished +-- score table. With the lock-on already up the layer stops caring about its +-- own slot and doubly encourages every move the enemy would otherwise struggle +-- to land ("dec [hl]" twice per move under `71 percent - 1`); the `.dismiss` +-- tail that then buries Lock-On itself is what S.EFFECT_LOCK_ON returns. +-- +-- The loop is per Lock-On in the list, not per turn: a mon carrying both +-- Lock-On and Mind Reader runs the scoring layer twice and so lands the +-- encouragement twice, which is exactly what the cart does. +-- +-- `defs` is the move definition for each score slot, in the same order. +function Ai.lockOnPostPass(scores, defs) + local rounds = 0 + for i = 1, #scores do + local def = defs[i] + if def and def.effect == "EFFECT_LOCK_ON" then rounds = rounds + 1 end + end + if rounds == 0 then return scores end + for i = 1, #scores do + local def = defs[i] + -- accuracyRaw is the cart's own byte; the percentage is the fallback for a + -- caller that only has the human-readable number. + local raw = def and (def.accuracyRaw + or (def.accuracy and math.floor(def.accuracy * 255 / 100))) + if raw and raw < Ai.LOCK_ON_ACCURACY then + scores[i] = scores[i] - 2 * rounds + end + end + return scores +end + +-- AI_Smart_DefrostOpponent. Dead twice over, and both are kept: no move +-- carries EFFECT_DEFROST_OPPONENT (the cart says so itself), and the status it +-- reads is wEnemyMonStatus, the AI's OWN freeze, not the opponent the effect +-- names. +S.EFFECT_DEFROST_OPPONENT = function(_, st) + if st.enemyStatus ~= "freeze" then return 0 end + return -3 +end + +-- AI_Smart_SleepTalk is the same label body as AI_Smart_Snore. +S.EFFECT_SLEEP_TALK = snoreOrSleepTalk + +-- AI_Smart_DestinyBond is the third label on the body `needsLowHp` already +-- carries for AI_Smart_Reversal and AI_Smart_SkullBash. +S.EFFECT_DESTINY_BOND = needsLowHp + +-- AI_Smart_Spite. With nothing to drain yet it is a stall move: dismissed +-- from ahead, half discouraged from behind. Once the player has shown a move +-- it goes by that move's remaining PP: under 6 is worth taking, 15 or more is +-- not. The cart reads the raw PP byte without masking off the PP Up bits, so +-- a move with any PP Ups always lands on `.discourage`; the port stores plain +-- PP and has no such bits to mask, so that quirk cannot be reproduced. +S.EFFECT_SPITE = function(ctx, st) + if not st.playerLastMove then + if st.enemyFaster then return DISMISS end + if chance(ctx.random, 50) then return 0 end + return 1 + end + -- `.moveloop` falls out with no score when that move is not in the player's + -- current move list, which is what a nil PP stands in for here. + local pp = st.playerLastMovePp + if pp == nil then return 0 end + if pp < 6 then + if chance(ctx.random, 39) then return 0 end + return -2 + end + if pp >= 15 then return 1 end + if not chance(ctx.random, 39) then return 0 end + return 1 +end + +-- AI_Smart_HealBell. The cart ORs the status byte of every unfainted mon in +-- wOTParty: nothing statused and a clean active mon dismisses the move. +-- Otherwise one step of encouragement for the active mon being statused, and a +-- coin flip for two more when that status is sleep or freeze, the two it cannot +-- simply wait out. `.ok` is reached both by the `jr z` and by falling through +-- the `dec [hl]`, so the bonus stacks on top of the first step. +S.EFFECT_HEAL_BELL = function(ctx, st) + if st.enemyPartyStatus == nil then return 0 end + if not st.enemyPartyStatus then + -- .no_status: the party copy can lag the active mon, which is the only way + -- this test and the one above disagree. + if st.enemyStatus then return 0 end + return DISMISS + end + local score = 0 + if st.enemyStatus then score = score - 1 end + if st.enemyStatus == "sleep" or st.enemyStatus == "freeze" then + if chance(ctx.random, 50) then return score end + score = score - 2 + end + return score +end + +-- AI_Smart_Thief: `ld a, [hl] / add $1e`. Three times a dismissal, so Thief is +-- only ever picked when nothing else is left. +S.EFFECT_THIEF = function() + return 30 +end + +-- AI_Smart_MeanLook. Needs the enemy above half HP and the player holding +-- something in reserve (trapping the player's last mon is dismissed outright). +-- 80% to greatly encourage against a player who is already suffering, else +-- discourage unless CheckPlayerMoveTypeMatchups says the player has nothing +-- effective to answer with. +-- CART BUG (docs/bugs_and_glitches.md): the badly-poisoned test reads +-- wEnemySubStatus5, so the AI encourages Mean Look when IT is the poisoned one. +S.EFFECT_MEAN_LOOK = function(ctx, st) + local encourage = false + if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then + if st.playerLastMon then return DISMISS end + if st.enemyToxic then + encourage = true + elseif st.playerInLove or st.playerRollout or st.playerIdentified + or st.playerNightmare then + encourage = true + elseif (st.playerMatchupScore or Ai.BASE_SWITCH_SCORE) + >= Ai.BASE_SWITCH_SCORE + 1 then + return 0 + end + end + if not encourage then return 1 end + if chance(ctx.random, 20) then return 0 end + return -3 +end + +-- AI_Smart_Nightmare: a flat coin flip. AI_Basic is what keeps it off a +-- target that is not asleep. +S.EFFECT_NIGHTMARE = function(ctx) + if chance(ctx.random, 50) then return 0 end + return -1 +end + +-- AI_Smart_FlameWheel: five steps of encouragement when the enemy is frozen, +-- because Flame Wheel and Sacred Fire thaw their own user in Gen 2. Its status +-- read really is the enemy's own, unlike AI_Smart_DefrostOpponent's. +S.EFFECT_FLAME_WHEEL = function(_, st) + if st.enemyStatus ~= "freeze" then return 0 end + return -5 +end + +-- Curse (AI_Smart_Curse). A Ghost type enemy pays half its HP for a residual, +-- so that half of the routine wants a healthy enemy, an uncursed target and the +-- target's very first turn; the non-Ghost half is an Attack boost and wants a +-- target it can actually punch, meaning neither of its types is special. +S.EFFECT_CURSE = function(ctx, st) + local enemyTypes = st.enemyTypes or {} + local playerTypes = st.playerTypes or {} + if enemyTypes[1] == "GHOST" or enemyTypes[2] == "GHOST" then + -- .ghost_curse: dismissed at or below 25% (the cut would be suicide), and + -- discouraged at or below 50%. + if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return DISMISS end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end + if st.playerCursed then return DISMISS end + if (st.playerTurns or 0) > 0 then return 0 end + if chance(ctx.random, 50) then return 0 end + return -2 + end + if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end + -- wEnemyAtkLevel against BASE_STAT_LEVEL + 4 and + 2: at +4 the boost is + -- discouraged outright, at +2 the AI simply has no opinion left. + local attack = st.stages and st.stages.attack or 0 + if attack >= 4 then return 1 end + if attack >= 2 then return 0 end + -- `cp GHOST` comes before `cp SPECIAL`, and `.greatly_discourage` falls + -- THROUGH into `.discourage`, so a Ghost FIRST type is +2, not +1. + if playerTypes[1] == "GHOST" then return 2 end + if st.playerSpecialType then return 0 end + if chance(ctx.random, 20) then return 0 end + return -2 +end + +-- Foresight (AI_Smart_Foresight). Worth 61% encouragement when the accuracy +-- war has already been lost (enemy accuracy at -3, player evasion at +3) or +-- when the target is a Ghost the enemy's Normal and Fighting moves cannot +-- touch; a flat 92% discouragement otherwise. +S.EFFECT_FORESIGHT = function(ctx, st) + local playerTypes = st.playerTypes or {} + local encourage = (st.stages and st.stages.accuracy or 0) <= -3 + or (st.playerStages and st.playerStages.evasion or 0) >= 3 + or playerTypes[1] == "GHOST" or playerTypes[2] == "GHOST" + if not encourage then + if chance(ctx.random, 8) then return 0 end + return 1 + end + if chance(ctx.random, 39) then return 0 end + return -2 +end + +-- Perish Song (AI_Smart_PerishSong). FindAliveEnemyMons first: with nothing on +-- the bench the countdown kills the enemy too, which is worth five points of +-- discouragement. A trapped player cannot run from it, so that is the one case +-- the cart encourages; otherwise it is only left alone while the AI is losing +-- the matchup and would rather rotate out anyway. +S.EFFECT_PERISH_SONG = function(ctx, st) + if st.enemyHasBench == false then return 5 end + if st.playerTrapped then + if chance(ctx.random, 50) then return 0 end + return -1 + end + local score = st.playerMatchupScore or Ai.BASE_SWITCH_SCORE + if score < Ai.BASE_SWITCH_SCORE then return 0 end + if chance(ctx.random, 50) then return 0 end + return 1 +end + +-- AI_Smart_Sandstorm's own .SandstormImmuneTypes, walked with IsInArray once +-- per type slot. +local SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true } + +-- Sandstorm (AI_Smart_Sandstorm). Greatly discouraged against anything that +-- shrugs the chip off (`.greatly_discourage` falls through into `.discourage`, +-- hence +2), discouraged once the target is at or below half (the chip will not +-- decide the fight any more), a coin flip otherwise. +S.EFFECT_SANDSTORM = function(ctx, st) + local playerTypes = st.playerTypes or {} + if SANDSTORM_IMMUNE[playerTypes[1] or ""] + or SANDSTORM_IMMUNE[playerTypes[2] or ""] then + return 2 + end + if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return 1 end + if chance(ctx.random, 50) then return 0 end + return -1 +end + +-- Swagger (AI_Smart_Swagger) jumps straight into AI_Smart_Attract: both are +-- openers, 80% encouraged on the target's first turn and 80% discouraged after. +S.EFFECT_SWAGGER = S.EFFECT_ATTRACT + +-- Safeguard (AI_Smart_Safeguard). 80% discouraged once the PLAYER is at or +-- below half HP: the cart reads the player's bar, not the enemy's, on the +-- theory that a nearly dead target is not going to status anything. +-- AICheckPlayerHalfHP sets carry when the player is ABOVE half and the routine +-- is `ret c`, so this layer only ever discourages. +S.EFFECT_SAFEGUARD = function(ctx, st) + if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end + if chance(ctx.random, 20) then return 0 end + return 1 +end + +-- Magnitude (AI_Smart_Magnitude), which AI_Smart_Earthquake shares outright. +-- It only ever fires when the player's last move was Dig: greatly encouraged if +-- the player is underground right now and the enemy moves first, and a coin +-- flip when the player has surfaced but the enemy is SLOWER, which is the +-- cart's guess that the player is about to dig again. The two speed tests are +-- opposite senses of the same AICompareSpeed carry. +local function smartEarthquake(ctx, st) + if st.playerLastMove ~= "DIG" then return 0 end + if st.playerUnderground then + if not st.enemyFaster then return 0 end + return -2 + end + -- .could_dig + if st.enemyFaster then return 0 end + if chance(ctx.random, 50) then return 0 end + return -1 +end +S.EFFECT_MAGNITUDE = smartEarthquake + +-- Baton Pass (AI_Smart_BatonPass) is AI_Smart_ForceSwitch's body again: the +-- cart never looks at what the enemy would actually be passing. +S.EFFECT_BATON_PASS = switchMatchup + +-- Pursuit (AI_Smart_Pursuit). 50% chance to greatly encourage it once the +-- target is at or below 25% and likely to run or rotate; 80% discouraged +-- otherwise, since at full HP it is just a 40 power Dark move. +S.EFFECT_PURSUIT = function(ctx, st) + if not fraction(st.playerHp, st.playerMaxHp, 0.25) then + if chance(ctx.random, 50) then return 0 end + return -2 + end + if chance(ctx.random, 20) then return 0 end + return 1 +end + +-- Rapid Spin (AI_Smart_RapidSpin). 80% chance to greatly encourage it when it +-- would actually clear something off the ENEMY's own side: a Bind style trap, +-- Leech Seed, or Spikes. No opinion at all otherwise. +S.EFFECT_RAPID_SPIN = function(ctx, st) + if not (st.enemyWrapped or st.enemyLeechSeed or st.enemySpikes) then + return 0 + end + if chance(ctx.random, 20) then return 0 end + return -2 +end + +-- AI_Smart_MorningSun, AI_Smart_Synthesis and AI_Smart_Moonlight are three more +-- labels on AI_Smart_Heal's body. The cart makes no weather check at all here, +-- even though the three moves heal different fractions by weather. +S.EFFECT_MORNING_SUN = healSelf +S.EFFECT_SYNTHESIS = healSelf +S.EFFECT_MOONLIGHT = healSelf + +-- Hidden Power (AI_Smart_HiddenPower): the cart throws away the Normal-type +-- stub in the move table and recomputes the move's real type and base power +-- from the enemy's DVs (HiddenPowerDamage), then scores THAT. Resisted, or +-- under 50 power, is discouraged; super effective, or a full 70 power at +-- neutral, is encouraged. The `matchup` argument the layer passes in is +-- deliberately unused: it is the declared type's, which is what the cart +-- discards. +S.EFFECT_HIDDEN_POWER = function(_, st) + local power, matchup = st.hiddenPowerPower, st.hiddenPowerMatchup + if not (power and matchup) then return 0 end + -- cp EFFECTIVE: not very effective, or immune, is `.bad`. + if matchup < 10 then return 1 end + if power < 50 then return 1 end + -- cp EFFECTIVE + 1: super effective is `.good` whatever the power is. + if matchup > 10 then return -1 end + if power < 70 then return 0 end + return -1 +end + +-- Rain Dance and Sunny Day share AI_Smart_WeatherMove and its two tails, +-- AIBadWeatherType (three inc [hl]) and AIGoodWeatherType (two dec [hl]). +-- The player's type slots are read IN ORDER, bad then good per slot, so a +-- Fire/Water target reads as "good" for Rain Dance where a Water/Fire one reads +-- as "bad": that ordering is load bearing, do not fold it into a set. +local function weatherTypeVerdict(types, badType, goodType) + for i = 1, 2 do + local slot = (types or {})[i] + if slot == badType then return "bad" end + if slot == goodType then return "good" end + end + return nil +end + +-- AIBadWeatherType. +local BAD_WEATHER = 3 + +-- AIGoodWeatherType: the weather would disfavour the player type-wise, so set +-- it up while the player is still above half, and only while one of the two +-- mons is still on its first turn. +local function goodWeatherType(_, st) + if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end + if (st.playerTurns or 0) == 0 then return -2 end + if (st.enemyTurns or 0) ~= 0 then return 0 end + return -2 +end + +-- AI_Smart_WeatherMove: greatly discouraged unless the enemy actually knows a +-- move off the matching list, and again once the player is at or below half; a +-- coin flip encourages it otherwise. +local function weatherMove(ctx, st, moves) + if not st.enemyMoveIds then return 0 end + local hasOne = false + for _, id in ipairs(moves) do + if st.enemyMoveIds[id] then hasOne = true break end + end + if not hasOne then return BAD_WEATHER end + if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return BAD_WEATHER end + if chance(ctx.random, 50) then return 0 end + return -1 +end + +-- data/battle/ai/rain_dance_moves.asm, in list order. +local RAIN_DANCE_MOVES = { + "WATER_GUN", "HYDRO_PUMP", "SURF", "BUBBLEBEAM", "THUNDER", "WATERFALL", + "CLAMP", "BUBBLE", "CRABHAMMER", "OCTAZOOKA", "WHIRLPOOL", +} + +-- Rain Dance (AI_Smart_RainDance): greatly discouraged against a Water-type (it +-- would hand the player the boost), taken eagerly against a Fire-type, and +-- otherwise only worth it when the enemy has something on RainDanceMoves to +-- spend the weather on. +S.EFFECT_RAIN_DANCE = function(ctx, st) + local verdict = weatherTypeVerdict(st.playerTypes, "WATER", "FIRE") + if verdict == "bad" then return BAD_WEATHER end + if verdict == "good" then return goodWeatherType(ctx, st) end + return weatherMove(ctx, st, RAIN_DANCE_MOVES) +end + +-- data/battle/ai/sunny_day_moves.asm, in list order. CART BUG, kept: the list +-- leaves out SOLARBEAM, FLAME_WHEEL and MOONLIGHT, so the AI never encourages +-- Sunny Day for the three moves that want it most +-- (docs/bugs_and_glitches.md). +local SUNNY_DAY_MOVES = { + "FIRE_PUNCH", "EMBER", "FLAMETHROWER", "FIRE_SPIN", "FIRE_BLAST", + "SACRED_FIRE", "MORNING_SUN", "SYNTHESIS", +} + +-- Sunny Day (AI_Smart_SunnyDay): the mirror of Rain Dance, Fire-type bad and +-- Water-type good, sharing AI_Smart_WeatherMove by fallthrough in the cart. +S.EFFECT_SUNNY_DAY = function(ctx, st) + local verdict = weatherTypeVerdict(st.playerTypes, "FIRE", "WATER") + if verdict == "bad" then return BAD_WEATHER end + if verdict == "good" then return goodWeatherType(ctx, st) end + return weatherMove(ctx, st, SUNNY_DAY_MOVES) +end + +-- Psych Up copies the player's stat levels, so it is only worth it when the +-- player is the one who has been setting up: AI_Smart_PsychUp sums both sides +-- and discourages when the enemy is already ahead. +-- Two cart quirks, both kept. The sums walk NUM_LEVEL_STATS = 8 entries, one +-- past EVASION into the ABILITY slot BattleCommand_Curse uses; both sides read +-- the same slot, so the comparison is unchanged and the Lua sums the seven real +-- stages. And the encouraging tail asks for a player evasion that is both at +-- least +2 and below +1, so its 80% dec [hl] is dead code the cart itself +-- flags: this handler can only ever return +1 or 0. +S.EFFECT_PSYCH_UP = function(ctx, st) + local mine, theirs = st.stages, st.playerStages + if not (mine and theirs) then return 0 end + local enemySum, playerSum = 0, 0 + for _, key in ipairs(STAGE_KEYS) do + enemySum = enemySum + (mine[key] or 0) + playerSum = playerSum + (theirs[key] or 0) + end + if enemySum >= playerSum then return 1 end + if (theirs.accuracy or 0) < -1 then return 0 end + if (theirs.evasion or 0) < 2 then return 0 end + -- Never reached: evasion cannot be at least +2 and below +1 at once. + if (theirs.evasion or 0) >= 1 then return 0 end + if chance(ctx.random, 20) then return 0 end + return -1 +end + +-- Mirror Coat answers special damage, so AI_Smart_MirrorCoat counts how many of +-- the moves the player has ACTUALLY used are special AND do damage. Three or +-- more is enough on its own; one or two only counts when the player's last move +-- was special damage too; none at all is discouraged outright. This is +-- AI_Smart_Counter's routine with `jr c` and `jr nc` swapped on the type test. +S.EFFECT_MIRROR_COAT = function(ctx, st, _, _, playerLastPower) + local special = st.playerSpecialMoves + if special == nil then return 0 end + if special == 0 then return 1 end + local encourage = special >= 3 + if not encourage then + encourage = st.playerLastMove ~= nil and (playerLastPower or 0) > 0 + and st.playerLastMoveSpecial == true + end + if not encourage then return 0 end + if chance(ctx.random, 39) then return 0 end + return -1 +end + +-- Twister and Gust share one body (AI_Smart_Twister falls straight into +-- AI_Smart_Gust): both hit a target that is up in the air, so the cart only +-- looks at them when the player's last move was Fly. Already flying and slower +-- than the enemy is a free double hit; still on the ground is a coin flip on +-- predicting the Fly, and only from behind, since going second is what lands +-- the hit. This reads SUBSTATUS_FLYING specifically, not the FLYING|UNDERGROUND +-- mask st.playerFlying carries. +local function smartGust(ctx, st) + if st.playerLastMove ~= "FLY" then return 0 end + if st.playerFlyingUp then + if not st.enemyFaster then return 0 end + return -2 + end + -- .couldFly: try to predict the Fly this turn. + if st.enemyFaster then return 0 end + if chance(ctx.random, 50) then return 0 end + return -1 +end +S.EFFECT_TWISTER = smartGust + +-- AI_Smart_Earthquake is the label AI_Smart_Magnitude sits on. +S.EFFECT_EARTHQUAKE = smartEarthquake + +-- Future Sight (AI_Smart_FutureSight) lands a turn late, which is exactly when +-- a player who is flying or underground comes back down. The cart checks the +-- speed first here and the substatus first in AI_Smart_Fly; same answer either +-- way, and both read the combined FLYING|UNDERGROUND mask. +S.EFFECT_FUTURE_SIGHT = function(_, st) + if not st.enemyFaster then return 0 end + if not st.playerFlying then return 0 end + return -2 +end + +-- AI_Smart_Gust shares AI_Smart_Twister's body; see smartGust above. +S.EFFECT_GUST = smartGust + +-- Stomp (AI_Smart_Stomp) doubles against a minimized target, so an 80% +-- encourage once the player has used Minimize at all. +S.EFFECT_STOMP = function(ctx, st) + if not st.playerMinimized then return 0 end + if chance(ctx.random, 20) then return 0 end + return -1 +end + +-- SolarBeam (AI_Smart_Solarbeam) skips its charge turn in sun and is halved in +-- rain, and the cart scores exactly that: 80% to greatly encourage while the +-- sun is out, 90% to greatly discourage while it is raining, no opinion in +-- anything else. +S.EFFECT_SOLARBEAM = function(ctx, st) + if st.weather == "sun" then + if chance(ctx.random, 20) then return 0 end + return -2 + end + if st.weather ~= "rain" then return 0 end + if chance(ctx.random, 10) then return 0 end + return 2 +end + +-- Thunder (AI_Smart_Thunder) drops to 50% accuracy in sun, so a 90% chance to +-- discourage it while the sun is out. The cart scores nothing at all for rain, +-- even though Thunder never misses then: that asymmetry is the routine as +-- written. +S.EFFECT_THUNDER = function(ctx, st) + if st.weather ~= "sun" then return 0 end + if chance(ctx.random, 10) then return 0 end + return 1 +end + +-- Fly and Dig, both EFFECT_FLY (AI_Smart_Fly): a semi-invulnerable player is +-- about to come back down, so a FASTER enemy can start its own two-turn move +-- now and land it as the player reappears. Three dec [hl], the layer's +-- strongest push. AICompareSpeed returns carry when the ENEMY is faster and +-- the routine is `ret nc`, which reads backwards against the cart's own comment. +S.EFFECT_FLY = function(_, st) + if not st.playerFlying then return 0 end + if not st.enemyFaster then return 0 end + return -3 +end + +-------------------------------------------------------------------------- +-- The switch / item layer (engine/battle/ai/items.asm, switch.asm) +-------------------------------------------------------------------------- +-- +-- TRNATTR_AI_ITEM_SWITCH is bytes 6-7 of the class attributes. Three flags +-- decide how eager the class is to rotate; CheckAbleToSwitch scores the bench +-- and the flag turns that score into a probability. + +Ai.SWITCH_FLAGS = { + OFTEN = 0x0001, + RARELY = 0x0002, + SOMETIMES = 0x0004, +} + +function Ai.switchFlagsOf(attributes) + if type(attributes) ~= "table" then return 0 end + return (attributes[6] or 0) + (attributes[7] or 0) * 256 +end + +-- CheckAbleToSwitch's answer, as its two nybbles: the high one is how strongly +-- the AI wants to rotate ($10 / $20 / $30) and the low one is the party slot. +-- Perish Song at one turn left is the maximum; otherwise it is whether the +-- player's moves beat what is out and something on the bench does better. +-- +-- state: +-- bench array of { index, mon, resists, superEffective, healthy } +-- perishCount the active mon's perish counter, or nil +-- matchupScore CheckPlayerMoveTypeMatchups' score (10 is neutral) +function Ai.switchScore(state) + state = state or {} + local candidates = {} + for _, entry in ipairs(state.bench or {}) do + if entry.healthy then candidates[#candidates + 1] = entry end + end + if #candidates == 0 then return 0, nil end + if state.perishCount == 1 then + return 0x30, candidates[1].index + end + -- Below BASE_AI_SWITCH_SCORE means the player's moves are winning. + if (state.matchupScore or 10) >= 10 then return 0, nil end + local best, weight + for _, entry in ipairs(candidates) do + if entry.resists and entry.superEffective then + best, weight = entry.index, 0x20 + break + elseif entry.resists and not best then + best, weight = entry.index, 0x10 + end + end + if not best then return 0, nil end + return weight, best +end + +-- The flag turns the score into a roll. These are the cart's own percentages +-- (SwitchOften / SwitchRarely / SwitchSometimes), and a score of $30 inverts +-- the test: the AI switches UNLESS the roll comes up. +Ai.SWITCH_CHANCES = { + OFTEN = { [0x10] = 50, [0x20] = 79, [0x30] = 96 }, + SOMETIMES = { [0x10] = 20, [0x20] = 50, [0x30] = 80 }, + RARELY = { [0x10] = 8, [0x20] = 12, [0x30] = 21 }, +} + +function Ai.shouldSwitch(attributes, score, random) + if score == 0 then return false end + local flags = Ai.switchFlagsOf(attributes) + local name + for key, mask in pairs(Ai.SWITCH_FLAGS) do + if math.floor(flags / mask) % 2 == 1 then name = key break end + end + if not name then return false end + local percent = (Ai.SWITCH_CHANCES[name] or {})[score] + if not percent then return false end + return ((random and random(100) or 0) + 1) <= percent +end + +-- AI_TryItem's table, in the order the cart walks it: the first item the +-- trainer holds whose condition is met is the one used. A trainer only uses +-- an item at all when its active mon is its highest-level one (.IsHighestLevel). +Ai.ITEM_ORDER = { + "FULL_RESTORE", "MAX_POTION", "HYPER_POTION", "SUPER_POTION", "POTION", + "X_ACCURACY", "FULL_HEAL", "GUARD_SPEC", "DIRE_HIT", "X_ATTACK", + "X_DEFEND", "X_SPEED", "X_SPECIAL", +} + +Ai.HEAL_ITEMS = { + FULL_RESTORE = math.huge, MAX_POTION = math.huge, + HYPER_POTION = 200, SUPER_POTION = 50, POTION = 20, +} + +-- The healing items want the mon below half and missing at least what they +-- would restore; FULL_HEAL wants a status. Everything else is a stat booster +-- and is used on the first turn. +function Ai.chooseItem(state) + state = state or {} + local held = {} + for _, id in ipairs(state.items or {}) do held[id] = true end + if not state.isHighestLevel then return nil end + local hp, maxHp = state.hp or 0, state.maxHp or 1 + for _, id in ipairs(Ai.ITEM_ORDER) do + if held[id] then + local heal = Ai.HEAL_ITEMS[id] + if heal then + if hp * 2 <= maxHp and (maxHp - hp) >= math.min(heal, maxHp) / 2 then + return id + end + elseif id == "FULL_HEAL" then + if state.status then return id end + elseif (state.enemyTurns or 0) == 0 then + return id + end + end + end + return nil +end + +-------------------------------------------------------------------------- +-- The scoring layers, as ai_classes records +-------------------------------------------------------------------------- +-- +-- One record per AI_* pass of engine/battle/ai/scoring.asm, in the shape +-- src/mods/Schemas.lua's `ai_classes` registry validates. Same registry NAME +-- Gen 1 fills from src/battle/TrainerAI.lua, the same `kind = "layer"`, and +-- the same score signature fn(view, def, score) -> score -- Gen 1's LAYER_1.. +-- LAYER_3 and these ten are the same kind of thing, so they share the noun. +-- +-- The ids are the TRNATTR_AI_MOVE_WEIGHTS flag names, which is what makes them +-- addressable: `flag` names the bit in Ai.FLAGS that turns the layer on, so a +-- class with no bits set runs no layers at all -- AIChooseMove's own answer +-- when wEnemyTrainerAIFlags is zero. `flag` is the one field Gen 2 adds; a +-- mod's own layer may leave it out, and then it runs for every class that runs +-- any AI at all. +-- +-- `view` is the state a layer scores against, rebuilt per move by Ai.choose: +-- context / random / flags the caller's own +-- attacker / defender the two mons, types resolved +-- move / damage / damaging / status the move being scored +-- best the highest expected damage of the set +Ai.LAYERS = { + -- AI_Basic: never throw a status move at a target that already has one, and + -- never use a move whose only effect has already landed. The confusion + -- moves read SUBSTATUS_CONFUSED (defender.confused), not the status byte, + -- since confusion is a volatile on the cart. + BASIC = { kind = "layer", flag = "BASIC", + score = function(view, _, score) + local status, defender = view.status, view.defender + if status and (defender.status + or (status == "confuse" and defender.confused)) then + return score + 5 + end + return score + end }, + -- AI_Types: dismiss what the target is immune to, encourage super-effective, + -- discourage not very effective. + TYPES = { kind = "layer", flag = "TYPES", + score = function(view, def, score) + if not view.damaging then return score end + local matchup = matchupOf(view.context, def, view.defender) + if matchup == 0 then return score + 10 end + if matchup > 10 then return score - 1 end + if matchup < 10 then return score + 1 end + return score + end }, + -- AI_Offensive: discourage anything that does not do damage. + OFFENSIVE = { kind = "layer", flag = "OFFENSIVE", + score = function(view, _, score) + if view.damaging then return score end + return score + 1 + end }, + -- AI_Aggressive: encourage whichever move hits hardest and discourage every + -- other damaging one. + AGGRESSIVE = { kind = "layer", flag = "AGGRESSIVE", + score = function(view, _, score) + if not view.damaging then return score end + if view.damage >= view.best and view.best > 0 then return score - 1 end + return score + 1 + end }, + -- AI_Status: refuse a status move outright against a target that already has + -- that status. + STATUS = { kind = "layer", flag = "STATUS", + score = function(view, _, score) + if view.status and view.defender.status == view.status then + return score + 10 + end + return score + end }, + -- AI_Risky: take a kill when one is on the table, whatever else says. + RISKY = { kind = "layer", flag = "RISKY", + score = function(view, _, score) + if view.damage >= (view.defender.hp or 0) and view.damage > 0 then + return score - 5 + end + return score + end }, + -- AI_Setup: stat moves on turn one, and almost never after. + SETUP = { kind = "layer", flag = "SETUP", + score = function(view, def, score) + local context, random = view.context, view.random + local up = Ai.STAT_UP_EFFECTS[def.effect] + local down = Ai.STAT_DOWN_EFFECTS[def.effect] + if up then + if (context.enemyTurns or 0) == 0 and random(2) == 0 then + return score - 2 + end + return score + 2 + elseif down then + if (context.playerTurns or 0) == 0 and random(2) == 0 then + return score - 2 + end + return score + 2 + end + return score + end }, + -- AI_Opportunist: no stalling when the enemy is nearly dead. + OPPORTUNIST = { kind = "layer", flag = "OPPORTUNIST", + score = function(view, def, score) + if not Ai.STALL_EFFECTS[def.effect] then return score end + local hp, maxHp = view.context.enemyHp, view.context.enemyMaxHp + if not (hp and maxHp and hp * 2 <= maxHp) then return score end + local low = hp * 4 <= maxHp + if low or view.random(2) == 0 then return score + 1 end + return score + end }, + -- AI_Cautious: 90% chance to drop a residual move after turn one. + CAUTIOUS = { kind = "layer", flag = "CAUTIOUS", + score = function(view, def, score) + if (view.context.enemyTurns or 0) <= 0 then return score end + if not Ai.RESIDUAL_EFFECTS[def.effect] then return score end + if view.random(100) < 90 then return score + 1 end + return score + end }, + -- AI_Smart: the per-effect layer. Ai.lockOnPostPass is its one pass that + -- edits somebody else's slot, so Ai.choose runs that after the move loop. + SMART = { kind = "layer", flag = "SMART", + score = function(view, def, score) + local handler = Ai.SMART[def.effect] + if not handler then return score end + local context, random = view.context, view.random + local state = context.smart or {} + state.random = state.random or random + -- AI_Smart_SpeedDownHit is the one handler that reads the move it is + -- scoring (wEnemyMoveStruct + MOVE_ANIM), so the id rides along. + local delta = handler({ random = random, moveId = view.move.id }, state, + matchupOf(context, def, view.defender), view.damage, + context.playerLastPower) + return score + (delta or 0) + end }, +} + +-- scoring.asm's own order. It is load bearing: SETUP, OPPORTUNIST and +-- CAUTIOUS each roll, so a reordering changes which move gets which byte. +Ai.LAYER_ORDER = { + "BASIC", "TYPES", "OFFENSIVE", "AGGRESSIVE", "STATUS", "RISKY", + "SETUP", "OPPORTUNIST", "CAUTIOUS", "SMART", +} + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function Ai.registerInto(registry, _, owner) + for id, record in pairs(Ai.LAYERS) do + registry:register(id, record, owner) + end +end + +-- the merged `ai_classes` record for an id, the module's own when no loader ran +function Ai.classFor(data, id) + if id == nil then return nil end + local merged = data and data.gen2AiClasses + return (merged and merged[id]) or Ai.LAYERS[id] +end + +-- The ordered layer list for one AI word: the vanilla ten in scoring.asm order +-- first, each resolved through the registry so a mod's patch of AI_Smart is the +-- one that runs, then any layer a mod registered under a new id, in sorted id +-- order so the roll sequence is the same on every boot. A layer's `flag` gates +-- it on the class's bits; a mod layer without one runs for every class that +-- runs any AI at all, which is the only honest default when the ten flag bits +-- are all spoken for. +function Ai.layersFor(data, flags) + local out, seen = {}, {} + for _, id in ipairs(Ai.LAYER_ORDER) do + seen[id] = true + if Ai.has(flags, id) then + local record = Ai.classFor(data, id) + if record and record.score then out[#out + 1] = record end + end + end + local merged = data and data.gen2AiClasses + if merged then + local extra = {} + for id, record in pairs(merged) do + if not seen[id] and record and record.score + and (record.kind == nil or record.kind == "layer") + and (record.flag == nil or Ai.has(flags, record.flag)) then + extra[#extra + 1] = id + end + end + table.sort(extra) + for _, id in ipairs(extra) do out[#out + 1] = merged[id] end + end + return out +end + +-- context: +-- moves array of { id, pp } the enemy may use +-- moveDef(id) the move record +-- attacker the enemy mon, with .types resolved +-- defender the player's mon, with .types resolved +-- typeChart { types, matchups } +-- attackerStages / defenderStages +-- flags the class's AI word +-- random(n) 0..n-1 +-- +-- Returns the chosen move id, plus the score table for the tests to read. +function Ai.choose(context) + local moves = {} + for _, move in ipairs(context.moves or {}) do + if (move.pp or 0) > 0 then moves[#moves + 1] = move end + end + if #moves == 0 then return nil, {} end + + local random = context.random or function(n) return 0 end + local flags = context.flags or 0 + if flags == 0 then + -- No AI: pick at random, the way a wild mon does. + return moves[random(#moves) + 1].id, {} + end + + local attacker, defender = context.attacker or {}, context.defender or {} + local scores, damages, defs = {}, {}, {} + local best = -1 + for i, move in ipairs(moves) do + scores[i] = Ai.BASE_SCORE + local def = context.moveDef and context.moveDef(move.id) or nil + defs[i] = def + damages[i] = def and expectedDamage(context, attacker, defender, def) or 0 + if damages[i] > best then best = damages[i] end + end + + -- The scoring layers this class runs, resolved once: which records exist is + -- a property of the boot, not of the move being scored, and the per-move + -- loop below is hot. + local layers = Ai.layersFor(context.data, flags) + + local view = { + context = context, random = random, flags = flags, + attacker = attacker, defender = defender, best = best, + } + for i, move in ipairs(moves) do + local def = defs[i] + if def then + -- the per-move half of the view, rebuilt in place so ten layers share + -- one table rather than allocating ten + view.move = move + view.damage = damages[i] + view.damaging = (def.power or 0) > 0 + view.status = Ai.STATUS_EFFECTS[def.effect] + for _, record in ipairs(layers) do + scores[i] = record.score(view, def, scores[i]) or scores[i] + end + end + end + + -- The one scoring layer that edits somebody else's slot, so it cannot run + -- inside the per-move pass above. + if Ai.has(flags, "SMART") and (context.smart or {}).playerLockOn then + Ai.lockOnPostPass(scores, defs) + end + + -- Lowest score wins; ties are broken by a roll so a trainer is not perfectly + -- predictable turn to turn. + local lowest = math.huge + for _, score in ipairs(scores) do + if score < lowest then lowest = score end + end + local tied = {} + for i, score in ipairs(scores) do + if score == lowest then tied[#tied + 1] = moves[i].id end + end + return tied[random(#tied) + 1], scores +end + +return Ai diff --git a/src/battle/gen2/AnimObjects.lua b/src/battle/gen2/AnimObjects.lua new file mode 100644 index 00000000..4e46d31d --- /dev/null +++ b/src/battle/gen2/AnimObjects.lua @@ -0,0 +1,1999 @@ +-- The Gen 2 battle-animation OBJECT runtime. +-- +-- pokegold engine/battle_anims/core.asm (the struct pool and the OAM writer), +-- engine/battle_anims/helpers.asm (GetBattleAnimFrame) and +-- engine/battle_anims/functions.asm (the 80 per-frame functions). The DATA +-- these run on -- objects, framesets, OAM sets, GFX sheets -- is extracted +-- into `data/generated/battle_anims.lua`; nothing here is transcribed by hand. +-- +-- Ten structs, and one of them is picked by QueueBattleAnimation the same way +-- _InitSpriteAnimStruct picks a sprite-anim slot: the first with INDEX == 0. +-- Every field is a byte and wraps, and several functions rely on that: an +-- object walks its Y offset down past 0 into $ff.. and reads the result back +-- as a negative pixel offset. +-- +-- Coordinates are hardware OAM coordinates: an OBJ at struct (x, y) draws at +-- (x - 8, y - 16) on the 160x144 screen. `xOffset`/`yOffset` are signed bytes +-- added on top, which is what every sine-driven function writes. +-- +-- Two traps this file exists to get right: +-- +-- * ASM fallthrough is not the same as a jumptable branch. A dozen of these +-- functions end their `.zero` case by dropping straight into `.one` on the +-- SAME frame, so an object that only ran its init would be a frame late for +-- the rest of its life. Every one of those is written out here. +-- * `ld a, [hl]` followed by `inc [hl]` leaves `a` holding the value from +-- BEFORE the increment. Dizzy's frameset flip and Perish Song's descent +-- both read the pre-increment value, and using the new one desynchronises +-- the animation from its own frameset. +-- +-- Love-free on purpose: src/ui/gen2/BattleAnimView.lua turns the OAM list into +-- draw calls, and the tests step whole animations with no window at all. + +local bit = require("bit") +-- The sine table is engine/math/sine.asm's `sine_table 32`, shared with the +-- overworld/intro sprite anims: BattleAnim_Sine is `calc_sine_wave +-- BattleAnimSineWave`, the same macro over the same 32-entry quarter wave. +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local AnimObjects = {} + +local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS +-- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full +-- and BattleAnim_UpdateOAM_All stops walking the structs. +local OAM_LIMIT = 40 + +local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20 +local OAM_FLAG_MASK = 0xe0 +local OAM_PAL1 = 0x10 +-- BATTLEANIMSTRUCT_OAMFLAGS_FIX_COORDS_F: bit 0 of the object's flags byte +-- means "mirror this object onto the other battler's side when the enemy is +-- the one attacking". +local FIX_COORDS = 0x01 + +-------------------------------------------------------------------------- +-- Byte arithmetic +-------------------------------------------------------------------------- + +local function u8(value) return value % 256 end + +-- The byte read as a signed value, which is what every `bit 7, a` test and +-- every coordinate add is really doing. +local function s8(value) + value = value % 256 + return value < 0x80 and value or value - 256 +end + +-- `sra a`: arithmetic shift right, sign preserved. +local function sra(value) return u8(math.floor(s8(value) / 2)) end + +-- `swap a` +local function swap(value) + value = u8(value) + return bit.bor(bit.rshift(value, 4), bit.band(bit.lshift(value, 4), 0xf0)) +end + +-- `rlca` +local function rlca(value) + value = u8(value) + return u8(bit.lshift(value, 1) + bit.rshift(value, 7)) +end + +local sine = SpriteAnims.sine +local cosine = SpriteAnims.cosine + +-------------------------------------------------------------------------- +-- The struct pool +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newStruct() + return { + index = 0, oamFlags = 0, fixY = 0, framesetId = 0, func = 0, palette = 0, + tileId = 0, x = 0, y = 0, xOffset = 0, yOffset = 0, param = 0, + duration = 0, frame = 0xff, jt = 0, var1 = 0, var2 = 0, + } +end + +-- `data` is the cache's battle_anims.lua table; `constants` the cache's +-- constants.lua (for the ordered name lists an id indexes into). +-- +-- `env` is what the battle screen owns and this pool only reads: +-- env.battleTurn hBattleTurn: 0 while the player is attacking +-- env.animId wFXAnimID, the move whose script is running (KINESIS, +-- SOFTBOILED and MILK_DRINK get their own Y nudge) +-- env.ballPalette the PAL_BATTLE_OB_* name GetBallAnimPal resolves for +-- wCurItem, or nil outside a ball throw +-- env.sgb hSGB, which only Sky Attack's palette cycle reads +function AnimObjects.new(data, constants, env) + local self = setmetatable({}, Pool) + self.data = data or {} + self.env = env or {} + self.structs = {} + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 -- wLastAnimObjectIndex + self.oam = {} + + constants = constants or {} + self.objectOrder = constants.battleAnimObjectOrder or {} + self.framesetOrder = constants.battleAnimFramesetOrder or {} + -- Name -> numeric id, because several functions do frameset ARITHMETIC + -- (`ld a, BATTLE_ANIM_FRAMESET_SOUND_1; add [hl]`) while the extractor + -- writes names. Keeping the struct's FRAMESET_ID numeric is what makes + -- those adds mean the same thing they do on the cart. + self.framesetIds = {} + for index, name in ipairs(self.framesetOrder) do + self.framesetIds[name] = index - 1 + end + -- Written by the two functions that reach past their own struct: Sky + -- Attack's OBP0 cycle and Surf's per-scanline window. The view reads them. + self.obp0 = nil + self.lyOverride = nil + return self +end + +function Pool:clear() + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 + self.oam = {} + self.obp0 = nil + self.lyOverride = nil +end + +-- BattleAnimCmd_ClearObjs. The cart's loop clears $a0 bytes from +-- wActiveAnimObjects and BATTLEANIMSTRUCT_LENGTH is $18, so it reaches six +-- whole structs plus the first sixteen bytes of the seventh -- enough to zero +-- that one's INDEX, and no further. Structs 8-10 keep running: that is the +-- documented bug (docs/bugs_and_glitches.md), and an animation that spawns +-- more than seven objects visibly depends on it. +function Pool:clearObjs() + for slot = 1, 7 do self.structs[slot] = newStruct() end +end + +function Pool:framesetId(name) + local id = self.framesetIds[name] + if not id then error("unknown battle anim frameset: " .. tostring(name)) end + return id +end + +-- ReinitBattleAnimFrameset: swap framesets and restart the frame walk. +local function reinit(st, framesetId) + st.framesetId = u8(framesetId) + st.duration = 0 + st.frame = 0xff -- `ld [hl], -1` +end + +local function deinit(st) st.index = 0 end + +-- InitBattleAnimation. The object row's six bytes land in the struct in +-- order; the seventh field, TILEID, comes from the tile dict instead +-- (GetBattleAnimTileOffset), because where a sheet ended up in VRAM is a +-- property of the running script and not of the object. +function Pool:queue(objectId, x, y, param, tileOffsetFor) + local name = objectId + if type(objectId) == "number" then + name = self.objectOrder[objectId + 1] or objectId + end + local object = (self.data.objects or {})[name] + if not object then return nil end + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == 0 then + self.lastIndex = u8(self.lastIndex + 1) + st.index = self.lastIndex + st.oamFlags = object.flags or 0 + st.fixY = object.fixY or 0 + st.framesetId = self.framesetIds[object.frameset] or 0 + st.func = object.func or 0 + st.palette = object.palette or 0 + st.tileId = tileOffsetFor and tileOffsetFor(object.gfx) or 0 + st.x, st.y = u8(x), u8(y) + st.xOffset, st.yOffset = 0, 0 + st.param = u8(param or 0) + st.duration = 0 + st.frame = 0xff + st.jt, st.var1, st.var2 = 0, 0, 0 + st.objectId = name + return st + end + end + -- QueueBattleAnimation returns carry when all ten are busy; the script does + -- not look, and neither does anything here. + return nil +end + +function Pool:findByIndex(value) + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == value then return st end + end + return nil +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_STRUCTS do + if self.structs[slot].index ~= 0 then count = count + 1 end + end + return count +end + +-------------------------------------------------------------------------- +-- GetBattleAnimFrame (engine/battle_anims/helpers.asm) +-------------------------------------------------------------------------- + +-- What a frameset row yields: the OAM set name, or the "wait" / "delete" +-- pseudo-commands BattleAnimOAMUpdate acts on, plus the frame's flip flags. +local function frameYield(row) + local kind = row[1] + if kind == "wait" then return "wait", 0 end + if kind == "delete" then return "delete", 0 end + return row[2], row[4] or 0 +end + +-- `oamwait n` is not skipped here: GetBattleAnimFrame stores n as the struct's +-- duration and hands the command itself back, so the struct genuinely spends +-- n frames drawing nothing. Only BattleAnimOAMUpdate knows what to do with it. +function Pool:getFrame(st) + local frames = (self.data.framesets or {})[self.framesetOrder[st.framesetId + 1]] + if not frames then return nil, 0 end + for _ = 1, 64 do + if st.duration ~= 0 then + st.duration = st.duration - 1 + local row = frames[st.frame + 1] + if not row then return nil, 0 end + return frameYield(row) + end + st.frame = u8(st.frame + 1) + local row = frames[st.frame + 1] + if not row then return nil, 0 end + local kind = row[1] + if kind == "restart" then + st.duration = 0 + st.frame = 0xff + elseif kind == "end" then + -- Step back two so the next pass lands on the frame before this one and + -- then holds it forever. + st.duration = 0 + st.frame = u8(st.frame - 2) + elseif kind == "delete" then + -- `oamdelete` carries no argument; the cart reads the next byte as a + -- duration anyway and then throws the whole struct away, so it does not + -- matter what lands here. + st.duration = 0 + return "delete", 0 + elseif kind == "wait" then + st.duration = u8(row[2]) + return "wait", 0 + else + st.duration = u8(row[3]) + return frameYield(row) + end + end + error("battle anim frameset never yields a frame: " + .. tostring(self.framesetOrder[st.framesetId + 1])) +end + +-------------------------------------------------------------------------- +-- BattleAnimOAMUpdate (engine/battle_anims/core.asm) +-------------------------------------------------------------------------- + +-- AddOrSubtractY / AddOrSubtractX: a flipped entry mirrors around its own +-- 8-pixel cell, which is `-(offset + 8)`. +local function mirror(value, flip) + if not flip then return value end + return u8(-(u8(value) + 8)) +end + +-- InitBattleAnimBuffer. On the enemy's turn the whole object is reflected +-- onto the other side of the field -- but only if its OAMFLAGS ask for it. +function Pool:initBuffer(st) + local buf = { + oamFlags = bit.band(st.oamFlags, OAM_PRIO), + palette = st.palette, + tileId = st.tileId, + x = st.x, y = st.y, + xOffset = st.xOffset, yOffset = st.yOffset, + } + if (self.env.battleTurn or 0) == 0 then return buf end + buf.oamFlags = st.oamFlags + if bit.band(st.oamFlags, FIX_COORDS) == 0 then return buf end + -- x' = (-10 tiles + 4) - x: reflected about the middle of the field. + buf.x = u8((-10 * 8 + 4) - st.x) + if st.fixY == 0xff then + buf.y = u8(5 * 8 + st.y) + else + local y = u8(st.fixY - st.y) + local animId = self.env.animId + -- The three self-targeting animations whose object sits one tile higher + -- on the enemy's side. + if animId == "KINESIS" or animId == "SOFTBOILED" or animId == "MILK_DRINK" then + y = u8(y - 8) + end + buf.y = y + end + buf.xOffset = u8(-st.xOffset) + return buf +end + +-- One struct's OAM entries appended to self.oam. Returns true once the +-- 40-object shadow OAM is full, which is the carry the caller stops on. +function Pool:updateOam(st) + local buf = self:initBuffer(st) + local oamsetName, frameFlags = self:getFrame(st) + if oamsetName == "wait" or oamsetName == nil then return false end + if oamsetName == "delete" then + deinit(st) + return false + end + buf.oamFlags = bit.band(bit.bxor(frameFlags, buf.oamFlags), OAM_FLAG_MASK) + local set = (self.data.oamsets or {})[oamsetName] + if not set then return false end + local tileId = u8(buf.tileId + (set.vtile or 0)) + local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0 + local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0 + for _, entry in ipairs(set.sprites or {}) do + if #self.oam >= OAM_LIMIT then return true end + -- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's; + -- OAM_PAL1 passes through from the entry, and the palette slot comes from + -- the struct. + local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK) + attr = attr + bit.band(entry.attr or 0, OAM_PAL1) + self.oam[#self.oam + 1] = { + y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)), + x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)), + -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by + -- every sheet lookup, so the port keeps tiles in sheet-relative space. + tile = u8(tileId + (entry.tile or 0)), + attr = attr, + palette = buf.palette, + } + end + return false +end + +-- BattleAnim_UpdateOAM_All: run every live struct's function, then let it +-- write its OAM entries. A struct that deinitialises itself inside its +-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate +-- unconditionally. +function Pool:playFrame() + self.oam = {} + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index ~= 0 then + local fn = AnimObjects.FUNCTIONS[st.func] + if fn then fn(self, st) end + if self:updateOam(st) then break end + end + end + return self.oam +end + +-------------------------------------------------------------------------- +-- engine/battle_anims/functions.asm +-------------------------------------------------------------------------- + +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BattleAnim_StepToTarget: inches the object toward the opponent's side, half +-- as far vertically as horizontally. The `dec [hl]` loop runs BEFORE `dec e` +-- is tested, so a vertical step of 0 walks the Y coordinate 256 times -- right +-- back where it started, which is the point. +local function stepToTarget(st, speed) + local e = bit.band(speed, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) +end + +-- BattleAnim_StepCircle: circular movement whose height is a quarter of its +-- width. +local function stepCircle(st, angle, radius) + st.yOffset = sra(sra(sine(angle, radius))) + st.xOffset = cosine(angle, radius) +end + +-- A 16-bit accumulator spread over two byte fields, which is how every +-- sub-pixel movement here is done: the HIGH byte is the pixel coordinate and +-- the LOW byte the fraction. +local function add16(high, low, delta) + local value = (u8(high) * 256 + u8(low) + delta) % 0x10000 + return math.floor(value / 256), value % 256 +end + +local F = {} + +F.BATTLE_ANIM_FUNC_NULL = function(_, st) + -- anim_incobj is what walks this one to `.one`, which deletes the object. + if st.jt ~= 0 then deinit(st) end +end + +-- BattleAnimFunction_ThrowFromUserToTarget: right 2 and up 1 a frame, with the +-- object's PARAM as the amplitude of a sine on the Y offset. Returns true for +-- "still going", which is the carry the AndDisappear wrapper reads. +local function throwToTarget(st) + if st.x >= 0x88 then return false end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, st.param) + return true +end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET = function(_, st) throwToTarget(st) end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR = function(_, st) + if not throwToTarget(st) then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_WAVE_TO_TARGET = function(_, st) + if st.x >= 0x88 then + deinit(st) + return + end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 4) + st.yOffset = sine(angle, 0x10) + -- The X offset is the cosine divided by sixteen, so the wave is much + -- flatter across than it is up. + st.xOffset = sra(sra(sra(sra(cosine(angle, 0x10))))) +end + +F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE = function(_, st) + if st.jt == 0 then + incJt(st) + -- Bit 7 of PARAM starts the object half a turn round; the rest is the + -- radius, so the flag has to come off before it is used as one. + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x20 or 0 + st.param = bit.band(st.param, 0x7f) + end + local angle = st.var1 + st.yOffset = sine(angle, st.param) + st.xOffset = cosine(angle, st.param) + st.var1 = u8(st.var1 + 1) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET = function(_, st) + if st.jt ~= 0 then + deinit(st) + return + end + if st.x >= 0x84 then return end + stepToTarget(st, st.param) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR = function(_, st) + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) +end + +-- GetBallAnimPal: the thrown ball wears the colour of the ball being thrown +-- (data/battle_anims/ball_colors.asm). The battle screen resolves that for +-- wCurItem and hands it over as env.ballPalette. +local function ballPal(self, st) + if self.env.ballPalette then st.palette = self.env.ballPalette end +end + +-- .four: the ball bounces on a shrinking sine while VAR2 steps down by four; +-- when it reaches zero the ball opens. +local function pokeballBounce(self, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- `ld [hl], a` after the mask: VAR1 is zeroed, not just left on a boundary, + -- so every bounce starts from the same phase. + st.var1 = 0 + st.var2 = u8(st.var2 - 4) + if st.var2 ~= 0 then return end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_4")) + incJt(st) +end + +-- .eight/.ten: the same sine, but every $10 steps the jumptable advances, so +-- the caught / broke-free branch is only ever reached on a shake boundary. +local function pokeballWobble(_, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) == 0 then + deinit(st) + return + end + if bit.band(st.var1, 0xf) ~= 0 then return end + incJt(st) +end + +F.BATTLE_ANIM_FUNC_POKEBALL = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if throwToTarget(st) then return end + -- The arc's Y offset is folded into the coordinate before the ball + -- switches to its opening frameset, so it lands where it fell. + st.y = u8(st.y + st.yOffset) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_3")) + incJt(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_1")) + st.var1, st.var2 = 0, 0x10 + pokeballBounce(self, st) -- .three falls into .four + elseif jt == 4 then + pokeballBounce(self, st) + elseif jt == 6 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_5")) + st.jt = u8(st.jt - 1) + elseif jt == 7 then + ballPal(self, st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_2")) + incJt(st) + st.var2 = 0x20 + pokeballWobble(self, st) -- .seven falls into .eight + elseif jt == 8 or jt == 10 then + pokeballWobble(self, st) + elseif jt == 11 then + deinit(st) + end +end + +local function pokeballBlockedFall(_, st) + if st.y >= 0x80 then + deinit(st) + return + end + st.y = u8(st.y + 4) + st.x = u8(st.x - 2) +end + +F.BATTLE_ANIM_FUNC_POKEBALL_BLOCKED = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if st.x < 0x70 then + throwToTarget(st) + return + end + incJt(st) + pokeballBlockedFall(self, st) -- .next falls into .two + elseif jt == 2 then + pokeballBlockedFall(self, st) + end +end + +F.BATTLE_ANIM_FUNC_EMBER = function(self, st) + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks which branch this object runs. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + if st.x >= 0x88 then return end + stepToTarget(st, st.param) + elseif jt == 2 then + deinit(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_FLAMETHROWER")) + end +end + +F.BATTLE_ANIM_FUNC_DROP = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x30, 0x48 + end + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + -- Each bounce loses PARAM off the amplitude; once that would go to zero or + -- below, the object is done. + local left = st.var2 - st.param + if left <= 0 then + deinit(st) + return + end + st.var2 = left +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN = function(_, st) + -- .SetCoords: the lower nybble of PARAM is the horizontal step, half of it + -- the vertical one. + local function setCoords() + local e = bit.band(st.param, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) + end + -- .two: a circle whose top is flattened -- the cosine is pulled down by its + -- own radius and halved. + local function orbit() + if st.var1 < 0x40 then + st.yOffset = sra(u8(cosine(st.var1, 0x18) - 0x18)) + st.xOffset = sine(st.var1, 0x18) + st.var1 = u8(st.var1 + bit.band(st.param, 0xf)) + return + end + -- .loop_back: the upper nybble is a lap counter. + local laps = bit.band(st.param, 0xf0) + if laps == 0 then + incJt(st) -- .finish falls into .three + if st.x >= 0xb0 then + deinit(st) + else + setCoords() + end + return + end + st.param = bit.band(st.param, 0xf) + (laps - 0x10) + st.jt = u8(st.jt - 1) + end + local jt = st.jt + if jt == 0 then + if st.x < 0x80 then + setCoords() + return + end + -- .next -> .one -> .two, all on this frame. + incJt(st) + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 1 then + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 2 then + orbit() + elseif jt == 3 then + if st.x >= 0xb0 then + deinit(st) + return + end + setCoords() + end +end + +F.BATTLE_ANIM_FUNC_SHAKE = function(_, st) + -- .done_one: hold for the upper nybble of PARAM, then jump to the other side. + local function flip() + st.var1 = bit.band(swap(st.param), 0xf) + st.xOffset = u8(-st.xOffset) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0 + st.xOffset = bit.band(st.param, 0xf) + flip() -- .zero falls into .one, and VAR1 is 0, so .done_one runs at once + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + flip() + elseif jt == 2 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FIRE_BLAST = function(self, st) + -- .eight: the travelling flame spirals once it arrives. + local function spin() + local angle = st.var1 + st.yOffset = sine(angle, 0x10) + st.xOffset = cosine(angle, 0x10) + st.var1 = u8(st.var1 + 1) + end + -- .seven: straight across, then hand over to the spiral. + local function travel() + if st.x < 0x88 then + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EMBER")) + spin() -- .set_up_eight falls into .eight + end + local jt = st.jt + if jt == 0 then + -- PARAM picks the branch outright: 7 is the flame that travels, and the + -- rest are the five arms of the blast, which only drift. + st.jt = st.param + if st.param == 7 then + travel() + else + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_BURNED")) + end + elseif jt == 1 then + st.yOffset = u8(st.yOffset - 1) + elseif jt == 2 then + st.xOffset = u8(st.xOffset - 1) + elseif jt == 3 then + st.xOffset = u8(st.xOffset + 1) + elseif jt == 4 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset - 1) + elseif jt == 5 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset + 1) + elseif jt == 7 then + travel() + elseif jt == 8 then + spin() + elseif jt == 9 then + deinit(st) + end +end + +-- BattleAnim_ScatterHorizontal: a 16-bit per-frame X step picked from the +-- object's PARAM, so a screenful of leaves fans out instead of moving as one. +local function scatterHorizontal(st) + local param = st.param + if bit.band(param, 0x80) == 0 then + if param >= 0x20 then return 0x100 end + if param >= 0x18 then return 0x180 end + return 0x200 + end + local masked = bit.band(param, 0x3f) + if masked >= 0x20 then return -0x100 end + if masked >= 0x18 then return -0x180 end + return -0x200 +end + +F.BATTLE_ANIM_FUNC_RAZOR_LEAF = function(self, st) + local function arcStep() + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) + end + local function arcOrLand() + if st.var1 >= 0x30 then + arcStep() + return + end + incJt(st) + st.var1, st.var2 = 0, 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2")) + -- Bit 6 starts the second frameset six frames in, which is the leaf + -- already half-turned. + if bit.band(st.param, 0x40) ~= 0 then st.frame = 5 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x40 + arcOrLand() -- .zero falls into .one + elseif jt == 1 then + arcOrLand() + elseif jt == 2 then + if st.yOffset == 0x20 then + deinit(st) + return + end + st.xOffset = sine(st.var1, 0x10) + if bit.band(st.param, 0x40) ~= 0 then + st.var1 = u8(st.var1 - 1) + else + st.var1 = u8(st.var1 + 1) + end + st.yOffset, st.var2 = add16(st.yOffset, st.var2, 0x80) + elseif jt == 3 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1")) + st.oamFlags = bit.band(st.oamFlags, 0xff - OAM_XFLIP) + incJt(st) + elseif jt >= 4 and jt <= 7 then + incJt(st) + elseif jt == 8 then + if st.x >= 0xc0 then return end + stepToTarget(st, 8) + end +end + +F.BATTLE_ANIM_FUNC_ROCK_SMASH = function(self, st) + if st.jt == 0 then + -- Bit 6 picks between the two rock framesets. + st.framesetId = u8(rlca(rlca(bit.band(st.param, 0x40))) + + self:framesetId("BATTLE_ANIM_FRAMESET_BIG_ROCK")) + incJt(st) + st.var1 = 0x40 + end + if st.var1 < 0x30 then + deinit(st) + return + end + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) +end + +F.BATTLE_ANIM_FUNC_BUBBLE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + stepToTarget(st, st.param) + return + end + incJt(st) + st.var1 = 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PULSING_BUBBLE")) + return + end + if jt ~= 2 then return end + if st.x < 0x98 then + st.x, st.var1 = add16(st.x, st.var1, 0x60) + end + if st.y < 0x20 then return end + -- The upper nybble of PARAM is a per-frame rise; `ld d, $ff` is what makes + -- it a NEGATIVE 16-bit step. + st.y, st.var2 = add16(st.y, st.var2, bit.band(st.param, 0xf0) - 0x100) +end + +-- BattleAnimFunction_Surf: the wave is a per-scanline SCY override, so the +-- port hands the view the same window the cart writes to hLYOverrideStart / +-- hLYOverrideEnd rather than moving anything itself. +F.BATTLE_ANIM_FUNC_SURF = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self.lyOverride = { register = "SCY", first = 0x58, last = 0x5e } + return + end + if jt == 1 then + if st.y < st.param then + incJt(st) + if self.lyOverride then self.lyOverride.first = 0 end + return + end + st.y = u8(st.y - 1) + st.yOffset = sine(st.var1, 0x10) + local top = st.yOffset + st.y - 0x10 + -- `ret c`: the wave stops climbing entirely on the frames the subtraction + -- underflows, offsets and all. + if top < 0 then return end + if self.lyOverride then self.lyOverride.first = u8(top) end + st.xOffset = bit.band(st.xOffset + 1, 7) + st.var1 = u8(st.var1 + 2) + return + end + if jt == 3 then + if st.y >= 0x70 then + self.lyOverride = nil + deinit(st) + return + end + st.y = u8(st.y + 2) + local top = st.y - 0x10 + if top < 0 then return end + if self.lyOverride then self.lyOverride.first = u8(top) end + return + end + if jt == 4 then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_SING = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.x >= 0xb8 then + deinit(st) + return + end + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_WATER_GUN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + jt = 1 + end + if jt == 1 then + if st.y >= 0x30 then + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_2")) + st.yOffset = 0 + st.y = 0x30 + -- Everything but FIX_COORDS is dropped, so the splash never flips. + st.oamFlags = bit.band(st.oamFlags, FIX_COORDS) + jt = 2 + end + if jt ~= 2 then return end + if st.yOffset >= 0x18 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_3")) + return + end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_POWDER = function(_, st) + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, 0x80) + -- Shakes sixteen pixels either side by toggling one bit. + st.xOffset = bit.bxor(st.xOffset, 0x10) +end + +F.BATTLE_ANIM_FUNC_RECOVER = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0xf0) -- radius + st.var1 = u8(bit.band(st.param, 0xf) * 8) -- starting angle + st.param = 1 -- reused as an every-other-frame toggle + end + if st.var2 == 0 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, st.var2) + st.xOffset = cosine(angle, st.var2) + st.param = bit.bxor(st.param, 1) + if st.param == 0 then return end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_THUNDER_WAVE = function(self, st) + if st.jt == 1 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA")) + elseif st.jt == 3 then + deinit(st) + end +end + +-- Clamp/Encore: two halves clap together, twice. The frameset the object +-- switches to is the base or the base + 1 (CLAMP_FLIPPED / ENCORE_HAND_ +-- FLIPPED), picked by the SIGN of the sine, so both halves close together. +F.BATTLE_ANIM_FUNC_CLAMP_ENCORE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.xOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 and st.var2 or u8(st.var2 + 1)) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) -- falls into .two, which is a bare IncAnonJumptableIndex + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = st.framesetId + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_BITE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.yOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 + and self:framesetId("BATTLE_ANIM_FRAMESET_BITE_1") + or self:framesetId("BATTLE_ANIM_FRAMESET_BITE_2")) + st.var1 = u8(st.var1 + 2) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_SOLAR_BEAM = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x28, 0 + end + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + if st.var1 == 0 then + deinit(st) + return + end + -- The radius is a 16-bit value shrinking half a pixel a frame. + st.var1, st.var2 = add16(st.var1, st.var2, -0x80) +end + +-- The gust's radius comes from a nine-entry table, so the whirl pulses rather +-- than turning at a constant width. +local GUST_OFFSETS = { [0] = 8, [1] = 6, [2] = 5, [3] = 4, [4] = 5, [5] = 6, + [6] = 8, [7] = 12, [8] = 16 } + +F.BATTLE_ANIM_FUNC_GUST = function(_, st) + local function wobble() + local radius = GUST_OFFSETS[st.var2] or 8 + local angle = st.var1 + -- Height is a sixteenth of the width, plus PARAM's own drift. + st.yOffset = u8(sra(sra(sra(sra(sine(angle, radius))))) + st.param) + st.xOffset = cosine(angle, radius) + st.var1 = u8(st.var1 - 8) + -- PARAM counts DOWN from 0 through $ff; once it drops below $c2 the whirl + -- settles back to the middle. + if st.param ~= 0 and st.param < 0xc2 then + st.var2, st.param, st.xOffset, st.yOffset = 0, 0, 0, 0 + return + end + st.param = u8(st.param - 1) + if bit.band(st.param, 7) ~= 0 then return end + st.var2 = u8(st.var2 + 1) + end + local function move() + wobble() + st.x = u8(st.x + 1) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + wobble() -- .zero falls into .one + elseif jt == 1 or jt == 3 then + wobble() + elseif jt == 2 then + if st.x < 0x88 then + move() + else + incJt(st) + end + elseif jt == 4 then + if st.x < 0xb8 then + move() + else + deinit(st) + end + end +end + +F.BATTLE_ANIM_FUNC_ABSORB = function(_, st) + if st.x < 0x30 then + deinit(st) + return + end + local e = bit.band(st.param, 0xf) + st.x = u8(st.x - e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y + (steps == 0 and 256 or steps)) +end + +F.BATTLE_ANIM_FUNC_WRAP = function(_, st) + -- anim_incobj walks the frameset one step along the BIND_1..4 run. + if st.jt ~= 1 then return end + reinit(st, u8(st.framesetId + 1)) + incJt(st) + st.var1 = 8 +end + +-- BattleAnim_StepThrownToTarget: a parabola whose horizontal step is PARAM's +-- two nybbles read as a 16-bit fixed-point number -- the LOW nybble is the +-- fraction and the HIGH nybble the whole pixels, which is the reverse of how +-- the macro's argument reads. +local function stepThrownToTarget(st) + st.var2 = u8(st.var2 - 1) + st.yOffset = sine(st.var2, 0x20) + st.fixY = u8(st.fixY + 2) + local step = bit.rshift(bit.band(st.param, 0xf0), 4) * 256 + + swap(bit.band(st.param, 0xf)) + st.x, st.var1 = add16(st.x, st.var1, step) + if bit.band(st.var2, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_LEECH_SEED = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + st.var2 = 0x40 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_2")) + incJt(st) + elseif jt == 2 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_3")) + end +end + +F.BATTLE_ANIM_FUNC_SPIKES = function(_, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + incJt(st) + end +end + +F.BATTLE_ANIM_FUNC_RAZOR_WIND = function(self, st) + F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE(self, st) + -- Fifteen extra steps a frame, so the object races round the circle. + st.var1 = u8(st.var1 + 0xf) +end + +local function kickRoll(_, st) + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_KICK = function(_, st) + local jt = st.jt + if jt == 1 then + if st.y < 0x30 then + st.y = u8(st.y + 4) + return + end + st.jt = 0 + elseif jt == 2 then + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + -- The kick pins itself to the target's side and holds one frame. + st.oamFlags = bit.bor(st.oamFlags, FIX_COORDS) + st.fixY = 0x90 + st.frame = 0 + st.duration = 2 + st.y = u8(st.y - 1) + elseif jt == 3 then + incJt(st) + st.var1 = 0x2c + st.frame = 0 + st.duration = 0x80 + kickRoll(_, st) -- .three falls into .four + elseif jt == 4 then + kickRoll(_, st) + end +end + +F.BATTLE_ANIM_FUNC_EGG = function(self, st) + -- .EggVerticalWaveMotion, shared by both openings. + local function wave() + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = u8(st.var2 - 8) + if st.var2 ~= 0 then return end + st.var1, st.var2 = 0, 0 + incJt(st) + end + -- .egg_bomb_step: the egg drifts up half a pixel a frame while it travels. + local function step() + st.x = u8(st.x + 1) + st.y, st.var1 = add16(st.y, st.var1, -0x80) + end + local jt = st.jt + if jt == 0 then + -- The object starts here and then jumps to whichever branch PARAM names, + -- which is how one object serves both Egg Bomb and Softboiled. + st.var1, st.var2 = 0x28, 0x10 + st.jt = st.param + elseif jt == 1 then + if st.x < 0x40 then st.x = u8(st.x + 1) end + wave() + elseif jt == 2 then + if st.x >= 0x88 then + incJt(st) + incJt(st) -- .egg_bomb_done skips straight to .four + return + end + if bit.band(st.x, 0xf) ~= 0 then + step() + return + end + st.var2 = 0x10 + incJt(st) + elseif jt == 3 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + st.jt = u8(st.jt - 1) + step() + elseif jt == 5 then + deinit(st) + elseif jt == 6 then + if st.x < 0x4b then st.x = u8(st.x + 1) end + wave() + elseif jt == 7 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_WOBBLE")) + incJt(st) + elseif jt == 8 then + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 2) + elseif jt == 9 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM")) + st.yOffset = 4 + incJt(st) + elseif jt == 11 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP")) + incJt(st) + st.var1 = 0x40 + elseif jt == 12 then + st.yOffset = sine(st.var1, 0x20) + if st.var1 < 0x30 then + incJt(st) + return + end + st.var1 = u8(st.var1 - 1) + end +end + +F.BATTLE_ANIM_FUNC_MOVE_UP = function(_, st) + -- Runs while the offset is 0 or already past $d8 going negative; anything + -- in between is "far enough up" and ends the object. + if st.yOffset ~= 0 and st.yOffset < 0xd8 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - st.param) +end + +F.BATTLE_ANIM_FUNC_SOUND = function(self, st) + local function motion() + local angle = st.var2 + st.var2 = u8(st.var2 + 2) + local value = sine(angle, 0x10) + st.xOffset = value + if st.param == 0 then + st.yOffset = u8(-value) + elseif st.param ~= 1 then + st.yOffset = value + end + -- PARAM 1 leaves the Y offset alone: that is the flat sideways wave. + end + if st.jt == 0 then + if (self.env.battleTurn or 0) ~= 0 then + -- `xor $ff; add $3` is 2 - param: the enemy's three angles are the + -- player's three mirrored, 0 <-> 2. + st.param = u8(2 - st.param) + end + incJt(st) + st.var1 = 8 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SOUND_1") + st.param) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = st.var1 - 1 + motion() +end + +F.BATTLE_ANIM_FUNC_CONFUSE_RAY = function(self, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0x3f) + -- Bit 7 becomes both the frameset offset and, once swapped, the radius. + st.param = rlca(bit.band(st.param, 0x80)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1") + st.param) + return + end + local radius = swap(st.param) + local angle = st.var2 + st.var2 = u8(st.var2 + 1) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) + if st.x >= 0x80 then return end + -- Both tests read the NEW VAR2, and the second `and $1` is applied to what + -- the first `and $3` left, not to the register again. + local phase = bit.band(st.var2, 3) + if phase == 0 then st.y = u8(st.y - 1) end + if bit.band(phase, 1) ~= 0 then return end + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_DIZZY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = st.framesetId + reinit(st, u8(st.var1 + rlca(bit.band(st.param, 0x80)))) + st.param = bit.band(st.param, 0x7f) + end + local angle = st.param + st.yOffset = sra(sra(sine(angle, 0x10))) + st.xOffset = cosine(angle, 0x10) + -- `ld a, [hl]` then `inc [hl]`: the frameset flip tests the PRE-increment + -- angle, so the two chick frames swap on the same beat as the circle. + st.param = u8(st.param + 1) + local phase = bit.band(angle, 0x3f) + if phase == 0 then + reinit(st, st.var1) + elseif bit.band(phase, 0x1f) == 0 then + reinit(st, u8(st.var1 + 1)) + end +end + +-- Hardcoded Y offsets, one per PARAM. +local AMNESIA_OFFSETS = { [0] = 0xec, [1] = 0xf8, [2] = 0x00 } + +F.BATTLE_ANIM_FUNC_AMNESIA = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_AMNESIA_1") + st.param) + st.yOffset = AMNESIA_OFFSETS[st.param] or 0 + elseif st.jt == 2 then + -- anim_incobj forces the object to deinit; Present is what uses it. + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FLOAT_UP = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 4) + -- `lb hl, -1, $a0` is the 16-bit constant $ffa0: up 3/8 of a pixel a frame. + st.yOffset, st.var2 = add16(st.yOffset, st.var2, -0x60) +end + +F.BATTLE_ANIM_FUNC_DIG = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 - 2) + st.yOffset = sine(angle, 0x10) + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_STRING = function(self, st) + if st.jt ~= 0 then return end + incJt(st) + -- PARAM 0 is the one that flips on the enemy's turn. + if st.param == 0 then st.oamFlags = bit.bor(st.oamFlags, OAM_YFLIP) end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_STRING_SHOT_1") + st.param) +end + +F.BATTLE_ANIM_FUNC_PARALYZED = function(self, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0 + local param = st.param + -- Bits 4-6 become the hold time; bit 7 flips the object, and the low + -- nybble is how far it jitters. + st.param = bit.band(swap(bit.band(param, 0x70)), 0xf) + if bit.band(param, 0x80) == 0 then + st.xOffset = bit.band(param, 0xf) + else + st.xOffset = u8(-bit.band(param, 0xf)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED")) + end + return + end + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + st.var1 = st.param + st.xOffset = u8(-st.xOffset) +end + +-- A shared descent: a circle whose height is an eighth of its width, sinking +-- one pixel every few frames until it is $28 down. Spiral Descent checks +-- every eight frames, Petal Dance every four. +local function spiralDescent(st, mask) + local angle = st.var1 + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, mask) ~= 0 then return end + if st.var2 >= 0x28 then + deinit(st) + return + end + st.var2 = u8(st.var2 + 1) +end + +F.BATTLE_ANIM_FUNC_SPIRAL_DESCENT = function(_, st) spiralDescent(st, 7) end +F.BATTLE_ANIM_FUNC_PETAL_DANCE = function(_, st) spiralDescent(st, 3) end + +F.BATTLE_ANIM_FUNC_POISON_GAS = function(_, st) + if st.jt ~= 0 then + spiralDescent(st, 7) + return + end + if st.x >= 0x84 then + incJt(st) + return + end + st.x = u8(st.x + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + if st.var2 == 0xe8 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_SACRED_FIRE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 3) ~= 0 then return end + if st.var2 == 0xd0 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x34, 0x10 + elseif st.jt == 2 then + deinit(st) + return + end + if st.jt ~= 1 then return end + if st.x >= 0x6c then return end + stepToTarget(st, 2) + local value = sine(st.var1, st.var2) + -- Only the upper half of the bounce shows: a positive sine is negated, so + -- the puff always sits above its line. + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + st.var1 = u8(st.var1 - 4) + -- The halving below is unreachable on the cart: `and $1f` can never leave + -- $20, so Present's puff keeps its height the whole way across. +end + +F.BATTLE_ANIM_FUNC_HORN = function(_, st) + local function spin() + local value = sine(st.var2, 8) + st.xOffset = value + st.y = u8(st.var1 - sra(value)) + st.var2 = u8(st.var2 + 8) + end + local jt = st.jt + if jt == 0 then + st.jt = st.param + st.var1 = st.y + elseif jt == 1 then + if st.x >= 0x58 then return end + stepToTarget(st, 2) + elseif jt == 2 then + if st.var2 >= 0x20 then + deinit(st) + return + end + spin() + elseif jt == 3 then + spin() + end +end + +F.BATTLE_ANIM_FUNC_NEEDLE = function(_, st) + local function line() + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) + end + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks straight line or arc. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + line() + elseif jt == 2 then + local value = sine(st.var1, 0x10) + -- Only the negative half is written, so the needle arcs upward only. + if bit.band(value, 0x80) ~= 0 then st.yOffset = value end + st.var1 = u8(st.var1 - 4) + line() -- .two falls into .one + end +end + +F.BATTLE_ANIM_FUNC_THIEF_PAYDAY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0x28 + st.var2 = u8(st.y - 0x28) + end + st.yOffset = sine(st.var1, st.var2) + -- PARAM is a MASK, so the coin only drifts left on the frames that clear it. + if bit.band(st.var1, st.param) == 0 then st.x = u8(st.x - 1) end + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = bit.rshift(st.var2, 1) +end + +F.BATTLE_ANIM_FUNC_ABSORB_CIRCLE = function(_, st) + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + st.param = u8(st.param + 1) + if bit.band(st.param, 1) == 0 then st.x = u8(st.x - 1) end + if bit.band(st.param, 3) == 0 then st.y = u8(st.y + 1) end + if st.x >= 0x5a then + st.var1 = u8(st.var1 + 1) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = u8(st.var1 - 1) +end + +F.BATTLE_ANIM_FUNC_CONVERSION = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + local age = st.var2 + st.var2 = u8(st.var2 + 1) + if age < 0x40 then + st.var1 = u8(st.var1 + 1) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 - 1) + if radius ~= 0 then return end + deinit(st) +end + +F.BATTLE_ANIM_FUNC_BONEMERANG = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = st.y + end + st.y = u8(st.var2 + sine(st.param, 0x30)) + -- Eight steps of phase between the two axes is what bends the throw into a + -- boomerang instead of a circle. + st.xOffset = cosine(st.param + 8, 0x30) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_SHINY = function(_, st) + if st.jt ~= 0 then return end + incJt(st) + st.yOffset = sine(st.param, 0x10) + st.xOffset = cosine(st.param, 0x10) + st.var2 = 0xf +end + +-- Sky Attack pulses OBP0 rather than moving anything, so the palette write is +-- what the view has to see. +local SKY_ATTACK_GBC = { [0] = 0xff, [1] = 0xaa, [2] = 0x55, [3] = 0xaa } +local SKY_ATTACK_SGB = { [0] = 0xff, [1] = 0xff, [2] = 0x00, [3] = 0x00 } + +F.BATTLE_ANIM_FUNC_SKY_ATTACK = function(self, st) + local function cyclePalette() + local phase = bit.band(st.var2, 7) + st.var2 = u8(st.var2 + 1) + local pals = self.env.sgb and SKY_ATTACK_SGB or SKY_ATTACK_GBC + self.obp0 = bit.band(pals[bit.rshift(phase, 1)] or 0xff, st.var1) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = (self.env.battleTurn or 0) == 0 and 0xf0 or 0xcc + elseif jt == 1 then + cyclePalette() + elseif jt == 2 then + cyclePalette() + if st.x >= 0x84 then return end + stepToTarget(st, 4) + elseif jt == 3 then + cyclePalette() + if st.x >= 0xd0 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.param = u8(st.param + 1) + st.xOffset = cosine(angle, 0x18) + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS = function(_, st) + local jt = st.jt + if jt == 0 then + if st.yOffset == 0xe0 then + incJt(st) + st.var1 = 2 + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, -0x80) + elseif jt == 1 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + -- Shakes by negating the accumulated step and folding it into the offset + -- every four frames. + st.var2 = 4 + st.var1 = u8(-st.var1) + st.yOffset = u8(st.yOffset + st.var1) + elseif jt == 2 then + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_SPEED_LINE = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SPEED_LINE_1") + + bit.band(st.param, 0x7f)) + end + if bit.band(st.param, 0x80) ~= 0 then + st.xOffset = u8(st.xOffset - 1) + else + st.xOffset = u8(st.xOffset + 1) + end +end + +F.BATTLE_ANIM_FUNC_SLUDGE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST")) + st.yOffset = u8(st.yOffset - 1) -- .done falls into .two + elseif jt == 2 then + st.yOffset = u8(st.yOffset - 1) + end +end + +F.BATTLE_ANIM_FUNC_METRONOME_HAND = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(angle, 2) + st.xOffset = cosine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH = function(_, st) + if st.yOffset >= 0x20 then + deinit(st) + return + end + st.xOffset = cosine(st.param, 8) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_AGILITY = function(_, st) + -- anim_incobj is what makes it disappear. + if st.jt ~= 0 then + deinit(st) + return + end + st.x = u8(st.x + st.param) +end + +F.BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT = function(_, st) + local angle = st.param + st.yOffset = sine(angle, 0x18) + st.xOffset = sra(cosine(angle, 0x18)) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER = function(_, st) + -- .two: hold for $10 frames, then go. + local function hold() + local left = st.var1 + st.var1 = u8(st.var1 - 1) + if left ~= 0 then return end + deinit(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x28 + -- The low nybble walks along the four LOCK_ON / MIND_READER framesets, + -- and the object's own frameset id is the base of that run. + reinit(st, u8(st.framesetId + bit.band(st.param, 0xf))) + st.param = bit.bor(bit.band(st.param, 0xf0), 8) + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + -- The radius is the distance still to run, so the ring converges. + local radius = u8(st.var1 + 8) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) + return + end + st.var1 = 0x10 + incJt(st) + hold() -- .done falls into .two + return + end + if jt == 2 then hold() end +end + +F.BATTLE_ANIM_FUNC_HEAL_BELL_NOTES = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + -- Tests the Y COORDINATE, not the counter: the note drifts left on the + -- frames its row happens to be even. + if bit.band(st.y, 1) ~= 0 then return end + st.x = u8(st.x - 1) +end + +F.BATTLE_ANIM_FUNC_BATON_PASS = function(_, st) + if st.param == 0 then return end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + local value = sine(angle, st.param) + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- Each bounce is half the last. + st.param = bit.rshift(st.param, 1) +end + +F.BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM = function(_, st) + if st.var1 >= 0x10 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) +end + +F.BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN = function(_, st) + -- The top two bits of PARAM are the speed and the low six the angle; the + -- amplitude is VAR1 as it was BEFORE this frame's speed was added. + local radius = st.var1 + st.var1 = u8(st.var1 + rlca(rlca(bit.band(st.param, 0xc0)))) + local angle = bit.band(st.param, 0x3f) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) +end + +F.BATTLE_ANIM_FUNC_HIDDEN_POWER = function(_, st) + -- .two: the ring expands eight pixels a frame and then vanishes. + local function expand() + if st.var1 >= 0x80 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 8) + stepCircle(st, st.param, radius) + end + local jt = st.jt + if jt == 0 then + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) + elseif jt == 1 then + incJt(st) + st.var1 = 0x18 + expand() -- .one falls into .two + elseif jt == 2 then + expand() + end +end + +F.BATTLE_ANIM_FUNC_CURSE = function(_, st) + if st.jt ~= 1 then return end + if st.x < 0x30 then + deinit(st) + return + end + st.x = u8(st.x - 2) + st.y = u8(st.y + 2) +end + +F.BATTLE_ANIM_FUNC_PERISH_SONG = function(_, st) + local angle = st.param + st.param = u8(st.param + 2) + -- VAR1 is both the sink and the counter: the sine is added to it and then + -- it is stepped, so the ring drifts down as it turns. + st.yOffset = u8(sra(sra(sine(angle, 0x50))) + st.var1) + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x50) +end + +F.BATTLE_ANIM_FUNC_RAPID_SPIN = function(_, st) + if st.yOffset == 0xd0 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - 4) +end + +F.BATTLE_ANIM_FUNC_BETA_PURSUIT = function(_, st) + local jt = st.jt + if jt == 0 then + if st.param ~= 0 then + incJt(st) + incJt(st) + return + end + incJt(st) + st.yOffset = 0xec + elseif jt == 1 then + if st.yOffset == 4 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 4) + elseif jt == 2 then + if st.yOffset == 0xd8 then return end + st.yOffset = u8(st.yOffset - 4) + elseif jt == 3 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_RAIN_SANDSTORM = function(_, st) + -- The Y offset wraps at $70, which is what makes a single object read as a + -- continuous fall of rain. + local function fall(step) + local y = st.yOffset + 4 + st.yOffset = (y < 0x70) and y or 0 + st.xOffset = u8(st.xOffset + step) + end + local jt = st.jt + if jt == 0 then + st.jt = u8(st.param + 1) -- .zero sets the index from PARAM and then incs + elseif jt == 1 then + fall(2) + elseif jt == 2 then + fall(8) + elseif jt == 3 then + fall(4) + end +end + +F.BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0 = function(_, st) + -- Unused on the cart (nothing names BATTLE_ANIM_OBJ_B0), transcribed + -- because the jumptable slot is real: PARAM's nybbles become a 16-bit step + -- over XCOORD:VAR1, the high nybble duplicated into both halves of the + -- whole-pixel byte. + local high = bit.band(st.param, 0xf0) + high = bit.bor(high, bit.rshift(high, 4)) + st.x, st.var1 = add16(st.x, st.var1, + high * 256 + swap(bit.band(st.param, 0xf))) +end + +F.BATTLE_ANIM_FUNC_PSYCH_UP = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) +end + +F.BATTLE_ANIM_FUNC_COTTON = function(_, st) + local phase = st.var2 + st.var2 = u8(st.var2 + 1) + stepCircle(st, u8(bit.rshift(phase, 1) + st.param), 0x18) +end + +F.BATTLE_ANIM_FUNC_ANCIENT_POWER = function(_, st) + if st.var1 >= 0x20 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = u8(-sine(angle, st.param)) +end + +AnimObjects.FUNCTIONS = F +AnimObjects.NUM_STRUCTS = NUM_STRUCTS +AnimObjects.OAM_LIMIT = OAM_LIMIT +AnimObjects.OAM_XFLIP = OAM_XFLIP +AnimObjects.OAM_YFLIP = OAM_YFLIP +AnimObjects.OAM_PRIO = OAM_PRIO +AnimObjects.OAM_PAL1 = OAM_PAL1 +AnimObjects.FIX_COORDS = FIX_COORDS +AnimObjects.sine, AnimObjects.cosine = sine, cosine +AnimObjects.u8, AnimObjects.s8, AnimObjects.sra = u8, s8, sra +AnimObjects.swap, AnimObjects.rlca = swap, rlca + +return AnimObjects diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua new file mode 100644 index 00000000..3c92bb8b --- /dev/null +++ b/src/battle/gen2/AnimRunner.lua @@ -0,0 +1,480 @@ +-- The Gen 2 battle-animation command interpreter. +-- +-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame +-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through. +-- The scripts themselves are already disassembled into the cache by +-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM +-- address because that is what a branch names. +-- +-- One frame of an animation is exactly three things, in this order: +-- +-- RunBattleAnimCommand run script bytes until one asks to wait +-- ExecuteBGEffects one pass over the five BG-effect structs +-- BattleAnim_UpdateOAM_All one pass over the ten object structs +-- +-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The +-- animation is over when a `ret` runs outside a subroutine, which is what +-- BATTLEANIM_STOP_F means. +-- +-- Two things about the script format that are easy to get wrong and are +-- already handled by the extractor, repeated here because this is where they +-- bite: anything under $d0 is `anim_wait <n>` and carries no arguments, and a +-- branch's target is the LAST two bytes of the command, which the extractor +-- has already rewritten into a pool key. +-- +-- Love-free: sound and cries go out through the `hooks` table the battle +-- screen supplies, so a test can step a whole animation and assert what it +-- asked to play. + +local bit = require("bit") +local AnimObjects = require("src.battle.gen2.AnimObjects") +local BgEffects = require("src.battle.gen2.BgEffects") + +local AnimRunner = {} +local Runner = {} +Runner.__index = Runner + +-- wBattleAnimTileDict is five {gfx id, tile id} pairs. +local NUM_TILEDICT_ENTRIES = 5 +-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to +-- vTiles1, so the running allocator stops at 128 - 49. +local MAX_ANIM_TILES = 128 - 49 + +-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in +-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the +-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends. +local BATTLER_TILES = { + oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 }, + twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 }, +} + +-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own +-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS. +local CRY_DATA = { + [0] = { pitch = 0x0000, length = 0x00c0 }, + [1] = { pitch = 0x0000, length = 0x0040 }, + [2] = { pitch = 0x0000, length = 0x0000 }, + [3] = { pitch = 0x0000, length = 0x0000 }, +} + +-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair. +local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f } + +-------------------------------------------------------------------------- + +-- opts: +-- data the cache's battle_anims.lua +-- constants the cache's constants.lua +-- battleTurn hBattleTurn -- 0 while the player is attacking +-- param wBattleAnimParam, which the effect layer sets (hit count, +-- stat direction, the Beat Up party slot...) +-- animId the move (or ANIM_* id) whose script this is +-- hooks { sound(name, panning, duration), cry(side, pitch, length) } +-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown +function AnimRunner.new(opts) + opts = opts or {} + local self = setmetatable({}, Runner) + self.data = opts.data or {} + self.constants = opts.constants or {} + self.hooks = opts.hooks or {} + -- Shared by both pools; the object functions and the BG effects read the + -- same hBattleTurn. + self.env = { + battleTurn = opts.battleTurn or 0, + animId = opts.animId, + ballPalette = opts.ballPalette, + sgb = opts.sgb, + flying = opts.flying or {}, + } + self.objects = AnimObjects.new(self.data, self.constants, self.env) + self.bg = BgEffects.new(self.constants, self.env) + self.gfxOrder = self.constants.battleAnimGfxOrder or {} + self.sfxOrder = opts.sfxOrder or {} + + self.param = opts.param or 0 -- wBattleAnimParam + self.var = 0 -- wBattleAnimVar + self.delay = 0 -- wBattleAnimDelay + self.loops = 0 -- wBattleAnimLoops + self.inSubroutine = false + self.inLoop = false + self.stopped = false + self.keepSprites = false + self.frames = 0 + -- wBattleAnimTileDict, and the sheets it points at. + self.tileDict = {} + self.loaded = {} + -- Set by the substitute / minimize / transform commands, for the view. + self.picOverride = { player = nil, enemy = nil } + self.address = nil + self.parent = nil + return self +end + +-- ClearBattleAnims: the whole animation block, then the entry point. +function Runner:start(scriptKey) + self.objects:clear() + self.bg:reset() + self.var, self.delay, self.loops = 0, 0, 0 + self.inSubroutine, self.inLoop, self.stopped = false, false, false + self.keepSprites = false + self.frames = 0 + self.tileDict = {} + self.loaded = {} + self.picOverride = { player = nil, enemy = nil } + self.address = scriptKey and { key = scriptKey, index = 1 } or nil + self.parent = nil + return self +end + +-- The script for a move, or nil when the cache has none (which is what an +-- unextracted or modded move looks like). +function AnimRunner.scriptForMove(data, moveId) + local moves = (data or {}).moves or {} + return moves[moveId] +end + +function Runner:scriptRows(key) + return (self.data.scripts or {})[key] +end + +-- GetBattleAnimByte, one decoded row at a time. +function Runner:fetch() + local at = self.address + if not at then return nil end + local rows = self:scriptRows(at.key) + if not rows then return nil end + local row = rows[at.index] + if not row then return nil end + at.index = at.index + 1 + return row +end + +-- The three "skip the branch target" tails: a conditional that does not take +-- its branch steps the address past the two address bytes, which in a decoded +-- row list is simply "carry on". +function Runner:jumpTo(key) + self.address = { key = key, index = 1 } +end + +-------------------------------------------------------------------------- +-- The tile dict +-------------------------------------------------------------------------- + +-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile +-- returned; a miss is 0, which is why an object whose sheet the script never +-- loaded draws whatever happens to sit at the base tile. +function Runner:tileOffsetFor(gfxId) + for i = 1, NUM_TILEDICT_ENTRIES do + local entry = self.tileDict[i] + if entry and entry.gfx == gfxId then return entry.tile end + end + return 0 +end + +-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every +-- command and each sheet is laid down after the last, so two animations that +-- load different sheet counts do not agree about where anything is -- which +-- is exactly why the dict exists. Entries past the count are NOT cleared. +function Runner:loadGfx(names) + local tile = 0 + for slot, gfxId in ipairs(names) do + if tile >= MAX_ANIM_TILES then break end + local name = gfxId + if type(gfxId) == "number" then + name = self.gfxOrder[gfxId + 1] or gfxId + end + self.tileDict[slot] = { gfx = name, tile = tile } + local sheet = (self.data.gfx or {})[name] + self.loaded[#self.loaded + 1] = { + gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0, + } + tile = tile + ((sheet and sheet.tiles) or 0) + end +end + +-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are +-- APPENDED after whatever the script already loaded rather than replacing it, +-- and they always land on the same two fixed tile ids. +-- +-- (pokegold's jumptable has these two labels the other way round from the +-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while +-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which +-- is what a script actually writes.) +function Runner:loadBattlerGfx(rows) + local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow + local slot = 1 + while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do + slot = slot + 1 + end + if slot + 1 > NUM_TILEDICT_ENTRIES then return end + self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } + self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6, + battler = "player", rows = rows } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7, + battler = "enemy", rows = rows } +end + +-------------------------------------------------------------------------- +-- BattleAnimCommands +-------------------------------------------------------------------------- + +local C = {} + +C.obj = function(self, row) + self.objects:queue(row[2], row[3], row[4], row[5], function(gfx) + return self:tileOffsetFor(gfx) + end) +end + +for count = 1, 5 do + C[count .. "gfx"] = function(self, row) + local names = {} + for i = 1, count do names[i] = row[i + 1] end + self:loadGfx(names) + end +end + +C.incobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(st.jt + 1) end +end + +C.setobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(row[3]) end +end + +C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end + +C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end + +-- GetPokeBallWobble's answer, which the ball's own script then branches on. +C.checkpokeball = function(self) + self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0 +end + +-- The commands that swap a battler's pic out for something else. The port +-- records which, and the view draws it. +-- +-- Every one of them branches on hBattleTurn the same way +-- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the +-- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot. +-- So turn 0, the player attacking, always repaints the PLAYER's own pic, and +-- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's. +C.transform = function(self) + -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the + -- backpic slot, i.e. the player's sprite becomes what it transformed into. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "transform" +end + +C.raisesub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "substitute" +end + +C.dropsub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = nil +end + +-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the +-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The +-- other minimize opcode, $e9, is one of the dummies below. +C.minimizeopp = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "minimize" +end + +C.beatup = function(self) + -- wBattleAnimParam is the party slot whose pic to show. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = { kind = "beatup", slot = self.param } +end + +C.resetobp0 = function(self) + self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0 +end + +C.sound = function(self, row) + local packed = row[2] or 0 + -- The first byte is BOTH the duration (its top six bits) and the cry-track + -- pair (its bottom two), which is why the same value reads twice here. + local duration = bit.rshift(packed, 2) + local tracks = bit.band(packed, 3) + if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end + local id = row[3] or 0 + local name = self.sfxOrder[id + 1] + if self.hooks.sound then + self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id) + end +end + +C.cry = function(self, row) + local slot = bit.band(row[2] or 0, 3) + local entry = CRY_DATA[slot] or CRY_DATA[0] + local side = self.env.battleTurn == 0 and "player" or "enemy" + if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end +end + +C.clearobjs = function(self) self.objects:clearObjs() end + +C.oamon = function() end +C.oamoff = function() end +C.updateactorpic = function() end +-- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and +-- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six +-- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm). +-- The minimize animation that is actually drawn is $e2, minimizeopp above. +C.minimize = function() end +C.unknown_e7 = function() end +C.unknown_ea = function() end +C.unknown_eb = function() end +C.unknown_ec = function() end +C.unknown_ed = function() end +C.unknown_f5 = function() end +C.unknown_f6 = function() end +C.unknown_f7 = function() end + +C.keepsprites = function(self) self.keepSprites = true end + +C.bgp = function(self, row) self.bg.bgp = row[2] end +C.obp0 = function(self, row) self.bg.obp0 = row[2] end +C.obp1 = function(self, row) self.bg.obp1 = row[2] end + +C.bgeffect = function(self, row) + self.bg:queue(row[2], row[3], row[4], row[5]) +end + +C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end +C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end + +C.if_var_equal = function(self, row) + if row[2] == self.var then self:jumpTo(row[3]) end +end + +C.if_param_equal = function(self, row) + if row[2] == self.param then self:jumpTo(row[3]) end +end + +C.if_param_and = function(self, row) + if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end +end + +-- The one conditional that CONSUMES what it tests: each pass decrements +-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times. +C.jumpuntil = function(self, row) + if self.param == 0 then return end + self.param = AnimObjects.u8(self.param - 1) + self:jumpTo(row[2]) +end + +C.jump = function(self, row) self:jumpTo(row[2]) end + +C.loop = function(self, row) + local count = row[2] or 0 + if not self.inLoop then + -- A count of 0 loops forever and never claims the loop flag. + if count ~= 0 then + self.inLoop = true + self.loops = AnimObjects.u8(count - 1) + end + self:jumpTo(row[3]) + return + end + if self.loops == 0 then + self.inLoop = false + return -- falls through past the target + end + self.loops = self.loops - 1 + self:jumpTo(row[3]) +end + +C.call = function(self, row) + self.parent = { key = self.address.key, index = self.address.index } + self.inSubroutine = true + self:jumpTo(row[2]) +end + +C.ret = function(self) + self.inSubroutine = false + self.address = self.parent + and { key = self.parent.key, index = self.parent.index } or nil +end + +-------------------------------------------------------------------------- + +-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one +-- of them asks to wait or the animation ends. +function Runner:runCommands() + if self.delay ~= 0 then + self.delay = self.delay - 1 + return + end + for _ = 1, 512 do + local row = self:fetch() + if not row then + self.stopped = true + return + end + local cmd = row[1] + if cmd == "ret" then + -- A `ret` outside a subroutine is what ends the whole animation. + if not self.inSubroutine then + self.stopped = true + return + end + C.ret(self, row) + elseif cmd == "wait" then + self.delay = row[2] or 0 + return + else + local fn = C[cmd] + if fn then fn(self, row) end + end + end + -- A script that never waits would hang the battle; stopping is the only + -- honest thing to do with one. + self.stopped = true +end + +-- One frame. Returns false once the animation is over. +function Runner:step() + if self.stopped then return false end + self.frames = self.frames + 1 + self:runCommands() + self.bg:playFrame() + -- A BG effect can ask for an object (the battler-pic ones do), and it has + -- to land before the object pass or it would be a frame late. + for _, spawn in ipairs(self.bg:takeSpawns()) do + self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param, + function(gfx) return self:tileOffsetFor(gfx) end) + end + self.objects:playFrame() + -- Rollout hands the shake to the first object's Y offset. + if self.bg.rolloutYOffset then + local first = self.objects.structs[1] + if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end + end + if self.stopped then + -- BattleAnim_ClearOAM: unless the script asked to keep them, every object + -- goes at the end. + if not self.keepSprites then self.objects.oam = {} end + return false + end + return true +end + +function Runner:oam() return self.objects.oam end +function Runner:done() return self.stopped end + +AnimRunner.COMMANDS = C +AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES +AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES +AnimRunner.BATTLER_TILES = BATTLER_TILES + +return AnimRunner diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua new file mode 100644 index 00000000..120dbad5 --- /dev/null +++ b/src/battle/gen2/Battle.lua @@ -0,0 +1,4479 @@ +-- Gen 2 battle engine: the turn loop, as pure logic. +-- +-- No love calls and no rendering: a battle is a state machine that consumes +-- actions and produces a queue of events, so the same engine drives the screen +-- (src/ui/gen2/BattleState.lua), a headless driver, and the tests. Gen 1's +-- src/battle/BattleState.lua interleaves logic with drawing, which is exactly +-- what makes its turn order hard to assert; this does not repeat that. +-- +-- Ported from engine/battle/core.asm's turn structure: +-- * both sides choose (a move, an item, a switch, or run) +-- * order is by Speed after stat stages, with a coin flip on a tie +-- (DetermineMoveOrder); a switch or item always goes first +-- * each attack: PP, status gates (sleep/freeze/paralysis), accuracy, damage, +-- then the move's secondary effect +-- * end of turn: burn and poison tick, then faint checks and experience +-- +-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP +-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down +-- from 1-7 turns. + +local Damage = require("src.battle.gen2.Damage") +local Ai = require("src.battle.gen2.Ai") +local Effects = require("src.battle.gen2.Effects") +local Mon = require("src.battle.gen2.Mon") +local Happiness = require("src.core.gen2.Happiness") +local Pokerus = require("src.core.gen2.Pokerus") +local Roamers = require("src.core.gen2.Roamers") +local Prize = require("src.battle.gen2.Prize") +-- The mod event/hook buses. Every name raised from this file is the SAME name +-- src/battle/BattleState.lua raises on Gen 1, carrying the same payload keys +-- with the same meaning -- a mod written against Red's battle reads Gold's +-- without learning a second vocabulary (docs/mod-api-gen2-compat.md). Where +-- Gen 2 genuinely carries more (split special stats, a held item on either +-- battler) the extra rides BESIDE the Gen 1 key, never instead of it. +-- +-- Two shape differences are unavoidable and are called out at each site: +-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon +-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon +-- table directly, so that is what these payloads carry. +-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is +-- the cart's BattleRandom convention (0..n-1). Both keys are present. +local Runtime = require("src.mods.Runtime") +-- The two battle lines that carry the cart's own `line` break: a marker-bearing +-- literal has to stay reachable from a translation mod (#186, #245), which is +-- what tests/engine/gate_strings_coverage.lua watches for. +local Strings = require("src.core.Strings") + +local Battle = {} +Battle.__index = Battle + +-- Burn and poison both tick 1/8 of max HP at the end of a turn in Gen 2. +Battle.BURN_FRACTION = 8 +Battle.POISON_FRACTION = 8 +-- Burn halves physical Attack; paralysis quarters Speed. +Battle.BURN_ATTACK_DIVISOR = 2 +Battle.PARALYSIS_SPEED_DIVISOR = 4 +-- A paralysed mon loses its turn a quarter of the time. +Battle.PARALYSIS_SKIP_CHANCE = 4 +-- A frozen mon thaws on a 1-in-5 roll each turn it tries to move. +Battle.THAW_CHANCE = 5 + +-- Moves whose effect the engine models. Everything else lands as a plain hit +-- (or, with no power, as a no-op message), which is honest: an unmodelled +-- effect never silently does the wrong thing. +Battle.STATUS_EFFECTS = { + EFFECT_SLEEP = "sleep", + EFFECT_POISON = "poison", + EFFECT_TOXIC = "toxic", + EFFECT_PARALYZE = "paralyze", + EFFECT_BURN = "burn", + EFFECT_FREEZE = "freeze", + EFFECT_CONFUSE = "confuse", +} +Battle.SECONDARY_EFFECTS = { + EFFECT_POISON_HIT = "poison", + EFFECT_BURN_HIT = "burn", + EFFECT_FREEZE_HIT = "freeze", + EFFECT_PARALYZE_HIT = "paralyze", + EFFECT_SLEEP_HIT = "sleep", + EFFECT_CONFUSE_HIT = "confuse", +} + +local function rand(random, n) + if random then return random(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 +end + +-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only +-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into +-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader +-- (which starts halfway down) matches the last eight. Splitting them into two +-- separate tables here and forgetting the fallthrough would deny Brock's party +-- its HAPPINESS_GYMBATTLE, which is exactly the bug the comment at the top of +-- leaders.asm warns about. +Battle.KANTO_GYM_LEADER_CLASSES = { + BROCK = true, MISTY = true, LT_SURGE = true, ERIKA = true, + JANINE = true, SABRINA = true, BLAINE = true, BLUE = true, +} +Battle.GYM_LEADER_CLASSES = { + FALKNER = true, WHITNEY = true, BUGSY = true, MORTY = true, + PRYCE = true, JASMINE = true, CHUCK = true, CLAIR = true, + WILL = true, BRUNO = true, KAREN = true, KOGA = true, + CHAMPION = true, RED = true, +} +for class in pairs(Battle.KANTO_GYM_LEADER_CLASSES) do + Battle.GYM_LEADER_CLASSES[class] = true +end + +-- IsGymLeader / IsKantoGymLeader, as predicates. +function Battle.isGymLeader(class) + return class ~= nil and Battle.GYM_LEADER_CLASSES[class] == true +end + +function Battle.isKantoGymLeader(class) + return class ~= nil and Battle.KANTO_GYM_LEADER_CLASSES[class] == true +end + +-- The four items XItemEffect covers (data/items/x_stats.asm). DIRE_HIT and +-- GUARD_SPEC have their own effect routines and award nothing, so they are +-- deliberately not here. +Battle.X_ITEMS = { + X_ATTACK = true, X_DEFEND = true, X_SPEED = true, X_SPECIAL = true, +} + +-- data/items/x_stats.asm: which stat each X item raises one stage of. +-- X SPECIAL is SP_ATTACK only in Gen 2. +Battle.X_ITEM_STATS = { + X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed", + X_SPECIAL = "specialAttack", +} + +-- XAccuracyEffect / DireHitEffect / GuardSpecEffect (engine/items/ +-- item_effects.asm:2079-2113): each sets one wPlayerSubStatus4 bit on the +-- active mon and refuses when it is already up. The bits live in the mon's +-- volatile so a switch drops them, which is what SUBSTATUS4 does too. +Battle.SUBSTATUS_ITEMS = { + X_ACCURACY = "xAccuracy", -- SUBSTATUS_X_ACCURACY: skip the accuracy roll + DIRE_HIT = "focusEnergy", -- SUBSTATUS_FOCUS_ENERGY: +1 critical level + GUARD_SPEC = "mist", -- SUBSTATUS_MIST: no stat drops from the foe +} + +-- constants/battle_constants.asm const order: the two battle types whose +-- whole meaning is "no escape". TryToRunAwayFromBattle jumps straight to +-- .cant_escape for both, ahead of even the trainer check, and +-- BattleCommand_ForceSwitch fails outright for both -- the Lake of Rage +-- Gyarados (FORCESHINY) and the Rocket base's exploding traps (TRAP) cannot +-- be run from or Roared away. +Battle.BATTLETYPE_FORCESHINY = 7 +Battle.BATTLETYPE_TRAP = 9 + +-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges +-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other +-- badge bit after swapping PlainBadge and MineralBadge, which is what lands +-- Mineral on Defense and Plain on Speed; Glacier boosts Special Attack, and +-- its Special Defense re-check is the buggy tail modelled in +-- Battle.glacierBoostsSpDef below. +Battle.BADGE_STAT_BOOSTS = { + attack = "ZEPHYR", + defense = "MINERAL", + speed = "PLAIN", + specialAttack = "GLACIER", +} + +-- data/types/badge_type_boosts.asm, in the cart's own walk order: the eight +-- wJohtoBadges bits, then the eight wKantoBadges bits. DoBadgeTypeBoosts +-- boosts the player's damage by 1/8 when an owned badge's type matches the +-- move's. +Battle.BADGE_TYPE_BOOSTS = { + { store = "badges", badge = "ZEPHYR", type = "FLYING" }, + { store = "badges", badge = "HIVE", type = "BUG" }, + { store = "badges", badge = "PLAIN", type = "NORMAL" }, + { store = "badges", badge = "FOG", type = "GHOST" }, + { store = "badges", badge = "MINERAL", type = "STEEL" }, + { store = "badges", badge = "STORM", type = "FIGHTING" }, + { store = "badges", badge = "GLACIER", type = "ICE" }, + { store = "badges", badge = "RISING", type = "DRAGON" }, + { store = "kantoBadges", badge = "BOULDER", type = "ROCK" }, + { store = "kantoBadges", badge = "CASCADE", type = "WATER" }, + { store = "kantoBadges", badge = "THUNDER", type = "ELECTRIC" }, + { store = "kantoBadges", badge = "RAINBOW", type = "GRASS" }, + { store = "kantoBadges", badge = "SOUL", type = "POISON" }, + { store = "kantoBadges", badge = "MARSH", type = "PSYCHIC_TYPE" }, + { store = "kantoBadges", badge = "VOLCANO", type = "FIRE" }, + { store = "kantoBadges", badge = "EARTH", type = "GROUND" }, +} + +-- wJohtoBadges bit order, for the positional keying FieldMoves.hasBadge also +-- accepts (a save may key player.badges by name or by bit position). +Battle.JOHTO_BADGE_ORDER = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} +Battle.KANTO_BADGE_ORDER = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", + "SOUL", "MARSH", "VOLCANO", "EARTH", +} + +-- opts: +-- data { pokemon, moves, type_chart, items } +-- party the player's party (array of Mon) +-- wild a single Mon for a wild battle +-- trainer { class, name, party, baseMoney } for a trainer battle; +-- baseMoney is the class's TRNATTR_BASE_REWARD and is what +-- src/battle/gen2/Prize.lua pays out of when the trainer loses +-- save the Gold save, for the two money accounts WinTrainerBattle +-- writes. Optional: a headless turn-order test hands in no save +-- and the payout is simply skipped, the way wMoney is untouched +-- by a link battle +-- roaming the save's roamer slot index (1 Raikou, 2 Entei, 3 Suicune) when +-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild` +-- through Roamers.beginBattle and reads Battle.roaming back to +-- bank the beast's HP afterwards +-- random(n) 0..n-1, injected so a test is deterministic +function Battle.new(opts) + opts = opts or {} + local self = setmetatable({}, Battle) + self.data = opts.data or {} + self.random = opts.random + self.party = opts.party or {} + self.trainer = opts.trainer + self.save = opts.save + -- wBattleType, when the caller knows it: "fish" gates the Lure Ball's x3 + -- (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 + self.events = {} + self.turn = 0 + self.over = false + self.outcome = nil -- "win" | "lose" | "run" | "caught" + -- Participants earn experience; a switch adds to the set. + self.participants = {} + + self.playerIndex = Battle.firstHealthy(self.party) or 1 + self.player = self.party[self.playerIndex] + if self.player then self.participants[self.playerIndex] = true end + -- wAmuletCoin, latched by CheckAmuletCoin on every send-out and never + -- cleared again until the next battle starts. + self.amuletCoin = false + self:checkAmuletCoin(self.player) + + if opts.wild then + self.wild = true + self.enemy = opts.wild + self.enemyParty = { opts.wild } + self.enemyIndex = 1 + -- wBattleType = BATTLETYPE_ROAMING. Kept as the SLOT index rather than a + -- boolean because BattleEnd_HandleRoamMons needs to know whose HP byte to + -- write, and GetRoamMonHP resolves that from the species. + self.roaming = opts.roaming + else + self.wild = false + self.enemyParty = (self.trainer and self.trainer.party) or {} + -- trainer.party, the same hook BattleState:startTrainer calls on Gen 1 and + -- with the same three arguments: the class, which roster of that class, and + -- the roster itself, returning the roster to fight. Gen 2's rows carry a + -- held item and split special stats; a mod that hands back rows it built + -- itself keeps them, because nothing here rewrites what the hook returned. + -- The second argument is the party MEMBER id (RIVAL2_2_CHIKORITA), which is + -- what picks a roster out of a class in Gen 2 -- Gen 1's numeric index by + -- another name. + if self.trainer and Runtime.wantsHook("trainer.party") then + self.enemyParty = Runtime.call("trainer.party", function(_, _, party) + return party + end, self.trainer.classId or self.trainer.class, + self.trainer.memberId or self.trainer.index or 1, + self.enemyParty) or self.enemyParty + end + self.enemyIndex = Battle.firstHealthy(self.enemyParty) or 1 + self.enemy = self.enemyParty[self.enemyIndex] + end + + -- Battle RAM opens empty on both sides: NewBattleMonStatus and + -- NewEnemyMonStatus run at the first send-out of every battle. + self:clearAllVolatiles() + + self.stages = { + player = Battle.newStages(), + enemy = Battle.newStages(), + } + -- wBattleWeather / wWeatherCount: field state, not per-mon, so it survives a + -- switch on either side. + self.weather = nil + self.weatherTurns = 0 + -- wPlayerScreens / wEnemyScreens SCREENS_SPIKES: laid on the side that will + -- be switching INTO them. + self.spikes = { player = false, enemy = false } + -- The other two wPlayerScreens bits, with their five-turn counts + -- (BattleCommand_Screen / HandleScreens): SIDE state like the spikes, so + -- a switch does not take a screen down. + self.screens = { player = {}, enemy = {} } + + -- The side substrate Gen 1's battle carries (src/battle/BattleState.lua's + -- own self.sides): index 1 is the player's side, index 2 the foe's, and the + -- engine writes nothing into screens/hazards/tokens -- they are the stable + -- shape mods hang their own state on. This is what battle.battler_switched + -- names as `side`, so a mod reading payload.side.index reads the same number + -- it does on Red. `battlers[1]` is kept current by Battle:syncSides. + self.sides = Battle.newSides() + + -- InitEnemyTrainer's tail: a Gym Leader (or an Elite Four member, or the + -- Champion, or Red -- IsGymLeader's list is longer than its name) raises the + -- happiness of every party mon still standing, BEFORE the first turn. You + -- are paid for showing up, not for winning. + if self.trainer and Battle.isGymLeader(self.trainer.class) then + Happiness.changeParty(self.party, "GYMBATTLE") + end + -- battle.started, the payload BattleState:enter emits on Gen 1: `kind` is the + -- battle's shape, `trainerId` the class the fight is against (nil for a wild + -- one), and `species` / `level` the mon standing opposite. The battle object + -- differs between generations and always has -- a mod reads it through the + -- fields it knows, which is why the four scalars are here at all. + Runtime.emit("battle.started", { + battle = self, kind = self.wild and "wild" or "trainer", + trainerId = self.trainer + and (self.trainer.classId or self.trainer.class) or nil, + species = self.enemy and self.enemy.species, + level = self.enemy and self.enemy.level, + -- Gen 2 additions: the wBattleType byte (BATTLETYPE_FORCESHINY and friends, + -- or "fish"), and the roster this trainer brought, held items and all. + battleType = self.battleType, + trainer = self.trainer, + }) + return self +end + +-- The side substrate, kept next to the stage table it sits beside in the +-- constructor. Built lazily by Battle:syncSides as well, so a caller that +-- assembles a battle by hand (the tests drive resolveFaints against a stub) +-- still gets the shape battle.battler_switched reports. +function Battle.newSides() + return { + { index = 1, key = "player", battlers = {}, screens = {}, hazards = {}, + tokens = {} }, + { index = 2, key = "enemy", battlers = {}, screens = {}, hazards = {}, + tokens = {} }, + } +end + +function Battle.newStages() + return { + attack = 0, defense = 0, speed = 0, + specialAttack = 0, specialDefense = 0, + accuracy = 0, evasion = 0, + } +end + +-- The first party member that can actually FIGHT. +-- +-- An EGG has HP and is not fainted, and nothing here used to exclude it -- so +-- carrying the Togepi egg (which the game hands you in Violet City and expects +-- you to keep until it hatches) meant it counted as a battler: the wipe check +-- never fired while the egg was intact, and the game asked you to send an egg +-- out against Morty. The cart cannot: CheckCurPartyMon and the switch menu +-- both refuse an egg, and `wPartyCount` minus the eggs is what decides a +-- whiteout. +function Battle.firstHealthy(party) + for index, mon in ipairs(party or {}) do + if not mon.isEgg and (mon.hp or 0) > 0 then return index end + end + return nil +end + +function Battle:emit(event) + self.events[#self.events + 1] = event + return event +end + +-- Drain the event queue; the screen calls this each time it finishes showing +-- what it already had. +function Battle:takeEvents() + local out = self.events + self.events = {} + return out +end + +-- The one place a battle is decided, so battle.ended is raised exactly once +-- however many times the faint sweep runs over an already-finished battle. +-- Gen 1's payload is { battle, result }; `result` here is the same string +-- Battle.outcome carries, with Gen 2's own two extra outcomes ("fled" for +-- WildFled_EnemyFled, "draw" for a Bug Contest that ran out of balls) beside +-- Gen 1's win / lose / run / caught. +function Battle:endBattle(outcome) + self.over = true + self.outcome = outcome + -- Whoever is still standing leaves the battle as itself: the copy Transform + -- wrote is battle ram on the cart and CleanUpBattleRAM takes it. The screen + -- reaches the same restore through Battle:clearAllVolatiles, but a caller + -- that ends a battle without a screen (every headless test, and the scripted + -- exits) has to leave the party clean too -- Battle.party IS save.party. + self:untransform(self.player) + self:untransform(self.enemy) + if self.endedEmitted then return end + self.endedEmitted = true + Runtime.emit("battle.ended", { battle = self, result = outcome }) +end + +-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the +-- constructor (a headless test injects one, the game leaves it to love.math), +-- and a mod reaching for ctx.rng must not have to know that. Note the +-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's +-- love.math.random (1..n). +function Battle:roller() + if not self.rollerFn then + self.rollerFn = function(n) return rand(self.random, n) end + end + return self.rollerFn +end + +function Battle:sideOf(mon) + return (mon == self.player) and "player" or "enemy" +end + +-- Point each side record at whoever is standing on it, the way Gen 1's +-- BattleState:syncSides does. +function Battle:syncSides() + self.sides = self.sides or Battle.newSides() + self.sides[1].battlers[1] = self.player + self.sides[2].battlers[1] = self.enemy +end + +-- The side RECORD a mon is on (Gen 1's payload shape) rather than the string +-- key the Gen 2 engine indexes its own tables with. +function Battle:sideRecord(mon) + self:syncSides() + return (mon == self.player) and self.sides[1] or self.sides[2] +end + +function Battle:monName(mon) + if not mon then return "?" end + return mon.nickname or mon.name or mon.species or "?" +end + +function Battle:moveDef(moveId) + return self.data.moves and self.data.moves[moveId] or nil +end + +-- The board as AI_Smart reads it. Every field is optional on the AI side, so +-- a value the engine does not model simply never fires its branch. +-- CheckPlayerMoveTypeMatchups (engine/battle/ai/switch.asm). +-- wEnemyAISwitchScore starts at BASE_AI_SWITCH_SCORE and walks down one for +-- every super-effective move the player has ACTUALLY shown against whatever the +-- AI has out; below that base means the player is winning the type war. Both +-- the switch layer and four AI_Smart handlers (ForceSwitch, BatonPass, +-- PerishSong, MeanLook) read this one number, so it lives in one place and the +-- two cannot drift apart. +function Battle:playerMatchupScore() + local score = Ai.BASE_SWITCH_SCORE + local enemyTypes = (self:speciesDef(self.enemy) or {}).types + or self.enemy.types or {} + local matchups = self.data.type_chart and self.data.type_chart.matchups + for _, id in ipairs(self:volatile(self.player).usedMoves or {}) do + local def = self:moveDef(id) + if def and (def.power or 0) > 0 + and Damage.typeMultiplier(def.type, enemyTypes, matchups) > 10 then + score = score - 1 + end + end + return score +end + +-- engine/battle/hidden_power.asm's type table, as the sixteen values +-- (Atk & 3) * 4 + (Def & 3) can take. The routine's `inc a` past NORMAL, its +-- second `inc a` past BIRD and its `add UNUSED_TYPES_END - UNUSED_TYPES` +-- collapse to exactly this list, in this order. +Battle.HIDDEN_POWER_TYPES = { + "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", "STEEL", + "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC_TYPE", "ICE", "DRAGON", "DARK", +} + +-- HiddenPowerDamage: Hidden Power's real base power (31..70) and type come from +-- the user's DVs, not from the move table's stub. Returns nil, nil when the +-- mon carries no DVs to read, which is the only honest answer for a mon the +-- fixtures built by hand. +-- +-- The power byte takes the TOP bit of each of the four DVs (`and %1000`), NOT +-- the low bits Mon.hpDV builds the HP DV out of, so this cannot borrow that +-- helper. +function Battle:hiddenPower(mon) + local dvs = mon and mon.dvs + if not dvs then return nil, nil end + local function high(value) return math.floor((value or 0) / 8) % 2 end + local bits = high(dvs.attack) * 8 + high(dvs.defense) * 4 + + high(dvs.speed) * 2 + high(dvs.special) + local power = math.floor((bits * 5 + (dvs.special or 0) % 4) / 2) + 31 + local index = ((dvs.attack or 0) % 4) * 4 + ((dvs.defense or 0) % 4) + return power, Battle.HIDDEN_POWER_TYPES[index + 1] +end + +-- Everything the AI_Smart layer reads, gathered once per enemy decision. A +-- field the engine cannot answer honestly is simply left nil, and the matching +-- handler branch never fires: see the "read but never produced" list in Ai.lua. +function Battle:smartAiState() + local enemyState = self:volatile(self.enemy) + local playerState = self:volatile(self.player) + local chart = self.data.type_chart + local typeTable = chart and chart.types + local matchups = chart and chart.matchups + + -- AIHasMoveEffect walks the enemy's list by effect; AIHasMoveInArray (the + -- weather moves) matches raw move IDS, so both shapes are built here. + local known, ids = {}, {} + for _, move in ipairs(self.enemy.moves or {}) do + local def = self:moveDef(move.id) + if def and def.effect then known[def.effect] = true end + ids[move.id] = true + end + + -- wPlayerUsedMoves, read three ways: AI_Smart_Counter counts the physical + -- damaging entries, AI_Smart_MirrorCoat the special ones, and + -- AI_Smart_RazorWind wants the EFFECTS behind them (it dismisses itself on + -- EFFECT_PROTECT). + local physical, special = 0, 0 + local usedEffects = {} + for _, id in ipairs(playerState.usedMoves or {}) do + local def = self:moveDef(id) + if def then + if def.effect then usedEffects[def.effect] = true end + if (def.power or 0) > 0 then + if Damage.isPhysical(def.type, typeTable) then + physical = physical + 1 + else + special = special + 1 + end + end + end + end + + local enemyTypes = (self:speciesDef(self.enemy) or {}).types + or self.enemy.types or {} + local playerTypes = (self:speciesDef(self.player) or {}).types + or self.player.types or {} + -- `cp SPECIAL` against wBattleMonType1/2: AI_Smart_SpDefenseUp2 and + -- AI_Smart_Curse ask the same question, "is EITHER player type special". + -- nil rather than false when the types are unknown, so the branch stays shut. + local playerSpecialType + for _, name in ipairs(playerTypes) do + if not Damage.isPhysical(name, typeTable) then playerSpecialType = true end + end + + -- The player's own ramp. The port keeps ONE counter pair for Rollout and + -- Fury Cutter, so the loaded move is what tells wPlayerFuryCutterCount from + -- SUBSTATUS_ROLLOUT apart. + local rampDef = playerState.rampMove and self:moveDef(playerState.rampMove) + local rampEffect = rampDef and rampDef.effect + + -- wLastPlayerCounterMove: what Spite drains, what Mimic would copy (the cart + -- sets hBattleTurn to 1, so the matchup defends with the PLAYER's own types) + -- and what Mirror Coat's tail tests. + local lastId = playerState.lastMove + local lastDef = lastId and self:moveDef(lastId) + local lastEntry = lastId and self:findMove(self.player, lastId) + + -- AI_Smart_LockOn's `.checkmove`: a move worth aiming, meaning one under + -- `71 percent - 1` ($b4) raw accuracy whose type is at least neutral against + -- the player. Explicitly false when the loop found nothing, since that is + -- the case the cart discourages on. + local aimable = false + for _, move in ipairs(self.enemy.moves or {}) do + local def = self:moveDef(move.id) + if def and (def.accuracyRaw or 255) < 0xb4 + and Damage.typeMultiplier(def.type, playerTypes, matchups) >= 10 then + aimable = true + end + end + + -- AI_Smart_HealBell ORs the status byte of every unfainted mon in wOTParty, + -- the active one included. + local partyStatus = false + for _, mon in ipairs(self.enemyParty or {}) do + if (mon.hp or 0) > 0 and mon.status then partyStatus = true end + end + + -- FindAliveEnemyMons and AICheckLastPlayerMon: both skip the mon that is out + -- and ask whether anything is left behind it. + local enemyHasBench = false + for index, mon in ipairs(self.enemyParty or {}) do + if index ~= self.enemyIndex and (mon.hp or 0) > 0 then + enemyHasBench = true + end + end + local playerLastMon = true + for index, mon in ipairs(self.party or {}) do + if index ~= self.playerIndex and (mon.hp or 0) > 0 then + playerLastMon = false + end + end + + local hiddenPowerPower, hiddenPowerType = self:hiddenPower(self.enemy) + + return { + enemyHp = self.enemy.hp, + enemyMaxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp, + playerHp = self.player.hp, + playerMaxHp = self.player.maxHp or (self.player.stats or {}).hp, + enemyLevel = self.enemy.level, playerLevel = self.player.level, + enemyFaster = self:effectiveSpeed(self.enemy) + > self:effectiveSpeed(self.player), + enemyStatus = self.enemy.status, playerStatus = self.player.status, + enemyTurns = enemyState.turnsTaken or 0, + playerTurns = playerState.turnsTaken or 0, + stages = self.stages.enemy, playerStages = self.stages.player, + playerToxic = self.player.status == "toxic", + playerLeechSeed = playerState.leechSeed, + playerCharged = playerState.chargeMove ~= nil, + playerFlying = playerState.vanished, + playerLastMove = lastId, + -- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since + -- BattleCommand_LockOn sets the bit on the target it was aimed at. + playerLockOn = playerState.lockOn or nil, + playerPhysicalMoves = physical, + enemyRage = enemyState.rage, + enemyRageCount = enemyState.rageCount, + enemyProtectCount = enemyState.protectCount, + enemyFuryCutterCount = enemyState.rampCount, + enemyConfused = enemyState.confuseCount ~= nil, + -- wPlayerWrapCount and SUBSTATUS_CURSE, live now that the trap and + -- curse volatiles are modelled. + playerTrapped = ((playerState.wrapCount or 0) > 0) or nil, + playerCursed = playerState.cursed or nil, + knownEffects = known, + enemyMoveIds = ids, + + -- Types, IN SLOT ORDER: the weather handlers read slot 1 before slot 2 and + -- a swapped pair scores differently, so this is never sorted. + enemyTypes = enemyTypes, + playerTypes = playerTypes, + playerSpecialType = playerSpecialType, + + playerMatchupScore = self:playerMatchupScore(), + playerSpecialMoves = special, + playerUsedEffects = usedEffects, + + -- SUBSTATUS_FLYING and SUBSTATUS_UNDERGROUND split apart. Both Fly and Dig + -- carry EFFECT_FLY in Gen 2, so the vanish flag alone is ambiguous and the + -- stored move is what separates them; playerFlying above stays the combined + -- mask AI_Smart_Fly and AI_Smart_FutureSight want. + playerFlyingUp = (playerState.vanished + and playerState.chargeMove == "FLY") or nil, + playerUnderground = (playerState.vanished + and playerState.chargeMove == "DIG") or nil, + + playerFuryCutter = (rampEffect == "EFFECT_FURY_CUTTER") + and (playerState.rampCount or 0) or nil, + playerRollout = (rampEffect == "EFFECT_ROLLOUT") or nil, + + playerLastMovePp = lastEntry and lastEntry.pp or nil, + playerLastMoveMatchup = lastDef + and Damage.typeMultiplier(lastDef.type, playerTypes, matchups) or nil, + playerLastMoveSpecial = lastDef ~= nil + and not Damage.isPhysical(lastDef.type, typeTable) or nil, + playerLastMon = playerLastMon, + + enemyToxic = self.enemy.status == "toxic", + enemyLeechSeed = enemyState.leechSeed, + enemySpikes = self.spikes and self.spikes.enemy or nil, + enemyPerishCount = enemyState.perish, + -- wEnemyMonStatus & SLP_MASK, on the cart's own scale: Battle:canAct + -- decrements statusTurns and clears the status at zero, so a value of 1 is + -- exactly the `cp 1` last sleeping turn. Always a number, never nil, or + -- AI_Smart_Snore scores nothing at all. + enemySleepTurns = (self.enemy.status == "sleep") + and (self.enemy.statusTurns or 0) or 0, + enemyPartyStatus = partyStatus, + enemyHasBench = enemyHasBench, + enemyInaccurateEffectiveMove = aimable, + + hiddenPowerPower = hiddenPowerPower, + hiddenPowerMatchup = hiddenPowerType + and Damage.typeMultiplier(hiddenPowerType, playerTypes, matchups) or nil, + + weather = self.weather, + } +end + +-- Every move id the cache knows, for Metronome. Sorted so the pick is +-- reproducible from a seeded roll rather than from Lua's hash order. +function Battle:moveOrder() + if self._moveOrder then return self._moveOrder end + local out = {} + for id, def in pairs(self.data.moves or {}) do + if type(def) == "table" and def.power ~= nil then out[#out + 1] = id end + end + table.sort(out) + self._moveOrder = out + return out +end + +function Battle:speciesDef(mon) + return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil +end + +-- One badge, read the way FieldMoves.hasBadge reads it: save.player.badges / +-- save.player.kantoBadges keyed by name, with the bit position accepted as a +-- fallback key so the two readers cannot disagree about who owns what. +function Battle:hasBadge(store, badge) + local player = self.save and self.save.player + local owned = player and player[store] + if type(owned) ~= "table" then return false end + if owned[badge] then return true end + local order = store == "kantoBadges" and Battle.KANTO_BADGE_ORDER + or Battle.JOHTO_BADGE_ORDER + for index, name in ipairs(order) do + if name == badge then return owned[index] == true end + end + return false +end + +-- BoostStat (engine/battle/core.asm:6590): raise a stat by 1/8, capped at +-- MAX_STAT_VALUE (999). The eighth is a plain shift, so a stat under 8 +-- gains nothing. +function Battle.boostStat(value) + return math.min(999, value + math.floor(value / 8)) +end + +-- BadgeStatBoosts' buggy tail: the Special Defense re-check does `srl a` +-- assuming `a` still holds the badge bits, but when GlacierBadge fired for +-- Special Attack the preceding BoostStat overwrote `a` with its cap-check +-- arithmetic. So with Glacier owned, whether SpDef is ALSO boosted depends +-- on the boosted Special Attack value: +-- * at or past the 999 cap, `a` leaves as LOW(999) = $e7, odd: boosted +-- * otherwise `a` is high(v) - 3 - borrow, where the borrow is set when +-- low(v) < LOW(999); the shifted-out low bit decides +-- (pokegold's own comment at core.asm:6584 marks the check buggy.) +function Battle.glacierBoostsSpDef(boostedSpAtk) + local v = boostedSpAtk or 0 + if v >= 999 then return true end + local borrow = (v % 256) < 231 and 1 or 0 + local a = (math.floor(v / 256) - 3 - borrow) % 256 + return a % 2 == 1 +end + +-- The stat a hit actually reads: the party stat, plus the player-side badge +-- boost. BadgeStatBoosts runs against wBattleMon (the PLAYER's active mon +-- only, never the enemy and never in link), so the boost is applied here at +-- read time rather than mutating mon.stats, which IS the party slot in this +-- port and must survive the battle unboosted. +function Battle:battleStat(mon, key) + local value = (mon.stats or {})[key] or 1 + if mon ~= self.player then return value end + local badge = Battle.BADGE_STAT_BOOSTS[key] + if badge and self:hasBadge("badges", badge) then + return Battle.boostStat(value) + end + if key == "specialDefense" and self:hasBadge("badges", "GLACIER") then + local spAtk = Battle.boostStat((mon.stats or {}).specialAttack or 1) + if Battle.glacierBoostsSpDef(spAtk) then + return Battle.boostStat(value) + end + end + return value +end + +-- DoBadgeTypeBoosts (engine/battle/misc.asm:146): player's turn only, and +-- the first owned badge whose BadgeTypeBoosts row matches the move's type +-- 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 + for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do + if row.type == moveType then + return self:hasBadge(row.store, row.badge) + end + end + return false +end + +-- The screen guarding this defender against this KIND of hit, the way +-- DamageStats consults wEnemyScreens/wPlayerScreens: Reflect doubles the +-- defending side's Defense against a physical move, Light Screen its +-- Special Defense against a special one. +function Battle:screenActive(defender, physical) + local side = self.screens and self.screens[self:sideOf(defender)] + if not side then return false end + local turns = physical and (side.reflect or 0) or (side.lightScreen or 0) + return turns > 0 +end + +-- GetUserItem's b/c pair: the held effect id and its parameter out of +-- ItemAttributes, or nil/0 for an empty hand. +-- +-- held_item.trigger, the most load-bearing of the names Gen 2 invents: Gen 1 +-- has no held items at all, so there is no name to share. It wraps this +-- function rather than each of the eight places an item acts, because on the +-- cart those eight places are all one routine -- GetUserItem / GetOpponentItem +-- loading b and c and the caller comparing b against the HELD_* it cares about +-- -- and `trigger` says which comparison is about to happen: +-- +-- "priority" DetermineMoveOrder's .equal_priority (Quick Claw) +-- "damage" DamageCalc's crit ladder and .DoneItem type boost (Scope Lens, +-- the HELD_<TYPE>_BOOST family) +-- "endure" the 1 HP clamp (Focus Band) +-- "flinch" the post-hit flinch roll (King's Rock) +-- "accuracy" BattleCommand_CheckHit's .BrightPowder +-- "confuse" the confusion gate (HELD_PREVENT_CONFUSE) +-- "residual" Battle:tickHeldItem, the end-of-turn Leftovers/Berry/cure arm +-- "check" any other read; nothing in the engine passes this today +-- +-- ctx: battle, mon, item (the item id), def (its record), effect, parameter, +-- trigger. Vanilla answers `ctx.effect, ctx.parameter`, so a chain that wants +-- the item to do nothing at this trigger returns nil and one that wants a +-- different behaviour returns another HELD_* name -- the call sites all +-- compare against a name, so substitution is the whole mechanism. A returned +-- effect that is not a string is read as "no effect"; the parameter falls back +-- to the item's own rather than to 0, because 0 is a meaningful parameter +-- (a 0% BrightPowder) and a mod that only wanted to rename the effect should +-- not silently lose the number. +function Battle:heldEffect(mon, trigger) + local def = self:itemDef(mon and mon.item) + local effect = def and def.heldEffect or nil + local parameter = (def and def.heldParameter) or 0 + if not Runtime.wantsHook("held_item.trigger") then return effect, parameter end + local hookedEffect, hookedParameter = Runtime.call("held_item.trigger", + function(c) return c.effect, c.parameter end, + { battle = self, mon = mon, item = mon and mon.item, def = def, + effect = effect, parameter = parameter, trigger = trigger or "check" }) + if type(hookedEffect) ~= "string" then return nil, 0 end + return hookedEffect, tonumber(hookedParameter) or parameter +end + +-- Effective Speed for ordering: stat stages, then the paralysis quarter. +function Battle:effectiveSpeed(mon) + local stages = self.stages[self:sideOf(mon)] + local speed = Damage.applyStage(self:battleStat(mon, "speed"), stages.speed) + return Battle.statusPenaltyFor(self.data, mon, "speed", speed) +end + +-- DetermineMoveOrder: faster side first, a coin flip on a tie. Priority comes +-- from the move (Quick Attack and friends) and beats Speed outright. +function Battle:orderOf(playerMove, enemyMove) + local playerPriority = self:movePriority(playerMove) + local enemyPriority = self:movePriority(enemyMove) + if playerPriority ~= enemyPriority then + return playerPriority > enemyPriority and "player" or "enemy" + end + -- HELD_QUICK_CLAW (engine/battle/core.asm `.equal_priority`): consulted + -- only once priority ties, ahead of the Speed compare. One byte against + -- the item's parameter (60 -> 60/256). When both sides hold one the + -- ENEMY's roll goes first, exactly as the non-link `.both_have_quick_claw` + -- arm orders them. + local playerEffect, playerParam = self:heldEffect(self.player, "priority") + local enemyEffect, enemyParam = self:heldEffect(self.enemy, "priority") + local playerClaw = playerEffect == "HELD_QUICK_CLAW" + local enemyClaw = enemyEffect == "HELD_QUICK_CLAW" + if playerClaw and enemyClaw then + if rand(self.random, 256) < enemyParam then return "enemy" end + if rand(self.random, 256) < playerParam then return "player" end + elseif playerClaw then + if rand(self.random, 256) < playerParam then return "player" end + elseif enemyClaw then + if rand(self.random, 256) < enemyParam then return "enemy" end + end + local playerSpeed = self:effectiveSpeed(self.player) + local enemySpeed = self:effectiveSpeed(self.enemy) + if playerSpeed ~= enemySpeed then + return playerSpeed > enemySpeed and "player" or "enemy" + end + return rand(self.random, 2) == 0 and "player" or "enemy" +end + +-- Gen 2 priority moves. data/moves/effects_priorities.asm keys off the move +-- *effect*, so a modded move inherits the priority of whatever it copies. +Battle.PRIORITY = { + EFFECT_PRIORITY_HIT = 1, -- Quick Attack, Mach Punch + EFFECT_PROTECT = 3, + EFFECT_ENDURE = 3, + EFFECT_COUNTER = -1, + EFFECT_MIRROR_COAT = -1, + EFFECT_VITAL_THROW = -1, +} + +function Battle:movePriority(moveId) + local def = self:moveDef(moveId) + return (def and Battle.PRIORITY[def.effect]) or 0 +end + +-- Can this mon act? Returns true, or false plus the message the cart prints. +function Battle:canAct(mon) + local name = self:monName(mon) + -- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads + -- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon + -- that is both recharging and asleep spends this turn recharging. + local vol = self:volatile(mon) + if vol.recharge then + vol.recharge = nil + self:emit({ kind = "message", text = name .. " must recharge!" }) + return false + end + -- The status arms, through the merged record. beforeMovePriority is what + -- puts sleep (40) and freeze (30) ahead of the flinch/confusion block and + -- paralysis (10) after it, the way CheckPlayerTurn orders them; the high + -- arms answer for the whole turn (a mon that woke up does not then get + -- asked about flinching) and the low one falls through when it lets the + -- move go. + local record = Battle.statusRecordFor(self.data, mon.status) + local beforeMove = record and record.beforeMove + if beforeMove + and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then + return beforeMove(self, mon, name) and true or false + end + -- SUBSTATUS_FLINCHED, read and cleared right after the freeze check + -- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the + -- opponent's HELD_FLINCH item (King's Rock) -- and the EFFECT_FLINCH_HIT + -- moves once they write the same flag. + if vol.flinched then + vol.flinched = nil + self:emit({ kind = "message", text = name .. " flinched!" }) + return false + end + -- SUBSTATUS_CONFUSED (CheckPlayerTurn past `.not_flinched`): the count + -- decrements FIRST and zero snaps out -- the mon still acts that turn. + -- While it holds, one byte under 50 percent + 1 spends the turn on + -- HitConfusion's self-hit instead. + if vol.confuseCount then + vol.confuseCount = vol.confuseCount - 1 + if vol.confuseCount <= 0 then + vol.confuseCount = nil + self:emit({ kind = "message", text = name .. "'s confused no more!" }) + else + self:emit({ kind = "message", text = name .. " is confused!" }) + if rand(self.random, 256) < 128 then + self:confusionSelfHit(mon) + return false + end + end + end + if beforeMove then + return beforeMove(self, mon, name) and true or false + end + return true +end + +-- STRUGGLE, the move a mon with nothing left to spend falls back to +-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and +-- `.struggle` for the enemy). It lives in the move table like any other move +-- -- typeless-in-practice NORMAL, 50 power, EFFECT_RECOIL_HIT -- and is +-- deliberately NOT in anyone's move list, which is why useMove's PP guard is +-- written `if move and ...`: findMove returns nil for it and the guard is +-- skipped rather than tripped. +Battle.STRUGGLE = "STRUGGLE" + +-- .LockOn's three exceptions against a flying target +-- (engine/battle/effect_commands.asm:1683-1688). +Battle.LOCK_ON_GROUND_MOVES = { EARTHQUAKE = true, FISSURE = true, + MAGNITUDE = true } + +-- .CheckPlayerHasUsableMoves skips the disabled slot (engine/battle/core.asm:5290-5305). +function Battle:hasUsableMoves(mon) + local disabled = mon and mon.volatile and mon.volatile.disabled + for _, move in ipairs((mon and mon.moves) or {}) do + if (move.pp or 0) > 0 and move.id ~= disabled then return true end + end + return false +end + +function Battle:findMove(mon, moveId) + for _, move in ipairs(mon.moves or {}) do + if move.id == moveId then return move end + end + return nil +end + +-- A "state" is the per-mon volatile bookkeeping a turn needs: the charge a +-- two-turn move is midway through, a Substitute's remaining HP, the counters +-- Rollout and Fury Cutter ramp on, and what the mon took this turn so Counter +-- and Mirror Coat have something to answer. It hangs off the mon rather than +-- the battle so a switch takes it away, which is what the cart does. +function Battle:volatile(mon) + mon.volatile = mon.volatile or {} + return mon.volatile +end + +-- Clears everything a switch clears (ResetBattleParticipants / SwitchOutMon). +-- +-- SwitchOutMon reloads the battle struct from the party slot, which is what +-- takes a Transform down with the switch; the port's one-table-per-mon shape +-- makes that a restore rather than a reload (Battle:untransform). It has to +-- happen HERE and not only at the switch sites, because CleanUpBattleRAM at +-- the end of the battle runs through Battle:clearAllVolatiles -- and for a +-- wild catch that table is already sitting in the player's party. +function Battle:clearVolatile(mon) + if not mon then return end + self:untransform(mon) + mon.volatile = nil +end + +-- The cart keeps every substatus in battle RAM (wPlayerSubStatus1-5), which +-- NewBattleMonStatus zeroes at each send-out and CleanUpBattleRAM zeroes on +-- the way out of the battle. This port hangs the same bookkeeping off the mon +-- record, and Battle.party IS save.party, so nothing a battle wrote may be +-- left on a party table: an X item's bit, a confusion count or a wrap counter +-- would otherwise be written to the save file and read back by the next +-- battle, where DIRE HIT is then refused forever as an already-set bit. +function Battle:clearAllVolatiles() + for _, mon in ipairs(self.party or {}) do self:clearVolatile(mon) end + for _, mon in ipairs(self.enemyParty or {}) do self:clearVolatile(mon) end + self:clearVolatile(self.player) + self:clearVolatile(self.enemy) +end + +-- A battle.damage chain may be a Gen 1 mod, which returns Gen 1's info table +-- ({ crit, typeMult }) rather than Gen 2's ({ critical, effectiveness, ... }). +-- The two names mean the same thing in both generations, so read either -- +-- src/battle/gen2/Damage.lua answers to both for the same reason. +local function normalizeDamageInfo(info) + if type(info) ~= "table" then return info end + if info.critical == nil and info.crit ~= nil then info.critical = info.crit end + if info.effectiveness == nil and info.typeMult ~= nil then + info.effectiveness = info.typeMult + end + return info +end + +-- One damaging hit. Returns the damage actually dealt (0 when the move did +-- not connect at all), so recoil, drain and Counter all read the same number. +function Battle:hitOnce(attacker, defender, def, opts) + opts = opts or {} + local attackerStages = self.stages[self:sideOf(attacker)] + local defenderStages = self.stages[self:sideOf(defender)] + local types = self.data.type_chart and self.data.type_chart.types + local matchups = self.data.type_chart and self.data.type_chart.matchups + + local heldEffect, heldParam = self:heldEffect(attacker, "damage") + -- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a + -- DIRE HIT) and HELD_CRITICAL_UP (Scope Lens) each raise the ladder a + -- rung; a high-crit move raises it two. + local criticalLevel = Damage.criticalLevel({ + highCritMove = def.effect == "EFFECT_ALWAYS_CRIT", + focusEnergy = self:volatile(attacker).focusEnergy, + scopeLens = heldEffect == "HELD_CRITICAL_UP", + }) + -- battle.crit, the same hook src/battle/Damage.lua calls on Gen 1, with the + -- same ctx keys: a mod that forces or refuses criticals reads `attacker`, + -- `moveId` and `highCrit` exactly where it did on Red. `ruleset` has no Gen 2 + -- counterpart (Gold's engine IS the ruleset) so it is absent rather than + -- invented, and `criticalLevel` is the Gen 2 addition -- the rung of + -- data/battle/critical_hit_chances.asm this hit reached, which Gen 1's + -- base-Speed derivation had no equivalent of. + local critical + if Runtime.wantsHook("battle.crit") then + critical = Runtime.call("battle.crit", function(c) + return Damage.rollCritical(c.criticalLevel, c.battle.random) + end, { battle = self, attacker = attacker, moveId = opts.moveId or def.id, + rng = self:roller(), random = self.random, + highCrit = def.effect == "EFFECT_ALWAYS_CRIT", + criticalLevel = criticalLevel }) + else + critical = Damage.rollCritical(criticalLevel, self.random) + end + local attack = self:battleStat(attacker, "attack") + -- Burn halves physical Attack (Gen 2 does this in DamageStats), off the + -- status record's statPenalty. + attack = Battle.statusPenaltyFor(self.data, attacker, "attack", attack) + -- The HELD_<TYPE>_BOOST items (Charcoal, Mystic Water, ...): the item's + -- parameter is the percent boost DamageCalc's .DoneItem applies when the + -- held type matches the move's. PSYCHIC's type id is PSYCHIC_TYPE in the + -- port's chart, so the effect name is rebuilt from the move type. + local itemBoost + if def.type and heldEffect then + local wanted = "HELD_" .. (def.type == "PSYCHIC_TYPE" and "PSYCHIC" + or def.type) .. "_BOOST" + if heldEffect == wanted then itemBoost = heldParam end + end + local calcOpts = { + level = attacker.level or 1, + power = opts.power or def.power, + moveType = def.type, + attacker = { + attack = attack, + specialAttack = self:battleStat(attacker, "specialAttack"), + types = (self:speciesDef(attacker) or {}).types or attacker.types, + stages = attackerStages, + }, + defender = { + defense = self:battleStat(defender, "defense"), + specialDefense = self:battleStat(defender, "specialDefense"), + types = (self:speciesDef(defender) or {}).types or defender.types, + stages = defenderStages, + }, + types = types, + matchups = matchups, + critical = critical, + itemBoostPercent = itemBoost, + -- DoWeatherModifiers, the first thing BattleCommand_Stab farcalls + -- (effect_commands.asm:1254): rain boosts Water and cuts Fire, sun the + -- reverse, and rain cuts Solarbeam by its EFFECT rather than its type. + -- Scaled to the cart's tenths here so Damage.calc can apply it where the + -- cart does, ahead of the badge boost, STAB, the type rows and the roll. + weatherPercent = math.floor( + Effects.weatherModifier(self.weather, def.type, def.effect) * 10), + -- DoBadgeTypeBoosts, farcalled between the weather modifiers and STAB. + badgeTypeBoost = self:badgeTypeBoost(attacker, def.type), + -- SCREENS_REFLECT / SCREENS_LIGHT_SCREEN on the defending side double + -- the matching defence (the crit exemption lives in Damage.calc). + screen = self:screenActive(defender, + Damage.isPhysical(def.type, types)), + -- BattleCommand_DamageCalc's `srl c` (effect_commands.asm:2905-2913). + defenseHalved = def.effect == "EFFECT_SELFDESTRUCT", + random = self.random, + } + -- battle.damage, the same hook BattleState:computeDamage calls on Gen 1 and + -- with the same ctx keys: `user`, `target`, `move` and the `opts` table the + -- formula is actually run on, so a mod that edits c.opts (or returns its own + -- number) works the same way it does on Red. Gen 2's opts carry more than + -- Gen 1's -- the split special stats live inside c.opts.attacker / + -- c.opts.defender, and the weather, badge and held-item modifiers are there + -- as their own fields. `ruleset` is absent for the reason given on + -- battle.crit above. The ctx table is only built when a chain is installed, + -- so a mod-free boot pays nothing. + local damage, info + if Runtime.wantsHook("battle.damage") then + damage, info = Runtime.call("battle.damage", function(c) + return Damage.calc(c.opts) + end, { battle = self, user = attacker, target = defender, move = def, + moveId = opts.moveId or def.id, opts = calcOpts, + rng = self:roller(), random = self.random }) + info = normalizeDamageInfo(info) or { effectiveness = 10 } + damage = damage or 0 + else + damage, info = Damage.calc(calcOpts) + end + + if info.effectiveness == 0 then + -- BattleCommand_Stab's `.GotMatchup` arm writes wAttackMissed when the + -- matchup byte is 0 (effect_commands.asm:1337), and `stab` runs ahead of + -- `moveanim` in every damaging effect list (data/moves/effects.asm:5), so + -- BattleCommand_MoveAnimNoSub's wAttackMissed early-out (:1958) turns an + -- immune hit into MoveDelay and no animation at all. + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return 0, info + end + -- BattleCommand_FalseSwipe (engine/battle/move_effects/false_swipe.asm): + -- wCurDamage is capped at the target's HP minus one before applydamage, so + -- the move can never KO -- the clamp that makes it a safe catching tool + -- against a mon (a roamer above all) a win would retire. + if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then + damage = math.max(0, (defender.hp or 0) - 1) + end + return self:dealDamage(attacker, defender, damage, { + critical = critical, effectiveness = info.effectiveness, + -- Counter answers physical damage and Mirror Coat special, so what kind + -- of hit this was has to be recorded with it. + kind = Damage.isPhysical(def.type, types) and "physical" or "special", + -- Carried only so battle.damage_dealt can name the move, the way Gen 1's + -- EffectRegistry damage loop does. + move = def, moveId = opts.moveId or def.id, + }), info +end + +-- Applies damage, routing it through the target's Substitute first: a +-- Substitute soaks the whole hit and breaks when it runs out +-- (BattleCommand_SubstituteFadeIfDead), so the mon behind it never loses HP. +function Battle:dealDamage(attacker, defender, damage, opts) + opts = opts or {} + damage = math.max(0, math.floor(damage or 0)) + local state = self:volatile(defender) + if (state.substitute or 0) > 0 then + local absorbed = math.min(state.substitute, damage) + state.substitute = state.substitute - absorbed + self:emit({ kind = "message", + text = "The SUBSTITUTE took damage for " .. self:monName(defender) .. "!" }) + if state.substitute <= 0 then + state.substitute = nil + self:emit({ kind = "message", + text = self:monName(defender) .. "'s SUBSTITUTE broke!" }) + end + return absorbed + end + + local defenderState = self:volatile(defender) + -- Endure leaves the holder on one hit point, however big the hit was. + -- BattleCommand_ApplyDamage calls BattleCommand_FalseSwipe unconditionally + -- for the Endure bit and FalseSwipe clamps wCurDamage to MonHP - 1, so a mon + -- braced at exactly 1 HP takes zero and still holds. + -- HELD_FOCUS_BAND rides the same clamp: the band is only consulted once + -- Endure is down, rolling one byte against the item parameter + -- (30 -> 30/256) and reusing the False Swipe clamp on success. + local endured, hungOn = false, false + if defenderState.endure and damage >= (defender.hp or 0) + and (defender.hp or 0) > 0 then + damage = (defender.hp or 0) - 1 + endured = true + elseif damage >= (defender.hp or 0) and (defender.hp or 0) > 0 then + local effect, parameter = self:heldEffect(defender, "endure") + if effect == "HELD_FOCUS_BAND" + and rand(self.random, 256) < parameter then + damage = (defender.hp or 0) - 1 + hungOn = true + end + end + defender.hp = math.max(0, (defender.hp or 0) - damage) + defenderState.tookThisTurn = (defenderState.tookThisTurn or 0) + damage + defenderState.tookKind = opts.kind or "physical" + -- Bide stores everything the user takes while it is counting down. + if defenderState.bideTurns then + defenderState.bideStored = (defenderState.bideStored or 0) + damage + end + self:emit({ + kind = "damage", side = self:sideOf(defender), + amount = damage, hp = defender.hp, critical = opts.critical, + effectiveness = opts.effectiveness, + }) + if opts.critical then + self:emit({ kind = "message", text = "A critical hit!" }) + end + if opts.effectiveness and opts.effectiveness > 10 then + self:emit({ kind = "message", text = "It's super effective!" }) + elseif opts.effectiveness and opts.effectiveness < 10 then + self:emit({ kind = "message", text = "It's not very effective..." }) + end + if endured then + self:emit({ kind = "message", + text = self:monName(defender) .. " endured the hit!" }) + elseif hungOn then + -- HungOnText, named after the item the way the cart pipes it through + -- wStringBuffer1. + local def = self:itemDef(defender.item) + self:emit({ kind = "message", + text = self:monName(defender) .. " hung on with " + .. ((def and def.name) or "FOCUS BAND") .. "!" }) + end + -- SUBSTATUS_RAGE: being hit while raging raises the rager's Attack. + if defenderState.rage and damage > 0 and (defender.hp or 0) > 0 then + self:changeStage(defender, "attack", 1) + end + -- battle.damage_dealt, the payload src/battle/EffectRegistry.lua emits once + -- per landed hit on Gen 1, guarded the same way so an unsubscribed boot + -- builds nothing. `typeMult` is the x10 type multiplier under Gen 1's name; + -- Gen 2's own name for the same number is `effectiveness`, and both are here. + -- `move` is nil for the damage no move owns (Counter's answer, Future Sight's + -- delayed hit, spikes), which is a Gen 2 shape Gen 1 has no site for. + if Runtime.wants("battle.damage_dealt") then + Runtime.emit("battle.damage_dealt", { + battle = self, user = attacker, target = defender, + move = opts.move, moveId = opts.moveId, + damage = damage, crit = opts.critical or false, + typeMult = opts.effectiveness or 10, + -- Gen 2 additions: which side took it and whether the hit was physical + -- or special, which is what Counter and Mirror Coat answer. + effectiveness = opts.effectiveness or 10, + side = self:sideOf(defender), kind = opts.kind, + }) + end + return damage +end + +function Battle:heal(mon, amount) + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 + local before = mon.hp or 0 + mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0))) + local healed = mon.hp - before + if healed > 0 then + self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed, + hp = mon.hp }) + end + return healed +end + +-- move_effects/selfdestruct.asm:6-12: the user's status and both HP bytes are +-- zeroed, and the user's Leech Seed goes with them. +function Battle:selfdestructUser(attacker) + local lost = attacker.hp or 0 + attacker.status, attacker.statusTurns = nil, nil + attacker.toxicCounter = nil + attacker.hp = 0 + self:volatile(attacker).leechSeed = nil + -- The move carries ONE after-anim for the whole thing + -- (move_effects/selfdestruct.asm:2-3), and the target's hit already plays it. + self:emit({ kind = "damage", side = self:sideOf(attacker), + amount = lost, hp = 0, anim = false }) +end + +-- One stat change, with the cart's own message (or its refusal). +function Battle:changeStage(target, stat, stages) + local applied = Effects.applyStage(self.stages[self:sideOf(target)], stat, + stages) + local name = self:monName(target) + if not applied then + -- WontRiseAnymoreText / WontDropAnymoreText (data/text/battle.asm:718-732). + self:emit({ kind = "message", text = ("%s's %s won't %s anymore!"):format( + name, Effects.STAT_NAMES[stat] or stat, + stages > 0 and "rise" or "drop") }) + return false + end + self:emit({ kind = "stage", side = self:sideOf(target), stat = stat, + stages = applied, text = Effects.stageMessage(name, stat, applied) }) + return true +end + +-- wAttackMissed, modelled on the event the screen animates off. Every path +-- that sets it (CheckHit's .Miss arms and the effect commands' own `.failed` +-- tails, which reach AnimateFailedMove: a delay and no animation) marks the +-- move event, and the screen skips the attack animation for a marked one -- +-- BattleCommand_MoveAnimNoSub, engine/battle/effect_commands.asm:1958. +function Battle:markMissed() + if self.moveEvent then self.moveEvent.missed = true end +end + +-- One attack, start to finish. +function Battle:useMove(attacker, defender, moveId) + local move = self:findMove(attacker, moveId) + local def = self:moveDef(moveId) + local name = self:monName(attacker) + local state = self:volatile(attacker) + -- wAttackMissed is per-move: BattleTurn's ResetTurn clears it before the + -- effect list runs, so nothing a previous move set can reach this one. + self.moveEvent = nil + if not def then + self:emit({ kind = "message", text = name .. " has no move to use!" }) + return + end + + -- A mon locked into the second half of a two-turn move spends no PP and + -- makes no new choice: it just lands the stored attack. + local charging = state.chargeMove == moveId + if charging then + state.chargeMove = nil + state.vanished = nil + end + + -- BattleCommand_CheckRampage (effect_commands.asm:4851) is the FIRST command + -- in the Rampage list, ahead of checkobedience and doturn, and + -- SkipToBattleCommand leaves the script pointer PAST the command it looked + -- for (:6674-6689) -- so a continuing Thrash or Petal Dance spends no PP, + -- makes no obedience check and never re-rolls its count. The counter runs + -- down here; when it reaches zero the lock ends and the user is confused, + -- and the move STILL resolves this turn (`.continue_rampage`). + local rampaging = def.effect == "EFFECT_RAMPAGE" + and state.rampageMove == moveId and (state.rampageTurns or 0) > 0 + if rampaging then + state.rampageTurns = state.rampageTurns - 1 + if state.rampageTurns <= 0 then + state.rampageMove, state.rampageTurns = nil, nil + -- CheckRampage writes SUBSTATUS_CONFUSED and the count itself rather + -- than calling FinishConfusingTarget, so there is no text, no + -- Substitute test and no HELD_PREVENT_CONFUSE test -- just the same + -- `and %00000001` plus two roll, 2 or 3 turns. The cart's one + -- exemption is the user's own Safeguard, which this port does not model + -- yet. + state.confuseCount = state.confuseCount or (rand(self.random, 2) + 2) + end + end + + -- BattleCommand_CheckRollout (move_effects/rollout.asm) skips past + -- doturn_command while SUBSTATUS_ROLLOUT is set, so a continuing Rollout is + -- free of PP and obedience in exactly the same way. + local rolling = state.rolloutLock == moveId + + if not (charging or rampaging or rolling) then + if move and (move.pp or 0) <= 0 then + self:emit({ kind = "message", text = "No PP left for this move!" }) + return + end + if move then move.pp = (move.pp or 1) - 1 end + -- BattleCommand_Rampage (effect_commands.asm:4886): the opening turn rolls + -- 1 or 2 MORE turns of lock-in, so Thrash and Petal Dance run for two or + -- three turns in all. A mon acting through Sleep Talk never rampages. + if def.effect == "EFFECT_RAMPAGE" and attacker.status ~= "sleep" then + state.rampageMove = moveId + state.rampageTurns = rand(self.random, 2) + 1 + end + end + + -- BattleCommand_Rage sets SUBSTATUS_RAGE and leaves the move to hit + -- normally; any OTHER move clears it, which is why Rage has no entry in + -- MOVE_EFFECTS -- it falls straight through to the damage path. + state.rage = (def.effect == "EFFECT_RAGE") or nil + -- Fury Cutter and Rollout reset the moment another move is used; the ramp + -- itself is maintained below. + -- UsedMoveText is built out of _ActorNameText followed by _UsedMove1Text, + -- which is `text_start` plus `line "used @"` (data/text/common_2.asm:339), + -- so the break after the user's name is part of the string and lands on the + -- box's second row however short the name is. It is not a wrap, and the + -- panel must not be left to invent one. + -- + -- The event is kept so the miss paths below can mark it: the screen animates + -- off this event, and BattleCommand_MoveAnimNoSub + -- (engine/battle/effect_commands.asm:1958) opens on + -- `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`, so a + -- move that missed or failed burns the delay and plays nothing at all. + self.moveEvent = self:emit({ kind = "move", side = self:sideOf(attacker), + move = moveId, + text = Strings("%s\nused %s!", name, def.name or moveId) }) + + -- battle.move_used, where BattleState:executeMove raises it on Gen 1: after + -- the announcement and before the effect runs, so a mod sees the move that + -- is about to resolve. `move` is the move record (it carries `id`, the way + -- Gen 1's does) and `isCalled` is true for the move Metronome or Mirror Move + -- picked -- Gen 2 tracks that as the copy depth rather than a flag argument. + if Runtime.wants("battle.move_used") then + Runtime.emit("battle.move_used", { + battle = self, user = attacker, target = defender, move = def, + isCalled = (self.copyDepth or 0) > 0, + -- Gen 2 additions: the id on its own (Gen 1 mods read move.id), and + -- which side is swinging. + moveId = moveId, side = self:sideOf(attacker), + }) + end + + -- Metronome and Mirror Move do not attack: they pick another move and run + -- it instead (both end in `ResetTurn`). `copyDepth` is the port's own + -- guard -- the cart cannot recurse because it restarts the turn, and + -- Metronome's own exception list keeps it from picking itself. + if def.effect == "EFFECT_METRONOME" or def.effect == "EFFECT_MIRROR_MOVE" then + local picked + if def.effect == "EFFECT_METRONOME" then + local order = (self.data.constants or {}).moveOrder + or (self.data.moves and self.data.moves.order) + picked = Effects.metronomePick(order or self:moveOrder(), + attacker.moves, self.random) + else + -- Mirror Move copies the OPPONENT's last move and fails when there is + -- none, or when the user already knows it (CheckUserMove). + local last = self:volatile(defender).lastMove + picked = last + for _, own in ipairs(attacker.moves or {}) do + if own.id == last then picked = nil break end + end + end + if not picked or (self.copyDepth or 0) > 0 then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + return + end + self.copyDepth = (self.copyDepth or 0) + 1 + self:useMove(attacker, defender, picked) + self.copyDepth = self.copyDepth - 1 + return + end + + -- Everything past here counts as "the user's last move" for Mirror Move, + -- Encore and Disable. + state.lastMove = moveId + state.turnsTaken = (state.turnsTaken or 0) + 1 + state.usedMoves = state.usedMoves or {} + local seen = false + for _, id in ipairs(state.usedMoves) do if id == moveId then seen = true end end + if not seen then state.usedMoves[#state.usedMoves + 1] = moveId end + + -- ParsePlayerAction (core.asm:618-624) and its enemy twin (core.asm:5621- + -- 5627) zero the protect count for any move that is not Protect or Endure. + if def.effect ~= "EFFECT_PROTECT" and def.effect ~= "EFFECT_ENDURE" then + state.protectCount = nil + end + + -- Turn one of a charge move: print the line, remember the move, done. + -- BattleCommand_SkipSunCharge (effect_commands.asm:6488): in sun, + -- Solarbeam's effect list jumps straight past the charge command and the + -- beam fires in one turn. + local charge = Effects.CHARGE[def.effect] + if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then + charge = nil + end + if charge and not charging then + state.chargeMove = moveId + state.vanished = charge.vanish or nil + -- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table + -- keyed by effect cannot tell them apart and DIG announced itself with + -- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the + -- effect: `cp DIG` and then the burrow text. + local text = charge.text + if moveId == "DIG" then text = "%s dug a hole!" end + self:emit({ kind = "message", text = text:format(name) }) + return + end + + -- Counter and Mirror Coat answer what the user took this turn, at double, + -- and fail outright when nothing of the right kind landed. + local counterKind = Effects.COUNTER[def.effect] + if counterKind then + local taken = state.tookThisTurn or 0 + if taken <= 0 or state.tookKind ~= counterKind then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + return + end + self:dealDamage(attacker, defender, Effects.counterDamage(taken), + { move = def, moveId = moveId }) + return + end + + -- Protect turns the whole move aside before accuracy is even rolled. + if self:volatile(defender).protect then + -- CheckHit's .Protect arm jumps to .Miss (effect_commands.asm:1557). + if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end + self:markMissed() + self:emit({ kind = "message", + text = self:monName(defender) .. " protected itself!" }) + return + end + + -- BattleCommand_CheckHit's .LockOn: the flag Lock-On left on the TARGET is + -- read and cleared by the very next move aimed at it, and while it is up the + -- accuracy roll does not happen at all. + local locked = self:consumeLockOn(defender) + -- SUBSTATUS_X_ACCURACY (an X ACCURACY) grants the same roll bypass, + -- CheckHit's `.XAccuracy` arm, but is not consumed: it lasts until the + -- switch drops the volatile. + local sureHit = locked or self:volatile(attacker).xAccuracy == true + + -- .LockOn runs ahead of .FlyDigMoves and returns a HIT unless the target is + -- flying and the move is one of the three (effect_commands.asm:1563-1567, + -- :1674-1691). + local lockedThrough = locked and not ( + self:volatile(defender).chargeMove == "FLY" + and Battle.LOCK_ON_GROUND_MOVES[moveId]) + + -- .FlyDigMoves: four moves reach a flying target, three an underground one + -- (effect_commands.asm:1566-1567, :1713-1746). + if self:volatile(defender).vanished and not lockedThrough + and not Effects.hitsVanished(self:volatile(defender).chargeMove, moveId) then + -- CheckHit's .Miss only sets wAttackMissed (effect_commands.asm:1619-1630), + -- so `selfdestruct` still runs ahead of failuretext. + if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end + self:markMissed() + self:emit({ kind = "message", text = name .. "'s attack missed!" }) + return + end + + -- The status-shaped moves: each one either sets its own state and returns, + -- or falls through to the ordinary damage path. Through the merged + -- `move_effects` record, so a mod's own primary effect is dispatched here + -- the way BattleState:performMove dispatches one on Gen 1. + local effectRecord = Battle.moveEffectRecordFor(self.data, def.effect) + local handler = effectRecord and effectRecord.run + if handler then + handler(self, attacker, defender, def, moveId, sureHit) + return + end + + -- BattleCommand_CheckHit opens on `call .DreamEater / jp z, .Miss` + -- (engine/battle/effect_commands.asm:1554): DREAM EATER against a target + -- that is not asleep is a MISS, before anything is rolled, so no damage + -- lands and nothing is sapped. The gate sits ahead of CheckHit's .LockOn + -- and .XAccuracy arms, which is why `sureHit` does not carry the move past + -- it -- and ahead of the damage block, which is where this used to sit, + -- refusing the move only after it had already hit and healed. + if def.effect == "EFFECT_DREAM_EATER" and defender.status ~= "sleep" then + self:markMissed() + self:emit({ kind = "message", text = name .. "'s attack missed!" }) + return + end + + -- MAGNITUDE rolls its power before checkhit (`getmagnitude` sits between + -- damagestats and damagecalc, data/moves/effects.asm:1705), so the number is + -- announced even on a miss. The rolled power replaces the move's stored + -- one, which the ROM keeps at 1 for exactly this reason. + local powerOverride + if def.effect == "EFFECT_MAGNITUDE" then + local rolled, number = Effects.magnitudePower(self.random) + powerOverride = rolled + self:emit({ kind = "message", + text = ("Magnitude %d!"):format(number) }) + end + + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + -- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and + -- failuretext, so a missed Explosion still kills the user. + if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end + self:markMissed() + self:emit({ kind = "message", text = name .. "'s attack missed!" }) + -- Fury Cutter's ramp resets the moment it misses. + state.rampMove = nil + state.rampCount = nil + -- BattleCommand_RolloutPower reads wAttackMissed before it touches the + -- counter and clears SUBSTATUS_ROLLOUT outright (rollout.asm), so a missed + -- Rollout releases the lock as well as the power ramp. A missed rampage + -- does NOT: `rampage` runs ahead of checkhit and nothing reads the miss. + state.rolloutLock = nil + return + end + + -- move_effects/selfdestruct.asm:6-12, run before applydamage. + if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end + + -- Substitute: a quarter of max HP, refused when the user has no more than + -- that to give. + if def.effect == "EFFECT_SUBSTITUTE" then + local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 + local cost = Effects.substituteCost(maxHp) + if (attacker.hp or 0) <= cost or (state.substitute or 0) > 0 then + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) + return + end + attacker.hp = attacker.hp - cost + state.substitute = cost + -- The cost is paid silently: SUBSTITUTE's own anim is all that plays + -- (move_effects/substitute.asm:57-68). + self:emit({ kind = "damage", side = self:sideOf(attacker), amount = cost, + hp = attacker.hp, anim = false }) + self:emit({ kind = "message", + text = name .. " made a SUBSTITUTE!" }) + return + end + + -- Damage that skips the formula entirely. + local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random) + if fixed then + self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId }) + return + end + + local dealt, info = 0, nil + if ((powerOverride or def.power) or 0) > 0 then + -- Rollout and Fury Cutter double their power for each consecutive use. + local power = powerOverride or def.power + if Effects.RAMPING[def.effect] then + -- The two ramps are separate bytes on the cart with separate reset + -- rules, so "is this a continuation?" is asked differently for each. + -- + -- ROLLOUT: BattleCommand_CheckRollout's `.reset` arm zeroes + -- wPlayerRolloutCount whenever SUBSTATUS_ROLLOUT is CLEAR as the move + -- starts (move_effects/rollout.asm), and the fifth hit is what clears + -- that bit. So a sequence that has run its five hits out does NOT feed + -- the next one: picking ROLLOUT again opens a fresh count, at base power + -- and re-locked. Testing "was the last move also ROLLOUT?" instead kept + -- the spent counter, which left the second sequence starting at the 16x + -- cap and never locking the menu at all. + -- + -- FURY CUTTER: wPlayerFuryCutterCount has no such bit. It is zeroed by + -- ResetFuryCutterCount, which move_effects/fury_cutter.asm calls on a + -- miss and effect_commands.asm:355 calls whenever another move is used, + -- which is exactly the same-move test below. + local continuing + if def.effect == "EFFECT_ROLLOUT" then + continuing = state.rolloutLock == moveId + else + continuing = state.rampMove == moveId + end + if continuing then + state.rampCount = math.min((state.rampCount or 0) + 1, + Effects.RAMPING[def.effect] - 1) + else + state.rampMove, state.rampCount = moveId, 0 + end + power = Effects.rampedPower(def.power, state.rampCount, + def.effect == "EFFECT_ROLLOUT" and state.curled) + -- BattleCommand_RolloutPower's `.hit` arm sets SUBSTATUS_ROLLOUT while + -- the incremented counter is still short of MAX_ROLLOUT_COUNT and + -- clears it on the fifth (rollout.asm), and CheckPlayerLockedIn + -- (core.asm:546) offers no menu at all while the bit is set. Fury + -- Cutter shares the power ramp but not the lock: its effect list + -- carries no checkrollout. `rampCount` is the cart's counter minus + -- one, so the last locked turn is the one below the cap. + if def.effect == "EFFECT_ROLLOUT" then + local last = state.rampCount >= (Effects.RAMPING[def.effect] - 1) + state.rolloutLock = (not last) and moveId or nil + end + else + state.rampMove, state.rampCount = nil, nil + state.rolloutLock = nil + end + + local hits = Effects.hitCount(def.effect, self.random) + local landed = 0 + for hit = 1, hits do + if (defender.hp or 0) <= 0 then break end + local hitPower = power + if def.effect == "EFFECT_TRIPLE_KICK" then + hitPower = Effects.tripleKickPower(def.power, hit) + -- Each kick rolls its own accuracy and the sequence stops on a miss. + if hit > 1 and not sureHit + and not self:accuracyRoll(def, attacker, defender) then + break + end + end + local amount + amount, info = self:hitOnce(attacker, defender, def, { power = hitPower }) + if info and info.effectiveness == 0 then + -- rolloutpower sits after checkhit and reads the wAttackMissed that + -- `stab` set for the immunity, so an immune target breaks the Rollout + -- lock (rollout.asm, the arm above `.hit`). + state.rolloutLock = nil + return + end + dealt = dealt + amount + landed = landed + 1 + end + if landed > 1 then + self:emit({ kind = "message", + text = ("Hit %d time(s)!"):format(landed) }) + end + + -- Recoil is a quarter of what was dealt; drain heals half of it. Dream + -- Eater's sleep requirement is checkhit's, not this block's, so by the + -- time a drain is paid out the target is known to have been asleep. + if def.effect == "EFFECT_RECOIL_HIT" and dealt > 0 then + local recoil = Effects.recoilDamage(dealt) + attacker.hp = math.max(0, (attacker.hp or 0) - recoil) + -- BattleCommand_Recoil is bar, huds and RecoilText only: no anim at all + -- (effect_commands.asm:5674-5687). + self:emit({ kind = "damage", side = self:sideOf(attacker), + amount = recoil, hp = attacker.hp, anim = false }) + self:emit({ kind = "message", text = name .. " is hit with recoil!" }) + elseif Effects.DRAIN[def.effect] and dealt > 0 then + self:heal(attacker, Effects.drainAmount(dealt)) + self:emit({ kind = "message", + text = self:monName(defender) .. "'s energy was drained!" }) + end + + -- BattleCommand_RechargeNextTurn (effect_commands.asm:5899): HYPER BEAM + -- sets SUBSTATUS_RECHARGE on the user, and CheckPlayerTurn / + -- CheckEnemyTurn spend the next turn clearing it. Nothing here implemented + -- it, so HYPER BEAM was a 150-power move with no cost at all. + -- + -- Found by the Gold route bot: CHAMPION LANCE's three DRAGONITE all carry + -- it, and they were firing it every single turn -- twice the damage output + -- the fight is balanced around, against a bot with one healthy mon. + -- Unlike Gen 1 there is no "no recharge if it KOs" exemption; the command + -- runs at the end of the effect list whenever the move connected. + if def.effect == "EFFECT_HYPER_BEAM" and dealt > 0 then + state.recharge = true + end + + -- BattleCommand_HeldFlinch (effect_commands.asm:5349): a damaging move + -- that connected lets the ATTACKER's HELD_FLINCH item (King's Rock) + -- flinch the target, one byte against the parameter (30 -> 30/256). + -- Silent when it lands -- the message is the target's own "flinched!" + -- when it tries to act. A Substitute blocks it. + if dealt > 0 and (defender.hp or 0) > 0 then + local held, parameter = self:heldEffect(attacker, "flinch") + if held == "HELD_FLINCH" + and (self:volatile(defender).substitute or 0) <= 0 + and rand(self.random, 256) < parameter then + self:volatile(defender).flinched = true + end + end + + -- BattleCommand_FlinchTarget (effect_commands.asm:5314): the *_HIT + -- flinch moves (Rock Slide, Headbutt, Bite) roll the move's effect + -- chance after a connected hit; a Substitute blocks it. Silent when it + -- lands, same as the held-item flinch above. + if def.effect == "EFFECT_FLINCH_HIT" and dealt > 0 + and (defender.hp or 0) > 0 + and (self:volatile(defender).substitute or 0) <= 0 then + local chance = def.effectChance or 0 + if chance > 0 and rand(self.random, 100) < chance then + self:volatile(defender).flinched = true + end + end + + -- BattleCommand_TrapTarget (effect_commands.asm:5569): a connected Bind + -- class hit starts a 2-5 turn partial trap on the target -- unless one + -- is already running or a Substitute is up. The stored count is + -- `and %11` plus three because HandleWrap decrements BEFORE it acts, so + -- a count of n hurts on n-1 turns and releases on the last. + if def.effect == "EFFECT_TRAP_TARGET" and dealt > 0 + and (defender.hp or 0) > 0 then + local target = self:volatile(defender) + if not target.wrapCount and (target.substitute or 0) <= 0 then + target.wrapCount = rand(self.random, 4) + 3 + target.wrapMove = def.name or moveId + -- wFXAnimID keeps the trapping move itself, which is what HandleWrap + -- replays every turn (core.asm:1185-1202). + target.wrapMoveId = moveId + local trapText = Battle.TRAP_TEXT[moveId] + self:emit({ kind = "message", + text = trapText and trapText(self:monName(defender), name) + or (self:monName(defender) .. " was trapped!") }) + end + end + end + + -- Defense Curl arms Rollout as well as raising Defense. + if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end + + -- Stat changes: the primary ones always land, the *_HIT ones roll the + -- move's effect chance after a hit that connected. + -- + -- A refused primary change is a failure the cart detects BEFORE its anim + -- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` / + -- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400), + -- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER + -- `attackup` / `attackdown` in the effect list (data/moves/effects.asm, + -- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs + -- after `moveanim` (AttackDownHit), so the animation has already played. + local change = Effects.STAT_CHANGES[def.effect] + if change then + local target = change[3] == "self" and attacker or defender + if not self:changeStageAgainstMist(attacker, target, change[1], change[2]) + then + self:markMissed() + end + else + local onHit = Effects.STAT_CHANGES_ON_HIT[def.effect] + if onHit and dealt > 0 then + local chance = def.effectChance or 0 + if chance > 0 and rand(self.random, 100) < chance then + local target = onHit[3] == "self" and attacker or defender + self:changeStageAgainstMist(attacker, target, onHit[1], onHit[2]) + end + elseif def.effect == "EFFECT_ALL_UP_HIT" and dealt > 0 then + local chance = def.effectChance or 0 + if chance > 0 and rand(self.random, 100) < chance then + for _, stat in ipairs(Effects.ALL_UP_STATS) do + self:changeStage(attacker, stat, 1) + end + end + end + end + + -- Status moves land their status; damaging moves roll their effect chance. + -- Both come off the merged `move_effects` record: a primary record's + -- `status` is the one a zero-power move lands, a secondary record's is the + -- one rolled against the move's effect chance after a hit. + local record = Battle.moveEffectRecordFor(self.data, def.effect) + local status = record and record.kind == "primary" and record.status or nil + if status and (def.power or 0) == 0 then + -- Every status command's already-statused / immune arm ends on + -- AnimateFailedMove (BattleCommand_Poison's `.failed`, + -- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay, + -- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the + -- success path (:3752), and the effect scripts carry no moveanim of their + -- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS + -- branch below is the opposite case: that move already hit and already + -- animated, so a refused secondary must leave the event unmarked. + if not self:applyStatus(defender, status, attacker) then self:markMissed() end + else + local secondary = record and record.kind == "secondary" + and record.status or nil + if secondary and (defender.hp or 0) > 0 then + local chance = def.effectChance or 0 + if chance > 0 and rand(self.random, 100) < chance then + self:applyStatus(defender, secondary, attacker) + end + end + end +end + +-------------------------------------------------------------------------- +-- The moves whose whole job is to set state +-------------------------------------------------------------------------- +-- +-- Each entry is one command out of engine/battle/move_effects/, and each one +-- either sets its state and returns or prints the cart's own failure line. +-- Anything NOT in this table falls through to the ordinary damage path, which +-- is what keeps an unmodelled effect honest. +-- +-- Cross-file contract: useMove does NOT dispatch on this table any more, it +-- dispatches on Battle.MOVE_EFFECT_RECORDS, which is folded out of this one +-- (and out of STATUS_EFFECTS / SECONDARY_EFFECTS) at the bottom of the block. +-- A new effect goes here, ABOVE that fold; one added below it would be a +-- handler nothing ever calls. + +Battle.MOVE_EFFECTS = {} + +-- Every effect command's own `.failed` tail reaches AnimateFailedMove +-- (effect_commands.asm:6656): a delay and no animation, so a failed move is +-- marked the same way a missed one is. +local function fail(self) + self:markMissed() + self:emit({ kind = "message", text = "But it failed!" }) +end + +-- BattleCommand_Splash (engine/battle/move_effects/splash.asm): the whole +-- command is the animation and then `jp PrintNothingHappened`, and the effect +-- list (data/moves/effects.asm:1156) has no checkhit at all, so the move never +-- rolls accuracy and never touches the target. Without an entry here SPLASH +-- fell through to the damage path, where its zero power meant it announced +-- itself and then said nothing. +Battle.MOVE_EFFECTS.EFFECT_SPLASH = function(self) + -- NothingHappenedText (data/text/battle.asm:870): "But nothing" / "happened." + self:emit({ kind = "message", + text = Strings("But nothing\nhappened.") }) +end + +-- BattleCommand_StartRain / StartSun / StartSandstorm. Sandstorm is the only +-- one that refuses to re-cast itself. +for effect, weather in pairs(Effects.WEATHER) do + Battle.MOVE_EFFECTS[effect] = function(self, attacker) + if weather == "sandstorm" and self.weather == "sandstorm" then + return fail(self) + end + self.weather = weather + self.weatherTurns = Effects.WEATHER_TURNS + self:emit({ kind = "weather", weather = weather, + text = Effects.WEATHER_START_TEXT[weather] }) + end +end + +-- BattleCommand_PerishSong: four turns on BOTH sides, and it fails only when +-- both are already counting. +Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self) + local mine = self:volatile(self.player) + local theirs = self:volatile(self.enemy) + if mine.perish and theirs.perish then return fail(self) end + if not mine.perish then mine.perish = Effects.PERISH_TURNS end + if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end + self:emit({ kind = "message", + text = "All POKéMON hearing the song will faint in three turns!" }) +end + +-- BattleCommand_Encore: 3-6 turns locked into the move the target last used. +Battle.MOVE_EFFECTS.EFFECT_ENCORE = function(self, attacker, defender) + local target = self:volatile(defender) + local last = target.lastMove + if not last or Effects.ENCORE_BLOCKED[last] or target.encore then + return fail(self) + end + -- The move has to still be in the target's list with PP left. + local found + for _, move in ipairs(defender.moves or {}) do + if move.id == last and (move.pp or 0) > 0 then found = move end + end + if not found then return fail(self) end + target.encore = last + target.encoreTurns = Effects.encoreTurns(self.random) + self:emit({ kind = "message", + text = self:monName(defender) .. " got an ENCORE!" }) +end + +-- BattleCommand_Disable: one of the target's moves, for 2-9 turns. It fails +-- when something is already disabled, or when the target has not moved. +Battle.MOVE_EFFECTS.EFFECT_DISABLE = function(self, attacker, defender) + local target = self:volatile(defender) + if target.disabled then return fail(self) end + local last = target.lastMove + if not last or last == "STRUGGLE" then return fail(self) end + local found + for _, move in ipairs(defender.moves or {}) do + if move.id == last and (move.pp or 0) > 0 then found = move end + end + if not found then return fail(self) end + target.disabled = last + target.disabledTurns = Effects.disableTurns(self.random) + self:emit({ kind = "message", + text = self:monName(defender) .. "'s " .. last .. " was disabled!" }) +end + +-- BattleCommand_LockOn: Lock-On and Mind Reader set SUBSTATUS_LOCK_ON on the +-- TARGET, not on the user, which is why the AI reads wPlayerSubStatus5 to ask +-- whether its own lock-on has landed. A Substitute blocks it outright. +Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender) + local target = self:volatile(defender) + if (target.substitute or 0) > 0 then + -- lock_on.asm's `.fail`: AnimateFailedMove, then PrintDidntAffect. The + -- animation is AnimateCurrentMove on the success arm only. + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return + end + target.lockOn = true + self:emit({ kind = "message", + text = self:monName(attacker) .. " took aim!" }) +end + +-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next +-- move aimed at the mon carrying it, whether or not that move was the one the +-- lock-on was meant for, and whether or not the exception at :1683-1688 then +-- misses (effect_commands.asm:1671-1672). +function Battle:consumeLockOn(defender) + local target = self:volatile(defender) + if not target.lockOn then return false end + target.lockOn = nil + return true +end + +-- BattleCommand_CheckHit's `.BrightPowder`: the DEFENDER's HELD_BRIGHTPOWDER +-- subtracts its parameter (20) from the accuracy byte before the roll. The +-- port rolls accuracy in the percent domain, so the byte penalty is scaled +-- by 100/256 and floored -- never below 1, because rollHit reads a +-- non-positive accuracy as "never misses", the exact opposite of the cart's +-- underflow-to-zero always-miss. +function Battle:moveAccuracy(accuracy, defender) + if not accuracy or accuracy <= 0 then return accuracy end + local effect, parameter = self:heldEffect(defender, "accuracy") + if effect == "HELD_BRIGHTPOWDER" then + accuracy = math.max(1, + accuracy - math.floor((parameter or 0) * 100 / 256)) + end + return accuracy +end + +-- The one accuracy roll (BattleCommand_CheckHit), hooked as battle.accuracy -- +-- the same hook BattleState:accuracyRoll calls on Gen 1, with the same ctx +-- keys: `move`, `user`, `target` and the rng, so a mod that makes a move never +-- miss reads the same fields it did on Red. `ruleset` has no Gen 2 +-- counterpart and is absent rather than invented; `accuracy` (the byte the +-- roll is actually made against, after Bright Powder) and `moveId` are Gen 2 +-- additions. The ctx table is built only when a chain is installed. +function Battle:accuracyRoll(def, attacker, defender, accuracy) + accuracy = accuracy or (def and def.accuracy) + if Runtime.wantsHook("battle.accuracy") then + return Runtime.call("battle.accuracy", function(c) + return c.battle:vanillaAccuracyRoll(c.accuracy, c.user, c.target) + end, { battle = self, move = def, moveId = def and def.id, + user = attacker, target = defender, accuracy = accuracy, + rng = self:roller(), random = self.random }) + end + return self:vanillaAccuracyRoll(accuracy, attacker, defender) +end + +function Battle:vanillaAccuracyRoll(accuracy, attacker, defender) + return Damage.rollHit(self:moveAccuracy(accuracy, defender), + self.stages[self:sideOf(attacker)].accuracy, + self.stages[self:sideOf(defender)].evasion, self.random) +end + +-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE +-- aims at the holder answers ProtectedByMistText and changes nothing. The +-- holder's own drops are not Mist's business, so self-targeted changes pass +-- straight through. +function Battle:changeStageAgainstMist(attacker, target, stat, stages) + if target ~= attacker and (stages or 0) < 0 + and self:volatile(target).mist then + self:emit({ kind = "message", + text = self:monName(target) .. "'s protected by MIST." }) + return false + end + return self:changeStage(target, stat, stages) +end + +-- BattleCommand_Spikes: laid on the side that will switch into them, and it +-- refuses a second layer (Gen 2 has only one). +Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender) + local side = self:sideOf(defender) + if self.spikes[side] then return fail(self) end + self.spikes[side] = true + self:emit({ kind = "message", text = "Spikes were scattered all around!" }) +end + +-- BattleCommand_Protect / Endure share ProtectChance, which halves the odds +-- for every consecutive use and zeroes the counter the moment one fails. +local function protectLike(field, text) + return function(self, attacker) + local state = self:volatile(attacker) + -- move_effects/protect.asm:22-23: `call CheckOpponentWentFirst / jr nz, + -- .failed`, ahead of everything else ProtectChance rolls. + -- move_effects/protect.asm:27-30: no Protect from behind a Substitute. + if (self.firstMover and self.firstMover ~= self:sideOf(attacker)) + or (state.substitute or 0) > 0 + or not Effects.protectSucceeds(state.protectCount or 0, self:roller()) + then + state.protectCount = 0 + return fail(self) + end + state.protectCount = (state.protectCount or 0) + 1 + state[field] = true + self:emit({ kind = "message", text = self:monName(attacker) .. text }) + end +end + +Battle.MOVE_EFFECTS.EFFECT_PROTECT = protectLike("protect", " protected itself!") +Battle.MOVE_EFFECTS.EFFECT_ENDURE = protectLike("endure", " braced itself!") + +-- BattleCommand_UnleashEnergy / StoreEnergy. Turn one starts the store; the +-- turn the counter runs out the user hits for double everything it took. +Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId) + local state = self:volatile(attacker) + if not state.bideTurns then + state.bideTurns = Effects.bideTurns(self.random) + state.bideStored = 0 + self:emit({ kind = "message", + text = self:monName(attacker) .. " is storing energy!" }) + return + end + state.bideTurns = state.bideTurns - 1 + if state.bideTurns > 0 then + self:emit({ kind = "message", + text = self:monName(attacker) .. " is storing energy!" }) + return + end + local damage = Effects.bideDamage(state.bideStored) + state.bideTurns, state.bideStored = nil, nil + self:emit({ kind = "message", + text = self:monName(attacker) .. " unleashed energy!" }) + if damage <= 0 then return fail(self) end + self:dealDamage(attacker, defender, damage, { move = def, moveId = moveId }) +end + +-- BattleCommand_Transform: the user takes the target's species, types, moves +-- and stats, keeping its own HP and level. Every copied move gets 5 PP. +Battle.MOVE_EFFECTS.EFFECT_TRANSFORM = function(self, attacker, defender) + local state = self:volatile(attacker) + if state.transformed or self:volatile(defender).substitute then + return fail(self) + end + state.transformed = true + -- The cart copies the target into BATTLE ram (wBattleMon / wEnemyMon) and + -- leaves the struct the mon was loaded FROM alone, so every route out of the + -- battle -- SwitchOutMon reloading the party slot, and PokeBallEffect + -- reloading the caught mon out of its base data -- hands back a DITTO. This + -- port has one table per mon, so the identity the copy is about to overwrite + -- is kept here and put back by Battle:untransform, which every one of those + -- routes goes through. Without it a Ditto that transformed was a permanent + -- copy of whatever it last faced: the caught record, and the player's own + -- party slot, went into the save as the wrong species with the wrong moves. + state.preTransform = { + species = attacker.species, + types = attacker.types, + moves = attacker.moves, + shiny = attacker.shiny, + stats = {}, + } + local targetDef = self:speciesDef(defender) + attacker.species = defender.species + attacker.types = (targetDef and targetDef.types) or defender.types + attacker.shiny = defender.shiny + local moves = {} + for index, move in ipairs(defender.moves or {}) do + moves[index] = { id = move.id, pp = 5, maxPp = 5 } + end + attacker.moves = moves + -- Everything but HP is copied, which is why a Transformed Ditto has the + -- target's Attack and its own hit points. + local stats = attacker.stats or {} + local theirs = defender.stats or {} + for _, key in ipairs({ "attack", "defense", "speed", "specialAttack", + "specialDefense" }) do + state.preTransform.stats[key] = stats[key] + stats[key] = theirs[key] or stats[key] + end + attacker.stats = stats + self:emit({ kind = "message", text = self:monName(attacker) + .. " TRANSFORMED into " .. (defender.species or "?") .. "!" }) + -- The moment itself, for the screen. src/ui/gen2/BattleState.lua draws each + -- side's pic and HUD from `shownMon`, which follows the EVENT QUEUE rather + -- than the battle -- a whole round is resolved by Battle:takeTurn before its + -- first message is read, so anything written straight into the mon record is + -- on screen a beat before its own line. That is what made a wild DITTO + -- change shape the instant the player confirmed a move. Damage has + -- `shownHp` and a switch has the `send` event for exactly this; a transform + -- is the third identity swap and this is its event. `mon` is the battler + -- whose pic changes (the same key `send` carries) and `from` is what it was. + self:emit({ kind = "transform", side = self:sideOf(attacker), + mon = attacker, species = attacker.species, + from = state.preTransform.species }) +end + +-- SwitchOutMon / PokeBallEffect's reload: the copy Transform wrote lives in +-- battle ram on the cart, so it never survives the mon leaving the field. +-- Called from Battle:clearVolatile (every switch, and CleanUpBattleRAM at the +-- end of the battle) and from Battle:caught, which is the catch's own reload. +-- Returns whether anything was restored. +function Battle:untransform(mon) + local state = mon and mon.volatile + local before = state and state.preTransform + if not before then return false end + mon.species = before.species + mon.types = before.types + mon.moves = before.moves + mon.shiny = before.shiny + -- The stat table is written through in place (a mon's `stats` is handed + -- around by reference), so the five copied numbers are put back one by one. + local stats = mon.stats + if stats then + for key, value in pairs(before.stats) do stats[key] = value end + end + state.preTransform = nil + state.transformed = nil + return true +end + +-- BattleCommand_FutureSight: four turns, damage rolled and stored now. +Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def) + local state = self:volatile(attacker) + if state.futureSight then return fail(self) end + local damage = Damage.calc({ + level = attacker.level or 1, + power = def.power, + moveType = def.type, + attacker = { + attack = (attacker.stats or {}).attack, + specialAttack = (attacker.stats or {}).specialAttack, + types = (self:speciesDef(attacker) or {}).types or attacker.types, + stages = self.stages[self:sideOf(attacker)], + }, + defender = { + defense = (defender.stats or {}).defense, + specialDefense = (defender.stats or {}).specialDefense, + types = (self:speciesDef(defender) or {}).types or defender.types, + stages = self.stages[self:sideOf(defender)], + }, + types = self.data.type_chart and self.data.type_chart.types, + matchups = self.data.type_chart and self.data.type_chart.matchups, + random = self.random, + }) + state.futureSight = Effects.FUTURE_SIGHT_TURNS + state.futureSightDamage = math.max(1, damage) + state.futureSightSide = self:sideOf(defender) + self:emit({ kind = "message", + text = self:monName(attacker) .. " foresaw an attack!" }) +end + +-- BattleCommand_OHKO: fails outright against a higher-level target, and the +-- level difference is worth two accuracy points each. +Battle.MOVE_EFFECTS.EFFECT_OHKO = function(self, attacker, defender, def, _, + locked) + local accuracy = Effects.ohkoAccuracy(def.accuracy, attacker.level, + defender.level) + if not accuracy then + -- `.no_effect` sets wAttackMissed (effect_commands.asm:5414-5419) and + -- `ohko` sits ahead of `moveanim` in OHKOHit (data/moves/effects.asm:917), + -- so the level refusal plays MoveDelay and nothing else. + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return + end + -- BattleCommand_OHKO ends on `call BattleCommand_CheckHit`, so a lock-on + -- carries Fissure past the level-scaled roll -- and so does + -- SUBSTATUS_X_ACCURACY, which is why an X ACCURACY makes the OHKO moves + -- sure hits in Gen 2 (`locked` here is useMove's sureHit). + local hit = locked or self:accuracyRoll(def, attacker, defender, accuracy) + if not hit then + self:markMissed() + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s attack missed!" }) + return + end + self:dealDamage(attacker, defender, defender.hp or 1, + { move = def, moveId = def and def.id }) + self:emit({ kind = "message", text = "It's a one-hit KO!" }) +end + +-- BattleCommand_BeatUp: one hit per healthy, unstatused party member, each +-- swinging with its own base Attack. +Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def) + local party = attacker == self.player and self.party or self.enemyParty + local active = attacker == self.player and self.playerIndex or self.enemyIndex + local hits = Effects.beatUpParty(party, active) + if #hits == 0 then return fail(self) end + local targetDef = self:speciesDef(defender) + local landed = 0 + for _, entry in ipairs(hits) do + if (defender.hp or 0) <= 0 then break end + local monDef = self.data.pokemon and self.data.pokemon[entry.mon.species] + local base = monDef and monDef.baseStats or {} + local damage = Damage.calc({ + level = entry.mon.level or attacker.level or 1, + power = def.power, + moveType = def.type, + -- The BASE stats, not the battle stats: Beat Up asks GetBaseData for + -- each party member and the target both. + attacker = { attack = base.attack or 1, specialAttack = base.attack or 1, + types = {}, stages = {} }, + defender = { + defense = (targetDef and targetDef.baseStats + and targetDef.baseStats.defense) or 1, + specialDefense = (targetDef and targetDef.baseStats + and targetDef.baseStats.defense) or 1, + types = {}, stages = {}, + }, + types = self.data.type_chart and self.data.type_chart.types, + matchups = self.data.type_chart and self.data.type_chart.matchups, + random = self.random, + }) + self:emit({ kind = "message", + text = self:monName(entry.mon) .. "'s attack!" }) + self:dealDamage(attacker, defender, math.max(1, damage), + { move = def, moveId = def and def.id }) + landed = landed + 1 + end + self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) }) +end + +-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both +-- EFFECT_HEAL and split on the MOVE (:6007), Rest taking GetMaxHP (:6043). +Battle.MOVE_EFFECTS.EFFECT_HEAL = function(self, attacker, _, _, moveId) + local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 + -- effect_commands.asm:6061: full HP answers HPIsFullText, not the fail line. + if (attacker.hp or 0) >= maxHp then + self:markMissed() + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s HP is full!" }) + return + end + if moveId == "REST" then + -- effect_commands.asm:6015-6027: the toxic bit clears, the status byte + -- becomes REST_SLEEP_TURNS + 1, and the line depends on the old status. + local cured = attacker.status ~= nil + attacker.status = "sleep" + attacker.statusTurns = 3 + attacker.toxicCounter = nil + self:emit({ kind = "status", side = self:sideOf(attacker), + status = "sleep", text = self:monName(attacker) + .. (cured and " fell asleep and became healthy!" + or " went to sleep!") }) + self:heal(attacker, maxHp) + else + self:heal(attacker, math.max(1, math.floor(maxHp / 2))) + end + -- effect_commands.asm:6058, RegainedHealthText. + self:emit({ kind = "message", + text = self:monName(attacker) .. " regained health!" }) +end + +-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the +-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and +-- RegainedHealthText (:6440). +for effect in pairs(Effects.SUN_HEAL) do + Battle.MOVE_EFFECTS[effect] = function(self, attacker) + local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 + if (attacker.hp or 0) >= maxHp then + self:markMissed() + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s HP is full!" }) + return + end + local fraction = Effects.weatherHealFraction(self.weather) + self:heal(attacker, math.max(1, math.floor(maxHp * fraction))) + self:emit({ kind = "message", + text = self:monName(attacker) .. " regained health!" }) + end +end + +-- BattleCommand_BatonPass: the switch keeps everything ResetBatonPassStatus +-- does NOT clear -- the stat stages above all, which is the point of the move. +Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker) + local side = self:sideOf(attacker) + local party = side == "player" and self.party or self.enemyParty + local current = side == "player" and self.playerIndex or self.enemyIndex + local target + for index, mon in ipairs(party) do + if index ~= current and (mon.hp or 0) > 0 then + target = index + break + end + end + if not target then return fail(self) end + local carried = self:volatile(attacker) + for _, key in ipairs(Effects.BATON_PASS_DROPS) do carried[key] = nil end + -- The area moves with the baton rather than being copied: what the passer + -- leaves the field with is an empty one, the same as any other switch out. + self:clearVolatile(attacker) + self:emit({ kind = "baton-pass", side = side, index = target, + text = self:monName(attacker) .. " passed the baton!" }) + if side == "player" then + self.playerIndex = target + self.player = party[target] + self.participants[target] = true + self.player.volatile = carried + else + self.enemyIndex = target + self.enemy = party[target] + self.enemy.volatile = carried + end + self:emit({ kind = "send", side = side, + mon = side == "player" and self.player or self.enemy, + text = "Go! " .. self:monName(side == "player" and self.player + or self.enemy) .. "!" }) +end + +-- BattleCommand_TrapTarget's .Traps table, one line per move: target first, +-- user second. FIRE_SPIN and WHIRLPOOL share the plain WasTrappedText +-- fallback in the caller. +Battle.TRAP_TEXT = { + BIND = function(target, user) + return user .. " used BIND on " .. target .. "!" + end, + WRAP = function(target, user) + return target .. " was WRAPPED by " .. user .. "!" + end, + CLAMP = function(target, user) + return target .. " was CLAMPED by " .. user .. "!" + end, +} + +-- BattleCommand_Screen (effect_commands.asm:6100): one wPlayerScreens bit +-- and a five-turn count per side; the second cast fails while the first is +-- still up. +Battle.SCREEN_TURNS = 5 + +Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker) + local side = self.screens[self:sideOf(attacker)] + if (side.lightScreen or 0) > 0 then return fail(self) end + side.lightScreen = Battle.SCREEN_TURNS + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s SPCL.DEF rose!" }) +end + +Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) + local side = self.screens[self:sideOf(attacker)] + if (side.reflect or 0) > 0 then return fail(self) end + side.reflect = Battle.SCREEN_TURNS + self:emit({ kind = "message", + text = self:monName(attacker) .. "'s DEFENSE rose!" }) +end + +-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in +-- one body. A non-Ghost user trades a stage of Speed for one each of Attack +-- and Defense, refused only when BOTH raises are already capped; a Ghost +-- user pays half its max HP -- the cut can faint it -- to set +-- SUBSTATUS_CURSE on the target, worth a quarter of max HP every turn +-- (ResidualDamage's curse arm, Battle:tickSeedAndCurse). +Battle.MOVE_EFFECTS.EFFECT_CURSE = function(self, attacker, defender) + local ghost = false + for _, type_ in ipairs((self:speciesDef(attacker) or {}).types + or attacker.types or {}) do + if type_ == "GHOST" then ghost = true end + end + local name = self:monName(attacker) + if not ghost then + local stages = self.stages[self:sideOf(attacker)] + if (stages.attack or 0) >= Effects.MAX_STAGE + and (stages.defense or 0) >= Effects.MAX_STAGE then + -- curse.asm's `.cantraise`: AnimateFailedMove, then WontRiseAnymoreText. + -- The raising arm is the only one that calls AnimateCurrentMove. + self:markMissed() + self:emit({ kind = "message", + text = name .. "'s ATTACK won't rise anymore!" }) + return + end + -- The cart's own order: Speed down first, then the two raises. The + -- user's own drop is not Mist's business. + self:changeStage(attacker, "speed", -1) + self:changeStage(attacker, "attack", 1) + self:changeStage(attacker, "defense", 1) + return + end + local target = self:volatile(defender) + if target.vanished or (target.substitute or 0) > 0 or target.cursed then + return fail(self) + end + target.cursed = true + local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 + local cost = math.max(1, math.floor(maxHp / 2)) + attacker.hp = math.max(0, (attacker.hp or 0) - cost) + -- AnimateCurrentMove has already run; SubtractHPFromUser adds nothing + -- (move_effects/curse.asm:70-76). + self:emit({ kind = "damage", side = self:sideOf(attacker), amount = cost, + hp = attacker.hp, anim = false }) + self:emit({ kind = "message", + text = name .. " cut its own HP and put a CURSE on " + .. self:monName(defender) .. "!" }) +end + +-- BattleCommand_LeechSeed (engine/battle/move_effects/leech_seed.asm): the +-- flag sits on the SEEDED mon and ResidualDamage drains an eighth every turn +-- into whoever stands on the other side by then. A Grass target does not +-- take it at all; a miss, a Substitute or a repeat all "evaded" -- and the +-- move's own 90 accuracy rolls first, since its effect list carries +-- checkhit. +-- +-- All three refusals (`.evaded` and `.grass`) end on AnimateFailedMove, so +-- none of them animates; only the seeding arm reaches AnimateCurrentMove. +Battle.MOVE_EFFECTS.EFFECT_LEECH_SEED = function(self, attacker, defender, + def, _, sureHit) + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + self:markMissed() + self:emit({ kind = "message", + text = self:monName(defender) .. " evaded the attack!" }) + return + end + for _, type_ in ipairs((self:speciesDef(defender) or {}).types + or defender.types or {}) do + if type_ == "GRASS" then + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return + end + end + local target = self:volatile(defender) + if (target.substitute or 0) > 0 or target.leechSeed then + self:markMissed() + self:emit({ kind = "message", + text = self:monName(defender) .. " evaded the attack!" }) + return + end + target.leechSeed = true + self:emit({ kind = "message", + text = self:monName(defender) .. " was seeded!" }) +end + +-- BattleCommand_Spite (engine/battle/move_effects/spite.asm): 2-5 PP off the +-- move the TARGET used last, clamped to what it has left. The cart reads +-- BATTLE_VARS_LAST_COUNTER_MOVE_OPP, so a target that has not moved yet -- or +-- that answered with STRUGGLE, or whose slot is already dry -- falls into +-- `.failed`, which is `jp PrintDidntAffect2`. The effect list carries +-- checkhit (data/moves/effects.asm:1366), and MOVE_EFFECTS handlers run ahead +-- of useMove's own roll, so the roll is made here the way Leech Seed makes it. +-- +-- No party writeback: the cart copies the new PP into the party struct and +-- wWildMonPP behind the battle struct, but Battle.party IS save.party here and +-- the move table this edits is the live one. +Battle.MOVE_EFFECTS.EFFECT_SPITE = function(self, attacker, defender, def, _, + sureHit) + -- DidntAffect2Text (data/text/battle.asm:888). BattleCommand_Spite calls + -- AnimateCurrentMove itself and only on the way to the success text, so a + -- refused SPITE plays nothing. + local function didntAffect() + self:markMissed() + self:emit({ kind = "message", + text = "It didn't affect " .. self:monName(defender) .. "!" }) + end + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + return didntAffect() + end + local last = self:volatile(defender).lastMove + if not last or last == Battle.STRUGGLE then return didntAffect() end + local entry = self:findMove(defender, last) + if not entry or (entry.pp or 0) <= 0 then return didntAffect() end + -- `call BattleRandom / and %11 / inc a / inc a`, then `cp b / jr nc` keeps + -- the loss inside what the slot still holds. + local loss = math.min(rand(self.random, 4) + 2, entry.pp) + entry.pp = entry.pp - loss + local moveName = (self:moveDef(last) or {}).name or last + self:emit({ kind = "message", + text = ("%s's %s was reduced by %d!"):format(self:monName(defender), + moveName, loss) }) +end + +-- BattleCommand_ArenaTrap (effect_commands.asm:6238): Mean Look and Spider +-- Web set SUBSTATUS_CANT_RUN on the USER's side, meaning "my opponent cannot +-- run or switch" -- which is why the pin dies with its user (a switch drops +-- the volatile) and why TryEnemyFlee reads the PLAYER's substatus to hold a +-- roamer. No accuracy roll: the effect list has no checkhit, only the +-- hidden-target and repeat guards. +Battle.MOVE_EFFECTS.EFFECT_MEAN_LOOK = function(self, attacker, defender) + if self:volatile(defender).vanished + or self:volatile(attacker).trapsTarget then + return fail(self) + end + self:volatile(attacker).trapsTarget = true + self:emit({ kind = "message", + text = self:monName(defender) .. " can't escape now!" }) +end + +-- BattleCommand_ForceSwitch (effect_commands.asm:4913). Fails outright for +-- BATTLETYPE_FORCESHINY and BATTLETYPE_TRAP. Against a WILD mon the battle +-- simply ENDS -- either direction writes DRAW into wBattleResult, which is +-- what makes a Roared-away roamer bank its HP -- with the level ladder +-- deciding: the user's level at or above the target's succeeds outright, +-- and below it one re-rolled byte can still get past a quarter of the +-- target's level. In a TRAINER battle the user must be moving SECOND (both +-- arms read wEnemyGoesFirst) and a random other able mon is dragged out. +Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender, + def, moveId, sureHit) + if self.battleType == Battle.BATTLETYPE_FORCESHINY + or self.battleType == Battle.BATTLETYPE_TRAP then + return fail(self) + end + -- checkhit runs ahead of forceswitch in the effect list; `.missed` fails. + if not sureHit + and not self:accuracyRoll(def, attacker, defender) then + return fail(self) + end + + if self.wild then + -- `.wild_force_flee` / `.wild_succeed_playeristarget`. + local userLevel = attacker.level or 1 + local targetLevel = defender.level or 1 + local succeeds = userLevel >= targetLevel + if not succeeds then + local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1)) + succeeds = roll >= math.floor(targetLevel / 4) + end + if not succeeds then return fail(self) end + self.over = true + self.outcome = "fled" + -- FledInFearText for ROAR, BlownAwayText for everything else, naming + -- the mon that was sent away. + self.forcedSwitch = true + self:emit({ kind = "run", side = self:sideOf(defender), + text = self:monName(defender) + .. (moveId == "ROAR" and " fled in fear!" or " was blown away!") }) + return + end + + -- `.trainer` / `.vs_trainer`: the user has to be moving second, and the + -- other side needs someone able on the bench. + if self.firstMover == self:sideOf(attacker) then return fail(self) end + local party = defender == self.player and self.party or self.enemyParty + local active = defender == self.player and self.playerIndex + or self.enemyIndex + local bench = {} + for index, mon in ipairs(party) do + if index ~= active and (mon.hp or 0) > 0 and not mon.isEgg then + bench[#bench + 1] = index + end + end + if #bench == 0 then return fail(self) end + local pick = bench[rand(self.random, #bench) + 1] + self:clearVolatile(defender) + local incoming = party[pick] + if defender == self.player then + self.playerIndex = pick + self.player = incoming + self.participants[pick] = true + self.stages.player = Battle.newStages() + self:checkAmuletCoin(incoming) + else + self.enemyIndex = pick + self.enemy = incoming + self.stages.enemy = Battle.newStages() + end + self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, + text = self:monName(incoming) .. " was dragged out!" }) + self:breakTrapsOnSend(incoming) + self:spikesDamage(incoming) + -- wForcedSwitch: the round ends here, skipping the between-turn effects. + self.forcedSwitch = true +end + +-- -------------------------------------------------------- the move effects +-- +-- The three tables above as records, in the shape src/mods/Schemas.lua's +-- `move_effects` registry validates. Same registry NAME Gen 1 fills from +-- src/battle/MoveEffects.lua, and the same `kind` vocabulary: +-- +-- primary the effect runs INSTEAD of the damage path. Both the +-- state-setting commands above (`run`) and the zero-power status +-- moves (`status`) are primary; which one a record is is which +-- field it carries. +-- secondary a side-effect rolled against the move's effect chance after a +-- hit that already landed. +-- +-- `run` is the Gold signature fn(battle, attacker, defender, def, moveId, +-- sureHit), the same six arguments useMove has always dispatched on -- Gen 1's +-- run takes its own engine's context for the same reason the status records +-- above do. +-- +-- One field Gen 2 adds rather than renaming anything: `status`, the name the +-- effect writes into mon.status, which is what makes EFFECT_TOXIC and +-- EFFECT_POISON_HIT records rather than two more lookup tables. Effects that +-- are steered from inside the damage pipeline (EFFECT_MULTI_HIT, the recoil +-- and drain families) have no standalone handler and so no record yet, exactly +-- as Gen 1's "full" effects have none: they fall through to the damage path, +-- which is what keeps an unmodelled effect honest. +Battle.MOVE_EFFECT_RECORDS = {} + +for id, run in pairs(Battle.MOVE_EFFECTS) do + Battle.MOVE_EFFECT_RECORDS[id] = { kind = "primary", run = run } +end +for id, status in pairs(Battle.STATUS_EFFECTS) do + Battle.MOVE_EFFECT_RECORDS[id] = { kind = "primary", status = status } +end +for id, status in pairs(Battle.SECONDARY_EFFECTS) do + Battle.MOVE_EFFECT_RECORDS[id] = { kind = "secondary", status = status } +end + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function Battle.registerMoveEffectsInto(registry, _, owner) + for id, record in pairs(Battle.MOVE_EFFECT_RECORDS) do + registry:register(id, record, owner) + end +end + +-- the merged `move_effects` record for an effect id, the module's own when no +-- loader ran; a plain function over `data` for the same reason +-- Battle.statusRecordFor is one +function Battle.moveEffectRecordFor(data, effect) + if effect == nil then return nil end + local merged = data and data.gen2MoveEffects + return (merged and merged[effect]) or Battle.MOVE_EFFECT_RECORDS[effect] +end + +-- ------------------------------------------------------------ the statuses +-- +-- Gold's persistent conditions as records, in the shape src/mods/Schemas.lua's +-- `statuses` registry validates. Same registry NAME Gen 1 fills from +-- src/battle/Status.lua, because a mod that adds a status should not have to +-- learn a second noun -- only the ids differ, and they have to: Gold's engine +-- writes "sleep" and "burn" into mon.status where Red writes SLP and BRN. +-- +-- The Gen 1 fields keep their Gen 1 meaning: +-- +-- label / hudLabel the three-letter code the HUD draws +-- catchBonus what PokeBallEffect adds to the catch rate +-- statPenalty the one stat this status cuts, and by what +-- beforeMove CheckPlayerTurn's arm, run before the move +-- beforeMovePriority above VOLATILE_PRIORITY runs ahead of the +-- flinch/confusion block, at or below after it, +-- which is CheckPlayerTurn's own order +-- residual the end-of-turn chip, HandleStatusOnTurnEnd +-- +-- Their SIGNATURES are Gold's, because the two engines carry different +-- objects: Gen 1 hands a battler wrapper and returns message lists, Gold has +-- no battler wrapper and emits its own events, so beforeMove is +-- fn(battle, mon, name) -> canAct, residual is fn(battle, mon, maxHp) -> +-- damage, text, and onInflict is fn(battle, mon) with no return. A record is +-- generation-specific either way -- the ids are disjoint -- so the field names +-- stay shared and the shapes follow the engine that runs them. +-- +-- Three fields Gen 2 genuinely carries that Gen 1 does not, added rather than +-- renaming anything (the catalog's top-level records are extensible): +-- +-- inflictText the tail of the landing line, spliced after the name +-- catchBonusIntended the bonus the cart MEANT to give: the `and` that +-- tests for sleep/freeze leaves burn, poison and +-- paralysis at zero, so catchBonus is 0 for them and +-- this is the 5 that `fixBugs` asks for +-- (src/battle/gen2/Catching.lua statusBonus) +-- substatus true for confusion, which is SUBSTATUS_CONFUSED and +-- not a status byte at all +-- healClass the StatusHealingActions class that cures it, which +-- is how a mod status becomes curable: src/core/gen2/ +-- ItemEffects.lua reads it for any spelling its own +-- STATUS_CLASS fold does not already know +-- +-- Every consumer below reads through Battle:statusRecord, so a mod's sixth +-- status inflicts, chips, blocks a turn and cuts a stat like the vanilla six. +Battle.STATUSES = { + sleep = { + id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp", + inflictText = " fell asleep!", + catchBonus = 10, catchBonusIntended = 10, + -- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero. + onInflict = function(battle, mon) + mon.statusTurns = rand(battle.random, 7) + 1 + end, + beforeMovePriority = 40, + beforeMove = function(battle, mon, name) + mon.statusTurns = (mon.statusTurns or 1) - 1 + if mon.statusTurns <= 0 then + mon.status = nil + mon.statusTurns = nil + battle:emit({ kind = "message", text = name .. " woke up!" }) + return true + end + battle:emit({ kind = "message", text = name .. " is fast asleep!" }) + return false + end, + }, + poison = { + id = "poison", label = "PSN", hudLabel = "PSN", healClass = "psn", + inflictText = " was poisoned!", + catchBonus = 0, catchBonusIntended = 5, + residual = function(_, _, maxHp) + return math.max(1, math.floor(maxHp / Battle.POISON_FRACTION)), + " is hurt by poison!" + end, + }, + toxic = { + -- SUBSTATUS_TOXIC rides the poison byte, so the HUD says PSN either way. + id = "toxic", label = "PSN", hudLabel = "PSN", healClass = "psn", + inflictText = " was badly poisoned!", + catchBonus = 0, catchBonusIntended = 5, + onInflict = function(_, mon) mon.toxicCounter = 1 end, + -- Toxic ramps: n/16 of max HP on the nth turn. + residual = function(_, mon, maxHp) + local counter = mon.toxicCounter or 1 + mon.toxicCounter = counter + 1 + return math.max(1, math.floor(maxHp * counter / 16)), + " is hurt by poison!" + end, + }, + paralyze = { + id = "paralyze", label = "PAR", hudLabel = "PAR", healClass = "par", + inflictText = " is paralyzed! It may be unable to move!", + catchBonus = 0, catchBonusIntended = 5, + statPenalty = { stat = "speed", div = Battle.PARALYSIS_SPEED_DIVISOR }, + -- CheckPlayerTurn's last arm: after the flinch and confusion block. + beforeMovePriority = 10, + beforeMove = function(battle, mon, name) + if rand(battle.random, Battle.PARALYSIS_SKIP_CHANCE) ~= 0 then + return true + end + battle:emit({ kind = "message", text = name .. "'s fully paralyzed!" }) + return false + end, + }, + burn = { + id = "burn", label = "BRN", hudLabel = "BRN", healClass = "brn", + inflictText = " was burned!", + catchBonus = 0, catchBonusIntended = 5, + statPenalty = { stat = "attack", div = Battle.BURN_ATTACK_DIVISOR }, + residual = function(_, _, maxHp) + return math.max(1, math.floor(maxHp / Battle.BURN_FRACTION)), + " is hurt by its burn!" + end, + }, + freeze = { + id = "freeze", label = "FRZ", hudLabel = "FRZ", healClass = "frz", + inflictText = " was frozen solid!", + catchBonus = 10, catchBonusIntended = 10, + beforeMovePriority = 30, + beforeMove = function(battle, mon, name) + if rand(battle.random, Battle.THAW_CHANCE) == 0 then + mon.status = nil + battle:emit({ kind = "message", text = name .. " thawed out!" }) + return true + end + battle:emit({ kind = "message", text = name .. " is frozen solid!" }) + return false + end, + }, + -- SUBSTATUS_CONFUSED: it lives in the volatile beside the major status, so + -- applyStatus hands it to applyConfusion rather than writing mon.status. + -- It is a record all the same because its landing line is one of the seven + -- src/core/gen2/ItemEffects.lua is held against. + confuse = { + id = "confuse", label = "CONFUSED", inflictText = " became confused!", + substatus = true, + }, +} + +-- beforeMovePriority above this runs ahead of the flinch/confusion block, +-- at or below after it -- CheckPlayerTurn's order, and the same constant +-- src/battle/Status.lua uses for the Gen 1 gauntlet. +Battle.VOLATILE_PRIORITY = 20 + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function Battle.registerStatusesInto(registry, _, owner) + for id, record in pairs(Battle.STATUSES) do + registry:register(id, record, owner) + end +end + +-- Kept as the derived view of the records: src/core/gen2/ItemEffects.lua's +-- cross-file contract (every name Battle can write into mon.status resolves to +-- a heal class) is checked against this table, and building it from STATUSES +-- is what stops the two from drifting. +Battle.STATUS_TEXT = {} +for id, record in pairs(Battle.STATUSES) do + Battle.STATUS_TEXT[id] = record.inflictText +end + +-- The merged `statuses` record for a status id, the module's own when no +-- loader ran -- src/battle/BattleState.lua:effectRecord is the Gen 1 twin. +-- A plain function over `data` rather than a method on purpose: the tests +-- drive canAct and tickStatus against hand-built actor stubs that carry a mon +-- and an emit and nothing else, and a lookup that needed a method would make +-- every one of those stubs implement it. +function Battle.statusRecordFor(data, status) + if status == nil then return nil end + local merged = data and data.gen2Statuses + return (merged and merged[status]) or Battle.STATUSES[status] +end + +-- The one stat this mon's status cuts, applied. Burn halves Attack and +-- paralysis quarters Speed on the cart; both come off statPenalty so a mod +-- status cuts a stat through the same seam. +function Battle.statusPenaltyFor(data, mon, stat, value) + local record = Battle.statusRecordFor(data, mon and mon.status) + local penalty = record and record.statPenalty + if not penalty or penalty.stat ~= stat then return value end + return math.max(1, math.floor(value / math.max(1, penalty.div or 1))) +end + +-- `source` is the battler that inflicted it, carried only so +-- battle.status_inflicted can name it the way Gen 1's does. +function Battle:applyStatus(mon, status, source) + if (mon.hp or 0) <= 0 then return false end + -- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives + -- in the volatile beside the major status, so a confused mon can still be + -- burned and a switch shakes the confusion off. + if status == "confuse" then return self:applyConfusion(mon) end + -- One major status at a time. + if mon.status then + self:emit({ kind = "message", + text = "But it failed!" }) + return false + end + mon.status = status + -- Through the merged record: onInflict is where the sleep roll and the Toxic + -- counter live, so a mod status can arm its own counter here too. + local record = Battle.statusRecordFor(self.data, status) + if record and record.onInflict then record.onInflict(self, mon) end + self:emit({ kind = "status", side = self:sideOf(mon), status = status, + text = self:monName(mon) + .. ((record and record.inflictText) or " is afflicted!") }) + -- battle.status_inflicted, the payload src/battle/StatusRegistry.lua emits on + -- Gen 1, for the major status only -- confusion is a substatus in both + -- generations and Gen 1 raises nothing for it either. The `status` VALUE is + -- Gen 2's own spelling ("poison", "burn", "paralyze"), not Gen 1's PSN/BRN + -- code: the key still names the status that landed, and Gold's engine has no + -- three-letter codes to report. + Runtime.emit("battle.status_inflicted", { + battle = self, target = mon, status = status, source = source, + side = self:sideOf(mon), + }) + return true +end + +-- BattleCommand_FinishConfusingTarget (effect_commands.asm:5734): the +-- SUBSTATUS_CONFUSED bit plus a 2-5 turn count (`and %11` plus two). +-- `turns` is the Berserk Gene's override: HandleBerserkGene sets the bit +-- WITHOUT writing the count (core.asm:301), and the zero count decrements +-- through zero on the cart -- an effectively permanent lock, modelled here +-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright. +Battle.BERSERK_GENE_CONFUSE_TURNS = 256 + +function Battle:applyConfusion(mon, turns) + if (mon.hp or 0) <= 0 then return false end + local state = self:volatile(mon) + if (state.substitute or 0) > 0 then return false end + local held = self:heldEffect(mon, "confuse") + if held == "HELD_PREVENT_CONFUSE" then return false end + if state.confuseCount then + self:emit({ kind = "message", + text = self:monName(mon) .. "'s already confused!" }) + return false + end + state.confuseCount = turns or (rand(self.random, 4) + 2) + local record = Battle.statusRecordFor(self.data, "confuse") + self:emit({ kind = "message", + text = self:monName(mon) + .. ((record and record.inflictText) or " became confused!") }) + return true +end + +-- ResidualDamage picks the anim off the status byte, ANIM_BRN for a burn and +-- ANIM_PSN for either poison (engine/battle/core.asm:958-976). +Battle.RESIDUAL_ANIM = { + burn = "ANIM_BRN", poison = "ANIM_PSN", toxic = "ANIM_PSN", +} + +-- End of turn: burn and poison chip damage, through the merged record's +-- `residual`. The record computes and advances its own counter; the emit pair +-- stays here because the event shape belongs to this engine, not to the status. +function Battle:tickStatus(mon) + if (mon.hp or 0) <= 0 or not mon.status then return end + local record = Battle.statusRecordFor(self.data, mon.status) + local residual = record and record.residual + if not residual then return end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 + local name = self:monName(mon) + local damage, text = residual(self, mon, maxHp) + if not damage or damage <= 0 then return end + mon.hp = math.max(0, mon.hp - damage) + self:emit({ kind = "message", text = name .. (text or " is hurt!") }) + -- Call_PlayBattleAnim_OnlyIfVisible runs on the sufferer's own turn + -- (core.asm:970-976); a mod status the cart never had gets nothing. + self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage, + hp = mon.hp, anim = Battle.RESIDUAL_ANIM[mon.status] or false, + animSide = self:sideOf(mon) }) +end + +-- Faint bookkeeping and experience. Returns true when the battle ended. +function Battle:resolveFaints() + if (self.enemy.hp or 0) <= 0 then + self:emit({ kind = "faint", side = "enemy", + text = (self.wild and "Wild " or "") .. self:monName(self.enemy) + .. " fainted!" }) + -- battle.fainted, the payload BattleState:onFaint emits on Gen 1. + -- `battler` is the mon itself here: Gen 2's engine has no battler wrapper. + Runtime.emit("battle.fainted", { battle = self, battler = self.enemy, + side = self:sideRecord(self.enemy) }) + self:awardExperience(self.enemy) + local nextIndex = Battle.firstHealthy(self.enemyParty) + if not nextIndex then + if self.trainer then + self:emit({ kind = "message", + text = (self.trainer.name or "TRAINER") .. " was defeated!" }) + self:awardPrizeMoney() + end + self:endBattle("win") + return true + end + local previous = self.enemy + self:clearVolatile(self.enemy) + self.enemyIndex = nextIndex + self.enemy = self.enemyParty[nextIndex] + -- ResetEnemyBattleVars (engine/battle/core.asm:3016) and NewEnemyMonStatus + -- clear the move selection and the substatus bytes for the mon coming IN, + -- so a replacement never inherits anything from its last stint. + self:clearVolatile(self.enemy) + self.stages.enemy = Battle.newStages() + -- `replacement` marks HandleEnemySwitch's send, the only one EnemySwitch + -- can offer a shift on (engine/battle/core.asm:2241-2278). + self:emit({ kind = "send", side = "enemy", mon = self.enemy, + replacement = true, + text = (self.trainer and self.trainer.name or "Foe") .. " sent out " + .. self:monName(self.enemy) .. "!" }) + Runtime.emit("battle.battler_switched", { + battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, + previous = previous, + }) + self:breakTrapsOnSend(self.enemy) + -- core.asm runs SpikesDamage on every send-out; the faint replacement + -- is not exempt. + self:spikesDamage(self.enemy) + -- Battle_PlayerFirst reaches HandleEnemyMonFaint with `jp`, not `call` + -- (engine/battle/core.asm:872), so the round's attack phase is over: the + -- mon that just walked in never answers, and the move that was queued for + -- the one it replaced is never spent. Battle:takeTurn reads this. + self.faintInterrupt = true + return false + end + + if (self.player.hp or 0) <= 0 then + -- Announce a faint ONCE. + -- + -- This branch is the only one that returns without changing whose turn it + -- is: it emits `choose-switch` and waits for the caller to pick, so the + -- caller calls back in with the same mon still at 0 HP and the whole branch + -- ran again. The visible symptom was "TYPHLOSION fainted!" three times in + -- a row, but the real damage is one line lower -- `faintHappiness` was + -- charged once per re-entry, so a single faint cost two or three times the + -- happiness the cart takes (engine/battle/core.asm, HandlePlayerMonFaint + -- runs its happiness arm once). + -- + -- Keyed on the mon itself, so the next one in announces normally. + if self.faintAnnounced ~= self.player then + self.faintAnnounced = self.player + self:emit({ kind = "faint", side = "player", + text = self:monName(self.player) .. " fainted!" }) + Runtime.emit("battle.fainted", { battle = self, battler = self.player, + side = self:sideRecord(self.player) }) + self:faintHappiness(self.player) + end + local nextIndex = Battle.firstHealthy(self.party) + if not nextIndex then + self:emit({ kind = "message", text = "You have no more POKéMON!" }) + self:endBattle("lose") + return true + end + -- The player picks the replacement; the caller drives that with :switch. + -- + -- Asked for ONCE, keyed the same way the faint line above is: takeTurn + -- reaches resolveFaints up to three times in a round, and every one of + -- them still sees a 0 HP mon because nothing switches until the player + -- answers. HandlePlayerMonFaint runs ForcePlayerMonChoice a single time + -- (engine/battle/core.asm:2543) and the turn loop does not come back for + -- another; three prompts in the queue meant the party menu reopened on top + -- of the pick that had already been made, so the switch looked like it + -- took two or three attempts. Battle:switch releases the guard. + if not self.pendingSwitch then + self.pendingSwitch = true + self:emit({ kind = "choose-switch" }) + end + -- Same `jp`, not `call`, as the enemy arm above (core.asm:874): whatever + -- is left of the attack phase is abandoned. + self.faintInterrupt = true + return false + end + return false +end + +-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated +-- and the frontpic slide: the four quarters are dealt between the wallet and +-- Mom's savings and then one StdBattleTextbox names the figure. +-- +-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of +-- PrintWinLossText is the trainer's own after-battle line, which this port +-- runs from the script on the way out of the battle rather than from here. +-- The payout is not gated on it either way. +function Battle:awardPrizeMoney() + local save = self.save + if not (save and save.player) then return nil end + local award = Prize.award(save, { + baseMoney = self.trainer and self.trainer.baseMoney, + -- wCurPartyLevel, left behind by ReadTrainerParty: the LAST row of the + -- roster, whatever order the mons actually fainted in. + level = Prize.rewardLevel(self.enemyParty), + amuletCoin = self.amuletCoin, + }) + self.prize = award + self:emit({ kind = "money", award = award, + text = Prize.message(award, save.player and save.player.name) }) + return award +end + +-- UpdateFaintedPlayerMon (engine/battle/core.asm), the happiness half. Runs +-- on EVERY player faint, not only the whiteout, and picks between two events +-- by how outclassed the mon was: +-- +-- ld a, [wBattleMonLevel] / add 30 / ld b, a +-- ld a, [wEnemyMonLevel] / cp b / jr c, .got_param +-- +-- `jr c` keeps HAPPINESS_FAINTED while the foe is BELOW yourLevel + 30, so the +-- harsher HAPPINESS_BEATENBYSTRONGFOE needs the foe to be at least thirty +-- levels up -- and the two events differ only in the third tier anyway (-1 for +-- a plain loss against -10 for a beating, at happiness 200 or more). +function Battle:faintHappiness(mon) + if not mon then return end + local event = "FAINTED" + if (self.enemy.level or 0) >= (mon.level or 0) + 30 then + event = "BEATENBYSTRONGFOE" + end + -- ChangeHappiness runs against the party slot, and this mon IS that slot's + -- table, so a fainted mon is still the thing that loses the point. + Happiness.change(mon, event) +end + +-- GiveExperiencePoints' traded check: the mon's OT id against wPlayerID. A +-- mon with no recorded OT (the port's native catches and gifts) is the +-- player's own. +function Battle:isOutsider(mon) + local playerId = self.save and self.save.player and self.save.player.id + if mon.otId == nil or playerId == nil then return false end + return mon.otId ~= playerId +end + +-- One pass of GiveExperiencePoints over `recipients` (party indices). +-- `count` is the pass's own divisor -- the participant count for the first +-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether +-- any Share holder taxed the whole pool. +function Battle:giveExperiencePass(loser, def, recipients, count, halved) + for _, index in ipairs(recipients) do + local mon = self.party[index] + if mon and (mon.hp or 0) > 0 and not mon.isEgg then + local traded = self:isOutsider(mon) + -- `cp LUCKY_EGG` on the mon's item byte: by id, not held effect. + local luckyEgg = mon.item == "LUCKY_EGG" + -- exp.gain, the same hook src/battle/Experience.lua calls on Gen 1 and + -- with the same ctx keys (defeatedDef, level, isTrainer, participants, + -- traded, mon), so a mod that scales exp reads and edits the fields it + -- did on Red. `halved` (the EXP.SHARE tax on the whole pool) and + -- `luckyEgg` are Gen 2's own multipliers and ride beside them. + local amount + if Runtime.wantsHook("exp.gain") then + amount = Runtime.call("exp.gain", function(c) + return Mon.experienceGain(c.defeatedDef, c.level, c.participants, + c.isTrainer, { halved = c.halved, traded = c.traded, + luckyEgg = c.luckyEgg }) + end, { defeatedDef = def, level = loser.level, + isTrainer = self.trainer ~= nil, participants = count, + traded = traded, mon = mon, + halved = halved, luckyEgg = luckyEgg, + battle = self, loser = loser }) + else + amount = Mon.experienceGain(def, loser.level, count, + self.trainer ~= nil, { halved = halved, traded = traded, + luckyEgg = luckyEgg }) + end + -- Stat exp first: GiveExperiencePoints awards it before the exp points, + -- so a mon that levels on this kill recalculates its stats with the + -- effort it just earned already counted. Pokerus (or the immune marker + -- a cured mon keeps) doubles it. + Mon.gainStatExp(mon, def, count, Pokerus.doublesStatExp(mon), halved) + local result = Mon.gainExperience(mon, amount, self.data) + -- battle.exp_gained, the payload BattleState:awardExp emits on Gen 1. + -- `levels` is the LIST of levels reached, the same shape Gen 1's + -- Experience.apply returns, built out of Gen 2's from/to pair -- and + -- built only when something is listening, since a KO in a six-mon party + -- comes through here once per recipient. + if Runtime.wants("battle.exp_gained") then + local levels = {} + for level = (result.from or 0) + 1, result.to or 0 do + levels[#levels + 1] = level + end + Runtime.emit("battle.exp_gained", { + battle = self, mon = mon, gained = amount, levels = levels, + -- Gen 2 addition: the party slot, which is what the engine's own + -- experience event is keyed by. + index = index, + }) + end + self:emit({ kind = "experience", index = index, amount = amount, + -- BoostedExpPointsText, keyed on the traded arm alone. + text = self:monName(mon) .. " gained " + .. (traded and "a boosted " or "") .. amount .. " EXP. Points!" }) + if result.levels > 0 then + -- "level up happiness mod", the cart's own comment, sitting right + -- after the stat recalc and before the "grew to level" text. It fires + -- ONCE per exp award however many levels the mon jumped, because + -- ChangeHappiness is outside the level loop. + Happiness.change(mon, "GAINLEVEL") + self:emit({ kind = "level", index = index, level = mon.level, + text = self:monName(mon) .. " grew to level " .. mon.level .. "!" }) + for _, moveId in ipairs(result.learned) do + local ok, reason, entry = Mon.learnMove(mon, moveId, self.data) + local moveDef = self:moveDef(moveId) + local moveName = (moveDef and moveDef.name) or moveId + if ok then + self:emit({ kind = "message", + text = self:monName(mon) .. " learned " .. moveName .. "!" }) + elseif reason == "full" then + -- LearnMove's full-moveset arm calls ForgetMove, which asks with + -- AskForgetMoveText (engine/pokemon/learn.asm:29-34, :121-124). + self:emit({ kind = "choose-forget", index = index, move = entry, + moveName = moveName }) + end + end + end + end + end +end + +-- PokeBallEffect's captured tail, the battle half of it: the catch site +-- (src/ui/gen2/BattleState.lua:pushCaught) owns the #DEX, the party and the +-- nickname prompt, and this owns what the BATTLE still has to say about the +-- mon that was just taken off the field. The Gen 1 twin is +-- src/battle/BattleState.lua:storeCaughtMon, which opens on exactly these two +-- steps in this order. +-- +-- * the reload. `.catch_without_fail` puts wTempEnemyMonSpecies -- the +-- species the mon was SENT OUT as, which no move rewrites -- into +-- wCurPartySpecies before the mon is added, so a DITTO that transformed is +-- caught as a DITTO with its own moves. Gen 1 does the same thing for +-- Mimic (BattleState:restoreMimicked, cited at its own call). +-- * battle.catch_exp. Vanilla catches never grant exp; a mod can flip the +-- hook to true to pay out the same award a faint would have. Same name, +-- same default and the same one-key ctx as the Gen 1 site, so one +-- subscription covers both games (docs/mod-api-gen2-compat.md). +-- +-- Safe to call more than once: the reload is a no-op once the identity is +-- back, and `caughtHandled` keeps a second call from paying the exp twice. +function Battle:caught(mon) + mon = mon or self.enemy + if self.caughtHandled then return mon end + self.caughtHandled = true + self:untransform(mon) + if Runtime.wantsHook("battle.catch_exp") + and Runtime.call("battle.catch_exp", function() return false end, + { battle = self }) then + self:awardExperience(mon) + end + return mon +end + +-- GiveExperiencePoints, both calls (engine/battle/core.asm:2116/2130): with +-- any live EXP.SHARE holder in the party the enemy's base exp and base +-- stats are halved up front, the participants split the first pass, and a +-- second pass pays every holder -- participant or not, so a holder that +-- fought collects twice. Holders are found by ITEM id, the way +-- IsAnyMonHoldingExpShare's `cp EXP_SHARE` does, and a fainted holder gets +-- nothing (the pass loop skips fainted mons). +function Battle:awardExperience(loser) + local def = self:speciesDef(loser) + + local participants = {} + for index in pairs(self.participants) do + participants[#participants + 1] = index + end + table.sort(participants) + + local holders = {} + for index, mon in ipairs(self.party) do + if (mon.hp or 0) > 0 and not mon.isEgg and mon.item == "EXP_SHARE" then + holders[#holders + 1] = index + end + end + + local halved = #holders > 0 + local function vanillaAward() + self:giveExperiencePass(loser, def, participants, #participants, halved) + if halved then + self:giveExperiencePass(loser, def, holders, #holders, true) + end + end + + -- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and + -- with the same ctx: the participant COUNT, the live participants, and an + -- applyShare(mon, split) a mod can call to pay one mon its own share. The + -- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2 + -- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is + -- accepted and ignored rather than changing what is printed. `recipients`, + -- `holders` and `halved` are the Gen 2 additions. + if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end + local alive = {} + for _, index in ipairs(participants) do + local mon = self.party[index] + if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end + end + local function applyShare(mon, split) + for index, candidate in ipairs(self.party) do + if candidate == mon then + return self:giveExperiencePass(loser, def, { index }, + math.max(1, split or 1), halved) + end + end + end + Runtime.call("battle.exp_award", vanillaAward, { + battle = self, participants = #participants, alive = alive, + applyShare = applyShare, recipients = participants, holders = holders, + halved = halved, loser = loser, + }) +end + +-- The answer to a `choose-forget`: drop the move in `slot` and put the +-- pending one there, then queue the cart's "forgot X / learned Y" lines. The +-- battle slot aliases the party slot the same way Mimic does, so a mon in play +-- picks up the new move immediately. +function Battle:resolveForget(index, slot, entry, moveName) + local mon = self.party[index] + if not (mon and mon.moves and mon.moves[slot] and entry) then return false end + local old = mon.moves[slot] + local oldDef = self:moveDef(old.id) + local oldName = (oldDef and oldDef.name) or old.id + mon.moves[slot] = entry + -- Keep the in-play battler's move list pointing at the same table, so a mon + -- that levelled mid-battle fights the rest of it with the new move. + if self.player == mon and self.player.moves ~= mon.moves then + self.player.moves = mon.moves + end + self:emit({ kind = "message", + text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" }) + self:emit({ kind = "message", + text = self:monName(mon) .. " learned " + .. (moveName or (entry and entry.id) or "?") .. "!" }) + -- The forget path writes the slot itself rather than going through + -- Mon.learnMove, so pokemon.move_learned is raised here too: a move WAS + -- learned, and a mod counting moves must not miss the four-slot case. + Runtime.emit("pokemon.move_learned", { mon = mon, moveId = entry.id }) + return true +end + +-- The other answer: keep the four it has. MoveDidntLearn's line. +function Battle:declineForget(index, moveName) + local mon = self.party[index] + self:emit({ kind = "message", + text = (mon and self:monName(mon) or "It") .. " did not learn " + .. (moveName or "the move") .. "." }) +end + +-- NewBattleMonStatus / the enemy switch tail (core.asm:3864 and 3405): ANY +-- send-out ends BOTH partial traps and drops the CANT_RUN pin that was aimed +-- at the incoming side -- whose holder is the opponent, so it is the +-- opponent's volatile that carries it. +function Battle:breakTrapsOnSend(incoming) + for _, mon in ipairs({ self.player, self.enemy }) do + local state = self:volatile(mon) + state.wrapCount, state.wrapMove, state.wrapMoveId = nil, nil, nil + end + local opponent = (incoming == self.player) and self.enemy or self.player + self:volatile(opponent).trapsTarget = nil +end + +-- TryPlayerSwitch's `.check_trapped` (core.asm:4886): a live wrap on the +-- active mon or the enemy's CANT_RUN pin refuses a VOLUNTARY switch with +-- "can't be recalled!". The faint replacement path never asks. +function Battle:switchLocked() + if (self:volatile(self.player).wrapCount or 0) > 0 then return true end + return self:volatile(self.enemy).trapsTarget == true +end + +-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch +-- (engine/battle/core.asm:2959-2961). +function Battle:shiftSwitch(index) + self.participants = {} + return self:switch(index) +end + +-- Switch the player's active mon. A switch takes the whole turn. +function Battle:switch(index) + local mon = self.party[index] + if not mon or (mon.hp or 0) <= 0 then return false end + if mon == self.player then return false end + -- Switching out drops every volatile: the Substitute, the charge, the + -- Rollout ramp and the stat stages all go with it. The incoming mon starts + -- from an empty area too (NewBattleMonStatus runs at every send-out), so a + -- mon that comes back in carries nothing from its last stint. + local previous = self.player + self:clearVolatile(self.player) + self:clearVolatile(mon) + -- A mon that comes back (a REVIVE, or a second battle) has to be able to + -- announce its own faint again; see resolveFaints. + self.faintAnnounced = nil + -- ForcePlayerMonChoice has been answered, so the next faint may ask again. + self.pendingSwitch = nil + self.player = mon + self.playerIndex = index + self.participants[index] = true + self.stages.player = Battle.newStages() + self:emit({ kind = "send", side = "player", mon = mon, + text = "Go! " .. self:monName(mon) .. "!" }) + -- battle.battler_switched, the payload BattleState:resolveSwitch emits on + -- Gen 1: the side record, whoever walked in, and whoever walked out. + Runtime.emit("battle.battler_switched", { + battle = self, side = self:sideRecord(mon), battler = mon, + previous = previous, + }) + self:breakTrapsOnSend(mon) + self:checkAmuletCoin(mon) + self:spikesDamage(mon) + return true +end + +-- CheckAmuletCoin (engine/battle/core.asm), which sits in the send-out path +-- rather than in the payout: `ld a, [wBattleMonItem] / GetItemHeldEffect / cp +-- HELD_AMULET_COIN`, then a 1 into wAmuletCoin. Nothing clears the byte for +-- the rest of the battle, so a mon that was sent out holding the coin still +-- doubles the prize after it has fainted or been switched away. +function Battle:checkAmuletCoin(mon) + if mon and mon.item == Prize.AMULET_COIN then self.amuletCoin = true end +end + +-- HandleBerserkGene (engine/battle/core.asm:301). Checked by ITEM id, not +-- held effect (`sub BERSERK_GENE` on the item byte; its attribute byte is +-- HELD_NONE on the cart). The gene is consumed, Attack jumps two stages +-- (BattleCommand_AttackUp2) and the holder is confused -- with no count +-- written on the cart, the near-permanent lock the walkthroughs warn about +-- (Battle.BERSERK_GENE_CONFUSE_TURNS). +function Battle:checkBerserkGene(mon) + if not mon or mon.item ~= "BERSERK_GENE" or (mon.hp or 0) <= 0 then + return false + end + local def = self:itemDef(mon.item) + mon.item = nil + self:emit({ kind = "message", + text = self:monName(mon) .. "'s " + .. ((def and def.name) or "BERSERK GENE") .. " activated!" }) + self:changeStage(mon, "attack", 2) + self:applyConfusion(mon, Battle.BERSERK_GENE_CONFUSE_TURNS) + return true +end + +-- BattleCommand_CheckObedience's badge ladder: the obedience cap by owned +-- Johto badges. MAX_LEVEL + 1 for RISINGBADGE means nothing ever disobeys. +function Battle:obedienceLevel() + if self:hasBadge("badges", "RISING") then return Mon.MAX_LEVEL + 1 end + if self:hasBadge("badges", "STORM") then return 70 end + if self:hasBadge("badges", "FOG") then return 50 end + if self:hasBadge("badges", "HIVE") then return 30 end + return 10 +end + +-- The cart's `.rand1` / `.rand2`: one byte, re-rolled until it lands under +-- `limit`. Guarded so an injected test roller that never goes low cannot +-- spin forever; the fallback fold keeps the result in range. +function Battle:rollBelow(limit) + for _ = 1, 128 do + local roll = rand(self.random, 256) + if roll < limit then return roll end + end + return rand(self.random, 256) % math.max(1, limit) +end + +-- HitConfusion (engine/battle/effect_commands.asm:613): a typeless 40 power +-- physical hit against the user's OWN Defense -- stat stages and the badge +-- boosts apply through wPlayerStats, but there is no crit, no STAB, no type +-- row and no damage variation; DamageCalc's MIN_DAMAGE floor still holds. +-- Shared by the confusion self-hit and the disobedience self-hit. +function Battle:confusionSelfHit(mon) + local stages = self.stages[self:sideOf(mon)] + local attack = Damage.applyStage(self:battleStat(mon, "attack"), + stages.attack or 0) + attack = Battle.statusPenaltyFor(self.data, mon, "attack", attack) + local defense = Damage.applyStage(self:battleStat(mon, "defense"), + stages.defense or 0) + local damage = Damage.base(mon.level or 1, 40, attack, defense) + damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE) + + Damage.MIN_DAMAGE + self:emit({ kind = "message", + text = "It hurt itself in its confusion!" }) + mon.hp = math.max(0, (mon.hp or 0) - damage) + -- HitConfusion flickers with ANIM_HIT_CONFUSION on the self-hitter's own + -- turn, not the move after-anim (effect_commands.asm:624-632, :521-529). + self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage, + hp = mon.hp, anim = "ANIM_HIT_CONFUSION", animSide = self:sideOf(mon) }) + return damage +end + +-- BattleCommand_CheckObedience (engine/battle/effect_commands.asm:642). +-- Player side only; an outsider mon (OT id differs from the player's) above +-- the badge-gated level cap rolls to obey. Returns true when the mon +-- disobeyed and the turn is spent. +-- +-- The outcome ladder, in the asm's order: a first roll under the cap obeys; +-- a second roll under the cap uses a DIFFERENT move instead; past both, the +-- margin above the cap decides between napping, hitting itself and one of +-- the four loafing lines. +function Battle:checkObedience(moveId) + local mon = self.player + if not mon then return false end + -- CheckUserIsCharging: the stored half of a two-turn move is exempt. + if self:volatile(mon).chargeMove then return false end + local save = self.save + local playerId = save and save.player and save.player.id + if mon.otId == nil or playerId == nil or mon.otId == playerId then + return false + end + local cap = self:obedienceLevel() + local level = mon.level or 1 + if level <= cap then return false end + local limit = math.min(255, cap + level) + if self:rollBelow(limit) < cap then return false end + + local name = self:monName(mon) + if self:rollBelow(limit) < cap then + -- `.UseInstead`: another known move with PP, never the picked one and + -- never a disabled one; with no alternative it falls through to + -- loafing. + local others = {} + for _, move in ipairs(mon.moves or {}) do + if move.id ~= moveId and (move.pp or 0) > 0 + and not self:moveDisabled(mon, move.id) then + others[#others + 1] = move.id + end + end + if #others > 0 then + local pick = others[rand(self.random, #others) + 1] + self:useMove(mon, self.enemy, pick) + return true + end + end + + local margin = level - cap + local roll = rand(self.random, 256) + if roll < margin then + -- `.Nap`: 1-7 turns of sleep written STRAIGHT into the status byte, + -- over whatever was there. + mon.status = "sleep" + mon.statusTurns = rand(self.random, 7) + 1 + mon.toxicCounter = nil + self:emit({ kind = "status", side = self:sideOf(mon), status = "sleep", + text = name .. " began to nap!" }) + return true + end + if roll - margin < margin then + self:emit({ kind = "message", text = name .. " won't obey!" }) + self:confusionSelfHit(mon) + return true + end + -- `.DoNothing`: one of four lines. + local lines = { + " is loafing around.", " won't obey!", " turned away!", + " ignored orders!", + } + self:emit({ kind = "message", + text = name .. lines[rand(self.random, 4) + 1] }) + return true +end + +-- The battle half of the PACK's battle items, dispatched by the screen +-- (src/ui/gen2/BattleState.lua): the four X items raise one stage +-- (XItemEffect -> RaiseStat), and X ACCURACY / DIRE HIT / GUARD SPEC set +-- their SUBSTATUS bit, refusing a second use the way +-- WontHaveAnyEffect_NotUsedMessage does -- in that case the item is NOT +-- consumed and the turn not spent, which the false return tells the caller. +function Battle:useBattleItem(itemId) + local stat = Battle.X_ITEM_STATS[itemId] + if stat then + local def = self:itemDef(itemId) + self:emit({ kind = "message", + text = "Used the " .. ((def and def.name) or itemId) .. "." }) + self:changeStage(self.player, stat, 1) + return true + end + local field = Battle.SUBSTATUS_ITEMS[itemId] + if not field then return false, "unknown" end + local state = self:volatile(self.player) + if state[field] then return false, "no-effect" end + state[field] = true + local def = self:itemDef(itemId) + self:emit({ kind = "message", + text = "Used the " .. ((def and def.name) or itemId) .. "." }) + if itemId == "GUARD_SPEC" then + self:emit({ kind = "message", + text = self:monName(self.player) .. "'s shrouded in MIST!" }) + elseif itemId == "DIRE_HIT" then + self:emit({ kind = "message", + text = self:monName(self.player) .. " is getting pumped!" }) + end + return true +end + +-- SpikesDamage (engine/battle/core.asm): an eighth of max HP the moment a mon +-- walks into them. Gen 2 has one layer -- the stacking is Gen 3 -- but it +-- does have the Flying immunity: the routine reads wBattleMonType / the enemy +-- pair and `cp FLYING / ret z` on BOTH slots before GetEighthMaxHP, so a +-- Flying-type takes nothing and the line is not printed either. +function Battle:spikesDamage(mon) + local side = self:sideOf(mon) + if not self.spikes[side] or (mon.hp or 0) <= 0 then return end + local def = self:speciesDef(mon) + for _, monType in ipairs((def and def.types) or mon.types or {}) do + if monType == "FLYING" then return end + end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8 + local damage = math.max(1, math.floor(maxHp / 8)) + mon.hp = math.max(0, mon.hp - damage) + self:emit({ kind = "message", + text = self:monName(mon) .. " is hurt by SPIKES!" }) + -- SpikesDamage is text, HP and a HUD redraw: no anim (core.asm:3902-3910). + self:emit({ kind = "damage", side = side, amount = damage, hp = mon.hp, + anim = false }) +end + +-- CheckPlayerLockedIn (engine/battle/core.asm:533-556) quits ParsePlayerAction +-- outright for SUBSTATUS_ROLLOUT and SUBSTATUS_RAMPAGE, so a mon partway +-- through a Rollout or a Thrash is offered no menu, spends no PP (both +-- checkrollout and checkrampage skip past doturn) and makes no obedience +-- check. Split out because playerAttack needs the same answer. +function Battle:lockedInMove(mon) + local state = self:volatile(mon) + if state.rolloutLock then return state.rolloutLock end + if state.rampageMove and (state.rampageTurns or 0) > 0 then + return state.rampageMove + end + return nil +end + +-- Encore forces the move; Disable forbids one. Both are read by the screen +-- (to grey out the move list) and by the enemy's own choice below. +function Battle:forcedMove(mon) + local locked = self:lockedInMove(mon) + if locked then return locked end + local state = self:volatile(mon) + if not state.encore then return nil end + for _, move in ipairs(mon.moves or {}) do + if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end + end + -- Encore ends early when the move runs out of PP. + state.encore, state.encoreTurns = nil, nil + return nil +end + +function Battle:moveDisabled(mon, moveId) + return self:volatile(mon).disabled == moveId +end + +-- The moves a side may actually pick this turn. +function Battle:usableMoves(mon) + local forced = self:forcedMove(mon) + -- CheckPlayerLockedIn quits ParsePlayerAction ahead of + -- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage + -- that spent its last PP on the opening turn keeps running: no later turn + -- of the lock spends any. Encore is not in this exemption -- forcedMove + -- ends it the moment the encored move runs dry. + local locked = self:lockedInMove(mon) + local out = {} + for _, move in ipairs(mon.moves or {}) do + local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id) + if move.id == locked then ok = true end + if forced then ok = ok and move.id == forced end + if ok then out[#out + 1] = move end + end + return out +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. +function Battle:tryRun() + -- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone, + -- which is what BattleMenu_Run reads to decide whether the turn was spent + -- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the + -- bottom, writes BATTLEPLAYERACTION_USEITEM and buys the enemy a move. + self.runRefused = nil + -- The battle-type ladder runs FIRST: BATTLETYPE_TRAP and + -- 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 + self:emit({ kind = "message", text = "Can't escape!" }) + self.runRefused = true + return false + end + if self.trainer then + self:emit({ kind = "message", text = "No! There's no running from a " + .. "trainer battle!" }) + self.runRefused = true + return false + end + -- SUBSTATUS_CANT_RUN held by the ENEMY (its Mean Look pinned the player) + -- and a live wrap count on the player both refuse before the speed math + -- and before the attempt is even counted. + if self:volatile(self.enemy).trapsTarget + or (self:volatile(self.player).wrapCount or 0) > 0 then + self:emit({ kind = "message", text = "Can't escape!" }) + self.runRefused = true + return false + end + self.runAttempts = (self.runAttempts or 0) + 1 + if self:runRoll(self:effectiveSpeed(self.player), + self:effectiveSpeed(self.enemy)) then + self:emit({ kind = "run", text = "Got away safely!" }) + self:endBattle("run") + return true + end + self:emit({ kind = "message", text = "Can't escape!" }) + return false +end + +-- The escape roll itself, hooked as battle.run -- the same hook +-- BattleState:runRoll calls on Gen 1, with the same ctx keys (pSpd, eSpd, +-- attempts, rng) and the same boolean return. The attempt has already been +-- counted by the caller, exactly as it is on Gen 1, so a mod that refuses the +-- escape still leaves the count raised. +function Battle:runRoll(pSpd, eSpd) + if Runtime.wantsHook("battle.run") then + return Runtime.call("battle.run", function(c) + return c.battle:runRollVanilla(c.pSpd, c.eSpd) + end, { battle = self, pSpd = pSpd, eSpd = eSpd, + attempts = self.runAttempts, rng = self:roller(), + random = self.random }) + end + return self:runRollVanilla(pSpd, eSpd) +end + +function Battle:runRollVanilla(pSpd, eSpd) + if pSpd >= eSpd then return true end + -- (playerSpeed * 32 / (enemySpeed / 4)) + 30 * attempts, out of 256. + local odds = math.floor(pSpd * 32 + / math.max(1, math.floor(eSpd / 4))) + 30 * (self.runAttempts or 1) + return odds >= 256 or rand(self.random, 256) < odds +end + +-- TryEnemyFlee (engine/battle/core.asm), called at the head of the enemy's +-- half of the turn in BOTH orders (Battle_EnemyFirst runs it first thing, +-- Battle_PlayerFirst runs it once the player's move has resolved). +-- +-- The gates, in the asm's order: +-- * trainer battles never flee (`ld a, [wBattleMode] / dec a / jr nz`) +-- * SUBSTATUS_CANT_RUN on the PLAYER (Mean Look, Spider Web) pins it +-- * a live wrap count pins it +-- * frozen or asleep pins it +-- * AlwaysFleeMons -> gone, no roll at all. Raikou, Entei and Suicune are +-- that whole list, which is why a beast gets exactly one turn of yours +-- * otherwise one random byte: under 50 percent + 1 lets OftenFleeMons go, +-- and under 10 percent + 1 lets SometimesFleeMons go. ONE byte for both +-- gates, so the two lists are not independent rolls +-- +-- `percent` is `* $ff / 100` (macros/data.asm), so those two thresholds are +-- 128 and 26 rather than 128 and 26-ish: 50*255/100 = 127, +1; 10*255/100 = +-- 25, +1. +-- +Battle.OFTEN_FLEE_ROLL = 128 -- 50 percent + 1 +Battle.SOMETIMES_FLEE_ROLL = 26 -- 10 percent + 1 + +function Battle:tryEnemyFlee() + if not self.wild then return false end + -- SUBSTATUS_CANT_RUN on the player's side (its Mean Look holds the wild + -- mon) and a live wrap count on the enemy pin it BEFORE the status check + -- -- the pin that makes a roamer catchable at full HP. + if self:volatile(self.player).trapsTarget then return false end + if (self:volatile(self.enemy).wrapCount or 0) > 0 then return false end + local status = self.enemy.status + if status == "freeze" or status == "sleep" then return false end + local species = self.enemy.species + if Roamers.ALWAYS_FLEE[species] then return self:enemyFled() end + local roll = rand(self.random, 256) + if roll >= Battle.OFTEN_FLEE_ROLL then return false end + if Roamers.OFTEN_FLEE[species] then return self:enemyFled() end + if roll >= Battle.SOMETIMES_FLEE_ROLL then return false end + if Roamers.SOMETIMES_FLEE[species] then return self:enemyFled() end + return false +end + +-- WildFled_EnemyFled_LinkBattleCanceled. The result it writes is DRAW, the +-- same value the player's own successful run writes, which is what makes +-- BattleEnd_HandleRoamMons bank the beast's HP instead of clearing its slot. +-- The port's outcome name is "fled" so a caller can tell the two apart, and +-- Evolution.runsAfterBattle already treats anything that is not a loss or a +-- draw-by-forfeit as evolvable. +function Battle:enemyFled() + self:endBattle("fled") + self:emit({ kind = "run", side = "enemy", + text = "Wild " .. self:monName(self.enemy) .. " fled!" }) + return true +end + +-- The AI's move. A trainer class's TRNATTR_AI_MOVE_WEIGHTS decides which +-- scoring layers run (src/battle/gen2/Ai.lua); a wild mon -- and a class with +-- no flags -- picks at random from what it knows, which is what AIChooseMove +-- does when wEnemyTrainerAIFlags is zero. +-- engine/battle/ai/items.asm AI_SwitchOrTryItem. Wild mons never do either; +-- a trainer's class decides how eager it is. Returns true when the turn was +-- spent on the switch or the item. +function Battle:enemyTrySwitchOrItem() + if self.wild or not self.trainer then return false end + local attributes = self.trainer.attributes + if type(attributes) ~= "table" then return false end + + -- The AI cannot rotate out of a trap either: a live wrap count on its + -- active mon or the player's CANT_RUN pin close the switch branch the way + -- they close TryPlayerSwitch, leaving only the item check. + local trapped = (self:volatile(self.enemy).wrapCount or 0) > 0 + or self:volatile(self.player).trapsTarget == true + + -- CheckAbleToSwitch, then the class's own probability. + local bench = {} + local playerTypes = (self:speciesDef(self.player) or {}).types + or self.player.types or {} + for index, mon in ipairs(self.enemyParty) do + if index ~= self.enemyIndex and (mon.hp or 0) > 0 then + local def = self:speciesDef(mon) + local types = (def and def.types) or mon.types or {} + -- "Resists" is the player's own type against the bench mon, which is + -- what FindEnemyMonsThatResistPlayer measures. + local incoming = Damage.typeMultiplier(playerTypes[1], types, + self.data.type_chart and self.data.type_chart.matchups) + local super_ = false + for _, move in ipairs(mon.moves or {}) do + local moveDef = self:moveDef(move.id) + if moveDef and (moveDef.power or 0) > 0 then + local mult = Damage.typeMultiplier(moveDef.type, playerTypes, + self.data.type_chart and self.data.type_chart.matchups) + if (mult or 10) > 10 then super_ = true end + end + end + bench[#bench + 1] = { index = index, mon = mon, healthy = true, + resists = (incoming or 10) < 10, superEffective = super_ } + end + end + + -- CheckPlayerMoveTypeMatchups: below BASE_AI_SWITCH_SCORE means the player's + -- moves are beating what is out. Battle:playerMatchupScore owns that loop so + -- this layer and AI_Smart's four readers of it cannot disagree. + local score, target = Ai.switchScore({ + bench = bench, + perishCount = self:volatile(self.enemy).perish, + matchupScore = self:playerMatchupScore(), + }) + if not trapped and target + and Ai.shouldSwitch(attributes, score, self.random) then + self:clearVolatile(self.enemy) + -- AI_Switch prints EnemyWithdrewText BEFORE it farcalls EnemySwitch + -- (engine/battle/ai/items.asm:685), so a rotation announces the mon + -- coming OFF the field as well as the one coming on; without it a + -- trainer swapping between two of the same species looked like nothing + -- had happened. The line is skipped only when Pursuit hit the mon on + -- its way out, which this port has no analogue for yet. + local outgoing = self.enemy + self:emit({ kind = "message", + text = (self.trainer.name or "TRAINER") .. " withdrew " + .. self:monName(outgoing) .. "!" }) + self.enemyIndex = target + self.enemy = self.enemyParty[target] + -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove + -- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so + -- the mon coming IN starts from an empty area -- the same pair of clears + -- Battle:switch makes for the player's side. + self:clearVolatile(self.enemy) + self.stages.enemy = Battle.newStages() + self:emit({ kind = "send", side = "enemy", mon = self.enemy, + text = (self.trainer.name or "TRAINER") .. " sent out " + .. self:monName(self.enemy) .. "!" }) + Runtime.emit("battle.battler_switched", { + battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, + previous = outgoing, + }) + self:breakTrapsOnSend(self.enemy) + self:spikesDamage(self.enemy) + return true + end + + -- AI_TryItem: only the trainer's highest-level mon is worth an item. + local highest = 0 + for _, mon in ipairs(self.enemyParty) do + highest = math.max(highest, mon.level or 0) + end + local item = Ai.chooseItem({ + items = self.trainer.items, + isHighestLevel = (self.enemy.level or 0) >= highest, + hp = self.enemy.hp, + maxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp, + status = self.enemy.status, + enemyTurns = self:volatile(self.enemy).turnsTaken or 0, + }) + if not item then return false end + -- Consume it, so a trainer with one Potion cannot drink it every turn. + for index, id in ipairs(self.trainer.items or {}) do + if id == item then table.remove(self.trainer.items, index) break end + end + local heal = Ai.HEAL_ITEMS[item] + if heal then + self:heal(self.enemy, heal == math.huge + and (self.enemy.maxHp or (self.enemy.stats or {}).hp or 1) or heal) + if item == "FULL_RESTORE" then + self.enemy.status = nil + self:volatile(self.enemy).confuseCount = nil + end + elseif item == "FULL_HEAL" then + self.enemy.status = nil + self:volatile(self.enemy).confuseCount = nil + end + self:emit({ kind = "message", text = (self.trainer.name or "TRAINER") + .. " used " .. item .. "!" }) + return true +end + +-- battle.enemy_action, the same hook BattleState:enemyAction calls on Gen 1: +-- the whole choke point is wrapped, so a mod can rewrite any trainer's choice +-- without registering a brain. Gen 1's chain returns an ACTION table and +-- Gen 2's engine speaks in bare move ids, so a table with an `id` (or a +-- `move`) is unwrapped rather than refused -- which is what lets one mod +-- source answer this hook on both generations. +function Battle:enemyMove() + if Runtime.wantsHook("battle.enemy_action") then + local chosen = Runtime.call("battle.enemy_action", function(battle) + return Battle.vanillaEnemyMove(battle) + end, self) + if type(chosen) == "table" then return chosen.id or chosen.move end + return chosen + end + -- Called through the module rather than the metatable: the charge-lock test + -- drives this against a bare stub table, which is also how the lock is + -- proved to read nothing but the volatile. + return Battle.vanillaEnemyMove(self) +end + +function Battle:vanillaEnemyMove() + -- A mon halfway through a two-turn move does not get to choose again. + -- + -- On the cart the charge sets SUBSTATUS_CHARGED and `CheckEnemyTurn` reuses + -- wEnemySelectedMove; the AI is never consulted for the second turn. Without + -- that lock the AI picks freely, which skips the stored attack AND -- because + -- `vanished` is only cleared by the branch in useMove that recognises the + -- second half -- leaves the mon semi-invulnerable **for the rest of the + -- battle**. + -- + -- Found by the Gold route bot: ELITE FOUR BRUNO's HITMONLEE opens with DIG, + -- then attacks with HI JUMP KICK from underground forever. Every incoming + -- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged + -- by anything, at any level. Fifteen straight attempts at the Elite Four + -- died there, and no amount of grinding could ever have got past it. + local charged = self:volatile(self.enemy).chargeMove + if charged then return charged end + + -- Encore and Disable narrow the pool before the AI ever scores it. + local moves = self:usableMoves(self.enemy) + if #moves == 0 then + -- `.not_linked`'s encore arm runs ahead of the disable scan + -- (engine/battle/core.asm:5524-5529). + local forced = self:forcedMove(self.enemy) + if forced then return forced end + -- `.disabled` walks off the end into `.struggle` (:5555-5560). + return nil + end + local flags = Ai.flagsOf(self.trainer and self.trainer.attributes) + if flags == 0 then + return moves[rand(self.random, #moves) + 1].id + end + local chosen = Ai.choose({ + moves = moves, + moveDef = function(id) return self:moveDef(id) end, + attacker = { + level = self.enemy.level, + stats = self.enemy.stats, + types = (self:speciesDef(self.enemy) or {}).types or self.enemy.types, + }, + defender = { + hp = self.player.hp, + stats = self.player.stats, + status = self.player.status, + -- AI_Basic reads SUBSTATUS_CONFUSED for the confusion moves, not the + -- status byte. + confused = self:volatile(self.player).confuseCount ~= nil, + types = (self:speciesDef(self.player) or {}).types or self.player.types, + }, + typeChart = self.data.type_chart, + -- The dataset itself, which is where Ai.layersFor reads the merged + -- `ai_classes` records from (data.gen2AiClasses). Without it the module's + -- own ten layers answer, which is the same behaviour a boot with no loader + -- has always had. + data = self.data, + -- Everything the SETUP / OPPORTUNIST / CAUTIOUS / SMART layers read. + enemyHp = self.enemy.hp, + enemyMaxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp, + enemyTurns = self:volatile(self.enemy).turnsTaken or 0, + playerTurns = self:volatile(self.player).turnsTaken or 0, + smart = self:smartAiState(), + -- wLastPlayerCounterMove's base power, which AI_Smart_Encore and + -- AI_Smart_MirrorCoat both take as their fifth argument. + playerLastPower = (function() + local last = self:volatile(self.player).lastMove + local def = last and self:moveDef(last) + return def and def.power or nil + end)(), + attackerStages = self.stages.enemy, + defenderStages = self.stages.player, + flags = flags, + random = function(n) return rand(self.random, n) end, + }) + return chosen or moves[1].id +end + +-- Run one turn. `action` is: +-- { kind = "move", move = <id> } +-- { kind = "switch", index = n } +-- { kind = "run" } +-- { kind = "item", item = <id>, target = n } (handled by the caller, which +-- applies the effect and then calls this with kind = "item" so the enemy +-- still gets its turn) +local function runTurn(self, action) + if self.over then return self:takeEvents() end + self.turn = self.turn + 1 + action = action or { kind = "move" } + -- HandleBerserkGene sits at the top of BattleTurn's loop + -- (engine/battle/core.asm:160), player first then enemy, so a holder + -- fires on its first turn out whether it started the battle or switched + -- in. Consuming the item is what keeps it one-shot. + self:checkBerserkGene(self.player) + self:checkBerserkGene(self.enemy) + -- Counter and Mirror Coat answer damage taken *this* turn, so the tally + -- starts empty (BattleCommand_Counter reads wCurDamage, which the turn + -- clears). + self:volatile(self.player).tookThisTurn = nil + self:volatile(self.enemy).tookThisTurn = nil + -- Set by resolveFaints; per-round, so it can never leak into the next one. + self.faintInterrupt = nil + + if action.kind == "run" then + if self:tryRun() then return self:takeEvents() end + -- Only the failed ROLL costs the turn. .cant_escape_2 writes + -- BATTLEPLAYERACTION_USEITEM before printing its line, so BattleMenu_Run's + -- `ld a, [wBattlePlayerAction] / and a / ret nz` lets the round proceed; + -- .cant_escape and .cant_run_from_trainer leave the action at + -- BATTLEPLAYERACTION_USEMOVE and fall into `jp BattleMenu` + -- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the + -- turn unspent -- so a refused RUN never bought the enemy a free attack. + if self.runRefused then return self:takeEvents() end + action = { kind = "skip" } + end + + if action.kind == "switch" then + self:switch(action.index) + action = { kind = "skip" } + end + + -- XItemEffect's tail: the four X items award HAPPINESS_USEDXITEM to + -- wCurBattleMon, i.e. whoever is out, not whoever the PACK was pointed at. + -- The caller applies the item's own effect and then hands the turn here, so + -- this is where the award lands. + if action.kind == "item" and Battle.X_ITEMS[action.item] then + Happiness.change(self.player, "USEDXITEM") + end + + -- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides + -- to rotate or drink a potion spends its whole turn on it. + local enemyActed = self:enemyTrySwitchOrItem() + local enemyMoveId = (not enemyActed) and self:enemyMove() or nil + + -- battle.turn_started, where BattleState:resolveTurn raises it on Gen 1: + -- once both sides have chosen and before either acts. Gen 1's action tables + -- key their move as `id` and Gen 2's as `move`, so the payload's copies carry + -- both spellings rather than making a mod know which engine it is in. + if Runtime.wants("battle.turn_started") then + Runtime.emit("battle.turn_started", { + battle = self, turn = self.turn, + playerAction = { kind = action.kind, id = action.move, + move = action.move, index = action.index, + item = action.item }, + enemyAction = enemyMoveId + and { kind = "move", id = enemyMoveId, move = enemyMoveId } or nil, + }) + end + self.turnOpen = true + + -- A switch or item always resolves before the enemy's move; otherwise Speed + -- decides. + local playerFirst + if action.kind == "skip" or action.kind == "item" then + playerFirst = true + elseif Runtime.wantsHook("battle.turn_order") then + -- battle.turn_order, the same hook BattleState:resolveTurn calls on Gen 1 + -- and with the same five arguments: both battlers, both move records, and + -- a ctx carrying the rng. Gen 2's ordering reads move IDS rather than + -- records (priority comes off the id), so the ids are in the ctx as + -- playerMove / enemyMove and that is what vanilla resolves. + playerFirst = Runtime.call("battle.turn_order", function(_, _, _, _, c) + return c.battle:orderOf(c.playerMove, c.enemyMove) == "player" + end, self.player, action.move and self:moveDef(action.move) or nil, + self.enemy, enemyMoveId and self:moveDef(enemyMoveId) or nil, + { battle = self, rng = self:roller(), random = self.random, + playerMove = action.move, enemyMove = enemyMoveId }) and true or false + else + playerFirst = self:orderOf(action.move, enemyMoveId) == "player" + end + -- wEnemyGoesFirst, which BattleCommand_ForceSwitch's two trainer arms + -- read: Roar and Whirlwind only work for a user moving SECOND. + self.firstMover = playerFirst and "player" or "enemy" + + local function playerAttack() + if action.kind ~= "move" then return end + if not self:canAct(self.player) then return end + local move = action.move + -- An encored mon has no choice, whatever the menu said. + local forced = self:forcedMove(self.player) + if forced then move = forced end + -- .CheckPlayerHasUsableMoves: a mon with nothing left to spend attacks + -- with STRUGGLE rather than losing the turn. The menu can still hand us a + -- dry move (nothing stops the player picking one), so the substitution is + -- made here, where the cart makes it, rather than in the menu. + -- + -- The second half of a two-turn move is exempt: it spends no PP and makes + -- no new choice, so a mon that went dry while charging still lands the + -- attack it stored. + -- + -- The lock itself, same as the enemy's in Battle:enemyMove: whatever the + -- menu handed us, a mon with a stored charge move uses THAT. Otherwise + -- the stored attack is skipped and `vanished` is never cleared, and the + -- player's own mon spends the rest of the battle underground. + local stored = self:volatile(self.player).chargeMove + if stored then move = stored end + -- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before + -- checkobedience, so a locked Rollout or Thrash is exempt from the + -- Struggle substitution and the obedience roll the same way the second + -- half of a charge move is. + local charging = self:volatile(self.player).chargeMove == move + or self:lockedInMove(self.player) == move + if not charging and not self:hasUsableMoves(self.player) then + self:emit({ kind = "message", + text = self:monName(self.player) .. " has no moves left!" }) + move = Battle.STRUGGLE + end + -- CheckPlayerTurn's disabled arm spends the turn, whatever was selected + -- (engine/battle/effect_commands.asm:314-326). + if self:moveDisabled(self.player, move) then + -- MoveDisabled fails the stored charge (:599-603) and CantMove brings a + -- vanished FLY/DIG user back (:364-368). + local state = self:volatile(self.player) + state.chargeMove, state.vanished = nil, nil + self:emit({ kind = "message", + text = self:monName(self.player) .. "'s " .. move .. " is DISABLED!" }) + return + end + -- BattleCommand_CheckObedience runs at the head of the move's effect + -- list, after the status gates and before PP is spent; the second half + -- of a charge move is exempt (CheckUserIsCharging). + if not charging and self:checkObedience(move) then return end + self:useMove(self.player, self.enemy, move) + end + local function enemyAttack() + if enemyActed then return end + if (self.enemy.hp or 0) <= 0 then return end + -- TryEnemyFlee sits here in both of the cart's turn orders, ahead of the + -- enemy's move and behind the faint checks. + if self:tryEnemyFlee() then return end + if not enemyMoveId then + -- `.struggle` (engine/battle/core.asm:5630-5632) sets STRUGGLE and + -- finishes silently: BattleText_MonHasNoMovesLeft is text_ram + -- wBattleMonNickname (data/text/battle.asm:325-329) and only + -- .force_struggle (core.asm:5311-5317) ever prints it. Returning here + -- instead -- which is what this did -- left a dry enemy unable to act at + -- all, so a battle where both sides had run out could never end and, + -- against a trainer, could not be escaped either. + enemyMoveId = Battle.STRUGGLE + end + if not self:canAct(self.enemy) then return end + -- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574): + -- the AI chose before the player's Disable landed, so the turn is spent here. + if self:moveDisabled(self.enemy, enemyMoveId) then + local state = self:volatile(self.enemy) + state.chargeMove, state.vanished = nil, nil + self:emit({ kind = "message", + text = self:monName(self.enemy) .. "'s " .. enemyMoveId .. " is DISABLED!" }) + return + end + self:useMove(self.enemy, self.player, enemyMoveId) + end + + if playerFirst then + playerAttack() + -- A wild Roar or Whirlwind ends the battle from THIS half of the turn the + -- same way a flee ends it from the other: `.wild_force_flee` writes DRAW + -- into wBattleResult and the turn loop's `.quit` takes the round with it, + -- so the mon that was blown away never answers. + if self.over then return self:takeEvents() end + if self:resolveFaints() then return self:takeEvents() end + -- A faint ends the attack phase. Battle_PlayerFirst reaches both faint + -- handlers with `jp`, not `call` (engine/battle/core.asm:871-874), so the + -- enemy's half of the round is never run: the replacement the trainer + -- just sent out does not attack on the turn it walked in, and the move + -- picked for the mon it replaced is dropped rather than fired by whoever + -- happens to be standing there now. The end-of-turn block below still + -- runs -- HandleEnemyMonFaint returns into BattleTurn's `.proceed`, which + -- calls HandleBetweenTurnEffects (core.asm:196). + if self.faintInterrupt then + self.faintInterrupt = nil + elseif (self.player.hp or 0) > 0 then + enemyAttack() + end + else + enemyAttack() + -- A flee ends the battle where it stands: the cart jumps straight to + -- WildFled_EnemyFled_LinkBattleCanceled and never reaches the player's + -- half of the turn or the residual damage. + if self.over then return self:takeEvents() end + if self:resolveFaints() then return self:takeEvents() end + -- Same `jp` (core.asm:834-837): a mon that fainted to the enemy's move + -- takes the rest of the attack phase with it. + if self.faintInterrupt then + self.faintInterrupt = nil + elseif (self.player.hp or 0) > 0 then + playerAttack() + end + end + if self.over then return self:takeEvents() end + if self:resolveFaints() then return self:takeEvents() end + self.faintInterrupt = nil + + -- A successful Roar or Whirlwind ends the ROUND: the turn loop's `.quit` + -- on wForcedSwitch skips HandleBetweenTurnEffects, so nothing ticks on + -- the turn a mon was dragged out. + if self.forcedSwitch then + self.forcedSwitch = nil + return self:takeEvents() + end + + -- End of turn, in the cart's own order (HandleWeather runs before the + -- residual damage, and the counters that end a mon come last): + -- weather, then status chip and the Leech Seed / Curse residuals, then + -- the wrap ticks, then held items, then Future Sight and Perish Song, + -- then the screens and the per-turn counters. + self:tickWeather() + self:tickStatus(self.player) + self:tickSeedAndCurse(self.player) + self:tickStatus(self.enemy) + self:tickSeedAndCurse(self.enemy) + self:tickWrap(self.player) + self:tickWrap(self.enemy) + self:tickHeldItem(self.player) + self:tickHeldItem(self.enemy) + self:tickFutureSight(self.player) + self:tickFutureSight(self.enemy) + self:tickPerish(self.player) + self:tickPerish(self.enemy) + self:tickScreens() + self:tickCounters(self.player) + self:tickCounters(self.enemy) + self:resolveFaints() + return self:takeEvents() +end + +-- battle.turn_ended closes the round battle.turn_started opened, whichever of +-- runTurn's exits was taken -- a faint, a flee, a forced switch or the ordinary +-- residual sweep. A round that never happened (a refused RUN, a battle that +-- was already over) opened nothing and so closes nothing, which is what keeps +-- the two events paired the way Gen 1's endOfTurn keeps them. +function Battle:takeTurn(action) + local events = runTurn(self, action) + if self.turnOpen then + self.turnOpen = nil + if Runtime.wants("battle.turn_ended") then + Runtime.emit("battle.turn_ended", { battle = self, turn = self.turn }) + end + end + return events +end + +-- HandleWeather: the count ticks down every turn and the weather ends the turn +-- it reaches zero. Sandstorm chips an eighth off everything that is not Rock, +-- Ground or Steel. +function Battle:tickWeather() + if not self.weather then return end + self.weatherTurns = self.weatherTurns - 1 + if self.weatherTurns <= 0 then + self:emit({ kind = "weather", weather = nil, + text = Effects.WEATHER_END_TEXT[self.weather] }) + self.weather = nil + return + end + self:emit({ kind = "message", + text = Effects.WEATHER_TURN_TEXT[self.weather] }) + if self.weather ~= "sandstorm" then return end + for _, mon in ipairs({ self.player, self.enemy }) do + if (mon.hp or 0) > 0 and not self:volatile(mon).vanished then + local def = self:speciesDef(mon) + local types = (def and def.types) or mon.types + if Effects.sandstormHits(types) then + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8 + local damage = Effects.sandstormDamage(maxHp) + mon.hp = math.max(0, mon.hp - damage) + self:emit({ kind = "message", + text = self:monName(mon) .. " is buffeted by the sandstorm!" }) + -- .SandstormDamage plays ANIM_IN_SANDSTORM between two SwitchTurnCore + -- calls, so it runs from the OTHER side (core.asm:1688-1693). + self:emit({ kind = "damage", side = self:sideOf(mon), + amount = damage, hp = mon.hp, anim = "ANIM_IN_SANDSTORM" }) + end + end + end +end + +-- BattleCommand_CheckFutureSight: the stored damage lands when the counter +-- reaches one, on whoever is standing on the target's side by then. +function Battle:tickFutureSight(mon) + local state = self:volatile(mon) + if not state.futureSight then return end + state.futureSight = state.futureSight - 1 + if state.futureSight > 0 then return end + local target = state.futureSightSide == "player" and self.player or self.enemy + local damage = state.futureSightDamage or 1 + state.futureSight, state.futureSightDamage, state.futureSightSide = + nil, nil, nil + if (target.hp or 0) <= 0 then return end + self:emit({ kind = "message", text = self:monName(target) + .. " took the FUTURE SIGHT attack!" }) + self:dealDamage(mon, target, damage, {}) +end + +-- The perish count ticks at the end of every turn and the mon faints on zero. +function Battle:tickPerish(mon) + local state = self:volatile(mon) + if not state.perish or (mon.hp or 0) <= 0 then return end + state.perish = state.perish - 1 + if state.perish > 0 then + self:emit({ kind = "message", text = self:monName(mon) + .. "'s PERISH count is " .. state.perish .. "!" }) + return + end + state.perish = nil + mon.hp = 0 + -- HandlePerishSong just zeroes both HP bytes (core.asm:1119-1135). + self:emit({ kind = "damage", side = self:sideOf(mon), amount = 0, hp = 0, + anim = false }) +end + +-- ResidualDamage's Leech Seed and Curse arms (engine/battle/core.asm:1010 +-- and 1054): an eighth of the seeded mon's max HP crosses to whoever stands +-- on the OTHER side by now, then a quarter for the curse. Both run only +-- while the sufferer still stands, and both survive the trapper leaving -- +-- the flags sit on the suffering mon itself. +function Battle:tickSeedAndCurse(mon) + local state = self:volatile(mon) + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8 + if state.leechSeed and (mon.hp or 0) > 0 then + local damage = math.min(math.max(1, math.floor(maxHp / 8)), mon.hp) + mon.hp = mon.hp - damage + self:emit({ kind = "message", + text = "LEECH SEED saps " .. self:monName(mon) .. "!" }) + -- ANIM_SAP plays between two SwitchTurnCore calls, from the seeder's side + -- (core.asm:1013-1021). + self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage, + hp = mon.hp, anim = "ANIM_SAP" }) + local other = mon == self.player and self.enemy or self.player + if (other.hp or 0) > 0 then self:heal(other, damage) end + end + if state.cursed and (mon.hp or 0) > 0 then + local damage = math.max(1, math.floor(maxHp / 4)) + mon.hp = math.max(0, mon.hp - damage) + self:emit({ kind = "message", + text = self:monName(mon) .. "'s hurt by the CURSE!" }) + -- The curse arm borrows ANIM_IN_NIGHTMARE, on the sufferer's own turn + -- (core.asm:1057-1060). + self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage, + hp = mon.hp, anim = "ANIM_IN_NIGHTMARE", animSide = self:sideOf(mon) }) + end +end + +-- HandleWrap (engine/battle/core.asm:1153): the count on the trapped mon +-- decrements FIRST -- release at zero, else a sixteenth of max HP. A +-- Substitute suspends the whole tick, count included. +function Battle:tickWrap(mon) + local state = self:volatile(mon) + if not state.wrapCount or (mon.hp or 0) <= 0 then return end + if (state.substitute or 0) > 0 then return end + state.wrapCount = state.wrapCount - 1 + local moveName = state.wrapMove or "the trap" + if state.wrapCount <= 0 then + state.wrapCount, state.wrapMove, state.wrapMoveId = nil, nil, nil + self:emit({ kind = "message", + text = self:monName(mon) .. " was released from " .. moveName .. "!" }) + return + end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 16 + local damage = math.max(1, math.floor(maxHp / 16)) + mon.hp = math.max(0, mon.hp - damage) + self:emit({ kind = "message", + text = self:monName(mon) .. "'s hurt by " .. moveName .. "!" }) + -- The trapping move's own anim, played from the trapper's side between two + -- SwitchTurnCore calls (core.asm:1198-1203). + self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage, + hp = mon.hp, anim = false, animMove = state.wrapMoveId }) +end + +-- HandleScreens (engine/battle/core.asm:1564): each side's five-turn counts +-- tick down and the screen falls the turn its count reaches zero. +Battle.SCREEN_SIDE_LABEL = { player = "Your", enemy = "Enemy" } +Battle.SCREEN_FALL_TEXT = { + lightScreen = " POKéMON's LIGHT SCREEN fell!", + reflect = " POKéMON's REFLECT faded!", +} + +function Battle:tickScreens() + for _, side in ipairs({ "player", "enemy" }) do + local screens = self.screens[side] + for _, field in ipairs({ "lightScreen", "reflect" }) do + if (screens[field] or 0) > 0 then + screens[field] = screens[field] - 1 + if screens[field] <= 0 then + screens[field] = nil + self:emit({ kind = "message", + text = Battle.SCREEN_SIDE_LABEL[side] + .. Battle.SCREEN_FALL_TEXT[field] }) + end + end + end + end +end + +-- Encore, Disable and the two one-turn braces. Protect and Endure last only +-- the turn they are used, which is why they are cleared here rather than by +-- whatever they blocked. +function Battle:tickCounters(mon) + local state = self:volatile(mon) + state.protect, state.endure = nil, nil + -- A flinch lasts only the turn it was inflicted; a leftover one (the + -- target moved first, or fainted) must not eat next turn. + state.flinched = nil + if state.encoreTurns then + state.encoreTurns = state.encoreTurns - 1 + if state.encoreTurns <= 0 then + state.encore, state.encoreTurns = nil, nil + self:emit({ kind = "message", + text = self:monName(mon) .. "'s ENCORE ended!" }) + end + end + if state.disabledTurns then + state.disabledTurns = state.disabledTurns - 1 + if state.disabledTurns <= 0 then + state.disabled, state.disabledTurns = nil, nil + self:emit({ kind = "message", + text = self:monName(mon) .. "'s move is no longer disabled!" }) + end + end +end + +-- Held items with an end-of-turn effect. +-- +-- HELD_LEFTOVERS heals maxHP / 16 every turn (HandleLeftovers) +-- HELD_BERRY heals its parameter once the holder drops below half +-- (HandleHealingItems), and is consumed +-- HELD_HEAL_* cures the status it names, and is consumed +-- +-- The rest of the held effects act inside a hit rather than at the end of a +-- turn, so they are not this function's business. +-- Confusion is a volatile, not a status byte, so HELD_HEAL_CONFUSION is not +-- in this table: its cure (and HELD_HEAL_STATUS's catch-all) reads the +-- confuseCount volatile in tickHeldItem's own arm below. +Battle.HELD_STATUS_CURES = { + HELD_HEAL_POISON = "poison", + HELD_HEAL_SLEEP = "sleep", + HELD_HEAL_BURN = "burn", + HELD_HEAL_FREEZE = "freeze", + HELD_HEAL_PARALYZE = "paralyze", +} + +function Battle:itemDef(itemId) + local items = self.data.items + return itemId and items and items[itemId] or nil +end + +function Battle:tickHeldItem(mon) + if (mon.hp or 0) <= 0 then return end + local def = self:itemDef(mon.item) + if not def then return end + -- Through Battle:heldEffect rather than off the record, so the end-of-turn + -- arm is one more held_item.trigger site and not a hole in it. `def` stays + -- the item's own record: the messages below name the ITEM the mon is + -- holding, which a substituted effect does not change. + local effect, parameter = self:heldEffect(mon, "residual") + if not effect then return end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 + local name = self:monName(mon) + + if effect == "HELD_LEFTOVERS" then + if (mon.hp or 0) >= maxHp then return end + local healed = self:heal(mon, math.max(1, math.floor(maxHp / 16))) + if healed > 0 then + self:emit({ kind = "message", + text = name .. "'s " .. (def.name or "item") .. " restored health!" }) + end + return + end + + if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then + self:heal(mon, parameter > 0 and parameter or 10) + mon.item = nil + self:emit({ kind = "message", + text = name .. " ate the " .. (def.name or "BERRY") .. "!" }) + return + end + + local cure = Battle.HELD_STATUS_CURES[effect] + if effect == "HELD_HEAL_STATUS" then cure = mon.status end + if cure and mon.status == cure then + mon.status = nil + mon.statusTurns = nil + mon.toxicCounter = nil + mon.item = nil + self:emit({ kind = "status", side = self:sideOf(mon), status = nil, + text = name .. "'s " .. (def.name or "item") .. " cured its status!" }) + end + + -- UseConfusionHealingItem: HELD_HEAL_CONFUSION (a Bitter Berry) and the + -- catch-all HELD_HEAL_STATUS also clear the confusion volatile, and are + -- consumed doing it. + if (effect == "HELD_HEAL_CONFUSION" or effect == "HELD_HEAL_STATUS") + and self:volatile(mon).confuseCount then + self:volatile(mon).confuseCount = nil + mon.item = nil + self:emit({ kind = "message", + text = name .. "'s " .. (def.name or "item") + .. " cured its confusion!" }) + end +end + +Battle.Damage = Damage +Battle.Mon = Mon + +return Battle diff --git a/src/battle/gen2/BattleMusic.lua b/src/battle/gen2/BattleMusic.lua new file mode 100644 index 00000000..72bf94d2 --- /dev/null +++ b/src/battle/gen2/BattleMusic.lua @@ -0,0 +1,131 @@ +-- Which song a battle plays, and which one its win plays. +-- +-- engine/battle/start_battle.asm PlayBattleMusic and engine/battle/core.asm +-- PlayVictoryMusic, as pure lookups: the caller hands over the trainer class, +-- the region and the time of day, and gets a song label back. Keeping it out +-- of World.lua is what lets a test assert the whole ladder -- Falkner's theme, +-- the Kanto split, the RIVAL2 cut-off -- without a window or an audio device. +-- +-- Both routines start with `ld de, MUSIC_NONE / call PlayMusic`, i.e. the map +-- theme is stopped first; the port's Music.play replaces whatever is current, +-- so that step needs nothing here. + +local BattleMusic = {} + +-- data/trainers/leaders.asm. The two lists are contiguous in the ROM and +-- IsGymLeader reads BOTH (GymLeaders falls through into KantoGymLeaders), +-- while IsKantoGymLeader starts at the second -- so a Kanto leader is in each +-- list and a Johto one only in the first. +BattleMusic.KANTO_GYM_LEADERS = { + BROCK = true, MISTY = true, LT_SURGE = true, ERIKA = true, + JANINE = true, SABRINA = true, BLAINE = true, BLUE = true, +} +BattleMusic.JOHTO_GYM_LEADERS = { + FALKNER = true, WHITNEY = true, BUGSY = true, MORTY = true, + PRYCE = true, JASMINE = true, CHUCK = true, CLAIR = true, + WILL = true, BRUNO = true, KAREN = true, KOGA = true, + -- The list carries these two as well; PlayBattleMusic never reaches them + -- because it tests for them first, but PlayVictoryMusic's IsGymLeader call + -- does, which is why the Champion's defeat plays the gym jingle. + CHAMPION = true, RED = true, +} + +-- IsGymLeader searches GymLeaders, which runs on into KantoGymLeaders. +function BattleMusic.isGymLeader(class) + if not class then return false end + return BattleMusic.JOHTO_GYM_LEADERS[class] == true + or BattleMusic.KANTO_GYM_LEADERS[class] == true +end + +function BattleMusic.isKantoGymLeader(class) + return class ~= nil and BattleMusic.KANTO_GYM_LEADERS[class] == true +end + +-- RegionCheck (engine/overworld/landmarks.asm) compares the map's landmark +-- against KANTO_LANDMARK; the Victory Road block above it counts as Johto +-- again, and so does the S.S. Aqua. +-- Indices into constants.lua's `landmarkOrder`. +BattleMusic.KANTO_LANDMARK = 46 +BattleMusic.LANDMARK_VICTORY_ROAD = 87 +BattleMusic.LANDMARK_FAST_SHIP = 94 + +function BattleMusic.isKanto(landmark) + local index = landmark or 0 + if index == BattleMusic.LANDMARK_FAST_SHIP then return false end + if index < BattleMusic.KANTO_LANDMARK then return false end + return index < BattleMusic.LANDMARK_VICTORY_ROAD +end + +-- The rival's theme becomes the Champion's from RIVAL2_2 onward (the Indigo +-- Plateau rematch): `cp RIVAL2_2_CHIKORITA / jr c, .done`, a comparison +-- against the MEMBER id inside the class. +BattleMusic.RIVAL2_CHAMPION_MEMBER = "RIVAL2_2_CHIKORITA" + +-- opts: +-- class trainer class id ("FALKNER"), or nil for a wild battle +-- member trainer member id ("RIVAL2_1_TOTODILE") -- only RIVAL2 reads it +-- members the class's member list, to order `member` against +-- landmark the map's landmark index, for RegionCheck +-- daytime "MORN" | "DAY" | "NITE" | "DARK" +function BattleMusic.battleSong(opts) + opts = opts or {} + local class = opts.class + local kanto = BattleMusic.isKanto(opts.landmark) + + if not class then + if kanto then return "Music_KantoWildBattle" end + -- Only NITE has its own wild theme; DARK (an unlit cave) is a palette + -- state, not a time of day, and keeps the day theme. + if opts.daytime == "NITE" then return "Music_JohtoWildBattleNight" end + return "Music_JohtoWildBattle" + end + + if class == "CHAMPION" or class == "RED" then + return "Music_ChampionBattle" + end + -- The cart's own bug, kept: only the two GRUNT classes get the Rocket + -- theme, so an EXECUTIVE or SCIENTIST fights to the ordinary trainer song + -- (docs/bugs_and_glitches.md). + if class == "GRUNTM" or class == "GRUNTF" then + return "Music_RocketBattle" + end + if BattleMusic.isKantoGymLeader(class) then + return "Music_KantoGymBattle" + end + if BattleMusic.isGymLeader(class) then + return "Music_JohtoGymBattle" + end + if class == "RIVAL1" then return "Music_RivalBattle" end + if class == "RIVAL2" then + local cutoff, index + for i, id in ipairs(opts.members or {}) do + if id == BattleMusic.RIVAL2_CHAMPION_MEMBER then cutoff = i end + if id == opts.member then index = i end + end + if cutoff and index and index >= cutoff then + return "Music_ChampionBattle" + end + return "Music_RivalBattle" + end + if kanto then return "Music_KantoTrainerBattle" end + return "Music_JohtoTrainerBattle" +end + +-- PlayVictoryMusic. A wild win is SILENT unless the player still has a +-- participant standing (or an Exp. Share, or Pay Day money) -- `wBattle +-- ParticipantsNotFainted` zero falls through to `.lost` with no PlayMusic at +-- all, which is why a battle won by a mon that fainted to recoil ends on the +-- map theme. Returns nil for that case. +function BattleMusic.victorySong(opts) + opts = opts or {} + if not opts.class then + if opts.participantsFainted then return nil end + return "Music_WildPokemonVictory" + end + if BattleMusic.isGymLeader(opts.class) then + return "Music_GymLeaderVictory" + end + return "Music_TrainerVictory" +end + +return BattleMusic diff --git a/src/battle/gen2/BerryJuice.lua b/src/battle/gen2/BerryJuice.lua new file mode 100644 index 00000000..b4a55b80 --- /dev/null +++ b/src/battle/gen2/BerryJuice.lua @@ -0,0 +1,60 @@ +-- ConvertBerriesToBerryJuice (engine/events/pokerus/pokerus.asm:124), the +-- first thing GivePokerusAndConvertBerries does on a battle WIN: gated on +-- ENGINE_REACHED_GOLDENROD like the Pokerus roll beside it, one byte under +-- `1 out_of 16` (16/256), then a walk down the party for a SHUCKLE holding +-- a BERRY. Only the FIRST match converts -- the routine returns the moment +-- it rewrites one item byte -- and nothing tells the player; the changed +-- held item is the whole event. +-- +-- Shuckie (the Cianwood loaner) arrives holding a BERRY, which is the +-- intended payoff. The Pokerus half lives in src/core/gen2/Pokerus.lua; +-- both are called from the same battle-exit arm in +-- src/ui/gen2/BattleState.lua, conversion first, the way the asm orders +-- them. + +local BerryJuice = {} + +-- constants/engine_flags.asm index 21, same gate Pokerus.give reads. +BerryJuice.ENGINE_REACHED_GOLDENROD = 21 + +-- `cp 1 out_of 16` with out_of = `* $100 /`: a byte under 16 converts. +BerryJuice.ROLL_LIMIT = 16 + +function BerryJuice.random() + if love and love.math and love.math.random then + return love.math.random(0, 255) + end + return math.random(0, 255) +end + +-- The walk itself. `opts.random` is a function of no arguments returning +-- 0..255 (the Pokerus convention), `opts.reachedGoldenrod` the engine flag. +-- Returns the party slot that converted, or nil. +function BerryJuice.convert(party, opts) + opts = opts or {} + if not opts.reachedGoldenrod then return nil end + local roll = (opts.random or BerryJuice.random)() + if roll >= BerryJuice.ROLL_LIMIT then return nil end + for index, mon in ipairs(party or {}) do + if mon.species == "SHUCKLE" and mon.item == "BERRY" then + mon.item = "BERRY_JUICE" + return index + end + end + return nil +end + +-- The save-facing wrapper, mirroring Pokerus.giveAfterBattle's shape so the +-- battle exit calls the two the same way. +function BerryJuice.convertAfterBattle(save, party, opts) + if type(save) ~= "table" then return nil end + opts = opts or {} + local flags = save.engineFlags or {} + return BerryJuice.convert(party or save.party or {}, { + random = opts.random, + reachedGoldenrod = + flags[BerryJuice.ENGINE_REACHED_GOLDENROD] == true, + }) +end + +return BerryJuice diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua new file mode 100644 index 00000000..6a90356b --- /dev/null +++ b/src/battle/gen2/BgEffects.lua @@ -0,0 +1,1322 @@ +-- The Gen 2 battle-animation BACKGROUND effects. +-- +-- pokegold engine/battle_anims/bg_effects.asm: five concurrent structs, each +-- of {function, jumptable index, battle turn, param}, and a jumptable of 53 +-- effects that between them do all the shaking, flashing and sinking a Gen 2 +-- battle animation is made of. Objects (src/battle/gen2/AnimObjects.lua) are +-- OBJs; these are the BG layer. +-- +-- The one mechanism worth understanding before reading any of it: almost +-- nothing here moves a sprite. It writes wLYOverridesBackup, a per-scanline +-- value the LCD STAT interrupt copies into rSCX, rSCY or rBGP as the beam +-- passes. BattleBGEffect_SetLCDStatCustoms1 picks the window -- scanlines +-- $00-$36 for the enemy's pic, $2f-$5e for the player's -- so "shake the +-- attacker" is "write the same SCX to every scanline the attacker occupies". +-- +-- The port keeps that literally: `lyBackup` is 144 bytes, `lcdc` names the +-- register they land in, and src/ui/gen2/BattleAnimView.lua draws the BG layer +-- one scanline band at a time. Modelling it as "move the mon pic" instead +-- would work for Tackle and then fall apart on Withdraw and Dig, which push a +-- GROWING number of rows off and leave the rest where they are. +-- +-- Love-free, like AnimObjects. + +local bit = require("bit") +local AnimObjects = require("src.battle.gen2.AnimObjects") + +local u8, sra = AnimObjects.u8, AnimObjects.sra +local swap = AnimObjects.swap +local sine, cosine = AnimObjects.sine, AnimObjects.cosine + +local BgEffects = {} + +local NUM_EFFECTS = 5 -- NUM_BG_EFFECTS +local SCREEN_ROWS = 0x90 -- wLYOverridesBackup is $91 bytes + +-- `dc a, b, c, d` packs four 2-bit shades into a DMG palette byte, high pair +-- first: `dc 3, 2, 1, 0` is %11100100 = $e4, the identity ramp. +local function dc(a, b, c, d) return a * 64 + b * 16 + c * 4 + d end + +local NORMAL_PAL = dc(3, 2, 1, 0) + +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newEffect() + return { func = nil, jt = 0, turn = 0, param = 0 } +end + +-- `env` is shared with the object pool: env.battleTurn is hBattleTurn, and +-- env.flying tells BGEffect_CheckFlyDigStatus whether the battler in question +-- is mid-Fly or mid-Dig (in which case ShowMon and the battler-pic objects +-- decline to draw a mon that is not on the field). +function BgEffects.new(constants, env) + local self = setmetatable({}, Pool) + self.env = env or {} + self.effects = {} + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + self.order = (constants or {}).battleBgEffectOrder or {} + self:reset() + return self +end + +function Pool:reset() + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + -- hSCX / hSCY: a whole-screen scroll, which is what the screen shakes use. + self.scx, self.scy = 0, 0 + -- hLCDCPointer plus its window, and the per-scanline values themselves. + self.lcdc, self.lyStart, self.lyEnd = nil, 0, 0 + self.lyBackup = {} + for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end + -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. + self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL + -- Per-battler state the CGB paths write instead of touching wBGP: a DMG + -- shade byte the view remaps that battler's pic through, whether the pic is + -- hidden outright, and which of the six BG squares it is drawn at. + self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } + self.hidden = { player = false, enemy = false } + self.picSize = { player = nil, enemy = nil } + self.slide = { player = 0, enemy = 0 } + -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the + -- overrides. nil until InitSurfWaves lays one down. + self.surfWave = nil + -- The objects a BG effect asks the object pool to spawn, drained by the + -- runner after each frame. + self.spawns = {} +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_EFFECTS do + if self.effects[slot].func then count = count + 1 end + end + return count +end + +-- QueueBGEffect: first free struct wins; a full pool silently drops the +-- request, which is exactly what the carry return means to the caller. +function Pool:queue(effectId, jumptableIndex, turn, param) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if not st.func then + st.func = name + st.jt = u8(jumptableIndex or 0) + st.turn = u8(turn or 0) + st.param = u8(param or 0) + return st + end + end + return nil +end + +-- BattleAnimCmd_IncBGEffect: bump the jumptable index of the first struct +-- running this effect. +function Pool:incEffect(effectId) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func == name then + st.jt = u8(st.jt + 1) + return st + end + end + return nil +end + +-------------------------------------------------------------------------- +-- Shared helpers +-------------------------------------------------------------------------- + +local function endEffect(st) st.func = nil end +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BGEffect_CheckBattleTurn: non-zero means "the side this effect is aimed at +-- is the player's". A struct's `turn` is BG_EFFECT_USER / BG_EFFECT_TARGET, +-- so the same effect id follows whichever battler is attacking. +function Pool:playerSide(st) + return bit.bxor(bit.band(self.env.battleTurn or 0, 1), st.turn) ~= 0 +end + +function Pool:sideKey(st) + return self:playerSide(st) and "player" or "enemy" +end + +-- BGEffect_CheckFlyDigStatus: zero means "on the field". +function Pool:flyDig(st) + local flying = self.env.flying or {} + return flying[self:sideKey(st)] and true or false +end + +function Pool:clearLYOverrides(value) + value = value or 0 + for row = 0, SCREEN_ROWS do self.lyBackup[row] = value end +end + +-- BattleBGEffect_SetLCDStatCustoms1: the window is the attacker's pic rows. +function Pool:setLCDStatCustoms1(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2f, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +function Pool:resetLCDStatCustom(st) + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) + self.lcdc = nil + endEffect(st) +end + +function Pool:resetVideoHRAM() + self.lcdc = nil + self.bgp, self.obp1 = NORMAL_PAL, NORMAL_PAL + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) +end + +-- BGEffect_FillLYOverridesBackup: the same value on every scanline in the +-- window. `dec d; jr nz` after the first store, so a zero-width window would +-- run 256 times; the port refuses instead of wrapping the array. +function Pool:fillLY(value) + local count = u8(self.lyEnd - self.lyStart) + if count == 0 then count = 256 end + for i = 0, count - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = u8(value) + end +end + +-- BGEffect_DisplaceLYOverridesBackup: the first `a` scanlines of the window +-- are scrolled to a blank part of the map ($90) and the rest are pushed down +-- by a + 1. That is what makes Withdraw and Dig look like the mon sinking +-- rather than sliding. +function Pool:displaceLY(a) + a = u8(a) + local span = u8(self.lyEnd - self.lyStart) + local rest = u8(span - a) + local row = self.lyStart + for _ = 1, (a == 0 and 256 or a) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = 0x90 + row = row + 1 + end + local pushed = u8(0xff - a) + for _ = 1, (rest == 0 and 256 or rest) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = pushed + row = row + 1 + end +end + +-- DeformScreen: a standing sine wave down the window. It walks the FIRST +-- $80 entries of wLYOverridesBackup by their low address byte and writes only +-- the ones inside the window -- `cp c / jr nc` skips while lyStart >= c and +-- `cp c / jr c` skips once lyEnd < c, so the row written is strictly +-- lyStart < row <= lyEnd -- but the phase advances on EVERY iteration, window +-- or not. So where the window sits decides which part of the wave lands on +-- it, and two effects with the same amplitude and offset but different +-- windows do not look alike. +-- +-- `lb de, d, e` puts the AMPLITUDE in d and the phase step in e. +function Pool:deformScreen(amplitude, offset) + local progress = 0 + for row = 0, 0x7f do + if self.lyStart < row and row <= self.lyEnd and row <= SCREEN_ROWS then + self.lyBackup[row] = sine(progress, amplitude) + end + progress = u8(progress + offset) + end +end + +-- InitSurfWaves: the same wave, into the $40-byte wSurfWaveBGEffect ring +-- rather than the overrides themselves. Surf rotates that ring a step a frame +-- and copies it out, which is what makes the water ROLL instead of standing +-- still the way DeformScreen's does. +Pool.SURF_WAVE_LENGTH = 0x40 + +function Pool:initSurfWaves(amplitude, offset) + local progress = 0 + self.surfWave = {} + for index = 0, Pool.SURF_WAVE_LENGTH - 1 do + self.surfWave[index] = sine(progress, amplitude) + progress = u8(progress + offset) + end +end + +-- BattleBGEffect_Surf's `.RotatewSurfWaveBGEffect`: rotate the ring left one, +-- then paint scanlines $00-$5e from it -- zero at and below lyStart, the ring +-- (wrapping every $40 rows) above it. The ring index advances on every +-- scanline including the zeroed ones, so the wave keeps its phase across the +-- boundary. +function Pool:rotateSurfWave() + local wave = self.surfWave + if not wave then return end + local first = wave[0] + for index = 0, Pool.SURF_WAVE_LENGTH - 2 do + wave[index] = wave[index + 1] + end + wave[Pool.SURF_WAVE_LENGTH - 1] = first + local ring = 0 + for row = 0, 0x5e do + local value = 0 + if self.lyStart < row then value = wave[ring] end + if row <= SCREEN_ROWS then self.lyBackup[row] = u8(value) end + ring = bit.band(ring + 1, Pool.SURF_WAVE_LENGTH - 1) + end +end + +-- DeformWater: `count` PAIRS of scanlines either side of a centre at +-- lyStart + `progress`, each pair taking the next step of a sine whose angle +-- climbs by 4 a pair. Both walkers start on the centre row, so it is written +-- twice and the figure is symmetric about it. The two bounds checks are not +-- the same test: the downward walker stops once lyEnd < its row, the upward +-- one once lyStart >= its row. +function Pool:deformWater(count, amplitude, offset, progress) + local down = self.lyStart + (progress or 0) + local up = down + local angle = u8(offset) + for _ = 1, u8(count) do + local value = sine(angle, amplitude) + if self.lyEnd >= down then + if down >= 0 and down <= SCREEN_ROWS then self.lyBackup[down] = value end + down = down + 1 + end + if self.lyStart < up then + if up >= 0 and up <= SCREEN_ROWS then self.lyBackup[up] = value end + up = up - 1 + end + angle = u8(angle + 4) + end +end + +-- BattleBGEffect_WavyScreenFX: rotate the window's overrides up one row, the +-- old top row wrapping around to the bottom. Every wobble effect is +-- DeformScreen once to lay the wave down and then this, once a frame, to make +-- it travel. +function Pool:wavyScreenFX() + local span = u8(self.lyEnd - self.lyStart) + if span == 0 then return end + local first = self.lyBackup[self.lyStart] or 0 + for i = 0, span - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = self.lyBackup[row + 1] or 0 + end + local last = self.lyStart + span + if last <= SCREEN_ROWS then self.lyBackup[last] = first end +end + +-- BattleBGEffect_GetFirstDMGPal / GetNextDMGPal walking a `dc` list. +-- $ff ends the effect (returns nil); $fe restarts the list from the top. +local function nextPal(st, pals) + local index = st.param + st.param = u8(st.param + 1) + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + -- Rewind and hand back the list's first entry. + st.param = 0 + value = pals[1] + end + return value +end + +-- BattleBGEffect_GetNthDMGPal: JT doubles as a per-step frame counter, and it +-- is reloaded from the struct's `turn` -- so the SAME field is the flash speed +-- here and the battler side everywhere else. +local function nthPal(st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + local index = st.param + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + st.param = 0 + value = pals[1] + end + return value + end + st.jt = st.turn + return nextPal(st, pals) +end + +-------------------------------------------------------------------------- +-- The effects (BattleBGEffects jumptable) +-------------------------------------------------------------------------- + +local E = {} + +E.BATTLE_BG_EFFECT_END = function(_, st) endEffect(st) end + +-- BattleBGEffect_FlashContinue: `turn` is the flash duration, `param` the +-- number of flashes left, and the two palettes alternate. +local function flash(self, st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + return + end + st.jt = st.turn + if st.param == 0 then + endEffect(st) + return + end + st.param = u8(st.param - 1) + self.bgp = pals[bit.band(st.param, 1) + 1] +end + +E.BATTLE_BG_EFFECT_FLASH_INVERTED = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 1, 2, 3) }) +end + +E.BATTLE_BG_EFFECT_FLASH_WHITE = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 0, 0, 0) }) +end + +local WHITE_HUES = { dc(3, 2, 1, 0), dc(3, 2, 0, 0), dc(3, 1, 0, 0), 0xff } +local BLACK_HUES = { dc(3, 2, 1, 0), dc(3, 3, 1, 0), dc(3, 3, 2, 0), 0xff } +local ALTERNATE_HUES = { + dc(3, 2, 1, 0), dc(3, 3, 2, 0), dc(3, 3, 3, 0), dc(3, 3, 2, 0), + dc(3, 2, 1, 0), dc(2, 1, 0, 0), dc(1, 0, 0, 0), dc(2, 1, 0, 0), 0xfe, +} + +E.BATTLE_BG_EFFECT_WHITE_HUES = function(self, st) + local value = nthPal(st, WHITE_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_BLACK_HUES = function(self, st) + local value = nthPal(st, BLACK_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_ALTERNATE_HUES = function(self, st) + local value = nthPal(st, ALTERNATE_HUES) + if not value then + endEffect(st) + return + end + self.bgp, self.obp1 = value, value +end + +local OB_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(2, 1, 0, 0), 0xfe } +local OB_MID_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(3, 1, 2, 0), 0xfe } +local BG_INVERTED = { dc(0, 1, 2, 3), dc(1, 2, 0, 3), dc(2, 0, 1, 3), 0xfe } + +E.BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_MID_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED = function(self, st) + local value = nthPal(st, BG_INVERTED) + if value then self.bgp = value end +end + +-- The mon's pic box is simply cleared, held for three frames and restored. +E.BATTLE_BG_EFFECT_HIDE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self.hidden[self:sideKey(st)] = true + elseif jt >= 1 and jt <= 3 then + incJt(st) + elseif jt == 4 then + self.hidden[self:sideKey(st)] = false + endEffect(st) + end +end + +-- BattleBGEffect_RunPicResizeScript: rows of {size, base tile, coord slot}, +-- $ff ending, $fe clearing a box, $fd skipping the draw. The sizes are the +-- six BG squares (6x6, 4x4, 2x2 for the player; 7x7, 5x5, 3x3 for the enemy), +-- which is how a mon grows into or shrinks out of the field. Only the size +-- matters to this port -- the tile ids and the coord slot are the same pic in +-- the same box -- so the script is followed for its TIMING and its scale. +local PIC_RESIZE = { + -- BattleBGEffect_ShowMon + showPlayer = { 0 }, + showEnemy = { 3 }, + -- BattleBGEffect_EnterMon + enterPlayer = { 2, 1, 0 }, + enterEnemy = { 5, 4, 3 }, + -- BattleBGEffect_ReturnMon: each step is preceded by a box clear, which is + -- the -2 row, and the last -3 row leaves the field empty. + returnPlayer = { 0, 1, 2, false }, + returnEnemy = { 3, 4, 5, false }, +} + +local function runPicResize(self, st, script) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + local step = script[st.param + 1] + st.param = u8(st.param + 1) + if step == nil then + self.picSize[side] = nil + endEffect(st) + return + end + if step == false then + self.picSize[side] = nil + self.hidden[side] = true + else + self.picSize[side] = step + self.hidden[side] = false + end + incJt(st) + elseif jt >= 1 and jt <= 2 then + incJt(st) + elseif jt == 3 then + st.jt = 0 + elseif jt == 4 then + self.picSize[side] = nil + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_SHOW_MON = function(self, st) + if self:flyDig(st) then + endEffect(st) + return + end + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.showPlayer or PIC_RESIZE.showEnemy) +end + +E.BATTLE_BG_EFFECT_ENTER_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.enterPlayer or PIC_RESIZE.enterEnemy) +end + +E.BATTLE_BG_EFFECT_RETURN_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.returnPlayer or PIC_RESIZE.returnEnemy) +end + +-- BattleBGEffect_RemoveMon slides the pic's tilemap one column a frame +-- towards the edge it came from, eight or nine columns' worth. +E.BATTLE_BG_EFFECT_REMOVE_MON = function(self, st) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = self:playerSide(st) and 9 or 8 + elseif jt == 1 then + self.slide[side] = self.slide[side] + (self:playerSide(st) and -8 or 8) + incJt(st) + st.param = u8(st.param - 1) + elseif jt == 2 or jt == 3 then + incJt(st) + elseif jt == 4 then + if st.param == 0 then + self.slide[side] = 0 + self.hidden[side] = true + endEffect(st) + return + end + st.jt = 1 + end +end + +-- The two battler-pic objects: the animation borrows the mon's own tiles as +-- an OBJ so it can be moved without touching the tilemap. +local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) + local jt = st.jt + if jt == 0 then + if self:flyDig(st) then + endEffect(st) + return + end + incJt(st) + local player = self:playerSide(st) + self.spawns[#self.spawns + 1] = { + object = player and objectPlayer or objectEnemy, + x = player and (6 * 8) or (16 * 8 + 4), + y = 8 * 8, + param = 0, + } + elseif jt == 1 then + incJt(st) + -- The rows the OBJ now covers are cleared out of the tilemap so the mon + -- is not drawn twice. + self.hidden[self:sideKey(st)] = clearRows + elseif jt >= 2 and jt <= 4 then + incJt(st) + elseif jt == 5 then + self.hidden[self:sideKey(st)] = false + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true) +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true) +end + +-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler +-- (the struct's side) rather than to the whole background, which is what the +-- per-mon fades want; the port keeps that and leaves wBGP alone. +local function rapidCyclePals(self, st, pals) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.turn = st.param + st.param = 0 + return + end + if jt == 1 then + if bit.band(st.turn, 0xf) ~= 0 then + st.turn = u8(st.turn - 1) + return + end + -- The low nybble is reloaded from the high one, which is the step delay. + st.turn = bit.bor(swap(st.turn), st.turn) + local value = nextPal(st, pals) + if value == nil then + st.param = u8(st.param - 1) + incJt(st) + return + end + self.monShade[side] = value + return + end + self.monShade[side] = NORMAL_PAL + endEffect(st) +end + +local RAPID_PALS = { + BATTLE_BG_EFFECT_RAPID_FLASH = { 0xe4, 0x6c, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT = { 0xe4, 0x90, 0x40, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK = { 0xe4, 0xf8, 0xfc, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING = { 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING = { 0xe4, 0xf8, 0xfc, 0xf8, 0xfe }, + BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING = + { 0xe4, 0xf8, 0xfc, 0xf8, 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FLASH_MON_REPEATING = { 0xe4, 0xfc, 0xe4, 0x00, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK = { + 0xe4, 0x90, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x90, 0xe4, 0xff, + }, + BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE = { 0x00, 0x40, 0x90, 0xe4, 0xff }, +} + +for name, pals in pairs(RAPID_PALS) do + E[name] = function(self, st) rapidCyclePals(self, st, pals) end +end + +-- BattleBGEffect_FadeMonsToBlackRepeating fades BOTH battlers, on opposite +-- halves of the same four-step ramp. +local FADE_BOTH = { 0xe4, 0xe4, 0xf8, 0x90, 0xfc, 0x40, 0xf8, 0x90 } + +E.BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + return + end + if jt == 1 then + local age = st.param + st.param = u8(st.param + 1) + if bit.band(age, 7) ~= 0 then return end + -- Bits 3-4 pick the pair, doubled into a row index. + local index = bit.rshift(bit.band(age, 0x18), 3) * 2 + local first = FADE_BOTH[index + 1] or NORMAL_PAL + local second = FADE_BOTH[index + 2] or NORMAL_PAL + if self:playerSide(st) then + self.monShade.player, self.monShade.enemy = first, second + else + self.monShade.enemy, self.monShade.player = first, second + end + return + end + self.monShade.player, self.monShade.enemy = NORMAL_PAL, NORMAL_PAL + endEffect(st) +end + +-- BattleBGEffects_GetShakeAmount. JT is the total frame count, PARAM's low +-- nybble the countdown to the next flip (reloaded from its high nybble) and +-- `turn` the amplitude, negated on every flip. Returns nil once it is done. +local function shakeAmount(self, st) + if st.jt == 0 then + endEffect(st) + return nil + end + st.jt = st.jt - 1 + if bit.band(st.param, 0xf) ~= 0 then + st.param = u8(st.param - 1) + return st.turn + end + st.param = bit.bor(swap(st.param), st.param) + st.turn = u8(-st.turn) + return st.turn +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_X = function(self, st) + self.scx = shakeAmount(self, st) or 0 +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_Y = function(self, st) + self.scy = shakeAmount(self, st) or 0 +end + +-- Rollout shakes vertically and hands the negated amount to the first anim +-- object's Y offset, so the boulder rides the shake instead of floating over +-- it. The cart's extra DelayFrame here is what makes Rollout's animation run +-- at half speed; RunBattleAnimScript skips its own frame delay to compensate. +E.BATTLE_BG_EFFECT_ROLLOUT = function(self, st) + local amount = shakeAmount(self, st) + if amount == nil or bit.band(amount, 0x80) ~= 0 then amount = 0 end + self.scy = amount + self.rolloutYOffset = u8(-amount) +end + +E.BATTLE_BG_EFFECT_WOBBLE_SCREEN = function(self, st) + if st.param >= 0x40 then + self.scx = 0 + return + end + self.scx = sine(st.param, 6) + st.param = u8(st.param + 2) +end + +-- Withdraw: a growing number of the attacker's scanlines are pushed off, so +-- the mon appears to pull into its shell. PARAM's low six bits are how far +-- to go and its top two the step. +E.BATTLE_BG_EFFECT_WITHDRAW = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + elseif jt == 1 then + local limit = bit.band(st.param, 0x3f) + if st.turn >= limit then return end + self:displaceLY(st.turn) + local step = bit.band(bit.rshift(st.param, 6), 3) + st.turn = u8(st.turn + step) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Dig: the same displacement, but it pauses and then eats the pic two +-- scanlines at a time until the whole window is gone. +E.BATTLE_BG_EFFECT_DIG = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 2 + st.param = 0 + elseif jt == 1 then + if st.param ~= 0 then + st.param = u8(st.param - 1) + return + end + st.param = 0x10 + incJt(st) + elseif jt == 2 then + local span = u8(self.lyEnd - self.lyStart) - 1 + if span < st.turn then return end + -- Every eighth scanline the effect steps back a state, which is the + -- pause between digs. + if bit.band(st.turn, 7) == 0 then st.jt = u8(st.jt - 1) end + self:displaceLY(st.turn) + st.turn = u8(st.turn + 2) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- Tackle: the attacker's rows slide eight pixels towards the target and back. +-- `turn` is the signed step and `param` the distance travelled so far. +local function tackleMoveForward(self, st) + if st.param == u8(-8) or st.param == 8 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + st.turn) +end + +local function tackleReturn(self, st) + if st.param == 0 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + u8(-st.turn)) +end + +local function tackleInit(self, st, backwards) + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + -- SCX scrolls the BACKGROUND, so a negative value moves the mon RIGHT: the + -- player's back pic steps towards the enemy on -2, not +2. + local forward = self:playerSide(st) and u8(-2) or 2 + if backwards then forward = self:playerSide(st) and 2 or u8(-2) end + st.param = 0 + st.turn = forward +end + +E.BATTLE_BG_EFFECT_TACKLE = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, false) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VITAL_THROW = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 3 then + tackleReturn(self, st) + elseif jt == 4 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + elseif jt == 1 then + self:fillLY(sine(st.param, 8)) + st.param = u8(st.param + 4) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Always the player's rows, and the window is written directly rather than +-- through SetLCDStatCustoms1 -- this is the wobble the player's own mon does +-- when it is confused, whoever is attacking. +E.BATTLE_BG_EFFECT_WOBBLE_PLAYER = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x37 + st.param = 0 + elseif jt == 1 then + if st.param >= 0x40 then + self:resetLCDStatCustom(st) + return + end + self:fillLY(sine(st.param, 6)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Two sines an octave apart, which is what makes Flail read as thrashing +-- rather than swaying. +E.BATTLE_BG_EFFECT_FLAIL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn, st.param = 0, 0 + elseif jt == 1 then + local wide = sine(st.param, 6) + local narrow = sine(st.turn, 2) + self:fillLY(u8(wide + narrow)) + st.turn = u8(st.turn + 8) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VIBRATE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + st.param = u8(st.param - 1) + -- Flips on the even frames only, so it buzzes at 30 Hz rather than 60. + if bit.band(st.param, 1) ~= 0 then return end + st.turn = u8(-st.turn) + self:fillLY(st.turn) + end +end + +-- BounceDown: the attacker drops in on a cosine and settles, using the same +-- scanline displacement Withdraw does. +E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.turn >= 0x38 then return end + local height = u8(cosine(st.param, 0x10) + 0x10) + self:displaceLY(u8(st.turn + height)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-------------------------------------------------------------------------- +-- The screen-wide deformations +-------------------------------------------------------------------------- +-- +-- These thirteen are the ones that write a DIFFERENT value to every scanline +-- rather than the same one to a band, so they all sit on DeformScreen, +-- DeformWater or the surf ring above. The shape of each is the ASM's; what +-- the port cannot reproduce is the CGB writing rSCX mid-frame at sub-pixel +-- timing, and none of these depend on that -- they depend on the ARRAY, which +-- is modelled exactly. + +-- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to +-- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and +-- `.one` declines to do anything at all until some OTHER effect has set +-- hLCDCPointer -- START_WATER is what the Surf animation queues first. +E.BATTLE_BG_EFFECT_SURF = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:initSurfWaves(2, 2) + jt = 1 + end + if jt == 1 then + if not self.lcdc then return end + self:rotateSurfWave() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Whirlpool: the wave covers the WHOLE screen ($00-$5e) rather than one +-- battler's rows, and it scrolls vertically (rSCY), so the water rolls +-- top to bottom behind both mons. +E.BATTLE_BG_EFFECT_WHIRLPOOL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCY" + self.lyStart, self.lyEnd = 0, 0x5e + self:deformScreen(2, 2) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- The three water effects, which the Surf animation drives as a set: START +-- opens the window and ends itself immediately, WATER does the work, END puts +-- the registers back. +E.BATTLE_BG_EFFECT_START_WATER = function(self, st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + endEffect(st) +end + +-- WATER is the one effect whose three struct fields are all something else: +-- PARAM is the sine phase (climbing 4 a frame), BATTLE_TURN is a frame +-- counter that doubles as the amplitude, and JT_INDEX is the Y position the +-- deformation is centred on. +-- +-- `ld a, [hl]` then `inc [hl]` twice leaves `a` holding the PRE-increment +-- turn, and that is the count DeformWater is called with -- so the figure +-- grows two scanlines a frame from nothing until the counter passes $20. +E.BATTLE_BG_EFFECT_WATER = function(self, st) + local offset = st.param + st.param = u8(st.param + 4) + -- (0xff XOR the high nibble) + 4: the amplitude SHRINKS as the counter + -- climbs, so the wave is widest when it first appears. + local amplitude = u8(bit.bxor(bit.rshift(bit.band(st.turn, 0xf0), 4), 0xff) + 4) + local progress = st.jt + local count = st.turn + if count >= 0x20 then + self:clearLYOverrides(0) + endEffect(st) + return + end + st.turn = u8(st.turn + 2) + self:deformWater(count, amplitude, offset, progress) +end + +E.BATTLE_BG_EFFECT_END_WATER = function(self, st) + self:resetLCDStatCustom(st) +end + +-- Psychic is hardcoded to the whole screen ($00-$5f) whichever side used it, +-- and only travels every FOURTH frame (`and $3 / ret nz`), which is what makes +-- it a slow ripple rather than Teleport's shimmer. +E.BATTLE_BG_EFFECT_PSYCHIC = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x5f + self:deformScreen(6, 5) + st.param = 0 + elseif jt == 1 then + local counter = st.param + st.param = u8(st.param + 1) + if bit.band(counter, 3) ~= 0 then return end + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Teleport: the same wave as Psychic but only over the user's own rows, and +-- travelling every frame. +E.BATTLE_BG_EFFECT_TELEPORT = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self:deformScreen(6, 5) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Night Shade takes its phase step from the struct's PARAM, so the same +-- effect id gives a long slow roll or a tight ripple depending on what the +-- script queued it with. +E.BATTLE_BG_EFFECT_NIGHT_SHADE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Double Team's afterimage: alternate scanlines are pushed +n and -n, so the +-- pic reads as two copies of itself a few pixels apart. `.UpdateLYOverrides` +-- writes the pair (e, -e) down the window and, on an odd-height window, +-- repeats `e` on the last row -- `srl a` leaves the odd bit in carry and +-- `ret nc` is what skips that store on an even one. +local function doubleTeamOverrides(self, value) + local e = u8(value) + local d = u8(-e) + local span = u8(self.lyEnd - self.lyStart) + local pairs_ = bit.rshift(span, 1) + local odd = bit.band(span, 1) ~= 0 + local row = self.lyStart + for _ = 1, pairs_ do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = e + row = row + 1 + if row > SCREEN_ROWS then return end + self.lyBackup[row] = d + row = row + 1 + end + if odd and row <= SCREEN_ROWS then self.lyBackup[row] = e end +end + +E.BATTLE_BG_EFFECT_DOUBLE_TEAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 0 + elseif jt == 1 then + -- Split apart, a pixel a frame, to $10. + if st.param >= 0x10 then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param + 1) + doubleTeamOverrides(self, value) + elseif jt == 2 then + -- Hold, wobbling about the current separation. This state never advances + -- itself; the script's own `incbgeffect` is what moves it on. + local wobble = u8(sine(st.turn, 2) + st.param) + doubleTeamOverrides(self, wobble) + st.turn = u8(st.turn + 4) + elseif jt == 3 then + -- Come back together. The test is `cp $ff`, so a PARAM that started at 0 + -- underflows to $ff and stops there rather than at zero. + if st.param == 0xff then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param - 1) + doubleTeamOverrides(self, value) + elseif jt == 5 then + self:resetLCDStatCustom(st) + end + -- jt 4 is a bare `ret`: the gap the script sits in between the two halves. +end + +-- Acid Armor: the wave is laid down once and then the whole window is scrolled +-- DOWN one scanline a frame, with a blank row ($90) fed in at the top -- so +-- the mon melts into the floor instead of wobbling in place. The two +-- fix-ups at the bottom clear the last two rows once their values are large +-- enough to be showing the pic's own bottom edge. +E.BATTLE_BG_EFFECT_ACID_ARMOR = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + self.lyBackup[self.lyEnd] = 0 + self.lyBackup[self.lyEnd - 1] = 0 + elseif jt == 1 then + for row = self.lyEnd, self.lyStart + 1, -1 do + self.lyBackup[row] = self.lyBackup[row - 1] or 0 + end + self.lyBackup[self.lyStart] = 0x90 + local last = self.lyBackup[self.lyEnd] or 0 + if last >= 1 and last ~= 0x90 then self.lyBackup[self.lyEnd] = 0 end + local penultimate = self.lyBackup[self.lyEnd - 1] or 0 + if penultimate >= 2 and penultimate ~= 0x90 then + self.lyBackup[self.lyEnd - 1] = 0 + end + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Wave Deform: the amplitude ramps up to $20 in state 1 and back down to 0 in +-- state 2, at a fixed phase step of 4. Neither ramp advances the state on its +-- own -- the script does -- so how far it gets is the script's business. +E.BATTLE_BG_EFFECT_WAVE_DEFORM_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + elseif jt == 1 then + if st.param >= 0x20 then return end + local amplitude = st.param + st.param = u8(st.param + 1) + self:deformScreen(amplitude, 4) + elseif jt == 2 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + local amplitude = st.param + st.param = u8(st.param - 1) + self:deformScreen(amplitude, 4) + end +end + +-- The two beta send-outs are `; unused` on the cart -- nothing queues them -- +-- but they are in the jumptable, so a mod or a hand-written script can, and +-- an unimplemented entry would sit in the pool forever. +-- +-- MON1 writes rBGP per scanline rather than a scroll register: every other +-- row of the window steps through $00 (all white), $40, $90 and $e4 (normal), +-- eight frames apart, so the pic fades in through a venetian blind. +local BETA_SEND_OUT_PALS = { 0x00, 0x40, 0x90, 0xe4 } + +-- `.SetLYOverridesBackup`: every SECOND scanline, (lyEnd - lyStart) / 2 times. +local function betaBlind(self, value) + local count = bit.rshift(u8(self.lyEnd - self.lyStart), 1) + local row = self.lyStart + for _ = 1, count do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = u8(value) + row = row + 2 + end +end + +-- `.GetLYOverride`: PARAM counts up and its top bits index the palette list, +-- so each entry is held eight frames. Past the end it returns nil, which is +-- the `cp $ff` the caller branches on. +local function betaPal(st) + local index = bit.rshift(st.param, 3) + st.param = u8(st.param + 1) + return BETA_SEND_OUT_PALS[index + 1] +end + +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0xe4) + self:setLCDStatCustoms1("BGP", st) + self.lyEnd = u8(self.lyEnd + 1) + for row = self.lyStart, self.lyEnd - 1 do + if row > SCREEN_ROWS then break end + self.lyBackup[row] = 0 + end + st.param = 0 + -- `.zero` falls into `.one`, which is a bare ret. + elseif jt == 2 then + local value = betaPal(st) + if value then + betaBlind(self, value) + return + end + st.param = 0 + self.lyStart = u8(self.lyStart + 1) + incJt(st) + elseif jt == 3 then + local value = betaPal(st) + if not value then + incJt(st) + return + end + betaBlind(self, value) + -- The second pass also fills the row the blind skipped at the bottom. + self.lyBackup[self.lyEnd - 1] = u8(value) + elseif jt == 5 then + self:resetVideoHRAM() + endEffect(st) + end + -- jt 1 and 4 are bare rets. +end + +-- MON2 is a plain DeformScreen whose amplitude and phase step are the SAME +-- value, counted down from $40 in eighths -- so the wobble starts at 8 and +-- unwinds to nothing. +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + st.turn = 0x40 + elseif jt == 1 then + if st.turn == 0 then + self:resetLCDStatCustom(st) + return + end + local value = st.turn + st.turn = u8(st.turn - 1) + -- `ld a, [hl] / dec [hl] / srl a x3`: the PRE-decrement value, shifted. + local amount = bit.band(bit.rshift(value, 3), 0x0f) + self:deformScreen(amount, amount) + end +end + +-- Nothing is left unmodelled. The name stays so a caller (and the tests) can +-- still ask, and so the answer is checkable rather than a claim in a comment. +local UNMODELLED = {} +for _, name in ipairs(UNMODELLED) do + E[name] = function(_, st) endEffect(st) end +end + +-------------------------------------------------------------------------- + +-- ExecuteBGEffects: one pass over the five structs. +function Pool:playFrame() + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func then + local fn = E[st.func] + if fn then + fn(self, st) + else + -- An id with no entry would otherwise sit in the pool forever and + -- keep the animation from ending. + endEffect(st) + end + end + end +end + +function Pool:takeSpawns() + local spawns = self.spawns + self.spawns = {} + return spawns +end + +BgEffects.EFFECTS = E +BgEffects.NUM_EFFECTS = NUM_EFFECTS +BgEffects.NORMAL_PAL = NORMAL_PAL +BgEffects.SCREEN_ROWS = SCREEN_ROWS +BgEffects.UNMODELLED = UNMODELLED + +return BgEffects diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua new file mode 100644 index 00000000..4521d34f --- /dev/null +++ b/src/battle/gen2/Catching.lua @@ -0,0 +1,401 @@ +-- Gen 2 catch rate (engine/items/item_effects.asm PokeBallEffect). +-- +-- The rate itself, transcribed from the ASM: +-- +-- rate = ((3 * maxHP - 2 * curHP) * ballAdjustedCatchRate) / (3 * maxHP) +-- rate = max(1, rate) + statusBonus +-- rate = min(255, rate) +-- +-- Two documented cart bugs are reproduced deliberately, because a port that +-- "fixes" them catches mons at rates the real game never would (both are in +-- pokegold's docs/bugs_and_glitches.md): +-- +-- * When 3 * maxHP >= 256 the routine shifts both HP terms right by two +-- before subtracting, which loses precision and makes the formula +-- misbehave for maxHP above 341. +-- * The status bonus was meant to be 10 for sleep/freeze and 5 for +-- burn/poison/paralysis, but the `and` that tests for sleep/freeze leaves +-- the accumulator zero on the fall-through, so burn, poison and paralysis +-- give no bonus at all. +-- +-- Pass `fixBugs = true` to get the intended behaviour instead; nothing in the +-- game sets it, but it makes the difference testable and documents intent. + +-- The mod event/hook buses. `catch.rate` and `battle.ball_thrown` are the SAME +-- 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 Catching = {} + +-- Ball multipliers applied to the species catch rate before the HP term. +-- MASTER_BALL never fails, so it short-circuits rather than multiplying. +-- The specialty balls (BallMultiplierFunctionTable) are conditional and live +-- in Catching.specialtyRate below; FRIEND_BALL has no rate function at all -- +-- its whole effect is the caught mon's happiness, which the catch site sets. +Catching.BALL_MULTIPLIER = { + MASTER_BALL = math.huge, + ULTRA_BALL = 2, + -- SafariBallMultiplier, GreatBallMultiplier and ParkBallMultiplier are one + -- shared routine on the cart (x1.5); Safari is the RBY leftover. + GREAT_BALL = 1.5, + POKE_BALL = 1, + SAFARI_BALL = 1.5, + PARK_BALL = 1.5, + FRIEND_BALL = 1, +} + +-- FastBallMultiplier (engine/items/item_effects.asm): meant to cover all +-- three FleeMons tables, but the loop advances `d` on every byte instead of +-- every table (`jr nz, .next` where the intended jump is `.loop`), so only +-- the first three rows of SometimesFleeMons (data/wild/flee_mons.asm) ever +-- get the x4. Reproduced deliberately, like the two catch-formula bugs. +Catching.FAST_BALL_SPECIES = { + MAGNEMITE = true, GRIMER = true, TANGELA = true, +} + +-- FRIEND_BALL_HAPPINESS (constants/pokemon_data_constants.asm): the one thing +-- a Friend Ball does. The catch site stamps it on the caught mon. +Catching.FRIEND_BALL_HAPPINESS = 200 + +-- HeavyBallMultiplier's weight conversion: the dex weight (tenths of a +-- pound) is turned into tenths of a kilogram with three shift-subtracts +-- (w/2 - w/32 - w/64), and only the HIGH byte of that is compared. +function Catching.heavyBallBoost(weight) + local half = math.floor((weight or 0) / 2) + local sub1 = math.floor(half / 16) + local sub2 = math.floor(sub1 / 2) + local high = math.floor((half - sub1 - sub2) / 256) + if high < 4 then return -20 end -- under 102.4 kg + if high < 8 then return 0 end -- under 204.8 kg + if high < 12 then return 20 end -- under 307.2 kg + if high < 16 then return 30 end -- under 409.6 kg + return 40 +end + +-- The conditional balls (engine/items/item_effects.asm +-- BallMultiplierFunctionTable, HeavyBallMultiplier..FastBallMultiplier). +-- `rate` is the species catch rate byte; every arm caps at 255 the way each +-- `sla b / jr c` pins $ff. Three cart bugs are reproduced deliberately (all +-- in pokegold's own comments): Fast Ball only knows three species, Love Ball +-- boosts SAME-sex pairs, and Moon Ball compares the evolution stone against +-- Gen 1's Moon Stone constant -- Burn Heal in Gen 2 -- so it never boosts. +-- `fixBugs` flips all three to the intended behaviour, same contract as the +-- catch-formula bugs above. +-- One arm per row of BallMultiplierFunctionTable, each fn(rate, opts) -> rate. +-- The records below hang these on `specialty` by identity, so a registry read +-- and Catching.specialtyRate can never answer differently. +local SPECIALTY = { + HEAVY_BALL = function(rate, opts) + -- Additive, not a multiplier; the light-mon subtraction floors at 1 + -- (`ld b, $1` on underflow). + if not opts.weight then return rate end + return math.max(1, rate + Catching.heavyBallBoost(opts.weight)) + end, + LEVEL_BALL = function(rate, opts) + -- x2 / x4 / x8 as the wild level falls below the player's level, its + -- half and its quarter (strictly below at each rung). + local player = opts.playerLevel + local enemy = opts.level + if not (player and enemy) or enemy >= player then return rate end + rate = rate * 2 + if enemy < math.floor(player / 2) then rate = rate * 2 end + if enemy < math.floor(player / 4) then rate = rate * 2 end + return math.min(255, rate) + end, + LURE_BALL = function(rate, opts) + -- x3, only in a BATTLETYPE_FISH battle. + if not opts.fishing then return rate end + return math.min(255, rate * 3) + end, + FAST_BALL = function(rate, opts) + if opts.fixBugs then + if not opts.fleeing then return rate end + elseif not Catching.FAST_BALL_SPECIES[opts.species] then + return rate + end + return math.min(255, rate * 4) + end, + MOON_BALL = function(rate, opts) + -- MOON_STONE_RED is BURN_HEAL's Gen 2 id and nothing evolves with a + -- Burn Heal, so the intended x4 never happens on the cart. + local wanted = opts.fixBugs and "MOON_STONE" or "BURN_HEAL" + if opts.evolveItem ~= wanted then return rate end + return math.min(255, rate * 4) + end, + LOVE_BALL = function(rate, opts) + -- x8 for the same species; the sex test's `ret nz` should be `ret z`, + -- so the boost lands on SAME-sex pairs. Genderless mons never boost. + if not opts.species or opts.species ~= opts.playerSpecies then + return rate + end + local wild, player = opts.gender, opts.playerGender + if not wild or not player or wild == "unknown" + or player == "unknown" then + return rate + end + local same = wild == player + if opts.fixBugs then same = not same end + if not same then return rate end + return math.min(255, rate * 8) + end, +} + +function Catching.specialtyRate(rate, ball, opts) + opts = opts or {} + local arm = SPECIALTY[ball] + if not arm then return rate end + return arm(rate, opts) +end + +Catching.STATUS_BONUS = { sleep = 10, freeze = 10 } +Catching.STATUS_BONUS_FIXED = { + sleep = 10, freeze = 10, burn = 5, poison = 5, toxic = 5, paralyze = 5, +} + +-- ------------------------------------------------------------------ registry +-- +-- Gold's own ball records, in the shape src/mods/Schemas.lua's `balls` registry +-- validates -- the SAME registry name Gen 1 fills from src/battle/Catching.lua, +-- because a mod that adds a ball should not have to learn a second noun. The +-- Gen 1 fields keep their Gen 1 meaning where Gen 2 has one: +-- +-- randMax the ceiling of the catch roll. PokeBallEffect rolls ONE byte +-- against wFinalCatchRate, so every rolling ball is 255 here; +-- Gen 1's per-ball 200/150 ceilings have no Gen 2 counterpart. +-- autoCatch MASTER_BALL, which returns before the rate is computed. +-- flicker DoBallTossSpecialEffects' OBJ-palette strobe, Master and Ultra. +-- +-- hpFactor / wobbleFactor / tossAnim are deliberately absent: Gen 2 decides the +-- wobble count inside the animation (GetPokeBallWobble re-rolls per wobble), so +-- there is no ballFactor2 to carry, and the toss arc is the animation's. +-- +-- Two fields Gen 2 genuinely carries that Gen 1 does not, added rather than +-- renaming anything (the catalog's top-level records are extensible): +-- +-- multiplier the flat factor BallMultiplierFunctionTable applies to the +-- species catch rate. math.huge is the Master Ball's "never +-- fails" and pairs with autoCatch. +-- specialty the conditional arm, fn(rate, opts) -> rate, for the balls +-- whose factor depends on the battle rather than the ball. +-- Present exactly where BALL_MULTIPLIER has no row. +-- +-- FRIEND_BALL keeps multiplier 1 and carries its one real effect as +-- catchHappiness; src/ui/gen2/BattleState.lua stamps it on the caught mon. +Catching.BALLS = { + MASTER_BALL = { randMax = 0, autoCatch = true, flicker = true, + multiplier = Catching.BALL_MULTIPLIER.MASTER_BALL }, + ULTRA_BALL = { randMax = 255, flicker = true, + multiplier = Catching.BALL_MULTIPLIER.ULTRA_BALL }, + GREAT_BALL = { randMax = 255, + multiplier = Catching.BALL_MULTIPLIER.GREAT_BALL }, + POKE_BALL = { randMax = 255, + multiplier = Catching.BALL_MULTIPLIER.POKE_BALL }, + SAFARI_BALL = { randMax = 255, + multiplier = Catching.BALL_MULTIPLIER.SAFARI_BALL }, + PARK_BALL = { randMax = 255, + multiplier = Catching.BALL_MULTIPLIER.PARK_BALL }, + FRIEND_BALL = { randMax = 255, + multiplier = Catching.BALL_MULTIPLIER.FRIEND_BALL, + catchHappiness = Catching.FRIEND_BALL_HAPPINESS }, + HEAVY_BALL = { randMax = 255 }, + LEVEL_BALL = { randMax = 255 }, + LURE_BALL = { randMax = 255 }, + FAST_BALL = { randMax = 255 }, + MOON_BALL = { randMax = 255 }, + LOVE_BALL = { randMax = 255 }, +} + +-- the conditional arms, hung on the records they belong to by identity so a +-- registry read and Catching.specialtyRate cannot answer differently +for id, arm in pairs(SPECIALTY) do + Catching.BALLS[id].specialty = arm +end + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function Catching.registerInto(registry, _, owner) + for id, record in pairs(Catching.BALLS) do + registry:register(id, record, owner) + end +end + +-- The merged `balls` table for this boot, or nil. Every catch site hands the +-- module what it already holds -- a Battle (src/battle/gen2/Battle.lua keeps +-- `data`), the data table itself, or the merged subtable -- so no state has to +-- live on this module. +local function mergedBalls(opts) + if not opts then return nil end + if opts.balls then return opts.balls end + local data = opts.data or (opts.battle and opts.battle.data) + return data and data.gen2Balls or nil +end + +-- The merged record for a ball id, the module's own when no loader ran. An +-- unknown id answers nil on both paths: PokeBallEffect's table has no default +-- row, and the rate math below leaves the species rate alone for one. +function Catching.recordFor(ball, opts) + local merged = mergedBalls(opts) + local record = merged and merged[ball] + if record then return record end + return Catching.BALLS[ball] +end + +local function rand(random, n) + if random then return random(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 +end + +-- The 0..255 rate. `opts`: maxHp, hp, catchRate, ball, status, fixBugs; +-- the specialty-ball conditions ride the same table (weight, level, +-- playerLevel, fishing, species, playerSpecies, gender, playerGender, +-- evolveItem), each supplied by the catch site out of what it already knows. +-- +-- One more optional key and it is the registry seam: `data` (or `battle`, or +-- the merged `balls` / `statuses` subtables directly). A catch site that +-- passes it gets the merged records, so a mod's ball and a mod's status reach +-- the roll; one that does not gets the module's own, which is what every +-- pure-module test and every loader-free boot has always had. +function Catching.rate(opts) + opts = opts or {} + local ball = opts.ball or "POKE_BALL" + -- Through the merged `balls` registry, module records when no loader ran. + -- The three arms are exactly the three rows the record can carry: a flat + -- multiplier, a conditional arm, or neither -- and "neither" is also what an + -- unknown ball id gets, which is BallMultiplierFunctionTable's own answer of + -- leaving the species rate alone. + local record = Catching.recordFor(ball, opts) + local multiplier = record and record.multiplier + if record and record.autoCatch then return 255, true end + if multiplier == math.huge then return 255, true end + + local maxHp = math.max(1, opts.maxHp or 1) + local hp = math.max(0, math.min(opts.hp or maxHp, maxHp)) + local catchRate + if multiplier then + catchRate = math.floor((opts.catchRate or 45) * multiplier) + elseif record and record.specialty then + catchRate = record.specialty(opts.catchRate or 45, opts) + else + catchRate = opts.catchRate or 45 + end + catchRate = math.max(1, math.min(255, catchRate)) + + local tripleMax = maxHp * 3 + local doubleHp = hp * 2 + if tripleMax >= 256 then + -- The cart's precision loss: both terms shift right two bits. + tripleMax = math.floor(tripleMax / 4) + doubleHp = math.floor(doubleHp / 4) + if not opts.fixBugs then + -- And it then compares only the low byte of the shifted max. + tripleMax = tripleMax % 256 + end + doubleHp = math.max(1, doubleHp) + end + tripleMax = math.max(1, tripleMax) + + local rate = math.floor((tripleMax - doubleHp) * catchRate / tripleMax) + rate = math.max(1, rate) + + rate = rate + Catching.statusBonus(opts.status, opts) + return math.min(255, rate), false +end + +-- The status half of the rate, off the merged `statuses` record the same way +-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records +-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers: +-- `catchBonus` is what the cart actually adds (the `and` that tests for +-- sleep/freeze leaves burn, poison and paralysis at zero) and +-- `catchBonusIntended` is the 5 the table meant to give them, which is what +-- `fixBugs` asks for. The two module tables answer when no loader ran. +function Catching.statusBonus(status, opts) + if not status then return 0 end + local data = opts and (opts.data or (opts.battle and opts.battle.data)) + local statuses = (opts and opts.statuses) or (data and data.gen2Statuses) + local record = statuses and statuses[status] + if record then + if opts and opts.fixBugs then + return record.catchBonusIntended or record.catchBonus or 0 + end + return record.catchBonus or 0 + end + local bonuses = (opts and opts.fixBugs) and Catching.STATUS_BONUS_FIXED + or Catching.STATUS_BONUS + return bonuses[status] or 0 +end + +-- Does the ball catch? Returns caught and the final rate (wFinalCatchRate). +-- A rate of 255 or a Master Ball is certain. +-- +-- No wobble count comes out of here: unlike Gen 1, Gen 2 decides how many times +-- the ball rocks DURING the animation. GetPokeBallWobble +-- (engine/battle_anims/pokeball_wobble.asm) is called once per wobble and +-- re-rolls Random against the WobbleProbabilities row that wFinalCatchRate +-- picks, so the count is a property of the animation loop and not of how near +-- the catch roll was. The caller runs that loop with the rate returned here. +function Catching.attempt(opts) + opts = opts or {} + local caught, rate + if Runtime.wantsHook("catch.rate") then + -- catch.rate, the same hook BattleState:catchAttempt calls on Gen 1 and + -- with the same four arguments: the ball id, the target mon, its species + -- record, and the options table vanilla is actually run on -- so a mod + -- that edits o.catchRate or o.status changes the roll exactly as it does + -- on Red, and one that returns `caught, rate` replaces it outright. + -- + -- `mon` and `def` are whatever the catch site supplied. Gold's battle + -- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table + -- -- hp, maxHp, catchRate, status, species -- rather than the mon and its + -- record, so both are nil there until it passes them; nil, not a stand-in + -- a mod would read as the real mon. Passing `battle = self.battle` and + -- `mon = enemy` with them is what puts the capture tail below on the real + -- catch: the Transform reload and the battle.catch_exp hook both hang off + -- the battle, and neither can be reached from a flat table. + caught, rate = Runtime.call("catch.rate", function(_, _, _, o) + return Catching.vanillaAttempt(o) + end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts) + else + caught, rate = Catching.vanillaAttempt(opts) + end + -- PokeBallEffect's captured tail runs BEFORE the mon is added to anything + -- (item_effects.asm:514-566), so a caught mon is reloaded out of its base + -- data -- and the battle.catch_exp hook is asked -- here, while the record + -- the catch site is about to keep is still the battle's. Both live on + -- Battle:caught; this is the seam that reaches it, and it is only reachable + -- when the site hands over the battle it is catching out of (see the note on + -- `mon` below). A caller with no battle -- every pure-module test -- gets + -- the roll and nothing else, exactly as before. + if caught and opts.battle and opts.battle.caught then + opts.battle:caught(opts.mon) + end + -- battle.ball_thrown, the payload BattleState:throwBall emits on Gen 1. + -- `shakes` is deliberately nil rather than 0: Gen 2 does not decide the + -- wobble count here at all (see the note on Catching.attempt above -- the + -- animation loop re-rolls GetPokeBallWobble per wobble), so there is no + -- number to report at throw time and a 0 would read as "it did not rock". + -- `rate` is the Gen 2 addition, the wFinalCatchRate the animation runs on. + if Runtime.wants("battle.ball_thrown") then + Runtime.emit("battle.ball_thrown", { + battle = opts.battle, ball = opts.ball or "POKE_BALL", + caught = caught, shakes = nil, rate = rate, + mon = opts.mon, species = opts.species, + }) + end + return caught, rate +end + +function Catching.vanillaAttempt(opts) + local rate, guaranteed = Catching.rate(opts) + local random = opts and opts.random + if guaranteed or rate >= 255 then return true, rate end + -- The cart rolls one byte against the rate; a roll under it catches. + local roll = rand(random, 256) + if roll < rate then return true, rate end + return false, rate +end + +return Catching diff --git a/src/battle/gen2/Damage.lua b/src/battle/gen2/Damage.lua new file mode 100644 index 00000000..58a2b112 --- /dev/null +++ b/src/battle/gen2/Damage.lua @@ -0,0 +1,328 @@ +-- Gen 2 damage. +-- +-- Ported from engine/battle/effect_commands.asm, in the order the cart's move +-- sequence runs them: damagestats -> damagecalc -> stab -> damagevariation. +-- +-- What differs from Gen 1 (src/battle/Damage.lua), and why this is its own +-- module rather than a flag on that one: +-- * Special is split into Special Attack and Special Defense, so a special +-- move reads the attacker's SpA against the defender's SpD instead of both +-- sides' single `special`. +-- * Critical hits are a *chance ladder* (data/battle/critical_hit_chances.asm +-- 1/15, 1/8, 1/4, 1/3, 1/2) indexed by a "critical level" that Focus +-- Energy, a high-crit move, Scope Lens, and the Lucky Punch / Stick raise. +-- Gen 1 instead derived the chance from base Speed. +-- * A critical hit is a flat x2 and, unlike Gen 1, ignores the attacker's +-- *negative* stat stages rather than all stages. +-- * Type-boost held items (Charcoal, Mystic Water, ...) multiply before the +-- crit, and Steel and Dark exist in the matchup table. +-- +-- Whether a move is physical or special is still decided by its *type*, not +-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's +-- records carry that as `category`. + +local Damage = {} + +-- data/battle/critical_hit_chances.asm, as "1 in N". +Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 } + +-- Gen 2's damage spread: 85% to 100% inclusive. +Damage.MIN_VARIATION = 85 +Damage.MAX_VARIATION = 100 + +-- The cart caps a single hit at 999 (DAMAGE_CAP + MIN_DAMAGE in +-- BattleCommand_DamageCalc). +Damage.MAX_DAMAGE = 999 + +-- BattleCommand_DamageCalc's tail (engine/battle/effect_commands.asm) caps the +-- computed damage at DAMAGE_CAP (997) and then adds MIN_DAMAGE (2) back, so +-- every damaging hit leaves DamageCalc worth at least 2 -- which is what keeps +-- a resisted hit from flooring to zero once the type matchup halves it. +Damage.MIN_DAMAGE = 2 + +-- Stat stage multipliers (numerator, denominator), -6..+6. Same table as +-- Gen 1; a critical hit skips only the negative half for the attacker. +local STAGE = { + [-6] = { 25, 100 }, [-5] = { 28, 100 }, [-4] = { 33, 100 }, + [-3] = { 40, 100 }, [-2] = { 50, 100 }, [-1] = { 66, 100 }, + [0] = { 1, 1 }, + [1] = { 15, 10 }, [2] = { 2, 1 }, [3] = { 25, 10 }, + [4] = { 3, 1 }, [5] = { 35, 10 }, [6] = { 4, 1 }, +} + +function Damage.stageMultiplier(stage) + local entry = STAGE[math.max(-6, math.min(6, stage or 0))] + return entry[1], entry[2] +end + +-- Apply a stat stage, flooring like the cart's Multiply/Divide pair, and never +-- letting a stat reach 0 (a 0 defence would divide by zero). +function Damage.applyStage(value, stage) + local numerator, denominator = Damage.stageMultiplier(stage) + local out = math.floor(value * numerator / denominator) + return math.max(1, out) +end + +-- Is this move physical? `types` is type_chart.lua's `types` table. +function Damage.isPhysical(moveType, types) + local record = types and types[moveType] + if record and record.category then return record.category == "physical" end + -- Without the table, fall back to the Gen 1/2 boundary: the physical block + -- runs NORMAL..GROUND, and the special block starts at FIRE. + local PHYSICAL = { + NORMAL = true, FIGHTING = true, FLYING = true, POISON = true, + GROUND = true, ROCK = true, BUG = true, GHOST = true, STEEL = true, + } + return PHYSICAL[moveType] == true +end + +-- The 1-in-N chance for a critical level. +function Damage.criticalChance(level) + local capped = math.max(0, math.min(6, level or 0)) + return Damage.CRITICAL_CHANCES[capped] +end + +-- BattleCommand_Critical, as a level rather than a roll: +-- +1 Focus Energy, +2 a high-crit move, +1 Scope Lens, +-- +2 Lucky Punch on Chansey / Stick on Farfetch'd. +function Damage.criticalLevel(opts) + local level = 0 + if opts.focusEnergy then level = level + 1 end + if opts.highCritMove then level = level + 2 end + if opts.scopeLens then level = level + 1 end + if opts.speciesItemBonus then level = level + 2 end + return math.min(6, level) +end + +-- Roll a critical hit. `random(n)` must return 0..n-1 (the cart compares a +-- BattleRandom byte against the chance), and defaults to love/math random. +function Damage.rollCritical(criticalLevel, random) + local chance = Damage.criticalChance(criticalLevel) + local roll + if random then + roll = random(chance) + elseif love and love.math then + roll = love.math.random(chance) - 1 + else + roll = math.random(chance) - 1 + end + return roll == 0 +end + +-- The x10 type multiplier of a move against a defender, applying each matchup +-- row separately and flooring in between -- the same rule Gen 1 follows, which +-- is why a dual type can land on 4x or 0.25x. +function Damage.typeMultiplier(moveType, defenderTypes, matchups) + local multiplier = 10 + for _, row in ipairs(matchups or {}) do + if row.attacker == moveType then + for _, defenderType in ipairs(defenderTypes or {}) do + if row.defender == defenderType then + multiplier = math.floor(multiplier * row.multiplier / 10) + break + end + end + end + end + return multiplier +end + +-- The core formula (BattleCommand_DamageCalc): +-- +-- (((2 * Level / 5 + 2) * Power * Attack / Defense) / 50) +-- +-- every step floored, defence clamped to at least 1. +function Damage.base(level, power, attack, defense) + if (power or 0) <= 0 then return 0 end + defense = math.max(1, defense or 1) + local value = math.floor(level * 2 / 5) + 2 + value = value * power + value = value * attack + value = math.floor(value / defense) + value = math.floor(value / 50) + return value +end + +-- Every `info` table Damage.calc returns carries BOTH generations' names for +-- the same two facts: Gen 2 calls them `critical` and `effectiveness`, Gen 1 +-- (src/battle/Damage.lua) calls them `crit` and `typeMult`. The battle.damage +-- hook hands this table to whatever wrapped it, and a mod written against Red +-- must be able to read what it wrapped. +local function withGen1Names(info) + info.crit = info.critical + info.typeMult = info.effectiveness + return info +end + +-- opts: +-- level, power, moveType +-- attacker { attack, specialAttack, types, stages = { attack =, ... } } +-- defender { defense, specialDefense, types, stages } +-- types, matchups -- type_chart.lua's `types` / `matchups` +-- critical -- boolean (roll it with rollCritical first) +-- itemBoostPercent -- type-boost held item, e.g. 10 for Charcoal +-- weatherPercent -- DoWeatherModifiers in tenths (15 / 5 / nil), +-- applied ahead of the badge boost and STAB +-- badgeTypeBoost -- DoBadgeTypeBoosts: the player owns the badge +-- matching the move's type, +1/8 before STAB +-- variation -- 85..100; omit to roll +-- random(n) -- 0..n-1, for the variation roll +-- screen -- Reflect/Light Screen active on the defender +-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c +-- +-- Returns damage, info where info carries the pieces a battle message needs: +-- effectiveness (x10), critical, physical, variation. +function Damage.calc(opts) + local physical = Damage.isPhysical(opts.moveType, opts.types) + local attacker = opts.attacker or {} + local defender = opts.defender or {} + local stagesA = attacker.stages or {} + local stagesD = defender.stages or {} + + local rawAttack = physical and (attacker.attack or 1) + or (attacker.specialAttack or attacker.special or 1) + local rawDefense = physical and (defender.defense or 1) + or (defender.specialDefense or defender.special or 1) + local stageA = physical and (stagesA.attack or 0) + or (stagesA.specialAttack or 0) + local stageD = physical and (stagesD.defense or 0) + or (stagesD.specialDefense or 0) + + -- A critical hit ignores stat changes that would *lower* the damage: the + -- attacker's negative stages and the defender's positive ones. + if opts.critical then + if stageA < 0 then stageA = 0 end + if stageD > 0 then stageD = 0 end + end + + local attack = Damage.applyStage(rawAttack, stageA) + local defense = Damage.applyStage(rawDefense, stageD) + + -- Reflect and Light Screen double the matching defence, and are the one + -- multiplier a critical hit also ignores. + if opts.screen and not opts.critical then + defense = defense * 2 + end + + -- 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 + + if (opts.power or 0) <= 0 then + return 0, withGen1Names({ effectiveness = 10, critical = false, + physical = physical }) + end + local damage = Damage.base(opts.level or 1, opts.power or 0, attack, defense) + + -- Type-boost held items multiply before the crit (.NextItem / .DoneItem). + if opts.itemBoostPercent and opts.itemBoostPercent > 0 then + damage = math.floor(damage * (100 + opts.itemBoostPercent) / 100) + end + + if opts.critical then damage = damage * 2 end + + -- BattleCommand_DamageCalc's tail: cap at DAMAGE_CAP, then add MIN_DAMAGE + -- back, so even a hit whose stat math floored to nothing leaves with 2. + damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE) + + Damage.MIN_DAMAGE + + -- DoWeatherModifiers (engine/battle/misc.asm:102-140), farcalled by + -- BattleCommand_Stab as its very FIRST act (effect_commands.asm:1254): it + -- sits ahead of the badge boost, the STAB x1.5, the type rows and + -- DamageVariation, so every later step floors on top of it. The table's + -- values are tenths (weather_modifiers.asm: MORE_EFFECTIVE 15, + -- NOT_VERY_EFFECTIVE 05), and .ApplyModifier's zero-quotient arm forces the + -- result back to 1, so a weather-halved hit never falls to nothing. + if opts.weatherPercent and opts.weatherPercent ~= 10 then + damage = math.max(1, math.floor(damage * opts.weatherPercent / 10)) + end + + -- DoBadgeTypeBoosts (engine/battle/misc.asm:146), farcalled from + -- BattleCommand_Stab ahead of the STAB multiply: a matching owned badge + -- adds an eighth of the running damage, at least 1, on the player's turn. + if opts.badgeTypeBoost then + damage = damage + math.max(1, math.floor(damage / 8)) + end + + -- STAB, then each type row. BattleCommand_Stab does STAB first, so a + -- resisted same-type move floors after the x1.5. + local stab = false + for _, attackerType in ipairs(attacker.types or {}) do + if attackerType == opts.moveType then stab = true break end + end + if stab then damage = math.floor(damage * 15 / 10) end + + -- Each matchup row multiplies the running damage separately, the way + -- BattleCommand_Stab's .TypesLoop does -- and its zero-quotient check forces + -- the damage back to 1 whenever a non-immune row floors it to nothing, so a + -- resisted hit that lands always deals at least 1 HP. + local effectiveness = Damage.typeMultiplier( + opts.moveType, defender.types, opts.matchups) + for _, row in ipairs(opts.matchups or {}) do + if row.attacker == opts.moveType then + for _, defenderType in ipairs(defender.types or {}) do + if row.defender == defenderType then + damage = math.floor(damage * row.multiplier / 10) + if damage == 0 and row.multiplier > 0 then damage = 1 end + break + end + end + end + end + + if effectiveness <= 0 or damage <= 0 then + return 0, withGen1Names({ + effectiveness = effectiveness, critical = opts.critical or false, + physical = physical, stab = stab, + }) + end + + -- Damage variation last, and only when the running damage is 2 or more + -- (BattleCommand_DamageVariation returns early below that). + local variation = opts.variation + if not variation then + if opts.random then + variation = Damage.MIN_VARIATION + + opts.random(Damage.MAX_VARIATION - Damage.MIN_VARIATION + 1) + elseif love and love.math then + variation = love.math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION) + else + variation = math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION) + end + end + if damage >= 2 then + damage = math.floor(damage * variation / 100) + end + + damage = math.max(1, math.min(Damage.MAX_DAMAGE, damage)) + return damage, withGen1Names({ + effectiveness = effectiveness, + critical = opts.critical or false, + physical = physical, + stab = stab, + variation = variation, + }) +end + +-- Accuracy check. Gen 2 rolls one byte against accuracy scaled by the +-- attacker's accuracy stage and the defender's evasion stage; accuracy of 0 in +-- the data means "never misses" (Swift and friends). +function Damage.rollHit(accuracy, accuracyStage, evasionStage, random) + if not accuracy or accuracy <= 0 then return true end + local numerator, denominator = Damage.stageMultiplier(accuracyStage or 0) + local value = math.floor(accuracy * numerator / denominator) + numerator, denominator = Damage.stageMultiplier(-(evasionStage or 0)) + value = math.floor(value * numerator / denominator) + value = math.max(1, math.min(100, value)) + local roll + if random then + roll = random(100) + elseif love and love.math then + roll = love.math.random(100) - 1 + else + roll = math.random(100) - 1 + end + return roll < value +end + +return Damage diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua new file mode 100644 index 00000000..12a3dcd3 --- /dev/null +++ b/src/battle/gen2/Effects.lua @@ -0,0 +1,510 @@ +-- Gen 2 move effects, as data plus the small amount of arithmetic each one +-- needs. Ported from engine/battle/effect_commands.asm; the battle engine +-- (src/battle/gen2/Battle.lua) owns the turn loop and calls in here for what a +-- move does beyond "roll damage, maybe inflict a status". +-- +-- Everything is keyed by the move's *effect*, the same byte data/moves/moves.asm +-- stores, so a modded move that copies an effect inherits its behaviour -- and +-- an effect this table does not name still lands as an ordinary hit rather than +-- silently doing the wrong thing. +-- +-- No love calls and no engine state: every function takes what it needs and +-- returns a value or a small table, which is what lets the tests drive them +-- directly. + +local Effects = {} + +-- --------------------------------------------------------------- stat stages +-- +-- Stat changes come in four shapes and the effect name says which: +-- *_UP / *_UP_2 raise the user, by one stage or two +-- *_DOWN / *_DOWN_2 lower the target +-- *_UP_HIT raise the user after a damaging hit +-- *_DOWN_HIT lower the target after a damaging hit +-- StatUpMessage / StatDownMessage are the same either way, so the direction +-- and the target are the only things worth tabulating. + +-- { stat, stages, target } where target is "self" or "foe". +Effects.STAT_CHANGES = { + EFFECT_ATTACK_UP = { "attack", 1, "self" }, + EFFECT_DEFENSE_UP = { "defense", 1, "self" }, + EFFECT_SP_ATK_UP = { "specialAttack", 1, "self" }, + EFFECT_EVASION_UP = { "evasion", 1, "self" }, + EFFECT_ATTACK_UP_2 = { "attack", 2, "self" }, + EFFECT_DEFENSE_UP_2 = { "defense", 2, "self" }, + EFFECT_SPEED_UP_2 = { "speed", 2, "self" }, + EFFECT_SP_DEF_UP_2 = { "specialDefense", 2, "self" }, + -- Defense Curl also arms Rollout, which Battle tracks separately. + EFFECT_DEFENSE_CURL = { "defense", 1, "self" }, + + EFFECT_ATTACK_DOWN = { "attack", -1, "foe" }, + EFFECT_DEFENSE_DOWN = { "defense", -1, "foe" }, + EFFECT_SPEED_DOWN = { "speed", -1, "foe" }, + EFFECT_ACCURACY_DOWN = { "accuracy", -1, "foe" }, + EFFECT_EVASION_DOWN = { "evasion", -1, "foe" }, + EFFECT_ATTACK_DOWN_2 = { "attack", -2, "foe" }, + EFFECT_DEFENSE_DOWN_2 = { "defense", -2, "foe" }, + EFFECT_SPEED_DOWN_2 = { "speed", -2, "foe" }, +} + +-- The secondary versions, rolled against the move's effect chance after a hit. +Effects.STAT_CHANGES_ON_HIT = { + EFFECT_ATTACK_UP_HIT = { "attack", 1, "self" }, + EFFECT_DEFENSE_UP_HIT = { "defense", 1, "self" }, + EFFECT_ATTACK_DOWN_HIT = { "attack", -1, "foe" }, + EFFECT_DEFENSE_DOWN_HIT = { "defense", -1, "foe" }, + EFFECT_SPEED_DOWN_HIT = { "speed", -1, "foe" }, + EFFECT_ACCURACY_DOWN_HIT = { "accuracy", -1, "foe" }, + EFFECT_SP_DEF_DOWN_HIT = { "specialDefense", -1, "foe" }, +} + +-- Ancient Power raises every one of the user's stats at once. +Effects.ALL_UP_STATS = { + "attack", "defense", "speed", "specialAttack", "specialDefense", +} + +Effects.STAT_NAMES = { + attack = "ATTACK", defense = "DEFENSE", speed = "SPEED", + specialAttack = "SPCL.ATK", specialDefense = "SPCL.DEF", + accuracy = "ACCURACY", evasion = "EVASION", +} + +-- Stages clamp at ±6 (BattleCommand_StatUp's .CantRaise / .CantLower). +Effects.MAX_STAGE = 6 + +-- Applies a change and says what happened, so the caller can emit the cart's +-- own message: nil when the stage was already at the cap. +function Effects.applyStage(stages, stat, delta) + if not (stages and stat) then return nil end + local current = stages[stat] or 0 + local wanted = current + delta + if wanted > Effects.MAX_STAGE then wanted = Effects.MAX_STAGE end + if wanted < -Effects.MAX_STAGE then wanted = -Effects.MAX_STAGE end + if wanted == current then return nil end + stages[stat] = wanted + return wanted - current +end + +-- BattleCommand_StatUpMessage / StatDownMessage: one stage is "rose"/"fell", +-- two are "sharply rose" / "sharply fell". +function Effects.stageMessage(name, stat, applied) + local label = Effects.STAT_NAMES[stat] or stat + local sharply = math.abs(applied) >= 2 and "sharply " or "" + local verb = applied > 0 and "rose" or "fell" + return ("%s's %s %s%s!"):format(name, label, sharply, verb) +end + +-- ------------------------------------------------------------------ hit count +-- +-- BattleCommand_CheckHit's multi-hit roll: 2 and 3 hits are 3/8 each, 4 and 5 +-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step +-- fallthrough in .DetermineNumberOfHits produces. +function Effects.multiHitCount(random) + local roll = random and random(4) or 0 + if roll < 2 then return roll + 2 end + -- Hits 4 and 5 take a second roll, so each ends up half as likely. + local second = random and random(2) or 0 + return second + 4 +end + +Effects.HIT_COUNTS = { + EFFECT_DOUBLE_HIT = 2, + -- Triple Kick stops early if a hit misses; Battle rolls that per hit. + EFFECT_TRIPLE_KICK = 3, +} + +function Effects.hitCount(effect, random) + if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then + return Effects.multiHitCount(random) + end + return Effects.HIT_COUNTS[effect] or 1 +end + +-- Triple Kick's power climbs 10/20/30 across its three kicks +-- (BattleCommand_TripleKick). +function Effects.tripleKickPower(base, hit) + return (base or 10) * hit +end + +-- ----------------------------------------------------------- recoil and drain + +-- BattleCommand_Recoil: a quarter of the damage dealt, minimum 1. +function Effects.recoilDamage(damageDealt) + return math.max(1, math.floor((damageDealt or 0) / 4)) +end + +-- BattleCommand_DrainTarget: half the damage dealt, minimum 1. +function Effects.drainAmount(damageDealt) + return math.max(1, math.floor((damageDealt or 0) / 2)) +end + +Effects.DRAIN = { + EFFECT_LEECH_HIT = true, + EFFECT_DREAM_EATER = true, +} + +-- --------------------------------------------------------------- two-turn +-- +-- The charge moves all share BattleCommand_Charge: turn one prints a line and +-- stores the move, turn two attacks. Fly and Dig also make the user +-- untargetable in between, which is the `semi-invulnerable` flag here. +Effects.CHARGE = { + EFFECT_RAZOR_WIND = { text = "%s made a whirlwind!" }, + EFFECT_SOLARBEAM = { text = "%s took in sunlight!" }, + EFFECT_SKULL_BASH = { text = "%s lowered its head!" }, + EFFECT_SKY_ATTACK = { text = "%s is glowing!" }, + EFFECT_FLY = { text = "%s flew up high!", vanish = true }, +} + +-- CheckHit's .FlyDigMoves (effect_commands.asm:1713-1746): a vanished target +-- is not a flat miss, four moves reach it in the air and three underground. +Effects.FLY_DIG_EXCEPTIONS = { + FLY = { GUST = true, WHIRLWIND = true, THUNDER = true, TWISTER = true }, + DIG = { EARTHQUAKE = true, FISSURE = true, MAGNITUDE = true }, +} + +-- Keyed by the charge move the target is partway through, which is what the +-- port carries in place of SUBSTATUS_FLYING / SUBSTATUS_UNDERGROUND. +function Effects.hitsVanished(chargeMove, moveId) + local reaches = Effects.FLY_DIG_EXCEPTIONS[chargeMove] + return (reaches and reaches[moveId]) and true or false +end + +-- --------------------------------------------------------------- fixed damage + +-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the +-- damage formula entirely. +function Effects.fixedDamage(effect, attacker, defender, random) + if effect == "EFFECT_LEVEL_DAMAGE" then + return math.max(1, attacker.level or 1) + end + if effect == "EFFECT_SUPER_FANG" then + return math.max(1, math.floor((defender.hp or 1) / 2)) + end + if effect == "EFFECT_PSYWAVE" then + -- 1..(level * 1.5), rerolled until it is in range; one roll is enough here. + local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2)) + return math.max(1, (random and random(ceiling) or 0) + 1) + end + return nil +end + +-- --------------------------------------------------------------- Substitute + +-- BattleCommand_Substitute: a quarter of max HP, which is also what the user +-- pays. Refuses when the user has that much HP or less. +function Effects.substituteCost(maxHp) + return math.max(1, math.floor((maxHp or 1) / 4)) +end + +-- ------------------------------------------------------------ counter moves + +-- Counter answers physical damage, Mirror Coat special, both at double and +-- both only when the foe hit the user this turn (BattleCommand_Counter). +Effects.COUNTER = { + EFFECT_COUNTER = "physical", + EFFECT_MIRROR_COAT = "special", +} + +function Effects.counterDamage(taken) + return math.max(1, (taken or 0) * 2) +end + +-- ------------------------------------------------------ rollout / fury cutter + +-- Both double their power per consecutive use, Rollout for five turns and Fury +-- Cutter until it misses; the cart caps the doubling at 5 steps either way. +Effects.RAMPING = { + EFFECT_ROLLOUT = 5, + EFFECT_FURY_CUTTER = 5, +} + +function Effects.rampedPower(base, count, curled) + local steps = math.min(math.max(count or 0, 0), 4) + local power = (base or 1) * 2 ^ steps + -- Defense Curl doubles Rollout again (BattleCommand_RolloutPower). + if curled then power = power * 2 end + return math.floor(power) +end + +-- ----------------------------------------------------------------- magnitude +-- +-- data/moves/magnitude_power.asm, one row per magnitude: { chance, power, +-- magnitude number }. The chance column is assembled through `percent` +-- (`* $ff / 100`, macros/data.asm:23), so `5 percent + 1` is 13 and +-- `100 percent` is 255 -- the thresholds below are those bytes, not the +-- percentages they were written as. +Effects.MAGNITUDE_POWER = { + { 13, 10, 4 }, + { 38, 30, 5 }, + { 89, 50, 6 }, + { 166, 70, 7 }, + { 217, 90, 8 }, + { 242, 110, 9 }, + { 255, 150, 10 }, +} + +-- BattleCommand_GetMagnitude (engine/battle/move_effects/magnitude.asm): ONE +-- random byte walks the table and the first row whose threshold is not below +-- it wins (`ld a, [hli] / cp b / jr nc`). The row's power goes into d, which +-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores +-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the +-- power and the magnitude number the text prints. +function Effects.magnitudePower(random) + local roll = random and random(256) or 0 + for _, row in ipairs(Effects.MAGNITUDE_POWER) do + if row[1] >= roll then return row[2], row[3] end + end + local last = Effects.MAGNITUDE_POWER[#Effects.MAGNITUDE_POWER] + return last[2], last[3] +end + +-- ------------------------------------------------------------------- weather +-- +-- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to +-- 5, which HandleWeather decrements at the end of every turn; the turn it +-- reaches zero the weather ends. data/battle/weather_modifiers.asm is the +-- whole of what weather does to damage. + +Effects.WEATHER = { + EFFECT_RAIN_DANCE = "rain", + EFFECT_SUNNY_DAY = "sun", + EFFECT_SANDSTORM = "sandstorm", +} + +Effects.WEATHER_TURNS = 5 + +Effects.WEATHER_START_TEXT = { + rain = "It started to rain!", + sun = "The sunlight got bright!", + sandstorm = "A sandstorm brewed!", +} + +Effects.WEATHER_TURN_TEXT = { + rain = "Rain continues to fall.", + sun = "The sunlight is strong.", + sandstorm = "The sandstorm rages.", +} + +Effects.WEATHER_END_TEXT = { + rain = "The rain stopped.", + sun = "The sunlight faded.", + sandstorm = "The sandstorm subsided.", +} + +-- data/battle/weather_modifiers.asm pairs each weather with MORE_EFFECTIVE or +-- NOT_VERY_EFFECTIVE, and those are 15 and 05 in tenths +-- (constants/battle_constants.asm:22, :24) -- MORE_EFFECTIVE is x1.5, NOT the +-- type chart's x2, which is SUPER_EFFECTIVE (20). Gen 2's weather boost is a +-- half again, and only the type chart doubles. +Effects.WEATHER_TYPE_MODIFIERS = { + rain = { WATER = 1.5, FIRE = 0.5 }, + sun = { FIRE = 1.5, WATER = 0.5 }, +} + +-- The one move whose EFFECT rather than type is modified: Solarbeam in rain. +Effects.WEATHER_MOVE_MODIFIERS = { + rain = { EFFECT_SOLARBEAM = 0.5 }, +} + +function Effects.weatherModifier(weather, moveType, effect) + if not weather then return 1 end + local byType = Effects.WEATHER_TYPE_MODIFIERS[weather] + if byType and byType[moveType] then return byType[moveType] end + local byMove = Effects.WEATHER_MOVE_MODIFIERS[weather] + if byMove and byMove[effect] then return byMove[effect] end + return 1 +end + +-- HandleWeather's .SandstormDamage: an eighth of max HP, and Rock, Ground and +-- Steel are immune. A mon underground (Dig) is skipped too. +Effects.SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true } + +function Effects.sandstormDamage(maxHp) + return math.max(1, math.floor((maxHp or 8) / 8)) +end + +function Effects.sandstormHits(types) + for _, type_ in ipairs(types or {}) do + if Effects.SANDSTORM_IMMUNE[type_] then return false end + end + return true +end + +-- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather +-- shifts that one step either way: x2 in sun, /2 in rain or sandstorm +-- (BattleCommand_Heal's .Weather block walks a multiplier index). +Effects.SUN_HEAL = { + EFFECT_MORNING_SUN = true, + EFFECT_SYNTHESIS = true, + EFFECT_MOONLIGHT = true, +} + +function Effects.weatherHealFraction(weather) + if weather == "sun" then return 2 / 3 end + if weather == "rain" or weather == "sandstorm" then return 1 / 4 end + return 1 / 2 +end + +-- ---------------------------------------------------------------- Perish Song +-- +-- BattleCommand_PerishSong sets the counter to 4 on BOTH sides; it ticks down +-- at the end of every turn and the mon faints when it reaches 0. +Effects.PERISH_TURNS = 4 + +-- -------------------------------------------------------------------- Encore +-- +-- 3-6 turns (`and $3` plus three increments), and the target is locked into +-- the move it last used. Encore, Mirror Move and Struggle cannot be encored, +-- and neither can a move with no PP left. +Effects.ENCORE_BLOCKED = { + ENCORE = true, MIRROR_MOVE = true, STRUGGLE = true, +} + +function Effects.encoreTurns(random) + return (random and random(4) or 0) + 3 +end + +-- ------------------------------------------------------------------- Disable +-- +-- The count is a packed byte: the low nybble is the number of turns (1-8, the +-- `and 7` retried until nonzero, then incremented) and the high nybble is the +-- move slot plus one. Only the turn count matters to the port, but the shape +-- is what says a slot of 0 means "nothing disabled". +function Effects.disableTurns(random) + local roll = 0 + for _ = 1, 8 do + roll = (random and random(8) or 1) % 8 + if roll ~= 0 then break end + end + if roll == 0 then roll = 1 end + return roll + 1 +end + +-- --------------------------------------------------------- Protect and Endure +-- +-- ProtectChance halves the success chance for every CONSECUTIVE use: the +-- threshold starts at $ff and is shifted right once per use, so use n +-- succeeds with probability (256 >> n) / 256. Once the shift reaches zero the +-- move always fails, which is five uses. +function Effects.protectChance(consecutive) + local threshold = 0xff + for _ = 1, (consecutive or 0) do + threshold = math.floor(threshold / 2) + if threshold == 0 then return 0 end + end + return threshold +end + +-- The roll is a non-zero byte, decremented, and the move succeeds when it is +-- BELOW the threshold. +function Effects.protectSucceeds(consecutive, random) + local threshold = Effects.protectChance(consecutive) + if threshold == 0 then return false end + local roll = (random and random(255) or 0) + 1 + return (roll - 1) < threshold +end + +-- ---------------------------------------------------------------------- Bide +-- +-- BattleCommand_UnleashEnergy stores for 2 or 3 turns (`and 1` plus two +-- increments) and BattleCommand_StoreEnergy pays back DOUBLE everything the +-- user took while storing, capped at the 16-bit maximum. +function Effects.bideTurns(random) + return (random and random(2) or 0) + 2 +end + +function Effects.bideDamage(stored) + return math.min(0xffff, (stored or 0) * 2) +end + +-- ---------------------------------------------------------------------- Rage +-- +-- SUBSTATUS_RAGE: while it is set, every hit the user takes raises its Attack +-- one stage. It is cleared by using any other move. + +-- ---------------------------------------------------------------- Future Sight +-- +-- Four turns: the damage is rolled NOW, stored, and lands when the counter +-- reaches one. The move itself does nothing on the turn it is used. +Effects.FUTURE_SIGHT_TURNS = 4 + +-- ---------------------------------------------------------------------- OHKO +-- +-- BattleCommand_OHKO fails outright when the target is the higher level; when +-- it is not, the move's accuracy becomes acc + 2 * (level difference), capped +-- at 255, and a hit sets damage to $ffff. +function Effects.ohkoAccuracy(baseAccuracy, userLevel, targetLevel) + if (targetLevel or 1) > (userLevel or 1) then return nil end + local bonus = ((userLevel or 1) - (targetLevel or 1)) * 2 + return math.min(255, (baseAccuracy or 0) + bonus) +end + +-- ------------------------------------------------------------------- Beat Up +-- +-- One hit per party member that is alive and free of any major status, each +-- swinging with that member's own base Attack and level against the target's +-- base Defense. The move fails outright when nobody qualifies. +function Effects.beatUpParty(party, activeIndex) + local hits = {} + for index, mon in ipairs(party or {}) do + local healthy = (mon.hp or 0) > 0 + -- The ACTIVE mon is checked against its battle status rather than its + -- party record, which is the same thing here. + local clean = not mon.status or (index == activeIndex and not mon.status) + if healthy and clean then + hits[#hits + 1] = { index = index, mon = mon } + end + end + return hits +end + +-- --------------------------------------------------------------- Baton Pass +-- +-- ResetBatonPassStatus: what does NOT survive the switch. Everything else -- +-- the stat stages, Substitute, Leech Seed, Perish Song, the confusion counter +-- -- goes with the incoming mon, which is the whole point of the move. +-- +-- `preTransform` is deliberately NOT on this list even though `transformed` is: +-- it is the passer's own identity waiting to be put back, and the drops run +-- BEFORE Battle:clearVolatile, which is what puts it back and clears both keys +-- (Battle:untransform). Dropping it here would strand a passing DITTO as a +-- permanent copy instead. +Effects.BATON_PASS_DROPS = { + "nightmare", "disable", "disableTurns", "attract", "transformed", + "encore", "encoreTurns", "lastMove", +} + +-- ------------------------------------------------------------------ Metronome +-- +-- data/moves/metronome_exception_moves.asm: Metronome cannot pick these, and +-- it also never picks a move the user already knows. +Effects.METRONOME_EXCEPTIONS = { + METRONOME = true, STRUGGLE = true, SKETCH = true, MIMIC = true, + COUNTER = true, MIRROR_COAT = true, PROTECT = true, DETECT = true, + ENDURE = true, DESTINY_BOND = true, SLEEP_TALK = true, THIEF = true, +} + +-- Picks a move id uniformly out of `moveOrder`, rerolling on an excepted move +-- or one the user already has -- the same reject loop .GetMove runs. +function Effects.metronomePick(moveOrder, known, random) + local count = #(moveOrder or {}) + if count == 0 then return nil end + local owned = {} + for _, move in ipairs(known or {}) do owned[move.id or move] = true end + for _ = 1, 64 do + local pick = moveOrder[(random and random(count) or 0) + 1] + if pick and not Effects.METRONOME_EXCEPTIONS[pick] and not owned[pick] then + return pick + end + end + return nil +end + +-- ---------------------------------------------------------------- Mirror Move +-- +-- Copies the OPPONENT's last move, and fails when there is none or when the +-- user already knows it -- CheckUserMove returns "found", and Mirror Move +-- takes the .failed branch on a hit. + +return Effects diff --git a/src/battle/gen2/Encounter.lua b/src/battle/gen2/Encounter.lua new file mode 100644 index 00000000..a8f8fbd7 --- /dev/null +++ b/src/battle/gen2/Encounter.lua @@ -0,0 +1,182 @@ +-- Gen 2 wild encounters (engine/overworld/wildmons.asm). +-- +-- The Gen 2 mechanic Gen 1 does not have: a grass table holds three separate +-- seven-slot lists, one per time of day, plus a per-time encounter *rate*. So +-- the same patch of grass on Route 29 gives Pidgey in the morning and Hoothoot +-- at night, and the clock that decides which is the same one that decides the +-- palette -- see src/world/gen2/Palettes.lua. +-- +-- Slot probabilities are Gen 2's ProbabilityTable (data/wild/probabilities.asm): +-- 30, 30, 20, 10, 5, 4, 1 percent across the seven slots, cumulative. + +local Encounter = {} + +-- data/wild/probabilities.asm, cumulative out of 100. +Encounter.GRASS_SLOT_CHANCES = { 30, 60, 80, 90, 95, 99, 100 } +-- Water has three slots: 60, 30, 10. +Encounter.WATER_SLOT_CHANCES = { 60, 90, 100 } + +local function roll(random, n) + if random then return random(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 +end + +-- Which slot a 0..99 roll lands in. +local function slotFor(chances, value) + for index, cumulative in ipairs(chances) do + if value < cumulative then return index end + end + return #chances +end + +-- Does a step in grass start a battle? The map's rate is out of 256 +-- (`db 2 percent`), and the cart compares one random byte against it. +function Encounter.triggers(rate, random) + if not rate or rate <= 0 then return false end + return roll(random, 256) < rate +end + +-- The grass encounter for a map at a time of day, or nil when that map has +-- none. `daytime` is "MORN"/"DAY"/"NITE"/"DARK"; DARK reuses the night list, +-- since the cart only stores three (wildmons.asm masks the palette daytime down +-- to three when indexing). +function Encounter.grassSlot(encounters, mapId, daytime, random) + local entry = encounters and encounters.grass and encounters.grass[mapId] + if not entry then return nil end + local key = (daytime == "DARK") and "NITE" or (daytime or "DAY") + local slots = entry.slots and (entry.slots[key] or entry.slots.DAY) + if not slots then return nil end + local index = slotFor(Encounter.GRASS_SLOT_CHANCES, roll(random, 100)) + local slot = slots[index] + if not slot or not slot.species then return nil end + return { species = slot.species, level = slot.level, slot = index } +end + +function Encounter.grassRate(encounters, mapId, daytime) + local entry = encounters and encounters.grass and encounters.grass[mapId] + if not entry then return 0 end + local key = (daytime == "DARK") and "NITE" or (daytime or "DAY") + return (entry.rates and (entry.rates[key] or entry.rates.DAY)) or 0 +end + +function Encounter.waterSlot(encounters, mapId, random) + local entry = encounters and encounters.water and encounters.water[mapId] + if not entry or not entry.slots then return nil end + local index = slotFor(Encounter.WATER_SLOT_CHANCES, roll(random, 100)) + local slot = entry.slots[index] + if not slot or not slot.species then return nil end + return { species = slot.species, level = slot.level, slot = index } +end + +function Encounter.waterRate(encounters, mapId) + local entry = encounters and encounters.water and encounters.water[mapId] + return (entry and entry.rate) or 0 +end + +-- Fishing: a rod's list is (cumulative chance, species, level) rows out of 256, +-- ending at 100%. A roll past the group's own `chance` is a bite of nothing. +function Encounter.fish(encounters, fishGroup, rod, random) + local group = encounters and encounters.fishGroups + and encounters.fishGroups[fishGroup] + if not group then return nil end + local list = group[rod or "old"] + if not list or #list == 0 then return nil end + local value = roll(random, 256) + for _, row in ipairs(list) do + if value < (row.chance or 0) then + if not row.species or row.species == "NO_ITEM" then return nil end + return { species = row.species, level = row.level } + end + end + return nil +end + +-- GetFishGroupIndex (engine/events/fish.asm), the fishing half of a swarm: +-- Fish calls it before it indexes FishGroups, and it swaps FISHGROUP_QWILFISH +-- for FISHGROUP_QWILFISH_SWARM (and FISHGROUP_REMORAID for +-- FISHGROUP_REMORAID_SWARM) while wFishingSwarmFlag names that swarm. Nothing +-- else is substituted: FISHGROUP_QWILFISH_NO_SWARM is a map header value of its +-- own and never becomes a swarm group. `fishSwarm` is the FISHSWARM_* byte +-- (constants/script_constants.asm), which the port keeps in +-- save.dailyFlags.fishingSwarm and reads back through Roamers.Swarm.fishing. +Encounter.FISHSWARM_NONE = 0 +Encounter.FISHSWARM_QWILFISH = 1 +Encounter.FISHSWARM_REMORAID = 2 + +local FISH_SWARM_GROUPS = { + [Encounter.FISHSWARM_QWILFISH] = { + FISHGROUP_QWILFISH = "FISHGROUP_QWILFISH_SWARM", + }, + [Encounter.FISHSWARM_REMORAID] = { + FISHGROUP_REMORAID = "FISHGROUP_REMORAID_SWARM", + }, +} + +-- A cache built before the extractor carried the two swarm rows has no such +-- group at all, and Fish on a missing group is a bite of nothing; falling back +-- to the map's own group keeps those rods rolling their ordinary list. +function Encounter.fishGroupFor(encounters, group, fishSwarm) + local swap = FISH_SWARM_GROUPS[fishSwarm or Encounter.FISHSWARM_NONE] + local swarmed = swap and swap[group] + if not swarmed then return group end + local groups = encounters and encounters.fishGroups + if not (groups and groups[swarmed]) then return group end + return swarmed +end + +-- Which fish group a MAP belongs to lives on the map record, so a caller with +-- a map id and a rod does not have to know about groups at all. +function Encounter.fishSlot(encounters, mapId, rod, random, maps, fishSwarm) + local map = maps and maps[mapId] + local group = map and map.fishGroup + if not group then + -- Callers that already hold the map (the World does) pass it in; without + -- it, fall back to the pond, which is what an unlisted map fishes. + group = "FISHGROUP_POND" + end + group = Encounter.fishGroupFor(encounters, group, fishSwarm) + local key = rod + if rod == "OLD_ROD" then key = "old" + elseif rod == "GOOD_ROD" then key = "good" + elseif rod == "SUPER_ROD" then key = "super" end + return Encounter.fish(encounters, group, key or "old", random) +end + +-- Headbutt trees: TreeMonMaps says which set a map uses and TreeMons holds +-- that set's two lists. The rows are cumulative percentages ending at -1, the +-- same shape as the fishing lists. +function Encounter.treeSet(encounters, mapId) + return encounters and encounters.trees and encounters.trees[mapId] or nil +end + +-- A headbutt on the tree at (cx, cy). Whether the COMMON or the RARE list is +-- rolled comes from the tree's own coordinates on the cart -- GetTreeMons +-- hashes them so the same tree always behaves the same way -- which is what +-- keeps a player from re-rolling one tree for a rare mon. +function Encounter.treeIsRare(cx, cy) + return ((cx or 0) * 5 + (cy or 0) * 7) % 10 < 1 +end + +function Encounter.treeSlot(encounters, mapId, cx, cy, random) + local setName = Encounter.treeSet(encounters, mapId) + if not setName then return nil end + local set = encounters and encounters.treeSets and encounters.treeSets[setName] + if not set then return nil end + local list = Encounter.treeIsRare(cx, cy) and set.rare or set.common + if not list or #list == 0 then return nil end + local value = roll(random, 100) + local total = 0 + for _, row in ipairs(list) do + total = total + (row.chance or 0) + if value < total then + if not row.species then return nil end + return { species = row.species, level = row.level } + end + end + return nil +end + +return Encounter diff --git a/src/battle/gen2/HpBar.lua b/src/battle/gen2/HpBar.lua new file mode 100644 index 00000000..a146e4f6 --- /dev/null +++ b/src/battle/gen2/HpBar.lua @@ -0,0 +1,155 @@ +-- The HP bar, exactly as the cart computes it. +-- +-- Two routines, and both matter for parity because the bar is what a player +-- reads the whole battle off: +-- +-- engine/pokemon/health.asm ComputeHPBarPixels +-- pixels = curHP * HP_BAR_LENGTH_PX / maxHP, floored, where +-- HP_BAR_LENGTH_PX = HP_BAR_LENGTH (6 tiles) * TILE_WIDTH (8) = 48. +-- A live mon never shows an empty bar: a result of 0 is forced to 1. +-- A fainted mon shows exactly 0. +-- When maxHP >= 256 the routine divides both the product and maxHP by 4 +-- first, because hDivisor is one byte -- so a high-HP mon's bar moves in +-- coarser steps than the exact ratio would. Reproduced, not smoothed over. +-- +-- home/tilemap.asm GetHPPal +-- green when pixels >= 24 (HP_BAR_LENGTH_PX * 50 / 100) +-- yellow when pixels >= 10 (HP_BAR_LENGTH_PX * 21 / 100, integer 10) +-- red otherwise +-- Note the boundaries are inclusive on the *pixel* count, not a percentage: +-- exactly half HP is green, and the yellow floor is 10/48 rather than 21%. +-- +-- Colours themselves come from palettes.lua's hpBar (gfx/battle/hp_bar.pal). + +local HpBar = {} + +HpBar.LENGTH_TILES = 6 +HpBar.TILE_WIDTH = 8 +HpBar.LENGTH_PX = HpBar.LENGTH_TILES * HpBar.TILE_WIDTH -- 48 +-- How tall the coloured channel is inside the bar's frame. The cart's bar +-- tiles are 8px rows with a 1px rule above and below the fill. +HpBar.CHANNEL_PX = 3 + +-- GetHPPal's thresholds, computed the way RGBDS does (integer division). +HpBar.GREEN_PIXELS = math.floor(HpBar.LENGTH_PX * 50 / 100) -- 24 +HpBar.YELLOW_PIXELS = math.floor(HpBar.LENGTH_PX * 21 / 100) -- 10 + +-- Pixels of bar to fill, 0..48. +function HpBar.pixels(hp, maxHp) + hp = math.max(0, hp or 0) + maxHp = math.max(0, maxHp or 0) + if hp == 0 then return 0 end + if maxHp == 0 then return 0 end + local product = hp * HpBar.LENGTH_PX + local divisor = maxHp + if divisor >= 256 then + -- The one-byte-divisor shift, applied to both sides. + product = math.floor(product / 4) + divisor = math.floor(divisor / 4) + if divisor == 0 then divisor = 1 end + end + local pixels = math.floor(product / divisor) + if pixels == 0 then return 1 end + return math.min(HpBar.LENGTH_PX, pixels) +end + +-- "green" / "yellow" / "red", keyed to match palettes.lua's hpBar table. +function HpBar.palette(pixels) + if (pixels or 0) >= HpBar.GREEN_PIXELS then return "green" end + if (pixels or 0) >= HpBar.YELLOW_PIXELS then return "yellow" end + return "red" +end + +function HpBar.paletteFor(hp, maxHp) + return HpBar.palette(HpBar.pixels(hp, maxHp)) +end + +-- The bar's two colours out of palettes.lua: the light background the empty +-- part of the bar shows, and the fill. +function HpBar.colors(palettes, key) + local pal = palettes and palettes.hpBar and palettes.hpBar[key] + if not pal then return nil, nil end + return pal[1], pal[2] +end + +-- Draw the bar at a pixel position: 6 tiles wide, black frame, white interior, +-- coloured fill from the left. +-- +-- The cart builds it out of tiles -- $62 is an empty bar cell and $63..$6a are +-- the eight partial fills, so the fill really does move one pixel at a time +-- inside a fixed 48px frame, and the *unfilled* part is white, not tinted. +function HpBar.draw(palettes, hp, maxHp, px, py) + local G = love and love.graphics + if not G then return end + local pixels = HpBar.pixels(hp, maxHp) + local _, fill = HpBar.colors(palettes, HpBar.palette(pixels)) + -- Frame: one pixel of black around the 48x2 channel the fill lives in. + G.setColor(0, 0, 0, 1) + G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, HpBar.CHANNEL_PX + 2) + G.setColor(1, 1, 1, 1) + G.rectangle("fill", px, py, HpBar.LENGTH_PX, HpBar.CHANNEL_PX) + if pixels > 0 then + if fill then + G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) + else + G.setColor(0.1, 0.1, 0.1, 1) + end + G.rectangle("fill", px, py, pixels, HpBar.CHANNEL_PX) + end + G.setColor(0, 0, 0, 1) +end + +-- The battle HUD's bar, which is the plain bar with the cart's "HP:" prefix in +-- front of it (tiles $60/$61 in home/pokemon.asm, two tiles wide). `tx`/`ty` +-- are the tile the prefix starts at; the bar follows two tiles later, so the +-- whole assembly is 2 + 6 = 8 tiles wide. +-- +-- Returns the tile column just past the bar, so a caller can put the bar's end +-- cap or the frame stub there. +function HpBar.drawWithLabel(palettes, hp, maxHp, tx, ty, font) + if font then + love.graphics.setColor(0, 0, 0, 1) + font.draw("HP:", tx * 8, ty * 8) + end + -- The bar's channel sits in the middle of the tile row, matching the tiles. + HpBar.draw(palettes, hp, maxHp, (tx + 2) * 8, ty * 8 + 2) + return tx + 2 + HpBar.LENGTH_TILES +end + +-- The experience bar under the player's HUD (FillInExpBar). Same 6-tile +-- width, but it fills toward the *next* level rather than showing a ratio of a +-- maximum, and it is a flat blue with no colour states (gfx/battle/exp_bar.pal). +function HpBar.drawExp(palettes, fraction, px, py) + local G = love and love.graphics + if not G then return end + fraction = math.max(0, math.min(1, fraction or 0)) + local pixels = math.floor(fraction * HpBar.LENGTH_PX) + local pal = palettes and palettes.expBar + local fill = pal and pal[2] or pal and pal[1] + G.setColor(0, 0, 0, 1) + G.rectangle("fill", px - 1, py - 1, HpBar.LENGTH_PX + 2, 3) + G.setColor(1, 1, 1, 1) + G.rectangle("fill", px, py, HpBar.LENGTH_PX, 1) + if pixels > 0 then + if fill then + G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) + else + G.setColor(0.3, 0.55, 0.95, 1) + end + G.rectangle("fill", px, py, pixels, 1) + end + G.setColor(0, 0, 0, 1) +end + +-- How far along its current level a mon is, for the exp bar. +function HpBar.expFraction(mon, growth, levelFor) + if not (mon and growth and levelFor) then return 0 end + local level = mon.level or 1 + local base = levelFor(growth, level) + local next_ = levelFor(growth, level + 1) + if next_ <= base then return 0 end + local into = (mon.experience or base) - base + return math.max(0, math.min(1, into / (next_ - base))) +end + +return HpBar diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua new file mode 100644 index 00000000..5ae7f8df --- /dev/null +++ b/src/battle/gen2/Mon.lua @@ -0,0 +1,480 @@ +-- A Gen 2 party member: stats, moves, level-up and experience. +-- +-- Separate from src/pokemon/Pokemon.lua because the struct itself changed: +-- Gen 2 splits `special` into Special Attack and Special Defense, adds a held +-- item, happiness and pokerus, and its level-up moves come from EvosAttacks +-- rather than a Gen 1 learnset table. +-- +-- Stat formula is unchanged from Gen 1 (data/pokemon/base_stats + DVs): +-- stat = floor((base * 2 + DV * 2 + floor(sqrt(statExp) / 4)) * level / 100) + 5 +-- HP is the same but + level + 10 +-- with the Gen 2 twist that a mon's SpA and SpD share one Special DV, which is +-- why a high-Special DV raises both. +-- +-- Experience curves come from data/growth_rates.asm, whose `growth_rate` macro +-- documents its own polynomial: +-- [1]/[2] * n^3 + [3] * n^2 + [4] * n - [5] +-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per +-- GROWTH_* so this needs no hardcoded table. + +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 +-- Gen 1, with the same payload keys: a mod that watches a Red party watches a +-- Gold one unchanged (docs/mod-api-gen2-compat.md). +local Runtime = require("src.mods.Runtime") + +local Mon = {} + +Mon.MAX_LEVEL = 100 +Mon.PARTY_SIZE = 6 + +-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess. +Mon.MAX_DV = 15 + +local function rand(a, b) + if love and love.math and love.math.random then + return love.math.random(a, b) + end + return math.random(a, b) +end + +function Mon.randomDVs() + return { + hp = nil, -- derived below + attack = rand(0, Mon.MAX_DV), + defense = rand(0, Mon.MAX_DV), + speed = rand(0, Mon.MAX_DV), + special = rand(0, Mon.MAX_DV), + } +end + +-- The HP DV is not stored: it is the low bit of each of the other four +-- (Gen 1 and 2 both build it this way), which is why a perfect-HP mon needs +-- all four others odd. +function Mon.hpDV(dvs) + local function bit(value) return (value or 0) % 2 end + return bit(dvs.attack) * 8 + bit(dvs.defense) * 4 + + bit(dvs.speed) * 2 + bit(dvs.special) +end + +local function statValue(base, dv, level, statExp) + local exp = math.floor(math.sqrt(statExp or 0) / 4) + return math.floor((((base or 1) * 2 + (dv or 0) * 2 + exp) * level) / 100) + 5 +end + +-- All six stats at a level. `statExp` is optional per-stat effort. +function Mon.stats(baseStats, dvs, level, statExp) + baseStats = baseStats or {} + dvs = dvs or {} + statExp = statExp or {} + local hpDv = dvs.hp or Mon.hpDV(dvs) + local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2 + + math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100) + + level + 10 + return { + hp = hp, + attack = statValue(baseStats.attack, dvs.attack, level, statExp.attack), + defense = statValue(baseStats.defense, dvs.defense, level, statExp.defense), + speed = statValue(baseStats.speed, dvs.speed, level, statExp.speed), + -- One Special DV feeds both special stats, and so does one Special stat + -- exp: the Gen 2 party struct kept Gen 1's five exp words (macros/ram.asm + -- box_struct ends them at SpcExp), so SpA and SpD grow together. The + -- per-stat keys are still read as a fallback for a record written before + -- the shared word existed. + specialAttack = statValue(baseStats.specialAttack, dvs.special, level, + statExp.special or statExp.specialAttack), + specialDefense = statValue(baseStats.specialDefense, dvs.special, level, + statExp.special or statExp.specialDefense), + } +end + +-- The five stat exp words, in struct order. There is no sixth: see Mon.stats. +Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" } + +-- Each word is 16 bit and GiveExperiencePoints stops it at $ffff rather than +-- letting it wrap (.stat_exp_maxed_out). +Mon.MAX_STAT_EXP = 65535 + +function Mon.newStatExp() + return { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } +end + +-- GiveExperiencePoints' .stat_exp_loop (engine/battle/core.asm): the defeated +-- mon's base stats are added to every participant's stat exp, and the loop runs +-- NUM_EXP_STATS = 5 times over a six-entry base stat block, so the Special word +-- takes the loser's Special ATTACK and the Special Defense base stat is never +-- read at all. +-- +-- `.EvenlyDivideExpAmongParticipants` divides the base stats in place before +-- any of this, and only when two or more mons took part, which is why the +-- divisor is shared with Mon.experienceGain rather than computed here. +-- +-- Pokerus adds the same value a SECOND time (`jr z, .stat_exp_awarded` skips +-- the second add when the byte is zero) -- doubled, not multiplied by a rate, +-- so it stacks with nothing. +-- `halved` is the EXP.SHARE tax: with any holder in the party the whole +-- wEnemyMon base stat block is `srl`'d in place before EITHER pass runs +-- (engine/battle/core.asm, the IsAnyMonHoldingExpShare block ahead of the +-- first GiveExperiencePoints call), so participants and holders both draw +-- stat exp from the halved values. +function Mon.gainStatExp(mon, loserDef, participants, doubled, halved) + if type(mon) ~= "table" then return nil end + local base = (loserDef and loserDef.baseStats) or {} + local share = math.max(1, math.floor(participants or 1)) + mon.statExp = mon.statExp or Mon.newStatExp() + local gains = {} + for _, key in ipairs(Mon.STAT_EXP_ORDER) do + local from = (key == "special") and base.specialAttack or base[key] + from = from or 0 + if halved then from = math.floor(from / 2) end + local gain = math.floor(from / share) + if doubled then gain = gain * 2 end + local value = (mon.statExp[key] or 0) + gain + if value > Mon.MAX_STAT_EXP then value = Mon.MAX_STAT_EXP end + mon.statExp[key] = value + gains[key] = gain + end + return gains +end + +-- Total experience needed to *be* `level`, from a GROWTH_* record. +-- The single point every experience calculation resolves its curve through. +-- The merged growth_rates registry wins, then the extractor's own coefficient +-- rows on data.pokemon.growthRates, so a mod-free boot reads exactly the table +-- it always did and a boot with a registered curve reads that instead. +-- +-- Both live at the same key space (GROWTH_MEDIUM_FAST and friends), and the +-- registry's Data path is the shared `growth_rates` one Gen 1 uses -- the +-- point of routing it there rather than to a gen2 table is that a mod writes +-- ONE record for both games (src/mods/Builtins.lua's Gen 2 registrant seeds +-- this registry from the coefficient rows, so the vanilla curves are Gold's +-- own). +function Mon.growthFor(data, curve) + if not curve then return nil end + local registered = data and data.growth_rates and data.growth_rates[curve] + if registered then return registered end + return data and data.pokemon and data.pokemon.growthRates + and data.pokemon.growthRates[curve] +end + +-- Seeds the growth_rates registry with Gold's own curves, as records carrying +-- expForLevel so the id space and the record shape both match Gen 1's. Called +-- by src/mods/Builtins.lua under Gen 2. A dataset with no coefficient rows +-- (the ROM-free fixtures) seeds nothing rather than registering broken curves. +function Mon.registerInto(registry, data, owner) + local rows = data and data.pokemon and data.pokemon.growthRates + if type(rows) ~= "table" then return end + for curve, row in pairs(rows) do + -- the closure holds the coefficient row, so the registered record computes + -- exactly what the arm below would have + registry:register(curve, { + expForLevel = function(level) return Mon.experienceForLevel(row, level) end, + }, owner) + end +end + +-- `growth` is either the extractor's coefficient row (numerator / denominator / +-- squared / linear / constant, straight off GrowthRates in the ROM) or a +-- growth_rates REGISTRY record, which carries expForLevel(level) instead -- +-- the same record shape Gen 1's registry uses (src/pokemon/Growth.lua), so a +-- mod that registers a custom curve writes one record and it works in both +-- games. A registered curve wins outright; the coefficient arm is what a +-- mod-free boot and every driver still run. +function Mon.experienceForLevel(growth, level) + if growth and growth.expForLevel then + return math.max(0, math.floor(growth.expForLevel(level) or 0)) + end + if not growth then return level * level * level end + local n = level + local numerator = growth.numerator or 1 + local denominator = growth.denominator or 1 + local value = math.floor(numerator * n * n * n / denominator) + value = value + (growth.squared or 0) * n * n + value = value + (growth.linear or 0) * n + value = value - (growth.constant or 0) + return math.max(0, value) +end + +-- The level a total experience buys. Walks up rather than inverting the +-- polynomial, which the cart also does (it only ever compares against the next +-- level's threshold). +function Mon.levelForExperience(growth, experience) + local level = 1 + while level < Mon.MAX_LEVEL do + if experience < Mon.experienceForLevel(growth, level + 1) then break end + level = level + 1 + end + return level +end + +-- The moves a species knows on arrival at `level`: its last four level-up +-- moves at or below it (EvosAttacks order, later moves pushing earlier ones +-- out, which is what makes a caught mon's moveset deterministic). +function Mon.movesAtLevel(def, level, moves) + local known = {} + for _, entry in ipairs((def and def.levelMoves) or {}) do + if entry.level <= level then + -- A move already known is not learned twice. + local duplicate = false + for _, existing in ipairs(known) do + if existing == entry.move then duplicate = true break end + end + if not duplicate then + known[#known + 1] = entry.move + if #known > 4 then table.remove(known, 1) end + end + end + end + local out = {} + for _, id in ipairs(known) do + local moveDef = moves and moves[id] + out[#out + 1] = { + id = id, + pp = moveDef and moveDef.pp or 0, + maxPp = moveDef and moveDef.pp or 0, + } + end + return out +end + +-- Build a party member. `data` needs `pokemon` and `moves`; growth records +-- live on data.pokemon.growthRates (written by the extractor). +function Mon.new(data, species, level, opts) + opts = opts or {} + local def = data and data.pokemon and data.pokemon[species] + if not def then return nil end + level = math.max(1, math.min(Mon.MAX_LEVEL, level or 5)) + local dvs = opts.dvs or Mon.randomDVs() + dvs.hp = Mon.hpDV(dvs) + local statExp = opts.statExp or Mon.newStatExp() + local stats = Mon.stats(def.baseStats, dvs, level, statExp) + local growth = Mon.growthFor(data, def.growthRate) + return { + species = species, + name = def.name or species, + nickname = opts.nickname, + level = level, + experience = Mon.experienceForLevel(growth, level), + dvs = dvs, + -- The five stat exp words. A wild or gift mon starts at zero: nothing in + -- the cart seeds them, MON_STAT_EXP is zeroed by _MoveMon. + statExp = statExp, + -- MON_PKRS. Zero is "never infected"; src/core/gen2/Pokerus.lua owns every + -- read and write of it after this. + pokerus = opts.pokerus or 0, + stats = stats, + hp = opts.hp or stats.hp, + maxHp = stats.hp, + types = def.types, + moves = opts.moves or Mon.movesAtLevel(def, level, data.moves), + -- Held item; wild mons roll one from BaseData's two item slots on the cart, + -- which is not modeled yet, so only scripted gifts carry one. + item = opts.item, + status = nil, + -- 70 for a caught mon, 120 for a gift/hatched one. + happiness = opts.happiness or 70, + caughtLevel = level, + -- 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. + shiny = opts.shiny or Mon.isShiny(dvs, + { species = species, def = def, level = level }), + gender = Mon.gender(def, dvs, { species = species, level = level }), + -- Unown has no gender and no shininess worth looking at, but it does have + -- a FORM, and the form is the same DVs read a different way + -- (GetUnownLetter, engine/gfx/load_pics.asm). Stamped at build time so + -- every screen that shows an Unown -- the battle pic, the box, the #DEX -- + -- reads one field instead of each redoing the bit shuffle. + unownLetter = (species == Unown.SPECIES) + and Unown.letterFromDVs(dvs) or nil, + } +end + +-- AddPartyMon copies wPlayerName into wPartyMonOTs and wPlayerID into MON_ID +-- (move_mon.asm:44-56, :143-149); SendMonIntoBox does the same (:970-994). +function Mon.stampOT(save, mon) + local player = save and save.player + if not (mon and player) then return mon end + if player.id == nil then player.id = rand(0, 65535) end + mon.ot = mon.ot or player.name + -- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints. + mon.otName = mon.otName or mon.ot + mon.otId = mon.otId or player.id + return mon +end + +-- shiny.roll and gender.roll, two of the names Gen 2 invents: Gen 1 has +-- neither shininess nor gender in the ROM at all (Red's shiny indicator mods +-- read src/pokemon/Stats.lua's virtual pattern, which is a mod-side +-- convention, not an engine seam), so there is no Gen 1 name to share. +-- +-- Both wrap the DV-derived roll rather than the mon that comes out of it, +-- because on the cart these ARE the roll: CheckShininess and GetGender read +-- the same two DV bytes LoadEnemyMon just generated, and nothing later can +-- change the answer without changing the DVs. Wrapping here means a shiny-odds +-- mod and a gender-ratio mod work on every route a mon arrives by -- a wild +-- encounter, a gift, a hatch, a trade -- because Mon.new is the one builder. +-- +-- Shared ctx keys: +-- dvs the DV set being read, exactly as stored on the mon +-- species the species id, nil when the caller had none to give +-- def that species' record, nil likewise +-- level the level the mon is being built at, nil for a bare query +-- +-- gender.roll's ctx carries `ratio` as well, BaseData's genderRatio byte, so a +-- mod can shift the threshold rather than restate the whole rule. A chain that +-- returns something that is not one of "male" / "female" / "unknown" is +-- ignored, because every screen that prints a gender indexes by those three. + +-- Gen 2 shininess: the classic DV pattern (Speed/Defense/Special all 10, and +-- Attack in {2,3,6,7,10,11,14,15}). +function Mon.vanillaShiny(dvs) + if not dvs then return false end + if dvs.speed ~= 10 or dvs.defense ~= 10 or dvs.special ~= 10 then + return false + end + local attack = dvs.attack or 0 + return attack % 4 == 2 or attack % 4 == 3 +end + +function Mon.isShiny(dvs, ctx) + if not Runtime.wantsHook("shiny.roll") then return Mon.vanillaShiny(dvs) end + local shiny = Runtime.call("shiny.roll", function(c) + return Mon.vanillaShiny(c.dvs) + end, { dvs = dvs, species = ctx and ctx.species, def = ctx and ctx.def, + level = ctx and ctx.level }) + return shiny and true or false +end + +-- Gender comes from the Attack DV against the species' ratio threshold: an +-- Attack DV *below* the threshold is female (BaseData's `db GENDER_F12_5` is +-- already scaled out of 256). +function Mon.vanillaGender(def, dvs) + local ratio = def and def.genderRatio + if not ratio then return "unknown" end + if ratio == 0xff then return "unknown" end + -- The DV is 0..15; the threshold is out of 256 in steps of 16. + local threshold = math.floor(ratio / 16) + return ((dvs and dvs.attack or 0) < threshold) and "female" or "male" +end + +local GENDERS = { male = true, female = true, unknown = true } + +function Mon.gender(def, dvs, ctx) + if not Runtime.wantsHook("gender.roll") then + return Mon.vanillaGender(def, dvs) + end + local gender = Runtime.call("gender.roll", function(c) + return Mon.vanillaGender(c.def, c.dvs) + end, { def = def, dvs = dvs, ratio = def and def.genderRatio, + species = (ctx and ctx.species) or (def and def.id), + level = ctx and ctx.level }) + if not GENDERS[gender] then return Mon.vanillaGender(def, dvs) end + return gender +end + +-- Experience for defeating `loser`, per recipient. Gen 2: +-- exp = baseExp * loserLevel / 7, split among the recipients of the pass, +-- then GiveExperiencePoints' three BoostExp arms in the cart's own order, +-- each a floored x1.5 on the running amount: +-- traded the mon's OT id differs from the player's (BoostedExpPointsText) +-- trainer a trainer battle (wBattleMode) +-- luckyEgg the mon HOLDS a LUCKY_EGG -- checked by item id, not held +-- effect, exactly as the cart's `cp LUCKY_EGG` does +-- opts.halved is the EXP.SHARE tax: any holder in the party halves the base +-- exp byte before either pass (the same `srl` block that halves stat exp). +function Mon.experienceGain(loserDef, loserLevel, participants, trainer, opts) + opts = opts or {} + local baseExp = (loserDef and loserDef.baseExp) or 0 + if opts.halved then baseExp = math.floor(baseExp / 2) end + local value = math.floor(baseExp * (loserLevel or 1) / 7) + value = math.floor(value / math.max(1, participants or 1)) + if opts.traded then value = math.floor(value * 3 / 2) end + if trainer then value = math.floor(value * 3 / 2) end + if opts.luckyEgg then value = math.floor(value * 3 / 2) end + return math.max(1, value) +end + +-- Award experience, level up as far as it reaches, and report what happened so +-- the battle can print "grew to level N!" and offer new moves. +function Mon.gainExperience(mon, amount, data) + local def = data and data.pokemon and data.pokemon[mon.species] + local growth = Mon.growthFor(data, def and def.growthRate) + mon.experience = (mon.experience or 0) + math.max(0, amount or 0) + local before = mon.level + local capped = Mon.experienceForLevel(growth, Mon.MAX_LEVEL) + if mon.experience > capped then mon.experience = capped end + local after = Mon.levelForExperience(growth, mon.experience) + if after <= before then + return { levels = 0, learned = {} } + end + mon.level = after + -- Recompute stats and carry the HP gain, the way the cart adds the delta + -- rather than refilling. + local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or 1 + mon.stats = Mon.stats(def and def.baseStats, mon.dvs, after, mon.statExp) + mon.maxHp = mon.stats.hp + mon.hp = math.min(mon.maxHp, (mon.hp or previousMax) + + (mon.maxHp - previousMax)) + + -- pokemon.level_up, once per level crossed and after the stats were + -- recalculated, exactly as src/battle/Experience.lua raises it on Gen 1 -- + -- a jump of three levels is three events, not one. `learnable` is the moves + -- this species learns at exactly that level, the same list Gen 1 carries. + if Runtime.wants("pokemon.level_up") then + for level = before + 1, after do + local learnable = {} + for _, entry in ipairs((def and def.levelMoves) or {}) do + if entry.level == level then learnable[#learnable + 1] = entry.move end + end + Runtime.emit("pokemon.level_up", { + mon = mon, level = level, prevLevel = level - 1, learnable = learnable, + }) + end + end + + -- Every level-up move between the old and new level is offered. + local learned = {} + for _, entry in ipairs((def and def.levelMoves) or {}) do + if entry.level > before and entry.level <= after then + learned[#learned + 1] = entry.move + end + end + return { levels = after - before, learned = learned, from = before, to = after } +end + +-- Teach a move, or report that all four slots are full so the caller can ask +-- which to forget. +function Mon.learnMove(mon, moveId, data) + mon.moves = mon.moves or {} + for _, move in ipairs(mon.moves) do + if move.id == moveId then return false, "known" end + end + local def = data and data.moves and data.moves[moveId] + local entry = { + id = moveId, + pp = def and def.pp or 0, + maxPp = def and def.pp or 0, + } + if #mon.moves >= 4 then return false, "full", entry end + mon.moves[#mon.moves + 1] = entry + -- pokemon.move_learned, the payload BattleState:learnMove emits on Gen 1. + -- This is Gen 2's single choke point for teaching a move -- the level-up + -- award, an evolution's new move and the TM path all arrive here -- so the + -- event covers all three rather than only the battle's. + Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) + return true +end + +-- Which evolution (if any) fires at this level. +function Mon.evolutionAtLevel(def, level) + for _, entry in ipairs((def and def.evolutions) or {}) do + if entry.method == "EVOLVE_LEVEL" and (entry.level or 0) <= level then + return entry + end + end + return nil +end + +return Mon diff --git a/src/battle/gen2/Prize.lua b/src/battle/gen2/Prize.lua new file mode 100644 index 00000000..4e492bd9 --- /dev/null +++ b/src/battle/gen2/Prize.lua @@ -0,0 +1,223 @@ +-- Prize money for beating a trainer. +-- +-- Two routines, in two files, and both halves matter: +-- +-- ComputeTrainerReward (engine/battle/read_trainer_party.asm) runs when the +-- party is READ, not when it is beaten: wBattleReward = the class's +-- TRNATTR_BASE_REWARD times wCurPartyLevel, and wCurPartyLevel at that +-- point is whatever the LAST row of data/trainers/parties.asm left there. +-- So Falkner pays for his level 9 Pidgeotto and not for the level 7 Pidgey +-- that came out first, whichever of them faints last. +-- WinTrainerBattle (engine/battle/core.asm) is the trainer-defeated arm, and +-- it hands out wBattleReward FOUR TIMES -- `ld c, 4`, one add per pass -- +-- before doubling the figure twice for the text. That factor of four is +-- the difference between Falkner's ¥900 and a ¥225 that would look +-- plausible and be wrong. +-- +-- The split is the other half of Bank of Mom: those four quarters are dealt +-- between wMoney and wMomsMoney by wMomSavingMoney, so "save some money for +-- me" is a standing 25% deduction on every trainer you beat, not a thing that +-- only happens when you walk into the house. +-- +-- love-free and save-shaped: takes the Gold save table +-- (src/core/gen2/Save.lua) and writes the two accounts on it, so the battle +-- engine, the world and the tests all reach the same routine. + +local Strings = require("src.core.Strings") + +local Prize = {} + +-- constants/misc_constants.asm. The same cap Save.MAX_MONEY carries; spelled +-- out here so this module stays usable against a bare save-shaped table. +Prize.MAX_MONEY = 999999 + +-- .DoubleReward saturates: `sla [hl] / rl [hl] / rl [hl] / ret nc` and then +-- $ff into all three bytes, so the shift tops out at 24 bits rather than +-- wrapping. wBattleReward is three bytes, which is where the number comes +-- from -- it is NOT the money cap. +local REWARD_CAP = 0xffffff + +-- constants/ram_constants.asm, wMomSavingMoney's low bits. MOM_ACTIVE_F (bit +-- 7) is deliberately outside MOM_SAVING_MONEY_MASK: it says the bank +-- conversation has happened, not that anything is being skimmed. +-- +-- "All" is bits 0 AND 1 together, not MOM_SAVING_ALL_MONEY_F -- that third +-- bit is inside the mask and is never written by anything, which is why +-- WinTrainerBattle compares against `(1 << SOME) | (1 << HALF)` rather than +-- against it. +local MOM_SAVING_MONEY_MASK = 7 +local MOM_SAVING_SOME, MOM_SAVING_HALF = 1, 2 +local MOM_SAVING_ALL = MOM_SAVING_SOME + MOM_SAVING_HALF + +-- data/items/attributes.asm: HELD_AMULET_COIN is the only held effect that +-- reaches this file. CheckAmuletCoin (engine/battle/core.asm) latches +-- wAmuletCoin when a mon holding one is SENT OUT, and nothing clears it for +-- the rest of the battle, so the coin still pays after its holder has fainted. +Prize.AMULET_COIN = "AMULET_COIN" + +-- data/text/battle.asm. Declared here and formatted at the call site so +-- Strings.source is what registers them, the same way Decorations declares +-- its own five. No line markers: every battle message in this port is one +-- flowing string that Chrome.wrap breaks to the box. +local GOT_MONEY = Strings.source("%s got %s%d for winning!") +local SENT_SOME = Strings.source("%s got %s%d for winning! Sent some to MOM!") +-- The half and all texts really are this short on the cart: they replace the +-- money line rather than following it, which is a quirk no Gold player can +-- see because BankOfMom only ever writes MOM_SAVING_SOME_MONEY_F. +local SENT_HALF = Strings.source("Sent half to MOM!") +local SENT_ALL = Strings.source("Sent all to MOM!") + +-- charmap.asm: the currency glyph, the same one Chrome.money floats in front +-- of a six-digit field. +local YEN = "\xc2\xa5" + +-------------------------------------------------------------------------- +-- ComputeTrainerReward +-------------------------------------------------------------------------- + +-- hProduct is four bytes and wBattleReward takes the low two of them with a +-- zero on top, so the product is kept modulo 65536. No vanilla class can +-- reach that (255 * 100 = 25500), but a mod that raises a base reward should +-- truncate the way the cart does rather than quietly pay more. +function Prize.reward(baseMoney, level) + local base = math.floor(tonumber(baseMoney) or 0) + local lvl = math.floor(tonumber(level) or 0) + if base < 0 then base = 0 end + if lvl < 0 then lvl = 0 end + return (base * lvl) % 0x10000 +end + +-- The level ComputeTrainerReward would have seen: wCurPartyLevel after +-- ReadTrainerParty's loop, which is the last row it built. +function Prize.rewardLevel(party) + local last = party and party[#party] + return (last and last.level) or 0 +end + +local function doubleReward(value) + local doubled = (value or 0) * 2 + if doubled > REWARD_CAP then return REWARD_CAP end + return doubled +end + +-------------------------------------------------------------------------- +-- The accounts +-------------------------------------------------------------------------- + +local function playerMoney(save) + local player = save and save.player + return (player and player.money) or 0 +end + +local function momMoney(save) + local mom = save and save.mom + return (mom and mom.savedMoney) or 0 +end + +-- AddBattleMoneyToAccount: a 24-bit add followed by a compare against +-- MAX_MONEY, and the overflow arm WRITES the cap rather than refusing the +-- add. The cart clamps; it does not wrap and it does not reject. +local function addToAccount(have, amount) + local total = have + amount + if total > Prize.MAX_MONEY then return Prize.MAX_MONEY end + return total +end + +local function setPlayerMoney(save, value) + local player = save and save.player + if player then player.money = value end +end + +local function setMomMoney(save, value) + local mom = save and save.mom + if mom then mom.savedMoney = value end +end + +-- wMomSavingMoney & MOM_SAVING_MONEY_MASK. BankOfMom (engine/events/mom.asm) +-- only ever stores (1 << MOM_ACTIVE_F) or that plus (1 << +-- MOM_SAVING_SOME_MONEY_F), so in Gold the masked byte is 0 or 1 and nothing +-- else -- which is why `savingMoney` is a boolean on this save rather than a +-- number. MOM_SAVING_HALF / _ALL are kept below anyway because the split +-- loop reads them and a Crystal-shaped save would set them. +function Prize.savingMode(save) + local mom = save and save.mom + if not (mom and mom.active and mom.savingMoney) then return 0 end + if type(mom.savingMoney) == "number" then + return mom.savingMoney % (MOM_SAVING_MONEY_MASK + 1) + end + return MOM_SAVING_SOME +end + +-- `ld b, a` then the two loops: b quarters to Mom, 4 - b to the wallet. The +-- `cp (1 << SOME) | (1 << HALF) / inc a` is what turns the setting into a +-- count -- 3 means ALL, which is four quarters, not three. A masked byte of +-- 4 or more is not a value anything writes, and the cart's own text lookup +-- would run off the end of .SentToMomTexts for one, so it is read as nothing +-- rather than guessed at. +local function quartersToMom(mode) + if mode == MOM_SAVING_ALL then return 4 end + if mode == MOM_SAVING_HALF then return 2 end + if mode == MOM_SAVING_SOME then return 1 end + return 0 +end + +Prize.QUARTERS = 4 + +-------------------------------------------------------------------------- +-- WinTrainerBattle +-------------------------------------------------------------------------- + +-- opts: +-- baseMoney the class's TRNATTR_BASE_REWARD (Trainers.lookup's baseMoney) +-- level wCurPartyLevel, i.e. Prize.rewardLevel(the trainer's party) +-- amuletCoin wAmuletCoin, latched by CheckAmuletCoin +-- +-- Returns a record of what happened, which is what the caller turns into the +-- message: `total` is the figure the text prints (the quarter doubled twice), +-- `toMom` is how many of the four quarters Mom took, and `mode` is the +-- wMomSavingMoney setting the text is chosen by. +function Prize.award(save, opts) + opts = opts or {} + local quarter = Prize.reward(opts.baseMoney, opts.level) + -- `ld a, [wAmuletCoin] / and a / call nz, .DoubleReward` -- before the + -- split, so Mom's cut doubles with everything else. + if opts.amuletCoin then quarter = doubleReward(quarter) end + + -- .CheckMaxedOutMomMoney: carry means wMomsMoney is BELOW the cap. With no + -- carry the whole reward goes to the wallet and the text is .KeepItAll, + -- however the savings setting is left -- Mom stops skimming once she is + -- full rather than throwing the quarter away. + local mode = 0 + if momMoney(save) < Prize.MAX_MONEY then mode = Prize.savingMode(save) end + local toMom = quartersToMom(mode) + + local wallet, saved = playerMoney(save), momMoney(save) + for _ = 1, toMom do saved = addToAccount(saved, quarter) end + for _ = 1, Prize.QUARTERS - toMom do wallet = addToAccount(wallet, quarter) end + setPlayerMoney(save, wallet) + setMomMoney(save, saved) + + return { + quarter = quarter, + total = doubleReward(doubleReward(quarter)), + toMom = toMom, + mode = mode, + wallet = wallet, + saved = saved, + } +end + +-- The line StdBattleTextbox prints, chosen by .SentToMomTexts / .KeepItAll. +function Prize.message(award, playerName) + local name = playerName or "PLAYER" + local total = (award and award.total) or 0 + local mode = (award and award.mode) or 0 + if mode == MOM_SAVING_ALL then return Strings(SENT_ALL) end + if mode == MOM_SAVING_HALF then return Strings(SENT_HALF) end + if mode == MOM_SAVING_SOME then + return Strings(SENT_SOME, name, YEN, total) + end + return Strings(GOT_MONEY, name, YEN, total) +end + +return Prize diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 520961ed..cb33bbb6 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -113,6 +113,8 @@ local function slimAudio(data) bankOrder = audio.bankOrder, waveBanks = audio.waveBanks, noiseHeaders = audio.noiseHeaders, + generation = audio.generation, + drumkits = audio.drumkits, } end @@ -435,13 +437,18 @@ end -- a mono Source is spatialized by OpenAL at the listener position and spreads -- over every output an interface has (#626). The siren itself is unchanged, -- both channels carry the same sample. +-- PlayDanger (audio/engine.asm:531) counts one frame per call and resets with +-- `cp 30 / jr c, .noreset`, so the cycle is frames 0..29 and the buffer holds +-- exactly two of them. DangerSoundHigh goes in on the `and a / jr z, .begin` +-- frame 0 and DangerSoundLow on the `cp 16 / jr z, .halfway` frame 16, so the +-- high tone owns 0..15 and the low tone 16..29. function ChipAudio.newLowHealthAlarm() - local samples = math.floor(SAMPLE_RATE * 62 / 60) + local samples = math.floor(SAMPLE_RATE * 60 / 60) local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2) local phase = 0 for index = 0, samples - 1 do - local frame = math.floor(index * 60 / SAMPLE_RATE) % 31 - local register = frame < 11 and 0x750 or 0x6EE + local frame = math.floor(index * 60 / SAMPLE_RATE) % 30 + local register = frame < 16 and 0x750 or 0x6EE local frequency = 131072 / (2048 - register) phase = (phase + frequency / SAMPLE_RATE) % 1 local value = (phase < 0.5 and 1 or -1) * 0.25 diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 01f4f376..1b4737b3 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -98,6 +98,15 @@ local PITCHES = { 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, } +-- Gen 2 FrequencyTable (audio/notes.asm): index 0 = rest, then C_..B_ twice +-- so transpose can walk into the next octave without an octave command. +local GEN2_FREQUENCY = { + 0x0000, + 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, + 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, + 0xFC16, 0xFC4E, 0xFC83, 0xFCB5, 0xFCE5, 0xFD11, + 0xFD3B, 0xFD63, 0xFD89, 0xFDAC, 0xFDCD, 0xFDED, +} -- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index local WAVE_PATTERN_TABLES = { [0] = {0, 0, 0, 0, 0, 0, 0, 1}, @@ -243,6 +252,9 @@ function Channel.new(engine, spec, options) options = options or {} local hardware = (spec.number - 1) % 4 + 1 local isSfxChannel = spec.number > 4 + -- Default LR tracks match pokegold MonoTracks / StereoTracks ($11/$22/…). + local trackBit = bit.lshift(1, hardware - 1) + local tracks = bit.bor(bit.lshift(trackBit, 4), trackBit) return setmetatable({ engine = engine, bank = options.bank, @@ -257,10 +269,18 @@ function Channel.new(engine, spec, options) frequencyOffset = options.frequencyOffset or 0, frameTicks = options.frameTicks or FRAME_TICKS, speed = 12, + noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type) + durationModifier = 0, -- Gen 2 fractional-frame carry volume = 12, fade = 0, duty = 2, octave = 4, + transposition = 0, -- Gen 2: hi=octaves, lo=pitches + pitchOffset = 0, -- Gen 2 pitch_offset (signed word add to freq) + noiseKit = 0, + noiseSampling = false, -- Gen 2 toggle_noise + condition = 0, -- Gen 2 set_condition / sound_jump_if + tracks = tracks, -- Gen 2 CHANNEL_TRACKS (NR51 bits for this channel) waveInstrument = 0, waveLevel = 1, perfectPitch = false, @@ -299,6 +319,24 @@ function Channel:frequency(note, octave) return bit.band(register + self.frequencyOffset, 0x7FF) end +-- pokegold GetFrequency: FrequencyTable[pitch+transpose] with asr while +-- CHANNEL_OCTAVE (+ transpose hi) < 7, then optional pitch_offset. +function Channel:frequencyGen2(note, octave) + local trans = self.transposition or 0 + local pitch = note + bit.band(trans, 0x0F) + local oct = (octave or self.octave) + bit.rshift(trans, 4) + local tableVal = GEN2_FREQUENCY[pitch + 1] or 0 + local signed = tableVal - 0x10000 + local shifts = 0 + while oct < 7 do + shifts = shifts + 1 + oct = oct + 1 + end + local register = bit.band(bit.arshift(signed, shifts), 0x7FF) + register = bit.band(register + (self.pitchOffset or 0), 0x7FF) + return bit.band(register + self.frequencyOffset, 0x7FF) +end + function Channel:durationTicks(length) local tempo = self.sfx and self.frameTicks or self.engine.tempo local speed = self.sfx and (self.executeMusic and self.speed or 1) @@ -306,6 +344,33 @@ function Channel:durationTicks(length) return length * speed * tempo end +-- Gen 2 SetNoteDuration (audio/engine.asm). Two eight-bit multiplies, and +-- BOTH of them throw the overflow away -- which is the whole character of the +-- routine and the reason it cannot be written as one product: +-- +-- low = LOW((length + 1) * NoteLength) `ld a, l` after .Multiply +-- product = tempo * low + DurationModifier 16-bit, wraps +-- frames = HIGH(product) `ld [hl], d`, one byte +-- modifier= LOW(product) carries into the next note +-- +-- Keeping the full product instead is what made a cry run for seconds: a cry +-- sets CHANNEL_TEMPO to its length word (up to 576), so tempo * low routinely +-- runs past 16 bits and the truncation is load bearing rather than incidental. +-- +-- NoteLength defaults to 1 and tempo to $100 -- LoadChannel's own defaults -- +-- so a channel that never issues note_type or tempo still times correctly. +-- After toggle_sfx (executeMusic), fanfares like Sfx_CaughtMon use the +-- channel's tempo command, not the SFX frameTicks seed. +function Channel:durationTicksGen2(length) + local tempo = (self.sfx and not self.executeMusic) + and self.frameTicks or self.engine.tempo + local low = bit.band((length + 1) * (self.noteLength or 1), 0xFF) + local product = bit.band(tempo * low + (self.durationModifier or 0), 0xFFFF) + self.durationModifier = bit.band(product, 0xFF) + local frames = math.floor(product / 256) + return frames * FRAME_TICKS +end + function Channel:timedEvent(event, ticks) local first = snapTicks(self.timeTicks) self.timeTicks = self.timeTicks + ticks @@ -318,6 +383,11 @@ end function Channel:pan() local mask = bit.lshift(1, self.hardware - 1) + if self.engine.generation == 2 then + local tracks = self.tracks or 0xFF + return bit.band(bit.rshift(tracks, 4), mask) ~= 0, + bit.band(tracks, mask) ~= 0 + end return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0, bit.band(self.engine.pan, mask) ~= 0 end @@ -365,9 +435,15 @@ end function Channel:drumEvent(ticks, instrument) local panLeft, panRight = self:pan() + local drum + if self.engine.generation == 2 then + drum = self.engine:drumInstrumentGen2(self.noiseKit or 0, instrument) + else + drum = self.engine:noiseInstrument(instrument) + end return self:timedEvent({ noise = true, - drum = self.engine:noiseInstrument(instrument), + drum = drum, panLeft = panLeft, panRight = panRight, }, ticks) @@ -378,6 +454,9 @@ function Channel:silenceEvent(ticks) end function Channel:nextEvent() + if self.engine.generation == 2 then + return self:nextEventGen2() + end if self.ended then return nil end for _ = 1, 100000 do local commandAddress = self.address @@ -526,6 +605,222 @@ function Channel:nextEvent() return nil end +-- Gen 2 music bytecode (pokegold macros/scripts/audio.asm, FIRST_MUSIC_CMD=$d0). +-- Notes share the Gen 1 packing; rest is pitch 0. Call/loop opcodes are +-- swapped vs Gen 1 ($fe call, $fd loop) and $fc is sound_jump. +function Channel:nextEventGen2() + if self.ended then return nil end + for _ = 1, 100000 do + local commandAddress = self.address + local command = self:byte() + + if command < 0xD0 and self.sfx and not self.executeMusic then + -- ParseSFXOrCry. On a channel carrying SOUND_SFX or SOUND_CRY a byte + -- under $d0 is not a packed note at all: it is a `square_note` / + -- `noise_note` row, and SetNoteDuration is handed the WHOLE byte rather + -- than its low nibble. What follows is the volume envelope and then + -- the raw frequency register -- two bytes on a tone channel, one on + -- noise, where it is the polynomial counter instead. + -- + -- Parsing these as music notes is what made every Gold cry and sound + -- effect wrong: the envelope byte was read as a second note and the + -- frequency low byte ($d8 for 1752, say) as a note_type command that + -- then ate the next two bytes. + local ticks = self:durationTicksGen2(command) + local packed = self:byte() + local volume = bit.rshift(packed, 4) + local fade = fadeValue(bit.band(packed, 0x0F)) + if self.noise then + local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) + return self:noiseEvent(ticks, volume, fade, parameter) + end + -- CHANNEL_PITCH_OFFSET is wCryPitch for a cry and the SFX pitch + -- modifier otherwise; both land in frequencyOffset. The add is 16-bit + -- on hardware and only 11 bits reach the register, so a negative pitch + -- stored as its unsigned word still comes out right. + local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) + return self:tone(ticks, register, volume, fade) + elseif command < 0xD0 then + local note = bit.rshift(command, 4) + local length = bit.band(command, 0x0F) + local ticks = self:durationTicksGen2(length) + if note == 0 then + return self:silenceEvent(ticks) + end + if self.noise and self.noiseSampling then + return self:drumEvent(ticks, note) + end + if self.noise then + return self:silenceEvent(ticks) + end + return self:tone(ticks, self:frequencyGen2(note)) + elseif command >= 0xD0 and command <= 0xD7 then + -- octave 8 → $d0 (stored 0); octave 1 → $d7 (stored 7) + self.octave = bit.band(command, 7) + elseif command == 0xD8 then -- note_type / drum_speed + self.noteLength = self:byte() + if not self.noise then + local packed = self:byte() + if self.wave then + self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] + self.waveInstrument = bit.band(packed, 0x0F) + else + self.volume = bit.rshift(packed, 4) + self.fade = fadeValue(bit.band(packed, 0x0F)) + end + end + elseif command == 0xD9 then -- transpose + self.transposition = self:byte() + elseif command == 0xDA then -- tempo (big-endian) + local high, low = self:byte(), self:byte() + if not self.engine.tempoLocked then + self.engine.tempo = high * 0x100 + low + end + self.durationModifier = 0 + elseif command == 0xDB then -- duty_cycle + self.duty = bit.band(self:byte(), 3) + elseif command == 0xDC then -- volume_envelope + local packed = self:byte() + if self.wave then + self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] + self.waveInstrument = bit.band(packed, 0x0F) + else + self.volume = bit.rshift(packed, 4) + self.fade = fadeValue(bit.band(packed, 0x0F)) + end + elseif command == 0xDD then -- pitch_sweep (SFX; keep for completeness) + local packed = self:byte() + self.sweep = { + pace = bit.band(bit.rshift(packed, 4), 7), + subtract = bit.band(packed, 8) ~= 0, + shift = bit.band(packed, 7), + } + elseif command == 0xDE then -- duty_cycle_pattern + local packed = self:byte() + self.duty = { + bit.band(bit.rshift(packed, 6), 3), + bit.band(bit.rshift(packed, 4), 3), + bit.band(bit.rshift(packed, 2), 3), + bit.band(packed, 3), + } + elseif command == 0xDF then -- toggle_sfx + self.executeMusic = not self.executeMusic + elseif command == 0xE0 then -- pitch_slide + local length, packed = self:byte(), self:byte() + local octave = bit.rshift(packed, 4) + self.pendingSlide = { + length = length, + target = self:frequencyGen2(bit.band(packed, 0x0F), octave), + } + elseif command == 0xE1 then -- vibrato + local delay, packed = self:byte(), self:byte() + local depth = bit.rshift(packed, 4) + if depth == 0 then + self.vibrato = nil + else + self.vibrato = { + delay = delay, + above = bit.rshift(depth, 1) + bit.band(depth, 1), + below = bit.rshift(depth, 1), + rate = bit.band(packed, 0x0F), + } + end + elseif command == 0xE2 then -- unknownmusic0xe2 + self:byte() + elseif command == 0xE3 then -- toggle_noise + if self.noiseSampling then + self.noiseSampling = false + else + self.noiseSampling = true + self.noiseKit = self:byte() + end + elseif command == 0xE4 then -- force_stereo_panning + local packed = self:byte() + local mask = bit.lshift(1, self.hardware - 1) + local default = bit.bor(bit.lshift(mask, 4), mask) + self.tracks = bit.band(packed, default) + elseif command == 0xE5 then -- volume (global master; ignored for mix) + self:byte() + elseif command == 0xE6 then -- pitch_offset (big-endian) + local high, low = self:byte(), self:byte() + local value = high * 0x100 + low + if value >= 0x8000 then value = value - 0x10000 end + self.pitchOffset = value + elseif command == 0xE7 or command == 0xE8 then -- unused + self:byte() + elseif command == 0xE9 then -- tempo_relative + local adj = self:byte() + if adj >= 0x80 then adj = adj - 0x100 end + self.engine.tempo = bit.band(self.engine.tempo + adj, 0xFFFF) + elseif command == 0xEA then -- restart_channel + self.address = self:word() + elseif command == 0xEB then -- new_song (unused in music streams) + self:word() + elseif command == 0xEC or command == 0xED then -- sfx priority on/off + -- no-op for the PCM renderer + elseif command == 0xEE then -- unknownmusic0xee + self:word() + elseif command == 0xEF then -- stereo_panning (honor always; options.stereo) + local packed = self:byte() + local mask = bit.lshift(1, self.hardware - 1) + local default = bit.bor(bit.lshift(mask, 4), mask) + self.tracks = bit.band(packed, default) + elseif command == 0xF0 then -- sfx_toggle_noise + if self.noiseSampling then + self.noiseSampling = false + else + self.noiseSampling = true + self.noiseKit = self:byte() + end + elseif command >= 0xF1 and command <= 0xF9 then + -- music0xf1-f9 / unused: no params + elseif command == 0xFA then -- set_condition + self.condition = self:byte() + elseif command == 0xFB then -- sound_jump_if + local want, target = self:byte(), self:word() + if self.condition == want then self.address = target end + elseif command == 0xFC then -- sound_jump + self.address = self:word() + elseif command == 0xFD then -- sound_loop (Gen 2; Gen 1 used $fe) + local count, target = self:byte(), self:word() + if count == 0 then + if self.allowLoops then + self.address = target + else + self.ended = true + return nil + end + else + local remaining = self.loopCounts[commandAddress] + if remaining == nil then remaining = count end + remaining = remaining - 1 + if remaining > 0 then + self.loopCounts[commandAddress] = remaining + self.address = target + else + self.loopCounts[commandAddress] = nil + end + end + elseif command == 0xFE then -- sound_call + self.callStack[#self.callStack + 1] = self.address + 2 + self.address = self:word() + elseif command == 0xFF then -- sound_ret + local returnAddress = table.remove(self.callStack) + if returnAddress then + self.address = returnAddress + else + self.ended = true + return nil + end + else + self.ended = true + return nil + end + end + self.ended = true + return nil +end + local function envelopeVolume(volume, fade, elapsed) if fade == 0 then return volume end local steps = math.floor(elapsed / (math.abs(fade) / 64)) @@ -778,6 +1073,41 @@ function Engine:noiseInstrument(number) return segments end +-- Gen 2 Drumkits → kit pointer → instrument noise_note script (ReadNoiseSample). +function Engine:drumInstrumentGen2(kit, pitch) + local key = kit * 256 + pitch + local cached = self.noiseInstruments[key] + if cached then return cached end + local segments = {} + local spec = self.drumkits + if spec and pitch and pitch > 0 then + local kitAddr = romWord(self.banks, spec.bank, spec.address + kit * 2) + local instrAddr = romWord(self.banks, spec.bank, kitAddr + pitch * 2) + local address = instrAddr + local ticks = 0 + for _ = 1, 64 do + local command = romByte(self.banks, spec.bank, address) + address = address + 1 + if command == 0xFF then break end + local packed = romByte(self.banks, spec.bank, address) + local parameter = romByte(self.banks, spec.bank, address + 1) + address = address + 2 + -- ReadNoiseSample: delay = (length & $f) + 1 frames + local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS + segments[#segments + 1] = { + startSample = snapTicks(ticks), + endSample = snapTicks(ticks + duration), + volume = bit.rshift(packed, 4), + fade = fadeValue(bit.band(packed, 0x0F)), + parameter = parameter, + } + ticks = ticks + duration + end + end + self.noiseInstruments[key] = segments + return segments +end + local function readWaves(banks, audio, engineNumber) local spec = audio.waveBanks[tostring(engineNumber)] local waves = {} @@ -802,6 +1132,24 @@ local function readWaves(banks, audio, engineNumber) return waves end +-- Gen 2 WaveSamples: 10 patterns × 16 bytes (instruments 0-9). +local function readWavesGen2(banks, audio) + local spec = audio.waveBanks and audio.waveBanks["1"] + if not spec then return {} end + local waves = {} + for wave = 0, 9 do + local values = {} + for byteIndex = 0, 15 do + local packed = romByte( + banks, spec.bank, spec.address + wave * 16 + byteIndex) + values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8 + values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8 + end + waves[#waves + 1] = values + end + return waves +end + -- def-local waves are authored either as raw 0-15 nibbles (the ROM's own -- units) or as the -1..1 samples readWaves produces; the synth wants the -- latter (LuaGB: (nibble - 8) / 8) @@ -828,10 +1176,18 @@ function Engine.new(data, header, options) -- may supply its own waves/drums, falling back to a ROM engine's tables local chip = header.chip local banks = engineBanks(data, chip) - local engineNumber = chip and (chip.engine or 1) or header.engine + local generation = header.generation or audio.generation or 1 + local engineNumber = chip and (chip.engine or 1) or header.engine or 1 local waves if chip and chip.waves then waves = normalizeWaves(chip.waves) + elseif generation == 2 then + if chip then + local ok, romWaves = pcall(readWavesGen2, banks, audio) + waves = ok and romWaves or {} + else + waves = readWavesGen2(banks, audio) + end elseif chip then local ok, romWaves = pcall(readWaves, banks, audio, engineNumber) waves = ok and romWaves or {} @@ -840,11 +1196,13 @@ function Engine.new(data, header, options) end local engine = setmetatable({ banks = banks, + generation = generation, tempo = 0x100, pan = 0xFF, waves = waves, noiseHeaders = audio.noiseHeaders and audio.noiseHeaders[tostring(engineNumber)] or {}, + drumkits = audio.drumkits, customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, @@ -864,7 +1222,13 @@ function Engine.new(data, header, options) if hardware == 4 then frameTicks = FRAME_TICKS elseif options.cryLength then - frameTicks = 0x80 + options.cryLength + -- Gen 1: Audio_SetSfxTempo builds a 9-bit tempo out of $80 plus the + -- cry's length BYTE. Gen 2: _PlayCry writes wCryLength -- a full word, + -- and its own comment says "Tempo is effectively length" -- straight + -- into CHANNEL_TEMPO, with no $80 base. Adding one anyway stretched + -- every Gold cry by a third on top of the parse bug above. + frameTicks = generation == 2 and options.cryLength + or (0x80 + options.cryLength) end engine.channels[#engine.channels + 1] = Channel.new(engine, spec, { bank = chip and 0 or header.bank, diff --git a/src/core/Game2.lua b/src/core/Game2.lua new file mode 100644 index 00000000..ed5ca6c0 --- /dev/null +++ b/src/core/Game2.lua @@ -0,0 +1,1975 @@ +-- Gold's service owner: the Gen 2 peer of src/core/Game.lua. It owns the +-- data tables, input, state stack, world and save state for a Gold boot, and +-- everything under src/*/gen2 reaches shared services through here. Gen 1 +-- Game:load cannot consume a Gen 2 cache -- different generated tables, save +-- shape and screen registry -- so main.lua's bootGame picks this owner when +-- GameVersion.isGold(), and the two never branch into each other. +-- +-- Boot: copyright → GameFreak Presents → GS intro stub → title +-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink) +-- → name pick → New Bark Town via src/world/gen2/World.lua. +-- +-- IMPORTANT: do not use a catch-all __index that returns functions. main.lua +-- also reads optional fields like Game.capturePath every frame; a truthy +-- function there is treated as a path and crashes io.open. + +local AutoInput = require("src.core.gen2.AutoInput") +local Chrome = require("src.ui.gen2.Chrome") +local Clock = require("src.core.gen2.Clock") +local FixedStep = require("src.core.FixedStep") +local Font = require("src.render.Font") +local Input = require("src.core.Input") +local Music = require("src.core.Music") +local Save = require("src.core.gen2.Save") +local StateStack = require("src.core.StateStack") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +-- The mobile on-screen pad, shared with Gen 1 rather than reimplemented: the +-- same module, the same Xelu CC0 art under assets/touch/, the same +-- options.touchControls layout the launcher's editor writes, and the same +-- Input:overlayPressed source names. A player who lays the pad out in Red +-- finds it in the same place in Gold. +local TouchControls = require("src.core.TouchControls") +local World = require("src.world.gen2.World") + +-- The mod event/hook buses. Gold reaches them through Runtime like every +-- other engine file, so a call site here is the same call site Gen 1 has. +local ModRuntime = require("src.mods.Runtime") +-- Only for the mod-supplied save migrations and the mods-changed report, which +-- are keyed off save.meta and know nothing about a generation; Gold's own save +-- IO is src/core/gen2/Save.lua. +local SaveData = require("src.core.SaveData") + +-- Every Gold screen this file opens goes through a src/ui/Screens.lua id +-- rather than a direct require, the same contract the Gen 1 path has: the id +-- is what a mod registers a replacement under, and the boot cinema, the START +-- menu and its submenus are exactly the screens a reskin wants. Screens.push +-- resolves the registry, falls back to src/ui/gen2/<name>.lua when nothing is +-- registered, degrades a broken mod screen back to that builtin, and lands the +-- instance on self.stack -- so these call sites keep the push semantics they +-- had when they required the module by hand. +local Screens = require("src.ui.Screens") + +local Game2 = {} +Game2.__index = Game2 + +local function noop() end + +for _, name in ipairs({ + "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", + "joystickadded", +}) do + Game2[name] = noop +end + +-- Not a noop, because the overlay has to come back on its own: a player who +-- unplugs the only controller would otherwise have to tap a blind screen to +-- get the pad back (src/core/Game.lua:869 does the same). +function Game2:joystickremoved() + TouchControls:joystickremoved() +end + +-- THE FRAME AND INPUT SEAMS. +-- +-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad +-- (the FixedStep callback in Game2:load), so none of it goes through +-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six +-- hooks below never used to fire here; it is not a reason they should not. A +-- hook is a contract about a MOMENT in the frame, and Gold has every one of +-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1 +-- PAYLOAD, at the Gen 1 point in the order: +-- +-- input.step before the pad is read (src/core/Game.lua:191) +-- input.pointer uncaptured pointer events (src/core/Game.lua:887) +-- render.zones the palette pass, pre-blit (src/core/Game.lua:505) +-- render.compose the whole-window composite (Renderer.lua:759) +-- render.letterbox the void around the 160x144 blit (Renderer.lua:840) +-- render.hud screen-space UI over the frame (src/core/Game.lua:521) +-- +-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the +-- world pass and the UI into ONE canvas, not two -- the call site says so and +-- fills both keys with what it does have, rather than inventing a second name. + +-- vanilla for input.pointer: nobody consumed the event (src/core/Game.lua:882) +local function pointerUnclaimed() return false end + +-- render.zones' identity default: unhooked, the zone list reaches the present +-- pass exactly as the frame computed it (src/core/Game.lua:278) +local function sameZones(_, zones) return zones end + +-- Gold runs the engine's own src/core/StateStack.lua, not a private stack. +-- It already draws bottom-up from the topmost opaque state, which is the +-- behavior the boot cinema needs (Oak's pic stays under a TextBox), and going +-- through it is what gives Gold screen.pushed / screen.popped and the +-- screen.render_visible hook for free -- the same three a mod gets in Gen 1. +-- Push semantics are identical; the exit callback is named `exit` there and +-- no Gold screen defines one. +local function makeStack() + StateStack:init() + return StateStack +end + +local function loadGenerated(path) + local chunk = love.filesystem.load(path) + if not chunk then return nil end + local ok, data = pcall(chunk) + if ok then return data end + return nil +end + +-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first +-- line is `farcall InitClock`: wStartHour / wStartMinute are anchored before +-- InitializeWorld runs, on every new game there is. A run that never reaches +-- that screen -- a driver with the boot cinema skipped -- would leave the base +-- unset, and Clock reads an unanchored save straight off the host clock, so the +-- same new game is MORN on one run and NITE on the next (which mon a patch of +-- grass rolls follows from that). Anchor with InitClock's own 10 AM default so +-- every run mode starts on the same clock the cinema's default would have set. +-- +-- POKEPORT_GOLD_HOUR anchors here as well as pinning World:hour, so the +-- Pokegear card and the main menu box agree with the light outside; the day +-- stays wStartDay 0 (the host weekday), because InitDayOfWeek is Mom's wheel +-- and not part of New Game. +local function anchorNewGameClock(save) + if Clock.isSet(save) then return false end + local forced = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "") + return Clock.setTime(save, forced or Clock.DEFAULT_HOUR, + Clock.DEFAULT_MINUTE) +end +Game2.anchorNewGameClock = anchorNewGameClock + +function Game2.new() + local self = setmetatable({ + speedOverride = 1, + capturePath = nil, + world = nil, + status = nil, + phase = "boot", -- boot | play | error + input = Input, + -- The automated joypad stream (home/joypad.asm). Owned here rather than + -- by the World so an armed stream survives the map reload a script can do + -- while it is running, and so the boot cinema shares one ring with play. + autoInput = AutoInput.new(), + stack = makeStack(), + -- A real save arrives from CONTINUE or Save.newGame; this skeleton only + -- has to survive the boot cinema, which reads player.name. + save = Save.newGame(), + -- No `tokens` here, deliberately. It is the tokens registry's Data + -- target, and src/mods/Builtins.lua seeds that registry from + -- TextBox.registerInto on both generations -- so a table sitting here + -- before the merge is a BASE the registry folds against, and every one of + -- those seed registrations then collides ("tokens already registered: + -- RIVAL") and takes the whole mod subsystem down with it. Gen 1 has no + -- Data.tokens before the merge either; the merge is what creates it. + -- TextBox.substitute falls back to TextBox.TOKENS while it is absent, + -- which covers the window before mods:load, and nothing draws text in it. + data = { audio = {}, pokemon = {} }, + titleData = nil, + oakSpeechData = nil, + fontData = nil, + -- Options live in options.lua under `gold`, not in the save file: they + -- survive New Game, and the launcher's gear edits the same block before + -- the game boots. The save keeps a reference so the OPTION screen and + -- anything holding a save still read one table. + options = Save.loadOptions(), + }, Game2) + self.save.options = self.options + anchorNewGameClock(self.save) + return self +end + +-- Persist the option block. Called from every place that changes it -- the +-- OPTION screen, the hotkey ladder, the pad's speed buttons -- rather than +-- from applyOptions, which also runs on boot and on CONTINUE where there is +-- nothing new to write. +function Game2:persistOptions() + pcall(Save.saveOptions, self.options) +end + +-- Point the loader's mod.save backing at this save's modData so per-mod state +-- persists with the slot. Same contract and same three call sites as Gen 1 +-- (src/core/Game.lua:990): seedBuckets is boot-only and keeps what entry +-- chunks wrote before any save existed, NEW GAME and CONTINUE replace the +-- backing outright. src/core/gen2/Save.lua serializes the whole table and +-- Save.normalize keeps keys it does not know, so modData round-trips. +function Game2:adoptSave(save, seedBuckets) + if not save then return end + save.modData = save.modData or {} + local loader = self.mods + if not loader then return end + if seedBuckets then + for id, bucket in pairs(loader.modSave or {}) do + if save.modData[id] == nil then save.modData[id] = bucket end + end + end + loader.modSave = save.modData +end + +function Game2:startWorld() + if self.world and self.world.map then + self.phase = "play" + return true + end + self.world = World.new(self) + if not self.world:load() then + self.status = self.world.status + self.phase = "error" + return false + end + self.phase = "play" + return true +end + +function Game2:showOakSpeech() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2OakSpeech", { + data = self.oakSpeechData or {}, + font = self.fontData, + onDone = function() + self.stack:clear() + self:startWorld() + end, + }) +end + +-- NEW GAME: a fresh save, then the Oak speech (which collects the name), then +-- SPAWN_HOME. engine/menus/intro_menu.asm NewGame is this same order. +function Game2:newGame() + self.save = Save.newGame({ playerName = self.save.player.name }) + self.save.options = self.options + -- InitClock re-anchors this the moment the player answers Oak; the default + -- only has to hold for a run that skips the screen. + anchorNewGameClock(self.save) + -- Where Gen 1 emits it on NEW GAME (src/core/Game.lua:158): the skeleton is + -- finished and adopted, nothing has been pushed yet. save.new_game already + -- fired inside Save.newGame, so a mod that reshaped the skeleton sees its + -- own work in this payload. Same name, same `save` key as Gen 1. + -- No bucket carry-over, for the reason Gen 1 gives (src/core/Game.lua:155): + -- state from an abandoned session must not leak into a fresh slot. + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) + self:showOakSpeech() +end + +-- CONTINUE: adopt the loaded save and drop straight into the world at the +-- position it recorded (World:load reads save.position). +function Game2:continueGame(save) + if not save then + self:newGame() + return + end + -- Gen 1 raises this from Game:restoreSave (src/core/Game.lua:1079) at the + -- same point: the file is off disk and migrated but not yet adopted, so a + -- mod still has a window to repair its own data in `raw`. Gold's read + -- happens in the caller (the intro menu's CONTINUE row, the F2 hotkey), the + -- way Gen 1's happens in SaveData.load before restoreSave is reached. + if ModRuntime.wants("save.loading") then + ModRuntime.emit("save.loading", { raw = save }) + end + -- Mod chains replay before the world stands, where Gen 1 runs them + -- (src/core/Game.lua:1076): a mod repairs its own data before anything reads + -- it. Generation-blind -- these are mod-supplied functions keyed off + -- save.meta -- so there is no Gen 2 variant to write. + local activeMods = self.modStatus and self.modStatus.loaded + SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods) + local modsDiff = SaveData.modsDiff(save, activeMods) + self.save = save + self:adoptSave(save) + -- options.lua wins over anything a save file carries: options are a display + -- preference that survives New Game and is edited from the launcher, so a + -- save written before they moved out must not drag old values back in. + self.save.options = self.options + self:applyOptions() + self.stack:clear() + self.world = nil + 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 + end + if ModRuntime.wants("save.loaded") then + ModRuntime.emit("save.loaded", + { save = save, meta = save.meta, modsDiff = modsDiff }) + end +end + +function Game2:showMainMenu() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2MainMenu", { + onNewGame = function() self:newGame() end, + onContinue = function(save) self:continueGame(save) end, + onOption = function() self:showOptions(function() self:showMainMenu() end) end, + }) +end + +-- QUIT from the START menu: back to the title like a power-cycle, with +-- everything since the last save discarded. Same contract as Game:returnToTitle +-- in the Gen 1 path, so the two generations' QUIT rows behave identically. +function Game2:returnToTitle() + Music.stop() + self.stack:clear() + self.world = nil + self:showTitle() +end + +-- Reset (home/init.asm:1-14) falls into Init -> GameInit -> IntroSequence +-- (engine/menus/intro_menu.asm:1140-1143, :848-849): copyright splash, not title. +function Game2:softReset() + Music.stop() + self.stack:clear() + self.world = nil + self:showCopyright() +end + +function Game2:showOptions(onDone) + Screens.push(self, "Gen2OptionsMenu", { + options = self.options, + onDone = function(options) + self.options = options + if self.save then self.save.options = options end + self:applyOptions() + self:persistOptions() + if onDone then onDone() end + end, + }) +end + +function Game2:showTitle() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2TitleState", { + title = self.titleData or {}, + onContinue = function() + self:showMainMenu() + end, + }) +end + +function Game2:showIntro() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2GoldSilverIntro", { + onDone = function() + self:showTitle() + end, + }) +end + +function Game2:showGameFreak() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2GameFreakPresents", { + title = self.titleData or {}, + oakSpeech = self.oakSpeechData or {}, + onDone = function() + self:showIntro() + end, + }) +end + +function Game2:showCopyright() + self.stack:clear() + self.phase = "boot" + Screens.push(self, "Gen2CopyrightSplash", { + title = self.titleData or {}, + onDone = function() + self:showGameFreak() + end, + }) +end + +-- START in the overworld. The submenus each push themselves and pop back to +-- the start menu, matching .MenuReturns (most entries reopen it; SAVE and EXIT +-- close it). +function Game2:openStartMenu() + Screens.push(self, "Gen2StartMenu", { + save = self.save, + onClose = function() self.stack:pop() end, + onChoose = function(id) self:openStartMenuItem(id) end, + }) +end + +function Game2:openStartMenuItem(id) + local function back() self.stack:pop() end + if id == "pokedex" then + Screens.push(self, "Gen2PokedexMenu", { onClose = back }) + elseif id == "pokemon" then + -- The field list is the one flavour that opens PokemonActionSubmenu on A + -- (engine/pokemon/mon_menu.asm) rather than answering to a caller. + Screens.push(self, "Gen2PartyMenu", { + prompt = "choose", submenu = true, onCancel = back, + }) + elseif id == "pack" then + Screens.push(self, "Gen2PackMenu", { + onClose = back, + onChoose = function(itemId) self:useFieldItem(itemId) end, + }) + elseif id == "pokegear" then + Screens.push(self, "Gen2Pokegear", { + onClose = back, + currentLandmark = self:currentLandmark(), + onCall = function(call) return self:runPokegearCall(call) end, + }) + elseif id == "status" then + Screens.push(self, "Gen2TrainerCard", { onClose = back }) + elseif id == "save" then + Screens.push(self, "Gen2SaveMenu", { + save = self:snapshotSave(), + -- The screen's default writer is Save.save; route it through writeSave + -- so the save.write veto and the save.writing event fire at the moment + -- the cart writes (between the two SAVING messages) rather than when + -- the menu opened. It re-snapshots, which costs nothing and cannot go + -- stale if a script moved the player while the box was up. + writer = function() return self:writeSave() end, + onDone = function() + self.stack:pop() -- the save screen + self.stack:pop() -- and the start menu, like .Exit does + end, + }) + elseif id == "option" then + self:showOptions(back) + elseif id == "mods" then + Screens.push(self, "ManagerState") + end +end + +-- MakePhoneCallFromPokegear's .DoPhoneCall (engine/phone/phone.asm): the +-- contact's SCRIPT1 runs while the Pokegear keeps the screen -- on the cart +-- through ExecuteCallbackScript, here through the overworld VM, whose text +-- pages are TextBox states pushed OVER the card, exactly the stack they ride +-- over the overworld. Only a connected call ("call" without the wrong-number +-- fallback) has a script to run; the out-of-area / just-talk kinds keep the +-- card's own one-line answer. wCurCaller rides vm.curPhoneCaller so +-- GetCallerLocation's two specials know who picked up, and A/B afterwards is +-- PokegearPhone_FinishPhoneCall's hang-up, unchanged. +function Game2:runPokegearCall(call) + if not (call and call.kind == "call") or call.wrongNumber then return false end + local world = self.world + local vm = world and world.vm + local key = call.scriptKey + if not (vm and key and vm.scripts[key]) then return false end + if vm:running() then return false end + vm.curPhoneCaller = call.contact + local ok = vm:start(key) + if ok then call.ranScript = true end + return ok +end + +-- home/hm_moves.asm IsHMMove's .HMMoves. +local HM_MOVES = { + CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, + WATERFALL = true, WHIRLPOOL = true, +} + +-- LearnMove (engine/pokemon/learn.asm): a free slot learns outright, a full +-- set runs ForgetMove's ask / pick / "Stop learning" loop. onDone(true) is +-- the routine's own `ld b, 1`. +function Game2:learnMoveOn(mon, moveId, onDone) + local Mon = require("src.battle.gen2.Mon") + local moveDef = (self.data.moves or {})[moveId] + local moveName = (moveDef and moveDef.name) or moveId + local name = mon.nickname or mon.name or mon.species or "?" + local ok, reason, entry = Mon.learnMove(mon, moveId, self.data) + local function finish(learned) + if onDone then onDone(learned) end + end + if ok then + return self:say(("%s learned\n%s!"):format(name, moveName), + function() finish(true) end) + end + if reason ~= "full" then return finish(false) end + local askForget, pickMove, askStop + -- DidNotLearnMoveText, then `ld b, 0` (learn.asm:110-113). + local function decline() + self:say(("%s\ndid not learn\v%s."):format(name, moveName), + function() finish(false) end) + end + -- ForgetMove's AskForgetMoveText + YesNoBox (learn.asm:123-127). + askForget = function() + self.stack:push(TextBox.new(self, + ("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." + .. "\fDelete an older\nmove to make room\vfor %s?") + :format(name, moveName, name, moveName), + nil, { choice = function(yes) + if yes then return pickMove() end + return askStop() + end })) + end + -- StopLearningMoveText, whose NO is `jp c, .loop` (learn.asm:104-108). + askStop = function() + self.stack:push(TextBox.new(self, + ("Stop learning\n%s?"):format(moveName), nil, + { choice = function(yes) + if yes then return decline() end + return askForget() + end })) + end + -- The four-slot list ForgetMove draws under MoveAskForgetText, which is the + -- Blackthorn deleter's own SetUpMoveList box (learn.asm:135-146). + local function pushList() + Screens.push(self, "Gen2MoveDeleter", { + mon = mon, + moves = self.data.moves, + onCancel = function() + self.stack:pop() -- the move list + self.stack:pop() -- the question it stood on + askStop() + end, + onChoose = function(slot) + local old = mon.moves[slot] + self.stack:pop() -- the move list + -- MoveCantForgetHMText, then `jr .loop` (learn.asm:183-197): the + -- question stays up and the list comes back over it. + if old and HM_MOVES[old.id] then + return self:say("HM moves can't be\nforgotten now.", pushList) + end + self.stack:pop() -- the question the list stood on + local oldDef = (self.data.moves or {})[old and old.id] + local oldName = (oldDef and oldDef.name) or (old and old.id) or "?" + mon.moves[slot] = entry + -- The slot is written here rather than through Mon.learnMove, so + -- pokemon.move_learned is raised here too. + ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) + self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") + :format(name, oldName, name, moveName), + function() finish(true) end) + end, + }) + end + -- MoveAskForgetText, a `done` text: the box stays while the list stands on + -- it (learn.asm:136-137). + pickMove = function() + self.stack:push(TextBox.new(self, "Which move should\nbe forgotten?", nil, + { stay = { onShown = pushList } })) + end + askForget() +end + +-- Using an item from the PACK outside a battle: pack.asm UseItem's .Party +-- arm, for the two families it covers. A TM/HM opens the party to teach +-- (ItemAttributes says its ITEMMENU_PARTY opens the list, `teaches` names the +-- move, BASE_TMHM on the species says whether it may learn it -- TeachTMHM); +-- everything else with a ported party effect (src/core/gen2/ItemEffects.lua: +-- heals, status cures, revives, RARE CANDY, the PP family) opens the same +-- list under UseOnWhichPKMNString and runs its item_effects.asm routine on +-- the pick. The world's own .Current / .Field items never reach here -- +-- PackMenu hands them to World:useFieldItem first. +function Game2:useFieldItem(itemId) + local items = self.data.items or {} + local def = items[itemId] + local moveId = def and def.teaches + if not moveId then return self:usePartyItem(itemId) end + local moves = self.data.moves or {} + local moveDef = moves[moveId] + local moveName = (moveDef and moveDef.name) or moveId + Screens.push(self, "Gen2PartyMenu", { + prompt = "choose", + onCancel = function() self.stack:pop() end, + onChoose = function(index, mon) + self.stack:pop() + local species = self.data.pokemon and self.data.pokemon[mon.species] + local learnable = species and species.tmhm + local allowed = false + for _, id in ipairs(learnable or {}) do + if id == moveId then allowed = true end + end + if not allowed then + self:say(("%s can't learn %s!"):format( + mon.nickname or mon.species or "?", moveName)) + return + end + for _, move in ipairs(mon.moves or {}) do + if move.id == moveId then + self:say(("%s already knows %s!"):format( + mon.nickname or mon.species or "?", moveName)) + return + end + end + -- TeachTMHM's `predef LearnMove`, then `ld a, b / and a / jr z, .nope`: + -- a refusal spends nothing (engine/items/tmhm.asm:142-153). + self:learnMoveOn(mon, moveId, function(learned) + if not learned then return end + -- IsHM `ret c`: an HM is never consumed and pays no happiness. + if tostring(itemId):sub(1, 3) == "HM_" then return end + require("src.core.gen2.Happiness").change(mon, "LEARNMOVE") + self:consumeItem(itemId) + end) + end, + }) +end + +-- UseDisposableItem (engine/items/item_effects.asm): one copy leaves the +-- pack, and only on a success -- every refusal above it returns first. +function Game2:consumeItem(itemId) + if not (self.save and self.save.inventory) then return end + local left = (self.save.inventory[itemId] or 1) - 1 + self.save.inventory[itemId] = left > 0 and left or nil +end + +-- RareCandyEffect's tail (engine/items/item_effects.asm): LearnLevelMoves at +-- the new level, then EvolvePokemon. LearnLevelMoves' .learn arm is `predef +-- LearnMove` (engine/pokemon/evolve.asm), so a full set gets ForgetMove's ask +-- rather than a refusal; the evolution rides the very screen the battle's own +-- EvolveAfterBattle pass pushes, with wForceEvolution clear -- +-- Evolution.checkMon's ordinary condition walk -- so an Everstone or an +-- unmet happiness row still refuses. +function Game2:afterRareCandy(mon, result, onDone) + local data = self.data + local queue = {} + for _, moveId in ipairs(result.learned or {}) do + queue[#queue + 1] = moveId + end + local function evolve() + local Evolution = require("src.core.gen2.Evolution") + local Palettes = require("src.world.gen2.Palettes") + local entry = Evolution.checkMon(data, mon, { + timeOfDay = Palettes.clockDaytime(), + }) + if not entry then + if onDone then onDone() end + return + end + local party = (self.save and self.save.party) or {} + local index + for i, member in ipairs(party) do + if member == mon then index = i end + end + Screens.push(self, "Gen2EvolutionAnim", { + mon = mon, + entry = entry, + index = index, + party = party, + save = self.save, + onDone = function() + self.stack:pop() + if onDone then onDone() end + end, + }) + end + local nextMove + nextMove = function() + local moveId = table.remove(queue, 1) + if not moveId then return evolve() end + self:learnMoveOn(mon, moveId, function() nextMove() end) + end + nextMove() +end + +-- The non-TM half of UseItem's .Party: ChooseMonToUseItemOn over the party +-- ("Use on which <PK><MN>?"), then the item family's own item_effects.asm +-- routine on the pick. The PP family threads one more screen first -- +-- MoveSelectionScreen's "Restore the PP of which move?" pick, which the port +-- serves with the move-list screen the Blackthorn deleter already draws +-- (both are SetUpMoveList on the cart). Backing out of either list is the +-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent. +function Game2:usePartyItem(itemId) + local ItemEffects = require("src.core.gen2.ItemEffects") + local action = ItemEffects.partyAction(itemId) + if not action then return end + local party = (self.save and self.save.party) or {} + if #party == 0 then + -- UseItem's .NoPokemon arm (_YouDontHaveAMonText). + self:say(Strings("You don't have a\n#MON!")) + return + end + local function finish(result, mon) + if not result.used then + self:say(result.text) + return + end + self:consumeItem(itemId) + if action == "candy" then + self:say(result.text, function() self:afterRareCandy(mon, result) end) + else + self:say(result.text) + end + end + Screens.push(self, "Gen2PartyMenu", { + prompt = "useItem", + onCancel = function() self.stack:pop() end, + onChoose = function(_, mon) + if action ~= "pp" then + self.stack:pop() + finish(ItemEffects.useOnMon(itemId, mon, self.data), mon) + return + end + -- RestorePPEffect: the ELIXER pair needs no move pick; an EGG refuses + -- before the move list ever opens (UseItem_SelectMon's `cp EGG`). + local row = ItemEffects.RESTORE_PP[itemId] or {} + if row.each or mon.isEgg then + self.stack:pop() + finish(ItemEffects.usePpItem(itemId, mon), mon) + return + end + Screens.push(self, "Gen2MoveDeleter", { + mon = mon, + moves = self.data.moves, + onCancel = function() self.stack:pop() end, + onChoose = function(slot) + self.stack:pop() -- the move list + self.stack:pop() -- the party list + finish(ItemEffects.usePpItem(itemId, mon, slot), mon) + end, + }) + end, + }) +end + +-- SelectMenu (engine/overworld/select_menu.asm): the SELECT press in the +-- overworld. World:useSelectItem runs CheckRegisteredItem's re-validation +-- and UseRegisteredItem's dispatch; everything past that is just which of +-- the cart's fixed messages to print, the same way the START handler above +-- is the whole of .MenuReturns for its own button. +function Game2:useSelectItem() + local outcome, itemId = self.world:useSelectItem() + if outcome == "not_registered" then + -- MayRegisterItemText. + self:say(Strings( + "An item in your\nPACK may be\fregistered for use\non SELECT Button.")) + elseif outcome == "cant_use" or outcome == "nowhere" then + -- ItemsOakWarningText, the same "not the time" line CheckItemMenu's + -- .CantUse arm and a busy world both land on. + self:say(Strings("OAK: {PLAYER}!\nThis isn't the\vtime to use that!")) + elseif outcome == "repel_active" then + self:say(Strings("The REPEL used\nearlier is still\vin effect.")) + elseif outcome == "repel_used" then + local items = self.data.items or {} + local name = (items[itemId] and items[itemId].name) or itemId + self:say(Strings("{PLAYER} used the\n%s.", name)) + elseif outcome == "trophy_sent" then + self:say(Strings( + "There was a trophy\ninside!\fThe trophy was\nsent home.")) + end + -- Anything else (a fishing bite, the ITEMFINDER's queued script) already + -- drives its own presentation off World:step -- nothing left to print here. +end + +-- A message over whatever is on screen. TextBox pops ITSELF on the final A +-- press before running onDone -- the same contract every other push site in +-- the tree leans on -- so onDone here is only the caller's continuation. An +-- onDone that popped again ate the state UNDER the box: dismissing a message +-- over the PACK closed the PACK with it, and over an empty overworld stack it +-- was a silent extra pop. +function Game2:say(text, onDone) + local TextBox = require("src.render.TextBox") + self.stack:push(TextBox.new(self, text, onDone)) +end + +-- The landmark the player is standing in, for the Pokegear map's marker. +-- Through src/core/gen2/Nests.lua rather than off landmarks.order, so the +-- `landmarks` registry's own records answer too: order is a flat list the +-- extractor writes and a registered landmark is not in it, while every record +-- carries the map header's own `index` byte. +function Game2:currentLandmark() + local map = self.world and self.world.map and self.world.map.def + return require("src.core.gen2.Nests") + .landmarkId(self.data, map and map.landmark) +end + +-- Fold the live world state into the save before writing it, so a reload comes +-- back on the same tile facing the same way. +function Game2:snapshotSave() + local world = self.world + if world and world.map and world.player then + self.save.position = { + map = world.map.id, + x = world.player.cellX, + y = world.player.cellY, + facing = world.player.facing, + } + self.save.events = world.events and world.events:serialize() + or self.save.events + self.save.mapScenes = world.mapScenes or self.save.mapScenes + -- wPlayerState, out of the same sPlayerData block the flags and the scene + -- ids come from: save on the BICYCLE and the reload has to come back on + -- the BICYCLE, save aboard a Lapras and it has to come back afloat. + -- Without this line a save walks the player off the bike and, worse, off + -- the water -- World:loadPlayerData reads it back. + self.save.playerState = world.playerState or self.save.playerState + -- The script VM's sparse WRAM store. These are counters no other field + -- covers -- the Goldenrod underground switch positions and the MooMoo + -- berries -- so leaving them out of the snapshot is the same as never + -- having flicked a switch. + self.save.scriptMem = world.vm and world.vm:serializeMem() + or self.save.scriptMem + -- wVariableSprites. WRAM on the cart and therefore never saved there, + -- which the cart survives because it never rebuilds the world mid-session. + -- This port does, on every CONTINUE -- and an unfilled slot is an object + -- that does not spawn -- so the chosen sprites ride along with the save. + -- Route 36 is why it matters both ways: the slot holds the disguised + -- Sudowoodo before the fight and the TWIN who replaces it after. + self.save.variableSprites = world.variableSprites + or self.save.variableSprites + -- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber (home/map.asm + -- CopyWarpData), which a -1 warp destination resolves through. Saved + -- WRAM on the cart, so a save made on POKECENTER_2F must still know which + -- centre's stairs lead back down -- World:loadPlayerData reads it back. + self.save.backupWarp = world.backupWarp or self.save.backupWarp + end + self.save.options = self.options + return self.save +end + +-- Snapshot the world and persist it. The mirror of Game:writeSave +-- (src/core/Game.lua:1005): same veto hook, same event, same order, so a mod +-- written against the Gen 1 save lifecycle behaves identically on Gold. +-- +-- Every write the player can ask for goes through here -- the SAVE row of the +-- start menu (via the writer handed to Gen2SaveMenu) and the F1 hotkey -- so +-- there is one place the veto has to hold. +function Game2:writeSave() + -- Tool sessions can be deliberately ephemeral. Give them one narrow veto + -- before snapshotSave folds the live world in or any progress bytes reach + -- disk. Returning false here is what SaveMenu reads back as "not saved". + if ModRuntime.call("save.write", function() return true end, self) == false then + return false + end + local save = self:snapshotSave() + -- The snapshot is complete, so this payload carries exactly the table the + -- file gets; mods stash runtime state into their own keys now. `meta` is + -- the Gen 1 key, absent rather than renamed: a Gold save stamps no meta + -- block yet (see save.loaded in continueGame). + if ModRuntime.wants("save.writing") then + ModRuntime.emit("save.writing", { save = save, meta = save.meta }) + end + return Save.save(save) +end + +function Game2:load() + Input:init() + -- Before applyOptions, which is what pushes options.touchControls into it: + -- init() decides whether the platform wants the overlay at all and loads the + -- art, applyOptions then lays it out (src/core/Game.lua:59-60 does the pair + -- in the same order). + TouchControls:init() + self.touchControls = TouchControls + self:applyOptions() + self.titleData = loadGenerated("data/generated/title.lua") or {} + self.oakSpeechData = loadGenerated("data/generated/oak_speech.lua") or {} + -- data.font, not a private field: it is the `font` registry's Data target, + -- so a mod that overrides a glyph is merged in before Font.load reads it + -- below. self.fontData stays as the handle the boot screens pass around. + self.data.font = loadGenerated("data/generated/font.lua") + self.fontData = self.data.font + self.data.audio = loadGenerated("data/generated/audio.lua") or {} + self.data.pokemon = loadGenerated("data/generated/pokemon.lua") or {} + self.data.items = loadGenerated("data/generated/items.lua") or {} + self.data.moves = loadGenerated("data/generated/moves.lua") or {} + self.data.type_chart = loadGenerated("data/generated/type_chart.lua") or {} + -- The `held_items` registry's merge target: ItemAttributes' last two columns + -- as their own table, so a mod can give an item a held behaviour without + -- owning the whole item record. Built BEFORE mods:load so the registry + -- folds against the vanilla row (a mod's register collides, a patch stacks), + -- and snapshotted so the write-back below can tell what the merge actually + -- changed from what a mod reached through the shared `items` registry + -- instead. Both halves live in src/core/gen2/ItemEffects.lua. + local ItemEffects = require("src.core.gen2.ItemEffects") + self.data.gen2HeldItems = ItemEffects.heldItemsFrom(self.data.items) + local heldBefore = ItemEffects.heldSnapshot(self.data.gen2HeldItems) + -- Gen 2-only tables the menus read. Namespaced so nothing collides with the + -- Gen 1 keys of the same idea (data.palettes, data.icons). + self.data.gen2Palettes = loadGenerated("data/generated/palettes.lua") + self.data.gen2Icons = loadGenerated("data/generated/icons.lua") + self.data.gen2Pokedex = loadGenerated("data/generated/pokedex.lua") + self.data.gen2Landmarks = loadGenerated("data/generated/landmarks.lua") + self.data.gen2Sprites = loadGenerated("data/generated/sprites.lua") + self.data.gen2MenuGfx = loadGenerated("data/generated/menu_gfx.lua") + self.data.gen2Intro = loadGenerated("data/generated/intro.lua") + self.data.gen2Credits = loadGenerated("data/generated/credits.lua") + self.data.gen2Diploma = loadGenerated("data/generated/diploma.lua") + self.data.gen2Trade = loadGenerated("data/generated/trade.lua") + self.data.gen2Trainers = loadGenerated("data/generated/trainers.lua") + self.data.gen2Encounters = loadGenerated("data/generated/encounters.lua") + -- The battle-animation script pool and the ordered name lists its ids index + -- into (BattleAnimObjects rows, framesets, OAM sets, BG effects). Both are + -- read by src/battle/gen2/AnimRunner.lua. + self.data.gen2BattleAnims = loadGenerated("data/generated/battle_anims.lua") + self.data.gen2Constants = loadGenerated("data/generated/constants.lua") + -- The overworld's own tables. src/world/gen2/World.lua:load used to read + -- these straight off disk into World fields, which meant they never passed + -- through game.data and a registry had no Data path to merge into -- the one + -- cause behind most of the `false` rows in Schemas.GEN2. Loading them here + -- puts them in self.data ahead of the mods:load(self.data) call below, so a + -- merge lands before World ever holds the table; World takes each by + -- reference and never copies, so the table it walks IS the merged one. + self.data.gen2Maps = loadGenerated("data/generated/maps.lua") + self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua") + self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua") + self.data.gen2Marts = loadGenerated("data/generated/marts.lua") + self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua") + self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua") + self.data.gen2Text = loadGenerated("data/generated/text.lua") + -- data/generated/events.lua: the side tables a script command NAMES rather + -- than carries -- the phone book, the in-game trades, the elevator's floor + -- labels, the decoration descriptions. Keyed for World's own `eventTables` + -- field rather than "gen2Events" so it is never read as the mod event bus. + self.data.gen2EventTables = loadGenerated("data/generated/events.lua") + -- InitializeEventsScript's seed: the setevent list AND the nine + -- `variablesprite` assignments, both of which World:load applies. + self.data.gen2InitialEvents = + loadGenerated("data/generated/initial_events.lua") + -- Gold's trainer table under the second name the Gen 2 code already reads it + -- by (World:trainerParty, src/core/gen2/BugContest.lua and + -- src/world/gen2/Palettes.lua all say data.trainers). The SAME table as + -- data.gen2Trainers, never a copy, so a merge into either key is seen + -- through both; World:load used to set this from a second disk read, which + -- is what dropped a merge on the floor. + self.data.trainers = self.data.gen2Trainers + + -- Mods. Discovery and the manager are generation-agnostic -- they read + -- manifests and the enable/disable state, not game data -- so Gold gets the + -- same MODS row the Gen 1 start menu has. Only a mod whose manifest says + -- gen2compat is actually run here (Loader:_gateGeneration); the rest are + -- listed and skipped rather than half-applied. Registry targets route per + -- generation through Schemas.GEN2, so the ones with a Gen 2 home merge into + -- self.data and the ones without report instead of silently vanishing. The + -- whole thing is behind a pcall so a mod problem can never cost Gold its + -- boot. + local ok, loader = pcall(function() + local mods = require("src.mods.Loader").new() + -- the live service owner, before load: mod.world and mod.input resolve + -- through this, and without it the facade would bind to the Gen 1 + -- src/core/Game.lua singleton that a Gold boot never loads + mods.game = self + mods:load(self.data) + return mods + end) + if ok and loader then + self.mods = loader + self.modStatus = loader:status() + else + -- The pcall above keeps a mod problem from costing Gold its boot, which is + -- right; swallowing it without a word is not. A throw here takes out the + -- WHOLE subsystem -- no discovery, no manager, no registry merge -- and + -- with no line printed the only symptom is that mods quietly do nothing, + -- which is indistinguishable from having none installed. + require("src.core.Logger").error( + "mods failed to load, continuing without them: %s", tostring(loader)) + end + + -- The Gen 2-only content registries, collected after the merge and before + -- anything reads their tables. Each module holds the merged table by + -- reference (or folds it onto the rows it already keys by, for the two whose + -- callers have a byte and not a dataset), so this is where a registered + -- decoration, phone contact, apricorn or held item becomes the one the game + -- actually uses. `landmarks` and `radio_channels` need no call: their + -- consumers (src/core/gen2/Nests.lua, src/ui/gen2/MapRadio.lua) read + -- game.data at the point of use. + ItemEffects.applyHeldItems(self.data, heldBefore) + require("src.core.gen2.Phone").useRegistry(self.data) + require("src.core.gen2.Decorations").useRegistry(self.data) + require("src.core.gen2.Apricorns").useRegistry(self.data) + + -- Rendering pipelines: the engine half of the render_pipelines registry + -- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset + -- -- Gen 1 points it at the src/core/Data.lua singleton, which a Gold boot + -- never loads (src/core/Game.lua:47) -- and applyOptions restores the ladder + -- the player left in options.pipelines. Both after the merge, so a mod's + -- pipeline record is already in data.render_pipelines when either reads it. + local Pipelines = require("src.render.Pipelines") + Pipelines.install(self.data) + Pipelines.applyOptions(self.options) + -- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the + -- world half: its overworld draws straight to the window rather than into a + -- canvas the way src/world/OverworldController.lua:4827 hands one to + -- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet. + -- A restored level for a world-only pipeline is retired rather than left + -- switched on, because on Gold it would render nothing AND hold TILT off + -- (Pipelines.setLevel's tilt exclusion). The stored level in + -- options.pipelines is left untouched, so the mode comes back the day Gold + -- grows a world canvas; Tilt is re-applied from the option the exclusion just + -- cleared. + local retired = false + for _, entry in ipairs(Pipelines.list()) do + if entry.def.drawWorld and not entry.def.present + and Pipelines.level(entry.id) > 0 then + Pipelines.setLevel(entry.id, 0) + retired = true + end + end + if retired then + require("src.render.Tilt").applyOptions(self.options) + end + + -- After the merge, so a font override and a translation mod's catalog + -- (#501) are both in Data before the first screen draws a glyph. Gen 1 + -- calls these two here for the same reason (src/core/Game.lua:65,70). + if self.data.font then + pcall(Font.load, self.data) + end + Strings.load(self.data) + + -- The boot skeleton (Game2.new built it, before any bus existed) announced + -- here rather than at its construction, which is the same spot in the boot + -- order Gen 1 announces its own from: after the merge, before game.ready, + -- stack still empty (src/core/Game.lua:79). A driver that skips the cinema + -- plays on this save, so a mod that seeds through save.created has to be + -- given it exactly once, here. + -- seed=true keeps what entry chunks wrote through mod.save before any save + -- existed, the way Gen 1 seeds its boot skeleton (src/core/Game.lua:78). + self:adoptSave(self.save, true) + ModRuntime.emit("save.created", { save = self.save }) + + -- The handshake every behavior mod waits on: the one place a mod is handed + -- the live service owner (mod.input needs it, and it is what the docs tell + -- a mod to hold). Emitted where Gen 1 emits it -- every service up, the + -- stack still empty -- so a listener that pushes a state lands underneath + -- the boot cinema rather than being buried by it. + ModRuntime.emit("game.ready", { game = self }) + + -- Drivers that walk the overworld skip boot cinema so smoke stays stable. + -- POKEPORT_BOOT_CINEMA=1 opts back in, which is how the boot-chain driver + -- exercises copyright -> title -> intro menu -> Oak -> naming. + if os.getenv("POKEPORT_DRIVER") + and os.getenv("POKEPORT_BOOT_CINEMA") ~= "1" then + self:startWorld() + else + self:showCopyright() + end + + FixedStep:init(function(dt) + -- Tool mods (autoplay, accessibility drivers, input visualizers) act on the + -- same fixed-step boundary a physical controller does. Raised HERE, ahead + -- of both the AUTO_INPUT arm and Input:step, for the reason Gen 1 raises it + -- ahead of Input:step (src/core/Game.lua:188): a button chosen by a mod has + -- to be visible to THIS logic tick, not the next one, and the cart's own + -- canned stream must be able to overwrite it the way GetJoypad's arm + -- overwrites the mirrors. Payload is Gen 1's exactly: (game, fixed dt). + ModRuntime.call("input.step", noop, self, dt or 1 / 60) + -- GetJoypad's AUTO_INPUT arm runs ahead of everything that reads the pad, + -- and it overwrites the mirrors outright, so a stream frame has to land + -- before Input:step promotes this tick's edges -- otherwise the canned + -- press would be a tick late and the player's own keys would still be in + -- the queue alongside it. + self.autoInput:step(self.input) + self.input:step() + -- UpdateJoypad's soft reset (home/joypad.asm:99-102) is `and PAD_BUTTONS / + -- cp PAD_BUTTONS`, so the d-pad is masked off and the chord fires at once. + if self.input.isDown and self.input:isDown("a") and self.input:isDown("b") + and self.input:isDown("start") and self.input:isDown("select") then + Input:reset() + TouchControls:reset() + self:softReset() + return + end + -- Not the audio tick: _UpdateSound runs once per frame off VBlank + -- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock. + local top = self.stack:top() + if top and top.update then + top:update(1 / 60) + return + end + if self.phase ~= "play" or not self.world then return end + -- The play clock only runs in the overworld, the way wGameTimerPaused is + -- set while the intro menu is up. + Save.tickPlayTime(self.save) + -- START and SELECT are read only at the tail of OWPlayerInput, which + -- PlayerEvents never reaches while a script is running or the player is + -- mid-step (World:acceptsMenuInput transcribes the three gates). A press + -- that arrives under one of them is dropped, not queued -- and the frame + -- still runs, so world:step must not be skipped on the swallowed press. + if self.input:wasPressed("start") and self.world:acceptsMenuInput() then + self:openStartMenu() + return + end + if self.input:wasPressed("select") and self.world:acceptsMenuInput() then + self:useSelectItem() + return + end + self.world:pollInput(self.input) + if self.input:wasPressed("a") then + self.world:interact() + end + self.world:step() + end) +end + +function Game2:inFillBoot() + -- Entire pre-world cinema (copyright / title / Oak / name / nested NamingScreen) + -- draws in GB letterbox space. + return self.phase == "boot" and self.stack:top() ~= nil +end + +function Game2:update(dt) + -- _UpdateSound is a VBlank job, so it runs at 60Hz off real time whatever the + -- logic multiplier is (audio/engine.asm:84, home/vblank.asm:141-143). + local step = FixedStep.STEP + self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25) + while self.audioAccum >= step do + self.audioAccum = self.audioAccum - step + Music.update(self.data) + end + -- TILT eases toward its new angle in real time, not on the logic clock, so + -- fast-forward does not fling the camera over. + require("src.render.Tilt").update(dt) + -- Mod render pipelines tween on the same real-frame clock, for the same + -- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265): + -- they are presentational, so fast-forward must not speed them up. + require("src.render.Pipelines").update(dt) + if self.phase == "boot" then + FixedStep.maxAccum = 0.25 + FixedStep:update(dt) + return + end + if not self.world or not self.world.map then return end + -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: + -- audio runs off its own real-time accumulator, so music and sfx keep their + -- tempo at every multiplier. speedOverride is the driver/CLI hook and wins + -- over the saved option. + local speed = math.max(1, + tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) + or 1) + FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) + FixedStep:update(dt * speed) +end + +-- The screen-pixels-per-GB-pixel scale the post passes need so their grid and +-- shadow offsets stay window-size independent. Always the plain letterbox +-- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is +-- being shown on, and the panel does not resize when the player zooms the map +-- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason +-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to +-- one screen pixel a cell out at survey range. +function Game2:pixelScale(w, h) + return math.max(1, math.floor(math.min(w / 160, h / 144))) +end + +-- A window-sized canvas the whole frame is composed into, so the post passes +-- have something to read. Rebuilt on resize; nil (and a plain draw) when the +-- backend cannot give us one. +function Game2:presentCanvas(index, w, h) + self._canvases = self._canvases or {} + local canvas = self._canvases[index] + if canvas then + local cw, ch = canvas:getDimensions() + if cw ~= w or ch ~= h then canvas = nil end + end + if not canvas then + local ok, made = pcall(love.graphics.newCanvas, w, h) + if not ok or not made then return nil end + made:setFilter("nearest", "nearest") + self._canvases[index] = made + canvas = made + end + return canvas +end + +-- The letterbox this frame is being drawn in, in the terms Gen 1's +-- Renderer:endFrame reports it: the integer fit scale, the centred origin that +-- goes with it, the window in LOVE units and in framebuffer pixels, and the DPI +-- scale between the two. +-- +-- One difference from Gen 1 has to be named, because two payload fields carry +-- it. Gen 1 fits in FRAMEBUFFER pixels and divides back into units, so its +-- `scale` is framebuffer-pixels-per-GB-pixel; Gold fits in LOVE units +-- throughout (Chrome.fitScale takes love.graphics.getDimensions), so `scale` +-- here is units-per-GB-pixel. They are the same number on every 1x display, +-- which is where Gen 1 mods are written and verified, and on a HiDPI display +-- this is the one that actually describes Gold's picture -- `gameX + x * scale` +-- lands on GB pixel x either way. The rect fields (gameX/gameY/gameWidth/ +-- gameHeight, ox/oy/vpw/vph) are LOVE units in both generations. +function Game2:frameFit(w, h) + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + local dpi = 1 + if love.window and love.window.getDPIScale then + dpi = tonumber(love.window.getDPIScale()) or 1 + end + local pw, ph = w * dpi, h * dpi + if love.graphics.getPixelDimensions then + pw, ph = love.graphics.getPixelDimensions() + end + return scale, ox, oy, dpi, pw, ph +end + +-- render.hud's payload (src/core/Game.lua:521), which is what Renderer:endFrame +-- returns on the Gen 1 side: the window and the playfield rect inside it, both +-- in LOVE window units. Built only when someone is subscribed, so a mod-free +-- frame allocates nothing. +function Game2:viewport(w, h) + local scale, ox, oy, dpi = self:frameFit(w, h) + return { + width = w, height = h, + gameX = ox, gameY = oy, + gameWidth = 160 * scale, gameHeight = 144 * scale, + scale = scale, dpiX = dpi, dpiY = dpi, + } +end + +-- The screen-space layer, in the Gen 1 order: render.hud and then the +-- on-screen pad (src/core/Game.lua:521 and :524, either side of +-- Renderer:endFrame). Both are window-space, both sit over the finished +-- frame -- post passes, letterbox and all -- and neither ever enters the game +-- canvas. Every exit path of Game2:draw ends here, which is what makes that +-- true of the composed frame a mod owns as well as of the plain one. +-- +-- render.hud: persistent tool status. The call is fenced with +-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a +-- mod render callback: a subscriber that returns cleanly but leaves a shader +-- bound, the canvas redirected or the colour changed must not corrupt the next +-- frame -- or, now, the pad drawn immediately after it. +function Game2:drawHud(w, h) + if ModRuntime.wantsHook("render.hud") then + local G = love.graphics + G.push("all") + ModRuntime.call("render.hud", noop, self, self:viewport(w, h)) + G.pop() + end + -- The pad LAST, so a HUD mod cannot draw over the controls the player is + -- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1 + -- forces it, and nothing ever while a controller is in use. + TouchControls:draw() +end + +-- render.letterbox: SGB borders and custom void art in the bars around the +-- 160x144 blit. Gen 1 raises it in Renderer:endFrame after the background +-- clear and before the game canvas, so the playfield sits on top of the border; +-- this is the same instant, and Gold reaches it five different ways -- a title +-- screen's own widescreen sky, a page's paper surround, the white void a nested +-- screen gets, the opaque-page safety net, and the live overworld -- so +-- drawScene calls this at each of them and exactly one fires per frame. +-- +-- Payload is Gen 1's table field for field (Renderer.lua:840). +function Game2:letterbox(w, h, worldActive) + if not ModRuntime.wantsHook("render.letterbox") then return end + local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h) + local G = love.graphics + G.push("all") + ModRuntime.call("render.letterbox", noop, { + ww = w, wh = h, pw = pw, ph = ph, + ox = ox, oy = oy, vpw = 160 * scale, vph = 144 * scale, + scale = scale, dpiX = dpi, dpiY = dpi, + worldActive = worldActive and true or false, + }) + G.pop() +end + +-- The zone pass: one scissored full-frame draw per zone, later zones on top, +-- each through its own palette. This is src/render/Renderer.lua:blitCanvas +-- with Gold's palette shader standing in for PaletteFX's, down to the +-- `colors == false` opt-out that draws its rect with no shader at all. +-- +-- Zone rects are 160x144 SCREEN space and map onto the WINDOW, not onto the +-- letterbox: Gold's picture fills the window (the overworld draws edge to edge +-- at World:zoomScale, and every full-screen page paints its own surround), so +-- a whole-screen zone is the whole window -- which is exactly what the CLASSIC +-- present pass has always been. +function Game2:blitZones(canvas, zones, w, h) + local G = love.graphics + local GbcPalette = require("src.render.GbcPalette") + local sx, sy = w / 160, h / 144 + G.setColor(1, 1, 1, 1) + for _, z in ipairs(zones) do + -- a colors == false zone is the true-colour opt-out; anything the shader + -- refuses (no GPU shader support) also falls back to a plain draw + if z.colors == false or not GbcPalette.useRaw(z.colors) then + G.setShader() + end + -- Clamped to the frame and SKIPPED when it clamps to nothing, which is + -- what src/render/Renderer.lua:scissorClamped does with a zone rect on the + -- Gen 1 side. A zone list is mod input (render.zones), so an empty or + -- backwards rect is reachable -- a weather mod deriving one from a + -- viewport that is momentarily zero-sized, say -- and there it just draws + -- nothing. Here it reached love.graphics.setScissor, which raises "Can't + -- set scissor with negative width and/or height" from inside Game2:draw + -- and takes the whole frame down: the hazard the seam rule names, a hook + -- whose contract differs from Gen 1's. Whole-screen and half-screen zones + -- come out of this at exactly the pixels the plain floor/ceil pair gave + -- them, so the vanilla picture is untouched. + local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy + local x1 = math.floor(math.max(zx, 0)) + local y1 = math.floor(math.max(zy, 0)) + local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w)) + local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h)) + if x2 > x1 and y2 > y1 then + G.setScissor(x1, y1, x2 - x1, y2 - y1) + G.draw(canvas, 0, 0) + end + end + G.setScissor() + G.setShader() +end + +-- render.compose: hand a mod the finished frame and the frame metrics and let +-- it lay the picture out however it likes -- two stacked Game Boy screens, one +-- driven onto a second physical display. The mod returns true to take over the +-- whole window; anything else falls through to the normal present below. +-- Returns whether it took over. +-- +-- Gold composites its world pass and its UI into ONE canvas rather than the two +-- Gen 1 keeps apart, so `worldCanvas` and `uiCanvas` are the same texture here +-- and `worldZones` is nil -- there is no second zone space for them to be in. +-- Every other key is what Renderer.lua:748 puts there, and `generation` / +-- `sceneCanvas` are ADDITIONS, so a mod that reads uiCanvas plus the metrics +-- works unchanged while one that needs the two passes apart can tell which +-- game it is in. +function Game2:compose(scene, zones, w, h) + local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h) + local ctx = { + renderer = self, + worldCanvas = scene, uiCanvas = scene, + worldOverride = nil, + worldActive = self.frameWorldActive and true or false, + zones = zones, worldZones = nil, + ww = w, wh = h, pw = pw, ph = ph, ox = ox, oy = oy, + vpw = 160 * scale, vph = 144 * scale, uiw = 160, uih = 144, + scale = scale, Sx = scale, Sy = scale, dpiX = dpi, dpiY = dpi, + secondScreen = require("src.render.SecondScreen"), + -- Gen 2 additions: the one canvas both passes landed in, and which game + -- this is, so a compose mod can branch instead of guessing from uiw. + sceneCanvas = scene, generation = 2, + } + local G = love.graphics + G.push("all") + local handled = ModRuntime.call("render.compose", + function() return false end, self, ctx) == true + G.pop() + return handled +end + +-- Gold's frame, and then the passes that run over it. +-- +-- The Gen 1 path gets these for free because everything it draws goes through +-- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there. +-- Gold draws straight to the screen instead, which is why its GBC FX row used +-- to change a number and nothing else: nothing ever presented a canvas for the +-- shader to read. So compose into one here when a pass wants it, and skip the +-- canvas entirely when none does -- the common case, and one less full-screen +-- blit than the old path would have paid. +-- +-- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette +-- IS the picture, and the screen effects are simulating the panel that picture +-- is being shown on. Mod post-processes fold in between the two, where +-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid +-- is then drawn over, rather than something that smears the grid itself. +function Game2:draw() + local G = love.graphics + local w, h = G.getDimensions() + local GBCFX = require("src.render.GBCFX") + local GbcPalette = require("src.render.GbcPalette") + local Pipelines = require("src.render.Pipelines") + local fx = GBCFX.active() + + -- render.zones, at the instant Gen 1 raises it: the palette list is settled + -- and the blit has not happened yet. Gen 1's list is the SGB packet zones + -- the top state exposed; Gold is a CGB game whose colour is already IN the + -- picture, so the only zone it computes for itself is the whole-screen + -- present palette CLASSIC needs. That is the same case Gen 1 covers with + -- PaletteFX.ensureZones, where a forced mono/CLASSIC mode over a raw DMG + -- canvas gets exactly one whole-screen zone and nothing else -- same rect + -- shape (x/y/w/h in 160x144 screen space, `colors` four 0-255 triples, + -- `colors == false` the opt-out), same identity default -- so a weather or + -- lighting mod written against Gen 1 tints Gold through the same seam. + local zones = nil + local classic = GbcPalette.available() and GbcPalette.presentColors() or nil + if classic then + zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } } + end + if ModRuntime.wantsHook("render.zones") then + zones = ModRuntime.call("render.zones", sameZones, self, zones) + end + local zoned = type(zones) == "table" and zones[1] ~= nil + + -- A present canvas is paid for only when something reads it: the zone pass, + -- GBC FX, a mod post-process, or a render.compose subscriber about to be + -- handed the finished frame. With none of them the frame draws straight to + -- the screen exactly as it always did. + local composing = ModRuntime.wantsHook("render.compose") + local scene = nil + if zoned or fx or composing or Pipelines.wantsPresent() then + scene = self:presentCanvas(1, w, h) + end + if not scene then + self:drawScene(w, h) + self:drawHud(w, h) + return + end + + local previous = G.getCanvas() + -- A canvas does not reset the transform, so this needs its own origin. + G.push() + G.origin() + G.setCanvas(scene) + G.clear(0, 0, 0, 1) + self:drawScene(w, h) + G.setCanvas(previous) + + if composing and self:compose(scene, zones, w, h) then + -- the mod owns the window this frame; the HUD still draws over it, as it + -- does over Gen 1's composed frame + G.pop() + G.setColor(1, 1, 1, 1) + self:drawHud(w, h) + return + end + + -- The zone pass has to land in a texture whenever anything still reads one + -- after it: GBC FX and a post-process both sample the tinted image, not the + -- untinted one. On its own the tint rides the final blit and no second + -- canvas is paid for. + local source = scene + local reread = fx or Pipelines.wantsPresent() + if zoned and reread then + local tinted = self:presentCanvas(2, w, h) + if tinted then + G.setCanvas(tinted) + G.clear(0, 0, 0, 1) + self:blitZones(scene, zones, w, h) + G.setCanvas(previous) + source = tinted + end + -- no second canvas: drop the tint rather than the frame + elseif zoned then + self:blitZones(scene, zones, w, h) + source = nil -- already on the screen + end + + if source then + -- Post-process pipelines run over the finished composite and before GBC + -- FX. Each hands back a canvas; with none registered this returns `source` + -- unchanged and the frame is byte-identical (Renderer.lua:1058). + local scale, _, _, dpi = self:frameFit(w, h) + source = Pipelines.present(source, { width = w, height = h, scale = scale, + dpi = dpi, dpiX = dpi, dpiY = dpi }) or source + if fx then + GBCFX.present(source, self:pixelScale(w, h)) + else + G.setColor(1, 1, 1, 1) + G.draw(source, 0, 0) + G.setShader() + end + end + G.pop() + G.setColor(1, 1, 1, 1) + self:drawHud(w, h) +end + +-- The paper a pushed TextBox has to sit on. A textbox is built entirely from +-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0 +-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm +-- TownMapPals: the attribute map covers $00-$5f and everything >= $60 uses +-- palette 0). White on every screen whose colour 0 is white, which is all of +-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which +-- is what Font.drawBox does by default. +function Game2:textboxPaper() + local base = self.stack and self.stack.states + and self.stack.states[self.stack:visibleBase()] + if base and base.paperColor then return base:paperColor() end + return nil +end + +function Game2:drawScene(w, h) + local G = love.graphics + -- render.compose reads this after the scene is drawn; the plain overworld + -- branch below is the only one where Gen 1 would call the world pass live. + self.frameWorldActive = false + + if self:inFillBoot() then + local top = self.stack:top() + local base = self.stack.states[self.stack:visibleBase()] + -- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and + -- name pick paint a paper-white surround via drawWidescreen. + local wide = (top and top.drawsWidescreen and top:drawsWidescreen() + and top.drawWidescreen) and top + or (base and base.drawsWidescreen and base:drawsWidescreen() + and base.drawWidescreen and base) + if wide then + -- Widescreen layer paints the surround; GB canvas stacks on top so + -- TextBox can overlay Oak's pic without wiping the white field. + wide:drawWidescreen(w, h) + self:letterbox(w, h, false) + if wide ~= top or #self.stack.states > self.stack:visibleBase() then + -- Same integer blit the widescreen layer under it used, or the GB + -- canvas would land on a different grid than the panel it overlays. + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self.stack:draw() + G.pop() + end + else + -- Nested NamingScreen etc.: paper void instead of black pillarboxes. + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, w, h) + self:letterbox(w, h, false) + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self.stack:draw() + G.pop() + end + return + end + + if self.world and self.world.map then + -- A screen that paints its own surround (the battle) covers the window + -- edge to edge instead of sitting in a letterbox over the overworld -- + -- the battle background IS white on the cart, so a white field is what + -- "full screen" means here. + -- The widescreen layer is whichever of the stack's TOP or its visible BASE + -- paints one, the same resolution inFillBoot already makes above. Testing + -- only the top loses the surround the moment anything is pushed over such a + -- screen: .DoPhoneCall (engine/phone/phone.asm) runs the caller's script + -- with the POKEGEAR still owning the screen, and the same happens to the + -- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a + -- TextBox goes up over them. + local top = self.stack:top() + local base = self.stack.states[self.stack:visibleBase()] + local wide = (top and top.drawsWidescreen and top:drawsWidescreen() + and top.drawWidescreen) and top + or (base and base.drawsWidescreen and base:drawsWidescreen() + and base.drawWidescreen and base) + if wide then + wide:drawWidescreen(w, h) + self:letterbox(w, h, false) + if wide ~= top then + -- The pushed box blits at the same integer fit the widescreen layer + -- used, or it lands on a different grid than the panel underneath it. + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self.stack:draw() + G.pop() + end + return + end + + -- CLEARTILEMAP SAFETY NET. Every full-screen Gold page wipes the tilemap + -- on its way in -- ClearBGPalettes / ClearTilemap at engine/games/ + -- unown_puzzle.asm:11, engine/events/diploma.asm:13, engine/events/ + -- magnet_train.asm:101 (ClearBGPalettes / ClearSprites / DisableLCD), + -- engine/printer/print_party.asm:134 and engine/events/print_unown.asm:17 + -- -- so not one map tile can survive underneath one. A screen that + -- declares itself OPAQUE but ships no widescreen layer would otherwise + -- letterbox over a live `world:draw()` and show the overworld all round + -- its edges, which is the one thing the cart cannot do. + -- + -- Gated strictly on isOpaque, because the screens that deliberately sit + -- OVER the map (StartMenu, DayCareMenu, MailboxMenu, ScriptMenu, + -- ElevatorMenu, HeldItemMenu, MoveDeleter, TradeMenu, MapRadio, + -- BankOfMom, BattleTransition) leave it false and MUST keep the world + -- behind them -- src/ui/gen2/DayCareMenu.lua draws only its box. + if base and base.isOpaque then + if base.drawWidescreen then + -- Already paints its own surround and only misses the + -- `drawsWidescreen` opt-in the resolution above tests, so use it: + -- the panel's own field colour is what belongs outside the page. + base:drawWidescreen(w, h) + else + -- No surround of its own: the paper-white void the boot path uses, + -- rather than a window full of somebody else's map. + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, w, h) + end + self:letterbox(w, h, false) + -- Same integer blit every widescreen layer uses, or the GB canvas lands + -- on a different grid than the field behind it. + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self.stack:draw() + G.pop() + return + end + + -- The live overworld IS the background here -- it draws edge to edge at + -- World:zoomScale, with no surround to paint first -- so the border seam + -- sits ahead of it, which is where Gen 1 puts it too: Renderer:endFrame + -- raises render.letterbox before the world blit as well as before the UI + -- one, and `worldActive` in the payload is how a subscriber tells the two + -- frames apart. + self.frameWorldActive = true + self:letterbox(w, h, true) + self.world:draw() + if self.stack:top() then + -- ZOOM RESIZES THE MAP, NOT THE UI. The world fills the window at + -- `world:zoomScale()`; the stack canvas -- the dialogue box, the START + -- menu, every screen that sits over the overworld -- blits at the plain + -- integer letterbox fit instead, so a zoom step moves the map under a + -- text box that stays exactly the size it is at FIT. + -- + -- This is the split src/render/Renderer.lua makes for Gen 1, whose UI + -- LAYOUT defaults to CENTERED: `Renderer:uiScale` returns `fitScale()` + -- and only the world canvas follows `Zoom.scale`. Gold has no DYNAMIC + -- row to opt into the step-down half, so CENTERED is the whole rule + -- here. + local s = self.world:fitScale() + G.push() + G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2)) + G.scale(s, s) + self.stack:draw() + G.pop() + end + return + end + + G.clear(0.07, 0.05, 0.02, 1) + G.setColor(0.85, 0.57, 0.13, 1) + G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center") + G.setColor(0.92, 0.90, 0.82, 1) + G.printf(self.status or "Failed to boot Gen 2 world.", + 0, math.floor(h * 0.48), w, "center") + G.printf("Press Escape to quit.", 0, math.floor(h * 0.62), w, "center") + G.setColor(1, 1, 1, 1) +end + +-- The display/speed hotkey ladder, the same keys and the same order the Gen 1 +-- path binds them in (src/core/Game.lua keypressed), driving the same shared +-- modules so a player's muscle memory carries between the two games: +-- +-- F1/F2 write / reload the save 1 GAME SPEED +-- - = zoom one step out / in 2 COLOR +-- 4 cycle ZOOM 3 TILT (mnemonic: 3D) +-- 5 GBC FX +-- +-- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB +-- palette packs, which a CGB-native game has no use for; what it cycles here +-- is whether the cart's own colour is showing at all (GBC / DMG / CLASSIC). +-- Same key, same place in the ladder, same idea: "change how this looks". +function Game2:hotkey(key) + local options = self.options or {} + local function persist() + if self.save then self.save.options = options end + self:persistOptions() + end + if key == "f1" then + self:writeSave() + return true + elseif key == "f2" then + local loaded = Save.load("gold") + if loaded then self:continueGame(loaded) end + return true + elseif key == "1" then + local GameSpeed = require("src.core.GameSpeed") + options.speed = GameSpeed.cycle(options.speed, 1) + persist() + return true + elseif key == "2" then + local GbcPalette = require("src.render.GbcPalette") + GbcPalette.setMode(options.color or "gbc") + options.color = GbcPalette.cycle(1) + persist() + return true + elseif key == "3" then + local Tilt = require("src.render.Tilt") + options.tilt = Tilt.cycle() + persist() + return true + elseif key == "5" then + local GBCFX = require("src.render.GBCFX") + if GBCFX.isSupported() then + options.gbcfx = GBCFX.cycle() + persist() + end + return true + end + if not (self.world and self.world.map) then + return self:pipelineHotkey(key, options, persist) + end + if key == "-" or key == "kp-" then + self.world:zoomStep(-1) + options.zoom = require("src.render.Zoom").offset + persist() + return true + elseif key == "=" or key == "kp+" then + self.world:zoomStep(1) + options.zoom = require("src.render.Zoom").offset + persist() + return true + elseif key == "4" then + self.world:zoomCycle() + options.zoom = require("src.render.Zoom").offset + persist() + return true + end + return self:pipelineHotkey(key, options, persist) +end + +-- The (top, overworld) pair src/render/Pipelines.lua's free-roam gate reads. +-- +-- Gen 1 hands it (stack:top(), overworld), and the default gate (Zoom.gateOK) +-- asks "is the overworld itself the top state, and is it idle". Gold's +-- overworld is not a state at all -- an empty stack IS free roam -- so +-- reporting the world as its own top in that case is what makes a Gen 1-shaped +-- gate answer correctly here. The idle half is World:acceptsMenuInput, which +-- transcribes CheckMenuOW's three gates (engine/overworld/events.asm:802) and +-- is the same test Gold's own START/SELECT presses go through. +function Game2:pipelineGate() + local world = self.world + if not (world and world.map) then return nil, nil end + local top = self.stack:top() + if top then return top, world end + if not world:acceptsMenuInput() then return nil, world end + return world, world +end + +-- Mod render pipelines claim their hotkeys LAST, so one can never shadow an +-- engine display key however a mod declares it -- the rule and the order +-- src/core/Game.lua:652 follows. syncOptions writes the whole ladder back, +-- including the tilt exclusion a world pipeline forces. +function Game2:pipelineHotkey(key, options, persist) + local Pipelines = require("src.render.Pipelines") + local top, world = self:pipelineGate() + if not Pipelines.hotkey(key, top, world) then return false end + Pipelines.syncOptions(options) + require("src.render.Tilt").setLevel(options.tilt or 0) + persist() + return true +end + +function Game2:keypressed(key) + -- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is + -- how the start menu opens on a desktop keyboard. Quitting is the start + -- menu's QUIT row and the intro menu's EXIT GAME. + -- A screen that is open owns the keyboard, the same way Game hands the top + -- state first refusal -- except for the display ladder, which is a host + -- control rather than a game button. It runs during the boot cinema too: + -- the title screen and the intro menu are exactly where someone tries the + -- COLOR key, and the ladder's world-only rungs already refuse themselves + -- when there is no map. + if self:hotkey(key) then return end + Input:keypressed(key) +end + +function Game2:keyreleased(key) + Input:keyreleased(key) +end + +function Game2:wheelmoved(_x, dy) + if self.phase == "boot" or self.stack:top() then return end + if not (self.world and self.world.map) then return end + if dy > 0 then + self.world:zoomStep(1) + elseif dy < 0 then + self.world:zoomStep(-1) + end +end + +-- ---- the gameplay pointer seam (#807) -------------------------------------- +-- +-- The same hook, the same payload and the same lifecycle rules +-- src/core/Game.lua:872 documents, including the ownership rule: +-- src/core/TouchControls.lua gets FIRST REFUSAL on every touch, because a +-- pointer that begins on a virtual d-pad belongs to the pad for its whole life +-- and must never reach a mod. Capture is decided at press and rides +-- TouchControls.touches[id]; a pointer that begins outside the controls stays +-- mod-visible even if it later wanders across one. +-- +-- Everything a subscriber costs -- the per-pointer records in self.modPointers, +-- the payload tables -- is behind wantsHook, so a mod-free boot allocates +-- nothing here. + +-- coordinates are LOVE window units, the same space render.hud's viewport is in +function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + return ModRuntime.call("input.pointer", pointerUnclaimed, self, { + phase = phase, source = source, id = id, x = x, y = y, + dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, + }) +end + +function Game2:touchpressed(id, x, y, dx, dy, pressure) + if TouchControls:touchpressed(id, x, y) then return end + if not ModRuntime.wantsHook("input.pointer") then return end + -- POKEPORT_TOUCH routes the mouse through here as a stand-in finger under the + -- id "mouse" (see main.lua); mods still see its true source + local source = id == "mouse" and "mouse" or "touch" + self.modPointers = self.modPointers or {} + self.modPointers[id] = { source = source, x = x, y = y, pressure = pressure } + self:pointerEvent("pressed", source, id, x, y, dx, dy, pressure) +end + +function Game2:touchmoved(id, x, y, dx, dy, pressure) + -- The pad tracks only ids it captured at press, so this is a no-op for a + -- mod-visible pointer; a captured one sliding between d-pad directions swaps + -- the held GB button here. + TouchControls:touchmoved(id, x, y) + local p = self.modPointers and self.modPointers[id] + if not p then return end + -- the POKEPORT_TOUCH mouse path carries no deltas; derive them from the + -- pointer's last seen position so drags read the same either way + if dx == nil then dx, dy = x - p.x, y - p.y end + p.x, p.y = x, y + if pressure ~= nil then p.pressure = pressure end + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("moved", p.source, id, x, y, dx, dy, pressure) + end +end + +function Game2:touchreleased(id, x, y, dx, dy, pressure) + TouchControls:touchreleased(id, x, y) + local p = self.modPointers and self.modPointers[id] + if not p then return end + self.modPointers[id] = nil + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("released", p.source, id, x, y, dx, dy, pressure) + end +end + +-- A real mouse without POKEPORT_TOUCH. Gameplay itself has no mouse verbs, so +-- the pointer hook is the only consumer and everything is behind the wantsHook +-- gate. A synthesized istouch twin is dropped unconditionally: the same +-- contact already arrived through touchpressed, and forwarding both would fire +-- a mobile touch twice. +function Game2:mousepressed(x, y, button, istouch) + if istouch then return end + if not ModRuntime.wantsHook("input.pointer") then return end + self.modPointers = self.modPointers or {} + local p = self.modPointers.mouse + if p then + p.held, p.x, p.y = (p.held or 1) + 1, x, y + else + self.modPointers.mouse = { source = "mouse", x = x, y = y, + held = 1, button = button } + end + self:pointerEvent("pressed", "mouse", "mouse", x, y, 0, 0, nil, button) +end + +-- hover moves are delivered too (button = nil); only pressed pointers are +-- tracked, because only they owe a released/cancelled later +function Game2:mousemoved(x, y, dx, dy, istouch) + if istouch then return end + local p = self.modPointers and self.modPointers.mouse + if p then p.x, p.y = x, y end + if not ModRuntime.wantsHook("input.pointer") then return end + self:pointerEvent("moved", "mouse", "mouse", x, y, dx, dy, nil, nil) +end + +function Game2:mousereleased(x, y, button, istouch) + if istouch then return end + local p = self.modPointers and self.modPointers.mouse + if not p then return end + p.held = (p.held or 1) - 1 + if p.held <= 0 then self.modPointers.mouse = nil end + if ModRuntime.wantsHook("input.pointer") then + self:pointerEvent("released", "mouse", "mouse", x, y, 0, 0, nil, button) + end +end + +-- Focus/visibility loss swallows pointer releases the same way it swallows +-- key-ups (the hazard Input:reset exists for): every mod-visible pointer gets a +-- "cancelled" instead of leaving subscribers waiting on a "released" that can +-- never arrive. Cleared even when the subscriber is already gone, so no stale +-- record outlives its mod. +function Game2:cancelPointers() + local pointers = self.modPointers + if not pointers then return end + self.modPointers = nil + if not ModRuntime.wantsHook("input.pointer") then return end + for id, p in pairs(pointers) do + self:pointerEvent("cancelled", p.source, id, p.x, p.y, 0, 0, + p.pressure, p.button) + end +end + +-- The three window-lifecycle callbacks main.lua forwards, with the bodies +-- src/core/Game.lua:804 gives them: drop every held button the window is about +-- to stop hearing key-ups for, reconcile back whatever is still physically +-- down, cancel live pointers, and clear the touch overlay. LÖVE has no +-- touchcancelled, so a finger the OS takes away (an app switch, a system +-- gesture) never fires touchreleased and would strand its GB button held +-- forever -- TouchControls:reset is the only thing that frees it. +function Game2:focus(f) + Input:reset() + TouchControls:reset() + if f then Input:reconcile() end + self:cancelPointers() +end + +function Game2:visible(v) + if v then + self:onResume() + else + Input:reset() + TouchControls:reset() + self:cancelPointers() + end +end + +function Game2:onResume() + Input:reset() + TouchControls:reset() + Input:reconcile() + self:cancelPointers() +end + +-- Push the saved display options into the modules that own them. Called +-- whenever the options table changes hands (boot, CONTINUE, the OPTION +-- screen), so a reload comes back at the zoom, tilt and GBC FX the player left. +function Game2:applyOptions() + local options = self.options or {} + Music.applyOptions(options) + require("src.core.Sound").applyOptions(options) + require("src.render.Zoom").applyOptions(options) + require("src.render.Tilt").applyOptions(options) + require("src.render.GbcPalette").applyOptions(options) + -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. + Font.setFrame(options.frame or 1) + -- the mod pipeline ladder rides options.pipelines and restores with the rest + -- of the display block, as it does in src/core/Game.lua:1041 + require("src.render.Pipelines").applyOptions(options) + -- options.touchControls (the launcher editor's per-orientation layouts) and + -- options.haptics, the same two keys Gen 1 hands over here + -- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a + -- player laid out for Red is already the pad Gold draws. + TouchControls:applyOptions(options) + local GBCFX = require("src.render.GBCFX") + if GBCFX.applyOptions(options) and self.save then + -- applyOptions returns true when it had to clear an unsupported level. + self.save.options = options + end +end + +-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS +-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been +-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written +-- (`back = "select"`). It used to QUIT here, from the same era as the START +-- comment below: before there was a start menu, the menu button was the only +-- way out of a Gold boot. That left a controller with no SELECT at all -- the +-- register/use-item press (UseRegisteredItem, engine/overworld/select_menu.asm), +-- the PACK's move-item, the party menu's reorder and half the soft-reset chord +-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button +-- to find out killed the process. It reaches Input like every other button now. +function Game2:gamepadpressed(_joystick, button) + -- a controller is being used: the touch overlay steps aside until the next + -- screen touch (mobile only; a no-op elsewhere) + TouchControls:noteGamepad() + -- The shoulders cycle GAME SPEED, as they do in the Gen 1 path. + if button == "rightshoulder" or button == "leftshoulder" then + local GameSpeed = require("src.core.GameSpeed") + local dir = button == "rightshoulder" and 1 or -1 + self.options.speed = GameSpeed.cycle(self.options.speed, dir) + if self.save then self.save.options = self.options end + self:persistOptions() + return + end + -- START opens the start menu in the overworld; it used to quit, from before + -- there was a menu to open. + + Input:gamepadpressed(_joystick, button) +end + +function Game2:gamepadreleased(joystick, button) + Input:gamepadreleased(joystick, button) +end + +function Game2:gamepadaxis(joystick, axis, value) + -- past-deadzone only, so resting-stick drift cannot hide the overlay + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:gamepadaxis(joystick, axis, value) +end + +return Game2 diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 774eef6e..8ec25399 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1,14 +1,14 @@ --- Which Gen-1 game this process is running: Red (the historical default), --- Blue, or Yellow. One source of truth for everything that differs by +-- Which game this process is running: Red (the historical default), Blue, +-- Yellow, or Gold. 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. -- -- Red keeps the un-suffixed save paths it always used (save.lua) so existing --- saves are untouched, but its extracted cache lives under red/ like Blue and --- Yellow (issue #899); a legacy root cache is moved into red/ once by --- CacheFs.migrateLegacyRedCache. All three versions can be imported and --- played side by side. +-- 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 is Gen 2 (see docs/gold-phase1.md). -- -- 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 @@ -47,10 +47,27 @@ GameVersion.VERSIONS = { cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp }, + -- 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 + -- RomExtractor. + gold = { + id = "gold", + label = "Gold", + displayName = "Pokemon Gold", + -- Still Gen 2 Phase work; the launcher panel / Play button say Beta so + -- players do not treat it like the shipped Gen 1 columns. + launcherName = "Gold (Beta)", + sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94", + manifest = "tools/rom_manifest_gold.json", + cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated + saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp + -- The only row that carries one; absent reads as 1 (GameVersion.generation) + generation = 2, + }, } -- Launcher column order. -GameVersion.ORDER = { "red", "blue", "yellow" } +GameVersion.ORDER = { "red", "blue", "yellow", "gold" } GameVersion.current = "red" @@ -71,6 +88,19 @@ function GameVersion.isYellow() return GameVersion.current == "yellow" end +function GameVersion.isGold() + return GameVersion.current == "gold" +end + +-- 1 or 2. The mod API is shared across both (same hook names, same registry +-- names), so the pieces that must branch -- the manifest gen2compat gate, the +-- registry target routing, the mod.world arm -- ask this rather than each +-- spelling out its own isGold() test. A third generation adds a `generation` +-- to its VERSIONS row and nothing else changes shape. +function GameVersion.generation(id) + return GameVersion.info(id).generation or 1 +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/Input.lua b/src/core/Input.lua index 33543c97..49616f1e 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -32,6 +32,25 @@ local STICK_OFF = 0.3 -- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua -- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632). +-- +-- SELECT ON A PLAYSTATION PAD, checked rather than assumed. There is no +-- button called "select" in SDL's game-controller vocabulary: the small +-- left-hand menu button is `back` on every family, and GamepadMap's +-- DEFAULT_GAMEPAD_BINDINGS maps it to GB SELECT. The DualSense's CREATE +-- button (and the DualShock 4's SHARE) is that button -- the controller +-- database LOVE 11.5 ships spells the DualSense row +-- "PS5 Controller,a:b1,b:b2,back:b8,...,misc1:b13,start:b9" -- so a +-- recognized pad delivers it here as gamepadpressed(_, "back") and needs no +-- entry of its own. What it also has, and what LOVE 11.x has no name for at +-- all, is the TOUCHPAD click (SDL_CONTROLLER_BUTTON_TOUCHPAD) and the mute +-- key (`misc1`): neither reaches love.gamepadpressed, so neither can be bound, +-- and a player reaching for the touchpad expecting SELECT will find nothing. +-- Not a mapping this file can add -- the event never arrives. +-- +-- An unrecognized PlayStation pad falls to the raw path instead, where SHARE / +-- CREATE is generic-HID button 9 and RAW_BUTTON_BINDINGS[9] is already +-- "select". Both roads reach SELECT; the one road that did not was Gold's, +-- where src/core/Game2.lua used to answer `back` with love.event.quit(). local HAT_DIRECTIONS = { u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 216e26df..cac66de6 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -2,6 +2,7 @@ -- -- love . --game=red -- boot Red -- love . --game=yellow --slot=2 -- boot Yellow on save slot 2 +-- love . --game=gold -- boot Gold (src/core/Game2.lua) -- love . --game=red --launcher -- open the launcher anyway (a shortcut -- the player wants to edit) -- POKEPORT_GAME=blue love . -- same, for launchers that only pass env @@ -37,6 +38,7 @@ local function normalizeVersion(v) r = "red", red = "red", b = "blue", blue = "blue", y = "yellow", yellow = "yellow", + g = "gold", gold = "gold", } v = alias[v] or v if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end diff --git a/src/core/Music.lua b/src/core/Music.lua index 7ac621bc..48f87cde 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -413,6 +413,18 @@ function Music.oneShotPlaying() return state.pendingRestore == true end +-- The label of the song that is current, or nil. A read-only window on the +-- state, for drivers and tests that need to assert what is playing. +function Music.current() + return state.current +end + +-- The remembered map song (wMapMusic), the same read-only window: what a +-- battle's restore will replay. setMapSong below is the write half. +function Music.mapSong() + return state.mapSong +end + function Music.restoreMap(data) state.current = nil state.pendingRestore = nil @@ -420,6 +432,14 @@ function Music.restoreMap(data) if play then Music.play(data, play, nil, { reason = "map" }) end end +-- Overwrite the remembered map song without playing anything: the wMapMusic +-- write in pokegold engine/pokegear/pokegear.asm RadioMusicRestartDE. A radio +-- station's song becomes the map music itself, so a battle's restoreMap brings +-- the STATION back and only the next playMap (a map change) replaces it. +function Music.setMapSong(song) + state.mapSong = song +end + -- 0-7 music volume (0 mutes), applied to the playing song and the -- queued loop body as well as everything played later function Music.setVolumeLevel(level) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 4b8511bf..7bdfcb93 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -295,6 +295,15 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + -- Mods the player forced past the target gate (Loader:_gateGeneration). + -- modsGen2[id][version] = true, one answer per game; a bare `true` is the + -- pre-per-game shape and means the Gen 2 games only (see modForced). + modsGen2 = {}, + -- Per-game enablement: modsByVersion[version][id] answers for that game + -- only and falls through to the shared mods[id] above when absent, so an + -- options.lua written before this key keeps its exact meaning. Read and + -- written through SaveData.modEnabled / SaveData.setModEnabled. + modsByVersion = {}, -- Named setups the player can switch between (#593; src/mods/ModProfile.lua -- owns the shape, src/mods/ManagerState.lua the UI): each row is -- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }. @@ -541,6 +550,127 @@ function SaveData.loadOptions(fs) return SaveData.mergeOptions(data) end +-- ------- per-game mod enablement +-- +-- One installed mod, one id, one enable flag per game that wants to differ. +-- options.mods is the shared answer every version used to get; the overlay +-- only holds the games the player actually chose for, so a mod set can differ +-- between Red and Gold without either one owning the other's flags. + +-- Whether a per-game answer is honoured at boot. The loader reads the enable +-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this +-- flips on with that read and not before: until then every writer keeps to the +-- shared flag and no surface promises what the boot does not do. +SaveData.PER_VERSION_MODS = false + +-- The version a write should be scoped to: the game asked for once per-game +-- flags are live, nil (the shared flag) while they are only a preview. +function SaveData.modScope(version) + if SaveData.PER_VERSION_MODS then return version end + return nil +end + +-- true/false as chosen for `version`, else the shared flag, else nil -- the +-- caller owns the default (the loader enables, the launcher keeps +-- experimental mods off until asked). +function SaveData.modEnabled(options, id, version) + local byVersion = options and options.modsByVersion + local bucket = version and type(byVersion) == "table" and byVersion[version] + if type(bucket) == "table" and type(bucket[id]) == "boolean" then + return bucket[id] + end + local shared = options and options.mods + if type(shared) == "table" and type(shared[id]) == "boolean" then + return shared[id] + end + return nil +end + +-- Write the choice for one game, or the shared flag when version is nil. A +-- per-game entry that agrees with the shared flag is dropped rather than +-- stored, so the overlay stays the list of deliberate differences. +function SaveData.setModEnabled(options, id, enabled, version) + if type(options) ~= "table" or type(id) ~= "string" or id == "" then + return options + end + enabled = enabled and true or false + if not version then + options.mods = options.mods or {} + options.mods[id] = enabled + return options + end + options.modsByVersion = options.modsByVersion or {} + local bucket = options.modsByVersion[version] or {} + options.modsByVersion[version] = bucket + -- no shared flag reads as enabled, the same default the loader applies to a + -- missing entry, so a fresh install never fills the overlay with agreement + local shared = options.mods and options.mods[id] + if type(shared) ~= "boolean" then shared = true end + if shared == enabled then + bucket[id] = nil + else + bucket[id] = enabled + end + return options +end + +-- ------- the player's target override +-- +-- The manifest's `games` is the AUTHOR's claim and the loader enforces it +-- (Loader:_gateGeneration); this is the player's per-game override of that +-- claim. Scoped by version, because "run it on Gold anyway" is not an answer +-- about Red: a version-blind flag forced a mod past a gate on a game its +-- owner was never asked about. + +-- A pre-per-game `true` could only ever take effect on a Gen 2 boot (the gate +-- returned early on Gen 1), so that is exactly what it is read as here. +local function forcedGenerations(entry) + return entry == true and 2 or nil +end + +function SaveData.modForced(options, id, version, generation) + local entry = type(options) == "table" and type(options.modsGen2) == "table" + and options.modsGen2[id] + if entry == nil or entry == false then return false end + local gen = generation or (version and GameVersion.generation(version)) + local legacy = forcedGenerations(entry) + if legacy then return legacy == gen end + if type(entry) ~= "table" then return false end + if version then return entry[version] == true end + -- no version, only a generation: a harness seam, so ask whether ANY game of + -- that generation was forced rather than inventing a game + for id2, on in pairs(entry) do + if on == true and GameVersion.generation(id2) == gen then return true end + end + return false +end + +-- Write the override for one game. Without a version there is no game to +-- answer for, so this writes nothing rather than guessing (the caller keeps +-- the choice in memory for this boot and says so). +function SaveData.setModForced(options, id, forced, version) + if type(options) ~= "table" or type(id) ~= "string" or id == "" then + return false + end + if not (version and GameVersion.VERSIONS[version]) then return false end + options.modsGen2 = options.modsGen2 or {} + local entry = options.modsGen2[id] + if type(entry) ~= "table" then + -- migrate the legacy flag in place, keeping the games it already covered + local expanded = {} + if forcedGenerations(entry) then + for _, other in ipairs(GameVersion.ORDER) do + if GameVersion.generation(other) == 2 then expanded[other] = true end + end + end + entry = expanded + options.modsGen2[id] = entry + end + entry[version] = forced and true or nil + if next(entry) == nil then options.modsGen2[id] = nil end + return true +end + -- ------- save slots -- A version's playthroughs live in numbered slots under saves/<version>/; @@ -677,7 +807,20 @@ function SaveData.slotSummary(save) for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do dexCount = dexCount + 1 end - local t = math.floor(save.playTime or 0) + -- playTime is a plain seconds count in a Gen 1 save but a + -- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching + -- the cart's wGameTime* bytes. The launcher calls slotSummary on EVERY + -- version's slot, so this has to read both shapes or the whole launcher + -- crashes the moment a Gold save exists (math.floor on the table). + local pt = save.playTime + local t + if type(pt) == "table" then + t = (tonumber(pt.hours) or 0) * 3600 + + (tonumber(pt.minutes) or 0) * 60 + + (tonumber(pt.seconds) or 0) + else + t = math.floor(tonumber(pt) or 0) + end local timeText = ("%d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60) return name, { diff --git a/src/core/Sound.lua b/src/core/Sound.lua index f3fd35b0..08888cf4 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -173,10 +173,64 @@ local function playPath(data, key, def, pitch, tempo) return src end +-- A Gen 2 sfx header declares how many of the four sfx channels it wants +-- (`channel_count N` in audio/sfx.asm), and sfx channel N takes hardware +-- channel N over from the music channel with the same number for as long as +-- it sounds. So a FOUR-channel sfx silences the song outright -- that is what +-- the cart does with every jingle: Sfx_RegisterPhoneNumber, Sfx_GetTm, +-- Sfx_GetBadge, Sfx_GetEgg, Sfx_Item, Sfx_CaughtMon, the eight dex fanfares. +-- The port's fanfare table was six hardcoded names, so the phone-number jingle +-- (and a dozen others) played OVER the music instead of replacing it. +-- +-- Three-channel sfx are NOT ducked even though they too silence three quarters +-- of the song: most of them are battle move sounds (Psychic, Hyper Beam, Surf) +-- that fire several times a second, and pausing/resuming the song under each +-- one would stutter far worse than letting them overlay. The handful of +-- three-channel JINGLES are named below instead. +local GEN2_JINGLES = { + Sfx_Fanfare = true, Sfx_Fanfare2 = true, + Sfx_3rdPlace = true, Sfx_TrainArrived = true, +} +local FULL_BAND = 4 +local channelCounts = {} -- per sfx name; the header read is not free + +local function claimsEveryChannel(data, name, def) + if type(def) ~= "table" or not def.address then return false end + -- Gen 2 only. Gen 1's fanfare set is already listed by name and its sfx + -- headers count channels differently; widening the rule there would change + -- Red/Blue behaviour for no reported reason. + if def.generation ~= 2 then return false end + local known = channelCounts[name] + if known == nil then + local ok, channels = pcall( + require("src.core.ChipSynth").effectChannels, data, def) + -- effectChannels answers nil for "not knowable HERE" -- a file def, or the + -- program banks not readable yet (src/core/ChipSynth.lua effectChannels). + -- That is a "not yet", not a channel count: memoizing it as zero would + -- stamp a four-channel jingle as non-ducking for the rest of the session, + -- so it plays over the map music until the next launch. Only a header + -- that actually read is cached; a failed read is retried on the next play. + if not (ok and channels) then return false end + known = #channels + channelCounts[name] = known + end + return known >= FULL_BAND +end + local function ducks(data, name, def) if type(def) == "table" and def.fanfare then return true end local fanfares = data.audio and data.audio.fanfares or FANFARES - return fanfares[name] and true or false + if fanfares[name] then return true end + if GEN2_JINGLES[name] then return true end + return claimsEveryChannel(data, name, def) +end + +-- Does playing this sfx stop the song? Exposed so a test can assert the rule +-- without an audio device. +function Sound.ducksMusic(data, name) + local sfx = data and data.audio and data.audio.sfx + name = Sound.resolve(data, name) + return ducks(data or {}, name, sfx and sfx[name]) end local function played(kind, name, species) @@ -184,12 +238,125 @@ local function played(kind, name, species) Runtime.emit("sound.played", { kind = kind, name = name, species = species }) end --- returns the started source (nil headless, or when the def failed to load) --- so callers that block on a fanfare like the original's --- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it -function Sound.play(data, name) - local sfx = data.audio and data.audio.sfx - local def = sfx and sfx[name] +-- The shared UI names its sounds the way pokered does; Gen 2's sfx table is +-- keyed by pokegold's own labels, so a Gold session asking for "Press_AB" +-- finds nothing and the menu goes silent -- which is exactly what happened to +-- the A-press beep on every Gold dialogue. Only the shared modules a Gold +-- session actually enters need a row here, and today that is src/render/ +-- TextBox.lua and src/ui/ChoiceBox.lua, both playing "Press_AB": the cart +-- sounds SFX_READ_TEXT_2 at both of those moments (home/joypad.asm +-- PromptButton for the textbox wait, home/menu.asm PlayClickSFX for a menu +-- pick). Every other shared player of a pokered sfx name sits in a module +-- Gold replaces under src/world/gen2 or src/ui/gen2, and those name their +-- sounds in pokegold's labels directly. So a row belongs here only once a +-- shared module is reachable from Gold, and its target is whatever the cart +-- plays at that same moment -- not the nearest-sounding Gen 2 label. +Sound.GEN2_ALIASES = { + Press_AB = "Sfx_ReadText2", +} + +-- The hop Sound.resolve took for a raw name, so the argument-only entry points +-- (stop, isPlaying) can reach a source Sound.play cached under the resolved +-- key without a data table of their own. +local aliased = {} + +function Sound.resolve(data, name) + local sfx = data and data.audio and data.audio.sfx + if not sfx then return name end + if sfx[name] then return name end + local alias = Sound.GEN2_ALIASES[name] + if alias and sfx[alias] then + aliased[name] = alias + return alias + end + return name +end + +-- the source a raw name plays through, whichever key it ended up cached under +local function cached(name) + local src = cache[name] + if src == nil then + local key = aliased[name] + if key then src = cache[key] end + end + return src +end + +-- Gen 2's overworld/menu entry point is a PRIORITY GATE, not a bare play +-- (home/audio.asm PlaySFX). It asks CheckSFX whether any of the four sfx +-- channels is still sounding, and when one is it compares the id that owns +-- them: `ld a, [wCurSFX] / cp e / jr c, .done` DROPS the new sound outright +-- while the playing id is numerically lower (constants/sfx_constants.asm +-- orders the table highest priority first). Only an id at or below wCurSFX +-- falls through, and _PlaySFX turns off and re-zeroes ch5-ch8 before it loads +-- the new header (audio/engine.asm _PlaySFX), cutting the old sound dead. +-- Either way sfx NEVER layer here. SproutTower3FRivalScene is the plain +-- case: `playsound SFX_TACKLE` ($41) then `playsound SFX_ELEVATOR` ($6e) one +-- command later, with the two-note tackle still sounding, so the cart never +-- plays the elevator rumble at all -- the pillar sways to the thud alone. +-- +-- Battle ANIMATION sounds are a different entry point and must not come +-- through here: anim_sound reaches PlayStereoSFX (engine/battle_anims/ +-- anim_commands.asm), which has no gate at all and, with stereo on, does not +-- even clear the channels another sfx holds. That is Sound.playStereo below. +local sfxIds -- { label -> SFX_* id }, derived from data.audio.sfxOrder +local curSfx -- { src, id } of the last gated sfx that started, i.e. wCurSFX + +local function sfxIdFor(data, name) + local order = data and data.audio and data.audio.sfxOrder + if not order then return nil end + if not sfxIds then + sfxIds = {} + -- sfxOrder is audio/sfx_pointers.asm in table order, so id = index - 1 + -- (RomExtractorGen2 extractAudio writes it from constants.sfxOrder). + for index, label in ipairs(order) do sfxIds[label] = index - 1 end + end + return sfxIds[name] +end + +-- Would PlaySFX start this sound now? Answers false for `jr c, .done`, which +-- the caller honours by dropping the request whole: a discarded sfx neither +-- sounds nor ducks the music. The second return is the id to remember as +-- wCurSFX once the sound actually starts. +local function sfxPriorityGate(data, name, def) + -- Gen 1 keeps today's behaviour: pokered's PlaySound arbitrates by channel + -- rather than by a single wCurSFX, and Sound.playMove already ports that. + if type(def) ~= "table" or def.generation ~= 2 then return true end + local id = sfxIdFor(data, name) + if not id then return true end + if curSfx then + local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src) + if not (ok and playing) then + curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering + elseif curSfx.id < id then + return false -- the sound already going outranks this one + else + pcall(curSfx.src.stop, curSfx.src) -- _PlaySFX zeroes ch5-ch8 first + curSfx = nil + end + end + return true, id +end + +-- CheckSFX (home/audio.asm): is a gated sfx still sounding on ch5-ch8? This +-- is the state WaitSFX blocks on, and it is the gate's OWN wCurSFX rather +-- than whatever the caller last held a source for, so a sound started +-- somewhere else entirely (the A-press beep a textbox plays) still answers +-- busy here. Phone_StartRinging (engine/phone/phone.asm:564) is the caller +-- that needs it: SFX_CALL is $6a, low enough that any louder sound still on +-- the channels makes sfxPriorityGate DROP the ring outright, where the cart +-- merely waits for it. +function Sound.sfxBusy() + if not curSfx then return false end + local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src) + if not (ok and playing) then + curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering + return false + end + return true +end + +local function startSfx(data, name, def) local src = playPath(data, name, def) if not src then return end if ducks(data, name, def) then @@ -199,6 +366,31 @@ function Sound.play(data, name) return src end +-- returns the started source (nil headless, when the def failed to load, or +-- when the priority gate dropped the sound) so callers that block on a +-- fanfare like the original's PlaySoundWaitForCurrent -> WaitForSoundToFinish +-- can poll it +function Sound.play(data, name) + local sfx = data.audio and data.audio.sfx + name = Sound.resolve(data, name) + local def = sfx and sfx[name] + local allowed, id = sfxPriorityGate(data, name, def) + if not allowed then return end + local src = startSfx(data, name, def) + if src and id then curSfx = { src = src, id = id } end + return src +end + +-- PlayStereoSFX (audio/engine.asm), the battle animation path: same sound, +-- same fanfare duck, but no CheckSFX/wCurSFX gate, and it never writes +-- wCurSFX either -- so an animation sound can neither be dropped by, nor +-- become, the priority the overworld path compares against. +function Sound.playStereo(data, name) + local sfx = data.audio and data.audio.sfx + name = Sound.resolve(data, name) + return startSfx(data, name, sfx and sfx[name]) +end + -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers -- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/ -- wTempoModifier and the battle sound engine applies them to every @@ -426,7 +618,7 @@ end -- .musicLoop polls wChannelSoundIDs+CHAN5 until SFX_SAFARI_ZONE_PA -- ends.) Headless / never-played names read as silent. function Sound.isPlaying(name) - local src = cache[name] + local src = cached(name) if not src then return false end local ok, playing = pcall(src.isPlaying, src) return ok and playing or false @@ -435,7 +627,7 @@ end -- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the -- elevator shake stop the last collision thud mid-ring) function Sound.stop(name) - local src = cache[name] + local src = cached(name) if src then pcall(src.stop, src) end end @@ -520,6 +712,14 @@ end -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) lastMoveSfx = nil -- its source is about to be dropped or stopped + -- Same for wCurSFX, and a reloaded table can repoint the id order. + curSfx = nil + sfxIds = nil + -- A replaced def may claim a different set of channels. + if name then channelCounts[name] = nil else channelCounts = {} end + -- A mod that registers the raw name outright ends the alias hop, so the + -- memo has to be re-derived from the reloaded sfx table too. + if name then aliased[name] = nil else aliased = {} end local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index 3a1995a7..2d9dabf9 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -13,9 +13,22 @@ end -- screen.pushed/popped fire after enter/exit so listeners observe the -- settled state; the wants guard keeps the no-listener path allocation-free +-- enter/exit are OPTIONAL callbacks, so the test is "is it callable", not "is +-- it there". A state is an ordinary table and `exit` is an ordinary word: the +-- Gen 2 GameFreak screen counts its 16-frame exit tail in a field, and under a +-- truthiness test the stack called into a number and took the process down at +-- a screen hand-off. Reserving the names is still the contract (see +-- src/ui/gen2/GameFreakPresents.lua's exitTail), but the stack does not need +-- to be the thing that enforces it by crashing. +local function callback(state, name) + local fn = state and state[name] + return type(fn) == "function" and fn or nil +end + function StateStack:push(state, ...) table.insert(self.states, state) - if state.enter then state:enter(...) end + local enter = callback(state, "enter") + if enter then enter(state, ...) end if Runtime.wants("screen.pushed") then Runtime.emit("screen.pushed", { state = state }) end @@ -23,7 +36,8 @@ end function StateStack:pop() local state = table.remove(self.states) - if state and state.exit then state:exit() end + local exit = callback(state, "exit") + if exit then exit(state) end if state and Runtime.wants("screen.popped") then Runtime.emit("screen.popped", { state = state }) end @@ -34,6 +48,15 @@ function StateStack:top() return self.states[#self.states] end +-- Tear the whole stack down top-first, so every state still gets its exit and +-- every listener still sees screen.popped in the order it would have on a +-- hand-written unwind. Gold's boot cinema hands off between screens this way +-- (title -> intro menu -> Oak) and the Gen 1 paths that did +-- `while self.stack:top() do self.stack:pop() end` mean exactly this. +function StateStack:clear() + while self:top() do self:pop() end +end + function StateStack:update(dt) local top = self:top() if top and top.update then top:update(dt) end diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index a2e3b22b..0fd3fc2e 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -12,6 +12,17 @@ -- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it -- off everywhere. -- +-- BOTH GENERATIONS, one module. Red/Blue/Yellow reach it from +-- src/core/Game.lua and Gold from src/core/Game2.lua, through the same six +-- seams in the same order: init + applyOptions at boot, touchpressed / +-- touchmoved / touchreleased ahead of the mod pointer hook (the pad keeps +-- first refusal, #807), noteGamepad on any controller input, joystickremoved +-- when the last pad goes away, reset on focus/visibility loss, and draw as the +-- last thing in the frame -- after the post passes, so the controls are never +-- inside the CRT/GBC grid the picture is being shown through. One +-- options.touchControls block serves both games, so a layout edited in the +-- launcher's editor is the layout Gold draws. +-- -- Player preferences (options.touchControls) can permanently disable the -- overlay and/or override per-control positions as normalized window -- fractions. Positions and a size multiplier are stored per orientation @@ -589,9 +600,10 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end --- Screen-space, called by Game:draw after Renderer:endFrame so the --- overlay rides on top of everything (world, UI, CRT/GBC FX included). --- Also used by the launcher layout editor under preview mode. +-- Screen-space, called by Game:draw after Renderer:endFrame -- and by +-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top +-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher +-- layout editor under preview mode. function TouchControls:draw() if not self:visible() then return end local L = self:layout() diff --git a/src/core/gen2/Apricorns.lua b/src/core/gen2/Apricorns.lua new file mode 100644 index 00000000..b1fc7e6e --- /dev/null +++ b/src/core/gen2/Apricorns.lua @@ -0,0 +1,522 @@ +-- Kurt, the apricorns, the trees they grow on, and the day he takes to turn +-- one into a ball. +-- +-- Gold has no engine/events/kurt.asm: the whole conversation is map script +-- bytecode in maps/KurtsHouse.asm, and the only compiled routines behind it are +-- Kurt_SelectApricorn (engine/menus/menu_2.asm), the ApricornBalls table +-- (data/items/apricorn_balls.asm) and the SelectApricornForKurt special +-- (engine/events/specials.asm), which is the one that actually takes the +-- apricorn out of the bag. So this module is those three plus the clock the +-- script leans on, and nothing else: the dialogue belongs to the extracted +-- script and stays there. +-- +-- The trees are the other half. engine/events/fruit_trees.asm is a whole +-- FruitTreeScript in the ROM already; what it needs from the port is the +-- FruitTreeItems lookup, the per-tree picked flag and the daily reset that +-- refills every tree at once. Seven of the thirty trees are apricorn trees, +-- which is why they live in this file rather than in a fruit module of their +-- own. +-- +-- THE DAY-LONG WAIT is the piece most likely to be got wrong. Kurt does not +-- run a timer of his own. `setflag ENGINE_KURT_MAKING_BALLS` sets bit 0 of +-- wDailyFlags1, and the ONLY thing that ever clears it is CheckDailyResetTimer +-- wiping wDailyFlags1 and wDailyFlags2 whole once a day has passed +-- (engine/overworld/time.asm). So "come back tomorrow" means "come back after +-- the next daily rollover", which can be twenty-three hours or one minute +-- depending on when you handed the apricorn over -- and a player who winds the +-- clock BACK gets the rollover immediately, because _CalcDaysSince wraps a +-- negative difference into a large positive one instead of clamping it. The +-- cart does nothing to stop that, and neither does this. +-- +-- The RTC helpers themselves are in src/core/gen2/BugContest.lua, which is the +-- port's only second-resolution consumer of the same engine/overworld/time.asm +-- block; both belong in a src/core/gen2/Time.lua once one exists. + +local BugContest = require("src.core.gen2.BugContest") +local Runtime = require("src.mods.Runtime") + +local Apricorns = {} + +-- ------------------------------------------------------- apricorns and balls +-- +-- data/items/apricorn_balls.asm, in table order. That order is load bearing +-- twice over: FindApricornsInBag walks it to build Kurt's menu, so the menu is +-- always red, blue, yellow, green, white, black, pink regardless of pack +-- order, and Kurt1's checkevent chain in maps/KurtsHouse.asm tests the seven +-- EVENT_GAVE_KURT_*_APRICORN flags in the same order, so a save that somehow +-- held two of them would hand back the earlier ball first. +-- +-- The event ids are constants/event_flags.asm indices, which is what the +-- extracted script's `checkevent` / `setevent` / `clearevent` carry. They are +-- NOT the count of `const` lines above them: `const_next 600` two lines before +-- EVENT_GAVE_KURT_RED_APRICORN jumps the counter, which is why the block sits +-- at 600 rather than at the 237 a reader who counted would arrive at. The +-- extracted Kurt1 script agrees -- its red-apricorn arm is `setevent 600`. +Apricorns.BALLS = { + { apricorn = "RED_APRICORN", ball = "LEVEL_BALL", event = 600 }, + { apricorn = "BLU_APRICORN", ball = "LURE_BALL", event = 601 }, + { apricorn = "YLW_APRICORN", ball = "MOON_BALL", event = 602 }, + { apricorn = "GRN_APRICORN", ball = "FRIEND_BALL", event = 603 }, + { apricorn = "WHT_APRICORN", ball = "FAST_BALL", event = 604 }, + { apricorn = "BLK_APRICORN", ball = "HEAVY_BALL", event = 605 }, + { apricorn = "PNK_APRICORN", ball = "LOVE_BALL", event = 606 }, +} + +-- ENGINE_KURT_MAKING_BALLS, constants/engine_flags.asm index 79, backed by +-- wDailyFlags1 bit DAILYFLAGS1_KURT_MAKING_BALLS_F. +Apricorns.ENGINE_KURT_MAKING_BALLS = 79 + +-- Every ENGINE_* id that lives in wDailyFlags1 or wDailyFlags2, in order, so +-- the daily reset can clear the lot the way `ld [hli], a / ld [hl], a` over the +-- two bytes does. Named as well as numbered because the port's save keeps +-- engine flags in a sparse table keyed by the script's numeric id, and a +-- reader of this list should not have to count constants to know what it just +-- wiped. +Apricorns.DAILY_ENGINE_FLAGS = { + { id = 79, name = "ENGINE_KURT_MAKING_BALLS" }, + { id = 80, name = "ENGINE_DAILY_BUG_CONTEST" }, + { id = 81, name = "ENGINE_SWARM" }, + { id = 82, name = "ENGINE_TIME_CAPSULE" }, + { id = 83, name = "ENGINE_ALL_FRUIT_TREES" }, + { id = 84, name = "ENGINE_GOT_SHUCKIE_TODAY" }, + { id = 85, name = "ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED" }, + { id = 86, name = "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY" }, + { id = 87, name = "ENGINE_MT_MOON_SQUARE_CLEFAIRY" }, + { id = 88, name = "ENGINE_UNION_CAVE_LAPRAS" }, + { id = 89, name = "ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT" }, + { id = 90, name = "ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN" }, + { id = 91, name = "ENGINE_DAISYS_GROOMING" }, + { id = 92, name = "ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT" }, +} + +-- ENGINE_ALL_FRUIT_TREES, the wDailyFlags1 bit TryResetFruitTrees tests before +-- it will refill the trees. +Apricorns.ENGINE_ALL_FRUIT_TREES = 83 + +local BY_APRICORN, BY_BALL = {}, {} +for index, row in ipairs(Apricorns.BALLS) do + row.index = index + BY_APRICORN[row.apricorn] = row + BY_BALL[row.ball] = row +end + +-- ------------------------------------------------------------- the registry +-- +-- The `apricorns` registry (src/mods/Schemas.lua), one of the Gen 2-only six: +-- Red has no Kurt and no apricorn balls, so the name is gated under Gen 1 and +-- routed to data.gen2Apricorns under Gen 2. src/mods/Builtins.lua seeds it +-- with the seven rows above, engine-owned, keyed by the apricorn item -- what +-- the player hands over and what FindApricornsInBag walks the bag for. +-- +-- The three lookups below are rebuilt from the merged table when there is one, +-- so a registered row reaches Kurt's menu (Apricorns.inBag walks BALLS in +-- table order, which is why `index` is a field and the rebuild sorts by it), +-- the ball he hands back and the apricorn test the bag uses. With no loader +-- the module's own rows stand, which is what every headless test gets. +local function rebuildLookups(rows) + local ordered = {} + for _, row in pairs(rows) do + if type(row) == "table" and row.apricorn and row.ball then + ordered[#ordered + 1] = row + end + end + table.sort(ordered, function(a, b) + if (a.index or 0) ~= (b.index or 0) then + return (a.index or 0) < (b.index or 0) + end + return tostring(a.apricorn) < tostring(b.apricorn) + end) + Apricorns.BALLS, BY_APRICORN, BY_BALL = ordered, {}, {} + for _, row in ipairs(ordered) do + BY_APRICORN[row.apricorn] = row + BY_BALL[row.ball] = row + end + return #ordered +end + +-- vanilla registrations, engine-owned +function Apricorns.registerInto(registry, _, owner) + for _, row in ipairs(Apricorns.BALLS) do + registry:register(row.apricorn, row, owner) + end + return #Apricorns.BALLS +end + +-- the merged table, folded into the three lookups; nil restores nothing (the +-- module's rows are already standing) +function Apricorns.useRegistry(data) + local rows = data and data.gen2Apricorns + if type(rows) ~= "table" then return 0 end + return rebuildLookups(rows) +end + +function Apricorns.row(apricorn) return BY_APRICORN[apricorn] end +function Apricorns.isApricorn(item) return BY_APRICORN[item] ~= nil end + +function Apricorns.ballFor(apricorn) + local row = BY_APRICORN[apricorn] + return row and row.ball or nil +end + +function Apricorns.apricornFor(ball) + local row = BY_BALL[ball] + return row and row.apricorn or nil +end + +-- ------------------------------------------------------------- the save shape +-- +-- Kurt keeps NO state of his own. Everything he reads is already in the two +-- tables the extracted script writes: +-- +-- save.events wEventFlags, the EVENT_GAVE_KURT_*_APRICORN ids above +-- and EVENT_KURT_GAVE_YOU_LURE_BALL (53) for the free one +-- save.engineFlags ENGINE_KURT_MAKING_BALLS (79) +-- +-- and the one thing that is genuinely new: +-- +-- save.dailyReset { remaining = 1, day = <wCurDay> } +-- wDailyResetTimer and the start day beside it +-- save.fruitTrees { [FRUITTREE_*] = true } for a tree already picked today +-- +-- so nothing below invents a parallel copy of a flag the script can also see. +-- +-- save.events is the SERIALIZED BITFIELD src/world/gen2/Events.lua writes -- +-- byte index -> byte value, the shape wEventFlags has in SRAM -- and not a set +-- of ids, which is why the two helpers below do the byte and bit arithmetic +-- rather than indexing it. While the game is running the live copy is +-- world.events and the save's is only refreshed on a write, so anything that +-- has to flip one of these flags MID PLAY goes through the script's own +-- `setevent` (Kurt's does): these two are for a save file at rest. +local FLAGS_PER_BYTE = 8 + +local function events(save) + if type(save) ~= "table" then return nil end + save.events = save.events or {} + return save.events +end + +local function engineFlags(save) + if type(save) ~= "table" then return nil end + save.engineFlags = save.engineFlags or {} + return save.engineFlags +end + +function Apricorns.event(save, id) + local flags = events(save) + if not (flags and id) then return false end + local byte = flags[math.floor(id / FLAGS_PER_BYTE)] or 0 + return math.floor(byte / 2 ^ (id % FLAGS_PER_BYTE)) % 2 == 1 +end + +function Apricorns.setEvent(save, id, value) + local flags = events(save) + if not (flags and id) then return end + local index = math.floor(id / FLAGS_PER_BYTE) + local mask = 2 ^ (id % FLAGS_PER_BYTE) + local byte = flags[index] or 0 + local set = math.floor(byte / mask) % 2 == 1 + if value and not set then + flags[index] = byte + mask + elseif not value and set then + flags[index] = byte - mask + end +end + +-- ------------------------------------------------------ Kurt_SelectApricorn +-- +-- FindApricornsInBag walks ApricornBalls and appends every apricorn the pack +-- holds, then appends a 0 for the CANCEL row -- so the list Kurt shows is +-- always in table order and always ends in CANCEL. Its `scf` return is the +-- "you have none" case: a count of exactly 1 means nothing but CANCEL, and the +-- script's `ifequal FALSE` treats that the same as backing out. +-- +-- `inventory` is the flat item -> count map the port's bag uses. +function Apricorns.bagList(inventory) + inventory = inventory or {} + local list = {} + for _, row in ipairs(Apricorns.BALLS) do + if (inventory[row.apricorn] or 0) > 0 then + list[#list + 1] = row.apricorn + end + end + -- The CANCEL row is part of the cart's list, not a decoration the menu adds: + -- wKurtApricornCount counts it, and the menu's last entry is item 0. + list.cancel = #list + 1 + list.empty = #list == 0 + return list +end + +-- What the SelectApricornForKurt special leaves in wScriptVar: the chosen +-- apricorn, or FALSE. `choice` is 1-based over the list bagList returned, and +-- the CANCEL row is `list.cancel`. +function Apricorns.select(inventory, choice) + local list = Apricorns.bagList(inventory) + if list.empty then return nil end + if not choice or choice >= list.cancel then return nil end + return list[choice] +end + +-- The rest of SelectApricornForKurt, which the map script does NOT do and +-- which is easy to miss because it is in the special rather than in the +-- bytecode: the chosen apricorn is tossed out of the bag, one unit, before the +-- script ever sets its event. +-- +-- ld [wCurItem], a / ld a, 1 / ld [wItemQuantityChange], a +-- ld hl, wNumItems / call TossItem +function Apricorns.takeApricorn(save, apricorn) + if not (save and BY_APRICORN[apricorn]) then return false end + local inventory = save.inventory or {} + save.inventory = inventory + local have = inventory[apricorn] or 0 + if have <= 0 then return false end + have = have - 1 + inventory[apricorn] = have > 0 and have or nil + return true +end + +-- The whole handover, as one call: take the apricorn, set that colour's event, +-- and set ENGINE_KURT_MAKING_BALLS. The two script lines this stands in for +-- are `setevent EVENT_GAVE_KURT_<colour>_APRICORN` and +-- `setflag ENGINE_KURT_MAKING_BALLS`, in .GaveKurtApricorns. +function Apricorns.give(save, apricorn) + local row = BY_APRICORN[apricorn] + if not row then return false end + if not Apricorns.takeApricorn(save, apricorn) then return false end + Apricorns.setEvent(save, row.event, true) + local flags = engineFlags(save) + if flags then flags[Apricorns.ENGINE_KURT_MAKING_BALLS] = true end + return true, row.ball +end + +-- Which apricorn Kurt currently has, in the order Kurt1 tests the events. He +-- takes exactly ONE at a time: the .AskApricorn branch is only reachable when +-- every one of the seven events is clear. +function Apricorns.pending(save) + for _, row in ipairs(Apricorns.BALLS) do + if Apricorns.event(save, row.event) then + return row.apricorn, row.ball + end + end + return nil +end + +function Apricorns.isWorking(save) + local flags = engineFlags(save) + return (flags and flags[Apricorns.ENGINE_KURT_MAKING_BALLS]) == true +end + +-- .GiveLevelBall and its six siblings all open with +-- `checkflag ENGINE_KURT_MAKING_BALLS / iftrue .KurtMakingBallsScript`, so the +-- ball is ready exactly when he has an apricorn and the daily flag has rolled +-- over. +function Apricorns.readyBall(save) + if Apricorns.isWorking(save) then return nil end + local _, ball = Apricorns.pending(save) + return ball +end + +-- `verbosegiveitem <BALL> / iffalse .NoRoomForBall / clearevent +-- EVENT_GAVE_KURT_<colour>_APRICORN`. The clear happens only after the ball +-- actually lands in the pack, which is why a full pack leaves Kurt holding it +-- and this returns the ball WITHOUT clearing on a refusal. +function Apricorns.collect(save) + local ball = Apricorns.readyBall(save) + if not ball then return nil end + local apricorn = Apricorns.apricornFor(ball) + local row = BY_APRICORN[apricorn] + Apricorns.setEvent(save, row.event, false) + -- apricorn.converted, a Gen 2 invention: Gen 1 has no Kurt and no apricorn, + -- so there is no name to share. Raised on the handover rather than on + -- Apricorns.give, because the apricorn only becomes a ball once the daily + -- rollover has run and the ball has actually landed in the pack -- a full + -- pack leaves Kurt holding it and this function is not reached at all. + -- + -- apricorn the APRICORN_* item that went in + -- ball the BALL item that came out + -- event the EVENT_GAVE_KURT_*_APRICORN flag just cleared + if Runtime.wants("apricorn.converted") then + Runtime.emit("apricorn.converted", + { apricorn = apricorn, ball = ball, event = row.event }) + end + return ball, apricorn +end + +-- --------------------------------------------------------- the daily rollover +-- +-- RestartDailyResetTimer / InitOneDayCountdown: one day, counted from today. +function Apricorns.startDailyResetTimer(save, now) + if type(save) ~= "table" then return nil end + local stamp = now or BugContest.now() + save.dailyReset = { remaining = 1, day = stamp.day } + return save.dailyReset +end + +-- UpdateTimeRemaining: subtract the elapsed units from the counter, clamp at +-- zero, and set carry when it reaches zero. A delta of -1 (the "exceeds this +-- unit's range" sentinel GetTimeElapsed_ExceedsUnitLimit returns) zeroes it +-- outright. +local function updateTimeRemaining(remaining, elapsed) + if elapsed == -1 then return 0, true end + local left = remaining - elapsed + if left < 0 then left = 0 end + return left, left == 0 +end + +Apricorns.updateTimeRemaining = updateTimeRemaining + +-- CheckDailyResetTimer. CheckDayDependentEventHL walks past the counter to +-- the start day, takes the days since it -- ADVANCING the stored day to today +-- as it goes, which is what makes the counter decrement by "days since the +-- last poll" rather than by "days since the timer started" -- and then +-- UpdateTimeRemaining decides whether the day is up. +-- +-- When it is, wDailyFlags1 and wDailyFlags2 are cleared whole and the timer +-- restarts. Returns true on the frames the rollover actually happened. +function Apricorns.checkDailyResetTimer(save, now) + if type(save) ~= "table" then return false end + if not save.dailyReset then + Apricorns.startDailyResetTimer(save, now) + return false + end + local timer = save.dailyReset + local stamp = { day = timer.day } + local since = BugContest.elapsedSince(stamp, now, "day") + timer.day = stamp.day + local remaining, expired = updateTimeRemaining(timer.remaining or 1, + since.days) + timer.remaining = remaining + if not expired then return false end + Apricorns.dailyReset(save) + Apricorns.startDailyResetTimer(save, now) + return true +end + +-- `xor a / ld hl, wDailyFlags1 / ld [hli], a / ld [hl], a`: both bytes, every +-- bit, in one go. Kurt's ball being finished is a SIDE EFFECT of this and not +-- a thing anyone checks for -- which is also why finishing the Bug Contest +-- (ENGINE_DAILY_BUG_CONTEST) and refilling every fruit tree +-- (ENGINE_ALL_FRUIT_TREES) happen on the same tick. +function Apricorns.dailyReset(save) + local flags = engineFlags(save) + if not flags then return end + for _, row in ipairs(Apricorns.DAILY_ENGINE_FLAGS) do + flags[row.id] = nil + end + -- The port keeps a couple of these under names as well as ids + -- (src/script/gen2/Specials.lua's ActivateFishingSwarm writes + -- save.dailyFlags), so the same wipe has to reach that table. + save.dailyFlags = {} +end + +-- ---------------------------------------------------------------- the trees +-- +-- data/items/fruit_trees.asm, indexed by FRUITTREE_*. That enum opens +-- `const_def 1`, so it is ONE based and a 1-based Lua list lines up with it +-- exactly -- GetCurTreeFruit's `dec a` before GetFruitTreeItem is the cart +-- converting the same 1-based id into a 0-based offset, not evidence of a +-- 0-based table. +Apricorns.FRUIT_TREES = { + "BERRY", -- 01 FRUITTREE_ROUTE_29 + "BERRY", -- 02 FRUITTREE_ROUTE_30_1 + "BERRY", -- 03 FRUITTREE_ROUTE_38 + "BERRY", -- 04 FRUITTREE_ROUTE_46_1 + "PSNCUREBERRY", -- 05 FRUITTREE_ROUTE_30_2 + "PSNCUREBERRY", -- 06 FRUITTREE_ROUTE_33 + "BITTER_BERRY", -- 07 FRUITTREE_ROUTE_31 + "BITTER_BERRY", -- 08 FRUITTREE_ROUTE_43 + "PRZCUREBERRY", -- 09 FRUITTREE_VIOLET_CITY + "PRZCUREBERRY", -- 0a FRUITTREE_ROUTE_46_2 + "MYSTERYBERRY", -- 0b FRUITTREE_ROUTE_35 + "MYSTERYBERRY", -- 0c FRUITTREE_ROUTE_45 + "ICE_BERRY", -- 0d FRUITTREE_ROUTE_36 + "ICE_BERRY", -- 0e FRUITTREE_ROUTE_26 + "MINT_BERRY", -- 0f FRUITTREE_ROUTE_39 + "BURNT_BERRY", -- 10 FRUITTREE_ROUTE_44 + "RED_APRICORN", -- 11 FRUITTREE_ROUTE_37_1 + "BLU_APRICORN", -- 12 FRUITTREE_ROUTE_37_2 + "BLK_APRICORN", -- 13 FRUITTREE_ROUTE_37_3 + "WHT_APRICORN", -- 14 FRUITTREE_AZALEA_TOWN + "PNK_APRICORN", -- 15 FRUITTREE_ROUTE_42_1 + "GRN_APRICORN", -- 16 FRUITTREE_ROUTE_42_2 + "YLW_APRICORN", -- 17 FRUITTREE_ROUTE_42_3 + "BERRY", -- 18 FRUITTREE_ROUTE_11 + "PSNCUREBERRY", -- 19 FRUITTREE_ROUTE_2 + "BITTER_BERRY", -- 1a FRUITTREE_ROUTE_1 + "PRZCUREBERRY", -- 1b FRUITTREE_ROUTE_8 + "ICE_BERRY", -- 1c FRUITTREE_PEWTER_CITY_1 + "MINT_BERRY", -- 1d FRUITTREE_PEWTER_CITY_2 + "BURNT_BERRY", -- 1e FRUITTREE_FUCHSIA_CITY +} + +Apricorns.NUM_FRUIT_TREES = #Apricorns.FRUIT_TREES + +-- GetCurTreeFruit. +function Apricorns.treeFruit(tree) + return Apricorns.FRUIT_TREES[tree] +end + +local function treeFlags(save) + if type(save) ~= "table" then return nil end + save.fruitTrees = save.fruitTrees or {} + return save.fruitTrees +end + +-- TryResetFruitTrees, run at the TOP of FruitTreeScript, before the tree is +-- checked: if ENGINE_ALL_FRUIT_TREES is clear then every tree in the game +-- refills at once and the flag is set so it only happens once a day. It is +-- the daily reset above that clears the flag again. +function Apricorns.tryResetFruitTrees(save) + local flags = engineFlags(save) + if not flags then return false end + if flags[Apricorns.ENGINE_ALL_FRUIT_TREES] then return false end + save.fruitTrees = {} + flags[Apricorns.ENGINE_ALL_FRUIT_TREES] = true + return true +end + +-- CheckFruitTree's `ld b, 2 / GetFruitTreeFlag` is a CHECK_FLAG, and the +-- wScriptVar it leaves is TRUE for a tree already picked -- so the script's +-- `iffalse .fruit` reads "not picked yet, there is fruit here". +function Apricorns.treePicked(save, tree) + local flags = treeFlags(save) + return (flags and flags[tree]) == true +end + +-- PickedFruitTree's `ld b, 1` is a SET_FLAG. +function Apricorns.pickTree(save, tree) + local flags = treeFlags(save) + if not (flags and Apricorns.FRUIT_TREES[tree]) then return nil end + if flags[tree] then return nil end + flags[tree] = true + return Apricorns.FRUIT_TREES[tree] +end + +-- ------------------------------------------------------------- the module map +-- +-- Where each half is called from, now that all four have a call site: +-- +-- SelectApricornForKurt src/script/gen2/Specials.lua's handler: +-- Apricorns.bagList(save.inventory) builds +-- FindApricornsInBag's list, Apricorns.select(...) +-- reads the row the menu came back with, and +-- Apricorns.takeApricorn is the special's own +-- TossItem. It stops there ON PURPOSE: the setevent +-- and the setflag after it belong to +-- maps/KurtsHouse.asm, which the extractor has, so +-- doing them here as well would set them twice. +-- Apricorns.give is the same pair as one call for a +-- caller that has no script behind it. +-- Kurt1's .GiveXBall the extracted script's own `checkflag +-- ENGINE_KURT_MAKING_BALLS` / `verbosegiveitem` / +-- `clearevent`; Apricorns.readyBall and +-- Apricorns.collect are the same rule for a reader +-- holding nothing but a save file. +-- FruitTreeScript the VM's `fruittree` branch, through World's +-- fruitTreeItem / fruitTreeReset / fruitTreePicked / +-- fruitTreePick hooks: Apricorns.treeFruit, +-- Apricorns.tryResetFruitTrees, Apricorns.treePicked +-- and Apricorns.pickTree in that order. +-- CheckTimeEvents World:checkTimeEvents, once a frame off the +-- player-event chain: Apricorns.checkDailyResetTimer + +return Apricorns diff --git a/src/core/gen2/AutoInput.lua b/src/core/gen2/AutoInput.lua new file mode 100644 index 00000000..e4921412 --- /dev/null +++ b/src/core/gen2/AutoInput.lua @@ -0,0 +1,313 @@ +-- Gen 2 automated joypad input: home/joypad.asm (GetJoypad's .auto arm, +-- StartAutoInput, StopAutoInput). +-- +-- While a stream is armed the cart stops looking at the joypad entirely. +-- GetJoypad branches on wInputType == AUTO_INPUT before it ever reads +-- hJoypadDown, and writes hJoyDown / hJoyPressed straight out of the stream, +-- so anything the player is physically holding is discarded until the stream +-- ends. That suppression is half the feature: the catching tutorial hands the +-- DUDE the controller, and a player mashing A must not be able to steer it. +-- +-- Stream format, quoting the asm: [input][duration], and an input of $ff ends +-- the stream immediately. A duration is the number of EXTRA frames the input +-- is held for (wAutoInputLength counts down, and only a zero count re-reads +-- the stream), so a duration of 0 means "one frame". A duration of $ff is the +-- odd one: it stores $ff, forces the input to NO_INPUT, and leaves +-- wAutoInputAddress pointing at the same pair -- the two `dec hl`s in that arm +-- are vestigial, because only the .next arm ever writes the address back. The +-- effect is "hold nothing forever", re-arming itself every 256 frames, which +-- is how every stream in the ROM parks at its end without releasing control. +-- +-- The port feeds the decoded frame through src/core/Input.lua the way +-- src/core/TouchControls.lua does, under its own source names, so the per +-- fixed-step edge detection in Input:step sees a real press and a real +-- release. Game2 steps this BEFORE Input:step for the same reason tool +-- mods run there: a button chosen this tick has to be visible to this +-- tick's logic, not the next one. + +local AutoInput = {} +AutoInput.__index = AutoInput + +-- constants/hardware.inc PAD_*. Same bit order as hJoypadDown. +local PAD_A = 0x01 +local PAD_B = 0x02 +local PAD_SELECT = 0x04 +local PAD_START = 0x08 +local PAD_RIGHT = 0x10 +local PAD_LEFT = 0x20 +local PAD_UP = 0x40 +local PAD_DOWN = 0x80 +local NO_INPUT = 0x00 + +AutoInput.PAD_A = PAD_A +AutoInput.PAD_B = PAD_B +AutoInput.PAD_RIGHT = PAD_RIGHT +AutoInput.PAD_DOWN = PAD_DOWN +AutoInput.NO_INPUT = NO_INPUT + +-- Bit -> the GB button name the rest of the engine uses. Ordered so a decoded +-- frame always presses in the same sequence, which keeps Input:step's queue +-- deterministic for the tests. +local BITS = { + { PAD_A, "a" }, + { PAD_B, "b" }, + { PAD_SELECT, "select" }, + { PAD_START, "start" }, + { PAD_RIGHT, "right" }, + { PAD_LEFT, "left" }, + { PAD_UP, "up" }, + { PAD_DOWN, "down" }, +} + +-- Lua 5.1 has no bitops in the base library and the engine targets LuaJIT +-- semantics, so the mask test is arithmetic: these are eight distinct single +-- bits, and a stream byte is always < 256. +local function held(mask, bit) + return math.floor(mask / bit) % 2 == 1 +end + +-- The four streams that exist in the ROM. Flat [input][duration] byte arrays, +-- transcribed rather than generated: the extractor emits only the bank and +-- pointer an `autoinput` command carries (Script_autoinput's three GetScriptByte +-- calls), never the bytes behind it. +AutoInput.STREAMS = { + -- engine/events/catch_tutorial.asm CatchTutorial.AutoInput: the DUDE's battle + -- is played entirely by the re-arms below, so the stream wrapped around + -- StartBattle only has to hold the player's own hands off the controller. + CATCH_TUTORIAL = { NO_INPUT, 0xff }, + -- engine/events/catch_tutorial_input.asm. PromptButton, the battle menu and + -- the pack re-arm one of these each time they want the DUDE to answer, which + -- is why the tutorial reads as a person playing rather than as a macro. + DUDE_A = { + NO_INPUT, 0x50, + PAD_A, 0x00, + NO_INPUT, 0xff, + }, + DUDE_RIGHT_A = { + NO_INPUT, 0x08, + PAD_RIGHT, 0x00, + NO_INPUT, 0x08, + PAD_A, 0x00, + NO_INPUT, 0xff, + }, + DUDE_DOWN_A = { + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + PAD_DOWN, 0x00, + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + NO_INPUT, 0xfe, + PAD_A, 0x00, + NO_INPUT, 0xff, + }, +} + +-- bank:address -> stream name, from ../pokegold-symbols/pokegold.sym. An +-- `autoinput` command names its stream by a `dba`, so this is how a script's +-- bank:pointer becomes bytes we actually have. Nothing else in the ROM can be +-- the target: StartAutoInput has exactly these four call sites. +AutoInput.POINTERS = { + ["08:79fc"] = "CATCH_TUTORIAL", + ["70:4dfe"] = "DUDE_A", + ["70:4e04"] = "DUDE_RIGHT_A", + ["70:4e0e"] = "DUDE_DOWN_A", +} + +function AutoInput.new() + return setmetatable({ + -- wAutoInputAddress, as an index into `bytes` + pos = 1, + -- wAutoInputLength + length = 0, + bytes = nil, + active = false, + -- hJoyDown's current value, kept so a frame inside a duration can leave the + -- mirrors alone the way the .quit arm does + current = NO_INPUT, + }, AutoInput) +end + +function AutoInput:isActive() + return self.active +end + +-- StartAutoInput. `stream` is a stream name from AutoInput.STREAMS or a raw +-- byte array; `input` is src/core/Input.lua, whose mirrors are cleared here the +-- way StartAutoInput clears hJoyPressed / hJoyReleased / hJoyDown, so a button +-- the player was holding when the stream armed does not leak into it. +function AutoInput:start(stream, input) + local bytes = stream + if type(stream) == "string" then bytes = AutoInput.STREAMS[stream] end + if type(bytes) ~= "table" or bytes[1] == nil then return false end + self.bytes = bytes + self.pos = 1 + -- "Start reading the stream immediately": a zero length makes the very next + -- step take the .updateauto arm. + self.length = 0 + self.current = NO_INPUT + self.active = true + -- Frame pace unless the caller asks for poll pace; see skipIdle. + self.pollPaced = nil + if input and input.reset then input:reset() end + return true +end + +-- Play the armed stream at POLL pace instead of frame pace: every pair that +-- presses nothing is skipped, so only the buttons are left, one per step. +-- +-- This is a PORT correction, not something the cart does, and it is only for +-- the streams a menu consumes. The cart advances the stream once per GetJoypad +-- call, and a menu's wait loop calls GetJoypad with no frame delay at all +-- (engine/menus/menu.asm `.loopRTC`, engine/items/pack.asm's own loop), so +-- DudeAutoInput_DownA's four `NO_INPUT, $fe` runs are loop iterations there and +-- are gone in a frame or two. This port polls once per fixed step, where the +-- same runs would be 1020 steps of the DUDE staring at the battle menu. The +-- presses and their ORDER -- which is all those streams encode -- are untouched. +-- +-- PromptButton's own loop DOES delay a frame per iteration, so DUDE_A is left +-- frame-paced and its 0x51 blank frames are the real beat between two lines. +-- +-- A `$ff` duration is never skipped: that pair is the stream parking itself, +-- not a pause before a press. +function AutoInput:skipIdle() + self.pollPaced = true + if not self.active then return false end + local skipped = self:dropIdlePairs() + -- A zero length is what makes the next step re-read the stream. + self.length = 0 + self.current = NO_INPUT + return skipped +end + +function AutoInput:dropIdlePairs() + local bytes = self.bytes or {} + local skipped = false + while true do + local value = bytes[self.pos] + local duration = bytes[self.pos + 1] + if value ~= NO_INPUT or duration == nil or duration == 0xff then break end + self.pos = self.pos + 2 + skipped = true + end + return skipped +end + +-- Script_autoinput's `dba`: bank first, then the 16-bit address. +function AutoInput:startPointer(bank, address, input) + local key = string.format("%02x:%04x", bank or 0, address or 0) + local name = AutoInput.POINTERS[key] + if not name then + -- The bytes are not in the cache and the pointer is not one of the ROM's + -- own streams, so there is nothing to replay. Recorded rather than + -- guessed: arming an invented stream would take the controller away from + -- the player with no way to hand it back. + self.unknownPointer = key + return false + end + return self:start(name, input) +end + +-- StopAutoInput. Clears the stream and puts wInputType back to normal input; +-- Input:reconcile is the port's equivalent of GetJoypad going back to reading +-- hJoypadDown, i.e. a key the player is still physically holding is down again +-- on the very next step rather than waiting for a fresh keypress event. +function AutoInput:stop(input) + self.bytes = nil + self.pos = 1 + self.length = 0 + self.current = NO_INPUT + local wasActive = self.active + self.active = false + self.restorePending = nil + if wasActive and input then + if input.reset then input:reset() end + if input.reconcile then input:reconcile() end + end + return wasActive +end + +-- One GetJoypad .auto pass. Returns the pad mask for this frame, and true as +-- a second value on the frame the stream ended: .stopauto calls StopAutoInput +-- from inside GetJoypad, so the ring is disarmed here rather than by the +-- caller, and the handback to the real pad is left for the step after. +function AutoInput:advance() + if not self.active then return NO_INPUT end + -- "We only update when the input duration has expired." + if self.length ~= 0 then + self.length = self.length - 1 + return self.current + end + -- A poll-paced stream drops the blank pairs BETWEEN its presses as well as + -- the ones in front of them: on the cart the loop consuming it burns through + -- both at the same speed. See skipIdle. + if self.pollPaced then self:dropIdlePairs() end + local bytes = self.bytes or {} + local value = bytes[self.pos] + -- "An input of $ff will end the stream." A stream that runs off its own end + -- is malformed data rather than something the ROM can produce, and is + -- treated as the terminator so control still comes back. + if value == nil or value == 0xff then + self:stop() + self.restorePending = true + return NO_INPUT, true + end + local duration = bytes[self.pos + 1] + if duration == nil then + self:stop() + self.restorePending = true + return NO_INPUT, true + end + self.length = duration + if duration == 0xff then + -- "A duration of $ff will end the stream indefinitely": the current input + -- is overwritten and the address is left pointing at this same pair. + value = NO_INPUT + else + self.pos = self.pos + 2 + end + self.current = value + return value +end + +-- Called once per fixed step, before Input:step. Presses this frame's buttons +-- through the same per-source bookkeeping the touch overlay and mod input use. +-- Returns true while the stream owns the controller. +function AutoInput:step(input) + if not self.active then + -- The terminator frame below still belonged to the stream, so the handback + -- lands here, one step later: that is the frame GetJoypad would first read + -- hJoypadDown again. Doing it on the terminator frame itself would let a + -- key the player was leaning on register a press the cart never saw. + if self.restorePending then + self.restorePending = nil + if input then + if input.reset then input:reset() end + if input.reconcile then input:reconcile() end + end + end + return false + end + local mask = self:advance() + if input then + -- GetJoypad overwrites the mirrors outright in this arm, so every other + -- source is dropped for the frame. Re-pressing each held button every + -- step is deliberate: hJoyPressed is written once per stream update and + -- then left latched for the whole duration, so an auto-held A really does + -- read as pressed on every frame it covers. + if input.reset then input:reset() end + for _, entry in ipairs(BITS) do + if held(mask, entry[1]) then + input:sourcePress(entry[2], "auto:" .. entry[2]) + end + end + end + -- .stopauto has already disarmed the ring inside advance; the frame the + -- terminator lands on is still an auto frame (NO_INPUT into the mirrors), + -- and the step after it is the player's. + return true +end + +return AutoInput diff --git a/src/core/gen2/Boxes.lua b/src/core/gen2/Boxes.lua new file mode 100644 index 00000000..9cbaf746 --- /dev/null +++ b/src/core/gen2/Boxes.lua @@ -0,0 +1,165 @@ +-- Gen 2 storage system: 14 boxes of 20, the party<->box moves the PC does, and +-- the default BOX1..BOX14 names. +-- +-- The save already carries `boxes`, `boxNames` and `currentBox` +-- (src/core/gen2/Save.lua); this is the logic that operates on them, kept out +-- of the UI so a deposit is testable without a screen. +-- +-- Rules taken from engine/pokemon/bills_pc.asm: +-- * the PC cannot be opened with an empty party (.CheckCanUsePC) +-- * DEPOSIT refuses to send the last healthy party mon away, because a party +-- of nothing whites you out on the next step +-- * WITHDRAW refuses once the party is full +-- * a box holds MONS_PER_BOX and no more +-- * DEPOSIT refuses a mon holding MAIL, because sPartyMail has six slots and +-- a boxed mon has none of them (src/core/gen2/Mail.lua) + +local Mail = require("src.core.gen2.Mail") +local Save = require("src.core.gen2.Save") + +local Boxes = {} + +Boxes.NUM_BOXES = Save.NUM_BOXES +Boxes.MONS_PER_BOX = Save.MONS_PER_BOX +Boxes.PARTY_SIZE = Save.PARTY_SIZE + +-- SetDefaultBoxNames (engine/menus/intro_menu.asm): "BOX" then 1..14. +function Boxes.defaultName(index) + return "BOX" .. tostring(index) +end + +function Boxes.name(save, index) + local names = save and save.boxNames + local given = names and names[index] + if type(given) == "string" and given ~= "" then return given end + return Boxes.defaultName(index) +end + +function Boxes.rename(save, index, name) + if not save or not index then return false end + if index < 1 or index > Boxes.NUM_BOXES then return false end + save.boxNames = save.boxNames or {} + save.boxNames[index] = name + return true +end + +-- The box's mon list, created on demand so a fresh save carries 14 empty +-- tables only once one is actually used. +function Boxes.box(save, index) + if not save then return {} end + index = index or save.currentBox or 1 + if index < 1 or index > Boxes.NUM_BOXES then return {} end + save.boxes = save.boxes or {} + save.boxes[index] = save.boxes[index] or {} + return save.boxes[index] +end + +function Boxes.count(save, index) + return #Boxes.box(save, index) +end + +function Boxes.isFull(save, index) + return Boxes.count(save, index) >= Boxes.MONS_PER_BOX +end + +function Boxes.setCurrent(save, index) + if not save or index < 1 or index > Boxes.NUM_BOXES then return false end + save.currentBox = index + return true +end + +-- How many party members could still fight. DEPOSIT checks this, not the raw +-- party count: a party of one fainted mon plus one healthy one may not send +-- the healthy one to a box. +function Boxes.healthyCount(party) + local n = 0 + for _, mon in ipairs(party or {}) do + if (mon.hp or 0) > 0 then n = n + 1 end + end + return n +end + +-- Returns true, or false plus a reason string the caller shows in a text box. +function Boxes.canDeposit(save, partyIndex, boxIndex) + if not save then return false, "No save." end + local mon = save.party and save.party[partyIndex] + if not mon then return false, "There is no POKéMON there." end + if Boxes.isFull(save, boxIndex) then + return false, "The BOX is full." + end + if (mon.hp or 0) > 0 and Boxes.healthyCount(save.party) <= 1 then + return false, "You can't deposit\nthe last POKéMON!" + end + -- BillsPC_CheckMon's .HasMail arm (engine/pokemon/bills_pc.asm), which reads + -- the wBillsPC_MonHasMail byte PCMonInfo set while drawing the row. It is + -- checked AFTER the last-healthy rule and prints PCString_RemoveMail, which + -- is one short line rather than a two-line refusal: a boxed mon's letter has + -- nowhere to live, because sPartyMail is six structs keyed by party slot. + if Mail.monHoldsMail(mon) then + return false, "Remove MAIL." + end + return true +end + +function Boxes.deposit(save, partyIndex, boxIndex) + local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex) + if not ok then return false, reason end + local mon = table.remove(save.party, partyIndex) + -- RemoveMonFromPartyOrBox's "Mail time!" tail: sPartyMail is keyed by SLOT, + -- so every letter after the departing mon moves up one. The mon leaving + -- here never has mail of its own (canDeposit just refused that), but the + -- ones behind it may. + Mail.removeSlot(save, partyIndex) + local box = Boxes.box(save, boxIndex) + box[#box + 1] = mon + return true, mon +end + +function Boxes.canWithdraw(save, boxIndex, slot) + if not save then return false, "No save." end + local box = Boxes.box(save, boxIndex) + if not box[slot] then return false, "There is no POKéMON there." end + if #(save.party or {}) >= Boxes.PARTY_SIZE then + return false, "You can't take\nany more POKéMON." + end + return true +end + +function Boxes.withdraw(save, boxIndex, slot) + local ok, reason = Boxes.canWithdraw(save, boxIndex, slot) + if not ok then return false, reason end + local mon = table.remove(Boxes.box(save, boxIndex), slot) + save.party = save.party or {} + save.party[#save.party + 1] = mon + return true, mon +end + +-- RELEASE from a box. The cart lets you release anything in storage; the +-- party's last-healthy rule does not apply because a boxed mon is never in it. +function Boxes.release(save, boxIndex, slot) + local box = Boxes.box(save, boxIndex) + if not box[slot] then return false, "There is no POKéMON there." end + return true, table.remove(box, slot) +end + +-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case). +function Boxes.move(save, fromBox, slot, toBox) + if fromBox == toBox then return false, "It's already there." end + local source = Boxes.box(save, fromBox) + if not source[slot] then return false, "There is no POKéMON there." end + if Boxes.isFull(save, toBox) then return false, "The BOX is full." end + local mon = table.remove(source, slot) + local target = Boxes.box(save, toBox) + target[#target + 1] = mon + return true, mon +end + +-- .CheckCanUsePC: "You'll need a POKéMON to call with." +function Boxes.canUsePc(save) + if not (save and save.party and #save.party > 0) then + return false, "You'll need a\nPOKéMON to call\nwith." + end + return true +end + +return Boxes diff --git a/src/core/gen2/Breeding.lua b/src/core/gen2/Breeding.lua new file mode 100644 index 00000000..25a82de5 --- /dev/null +++ b/src/core/gen2/Breeding.lua @@ -0,0 +1,1138 @@ +-- Gen 2 Day-Care, breeding and eggs: the whole model, love-free. +-- +-- Sits beside src/core/gen2/Boxes.lua and src/core/gen2/Evolution.lua for the +-- same reason they are separate from their screens: every question the Day-Care +-- conversation asks is table math over data/generated/pokemon.lua's +-- `eggGroups`, `eggSteps`, `genderRatio`, `evolutions` and `levelMoves` rows, +-- so tests/gen2_breeding_test.lua can deposit two mons, walk 5120 steps and +-- hatch an egg with no window open. src/ui/gen2/DayCareMenu.lua is the only +-- half that draws. +-- +-- Ported from: +-- engine/events/daycare.asm DayCareMan / DayCareLady (the whole +-- conversation), DayCareAskDepositPokemon, +-- GetPriceToRetrieveBreedmon, DayCareGiveEgg, +-- DayCareManOutside and DayCare_InitBreeding +-- (which is where the egg is actually built) +-- engine/pokemon/breeding.asm CheckBreedmonCompatibility, DoEggStep, +-- HatchEggs, InitEggMoves, GetEggMove, +-- LoadEggMove, GetHeritableMoves, +-- GetBreedmonMovePointer and +-- DayCareMonCompatibilityText +-- engine/events/happiness_egg.asm DayCareStep: the +1 exp per step and the +-- wStepsToEgg countdown that rolls the egg +-- engine/pokemon/move_mon.asm DepositBreedmon / RetrieveBreedmon +-- engine/pokemon/breedmon_level_growth.asm GetBreedMon1LevelGrowth +-- engine/overworld/events.asm the step block that orders DoEggStep +-- against DayCareStep +-- +-- A hatched mon is built by src/battle/gen2/Mon.lua and nothing else, the same +-- rule Evolution.apply follows: Breeding.hatch hands Mon.new the egg's species, +-- level, DVs and moves and lets the one builder produce the record, so a +-- hatchling can never end up with the half-filled shape a second builder gives. +-- +-- Two data fields this reads that the extractor does not write yet: +-- def.eggMoves data/pokemon/egg_moves.asm, one list of move ids per +-- species. Absent from the cache today; every reader below +-- falls back to an empty list, which costs exactly the +-- "father passes an egg move" branch of GetEggMove and +-- nothing else. It starts working the moment the field +-- appears -- see the test's cache block. +-- (def.tmhm, def.eggGroups, def.eggSteps, def.genderRatio and def.evolutions +-- are all already in data/generated/pokemon.lua.) + +local Mail = require("src.core.gen2.Mail") +local Mon = require("src.battle.gen2.Mon") +local Runtime = require("src.mods.Runtime") + +local Breeding = {} + +-------------------------------------------------------------------------- +-- Constants +-------------------------------------------------------------------------- + +-- constants/battle_constants.asm: an egg hatches at level 5. +Breeding.EGG_LEVEL = 5 + +-- HatchEggs' `ld [hl], $78`: the hatch counter's byte becomes the hatchling's +-- happiness, and $78 is 120 -- not BASE_HAPPINESS (70), which is what a caught +-- mon gets. A hatchling really does start friendlier than a catch. +Breeding.HATCH_HAPPINESS = 0x78 + +-- DayCare_InitBreeding's .String_EGG. An egg is a party member whose species +-- is already the hatchling's (the cart keeps it in the box struct and only +-- writes EGG into wPartySpecies), so the port marks the slot with `isEgg` +-- rather than overwriting the species -- see Breeding.isEgg. +Breeding.EGG_NAME = "EGG" + +-- constants/pokemon_data_constants.asm PARTY_LENGTH / NUM_MOVES. +Breeding.PARTY_SIZE = Mon.PARTY_SIZE +Breeding.NUM_MOVES = 4 +Breeding.MAX_LEVEL = Mon.MAX_LEVEL + +-- DayCare_InitBreeding's `.loop: call Random / cp 150 / jr c, .loop` -- a +-- rejection sample, so the first countdown is 150..255 steps and never less. +-- Every countdown AFTER that is a plain `call Random` (happiness_egg.asm +-- .check_egg), i.e. 0..255, which is why the first egg is reliably slower than +-- the ones that follow it. +Breeding.MIN_STEPS_TO_EGG = 150 + +-- engine/overworld/events.asm: wStepCount is a byte, StepHappiness fires when +-- it wraps to 0 and DoEggStep when it reads $80 -- so both run once per 256 +-- steps, 128 steps out of phase with each other. +Breeding.STEP_CYCLE = 256 +Breeding.EGG_STEP_PHASE = 0x80 + +-- constants/misc_constants.asm MAX_DAY_CARE_EXP is $500000, but DayCareStep +-- only clamps the HIGH byte (`cp HIGH(MAX_DAY_CARE_EXP >> 8)` is `cp $50`) +-- once a carry reaches it, so the real ceiling is $50ffff. +Breeding.MAX_DAY_CARE_EXP = 0x50FFFF + +-- GetPriceToRetrieveBreedmon: `hl = 100 * levelsGrown` then `add hl, 100`. +Breeding.WITHDRAW_FEE = 100 +Breeding.WITHDRAW_FEE_PER_LEVEL = 100 + +-- The two species the routines name outright. +Breeding.DITTO = "DITTO" +Breeding.NIDORAN_F = "NIDORAN_F" +Breeding.NIDORAN_M = "NIDORAN_M" +Breeding.TOGEPI = "TOGEPI" + +-- constants/pokemon_data_constants.asm's egg-group enum. EGG_NONE is $f, and +-- a species with EGG_NONE in BOTH nibbles ($ff) is the "No Eggs" group that +-- .CheckBreedingGroupCompatibility refuses before it looks at anything else. +Breeding.EGG_NONE = "EGG_NONE" +Breeding.NO_EGGS_RAW = 0xFF + +-- wDayCareMan / wDayCareLady are two separate bytes, so the two sides carry +-- their own HAS_MON and their own DAYCARE_INTRO_SEEN_F. MONS_COMPATIBLE_F and +-- HAS_EGG_F live only on the man's byte, which is why both are on the shared +-- day-care record below rather than on a side. +Breeding.SIDES = { "man", "lady" } + +-------------------------------------------------------------------------- +-- Random +-------------------------------------------------------------------------- + +-- `call Random` yields one byte. Every roll below goes through this so a test +-- can hand in a scripted sequence and get the cart's exact decisions back. +local function randomByte(rng) + if rng then return math.floor(rng()) % 256 end + if love and love.math and love.math.random then + return love.math.random(0, 255) + end + return math.random(0, 255) +end + +Breeding.randomByte = randomByte + +-------------------------------------------------------------------------- +-- Species lookup +-------------------------------------------------------------------------- + +-- GetPreEvolution walks species 1..NUM_POKEMON in index order and takes the +-- FIRST one that evolves into the target, so the walk needs the dex ordering +-- rather than pairs() order. Cached against the data table itself (weak keys, +-- so a hot reload's replacement table does not pin the old one). +local orderCache = setmetatable({}, { __mode = "k" }) + +local function speciesOrder(data) + local pokemon = data and data.pokemon + if not pokemon then return {} end + local hit = orderCache[pokemon] + if hit then return hit end + local rows = {} + for id, def in pairs(pokemon) do + -- growthRates / tmhmMoves ride the same table and carry no index. + if type(def) == "table" and type(def.index) == "number" then + rows[#rows + 1] = { id = id, index = def.index } + end + end + table.sort(rows, function(a, b) return a.index < b.index end) + local out = {} + for i, row in ipairs(rows) do out[i] = row.id end + orderCache[pokemon] = out + return out +end + +Breeding.speciesOrder = speciesOrder + +local function defOf(data, species) + if not (data and data.pokemon and species) then return nil end + local def = data.pokemon[species] + return type(def) == "table" and def or nil +end + +-- Mon.growthFor, not the raw coefficient table: a hatched egg's starting +-- experience has to sit on the same curve battle EXP and a Rare Candy use, or +-- a mod-registered curve would apply to some of a mon's life and not the rest. +local function growthOf(data, def) + return require("src.battle.gen2.Mon").growthFor(data, def and def.growthRate) +end + +-------------------------------------------------------------------------- +-- Eggs as party members +-------------------------------------------------------------------------- + +-- wPartySpecies holds EGG ($fd) for an egg slot while the box struct under it +-- still holds the hatchling's real species. The port keeps the species and +-- flags the slot, so every reader that wants "is this thing a mon" asks here. +function Breeding.isEgg(mon) + return type(mon) == "table" and mon.isEgg == true +end + +-- An egg is carried, not fought: DayCareAskDepositPokemon refuses it, the +-- party menu greys it, and it is worth nothing to CheckCurPartyMonFainted +-- because DayCare_GiveEgg zeroes its HP. +function Breeding.canFight(mon) + return type(mon) == "table" and not Breeding.isEgg(mon) +end + +-- How many party members could still battle. Eggs are excluded by their zero +-- HP alone, but saying so out loud is what keeps a party of one mon and five +-- eggs from reading as six fighters. +function Breeding.healthyCount(party) + local n = 0 + for _, mon in ipairs(party or {}) do + if Breeding.canFight(mon) and (mon.hp or 0) > 0 then n = n + 1 end + end + return n +end + +-------------------------------------------------------------------------- +-- Gender +-------------------------------------------------------------------------- + +-- GetGender (engine/pokemon/mon_stats.asm) against BASE_GENDER. Deliberately +-- delegated to the port's ONE gender routine rather than transcribed a second +-- time here: a mon that reads "female" in the party menu and "male" to the +-- Day-Care would be a worse bug than the rounding Mon.gender currently has. +-- +-- (That rounding: GetGender compares the ratio byte against +-- `attackDV * 16 + speedDV`, and Mon.gender drops the speed term. The two +-- disagree only for one Attack DV per species -- see the note in this port's +-- Breeding report -- and the fix belongs in src/battle/gen2/Mon.lua, which +-- this file does not own.) +function Breeding.gender(def, dvs) + return Mon.gender(def, dvs) +end + +function Breeding.genderOf(data, mon) + if not mon then return "unknown" end + return Breeding.gender(defOf(data, mon.species), mon.dvs) +end + +-------------------------------------------------------------------------- +-- Egg groups and compatibility +-------------------------------------------------------------------------- + +-- BASE_EGG_GROUPS is one `dn EGG_x, EGG_y` byte: high nibble first. The +-- extractor writes both names into `eggGroups` and the raw byte into +-- `eggGroupsRaw`. +function Breeding.eggGroups(def) + local groups = def and def.eggGroups + if type(groups) ~= "table" then return nil, nil end + return groups[1], groups[2] +end + +-- `cp EGG_NONE * $11`, i.e. the whole byte is $ff: BOTH nibbles have to be +-- EGG_NONE. Prefer the raw byte when the cache carries it, because that is +-- the comparison the ASM makes; the names are the fallback for a fixture. +function Breeding.isNoEggs(def) + if not def then return true end + if type(def.eggGroupsRaw) == "number" then + return def.eggGroupsRaw == Breeding.NO_EGGS_RAW + end + local first, second = Breeding.eggGroups(def) + return first == Breeding.EGG_NONE and second == Breeding.EGG_NONE +end + +-- .CheckBreedingGroupCompatibility, in its own order: mon2's No-Eggs check, +-- then mon1's, then Ditto (which is compatible with everything that got this +-- far), and only then the four-way nibble comparison. The order is preserved +-- because it is what makes a Ditto x Legendary pair incompatible: the No-Eggs +-- refusal happens BEFORE the Ditto shortcut. +function Breeding.groupsCompatible(data, species1, species2) + local def1, def2 = defOf(data, species1), defOf(data, species2) + if not (def1 and def2) then return false end + if Breeding.isNoEggs(def2) then return false end + if Breeding.isNoEggs(def1) then return false end + if species2 == Breeding.DITTO then return true end + if species1 == Breeding.DITTO then return true end + local b, c = Breeding.eggGroups(def2) + local d, e = Breeding.eggGroups(def1) + -- `cp b / cp c` for each of mon1's two groups: four comparisons, any hit + -- wins. Spelled out rather than looped over {d, e}, because ipairs stops at + -- the first nil and a def with only one group would drop the second test. + if d ~= nil and (d == b or d == c) then return true end + if e ~= nil and (e == b or e == c) then return true end + return false +end + +-- .CheckDVs, verbatim: the Defense DVs and the LOW THREE BITS of the Special +-- DVs both matching is the cart's "these two are too alike" sentinel. It is +-- not a compatibility bonus even though it produces the highest number -- +-- DayCare_InitBreeding's `inc a / ret z` throws 255 out, so the pair never +-- breeds and the Day-Care Man cheerfully reports the mon is "brimming with +-- energy" anyway. +function Breeding.dvsMatch(mon1, mon2) + local a = (mon1 and mon1.dvs) or {} + local b = (mon2 and mon2.dvs) or {} + if (a.defense or 0) % 16 ~= (b.defense or 0) % 16 then return false end + return (a.special or 0) % 8 == (b.special or 0) % 8 +end + +-- wBreedMon1ID / wBreedMon2ID. The port's party record carries no OT id yet, +-- so two home-caught mons both read nil and compare EQUAL -- which is exactly +-- what the cart sees for two mons the player caught himself, and is the whole +-- reason `-77` is the common case rather than the rare one. +local function otId(mon) + return mon and mon.otId +end + +-- CheckBreedmonCompatibility. Returns wBreedingCompatibility: +-- 0 incompatible (no eggs ever) +-- 255 the matching-DVs sentinel: the Day-Care Man likes them, they do not +-- breed (see Breeding.dvsMatch) +-- 254 same species, different OT ids +-- 177 same species, same OT id (254 - 77) +-- 128 different species, different OT ids +-- 51 different species, same OT id (128 - 77) +-- breeding.compatibility, a Gen 2 invention: Gen 1 has no Day-Care pair and so +-- no name to share (docs/mod-api-gen2-compat.md, "New in Gen 2"). The hook +-- wraps the whole of CheckBreedmonCompatibility rather than one of its gates, +-- because every answer the routine can give is a number on the same scale and +-- a mod that wants "these two may breed" only has to return one: +-- +-- ctx.data the Data table the species records come out of +-- ctx.mon1, mon2 the two day-care records, man's side first +-- ctx.dayCare true when the call came from the yard, false for a +-- bare query (the DAY-CARE MAN's compatibility line) +-- +-- Returning 0 means "no eggs ever" and 255 means the matching-DVs sentinel; +-- both are refusals to Breeding.initBreeding, so a mod that wants a pair to +-- breed must return one of the four real values. +function Breeding.compatibility(data, mon1, mon2, opts) + if not Runtime.wantsHook("breeding.compatibility") then + return Breeding.vanillaCompatibility(data, mon1, mon2) + end + local value = Runtime.call("breeding.compatibility", function(c) + return Breeding.vanillaCompatibility(c.data, c.mon1, c.mon2) + end, { data = data, mon1 = mon1, mon2 = mon2, + dayCare = (opts and opts.dayCare) == true }) + return math.max(0, math.min(255, math.floor(tonumber(value) or 0))) +end + +function Breeding.vanillaCompatibility(data, mon1, mon2) + if not (mon1 and mon2) then return 0 end + if not Breeding.groupsCompatible(data, mon1.species, mon2.species) then + return 0 + end + + -- The gender test: two different genders reach .compute directly. Anything + -- else -- either mon genderless, or both the same gender -- falls into + -- .genderless, where only a Ditto can rescue the pair. + local gender1 = Breeding.genderOf(data, mon1) + local gender2 = Breeding.genderOf(data, mon2) + local paired = gender1 ~= "unknown" and gender2 ~= "unknown" + and gender1 ~= gender2 + if not paired then + if mon1.species == Breeding.DITTO then + -- .ditto1: two Dittos are the one pair that fails here. + if mon2.species == Breeding.DITTO then return 0 end + elseif mon2.species ~= Breeding.DITTO then + return 0 + end + end + + -- .compute + if Breeding.dvsMatch(mon1, mon2) then return 255 end + local value = (mon1.species == mon2.species) and 254 or 128 + -- .compare_ids: `sub 77` on a shared OT id, which is the cart's way of + -- discouraging inbreeding without forbidding it. + if otId(mon1) == otId(mon2) then value = value - 77 end + return value +end + +-- DayCareMonCompatibilityText, in the ASM's own fall-through order: the 255 +-- sentinel first, then 0, then the two `jr nc` thresholds. +Breeding.COMPATIBILITY_BRIMMING = "brimming" +Breeding.COMPATIBILITY_NONE = "none" +Breeding.COMPATIBILITY_CARES = "cares" +Breeding.COMPATIBILITY_FRIENDLY = "friendly" +Breeding.COMPATIBILITY_INTEREST = "interest" + +function Breeding.compatibilityText(value) + value = value or 0 + if value == 255 then return Breeding.COMPATIBILITY_BRIMMING end + if value == 0 then return Breeding.COMPATIBILITY_NONE end + if value >= 230 then return Breeding.COMPATIBILITY_CARES end + if value >= 70 then return Breeding.COMPATIBILITY_FRIENDLY end + return Breeding.COMPATIBILITY_INTEREST +end + +-- happiness_egg.asm .check_egg's ladder, with `percent` expanded the way +-- macros/data.asm defines it (`* $ff / 100`, integer division): +-- 31 percent + 1 = 80, 16 percent = 40, 12 percent = 30, 4 percent = 10 +-- The roll that follows is `call Random / cp b / ret nc`, so an egg appears +-- when the byte is STRICTLY under this number. +function Breeding.eggChance(value) + value = value or 0 + if value >= 230 then return 80 end + if value >= 170 then return 40 end + if value >= 110 then return 30 end + return 10 +end + +-------------------------------------------------------------------------- +-- Which parent is the mother +-------------------------------------------------------------------------- + +-- wBreedMotherOrNonDitto, as a 1-based slot rather than the cart's 0/1 byte. +-- The Ditto tests come first, so a Ditto is never "the mother": the other mon +-- is, whatever its gender. With no Ditto the test is GetGender on breedmon 1, +-- and its `jr z` catches female AND genderless (GetGender's .Genderless path +-- leaves z set from its own `cp GENDER_UNKNOWN`), so a genderless breedmon 1 +-- is treated as the mother. +function Breeding.motherSlot(data, mon1, mon2) + if not (mon1 and mon2) then return 1 end + if mon1.species == Breeding.DITTO then return 2 end + if mon2.species == Breeding.DITTO then return 1 end + return Breeding.genderOf(data, mon1) == "male" and 2 or 1 +end + +-------------------------------------------------------------------------- +-- The egg's species +-------------------------------------------------------------------------- + +-- GetPreEvolution: the first species in dex order whose EvosAttacks rows +-- contain an evolution INTO `species`. Nothing about the evolution's method +-- is looked at, which is why a stone or trade evolution is walked back through +-- just as readily as a level one. +function Breeding.preEvolution(data, species) + if not species then return nil end + for _, id in ipairs(speciesOrder(data)) do + local def = defOf(data, id) + for _, evo in ipairs((def and def.evolutions) or {}) do + if evo.into == species then return id end + end + end + return nil +end + +-- `callfar GetPreEvolution` TWICE, which is what walks a three-stage chain all +-- the way back (VENUSAUR -> IVYSAUR -> BULBASAUR) and leaves a two-stage one +-- alone on the second pass. Exactly two: a hypothetical four-stage line would +-- stop one short, and that bound is the routine, not an optimisation. +function Breeding.baseForm(data, species) + for _ = 1, 2 do + local previous = Breeding.preEvolution(data, species) + if not previous then break end + species = previous + end + return species +end + +-- The mother's base form, plus the one documented exception: "Nidoran♀ can +-- give birth to either gender of Nidoran". `cp 50 percent + 1` is `cp 128` +-- and `jr c` keeps NIDORAN_F, so the roll is a clean half. +-- +-- Returns species, motherSlot. +function Breeding.eggSpecies(data, mon1, mon2, rng) + local slot = Breeding.motherSlot(data, mon1, mon2) + local mother = (slot == 1) and mon1 or mon2 + local species = Breeding.baseForm(data, mother and mother.species) + if species == Breeding.NIDORAN_F then + species = randomByte(rng) < 128 and Breeding.NIDORAN_F + or Breeding.NIDORAN_M + end + return species, slot +end + +-------------------------------------------------------------------------- +-- Inherited moves +-------------------------------------------------------------------------- + +local function moveIdAt(moves, slot) + local entry = moves and moves[slot] + if entry == nil then return nil end + if type(entry) == "table" then return entry.id end + return entry +end + +-- GetHeritableMoves: the FATHER's four move slots, which is the list +-- InitEggMoves walks. +-- +-- With a Ditto in the box the roles are decided by the OTHER mon's gender: +-- a male or genderless partner passes its own moves, and a FEMALE partner +-- makes the Ditto the father -- which is the famous "breed a female with Ditto +-- to pass nothing" rule, and also why a female with a Ditto passes the Ditto's +-- (empty of anything useful) moves. +-- +-- .ditto2's last branch is a fall-through, not a jump: GetGender on breedmon 1 +-- returning z (female) drops into .inherit_mon2_moves rather than jumping. +function Breeding.heritableMoves(data, mon1, mon2, motherSlot) + if not (mon1 and mon2) then return {} end + if mon1.species == Breeding.DITTO then + return Breeding.genderOf(data, mon2) == "female" and (mon1.moves or {}) + or (mon2.moves or {}) + end + if mon2.species == Breeding.DITTO then + return Breeding.genderOf(data, mon1) == "female" and (mon2.moves or {}) + or (mon1.moves or {}) + end + motherSlot = motherSlot or Breeding.motherSlot(data, mon1, mon2) + return motherSlot == 1 and (mon2.moves or {}) or (mon1.moves or {}) +end + +-- GetBreedmonMovePointer: the MOTHER's four move slots -- or, when a Ditto is +-- in the box, that Ditto's, whichever side it sits on. This is the list +-- GetEggMove's .loop2 checks a candidate against, so it is the "does the other +-- parent know it too" half of the level-up rule. +function Breeding.breedmonMoves(data, mon1, mon2, motherSlot) + if not (mon1 and mon2) then return {} end + if mon1.species == Breeding.DITTO then return mon1.moves or {} end + if mon2.species == Breeding.DITTO then return mon2.moves or {} end + motherSlot = motherSlot or Breeding.motherSlot(data, mon1, mon2) + return motherSlot == 1 and (mon1.moves or {}) or (mon2.moves or {}) +end + +-- GetEggMove: may this move be inherited by `eggSpecies`? Three ways in, in +-- the ASM's order, and the second one is the one people misremember: +-- 1. it is an egg move of the egg species (data/pokemon/egg_moves.asm) +-- 2. .reached_end -> .loop2 -> .found_eggmove: the OTHER parent knows it too +-- AND it is one of the egg species' own level-up moves +-- 3. .inherit_tmhm: it is a TM/HM move the egg species can learn +-- Returns ok, reason. +function Breeding.canInheritMove(data, eggSpecies, move, otherMoves) + local def = defOf(data, eggSpecies) + if not (def and move) then return false end + + for _, id in ipairs(def.eggMoves or {}) do + if id == move then return true, "eggMove" end + end + + -- .loop2 walks all four of the other parent's slots; an empty slot is 0 and + -- a move id is never 0, so a short moveset simply never matches. + local shared = false + for slot = 1, Breeding.NUM_MOVES do + if moveIdAt(otherMoves, slot) == move then shared = true break end + end + if shared then + for _, row in ipairs(def.levelMoves or {}) do + if row.move == move then return true, "levelMove" end + end + end + + -- CanLearnTMHMMove against BASE_TMHM, which the extractor has already + -- expanded into a list of move ids. + for _, id in ipairs(def.tmhm or {}) do + if id == move then return true, "tmhm" end + end + return false +end + +-- LoadEggMove: into the first empty slot, or -- when all four are full -- +-- shift slots 2..4 down and write the newcomer into slot 4. The OLDEST move +-- is the one that goes, which is why a father with four heritable moves leaves +-- the baby with none of its own level-up set. +function Breeding.loadEggMove(moves, moveId, data) + local def = data and data.moves and data.moves[moveId] + local pp = (def and def.pp) or 0 + if #moves >= Breeding.NUM_MOVES then table.remove(moves, 1) end + moves[#moves + 1] = { id = moveId, pp = pp, maxPp = pp } + return moves +end + +-- InitEggMoves: the father's four slots in order, stopping at the first empty +-- one, skipping anything the egg already knows, and loading whatever +-- GetEggMove approves. Mutates and returns `moves`. +function Breeding.initEggMoves(data, eggSpecies, moves, fatherMoves, motherMoves) + moves = moves or {} + for slot = 1, Breeding.NUM_MOVES do + local move = moveIdAt(fatherMoves, slot) + -- `ld a, [de] / and a / jr z, .done`: an empty slot ends the walk, it does + -- not skip to the next one. + if not move then break end + local known = false + for _, entry in ipairs(moves) do + if entry.id == move then known = true break end + end + if not known and + Breeding.canInheritMove(data, eggSpecies, move, motherMoves) then + Breeding.loadEggMove(moves, move, data) + end + end + return moves +end + +-------------------------------------------------------------------------- +-- Building the egg +-------------------------------------------------------------------------- + +-- DayCare_InitBreeding's .UselessJump block: everything from the egg's species +-- down to its hatch counter, decided the moment the pair becomes compatible -- +-- NOT when the egg finally appears. Depositing two mons fixes the species, +-- the DVs and the moveset there and then; walking around only decides when the +-- Day-Care Man will hand it over. +-- +-- opts: rng, playerName, playerId. +function Breeding.makeEgg(data, mon1, mon2, opts) + opts = opts or {} + local rng = opts.rng + local species, motherSlot = Breeding.eggSpecies(data, mon1, mon2, rng) + local def = defOf(data, species) + if not def then return nil end + local level = Breeding.EGG_LEVEL + + -- `predef FillMoves` with wSkipMovesBeforeLevelUp = FALSE at wCurPartyLevel + -- = EGG_LEVEL: the base form's level-up moves at level 5, before any + -- inheritance. + local moves = Mon.movesAtLevel(def, level, data.moves) + -- `farcall InitEggMoves` right after, so the father's moves push the + -- level-up ones out rather than the other way round. + Breeding.initEggMoves(data, species, moves, + Breeding.heritableMoves(data, mon1, mon2, motherSlot), + Breeding.breedmonMoves(data, mon1, mon2, motherSlot)) + + -- Two `call Random` bytes, laid out the way the DV word is: byte 0 is + -- Attack<<4 | Defense, byte 1 is Speed<<4 | Special. + local byte0, byte1 = randomByte(rng), randomByte(rng) + local dvs = { + attack = math.floor(byte0 / 16), defense = byte0 % 16, + speed = math.floor(byte1 / 16), special = byte1 % 16, + } + + -- Which parent's DVs bleed through. The Ditto tests come FIRST, before the + -- gender branch, so a Ditto always donates -- even to a genderless egg, + -- which would otherwise take the .SkipDVs exit and keep everything it rolled. + local source + if mon1.species == Breeding.DITTO then + source = mon1 + elseif mon2.species == Breeding.DITTO then + source = mon2 + else + -- GetGender with TEMPMON on the DVs just rolled, i.e. the EGG's own + -- gender, against the EGG's species ratio. + local gender = Breeding.gender(def, dvs) + local mother = (motherSlot == 1) and mon1 or mon2 + local father = (motherSlot == 1) and mon2 or mon1 + if gender == "male" then + source = mother + elseif gender == "female" then + source = father + end + -- "unknown" is .SkipDVs: source stays nil and every rolled DV survives. + end + + if source then + local parent = source.dvs or {} + -- `ld a, [de] / and $f` then `ld a, [hl] / and $f0 / add b`: the whole + -- Defense nibble is replaced and the rolled Attack nibble is kept. + dvs.defense = (parent.defense or 0) % 16 + -- `and $7` of the parent against `and $f8` of the roll: only the LOW THREE + -- BITS of Special are inherited, so the egg keeps bit 3 of its own roll. + dvs.special = dvs.special - (dvs.special % 8) + ((parent.special or 0) % 8) + end + dvs.hp = Mon.hpDV(dvs) + + -- The one builder. hp = 0 is DayCare_GiveEgg's `ld hl, MON_HP / xor a / + -- ld [hli], a / ld [hl], a`: an egg is carried at zero HP and cannot fight. + local egg = Mon.new(data, species, level, { + dvs = dvs, + moves = moves, + hp = 0, + nickname = Breeding.EGG_NAME, + happiness = Breeding.HATCH_HAPPINESS, + }) + if not egg then return nil end + -- `callfar CalcExpAtLevel` at wCurPartyLevel; Mon.new already writes exactly + -- this, spelled out because the cart does it as its own step. + egg.experience = Mon.experienceForLevel(growthOf(data, def), level) + egg.isEgg = true + -- wEggMonHappiness doubles as the hatch counter while the thing is an egg + -- (`ld a, [wBaseEggSteps] / ld [hli], a`), and only becomes happiness when + -- HatchEggs writes $78 over it. The port keeps the two apart so `happiness` + -- never means two things at once. + egg.eggSteps = def.eggSteps or 0 + -- wEggMonOT / wEggMonID: the player is always an egg's original trainer. + egg.ot = opts.playerName + egg.otId = opts.playerId + return egg +end + +-------------------------------------------------------------------------- +-- The day-care record +-------------------------------------------------------------------------- + +-- The three ENGINE_* ids that are literal bits of the two day-care bytes: +-- data/events/engine_flags.asm's table maps them to wDayCareMan's +-- DAYCAREMAN_HAS_EGG_F and DAYCAREMAN_HAS_MON_F and to wDayCareLady's +-- DAYCARELADY_HAS_MON_F, and constants/engine_flags.asm numbers them 5, 6 and +-- 7 (the pokegear block is 0..4). They are NOT a save flag byte of their own: +-- checkflag/setflag on any of these three reads or writes the day-care record +-- below, which is why the DayCare and ROUTE_34 object callbacks see the yard +-- mons and the gramps outside the moment a mon is deposited or an egg is due. +Breeding.ENGINE_DAY_CARE_MAN_HAS_EGG = 5 +Breeding.ENGINE_DAY_CARE_MAN_HAS_MON = 6 +Breeding.ENGINE_DAY_CARE_LADY_HAS_MON = 7 + +-- save.dayCare, created on demand the way Boxes.box creates a box. +-- man / lady { mon = <party record>, introSeen = bool } +-- compatible DAYCAREMAN_MONS_COMPATIBLE_F +-- hasEgg DAYCAREMAN_HAS_EGG_F (ENGINE_DAY_CARE_MAN_HAS_EGG) +-- stepsToEgg wStepsToEgg +-- egg wEggMon, built at DayCare_InitBreeding time +function Breeding.dayCare(save) + if type(save) ~= "table" then return nil end + save.dayCare = save.dayCare or {} + local dc = save.dayCare + dc.man = dc.man or {} + dc.lady = dc.lady or {} + dc.compatible = dc.compatible or false + dc.hasEgg = dc.hasEgg or false + dc.stepsToEgg = dc.stepsToEgg or 0 + return dc +end + +function Breeding.side(save, which) + local dc = Breeding.dayCare(save) + if not dc then return nil end + return dc[which == "lady" and "lady" or "man"] +end + +-- DAYCARE_INTRO_SEEN_F, bit 7 of each side's own byte. DayCareIntroText tests +-- it, sets it, and `inc a` picks the LONGER "do you know about EGGS?" script +-- the first time round -- so the egg explanation is the intro you get once, +-- not a line about an egg you are owed. +function Breeding.takeIntro(save, which) + local slot = Breeding.side(save, which) + if not slot then return false end + if slot.introSeen then return false end + slot.introSeen = true + return true +end + +-------------------------------------------------------------------------- +-- Deposit +-------------------------------------------------------------------------- + +-- CheckCurPartyMonFainted (engine/pokemon/bills_pc_top.asm), despite its name: +-- it walks the party SKIPPING wCurPartyMon and returns "ok" the moment it +-- finds any other slot with HP left. The mon being given away is not tested +-- at all, so handing over your only healthy mon is what it blocks, not handing +-- over a fainted one. +function Breeding.hasAnotherHealthyMon(party, partyIndex) + for index, mon in ipairs(party or {}) do + if index ~= partyIndex and (mon.hp or 0) > 0 then return true end + end + return false +end + +-- DayCareAskDepositPokemon's refusals, in the order it makes them. The keys +-- are the DAYCARETEXT_* constants they map to; DayCareMenu turns them into the +-- transcribed lines. +Breeding.REFUSE_LAST_MON = "lastMon" +Breeding.REFUSE_EGG = "cantAcceptEgg" +Breeding.REFUSE_LAST_ALIVE = "lastAliveMon" +Breeding.REFUSE_MAIL = "removeMail" +Breeding.REFUSE_PARTY_FULL = "partyFull" +Breeding.REFUSE_NO_MONEY = "notEnoughMoney" +Breeding.REFUSE_OCCUPIED = "occupied" +Breeding.REFUSE_NO_MON = "noMon" + +-- `ld a, [wPartyCount] / cp 2 / jr c, .OnlyOneMon` -- checked BEFORE the party +-- menu opens, so a lone mon never even gets a list to pick from. +function Breeding.canOpenDeposit(save) + local party = (save and save.party) or {} + if #party < 2 then return false, Breeding.REFUSE_LAST_MON end + return true +end + +-- ItemIsMail (engine/pokemon/mail_2.asm), which is a search of the ten-entry +-- MailItems list and nothing else. This used to guess from the item id's +-- spelling, which missed LITEBLUEMAIL and PORTRAITMAIL -- neither ends in +-- "_MAIL" -- so the Day-Care would happily take a mon carrying either. `data` +-- stays in the signature because every call site passes it. +function Breeding.holdsMail(_data, mon) + return Mail.monHoldsMail(mon) +end + +function Breeding.canDeposit(data, save, which, partyIndex) + local slot = Breeding.side(save, which) + if not slot then return false, Breeding.REFUSE_NO_MON end + if slot.mon then return false, Breeding.REFUSE_OCCUPIED end + local ok, reason = Breeding.canOpenDeposit(save) + if not ok then return false, reason end + local mon = save.party[partyIndex] + if not mon then return false, Breeding.REFUSE_NO_MON end + if Breeding.isEgg(mon) then return false, Breeding.REFUSE_EGG end + if not Breeding.hasAnotherHealthyMon(save.party, partyIndex) then + return false, Breeding.REFUSE_LAST_ALIVE + end + if Breeding.holdsMail(data, mon) then return false, Breeding.REFUSE_MAIL end + return true +end + +-- DepositBreedmon + RemoveMonFromPartyOrBox, then DayCare_InitBreeding. The +-- mon's `level` is frozen here on purpose: wBreedMon1Level is the box struct's +-- MON_LEVEL and DayCareStep only ever raises MON_EXP, which is what makes +-- GetBreedMon1LevelGrowth's subtraction mean anything. +-- +-- Returns ok, mon (or false, reason). +function Breeding.deposit(data, save, which, partyIndex, opts) + local ok, reason = Breeding.canDeposit(data, save, which, partyIndex) + if not ok then return false, reason end + local slot = Breeding.side(save, which) + local mon = table.remove(save.party, partyIndex) + -- RemoveMonFromPartyOrBox's mail shift: sPartyMail is keyed by party slot, + -- so a letter behind the deposited mon moves up with its owner. + Mail.removeSlot(save, partyIndex) + slot.mon = mon + Breeding.initBreeding(data, save, opts) + return true, mon +end + +-------------------------------------------------------------------------- +-- Withdraw +-------------------------------------------------------------------------- + +-- GetBreedMon1LevelGrowth: CalcLevel from the exp the mon has NOW against the +-- level it was deposited at. Returns storedLevel, newLevel, grown. +function Breeding.levelGrowth(data, slot) + local mon = slot and slot.mon + if not mon then return 0, 0, 0 end + local stored = mon.level or 1 + local def = defOf(data, mon.species) + local newLevel = Mon.levelForExperience(growthOf(data, def), + mon.experience or 0) + if newLevel < stored then newLevel = stored end + return stored, newLevel, newLevel - stored +end + +-- GetPriceToRetrieveBreedmon: `AddNTimes` of 100 by the number of levels +-- grown, plus a flat 100. A mon that grew nothing still costs ¥100, which is +-- the number _BackAlreadyText spells out. +function Breeding.retrievePrice(grown) + return Breeding.WITHDRAW_FEE_PER_LEVEL * math.max(0, grown or 0) + + Breeding.WITHDRAW_FEE +end + +-- DayCare_AskWithdrawBreedMon's two gates, after the yes/no boxes: the money +-- first, then the party space. +function Breeding.canWithdraw(data, save, which) + local slot = Breeding.side(save, which) + if not (slot and slot.mon) then return false, Breeding.REFUSE_NO_MON end + local _, _, grown = Breeding.levelGrowth(data, slot) + local price = Breeding.retrievePrice(grown) + local money = (save.player and save.player.money) or 0 + if money < price then return false, Breeding.REFUSE_NO_MONEY, price end + if #(save.party or {}) >= Breeding.PARTY_SIZE then + return false, Breeding.REFUSE_PARTY_FULL, price + end + return true, nil, price +end + +-- FillMoves with wSkipMovesBeforeLevelUp = TRUE: only the level-up moves in +-- (prevLevel, newLevel] are learned, each into the first empty slot or -- once +-- all four are full -- over the oldest, which is ShiftMoves. This is the same +-- shape LoadEggMove has, and it is why a mon left in the Day-Care for thirty +-- levels comes out with a completely replaced moveset. +function Breeding.learnMovesFromDayCare(data, mon, fromLevel, toLevel) + local def = defOf(data, mon and mon.species) + mon.moves = mon.moves or {} + for _, row in ipairs((def and def.levelMoves) or {}) do + if row.level > fromLevel and row.level <= toLevel then + local known = false + for _, entry in ipairs(mon.moves) do + if entry.id == row.move then known = true break end + end + if not known then Breeding.loadEggMove(mon.moves, row.move, data) end + end + end + return mon.moves +end + +-- RetrieveBreedmon. Level jumps to the one the exp bought, stats are +-- recalculated through the one builder, the moves between the two levels are +-- learned, HealPartyMon refills everything, and then -- this is the bug the +-- ASM flags in move_mon.asm -- CalcExpAtLevel OVERWRITES the exp with the +-- minimum for the new level, so every point past that threshold is thrown +-- away. Transcribed, bug included. +-- +-- Returns ok, mon, price (or false, reason). +function Breeding.withdraw(data, save, which) + local ok, reason, price = Breeding.canWithdraw(data, save, which) + if not ok then return false, reason, price end + local slot = Breeding.side(save, which) + local stored, newLevel = Breeding.levelGrowth(data, slot) + local mon = slot.mon + local def = defOf(data, mon.species) + + local rebuilt = Mon.new(data, mon.species, newLevel, { + dvs = mon.dvs, + moves = mon.moves, + item = mon.item, + happiness = mon.happiness, + nickname = mon.nickname, + }) + if not rebuilt then return false, Breeding.REFUSE_NO_MON end + Breeding.learnMovesFromDayCare(data, rebuilt, stored, newLevel) + -- HealPartyMon: full HP, full PP, no status. + rebuilt.hp = rebuilt.maxHp + rebuilt.status = nil + for _, move in ipairs(rebuilt.moves) do move.pp = move.maxPp end + rebuilt.caughtLevel = mon.caughtLevel or rebuilt.caughtLevel + rebuilt.ot, rebuilt.otId = mon.ot, mon.otId + -- CalcExpAtLevel, which is the experience loss. + rebuilt.experience = Mon.experienceForLevel(growthOf(data, def), newLevel) + + save.player = save.player or {} + save.player.money = math.max(0, (save.player.money or 0) - price) + save.party = save.party or {} + save.party[#save.party + 1] = rebuilt + slot.mon = nil + + -- Both withdrawal paths clear MONS_COMPATIBLE_F: the man's also clears his + -- own HAS_MON, the lady's reaches across to the man's byte to clear the + -- shared compatibility bit. HAS_EGG is deliberately NOT cleared -- an egg + -- already earned survives taking a parent home. + local dc = Breeding.dayCare(save) + dc.compatible = false + return true, rebuilt, price +end + +-------------------------------------------------------------------------- +-- Starting a clutch +-------------------------------------------------------------------------- + +-- DayCare_InitBreeding. Runs after every deposit and after every egg is +-- collected; returns early unless both sides are occupied. +-- +-- `and a / ret z` throws out compatibility 0, and `inc a / ret z` throws out +-- 255 -- the matching-DVs sentinel. Only then is MONS_COMPATIBLE_F set, the +-- countdown seeded and the egg itself built. +function Breeding.initBreeding(data, save, opts) + opts = opts or {} + local dc = Breeding.dayCare(save) + if not (dc and dc.man.mon and dc.lady.mon) then return false end + local value = Breeding.compatibility(data, dc.man.mon, dc.lady.mon, + { dayCare = true }) + if value == 0 then return false end + if value == 255 then return false end + dc.compatible = true + local steps + repeat steps = randomByte(opts.rng) until steps >= Breeding.MIN_STEPS_TO_EGG + dc.stepsToEgg = steps + dc.egg = Breeding.makeEgg(data, dc.man.mon, dc.lady.mon, { + rng = opts.rng, + playerName = opts.playerName + or (save.player and save.player.name), + playerId = opts.playerId or (save.player and save.player.id), + }) + -- breeding.egg_created, a Gen 2 invention. It fires HERE and not when the + -- Day-Care Man hands the egg over, because .UselessJump is where the record + -- is decided: species, DVs, inherited moves and hatch counter are all fixed + -- the moment the pair becomes compatible, and Breeding.collectEgg only moves + -- that same table into the party. A mod that wants to edit an egg has to be + -- here; by collection time the answer is already written. + -- + -- egg the record just built (nil if makeEgg refused the pair) + -- mother/father the two day-care records, resolved through motherSlot so + -- the names mean what they say rather than "man's side" + -- compatibility wBreedingCompatibility, the value that let this run + -- stepsToEgg wStepsToEgg, the countdown this egg is waiting out + if Runtime.wants("breeding.egg_created") then + local motherSlot = Breeding.motherSlot(data, dc.man.mon, dc.lady.mon) + Runtime.emit("breeding.egg_created", { + egg = dc.egg, + mother = (motherSlot == 1) and dc.man.mon or dc.lady.mon, + father = (motherSlot == 1) and dc.lady.mon or dc.man.mon, + compatibility = value, + stepsToEgg = dc.stepsToEgg, + }) + end + return true +end + +-------------------------------------------------------------------------- +-- Walking +-------------------------------------------------------------------------- + +-- DayCareStep's first half: +1 experience per step for each deposited mon, up +-- to the $50ffff ceiling, and nothing at all once the mon's STORED level is +-- MAX_LEVEL (the stored level, not the grown one, so a mon deposited at 99 +-- keeps earning past 100's threshold). +local function growDeposited(slot) + local mon = slot and slot.mon + if not mon then return end + if (mon.level or 1) >= Breeding.MAX_LEVEL then return end + mon.experience = math.min((mon.experience or 0) + 1, + Breeding.MAX_DAY_CARE_EXP) +end + +-- DayCareStep, in full. Called once per overworld step. +function Breeding.dayCareStep(data, save, rng) + local dc = Breeding.dayCare(save) + if not dc then return false end + growDeposited(dc.man) + growDeposited(dc.lady) + + -- .check_egg: the countdown only runs while the pair is flagged compatible, + -- so withdrawing either parent stops it dead. + if not dc.compatible then return false end + -- `dec [hl] / ret nz` on a byte: a counter already at 0 wraps to 255 rather + -- than firing, which is why a zero rolled below costs a further 256 steps. + dc.stepsToEgg = ((dc.stepsToEgg or 0) - 1) % 256 + if dc.stepsToEgg ~= 0 then return false end + + -- `call Random / ld [hl], a`: the NEXT countdown is a plain byte, with none + -- of the >= 150 rejection the first one had. + dc.stepsToEgg = randomByte(rng) + local value = Breeding.compatibility(data, dc.man.mon, dc.lady.mon, + { dayCare = true }) + if randomByte(rng) >= Breeding.eggChance(value) then return false end + dc.compatible = false + dc.hasEgg = true + return true +end + +-- DoEggStep: one tick off the FIRST egg in party order whose counter does not +-- reach zero, and a stop the moment one does. Eggs after that one are not +-- decremented on that step at all, which is the cart's behaviour and the +-- reason two eggs never hatch on the same footfall. +function Breeding.doEggStep(save) + for _, mon in ipairs((save and save.party) or {}) do + if Breeding.isEgg(mon) then + mon.eggSteps = ((mon.eggSteps or 0) - 1) % 256 + if mon.eggSteps == 0 then return true end + end + end + return false +end + +-- engine/overworld/events.asm's step block, in its order: +-- wStepCount++ (StepHappiness when it wraps, not modelled here) +-- at $80: DoEggStep, and a hatch SKIPS DayCareStep for that step +-- DayCareStep +-- Returns "hatch" when an egg is ready, otherwise nil. +function Breeding.step(data, save, rng) + if type(save) ~= "table" then return nil end + save.stepCount = ((save.stepCount or 0) + 1) % Breeding.STEP_CYCLE + if save.stepCount == Breeding.EGG_STEP_PHASE then + if Breeding.doEggStep(save) then return "hatch" end + end + Breeding.dayCareStep(data, save, rng) + return nil +end + +-- How many footfalls are still owed on an egg, for a driver or a test: the +-- counter is in 256-step cycles and the tick lands 128 steps into each one. +function Breeding.stepsToHatch(mon) + if not Breeding.isEgg(mon) then return nil end + return (mon.eggSteps or 0) * Breeding.STEP_CYCLE +end + +-------------------------------------------------------------------------- +-- Collecting and hatching +-------------------------------------------------------------------------- + +-- DayCareManOutside .AskGiveEgg -> DayCare_GiveEgg, then the following +-- DayCare_InitBreeding that rolls the NEXT egg. The record handed over is the +-- one built when the pair became compatible, not one made here. +function Breeding.collectEgg(data, save, opts) + local dc = Breeding.dayCare(save) + if not (dc and dc.hasEgg) then return false, Breeding.REFUSE_NO_MON end + save.party = save.party or {} + -- `.PartyFull` sets wScriptVar to TRUE and keeps the egg for later. + if #save.party >= Breeding.PARTY_SIZE then + return false, Breeding.REFUSE_PARTY_FULL + end + local egg = dc.egg + if not egg then return false, Breeding.REFUSE_NO_MON end + save.party[#save.party + 1] = egg + dc.egg = nil + dc.hasEgg = false + Breeding.initBreeding(data, save, opts) + return true, egg +end + +-- HatchEggs' loop condition: an egg slot whose counter has reached 0. +function Breeding.readyToHatch(save) + local out = {} + for index, mon in ipairs((save and save.party) or {}) do + if Breeding.isEgg(mon) and (mon.eggSteps or 0) == 0 then + out[#out + 1] = index + end + end + return out +end + +-- HatchEggs, for one slot. The hatchling is built by Mon.new and nothing +-- else; its DVs, moves, level and experience are the egg's, its happiness is +-- $78, and MON_HP is copied straight from MON_MAXHP so it walks out at full +-- health. A nickname of nil is the "no thanks" answer -- HatchEggs copies the +-- species name into the slot then, and the port stores that as no nickname. +-- +-- 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) + local party = (save and save.party) or {} + local egg = party[index] + if not Breeding.isEgg(egg) then return nil end + local def = defOf(data, egg.species) + if not def then return nil end + + local hatched = Mon.new(data, egg.species, egg.level or Breeding.EGG_LEVEL, { + dvs = egg.dvs, + moves = egg.moves, + nickname = nickname, + happiness = Breeding.HATCH_HAPPINESS, + }) + if not hatched then return nil end + hatched.experience = egg.experience + -- `ld a, [de] / ld [hli], a` twice: HP := MaxHP. + hatched.hp = hatched.maxHp + 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 + party[index] = hatched + + Breeding.markPokedex(save, egg.species) + -- egg.hatched, a Gen 2 invention: Gen 1 has no egg, so there is no name to + -- share and none of pokemon.caught / pokemon.received fits (nothing was + -- caught and nothing was given). Raised AFTER the slot is replaced and the + -- #DEX marked, so a listener that walks the party sees the hatchling rather + -- than the egg it grew out of. + -- + -- mon the hatchling, the record now sitting in the party + -- egg the egg record it replaced, still holding the hatch counter + -- slot the party index, 1 based + -- species the species that hatched, the same id SetSeenAndCaughtMon took + -- nickname what the player answered the naming screen, or nil for "no" + if Runtime.wants("egg.hatched") then + Runtime.emit("egg.hatched", { + mon = hatched, egg = egg, slot = index, + species = egg.species, nickname = nickname, + }) + end + return hatched, { + species = egg.species, + togepi = egg.species == Breeding.TOGEPI, + } +end + +-- SetSeenAndCaughtMon, the same pair Evolution.markPokedex sets: a hatchling +-- is in the party, so it is both. +function Breeding.markPokedex(save, species) + if not (save and species) then return false end + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[species] = true + save.pokedex.caught[species] = true + return true +end + +return Breeding diff --git a/src/core/gen2/BugContest.lua b/src/core/gen2/BugContest.lua new file mode 100644 index 00000000..ffd332d5 --- /dev/null +++ b/src/core/gen2/BugContest.lua @@ -0,0 +1,834 @@ +-- The Bug Catching Contest (engine/events/bug_contest/). +-- +-- A MODE, not a menu. For its duration the party is masked down to the lead +-- mon, the pack is replaced by twenty PARK BALLs, a twenty minute clock runs +-- off the RTC, wild encounters come from the contest's OWN table rather than +-- from National Park's grass, and only ONE caught mon is kept at a time. When +-- it is over the judge scores that mon against five rolled contestants, and +-- the placing decides whether the player walks out with a SUN STONE. +-- +-- The pieces, and the file each is transcribed from: +-- +-- ContestScore bug_contest/judging.asm the player's score +-- ComputeAIContestantScores bug_contest/judging.asm the five AI rolls +-- DetermineContestWinners bug_contest/judging.asm the podium +-- BugContest_GetPlayersResult bug_contest/judging.asm the placing, 0..3 +-- BugContestantPointers data/events/bug_contest_winners.asm +-- BugCatchingContestantEventFlagTable data/events/bug_contest_flags.asm +-- ContestMons data/wild/bug_contest_mons.asm +-- ChooseWildEncounter_BugContest engine/overworld/events.asm +-- TryWildEncounter_BugContest engine/overworld/events.asm +-- ContestDropOffMons / ContestReturnMons bug_contest/contest_2.asm +-- BugContest_SetCaughtContestMon bug_contest/caught_mon.asm +-- GiveParkBalls bug_contest/contest.asm +-- StartBugContestTimer / CheckBugContestTimer engine/overworld/time.asm +-- +-- NOTHING here draws. src/ui/gen2/ContestMenu.lua is the STOCK-versus-THIS +-- comparison screen, and it asks this module every question it needs answered, +-- so the rules can be tested with no love at all. +-- +-- The DRIVER is the extracted script bytecode: Route35NationalParkGate's +-- officer calls ContestDropOffMons, GiveParkBalls and +-- SelectRandomBugContestContestants, and BugContestResultsScript calls +-- BugContestJudging, ContestReturnMons and CheckPartyFullAfterContest. Each of +-- those specials is one call into this module -- see the module map at the +-- bottom of the file. +-- +-- This file also carries the RTC delta helpers from engine/overworld/time.asm. +-- They live here because the contest timer is the port's only SECOND +-- resolution consumer of them; src/core/gen2/Apricorns.lua reuses the same two +-- functions at day resolution for Kurt's wait. Both belong in a +-- src/core/gen2/Time.lua the day one exists. + +local Runtime = require("src.mods.Runtime") + +local BugContest = {} + +-- ---------------------------------------------------------------- constants +-- +-- constants/script_constants.asm. +BugContest.BALLS = 20 -- BUG_CONTEST_BALLS +BugContest.MINUTES = 20 -- BUG_CONTEST_MINUTES +BugContest.SECONDS = 0 -- BUG_CONTEST_SECONDS +BugContest.PLAYER = 1 -- BUG_CONTEST_PLAYER +BugContest.NUM_CONTESTANTS = 10 -- NUM_BUG_CONTESTANTS, not counting the player +BugContest.CONTESTANT_SIZE = 4 -- BUG_CONTESTANT_SIZE: id, mon, score hi, lo +-- SelectRandomBugContestContestants sets five of the ten flags. +BugContest.CONTESTANTS_PICKED = 5 + +-- The three-way answer CheckPartyFullAfterContest leaves in wScriptVar, which +-- BugContestResults_DidNotLeaveMons branches on. +BugContest.CAUGHT_MON = 0 +BugContest.BOXED_MON = 1 +BugContest.NO_CATCH = 2 + +-- constants/engine_flags.asm, by index: the two ENGINE_* ids the gate scripts +-- set and clear around a contest. ENGINE_BUG_CONTEST_TIMER is what makes +-- CheckTimeEvents poll the clock instead of the daily reset; +-- ENGINE_DAILY_BUG_CONTEST is a wDailyFlags1 bit, so it clears itself overnight +-- and that is what makes the contest a once-a-day thing. +BugContest.ENGINE_BUG_CONTEST_TIMER = 16 +BugContest.ENGINE_DAILY_BUG_CONTEST = 80 + +-- Route35OfficerScriptContest turns you away on SUNDAY, MONDAY, WEDNESDAY and +-- FRIDAY, so the contest runs Tuesday, Thursday and Saturday. Weekday numbers +-- are GetWeekday's, which is wCurDay mod 7 with SUNDAY == 0 +-- (constants/ram_constants.asm). +BugContest.SUNDAY, BugContest.MONDAY, BugContest.TUESDAY = 0, 1, 2 +BugContest.WEDNESDAY, BugContest.THURSDAY = 3, 4 +BugContest.FRIDAY, BugContest.SATURDAY = 5, 6 +BugContest.CONTEST_DAYS = { [2] = true, [4] = true, [6] = true } + +-- BugContestResults_FirstPlace / _SecondPlace / _ThirdPlace, and the +-- consolation BERRY every entrant who placed nowhere gets. +BugContest.PRIZES = { "SUN_STONE", "EVERSTONE", "GOLD_BERRY" } +BugContest.CONSOLATION_PRIZE = "BERRY" + +-- The one ball that works inside the park. BattleMenu_Pack's `.contest` +-- branch does not open the pack at all: it loads PARK_BALL into wCurItem and +-- runs the item effect, so there is no way to throw anything else. +BugContest.BALL = "PARK_BALL" + +-- ------------------------------------------------------------- the RTC +-- +-- wCurDay counts 0..139 (_CalcDaysSince wraps by adding 20 * 7), hours by +-- MAX_HOUR, minutes and seconds by 60. +BugContest.DAY_WRAP = 20 * 7 +BugContest.HOUR_WRAP = 24 -- MAX_HOUR, constants/misc_constants.asm + +-- The host clock in the cart's shape. `stamp` is optional and is an os.time() +-- value, so a test can pin the clock without touching the real one. +function BugContest.now(stamp) + local t = os.date("*t", stamp) + -- Days since the epoch in LOCAL time, folded into wCurDay's range. The + -- absolute value is meaningless (the cart's own wCurDay is only ever read + -- through a difference or a mod 7), so any monotone daily counter serves, + -- and folding it here is what makes a save survive a year of real time. + local midday = os.time({ year = t.year, month = t.month, day = t.day, + hour = 12, min = 0, sec = 0 }) + local day = math.floor(midday / 86400) % BugContest.DAY_WRAP + return { day = day, hour = t.hour, minute = t.min, second = t.sec } +end + +-- GetWeekday: wCurDay mod 7, SUNDAY == 0. +function BugContest.weekday(now) + return ((now or BugContest.now()).day or 0) % 7 +end + +function BugContest.isContestDay(now) + return BugContest.CONTEST_DAYS[BugContest.weekday(now)] == true +end + +local function borrowed(value, wrap) + -- One `sub`/`sbc` step: the wrapped difference, plus the borrow that has to + -- carry into the next unit up. + if value < 0 then return value + wrap, 1 end + return value, 0 +end + +-- CalcSecsMinsHoursDaysSince. Two things about this routine matter and both +-- are easy to lose in a port: +-- +-- * it ADVANCES the stored stamp to `now` in place (`ld [hl], c ; current +-- seconds`, and again for minutes, hours and days). So the deltas are +-- "since the LAST poll", not "since the timer started", and two polls a +-- minute apart subtract one minute each rather than one and then two. +-- * every unit wraps rather than going negative. A clock moved BACKWARDS +-- therefore reads as a very large jump forward, which is exactly what +-- makes a rewound clock end the contest and roll the daily flags. +-- +-- `depth` picks how far down the struct the cart went: CalcDaysSince stops at +-- days, CalcMinsHoursDaysSince at minutes, CalcSecsMinsHoursDaysSince runs the +-- lot. Unread units are left alone in the stamp, the same way the shorter +-- entry points never touch them. +function BugContest.elapsedSince(stamp, now, depth) + now = now or BugContest.now() + depth = depth or "second" + local out = { days = 0, hours = 0, minutes = 0, seconds = 0 } + local carry = 0 + if depth == "second" then + local value + value, carry = borrowed((now.second or 0) - (stamp.second or 0), 60) + stamp.second = now.second or 0 + out.seconds = value + end + if depth == "second" or depth == "minute" then + local value + value, carry = borrowed((now.minute or 0) - (stamp.minute or 0) - carry, 60) + stamp.minute = now.minute or 0 + out.minutes = value + end + if depth ~= "day" then + local value + value, carry = borrowed((now.hour or 0) - (stamp.hour or 0) - carry, + BugContest.HOUR_WRAP) + stamp.hour = now.hour or 0 + out.hours = value + end + local days + days, carry = borrowed((now.day or 0) - (stamp.day or 0) - carry, + BugContest.DAY_WRAP) + stamp.day = now.day or 0 + out.days = days + return out +end + +-- ------------------------------------------------------------- the state +-- +-- Everything the contest owns lives under save.bugContest, spelled after the +-- WRAM it stands in for: +-- +-- active ENGINE_BUG_CONTEST_TIMER (wStatusFlags2 bit 0) +-- balls wParkBallsRemaining +-- minutes wBugContestMinsRemaining +-- seconds wBugContestSecsRemaining +-- startTime wBugContestStartTime, { day, hour, minute, second } +-- caught wContestMon, one party-shaped mon or nil +-- stash the party tail ContestDropOffMons masks off +-- contestants { [1..10] = true } for a SET flag, i.e. NOT in this contest +-- results wBugContestResults, { first, second, third } +-- place what BugContestJudging left in wScriptVar, 0..3 +function BugContest.state(save) + if type(save) ~= "table" then return nil end + save.bugContest = save.bugContest or {} + return save.bugContest +end + +function BugContest.isActive(save) + local state = BugContest.state(save) + return (state and state.active) == true +end + +-- ------------------------------------------------------- the encounter table +-- +-- data/wild/bug_contest_mons.asm, transcribed. `chance` is the row's slice of +-- 100, NOT a cumulative total: ChooseWildEncounter_BugContest subtracts each +-- row from the roll until it borrows. The ten real rows already add to 100, so +-- the trailing VENOMOTH row -- whose chance byte is -1, i.e. "always" -- is +-- unreachable. It is kept because dropping it would be editing the cart's +-- table, and because it is the row a modified chance list would fall through +-- to. +BugContest.MONS = { + { chance = 20, species = "CATERPIE", min = 7, max = 18 }, + { chance = 20, species = "WEEDLE", min = 7, max = 18 }, + { chance = 10, species = "METAPOD", min = 9, max = 18 }, + { chance = 10, species = "KAKUNA", min = 9, max = 18 }, + { chance = 5, species = "BUTTERFREE", min = 12, max = 15 }, + { chance = 5, species = "BEEDRILL", min = 12, max = 15 }, + { chance = 10, species = "VENONAT", min = 10, max = 16 }, + { chance = 10, species = "PARAS", min = 10, max = 17 }, + { chance = 5, species = "SCYTHER", min = 13, max = 14 }, + { chance = 5, species = "PINSIR", min = 13, max = 14 }, + { chance = 255, species = "VENOMOTH", min = 30, max = 40 }, +} + +-- The extractor writes the same eleven rows as encounters.bugContest, which is +-- its own table in the cache and not a grass row: the park's grass entry is +-- what the map rolls off OUTSIDE the twenty minutes. This reader prefers the +-- cache, so the transcription above is the pinned fallback rather than a +-- second source of truth that can drift. +function BugContest.contestMons(data) + local extracted = data and data.encounters and data.encounters.bugContest + if type(extracted) == "table" and #extracted > 0 then return extracted end + return BugContest.MONS +end + +-- `call Random` gives one byte. Kept on the module, not taken from the VM, so +-- every roll below can be pinned by a test; the convention is the ASM's, a +-- function of no arguments returning 0..255. +function BugContest.random() + if love and love.math and love.math.random then + return love.math.random(0, 255) + end + return math.random(0, 255) +end + +local function byte(random) + return (random or BugContest.random)() +end + +-- TryWildEncounter_BugContest: 40 percent in super-tall grass, 20 percent in +-- ordinary grass, and `percent` is `* $ff / 100` so those are 102 and 51 out of +-- 256, not 40 and 20 out of 100. +BugContest.ENCOUNTER_RATE_SUPER_TALL = math.floor(40 * 0xff / 100) +BugContest.ENCOUNTER_RATE_GRASS = math.floor(20 * 0xff / 100) + +function BugContest.encounterRate(superTallGrass) + if superTallGrass then return BugContest.ENCOUNTER_RATE_SUPER_TALL end + return BugContest.ENCOUNTER_RATE_GRASS +end + +function BugContest.triggers(superTallGrass, random) + return byte(random) < BugContest.encounterRate(superTallGrass) +end + +-- ChooseWildEncounter_BugContest. The roll is rejected until it is under +-- 200 and then halved, which is a uniform 0..99 with no modulo bias, and the +-- level is `min + Random % (max - min + 1)` unless min and max are equal. +function BugContest.chooseWild(data, random) + local rows = BugContest.contestMons(data) + local roll + repeat + roll = byte(random) + until roll < 200 + roll = math.floor(roll / 2) + + local row + for index = 1, #rows do + row = rows[index] + local chance = row.chance or 0 + if roll < chance then break end + roll = roll - chance + end + if not row then return nil end + + local level = row.min or 1 + local span = (row.max or level) - level + if span ~= 0 then + -- SimpleDivide's remainder over (max - min + 1), added to min. + level = level + (byte(random) % (span + 1)) + end + return { species = row.species, level = level } +end + +-- ------------------------------------------------------------ the contestants +-- +-- data/events/bug_contest_winners.asm. Each row is `db class, id` followed by +-- three `dbw mon, score` rows, best first. +-- +-- INDEXING, which is where this table bites. BugContestantPointers has +-- NUM_BUG_CONTESTANTS + 1 entries: slot 0 is a duplicate of Bug Catcher Don +-- that the comment marks "this reverts back to the player" and nothing ever +-- reads. ComputeAIContestantScores walks e = 0..9 and looks up slot e + 1, +-- and LoadContestantName takes a winner ID and looks up slot ID - 1. So the +-- ten real contestants are slots 1..10 and their winner IDs are 2..11, with +-- ID 1 reserved for the player (BUG_CONTEST_PLAYER). A 1-based Lua list lines +-- up with the slot numbers exactly, which is why this one is NOT zero based. +BugContest.CONTESTANTS = { + { class = "BUG_CATCHER", trainer = 1, name = "DON", + mons = { { species = "KAKUNA", score = 300 }, + { species = "METAPOD", score = 285 }, + { species = "CATERPIE", score = 226 } } }, + { class = "BUG_CATCHER", trainer = 3, name = "ED", + mons = { { species = "BUTTERFREE", score = 286 }, + { species = "BUTTERFREE", score = 251 }, + { species = "CATERPIE", score = 237 } } }, + { class = "COOLTRAINERM", trainer = 1, name = "NICK", + mons = { { species = "SCYTHER", score = 357 }, + { species = "BUTTERFREE", score = 349 }, + { species = "PINSIR", score = 368 } } }, + { class = "POKEFANM", trainer = 1, name = "WILLIAM", + mons = { { species = "PINSIR", score = 332 }, + { species = "BUTTERFREE", score = 324 }, + { species = "VENONAT", score = 321 } } }, + { class = "BUG_CATCHER", trainer = 5, name = "BENNY", + mons = { { species = "BUTTERFREE", score = 318 }, + { species = "WEEDLE", score = 295 }, + { species = "CATERPIE", score = 285 } } }, + { class = "CAMPER", trainer = 5, name = "BARRY", + mons = { { species = "PINSIR", score = 366 }, + { species = "VENONAT", score = 329 }, + { species = "KAKUNA", score = 314 } } }, + { class = "PICNICKER", trainer = 5, name = "CINDY", + mons = { { species = "BUTTERFREE", score = 341 }, + { species = "METAPOD", score = 301 }, + { species = "CATERPIE", score = 264 } } }, + { class = "BUG_CATCHER", trainer = 7, name = "JOSH", + mons = { { species = "SCYTHER", score = 326 }, + { species = "BUTTERFREE", score = 292 }, + { species = "METAPOD", score = 282 } } }, + { class = "YOUNGSTER", trainer = 5, name = "SAMUEL", + mons = { { species = "WEEDLE", score = 270 }, + { species = "PINSIR", score = 282 }, + { species = "CATERPIE", score = 251 } } }, + { class = "SCHOOLBOY", trainer = 2, name = "KIPP", + mons = { { species = "VENONAT", score = 267 }, + { species = "PARAS", score = 254 }, + { species = "KAKUNA", score = 259 } } }, +} + +-- The winner ID a slot answers to, and back. BUG_CONTEST_PLAYER is 1. +function BugContest.contestantId(slot) return slot + 1 end +function BugContest.contestantSlot(id) return id - 1 end + +-- LoadContestantName: the trainer CLASS name, its trailing terminator replaced +-- by a space, then the trainer's own name appended -- "BUG CATCHER DON". ID 1 +-- is the player, whose name is copied straight out of wPlayerName. +-- +-- The class and trainer names come from the cache (trainers.lua keys classes +-- by their constant and lists each class's members in order), and fall back to +-- the transcribed row when a class is missing, so a headless test needs no +-- cache at all. +function BugContest.contestantName(data, id, playerName) + if id == BugContest.PLAYER then return playerName or "<PLAYER>" end + local row = BugContest.CONTESTANTS[BugContest.contestantSlot(id)] + if not row then return "" end + local classes = data and data.trainers and data.trainers.classes + local class = classes and classes[row.class] + local className = (class and class.name) or row.class + local member = class and class.trainers and class.trainers[row.trainer] + local trainerName = (member and member.name) or row.name + return className .. " " .. trainerName +end + +-- ---------------------------------------------------------------- the score +-- +-- ContestScore. Everything it tallies is an EIGHT BIT read out of the party +-- struct, and the struct is big-endian, so `[wContestMonMaxHP + 1]` is the LOW +-- byte of max HP and not the high one. The accumulator itself is 16 bit: +-- .AddContestStat adds into hMultiplicand and carries into the byte below it, +-- which the union in ram/hram.asm makes hProduct, and BugContest_JudgeContestants +-- reads that pair back as the score. +-- +-- max HP low byte, four times +-- Attack, Defense, Speed, Special Attack, Special Defense, low bytes +-- a DV term (below) +-- current HP low byte, shifted right three times +-- 1 if the mon is holding an item +-- +-- The DV term is the odd one. It reads BIT 1 of four of the DVs -- never bit +-- 0, never the whole nibble -- and weights them 16 for Defense, 8 for Attack, +-- 4 for Special and 1 for Speed: +-- +-- ld a, [wContestMonDVs + 0] / and %0010 / add a / add a ; c = 8 * def_bit1 +-- swap b / and %0010 / add a / add c ; d = 4 * atk_bit1 + c +-- ld a, [wContestMonDVs + 1] / and %0010 ; c = 2 * spc_bit1 +-- swap b / and %0010 / srl a / add c / add c / add d / add d +-- +-- which lands on spd_bit1 + 4 * spc_bit1 + 8 * atk_bit1 + 16 * def_bit1. +local function low(value) return math.floor(value or 0) % 256 end + +local function bit1(value) return math.floor((value or 0) / 2) % 2 end + +function BugContest.score(mon) + -- `ld a, [wContestMonSpecies] / and a / jr z, .done`: an empty slot scores 0. + if not (mon and mon.species) then return 0 end + local stats = mon.stats or {} + local maxHp = mon.maxHp or stats.hp + local total = low(maxHp) * 4 + total = total + low(stats.attack) + low(stats.defense) + low(stats.speed) + + low(stats.specialAttack) + low(stats.specialDefense) + local dvs = mon.dvs or {} + total = total + 16 * bit1(dvs.defense) + 8 * bit1(dvs.attack) + + 4 * bit1(dvs.special) + bit1(dvs.speed) + total = total + math.floor(low(mon.hp) / 8) + -- `ld a, [wContestMonItem] / and a / jr z, .done`, the last term either way. + if mon.item then total = total + 1 end + return total % 65536 +end + +-- ---------------------------------------------------------------- the podium +-- +-- DetermineContestWinners compares the temp entry against first, then second, +-- then third, with CompareBytes over the two score bytes. CompareBytes only +-- sets carry when the temp score is STRICTLY LESS, so an equal score DISPLACES +-- the sitting entry and pushes it down a place. That is not a rounding detail: +-- it is why the player, who is scored last, wins a tie. +local function beats(entry, incumbent) + return (entry.score or 0) >= ((incumbent and incumbent.score) or 0) +end + +local function copyEntry(entry) + if not entry then return nil end + return { id = entry.id, species = entry.species, score = entry.score } +end + +function BugContest.placeEntry(results, entry) + if beats(entry, results.first) then + results.third = copyEntry(results.second) + results.second = copyEntry(results.first) + results.first = copyEntry(entry) + elseif beats(entry, results.second) then + results.third = copyEntry(results.second) + results.second = copyEntry(entry) + elseif beats(entry, results.third) then + results.third = copyEntry(entry) + end + return results +end + +-- ComputeAIContestantScores' inner roll, for ONE contestant. Two `call Random` +-- bytes: the first picks which of the three listed mons this contestant turned +-- up with (masked to 2 bits and REROLLED on 3, so 0, 1 and 2 are equally +-- likely), the second is a 0..7 bump added to that mon's listed score. +function BugContest.rollContestant(slot, random) + local row = BugContest.CONTESTANTS[slot] + if not row then return nil end + local pick + repeat + pick = byte(random) % 4 + until pick ~= 3 + local mon = row.mons[pick + 1] + return { + id = BugContest.contestantId(slot), + species = mon.species, + score = mon.score + (byte(random) % 8), + } +end + +-- BugContest_JudgeContestants. ClearContestResults wipes the podium, +-- ComputeAIContestantScores walks the ten contestants and skips any whose flag +-- is SET (a set flag is what kept that trainer OFF the contest map, so the five +-- SelectRandomBugContestContestants picked are the five who do NOT score), and +-- only THEN is the player's own entry placed. +-- +-- `state.contestants` is that flag table: state.contestants[slot] == true means +-- the flag is set, i.e. absent. A state with no table at all scores all ten, +-- which is what a save from before the picking ran looks like. +function BugContest.judge(state, playerMon, playerScore, random) + local absent = (state and state.contestants) or {} + local results = { first = nil, second = nil, third = nil } + for slot = 1, BugContest.NUM_CONTESTANTS do + if not absent[slot] then + local entry = BugContest.rollContestant(slot, random) + if entry then BugContest.placeEntry(results, entry) end + end + end + BugContest.placeEntry(results, { + id = BugContest.PLAYER, + species = playerMon and playerMon.species or nil, + score = playerScore or BugContest.score(playerMon), + }) + return results +end + +-- BugContest_GetPlayersResult: walk the podium from THIRD upwards with b +-- counting 3, 2, 1 and stop at the player. Falling off the end leaves b at 0, +-- and that 0 is what sends the script down the consolation-BERRY branch. +function BugContest.playerPlace(results) + local order = { results.third, results.second, results.first } + for index = 1, 3 do + local entry = order[index] + if entry and entry.id == BugContest.PLAYER then return 4 - index end + end + return 0 +end + +-- _BugContestJudging end to end: score, judge, and leave the placing where +-- BugContestJudging's `ld a, b / ld [wScriptVar], a` leaves it. The results +-- are kept on the state so the gate's three text pages can name each winner. +function BugContest.runJudging(save, random) + local state = BugContest.state(save) + if not state then return 0 end + local mon = state.caught + local score = BugContest.score(mon) + local results = BugContest.judge(state, mon, score, random) + state.results = results + state.playerScore = score + state.place = BugContest.playerPlace(results) + -- bug_contest.scored, a Gen 2 invention: Gen 1 has no contest, so there is + -- no name to share. Raised once per contest, after the podium is settled + -- and before the gate prints it, which is the only moment both the player's + -- number and the three winners exist together. + -- + -- mon the mon the player brought out of the park, or nil for none + -- score BugContest.score of it, the number DetermineContestWinners used + -- place 1, 2, 3, or 0 for the consolation BERRY branch + -- results the podium, { first, second, third }, each + -- { id, species, score } with id BugContest.PLAYER for the player + if Runtime.wants("bug_contest.scored") then + Runtime.emit("bug_contest.scored", { + mon = mon, score = score, place = state.place, results = results, + }) + end + return state.place +end + +-- The prize for a placing, or the consolation BERRY for 0. +function BugContest.prizeFor(place) + return BugContest.PRIZES[place] or BugContest.CONSOLATION_PRIZE +end + +-- ---------------------------------------------------- SelectRandomBugContestContestants +-- +-- data/events/bug_contest_flags.asm, transcribed: EVENT_BUG_CATCHING_CONTESTANT_1A +-- through _10A, by NUMBER, because wEventFlags is keyed by number. These are +-- the ten NationalParkBugContest object_event flags, in slot order, and they +-- are the *A set -- the *B set is the same ten people standing in +-- Route36NationalParkGate before the contest starts and nothing here touches +-- it. +-- +-- The extractor now writes the same ten as events.bugContestFlags, so this is +-- the pinned fallback for a cache that predates it rather than a second source +-- of truth; tests/gen2_contest_test.lua compares the two row by row. +BugContest.FLAGS = { + 1814, 1815, 1816, 1817, 1818, 1819, 1820, 1821, 1822, 1823, +} + +-- data/generated/events.lua's copy when it is there, the transcription above +-- when it is not. `tables` is the eventTables the VM carries, not the whole +-- cache: the flag table is a side table a command NAMES, the same as the +-- trades and the floor labels. +function BugContest.contestantFlags(tables) + local extracted = tables and tables.bugContestFlags + if type(extracted) == "table" and #extracted == BugContest.NUM_CONTESTANTS then + return extracted + end + return BugContest.FLAGS +end + +-- Five flags chosen at uniform random out of ten, rejecting a duplicate rather +-- than reshuffling: `call Random / cp $ff / 10 * 10 / jr nc` throws away any +-- byte 250 or over, divides by 25 to land on 0..9, and rerolls a slot whose +-- flag is already set. A SET flag hides that contestant's sprite, so these +-- five are the ones NOT in the park -- and, downstream, the five +-- ComputeAIContestantScores skips. +function BugContest.pickContestants(save, random) + local state = BugContest.state(save) + if not state then return nil end + local n = BugContest.NUM_CONTESTANTS + local limit = math.floor(0xff / n) * n + local step = math.floor(0xff / n) + local chosen = {} + local picked = 0 + while picked < BugContest.CONTESTANTS_PICKED do + local roll + repeat + roll = byte(random) + until roll < limit + -- SimpleDivide's quotient, i.e. 0..9, then 1-based for the Lua table. + local slot = math.floor(roll / step) + 1 + if not chosen[slot] then + chosen[slot] = true + picked = picked + 1 + end + end + state.contestants = chosen + return chosen +end + +-- The half of SelectRandomBugContestContestants that touches wEventFlags, and +-- the reason it is a separate call: `.loop1` runs EventFlagAction RESET_FLAG +-- over ALL TEN before a single pick is made. That reset is not tidiness -- +-- without it last contest's five are still hidden, and the two contests +-- together would empty the park. So every slot is written here, true for a +-- picked one and false for the rest, rather than only the five being set. +-- +-- `events` is src/world/gen2/Events.lua (wEventFlags); a nil one is a headless +-- caller with no flag store, which changes nothing else about the pick. +function BugContest.applyContestantFlags(events, chosen, tables) + if not events then return nil end + local flags = BugContest.contestantFlags(tables) + chosen = chosen or {} + for slot = 1, BugContest.NUM_CONTESTANTS do + local flag = flags[slot] + if flag then events:set(flag, chosen[slot] == true) end + end + return flags +end + +-- ------------------------------------------------------- entering and leaving +-- +-- ContestDropOffMons. The party is not stored anywhere on the cart, it is +-- MASKED: wPartyCount is written down to 1 and the second species byte is +-- replaced with the -1 terminator, so for the duration only the lead mon +-- exists. A Lua list cannot be truncated in place and restored, so the tail +-- moves to state.stash -- which MUST live on the save, because the cart's +-- masked-off party is still sitting in SRAM and survives a save and reload +-- mid-contest. +-- +-- Returns FALSE (0) on success and TRUE (1) when the lead mon has fainted, +-- which is the wScriptVar the officer's `iftrue` branches on. +function BugContest.dropOffMons(save) + local state = BugContest.state(save) + local party = save and save.party + if not (state and party) then return 1 end + local lead = party[1] + if not lead or (lead.hp or 0) <= 0 then return 1 end + local stash = {} + for index = 2, #party do stash[#stash + 1] = party[index] end + state.stash = stash + for index = #party, 2, -1 do party[index] = nil end + return 0 +end + +-- ContestReturnMons. The species of the second mon goes back and the party +-- count is RECOMPUTED by walking to the terminator, which is why a mon caught +-- during the contest -- already sitting in slot 2 by the time this runs -- is +-- kept and the tail lands BEHIND it rather than over it. +function BugContest.returnMons(save) + local state = BugContest.state(save) + local party = save and save.party + if not (state and party) then return end + for _, mon in ipairs(state.stash or {}) do + party[#party + 1] = mon + end + state.stash = nil +end + +-- GiveParkBalls, plus the StartBugContestTimer it farcalls. wContestMon is +-- cleared first, so entering a second contest cannot inherit the last one's +-- catch. +function BugContest.start(save, now) + local state = BugContest.state(save) + if not state then return nil end + state.active = true + state.caught = nil + state.balls = BugContest.BALLS + state.minutes = BugContest.MINUTES + state.seconds = BugContest.SECONDS + state.results = nil + state.place = nil + state.playerScore = nil + local stamp = now or BugContest.now() + state.startTime = { day = stamp.day, hour = stamp.hour, + minute = stamp.minute, second = stamp.second } + return state +end + +-- BugContestResultsScript's `clearflag ENGINE_BUG_CONTEST_TIMER`, and the +-- clean-up around it. The caught mon is deliberately LEFT on the state: +-- CheckPartyFullAfterContest is what consumes it, and it runs after this. +function BugContest.stop(save) + local state = BugContest.state(save) + if not state then return end + state.active = false + state.minutes = 0 + state.seconds = 0 + state.startTime = nil +end + +-- CheckBugContestTimer. Called from CheckTimeEvents while +-- ENGINE_BUG_CONTEST_TIMER is set, and returning true is what makes the +-- overworld call BugCatchingContestOverScript. +-- +-- Any whole day or hour of elapsed time ends it outright. Otherwise the +-- seconds are subtracted with a wrap, and the BORROW that wrap produces is +-- carried into the minutes by the `sbc` -- the `add 60` that rewraps the +-- seconds always overflows a byte (the largest possible shortfall is 59), so +-- the carry it leaves behind is the borrow, not an accident. A minute count +-- that goes negative is the timeout. +function BugContest.tickTimer(save, now) + local state = BugContest.state(save) + if not (state and state.active and state.startTime) then return false end + local since = BugContest.elapsedSince(state.startTime, now, "second") + if since.days ~= 0 or since.hours ~= 0 then + state.minutes, state.seconds = 0, 0 + return true + end + local seconds, borrow = borrowed((state.seconds or 0) - since.seconds, 60) + state.seconds = seconds + local minutes = (state.minutes or 0) - since.minutes - borrow + if minutes < 0 then + state.minutes, state.seconds = 0, 0 + return true + end + state.minutes = minutes + return false +end + +function BugContest.timeLeft(save) + local state = BugContest.state(save) + if not state then return 0, 0 end + return state.minutes or 0, state.seconds or 0 +end + +-- ------------------------------------------------------------- the park balls +-- +-- ContestBattleMenu's third row is "PARKBALL×" followed by +-- wParkBallsRemaining, and PokeBallEffect's `.used_park_ball` does `dec [hl]` +-- instead of tossing an item out of the pack -- so a Park Ball is never in the +-- bag, never taken from it, and never restored. +function BugContest.ballsLeft(save) + local state = BugContest.state(save) + return (state and state.balls) or 0 +end + +function BugContest.useBall(save) + local state = BugContest.state(save) + if not state then return 0 end + state.balls = math.max(0, (state.balls or 0) - 1) + return state.balls +end + +-- CheckContestBattleOver: no balls left is a DRAW and ends the battle, and +-- BugCatchingContestBattleScript's `readmem wParkBallsRemaining / iffalse` +-- then sends the player back to the gate. +function BugContest.isOver(save) + return BugContest.ballsLeft(save) <= 0 +end + +-- ----------------------------------------------------------- the caught mon +-- +-- BugContest_SetCaughtContestMon. With no stock mon the catch is kept +-- outright; with one, the player is shown the STOCK versus THIS comparison and +-- asked, and a YES swaps. PlaceYesNoBox's `ret c` is the NO, so the DEFAULT -- +-- cancelling out of the box with B -- keeps the mon already in stock. +BugContest.KEEP_FIRST = "first" -- .firstcatch, no question asked +BugContest.ASK_SWITCH = "switch" -- DisplayCaughtContestMonStats, then yes/no + +function BugContest.catch(save, mon) + local state = BugContest.state(save) + if not state then return nil end + BugContest.useBall(save) + if not state.caught then + state.caught = mon + return BugContest.KEEP_FIRST + end + return BugContest.ASK_SWITCH, state.caught, mon +end + +-- The YES arm of that question. +function BugContest.switchCaught(save, mon) + local state = BugContest.state(save) + if not state then return nil end + state.caught = mon + return mon +end + +function BugContest.caughtMon(save) + local state = BugContest.state(save) + return state and state.caught or nil +end + +-- ------------------------------------------------- CheckPartyFullAfterContest +-- +-- The catch joins the party if there is room and goes to the current box if +-- there is not, and the answer is the three-way wScriptVar the gate branches +-- on. Boxing needs src/core/gen2/Boxes.lua, which is required lazily so this +-- module stays loadable on its own. +function BugContest.collectCaughtMon(save, partySize, boxes) + local state = BugContest.state(save) + local mon = state and state.caught + if not mon then return BugContest.NO_CATCH end + state.caught = nil + -- caught_nickname.asm:34-39 copies wPlayerName when the contest mon joins. + require("src.battle.gen2.Mon").stampOT(save, mon) + local party = save.party or {} + save.party = party + partySize = partySize or 6 + if #party < partySize then + party[#party + 1] = mon + return BugContest.CAUGHT_MON, mon + end + boxes = boxes or require("src.core.gen2.Boxes") + local box = boxes.box(save, save.currentBox or 1) + if box then box[#box + 1] = mon end + return BugContest.BOXED_MON, mon +end + +-- ------------------------------------------------------------- the module map +-- +-- What each special in data/events/special_pointers.asm should call, so the +-- half of this system that lives in src/script/gen2/Specials.lua meets this +-- half exactly once: +-- +-- ContestDropOffMons BugContest.dropOffMons(save) -> scriptVar +-- ContestReturnMons BugContest.returnMons(save) +-- GiveParkBalls BugContest.start(save) +-- BugContestJudging BugContest.runJudging(save) -> scriptVar +-- CheckPartyFullAfterContest BugContest.collectCaughtMon(save) +-- -> scriptVar +-- SelectRandomBugContestContestants BugContest.pickContestants(save) and +-- BugContest.applyContestantFlags(events, +-- chosen, eventTables) +-- +-- and outside the specials table: +-- +-- CheckTimeEvents BugContest.tickTimer(save) -> ended? +-- ChooseWildEncounter_BugContest BugContest.chooseWild(data) +-- TryWildEncounter_BugContest BugContest.triggers(superTall) +-- PokeBallEffect .used_park_ball BugContest.catch(save, mon) +BugContest.SCREEN_ID = "Gen2ContestMenu" + +return BugContest diff --git a/src/core/gen2/CatchTutorial.lua b/src/core/gen2/CatchTutorial.lua new file mode 100644 index 00000000..ea7d5dfb --- /dev/null +++ b/src/core/gen2/CatchTutorial.lua @@ -0,0 +1,137 @@ +-- The DUDE's catching demonstration (engine/events/catch_tutorial.asm). +-- +-- `catchtutorial BATTLETYPE_TUTORIAL` on Route 29 is a REAL battle, not a +-- cutscene: CatchTutorial swaps the player's name for the DUDE's, hands him a +-- pack of his own, arms an auto-input stream and then farcalls StartBattle. +-- Everything the DUDE does inside that battle is the auto-input ring +-- (src/core/gen2/AutoInput.lua) answering the prompts, which is why the demo +-- reads as somebody playing rather than as a scripted animation. +-- +-- What BATTLETYPE_TUTORIAL changes inside the battle itself, all of it from +-- engine/battle/core.asm and engine/items/item_effects.asm: +-- +-- * no mon is sent out (`jp z, .tutorial_debug` straight to BattleMenu), so +-- the player's box keeps a trainer back-pic for the whole battle and there +-- is no player HUD; +-- * GetTrainerBackpic swaps ChrisBackpic for DudeBackpic; +-- * BattleMenu skips UpdateBattleHuds and EmptyBattleTextbox, so whatever +-- the textbox already said stays under the menu; +-- * BattleMenu_Pack takes `.tutorial`: TutorialPack shows the DUDE's pack, +-- its answer is thrown away (`wPackUsedItem` = FALSE) and POKE_BALL is +-- used regardless; +-- * PokeBallEffect jumps to `.catch_without_fail`, and its tail returns +-- early for a tutorial battle, so nothing is added to the party, nothing +-- is written to the Pokedex and no ball is taken out of the bag. +-- +-- The port keeps all of that here and in src/ui/gen2/BattleState.lua's +-- `tutorial` arm rather than in a Gen 1-shaped fork. + +local AutoInput = require("src.core.gen2.AutoInput") + +local CatchTutorial = {} + +-- CatchTutorial.Dude: `db "DUDE@"`. +CatchTutorial.DUDE_NAME = "DUDE" + +-- wBattleType (constants/battle_constants.asm), the value Route 29's three +-- `catchtutorial` commands carry. +CatchTutorial.BATTLETYPE_TUTORIAL = 3 + +-- .LoadDudeData, as a flat id -> count bag of the shape PackMenu reads. +-- +-- The POKE_BALL count really is 5: the routine writes the ball's own item id +-- into the quantity byte as well, +-- +-- ld a, POKE_BALL +-- ld [hli], a ; the item +-- ld [hli], a ; its quantity +-- +-- and POKE_BALL is 5 in constants/item_constants.asm. It is invisible on the +-- cart only because the DUDE never gets to a second throw. Reproduced rather +-- than tidied to 1, the same way src/battle/gen2/Catching.lua reproduces the +-- catch-rate bugs: a "fixed" pack shows the player a screen the game never +-- draws. +CatchTutorial.PACK = { POTION = 1, POKE_BALL = 5 } + +-- The ball the demo always throws, whatever TutorialPack came back with. +CatchTutorial.BALL = "POKE_BALL" + +-- The four re-arm points, by the stream name in AutoInput.STREAMS: +-- PROMPT home/joypad.asm .wait_input, every text box that waits for A +-- MENU engine/battle/core.asm BattleMenu, which picks ITEM +-- PACK engine/items/pack.asm TutorialPack, which crosses to the BALL +-- pocket and picks the POKE BALL +-- and CatchTutorial's own stream, which is armed around StartBattle and does +-- nothing but hold the player's hands off the controller. +CatchTutorial.PROMPT_STREAM = "DUDE_A" +CatchTutorial.MENU_STREAM = "DUDE_DOWN_A" +CatchTutorial.PACK_STREAM = "DUDE_RIGHT_A" +CatchTutorial.BATTLE_STREAM = "CATCH_TUTORIAL" + +-- Arm one of the streams above on the ring, if there is one. Every re-arm in +-- the ASM is guarded by `ld a, [wInputType] / or a / jr z, .skip`: the DUDE +-- only answers while an auto-input stream is already running, so a player who +-- somehow reaches these prompts by hand is never pushed around by them. +-- +-- `skipIdle` drops the leading blank pairs of a stream a MENU consumes; see +-- AutoInput:skipIdle for why the two kinds of stream are paced differently. +function CatchTutorial.rearm(ring, stream, input, skipIdle) + if not (ring and ring.isActive and ring:isActive()) then return false end + if not AutoInput.STREAMS[stream] then return false end + if not ring:start(stream, input) then return false end + if skipIdle then ring:skipIdle() end + return true +end + +-- The pack TutorialPack draws: wDudeNumItems / wDudeNumBalls are their own +-- buffers, so this is a save-shaped shim rather than a swap of the real bag. +-- The DUDE's name rides along because the PACK's own text addresses the +-- trainer whose bag it is. +function CatchTutorial.dudeSave() + local inventory = {} + for id, count in pairs(CatchTutorial.PACK) do inventory[id] = count end + return { + player = { name = CatchTutorial.DUDE_NAME }, + inventory = inventory, + } +end + +-- The bracket CatchTutorial puts around StartBattle, in the ASM's order: +-- back the player's name up into wMomsName, copy DUDE over it, then force the +-- text delay to TEXT_DELAY_MED so the demo reads at one speed whatever the +-- player set. Returns the state CatchTutorial.finish needs to undo it. +function CatchTutorial.begin(save, options) + local player = save and save.player + local state = { + name = player and player.name, + textSpeed = options and options.textSpeed, + } + if player then + -- `ld hl, wPlayerName / ld de, wMomsName / call CopyBytes`. This is not + -- scratch space: wMomsName is where InitializeNPCNames put "MOM", and the + -- tutorial overwrites it and never puts it back, so from here on the <MOM> + -- character prints the player's name. A real, observable cart quirk, kept + -- for the same reason the catch-rate bugs are kept. + save.mom = save.mom or {} + save.mom.name = player.name + player.name = CatchTutorial.DUDE_NAME + end + if options then + -- `and ~TEXT_DELAY_MASK / add TEXT_DELAY_MED`: only the delay field is + -- touched, every other option bit survives. + options.textSpeed = "MID" + end + return state +end + +-- The tail of .DudeTutorial: `pop af / ld [wOptions], a`, then the player's +-- name is copied back out of wMomsName. Mom's name is NOT restored, because +-- the cart has nowhere left to restore it from. +function CatchTutorial.finish(save, options, state) + state = state or {} + local player = save and save.player + if player and state.name then player.name = state.name end + if options and state.textSpeed then options.textSpeed = state.textSpeed end +end + +return CatchTutorial diff --git a/src/core/gen2/Clock.lua b/src/core/gen2/Clock.lua new file mode 100644 index 00000000..e926cda7 --- /dev/null +++ b/src/core/gen2/Clock.lua @@ -0,0 +1,131 @@ +-- The game clock, as the cart keeps it (home/time.asm, engine/rtc/timeset.asm). +-- +-- Gold does not store "the time". It stores wStartHour / wStartMinute / +-- wStartDay -- the RTC reading at the moment the player answered Oak -- and +-- every read is CalcNSecsHoursDaysSince: the RTC now, MINUS that base, plus +-- what the player said it was. That is why setting the clock to 10 AM does +-- not stop it: it only re-anchors the offset the RTC is read through. +-- +-- The port has no battery-backed RTC to read, so `now` is the host clock and +-- the base is the host clock at the moment the player answered. The stored +-- pair is the same pair the cart stores, so the arithmetic below IS +-- InitTime's, not a second clock: a save with no base at all reads the host +-- clock straight through, which is what every save made before this did. +-- +-- Lives here rather than on World because two screens and one special write +-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek) +-- and World only ever reads it. + +local Runtime = require("src.mods.Runtime") + +local Clock = {} + +Clock.MINUTES_PER_DAY = 24 * 60 +Clock.DAYS = 7 + +-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute +-- buffer left at the zero ByteFill put there. +Clock.DEFAULT_HOUR = 10 +Clock.DEFAULT_MINUTE = 0 + +local function hostMinutes() + local hour = tonumber(os.date("%H")) or 0 + local minute = tonumber(os.date("%M")) or 0 + return (hour * 60 + minute) % Clock.MINUTES_PER_DAY +end + +local function hostWeekday() + -- os.date("%w") is Sunday 0, and constants/misc_constants.asm's SUNDAY is 0 + -- too, so the two agree without a shift. + return (tonumber(os.date("%w")) or 0) % Clock.DAYS +end + +Clock.hostMinutes = hostMinutes +Clock.hostWeekday = hostWeekday + +local function rtc(save) + return type(save) == "table" and save.rtc or nil +end + +-- clock.day_changed, a Gen 2 invention: Gen 1 has no clock at all, so there is +-- no name to share. The cart has no "day changed" routine either -- everything +-- daily is a countdown compared against wCurDay when it is next read -- so the +-- event is raised off the read that IS GetWeekday: every consumer of the day +-- (VAR_WEEKDAY, the world.tod ctx, the Pokegear clock card, the daily resets) +-- goes through Clock.weekday, so a rollover cannot get past this. +-- +-- day the weekday now, SUNDAY 0 .. SATURDAY 6 +-- previous the weekday the last read answered +-- reason "rollover" for the host clock crossing midnight, "set" for +-- Mom's wheel re-anchoring the day (src/ui/gen2/InitClock.lua) +-- +-- The last-seen day is process-local rather than saved: the first read after a +-- boot has nothing to compare against and reports nothing, which is why a +-- Gold boot does not open with a spurious day change. It is only maintained +-- while somebody is subscribed, which is what keeps a mod-free boot free. +local lastDay = nil + +local function noteDay(day, reason) + if not Runtime.wants("clock.day_changed") then + lastDay = nil + return day + end + local previous = lastDay + lastDay = day + if previous ~= nil and previous ~= day then + Runtime.emit("clock.day_changed", + { day = day, previous = previous, reason = reason }) + end + return day +end + +-- _InitTime: store the base so that reading it back answers `hour:minute`. +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + save.rtc = save.rtc or {} + local wanted = (math.floor(hour or 0) % 24) * 60 + + (math.floor(minute or 0) % 60) + save.rtc.startMinute = (wanted - hostMinutes()) % Clock.MINUTES_PER_DAY + return true +end + +-- InitDayOfWeek, which is the same anchor for wCurDay. +function Clock.setWeekday(save, day) + if type(save) ~= "table" then return false end + save.rtc = save.rtc or {} + save.rtc.startDay = (math.floor(day or 0) - hostWeekday()) % Clock.DAYS + save.rtc.dayOfWeek = math.floor(day or 0) % Clock.DAYS + noteDay(save.rtc.dayOfWeek, "set") + return true +end + +-- The clock the game reads: the host clock through the stored offset. +function Clock.minutes(save) + local r = rtc(save) + local offset = r and tonumber(r.startMinute) or 0 + return (hostMinutes() + offset) % Clock.MINUTES_PER_DAY +end + +function Clock.hour(save) + return math.floor(Clock.minutes(save) / 60) +end + +function Clock.minute(save) + return Clock.minutes(save) % 60 +end + +-- GetWeekday, and the poll site clock.day_changed is raised from (see noteDay). +function Clock.weekday(save) + local r = rtc(save) + local offset = r and tonumber(r.startDay) or 0 + return noteDay((hostWeekday() + offset) % Clock.DAYS, "rollover") +end + +-- True once the player has actually answered Oak, so a caller can tell "10 AM +-- because that is what the host says" from "10 AM because the player set it". +function Clock.isSet(save) + local r = rtc(save) + return r ~= nil and r.startMinute ~= nil +end + +return Clock diff --git a/src/core/gen2/CoinCase.lua b/src/core/gen2/CoinCase.lua new file mode 100644 index 00000000..fabc35db --- /dev/null +++ b/src/core/gen2/CoinCase.lua @@ -0,0 +1,70 @@ +-- The player's coin case: engine/events/money.asm GiveCoins / TakeCoins / +-- CheckCoins, transcribed onto save.player.coins. +-- +-- This used to live inside src/ui/gen2/PrizeMenu.lua, which is a registered +-- screen module (Screens.lua id Gen2PrizeMenu). The slot machine and card +-- flip screens read the same case, so a mod that replaces the prize-counter +-- screen has no business also replacing the coin case those other two +-- screens depend on. It is model, not menu, so it lives here instead. +-- +-- MAX_COINS is 9999 (constants/misc_constants.asm) and Save.MAX_COINS is the +-- same number on the save side (src/core/gen2/Save.lua), which is also where +-- Save.normalize re-clamps a loaded file. +local Save = require("src.core.gen2.Save") + +local CoinCase = {} + +CoinCase.MAX_COINS = Save.MAX_COINS + +function CoinCase.coins(save) + local player = save and save.player + return (player and player.coins) or 0 +end + +-- GiveCoins: add, and if the total passes MAX_COINS write MAX_COINS back and +-- return carry. Returns the new balance and whether the case capped. +function CoinCase.giveCoins(save, amount) + local player = save and save.player + if not player then return 0, false end + local total = (player.coins or 0) + math.floor(amount or 0) + if total >= CoinCase.MAX_COINS then + player.coins = CoinCase.MAX_COINS + return player.coins, true + end + player.coins = total + return total, false +end + +-- TakeCoins: subtract, and on borrow leave the case at zero rather than +-- wrapping (`; leave with 0 coins`). +function CoinCase.takeCoins(save, amount) + local player = save and save.player + if not player then return 0, false end + local total = (player.coins or 0) - math.floor(amount or 0) + if total < 0 then + player.coins = 0 + return 0, true + end + player.coins = total + return total, false +end + +-- CheckCoins -> CompareMoneyAction, which writes wScriptVar. +-- constants/script_constants.asm: HAVE_MORE 0, HAVE_AMOUNT 1, HAVE_LESS 2. +CoinCase.HAVE_MORE = 0 +CoinCase.HAVE_AMOUNT = 1 +CoinCase.HAVE_LESS = 2 + +function CoinCase.checkCoins(save, amount) + local have = CoinCase.coins(save) + amount = math.floor(amount or 0) + if have < amount then return CoinCase.HAVE_LESS end + if have == amount then return CoinCase.HAVE_AMOUNT end + return CoinCase.HAVE_MORE +end + +function CoinCase.canAfford(save, cost) + return CoinCase.checkCoins(save, cost) ~= CoinCase.HAVE_LESS +end + +return CoinCase diff --git a/src/core/gen2/CommonText.lua b/src/core/gen2/CommonText.lua new file mode 100644 index 00000000..f6aa704d --- /dev/null +++ b/src/core/gen2/CommonText.lua @@ -0,0 +1,117 @@ +-- The strings an ENGINE routine prints, looked up by their pokegold label. +-- +-- data/generated/text.lua is keyed by "bank:addr", because every string in it +-- was found by following a script pointer. The Day-Care, the POKeMART and the +-- Hall of Fame are printed by asm instead (`ld hl, .SomeText / call +-- PrintText`), so RomExtractorGen2's NAMED_TEXT seeds the walker at those +-- symbols by name and writes text.labels[label] -> that key. This is the +-- lookup on the other side of that table: a screen asks for +-- "_MartWelcomeText" and never for an address, so a repointed string still +-- resolves and a cache built before the seed simply answers nil. +-- +-- `pages` puts the decoded stream back into the shape the Gen 2 speech box +-- draws it in -- up to two lines per screenful -- following home/text.asm: +-- +-- \n `line` / `next`: the box's second row. +-- \f `para`: PlaceString clears the box, so the next screenful starts empty. +-- \v `cont`: the box SCROLLS one row, so the line that was on the bottom +-- row is now on the top one and the new text lands under it. That is +-- why a `cont` shows up here as a page whose first line repeats the +-- previous page's second. +-- +-- `fill` substitutes the markers the decoder leaves behind for the values the +-- cart splices at runtime: {STRBUF} for a TX_RAM name and {NUM} for a +-- TX_DECIMAL PrintNum field, both in the order they appear, plus the named +-- {PLAYER} / {RIVAL}. + +local CommonText = {} + +-- The decoded string for a pokegold label, or nil when this cache predates +-- the seed (or the string is genuinely empty, like _DaycareDummyText). +function CommonText.get(text, label) + if type(text) ~= "table" or not label then return nil end + local labels = text.labels + local key = type(labels) == "table" and labels[label] + local body = key and text[key] + if type(body) ~= "string" or body == "" then return nil end + return body +end + +-- One page is an array of one or two lines. +function CommonText.pages(body) + if type(body) ~= "string" or body == "" then return nil end + local out = {} + local top, bottom = "", nil + local function flush() + if bottom then + out[#out + 1] = { top, bottom } + else + out[#out + 1] = { top } + end + end + local i = 1 + while i <= #body do + local marker = body:find("[\n\f\v]", i) + local chunk = body:sub(i, (marker or (#body + 1)) - 1) + if bottom then + bottom = bottom .. chunk + else + top = top .. chunk + end + if not marker then break end + local m = body:sub(marker, marker) + if m == "\n" then + bottom = bottom or "" + elseif m == "\f" then + flush() + top, bottom = "", nil + else -- "\v" + flush() + top, bottom = bottom or "", "" + end + i = marker + 1 + end + flush() + return out +end + +-- values: an array consumed in order by {STRBUF} and {NUM}, and optionally +-- values.player / values.rival for the two named markers. +function CommonText.fill(pages, values) + if not pages then return nil end + values = values or {} + local next_ = 1 + -- One pass over both markers, because the order they are CONSUMED in is the + -- order they appear in: _MartFinalPriceText opens on its {NUM} and + -- _BargainShopFinalPriceText on its {STRBUF}, and filling one kind before + -- the other would swap the price and the item name on one of them. + local function marker(name) + if name == "PLAYER" or name == "RIVAL" then + return values[name:lower()] or ("{" .. name .. "}") + end + if name ~= "STRBUF" and name ~= "NUM" then return "{" .. name .. "}" end + local v = values[next_] + next_ = next_ + 1 + return v ~= nil and tostring(v) or "" + end + local out = {} + for p, page in ipairs(pages) do + local lines = {} + for l, line in ipairs(page) do + lines[l] = (line:gsub("{(%u+)}", marker)) + end + out[p] = lines + end + return out +end + +-- The whole lookup in one call: nil when the cache has no such label, which +-- is every call site's cue to fall back to its own transcription. +function CommonText.of(text, label, values) + local pages = CommonText.pages(CommonText.get(text, label)) + if not pages then return nil end + if values then return CommonText.fill(pages, values) end + return pages +end + +return CommonText diff --git a/src/core/gen2/Decorations.lua b/src/core/gen2/Decorations.lua new file mode 100644 index 00000000..81e24a7f --- /dev/null +++ b/src/core/gen2/Decorations.lua @@ -0,0 +1,523 @@ +-- The ornaments in the player's bedroom: what the player owns, what is set up +-- where, and the two routines that put both on the map. +-- engine/overworld/decorations.asm, with data/decorations/attributes.asm, +-- names.asm and decorations.asm beside it. +-- +-- Three separate pieces of state, and keeping them apart is the whole model: +-- +-- OWNED one wEventFlags bit per decoration (DECOATTR_EVENT_FLAG). Set +-- means the player has it; nothing else ever clears one. This is +-- the same bitfield `setevent` writes, which is why owning a +-- decoration survives in the save with no new field. +-- PLACED eight bytes (wDecoBed .. wDecoRightOrnament), each holding the +-- DECO_* id standing in that slot or 0 for nothing. A slot holds +-- ONE thing: setting up a second bed puts the first away. +-- VISIBLE what the map shows, which is neither of the above. It is +-- rebuilt from PLACED by ToggleDecorationsVisibility (the four +-- object slots) and ToggleMaptileDecorations (the four blocks) -- +-- and only ever on a MAP LOAD, because those are the +-- PLAYERS_HOUSE_2F NEWMAP and TILES callbacks. A flag a running +-- script sets does not move an object; the object list is read +-- when the map loads and not again, which is why the PC's own +-- `warp NONE, 0, 0` (Script_warp's MAPSETUP_BADWARP arm) is what +-- makes a placement appear. +-- +-- Everything here is love-free and takes its state by argument, so the menu on +-- top of it (src/ui/gen2/DecorationMenu.lua) and the tests can drive the same +-- routines the map callbacks do. + +local Strings = require("src.core.Strings") + +local Decorations = {} + +-- constants/deco_constants.asm, decoration types. The type decides how +-- GetDecoName spells the row and, for the four maptile kinds, that +-- DECOATTR_SPRITE is a BLOCK id rather than a sprite one. +local PLANT, BED, CARPET, POSTER, DOLL, BIGDOLL = 1, 2, 3, 4, 5, 6 + +-- The eight wDeco* bytes. `slot` on an action names one of these. +Decorations.SLOTS = { + "bed", "carpet", "plant", "poster", "console", "bigDoll", + "leftOrnament", "rightOrnament", +} + +-- DoDecorationAction2.DecoActions, as a slot plus a direction rather than a +-- jumptable index: the fourteen entries are seven pairs, and the pair is the +-- only thing any caller cares about. The ornament pair is the odd one out -- +-- it asks which side first, so its slot is decided at run time. +local ACTIONS = { + SET_UP_BED = { slot = "bed" }, + PUT_AWAY_BED = { slot = "bed", put = true }, + SET_UP_CARPET = { slot = "carpet" }, + PUT_AWAY_CARPET = { slot = "carpet", put = true }, + SET_UP_PLANT = { slot = "plant" }, + PUT_AWAY_PLANT = { slot = "plant", put = true }, + SET_UP_POSTER = { slot = "poster" }, + PUT_AWAY_POSTER = { slot = "poster", put = true }, + SET_UP_CONSOLE = { slot = "console" }, + PUT_AWAY_CONSOLE = { slot = "console", put = true }, + SET_UP_BIG_DOLL = { slot = "bigDoll" }, + PUT_AWAY_BIG_DOLL = { slot = "bigDoll", put = true }, + SET_UP_DOLL = { ornament = true }, + PUT_AWAY_DOLL = { ornament = true, put = true }, +} +Decorations.ACTIONS = ACTIONS + +-- wEventFlags bit numbers, from constants/event_flags.asm. Numbers rather +-- than names because that is what the bitfield is keyed by everywhere else in +-- this port (src/world/gen2/Events.lua), and because the extracted scripts +-- that share these bits carry numbers too. +local EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 = 0 +local EVENT_DECO_BED_1 = 676 +local EVENT_DECO_CARPET_1 = 680 +local EVENT_DECO_PLANT_1 = 684 +local EVENT_DECO_POSTER_1 = 687 +local EVENT_DECO_FAMICOM = 691 +local EVENT_DECO_PIKACHU_DOLL = 695 +local EVENT_PLAYERS_ROOM_POSTER = 716 +local EVENT_DECO_GOLD_TROPHY = 717 +local EVENT_DECO_SILVER_TROPHY = 718 +local EVENT_DECO_BIG_SNORLAX_DOLL = 719 + +Decorations.EVENT_PLAYERS_ROOM_POSTER = EVENT_PLAYERS_ROOM_POSTER + +-- The four objects PLAYERS_HOUSE_2F hangs its decorations off. Each is a +-- wVariableSprites slot (SPRITE_VARS-relative, the way `variablesprite`'s byte +-- already is) paired with the object's own event flag, and +-- ToggleDecorationVisibility writes both: the sprite byte says WHAT stands +-- there and the flag says WHETHER it stands there at all. +Decorations.OBJECT_SLOTS = { + { slot = "console", sprite = 0, flag = 1857 }, -- SPRITE_CONSOLE + { slot = "leftOrnament", sprite = 1, flag = 1858 }, -- SPRITE_DOLL_1 + { slot = "rightOrnament", sprite = 2, flag = 1859 }, -- SPRITE_DOLL_2 + { slot = "bigDoll", sprite = 3, flag = 1860 }, -- SPRITE_BIG_DOLL +} + +-- data/decorations/attributes.asm, verbatim and in its order: row 0 is the +-- unnamed CANCEL row every category menu ends on, and the seven rows whose +-- name is PUT_IT_AWAY are the category headers the deco constants share their +-- numbering with (BEDS = 1, CARPETS = 6, ...). So this table is indexed by +-- DECO_*, and `wMenuSelection` on the cart is an index straight into it. +-- +-- `sprite` is one byte with two meanings, exactly as DECOATTR_SPRITE is: a +-- BLOCK id for the four kinds ToggleMaptileDecorations paints, and a SPRITE_* +-- byte for the four an object stands on. The SPRITE_* names are in comments +-- because the value the cart stores IS the byte -- wVariableSprites holds it +-- raw and World:resolveSprite looks it up in constants.spriteOrder. +local function deco(kind, name, action, flag, sprite) + return { type = kind, name = name, action = action, flag = flag, + sprite = sprite } +end + +local TEMP = EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 + +local ATTRIBUTES = { + [0] = deco(PLANT, "CANCEL", nil, TEMP, 0), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_BED", TEMP, 0), -- BEDS + deco(BED, "FEATHERY", "SET_UP_BED", EVENT_DECO_BED_1 + 0, 0x1b), + deco(BED, "PINK", "SET_UP_BED", EVENT_DECO_BED_1 + 1, 0x1c), + deco(BED, "POLKADOT", "SET_UP_BED", EVENT_DECO_BED_1 + 2, 0x1d), + deco(BED, "PIKACHU", "SET_UP_BED", EVENT_DECO_BED_1 + 3, 0x1e), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_CARPET", TEMP, 0), -- CARPETS + deco(CARPET, "RED", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 0, 0x08), + deco(CARPET, "BLUE", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 1, 0x0b), + deco(CARPET, "YELLOW", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 2, 0x0e), + deco(CARPET, "GREEN", "SET_UP_CARPET", EVENT_DECO_CARPET_1 + 3, 0x11), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_PLANT", TEMP, 0), -- PLANTS + deco(PLANT, "MAGNAPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 0, 0x20), + deco(PLANT, "TROPICPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 1, 0x21), + deco(PLANT, "JUMBOPLANT", "SET_UP_PLANT", EVENT_DECO_PLANT_1 + 2, 0x22), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_POSTER", TEMP, 0), -- POSTERS + -- The TOWN MAP poster is a DECO_PLANT: its name is a DecorationNames entry + -- rather than a species, so GetDecoName must not append " POSTER" to it. + deco(PLANT, "TOWN MAP", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 0, 0x1f), + deco(POSTER, "PIKACHU", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 1, 0x23), + deco(POSTER, "CLEFAIRY", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 2, 0x24), + deco(POSTER, "JIGGLYPUFF", "SET_UP_POSTER", EVENT_DECO_POSTER_1 + 3, 0x25), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_CONSOLE", TEMP, 0), -- CONSOLES + deco(PLANT, "NES", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 0, 0x5c), -- SPRITE_FAMICOM + deco(PLANT, "SUPER NES", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 1, 0x5b), + deco(PLANT, "NINTENDO64", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 2, 0x51), + deco(PLANT, "VIRTUAL BOY", "SET_UP_CONSOLE", EVENT_DECO_FAMICOM + 3, 0x57), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_BIG_DOLL", TEMP, 0), -- BIG_DOLLS + deco(BIGDOLL, "SNORLAX", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 0, 0x33), + deco(BIGDOLL, "ONIX", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 1, 0x50), + deco(BIGDOLL, "LAPRAS", "SET_UP_BIG_DOLL", EVENT_DECO_BIG_SNORLAX_DOLL + 2, 0x47), + deco(PLANT, "PUT IT AWAY", "PUT_AWAY_DOLL", TEMP, 0), -- DOLLS + deco(DOLL, "PIKACHU", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 0, 0x8e), + -- The surfing Pikachu doll is a DECO_PLANT too, and for the same reason: + -- "SURF PIKACHU DOLL" is one DecorationNames string, not a mon plus " DOLL". + deco(PLANT, "SURF PIKACHU DOLL", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 1, 0x34), + deco(DOLL, "CLEFAIRY", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 2, 0x8f), + deco(DOLL, "JIGGLYPUFF", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 3, 0x94), + deco(DOLL, "BULBASAUR", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 4, 0x93), + deco(DOLL, "CHARMANDER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 5, 0x90), + deco(DOLL, "SQUIRTLE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 6, 0x89), + deco(DOLL, "POLIWAG", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 7, 0x8d), + deco(DOLL, "DIGLETT", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 8, 0x8c), + -- STARYU's doll stands on SPRITE_STARMIE; the cart's own row says so. + deco(DOLL, "STARYU", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 9, 0x92), + deco(DOLL, "MAGIKARP", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 10, 0x88), + deco(DOLL, "ODDISH", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 11, 0x85), + deco(DOLL, "GENGAR", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 12, 0x86), + deco(DOLL, "SHELLDER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 13, 0x84), + deco(DOLL, "GRIMER", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 14, 0x95), + deco(DOLL, "VOLTORB", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 15, 0x9b), + deco(DOLL, "WEEDLE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 16, 0x83), + deco(DOLL, "UNOWN", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 17, 0x80), + deco(DOLL, "GEODUDE", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 18, 0x81), + deco(DOLL, "MACHOP", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 19, 0x9a), + deco(DOLL, "TENTACOOL", "SET_UP_DOLL", EVENT_DECO_PIKACHU_DOLL + 20, 0x98), + -- Both trophies are SET_UP_DOLL: a trophy stands in an ornament slot. + deco(PLANT, "GOLD TROPHY", "SET_UP_DOLL", EVENT_DECO_GOLD_TROPHY, 0x5e), + deco(PLANT, "SILVER TROPHY", "SET_UP_DOLL", EVENT_DECO_SILVER_TROPHY, 0x5f), +} +Decorations.ATTRIBUTES = ATTRIBUTES + +-- The seven category menus, in _PlayerDecorationMenu's .owned_pointers order. +-- `id` is the DECO_* of the category's own PUT_IT_AWAY row, which is exactly +-- what FindOwnedDecosInCategory appends to its list, and `members` is that +-- routine's own db list -- transcribed rather than derived from a range, +-- because the doll list runs past the two trophies and the big dolls do not +-- sit next to the small ones. +local function range(first, last) + local out = {} + for id = first, last do out[#out + 1] = id end + return out +end + +Decorations.CATEGORIES = { + { id = 1, label = "BED", members = range(2, 5) }, + { id = 6, label = "CARPET", members = range(7, 10) }, + { id = 11, label = "PLANT", members = range(12, 14) }, + { id = 15, label = "POSTER", members = range(16, 19) }, + { id = 20, label = "GAME CONSOLE", members = range(21, 24) }, + { id = 29, label = "ORNAMENT", members = range(30, 52) }, + { id = 25, label = "BIG DOLL", members = range(26, 28) }, +} + +-- data/decorations/decorations.asm DecorationIDs: DECOFLAG_* -> DECO_*. The +-- only thing that reads it is GetDecorationID, i.e. the routines that GIVE a +-- decoration, which name what they hand over by DECOFLAG. +local DECORATION_IDS = {} +do + local order = { + range(2, 5), range(7, 10), range(12, 14), range(16, 19), range(21, 24), + range(30, 50), range(26, 28), { 51, 52 }, + } + for _, group in ipairs(order) do + for _, id in ipairs(group) do + DECORATION_IDS[#DECORATION_IDS + 1] = id + end + end +end +-- DECOFLAG_* is a `const_def` block, so it is 0-based: shift the 1-based Lua +-- list rather than leaving a caller to guess. +function Decorations.idForFlag(decoFlag) + return DECORATION_IDS[(decoFlag or 0) + 1] +end + +-- constants/deco_constants.asm DECOFLAG_*, for the two callers that name one. +Decorations.DECOFLAG_GOLD_TROPHY_DOLL = 43 +Decorations.DECOFLAG_SILVER_TROPHY_DOLL = 44 + +-- DescribeDecoration's five arms and the wDeco* byte each one reads +-- (constants/script_constants.asm DECODESC_*, which is what the cache's +-- decorationOrder carries). Only the three that share +-- DecorationDesc_OrnamentOrConsole put a NAME in wStringBuffer3; the poster +-- arm picks a different script instead, and the giant ornament's says the same +-- thing whatever is standing there. +Decorations.DESC_SLOTS = { + DECODESC_POSTER = { slot = "poster" }, + DECODESC_LEFT_DOLL = { slot = "leftOrnament", named = true }, + DECODESC_RIGHT_DOLL = { slot = "rightOrnament", named = true }, + DECODESC_BIG_DOLL = { slot = "bigDoll" }, + DECODESC_CONSOLE = { slot = "console", named = true }, +} + +-------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------- + +-- The eight wDeco* bytes, on the save. InitDecorations (called from +-- intro_menu.asm at New Game) is the two defaults below: the feathery bed and +-- the TOWN MAP poster are set up before the player has chosen anything, which +-- is why a new game's room already has a bed in it. Filling them in lazily +-- rather than in Save.newGame means an older save gets the same room. +function Decorations.state(save) + if type(save) ~= "table" then return {} end + local state = save.decorations + if not state then + state = { bed = 2, poster = 16 } -- DECO_FEATHERY_BED, DECO_TOWN_MAP + save.decorations = state + end + return state +end + +-- ------------------------------------------------------------ the registry +-- +-- The `decorations` registry (src/mods/Schemas.lua), one of the Gen 2-only +-- six: Red's bedroom has no PC decoration menu, so the name is gated under +-- Gen 1 and routed to data.gen2Decorations under Gen 2. src/mods/Builtins.lua +-- seeds it with the ATTRIBUTES rows above, engine-owned. +-- +-- Ids are "deco:<n>", where n is the attribute row's index -- the DECO_* byte, +-- which is what wMenuSelection holds and what every caller passes. The cart's +-- decoration constants are a bare const_def block with no name table in the +-- ROM behind it, so there is nothing to spell them by; battle_anims addresses +-- its unnamed rows the same way ("subanim:<n>"). +local DECO_ID_PREFIX = "deco:" +local registryRows = nil + +function Decorations.idFor(decoId) + return DECO_ID_PREFIX .. tostring(decoId) +end + +-- One read point for the attribute row, so the merged record reaches every +-- caller: name/owns/give/apply/visibility/tiles below all come through here, +-- as do src/ui/gen2/DecorationMenu.lua and the two DECO_* screens. Falls back +-- to the module's own table, which is what a headless test and a boot with no +-- loader get. +function Decorations.attributes(decoId) + if decoId == nil then return nil end + local merged = registryRows and registryRows[DECO_ID_PREFIX .. tostring(decoId)] + return merged or ATTRIBUTES[decoId] +end + +-- vanilla registrations, engine-owned +function Decorations.registerInto(registry, _, owner) + local count = 0 + for decoId, attr in pairs(ATTRIBUTES) do + registry:register(Decorations.idFor(decoId), attr, owner) + count = count + 1 + end + return count +end + +-- the merged table, held by reference; nil forgets it +function Decorations.useRegistry(data) + registryRows = data and data.gen2Decorations or nil + return registryRows ~= nil +end + +-- GetDecoName: the display name, built from the type and the name column. The +-- four types that name a SPECIES read the mon's name out of the data table, +-- which is what `monName` is for; with no resolver the constant is already the +-- English name for all twenty-four of them. +function Decorations.name(decoId, monName) + local attr = Decorations.attributes(decoId) + if not attr then return "" end + local base = attr.name + if attr.type == BED then return base .. " BED" end + if attr.type == CARPET then return base .. " CARPET" end + local mon = (monName and monName(base)) or base + if attr.type == POSTER then return mon .. " POSTER" end + if attr.type == DOLL then return mon .. " DOLL" end + if attr.type == BIGDOLL then return "BIG " .. mon end + return base +end + +-------------------------------------------------------------------------- +-- Owning +-------------------------------------------------------------------------- + +-- DecorationFlagAction CHECK_FLAG. `events` is the src/world/gen2/Events.lua +-- bitfield the rest of the port keys by number. +function Decorations.owns(events, decoId) + local attr = Decorations.attributes(decoId) + if not (events and attr and attr.flag) then return false end + return events:get(attr.flag) and true or false +end + +-- SetSpecificDecorationFlag, i.e. how a decoration is acquired at all: the +-- NORMAL_BOX / GORGEOUS_BOX trophies (engine/items/item_effects.asm), Mom's +-- doll purchases (engine/events/mom_phone.asm Mom_GiveItemOrDoll) and Mystery +-- Gift all end here. Named by DECOFLAG_*, because that is what every caller +-- passes. +function Decorations.giveFlag(events, decoFlag) + return Decorations.give(events, Decorations.idForFlag(decoFlag)) +end + +function Decorations.give(events, decoId) + local attr = Decorations.attributes(decoId) + if not (events and attr and attr.flag) then return false end + events:set(attr.flag, true) + return true +end + +-- .FindOwnedDecos: the categories with at least one owned decoration, in the +-- .owned_pointers order. EXIT is not in that list -- DecoExitMenu is the +-- eighth .category_pointers row and is always on the menu -- so the caller +-- appends it, the way .FindCategoriesWithOwnedDecos appends its own 7. +function Decorations.ownedCategories(events) + local out = {} + for _, category in ipairs(Decorations.CATEGORIES) do + for _, id in ipairs(category.members) do + if Decorations.owns(events, id) then + out[#out + 1] = category + break + end + end + end + return out +end + +-- FindOwnedDecosInCategory: every owned decoration in the category, then the +-- category's own PUT_IT_AWAY row, then row 0 (CANCEL). An empty category +-- answers an empty list and PopulateDecoCategoryMenu prints "There's nothing +-- to choose." instead of opening a menu. +function Decorations.rows(events, category) + local out = {} + for _, id in ipairs(category and category.members or {}) do + if Decorations.owns(events, id) then out[#out + 1] = id end + end + if #out == 0 then return out end + out[#out + 1] = category.id + out[#out + 1] = 0 + return out +end + +-------------------------------------------------------------------------- +-- Placing +-------------------------------------------------------------------------- + +-- data/text/common_1.asm. Declared up here and formatted at the call site, so +-- Strings.source is what registers them. +local SET_UP = Strings.source("Set up the\n%s.") +local PUT_AWAY = Strings.source("Put away the\n%s.") +local NOTHING_TO_PUT_AWAY = Strings.source("There's nothing to\nput away.") +local ALREADY_SET_UP = Strings.source("That's already set\nup.") +local NOTHING_TO_CHOOSE = Strings.source("There's nothing to\nchoose.") +-- _PutAwayAndSetUpText is one text with a `para` in it, so it is two pages. +local PUT_AWAY_PAGE = Strings.source("Put away the\n%s") +local AND_SET_UP = Strings.source("and set up the\n%s.") + +Decorations.NOTHING_TO_CHOOSE = NOTHING_TO_CHOOSE + +-- DoDecorationAction2 for one menu row. Returns +-- changed wChangedDecorations: TRUE only when the room actually changed, +-- which is what makes the PC reload the map on the way out +-- pages the text to print, in order +-- `side` is "left" or "right" and only an ornament row reads it; a nil side on +-- an ornament row is DecoAction_AskWhichSide's cancel (`scf`), which changes +-- nothing and prints nothing. +function Decorations.apply(state, decoId, side, monName) + local attr = Decorations.attributes(decoId) + if not (state and attr) then return false, {} end + local action = attr.action and ACTIONS[attr.action] + -- DecoAction_nothing: row 0, the CANCEL row. `scf` and no text. + if not action then return false, {} end + + local slot = action.slot + if action.ornament then + if side ~= "left" and side ~= "right" then return false, {} end + slot = (side == "right") and "rightOrnament" or "leftOrnament" + end + + local current = state[slot] or 0 + local name = function(id) return Decorations.name(id, monName) end + + if action.put then + -- DecoAction_TryPutItAway clears the slot BEFORE it checks what was in it, + -- so putting away an empty slot still writes a 0 over the 0. + state[slot] = 0 + if current == 0 then return false, { Strings(NOTHING_TO_PUT_AWAY) } end + -- DecoAction_PutItAway_Ornament names the thing that WAS out, not the row + -- the player picked (the row is the PUT IT AWAY row and has no name). + return true, { Strings(PUT_AWAY, name(current)) } + end + + if current == decoId then + -- .alreadythere: carry, so nothing is written and nothing changed. + return false, { Strings(ALREADY_SET_UP) } + end + + state[slot] = decoId + if current == 0 then + return true, { Strings(SET_UP, name(decoId)) } + end + return true, { Strings(PUT_AWAY_PAGE, name(current)), + Strings(AND_SET_UP, name(decoId)) } +end + +-- DecoAction_SetItUp_Ornament .getwhichside: setting a doll up on one side +-- when the SAME doll is already on the other takes it off the other side -- +-- there is only one of each. Called by the menu right after apply() on an +-- ornament row, because the cart does it inside the same action. +function Decorations.clearOtherSide(state, decoId, side) + if not (state and decoId and decoId ~= 0) then return end + local other = (side == "right") and "leftOrnament" or "rightOrnament" + if state[other] == decoId then state[other] = 0 end +end + +-------------------------------------------------------------------------- +-- Showing: the two map callbacks +-------------------------------------------------------------------------- + +-- ToggleDecorationsVisibility (the PLAYERS_HOUSE_2F MAPCALLBACK_NEWMAP). One +-- row per object slot: an empty slot SETS the object's event flag, which hides +-- it, and a filled one clears the flag and writes the decoration's sprite byte +-- into wVariableSprites. +-- +-- Answers a plain list so the caller can apply it to a live world or a test +-- table; nothing here touches love or the map. +function Decorations.visibility(state) + local out = {} + for _, row in ipairs(Decorations.OBJECT_SLOTS) do + local decoId = state and state[row.slot] or 0 + local attr = Decorations.attributes(decoId) + if decoId ~= 0 and attr then + out[#out + 1] = { sprite = row.sprite, byte = attr.sprite, + flag = row.flag, hidden = false } + else + out[#out + 1] = { sprite = row.sprite, flag = row.flag, hidden = true } + end + end + return out +end + +-- ToggleMaptileDecorations (the MAPCALLBACK_TILES one). Its coordinates "work +-- the same way as for changeblock": PadCoords_de adds 4 to each and +-- GetBlockLocation halves them, so the pairs in the asm are CELL coordinates +-- and the block written is (x / 2, y / 2) -- the same halving +-- src/script/gen2/Vm.lua does for `changeblock`. +-- +-- bed cell (0, 4) -> block (0, 2) +-- plant cell (7, 4) -> block (3, 2) +-- poster cell (6, 0) -> block (3, 0) +-- carpet cell (0, 0) -> block (0, 0), and cell (0, 2) -> block row (0, 1) +-- +-- The carpet is the only one that writes more than one block: its top-left +-- block is the sprite byte and the row under it is +1, +2, +1. An empty slot +-- writes NOTHING (SetDecorationTile's `and a / ret z`), so the map keeps the +-- bare block it was loaded with. +function Decorations.tiles(state) + local out = {} + local function put(slot, blockX, blockY) + local attr = Decorations.attributes(state and state[slot]) + if attr and attr.sprite and attr.sprite ~= 0 then + out[#out + 1] = { x = blockX, y = blockY, block = attr.sprite } + return attr.sprite + end + return nil + end + put("bed", 0, 2) + put("plant", 3, 2) + put("poster", 3, 0) + local carpet = put("carpet", 0, 0) + if carpet then + out[#out + 1] = { x = 0, y = 1, block = carpet + 1 } + out[#out + 1] = { x = 1, y = 1, block = carpet + 2 } + out[#out + 1] = { x = 2, y = 1, block = carpet + 1 } + end + return out +end + +-- SetPosterVisibility, which rides along inside ToggleMaptileDecorations: the +-- bedroom's poster bg_event is BGEVENT_IFSET on EVENT_PLAYERS_ROOM_POSTER, so +-- a bare wall must not be readable at all. +function Decorations.posterVisible(state) + return ((state and state.poster) or 0) ~= 0 +end + +return Decorations diff --git a/src/core/gen2/Evolution.lua b/src/core/gen2/Evolution.lua new file mode 100644 index 00000000..d295debb --- /dev/null +++ b/src/core/gen2/Evolution.lua @@ -0,0 +1,510 @@ +-- Gen 2 evolution: which species a party member turns into, whether its +-- condition is met right now, and what the party record becomes afterwards. +-- +-- Love-free on purpose, the same way src/core/gen2/Boxes.lua is: every +-- question a screen asks here is table math over data/generated/pokemon.lua's +-- `evolutions` rows, so tests/gen2_evolution_test.lua can drive a whole +-- evolution with no window. src/ui/gen2/EvolutionAnim.lua is the only half +-- that draws. +-- +-- Ported from engine/pokemon/evolve.asm: +-- EvolveAfterBattle the master loop over the party, one flagged +-- slot at a time, and the condition walk inside +-- each species' EvosAttacks rows +-- UpdateSpeciesNameIfNotNicknamed the nickname keeps only if it is a real +-- nickname and not the old species' own name +-- LearnLevelMoves the new species' moves for the level it is +-- already at, run right after the pic changes +-- and the frame counts of engine/movie/evolution_animation.asm, which live +-- here rather than in the screen so the schedule is assertable. +-- +-- What flags a slot: engine/battle/core.asm sets wEvolvableFlags for a mon the +-- moment it levels up (right after its LearnLevelMoves run), and ExitBattle +-- calls EvolveAfterBattle only on a win. src/ui/gen2/BattleState.lua keeps +-- that flag set from the battle's own `level` events. +-- +-- Everything a party member becomes is built by src/battle/gen2/Mon.lua and +-- nothing else: Evolution.apply recomputes stats through Mon.stats and rebuilds +-- the record through Mon.new, so an evolved mon can never end up with the +-- half-filled shape a second builder would hand back. + +local Mon = require("src.battle.gen2.Mon") +local Runtime = require("src.mods.Runtime") + +local Evolution = {} + +-- constants/pokemon_data_constants.asm, as the extractor spells them into +-- pokemon.lua's `evolutions` rows. +Evolution.LEVEL = "EVOLVE_LEVEL" +Evolution.ITEM = "EVOLVE_ITEM" +Evolution.TRADE = "EVOLVE_TRADE" +Evolution.HAPPINESS = "EVOLVE_HAPPINESS" +Evolution.STAT = "EVOLVE_STAT" + +-- HAPPINESS_TO_EVOLVE EQU 220. +Evolution.HAPPINESS_TO_EVOLVE = 220 + +-- IsMonHoldingEverstone: one item id, checked before LEVEL, HAPPINESS, STAT +-- and TRADE. It is deliberately NOT checked on the ITEM path -- .item in +-- EvolveAfterBattle never calls it -- which is why a stone still works on a +-- mon holding an Everstone in Gen 2. +Evolution.EVERSTONE = "EVERSTONE" + +-- EVOLVE_HAPPINESS triggers (TR_ANYTIME / TR_MORNDAY / TR_NITE). A row with +-- no `time` is TR_ANYTIME, the first constant. +Evolution.ANYTIME = "ANYTIME" +Evolution.MORNDAY = "MORNDAY" +Evolution.NITE = "NITE" + +-- EVOLVE_STAT comparisons (ATK_GT_DEF / ATK_LT_DEF / ATK_EQ_DEF). +Evolution.ATK_GT_DEF = "ATK_GT_DEF" +Evolution.ATK_LT_DEF = "ATK_LT_DEF" +Evolution.ATK_EQ_DEF = "ATK_EQ_DEF" + +-------------------------------------------------------------------------- +-- Conditions +-------------------------------------------------------------------------- + +function Evolution.holdsEverstone(mon) + return (mon and mon.item) == Evolution.EVERSTONE +end + +-- .got_tyrogue_evo: CompareBytes over wTempMonAttack vs wTempMonDefense, so +-- the comparison is on the mon's CURRENT stats, not its base stats or DVs. +function Evolution.statComparison(mon) + local stats = (mon and mon.stats) or {} + local attack, defense = stats.attack or 0, stats.defense or 0 + if attack == defense then return Evolution.ATK_EQ_DEF end + if attack < defense then return Evolution.ATK_LT_DEF end + return Evolution.ATK_GT_DEF +end + +-- wTimeOfDay is compared against NITE_F and nothing else, so every daytime +-- that is not night reads the same to a happiness evolution. +local function isNight(timeOfDay) + return timeOfDay == Evolution.NITE or timeOfDay == "NITE_F" +end + +-- One EvosAttacks row against one mon. Returns true, or false plus the short +-- reason the row was skipped (for tests and the driver; the cart just falls +-- through to .dont_evolve_N). +-- +-- `ctx` is the state EvolveAfterBattle reads out of WRAM: +-- link wLinkMode ~= 0 (a trade is in progress) +-- timeCapsule wLinkMode == LINK_TIMECAPSULE +-- force wForceEvolution ~= 0 (an evolution stone was just used), +-- which is what gates the ITEM path ON and every other +-- non-trade path OFF +-- item wCurItem, the stone being used +-- timeOfDay wTimeOfDay, one of MORN / DAY / NITE / DARK +-- One record per EvosAttacks method, in the shape src/mods/Schemas.lua's +-- `evolution_methods` registry validates. Same registry NAME Gen 1 fills from +-- src/pokemon/Evolution.lua, because a mod that adds a way to evolve should not +-- have to learn a second noun -- only the ids differ, and they have to: Gold's +-- extractor writes EVOLVE_LEVEL where Red's writes LEVEL. +-- +-- `check` is the schema's required field and keeps its Gen 1 job of answering +-- "does this row fire right now"; the signature is Gold's own +-- fn(entry, mon, ctx) -> ok, reason, consumesHeldItem, because Gold reads an +-- EvosAttacks row and a party record rather than Gen 1's game/mon/evo/trigger. +-- +-- Two fields Gen 2 adds rather than renaming anything, and both exist because +-- EvolveAfterBattle's two cross-cutting gates are per-method: +-- +-- requiresLink EVOLVE_TRADE, the one method a link ENABLES rather than +-- blocks, so it is tested ahead of the link gate +-- requiresForce EVOLVE_ITEM, which only ever fires from a stone's use, so +-- wForceEvolution does not block it the way it blocks the rest +Evolution.METHODS = { + [Evolution.TRADE] = { + requiresLink = true, + check = function(entry, mon, ctx) + if not ctx.link then return false, "not trading" end + if Evolution.holdsEverstone(mon) then return false, "everstone" end + -- `ld a, [hli] / ld b, a / inc a / jr z, .proceed`: $ff (the extractor + -- writes that as no item at all) means any trade will do. + if entry.item then + if ctx.timeCapsule then return false, "time capsule" end + if (mon and mon.item) ~= entry.item then return false, "wrong item" end + -- The held item is consumed by the trade evolution. + return true, nil, true + end + return true + end, + }, + [Evolution.ITEM] = { + requiresForce = true, + check = function(entry, _, ctx) + if entry.item and ctx.item ~= entry.item then + return false, "wrong item" + end + -- .item's own `ld a, [wForceEvolution] / and a / jp z, .dont_evolve_3`: + -- a stone evolution only ever fires from the item's use, never from the + -- after-battle sweep. + if not ctx.force then return false, "not forced" end + return true + end, + }, + [Evolution.LEVEL] = { + check = function(entry, mon, _) + if ((mon and mon.level) or 1) < (entry.level or 0) then + return false, "level" + end + if Evolution.holdsEverstone(mon) then return false, "everstone" end + return true + end, + }, + [Evolution.HAPPINESS] = { + check = function(entry, mon, ctx) + if ((mon and mon.happiness) or 0) < Evolution.HAPPINESS_TO_EVOLVE then + return false, "happiness" + end + if Evolution.holdsEverstone(mon) then return false, "everstone" end + local trigger = entry.time or Evolution.ANYTIME + if trigger == Evolution.NITE and not isNight(ctx.timeOfDay) then + return false, "daytime" + end + if trigger == Evolution.MORNDAY and isNight(ctx.timeOfDay) then + return false, "night" + end + return true + end, + }, + [Evolution.STAT] = { + check = function(entry, mon, _) + if ((mon and mon.level) or 1) < (entry.level or 0) then + return false, "level" + end + if Evolution.holdsEverstone(mon) then return false, "everstone" end + if entry.comparison ~= Evolution.statComparison(mon) then + return false, "stats" + end + return true + end, + }, +} + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function Evolution.registerInto(registry, _, owner) + for id, record in pairs(Evolution.METHODS) do + registry:register(id, record, owner) + end +end + +-- the merged `evolution_methods` record for a method id, the module's own when +-- no loader ran (src/pokemon/Evolution.lua:pendingFor is the Gen 1 twin) +function Evolution.methodFor(data, method) + if method == nil then return nil end + local merged = data and data.gen2EvolutionMethods + return (merged and merged[method]) or Evolution.METHODS[method] +end + +function Evolution.rowMatches(entry, mon, ctx, data) + ctx = ctx or {} + if not (entry and entry.method and entry.into) then return false, "empty" end + local record = Evolution.methodFor(data, entry.method) + local check = record and record.check + + -- The two cross-cutting gates in EvolveAfterBattle's own order, with each + -- method's exemption tested just ahead of the gate it is exempt from -- so + -- an unknown method still reports "linked" or "forced" first, exactly as the + -- if-chain this replaced did. + -- + -- .trade runs BEFORE the link check, because it is the one method that + -- requires a link rather than being blocked by one. + if check and record.requiresLink then return check(entry, mon, ctx) end + -- `ld a, [wLinkMode] / and a / jp nz, .dont_evolve_2`: nothing else fires + -- while a link is up. + if ctx.link then return false, "linked" end + -- .item runs before the force check for the mirror reason: a stone + -- evolution only ever fires WITH wForceEvolution set. + if check and record.requiresForce then return check(entry, mon, ctx) end + -- Everything else is blocked once wForceEvolution is set, so using a stone + -- cannot also trip a level or happiness evolution on the same mon. + if ctx.force then return false, "forced" end + + if not check then return false, "unknown method" end + return check(entry, mon, ctx) +end + +-- The first row of `def.evolutions` that fires, walked in EvosAttacks order +-- exactly the way .loop does -- the order in the ROM is the tiebreak, which is +-- why Poliwhirl's WATER_STONE row beats its KING'S ROCK trade row. +-- +-- Returns entry, consumesHeldItem. +-- +-- Each row's decision is wrapped by the evolution.check hook so a mod can +-- cancel or force any evolution. The contract is the Gen 1 one verbatim +-- (src/pokemon/Evolution.lua:pendingFor): four arguments, and the chain +-- returns ONE boolean. Positions 2, 3 and 4 carry the same things in both +-- games -- the mon, the evolutions[] row, the trigger -- so a wrap written +-- once serves both. Position 1 is Gen 1's `game`, which does not exist this +-- deep in Gold; it carries `data` here, which is the object a listener would +-- reach through game.data anyway. +-- +-- rowMatches is a pure predicate, so running it as the chain's vanilla costs +-- nothing when a mod skips it. `consumes` (the trade row that eats its held +-- item) rides an upvalue rather than a second return, because a Gen 1 mod +-- returns a bare boolean and would otherwise silently clear it; a mod that +-- forces an evolution without calling next() therefore gets consumes = false, +-- which is the safe direction -- an item not eaten, never one eaten twice. +function Evolution.check(def, mon, ctx, data) + local hooked = Runtime.wantsHook("evolution.check") + for _, entry in ipairs((def and def.evolutions) or {}) do + local consumes = false + local function vanilla() + local matched, _, eats = Evolution.rowMatches(entry, mon, ctx, data) + consumes = eats or false + return matched and true or false + end + local ok + if hooked then + ok = Runtime.call("evolution.check", vanilla, data, mon, entry, ctx) + else + ok = vanilla() + end + if ok then return entry, consumes end + end + return nil +end + +-- The same, looking the species up in pokemon.lua for the caller. `data` +-- carries on to rowMatches, which is where the merged evolution_methods +-- registry is read. +function Evolution.checkMon(data, mon, ctx) + local def = data and data.pokemon and mon and data.pokemon[mon.species] + if not def then return nil end + return Evolution.check(def, mon, ctx, data) +end + +-- ExitBattle's gate: the sweep runs only when `wBattleResult & $f` is WIN, so +-- a loss (and the whiteout that follows it) never evolves anything. +function Evolution.runsAfterBattle(outcome) + return outcome ~= "lose" and outcome ~= "draw" +end + +-- EvolveAfterBattle_MasterLoop: the flagged party slots in party order, each +-- with the row that will fire. `flags` is a set of party indices, matching +-- wEvolvableFlags; nil means every slot is eligible (the item path, which sets +-- the flag for wCurPartyMon only, passes a single-entry set). +function Evolution.plan(data, party, flags, ctx) + local out = {} + for index, mon in ipairs(party or {}) do + if not flags or flags[index] then + local entry, consumes = Evolution.checkMon(data, mon, ctx) + if entry then + out[#out + 1] = { + index = index, + mon = mon, + entry = entry, + into = entry.into, + consumesHeldItem = consumes, + } + end + end + end + return out +end + +-------------------------------------------------------------------------- +-- Applying it +-------------------------------------------------------------------------- + +-- The species' display name, which is what the nickname is compared against. +function Evolution.speciesName(data, species) + local def = data and data.pokemon and data.pokemon[species] + return (def and def.name) or species +end + +-- UpdateSpeciesNameIfNotNicknamed: wStringBuffer2 (the nickname captured +-- before the animation) is compared byte for byte against the OLD species' +-- name, and only a mon whose "nickname" is not that name keeps it. The port +-- stores nil for an un-nicknamed mon, so both shapes have to read as "no +-- nickname" here. +function Evolution.keptNickname(data, mon) + local nickname = mon and mon.nickname + if not nickname or nickname == "" then return nil end + if nickname == Evolution.speciesName(data, mon.species) then return nil end + return nickname +end + +-- LearnLevelMoves at wCurPartyLevel: the NEW species' level-up moves whose +-- level is EXACTLY the level the mon is already at (`cp b / jr nz`, not a +-- range), skipping any it already knows. An evolution at level 16 therefore +-- teaches only the moves the new species learns at 16 -- everything it "should" +-- have learned earlier stays unlearned, which is the cart's behaviour. +function Evolution.learnedOnEvolve(data, species, level, mon) + local def = data and data.pokemon and data.pokemon[species] + local known = {} + for _, move in ipairs((mon and mon.moves) or {}) do known[move.id] = true end + local out = {} + for _, row in ipairs((def and def.levelMoves) or {}) do + if row.level == level and not known[row.move] then + known[row.move] = true + out[#out + 1] = row.move + end + end + return out +end + +-- Every field src/battle/gen2/Mon.lua's builder writes. Evolution.apply hands +-- all of these to Mon.new (or sets them straight after) and carries only the +-- keys outside this set across, so the two files cannot drift into disagreeing +-- about who owns a party record's shape. +Evolution.MON_FIELDS = { + species = true, name = true, nickname = true, level = true, + 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, +} + +-- Turn `mon` into `entry.into`. Returns the NEW record; the caller writes it +-- back into the party slot the way `.pop de / pop hl / ld [hl], a` does. +-- +-- The cart's order, and the reason each step is where it is: +-- UpdateSpeciesNameIfNotNicknamed before GetBaseData, so the comparison is +-- still against the old species' name +-- GetBaseData + CalcMonStats new stats at the SAME level and DVs +-- HP += (newMaxHP - oldMaxHP) the delta, not a refill and not a refill +-- to full: a mon that walked in at half +-- health walks out at half health plus the +-- max-HP gain +-- CopyBytes tempmon -> party slot +-- LearnLevelMoves handled by the caller so it can print +-- SetSeenAndCaughtMon Evolution.markPokedex +function Evolution.apply(data, mon, entry) + local species = entry and entry.into + local def = data and data.pokemon and species and data.pokemon[species] + if not def then return nil end + + local level = mon.level or 1 + -- CalcMonStats runs through the one builder, so an evolved mon's stats can + -- never disagree with a freshly built one's. + local stats = Mon.stats(def.baseStats, mon.dvs, level, mon.statExp) + local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or stats.hp + local hp = (mon.hp or previousMax) + (stats.hp - previousMax) + -- The cart does not clamp; the bound only matters for data where an + -- evolution LOSES max HP, which no shipped species does. + hp = math.max(0, math.min(stats.hp, hp)) + + -- `xor a / ld [wTempMonItem], a`: only the trade branch that DEMANDED a held + -- item consumes it; the `$ff` (any trade) branch jumps to .proceed with the + -- item still on. Spelled out rather than as `cond and nil or item`, which + -- would quietly evaluate to the item in both cases. + local heldItem = mon.item + if entry.method == Evolution.TRADE and entry.item then heldItem = nil end + + local evolved = Mon.new(data, species, level, { + dvs = mon.dvs, + moves = mon.moves, + hp = hp, + item = heldItem, + happiness = mon.happiness, + nickname = Evolution.keptNickname(data, mon), + }) + if not evolved then return nil end + + -- wTempMonExp is never touched: the mon keeps the experience it walked in + -- with, so an evolution cannot push it up or down a level. + evolved.experience = mon.experience + evolved.status = mon.status + evolved.caughtLevel = mon.caughtLevel + -- 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 + -- UpdateSpeciesNameIfNotNicknamed, and copying `item` back would undo the + -- trade evolution's `xor a / ld [wTempMonItem], a`. + for key, value in pairs(mon) do + if Evolution.MON_FIELDS[key] == nil then evolved[key] = value end + end + -- Same name and payload keys as the Gen 1 site (src/pokemon/Evolution.lua), + -- so one subscription covers both games. `mon` is the EVOLVED record, not + -- the one that walked in: Gen 1 emits after the species swap, and a listener + -- reading mon.species expects the new one. `via` is the method id that + -- fired (EVOLVE_LEVEL, EVOLVE_ITEM, EVOLVE_TRADE, EVOLVE_HAPPINESS, ...), + -- which is Gen 2's equivalent of Gen 1's trigger kind. + Runtime.emit("pokemon.evolved", { + mon = evolved, fromSpecies = mon.species, toSpecies = species, + via = entry.method, + }) + return evolved +end + +-- SetSeenAndCaughtMon: an evolution ticks the new species off as BOTH seen and +-- caught, the same pair GivePoke sets, because the mon is in the party. +function Evolution.markPokedex(save, species) + if not (save and species) then return false end + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[species] = true + save.pokedex.caught[species] = true + return true +end + +-------------------------------------------------------------------------- +-- Animation schedule (engine/movie/evolution_animation.asm) +-------------------------------------------------------------------------- + +-- EvolveAfterBattle prints EvolvingText ("What? <NICK> is evolving!") and then +-- `ld c, 50 / call DelayFrames` before it clears the top 12 rows and starts +-- the animation. The text box itself is NOT cleared, so that line stays under +-- the pic for the whole animation. +Evolution.EVOLVING_FRAMES = 50 + +-- The old mon's cry, then MUSIC_EVOLUTION, then `ld c, 80 / call DelayFrames` +-- before the palette goes to PREDEFPAL_BLACKOUT and the flashing starts. +Evolution.MUSIC_FRAMES = 80 + +-- Each .ReplaceFrontpic ends in WaitBGMap, i.e. one frame per pic swap, and a +-- "flash" is two of them: the new stage's tiles, then back to the old. +Evolution.SWAP_FRAMES = 1 + +-- .PlayEvolvedSFX: 32 frames spawning balls of light (two every other frame, +-- 32 in all) and then `ld c, 32` more frames animating them out. +Evolution.BALL_SPAWN_FRAMES = 32 +Evolution.BALL_TAIL_FRAMES = 32 + +-- After the animation: CongratulationsYourPokemonText, EvolvedIntoText, +-- MUSIC_NONE, SFX_CAUGHT_MON, WaitSFX, then `ld c, 40 / call DelayFrames`. +Evolution.CONGRATS_FRAMES = 40 + +-- `lb bc, 1, 16` then, per round, `inc b / dec c / dec c`: eight rounds of +-- "hold the old pic for c frames (watching for B), then alternate the two pics +-- b times". The hold shrinks by two frames a round while the alternation gets +-- one flash longer, which is what makes the flicker accelerate. +function Evolution.flashRounds() + local rounds = {} + local flashes, wait = 1, 16 + while wait > 0 do + rounds[#rounds + 1] = { wait = wait, flashes = flashes } + flashes = flashes + 1 + wait = wait - 2 + end + return rounds +end + +-- How many frames the flashing half of the animation takes, end to end. +function Evolution.flashFrames() + local total = 0 + for _, round in ipairs(Evolution.flashRounds()) do + total = total + round.wait + round.flashes * 2 * Evolution.SWAP_FRAMES + end + return total +end + +-- .GenerateBallOfLight spawns two balls on every EVEN jumptable index over the +-- 32 spawn frames, 180 degrees apart, and AnimSeq_RevealNewMon walks each one +-- out from radius $10 in steps of $08 until it passes $80. +Evolution.BALL_RADIUS_START = 0x10 +Evolution.BALL_RADIUS_STEP = 0x08 +Evolution.BALL_RADIUS_END = 0x80 + +-- depixel 9, 11 -- Y TILE FIRST -- and an OAM object draws at (x - 8, y - 16), +-- so the balls come out of (80, 56), just under the middle of the 7x7 pic box +-- at hlcoord 7, 2. +Evolution.BALL_ORIGIN_X = 11 * 8 - 8 +Evolution.BALL_ORIGIN_Y = 9 * 8 - 16 + +return Evolution diff --git a/src/core/gen2/HallOfFame.lua b/src/core/gen2/HallOfFame.lua new file mode 100644 index 00000000..719d7f4a --- /dev/null +++ b/src/core/gen2/HallOfFame.lua @@ -0,0 +1,262 @@ +-- The Hall of Fame roster: what the save keeps when the champion is beaten. +-- +-- Two pokegold routines, and neither of them draws anything: +-- +-- engine/events/halloffame.asm HallOfFame the induction's bookkeeping -- +-- the status flag, wSpawnAfterChampion, the win counter and its cap, and +-- GetHallOfFameParty, which packs the party into the roster row +-- engine/menus/save.asm AddHallOfFameEntry the SRAM shuffle that pushes +-- that row in at the front and drops the thirtieth +-- +-- The screens (src/ui/gen2/HallOfFame.lua) read this and nothing else, which +-- is what lets the whole roster be tested headless. +-- +-- THE ROW. constants/pokemon_data_constants.asm spells the format out: +-- +-- hof_mon: species, id, dvs, level, nickname HOF_MON_LENGTH $10 +-- hall_of_fame: win count, party, terminator HOF_LENGTH $62 +-- +-- so a row is one win count, up to PARTY_LENGTH mons and a -1. The nickname +-- really is capped at MON_NAME_LENGTH - 1 = 10 characters: GetHallOfFameParty +-- copies exactly that many bytes and DisplayHOFMon writes the '@' itself. A +-- Lua list needs no terminator, so the -1 becomes `#entry.mons`, and the cap +-- is enforced on the way in rather than left to whoever reads it back. +-- +-- WHAT IS NOT KEPT. The row has no stats, no moves and no OT name. That is +-- the cart's own choice and it is why the PC's viewer prints a species, a +-- nickname, a level and an ID and nothing else: a Hall of Fame entry is a +-- photograph, not a mon. Nothing here should grow past those six fields. + +local HallOfFame = {} + +-- constants/pokemon_data_constants.asm +HallOfFame.NUM_TEAMS = 30 -- NUM_HOF_TEAMS +HallOfFame.PARTY_LENGTH = 6 -- PARTY_LENGTH +HallOfFame.MON_LENGTH = 0x10 -- HOF_MON_LENGTH, for the record +HallOfFame.LENGTH = 0x62 -- HOF_LENGTH, ditto +-- constants/text_constants.asm MON_NAME_LENGTH - 1 +HallOfFame.NAME_LENGTH = 10 +-- constants/misc_constants.asm +HallOfFame.MASTER_COUNT = 200 -- HOF_MASTER_COUNT + +-- constants/ram_constants.asm. wSpawnAfterChampion is one byte with two +-- values that matter: SPAWN_LANCE after the Elite Four, SPAWN_RED after the +-- Mt. Silver credits. The port keeps the spawn's own name rather than the +-- enum, because that is what src/world/gen2/World.lua resolves against +-- landmarks.spawns. +HallOfFame.SPAWN_LANCE = "SPAWN_LANCE" +HallOfFame.SPAWN_RED = "SPAWN_RED" +-- engine/menus/intro_menu.asm .SpawnAfterE4 / SpawnAfterRed: where each of +-- those two actually puts the player back on CONTINUE. +HallOfFame.POST_CREDITS_SPAWN = { + SPAWN_LANCE = "SPAWN_NEW_BARK", + SPAWN_RED = "SPAWN_MT_SILVER", +} + +-------------------------------------------------------------------------- +-- The save's block +-------------------------------------------------------------------------- + +-- sHallOfFame plus wHallOfFameCount, as one table. Created on demand so a +-- caller never has to check, and so src/core/gen2/Save.lua's normalize can +-- lean on the same shape a migration produces. +function HallOfFame.record(save) + if type(save) ~= "table" then return nil end + save.hallOfFame = save.hallOfFame or {} + local record = save.hallOfFame + record.count = tonumber(record.count) or 0 + if type(record.teams) ~= "table" then record.teams = {} end + return record +end + +-- STATUSFLAGS_HALL_OF_FAME_F (constants/ram_constants.asm), the bit +-- `HallOfFame::` sets before it saves. It is not decoration: the Pokegear map +-- reads it to unlock Kanto (engine/pokegear/pokegear.asm), the radio reads it, +-- and Credits reads it to decide whether B may skip. +function HallOfFame.hasEntered(save) + local record = HallOfFame.record(save) + if not record then return false end + return record.count > 0 or record.entered == true +end + +function HallOfFame.count(save) + local record = HallOfFame.record(save) + return record and record.count or 0 +end + +-- `ld a, [hl] / cp HOF_MASTER_COUNT / jr nc, .ok / inc [hl]`. +-- +-- `ld a, [hl]` leaves a holding the PRE-increment count, so the test is on the +-- OLD value: a save sitting at exactly 200 stops counting there. Returns the +-- new count, which is what GetHallOfFameParty then writes into the row. +function HallOfFame.bumpCount(save) + local record = HallOfFame.record(save) + if not record then return 0 end + if record.count < HallOfFame.MASTER_COUNT then + record.count = record.count + 1 + end + return record.count +end + +-------------------------------------------------------------------------- +-- GetHallOfFameParty +-------------------------------------------------------------------------- + +local function isEgg(mon) + if not mon then return false end + if mon.isEgg or mon.egg then return true end + if mon.species == "EGG" then return true end + local ok, Breeding = pcall(require, "src.core.gen2.Breeding") + if ok and Breeding and Breeding.isEgg then return Breeding.isEgg(mon) end + return false +end +HallOfFame.isEgg = isEgg + +-- One hof_mon out of one party member. The six fields are exactly the six +-- `ld [de], a` runs in GetHallOfFameParty's .mon block, in its order. +local function packMon(mon) + return { + species = mon.species, + otId = tonumber(mon.otId) or 0, + -- MON_DVS is two bytes and the port keeps them as the four nibbles; both + -- forms are stored so the viewer can show a shiny or an Unown letter + -- without a second table. + dvs = mon.dvs, + level = tonumber(mon.level) or 1, + -- `ld bc, MON_NAME_LENGTH - 1 / call CopyBytes`: ten bytes, no terminator. + nickname = tostring(mon.nickname or mon.name or mon.species or "") + :sub(1, HallOfFame.NAME_LENGTH), + shiny = mon.shiny or nil, + gender = mon.gender or nil, + } +end +HallOfFame.packMon = packMon + +-- GetHallOfFameParty: the win count, then every party member that is not an +-- EGG, then the -1. +-- +-- `cp EGG / jr nz, .mon` skips the egg WITHOUT copying it but still steps the +-- party index (`inc c`), which is why the mon behind an egg lands in the row +-- at its own party slot's data and not at the egg's. A Lua walk gets that for +-- free; the loop is written the cart's way anyway so the skip is visible. +function HallOfFame.buildParty(save, party) + local record = HallOfFame.record(save) + local entry = { winCount = record and record.count or 0, mons = {} } + for _, mon in ipairs(party or {}) do + if #entry.mons >= HallOfFame.PARTY_LENGTH then break end + if mon and not isEgg(mon) then + entry.mons[#entry.mons + 1] = packMon(mon) + end + end + return entry +end + +-------------------------------------------------------------------------- +-- AddHallOfFameEntry +-------------------------------------------------------------------------- + +-- The SRAM shuffle: every stored row is copied one slot UP (the copy runs +-- backwards, from the second-to-last row to the last, so nothing is clobbered +-- on the way), the thirtieth falls off the end, and the new row is written at +-- sHallOfFame. So the roster is newest first and holds NUM_HOF_TEAMS. +function HallOfFame.addEntry(save, entry) + local record = HallOfFame.record(save) + if not (record and entry) then return nil end + table.insert(record.teams, 1, entry) + while #record.teams > HallOfFame.NUM_TEAMS do + table.remove(record.teams) + end + return entry +end + +-- LoadHOFTeam: `cp NUM_HOF_TEAMS / jr nc, .invalid` and then `ld a, [hl] / +-- and a / jr z, .absent` -- an index past the end of the table and a row whose +-- first byte (the win count) is zero both mean "stop", which is what ends the +-- PC's master loop at the oldest entry the player actually has. +function HallOfFame.team(save, index) + local record = HallOfFame.record(save) + if not record then return nil end + index = tonumber(index) or 0 + if index < 1 or index > HallOfFame.NUM_TEAMS then return nil end + local entry = record.teams[index] + if not entry or (tonumber(entry.winCount) or 0) == 0 then return nil end + return entry +end + +function HallOfFame.teamCount(save) + local record = HallOfFame.record(save) + if not record then return 0 end + local count = 0 + for index = 1, HallOfFame.NUM_TEAMS do + if not HallOfFame.team(save, index) then break end + count = count + 1 + end + return count +end + +-------------------------------------------------------------------------- +-- The induction +-------------------------------------------------------------------------- + +-- Everything `HallOfFame::` does to the save, in its order and without the +-- screens: +-- +-- set STATUSFLAGS_HALL_OF_FAME_F +-- wSpawnAfterChampion = SPAWN_LANCE +-- bump wHallOfFameCount, capped +-- SaveGameData +-- GetHallOfFameParty +-- AddHallOfFameEntry +-- +-- The save really does happen BEFORE the roster row is written: the cart's +-- AddHallOfFameEntry pokes SRAM directly, so it needs no second save. This +-- port has no SRAM, so `saveFn` is called after the row lands instead -- the +-- one deliberate reordering here, and it exists so a crash between the two +-- cannot leave a save whose count says "inducted" and whose roster is empty. +-- +-- Returns the row, and SECOND the value STATUSFLAGS_HALL_OF_FAME_F held BEFORE +-- the induction. That second value is not bookkeeping: `HallOfFame::` pushes +-- wStatusFlags before it sets the bit and hands the pushed copy to Credits, +-- which is the whole reason a first-time champion cannot fast-forward the +-- credits and a repeat one can. +function HallOfFame.induct(save, party, opts) + opts = opts or {} + local record = HallOfFame.record(save) + if not record then return nil end + local wasEntered = HallOfFame.hasEntered(save) + record.entered = true + save.spawnAfterChampion = opts.spawn or HallOfFame.SPAWN_LANCE + HallOfFame.bumpCount(save) + local entry = HallOfFame.buildParty(save, party or save.party) + HallOfFame.addEntry(save, entry) + if opts.saveFn then opts.saveFn(save) end + return entry, wasEntered +end + +-- RedCredits' half of the same thing: no roster row and no counter, just the +-- spawn that sends CONTINUE to Mt. Silver. Kept here because the byte is the +-- same byte and nothing else in the port writes it. +function HallOfFame.markRedCredits(save) + if type(save) ~= "table" then return end + save.spawnAfterChampion = HallOfFame.SPAWN_RED +end + +-------------------------------------------------------------------------- +-- The post-game continue +-------------------------------------------------------------------------- + +-- engine/menus/intro_menu.asm: CONTINUE reads wSpawnAfterChampion, and a +-- non-zero one replaces the saved position with a spawn point and a WARP map +-- entry rather than a CONTINUE one. PostCreditsSpawn then clears the byte, so +-- this only ever fires on the first load after the credits. +-- +-- Returns the SPAWN_* id to start at, or nil for an ordinary continue. +function HallOfFame.consumePostGameSpawn(save) + if type(save) ~= "table" then return nil end + local pending = save.spawnAfterChampion + if not pending then return nil end + save.spawnAfterChampion = nil + return HallOfFame.POST_CREDITS_SPAWN[pending] +end + +return HallOfFame diff --git a/src/core/gen2/Happiness.lua b/src/core/gen2/Happiness.lua new file mode 100644 index 00000000..c31daedf --- /dev/null +++ b/src/core/gen2/Happiness.lua @@ -0,0 +1,288 @@ +-- Gen 2 friendship. +-- +-- src/battle/gen2/Mon.lua has carried a `happiness` field since the party +-- struct was ported and src/core/gen2/Evolution.lua reads it, but until this +-- module existed nothing ever MOVED it: every mon sat on BASE_HAPPINESS +-- forever, which made EVOLVE_HAPPINESS unreachable and the Goldenrod +-- friendship rater a constant. +-- +-- Two separate mechanisms, both from the cart: +-- +-- ChangeHappiness (engine/events/happiness_egg.asm) applies one of the +-- eighteen HAPPINESS_* events to one party mon. The step it applies is NOT +-- fixed: HappinessChanges (data/events/happiness_changes.asm) is a +-- `table_width 3` block whose three columns are "happiness < 100", +-- "happiness < 200", and "otherwise", so a mon that already likes you gains +-- less and (for the bitter herbs and a poison faint) loses MORE. That tier +-- is read off the value BEFORE the change. +-- +-- StepHappiness (engine/events/happiness_egg.asm) raises the whole party by +-- one point, and it is called only when wStepCount wraps -- and then only on +-- every OTHER call, because it keeps its own wHappinessStepCount toggle. +-- The visible period is therefore 512 footfalls, not 256. +-- +-- Both routines refuse to touch an EGG: ChangeHappiness `cp EGG / ret z` on +-- wPartySpecies before it even finds the byte, and StepHappiness's loop skips +-- the slot. An egg's "happiness" byte is its remaining hatch cycles +-- (engine/pokemon/move_mon.asm writes wBaseEggSteps there), so incrementing it +-- would hand the player a Togepi 512 steps early. The port keeps the two +-- apart on `mon.eggSteps` (see src/core/gen2/Breeding.lua), and this module +-- still honours the egg gate so the ORDER of events matches the cart. + +local Runtime = require("src.mods.Runtime") + +local Happiness = {} + +-- happiness.changed, one of the handful of names Gen 2 invents because Gen 1 +-- has no friendship byte at all (docs/mod-api-gen2-compat.md, "New in Gen 2"). +-- Raised from the two routines that MOVE the byte and from nowhere else, so a +-- mod that mirrors friendship into its own UI sees every point: +-- +-- mon the party record whose byte moved +-- event the HAPPINESS_* name/index the caller passed, or nil for a step +-- reason "event" for ChangeHappiness, "step" for StepHappiness +-- delta the signed step actually applied, AFTER the byte's own clamps +-- from, to the value either side of the change +-- +-- `delta` is `to - from` rather than the table's column, because the $ff and 0 +-- carry clamps are part of what the cart applied: a mon at 254 gaining "5" +-- gained 1. +local function emitChanged(mon, event, reason, from, to) + if not Runtime.wants("happiness.changed") then return end + Runtime.emit("happiness.changed", { + mon = mon, event = event, reason = reason, + delta = to - from, from = from, to = to, + }) +end + +-- constants/pokemon_data_constants.asm, "significant happiness values". +Happiness.BASE = 70 +Happiness.FRIEND_BALL = 200 +Happiness.TO_EVOLVE = 220 +Happiness.THRESHOLD_1 = 100 +Happiness.THRESHOLD_2 = 200 +-- The byte's own ceiling; the floor is 0. +Happiness.MAX = 255 + +-- The HAPPINESS_* enum. Its `const_def 1` makes it ONE based, so these +-- indices line up with a 1-based Lua array without an offset -- the shift that +-- would otherwise drop the last row (HAPPINESS_GROOMING) to nil. +Happiness.EVENT = { + GAINLEVEL = 1, -- 01 + USEDITEM = 2, -- 02 a vitamin + USEDXITEM = 3, -- 03 X ATTACK / X DEFEND / X SPEED / X SPECIAL + GYMBATTLE = 4, -- 04 + LEARNMOVE = 5, -- 05 a TM, not an HM + FAINTED = 6, -- 06 + POISONFAINT = 7, -- 07 + BEATENBYSTRONGFOE = 8, -- 08 + OLDERCUT1 = 9, -- 09 + OLDERCUT2 = 10, -- 0a + OLDERCUT3 = 11, -- 0b + YOUNGCUT1 = 12, -- 0c + YOUNGCUT2 = 13, -- 0d + YOUNGCUT3 = 14, -- 0e + BITTERPOWDER = 15, -- 0f HEAL POWDER / ENERGYPOWDER + ENERGYROOT = 16, -- 10 + REVIVALHERB = 17, -- 11 + GROOMING = 18, -- 12 +} +Happiness.NUM_EVENTS = 18 + +-- data/events/happiness_changes.asm, transcribed row for row. The three +-- columns are the three tiers below, in order. +Happiness.CHANGES = { + { 5, 3, 2 }, -- 01 Gained a level + { 5, 3, 2 }, -- 02 Vitamin + { 1, 1, 0 }, -- 03 X Item + { 3, 2, 1 }, -- 04 Battled a Gym Leader + { 1, 1, 0 }, -- 05 Learned a move + { -1, -1, -1 }, -- 06 Lost to an enemy + { -5, -5, -10 }, -- 07 Fainted due to poison + { -5, -5, -10 }, -- 08 Lost to a much stronger enemy + { 1, 1, 1 }, -- 09 Haircut (older brother) 1 + { 3, 3, 1 }, -- 0a Haircut (older brother) 2 + { 5, 5, 2 }, -- 0b Haircut (older brother) 3 + { 1, 1, 1 }, -- 0c Haircut (younger brother) 1 + { 3, 3, 1 }, -- 0d Haircut (younger brother) 2 + { 10, 10, 4 }, -- 0e Haircut (younger brother) 3 + { -5, -5, -10 }, -- 0f Used Heal Powder or Energypowder (bitter) + { -10, -10, -15 }, -- 10 Used Energy Root (bitter) + { -15, -15, -20 }, -- 11 Used Revival Herb (bitter) + { 3, 3, 1 }, -- 12 Grooming +} + +-- Which of HappinessChanges' three columns a CURRENT value reads. The cart +-- builds this as `e`: 0, then +1 once the value is >= 100, then +1 again once +-- it is >= 200. Returned 1-based to index the rows above. +function Happiness.tier(value) + value = value or 0 + if value < Happiness.THRESHOLD_1 then return 1 end + if value < Happiness.THRESHOLD_2 then return 2 end + return 3 +end + +-- Resolve an event to its index. Callers may pass the name ("GAINLEVEL"), +-- the full constant ("HAPPINESS_GAINLEVEL") or the raw number, so a hand +-- ported script and an extracted one can both say what they mean. +function Happiness.eventIndex(event) + if type(event) == "number" then + if event >= 1 and event <= Happiness.NUM_EVENTS then return event end + return nil + end + if type(event) ~= "string" then return nil end + local name = event:match("^HAPPINESS_(.+)$") or event + return Happiness.EVENT[name] +end + +-- The signed step an event applies at a current value, or nil for an event +-- this table does not know. Split out so a test can assert the tier +-- boundaries without going through a mon. +function Happiness.delta(event, current) + local index = Happiness.eventIndex(event) + if not index then return nil end + local row = Happiness.CHANGES[index] + if not row then return nil end + return row[Happiness.tier(current)] +end + +-- An egg is skipped, exactly as ChangeHappiness's `cp EGG / ret z` does. +-- Matches src/core/gen2/Breeding.lua's isEgg without requiring it, so this +-- module stays loadable on its own. +local function isEgg(mon) + return type(mon) == "table" and mon.isEgg == true +end + +-- ChangeHappiness itself. Returns the new value, or nil when nothing moved +-- (no mon, an egg, or an event the table does not carry). +-- +-- The clamps are the cart's carry checks, not a max/min bolted on: a positive +-- step that overflows the byte lands on $ff (`ld a, -1`), and a negative one +-- that underflows lands on 0 (`xor a`). Both edges are reachable in normal +-- play -- 255 from walking, 0 from a poison faint at low friendship -- so they +-- are load bearing rather than defensive. +function Happiness.change(mon, event) + if type(mon) ~= "table" or isEgg(mon) then return nil end + local current = mon.happiness or 0 + local delta = Happiness.delta(event, current) + if not delta then return nil end + local value = current + delta + if value > Happiness.MAX then value = Happiness.MAX end + if value < 0 then value = 0 end + mon.happiness = value + emitChanged(mon, event, "event", current, value) + return value +end + +-- The same event across a party, which is how the Gym Leader award is written +-- out longhand in engine/battle/core.asm InitEnemyTrainer: +-- +-- ld a, MON_HP / call GetPartyParamLocation +-- ld a, [hli] / or [hl] / jr z, .skipfaintedmon +-- +-- so a mon that is already down does not earn the leader's approval. +-- +-- The OTHER party-wide site is not this loop and must not use it: +-- engine/events/poisonstep.asm walks wPoisonStepPartyFlags and awards +-- HAPPINESS_POISONFAINT to exactly the mons that just dropped, every one of +-- which is at zero HP. That caller wants Happiness.change per flagged slot, +-- or this with opts.includeFainted. +function Happiness.changeParty(party, event, opts) + opts = opts or {} + local touched = 0 + for _, mon in ipairs(party or {}) do + local alive = (mon.hp or 0) > 0 or opts.includeFainted + if alive and Happiness.change(mon, event) then touched = touched + 1 end + end + return touched +end + +-- StepHappiness. Its own toggle: `inc a / and 1 / ld [hl], a / ret nz` alternates +-- 1, 0, 1, 0 and only falls through on the 0, so the party gains a point every +-- SECOND time this is called. The `inc [hl] / jr nz / ld [hl], $ff` on each +-- mon is why 255 sticks rather than wrapping to 0. +-- +-- Returns true on the calls that actually raised the party. +function Happiness.stepCycle(save) + if type(save) ~= "table" then return false end + save.happinessStepCount = ((save.happinessStepCount or 0) + 1) % 2 + if save.happinessStepCount ~= 0 then return false end + for _, mon in ipairs(save.party or {}) do + if not isEgg(mon) then + local from = mon.happiness or 0 + mon.happiness = math.min(Happiness.MAX, from + 1) + -- A mon already sitting on $ff is walked over by `inc [hl] / jr nz` + -- writing $ff back, so nothing moved and there is nothing to report. + if mon.happiness ~= from then + emitChanged(mon, nil, "step", from, mon.happiness) + end + end + end + return true +end + +-- One overworld footfall, from engine/overworld/events.asm's step block: +-- +-- ld hl, wStepCount / inc [hl] / jr nz, .skip_happiness / farcall StepHappiness +-- +-- `inc [hl]` sets z only on the wrap, so StepHappiness runs on the step that +-- takes wStepCount from 255 back to 0 -- one call every 256 steps, and a +-- party point every 512. src/core/gen2/Breeding.lua owns that same counter +-- (`save.stepCount`, advanced by Breeding.step), so this must be called AFTER +-- Breeding.step on the same footfall or it will read the previous step's +-- value. +function Happiness.step(save) + if type(save) ~= "table" then return false end + if (save.stepCount or 0) ~= 0 then return false end + return Happiness.stepCycle(save) +end + +-- How many footfalls are still owed before the party next gains a point. For +-- a driver or a test that wants to walk exactly far enough rather than 512 +-- times blind. +function Happiness.stepsToGain(save) + if type(save) ~= "table" then return nil end + local cycle = 256 + local toWrap = (cycle - (save.stepCount or 0)) % cycle + if toWrap == 0 then toWrap = cycle end + -- A toggle sitting at 1 means the NEXT wrap is the one that pays out. + if (save.happinessStepCount or 0) == 1 then return toWrap end + return toWrap + cycle +end + +-- The three answers HappinessCheckScript (engine/events/std_scripts.asm) picks +-- between off GetFirstPokemonHappiness: `ifless 50` and `ifless 150`, so the +-- boundaries are inclusive at the top of each band. +Happiness.RATER_UNHAPPY = 50 +Happiness.RATER_KINDA = 150 + +function Happiness.raterBand(value) + value = value or 0 + if value < Happiness.RATER_UNHAPPY then return "unhappy" end + if value < Happiness.RATER_KINDA then return "kinda" end + return "happy" -- HappinessText3, the one that means "it adores you" +end + +-- GetFirstPokemonHappiness: the first party slot that is NOT an egg, which is +-- what the rater and the Goldenrod NPCs read. Returns the mon and its slot. +function Happiness.firstMon(party) + for index, mon in ipairs(party or {}) do + if not isEgg(mon) then return mon, index end + end + return nil, nil +end + +-- What a mon starts life on. There is no ChangeHappiness event for a TRADE in +-- Gen 2 (that arrives in Gen 3): a traded or gifted mon simply comes in +-- through the struct initialisers in engine/pokemon/move_mon.asm, every one of +-- which writes BASE_HAPPINESS. The two exceptions are a FRIEND_BALL capture +-- (engine/items/item_effects.asm writes FRIEND_BALL_HAPPINESS over it, for the +-- party AND the box copy) and a hatchling, which +-- src/core/gen2/Breeding.lua sets to its own $78. +function Happiness.forNewMon(opts) + opts = opts or {} + if opts.ball == "FRIEND_BALL" then return Happiness.FRIEND_BALL end + return Happiness.BASE +end + +return Happiness diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua new file mode 100644 index 00000000..7095ada9 --- /dev/null +++ b/src/core/gen2/ItemEffects.lua @@ -0,0 +1,483 @@ +-- Gen 2 pack items used on a party mon outside battle: the ITEMMENU_PARTY +-- half of engine/items/pack.asm UseItem, ported per item family from +-- engine/items/item_effects.asm. +-- +-- Love-free on purpose, the same split src/core/gen2/Evolution.lua makes: +-- every routine here is table math over a party record, so the whole family +-- is assertable without a window. The screens (PackMenu -> PartyMenu -> +-- MoveDeleter) only choose the target and print what comes back. +-- +-- Two contracts every entry point keeps, both from UseItem_SelectMon: +-- - an EGG refuses with CantUseOnEggMessage before any effect runs +-- (`cp EGG` is the routine's first test after the pick), and +-- - a refusal costs nothing: the item is only removed by the caller when +-- `used` comes back true, which is UseDisposableItem's own placement at +-- the tail of each success path. + +local Happiness = require("src.core.gen2.Happiness") +local Mon = require("src.battle.gen2.Mon") + +local ItemEffects = {} + +-- data/items/heal_hp.asm HealingHPAmounts. MAX_STAT_VALUE (999) is the +-- table's own "everything" byte pair; nothing reaches it before the min() +-- against the missing HP. +ItemEffects.HEAL_HP = { + FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, + HYPER_POTION = 200, SUPER_POTION = 50, POTION = 20, + MAX_POTION = 999, FULL_RESTORE = 999, MOOMOO_MILK = 100, + BERRY = 10, GOLD_BERRY = 30, ENERGYPOWDER = 50, ENERGY_ROOT = 200, + RAGECANDYBAR = 20, BERRY_JUICE = 20, +} + +-- data/items/heal_status.asm StatusHealingActions, folded to the status class +-- each row's mask names. FULL_RESTORE is deliberately absent: its status +-- half only runs from FullRestoreEffect's full-HP arm, handled in useOnMon. +ItemEffects.HEAL_STATUS = { + ANTIDOTE = "psn", BURN_HEAL = "brn", ICE_HEAL = "frz", + AWAKENING = "slp", PARLYZ_HEAL = "par", + FULL_HEAL = "all", HEAL_POWDER = "all", + PSNCUREBERRY = "psn", PRZCUREBERRY = "par", BURNT_BERRY = "frz", + ICE_BERRY = "brn", MINT_BERRY = "slp", MIRACLEBERRY = "all", +} + +-- RevivePokemon's one split: `cp REVIVE / jr z, .revive_half_hp` -- only the +-- plain REVIVE halves, MAX_REVIVE and REVIVAL_HERB both take ReviveFullHP. +ItemEffects.REVIVE = { + REVIVE = "half", MAX_REVIVE = "full", REVIVAL_HERB = "full", +} + +-- RestorePP's per-item amounts: `ld c, 10` for the ETHER family, `ld c, 5` +-- under `cp MYSTERYBERRY`, and the `.restore_all` arm for the MAX pair. +-- `each` marks Elixer_RestorePPofAllMoves' loop over all four slots. +ItemEffects.RESTORE_PP = { + ETHER = { amount = 10 }, + MAX_ETHER = { amount = "all" }, + MYSTERYBERRY = { amount = 5 }, + ELIXER = { amount = 10, each = true }, + MAX_ELIXER = { amount = "all", each = true }, +} + +-- EnergypowderEnergyRootCommon / HealPowderEffect: the herb items charge +-- happiness for tasting bitter on top of their heal. +local BITTER = { + ENERGYPOWDER = "BITTERPOWDER", ENERGY_ROOT = "ENERGYROOT", + HEAL_POWDER = "BITTERPOWDER", REVIVAL_HERB = "REVIVALHERB", +} + +-- _ItemWontHaveEffectText / _ItemCantUseOnEggText (data/text/common_3.asm). +ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect." +ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG." +-- _PPRestoredText (data/text/common_3.asm). +ItemEffects.TEXT_PP_RESTORED = "PP was restored." + +-- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm), +-- keyed by the class GetItemHealingAction resolves. Each is the two rows the +-- cart prints: the nickname line, then the fixed line. +local STATUS_TEXT = { + psn = "%s's\ncured of poison.", + par = "%s's\nrid of paralysis.", + brn = "%s's\nburn was healed.", + frz = "%s\nwas defrosted.", + slp = "%s\nwoke up.", + all = "%s's\nhealth returned.", +} + +-- The port's party records spell status several ways (the battle writes the +-- long names, the party list reads both); fold them to the class letters the +-- heal tables use. FNT is an HP fact, not a status, and is not here. +-- +-- Cross-file contract: every name src/battle/gen2/Battle.lua can write into +-- mon.status (STATUS_EFFECTS, SECONDARY_EFFECTS, HELD_STATUS_CURES and the +-- STATUS_TEXT lines beside them) must have a row here, or the cure for it +-- refuses everywhere in the pack. tests/gen2_battle_pack_test.lua walks the +-- battle's own tables against this one so the two cannot drift apart. +ItemEffects.STATUS_CLASS = { + psn = "psn", poison = "psn", toxic = "psn", + brn = "brn", burn = "brn", + frz = "frz", freeze = "frz", + par = "par", paralysis = "par", paralyze = "par", + slp = "slp", sleep = "slp", +} +local STATUS_CLASS = ItemEffects.STATUS_CLASS + +local function monName(mon) + return (mon and (mon.nickname or mon.name or mon.species)) or "?" +end + +local function maxHpOf(mon) + return mon.maxHp or (mon.stats and mon.stats.hp) or 0 +end + +local function fainted(mon) + return (mon.hp or 0) <= 0 +end + +-- HealStatus's field half: the status byte, the toxic counter and the turn +-- counter all clear together (wPlayerSubStatus5's SUBSTATUS_TOXIC rides the +-- same wipe on the cart; this port keeps that ramp on the mon record, so it +-- goes with the byte rather than with the battler). +local function clearStatus(mon) + mon.status = nil + mon.statusTurns = nil + mon.toxicCounter = nil +end + +local function bitterHappiness(itemId, mon) + local event = BITTER[itemId] + if event then Happiness.change(mon, event) end +end + +-- ItemRestoreHP: fainted and full-HP targets refuse before anything is spent, +-- then RestoreHealth adds the HealingHPAmounts row capped at max HP and +-- PARTYMENUTEXT_HEAL_HP prints the delta ("<name>\nrecovered NN HP!"). +local function restoreHp(itemId, mon) + local amount = ItemEffects.HEAL_HP[itemId] + local maxHp = maxHpOf(mon) + if fainted(mon) or (mon.hp or 0) >= maxHp then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + local healed = math.min(maxHp, (mon.hp or 0) + amount) + local gained = healed - (mon.hp or 0) + mon.hp = healed + -- FullRestoreEffect's .FullRestore clears the status alongside the refill. + if itemId == "FULL_RESTORE" then clearStatus(mon) end + bitterHappiness(itemId, mon) + return { + used = true, + text = ("%s\nrecovered %d HP!"):format(monName(mon), gained), + } +end + +-- Which StatusHealingActions class cures this status. The fold table above +-- answers for every spelling the port writes; anything it does not know is a +-- status some mod registered, and its own `statuses` record says which cure +-- answers for it (src/battle/gen2/Battle.lua STATUSES, field `healClass`) -- +-- read straight off the merged table so this module does not have to require +-- the battle engine to cure a burn. +function ItemEffects.healClassOf(status, data) + local key = tostring(status or ""):lower() + local class = STATUS_CLASS[key] + if class then return class end + local statuses = data and data.gen2Statuses + local record = statuses and (statuses[status] or statuses[key]) + return record and record.healClass or nil +end + +-- UseStatusHealer: the status byte must intersect the item's mask ($ff for +-- the HEAL_ALL family); a clean or fainted mon refuses. Field only -- the +-- confusion arm reads wPlayerSubStatus3, which does not exist out of battle. +local function healStatus(itemId, mon, class, data) + if fainted(mon) then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + local have = ItemEffects.healClassOf(mon.status, data) + if not have or (class ~= "all" and have ~= class) then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + clearStatus(mon) + bitterHappiness(itemId, mon) + local shape = STATUS_TEXT[class == "all" and "all" or have] + return { used = true, text = shape:format(monName(mon)) } +end + +-- RevivePokemon: only a fainted mon accepts; REVIVE stands it up at half max +-- HP (ReviveHalfHP's `srl d / rr e`), the other two at full. +local function revive(itemId, mon) + if not fainted(mon) then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + local maxHp = maxHpOf(mon) + mon.hp = (ItemEffects.REVIVE[itemId] == "half") + and math.max(1, math.floor(maxHp / 2)) or maxHp + clearStatus(mon) + bitterHappiness(itemId, mon) + return { + used = true, + text = ("%s\nis revitalized."):format(monName(mon)), + } +end + +-- RareCandyEffect: MAX_LEVEL refuses; otherwise the level goes up one, the +-- experience is SET to CalcExpAtLevel's threshold, the stats recompute and +-- the CURRENT HP gains the max-HP delta -- no clamp and no faint check, so a +-- fainted mon stands up with the delta, exactly as the cart's arithmetic +-- leaves it. `learned` is LearnLevelMoves' slice: the EvosAttacks rows at +-- exactly the new level, for the caller to offer. +local function rareCandy(mon, data) + if (mon.level or 0) >= Mon.MAX_LEVEL then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + local def = data and data.pokemon and data.pokemon[mon.species] + -- through Mon.growthFor, so a growth_rates record a mod registered is the + -- curve a Rare Candy uses too; wiring only some of the six readers would let + -- a mod curve drive battle EXP but not the candy + local growth = Mon.growthFor(data, def and def.growthRate) + local newLevel = (mon.level or 1) + 1 + mon.level = newLevel + mon.experience = Mon.experienceForLevel(growth, newLevel) + local previousMax = maxHpOf(mon) + if def and def.baseStats then + mon.stats = Mon.stats(def.baseStats, mon.dvs, newLevel, mon.statExp) + mon.maxHp = mon.stats.hp + mon.hp = (mon.hp or previousMax) + (mon.maxHp - previousMax) + end + Happiness.change(mon, "GAINLEVEL") + local learned = {} + for _, entry in ipairs((def and def.levelMoves) or {}) do + if entry.level == newLevel then learned[#learned + 1] = entry.move end + end + return { + used = true, + level = newLevel, + learned = learned, + text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), + } +end + +-- --------------------------------------------------------- held attributes +-- +-- The `held_items` registry (src/mods/Schemas.lua), which is the last two +-- columns of data/items/attributes.asm lifted out of the item record: +-- ItemAttributes' HELD_* effect byte and its parameter. Gen 1 has neither, so +-- this is one of the Gen 2-only registries -- gated under Gen 1, routed to +-- data.gen2HeldItems under Gen 2. +-- +-- The table is a VIEW of data.items rather than a second source of truth: the +-- extractor writes both columns onto the item record and +-- src/battle/gen2/Battle.lua:itemDef reads them from there, so the registry +-- has to end up back on data.items or a mod's write would land in a table the +-- battle never opens. Hence the three routines below and the two calls in +-- src/core/Game2.lua:load that use them: +-- +-- heldItemsFrom(items) builds the merge target, BEFORE mods:load, so the +-- registry's base is the vanilla row and a mod's +-- patch stacks on top of it (register collides, the +-- way it does against any other seeded id) +-- heldSnapshot(view) the same two bytes per id, kept aside +-- applyHeldItems(...) AFTER the merge: writes back only the ids whose +-- merged value differs from that snapshot +-- +-- The diff is the whole reason this is not a blind write-back. A mod may just +-- as well reach an item's held columns through the shared `items` registry; +-- that merge has already landed on data.items by the time this runs, and +-- writing every row back would revert it to the vanilla view captured before +-- the merge. Only what the held_items merge actually changed is written, so +-- the two routes compose instead of racing. +function ItemEffects.heldItemsFrom(items) + local out = {} + for id, def in pairs(items or {}) do + if type(def) == "table" and def.heldEffect ~= nil then + out[id] = { heldEffect = def.heldEffect, + heldParameter = def.heldParameter or 0 } + end + end + return out +end + +function ItemEffects.heldSnapshot(view) + local out = {} + for id, row in pairs(view or {}) do + if type(row) == "table" then + out[id] = { heldEffect = row.heldEffect, heldParameter = row.heldParameter } + end + end + return out +end + +-- the merged `held_items` record for an item, the item's own columns when no +-- loader ran +function ItemEffects.heldItemFor(itemId, data) + if itemId == nil then return nil end + local merged = data and data.gen2HeldItems + local row = merged and merged[itemId] + if row then return row end + local def = data and data.items and data.items[itemId] + if type(def) ~= "table" or def.heldEffect == nil then return nil end + return { heldEffect = def.heldEffect, heldParameter = def.heldParameter or 0 } +end + +-- Returns the number of item records the merge changed. Zero on a mod-free +-- boot, which is the parity claim: the seeded row IS the item's own two bytes, +-- so nothing differs and nothing is written. +function ItemEffects.applyHeldItems(data, snapshot) + local items = data and data.items + local merged = data and data.gen2HeldItems + if not (items and merged) then return 0 end + local applied = 0 + for id, row in pairs(merged) do + if type(row) == "table" then + local was = (snapshot or {})[id] + if not was or was.heldEffect ~= row.heldEffect + or was.heldParameter ~= row.heldParameter then + local def = items[id] + if type(def) == "table" then + def.heldEffect = row.heldEffect + def.heldParameter = row.heldParameter or 0 + applied = applied + 1 + end + end + end + end + -- a tombstoned id (mod.content.held_items:remove) leaves the merged table + -- without the row, which is the cart's own "holds nothing" + for id, was in pairs(snapshot or {}) do + if merged[id] == nil and type(items[id]) == "table" and was.heldEffect then + items[id].heldEffect = nil + items[id].heldParameter = nil + applied = applied + 1 + end + end + return applied +end + +-- the merged `item_effects` record for an item id, the module's own when no +-- loader ran; `data` is optional so the callers that only know an item id +-- (src/core/Game2.lua asks partyAction before it has picked a mon) keep +-- their signature and read the module records +function ItemEffects.recordFor(itemId, data) + if itemId == nil then return nil end + local merged = data and data.gen2ItemEffects + return (merged and merged[itemId]) or ItemEffects.RECORDS[itemId] +end + +-- Which family a PACK item runs on a party mon, or nil for anything whose +-- ITEMMENU_PARTY behaviour is not ported (vitamins, PP UP, evolution stones). +-- FULL_RESTORE classifies as "heal"; its full-HP status arm lives inside the +-- record's own `use` the way FullRestoreEffect keeps both halves in one +-- routine. +function ItemEffects.partyAction(itemId, data) + local record = ItemEffects.recordFor(itemId, data) + return record and record.action or nil +end + +-- The one-call families (everything but PP, which needs a move pick first). +-- Returns { used, text, learned?, level? }. +function ItemEffects.useOnMon(itemId, mon, data) + if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if mon.isEgg then + return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + end + local record = ItemEffects.recordFor(itemId, data) + -- The PP family has its own entry point; reaching it here is the same + -- "nothing happens" the unported items get. + if not record or not record.use or record.action == "pp" then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + return record.use({ item = itemId, mon = mon, data = data }) +end + +-- RestorePP over one move entry: a slot already at max refuses (`cp b / +-- jr nc, .dont_restore`), "all" fills it, a number adds capped at max. +local function restoreMove(move, amount) + if type(move) ~= "table" or not move.id then return false end + local maxPp = move.maxPp or move.pp or 0 + if (move.pp or 0) >= maxPp then return false end + if amount == "all" then + move.pp = maxPp + else + move.pp = math.min(maxPp, (move.pp or 0) + amount) + end + return true +end + +-- RestorePPEffect's two shapes: the ETHER family lands on one chosen slot, +-- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and counts +-- -- one restored move is enough for the item to be spent. +function ItemEffects.usePpItem(itemId, mon, slot, data) + if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if mon.isEgg then + return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + end + local record = ItemEffects.recordFor(itemId, data) + if not record or not record.use or record.action ~= "pp" then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + return record.use({ item = itemId, mon = mon, data = data, slot = slot }) +end + +-- ------------------------------------------------------------- the registry +-- +-- The four tables above as records, in the shape src/mods/Schemas.lua's +-- `item_effects` registry validates. Same registry NAME the Gen 1 catalog +-- carries, and the same two Gen 1 fields where Gen 2 has a meaning for them: +-- `use` (required) and `field`, which is true for every row here because this +-- module is the ITEMMENU_PARTY half of pack.asm and nothing else. +-- +-- `use` is fn(ctx) -> { used, text, learned?, level? }, where ctx carries +-- { item, mon, data, slot }. Gen 1 has no call site for its own item_effects +-- records yet, so this is the first shape either generation gives them; it is +-- the one Gold's two entry points already hand around. +-- +-- Two fields Gen 2 adds rather than renaming anything: `action`, the family +-- src/core/Game2.lua and src/ui/gen2/BattleState.lua branch on before they +-- know a target (it is what partyAction answers), and `needsTarget`, which +-- the same screens read as "pick a party mon first". +ItemEffects.RECORDS = {} + +local function record(itemId, action, use) + ItemEffects.RECORDS[itemId] = { + use = use, action = action, field = true, needsTarget = true, + } +end + +-- Built in reverse precedence order, so an id that appeared in two of the +-- source tables would resolve the way partyAction's if-chain used to: heal +-- first, then status, revive, candy and pp. Nothing overlaps today; the order +-- is what keeps that true if something ever does. +for itemId, row in pairs(ItemEffects.RESTORE_PP) do + record(itemId, "pp", function(ctx) + -- RestorePPEffect's two shapes: the ETHER family lands on the chosen slot, + -- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and + -- counts -- one restored move is enough for the item to be spent. + local moves = ctx.mon.moves or {} + local any = false + if row.each then + for _, move in ipairs(moves) do + if restoreMove(move, row.amount) then any = true end + end + else + any = restoreMove(moves[ctx.slot], row.amount) + end + if not any then + return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + end + return { used = true, text = ItemEffects.TEXT_PP_RESTORED } + end) +end + +record("RARE_CANDY", "candy", function(ctx) + return rareCandy(ctx.mon, ctx.data) +end) + +for itemId in pairs(ItemEffects.REVIVE) do + record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end) +end + +for itemId, class in pairs(ItemEffects.HEAL_STATUS) do + record(itemId, "status", function(ctx) + return healStatus(ctx.item, ctx.mon, class, ctx.data) + end) +end + +for itemId in pairs(ItemEffects.HEAL_HP) do + record(itemId, "heal", function(ctx) + -- FullRestoreEffect: a full-HP target falls through to FullyHealStatus + -- rather than refusing, so a paralyzed mon at full health is still cured. + if ctx.item == "FULL_RESTORE" and not fainted(ctx.mon) + and (ctx.mon.hp or 0) >= maxHpOf(ctx.mon) then + return healStatus(ctx.item, ctx.mon, "all", ctx.data) + end + return restoreHp(ctx.item, ctx.mon) + end) +end + +-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of +-- one of these ids collides the way it does on Red and has to say override +function ItemEffects.registerInto(registry, _, owner) + for id, entry in pairs(ItemEffects.RECORDS) do + registry:register(id, entry, owner) + end +end + +return ItemEffects diff --git a/src/core/gen2/MagnetTrain.lua b/src/core/gen2/MagnetTrain.lua new file mode 100644 index 00000000..a8c51cb2 --- /dev/null +++ b/src/core/gen2/MagnetTrain.lua @@ -0,0 +1,361 @@ +-- The Magnet Train ride (pokegold engine/events/magnet_train.asm). +-- +-- `special MagnetTrain` is a self-contained cutscene: it takes the whole frame +-- loop away from the overworld, redraws the background out of the train +-- station tileset, and runs a seven-entry jumptable until it sets +-- JUMPTABLE_EXIT. Everything below is that routine with no love calls in it, +-- so the timing, the scroll and the frameset can be asserted headless; the +-- screen that draws it is src/ui/gen2/MagnetTrainRide.lua. +-- +-- The illusion is one 32x18 background and three horizontal SCX bands: +-- +-- scanlines 0-46 wMagnetTrainOffset * 2 bushes, always moving +-- scanlines 47-94 wMagnetTrainPosition the train body +-- scanlines 95-143 wMagnetTrainOffset * 2 bushes again +-- +-- MagnetTrain_UpdateLYOverrides writes those three runs into +-- wLYOverridesBackup every frame and then advances the offset, so the scenery +-- never stops even while the jumptable is parked on a .WaitScene. The train +-- band is what the jumptable actually moves, and the player sprite rides it +-- through wGlobalAnimXOffset, which is why the two stay locked together. +-- +-- Everything here is 8-bit and wraps, exactly as the ASM's `add` does: the +-- forward trip runs wMagnetTrainPosition from 96 down past 0 to -96, and it is +-- the byte wrap that keeps SCX legal on the way. + +local MagnetTrain = {} +MagnetTrain.__index = MagnetTrain + +-- constants/gfx_constants.asm +local TILE_WIDTH = 8 +local SCREEN_WIDTH, SCREEN_HEIGHT = 20, 18 +local TILEMAP_WIDTH = 32 +local SCREEN_HEIGHT_PX = 144 + +-- PAL_BG_* (constants/gfx_constants.asm) as 1-based palette slots, the way +-- src/world/gen2/Palettes.lua indexes a bgSet. +MagnetTrain.PAL_BG_GRAY = 1 +MagnetTrain.PAL_BG_GREEN = 3 +MagnetTrain.PAL_BG_YELLOW = 5 + +-- SetMagnetTrainPals paints the attribute map in four ByteFills: four rows of +-- green, ten of gray, four more of green, and then six tiles of yellow at +-- (7, 8) for the window the player is framed in. +local BUSH_ROWS_TOP = 4 -- hlbgcoord 0, 0 / bc = 4 * TILEMAP_WIDTH +local TRAIN_ROWS = 10 -- hlbgcoord 0, 4 / bc = 10 * TILEMAP_WIDTH +local WINDOW_ROW = 8 -- hlbgcoord 7, 8 / bc = 6 +local WINDOW_COL, WINDOW_WIDTH = 7, 6 + +-- DrawMagnetTrain lays MagnetTrainTilemap over BG rows 6-9. +local FG_ROW = 6 +local FG_ROWS = 4 + +-- Every value in this file is a hardware byte. +local function b(value) return value % 256 end +MagnetTrain.byte = b + +-------------------------------------------------------------------------- +-- The player in the window +-------------------------------------------------------------------------- + +-- data/sprite_anims/framesets.asm .Frameset_MagnetTrainRed: two OAM sets on an +-- eight frame beat, the fourth of them mirrored, then `oamrestart`. The +-- object's own sequence is SPRITE_ANIM_FUNC_NULL (data/sprite_anims/ +-- objects.asm), so nothing ever moves the struct: the only motion the player +-- has is wGlobalAnimXOffset, which the two MoveTrain states advance. +local FRAMESET = { + { oamset = 1, duration = 8, xflip = false }, + { oamset = 2, duration = 8, xflip = false }, + { oamset = 1, duration = 8, xflip = false }, + { oamset = 2, duration = 8, xflip = true }, + "restart", +} + +-- data/sprite_anims/oam.asm: SPRITE_ANIM_OAMSET_MAGNET_TRAIN_RED_1 and _2 are +-- vtile $00 and $04 over the same .OAMData_MagnetTrainRed 2x2 block. Those +-- two vtiles are the two four-tile requests MagnetTrain_LoadGFX_PlayMusic +-- makes: ChrisSpriteGFX at vTiles0 $00, and ChrisSpriteGFX + 12 tiles at +-- vTiles0 $04. A walking overworld sprite is six 16x16 frames, so those are +-- sheet frame 0 (standing down) and sheet frame 3 (the down walk step). +local OAMSET_VTILE = { 0x00, 0x04 } +MagnetTrain.SHEET_FRAME = { [0x00] = 0, [0x04] = 3 } + +-- .OAMData_MagnetTrainRed, `dbsprite x tile, y tile, x px, y px, vtile, attr`. +-- Every entry carries OAM_PRIO, so on the cart the four tiles sit BEHIND +-- background colours 1-3 and only show through the window's colour 0. +local OAM_DATA = { + { x = b(-1 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x00 }, + { x = b(0 * TILE_WIDTH), y = b(-1 * TILE_WIDTH), tile = 0x01 }, + { x = b(-1 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x02 }, + { x = b(0 * TILE_WIDTH), y = b(0 * TILE_WIDTH), tile = 0x03 }, +} + +-- AddOrSubtractX: a mirrored object flips around its own 8-pixel cell. +local function mirror(value, flip) + if not flip then return value end + return b(-(value + TILE_WIDTH)) +end + +-------------------------------------------------------------------------- +-- The ride +-------------------------------------------------------------------------- + +-- `toGoldenrod` is the wScriptVar the script left behind: Goldenrod's officer +-- writes `setval FALSE` and Saffron's writes `setval TRUE`, and MagnetTrain +-- reads it as "and a / jr nz, .ToGoldenrod". +-- +-- opts.bgTiles is MagnetTrainBGTiles (a 2x18 tilemap) and opts.fgTilemap is +-- MagnetTrainTilemap (20x4); both come from the extracted cache and either may +-- be missing, in which case :tilemap() answers nil and the ride still runs. +function MagnetTrain.new(opts) + opts = opts or {} + local self = setmetatable({}, MagnetTrain) + self.toGoldenrod = opts.toGoldenrod and true or false + if self.toGoldenrod then + -- .ToGoldenrod: `ld a, -1` / `lb bc, -8 tiles, -12 tiles` / + -- `lb de, (11 tiles) + (11 tiles + 4), 12 tiles`. + self.direction = b(-1) + self.holdPosition = b(-8 * TILE_WIDTH) -- b + self.initPosition = b(-12 * TILE_WIDTH) -- c + self.finalPosition = b(12 * TILE_WIDTH) -- e + self.playerSpriteInitX = + b((11 * TILE_WIDTH) + (11 * TILE_WIDTH + 4)) -- d + else + -- forwards: `ld a, 1` / `lb bc, 8 tiles, 12 tiles` / + -- `lb de, (11 tiles) - (11 tiles + 4), -12 tiles`. + self.direction = 1 + self.holdPosition = b(8 * TILE_WIDTH) + self.initPosition = b(12 * TILE_WIDTH) + self.finalPosition = b(-12 * TILE_WIDTH) + self.playerSpriteInitX = b((11 * TILE_WIDTH) - (11 * TILE_WIDTH + 4)) + end + + -- MagnetTrain_LoadGFX_PlayMusic's tail writes wJumptableIndex and the three + -- bytes after it, so the wait counter starts life holding the init position. + -- State 0 overwrites it before any .WaitScene reads it. + self.index = 0 + self.offset = self.initPosition + self.position = self.initPosition + self.waitCounter = self.initPosition + self.exited = false + self.globalX = 0 + + -- The sprite struct does not exist until .InitPlayerSpriteAnim runs. + self.spriteX, self.spriteY = nil, nil + self.frame, self.frameDuration = -1, 0 + self.oamFrame = nil + + self.bgTiles = opts.bgTiles + self.fgTilemap = opts.fgTilemap + self:updateLYOverrides(true) + return self +end + +function MagnetTrain:done() return self.exited end + +-- MagnetTrain's .loop, one pass: the exit bit, PlaySpriteAnimations, the +-- jumptable, then the LY overrides. Returns the sfx label the frame played, +-- which is only ever SFX_TRAIN_ARRIVED on the last one. +function MagnetTrain:update() + if self.exited then return nil end + self:stepSpriteFrame() + local sfx = self:runJumptable() + self:updateLYOverrides() + return sfx +end + +-- MagnetTrain_Jumptable.Next +function MagnetTrain:next() + self.index = self.index + 1 +end + +-- .WaitScene: zero means "advance", anything else counts down. A counter of +-- 128 therefore holds for 129 frames, the last of which is the one that reads +-- zero and moves on. +function MagnetTrain:waitScene() + if self.waitCounter == 0 then + self:next() + return + end + self.waitCounter = self.waitCounter - 1 +end + +function MagnetTrain:runJumptable() + local index = self.index + if index == 0 then + -- .InitPlayerSpriteAnim: InitSpriteAnimStruct at d = (8 + 2) * 8 + 5, + -- e = wMagnetTrainPlayerSpriteInitX, then SPRITEANIMSTRUCT_TILE_ID = 0. + self.spriteY = b((8 + 2) * TILE_WIDTH + 5) + self.spriteX = self.playerSpriteInitX + self.frame, self.frameDuration, self.oamFrame = -1, 0, nil + self:next() + self.waitCounter = 128 + elseif index == 1 or index == 3 or index == 5 then + self:waitScene() + elseif index == 2 then + -- .MoveTrain1: one pixel a frame until the train reaches its hold + -- position, then park for another 128 frames. + if self.position == self.holdPosition then + self:next() + self.waitCounter = 128 + return nil + end + self.position = b(self.position - self.direction) + self.globalX = b(self.globalX + self.direction) + elseif index == 4 then + -- .MoveTrain2: the same, at double speed, until it leaves the screen. + if self.position == self.finalPosition then + self:next() + return nil + end + self.position = b(self.position - 2 * self.direction) + self.globalX = b(self.globalX + 2 * self.direction) + elseif index >= 6 then + -- .TrainArrived: JUMPTABLE_EXIT and SFX_TRAIN_ARRIVED, and the loop reads + -- the exit bit at the top of the next pass. + self.exited = true + return "Sfx_TrainArrived" + end + return nil +end + +-- MagnetTrain_UpdateLYOverrides. The three runs are 6*8-1, 6*8 and 6*8+1 +-- entries, which is 144 scanlines exactly; hSCX takes the first band's value +-- because line 0 is drawn before the LCD interrupt has fired. The offset +-- advances by two per frame (`add d` twice) AFTER the overrides are written. +-- +-- `initial` builds the first frame's table without advancing, matching +-- MagnetTrain_InitLYOverrides, which ByteFills the whole array with the init +-- position before the loop starts. +function MagnetTrain:updateLYOverrides(initial) + local ly = self.ly or {} + if initial then + for line = 1, SCREEN_HEIGHT_PX do ly[line] = self.initPosition end + self.ly = ly + self.scx = self.initPosition + return ly + end + local scx = b(self.offset * 2) + self.scx = scx + local line = 1 + for _ = 1, 6 * TILE_WIDTH - 1 do ly[line] = scx; line = line + 1 end + for _ = 1, 6 * TILE_WIDTH do ly[line] = self.position; line = line + 1 end + for _ = 1, 6 * TILE_WIDTH + 1 do ly[line] = scx; line = line + 1 end + self.ly = ly + self.offset = b(self.offset + 2 * self.direction) + return ly +end + +-- The SCX each of the three bands is scrolled by this frame, as +-- { first scanline, last scanline (inclusive), scx }. A band is a run of +-- equal LY overrides, so this is the same information the table holds and the +-- shape a renderer wants. +function MagnetTrain:bands() + local ly = self.ly + if not ly then return {} end + local out = {} + local start, value = 0, ly[1] + for line = 1, SCREEN_HEIGHT_PX do + if ly[line] ~= value then + out[#out + 1] = { start, line - 2, value } + start, value = line - 1, ly[line] + end + end + out[#out + 1] = { start, SCREEN_HEIGHT_PX - 1, value } + return out +end + +-------------------------------------------------------------------------- +-- The background +-------------------------------------------------------------------------- + +-- DrawMagnetTrain. Rows 0-17 are MagnetTrainBGTiles' two-tile pair for that +-- row repeated across all 32 columns (`.FillAlt`, TILEMAP_WIDTH / 2 times), +-- and then MagnetTrainTilemap's four 20-tile lines are laid over rows 6-9. +-- +-- Answers nil when the cache carries no tilemaps, which is what a cache built +-- before the extractor learned about them looks like. +function MagnetTrain:tilemap() + local bg = self.bgTiles + if not (bg and #bg >= SCREEN_HEIGHT * 2) then return nil end + local rows = {} + for row = 0, SCREEN_HEIGHT - 1 do + local even, odd = bg[row * 2 + 1], bg[row * 2 + 2] + local line = {} + for col = 0, TILEMAP_WIDTH - 1 do + line[col + 1] = (col % 2 == 0) and even or odd + end + rows[row + 1] = line + end + local fg = self.fgTilemap + if fg and #fg >= SCREEN_WIDTH * FG_ROWS then + for line = 0, FG_ROWS - 1 do + local row = rows[FG_ROW + line + 1] + for col = 0, SCREEN_WIDTH - 1 do + row[col + 1] = fg[line * SCREEN_WIDTH + col + 1] + end + end + end + return rows +end + +-- SetMagnetTrainPals, read back as "which palette does this cell use". +-- `col` and `row` are 0-based BG map coordinates. +function MagnetTrain.paletteSlot(col, row) + if row == WINDOW_ROW and col >= WINDOW_COL + and col < WINDOW_COL + WINDOW_WIDTH then + return MagnetTrain.PAL_BG_YELLOW + end + if row < BUSH_ROWS_TOP then return MagnetTrain.PAL_BG_GREEN end + if row < BUSH_ROWS_TOP + TRAIN_ROWS then return MagnetTrain.PAL_BG_GRAY end + return MagnetTrain.PAL_BG_GREEN +end + +-------------------------------------------------------------------------- +-- The sprite +-------------------------------------------------------------------------- + +-- GetSpriteAnimFrame, cut down to one frameset that never waits, ends or +-- changes sequence. A frame with duration 8 is therefore shown nine times: +-- the pass that sets the duration, then eight that decrement it. +function MagnetTrain:stepSpriteFrame() + if not self.spriteX then return end + if self.frameDuration ~= 0 then + self.frameDuration = self.frameDuration - 1 + return + end + self.frame = self.frame + 1 + local entry = FRAMESET[self.frame + 1] + if entry == "restart" then + self.frame = 0 + entry = FRAMESET[1] + end + self.frameDuration = entry.duration + self.oamFrame = entry +end + +-- The four OAM entries the player is drawn as this frame, each +-- { x, y, tile, xflip } in SCREEN pixels (the hardware's byte minus the 8 and +-- 16 pixel OAM origins). `tile` is the vtile the OAM set resolves to, which +-- MagnetTrain.SHEET_FRAME turns into a 16x16 frame of the walking sheet. +-- +-- Empty before .InitPlayerSpriteAnim has run. +function MagnetTrain:playerOam() + local entry = self.oamFrame + if not (entry and self.spriteX) then return {} end + local vtile = OAMSET_VTILE[entry.oamset] + local out = {} + for _, sprite in ipairs(OAM_DATA) do + local x = b(self.spriteX + self.globalX + mirror(sprite.x, entry.xflip)) + local y = b(self.spriteY + sprite.y) + out[#out + 1] = { + x = x - 8, + y = y - 16, + tile = vtile + sprite.tile, + xflip = entry.xflip, + } + end + return out +end + +return MagnetTrain diff --git a/src/core/gen2/Mail.lua b/src/core/gen2/Mail.lua new file mode 100644 index 00000000..859ebea7 --- /dev/null +++ b/src/core/gen2/Mail.lua @@ -0,0 +1,503 @@ +-- MAIL: the ten mail items, the letter a party mon carries, and the MAILBOX +-- the player's PC keeps. engine/pokemon/mail.asm and engine/pokemon/mail_2.asm, +-- with the `mailmsg` struct from macros/ram.asm as it is laid out in +-- ram/sram.asm (sPartyMail, sMailboxCount, sMailboxes). +-- +-- The struct is what makes this save-format work rather than a field on a mon: +-- +-- Message MAIL_MSG_LENGTH bytes, drawn as two MAIL_LINE_LENGTH lines +-- Author NAME_LENGTH - 1 characters, the player at compose time +-- AuthorID dw, wPlayerID -- what CheckPokeMail's OT half would read +-- Species db, wCurPartySpecies when the letter was written +-- Type db, the MAIL item itself, which is what picks the stationery +-- +-- sPartyMail is SIX of those indexed BY PARTY SLOT, not by mon, which is the +-- whole reason `removeSlot` and `swapSlots` exist below: RemoveMonFromPartyOrBox +-- shifts the mail up behind a departing mon (engine/pokemon/move_mon.asm's +-- "Mail time!" tail) and SwitchPartyMons swaps two structs +-- (engine/pokemon/switchpartymons.asm). Hanging the letter off the mon table +-- instead would have been easier and would have been a different save. +-- +-- Nothing here touches love or a screen: the four mail screens +-- (src/ui/gen2/MailCompose, MailRead, MailMenu, MailboxMenu) and the script +-- VM's `givepokemail` / `checkpokemail` all drive these routines, so the rules +-- are testable without a keyboard on screen. + +local Runtime = require("src.mods.Runtime") + +local Mail = {} + +-- constants/item_data_constants.asm. +Mail.MAIL_MSG_LENGTH = 0x20 +Mail.MAIL_LINE_LENGTH = 0x10 +Mail.MAILBOX_CAPACITY = 10 +-- constants/text_constants.asm NAME_LENGTH is 11 including the terminator, and +-- the struct's Author field is NAME_LENGTH - 1. +Mail.AUTHOR_LENGTH = 10 +-- constants/pokemon_data_constants.asm PARTY_LENGTH; sPartyMail is this many +-- structs and no more. +Mail.PARTY_LENGTH = 6 + +-- data/items/mail_items.asm MailItems, in its order. ItemIsMail is a linear +-- search of exactly this list, so anything not on it is not mail however its +-- id is spelled -- which matters, because LITEBLUEMAIL and PORTRAITMAIL do not +-- end in "_MAIL" and a name test would miss both. +Mail.ITEMS = { + "FLOWER_MAIL", "SURF_MAIL", "LITEBLUEMAIL", "PORTRAITMAIL", "LOVELY_MAIL", + "EON_MAIL", "MORPH_MAIL", "BLUESKY_MAIL", "MUSIC_MAIL", "MIRAGE_MAIL", +} + +-- The *_MAIL_INDEX block at the top of engine/pokemon/mail_2.asm, which is a +-- plain `const_def` (0-based) and indexes both MailGFXPointers and +-- LoadMailPalettes.MailPals. Kept as a lookup because the READ screen needs +-- it to know where the author line sits: PORTRAITMAIL_INDEX puts it at column +-- 8, MORPH_MAIL_INDEX at 6, everything else at 5 (MailGFX_PlaceMessage). +Mail.INDEX = {} +local IS_MAIL = {} +for i, id in ipairs(Mail.ITEMS) do + Mail.INDEX[id] = i - 1 + IS_MAIL[id] = true +end + +-- constants/script_constants.asm POKEMAIL_*, a `const_def` block, so 0-based. +Mail.POKEMAIL_WRONG_MAIL = 0 +Mail.POKEMAIL_CORRECT = 1 +Mail.POKEMAIL_REFUSED = 2 +Mail.POKEMAIL_NO_MAIL = 3 +Mail.POKEMAIL_LAST_MON = 4 + +-- ItemIsMail (engine/pokemon/mail_2.asm), which is the ONLY definition of +-- "this is mail" anywhere on the cart. Everything that asks -- the Day-Care, +-- the PC's deposit, the party submenu's MAIL row, the Time Capsule -- calls it. +function Mail.isMail(itemId) + return itemId ~= nil and IS_MAIL[itemId] == true +end + +function Mail.monHoldsMail(mon) + return type(mon) == "table" and Mail.isMail(mon.item) +end + +-------------------------------------------------------------------------- +-- Storage +-------------------------------------------------------------------------- + +-- save.mail is the pair of SRAM regions: `party` is sPartyMail (a sparse array +-- keyed by party slot) and `box` is sMailboxes with sMailboxCount implied by +-- its length. Created on demand so a save that has never seen a letter still +-- serializes as an empty table rather than six zero-filled structs. +function Mail.state(save) + if type(save) ~= "table" then return { party = {}, box = {} } end + local state = save.mail + if type(state) ~= "table" then + state = { party = {}, box = {} } + save.mail = state + end + state.party = state.party or {} + state.box = state.box or {} + return state +end + +-- One `mailmsg`. `message` keeps the line break as "\n" the way the text +-- decoder does, because GivePokeMail copies the script's bytes verbatim and a +-- `next` in there is a real character in the buffer. +function Mail.entry(itemId, message, author, authorId, species) + return { + type = itemId, + message = message or "", + author = author or "", + authorId = authorId or 0, + species = species, + } +end + +-- Picking a party letter out of the record. This is also the mail.read +-- latch's re-arm (see Mail.lines): every reader asks here for the struct it is +-- about to open, so a second look at the same letter is a second event. +function Mail.get(save, slot) + Mail.armRead() + return Mail.state(save).party[slot] +end + +function Mail.set(save, slot, entry) + if not (slot and slot >= 1 and slot <= Mail.PARTY_LENGTH) then return false end + Mail.state(save).party[slot] = entry + return true +end + +function Mail.clear(save, slot) + Mail.state(save).party[slot] = nil +end + +-- The "Mail time!" tail of RemoveMonFromPartyOrBox: every struct after the +-- departing slot moves up one, and the slot that was last is cleared. Called +-- by anything that takes a mon OUT of the party -- a deposit, a trade, the +-- CheckPokeMail handover -- because sPartyMail is keyed by slot and would +-- otherwise hand the next mon along someone else's letter. +function Mail.removeSlot(save, slot) + local party = Mail.state(save).party + if not (slot and slot >= 1) then return end + for i = slot, Mail.PARTY_LENGTH - 1 do + party[i] = party[i + 1] + end + party[Mail.PARTY_LENGTH] = nil +end + +-- SwitchPartyMons copies the two structs through wSwitchMonBuffer, so a party +-- reorder carries each letter with its mon. +function Mail.swapSlots(save, a, b) + local party = Mail.state(save).party + party[a], party[b] = party[b], party[a] +end + +-- IsAnyMonHoldingMail. The PC's MOVE POKéMON W/O MAIL row and the Time +-- Capsule's party check are the two callers; both refuse outright rather than +-- naming which mon. +function Mail.anyMonHoldingMail(save) + for _, mon in ipairs((save and save.party) or {}) do + if Mail.monHoldsMail(mon) then return true end + end + return false +end + +-------------------------------------------------------------------------- +-- Writing +-------------------------------------------------------------------------- + +-- mail.written, a Gen 2 invention: Gen 1 has no mail, so there is no name to +-- share. Both routines that put a NEW struct into a party slot raise it -- +-- the compose screen and the `givepokemail` a gift script runs -- because from +-- a mod's side they are the same fact: a letter now exists and is pinned to a +-- mon. Moving one that already exists (MoveMailFromPCToParty, SwitchPartyMons) +-- does not, because nothing was written. +-- +-- entry the `mailmsg` struct, already trimmed to MAIL_MSG_LENGTH +-- slot the party slot it landed on, 1 based +-- mon the party record it is pinned to, or nil when compose was handed +-- no mon +-- message the stored text, with its "\n" kept as one character +-- author whose name the letter carries -- the player for a composed one, +-- the giver's OT for a scripted one +-- source "compose" for ComposeMailMessage, "script" for GivePokeMail +local function emitWritten(entry, slot, mon, source) + if not Runtime.wants("mail.written") then return end + Runtime.emit("mail.written", { + entry = entry, slot = slot, mon = mon, + message = entry.message, author = entry.author, source = source, + }) +end + +-- ComposeMailMessage (engine/pokemon/mon_menu.asm), the tail after the +-- keyboard closes: the author is wPlayerName, the id wPlayerID, the species +-- wCurPartySpecies and the type wCurItem -- so a letter remembers who wrote it +-- and which mon it was pinned to, neither of which the reader can change +-- afterwards. +function Mail.compose(save, slot, message, mon, itemId) + if not (save and slot and itemId) then return false end + local player = save.player or {} + local entry = Mail.entry(itemId, Mail.trim(message), + (player.name or ""):sub(1, Mail.AUTHOR_LENGTH), player.id or 0, + mon and mon.species) + local ok = Mail.set(save, slot, entry) + if ok then emitWritten(entry, slot, mon, "compose") end + return ok +end + +-- GivePokeMail (engine/pokemon/mail.asm): `ld a, [wPartyCount] / dec a` -- the +-- letter always lands on the LAST party member, which is the mon the +-- `givepoke` right before it just added. The author fields come from that +-- mon's OT rather than from the player, because the giver is the OT. +function Mail.give(save, itemId, message) + local party = save and save.party + if not (party and #party > 0 and Mail.isMail(itemId)) then return false end + local slot = #party + local mon = party[slot] + mon.item = itemId + local entry = Mail.entry(itemId, Mail.trim(message), + tostring(mon.otName or mon.ot or ""):sub(1, Mail.AUTHOR_LENGTH), + mon.otId or 0, mon.species) + local ok = Mail.set(save, slot, entry) + if ok then emitWritten(entry, slot, mon, "script") end + return ok +end + +-- The buffer is MAIL_MSG_LENGTH bytes and the compose screen can never write +-- past it, but a script's string and a hand-built save can, so the trim is +-- here rather than at each call site. Counted in characters, not bytes: the +-- charset carries a handful of multi-byte glyphs (é, ♂, ¥, …). +function Mail.trim(message) + message = tostring(message or "") + local out, count = {}, 0 + for _, ch in ipairs(Mail.characters(message)) do + if count >= Mail.MAIL_MSG_LENGTH then break end + out[#out + 1] = ch + count = count + 1 + end + return table.concat(out) +end + +-- UTF-8 aware split, so "é" counts as one character the way one charmap byte +-- does. A "\n" is a character too: it is the `next` byte GivePokeMail copied. +function Mail.characters(text) + local out = {} + local i, n = 1, #text + while i <= n do + local b = text:byte(i) + local width = 1 + if b >= 0xF0 then width = 4 + elseif b >= 0xE0 then width = 3 + elseif b >= 0xC0 then width = 2 end + out[#out + 1] = text:sub(i, i + width - 1) + i = i + width + end + return out +end + +-- The two rows MailGFX_PlaceMessage draws. A message written on the compose +-- screen has no break in it -- the cart stores '<NEXT>' at offset +-- MAIL_LINE_LENGTH and PlaceString obeys it -- so the split is by width; a +-- script's message carries its own break and is split on that instead. +-- mail.read, a Gen 2 invention: Gen 1 has no mail, so there is no name to +-- share. MailGFX_PlaceMessage is the cart's own "the player is looking at +-- this letter" moment and Mail.lines is the port's transcription of it, so the +-- event rides here rather than on the reader screen -- one seam serves the +-- party reader, the MAILBOX reader and anything a mod opens itself. +-- +-- The latch is what makes it one event per opened letter instead of one per +-- frame: src/ui/gen2/MailRead.lua redraws the page every frame while it is up. +-- It is re-armed by Mail.get and Mail.mailbox, which are how a reader picks +-- the letter it is about to open (src/ui/gen2/MailMenu.lua:read, +-- src/ui/gen2/MailboxMenu.lua:readMail), so opening the SAME letter twice is +-- two events rather than one. +-- +-- entry the `mailmsg` struct being read +-- message its stored text +-- author the name printed under it, "" when the struct carries none +-- top, bottom the two rows as MailGFX_PlaceMessage lays them out +local lastRead = nil + +function Mail.armRead() + lastRead = nil +end + +local function emitRead(entry, top, bottom) + if type(entry) ~= "table" then return end + if not Runtime.wants("mail.read") then + lastRead = nil + return + end + if lastRead == entry then return end + lastRead = entry + Runtime.emit("mail.read", { + entry = entry, message = entry.message, author = entry.author, + top = top, bottom = bottom, + }) +end + +function Mail.lines(entry) + local message = (type(entry) == "table" and entry.message) or "" + local top, bottom = message:match("^(.-)\n(.*)$") + if not top then + local chars = Mail.characters(message) + if #chars <= Mail.MAIL_LINE_LENGTH then + top, bottom = message, "" + else + top = table.concat(chars, "", 1, Mail.MAIL_LINE_LENGTH) + bottom = table.concat(chars, "", Mail.MAIL_LINE_LENGTH + 1) + end + end + emitRead(entry, top, bottom) + return top, bottom +end + +-------------------------------------------------------------------------- +-- The MAILBOX +-------------------------------------------------------------------------- + +function Mail.mailboxCount(save) + return #Mail.state(save).box +end + +-- sMailboxes itself. Re-arms the mail.read latch for the same reason +-- Mail.get does: the MAILBOX reader picks its letter out of this list. +function Mail.mailbox(save) + Mail.armRead() + return Mail.state(save).box +end + +function Mail.mailboxFull(save) + return Mail.mailboxCount(save) >= Mail.MAILBOX_CAPACITY +end + +-- SendMailToPC. Carry (false here) on either "this mon is not holding mail" +-- or "the MAILBOX is full" -- MonMailAction prints the same .MailboxFullText +-- for both, because the first can only happen if the menu row lied. On +-- success the struct moves, the party slot is zero-filled AND the mon's held +-- item is cleared, all three in the one routine. +function Mail.sendToPc(save, slot) + local mon = save and save.party and save.party[slot] + if not (mon and Mail.monHoldsMail(mon)) then return false end + if Mail.mailboxFull(save) then return false end + local state = Mail.state(save) + local entry = state.party[slot] + if not entry then + -- A mon carrying a mail ITEM with no struct behind it (an older save, or a + -- letter the extractor could not resolve): the cart would copy 47 zero + -- bytes, so send a blank letter rather than dropping the item on the floor. + entry = Mail.entry(mon.item, "", "", 0, mon.species) + end + state.box[#state.box + 1] = entry + state.party[slot] = nil + mon.item = nil + return true +end + +-- DeleteMailFromPC: the shift-up that keeps sMailboxes dense and decrements +-- sMailboxCount. +function Mail.deleteFromPc(save, index) + local box = Mail.state(save).box + if not box[index] then return nil end + return table.remove(box, index) +end + +-- MoveMailFromPCToParty, ATTACH MAIL's own half. The struct is copied into +-- the party slot, the mail's TYPE byte becomes the mon's held item -- which is +-- how a letter and its stationery stay together -- and only then is the +-- mailbox entry deleted. +function Mail.moveFromPcToParty(save, index, slot) + local box = Mail.state(save).box + local entry = box[index] + local mon = save and save.party and save.party[slot] + if not (entry and mon) then return false end + Mail.set(save, slot, entry) + mon.item = entry.type + table.remove(box, index) + return true +end + +-------------------------------------------------------------------------- +-- CheckPokeMail +-------------------------------------------------------------------------- + +-- CheckPokeMail (engine/pokemon/mail.asm), the `checkpokemail` opcode's whole +-- body once the party list has answered. `slot` is nil for the B press. +-- +-- The order is the cart's and it matters: a REFUSED never looks at the mon at +-- all, NO_MAIL beats WRONG_MAIL, and LAST_MON is checked AFTER the message +-- compares equal -- so handing over the right mon with the right letter while +-- it is your last conscious one still loses you the reward and keeps the mon. +-- +-- `expected` is the raw string the script points at, terminated by '@' on the +-- cart; the comparison runs until that terminator, so a stored message LONGER +-- than the expected one still matches. The removal on CORRECT is +-- RemoveMonFromPartyOrBox with REMOVE_PARTY, which is why the mail shift rides +-- along with it. +function Mail.checkPokeMail(save, slot, expected) + if not slot then return Mail.POKEMAIL_REFUSED end + local mon = save and save.party and save.party[slot] + if not (mon and Mail.monHoldsMail(mon)) then return Mail.POKEMAIL_NO_MAIL end + local entry = Mail.get(save, slot) + local got = (entry and entry.message) or "" + if type(expected) ~= "string" or expected == "" then + -- No expected message resolved (a cache built before the extractor + -- followed the operand). The cart compares against real bytes; with none, + -- WRONG_MAIL is the answer that changes nothing and keeps the mon. + return Mail.POKEMAIL_WRONG_MAIL + end + if got:sub(1, #expected) ~= expected then + return Mail.POKEMAIL_WRONG_MAIL + end + -- CheckCurPartyMonFainted: carry when this is the last conscious mon. + local healthy = 0 + for i, member in ipairs(save.party) do + if i ~= slot and (member.hp or 0) > 0 then healthy = healthy + 1 end + end + if (mon.hp or 0) > 0 and healthy == 0 then return Mail.POKEMAIL_LAST_MON end + table.remove(save.party, slot) + Mail.removeSlot(save, slot) + return Mail.POKEMAIL_CORRECT +end + +-------------------------------------------------------------------------- +-- Save hygiene +-------------------------------------------------------------------------- + +-- A quarantine pass with the same discipline src/core/gen2/Save.lua's +-- scrubScriptMem has: a struct play would nil-index or draw off the screen +-- never reaches the game, and whatever had to be dropped is reported. +-- +-- What can go wrong here that nothing else can vouch for: a party key outside +-- 1..6 (sPartyMail is six structs), a mailbox past MAILBOX_CAPACITY, a `type` +-- that is not one of the ten mail items (so the READ screen would have no +-- stationery and MoveMailFromPCToParty would hang a non-item on a mon), and a +-- message longer than the buffer, which is trimmed rather than dropped because +-- the first MAIL_MSG_LENGTH characters are still the player's letter. +local function cleanEntry(entry) + if type(entry) ~= "table" then return nil, "not a struct" end + if not Mail.isMail(entry.type) then return nil, "not a MAIL item" end + local message = tostring(entry.message or "") + local trimmed = Mail.trim(message) + local author = tostring(entry.author or ""):sub(1, Mail.AUTHOR_LENGTH) + local authorId = tonumber(entry.authorId) or 0 + if authorId ~= math.floor(authorId) or authorId < 0 or authorId > 0xFFFF then + authorId = 0 + end + return { + type = entry.type, + message = trimmed, + author = author, + authorId = authorId, + species = entry.species, + }, (trimmed ~= message) and "message trimmed" or nil +end + +-- report.lostMail collects { where = "party"|"box", slot, why }. +function Mail.validate(save, report) + local lost = report and report.lostMail or {} + if report then report.lostMail = lost end + if type(save) ~= "table" then return lost end + local raw = save.mail + if raw ~= nil and type(raw) ~= "table" then + lost[#lost + 1] = { where = "mail", why = "not a table" } + save.mail = nil + end + local state = Mail.state(save) + + local party = {} + for key, entry in pairs(state.party) do + local slot = tonumber(key) + if not (slot and slot == math.floor(slot) + and slot >= 1 and slot <= Mail.PARTY_LENGTH) then + lost[#lost + 1] = { where = "party", slot = key, why = "slot out of range" } + else + local clean, why = cleanEntry(entry) + if clean then + party[slot] = clean + if why then + lost[#lost + 1] = { where = "party", slot = slot, why = why } + end + else + lost[#lost + 1] = { where = "party", slot = slot, why = why } + end + end + end + state.party = party + + local box = {} + for _, entry in ipairs(state.box) do + local clean, why = cleanEntry(entry) + if not clean then + lost[#lost + 1] = { where = "box", slot = #box + 1, why = why } + elseif #box >= Mail.MAILBOX_CAPACITY then + lost[#lost + 1] = { where = "box", slot = #box + 1, why = "MAILBOX full" } + else + box[#box + 1] = clean + if why then + lost[#lost + 1] = { where = "box", slot = #box, why = why } + end + end + end + state.box = box + return lost +end + +return Mail diff --git a/src/core/gen2/MomShopping.lua b/src/core/gen2/MomShopping.lua new file mode 100644 index 00000000..a061f9b5 --- /dev/null +++ b/src/core/gen2/MomShopping.lua @@ -0,0 +1,244 @@ +-- Mom spends the money she is saving for you. +-- +-- engine/events/mom_phone.asm, with data/items/mom_phone.asm beside it. This +-- is the other end of Bank of Mom: the quarter WinTrainerBattle skims off +-- every won trainer battle (src/battle/gen2/Prize.lua) piles up in +-- wMomsMoney, and MomTriesToBuySomething is what she does with it. Nothing +-- else in the game spends her savings. +-- +-- Two shopping lists, and they behave completely differently: +-- +-- MomItems_2 is a LADDER, walked once in order by wWhichMomItem. Each row +-- carries the savings balance that unlocks it, so the four DOLLS -- the +-- only way a Gold player gets a CHARMANDER, CLEFAIRY or PIKACHU doll or +-- the BIG SNORLAX at all -- arrive at 10000, 30000, 50000 and 100000 +-- saved. A row is bought once and the index moves on. +-- MomItems_1 is a RANDOM consolation buy that fires only when the savings +-- land EXACTLY on a multiple of MOM_MONEY (2300) that +-- wMomItemTriggerBalance has not already passed. It never advances +-- wWhichMomItem, so it cannot cost the player a rung of the ladder. +-- +-- love-free and save-shaped: takes the Gold save (src/core/gen2/Save.lua) and +-- the event bitfield (src/world/gen2/Events.lua), so World, and the tests, +-- drive the same routine. + +local Strings = require("src.core.Strings") +local Decorations = require("src.core.gen2.Decorations") + +local MomShopping = {} + +-- constants/misc_constants.asm. +local MOM_MONEY = 2300 +MomShopping.MOM_MONEY = MOM_MONEY + +-- constants/misc_constants.asm again; the same cap Prize and Save carry. +local MAX_MONEY = 999999 + +-- The `momitem kind` const_def 1 block at the top of mom_phone.asm. +local MOM_ITEM, MOM_DOLL = 1, 2 + +-- wNumPCItems: PC_ITEM_CAPACITY stacks of at most 99, which is what +-- ReceiveItem enforces for the PC list the way it does for the bag. +local PC_ITEM_CAPACITY = 50 +local MAX_STACK = 99 + +-- data/items/mom_phone.asm, both tables verbatim and in order. `trigger` is +-- MOMITEM_TRIGGER, `cost` MOMITEM_COST, `kind` MOMITEM_KIND and `item` is +-- MOMITEM_ITEM -- an item id for a MOM_ITEM row and a DECO_* id for a +-- MOM_DOLL one, because Mom_GiveItemOrDoll reaches the doll through +-- DecorationFlagAction_c, which takes the decoration itself rather than a +-- DECOFLAG_*. The DECO numbers are the same ones +-- src/core/gen2/Decorations.lua indexes its ATTRIBUTES table by. +local function momitem(trigger, cost, kind, item) + return { trigger = trigger, cost = cost, kind = kind, item = item } +end + +local DECO_BIG_SNORLAX_DOLL = 26 +local DECO_PIKACHU_DOLL = 30 +local DECO_CLEFAIRY_DOLL = 32 +local DECO_CHARMANDER_DOLL = 35 + +MomShopping.ITEMS_1 = { + momitem(0, 600, MOM_ITEM, "SUPER_POTION"), + momitem(0, 90, MOM_ITEM, "ANTIDOTE"), + momitem(0, 180, MOM_ITEM, "POKE_BALL"), + momitem(0, 450, MOM_ITEM, "ESCAPE_ROPE"), + momitem(0, 500, MOM_ITEM, "GREAT_BALL"), +} + +MomShopping.ITEMS_2 = { + momitem(900, 600, MOM_ITEM, "SUPER_POTION"), + momitem(4000, 270, MOM_ITEM, "REPEL"), + momitem(7000, 600, MOM_ITEM, "SUPER_POTION"), + momitem(10000, 1800, MOM_DOLL, DECO_CHARMANDER_DOLL), + momitem(15000, 3000, MOM_ITEM, "MOON_STONE"), + momitem(19000, 600, MOM_ITEM, "SUPER_POTION"), + momitem(30000, 4800, MOM_DOLL, DECO_CLEFAIRY_DOLL), + momitem(40000, 900, MOM_ITEM, "HYPER_POTION"), + momitem(50000, 8000, MOM_DOLL, DECO_PIKACHU_DOLL), + momitem(100000, 22800, MOM_DOLL, DECO_BIG_SNORLAX_DOLL), +} + +-- data/text/common_1.asm, transcribed the way Specials' MOM_TEXT transcribes +-- the bank's own bank. Mom never names what she bought, in either script. +-- `cont` folds into the same `\n` as `line`. +local MOM_HI = Strings.source("Hi, {PLAYER}!\nHow are you?") +local FOUND_AN_ITEM = Strings.source( + "I found a useful\nitem shopping, so") +local FOUND_A_DOLL = Strings.source( + "While shopping\ntoday, I saw this\nadorable doll, so") +local BOUGHT_WITH_YOUR_MONEY = Strings.source( + "I bought it with\nyour money. Sorry!") +local ITS_IN_PC = Strings.source("It's in your PC.\nYou'll like it!") +local ITS_IN_YOUR_ROOM = Strings.source("It's in your room.\nYou'll love it!") + +-------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------- + +-- wWhichMomItem and wMomItemTriggerBalance, both seeded by NewGame +-- (engine/menus/intro_menu.asm): the ladder starts at its first rung and the +-- consolation threshold starts at MOM_MONEY. Filled in lazily so a save made +-- before this existed gets the same two defaults rather than an unlocked +-- ladder. +function MomShopping.state(save) + local mom = save and save.mom + if type(mom) ~= "table" then return nil end + if mom.whichItem == nil then mom.whichItem = 0 end + if mom.triggerBalance == nil then mom.triggerBalance = MOM_MONEY end + return mom +end + +local function savedMoney(save) + local mom = save and save.mom + return (mom and mom.savedMoney) or 0 +end + +-------------------------------------------------------------------------- +-- CheckBalance_MomItem2 +-------------------------------------------------------------------------- + +-- Answers the row Mom is about to buy, as { row, set }: set 2 is the ladder +-- and set 1 the random consolation buy. nil is the routine's `xor a / ret`, +-- i.e. she buys nothing this time. +-- +-- `random(n)` returns 0..n-1, the way RandomRange does; injected so the test +-- is deterministic. +function MomShopping.pick(save, random) + local mom = MomShopping.state(save) + if not mom then return nil end + local saved = savedMoney(save) + + -- `cp (MomItems_2.End - MomItems_2) / MOMITEM_SIZE / jr nc, .nope`: a + -- ladder that has run out falls through to the consolation test rather + -- than reading off the end of the table. + local row = MomShopping.ITEMS_2[mom.whichItem + 1] + if row and saved >= row.trigger then + return { row = row, set = 2 } + end + + -- .check_have_2300, which is a WHILE and not an IF: the balance is walked + -- up in MOM_MONEY steps until it reaches or passes the savings, and only an + -- EXACT landing buys anything. Overshooting is `.less_than`, which returns + -- with no carry and leaves the balance where the walk left it -- so the + -- next call starts from the rung above and the same 2300 cannot pay twice. + while mom.triggerBalance < saved do + mom.triggerBalance = mom.triggerBalance + MOM_MONEY + end + if mom.triggerBalance ~= saved then return nil end + mom.triggerBalance = mom.triggerBalance + MOM_MONEY + local roll = 0 + if random then roll = math.floor(random(#MomShopping.ITEMS_1) or 0) end + return { row = MomShopping.ITEMS_1[roll + 1], set = 1 } +end + +-------------------------------------------------------------------------- +-- Mom_GiveItemOrDoll +-------------------------------------------------------------------------- + +-- The PC half of ReceiveItem, over save.pcItems. Returns false for a full +-- PC, which is the no-carry Mom_GiveItemOrDoll passes straight back up: the +-- purchase does not happen and nothing is deducted. +local function receiveItemToPc(save, id, data) + if type(save) ~= "table" then return false end + save.pcItems = save.pcItems or {} + local pc = save.pcItems + local held = pc[id] or 0 + if held == 0 then + local cap = (data and data.field and data.field.pcItemCap) or PC_ITEM_CAPACITY + local stacks = 0 + for _ in pairs(pc) do stacks = stacks + 1 end + if stacks >= cap then return false end + elseif held + 1 > MAX_STACK then + return false + end + pc[id] = held + 1 + return true +end + +-------------------------------------------------------------------------- +-- MomTriesToBuySomething +-------------------------------------------------------------------------- + +-- opts: +-- events the src/world/gen2/Events.lua bitfield, for a doll's flag +-- data the cache, for the PC's stack cap +-- random(n) 0..n-1, RandomRange +-- phoneService GetMapPhoneService: false on a map with no reception, and +-- the routine `ret`s before it looks at the balance at all +-- +-- Returns the purchase, or nil. A purchase is +-- { kind = "item" | "doll", item, cost, set, saved }, and MomShopping.pages +-- turns it into the four lines the phone call speaks. +function MomShopping.tryBuy(save, opts) + opts = opts or {} + if opts.phoneService == false then return nil end + local mom = MomShopping.state(save) + if not mom then return nil end + + -- wWhichMomItemSet is cleared before the balance check and only written by + -- the consolation arm, which is what makes .ASMFunction's `and a / jr nz` + -- advance wWhichMomItem for a LADDER buy alone. + local pick = MomShopping.pick(save, opts.random) + if not (pick and pick.row) then return nil end + local row = pick.row + + if row.kind == MOM_DOLL then + -- DecorationFlagAction_c with b = SET_FLAG, and the arm ends `scf`: a + -- doll cannot fail, there is nowhere for it to not fit. + Decorations.give(opts.events, row.item) + elseif not receiveItemToPc(save, row.item, opts.data) then + return nil + end + + -- MomBuysItem_DeductFunds: TakeMoney out of wMomsMoney, which floors at + -- zero rather than borrowing. + mom.savedMoney = math.max(0, math.min(savedMoney(save), MAX_MONEY) - row.cost) + if pick.set == 2 then mom.whichItem = mom.whichItem + 1 end + + return { + kind = (row.kind == MOM_DOLL) and "doll" or "item", + item = row.item, + cost = row.cost, + set = pick.set, + saved = mom.savedMoney, + } +end + +-- Mom_GetScriptPointer's two scripts, .ItemScript and .DollScript: four +-- writetexts each, differing only in the middle line and the last. +-- +-- The SOURCE strings, not looked-up ones: the caller feeds them to `rawtext`, +-- which is where the Strings lookup happens (src/script/gen2/Vm.lua). A +-- module-level template resolved here would freeze the English before +-- Strings.load has a catalog, which is exactly what Strings.source exists to +-- avoid. +function MomShopping.pages(purchase) + if not purchase then return {} end + if purchase.kind == "doll" then + return { MOM_HI, FOUND_A_DOLL, BOUGHT_WITH_YOUR_MONEY, ITS_IN_YOUR_ROOM } + end + return { MOM_HI, FOUND_AN_ITEM, BOUGHT_WITH_YOUR_MONEY, ITS_IN_PC } +end + +return MomShopping diff --git a/src/core/gen2/Nests.lua b/src/core/gen2/Nests.lua new file mode 100644 index 00000000..ee44b33e --- /dev/null +++ b/src/core/gen2/Nests.lua @@ -0,0 +1,162 @@ +-- Where does this species live? engine/overworld/wildmons.asm FindNest, which +-- is the data behind the Pokedex's AREA page and the Pokegear MAP card's +-- "<MON>'S NEST" overlay. +-- +-- FindNest takes a species and a region (e: 0 Johto, 1 Kanto) and fills the +-- tilemap with LANDMARK indices -- one per map whose wild data contains the +-- species. It reads exactly three sources, in this order: +-- +-- .FindGrass JohtoGrassWildMons / KantoGrassWildMons, all NUM_GRASSMON * 3 +-- slots, so morning, day AND night count +-- .FindWater JohtoWaterWildMons / KantoWaterWildMons +-- .RoamMon1/2/3 the three roamers' CURRENT map, Johto only +-- +-- and nothing else. Headbutt trees, fishing groups, the Bug Contest and swarms +-- are all absent from it, so a HEADBUTT-only species legitimately has no nest +-- and the page stays blank -- that is the cart's answer, not a gap. +-- +-- Region is decided here by LANDMARK INDEX rather than by which of the two +-- tables a map came from: the extractor emits one `grass`/`water` table keyed +-- by map, and Johto's landmarks are the run below LANDMARK_PALLET_TOWN with +-- Kanto's above it (constants/landmark_constants.asm). Same split, different +-- spelling. +local Nests = {} + +-- constants/landmark_constants.asm. LANDMARK_SPECIAL is 0 and never a nest. +Nests.LANDMARK_PALLET_TOWN = 0x2e +Nests.LANDMARK_FAST_SHIP = 0x5e + +function Nests.regionOf(landmark) + if not landmark or landmark <= 0 then return nil end + if landmark >= Nests.LANDMARK_FAST_SHIP then return nil end + return (landmark < Nests.LANDMARK_PALLET_TOWN) and "johto" or "kanto" +end + +-- ---------------------------------------------------------- the landmarks +-- +-- data/maps/landmarks.asm, which on Gold is one index space shared by every +-- map header's `landmark` byte, the Pokegear MAP card and the #DEX AREA page +-- this file feeds. It is the `landmarks` registry (src/mods/Schemas.lua), one +-- of the Gen 2-only six: Red's town map is a different table with a different +-- id space, so the name is gated under Gen 1 and routed to +-- gen2Landmarks.landmarks under Gen 2 -- straight onto the cache's own table, +-- which means the merge lands in the very table the map card draws from and no +-- Builtins seeding is needed (the same arrangement gen2Maps has). +-- +-- Two reads go through here rather than through landmarks.order, which is a +-- plain ordered list the extractor writes and a registered landmark is +-- therefore absent from: src/core/Game2.lua:currentLandmark and +-- src/ui/gen2/MapRadio.lua's region test. `index` on a record is the byte the +-- map header carries, so the lookup is by that and the order list stays the +-- fallback for a dataset whose records predate it. + +-- memoized per landmark table (weak keys, so a second dataset in one process +-- does not pin the first); built on first read, which is after the merge -- +-- nothing asks for a landmark before the overworld exists +local byIndex = setmetatable({}, { __mode = "k" }) + +-- Two records may claim one index -- a mod that registers a landmark at a byte +-- the cart already uses -- and pairs() would decide which one answers per +-- process. The cache's own row wins its own slot (landmarks.order is that +-- list), and between two newcomers the lower id wins, so the answer is the +-- same on every boot. A mod that means to MOVE a vanilla landmark patches +-- that record rather than shadowing its index. +local function indexTable(landmarks) + local hit = byIndex[landmarks] + if hit then return hit end + local map, order = {}, landmarks.order or {} + for id, record in pairs(landmarks.landmarks or {}) do + local index = type(record) == "table" and record.index + if index then + local held = map[index] + if held == nil or order[index + 1] == id + or (order[index + 1] ~= held and id < held) then + map[index] = id + end + end + end + byIndex[landmarks] = map + return map +end + +-- The LANDMARK_* id at a map header's landmark byte, or nil. +function Nests.landmarkId(data, index) + local landmarks = data and data.gen2Landmarks + if not (landmarks and index) then return nil end + local hit = indexTable(landmarks)[index] + if hit then return hit end + return landmarks.order and landmarks.order[index + 1] or nil +end + +-- The record behind that byte: the two-line name and the map-card position. +function Nests.landmark(data, index) + local landmarks = data and data.gen2Landmarks + local id = Nests.landmarkId(data, index) + return id and landmarks.landmarks and landmarks.landmarks[id] or nil +end + +local function landmarkOfMap(data, mapId) + local def = data and data.maps and data.maps[mapId] + return def and def.landmark +end + +-- Every slot of one encounter table entry, across all times of day: the cart +-- walks `NUM_GRASSMON * 3` bytes without caring which third it is in. +local function tableHasSpecies(entry, species) + if type(entry) ~= "table" then return false end + local slots = entry.slots + if type(slots) ~= "table" then return false end + for _, list in pairs(slots) do + if type(list) == "table" then + for _, slot in ipairs(list) do + if slot and slot.species == species then return true end + end + end + end + return false +end + +-- The landmark indices where `species` can be met, in ascending order. +-- +-- `region` is "johto" or "kanto"; nil means both, which no cart screen asks +-- for but is the useful answer for a test. +function Nests.find(data, species, region, save) + local out, seen = {}, {} + local function add(landmark) + if not landmark or landmark <= 0 or seen[landmark] then return end + local where = Nests.regionOf(landmark) + if not where then return end + if region and where ~= region then return end + seen[landmark] = true + out[#out + 1] = landmark + end + + local enc = data and data.encounters + for _, key in ipairs({ "grass", "water" }) do + for mapId, entry in pairs((enc and enc[key]) or {}) do + if tableHasSpecies(entry, species) then + add(landmarkOfMap(data, mapId)) + end + end + end + + -- .RoamMon1/2/3: the roamer's CURRENT map, and only while it is still out + -- there -- a caught or defeated one keeps its slot but loses its species and + -- map. Johto-only on the cart, which the region filter above enforces. + -- + -- The test is Roamers.active, NOT "has HP": a roamer starts life at hp 0 + -- (`xor a ; generate new stats`) and only gets a real value once you have met + -- it, so an HP test would hide all three until first contact -- exactly the + -- ones the page is most useful for. + local Roamers = require("src.core.gen2.Roamers") + for _, slot in ipairs((save and save.roamers) or {}) do + if Roamers.active(slot) and slot.species == species then + add(landmarkOfMap(data, slot.map)) + end + end + + table.sort(out) + return out +end + +return Nests diff --git a/src/core/gen2/NpcTrade.lua b/src/core/gen2/NpcTrade.lua new file mode 100644 index 00000000..5c8d97e9 --- /dev/null +++ b/src/core/gen2/NpcTrade.lua @@ -0,0 +1,165 @@ +-- The in-game trades (engine/events/npc_trade.asm, data/events/npc_trades.asm). +-- +-- Six of them, one per NPC_TRADE_* constant, reached by the `trade` script +-- command. Each row names the mon the NPC wants, the mon it hands over, and +-- everything that mon arrives wearing: its nickname, its DVs, its held item, +-- its original trainer's name and ID, and which gender of the requested mon it +-- will accept. +-- +-- love-free: the conversation is src/ui/gen2/TradeMenu.lua, this is the rules. +-- +-- Facts worth keeping: +-- +-- * NPCTRADE_GIVEMON is what YOU hand over and NPCTRADE_GETMON what you get, +-- which is the opposite way round from the macro's own argument comment +-- ("requested mon, offered mon"). GetTradeAttr reads them by name, so the +-- comment is the only thing that is backwards. +-- * The row's DVs are TWO RAW BYTES, not a number: attack/defense in the +-- high and low nibbles of the first, speed/special of the second. The +-- mon's gender and shininess fall straight out of them, which is why every +-- one of these trades hands over the same mon to every player. +-- * The OT ID is stored little-endian in the table and byte-swapped into the +-- party struct (Trade_CopyTwoBytesReverseEndian), so the number the table +-- holds IS the ID the player sees. +-- * `trade` writes no wScriptVar. Every outcome -- the refusal, the wrong +-- mon, the completed trade -- prints its line and returns, and the script +-- after it carries on either way. +-- * The trade is one-shot, tracked in wTradeFlags by the trade's own id. A +-- second visit prints TRADE_DIALOG_AFTER and nothing else, which is the +-- check that happens BEFORE the intro line. +-- * ComputeNPCTrademonStats runs at the END, on the mon that just landed in +-- the last party slot: the received mon keeps the LEVEL of the one handed +-- over and recomputes its stats from the new species' bases. + +local Mail = require("src.core.gen2.Mail") +local Mon = require("src.battle.gen2.Mon") + +local NpcTrade = {} + +-- constants/npc_trade_constants.asm +NpcTrade.NUM_NPC_TRADES = 6 +NpcTrade.TRADE_GENDER_EITHER = "TRADE_GENDER_EITHER" +NpcTrade.TRADE_GENDER_MALE = "TRADE_GENDER_MALE" +NpcTrade.TRADE_GENDER_FEMALE = "TRADE_GENDER_FEMALE" + +-- The outcomes, which are also the TRADE_DIALOG_* rows PrintTradeText picks. +NpcTrade.DIALOG_INTRO = "TRADE_DIALOG_INTRO" +NpcTrade.DIALOG_CANCEL = "TRADE_DIALOG_CANCEL" +NpcTrade.DIALOG_WRONG = "TRADE_DIALOG_WRONG" +NpcTrade.DIALOG_COMPLETE = "TRADE_DIALOG_COMPLETE" +NpcTrade.DIALOG_AFTER = "TRADE_DIALOG_AFTER" + +-- data/generated/events.lua `trades`, 1-based over the 0-based NPC_TRADE_*. +function NpcTrade.row(eventTables, id) + local rows = type(eventTables) == "table" and eventTables.trades + if type(rows) ~= "table" then return nil end + return rows[(tonumber(id) or 0) + 1] +end + +-- wTradeFlags, a bit per trade id. Save-side it is a plain set. +function NpcTrade.done(save, id) + local flags = save and save.tradeFlags + return (flags and flags[tonumber(id) or -1]) == true +end + +function NpcTrade.markDone(save, id) + if not save then return end + save.tradeFlags = save.tradeFlags or {} + save.tradeFlags[tonumber(id) or 0] = true +end + +-- The row's two DV bytes as the port's named-DV table. `dn attack, defense` +-- then `dn speed, special` -- the same packing wild mons use. +function NpcTrade.dvs(row) + local raw = (row and row.dvs) or {} + local dvs = { + attack = math.floor((raw[1] or 0) / 16), + defense = (raw[1] or 0) % 16, + speed = math.floor((raw[2] or 0) / 16), + special = (raw[2] or 0) % 16, + } + dvs.hp = Mon.hpDV(dvs) + return dvs +end + +-- NPCTRADE_ITEM is an item id BYTE (data/events/npc_trades.asm's `db \5, \6, +-- \7` tail), and DoNPCTrade copies that byte straight into wPartyMon1Item of +-- the last party slot, so the received mon wears it like any other held item. +-- Everywhere else in this port a held item is a KEY of data/generated/items.lua +-- -- wild base data, trainer party mons and `givepokemail` are named at +-- extraction, and `givepoke` names its own byte at runtime through World's +-- itemByIndex -- so the byte is named here too and nothing downstream has to +-- know the row is raw. A row item of 0 is NO_ITEM. A cache that already +-- carries the name passes straight through. +function NpcTrade.item(data, row) + local raw = row and row.item + if raw == nil or raw == 0 then return nil end + if type(raw) == "string" then return raw end + local items = data and data.items + if type(items) == "table" then + for id, def in pairs(items) do + if type(def) == "table" and def.index == raw then return id end + end + end + local order = data and data.constants and data.constants.itemOrder + return (order and order[raw]) or nil +end + +-- CheckTradeGender. EITHER takes anything; the other two run GetGender on the +-- mon the player picked and refuse on a mismatch. A genderless species +-- ("unknown") satisfies neither, which is the `jr nz` / `jr z` pair falling to +-- .not_matching. +function NpcTrade.genderOk(row, mon) + local want = row and row.gender + if not want or want == NpcTrade.TRADE_GENDER_EITHER then return true end + local gender = mon and mon.gender + if want == NpcTrade.TRADE_GENDER_MALE then return gender == "male" end + return gender == "female" +end + +-- The three refusals NPCTrade checks in order, before any swap happens. +-- Answers the TRADE_DIALOG_* the conversation should print, or nil for "go +-- ahead". +function NpcTrade.check(row, mon) + if not row then return NpcTrade.DIALOG_CANCEL end + if not mon then return NpcTrade.DIALOG_CANCEL end + if mon.species ~= row.give then return NpcTrade.DIALOG_WRONG end + if not NpcTrade.genderOk(row, mon) then return NpcTrade.DIALOG_WRONG end + return nil +end + +-- DoNPCTrade: the mon at `index` leaves the party and the row's mon takes the +-- last slot, at the SAME level, with the row's DVs, nickname, held item, OT +-- name and OT ID. Answers the two mons, given away first. +-- +-- RemoveMonFromPartyOrBox runs before TryAddMonToParty, so the incoming mon +-- lands in the slot vacated by the outgoing one only when that was the last +-- slot -- otherwise the party closes up and the new mon goes on the end. That +-- reordering is visible in the party list, so it is reproduced rather than +-- tidied into an in-place swap. +function NpcTrade.perform(data, save, row, index) + local party = save and save.party + local given = party and party[index] + if not (data and given and row) then return nil end + local received = Mon.new(data, row.get, given.level, { + dvs = NpcTrade.dvs(row), + nickname = row.nickname, + item = NpcTrade.item(data, row), + }) + if not received then return nil end + -- `ot` is what Breeding reads and `otName` what the summary screen prints; + -- both are set rather than picking one, because the two halves of the port + -- already disagree and a traded mon has to answer both. + received.ot, received.otName = row.otName, row.otName + received.otId = row.otId + table.remove(party, index) + -- RemoveMonFromPartyOrBox's "Mail time!" tail. NPCTrade itself has no mail + -- check -- unlike the Day-Care and the PC, it will trade a mon holding a + -- letter away -- so the shift here is what stops the mon that closes up into + -- that slot inheriting it (src/core/gen2/Mail.lua). + Mail.removeSlot(save, index) + party[#party + 1] = received + return given, received +end + +return NpcTrade diff --git a/src/core/gen2/Phone.lua b/src/core/gen2/Phone.lua new file mode 100644 index 00000000..631f3f8f --- /dev/null +++ b/src/core/gen2/Phone.lua @@ -0,0 +1,1201 @@ +-- The POKeGEAR's phone (engine/phone/phone.asm, data/phone/*.asm and the +-- receive-call timer in engine/overworld/time.asm). +-- +-- love-free on purpose: everything here is model. The Pokegear card +-- (src/ui/gen2/Pokegear.lua) drives the outgoing half, the script VM +-- (src/script/gen2/Vm.lua) drives the incoming half and the four phone +-- opcodes, and both talk to this module rather than to each other. +-- +-- The cart's phone is three separate machines that share one contact table: +-- +-- OUTGOING MakePhoneCallFromPokegear. You pick a name off wPhoneList; the +-- contact's SCRIPT1 ("callee": you called them) runs, unless there +-- is no signal, unless they cannot take a call at this time of +-- day, or unless you are standing on their own map -- in which +-- case the game tells you to go talk to them instead. +-- +-- INCOMING CheckPhoneCall, run once per step out of PlayerEvents. A gate +-- of five tests decides whether anyone rings; if one does, that +-- contact's SCRIPT2 ("caller": they called you) runs. This is the +-- half that hands out the trainer rematch flags. +-- +-- SPECIAL CheckSpecialPhoneCall, run BEFORE the step is counted. A script +-- somewhere in the world has done `specialphonecall N`, which +-- parks N in wSpecialPhoneCallID; the next time its condition +-- holds, that scripted call jumps the queue. This is main quest, +-- not flavour: Elm's "your POKeMON was stolen" and "the egg +-- hatched" beats, Mom's lecture and the bike shop's call are all +-- special calls. +-- +-- Notes that cost real time to work out, kept here so the next reader does not +-- have to: +-- +-- * PhoneContacts rows carry TWO time-of-day masks. SCRIPT1_TIME gates YOU +-- calling THEM, SCRIPT2_TIME gates THEM calling YOU. Mom, Elm, Bill and +-- the bike shop all have SCRIPT2_TIME 0, so none of them can ever be +-- picked as a random caller -- every unprompted call from Elm arrives +-- through the special-call queue. +-- +-- * The delay timer is restarted by StartMap (engine/overworld/events.asm +-- `farcall InitCallReceiveDelay`), so it starts over at twenty in-game +-- minutes on EVERY map load. A player who keeps warping is never called. +-- +-- * The queue is cleared by the called script itself (`specialphonecall +-- SPECIALCALL_NONE` is the first or second line of every special caller +-- script), not by the engine. See Phone.endCall for the fallback a cache +-- with no bank $41 scripts still gets. +-- +-- The call scripts live in ROM bank $41, which the extractor reaches by +-- seeding its queue from PhoneContacts and SpecialPhoneCallList (no map +-- points into that bank, so those two tables are the only way in), and +-- Phone.useExtracted overlays the cache's own rows below. Every contact +-- still names its pokegold script LABEL and the "bank:addr" key from the +-- symbol file: Phone.scriptKey resolves a label to that key, a call +-- descriptor carries both, and the pair is what lets a repointed cache be +-- caught by test rather than call the wrong script. A call's presentation +-- (the ring, the Click!, the countdown restart) is src/core/gen2/PhoneRing.lua. + +local Runtime = require("src.mods.Runtime") + +local Phone = {} + +-- ------------------------------------------------------- constants + +-- constants/phone_constants.asm +Phone.CONTACT_LIST_SIZE = 10 +Phone.NUM_PHONE_CONTACTS = 36 +Phone.SPECIALCALL_NONE = 0 +Phone.NUM_SPECIALCALLS = 8 + +-- constants/ram_constants.asm wTimeOfDay. DARKNESS has no bit of its own in +-- CheckTime's table, and IsInArray stops at the first match, so a dark map +-- resolves to c = 0: nobody is available and no contact can be called. +Phone.MORN, Phone.DAY, Phone.NITE = 1, 2, 4 +Phone.ANYTIME = 7 -- MORN | DAY | NITE + +-- constants/misc_constants.asm time of day boundaries. +local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 +local MAX_HOUR = 24 +-- _CalcDaysSince wraps with `add 20 * 7`; wCurDay counts inside that window. +local MAX_DAY = 140 + +-- constants/script_constants.asm, askforphonenumber return values. +Phone.CONTACT_GOT = 0 +Phone.CONTACTS_FULL = 1 +Phone.CONTACT_REFUSED = 2 + +-- constants/script_constants.asm readvar id, for the VM's readVar hook: +-- `dwb wSpecialPhoneCallID, RETVAR_STRBUF2` (engine/overworld/variables.asm). +Phone.VAR_SPECIALPHONECALL = 0x14 + +-- constants/map_data_constants.asm environments. SpecialCallOnlyWhenOutside +-- takes TOWN and ROUTE and nothing else. +local OUTSIDE_ENVIRONMENTS = { TOWN = true, ROUTE = true } + +-- constants/trainer_constants.asm. That block opens `const_def 1`, so the +-- non-trainer contacts are ONE based and PHONECONTACT_MOM is 1, not 0. They +-- double as PhoneContacts row indexes, which is why SpecialPhoneCallList can +-- hand PHONECONTACT_ELM straight to LoadCallerScript. +Phone.PHONECONTACT_MOM = 1 +Phone.PHONECONTACT_BIKESHOP = 2 +Phone.PHONECONTACT_BILL = 3 +Phone.PHONECONTACT_ELM = 4 + +-- data/phone/non_trainer_names.asm, in table order (index = the row's trainer +-- NUMBER byte, which for a non-trainer is its PHONECONTACT_* constant). +Phone.NON_TRAINER_NAMES = { + [0] = "----------", + [1] = "MOM", + [2] = "BIKE SHOP", + [3] = "BILL", + [4] = "PROF.ELM", +} + +-- data/phone/permanent_numbers.asm. GetRemainingSpaceInPhoneList reserves a +-- slot for each of these you do not have yet, so a player who has not met Elm +-- can only fill eight of the ten slots. +Phone.PERMANENT_NUMBERS = { Phone.PHONECONTACT_MOM, Phone.PHONECONTACT_ELM } + +-- ------------------------------------------------------- the contact table +-- +-- data/phone/phone_contacts.asm, one row per PHONE_* constant +-- (constants/phone_constants.asm). The macro's argument order is +-- trainer class, trainer id, map, callee time, callee script, +-- caller time, caller script +-- and the struct order is TRAINER_CLASS, TRAINER_NUMBER, MAP_GROUP, +-- MAP_NUMBER, SCRIPT1_TIME/BANK/ADDR, SCRIPT2_TIME/BANK/ADDR -- so SCRIPT1 is +-- the callee pair and SCRIPT2 the caller pair. +-- +-- `map` is the map's id string; the cart stores a group/number pair and +-- compares it against wMapGroup/wMapNumber, and data/generated/maps.lua keys +-- every map by exactly that id, so the string compare is the same test. N_A +-- is group $ff / map $ff, which no real map ever equals: nil here. +-- +-- The table is indexed from ZERO: PHONE_00 is a real row (the wrong-number +-- filler that LoadCallerScript falls back to) and three of its siblings sit in +-- the middle of the table as const_skip holes. +Phone.CONTACTS = { + [0] = { number = 0, map = nil, + calleeTime = 0, callee = "UnusedPhoneScript", + callerTime = 0, caller = "UnusedPhoneScript" }, + [1] = { number = Phone.PHONECONTACT_MOM, map = "PLAYERS_HOUSE_1F", + calleeTime = Phone.ANYTIME, callee = "MomPhoneCalleeScript", + callerTime = 0, caller = "UnusedPhoneScript" }, + [2] = { number = Phone.PHONECONTACT_BIKESHOP, map = "OAKS_LAB", + calleeTime = 0, callee = "UnusedPhoneScript", + callerTime = 0, caller = "UnusedPhoneScript" }, + [3] = { number = Phone.PHONECONTACT_BILL, map = nil, + calleeTime = Phone.ANYTIME, callee = "BillPhoneCalleeScript", + callerTime = 0, caller = "BillPhoneCallerScript" }, + [4] = { number = Phone.PHONECONTACT_ELM, map = "ELMS_LAB", + calleeTime = Phone.ANYTIME, callee = "ElmPhoneCalleeScript", + callerTime = 0, caller = "ElmPhoneCallerScript" }, + [5] = { class = "SCHOOLBOY", member = "JACK1", map = "NATIONAL_PARK", + callee = "JackPhoneCalleeScript", caller = "JackPhoneCallerScript" }, + [6] = { class = "POKEFANF", member = "BEVERLY1", map = "NATIONAL_PARK", + callee = "BeverlyPhoneCalleeScript", + caller = "BeverlyPhoneCallerScript" }, + [7] = { class = "SAILOR", member = "HUEY1", map = "OLIVINE_LIGHTHOUSE_2F", + callee = "HueyPhoneCalleeScript", caller = "HueyPhoneCallerScript" }, + -- const_skip x3 + [8] = false, [9] = false, [10] = false, + [11] = { class = "COOLTRAINERM", member = "GAVEN3", map = "ROUTE_26", + callee = "GavenPhoneCalleeScript", + caller = "GavenPhoneCallerScript" }, + [12] = { class = "COOLTRAINERF", member = "BETH1", map = "ROUTE_26", + callee = "BethPhoneCalleeScript", caller = "BethPhoneCallerScript" }, + [13] = { class = "BIRD_KEEPER", member = "JOSE2", map = "ROUTE_27", + callee = "JosePhoneCalleeScript", caller = "JosePhoneCallerScript" }, + [14] = { class = "COOLTRAINERF", member = "REENA1", map = "ROUTE_27", + callee = "ReenaPhoneCalleeScript", + caller = "ReenaPhoneCallerScript" }, + [15] = { class = "YOUNGSTER", member = "JOEY1", map = "ROUTE_30", + callee = "JoeyPhoneCalleeScript", caller = "JoeyPhoneCallerScript" }, + [16] = { class = "BUG_CATCHER", member = "WADE1", map = "ROUTE_31", + callee = "WadePhoneCalleeScript", caller = "WadePhoneCallerScript" }, + [17] = { class = "FISHER", member = "RALPH1", map = "ROUTE_32", + callee = "RalphPhoneCalleeScript", + caller = "RalphPhoneCallerScript" }, + [18] = { class = "PICNICKER", member = "LIZ1", map = "ROUTE_32", + callee = "LizPhoneCalleeScript", caller = "LizPhoneCallerScript" }, + [19] = { class = "HIKER", member = "ANTHONY2", map = "ROUTE_33", + callee = "AnthonyPhoneCalleeScript", + caller = "AnthonyPhoneCallerScript" }, + [20] = { class = "CAMPER", member = "TODD1", map = "ROUTE_34", + callee = "ToddPhoneCalleeScript", caller = "ToddPhoneCallerScript" }, + [21] = { class = "PICNICKER", member = "GINA1", map = "ROUTE_34", + callee = "GinaPhoneCalleeScript", caller = "GinaPhoneCallerScript" }, + [22] = { class = "JUGGLER", member = "IRWIN1", map = "ROUTE_35", + callee = "IrwinPhoneCalleeScript", + caller = "IrwinPhoneCallerScript" }, + [23] = { class = "BUG_CATCHER", member = "ARNIE1", map = "ROUTE_35", + callee = "ArniePhoneCalleeScript", + caller = "ArniePhoneCallerScript" }, + [24] = { class = "SCHOOLBOY", member = "ALAN1", map = "ROUTE_36", + callee = "AlanPhoneCalleeScript", caller = "AlanPhoneCallerScript" }, + -- const_skip + [25] = false, + [26] = { class = "LASS", member = "DANA1", map = "ROUTE_38", + callee = "DanaPhoneCalleeScript", caller = "DanaPhoneCallerScript" }, + [27] = { class = "SCHOOLBOY", member = "CHAD1", map = "ROUTE_38", + callee = "ChadPhoneCalleeScript", caller = "ChadPhoneCallerScript" }, + [28] = { class = "POKEFANM", member = "DEREK1", map = "ROUTE_39", + callee = "DerekPhoneCalleeScript", + caller = "DerekPhoneCallerScript" }, + [29] = { class = "FISHER", member = "CHRIS1", map = "ROUTE_42", + callee = "ChrisPhoneCalleeScript", + caller = "ChrisPhoneCallerScript" }, + [30] = { class = "POKEMANIAC", member = "BRENT1", map = "ROUTE_43", + callee = "BrentPhoneCalleeScript", + caller = "BrentPhoneCallerScript" }, + [31] = { class = "PICNICKER", member = "TIFFANY3", map = "ROUTE_43", + callee = "TiffanyPhoneCalleeScript", + caller = "TiffanyPhoneCallerScript" }, + [32] = { class = "BIRD_KEEPER", member = "VANCE1", map = "ROUTE_44", + callee = "VancePhoneCalleeScript", + caller = "VancePhoneCallerScript" }, + [33] = { class = "FISHER", member = "WILTON1", map = "ROUTE_44", + callee = "WiltonPhoneCalleeScript", + caller = "WiltonPhoneCallerScript" }, + [34] = { class = "BLACKBELT_T", member = "KENJI3", map = "ROUTE_45", + callee = "KenjiPhoneCalleeScript", + caller = "KenjiPhoneCallerScript" }, + [35] = { class = "HIKER", member = "PARRY1", map = "ROUTE_45", + callee = "ParryPhoneCalleeScript", + caller = "ParryPhoneCallerScript" }, + [36] = { class = "PICNICKER", member = "ERIN1", map = "ROUTE_46", + callee = "ErinPhoneCalleeScript", caller = "ErinPhoneCallerScript" }, +} + +-- Every trainer row in the table is `ANYTIME, <callee>, ANYTIME, <caller>`; +-- only the five non-trainer rows above spell their masks out. Fill the rest +-- in rather than repeating ANYTIME twenty-eight times, and turn the const_skip +-- holes into copies of row 0 so an out-of-range id can never index nil. +for index = 0, Phone.NUM_PHONE_CONTACTS do + local row = Phone.CONTACTS[index] + if row == false or row == nil then + row = { number = 0, map = nil, + calleeTime = 0, callee = "UnusedPhoneScript", + callerTime = 0, caller = "UnusedPhoneScript" } + Phone.CONTACTS[index] = row + end + row.index = index + if row.class then + row.calleeTime = row.calleeTime or Phone.ANYTIME + row.callerTime = row.callerTime or Phone.ANYTIME + end +end + +-- ------------------------------------------------------- the registry +-- +-- The `phone_contacts` registry (src/mods/Schemas.lua), one of the Gen 2-only +-- six: Red has no Pokegear, so the name is gated under Gen 1 and routed to +-- data.gen2PhoneContacts under Gen 2. src/mods/Builtins.lua seeds it with the +-- rows above, engine-owned, so a mod's register of PHONE_YOUNGSTER_JOEY +-- collides and has to say override -- the same contract every other seeded +-- registry keeps. +-- +-- The id space is the cart's own PHONE_* constants, which arrive as +-- data.gen2Constants.phoneContactOrder; `index` on each record is the row byte +-- every lookup in this file keys by, and it is what puts a merged record back +-- on the right row. The four PHONE_UNUSED const_skip holes are not +-- registered: one id cannot name four rows, and all four are copies of the +-- wrong-number filler. +-- +-- Rows are applied ONTO Phone.CONTACTS rather than read through it, because +-- every reader here (and every caller in src/ui/gen2/) has the contact byte +-- and not the dataset -- the same shape Phone.useExtracted already has for the +-- cache's own rows. The order is literal -> cache -> registry: useExtracted +-- re-applies the stored rows at its tail, so a mod's edit survives the cache +-- overlay whichever of the two runs second. +local registryRows = nil + +local function applyRegistryRows() + if not registryRows then return 0 end + local applied = 0 + for _, record in pairs(registryRows) do + local index = type(record) == "table" and record.index + local dest = index and Phone.CONTACTS[index] + if dest then + for key, value in pairs(record) do dest[key] = value end + applied = applied + 1 + end + end + return applied +end + +-- vanilla registrations, engine-owned; `data` carries the constant order the +-- ids come from, so a dataset without it registers nothing rather than +-- inventing names +function Phone.registerInto(registry, data, owner) + local order = data and data.gen2Constants and data.gen2Constants.phoneContactOrder + if type(order) ~= "table" then return 0 end + local count = 0 + for index = 0, Phone.NUM_PHONE_CONTACTS do + local id = order[index + 1] + local row = Phone.CONTACTS[index] + if type(id) == "string" and id ~= "PHONE_UNUSED" and row then + registry:register(id, row, owner) + count = count + 1 + end + end + return count +end + +-- the merged table, held by reference and folded onto the contact rows. Pass +-- nil to forget it (a second dataset in one process). +function Phone.useRegistry(data) + registryRows = data and data.gen2PhoneContacts or nil + return applyRegistryRows() +end + +-- ------------------------------------------------------- script keys +-- +-- ../pokegold-symbols/pokegold.sym. scripts.lua keys a command list by +-- "<bank hex>:<addr hex>", the same form the extractor writes for a map +-- script, so these become live the moment bank $41 (the phone scripts) and +-- bank $24 (the phone engine's own little scripts) are extracted. +Phone.SCRIPT_KEYS = { + UnusedPhoneScript = "41:4000", + MomPhoneCalleeScript = "41:4004", + MomPhoneLectureScript = "41:4124", + BillPhoneCalleeScript = "41:4137", + BillPhoneCallerScript = "41:4172", + ElmPhoneCalleeScript = "41:4177", + ElmPhoneCallerScript = "41:41e1", + JackPhoneCalleeScript = "41:422a", + JackPhoneCallerScript = "41:4234", + BeverlyPhoneCalleeScript = "41:4256", + BeverlyPhoneCallerScript = "41:4260", + HueyPhoneCalleeScript = "41:4282", + HueyPhoneCallerScript = "41:428c", + GavenPhoneCalleeScript = "41:42a7", + GavenPhoneCallerScript = "41:42b1", + BethPhoneCalleeScript = "41:42d3", + BethPhoneCallerScript = "41:42dd", + JosePhoneCalleeScript = "41:42ff", + JosePhoneCallerScript = "41:4309", + ReenaPhoneCalleeScript = "41:4332", + ReenaPhoneCallerScript = "41:433c", + JoeyPhoneCalleeScript = "41:435e", + JoeyPhoneCallerScript = "41:4368", + WadePhoneCalleeScript = "41:4390", + WadePhoneCallerScript = "41:43b5", + RalphPhoneCalleeScript = "41:43f8", + RalphPhoneCallerScript = "41:4402", + LizPhoneCalleeScript = "41:4446", + LizPhoneCallerScript = "41:4450", + AnthonyPhoneCalleeScript = "41:4478", + AnthonyPhoneCallerScript = "41:4482", + ToddPhoneCalleeScript = "41:44c4", + ToddPhoneCallerScript = "41:44ce", + GinaPhoneCalleeScript = "41:44f6", + GinaPhoneCallerScript = "41:4506", + IrwinPhoneCalleeScript = "41:4534", + IrwinPhoneCallerScript = "41:4544", + ArniePhoneCalleeScript = "41:456c", + ArniePhoneCallerScript = "41:4576", + AlanPhoneCalleeScript = "41:45b2", + AlanPhoneCallerScript = "41:45bc", + DanaPhoneCalleeScript = "41:45de", + DanaPhoneCallerScript = "41:45e8", + ChadPhoneCalleeScript = "41:460a", + ChadPhoneCallerScript = "41:4614", + DerekPhoneCalleeScript = "41:4650", + DerekPhoneCallerScript = "41:4675", + ChrisPhoneCalleeScript = "41:46b2", + ChrisPhoneCallerScript = "41:46bc", + BrentPhoneCalleeScript = "41:46de", + BrentPhoneCallerScript = "41:46e8", + TiffanyPhoneCalleeScript = "41:4711", + TiffanyPhoneCallerScript = "41:471b", + VancePhoneCalleeScript = "41:4744", + VancePhoneCallerScript = "41:474e", + WiltonPhoneCalleeScript = "41:4770", + WiltonPhoneCallerScript = "41:477a", + KenjiPhoneCalleeScript = "41:47b8", + KenjiPhoneCallerScript = "41:47c2", + ParryPhoneCalleeScript = "41:47e4", + ParryPhoneCallerScript = "41:47ee", + ErinPhoneCalleeScript = "41:482a", + ErinPhoneCallerScript = "41:4834", + BikeShopPhoneCallerScript = "41:4a80", + -- bank $24: the engine's own scripts, reached without a contact row. + WrongNumberScript = "24:4240", + PhoneOutOfAreaScript = "24:4626", + PhoneScript_JustTalkToThem = "24:462f", +} + +function Phone.scriptKey(label) + return label and Phone.SCRIPT_KEYS[label] or nil +end + +-- Take the contact rows and the special-call rows out of the CACHE instead of +-- out of the two tables above. +-- +-- Those tables were written when nothing pointed into bank $41 and the port +-- had to name every script by its pokegold label and its symbol-file address. +-- The extractor follows PhoneContacts and SpecialPhoneCallList now, so the +-- rows arrive already resolved and a repointed table cannot silently call the +-- wrong script -- the same reason `special` is dispatched by name through +-- specialOrder rather than by a counted index. +-- +-- Only the fields the cart actually stores are overlaid. `class`, `member` +-- and the SCRIPT_KEYS labels stay: they are what the trainer rematch +-- machinery and Phone.contactName read, and the ROM row carries a trainer +-- class BYTE rather than a name. A cache with no events.lua leaves both +-- tables exactly as they are. +function Phone.useExtracted(events) + local rows = type(events) == "table" and events.phone + if type(rows) ~= "table" then return false end + local applied = 0 + for index = 0, Phone.NUM_PHONE_CONTACTS do + local row, dest = rows[index], Phone.CONTACTS[index] + if type(row) == "table" and dest then + dest.map = row.map + dest.calleeTime = row.calleeTime + dest.callerTime = row.callerTime + dest.calleeKey = row.callee + dest.callerKey = row.caller + -- A row the cart fills with UnusedPhoneScript has a real number of 0; + -- keep the hand-ported number for the four PHONECONTACT_* rows, whose + -- ids the rest of this file compares against by name. + if row.number and row.number ~= 0 then dest.number = row.number end + applied = applied + 1 + end + end + for _, row in ipairs(events.specialCalls or {}) do + local dest = Phone.SPECIAL_CALLS[row.id] + if dest then + dest.contact = row.contact + dest.scriptKey = row.script + end + end + -- The three scripts the engine runs without a contact row. These stay in + -- SCRIPT_KEYS because Phone.call names them by label. + for label, row in pairs(events.phoneScripts or {}) do + if row.script then Phone.SCRIPT_KEYS[label] = row.script end + end + -- Last word to the `phone_contacts` merge: this routine overwrites six + -- fields on every row it has a cache entry for, and it runs from + -- src/world/gen2/World.lua:load -- after src/core/Game2.lua:load has + -- already folded the merged rows in. Re-applying them here is what + -- keeps the order literal -> cache -> registry whichever way round the two + -- calls land. + applyRegistryRows() + Phone.extracted = applied > 0 + return Phone.extracted +end + +-- ------------------------------------------------------- special calls +-- +-- data/phone/special_calls.asm, indexed by SPECIALCALL_* (which starts at +-- SPECIALCALL_NONE = 0, so the first real entry is 1 and the table is read as +-- `SpecialPhoneCallList + (id - 1) * SPECIALCALL_SIZE`). +-- +-- Each row is `condition, contact, script`: the condition decides WHEN the +-- queued call is allowed to fire, the contact names whose textbox and name it +-- wears, and the script REPLACES that contact's SCRIPT2 for this one call. +Phone.SPECIAL_CALLS = { + [1] = { name = "SPECIALCALL_POKERUS", condition = "outside", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, + [2] = { name = "SPECIALCALL_ROBBED", condition = "outside", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, + [3] = { name = "SPECIALCALL_ASSISTANT", condition = "outside", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, + [4] = { name = "SPECIALCALL_WEIRDBROADCAST", condition = "outside", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, + [5] = { name = "SPECIALCALL_SSTICKET", condition = "anywhere", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, + [6] = { name = "SPECIALCALL_BIKESHOP", condition = "anywhere", + contact = Phone.PHONECONTACT_BIKESHOP, + script = "BikeShopPhoneCallerScript" }, + [7] = { name = "SPECIALCALL_WORRIED", condition = "anywhere", + contact = Phone.PHONECONTACT_MOM, + script = "MomPhoneLectureScript" }, + [8] = { name = "SPECIALCALL_MASTERBALL", condition = "outside", + contact = Phone.PHONECONTACT_ELM, script = "ElmPhoneCallerScript" }, +} + +-- Name -> id, so a caller can queue by the constant it reads in the decomp. +Phone.SPECIALCALL = { SPECIALCALL_NONE = 0 } +for id, entry in pairs(Phone.SPECIAL_CALLS) do + Phone.SPECIALCALL[entry.name] = id +end + +-- ------------------------------------------------------- rematch flags +-- +-- engine/phone/scripts/trainers.asm: every caller script has a .WantsBattle +-- branch whose `setevent EVENT_<NAME>_READY_FOR_REMATCH` is the whole rematch +-- mechanic -- the trainer's own map script then reads that flag and offers the +-- second party. So the flag is set by the extracted script, not by this +-- module; what lives here is the contact -> flag mapping, for the callers that +-- want to ask "is this contact waiting?" and for the fallback in +-- Phone.setRematchReady while bank $41 is unextracted. +-- +-- The numbers are wEventFlags bit indexes, counted through +-- constants/event_flags.asm's const_def / const_skip / const_next chain (the +-- same chain that gives EVENT_ROUTE_30_BATTLE = 1812, which is the eventFlag +-- the extracted Route 30 object already carries -- that match is what verifies +-- this counting). +Phone.REMATCH_EVENTS = { + [5] = 608, -- EVENT_JACK_READY_FOR_REMATCH + [6] = 610, -- EVENT_BEVERLY_READY_FOR_REMATCH + [7] = 612, -- EVENT_HUEY_READY_FOR_REMATCH + [11] = 620, -- EVENT_GAVEN_READY_FOR_REMATCH + [12] = 622, -- EVENT_BETH_READY_FOR_REMATCH + [13] = 624, -- EVENT_JOSE_READY_FOR_REMATCH + [14] = 626, -- EVENT_REENA_READY_FOR_REMATCH + [15] = 628, -- EVENT_JOEY_READY_FOR_REMATCH + [16] = 630, -- EVENT_WADE_READY_FOR_REMATCH + [17] = 632, -- EVENT_RALPH_READY_FOR_REMATCH + [18] = 634, -- EVENT_LIZ_READY_FOR_REMATCH + [19] = 636, -- EVENT_ANTHONY_READY_FOR_REMATCH + [20] = 638, -- EVENT_TODD_READY_FOR_REMATCH + [21] = 640, -- EVENT_GINA_READY_FOR_REMATCH + [22] = 642, -- EVENT_IRWIN_READY_FOR_REMATCH + [23] = 644, -- EVENT_ARNIE_READY_FOR_REMATCH + [24] = 646, -- EVENT_ALAN_READY_FOR_REMATCH + [26] = 650, -- EVENT_DANA_READY_FOR_REMATCH + [27] = 652, -- EVENT_CHAD_READY_FOR_REMATCH + [28] = 654, -- EVENT_DEREK_READY_FOR_REMATCH + [29] = 656, -- EVENT_CHRIS_READY_FOR_REMATCH + [30] = 658, -- EVENT_BRENT_READY_FOR_REMATCH + [31] = 660, -- EVENT_TIFFANY_READY_FOR_REMATCH + [32] = 662, -- EVENT_VANCE_READY_FOR_REMATCH + [33] = 664, -- EVENT_WILTON_READY_FOR_REMATCH + [34] = 666, -- EVENT_KENJI_READY_FOR_REMATCH + [35] = 668, -- EVENT_PARRY_READY_FOR_REMATCH + [36] = 670, -- EVENT_ERIN_READY_FOR_REMATCH +} + +-- ------------------------------------------------------- save state +-- +-- src/core/gen2/Save.lua owns the file; this is the block the phone keeps in +-- it. `list` is wPhoneList (ten ordered slots, 0 = empty), which is the shape +-- the Pokegear reads and the shape AddPhoneNumber's reserved-slot arithmetic +-- needs -- a set of ids cannot answer "which slot". +-- +-- save.phoneContacts (the id -> true set the VM's addcellnum/checkcellnum +-- hooks already write in src/world/gen2/World.lua) is kept mirrored in both +-- directions so neither half has to change before the other does. +local function newState() + return { + list = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + specialCall = 0, -- wSpecialPhoneCallID + timeCycles = 0, -- wTimeCyclesSinceLastCall + delayMins = 20, -- wReceiveCallDelay_MinsRemaining + delayStart = nil, -- wReceiveCallDelay_StartTime {day,hour,minute} + } +end + +local function inList(state, id) + for slot = 1, Phone.CONTACT_LIST_SIZE do + if state.list[slot] == id then return slot end + end + return nil +end + +function Phone.state(save) + if type(save) ~= "table" then return newState() end + local state = save.phone + if type(state) ~= "table" then + state = newState() + save.phone = state + end + state.list = type(state.list) == "table" and state.list or {} + for slot = 1, Phone.CONTACT_LIST_SIZE do + state.list[slot] = tonumber(state.list[slot]) or 0 + end + state.specialCall = tonumber(state.specialCall) or 0 + state.timeCycles = tonumber(state.timeCycles) or 0 + state.delayMins = tonumber(state.delayMins) or 20 + -- Adopt anything the VM's addcellnum hook put in the legacy set, then mirror + -- the list back over it. An id in the set but not the list takes the first + -- free slot; the mirror is rebuilt from scratch so a delete propagates. + local legacy = save.phoneContacts + if type(legacy) == "table" then + for key in pairs(legacy) do + local id = tonumber(key) + if id and id > 0 and not inList(state, id) then + for slot = 1, Phone.CONTACT_LIST_SIZE do + if state.list[slot] == 0 then + state.list[slot] = id + break + end + end + end + end + end + Phone.mirror(save, state) + return state +end + +-- Rebuild save.phoneContacts from wPhoneList. Separate from Phone.state +-- because state ADOPTS the mirror first: anything that has just taken an id +-- out of the list has to mirror without adopting, or the id it removed walks +-- straight back into the first free slot. +function Phone.mirror(save, state) + state = state or (save and save.phone) or {} + local legacy = {} + for slot = 1, Phone.CONTACT_LIST_SIZE do + local id = state.list and state.list[slot] + if id and id ~= 0 then legacy[id] = true end + end + save.phoneContacts = legacy + return legacy +end + +-- ------------------------------------------------------- the contact list +-- +-- engine/phone/phone.asm _CheckCellNum. Transcribed literally, including its +-- one oddity: it compares against ten slots with no "is this a real contact" +-- test, so `Phone.hasContact(save, 0)` reports true whenever a slot is empty. +-- AddPhoneNumber leans on exactly that to refuse adding contact 0. +function Phone.hasContact(save, id) + local state = Phone.state(save) + return inList(state, id) ~= nil +end + +-- GetRemainingSpaceInPhoneList. wRegisteredPhoneNumbers is named backwards: +-- it counts the permanent numbers you have NOT registered yet, and each one +-- costs a slot that stays reserved for it. `id` is the number being added, +-- which is skipped (`cp c / jr z, .continue`) so a permanent number never +-- reserves a slot against itself. +function Phone.remainingSlots(save, id) + local state = Phone.state(save) + local reserved = 0 + for _, permanent in ipairs(Phone.PERMANENT_NUMBERS) do + if permanent ~= id and not inList(state, permanent) then + reserved = reserved + 1 + end + end + return Phone.CONTACT_LIST_SIZE - reserved +end + +-- Phone_FindOpenSlot: the first empty slot inside the unreserved run. +function Phone.openSlot(save, id) + local state = Phone.state(save) + local usable = Phone.remainingSlots(save, id) + for slot = 1, usable do + if state.list[slot] == 0 then return slot end + end + return nil +end + +-- AddPhoneNumber. Returns true, or false plus "already" / "full" -- the cart +-- sets carry for both and Script_askforphonenumber reports either as +-- PHONE_CONTACTS_FULL, so the reason is for the port's own callers. +function Phone.addContact(save, id) + id = tonumber(id) + if not id or not Phone.CONTACTS[id] then return false, "unknown" end + local state = Phone.state(save) + if inList(state, id) then return false, "already" end + local slot = Phone.openSlot(save, id) + if not slot then return false, "full" end + state.list[slot] = id + save.phoneContacts = save.phoneContacts or {} + save.phoneContacts[id] = true + return true +end + +-- DelCellNum: blank the slot in place, leaving the hole where it was. +function Phone.removeContact(save, id) + local state = Phone.state(save) + local slot = inList(state, id) + if not slot then return false end + state.list[slot] = 0 + Phone.mirror(save, state) + return true +end + +-- PokegearPhone_DeletePhoneNumber: the Pokegear's own delete, which blanks the +-- slot and THEN compacts the list so the display has no gap in it. Every pass +-- of its loop pulls the next entry back one slot, which is why it runs +-- CONTACT_LIST_SIZE times rather than once. +function Phone.deleteContactAt(save, slot) + local state = Phone.state(save) + if not (slot and state.list[slot]) then return false end + state.list[slot] = 0 + for _ = 1, Phone.CONTACT_LIST_SIZE do + for index = 1, Phone.CONTACT_LIST_SIZE - 1 do + if state.list[index] == 0 then + state.list[index] = state.list[index + 1] + state.list[index + 1] = 0 + end + end + end + Phone.mirror(save, state) + return true +end + +-- CheckCanDeletePhoneNumber: MOM and PROF.ELM cannot be deleted, and neither +-- can an empty slot; every trainer can. (`ld a, c / and a / ret nz` -- a row +-- with a trainer class is deletable outright.) +function Phone.canDelete(id) + local contact = Phone.CONTACTS[id or -1] + if not contact then return false end + if contact.class then return true end + if contact.number == Phone.PHONECONTACT_MOM then return false end + if contact.number == Phone.PHONECONTACT_ELM then return false end + return contact.number ~= 0 +end + +-- wPhoneList as an array of slot values, 0 for empty. The Pokegear draws all +-- ten slots including the empty ones (they render as "----------"), so this +-- keeps them rather than compacting. +local function sameList(_, list) return list end + +function Phone.contacts(save) + local state = Phone.state(save) + local out = {} + for slot = 1, Phone.CONTACT_LIST_SIZE do out[slot] = state.list[slot] end + if not Runtime.wantsHook("phone.contact_list") then return out end + -- phone.contact_list, a Gen 2 invention: Red has no phone, so there is no + -- Gen 1 name to share. Shaped like the other list hooks (ui.pc.items, + -- ui.start_menu.items) -- (save, list), returning the list to draw -- rather + -- than a ctx table, because that is what a mod that reorders or hides rows + -- already knows how to write. + -- + -- The list is wPhoneList itself: exactly CONTACT_LIST_SIZE slots, each a + -- PHONE_* contact id or 0 for an empty one, and the empty slots are part of + -- the display. A chain that returns something that is not a table, or one + -- of the wrong length, is ignored: the Pokegear indexes this by slot and a + -- short list would put the cursor on nil. Ids the CONTACTS table does not + -- know are blanked rather than dropped, so the slot count survives. + local hooked = Runtime.call("phone.contact_list", sameList, save, out) + if type(hooked) ~= "table" or #hooked ~= Phone.CONTACT_LIST_SIZE then + return out + end + for slot = 1, Phone.CONTACT_LIST_SIZE do + local id = tonumber(hooked[slot]) or 0 + hooked[slot] = Phone.CONTACTS[id] and id or 0 + end + return hooked +end + +-- Script_askforphonenumber (engine/overworld/scripting.asm). `accepted` is +-- the YesNoBox result; the script writes one of the three PHONE_CONTACT_* +-- values into wScriptVar and its ifequal chain branches on it. +function Phone.askForNumber(save, id, accepted) + if not accepted then return Phone.CONTACT_REFUSED end + if Phone.addContact(save, id) then return Phone.CONTACT_GOT end + return Phone.CONTACTS_FULL +end + +-- ------------------------------------------------------- names + +-- GetCallerName. A trainer contact prints "<name>:" over its class name; a +-- non-trainer prints its NonTrainerCallerNames string and nothing under it. +-- `trainerData` is data/generated/trainers.lua; without it a trainer contact +-- still returns its member id, which is better than a bare number. +function Phone.contactName(id, trainerData) + local contact = Phone.CONTACTS[id or -1] + if not contact then return Phone.NON_TRAINER_NAMES[0], nil end + if not contact.class then + return Phone.NON_TRAINER_NAMES[contact.number or 0] + or Phone.NON_TRAINER_NAMES[0], nil + end + local class = trainerData and trainerData.classes + and trainerData.classes[contact.class] + if class and class.trainers then + for _, row in ipairs(class.trainers) do + if row.id == contact.member then + return row.name, class.name or contact.class + end + end + end + return contact.member, contact.class +end + +-- ------------------------------------------------------- context helpers + +local function rngOf(ctx) + local rng = ctx and ctx.rng + if rng then return rng end + return function() return math.random(0, 255) end +end + +local function clockOf(ctx) + local clock = ctx and ctx.clock + if type(clock) == "table" then + return { + day = tonumber(clock.day) or 0, + hour = tonumber(clock.hour) or 0, + minute = tonumber(clock.minute or clock.min) or 0, + } + end + return { + day = (tonumber(os.date("%j")) or 1) % MAX_DAY, + hour = tonumber(os.date("%H")) or 0, + minute = tonumber(os.date("%M")) or 0, + } +end + +-- CheckTime: wTimeOfDay -> the MORN / DAY / NITE bit. DARKNESS is absent from +-- .TimeOfDayTable's live rows, so it comes back as 0. +local TIME_BITS = { + MORN = Phone.MORN, DAY = Phone.DAY, NITE = Phone.NITE, + MORN_F = Phone.MORN, DAY_F = Phone.DAY, NITE_F = Phone.NITE, + DARK = 0, DARKNESS = 0, +} + +function Phone.timeOfDay(ctx) + local given = ctx and (ctx.timeOfDay or ctx.daytime) + if type(given) == "number" then return given end + if type(given) == "string" then return TIME_BITS[given] or 0 end + local hour = clockOf(ctx).hour + if hour < MORN_HOUR then return Phone.NITE end + if hour < DAY_HOUR then return Phone.MORN end + if hour < NITE_HOUR then return Phone.DAY end + return Phone.NITE +end + +-- Lua 5.1 has no bit ops in the base library and this module stays +-- dependency-free, so AND the three time bits by hand. +local function timeMatches(mask, checked) + mask = mask or 0 + checked = checked or 0 + for _, bit in ipairs({ Phone.MORN, Phone.DAY, Phone.NITE }) do + if math.floor(mask / bit) % 2 == 1 and math.floor(checked / bit) % 2 == 1 then + return true + end + end + return false +end + +Phone.timeMatches = timeMatches + +-- GetMapPhoneService returns the map header's phone-service nybble and every +-- caller tests `and a` -- ZERO means the map HAS service. maps.lua already +-- decodes that nybble into a boolean, so a map record can be handed in whole. +local function mapRecord(ctx) + local map = ctx and ctx.map + if type(map) == "table" then return map end + return nil +end + +function Phone.mapHasService(ctx) + local record = mapRecord(ctx) + if record ~= nil and record.phoneService ~= nil then + return record.phoneService and true or false + end + if ctx and ctx.phoneService ~= nil then + return ctx.phoneService and true or false + end + -- No header to read: assume service, the way most of Johto has it. + return true +end + +local function currentMapId(ctx) + local record = mapRecord(ctx) + if record then return record.id end + local map = ctx and ctx.map + if type(map) == "string" then return map end + return ctx and ctx.mapId or nil +end + +-- The wMapGroup / wMapNumber compare from GetAvailableCallers and +-- MakePhoneCallFromPokegear: a contact standing on your own map is skipped. +function Phone.onSameMap(contact, ctx) + if not (contact and contact.map) then return false end + local here = currentMapId(ctx) + if here then return here == contact.map end + local record = mapRecord(ctx) + local maps = ctx and ctx.maps + local theirs = maps and maps[contact.map] + if record and theirs then + return record.group == theirs.group and record.map == theirs.map + end + return false +end + +-- SpecialCallOnlyWhenOutside: TOWN and ROUTE, nothing else. +function Phone.isOutside(ctx) + local record = mapRecord(ctx) + local environment = (record and record.environment) + or (ctx and ctx.environment) + return OUTSIDE_ENVIRONMENTS[environment] == true +end + +-- ------------------------------------------------------- the receive timer +-- +-- engine/overworld/time.asm. The delay is a countdown in in-game minutes with +-- a rebasing start stamp: every check works out how many minutes have passed +-- since the stamp, writes the current time back over the stamp, and subtracts +-- the difference from what is left. + +-- .ReceiveCallDelays, indexed by wTimeCyclesSinceLastCall (0..3, capped). The +-- gap between calls shrinks the longer you go without one: twenty minutes for +-- the first, then ten, five and three. +Phone.RECEIVE_CALL_DELAYS = { 20, 10, 5, 3 } + +-- CalcMinsHoursDaysSince, borrow chain and all. Returns minutes, hours, days. +local function since(now, start) + local borrow = 0 + local minutes = now.minute - start.minute - borrow + if minutes < 0 then minutes = minutes + 60 borrow = 1 else borrow = 0 end + local hours = now.hour - start.hour - borrow + if hours < 0 then hours = hours + MAX_HOUR borrow = 1 else borrow = 0 end + local days = now.day - start.day - borrow + if days < 0 then days = days + MAX_DAY end + return minutes, hours, days +end + +-- RestartReceiveCallDelay: park the countdown and stamp "now". +function Phone.restartReceiveDelay(save, minutes, ctx) + local state = Phone.state(save) + state.delayMins = minutes + state.delayStart = clockOf(ctx) + return state +end + +-- NextCallReceiveDelay. +function Phone.nextReceiveDelay(save, ctx) + local state = Phone.state(save) + local cycles = state.timeCycles or 0 + if cycles > 3 then cycles = 3 end + return Phone.restartReceiveDelay(save, + Phone.RECEIVE_CALL_DELAYS[cycles + 1], ctx) +end + +-- InitCallReceiveDelay. StartMap runs this on every map load, and so does the +-- tail of Script_ReceivePhoneCall after a call is hung up. +function Phone.initReceiveDelay(save, ctx) + local state = Phone.state(save) + state.timeCycles = 0 + return Phone.nextReceiveDelay(save, ctx) +end + +-- StartMap's `farcall InitCallReceiveDelay`, under the name the World will +-- want to call it by. +function Phone.onMapLoad(save, ctx) + return Phone.initReceiveDelay(save, ctx) +end + +-- CheckReceiveCallDelay -> UpdateTimeRemaining. True means the countdown has +-- reached zero. Anything longer than an hour (or a rolled-over day) comes +-- back from GetMinutesSinceIfLessThan60 as -1, which UpdateTimeRemaining +-- treats as "expired" outright rather than trying to subtract it. +function Phone.checkReceiveCallDelay(save, ctx) + local state = Phone.state(save) + local now = clockOf(ctx) + if not state.delayStart then + state.delayStart = now + return false + end + local minutes, hours, days = since(now, state.delayStart) + -- The routine writes the current value back into each byte as it walks the + -- stamp, so the next check measures from here. + state.delayStart = now + local elapsed = minutes + if days ~= 0 or hours ~= 0 then elapsed = -1 end + if elapsed == -1 then + state.delayMins = 0 + return true + end + local left = (state.delayMins or 0) - elapsed + if left < 0 then left = 0 end + state.delayMins = left + return left == 0 +end + +-- CheckReceiveCallTimer: consume the expiry, wind the cycle counter on (capped +-- at 3) and restart the countdown at the next, shorter delay. +function Phone.checkReceiveCallTimer(save, ctx) + if not Phone.checkReceiveCallDelay(save, ctx) then return false end + local state = Phone.state(save) + if (state.timeCycles or 0) < 3 then + state.timeCycles = (state.timeCycles or 0) + 1 + end + Phone.nextReceiveDelay(save, ctx) + return true +end + +-- ------------------------------------------------------- call descriptors + +-- LoadCallerScript. Contact 0 is not a contact: the routine swaps in the +-- WrongNumber record, whose script is one writetext and an end. +local function descriptor(id, direction, scriptField) + id = tonumber(id) or 0 + local contact = Phone.CONTACTS[id] + if id == 0 or not contact then + return { + kind = "call", contact = 0, direction = direction, + script = "WrongNumberScript", + scriptKey = Phone.SCRIPT_KEYS.WrongNumberScript, + wrongNumber = true, + } + end + local label = contact[scriptField] + return { + kind = "call", + contact = id, + direction = direction, + class = contact.class, + member = contact.member, + number = contact.number, + map = contact.map, + script = label, + -- The extracted key wins over the label lookup: Phone.useExtracted put the + -- cart's own pointer here, and the SCRIPT_KEYS table behind + -- Phone.scriptKey is a transcription of the symbol file. + scriptKey = contact[scriptField .. "Key"] or Phone.scriptKey(label), + } +end + +Phone.loadCallerScript = descriptor + +-- ------------------------------------------------------- outgoing +-- +-- MakePhoneCallFromPokegear. `kind` says which of the three things the cart +-- does happened: +-- "call" ring the contact and run their callee script +-- "justtalk" PhoneScript_JustTalkToThem -- they are on this very map +-- "outofarea" PhoneOutOfAreaScript -- link mode, no signal, or wrong hour +-- +-- Note what is NOT checked: whether the contact is in your phone book at all. +-- The Pokegear only ever offers listed contacts, so the cart never asks. +function Phone.call(save, id, ctx) + ctx = ctx or {} + local outOfArea = { + kind = "outofarea", + contact = tonumber(id) or 0, + direction = "outgoing", + script = "PhoneOutOfAreaScript", + scriptKey = Phone.SCRIPT_KEYS.PhoneOutOfAreaScript, + } + if ctx.linkMode then return outOfArea end + if not Phone.mapHasService(ctx) then return outOfArea end + local contact = Phone.CONTACTS[tonumber(id) or -1] + if not contact then return outOfArea end + -- CheckPhoneContactTimeOfDay masks the row's SCRIPT1_TIME with ANYTIME and + -- then with the current time bit. + if not timeMatches(contact.calleeTime, Phone.timeOfDay(ctx)) then + return outOfArea + end + if Phone.onSameMap(contact, ctx) then + return { + kind = "justtalk", + contact = tonumber(id), + direction = "outgoing", + script = "PhoneScript_JustTalkToThem", + scriptKey = Phone.SCRIPT_KEYS.PhoneScript_JustTalkToThem, + } + end + return descriptor(id, "outgoing", "callee") +end + +-- ------------------------------------------------------- incoming + +-- GetAvailableCallers. Walks all ten slots of wPhoneList and keeps the +-- contacts whose SCRIPT2_TIME covers the current time of day and who are not +-- standing on the map you are standing on. +function Phone.availableCallers(save, ctx) + local state = Phone.state(save) + local checked = Phone.timeOfDay(ctx) + local out = {} + for slot = 1, Phone.CONTACT_LIST_SIZE do + local id = state.list[slot] + if id ~= 0 then + local contact = Phone.CONTACTS[id] + if contact and timeMatches(contact.callerTime, checked) + and not Phone.onSameMap(contact, ctx) then + out[#out + 1] = id + end + end + end + return out +end + +-- ChooseRandomCaller. One Random call, whose byte is nibble-swapped and +-- masked to 0..31, then reduced modulo the number of available callers by +-- SimpleDivide (which returns the remainder in a). The swap-and-mask is why a +-- book with more than a few contacts still samples evenly enough. +function Phone.chooseRandomCaller(callers, rng) + if not callers or #callers == 0 then return nil end + rng = rng or rngOf(nil) + local roll = rng() % 256 + local swapped = (roll % 16) * 16 + math.floor(roll / 16) + local index = (swapped % 32) % #callers + return callers[index + 1] +end + +-- CheckPhoneCall, the whole gate, in the cart's order. The order matters: the +-- timer check has side effects (it restarts the countdown and winds the cycle +-- counter), so it must run before the coin flip and not after it. +-- +-- 1. CheckStandingOnEntrance -- a door, staircase or cave tile never rings +-- 2. CheckReceiveCallTimer -- the countdown has to have run out +-- 3. a 50% coin flip +-- 4. GetMapPhoneService -- the map has to have a signal +-- 5. someone has to be available at this hour and off this map +function Phone.tryRandomCall(save, ctx) + ctx = ctx or {} + if ctx.standingOnEntrance then return nil end + if not Phone.checkReceiveCallTimer(save, ctx) then return nil end + local rng = rngOf(ctx) + -- `call Random / ld b, a / and %01111111 / cp b / jr nz`: equal only when + -- the top bit was already clear, so this passes half the time. + if (rng() % 256) >= 0x80 then return nil end + if not Phone.mapHasService(ctx) then return nil end + local who = Phone.chooseRandomCaller(Phone.availableCallers(save, ctx), rng) + if not who then return nil end + return descriptor(who, "incoming", "caller") +end + +-- The cart's own name for it, for a reader coming from phone.asm. +Phone.checkPhoneCall = Phone.tryRandomCall + +-- ------------------------------------------------------- special calls + +-- Script_specialphonecall: the id goes into wSpecialPhoneCallID and sits there +-- until a called script clears it. `SPECIALCALL_NONE` (0) is the clear. +function Phone.queueSpecialCall(save, id) + local state = Phone.state(save) + state.specialCall = tonumber(id) or 0 + return state.specialCall +end + +function Phone.clearSpecialCall(save) + return Phone.queueSpecialCall(save, Phone.SPECIALCALL_NONE) +end + +-- Script_checkphonecall: false when nothing is queued. +function Phone.hasSpecialCall(save) + return Phone.state(save).specialCall ~= 0 +end + +-- readvar VAR_SPECIALPHONECALL, which is how every special caller script works +-- out which of its branches to take (ElmPhoneCallerScript's ifequal chain). +function Phone.specialCallVar(save) + return Phone.state(save).specialCall or 0 +end + +-- CheckSpecialPhoneCall, run from CountStep before the step is counted. The +-- queued row's script REPLACES the contact's own SCRIPT2 for this call, which +-- is how one Elm contact serves five different scripted calls. +-- +-- `delay` is the `pause 30` its little wrapper script runs before +-- Script_ReceivePhoneCall; a random call has no such pause. +function Phone.checkSpecialCall(save, ctx) + local state = Phone.state(save) + local queued = state.specialCall or 0 + if queued == 0 then return nil end + local entry = Phone.SPECIAL_CALLS[queued] + if not entry then return nil end + if entry.condition == "outside" and not Phone.isOutside(ctx) then + return nil + end + local call = descriptor(entry.contact, "incoming", "caller") + call.special = queued + call.specialName = entry.name + call.script = entry.script + call.scriptKey = entry.scriptKey or Phone.scriptKey(entry.script) + call.delay = 30 + return call +end + +-- ------------------------------------------------------- rematches + +function Phone.rematchEvent(id) + return Phone.REMATCH_EVENTS[id or -1] +end + +-- The caller script's own `setevent EVENT_<NAME>_READY_FOR_REMATCH`. With +-- bank $41 extracted the VM runs that setevent itself and nothing in the +-- engine calls this; it stays as the one named seam for a test or a cache +-- without the scripts to land the same flag. +function Phone.setRematchReady(events, id, value) + local flag = Phone.rematchEvent(id) + if not (flag and events) then return false end + events:set(flag, value ~= false) + return true +end + +function Phone.isReadyForRematch(events, id) + local flag = Phone.rematchEvent(id) + if not (flag and events) then return false end + return events:get(flag) and true or false +end + +-- ------------------------------------------------------- ending a call + +-- Script_ReceivePhoneCall's tail: HangUp, closetext, InitCallReceiveDelay. +-- +-- The special-call queue is cleared by the CALLED SCRIPT on the cart +-- (`specialphonecall SPECIALCALL_NONE`, the line right after the writetext in +-- every one of them), not by the engine, and with bank $41 extracted that +-- line runs through the VM's own specialphonecall arm. The fallback here +-- covers a cache built before the extractor reached that bank: a caller that +-- reports no script ran (`ranScript` unset) has its queue cleared so the +-- call cannot ring forever. +function Phone.endCall(save, call, ctx) + if call and call.special and call.special ~= 0 and not call.ranScript then + Phone.clearSpecialCall(save) + end + Phone.initReceiveDelay(save, ctx) + return true +end + +return Phone diff --git a/src/core/gen2/PhoneRing.lua b/src/core/gen2/PhoneRing.lua new file mode 100644 index 00000000..b2eb5882 --- /dev/null +++ b/src/core/gen2/PhoneRing.lua @@ -0,0 +1,130 @@ +-- Script_ReceivePhoneCall (engine/phone/phone.asm), as the row list the VM +-- runs around a caller script: +-- +-- Script_ReceivePhoneCall: +-- reanchormap +-- callasm RingTwice_StartCall +-- memcall wCallerContact + PHONE_CONTACT_SCRIPT2_BANK +-- waitbutton +-- callasm HangUp +-- closetext +-- callasm InitCallReceiveDelay +-- end +-- +-- The memcall is the caller's own bank $41 script, which the extractor +-- reaches through PhoneContacts / SpecialPhoneCallList and the call +-- descriptor names by its scripts.lua key. Around it: +-- +-- * RingTwice_StartCall dispatches through src/script/gen2/CallAsm.lua for +-- both of its halves: SFX_CALL, and the caller-ID box it draws through +-- .CallerTextboxWithName -> Phone_TextboxWithName (:466, :474, :582) -- +-- the phone icon, the caller's name, the class under it -- which is +-- src/ui/gen2/CallerBox.lua, pushed under this call's text pages and taken +-- down again by the InitCallReceiveDelay row at the tail. What the port +-- cannot keep is the FLASH: the cart blinks the box against +-- Phone_Wait20Frames six times, and this port's textbox holds for A rather +-- than returning the way PrintText does, so the box goes up with the first +-- ring and stays. The rawtext page below is the beat that hold needs; it +-- names the caller too, so a player who is looking at the bottom of the +-- screen reads the same thing the box says. +-- The RINGS themselves are real, though: RingTwice_StartCall is `call +-- .Ring` falling through into .Ring (engine/phone/phone.asm:458-469), so +-- it rings TWICE, and each pass opens on Phone_StartRinging's `call +-- WaitSFX` before its PlaySFX (:564-567). The handler is one ring, so +-- both halves are rows here: waitsfx, ring, the three Phone_Wait20Frames +-- that separate the passes (:576-580), waitsfx, ring. The wait is not +-- decoration -- SFX_CALL is $6a (constants/sfx_constants.asm:109), low +-- enough that the PlaySFX priority gate DROPS it outright while a louder +-- sound (SFX_READ_TEXT_2 $08, the A-press beep of the textbox that +-- queued the call) is still on the channels, so without it the player +-- can hear no ring at all. +-- * HangUp is the VM's own `hangup` op: SFX_HANG_UP under the Click! page, +-- transcribed once there rather than twice. +-- * InitCallReceiveDelay dispatches through CallAsm too, so a hung-up call +-- restarts the same receive countdown a map load does. +-- +-- Mom's shopping call (engine/events/mom_phone.asm MomTriesToBuySomething) +-- ends `farsjump Script_ReceivePhoneCall` with her pages queued in +-- wCallerContact, which is why `scriptKey` may be an inline row list: the +-- VM's runList takes either. +-- +-- Kept apart from src/core/gen2/Phone.lua on purpose: the model stays +-- dependency-free, and this file is the one that needs Strings. + +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") + +local PhoneRing = {} + +local RING_PAGE = Strings.source("RING!…RING!…\n%s") + +-- GetCallerClassAndName: a trainer contact is "<name>:" with the class name +-- beside it, a non-trainer its NonTrainerCallerNames row and the colon alone. +function PhoneRing.callerId(name, className) + local line = (name or "") .. ":" + if className and className ~= "" then + line = line .. " " .. className + end + return line +end + +-- `call` is a Phone.loadCallerScript / Phone.checkSpecialCall descriptor. +-- `delay` on it is the `pause 30` Script_SpecialElmCall's siblings run before +-- the ring; a random call carries none. +function PhoneRing.script(call, name, className) + -- phone.call_received, a Gen 2 invention: Gen 1 has no Pokegear and so no + -- name to share. It lives here rather than beside the two model-side + -- deciders (Phone.tryRandomCall and Phone.checkSpecialCall) because this is + -- the point every incoming call actually reaches the player: all three World + -- sites that ring the phone -- the random call, the queued special call and + -- Mom's shopping call -- build their rows through this one function, and a + -- descriptor the world decided not to run never gets here. + -- + -- call the descriptor, exactly as Phone.loadCallerScript built it + -- contact the PHONE_* contact id, 0 for the wrong-number script + -- name the caller's name as the caller-ID box prints it + -- className the trainer class under it, nil for a non-trainer caller + -- special the SPECIALCALL_* id for a scripted call, nil for a random + -- scriptKey the "bank:addr" key of the caller's own bank $41 script + -- + -- Observation only: the rows are built after it, so a listener cannot veto + -- the call. The veto seam is the VM's own script.started, which fires when + -- these rows run. + if Runtime.wants("phone.call_received") then + Runtime.emit("phone.call_received", { + call = call, + contact = call and call.contact or 0, + name = name, className = className, + special = call and call.special, + scriptKey = call and call.scriptKey, + }) + end + local rows = {} + if call and call.delay then + rows[#rows + 1] = { op = "pause", frames = call.delay } + end + rows[#rows + 1] = { op = "reanchormap" } + -- RingTwice_StartCall's two .Ring passes, each opening on + -- Phone_StartRinging's WaitSFX (engine/phone/phone.asm:458-469, :564-567) + -- and spaced by its three Phone_Wait20Frames (:576-580). + -- The box goes up inside the FIRST of these (idempotent, so the second pass + -- does not stack a duplicate) and comes down inside the InitCallReceiveDelay + -- row at the bottom -- no row of its own, because Script_ReceivePhoneCall has + -- none: the cart's box is tilemap that nothing erases. + rows[#rows + 1] = { op = "waitsfx" } + rows[#rows + 1] = { op = "callasm", label = "RingTwice_StartCall" } + rows[#rows + 1] = { op = "pause", frames = 60 } + rows[#rows + 1] = { op = "waitsfx" } + rows[#rows + 1] = { op = "callasm", label = "RingTwice_StartCall" } + rows[#rows + 1] = { op = "rawtext", + text = Strings(RING_PAGE, PhoneRing.callerId(name, className)) } + rows[#rows + 1] = { op = "farscall", script = call and call.scriptKey } + rows[#rows + 1] = { op = "waitbutton" } + rows[#rows + 1] = { op = "hangup" } + rows[#rows + 1] = { op = "closetext" } + rows[#rows + 1] = { op = "callasm", label = "InitCallReceiveDelay" } + rows[#rows + 1] = { op = "end" } + return rows +end + +return PhoneRing diff --git a/src/core/gen2/Pokerus.lua b/src/core/gen2/Pokerus.lua new file mode 100644 index 00000000..1d15cc66 --- /dev/null +++ b/src/core/gen2/Pokerus.lua @@ -0,0 +1,290 @@ +-- Pokerus: engine/events/pokerus/pokerus.asm, check_pokerus.asm and +-- apply_pokerus_tick.asm. +-- +-- One byte per party slot (box_struct's PokerusStatus, macros/ram.asm), and +-- both nybbles matter: +-- +-- high nybble the strain, 0..8. Never cleared once set. +-- low nybble days remaining, 1..4. Counted down by the daily tick. +-- +-- So a byte reads three ways, and every routine here picks a different one: +-- +-- $00 never infected. Can catch it. +-- $34 infected: strain 3, three days left. Spreads, doubles stat exp. +-- $30 cured: the strain stays behind as the immune marker. Does NOT +-- spread and cannot be reinfected, but STILL doubles stat exp -- +-- GiveExperiencePoints tests the whole byte (`ld a, [hl] / and a`) +-- rather than the day count, which is why a cured mon is the one +-- people train on. +-- +-- Two quirks below are the cart's and are ported deliberately: +-- +-- * .randomPokerusLoop can roll strain 0 (a byte whose high nybble came up +-- zero takes the `jr z, .load_pkrs` arm with a = 0), producing $01. That +-- mon cures to $00 and is therefore infectable again. +-- * the spread walk stops on a neighbour whose byte has its low two bits +-- clear (`and $3 / ret z`). That is meant to be "stop at a cured mon", but +-- it also stops at a four-day infection, because 4 and 8 and 12 are all +-- $3-clear. +-- +-- The de novo roll is gated on ENGINE_REACHED_GOLDENROD, so a save that has not +-- walked into Goldenrod City cannot catch it at all -- the flag is set by +-- GoldenrodCity's map callback (maps/GoldenrodCity.asm), which the port runs +-- like any other. + +local BugContest = require("src.core.gen2.BugContest") +local Runtime = require("src.mods.Runtime") + +local Pokerus = {} + +-- pokerus.infected, a Gen 2 invention: Gen 1 has no Pokerus byte, so there is +-- no name to share. Raised from the two -- and only two -- writes that turn a +-- clean byte into an infected one, so a mod that wants to notice the virus +-- does not have to poll the party: +-- +-- party the party the write landed in +-- slot the 1-based party index that caught it +-- mon that party record, already carrying the new byte +-- strain the high nybble, 1..8 (0 is the "cured, immune" strain) +-- days the low nybble's countdown, 1..4 days +-- source "spread" for .TrySpreadPokerus walking off an infected +-- neighbour, "contracted" for the 3-in-65536 de novo roll +local function emitInfected(party, slot, source) + if not Runtime.wants("pokerus.infected") then return end + local mon = party and party[slot] + Runtime.emit("pokerus.infected", { + party = party, slot = slot, mon = mon, + strain = Pokerus.strain(mon), days = Pokerus.days(mon), + source = source, + }) +end + +-- constants/engine_flags.asm index 21, ENGINE_REACHED_GOLDENROD, backed by +-- wStatusFlags2 bit STATUSFLAGS2_REACHED_GOLDENROD_F. +Pokerus.ENGINE_REACHED_GOLDENROD = 21 + +-- `percent` is `* $ff / 100` (macros/data.asm), so these are 85 and 128 out of +-- 256 rather than 33 and 50 out of 100. +Pokerus.SPREAD_CHANCE = math.floor(33 * 0xff / 100) + 1 +Pokerus.BACKWARD_CHANCE = math.floor(50 * 0xff / 100) + 1 + +-- `call Random` gives one byte; the convention is the same as +-- BugContest.random's, a function of no arguments returning 0..255, so a test +-- can pin every roll. +function Pokerus.random() + if love and love.math and love.math.random then + return love.math.random(0, 255) + end + return math.random(0, 255) +end + +local function byte(random) + return (random or Pokerus.random)() +end + +-- ------------------------------------------------------------- reading a mon + +function Pokerus.byteOf(mon) + local value = tonumber(mon and mon.pokerus) or 0 + if value < 0 then return 0 end + return math.floor(value) % 256 +end + +function Pokerus.strain(mon) + return math.floor(Pokerus.byteOf(mon) / 16) +end + +function Pokerus.days(mon) + return Pokerus.byteOf(mon) % 16 +end + +-- An active infection: the low nybble is what _CheckPokerus and the party scan +-- in GivePokerusAndConvertBerries both test. +function Pokerus.isInfected(mon) + return Pokerus.days(mon) ~= 0 +end + +-- Cured, and carrying the strain as the immune marker. This is the dot the +-- stats screen prints beside the level (src/ui/gen2/SummaryMenu.lua). +function Pokerus.isImmune(mon) + local value = Pokerus.byteOf(mon) + return value ~= 0 and value % 16 == 0 +end + +-- GiveExperiencePoints' `ld a, MON_POKERUS / call GetPartyParamLocation / +-- ld a, [hl] / and a`: the WHOLE byte, so immune counts. +function Pokerus.doublesStatExp(mon) + return Pokerus.byteOf(mon) ~= 0 +end + +-- _CheckPokerus: carry when any party member has an active infection. The +-- CheckPokerus special and the Pokemon Center nurse both go through this. +function Pokerus.inParty(party) + for _, mon in ipairs(party or {}) do + if Pokerus.isInfected(mon) then return true end + end + return false +end + +-- ------------------------------------------------------------- the daily tick + +-- ApplyPokerusTick: subtract `days` from every active counter, clamped at zero, +-- leaving the strain nybble alone. That clamp is the whole immunity mechanic: +-- a counter that reaches zero keeps its strain, and every routine that could +-- reinfect the mon tests the strain first. Returns the slots that cured on +-- this tick. +function Pokerus.applyTick(party, days) + local cured = {} + days = math.max(0, math.floor(tonumber(days) or 0)) + for index, mon in ipairs(party or {}) do + local value = Pokerus.byteOf(mon) + local left = value % 16 + if left ~= 0 then + left = left - days + if left < 0 then left = 0 end + mon.pokerus = (value - value % 16) + left + if left == 0 then cured[#cured + 1] = index end + end + end + return cured +end + +-- CheckPokerusTick (engine/overworld/time.asm), the `.do_daily` arm of the +-- player-event chain. CalcDaysSince ADVANCES the stored day to today as it +-- reads it (`ld [hl], c ; current days`), so the tick subtracts "days since the +-- last poll" and not "days since the timer started" -- and a clock wound +-- backwards wraps into a large jump forward rather than going negative, which +-- cures a party outright. +-- +-- wTimerEventStartDay is written once at new game (_InitializeStartDay) and +-- only ever by this routine after that; a save from before this landed has no +-- day stamped, so the first poll stamps today and ticks nothing. +function Pokerus.checkTick(save, now) + if type(save) ~= "table" then return false end + if save.pokerusStartDay == nil then + save.pokerusStartDay = (now or BugContest.now()).day + return false + end + local stamp = { day = save.pokerusStartDay } + local since = BugContest.elapsedSince(stamp, now, "day") + save.pokerusStartDay = stamp.day + if since.days == 0 then return false end + Pokerus.applyTick(save.party or {}, since.days) + return true +end + +-- ----------------------------------------------------------- catching it + +-- .infectMon: the new slot takes the strain of the byte the walk last looked at +-- (register c, which is the carrier on the first step and the neighbour walked +-- over after that) and a fresh counter from that strain's low two bits: +-- `swap a / and $3 / inc a`, so 1..4 days. +local function infect(party, slot, carrier) + local mon = party[slot] + if not mon then return nil end + local strainBits = carrier - carrier % 16 + local days = (math.floor(carrier / 16) % 4) + 1 + mon.pokerus = strainBits + days + emitInfected(party, slot, "spread") + return slot +end + +-- .TrySpreadPokerus, entered with `index` at the first infected slot. Register +-- b is the number of slots from that one to the end of the party inclusive, +-- which is how the cart knows whether there is anything left to walk to; both +-- loops keep it in that shape rather than counting slots directly. +local function spread(party, index, random) + local count = #party + if byte(random) >= Pokerus.SPREAD_CHANCE then return nil end + if count == 1 then return nil end + local b = count - index + 1 + local carrier = Pokerus.byteOf(party[index]) + local slot = index + -- `ld a, b / cp 2 / jr c` : the last slot has nothing after it, so it always + -- walks backwards. Otherwise it is a coin flip. + local forward = b >= 2 and byte(random) >= Pokerus.BACKWARD_CHANCE + if forward then + while true do + slot = slot + 1 + local value = Pokerus.byteOf(party[slot]) + if value == 0 then return infect(party, slot, carrier) end + carrier = value + if value % 4 == 0 then return nil end + b = b - 1 + if b == 1 then return nil end + end + end + while true do + -- `ld a, [wPartyCount] / cp b / ret z`: b back up at the party count means + -- the walk is at slot one and there is nothing before it. + if b == count then return nil end + slot = slot - 1 + local value = Pokerus.byteOf(party[slot]) + if value == 0 then return infect(party, slot, carrier) end + carrier = value + if value % 4 == 0 then return nil end + b = b + 1 + end +end + +-- GivePokerusAndConvertBerries, the Pokerus half (the Shuckle berry half is a +-- separate routine and is not this module's). Runs on a battle WIN, once. +-- +-- The party scan comes first and it is not a formality: while ANY slot is +-- infected the whole routine becomes a spread roll, so a party with an active +-- infection can never contract a second one. Returns the slot that changed, or +-- nil when nothing did. +-- +-- opts.random pins the rolls, opts.reachedGoldenrod is +-- ENGINE_REACHED_GOLDENROD. +function Pokerus.give(party, opts) + opts = opts or {} + local random = opts.random + local count = #(party or {}) + if count == 0 then return nil end + for index, mon in ipairs(party) do + if Pokerus.isInfected(mon) then + return spread(party, index, random) + end + end + if not opts.reachedGoldenrod then return nil end + -- 3 in 65536: hRandomAdd must be zero and hRandomSub under 3. One `call + -- Random` fills both, which is two bytes off this module's roller. + if byte(random) ~= 0 then return nil end + if byte(random) >= 3 then return nil end + -- `and $7 / cp b / jr nc`: reroll until the slot is inside the party. + local slot + repeat + slot = byte(random) % 8 + until slot < count + local mon = party[slot + 1] + local value = Pokerus.byteOf(mon) + -- `and $f0 / ret nz`: a strain in the high nybble means infected or immune, + -- and either way this mon is done catching it. + if value - value % 16 ~= 0 then return nil end + -- .randomPokerusLoop samples strain and duration from ONE non-zero byte. + local roll + repeat + roll = byte(random) + until roll ~= 0 + local strain = 0 + if roll >= 16 then strain = (roll % 8) + 1 end + mon.pokerus = strain * 16 + (strain % 4) + 1 + emitInfected(party, slot + 1, "contracted") + return slot + 1 +end + +-- The call-site shape: ExitBattle runs this off the save, so the Goldenrod +-- flag comes out of the engine-flag table the scripts write +-- (src/world/gen2/World.lua setEngineFlag) rather than being passed in. +function Pokerus.giveAfterBattle(save, party, opts) + if type(save) ~= "table" then return nil end + opts = opts or {} + local flags = save.engineFlags or {} + return Pokerus.give(party or save.party or {}, { + random = opts.random, + reachedGoldenrod = flags[Pokerus.ENGINE_REACHED_GOLDENROD] == true, + }) +end + +return Pokerus diff --git a/src/core/gen2/Roamers.lua b/src/core/gen2/Roamers.lua new file mode 100644 index 00000000..9526f5f6 --- /dev/null +++ b/src/core/gen2/Roamers.lua @@ -0,0 +1,573 @@ +-- The two pieces of Gen 2 world state that override where a wild mon comes +-- from: the three 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 +-- because ChooseWildEncounter (engine/overworld/wildmons.asm) consults them in +-- one breath: _GrassWildmonLookup checks the swarm table before the Johto one, +-- and ChooseWildEncounter checks the roamers before it rolls a slot at all. +-- +-- Roamers engine/overworld/wildmons.asm InitRoamMons, CheckEncounterRoamMon, +-- UpdateRoamMons, JumpRoamMons, _BackUpMapIndices +-- engine/battle/core.asm BattleEnd_HandleRoamMons +-- data/wild/roammon_maps.asm RoamMaps +-- Swarms engine/events/specials.asm StoreSwarmMapIndices, SetSwarmFlag, +-- CheckSwarmFlag, ActivateFishingSwarm +-- engine/overworld/wildmons.asm _SwarmWildmonCheck +-- data/wild/swarm_grass.asm, swarm_water.asm +-- +-- Everything here is save state. A roamer that forgets where it is or how +-- hurt it is between two encounters is not a roamer, it is a random spawn. +-- +-- MAP IDENTITY: the cart carries a roamer's position as a (group, number) +-- pair; the port carries map ids ("ROUTE_42"), which are the same fact with +-- the indirection removed, so every comparison below that reads as `cp d / +-- cp e` in the asm is one string compare here. GROUP_N_A / MAP_N_A -- the +-- pair InitRoamMons never writes and BattleEnd_HandleRoamMons writes when a +-- beast is caught or beaten -- is nil. + +local Mon = require("src.battle.gen2.Mon") +local Runtime = require("src.mods.Runtime") + +local Roamers = {} + +-- roamer.moved, a Gen 2 invention: Gen 1 has no roaming legendary, so there is +-- no name to share. One event per beast that actually changed route, raised +-- from both routines that move them -- UpdateRoamMons (the one-route walk a +-- 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 +-- slot that roamer record, already carrying the new map +-- species the beast's species id +-- from the map id it left +-- to the map id it is on now +-- reason "connection" for UpdateRoamMons, "jump" for JumpRoamMons +-- +-- A beast whose roll left it on the map it was already on raises nothing: +-- .Update's `jr z` re-rolls rather than standing still, so "moved" means moved. +local function emitMoved(index, slot, from, reason) + if from == slot.map then return end + if not Runtime.wants("roamer.moved") then return end + Runtime.emit("roamer.moved", { + index = index, slot = slot, species = slot.species, + from = from, to = slot.map, reason = reason, + }) +end + +-------------------------------------------------------------------------- +-- The beasts +-------------------------------------------------------------------------- + +-- InitRoamMons, written out. The species order IS the slot order -- Raikou 1, +-- Entei 2, Suicune 3 -- because CheckEncounterRoamMon indexes the structs by a +-- random 0..2 and GetRoamMonHP walks them by species, so renumbering them +-- would send Suicune's damage to Raikou's byte. +Roamers.SPECIES = { + { species = "RAIKOU", level = 40, map = "ROUTE_42" }, + { species = "ENTEI", level = 40, map = "ROUTE_37" }, + { species = "SUICUNE", level = 40, map = "ROUTE_38" }, +} +Roamers.COUNT = 3 +Roamers.LEVEL = 40 + +-- 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 +-- an ENTRY by a four-bit index, so shuffling the rows changes where a +-- teleport drops it. +-- +-- Route 40 and Route 41 are deliberately absent (they are water routes, and +-- CheckEncounterRoamMon refuses to fire while the player is surfing anyway). +Roamers.NUM_MAPS = 16 +Roamers.MAPS = { + { map = "ROUTE_29", to = { "ROUTE_30", "ROUTE_46" } }, + { map = "ROUTE_30", to = { "ROUTE_29", "ROUTE_31" } }, + { map = "ROUTE_31", to = { "ROUTE_30", "ROUTE_32", "ROUTE_36" } }, + { map = "ROUTE_32", to = { "ROUTE_36", "ROUTE_31", "ROUTE_33" } }, + { map = "ROUTE_33", to = { "ROUTE_32", "ROUTE_34" } }, + { map = "ROUTE_34", to = { "ROUTE_33", "ROUTE_35" } }, + { map = "ROUTE_35", to = { "ROUTE_34", "ROUTE_36" } }, + { map = "ROUTE_36", to = { "ROUTE_35", "ROUTE_31", "ROUTE_32", "ROUTE_37" } }, + { map = "ROUTE_37", to = { "ROUTE_36", "ROUTE_38", "ROUTE_42" } }, + { map = "ROUTE_38", to = { "ROUTE_37", "ROUTE_39", "ROUTE_42" } }, + { map = "ROUTE_39", to = { "ROUTE_38" } }, + { map = "ROUTE_42", to = { "ROUTE_43", "ROUTE_44", "ROUTE_37", "ROUTE_38" } }, + { map = "ROUTE_43", to = { "ROUTE_42", "ROUTE_44" } }, + { map = "ROUTE_44", to = { "ROUTE_42", "ROUTE_43", "ROUTE_45" } }, + { map = "ROUTE_45", to = { "ROUTE_44", "ROUTE_46" } }, + { map = "ROUTE_46", to = { "ROUTE_45", "ROUTE_29" } }, +} + +-- RomExtractorGen2 emits `encounters.roamMaps` in exactly the shape above, so +-- the table above is the fallback for a cache built before it did (and for a +-- caller with no cache at all, which every unit test here is). +function Roamers.mapTable(encounters) + local extracted = encounters and encounters.roamMaps + if type(extracted) == "table" and #extracted > 0 then return extracted end + return Roamers.MAPS +end + +-- `.Update`'s search: walk RoamMaps for the entry whose START map is `mapId`. +-- The asm ends on `cp -1 / ret z`, which leaves b and c untouched -- a roamer +-- standing somewhere RoamMaps does not list simply does not move. +function Roamers.entryFor(mapId, encounters) + if not mapId then return nil end + for _, row in ipairs(Roamers.mapTable(encounters)) do + if row.map == mapId then return row end + end + return nil +end + +-- 0 .. n-1, the same convention src/battle/gen2/Encounter.lua and the battle +-- engine use for an injected random, so one seeded generator drives them all. +local function rand(random, n) + if random then return random(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 +end + +-- JumpRoamMon: a completely random RoamMaps entry, re-rolled while it lands on +-- the map the PLAYER is standing on. (The asm's `maskbits NUM_ROAMMON_MAPS / +-- cp NUM_ROAMMON_MAPS / jr nc` retry is dead code -- there are exactly 16 +-- entries and the mask is four bits -- so it is not modelled.) +-- +-- The retry is unbounded on the cart; `tries` caps it here so a caller that +-- hands in a degenerate random (a stub that always returns 0) cannot hang the +-- overworld. Falling out of the loop leaves the beast where it was, which is +-- the same outcome `.Update`'s not-found path has. +function Roamers.jumpOne(playerMapId, random, encounters) + local table_ = Roamers.mapTable(encounters) + local count = #table_ + if count == 0 then return nil end + for _ = 1, 32 do + local row = table_[rand(random, count) + 1] + if row and row.map ~= playerMapId then return row.map end + end + return nil +end + +-- `.Update`: one roamer's move on a map change. +-- +-- The single random byte does double duty, which is the part a paraphrase +-- always loses: +-- +-- and %00011111 -> zero (1 in 32) means "jump to a completely random map" +-- and %11 -> otherwise the LOW TWO BITS of that same masked value are +-- the connection index +-- +-- so the choice of connection is not independent of the choice to jump. An +-- index at or past the entry's connection count re-rolls, and so does a +-- connection that equals wRoamMons_LastMapGroup/Number -- the map the player +-- was on BEFORE the one they are on now, which is what keeps a beast from +-- following the player back and forth down one pair of routes. +function Roamers.moveOne(mapId, lastMapId, playerMapId, random, encounters) + local entry = Roamers.entryFor(mapId, encounters) + if not entry then return mapId end + local list = entry.to or {} + for _ = 1, 64 do + local value = rand(random, 256) % 32 + if value == 0 then + return Roamers.jumpOne(playerMapId, random, encounters) or mapId + end + local index = value % 4 + if index < #list then + local candidate = list[index + 1] + if candidate ~= lastMapId then return candidate end + end + end + return mapId +end + +-------------------------------------------------------------------------- +-- The save record +-------------------------------------------------------------------------- +-- +-- save.roamers is a three-slot array in the order above, written first by the +-- InitRoamMons special (src/script/gen2/Specials.lua) when the Burned Tower +-- basement script fires. Each slot: +-- +-- species nil once the beast has been caught or beaten (GetRoamMonSpecies +-- writes 0 there, which is what stops it ever appearing again) +-- level 40, and it never changes: the roam struct has no experience +-- map the map id it occupies, nil for GROUP_N_A / MAP_N_A +-- hp its remaining HP, 0 meaning "not yet rolled". ONE byte on the +-- cart, because Raikou and Entei have under 256 HP at level 40 -- +-- the port keeps the same ceiling rather than quietly widening it +-- dvs rolled at the FIRST encounter and kept, so a beast you chase all +-- game is the same individual +-- +-- There is deliberately no status field: the seven-byte roam_struct +-- (macros/ram.asm) has no room for one, and LoadEnemyMon zeroes +-- wEnemyMonStatus for every wild mon, so a beast you paralyse walks it off the +-- moment it flees. Storing status would be a buff the cart does not grant. +Roamers.MAX_STORED_HP = 255 + +function Roamers.list(save) + return (type(save) == "table" and save.roamers) or nil +end + +function Roamers.slot(save, index) + local list = Roamers.list(save) + return list and list[index] or nil +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. +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 list = {} + for _, row in ipairs(Roamers.SPECIES) do + list[#list + 1] = { + species = row.species, + level = row.level, + map = row.map, + -- `xor a ; generate new stats` -- the asm comments its own zero. + hp = 0, + } + end + save.roamers = list + return list +end + +-- Is this beast still out there? A caught or defeated one keeps its slot but +-- has neither a species nor a map, and every path below refuses it. +function Roamers.active(slot) + return type(slot) == "table" and slot.species ~= nil and slot.map ~= nil +end + +-- _BackUpMapIndices: Cur shifts into Last, then the player's current map +-- becomes Cur. It runs at the END of both UpdateRoamMons and JumpRoamMons, so +-- the "last map" a move avoids is the one the player left BEFORE the one they +-- are standing on. +function Roamers.backUpMapIndices(save, playerMapId) + if type(save) ~= "table" then return end + local marks = save.roamerMaps or {} + marks.last = marks.current + marks.current = playerMapId + save.roamerMaps = marks +end + +function Roamers.lastMap(save) + return (type(save) == "table" and save.roamerMaps and save.roamerMaps.last) + or nil +end + +-- UpdateRoamMons: each live beast moves along a connection, then the map +-- indices are backed up. Runs on a map CONNECTION and on a door / fall warp +-- (data/maps/setup_scripts.asm MapSetupScript_Connection and +-- MapSetupScript_Door), not on a plain warp. +function Roamers.update(save, playerMapId, random, encounters) + local list = Roamers.list(save) + if not list then return false end + local lastMapId = Roamers.lastMap(save) + for index, slot in ipairs(list) do + if Roamers.active(slot) then + local from = slot.map + slot.map = Roamers.moveOne(from, lastMapId, playerMapId, random, + encounters) + emitMoved(index, slot, from, "connection") + end + end + Roamers.backUpMapIndices(save, playerMapId) + return true +end + +-- JumpRoamMons: every live beast teleports to a random roam map. This is the +-- Teleport setup script (MapSetupScript_Teleport), which is why flying or +-- teleporting across Johto scatters them instead of nudging them one route. +function Roamers.jumpAll(save, playerMapId, random, encounters) + local list = Roamers.list(save) + if not list then return false end + for index, slot in ipairs(list) do + if Roamers.active(slot) then + local from = slot.map + slot.map = Roamers.jumpOne(playerMapId, random, encounters) or slot.map + emitMoved(index, slot, from, "jump") + end + end + Roamers.backUpMapIndices(save, playerMapId) + return true +end + +-------------------------------------------------------------------------- +-- Meeting one +-------------------------------------------------------------------------- + +-- CheckEncounterRoamMon, which ChooseWildEncounter calls BEFORE it rolls a +-- slot -- so a roamer replaces the map's own encounter rather than adding to +-- it, and only on a map that has an encounter table at all. +-- +-- One random byte, three gates: +-- cp 100 / jr nc -> 100 of 256 get past +-- and %11 / jr z -> three quarters of those get past +-- dec a -> 1, 2 or 3 becomes slot 0, 1 or 2 +-- which is 75/256, about 29%, split evenly between the three beasts. A slot +-- whose map is not the player's map fails outright: there is NO re-roll onto +-- another beast, so two beasts sharing your route still only get one roll +-- each encounter. +-- +-- Surfing refuses before anything else (`call CheckOnWater / jr z`), which is +-- what keeps Suicune out of the water on Route 42. +function Roamers.checkEncounter(save, mapId, onWater, random) + if onWater then return nil end + local list = Roamers.list(save) + if not list then return nil end + local value = rand(random, 256) + if value >= 100 then return nil end + local index = value % 4 + if index == 0 then return nil end + local slot = list[index] + if not Roamers.active(slot) then return nil end + if slot.map ~= mapId then return nil end + local hit = { index = index, slot = slot, species = slot.species, + level = slot.level } + -- roamer.encountered, a Gen 2 invention, raised on the roll that REPLACES + -- the map's own encounter -- CheckEncounterRoamMon runs ahead of + -- ChooseWildEncounter, so by the time the shared encounter.species hook sees + -- anything the beast has already won the slot. This is the only notice a + -- mod gets that the wild mon about to appear is the roamer. + -- + -- index / slot / species / level the same four fields the caller takes + -- mapId the map the player is standing on, which is also the beast's + -- + -- Observation only: the shared encounter.species hook still runs downstream + -- and is where a mod changes what appears. + if Runtime.wants("roamer.encountered") then + Runtime.emit("roamer.encountered", { + index = index, slot = slot, species = slot.species, + level = slot.level, mapId = mapId, + }) + end + return hit +end + +-- Build the enemy for a roaming battle, out of the ONE party-member builder +-- (src/battle/gen2/Mon.lua) so the beast arrives with a real Gen 2 moveset. +-- +-- LoadEnemyMon's two roam branches, in the order it runs them: +-- * DVs: `and a` on the stored HP decides. Zero means the struct has never +-- been used, so fresh DVs are rolled and kept; anything else reuses them. +-- * HP: zero takes .InitRoamHP, which writes the mon's FULL HP back into the +-- struct. So the first encounter both rolls and banks it. +function Roamers.beginBattle(save, index, data) + local slot = Roamers.slot(save, index) + if not Roamers.active(slot) then return nil end + local fresh = (slot.hp or 0) == 0 + local mon = Mon.new(data, slot.species, slot.level or Roamers.LEVEL, { + dvs = (not fresh) and slot.dvs or nil, + }) + if not mon then return nil end + slot.dvs = mon.dvs + if fresh then + -- .InitRoamHP: the struct takes the mon's max HP now, not at the end of + -- the battle. + slot.hp = math.min(Roamers.MAX_STORED_HP, mon.maxHp or 0) + else + mon.hp = math.min(slot.hp, mon.maxHp or slot.hp) + end + return mon, slot +end + +-- BattleEnd_HandleRoamMons. +-- +-- `outcome` is the port's battle outcome; the cart reads the low nibble of +-- wBattleResult, where WIN is 0 and both a wild flee and a player run write +-- DRAW (WildFled_EnemyFled_LinkBattleCanceled, and TryToRunAwayFromBattle's +-- .can_escape). So "caught" and "win" clear the beast for good, and +-- everything else banks its HP and moves it. +-- +-- The `.not_roaming` tail is the other half of this routine and belongs to +-- ordinary wild battles: a 1-in-16 roll moves the beasts anyway, which is why +-- they drift while you grind and not only while you walk. +function Roamers.endBattle(save, index, outcome, hp, playerMapId, random, + encounters) + local slot = Roamers.slot(save, index) + if not Roamers.active(slot) then return false end + if outcome == "win" or outcome == "caught" then + slot.species = nil + slot.map = nil + slot.hp = 0 + return true + end + slot.hp = math.max(0, math.min(Roamers.MAX_STORED_HP, hp or 0)) + Roamers.update(save, playerMapId, random, encounters) + return true +end + +-- BattleEnd_HandleRoamMons `.not_roaming`: after ANY other wild battle, +-- `call BattleRandom / and $f / ret nz` gives one chance in sixteen that the +-- beasts move. +function Roamers.afterWildBattle(save, playerMapId, random, encounters) + if not Roamers.list(save) then return false end + if rand(random, 256) % 16 ~= 0 then return false end + return Roamers.update(save, playerMapId, random, encounters) +end + +-- data/wild/flee_mons.asm. TryEnemyFlee walks AlwaysFleeMons first and takes +-- the carry straight to `.Flee`, which is why a beast never gets a second turn +-- -- 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 } +Roamers.OFTEN_FLEE = { + CUBONE = true, ARTICUNO = true, ZAPDOS = true, MOLTRES = true, + QUAGSIRE = true, DELIBIRD = true, PHANPY = true, TEDDIURSA = true, +} +Roamers.SOMETIMES_FLEE = { + MAGNEMITE = true, GRIMER = true, TANGELA = true, MR__MIME = true, + EEVEE = true, PORYGON = true, DRATINI = true, DRAGONAIR = true, + TOGETIC = true, UMBREON = true, UNOWN = true, SNUBBULL = true, + HERACROSS = true, +} + +-------------------------------------------------------------------------- +-- Swarms +-------------------------------------------------------------------------- + +local Swarm = {} +Roamers.Swarm = Swarm + +-- The state, on the save: +-- save.swarmMap wSwarmMapGroup / wSwarmMapNumber, as a map id +-- 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. + +-- constants/script_constants.asm, ActivateFishingSwarm setval arguments. +Swarm.FISH_NONE = 0 +Swarm.FISH_QWILFISH = 1 +Swarm.FISH_REMORAID = 2 + +-- 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) + if type(save) ~= "table" then return false end + save.dailyFlags = save.dailyFlags or {} + save.dailyFlags.swarm = true + save.swarmMap = mapId + return true +end + +-- ActivateFishingSwarm: wScriptVar into wFishingSwarmFlag, then the same +-- fallthrough into SetSwarmFlag -- note it does NOT touch the map pair, so a +-- fishing swarm rides whatever map a grass swarm left behind. +function Swarm.setFishing(save, kind) + if type(save) ~= "table" then return false end + save.dailyFlags = save.dailyFlags or {} + save.dailyFlags.fishingSwarm = kind or Swarm.FISH_NONE + save.dailyFlags.swarm = true + return true +end + +function Swarm.active(save) + return type(save) == "table" and save.dailyFlags ~= nil + and save.dailyFlags.swarm == true +end + +function Swarm.mapId(save) + if not Swarm.active(save) then return nil end + return save.swarmMap +end + +function Swarm.fishing(save) + if not Swarm.active(save) then return Swarm.FISH_NONE end + return (save.dailyFlags and save.dailyFlags.fishingSwarm) or Swarm.FISH_NONE +end + +-- CheckSwarmFlag. Returns the value it leaves in wScriptVar: 0 while the flag +-- is up, 1 once it is not -- and on that 1 it clears the fishing flag and the +-- map pair, which is the ONLY thing that ever ends a swarm. Note the polarity: +-- an `iffalse` after this special means "the swarm is still on". +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 + save.swarmMap = nil + return 1 +end + +-- CheckDailyResetTimer (engine/overworld/time.asm): a one-day countdown that, +-- when it runs out, zeroes wDailyFlags1 AND wDailyFlags2 and restarts itself. +-- `day` is a day number that only has to be monotonic and comparable -- the +-- port's save carries os.date("%j") in save.rtc.day. +function Swarm.checkDailyReset(save, day) + if type(save) ~= "table" or not day then return false end + if save.dailyResetDay == nil then + save.dailyResetDay = day + return false + end + if save.dailyResetDay == day then return false end + save.dailyFlags = {} + save.dailyResetDay = day + return true +end + +-- CheckTimeEvents' `.do_daily` block, in its order: the reset timer first, then +-- CheckSwarmFlag -- which is precisely why a swarm dies a day after it was set +-- rather than needing its own timer. Returns true when the swarm ended on +-- this call. +function Swarm.timeEvents(save, day) + local reset = Swarm.checkDailyReset(save, day) + local hadMap = save and save.swarmMap ~= nil + Swarm.check(save) + return reset and hadMap and (save.swarmMap == nil) +end + +-- _SwarmWildmonCheck: the swarm table is searched BEFORE the Johto/Kanto one, +-- and only when the player is standing on the swarm's own map. A swarm map +-- that is not in the swarm table falls through to the normal lookup +-- (`call LookUpWildmonsForMapDE / jr nc, .noSwarm`), which is what keeps a +-- fishing swarm from blanking the grass on Route 32. +-- +-- The reader below looks for `encounters.swarmGrass` and +-- `encounters.swarmWater`, keyed by map id in exactly the shape +-- `encounters.grass` / `encounters.water` already use -- which is what +-- RomExtractorGen2 writes, since the cart's swarm tables ARE grass and water +-- records. A cache built before it did simply has no swarm rows and every +-- 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 + local table_ = (kind == "water") and encounters.swarmWater + or encounters.swarmGrass + return table_ and table_[mapId] or nil +end + +-- An `encounters` view with the swarm's rows in front of the map's own, for a +-- caller that wants to keep using src/battle/gen2/Encounter.lua unchanged. +-- Returns the ORIGINAL table when no swarm applies, so the common step pays +-- nothing. +function Swarm.tables(save, encounters, mapId) + if not encounters then return encounters end + local grass = Swarm.entry(save, encounters, mapId, "grass") + local water = Swarm.entry(save, encounters, mapId, "water") + if not (grass or water) then return encounters end + local view = {} + for key, value in pairs(encounters) do view[key] = value end + if grass then + local rows = {} + for key, value in pairs(encounters.grass or {}) do rows[key] = value end + rows[mapId] = grass + view.grass = rows + end + if water then + local rows = {} + for key, value in pairs(encounters.water or {}) do rows[key] = value end + rows[mapId] = water + view.water = rows + end + return view +end + +return Roamers diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua new file mode 100644 index 00000000..0a4e7b1c --- /dev/null +++ b/src/core/gen2/Save.lua @@ -0,0 +1,792 @@ +-- Gen 2 save file. +-- +-- Deliberately separate from src/core/SaveData.lua rather than a branch inside +-- it: that module's shape is Gen 1's SRAM (Kanto badges, 12 boxes of 20, +-- pikachu happiness, the Gen 1 party struct with one `special` stat), and its +-- validate/migration chain asserts against that shape. A Gold save has a +-- different party struct (SpA/SpD, held item, happiness, pokerus), different +-- boxes, a phone book, a Pokedex with two orderings, and an RTC. +-- +-- What IS shared, on purpose: +-- * SaveSerializer, so both generations' files are the same Lua-table format +-- and the standalone save editor can read either +-- * the save-file naming convention (GameVersion.saveSuffix -> save_gold.lua +-- plus .bak / .tmp), so Gold sits beside Red/Blue/Yellow without touching +-- them, and the same atomic write dance protects it +-- * options.lua, which is version-independent and survives New Game +-- +-- Layout notes taken from the cart: a New Game starts at SPAWN_HOME +-- (PLAYERS_HOUSE_2F 3,3 -- engine/menus/intro_menu.asm NewGame), the money cap +-- is 999999, and playtime is kept as h/m/s/frames the way wGameTime* is. + +local GameVersion = require("src.core.GameVersion") +local HallOfFame = require("src.core.gen2.HallOfFame") +local Mail = require("src.core.gen2.Mail") +local MomShopping = require("src.core.gen2.MomShopping") +local Logger = require("src.core.Logger") +-- The mod hook bus. Same module the Gen 1 save reaches for +-- (src/core/SaveData.lua), because the hook NAMES are shared across +-- generations: a mod that wraps save.new_game reshapes either game's skeleton +-- without knowing which one it is running under. Null objects until a loader +-- installs the live buses, so a mod-free boot and every headless test pay +-- nothing for the call. +local Runtime = require("src.mods.Runtime") +local SaveSerializer = require("src.core.SaveSerializer") + +local function rand(a, b) + if love and love.math and love.math.random then + return love.math.random(a, b) + end + return math.random(a, b) +end + +local Save = {} + +-- Bumped whenever a field's meaning changes; migrations key off it. +-- +-- 1 -> 2 the Hall of Fame roster (sHallOfFame + wHallOfFameCount) and +-- wSpawnAfterChampion. A format-1 save predates the endgame, so it +-- has neither and the migration is the empty roster. +-- 2 -> 3 scriptMem, the script VM's sparse WRAM store. A format-2 save +-- kept those bytes per-session, so the migration is the empty table: +-- every address reads back as 0, which is what the cart's own +-- zero-filled WRAM gives a save that never touched one. +-- 3 -> 4 mail: sPartyMail (six `mailmsg` structs keyed by PARTY SLOT) and +-- sMailboxes + sMailboxCount (the PC's MAILBOX). A format-3 save +-- has neither and cannot have a letter anywhere, so the upgrade is +-- the empty pair -- and a mon in one of those saves carrying a MAIL +-- item is exactly the case Mail.sendToPc's blank-struct fallback +-- covers. +-- 4 -> 5 `events` (wEventFlags) and `mapScenes` (the w<Map>SceneID block) +-- became LOAD BEARING. Both fields existed in format 4 and both +-- were written on every save, but nothing ever read them back -- +-- World:loadPlayerData is what does now -- so a format-4 file's +-- copies had never been validated by anything. The migration is +-- the pair of tables; Save.validate is what scrubs them from here +-- on, the same way it always has for scriptMem. +-- 5 -> 6 `playerState` (wPlayerState), which sits in the same sPlayerData +-- block the two fields above do and had never been written at all: +-- a format-5 save made on the BICYCLE or aboard a Lapras came back +-- on foot. There is nothing to carry across, so the upgrade is +-- PLAYER_NORMAL -- the cart's own zero byte, and what those files +-- have effectively been loading as all along. +-- 6 -> 7 `mom.whichItem` (wWhichMomItem) and `mom.triggerBalance` +-- (wMomItemTriggerBalance), the two bytes MomTriesToBuySomething +-- walks (src/core/gen2/MomShopping.lua). NewGame seeds them in +-- engine/menus/intro_menu.asm, so a file made before Mom could +-- spend anything upgrades to exactly those seeds: the ladder on its +-- first rung and the consolation threshold at MOM_MONEY. A save +-- that already has savings banked therefore starts buying from the +-- bottom of the list, which is what a cartridge whose owner had +-- saved that much would also do. +Save.FORMAT = 7 + +Save.MAX_MONEY = 999999 +Save.MAX_COINS = 9999 +-- constants/pokemon_data_constants.asm: 6 party slots, 14 boxes of 20. +Save.PARTY_SIZE = 6 +Save.NUM_BOXES = 14 +Save.MONS_PER_BOX = 20 +-- wEventFlags is `flag_array NUM_EVENTS` (ram/wram.asm) and NUM_EVENTS is +-- $800, so the bitfield is 256 bytes and a byte index past the last one cannot +-- have come from the cart. +Save.EVENT_BYTES = 256 +-- wPlayerState (constants/ram_constants.asm), kept by NAME rather than as the +-- raw byte so a save that round-trips one stays readable. These are the four +-- strings src/world/gen2/FieldMoves.lua names the states by, and this is the +-- set World:loadPlayerData tests a restored value against as well, so the two +-- ends of the round trip cannot drift apart. PLAYER_SKATE has no entry for +-- the same reason FieldMoves has no name for it: nothing in Gold writes it. +Save.PLAYER_NORMAL = "normal" +Save.PLAYER_STATES = { + normal = true, bike = true, surf = true, surf_pika = true, +} + +local function saveNames(version) + version = version or "gold" + -- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to + -- the flat save_<version>.lua when no slot is registered. + -- + -- The launcher's slot system (src/core/SaveData.lua) migrates a flat + -- save_gold.lua into saves/<version>/<slot>.lua the first time it lists the + -- version's slots -- and DELETES the flat file. This module read the flat + -- name unconditionally, so after that migration the Gold title screen found + -- no save and dropped CONTINUE (and a following SAVE wrote a second copy to + -- the flat path the launcher no longer looks at). Reading through the same + -- slot resolution keeps the in-game load/save and the launcher on one file. + local ok, SaveData = pcall(require, "src.core.SaveData") + local slot = ok and SaveData.activeSlot and SaveData.activeSlot(version) or nil + if slot then + local main = "saves/" .. version .. "/" .. slot .. ".lua" + return main, main .. ".bak", main .. ".tmp" + end + local main = "save" .. GameVersion.saveSuffix(version) .. ".lua" + return main, main .. ".bak", main .. ".tmp" +end + +Save.filenames = saveNames + +local function fs() + return love.filesystem +end + +-- A fresh Gold save. `opts` carries what the intro collected: player name, +-- rival name, and the options the OPTION screen was left on. +function Save.newGame(opts) + opts = opts or {} + local save = { + format = Save.FORMAT, + version = "gold", + generation = 2, + player = { + name = opts.playerName or "GOLD", + -- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd + -- (engine/menus/intro_menu.asm:41-49). + id = opts.trainerId or rand(0, 65535), + gender = opts.gender or "male", + money = 3000, + coins = 0, + badges = {}, + kantoBadges = {}, + }, + -- NewGame seeds wRivalName with "???", not with SILVER: _ResetWRAM calls + -- InitializeNPCNames (engine/menus/intro_menu.asm:131, :193-214), whose + -- .Rival row is literally `db "???@"`. SILVER is NameRival's InitName + -- FALLBACK (engine/events/specials.asm:80-91), copied in only after the + -- naming screen has closed on a blank entry, so the rival is "???" for + -- every {RIVAL} line and every RIVAL1 battle before the officer scene -- + -- the Cherrygrove fight included. + rival = { name = opts.rivalName or "???" }, + -- wMomSavingMoney's two bits BankOfMom actually flips (MOM_ACTIVE_F, + -- MOM_SAVING_SOME_MONEY_F -- src/script/gen2/Specials.lua H.BankOfMom): + -- `active` is "the bank conversation has happened at least once", which + -- gates whether a later visit opens on InitializeBank or on + -- IsThisAboutYourMoney; `savingMoney` is only meaningful once active. + -- + -- `whichItem` and `triggerBalance` are wWhichMomItem and + -- wMomItemTriggerBalance, both written by NewGame itself + -- (engine/menus/intro_menu.asm): the MomItems_2 ladder starts on its + -- first rung and the consolation threshold starts at MOM_MONEY. + mom = { name = opts.momName or "MOM", active = false, savingMoney = false, + savedMoney = 0, whichItem = 0, + triggerBalance = MomShopping.MOM_MONEY }, + -- Where the world resumes. nil means "use SPAWN_HOME". + position = nil, + -- Last Pokecenter, for a whiteout warp. + spawn = "SPAWN_HOME", + -- wPlayerState. A New Game starts on foot; the BICYCLE and SURF are what + -- write it, and it rides the save because the sprite, the step duration + -- and the tiles a step may land on all follow from it + -- (World:loadPlayerData). + playerState = Save.PLAYER_NORMAL, + -- sHallOfFame + wHallOfFameCount: `count` is how many times the champion + -- has been beaten (capped at HOF_MASTER_COUNT) and `teams` is the roster, + -- newest first, NUM_HOF_TEAMS deep. src/core/gen2/HallOfFame.lua owns + -- every read and write of it. + hallOfFame = { count = 0, teams = {} }, + -- wSpawnAfterChampion, a one-shot: set by the induction, consumed by the + -- next CONTINUE (HallOfFame.consumePostGameSpawn). nil means "resume + -- where the save says", which is every ordinary load. + spawnAfterChampion = nil, + party = {}, + boxes = {}, + currentBox = 1, + boxNames = {}, + inventory = {}, + -- Gen 2 splits the bag into four pockets (ITEM / KEY_ITEM / BALL / TM_HM); + -- `inventory` stays the flat id->count map Gen 1's Bag uses, and PackMenu + -- buckets it by each item's extracted `pocket`. + -- + -- wWhichRegisteredItem/wRegisteredItem (engine/overworld/select_menu.asm): + -- the item the SELECT button dispatches, set from the PACK + -- (World:registerItem) and re-validated against the live inventory on + -- every SELECT press (World:registeredItemId). nil means nothing is + -- registered, the same as the cart's byte being 0. + registeredItem = nil, + -- MAIL, both SRAM regions (src/core/gen2/Mail.lua): `party` is sPartyMail + -- keyed by party slot and `box` is sMailboxes, with sMailboxCount implied + -- by its length. + mail = { party = {}, box = {} }, + pcItems = {}, + phoneContacts = {}, + tradeFlags = {}, + pokedex = { seen = {}, caught = {} }, + -- wUnownDex: the distinct Unown FORMS caught, in catching order. A second + -- record beside the #DEX because the #DEX knows only the species + -- (src/core/gen2/Unown.lua). + unownDex = {}, + -- wFirstUnownSeen (ram/wram.asm:2703): the form letter of the FIRST Unown + -- the player ever met, latched once (engine/battle/core.asm:7894-7902) and + -- read back by the #DEX entry. 0 means "none yet", the cart's zero byte. + firstUnownSeen = 0, + events = {}, + flags = {}, + mapScenes = {}, + -- The script VM's sparse WRAM store (src/script/gen2/Vm.lua `mem`): + -- address -> byte, for the addresses Script_readmem / Script_writemem + -- (engine/overworld/scripting.asm) poke that the port has nowhere else to + -- keep -- wUndergroundSwitchPositions in the Goldenrod underground and + -- wMooMooBerries at the Route 39 barn. Sparse on purpose: the cart's WRAM + -- is 8K and a save has no business carrying a dense image of it, only the + -- handful of bytes a script actually wrote. + scriptMem = {}, + playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 }, + -- RTC bookkeeping: which real day the save last saw, so daily events can + -- roll over (engine/rtc/rtc.asm StageRTCTimeForSave). + rtc = { day = tonumber(os.date("%j")) or 1, hour = tonumber(os.date("%H")) or 0, + minute = tonumber(os.date("%M")) or 0 }, + options = nil, -- lives in options.lua; see SaveData.saveOptions + createdAt = os.time(), + } + -- Same hook, same name, same contract as Gen 1's SaveData.newGame: a total + -- conversion reshapes the skeleton (spawn, party, money) before anything + -- reads it. Unhooked this returns save unchanged, and it is the SAME table + -- so a caller holding the literal is never left behind. + return Runtime.call("save.new_game", function(s) return s end, save) +end + +-- Gen 2's OPTION screen (engine/menus/options_menu.asm StringOptions). +-- Values are stored as names so a save stays readable and a changed enum +-- ordering cannot silently repoint an option. +Save.DEFAULT_OPTIONS = { + textSpeed = "MID", -- FAST / MID / SLOW + battleScene = true, -- animations on + battleStyle = "SHIFT", -- SHIFT / SET + sound = "MONO", -- MONO / STEREO + print = "NORMAL", -- LIGHTEST..DARKEST + menuAccount = true, -- show the start menu's description box + frame = 1, -- textbox frame 1-8 + -- Port options, not the cart's. These are the same keys the Gen 1 save + -- uses (src/core/SaveData.lua) and they drive the same shared modules, so + -- a player's display and speed choices mean the same thing in both games. + speed = 1, -- GameSpeed.LEVELS multiplier, logic only + zoom = 0, -- Zoom offset from the window's fit scale + tilt = 0, -- Tilt.LEVELS degrees, 0 = off + gbcfx = 0, -- GBCFX ladder, 0 = off + -- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the + -- default -- this is a Game Boy Color game, so colour is ON out of the box + -- and the other two rungs are the deliberate step DOWN to a grey or green + -- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which + -- means something different, hence the different name. + color = "gbc", + musicVol = 7, -- 0-7, like the GB's NR50 master volume + sfxVol = 7, -- 0-7 + musicFilter = 0, -- low-pass steps, 0 = off +} + +function Save.defaultOptions() + local out = {} + for key, value in pairs(Save.DEFAULT_OPTIONS) do out[key] = value end + return out +end + +-- ------- options.lua +-- +-- Gold's options live in the shared options.lua under their own `gold` key, +-- not on the flat path the Gen 1 keys use. Several names collide across the +-- two generations with DIFFERENT types -- battleStyle is "shift" in Gen 1 and +-- "SHIFT" here, textSpeed a frame delay there and a label here -- so sharing +-- the flat namespace would have each game quietly corrupting the other's +-- settings. The file itself is shared, which is what lets the launcher's +-- gear edit these before the game starts (src/import/LauncherSettings.lua). +Save.OPTIONS_KEY = "gold" + +function Save.loadOptions(fs) + local options = Save.defaultOptions() + local ok, SaveData = pcall(require, "src.core.SaveData") + if not ok then return options end + local loaded = SaveData.loadOptions(fs) + local stored = loaded and loaded[Save.OPTIONS_KEY] + if type(stored) == "table" then + for key, value in pairs(stored) do options[key] = value end + end + return options +end + +-- Read-modify-write, so writing Gold's block never drops the Gen 1 keys (or +-- the slot registry, or modOptions) sitting beside it. +function Save.saveOptions(options, fs) + if type(options) ~= "table" then return false end + local ok, SaveData = pcall(require, "src.core.SaveData") + if not ok then return false end + local file = SaveData.loadOptions(fs) or {} + local block = {} + for key, value in pairs(options) do block[key] = value end + file[Save.OPTIONS_KEY] = block + SaveData.saveOptions(file, fs) + return true +end + +-- MON_PKRS is one byte in the party and box structs, and every reader of it +-- (src/core/gen2/Pokerus.lua) splits it into two nybbles -- so a file that +-- somehow grew a float, a negative or a value past 255 there would hand out a +-- strain and a day count that no cartridge could produce. Folded rather than +-- dropped, the way the money and coin caps above are clamped rather than reset. +local function normalizePokerus(mons) + for _, mon in ipairs(mons or {}) do + if type(mon) == "table" and mon.pokerus ~= nil then + local value = tonumber(mon.pokerus) or 0 + if value < 0 then value = 0 end + mon.pokerus = math.floor(value) % 256 + end + end +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) + if type(save) ~= "table" then return nil end + save.format = save.format or Save.FORMAT + save.version = "gold" + save.generation = 2 + save.player = save.player or {} + save.player.name = save.player.name or "GOLD" + 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)) + save.player.badges = save.player.badges or {} + save.player.kantoBadges = save.player.kantoBadges or {} + -- Same InitializeNPCNames seed as newGame: a save carrying no rival field is + -- a save that has not reached the officer, so it reads "???" rather than + -- NameRival's post-screen default. + save.rival = save.rival or { name = "???" } + save.mom = save.mom or {} + save.mom.name = save.mom.name or "MOM" + -- An older save (or one normalized before H.BankOfMom existed) has a `mom` + -- table with no `active`/`savingMoney` at all; both default to unset the + -- same way a cartridge that has never run BankOfMom reads wMomSavingMoney + -- as zero -- the bank has never been talked to and nothing is being saved. + if save.mom.active == nil then save.mom.active = false end + if save.mom.savingMoney == nil then save.mom.savingMoney = false end + save.mom.savedMoney = math.max(0, math.min( + tonumber(save.mom.savedMoney) or 0, Save.MAX_MONEY)) + -- wWhichMomItem indexes MomItems_2 and wMomItemTriggerBalance is a money + -- field, so both are folded the way every other counter here is: an index + -- past the end of the ladder is what CheckBalance_MomItem2's own `cp + -- (MomItems_2.End - MomItems_2) / MOMITEM_SIZE` treats as "no rung left", + -- which is a legal resting state and not a value to clamp away. + save.mom.whichItem = math.max(0, math.floor(tonumber(save.mom.whichItem) or 0)) + save.mom.triggerBalance = math.max(0, math.min( + math.floor(tonumber(save.mom.triggerBalance) or MomShopping.MOM_MONEY), + Save.MAX_MONEY + MomShopping.MOM_MONEY)) + save.party = save.party or {} + save.boxes = save.boxes or {} + save.boxNames = save.boxNames or {} + save.currentBox = save.currentBox or 1 + save.inventory = save.inventory or {} + -- Mail.state creates both SRAM regions on demand, so an older save (or one a + -- driver built by hand) can be indexed freely from the first letter on. + Mail.state(save) + save.pcItems = save.pcItems or {} + save.phoneContacts = save.phoneContacts or {} + -- wTradeFlags: one bit per NPC_TRADE_*, so a trade only ever happens once. + -- A set here, keyed by the trade's own id (src/core/gen2/NpcTrade.lua). + save.tradeFlags = save.tradeFlags or {} + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + -- wUnownDex is NUM_UNOWN bytes; a file that somehow grew past that is + -- trimmed for the same reason an over-long party is. + save.unownDex = save.unownDex or {} + while #save.unownDex > 26 do table.remove(save.unownDex) end + -- wFirstUnownSeen is one byte holding a letter index 1..NUM_UNOWN, or 0 + -- before any Unown has been met; anything else is a file that was edited. + local firstUnown = tonumber(save.firstUnownSeen) or 0 + firstUnown = math.floor(firstUnown) + if firstUnown < 0 or firstUnown > 26 then firstUnown = 0 end + save.firstUnownSeen = firstUnown + -- wEventFlags, as the SERIALIZED BITFIELD src/world/gen2/Events.lua writes: + -- byte index -> byte value, sparse, keyed by NUMBER and not by name. Empty + -- means the file predates InitializeEventsScript ever running, which is what + -- World:loadPlayerData falls back to the seed on. + save.events = save.events or {} + save.flags = save.flags or {} + -- The w<Map>SceneID block (ram/wram.asm), as map id -> scene id. A map with + -- no entry is on scene 0, the same as the cart's zero-filled byte. + save.mapScenes = save.mapScenes or {} + -- wPlayerState. A file that predates the field reads as PLAYER_NORMAL, the + -- same as the cart's zero byte; Save.validate is what rejects a name no + -- cartridge could have produced. + save.playerState = save.playerState or Save.PLAYER_NORMAL + save.scriptMem = save.scriptMem or {} + save.playTime = save.playTime + or { hours = 0, minutes = 0, seconds = 0, frames = 0 } + save.rtc = save.rtc or {} + -- 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. + local hof = HallOfFame.record(save) + while #hof.teams > HallOfFame.NUM_TEAMS do + table.remove(hof.teams) + end + -- Trim an over-long party rather than letting a corrupt file feed a + -- seventh mon into battle. + while #save.party > Save.PARTY_SIZE do + table.remove(save.party) + end + normalizePokerus(save.party) + for _, box in pairs(save.boxes) do + if type(box) == "table" then normalizePokerus(box) end + end + -- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves + -- written before the stamp existed get it here. + local Mon = require("src.battle.gen2.Mon") + for _, mon in ipairs(save.party) do Mon.stampOT(save, mon) end + for _, box in pairs(save.boxes) do + if type(box) == "table" then + for _, mon in ipairs(box) do Mon.stampOT(save, mon) end + end + end + return save +end + +-- Migrations, oldest first. Each entry upgrades a save at `from` to `from+1`. +Save.MIGRATIONS = { + -- 1 -> 2: the endgame landed. A format-1 save was written before the Hall + -- of Fame existed, so it has no roster and cannot have been inducted; the + -- upgrade is the empty block, and wSpawnAfterChampion stays nil so the first + -- load after the upgrade is an ordinary CONTINUE rather than a warp to New + -- Bark Town. + [1] = function(save) + save.hallOfFame = save.hallOfFame or { count = 0, teams = {} } + save.spawnAfterChampion = nil + end, + -- 2 -> 3: the script VM's readmem / writemem bytes started riding the save. + -- Nothing to carry across (a format-2 file never wrote them down), so the + -- upgrade is the empty store and every address reads back 0. + [2] = function(save) + save.scriptMem = save.scriptMem or {} + end, + -- 3 -> 4: MAIL. A format-3 file predates sPartyMail and sMailboxes + -- entirely, so there is nothing to carry across and the upgrade is the empty + -- pair. A mon in one of those saves may still be HOLDING a mail item + -- (`givepokemail` used to hand one over with no struct behind it), and that + -- mon reads back as holding a blank letter rather than as holding nothing -- + -- which is what the cart's own zero-filled struct would say too. + [3] = function(save) + save.mail = save.mail or { party = {}, box = {} } + end, + -- 4 -> 5: the world state started being read back. A format-4 file already + -- carries both tables (the snapshot has always written them), so this is not + -- a conversion -- it is the point at which they stop being write-only, and a + -- file that never had them gets the empty pair. An empty `events` is the + -- honest answer for a save whose world never ran: World:loadPlayerData reads + -- it as "InitializeEventsScript has not happened yet" and applies the seed, + -- which is the same branch PlayersHouse2FInitializeRoomCallback takes. + [4] = function(save) + save.events = save.events or {} + save.mapScenes = save.mapScenes or {} + end, + -- 5 -> 6: wPlayerState. Unlike the pair above, this field was never written + -- by anything, so there is genuinely nothing to carry across and every + -- format-5 file upgrades to PLAYER_NORMAL -- which is exactly what those + -- saves already came back as, because a world that read no state started on + -- foot. The `or` is for a file some other tool put a state in. + [5] = function(save) + save.playerState = save.playerState or Save.PLAYER_NORMAL + end, + -- 6 -> 7: Mom's shopping pair. Nothing could have written either byte + -- before MomTriesToBuySomething existed, so the upgrade is NewGame's own + -- seed and a file that somehow carries one keeps it. + [6] = function(save) + save.mom = save.mom or {} + if save.mom.whichItem == nil then save.mom.whichItem = 0 end + if save.mom.triggerBalance == nil then + save.mom.triggerBalance = MomShopping.MOM_MONEY + end + end, +} + +function Save.migrate(save) + local format = tonumber(save.format) or 1 + while format < Save.FORMAT do + local step = Save.MIGRATIONS[format] + if not step then break end + step(save) + format = format + 1 + save.format = format + end + return save +end + +-- ------- validation and quarantine +-- +-- Same discipline as src/core/SaveData.lua's validate: a value play would +-- nil-index or wrap on never reaches the game, and whatever had to be dropped +-- is reported rather than vanishing silently. scriptMem is the field that +-- needs it most, because its keys are raw WRAM addresses rather than ids out +-- of a table this port owns, so nothing else can vouch for them. + +-- Script_readmem / Script_writemem (engine/overworld/scripting.asm) read a +-- two-byte address and move a single byte through wScriptVar, so an entry +-- outside 0..$ffff / 0..255 cannot have come from a script running here. +-- Keys survive the serializer as strings in some files, hence the tonumber. +local function scrubScriptMem(save, report) + local mem = save.scriptMem + if type(mem) ~= "table" then + if mem ~= nil then + report.lostScriptMem[#report.lostScriptMem + 1] = + { addr = nil, value = mem } + end + save.scriptMem = {} + return + end + local clean = {} + for key, value in pairs(mem) do + local addr, byte = tonumber(key), tonumber(value) + local okAddr = addr and addr == math.floor(addr) + and addr >= 0 and addr <= 0xFFFF + local okByte = byte and byte == math.floor(byte) + and byte >= 0 and byte <= 255 + if okAddr and okByte then + clean[addr] = byte + else + report.lostScriptMem[#report.lostScriptMem + 1] = + { addr = key, value = value } + end + end + save.scriptMem = clean +end + +-- wEventFlags is 256 bytes of bitfield and nothing else in the save vouches +-- for a byte index, so it gets the same treatment scriptMem does: an index +-- past the last byte or a value that is not a byte could not have come from +-- the cart's array, and handing one to Events:restore would put a flag id no +-- object can ever name into the live bitfield. Keys survive the serializer as +-- strings in some files, hence the tonumber. +local function scrubEvents(save, report) + local flags = save.events + if type(flags) ~= "table" then + if flags ~= nil then + report.lostEvents[#report.lostEvents + 1] = { byte = nil, value = flags } + end + save.events = {} + return + end + local clean = {} + for key, value in pairs(flags) do + local index, byte = tonumber(key), tonumber(value) + local okIndex = index and index == math.floor(index) + and index >= 0 and index < Save.EVENT_BYTES + local okByte = byte and byte == math.floor(byte) + and byte >= 0 and byte <= 255 + if okIndex and okByte then + clean[index] = byte + else + report.lostEvents[#report.lostEvents + 1] = { byte = key, value = value } + end + end + save.events = clean +end + +-- The w<Map>SceneID block: one BYTE per map, keyed here by the map id the +-- cache uses rather than by the WRAM address, because that is what +-- World:mapSceneOf looks up. An id this cache does not know is left alone -- +-- nothing ever reads it, the same way an unused scene byte sits in WRAM -- but +-- a key that is not a map id at all, or a scene that is not a byte, is dropped +-- rather than handed to a scene-script lookup that would index past its arms. +local function scrubMapScenes(save, report) + local scenes = save.mapScenes + if type(scenes) ~= "table" then + if scenes ~= nil then + report.lostMapScenes[#report.lostMapScenes + 1] = + { map = nil, scene = scenes } + end + save.mapScenes = {} + return + end + local clean = {} + for key, value in pairs(scenes) do + local scene = tonumber(value) + local okMap = type(key) == "string" and key ~= "" + local okScene = scene and scene == math.floor(scene) + and scene >= 0 and scene <= 255 + if okMap and okScene then + clean[key] = scene + else + report.lostMapScenes[#report.lostMapScenes + 1] = + { map = key, scene = value } + end + end + save.mapScenes = clean +end + +-- wPlayerState is one byte on the cart and one of four names here, so anything +-- else -- a raw byte out of a hand-edited file, a state this port has never +-- had -- is dropped back to PLAYER_NORMAL. Left alone it would give the +-- player a sprite lookup with no row of its own and step rules that belong to +-- nobody: neither isBiking nor isSurfing would answer true, so they would walk +-- at walking pace over land while the save insisted they were somewhere else. +local function scrubPlayerState(save, report) + local state = save.playerState + if Save.PLAYER_STATES[state] then return end + if state ~= nil then + report.lostPlayerState[#report.lostPlayerState + 1] = { state = state } + end + save.playerState = Save.PLAYER_NORMAL +end + +function Save.validate(save) + local report = { lostScriptMem = {}, lostMail = {}, lostEvents = {}, + lostMapScenes = {}, lostPlayerState = {} } + if type(save) ~= "table" then return report end + scrubScriptMem(save, report) + -- The world state World:loadPlayerData hands back to the live game: the + -- event bitfield, the per-map scene ids and wPlayerState. All three are + -- read on every load, so all three have to be trustworthy before the first + -- map comes up. + scrubEvents(save, report) + scrubMapScenes(save, report) + scrubPlayerState(save, report) + -- The `mailmsg` structs get the same treatment for the same reason: their + -- `type` byte is an item id nothing else in the save vouches for, and a + -- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the + -- cart could not have written. Mail.validate owns the rules; this is only + -- where the ledger is collected (src/core/gen2/Mail.lua). + Mail.validate(save, report) + return report +end + +-- True when nothing was quarantined, so a vanilla save loads without a word. +function Save.emptyReport(report) + if type(report) ~= "table" then return true end + return #(report.lostScriptMem or {}) == 0 + and #(report.lostMail or {}) == 0 + and #(report.lostEvents or {}) == 0 + and #(report.lostMapScenes or {}) == 0 + and #(report.lostPlayerState or {}) == 0 +end + +-- Does a Gold save exist? This is what decides whether the intro menu offers +-- CONTINUE (engine/menus/main_menu.asm MainMenu_GetWhichMenu reads +-- wSaveFileExists for exactly this). +function Save.exists(version) + local main, backup = saveNames(version) + local f = fs() + if not f then return false end + return (f.getInfo(main) ~= nil) or (f.getInfo(backup) ~= nil) +end + +local function readTable(path) + local f = fs() + if not f or not f.getInfo(path) then return nil, "missing" end + local raw = f.read(path) + if not raw then return nil, "unreadable" end + local ok, value = pcall(SaveSerializer.decode, raw) + if not ok or type(value) ~= "table" then + return nil, "corrupt: " .. tostring(value) + end + return value +end + +-- Returns save, recovered ("bak"/"tmp" when a staged or backup copy had to be +-- promoted), err, report (the quarantine ledger from Save.validate; empty for +-- any save this port wrote itself). +function Save.load(version) + local main, backup, tmp = saveNames(version) + local data, err = readTable(main) + local recovered + if not data then + local staged = readTable(tmp) + if staged then + data, recovered = staged, "tmp" + else + local prev = readTable(backup) + if prev then data, recovered = prev, "bak" end + end + end + if not data then return nil, nil, err end + Save.migrate(data) + Save.normalize(data) + local report = Save.validate(data) + if not Save.emptyReport(report) then + -- Gold has no report screen of its own yet; the log is what keeps a + -- quarantine from being invisible, the way Game.lua falls back for Gen 1. + Logger.warn( + "gold load report: %d script memory byte(s), %d MAIL struct(s), " .. + "%d event byte(s), %d map scene(s) and %d player state(s) dropped", + #report.lostScriptMem, #report.lostMail, #report.lostEvents, + #report.lostMapScenes, #report.lostPlayerState) + end + return data, recovered, nil, report +end + +-- Atomic-ish write, matching SaveData.save: back the old file up, stage a +-- .tmp, replace, drop the .tmp. love.filesystem has no rename, so the .tmp +-- copy is the witness that survives a crash mid-replace. +function Save.save(save) + if type(save) ~= "table" then return false, "no save" end + local main, backup, tmp = saveNames(save.version) + local f = fs() + if not f then return false, "no filesystem" end + -- saveNames may now return a saves/<version>/<slot>.lua path, and + -- love.filesystem.write does not create missing parent directories. + local dir = main:match("^(.*)/[^/]+$") + if dir and f.createDirectory then f.createDirectory(dir) end + Save.normalize(save) + save.savedAt = os.time() + local encoded = SaveSerializer.encode(save) + if f.getInfo(main) then + local prev = f.read(main) + if prev then f.write(backup, prev) end + end + local ok, err = f.write(tmp, encoded) + if not ok then + Logger.error("gold save failed: %s", tostring(err)) + return false, err + end + f.remove(main) + ok, err = f.write(main, encoded) + if not ok then + Logger.error("gold save failed: %s", tostring(err)) + return false, err + end + f.remove(tmp) + Logger.info("saved gold game") + return true +end + +-- The three lines Gold's CONTINUE panel shows before you confirm +-- (DisplaySaveInfoOnContinue): who, how many badges, how much of the dex, and +-- how long. Returned as data so the screen can lay it out. +function Save.summary(save) + if type(save) ~= "table" then return nil end + local badges = 0 + for _, has in pairs(save.player and save.player.badges or {}) do + if has then badges = badges + 1 end + end + local caught = 0 + for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do + if has then caught = caught + 1 end + end + local time = save.playTime or {} + return { + name = save.player and save.player.name or "?", + badges = badges, + caught = caught, + hours = time.hours or 0, + minutes = time.minutes or 0, + map = save.position and save.position.map or save.spawn, + } +end + +-- Advance the play clock one logic tick. Called from the fixed step, so 60 +-- calls is one second, the same rate wGameTimeFrames counts at. +function Save.tickPlayTime(save) + local t = save and save.playTime + if not t then return end + t.frames = (t.frames or 0) + 1 + if t.frames < 60 then return end + t.frames = 0 + t.seconds = (t.seconds or 0) + 1 + if t.seconds < 60 then return end + t.seconds = 0 + t.minutes = (t.minutes or 0) + 1 + if t.minutes < 60 then return end + t.minutes = 0 + -- The cart caps at 999:59 and stops counting; do the same rather than + -- letting the trainer card overflow its field. + t.hours = math.min((t.hours or 0) + 1, 999) +end + +return Save diff --git a/src/core/gen2/TradeAnim.lua b/src/core/gen2/TradeAnim.lua new file mode 100644 index 00000000..1c9ebe8b --- /dev/null +++ b/src/core/gen2/TradeAnim.lua @@ -0,0 +1,274 @@ +-- The in-game trade animation (engine/movie/trade_animation.asm +-- TradeAnimation), the cable-and-ball sequence NPCTrade runs between +-- DoNPCTrade and TradedForText. +-- +-- love-free: this file is the script and its clock, src/ui/gen2/TradeAnim.lua +-- is the half that draws. Nothing about the trade's outcome depends on any of +-- it -- DoNPCTrade has already swapped the two mons by the time the first +-- frame runs -- which is why the screen can be skipped without a branch. +-- +-- The cart drives this from a byte script (`tradeanim` rows into +-- DoTradeAnimation.Jumptable), one command per frame, and each command either +-- runs a piece of setup and advances the pointer or sits on wFrameCounter +-- until it drains. The commands that only set something up (a palette, a +-- window position, a sprite struct) cost no frames, so the whole script +-- flattens to the list of WAITS below, with the setup a command did folded +-- into the `cue` of the beat that follows it. Two examples, since the folding +-- is the one place this stops being a transcription: +-- +-- * TradeAnim_Poof sets wFrameCounter to 16 and advances immediately, so the +-- poof is still on screen while TradeAnim_EnterLinkTube2 slides the cable +-- in over its first 40 frames. It gets no beat of its own; `tube_in` +-- carries the "poof" cue and the drawing side gives the puff 16 frames. +-- * TradeAnim_RockingBall's 64 frames are only spent later, by the +-- TradeAnim_WaitAnim that follows EnterLinkTube2's own 40 + 80 -- the two +-- commands in between never touch wFrameCounter. That wait is `ball_rock`. +-- +-- The frame counts are the cart's own: `ld c, 80 / call DelayFrames`, +-- `ld a, 92 / ld [wFrameCounter], a`, and the scrolls' step per frame. +-- +-- Only the player-1 script is here. TradeAnimationPlayer2 is the same beats +-- in the other order and is reached from the cable club, which the port does +-- not have. + +local TradeAnim = {} + +-- Scroll steps, in pixels per frame. +-- +-- TradeAnim_DoGivemonScroll moves hWX and hSCX 4 a frame until the window is +-- home; TradeAnim_EnterLinkTube2 / TradeAnim_ExitLinkTube move hSCX 4 a frame +-- over the tube's own $a0; the two Game Boy pans move hSCX 2 a frame. +TradeAnim.SCROLL_STEP = 4 +TradeAnim.PAN_STEP = 2 + +-- hSCX starts at $88 for the frontpic scroll and hWX at $8f, i.e. both are +-- $88 from home. +TradeAnim.GIVEMON_SCROLL = 0x88 +-- The link tube enters and leaves across $a0. +TradeAnim.TUBE_SCROLL = 0xa0 + +-- The Game Boy pan is a full wrap of the 256-pixel BG map: hSCX runs +-- 0 -> $50 -> $a0 -> $100, redrawing the tilemap at each boundary in the part +-- of the map the window has already left, so the three states read as one +-- continuous scene 256 pixels long. TradeAnim_InitTubeAnim's own +-- `hlbgcoord 20, 3 / ld bc, 12 / ld a, $60 / ByteFill` is what keeps the cable +-- unbroken across the seam. +TradeAnim.PAN_TOTAL = 0x100 + +-- The script. `frames` is how long the beat holds, `cue` fires on its first +-- frame. +-- +-- The two Game Boy pans are one beat per hSCX target rather than one long one +-- because the cart really does stop at $50 and $a0 to swap the tilemap, and a +-- beat boundary is where the drawing side gets to notice. +TradeAnim.SCRIPT = { + -- ShowGivemonData, then TradeAnim_DoGivemonScroll's $88 at 4 a frame. + { id = "givemon_scroll", frames = 34, cue = "show_give" }, + { id = "givemon_hold", frames = 80 }, + -- Poof, RockingBall, EnterLinkTube1: the mon becomes a ball and the cable + -- slides in over it. + { id = "tube_in", frames = 40, cue = "poof" }, + -- EnterLinkTube2's `ld c, 80 / call DelayFrames` once hSCX is home. + { id = "tube_hold", frames = 80 }, + -- The WaitAnim spending RockingBall's 64. + { id = "ball_rock", frames = 64 }, + { id = "bulge", frames = 128, cue = "bulge" }, + -- GiveTrademonSFX, then TubeToOT2/3/4. + { id = "send_pan_a", frames = 40, cue = "give_sfx" }, + { id = "send_pan_b", frames = 40 }, + { id = "send_pan_c", frames = 48 }, + -- TubeToOT5 spends the 92 TubeToOT1 set, TubeToOT6/7 the 128 after it. + { id = "send_wait", frames = 92 }, + { id = "send_hold", frames = 128 }, + -- SentToOTText: the empty _MonNameSentToText holds an open box for 189 + -- frames before the line itself, which then gets 80 + 128. + { id = "sent_blank", frames = 189, cue = "clear" }, + { id = "sent_text", frames = 208 }, + -- OTSendsText1's two pages, the second carrying its trailing `ld c, 14`. + { id = "ot_sends_a", frames = 80 }, + { id = "ot_sends_b", frames = 94 }, + -- OTBidsFarewell's two. + { id = "farewell_a", frames = 80 }, + { id = "farewell_b", frames = 80 }, + -- GetTrademonSFX, then TubeToPlayer2 waits its 92 BEFORE the pan (the + -- mirror of the send, where the wait comes after). + { id = "get_wait", frames = 92, cue = "get_sfx" }, + { id = "get_pan_a", frames = 40 }, + { id = "get_pan_b", frames = 40 }, + { id = "get_pan_c", frames = 48 }, + { id = "get_hold", frames = 128 }, + -- EnterLinkTube again, then DropBall / ExitLinkTube. + { id = "tube_in2", frames = 40, cue = "tube" }, + { id = "tube_hold2", frames = 80 }, + { id = "tube_out", frames = 40, cue = "drop" }, + { id = "ball_wait", frames = 56 }, + -- ShowGetmonData, then Poof's 16. + { id = "getmon_poof", frames = 16, cue = "show_get" }, + -- FrontpicScrollStart brings the stats window back up for Wait80. + { id = "getmon_hold", frames = 80 }, + { id = "take_care", frames = 80 }, +} + +-- Which unrolled pan position a beat starts at, and which way it moves. The +-- send pans forward across the 256, the get pans back: TubeToPlayer3/4/5 +-- SUBTRACT 2 a frame, starting from the wrap. +local PAN = { + send_pan_a = { base = 0x00, step = TradeAnim.PAN_STEP }, + send_pan_b = { base = 0x50, step = TradeAnim.PAN_STEP }, + send_pan_c = { base = 0xa0, step = TradeAnim.PAN_STEP }, + send_wait = { base = 0x100, step = 0 }, + send_hold = { base = 0x100, step = 0 }, + get_wait = { base = 0x100, step = 0 }, + get_pan_a = { base = 0x100, step = -TradeAnim.PAN_STEP }, + get_pan_b = { base = 0xb0, step = -TradeAnim.PAN_STEP }, + get_pan_c = { base = 0x60, step = -TradeAnim.PAN_STEP }, + get_hold = { base = 0x00, step = 0 }, +} + +-- The beats that print a line, and the text label each one prints. The empty +-- _MonNameSentToText is not here: it draws an open box and nothing else, which +-- is what `sent_blank` having no entry means. +TradeAnim.TEXT = { + sent_text = "_MonWasSentToText", + ot_sends_a = "_ForYourMonSendsText", + ot_sends_b = "_OTSendsText", + farewell_a = "_BidsFarewellToMonText", + farewell_b = "_MonNameBidsFarewellText", + take_care = "_TakeGoodCareOfMonText", +} + +TradeAnim.TOTAL = 0 +for _, beat in ipairs(TradeAnim.SCRIPT) do + TradeAnim.TOTAL = TradeAnim.TOTAL + beat.frames +end + +-- The beat a frame index (0-based) lands in, and how far into it that is. +-- Past the end answers the last beat, so a caller that overruns by a frame +-- draws the final picture rather than nothing. +function TradeAnim.beatAt(frame) + frame = math.max(0, math.floor(tonumber(frame) or 0)) + local start = 0 + for index, beat in ipairs(TradeAnim.SCRIPT) do + if frame < start + beat.frames then + return beat, frame - start, index + end + start = start + beat.frames + end + local last = TradeAnim.SCRIPT[#TradeAnim.SCRIPT] + return last, last.frames, #TradeAnim.SCRIPT +end + +-- The frame index a beat starts on, for tests and for a caller that wants to +-- jump. +function TradeAnim.startOf(id) + local start = 0 + for _, beat in ipairs(TradeAnim.SCRIPT) do + if beat.id == id then return start end + start = start + beat.frames + end + return nil +end + +-- hSCX during the two scrolls that bring the give-mon panel home. Both the +-- background and the window are $88 out and close at 4 a frame. +function TradeAnim.givemonOffset(t) + return math.max(0, TradeAnim.GIVEMON_SCROLL - TradeAnim.SCROLL_STEP * t) +end + +-- hSCX for the link tube. Entering, it closes from $a0; leaving, it opens +-- back out to $a0. The tube's tilemap sits at hlcoord 8, 2, so the drawing +-- side subtracts this from that x -- SCX scrolls the BACKGROUND, and a +-- positive value moves the picture LEFT. +function TradeAnim.tubeOffset(id, t) + local step = TradeAnim.SCROLL_STEP * t + if id == "tube_out" then + return math.min(TradeAnim.TUBE_SCROLL, step) + end + return math.max(0, TradeAnim.TUBE_SCROLL - step) +end + +-- How far along the 256-pixel Game Boy scene the window is, unrolled: 0 is the +-- player's Game Boy at the left, 256 is the other one. nil for a beat that is +-- not part of a pan. +function TradeAnim.pan(id, t) + local row = PAN[id] + if not row then return nil end + local value = row.base + row.step * (tonumber(t) or 0) + if value < 0 then return 0 end + if value > TradeAnim.PAN_TOTAL then return TradeAnim.PAN_TOTAL end + return value +end + +-- The trademon object's two ends, in screen pixels: TubeToOT1's +-- `depixel 5, 11, 4, 0` and TubeToPlayer1's `depixel 9, 18, 4, 4`, OAM-adjusted. +local ICON_NEAR_X, ICON_NEAR_Y = 80, 28 +local ICON_FAR_X, ICON_FAR_Y = 140, 60 +-- .MoveRight's `cp $94` / .MoveLeft's `cp $58` and .MoveDown's `cp $4c` / +-- .MoveUp's `cp $2c`, one pixel a frame. +local ICON_RUN = ICON_FAR_X - ICON_NEAR_X +local ICON_DROP = ICON_FAR_Y - ICON_NEAR_Y + +-- .WaitTimer1 and .WaitTimer2 hold it still for their $80 apiece. +local ICON_PARKED = { + send_pan_a = true, send_pan_b = true, send_pan_c = true, + get_pan_a = true, get_pan_b = true, get_pan_c = true, +} + +-- Where TradeAnim_AnimateTrademonInTube has the icon and its bubble on a pan +-- beat, or nil once .done_move_down / .WaitTimer2 zero SPRITEANIMSTRUCT_INDEX. +function TradeAnim.tubeIcon(id, t) + t = math.max(0, math.floor(tonumber(t) or 0)) + if ICON_PARKED[id] then return ICON_NEAR_X, ICON_NEAR_Y end + if id == "send_wait" then + local run = math.min(ICON_RUN, t) + local drop = math.min(ICON_DROP, math.max(0, t - ICON_RUN)) + return ICON_NEAR_X + run, ICON_NEAR_Y + drop + end + if id == "get_wait" then + local drop = math.min(ICON_DROP, t) + local run = math.min(ICON_RUN, math.max(0, t - ICON_DROP)) + return ICON_FAR_X - run, ICON_FAR_Y - drop + end + return nil +end + +-- The two trademon records TradeAnimation reads, built the way DoNPCTrade +-- fills them: the PLAYER's is the mon that just left the party (its own DVs, +-- OT and ID, under the player's name as sender), the OT's is the row's mon +-- (the row's OT name doubling as the sender). Called with the two records +-- NpcTrade.perform answered, so the given mon is the one that walked in, not +-- whatever now sits in that party slot. +function TradeAnim.records(data, save, row, given, received) + local pokemon = (data and data.pokemon) or {} + local player = (save and save.player) or {} + local function speciesOf(id) + local def = id and pokemon[id] + return { + species = id, + name = (def and def.name) or id or "?", + dex = (def and def.dex) or 0, + } + end + local give = speciesOf(given and given.species) + local get = speciesOf(received and received.species + or (row and row.get)) + give.senderName = player.name or "GOLD" + give.otName = (given and (given.otName or given.ot)) or give.senderName + give.id = (given and given.otId) or player.id or 0 + give.shiny = given and given.shiny or false + -- The DVs ride along because TradeAnim_GetFrontpic runs `predef + -- GetUnownLetter` before GetBaseData (engine/movie/trade_animation.asm: + -- 795-804): without them a traded Unown draws as letter A. unownLetter is + -- carried too, since Unown.monLetter prefers the stored form. + give.dvs = given and given.dvs + give.unownLetter = given and given.unownLetter + get.senderName = (row and row.otName) or (received and received.otName) or "?" + get.otName = get.senderName + get.id = (received and received.otId) or (row and row.otId) or 0 + get.shiny = received and received.shiny or false + get.dvs = received and received.dvs + get.unownLetter = received and received.unownLetter + return give, get +end + +return TradeAnim diff --git a/src/core/gen2/Unown.lua b/src/core/gen2/Unown.lua new file mode 100644 index 00000000..dc55e710 --- /dev/null +++ b/src/core/gen2/Unown.lua @@ -0,0 +1,328 @@ +-- Unown: the letter a set of DVs spells, which letters the Ruins of Alph have +-- unlocked, and the #DEX's own catching-order list of them. +-- +-- Sources, all of them small and all of them in different files on the cart: +-- +-- GetUnownLetter engine/gfx/load_pics.asm -- the DVs -> 1..26 map +-- CheckUnownLetter engine/battle/core.asm -- is that form unlocked +-- UnlockedUnownLetterSets data/wild/unlocked_unowns.asm -- the four sets +-- UpdateUnownDex engine/pokedex/unown_dex.asm -- wUnownDex +-- PrintUnownWord engine/pokedex/unown_dex.asm -- the word per form +-- CountUnown engine/events/specials.asm -- how many so far +-- +-- They live together here because every one of them is about the FORM rather +-- than the species, and the port's save keys its #DEX by species (a single +-- UNOWN flag). The form list is a second, parallel record: `save.unownDex`, +-- a list of letter numbers in the order they were first caught, exactly the +-- shape of wUnownDex. +-- +-- Letters are NUMBERS here, 1 = A .. 26 = Z, because that is what the cart +-- stores and what UnownWords / UnownPicPointers index by. `Unown.name` is +-- the only place a number becomes a character. + +local Runtime = require("src.mods.Runtime") + +local Unown = {} + +-- constants/pokemon_constants.asm: NUM_UNOWN EQU 26. +Unown.NUM_UNOWN = 26 +Unown.SPECIES = "UNOWN" + +Unown.ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +-- 1 -> "A". Anything off the end answers nil rather than an empty string, so +-- a caller that got its number from bad data notices. +function Unown.name(letter) + if type(letter) ~= "number" then return nil end + if letter < 1 or letter > Unown.NUM_UNOWN then return nil end + return Unown.ALPHABET:sub(letter, letter) +end + +-- "A" -> 1, and a number passes straight through. Handy for tests and for the +-- pic table, whose keys in pokemon.lua are the letters themselves. +function Unown.index(letter) + if type(letter) == "number" then return letter end + if type(letter) ~= "string" or #letter ~= 1 then return nil end + local at = Unown.ALPHABET:find(letter:upper(), 1, true) + return at +end + +-- ------------------------------------------------------------ the letter +-- +-- GetUnownLetter takes the MIDDLE two bits of each of the four DVs and packs +-- them in the order atk, def, spd, spc: +-- +-- ; atk def spd spc +-- ; .ww..xx. .yy..zz. +-- +-- The asm reads the two packed DV bytes, so its masks are $60 / $06 on each -- +-- bits 1 and 2 of every nibble. With the DVs already unpacked into fields the +-- same two bits are `(dv >> 1) % 4`, which is what the shifts add up to. +-- +-- Then `ld a, $ff / NUM_UNOWN + 1` is 10, Divide gives 0..25, and `inc a` +-- makes it 1..26. The divisor is integer-truncated on the cart ($ff / 26 is +-- 9), so it is 10 and not 255/26: a value of 250..255 still lands on Z. +local function middleBits(dv) + return math.floor((dv or 0) / 2) % 4 +end + +-- No DVs means the caller has a mon this module cannot read, which is a bug in +-- the caller rather than a letter A: Mon.new always rolls DVs (opts.dvs or +-- Mon.randomDVs, src/battle/gen2/Mon.lua:198) and a catch appends that same +-- record by reference, so every real party or box Unown has them. Answering 1 +-- here is how a missing plumbing hop reads as a legitimate Unown A. +function Unown.letterFromDVs(dvs) + if not dvs then return nil end + local packed = middleBits(dvs.attack) * 64 + + middleBits(dvs.defense) * 16 + + middleBits(dvs.speed) * 4 + + middleBits(dvs.special) + return math.floor(packed / 10) + 1 +end + +-- ------------------------------------------------------- the unlocked sets +-- +-- data/wild/unlocked_unowns.asm. Each solved puzzle sets one ENGINE_* flag and +-- that flag unlocks one contiguous run of letters; the runs are uneven because +-- they were cut to the four chamber puzzles, not to equal thirds. +-- +-- The ids are constants/engine_flags.asm indices, counted the same way +-- src/core/gen2/Apricorns.lua counts its daily flags (const_def, 0-based, with +-- const_skip advancing). ENGINE_UNLOCKED_UNOWNS_A_TO_K is 42. +-- ENGINE_UNOWN_DEX, the flag RuinsOfAlphResearchCenterGetUnownDexScript sets. +-- It is what gates the #DEX's UNOWN MODE (Pokedex_CheckUnlockedUnownMode reads +-- it as wStatusFlags bit STATUSFLAGS_UNOWN_DEX_F), and it is a different thing +-- from having caught an Unown. +Unown.ENGINE_UNOWN_DEX = 12 + +Unown.UNLOCK_SETS = { + { flag = 42, name = "ENGINE_UNLOCKED_UNOWNS_A_TO_K", first = 1, last = 11 }, + { flag = 43, name = "ENGINE_UNLOCKED_UNOWNS_L_TO_R", first = 12, last = 18 }, + { flag = 44, name = "ENGINE_UNLOCKED_UNOWNS_S_TO_W", first = 19, last = 23 }, + { flag = 45, name = "ENGINE_UNLOCKED_UNOWNS_X_TO_Z", first = 24, last = 26 }, +} + +-- The four puzzles, in UNOWNPUZZLE_* order (constants/script_constants.asm), +-- with the flag each chamber's .PuzzleComplete arm sets. `setval +-- UNOWNPUZZLE_KABUTO / special UnownPuzzle` is how the screen is told which +-- picture to slice, so the id the script passes indexes this list from 0. +Unown.PUZZLES = { + [0] = { id = "KABUTO", flag = 42, event = "EVENT_SOLVED_KABUTO_PUZZLE" }, + [1] = { id = "OMANYTE", flag = 43, event = "EVENT_SOLVED_OMANYTE_PUZZLE" }, + [2] = { id = "AERODACTYL", flag = 44, + event = "EVENT_SOLVED_AERODACTYL_PUZZLE" }, + [3] = { id = "HO_OH", flag = 45, event = "EVENT_SOLVED_HO_OH_PUZZLE" }, +} + +-- CheckUnownLetter: walk the four sets, skip a set whose bit is clear, and +-- answer true as soon as the letter turns up in one that is set. Returns +-- carry on the cart, which is "NOT unlocked", so the sense is flipped here to +-- read the way the call sites want it. +function Unown.letterUnlocked(letter, engineFlags) + local index = Unown.index(letter) + if not index then return false end + for _, set in ipairs(Unown.UNLOCK_SETS) do + if engineFlags and engineFlags[set.flag] then + if index >= set.first and index <= set.last then return true end + end + end + return false +end + +-- ChooseWildEncounter's `ld a, [wUnlockedUnowns] / and a / jr z, +-- .nowildbattle`: with no puzzle solved at all an Unown slot is not an +-- encounter, it is no encounter. The whole byte is tested, so the four unused +-- bits would count too; nothing ever sets them. +function Unown.anyUnlocked(engineFlags) + if not engineFlags then return false end + for _, set in ipairs(Unown.UNLOCK_SETS) do + if engineFlags[set.flag] then return true end + end + return false +end + +-- Every letter currently reachable, in order. The puzzle screen has nothing +-- to say about this; it is here because the researcher's dialogue and the +-- encounter roll both want the same list. +function Unown.unlockedLetters(engineFlags) + local out = {} + for _, set in ipairs(Unown.UNLOCK_SETS) do + if engineFlags and engineFlags[set.flag] then + for letter = set.first, set.last do out[#out + 1] = letter end + end + end + table.sort(out) + return out +end + +-- LoadEnemyMon's .GenerateDVs loop: roll DVs, take the letter, and roll again +-- while the letter is locked. The cart's loop is unbounded, and the comment +-- above it says so ("If combined with forced shiny battletype, causes an +-- infinite loop") -- here the retries are capped and the fallback picks an +-- unlocked letter directly, so a caller that hands in a degenerate RNG gets a +-- legal mon instead of a hang. +-- +-- `randomDVs` is src/battle/gen2/Mon.randomDVs, passed in rather than required +-- so this module stays free of the party builder. +local DV_RETRIES = 256 + +function Unown.wildDVs(engineFlags, randomDVs) + local dvs = randomDVs() + if not Unown.anyUnlocked(engineFlags) then return dvs end + local tries = 0 + while not Unown.letterUnlocked(Unown.letterFromDVs(dvs), engineFlags) do + tries = tries + 1 + if tries >= DV_RETRIES then + return Unown.dvsForLetter(Unown.unlockedLetters(engineFlags)[1] or 1) + end + dvs = randomDVs() + end + return dvs +end + +-- The inverse of GetUnownLetter, for the fallback above and for a test that +-- wants a mon of a named form. Letter n covers packed values 10*(n-1) .. +-- 10*(n-1)+9, so the lowest one in the band is the tidy representative; the +-- two middle bits of each DV are set from it and the outer bits left at zero. +function Unown.dvsForLetter(letter) + local index = Unown.index(letter) or 1 + local packed = (index - 1) * 10 + local function dv(shift) + return (math.floor(packed / shift) % 4) * 2 + end + return { + attack = dv(64), + defense = dv(16), + speed = dv(4), + special = dv(1), + } +end + +-- ------------------------------------------------------------- the #DEX +-- +-- wUnownDex is 26 bytes of letter numbers in the order they were first caught, +-- zero-terminated. UpdateUnownDex walks it: a letter already in the list +-- returns at once, and the first zero is where a new one lands. +function Unown.dex(save) + if not save then return {} end + save.unownDex = save.unownDex or {} + return save.unownDex +end + +function Unown.updateDex(save, letter) + local index = Unown.index(letter) + if not (save and index) then return false end + local list = Unown.dex(save) + for _, seen in ipairs(list) do + if seen == index then return false end + end + if #list >= Unown.NUM_UNOWN then return false end + list[#list + 1] = index + -- unown.unlocked, a Gen 2 invention: Gen 1 has one sprite per species and no + -- form list at all, so there is no name to share and pokemon.caught would be + -- the wrong one (this fires for a box deposit too, and not for the second + -- Unown of a letter already listed). UpdateUnownDex's early return IS the + -- gate: the event marks the moment a FORM becomes something the #DEX's UNOWN + -- MODE and the ALPH RUINS STAMP machine can show, which happens exactly once + -- per letter. + -- + -- letter 1..26, the same number wUnownDex stores (A is 1) + -- name "A".."Z", for a mod that would rather print than index + -- word data/pokemon/unown_words.asm's word for the form + -- count how many forms are listed now, which is also VAR_UNOWNCOUNT + if Runtime.wants("unown.unlocked") then + Runtime.emit("unown.unlocked", { + letter = index, name = Unown.name(index), word = Unown.word(index), + count = #list, + }) + end + return true +end + +function Unown.caught(save, letter) + local index = Unown.index(letter) + if not index then return false end + for _, seen in ipairs(Unown.dex(save)) do + if seen == index then return true end + end + return false +end + +-- The two places the cart calls `predef GetUnownLetter / callfar UpdateUnownDex` +-- are AddPartyMon's `.registerunowndex` and SendMonIntoBox (both +-- engine/pokemon/move_mon.asm), i.e. every route a caught Unown can take. The +-- port's equivalents are the battle's catch handler and `givepoke`, and both +-- call this rather than reaching into the list themselves. +-- +-- Anything that is not an Unown falls straight through, so a call site does not +-- have to check the species first. +function Unown.registerCatch(save, mon) + local letter = Unown.monLetter(mon) + if not (save and letter) then return false end + return Unown.updateDex(save, letter) +end + +-- CountUnown: `ld b, 0 / loop / ret z` -- the count of non-zero entries, which +-- with the list above is just its length. This is also VAR_UNOWNCOUNT +-- (engine/overworld/variables.asm .UnownCaught). +function Unown.count(save) + return #Unown.dex(save) +end + +-- ------------------------------------------------------------- the words +-- +-- data/pokemon/unown_words.asm. Each form has one word, printed under its +-- picture on the #DEX's UNOWN MODE page by PrintUnownWord at hlcoord 4, 15. +-- X really is "XXXXX" on the cart. +Unown.WORDS = { + "ANGRY", "BEAR", "CHASE", "DIRECT", "ENGAGE", "FIND", "GIVE", "HELP", + "INCREASE", "JOIN", "KEEP", "LAUGH", "MAKE", "NUZZLE", "OBSERVE", "PERFORM", + "QUICKEN", "REASSURE", "SEARCH", "TELL", "UNDO", "VANISH", "WANT", "XXXXX", + "YIELD", "ZOOM", +} + +function Unown.word(letter) + local index = Unown.index(letter) + return index and Unown.WORDS[index] or nil +end + +-- The letter a party/box mon is, or nil for anything that is not an Unown. +-- Reads the stored form first: a mon built before this existed still has DVs, +-- and the two always agree because the stored value comes from the DVs. +function Unown.monLetter(mon) + if not mon or mon.species ~= Unown.SPECIES then return nil end + if mon.unownLetter then return Unown.index(mon.unownLetter) end + return Unown.letterFromDVs(mon.dvs) +end + +-- pokemon.lua's UNOWN entry carries `letters.A .. letters.Z`, each with its own +-- spriteFront / spriteBack: the pics come out of UnownPicPointers, not the +-- species' own row (pokegold engine/gfx/load_pics.asm GetFrontpic swaps the +-- pointer table for UnownPicPointers and indexes it by wUnownLetter). A cache +-- built before that landed has no `letters` table, and every caller here +-- degrades to the species' own pics -- which ARE letter A's, since that is +-- what GetUnownLetter defaults to. +function Unown.forms(pokemon) + local def = pokemon and pokemon[Unown.SPECIES] + return def and def.letters or nil +end + +function Unown.formSprite(pokemon, letter, back) + local def = pokemon and pokemon[Unown.SPECIES] + if not def then return nil end + -- A caller that cannot name the letter answers nil, never letter A. Every + -- screen here is handed the mon and reads Unown.monLetter off it; a site that + -- resolved the pic from the SPECIES instead (as SummaryMenu:picFor once did) + -- has no letter to give, and coercing that to 1 is exactly what made a caught + -- Unown D show up in the party as an A with nothing logged. + local index = Unown.index(letter) + if not index then return nil end + local name = Unown.name(index) + local form = def.letters and name and def.letters[name] + if form then + return back and form.spriteBack or form.spriteFront + end + return back and def.spriteBack or def.spriteFront +end + +return Unown diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 03f1b8cc..c44deeb7 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -329,7 +329,6 @@ local function discoverModSchemas(opts) local okJson, Json = pcall(require, "src.link.Json") local okMan, Manifest = pcall(require, "src.mods.Manifest") if not (okJson and okMan) then return out end - local enabledFlags = opts.mods or {} local seen = {} for _, name in ipairs(fs.getDirectoryItems("mods")) do local path = "mods/" .. name @@ -341,9 +340,10 @@ local function discoverModSchemas(opts) if data then okV, m = pcall(Manifest.validate, data, path) end if okV and m and not seen[m.id] and m.options_schema then seen[m.id] = true - -- deriveList's enable resolution: a missing entry means enabled, + -- deriveList's enable resolution, through the one reader both mod + -- surfaces use (SaveData.modEnabled): unanswered means enabled, -- except experimental mods, which stay off until opted in. - local flag = enabledFlags[m.id] + local flag = require("src.core.SaveData").modEnabled(opts, m.id) local enabled = flag == true or (flag == nil and not m.experimental) if enabled then local chunk = fs.load(path .. "/" .. m.options_schema) @@ -447,17 +447,142 @@ local function modRows(opts, mod) return rows end +-- ------- Gen 2 (Gold) +-- +-- Gold reads NONE of the rows above. Its OPTION screen writes a different +-- set of names, several of which collide with Gen 1's at a different TYPE +-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and +-- its renderer has no battle layout, no SGB palette packs and no void fill -- +-- so a gear opened on the Gold tab used to offer a dozen controls that did +-- nothing and hide the seven that the cart itself has. +-- +-- The block lives in options.lua under `gold`, which is exactly where +-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the +-- next boot the same way a Gen 1 edit is. Ladders mirror +-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync. +local GEN2_KEY = "gold" + +local function gen2Rows(opts) + local rows = {} + local function add(label, value, step) + rows[#rows + 1] = { label = label, value = value, step = step } + end + + -- The cart's own seven (engine/menus/options_menu.asm _Option). + add(Strings("TEXT SPEED"), ladder(opts, "textSpeed", + { { "FAST", "FAST" }, { "MID", "MID" }, { "SLOW", "SLOW" } }, "MID")) + add(Strings("BATTLE SCENE"), ladder(opts, "battleScene", + { { true, "ON" }, { false, "OFF" } }, true)) + add(Strings("BATTLE STYLE"), ladder(opts, "battleStyle", + { { "SHIFT", "SHIFT" }, { "SET", "SET" } }, "SHIFT")) + add(Strings("SOUND"), ladder(opts, "sound", + { { "MONO", "MONO" }, { "STEREO", "STEREO" } }, "MONO")) + add(Strings("PRINT"), ladder(opts, "print", { + { "LIGHTEST", "LIGHTEST" }, { "LIGHTER", "LIGHTER" }, + { "NORMAL", "NORMAL" }, { "DARKER", "DARKER" }, { "DARKEST", "DARKEST" }, + }, "NORMAL")) + add(Strings("MENU ACCOUNT"), ladder(opts, "menuAccount", + { { false, "OFF" }, { true, "ON" } }, true)) + -- FRAME is the textbox border, 1-8, wrapping (UpdateFrame masks to 3 bits). + add(Strings("FRAME"), + function() return tostring(opts.frame or 1) end, + function(dir) + opts.frame = wrapIndex((opts.frame or 1) - 1 + (dir or 1), 8) + 1 + return true + end) + + -- ...then the port's, the same shared modules the Gen 1 rows drive. + add(Strings("MUSIC VOL"), + function() return volLabel(opts.musicVol) end, + function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end) + add(Strings("SFX VOL"), + function() return volLabel(opts.sfxVol) end, + function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end) + add(Strings("MUSIC FILTER"), + function() return FILTERS[(opts.musicFilter or 0) + 1] end, + function(dir) + opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS + return true + end) + + local okPal, GbcPalette = pcall(require, "src.render.GbcPalette") + if okPal then + add(Strings("COLOR"), + function() return GbcPalette.modeLabel(opts.color or "gbc") end, + function(dir) + local cur, idx = opts.color or "gbc", 1 + for i, mode in ipairs(GbcPalette.MODES) do + if mode == cur then idx = i break end + end + opts.color = + GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1] + return true + end) + end + + local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") + if okSpd then + add(Strings("GAME SPEED"), + function() return GameSpeed.levelLabel(opts.speed) end, + function(dir) + opts.speed = GameSpeed.cycle(opts.speed, dir) + return true + end) + end + + local okTilt, Tilt = pcall(require, "src.render.Tilt") + if okTilt then + add(Strings("TILT"), + function() return Tilt.levelLabel(opts.tilt or 0) end, + function(dir) + opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4) + return true + end) + end + + -- Same #136 gate as the Gen 1 row and the in-game one. + local okFx, GBCFX = pcall(require, "src.render.GBCFX") + if okFx and GBCFX.isSupported() then + add(Strings("GBC FX"), + function() return GBCFX.levelLabel(opts.gbcfx or 0) end, + function(dir) + opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) + return true + end) + end + + return rows +end + -- Build the whole settings model: one options table (edited in place), -- sections of rows, and a save() that persists it. The caller keeps the -- model for as long as the panel is open; nothing else in the launcher -- writes options while a modal covers it, so the cached table stays true. -- `hooks` carries the host actions a row cannot perform itself: -- editTouchControls() -- hand the screen to the touch-overlay editor -function LauncherSettings.open(hooks) +-- +-- `version` is the game the gear was opened on. It picks the row set, and +-- for Gold it also picks WHICH table the rows edit: the `gold` block inside +-- options.lua rather than the flat Gen 1 one. +function LauncherSettings.open(hooks, version) local opts = SaveData.loadOptions() - local sections = { - { title = Strings("OPTIONS"), rows = coreRows(opts, hooks) }, - } + local sections + if version == "gold" then + local block = opts[GEN2_KEY] + if type(block) ~= "table" then + block = {} + opts[GEN2_KEY] = block + end + sections = { + { title = Strings("OPTIONS"), rows = gen2Rows(block) }, + } + else + sections = { + { title = Strings("OPTIONS"), rows = coreRows(opts, hooks) }, + } + end + -- Mod options are generation-agnostic (the manager's options_schema + -- contract), so they ride along either way. for _, mod in ipairs(discoverModSchemas(opts)) do local rows = modRows(opts, mod) if #rows > 0 then @@ -466,6 +591,7 @@ function LauncherSettings.open(hooks) end return { opts = opts, + version = version, sections = sections, save = function() SaveData.saveOptions(opts) end, } diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 54518c40..b5f35c8a 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -306,6 +306,7 @@ end -- the label is read. Unknown versions fall back to the commit green. local CART_COLOR = { red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, + gold = PAL.railAmber, } local function cartColor(version) return CART_COLOR[version] or PAL.green @@ -314,9 +315,42 @@ end local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end if status == "conflict" then return Strings("Conflict"), PAL.red end + -- not a fault: the mod is intact, this is simply not a game it is for + -- (src/mods/ModTargets.lua) + if status == "other_game" then return Strings("Not for this game"), PAL.muted end return Strings("Incompatible"), PAL.yellow end +-- MODS panel scope row: which game the list is answering for. Drawn from +-- GameVersion.ORDER so a new game needs nothing here. +local function buildModScopeRow(imp, x, y, w, m) + local GameVersion = require("src.core.GameVersion") + local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) + local gap = math.floor(6 * m.s) + local label = Strings("Show for:") + Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s) + local options = { { id = nil, label = Strings("All games") } } + for _, version in ipairs(GameVersion.ORDER) do + if imp.ready and imp.ready[version] then + options[#options + 1] = + { id = version, label = GameVersion.info(version).label } + end + end + if #options < 2 then return 0 end + for _, opt in ipairs(options) do + local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) + if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong, + "mod-scope-" .. tostring(opt.id or "all")) then + local want = opt.id + queueAction(imp, "mod-scope-" .. tostring(want or "all"), + function() imp:_setModScope(want) end) + end + cx = cx + cw + gap + end + return h + math.floor(8 * m.s) +end + local function findActionFor(entry, installedVersion) local ModIndex = require("src.mods.ModIndex") if not ModIndex.canInstall(entry) then @@ -449,28 +483,48 @@ local function buildHeader(imp, m) or love.graphics.newImage("assets/launcher/mods.png") imp._findIcon = imp._findIcon or love.graphics.newImage("assets/launcher/find.png") - -- The three game tabs keep their cartridge colours -- that is the one piece - -- of brand identity in the launcher, and "the red one" is how people - -- actually refer to these. The colour rides the outline and the glyph at - -- rest and becomes the fill when active, the same rule the buttons follow. + -- Game tabs keep their cartridge colours -- that is the one piece of brand + -- identity in the launcher, and "the red one" is how people actually refer + -- to these. The colour rides the outline and the glyph at rest and becomes + -- the fill when active, the same rule the buttons follow. Yellow stays the + -- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not + -- collide. local tabs = { { id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed }, { id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue }, { id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold }, + { id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber }, { id = "mods", icon = imp._modsIcon, label = Strings("MODS") }, { id = "find", icon = imp._findIcon, label = Strings("FIND MODS") }, } local tabH = m.chip local tx = m.x + m.pad local ty = y + math.floor(6 * m.s) + -- Wrap the strip instead of running off the edge. + -- + -- Six tabs used to escape a phone width when an active icon tab spelled its + -- name out (FIND MODS at 412x915). Game tabs (R/B/Y/G) stay glyph-only even + -- when active; only MODS / FIND MODS expand. Still wrap when the next tab + -- would not fit so the divider below moves with the row count. + local tabLeft = tx + local tabRight = m.x + m.w - m.pad + local tabGap = math.floor(6 * m.s) + local tabRowGap = math.floor(4 * m.s) for _, t in ipairs(tabs) do local active = imp.tab == t.id local key = "tab-" .. t.id - local labelW = Kit.textWidth("tab", t.label) - -- The active tab spells its name out; inactive tabs are the glyph alone, - -- so five tabs fit a phone width without wrapping. - local w = active and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s)) + -- Cartridge tabs stay square (letter only). Icon tabs still expand to + -- show MODS / FIND MODS when selected. + local expand = active and t.icon ~= nil + local labelW = expand and Kit.textWidth("tab", t.label) or 0 + local w = expand and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s)) or tabH + -- Never wrap the first tab of a row: if one tab alone is wider than the + -- panel there is nowhere better to put it, and wrapping would loop. + if tx > tabLeft and tx + w > tabRight then + tx = tabLeft + ty = ty + tabH + tabRowGap + end Kit._audit("control", tx, ty, w, tabH, key) local focused = Kit.focusable(key, tx, ty, w, tabH) local hot = focused or Kit.hover(tx, ty, w, tabH) @@ -497,16 +551,17 @@ local function buildHeader(imp, m) Kit.textCenter("tab", t.letter, tx, ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink) end - if active then + if expand then Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s), ty + (tabH - Kit.textHeight("tab")) / 2, ink) end if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then queueAction(imp, key, function() imp:_switchTab(t.id) end) end - tx = tx + w + math.floor(6 * m.s) + tx = tx + w + tabGap end + -- `ty` has walked down with the wraps, so this stays correct at one row too. y = ty + tabH + math.floor(8 * m.s) Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) return y + math.floor(10 * m.s) @@ -1127,6 +1182,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2) + math.floor(8 * m.s) + cy = cy + buildModScopeRow(imp, x, cy, w, m) + if #mods == 0 then Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) return @@ -1225,12 +1282,21 @@ local function buildModsPanel(imp, x, y, w, availH, m) local textW = inner - chipsW - math.floor(12 * m.s) local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) + -- the games the mod is for, beside its category: the same chip the + -- in-game manager shows (src/mods/ModTargets.lua) + local gamesW = mod.targets + and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - math.floor(8 * m.s)) + textW - badgeW - gamesW - math.floor(12 * m.s)) Kit.text("button", nameShown, px, ly, PAL.heading) - Kit.tag(px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s), ly, - badgeW, Kit.textHeight("button"), mod.badge, + local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) + Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, mod.experimental and PAL.yellow or PAL.muted) + if mod.targets then + Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.textHeight("button"), mod.targets, + mod.targetsHere == false and PAL.steel or PAL.blue) + end ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) -- version + status + update state diff --git a/src/import/Rom.lua b/src/import/Rom.lua index 6eb27ed6..69c75922 100644 --- a/src/import/Rom.lua +++ b/src/import/Rom.lua @@ -208,4 +208,103 @@ function Rom.decompressPic(data) return output, width end +-- pokegold's "lz3" compression (home/decompress.asm), used for Gen 2 +-- graphics (tilesets, Pokemon pics, title screen art, ...). `data` is a Lua +-- array of bytes (as returned by Rom:bytes) or a raw string; returns a Lua +-- array of decompressed bytes. Ported instruction-for-instruction against +-- pokegold's Decompress routine and cross-checked against tools/lzcompress.c +-- (--uncompress path), which is the canonical reference for this format. +local LZ_END = 0xFF +local LZ_LITERAL = 0 +local LZ_ITERATE = 1 +local LZ_ALTERNATE = 2 +local LZ_ZERO = 3 +local LZ_FLIP = 5 +local LZ_REVERSE = 6 +local LZ_LONG = 7 + +local function flipBits(value) + local flipped = 0 + for bitIndex = 0, 7 do + flipped = flipped + math.floor(value / 2 ^ bitIndex) % 2 * 2 ^ (7 - bitIndex) + end + return flipped +end + +function Rom.decompressLz3(data) + local bytes = data + if type(data) == "string" then + bytes = {} + for index = 1, #data do bytes[index] = data:byte(index) end + end + + local pos = 1 + local function nextByte() + local value = bytes[pos] + if not value then error("lz3 stream ended unexpectedly") end + pos = pos + 1 + return value + end + + local out = {} + while true do + local first = bytes[pos] + if not first then error("lz3 stream ended without a terminator") end + pos = pos + 1 + if first == LZ_END then break end + + local command, length + if math.floor(first / 0x20) == LZ_LONG then + -- 111xxxyy yyyyyyyy: xxx is the real command, yy.. is a 10-bit length + command = math.floor(first / 4) % 8 + local high = first % 4 + length = high * 0x100 + nextByte() + 1 + else + command = math.floor(first / 0x20) + length = first % 0x20 + 1 + end + + if command == LZ_LITERAL then + for _ = 1, length do out[#out + 1] = nextByte() end + elseif command == LZ_ITERATE then + local value = nextByte() + for _ = 1, length do out[#out + 1] = value end + elseif command == LZ_ALTERNATE then + local a, b = nextByte(), nextByte() + for index = 1, length do + out[#out + 1] = (index % 2 == 1) and a or b + end + elseif command == LZ_ZERO then + for _ = 1, length do out[#out + 1] = 0 end + else + -- Lookback commands (LZ_REPEAT/LZ_FLIP/LZ_REVERSE, and the unused id + -- 7 which the hardware routine falls through to LZ_REPEAT for). + -- A high-bit offset byte is a 7-bit negative lookback from the + -- current output position; otherwise it is a 15-bit absolute offset + -- from the start of the output buffer (two bytes, big-endian). + local offsetByte = nextByte() + local from + if offsetByte >= 0x80 then + from = #out - (offsetByte % 0x80) + else + from = offsetByte * 0x100 + nextByte() + 1 + end + if command == LZ_FLIP then + for index = 0, length - 1 do + out[#out + 1] = flipBits(out[from + index]) + end + elseif command == LZ_REVERSE then + for index = 0, length - 1 do + out[#out + 1] = out[from - index] + end + else + for index = 0, length - 1 do + out[#out + 1] = out[from + index] + end + end + end + end + return out +end + return Rom diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua new file mode 100644 index 00000000..42847e1a --- /dev/null +++ b/src/import/RomExtractorGen2.lua @@ -0,0 +1,5905 @@ +-- Gold Gen 2 extractor: pret/pokegold's ROM layout, parallel to +-- src/import/RomExtractor.lua (Gen 1) but never branched into it -- the two +-- generations disagree on almost everything below the constants layer +-- (species order IS dex order, pics/tilesets are lz3-compressed rather than +-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md. +local bit = require("bit") +local ImageWriter = require("src.import.ImageWriter") +local LuaWriter = require("src.import.LuaWriter") +local Rom = require("src.import.Rom") + +-- engine/gfx/load_pics.asm FixPicBank. `dba_pic` does NOT store the real +-- bank for the three "Pics" sections that live above the 8-bit-friendly range: +-- it writes $13, $14 or $1f and the loader maps them back on the way out. +-- A reader that trusts the stored byte lands in the wrong bank entirely, which +-- is why nine of the Unown letters decoded as noise and the rest as nothing. +local FIX_PIC_BANK = { + [0x13] = 0x1f, -- BANK("Pics 12") + [0x14] = 0x20, -- BANK("Pics 13") + [0x1f] = 0x2e, -- BANK("Pics 14") +} + +local RomExtractorGen2 = {} +RomExtractorGen2.__index = RomExtractorGen2 + +-- constants, font, palettes, tilesets, maps, sprites, scripts+text, std +-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex, +-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation, +-- audio, stubs +local STAGE_COUNT = 26 +local Opcodes = require("src.script.gen2.Opcodes") + +-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm +-- PAL_BG_*); PAL_BG_ROOF is the one LoadMapPals overrides per map group. +local PAL_BG_NAMES = { + "GRAY", "RED", "GREEN", "WATER", "YELLOW", "BROWN", "ROOF", "TEXT", +} +local PAL_BG_ROOF = 6 -- 0-based slot index +-- Time-of-day palette sets, in wTimeOfDayPal order (MORN_F..DARKNESS_F). +local DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +-- gfx/tilesets/bg_tiles.pal: morn/day/nite/dark/indoor (8 each) plus the two +-- overworld-water palettes at $28-$29 -- "Valid indices: $00 - $29". +local BG_PALETTE_COUNT = 0x2a +-- gfx/overworld/npc_sprites.pal is PAL_OW_* x NUM_DAYTIMES. +local OW_PALETTE_COUNT = 8 +-- data/maps/environment_colors.asm rows are 8 indices per daytime. +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. +local PAL_MAP_BANK = 0x02 +-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two +-- 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 +-- Every Gen 2 back pic is 6x6 tiles (48x48); only front pics vary in size. +local BACK_PIC_TILES = 6 + +-- Gen 2 tilesets always ship 128 metatiles (16 tile ids each) and 128 +-- collision quads (4 COLL_* bytes each) -- see gfx/tilesets/*_metatiles.bin. +local METATILE_COUNT = 128 +local MAP_LENGTH = 9 +local ATTR_LENGTH = 12 +local CONNECTION_LENGTH = 12 +local WARP_LENGTH = 5 +local COORD_LENGTH = 8 +local BG_LENGTH = 5 +local OBJECT_LENGTH = 13 +-- constants/script_constants.asm OBJECTTYPE_*: the object_event function byte +-- that says whether its pointer is bytecode, a `trainer` struct, or an item. +local OBJECTTYPE_ITEMBALL, OBJECTTYPE_TRAINER = 1, 2 +-- constants/script_constants.asm BGEVENT_*: the bg_event function byte does the +-- same job for a sign's pointer. BGEVENT_ITEM is a HIDDEN ITEM and its operand +-- aims at `hiddenitem item, flag` (macros/scripts/maps.asm), not at bytecode -- +-- 87 of them, and disassembling those three bytes as commands is where most of +-- the port's unknown-opcode rows came from. +local BGEVENT_ITEM = 7 +-- constants/map_setup_constants.asm. `def_callbacks` asserts one callback per +-- type at most, so a count above this is a misread header rather than data. +local NUM_MAPCALLBACK_TYPES = 5 +-- constants/script_constants.asm: the cmdqueue entry a `writecmdqueue` names +-- (dbw type, addr + dw filler) and the stonetable row it points at +-- (db warp, object + dw script), a `db -1` ending the list. +local CMDQUEUE_ENTRY_SIZE = 6 +local CMDQUEUE_STONETABLE = 2 +local STONETABLE_LENGTH = 4 +-- MenuHeader (ram/wram.asm wMenuHeader): db flags; menu_coords lays the +-- corners down y-first (macros/coords.asm `db \2, \1` twice); dw the data +-- pointer; db the default cursor position. +local MENU_HEADER_LENGTH = 8 +-- constants/npc_trade_constants.asm. The rsreset block ends on an `rb_skip` +-- padding byte, so the stride is 32 rather than the 31 the fields add up to -- +-- the same "read the rsreset, not the macro" rule TrainerClassAttributes' +-- seven bytes came from. `dname` pads with '@', so each name field carries +-- its own terminator inside its 11 bytes. +local NPCTRADE_STRUCT_LENGTH = 32 +local MON_NAME_LENGTH, NAME_LENGTH = 11, 11 +local NUM_NPC_TRADES = 6 +-- 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 +-- constants/phone_constants.asm PHONE_CONTACT_SIZE and SPECIALCALL_SIZE. +local PHONE_CONTACT_SIZE = 12 +local SPECIALCALL_SIZE = 6 + +-- The WRAM string buffers a `text_ram` can name (ram/wram.asm, addresses from +-- pokegold.sym). Every one of them decodes to the same `{STRBUF}` marker -- +-- this port has one shared buffer -- so the names are only recorded where a +-- caller asks, to tell two markers in one line apart. +local TEXT_BUFFERS = { + [0xcf48] = "wMonOrItemNameBuffer", + [0xcf6b] = "wStringBuffer1", + [0xcf7e] = "wStringBuffer2", + [0xcf91] = "wStringBuffer3", + [0xcfa4] = "wStringBuffer4", + [0xcfb7] = "wStringBuffer5", + -- The trade animation names the two trademon records instead + -- (data/text/common_1.asm _MonWasSentToText and the rest), and the four it + -- reaches for are the only way to tell "MACHOP was sent to MIKE" from the + -- line with the two names the other way round. + [0xc5d1] = "wPlayerTrademonSpeciesName", + [0xc5e7] = "wPlayerTrademonSenderName", + [0xc602] = "wOTTrademonSpeciesName", + [0xc618] = "wOTTrademonSenderName", +} +-- The text commands that print nothing and carry no argument +-- (macros/scripts/text.asm, in TextCommands order): TX_LOW, TX_SCROLL, +-- TX_PAUSE, TX_WAIT_BUTTON, TX_DAY, and the six TX_SOUND_* jingles. +local TEXT_NO_GLYPH = { + [0x05] = true, [0x07] = true, [0x0a] = true, [0x0b] = true, [0x0d] = true, + [0x0e] = true, [0x0f] = true, [0x10] = true, [0x11] = true, [0x12] = true, + [0x13] = true, [0x15] = true, +} + +local ROOF_TILES = 9 +local SPRITEDATA_LENGTH = 6 + +-- data/sprites/sprites.asm overworld_sprite type / palette bytes. +local WALKING_SPRITE, STANDING_SPRITE, STILL_SPRITE = 1, 2, 3 +local SPRITE_TYPE_NAME = { + [WALKING_SPRITE] = "WALKING_SPRITE", + [STANDING_SPRITE] = "STANDING_SPRITE", + [STILL_SPRITE] = "STILL_SPRITE", +} +local SPRITE_PALETTE_NAME = { + [0] = "PAL_OW_RED", [1] = "PAL_OW_BLUE", [2] = "PAL_OW_GREEN", + [3] = "PAL_OW_BROWN", [4] = "PAL_OW_PINK", [5] = "PAL_OW_EMOTE", + [6] = "PAL_OW_TREE", [7] = "PAL_OW_ROCK", +} + +-- Connection flag bits (constants/map_data_constants.asm shift_const order). +local CONN_EAST, CONN_WEST, CONN_SOUTH, CONN_NORTH = 0x01, 0x02, 0x04, 0x08 + +local function copy(value) + if type(value) ~= "table" then return value end + local result = {} + for key, item in pairs(value) do result[copy(key)] = copy(item) end + return result +end + +function RomExtractorGen2.new(romData, manifest, progress) + return setmetatable({ + rom = Rom.new(romData), + manifest = manifest, + symbols = manifest.symbols, + progress = progress, + stage = 0, + }, RomExtractorGen2) +end + +function RomExtractorGen2:symbol(name) + local location = self.symbols[name] + if not location then error("required symbol is missing: " .. tostring(name)) end + return { bank = location[1], address = location[2], name = name } +end + +-- A headless import shows nothing but a spinner, so a stage that dies takes +-- its own name down with it. POKEPORT_IMPORT_TRACE=1 prints each stage as it +-- starts, which is what turns "the import hung" into "the import hung in +-- Pokemon". +local TRACE = os.getenv("POKEPORT_IMPORT_TRACE") == "1" + +function RomExtractorGen2:beginStage(name) + self.stage = self.stage + 1 + if TRACE then + io.write(("[gold import] %d/%d %s\n"):format(self.stage, STAGE_COUNT, name)) + io.flush() + end + if self.progress then self.progress(self.stage - 1, STAGE_COUNT, name, 0, 1) end +end + +-- Sub-stage trace: the same POKEPORT_IMPORT_TRACE switch, for the places +-- inside a stage where a bad pointer could send a terminator-driven walk into +-- the weeds. +function RomExtractorGen2:trace(message) + if not TRACE then return end + io.write(("[gold import] %s\n"):format(message)) + io.flush() +end + +function RomExtractorGen2:tick(name, current, total) + if self.progress then + self.progress(self.stage - 1 + current / total, STAGE_COUNT, + name, current, total) + end +end + +function RomExtractorGen2:write(name, value) + LuaWriter.write("data/generated/" .. name .. ".lua", value) +end + +function RomExtractorGen2:save(image, relative) + ImageWriter.save(image, "assets/generated/" .. relative) +end + +-- Pics and tileset graphics are lz3-compressed (home/decompress.asm); the +-- terminator ($ff) is what actually ends the stream, so -- same trick as +-- Gen 1's writeCompressedPic -- it is safe to just hand over everything to +-- the end of the bank rather than track an exact compressed length. +function RomExtractorGen2:decompressLz3Symbol(label) + local symbol = self:symbol(label) + local compressed = self.rom:bytes( + symbol.bank, symbol.address, 0x8000 - symbol.address) + return Rom.decompressLz3(compressed) +end + +function RomExtractorGen2:write2bpp(raw, width, height, relative, transparent) + local image = ImageWriter.decode2bpp(raw, width, height, transparent) + self:save(image, relative) +end + +-- Decompresses a lz3 Pokemon/trainer pic and writes it at its native size +-- (BASE_PIC_SIZE low nibble, tiles wide/tall -- Gen 2 pics are always +-- 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). +function RomExtractorGen2:writeCompressedPic(label, tiles, relative) + local pixels = 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 + pixels = ImageWriter.columnsToRows(pixels, tiles, tiles) + self:write2bpp(pixels, size, size, relative) +end + +function RomExtractorGen2:extractConstants() + self:beginStage("Game constants") + local data = copy(self.manifest.constants) + data.generation = 2 + self:write("constants", data) + self:tick("Game constants", 1, 1) + return data +end + +-- GBC colour. Gen 2 is a CGB-native game: everything on screen is drawn +-- through eight 4-colour BG palettes plus eight OBJ palettes, so unlike Gen 1 +-- (where SGB palette *zones* tint a fundamentally 4-shade image) the colours +-- here are not decoration -- a tile's palette is part of its identity. +-- +-- engine/gfx/color.asm LoadMapPals is the whole overworld colour pipeline: +-- 1. EnvironmentColorsPointers[wEnvironment] -> a table of 4 rows (one per +-- time of day), each 8 bytes. Each byte is an index into the shared +-- TilesetBGPalette pool, and lands in BG palette slot 0-7. +-- 2. MapObjectPals[wTimeOfDayPal] -> the 8 OBJ palettes for OW sprites +-- (each sprite's PAL_OW_* comes from data/sprites/sprites.asm). +-- 3. Outdoors only, RoofPals[wMapGroup] overwrites PAL_BG_ROOF colours 1 +-- and 2, which is what makes each town's roofs a different colour while +-- sharing one roof tile sheet. +-- +-- Colours are stored 0-255 per channel to match Gen 1's palettes.lua, so a +-- reader can hand either generation's table to the same shader. +local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end + +-- RGBDS's `percent` macro stores N% as 255*N/100, so $ff is 100% and $e6 is +-- 90%. Move accuracy and effect chance both go through it; converting back +-- here keeps moves.lua reading like Gen 1's (accuracy 90, not 230). +local function percentOf(raw) + return math.floor((raw or 0) * 100 / 255 + 0.5) +end + +-- ITEMMENU_* (constants/item_data_constants.asm) is NOT contiguous: NOUSE is 0, +-- then `const_skip 3`, then CURRENT 4, PARTY 5, CLOSE 6. A positional list +-- cannot be indexed by value, so the enum is spelled out with its real numbers. +local ITEM_MENU_NAME = { + [0] = "ITEMMENU_NOUSE", + [4] = "ITEMMENU_CURRENT", + [5] = "ITEMMENU_PARTY", + [6] = "ITEMMENU_CLOSE", +} + +-- Font is 1bpp in Gen 2 (gfx/font.asm); FontExtra/FontBattleExtra are 2bpp. +-- TextBox / Font.drawCode multiply by the current color, so sheets MUST be +-- black ink on transparent (Gen 1's shape) -- opaque white+black becomes a +-- solid black rectangle when drawn with color (0,0,0). +-- +-- Textbox borders ┌─┐│└┘ live in Frames (1bpp, loaded at $79), NOT FontExtra +-- (engine/gfx/load_font.asm LoadFrame). We composite frame 0 into imageExtra. +local TEXTBOX_FRAME_TILES = 6 +local NUM_FRAMES = 8 + +local function inkFrom1bpp(raw, width, height) + local image = ImageWriter.blank(width, height, 0, 0, 0, 0) + local tilesPerRow = width / 8 + for tile = 0, #raw / 8 - 1 do + local tileX, tileY = tile % tilesPerRow * 8, math.floor(tile / tilesPerRow) * 8 + for y = 0, 7 do + local row = raw[tile * 8 + y + 1] + for x = 0, 7 do + if bit.band(row, 2 ^ (7 - x)) ~= 0 then + image:setPixel(tileX + x, tileY + y, 0, 0, 0, 1) + end + end + end + end + return image +end + +local function inkFrom2bpp(raw, width, height) + local shaded = ImageWriter.decode2bpp(raw, width, height) + local image = ImageWriter.blank(width, height, 0, 0, 0, 0) + for y = 0, height - 1 do + for x = 0, width - 1 do + local r = shaded:getPixel(x, y) + if r < 0.5 then image:setPixel(x, y, 0, 0, 0, 1) end + end + end + return image +end + +-- NUM_UNOWN + 1 tiles (the letters plus the cursor), on pret's own 3-wide +-- sheet; see the block in extractFont below. +local UNOWN_FONT_TILES = 27 +local UNOWN_FONT_WIDE = 3 + +function RomExtractorGen2:extractFont() + self:beginStage("Fonts") + local font = self:symbol("Font") + local raw = self.rom:bytes(font.bank, font.address, 128 * 8) + self:save(inkFrom1bpp(raw, 128, 64), "fonts/font.png") + self:tick("Fonts", 1, 4) + + -- Extra page ($60+): FontExtra 2bpp ink, then Frames borders at $79-$7E. + local extra = self:symbol("FontExtra") + local extraImg = inkFrom2bpp( + self.rom:bytes(extra.bank, extra.address, 128 * 16 / 4), 128, 16) + local frames = self:symbol("Frames") + -- gfx/font.asm:10 Frames: NUM_FRAMES rows of TEXTBOX_FRAME_TILES 1bpp tiles, + -- which is what LoadFrame's AddNTimes indexes off wTextboxFrame. + local frameRaw = self.rom:bytes( + frames.bank, frames.address, NUM_FRAMES * TEXTBOX_FRAME_TILES * 8) + local frameSheet = inkFrom1bpp(frameRaw, + TEXTBOX_FRAME_TILES * 8, NUM_FRAMES * 8) + self:save(frameSheet, "fonts/frames.png") + -- Row 0 still bakes into the extra page, so a cache without the sheet keeps + -- frame 1 exactly as before. + for t = 0, TEXTBOX_FRAME_TILES - 1 do + local destId = 0x79 + t - 0x60 -- tile index in the $60-based extra sheet + local dx, dy = (destId % 16) * 8, math.floor(destId / 16) * 8 + ImageWriter.blit(extraImg, frameSheet, dx, dy, t * 8, 0, 8, 8) + end + -- _LoadFontsExtra (engine/gfx/load_font.asm:7-20) does NOT lay FontExtra + -- down from $60. It writes three sources, and only the third is FontExtra: + -- + -- $60-$61 FontsExtra_SolidBlackAndUpArrowGFX, 2 tiles, Get1bpp + -- $62 PokegearPhoneIconGFX, 1 tile, Get2bpp + -- $63+ FontExtra + 3 tiles, 22 tiles, Get2bpp + -- + -- The third line is why the page above is still right from $63 up: FontExtra + -- tile n lands at $60 + n either way, so only the first three cells differ. + -- Left alone they hold FontExtra's own <BOLD_A>/<BOLD_B>/<BOLD_C>, which the + -- cart never shows -- constants/charmap.asm:41 marks the $62 one "unused" + -- and :88 gives $62 to "☎" -- and that is exactly the bold C the Pokegear's + -- caller box drew where the phone icon belongs. + -- + -- Both are tolerated rather than required, like UnownFont below: a manifest + -- built before these symbols were listed still imports and simply keeps the + -- three unused letters. + if self.symbols["FontsExtra_SolidBlackAndUpArrowGFX"] then + local solid = self:symbol("FontsExtra_SolidBlackAndUpArrowGFX") + -- gfx/font/black.1bpp then gfx/font/up_arrow.1bpp, one tile each + local solidImg = inkFrom1bpp( + self.rom:bytes(solid.bank, solid.address, 2 * 8), 16, 8) + ImageWriter.blit(extraImg, solidImg, 0, 0, 0, 0, 8, 8) -- $60 + ImageWriter.blit(extraImg, solidImg, 8, 0, 8, 0, 8, 8) -- $61 + end + if self.symbols["PokegearPhoneIconGFX"] then + local phone = self:symbol("PokegearPhoneIconGFX") + local phoneImg = inkFrom2bpp( + self.rom:bytes(phone.bank, phone.address, 8 * 8 / 4), 8, 8) + ImageWriter.blit(extraImg, phoneImg, 16, 0, 0, 0, 8, 8) -- $62 + end + self:save(extraImg, "fonts/font_extra.png") + self:tick("Fonts", 2, 4) + + local battleExtra = self:symbol("FontBattleExtra") + self:save(inkFrom2bpp( + self.rom:bytes(battleExtra.bank, battleExtra.address, 128 * 16 / 4), + 128, 16), "fonts/font_battle_extra.png") + self:tick("Fonts", 3, 5) + + local data = { + generation = 2, + source = "ROM:Font, FontExtra, Frames, FontBattleExtra, PokegearPhoneIconGFX, FontsExtra_SolidBlackAndUpArrowGFX", + image = "assets/generated/fonts/font.png", + imageExtra = "assets/generated/fonts/font_extra.png", + imageBattleExtra = "assets/generated/fonts/font_battle_extra.png", + imageFrames = "assets/generated/fonts/frames.png", + frameBase = 0x79, frameTiles = TEXTBOX_FRAME_TILES, + mainBase = 0x80, extraBase = 0x60, glyphsPerRow = 16, + charmap = self.manifest.fontCharmap or {}, + } + + -- The Unown font (gfx/font/unown_font.2bpp, gfx/font.asm UnownFont). It is + -- not a page of the ordinary font: Pokedex_LoadUnownFont copies 27 tiles to + -- vTiles2 tile FIRST_UNOWN_CHAR ($40) only while the #DEX's UNOWN MODE is + -- up, so the letters are addressed as tiles rather than as characters and + -- this is written as a SHEET (four shades, drawn through a palette) rather + -- than as ink like the three pages above. + -- + -- Tile n is letter n + 1 (A..Z) and tile 26 -- FIRST_UNOWN_CHAR + NUM_UNOWN + -- -- is the diamond cursor the ring of letters is pointed at with. pret's + -- PNG is 3 tiles across and 9 down and the 2bpp is built without + -- --columns, so a straight row-major 24x72 decode IS gfx/font/ + -- unown_font.png, byte for byte. + -- + -- The sheet goes out UNINVERTED, the way it sits in the ROM: the routine's + -- Pokedex_InvertTiles pass is the same flip Chrome.printInverted already + -- applies to the ordinary font (the palette read backwards), so inverting + -- here would only make the drawing side undo it. + -- + -- Tolerated rather than required, like credits and the diploma: a manifest + -- built before this symbol was listed still imports, it just leaves UNOWN + -- MODE printing its letters in the ordinary font. + if self.symbols["UnownFont"] then + local unown = self:symbol("UnownFont") + self:write2bpp( + self.rom:bytes(unown.bank, unown.address, UNOWN_FONT_TILES * 16), + UNOWN_FONT_WIDE * 8, UNOWN_FONT_TILES / UNOWN_FONT_WIDE * 8, + "fonts/unown_font.png") + data.imageUnown = "assets/generated/fonts/unown_font.png" + data.unownTiles = UNOWN_FONT_TILES + data.unownWide = UNOWN_FONT_WIDE + data.unownBase = 0x40 -- FIRST_UNOWN_CHAR + data.source = data.source .. ", UnownFont" + end + self:tick("Fonts", 4, 5) + + self:write("font", data) + self:tick("Fonts", 5, 5) + return data +end + +-- One GBC colour word: little-endian BGR555 (bits 0-4 red, 5-9 green, +-- 10-14 blue). +function RomExtractorGen2:color(bank, address) + local value = self.rom:word(bank, address) + return { + scale5(value % 32), + scale5(math.floor(value / 32) % 32), + scale5(math.floor(value / 1024) % 32), + } +end + +-- `count` consecutive colours starting at address. +function RomExtractorGen2:colors(bank, address, count) + local out = {} + for i = 0, count - 1 do + out[#out + 1] = self:color(bank, address + i * 2) + end + return out +end + +-- BattleObjectPals (gfx/battle_anims/battle_anims.pal): SIX four-colour +-- palettes, and the first of them is PAL_BATTLE_OB_GRAY -- not slot 0. Slots +-- 0 and 1 are PAL_BATTLE_OB_ENEMY / PAL_BATTLE_OB_PLAYER, which +-- _CGB_BattleColors fills with the two battlers' own colours, so the block on +-- disk starts two slots in and `battleAnimObPaletteOrder` is indexed from +-- PAL_BATTLE_OB_GRAY onward here. +function RomExtractorGen2:battleObjectPals() + local names = (self.manifest.constants or {}).battleAnimObPaletteOrder or {} + -- Tolerated rather than required: a manifest built before this symbol was + -- listed still imports, it just leaves the runtime on its fallback ramp. + if not self.symbols["BattleObjectPals"] then return nil end + local symbol = self:symbol("BattleObjectPals") + local out = {} + -- The block's row 0 is PAL_BATTLE_OB_GRAY, which is index 3 of the 1-based + -- name list (ENEMY, PLAYER, GRAY, ...). + for row = 0, 5 do + local name = names[row + 3] + if name then + out[name] = self:colors(symbol.bank, symbol.address + row * 8, 4) + end + end + 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. +function RomExtractorGen2:readPalMap(address) + local raw = self.rom:bytes(PAL_MAP_BANK, address, TILESET_TILE_COUNT / 2) + local out = {} + for i, byte in ipairs(raw) do + out[(i - 1) * 2 + 1] = byte % 8 + 1 + out[(i - 1) * 2 + 2] = math.floor(byte / 16) % 8 + 1 + end + return out +end + +function RomExtractorGen2:extractPalettes() + self:beginStage("Color palettes") + local consts = self.manifest.constants + + -- The shared pool every BG palette slot is filled from. + local bgSymbol = self:symbol("TilesetBGPalette") + local bg = {} + for index = 0, BG_PALETTE_COUNT - 1 do + bg[index + 1] = self:colors(bgSymbol.bank, bgSymbol.address + index * 8, 4) + end + self:tick("Color palettes", 1, 6) + + -- environment -> daytime -> 8 pool indices (stored 1-based to index `bg`). + local envSymbol = self:symbol("EnvironmentColorsPointers") + local environments = {} + local environmentOrder = consts.environmentOrder or {} + for slot = 0, ENV_POINTER_COUNT - 1 do + local rowAddress = self.rom:word( + envSymbol.bank, envSymbol.address + slot * 2) + -- Slot 0 is the unused leading entry; environment ids are 1-based. + local name = environmentOrder[slot] + if name then + local perDaytime = {} + for day = 0, #DAYTIMES - 1 do + local indices = {} + for i = 0, 7 do + indices[i + 1] = self.rom:byte( + envSymbol.bank, rowAddress + day * 8 + i) + 1 + end + perDaytime[DAYTIMES[day + 1]] = indices + end + environments[name] = perDaytime + end + end + self:tick("Color palettes", 2, 6) + + -- OW sprite OBJ palettes, one set of 8 per time of day. + local objSymbol = self:symbol("MapObjectPals") + local objects = {} + for day = 0, #DAYTIMES - 1 do + local set = {} + for pal = 0, OW_PALETTE_COUNT - 1 do + set[pal + 1] = self:colors(objSymbol.bank, + objSymbol.address + (day * OW_PALETTE_COUNT + pal) * 8, 4) + end + objects[DAYTIMES[day + 1]] = set + end + self:tick("Color palettes", 3, 6) + + -- RoofPals rows are `table_width COLOR_SIZE * 2 * 2`: two morn/day colours + -- followed by two nite colours, copied over PAL_BG_ROOF colours 1-2. + local roofSymbol = self:symbol("RoofPals") + local roofs = {} + for group = 0, MAP_GROUP_COUNT do + local base = roofSymbol.address + group * 8 + roofs[group] = { + mornDay = self:colors(roofSymbol.bank, base, 2), + nite = self:colors(roofSymbol.bank, base + 4, 2), + } + end + self:tick("Color palettes", 4, 6) + + -- Mon and trainer pics ship only their two middle colours; white and black + -- bracket them (data/pokemon/palettes.asm "only the middle two colors"). + local monSymbol = self:symbol("PokemonPalettes") + local pokemon = {} + for index, species in ipairs(consts.speciesOrder or {}) do + if species and species ~= "UNUSED" then + local base = monSymbol.address + index * 8 + pokemon[species] = { + normal = self:colors(monSymbol.bank, base, 2), + shiny = self:colors(monSymbol.bank, base + 4, 2), + } + end + end + -- The table runs past the 251 species: data/pokemon/palettes.asm:530 gives + -- EGG ($fd, constants/pokemon_constants.asm:271) a row of its own, which is + -- why `assert_table_length EGG + 1` sits right under it. _CGB_Evolution + -- reaches it through GetPlayerOrMonPalettePointer (engine/gfx/color.asm:620) + -- whenever a pic's species is EGG, which is what Hatch_LoadFrontpicPal and + -- the egg stats screen both hand it. + pokemon.EGG = { + normal = self:colors(monSymbol.bank, monSymbol.address + 253 * 8, 2), + shiny = self:colors(monSymbol.bank, monSymbol.address + 253 * 8 + 4, 2), + } + self:tick("Color palettes", 5, 6) + + local trainerSymbol = self:symbol("TrainerPalettes") + local trainers = {} + for index, class in ipairs(consts.trainerClassOrder or {}) do + -- Row 0 is PlayerPalette (Chris shares Cal's colours); TRAINER_NONE has + -- no pic of its own, so name that row PLAYER instead. + local name = (index == 1) and "PLAYER" or class + trainers[name] = self:colors( + trainerSymbol.bank, trainerSymbol.address + (index - 1) * 4, 2) + end + + local hpSymbol = self:symbol("HPBarPals") + local expSymbol = self:symbol("ExpBarPalette") + local partySymbol = self:symbol("PartyMenuOBPals") + + local data = { + generation = 2, + source = "ROM:TilesetBGPalette/EnvironmentColorsPointers/MapObjectPals/RoofPals", + daytimes = { DAYTIMES[1], DAYTIMES[2], DAYTIMES[3], DAYTIMES[4] }, + slotNames = PAL_BG_NAMES, + roofSlot = PAL_BG_ROOF + 1, + bg = bg, + environments = environments, + objects = objects, + roofs = roofs, + pokemon = pokemon, + trainers = trainers, + -- Two colours per HP-bar state (green/yellow/red plus the unused blue). + hpBar = { + green = self:colors(hpSymbol.bank, hpSymbol.address, 2), + yellow = self:colors(hpSymbol.bank, hpSymbol.address + 4, 2), + red = self:colors(hpSymbol.bank, hpSymbol.address + 8, 2), + blue = self:colors(hpSymbol.bank, hpSymbol.address + 12, 2), + }, + expBar = self:colors(expSymbol.bank, expSymbol.address, 2), + partyMenu = { + self:colors(partySymbol.bank, partySymbol.address, 4), + self:colors(partySymbol.bank, partySymbol.address + 8, 4), + }, + -- The six animation-object palettes, keyed by the PAL_BATTLE_OB_* name so + -- an object's palette byte resolves straight through + -- battleAnimObPaletteOrder. Slots 0 and 1 (ENEMY / PLAYER) are the two + -- battlers' own colours and are not in this block. + battleObjects = self:battleObjectPals(), + } + self:write("palettes", data) + self:tick("Color palettes", 6, 6) + return data +end + +-- The functions a `tileframe` row can name (data/tileset_anims.asm's macro: +-- `dw argument` then `dw function`), reverse-mapped by address so a tileset's +-- Anim pointer decodes into named steps instead of raw bank $3f addresses. +local ANIM_FUNCTIONS = { + "DoneTileAnimation", "WaitTileAnimation", + "StandingTileFrame", "StandingTileFrame8", + "AnimateWaterTile", "AnimateFlowerTile", "AnimateWaterPalette", + "ReadTileToAnimBuffer", "WriteTileFromAnimBuffer", + "ScrollTileRightLeft", "ScrollTileDown", "ScrollTileUp", + "ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile", + "AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2", + "AnimateTowerPillarTile", "FlickeringCaveEntrancePalette", +} + +local ANIM_BANK = 0x3f +local ANIM_MAX_FRAMES = 32 +local VTILES2 = 0x9000 + +-- How many tiles of each shared strip its animation function can index: +-- whirlpool `and %11` (engine/tilesets/tileset_anims.asm:368), tower pillar's +-- 0..4 offsets table (:334-342), lava `and %011` (:245). +local ANIM_STRIP_FRAMES = { whirlpool = 4, tower = 5, lava = 4 } + +-- The two functions whose `tileframe` argument is a `dw vTiles2 tile, dw +-- frames` pair in bank $3f rather than a VRAM address (:290, :350). +local ANIM_POINTER_KIND = { + AnimateWhirlpoolTile = "whirlpool", + AnimateTowerPillarTile = "tower", +} + +-- One shared frame strip, written once however many tilesets name it: the +-- source tiles stacked into an 8x(n*8) sheet, same shape as water_frames.png. +function RomExtractorGen2:animStrip(strips, name, kind, bank, address) + if not strips[name] then + local count = ANIM_STRIP_FRAMES[kind] + local rel = "tilesets/anim/" .. name .. ".png" + self:write2bpp(self.rom:bytes(bank, address, count * 16), 8, count * 8, rel) + strips[name] = { image = "assets/generated/" .. rel, frames = count } + end + return strips[name] +end + +-- One tileset's `wTilesetAnim` program. _AnimateTileset (engine/tilesets/ +-- tileset_anims.asm:11) runs ONE row per frame and DoneTileAnimation (:48) +-- wraps the index, so the row count IS the frames per pass. +function RomExtractorGen2:readTilesetAnim(address, byAddress, strips) + if not (address and address > 0) then return nil end + local frames = {} + -- A ReadTileToAnimBuffer/ScrollTile*/WriteTileFromAnimBuffer run (:399, :65, + -- :139, :386) scrolls the tileset's OWN tile, so the write row carries how + -- far one pass moves it instead of naming a strip. + local pending = nil + for index = 0, ANIM_MAX_FRAMES - 1 do + local at = address + index * 4 + if at + 3 >= 0x8000 then break end + 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 + pending = nil + end + frames[#frames + 1] = frame + if name == "DoneTileAnimation" then + return { period = #frames, frames = frames } + end + end + return nil +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 +-- index == the TILESET_* constant value, so tilesetOrder[n]'s row starts at +-- headers.address + n*15. GFX is lz3; Meta and Coll are raw. +function RomExtractorGen2:extractTilesets() + self:beginStage("World tiles") + local order = self.manifest.constants.tilesetOrder + local headers = self:symbol("Tilesets") + local imageWidth, imageHeight = 128, 48 + local byteLength = imageWidth * imageHeight / 4 + + -- A cache built from a manifest without the tileset_anims symbols simply + -- resolves nothing here, and every `anim` below comes out nil. + local animByAddress = {} + for _, label in ipairs(ANIM_FUNCTIONS) do + local location = self.symbols[label] + if location and location[1] == ANIM_BANK then + animByAddress[location[2]] = label + end + end + + -- Whirlpool/tower-pillar/lava strips live in bank $3f, not in a tileset, so + -- one table dedupes them across every program that names them. + local animStrips = {} + + local out = {} + for index, constName in ipairs(order) do + local rowAddress = headers.address + index * 15 + local gfxBank = self.rom:byte(headers.bank, rowAddress) + local gfxAddress = self.rom:word(headers.bank, rowAddress + 1) + local metaBank = self.rom:byte(headers.bank, rowAddress + 3) + local metaAddress = self.rom:word(headers.bank, rowAddress + 4) + local collBank = self.rom:byte(headers.bank, rowAddress + 6) + local collAddress = self.rom:word(headers.bank, rowAddress + 7) + local animAddress = self.rom:word(headers.bank, rowAddress + 9) + local palMapAddress = self.rom:word(headers.bank, rowAddress + 13) + + local compressed = self.rom:bytes( + gfxBank, gfxAddress, 0x8000 - gfxAddress) + local pixels = Rom.decompressLz3(compressed) + while #pixels < byteLength do pixels[#pixels + 1] = 0 end + while #pixels > byteLength do table.remove(pixels) end + local base = constName:lower():gsub("^tileset_", "") + self:write2bpp(pixels, imageWidth, imageHeight, "tilesets/" .. base .. ".png") + + local metaRaw = self.rom:bytes( + metaBank, metaAddress, METATILE_COUNT * 16) + local blocks = {} + for offset = 1, #metaRaw, 16 do + local block = {} + for pos = offset, offset + 15 do block[#block + 1] = metaRaw[pos] end + blocks[#blocks + 1] = block + end + + local collRaw = self.rom:bytes( + collBank, collAddress, METATILE_COUNT * 4) + local collision = {} + for offset = 1, #collRaw, 4 do + collision[#collision + 1] = { + collRaw[offset], collRaw[offset + 1], + collRaw[offset + 2], collRaw[offset + 3], + } + end + + out[constName] = { + id = constName, + generation = 2, + source = ("ROM:Tilesets[%d]"):format(index), + header = self.rom:bytes(headers.bank, rowAddress, 15), + image = "assets/generated/tilesets/" .. base .. ".png", + imageWidth = imageWidth, imageHeight = imageHeight, + tilesPerRow = imageWidth / 8, + blocks = blocks, + 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). + tilePalettes = self:readPalMap(palMapAddress), + } + self:tick("World tiles", index, #order) + end + -- The two frame strips every tileset's water/flower step writes from + -- (engine/tilesets/tileset_anims.asm:194 and :225), four 8x8 tiles each, + -- stacked into one 8x32 sheet. BG tiles, so no colour-0 key. + local waterFrames = self.symbols["AnimateWaterTile.WaterTileFrames"] + if waterFrames then + self:write2bpp(self.rom:bytes(waterFrames[1], waterFrames[2], 4 * 16), + 8, 32, "tilesets/water_frames.png") + out.waterFrames = "assets/generated/tilesets/water_frames.png" + end + local flowerFrames = self.symbols["AnimateFlowerTile.FlowerTileFrames"] + if flowerFrames then + self:write2bpp(self.rom:bytes(flowerFrames[1], flowerFrames[2], 4 * 16), + 8, 32, "tilesets/flower_frames.png") + out.flowerFrames = "assets/generated/tilesets/flower_frames.png" + -- dmg_1, cgb_1, dmg_2, cgb_2: `and %10` plus hCGB picks rows 2 and 4 + -- (tileset_anims.asm:204-212). + out.flowerCgbFrames = { 2, 4 } + end + -- Every shared strip the programs above named, for tests and mods; the anim + -- rows themselves already carry their own image path. + out.animFrames = animStrips + self:write("tilesets", out) + return out +end + +function RomExtractorGen2:extractRoofs() + -- Outdoor Johto towns replace VRAM tiles $0a-$12 from Roofs, indexed by + -- MapGroupRoofs[group] (engine/tilesets/mapgroup_roofs.asm). + local roofs = self:symbol("Roofs") + local groupRoofs = self:symbol("MapGroupRoofs") + local out = { generation = 2, roofs = {}, mapGroupRoofs = {} } + -- Five roof sets (ROOF_NEW_BARK .. ROOF_GOLDENROD), 9 tiles each. + local roofNames = { + "NEW_BARK", "VIOLET", "AZALEA", "OLIVINE", "GOLDENROD", + } + for index, name in ipairs(roofNames) do + local addr = roofs.address + (index - 1) * ROOF_TILES * 16 + local pixels = self.rom:bytes(roofs.bank, addr, ROOF_TILES * 16) + local rel = "tilesets/roofs/" .. name:lower() .. ".png" + self:write2bpp(pixels, 72, 8, rel) + out.roofs[name] = { + id = name, index = index - 1, + image = "assets/generated/" .. rel, + } + end + -- MapGroupRoofs: one signed byte per map group (groups are 1-based; the + -- table is indexed by group id, with a leading -1 for group 0). + for group = 1, 26 do + local value = self.rom:byte(groupRoofs.bank, groupRoofs.address + group) + if value < 0x80 and roofNames[value + 1] then + out.mapGroupRoofs[group] = roofNames[value + 1] + end + end + self:write("roofs", out) + return out +end + +local function signedByte(value) + if value >= 0x80 then return value - 0x100 end + return value +end + +local function orderName(list, index, fallback) + if type(list) ~= "table" then return fallback or index end + return list[index] or fallback or index +end + +-- Resolve (group, map) -> const name from the manifest's mapGroups list. +function RomExtractorGen2:mapNameByIds(group, map) + if not self._mapByIds then + self._mapByIds = {} + for _, spec in ipairs(self.manifest.constants.mapGroups or {}) do + self._mapByIds[spec.group * 1000 + spec.map] = spec.name + end + end + return self._mapByIds[group * 1000 + map] +end + +function RomExtractorGen2:readMapGroupEntry(group, map) + local pointers = self:symbol("MapGroupPointers") + local groupPtr = self.rom:word( + pointers.bank, pointers.address + (group - 1) * 2) + local entry = groupPtr + (map - 1) * MAP_LENGTH + local bank = pointers.bank + return { + attributesBank = self.rom:byte(bank, entry), + tileset = self.rom:byte(bank, entry + 1), + environment = self.rom:byte(bank, entry + 2), + attributesAddress = self.rom:word(bank, entry + 3), + landmark = self.rom:byte(bank, entry + 5), + music = self.rom:byte(bank, entry + 6), + phoneAndPalette = self.rom:byte(bank, entry + 7), + fishGroup = self.rom:byte(bank, entry + 8), + } +end + +function RomExtractorGen2:readConnections(bank, address, flags) + local connections = {} + local dirs = { + { bit = CONN_NORTH, key = "north" }, + { bit = CONN_SOUTH, key = "south" }, + { bit = CONN_WEST, key = "west" }, + { bit = CONN_EAST, key = "east" }, + } + local cursor = address + for _, dir in ipairs(dirs) do + if bit.band(flags, dir.bit) ~= 0 then + local group = self.rom:byte(bank, cursor) + local map = self.rom:byte(bank, cursor + 1) + local yOffset = signedByte(self.rom:byte(bank, cursor + 8)) + local xOffset = signedByte(self.rom:byte(bank, cursor + 9)) + local offset + if dir.key == "north" or dir.key == "south" then + offset = -math.floor(xOffset / 2) + else + offset = -math.floor(yOffset / 2) + end + -- Avoid Lua's signed-zero (-0) from -math.floor(0/2). + if offset == 0 then offset = 0 end + connections[dir.key] = { + group = group, map = map, + mapId = self:mapNameByIds(group, map), + stripLength = self.rom:byte(bank, cursor + 6), + width = self.rom:byte(bank, cursor + 7), + yOffset = yOffset, xOffset = xOffset, + offset = offset, + } + cursor = cursor + CONNECTION_LENGTH + end + end + return connections +end + +function RomExtractorGen2:readMapEvents(bank, address, spriteOrder) + -- *_MapEvents always starts with `db 0, 0 ; filler`. + local cursor = address + 2 + local warpCount = self.rom:byte(bank, cursor) + cursor = cursor + 1 + local warps = {} + for i = 1, warpCount do + local y = self.rom:byte(bank, cursor) + local x = self.rom:byte(bank, cursor + 1) + local destWarp = self.rom:byte(bank, cursor + 2) + local destGroup = self.rom:byte(bank, cursor + 3) + local destMap = self.rom:byte(bank, cursor + 4) + warps[i] = { + x = x, y = y, destWarp = destWarp, + destGroup = destGroup, destMapNum = destMap, + destMap = self:mapNameByIds(destGroup, destMap), + } + cursor = cursor + WARP_LENGTH + end + + local coordCount = self.rom:byte(bank, cursor) + cursor = cursor + 1 + local coordEvents = {} + for i = 1, coordCount do + coordEvents[i] = { + sceneId = self.rom:byte(bank, cursor), + y = self.rom:byte(bank, cursor + 1), + x = self.rom:byte(bank, cursor + 2), + script = self.rom:word(bank, cursor + 4), + } + cursor = cursor + COORD_LENGTH + end + + local bgCount = self.rom:byte(bank, cursor) + cursor = cursor + 1 + local bgEvents = {} + for i = 1, bgCount do + local kind = self.rom:byte(bank, cursor + 2) + local pointer = self.rom:word(bank, cursor + 3) + local ev = { + y = self.rom:byte(bank, cursor), + x = self.rom:byte(bank, cursor + 1), + kind = kind, + script = pointer, + } + -- BGEVENT_IFSET (5) and BGEVENT_IFNOTSET (6) do not point at a script. + -- They point at a `conditional_event` (dw event / dba script): two bytes of + -- EVENT id followed by a three-byte far pointer to the script proper. + -- `.ifset` / `.ifnotset` in engine/overworld/events.asm read the flag, and + -- only then `inc hl / inc hl / GetFarWord` to reach the real thing. + -- + -- Extracted as a plain script pointer, those five bytes were disassembled + -- as commands and produced nonsense -- TeamRocketBaseB3F's locked door came + -- out as a lone `sjump` to an unrelated address. Nothing could run it, so + -- Giovanni's door never opened and the whole Rocket hideout dead-ended. + -- `MACRO conditional_event` is `dw \1, \2` -- two WORDS, the event then + -- the script address, both in the map's own bank. (Not a `dba`: the + -- handler's `inc hl / inc hl / GetFarWord` skips one word and reads the + -- next, and GetMapScriptsBank supplies the bank.) + if kind == 5 or kind == 6 then + ev.event = self.rom:word(bank, pointer) + ev.script = self.rom:word(bank, pointer + 2) + end + bgEvents[i] = ev + cursor = cursor + BG_LENGTH + end + + local objectCount = self.rom:byte(bank, cursor) + cursor = cursor + 1 + local objects = {} + for i = 1, objectCount do + local spriteId = self.rom:byte(bank, cursor) + local y = self.rom:byte(bank, cursor + 1) - 4 + local x = self.rom:byte(bank, cursor + 2) - 4 + local movement = self.rom:byte(bank, cursor + 3) + local radius = self.rom:byte(bank, cursor + 4) + local hour1 = signedByte(self.rom:byte(bank, cursor + 5)) + local hour2 = signedByte(self.rom:byte(bank, cursor + 6)) + local palType = self.rom:byte(bank, cursor + 7) + local sight = self.rom:byte(bank, cursor + 8) + local script = self.rom:word(bank, cursor + 9) + local eventFlag = self.rom:word(bank, cursor + 11) + objects[i] = { + index = i, + spriteId = spriteId, + sprite = orderName(spriteOrder, spriteId), + x = x, y = y, + movement = movement, + radius = { y = math.floor(radius / 16), x = radius % 16 }, + hours = { hour1, hour2 }, + palette = math.floor(palType / 16), + type = palType % 16, + sight = sight, + script = script, + eventFlag = eventFlag == 0xFFFF and nil or eventFlag, + } + cursor = cursor + OBJECT_LENGTH + end + + return { + warps = warps, coordEvents = coordEvents, + bgEvents = bgEvents, objects = objects, + } +end + +function RomExtractorGen2:extractMaps() + self:beginStage("Maps") + local consts = self.manifest.constants + local mapOrder = consts.mapOrder + local tilesetOrder = consts.tilesetOrder + local envOrder = consts.environmentOrder + local paletteOrder = consts.paletteOrder + local fishOrder = consts.fishGroupOrder + local spriteOrder = consts.spriteOrder + + local out = {} + for index, name in ipairs(mapOrder) do + local spec = self.manifest.maps[name] + local entry = self:readMapGroupEntry(spec.group, spec.map) + local attrBank = entry.attributesBank + local attrAddr = entry.attributesAddress + + local border = self.rom:byte(attrBank, attrAddr) + local height = self.rom:byte(attrBank, attrAddr + 1) + local width = self.rom:byte(attrBank, attrAddr + 2) + assert(width == spec.width and height == spec.height, + name .. ": attributes dims mismatch manifest") + local blocksBank = self.rom:byte(attrBank, attrAddr + 3) + local blocksAddr = self.rom:word(attrBank, attrAddr + 4) + local eventsBank = self.rom:byte(attrBank, attrAddr + 6) + local scriptsAddr = self.rom:word(attrBank, attrAddr + 7) + local eventsAddr = self.rom:word(attrBank, attrAddr + 9) + local connFlags = self.rom:byte(attrBank, attrAddr + 11) + + local blocks = self.rom:bytes( + blocksBank, blocksAddr, width * height) + local connections = self:readConnections( + attrBank, attrAddr + ATTR_LENGTH, connFlags) + local events = self:readMapEvents(eventsBank, eventsAddr, spriteOrder) + + local phonePalette = entry.phoneAndPalette + -- Map scripts header (macros/scripts/maps.asm): db scene_count; + -- scene_script {dw script, dw filler} × N; db callback_count; + -- callback {db type, dw script} × M. Scene 0 runs on map enter (Elm + -- walk-up, etc.); the callbacks are what RunMapCallback dispatches on a + -- load -- MAPCALLBACK_TILES repaints blocks, _OBJECTS moves NPCs, + -- _CMDQUEUE refills wCmdQueue (the two stone tables), _SPRITES swaps + -- sheets, _NEWMAP runs once per new game. + local sceneScripts, callbacks = {}, {} + if eventsBank and eventsBank > 0 and scriptsAddr and scriptsAddr >= 0x4000 then + local okCount, sceneCount = pcall( + self.rom.byte, self.rom, eventsBank, scriptsAddr) + if okCount and sceneCount and sceneCount < 32 then + for si = 0, sceneCount - 1 do + local addr = scriptsAddr + 1 + si * 4 + local okSc, script = pcall(self.rom.word, self.rom, eventsBank, addr) + if okSc and script and script ~= 0 then + sceneScripts[si] = { + sceneId = si, + script = script, + scriptKey = Opcodes.key(eventsBank, script), + } + end + end + local cbBase = scriptsAddr + 1 + sceneCount * 4 + local okCb, cbCount = pcall(self.rom.byte, self.rom, eventsBank, cbBase) + if okCb and cbCount and cbCount <= NUM_MAPCALLBACK_TYPES then + for ci = 0, cbCount - 1 do + local row = cbBase + 1 + ci * 3 + local okType, kind = pcall(self.rom.byte, self.rom, eventsBank, row) + local okAddr, script = pcall( + self.rom.word, self.rom, eventsBank, row + 1) + if okType and okAddr and script and script ~= 0 then + -- MAPCALLBACK_* is `const_def 1` and mapCallbackOrder carries a + -- placeholder in front of it, so a type byte lands on Lua index + -- byte + 1 like every other 0-based order in this file. + local name = orderName(consts.mapCallbackOrder, kind + 1) + callbacks[#callbacks + 1] = { + type = kind, + callback = name, + script = script, + scriptKey = Opcodes.key(eventsBank, script), + } + end + end + end + end + end + + out[name] = { + id = name, + generation = 2, + group = spec.group, map = spec.map, + width = width, height = height, + borderBlock = border, + tileset = orderName(tilesetOrder, entry.tileset), + tilesetId = entry.tileset, + environment = orderName(envOrder, entry.environment), + environmentId = entry.environment, + landmark = entry.landmark, + music = entry.music, + -- GetMapPhoneService (home/map.asm): the attribute byte's HIGH nybble, + -- and every caller tests `and a` -- ZERO means the map HAS service, so + -- the boolean is the nybble's emptiness. Caves and Kanto's dead zones + -- carry a non-zero nybble; towns and routes carry zero. + phoneService = math.floor(phonePalette / 16) == 0, + palette = orderName(paletteOrder, phonePalette % 16 + 1) + or (phonePalette % 16), + fishGroup = orderName(fishOrder, entry.fishGroup + 1) + or entry.fishGroup, + blocks = blocks, + -- Where those blocks live in ROM. `changemapblocks` hands the VM a raw + -- bank/pointer into blockdata and nothing else in the cache is keyed by + -- one, so World:blockdataAt places the pointer by walking these. + blockdata = { bank = blocksBank, address = blocksAddr }, + connections = connections, + warps = events.warps, + coordEvents = events.coordEvents, + bgEvents = events.bgEvents, + objects = events.objects, + sceneScripts = sceneScripts, + callbacks = callbacks, + scripts = { bank = eventsBank, address = scriptsAddr }, + events = { bank = eventsBank, address = eventsAddr }, + source = ("ROM:MapGroupPointers[%d][%d]"):format(spec.group, spec.map), + } + self:tick("Maps", index, #mapOrder) + end + + self:extractRoofs() + self:write("maps", out) + return out +end + +-- OverworldSprites rows are NUM_SPRITEDATA_FIELDS (6) bytes each +-- (data/sprites/sprites.asm): dw addr, db length, bank, type, palette. +-- Length is already in bytes (`N tiles` in RGBDS). WALKING_SPRITE sheets +-- store standing + walking halves back-to-back (see ChrisSpriteGFX + 12 +-- tiles), so ROM length is size*2; STANDING/STILL use size as-is. Layout +-- matches Gen 1's 16-wide strips that SpriteRenderer already understands +-- (stand down/up/left, walk down/up/left; right = X-flip). +-- +-- Only the first `numOverworldSprites` ids are rows of that table; the ids from +-- SPRITE_POKEMON on are SpriteMons rows instead (extractMonSprites below), and +-- reading them out of OverworldSprites would decode whatever data follows it. +function RomExtractorGen2:extractSprites() + self:beginStage("Overworld sprites") + local consts = self.manifest.constants + local order = consts.spriteOrder + local rows = consts.numOverworldSprites or #order + local table = self:symbol("OverworldSprites") + local out, written = {}, {} + for index = 1, rows do + local constName = order[index] + local rowAddr = table.address + (index - 1) * SPRITEDATA_LENGTH + local pointer = self.rom:word(table.bank, rowAddr) + local sizeBytes = self.rom:byte(table.bank, rowAddr + 2) + local bank = self.rom:byte(table.bank, rowAddr + 3) + local spriteType = self.rom:byte(table.bank, rowAddr + 4) + local palette = self.rom:byte(table.bank, rowAddr + 5) + local byteLength = sizeBytes + if spriteType == WALKING_SPRITE then + byteLength = sizeBytes * 2 + end + local width = 16 + assert(byteLength > 0 and byteLength % 16 == 0, + constName .. ": sprite length not tile-aligned") + local height = byteLength * 4 / width + assert(height % 16 == 0, constName .. ": sprite height not frame-aligned") + local frames = height / 16 + local base = constName:lower():gsub("^sprite_", "") + if not written[base] then + self:write2bpp(self.rom:bytes(bank, pointer, byteLength), + width, height, "sprites/" .. base .. ".png", true) + written[base] = true + end + out[constName] = { + id = constName, + source = ("ROM:OverworldSprites[%d]"):format(index - 1), + image = "assets/generated/sprites/" .. base .. ".png", + frames = frames, + walker = spriteType == WALKING_SPRITE or frames >= 6, + spriteType = SPRITE_TYPE_NAME[spriteType] or spriteType, + palette = SPRITE_PALETTE_NAME[palette] or palette, + paletteId = palette, + } + self:tick("Overworld sprites", index, #order) + end + self:extractMonSprites(out) + self:write("sprites", out) + return out +end + +-- The mon-doll half of the sprite ids (data/sprites/sprite_mons.asm). +-- +-- SpriteMons is a `table_width 1` list of species, one per id from +-- SPRITE_POKEMON ($80) up, and GetMonSprite (engine/overworld/overworld.asm) +-- is what makes it a sprite: its .Icon arm subtracts SPRITE_POKEMON, reads the +-- species out of SpriteMons and hands it to LoadOverworldMonIcon, which is +-- ReadMonMenuIcon + IconPointers -- i.e. the mon's PARTY MENU icon, eight +-- tiles, no sheet of its own. So the rows here point at the icon sheets +-- extractIcons already writes rather than at a second copy of them. +-- +-- One frame, not two. _DoesSpriteHaveFacings sends everything from +-- SPRITE_POKEMON up to .only_down, and a doll is a still object that never +-- steps, so the only OAM set it ever uses is FacingStepDown0 -- tiles $00..$03, +-- the icon's FIRST frame (data/sprites/facings.asm). The second frame is the +-- party menu's bob and never reaches the map. +-- +-- Palette 0 because _GetSpritePalette answers `xor a` for every mon sprite, +-- which is PAL_OW_RED in the MapObjectPals set. +function RomExtractorGen2:extractMonSprites(out) + local consts = self.manifest.constants + local order = consts.spriteOrder + local first = consts.spritePokemon + if not first or first > #order then return out end + local spriteMons = self:symbol("SpriteMons") + local monIcons = self:symbol("MonMenuIcons") + local iconOrder = consts.iconOrder or {} + local speciesOrder = consts.speciesOrder or {} + for index = first, #order do + local constName = order[index] + if constName and constName ~= "UNUSED" then + local row = index - first + local species = self.rom:byte(spriteMons.bank, spriteMons.address + row) + -- MonMenuIcons is 0-based on species-1, the same shift extractIcons uses. + local iconId = self.rom:byte( + monIcons.bank, monIcons.address + (species - 1)) + local icon = iconOrder[iconId + 1] + assert(icon, constName .. ": no ICON_* name for icon " .. iconId) + local base = icon:lower():gsub("^icon_", "") + out[constName] = { + id = constName, + source = ("ROM:SpriteMons[%d]"):format(row), + image = "assets/generated/icons/gen2/" .. base .. ".png", + frames = 1, + walker = false, + spriteType = "POKEMON_SPRITE", + palette = SPRITE_PALETTE_NAME[0], + paletteId = 0, + species = speciesOrder[species], + icon = icon, + } + end + self:tick("Overworld sprites", index, #order) + end + return out +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, +-- and each row's own first byte repeats that same dex number). Unown's base +-- data is read like everything else; only its PICS are special -- there is no +-- PokemonPicPointers row for it, because the 26 letters come out of +-- UnownPicPointers instead, and letter A's pics stand in for the species. +-- EvosAttacks: a per-species blob of 3-byte evolution rows terminated by 0, +-- then (level, move) pairs terminated by 0. Both halves are variable length, +-- so there is no row stride to index -- the terminators are the structure. +-- +-- EVOLVE_STAT's second byte is a level and its *third* is the ATK_*_DEF +-- comparison, which is why that method takes four bytes where the rest take +-- three (data/pokemon/evos_attacks.asm's header comment). +local EVOLVE_LEVEL, EVOLVE_ITEM = 1, 2 +local EVOLVE_TRADE, EVOLVE_HAPPINESS, EVOLVE_STAT = 3, 4, 5 +-- EVOLVE_HAPPINESS parameter (constants/pokemon_data_constants.asm TR_*). +-- Both of these blocks are `const_def 1`, so the stored byte is 1-based and +-- the table must be too: a [0]-based one shifts every row down and drops the +-- last to nil, which silently made TR_NITE and ATK_EQ_DEF unreachable. +local TR_NAMES = { "ANYTIME", "MORNDAY", "NITE" } +-- EVOLVE_STAT comparison. The ROM order is GT, LT, EQ -- not the order the +-- method reads in, which is the easy way to get this pair backwards. +local ATK_NAMES = { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" } + +function RomExtractorGen2:readEvosAttacks(bank, address) + local moveOrder = self.manifest.constants.moveOrder or {} + local itemOrder = self.manifest.constants.itemOrder or {} + local evolutions, levelMoves = {}, {} + local pc = address + for _ = 1, 16 do + local method = self.rom:byte(bank, pc) + if method == 0 then pc = pc + 1 break end + if method == EVOLVE_STAT then + evolutions[#evolutions + 1] = { + method = "EVOLVE_STAT", + level = self.rom:byte(bank, pc + 1), + comparison = ATK_NAMES[self.rom:byte(bank, pc + 2)], + into = self:speciesName(self.rom:byte(bank, pc + 3)), + } + pc = pc + 4 + else + local parameter = self.rom:byte(bank, pc + 1) + local into = self:speciesName(self.rom:byte(bank, pc + 2)) + local row = { into = into } + if method == EVOLVE_LEVEL then + row.method, row.level = "EVOLVE_LEVEL", parameter + elseif method == EVOLVE_ITEM then + row.method, row.item = "EVOLVE_ITEM", itemOrder[parameter] + elseif method == EVOLVE_TRADE then + row.method = "EVOLVE_TRADE" + -- -1 means "no held item required". + row.item = (parameter ~= 0xff) and itemOrder[parameter] or nil + elseif method == EVOLVE_HAPPINESS then + row.method, row.time = "EVOLVE_HAPPINESS", TR_NAMES[parameter] + else + row.method, row.parameter = method, parameter + end + evolutions[#evolutions + 1] = row + pc = pc + 3 + end + end + for _ = 1, 64 do + local level = self.rom:byte(bank, pc) + if level == 0 then break end + local move = self.rom:byte(bank, pc + 1) + levelMoves[#levelMoves + 1] = { + level = level, + move = moveOrder[move] or move, + } + pc = pc + 2 + end + return evolutions, levelMoves +end + +function RomExtractorGen2:extractPokemon() + self:beginStage("Pokemon") + local speciesOrder = self.manifest.constants.speciesOrder + local typeById = {} + for name, value in pairs(self.manifest.constants.types) do typeById[value] = name end + local baseData = self:symbol("BaseData") + local names = self:symbol("PokemonNames") + local evos = self:symbol("EvosAttacksPointers") + local growthRates = self:symbol("GrowthRates") + local tmhmMoves = self:symbol("TMHMMoves") + local eggMovePointers = self.symbols["EggMovePointers"] + and self:symbol("EggMovePointers") or nil + local growthOrder = self.manifest.constants.growthRateOrder or {} + local eggGroupOrder = self.manifest.constants.eggGroupOrder or {} + local moveOrder = self.manifest.constants.moveOrder or {} + local itemOrder = self.manifest.constants.itemOrder or {} + + -- 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 = {} + for i = 0, 63 do + local moveId = self.rom:byte(tmhmMoves.bank, tmhmMoves.address + i) + if moveId == 0 then break end + tmhmList[i + 1] = moveOrder[moveId] or moveId + end + + -- GrowthRates rows: dn numerator, denominator; then the n^2, n and constant + -- terms, with a $80 sign bit on the n^2 term (data/growth_rates.asm). + local growth = {} + for index, name in ipairs(growthOrder) do + local base = growthRates.address + (index - 1) * 4 + local packed = self.rom:byte(growthRates.bank, base) + local squared = self.rom:byte(growthRates.bank, base + 1) + local negative = squared >= 0x80 + growth[name] = { + id = name, index = index - 1, + numerator = math.floor(packed / 16), + denominator = packed % 16, + squared = negative and -(squared - 0x80) or squared, + linear = self.rom:byte(growthRates.bank, base + 2), + constant = self.rom:byte(growthRates.bank, base + 3), + } + end + + local out = { growthRates = growth, tmhmMoves = tmhmList } + for index, species in ipairs(speciesOrder) do + if species then + local row = self.rom:bytes( + baseData.bank, baseData.address + (index - 1) * 32, 32) + assert(row[1] == index, species .. ": base data dex mismatch") + local name = self.rom:decodeText( + self.rom:bytes(names.bank, names.address + (index - 1) * 10, 10), + self.manifest.charmap) + + local asset = self.manifest.pokemonAssets[species] + local tiles = row[18] % 16 -- BASE_PIC_SIZE low nibble, tiles wide/tall + local front, back + if asset and asset.frontLabel then + self:writeCompressedPic(asset.frontLabel, tiles, + "battle/front/" .. asset.front .. ".png") + front = asset.front + end + if asset and asset.backLabel then + -- Back pics are ALWAYS 6x6 tiles (48x48). BASE_PIC_SIZE's low nibble + -- describes the *front* pic only -- Cyndaquil's front is 5x5 and Onix's + -- 7x7, but both backs are 48x48 (gfx/pokemon/*/back.png). Decoding a + -- back at the front's size reads the wrong number of tiles and lays + -- them out in the wrong number of columns, which comes out as garbage. + self:writeCompressedPic(asset.backLabel, BACK_PIC_TILES, + "battle/back/" .. asset.back .. ".png") + back = asset.back + end + + -- EvosAttacksPointers is `table_width 2`, i.e. plain `dw` pointers, and + -- the blobs sit in the same bank as the table (main.asm's "Evolutions + -- and Attacks" section) -- there is no bank byte to read. + local evoAddress = self.rom:word( + evos.bank, evos.address + (index - 1) * 2) + local evolutions, levelMoves = self:readEvosAttacks(evos.bank, evoAddress) + + -- Egg moves (data/pokemon/egg_moves.asm): EggMovePointers is a `dw` per + -- species into its OWN bank -- GetEggMove reads the pointer with + -- BANK(EggMovePointers) and then the list with BANK("Egg Moves"), and on + -- Gold those are the same bank. A species with none points at + -- NoEggMoves, which is a bare `db -1`, so it comes out as an empty list + -- rather than as nil: "this was extracted and there are none" is a + -- different claim from "this was never extracted". + local eggMoves = {} + if eggMovePointers then + local listAddr = self.rom:word( + eggMovePointers.bank, eggMovePointers.address + (index - 1) * 2) + for offset = 0, 15 do + local move = self.rom:byte(eggMovePointers.bank, listAddr + offset) + if move == 0xff then break end + eggMoves[#eggMoves + 1] = moveOrder[move] or move + end + end + + -- BASE_TMHM is (NUM_TM_HM + 7) / 8 bytes, bit i of byte n meaning + -- 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 = {} + 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 + end + end + end + + out[species] = { + id = species, index = index, dex = index, name = name, + source = ("ROM:BaseData[%d]"):format(index), + baseStats = { + hp = row[2], attack = row[3], defense = row[4], speed = row[5], + specialAttack = row[6], specialDefense = row[7], + }, + types = { typeById[row[8]] or row[8], typeById[row[9]] or row[9] }, + catchRate = row[10], baseExp = row[11], + items = { itemOrder[row[12]], itemOrder[row[13]] }, + -- Gender ratio is a raw threshold: a DV roll under it is female, so + -- GENDER_F0 = 0 is male-only and $fe (GENDER_F100) female-only. + genderRatio = row[14], + eggSteps = row[16], + picSize = row[18] % 16, + growthRateId = row[23], + growthRate = growthOrder[row[23] + 1], + -- One nibble per egg group (dn EGG_x, EGG_y). + eggGroups = { + eggGroupOrder[math.floor(row[24] / 16)], + eggGroupOrder[row[24] % 16], + }, + eggGroupsRaw = row[24], + tmhmRaw = tmhmRaw, + tmhm = tmhm, + 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, + } + end + self:tick("Pokemon", index, #speciesOrder) + end + -- UnownPicPointers: 26 rows of `dba_pics front, back` -- a three-byte far + -- pointer each -- one per letter. PokemonPicPointers' UNOWN row is only + -- the A form, so without this table the other twenty-five letters have no + -- pic at all. Both pics are the usual lz3 column-major blobs. + if self.symbols["UnownPicPointers"] and out.UNOWN then + local symbol = self:symbol("UnownPicPointers") + local letters = {} + local tiles = out.UNOWN.picSize or 6 + for index = 0, 25 do + local letter = string.char(string.byte("A") + index) + local base = symbol.address + index * 6 + local entry = {} + 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 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() + -- GetLZByte bumps the bank and drops back to $4000 when the read + -- pointer passes $8000, so a pic near the top of its bank keeps + -- going into the next one. + local compressed = self.rom:bytes(bank, address, 0x8000 - address) + local nextBank = self.rom:bytes(bank + 1, 0x4000, 0x4000) + for _, byte in ipairs(nextBank) do + compressed[#compressed + 1] = byte + end + local pixels = Rom.decompressLz3(compressed) + 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 + pixels = ImageWriter.columnsToRows(pixels, size, size) + self:write2bpp(pixels, pixelSize, pixelSize, rel) + end) + if ok then + entry[key] = "assets/generated/" .. rel + else + self:trace(("unown %s %s: %s"):format(letter, key, tostring(err))) + end + end + readPic(0, tiles, "front", "spriteFront") + readPic(3, 6, "back", "spriteBack") + letters[letter] = entry + end + out.UNOWN.letters = letters + -- The species' own pics: letter A, which is what the cart shows when + -- nothing has picked a form yet (GetUnownLetter defaults to it). + out.UNOWN.spriteFront = out.UNOWN.spriteFront + or (letters.A and letters.A.spriteFront) + out.UNOWN.spriteBack = out.UNOWN.spriteBack + or (letters.A and letters.A.spriteBack) + end + + self:write("pokemon", out) + return out +end + +-- Title screen: TitleScreenGFX1 -> vTiles2 (BG ids $00-$7F), GFX2 -> +-- vTiles1 (ids $80+), then LoadTitleScreenTilemap streams +-- TitleScreenTilemap (gfx/title/logo.tilemap) into the BG map until $FF. +-- Same class of trap as Yellow -- never trust a naive atlas stack for the +-- on-screen image (see RomExtractor:extractYellowTitleArt). Ho-Oh is OBJ +-- (TitleScreenGFX4 @ vTiles0); trail tiles (GFX3) are animated OAM. +-- 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() + self:beginStage("Title screen") + + local function tilesFrom2bpp(raw, transparent) + local tiles = {} + for offset = 1, #raw - (#raw % 16), 16 do + local one = {} + for i = offset, offset + 15 do one[#one + 1] = raw[i] end + tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent) + end + return tiles + end + + local function blitSprite(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 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 + + -- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals). + local BG_PALS = { + { { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } }, + { { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } }, + { { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } }, + { { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } }, + { { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } }, + } + -- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown); + -- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail). + local OBJ_HOOH = { + { 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 }, + } + local OBJ_TRAIL = { + { 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 }, + } + + local function palColor(pal, shade) + local c = pal[shade + 1] or pal[4] + return c[1] / 31, c[2] / 31, c[3] / 31, 1 + end + + -- LoadTitleScreenPals' non-CGB branch (engine/movie/title.asm) is what the + -- greyscale set has to be baked through, and Gold's registers are not the + -- identity: rBGP is %11011000, so the BG's colours 1 and 2 come out the + -- OTHER WAY ROUND from the shade the tile stores, and rOBP0 is %11111111 -- + -- all four of Ho-Oh's colours map to shade 3, which is why the bird is a + -- solid BLACK silhouette on a monochrome screen rather than the shaded pose + -- a straight decode gives. rOBP1 (%11111000) carries the gold trail. + local DMG_BGP = { 0, 2, 1, 3 } + local DMG_OBP0 = { 3, 3, 3, 3 } + local DMG_OBP1 = { 0, 2, 3, 3 } + -- ImageWriter's four hardware shades, by shade number. + local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 } + + -- Re-shade a decoded image through one of those registers, keeping whatever + -- alpha the decode gave it (an OBJ's colour 0 is transparent on hardware, so + -- it never reaches a palette register at all). + local function throughRegister(image, register) + 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 v = DMG_SHADE[register[shadeOf(r) + 1] + 1] + out:setPixel(x, y, v, v, v, 1) + end + end + end + return out + end + + -- FillTitleScreenPals (engine/movie/title.asm): pal 1 on rows 0-6, + -- pal 3 on the GOLD VERSION strip (row 6, cols 5-14), pal 4 on rows 12+. + local function bgPalAt(col, row) + if row >= 12 then return 4 end + if row == 6 and col >= 5 and col <= 14 then return 3 end + if row <= 6 then return 1 end + return 0 + end + + local function colorize(image, palFor) + local w, h = image:getDimensions() + local out = ImageWriter.blank(w, h, 0, 0, 0, 1) + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, g, b, a = image:getPixel(x, y) + if a == 0 then + out:setPixel(x, y, 0, 0, 0, 0) + else + local pal = palFor(x, y) + local cr, cg, cb = palColor(pal, shadeOf(r)) + out:setPixel(x, y, cr, cg, cb, 1) + end + end + end + return out + end + + local vtiles2 = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX1")) + local vtiles1 = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX2")) + self:tick("Title screen", 1, 5) + + local function tileFor(id) + if id < 0x80 then return vtiles2[id + 1] end + return vtiles1[id - 0x80 + 1] + end + + -- TILEMAP_WIDTH = 32; visible screen is 20x18. Bytes stream linearly + -- into vBGMap until the $FF terminator (engine/movie/title.asm). + local map = self:symbol("TitleScreenTilemap") + local screen = ImageWriter.blank(160, 144, 1, 1, 1, 1) + local index = 0 + while true do + local id = self.rom:byte(map.bank, map.address + index) + if id == 0xFF then break end + local col = index % 32 + local row = math.floor(index / 32) + index = index + 1 + if col < 20 and row < 18 then + local tile = tileFor(id) + if tile then ImageWriter.blit(screen, tile, col * 8, row * 8) end + end + end + + local colored = colorize(screen, function(x, y) + return BG_PALS[bgPalAt(math.floor(x / 8), math.floor(y / 8)) + 1] + end) + self:save(colored, "title/title_screen.png") + -- ...and the same composition BEFORE colorize. Unlike every other Gen 2 + -- sheet the title's are baked with their colours in, because the screen is + -- one tilemap with a hand-written per-region palette map rather than a + -- tileset the renderer can colour per tile. That leaves the COLOR option + -- nothing to substitute, so the grey source is kept alongside: under DMG and + -- CLASSIC it IS the picture, and under GBC it is simply unused. It goes + -- through rBGP rather than out of the decoder raw, or the logo's two middle + -- shades read inverted against the colour sheet. + local screenGray = throughRegister(screen, DMG_BGP) + self:save(screenGray, "title/title_screen_gray.png") + -- Cloud band for ScrollTitleScreenClouds (rows 11-16). + local clouds = ImageWriter.blank(160, 48, 0, 0, 0, 1) + ImageWriter.blit(clouds, colored, 0, 0, 0, 88, 160, 48) + self:save(clouds, "title/clouds.png") + local cloudsGray = ImageWriter.blank(160, 48, 0, 0, 0, 1) + ImageWriter.blit(cloudsGray, screenGray, 0, 0, 0, 88, 160, 48) + self:save(cloudsGray, "title/clouds_gray.png") + -- Logo strip (rows 0-6): readiness marker. + local logo = ImageWriter.blank(160, 56, 0, 0, 0, 1) + ImageWriter.blit(logo, colored, 0, 0, 0, 0, 160, 56) + self:save(logo, "title/pokemon_logo.png") + self:tick("Title screen", 2, 5) + + -- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm). + local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true) + local HOOH_FRAMES = { + { -- 1 + { -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 }, + { -2, -3, 0, 0, 0x06 }, { -2, -1, 0, 0, 0x08 }, { -2, 1, 0, 0, 0x0a }, + { -1, -3, 0, 0, 0x0c }, { -1, -1, 0, 0, 0x0e }, { -1, 1, 0, 0, 0x10 }, + { 0, -3, 0, 0, 0x12 }, { 0, -1, 0, 0, 0x14 }, { 0, 1, 0, 0, 0x16 }, + { 1, -3, 0, 0, 0x18 }, { 1, -1, 0, 0, 0x1a }, { 1, 1, 0, 0, 0x1c }, + { 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 }, + { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 }, + }, + { -- 2 + { -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 }, + { -2, -1, 0, 0, 0x26 }, { -2, 1, 0, 0, 0x0a }, + { -1, -3, 0, 0, 0x28 }, { -1, -1, 0, 0, 0x2a }, { -1, 1, 0, 0, 0x10 }, + { 0, -1, 0, 0, 0x2c }, { 0, 1, 0, 0, 0x16 }, + { 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c }, + { 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 }, + { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 }, + }, + { -- 3 + { -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x32 }, + { -2, -1, 0, 0, 0x34 }, { -2, 1, 0, 0, 0x36 }, + { -1, -1, 0, 0, 0x38 }, { -1, 1, 0, 0, 0x3a }, + { 0, -1, 0, 0, 0x3c }, { 0, 1, 0, 0, 0x3e }, + { 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c }, + { 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 }, + { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 }, + }, + { -- 4 + { -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 }, + { -2, -1, 0, 0, 0x40 }, { -2, 1, 0, 0, 0x42 }, { -2, 3, 0, 0, 0x44 }, + { -1, -1, 0, 0, 0x46 }, { -1, 1, 0, 0, 0x48 }, { -1, 3, 0, 0, 0x4a }, + { 0, -1, 0, 0, 0x4c }, { 0, 1, 0, 0, 0x4e }, + { 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c }, + { 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 }, + { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 }, + }, + { -- 5 + { -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 }, + { -2, -1, 0, 0, 0x50 }, { -2, 1, 0, 0, 0x0a }, + { -1, -3, 0, 0, 0x52 }, { -1, -1, 0, 0, 0x54 }, { -1, 1, 0, 0, 0x10 }, + { 0, -3, 0, 0, 0x56 }, { 0, -1, 0, 0, 0x2e }, { 0, 1, 0, 0, 0x16 }, + { 1, -1, 0, 0, 0x30 }, { 1, 1, 0, 0, 0x1c }, + { 2, -1, 0, 0, 0x1e }, { 2, 1, 0, 0, 0x20 }, + { 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 }, + }, + } + -- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations. + local HOOH_SEQUENCE = { + { 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 }, + } + local hoohPaths, hoohGrayPaths = {}, {} + local originX, originY = 32, 24 + for fi, oam in ipairs(HOOH_FRAMES) do + -- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent + -- wherever it falls, so a gap enclosed by the bird shows the sky through + -- exactly like one outside it, and there is no matte to flood-fill. + local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0) + for _, spr in ipairs(oam) do + local px = originX + spr[1] * 8 + spr[3] + local py = originY + spr[2] * 8 + spr[4] + blitSprite(pose, hoohTiles[spr[5] + 1], px, py) + blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8) + end + local tinted = colorize(pose, function() return OBJ_HOOH end) + local rel = ("title/hooh_%d.png"):format(fi) + self:save(tinted, rel) + hoohPaths[fi] = "assets/generated/" .. rel + -- ...and the same pose through rOBP0, which is the black silhouette. + local grayRel = ("title/hooh_%d_gray.png"):format(fi) + self:save(throughRegister(pose, DMG_OBP0), grayRel) + hoohGrayPaths[fi] = "assets/generated/" .. grayRel + if fi == 1 then + -- Readiness marker + older stubs expect hooh.png == frame 1. + self:save(tinted, "title/hooh.png") + end + end + self:tick("Title screen", 3, 5) + + -- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16 + -- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal. + local trailSym = self:symbol("TitleScreenGFX3") + local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16) + local trailTiles = tilesFrom2bpp(trailRaw, true) + local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0) + blitSprite(trail, trailTiles[1], 0, 0) + blitSprite(trail, trailTiles[2], 0, 8) + local trailTint = colorize(trail, function() return OBJ_TRAIL end) + self:save(trailTint, "title/trail.png") + self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png") + self:tick("Title screen", 4, 5) + + -- Copyright splash (data/copyright.asm PlaceString over CopyrightGFX). + local copyright = self:symbol("CopyrightGFX") + local copyRaw = self.rom:bytes(copyright.bank, copyright.address, 30 * 16) + local copyTiles = tilesFrom2bpp(copyRaw) + self:write2bpp(copyRaw, 240, 8, "title/copyright.png") + local copyLines = { + { 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d, + 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a }, + { 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d, + 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72 }, + { 0x60, 0x61, 0x62, 0x63, 0x7a, 0x7b, 0x7c, 0x7d, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x71, 0x72 }, + } + local splash = ImageWriter.blank(160, 144, 1, 1, 1, 1) + for li, line in ipairs(copyLines) do + local y = (7 + (li - 1)) * 8 -- hlcoord 2, 7 + next + local x = 2 * 8 + for _, tid in ipairs(line) do + local tile = copyTiles[tid - 0x60 + 1] + if tile then ImageWriter.blit(splash, tile, x, y) end + x = x + 8 + end + end + self:save(splash, "title/copyright_splash.png") + self:tick("Title screen", 5, 5) + + local data = { + generation = 2, + layout = "gold_title", + source = "ROM:TitleScreenTilemap + TitleScreenGFX1/2/3/4, CopyrightGFX", + screen = "assets/generated/title/title_screen.png", + clouds = "assets/generated/title/clouds.png", + image = "assets/generated/title/pokemon_logo.png", + hooh = "assets/generated/title/hooh.png", + hoohFrames = hoohPaths, + -- The uncoloured set the COLOR option's DMG and CLASSIC modes draw. + screenGray = "assets/generated/title/title_screen_gray.png", + cloudsGray = "assets/generated/title/clouds_gray.png", + hoohFramesGray = hoohGrayPaths, + trailGray = "assets/generated/title/trail_gray.png", + -- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames. + hoohSequence = HOOH_SEQUENCE, + -- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had + -- fallen into both, which is what put Ho-Oh off-centre: + -- * ldpixel's own comment calls its first tile argument the X one and is + -- WRONG. It builds `lb de, arg1 * 8, arg2 * 8`, and + -- _InitSpriteAnimStruct takes "x=e, y=d" -- so arg1 is the Y tile. + -- NamingScreen .InitCursor proves it: it overrides `d` alone to move + -- the cursor two rows up on the box screen. So this is x 88, y 96. + -- * those are OAM coordinates, which are biased; a drawn object sits at + -- (x - 8, y - 16) on screen. + -- The pose canvas holds its own origin at (32, 24), so the sheet's corner + -- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands + -- centred on the screen, 48 to 112. + hoohX = 48, + hoohY = 56, + trail = "assets/generated/title/trail.png", + copyright = "assets/generated/title/copyright.png", + copyrightSplash = "assets/generated/title/copyright_splash.png", + -- ScrollTitleScreenClouds: 1px left every 8 frames (Gold). + cloudScrollEvery = 8, + cloudY = 88, + } + self:write("title", data) + return data +end + +-- The credits roll's graphics (engine/movie/credits.asm). Four things come +-- out of the ROM here, and the shapes are all fixed by the load calls at the +-- top of `Credits::`: +-- +-- CreditsBorderGFX 9 tiles -> vTiles2 $20. DrawCreditsBorder writes four +-- running ids five times across a row, starting at $24 +-- for row 4 and $20 for row 13 -- so tiles 5-8 of the +-- strip are the TOP border and tiles 1-4 the bottom. +-- The 9th, $28, is the tile ConstructCreditsTilemap +-- ByteFills the whole screen with before anything else, +-- which makes it the banner's backdrop. +-- Credits<Mon>GFX 4x4-tile frames stacked: three for Bellossom, Togepi +-- and Elekid, four for Sentret (Credits_LoadBorderGFX's +-- .Frames offsets run to +48 tiles for Sentret alone). +-- pret builds these WITHOUT --columns, so the 2bpp +-- stream is already row-major over the whole sheet and +-- a 32 x (32 * frames) decode is the pret PNG. +-- TheEndGFX 16 tiles, its own "The End" section in gfx/misc.asm, +-- loaded to $40 and placed 8 wide on rows 8 and 9. +-- CreditsPalettes gfx/credits/credits.pal, six four-colour sets. +-- GetCreditsPalette masks the scene with %11, so only +-- the first four are reachable from the script, but all +-- six are written out because the block is one table. +-- +-- Everything is tolerated rather than required: a manifest built before these +-- symbols were listed still imports, it just leaves the credits on the mon +-- icon fallback src/ui/gen2/Credits.lua carries. +local CREDITS_SCENES = { + { species = "BELLOSSOM", label = "CreditsBellossomGFX", frames = 3 }, + { species = "TOGEPI", label = "CreditsTogepiGFX", frames = 3 }, + { species = "ELEKID", label = "CreditsElekidGFX", frames = 3 }, + { species = "SENTRET", label = "CreditsSentretGFX", frames = 4 }, +} +local CREDITS_BORDER_TILES = 9 +local CREDITS_THEEND_TILES = 16 + +function RomExtractorGen2:extractCredits() + self:beginStage("Credits") + local steps = #CREDITS_SCENES + 3 + local data = { + generation = 2, + source = "ROM:CreditsBorderGFX + Credits<Mon>GFX + TheEndGFX" + .. " + CreditsPalettes", + } + + if self.symbols["CreditsBorderGFX"] then + local border = self:symbol("CreditsBorderGFX") + self:write2bpp(self.rom:bytes(border.bank, border.address, + CREDITS_BORDER_TILES * 16), CREDITS_BORDER_TILES * 8, 8, + "credits/border.png") + data.border = "assets/generated/credits/border.png" + data.borderTiles = CREDITS_BORDER_TILES + -- 1-based tile columns of the strip, as DrawCreditsBorder's start ids. + data.borderTopTile = 5 + data.borderBottomTile = 1 + data.borderFillTile = 9 + end + self:tick("Credits", 1, steps) + + if self.symbols["TheEndGFX"] then + local theEnd = self:symbol("TheEndGFX") + self:write2bpp(self.rom:bytes(theEnd.bank, theEnd.address, + CREDITS_THEEND_TILES * 16), 64, 16, "credits/theend.png") + data.theEnd = "assets/generated/credits/theend.png" + -- Credits_TheEnd: hlcoord 6, 8 and 6, 9, eight tiles apiece. + data.theEndX, data.theEndY, data.theEndWidth = 6, 8, 8 + end + self:tick("Credits", 2, steps) + + local scenes = {} + for index, scene in ipairs(CREDITS_SCENES) 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) + local rel = ("credits/%s.png"):format(scene.species:lower()) + self:write2bpp(raw, 32, 32 * scene.frames, rel) + scenes[index] = { + species = scene.species, + image = "assets/generated/" .. rel, + frames = scene.frames, + width = 32, + height = 32, + } + end + self:tick("Credits", 2 + index, steps) + end + if #scenes == #CREDITS_SCENES then data.scenes = scenes end + + if self.symbols["CreditsPalettes"] then + local pal = self:symbol("CreditsPalettes") + local palettes = {} + for set = 0, 5 do + palettes[set + 1] = self:colors(pal.bank, pal.address + set * 8, 4) + end + data.palettes = palettes + end + self:tick("Credits", steps, steps) + + self:write("credits", data) + return data +end + +-- The #DEX diploma's page (engine/events/diploma.asm PlaceDiplomaOnScreen). +-- The certificate is not a text box: the routine decompresses DiplomaGFX into +-- vTiles2 and then CopyBytes' DiplomaPage1Tilemap straight over the whole +-- background before a single string is placed, so the border, the seal and +-- the ribbon are one 20x18 tilemap of cart art. +-- +-- DiplomaGFX gfx/diploma/diploma.2bpp.lz, 112 tiles. pret +-- builds it WITHOUT --columns (gfx/lz.mk only sets +-- LZFLAGS), so the stream is row-major over the 16x7 +-- sheet and a straight 128x56 decode IS the pret PNG. +-- DiplomaPage1Tilemap gfx/diploma/page1.tilemap, exactly SCREEN_AREA +-- bytes of tile ids into that sheet. Page 2 is the +-- Game Boy Printer's second sheet and is not read +-- here, the same way PrintDiploma stays stubbed. +-- DiplomaPalettes gfx/diploma/diploma.pal, eight four-colour sets. +-- _CGB_Diploma (engine/gfx/cgb_layouts.asm) loads all +-- eight and then WipeAttrmap zeroes the attrmap, so +-- set 0 is the one the whole screen actually draws +-- through; the rest are written out because the block +-- is one table. +-- +-- Tolerated rather than required, like credits: a manifest built before these +-- three symbols were listed still imports, it just leaves src/ui/gen2/ +-- Diploma.lua on its placeholder frame. +local DIPLOMA_TILES = 112 +local DIPLOMA_SHEET_TILES = 16 -- tiles per row of the sheet +local DIPLOMA_SCREEN_W, DIPLOMA_SCREEN_H = 20, 18 +local DIPLOMA_PALETTE_SETS = 8 + +function RomExtractorGen2:extractDiploma() + self:beginStage("Diploma") + local data = { + generation = 2, + source = "ROM:DiplomaGFX + DiplomaPage1Tilemap + DiplomaPalettes", + } + + if self.symbols["DiplomaGFX"] then + local pixels = self:decompressLz3Symbol("DiplomaGFX") + local byteLength = DIPLOMA_TILES * 16 + while #pixels < byteLength do pixels[#pixels + 1] = 0 end + while #pixels > byteLength do table.remove(pixels) end + self:write2bpp(pixels, DIPLOMA_SHEET_TILES * 8, + DIPLOMA_TILES / DIPLOMA_SHEET_TILES * 8, "diploma/diploma.png") + data.image = "assets/generated/diploma/diploma.png" + data.tiles = DIPLOMA_TILES + data.sheetTiles = DIPLOMA_SHEET_TILES + end + self:tick("Diploma", 1, 3) + + if self.symbols["DiplomaPage1Tilemap"] then + local sym = self:symbol("DiplomaPage1Tilemap") + local raw = self.rom:bytes(sym.bank, sym.address, + DIPLOMA_SCREEN_W * DIPLOMA_SCREEN_H) + -- Kept flat, in the row-major order CopyBytes writes it, because that is + -- what the drawing side indexes with row * SCREEN_WIDTH + column. + data.page1 = raw + data.width = DIPLOMA_SCREEN_W + data.height = DIPLOMA_SCREEN_H + end + self:tick("Diploma", 2, 3) + + if self.symbols["DiplomaPalettes"] then + local pal = self:symbol("DiplomaPalettes") + local palettes = {} + for set = 0, DIPLOMA_PALETTE_SETS - 1 do + palettes[set + 1] = self:colors(pal.bank, pal.address + set * 8, 4) + end + data.palettes = palettes + end + self:tick("Diploma", 3, 3) + + self:write("diploma", data) + return data +end + +-- The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/). +-- Everything the cable-and-ball sequence draws that is not a frontpic or a +-- mon icon comes out of nine labels at the bottom of that file, and the +-- animation is a BACKGROUND that scrolls plus a handful of OAM objects, so +-- the two halves are extracted differently: +-- +-- TradeGameBoyLZ gfx/trade/game_boy_cable.2bpp.lz, which the +-- Makefile builds as game_boy.2bpp (--remove- +-- duplicates) then link_cable.2bpp run together. It +-- decompresses to 49 tiles into vTiles2 tile $31, and +-- because Gen 2 runs the BG in $8800 mode a BG id of +-- N < $80 IS vTiles2 tile N: the two tilemaps below +-- and the loose cable ids the jumptable ByteFills +-- ($5b, $5d, $5f, $60, $61) all index this one sheet +-- from $31. Written flat with its base tile beside +-- it rather than re-split, since nothing on the +-- drawing side wants the Game Boy and the cable as +-- separate sheets. +-- TradeGameBoyTilemap gfx/trade/game_boy.tilemap, 6x8 ids, stamped at +-- hlcoord 3, 2 in state 0 and hlcoord 10, 6 in +-- state 2 (TradeAnim_CopyTradeGameBoyTilemap). +-- TradeLinkTubeTilemap gfx/trade/link_cable.tilemap, 12x3, stamped at +-- hlcoord 8, 2 by TradeAnim_EnterLinkTube1. +-- +-- The objects are all mirrored quadrants, which is why so few tiles draw so +-- much (data/sprite_anims/oam.asm): +-- +-- TradeBallGFX 6 tiles. Frame 1 is .OAMData_TradePokeBall1: tiles 0 +-- and 1 are the ball's LEFT half and the right half is the +-- same two X-flipped. Frame 2 is .OAMData_MagnetTrainRed, +-- four distinct tiles in a plain 2x2. pret's ball.png is +-- 16x32 because it holds the blank right half of frame 1; +-- the 2bpp is built with --remove-whitespace, so the ROM +-- has 6 tiles and not 8 and this writes them as a strip +-- rather than inventing the two blanks back. +-- TradePoofGFX 12 tiles, three .OAMData_TradePoofBubble frames of 4; +-- each frame is a 2x2 quadrant mirrored into a 32x32 +-- object, so the sheet is pret's own 16x48. +-- TradeCableGFX 2 tiles, one per .OAMData_TradeTubeBulge frame: the +-- bulge that travels inside the tube, one quadrant tile +-- mirrored into 16x16. Named "cable" in gfx/trade/ but +-- loaded at vTiles0 tile $74, which is where the bulge's +-- $12/$13 dictionary offsets land. +-- TradeBubbleGFX 4 tiles, the quadrant of the 32x32 bubble the mon icon +-- rides in (SPRITE_ANIM_OAMSET_TRADEMON_BUBBLE, offset +-- $10 off the $62 dictionary base). +-- TradeArrowRightGFX / TradeArrowLeftGFX +-- one BG tile each, ByteFilled six across at hlcoord 7, 2 +-- of the window map by TradeAnim_PlaceTrademonStatsOnTube- +-- Anim to point the trade the way it is going. Two +-- symbols, one two-tile sheet. +-- +-- Object tiles are written with colour 0 transparent, because that is what +-- colour 0 means to an OBJ; the BG sheets keep it as white. +-- +-- Tolerated rather than required, like credits and the diploma: a manifest +-- built before these labels were listed still imports, it just leaves +-- src/ui/gen2/TradeAnim.lua drawing its placeholder shapes. +local TRADE_BASE_TILE = 0x31 +local TRADE_SCENE_TILES = 49 +local TRADE_SHEET_TILES = 7 -- 49 tiles is exactly 7x7, so nothing is padded +local TRADE_GAMEBOY_W, TRADE_GAMEBOY_H = 6, 8 +local TRADE_TUBE_W, TRADE_TUBE_H = 12, 3 +local TRADE_BALL_TILES = 6 +local TRADE_POOF_TILES = 12 +local TRADE_BULGE_TILES = 2 +local TRADE_BUBBLE_TILES = 4 + +function RomExtractorGen2:extractTrade() + self:beginStage("Trade animation") + local data = { + generation = 2, + source = "ROM:TradeGameBoyLZ + TradeGameBoyTilemap + TradeLinkTubeTilemap" + .. " + TradeBallGFX + TradePoofGFX + TradeCableGFX + TradeBubbleGFX" + .. " + TradeArrowRightGFX + TradeArrowLeftGFX", + } + + if self.symbols["TradeGameBoyLZ"] then + local pixels = self:decompressLz3Symbol("TradeGameBoyLZ") + local byteLength = TRADE_SCENE_TILES * 16 + while #pixels < byteLength do pixels[#pixels + 1] = 0 end + while #pixels > byteLength do table.remove(pixels) end + self:write2bpp(pixels, TRADE_SHEET_TILES * 8, TRADE_SHEET_TILES * 8, + "trade/scene.png") + data.image = "assets/generated/trade/scene.png" + data.tiles = TRADE_SCENE_TILES + data.sheetTiles = TRADE_SHEET_TILES + -- The id a tilemap byte carries, minus this, is the tile's index into + -- the sheet above. + data.baseTile = TRADE_BASE_TILE + end + self:tick("Trade animation", 1, 3) + + -- Both tilemaps stay flat, in the order TradeAnim_CopyBoxFromDEtoHL walks + -- them (row by row), because that is what the drawing side indexes with + -- row * width + column. + local function tilemap(label, width, height) + if not self.symbols[label] then return nil end + local sym = self:symbol(label) + return { + width = width, + height = height, + tiles = self.rom:bytes(sym.bank, sym.address, width * height), + } + end + data.gameBoy = tilemap("TradeGameBoyTilemap", TRADE_GAMEBOY_W, + TRADE_GAMEBOY_H) + data.tube = tilemap("TradeLinkTubeTilemap", TRADE_TUBE_W, TRADE_TUBE_H) + self:tick("Trade animation", 2, 3) + + local function sprite(label, tiles, across, relative) + if not self.symbols[label] then return nil end + local sym = self:symbol(label) + local raw = self.rom:bytes(sym.bank, sym.address, tiles * 16) + self:write2bpp(raw, across * 8, tiles / across * 8, relative, true) + return { image = "assets/generated/" .. relative, tiles = tiles, + sheetTiles = across } + end + data.ball = sprite("TradeBallGFX", TRADE_BALL_TILES, 1, "trade/ball.png") + data.poof = sprite("TradePoofGFX", TRADE_POOF_TILES, 2, "trade/poof.png") + data.bulge = sprite("TradeCableGFX", TRADE_BULGE_TILES, 1, "trade/bulge.png") + data.bubble = sprite("TradeBubbleGFX", TRADE_BUBBLE_TILES, 2, + "trade/bubble.png") + + -- The arrows are two one-tile symbols, kept in the order the port draws + -- them: index 0 sends, index 1 receives. They are background tiles, so + -- colour 0 stays white. + if self.symbols["TradeArrowRightGFX"] and self.symbols["TradeArrowLeftGFX"] + then + local right = self:symbol("TradeArrowRightGFX") + local left = self:symbol("TradeArrowLeftGFX") + local raw = self.rom:bytes(right.bank, right.address, 16) + for _, byte in ipairs(self.rom:bytes(left.bank, left.address, 16)) do + raw[#raw + 1] = byte + end + self:write2bpp(raw, 8, 16, "trade/arrows.png") + data.arrows = { image = "assets/generated/trade/arrows.png", tiles = 2, + sheetTiles = 1 } + end + self:tick("Trade animation", 3, 3) + + self:write("trade", data) + return data +end + +-- Dump Gen 2 audio banks and wire song/wave/drum metadata for ChipSynth's +-- generation-2 channel driver. Banks are NOT Gen 1's {2,8,31}. +function RomExtractorGen2:extractAudio(maps) + self:beginStage("Sound programs") + local bankOrder = { 0x07, 0x33, 0x3a, 0x3b, 0x3c, 0x3d } + local chunks = {} + for index, bank in ipairs(bankOrder) do + local first = Rom.offset(bank, 0x4000) + 1 + chunks[index] = self.rom.data:sub(first, first + 0x3FFF) + self:tick("Sound programs", index, #bankOrder + 2) + end + local CacheFs = require("src.import.CacheFs") + local ok, writeError = CacheFs.write( + "assets/generated/audio/programs.bin", table.concat(chunks)) + if not ok then + error("could not write audio programs: " .. tostring(writeError)) + end + + local musicOrder = self.manifest.constants.musicOrder or {} + local songs = {} + for _, name in ipairs(musicOrder) do + if name ~= "Music_Nothing" and self.symbols[name] then + local loc = self.symbols[name] + songs[name] = { bank = loc[1], address = loc[2], generation = 2 } + end + end + + -- Prefer live Music: dba table when a label is missing from the sym embed. + local musicPtr = self:symbol("Music") + for index, name in ipairs(musicOrder) do + if name ~= "Music_Nothing" and not songs[name] then + local row = self.rom:bytes( + musicPtr.bank, musicPtr.address + (index - 1) * 3, 3) + songs[name] = { + bank = row[1], + address = row[2] + row[3] * 256, + generation = 2, + } + end + end + + local wave = self:symbol("WaveSamples") + local drums = self:symbol("Drumkits") + local mapSongs = {} + if maps then + for mapId, def in pairs(maps) do + local id = def.music + if type(id) == "number" and id > 0 then + local label = musicOrder[id + 1] + if label and songs[label] then + mapSongs[mapId] = label + end + end + end + end + + -- SFX pointer table (audio/sfx_pointers.asm): 3-byte dba per SFX_* id. + local sfxOrder = self.manifest.constants.sfxOrder or {} + local sfxPtr = self:symbol("SFX") + local sfx, fanfares = {}, {} + local FANFARE_NAMES = { + Sfx_CaughtMon = true, Sfx_Item = true, + Sfx_DexFanfare2049 = true, Sfx_DexFanfare5079 = true, + Sfx_DexFanfare80109 = true, Sfx_Fanfare = true, + } + for index, name in ipairs(sfxOrder) do + local row = self.rom:bytes( + sfxPtr.bank, sfxPtr.address + (index - 1) * 3, 3) + local def = { + bank = row[1], + address = row[2] + row[3] * 256, + generation = 2, + } + if FANFARE_NAMES[name] then def.fanfare = true end + sfx[name] = def + end + for name in pairs(FANFARE_NAMES) do + if sfx[name] then fanfares[name] = true end + end + + -- Cry base headers (Cries:) + per-species PokemonCries (dw index,pitch,length). + local cryPtr = self:symbol("Cries") + local cryHeaders = {} + local NUM_CRIES = 68 -- pret NUM_CRIES + for i = 0, NUM_CRIES - 1 do + local row = self.rom:bytes(cryPtr.bank, cryPtr.address + i * 3, 3) + cryHeaders[i] = { + bank = row[1], + address = row[2] + row[3] * 256, + generation = 2, + } + end + local pokeCryPtr = self:symbol("PokemonCries") + local speciesOrder = self.manifest.constants.speciesOrder or {} + local cries = {} + for index, species in ipairs(speciesOrder) do + if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then + local row = self.rom:bytes( + pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6) + local cryIndex = row[1] + row[2] * 256 + local pitch = row[3] + row[4] * 256 + local length = row[5] + row[6] * 256 + local header = cryHeaders[cryIndex] + if header then + cries[species] = { + header = header, + pitch = pitch, + length = length, + } + end + end + end + + local data = { + generation = 2, + runtime = true, + programFile = "assets/generated/audio/programs.bin", + bankOrder = bankOrder, + music = musicPtr, + songs = songs, + musicOrder = musicOrder, + mapSongs = mapSongs, + sfx = sfx, + sfxOrder = sfxOrder, + fanfares = fanfares, + cries = cries, + -- ChipSynth Gen 1 shape: one wave bank key; Gen 2 uses WaveSamples. + waveBanks = { + ["1"] = { bank = wave.bank, address = wave.address, name = "WaveSamples" }, + }, + drumkits = { bank = drums.bank, address = drums.address, name = "Drumkits" }, + source = "canonical Pokemon Gold ROM sound programs (Gen 2 driver)", + } + self:write("audio", data) + self:tick("Sound programs", #bankOrder + 2, #bankOrder + 2) + return data +end + +-- Decode a Gen 2 text stream (macros/scripts/text.asm) into the port's +-- TextBox markers: \n line, \f page, \v scroll, {PLAYER}/{RIVAL}. +-- +-- home/text.asm is TWO interleaved loops and the difference is load bearing: +-- +-- DoTextUntilTerminator reads a COMMAND byte, and $50 there is TX_END -- the +-- whole stream stops. +-- TextCommand_START hands over to PlaceString, which prints CHARACTERS until +-- it meets $50 -- which is `@`, the end of that chunk and nothing more. +-- +-- So the same byte ends a string or ends the text depending on which loop is +-- holding it, and `line "@"` really does sit inside the chunk `text "..."` +-- opened (a `text` macro emits no terminator of its own). Reading $50 as +-- "skip" everywhere works for a map string, which ends on `done` ($57), and +-- runs off the end of any string that ends on `text_end` -- which is every +-- string in data/text/*.asm, i.e. everything the phone and the decorations say. +-- +-- `buffers`, when a caller passes one, collects the WRAM name behind each +-- TX_RAM in the order they appear. The decoded string writes every one of +-- them as the same `{STRBUF}` -- the port has one shared string buffer and +-- src/render/TextBox.lua resolves that single token -- but the cart has six, +-- and a line like the trade intro names two DIFFERENT ones. Recording which +-- is which alongside the text lets a caller fill them in order without +-- changing a marker every screen already reads. +function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers) + local out = {} + local i = 0 + local hops = 0 + -- false = DoTextUntilTerminator, true = inside PlaceString. + local inString = false + while i < 4096 do + local b = self.rom:byte(bank, address + i) + if b == 0x50 then + if not inString then break end -- TX_END + inString = false -- `@`: end of this chunk + elseif b == 0x57 or b == 0x58 then -- DONE / PROMPT, both PlaceString's + break + elseif b == 0x00 then + inString = true -- TX_START + elseif b == 0x16 and not inString then + -- TX_FAR: `db TX_FAR / dw addr / db bank` (macros/scripts/text.asm). + -- The stream CONTINUES at the far address rather than embedding it, and + -- everything after the pointer in this bank is the next string. No map + -- text uses one -- the 620 that do are engine strings, which is why this + -- only started mattering once the extractor reached banks $09 and $41. + local farAddr = self.rom:word(bank, address + i + 1) + local farBank = self.rom:byte(bank, address + i + 3) + hops = hops + 1 + if hops > 8 or farBank == 0 or farBank > 0x7f + or farAddr < 0x4000 or farAddr >= 0x8000 then + break + end + -- The loop's own `i = i + 1` runs after this branch, so -1 lands on the + -- far stream's first byte. + bank, address, i = farBank, farAddr, -1 + elseif b == 0x01 then + -- TX_RAM: dw wStringBuffer*; runtime fills via getmonname etc. + out[#out + 1] = "{STRBUF}" + if buffers then + local target = self.rom:word(bank, address + i + 1) + buffers[#buffers + 1] = TEXT_BUFFERS[target] or target + end + i = i + 2 + elseif b == 0x4e or b == 0x4f then + out[#out + 1] = "\n" + elseif b == 0x51 then + out[#out + 1] = "\f" + elseif b == 0x55 then + out[#out + 1] = "\v" + elseif b == 0x52 then + out[#out + 1] = "{PLAYER}" + elseif b == 0x53 then + out[#out + 1] = "{RIVAL}" + elseif b == 0x54 then + out[#out + 1] = "POKé" + elseif b == 0x06 then + -- TX_PROMPT_BUTTON (e.g. empty _OakText3) : no glyphs. + elseif b == 0x09 and not inString then + -- TX_DECIMAL: `dw address / dn bytes, digits` (macros/scripts/text.asm). + -- PrintNum writes a runtime number here -- the price the Day-Care asks, + -- the quantity a mart clerk rings up -- so the marker stands in for it + -- and the call site fills it, the same way {STRBUF} stands in for a + -- TX_RAM. Four bytes: the byte AFTER the `dn` is the next command. + out[#out + 1] = "{NUM}" + i = i + 3 + elseif b == 0x14 and not inString then + -- TX_STRINGBUFFER: `db buffer id`. Another name spliced at runtime, so + -- it decodes to the same marker TX_RAM does. + out[#out + 1] = "{STRBUF}" + i = i + 1 + elseif b == 0x0c and not inString then + i = i + 1 -- TX_DOTS: `db count`, an animated ellipsis with no glyphs. + elseif not inString and TEXT_NO_GLYPH[b] then + -- TX_LOW / TX_SCROLL / TX_PAUSE / TX_WAIT_BUTTON / TX_DAY and the six + -- TX_SOUND_* jingles: box and timing commands that print nothing. Read + -- as characters they came out as whatever glyph the charmap had at that + -- byte -- `sound_caught_mon` inside _BreedEggHatchText decoded as a + -- kana -- so they are consumed here rather than printed. + else + local ch = charmap[tostring(b)] + if ch and not ch:match("^<") then + out[#out + 1] = ch + elseif ch == "<……>" or b == 0x56 then + out[#out + 1] = "……" + elseif not ch then + out[#out + 1] = ("{BYTE:%02X}"):format(b) + end + -- Skip other <$xx> control glyphs from the charmap. + end + i = i + 1 + end + return table.concat(out) +end + +local function wordFromArgs(args) + return (args[1] or 0) + (args[2] or 0) * 0x100 +end + +-- The `dba` operand every far-reaching opcode carries (farscall, farsjump, +-- farwritetext). macros/data.asm: `dba` is `dbw BANK(\1), \1`, so the BANK +-- comes FIRST and the address follows little-endian -- the opposite layout +-- from the neighbouring `dab`. Script_farsjump (engine/overworld/scripting.asm +-- 1190) reads it in that order too: `ld b, a` on the first byte, then l then h. +local function dbaFromArgs(args) + return (args[1] or 0), (args[2] or 0) + (args[3] or 0) * 0x100 +end + +local function romAddrOk(bank, address) + if type(bank) ~= "number" or type(address) ~= "number" then return false end + if bank < 0 or bank > 0x7f then return false end + if bank == 0 then return address >= 0 and address < 0x4000 end + return address >= 0x4000 and address < 0x8000 +end + +-- The side tables a script command NAMES rather than carries: the phone book, +-- the in-game trades, the elevator's floor labels, and the five decoration +-- descriptions. Each row's script pointers come back with the rest so +-- extractScriptsAndText can seed its queue from them -- which is the only way +-- into ROM bank $41, where every phone script lives and which no map points at. +-- +-- Returns the table that is written as data/generated/events.lua; the caller +-- adds the disassembly. +function RomExtractorGen2:readEventTables() + local consts = self.manifest.constants + local charmap = self.manifest.charmap or {} + local out = {} + + local function name(list, index, fallback) + if type(list) ~= "table" then return fallback end + return list[index + 1] or fallback + end + local function readName(bank, address, length) + local ok, str = pcall(self.rom.readString, self.rom, + bank, address, charmap, 0x50, length) + return ok and str or nil + end + + -- data/phone/phone_contacts.asm. The struct (constants/phone_constants.asm + -- rsreset) is class, number, map group, map number, then two + -- {time, bank, addr} triples -- SCRIPT1 is the CALLEE half (you rang them), + -- SCRIPT2 the CALLER half (they rang you). Both are `dba`, so the bank byte + -- comes first and the address after it. + local contacts = self:symbol("PhoneContacts") + local phone = {} + for row = 0, #(consts.phoneContactOrder or {}) - 1 do + local base = contacts.address + row * PHONE_CONTACT_SIZE + local raw = self.rom:bytes(contacts.bank, base, PHONE_CONTACT_SIZE) + local group, mapNum = raw[3], raw[4] + phone[row] = { + id = row, + contact = name(consts.phoneContactOrder, row), + trainerClass = raw[1], + number = raw[2], + -- N_A is group $ff / map $ff, which no real map equals. + map = (group ~= 0xff) and self:mapNameByIds(group, mapNum) or nil, + calleeTime = raw[5], + callee = Opcodes.key(raw[6], raw[7] + raw[8] * 0x100), + calleeBank = raw[6], calleeAddress = raw[7] + raw[8] * 0x100, + callerTime = raw[9], + caller = Opcodes.key(raw[10], raw[11] + raw[12] * 0x100), + callerBank = raw[10], callerAddress = raw[11] + raw[12] * 0x100, + } + end + out.phone = phone + + -- data/phone/special_calls.asm: `dw condition; db contact; dba script`. + -- The condition is a routine (SpecialCallOnlyWhenOutside / + -- SpecialCallWhereverYouAre), so its address is kept as a number for + -- Phone.lua to recognise rather than as anything runnable. + local special = self:symbol("SpecialPhoneCallList") + local specialCalls = {} + for row = 0, #(consts.specialCallOrder or {}) - 1 do + local base = special.address + row * SPECIALCALL_SIZE + local raw = self.rom:bytes(special.bank, base, SPECIALCALL_SIZE) + specialCalls[row + 1] = { + id = row + 1, -- SPECIALCALL_NONE is 0, so row 0 is SPECIALCALL_POKERUS + call = name(consts.specialCallOrder, row + 1), + condition = raw[1] + raw[2] * 0x100, + contact = raw[3], + script = Opcodes.key(raw[4], raw[5] + raw[6] * 0x100), + scriptBank = raw[4], scriptAddress = raw[5] + raw[6] * 0x100, + } + end + out.specialCalls = specialCalls + + -- engine/phone/phone.asm's own three scripts, which no contact row and no + -- map points at: the wrong-number arm, the no-signal arm, and "they are on + -- this map, go talk to them". Named by the port's own labels rather than by + -- the symbol's, because WrongNumber.script is a local label. + out.phoneScripts = {} + for label, symbol in pairs({ + WrongNumberScript = "WrongNumber.script", + PhoneOutOfAreaScript = "PhoneOutOfAreaScript", + PhoneScript_JustTalkToThem = "PhoneScript_JustTalkToThem", + }) do + local sym = self.symbols[symbol] + if sym then + out.phoneScripts[label] = { + script = Opcodes.key(sym[1], sym[2]), + scriptBank = sym[1], scriptAddress = sym[2], + } + end + end + + -- data/events/npc_trades.asm, the `trade` command's own table. + local trades = self:symbol("NPCTrades") + -- NPCTRADE_ITEM is an item id byte (data/events/npc_trades.asm), and every + -- other item byte this file writes is named at extraction: wild held items + -- (:1329), trainer parties (:4120), givepokemail (:3178). Name it here too + -- 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 base = trades.address + row * NPCTRADE_STRUCT_LENGTH + local raw = self.rom:bytes(trades.bank, base, 3) + local dvBase = base + 3 + MON_NAME_LENGTH + local tail = self.rom:bytes(trades.bank, dvBase, 5) + tradeRows[row + 1] = { + id = row, + dialog = name(consts.tradeDialogOrder, raw[1]), + -- The mon the player HANDS OVER is GIVEMON and the one they receive is + -- GETMON; the macro's own argument names are "requested, offered". + give = self:speciesName(raw[2]), + giveIndex = raw[2], + get = self:speciesName(raw[3]), + getIndex = raw[3], + nickname = readName(trades.bank, base + 3, MON_NAME_LENGTH), + -- NPCTRADE_DVS is a `dw` read as two raw DV bytes (attack/defense then + -- speed/special), not as a number. + dvs = { tail[1], tail[2] }, + -- NO_ITEM (0) drops entirely; the numeric fallback keeps a manifest + -- built before itemOrder existed importable. + item = (tail[3] ~= 0) and (itemOrder[tail[3]] or tail[3]) or nil, + otId = tail[4] + tail[5] * 0x100, + otName = readName(trades.bank, dvBase + 5, NAME_LENGTH), + gender = name(consts.tradeGenderOrder, + self.rom:byte(trades.bank, dvBase + 5 + NAME_LENGTH)), + } + end + out.trades = tradeRows + + -- 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. + local tradeTexts = self:symbol("TradeTexts") + local dialogs = { "TRADE_DIALOG_INTRO", "TRADE_DIALOG_CANCEL", + "TRADE_DIALOG_WRONG", "TRADE_DIALOG_COMPLETE", "TRADE_DIALOG_AFTER" } + local sets = consts.tradeDialogOrder or {} + out.tradeTexts, out.tradeBuffers = {}, {} + -- The two markers a trade line splices are DIFFERENT buffers -- the intro is + -- wStringBuffer1 ("do you have X?") then wStringBuffer2 ("for my Y?") -- + -- and both decode to {STRBUF}, so the order has to be recorded next to the + -- text or the two mons come out swapped. + for d, dialog in ipairs(dialogs) do + local row, bufRow = {}, {} + for s = 1, #sets do + local addr = self.rom:word(tradeTexts.bank, + tradeTexts.address + (d - 1) * 6 + (s - 1) * 2) + local buffers = {} + row[sets[s]] = + self:decodeGen2Text(tradeTexts.bank, addr, charmap, buffers) + bufRow[sets[s]] = buffers + end + out.tradeTexts[dialog] = row + out.tradeBuffers[dialog] = bufRow + end + -- The two lines NPCTrade prints around the swap, then the six the trade + -- ANIMATION prints (engine/movie/trade_animation.asm, text in + -- data/text/common_1.asm). They all land in the same table because they are + -- all named rather than indexed; the empty _MonNameSentToText is skipped, + -- since an open box with nothing in it needs no string. + for _, label in ipairs({ "NPCTradeCableText", "TradedForText", + "_MonWasSentToText", "_ForYourMonSendsText", "_OTSendsText", + "_BidsFarewellToMonText", "_MonNameBidsFarewellText", + "_TakeGoodCareOfMonText" }) do + local sym = self.symbols[label] + if sym then + local buffers = {} + out.tradeTexts[label] = + self:decodeGen2Text(sym[1], sym[2], charmap, buffers) + out.tradeBuffers[label] = buffers + end + end + + -- data/events/bug_contest_flags.asm: `table_width 2`, one + -- EVENT_BUG_CATCHING_CONTESTANT_*A word per contestant. These are wEventFlags + -- NUMBERS, not names, which is what SelectRandomBugContestContestants + -- (engine/events/bug_contest/contest_2.asm) hands to EventFlagAction -- it + -- resets all ten and then sets five, and a SET flag is what keeps that + -- object off NationalParkBugContest. A missing symbol is skipped rather + -- than fatal so an older manifest still imports and BugContest.FLAGS carries + -- the map. + local contestFlags = self.symbols.BugCatchingContestantEventFlagTable + if contestFlags then + local flagTable = self:symbol("BugCatchingContestantEventFlagTable") + local flags = {} + for row = 0, NUM_BUG_CONTESTANTS - 1 do + flags[row + 1] = + self.rom:word(flagTable.bank, flagTable.address + row * 2) + end + out.bugContestFlags = flags + end + + -- data/events/elevator_floors.asm: a `dw` per FLOOR_* at a "B1F@" string. + local floors = self:symbol("ElevatorFloorNames") + local floorNames = {} + for row = 0, #(consts.floorOrder or {}) - 1 do + local addr = self.rom:word(floors.bank, floors.address + row * 2) + floorNames[row + 1] = readName(floors.bank, addr, 8) + end + out.floorNames = floorNames + + -- describedecoration's five arms (engine/overworld/decorations.asm). Each + -- arm is ASM that hands back a script, so what is emitted is the SCRIPT each + -- can pick: the poster table plus its "nothing installed" miss, the one + -- script the two ornaments and the console share, and the giant ornament's. + local function scriptRef(bank, address) + return { script = Opcodes.key(bank, address), + scriptBank = bank, scriptAddress = address } + end + local posterTable = self:symbol("DecorationDesc_PosterPointers") + local posters = {} + for i = 0, 15 do + local row = posterTable.address + i * 3 + local deco = self.rom:byte(posterTable.bank, row) + if deco == 0xff then break end + local ref = scriptRef(posterTable.bank, + self.rom:word(posterTable.bank, row + 1)) + ref.decoration = deco + posters[#posters + 1] = ref + end + local nullPoster = self:symbol("DecorationDesc_NullPoster") + local ornament = self:symbol( + "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript") + local bigDoll = self:symbol("DecorationDesc_GiantOrnament.BigDollScript") + local poster = scriptRef(nullPoster.bank, nullPoster.address) + poster.posters = posters + out.decorations = { + DECODESC_POSTER = poster, + DECODESC_LEFT_DOLL = scriptRef(ornament.bank, ornament.address), + DECODESC_RIGHT_DOLL = scriptRef(ornament.bank, ornament.address), + DECODESC_BIG_DOLL = scriptRef(bigDoll.bank, bigDoll.address), + DECODESC_CONSOLE = scriptRef(ornament.bank, ornament.address), + } + out.decorationOrder = consts.decoDescOrder + + return out +end + +-- Text NO script pointer reaches, seeded by name. +-- +-- The walker below only decodes a string some `writetext` / `farwritetext` / +-- `trainer` struct named, which is every line an NPC says and nothing else. +-- A line an ENGINE routine prints -- `ld hl, .SomeText / call PrintText` -- +-- has no pointer in any bytecode, so three whole blocks used to arrive only +-- as hand transcriptions at their call sites: +-- +-- the Day-Care and breeding block of data/text/common_1.asm and +-- common_2.asm, printed by engine/events/daycare.asm and +-- engine/pokemon/breeding.asm (src/ui/gen2/DayCareMenu.lua), +-- the POKeMART clerk's whole conversation in data/text/common_2.asm, +-- printed by engine/items/mart.asm (src/ui/gen2/MartMenu.lua), +-- and the Hall of Fame's three flavour strings, which are plain `db "…@"` +-- inside engine/events/halloffame.asm rather than text streams at all +-- (src/ui/gen2/HallOfFame.lua). +-- +-- Each label below is the FAR string (`_Foo`), not the near `text_far` stub +-- that names it: both decode to the same characters, and the far one is the +-- symbol the whole block shares a bank with. They are written out as +-- text.labels[label] -> the "bank:addr" key the string landed on, so a call +-- site asks for a pokegold label and never for an address. +local NAMED_TEXT = { + -- engine/events/daycare.asm, in its own text-table order. + "_DaycareDummyText", + "_DayCareManIntroText", "_DayCareManIntroEggText", + "_DayCareLadyIntroText", "_DayCareLadyIntroEggText", + "_WhatShouldIRaiseText", "_OnlyOneMonText", "_CantAcceptEggText", + "_RemoveMailText", "_LastHealthyMonText", "_IllRaiseYourMonText", + "_ComeBackLaterText", "_AreWeGeniusesText", "_YourMonHasGrownText", + "_PerfectHeresYourMonText", "_GotBackMonText", "_BackAlreadyText", + "_HaveNoRoomText", "_NotEnoughMoneyText", "_OhFineThenText", + "_ComeAgainText", "_NotYetText", "_FoundAnEggText", "_ReceivedEggText", + "_TakeGoodCareOfEggText", "_IllKeepItThanksText", "_NoRoomForEggText", + -- engine/pokemon/breeding.asm: the hatch, the "you left X here" lines the + -- DayCareMon specials print, and the five compatibility verdicts. + "Text_BreedHuh", "_BreedClearboxText", "_BreedEggHatchText", + "_BreedAskNicknameText", + "_LeftWithDayCareManText", "_LeftWithDayCareLadyText", + "_BreedBrimmingWithEnergyText", "_BreedNoInterestText", + "_BreedAppearsToCareForText", "_BreedFriendlyText", + "_BreedShowsInterestText", + -- engine/items/mart.asm: the four MARTTYPE_* dialogs and the sell flow. + "_MartWelcomeText", "_MartAskMoreText", "_MartComeAgainText", + "_MartHowManyText", "_MartFinalPriceText", "_MartThanksText", + "_MartNoMoneyText", "_MartPackFullText", + "_HerbShopLadyIntroText", "_HerbalLadyHowManyText", + "_HerbalLadyFinalPriceText", "_HerbalLadyThanksText", + "_HerbalLadyPackFullText", "_HerbalLadyNoMoneyText", + "_HerbalLadyComeAgainText", + "_BargainShopIntroText", "_BargainShopFinalPriceText", + "_BargainShopThanksText", "_BargainShopPackFullText", + "_BargainShopSoldOutText", "_BargainShopNoFundsText", + "_BargainShopComeAgainText", + "_PharmacyIntroText", "_PharmacyHowManyText", "_PharmacyFinalPriceText", + "_PharmacyThanksText", "_PharmacyPackFullText", "_PharmacyNoMoneyText", + "_PharmacyComeAgainText", + "_NothingToSellText", "_MartSellHowManyText", "_MartSellPriceText", + "_MartCantBuyText", "_MartBoughtText", + -- engine/events/halloffame.asm. These three are `db` strings ending on the + -- same "@" a text chunk ends on, so the text walker reads them unchanged -- + -- and their leading spaces are load bearing, because PrintNum writes the + -- win count over the first two columns of " -Time Famer". + "AnimateHallOfFame.String_NewHallOfFamer", + "_HallOfFamePC.TimeFamer", "_HallOfFamePC.HOFMaster", + -- data/text/common_2.asm's MAIL block, printed by engine/pokemon/mail.asm's + -- MailboxPC and engine/pokemon/mon_menu.asm's MonMailAction + -- (src/ui/gen2/MailboxMenu.lua and src/ui/gen2/MailMenu.lua). Same shape as + -- the Day-Care block above: asm prints these, so no bytecode points at them + -- and the walker has to be seeded by name. + "_EmptyMailboxText", "_MailClearedPutAwayText", "_MailPackFullText", + "_MailMessageLostText", "_MailAlreadyHoldingItemText", "_MailEggText", + "_MailMovedFromBoxText", "_MailLoseMessageText", "_MailDetachedText", + "_MailNoSpaceText", "_MailAskSendToPCText", "_MailboxFullText", + "_MailSentToPCText", "_PCMonHoldingMailText", "_PokemonRemoveMailText", +} + +-- Walk every map's object/bg/coord/scene script pointers, disassemble +-- bytecode into command lists, rip writetext strings, and capture +-- applymovement byte streams. Runtime never needs the ROM -- just +-- data/generated/scripts.lua + text.lua. +function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) + self:beginStage("Scripts & text") + local charmap = self.manifest.charmap or {} + local scripts, text = { generation = 2 }, { generation = 2 } + local movements = {} + local queue = {} + local queued = {} + + local function enqueue(bank, address) + if not romAddrOk(bank, address) or address == 0 then return end + -- Scripts live in banked ROM, not ROM0. + if bank == 0 then return end + local key = Opcodes.key(bank, address) + if queued[key] then return end + queued[key] = true + queue[#queue + 1] = { bank = bank, address = address, key = key } + end + + -- home/map.asm ObjectEvent ("jumptextfaceplayer ObjectEventText") is the + -- line an object with no script of its own says, and 44 object_events across + -- the maps point at it with the same `dw`. It lives in ROM0, which is + -- visible from every bank, so its pointer is BELOW $4000 -- exactly what + -- enqueue rejects, because a real map script pointer is always banked. The + -- shared body is walked once at bank 0 and every object that names it points + -- at that one key, rather than at a key in its own map's script bank where + -- nothing was ever disassembled. + -- + -- Returns the key so a caller can use it as its scriptKey, or nil when the + -- address is not a ROM0 one after all. + local function enqueueHome(address) + if not romAddrOk(0, address) or address == 0 then return nil end + local key = Opcodes.key(0, address) + if not queued[key] then + queued[key] = true + queue[#queue + 1] = { bank = 0, address = address, key = key } + end + return key + end + + local function ensureText(bank, address) + if not romAddrOk(bank, address) then return nil end + local key = Opcodes.key(bank, address) + if not text[key] then + local ok, decoded = pcall(self.decodeGen2Text, self, bank, address, charmap) + text[key] = ok and decoded or "" + end + return key + end + + local function ensureMovement(bank, address) + if not romAddrOk(bank, address) or address == 0 then return nil end + local key = Opcodes.key(bank, address) + if movements[key] then return key end + local bytes = {} + for i = 0, 64 do + local ok, b = pcall(self.rom.byte, self.rom, bank, address + i) + if not ok then break end + bytes[#bytes + 1] = b + if b == 0x47 or b == 0x48 then break end -- step_end / step_wait_end + end + movements[key] = bytes + return key + end + + -- A MAIL message: raw charmap bytes terminated by '@', NOT a text stream. + -- Both mail opcodes point at one (Script_givepokemail FarCopyBytes's + -- MAIL_MSG_LENGTH of them into wMonMailMessageBuffer; Script_checkpokemail + -- hands its pointer to CheckPokeMail, which compares byte for byte until the + -- '@'), so neither can go through decodeGen2Text -- that walks TX_* commands + -- and would treat a $16 in the middle of somebody's sentence as a far jump. + -- + -- The one control byte that IS meaningful here is `next` ($4e), the line + -- break the message carries across the struct's two MAIL_LINE_LENGTH rows; + -- it decodes to "\n" so the stored letter and the expected one compare equal + -- (src/core/gen2/Mail.lua). + local function readMailMessage(bank, address, limit) + if not romAddrOk(bank, address) then return nil end + local out = {} + for i = 0, (limit or 0x20) - 1 do + local ok, b = pcall(self.rom.byte, self.rom, bank, address + i) + if not ok then break end + if b == 0x50 then break end + if b == 0x4e or b == 0x4f then + out[#out + 1] = "\n" + else + local ch = charmap[tostring(b)] + if ch and not ch:match("^<") then + out[#out + 1] = ch + elseif not ch then + out[#out + 1] = ("{BYTE:%02X}"):format(b) + end + end + end + return table.concat(out) + end + + -- MenuHeader (home/menu.asm LoadMenuHeader, ram/wram.asm wMenuHeader): + -- db flags; menu_coords x1, y1, x2, y2; dw data; db default cursor + -- `menu_coords` lays each corner down Y FIRST (macros/coords.asm is + -- `db \2, \1` twice), so the four bytes are top, left, bottom, right. + -- + -- The data block behind it is read two ways depending on which command + -- consumes it. A vertical menu is `db flags; db items;` then one `@`-ended + -- string per item. A 2D menu is `db flags; dn rows, cols; db spacing; + -- dba strings; dbw bank, function` -- so its strings sit behind ANOTHER far + -- pointer, and rows * cols is how many of them to read. + -- + -- Both shapes are emitted; the command that follows loadmenu picks. Reading + -- both is safe because neither walks past the item count it was given. + local function readMenuStrings(bank, address, count, limit) + local items, cursor = {}, address + for _ = 1, count do + if not romAddrOk(bank, cursor) then break end + local ok, str = pcall(self.rom.readString, self.rom, + bank, cursor, charmap, 0x50, limit or 24) + if not ok then break end + items[#items + 1] = str + -- readString stops AT the terminator and does not report how far it got, + -- so the stride is measured here. + local len = 0 + while len < (limit or 24) do + local okB, b = pcall(self.rom.byte, self.rom, bank, cursor + len) + if not okB or b == 0x50 then break end + len = len + 1 + end + cursor = cursor + len + 1 + end + return items + end + + local function readMenuHeader(bank, address) + if not romAddrOk(bank, address) then return nil end + local okRaw, raw = pcall(self.rom.bytes, self.rom, + bank, address, MENU_HEADER_LENGTH) + if not okRaw then return nil end + local dataAddr = raw[6] + raw[7] * 0x100 + local header = { + flags = raw[1], + top = raw[2], left = raw[3], bottom = raw[4], right = raw[5], + cursor = raw[8], + key = Opcodes.key(bank, address), + } + if not romAddrOk(bank, dataAddr) then return header end + local okData, data = pcall(self.rom.bytes, self.rom, bank, dataAddr, 8) + if not okData then return header end + header.dataFlags = data[1] + -- Vertical: db items, then the strings inline. + local count = data[2] + if count > 0 and count <= 16 then + header.items = readMenuStrings(bank, dataAddr + 2, count) + end + -- 2D: dn rows, cols (one byte) / db spacing / dba strings. + local rows, cols = math.floor(data[2] / 16), data[2] % 16 + if rows > 0 and cols > 0 and rows * cols <= 32 then + local strBank = data[4] + local strAddr = data[5] + data[6] * 0x100 + if romAddrOk(strBank, strAddr) then + header.grid = { rows = rows, cols = cols, spacing = data[3] } + header.gridItems = readMenuStrings(strBank, strAddr, rows * cols) + end + end + return header + end + + -- `writecmdqueue` names a cmdqueue entry (macros/scripts/maps.asm): + -- dbw type, addr; dw filler + -- and the only type any map uses is CMDQUEUE_STONETABLE, whose address is a + -- `stonetable warp, object, script` list ending on `db -1`. Following it is + -- what puts the per-boulder scripts in scripts.lua: nothing else points at + -- them, so without this the Ice Path and Blackthorn Gym tables have to be + -- hand-ported. + local function readCmdQueueEntry(bank, address) + if not romAddrOk(bank, address) then return nil end + local okRaw, raw = pcall(self.rom.bytes, self.rom, + bank, address, CMDQUEUE_ENTRY_SIZE) + if not okRaw then return nil end + local kind = raw[1] + local target = raw[2] + raw[3] * 0x100 + local entry = { + type = kind, + queue = orderName(self.manifest.constants.cmdQueueOrder, kind + 1), + address = target, + } + if kind ~= CMDQUEUE_STONETABLE or not romAddrOk(bank, target) then + return entry + end + local rows = {} + for i = 0, 15 do + local row = target + i * STONETABLE_LENGTH + local okWarp, warp = pcall(self.rom.byte, self.rom, bank, row) + if not okWarp or warp == 0xff then break end + local okScript, script = pcall(self.rom.word, self.rom, bank, row + 2) + if not okScript then break end + rows[#rows + 1] = { + warp = warp, + object = self.rom:byte(bank, row + 1), + scriptKey = Opcodes.key(bank, script), + } + enqueue(bank, script) + end + entry.rows = rows + return entry + end + + -- `elevator` names a floor list: db count, then `elevfloor floor, warp, map` + -- (db floor, warp; map_id) rows. Elevator itself performs the ride, so the + -- warp number and destination map are the whole of what the port needs. + local function readElevator(bank, address) + if not romAddrOk(bank, address) then return nil end + local okCount, count = pcall(self.rom.byte, self.rom, bank, address) + if not okCount or count == 0 or count > 16 then return nil end + local floors = {} + for i = 0, count - 1 do + local row = address + 1 + i * 4 + local okRaw, raw = pcall(self.rom.bytes, self.rom, bank, row, 4) + if not okRaw then break end + floors[#floors + 1] = { + floor = orderName(self.manifest.constants.floorOrder, raw[1] + 1), + floorId = raw[1], + destWarp = raw[2], + destGroup = raw[3], destMapNum = raw[4], + destMap = self:mapNameByIds(raw[3], raw[4]), + } + end + return floors + end + + -- `trainer` struct (macros/scripts/maps.asm): + -- dw beat-event flag; db class, member; dw seen, win, loss; dw after-script + -- The after-script is the only part that is bytecode, so it is the pointer + -- the object's scriptKey ends up naming. + local function readTrainerHeader(bank, address) + if not romAddrOk(bank, address) then return nil end + local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 12) + if not ok then return nil end + local function word(i) return raw[i] + raw[i + 1] * 0x100 end + local afterAddr = word(11) + local entry = { + event = word(1), + class = raw[3], + member = raw[4], + seenText = ensureText(bank, word(5)), + winText = ensureText(bank, word(7)), + lossText = ensureText(bank, word(9)), + } + if entry.event == 0xFFFF then entry.event = nil end + if afterAddr ~= 0 and romAddrOk(bank, afterAddr) then + entry.scriptKey = Opcodes.key(bank, afterAddr) + enqueue(bank, afterAddr) + end + return entry + end + + -- `itemball item, quantity` -- two bytes, no script. + local function readItemBall(bank, address) + if not romAddrOk(bank, address) then return nil end + local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 2) + if not ok then return nil end + return { item = raw[1], quantity = raw[2] } + end + + -- `hiddenitem item, flag` is `dwb \2, \1` (macros/scripts/maps.asm): the + -- EVENT_* flag first as a word, the item id after it as a byte. Three bytes, + -- no script -- CheckForHiddenItems (engine/events/checkforhiddenitems.asm) + -- reads the word straight into EventFlagAction and HiddenItemScript + -- (engine/events/hidden_item.asm) reads the byte through wHiddenItemID. + -- + -- The word comes first and the byte last, which is the opposite of the way + -- `itemball` above lays its two bytes down; `dwb` is where that order lives, + -- not the macro's argument list. + local function readHiddenItem(bank, address) + if not romAddrOk(bank, address) then return nil end + local ok, raw = pcall(self.rom.bytes, self.rom, bank, address, 3) + if not ok then return nil end + return { event = raw[1] + raw[2] * 0x100, item = raw[3] } + end + + -- Seed the std scripts first: they are reachable only through callstd / + -- jumpstd ids, never through a map pointer, so nothing else would queue + -- them and every `callstd` would dead-end at a missing key. + for _, entry in pairs(stdScripts and stdScripts.scripts or {}) do + enqueue(entry.bank, entry.address) + end + + -- Seed the side tables next, for the same reason: the phone's callee/caller + -- scripts all live in ROM bank $41 and no map points into it, so without + -- these three seeds the bank is unreachable and every phone call arrives + -- with a descriptor and no body. The decoration scripts are the same shape + -- one bank over. + local events = self:readEventTables() + for _, row in pairs(events.phone or {}) do + enqueue(row.calleeBank, row.calleeAddress) + enqueue(row.callerBank, row.callerAddress) + end + for _, row in ipairs(events.specialCalls or {}) do + enqueue(row.scriptBank, row.scriptAddress) + end + for _, row in pairs(events.phoneScripts or {}) do + enqueue(row.scriptBank, row.scriptAddress) + end + for _, arm in pairs(events.decorations or {}) do + enqueue(arm.scriptBank, arm.scriptAddress) + for _, poster in ipairs(arm.posters or {}) do + enqueue(poster.scriptBank, poster.scriptAddress) + end + end + + -- And seed home/map.asm ObjectEvent, for a third flavour of the same reason: + -- it is the shared generic line, it lives in an engine bank the map walk + -- never enters, and the maps that name it all name it by an address enqueue + -- would throw away. Seeding it from the symbol keeps the body present even + -- if the maps ever stop pointing at it. + local objectEvent = self.symbols.ObjectEvent + if objectEvent and objectEvent[1] == 0 then + enqueueHome(objectEvent[2]) + end + + -- And seed the NAMED_TEXT blocks, which are the same problem one step + -- further out: no script points at them because no SCRIPT prints them. A + -- missing symbol is skipped rather than fatal, so an older manifest still + -- imports and the call sites fall back to their transcriptions. + local labels = {} + for _, label in ipairs(NAMED_TEXT) do + local sym = self.symbols[label] + if sym then + local key = ensureText(sym[1], sym[2]) + if key then labels[label] = key end + end + end + text.labels = labels + + -- Seed from every map event that carries a script pointer. + local mapCount = 0 + for mapId, def in pairs(maps) do + if type(def) == "table" and def.scripts and def.scripts.bank then + mapCount = mapCount + 1 + local bank = def.scripts.bank + for _, obj in ipairs(def.objects or {}) do + -- object_event's function byte decides what its pointer *is*. Only + -- OBJECTTYPE_SCRIPT aims at bytecode; OBJECTTYPE_TRAINER aims at the + -- `trainer` struct (macros/scripts/maps.asm) and OBJECTTYPE_ITEMBALL + -- at two raw bytes. Disassembling those as commands yields noise. + if obj.type == OBJECTTYPE_TRAINER and obj.script then + obj.trainer = readTrainerHeader(bank, obj.script) + if obj.trainer and obj.trainer.scriptKey then + obj.scriptKey = obj.trainer.scriptKey + end + elseif obj.type == OBJECTTYPE_ITEMBALL and obj.script then + obj.itemball = readItemBall(bank, obj.script) + elseif obj.script then + -- A pointer below $4000 is ROM0, i.e. the shared ObjectEvent line, + -- and belongs to bank 0 rather than to this map's script bank. + obj.scriptKey = enqueueHome(obj.script) + if not obj.scriptKey then + obj.scriptKey = Opcodes.key(bank, obj.script) + enqueue(bank, obj.script) + end + end + end + for _, ev in ipairs(def.bgEvents or {}) do + -- A bg_event's function byte decides what its pointer *is*, exactly as + -- an object_event's does above. BGEVENT_ITEM aims at `hiddenitem` + -- data, so it is read rather than walked: queueing it disassembled the + -- flag word and the item byte as opcodes and, worse, followed whatever + -- jump the noise happened to spell into more noise. + if ev.kind == BGEVENT_ITEM and ev.script then + ev.hiddenItem = readHiddenItem(bank, ev.script) + elseif ev.script then + -- IFSET/IFNOTSET already resolved their `conditional_event` above; + -- the script it names is in the map's own bank. + local scriptBank = bank + local key = Opcodes.key(scriptBank, ev.script) + ev.scriptKey = key + enqueue(scriptBank, ev.script) + end + end + for _, ev in ipairs(def.coordEvents or {}) do + if ev.script then + local key = Opcodes.key(bank, ev.script) + ev.scriptKey = key + enqueue(bank, ev.script) + end + end + for _, sc in pairs(def.sceneScripts or {}) do + if type(sc) == "table" and sc.script then + enqueue(bank, sc.script) + end + end + for _, cb in ipairs(def.callbacks or {}) do + enqueue(bank, cb.script) + end + end + end + + local qi, disassembled = 1, 0 + while queue[qi] do + local item = queue[qi] + qi = qi + 1 + local bank, pc = item.bank, item.address + local commands = {} + for _ = 1, 256 do + if not romAddrOk(bank, pc) then + commands[#commands + 1] = { op = "truncated", reason = "pc" } + break + end + local okByte, opcode = pcall(self.rom.byte, self.rom, bank, pc) + if not okByte then + commands[#commands + 1] = { op = "truncated", reason = "read" } + break + end + local info = Opcodes[opcode] + if not info then + commands[#commands + 1] = { + op = "unknown", code = opcode, + source = ("ROM:%s"):format(item.key), + } + break + end + -- givepoke is variable-length: 4 bytes, or 8 when the trainer flag is set. + local size = info.size + if info.name == "givepoke" then + local okTr, trainer = pcall(self.rom.byte, self.rom, bank, pc + 4) + size = (okTr and trainer ~= 0) and 8 or 4 + end + if not romAddrOk(bank, pc + size) then + commands[#commands + 1] = { op = "truncated", reason = "args" } + break + end + local okArgs, args = pcall(self.rom.bytes, self.rom, bank, pc + 1, size) + if not okArgs then + commands[#commands + 1] = { op = "truncated", reason = "args" } + break + end + local cmd = { op = info.name } + local nextPc = pc + 1 + size + + if info.name == "writetext" or info.name == "jumptext" + or info.name == "jumptextfaceplayer" then + cmd.text = ensureText(bank, wordFromArgs(args)) + elseif info.name == "farwritetext" then + local tBank, tAddr = dbaFromArgs(args) + cmd.text = ensureText(tBank, tAddr) + elseif info.name == "checkevent" or info.name == "setevent" + or info.name == "clearevent" then + cmd.event = wordFromArgs(args) + elseif info.name == "checkflag" or info.name == "setflag" + or info.name == "clearflag" then + cmd.flag = wordFromArgs(args) + elseif info.name == "iftrue" or info.name == "iffalse" + or info.name == "sjump" or info.name == "scall" + or info.name == "stopandsjump" or info.name == "sdefer" then + local target = wordFromArgs(args) + cmd.script = Opcodes.key(bank, target) + enqueue(bank, target) + elseif info.name == "ifequal" or info.name == "ifnotequal" + or info.name == "ifgreater" or info.name == "ifless" then + cmd.value = args[1] + local target = args[2] + args[3] * 0x100 + cmd.script = Opcodes.key(bank, target) + enqueue(bank, target) + elseif info.name == "farscall" or info.name == "farsjump" then + local tBank, tAddr = dbaFromArgs(args) + cmd.script = Opcodes.key(tBank, tAddr) + enqueue(tBank, tAddr) + elseif info.name == "jumpstd" or info.name == "callstd" then + cmd.id = wordFromArgs(args) + -- Resolve now so the VM can jump straight to a scripts.lua key + -- instead of carrying a std-script table of its own. + local label = stdScripts and stdScripts.byId + and stdScripts.byId[cmd.id] + local entry = label and stdScripts.scripts[label] + if entry then + cmd.std = label + cmd.script = entry.key + end + elseif info.name == "special" or info.name == "playmusic" + or info.name == "playsound" or info.name == "cry" then + cmd.id = wordFromArgs(args) + elseif info.name == "pause" then + cmd.frames = args[1] + elseif info.name == "setscene" then + cmd.scene = args[1] + elseif info.name == "setmapscene" then + cmd.group, cmd.map, cmd.scene = args[1], args[2], args[3] + elseif info.name == "turnobject" then + cmd.object = args[1] + cmd.facing = args[2] + elseif info.name == "applymovement" then + cmd.object = args[1] + local movAddr = wordFromArgs({ args[2], args[3] }) + cmd.movement = ensureMovement(bank, movAddr) + elseif info.name == "applymovementlasttalked" then + local movAddr = wordFromArgs(args) + cmd.movement = ensureMovement(bank, movAddr) + elseif info.name == "givepoke" then + cmd.species, cmd.level, cmd.item, cmd.trainer = + args[1], args[2], args[3], args[4] + elseif info.name == "pokepic" or info.name == "disappear" then + cmd.species = args[1] -- pokepic + cmd.object = args[1] -- disappear (same byte) + cmd.args = args + elseif info.name == "getmonname" then + cmd.species, cmd.buffer = args[1], args[2] + elseif info.name == "getitemname" then + cmd.item, cmd.buffer = args[1], args[2] + elseif info.name == "getstring" then + -- `getstring buffer, pointer` lays the pointer down first (dw) and the + -- buffer id last -- see macros/scripts/events.asm. The target is a + -- plain `@`-terminated name, not a text stream, so read it as a string: + -- Script_getstring CopyName1's it into wStringBuffer2, which is what + -- the following writetext's TX_RAM ({STRBUF}) prints. + cmd.buffer = args[3] + local sAddr = wordFromArgs(args) + if romAddrOk(bank, sAddr) then + local okStr, str = pcall(self.rom.readString, self.rom, + bank, sAddr, charmap, 0x50, 32) + cmd.string = okStr and str or nil + end + elseif info.name == "givepokemail" then + -- `givepokemail pointer` (Script_givepokemail): the target is `db item` + -- followed by MAIL_MSG_LENGTH message bytes, in the SCRIPT'S own bank + -- (`ld a, [wScriptBank] / call GetFarByte`). One call site in the + -- game, RandyScript's GiftSpearowMail in + -- maps/Route35GoldenrodGate.asm. `args` is kept alongside so a reader + -- that only knows the raw word still has it. + cmd.args = args + local mAddr = wordFromArgs(args) + if romAddrOk(bank, mAddr) then + local okItem, itemByte = pcall(self.rom.byte, self.rom, bank, mAddr) + local message = readMailMessage(bank, mAddr + 1, 0x20) + if okItem and message then + cmd.mail = { + item = (self.manifest.constants.itemOrder or {})[itemByte], + message = message, + } + end + end + elseif info.name == "checkpokemail" then + -- `checkpokemail pointer`: the target is the EXPECTED message alone -- + -- no item byte -- and CheckPokeMail walks it until the '@', so a + -- message stored longer than this one still matches. + cmd.args = args + local mAddr = wordFromArgs(args) + local message = readMailMessage(bank, mAddr, 0x20) + if message then cmd.mail = { message = message } end + elseif info.name == "gettrainername" then + cmd.group, cmd.trainer, cmd.buffer = args[1], args[2], args[3] + elseif info.name == "loadtrainer" then + cmd.class, cmd.member = args[1], args[2] + elseif info.name == "loadwildmon" then + cmd.species, cmd.level = args[1], args[2] + elseif info.name == "winlosstext" then + -- Overrides the `trainer` struct's win/loss text for one battle. + cmd.winText = ensureText(bank, wordFromArgs(args)) + cmd.lossText = ensureText(bank, args[3] + args[4] * 0x100) + elseif info.name == "trainertext" then + cmd.index = args[1] + elseif info.name == "trainerflagaction" then + cmd.action = args[1] + elseif info.name == "setlasttalked" then + cmd.object = args[1] + elseif info.name == "showemote" then + cmd.emote, cmd.object, cmd.frames = args[1], args[2], args[3] + elseif info.name == "giveitem" or info.name == "verbosegiveitem" then + cmd.item, cmd.quantity = args[1], args[2] + elseif info.name == "addcellnum" or info.name == "delcellnum" + or info.name == "checkcellnum" then + cmd.phone = args[1] + elseif info.name == "readvar" or info.name == "writevar" then + cmd.var = args[1] + elseif info.name == "follow" or info.name == "faceobject" + or info.name == "follownotexact" then + cmd.a, cmd.b = args[1], args[2] + elseif info.name == "loadmenu" then + -- The header sits in the SCRIPT'S own bank (Script_loadmenu calls + -- LoadMenuHeader through Call_a_de with wScriptBank in a). + cmd.menu = readMenuHeader(bank, wordFromArgs(args)) + elseif info.name == "writecmdqueue" then + cmd.queue = readCmdQueueEntry(bank, wordFromArgs(args)) + elseif info.name == "elevator" then + cmd.floors = readElevator(bank, wordFromArgs(args)) + elseif info.name == "trade" then + cmd.trade = args[1] + elseif info.name == "describedecoration" then + cmd.decoration = args[1] + cmd.decorationName = orderName( + self.manifest.constants.decoDescOrder, args[1] + 1) + elseif size > 0 then + cmd.args = args + end + + commands[#commands + 1] = cmd + if Opcodes.TERMINATORS[info.name] then break end + pc = nextPc + end + scripts[item.key] = commands + disassembled = disassembled + 1 + if disassembled % 64 == 0 then + self:tick("Scripts & text", disassembled, math.max(disassembled, #queue)) + end + end + + scripts.movements = movements + + -- Rewrite maps.lua with scriptKey fields now that objects were annotated. + self:write("maps", maps) + self:write("scripts", scripts) + self:write("text", text) + self:write("events", events) + self:extractInitialEvents() + self:tick("Scripts & text", 1, 1) + return { scripts = scripts, text = text, events = events, + mapCount = mapCount, scriptCount = disassembled } +end + +-- New-game seed: walk InitializeEventsScript and collect what it sets. +-- +-- Retail Gold's numeric EVENT_* values differ from pret's current const_def +-- order, so we must take them from the cart, not hardcode. +-- +-- It is not only `setevent`. The same script ends with nine `variablesprite` +-- assignments, and they are not decoration: SPRITE_WEIRD_TREE ($f4) is a +-- wVariableSprites SLOT, not a sheet, so until something fills it nothing +-- spawns at all. Dropping them meant the Sudowoodo on Route 36 was simply not +-- there -- and with it TM08 ROCK SMASH, the Burned Tower, Morty, FOGBADGE and +-- SURF -- along with the Olivine rival, the Azalea Rocket, the four Fuchsia +-- gym Janines, the Copycat and the Janine impersonator. +function RomExtractorGen2:extractInitialEvents() + local sym = self.symbols.InitializeEventsScript + if not sym then + self:write("initial_events", { + generation = 2, + source = "missing symbol InitializeEventsScript", + flags = {}, engineFlags = {}, sprites = {}, + }) + return + end + local bank, pc = sym[1], sym[2] + local flags, seen = {}, {} + local engineFlags, engineSeen = {}, {} + local sprites = {} + for _ = 1, 512 do + 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] + if not info then break end + local size = info.size + local okArgs, args = true, {} + if size > 0 then + okArgs, args = pcall(self.rom.bytes, self.rom, bank, pc + 1, size) + if not okArgs then break end + end + if info.name == "setevent" then + local id = wordFromArgs(args) + if not seen[id] then + seen[id] = true + flags[#flags + 1] = id + end + elseif info.name == "setflag" then + local id = wordFromArgs(args) + if not engineSeen[id] then + engineSeen[id] = true + engineFlags[#engineFlags + 1] = id + end + elseif info.name == "variablesprite" then + -- Script_variablesprite: two bytes, `wVariableSprites + slot = sprite`. + -- The slot operand is already the offset from SPRITE_VARS. + if args[1] and args[2] then + sprites[#sprites + 1] = { slot = args[1], sprite = args[2] } + end + end + pc = pc + 1 + size + if Opcodes.TERMINATORS[info.name] then break end + end + self:write("initial_events", { + generation = 2, + source = "ROM:InitializeEventsScript", + flags = flags, + engineFlags = engineFlags, + sprites = sprites, + }) +end + +-- PREDEFPAL_GAMEFREAK_LOGO_OB / _BG (constants/scgb_constants.asm), the two +-- palettes _CGB_GamefreakLogo loads: the OB pair is white/white/yellow/yellow +-- and the BG runs black to white. +-- constants/scgb_constants.asm, the constant immediately before +-- PREDEFPAL_GAMEFREAK_LOGO_OB. +local PREDEFPAL_UNOWN_PUZZLE = 76 +local PREDEFPAL_GAMEFREAK_LOGO_OB = 77 +local PREDEFPAL_GAMEFREAK_LOGO_BG = 78 + +-- The GameFreak Presents splash's own graphics (engine/movie/splash.asm +-- GameFreakPresentsInit). Both labels are two INCBINs run together, which is +-- the thing that used to be got wrong here: the 15 tiles at the START of +-- GameFreakLogoGFX are gamefreak_presents' letter strip, not the logo, so the +-- old "first 15 tiles are the mark" read wrote a slice of text glyphs and the +-- splash drew nothing recognisable. +-- +-- GameFreakLogoGFX = gamefreak_presents.1bpp (13 tiles, vTiles1 $80-$8c) +-- + gamefreak_logo.1bpp (15 tiles, $8d-$9b) +-- GameFreakLogoStarsGFX = logo_star.2bpp (2 tiles, $9c-$9d) +-- + logo_sparkle.2bpp (3 tiles, $9e-$a0) +-- +-- The logo's 15 tiles are laid out row-major by OAMData_GSGameFreakLogo (tile +-- $00 at the top-left of a 3x5 block, then left to right), so a 24x40 sheet +-- 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 + local gfx = self:symbol("GameFreakLogoGFX") + local raw = self.rom:bytes(gfx.bank, gfx.address, 28 * 8) + local presents, logo = {}, {} + for i = 1, 13 * 8 do presents[i] = raw[i] or 0 end + for i = 1, 15 * 8 do logo[i] = raw[13 * 8 + i] or 0 end + -- 1bpp, so black ink on transparent: the splash paints its own black field + -- and these draw over it through the palette the port picks. + self:save(inkFrom1bpp(presents, 104, 8), "splash/presents.png") + self:save(inkFrom1bpp(logo, 24, 40), "splash/logo.png") + -- Kept at the old path too: a cache reader that predates this table still + -- 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 { + 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), + } +end + +-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the +-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls +-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema. +function RomExtractorGen2:extractOakSpeech(pokemon) + local charmap = self.manifest.charmap or {} + local texts = {} + for i = 1, 7 do + local label = ("_OakText%d"):format(i) + local sym = self:symbol(label) + texts[label] = self:decodeGen2Text(sym.bank, sym.address, charmap) + end + -- Trainer pics are always 7x7 tiles (GetTrainerPic / PlaceGraphic). + self:writeCompressedPic("PokemonProfPic", 7, "intro/oak.png") + self:writeCompressedPic("CalPic", 7, "intro/cal.png") + -- ShrinkPlayer frames (gfx/new_game/shrink{1,2}.2bpp.lz) : also 7x7. + if self.symbols.Shrink1Pic then + pcall(self.writeCompressedPic, self, "Shrink1Pic", 7, "intro/shrink1.png") + end + if self.symbols.Shrink2Pic then + pcall(self.writeCompressedPic, self, "Shrink2Pic", 7, "intro/shrink2.png") + end + local splash = self:splashGfx() + local marill = pokemon and pokemon.MARILL + local data = { + generation = 2, + source = "ROM:OakSpeech (_OakText1-7, PokemonProfPic, CalPic)", + music = "Music_Route30", + demoSpecies = "MARILL", + oakPic = "assets/generated/intro/oak.png", + playerPic = "assets/generated/intro/cal.png", + marillPic = marill and marill.spriteFront + or "assets/generated/battle/front/marill.png", + shrink1 = "assets/generated/intro/shrink1.png", + shrink2 = "assets/generated/intro/shrink2.png", + gamefreakLogo = "assets/generated/intro/gamefreak_logo.png", + splash = splash, + text = texts, + } + self:write("oak_speech", data) + return data +end + +-- ItemNames only (attributes / effects / TMs stay Phase 2). +function RomExtractorGen2:extractItems() + self:beginStage("Items") + local order = self.manifest.constants and self.manifest.constants.itemOrder + if not order or #order == 0 then + self:write("items", { + generation = 2, + source = "Gold items: itemOrder missing from manifest : re-run make_gold_manifest.py", + }) + self:tick("Items", 1, 1) + return {} + end + local charmap = self.manifest.charmap or {} + local consts = self.manifest.constants + local names = self:symbol("ItemNames") + local attributes = self:symbol("ItemAttributes") + local descriptions = self:symbol("ItemDescriptions") + local pocketOrder = consts.pocketOrder or {} + local menuOrder = consts.itemMenuOrder or {} + local heldOrder = consts.heldEffectOrder or {} + local out = { + generation = 2, + source = "ROM:ItemNames + ItemAttributes + constants/item_constants.asm", + pockets = pocketOrder, + } + -- ItemNames only has rows for item ids 1..NUM_ITEMS; the TM and HM items + -- past that carry no name of their own on the cart (the PACK prints + -- "TM08" from their TM number instead), so reading a name for them would + -- walk off the end of the table. + local nameCount = consts.itemNameCount or #order + local address = names.address + for index, itemId in ipairs(order) do + local value + if index <= nameCount then + local consumed + value, consumed = self.rom:readString( + names.bank, address, charmap, 0x50, 32) + address = address + consumed + end + if itemId and itemId ~= "UNUSED" then + -- ItemAttributes rows (ITEMATTR_STRUCT_LENGTH = 7): dw price; + -- db held effect, parameter, property, pocket; dn field menu, battle + -- menu. Rows are 1-based on item id, so MASTER_BALL (1) is row 0. + local base = attributes.address + (index - 1) * 7 + local price = self.rom:word(attributes.bank, base) + local property = self.rom:byte(attributes.bank, base + 4) + local pocket = self.rom:byte(attributes.bank, base + 5) + local menus = self.rom:byte(attributes.bank, base + 6) + local descAddress = self.rom:word( + descriptions.bank, descriptions.address + (index - 1) * 2) + out[itemId] = { + id = itemId, + index = index, + name = value, + source = ("ROM:ItemNames[%d]"):format(index), + price = price, + heldEffect = heldOrder[self.rom:byte(attributes.bank, base + 2) + 1], + heldParameter = self.rom:byte(attributes.bank, base + 3), + -- `property` is a bitfield, not an enum: shift_const CANT_SELECT is + -- bit 6 and CANT_TOSS bit 7 (constants/item_data_constants.asm), so + -- NO_LIMITS = 0 means "both allowed". + canSelect = math.floor(property / 0x40) % 2 == 0, + canToss = math.floor(property / 0x80) % 2 == 0, + propertyRaw = property, + -- Item types are declared `const_def 1`, so ITEM is 1 and the list is + -- indexed by the value itself, not value + 1. + pocket = pocketOrder[pocket] or pocket, + pocketId = pocket, + -- dn field, battle: high nibble is the field menu behavior. The + -- ITEMMENU_* enum has a `const_skip 3` hole between NOUSE and CURRENT, + -- so a positional list cannot be indexed by value. + fieldMenu = ITEM_MENU_NAME[math.floor(menus / 16)], + battleMenu = ITEM_MENU_NAME[menus % 16], + description = self.rom:readString( + descriptions.bank, descAddress, charmap, 0x50, 128), + } + end + if index % 32 == 0 then + self:tick("Items", index, #order) + end + end + + -- TM/HM items carry no name of their own in ItemNames: add_tm/add_hm name + -- the item TM_<MOVE> (the "TM08" form is only the TM##_MOVE alias) and give + -- it a TM/HM number, which is its position in that declaration order. The + -- move it teaches comes from TMHMMoves, indexed by that number -- which is + -- also what makes a species' BASE_TMHM bitfield readable. + local tmhmMoves = self:symbol("TMHMMoves") + local moveOrder = consts.moveOrder or {} + local number = 0 + local hmCount = 0 + for _, itemId in ipairs(order) do + local entry = out[itemId] + if entry and (itemId:match("^TM_") or itemId:match("^HM_")) then + number = number + 1 + local moveId = self.rom:byte( + tmhmMoves.bank, tmhmMoves.address + number - 1) + entry.tmNumber = number + entry.teaches = moveOrder[moveId] or moveId + -- The label the PACK prints: TM01..TM50, then HM01 onwards (the HMs + -- restart their own numbering). + if itemId:match("^HM_") then + hmCount = hmCount + 1 + entry.tmLabel = ("HM%02d"):format(hmCount) + else + entry.tmLabel = ("TM%02d"):format(number) + end + -- These items have no ItemNames row, so the label IS the name. + entry.name = entry.name or entry.tmLabel + end + end + self:write("items", out) + self:tick("Items", #order, #order) + return out +end + +-- Mart shelves (data/items/marts.asm). Marts is NUM_MARTS same-bank `dw` +-- pointers in MART_* order; each list is `db count`, count item ids, `db -1`. +-- Written as `lists`, a 1-based array in that same order, so +-- src/ui/gen2/MartMenu.lua's inventory() indexes it with martId + 1 exactly +-- the way GetMart adds the id to the table. BargainShopData +-- (data/items/bargain_shop.asm) is its own `dbw item, price` rows -- the one +-- shop whose prices bypass ItemAttributes -- and lands under `bargain`. +-- +-- Both symbols are post-Phase-2 manifest additions, so a manifest from before +-- them writes a marts.lua with no lists at all rather than failing the whole +-- import; MartMenu treats that as the empty shelf it already handles. +local NUM_MARTS = 34 -- constants/mart_constants.asm, MART_UNDERGROUND is 33 +function RomExtractorGen2:extractMarts() + self:beginStage("Marts") + local order = (self.manifest.constants + and self.manifest.constants.itemOrder) or {} + local out = { + generation = 2, + source = "ROM:Marts + BargainShopData (data/items/marts.asm)", + } + local marts = self.symbols["Marts"] + if marts then + local bank = marts[1] + local lists = {} + for index = 0, NUM_MARTS - 1 do + local address = self.rom:word(bank, marts[2] + index * 2) + local count = self.rom:byte(bank, address) + local list = {} + for slot = 1, count do + local id = self.rom:byte(bank, address + slot) + if id == 0xff then break end + list[#list + 1] = order[id] or id + end + lists[index + 1] = list + end + out.lists = lists + end + local bargain = self.symbols["BargainShopData"] + if bargain then + local bank, address = bargain[1], bargain[2] + 1 -- past the count byte + local rows = {} + while true do + local id = self.rom:byte(bank, address) + if id == 0xff then break end + rows[#rows + 1] = { + item = order[id] or id, + price = self.rom:word(bank, address + 1), + } + address = address + 3 + end + out.bargain = rows + end + self:write("marts", out) + self:tick("Marts", 1, 1) + return out +end + +-- Moves + type chart. Shapes deliberately match Gen 1's moves.lua and +-- type_chart.lua so src/battle/TypeChart.lua reads either generation, but the +-- contents are Gen 2's: 251 moves, an effect-chance byte, and the Steel/Dark +-- rows that make the matchup table longer than Gen 1's. +function RomExtractorGen2:extractMoves() + self:beginStage("Moves") + local consts = self.manifest.constants + local order = consts.moveOrder or {} + local effects = consts.moveEffectOrder or {} + local typeById = {} + for name, value in pairs(consts.types or {}) do typeById[value] = name end + local charmap = self.manifest.charmap or {} + + local moves = self:symbol("Moves") + local names = self:symbol("MoveNames") + local descriptions = self:symbol("MoveDescriptions") + local out = { generation = 2, source = "ROM:Moves + MoveNames" } + local nameAddress = names.address + for index, moveId in ipairs(order) do + local row = self.rom:bytes( + moves.bank, moves.address + (index - 1) * 7, 7) + local name, consumed = self.rom:readString( + names.bank, nameAddress, charmap, 0x50, 32) + nameAddress = nameAddress + consumed + -- Descriptions are a pointer table into the same bank, one per move. + local descAddress = self.rom:word( + descriptions.bank, descriptions.address + (index - 1) * 2) + local description = self.rom:readString( + descriptions.bank, descAddress, charmap, 0x50, 128) + if moveId and moveId ~= "UNUSED" then + out[moveId] = { + id = moveId, + index = index, + name = name, + source = ("ROM:Moves[%d]"):format(index), + animation = row[1], + effect = effects[row[2] + 1] or row[2], + effectId = row[2], + power = row[3], + type = typeById[row[4]] or row[4], + -- `db N percent` stores 255*N/100, so 100% is $ff; convert back to a + -- real percentage the way Gen 1's moves.lua reads (BLIZZARD 90, not + -- $e6). The raw byte is kept for anything that wants to reproduce the + -- cart's out-of-256 accuracy roll exactly. + accuracy = percentOf(row[5]), + accuracyRaw = row[5], + pp = row[6], + effectChance = percentOf(row[7]), + effectChanceRaw = row[7], + } + out[moveId].description = description + end + if index % 32 == 0 then self:tick("Moves", index, #order) end + end + self:write("moves", out) + + -- TypeMatchups: attacker/defender/x10 triples. -2 ends the normal rows and + -- starts the Foresight-only block (which removes Ghost's immunities); -1 + -- ends the table. Both blocks are kept, tagged, so a Foresight + -- implementation has the rows without re-reading the cart. + local matchupSymbol = self:symbol("TypeMatchups") + local matchups, foresight = {}, {} + local target = matchups + local offset = 0 + while offset < 0x400 do + local attacker = self.rom:byte( + matchupSymbol.bank, matchupSymbol.address + offset) + if attacker == 0xff then break end + if attacker == 0xfe then + target = foresight + offset = offset + 1 + else + local defender = self.rom:byte( + matchupSymbol.bank, matchupSymbol.address + offset + 1) + local multiplier = self.rom:byte( + matchupSymbol.bank, matchupSymbol.address + offset + 2) + target[#target + 1] = { + attacker = typeById[attacker] or attacker, + defender = typeById[defender] or defender, + multiplier = multiplier, + } + offset = offset + 3 + end + end + + -- Gen 2 still splits physical/special by *type*, not by move: ids below + -- SPECIAL are physical. The type constants leave a gap before the special + -- block, so the boundary is the numeric id, not a position in a list. + local specialBoundary = (consts.types and consts.types.FIRE) or 0x14 + local typeNames = self:symbol("TypeNames") + local names2, records = {}, {} + for name, value in pairs(consts.types or {}) do + local pointer = self.rom:word( + typeNames.bank, typeNames.address + value * 2) + local display = self.rom:readString( + typeNames.bank, pointer, charmap, 0x50, 16) + names2[name] = display + records[name] = { + id = name, index = value, name = display, + category = (value < specialBoundary) and "physical" or "special", + } + end + self:write("type_chart", { + generation = 2, + source = "ROM:TypeMatchups + TypeNames", + names = names2, + types = records, + matchups = matchups, + foresightMatchups = foresight, + }) + self:tick("Moves", #order, #order) + return out +end + +-- Wild encounters. Grass tables carry three separate 7-slot lists (morn / +-- day / nite) plus a per-time encounter rate, which is the Gen 2 mechanic +-- that makes the clock part of gameplay rather than just lighting. +local GRASS_SLOTS = 7 -- NUM_GRASSMON +local WATER_SLOTS = 3 -- NUM_WATERMON +local GRASS_RECORD = 2 + 3 + GRASS_SLOTS * 2 * 3 -- GRASS_WILDDATA_LENGTH (47) +local WATER_RECORD = 2 + 1 + WATER_SLOTS * 2 -- WATER_WILDDATA_LENGTH (9) + +function RomExtractorGen2:readGrassTable(symbolName) + local symbol = self:symbol(symbolName) + local out = {} + local offset = 0 + -- Terminator-driven, but bounded: a bad pointer must fail the import loudly + -- rather than walk the whole cart. + for _ = 1, 512 do + local group = self.rom:byte(symbol.bank, symbol.address + offset) + if group == 0xff then break end + local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1) + local mapId = self:mapNameByIds(group, mapNum) + local rates = {} + for i = 0, 2 do + rates[DAYTIMES[i + 1]] = self.rom:byte( + symbol.bank, symbol.address + offset + 2 + i) + end + local slots = {} + for day = 0, 2 do + local list = {} + for slot = 0, GRASS_SLOTS - 1 do + local base = symbol.address + offset + 5 + + (day * GRASS_SLOTS + slot) * 2 + list[slot + 1] = { + level = self.rom:byte(symbol.bank, base), + species = self:speciesName( + self.rom:byte(symbol.bank, base + 1)), + } + end + slots[DAYTIMES[day + 1]] = list + end + if mapId then + out[mapId] = { map = mapId, rates = rates, slots = slots } + end + offset = offset + GRASS_RECORD + end + return out +end + +function RomExtractorGen2:readWaterTable(symbolName) + local symbol = self:symbol(symbolName) + local out = {} + local offset = 0 + for _ = 1, 512 do + local group = self.rom:byte(symbol.bank, symbol.address + offset) + if group == 0xff then break end + local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1) + local mapId = self:mapNameByIds(group, mapNum) + local rate = self.rom:byte(symbol.bank, symbol.address + offset + 2) + local slots = {} + for slot = 0, WATER_SLOTS - 1 do + local base = symbol.address + offset + 3 + slot * 2 + slots[slot + 1] = { + level = self.rom:byte(symbol.bank, base), + species = self:speciesName(self.rom:byte(symbol.bank, base + 1)), + } + end + if mapId then + out[mapId] = { map = mapId, rate = rate, slots = slots } + end + offset = offset + WATER_RECORD + end + return out +end + +function RomExtractorGen2:speciesName(index) + local order = self.manifest.constants.speciesOrder or {} + return order[index] or index +end + +-- data/wild/treemon_maps.asm: `treemon_map` rows of a two-byte map_id plus one +-- TREEMON_SET_* byte, ending at -1. TreeMonMaps and RockMonMaps are the same +-- table read twice -- GetTreeMonSet takes the list in hl, so HEADBUTT passes +-- one and ROCK SMASH the other (engine/events/treemons.asm). +function RomExtractorGen2:readTreeMonMaps(symbolName) + local symbol = self:symbol(symbolName) + local out = {} + local offset = 0 + while offset < 0x200 do + local group = self.rom:byte(symbol.bank, symbol.address + offset) + if group == 0xff then break end + local mapNum = self.rom:byte(symbol.bank, symbol.address + offset + 1) + local set = self.rom:byte(symbol.bank, symbol.address + offset + 2) + local mapId = self:mapNameByIds(group, mapNum) + if mapId then + out[mapId] = (self.manifest.constants.treeMonSetOrder or {})[set + 1] + or set + end + offset = offset + 3 + end + return out +end + +-- data/wild/roammon_maps.asm RoamMaps. A row is `map_id` (group, number), a +-- count byte, that many `map_id` pairs and a 0 that ends the row -- the 0 is +-- what `.Update`'s `.next` scan walks to when the start map does not match -- +-- and -1 ends the table. ORDER IS BEHAVIOUR: `.Update` picks a connection by +-- a two-bit index into the row and JumpRoamMon picks a ROW by a four-bit +-- index, so a reordered table sends the beasts somewhere else. +function RomExtractorGen2:readRoamMaps() + local symbol = self:symbol("RoamMaps") + local out = {} + local at = symbol.address + for _ = 1, 64 do + local group = self.rom:byte(symbol.bank, at) + if group == 0xff then break end + local mapNum = self.rom:byte(symbol.bank, at + 1) + local count = self.rom:byte(symbol.bank, at + 2) + local to = {} + for i = 0, count - 1 do + local toId = self:mapNameByIds( + self.rom:byte(symbol.bank, at + 3 + i * 2), + self.rom:byte(symbol.bank, at + 4 + i * 2)) + if toId then to[#to + 1] = toId end + end + local mapId = self:mapNameByIds(group, mapNum) + if mapId then out[#out + 1] = { map = mapId, to = to } end + at = at + 3 + count * 2 + 1 + end + return out +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 +-- ChooseWildEncounter_BugContest (engine/overworld/events.asm) walks with +-- `ld de, 4`, subtracting each chance byte from a 0..99 roll until it borrows. +-- +-- The list has no terminator. The ten real rows already add to 100, so the +-- walk cannot get past the eleventh -- whose chance byte is -1, i.e. "always" +-- -- and that row is what ends the read here. It is carried anyway, because +-- it is a row of the cart's table and it is the row a chance list edited +-- anywhere above it would fall through to. +local CONTEST_MON_RECORD = 4 + +function RomExtractorGen2:readContestMons() + local symbol = self:symbol("ContestMons") + local out = {} + for row = 0, 31 do + local base = symbol.address + row * CONTEST_MON_RECORD + -- Bounded the way the rod lists above are: a table with no terminator must + -- stop at the end of its bank rather than assert out of the import + -- coroutine. + if not romAddrOk(symbol.bank, base + CONTEST_MON_RECORD - 1) then break end + local raw = self.rom:bytes(symbol.bank, base, CONTEST_MON_RECORD) + out[row + 1] = { + chance = raw[1], + species = self:speciesName(raw[2]), + min = raw[3], + max = raw[4], + } + if raw[1] == 0xff then break end + end + return out +end + +function RomExtractorGen2:extractEncounters() + self:beginStage("Wild encounters") + local grass = {} + for _, name in ipairs({ "JohtoGrassWildMons", "KantoGrassWildMons" }) do + self:trace("grass " .. name) + for mapId, entry in pairs(self:readGrassTable(name)) do + grass[mapId] = entry + end + end + self:tick("Wild encounters", 1, 3) + local water = {} + for _, name in ipairs({ "JohtoWaterWildMons", "KantoWaterWildMons" }) do + self:trace("water " .. name) + for mapId, entry in pairs(self:readWaterTable(name)) do + water[mapId] = entry + end + end + self:tick("Wild encounters", 2, 3) + + -- FishGroups rows: chance byte then old/good/super rod pointers, each a + -- list of (cumulative chance, species, level) triples ending at 100%. + self:trace("fish groups") + local fish = self:symbol("FishGroups") + local fishGroups = {} + local function readRod(address) + local list = {} + for i = 0, 7 do + -- A rod list is terminated by its 100% row, not a length, so bail on a + -- pointer that has left the bank rather than letting Rom.offset assert + -- from inside the import coroutine. + if not romAddrOk(fish.bank, address + i * 3 + 2) then break end + local chance = self.rom:byte(fish.bank, address + i * 3) + local species = self.rom:byte(fish.bank, address + i * 3 + 1) + local level = self.rom:byte(fish.bank, address + i * 3 + 2) + list[#list + 1] = { + chance = chance, + species = self:speciesName(species), + level = level, + } + -- Rows are cumulative and the last one is 100% ($ff after `percent`). + if chance >= 0xfe then break end + end + return list + end + -- FishGroups has NUM_FISHGROUPS (13) rows, one per group *except* + -- FISHGROUP_NONE: the constants start at 0 with NONE, so the table's row for + -- a group is its id minus one. Walking fishGroupOrder from position 1 reads + -- every row shifted by one and runs a row off the end of the table. + local fishOrder = self.manifest.constants.fishGroupOrder or {} + for index = 2, #fishOrder do + local groupId = fishOrder[index] + local row = index - 2 + local base = fish.address + row * 7 + fishGroups[groupId] = { + id = groupId, + index = index - 1, + chance = self.rom:byte(fish.bank, base), + old = readRod(self.rom:word(fish.bank, base + 1)), + good = readRod(self.rom:word(fish.bank, base + 3)), + super = readRod(self.rom:word(fish.bank, base + 5)), + } + end + + -- Headbutt trees: map -> TREEMON_SET_*, and each set's common/rare lists. + self:trace("headbutt trees") + local trees = self:readTreeMonMaps("TreeMonMaps") + -- RockMonMaps, the four maps whose smashable rocks can hold a wild mon + -- (Cianwood, Route 40, Dark Cave Violet Entrance, Slowpoke Well B1F). Gated + -- on the symbol so an older manifest still imports; RockMonEncounter answers + -- "nothing here" for every map when the table is missing. + local rocks = self.symbols.RockMonMaps + and self:readTreeMonMaps("RockMonMaps") or nil + + -- TreeMons: a pointer per TREEMON_SET_*, each aiming at TWO `db %, species, + -- level` lists back to back -- the common one and the rare one -- with a + -- -1 between them. Which of the two is rolled comes from how hard the tree + -- was hit (engine/events/treemons.asm), so both are carried here. + local treeSets = {} + local treeMonsSymbol = self.symbols["TreeMons"] and self:symbol("TreeMons") + if treeMonsSymbol then + local setOrder = self.manifest.constants.treeMonSetOrder or {} + for index, name in ipairs(setOrder) do + local pointer = self.rom:word(treeMonsSymbol.bank, + treeMonsSymbol.address + (index - 1) * 2) + local at = pointer + local lists = {} + for _ = 1, 2 do + local rows = {} + for _ = 1, 16 do + local chance = self.rom:byte(treeMonsSymbol.bank, at) + if chance == 0xff then + at = at + 1 + break + end + local species = self.rom:byte(treeMonsSymbol.bank, at + 1) + local level = self.rom:byte(treeMonsSymbol.bank, at + 2) + rows[#rows + 1] = { + chance = chance, + species = (self.manifest.constants.speciesOrder or {})[species], + level = level, + } + at = at + 3 + end + lists[#lists + 1] = rows + end + treeSets[name] = { common = lists[1] or {}, rare = lists[2] or {} } + end + end + + -- The Bug Catching Contest's own table. It sits beside the grass rather + -- than inside it because the park's encounters come from HERE for the + -- twenty minutes the contest runs and from NATIONAL_PARK's grass row the + -- rest of the time. + -- Gated on the symbol the same way TreeMons above is, so an older manifest + -- still imports and BugContest.MONS carries the park. + self:trace("bug contest mons") + local bugContest = self.symbols.ContestMons and self:readContestMons() or nil + + -- The two tables that sit IN FRONT of the ordinary ones. Swarms are the + -- same grass / water records keyed by map (_SwarmWildmonCheck searches them + -- before the Johto list), and RoamMaps is where the three beasts may walk. + -- Both were hand-written in src/core/gen2/Roamers.lua until the extractor + -- reached them; that table stays as the fallback for an older cache. + self:trace("swarms and roam maps") + local swarmGrass = self.symbols.SwarmGrassWildMons + and self:readGrassTable("SwarmGrassWildMons") or nil + local swarmWater = self.symbols.SwarmWaterWildMons + and self:readWaterTable("SwarmWaterWildMons") or nil + local roamMaps = self.symbols.RoamMaps and self:readRoamMaps() or nil + + local data = { + generation = 2, + source = "ROM:JohtoGrassWildMons/KantoGrassWildMons/*WaterWildMons/FishGroups", + grass = grass, + water = water, + fishGroups = fishGroups, + trees = trees, + rocks = rocks, + treeSets = treeSets, + bugContest = bugContest, + swarmGrass = swarmGrass, + swarmWater = swarmWater, + roamMaps = roamMaps, + } + self:trace("writing encounters.lua") + self:write("encounters", data) + self:tick("Wild encounters", 3, 3) + return data +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. +function RomExtractorGen2:extractTrainers() + self:beginStage("Trainers") + local consts = self.manifest.constants + local classOrder = consts.trainerClassOrder or {} + local members = consts.trainerClassMembers or {} + local moveOrder = consts.moveOrder or {} + local itemOrder = consts.itemOrder or {} + local charmap = self.manifest.charmap or {} + + local groups = self:symbol("TrainerGroups") + local trainers = self:symbol("Trainers") + local classNames = self:symbol("TrainerClassNames") + local attributes = self:symbol("TrainerClassAttributes") + -- data/trainers/encounter_music.asm: `table_width 1`, one MUSIC_* id per + -- trainer class starting at class 0 (TRAINER_NONE), which is what + -- PlayTrainerEncounterMusic plays while the trainer walks up to you -- the + -- short encounter jingle, not the battle theme that follows it. + local encounterMusic = self:symbol("TrainerEncounterMusic") + local musicOrder = consts.musicOrder or {} + + local out = { + generation = 2, + source = "ROM:TrainerGroups + Trainers + TrainerClassNames" + .. " + TrainerEncounterMusic", + classes = {}, + } + + -- TrainerClassNames is a plain @-terminated list in class order, starting + -- at class 1 (TRAINER_NONE has no name row). + local nameAddress = classNames.address + local classDisplay = {} + for index = 2, #classOrder do + local value, consumed = self.rom:readString( + classNames.bank, nameAddress, charmap, 0x50, 24) + nameAddress = nameAddress + consumed + classDisplay[classOrder[index]] = value + end + + local typeOrder = consts.trainerTypeOrder or {} + for classIndex = 2, #classOrder do + local className = classOrder[classIndex] + local classId = classIndex - 1 + local pointer = self.rom:word( + groups.bank, groups.address + (classId - 1) * 2) + local address = pointer + local parties = {} + local memberNames = members[className] or {} + -- A group has no end marker: FalknerGroup's single party is followed + -- straight by WhitneyGroup's label, so a scan that only watches for the + -- party's own -1 walks through every remaining group and off the bank. + -- The count comes from trainer_constants.asm (each `trainerclass` opens a + -- const_def whose entries ARE that group's trainers), with the next + -- group's pointer as the belt-and-braces bound. + local expected = #memberNames + local nextPointer = 0x8000 + if classIndex < #classOrder then + local following = self.rom:word( + groups.bank, groups.address + classId * 2) + if following > pointer then nextPointer = following end + end + while (expected > 0 and #parties < expected) + or (expected == 0 and #parties < 1) do + if address >= nextPointer or not romAddrOk(trainers.bank, address) then + break + end + local first = self.rom:byte(trainers.bank, address) + if first == 0xff then break end + local name, consumed = self.rom:readString( + trainers.bank, address, charmap, 0x50, 16) + address = address + consumed + local trainerType = self.rom:byte(trainers.bank, address) + address = address + 1 + local hasMoves = trainerType == 1 or trainerType == 3 + local hasItem = trainerType == 2 or trainerType == 3 + local party = {} + while #party < 6 do + local level = self.rom:byte(trainers.bank, address) + if level == 0xff then break end + local species = self.rom:byte(trainers.bank, address + 1) + address = address + 2 + local mon = { + level = level, + species = self:speciesName(species), + } + if hasItem then + mon.item = itemOrder[self.rom:byte(trainers.bank, address)] + address = address + 1 + end + if hasMoves then + local moves = {} + for i = 0, 3 do + local moveId = self.rom:byte(trainers.bank, address + i) + if moveId ~= 0 then moves[#moves + 1] = moveOrder[moveId] end + end + address = address + 4 + mon.moves = moves + end + party[#party + 1] = mon + end + -- Skip the -1 that ends this party. + address = address + 1 + parties[#parties + 1] = { + id = memberNames[#parties + 1] or + ("%s%d"):format(className, #parties + 1), + index = #parties + 1, + name = name, + trainerType = typeOrder[trainerType + 1] or trainerType, + party = party, + } + end + -- TrainerClassAttributes is SEVEN bytes a class, not eight: + -- NUM_TRAINER_ATTRIBUTES is `_RS` after three `rb` and two `rw` + -- (constants/trainer_data_constants.asm): + -- 1-2 TRNATTR_ITEM1 / ITEM2 the two items the trainer may use + -- 3 TRNATTR_BASEMONEY the reward multiplier + -- 4-5 TRNATTR_AI_MOVE_WEIGHTS which scoring layers run + -- 6-7 TRNATTR_AI_ITEM_SWITCH how eager it is to rotate, and when it + -- is allowed to reach for an item + -- An eight-byte stride walks a byte further off with every class, which is + -- why the AI flags used to be noise past the first few trainers. + local attrRow = self.rom:bytes( + attributes.bank, attributes.address + (classId - 1) * 7, 7) + local carried = {} + for slot = 1, 2 do + local name = itemOrder[attrRow[slot]] + if name and name ~= "NO_ITEM" then carried[#carried + 1] = name end + end + local musicId = self.rom:byte( + encounterMusic.bank, encounterMusic.address + classId) + out.classes[className] = { + id = className, + index = classId, + name = classDisplay[className] or className, + encounterMusic = musicOrder[(musicId or 0) + 1], + -- Row 3, not row 1: the first two bytes are the items. + baseMoney = attrRow[3], + attributes = attrRow, + items = carried, + trainers = parties, + } + self:tick("Trainers", classIndex, #classOrder) + end + self:write("trainers", out) + return out +end + +-- callstd / jumpstd targets. StdScripts is a `dba` table (bank $40), and its +-- ids are exactly the stdScriptOrder the manifest scraped from +-- engine/events/std_scripts.asm. Resolving them is what lets +-- ReceiveItemScript, the mart/statue helpers and the phone-number scripts run +-- without every map duplicating their text. +-- +-- The bodies themselves are disassembled by extractScriptsAndText, which seeds +-- its walk from `key` here -- one code path for map and std scripts alike. +function RomExtractorGen2:extractStdScripts() + self:beginStage("Std scripts") + local order = self.manifest.constants.stdScriptOrder or {} + local table_ = self:symbol("StdScripts") + local out = { + generation = 2, + source = "ROM:StdScripts + engine/events/std_scripts.asm order", + order = order, + scripts = {}, + -- id -> label, so a `callstd 2` in a disassembled script reads as a name. + byId = {}, + } + for index, label in ipairs(order) do + local base = table_.address + (index - 1) * 3 + local bank = self.rom:byte(table_.bank, base) + local address = self.rom:word(table_.bank, base + 1) + local entry = { + id = label, + index = index - 1, + bank = bank, + address = address, + key = Opcodes.key(bank, address), + } + out.scripts[label] = entry + out.byId[index - 1] = label + self:tick("Std scripts", index, #order) + end + self:write("std_scripts", out) + return out +end + +-- Pokedex: the two alternate orderings the #DEX screen can sort by, plus each +-- species' entry (kind name, height/weight, description pages). +function RomExtractorGen2:extractPokedex() + self:beginStage("Pokedex") + local consts = self.manifest.constants + local speciesOrder = consts.speciesOrder or {} + local charmap = self.manifest.charmap or {} + + -- Entries are spread over four banks ("Pokedex Entries 001-064" and + -- friends) and the game picks the bank by rotating the species id + -- (engine/pokegear/radio.asm), so each entry's own symbol is the reliable + -- address -- the same reason pic labels are resolved per species. + local entries = {} + for index, species in ipairs(speciesOrder) do + local asset = self.manifest.pokemonAssets[species] + local symbol = asset and asset.dexLabel and self.symbols[asset.dexLabel] + if symbol and species ~= "UNUSED" then + local bank, address = symbol[1], symbol[2] + local kind, consumed = self.rom:readString( + bank, address, charmap, 0x50, 24) + local sizeAt = address + consumed + -- Both little-endian, and both are already the digits DisplayDexEntry + -- prints rather than a physical unit: the height word is fed to PrintNum + -- as 4 digits with 2 in front of the point (204 -> 2'04") and the weight + -- word as 5 digits with 4 in front (150 -> 15.0 lb). + local height = self.rom:word(bank, sizeAt) + local weight = self.rom:word(bank, sizeAt + 2) + -- The description is two pages: the `page` macro is a bare "@", so the + -- second page begins where the first one's terminator left off and + -- GetDexEntryPagePointer finds it by walking past that @. + local text, page1Length = self.rom:readString( + bank, sizeAt + 4, charmap, 0x50, 256) + local text2 = self.rom:readString( + bank, sizeAt + 4 + page1Length, charmap, 0x50, 256) + entries[species] = { + id = species, dex = index, + kind = kind, height = height, weight = weight, + text = text, text2 = text2, + } + end + if index % 32 == 0 then self:tick("Pokedex", index, #speciesOrder) end + end + + local function readOrder(symbolName, length) + local symbol = self:symbol(symbolName) + local list = {} + for i = 0, length - 1 do + local id = self.rom:byte(symbol.bank, symbol.address + i) + list[i + 1] = speciesOrder[id] or id + end + return list + end + + local data = { + generation = 2, + source = "ROM:PokedexDataPointerTable + NewPokedexOrder", + entries = entries, + -- New (Johto) order is what Gold's #DEX lists by default; alphabetical is + -- the other sort the screen offers. + newOrder = readOrder("NewPokedexOrder", #speciesOrder), + alphabeticalOrder = readOrder("AlphabeticalPokedexOrder", #speciesOrder), + } + self:write("pokedex", data) + self:tick("Pokedex", #speciesOrder, #speciesOrder) + return data +end + +-- Pokegear town map: each landmark's pixel position and name. The macro adds +-- the screen origin (x + 8, y + 16) so the values are already OAM +-- coordinates; store them back in tilemap space, which is what a UI wants. +function RomExtractorGen2:extractLandmarks() + self:beginStage("Landmarks") + local order = self.manifest.constants.landmarkOrder or {} + local symbol = self:symbol("Landmarks") + local charmap = self.manifest.charmap or {} + local out = { generation = 2, source = "ROM:Landmarks", order = order, + landmarks = {} } + for index, id in ipairs(order) do + local base = symbol.address + (index - 1) * 4 + local x = self.rom:byte(symbol.bank, base) - 8 + local y = self.rom:byte(symbol.bank, base + 1) - 16 + local pointer = self.rom:word(symbol.bank, base + 2) + local name = self.rom:readString( + symbol.bank, pointer, charmap, 0x50, 24) + -- charmap.asm: <BSP> ($1f) is a "breakable space", which the Town Map + -- renders as a line break -- that is what splits NEW BARK / TOWN onto two + -- rows. Store it as a newline so a UI can lay it out either way. + name = tostring(name):gsub("<BSP>", "\n") + out.landmarks[id] = { id = id, index = index - 1, x = x, y = y, name = name } + self:tick("Landmarks", index, #order) + end + + -- SpawnPoints (data/maps/spawn_points.asm): map_id + x/y, one row per + -- SPAWN_*. SPAWN_HOME is where a New Game puts the player -- the bedroom + -- upstairs in the player's house, not outside (intro_menu.asm NewGame sets + -- wDefaultSpawnpoint = SPAWN_HOME and warps there). The rest are Pokecenter + -- respawns. + local spawnSymbol = self:symbol("SpawnPoints") + out.spawns = {} + for index, id in ipairs(self.manifest.constants.spawnOrder or {}) do + local base = spawnSymbol.address + (index - 1) * 4 + local group = self.rom:byte(spawnSymbol.bank, base) + local mapNum = self.rom:byte(spawnSymbol.bank, base + 1) + out.spawns[id] = { + id = id, + index = index - 1, + map = self:mapNameByIds(group, mapNum), + x = self.rom:byte(spawnSymbol.bank, base + 2), + y = self.rom:byte(spawnSymbol.bank, base + 3), + } + end + + self:write("landmarks", out) + return out +end + +-- Party-menu mon icons: 16x32 sheets (two 16x16 animation frames stacked), +-- shared between species via MonMenuIcons. +local ICON_TILES = 8 + +function RomExtractorGen2:extractIcons() + self:beginStage("Menu icons") + local consts = self.manifest.constants + local iconOrder = consts.iconOrder or {} + local speciesOrder = consts.speciesOrder or {} + local pointers = self:symbol("IconPointers") + local icons = self:symbol("Icons") + + local out = { generation = 2, source = "ROM:IconPointers + MonMenuIcons", + icons = {}, species = {} } + for index, iconId in ipairs(iconOrder) do + local address = self.rom:word( + pointers.bank, pointers.address + (index - 1) * 2) + local raw = self.rom:bytes(icons.bank, address, ICON_TILES * 16) + local base = iconId:lower():gsub("^icon_", "") + local rel = "icons/gen2/" .. base .. ".png" + -- Icons are OBJ sprites, so shade 0 is the transparent color. + self:write2bpp(raw, 16, 32, rel, true) + out.icons[iconId] = { + id = iconId, index = index - 1, + image = "assets/generated/" .. rel, + width = 16, height = 32, frames = 2, + } + self:tick("Menu icons", index, #iconOrder) + end + local monIcons = self:symbol("MonMenuIcons") + for index, species in ipairs(speciesOrder) do + local iconId = self.rom:byte( + monIcons.bank, monIcons.address + (index - 1)) + out.species[species] = iconOrder[iconId + 1] or iconId + end + + -- .SpawnItemIcon (engine/gfx/mon_icons.asm:218-228): a party mon holding + -- something swaps its icon's BOTTOM-LEFT tile for one of the two + -- HeldItemIcons tiles (gfx/stats/mail.2bpp then gfx/stats/item.2bpp), which + -- GetIconGFX uploads straight after the eight icon tiles. One 8x16 sheet, + -- mail on top: the row order is load bearing, it is what + -- src/ui/gen2/PartyMenu.lua heldMarkerRow indexes with. + -- Tolerated rather than required, like UnownFont above: a manifest built + -- before the symbol was listed still imports, it just leaves the markers + -- undrawn. + if self.symbols["HeldItemIcons"] then + local markers = self:symbol("HeldItemIcons") + local rel = "icons/gen2/held_item_markers.png" + -- OBJ tiles, so shade 0 is transparent, the same as the icons above. + self:write2bpp(self.rom:bytes(markers.bank, markers.address, 2 * 16), + 8, 16, rel, true) + out.heldItem = { image = "assets/generated/" .. rel, + width = 8, height = 8, mailRow = 0, itemRow = 1 } + end + + self:write("icons", out) + return out +end + +-- The Gold/Silver intro movie's data (engine/movie/intro.asm). +-- +-- The water and grass acts build their background the same way +-- (Intro_DrawBackground / Intro_Draw2x2Tiles): a compressed tile sheet, a +-- metatile table where every entry is four tile ids in 2x2 order, and a grid +-- of metatile indices laid across the whole 32x32 BG map. None of it is +-- composed into a finished picture here, because the movie keeps editing the +-- map as it plays -- the water act streams a fresh metatile row in at the top +-- every 16 pixels of climb (Intro_UpdateTilemapAndBGMap) and repaints BG row +-- 15 from a four-frame wave cycle (Intro_AnimateOceanWaves), and the fire act +-- writes its Charizard rectangles straight into the map +-- (DrawIntroCharizardGraphic). So the tables ship as tables and +-- src/ui/gen2/GoldSilverIntro.lua runs the same routines over them. +-- +-- Tile sheets are written 16 tiles per row so a tile id resolves to +-- (id % 16, id / 16). That is what data/sprite_anims/oam.asm assumes -- a +-- 4x4 OBJ is $00..$03 over $10..$13 -- and what the BG maps assume too. +local INTRO_TILEMAP_WIDTH = 16 -- TILEMAP_WIDTH / 2, counted in metatiles +local INTRO_METATILE_LENGTH = 4 +local INTRO_SHEET_TILES = 16 + +-- The water tilemap is 16 metatiles wide by 32 tall; IntroScene1 starts +-- reading it at `Intro_WaterTilemap + 15 tiles` (row 15) and the climb walks +-- backwards a row at a time from there. The grass tilemap is just the 16 +-- rows that fill the BG map once. +local INTRO_WATER_TILEMAP_ROWS = 32 +local INTRO_GRASS_TILEMAP_ROWS = 16 +local INTRO_WATER_FIRST_ROW = 15 + +-- constants/scgb_constants.asm PREDEFPAL_*, for the palettes _CGB_GSIntro +-- pulls out of the shared pool rather than carrying inline. +local PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_BG = 56 +local PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_OB = 57 +local PREDEFPAL_GS_INTRO_STARTERS_TRANSITION = 58 + +-- OAMData_GSIntroStarter lays 25 tiles in five columns of five, which is how +-- big the three Johto starters' front pics are. +local INTRO_STARTER_TILES = 25 +-- IntroScene10's three `Intro_GetMonFrontpic` destinations, as tile ids in +-- the act's OBJ sheet (vTiles0). +local INTRO_STARTER_VTILES = { 0x10, 0x29, 0x42 } + +-- Pads a decompressed 2bpp stream out to whole 16-tile rows and writes it as +-- one sheet. Returns the asset path. +function RomExtractorGen2:writeIntroSheet(pixels, relative, transparent) + local tiles = math.floor(#pixels / 16) + local rows = math.max(1, math.ceil(tiles / INTRO_SHEET_TILES)) + local length = rows * INTRO_SHEET_TILES * 16 + while #pixels > length do table.remove(pixels) end + while #pixels < length do pixels[#pixels + 1] = 0 end + self:write2bpp(pixels, INTRO_SHEET_TILES * 8, rows * 8, relative, transparent) + return "assets/generated/" .. relative +end + +-- One act's BG data: the tile sheet plus the two tables Intro_Draw2x2Tiles +-- reads. Both tables ship as flat 1-based byte arrays; the metatile count is +-- taken from the highest index the grid actually names, so the table's end +-- does not have to be bounded by the next symbol. +function RomExtractorGen2:introBackground(gfxLabel, metaLabel, tilemapLabel, + tilemapRows, relative) + local out = {} + -- BG sheets are written with colour 0 transparent so a priority OBJ shows + -- through exactly where the hardware would let it; the backdrop the movie + -- draws under everything is BG palette colour 0. + out.tiles = self:writeIntroSheet( + self:decompressLz3Symbol(gfxLabel), relative, true) + + local tilemap = self:symbol(tilemapLabel) + local grid, highest = {}, 0 + for index = 0, tilemapRows * INTRO_TILEMAP_WIDTH - 1 do + local value = self.rom:byte(tilemap.bank, tilemap.address + index) + grid[index + 1] = value + if value > highest then highest = value end + end + out.tilemap = grid + out.tilemapRows = tilemapRows + + local meta = self:symbol(metaLabel) + local metatiles = {} + for index = 0, (highest + 1) * INTRO_METATILE_LENGTH - 1 do + metatiles[index + 1] = self.rom:byte(meta.bank, meta.address + index) + end + out.meta = metatiles + return out +end + +-- PredefPals is a flat pool of 4-colour palettes; GetPredefPal indexes it by +-- the PREDEFPAL_* constant (engine/gfx/color.asm). +function RomExtractorGen2:predefPal(index) + local pals = self:symbol("PredefPals") + return self:colors(pals.bank, pals.address + index * 8, 4) +end + +-- The movie's CGB palettes. Every act calls WipeAttrmap, so BG palette 0 +-- colours the entire screen; an OBJ's palette is the low 3 bits of its OAM +-- attribute byte, which is why the water act needs two and the others one. +function RomExtractorGen2:introPalettes() + local waterBg = self:symbol("_CGB_GSIntro.ShellderLaprasBGPalette") + local waterOb = self:symbol("_CGB_GSIntro.ShellderLaprasOBPals") + local karpBg = self:symbol("Intro_LoadMagikarpPalettes.MagikarpBGPal") + local karpOb = self:symbol("Intro_LoadMagikarpPalettes.MagikarpOBPal") + -- _CGB_GSIntro.StartersCharizardScene runs CopyFourPalettes over + -- PalPacket_Pack + 1, so the fire act's BG palettes are four PREDEFPAL_* + -- indices stored inside that SGB packet. + local packet = self:symbol("PalPacket_Pack") + local fireBg = {} + for slot = 1, 4 do + fireBg[slot] = self:predefPal( + self.rom:byte(packet.bank, packet.address + slot)) + end + return { + waterBg = self:colors(waterBg.bank, waterBg.address, 4), + waterOb = { + self:colors(waterOb.bank, waterOb.address, 4), + self:colors(waterOb.bank, waterOb.address + 8, 4), + }, + magikarpBg = self:colors(karpBg.bank, karpBg.address, 4), + magikarpOb = self:colors(karpOb.bank, karpOb.address, 4), + grassBg = self:predefPal(PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_BG), + grassOb = self:predefPal(PREDEFPAL_GS_INTRO_JIGGLYPUFF_PIKACHU_OB), + startersOb = self:predefPal(PREDEFPAL_GS_INTRO_STARTERS_TRANSITION), + fireBg = fireBg, + } +end + +function RomExtractorGen2:extractIntro() + self:beginStage("Intro movie") + local out = { generation = 2, source = "ROM:Intro_*GFX/Tilemap/Meta" } + + -- Act 1, underwater. + out.water = self:introBackground("Intro_WaterGFX1", "Intro_WaterMeta", + "Intro_WaterTilemap", INTRO_WATER_TILEMAP_ROWS, "intro/water_tiles.png") + out.water.firstRow = INTRO_WATER_FIRST_ROW + out.water.sprites = self:writeIntroSheet( + self:decompressLz3Symbol("Intro_WaterGFX2"), "intro/water_sprites.png", + true) + self:tick("Intro movie", 1, 4) + + -- Act 2, grass: same shape, its own sheet and grid, read from row 0. + out.grass = self:introBackground("Intro_GrassGFX1", "Intro_GrassMeta", + "Intro_GrassTilemap", INTRO_GRASS_TILEMAP_ROWS, "intro/grass_tiles.png") + out.grass.firstRow = 0 + out.grass.sprites = self:writeIntroSheet( + self:decompressLz3Symbol("Intro_GrassGFX2"), "intro/grass_sprites.png", + true) + self:tick("Intro movie", 2, 4) + + -- Act 3 has no tilemap: DrawIntroCharizardGraphic writes the silhouette's + -- rectangle of running tile ids into the map itself. Its BG tiles are + -- Intro_FireGFX1 at vTiles2 ($00-$7f) followed by Intro_FireGFX2 at + -- vTiles1 ($80-$cf), so the two decompress into one sheet. + local fireTiles = self:decompressLz3Symbol("Intro_FireGFX1") + while #fireTiles < 0x80 * 16 do fireTiles[#fireTiles + 1] = 0 end + while #fireTiles > 0x80 * 16 do table.remove(fireTiles) end + for _, byte in ipairs(self:decompressLz3Symbol("Intro_FireGFX2")) do + fireTiles[#fireTiles + 1] = byte + end + out.fire = { + tiles = self:writeIntroSheet(fireTiles, "intro/fire_tiles.png", true), + } + self:tick("Intro movie", 3, 4) + + -- The act's OBJ sheet is Intro_FireGFX3 (the fireball) with the three + -- Johto starters' front pics decompressed over the top of it at $10/$29/$42. + -- Pics are stored column-major and OAMData_GSIntroStarter reads them that + -- way, so they go in as-is rather than through columnsToRows. + local fireSprites = self:decompressLz3Symbol("Intro_FireGFX3") + for index, label in ipairs({ + "ChikoritaFrontpic", "CyndaquilFrontpic", "TotodileFrontpic", + }) do + local pic = self:decompressLz3Symbol(label) + local base = INTRO_STARTER_VTILES[index] * 16 + for offset = 1, INTRO_STARTER_TILES * 16 do + fireSprites[base + offset] = pic[offset] or 0 + end + end + out.fire.sprites = self:writeIntroSheet(fireSprites, + "intro/fire_sprites.png", true) + + out.palettes = self:introPalettes() + self:write("intro", out) + self:tick("Intro movie", 4, 4) + return out +end + +-- Menu chrome that is not part of the font: the naming screen's patterned +-- backdrop tile, its 2-tile cursor, and the middle/under line glyphs that mark +-- the name-entry field. These are loaded straight into VRAM by +-- LoadNamingScreenGFX rather than living in a font page, so nothing else in +-- the import would pick them up. +function RomExtractorGen2:extractMenuGfx() + self:beginStage("Menu graphics") + local out = { generation = 2, source = "ROM:NamingScreenGFX_*" } + + -- Border is 2bpp and tiles the whole screen, so it stays opaque. + local border = self:symbol("NamingScreenGFX_Border") + self:write2bpp(self.rom:bytes(border.bank, border.address, 16), 8, 8, + "naming/border.png") + out.border = "assets/generated/naming/border.png" + + -- Cursor is 2bpp and 2 tiles, drawn as an 8x16 OBJ (the naming screen's + -- cursor is a tall arrow, not a wide one), so the sheet is one tile wide by + -- two tall. It is an OBJ, so color 0 is transparent. + local cursor = self:symbol("NamingScreenGFX_Cursor") + self:write2bpp(self.rom:bytes(cursor.bank, cursor.address, 32), 8, 16, + "naming/cursor.png", true) + out.cursor = "assets/generated/naming/cursor.png" + + -- Both lines are 1bpp; inkFrom1bpp gives black-on-transparent so they can be + -- drawn in whatever color the screen is using. + for key, label in pairs({ + middleLine = "NamingScreenGFX_MiddleLine", + underLine = "NamingScreenGFX_UnderLine", + }) do + local symbol = self:symbol(label) + local raw = self.rom:bytes(symbol.bank, symbol.address, 8) + local rel = "naming/" .. key:lower() .. ".png" + self:save(inkFrom1bpp(raw, 8, 8), rel) + out[key] = "assets/generated/" .. rel + end + + -- Battle HUD tiles (engine/gfx/load_font.asm LoadHPBar). The HUD is built + -- out of tiles on the cart, so extracting them is what makes the layout + -- align on the 8px grid by construction instead of by eye: + -- + -- FontBattleExtra -> $60: "HP:" is $60/$61, then the HP bar's cells + -- $62 (empty) .. $6a (8px full) and $6b (end cap) + -- EnemyHPBarBorderGFX-> $6c: 4 tiles; $6d left side, $6f bottom left + -- HPExpBarBorderGFX -> $73: 6 tiles; $73 right side, $74 bottom left, + -- $76 bottom side, $77/$78 bottom right + -- ExpBarGFX -> $55: 9 exp-bar fill cells + -- + -- The two border sheets are 1bpp, so they come out as black ink on + -- transparent and can be drawn in any color; the exp bar is 2bpp. + local hud = {} + local enemyBorder = self:symbol("EnemyHPBarBorderGFX") + self:save(inkFrom1bpp( + self.rom:bytes(enemyBorder.bank, enemyBorder.address, 4 * 8), 32, 8), + "battle/hud/enemy_border.png") + hud.enemyBorder = "assets/generated/battle/hud/enemy_border.png" + hud.enemyBorderFirstTile = 0x6c + + local playerBorder = self:symbol("HPExpBarBorderGFX") + self:save(inkFrom1bpp( + self.rom:bytes(playerBorder.bank, playerBorder.address, 6 * 8), 48, 8), + "battle/hud/player_border.png") + hud.playerBorder = "assets/generated/battle/hud/player_border.png" + hud.playerBorderFirstTile = 0x73 + + -- The player's own back-pic, which stands in the player's box for the whole + -- battle intro. gfx/player/chris_back.png is 48x48, i.e. SIX tiles square: + -- GetTrainerBackpic's `ld c, 7 * 7` is how many tiles of VRAM it asks for, + -- not how big the pic is, and reading it as 7x7 scrambles every column. + -- Column-major like every other pic, hence columnsToRows. + if self.symbols["ChrisBackpic"] then + local ok = pcall(function() + self:writeCompressedPic("ChrisBackpic", 6, "battle/player_back.png") + end) + if ok then hud.playerBack = "assets/generated/battle/player_back.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). + if self.symbols["DudeBackpic"] then + local ok = pcall(function() + self:writeCompressedPic("DudeBackpic", 6, "battle/dude_back.png") + end) + if ok then hud.dudeBack = "assets/generated/battle/dude_back.png" end + end + + -- TrainerPicPointers (data/trainers/pic_pointers.asm): one `dba_pic` per + -- trainer class, `table_width 3`. GetTrainerPic (engine/gfx/load_pics.asm: + -- 254-257) indexes it with `ld a, [wTrainerClass] / dec a / ld bc, 3`, so + -- row 0 is FALKNER and TRAINER_NONE has no row at all. Trainer pics are + -- always 7 tiles square (`ld c, 7 * 7`, :277) and, unlike a mon frontpic, + -- are never PadFrontpic'd. + if self.symbols["TrainerPicPointers"] then + local symbol = self:symbol("TrainerPicPointers") + local classOrder = (self.manifest.constants or {}).trainerClassOrder or {} + local pics = {} + for index, class in ipairs(classOrder) do + -- 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 address = self.rom:word(symbol.bank, base + 1) + local rel = ("battle/trainers/%s.png"):format(class:lower()) + local ok, err = pcall(function() + -- Same bank-crossing read as the Unown pics: GetLZByte drops back + -- to $4000 in the next bank once the read pointer passes $8000. + local compressed = self.rom:bytes(bank, address, 0x8000 - address) + local nextBank = self.rom:bytes(bank + 1, 0x4000, 0x4000) + for _, byte in ipairs(nextBank) do + compressed[#compressed + 1] = byte + end + local pixels = Rom.decompressLz3(compressed) + local byteLength = 56 * 56 / 4 + while #pixels < byteLength do pixels[#pixels + 1] = 0 end + while #pixels > byteLength do table.remove(pixels) end + pixels = ImageWriter.columnsToRows(pixels, 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 + + local expBar = self:symbol("ExpBarGFX") + self:write2bpp(self.rom:bytes(expBar.bank, expBar.address, 9 * 16), + 72, 8, "battle/hud/exp_bar.png") + hud.expBar = "assets/generated/battle/hud/exp_bar.png" + hud.expBarFirstTile = 0x55 + hud.expBarCells = 9 + + -- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on- + -- transparent font page: the bar's rule is shade 3 (black) while its fill is + -- shade 1/2 (the HP colour), so flattening it to one ink loses the very + -- distinction that makes the bar readable. Drawn through GbcPalette with + -- palettes.hpBar, exactly like the cart colours it from HPBarPals. + local battleExtra = self:symbol("FontBattleExtra") + self:write2bpp( + self.rom:bytes(battleExtra.bank, battleExtra.address, 12 * 16), + 96, 8, "battle/hud/hp_bar.png") + hud.hpBar = "assets/generated/battle/hud/hp_bar.png" + hud.hpBarTiles = 12 + hud.battleExtra = "assets/generated/fonts/font_battle_extra.png" + hud.battleExtraFirstTile = 0x60 + hud.hpLabelTiles = { 0x60, 0x61 } + hud.hpBarEmptyTile = 0x62 + hud.hpBarFullTile = 0x6a + hud.hpBarEndTile = 0x6b + out.battleHud = hud + + -- PACK (engine/items/pack.asm). The screen is built the same way the + -- battle HUD is -- out of the cart's own tiles at the cart's own coordinates + -- -- rather than from rectangles: + -- + -- PackMenuGFX -> $00: $60 tiles. Pack_InitGFX copies the whole sheet to + -- vTiles2 tile $00, fills rows 1-11 with $24, and lays the + -- header strip at (0,0) as $28..$3b (20 running tiles). + -- PackGFX -> $50: four 15-tile (5x3) pack pictures. DrawPackGFX swaps + -- in the one for wCurPocket and PlacePackGFX lays $50..$5e + -- at (0,3). PackGFXPointers orders them KEY_ITEM, ITEM, + -- TM_HM, BALL in ROM, indexed by pocket. + -- pocket name -> DrawPocketName's 5x12 tilemap: four 5x3 blocks, laid at + -- (0,7), one per pocket in *_POCKET order. + -- palettes -> _CGB_PackPals: six BG palettes and the attrmap zones that + -- make the header halves, the pack picture and the pocket + -- name different colours. + local pack = {} + local packMenu = self:symbol("PackMenuGFX") + self:write2bpp(self.rom:bytes(packMenu.bank, packMenu.address, 0x60 * 16), + 128, 48, "pack/menu.png") + pack.menu = "assets/generated/pack/menu.png" + pack.menuTiles = 0x60 + pack.menuTilesWide = 16 + pack.backgroundTile = 0x24 + pack.headerFirstTile = 0x28 + + local packGfx = self:symbol("PackGFX") + self:write2bpp(self.rom:bytes(packGfx.bank, packGfx.address, 60 * 16), + 40, 96, "pack/pack.png") + pack.pack = "assets/generated/pack/pack.png" + pack.packFirstTile = 0x50 + pack.packTilesWide = 5 + pack.packTilesHigh = 3 + -- PackGFXPointers, as the tile row each pocket's picture starts on. + pack.pocketPicture = { + ITEM = 15, BALL = 45, KEY_ITEM = 0, TM_HM = 30, + } + + local pocketMap = self:symbol("DrawPocketName.tilemap") + local pocketRaw = self.rom:bytes(pocketMap.bank, pocketMap.address, 60) + pack.pocketName = {} + for block = 0, 3 do + local tiles = {} + for i = 1, 15 do tiles[i] = pocketRaw[block * 15 + i] end + pack.pocketName[block + 1] = tiles + end + -- 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) + end + -- _CGB_PackPals' FillBoxCGB calls, as {x, y, width, height, palette}. + pack.paletteZones = { + { 0, 0, 10, 1, 2 }, + { 10, 0, 10, 1, 3 }, + { 7, 2, 1, 9, 4 }, + { 0, 7, 5, 3, 5 }, + { 0, 3, 5, 3, 6 }, + } + out.pack = pack + out.pokedex = self:pokedexGfx() + out.pokegear = self:pokegearGfx() + out.trainerCard = self:trainerCardGfx() + out.unownPuzzle = self:unownPuzzleGfx() + + -- Emote bubbles (data/sprites/emotes.asm). Each is 4 tiles laid row-major + -- (TL, TR, BL, BR), unlike the pics, so they decode straight to a 16x16 + -- sheet. They are OBJs, so colour 0 is transparent. Only the ones a + -- walkthrough reaches are pulled; showemote shows no bubble for the rest. + local emotes = {} + for key, label in pairs({ + shock = "ShockEmote", + question = "QuestionEmote", + happy = "HappyEmote", + sad = "SadEmote", + }) do + local symbol = self.symbols[label] + if symbol then + local raw = self.rom:bytes(symbol[1], symbol[2], 4 * 16) + self:write2bpp(raw, 16, 16, "emotes/" .. key .. ".png", true) + emotes[key] = "assets/generated/emotes/" .. key .. ".png" + end + end + -- constants/script_constants.asm:188-195 EMOTE_* order, so showemote's byte + -- indexes straight into it. + emotes.order = { "shock", "question", "happy", "sad", "heart", "bolt", + "sleep", "fish" } + -- data/sprites/emotes.asm:22 `emote GrassRustleGFX, 1, $fe`: one tile, the + -- object ShakeGrass spawns (engine/overworld/map_objects.asm:2031). + local rustle = self.symbols["GrassRustleGFX"] + if rustle then + self:write2bpp(self.rom:bytes(rustle[1], rustle[2], 16), + 8, 8, "emotes/grass_rustle.png", true) + emotes.grassRustle = "assets/generated/emotes/grass_rustle.png" + end + out.emotes = emotes + + -- The Pokecenter heal machine's OBJ art (engine/events/heal_machine_anim + -- .asm .HealMachineGFX): two tiles side by side -- $7c the machine's light, + -- $7d the ball -- decoded as a 16x8 sheet with colour 0 transparent, the + -- way every OBJ sheet is. `.palettes` is gfx/overworld/heal_machine.pal, + -- the four colours .LoadPalettes copies over PAL_OW_TREE while the machine + -- runs; World's anim rotates them per flash the way .FlashPalettes does. + -- Both symbols are post-Phase-2 manifest additions, so a cache built from + -- an older manifest simply has no healMachine entry and the anim degrades + -- to its sounds. + local healGfx = self.symbols["HealMachineAnim.HealMachineGFX"] + if healGfx then + self:write2bpp(self.rom:bytes(healGfx[1], healGfx[2], 2 * 16), + 16, 8, "emotes/heal_machine.png", true) + local healPal = self.symbols["HealMachineAnim.palettes"] + out.healMachine = { + sheet = "assets/generated/emotes/heal_machine.png", + palette = healPal and self:colors(healPal[1], healPal[2], 4) or nil, + } + end + + -- The egg hatch cutscene's two assets (engine/pokemon/breeding.asm + -- EggHatch_AnimationSequence). + -- + -- EggPic gfx/pokemon/egg/egg.2bpp.lz. EGG has no BaseData row, so + -- GetBaseData's `.egg` arm hardcodes `ld b, $55` -- 5 tiles + -- square (home/pokemon.asm:239-245) -- and GetFrontpic reads + -- the label directly instead of walking PokemonPicPointers. + -- A `--columns` pic like every other frontpic. + -- EggHatchGFX gfx/evo/egg_hatch.2bpp, TWO tiles copied to vTiles0 tile + -- $00 (breeding.asm:777): the crack and the shell fragment + -- the ten SPRITE_ANIM_OBJ_EGG_HATCH objects all draw from. + -- An OBJ sheet, so colour 0 is transparent. + -- + -- Both are guarded: a cache built from an older manifest simply has no + -- eggHatch entry and src/ui/gen2/EggHatchAnim.lua runs its timing-only beat. + local eggHatch = {} + if self.symbols["EggPic"] then + local ok = pcall(function() + self:writeCompressedPic("EggPic", 5, "battle/front/egg.png") + end) + if ok then eggHatch.egg = "assets/generated/battle/front/egg.png" end + end + local hatchGfx = self.symbols["EggHatchGFX"] + if hatchGfx then + local symbol = self:symbol("EggHatchGFX") + self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 2 * 16), + 8, 16, "menu/egg_hatch.png", true) + eggHatch.shell = "assets/generated/menu/egg_hatch.png" + eggHatch.shellTiles = 2 + end + if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end + + self:write("menu_gfx", out) + self:tick("Menu graphics", 1, 1) + return out +end + +-- Unown puzzle art (engine/games/unown_puzzle.asm). +-- +-- The four pictures are stored SMALL: each `*PuzzleLZ` decompresses to 36 +-- tiles (48x48), and ConvertLoadedPuzzlePieces doubles them on the way into +-- VRAM. `.EnlargePuzzlePieceTiles` walks one source ROW at a time, first the +-- top four pixel rows of its six tiles and then the bottom four, splitting +-- each tile's high and low nibbles into two destination tiles -- which adds up +-- to a plain 2x nearest-neighbour scale laid out as a 12x12 tile sheet. The +-- scale is done here rather than at draw time so the border pass below, which +-- is a bitplane OR at the ENLARGED resolution, lands on the same bytes the +-- cart ORs. +-- +-- .EnlargedTiles: each nibble bit becomes two bits, so $f -> $ff and $8 -> +-- $c0. Built rather than transcribed; the `for x, 16` in the asm builds it +-- the same way. +local ENLARGED_NIBBLE = {} +for value = 0, 15 do + ENLARGED_NIBBLE[value] = (value % 2) * 3 + + (math.floor(value / 2) % 2) * 0x0c + + (math.floor(value / 4) % 2) * 0x30 + + (math.floor(value / 8) % 2) * 0xc0 +end + +local function orByte(a, b) + local out, bit = 0, 1 + for _ = 1, 8 do + if (a % 2) == 1 or (b % 2) == 1 then out = out + bit end + a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 + end + return out +end + +-- UnownPuzzle_AddPuzzlePieceBorders' eight destination tiles: the outline is +-- drawn on every tile of a 3x3 piece EXCEPT its centre ($0d), and .LoadGFX +-- repeats each one across the 4x4 grid of pieces (+3 tiles per column, +36 per +-- row, since the sheet is 12 tiles wide). +local PUZZLE_BORDER_TILES = { 0x00, 0x01, 0x02, 0x0c, 0x0e, 0x18, 0x19, 0x1a } + +function RomExtractorGen2:unownPuzzlePicture(label, relative) + local small = self:decompressLz3Symbol(label) + local out = {} + for row = 0, 5 do + for half = 0, 1 do + for col = 0, 5 do + local base = (row * 6 + col) * 16 + half * 8 + for nibble = 0, 1 do + for line = 0, 3 do + local low = small[base + line * 2 + 1] or 0 + local high = small[base + line * 2 + 2] or 0 + if nibble == 0 then + low, high = math.floor(low / 16), math.floor(high / 16) + else + low, high = low % 16, high % 16 + end + low, high = ENLARGED_NIBBLE[low], ENLARGED_NIBBLE[high] + out[#out + 1] = low + out[#out + 1] = high + out[#out + 1] = low + out[#out + 1] = high + end + end + end + end + end + local borders = self:symbol("PuzzlePieceBorderData.TileBordersGFX") + local raw = self.rom:bytes(borders.bank, borders.address, 8 * 16) + for index, target in ipairs(PUZZLE_BORDER_TILES) do + for pieceRow = 0, 3 do + for pieceCol = 0, 3 do + local tile = target + pieceCol * 3 + pieceRow * 36 + for byte = 1, 16 do + local at = tile * 16 + byte + out[at] = orByte(out[at] or 0, raw[(index - 1) * 16 + byte] or 0) + end + end + end + end + self:write2bpp(out, 96, 96, relative) + return "assets/generated/" .. relative +end + +function RomExtractorGen2:unownPuzzleGfx() + local puzzle = {} + -- .LZPointers, in UNOWNPUZZLE_* order (constants/script_constants.asm), so + -- the `setval` the chamber's bg_event runs indexes this list from 0. + puzzle.pictures = {} + for index, row in ipairs({ + { "KABUTO", "KabutoPuzzleLZ" }, + { "OMANYTE", "OmanytePuzzleLZ" }, + { "AERODACTYL", "AerodactylPuzzleLZ" }, + { "HO_OH", "HoOhPuzzleLZ" }, + }) do + puzzle.pictures[index] = self:unownPuzzlePicture(row[2], + "menu/unown_puzzle/" .. row[1]:lower() .. ".png") + end + -- The picture is 4x4 pieces of 3x3 tiles; `.Corners` is the same arithmetic + -- written out, so a piece id (1..16) is a quad rather than a tile run. + puzzle.pieceTiles = 3 + puzzle.piecesWide = 4 + + -- UnownPuzzleStartCancelLZ lands at vTiles0 tile $ed: $ee is PUZZLE_BORDER, + -- $ef PUZZLE_VOID, $f0..$f5 the START>CANCEL box corners and edges, and + -- $f6..$ff the ten tiles of the caption itself. + local chrome = self:decompressLz3Symbol("UnownPuzzleStartCancelLZ") + self:write2bpp(chrome, 152, 8, "menu/unown_puzzle/chrome.png") + puzzle.chrome = "assets/generated/menu/unown_puzzle/chrome.png" + puzzle.chromeFirstTile = 0xed + puzzle.chromeTiles = 19 + + -- UnownPuzzleCursorGFX is four OBJ tiles at vTiles0 $e0; the cursor's OAM + -- template mirrors them into a 3x3 bracket, so the sheet stays one row of + -- four rather than a 2x2 square. + local cursor = self:symbol("UnownPuzzleCursorGFX") + self:write2bpp(self.rom:bytes(cursor.bank, cursor.address, 4 * 16), + 32, 8, "menu/unown_puzzle/cursor.png", true) + puzzle.cursor = "assets/generated/menu/unown_puzzle/cursor.png" + puzzle.cursorFirstTile = 0xe0 + + -- _CGB_UnownPuzzle (engine/gfx/cgb_layouts.asm:570) runs CopyFourPalettes + -- over PalPacket_UnownPuzzle, which is PREDEFPAL_UNOWN_PUZZLE four times, and + -- WipeAttrmap then puts every BG tile on palette 0: the whole board is + -- white / tan / dark brown / black. The `ld a, $e4` in _UnownPuzzle + -- (engine/games/unown_puzzle.asm:53) runs AFTER GetSGBLayout and is the + -- identity reorder of that palette, not a grey ramp. + puzzle.palette = self:predefPal(PREDEFPAL_UNOWN_PUZZLE) + -- The same palette goes into wOBPals1 with colour 0 overwritten by + -- `palred 31` (cgb_layouts.asm:577-581), and `ld a, $24 / call + -- DmgToCgbObjPal0` reorders OBJ pal 0 to entries 0, 1, 2, 0 -- so the cursor + -- bracket draws red. + local ob = self:predefPal(PREDEFPAL_UNOWN_PUZZLE) + ob[1] = { 255, 0, 0 } + puzzle.cursorPalette = { ob[1], ob[2], ob[3], ob[1] } + return puzzle +end + +-- constants/scgb_constants.asm PREDEFPAL_*, for the two screens that colour +-- themselves out of the shared pool rather than an inline palette. +local PREDEFPAL_POKEDEX = 29 +local PREDEFPAL_CGB_BADGE = 36 + +-- The GB tilemap, in tiles (SCREEN_WIDTH * SCREEN_HEIGHT). +local SCREEN_AREA = 20 * 18 + +-- #DEX chrome (engine/pokedex/pokedex.asm). +-- +-- Pokedex_LoadGFX is four steps and only the last one is a sheet of its own: +-- the standard font goes to vTiles1 and is *inverted* (xor $ff flips both +-- bitplanes, so colour c becomes 3 - c), FontExtra lands at $60 and is +-- inverted too, and then PokedexLZ decompresses over vTiles2 tile $31 and +-- takes $31..$70 back off it. The inversion is why the dex prints white on +-- black: under PREDEFPAL_POKEDEX (white, orange, dark red, black) an inverted +-- glyph's ink is colour 0 and its cell is colour 3. Nothing here has to +-- invert anything -- the font pages are ink on transparent and the screen +-- draws them in white over the sheet's own dark tiles. +-- +-- Tile ids the screens name, all relative to that one sheet: +-- $31 solid black, $32 the dark-red background ByteFill uses +-- $33..$3a the box frame (Pokedex_PlaceBorder: corners $33/$35/$38/$3a, +-- edges $34 top, $36 left, $37 right, $39 bottom) +-- $3b/$3c the ◀/▶ card arrows, $3d/$3e the ▲/▼ ones +-- $41..$4e the START/SEARCH/SELECT/OPTION word tiles, $4f the caught ball +-- $53/$54/$59/$5a/$5b the listing's vertical rule and its end caps +-- $55..$58 the dex entry's PAGE 1/2 marker, $5c/$5d "No.", +-- $5e ' and $5f ", $61 the horizontal divider, $62..$65 footprint +function RomExtractorGen2:pokedexGfx() + local dex = {} + local pixels = self:decompressLz3Symbol("PokedexLZ") + local tiles = math.floor(#pixels / 16) + local rows = math.ceil(tiles / 16) + while #pixels < rows * 16 * 16 do pixels[#pixels + 1] = 0 end + self:write2bpp(pixels, 128, rows * 8, "pokedex/dex.png") + dex.tiles = "assets/generated/pokedex/dex.png" + dex.tilesWide = 16 + dex.firstTile = 0x31 + dex.tileCount = tiles + -- The dex's OBJ sheet, at vTiles0. The search screen's Slowpoke is the + -- front of it; $30..$33 are the bracket the listing cursor is drawn from + -- (Pokedex_PutNewModeABCModeCursorOAM) and $0f the scrollbar thumb. + local objs = self:decompressLz3Symbol("PokedexSlowpokeLZ") + local objTiles = math.floor(#objs / 16) + local objRows = math.ceil(objTiles / 16) + while #objs < objRows * 16 * 16 do objs[#objs + 1] = 0 end + self:write2bpp(objs, 128, objRows * 8, "pokedex/objs.png", true) + dex.objs = "assets/generated/pokedex/objs.png" + dex.objsWide = 16 + -- _CGB_Pokedex loads PREDEFPAL_POKEDEX as BG palette 0 for the whole + -- interface and the mon's own palette as 1 for the 7x7 frontpic; + -- _CGB_Pokedex_Resume puts PokedexCursorPalette in OBJ slot 7, which is the + -- palette every cursor sprite names. + dex.palette = self:predefPal(PREDEFPAL_POKEDEX) + local cursorPal = self:symbol("PokedexCursorPalette") + dex.cursorPalette = self:colors(cursorPal.bank, cursorPal.address, 4) + + -- An unseen mon shows LoadQuestionMarkPic's 7x7 pic where the frontpic + -- goes, in its own green palette. It is a `--columns` pic like the mons'. + self:writeCompressedPic("LoadQuestionMarkPic.QuestionMarkLZ", 7, + "pokedex/question_mark.png") + dex.questionMark = "assets/generated/pokedex/question_mark.png" + local qmPal = self:symbol("PokedexQuestionMarkPalette") + dex.questionMarkPalette = self:colors(qmPal.bank, qmPal.address, 4) + + -- Footprints (Pokedex_LoadAnyFootprint). Each is 16x16 in 1bpp, but not + -- contiguously: the table is 256-byte blocks of eight species, the eight + -- top halves first and the eight bottom halves 128 bytes later, which the + -- ASM's own comment blames on a mis-set tile editor. Written as one + -- 16-pixel-wide strip, two tile rows per species in speciesOrder. + local prints = self:symbol("Footprints") + local species = self.manifest.constants.speciesOrder or {} + local stream = {} + for index = 1, #species do + local base = math.floor((index - 1) / 8) * 256 + ((index - 1) % 8) * 16 + for i = 0, 15 do + stream[#stream + 1] = self.rom:byte(prints.bank, prints.address + base + i) + end + for i = 0, 15 do + stream[#stream + 1] = + self.rom:byte(prints.bank, prints.address + base + 128 + i) + end + end + self:save(ImageWriter.decode1bpp(stream, 16, #species * 16), + "pokedex/footprints.png") + dex.footprints = "assets/generated/pokedex/footprints.png" + dex.footprintOrder = species + return dex +end + +-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's +-- own comment says "repeat count, tile ID" and has it backwards: it loads b +-- from the first byte, c from the second, and writes `b` c times. $ff ends +-- the stream. +function RomExtractorGen2:readTilemapRLE(label, cells) + local symbol = self:symbol(label) + local out, offset = {}, 0 + while #out < cells do + local tile = self.rom:byte(symbol.bank, symbol.address + offset) + if tile == 0xff then break end + local count = self.rom:byte(symbol.bank, symbol.address + offset + 1) + offset = offset + 2 + for _ = 1, count do + if #out >= cells then break end + out[#out + 1] = tile + end + end + -- A stream that stops short leaves the screen holding whatever + -- InitPokegearTilemap put there, and that is `ld a, $4f / call ByteFill` + -- over the whole SCREEN_AREA (engine/pokegear/pokegear.asm:232-238), not a + -- blank. Only rows 12-17 are ever reached, which every card covers with its + -- bottom Textbox, so this is invisible until a card stops doing that. + while #out < cells do out[#out + 1] = 0x4f end + return out +end + +-- Reads a flat $ff-terminated tilemap (FillTownMap). +function RomExtractorGen2:readFlatTilemap(label, cells) + local symbol = self:symbol(label) + local out = {} + for i = 0, cells - 1 do + local tile = self.rom:byte(symbol.bank, symbol.address + i) + if tile == 0xff then break end + out[i + 1] = tile + end + -- Same InitPokegearTilemap fill as readTilemapRLE above. + while #out < cells do out[#out + 1] = 0x4f end + return out +end + +-- POKeGEAR (engine/pokegear/pokegear.asm). +-- +-- Pokegear_LoadGFX puts TownMapGFX (48 tiles) at vTiles2 $00 and PokegearGFX +-- (48 tiles) at $30, so a card's tilemap addresses one 96-tile sheet and the +-- split at $30 is the only seam. Each card is a tilemap rather than a +-- layout: InitPokegearTilemap fills the screen with $4f, runs the card's +-- entry (an RLE tilemap for CLOCK/PHONE/RADIO, the painted region map for +-- MAP), then Pokegear_FinishTilemap lays the card icons across the top two +-- rows -- MAP at (2,0) from $40, PHONE at (4,0) from $44, RADIO at (6,0) +-- from $42, and the gear itself at (0,0) from $46, each a 2x2 written +-- $n, $n+1 / $n+2, $n+3. +-- +-- Colour is by tile id, not by rectangle: TownMapPals walks the tilemap and +-- reads .PalMap, a nybble per tile for $00..$5f (low nybble even ids, high +-- odd), with $60 and up falling back to palette 0. +function RomExtractorGen2:pokegearGfx() + local gear = {} + local townMap = self:decompressLz3Symbol("TownMapGFX") + local gearTiles = self:decompressLz3Symbol("PokegearGFX") + -- One sheet, 16 tiles per row, so a tile id is (id % 16, id / 16) the same + -- way the intro's sheets read. + local sheet = {} + for i = 1, 0x30 * 16 do sheet[i] = townMap[i] or 0 end + for i = 1, 0x30 * 16 do sheet[0x30 * 16 + i] = gearTiles[i] or 0 end + self:write2bpp(sheet, 128, 48, "pokegear/gear.png") + gear.tiles = "assets/generated/pokegear/gear.png" + gear.tilesWide = 16 + gear.townMapTiles = 0x30 + + local sprites = self:decompressLz3Symbol("PokegearSpritesGFX") + while #sprites < 10 * 16 do sprites[#sprites + 1] = 0 end + self:write2bpp(sprites, 16, 40, "pokegear/sprites.png", true) + gear.sprites = "assets/generated/pokegear/sprites.png" + gear.spritesWide = 2 + + local cells = SCREEN_AREA + gear.cards = { + clock = self:readTilemapRLE("ClockTilemapRLE", cells), + phone = self:readTilemapRLE("PhoneTilemapRLE", cells), + radio = self:readTilemapRLE("RadioTilemapRLE", cells), + } + gear.maps = { + johto = self:readFlatTilemap("JohtoMap", cells), + 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) + end + -- TownMapPals.PalMap: 48 bytes covering tiles $00..$5f, 1-based so the value + -- indexes gear.palettes directly. + local palMap = self:symbol("TownMapPals.PalMap") + gear.palMap = {} + for i = 0, 47 do + local byte = self.rom:byte(palMap.bank, palMap.address + i) + gear.palMap[i * 2 + 1] = byte % 8 + 1 + gear.palMap[i * 2 + 2] = math.floor(byte / 16) % 8 + 1 + end + return gear +end + +-- Trainer card (engine/menus/trainer_card.asm). +-- +-- ChrisPicAndTrainerCardGFX is two INCBINs run together -- chris_card (35 +-- tiles, the 5x7 portrait) then trainer_card (6 tiles, the frame) -- copied +-- as one 41-tile block to vTiles2 $00, so the frame tiles start at $23: +-- $23 is the card's border cell, $24 and $04 the two notches +-- TrainerCard_InitBorder pokes into rows 7 and 1 of each box. +-- +-- Page 1 then requests 86 tiles of CardStatusGFX at $29 and pages 2/3 +-- request LeaderGFX at the same address; the badges themselves are OBJs out +-- 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" + card.cardTilesWide = 16 + card.portraitTiles = 35 + card.portraitWide = 5 + card.frameFirstTile = 0x23 + + local status = self:symbol("CardStatusGFX") + self:write2bpp(self.rom:bytes(status.bank, status.address, 6 * 16), + 48, 8, "trainer_card/status.png") + card.status = "assets/generated/trainer_card/status.png" + card.statusWide = 6 + card.statusFirstTile = 0x29 + + -- LeaderGFX is 86 tiles from $29: eight 10-tile gym-leader faces followed + -- by the five "BADGES" caption tiles at $79. + local leaders = self:symbol("LeaderGFX") + local leaderTiles = self.rom:bytes(leaders.bank, leaders.address, 86 * 16) + while #leaderTiles < 90 * 16 do leaderTiles[#leaderTiles + 1] = 0 end + self:write2bpp(leaderTiles, 80, 72, "trainer_card/leaders.png") + card.leaders = "assets/generated/trainer_card/leaders.png" + card.leadersWide = 10 + card.leadersFirstTile = 0x29 + + local badges = self:symbol("BadgeGFX") + self:write2bpp(self.rom:bytes(badges.bank, badges.address, 44 * 16), + 16, 176, "trainer_card/badges.png", true) + card.badges = "assets/generated/trainer_card/badges.png" + card.badgesWide = 2 + + -- TrainerCard_JohtoBadgesOAM: a wJohtoBadges pointer, then per badge + -- `y, x, palette` and two 4-byte animation cycles. y/x are OAM values, so + -- screen space is (x - 8, y - 16). + local oam = self:symbol("TrainerCard_JohtoBadgesOAM") + card.badgeOam = {} + for i = 0, 7 do + local at = oam.address + 2 + i * 11 + local frames = {} + for f = 0, 7 do + frames[f + 1] = self.rom:byte(oam.bank, at + 3 + f) + end + card.badgeOam[i + 1] = { + y = self.rom:byte(oam.bank, at) - 16, + x = self.rom:byte(oam.bank, at + 1) - 8, + palette = self.rom:byte(oam.bank, at + 2), + frames = frames, + } + end + -- _CGB_TrainerCard loads nine palettes into an eight-palette table: BG 0 is + -- the player's own colours, BG 1-7 the first seven gym leaders', and the + -- ninth (PREDEFPAL_CGB_BADGE) runs off the end into OBJ palette 0, which is + -- exactly the palette every badge sprite names. The whole attrmap is + -- filled with palette 1 first, so the card frame -- and the eighth leader's + -- face, which gets no zone of its own -- wears Falkner's colours. + card.badgePalette = self:predefPal(PREDEFPAL_CGB_BADGE) + card.leaderClasses = { "FALKNER", "BUGSY", "WHITNEY", "MORTY", "CHUCK", + "JASMINE", "PRYCE" } + -- The FillBoxCGB zones, as {x, y, width, height, palette}, 1-based so the + -- palette indexes a Lua table directly. + card.paletteZones = { + { 14, 1, 5, 7, 1 }, + { 18, 1, 1, 1, 2 }, + { 2, 11, 4, 2, 2 }, { 6, 11, 4, 2, 3 }, + { 10, 11, 4, 2, 4 }, { 14, 11, 4, 2, 5 }, + { 2, 14, 4, 2, 6 }, { 6, 14, 4, 2, 7 }, + { 10, 14, 4, 2, 8 }, + } + return card +end + +-------------------------------------------------------------------------- +-- Battle animations (data/moves/animations.asm, data/battle_anims/*) +-------------------------------------------------------------------------- + +-- macros/scripts/battle_anims.asm. Anything under FIRST_BATTLE_ANIM_CMD +-- ($d0) is `anim_wait <n>` and carries no argument bytes; everything from +-- $d0 up is a command whose argument count is fixed. anim_obj's macro has +-- two spellings (a four-argument one and a legacy tile+offset one) but both +-- emit the same four bytes. +local ANIM_CMDS = { + [0xd0] = { "obj", 4 }, [0xd1] = { "1gfx", 1 }, [0xd2] = { "2gfx", 2 }, + [0xd3] = { "3gfx", 3 }, [0xd4] = { "4gfx", 4 }, [0xd5] = { "5gfx", 5 }, + [0xd6] = { "incobj", 1 }, [0xd7] = { "setobj", 2 }, + [0xd8] = { "incbgeffect", 1 }, + [0xd9] = { "battlergfx_2row", 0 }, [0xda] = { "battlergfx_1row", 0 }, + [0xdb] = { "checkpokeball", 0 }, [0xdc] = { "transform", 0 }, + [0xdd] = { "raisesub", 0 }, [0xde] = { "dropsub", 0 }, + [0xdf] = { "resetobp0", 0 }, + [0xe0] = { "sound", 2 }, [0xe1] = { "cry", 1 }, + [0xe2] = { "minimizeopp", 0 }, [0xe3] = { "oamon", 0 }, + [0xe4] = { "oamoff", 0 }, [0xe5] = { "clearobjs", 0 }, + [0xe6] = { "beatup", 0 }, [0xe7] = { "unknown_e7", 0 }, + [0xe8] = { "updateactorpic", 0 }, [0xe9] = { "minimize", 0 }, + [0xea] = { "unknown_ea", 0 }, [0xeb] = { "unknown_eb", 0 }, + [0xec] = { "unknown_ec", 0 }, [0xed] = { "unknown_ed", 0 }, + [0xee] = { "if_param_and", 3 }, [0xef] = { "jumpuntil", 2 }, + [0xf0] = { "bgeffect", 4 }, + [0xf1] = { "bgp", 1 }, [0xf2] = { "obp0", 1 }, [0xf3] = { "obp1", 1 }, + [0xf4] = { "keepsprites", 0 }, [0xf5] = { "unknown_f5", 0 }, + [0xf6] = { "unknown_f6", 0 }, [0xf7] = { "unknown_f7", 0 }, + [0xf8] = { "if_param_equal", 3 }, [0xf9] = { "setvar", 1 }, + [0xfa] = { "incvar", 0 }, [0xfb] = { "if_var_equal", 3 }, + [0xfc] = { "jump", 2 }, [0xfd] = { "loop", 3 }, + [0xfe] = { "call", 2 }, [0xff] = { "ret", 0 }, +} +-- The commands whose LAST two argument bytes are an address in the same +-- bank, which is what the disassembler follows to find sub-scripts. +local ANIM_BRANCHES = { + jumpuntil = 1, if_param_and = 2, if_param_equal = 2, if_var_equal = 2, + jump = 1, loop = 2, call = 1, +} +local NUM_BATTLE_ANIMS = 278 -- data/moves/animations.asm's table_width 2 rows +-- constants/move_constants.asm: the animation ids past the moves. The block +-- restarts at `const_next $ff`, so rows $fc-$fe of BattleAnimations are dead +-- padding and ANIM_SWEET_SCENT_2 is row $ff. These are the animations the +-- engine plays by id rather than by move -- the ball throw, the send-out, the +-- status loops, and the six "after" animations wBattleAfterAnim indexes. +local BATTLE_ANIM_IDS = { + [0xff] = "ANIM_SWEET_SCENT_2", [0x100] = "ANIM_THROW_POKE_BALL", + [0x101] = "ANIM_SEND_OUT_MON", [0x102] = "ANIM_RETURN_MON", + [0x103] = "ANIM_CONFUSED", [0x104] = "ANIM_SLP", [0x105] = "ANIM_BRN", + [0x106] = "ANIM_PSN", [0x107] = "ANIM_SAP", [0x108] = "ANIM_FRZ", + [0x109] = "ANIM_PAR", [0x10a] = "ANIM_IN_LOVE", + [0x10b] = "ANIM_IN_SANDSTORM", [0x10c] = "ANIM_IN_NIGHTMARE", + [0x10d] = "ANIM_IN_WHIRLPOOL", [0x10e] = "ANIM_MISS", + [0x10f] = "ANIM_ENEMY_DAMAGE", [0x110] = "ANIM_ENEMY_STAT_DOWN", + [0x111] = "ANIM_PLAYER_STAT_DOWN", [0x112] = "ANIM_PLAYER_DAMAGE", + [0x113] = "ANIM_WOBBLE", [0x114] = "ANIM_SHAKE", + [0x115] = "ANIM_HIT_CONFUSION", +} + +-- Disassemble one animation script, following every branch target so a +-- sub-script that is only ever reached by anim_call ends up in the pool too. +-- Scripts are keyed by their ROM address because that is what a branch names; +-- the per-move table then points at one of those keys. +function RomExtractorGen2:readBattleAnimScript(bank, address, pool, order) + local key = ("%04x"):format(address) + if pool[key] then return key end + local rows = {} + pool[key] = rows -- claimed before the walk, so a self-jump terminates + order[#order + 1] = key + local at = address + local pending = {} + for _ = 1, 4096 do + local byte = self.rom:byte(bank, at) + at = at + 1 + local spec = ANIM_CMDS[byte] + if not spec then + -- Under $d0 the byte IS the wait length. + rows[#rows + 1] = { "wait", byte } + else + local row = { spec[1] } + for i = 1, spec[2] do + row[i + 1] = self.rom:byte(bank, at) + at = at + 1 + end + local branch = ANIM_BRANCHES[spec[1]] + if branch then + -- The address is the last two bytes, little-endian. + local low, high = row[branch + 1], row[branch + 2] + local target = low + high * 256 + row[branch + 1] = ("%04x"):format(target) + row[branch + 2] = nil + pending[#pending + 1] = target + end + rows[#rows + 1] = row + if spec[1] == "ret" then break end + -- An unconditional jump ends this run; its target is walked below. + if spec[1] == "jump" then break end + end + end + for _, target in ipairs(pending) do + self:readBattleAnimScript(bank, target, pool, order) + end + return key +end + +-- BattleAnimObjects: seven bytes an entry (battleanimobj), naming the +-- frameset it plays, the per-frame function that moves it, its palette and +-- which GFX sheet its tiles come from. +function RomExtractorGen2:readBattleAnimObjects() + local consts = self.manifest.constants or {} + local names = consts.battleAnimObjectOrder or {} + local framesets = consts.battleAnimFramesetOrder or {} + local funcs = consts.battleAnimFuncOrder or {} + -- An animation object is an OBJ, so its palette byte names an OBJ palette: + -- PAL_BATTLE_OB_*, which is its own const block starting at zero, not the + -- BG one beside it in the same file. + local pals = consts.battleAnimObPaletteOrder or {} + local gfx = consts.battleAnimGfxOrder or {} + local symbol = self:symbol("BattleAnimObjects") + local out = {} + for index, name in ipairs(names) do + -- BATTLEANIMOBJ_LENGTH is `_RS - 1` -- the struct's INDEX byte is runtime + -- state and is NOT in the table -- so a row is SIX bytes, not seven. + local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 6, 6) + out[name] = { + -- bit 0 is "fix the enemy's coordinates"; bits 5-7 are flip/priority. + flags = row[1], + fixY = row[2], + frameset = framesets[row[3] + 1] or row[3], + func = funcs[row[4] + 1] or row[4], + palette = pals[row[5] + 1] or row[5], + -- battleAnimGfxOrder carries a placeholder for AnimObjGFX's empty row 0 + -- (the const block itself starts at 1), so value + 1 indexes it like + -- every other list here. + gfx = gfx[row[6] + 1] or row[6], + tileOffset = row[7], + } + end + return out +end + +-- BattleAnimFrameData: a pointer table of oamframe lists, in the SAME format +-- the overworld sprite anims use -- which is the point. src/ui/gen2/ +-- SpriteAnims.lua already runs these, so the runtime is shared rather than +-- written twice. +function RomExtractorGen2:readBattleAnimFramesets() + local names = (self.manifest.constants or {}).battleAnimFramesetOrder or {} + local oamsets = (self.manifest.constants or {}).battleAnimOamsetOrder or {} + local symbol = self:symbol("BattleAnimFrameData") + local out = {} + for index, name in ipairs(names) do + local low = self.rom:byte(symbol.bank, symbol.address + (index - 1) * 2) + local high = self.rom:byte(symbol.bank, symbol.address + (index - 1) * 2 + 1) + local at = low + high * 256 + local frames = {} + -- macros/scripts/oam_anims.asm counts DOWN from $ff: oamend $ff, + -- oamrestart $fe, oamwait $fd, oamdelete $fc. Everything below is an OAM + -- set id, and with 216 of them there is no collision. + for _ = 1, 64 do + local byte = self.rom:byte(symbol.bank, at) + at = at + 1 + if byte == 0xff then + frames[#frames + 1] = { "end" } + break + elseif byte == 0xfe then + frames[#frames + 1] = { "restart" } + break + elseif byte == 0xfd then + frames[#frames + 1] = { "wait", self.rom:byte(symbol.bank, at) } + at = at + 1 + elseif byte == 0xfc then + frames[#frames + 1] = { "delete" } + break + else + -- oamframe's OWN comment calls its first byte the duration and is + -- wrong: `oamframe BATTLE_ANIM_OAMSET_00, 6` passes the OAM SET as + -- \1, so the set comes first and the duration second. The duration's + -- top two bits are the flip arguments, one bit above the OAM flags + -- they become -- GetSpriteAnimFrame shifts them back down. + local duration = self.rom:byte(symbol.bank, at) + at = at + 1 + frames[#frames + 1] = { + "frame", oamsets[byte + 1] or byte, + bit.band(duration, 0x3f), + bit.rshift(bit.band(duration, 0xc0), 1), + } + end + end + out[name] = frames + end + return out +end + +-- BattleAnimOAMData: `battleanimoam <vtile offset>, <length>, <data>` and +-- then a run of dbsprite rows. +function RomExtractorGen2:readBattleAnimOamsets() + local names = (self.manifest.constants or {}).battleAnimOamsetOrder or {} + local symbol = self:symbol("BattleAnimOAMData") + local out = {} + for index, name in ipairs(names) do + local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 4, 4) + local at = row[3] + row[4] * 256 + local sprites = {} + for i = 1, row[2] do + -- dbsprite is four bytes: y, x, vtile offset, attributes -- and the y + -- byte comes FIRST even though the macro's arguments read x first. + local entry = self.rom:bytes(symbol.bank, at + (i - 1) * 4, 4) + sprites[i] = { + y = entry[1], x = entry[2], tile = entry[3], attr = entry[4], + } + end + out[name] = { vtile = row[1], sprites = sprites } + end + return out +end + +-- AnimObjGFX: `anim_obj_gfx <tiles>, <label>` -- a count and a three-byte +-- far pointer at a compressed sheet. +function RomExtractorGen2:readBattleAnimGfx() + local names = (self.manifest.constants or {}).battleAnimGfxOrder or {} + local symbol = self:symbol("AnimObjGFX") + local out = {} + for index, name in ipairs(names) do + local row = self.rom:bytes(symbol.bank, symbol.address + (index - 1) * 4, 4) + local tiles = row[1] + if tiles > 0 then + local bank, address = row[2], row[3] + row[4] * 256 + local ok, pixels = pcall(function() + local compressed = self.rom:bytes(bank, address, 0x8000 - address) + return Rom.decompressLz3(compressed) + end) + if ok and pixels then + local wide = math.min(tiles, 8) + local high = math.ceil(tiles / wide) + local need = wide * high * 16 + while #pixels < need do pixels[#pixels + 1] = 0 end + while #pixels > need do table.remove(pixels) end + local rel = ("battle_anims/%s.png"):format(name:lower()) + self:write2bpp(pixels, wide * 8, high * 8, rel, true) + out[name] = { + tiles = tiles, wide = wide, + image = "assets/generated/" .. rel, + } + end + end + end + return out +end + +function RomExtractorGen2:extractBattleAnims() + self:beginStage("Battle animations") + local moveOrder = (self.manifest.constants or {}).moveOrder or {} + local table_ = self:symbol("BattleAnimations") + local pool, order = {}, {} + local byMove, byId = {}, {} + for index = 1, NUM_BATTLE_ANIMS do + local base = table_.address + (index - 1) * 2 + local low = self.rom:byte(table_.bank, base) + local high = self.rom:byte(table_.bank, base + 1) + local address = low + high * 256 + -- Row 0 is BattleAnim_Dummy; rows 1..NUM_MOVES are the moves in order, + -- and the rows past them are the shared non-move animations (status, + -- stat changes, the ball throw) the engine plays by id. + local key = self:readBattleAnimScript(table_.bank, address, pool, order) + local name = (index > 1) and moveOrder[index - 1] or nil + if name then byMove[name] = key end + local idName = BATTLE_ANIM_IDS[index - 1] + if idName then byId[idName] = key end + if index % 40 == 0 then + self:tick("Battle animations", index, NUM_BATTLE_ANIMS) + end + end + local scripts = {} + for _, key in ipairs(order) do scripts[key] = pool[key] end + local data = { + generation = 2, + source = "ROM:BattleAnimations + data/battle_anims/*", + bank = table_.bank, + -- Every script in one pool keyed by its ROM address; `moves` and `ids` + -- point into it. A sub-script reached only by anim_call has no name of + -- its own, which is why the pool is addressed rather than named. + scripts = scripts, + scriptOrder = order, + moves = byMove, + -- The animations the engine plays by id: the ball throw, the send-out + -- slide, the status loops and the six wBattleAfterAnim entries. + ids = byId, + objects = self:readBattleAnimObjects(), + framesets = self:readBattleAnimFramesets(), + oamsets = self:readBattleAnimOamsets(), + gfx = self:readBattleAnimGfx(), + } + self:write("battle_anims", data) + self:tick("Battle animations", NUM_BATTLE_ANIMS, NUM_BATTLE_ANIMS) + return data +end + +-- The Magnet Train's two tilemaps (engine/events/magnet_train.asm). +-- +-- MagnetTrainBGTiles is a 2x18 strip: two tile ids per screen row, which +-- DrawMagnetTrain's .FillAlt repeats across all TILEMAP_WIDTH / 2 column pairs +-- to build the scrolling scenery. MagnetTrainTilemap is the 20x4 train itself, +-- laid over BG rows 6-9 on top of it. +-- +-- Neither is compressed and neither is pointed at by anything the script +-- walker follows, so both are read straight off their labels. No tile GRAPHICS +-- come with them: MagnetTrain_LoadGFX_PlayMusic loads none, and every id above +-- indexes whatever the station's own tileset (TILESET_TRAIN_STATION) already +-- had in VRAM. +local MAGNET_TRAIN_BG_ROWS = 18 -- SCREEN_HEIGHT +local MAGNET_TRAIN_FG_WIDTH = 20 -- SCREEN_WIDTH +local MAGNET_TRAIN_FG_ROWS = 4 + +function RomExtractorGen2:readMagnetTrain() + -- A manifest generated before these two labels were required carries neither, + -- and an import that hard-errored on that would take the whole cache build + -- down over a cutscene; the ride runs without the art. + if not (self.symbols["MagnetTrainBGTiles"] + and self.symbols["MagnetTrainTilemap"]) then + return nil + end + local bgSymbol = self:symbol("MagnetTrainBGTiles") + local bgTiles = {} + for index = 0, MAGNET_TRAIN_BG_ROWS * 2 - 1 do + bgTiles[index + 1] = self.rom:byte(bgSymbol.bank, bgSymbol.address + index) + end + local fgSymbol = self:symbol("MagnetTrainTilemap") + local tilemap = {} + for index = 0, MAGNET_TRAIN_FG_WIDTH * MAGNET_TRAIN_FG_ROWS - 1 do + tilemap[index + 1] = self.rom:byte(fgSymbol.bank, fgSymbol.address + index) + end + return { + source = "ROM:MagnetTrainBGTiles + MagnetTrainTilemap", + bgTiles = bgTiles, + tilemap = tilemap, + width = MAGNET_TRAIN_FG_WIDTH, + rows = MAGNET_TRAIN_FG_ROWS, + } +end + +-- The field table is mostly still a stub; `extras` is what a table has already +-- grown real contents in, merged over the stub note so the rest of it stays +-- honestly marked as unextracted. +function RomExtractorGen2:extractStubs() + self:beginStage("Remaining data (stubs)") + local names = { + "field", + } + local extras = { + field = { magnetTrain = self:readMagnetTrain() }, + } + for index, name in ipairs(names) do + local data = { + generation = 2, + source = "Gold Phase 2 stub -- not yet extracted, see docs/gold-phase1.md", + } + for key, value in pairs(extras[name] or {}) do data[key] = value end + self:write(name, data) + self:tick("Remaining data (stubs)", index, #names) + end + return true +end + +function RomExtractorGen2:run() + local results = {} + results.constants = self:extractConstants() + results.font = self:extractFont() + results.palettes = self:extractPalettes() + results.tilesets = self:extractTilesets() + results.maps = self:extractMaps() + results.sprites = self:extractSprites() + results.stdScripts = self:extractStdScripts() + results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts) + results.pokemon = self:extractPokemon() + results.moves = self:extractMoves() + results.items = self:extractItems() + results.marts = self:extractMarts() + results.encounters = self:extractEncounters() + results.trainers = self:extractTrainers() + results.pokedex = self:extractPokedex() + results.landmarks = self:extractLandmarks() + results.icons = self:extractIcons() + results.intro = self:extractIntro() + results.menuGfx = self:extractMenuGfx() + results.oakSpeech = self:extractOakSpeech(results.pokemon) + results.title = self:extractTitle() + results.credits = self:extractCredits() + results.diploma = self:extractDiploma() + results.trade = self:extractTrade() + results.audio = self:extractAudio(results.maps) + results.battleAnims = self:extractBattleAnims() + results.stubs = self:extractStubs() + if self.progress then + self.progress(STAGE_COUNT, STAGE_COUNT, "Ready", 1, 1) + end + return results +end + +return RomExtractorGen2 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index a38c5b0e..2f13993e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,5 +1,6 @@ local GameVersion = require("src.core.GameVersion") local GamepadMap = require("src.core.GamepadMap") +local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") local HostShell = require("src.core.HostShell") local Platform = require("src.core.Platform") @@ -34,6 +35,12 @@ end -- their Tilesets row (#889), which a .sav export replays so a Continue on -- real hardware has a map to load; a v9 cache has none of them and exports -- the same unbootable save as before. +-- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates +-- every version at once, and that gap is Gold-only. A per-version marker in +-- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack +-- the stage, which is what the Yellow markers below already do for #439/#557. +-- Reach for a bump when the change spans versions or has no single file to +-- point at. local CACHE_FORMAT = "rom-cache-v10:" -- The completion marker is written under each version's cache prefix -- (red/rom-cache.complete, blue/rom-cache.complete, ...). @@ -85,6 +92,54 @@ local VERSION_REQUIRED_FILES = { }, } +-- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets, +-- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This +-- list replaces REQUIRED_FILES entirely for that version so a successful +-- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers. +local VERSION_REQUIRED_FILES_OVERRIDE = { + gold = { + "data/generated/constants.lua", + "data/generated/maps.lua", + "data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches + "data/generated/sprites.lua", -- OW sheets (Chris + NPCs) + "data/generated/scripts.lua", -- disassembled map scripts + "data/generated/text.lua", -- decoded Gen 2 dialogue strings + "data/generated/pokemon.lua", + "data/generated/tilesets.lua", + "data/generated/audio.lua", + -- Mart shelves + the heal machine art ride the same import, so listing + -- marts.lua alone re-imports the caches from before either existed + -- (empty shop shelves, no Pokecenter light show). + "data/generated/marts.lua", + "assets/generated/fonts/font.png", + "assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames + "assets/generated/title/pokemon_logo.png", + "assets/generated/title/title_screen.png", -- TitleScreenTilemap composition + "assets/generated/title/hooh.png", + "assets/generated/title/hooh_5.png", -- wing-flap frames force re-import + "assets/generated/title/clouds.png", + "assets/generated/title/copyright_splash.png", + "data/generated/oak_speech.lua", -- Oak texts + trainer pics + "assets/generated/intro/oak.png", + "assets/generated/intro/cal.png", + "assets/generated/tilesets/johto.png", + "assets/generated/tilesets/roofs/new_bark.png", + "assets/generated/sprites/chris.png", + "assets/generated/battle/front/chikorita.png", + "assets/generated/battle/front/pikachu.png", + "assets/generated/battle/front/marill.png", -- Oak speech demo mon + -- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that + -- table, so a cache that produced any class pic at all produced this one. + -- Listed for the reason the Yellow markers above are: a cache built before + -- the stage existed reads as INCOMPLETE and re-imports itself, so this + -- particular gap cannot survive a tag bump being forgotten again. It + -- costs nothing on a current cache and is the difference between every + -- trainer battle opening with a picture and opening with none. + "assets/generated/battle/trainers/falkner.png", + "assets/generated/audio/programs.bin", + }, +} + -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup): -- a dark neon arcade panel, one column per game. -- Red, Blue, and Yellow share the same importer flow once listed in @@ -146,11 +201,14 @@ local function allRequiredFilesExist(version) local saved = CacheFs.prefix CacheFs.prefix = GameVersion.cachePrefix(version) local ok = true - for _, path in ipairs(REQUIRED_FILES) do + local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES + for _, path in ipairs(required) do if not CacheFs.exists(path) then ok = false; break end end - for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do - if not CacheFs.exists(path) then ok = false; break end + if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then + for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do + if not CacheFs.exists(path) then ok = false; break end + end end CacheFs.prefix = saved return ok @@ -350,7 +408,14 @@ local IMPORTS_DIR = "imports" local BASE_ROMS_DIR = "baseroms" local MODS_INBOX_DIR = "imports/mods" local SAVES_INBOX_DIR = "imports/saves" -local ROM_BYTES = 1024 * 1024 +local ROM_BYTES_GEN1 = 1024 * 1024 +local ROM_BYTES_GEN2 = 2 * 1024 * 1024 +-- Historical alias: Gen 1 helpers and tests still refer to ROM_BYTES. +local ROM_BYTES = ROM_BYTES_GEN1 + +local function isAcceptedRomSize(n) + return n == ROM_BYTES_GEN1 or n == ROM_BYTES_GEN2 +end local function savesInboxDir(version) return SAVES_INBOX_DIR .. "/" .. tostring(version) @@ -522,9 +587,9 @@ function RomImporter:_stepBaseRomScan() scan.index = scan.index + 1 local info = love.filesystem.getInfo(path, "file") - if info and info.size == ROM_BYTES then + if info and isAcceptedRomSize(info.size) then local data = love.filesystem.read(path) - if type(data) == "string" and #data == ROM_BYTES then + if type(data) == "string" and isAcceptedRomSize(#data) then local version = GameVersion.forSha1(sha1(data)) if version and not self.ready[version] and not self.baseRoms[version] then self.baseRoms[version] = { @@ -766,7 +831,7 @@ function RomImporter:rescanAction(version) self:setError("The file could not be read: " .. displayName, version) return end - if #data ~= ROM_BYTES then + if not isAcceptedRomSize(#data) then if not junkData then junkData, junkName = data, displayName end else local romVersion = GameVersion.forSha1(sha1(data)) @@ -837,12 +902,13 @@ end -- Only a .gb/.gbc whose SHA maps to a version that is not yet ready counts as -- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a -- naive "first ROM wins" scan would re-import Red when the player tries to --- add Blue (issue #167). Yellow carts are typically .gbc. +-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is +-- 2 MiB). local function findPendingRom(ready) for _, name in ipairs(love.filesystem.getDirectoryItems("")) do if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then local data = love.filesystem.read(name) - if type(data) == "string" and #data == 1024 * 1024 then + if type(data) == "string" and isAcceptedRomSize(#data) then local version = GameVersion.forSha1(sha1(data)) if version and not ready[version] then return name, data @@ -866,7 +932,7 @@ local function consumePickedRomError(self) local preferred = "picked_rom.gb" if not love.filesystem.getInfo(preferred, "file") then return false end local data = love.filesystem.read(preferred) - if type(data) == "string" and #data == 1024 * 1024 then + if type(data) == "string" and isAcceptedRomSize(#data) then local version = GameVersion.forSha1(sha1(data)) if version and self.ready[version] then return false end end @@ -1165,6 +1231,10 @@ function RomImporter.new(onComplete, opts) -- modScroll is the list scroll offset (px, clamped in draw); modNotice is -- the last install/delete result { ok, text } shown as a line above the list. mods = nil, modScroll = 0, modNotice = nil, + -- Which game the MODS panel is answering for (a GameVersion id, nil = + -- every game). Rows resolve their enable-state and their "runs here" + -- verdict against it (src/mods/ModTargets.lua). + modScope = nil, -- FIND MODS panel state (src/mods/ModIndex.lua). findLoaded gates the -- first fetch the way `mods = nil` gates the mods list, but it is a flag -- rather than a nil listing because "no index added" is a legitimate @@ -1215,7 +1285,7 @@ function RomImporter.new(onComplete, opts) self.returning[version] = (not ready) and marker ~= nil and marker ~= markerFor(version) self.romName[version] = "pokemon_" .. info.id - .. (info.id == "yellow" and ".gbc" or ".gb") + .. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb") end self:_applyLastVersionTab() self:_queueBaseRomScan() @@ -1397,6 +1467,16 @@ function RomImporter:setError(message, version) self.progress = 0 self.worker = nil self.romData = nil + -- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY + -- only ever quits from onComplete, so an import that fails here would sit in + -- the error state forever and look to a build script (or a person) exactly + -- like a hang. Log what broke and exit non-zero instead. Logger, not a + -- literal write: this is a diagnostic for whoever ran the import, never text + -- a player sees, so it is deliberately not a translated string. + if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then + Logger.error("import failed: %s", tostring(message)) + love.event.quit(1) + end end -- draw() may leave the system hand cursor set while hovering a Play / @@ -1424,8 +1504,9 @@ function RomImporter:startData(data, displayName) self:setError("The selected file could not be read.") return end - if #data ~= 1024 * 1024 then - self:setError(("Expected a 1 MiB Game Boy ROM; this file is %.2f MiB.") + 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); this file is %.2f MiB.") :format(#data / 1024 / 1024)) return end @@ -1433,7 +1514,7 @@ function RomImporter:startData(data, displayName) local version = GameVersion.forSha1(actualHash) if not version then self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon " - .. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps " + .. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps " .. "(tagged [b] or [BF]) never verify."):format(actualHash)) return end @@ -1468,7 +1549,9 @@ function RomImporter:startData(data, displayName) CacheFs.remove(MARKER_PATH) local manifest = decodeManifest(version) - local RomExtractor = require("src.import.RomExtractor") + local RomExtractor = version == "gold" + and require("src.import.RomExtractorGen2") + or require("src.import.RomExtractor") local extractor = RomExtractor.new(self.romData, manifest, function(progress, total, stage, current, stageTotal) self.status = stage @@ -2198,7 +2281,7 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "red", "blue", "yellow", "mods", "find" } + local order = { "red", "blue", "yellow", "gold", "mods", "find" } local idx = 1 for i, id in ipairs(order) do if id == self.tab then idx = i; break end @@ -2569,8 +2652,12 @@ function RomImporter:_openSettings() self.onEditTouchControls() end end + -- The tab the gear was opened on decides the row set: Gold reads a + -- different option block entirely, and offering it Gen 1's rows meant a + -- dozen controls that changed nothing (see LauncherSettings.gen2Rows). + local version = self.tab local ok, model = pcall(function() - return require("src.import.LauncherSettings").open(hooks) + return require("src.import.LauncherSettings").open(hooks, version) end) if ok and model then self._settings = model end end @@ -2849,10 +2936,17 @@ function RomImporter:_refreshMods() .. table.concat(failed, ", ") } end end - self.mods = LauncherMods.list() or {} + self.mods = LauncherMods.list(self.modScope) or {} self:_syncModUpdateInfo(false) end +-- Point the MODS panel at one game (or nil for all of them) and relist, so +-- every row's status is answered for that game. +function RomImporter:_setModScope(version) + self.modScope = GameVersion.VERSIONS[version] and version or nil + self:_refreshMods() +end + function RomImporter:_ensureMods() if not self.mods then self:_refreshMods() end end @@ -2974,7 +3068,7 @@ function RomImporter:_toggleMod(id, confirmed) return end self._modConfirm = nil - LauncherMods.setEnabled(id, want) + LauncherMods.setEnabled(id, want, self.modScope) self:_refreshMods() end @@ -3014,7 +3108,7 @@ function RomImporter:_setAllMods(want, confirmed) return end self._modConfirm = nil - LauncherMods.setAllEnabled(ids, want) + LauncherMods.setAllEnabled(ids, want, self.modScope) self:_refreshMods() self.modNotice = { ok = true, text = want and Strings("Enabled %d mods.", #ids) diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index 3bfef34f..fcacc0cc 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -1,39 +1,67 @@ --- The bag defaults to 20 slots (BAG_ITEM_CAPACITY, --- constants/menu_constants.asm), but mods may replace that limit through --- Data.constants.bagSize. A distinct item id occupies one slot regardless --- of quantity; badges live in the inventory table but are not bag items. --- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder --- it). +-- Gen 2 keeps FOUR pockets, not Gen 1's single bag (item_data_constants.asm): +-- Items 20, Balls 12, Key Items 25, TM/HM 57. A distinct item id occupies one +-- slot of ITS OWN pocket regardless of quantity, and a pocket fills +-- independently -- which is exactly why the cart can hold every TM, every key +-- item AND still pick up an HM. Modelling all four as one 20-slot list filled +-- the bag with TMs and key items by the Ice Path and refused HM07 WATERFALL. +-- Badges live in the inventory table but are not bag items. save.bagOrder +-- keeps acquisition order like wBagItems (SELECT can reorder it). local Bag = {} +-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of +-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override +-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did. +local POCKET_CAPACITY = { + ITEM = 20, + BALL = 12, + KEY_ITEM = 25, + TM_HM = 64, +} local DEFAULT_CAPACITY = 20 +local function isBadge(id) + return id:find("BADGE", 1, true) ~= nil +end + +-- Which pocket an id belongs to. Unknown ids (a stale cache, a mod that did +-- not declare a pocket) fall to ITEM, the Gen 1 behaviour. +local function pocketOf(id, data) + data = data or require("src.core.Data") + local def = data and data.items and data.items[id] + return (def and def.pocket) or "ITEM" +end +Bag.pocketOf = pocketOf + -- `data` is injectable for the save editor and headless mod tests. Normal -- gameplay may omit it because the loader merges mods into the Data --- singleton before any item can be added. The fallback keeps old/stale --- generated caches and isolated callers at the vanilla limit. -function Bag.capacity(data) +-- singleton before any item can be added. A pocket argument gives that +-- pocket's cap; omitting it keeps the old single-number ITEM answer so +-- existing callers (and the mod bagSize override) are unchanged. +function Bag.capacity(data, pocket) data = data or require("src.core.Data") + if pocket and pocket ~= "ITEM" then + return POCKET_CAPACITY[pocket] or DEFAULT_CAPACITY + end local configured = data and data.constants and data.constants.bagSize if type(configured) == "number" and configured >= 1 then return math.floor(configured) end - return DEFAULT_CAPACITY -end - -local function isBadge(id) - return id:find("BADGE", 1, true) ~= nil + return POCKET_CAPACITY.ITEM end -- exported so item lists that share save.inventory (e.g. the PC deposit -- menu) can exclude badges the same way the bag does Bag.isBadge = isBadge -function Bag.slots(save) +-- Occupied slots, of one pocket when named or of the whole inventory when not. +function Bag.slots(save, data, pocket) local n = 0 for id in pairs(save.inventory) do - if not isBadge(id) then n = n + 1 end + if not isBadge(id) + and (not pocket or pocketOf(id, data) == pocket) then + n = n + 1 + end end return n end @@ -72,18 +100,25 @@ end -- (AddItemToInventory's per-slot quantity cap). function Bag.add(save, id, qty, data) local inv = save.inventory + -- Only the item's OWN pocket has to have room -- a full ITEM pocket does not + -- keep a KEY_ITEM or an HM out, which is the whole point of pockets. + local pocket = pocketOf(id, data) if not inv[id] and not isBadge(id) - and Bag.slots(save) >= Bag.capacity(data) then + and Bag.slots(save, data, pocket) >= Bag.capacity(data, pocket) then return false end if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then return false end + -- Insert into the order BEFORE the inventory write: Bag.order's defensive + -- append reads save.inventory, so writing first made it add the id and the + -- table.insert below add it again -- one pickup, two bag rows, until the + -- next order() pass deduped it (and a save taken in between kept both). local isNew = not inv[id] - inv[id] = (inv[id] or 0) + (qty or 1) if isNew and not isBadge(id) then table.insert(Bag.order(save), id) end + inv[id] = (inv[id] or 0) + (qty or 1) return true end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 699eb68e..c557b23b 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -13,6 +13,15 @@ -- generated paths that differ between two otherwise identical machines), -- names, dex entries, learnsets and TM/HM lists (they change no battle math -- and no trade rebuild). +-- +-- Two generations, two surfaces. Gold's link surface is the same IDEA over +-- different tables -- statuses live at data.gen2Statuses, the special stat is +-- two stats, the exp curves are data rather than code, and held items exist at +-- all -- so the Gen 2 arm below is a second surface writer, not a widened Gen 1 +-- one. Widening would have moved the Gen 1 digest, which is pinned by +-- tests/engine/gate_fingerprint.lua and by every installed build in the wild. +-- docs/gen2-link-design.md section 5 is the field-by-field reasoning for what +-- the Gen 2 surface covers and what it deliberately leaves out. local Runtime = require("src.mods.Runtime") @@ -152,20 +161,124 @@ local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS, Fingerprint.FIELDS = RECORD_FIELDS -local function writeSection(out, data, kind) - local map = data[kind] +-- ------- the Gen 2 link surface +-- +-- Same doctrine, applied to Gold's records. Every difference from the Gen 1 +-- lists above is a real Gen 2 change rather than an extractor spelling: +-- +-- baseStats carries specialAttack/specialDefense instead of special +-- (pokegold data/pokemon/base_stats/), which writeValue hashes +-- by sorted key without needing to know either name +-- genderRatio Gen 2 has ATTRACT, so two peers that disagree on a species' +-- gender split disagree on whether a move lands. Nothing else +-- out of the breeding block is here: the Day-Care is local, +-- there is no link breeding, and an egg's contents are decided +-- before it can be traded. +-- evolutions points at `into` rather than `species` and carries the +-- happiness window / stat comparison; it decides what a traded +-- mon becomes, exactly as on Gen 1 +-- +-- catchRate stays out for the reason #511 gives, and so does the whole +-- eggGroups/eggMoves/eggSteps block, `items` (the wild held-item slots, rolled +-- before a link session can see them) and tmhm. +local GEN2_SPECIES_FIELDS = { "baseStats", "types", "baseExp", + "growthRate", "evolutions", "genderRatio" } +-- effectChance is the one addition: Gen 1 encodes a secondary effect's odds in +-- the effect itself, Gen 2 stores them per move (pokegold data/moves/moves.asm +-- `move` macro, the effect chance byte), so two peers that disagree about +-- BODY SLAM's 30 percent disagree about the battle. The rest of the Gen 1 +-- list rides along unchanged: those keys are absent from an extracted Gold +-- record, and writeFields skips an absent field, so they cost nothing and +-- cover a mod that sets one. +local GEN2_MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", + "effectChance", "category", "priority", "highCrit", + "fixedDamage", "multiHit", "counterable", + "semiInvulnerable" } +-- the same six the Gen 1 statuses carry: src/mods/Schemas.lua R.statuses is one +-- spec for both games, and Gold's own records (src/battle/gen2/Battle.lua +-- STATUS_RECORDS) fill exactly these +local GEN2_STATUS_FIELDS = STATUS_FIELDS +-- `status` beside kind: a Gen 2 move_effects record is +-- { kind = "primary"/"secondary", status = "burn" } for every status-inflicting +-- effect (src/battle/gen2/Battle.lua MOVE_EFFECT_RECORDS), so the status a +-- given effect inflicts is part of the surface rather than part of the handler +local GEN2_EFFECT_FIELDS = { "rev", "kind", "accuracyChecked", "status" } +-- ItemAttributes' last two columns. Pure battle math (Leftovers' heal, King's +-- Rock's odds, a type booster's percentage) and the item rides along with a +-- traded mon, which makes it trade surface too. +local GEN2_HELD_FIELDS = { "rev", "heldEffect", "heldParameter" } +-- The exp curve coefficients, straight off pokegold data/growth_rates.asm. On +-- Gen 1 this is code (src/pokemon/Growth.lua) and cannot be hashed at all; on +-- Gold it is data the extractor writes, and it decides what level a traded +-- mon's experience buys, so it is surface. +local GEN2_GROWTH_FIELDS = { "numerator", "denominator", "squared", "linear", + "constant" } + +local GEN2_RECORD_FIELDS = { pokemon = GEN2_SPECIES_FIELDS, + moves = GEN2_MOVE_FIELDS, + statuses = GEN2_STATUS_FIELDS, + move_effects = GEN2_EFFECT_FIELDS, + held_items = GEN2_HELD_FIELDS, + growth_rates = GEN2_GROWTH_FIELDS } + +Fingerprint.GEN2_FIELDS = GEN2_RECORD_FIELDS + +-- the allowlist table for a generation; unknown generations read as Gen 1, the +-- same default GameVersion.generation() carries +local function fieldsFor(generation) + if generation == 2 then return GEN2_RECORD_FIELDS end + return RECORD_FIELDS +end + +-- ------- which generation a merged dataset belongs to +-- +-- Read off the data rather than off GameVersion, for two reasons: this file is +-- loaded by tools and headless tests that never boot a game (see the FNV +-- comment above), and a caller that hands over a fixture dataset should get a +-- digest for THAT dataset rather than for whatever the process last booted. +-- +-- data.type_chart.generation is written by the Gen 2 extractor and is the +-- cheapest honest answer. The namespace check behind it covers a dataset +-- assembled without a type chart: gen2Statuses/gen2MoveEffects/gen2Constants +-- are Data keys only a Gen 2 boot ever creates (src/core/Game2.lua:load and +-- src/mods/Builtins.lua's Gen 2 registrants), and Schemas.GEN1 gates every one +-- of the Gen 2-only registries to false, so a Red boot cannot grow one. +function Fingerprint.generationOf(data) + if type(data) ~= "table" then return 1 end + local chart = data.type_chart + if type(chart) == "table" and tonumber(chart.generation) then + return tonumber(chart.generation) + end + if data.gen2Statuses or data.gen2MoveEffects or data.gen2Constants then + return 2 + end + return 1 +end + +-- data.pokemon on Gold carries one sibling that is not a species: the +-- extractor's `growthRates` coefficient rows, which src/battle/gen2/Mon.lua +-- reads through growthFor. It gets its own section in the surface, and it is +-- skipped here so the species id space -- which Fingerprint.records hands to +-- Protocol.eligibleParty as "the mons the peer can rebuild" -- never carries an +-- id no party slot could hold. +local NON_SPECIES = { growthRates = true } + +local function writeRecords(out, map, label, fields, skip) if map == nil then return end - local fields = RECORD_FIELDS[kind] - out[#out + 1] = "[" .. kind .. "]" + out[#out + 1] = "[" .. label .. "]" for _, id in ipairs(sortedIds(map)) do local record = map[id] - if type(record) == "table" then + if type(record) == "table" and not (skip and skip[id]) then out[#out + 1] = "@" .. id writeFields(out, record, fields) end end end +local function writeSection(out, data, kind) + writeRecords(out, data[kind], kind, RECORD_FIELDS[kind]) +end + -- the chart rows are an ordered array whose order the merge rebuilds from -- registration history, so they hash in place; the type records ride along -- because `category` decides the physical/special split @@ -186,6 +299,22 @@ local function writeTypeChart(out, data) end end +-- Gold's chart carries one extra ordered array: the matchups FORESIGHT +-- rewrites, which is how a Normal or Fighting move reaches a Ghost at all +-- (pokegold data/types/foresight_matchups.asm, read through +-- BattleCheckTypeMatchup's `.foresight` arm in engine/battle/effect_commands.asm). +-- Two peers that disagree about it disagree about a turn, so it is surface. +local function writeGen2TypeChart(out, data) + writeTypeChart(out, data) + local chart = data.type_chart + if not chart or not chart.foresightMatchups then return end + out[#out + 1] = "[foresight]" + for _, row in ipairs(chart.foresightMatchups) do + out[#out + 1] = ("@%s>%s"):format(tostring(row.attacker), tostring(row.defender)) + writeValue(out, row.multiplier) + end +end + local function writeConstants(out, data) if not data.constants then return end out[#out + 1] = "[constants]" @@ -229,7 +358,7 @@ Fingerprint.modKey = modKey -- entry to link play, and vanilla single-player must not pay for it at all local cache = setmetatable({}, { __mode = "k" }) -local function surface(data, mods) +local function surfaceGen1(data, mods) local out = {} writeSection(out, data, "pokemon") writeSection(out, data, "moves") @@ -242,16 +371,67 @@ local function surface(data, mods) return table.concat(out) end +-- The Gen 2 surface. Opens with a "[gen2]" tag so a Gen 2 digest can never +-- collide with a Gen 1 one even over degenerate data -- checkCompat refuses a +-- cross-generation pairing by the hello's `generation` field long before the +-- digests are compared, and this makes the digest agree with that refusal +-- instead of leaving it to luck. +-- +-- data.gen2Constants is deliberately absent, and it is the one omission worth +-- spelling out: it is the ROM's ordered NAME lists (speciesOrder, itemOrder, +-- heldEffectOrder, mapOrder...), an index space the extractor uses, and every +-- dispatch in the Gen 2 simulation goes by name -- Battle.heldEffect compares +-- record.heldEffect strings, moveEffectRecordFor keys by EFFECT_*. Reordering +-- one moves no battle math, so hashing it would split two peers over a table +-- neither of them dispatches on, which is the #511 mistake in a new place. +-- Balls and item_effects stay out for the reason the Gen 1 surface leaves them +-- out: no link mode lets a bag item be thrown. +local function surfaceGen2(data, mods) + local out = { "[gen2]" } + writeRecords(out, data.pokemon, "pokemon", GEN2_SPECIES_FIELDS, NON_SPECIES) + -- the growth curves live on the species map as a sibling of the species + -- records (data.pokemon.growthRates, written by the extractor and read by + -- src/battle/gen2/Mon.lua:growthFor), so they hash as their own section + -- rather than as a species with no fields + writeRecords(out, data.pokemon and data.pokemon.growthRates, + "growth_rates", GEN2_GROWTH_FIELDS) + writeRecords(out, data.moves, "moves", GEN2_MOVE_FIELDS) + writeGen2TypeChart(out, data) + writeRecords(out, data.gen2Statuses, "statuses", GEN2_STATUS_FIELDS) + writeRecords(out, data.gen2MoveEffects, "move_effects", GEN2_EFFECT_FIELDS) + writeRecords(out, data.gen2HeldItems, "held_items", GEN2_HELD_FIELDS) + out[#out + 1] = "[mods]" .. modKey(mods) + return table.concat(out) +end + +-- `generation` is optional everywhere: absent means "ask the data" +-- (Fingerprint.generationOf), which is what every caller but a test does. +local function surface(data, mods, generation) + if (generation or Fingerprint.generationOf(data)) == 2 then + return surfaceGen2(data, mods) + end + return surfaceGen1(data, mods) +end + Fingerprint.surface = surface -- mods: { { id, version, affectsLink } } -- the hello's mod array -function Fingerprint.compute(data, mods) +function Fingerprint.compute(data, mods, generation) if not data then return digest("") end - local key = modKey(mods) + generation = generation or Fingerprint.generationOf(data) + -- the generation rides in the memo key: one dataset asked for both digests + -- (a test, a tool) must not be handed the other one back + local key = modKey(mods) .. "|" .. tostring(generation) local hit = cache[data] if hit and hit.key == key then return hit.value end + -- The hook keeps its Gen 1 name AND its Gen 1 arity. `generation` is + -- captured by the closure rather than passed as a third argument, so a mod + -- that wraps link.fingerprint and forwards nxt(data, mods) -- the shape + -- docs/modding.md documents and tests/mod_link_tests.lua exercises -- keeps + -- working verbatim on Gold instead of silently dropping the argument and + -- computing a Gen 1 digest over Gen 2 data. local value = Runtime.call("link.fingerprint", function(d, m) - return digest(surface(d, m)) + return digest(surface(d, m, generation)) end, data, mods) cache[data] = { key = key, value = value } return value @@ -261,25 +441,42 @@ end -- exactly which species and moves they rebuild identically local recordCache = setmetatable({}, { __mode = "k" }) -function Fingerprint.records(data, kind) - local fields = RECORD_FIELDS[kind] - assert(fields, "no record allowlist for " .. tostring(kind)) +-- The Data path a record kind reads from for a generation. Only the Gen 2 +-- side ever differs, and only for the registries Schemas.GEN2 namespaces. +local GEN2_PATHS = { statuses = "gen2Statuses", move_effects = "gen2MoveEffects", + held_items = "gen2HeldItems" } + +local function recordMap(data, kind, generation) + if generation == 2 then + local path = GEN2_PATHS[kind] + if path then return data[path] end + end + return data[kind] +end + +function Fingerprint.records(data, kind, generation) + generation = generation or Fingerprint.generationOf(data) + local fields = fieldsFor(generation)[kind] + assert(fields, ("no record allowlist for %s (generation %s)") + :format(tostring(kind), tostring(generation))) local perData = recordCache[data] if not perData then perData = {} recordCache[data] = perData end - if perData[kind] then return perData[kind] end - local map = data[kind] or {} + local slot = kind .. "|" .. tostring(generation) + if perData[slot] then return perData[slot] end + local map = recordMap(data, kind, generation) or {} + local skip = (generation == 2 and kind == "pokemon") and NON_SPECIES or nil local out = {} for id, record in pairs(map) do - if type(record) == "table" then + if type(record) == "table" and not (skip and skip[id]) then local buf = { "@" .. id } writeFields(buf, record, fields) out[id] = digest(table.concat(buf)) end end - perData[kind] = out + perData[slot] = out return out end diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 710ad5c8..cea7b147 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -16,11 +16,31 @@ local Handshake = {} Handshake.PROTOCOL = Version.linkProtocol or 2 -- writing into any of these changes what a lockstep turn or a rebuilt trade --- mon looks like, which is what a v1 peer cannot know about us +-- mon looks like, which is what a v1 peer cannot know about us. Registry +-- NAMES, not Data paths, so one list covers both generations: `statuses` means +-- data.statuses on Red and data.gen2Statuses on Gold (Schemas.GEN2), and +-- mod.content.statuses is the one thing a mod ever names. +-- +-- held_items is Gen 2-only and is here for the same reason the rest are: a +-- Gold mod that changes what LEFTOVERS heals has changed the battle, and the +-- item travels on a traded mon. On Red the registry is gated to false +-- (Schemas.GEN1), so no op can land in it and the row costs a Gen 1 boot +-- nothing. +-- +-- growth_rates is here because it is the one link-surface registry whose +-- records the fingerprint cannot hash: a curve is an expForLevel FUNCTION +-- (src/mods/Schemas.lua R.growth_rates), and writeValue serializes a function +-- as "?". It decides what level a traded mon's experience buys -- Gen 1 reads +-- it through src/pokemon/Growth.lua and Gold through Mon.growthFor, which +-- prefers the merged registry over the extractor's own coefficient rows -- so +-- two peers that disagree about a curve rebuild the same traded mon at +-- different levels. Without this row a mod declaring affects_link = false +-- could rewrite every curve and be caught by neither the digest (modKey skips +-- it on its own say-so) nor the online gate. local LINK_SURFACE = { pokemon = true, moves = true, type_chart = true, statuses = true, move_effects = true, balls = true, rulesets = true, constants = true, - link_fields = true, + link_fields = true, held_items = true, growth_rates = true, } Handshake.LINK_SURFACE = LINK_SURFACE @@ -146,10 +166,20 @@ function Handshake.onlineAllowed(game) return #Handshake.onlineBlockers(game) == 0 end +-- Which generation this install is running, read off the merged dataset rather +-- than off GameVersion, so a headless harness that hands over a fixture gets an +-- answer about THAT dataset (Fingerprint.generationOf spells out the two +-- signals it reads). A game with no data at all is Gen 1, which is what every +-- pre-Gold build was. +function Handshake.generation(game) + return Fingerprint.generationOf(game and game.data) +end + -- mode is nil on the guest: it pairs and announces itself before the host -- has picked, and compatibility is decided from the two hellos, not the mode function Handshake.hello(game, mode) local mods = Handshake.mods(game) + local generation = Handshake.generation(game) return { type = "hello", protocol = Handshake.PROTOCOL, @@ -157,7 +187,11 @@ function Handshake.hello(game, mode) mode = mode, engineVersion = Version.engine, apiVersion = Version.modApi, - fingerprint = Fingerprint.compute(game and game.data, mods), + -- additive, like every other field here: a peer that omits `generation` is + -- Gen 1 by construction, because no build that shipped without this field + -- could link as anything else (docs/gen2-link-design.md section 4) + generation = generation, + fingerprint = Fingerprint.compute(game and game.data, mods, generation), linkModified = Handshake.linkModified(game), mods = mods, } @@ -172,9 +206,27 @@ end -- engine_skew both v2 on the same major, but different releases: trade -- still negotiates, battle is refused (see below) -- subset both v2 but the surfaces differ: negotiated trade, no battle --- refused an old build we would silently corrupt, or a different engine +-- refused an old build we would silently corrupt, a different engine, or a +-- peer running the other generation function Handshake.checkCompat(localHello, remoteHello) localHello = localHello or {} + -- Generation first, ahead of the v1 branch below: a Gold install meeting a + -- pre-Gold build has to refuse it as the wrong GAME, not read its missing + -- `protocol` as "peer is vanilla Red and is right about us". + -- + -- The cart's answer to a cross-generation cable was the Time Capsule, and it + -- is not a compatibility mode: CheckTimeCapsuleCompatibility + -- (pokegold engine/link/link.asm:1970) refuses any Johto species, any move + -- past STRUGGLE and any mon holding mail, and only then does + -- Link_PrepPartyData_Gen1 rewrite the whole party into Red's 44-byte struct + -- with the Special stat recomputed out of KantoMonSpecials. Until somebody + -- writes that conversion and its two validators, refusing the pairing is the + -- honest answer -- docs/gen2-link-design.md section 6. + local localGen = localHello.generation or 1 + local remoteGen = (remoteHello and remoteHello.generation) or 1 + if localGen ~= remoteGen then + return "refused", "generation_mismatch" + end if not remoteHello or not remoteHello.protocol then if localHello.linkModified then return "refused", "peer_v1_modified" @@ -277,6 +329,19 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} local peer = (remoteHello and remoteHello.name) or "THEY" if verdict == "refused" then + -- checked before the v1 arm for the same reason checkCompat checks it + -- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet + -- would be named as "an older version", which is the wrong sentence and + -- sends the player looking for an update that does not exist + if ((localHello and localHello.generation) or 1) + ~= ((remoteHello and remoteHello.generation) or 1) then + wrap(lines, "The other game is") + wrap(lines, "from a different") + wrap(lines, "generation.") + wrap(lines, "These two games") + wrap(lines, "can't link.") + return lines + end if not (remoteHello and remoteHello.protocol) then wrap(lines, "The other game is") wrap(lines, "an older version") diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index fac1c453..bdf072a4 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -162,6 +162,200 @@ function Protocol.unpackMon(data, packed, opts) } end +-- ------------------------------------------------------------------- +-- The Gen 2 party struct on the wire +-- +-- A SECOND codec rather than optional keys on the Gen 1 one, because every +-- field the two share is spelled differently or means something else -- +-- docs/gen2-link-design.md section 3 has the table. The sharpest of them is +-- `status`: Gen 1 writes "PSN"/"BRN"/"SLP" (src/battle/Status.lua:62) and +-- Gen 2 writes "poison"/"burn"/"sleep" (src/battle/gen2/Battle.lua:65), and a +-- shared codec would hand a Gold party a status string nothing in it +-- recognises -- a mon that arrives poisoned and never takes poison damage. +-- +-- Nothing sends these yet. They exist because the mapping is the part of a +-- Gen 2 trade that is decidable today and because a wrong guess here would +-- silently corrupt a traded mon later; the session, the UI and mail are listed +-- as not built in the design doc's section 7. +-- +-- The cart's own party block is `Link_PrepPartyData_Gen2` +-- (pokegold engine/link/link.asm:810): player name, party count and species +-- list, trainer ID, six PARTYMON_STRUCT_LENGTH structs, six OT names, six +-- nicknames -- and, in the Trade Center only, mail as a SEPARATE block copied +-- out of sPartyMail. Mail stays separate here for the same reason: it is not +-- a party-struct field (src/core/gen2/Mail.lua:84 keys it by party slot), and +-- packing it onto the mon would invent a shape the cart does not have. +-- ------------------------------------------------------------------- + +-- Gen 2 rolls four DVs and DERIVES the HP DV from their low bits +-- (Mon.hpDV, and pokegold's own GetMonDVs does the same shuffle), so the +-- hp entry never travels: sending it would let a tampered packet claim an HP +-- DV its four visible DVs cannot produce. +local GEN2_DVS = { "attack", "defense", "speed", "special" } +-- MON_STAT_EXP's five words, in struct order; src/battle/gen2/Mon.lua's +-- STAT_EXP_ORDER is the authority and there is no sixth (SpA and SpD share the +-- Special word, the way the Gen 1 struct left them) +local GEN2_STAT_EXP = { "hp", "attack", "defense", "speed", "special" } + +function Protocol.packMon2(mon) + local moves = {} + for _, mv in ipairs(mon.moves or {}) do + -- ppUps has no Gen 2 model yet (Mon.movesAtLevel writes id/pp/maxPp); + -- carried when present so a mod that adds one is not silently capped, the + -- same reasoning packMon gives for the Gen 1 field + table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps }) + end + local dvs = {} + for _, k in ipairs(GEN2_DVS) do dvs[k] = (mon.dvs or {})[k] end + local statExp = {} + for _, k in ipairs(GEN2_STAT_EXP) do statExp[k] = (mon.statExp or {})[k] end + return { + species = mon.species, + level = mon.level, + -- MON_EXP, and the field is `experience` on a Gen 2 mon, not `exp` + experience = mon.experience, + hp = mon.hp, + status = mon.status, + nickname = mon.nickname, + dvs = dvs, + statExp = statExp, + moves = moves, + -- MON_ITEM. The held item is battle math (Leftovers, King's Rock, the + -- type boosters) and it travels with the mon, which is why held_items is + -- link surface in the fingerprint. + item = mon.item, + -- MON_HAPPINESS / MON_PKRS, both of which the cart ships inside the party + -- struct and both of which outlive a trade + happiness = mon.happiness, + pokerus = mon.pokerus, + caughtLevel = mon.caughtLevel, + ot = mon.ot or mon.otName, + otId = mon.otId, + -- an egg is a party slot the cart marks by writing EGG into wPartySpecies; + -- the port marks it with isEgg instead (src/core/gen2/Breeding.lua:64) + isEgg = mon.isEgg or nil, + eggSteps = mon.isEgg and mon.eggSteps or nil, + extra = plainCopy(mon.extra), + } +end + +-- Rebuild a Gen 2 mon locally. Same contract as unpackMon: every number is +-- clamped and every derived value is RECOMPUTED from real species data, so a +-- tampered packet can invent neither stats nor a shiny. opts.strict refuses by +-- name instead of substituting once two v2 peers have agreed on a verdict. +function Protocol.unpackMon2(data, packed, opts) + local Mon = require("src.battle.gen2.Mon") + local strict = opts and opts.strict + local forceLevel = opts and tonumber(opts.forceLevel) or nil + local def = data and data.pokemon and data.pokemon[packed.species] + if not def then + if strict then return nil, "unknown POKéMON" end + return nil + end + local level = math.max(1, math.min(Mon.MAX_LEVEL, + math.floor(packed.level or 5))) + if forceLevel then + level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel))) + end + local dvs = {} + for _, k in ipairs(GEN2_DVS) do + dvs[k] = math.max(0, math.min(Mon.MAX_DV, + math.floor((packed.dvs or {})[k] or 0))) + end + -- derived, never taken from the packet (see GEN2_DVS above) + dvs.hp = Mon.hpDV(dvs) + local statExp = {} + for _, k in ipairs(GEN2_STAT_EXP) do + statExp[k] = math.max(0, math.min(65535, + math.floor((packed.statExp or {})[k] or 0))) + end + local stats = Mon.stats(def.baseStats, dvs, level, statExp) + local moves = {} + for _, mv in ipairs(packed.moves or {}) do + local mdef = data.moves and data.moves[mv.id] + if mdef and #moves < 4 then + local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) + local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5) + local entry = { id = mv.id, maxPp = maxPp, + pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) } + if mv.ppUps ~= nil then entry.ppUps = ppUps end + table.insert(moves, entry) + end + end + if #moves == 0 then + if strict then return nil, "no shared moves" end + -- the Gen 1 substitute, in Gen 2's move-entry shape + local tackle = data.moves and data.moves.TACKLE + moves = { { id = "TACKLE", pp = (tackle and tackle.pp) or 35, + maxPp = (tackle and tackle.pp) or 35 } } + end + -- An item the receiving game has never heard of cannot be held: the battle + -- would read no heldEffect for it and the bag would show a blank row. This + -- is the same judgement CheckTimeCapsuleCompatibility makes from the other + -- side (pokegold engine/link/link.asm:1970 refuses mail rather than shipping + -- an item the peer cannot represent), and the Gen 2 arm of + -- Protocol.eligibleParty is what keeps it from ever reaching here on a + -- negotiated trade. + local item = packed.item + if item ~= nil and not (data.items and data.items[item]) then + if strict then return nil, "unknown item" end + item = nil + end + local forced = forceLevel + local hp = forced and stats.hp + or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))) + local status = forced and nil or packed.status + local otId = packed.otId + and math.max(0, math.min(65535, math.floor(packed.otId))) or nil + local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil + local growth = Mon.growthFor(data, def.growthRate) + local mon = { + species = packed.species, + name = def.name or packed.species, + nickname = packed.nickname, + level = level, + experience = math.max(0, math.floor(packed.experience + or Mon.experienceForLevel(growth, level))), + dvs = dvs, + statExp = statExp, + stats = stats, + hp = hp, + maxHp = stats.hp, + types = def.types, + status = status, + moves = moves, + item = item, + -- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon + -- keeps what it arrived with, clamped to the byte the cart stores it in + happiness = math.max(0, math.min(255, + math.floor(packed.happiness or 70))), + pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))), + caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL, + math.floor(packed.caughtLevel or level))), + ot = ot, + otName = ot, + otId = otId, + extra = plainCopy(packed.extra), + } + if packed.isEgg then + mon.isEgg = true + mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0)) + end + -- Derived from the DVs on the RECEIVING side, exactly as they were derived on + -- the sending one: shininess, gender and an Unown's letter are all functions + -- of the same four bytes (Mon.isShiny / Mon.gender / Unown.letterFromDVs), so + -- sending them would only give a patched client a way to claim a shiny it + -- never rolled. + local ctx = { species = packed.species, def = def, level = level } + mon.shiny = Mon.isShiny(dvs, ctx) + mon.gender = Mon.gender(def, dvs, ctx) + local Unown = require("src.core.gen2.Unown") + if packed.species == Unown.SPECIES then + mon.unownLetter = Unown.letterFromDVs(dvs) + end + return mon +end + function Protocol.packParty(party, indices) local mons = {} if indices then @@ -185,9 +379,20 @@ end -- games showed neither side (#511). The full catalog is ~300 short hash -- strings -- still one message. function Protocol.recordsMessage(data, party) - return { type = "records", - pokemon = Fingerprint.records(data, "pokemon"), - moves = Fingerprint.records(data, "moves") } + local generation = Fingerprint.generationOf(data) + local msg = { type = "records", + pokemon = Fingerprint.records(data, "pokemon", generation), + moves = Fingerprint.records(data, "moves", generation) } + -- One more map on Gen 2, and additive by the same rule the hello follows: a + -- peer that never sends `heldItems` is one whose game has no held items, and + -- eligibleParty treats an absent map as "nothing to check" so the Gen 1 path + -- is byte-identical. A held item is battle math AND it rides along on the + -- traded mon, so a mon holding one the peer rebuilds differently is exactly + -- as untradeable as a mon that knows a move it rebuilds differently. + if generation == 2 then + msg.heldItems = Fingerprint.records(data, "held_items", generation) + end + return msg end -- a mon may cross the wire only if both peers rebuild it identically: the @@ -201,12 +406,31 @@ function Protocol.eligibleParty(party, myRecords, theirRecords) local theirMoves = theirRecords.moves or {} local mySpecies = (myRecords or {}).pokemon or {} local myMoves = (myRecords or {}).moves or {} + -- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps + -- the loop below unchanged for Red: a mon with no `item` never reaches the + -- held-item arm at all. + local theirHeld = theirRecords.heldItems + local myHeld = (myRecords or {}).heldItems or {} for i, mon in ipairs(party or {}) do local reason if not theirSpecies[mon.species] then reason = "not on the other game" elseif theirSpecies[mon.species] ~= mySpecies[mon.species] then reason = "different data" + -- Absence before difference, because a missing row on their side reads as + -- "different" to a naive comparison and the player would be told the wrong + -- thing. Both arms are guarded on myHeld[mon.item]: an item with no held + -- behaviour on EITHER game (a POTION in the item slot) is just an id, and + -- an id the peer already has by way of the species check. + elseif mon.item and theirHeld and myHeld[mon.item] + and not theirHeld[mon.item] then + -- we hold it AS a held item and they have no such row: the mon would + -- arrive holding something their battle cannot read + reason = "unknown item" + elseif mon.item and theirHeld and myHeld[mon.item] + and theirHeld[mon.item] ~= myHeld[mon.item] then + -- the item exists on both games but does something else on theirs + reason = "different item" else for _, mv in ipairs(mon.moves or {}) do if not theirMoves[mv.id] then @@ -277,9 +501,16 @@ function TradeSession:partyMessage() mons = Protocol.packParty(self.party, self.sendIndices) } end +-- Our own side of the comparison is built by the SAME function that puts our +-- records on the wire, so the two can never drift: an open-coded pair of +-- Fingerprint.records calls here left `heldItems` off our side only, and +-- eligibleParty's held-item arms are both guarded on myHeld[mon.item] -- so on +-- Gen 2 they were unreachable from the only production caller and a mon +-- holding an item the peer rebuilds differently sailed through the filter. +-- The message's `type` field is inert to eligibleParty, which reads exactly +-- the three record maps. function TradeSession:_negotiate(theirRecords) - local mine = { pokemon = Fingerprint.records(self.data, "pokemon"), - moves = Fingerprint.records(self.data, "moves") } + local mine = Protocol.recordsMessage(self.data, self.party) self.eligible, self.reasons = Protocol.eligibleParty(self.party, mine, theirRecords) local indices = {} diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 00136d95..66695a17 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -21,7 +21,25 @@ Builtins.OWNER = Schemas.ENGINE -- registry name -> the module that owns its vanilla records. Each exposes -- registerInto(registry, data, owner). local REGISTRANTS = { - { name = "type_chart", from = "src.battle.TypeChart" }, + -- The one registrant that owns its whole target: R.type_chart rebuilds + -- Data.type_chart.matchups AND .types from the op log, so whatever is not + -- registered here is not in the merged chart. TypeChart.registerInto + -- already reads the matchup rows out of the dataset, but its TYPE records + -- are the module's Gen 1 literals -- and on a Gold boot those replaced + -- Gold's own 19 type records with Red's 15, dropping DARK, STEEL, BIRD and + -- CURSE_TYPE outright and reverting every category to the Gen 1 split + -- (src/battle/gen2/Damage.lua:isPhysical reads exactly this table). A + -- dataset that ships its own type records is authoritative over the + -- module's fallback; Gen 1's extractor writes no `types`, so there the + -- override loop finds nothing and behavior is unchanged. + { name = "type_chart", modules = { "src.battle.TypeChart" }, + install = function(registry, modules, owner, data) + modules[1].registerInto(registry, data, owner) + local chart = data and data.type_chart + for id, record in pairs(chart and chart.types or {}) do + registry:override(id, record, owner) + end + end }, { name = "statuses", from = "src.battle.Status" }, { name = "move_effects", from = "src.battle.MoveEffects" }, { name = "balls", from = "src.battle.Catching" }, @@ -50,20 +68,94 @@ local REGISTRANTS = { end }, } +-- Gen 2 (Gold) reimplements the systems behind these registries, and since +-- Schemas.GEN2 routes them to their own Data paths the vanilla records that +-- land there have to be GOLD's, not Red's. Seeding Red's would be worse than +-- seeding nothing: the ids collide (both games call it GREAT_BALL, and Red's +-- record carries no `multiplier`, so src/battle/gen2/Catching.lua would read +-- nil and quietly drop the x1.5), and Ai.layersFor walks the merged table for +-- mod-registered layers, so Red's LAYER_1..LAYER_3 would join Gold's scoring +-- passes. A name mapped to `false` is seeded by nothing on Gold, which is the +-- right answer for `commands`: the Gen 2 VM's verb table is the mod verbs +-- alone (src/script/gen2/Vm.lua:runModCommand), and a Gen 1 row-list verb +-- handed Gold's ctx would find no runner on it. +-- +-- Same registry NAMES throughout -- only the records differ, exactly as only +-- the target path differs in Schemas.GEN2. +local GEN2_REGISTRANTS = { + -- src/battle/gen2/Battle.lua owns two registries, so it names its entry + -- points rather than exposing one registerInto + statuses = { from = "src.battle.gen2.Battle", fn = "registerStatusesInto" }, + move_effects = { from = "src.battle.gen2.Battle", + fn = "registerMoveEffectsInto" }, + item_effects = { from = "src.core.gen2.ItemEffects" }, + balls = { from = "src.battle.gen2.Catching" }, + ai_classes = { from = "src.battle.gen2.Ai" }, + evolution_methods = { from = "src.core.gen2.Evolution" }, + -- Gold's curves are coefficient rows in the extracted pokemon.lua, not + -- records; the Gen 2 registrant wraps each one as the { expForLevel } record + -- Gen 1's registry uses, so the registry keeps ONE record shape across both + -- games and a mod writes a custom curve once. + growth_rates = { from = "src.battle.gen2.Mon" }, + commands = false, + -- The Gen 2-only content registries (Schemas.GEN1 gates every one of these + -- under Gen 1, which is the mirror of the `false` rows in Schemas.GEN2). + -- Four of the six are seeded here, from the module that holds the cart's own + -- table; the other two merge onto a table that already exists when + -- mods:load runs and so have no registrant, exactly as `maps` has none -- + -- `landmarks` onto the cache's gen2Landmarks.landmarks, and `held_items` + -- onto the view src/core/Game2.lua builds from data.items. + phone_contacts = { from = "src.core.gen2.Phone" }, + decorations = { from = "src.core.gen2.Decorations" }, + apricorns = { from = "src.core.gen2.Apricorns" }, + radio_channels = { from = "src.ui.gen2.MapRadio" }, +} + +-- the registrant list for one generation, in registration order: the Gen 1 +-- entries with the reimplemented ones swapped out, then the Gen 2-only ones +-- (item_effects and the content five have no Gen 1 registrant to swap) in a +-- fixed order so two boots seed the same registries the same way +local GEN2_ONLY_ORDER = { "item_effects", "phone_contacts", "decorations", + "apricorns", "radio_channels" } + +local function registrantsFor(generation) + if generation ~= 2 then return REGISTRANTS end + local out, taken = {}, {} + for _, entry in ipairs(REGISTRANTS) do + local swap = GEN2_REGISTRANTS[entry.name] + if swap == nil then + out[#out + 1] = entry + elseif swap then + taken[entry.name] = true + out[#out + 1] = { name = entry.name, from = swap.from, fn = swap.fn } + end + end + for _, name in ipairs(GEN2_ONLY_ORDER) do + local swap = GEN2_REGISTRANTS[name] + if swap and not taken[name] then + out[#out + 1] = { name = name, from = swap.from, fn = swap.fn } + end + end + return out +end + -- the registries the engine seeds, in registration order; the parity tests -- read this to tell an engine-owned namespace from a stray one -function Builtins.registries() +function Builtins.registries(generation) local names = {} - for i, entry in ipairs(REGISTRANTS) do names[i] = entry.name end + for i, entry in ipairs(registrantsFor(generation)) do names[i] = entry.name end return names end -- the top-level Data keys those registrations bring into existence: the --- only namespaces a mod-free boot is allowed to add -function Builtins.namespaceRoots() +-- only namespaces a mod-free boot is allowed to add. Routed per generation +-- for the same reason the merge is (Schemas.GEN2): on Gold the engine's own +-- statuses land in data.gen2Statuses, so gen2Statuses is the root that appears. +function Builtins.namespaceRoots(generation) local roots = {} - for _, name in ipairs(Builtins.registries()) do - local target = Schemas.REGISTRIES[name] and Schemas.REGISTRIES[name].target + for _, name in ipairs(Builtins.registries(generation)) do + local spec = Schemas.REGISTRIES[name] + local target = spec and Schemas.targetFor(name, spec, generation) if target then roots[target:match("^[^%.]+")] = true end end return roots @@ -95,8 +187,8 @@ local function isolate(registry) }, { __index = registry }) end -function Builtins.install(content, data) - for _, entry in ipairs(REGISTRANTS) do +function Builtins.install(content, data, generation) + for _, entry in ipairs(registrantsFor(generation)) do local registry = content[entry.name] and isolate(content[entry.name]) if registry then if entry.install then @@ -105,12 +197,19 @@ function Builtins.install(content, data) modules[i] = load(path) if modules[i] == nil then complete = false end end - if complete then entry.install(registry, modules, Builtins.OWNER) end + -- data is the fourth argument, not the second, so the existing + -- installers keep their signature; only a registrant that seeds from + -- the loaded dataset (type_chart) reaches for it + if complete then + entry.install(registry, modules, Builtins.OWNER, data) + end else local module = load(entry.from) - if module and module.registerInto then - module.registerInto(registry, data, Builtins.OWNER) - end + -- entry.fn names the entry point for a module that owns more than one + -- registry (Gold's Battle owns statuses and move_effects); the default + -- is the registerInto every single-registry module exposes + local into = module and module[entry.fn or "registerInto"] + if into then into(registry, data, Builtins.OWNER) end end end end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua new file mode 100644 index 00000000..f336e61c --- /dev/null +++ b/src/mods/Gen2Compat.lua @@ -0,0 +1,2140 @@ +-- Gen 1 module facades for a Gen 2 boot: the Gen 1 API, backed by Gen 2 +-- internals, handed to a mod's own require by src/mods/Loader.lua's shim. +-- Contract and the full table: docs/mod-api-gen2-compat.md. +-- +-- Three rules, because a plausible wrong answer is worse than a missing +-- module: answered at CALL time (never a snapshot), absent or warned once when +-- Gold cannot back it, and one stable writable table for the whole run. +-- +-- A fourth that decides facade-versus-alias: a name a mod MONKEY-PATCHES has +-- to resolve to the very table Gold runs, or the patch lands on a copy and +-- silently never fires. Everything else is a translating facade, because an +-- alias to a Gen 2 module with a different argument list is the plausible +-- wrong answer in its purest form. +-- +-- Gen2Compat.coverage() publishes which is which, per member, so the modkit +-- checker cannot drift from what is actually built here. + +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local Gen2Compat = {} + +-- the genuine require: the Loader replaces _G.require while mods load +local rawRequire = require + +-- injected by src/mods/Loader.lua: the live Game2, or nil before one exists +local resolveGame = nil + +-- built adapters, one per name for the life of the process +local built = {} +-- mod ids that took each facade, for attribution in the warnings below +local claimants = {} +local warned = {} + +local function warnOnce(key, fmt, ...) + if warned[key] then return end + warned[key] = true + Logger.warn(fmt, ...) +end + +local function who(name) + local ids = claimants[name] + if not ids or #ids == 0 then return "a gen2compat mod" end + return table.concat(ids, ", ") +end + +local function live() + return resolveGame and resolveGame() or nil +end + +-- the live World, or nil plus one warning naming the member that wanted it +local function liveWorld(module, member) + local g = live() + local world = g and g.world + if world then return world end + warnOnce(module .. "." .. member .. ".noworld", + "[%s] %s.%s: no world is up yet; the honest replacement is the " + .. "game.ready / map.entered event", who(module), module, member) + return nil +end + +-- A member Gold cannot back: the call is answered with nil and named once, +-- with the mod that took the facade attributed. Never a default. +local function unbacked(module, member, why) + return function() + warnOnce(module .. "." .. member, + "[%s] %s.%s has no Gen 2 backing: %s", + who(module), module, member, why) + return nil + end +end + +-- ------- coverage +-- +-- One machine-readable answer to "is this module served, and what happened to +-- each member". Statuses are exactly three and the vocabulary is frozen: +-- backed the member is present and does the Gen 1 job on Gold +-- warned the member is present, answers nil (or degrades) and says so once +-- absent the member is deliberately not on the table; a nil call is the +-- honest failure and calling it is a mod bug, not an adapter gap +-- `notes` carries the one-line reason a checker or a doc wants to quote. + +local COVERAGE = {} + +Gen2Compat.COVERAGE_VERSION = 1 +Gen2Compat.STATUS = { BACKED = "backed", WARNED = "warned", ABSENT = "absent" } + +local function words(s) + local out = {} + for w in tostring(s or ""):gmatch("%S+") do out[#out + 1] = w end + return out +end + +-- ------- src.core.Game +-- +-- Gen 1's `Game` IS the running game (src/core/Game.lua:17); Game2 is a class +-- whose instance is minted per boot, so there is no table to alias. The +-- proxy resolves every key against the live instance at read time. +-- +-- Deliberately NOT a blanket forward: a dozen Gen 1 members Game2 simply +-- lacks would read nil with no word, and two (applyOptions, load) forward to a +-- Game2 method of the same name with a different contract. + +-- data.sprites is a rename (gen2Sprites, src/mods/Schemas.lua:475); the rest +-- of the renames sit beside it. data.constants is the one that must NOT be +-- routed: Gold's gen2Constants is the cart's ordered name lists, not Gen 1's +-- rule table, so the same word names a different thing. +local DATA_RENAMES = { + sprites = "gen2Sprites", maps = "gen2Maps", tilesets = "gen2Tilesets", + text = "gen2Text", encounters = "gen2Encounters", palettes = "gen2Palettes", + icons = "gen2Icons", battle_anims = "gen2BattleAnims", +} + +local DATA_UNBACKED = { + field = "Gold's ledges are src/world/gen2/Permissions.lua and its tile " + .. "pairs do not exist", + constants = "data.gen2Constants is the cart's ordered NAME LISTS, not Gen " + .. "1's rule table; partyMax / boxCount / badges have no Gold equivalent", + text_pointers = "the extractor's Gen 1 pointer table has no Gold counterpart", + trainer_headers = "the extractor's Gen 1 header table has no Gold counterpart", +} + +local dataProxies = setmetatable({}, { __mode = "kv" }) + +local function dataProxy(data) + if not data then return nil end + local hit = dataProxies[data] + if hit then return hit end + hit = setmetatable({}, { + __index = function(_, key) + local renamed = DATA_RENAMES[key] + if renamed then return data[renamed] end + local why = DATA_UNBACKED[key] + if why then + warnOnce("data." .. key, "[%s] game.data.%s is Gen 1 only: %s", + who("src.core.Game"), key, why) + return nil + end + return data[key] + end, + __newindex = function(_, key, value) data[DATA_RENAMES[key] or key] = value end, + }) + dataProxies[data] = hit + return hit +end + +-- Members Gen 1 answers and Gold does NOT, where a nil read would otherwise +-- pass for "the game says no" instead of "this API is not here". Everything +-- Game2 genuinely carries forwards; everything else reads nil AND says so. +local GAME_UNBACKED = { + renderer = "Gold composites in Game2:draw and never inits the Renderer " + .. "singleton; Game2:pixelScale / :frameFit / :viewport are the honest " + .. "replacements, and a Renderer alias would answer fitScale correctly " + .. "while every state write silently no-opped", + load = "calling it would re-run Gold's whole boot on top of a running game", + bootConfig = "Gold's new-game inputs are Save.newGame's opts, not a " + .. "data.field slice", + makeTitleState = "Gen 2 pushes the title itself (Game2:showTitle); nothing " + .. "returns a state to push", + step = "Gold's logic tick is the FixedStep callback in Game2:load; the " + .. "input.step hook fires at the identical moment", +} + +-- Pure whole-stack walks that are not generation-bound at all; pulled off the +-- real Gen 1 module rather than copied, so they cannot drift. +local GAME_STACK_STATICS = { + "worldBgBattleDim", "worldBgBattleInStack", "fillScaleInStack", + "wideBattleInStack", "uiAnchorsHeldInStack", "drawBaseInStack", "dynamicUI", +} + +local function buildGame() + local proxy = {} + local statics = nil + + local function gen1Static(key) + if statics == nil then + local ok, module = pcall(rawRequire, "src.core.Game") + statics = ok and module or false + end + return statics and statics[key] or nil + end + + local translate = {} + + -- `.overworld` is the Gen 1 spelling of Game2.world (src/core/Game2.lua:213) + function translate.overworld(g) return g.world end + function translate.data(g) return dataProxy(g.data) end + function translate.fixedStep() return rawRequire("src.core.FixedStep") end + + function translate.writeOptions() + return function() + local g = live() + if g and g.persistOptions then return g:persistOptions() end + end + end + + function translate.logicSpeed() + return function() + local g = live() + if not g then return 1 end + return math.max(1, tonumber(g.speedOverride) + or tonumber(g.options and g.options.speed) or 1) + end + end + + -- Game2:applyOptions takes NO argument and applies self.options, so a + -- blanket forward of Game:applyOptions(myTable) succeeds and applies the + -- wrong table. Adopt the argument first, exactly as Gen 1 does. + function translate.applyOptions() + return function(opts) + local g = live() + if not g then return end + if opts and opts ~= proxy and opts ~= g.options then + g.options = opts + if g.save then g.save.options = opts end + end + return g:applyOptions() + end + end + + function translate.restoreSave() + return function(loaded, recovered) + local g = live() + if not g then return end + if recovered ~= nil then + warnOnce("game.restoreSave.recovered", + "[%s] Game:restoreSave's `recovered` and the quarantine report are " + .. "unserved: Gold has no SaveData.validate pass", + who("src.core.Game")) + end + return g:continueGame(loaded) + end + end + + function translate.restartWithMods() + return function() return rawRequire("src.core.HostShell").restart() end + end + + function translate.recoverInput() + return function() + local g = live() + if not g then return end + local Input = rawRequire("src.core.Input") + local Touch = rawRequire("src.core.TouchControls") + Input:reset() + if Input.reconcile then Input:reconcile() end + Touch:reset() + if g.mods and g.mods.releaseModInput then g.mods:releaseModInput() end + if g.cancelPointers then g:cancelPointers() end + end + end + + -- Gen 1's joystickadded runs the whole input recovery; Gold's is a noop, so + -- a mod relying on hotplug recovery would get none and no signal. + function translate.joystickadded() + local recover = translate.recoverInput() + return function(joystick) + recover() + local g = live() + if g and g.joystickadded then g:joystickadded(joystick) end + end + end + + function translate.zoomStep() + return function(delta) + local g = live() + if not (g and g.world and g.world.map) then return end + g.world:zoomStep(delta) + g.options = g.options or {} + g.options.zoom = rawRequire("src.render.Zoom").offset + if g.persistOptions then g:persistOptions() end + end + end + + setmetatable(proxy, { + __index = function(_, key) + local why = GAME_UNBACKED[key] + if why then + warnOnce("game." .. key, "[%s] Game.%s has no Gen 2 backing: %s", + who("src.core.Game"), key, why) + return nil + end + local made = translate[key] + if made then + local g = live() + if not g and key ~= "writeOptions" and key ~= "logicSpeed" + and key ~= "fixedStep" and key ~= "restartWithMods" then + return nil + end + return made(g) + end + for _, name in ipairs(GAME_STACK_STATICS) do + if name == key then return gen1Static(key) end + end + local g = live() + if not g then return nil end + local value = g[key] + -- Gen 1 has it, Gold does not, and nothing above claimed it: nil is the + -- answer either way, but a mod deserves to hear which nil this is. + if value == nil then + warnOnce("game.unknown." .. tostring(key), + "[%s] Game.%s is not on the Gen 2 service owner; it reads nil " + .. "because Game2 has no member of that name", + who("src.core.Game"), tostring(key)) + return nil + end + -- a method reached through the facade gets the live game as self + if type(value) == "function" then + return function(first, ...) + if first == proxy then return value(g, ...) end + return value(first, ...) + end + end + return value + end, + __newindex = function(_, key, value) + local g = live() + if not g then return end + if key == "overworld" then g.world = value return end + g[key] = value + end, + }) + return proxy +end + +COVERAGE["src.core.Game"] = { + kind = "facade", + backed = "overworld data data.sprites data.maps data.tilesets data.text " + .. "data.encounters data.trainers data.palettes data.icons " + .. "data.battle_anims data.pokemon data.moves data.items data.type_chart " + .. "data.font data.audio save mods modStatus input touchControls stack " + .. "fixedStep speedOverride modPointers capturePath audioAccum " + .. "writeOptions logicSpeed applyOptions restoreSave restartWithMods " + .. "recoverInput zoomStep joystickadded returnToTitle update draw " + .. "wheelmoved keypressed keyreleased gamepadpressed gamepadreleased " + .. "gamepadaxis joystickpressed joystickreleased joystickaxis " + .. "joystickhat joystickremoved focus visible onResume pointerEvent " + .. "touchpressed touchmoved touchreleased mousepressed mousemoved " + .. "mousereleased cancelPointers adoptSave writeSave worldBgBattleDim " + .. "worldBgBattleInStack fillScaleInStack wideBattleInStack " + .. "uiAnchorsHeldInStack drawBaseInStack dynamicUI", + warned = "renderer load bootConfig makeTitleState step data.field " + .. "data.constants data.text_pointers data.trainer_headers", + absent = "linkNet linkSession saveReport save.money save.player.map " + .. "save.player.x save.player.y save.player.facing save.player.rival " + .. "save.defeatedTrainers save.meta save.box", + notes = { + identity = "the proxy can never compare equal to the Game2 instance the " + .. "game.ready payload carries (5.1 fires __eq only when both operands " + .. "share it)", + iteration = "pairs/next/rawget see an EMPTY table; enumerate the " + .. "game.ready payload instead", + rawset = "rawset lands on the proxy, invisible to the engine, and reads " + .. "back correctly through the same facade -- which is what hides it", + ["save.money"] = "save.player.money on Gold", + ["save.player.map"] = "save.position.map/x/y/facing on Gold", + ["save.player.rival"] = "save.rival.name on Gold", + keypressed = "F10, F5, backtick and `4` outside the world are silent " + .. "no-ops on Gold; `2` cycles GbcPalette, not PaletteFX", + keyreleased = "a Gold screen never sees onKeyReleased (#589)", + }, +} + +-- ------- src.world.NPC +-- +-- ALIAS, and it has to be: a mod tests getmetatable(npc) == the module it +-- required, or setmetatable(its own trailer, NPC). A facade table cannot be +-- the metatable of an object built by src/world/gen2/Npc.lua, so the Gen 1 +-- CONSTRUCTOR arity was added there instead (NPC.new sniffs a table first +-- argument) and the Gen 1 instance surface (pose, a camera-arity draw, +-- `marching`) with it. + +local function buildNpc() + local Npc2 = rawRequire("src.world.gen2.Npc") + -- the sheet a Gen 1 objDef naming a Kanto SPRITE_* falls back to + Npc2.fallbackSpriteDef = function() + local g = live() + return g and g.world and g.world.player and g.world.player.spriteDef + end + return Npc2 +end + +COVERAGE["src.world.NPC"] = { + kind = "alias", target = "src.world.gen2.Npc", + backed = "new __index facePlayer update walkPhase pose draw def id cellX " + .. "cellY px py facing moving progress stepFlip frozen timer targetX " + .. "targetY sprite spriteId passable stepFrames marching MOVE", + warned = "", + absent = "hopStep", + notes = { + new = "movement translated: WALK+UP_DOWN/LEFT_RIGHT to WALK_*, else " + .. "WANDER with a {x=3,y=3} radius default (radius 0 freezes the " + .. "object); STAY to STANDING_*, never STILL, which carries FIXED_FACING", + facePlayer = "Gen 2 returns early for a fixed-facing object or a sheet " + .. "with one frame, so a raw MOVE.STILL byte still no-ops", + draw = "two arguments is read as a Gen 1 camera; three is Gold's " + .. "(ox, oy, scale)", + hopStep = "Gen 2 interpolates cell to target, so a two-cell hop is " + .. "expressed by setting the target two cells out", + update = "wanders / roamDirs are not read; Gold branches on npc.kind, and " + .. "for kind == 'turn' roamDirs is a facing->facing MAP, not an array", + }, +} + +-- ------- src.world.Map +-- +-- ALIAS, for the same reason: a mod never builds a Map, it gets world.map, so +-- anything a facade added would be invisible. The Gen 1 module-level statics +-- and the missing instance methods went into src/world/gen2/Map.lua. + +COVERAGE["src.world.Map"] = { + kind = "alias", target = "src.world.gen2.Map", + backed = "new inBounds cellTile isWalkableCell isWaterCell isGrassCell " + .. "isCounterCell warpAtCell blockAt setBlock tileAt isDoorTileCell " + .. "isWarpTileCell signAtCell connection isOutdoor isOutside inRegion " + .. "isPushable defCellTile defIsWalkableCell defIsWaterCell defPassable " + .. "def tileset id widthCells heightCells DELTA warps", + warned = "cellTile defCellTile", + absent = "isFlyTown ghostBattles warpPadOrHoleAt walkable doorTiles " + .. "warpTiles waterTiles renderer signAt", + notes = { + warpAt = "NAME COLLISION: Gen 1's map.warpAt is a TABLE keyed by cell, " + .. "Gold's Map:warpAt is a METHOD of the same name. map.warpAt[k] " + .. "errors and pairs(map.warpAt) errors -- loud, but pointing at the " + .. "mod. Enumerate map.warps instead, which Gold carries as an " + .. "ordered array", + cellTile = "returns a COLL_* byte, not a Gen 1 tile id; unrelated number " + .. "spaces, warned once on first call", + isDoorTileCell = "the narrow arm (Permissions.isImmediateWarp): a door " + .. "walked INTO, not a mat stood on", + signAtCell = "coordinate-only, so it reports bg events World:bgEventAt " + .. "would filter out by facing; the record is a bgEvent, not a sign, " + .. "so sign.text is nil", + isOutdoor = "Gold decides both isOutdoor and isOutside from the header's " + .. "environment byte, so they collapse to one answer", + isFlyTown = "Gold's fly destinations are landmark SPAWN POINTS, not a " + .. "property of a def; ask world.landmarks.spawns", + walkable = "Gold has no per-map tile set to extend; widen movement " + .. "through the movement.collision hook, which Gold honours for the " + .. "player and for NPC wandering", + renderer = "Gold bakes whole-map images on the World; the equivalent " + .. "operation is mod.world:replaceBlock", + }, +} + +-- ------- src.world.Collision + +local function buildCollision() + local Map2 = rawRequire("src.world.gen2.Map") + local Permissions = rawRequire("src.world.gen2.Permissions") + local Collision = { DELTA = Map2.DELTA } + + function Collision.target(cx, cy, dir) + local d = Map2.DELTA[dir] + -- Gen 1 errors here (it indexes a nil DELTA row); softened deliberately, + -- so a loop on a bad dir spins rather than crashing. + if not d then return cx, cy end + return cx + d[1], cy + d[2] + end + + -- src/world/Collision.lua:20's contract, `passable` included + function Collision.occupied(entities, cx, cy, ignore) + for _, e in ipairs(entities or {}) do + if e ~= ignore and not e.passable then + if (e.cellX == cx and e.cellY == cy) + or (e.targetX == cx and e.targetY == cy) then + return e + end + end + end + return nil + end + + -- Gold has no tile-pair table and no data.field to read one out of; the + -- capability's Gen 2 home is Permissions, which is not data-driven. A + -- silent accept would leave the mod's intent unhappened with no trace. + function Collision.load(_data) + warnOnce("collision.load", + "[%s] Collision.load: Gold has no data.field.tilePairs; ledges and side " + .. "walls come from src/world/gen2/Permissions.lua, which is not " + .. "data-driven", who("src.world.Collision")) + return nil + end + + local function passthrough(allowed) return allowed end + + -- The verdict Gold's own player gets one line later, not a walkable test: + -- the surf exception and GetMovementPermissions' side-wall rule are both + -- invisible to map:isWalkableCell, and without them the facade says yes + -- where Gold bumps and no on every water cell a surfer is riding. + local function verdict(map, entities, mover, dir, tx, ty) + if not map:inBounds(tx, ty) then return false, "bounds" end + if not map:isWalkableCell(tx, ty) then + local surfable = mover.surfing + and Permissions.surfable(map:cellCollision(tx, ty)) ~= nil + if not surfable then return false, "tile" end + end + -- src/world/gen2/Map.lua:128, the refusal World:movePlayer applies BEFORE + -- tryMove. Reported as "tile", which is how it arrives at Gold's own + -- verdict; Gen 1 has no name for it. + if map.stepPermitted and not map:stepPermitted(mover.cellX, mover.cellY, dir) + then + return false, "tile" + end + if Collision.occupied(entities, tx, ty, mover) then return false, "entity" end + return true + end + + function Collision.canMove(map, entities, mover, dir) + local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) + local allowed, why = verdict(map, entities, mover, dir, tx, ty) + -- the mod's OWN movement.collision hook has to see its own canMove call, + -- with the ctx keys both generations already use + if Runtime.wantsHook("movement.collision") then + local ctx = { map = map, mover = mover, dir = dir, + fromX = mover.cellX, fromY = mover.cellY, + toX = tx, toY = ty, reason = why } + allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) + why = ctx.reason + end + if allowed then return true end + return false, why + end + + return Collision +end + +COVERAGE["src.world.Collision"] = { + kind = "facade", + backed = "DELTA target occupied canMove", + warned = "load", + absent = "", + notes = { + DELTA = "the LIVE table, as under Gen 1: a mod that adds a key mutates " + .. "Gold's own movement table", + target = "returns (cx, cy) unchanged for an unknown dir where Gen 1 errors", + canMove = "adds Gold's side-wall refusal (Map:stepPermitted) as reason " + .. "'tile'; Gen 1's tile-pair check has no Gen 2 counterpart and drops", + occupied = "tests targetX/Y unconditionally, as Gen 1 does; Gold's own " + .. "loops test them only while moving", + }, +} + +-- ------- src.world.FieldDefaults +-- +-- Pure Lua with no love.* and no world. Three members hold values that are +-- TRUE on Gold and are answered; the FIELD half is Kanto content and stays +-- refused, because a table with one real key and twelve Kanto keys is worse +-- than no table. + +-- Every one of these is the same number on both generations, verified against +-- the Gen 2 file named beside it. The four Gen 1 world constants NOT here +-- (poisonStepInterval, poisonDamage, blackoutMoneyDivisor, daycareExpPerStep) +-- are absent on purpose: Gold's step events do not read a shared constant +-- table and the Gen 1 numbers would be a guess. +local WORLD_CONSTANTS = { + stepFrames = 16, -- src/world/gen2/Npc.lua's STEP_FRAMES + turnFrames = 4, -- src/world/gen2/Player.lua's TURN_FRAMES + neighborHops = 2, -- the hop count World.computeNeighbors is called with +} + +local function buildFieldDefaults() + local FieldDefaults = {} + + local constants = { world = WORLD_CONSTANTS } + -- bikeStepFrames is derived, not copied: Gold's bike length comes out of + -- src/world/gen2/Bike.lua rather than a table. + local okBike, Bike = pcall(rawRequire, "src.world.gen2.Bike") + if okBike and Bike and Bike.stepFramesFor then + WORLD_CONSTANTS.bikeStepFrames = Bike.stepFramesFor(WORLD_CONSTANTS.stepFrames) + end + -- encounterBuckets and hmBadges are Kanto: absent, so a mod's + -- `for _, b in ipairs(CONSTANTS.encounterBuckets)` errors instead of + -- rolling Kanto odds on Gold. + FieldDefaults.CONSTANTS = constants + + function FieldDefaults.field(_data, key) + warnOnce("fieldDefaults.field." .. tostring(key), + "[%s] src.world.FieldDefaults.field(%s): Gold has no data.field", + who("src.world.FieldDefaults"), tostring(key)) + return nil + end + + -- Gen 1's is VARIADIC (src/world/FieldDefaults.lua:240); a fixed arity drops + -- every deeper path. Exactly one path resolves on Gold. + function FieldDefaults.fieldValue(_data, key, ...) + local first = ... + if key == "playerSprites" then + if first == "walk" then + return rawRequire("src.world.gen2.World").PLAYER_SPRITE + end + -- Gen 1 returns the whole TABLE here (walk/surf/bike/fly), so answering + -- the walk string would make a mod's `.surf` a string-index error. + if first == nil then + warnOnce("fieldDefaults.fieldValue.playerSprites", + "[%s] FieldDefaults.fieldValue(data, 'playerSprites') returns the " + .. "whole Gen 1 table; Gold can answer only the 'walk' leaf", + who("src.world.FieldDefaults")) + return nil + end + end + local path = tostring(key) + for i = 1, select("#", ...) do path = path .. "." .. tostring((select(i, ...))) end + warnOnce("fieldDefaults.fieldValue." .. path, + "[%s] src.world.FieldDefaults: Gold has no data.field %s", + who("src.world.FieldDefaults"), path) + return nil + end + + -- data.constants is the cache's own; the three Gen 1 default tables are + -- Kanto (encounterBuckets is the Gen 1 wild-slot spread, hmBadges names + -- Kanto badges) and must never be the fallback. + function FieldDefaults.constant(data, key) + local value = data and data.constants and data.constants[key] + if value ~= nil then return value end + if key == "world" then return constants.world end + warnOnce("fieldDefaults.constant." .. tostring(key), + "[%s] FieldDefaults.constant(%s): Gold's cache does not carry it and " + .. "the Gen 1 default is Kanto", who("src.world.FieldDefaults"), + tostring(key)) + return nil + end + + function FieldDefaults.world(data, key) + local scoped = data and data.constants and data.constants.world + local value = scoped and scoped[key] + if value ~= nil then return value end + value = WORLD_CONSTANTS[key] + if value ~= nil then return value end + warnOnce("fieldDefaults.world." .. tostring(key), + "[%s] FieldDefaults.world(%s): Gold's step events do not read a shared " + .. "constant table, so the Gen 1 number would be a guess", + who("src.world.FieldDefaults"), tostring(key)) + return nil + end + + -- Never let it write: seeding Kanto's field record into a Gold dataset + -- would put PALLET_TOWN palettes and SILPH_SCOPE rules where mod patches + -- and engine reads would find them. + FieldDefaults.seed = unbacked("src.world.FieldDefaults", "seed", + "seeding Kanto's FIELD into a Gold dataset would put PALLET_TOWN " + .. "palettes and SILPH_SCOPE rules into data.field") + + return FieldDefaults +end + +COVERAGE["src.world.FieldDefaults"] = { + kind = "facade", + backed = "CONSTANTS CONSTANTS.world constant world fieldValue", + warned = "field seed", + absent = "FIELD CONSTANTS.encounterBuckets CONSTANTS.hmBadges", + notes = { + ["CONSTANTS.world"] = "stepFrames, turnFrames, neighborHops and a derived " + .. "bikeStepFrames only; the four poison / blackout / daycare keys are " + .. "warned because Gold's StepEvents do not read a shared table", + fieldValue = "serves data.field playerSprites.walk and nothing else", + FIELD = "every leaf is Kanto (map ids, tileset names, SGB palettes); a " + .. "nil-index error is the honest outcome", + }, +} + +-- ------- src.pokemon.Boxes + +local function buildBoxes() + local Boxes2 = rawRequire("src.core.gen2.Boxes") + -- the pass-through is purely additive (name/rename/withdraw/release/move/ + -- canUsePc...); only `deposit` collides, and the override below takes it back + local adapter = setmetatable({}, { __index = Boxes2 }) + + adapter.COUNT = Boxes2.NUM_BOXES + adapter.CAPACITY = Boxes2.MONS_PER_BOX + + -- Gen 1's ensure MATERIALISES save.boxes because its callers then index it; + -- src/core/gen2/Boxes.lua:48 creates one box on demand instead. The clamp + -- is src/pokemon/Boxes.lua:22 verbatim and is load bearing: Boxes2.box + -- hands back a fresh DETACHED table for an index outside 1..NUM_BOXES, so + -- an unclamped currentBox loses whatever is deposited into it. + function adapter.ensure(save) + if not save then return {} end + save.boxes = save.boxes or {} + for i = 1, Boxes2.NUM_BOXES do + save.boxes[i] = save.boxes[i] or {} + end + save.currentBox = + math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1)) + return save.boxes + end + + function adapter.active(save) + local boxes = adapter.ensure(save) + return boxes[save and save.currentBox or 1] + end + + -- src/pokemon/Boxes.lua:33-43 with COUNT/CAPACITY swapped for Gold's. + -- NOT the inherited Gen 2 deposit(save, partyIndex, boxIndex): a Gen 1 call + -- would index save.party with a MON TABLE, get nil, and return false plus + -- "There is no POKeMON there." -- which reads to the caller exactly like + -- "every box is full" while the mon is silently dropped. + function adapter.deposit(save, mon) + local boxes = adapter.ensure(save) + for off = 0, Boxes2.NUM_BOXES - 1 do + local i = ((save.currentBox - 1 + off) % Boxes2.NUM_BOXES) + 1 + if #boxes[i] < Boxes2.MONS_PER_BOX then + table.insert(boxes[i], mon) + return i + end + end + return nil + end + + return adapter +end + +COVERAGE["src.pokemon.Boxes"] = { + kind = "facade", target = "src.core.gen2.Boxes", + backed = "COUNT CAPACITY ensure active deposit defaultName name rename box " + .. "count isFull setCurrent healthyCount canDeposit canWithdraw withdraw " + .. "release move canUsePc NUM_BOXES MONS_PER_BOX PARTY_SIZE", + warned = "", + absent = "", + notes = { + COUNT = "14 on Gold, not 12; data.constants.boxCount reads nil, so a UI " + .. "mod sizing a grid off constants and one sizing it off COUNT " + .. "disagree", + ensure = "the save.box migration arm is dropped; a Gold save is born with " + .. "boxes and never had one", + }, +} + +-- ------- src.world.WorldAPI +-- +-- ALIAS, and it must stay one: src/mods/Loader.lua:977 builds every mod's +-- mod.world from src.world.gen2.WorldAPI, so a patch applied to the aliased +-- table is a patch applied to every live mod.world. A facade copy would +-- produce two WorldAPIs: one the mod patches, one the engine handed it. + +COVERAGE["src.world.WorldAPI"] = { + kind = "alias", target = "src.world.gen2.WorldAPI", + backed = "new __index overworld current warpTo toggleObject replaceBlock " + .. "spawnNpc removeNpc npc queueScript invalidateMap", + warned = "setFlag getFlag", + absent = "", + notes = { + overworld = "Gold's World is not a stack state, so nothing can hide it; " + .. "the returned object is a World, not an OverworldState", + warpTo = "opts is accepted for parity and nothing in it is read: no " + .. "arrive FX, no onDone, no keepMusic -- a mod chaining off onDone " + .. "stops with no error", + toggleObject = "visibility IS the MAPOBJECT_EVENT_FLAG, so an off-map " + .. "toggle is refused where Gen 1 accepts it", + setFlag = "Gen 2 event flags are NUMERIC indices into wEventFlags; a " + .. "string is refused by name rather than written where nothing reads it", + npc = "looks up def.index or def.name only, NOT npc.id; the handle's " + .. "marchInPlace is refused outright and scriptMove refuses a second " + .. "concurrent movement (World.moveState is one slot)", + queueScript = "validates the WHOLE row list against a five-verb allow " + .. "list and refuses by name before the first row runs", + }, +} + +-- ------- src.world.OverworldController +-- +-- FACADE (dispatch table), never an alias. Gen 1's module IS the live state +-- singleton, so patching a method and calling one are the same table; Gold's +-- overworld is a World INSTANCE with a separate class table. So this is a +-- table Gold DISPATCHES THROUGH at named seams (World:step calls worldTick, +-- World:interact asks interactWrapper, World:interactBody asks talkToWrapper) +-- plus, where a member is a plain query, a function that resolves the live +-- world and forwards. + +local overworld + +local function defaultUpdate() end + +local function defaultInteract(world) + return world:interactBody() +end + +local function defaultTalkTo() + return false +end + +local OW = "src.world.OverworldController" + +-- the compass keys Map:connection is indexed by, from the up/down/left/right +-- vocabulary everything else uses. Getting this wrong lands the player on +-- the wrong edge with no error. +local COMPASS = { up = "north", down = "south", left = "west", right = "east" } + +-- The one place a Gen 1 ENTITY becomes a Gen 2 objectId: 0 is the player, 1 is +-- wLastTalked and an extracted object is its index + 1 +-- (src/world/gen2/World.lua:3559). The player has no .def, so the old +-- `(def.index or 0) + 1` walked the last-talked NPC instead; nil means "not +-- addressable", never a fallback. +local function objectIdOf(world, entity) + if entity == nil then return nil end + if world and entity == world.player then return 0 end + local index = entity.def and entity.def.index + if type(index) == "number" then return index + 1 end + return nil +end + +local function buildOverworld() + local Map2 = rawRequire("src.world.gen2.Map") + local Permissions = rawRequire("src.world.gen2.Permissions") + local World2 = rawRequire("src.world.gen2.World") + local Movement = rawRequire("src.script.gen2.Movement") + local HiddenItems = rawRequire("src.world.gen2.HiddenItems") + local Bike = rawRequire("src.world.gen2.Bike") + + local api = nil + -- one WorldAPI instance, so queueScript reuses the five-verb allow list + -- rather than growing a second one here + local function worldApi() + if api then return api end + local g = live() + if not g then return nil end + api = rawRequire("src.world.gen2.WorldAPI").new(g, OW) + return api + end + + local function w(member) return liveWorld(OW, member) end + + local ow = { + update = defaultUpdate, + interact = defaultInteract, + talkTo = defaultTalkTo, + } + + -- ---- entity lookups + + function ow.npcAtCell(cx, cy) + local world = w("npcAtCell") + return world and world:npcAt(cx, cy) or nil + end + + function ow.pushableAtCell(cx, cy) + local world = w("pushableAtCell") + if not world then return nil end + for _, npc in ipairs(world.npcs or {}) do + if World2.isStrengthBoulder(npc) and npc:covers(cx, cy) then return npc end + end + return nil + end + + -- Gen 2 objectIds are the extracted index PLUS ONE (0 is the player, 1 is + -- wLastTalked), so passing the Gen 1 index straight through returns the + -- object before the one asked for and nothing errors. Extracted indices are + -- 1-based on BOTH generations (src/world/gen2/World.lua:6924), so anything + -- below 1 names no object at all: World:objectEntity would answer talkNpc. + function ow.npcByIndex(index) + local world = w("npcByIndex") + if not world then return nil end + if type(index) ~= "number" or index < 1 then + warnOnce("ow.npcByIndex.range", + "[%s] OverworldState.npcByIndex(%s): object indices start at 1 on " + .. "both generations; the player is objectId 0 on Gold and is " + .. "world.player, not an entry in this list", who(OW), tostring(index)) + return nil + end + return world:objectEntity(index + 1) + end + + -- pool and data are the World's own; a mod keeping a private pool gets + -- Gold's objects instead, which is worth one line. + function ow.pooledNPC(pool, _data, mapId, obj) + local world = w("pooledNPC") + if not world then return nil end + if pool ~= nil and pool ~= world.npcPool then + warnOnce("ow.pooledNPC.pool", + "[%s] OverworldState.pooledNPC ignores the pool argument: Gold pools " + .. "on the World (World:pooledNpc)", who(OW)) + end + return world:pooledNpc(mapId, obj) + end + + ow.computeNeighbors = World2.computeNeighbors + + -- ---- map lifecycle + + function ow.setMap(mapId, x, y, facing, opts) + local world = w("setMap") + if not world then return nil end + if opts and opts.keepPikachu ~= nil and opts.keepFollower == nil then + opts = { via = opts.via, seamless = opts.seamless, + keepMusic = opts.keepMusic, keepFollower = opts.keepPikachu } + end + return world:setMap(mapId, x, y, facing, opts) + end + + function ow.rebuildNeighbors() + local world = w("rebuildNeighbors") + return world and world:rebuildNeighbors() or nil + end + + function ow.reloadMap(mapId, reason) + local world = w("reloadMap") + if not world then return nil end + if mapId ~= nil and world.map and world.map.id ~= mapId then return true end + return world:reloadMapBadWarp(reason) + end + + function ow.takeWarp(warpDef) + local world = w("takeWarp") + return world and world:takeWarp(warpDef) or nil + end + + function ow.startWarpTo(mapId, x, y, facing, onDone, _opts) + local world = w("startWarpTo") + if not world then return nil end + if onDone then + warnOnce("ow.startWarpTo.onDone", + "[%s] OverworldState.startWarpTo drops onDone on Gold " + .. "(World:warpToMapId has no completion callback); a mod chaining " + .. "off it stalls", who(OW)) + end + return world:warpToMapId(mapId, x, y, + facing or (world.player and world.player.facing)) + end + + function ow.canCollisionWarp() + local world = w("canCollisionWarp") + return world and not world:warpsSuppressed() or false + end + + function ow.refreshStandingOnWarp() + local world = w("refreshStandingOnWarp") + return world and world:armWarpCheck() or nil + end + + function ow.healPoint() + local world = w("healPoint") + return world and world:healPoint() or nil + end + + function ow.warpToHealPoint(onDone) + local world = w("warpToHealPoint") + if not world then return nil end + if onDone then + warnOnce("ow.warpToHealPoint.onDone", + "[%s] OverworldState.warpToHealPoint drops onDone and opts.arrive on " + .. "Gold (World:warpToSpawn takes neither)", who(OW)) + end + return world:warpToSpawn() + end + + -- ---- movement + + function ow.dirHeld() + local world = w("dirHeld") + return world and world.heldDir or nil + end + + function ow.handleInput() + local world = w("handleInput") + local g = live() + return world and world:pollInput(g and g.input) or nil + end + + function ow.stepForwardOrCrossEdge(dir) + local world = w("stepForwardOrCrossEdge") + return world and world:movePlayer(dir) or nil + end + + function ow.checkLedgeHop(dir) + local world = w("checkLedgeHop") + return world and world:tryLedgeJump(dir) or false + end + + function ow.checkEdgeExit(dir) + local world = w("checkEdgeExit") + return world and world:tryConnection(dir) or false + end + + -- Gold has no crossConnection/checkEdgeExit split: tryConnection does both. + ow.crossConnection = function(dir) return ow.checkEdgeExit(dir) end + + -- Gen 1's five-value shape, in order: the destination map DEF, its tileset + -- def, x, y, conn (src/world/OverworldController.lua:1404). Returning the + -- map ID first put a STRING where callers index .width, and dropped the + -- tileset the very next Map.defPassable call needs. + function ow.connectionLanding(dir) + local world = w("connectionLanding") + if not (world and world.map and world.player) then return nil end + local conn = world.map:connection(COMPASS[dir] or dir) + if not conn then return nil end + local destId = conn.map or conn.mapId + local destDef = destId and world.maps and world.maps[destId] + if not destDef then return nil end + local tileset = world.tilesets and world.tilesets[destDef.tileset] + if not tileset then return nil end + local x, y = Map2.connectionLanding(destDef, conn, dir, + world.player.cellX, world.player.cellY) + return destDef, tileset, x, y, conn + end + + function ow.checkBoulderPush(dir) + local world = w("checkBoulderPush") + if not (world and world.player) then return false end + local d = Map2.DELTA[dir] + if not d then return false end + local p = world.player + return world:tryPushBoulder(dir, p.cellX + d[1], p.cellY + d[2]) + end + + function ow.checkForcedMovement() + local world = w("checkForcedMovement") + return world and world:checkCarpetWhileStanding() or nil + end + + -- Gold has ONE movement slot; a second concurrent call is refused with a + -- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe). + function ow.scriptMove(entity, dir, tiles, onDone) + local world = w("scriptMove") + if not world then return nil, "no overworld" end + if world.moveState then return nil, "a movement is already running" end + local step = Movement.stepByte(dir) + if not step then return nil, "unknown direction: " .. tostring(dir) end + local bytes = {} + for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end + bytes[#bytes + 1] = Movement.STEP_END + local objectId = objectIdOf(world, entity) + if not objectId then + return nil, "no Gen 2 objectId for that entity: only the player and a " + .. "mapped object (def.index) can be moved" + end + world:beginMovement(objectId, bytes, onDone) + return true + end + + function ow.updateScriptMoves() + local world = w("updateScriptMoves") + return world and world:updateMovement() or nil + end + + -- ---- world state + + function ow.timeOfDay() + local world = w("timeOfDay") + if not world then return nil end + -- Gen 1 returns the CACHED tod, Gold recomputes from the clock every + -- call: a mod that set a custom tod reads the clock's answer back unless + -- its world.tod hook is still installed. + return world:timeOfDay(world:hour()) + end + + -- Gold's darkness is a map PROPERTY the palette set reads; the only thing a + -- mod can move is flashUsed, and only on a DARKNESS palset map. + function ow.setDark(on) + local world = w("setDark") + if not world then return nil end + warnOnce("ow.setDark", + "[%s] OverworldState.setDark only has an effect on maps whose palset is " + .. "DARKNESS; elsewhere it is a no-op (Gold's dimming is a palette set, " + .. "not a state boolean)", who(OW)) + world.flashUsed = not on + return world:applyPalettes() + end + + function ow.bikeAllowed(mapId) + local world = w("bikeAllowed") + if not world then return false end + local def = world.maps and world.maps[mapId] + if not def then return false end + return Bike.environmentAllows(def.environment) + end + + function ow.replaceBlock(bx, by, block) + local world = w("replaceBlock") + if not world then return nil end + -- NEVER World:replaceBlock, which exists on Gold under the same name and + -- takes a FLAT INDEX plus a block id: forwarding there edits an unrelated + -- block and drops the third argument, without erroring. + local ok = world:changeBlock(bx, by, block) + Runtime.emit("world.block_replaced", + { mapId = world.map and world.map.id, bx = bx, by = by, block = block }) + return ok + end + + ow.addRuntimeObject = function(mapId, objDef, owner) + local world = w("addRuntimeObject") + return world and world:addRuntimeObject(mapId, objDef, owner) or nil + end + + ow.removeRuntimeObject = function(npcId, owner) + local world = w("removeRuntimeObject") + return world and world:removeRuntimeObject(npcId, owner) or nil + end + + -- ---- interaction and field moves + + function ow.tryHiddenObject(fx, fy) + local world = w("tryHiddenObject") + if not (world and world.map) then return nil end + return HiddenItems.at(world.map.def, fx, fy, world.events) + end + + function ow.hasHiddenItemLeft() + local world = w("hasHiddenItemLeft") + if not (world and world.map) then return false end + return #HiddenItems.unfound(world.map.def, world.events) > 0 + end + + function ow.facingIsShoreOrWater() + local world = w("facingIsShoreOrWater") + if not (world and world.map and world.player) then return false end + local p = world.player + local d = Map2.DELTA[p.facing] + if not d then return false end + return Permissions.surfable( + world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) ~= nil + end + + function ow.facingIsLandDismount() + local world = w("facingIsLandDismount") + if not (world and world.map and world.player) then return false end + local p = world.player + if not p.surfing then return false end + local d = Map2.DELTA[p.facing] + if not d then return false end + return Permissions.isLand( + world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) + end + + function ow.trySurf(_fx, _fy, onClose) + local world = w("trySurf") + if not world then return nil end + if onClose then + warnOnce("ow.trySurf.onClose", + "[%s] OverworldState.trySurf drops onClose on Gold: World:trySurfOW " + .. "takes the faced cell from the player and has no callback", + who(OW)) + end + return world:trySurfOW() + end + + function ow.tryCut() + local world = w("tryCut") + return world and world:tryCutOW() or nil + end + + function ow.partyKnows(moveId) + local world = w("partyKnows") + -- Gold returns the MON; forwarding it raw hands back a table where the mod + -- expects true/false, which works in a conditional and breaks on `== true` + return world ~= nil and world:partyMoveUser(moveId) ~= nil + end + + function ow.goFishing(rod) + local world = w("goFishing") + if not world then return nil end + if type(rod) == "string" then + warnOnce("ow.goFishing.rod", + "[%s] OverworldState.goFishing takes a Gen 2 ITEM ID on Gold, not a " + .. "Gen 1 rod name like %s", who(OW), rod) + return nil + end + return world:useRod(rod) + end + + -- Gen 1 takes a MAP ID, Gold takes a SPAWN ID out of landmarks.spawns. + -- Forwarding the map id returns false and the player simply does not fly. + function ow.flyTo(mapId) + local world = w("flyTo") + if not world then return nil end + for key, spawn in pairs(world.landmarks and world.landmarks.spawns or {}) do + if spawn.map == mapId then return world:flyTo(key) end + end + warnOnce("ow.flyTo." .. tostring(mapId), + "[%s] OverworldState.flyTo(%s): no landmark spawn lands on that map, so " + .. "Gold has no fly destination for it", who(OW), tostring(mapId)) + return nil + end + + function ow.openPC(onDone) + local world = w("openPC") + return world and world:openPc({ onDone = onDone }) or nil + end + + function ow.nurseHeal(onDone) + local world = w("nurseHeal") + if not world then return nil end + return world:startHealMachineAnim(nil, function() + world:healParty() + if onDone then onDone() end + end) + end + + function ow.trainerDefeated(npc) + local world = w("trainerDefeated") + if not world then return false end + local def = npc and npc.def + return world:trainerBeaten(def and def.trainer or def) and true or false + end + + function ow.engageTrainer(npc, onDone, endBattleText, skipBattleText) + local world = w("engageTrainer") + if not world then return nil end + if endBattleText ~= nil or skipBattleText ~= nil then + warnOnce("ow.engageTrainer.text", + "[%s] OverworldState.engageTrainer drops endBattleText / " + .. "skipBattleText on Gold: the trainer flow is an extracted SCRIPT " + .. "and there is nowhere to put them", who(OW)) + end + local script = npc and npc.def and npc.def.scriptKey + local ok = world:startTrainerScript(npc, script, false) + if onDone then onDone(ok) end + return ok + end + + function ow.checkTrainerSight() + local world = w("checkTrainerSight") + return world and world:checkTrainerBattle() or false + end + + function ow.startTrainerApproach(npc, _dist) + local world = w("startTrainerApproach") + if not world then return nil end + if npc then + warnOnce("ow.startTrainerApproach.npc", + "[%s] OverworldState.startTrainerApproach drops its npc and distance " + .. "on Gold: World:trainerApproach takes them from the World's own " + .. "sight scan, so a specific object cannot be approached", who(OW)) + end + return world:trainerApproach() + end + + -- Forwarding a TEXT_* constant to World:showText prints the constant NAME + -- on screen, which looks like a mod bug forever. + function ow.showMapText(key, _npc, onDone) + local world = w("showMapText") + if not world then return nil end + local body = world.text and world.text[key] + if body == nil then + warnOnce("ow.showMapText." .. tostring(key), + "[%s] OverworldState.showMapText(%s): Gold's text ids are ROM pointer " + .. "strings, so a TEXT_* constant resolves to nothing; printing " + .. "nothing rather than the key", who(OW), tostring(key)) + return nil + end + return world:showText(body, onDone) + end + + function ow.applyFieldPoison() + local world = w("applyFieldPoison") + if not world then return nil end + local StepEvents = rawRequire("src.world.gen2.StepEvents") + local g = live() + return StepEvents.poisonStep(g and g.save and g.save.party) + end + + function ow.queueScript(rows, extra) + local a = worldApi() + if not a then return nil, "no overworld" end + return a:queueScript(rows, extra) + end + + -- Gen 1's module IS the state, so a mod calls these with a COLON and the + -- module arrives as self. Strip that leading argument -- and a World, which + -- is what a mod holding game.overworld would pass -- so one source calls + -- either way. The three seams below are called BY Gold with the world as + -- their first argument and must keep it. + local SEAMS = { update = true, interact = true, talkTo = true, + computeNeighbors = true } + for key, fn in pairs(ow) do + if type(fn) == "function" and not SEAMS[key] then + ow[key] = function(first, ...) + if first == overworld + or (type(first) == "table" and first.stepBody ~= nil) then + return fn(...) + end + return fn(first, ...) + end + end + end + + -- Gen 1's module IS the live state (src/core/Game.lua:87 assigns the module + -- itself), so `OverworldController.player` off the required module is the + -- ordinary idiom. These seven names are the World's own fields under the + -- same spelling and shape, and they read AND write through to it. + local LIVE_FIELD = { map = true, player = true, npcs = true, entities = true, + ghosts = true, npcPool = true, camera = true } + + setmetatable(ow, { + __index = function(_, key) + if LIVE_FIELD[key] then + local world = w(key) + return world and world[key] or nil + end + -- Gen 1's entries are { map = mapDef, ox, oy } and Gold's are + -- { id, ox, oy, image }: nb.map is nil there, so a scan silently matches + -- nothing (src/world/OverworldController.lua:529 vs World.lua:8275). + if key == "neighbors" then + warnOnce("ow.neighbors", + "[%s] OverworldState.neighbors has no Gen 2 backing with the Gen 1 " + .. "shape: Gold's entries carry `id`, not `map`. world.neighbors " + .. "plus world.maps[nb.id] is the honest route", who(OW)) + return nil + end + if key == "isOverworld" then + warnOnce("ow.isOverworld", + "[%s] OverworldController.isOverworld exists on this facade so a " + .. "module compare works, but Gold's world is NOT on the state " + .. "stack: a scan of game.stack.states finds nothing. Use " + .. "mod.world:overworld()", who(OW)) + return true + end + return nil + end, + -- a write to one of the live names has to move the world, the way a write + -- to the Gen 1 module did; everything else is a seam the mod installs + __newindex = function(t, key, value) + if LIVE_FIELD[key] then + -- never rawset one of these: the key would shadow the world for the + -- rest of the run and every later read would answer the stale value + local world = w(key) + if world then world[key] = value end + return + end + rawset(t, key, value) + end, + }) + + overworld = ow + return ow +end + +-- called once per logic frame from World:step's tail +function Gen2Compat.worldTick(world, dt) + if not overworld then return end + if overworld.update == defaultUpdate then return end + overworld.update(world, dt) +end + +-- nil while nothing replaced `interact`: one comparison on a mod-free press +function Gen2Compat.interactWrapper() + if not overworld or overworld.interact == defaultInteract then return nil end + return overworld.interact +end + +-- the same shape for the talk dispatch World:interactBody seams in +function Gen2Compat.talkToWrapper() + if not overworld or overworld.talkTo == defaultTalkTo then return nil end + return overworld.talkTo +end + +COVERAGE[OW] = { + kind = "facade", target = "src.world.gen2.World", + backed = "update interact talkTo npcAtCell pushableAtCell npcByIndex " + .. "pooledNPC computeNeighbors setMap rebuildNeighbors reloadMap takeWarp " + .. "startWarpTo canCollisionWarp refreshStandingOnWarp healPoint " + .. "warpToHealPoint dirHeld handleInput stepForwardOrCrossEdge " + .. "checkLedgeHop checkEdgeExit crossConnection connectionLanding " + .. "checkBoulderPush checkForcedMovement scriptMove updateScriptMoves " + .. "timeOfDay bikeAllowed replaceBlock addRuntimeObject " + .. "removeRuntimeObject tryHiddenObject hasHiddenItemLeft " + .. "facingIsShoreOrWater facingIsLandDismount tryCut partyKnows " + .. "goFishing flyTo openPC nurseHeal trainerDefeated engageTrainer " + .. "checkTrainerSight startTrainerApproach showMapText applyFieldPoison " + .. "queueScript map player npcs entities ghosts npcPool camera", + warned = "isOverworld setDark trySurf neighbors npcByIndex", + absent = "isOpaque objectVisible enter paletteNameFor sgbPalettes " + .. "sgbWorldZones isDungeonTransitionMap pushBattle drainPendingScripts " + .. "startParallel killParallel updateParallel startDustAnim " + .. "startCutTreeAnim useSurfFieldMove useCutFieldMove beginTeleportOut " + .. "syncSurfingPikachu tryBookshelf benchGuyText tryCardKeyDoor " + .. "trashCanSwitch stampClosedDoors billsHousePC billsHousePokemonList " + .. "billsHouseBillExits tilesetHasWater surfBlockedHere " + .. "checkSeafoamCurrent seafoamHolesFor boulderIntoHole openOaksPC " + .. "dexRating cableClubReceptionist finishNurseHeal stepHealAnim " + .. "checkVictoryRewards offerGymTm runVictoryHook onStepComplete " + .. "rollEncounter checkSpinner runSpinnerMoves rewrittenLastMap " + .. "syncLastMapRewrite rememberOutdoor checkBadgeGate inSafariStepZone " + .. "safariStep safariGameOver afterBattle draw drawWorld drawUI billboard " + .. "captureSave marchInPlace runner scriptMoves marchers parallelRunners " + .. "parallelQueue pendingScripts npcMoveLocks engaging lastOutdoor dark " + .. "tod", + notes = { + update = "called as update(world, dt) with dt fixed at 1/60, AFTER " + .. "World:stepBody -- a Gen 1 wrapper that expects to pre-empt the body " + .. "is too late; `self` is a World, not an OverworldState", + interact = "the default is world:interactBody(), so a wrapper receives " + .. "the World as self and can call through", + talkTo = "seamed into World:interactBody once the object is resolved; a " + .. "true return suppresses the built-in path", + npcAtCell = "Gold does NOT match targetX/targetY, so an NPC walking INTO " + .. "the cell is missed; it DOES match all four cells of a BIG_OBJECT", + player = "the LIVE World's field, read and written through: it is a " + .. "src/world/gen2/Player.lua, so cellX / cellY / facing / surfing " + .. "carry and the Gen 1 Player's own methods do not", + map = "the live World's map, a src/world/gen2/Map.lua instance -- which " + .. "IS what require('src.world.Map') hands the mod back here", + npcs = "the live lists; a ghost NPC on a neighbouring map is in `ghosts`, " + .. "not here, on both generations", + neighbors = "WARNED: Gold's entries are { id, ox, oy, image } where Gen " + .. "1's are { map = mapDef, ox, oy }, so the field answers nil rather " + .. "than a list whose nb.map is nil on every row", + npcByIndex = "1-based extracted indices only; 0 and below answer nil and " + .. "warn, because Gold's objectId 0 is the PLAYER (world.player) and " + .. "objectId 1 is wLastTalked, not object zero", + scriptMove = "the player maps to objectId 0 and a mapped object to " + .. "def.index + 1; an entity with neither (a mod's own guest) is " + .. "REFUSED with a reason rather than moving the last-talked NPC", + connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, " + .. "conn); `conn` is Gold's connection record, keyed map/mapId + offset", + timeOfDay = "recomputed from the clock every call and never cached, so a " + .. "custom tod does not read back; world.daytime is the field", + setDark = "only a DARKNESS palset map is affected", + rollEncounter = "absent: Gold's takes (kind, terrain, tables, vanilla) " + .. "and the Gen 1 kind vocabulary cannot be derived from an encDef", + runner = "absent from the facade; world.runner is the shim on the World " + .. "itself and answers isRunning()", + tod = "world.daytime is the Gen 2 spelling", + onStepComplete = "no seam; the world.stepped event is the supported route", + afterBattle = "no seam; the battle.ended event is the supported route", + draw = "no seam; the render_pipelines registry is the supported route on " + .. "both generations", + }, +} + +-- ------- src.world.PikachuFollower +-- +-- ALIAS, required: followers-ex reaches the module's file-local shouldSpawn +-- UPVALUE with debug.setupvalue and monkey-patches update / onMapEntered / +-- starterInParty on the module table. A facade copy would have different +-- upvalues and the patched function would be one nothing calls. + +COVERAGE["src.world.PikachuFollower"] = { + kind = "alias", target = "src.world.gen2.Follower", + backed = "setShouldSpawn current onMapEntered update rebase " + .. "talk starterInParty setVisible at SPRITE", + warned = "", + absent = "shouldSpawn onStep bumpHappiness modifyHappiness picLift hopToCounter " + .. "updateHop onBillsHouseEnter onBillWalksAroundPlayer " + .. "onBillEnteredMachine onBillExitedMachine", + notes = { + shouldSpawn = "ABSENT as a MODULE member on both generations: it is a " + .. "file-local, reachable through setShouldSpawn or the upvalue of that " + .. "name. The callback is passed (game, world) where Gen 1 passes " + .. "(game, ow); a predicate reading ow.player / ow.map is unchanged", + onMapEntered = "accepts opts.keepPikachu as well as opts.keepFollower", + talk = "the stub returns false and does NOT call done(); a mod that calls " + .. "it and waits for the callback hangs -- check the return", + ["ow.pikachuTrail"] = "aliased by reference to world.followerTrail, so a " + .. "reset through either name moves the live trail", + bumpHappiness = "Gold has per-mon friendship, not one companion byte; " + .. "mapping it would silently move a real game stat", + }, +} + +-- ------- src.ui.PartyMenu +-- +-- FACADE over the aliased Gen 2 class: __index falls through so a wrap of +-- .update / .draw resolves, __newindex writes THROUGH so that wrap lands on +-- Gold's live class, and only the four members whose contract differs are +-- overridden here. The icon statics and sgbPalettes must stay ABSENT. + +local UI_ABSENT_ICON = "Gold has per-species two-frame icon sheets, not Gen " + .. "1's nine shared icon classes, so there is no class name to key on" + +-- A mod's write WINS on read (rawequal(read, patch) holds, so the patch is +-- visible) and is written THROUGH so Gold's own class runs it too. The +-- overrides below must therefore call the member they CAPTURED at build time, +-- never target[key], or a wrapper that chains to the override re-enters it. +local PATCHED_NIL = {} + +local function passThroughProxy(target, overrides, absent, moduleName) + local patched = {} + return setmetatable({}, { + __index = function(_, key) + local mine = patched[key] + if mine == PATCHED_NIL then return nil end + if mine ~= nil then return mine end + local made = overrides[key] + if made ~= nil then return made end + local why = absent[key] + if why then + warnOnce(moduleName .. "." .. key, + "[%s] %s.%s has no Gen 2 backing: %s", + who(moduleName), moduleName, key, why) + return nil + end + return target[key] + end, + -- a monkey-patch has to land on the class Gold actually pushes + __newindex = function(_, key, value) + patched[key] = (value == nil) and PATCHED_NIL or value + target[key] = value + end, + }) +end + +local function buildPartyMenu() + local Party2 = rawRequire("src.ui.gen2.PartyMenu") + local overrides, absent = {}, {} + -- built first so .new can stamp whatever the MOD last wrote to these names + local proxy = passThroughProxy(Party2, overrides, absent, "src.ui.PartyMenu") + -- captured before any mod write: a wrapper chaining to overrides.new must + -- reach Gold's constructor, not itself + local newOrig = Party2.new + + absent.drawIcon = "PartyMenu.drawIcon is a STATIC under Gen 1 and a METHOD " + .. "on Gold, so the same call would bind self = game and die inside " + .. "iconFor; " .. UI_ABSENT_ICON + absent.frameFor = UI_ABSENT_ICON + absent.mirrorsIcon = UI_ABSENT_ICON .. " and Gold draws the whole 16x16 quad" + absent.iconFrames = UI_ABSENT_ICON .. "; an empty table would accept the " + .. "mod's writes and never draw from them" + absent.sgbPalettes = "Gold is a GBC title: colour is GbcPalette plus the " + .. "map palsets, and there are no SGB zone packets under src/ui/gen2/" + absent.animateTo = "Gold's party screen has no HP-fill animation; a stub " + .. "calling onDone immediately would print the mod's message with no fill " + .. "and leave A live over a menu the caller thinks is busy" + + -- Gen 1 pops ITSELF on B (src/ui/PartyMenu.lua:540) and Gold never pops. + function overrides.new(game, opts) + opts = opts or {} + -- the LIVE Game2, not the src.core.Game proxy the mod is holding: the Gen + -- 2 screen reads game.partyMenuCursor, game.data and game.stack off it + local g = live() or game + local inst + local function popSelf() + if g and g.stack and g.stack:top() == inst then g.stack:pop() end + end + if opts.keepOpen then + warnOnce("party.keepOpen", + "[%s] PartyMenu opts.keepOpen has no Gen 2 backing and is treated as " + .. "false: Gold's party list has no stay-open contract", + who("src.ui.PartyMenu")) + end + if opts.tmhm then + warnOnce("party.tmhm", + "[%s] PartyMenu opts.tmhm has no Gen 2 backing: Gold has no per-row " + .. "ABLE / NOT ABLE column, so prompt = 'teach' alone would make the " + .. "mod read every mon as ineligible", who("src.ui.PartyMenu")) + end + local prompt, submenu, battleSubmenu + if opts.forceSwitch then + prompt = "which" + elseif opts.battle and opts.onSwitch then + prompt, battleSubmenu = "choose", true + elseif opts.pickOnly then + prompt = "useItem" + elseif opts.onSwitch then + -- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself, + -- so the field submenu must not swallow the press + prompt = "choose" + else + prompt, submenu = "choose", true + end + -- Gen 1's saved cursor lives under a different name; copy it over or the + -- mod's stored slot is ignored. + if g and g.partyMenuSavedIndex and not g.partyMenuCursor then + g.partyMenuCursor = g.partyMenuSavedIndex + end + inst = newOrig(g, { + party = opts.party, + prompt = prompt, submenu = submenu, battleSubmenu = battleSubmenu, + -- (mon, menu) versus (index, mon): both the order and the arity differ + onChoose = function(_i, mon) + popSelf() + if opts.onSwitch then opts.onSwitch(mon, inst) end + end, + onCancel = function() + popSelf() + if opts.onCancel then opts.onCancel() end + end, + }) + -- Stamped per INSTANCE, never onto the Gen 2 class: the object's + -- metatable is Gold's PartyMenu, so a facade-only member would be + -- unreachable as inst:close(), and writing it onto the class would change + -- what Gold's own screen answers. + inst.close = proxy.close + inst.bottomMessage = proxy.bottomMessage + return inst + end + + -- src/ui/PartyMenu.lua:306, which Gold has no counterpart for and needs + -- nothing from Gen 2 to write. + function overrides.close(self) + if self.game and self.game.stack and self.game.stack:top() == self then + self.game.stack:pop() + end + end + + -- src/ui/gen2/PartyMenu.lua:734 verbatim + function overrides.bottomMessage(self) + if self.switchFrom then return Party2.PROMPTS.moveTo end + return self.prompt + end + + -- Gold's list starts at tile row 1, so Gen 1's (i-1)*16 sits 8px high on + -- every row. + function overrides.entryY(i) + return 8 + (i - 1) * 16 + end + + return proxy +end + +COVERAGE["src.ui.PartyMenu"] = { + kind = "facade", target = "src.ui.gen2.PartyMenu", + backed = "new close bottomMessage entryY update draw isOpaque __index " + .. "PROMPTS index party submenu switchFrom clock onCancel", + warned = "keepOpen tmhm", + absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo " + .. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch " + .. "pickOnly forceSwitch", + notes = { + new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts." + .. "battle carries only its BOOLEAN sense and self.battle is left nil " + .. "so a `menu.battle:say(...)` fails loudly", + index = "1..#party+1 on Gold because CANCEL is one past the last mon; " + .. "party[self.index] is nil there", + party = "always materialised, so `if not menu.party then` takes the wrong " + .. "branch", + swapFrom = "renamed switchFrom; a WRITE of menu.swapFrom is inert", + subItems = "self.submenu is a TABLE on Gold: subItems is submenu.items " + .. "and subIndex is submenu.index", + blink = "renamed clock, and Gold never wraps it (Gen 1 wraps at 320)", + onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field " + .. "Gen 2 never reads; pass it to .new instead", + bottomMessage = "returns Gold's strings with <PK>/<MN> charmap glyphs, so " + .. "a compare against \"Use on which one?\" will not match", + ["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold " + .. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a " + .. "BattleState", + }, +} + +-- ------- src.ui.BoxMenu +-- +-- ALIAS, but to src.ui.gen2.PcMenu, not src.ui.gen2.BoxMenu. Gen 1's +-- src.ui.BoxMenu is Bill's PC TOP MENU; Gold's BoxMenu is the withdraw / +-- deposit LIST that Gen 1 builds inline with ListMenu. The alias has to be +-- bare, because PcMenu is the table src/ui/Screens.lua caches for +-- "Gen2PcMenu" and a monkey-patch of .new must land there. + +COVERAGE["src.ui.BoxMenu"] = { + kind = "alias", target = "src.ui.gen2.PcMenu", + backed = "new", + warned = "", + absent = "", + notes = { + new = "PcMenu.new(game) with opts omitted yields the five-row folded PC; " + .. "the RETURNED object is a PcMenu, not a src.ui.Menu", + items = "renamed .entries; rows are { id, label } dispatched by id, and a " + .. "row carrying label + onSelect is now answered by PcMenu:choose's " + .. "onSelect arm so an injected row does something", + th = "PcMenu owns the whole screen (0,0,19,17); .th / .tx / .tw / " + .. ".scroll / :clampScroll do not exist and a write to them is inert", + ["hook ui.pc.items"] = "same NAME, DIFFERENT MENU: Gen 1 raises it over " + .. "the WHICH-PC list, Gold over Bill's PC's own rows", + }, +} + +-- ------- src.ui.StartMenu + +local function buildStartMenu() + local Start2 = rawRequire("src.ui.gen2.StartMenu") + local overrides, absent = {}, {} + -- captured before any mod write, so a wrapper of .new cannot re-enter here + local newOrig = Start2.new + + -- Both callbacks MUST be synthesised: Gen 2's :close() only calls + -- self.onClose and :choose() only calls self.onChoose, so without them B + -- does nothing and A on POKeMON does nothing -- a menu that opens and + -- cannot be exited. The wiring is src/core/Game2.lua:390-396. + function overrides.new(game) + local g = live() or game + local save = g and g.save + if save and save.startMenuIndex then + Start2.lastIndex = save.startMenuIndex + end + return newOrig(g, { + save = save, + onClose = function() + local owner = live() + if owner and owner.stack then owner.stack:pop() end + end, + onChoose = function(id) + local owner = live() + if owner and owner.openStartMenuItem then + owner:openStartMenuItem(id) + end + end, + }) + end + + return passThroughProxy(Start2, overrides, absent, "src.ui.StartMenu") +end + +COVERAGE["src.ui.StartMenu"] = { + kind = "facade", target = "src.ui.gen2.StartMenu", + backed = "new items ITEMS lastIndex", + warned = "", + absent = "", + notes = { + new = "synthesises onClose (stack:pop) and onChoose " + .. "(Game2:openStartMenuItem); without them the menu cannot be left", + index = "the cursor is menu.list.index on Gold (a Chrome.List)", + ["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct " + .. "and never copied back: Gold's cursor is a class field, not save data", + tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/" + .. "maxVisible/startCloses/noSound do not exist and writes are inert", + ["hook ui.start_menu.items"] = "full parity; a row carrying { label, " + .. "onSelect } and no value is answered on both generations", + }, +} + +-- ------- src.ui.OptionsMenu + +local function buildOptionsMenu() + local Options2 = rawRequire("src.ui.gen2.OptionsMenu") + local overrides, absent = {}, {} + -- captured before any mod write, so a wrapper of .new cannot re-enter here + local newOrig = Options2.new + + absent.sgbPalettes = "Gold is a GBC title; a mod wrapping this to append a " + .. "zone would get nil from the original and register a zone nothing reads" + + -- Gen 2's :leave_ is the only exit and it calls self.onDone or nothing, so + -- without a synthesised one the screen cannot be left. Body is + -- src/core/Game2.lua:330-341 plus the pop Gen 1 does for itself. + function overrides.new(game, opts) + opts = opts or {} + local g = live() or game + local inst + inst = newOrig(g, { + options = g and g.options, + onDone = function(options) + local owner = live() + if owner then + owner.options = options + if owner.save then owner.save.options = options end + if owner.applyOptions then owner:applyOptions() end + if owner.persistOptions then owner:persistOptions() end + if owner.stack and owner.stack:top() == inst then owner.stack:pop() end + end + if opts.onCancel then opts.onCancel() end + end, + }) + return inst + end + + return passThroughProxy(Options2, overrides, absent, "src.ui.OptionsMenu") +end + +COVERAGE["src.ui.OptionsMenu"] = { + kind = "facade", target = "src.ui.gen2.OptionsMenu", + backed = "new update draw isOpaque rows index scroll options", + warned = "", + absent = "sgbPalettes", + notes = { + new = "synthesises onDone out of opts.onCancel plus the pop Gen 1 does " + .. "for itself", + index = "1..#rows on Gold: there is no CANCEL row, so `index == #rows+1` " + .. "never fires. A row carrying cancel = true is the Gen 2 equivalent", + rows = "row.step's RETURN VALUE is ignored on Gold; the write happens " + .. "once, on close, through onDone", + options = "Game2 sets save.options = self.options, so a Gen 1 row's " + .. "g.save.options write edits the same table the screen edits", + ["hook ui.options.rows"] = "full parity: Gen 2 answers row.step, " + .. "row.activate and row.value first", + ["src.ui.OptionRows"] = "NOT gated by the shim and loads fine under Gold, " + .. "so a mod calling OptionRows.draw paints Red's chrome over Gold's", + }, +} + +-- ------- src.battle.BattleState +-- +-- FACADE, and mostly an absent one. Gen 1's BattleState is engine and screen +-- on one table; Gold splits them (src/battle/gen2/Battle.lua is the model, +-- src/ui/gen2/BattleState.lua the screen). Nine of ~163 members share a name +-- and two of those differ in signature. Everything else is named once, +-- grouped, and left absent rather than answered with a near miss. + +local function buildBattleState() + local Battle2 = rawRequire("src.ui.gen2.BattleState") + local overrides, absent = {}, {} + + local MOVED = "turn resolution, damage, catching and the AI live on " + .. "src/battle/gen2/Battle.lua, over a mon rather than a Gen 1 battler " + .. "wrapper, so no argument shape survives" + + for _, name in ipairs({ + "makeSafari", "makeGhost", "makeUnveiledGhost", "queueScopeReveal", + "sayChoice", "act", "ui", "animNext", "actNext", "sayNext", "sayNextAuto", + "uiNext", "drainNext", "waitNext", "buildScreen", "makeBattler", + "resolveTurn", "executeAction", "performMove", "enemyAction", + "vanillaEnemyAction", "resolveSwitch", "endOfTurn", "residualFor", + "queueResidual", "tickTokens", "applyDamage", "onFaint", "awardExp", + "learnMove", "clearVolatiles", "computeDamage", "accuracyRoll", + "catchAttempt", "runRoll", "runRollVanilla", "moveDef", "effectRecord", + "ballDef", "statusLabel", "aiUsesFor", "battleKind", "sideOf", + "syncSides", "playerHasPP", "lockedAction", "computeMusicKind", + "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", + "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", + "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", + "sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate", + "imageBattleScale", "resolveBattleScale", "backPlacement", + "frontPlacement", "StatBox", "enter", "exit", + }) do + absent[name] = MOVED + end + absent.newWild = "Gold has no factory that returns an UNPUSHED battle: " + .. "World:startBattle constructs a src/battle/gen2/Battle.lua and pushes " + .. "the screen in one call. A mod rewriting the species belongs on the " + .. "encounter.species hook, which Gold raises with the same name and shape" + absent.newTrainer = absent.newWild + absent.makeSafari = "Gold has no Safari Zone; the Bug-Catching Contest is a " + .. "different rule set (PARK BALL, a held-mon rule, no BAIT or ROCK), and " + .. "answering this would make a mod take its native safari path" + absent.enter = "Gold's battle sets up in .new and tears down in " + .. "finishBattle; StateStack calls enter/exit when callable, so INSTALLING " + .. "one where Gold has none makes the original a nil call" + absent.exit = absent.enter + absent.sgbPalettes = "Gold is a GBC title; there are no SGB zone packets" + + -- say/sayAuto: exact, because the generic text arm honours event.text on + -- any event. Gold's messages always auto-advance, so sayAuto's delay is + -- meaningless there rather than wrong. + function overrides.say(self, text) + return self:push({ kind = "message", text = text }) + end + + function overrides.sayAuto(self, text) + return self:push({ kind = "message", text = text }) + end + + function overrides.openItems(self) return self:openPack() end + function overrides.openReplacementMenu(self) return self:openParty(true) end + function overrides.finish(self) return self:finishBattle() end + + function overrides.askNicknameUI(self, mon, displayName) + if displayName then + warnOnce("battle.askNicknameUI", + "[%s] BattleState:askNicknameUI drops displayName on Gold: the " + .. "nickname prompt names the species itself", + who("src.battle.BattleState")) + end + return self:askNickname(mon) + end + + function overrides.playEntranceCry(self, battler) + return self:playCry(battler and battler.mon or battler) + end + + function overrides.stampOT(save, mon) + return rawRequire("src.battle.gen2.Mon").stampOT(save, mon) + end + + -- Gen 1's tryRun PREDICTS; Gold's model method RESOLVES the escape, which + -- is the difference between asking and doing. + function overrides.tryRun(self) + warnOnce("battle.tryRun", + "[%s] BattleState:tryRun RESOLVES the escape on Gold " + .. "(src/battle/gen2/Battle.lua:3618); Gen 1's queued a message and " + .. "returned a roll", who("src.battle.BattleState")) + return self.battle and self.battle:tryRun() or nil + end + + -- These seven are INSTANCE methods a mod calls as battle:say(...), and the + -- instance's metatable is Gold's own class -- a facade-only member would be + -- unreachable from it. Every one is a name Gold does not use, so the write + -- is purely additive and changes nothing Gold's screen does; a name the + -- class already answers is left exactly as it is. + for _, name in ipairs({ "say", "sayAuto", "openItems", "openReplacementMenu", + "finish", "askNicknameUI", "playEntranceCry", + "tryRun" }) do + if Battle2[name] == nil then Battle2[name] = overrides[name] end + end + + return passThroughProxy(Battle2, overrides, absent, "src.battle.BattleState") +end + +COVERAGE["src.battle.BattleState"] = { + kind = "facade", target = "src.ui.gen2.BattleState", + backed = "update draw __index isOpaque openParty swapMoves " + .. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems " + .. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT " + .. "tryRun wantsFillScale", + warned = "tryRun askNicknameUI", + absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " + .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " + .. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " + .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate " + .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " + .. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset " + .. "rng oppClass partyIndex aiUses introText dead", + notes = { + newWild = "ABSENT: Gold has no factory that returns an unpushed battle, " + .. "and World:startBattle constructs and pushes in one call. A mod " + .. "that wraps newWild to rewrite the species must be pointed at the " + .. "encounter.species hook, which Gold raises with the same name and " + .. "shape (World:rollEncounter)", + makeSafari = "must stay absent: the wilds mod probes for it by name and " + .. "takes its native safari path when it finds one", + openParty = "Gold's takes a `forced` argument Gen 1's does not; a wrap " + .. "must forward ... faithfully rather than normalising it away", + wantsFillScale = "returns TRUE unconditionally on Gold, which reads as " + .. "\"the player chose FILL\" and is not a choice at all", + swapMoves = "no disabled-slot migration and no sfx on Gold", + sides = "self.battle.sides is the same { index, battlers, screens, " + .. "hazards, tokens } shape, with the same index-1-is-player rule", + say = "Gold's messages always auto-advance, so sayAuto's delay is ignored", + }, +} + +-- ------- src.script.ScriptRunner +-- +-- NARROW FACADE, never an alias: ScriptRunner.new(game, overworld) landing on +-- Vm.new(scripts, text, events, hooks) would build a Vm whose `scripts` is +-- the Game and whose `text` is the World, and every later call would fail +-- deep inside the VM naming neither the mod nor the mismatch. +-- +-- Two halves. scanLabels and validate are PURE over the mod's own row list +-- and forward verbatim; the lifecycle half has a different execution model +-- and is a thin handle onto the one world.vm, with the two double-drive +-- members refused. + +local SR = "src.script.ScriptRunner" + +local function buildScriptRunner() + local Gen1 = rawRequire("src.script.ScriptRunner") + local adapter = {} + + -- pure over the caller's table; Gold's VM has no label concept, so a + -- scanned label is meaningful only to the mod's own tooling + adapter.scanLabels = Gen1.scanLabels + + -- The default lookup MUST be replaced: with lookup nil, Gen 1's validate + -- resolves against src/script/Commands.lua, so a script of show_text / wait + -- / warp validates CLEAN on Gold and then every row is skipped at run time. + -- A validator that passes a script the game cannot run is worse than none. + function adapter.validate(script, lookup) + return Gen1.validate(script, lookup or function(verb) + local g = live() + local commands = g and g.data and g.data.commands + return commands ~= nil and commands[verb] ~= nil + end) + end + + local Handle = {} + Handle.__index = function(self, key) + if key == "game" then return live() end + if key == "overworld" then return rawget(self, "world") end + local vm = rawget(self, "vm") + if key == "co" then return vm and vm.co end + if key == "ctx" then return vm and vm.ctx end + if key == "waitingFrames" then return vm and vm.waitLeft end + if key == "parallel" then + warnOnce("runner.parallel", + "[%s] runner.parallel has no Gen 2 backing: Gold has one script " + .. "frame, and src/script/gen2/Vm.lua unpacks the `foreground` and " + .. "`blocking` flags and ignores them", who(SR)) + return nil + end + if key == "waitingCheck" then + warnOnce("runner.waitingCheck", + "[%s] runner.waitingCheck has no Gen 2 backing: the Gen 2 VM parks on " + .. "a TYPED REQUEST the World resumes, with no polling model", who(SR)) + return nil + end + return rawget(Handle, key) + end + + function Handle:isRunning() + return self.vm ~= nil and self.vm:running() or false + end + + function Handle:run(script, extra) + if extra ~= nil then + warnOnce("runner.run.extra", + "[%s] runner:run's `extra` is unserved on Gold: the VM builds its own " + .. "ctx and there is no afterScript / onDone drain", who(SR)) + end + if not self.vm then return false end + return self.vm:start(script) + end + + -- Same name and calling convention as Gen 1's fire-and-forget resume, and + -- Gold's re-raises AND double-drives a VM the World is already driving, so + -- a pending request is dispatched twice. Refused rather than forwarded. + Handle.resume = unbacked(SR, "runner:resume", + "Gold's Vm:resume re-raises and dispatches the pending request, and the " + .. "World already drives it; a second call double-drives the VM") + + Handle.update = unbacked(SR, "runner:update", + "World already calls Vm:update every frame; a second call " + .. "double-decrements waitLeft, so every `pause` in a running script " + .. "finishes early") + + Handle.exec = unbacked(SR, "runner:exec", + "the patch point a mod wants is the script.command hook, which both " + .. "generations raise around the one-row dispatch with (ctx, name, args)") + + Handle.yield = unbacked(SR, "runner:yield", + "a mod verb blocks with ctx.vm:waitFrames(n) or ctx.vm:showText(key)") + + Handle.makeContext = unbacked(SR, "runner:makeContext", + "Vm:scriptCtx builds its own ctx and deliberately carries no game / save " + .. "/ overworld / runner") + + function adapter.new(game, overworldArg) + local world = overworldArg + if type(world) ~= "table" or world.vm == nil then + local g = live() or game + world = g and g.world + end + if not world or not world.vm then + error("src.script.ScriptRunner: Gold has one script frame and it does " + .. "not exist yet; take it after the game.ready event", 0) + end + return setmetatable({ vm = world.vm, world = world }, Handle) + end + + return adapter +end + +COVERAGE[SR] = { + kind = "facade", target = "src.script.gen2.Vm", + backed = "scanLabels validate new isRunning run game overworld co ctx " + .. "waitingFrames", + warned = "resume update exec yield makeContext parallel waitingCheck", + absent = "__index", + notes = { + validate = "the default verb lookup is game.data.commands (MOD verbs " + .. "only on Gold), not src/script/Commands.lua, or a script of Gen 1 " + .. "built-ins would validate clean and then run as nothing", + new = "a thin handle onto the ONE world.vm, never a second runner: a " + .. "mod-built Vm would carry none of World's ~70 hooks, so every " + .. "blocking opcode falls through", + run = "Vm:start returns FALSE when busy where Gen 1 asserts; only rows " + .. "whose verb is a REGISTERED mod command run, and a verb that raises " + .. "is pcall'd and skipped", + scanLabels = "Gold's VM has no label concept at all", + ["src.script.Commands"] = "NOT gated by the shim: a mod requiring it on " + .. "Gold gets the real Gen 1 table of ~100 verbs, none of which Gold " + .. "can run", + events = "script.started / script.ended / script.command already work on " + .. "Gold with the same payload and are the supported route", + }, +} + +-- ------- the table + +-- name -> how to build it. A string means the Gen 2 arm IS the adapter, so a +-- mod's patch lands on the table Gold runs rather than on a copy. +local ADAPTERS = { + ["src.core.Game"] = buildGame, + ["src.world.NPC"] = buildNpc, + ["src.world.Collision"] = buildCollision, + ["src.world.FieldDefaults"] = buildFieldDefaults, + ["src.pokemon.Boxes"] = buildBoxes, + ["src.world.OverworldController"] = buildOverworld, + ["src.ui.PartyMenu"] = buildPartyMenu, + ["src.ui.StartMenu"] = buildStartMenu, + ["src.ui.OptionsMenu"] = buildOptionsMenu, + ["src.battle.BattleState"] = buildBattleState, + ["src.script.ScriptRunner"] = buildScriptRunner, + ["src.world.PikachuFollower"] = "src.world.gen2.Follower", + ["src.world.Map"] = "src.world.gen2.Map", + ["src.world.WorldAPI"] = "src.world.gen2.WorldAPI", + -- Gen 1's BoxMenu is Bill's PC TOP MENU, which is PcMenu on Gold; + -- src/ui/gen2/BoxMenu.lua is the withdraw/deposit LIST Gen 1 builds inline. + ["src.ui.BoxMenu"] = "src.ui.gen2.PcMenu", +} + +Gen2Compat.ADAPTERS = ADAPTERS + +function Gen2Compat.bind(fn) + resolveGame = fn +end + +function Gen2Compat.serves(name) + return ADAPTERS[name] ~= nil +end + +-- ------- the coverage API +-- +-- Stable contract, versioned by Gen2Compat.COVERAGE_VERSION: +-- Gen2Compat.modules() -> sorted array of served module names +-- Gen2Compat.coverage(name) -> { module, kind, target, members, notes } +-- members[member] = "backed"|"warned"| +-- "absent"; a fresh table per call +-- Gen2Compat.memberStatus(n, m) -> that status string, or nil when the +-- module is unserved or the member is +-- not recorded + +function Gen2Compat.modules() + local out = {} + for name in pairs(ADAPTERS) do out[#out + 1] = name end + table.sort(out) + return out +end + +function Gen2Compat.coverage(name) + local row = COVERAGE[name] + if not row then return nil end + local members = {} + -- warned last on purpose: a name listed as backed AND warned is present and + -- degraded, and the weaker claim is the safe one to publish + for _, status in ipairs({ "backed", "absent", "warned" }) do + for _, member in ipairs(words(row[status])) do + members[member] = status + end + end + local notes = {} + for key, value in pairs(row.notes or {}) do notes[key] = value end + return { module = name, kind = row.kind, target = row.target, + members = members, notes = notes } +end + +function Gen2Compat.memberStatus(name, member) + local row = Gen2Compat.coverage(name) + return row and row.members[member] or nil +end + +-- The one entry point the Loader calls. `modId` is attribution only: no +-- adapter branches on it. +function Gen2Compat.resolve(name, modId) + local spec = ADAPTERS[name] + if not spec then return nil end + local module = built[name] + if not module then + module = type(spec) == "string" and rawRequire(spec) or spec() + built[name] = module + end + if modId then + local ids = claimants[name] + if not ids then ids = {} claimants[name] = ids end + local seen = false + for _, id in ipairs(ids) do if id == modId then seen = true break end end + if not seen then ids[#ids + 1] = modId end + end + return module +end + +return Gen2Compat diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index e078b232..af091c7d 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -32,6 +32,7 @@ local Manifest = require("src.mods.Manifest") local ManagerState = require("src.mods.ManagerState") +local ModTargets = require("src.mods.ModTargets") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local SaveData = require("src.core.SaveData") @@ -45,8 +46,20 @@ local LauncherMods = {} -- the id -> validated-manifest map resolveToggle reads (its dependencySpecs, -- conflictSpecs, version and game_version are exactly the fields the loader's -- Manifest.validate produced); enabledSet is the current desired enable-set. -local function statusFor(mods, id, enabledSet, enabled) +local function statusFor(mods, id, enabledSet, enabled, version, forcedFor) local m = mods[id] + local forced = forcedFor(id) + -- The game this mod is for outranks everything below it: a mod that is not + -- going to run here has no useful conflict or dependency verdict. Same + -- source as the in-game manager (src/mods/ModTargets.lua), so the two + -- surfaces cannot disagree about the same mod. + if version and not ModTargets.supports(m, version) then + if forced then + return "warn", "Forced onto " .. ModTargets.gameLabel(version) + .. " by you (untested)" + end + return "other_game", ModTargets.detail(m, version) + end -- conflict only bites an enabled mod: resolveToggle's conflict list is -- bidirectional (this mod's conflicts spec vs an enabled other, and an -- enabled other's spec vs this mod), which is exactly the launcher chip. @@ -59,8 +72,10 @@ local function statusFor(mods, id, enabledSet, enabled) "Conflicts with " .. ((other and other.name) or otherId) end end - -- warn: the engine is outside the mod's game_version range - if m.game_version + -- warn: the engine is outside the mod's game_version range. The dev + -- placeholder is skipped here exactly as Loader.devEngine skips it, so the + -- launcher and the loader cannot disagree about the same mod. + if m.game_version and Version.engine:match("^0%.0%.0%-") == nil and not Semver.satisfies(Version.engine, m.game_version) then return "warn", "Needs engine " .. m.game_version .. " (have " .. Version.engine .. ")" @@ -74,6 +89,13 @@ local function statusFor(mods, id, enabledSet, enabled) return "warn", "Needs " .. spec.id .. " (not installed)" elseif not enabledSet[spec.id] then return "warn", "Needs " .. spec.id .. " (disabled)" + -- installed and on, but not for THIS game: the loader skips the + -- dependency and the skip is contagious (Loader:_enforceDependencies), + -- so a mod that runs everywhere still does not run here + elseif version + and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then + return "warn", "Needs " .. spec.id .. " (not for " + .. ModTargets.gameLabel(version) .. ")" elseif spec.range and not Semver.satisfies(dep.version, spec.range) then return "warn", "Needs " .. spec.id .. " " .. spec.range @@ -82,34 +104,44 @@ local function statusFor(mods, id, enabledSet, enabled) return "ok", "Ready" end --- deriveList(manifests, options) -> the panel row list, pure. +-- deriveList(manifests, options [, version]) -> the panel row list, pure. -- manifests is an array of validated manifests (Manifest.validate output); --- options is the options table (only options.mods is read). Rows come back --- sorted by id so the panel order is stable. -function LauncherMods.deriveList(manifests, options) - local mods = options and options.mods or {} +-- options is the options table (options.mods, options.modsByVersion and +-- options.modsGen2 are read). `version` is the game the panel is showing: +-- nil keeps the pre-per-game view, where the shared flag is the whole answer. +-- Rows come back sorted by id so the panel order is stable. +function LauncherMods.deriveList(manifests, options, version) local ordered = {} for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end table.sort(ordered, function(a, b) return a.id < b.id end) + -- the override is one answer per game (SaveData.modForced), the same scope + -- the loader resolves it under + local forcedFor = function(id) + return version and SaveData.modForced(options, id, version) or false + end local byId, enabledSet = {}, {} for _, m in ipairs(ordered) do byId[m.id] = m - -- missing entry means enabled, matching the loader -- except experimental - -- mods, which stay off until the player opts in - if mods[m.id] == false then - -- stay off - elseif mods[m.id] == true then - enabledSet[m.id] = true - elseif not m.experimental then - enabledSet[m.id] = true - end + -- this game's choice, then the shared flag, then the default: enabled, + -- matching the loader -- except experimental mods, which stay off until + -- the player opts in. Scoped through modScope, so this reads exactly what + -- setEnabled writes and the loader loads: while per-game flags are a + -- preview the shared flag is the whole answer on every surface. + local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) + if decided == nil then decided = not m.experimental end + if decided then enabledSet[m.id] = true end end local out = {} for _, m in ipairs(ordered) do local enabled = enabledSet[m.id] == true - local status, detail = statusFor(byId, m.id, enabledSet, enabled) + local forced = forcedFor(m.id) + local status, detail = + statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + -- nil, not false, when the panel is showing every game at once + local here = nil + if version then here = ModTargets.runsHere(m, version, nil, forced) end local raw = m.raw or {} local badge = tostring(raw.category or m.profile or "MOD"):upper() if m.experimental then badge = "EXPERIMENTAL" end @@ -124,6 +156,10 @@ function LauncherMods.deriveList(manifests, options) statusDetail = detail, github = m.github, experimental = m.experimental == true, + -- what game this mod is for, and whether it will run on the one the + -- panel is showing (src/mods/ModTargets.lua) + targets = ModTargets.chip(m), + targetsHere = here, } end return out @@ -239,13 +275,13 @@ local function discover() return out end --- list() -> the mods-panel rows for the current install. Reads the same --- options.mods enable-state the loader persists, so a toggle here is what the --- game sees on its next boot. -function LauncherMods.list() +-- list([version]) -> the mods-panel rows for the current install. Reads the +-- same enable-state the loader persists, so a toggle here is what the game +-- sees on its next boot; `version` narrows that to one game's answers. +function LauncherMods.list(version) local ok, result = pcall(function() local options = SaveData.loadOptions() - return LauncherMods.deriveList(discover(), options) + return LauncherMods.deriveList(discover(), options, version) end) if not ok then -- a single bad options/mod file must not blank the launcher @@ -328,27 +364,27 @@ function LauncherMods.translationStrings() return merged end --- setEnabled(id, enabled): persist options.mods[id] in the exact shape --- Loader:_saveState writes (a plain boolean), so the running game and the --- in-game ManagerState pick it up unchanged. -function LauncherMods.setEnabled(id, enabled) +-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact +-- shape Loader:_saveState writes (a plain boolean), so the running game and +-- the in-game ManagerState pick it up unchanged. With `version` the choice +-- lands in that game's overlay instead and no other game moves. +function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() - options.mods = options.mods or {} - options.mods[id] = enabled and true or false + SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.saveOptions(options) return true end --- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons --- (#647). Writes exactly the options.mods shape setEnabled does, but loads and +-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable +-- all buttons (#647). Writes what setEnabled writes, but loads and -- saves once for the whole list: saveOptions rewrites the whole options file per -- call, so looping setEnabled over a big mods folder is one disk write per mod -- and leaves a half-applied state behind if one of them fails. -function LauncherMods.setAllEnabled(ids, enabled) +function LauncherMods.setAllEnabled(ids, enabled, version) local options = SaveData.loadOptions() - options.mods = options.mods or {} + local scope = SaveData.modScope(version) for _, id in ipairs(ids or {}) do - options.mods[id] = enabled and true or false + SaveData.setModEnabled(options, id, enabled, scope) end SaveData.saveOptions(options) return true diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index fa6f6a34..3d833f46 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -2,16 +2,19 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") local SaveData = require("src.core.SaveData") local Data = require("src.core.Data") +local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") +local ModTargets = require("src.mods.ModTargets") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") +local Gen2Compat = require("src.mods.Gen2Compat") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") @@ -20,6 +23,17 @@ Loader.__index = Loader local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only +-- The working tree's engine version is the "0.0.0-dev" placeholder that CI +-- restamps into the packed game.love (src/core/Version.lua:7), and it sorts +-- BELOW every release, so a checkout would fail every mod that names a +-- floor. A placeholder is not a compatibility statement: skip the range +-- check rather than answer it wrong. A stamped build checks as it always did. +-- Read at call time, not captured: a build stamps Version before this loads +-- and a test stamps it after. +local function devEngine() + return Version.engine:match("^0%.0%.0%-") ~= nil +end + -- walk a dotted target path without creating anything; the base view a -- registry folds against must never perturb Data on a mod-free boot local function resolvePath(root, path) @@ -62,6 +76,37 @@ end local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } +-- The Gen 1 engine modules a Gold boot never instantiates. Each one still +-- LOADS under Gen 2 -- require finds the file and hands back a module table -- +-- so a mod that captures src.core.Game and reads Game.overworld gets nil for +-- the life of the process and its patches land on code nothing runs. That is +-- the failure the generation gate exists to prevent, and it is worth naming +-- when a forced or gen2compat mod reaches for one anyway. Gold's own +-- counterparts are src/core/Game2.lua and the src/*/gen2/ trees; the live +-- owner is in the game.ready payload and mod.world resolves per generation. +local GEN1_ONLY_MODULES = { + ["src.core.Game"] = true, + ["src.world.OverworldController"] = true, + ["src.world.PikachuFollower"] = true, + ["src.world.NPC"] = true, + ["src.world.Collision"] = true, + ["src.world.WorldAPI"] = true, + ["src.world.Map"] = true, + ["src.battle.BattleState"] = true, + ["src.script.ScriptRunner"] = true, + -- Not a dead patch but a dead SCRIPT: Gold's registry carries mod verbs + -- only (src/mods/Builtins.lua:100), so every Gen 1 built-in in this table + -- resolves here and then runs as nothing. + ["src.script.Commands"] = true, + -- Loads fine under Gold and paints Red's chrome over Gold's options screen, + -- whose layout is one 18x16 box rather than four 20x4 ones. + ["src.ui.OptionRows"] = true, + ["src.ui.PartyMenu"] = true, + ["src.ui.BoxMenu"] = true, + ["src.ui.StartMenu"] = true, + ["src.ui.OptionsMenu"] = true, +} + -- the src.* modules the mod surface points authors at: another mod's -- exports carry a version string that wants range-checking before use, and -- ChipAsm is the authoring path for chip music and sfx @@ -71,6 +116,20 @@ local SUPPORTED_REQUIRES = { ["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods } +-- Where this file lives, so the shim can tell an engine require from a mod's: +-- a mod chunk is named after its own directory, and this is the only test that +-- survives a lazy require made long after Runtime.currentMod went back to nil. +local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "") + :gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "") + +local function callerIsMod(level) + if ENGINE_PREFIX == "" then return false end + local info = debug.getinfo(level, "S") + local source = info and info.source + if not source or source:sub(1, 1) ~= "@" then return false end + return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX +end + local function scanRequire(name) local modId = Runtime.currentMod if not modId or type(name) ~= "string" then return end @@ -81,6 +140,23 @@ local function scanRequire(name) devShim.warned[key] = true Logger.warn("[%s] undeclared %s require: %s", modId, permission, name) end + -- A Gen 1-only module on a Gold boot is not a permissions question, it is a + -- dead patch: reported once, attributed, and onto the boot error feed the + -- manager shows the player rather than a dev-only log line. + if devShim.generation ~= 1 and GEN1_ONLY_MODULES[name] + and not Gen2Compat.serves(name) then + local key = modId .. "|gen2|" .. name + if not devShim.warned[key] then + devShim.warned[key] = true + local message = ("%s: requires %s, which a Gen 2 game never runs and " + .. "src/mods/Gen2Compat.lua has no adapter for; take the game from " + .. "the game.ready payload and mod.world") + :format(modId, name) + local errors = devShim.errors + if errors then errors[#errors + 1] = message end + Logger.error("%s", message) + end + end -- link modules are the one place a mod can reach the wire, so network is -- the permission that governs them if name:match("^src%.link%.") then @@ -115,7 +191,25 @@ function Loader:_installDevShim() _G.require = function(name, ...) -- only the mod's own call is the mod's doing; whatever that module -- requires in turn is the engine wiring itself up - if devShim.depth == 0 then scanRequire(name) end + if devShim.depth == 0 then + scanRequire(name) + -- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it. + -- Engine code keeps the real module: src/render/PaletteFX.lua:776 + -- requires src.core.Game on both generations and means it. + if devShim.generation ~= 1 and Gen2Compat.serves(name) + and callerIsMod(3) then + local adapter = Gen2Compat.resolve(name, Runtime.currentMod) + if adapter then + local key = "adapter|" .. name + if not devShim.warned[key] then + devShim.warned[key] = true + Logger.info("gen2 facade: %s -> %s", name, + tostring(Gen2Compat.ADAPTERS[name])) + end + return adapter + end + end + end devShim.depth = devShim.depth + 1 local ok, result = pcall(delegate, name, ...) devShim.depth = devShim.depth - 1 @@ -141,20 +235,67 @@ function Loader.new(opts) modInput = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, + -- Which generation this boot is (1 or 2). Fixed at construction: the + -- active version is set once in main.lua's bootGame before anything + -- builds a loader, and a run never changes generation underneath one. + -- opts.generation is the test seam. + generation = (opts and opts.generation) or GameVersion.generation(), }, Loader) assert(self.fs, "Loader.new requires opts.fs when love is unavailable") + -- Schemas.shapeFor, not the catalog spec: a registry whose Gen 2 records are + -- shaped differently (a species' specialAttack/specialDefense, an encounter + -- table keyed by kind, a trainer CLASS hanging off .classes) carries its Gen + -- 2 shape beside the Gen 1 one, and resolving it once here is what makes + -- every reader downstream generation-blind: Schemas.check off registry.spec, + -- Registry's fold/baseAt/baseIds, _mergeOrder's depth and _merge's + -- spec.write / spec.semantics all read this one spec and never ask again. + -- Gen 1 and any registry with no Gen 2 shape get the catalog table itself. for name, spec in pairs(Schemas.REGISTRIES) do - self.content[name] = Registry.new(name, spec) + self.content[name] = Registry.new(name, Schemas.shapeFor(name, spec, self.generation)) end self.disabled = {} + self.gen2Forced = {} return self end +-- The game this boot is, or nil when a harness injected a generation the +-- running version disagrees with (only the generation can be trusted then). +function Loader:_targetVersion() + local version = GameVersion.get and GameVersion.get() + if not (version and GameVersion.VERSIONS[version]) then return nil end + if GameVersion.generation(version) ~= self.generation then return nil end + return version +end + +-- The version an enable flag is read and written under: this game once +-- per-game flags are live, nil (the shared flag) while they are a preview. +-- Reads and writes go through the same answer so the two can never drift. +function Loader:_enableScope() + return SaveData.modScope(self:_targetVersion()) +end + function Loader:_loadState() self.disabled = {} local options = SaveData.loadOptions(self.fs) - for id, enabled in pairs(options.mods or {}) do - if enabled == false then self.disabled[id] = true end + local scope = self:_enableScope() + local ids = {} + for id in pairs(options.mods or {}) do ids[id] = true end + local bucket = scope and (options.modsByVersion or {})[scope] + if type(bucket) == "table" then + for id in pairs(bucket) do ids[id] = true end + end + for id in pairs(ids) do + if SaveData.modEnabled(options, id, scope) == false then + self.disabled[id] = true + end + end + -- the player's target override, resolved for THIS game: forcing a mod onto + -- Gold never changes whether it runs on Red (SaveData.modForced) + self.gen2Forced = {} + for id in pairs(options.modsGen2 or {}) do + if SaveData.modForced(options, id, self:_targetVersion(), self.generation) then + self.gen2Forced[id] = true + end end -- mod.options reads through this; M11 owns writing it back self.modOptions = options.modOptions or {} @@ -185,8 +326,14 @@ function Loader:_saveState() if not self.fs.write then return end local options = SaveData.loadOptions(self.fs) options.mods = options.mods or {} + local scope = self:_enableScope() + local version = self:_targetVersion() for id in pairs(self.mods) do - options.mods[id] = not self.disabled[id] + SaveData.setModEnabled(options, id, not self.disabled[id], scope) + -- only the games this boot can answer for: another version's overrides + -- are not this run's to rewrite. With no version (an injected-generation + -- harness) the override stays in memory for this boot only. + SaveData.setModForced(options, id, self.gen2Forced[id] == true, version) end SaveData.saveOptions(options, self.fs) end @@ -199,6 +346,25 @@ function Loader:setEnabled(id, enabled) return true end +-- Takes effect on the next boot, like every other load-time decision: the +-- gate runs once, before any entry chunk. Second return is false when the +-- choice could not be persisted for a game, so the caller does not promise a +-- restart will honour it. +function Loader:setGen2Forced(id, forced) + if not self.mods[id] then return false, false end + self.gen2Forced[id] = forced or nil + self:_saveState() + local persisted = self:_targetVersion() ~= nil and self.fs.write ~= nil + if not persisted then + Logger.warn("mod %s: target override kept for this boot only", id) + end + return true, persisted +end + +function Loader:isGen2Forced(id) + return self.gen2Forced[id] == true +end + function Loader:_discover() if not self.fs.getDirectoryItems then return end local roots = { "mods" } @@ -240,10 +406,73 @@ function Loader:_fail(mod, state, reason) Logger.error("mod %s failed: %s", mod.manifest.id, reason) end +-- left out rather than broken: inactive like a failure, but off the boot +-- error list and rendered with its own manager row state (ManagerState:264) +function Loader:_skip(mod, state, reason) + if mod.failed then return end + mod.failed, mod.state, mod.skipReason = true, state, reason + Logger.info("mod %s skipped: %s", mod.manifest.id, reason) +end + local function isActive(mod) return mod.enabled and not mod.failed end +-- the Data path a registry merges into for THIS boot's generation, or nil +-- when it has no home here (Schemas.GEN2) +function Loader:_target(name, spec) + return Schemas.targetFor(name, spec, self.generation) +end + +-- Which games a mod runs on is opt-in per manifest (`games`, and the legacy +-- gen2compat it subsumes). A mod that did not claim THIS game is left out of +-- the boot whole: not loaded, no registrations, no subscriptions. The +-- alternative is what this replaces -- the mod loads, the manager shows it +-- enabled, and roughly four of its hooks out of a hundred actually fire -- +-- which reads as a broken mod rather than an absent one. This is a skip and +-- not a failure: it is not the mod's bug, so it stays off the boot error list +-- and out of the log's error stream, and the manager gives it its own row +-- state. +-- +-- The gate is per VERSION, not only per generation: `games: ["blue"]` is a +-- claim about Blue, and the two mod UIs already say "For Blue, not Red" off +-- the same ModTargets answer, so enforcing it here is what makes that line a +-- verdict instead of a decoration. +-- +-- The player owns the override. The manifest is the AUTHOR's claim, and a mod +-- written before the field existed can never carry it, so `options.modsGen2` +-- (the manager's TRY HERE ANYWAY toggle, scoped to one game) forces one on for +-- this boot; a forced mod loads normally and keeps a note saying it was never +-- verified here. +function Loader:_gateGeneration() + local version = self:_targetVersion() + for _, id in ipairs(orderedIds(self.mods, isActive)) do + local mod = self.mods[id] + if ModTargets.supports(mod.manifest, version, self.generation) then + -- nothing to say: the author claimed this game + elseif self.gen2Forced[id] then + mod.forcedGen2 = true + mod.skipReason = ("forced onto this Gen %d game; not verified by its author") + :format(self.generation) + Logger.warn("mod %s: %s", id, mod.skipReason) + elseif self.generation == 2 and not mod.manifest.gen2compat then + -- the whole-generation miss keeps its own wording: gen2compat is the + -- field the author has to add, so the skip line names it + self:_skip(mod, "wrong_generation", + ("not marked gen2compat; this is a Gen %d game"):format(self.generation)) + elseif version then + -- claimed some game, just not this one (ModTargets.detail) + self:_skip(mod, "wrong_generation", ModTargets.detail(mod.manifest, version)) + else + -- worded from the loader's own generation, not from GameVersion's + -- current id: the two agree in a real boot, and a harness that injects + -- a generation should not produce a sentence naming the wrong game + self:_skip(mod, "wrong_generation", + ("not made for a Gen %d game"):format(self.generation)) + end + end +end + function Loader:_exists(path) if not self.fs.getInfo then return true end return self.fs.getInfo(path) ~= nil @@ -264,7 +493,7 @@ function Loader:_validate() elseif manifest.assets_transforms and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then reason = "assets_transforms file missing: " .. manifest.assets_transforms - elseif manifest.game_version then + elseif manifest.game_version and not devEngine() then local ok, err = Semver.satisfies(Version.engine, manifest.game_version) if not ok then reason = ("needs game version %s, engine is %s") @@ -286,11 +515,20 @@ function Loader:_enforceDependencies() local mod = self.mods[id] for _, spec in ipairs(mod.manifest.dependencySpecs) do local dep = self.mods[spec.id] - local reason + local reason, skip if not dep then reason = "missing dependency: " .. spec.id elseif not dep.enabled then reason = ("dependency %s is disabled"):format(spec.id) + elseif dep.state == "wrong_generation" then + -- the gate's skip is contagious as a SKIP, not as a failure: the + -- dependency has no bug to report and neither does this mod, so + -- nothing here lands on the boot error list + skip = true + -- carry the dependency's own reason: it names the game or the + -- missing gen2compat, and a guess here would name the wrong one + reason = ("depends on %s, which does not run here (%s)") + :format(spec.id, dep.skipReason or "not made for this game") elseif dep.failed then reason = ("dependency %s failed to load"):format(spec.id) elseif spec.range @@ -299,7 +537,11 @@ function Loader:_enforceDependencies() :format(spec.id, spec.range, dep.manifest.version) end if reason then - self:_fail(mod, "blocked_dependency", reason) + if skip then + self:_skip(mod, "wrong_generation", reason) + else + self:_fail(mod, "blocked_dependency", reason) + end changed = true break end @@ -443,7 +685,9 @@ function Loader:_mergeOrder() for name, registry in pairs(self.content) do names[#names + 1] = name local segments = 0 - for _ in (registry.spec.target or ""):gmatch("[^%.]+") do + -- the routed path, not spec.target: nesting is a property of where the + -- content actually lands, and that is per generation (Schemas.GEN2) + for _ in (self:_target(name, registry.spec) or ""):gmatch("[^%.]+") do segments = segments + 1 end depth[name] = segments @@ -484,27 +728,57 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end + -- A registry with no home in this generation (Schemas.routing) takes the + -- write and drops it. Reported once per mod per registry, into the same feed + -- the manager shows, because a mod that declared gen2compat and then wrote + -- here is owed the reason -- but NOT fatal: a mod that supports both + -- generations registers its content unconditionally and should still load + -- the half that does apply. + -- + -- Worded from loader.generation, the way _gateGeneration's skipReason is, + -- because the gating runs BOTH ways now: Schemas.GEN1 gates the six Gen + -- 2-only registries (held_items, phone_contacts, decorations, apricorns, + -- landmarks, radio_channels), so a Red boot rejecting a write to + -- `decorations` must not claim it has "no Gen 2 target". + local gated = Schemas.gatedFor(registry.name, loader.generation) + local toldGated = false + local function dropped() + if not gated then return false end + if not toldGated then + toldGated = true + local message = ("%s: the %s registry has no Gen %d target; those " + .. "registrations do not apply here") + :format(modId, registry.name, loader.generation) + loader.errors[#loader.errors + 1] = message + Logger.warn("%s", message) + end + return true + end return { register = function(_, id, value) note() + if dropped() then return nil end validate("register", id, value) loader:_journal(registry.name) return registry:register(id, value, modId) end, override = function(_, id, value) note() + if dropped() then return nil end validate("override", id, value) loader:_journal(registry.name) return registry:override(id, value, modId) end, patch = function(_, id, partial) note() + if dropped() then return nil end validate("patch", id, partial) loader:_journal(registry.name) return registry:patch(id, partial, modId) end, remove = function(_, id) note() + if dropped() then return nil end loader:_journal(registry.name) return registry:remove(id, modId) end, @@ -767,10 +1041,21 @@ function Loader:_api(mod) -- acts on is still being wired when the entry chunk runs local world setmetatable(api, { __index = function(_, key) + -- mod.game is the live service owner, resolved per generation the way + -- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2 + -- INSTANCE Gold injected under Gen 2. Read on every touch rather than + -- cached, because the Gen 1 singleton's stack and save fill in after the + -- entry chunk runs. This is what a mod should hold instead of requiring + -- src.core.Game, which under Gold hands back a table nothing instantiated. + if key == "game" then return loader:_game() end if key ~= "world" then return nil end if world then return world end local game = loader:_game() - local module = game and engineRequire("src.world.WorldAPI") + -- one facade name, one arm per generation: Gold's world is not a stack + -- state and its flags are a bitfield, so the resolution differs even + -- where the method set does not (src/world/gen2/WorldAPI.lua) + local module = game and engineRequire(loader.generation == 2 + and "src.world.gen2.WorldAPI" or "src.world.WorldAPI") if not module then return nil end world = module.new(game, modId) return world @@ -781,8 +1066,16 @@ end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. +-- +-- Gen 2 has no fallback to reach for: src/core/Game.lua is the Gen 1 service +-- owner and a Gold boot never loads it, so returning it would hand mod.world a +-- live-looking object with no stack, no save and no overworld. Gold injects +-- itself (src/core/Game2.lua), and nil here is the honest answer if it +-- somehow did not. function Loader:_game() - return self.game or engineRequire("src.core.Game") + if self.game then return self.game end + if self.generation ~= 1 then return nil end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) @@ -926,8 +1219,8 @@ function Loader:load(data) self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later - for _, registry in pairs(self.content) do - local target = registry.spec.target + for name, registry in pairs(self.content) do + local target = self:_target(name, registry.spec) if target then registry.base = function() return data and resolvePath(data, target) @@ -936,7 +1229,10 @@ function Loader:load(data) end -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override - require("src.mods.Builtins").install(self.content, data) + -- the generation decides WHICH module owns a registry's vanilla records: + -- Gold reimplements the battle rules, so its own statuses/balls/AI records + -- go in instead of Red's, not beside them (src/mods/Builtins.lua) + require("src.mods.Builtins").install(self.content, data, self.generation) self:_loadState() self:_discover() -- Experimental mods stay off until the player opts in: a missing @@ -968,9 +1264,26 @@ function Loader:load(data) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on Runtime.install(self.events, self.hooks, self.errors) + -- before _validate: a mod that is not running on this generation should not + -- also be reported for a missing entry file it will never be asked for + self:_gateGeneration() self:_validate() local ordered = self:_resolve() - if self.dev then self:_installDevShim() end + -- The shim is a process singleton, so whichever loader is running owns these + -- two: a harness that builds a Gen 1 loader after a Gen 2 one must not keep + -- reporting against the old generation or the old error feed. + devShim.generation = self.generation + devShim.errors = self.errors + -- The Gen 1 Game facade proxies THIS loader's live game, and reads it on + -- every touch: a mod captures the facade at file scope, before Game2 has a + -- save or a world (src/mods/Gen2Compat.lua). + Gen2Compat.bind(function() return self:_game() end) + -- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants + -- the Gen 1-only require report, which is the difference between "the mod + -- does nothing" and knowing why. A Gold boot with no mods pays nothing. + if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then + self:_installDevShim() + end for _, mod in ipairs(ordered) do -- a mod ahead of this one may have failed and taken its dependents with -- it, so the order list is filtered as it is walked @@ -1005,8 +1318,9 @@ function Loader:load(data) for _, name in ipairs(self:_mergeOrder()) do local registry = self.content[name] local spec = registry.spec - if data and spec.target and next(registry.ops) ~= nil then - local target = Data.ensure(data, spec.target) + local path = self:_target(name, spec) + if data and path and next(registry.ops) ~= nil then + local target = Data.ensure(data, path) if spec.write then -- ids that do not map one-to-one onto target keys (type_chart's -- ordered rows, battle_anims' per-kind subtables) place themselves @@ -1104,6 +1418,12 @@ function Loader:status() manifest.enabled = mod.enabled ~= false manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.error = mod.failure + -- set instead of `error` when the mod was left out for a reason that is + -- not a fault of the mod (today: the gen2compat gate) + manifest.note = mod.skipReason + -- the player's override, which the manager offers on a Gen 2 boot for a + -- mod whose author never claimed one + manifest.gen2Forced = self.gen2Forced[mod.manifest.id] == true available[#available + 1] = manifest if manifest.state == "loaded" then loaded[#loaded + 1] = manifest end end diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 945b7adc..40cf0ab7 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -5,7 +5,10 @@ -- before they land, edits stage until one apply/restart, and safe mode is -- read from Runtime.safeMode (19 owns the detection). local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") +local ModTargets = require("src.mods.ModTargets") local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local Theme = require("src.ui.Theme") @@ -28,7 +31,10 @@ end -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy -local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } +-- `skipped` is not a fault: the mod is enabled and intact, this game is just +-- not the generation it declared (Loader:_gateGeneration) +local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?", + skipped = "-" } local TABS = { "MODS", "PROFILES", "ERRORS" } local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" } @@ -208,7 +214,9 @@ function ManagerState:refresh() if self.currentMod then self.currentMod = self.byId[self.currentMod.id] end - self.restartPending = #self:stagedList() > 0 + -- gen2Pending is not in stagedList: the Gen 2 override is not an enable flag + -- and there is nothing in `available` to diff it against + self.restartPending = #self:stagedList() > 0 or self.gen2Pending == true -- a live set that drifted off the named profile reverts to ad-hoc local opts = self:optionsTable() if opts.activeProfile then @@ -255,9 +263,29 @@ function ManagerState:stagedList() return out end +-- The game this manager judges targets against: the running version, unless a +-- harness injected a loader generation that disagrees with it (Loader.new +-- opts.generation), where only the generation can be trusted. +function ManagerState:targetGame() + local loader = self.game and self.game.mods + local gen = loader and loader.generation + local version = GameVersion.get() + if gen and GameVersion.generation(version) ~= gen then return nil, gen end + return version, GameVersion.generation(version) +end + +-- will this mod run on this game at all (src/mods/ModTargets.lua, the same +-- derivation the launcher panel shows) +function ManagerState:runsHere(m) + local version, gen = self:targetGame() + return ModTargets.runsHere(m, version, gen, m.gen2Forced) +end + function ManagerState:glyphFor(m) if self:isStaged(m) then return GLYPH.staged end if not m.enabled then return GLYPH.disabled end + if m.state == "wrong_generation" then return GLYPH.skipped end + if not self:runsHere(m) then return GLYPH.skipped end if m.state == "blocked_dependency" then return GLYPH.blocked end if m.error then return GLYPH.errored end return " " @@ -345,6 +373,22 @@ function ManagerState:detailRows(m) rows[#rows + 1] = { label = Strings("PERMISSIONS.."), action = function() self:goTo("permissions") end } end + -- The manifest's games list is the AUTHOR's claim, and a mod written before + -- the field existed can never carry one, so the player gets the override + -- here rather than being told to edit a manifest they do not own. Exactly + -- the answer the loader gates on (Loader:_gateGeneration reads the same + -- ModTargets.supports), so the row appears only where a restart can change + -- what this mod does. + local loader = self.game.mods + local version, gen = self:targetGame() + if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then + rows[#rows + 1] = { + label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), + action = function() self:toggleGen2Force(m) end } + end + -- which games the mod says it is for, in the one place the player is + -- already looking when they wonder why it did not run + rows[#rows + 1] = { inert = true, label = "FOR " .. ModTargets.chip(m) } if m.github then rows[#rows + 1] = { inert = true, label = "GH " .. m.github } end @@ -660,15 +704,52 @@ function ManagerState:beginToggle(m) proceed() end +-- The gate runs once, before any entry chunk, so this can only take effect on +-- the next boot: it stages a restart the way an enable toggle does. The +-- override is scoped to THIS game, and a boot that cannot name one keeps it in +-- memory only, which the notice says rather than promising a restart. +function ManagerState:toggleGen2Force(m) + local loader = self.game.mods + if not (loader and loader.setGen2Forced) then return end + local want = not m.gen2Forced + local function apply() + local _, persisted = loader:setGen2Forced(m.id, want) + self.gen2Pending = persisted ~= false + if loader.status then self.game.modStatus = loader:status() end + self:refresh() + if persisted == false then + -- the gate already ran, so an unsaved override changes no boot at all + self:notify("COULD NOT SAVE") + else + self:notify(want and "WILL TRY ON RESTART" or "WILL BE SKIPPED") + end + end + if not want then + apply() + return + end + self:openConfirm({ + "NOT MADE FOR", + "THIS GAME.", + "TRY IT ANYWAY?", + }, apply) +end + +-- Where the loader persisted an enable flag: this game's slot once it keeps +-- them per game, the shared flag until then (SaveData.modScope). +function ManagerState:enableScope() + return SaveData.modScope((self:targetGame())) +end + function ManagerState:commitToggle(apply) local loader = self.game.mods local opts = self:optionsTable() + local scope = self:enableScope() for id, en in pairs(apply) do if loader and loader.setEnabled then loader:setEnabled(id, en) end -- mirror into the live options so a later writeOptions cannot revert -- what setEnabled just persisted - opts.mods = opts.mods or {} - opts.mods[id] = en + SaveData.setModEnabled(opts, id, en, scope) end if loader and loader.status then self.game.modStatus = loader:status() end self:refresh() @@ -677,11 +758,11 @@ end function ManagerState:discardChanges() local loader = self.game.mods local opts = self:optionsTable() + local scope = self:enableScope() for _, m in ipairs(self:stagedList()) do local en = bootEnabled(m) if loader and loader.setEnabled then loader:setEnabled(m.id, en) end - opts.mods = opts.mods or {} - opts.mods[m.id] = en + SaveData.setModEnabled(opts, m.id, en, scope) end if loader and loader.status then self.game.modStatus = loader:status() end self:refresh() @@ -710,7 +791,9 @@ function ManagerState:matchesProfile(p) local want = p.enabled[m.id] ~= false if (m.enabled and true or false) ~= want then return false end end - return true + -- the per-game answers count too, or a profile that only differs on Gold + -- would read as still active after the player changed it + return ModProfile.matchesVersions(p, self:optionsTable()) end function ManagerState:persistOptions() @@ -749,6 +832,8 @@ function ManagerState:applyProfile(p) for _, move in ipairs(ModProfile.slotMoves(p)) do require("src.core.SaveData").setActiveSlot(move[1], move[2]) end + -- the per-game half of the setup, restored beside the shared enable set + ModProfile.restoreVersions(p, self:optionsTable()) self:optionsTable().activeProfile = p.name self:persistOptions() local missing = ModProfile.missingIds(p, self.byId) @@ -768,11 +853,12 @@ function ManagerState:saveCurrentAs() local opts = self:optionsTable() opts.modProfiles = opts.modProfiles or {} local snap = ModProfile.capture(self.status.available, - self:modOptionsTable()) + self:modOptionsTable(), opts.modsByVersion) local existing = self:findProfile(name) if existing then existing.enabled, existing.options, existing.slots = snap.enabled, snap.options, snap.slots + existing.enabledByVersion = snap.enabledByVersion else snap.name = name opts.modProfiles[#opts.modProfiles + 1] = snap @@ -1082,7 +1168,10 @@ function ManagerState:drawDetail() local title = wrap(m.name or m.id, 14) drawTruncated(title[1] .. " " .. (m.version or ""), 16, 2 * 8, 17) local statusLine = m.enabled and "ENABLED" or "DISABLED" - if m.state == "blocked_dependency" then + if m.state == "wrong_generation" or not self:runsHere(m) then + -- enabled and fine, just not for this game; the detail body says why + statusLine = statusLine .. " (NOT THIS GAME)" + elseif m.state == "blocked_dependency" then statusLine = statusLine .. " ?" elseif m.error then statusLine = statusLine .. " !" @@ -1091,7 +1180,8 @@ function ManagerState:drawDetail() drawTruncated(statusLine, 16, 3 * 8, 17) drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"), 16, 4 * 8, 17) - local lines = wrap(m.error and ("FAILED: " .. m.error) or m.description, 16) + local lines = wrap(m.error and ("FAILED: " .. m.error) + or (m.note and ("SKIPPED: " .. m.note)) or m.description, 16) local visible = 5 for i = 1, visible do local line = lines[self.descScroll + i - 1] diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 9d1378a6..a6312c41 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -2,6 +2,7 @@ -- valid. Pure (no filesystem): the loader's validate phase owns the checks -- that need to stat a file, this owns shape, vocabulary and range grammar. local Logger = require("src.core.Logger") +local ModTargets = require("src.mods.ModTargets") local Semver = require("src.mods.Semver") local Version = require("src.core.Version") @@ -189,6 +190,39 @@ function Manifest.validate(raw, path) "language must be a boolean") local language = raw.language == true + -- Gen 2 is opt-in and never inferred. The hook/event names and the + -- registry names are shared across generations on purpose, so a Gen 1 mod + -- LOOKS like it would work under Gold; what it actually gets is a subset + -- (Gold has its own battle, world, script VM and save), and half-running is + -- worse than not running. A mod author claims Gen 2 only after testing + -- there, and until then the loader leaves the mod out of a Gold boot + -- entirely (Loader:_gateGeneration) rather than letting it half-apply. + -- Absent means false: every mod written before this field existed is Gen 1 + -- only, which is exactly what it was tested as. + assert(raw.gen2compat == nil or type(raw.gen2compat) == "boolean", + "gen2compat must be a boolean") + + -- `games` is the same statement made per game: version ids ("red"), + -- generations ("gen1"), or "all" (src/mods/ModTargets.lua). gen2compat is + -- kept as its Gen 2 spelling and only ever ADDS Gen 2, so no shipped + -- manifest loses a game it already ran on, and gen2compat below is derived + -- from the resolved list -- the loader's gate reads that one field. + assert(raw.games == nil or type(raw.games) == "table", + "games must be an array") + local games, unknownGames = ModTargets.normalize(raw.games) + for _, token in ipairs(unknownGames) do + violation(strict, raw.id, ("unknown game %q"):format(token)) + end + if raw.games ~= nil and #games == 0 then + violation(strict, raw.id, "games names no game this engine knows") + end + if #games == 0 then + games = ModTargets.legacy(raw.gen2compat == true) + elseif raw.gen2compat == true then + games = ModTargets.union(games, ModTargets.generationVersions(2)) + end + local gen2compat = ModTargets.covers(games, 2) + -- overhauls and total conversions are assumed to move the link -- fingerprint unless the manifest says otherwise; content packs and -- declared translations are not @@ -224,6 +258,8 @@ function Manifest.validate(raw, path) experimental = experimental, profile = profile, language = language, + games = games, + gen2compat = gen2compat, affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, diff --git a/src/mods/ModProfile.lua b/src/mods/ModProfile.lua index f41181f8..860fee65 100644 --- a/src/mods/ModProfile.lua +++ b/src/mods/ModProfile.lua @@ -26,17 +26,34 @@ ModProfile.FORMAT = "g1rmodlist" ModProfile.FORMAT_VERSION = 1 -- deterministic order; GameVersion.VERSIONS is a map, and a profile file has --- to encode the same way twice for a diff to mean anything -local VERSION_ORDER = { "red", "blue", "yellow" } +-- to encode the same way twice for a diff to mean anything. GameVersion.ORDER +-- rather than a literal so the next version added is captured with the rest: +-- decode already validates against GameVersion.VERSIONS, so a gold slot in an +-- imported profile survived the read and was then dropped by capture. +local VERSION_ORDER = GameVersion.ORDER local function fsOr(fs) return fs or (love and love.filesystem) or nil end +-- one version's per-game enable overlay, copied flat (SaveData.modsByVersion) +local function copyFlags(bucket) + if type(bucket) ~= "table" then return nil end + local copy, any = {}, false + for id, on in pairs(bucket) do + if type(id) == "string" then + copy[id] = on and true or false + any = true + end + end + return any and copy or nil +end + -- Capture the live setup. `available` is ManagerState.status.available (the -- loader's status manifests, m.enabled = the desired set including staged --- flips); `modOptions` is options.modOptions. -function ModProfile.capture(available, modOptions) +-- flips); `modOptions` is options.modOptions; `byVersion` is +-- options.modsByVersion, the per-game answers that differ from it. +function ModProfile.capture(available, modOptions, byVersion) local enabled, options = {}, {} for _, m in ipairs(available or {}) do enabled[m.id] = m.enabled and true or false @@ -50,12 +67,47 @@ function ModProfile.capture(available, modOptions) options[m.id] = copy end end - local slots = {} + local slots, perVersion = {}, {} for _, id in ipairs(VERSION_ORDER) do local ok, slot = pcall(SaveData.activeSlot, id) if ok and slot then slots[id] = slot end + local flags = copyFlags(type(byVersion) == "table" and byVersion[id]) + if flags then perVersion[id] = flags end end - return { enabled = enabled, options = options, slots = slots } + return { enabled = enabled, options = options, slots = slots, + enabledByVersion = perVersion } +end + +-- Write a profile's per-game answers back into an options table, replacing +-- only the games it carries: a profile shared by someone who never played +-- Gold must not blank the Gold set here. +function ModProfile.restoreVersions(p, options) + if type(options) ~= "table" then return end + local wanted = type(p) == "table" and p.enabledByVersion or nil + if type(wanted) ~= "table" then return end + options.modsByVersion = options.modsByVersion or {} + for _, id in ipairs(VERSION_ORDER) do + local flags = copyFlags(wanted[id]) + if flags then options.modsByVersion[id] = flags end + end +end + +-- Does the live per-game overlay still read the way this profile captured it? +-- Only the games the profile names are compared, matching restoreVersions. +function ModProfile.matchesVersions(p, options) + local wanted = type(p) == "table" and p.enabledByVersion or nil + if type(wanted) ~= "table" then return true end + local live = (type(options) == "table" and options.modsByVersion) or {} + for _, id in ipairs(VERSION_ORDER) do + local want, got = wanted[id], live[id] + if type(want) == "table" then + for modId, on in pairs(want) do + local cur = type(got) == "table" and got[modId] or nil + if (cur and true or false) ~= (on and true or false) then return false end + end + end + end + return true end -- The slot moves a profile may actually make: a slot id has to be registered @@ -94,7 +146,8 @@ function ModProfile.encode(p) format = ModProfile.FORMAT, formatVersion = ModProfile.FORMAT_VERSION, profile = { name = p.name, enabled = p.enabled, - options = p.options, slots = p.slots }, + options = p.options, slots = p.slots, + enabledByVersion = p.enabledByVersion }, }) end @@ -109,7 +162,8 @@ function ModProfile.decode(body) if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then return nil, "BAD FILE" end - local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} } + local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {}, + enabledByVersion = {} } for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do if type(id) == "string" then p.enabled[id] = on and true or false end end @@ -130,6 +184,13 @@ function ModProfile.decode(body) p.slots[version] = slot end end + local shared = type(raw.enabledByVersion) == "table" and raw.enabledByVersion or {} + for version, bucket in pairs(shared) do + if GameVersion.VERSIONS[version] then + local flags = copyFlags(bucket) + if flags then p.enabledByVersion[version] = flags end + end + end return p end @@ -196,7 +257,7 @@ function ModProfile.ensureFirst(opts, available, modOptions) opts.modProfilesSeeded = true opts.modProfiles = opts.modProfiles or {} if #opts.modProfiles > 0 then return nil end - local p = ModProfile.capture(available, modOptions) + local p = ModProfile.capture(available, modOptions, opts.modsByVersion) p.name = "PROFILE 1" opts.modProfiles[1] = p opts.activeProfile = p.name diff --git a/src/mods/ModTargets.lua b/src/mods/ModTargets.lua new file mode 100644 index 00000000..58c7d05e --- /dev/null +++ b/src/mods/ModTargets.lua @@ -0,0 +1,171 @@ +-- Which games a mod is for. One derivation for the manifest's `games` key, +-- the legacy `gen2compat` flag, and the labels both mod surfaces draw: +-- src/mods/LauncherMods.lua and src/mods/ManagerState.lua read this rather +-- than each keeping its own copy of the rule. +local GameVersion = require("src.core.GameVersion") + +local ModTargets = {} + +-- every version of one generation, in launcher order (GameVersion.ORDER) +function ModTargets.generationVersions(gen) + local out = {} + for _, id in ipairs(GameVersion.ORDER) do + if GameVersion.generation(id) == gen then out[#out + 1] = id end + end + return out +end + +-- the generations this engine has games for, ascending +local function generations() + local seen, out = {}, {} + for _, id in ipairs(GameVersion.ORDER) do + local gen = GameVersion.generation(id) + if not seen[gen] then + seen[gen] = true + out[#out + 1] = gen + end + end + table.sort(out) + return out +end + +-- one manifest token -> the version ids it covers, or nil when it names no +-- game this engine knows: "red" | "gen1" | "all" +function ModTargets.expand(token) + if type(token) ~= "string" then return nil end + local key = token:lower():match("^%s*(.-)%s*$") + if key == "all" then return GameVersion.ORDER end + if GameVersion.VERSIONS[key] then return { key } end + local gen = key:match("^gen%s*(%d+)$") + if gen then + local list = ModTargets.generationVersions(tonumber(gen)) + if #list > 0 then return list end + end + return nil +end + +-- version-id lists are always ORDER-sorted and deduped, so two manifests that +-- say the same thing different ways encode and compare the same +local function fromSet(set) + local out = {} + for _, id in ipairs(GameVersion.ORDER) do + if set[id] then out[#out + 1] = id end + end + return out +end + +-- normalize a manifest `games` array; second return is the tokens that named +-- no game, which Manifest reports at its own api level +function ModTargets.normalize(list) + local set, unknown = {}, {} + for _, token in ipairs(type(list) == "table" and list or {}) do + local ids = ModTargets.expand(token) + if ids then + for _, id in ipairs(ids) do set[id] = true end + else + unknown[#unknown + 1] = tostring(token) + end + end + return fromSet(set), unknown +end + +function ModTargets.union(a, b) + local set = {} + for _, list in ipairs({ a or {}, b or {} }) do + for _, id in ipairs(list) do set[id] = true end + end + return fromSet(set) +end + +-- The pre-`games` reading of a manifest: Gen 1 always, Gen 2 only where the +-- author claimed gen2compat (src/mods/Manifest.lua). +function ModTargets.legacy(gen2compat) + local out = ModTargets.generationVersions(1) + if gen2compat then + return ModTargets.union(out, ModTargets.generationVersions(2)) + end + return out +end + +-- does a version-id list hold any game of that generation +function ModTargets.covers(versions, gen) + for _, id in ipairs(versions or {}) do + if GameVersion.generation(id) == gen then return true end + end + return false +end + +-- the version ids a validated manifest targets; Manifest.validate resolves +-- `games` at load, so this is the same answer everywhere +function ModTargets.versions(manifest) + local games = manifest and manifest.games + if type(games) == "table" and #games > 0 then return games end + return ModTargets.legacy(manifest and manifest.gen2compat) +end + +-- Does the mod target this game? `version` is a version id; pass nil with a +-- generation to ask about a whole generation (the loader's injected seam). +function ModTargets.supports(manifest, version, generation) + local versions = ModTargets.versions(manifest) + if version and GameVersion.VERSIONS[version] then + for _, id in ipairs(versions) do + if id == version then return true end + end + return false + end + return ModTargets.covers(versions, generation or GameVersion.generation()) +end + +-- What will actually happen here: the loader gates on this same answer, per +-- version (Loader:_gateGeneration), and the player's override forces past it, +-- so the two UIs report a run, not a claim. +function ModTargets.runsHere(manifest, version, generation, forced) + if forced then return true end + return ModTargets.supports(manifest, version, generation) +end + +-- "Gen 1" / "Gen 1+2" while a mod takes whole generations, the version names +-- ("Red/Gold") once it takes only some of one +function ModTargets.label(manifest) + local versions = ModTargets.versions(manifest) + local set = {} + for _, id in ipairs(versions) do set[id] = true end + local whole, partial = {}, false + for _, gen in ipairs(generations()) do + local all, any = true, false + for _, id in ipairs(ModTargets.generationVersions(gen)) do + if set[id] then any = true else all = false end + end + if any and all then whole[#whole + 1] = tostring(gen) + elseif any then partial = true end + end + if #whole > 0 and not partial then + return "Gen " .. table.concat(whole, "+") + end + local names = {} + for _, id in ipairs(versions) do + names[#names + 1] = GameVersion.info(id).label or id + end + if #names == 0 then return "No game" end + return table.concat(names, "/") +end + +-- the same label as an all-caps chip, for the launcher tag and the GB font +function ModTargets.chip(manifest) + return ModTargets.label(manifest):upper() +end + +-- one game's own name, for a line that has to say which one this is +function ModTargets.gameLabel(version) + local info = version and GameVersion.info(version) + return (info and info.label) or tostring(version) +end + +-- One launcher-voice line for a mod that does not target `version` +-- (src/mods/LauncherMods.lua statusFor). +function ModTargets.detail(manifest, version) + return ("For %s, not %s"):format(ModTargets.label(manifest), + ModTargets.gameLabel(version)) +end + +return ModTargets diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 3c033bbe..b72b85b0 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -214,7 +214,15 @@ end -- records are a feature -- but a patch key that is only a case/underscore -- variant of a schema field is the classic typo and gets rejected with a -- suggestion. -function Schemas.check(spec, registryName, id, value, mode) +-- +-- `generation` is optional and only ever narrows: passing it resolves the +-- per-generation shape first (Schemas.shapeFor), and omitting it validates +-- against the Gen 1 shape, which is what every Gen 1 call site wants and what +-- a caller already holding a derived spec has anyway. +function Schemas.check(spec, registryName, id, value, mode, generation) + if generation ~= nil then + spec = Schemas.shapeFor(registryName, spec, generation) + end if mode == "remove" or spec == nil then return true end -- register and patch are synonyms on a deep registry, so a partial -- payload is the normal case there and only override is a full value @@ -323,10 +331,10 @@ local function refsFor(spec, name, id, value) return refs end --- a structured target (battle_anims' per-kind subtables) hides its ids one --- level down, so the pristine scan asks the spec instead of the raw keys -local function baseEntries(registry, base) - local spec = registry.spec +-- a structured target (battle_anims' per-kind subtables, Gold's trainer +-- classes) hides its ids one level down, so the pristine scan asks the spec +-- instead of the raw keys +local function baseEntries(spec, base) if not spec.baseIds then return pairs(base) end local ids = spec.baseIds(base) local i = 0 @@ -357,13 +365,26 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + -- the shape this boot's generation validates by, so a Gen 2 record's + -- refs are read out of the Gen 2 fields (a species' `into`, not + -- `species`) instead of being missed entirely + local spec = Schemas.shapeFor(name, registry.spec, loader.generation) for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then for _, ref in ipairs(refsFor(spec, name, id, value)) do local refRegistry = Schemas.REGISTRIES[ref.registry] and loader.content[ref.registry] + -- A registry with no home in this generation has no id space to + -- check against: its base view resolves to nothing, so EVERY + -- reference into it would read as dangling. Gold's species carry a + -- growthRate and an evolution method like Red's do; the ids are + -- fine, it is the Gen 1 `growth_rates` / `evolution_methods` + -- namespaces that are not there to confirm them. Skipped for the + -- same reason an undeclared registry is: unknown, not wrong. + if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then + refRegistry = nil + end if refRegistry and refRegistry:get(ref.ref) == nil then problems[#problems + 1] = { owner = registry.owners[id], @@ -377,7 +398,7 @@ function Schemas.crossValidate(loader, data) if removed then local base = registry.base and registry.base() if base then - for id, value in baseEntries(registry, base) do + for id, value in baseEntries(spec, base) do if registry.ops[id] == nil then for _, ref in ipairs(refsFor(spec, name, id, value)) do local set = tombstoned[ref.registry] @@ -408,9 +429,307 @@ Schemas.ALIASES = { scripts = "map_scripts", ui = "screens" } -- pass skips it and stays zero-work on a mod-free boot Schemas.ENGINE = "engine" +-- ------- generation routing +-- +-- Registry NAMES are shared across generations on purpose: a mod writes +-- mod.content.pokemon whichever game is running, and mod.content.encounters +-- means "wild encounters" in both. What can differ is the Data path the +-- merge lands on, because Gold namespaces the tables whose Gen 1 counterpart +-- means something else (data.gen2Palettes beside data.palettes). +-- +-- One routing table per generation, read through Schemas.routing, and both +-- are read the same way: +-- +-- absent -> keeps spec.target in that generation +-- mapped to a path -> merges there instead +-- mapped to false -> no home in that generation; the write is taken, +-- dropped and reported +-- +-- That last case is the whole point of the manifest's gen2compat opt-in: a +-- mod that claims Gen 2 gets told which registry has no home there instead of +-- merging into a table nothing reads and appearing to work. It runs in both +-- directions, because the catalog now holds content BOTH ways round: Gold has +-- systems Red never had (the phone book, the decorations, the radio dial), and +-- those registries are the mirror image of the rows below -- declared once, +-- gated under GEN ONE, reported to a Red mod in the same sentence a Gold mod +-- gets about `tokens`. Schemas.GEN1 below Schemas.GEN2 carries them. +-- +-- The `false` rows used to have three causes and now have one. The first is +-- gone: Gold's overworld tables no longer load off disk into World fields -- +-- src/core/Game2.lua:load reads every one of them into game.data BEFORE it +-- calls mods:load(self.data), and src/world/gen2/World.lua:dataTable takes +-- them by reference and never copies, so a routed row merges into the very +-- table the world walks. The second is gone too: a registry whose Gen 2 +-- records are shaped differently now says so in its own spec (the gen2Fields / +-- gen2Keys layer below, resolved by Schemas.shapeFor), so routing it validates +-- a mod's record against the GEN 2 shape rather than against Red's. What is +-- left is the systems Gold has not reimplemented through a registry at all. +Schemas.GEN2 = { + -- Namespaced on Gold and merged there. The registry NAME stays shared -- + -- mod.content.maps means "maps" in both games -- and only the Data path + -- underneath it differs, which is the whole reason this table maps to paths + -- rather than renaming anything. Two id-space notes an author needs, and + -- docs/mod-api-gen2-compat.md spells out: Gold's `text` ids are ROM pointer + -- strings ("55:4067") rather than TEXT_* names, and a Gen 2 tileset carries + -- its walkability as `collision` where Gen 1 says `walkable`. + maps = "gen2Maps", tilesets = "gen2Tilesets", sprites = "gen2Sprites", + text = "gen2Text", + -- Namespaced AND differently shaped, and the shape is what these waited on. + -- Each carries a Gen 2 record schema in its catalog entry now, so the id + -- space is the one Gold actually keys by: the encounter KIND (.grass), the + -- trainer CLASS (one level into .classes, through gen2Write), a species id + -- or an ICON_ sheet name, and for palettes / battle_anims / constants the + -- target's own subtable names. + encounters = "gen2Encounters", trainers = "gen2Trainers", + palettes = "gen2Palettes", icons = "gen2Icons", + battle_anims = "gen2BattleAnims", constants = "gen2Constants", + -- Gold reimplements the system, and reads its rules back through the same + -- registry: src/battle/gen2/Battle.lua:statusRecordFor / moveEffectRecordFor, + -- Catching.recordFor, Ai.layersFor, Evolution.methodFor and + -- src/core/gen2/ItemEffects.lua:recordFor each read the merged table here + -- and fall back to their own module records when no loader ran. The vanilla + -- records at these paths are GOLD's, not Red's -- src/mods/Builtins.lua + -- swaps the registrant per generation, which it has to: both games call it + -- GREAT_BALL. + statuses = "gen2Statuses", move_effects = "gen2MoveEffects", + item_effects = "gen2ItemEffects", balls = "gen2Balls", + ai_classes = "gen2AiClasses", evolution_methods = "gen2EvolutionMethods", + -- The Gen 2-only six. They have no Gen 1 target to keep (their specs carry + -- none), so the routed path IS the only path they ever have, and the + -- Schemas.GEN1 rows below are what makes writing to one on Red a reported + -- drop rather than a merge into a table Red has never heard of. Two of them + -- merge onto a table that already exists when mods:load runs -- landmarks + -- onto the cache's own gen2Landmarks.landmarks, held_items onto the view + -- src/core/Game2.lua builds from data.items -- and the other four come + -- into existence AS the merge, seeded from their module's literals by + -- src/mods/Builtins.lua the way the battle-rule six are. + held_items = "gen2HeldItems", phone_contacts = "gen2PhoneContacts", + decorations = "gen2Decorations", apricorns = "gen2Apricorns", + landmarks = "gen2Landmarks.landmarks", radio_channels = "gen2RadioChannels", + -- Still no Gen 2 home. Every one of these is a system Gold reimplements + -- WITHOUT reading a registry: the Gen 1 target is still built and merged + -- into, but nothing in a Gold boot ever looks at it. Closing one is a + -- consumer change in the Gen 2 module first and a row here second, which is + -- exactly how battle_sprite_scales and render_pipelines came off this list + -- (see the note under it). + -- rulesets no Gen 2 ruleset dispatch exists + -- transitions Gold draws its own battle intro + -- (src/ui/gen2/BattleTransition.lua) and its STYLES table + -- is a boolean SET of the four cart wipes, not the + -- { frames, draw, sound, flash } record this registry + -- carries; there is no styleDef lookup for a mod id to + -- reach, so a registered style would fall back to vanilla + -- field the overworld grab bag; Gold's equivalents live in + -- gen2Maps and the VM's own tables + -- text_pointers Gen 1's TEXT_* indirection; Gold's text IS pointers + -- link_fields link play is Gen 1 only + rulesets = false, transitions = false, + field = false, text_pointers = false, link_fields = false, + -- battle_sprite_scales and render_pipelines are ABSENT from this table on + -- purpose: both keep the shared Gen 1 target because Gold reads the merged + -- table at that exact path. + -- battle_sprite_scales src/ui/gen2/BattleState.lua:imageScale reads + -- data.battle_sprite_scales with the same + -- image-then-species-then-default order as Gen 1's + -- BattleState.imageBattleScale / resolveBattleScale, + -- skipping the `_owners` bookkeeping row the same + -- way. Only the DEFAULT differs and neither side + -- reads it from here: Red's 32x32 back pics draw at + -- 2x, Gold's 48x48 ones fill their 6x6 box at 1x. + -- render_pipelines src/core/Game2.lua:load calls Pipelines.install + -- AFTER the merge, so data.render_pipelines is the + -- merged table, and Game2:draw composites `present` + -- through Pipelines.wantsPresent / Pipelines.present. + -- The `drawWorld` half is not composited yet (Gold's + -- overworld draws straight to the window rather than + -- into a canvas), and Game2 RETIRES a restored + -- drawWorld-only level rather than leaving it + -- switched on and rendering nothing; a mod that + -- registers only drawWorld is therefore inert on + -- Gold, which docs/mod-api-gen2-compat.md says in + -- those words. Gold also has no OPTION row for a + -- pipeline (Pipelines.rows is read only from + -- src/ui/OptionsMenu.lua), so a Gold player reaches + -- one by its hotkey. + -- src/script/gen2/Vm.lua is a bytecode VM over the cart's own opcodes, not + -- the Gen 1 row-list runner. `commands` IS routed (it is absent from this + -- table, so it keeps the shared data.commands target): the VM dispatches the + -- Opcodes.MOD_COMMAND row -- an op name with no cart byte behind it -- + -- through that merged table, so mod.commands:register works on both games. + -- data.gen2Scripts is that bytecode pool keyed by ROM pointer, so + -- `map_scripts` has no home there: a Lua row list merged into it is not + -- something the VM can run. (`tokens` used to sit on this line and does + -- not belong there -- TextBox.new runs TextBox.substitute on EVERY box in + -- both generations and substitute reads game.data.tokens, so the shared + -- target was already live on Gold. It keeps that target, absent from this + -- table, and src/core/Game2.lua seeds data.tokens with a copy of + -- TextBox.TOKENS so the merge cannot mutate the module table.) + map_scripts = false, + -- Everything not listed keeps its Gen 1 target and works on Gold today: + -- pokemon, moves, items, type_chart, audio + music/sfx/cries/map_songs, + -- screens (the Gen2* ids in src/ui/Screens.lua), strings, font and commands. +} + +-- The mirror of Schemas.GEN2: what a GEN 1 boot does with the registries that +-- only exist because Gold exists. Same three readings as the table above, and +-- only the third is used today -- there is no Gen 2-only registry with a +-- useful Red target to reroute to, because the systems themselves are absent +-- from Red rather than spelled differently there. +-- +-- held_items Red's items carry no held attributes at all; the whole +-- hold/trigger machinery is Gen 2 (src/battle/gen2/Battle.lua +-- heldEffect) +-- phone_contacts no Pokegear, no phone +-- decorations no bedroom PC decoration menu +-- apricorns no Kurt, no apricorn balls +-- landmarks Red's town map is a Gen 1 town-map table, not the +-- LANDMARK_* index space the Pokegear and the #DEX AREA +-- page share +-- radio_channels no radio +Schemas.GEN1 = { + held_items = false, phone_contacts = false, decorations = false, + apricorns = false, landmarks = false, radio_channels = false, +} + +-- The routing table for a generation: which one is consulted is the only +-- difference between the two directions. An unknown generation routes +-- nothing, so every registry keeps its catalog target. +local NO_ROUTING = {} + +function Schemas.routing(generation) + if generation == 2 then return Schemas.GEN2 end + if generation == 1 then return Schemas.GEN1 end + return NO_ROUTING +end + +-- The Data path `name` merges into for a generation, or nil when the registry +-- has no home there. +function Schemas.targetFor(name, spec, generation) + local routed = Schemas.routing(generation)[name] + if routed == nil then return spec.target end + return routed or nil +end + +-- true when the registry exists but this generation has nowhere to put it, +-- which is a different diagnostic from a registry that has no target at all +function Schemas.gatedFor(name, generation) + return Schemas.routing(generation)[name] == false +end + +-- ------- per-generation record shapes +-- +-- Routing says WHERE a registration lands; this says what a record at that +-- path LOOKS like. The two are separate questions and only the second one is +-- gating the rest of the catalog: Gold's tables are the Gen 2 ROM's own +-- layout, so a species carries specialAttack/specialDefense where Red carries +-- one `special`, wild encounters key by encounter kind and time of day rather +-- than by map, and the palette table is GBC four-colour rows in a dozen named +-- subtables. Validating any of those against the Gen 1 schema judges a mod's +-- record against the wrong shape, which is worse than refusing the write. +-- +-- So beside `value` / `fields` / `keys` / `keyValue` a spec may carry +-- `gen2Value` / `gen2Fields` / `gen2Keys` / `gen2KeyValue`, and beside +-- `semantics` / `extra` / `write` / `baseAt` / `baseIds` / `example` / +-- `notes` the matching `gen2*`. Absent means "the Gen 1 shape is right here +-- too", which is the common case and why most registries carry none of this. +-- The registry NAME, the verbs and (wherever the id space allows it) the ids +-- stay shared, exactly as the routing table keeps them shared. +-- +-- `gen2X = false` CLEARS the Gen 1 slot rather than setting it, the same +-- reading `false` has in the routing table above: battle_anims' Gen 1 `write` +-- routes ids into per-kind subtables by prefix, and under Gen 2 the ids ARE +-- the subtables, so the right Gen 2 write is the default one. No slot here +-- ever carries a meaningful `false` (they are functions, strings and tables), +-- so the two readings cannot collide. +-- +-- Schemas.shapeFor resolves it. It hands back the spec unchanged for Gen 1 +-- and for any registry with no Gen 2 shape; otherwise a derived spec with the +-- gen2* keys folded onto the canonical names, memoized per spec so the +-- resolve is one table lookup after the first call. Everything downstream -- +-- Schemas.check, Registry's fold and baseAt, the loader's merge and write -- +-- then reads one spec and never learns about generations. +local GEN2_SHAPE = { + gen2Value = "value", gen2Fields = "fields", gen2Keys = "keys", + gen2KeyValue = "keyValue", gen2Extra = "extra", + gen2Semantics = "semantics", gen2Write = "write", + gen2BaseAt = "baseAt", gen2BaseIds = "baseIds", + gen2Example = "example", gen2Notes = "notes", +} + +-- Schemas.check reads these four in a fixed order (keys/keyValue, then value, +-- then fields), so a Gen 2 shape that describes its records with `keys` must +-- clear the Gen 1 `value` rather than sit beside it: otherwise the first +-- branch that matches wins and the new schema is never consulted. +local VALUE_SLOTS = { value = true, fields = true, keys = true, keyValue = true } + +-- Weak keys: a derived spec lives exactly as long as the catalog entry it +-- came from, which in a headless harness is per require rather than forever. +-- Keyed by spec alone, which is sound because the catalog gives every +-- registry its own table -- the two ALIASES resolve to the canonical name +-- before anything reaches here, and `target` is the only name-dependent +-- field a derived spec carries. +local derivedSpecs = setmetatable({}, { __mode = "k" }) + +-- does this registry describe its Gen 2 records differently at all? +function Schemas.hasGen2Shape(spec) + if type(spec) ~= "table" then return false end + for source in pairs(GEN2_SHAPE) do + if spec[source] ~= nil then return true end + end + return false +end + +-- The spec to validate and merge `name` with under `generation`. Idempotent: +-- a derived spec carries no gen2* keys, so resolving one again returns it. +function Schemas.shapeFor(name, spec, generation) + if generation ~= 2 or not Schemas.hasGen2Shape(spec) then return spec end + local hit = derivedSpecs[spec] + if hit then return hit end + local out = {} + for key, value in pairs(spec) do out[key] = value end + local replacesValue = false + for source, slot in pairs(GEN2_SHAPE) do + if spec[source] ~= nil and VALUE_SLOTS[slot] then replacesValue = true end + end + if replacesValue then + for slot in pairs(VALUE_SLOTS) do out[slot] = nil end + end + for source, slot in pairs(GEN2_SHAPE) do + out[source] = nil + -- `or nil` is the clear: gen2Write = false leaves the slot empty + if spec[source] ~= nil then out[slot] = spec[source] or nil end + end + -- self-describing: a derived spec's `target` is the routed one, so a caller + -- holding it alone never reads the Gen 1 path by accident. targetFor stays + -- authoritative and stays idempotent over the result. + out.target = Schemas.targetFor(name, spec, generation) + derivedSpecs[spec] = out + return out +end + local R = {} Schemas.REGISTRIES = R +-- ------- shared Gen 2 leaves +-- +-- The ROM name spaces Gold's tables key by. They are enums rather than +-- f.str so a typo ("MORNING") fails at register time instead of writing a +-- subtable nothing ever reads; the ordered lists themselves ship as +-- data.gen2Constants (eggGroupOrder, trainerTypeOrder, ...). + +-- wild encounters, overworld palettes and the roof pair all bucket by time of +-- day; DARK is the fourth palette bucket and never an encounter one, so the +-- encounter maps take the three-value list (constants/time_of_day.asm) +local gen2Tod = f.enum{ "MORN", "DAY", "NITE" } +local gen2PaletteTod = f.enum{ "MORN", "DAY", "NITE", "DARK" } + +-- one GBC colour as the extractor writes it: a positional {r,g,b} triple +-- already expanded from 5-bit BGR to 0..255 (src/render/GbcPalette.lua) +local gen2Color = f.list(f.int(0, 255)) +-- a palette row. The OBJ rows the sprite and mon pics use carry two colours +-- (the cart supplies white and black), the BG rows carry all four. +local gen2PaletteRow = f.list(gen2Color) + R.pokemon = { semantics = "record", target = "pokemon", fields = { @@ -445,7 +764,61 @@ R.pokemon = { battleScaleFront = f.opt(f.numRange(0.25, 4.0)), battleScaleBack = f.opt(f.numRange(0.25, 4.0)), }, + -- Same registry, same target (data.pokemon), same species ids: only the + -- record differs, and it differs in four places, every one of them a real + -- Gen 2 change rather than an extractor spelling. Gen 2 splits `special` + -- into specialAttack/specialDefense (BaseData in pokegold's + -- data/pokemon/base_stats/), names the level-up table `levelMoves` and the + -- pic size `picSize`, has no separate level-1 move list (level 1 rows live + -- in levelMoves), and points an evolution at `into` rather than `species`. + -- Beside that it carries the breeding block Gen 1 has no analogue for + -- (eggGroups/eggMoves/eggSteps, genderRatio) and a held-item pair. + -- + -- Without this, mod.content.pokemon:register is unusable for a Gold + -- species -- every record fails on the missing `special` -- while patch + -- happens to work, which is the worst of both. + gen2Fields = { + id = f.str, name = f.str, dex = f.int(1), + index = f.opt(f.int(0, 255)), + types = f.list(f.id("type_chart")), + baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255), + defense = f.int(1, 255), speed = f.int(1, 255), + specialAttack = f.int(1, 255), + specialDefense = f.int(1, 255) }, + catchRate = f.int(0, 255), baseExp = f.int(0, 255), + growthRate = f.id("growth_rates"), growthRateId = f.opt(f.int(0, 255)), + levelMoves = f.list(f.rec{ level = f.int(1), move = f.id("moves") }), + tmhm = f.opt(f.list(f.id("moves"))), + -- the raw TM/HM bitfield bytes, kept beside the resolved list so a + -- re-export round-trips; the engine reads `tmhm` + tmhmRaw = f.opt(f.list(f.int(0, 255))), + evolutions = f.list(f.rec{ method = f.id("evolution_methods"), + into = f.id("pokemon"), + level = f.opt(f.int(1)), + item = f.opt(f.id("items")), + -- EVOLVE_HAPPINESS' window and EVOLVE_STAT's + -- attack-vs-defence test + time = f.opt(f.enum{ "ANYTIME", "MORNDAY", + "NITE" }), + comparison = f.opt(f.enum{ "ATK_LT_DEF", + "ATK_GT_DEF", + "ATK_EQ_DEF" }) }), + -- breeding: two egg groups (the raw byte packs both nibbles), the egg + -- move list, and the cycle count src/core/gen2/Breeding.lua counts down + eggGroups = f.opt(f.list(f.str)), eggGroupsRaw = f.opt(f.int(0, 255)), + eggMoves = f.opt(f.list(f.id("moves"))), eggSteps = f.opt(f.int(0)), + genderRatio = f.opt(f.int(0, 255)), + -- the two wild held items, in the ROM's own order (rare then common) + items = f.opt(f.list(f.id("items"))), + spriteFront = f.path, spriteBack = f.path, picSize = f.int(1, 7), + source = f.opt(f.str), + cry = f.opt(f.id("cries")), trueColor = f.opt(f.bool), + battleScaleFront = f.opt(f.numRange(0.25, 4.0)), + battleScaleBack = f.opt(f.numRange(0.25, 4.0)), + }, example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })', + gen2Example = 'mod.content.pokemon:patch("TOTODILE", ' + .. '{ baseStats = { specialAttack = 80 } })', } R.moves = { @@ -504,8 +877,16 @@ R.maps = { -- carries no palettes at all, so an id reference would fail validation for -- a perfectly good mod wherever there is no imported dataset. palette = f.opt(f.str), + -- destGroup / destMapNum are the ROM map-group pair Gen 2 carries beside + -- the destination it actually warps through (World:resolveWarp reads + -- destMap and destWarp, the same two keys Gen 1 does). Optional and + -- additive rather than a second warp shape: `maps` routes to + -- data.gen2Maps under Gen 2, so the records a mod patches there are the + -- extractor's own, and a strict rec would reject every one of them. warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0), - destMap = f.str, destWarp = f.int(0) })), + destMap = f.str, destWarp = f.int(0), + destGroup = f.opt(f.int(0)), + destMapNum = f.opt(f.int(0)) })), objects = f.opt(f.list(f.any)), signs = f.opt(f.list(f.any)), connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)), @@ -545,6 +926,36 @@ R.tilesets = { example = 'mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })', } +-- ------- Gen 2 wild encounters +-- +-- One wild slot. A fishing slot's species may be the literal 0 the ROM uses +-- for "no fish here, roll the map's water table instead" (pokegold +-- data/wild/fish.asm), which is why species is a union rather than a bare id. +local gen2Slot = f.rec{ level = f.int(1), species = f.id("pokemon") } +-- the sentinel row carries level 0 as well as species 0, so both floors drop +local gen2FishSlot = f.rec{ chance = f.int(0, 255), level = f.int(0), + species = f.union{ f.id("pokemon"), f.int(0, 0) } } +-- Headbutt/Rock Smash slots. species is optional and the level floor is 0 +-- because TreeMonSet_Rock has no `rare` half in the ROM (pokegold +-- data/wild/treemons.asm ends the table after the common rows), so the four +-- Rock Smash maps that point at it carry a rare table read out of whatever +-- follows: levels past 100 and rows with no species at all. Rejecting it +-- would mean the extractor's own table could never be re-registered. +local gen2TreeSlot = f.rec{ chance = f.int(0, 255), level = f.int(0), + species = f.opt(f.id("pokemon")) } + +-- a grass row: one encounter rate and one seven-slot table PER time of day, +-- which is the whole reason this cannot share the Gen 1 shape +local gen2GrassRow = f.rec{ + map = f.opt(f.str), + rates = f.map(gen2Tod, f.int(0, 255)), + slots = f.map(gen2Tod, f.list(gen2Slot)), +} +-- water has no time-of-day split: one rate, one three-slot table +local gen2WaterRow = f.rec{ + map = f.opt(f.str), rate = f.int(0, 255), slots = f.list(gen2Slot), +} + R.encounters = { semantics = "record", target = "encounters", fields = { @@ -556,7 +967,47 @@ R.encounters = { slots = f.list(f.rec{ level = f.int(1), species = f.id("pokemon") }) }), }, + -- Gold keys wild encounters by encounter KIND first and by map second + -- (data.gen2Encounters.grass.ROUTE_29), because the cart ships one table + -- per kind and a map appears in as many of them as it has water, swarms, + -- fishing spots and headbuttable trees. There is no per-map record to key + -- the registry by, so the id is the kind and `patch` folds per map instead + -- of replacing the kind's whole table. + -- + -- Semantics stay "record" rather than becoming "deep" even though the id is + -- a namespace: a slot table is an ORDERED list whose position is the + -- encounter roll, and Merge.deepMerge appends lists under "deep" semantics, + -- so a mod rewriting a seven-slot table would get a fourteen-slot one. + gen2Keys = { + grass = f.map(f.str, gen2GrassRow), + -- the swarm variants shadow their base table while a swarm is running + swarmGrass = f.map(f.str, gen2GrassRow), + water = f.map(f.str, gen2WaterRow), + swarmWater = f.map(f.str, gen2WaterRow), + -- fishing: a map's rod points at a named group, and the group carries a + -- chance-ordered table per rod + fishGroups = f.map(f.str, f.rec{ + id = f.opt(f.str), index = f.opt(f.int(0, 255)), + chance = f.int(0, 255), + old = f.list(gen2FishSlot), good = f.list(gen2FishSlot), + super = f.list(gen2FishSlot) }), + -- headbutt: map -> tree set id, and the set's common/rare tables. rocks + -- is the same indirection for Rock Smash. + trees = f.map(f.str, f.str), + rocks = f.map(f.str, f.str), + treeSets = f.map(f.str, f.rec{ common = f.list(gen2TreeSlot), + rare = f.list(gen2TreeSlot) }), + -- the Bug-Catching Contest pool (min/max level, not one level per slot) + bugContest = f.list(f.rec{ species = f.id("pokemon"), + min = f.int(1), max = f.int(1), + chance = f.int(0, 255) }), + -- where a roaming beast may walk next, keyed by the map it is on + roamMaps = f.list(f.rec{ map = f.str, to = f.list(f.str) }), + source = f.str, generation = f.int(1), + }, example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })', + gen2Example = 'mod.content.encounters:patch("grass", ' + .. '{ ROUTE_29 = { rates = { NITE = 40 } } })', } R.trainers = { @@ -582,7 +1033,64 @@ R.trainers = { -- battles. The victory jingle stays kind-based. battleTheme = f.opt(f.id("music")), }, + -- Gold hangs its rosters off data.gen2Trainers.classes, one record per + -- trainer CLASS carrying every named trainer of that class. The id space + -- is still the class id, so the registry keeps the Gen 1 call shape -- + -- mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) -- and only the + -- one level of indirection to `.classes` is new. That is the same trick + -- battle_anims plays with its per-kind subtables, and it is why these three + -- callbacks exist rather than a `classes` key nobody would guess. + gen2BaseAt = function(base, id) + return base.classes and base.classes[id] or nil + end, + gen2BaseIds = function(base) + local ids = {} + for id in pairs(base.classes or {}) do ids[#ids + 1] = id end + return ids + end, + gen2Write = function(target, registry) + local classes = target.classes + if not classes then + classes = {} + target.classes = classes + end + local tombstones = {} + for id in pairs(registry.ops) do + local value = registry:get(id) + if value == nil then + tombstones[#tombstones + 1] = id + else + classes[id] = value + end + end + for _, id in ipairs(tombstones) do classes[id] = nil end + end, + gen2Fields = { + id = f.opt(f.str), name = f.str, + index = f.opt(f.int(0, 255)), + baseMoney = f.opt(f.int(0)), + -- the class's battle theme; Gen 1 spells the same idea `battleTheme`, + -- but this is the extractor's own key and a strict rename would reject + -- every one of Gold's 66 classes + encounterMusic = f.opt(f.id("music")), + -- the items the class's AI may use mid-battle, and the seven raw AI + -- bytes behind them (pokegold data/trainers/attributes.asm) + items = f.opt(f.list(f.id("items"))), + attributes = f.opt(f.list(f.int(0, 255))), + -- one entry per named trainer of the class. trainerType decides which + -- optional party fields the cart actually stores, so `moves` and `item` + -- are optional here rather than four party shapes in a union. + trainers = f.list(f.rec{ + id = f.opt(f.str), name = f.str, index = f.opt(f.int(0, 255)), + trainerType = f.opt(f.enum{ "TRAINERTYPE_NORMAL", "TRAINERTYPE_MOVES", + "TRAINERTYPE_ITEM", + "TRAINERTYPE_ITEM_MOVES" }), + party = f.list(f.rec{ level = f.int(1), species = f.id("pokemon"), + item = f.opt(f.id("items")), + moves = f.opt(f.list(f.id("moves"))) }) }), + }, example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })', + gen2Example = 'mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })', } R.sprites = { @@ -605,7 +1113,32 @@ R.sprites = { -- the ROM (which is what `source` documents on imported records). paletteSource = f.opt(f.str), }, + -- Same name, same ids, and (unlike the rest of this section) already + -- routed: `sprites` merges into data.gen2Sprites and Gold walks that very + -- table. The Gen 1 schema accepts a Gen 2 record only because the extra + -- keys fall through as unknown-but-preserved, which means none of them is + -- checked -- a mod could write paletteId = "blue" and find out at draw + -- time. This types them. + gen2Fields = { + id = f.opt(f.str), image = f.path, frames = f.int(1), + walker = f.opt(f.bool), trueColor = f.opt(f.bool), + paletteSource = f.opt(f.str), + -- the OBJ palette this sprite draws with, by name and by the slot index + -- src/world/gen2/Palettes.lua indexes into (PAL_OW_RED is slot 0) + palette = f.opt(f.str), paletteId = f.opt(f.int(0, 7)), + -- how the overworld animates it: WALKING_SPRITE has the four facings and + -- a step cycle, STANDING_SPRITE only the facings, STILL_SPRITE one frame, + -- POKEMON_SPRITE the party-icon pair (pokegold constants/sprite_constants) + spriteType = f.opt(f.enum{ "WALKING_SPRITE", "STANDING_SPRITE", + "STILL_SPRITE", "POKEMON_SPRITE" }), + -- a POKEMON_SPRITE names the species it follows and the party icon it + -- borrows its art from + species = f.opt(f.id("pokemon")), icon = f.opt(f.str), + source = f.opt(f.str), + }, example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', + gen2Example = 'mod.content.sprites:patch("SPRITE_BEAUTY", ' + .. '{ palette = "PAL_OW_RED", paletteId = 0 })', } R.text = { @@ -839,7 +1372,48 @@ R.battle_anims = { into[key] = registry:get(id) end end, + -- Gold's battle animations are the cart's own bytecode, not a Lua sequence: + -- data.gen2BattleAnims is a script POOL keyed by ROM pointer plus the name + -- tables that index into it (a move id or an ANIM_* id resolves to a + -- pointer), and the object/frameset/OAM/graphics tables the scripts spawn + -- from. src/battle/gen2/AnimRunner.lua walks exactly those. The id is the + -- table, and `patch` adds one object without restating the pool. + -- + -- The Gen 1 write/baseAt/baseIds trio is cleared rather than reused: it + -- routes an id into a per-kind subtable by prefix, and here the ids ARE the + -- subtables, so the plain record placement is the correct one. + gen2Write = false, gen2BaseAt = false, gen2BaseIds = false, + gen2Keys = { + -- pointer -> the decoded command rows the runner steps; each row is a + -- verb string followed by its operands + scripts = f.map(f.str, f.list(f.list(f.any))), + -- the pool in ROM order, which is what a re-export writes back + scriptOrder = f.list(f.str), + -- move id -> script pointer, and ANIM_* id -> script pointer + moves = f.map(f.str, f.str), + ids = f.map(f.str, f.str), + -- an animation object: which graphics, palette, frameset and update + -- function it spawns with + objects = f.map(f.str, f.rec{ gfx = f.str, palette = f.str, + frameset = f.str, func = f.str, + fixY = f.opt(f.int(0, 255)), + flags = f.opt(f.int(0, 255)) }), + -- a frameset is its own little row list (frame / wait / delete) + framesets = f.map(f.str, f.list(f.list(f.any))), + -- OAM: the sprite rectangle an object draws, and the VRAM tile it starts + -- at. x/y are the cart's unsigned bytes, so 240 means -16. + oamsets = f.map(f.str, f.rec{ vtile = f.int(0, 255), + sprites = f.list(f.rec{ + x = f.int(0, 255), y = f.int(0, 255), + tile = f.int(0, 255), + attr = f.int(0, 255) }) }), + gfx = f.map(f.str, f.rec{ image = f.path, tiles = f.int(1), + wide = f.int(1) }), + bank = f.int(0), source = f.str, generation = f.int(1), + }, example = 'mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } })', + gen2Example = 'mod.content.battle_anims:patch("moves", ' + .. '{ SHADOW_BALL = "5e86" })', } R.transitions = { @@ -1052,7 +1626,45 @@ R.palettes = { return ("needs exactly 4 colors, got %d"):format(#colors) end end, + -- Gold's palette table is not a flat name -> four colours map: the GBC has + -- eight BG and eight OBJ slots and the cart reloads them per context, so + -- the extractor writes one subtable per context (mon pics with their shiny + -- twin, trainer pics, the BG rows a map's environment indexes into, the + -- overworld OBJ rows per time of day, the town roof pair, the HP and EXP + -- bars). The id is the context, so a mod that recolours one species + -- patches `pokemon` and leaves the other 250 alone. The Gen 1 four-colour + -- `extra` is cleared: it reads the record as one palette, and here a record + -- is a whole subtable of them. + gen2Extra = false, + gen2Keys = { + -- every species has both a normal and a shiny row; the shiny one is what + -- src/render/GbcPalette.lua swaps in on a shiny battler + pokemon = f.map(f.str, f.rec{ normal = gen2PaletteRow, + shiny = gen2PaletteRow }), + trainers = f.map(f.str, gen2PaletteRow), + -- the BG rows, indexed by number: `environments` names eight of them per + -- environment per time of day, which is how a map gets its palette + bg = f.list(gen2PaletteRow), + environments = f.map(f.str, f.map(gen2PaletteTod, f.list(f.int(0)))), + -- the eight overworld OBJ rows per time of day; a sprite's paletteId + -- indexes this + objects = f.map(gen2PaletteTod, f.list(gen2PaletteRow)), + -- one pair per roof group (keyed by the group number, 0 included), and + -- the BG slot the roof colours are written into + roofs = f.map(f.int(0), f.rec{ mornDay = gen2PaletteRow, + nite = gen2PaletteRow }), + roofSlot = f.int(0, 7), + hpBar = f.map(f.enum{ "green", "yellow", "red", "blue" }, gen2PaletteRow), + expBar = gen2PaletteRow, + partyMenu = f.list(gen2PaletteRow), + battleObjects = f.map(f.str, gen2PaletteRow), + -- the ordered name lists the numeric indices above resolve through + daytimes = f.list(f.str), slotNames = f.list(f.str), + source = f.str, generation = f.int(1), + }, example = 'mod.content.palettes:override("MEWMON", { {255,255,255}, ... })', + gen2Example = 'mod.content.palettes:patch("pokemon", ' + .. '{ TOTODILE = { shiny = { {255,255,255}, {255,0,0} } } })', } -- keyed by species id, unlike the vanilla byDex array: a species past the @@ -1061,10 +1673,63 @@ R.palettes = { -- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD, -- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a -- { image = <bundled file path>, frames? } table of your own art. +-- Gold splits the same idea in two: data.gen2Icons.icons is the 39 icon +-- SHEETS (each its own two-frame image) and data.gen2Icons.species is the +-- species -> sheet name assignment. Both halves keep the Gen 1 id space -- +-- a species id names an assignment, a sheet id names a sheet -- so one +-- registry serves both, routed by the ICON_ prefix every sheet name carries. +-- Two id forms in one registry is the same shape font and battle_anims use. +local function gen2IconIsSheet(id) + return tostring(id):match("^ICON_") ~= nil +end + R.icons = { semantics = "record", target = "icons.bySpecies", value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, + gen2Value = f.union{ + -- the assignment form: a species id mapped to a sheet name + f.str, + -- the sheet form: width/height are the sheet's pixel size, and every + -- vanilla sheet is a 16x32 two-frame strip + f.rec{ id = f.opt(f.str), index = f.opt(f.int(0, 255)), image = f.path, + width = f.int(1), height = f.int(1), frames = f.int(1) }, + }, + gen2Extra = function(id, value) + if gen2IconIsSheet(id) then + if type(value) ~= "table" then + return "an ICON_ id is a sheet and needs an image, width, height and frames" + end + elseif type(value) ~= "string" then + return "a species id takes the NAME of an ICON_ sheet, not a sheet" + end + end, + gen2BaseAt = function(base, id) + if gen2IconIsSheet(id) then return base.icons and base.icons[id] or nil end + return base.species and base.species[id] or nil + end, + gen2BaseIds = function(base) + local ids = {} + for id in pairs(base.icons or {}) do ids[#ids + 1] = id end + for id in pairs(base.species or {}) do ids[#ids + 1] = id end + return ids + end, + gen2Write = function(target, registry) + local sheets, species = target.icons, target.species + if not sheets then + sheets = {} + target.icons = sheets + end + if not species then + species = {} + target.species = species + end + for _, id in ipairs(registry.order) do + local into = gen2IconIsSheet(id) and sheets or species + into[id] = registry:get(id) + end + end, example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }', + gen2Example = 'mod.content.icons:override("TOTODILE", "ICON_MONSTER")', } -- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a @@ -1178,6 +1843,44 @@ R.tokens = { -- ------- deep registries: id is a top-level key of the target table +-- Gold's `constants` is not the Gen 1 rule block at all: it is the ROM's own +-- ordered name lists, one per enum the cart indexes by number. A script +-- opcode that says "special 12" or an animation that says "object 41" is +-- resolved through these, so replacing an entry renames what that number +-- means. Every one of them is a dense list of ids in ROM order, which is why +-- they can be built from a name list instead of restated one by one. +local GEN2_CONSTANT_ORDERS = { + "battleAnimBgPaletteOrder", "battleAnimFramesetOrder", "battleAnimFuncOrder", + "battleAnimGfxOrder", "battleAnimOamsetOrder", "battleAnimObPaletteOrder", + "battleAnimObjectOrder", "battleBgEffectOrder", "cmdQueueOrder", + "decoDescOrder", "eggGroupOrder", "environmentOrder", "evolveMethodOrder", + "fishGroupOrder", "floorOrder", "growthRateOrder", "heldEffectOrder", + "iconOrder", "itemMenuOrder", "itemOrder", "landmarkOrder", + "mapCallbackOrder", "mapOrder", "moveEffectOrder", "moveOrder", "musicOrder", + "paletteOrder", "phoneContactOrder", "pocketOrder", "sfxOrder", "spawnOrder", + "specialCallOrder", "specialOrder", "speciesOrder", "spriteOrder", + "stdScriptOrder", "tilesetOrder", "tradeDialogOrder", "tradeGenderOrder", + "trainerClassOrder", "trainerTypeOrder", "treeMonSetOrder", +} + +local gen2ConstantKeys = { + -- the map table the group/number pair in a warp resolves through + mapGroups = f.list(f.rec{ group = f.int(0), map = f.int(0), name = f.str, + width = f.int(1), height = f.int(1) }), + -- class id -> its named trainers, in the order the class's table stores them + trainerClassMembers = f.map(f.str, f.list(f.str)), + -- type id -> its ROM byte; the only one of these that is a lookup rather + -- than an ordered list, because the type numbers are not contiguous + types = f.map(f.str, f.int(0)), + -- counts the extractor stamps beside the lists + itemNameCount = f.int(0), numOverworldSprites = f.int(0), + spritePokemon = f.int(0), + source = f.str, generation = f.int(1), +} +for _, name in ipairs(GEN2_CONSTANT_ORDERS) do + gen2ConstantKeys[name] = f.list(f.str) +end + -- The rules the engine used to hard-code as Kanto/Red literals. Keys the -- importer does not stamp are seeded with their vanilla value at data load -- (src/core/Data.lua) so a patch always has something to fold over. @@ -1195,7 +1898,16 @@ R.constants = { hmMoves = f.list(f.id("moves")), encounterBuckets = f.list(f.int(1, 256)), }, + -- Gold's keys are ordered lists where position IS the id a script byte + -- resolves through, so they must replace rather than append -- which is + -- what "deep" semantics would do to them (Merge.deepMerge concatenates + -- lists there, and Gen 1's `field` rows genuinely want that). A key + -- neither catalog names is still a mod's own data and merges as-is. + gen2Semantics = "record", + gen2Keys = gen2ConstantKeys, example = 'mod.content.constants:patch("levelCap", 80)', + gen2Example = 'mod.content.constants:patch("speciesOrder", ' + .. '{ [252] = "MODMON" })', } -- The overworld's data grab bag. Only the keys this milestone routes are @@ -1267,6 +1979,150 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +-- ------- Gen 2 only content +-- +-- The mirror of the gated rows in Schemas.GEN2: six systems Gold has and Red +-- does not, so there is no Gen 1 table to share a target with and no Gen 1 +-- consumer to read one. Each spec therefore carries NO `target` at all -- the +-- routed Schemas.GEN2 path is its only home -- and a Schemas.GEN1 row of +-- `false`, which is what turns a Red mod's write into the same reported drop a +-- Gold mod gets for `tokens` instead of a silent merge into a namespace +-- nothing on Red would ever read. +-- +-- Names stay plain for the same reason hook and event names do: `decorations` +-- is what the thing is called, and a "gen2Decorations" registry NAME would be +-- a namespace no mod could ever share if Gen 1 grew the system later. Only +-- the Data path underneath carries the gen2 prefix. + +-- data/items/attributes.asm's last two columns, split out of the item record +-- so a mod can give an item a held behaviour without owning the whole item. +-- src/core/Game2.lua seeds the merge target from data.items and writes the +-- merged rows back onto it, and src/battle/gen2/Battle.lua's heldEffect (the +-- one read all eight held-item sites go through, and the held_item.trigger +-- hook's own site) reads it from there. +R.held_items = { + semantics = "record", + fields = { + -- the HELD_* name the battle compares against, out of + -- data.gen2Constants.heldEffectOrder; a mod may invent its own and steer + -- it from the held_item.trigger hook + heldEffect = f.str, + -- ItemAttributes' parameter byte: the boost percentage, the heal amount, + -- the BrightPowder odds -- whatever the effect reads it as + heldParameter = f.opt(f.int(0, 255)), + }, + example = 'mod.content.held_items:override("LEFTOVERS", ' + .. '{ heldEffect = "HELD_LEFTOVERS", heldParameter = 0 })', +} + +-- data/phone/phone_contacts.asm, one record per PHONE_* row. The id space is +-- data.gen2Constants.phoneContactOrder, so PHONE_YOUNGSTER_JOEY names the row +-- the cart calls PHONE_YOUNGSTER_JOEY; `index` is that row's byte, which is +-- what the save's contact list holds and what src/core/gen2/Phone.lua keys +-- every one of its own lookups by. The four PHONE_UNUSED const_skip holes are +-- not registered -- they are copies of the wrong-number filler row, and one id +-- cannot name four of them. +R.phone_contacts = { + semantics = "record", + fields = { + index = f.int(0), + -- non-trainer rows (MOM, BILL, ELM, the BIKE SHOP) carry a PHONECONTACT_* + -- number instead of a trainer; trainer rows carry the class and the + -- roster member, which is what the rematch machinery and the caller's + -- name are looked up by + number = f.opt(f.int(0, 255)), + class = f.opt(f.str), member = f.opt(f.str), + map = f.opt(f.id("maps")), + -- the SCRIPT1 / SCRIPT2 time masks: MORN | DAY | NITE, 0 for "never" + calleeTime = f.opt(f.int(0, 7)), callerTime = f.opt(f.int(0, 7)), + -- the script LABEL (Phone.SCRIPT_KEYS resolves it) and, once the cache + -- has been read, the "<bank>:<addr>" pointer it resolved to + callee = f.opt(f.str), caller = f.opt(f.str), + calleeKey = f.opt(f.str), callerKey = f.opt(f.str), + }, + example = 'mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", ' + .. '{ map = "ROUTE_31" })', +} + +-- data/decorations/attributes.asm, one record per DECO_* row. The cart's +-- decoration constants are a bare const_def block with no name table behind +-- them -- nothing in the ROM spells DECO_FEATHERY_BED -- so the id is the +-- attribute row's own index, written "deco:<n>" the way battle_anims writes +-- "subanim:<n>". That index IS wMenuSelection, which is what every caller +-- passes src/core/gen2/Decorations.lua. +R.decorations = { + semantics = "record", + fields = { + -- constants/deco_constants.asm decoration types: 1 PLANT, 2 BED, + -- 3 CARPET, 4 POSTER, 5 DOLL, 6 BIGDOLL. The type decides how GetDecoName + -- spells the row and whether `sprite` is a block id or a sprite one. + type = f.int(1, 6), + name = f.str, + -- DECOATTR_ACTION, as the Decorations.ACTIONS key rather than the + -- jumptable index; nil on the CANCEL row alone + action = f.opt(f.str), + -- DECOATTR_EVENT_FLAG: the wEventFlags bit that says the player owns it + flag = f.int(0), + -- DECOATTR_SPRITE: a BLOCK id for the four kinds the map paints, a + -- SPRITE_* byte for the four an object stands on + sprite = f.int(0, 255), + }, + example = 'mod.content.decorations:patch("deco:2", { name = "COZY" })', +} + +-- data/items/apricorn_balls.asm. Id = the apricorn item, because that is what +-- the player hands Kurt and what FindApricornsInBag walks the bag for; +-- `index` is the row's position in that table, which is load bearing twice +-- (Kurt's menu order and the checkevent chain in maps/KurtsHouse.asm). +R.apricorns = { + semantics = "record", + fields = { + apricorn = f.id("items"), ball = f.id("items"), + -- constants/event_flags.asm index of this apricorn's EVENT_GAVE_KURT_* + event = f.int(0), + index = f.int(1), + }, + example = 'mod.content.apricorns:override("RED_APRICORN", ' + .. '{ apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, index = 1 })', +} + +-- data/maps/landmarks.asm. The town-map places, which on Gold are one index +-- space shared by the Pokegear MAP card, the #DEX AREA page and every map +-- header's `landmark` byte. The merge lands inside the cache's own landmark +-- table (gen2Landmarks.landmarks), so a registered record is one the map card +-- can already draw. +R.landmarks = { + semantics = "record", + fields = { + id = f.opt(f.str), + -- the two-line name the town map prints, "\n" and all + name = f.str, + -- the marker's tile position on the 20x18 town map + x = f.int(0), y = f.int(0), + -- LANDMARK_*: the byte a map header carries, and what + -- src/core/gen2/Nests.lua's region split reads + index = f.int(0), + }, + example = 'mod.content.landmarks:patch("LANDMARK_ROUTE_29", { x = 12 })', +} + +-- PlayRadioStationPointers (engine/pokegear/pokegear.asm). Id = the station +-- the dial resolves to, which is the LoadStation_* id the show state machine +-- is keyed by; `channel` is its MAPRADIO_* dial position, the byte a wall +-- radio's `setval` passes to the MapRadio special. Position 0 is not a +-- station: it resolves by region and time of day, so no record claims it. +R.radio_channels = { + semantics = "record", + fields = { + channel = f.int(0, 255), + -- the name quoted in the text box; without one the Pokegear's own + -- STATION_NAMES row is used, which is where the vanilla eight get theirs + name = f.opt(f.str), + }, + example = 'mod.content.radio_channels:register("PIRATE_RADIO", ' + .. '{ channel = 9, name = "PIRATE RADIO" })', +} + -- ------- persistence -- compose, keyed by the owning mod id: the runner walks each owner's chain diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index fc89cdc9..e4b50976 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -76,6 +76,22 @@ function Sprites.playerPath(data, side, opts) trueColor = false, data = data, } + return Sprites.playerPic(path, ctx) +end + +-- Raise player.sprite over an ALREADY-resolved path. Gold's trainer art is +-- not in field.playerPics -- its back pic comes off gen2MenuGfx.battleHud, its +-- card and Hall of Fame off their own tables -- so its call sites resolve +-- their own path and hand it here, which keeps one hook name, one payload and +-- one mod source across both generations (src/ui/gen2/BattleState.lua). +-- ctx wants { side, kind, demo, oakDemo, battle, trueColor, data }. +-- Returns path, trueColor. +function Sprites.playerPic(path, ctx) + ctx = ctx or {} + ctx.side = ctx.side == "back" and "back" or "front" + ctx.kind = ctx.kind or "battle" + ctx.demo = ctx.demo and true or false + ctx.oakDemo = ctx.oakDemo and true or false if path and Runtime.wantsHook("player.sprite") then local hooked = Runtime.call("player.sprite", samePath, path, ctx) if type(hooked) == "string" and hooked ~= "" then path = hooked end diff --git a/src/render/Font.lua b/src/render/Font.lua index 3b59cffb..bb737baf 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -20,6 +20,11 @@ local Font = {} local GLYPH = 8 +-- engine/gfx/load_font.asm:29 LoadFrame: TEXTBOX_FRAME_TILES tiles at $79, one +-- row of Frames per wTextboxFrame. +local FRAME_BASE = 0x79 +local FRAME_TILES = 6 + -- TTF glyph codes are the Unicode codepoint offset far above any page base, -- so they flow through the same span/encode/drawCode pipeline as tiles. local TTF_BASE = 0x400000 @@ -35,6 +40,7 @@ Font.PLAINPIXEL_SIZE = 15 local state local loadedFrom +local currentFrame = 1 -- the two vanilla pages as the legacy def spells them, so a cache that -- predates the pages table still loads and a mod that registers only one @@ -49,6 +55,17 @@ local function pagesOf(def) pages.extra = { image = def.imageExtra, base = def.extraBase or 0x60, glyphsPerRow = def.glyphsPerRow or 16 } end + -- Gen 2 keeps two sheets for the same VRAM slot: LoadFontsExtra puts + -- FontExtra at $60 and LoadFontsBattleExtra puts FontBattleExtra there + -- instead. They are genuinely different glyphs -- $6e is "Lv" in one and + -- the bold ":L" in the other -- so the battle sheet is loaded as its own + -- page and Font.useBattleExtra swaps which one $60 resolves to. + if def.imageBattleExtra then + pages.battleExtra = { image = def.imageBattleExtra, + base = def.extraBase or 0x60, + glyphsPerRow = def.glyphsPerRow or 16, + inactive = true } + end for id, page in pairs(def.pages or {}) do if type(page) == "table" and page.image then pages[id] = page end end @@ -100,9 +117,34 @@ function Font.load(data) local entry = { id = id, image = img, quads = quads, base = page.base, advance = page.advance or GLYPH } state.pages[id] = entry - state.order[#state.order + 1] = entry + -- An inactive page is loaded but not in the resolution order until + -- something swaps it in; see Font.useBattleExtra. + if not page.inactive then + state.order[#state.order + 1] = entry + end end end + -- Frames as its own sheet, one row per style (gfx/font.asm:10). Without it + -- the extra page's baked-in row 0 answers $79-$7e, which is frame 1. + state.frameBase = def.frameBase or FRAME_BASE + state.frameTiles = def.frameTiles or FRAME_TILES + if def.imageFrames then + local ok, img = pcall(Assets.image, def.imageFrames) + if ok and img then + local iw, ih = img:getDimensions() + state.framePages = {} + for row = 0, math.floor(ih / GLYPH) - 1 do + local quads = {} + for t = 0, state.frameTiles - 1 do + quads[t] = love.graphics.newQuad(t * GLYPH, row * GLYPH, + GLYPH, GLYPH, iw, ih) + end + state.framePages[row + 1] = { id = "frames", image = img, + quads = quads, base = state.frameBase, advance = GLYPH } + end + end + end + -- highest base first: a code resolves against the last page that starts -- at or below it, which is exactly what the old main/extra chain did table.sort(state.order, function(a, b) return a.base > b.base end) @@ -188,15 +230,61 @@ end Assets.register(Font.invalidate) +-- How many tiles LoadFontsBattleExtra actually swaps: `lb bc, BANK(...), 25` +-- covers $60-$78 and then `jr LoadFrame` puts the textbox frame back at +-- $79-$7e and the blank at $7f (engine/gfx/load_font.asm). The border glyphs +-- are therefore the SAME tiles on a battle-sheet screen as everywhere else, +-- which is why the swap stops short of them. +local BATTLE_EXTRA_TILES = 25 + -- the page a glyph code draws from, or nil when nothing covers it local function pageFor(code) if not state then return nil end + -- LoadFrame runs after both extra sheets, so $79-$7e is the selected frame + -- whatever else holds the $60 slot (load_font.asm:20, :27). + local frames = state.framePages + if frames and code >= state.frameBase + and code < state.frameBase + state.frameTiles then + local page = frames[currentFrame] + if page then return page end + end + if state.battleExtra and state.pages.battleExtra then + local swap = state.pages.battleExtra + if code >= swap.base and code < swap.base + BATTLE_EXTRA_TILES then + return swap + end + end for _, page in ipairs(state.order) do if code >= page.base then return page end end return nil end +-- LoadFontsBattleExtra / LoadFontsExtra: which sheet the $60-$7f slot holds. +-- The battle screen and the party menu load the battle sheet, everything else +-- the normal one. Returns the previous setting so a caller can restore it. +function Font.useBattleExtra(on) + if not state then return false end + local was = state.battleExtra or false + state.battleExtra = on and true or false + return was +end + +function Font.battleExtraActive() + return state ~= nil and state.battleExtra == true +end + +-- engine/menus/options_menu.asm:475 UpdateFrame -> LoadFontsExtra -> LoadFrame. +-- Module state, not `state`: applyOptions runs before Font.load on boot +-- (src/core/Game2.lua Game2:load). +function Font.setFrame(index) + currentFrame = math.floor(tonumber(index) or 1) +end + +function Font.frameIndex() + return currentFrame +end + local SPACE = 0x7F -- Decode one UTF-8 sequence: codepoint and the index of its last byte, or @@ -242,6 +330,15 @@ local function ttfChar(ttf, code) return ch end +-- Text commands that place a fixed string rather than one tile. '#' is +-- charmap.asm $54, and home/text.asm's handler for it writes the four +-- characters "POKé" -- which is why the cart's own strings spell POKéMON as +-- `db " #MON"` (data/credits_strings.asm Credits_Staff) and why a +-- hand-ported string in this port may too. One glyph per replacement +-- character comes back out of Font.split, all of them pinned to the byte the +-- command sits on so a cut never lands inside the expansion. +local MACRO_TEXT = { ["#"] = "POK\xc3\xa9" } + -- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per -- drawn glyph, code nil when the charmap has nothing. A span is a whole -- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature @@ -260,46 +357,58 @@ function Font.split(text) local ttf = state and state.ttf local i, n = 1, #text while i <= n do - local span - local candidates = state and state.byFirstByte[text:byte(i)] - if candidates then - for _, entry in ipairs(candidates) do - local len = #entry.seq - if text:sub(i, i + len - 1) == entry.seq then - if ttf and not ttf.tiles[entry.seq] then - -- single characters belong to the TTF; only multi-character - -- sequences (ligatures, <PK> macros) keep their tile mapping, - -- plus anything the mod named in ttf.tiles (see Font.load) - local cp, last = utf8Decode(entry.seq, 1) - if cp and last == len then break end + -- A charmap entry for the same byte wins: a font that ships '#' as a real + -- glyph is describing its own sheet, and the macro is only the fallback + -- the vanilla charmap leaves room for. + local macro = not (state and state.byFirstByte[text:byte(i)]) + and MACRO_TEXT[text:sub(i, i)] + if macro then + for _, sub in ipairs(Font.split(macro)) do + spans[#spans + 1] = { from = i, to = i, code = sub.code } + end + i = i + 1 + else + local span + local candidates = state and state.byFirstByte[text:byte(i)] + if candidates then + for _, entry in ipairs(candidates) do + local len = #entry.seq + if text:sub(i, i + len - 1) == entry.seq then + if ttf and not ttf.tiles[entry.seq] then + -- single characters belong to the TTF; only multi-character + -- sequences (ligatures, <PK> macros) keep their tile mapping, + -- plus anything the mod named in ttf.tiles (see Font.load) + local cp, last = utf8Decode(entry.seq, 1) + if cp and last == len then break end + end + span = { from = i, to = i + len - 1, code = entry.code } + break end - span = { from = i, to = i + len - 1, code = entry.code } - break end end - end - if not span and ttf then - local cp, last = utf8Decode(text, i) - if cp and cp >= 0x20 then - span = { from = i, to = last, code = TTF_BASE + cp } - end - end - if not span then - -- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never - -- lands mid-character even for a glyph we cannot draw. - local last = i - if text:byte(i) >= 0xC0 then - local k = i + 1 - while k <= n do - local b = text:byte(k) - if b < 0x80 or b > 0xBF then break end - last, k = k, k + 1 + if not span and ttf then + local cp, last = utf8Decode(text, i) + if cp and cp >= 0x20 then + span = { from = i, to = last, code = TTF_BASE + cp } end end - span = { from = i, to = last } + if not span then + -- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never + -- lands mid-character even for a glyph we cannot draw. + local last = i + if text:byte(i) >= 0xC0 then + local k = i + 1 + while k <= n do + local b = text:byte(k) + if b < 0x80 or b > 0xBF then break end + last, k = k, k + 1 + end + end + span = { from = i, to = last } + end + spans[#spans + 1] = span + i = span.to + 1 end - spans[#spans + 1] = span - i = span.to + 1 end return spans end @@ -406,8 +515,19 @@ Font.BORDER = {} for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end -- Draw a Game Boy style bordered box in tile coordinates. -function Font.drawBox(tx, ty, tw, th) - -- The white interior is a fill, so it needs the color; everything after it +-- +-- `fill` is an optional {r,g,b} in 0..255 for the interior. White is the +-- right answer everywhere in Gen 1 and on nearly every Gold screen, because +-- the box is drawn from font-page tiles ($79-$7e plus the ' ' $7f interior, +-- all >= $60) and those take BG palette 0, whose colour 0 is white there. A +-- host screen whose palette 0 colour 0 is NOT white has to say so: the +-- Pokegear's is `RGB 28, 31, 20` and its tile-attribute map sends everything +-- >= $60 to palette 0 (pokegold engine/pokegear/pokegear.asm TownMapPals, +-- gfx/pokegear/pokegear.pal), so a box pushed over the gear must come out on +-- the gear's cream paper, not as a white band. Default stays white so no +-- existing call site changes. +function Font.drawBox(tx, ty, tw, th, fill) + -- The interior is a fill, so it needs the color; everything after it -- is a glyph and needs the caller's. Restoring is not cosmetic: the tile -- pages are black glyphs on transparent, so they come out black whatever -- the color is, and leaking white here was invisible for as long as every @@ -416,7 +536,11 @@ function Font.drawBox(tx, ty, tw, th) -- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL -- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put. local r, g, b, a = love.graphics.getColor() - love.graphics.setColor(1, 1, 1, 1) + if type(fill) == "table" and fill[1] and fill[2] and fill[3] then + love.graphics.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) + else + love.graphics.setColor(1, 1, 1, 1) + end love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) love.graphics.setColor(r, g, b, a) local B = Font.BORDER diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua new file mode 100644 index 00000000..0ee1d212 --- /dev/null +++ b/src/render/GbcPalette.lua @@ -0,0 +1,434 @@ +-- Draws a 4-shade GB image through a GBC palette. +-- +-- Every 2bpp sheet the importer writes is grayscale: shade 0 is white +-- (or transparent), shade 3 is black (ImageWriter.SHADES). A GBC game picks +-- four real colors per tile instead, so this recovers the shade index from the +-- red channel and substitutes the palette entry -- one shader for map tiles, +-- OW sprites, battle pics and menu chrome alike. +-- +-- Shade recovery is exact rather than approximate: the source values are +-- 1, 2/3, 1/3, 0, so rounding (1 - r) * 3 lands on 0..3 with a half-step of +-- headroom either side, which survives texture filtering set to nearest. +-- +-- Alpha passes through untouched, so a sheet written with transparent shade 0 +-- (OW sprites, the font) keeps its cutout. + +-- COLOR (the port's own display option, Gold's answer to the Gen 1 COLORS +-- row). Every colour in the Gen 2 port arrives here, because a CGB game's +-- colour IS its palettes -- so one substitution at this seam recolours the +-- whole game without a single screen knowing about it: +-- +-- GBC the cart's own palettes. The default: this is a Game Boy +-- Color game and its colour is the point. +-- DMG the original grey Game Boy. Every palette collapses to the +-- four hardware shades, and the sheets that are drawn straight +-- (chrome, text) are already those shades, so the screen is +-- uniformly monochrome. +-- CLASSIC the DMG pea-soup green. Rendered as DMG and then run through +-- the green ramp as a final full-screen pass, which is what +-- GbcPalette.presentColors is for -- a shade map is exactly what +-- this shader already does, so it needs no second shader. + +local GbcPalette = {} + +GbcPalette.MODES = { "gbc", "dmg", "classic" } +GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" } +GbcPalette.mode = "gbc" + +-- rBGP's four shades as the hardware shows them. +local DMG_SHADES = { + { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, +} +-- #9BBC0F / #8BAC0F / #306230 / #0F380F, the same ramp Gen 1's CLASSIC uses. +local CLASSIC_SHADES = { + { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, +} + +local SHADER_SOURCE = [[ +extern vec3 pal0; +extern vec3 pal1; +extern vec3 pal2; +extern vec3 pal3; + +vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { + vec4 px = Texel(tex, uv); + float shade = floor((1.0 - px.r) * 3.0 + 0.5); + vec3 rgb = pal0; + if (shade > 2.5) { + rgb = pal3; + } else if (shade > 1.5) { + rgb = pal2; + } else if (shade > 0.5) { + rgb = pal1; + } + return vec4(rgb, px.a) * tint; +} +]] + +-- rBGP, the DMG background palette register, as a remap of an ALREADY DRAWN +-- texture. +-- +-- A frame this port has finished drawing holds CGB colours and no shade index +-- any more, so the remap has to run backwards: match the pixel to the palette +-- entry that produced it, then substitute whatever the rBGP byte sends that +-- entry to. With one palette that is exact, because the four entries are the +-- only colours the texture can hold; a composited frame is only as exact as +-- GbcPalette.remapTable's dedupe (see the `ambiguous` count there). +-- +-- `remapTol` is a SQUARED distance and it is what keeps this pass off pixels +-- that were never a palette colour -- a letterboxed border, a linear-filtered +-- resample -- rather than snapping them to the nearest entry. Palette colours +-- land on exact 1/255 steps through a nearest-filtered canvas, so the default +-- is a couple of steps of headroom and nothing like the gap between two +-- entries of the same palette. +local REMAP_SOURCE = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[32]; +extern vec3 remapDst[32]; + +vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) { + vec4 px = Texel(tex, uv); + vec3 mapped = px.rgb; + float best = remapTol; + for (int i = 0; i < 32; i++) { + if (i >= remapCount) { break; } + vec3 d = px.rgb - remapSrc[i]; + float dist = dot(d, d); + if (dist < best) { + best = dist; + // Indexed by the LOOP variable and never by a value carried out of the + // loop: GLSL ES 1.00 only allows a constant-index-expression into a + // uniform array, which a loop counter is and `best`'s winner is not. + mapped = remapDst[i]; + } + } + return vec4(mapped, px.a) * tint; +} +]] + +-- The compiled-in array length above. A map's eight BG palettes are 32 +-- colours before dedupe, which is the worst case this has to hold. +GbcPalette.REMAP_MAX = 32 +-- Squared RGB distance, in 0..1 units: three 8-bit steps. +GbcPalette.REMAP_TOLERANCE = (3 / 255) ^ 2 + +local shader = nil +local failed = false +local remapShader = nil +local remapFailed = false + +-- nil (and a one-shot warning) if shaders are unavailable, so callers can fall +-- back to the plain grayscale draw instead of crashing a whole boot. +function GbcPalette.shader() + if shader or failed then return shader end + if not (love and love.graphics and love.graphics.newShader) then + failed = true + return nil + end + local ok, result = pcall(love.graphics.newShader, SHADER_SOURCE) + if not ok then + failed = true + return nil + end + shader = result + return shader +end + +-- The same contract as GbcPalette.shader for the backwards pass: nil rather +-- than an error, so a caller can fall back to its own approximation. +function GbcPalette.remapShader() + if remapShader or remapFailed then return remapShader end + if not (love and love.graphics and love.graphics.newShader) then + remapFailed = true + return nil + end + local ok, result = pcall(love.graphics.newShader, REMAP_SOURCE) + if not ok then + remapFailed = true + return nil + end + remapShader = result + return remapShader +end + +function GbcPalette.available() + return GbcPalette.shader() ~= nil +end + +local function channel(colors, index) + local c = colors and colors[index] + if not c then return 0, 0, 0 end + return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 +end + +-- What a palette actually draws as under the current COLOR mode. Anything +-- that reads a colour out of a palette directly -- a canvas cleared to BG +-- colour 0, say -- has to go through this too, or the backdrop would keep its +-- cart colour while everything on top of it went grey. +function GbcPalette.resolve(colors) + if GbcPalette.mode == "gbc" then return colors end + return DMG_SHADES +end + +-------------------------------------------------------------------------- +-- rBGP as data +-------------------------------------------------------------------------- + +-- %11100100. The `dc` macro emits colour 3 FIRST, so `dc 3, 2, 1, 0` packs to +-- $e4 and reads back as "colour i shows shade i": the identity. +GbcPalette.BGP_IDENTITY = 0xe4 + +-- The byte's four 2-bit fields, colour 0 in the low bits, returned 1-based so +-- shades[i + 1] is the shade colour i shows. +function GbcPalette.bgpShades(byte) + byte = byte or GbcPalette.BGP_IDENTITY + local shades = {} + for index = 0, 3 do + shades[index + 1] = math.floor(byte / (4 ^ index)) % 4 + end + return shades +end + +-- CopyPals (home/palettes.asm), which is the whole of what DmgToCgbBGPals does +-- on a CGB: a palette's four entries are REORDERED by the rBGP byte, so a pixel +-- drawn as colour i comes back as colour bgp(i) OF ITS OWN PALETTE. +-- +-- This is why a brightness veil can never be right and this table can: $f9 +-- (`dc 3, 3, 2, 1`) means "one step darker along this palette's own ramp", and +-- no two palettes have the same ramp. Returns `colors` itself for the identity +-- so the common case allocates nothing. +function GbcPalette.remap(colors, byte) + if not colors then return nil end + if not byte or byte == GbcPalette.BGP_IDENTITY then return colors end + local shades = GbcPalette.bgpShades(byte) + local out = {} + for index = 1, 4 do + out[index] = colors[shades[index] + 1] or colors[4] + end + return out +end + +-- The rBGP byte every subsequent GbcPalette.use / .with / .color folds in, or +-- nil for the identity. +-- +-- This is the FORWARD half of the register and the exact one: a screen that is +-- still being drawn can take the permutation on its palettes before they ever +-- reach the shader, which is bit for bit DmgToCgbBGPals. Only a frame that is +-- already baked (World's map canvas) needs the backwards pass above. +-- +-- GbcPalette.useRaw deliberately does NOT fold it in: the CLASSIC present pass +-- goes through useRaw and is not a BG palette, so a byte left standing there +-- would permute the green ramp itself. +GbcPalette.bgp = nil + +-- Set the active byte, returning the previous one so a caller can restore it. +-- The identity is stored as nil, so `setBgp($e4)` is the same as clearing it. +function GbcPalette.setBgp(byte) + local previous = GbcPalette.bgp + if byte == GbcPalette.BGP_IDENTITY then byte = nil end + GbcPalette.bgp = byte + return previous +end + +-- One colour out of a palette, mode and active rBGP byte applied. `index` is +-- 1-based. +function GbcPalette.color(colors, index) + local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp) + if resolved and resolved[index] then return resolved[index] end + return GbcPalette.remap(DMG_SHADES, GbcPalette.bgp)[index] +end + +-- The palette the finished frame is presented through, or nil when the frame +-- is already the right colour. Only CLASSIC needs one: the scene under it has +-- rendered in the four DMG shades, so mapping those to the green ramp is the +-- same shade substitution every other call here makes. +function GbcPalette.presentColors() + if GbcPalette.mode ~= "classic" then return nil end + return CLASSIC_SHADES +end + +function GbcPalette.setMode(mode) + for _, name in ipairs(GbcPalette.MODES) do + if name == mode then + GbcPalette.mode = mode + return mode + end + end + GbcPalette.mode = "gbc" + return GbcPalette.mode +end + +function GbcPalette.modeLabel(mode) + return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC" +end + +-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so +-- the OPTION screen's left press walks the ladder the other way. +function GbcPalette.cycle(delta) + local at = 1 + for index, name in ipairs(GbcPalette.MODES) do + if name == GbcPalette.mode then at = index break end + end + local count = #GbcPalette.MODES + at = (at - 1 + (delta or 1)) % count + 1 + GbcPalette.mode = GbcPalette.MODES[at] + return GbcPalette.mode +end + +function GbcPalette.applyOptions(opts) + return GbcPalette.setMode(opts and opts.color or "gbc") +end + +-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching +-- palettes.lua. Returns false when there is no shader to configure. +-- +-- Mode first, then rBGP: on the hardware the register indexes whatever the +-- palette buffer holds, and in DMG/CLASSIC mode that buffer IS the four grey +-- shades, so remapping the resolved palette is what the DMG itself does. +function GbcPalette.use(colors) + return GbcPalette.useRaw( + GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)) +end + +-- The same, ignoring the COLOR mode. The present pass needs it: it IS the +-- mode, so running its own palette back through resolve would flatten the +-- green ramp to grey and the mode would do nothing. +function GbcPalette.useRaw(colors) + local sh = GbcPalette.shader() + if not sh then return false end + for i = 0, 3 do + local r, g, b = channel(colors, i + 1) + sh:send("pal" .. i, { r, g, b }) + end + love.graphics.setShader(sh) + return true +end + +function GbcPalette.clear() + if love and love.graphics then love.graphics.setShader() end +end + +-------------------------------------------------------------------------- +-- The backwards pass: remapping a frame that is already drawn +-------------------------------------------------------------------------- + +local function colorKey(c) + return math.floor(c[1] or 0) .. "," .. math.floor(c[2] or 0) + .. "," .. math.floor(c[3] or 0) +end + +-- Source and destination colour lists for REMAP_SOURCE, deduplicated. +-- +-- `bgPalettes` is a LIST OF PALETTES the rBGP byte reaches: DmgToCgbBGPals +-- pushes one byte through all eight BG palettes at once, so they all take the +-- same permutation. `objPalettes` is the list it does NOT reach -- OBJ colours +-- go through the separate DmgToCgbObjPals, which the flash never calls -- and +-- they are here mapping to THEMSELVES, so a sprite's colours are recognised and +-- left alone instead of being matched onto a BG entry and swept along. +-- +-- Returns src, dst (0..1 triples, both padded to REMAP_MAX) and two counts: +-- `count`, the live length, and `ambiguous`. +-- +-- `ambiguous` is the exact limit of this whole pass, and it is worth naming: a +-- colour that is entry 1 of one palette and entry 2 of another has two right +-- answers, and a composited frame threw away which one this pixel was. The +-- first writer wins, which is why BG palettes are walked first and in slot +-- order -- the reading that matches "the whole picture flashes". Nothing here +-- is approximate when `ambiguous` is 0. +-- +-- A map's eight BG palettes are 32 entries, which is REMAP_MAX exactly, so a +-- map whose palettes share nothing at all fills the array and the OBJ list is +-- dropped. BG is walked first for that reason too: losing the sprite guard +-- costs a few sprite pixels, losing a BG palette would cost the effect. +function GbcPalette.remapTable(bgPalettes, byte, objPalettes) + local src, dst = {}, {} + local seen = {} + local ambiguous = 0 + + local function add(colors, mapped) + if not (colors and mapped) then return end + for index = 1, 4 do + local from, to = colors[index], mapped[index] + if from and to and #src < GbcPalette.REMAP_MAX then + local key = colorKey(from) + local at = seen[key] + if at then + -- Same colour, different destination: the pixel cannot say which + -- palette drew it, so the first answer stands and this is counted. + if colorKey(dst[at]) ~= colorKey(to) then ambiguous = ambiguous + 1 end + else + src[#src + 1] = { from[1], from[2], from[3] } + dst[#dst + 1] = { to[1], to[2], to[3] } + seen[key] = #src + end + end + end + end + + for _, colors in ipairs(bgPalettes or {}) do + local resolved = GbcPalette.resolve(colors) + add(resolved, GbcPalette.remap(resolved, byte)) + end + for _, colors in ipairs(objPalettes or {}) do + local resolved = GbcPalette.resolve(colors) + add(resolved, resolved) + end + + local count = #src + -- Shader:send fills the whole declared array, so the tail is padded with a + -- copy of the first entry; `count` keeps the loop off it either way. + for index = count + 1, GbcPalette.REMAP_MAX do + src[index] = src[1] and { src[1][1], src[1][2], src[1][3] } or { 0, 0, 0 } + dst[index] = dst[1] and { dst[1][1], dst[1][2], dst[1][3] } or { 0, 0, 0 } + end + return src, dst, count, ambiguous +end + +-- Bind the remap shader for a draw of an already-rendered texture. Returns +-- false when there is no shader or no palette, so a caller can fall back to +-- whatever approximation it had before; on success it also returns the +-- `ambiguous` count, which is 0 when the pass is exact. +function GbcPalette.useRemap(bgPalettes, byte, objPalettes) + local sh = GbcPalette.remapShader() + if not sh then return false end + local src, dst, count, ambiguous = + GbcPalette.remapTable(bgPalettes, byte, objPalettes) + if count == 0 then return false end + local sendSrc, sendDst = {}, {} + for index = 1, GbcPalette.REMAP_MAX do + sendSrc[index] = { src[index][1] / 255, src[index][2] / 255, + src[index][3] / 255 } + sendDst[index] = { dst[index][1] / 255, dst[index][2] / 255, + dst[index][3] / 255 } + end + -- pcall rather than an assert: a driver that will not take a 32-entry vec3 + -- array should drop the effect, not take the battle down with it. + local ok = pcall(function() + sh:send("remapCount", count) + sh:send("remapTol", GbcPalette.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(sendSrc)) + sh:send("remapDst", unpack(sendDst)) + end) + if not ok then + remapFailed = true + remapShader = nil + return false + end + love.graphics.setShader(sh) + return true, ambiguous +end + +-- Run `body` with `colors` active, restoring whatever shader was set before. +-- Nested use is safe: the previous shader is captured, not assumed to be nil. +function GbcPalette.with(colors, body) + local previous = love and love.graphics and love.graphics.getShader + and love.graphics.getShader() or nil + local applied = GbcPalette.use(colors) + local ok, err = pcall(body) + if love and love.graphics then love.graphics.setShader(previous) end + if not ok then error(err, 0) end + return applied +end + +return GbcPalette diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 36ebb36e..0accd16b 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ -- Right-facing frames are horizontal flips of the left frames. local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") local PaletteFX = require("src.render.PaletteFX") local SpriteRenderer = {} @@ -204,8 +205,38 @@ end -- pipeline that renders into its own canvas never runs through it. For the -- same reason the OG-RED bake is returned unconditionally here rather than -- only during a redraw pass -- there is no later pass to restore it. +-- Gen 2 hands its OBJ palette over explicitly. Gold is a CGB-native game: +-- every OW sprite already has a real 4-color OBJ palette (PAL_OW_* crossed +-- with the time of day, engine/gfx/color.asm MapObjectPals), so there is +-- nothing for the PaletteFX mode ladder below to infer -- src/world/gen2 just +-- says what the colors are. It rides the same getObpImage bake as RED++, +-- which is also what keys OBJ color 0 to alpha; the sheets carry no real +-- alpha of their own, so a raw blit would put a white box behind every +-- character. +-- +-- `group` must be distinct per palette or the bake cache collides -- callers +-- pass something like "gen2:NITE:1". +function SpriteRenderer:setObjPalette(colors, group) + self.objColors = colors + self.objGroup = group or "gen2" +end + +-- The Gen 2 OBJ palette with the COLOR option applied. Resolved on the way +-- to the bake rather than where the world hands the colours over: the option +-- can change between two frames of a standing map, and applyPalettes only +-- runs on map entry and once a second. The mode joins the cache group +-- because the bake is per-palette -- without it, DMG would keep serving the +-- colour bake it made first. +function SpriteRenderer:gen2Obp() + return GbcPalette.resolve(self.objColors), + self.objGroup .. "|" .. tostring(GbcPalette.mode) +end + function SpriteRenderer:resolveImage() if self.def.trueColor then return self.image end + if self.objColors then + return getObpImage(self.def.image, self:gen2Obp()) + end if PaletteFX.usesGbcPack() then local colors, group = PaletteFX.spriteObp(self.def, self.seed) if colors then return getObpImage(self.def.image, colors, group) end @@ -244,7 +275,13 @@ end -- caller then draws itself through :drawTile (Player:draw, #384). Vanilla -- frames therefore still draw 8 rows, while taller frames keep their larger -- body and reserve only the overlay row. -function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf) +-- `forceFlip` is the caller asking for the X-flipped copy of the frame it +-- already picked, for the facings whose OAM rows are the mirror of another +-- row's: FacingWeirdTree3 is FacingWeirdTree1's four tiles with the columns +-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional +-- and trailing, so every existing call site is unchanged. +function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, + topHalf, forceFlip) local x, y = self:getScreenOrigin(px, py, camX, camY) local image = self.image local redraw = false @@ -252,6 +289,11 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to -- is recorded below once the final frame/height is known. if self.def.trueColor then image = self.image + elseif self.objColors then + -- Gen 2: the palette came from the caller (setObjPalette). Like RED++ + -- this bakes to a true-color, real-alpha image and there is no BG zone + -- shader over the Gen 2 world to exempt it from. + image = getObpImage(self.def.image, self:gen2Obp()) elseif PaletteFX.usesGbcPack() then -- RED++: the world canvas is already true-color (TileRenderer bakes -- terrain, this bakes the sprite) and the world pass runs unshaded @@ -287,6 +329,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to -- still 3-frame sprites turn to face (the nurse at her machine, -- facePlayer on STAY NPCs) but never show walk frames. local frame, flip = pose(self, facing, walkPhase, stepFlip) + if forceFlip then flip = true end local quad = self.frames[frame] local drawHeight = self.frameHeight if topHalf and self.frameCount > 1 then diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 290c4ecc..46f454d4 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -51,6 +51,11 @@ function TextBox.new(game, text, onDone, opts) self.choiceNoSound = opts and opts.noSound self.auto = opts and opts.auto self.stay = opts and opts.stay + -- opts.instant: put the LAST page up already typed, with no typewriter and + -- no page waits. A `yesorno` follows a `writetext` that has already been + -- read, so re-typing the line under the YES/NO box would be wrong -- the + -- cart never closed the box in the first place. + self.instant = opts and opts.instant local box = Theme.textBox or {} self.boxTx = box.tx or BOX_TX self.boxTy = box.ty or BOX_TY @@ -70,6 +75,25 @@ function TextBox.new(game, text, onDone, opts) self.contAdvance = false self.done = false self.blink = 0 + if self.instant then + self.pageIndex = #self.pages + -- Both lines of the page at once, which is what the box looks like the + -- moment before the prompt appears. + local page = self.pages[self.pageIndex] or {} + -- The page's LAST two lines, which is what the box is holding. A `cont` + -- inside the page ran _ContTextNoPause (home/text.asm:442): TextScroll + -- twice, then the next line is written at TEXTBOX_INNERY + 2, i.e. the + -- bottom row. Taking the first two would walk the text backwards the + -- instant the prompt appears. + for index = math.max(1, #page - 1), #page do + self.shown[#self.shown + 1] = Font.encode(page[index]) + end + self.lineIndex = #page + self.codes = self.shown[#self.shown] or {} + self.charIndex = #self.codes + self.done = true + return self + end self:beginLine() return self end @@ -81,7 +105,27 @@ end -- (home/give.asm), and it stays set afterwards. TextBox.TOKENS = { PLAYER = function(game) return game.save.player.name or "RED" end, - RIVAL = function(game) return game.save.player.rival or "BLUE" end, + -- A Gen 1 save keeps the rival on player.rival; a Gold save keeps him at + -- save.rival.name, seeded "???" by InitializeNPCNames and written by the + -- NameRival special, whose own InitName fallback (not the seed) is where + -- "SILVER" comes from (pokegold engine/events/specials.asm + -- NameRival .DefaultName). The Gen 1 + -- default must not leak into a Gold textbox: the cart's officer never says + -- BLUE. The tail therefore splits by generation rather than ending on the + -- Gen 1 literal. A Gold save with no rival record at all is one that never + -- reached the naming screen, and wRivalName is then still what + -- InitializeNPCNames seeded it with, "???" + -- (pokegold engine/menus/intro_menu.asm .Rival). + RIVAL = function(game) + local gold = game.save.generation == 2 or game.save.version == "gold" + return game.save.player.rival + or (game.save.rival and game.save.rival.name) + or (gold and "???" or "BLUE") + end, + -- Gen 2's TX_RAM points at wStringBuffer2, which getstring / getmonname / + -- getitemname fill. An unset buffer prints nothing, the same as the cart's + -- freshly `@`-filled buffer. + STRBUF = function(game) return game.stringBuffer end, RAM = function(game, arg) if arg == "wStringBuffer" then return game.stringBuffer end if arg == "wBoxNumString" then return game.boxNumString end @@ -360,7 +404,15 @@ function TextBox:draw() r:setUIAnchor(self.boxTx * 8, self.boxTy * 8, self.boxTw * 8, self.boxTh * 8, "bottom") end - Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh) + -- The box's own tiles are all font-page ($79-$7e frame, ' ' $7f interior), + -- so they take whatever BG palette 0 colour 0 the screen UNDER the box is + -- using. On every Gen 1 screen and nearly every Gold one that is white and + -- this is nil; the Pokegear's is a pale cream, and a call's pushed textbox + -- has to sit on the gear's paper rather than paint a white band across it + -- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile + -- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil. + local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() + Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) love.graphics.setColor(0, 0, 0, 1) if self.scrollPx and self.scrollPx > 0 then self.scrollPx = self.scrollPx - 2 diff --git a/src/render/Tilt.lua b/src/render/Tilt.lua index e7cc68fd..3363cf5e 100644 --- a/src/render/Tilt.lua +++ b/src/render/Tilt.lua @@ -129,6 +129,21 @@ function Tilt.groundPoint(cx, cy, vw, vh) return sx, sy, scale end +-- Is a flat foot point on the ground quad at all? +-- +-- The ground is the flat world canvas warped onto the perspective plane, so it +-- STOPS at that canvas. groundPoint has no such edge: it happily projects a +-- point far above the viewport, and perspective pulls it back down toward the +-- horizon, which is how an NPC two screens away ended up standing over the +-- border fill past where the map is drawn at all. Billboards ask this first +-- and skip anything the ground does not reach. `margin` is the sprite's own +-- size, so someone half off the edge still draws. +function Tilt.onGround(fx, fy, vw, vh, margin) + margin = margin or 0 + return fx >= -margin and fx <= (vw or 0) + margin + and fy >= -margin and fy <= (vh or 0) + margin +end + function Tilt.viewGrowth() local a = Tilt.angle if a <= 0 then return 1 end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 0f754359..42333c21 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -396,10 +396,14 @@ local function encodeStatus(status) return 0 end +-- Def rows share a generated table's top level with provenance scalars on +-- some data sets (src/import/RomExtractorGen2.lua stamps `generation` and +-- `source` beside the entries), so every pairs(defs) walk here must keep +-- to table rows: indexing a scalar row raises instead of skipping it. local function buildIndexCrosswalk(defs) local byIndex, byId = {}, {} for id, def in pairs(defs or {}) do - if def.index ~= nil then + if type(def) == "table" and def.index ~= nil then byIndex[def.index] = id byId[id] = def.index end @@ -420,7 +424,8 @@ end local function buildDexCrosswalk(defs) local byDex, dexOf = {}, {} for id, def in pairs(defs or {}) do - local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]")) + local n = type(def) == "table" and def.source + and tonumber(def.source:match("BaseStats%[(%d+)%]")) if n then byDex[n] = id dexOf[id] = n @@ -438,7 +443,8 @@ end -- per slot -- i.e. HM01=196+.. , TM01=201+(number-1). local function addMachineIndices(defs, byIndex, byId) for id, def in pairs(defs or {}) do - if byId[id] == nil and def.machine and def.machine.number then + if type(def) == "table" and byId[id] == nil + and def.machine and def.machine.number then local base = def.machine.kind == "HM" and 195 or 200 local idx = base + def.machine.number byIndex[idx] = id diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 3df6122f..262f10de 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -214,6 +214,15 @@ SaveConvert.mergeDefaults = mergeDefaults -- Public API -- ------------------------------------------------------------------ +-- GenSave models Gen 1 SRAM and nothing else: a Gen 2 cart save is a +-- different bank map, party struct and checksum scheme (pokegold +-- ram/sram.asm sOptions/sCheckValue1/sPlayerData/sBox1-sBox14 vs +-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2 +-- 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" } + -- importSav(bytes, version, gameVersion) -> saveTable, err -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data -- checksum, decodes through GenSave, and returns a save table fully merged @@ -226,6 +235,10 @@ 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] + if gen2Name then + return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet." + end if #bytes ~= GenSave.SAVE_SIZE then return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) end @@ -258,6 +271,10 @@ function SaveConvert.exportSav(saveTable, gameVersion) if type(saveTable) ~= "table" then return nil, "expected a save table" end + local gen2Name = GEN2_SAV_UNSUPPORTED[gameVersion] + if gen2Name then + return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet." + end local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua new file mode 100644 index 00000000..a4999706 --- /dev/null +++ b/src/script/gen2/CallAsm.lua @@ -0,0 +1,734 @@ +-- `callasm` / `memcall` / `memcallasm` / `memjump`: the four script commands +-- whose operand is not bytecode. +-- +-- Script_callasm (engine/overworld/scripting.asm) reads a three-byte far +-- pointer out of the script and `rst FarCall`s it as raw Game Boy code. +-- Script_memcallasm does the same with a pointer read out of WRAM instead; +-- Script_memcall calls the WRAM pointer as bytecode, and Script_memjump jumps +-- to it and never comes back. No interpreter can run any of them, so each +-- target routine needs its own hand port, and this file is where they live. +-- +-- Dispatch is BY ADDRESS, the way src/script/gen2/Specials.lua dispatches by +-- name. SITES maps the `bank:addr` pair the command carries -- resolved +-- against pokegold-symbols/pokegold.sym -- onto a routine name, and HANDLERS +-- maps that name onto the port. Keying on the pair rather than on a counted +-- index means a routine that moved between revisions cannot silently call the +-- wrong port; keying the port on the NAME means the table can be asserted +-- against the symbol file by a test. +-- +-- WHAT wScriptVar DOES AND DOES NOT GET WRITTEN. Script_callasm itself never +-- touches wScriptVar: it is the TARGET routine that does, and only eleven of +-- them do. A handler therefore returns a byte only when its asm ends in +-- `ld [wScriptVar], a`, and nil otherwise -- nil meaning "leave it alone". +-- Inventing a 0 or a 1 for the rest would pick a branch at random for the +-- `callasm` / `iffalse` pairs (FindItemInBallScript and FruitTreeScript are +-- both one), which is exactly the bug this contract exists to prevent. +-- +-- WHAT IS ACTUALLY IN THE CACHE: nothing. Thirty-eight rows in +-- data/generated/scripts.lua used to carry one of these four opcodes and not +-- one of them was real -- every one sat inside a key the extractor had made +-- out of a three-byte `hiddenitem` bg_event operand (45:697a is +-- IlexForestHiddenEther, 47:40a5 is WhirlIslandB1FHiddenRareCandy) and then +-- disassembled as if it were code. A cache built since the extractor stopped +-- walking those operands has none at all, and a stale one still misses SITES +-- because its bank/address pairs are noise; tests/gen2_callasm_test.lua +-- asserts that neither resolves. +-- +-- The real sites are the fifty-seven in engine/*.asm listed below. None of +-- those engine scripts is reachable from a map script pointer, so none of them +-- is extracted either, and the port reaches their routines through the +-- hand-ported flows in src/world/gen2/ -- which is why CallAsm.run is a public +-- entry point beside CallAsm.dispatch. Both roads end at the same table, +-- which is the point of having one. +-- +-- love-free: a handler takes the World (or anything shaped like it) and calls +-- back into the module that already owns the behaviour. Nothing here +-- re-transcribes a table that has a home elsewhere. + +local Phone = require("src.core.gen2.Phone") + +local CallAsm = {} + +-- ---- the site table -------------------------------------------------------- +-- +-- Every `callasm` / `memcall` / `memcallasm` / `memjump` operand in the cart, +-- resolved against pokegold-symbols/pokegold.sym. The comment on each group +-- is the file the site is IN; the name is the routine the pointer reaches. +CallAsm.SITES = { + -- engine/overworld/scripting.asm: GiveItemScript's opening no-op. + ["25:6e71"] = "GiveItemScript_DummyFunction", + -- engine/overworld/events.asm: the START and SELECT presses, the queued + -- script the overworld hands itself, the egg hatch and the facing change. + ["04:6994"] = "StartMenu", + ["04:76e9"] = "SelectMenu", + ["05:7312"] = "OverworldHatchEgg", + ["25:664f"] = "EnableWildEncounters", + -- engine/phone/phone.asm and engine/events/mom_phone.asm. + ["24:4277"] = "RingTwice_StartCall", + ["24:42df"] = "HangUp", + ["04:5800"] = "InitCallReceiveDelay", + ["24:4264"] = "LoadBillScript", + ["24:4272"] = "LoadElmScript", + ["3f:4db2"] = "MomTriesToBuySomething_ASMFunction", + -- engine/items/itemfinder.asm. + ["04:6960"] = "ItemfinderSound", + -- engine/events/fruit_trees.asm. + ["11:4041"] = "GetCurTreeFruit", + ["11:404c"] = "TryResetFruitTrees", + ["11:4055"] = "CheckFruitTree", + ["11:405f"] = "PickedFruitTree", + -- engine/events/sweet_scent.asm. + ["14:4725"] = "SweetScentEncounter", + -- engine/events/trainer_scripts.asm. + ["02:490e"] = "TrainerWalkToPlayer", + -- engine/events/squirtbottle.asm. + ["14:4786"] = "CheckCanUseSquirtbottle", + -- engine/events/hidden_item.asm. + ["04:7a11"] = "SetMemEvent", + -- engine/events/poisonstep.asm. + ["14:468e"] = "PlayPoisonSFX", + ["14:46b1"] = "CheckWhitedOut", + -- engine/events/whiteout.asm. + ["04:68c7"] = "OverworldBGMap", + ["04:68d7"] = "BattleBGMap", + ["04:68e0"] = "HalveMoney", + ["04:68ee"] = "GetWhiteoutSpawn", + -- engine/events/overworld.asm: the field moves. + ["03:474b"] = "GetPartyNickname", + ["03:4855"] = "CutDownTreeOrGrass", + ["23:4a6b"] = "BlindingFlash", + ["00:310a"] = "HideSprites", + ["23:4d65"] = "FlyFromAnim", + ["05:560c"] = "SkipUpdateMapSprites", + ["23:4dab"] = "FlyToAnim", + ["05:415c"] = "LoadWalkingSpritesGFX", + ["03:4b49"] = "CheckContinueWaterfall", + ["03:4d15"] = "SetStrengthFlag", + ["03:4d7b"] = "TryStrengthOW", + ["03:4e20"] = "DisappearWhirlpool", + ["23:4a8e"] = "ShakeHeadbuttTree", + ["03:4f7f"] = "HasRockSmash", + ["03:5096"] = "PutTheRodAway", + ["03:506d"] = "Fishing_CheckFacingUp", + ["14:454f"] = "LoadFishingGFX", + ["03:51c7"] = "AskCutScript_CheckMap", + -- engine/events/treemons.asm, reached from the HEADBUTT and ROCK SMASH arms + -- of engine/events/overworld.asm. + ["2e:6378"] = "TreeMonEncounter", + ["2e:63a1"] = "RockMonEncounter", + -- engine/events/misc_scripts.asm. + ["04:66d1"] = "TryReceiveItem", +} + +-- The three WRAM addresses `memcall` / `memcallasm` / `memjump` take instead of +-- a routine. Not sites: the pointer AT the address is written at run time +-- (LoadMemScript for the queued script, the phone engine for the other two), +-- so there is nothing static to resolve and nothing to port. Kept so a test +-- can tell "a WRAM operand" apart from "a routine nobody has named yet". +CallAsm.MEM_OPERANDS = { + ["00:cfd8"] = "wQueuedScriptBank", + ["00:ceed"] = "wPhoneScriptBank", + ["00:cf2a"] = "wCallerContact", +} + +-- ---- constants ------------------------------------------------------------- + +-- constants/map_object_constants.asm. The port already keeps +-- SPRITEMOVEDATA_STRENGTH_BOULDER ($19) in World; this is its neighbour. +local SPRITEMOVEDATA_SUDOWOODO = 0x17 + +-- constants/sfx_constants.asm, resolved by LABEL at call time the way +-- src/world/gen2/HiddenItems.lua does it; the numbers are only the fallback +-- for a cache whose sfx table sits somewhere else. +local SFX_POISON = { "Sfx_Poison", 11 } +local SFX_SECOND_PART_OF_ITEMFINDER = { "Sfx_SecondPartOfItemfinder", 18 } +local SFX_TRANSACTION = { "Sfx_Transaction", 34 } +local SFX_CALL = { "Sfx_Call", 106 } + +-- ItemFinder.ItemfinderSound's `ld c, 4`. +local ITEMFINDER_SFX_LOOPS = 4 + +-- data/maps/spawn_points.asm. SPAWN_HOME is the first row, and it is what +-- GetWhiteoutSpawn falls back to when IsSpawnPoint says no. +local SPAWN_HOME = "SPAWN_HOME" + +-- ---- ctx plumbing ---------------------------------------------------------- + +-- A handler talks to the World through its own methods, and every one of them +-- is optional: a headless test hands in a table with two of them and the rest +-- of the file still runs. That is the same bargain Specials strikes with its +-- hook table, and it is what makes this dispatch testable without a map. +local function call(ctx, name, ...) + local fn = ctx and ctx[name] + if type(fn) ~= "function" then return nil end + return fn(ctx, ...) +end + +local function saveOf(ctx) + return ctx and ctx.game and ctx.game.save or nil +end + +-- ---- the phone's caller-ID box --------------------------------------------- +-- +-- Phone_TextboxWithName is the one routine in this file that DRAWS, so it is +-- the one place a handler reaches past the World's method table: the box is a +-- state on src/core/StateStack.lua, pushed under the call's text pages the way +-- the cart's box sits under the speech box. The require is lazy and the whole +-- pair is a no-op without a stack, so the "love-free" bargain at the top of +-- this file still holds for every headless caller. +-- +-- The state is tagged rather than tracked in a local: a handler is dispatched +-- from wherever the script happens to be running, and a module-level handle +-- would outlive a save reload or a second world. +local CALLER_BOX = "gen2CallerBox" + +local function stackOf(ctx) + return ctx and ctx.game and ctx.game.stack or nil +end + +-- GetCallerClassAndName reads wCurCaller (engine/phone/phone.asm:471-475), and +-- vm.curPhoneCaller is this port's wCurCaller: World:receivePhoneCall and +-- World:momTriesToBuy both park the PHONE_* contact there before the rows run. +-- Contact 0 is the wrong-number script, which NonTrainerCallerNames answers +-- for -- Phone.contactName already makes that distinction. +local function showCallerBox(ctx) + local stack = stackOf(ctx) + if not stack then return end + for _, state in ipairs(stack.states or {}) do + if state[CALLER_BOX] then return end + end + local contact = (ctx.vm and ctx.vm.curPhoneCaller) or 0 + local data = ctx.game and ctx.game.data + local name, className = Phone.contactName(contact, data and data.trainers) + local box = require("src.ui.gen2.CallerBox").new(name, className) + box[CALLER_BOX] = true + stack:push(box) +end + +-- The box is the top of the stack when this runs, and StateStack:pop is what +-- takes it off -- so `exit` and screen.popped fire the way they do for every +-- other Gold screen. It CAN only be the top: src/core/Game2.lua's fixed step +-- hands the tick to the top state and returns whenever that state has an +-- `update`, so the VM this handler is running inside is not advancing at all +-- while a text page is over the box. The by-identity search below is the +-- guard for the day that stops being true (a mod screen pushed from a hook, +-- say): the box comes off either way, because a caller box left on the stack +-- would sit over the overworld forever with nothing to take it down. +local function hideCallerBox(ctx) + local stack = stackOf(ctx) + local states = stack and stack.states + if not states then return end + for index = #states, 1, -1 do + if states[index][CALLER_BOX] then + if index == #states then + stack:pop() + else + table.remove(states, index) + end + return + end + end +end + +local H = {} + +-- ---- engine/overworld/scripting.asm --------------------------------------- + +-- GiveItemScript_DummyFunction is one `ret`. It exists so GiveItemScript +-- opens on a command rather than on `writetext`, and the port keeps it named +-- so the site is accounted for instead of falling through as unknown. +function H.GiveItemScript_DummyFunction() + return nil +end + +-- ---- engine/overworld/events.asm ------------------------------------------ + +-- SelectMenuScript's `callasm SelectMenu` reaches UseRegisteredItem +-- (engine/overworld/select_menu.asm), which is exactly World:useSelectItem: +-- the four ITEMMENU_* arms plus CantUseItem's two "nothing registered" and +-- "no field handler" cases. StartMenu, its neighbour in the same table, is a +-- stub instead -- the START menu is a pushed screen, not a blocking call. +function H.SelectMenu(ctx) + call(ctx, "useSelectItem") + return nil +end + +-- HatchEggScript is `callasm OverworldHatchEgg / end`: the whole player event +-- is this one routine. World:hatchEggs is HatchEggs +-- (engine/pokemon/breeding.asm) with its four text beats. +function H.OverworldHatchEgg(ctx) + call(ctx, "hatchEggs") + return nil +end + +-- ---- engine/phone/phone.asm and engine/overworld/time.asm ----------------- + +-- RingTwice_StartCall: Phone_StartRinging's SFX_CALL and the flashing +-- caller-ID box, twice over on a twenty-frame cadence. +-- +-- Both halves are here. The SOUND is the ring the player hears. The BOX is +-- .CallerTextboxWithName -> Phone_TextboxWithName (engine/phone/phone.asm:466, +-- :474, :582), the four-row strip across the top of the screen naming who is +-- calling; src/ui/gen2/CallerBox.lua is its port and carries the layout. This +-- port's textbox holds for A where the cart's PrintText returns, so the six +-- twenty-frame flashes have nowhere to live -- the box simply goes up with the +-- first ring and stays, which is the state the cart leaves it in anyway: it is +-- written straight into wTilemap, CloseText restores it with everything else +-- under the speech box, and only the overworld's own tilemap redraw at the end +-- of the call takes it down (which is what InitCallReceiveDelay does below). +-- +-- Idempotent, because Script_ReceivePhoneCall rings TWICE and a second box +-- would bury the first. ctx.game is absent in the pure-module tests and in +-- any headless harness, and then the ring is all there is to do. +function H.RingTwice_StartCall(ctx) + call(ctx, "playSfxNamed", SFX_CALL[1], SFX_CALL[2]) + showCallerBox(ctx) + return nil +end + +-- InitCallReceiveDelay (engine/overworld/time.asm): zero +-- wTimeCyclesSinceLastCall and restart the receive countdown at its twenty +-- in-game minutes. StartMap runs it on every map load (World:setMap -> +-- Phone.onMapLoad) and Script_ReceivePhoneCall's tail runs it here, so a +-- hung-up call arms the same timer a warp does. +function H.InitCallReceiveDelay(ctx) + -- The caller-ID box comes down here, and only here. Script_ReceivePhoneCall + -- runs this as its LAST row (engine/phone/phone.asm:431-438), which is the + -- moment the cart's overworld redraws its tilemap over the strip -- there is + -- no routine that erases the box, so there is no row to hang the teardown on + -- but this one. Ahead of the countdown, so a save-less harness still tidies + -- the screen. + hideCallerBox(ctx) + local save = saveOf(ctx) + if not save then return nil end + local step = call(ctx, "stepContext") + Phone.initReceiveDelay(save, step and step.phone) + return nil +end + +-- ---- engine/items/itemfinder.asm ------------------------------------------ + +-- .ItemfinderSound: four times round SFX_SECOND_PART_OF_ITEMFINDER then +-- SFX_TRANSACTION, each through WaitPlaySFX so the pair does not overlap. +-- The port plays them back to back; there is no channel to wait on. +function H.ItemfinderSound(ctx) + for _ = 1, ITEMFINDER_SFX_LOOPS do + call(ctx, "playSfxNamed", SFX_SECOND_PART_OF_ITEMFINDER[1], + SFX_SECOND_PART_OF_ITEMFINDER[2]) + call(ctx, "playSfxNamed", SFX_TRANSACTION[1], SFX_TRANSACTION[2]) + end + return nil +end + +-- ---- engine/events/fruit_trees.asm ---------------------------------------- +-- +-- All four read wCurFruitTree, which `fruittree tree_id` sets before it jumps +-- to FruitTreeScript; `ctx.curFruitTree` is that byte. The item table itself +-- lives in src/core/gen2/Apricorns.lua and is reached through World. + +-- GetCurTreeFruit: FruitTreeItems[wCurFruitTree - 1] -> wCurFruit. The `dec a` +-- is the cart turning a 1-based FRUITTREE_* into a 0-based offset, which +-- World:fruitTreeItem already undoes, so nothing is shifted here. +function H.GetCurTreeFruit(ctx) + local tree = (ctx and ctx.curFruitTree) or 0 + ctx.curFruit = call(ctx, "fruitTreeItem", tree) or 0 + return nil +end + +-- TryResetFruitTrees: gated on DAILYFLAGS1_ALL_FRUIT_TREES, so the FIRST tree +-- examined after the daily rollover refills every tree in the game. +function H.TryResetFruitTrees(ctx) + call(ctx, "fruitTreeReset") + return nil +end + +-- CheckFruitTree is `ld b, 2 / GetFruitTreeFlag`, a CHECK_FLAG over +-- wFruitTreeFlags, and it puts the flag itself in wScriptVar. The flag means +-- "already picked", so a CLEAR flag is the arm with fruit on it -- which is +-- why FruitTreeScript reads it with `iffalse .fruit`. +function H.CheckFruitTree(ctx) + local tree = (ctx and ctx.curFruitTree) or 0 + return call(ctx, "fruitTreePicked", tree) and 1 or 0 +end + +-- PickedFruitTree is `ld b, 1 / GetFruitTreeFlag`: SET_FLAG, no wScriptVar. +function H.PickedFruitTree(ctx) + local tree = (ctx and ctx.curFruitTree) or 0 + call(ctx, "fruitTreePick", tree) + return nil +end + +-- ---- engine/events/overworld.asm ------------------------------------------ + +-- GetPartyNickname copies wCurPartyMon's nickname into wStringBuffer1-3, which +-- is what {STRBUF} reads back for "<nickname> used CUT!". wCurPartyMon is +-- whatever CheckPartyMove last left there, and `ctx.curPartyMon` is the port's +-- name for the same slot. Five separate `callasm` sites reach it. +function H.GetPartyNickname(ctx) + local mon = ctx and ctx.curPartyMon + if not mon then + local save = saveOf(ctx) + mon = save and save.party and save.party[1] or nil + end + call(ctx, "setNickname", mon) + return nil +end + +-- CutDownTreeOrGrass writes wCutWhirlpoolReplacementBlock over the block at +-- wCutWhirlpoolOverworldBlockAddr, redraws, then plays OWCutAnimation. The +-- port owns the pair as World:replaceBlock, which is also what drops the map's +-- baked canvases -- a bake is keyed by map and daytime and knows nothing about +-- the blocks it came from, so a changed block that skips it stays on screen. +-- `ctx.cutWhirlpoolBlockIndex` / `ctx.cutWhirlpoolReplacement` are the port's +-- wCutWhirlpoolOverworldBlockAddr and wCutWhirlpoolReplacementBlock: an index +-- into the loaded map's block buffer rather than a pointer into it. +function H.CutDownTreeOrGrass(ctx) + local index = ctx and ctx.cutWhirlpoolBlockIndex + local blockId = ctx and ctx.cutWhirlpoolReplacement + if index and blockId then call(ctx, "replaceBlock", index, blockId) end + return nil +end + +-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of +-- OWCutAnimation: the same block write, the same redraw. +function H.DisappearWhirlpool(ctx) + return H.CutDownTreeOrGrass(ctx) +end + +-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting +-- the flag is all there is to it here: Palettes.daytimeFor already turns a +-- flashed PALETTE_DARK map into a NITE one, which is the cart's .UsedFlash +-- arm, and the reload is the port's own baked-canvas drop. +function H.BlindingFlash(ctx) + if not ctx then return nil end + ctx.flashUsed = true + if call(ctx, "applyPalettes") then call(ctx, "refreshMapImages") end + return nil +end + +-- .CheckContinueWaterfall, the bottom of Script_UsedWaterfall's loop: TRUE +-- when the tile the player has just climbed onto is ANOTHER waterfall tile, +-- and the `iffalse .loop` reads it inverted -- 0 keeps climbing. +function H.CheckContinueWaterfall(ctx) + local coll = call(ctx, "playerCollision") + local FieldMoves = require("src.world.gen2.FieldMoves") + return FieldMoves.waterfallContinues(coll) and 1 or 0 +end + +-- SetStrengthFlag: BIKEFLAGS_STRENGTH_ACTIVE, wStrengthSpecies from the mon +-- CheckPartyMove picked, and then a tail call into GetPartyNickname. It runs +-- FIRST, before the text, which is why the line that follows can name the mon. +function H.SetStrengthFlag(ctx) + ctx.strengthActive = true + local mon = ctx and ctx.curPartyMon + if mon then ctx.strengthSpecies = mon.species end + return H.GetPartyNickname(ctx) +end + +-- TryStrengthOW's three answers, and note the inversion in the cart: `bit +-- BIKEFLAGS_STRENGTH_ACTIVE_F` jumps to .already_using when the bit is CLEAR, +-- so 2 is the not-yet case and 0 the already-on one. +-- 0 STRENGTH is already active +-- 1 no mon knows it, or no PLAINBADGE +-- 2 it may be switched on right now +function H.TryStrengthOW(ctx) + local FieldMoves = require("src.world.gen2.FieldMoves") + local fieldCtx = call(ctx, "fieldContext") + if not fieldCtx then return 1 end + local result = FieldMoves.tryStrengthOW(fieldCtx) + if result.ok then return 2 end + if fieldCtx.strengthActive then return 0 end + return 1 +end + +-- .CheckMap, inside AskCutScript and reached only after the YES: it is +-- CheckMapForSomethingToCut with the answer inverted into wScriptVar, so the +-- `iftrue Script_Cut` that follows fires on 1. +function H.AskCutScript_CheckMap(ctx) + local FieldMoves = require("src.world.gen2.FieldMoves") + local fieldCtx = call(ctx, "fieldContext") + if not fieldCtx then return 0 end + return FieldMoves.somethingToCut(fieldCtx) and 1 or 0 +end + +-- HasRockSmash is INVERTED: `call CheckPartyMove / jr nc, .yes` puts 1 in +-- wScriptVar when the party does NOT know ROCK SMASH, which is why +-- AskRockSmashScript reads it with `ifequal 1, .no`. Transcribing it the +-- obvious way round refuses the move for every party that has it. +function H.HasRockSmash(ctx) + return call(ctx, "partyMoveUser", "ROCK_SMASH") and 0 or 1 +end + +-- PutTheRodAway: ClearBox over the text window, then wPlayerAction back to +-- PLAYER_NORMAL so the fishing sprite drops. Two of the fishing scripts end +-- on it and one has it in the middle, and in this port the whole rod pose is +-- World's `fishing` state -- dropping it IS wPlayerAction going back to +-- normal. It has to be gone before a battle is pushed, or World:busy would +-- still be holding the world when the battle returns. +function H.PutTheRodAway(ctx) + if ctx then ctx.fishing = nil end + return nil +end + +-- Fishing_CheckFacingUp: `and $c / cp OW_UP`. The rod is only cast upward +-- from a shore tile, and a 1 here is what lets the bite script run. +function H.Fishing_CheckFacingUp(ctx) + local player = ctx and ctx.player + return (player and player.facing == "up") and 1 or 0 +end + +-- ---- engine/events/treemons.asm ------------------------------------------- + +-- TreeMonEncounter: GetTreeMonSet / GetTreeMons / GetTreeMon, and +-- BATTLETYPE_TREE plus wScriptVar = 1 only if all three came back with carry. +-- The roll needs the tree's own cell, which HeadbuttScript left in the object +-- coordinates; `ctx.curHeadbuttCell` is the port's name for the same pair. +-- +-- World:tryHeadbutt fuses the roll with the `startbattle` that follows it in +-- the script, so a "battle" answer here is the 1 and everything else the 0. +-- That fusing is why the port's own headbutt path calls tryHeadbutt directly +-- rather than coming through this routine. +function H.TreeMonEncounter(ctx) + local cell = ctx and ctx.curHeadbuttCell + if not cell then return 0 end + return call(ctx, "tryHeadbutt", cell[1], cell[2]) == "battle" and 1 or 0 +end + +-- RockMonEncounter is that same twin over RockMonMaps with a flat 40 percent +-- (`ld a, 10 / RandomRange / cp 4`) where the tree has its coordinate score. +-- It writes NO wScriptVar: RockSmashScript reads the answer back one row later +-- with `readmem wTempWildMonSpecies / iffalse`, so returning a number here +-- would decide a branch the cart decides some other way. World's own routine +-- leaves the pair where that readmem can see it, and where the `randomwildmon` +-- two rows further down takes it into the battle. +function H.RockMonEncounter(ctx) + call(ctx, "rockMonEncounter") + return nil +end + +-- ---- engine/events/sweet_scent.asm ---------------------------------------- + +-- SweetScentEncounter: the same CanEncounterWildMon gate a step takes, but +-- every percentage roll downstream is skipped -- GetMapEncounterRate only has +-- to come back nonzero. World:sweetScentEncounter is that whole routine. +function H.SweetScentEncounter(ctx) + return call(ctx, "sweetScentEncounter") and 1 or 0 +end + +-- ---- engine/events/trainer_scripts.asm ------------------------------------ + +-- TrainerWalkToPlayer builds the movement that closes the gap and hands it to +-- the applymovement that follows. The World owns the path, so this is the +-- same seam the VM's `trainerapproach` branch uses. No wScriptVar. +function H.TrainerWalkToPlayer(ctx) + call(ctx, "trainerApproach") + return nil +end + +-- ---- engine/events/squirtbottle.asm --------------------------------------- + +-- .CheckCanUseSquirtbottle: Route 36, and the object the player is facing has +-- to carry SPRITEMOVEDATA_SUDOWOODO. Anything else is a 0 and the item says +-- nothing happened. GetFacingObject's own carry (nobody there) is the same 0. +function H.CheckCanUseSquirtbottle(ctx) + local map = ctx and ctx.map + if not (map and map.id == "ROUTE_36") then return 0 end + local player = ctx.player + if not player then return 0 end + local Map = require("src.world.gen2.Map") + local delta = Map.DELTA[player.facing or "down"] or Map.DELTA.down + local npc = call(ctx, "npcAt", player.cellX + delta[1], player.cellY + delta[2]) + local def = npc and npc.def + if def and def.movement == SPRITEMOVEDATA_SUDOWOODO then return 1 end + return 0 +end + +-- ---- engine/events/hidden_item.asm ---------------------------------------- + +-- SetMemEvent sets the flag whose number is sitting in wHiddenItemEvent, the +-- word the `hiddenitem` bg_event operand carries beside the item. It runs +-- AFTER the giveitem, so a full bag leaves the item on the map. +-- +-- wEventFlags is keyed by NUMBER: the operand is already one, and passing a +-- name here would throw. +function H.SetMemEvent(ctx) + local flag = ctx and ctx.hiddenItemEvent + if flag and ctx.events then ctx.events:set(flag, true) end + return nil +end + +-- ---- engine/events/poisonstep.asm ----------------------------------------- + +-- .PlayPoisonSFX: SFX_POISON, then LoadPoisonBGPals for two frames. The port +-- plays the sound; the two-frame red flash is the renderer's business. +function H.PlayPoisonSFX(ctx) + call(ctx, "playSfxNamed", SFX_POISON[1], SFX_POISON[2]) + return nil +end + +-- .CheckWhitedOut walks wPoisonStepPartyFlags, applies HAPPINESS_POISONFAINT +-- and prints one line per mon that actually dropped, and ends on +-- CheckPlayerPartyForFitMon -- whose `d` is 1 when something can still fight. +-- The `iffalse .whiteout` that follows is therefore reading "no fit mon". +-- +-- The happiness hit and the naming are World:poisonFaintScript's, because they +-- have to be spread over as many text boxes as there were faints; what is left +-- for this routine is the answer. +function H.CheckWhitedOut(ctx) + local StepEvents = require("src.world.gen2.StepEvents") + local save = saveOf(ctx) + local party = (save and save.party) or {} + return StepEvents.whitedOut(party) and 0 or 1 +end + +-- ---- engine/events/whiteout.asm ------------------------------------------- + +-- HalveMoney shifts wMoney right one bit as a 24-bit big-endian value: +-- `srl a` on the high byte then `rra` twice, so the carry walks down and the +-- whole three bytes end up halved with the remainder dropped. That is floor +-- division for every value wMoney can hold, and the wallet ALONE -- Mom's +-- savings are a separate three bytes the routine never reaches. +function H.HalveMoney(ctx) + local save = saveOf(ctx) + local player = save and save.player + if not player then return nil end + player.money = math.floor((player.money or 0) / 2) + return nil +end + +-- GetWhiteoutSpawn validates wLastSpawnMapGroup / wLastSpawnMapNumber (the +-- pair `blackoutmod` writes) against the SpawnPoints table and falls back to +-- SPAWN_HOME when it is not one of them, which is what stops a whiteout at sea +-- from respawning the player somewhere they cannot leave. +-- +-- Registered and ported, but NOT yet consumed: World:warpToSpawn prefers the +-- blackout map itself over the SPAWN_* row it matches, which is a deliberate +-- divergence tests/gen2_world_test.lua pins. The spawn id is left on the ctx +-- for the day that changes. +function H.GetWhiteoutSpawn(ctx) + local save = saveOf(ctx) + local override = save and save.blackoutMap + local spawns = ctx and ctx.landmarks and ctx.landmarks.spawns + local answer = SPAWN_HOME + if override and type(spawns) == "table" then + for id, row in pairs(spawns) do + if type(row) == "table" and row.map == override then + answer = id + break + end + end + end + ctx.defaultSpawnpoint = answer + return nil +end + +-- ---- stubs ----------------------------------------------------------------- +-- +-- { name, wScriptVar or nil, reason }. A nil second field is the whole point +-- for most of these: the asm writes no wScriptVar, so neither does the stub. +local STUB_ROWS = { + -- Graphics and VRAM. Every one of these is a tilemap, palette or sprite + -- reload around an animation the port's renderer draws its own way; there is + -- no VRAM here to load anything into. + { "OverworldBGMap", nil, "ClearPalettes / ClearScreen / RotateThreePalettesLeft: the fade to white is the renderer's" }, + { "BattleBGMap", nil, "SCGB_BATTLE_GRAYSCALE through GetSGBLayout: no SGB layout in this port" }, + { "HideSprites", nil, "clears OAM for the FLY animation; the port hides the party sprite itself" }, + { "FlyFromAnim", nil, "the bird's own frames; World:flyTo lifts the player under the setup script's fade instead" }, + { "FlyToAnim", nil, "the landing frames; the same lift read backwards under the fade in" }, + { "SkipUpdateMapSprites", nil, "suppresses one UpdateMapSprites while FLY is mid-air" }, + { "LoadWalkingSpritesGFX", nil, "reloads the walking sprite bank after FLY lands" }, + { "LoadFishingGFX", nil, "the rod and bobber tiles, which the extractor does not carry; World:updateFishing bobs the player instead" }, + { "ShakeHeadbuttTree", nil, "the tree wobble frames; World:runHeadbutt owns the shake and the outcome" }, + -- The START menu is a pushed screen with its own lifetime, not something a + -- script coroutine can block on the way the cart's StartMenu does. + { "StartMenu", nil, "the START menu is a Screens.push state, not a blocking call" }, + -- The phone. These four write into wCallerContact / wPhoneScriptBank so + -- that the `memcall` two lines later has somewhere to jump. The port's + -- call descriptors carry their scripts.lua key instead (bank $41 is + -- extracted through PhoneContacts now), so the pointer plumbing has nothing + -- to store: src/core/gen2/PhoneRing.lua hands the VM the caller script + -- directly, and RingTwice_StartCall / InitCallReceiveDelay above are the + -- two halves of Script_ReceivePhoneCall that still had asm to port. + { "HangUp", nil, "the VM's own `hangup` op carries the Click! and SFX_HANG_UP; PhoneRing.script uses it in this row's place" }, + { "LoadBillScript", nil, "writes wCallerContact for Script_SpecialBillCall; the call descriptor names the script key instead" }, + { "LoadElmScript", nil, "writes wCallerContact for Script_SpecialElmCall; the call descriptor names the script key instead" }, + { "MomTriesToBuySomething_ASMFunction", nil, "queues Mom's pages into wCallerContact; World:momTriesToBuy hands the same rows to PhoneRing.script" }, + -- wEnabledPlayerEvents has no home in this port: nothing ever CLEARS + -- PLAYEREVENTS_WILD_ENCOUNTERS here, so re-enabling it is already the + -- standing state and a write would be a write nobody reads. Not the same + -- byte as STATUSFLAGS_NO_WILD_ENCOUNTERS_F, which `wildoff` owns. + { "EnableWildEncounters", nil, "the port has no wEnabledPlayerEvents; the bit is never cleared" }, + -- FindItemInBallScript's opener. The port reaches Poke Ball items through + -- the object's own itemball row rather than through this script, so the + -- ReceiveItem here has no bag to write to and no wItemBallItemID to read. + -- A 0 or a 1 would pick the "no room" arm at random, so it stays nil. + { "TryReceiveItem", nil, "wItemBallItemID / wItemBallQuantity are set by a script path this port does not run" }, +} + +CallAsm.HANDLERS = H +CallAsm.STUBS = {} +CallAsm.STUB_REASONS = {} + +for _, row in ipairs(STUB_ROWS) do + local name, value, reason = row[1], row[2], row[3] + CallAsm.STUB_REASONS[name] = reason + CallAsm.STUBS[name] = function() + return value + end +end + +-- The merged table, built rather than written out so the two sets cannot +-- drift and so a name in both is a hard error here instead of a silent shadow. +CallAsm.ALL = {} +for name, fn in pairs(CallAsm.HANDLERS) do + CallAsm.ALL[name] = fn +end +for name, fn in pairs(CallAsm.STUBS) do + if CallAsm.ALL[name] then + error("gen2 callasm routine '" .. name .. "' is both implemented and stubbed", 0) + end + CallAsm.ALL[name] = fn +end + +-- ---- dispatch -------------------------------------------------------------- + +-- The `bank:addr` key, lower case hex, two digits and four -- the same shape +-- data/generated/scripts.lua uses for its own script keys, so the two can be +-- compared by eye. +function CallAsm.key(bank, addr) + return string.format("%02x:%04x", (bank or 0) % 0x100, (addr or 0) % 0x10000) +end + +-- The routine name for a site, or nil. `label` wins when the extractor has +-- one (it does not today: nothing resolves bank:addr against the symbol file +-- at import time), and the address pair is the fallback that always works. +function CallAsm.nameFor(label, bank, addr) + if label and CallAsm.ALL[label] then return label end + return CallAsm.SITES[CallAsm.key(bank, addr)] +end + +-- Run a routine by name. Returns the byte the asm leaves in wScriptVar, or +-- nil when it writes none -- and an unknown name is nil too, because a routine +-- nobody has ported must not decide a branch. +function CallAsm.run(ctx, name) + local fn = CallAsm.ALL[name] + if not fn then return nil end + local ok, value = pcall(fn, ctx) + -- A handler that throws is a bug, but it must not take the script down with + -- it: the cart's own callasm cannot fail, so the branch after it still has + -- to see the "left alone" answer rather than an aborted coroutine. + if not ok then return nil end + if type(value) ~= "number" then return nil end + return value % 0x100 +end + +-- The VM seam: src/script/gen2/Vm.lua's `callasm` / `memcallasm` branch, via +-- World:callAsm. Nil for anything not in the table, which is every row in +-- this cache -- all thirty-eight of them are mis-decoded `hiddenitem` data. +function CallAsm.dispatch(ctx, label, bank, addr) + local name = CallAsm.nameFor(label, bank, addr) + if not name then return nil end + return CallAsm.run(ctx, name) +end + +return CallAsm diff --git a/src/script/gen2/Movement.lua b/src/script/gen2/Movement.lua new file mode 100644 index 00000000..24cece57 --- /dev/null +++ b/src/script/gen2/Movement.lua @@ -0,0 +1,180 @@ +-- Gen 2 overworld movement-script helpers (macros/scripts/movement.asm). +-- Import stores raw byte streams; the world steps them one command at a time. + +local bit = require("bit") + +local Movement = {} + +local DIR = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" } +Movement.DIR = DIR +Movement.FACING = DIR + +-- High nibble / command family (low 2 bits = facing when directional). +local STEP_END = 0x47 +local STEP_WAIT_END = 0x48 +-- The two warp-exit bytes. Each of StepFunction_TeleportFrom's two beats runs +-- for 16 frames (`ld [hl], 16` into OBJECT_STEP_DURATION), and TeleportTo's +-- three -- wait, descent, final spin -- for 16 each. +local TELEPORT_FROM = 0x4c +local TELEPORT_TO = 0x4d +local TELEPORT_BEAT_FRAMES = 16 +local TELEPORT_FROM_FRAMES = 2 * TELEPORT_BEAT_FRAMES +local TELEPORT_TO_FRAMES = 3 * TELEPORT_BEAT_FRAMES +-- The four OBJECT_FLAGS1 control bytes (macros/scripts/movement.asm:78-96). +-- None of them consume a frame: each Movement_* handler ends in +-- `jp ContinueReadingMovement` (engine/overworld/movement.asm:353-375), so the +-- next byte is read in the same pass. +local REMOVE_SLIDING = 0x38 +local SET_SLIDING = 0x39 +local REMOVE_FIXED_FACING = 0x3a +local FIX_FACING = 0x3b +-- Movement_tree_shake (engine/overworld/movement.asm:334): 24 frames of +-- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE. +local TREE_SHAKE = 0x56 +local TREE_SHAKE_FRAMES = 24 + +function Movement.dir(nibble) + return DIR[nibble % 4] or "down" +end + +-- Decode one byte into an action table, or nil to ignore / end. +-- Returns { kind="step"|"turn"|"sleep"|"end", dir=?, frames=? }. +function Movement.decodeByte(b) + if b == STEP_END or b == STEP_WAIT_END then + return { kind = "end" } + end + local family = bit.band(b, 0xfc) + local dir = Movement.dir(bit.band(b, 0x03)) + if family == 0x00 then -- turn_head + return { kind = "turn", dir = dir } + elseif family == 0x04 then -- turn_step (face then step) + return { kind = "step", dir = dir } + elseif family == 0x08 then -- slow_step + return { kind = "step", dir = dir } + elseif family == 0x0c then -- step + return { kind = "step", dir = dir } + elseif family == 0x10 then -- big_step + return { kind = "step", dir = dir } + elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides + return { kind = "step", dir = dir } + elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall + return { kind = "turn", dir = dir } + elseif family == 0x2c or family == 0x30 or family == 0x34 then + -- slow_jump_step / jump_step / fast_jump_step, all of which reach + -- JumpStep (engine/overworld/movement.asm:741) and so run under + -- StepFunction_NPCJump (engine/overworld/map_objects.asm:1129). That + -- step type is TWO beats: `.Jump` walks a cell and then calls GetNextTile + -- a second time (map_objects.asm:1143), which re-advances OBJECT_MAP_X/Y, + -- and `.Land` walks that second cell. A jump therefore covers two cells, + -- not one; folding it into a plain step left every scripted jump a cell + -- short and carried the offset through the rest of the stream (the Ilex + -- Forest Farfetch'd ended up walking its last six UP steps through the + -- trees one column over). + return { kind = "jump", dir = dir } + elseif b == SET_SLIDING or b == REMOVE_SLIDING then + -- Movement_set_sliding / _remove_sliding toggle SLIDING_F in + -- OBJECT_FLAGS1 (engine/overworld/movement.asm:353-363). Both + -- SetFacingStepAction and SetFacingBumpAction bail straight to + -- SetFacingCurrent while it is set (engine/overworld/map_object_action.asm + -- :48 and :74), so the object keeps BOTH its facing and its current step + -- frame for the whole stream: it is a slide, not a walk. Dropping the + -- byte is what turned the three beasts around to face the way they flee + -- out of the Burned Tower and gave them a walk cycle on the way + -- (maps/BurnedTowerB1F.asm:103-125 wrap every beast stream in it). + return { kind = "sliding", on = (b == SET_SLIDING) } + elseif b == FIX_FACING or b == REMOVE_FIXED_FACING then + -- Movement_fix_facing / _remove_fixed_facing toggle FIXED_FACING_F in + -- OBJECT_FLAGS1 (engine/overworld/movement.asm:365-375). InitStep tests + -- it and jumps PAST the write to OBJECT_DIRECTION + -- (engine/overworld/map_objects.asm:284-294), so a fixed-facing object + -- steps without turning; ApplyObjectFacing refuses it too + -- (engine/overworld/scripting.asm:856), which is why faceplayer cannot + -- turn one either. Route 30's two Rattata lunge under it + -- (maps/Route30.asm:183-193) and ended up facing away from the fight. + return { kind = "fixfacing", fixed = (b == FIX_FACING) } + elseif b == 0x3c or b == 0x3d then -- show/hide object + return { kind = "nop" } + elseif b >= 0x3e and b <= 0x46 then -- step_sleep N + return { kind = "sleep", frames = (b - 0x3e + 1) * 16 } + elseif b == TELEPORT_FROM or b == TELEPORT_TO then + -- Movement_teleport_from / _to set STEP_TYPE_TELEPORT_FROM / _TO + -- (engine/overworld/movement.asm:95), whose step functions are + -- StepFunction_TeleportFrom / _TeleportTo in + -- engine/overworld/map_objects.asm: a spin on the spot, then a spinning + -- rise (or a wait, a spinning descent and a last spin) driven off + -- OBJECT_JUMP_HEIGHT through Sine. Without a branch here the byte fell off + -- the family ladder and read as `nop`, so Lance blinked out of the Lake of + -- Rage and Blue off Cinnabar with no animation at all. + return { + kind = "teleport", + mode = (b == TELEPORT_FROM) and "from" or "to", + frames = (b == TELEPORT_FROM) and TELEPORT_FROM_FRAMES + or TELEPORT_TO_FRAMES, + } + elseif b == TREE_SHAKE then + -- Movement_tree_shake (engine/overworld/movement.asm:334) puts 24 in + -- OBJECT_STEP_DURATION, sets STEP_TYPE_SLEEP and sets OBJECT_ACTION to + -- OBJECT_ACTION_WEIRD_TREE, so SetFacingWeirdTree + -- (engine/overworld/map_object_action.asm:204) rocks the tree through + -- FACING_WEIRD_TREE_0..3 for those 24 frames. $56 sits above the + -- step_sleep window and matches no family, so it used to reach the + -- trailing nop: the world then ate it and the step_end after it in one + -- pass and Sudowoodo's shake played in zero frames + -- (maps/Route36.asm:260-262 SudowoodoShakeMovement is this byte alone). + return { kind = "treeshake", frames = TREE_SHAKE_FRAMES } + end + return { kind = "nop" } +end + +function Movement.isEnd(b) + return b == STEP_END or b == STEP_WAIT_END +end + +Movement.STEP_END = STEP_END +Movement.STEP_WAIT_END = STEP_WAIT_END +Movement.TELEPORT_FROM = TELEPORT_FROM +Movement.TELEPORT_TO = TELEPORT_TO +Movement.TELEPORT_BEAT_FRAMES = TELEPORT_BEAT_FRAMES +Movement.REMOVE_SLIDING = REMOVE_SLIDING +Movement.SET_SLIDING = SET_SLIDING +Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING +Movement.FIX_FACING = FIX_FACING +Movement.TREE_SHAKE = TREE_SHAKE +Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES + +-- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE +-- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count +-- that reaches the facing table is frame+1 and the quarter changes every four +-- frames (engine/overworld/map_object_action.asm:204). FacingWeirdTree0 and +-- FacingWeirdTree2 are FacingStepDown0's tiles $00-$03, FacingWeirdTree1 is +-- $04-$07 and FacingWeirdTree3 is those same four with the columns swapped and +-- OAM_XFLIP on each, i.e. the mirror image (data/sprites/facings.asm:46-52, +-- :185-190, :192-197). +function Movement.treeShakeIndex(frame) + return math.floor(((frame or 0) + 1) % 16 / 4) % 4 +end + +-- Sine (home/sine.asm) with the amplitude StepFunction_TeleportFrom passes: +-- `ld d, $60 / call Sine / ld a, h / sub $60`, i.e. the high byte of +-- $60 * sin(height * 2pi / 64) minus $60. Both teleport beats walk +-- OBJECT_JUMP_HEIGHT through this and write the answer to +-- OBJECT_SPRITE_Y_OFFSET, so the sprite lifts a hundred-odd pixels off its +-- tile over sixteen frames and comes back down the same curve. +function Movement.teleportYOffset(height) + return math.floor(0x60 * math.sin((height % 64) * math.pi / 32)) - 0x60 +end + +-- OBJECT_JUMP_HEIGHT's own start values: $10 for the rise (.InitSpinRise) and +-- 0 for the descent (.InitDescent). +Movement.TELEPORT_RISE_HEIGHT = 0x10 +Movement.TELEPORT_FALL_HEIGHT = 0 + +local DIR_BYTE = { down = 0, up = 1, left = 2, right = 3 } + +-- The `step <dir>` family ($0c-$0f), which is what InitMovementBuffer fills +-- for a trainer walking up to the player. +function Movement.stepByte(dir) + return 0x0c + (DIR_BYTE[dir] or 0) +end + +return Movement diff --git a/src/script/gen2/Opcodes.lua b/src/script/gen2/Opcodes.lua new file mode 100644 index 00000000..80c34eb2 --- /dev/null +++ b/src/script/gen2/Opcodes.lua @@ -0,0 +1,214 @@ +-- Gen 2 script opcodes from pokegold/macros/scripts/events.asm. +-- `size` is operand bytes after the opcode (for import-time disassembly). + +local Opcodes = { + [0x00] = { name = "scall", size = 2 }, + [0x01] = { name = "farscall", size = 3 }, + [0x02] = { name = "memcall", size = 2 }, + [0x03] = { name = "sjump", size = 2 }, + [0x04] = { name = "farsjump", size = 3 }, + [0x05] = { name = "memjump", size = 2 }, + [0x06] = { name = "ifequal", size = 3 }, + [0x07] = { name = "ifnotequal", size = 3 }, + [0x08] = { name = "iffalse", size = 2 }, + [0x09] = { name = "iftrue", size = 2 }, + [0x0a] = { name = "ifgreater", size = 3 }, + [0x0b] = { name = "ifless", size = 3 }, + [0x0c] = { name = "jumpstd", size = 2 }, + [0x0d] = { name = "callstd", size = 2 }, + [0x0e] = { name = "callasm", size = 3 }, + [0x0f] = { name = "special", size = 2 }, + [0x10] = { name = "memcallasm", size = 2 }, + [0x11] = { name = "checkmapscene", size = 2 }, + [0x12] = { name = "setmapscene", size = 3 }, + [0x13] = { name = "checkscene", size = 0 }, + [0x14] = { name = "setscene", size = 1 }, + [0x15] = { name = "setval", size = 1 }, + [0x16] = { name = "addval", size = 1 }, + [0x17] = { name = "random", size = 1 }, + [0x18] = { name = "checkver", size = 0 }, + [0x19] = { name = "readmem", size = 2 }, + [0x1a] = { name = "writemem", size = 2 }, + [0x1b] = { name = "loadmem", size = 3 }, + [0x1c] = { name = "readvar", size = 1 }, + [0x1d] = { name = "writevar", size = 1 }, + [0x1e] = { name = "loadvar", size = 2 }, + [0x1f] = { name = "giveitem", size = 2 }, + [0x20] = { name = "takeitem", size = 2 }, + [0x21] = { name = "checkitem", size = 1 }, + [0x22] = { name = "givemoney", size = 4 }, -- account + 3-byte money (macro) + [0x23] = { name = "takemoney", size = 4 }, + [0x24] = { name = "checkmoney", size = 4 }, + [0x25] = { name = "givecoins", size = 2 }, + [0x26] = { name = "takecoins", size = 2 }, + [0x27] = { name = "checkcoins", size = 2 }, + [0x28] = { name = "addcellnum", size = 1 }, + [0x29] = { name = "delcellnum", size = 1 }, + [0x2a] = { name = "checkcellnum", size = 1 }, + [0x2b] = { name = "checktime", size = 1 }, + [0x2c] = { name = "checkpoke", size = 1 }, + -- Variable length in ROM (4, or 8 when trainer≠0); extractor special-cases it. + [0x2d] = { name = "givepoke", size = 4 }, + [0x2e] = { name = "giveegg", size = 2 }, + [0x2f] = { name = "givepokemail", size = 2 }, + [0x30] = { name = "checkpokemail", size = 2 }, + [0x31] = { name = "checkevent", size = 2 }, + [0x32] = { name = "clearevent", size = 2 }, + [0x33] = { name = "setevent", size = 2 }, + [0x34] = { name = "checkflag", size = 2 }, + [0x35] = { name = "clearflag", size = 2 }, + [0x36] = { name = "setflag", size = 2 }, + [0x37] = { name = "wildon", size = 0 }, + [0x38] = { name = "wildoff", size = 0 }, + [0x39] = { name = "xycompare", size = 2 }, + [0x3a] = { name = "warpmod", size = 3 }, + [0x3b] = { name = "blackoutmod", size = 2 }, + [0x3c] = { name = "warp", size = 4 }, + [0x3d] = { name = "getmoney", size = 2 }, + [0x3e] = { name = "getcoins", size = 1 }, + [0x3f] = { name = "getnum", size = 1 }, + [0x40] = { name = "getmonname", size = 2 }, + [0x41] = { name = "getitemname", size = 2 }, + [0x42] = { name = "getcurlandmarkname", size = 1 }, + [0x43] = { name = "gettrainername", size = 3 }, + [0x44] = { name = "getstring", size = 3 }, + [0x45] = { name = "itemnotify", size = 0 }, + [0x46] = { name = "pocketisfull", size = 0 }, + [0x47] = { name = "opentext", size = 0 }, + [0x48] = { name = "reanchormap", size = 1 }, + [0x49] = { name = "closetext", size = 0 }, + [0x4a] = { name = "writeunusedbyte", size = 1 }, + [0x4b] = { name = "farwritetext", size = 3 }, + [0x4c] = { name = "writetext", size = 2 }, + [0x4d] = { name = "repeattext", size = 2 }, + [0x4e] = { name = "yesorno", size = 0 }, + [0x4f] = { name = "loadmenu", size = 2 }, + [0x50] = { name = "closewindow", size = 0 }, + [0x51] = { name = "jumptextfaceplayer", size = 2 }, + [0x52] = { name = "jumptext", size = 2 }, + [0x53] = { name = "waitbutton", size = 0 }, + [0x54] = { name = "promptbutton", size = 0 }, + [0x55] = { name = "pokepic", size = 1 }, + [0x56] = { name = "closepokepic", size = 0 }, + [0x57] = { name = "_2dmenu", size = 0 }, + [0x58] = { name = "verticalmenu", size = 0 }, + [0x59] = { name = "loadpikachudata", size = 0 }, + [0x5a] = { name = "randomwildmon", size = 0 }, + [0x5b] = { name = "loadtemptrainer", size = 0 }, + [0x5c] = { name = "loadwildmon", size = 2 }, + [0x5d] = { name = "loadtrainer", size = 2 }, + [0x5e] = { name = "startbattle", size = 0 }, + [0x5f] = { name = "reloadmapafterbattle", size = 0 }, + [0x60] = { name = "catchtutorial", size = 1 }, + [0x61] = { name = "trainertext", size = 1 }, + [0x62] = { name = "trainerflagaction", size = 1 }, + [0x63] = { name = "winlosstext", size = 4 }, + [0x64] = { name = "scripttalkafter", size = 0 }, + [0x65] = { name = "endifjustbattled", size = 0 }, + [0x66] = { name = "checkjustbattled", size = 0 }, + [0x67] = { name = "setlasttalked", size = 1 }, + [0x68] = { name = "applymovement", size = 3 }, + [0x69] = { name = "applymovementlasttalked", size = 2 }, + [0x6a] = { name = "faceplayer", size = 0 }, + [0x6b] = { name = "faceobject", size = 2 }, + [0x6c] = { name = "variablesprite", size = 2 }, + [0x6d] = { name = "disappear", size = 1 }, + [0x6e] = { name = "appear", size = 1 }, + [0x6f] = { name = "follow", size = 2 }, + [0x70] = { name = "stopfollow", size = 0 }, + [0x71] = { name = "moveobject", size = 3 }, + [0x72] = { name = "writeobjectxy", size = 1 }, + [0x73] = { name = "loademote", size = 1 }, + [0x74] = { name = "showemote", size = 3 }, + [0x75] = { name = "turnobject", size = 2 }, + [0x76] = { name = "follownotexact", size = 2 }, + [0x77] = { name = "earthquake", size = 1 }, + [0x78] = { name = "changemapblocks", size = 3 }, + [0x79] = { name = "changeblock", size = 3 }, + [0x7a] = { name = "reloadmap", size = 0 }, + [0x7b] = { name = "refreshmap", size = 0 }, + [0x7c] = { name = "writecmdqueue", size = 2 }, + [0x7d] = { name = "delcmdqueue", size = 1 }, + [0x7e] = { name = "playmusic", size = 2 }, + [0x7f] = { name = "encountermusic", size = 0 }, + [0x80] = { name = "musicfadeout", size = 3 }, + [0x81] = { name = "playmapmusic", size = 0 }, + [0x82] = { name = "dontrestartmapmusic", size = 0 }, + [0x83] = { name = "cry", size = 2 }, + [0x84] = { name = "playsound", size = 2 }, + [0x85] = { name = "waitsfx", size = 0 }, + [0x86] = { name = "warpsound", size = 0 }, + [0x87] = { name = "specialsound", size = 0 }, + [0x88] = { name = "autoinput", size = 3 }, + [0x89] = { name = "newloadmap", size = 1 }, + [0x8a] = { name = "pause", size = 1 }, + [0x8b] = { name = "deactivatefacing", size = 1 }, + [0x8c] = { name = "sdefer", size = 2 }, + [0x8d] = { name = "warpcheck", size = 0 }, + [0x8e] = { name = "stopandsjump", size = 2 }, + [0x8f] = { name = "endcallback", size = 0 }, + [0x90] = { name = "end", size = 0 }, + [0x91] = { name = "reloadend", size = 1 }, + [0x92] = { name = "endall", size = 0 }, + [0x93] = { name = "pokemart", size = 3 }, + [0x94] = { name = "elevator", size = 2 }, + [0x95] = { name = "trade", size = 1 }, + [0x96] = { name = "askforphonenumber", size = 1 }, + [0x97] = { name = "phonecall", size = 2 }, + [0x98] = { name = "hangup", size = 0 }, + [0x99] = { name = "describedecoration", size = 1 }, + [0x9a] = { name = "fruittree", size = 1 }, + [0x9b] = { name = "specialphonecall", size = 2 }, + [0x9c] = { name = "checkphonecall", size = 0 }, + [0x9d] = { name = "verbosegiveitem", size = 2 }, + -- `swarm` is a bare `map_id` (macros/scripts/events.asm: `db swarm_command / + -- map_id \1`), and map_id is two bytes; Script_swarm makes exactly two + -- GetScriptByte calls. This row said 3, which ate the opcode byte after + -- every swarm and shifted the rest of that script by one. The four users all + -- live in engine/phone/scripts/trainers.asm, which the pointer walk does not + -- reach yet, so nothing in today's cache is mis-decoded by it. + [0x9e] = { name = "swarm", size = 2 }, + [0x9f] = { name = "halloffame", size = 0 }, + [0xa0] = { name = "credits", size = 0 }, + [0xa1] = { name = "warpfacing", size = 5 }, +} + +-- Commands that end the current linear path (jumps transfer control). +-- +-- `fruittree` and `describedecoration` are ScriptJumps (Script_fruittree does +-- `jp ScriptJump` into FruitTreeScript, Script_describedecoration jumps to +-- whatever DescribeDecoration hands back), and `halloffame` / `credits` both +-- fall into ReturnFromCredits, which is Script_endall plus MAPSTATUS_DONE. +-- Without them the disassembler kept walking the data that follows: all 42 +-- extracted fruittree scripts and all 13 describedecoration ones currently +-- carry a tail of garbage commands, which is why Vm.lua returns on each of +-- 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. +Opcodes.TERMINATORS = { + sjump = true, farsjump = true, memjump = true, jumpstd = true, + jumptext = true, jumptextfaceplayer = true, + stopandsjump = true, ["end"] = true, endall = true, endcallback = true, + reloadend = true, + fruittree = true, describedecoration = true, + halloffame = true, credits = true, +} + +-- The one op name this engine adds, and the reason it deliberately has NO byte +-- 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. +-- +-- src/script/gen2/Vm.lua:runModCommand is the only reader; the contract for the +-- row shapes and the verb table is documented there. +Opcodes.MOD_COMMAND = "modcommand" + +function Opcodes.key(bank, address) + return string.format("%02x:%04x", bank, address) +end + +return Opcodes diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua new file mode 100644 index 00000000..5b6c6451 --- /dev/null +++ b/src/script/gen2/Specials.lua @@ -0,0 +1,2465 @@ +-- 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 +-- INDEPENDENT routines that happen to share a dispatch table. Growing them +-- inside runList's else-chain would have buried the interpreter. +-- +-- Dispatch is by NAME. The script byte is an index into SpecialsPointers, +-- 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. +-- +-- Three kinds of entry live here: +-- +-- HANDLERS the routine, ported. Most read or write wScriptVar, so each +-- takes the Vm and leaves its answer in `vm.scriptVar`, exactly +-- as the asm leaves it in wScriptVar. +-- STUBS deliberately out of scope, with the reason written down and a +-- SANE return rather than a fall-through. Everything link cable, +-- Mystery Gift and the printer is here: those need a second +-- console or a Game Boy Printer, and a script that asks and gets +-- no answer at all takes the wrong branch. +-- ALL the merge, which is what Vm.SPECIALS is. The two sets are +-- disjoint by construction and tests/gen2_vm_test.lua asserts it. +-- +-- Everything a handler needs from the game is one call into `vm.specials`, the +-- hook table src/world/gen2/World.lua builds in World:specialHooks. A handler +-- with no hooks at all still runs and still leaves the right wScriptVar: that +-- is what makes the whole table testable headless. +-- +-- BLOCKING. A handler runs INSIDE the VM's coroutine (runList calls +-- Vm:runSpecial), so it may yield, and Specials.block below is how it parks on +-- a screen: the async work is started first and the coroutine only yields if +-- the callback has not already fired, which is what lets the same handler work +-- against a real pushed screen and against a test stub that answers on the +-- spot. The parked yield carries a kind Vm:resume does not recognise, and +-- that is deliberate: it means "nothing to do, wait", and the screen's own +-- callback is the only thing that can start the script again. + +-- Two cart tables this file USES but must not re-transcribe. Each already has +-- exactly one home, and a second copy here is how the pair drift apart: +-- Happiness data/events/happiness_changes.asm, plus ChangeHappiness's tier +-- pick, its two carry clamps and its `cp EGG / ret z`. +-- Roamers InitRoamMons' three wRoamMon structs, plus the roam walk that +-- indexes them BY SLOT. +-- BugContest data/wild/bug_contest_mons.asm, ContestScore's tally, the ten +-- contestants and the podium, plus the twenty minute clock. +-- Apricorns data/items/apricorn_balls.asm and FindApricornsInBag's walk of +-- 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 Happiness = require("src.core.gen2.Happiness") +local Phone = require("src.core.gen2.Phone") +local Pokerus = require("src.core.gen2.Pokerus") +local Strings = require("src.core.Strings") +local Roamers = require("src.core.gen2.Roamers") +local Unown = require("src.core.gen2.Unown") + +local Specials = {} + +-- constants/script_constants.asm +local TRUE, FALSE = 1, 0 + +-- constants/pokemon_constants.asm, for the handlers that name a species. +local MAGIKARP = "MAGIKARP" +local SHUCKLE = "SHUCKLE" + +-- constants/misc_constants.asm GBCHECK_*: what GameboyCheck answers. +local GBCHECK_GB, GBCHECK_SGB, GBCHECK_CGB = 0, 1, 2 + +-- data/events/magikarp_lengths.asm is one table, but the ARITHMETIC around it +-- (CalcMagikarpLength) is what actually produces a length, so both are below. + +-- Rolls. Kept on the module rather than taken from the VM so a test can pin +-- every weighted table below without reaching into love.math, and so the two +-- calling conventions in this codebase (`random(n) -> 1..n` here, +-- `random(n) -> 0..n-1` in src/battle/gen2) cannot get crossed. +Specials.random = math.random + +-------------------------------------------------------------------------- +-- Plumbing +-------------------------------------------------------------------------- + +local function hooks(vm) + return (vm and vm.specials) or {} +end + +-- Park the coroutine on `start`, which must call its `done` exactly once. +-- +-- The order matters. `start` runs FIRST, while the coroutine is still on the +-- stack, so a hook that answers synchronously (no love, no stack, a test stub) +-- sets `finished` before the yield is ever reached and the handler simply +-- carries on -- resuming a coroutine that is not suspended would be an error. +-- A hook that answers later leaves the coroutine parked on a yield nothing in +-- Vm:resume claims, and its own callback is what resumes it. +function Specials.block(vm, start) + local finished, answer = false, nil + start(function(value) + answer = value + finished = true + if vm.co and coroutine.status(vm.co) == "suspended" then + vm:resume(value) + end + end) + if finished then return answer end + return coroutine.yield({ kind = "specialwait" }) +end + +-- Print a page whose very next act is this handler's OWN `yesorno`. +-- +-- Vm's one-command lookahead (Vm:textStays) is what keeps a text box standing +-- under a YES/NO prompt, but it reads the SCRIPT LIST: inside a hand-ported +-- special the row being run is the `special` itself, so nextOp is whatever +-- follows it (`waitbutton` for MomScript) and the lookahead is structurally +-- blind to a prompt this file raises. Answering it for the length of one page +-- is what the cart does anyway -- `PrintText / call YesNoBox` with nothing in +-- between, and each of these texts ends in `done`, so DoneText returns with no +-- PromptButton (home/text.asm:484) and YesNoBox goes straight up over the box +-- nothing closed. Without this the box pops on a press the cart never asks +-- for and the question is re-printed underneath the prompt. +local function showRawHeld(vm, body) + local nextOp = vm.nextOp + vm.nextOp = "yesorno" + vm:showRaw(body) + vm.nextOp = nextOp +end + +-- The party, as the handlers see it. wPartyMon* is one array on the cart and +-- one Lua list here, and every routine below that walks it walks this. +local function party(vm) + local h = hooks(vm) + return (h.party and h.party()) or {} +end + +local function save(vm) + local h = hooks(vm) + return h.save and h.save() or nil +end + +local function data(vm) + local h = hooks(vm) + return h.data and h.data() or nil +end + +-- wScriptVar, spelled the way the asm spells it so a handler reads as its +-- source: `ld a, TRUE / ld [wScriptVar], a`. +local function answer(vm, value) + vm.scriptVar = value or 0 +end + +-- Every routine that ends `call GetPokemonName / jp +-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's +-- {STRBUF} will find it. +local function nameMon(vm, species) + local h = hooks(vm) + local name = h.monName and h.monName(species) + if not name and type(species) == "string" then name = species end + if name then vm:setStringBuffer(name) end +end + +-- SelectMonFromParty: the carry flag is "the player pressed B". `onDone` gets +-- (index, mon) or (nil, nil), and the handler blocks on it. +local function selectMon(vm, prompt) + local h = hooks(vm) + if not h.selectPartyMon then return nil, nil end + local picked = Specials.block(vm, function(done) + h.selectPartyMon(prompt, function(index, mon) + done({ index = index, mon = mon }) + end) + end) + picked = picked or {} + return picked.index, picked.mon +end + +-------------------------------------------------------------------------- +-- Magikarp lengths (engine/events/magikarp.asm) +-------------------------------------------------------------------------- + +-- MagikarpLengths (data/events/magikarp_lengths.asm): fourteen `dwb` triplets +-- of "threshold word, divisor byte". Not extracted -- nothing in the ROM's +-- script bytecode points at it -- so it is transcribed here beside the +-- arithmetic that reads it, in the file's own order. +Specials.MAGIKARP_LENGTHS = { + { 110, 1 }, -- not used unless the .BCLessThanDE bug is fixed + { 310, 2 }, + { 710, 4 }, + { 2710, 20 }, + { 7710, 50 }, + { 17710, 100 }, + { 32710, 150 }, + { 47710, 150 }, + { 57710, 100 }, + { 62710, 50 }, + { 64710, 20 }, + { 65210, 5 }, + { 65410, 2 }, + { 65510, 1 }, -- not used +} + +-- `rrc` on one byte: an 8-bit rotate right, the bit that falls off coming back +-- in at the top. +local function rrc(byte) + byte = (byte or 0) % 256 + return math.floor(byte / 2) + (byte % 2) * 128 +end + +local function xorByte(a, b) + a, b = (a or 0) % 256, (b or 0) % 256 + local out, bit = 0, 1 + for _ = 1, 8 do + if (a % 2) ~= (b % 2) then out = out + bit end + a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 + end + return out +end + +-- CalcMagikarpLength (engine/events/magikarp.asm), transcribed rather than +-- approximated, bug and all: the whole Lake of Rage guru sub-plot is this one +-- number, and the bug is what makes long Magikarp rare. +-- +-- bc = rrc(id_hi) ++ rrc(id_lo) XOR rrc(rrc(dv_hi)) ++ rrc(rrc(dv_lo)) +-- +-- Then, walking MagikarpLengths with an index that starts at 2: +-- * bc < 10 is a special case: the length is bc + 190 mm +-- * otherwise the first row whose threshold's HIGH BYTE exceeds bc's high +-- byte wins. That is .BCLessThanDE's bug -- `ret c / ret nc` makes the +-- low-byte comparison behind it dead code, so only b and d are compared -- +-- and it is why `bc - de` underflows and the length lands where it does. +-- * the length is 100 * index + low_byte_of((bc - de) / divisor) +-- * falling off the end of the table is (bc - 65510) + 1600 +-- +-- Both truncations are the cart's: Divide leaves a 32-bit quotient and the +-- routine reads only hQuotient + 3, its LOW BYTE. +function Specials.magikarpLength(otId, dvWord) + otId = (otId or 0) % 65536 + dvWord = (dvWord or 0) % 65536 + local b = xorByte(rrc(math.floor(otId / 256)), + rrc(rrc(math.floor(dvWord / 256)))) + local c = xorByte(rrc(otId % 256), rrc(rrc(dvWord % 256))) + local bc = b * 256 + c + + local mm + if b == 0 and c < 10 then + mm = bc + 190 + else + local index = 2 + local de = 0 + for _, row in ipairs(Specials.MAGIKARP_LENGTHS) do + de = row[1] + if b < math.floor(de / 256) then + local dividend = (bc - de) % 65536 + mm = 100 * index + (math.floor(dividend / row[2]) % 256) + break + end + index = index + 1 + end + if not mm then mm = ((bc - de) % 65536) + 1600 end + end + mm = mm % 65536 + + -- `hl = de * 10`, then a 254-step division: inches = mm * 10 / 254, i.e. + -- mm / 25.4. `a` is one byte, so a length past 2550 inches wraps -- which + -- no reachable length does. + local inches = math.floor(mm * 10 / 254) % 256 + return math.floor(inches / 12), inches % 12, mm +end + +-- PrintMagikarpLength: PRINTNUM_LEFTALIGN over one byte each, with the ′ and ″ +-- glyphs (font codes $6e / $6f) between them. Written as the plain ASCII pair +-- because Font.split has no charmap entry for the two prime marks. +local function magikarpLengthText(feet, inches) + return string.format("%d'%d\"", feet or 0, inches or 0) +end +Specials.magikarpLengthText = magikarpLengthText + +-- The DV word CalcMagikarpLength is handed, out of the port's DV table: +-- (attack << 12) | (defense << 8) | (speed << 4) | special, which is the +-- MON_DVS pair's own layout. +function Specials.dvWord(dvs) + if type(dvs) == "number" then return dvs % 65536 end + if type(dvs) ~= "table" then return 0 end + return ((dvs.attack or 0) % 16) * 4096 + + ((dvs.defense or 0) % 16) * 256 + + ((dvs.speed or 0) % 16) * 16 + + ((dvs.special or 0) % 16) +end + +-------------------------------------------------------------------------- +-- The handlers +-------------------------------------------------------------------------- + +local H = {} + +-- ---- 0 WarpToSpawnPoint --------------------------------------------------- +-- The whiteout warp, and the few scripted trips home that borrow it. +H.WarpToSpawnPoint = function(vm) + if vm.warpToSpawnFn then vm.warpToSpawnFn() end +end + +-- ---- 20-24 the Bug Catching Contest --------------------------------------- +-- +-- Every rule below lives in src/core/gen2/BugContest.lua and NOT here. These +-- five handlers plus BugContestJudging are the gate scripts' half of the +-- system: Route35NationalParkGate's officer runs ContestDropOffMons, +-- GiveParkBalls and SelectRandomBugContestContestants on the way in, and +-- BugContestResultsScript runs BugContestJudging, ContestReturnMons and +-- CheckPartyFullAfterContest on the way out. Keeping the state on the SAVE +-- (save.bugContest) rather than on the Vm is required, not tidiness: the cart's +-- masked party and its park balls are in SRAM and survive a save mid-contest. + +-- The party as save.bugContest reads it. The hooks hand back the same table +-- the save holds, so this only fills in a save that has no `party` key at all +-- (a headless test that passed nothing but a hook). +local function contestSave(vm) + local record = save(vm) + if not record then + -- A VM built with no `save` hook at all (a headless test) still has a + -- party, and the mask has to go somewhere: the VM stands in for the save, + -- which is enough for every rule and nothing like enough to survive a + -- reload -- exactly what a cartridge with no SRAM would do. + vm.contestSave = vm.contestSave or { party = party(vm) } + return vm.contestSave + end + if record.party == nil then record.party = party(vm) end + return record +end + +-- ContestDropOffMons: the party is not stored anywhere, it is MASKED -- the +-- count is written down to 1 and the second species byte is replaced with the +-- -1 terminator, so only the lead mon exists for the duration of the contest. +-- +-- `.fainted`: a lead mon with 0 HP refuses, and answers TRUE so the gate +-- attendant can say so. +H.ContestDropOffMons = function(vm) + answer(vm, BugContest.dropOffMons(contestSave(vm))) +end + +-- ContestReturnMons: the count is RECOMPUTED by walking to the terminator, so +-- a mon caught during the contest is still in slot 2 when the tail goes back +-- behind it. Restoring after the caught mon rather than over it is that walk. +H.ContestReturnMons = function(vm) + BugContest.returnMons(contestSave(vm)) +end + +-- GiveParkBalls: BUG_CONTEST_BALLS is 20, wContestMon is cleared first, and the +-- StartBugContestTimer it farcalls is what puts the twenty minutes on the clock. +Specials.BUG_CONTEST_BALLS = BugContest.BALLS + +H.GiveParkBalls = function(vm) + BugContest.start(contestSave(vm)) +end + +-- CheckPartyFullAfterContest: the mon caught in the contest goes into the +-- party if there is room and into the current box if there is not. wScriptVar +-- is the three-way answer BugContestResults_DidNotLeaveMons branches on: +-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2 +-- (constants/script_constants.asm). +-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the +-- routine that prints it is engine code and no script bytecode points at the +-- string, so the extractor never reaches it. +local CONTEST_NICKNAME_PROMPT = + Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?") + +H.CheckPartyFullAfterContest = function(vm) + local Breeding = require("src.core.gen2.Breeding") + local result, mon = + BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE) + -- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the + -- mon that joined the party and the one that went to the box -- and nowhere + -- else in the contest: BugContest_SetCaughtContestMon merely holds the catch + -- in wContestMon, so this is the only place the player is ever asked. + -- GetPokemonName runs first, which is what {STRBUF} reads. + if mon and result ~= BugContest.NO_CATCH then + nameMon(vm, mon.species) + -- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is + -- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page + -- left standing. + showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT)) + if coroutine.yield({ kind = "yesorno" }) then + -- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a + -- fresh catch (the Name Rater is the one that pre-fills), and InitNickname + -- copies the species name back over an empty entry -- so a cancelled + -- keyboard is the same as answering NO. + local h = hooks(vm) + local name = h.renameMon and Specials.block(vm, function(done) + h.renameMon(mon, done, { blank = true }) + end) + -- _InitString's own blank test (home/string.asm:6-30): "zero or more + -- spaces followed by a null". The keyboard's blank cells are real + -- typeable characters, so an all-space entry has to be discarded the + -- same way an empty one is, not stored as a name of spaces. + if name and name:gsub(" ", "") ~= "" then mon.nickname = name end + end + end + answer(vm, result) +end + +-- _BugContestJudging (engine/events/bug_contest/judging.asm): ContestScore over +-- wContestMon, BugContest_JudgeContestants for the podium, then the three text +-- pages -- third, second, first, in that order -- each followed by its score +-- page and its placing jingle. The placing itself is what +-- BugContest_GetPlayersResult leaves in wScriptVar, and the gate script's three +-- `ifequal`s are the prize branches. +-- +-- The pages are authored here rather than read out of text.lua because nothing +-- in the ROM's script bytecode points at ContestJudging_*Text: they hang off +-- engine code, so the extractor never reaches them. +local JUDGING = { + third = Strings.source("Placing third was\n%s,\fwho caught a\n%s!"), + second = Strings.source("Placing second was\n%s,\fwho caught a\n%s!"), + first = Strings.source( + "This Bug-Catching\nContest winner is\f%s,\nwho caught a\n%s!"), + score = Strings.source("The score was\n%d points!"), + winningScore = Strings.source("The winning score\nwas %d points!"), +} + +-- SFX_1ST_PLACE / SFX_2ND_PLACE / SFX_3RD_PLACE, by their pokegold labels: the +-- text_asm arm of each page plays one and waits for it. +local PLACE_SFX = { "Sfx_1stPlace", "Sfx_2ndPlace", "Sfx_3rdPlace" } + +H.BugContestJudging = function(vm) + local record = contestSave(vm) + local place = BugContest.runJudging(record) + local state = BugContest.state(record) or {} + local results = state.results or {} + local h = hooks(vm) + -- wPlayerName, which LoadContestantName copies straight out for ID 1. + local playerName = record.player and record.player.name + -- LoadContestantName reads the winner ID, and GetPokemonName the species the + -- podium recorded, so a slot nobody filled prints nothing at all. + local order = { + { entry = results.third, page = JUDGING.third, score = JUDGING.score, + sfx = PLACE_SFX[3] }, + { entry = results.second, page = JUDGING.second, score = JUDGING.score, + sfx = PLACE_SFX[2] }, + { entry = results.first, page = JUDGING.first, + score = JUDGING.winningScore, sfx = PLACE_SFX[1] }, + } + for _, row in ipairs(order) do + local entry = row.entry + if entry then + local who = BugContest.contestantName(data(vm), entry.id, playerName) + local what = (h.monName and h.monName(entry.species)) or entry.species + or "" + vm:showRaw(Strings(row.page, who, what)) + if h.playSfxNamed then h.playSfxNamed(row.sfx) end + vm:showRaw(Strings(row.score, entry.score or 0)) + end + end + answer(vm, place) +end + +-- ---- 25, 26 the Magikarp guru --------------------------------------------- + +-- CheckMagikarpLength's four answers, spelled out at the top of the routine: +-- 3 a Magikarp that beats the record +-- 2 a Magikarp the record still beats +-- 1 B pressed in the party list +-- 0 the mon picked is not a Magikarp +H.CheckMagikarpLength = function(vm) + local _, mon = selectMon(vm, "choose") + if not mon then + answer(vm, 1) + return + end + if mon.species ~= MAGIKARP then + answer(vm, 0) + nameMon(vm, mon.species) + return + end + local feet, inches = + Specials.magikarpLength(mon.otId, Specials.dvWord(mon.dvs)) + vm:setStringBuffer(magikarpLengthText(feet, inches)) + local record = save(vm) + local best = record and record.magikarpRecord + local total = (feet or 0) * 12 + (inches or 0) + local bestTotal = best and ((best.feet or 0) * 12 + (best.inches or 0)) or 0 + if total <= bestTotal then + answer(vm, 2) + return + end + if record then + record.magikarpRecord = { + feet = feet, inches = inches, + name = record.player and record.player.name, + } + end + answer(vm, 3) +end + +-- MagikarpHouseSign: the record on the wall, in the string buffer for the +-- writetext that follows. A house nobody has beaten yet reads 0'00", which is +-- what InitializeMagikarpHouse leaves behind. +H.MagikarpHouseSign = function(vm) + local record = save(vm) + local best = record and record.magikarpRecord + vm:setStringBuffer(magikarpLengthText(best and best.feet, best and best.inches)) +end + +-- ---- 27-29 the Pokecenter ------------------------------------------------- +H.HealParty = function(vm) + if vm.healPartyFn then vm.healPartyFn() end +end + +-- The heal machine's light show, BLOCKING: LoadBallsOntoMachine holds 30 +-- frames per party ball and .FlashPalettes8Times ten more per flash, and the +-- nurse's "thank you for waiting" must not come up over the machine still +-- running. wScriptVar carries the machine's location (`setval HEALMACHINE_*` +-- right before the special): 0 Pokecenter, 1 Elm's lab, 2 Hall of Fame. +H.HealMachineAnim = function(vm) + if not vm.healAnimFn then return end + Specials.block(vm, function(done) + vm.healAnimFn(vm.scriptVar or 0, done) + end) +end + +-- PokemonCenterPC (engine/events/pokecenter_pc.asm): the Pokecenter PC's +-- whose-PC top menu. The special only opens the screen -- World:openPc +-- pushes src/ui/gen2/CenterPcMenu.lua, which carries the party gate, the +-- BILL's / <PLAYER>'s / PROF.OAK's / HALL OF FAME gating and the shutdown. +-- The asm never writes wScriptVar, so neither does this. +H.PokemonCenterPC = function(vm) + if vm.openPcFn then vm.openPcFn() end +end + +-- PlayersHousePC ends `ld a, c / ld [wScriptVar], a`, and _PlayersHousePC's c +-- is TRUE for exactly one reason: the DECORATION menu moved something. That +-- is the branch behind it -- PlayersHousePCScript's `iftrue .Warp`, whose +-- `warp NONE, 0, 0` reloads the room so the two decoration callbacks run +-- again. A decoration placed with no reload would not appear until the next +-- time the map loaded anyway, which is the cart's behaviour and the reason the +-- warp is there. +H.PlayersHousePC = function(vm) + local h = hooks(vm) + if not h.playersHousePc then + -- No bedroom PC on this side (a test harness, or the Pokecenter hook only): + -- open what there is and answer the way a player who changed nothing does. + answer(vm, FALSE) + if vm.openPcFn then vm.openPcFn() end + return + end + local changed = Specials.block(vm, function(done) + h.playersHousePc(done) + end) + answer(vm, changed and TRUE or FALSE) +end + +-- ToggleDecorationsVisibility / ToggleMaptileDecorations +-- (engine/overworld/decorations.asm), the two PLAYERS_HOUSE_2F map callbacks. +-- Both rebuild the room from the eight wDeco* bytes: the first writes the four +-- object slots (wVariableSprites plus each object's event flag), the second +-- paints the bed, the plant, the poster and the carpet into the block buffer. +-- +-- Neither writes wScriptVar, and neither may block: a map callback is a nested +-- script run. With no hooks they do nothing at all, which is the room a save +-- with no decorations shows. +H.ToggleDecorationsVisibility = function(vm) + local h = hooks(vm) + if h.toggleDecorationsVisibility then h.toggleDecorationsVisibility() end +end + +H.ToggleMaptileDecorations = function(vm) + local h = hooks(vm) + if h.toggleMaptileDecorations then h.toggleMaptileDecorations() end +end + +-- ---- 30-32, 68-69 the Day Care -------------------------------------------- +-- +-- All three doors are the same screen with a different `side`, and the model +-- behind it is src/core/gen2/Breeding.lua. DayCareManOutside is the only one +-- that writes wScriptVar (TRUE = the party was full, so the egg is kept and +-- the script asks again), which is why the push carries its answer back. +local function dayCare(vm, side) + local h = hooks(vm) + if not h.dayCare then + answer(vm, FALSE) + return + end + local value = Specials.block(vm, function(done) + h.dayCare(side, done) + end) + answer(vm, value or FALSE) +end + +H.DayCareMan = function(vm) dayCare(vm, "man") end +H.DayCareLady = function(vm) dayCare(vm, "lady") end +H.DayCareManOutside = function(vm) dayCare(vm, "outside") end + +-- DayCareMon1 / DayCareMon2: not the conversation, just the "you left X here" +-- line, the deposited mon's cry, and -- only when the OTHER side is occupied +-- too -- the compatibility line about the pair. Both texts live in +-- data/text/common_2.asm and are printed by engine/pokemon/breeding.asm +-- rather than by any bytecode, so the extractor seeds its text walker at them +-- by name (RomExtractorGen2's NAMED_TEXT); `line` below prefers the cache's +-- own characters and falls back to the transcription, which is the shape +-- src/ui/gen2/DayCareMenu.lua uses for the same block. +local DAY_CARE_LEFT = { + man = { label = "_LeftWithDayCareManText", + body = Strings.source( + "It's {STRBUF}\nthat was left with\nthe DAY-CARE MAN.") }, + lady = { label = "_LeftWithDayCareLadyText", + body = Strings.source( + "It's {STRBUF}\nthat was left with\nthe DAY-CARE LADY.") }, +} + +-- DayCareMonCompatibilityText's five verdicts, keyed by the string +-- Breeding.compatibilityText hands back (Breeding.COMPATIBILITY_*), in the +-- ASM's own fall-through order. The name in {STRBUF} is the OTHER parent's: +-- `ld hl, wBreedMon2Nickname / call DayCareMonCompatibilityText` copies it +-- into wStringBuffer1 before the verdict is picked. +local COMPATIBILITY = { + brimming = { label = "_BreedBrimmingWithEnergyText", + body = Strings.source("It's brimming with\nenergy.") }, + none = { label = "_BreedNoInterestText", + body = Strings.source("It has no interest\nin {STRBUF}.") }, + cares = { label = "_BreedAppearsToCareForText", + body = Strings.source("It appears to care\nfor {STRBUF}.") }, + friendly = { label = "_BreedFriendlyText", + body = Strings.source("It's friendly with\n{STRBUF}.") }, + interest = { label = "_BreedShowsInterestText", + body = Strings.source("It shows interest\nin {STRBUF}.") }, +} + +-- The extracted string for one of the entries above, or its transcription. +local function commonLine(vm, entry) + if not entry then return "" end + local CommonText = require("src.core.gen2.CommonText") + return CommonText.get(vm and vm.text, entry.label) or Strings(entry.body) +end + +local function dayCareMon(vm, side) + local Breeding = require("src.core.gen2.Breeding") + local record = save(vm) + local h = hooks(vm) + local mine = Breeding.side(record, side) or {} + local other = Breeding.side(record, side == "man" and "lady" or "man") or {} + local mon = mine.mon + if not mon then return end + local function monName(m) + return m.nickname or m.name or m.species or "#MON" + end + vm:setStringBuffer(monName(mon)) + vm:showRaw(commonLine(vm, DAY_CARE_LEFT[side])) + local index = h.monIndex and h.monIndex(mon.species) + if index and vm.cryFn then vm.cryFn(index) end + -- `bit DAYCARE*_HAS_MON_F / jr z, DayCareMonCursor`: with only one mon in + -- there the routine stops at the blinking cursor and says nothing else. + if not other.mon then return end + local value = Breeding.compatibility(data(vm), mon, other.mon) + vm:setStringBuffer(monName(other.mon)) + vm:showRaw(commonLine(vm, COMPATIBILITY[Breeding.compatibilityText(value)])) +end + +H.DayCareMon1 = function(vm) dayCareMon(vm, "man") end +H.DayCareMon2 = function(vm) dayCareMon(vm, "lady") end + +-- ---- the Blackthorn move deleter ------------------------------------------- +-- +-- MoveDeletion (engine/events/move_deleter.asm). MoveDeletersHouse's own +-- script is `faceplayer / opentext / special MoveDeletion / waitbutton / +-- closetext` (maps/MoveDeletersHouse.asm), so every PrintText below runs +-- inside a textbox the caller already opened and this never opens or closes +-- one itself. The asm never writes wScriptVar on any path -- there is no +-- `ld [wScriptVar], a` anywhere in the routine -- so this handler leaves +-- vm.scriptVar exactly as `special` found it, the same way the deliberate +-- stubs this replaces used to leave it at a fixed value; nothing reads it +-- after this call because MoveDeleter's own script has no branch behind it. +-- +-- data/text/common_3.asm, transcribed the way DAY_CARE_LEFT above transcribes +-- its own common_* text: the extractor does not reach the common banks. +local MOVE_DELETER_TEXT = { + intro = Strings.source( + "Um… Oh, yes, I'm\nthe MOVE DELETER.\n\nI can make #MON\n" + .. "forget moves.\n\nShall I make a\n#MON forget?"), + declined = Strings.source("No? Come visit me\nagain."), + whichMon = Strings.source("Which #MON?"), + egg = Strings.source("An EGG doesn't\nknow any moves!"), + onlyOneMove = Strings.source("That #MON knows\nonly one move."), + whichMove = Strings.source("Which move should\nit forget, then?"), + confirm = Strings.source("Oh, make it forget\n{STRBUF}?"), + forgot = Strings.source("Done! Your #MON\nforgot the move."), +} + +-- ChooseMoveToDelete is its own screen (engine/pokemon/mon_menu.asm), not a +-- textbox, so it goes through World like SelectMonFromParty does. A mon with +-- one move never reaches it: `.onlyonemove` is checked first, exactly as the +-- asm checks `ld a, [hl] / and a / jr z, .onlyonemove` before the farcall. +local function chooseMoveToDelete(vm, mon) + local h = hooks(vm) + if not h.chooseMoveToDelete then return nil end + return Specials.block(vm, function(done) + h.chooseMoveToDelete(mon, done) + end) +end + +H.MoveDeletion = function(vm) + -- engine/events/move_deleter.asm:2-4, PrintText then `call YesNoBox`. + showRawHeld(vm, Strings(MOVE_DELETER_TEXT.intro)) + local wantsToDelete = coroutine.yield({ kind = "yesorno" }) + if not wantsToDelete then + vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) + return + end + + vm:showRaw(Strings(MOVE_DELETER_TEXT.whichMon)) + local _, mon = selectMon(vm, "choose") + if not mon then + vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) + return + end + + -- `ld a, [wCurPartySpecies] / cp EGG`: the port marks an egg slot with + -- `isEgg` instead of overwriting the species (src/core/gen2/Breeding.lua), + -- so that is the flag this reads. + if mon.isEgg then + vm:showRaw(Strings(MOVE_DELETER_TEXT.egg)) + return + end + + if #(mon.moves or {}) <= 1 then + vm:showRaw(Strings(MOVE_DELETER_TEXT.onlyOneMove)) + return + end + + vm:showRaw(Strings(MOVE_DELETER_TEXT.whichMove)) + local index = chooseMoveToDelete(vm, mon) + if not index then + vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) + return + end + + local entry = mon.moves[index] + local d = data(vm) + local def = entry and d and d.moves and d.moves[entry.id] + vm:setStringBuffer((def and def.name) or (entry and entry.id) or "?") + -- engine/events/move_deleter.asm:33-35, the same PrintText / YesNoBox pair. + showRawHeld(vm, Strings(MOVE_DELETER_TEXT.confirm)) + local reallyDelete = coroutine.yield({ kind = "yesorno" }) + if not reallyDelete then + vm:showRaw(Strings(MOVE_DELETER_TEXT.declined)) + return + end + + -- .DeleteMove: shifts every later move (and its PP) down one slot and + -- clears the last one, which is exactly what removing the array entry does + -- here -- `mon` is the live save.party reference selectMon handed back, not + -- a copy. + table.remove(mon.moves, index) + + -- `call WaitSFX / ld de, SFX_MOVE_DELETED / call PlaySFX / call WaitSFX`: + -- wait out whatever the YES/NO click left playing, then the deletion jingle, + -- then wait that out too before the last line prints. + coroutine.yield({ kind = "waitsfx" }) + local h = hooks(vm) + if h.playSfxNamed then h.playSfxNamed("Sfx_MoveDeleted", 97) end + coroutine.yield({ kind = "waitsfx" }) + + vm:showRaw(Strings(MOVE_DELETER_TEXT.forgot)) +end + +-- ---- the Goldenrod NAME RATER ---------------------------------------------- +-- +-- NameRater (engine/events/name_rater.asm). Like MoveDeletion above, the +-- caller's own script is just `special NameRater` inside an already-open +-- textbox, so every line here PrintTexts into that box and the handler never +-- opens or closes one. The asm never writes wScriptVar either, so vm.scriptVar +-- is left exactly as `special` found it. +-- +-- data/text/common_1.asm, transcribed the way MOVE_DELETER_TEXT transcribes +-- its own bank -- this one IS reached by the extractor, but a map's own +-- `special` byte only points at the routine, not at the text bank behind it. +local NAME_RATER_TEXT = { + hello = Strings.source( + "Hello, hello! I'm\nthe NAME RATER.\n\nI rate the names\nof #MON.\n\n" + .. "Would you like me\nto rate names?"), + comeAgain = Strings.source("OK, then. Come\nagain sometime."), + whichMon = Strings.source("Which #MON's\nnickname should I\nrate for you?"), + egg = Strings.source("Whoa… That's just\nan EGG."), + perfectName = Strings.source( + "Hm… {STRBUF}?\nWhat a great name!\nIt's perfect.\n\nTreat {STRBUF}\n" + .. "with loving care."), + betterName = Strings.source( + "Hm… {STRBUF}…\nThat's a fairly\ndecent name.\n\nBut, how about a\n" + .. "slightly better\nnickname?\n\nWant me to give it\na better name?"), + whatName = Strings.source("All right. What\nname should we\ngive it, then?"), + finished = Strings.source("That's a better\nname than before!\n\nWell done!"), + sameName = Strings.source( + "It might look the\nsame as before,\n\nbut this new name\n" + .. "is much better!\n\nWell done!"), + named = Strings.source("All right. This\n#MON is now\nnamed {STRBUF}."), +} + +-- IsNewNameEmpty: the typed name is empty if every character up to the +-- terminator (or MON_NAME_LENGTH - 1) is a space -- a blank keyboard entry +-- reads the same as a cancelled one. +local function isBlankName(name) + return not name or name:match("^%s*$") ~= nil +end + +local function renameMon(vm, mon) + local h = hooks(vm) + if not h.renameMon then return nil end + return Specials.block(vm, function(done) + h.renameMon(mon, done) + end) +end + +-- CheckIfMonIsYourOT: the OT name AND the OT id both have to match, or the +-- mon reads as traded. A mon that has never changed hands carries no `ot` / +-- `otId` at all (Mon.new sets neither), which this treats as "yours" the +-- same way H.FindPartyMonThatSpeciesYourTrainerID's `mon.otId == nil` arm +-- does. +local function isTradedMon(vm, mon) + if not mon then return false end + local record = save(vm) + local player = record and record.player + if not player then return false end + if mon.ot ~= nil and mon.ot ~= player.name then return true end + if mon.otId ~= nil and mon.otId ~= player.id then return true end + return false +end + +H.NameRater = function(vm) + -- engine/events/name_rater.asm:3-5, PrintText then `call YesNoBox`. + showRawHeld(vm, Strings(NAME_RATER_TEXT.hello)) + local wantsToRate = coroutine.yield({ kind = "yesorno" }) + if not wantsToRate then + vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) + return + end + + vm:showRaw(Strings(NAME_RATER_TEXT.whichMon)) + local _, mon = selectMon(vm, "choose") + if not mon then + vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) + return + end + + -- `cp EGG`, the same isEgg flag MoveDeletion checks above. + if mon.isEgg then + vm:showRaw(Strings(NAME_RATER_TEXT.egg)) + return + end + + -- GetCurNickname puts the current name where {STRBUF} finds it before + -- either of the two texts below read it. + local currentName = mon.nickname or mon.name or mon.species or "?" + vm:setStringBuffer(currentName) + + if isTradedMon(vm, mon) then + vm:showRaw(Strings(NAME_RATER_TEXT.perfectName)) + return + end + + -- engine/events/name_rater.asm:21-23, the same PrintText / YesNoBox pair. + showRawHeld(vm, Strings(NAME_RATER_TEXT.betterName)) + local wantsRename = coroutine.yield({ kind = "yesorno" }) + if not wantsRename then + vm:showRaw(Strings(NAME_RATER_TEXT.comeAgain)) + return + end + + vm:showRaw(Strings(NAME_RATER_TEXT.whatName)) + local newName = renameMon(vm, mon) + + -- IsNewNameEmpty and CompareNewToOld both fall into `.samename`: an empty + -- entry or a re-typed copy of the old name is treated as "unchanged", not + -- as a second decline. + local unchanged = isBlankName(newName) or newName == currentName + local finalName = currentName + if not unchanged then + mon.nickname = newName + finalName = newName + end + + -- `.samename` re-runs GetCurNickname (now the new name, on the changed + -- path) before NameRaterNamedText, then falls into whichever of + -- FinishedText / SameNameText applies. + vm:setStringBuffer(finalName) + vm:showRaw(Strings(NAME_RATER_TEXT.named)) + if unchanged then + vm:showRaw(Strings(NAME_RATER_TEXT.sameName)) + else + vm:showRaw(Strings(NAME_RATER_TEXT.finished)) + end +end + +-- ---- 36 NameRival --------------------------------------------------------- +-- engine/events/specials.asm NameRival: `farcall _NamingScreen` returns only +-- when the keyboard closes, then InitName fills an empty wRivalName with the +-- version default ("SILVER" on Gold). That default is the FALLBACK, not the +-- seed: before this special runs, wRivalName holds InitializeNPCNames' "???" +-- (engine/menus/intro_menu.asm), which is the name the Cherrygrove theft +-- battle prints. The script's very next writetext is the +-- officer's "OK! So <RIVAL>" line, so the handler has to PARK on the screen: +-- running on past it printed the old name with the keyboard still up. +H.NameRival = function(vm) + if not vm.nameRivalFn then return end + Specials.block(vm, function(done) vm.nameRivalFn(done) end) +end + +-- ---- 37, 108-110 the clock ------------------------------------------------ + +-- SetDayOfWeek (engine/rtc/timeset.asm:382): the "what day is it?" wheel Mom +-- puts up with the POKeGEAR. It BLOCKS -- the special does not return until +-- the player has picked a day and confirmed it -- which is why the script's +-- `.SetDayOfWeek` label loops back here. The screen is +-- src/ui/gen2/InitClock.lua's day mode; a run with no screen to push falls back +-- to the host clock's own day, which is what the player would have picked. +H.SetDayOfWeek = function(vm) + local record = save(vm) + if not record then return end + local Clock = require("src.core.gen2.Clock") + local h = hooks(vm) + local picked + if h.setDayOfWeek then + picked = Specials.block(vm, function(done) h.setDayOfWeek(done) end) + end + if type(picked) ~= "number" then + Clock.setWeekday(record, Clock.hostWeekday()) + end + record.rtc = record.rtc or {} + record.rtc.day = tonumber(os.date("%j")) or record.rtc.day +end + +-- InitialSetDSTFlag / InitialClearDSTFlag (engine/rtc/timeset.asm): one bit +-- in wDST, asked once during Mom's clock ladder. Each routine also reprints +-- the clock and puts its OWN confirmation into the open textbox -- the +-- PrintHoursMins time in front of .DSTIsThatOKText / .TimeAskOkayText -- and +-- the `yesorno` right after it in PlayersHouse1F's script reads THAT page; its +-- iffalse loops back to `.SetDayOfWeek`. A handler that stays silent leaves +-- the confirm prompt hanging on the previous question, which reads as Mom +-- asking about DST over and over. +local function dstConfirmTime(vm) + -- PrintHoursMins reads hHours / hMinutes, which are the GAME clock -- the + -- base InitClock and Mom's own wheel just wrote, not the host's. + local Clock = require("src.core.gen2.Clock") + local record = save(vm) + local w = hooks(vm).world + local hour = (w and w.hour and w:hour()) or Clock.hour(record) + return string.format("%d:%02d", hour, Clock.minute(record)) +end + +local DST_CONFIRM = Strings.source("%s DST,\nis that OK?") +local TIME_CONFIRM = Strings.source("%s,\nis that OK?") + +H.InitialSetDSTFlag = function(vm) + local record = save(vm) + if record then + record.rtc = record.rtc or {} + record.rtc.dst = true + end + vm:showRaw(Strings(DST_CONFIRM, dstConfirmTime(vm))) +end + +H.InitialClearDSTFlag = function(vm) + local record = save(vm) + if record then + record.rtc = record.rtc or {} + record.rtc.dst = false + end + vm:showRaw(Strings(TIME_CONFIRM, dstConfirmTime(vm))) +end + +-- MrChrono prints the raw RTC registers into the text box: a debug readout the +-- cart leaves reachable through the Goldenrod clock man. The numbers are put +-- in the string buffer rather than laid out by hand, since nothing here owns a +-- text box. +H.MrChrono = function(vm) + local record = save(vm) + local rtc = (record and record.rtc) or {} + vm:setStringBuffer(string.format("RT %d DF %d", + rtc.day or 0, rtc.dst and 1 or 0)) +end + +-- ---- 40 the wall radios ----------------------------------------------------- +-- +-- MapRadio (engine/events/specials.asm): `ld a, [wScriptVar] / ld e, a / +-- farcall PlayRadio`. The setval before the special left a MAPRADIO_* station +-- index in wScriptVar, and PlayRadio blocks with the joypad until A or B -- +-- which is what the six in-house radios and the bedroom set after the starter +-- all are (std_scripts.asm Radio1Script / Radio2Script). The screen is +-- src/ui/gen2/MapRadio.lua; the push goes through hooks.pushScreen so a test +-- can stub the seam. Neither the special nor PlayRadio writes wScriptVar +-- back, so vm.scriptVar keeps the station index it arrived with. +H.MapRadio = function(vm) + local h = hooks(vm) + if not h.pushScreen then return end + local channel = vm.scriptVar or 0 + Specials.block(vm, function(done) + local ok = h.pushScreen("Gen2MapRadio", { + channel = channel, + onDone = function() done(true) end, + }) + if not ok then done(false) end + end) +end + +-- ---- 42-44 the Game Corner ------------------------------------------------ +-- +-- StartGameCornerGame is CheckCoinsAndCoinCase and then the machine. The +-- check is transcribed here rather than left to the screen, because its two +-- refusals are TEXT and the script has to see them before the machine opens: +-- no coins at all, or no COIN_CASE to hold them. +local COIN_CASE = 0x47 -- constants/item_constants.asm + +-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm. +local NO_COINS_TEXT = "You have no coins." +local NO_COIN_CASE_TEXT = Strings.source("You don't have a\nCOIN CASE.") + +local function gameCornerGame(vm, kind) + local h = hooks(vm) + local coins = (h.coins and h.coins()) or 0 + if coins == 0 then + vm:showRaw(Strings(NO_COINS_TEXT)) + return + end + if h.hasItem and not h.hasItem(COIN_CASE) then + vm:showRaw(Strings(NO_COIN_CASE_TEXT)) + return + end + if not h.gameCornerGame then return end + Specials.block(vm, function(done) + h.gameCornerGame(kind, done) + end) +end + +H.SlotMachine = function(vm) gameCornerGame(vm, "slots") end +H.CardFlip = function(vm) gameCornerGame(vm, "cardflip") end + +-- ---- the Ruins of Alph ---------------------------------------------------- +-- +-- UnownPuzzle: `call FadeToMenu / farcall _UnownPuzzle / ld a, +-- [wSolvedUnownPuzzle] / ld [wScriptVar], a / call ExitAllMenus`. +-- +-- wScriptVar goes IN as well as out: LoadUnownPuzzlePiecesGFX reads it +-- (`maskbits NUM_UNOWN_PUZZLES`) to pick which of the four pictures is being +-- assembled, which is what the chamber's `setval UNOWNPUZZLE_KABUTO` in front +-- of the special is for. So the id has to be read before the screen opens and +-- the answer written after it closes, and a screen that cannot open answers 0 +-- -- the same "backed out" arm a quit takes. +H.UnownPuzzle = function(vm) + local h = hooks(vm) + local puzzle = (vm.scriptVar or 0) % 4 + if not h.unownPuzzle then + vm.scriptVar = 0 + return + end + local solved = Specials.block(vm, function(done) + h.unownPuzzle(puzzle, done) + end) + vm.scriptVar = solved and 1 or 0 +end + +-- CountUnown has no row in SpecialsPointers and therefore no handler here: it +-- is a plain routine, and its one caller is engine/overworld/variables.asm +-- .UnownCaught, which reads the count out of b. That is VAR_UNOWNCOUNT, and +-- World:readVar answers it from the same list (src/core/gen2/Unown.lua). + +-- UnownPrinter (engine/events/print_unown.asm _UnownPrinter), the research +-- centre's ALPH RUINS STAMP machine. It is one of the printer specials and +-- it is the one that is NOT stubbed, because only half of it needs the +-- peripheral: the viewer -- the sheet of stamps LEFT and RIGHT walk through +-- -- 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. +-- +-- `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 +-- wScriptVar, so vm.scriptVar is left exactly as `special` found it -- the +-- same contract H.PhotoStudio and H.MoveDeletion keep. +H.UnownPrinter = function(vm) + local h = hooks(vm) + local file = save(vm) + if file and #Unown.dex(file) == 0 then return end + if not h.showUnownPrinter then return end + Specials.block(vm, function(done) + h.showUnownPrinter(function() done(true) end) + end) +end + +-- GameCornerPrizeMonCheckDex: a prize mon the player has never caught shows +-- its #DEX page as it is handed over. The catch itself is the prize counter's +-- job (src/ui/gen2/PrizeMenu.lua); this is the entry, and wScriptVar carries +-- the species in and out untouched. +H.GameCornerPrizeMonCheckDex = function(vm) + local record = save(vm) + local h = hooks(vm) + local species = h.monName and h.monName(vm.scriptVar) + if not (record and species) then return end + record.pokedex = record.pokedex or { seen = {}, caught = {} } + if record.pokedex.caught[species] then return end + record.pokedex.seen[species] = true + record.pokedex.caught[species] = true +end + +-- UnusedSetSeenMon: SetSeenMon on wScriptVar - 1. Unreferenced in Gold, but +-- one line and correct. +H.UnusedSetSeenMon = function(vm) + local record = save(vm) + local h = hooks(vm) + local species = h.monName and h.monName(vm.scriptVar) + if not (record and species) then return end + record.pokedex = record.pokedex or { seen = {}, caught = {} } + record.pokedex.seen[species] = true +end + +-- ---- 45-55 the presentation block ----------------------------------------- +-- +-- None of these is state. They are the fade, the palette reload and the +-- sprite refresh a scripted cutscene brackets its set change with, and the +-- port's single map image plus one people list is what stands in for the +-- cart's VRAM shuffling. Every one is listed rather than folded together so +-- that a reader looking for FadeOutToBlack finds it. +local function fade(vm, kind) + local h = hooks(vm) + if h.fade then h.fade(kind) end +end + +H.FadeOutToWhite = function(vm) fade(vm, "outWhite") end +H.FadeOutToBlack = function(vm) fade(vm, "outBlack") end +H.FadeInFromWhite = function(vm) fade(vm, "inWhite") end +H.FadeInFromBlack = function(vm) fade(vm, "inBlack") 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. +H.ClearBGPalettes = function(vm) fade(vm, "outBlack") end +H.ClearBGPalettesBufferScreen = function(vm) fade(vm, "outBlack") end +H.ClearTilemap = function(vm) fade(vm, "outBlack") end + +-- UpdateTimePals: re-resolve the clock's palette without touching anything +-- else, which is exactly what World:applyPalettes plus a re-bake does. +H.UpdateTimePals = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(true) end +end + +-- UpdateSprites / ReloadSpritesNoPalettes: respawn the object list. The +-- second one is the same walk with LoadMapPalettes skipped, which is why a +-- scripted swap of an NPC's sprite does not restart the palette fade. +H.UpdateSprites = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(true) end +end + +H.ReloadSpritesNoPalettes = function(vm) + local h = hooks(vm) + if h.reloadSprites then h.reloadSprites(false) end +end + +-- LoadUsedSpritesGFX: the VRAM pack for whichever sprites this map actually +-- uses. The port loads a sheet per sprite on demand, so the whole routine is +-- the rebuild that follows it. +H.LoadUsedSpritesGFX = 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. +H.UpdatePlayerSprite = function(vm) + local h = hooks(vm) + if h.updatePlayerSprite then h.updatePlayerSprite() end +end + +-- ---- 58-62 sound and the water -------------------------------------------- + +-- WaitSFX: hold until the sound effect that is playing finishes. The VM has +-- the whole mechanism already (the `waitsfx` opcode), so this is that yield. +H.WaitSFX = function(vm) + coroutine.yield({ kind = "waitsfx" }) +end + +H.PlayMapMusic = function(vm) + local h = hooks(vm) + if h.playMapMusic then h.playMapMusic() end +end + +H.RestartMapMusic = function(vm) + local h = hooks(vm) + if h.restartMapMusic then h.restartMapMusic() end +end + +-- FadeOutMusic: MUSIC_NONE into wMusicFadeID with a control of 2, i.e. a fast +-- ramp to silence and nothing queued behind it. +H.FadeOutMusic = function(vm) + local h = hooks(vm) + if h.fadeOutMusic then h.fadeOutMusic() end +end + +-- SurfStartStep: the player goes onto the water. Script_UsedSurf calls this +-- rather than doing it itself, so the script route and the party-menu route +-- land in the same place. +H.SurfStartStep = function(vm) + local h = hooks(vm) + if h.surfStartStep then h.surfStartStep(party(vm)[1]) end +end + +-- PlayCurMonCry / PlaySlowCry: the cry of wCurPartySpecies, and the same cry +-- at a lower pitch (the Lake of Rage Gyarados, the Snorlax). The port has no +-- pitch control on a cry, so the slow one is the ordinary one -- which is a +-- known, deliberate flattening rather than a missing call. +local function currentCry(vm) + local h = hooks(vm) + local species = vm.scriptVar + if species and species ~= 0 and vm.cryFn then + vm.cryFn(species) + return + end + local mon = party(vm)[1] + local index = mon and h.monIndex and h.monIndex(mon.species) + if index and vm.cryFn then vm.cryFn(index) end +end + +H.PlayCurMonCry = currentCry +H.PlaySlowCry = currentCry + +-- ---- 63-66 the party searches --------------------------------------------- +-- +-- Four routines that share FoundOne / FoundNone: wScriptVar goes IN as the +-- thing looked for and comes back TRUE or FALSE. The port's party is a list +-- of records, so each predicate is one line. +local function findPartyMon(vm, predicate) + local wanted = vm.scriptVar or 0 + for _, mon in ipairs(party(vm)) do + if predicate(mon, wanted) then + answer(vm, TRUE) + return + end + end + answer(vm, FALSE) +end + +H.FindPartyMonAboveLevel = function(vm) + findPartyMon(vm, function(mon, level) return (mon.level or 0) >= level end) +end + +H.FindPartyMonAtLeastThatHappy = function(vm) + findPartyMon(vm, function(mon, want) + return (mon.happiness or 0) >= want + end) +end + +H.FindPartyMonThatSpecies = function(vm) + local h = hooks(vm) + findPartyMon(vm, function(mon, wanted) + return h.monIndex and h.monIndex(mon.species) == wanted + end) +end + +-- _FindPartyMonThatSpeciesYourTrainerID additionally requires the mon to be +-- YOURS: it is the check that stops a traded Pokemon from counting, which is +-- the whole point of the routine (the Goldenrod bike shop, the Trainer House). +H.FindPartyMonThatSpeciesYourTrainerID = function(vm) + local h = hooks(vm) + local record = save(vm) + local myId = record and record.player and record.player.id + findPartyMon(vm, function(mon, wanted) + if not (h.monIndex and h.monIndex(mon.species) == wanted) then return false end + return myId == nil or mon.otId == nil or mon.otId == myId + end) +end + +-- UnusedCheckUnusedTwoDayTimer: a timer nothing else in the ROM reads. Kept +-- so the name resolves, answering the 0 an untouched wUnusedTwoDayTimer holds. +H.UnusedCheckUnusedTwoDayTimer = function(vm) + answer(vm, 0) +end + +-- ---- 70-71 the swarms and the contestants --------------------------------- + +-- SelectRandomBugContestContestants: five of the ten contestant flags are set, +-- and a SET flag is what keeps that trainer OFF the contest map -- and, at +-- judging time, out of ComputeAIContestantScores. The rejection roll is +-- BugContest.pickContestants, which stores the choice as a SET keyed by slot: +-- a list of indices would make `absent[1]` the first PICK rather than the first +-- slot's flag, and every judging would then skip contestant 1. +-- +-- The flag table itself is data/events/bug_contest_flags.asm, which the +-- extractor writes as eventTables.bugContestFlags; applyContestantFlags is the +-- `.loop1` that RESETS all ten before the five are set, so a second contest +-- does not inherit the first one's absentees and empty the park. +-- +-- `vm.events` and `vm.eventTables` rather than a specials hook: both are +-- already on the VM (Vm.new takes them), setevent and clearevent write through +-- the same object, and onFlagsChanged is the deferred rebuild those two use so +-- a flag written mid-script does not pop an NPC out from under the player. +Specials.NUM_BUG_CONTESTANTS = BugContest.NUM_CONTESTANTS +Specials.BUG_CONTESTANTS_PICKED = BugContest.CONTESTANTS_PICKED + +H.SelectRandomBugContestContestants = function(vm) + local chosen = BugContest.pickContestants(contestSave(vm)) + BugContest.applyContestantFlags(vm.events, chosen, vm.eventTables) + if vm.events and vm.onFlagsChanged then vm.onFlagsChanged() end +end + +-- ActivateFishingSwarm: wFishingSwarmFlag takes wScriptVar, and the routine +-- FALLS THROUGH into SetSwarmFlag -- so the map pair and DAILYFLAGS1_SWARM are +-- both live afterwards. A port that set only the flag would leave the Qwilfish +-- swarm on for good. +H.ActivateFishingSwarm = function(vm) + local record = save(vm) + if not record then return end + record.dailyFlags = record.dailyFlags or {} + record.dailyFlags.fishingSwarm = vm.scriptVar or 0 + record.dailyFlags.swarm = true +end + +-- ---- 74-77 gifts and health ----------------------------------------------- + +-- GiveShuckle: a level 15 SHUCKLE holding a BERRY, with Mania's own OT and +-- trainer ID, nicknamed SHUCKIE. Those four facts are what ReturnShuckie +-- checks before it will take the thing back, so none of them is decoration. +Specials.MANIA_OT_ID = 518 +Specials.MANIA_OT = "MANIA" +Specials.SHUCKIE_NICKNAME = "SHUCKIE" +Specials.SHUCKIE_LEVEL = 15 + +H.GiveShuckle = function(vm) + local Breeding = require("src.core.gen2.Breeding") + local Mon = require("src.battle.gen2.Mon") + local list = party(vm) + if #list >= Breeding.PARTY_SIZE then + answer(vm, FALSE) + return + end + local mon = Mon.new(data(vm), SHUCKLE, Specials.SHUCKIE_LEVEL, { + nickname = Specials.SHUCKIE_NICKNAME, + item = "BERRY", + }) + if not mon then + answer(vm, FALSE) + return + end + mon.ot = Specials.MANIA_OT + mon.otId = Specials.MANIA_OT_ID + list[#list + 1] = mon + answer(vm, TRUE) +end + +-- ReturnShuckie: the mon has to BE the Shuckie -- species, Mania's trainer ID +-- and Mania's OT name, all three -- and it has to be conscious. wScriptVar's +-- five answers are the five arms of the routine. Ported to match the asm's +-- own numbering (constants/script_constants.asm) rather than Lua's usual +-- "0 is the sane default" habit, since ManiasHouse.asm's `ifequal` chain +-- checks these values by number. +Specials.SHUCKIE_WRONG_MON = 0 +Specials.SHUCKIE_REFUSED = 1 +Specials.SHUCKIE_RETURNED = 2 +Specials.SHUCKIE_HAPPY = 3 +Specials.SHUCKIE_FAINTED = 4 + +-- .HappyToStayWithYou's threshold: 150 happiness or better and Mania lets +-- you keep Shuckie instead of taking it back. +Specials.SHUCKIE_HAPPY_THRESHOLD = 150 + +H.ReturnShuckie = function(vm) + local index, mon = selectMon(vm, "choose") + if not mon then + answer(vm, Specials.SHUCKIE_REFUSED) + return + end + if mon.species ~= SHUCKLE + or mon.otId ~= Specials.MANIA_OT_ID + or mon.ot ~= Specials.MANIA_OT then + answer(vm, Specials.SHUCKIE_WRONG_MON) + return + end + if (mon.hp or 0) <= 0 then + answer(vm, Specials.SHUCKIE_FAINTED) + return + end + -- The happiness the mon comes back with is what Mania comments on, so it is + -- read before the slot might get emptied. + vm.shuckieHappiness = mon.happiness or 0 + if (mon.happiness or 0) >= Specials.SHUCKIE_HAPPY_THRESHOLD then + -- Shuckie stays with the player: the party slot is untouched. + answer(vm, Specials.SHUCKIE_HAPPY) + return + end + table.remove(party(vm), index) + answer(vm, Specials.SHUCKIE_RETURNED) +end + +-- BillsGrandfather: pick a mon and hand him its species. wScriptVar is the +-- species index he then names, 0 for a B press. +H.BillsGrandfather = function(vm) + local h = hooks(vm) + local _, mon = selectMon(vm, "choose") + if not mon then + answer(vm, 0) + return + end + local index = h.monIndex and h.monIndex(mon.species) + answer(vm, index or 0) + nameMon(vm, index or mon.species) +end + +-- CheckPokerus -> _CheckPokerus -> ScriptReturnCarry: 1 when any party member +-- carries the virus. `and $0f` is the whole test -- an ACTIVE infection only, +-- so a party of cured mons (high nybble set, counter zero) answers FALSE and +-- the nurse says nothing. +H.CheckPokerus = function(vm) + answer(vm, Pokerus.inParty(party(vm)) and TRUE or FALSE) +end + +-- ---- 78-80 the money boxes ------------------------------------------------ +-- +-- engine/menus/menu_2.asm. The three are DIFFERENT boxes, not one box with +-- three names -- DisplayMoneyAndCoinBalance prints both fields in a 13x3 box of +-- its own -- so the world hook is told which, and a hook that ignores the +-- argument still gets the money box it got before. +H.DisplayCoinCaseBalance = function(vm) + if vm.showCoinsFn then vm.showCoinsFn() end +end + +H.DisplayMoneyAndCoinBalance = function(vm) + if vm.showMoneyFn then vm.showMoneyFn("moneycoins") end +end + +H.PlaceMoneyTopRight = function(vm) + if vm.showMoneyFn then vm.showMoneyFn("money") end +end + +-- ---- 81-84 the Lucky Number Show ------------------------------------------ +-- +-- The radio show that gives out the MASTER BALL. Its whole rule is the number +-- of TRAILING digits a mon's trainer ID shares with the day's five-digit lucky +-- number, over the party AND every box: +-- +-- 5 digits first prize wScriptVar 1 +-- 3 or 4 second prize wScriptVar 2 +-- 2 third prize wScriptVar 3 +-- fewer nothing wScriptVar 0 +-- +-- The BEST match wins, which is why the comparison keeps the LOWER wScriptVar +-- (`cp b / jr c, .nomatch`), and a match found in a BOX rather than in the +-- party changes only which of two lines is printed. +local function trailingDigitsShared(a, b) + local left = string.format("%05d", (a or 0) % 100000) + local right = string.format("%05d", (b or 0) % 100000) + local shared = 0 + for i = 5, 1, -1 do + if left:sub(i, i) ~= right:sub(i, i) then break end + shared = shared + 1 + end + return shared +end +Specials.trailingDigitsShared = trailingDigitsShared + +local function luckyPrizeFor(shared) + if shared >= 5 then return 1 end + if shared >= 3 then return 2 end + if shared >= 2 then return 3 end + return 0 +end +Specials.luckyPrizeFor = luckyPrizeFor + +-- engine/overworld/time.asm's RestartLuckyNumberCountdown: the days from +-- `weekday` until the NEXT Friday, where Friday itself is a full week away +-- rather than zero (`sub c / jr z, .friday_saturday` before the `add 7`). +-- GetWeekday counts SUNDAY 0 .. SATURDAY 6, same as BugContest.weekday. +local function daysUntilFriday(weekday) + return ((BugContest.FRIDAY - (weekday or 0) - 1) % 7) + 1 +end +Specials.daysUntilFriday = daysUntilFriday + +-- save.luckyNumberReset stands in for wLuckyNumberDayTimer: { remaining, day } +-- the same shape Apricorns.startDailyResetTimer uses, just armed for a week +-- instead of a day. A save that has never armed it (day == nil) reads as +-- already expired, the same way a freshly zeroed SRAM byte does -- which is +-- what makes the FIRST visit to the Lucky Number Man always reset and roll a +-- number rather than reading a stale zero. +local function luckyNumberTimer(record) + if type(record) ~= "table" then return nil end + record.luckyNumberReset = record.luckyNumberReset or { remaining = 0 } + return record.luckyNumberReset +end + +-- _CheckLuckyNumberShowFlag: CheckDayDependentEventHL over wLuckyNumberDayTimer. +-- Advances the stored day as it measures (CalcDaysSince's side effect), then +-- clamps the remaining count at zero, same as Apricorns.checkDailyResetTimer's +-- one-day version. +local function checkLuckyNumberTimer(record, now) + local timer = luckyNumberTimer(record) + if not timer then return false end + if timer.day == nil then return true end + now = now or BugContest.now() + local stamp = { day = timer.day } + local since = BugContest.elapsedSince(stamp, now, "day") + timer.day = stamp.day + local left = (timer.remaining or 0) - since.days + if left < 0 then left = 0 end + timer.remaining = left + return left <= 0 +end + +H.CheckForLuckyNumberWinners = function(vm) + local record = save(vm) + local number = record and record.luckyNumber + answer(vm, 0) + if not number then return end + local best, bestMon, inBox = 0, nil, false + local function consider(mon, fromBox) + local Breeding = require("src.core.gen2.Breeding") + if Breeding.isEgg(mon) then return end + local prize = luckyPrizeFor(trailingDigitsShared(mon.otId, number)) + if prize == 0 then return end + if best == 0 or prize < best then + best, bestMon, inBox = prize, mon, fromBox + 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 + end + answer(vm, best) + if bestMon then + nameMon(vm, bestMon.species) + vm.luckyNumberInBox = inBox + end +end + +-- _CheckLuckyNumberShowFlag -> ScriptReturnCarry: TRUE once wLuckyNumberDayTimer +-- has counted down past the coming Friday, i.e. a new week has started since +-- the show was last reset. This is the WEEKLY gate; RadioTower1FLuckyNumberManScript +-- only calls ResetLuckyNumberShowFlag (which rolls a fresh number and clears +-- the "already won" bit) when this comes back TRUE, so visiting twice in the +-- same week keeps last week's number and last week's win on record. +H.CheckLuckyNumberShowFlag = function(vm) + answer(vm, checkLuckyNumberTimer(save(vm)) and TRUE or FALSE) +end + +-- ResetLuckyNumberShowFlag: RestartLuckyNumberCountdown re-arms the weekly +-- timer for the days until the NEXT Friday, `res LUCKYNUMBERSHOW_GAME_OVER_F` +-- clears the SAME storage `checkflag`/`setflag ENGINE_LUCKY_NUMBER_SHOW` read +-- and write (data/events/engine_flags.asm's `engine_flag wLuckyNumberShowFlag, +-- LUCKYNUMBERSHOW_GAME_OVER_F`), and LoadOrRegenerateLuckyIDNumber rolls a +-- fresh five-digit number. Because this only ever runs right after +-- CheckLuckyNumberShowFlag has confirmed the week turned over, "reroll every +-- time this fires" is the same as the cart's "reroll when the stored day +-- differs from today". +H.ResetLuckyNumberShowFlag = function(vm) + local record = save(vm) + if not record then return end + local now = BugContest.now() + local timer = luckyNumberTimer(record) + timer.remaining = daysUntilFriday(BugContest.weekday(now)) + timer.day = now.day + local h = hooks(vm) + if h.setEngineFlag then h.setEngineFlag("ENGINE_LUCKY_NUMBER_SHOW", nil) end + record.luckyNumber = Specials.random(0, 99999) +end + +-- PrintTodaysLuckyNumber: five digits with leading zeros, into the buffer the +-- following writetext reads. +H.PrintTodaysLuckyNumber = function(vm) + local record = save(vm) + vm:setStringBuffer(string.format("%05d", + (record and record.luckyNumber or 0) % 100000)) +end + +-- ---- 85 Kurt and the apricorns -------------------------------------------- + +-- The CANCEL row Kurt_SelectApricorn's .Name draws itself (`db "CANCEL@"`) +-- rather than taking from the item names, because item 0 has no name. +local CANCEL = Strings.source("CANCEL") + +-- .MenuHeader: MENU_BACKUP_TILES, `menu_coords 0, 0, 14, 17`, .MenuData and a +-- default option of 1; .MenuData's own byte is STATICMENU_CURSOR | +-- STATICMENU_WRAP (constants/menu_constants.asm bits 7 and 5). The wrap is +-- the one thing src/ui/gen2/ScriptMenu.lua does not honour yet, so the cursor +-- stops at the ends instead of rolling over; everything else is the header. +local KURT_MENU_FLAGS = 0x80 + 0x20 + +-- SelectApricornForKurt (engine/events/specials.asm) is two routines deep, and +-- the second one is the half a reader of maps/KurtsHouse.asm cannot see: +-- +-- farcall Kurt_SelectApricorn / ld a, c / ld [wScriptVar], a / and a / ret z +-- ld [wCurItem], a / ld a, 1 / ld [wItemQuantityChange], a / TossItem +-- +-- so wScriptVar is the ITEM id of the apricorn chosen -- .AskApricorn's ladder +-- is `ifequal BLU_APRICORN` and friends, item constants, not menu rows -- and +-- the apricorn leaves the pack HERE, before the script's setevent runs. +-- +-- Kurt_SelectApricorn (engine/menus/menu_2.asm) is FindApricornsInBag plus a +-- DoNthMenu over the list it builds, which is why the rows come out in +-- ApricornBalls order rather than pack order and the last row is always +-- 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. +H.SelectApricornForKurt = function(vm) + local h = hooks(vm) + local record = save(vm) + local list = Apricorns.bagList(record and record.inventory) + if list.empty then return answer(vm, FALSE) end + + local rows = {} + for index, apricorn in ipairs(list) do + rows[index] = (h.itemName and h.itemName(apricorn)) or apricorn + end + rows[list.cancel] = Strings(CANCEL) + + local choice = Specials.block(vm, function(done) + if not h.scriptMenu then return done(0) end + h.scriptMenu({ items = rows, left = 0, top = 0, right = 14, bottom = 17, + dataFlags = KURT_MENU_FLAGS, cursor = 1 }, done) + end) + + local apricorn = Apricorns.select(record and record.inventory, + tonumber(choice)) + if not apricorn then return answer(vm, FALSE) end + -- No item id, no toss: an answer the `ifequal` ladder cannot match would + -- fall through to .Red and hand Kurt an apricorn the player never lost. + 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) +end + +-- ---- 88-89 the first party slot ------------------------------------------- + +-- GetFirstPokemonHappiness: the happiness of the first NON-EGG party member -- +-- the loop skips eggs, which is why a party led by an egg still gets an answer +-- from the mon behind it. +H.GetFirstPokemonHappiness = function(vm) + local Breeding = require("src.core.gen2.Breeding") + for _, mon in ipairs(party(vm)) do + if not Breeding.isEgg(mon) then + answer(vm, mon.happiness or 0) + nameMon(vm, mon.species) + return + end + end + answer(vm, 0) +end + +-- CheckFirstMonIsEgg: TRUE when slot 1 holds an egg, and the name goes in the +-- buffer either way (`call GetPokemonName` is past the branch). +H.CheckFirstMonIsEgg = function(vm) + local Breeding = require("src.core.gen2.Breeding") + local mon = party(vm)[1] + answer(vm, Breeding.isEgg(mon) and TRUE or FALSE) + if mon then nameMon(vm, mon.species) end +end + +-- ---- 90 the rare-mon phone call ------------------------------------------- +-- +-- RandomUnseenWildMon: pick one of the three RAREST slots on the caller's map, +-- and if it is not also one of the four commonest AND has never been seen, the +-- caller tells you about it (wScriptVar 0). Anything else is 1, "nothing to +-- report". +-- +-- GetCallerLocation is a phone routine and the phone-call scripts live in an +-- unextracted ROM bank, so the only map this can honestly read is the one the +-- player is standing on. That is where the caller would be for every scripted +-- use of it that exists today. +H.RandomUnseenWildMon = function(vm) + local h = hooks(vm) + answer(vm, TRUE) + if not h.rareWildMon then return end + local species = h.rareWildMon() + if not species then return end + local record = save(vm) + local seen = record and record.pokedex and record.pokedex.seen + if seen and seen[species] then return end + nameMon(vm, species) + answer(vm, FALSE) +end + +-- ---- 91, 92 the phone chatter name-drops ----------------------------------- +-- +-- Both open on GetCallerLocation (engine/phone/phone.asm), which reads the +-- contact on the line out of wCurCaller; the port parks that id on +-- vm.curPhoneCaller when a call's script starts (World:receivePhoneCall for +-- an incoming ring, Game2:runPokegearCall for an outgoing one). +-- Neither routine writes wScriptVar: both end on CopyBytes into +-- wStringBuffer4, which is the {STRBUF} the chat line after them reads. + +-- trainers.lua and encounters.lua both store species IDS, not dex indexes, +-- so this is nameMon's sibling for a handler already holding the id. +local function nameSpecies(vm, species) + local defs = data(vm) + local def = defs and defs.pokemon and defs.pokemon[species] + vm:setStringBuffer((def and def.name) or species) +end + +-- RandomPhoneWildMon (engine/overworld/wildmons.asm): one of the FOUR +-- commonest grass slots (`call Random / and %11`) on the CALLER'S map, read +-- at the current time of day, named into the buffer. A caller whose map has +-- no grass table leaves the buffer alone, as the cart's unmatched +-- LookUpWildmonsForMapDE walk would read whatever sat past the last row. +H.RandomPhoneWildMon = function(vm) + local contact = Phone.CONTACTS[vm.curPhoneCaller or -1] + local w = hooks(vm).world + local grass = w and w.encounters and w.encounters.grass + local entry = contact and contact.map and grass and grass[contact.map] + local slots = entry and entry.slots + if not slots then return end + local daytime = (w and w.daytime) or "DAY" + if daytime == "DARK" then daytime = "NITE" end + local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)] + if slot and slot.species then nameSpecies(vm, slot.species) end +end + +-- RandomPhoneMon: a mon out of the calling trainer's OWN party, uniform over +-- its length (`call Random / maskbits PARTY_LENGTH / cp e / jr nc` rerolls). +-- The cart walks TrainerGroups by the contact row's class and member bytes; +-- trainers.lua is that table, and the contact stores the same pair as the +-- class id and the member's own id string. +H.RandomPhoneMon = function(vm) + local contact = Phone.CONTACTS[vm.curPhoneCaller or -1] + if not (contact and contact.class) then return end + local defs = data(vm) + local class = defs and defs.trainers and defs.trainers.classes + and defs.trainers.classes[contact.class] + local mons + for _, row in ipairs((class and class.trainers) or {}) do + if row.id == contact.member then + mons = row.party + break + end + end + if not (mons and #mons > 0) then return end + local mon = mons[Specials.random(#mons)] + if mon and mon.species then nameSpecies(vm, mon.species) end +end + +-- ---- 95 Snorlax ----------------------------------------------------------- +-- +-- SnorlaxAwake: TRUE only when the POKe FLUTE channel is the music that is +-- playing AND the player is on one of five cells beside the Snorlax. Both +-- halves matter -- the flute wakes it from next to it, not from across +-- Vermilion. The coordinates are the routine's own .ProximityCoords. +Specials.SNORLAX_PROXIMITY = { + { 33, 8 }, { 34, 10 }, { 35, 10 }, { 36, 8 }, { 36, 9 }, +} +Specials.POKE_FLUTE_SONG = "Music_PokeFluteChannel" + +H.SnorlaxAwake = function(vm) + local h = hooks(vm) + answer(vm, FALSE) + local song = h.currentMusic and h.currentMusic() + if song ~= Specials.POKE_FLUTE_SONG then return end + local x, y = 0, 0 + if h.playerCell then x, y = h.playerCell() end + for _, cell in ipairs(Specials.SNORLAX_PROXIMITY) do + if cell[1] == x and cell[2] == y then + answer(vm, TRUE) + return + end + end +end + +-- ---- 96-98 the haircut brothers and Daisy --------------------------------- +-- +-- One routine (HaircutOrGrooming) with three happiness tables in front of it. +-- Each table is a weighted roll: a random byte walks the rows subtracting each +-- row's weight, and the row it lands on carries the wScriptVar the script +-- branches on (which of three lines the barber says) and the HAPPINESS_* action +-- applied to the mon. +-- +-- The three tables are data/events/happiness_probabilities.asm, transcribed +-- because the extractor never reaches them. Rows are +-- { weight, scriptVar, happinessChange } where `weight` is the macro's own +-- `N percent` (`* $ff / 100`, integer) and -1 is 255, the catch-all last row: +-- +-- Older 30% -> 2, 50%+1 -> 3, rest -> 4 +-- Younger 60%+1 -> 2, 30% -> 3, rest -> 4 +-- Daisy always -> 2 +-- +-- The wScriptVar values really are 2, 3 and 4 -- not 0-based -- because the +-- barber's script branches on them with `ifequal`. +Specials.HAIRCUT_TABLES = { + older = { + { 76, 2, "OLDERCUT1" }, -- 30 percent + { 128, 3, "OLDERCUT2" }, -- 50 percent + 1 + { 255, 4, "OLDERCUT3" }, -- -1 + }, + younger = { + { 154, 2, "YOUNGCUT1" }, -- 60 percent + 1 + { 76, 3, "YOUNGCUT2" }, -- 30 percent + { 255, 4, "YOUNGCUT3" }, -- -1 + }, + daisy = { + { 255, 2, "GROOMING" }, + }, +} + +-- HappinessChanges (data/events/happiness_changes.asm) is transcribed ONCE, in +-- src/core/gen2/Happiness.lua, alongside the tier pick and the two clamps. +-- The seven rows the barbers and the groomer reach are a window onto that +-- table, not a second copy: the enum is `const_def 1`, so a row that drifted +-- here would move HAPPINESS_GROOMING off the end of the other one and the two +-- callers of the same cart routine would disagree about the same haircut. +Specials.HAPPINESS_CHANGES = {} +for _, action in ipairs({ "OLDERCUT1", "OLDERCUT2", "OLDERCUT3", + "YOUNGCUT1", "YOUNGCUT2", "YOUNGCUT3", "GROOMING" }) do + Specials.HAPPINESS_CHANGES[action] = + Happiness.CHANGES[Happiness.EVENT[action]] +end + +-- ChangeHappiness itself, which is Happiness.change: the band pick, the $ff +-- and 0 carry clamps, and the `cp EGG / ret z` that this wrapper used to be +-- missing. The egg case cannot be reached from here today (haircut below +-- refuses one before it ever gets this far, the way `.egg` does), but the +-- guard belongs to the routine rather than to one of its callers. +function Specials.changeHappiness(mon, action) + Happiness.change(mon, action) +end + +local function haircut(vm, which) + local _, mon = selectMon(vm, "choose") + if not mon then + answer(vm, 0) + return + end + local Breeding = require("src.core.gen2.Breeding") + -- `cp EGG / jr z, .egg`: an egg cannot be groomed, and `.egg` leaves + -- wScriptVar at 0 rather than answering one of the three rows. + if Breeding.isEgg(mon) then + answer(vm, 0) + return + end + nameMon(vm, mon.nickname or mon.species) + local rows = Specials.HAIRCUT_TABLES[which] or Specials.HAIRCUT_TABLES.daisy + -- `call Random / .loop: sub [hl] / jr c, .ok`: subtract each row's weight + -- from the rolled byte until it borrows. + local roll = Specials.random(0, 255) + local row = rows[#rows] + for _, candidate in ipairs(rows) do + if roll < candidate[1] then row = candidate break end + roll = roll - candidate[1] + end + answer(vm, row[2]) + Specials.changeHappiness(mon, row[3]) +end + +H.OlderHaircutBrother = function(vm) haircut(vm, "older") end +H.YoungerHaircutBrother = function(vm) haircut(vm, "younger") end +H.DaisysGrooming = function(vm) haircut(vm, "daisy") end + +-- ---- 100 PROF.OAK's PC #DEX rating ----------------------------------------- +-- +-- ProfOaksPCBoot (engine/events/prof_oaks_pc.asm). OaksLab's own script is +-- `writetext OakLabDexCheckText / waitbutton / special ProfOaksPCBoot`, so +-- like MoveDeletion and NameRater above every line here prints straight into +-- an already-open box and never opens or closes one. The asm never writes +-- wScriptVar either (no branch reads it anywhere), so this leaves +-- vm.scriptVar exactly as `special` found it. +-- +-- ProfOaksPC, the outer wrapper with the "want your #DEX rated?" yes/no gate +-- and the "link closed" shutdown line, is what the Pokemon Center's OaksPC +-- menu item farcalls; the whose-PC menu runs that flow inside its own screen +-- (src/ui/gen2/CenterPcMenu.lua oakRate) off the exports below. +-- ProfOaksPCBoot is the only label the cache's specialOrder ever names +-- (OaksLab's dex-completeness check goes straight to it, skipping the +-- yes/no), and it is the one this builds. +-- +-- data/text/common_2.asm _OakPCText2/_OakPCText3, transcribed the way +-- MOVE_DELETER_TEXT above transcribes its own bank. The seen/owned counts +-- are formatted straight into the text with %d instead of through {STRBUF}: +-- the cart puts them in two DIFFERENT buffers (wStringBuffer3, +-- wStringBuffer4) in the one textbox, and the VM's {STRBUF} substitution +-- only ever carries one value. +local OAK_PC_TEXT = { + completion = Strings.source("Current #DEX\ncompletion level:"), + counts = Strings.source( + "%d #MON seen\n%d #MON owned\n\nPROF.OAK's\nRating:"), +} + +-- OakRatings (data/events/pokedex_ratings.asm). Each row is (cap, sfx, +-- text); FindOakRating walks the table with `cp c / jr nc, .match` against +-- ascending caps, which is "the first row whose cap covers the caught +-- count" -- exactly what findOakRating below does. sfx is the Gold sfx +-- table's own label (audio/sfx_pointers.asm dba lines), not a pokered +-- fanfare name. +local OAK_RATINGS = { + { max = 9, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Look for #MON\nin grassy areas!") }, + { max = 19, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Good. I see you\nunderstand how to\nuse # BALLS.") }, + { max = 34, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You're getting\ngood at this.\n\nBut you have a\nlong way to go.") }, + { max = 49, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You need to fill\nup the #DEX.\n\nCatch different\nkinds of #MON!") }, + { max = 64, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "You're trying--I\ncan see that.\n\nYour #DEX is\ncoming together.") }, + { max = 79, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "To evolve, some\n#MON grow,\n\nothers use the\neffects of STONES.") }, + { max = 94, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Have you gotten a\nfishing ROD? You\n\ncan catch #MON\nby fishing.") }, + { max = 109, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Excellent! You\nseem to like col-\nlecting things!") }, + { max = 124, sfx = "Sfx_CaughtMon", text = Strings.source( + "Some #MON only\nappear during\n\ncertain times of\nthe day.") }, + { max = 139, sfx = "Sfx_CaughtMon", text = Strings.source( + "Your #DEX is\nfilling up. Keep\nup the good work!") }, + { max = 154, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "I'm impressed.\nYou're evolving\n\n#MON, not just\ncatching them.") }, + { max = 169, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "Have you met KURT?\nHis custom #\nBALLS should help.") }, + { max = 184, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Wow. You've found\nmore #MON than\n\nthe last #DEX\nresearch project.") }, + { max = 199, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Are you trading\nyour #MON?\n\nIt's tough to do\nthis alone!") }, + { max = 214, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "Wow! You've hit\n200! Your #DEX\nis looking great!") }, + { max = 229, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "You've found so\nmany #MON!\n\nYou've really\nhelped my studies!") }, + { max = 239, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Magnificent! You\ncould become a\n\n#MON professor\nright now!") }, + { max = 248, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Your #DEX is\namazing! You're\n\nready to turn\nprofessional!") }, + -- The top band (251 real species, the table's cap of 255 covers it): this + -- is the ONLY place the ROM checks "has the player finished the #DEX", and + -- it does it here rather than handing off to anything. The actual diploma + -- is not this special's business -- GameFreakGameDesignerScript in + -- 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"). + -- A finished #DEX here just means every rating after this one is this + -- same line. + { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Whoa! A perfect\n#DEX! I've\n\ndreamt about this!\nCongratulations!") }, +} + +-- CountSetBits over wPokedexSeen/wPokedexCaught. The port's dex is a +-- species-keyed bool map (src/core/gen2/Save.lua), not a bitfield, so this +-- counts `true` entries the same way Save.summary counts wPokedexCaught for +-- the CONTINUE panel. +local function dexCounts(record) + local dex = record and record.pokedex + local seen, caught = 0, 0 + for _, has in pairs((dex and dex.seen) or {}) do + if has then seen = seen + 1 end + end + for _, has in pairs((dex and dex.caught) or {}) do + if has then caught = caught + 1 end + end + return seen, caught +end + +-- FindOakRating. NUM_POKEMON is 251, so `caught` never exceeds the table's +-- own top cap of 255 and this never falls off the end. +local function findOakRating(caught) + for _, row in ipairs(OAK_RATINGS) do + if caught <= row.max then return row end + end + return OAK_RATINGS[#OAK_RATINGS] +end + +H.ProfOaksPCBoot = function(vm) + vm:showRaw(Strings(OAK_PC_TEXT.completion)) + local seen, caught = dexCounts(save(vm)) + vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught)) + local rating = findOakRating(caught) + local h = hooks(vm) + if h.playSfxNamed then h.playSfxNamed(rating.sfx) end + vm:showRaw(Strings(rating.text)) + -- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left + -- playing under the rating text, and the caller (OaksLab's own script) + -- waits it out before closing the box. + coroutine.yield({ kind = "waitsfx" }) +end + +-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs +-- ProfOaksPC's rating flow inside a screen rather than a script, so the +-- counts, the rating pick and the two OakPC texts are exported here rather +-- than transcribed a second time. +Specials.dexCounts = dexCounts +Specials.findOakRating = findOakRating +Specials.OAK_PC_TEXT = OAK_PC_TEXT + +-- ---- 101-102 the console and the Trainer House ---------------------------- + +-- GameboyCheck: GBCHECK_GB 0 / GBCHECK_SGB 1 / GBCHECK_CGB 2. Gold is a Game +-- Boy Color game and the port renders its GBC palettes, so the honest answer +-- follows the COLOR option: the deliberate step down to a grey Game Boy is a +-- real answer to this question, and the Goldenrod console kid's line changes +-- with it, which is the only place it is asked. +H.GameboyCheck = function(vm) + local GbcPalette = require("src.render.GbcPalette") + if GbcPalette.mode == "gbc" then + answer(vm, GBCHECK_CGB) + elseif GbcPalette.mode == "classic" then + answer(vm, GBCHECK_GB) + else + answer(vm, GBCHECK_SGB) + end +end + +-- TrainerHouse: sMysteryGiftTrainerHouseFlag, the byte a Mystery Gift trade +-- leaves behind so the Viridian Trainer House has somebody to fight. Mystery +-- Gift is out of scope, so the flag is permanently 0 and the house holds its +-- default opponent -- which is what an unlinked cartridge does. +-- +-- The byte has three readers (this, ReadTrainerParty and GetTrainerName) and +-- src/world/gen2/TrainerHouse.lua is the one that owns it, so the answer here +-- cannot drift from the party the battle then loads. Required inside the +-- handler the way H.GameboyCheck requires GbcPalette: the script layer does +-- not otherwise depend on the world layer. +H.TrainerHouse = function(vm) + local TrainerHouse = require("src.world.gen2.TrainerHouse") + answer(vm, TrainerHouse.hasCustomTrainer(save(vm)) and TRUE or FALSE) +end + +-- ---- 104 the roamers ------------------------------------------------------ +-- +-- InitRoamMons (engine/overworld/wildmons.asm): the three legendary beasts are +-- written into the wRoamMon structs on their starting routes. RoamMon_1 is +-- Raikou on ROUTE 42, 2 Entei on ROUTE 37, 3 Suicune on ROUTE 38, all at level +-- 40, and each HP byte is zeroed under the asm's own comment "generate new +-- stats" -- a roamer has no rolled stats until it is first met. +-- +-- The roster and the walk both live in src/core/gen2/Roamers.lua, which is the +-- ONE writer of save.roamers: this file used to carry a second copy of the +-- three rows, and two transcriptions of one table is how a renumbered slot +-- sends Suicune's damage to Raikou's byte. Specials.ROAMERS is kept as an +-- alias of that table so a reader landing here still sees what the routine +-- writes. +-- +-- `force` because the asm stores unconditionally. Roamers.init's default is +-- the port's own re-entry guard, which is the right default for a caller that +-- is not the cart's own command; the command itself has to be the cart. +Specials.ROAMERS = Roamers.SPECIES + +H.InitRoamMons = function(vm) + local record = save(vm) + if not record then return end + Roamers.init(record, { force = true }) +end + +-- ---- the #DEX-completion diploma ------------------------------------------- +-- +-- _Diploma (engine/events/diploma.asm): PlaceDiplomaOnScreen then +-- WaitPressAorB_BlinkCursor. Called by Celadon Mansion 3F's +-- GameFreakGameDesignerScript once VAR_DEXCAUGHT hits 251 (see the OAK_RATINGS +-- comment above); the caller wraps the farcall in FadeToMenu/ExitAllMenus +-- (engine/events/specials.asm Diploma), and _Diploma itself never touches +-- wScriptVar, so this leaves vm.scriptVar untouched the same way H.MoveDeletion +-- and H.NameRater do. The screen is src/ui/gen2/Diploma.lua; World:showDiploma +-- is the push, kept behind World the way every other screen-opening special is. +H.Diploma = function(vm) + local h = hooks(vm) + if not h.showDiploma then return end + Specials.block(vm, function(done) + h.showDiploma(function() done(true) end) + end) +end + +-- ---- Mom's savings ---------------------------------------------------------- +-- +-- BankOfMom (engine/events/mom.asm), reached from PlayersHouse1F's own +-- `MomScript` (`special BankOfMom`, run inside a caller-opened textbox the +-- same way H.MoveDeletion and H.NameRater's callers open theirs). The asm is +-- a nine-state jumptable; this ports its shape rather than its byte, with two +-- of its own behaviours kept on purpose: +-- +-- * StoreMoney/TakeMoney's insufficient-funds arms `ret` WITHOUT advancing +-- wJumptableIndex, so `.loop` re-enters the SAME state and asks again -- +-- the `while true do` loops below stand in for that. +-- * GiveMoney does not refuse an over-the-cap deposit, it CLAMPS to +-- MAX_MONEY and reports carry; the caller sees that as "no room" but the +-- clamp already landed, so a deposit that overflows Mom's account still +-- tops her out at 999999 without touching the wallet (`giveMoneyClamped` +-- below is exactly that GiveMoney, not a rejecting one). +-- +-- Not ported: the `.nope` arm of IsThisAboutYourMoney calls DSTChecks, which +-- nudges wStartHour/wStartDay to flip Daylight Saving on or off and reprints +-- the clock. This port has no wStartHour to nudge -- World:hour reads the +-- host clock directly rather than keeping an offset from it (SetDayOfWeek's +-- own comment above says the same for the day wheel) -- so there is nothing +-- for a yes/no here to change, and the conversation falls straight through to +-- MomJustDoWhatYouCanText the way it does once DSTChecks itself returns. +-- +-- data/text/common_1.asm, transcribed the way MOVE_DELETER_TEXT above +-- transcribes its own bank, and with the cart's own page structure: `para` is +-- a page break (`\f` -- the box clears and WAITS for A), `cont` is the +-- scrolled third line (`\v`, which also waits). Folding paras into plain +-- `\n`s let the whole of MomLeavingText1 type itself out as one page with no +-- button waits at all, straight through to the savings prompt. +local MOM_TEXT = { + leaving1 = Strings.source( + "Wow, that's a cute\n#MON.\fWhere did you get\nit?\f…\f" + .. "So, you're leaving\non an adventure…\fOK!\nI'll help too.\f" + .. "But what can I do\nfor you?\fI know! I'll save\nmoney for you.\f" + .. "On a long journey,\nmoney's important.\fDo you want me to\n" + .. "save your money?"), + leaving2 = Strings.source("OK, I'll take care\nof your money.\f…"), + leaving3 = Strings.source( + "Be careful.\f#MON are your\nfriends. You need\vto work as a team.\f" + .. "Now, go on!"), + isThisAboutMoney = Strings.source( + "Hi! Welcome home!\nYou're trying very\vhard, I see.\f" + .. "I've kept your\nroom tidy.\fOr is this about\nyour money?"), + whatDoYouWantToDo = Strings.source("What do you want\nto do?"), + storeMoney = Strings.source("How much do you\nwant to save?"), + takeMoney = Strings.source("How much do you\nwant to take?"), + saveMoney = Strings.source("Do you want to\nsave some money?"), + haventSavedThatMuch = Strings.source("You haven't saved\nthat much."), + notEnoughRoomInWallet = Strings.source("You can't take\nthat much."), + insufficientFundsInWallet = Strings.source("You don't have\nthat much."), + notEnoughRoomInBank = Strings.source("You can't save\nthat much."), + startSavingMoney = Strings.source( + "OK, I'll save your\nmoney. Trust me!\f{PLAYER}, stick\nwith it!"), + storedMoney = Strings.source("Your money's safe\nhere! Get going!"), + takenMoney = Strings.source("{PLAYER}, don't\ngive up!"), + justDoWhatYouCan = Strings.source("Just do what\nyou can."), +} + +-- constants/script_constants.asm. +local YOUR_MONEY, MOMS_MONEY = 0, 1 +local MOM_MAX_MONEY = 999999 + +-- BankOfMom_MenuHeader: `menu_coords 0, 0, 10, 10`, STATICMENU_CURSOR, four +-- items, cursor starting on GET. Answers through the same "menu" yield +-- Script_verticalmenu uses, so Gen2ScriptMenu draws it with no new screen. +local BANK_MENU_HEADER = { + left = 0, top = 0, right = 10, bottom = 10, + dataFlags = 0x80, -- STATICMENU_CURSOR + items = { "GET", "SAVE", "CHANGE", "CANCEL" }, + cursor = 1, +} + +local function bankMoney(vm, account) + local h = hooks(vm) + return (h.money and h.money(account)) or 0 +end + +local function setBankMoney(vm, account, value) + local h = hooks(vm) + if h.setMoney then + h.setMoney(account, math.max(0, math.min(value, MOM_MAX_MONEY))) + end +end + +-- GiveMoney (engine/events/money.asm): adds, clamps at MAX_MONEY, and reports +-- (via the second return) whether the clamp fired -- the caller's cue to show +-- the "no room" line even though the account already sits at the cap. +local function giveMoneyClamped(vm, account, amount) + local have = bankMoney(vm, account) + local total = have + amount + if total > MOM_MAX_MONEY then + setBankMoney(vm, account, MOM_MAX_MONEY) + return MOM_MAX_MONEY, true + end + setBankMoney(vm, account, total) + return total, false +end + +-- TakeMoney: subtracts, floors at 0 rather than borrowing. +local function takeMoneyFloored(vm, account, amount) + local have = bankMoney(vm, account) + if amount > have then + setBankMoney(vm, account, 0) + return 0 + end + setBankMoney(vm, account, have - amount) + return have - amount +end + +-- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu's six-digit keypad +-- (src/ui/gen2/BankOfMom.lua). `kind` is "deposit" or "withdraw", only for +-- the screen's own label; the amount it hands back is unvalidated, exactly +-- the way wStringBuffer2 is before StoreMoney/TakeMoney check it against the +-- other account. +local function bankOfMomAmount(vm, kind) + local h = hooks(vm) + if not h.bankOfMomAmount then return nil end + local saved, held = bankMoney(vm, MOMS_MONEY), bankMoney(vm, YOUR_MONEY) + return Specials.block(vm, function(done) + h.bankOfMomAmount(kind, saved, held, done) + end) +end + +local function transactionSfx(vm) + coroutine.yield({ kind = "waitsfx" }) + local h = hooks(vm) + if h.playSfxNamed then h.playSfxNamed("Sfx_Transaction", 22) end + coroutine.yield({ kind = "waitsfx" }) +end + +H.BankOfMom = function(vm) + local record = save(vm) + if not record then return end + record.mom = record.mom or {} + local mom = record.mom + + local function justDoWhatYouCan() + vm:showRaw(Strings(MOM_TEXT.justDoWhatYouCan)) + end + + -- .CheckIfBankInitialized / .InitializeBank: the very first visit, before + -- MOM_ACTIVE_F is ever set. Skips IsThisAboutYourMoney entirely. + if not mom.active then + -- engine/events/mom.asm:50-53, PrintText then `call YesNoBox`. + showRawHeld(vm, Strings(MOM_TEXT.leaving1)) + local wantsToSave = coroutine.yield({ kind = "yesorno" }) + mom.active = true + if wantsToSave then + mom.savingMoney = true + vm:showRaw(Strings(MOM_TEXT.leaving2)) + end + vm:showRaw(Strings(MOM_TEXT.leaving3)) + return + end + + -- .IsThisAboutYourMoney + -- engine/events/mom.asm:71-74, the same PrintText / YesNoBox pair. + showRawHeld(vm, Strings(MOM_TEXT.isThisAboutMoney)) + local aboutMoney = coroutine.yield({ kind = "yesorno" }) + if not aboutMoney then + -- .nope: DSTChecks does not apply here; see the header note above. + justDoWhatYouCan() + return + end + + -- .AccessBankOfMom + vm:showRaw(Strings(MOM_TEXT.whatDoYouWantToDo)) + local choice = coroutine.yield({ kind = "menu", style = "vertical", + header = BANK_MENU_HEADER }) + + if choice == 1 then + -- .withdraw -> .TakeMoney + while true do + vm:showRaw(Strings(MOM_TEXT.takeMoney)) + local amount = bankOfMomAmount(vm, "withdraw") + if not amount or amount == 0 then justDoWhatYouCan() return end + if amount > bankMoney(vm, MOMS_MONEY) then + vm:showRaw(Strings(MOM_TEXT.haventSavedThatMuch)) + else + local _, overflowed = giveMoneyClamped(vm, YOUR_MONEY, amount) + if overflowed then + vm:showRaw(Strings(MOM_TEXT.notEnoughRoomInWallet)) + else + takeMoneyFloored(vm, MOMS_MONEY, amount) + transactionSfx(vm) + vm:showRaw(Strings(MOM_TEXT.takenMoney)) + return + end + end + end + elseif choice == 2 then + -- .deposit -> .StoreMoney + while true do + vm:showRaw(Strings(MOM_TEXT.storeMoney)) + local amount = bankOfMomAmount(vm, "deposit") + if not amount or amount == 0 then justDoWhatYouCan() return end + if amount > bankMoney(vm, YOUR_MONEY) then + vm:showRaw(Strings(MOM_TEXT.insufficientFundsInWallet)) + else + local _, overflowed = giveMoneyClamped(vm, MOMS_MONEY, amount) + if overflowed then + vm:showRaw(Strings(MOM_TEXT.notEnoughRoomInBank)) + else + takeMoneyFloored(vm, YOUR_MONEY, amount) + transactionSfx(vm) + vm:showRaw(Strings(MOM_TEXT.storedMoney)) + return + end + end + end + elseif choice == 3 then + -- .stopsaving -> .StopOrStartSavingMoney + -- engine/events/mom.asm:255-258, the same PrintText / YesNoBox pair. + showRawHeld(vm, Strings(MOM_TEXT.saveMoney)) + local wantsToSave = coroutine.yield({ kind = "yesorno" }) + if wantsToSave then + mom.savingMoney = true + vm:showRaw(Strings(MOM_TEXT.startSavingMoney)) + else + mom.savingMoney = false + justDoWhatYouCan() + end + else + -- .cancel: CANCEL itself, or B. + justDoWhatYouCan() + end +end + +-- ---- the Magnet Train ------------------------------------------------------ +-- +-- MagnetTrain (engine/events/magnet_train.asm), the Goldenrod <-> Saffron ride +-- both station officers run once EVENT_RESTORED_POWER_TO_KANTO is set and the +-- PASS is in the bag. The routine READS wScriptVar and never writes it: +-- +-- ld a, [wScriptVar] +-- and a +-- jr nz, .ToGoldenrod +-- +-- so the `setval FALSE` in front of the Goldenrod call and the `setval TRUE` in +-- front of the Saffron one are what pick the direction, and vm.scriptVar has to +-- come back out of here exactly as it went in -- the `warpcheck` that follows +-- reads nothing, but a handler that clobbered it would still be lying about +-- what the routine does. +-- +-- The ride itself is src/core/gen2/MagnetTrain.lua and its screen is +-- src/ui/gen2/MagnetTrainRide.lua; World:magnetTrain is the push. With no hook +-- the special is a no-op that leaves the script to warp on its own, which is +-- what a headless run wants. +H.MagnetTrain = function(vm) + local h = hooks(vm) + if not h.magnetTrain then return end + local toGoldenrod = (vm.scriptVar or 0) ~= 0 + Specials.block(vm, function(done) + h.magnetTrain(toGoldenrod, function() done(true) end) + end) +end + +-- ---- the Cianwood photo studio --------------------------------------------- +-- +-- PhotoStudio (engine/events/print_photo.asm). CianwoodPhotoStudio's own +-- script (CianwoodPhotoStudioFishingGuruScript) is `faceplayer / opentext / +-- writetext .Question / yesorno / iffalse .Refused / writetext .Yes / +-- waitbutton / special PhotoStudio / waitbutton / closetext` -- the yes/no +-- gate and the surrounding textbox both belong to the map script (generic +-- VM opcodes; nothing to hand-port there), and PhotoStudio itself only ever +-- runs after the player has said yes. Like H.MoveDeletion and H.NameRater, +-- it never writes wScriptVar (no `ld [wScriptVar], a` anywhere in the +-- routine), so this leaves vm.scriptVar exactly as `special` found it. +-- +-- data/text/common_1.asm _WhichMonPhotoText/_HoldStillText/ +-- _PrestoAllDoneText/_NoPhotoText/_EggPhotoText, transcribed the way +-- MOVE_DELETER_TEXT above transcribes its own bank. +-- +-- farcall PrintPartymon (engine/printer/printer.asm) is the actual camera: +-- it draws the portrait card (src/ui/gen2/PhotoStudio.lua transcribes that +-- 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, +-- .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 +-- a cartridge with nothing plugged into its link port. +local PHOTO_STUDIO_TEXT = { + whichMon = Strings.source("Which #MON\nshould I photo-\ngraph?"), + holdStill = Strings.source("All righty. Hold\nstill for a bit."), + noPhoto = Strings.source("Oh, no picture?\nCome again, OK?"), + eggPhoto = Strings.source("An EGG? My talent\nis worth more…"), +} + +local function showPhotoStudio(vm, mon) + local h = hooks(vm) + if not h.showPhotoStudio then return end + Specials.block(vm, function(done) + h.showPhotoStudio(mon, function() done(true) end) + end) +end + +H.PhotoStudio = function(vm) + vm:showRaw(Strings(PHOTO_STUDIO_TEXT.whichMon)) + local _, mon = selectMon(vm, "choose") + if not mon then + vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) + return + end + + -- `ld a, [wCurPartySpecies] / cp EGG`: an egg slot is marked with `isEgg` + -- in this port (src/core/gen2/Breeding.lua), same test H.MoveDeletion and + -- H.NameRater make. + if mon.isEgg then + vm:showRaw(Strings(PHOTO_STUDIO_TEXT.eggPhoto)) + return + end + + vm:showRaw(Strings(PHOTO_STUDIO_TEXT.holdStill)) + showPhotoStudio(vm, mon) + -- hPrinter reads as an error unconditionally; see the header comment. + vm:showRaw(Strings(PHOTO_STUDIO_TEXT.noPhoto)) +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 + +-------------------------------------------------------------------------- +-- The deliberate stubs +-------------------------------------------------------------------------- +-- +-- Each carries the reason it is out of scope and the value it leaves in +-- wScriptVar. A stub is NOT the same thing as a missing handler: a special +-- that falls through leaves a STALE wScriptVar behind, and the `iffalse` two +-- commands later then takes whatever branch the last special happened to +-- leave -- which is the exact failure this whole module exists to stop. +-- +-- `value = nil` means the routine genuinely does not write wScriptVar. +local STUB_ROWS = { + -- Everything link cable. Two Game Boys and a cable; the port has link play + -- for Gen 1 only (src/link/), and none of the Gen 2 cable-club protocol is + -- ported. The values are the "no partner turned up" arm of each routine, + -- which is what an unplugged cartridge sees. + { "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" }, + { "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" }, + { "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" }, + { "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" }, + { "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" }, + { "EnterTimeCapsule", nil, "link cable: no Time Capsule" }, + { "TradeCenter", nil, "link cable: no trade room" }, + { "Colosseum", nil, "link cable: no battle room" }, + { "TimeCapsule", nil, "link cable: no Time Capsule" }, + { "CableClubCheckWhichChris", 0, "link cable: only one player exists" }, + { "DisplayLinkRecord", nil, "link cable: no link record is kept" }, + -- Mystery Gift. Infrared between two carts; nothing in the port has an IR + -- port, and sMysteryGiftItem is therefore permanently empty. + { "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. + { "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", nil, "needs the POKeGEAR map card in view mode" }, + { "UnusedMemoryGame", nil, "unused in Gold; needs the memory game screen" }, +} + +Specials.HANDLERS = H +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 + +-- 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 +end + +return Specials diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua new file mode 100644 index 00000000..c3f53c89 --- /dev/null +++ b/src/script/gen2/Vm.lua @@ -0,0 +1,2646 @@ +-- Gen 2 script VM over import-extracted command lists (data/generated/scripts.lua). +-- Yields on text / yesorno / movement / waitsfx so the overworld can drive UI. + +local Movement = require("src.script.gen2.Movement") +local Opcodes = require("src.script.gen2.Opcodes") +local Specials = require("src.script.gen2.Specials") +local Strings = require("src.core.Strings") +local Logger = require("src.core.Logger") +-- The mod event/hook buses. Null objects until a loader installs the live +-- ones, so every emit/call site below is a safe pass-through on a headless or +-- mod-free boot, and the Runtime.wants* guards keep even the payload +-- construction off that path. +local Runtime = require("src.mods.Runtime") + +local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility + +local Vm = {} +Vm.__index = Vm + +-- The op name a mod's row carries; no cart byte decodes to it. See +-- src/script/gen2/Opcodes.lua and Vm:runModCommand. +local MOD_COMMAND = Opcodes.MOD_COMMAND + +local function arg1(cmd) + if cmd == nil then return nil end + if cmd.args then return cmd.args[1] end + return nil +end + +-- constants/script_constants.asm. LAST_TALKED is -2, so the byte the +-- extractor writes is 254: `disappear LAST_TALKED` means hLastTalked, not +-- object 254. Script_disappear / Script_turnobject / Script_writeobjectxy all +-- carry that substitution; Script_appear pointedly does not. +local LAST_TALKED = 0xfe +-- CompareMoneyAction writes one of these three to wScriptVar, and HAVE_MORE is +-- ZERO: an `iffalse` after a checkmoney / checkcoins means the player has MORE +-- than the amount asked for, not less. Getting that round the wrong way is the +-- whole of the Goldenrod coin vendor. +local HAVE_MORE, HAVE_AMOUNT, HAVE_LESS = 0, 1, 2 +-- CheckPokeMail's five answers; REFUSED is what SelectMonFromParty's carry +-- produces when the player backs out, and so also what a driver with no mail +-- handler at all gets. The model is src/core/gen2/Mail.lua. +local POKEMAIL_REFUSED = 2 +-- Script_askforphonenumber's three answers. SUCCESS IS ZERO here. +local PHONE_CONTACT_GOT, PHONE_CONTACTS_FULL, PHONE_CONTACT_REFUSED = 0, 1, 2 +-- constants/misc_constants.asm: the caps GiveMoney and GiveCoins write back. +local MAX_MONEY, MAX_COINS = 999999, 9999 +-- constants/sfx_constants.asm +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/misc_constants.asm GS_VERSION: 0 Gold, 1 Silver. +local GS_VERSION_GOLD = 0 +-- engine/overworld/variables.asm .VarActionTable rows for wMapGroup and +-- wMapNumber. `readvar` is the only route this VM has to either, and the pair +-- is how the CART names a map (constants/map_constants.asm map_const): see +-- Vm:scriptCtx, which reports them to mods. Same ids World:readVar answers on. +local VAR_MAPGROUP, VAR_MAPNUMBER = 0x0c, 0x0d +-- wBattleResult (constants/battle_constants.asm), which is what `startbattle` +-- leaves in wScriptVar. A win is ZERO: see the command for the scripts that +-- depend on it. DRAW is the Sudowoodo / Gyarados "it fled" case; no battle +-- resumes with it yet, so it is here for the branch rather than for a caller. +local BATTLE_RESULTS = { win = 0, lose = 1, draw = 2 } +-- ItemPocketNames (data/items/pocket_names.asm), indexed by the item type +-- CheckItemPocket leaves in wItemAttributeValue: ITEM 1, KEY_ITEM 2, BALL 3, +-- TM_HM 4 (constants/item_data_constants.asm:15-19). GetPocketName copies the +-- matching string into wStringBuffer3, which is the SECOND blank in both +-- _PutItemInPocketText and _PocketIsFullText (data/text/common_2.asm:1351, +-- :1361). The cache's items.lua carries the same four names on `pocket`. +local POCKET_NAMES = { + ITEM = "ITEM POCKET", + KEY_ITEM = "KEY POCKET", + BALL = "BALL POCKET", + TM_HM = "TM POCKET", +} + +-- CompareMoney (engine/events/money.asm) reports account minus amount as one +-- of the HAVE_* three rather than as a boolean. +local function compareFunds(account, amount) + if account < amount then return HAVE_LESS end + if account == amount then return HAVE_AMOUNT end + return HAVE_MORE +end + +-- givemoney / takemoney / checkmoney lay their operand down as `db account` +-- followed by `bigdt money` (macros/scripts/events.asm), and the extractor +-- leaves that untouched in `args`: args[1] is the account, args[2..4] are the +-- three money bytes BIG-endian. +local function moneyArgs(cmd) + local a = cmd.args or {} + return a[1] or 0, + (a[2] or 0) * 0x10000 + (a[3] or 0) * 0x100 + (a[4] or 0) +end + +-- The plain `dw` operands (coins, WRAM addresses, menu headers, mail and call +-- pointers) are little-endian in ROM whatever order the routine that reads them +-- stores them in: LoadCoinAmountToMem flips its two bytes into hMoneyTemp, but +-- the value in the script is still args[1] + args[2] * 256. +local function wordArg(cmd, first) + local a = cmd.args or {} + first = first or 1 + return (a[first] or 0) + (a[first + 1] or 0) * 0x100 +end + +-- The operand list of a MOD's row (Vm:runModCommand): `args` as the row wrote +-- it, or the tail of a Gen 1 shaped row -- { "mymod:shake", 4, 2 } -> { 4, 2 }. +-- Built fresh per dispatch rather than cached on the row, because the row +-- belongs to the mod and this VM does not write to other people's tables. +local function modArgs(cmd) + if cmd.args then return cmd.args end + local out = {} + for i = 2, #cmd do out[i - 1] = cmd[i] end + return out +end + +-- Execute ONE command out of a list. Split out of runList so the +-- `script.command` mod hook below has a single function to wrap, exactly the +-- way src/script/ScriptRunner.lua:163-170 wraps its own one-row dispatch. +-- +-- Returns, in the same vocabulary the Gen 1 runner's control commands use: +-- "end" this command ends the list it is in -- `end`, a tail-call +-- `sjump`, a taken `iftrue`, a `jumptext` that never comes back +-- number a 1-based row to continue at; only a mod hook produces one, the +-- cart's own control flow is all whole-list tail calls +-- nil fall through to the next row +local runList + +local function runCmd(self, cmd, op) + if op == "end" or op == "endall" or op == "endcallback" or op == "reloadend" then + return "end" + elseif op == "sjump" or op == "stopandsjump" then + runList(self, cmd.script) + return "end" + elseif op == "scall" then + runList(self, cmd.script) + elseif op == "sdefer" then + -- Script_sdefer only RECORDS the target: it writes + -- wDeferredScriptBank/Addr and sets RUN_DEFERRED_SCRIPT, and it is + -- RunSceneScript (engine/overworld/events.asm:388) that picks it up -- + -- after ScriptEvents has run the scene body to its `end`, as that pass's + -- player event. Running it inline instead put the League walk-ins, the + -- Cerulean grunt and the Mt Moon rival cutscene inside the scene script, + -- i.e. a beat early and under the map's fade-in. Vm:runDeferred drains + -- it where the cart does. + self.deferred = cmd.script + elseif op == "farscall" then + -- Script_farscall is Script_scall with the bank taken from the command + -- rather than from wScriptBank; both fall into the same ScriptCall, which + -- pushes wScriptBank/wScriptPos and comes BACK here on `end`. The + -- extractor has already resolved bank:addr into a scripts.lua key and + -- queued the target for disassembly, so at this level the two are one + -- call. An unresolved key is a no-op: runList returns on a nil list. + runList(self, cmd.script) + elseif op == "farsjump" then + -- Script_farsjump is Script_sjump across banks: ScriptJump overwrites + -- wScriptBank/wScriptPos and never pushes a return, so nothing after it + -- in this list runs. Opcodes.TERMINATORS already stops the extractor + -- there; returning is what makes the interpreter agree with it. + runList(self, cmd.script) + return "end" + elseif op == "memjump" then + -- Script_memjump reads a three-byte far pointer OUT OF WRAM at the + -- address in args = {lo, hi} and jumps to it. The only real user is + -- engine/overworld/events.asm's `memjump wQueuedScriptBank`, filled in at + -- run time by LoadMemScript, so there is no static target for the + -- extractor to follow and nothing here to run. + -- + -- It is still a JUMP: the cart does not come back to this list. So this + -- returns rather than falling through, which is the difference between + -- ending the script cleanly and running whatever bytes sit after it. + -- Script_memjump never touches wScriptVar, so neither does this. + return "end" + elseif op == "memcall" then + -- Script_memcall reads a three-byte far pointer out of WRAM at + -- args = {lo, hi} and calls it. The pointer is written at run time by + -- the phone engine (engine/phone/phone.asm: `memcall wPhoneScriptBank`, + -- `memcall wCallerContact + PHONE_CONTACT_SCRIPT2_BANK`), so nothing + -- static exists for the extractor to follow and there is no target to + -- interpret. An explicit no-op rather than a silent skip; like the + -- cart's own Script_memcall it leaves wScriptVar alone. + elseif op == "memcallasm" then + -- Script_memcallasm: the same WRAM far pointer as memcall, but `rst + -- FarCall`ed as raw Game Boy code instead of as script bytecode. Only + -- engine/overworld/events.asm's `memcallasm wQueuedScriptBank` uses it. + -- No interpreter can honour that, so it is an explicit no-op. All 34 + -- rows the extractor found sit in data regions mis-read as code. + elseif op == "callasm" then + -- Script_callasm: `rst FarCall` into raw Game Boy code at bank:addr + -- (args = {bank, lo, hi}). Nothing in this port can run that, so this is + -- a no-op, but a LOUD one: the routine a real callasm reaches is usually + -- the one that writes wScriptVar for the iffalse right after it. + -- FindItemInBallScript is `callasm .TryReceiveItem / iffalse .no_room`, + -- FruitTreeScript is `callasm CheckFruitTree / iffalse .fruit`. + -- + -- Script_callasm itself never touches wScriptVar, so neither does this: + -- inventing a 0 or a 1 here would pick a branch at random for every + -- caller at once. The hook is the seam instead. No `callasm` exists + -- anywhere under pokegold/maps/, so nothing reachable today waits on it. + if self.callAsmFn then + -- cmd.label appears the day the importer resolves bank:addr through + -- pokegold-symbols/pokegold.sym; until then the hook gets nil for it + -- and can still match on the bank/address pair. A number back is + -- wScriptVar, nil means "not modelled, leave it alone". + 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 + self:emitFace(false) + self:showText(cmd.text) + return "end" + elseif op == "jumptextfaceplayer" then + self:emitFace(true) + self:showText(cmd.text) + return "end" + elseif op == "faceplayer" then + self:emitFace(true) + elseif op == "opentext" or op == "closetext" + or op == "promptbutton" or op == "closepokepic" then + -- UI framing / pokepic teardown handled by hooks or TextBox. + if op == "closepokepic" then + -- Script_closepokepic is CloseWindow on the window Script_pokepic + -- opened, so the pic-window flag `waitbutton` reads goes down with it. + self.picOpen = false + if self.hidePicFn then self.hidePicFn() end + end + elseif op == "reanchormap" then + -- Script_reanchormap calls ReanchorMap (home/window.asm), which is + -- ClearWindowData plus a re-blit of the BG map from the player's current + -- position. The re-blit half is free here -- src/render/Camera.lua + -- follows the player every frame rather than anchoring a scrolled map -- + -- but the window teardown is not: WillsRoom's walk-in reanchors between + -- the applymovement and the earthquake, and ElmsLab reanchors before + -- every `pokepic`, so a window still standing here is one the cart has + -- already taken down. + self.picOpen = false + if self.hidePicFn then self.hidePicFn() end + elseif op == "writetext" or op == "farwritetext" then + self:showText(cmd.text) + elseif op == "rawtext" then + -- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and + -- text.lua only holds strings the extractor reached through a script + -- pointer -- so a hand-ported script (src/world/gen2/CmdQueue.lua's two + -- stone tables, whose text hangs off a callback nothing walks) has a + -- literal and no key to name it by. This is the one command that carries + -- one, and the extractor never emits it. + -- Declared through Strings.source at the table, looked up here, which is + -- the split src/core/Strings.lua asks for: a module-level template must + -- not resolve before Strings.load has a catalog. + -- `stay` rides the row for the same reason the text does: a transcription + -- that holds ONE MapTextbox over the next few commands has to say so, and + -- Vm:textStays' one-command lookahead cannot work it out. + -- `stay` / `hold` ride the row for the same reason the text does: a + -- transcription that keeps ONE MapTextbox standing over the next few + -- commands has to say so, and Vm:textStays' one-command lookahead cannot + -- work it out. `hold` is the cart `pause` those commands contain, in + -- Script_pause's own doubled frames (Vm:pauseFrames). + self:showRaw(Strings(cmd.text), cmd.stay, + cmd.hold and Vm.pauseLength(cmd.hold) or nil) + elseif op == "waitbutton" then + -- Script_waitbutton (engine/overworld/scripting.asm) is WaitButton, i.e. + -- WaitPressAorB_BlinkCursor: a REAL press of A or B, not a frame count. + -- + -- After a `writetext` the port's TextBox has already taken that press on + -- the last page, so the ordinary `writetext / waitbutton / closetext` run + -- must not ask for a second one -- that was the whole of the old no-op. + -- The case it got wrong is the one with no text box under it: a `pokepic` + -- window. ElmsLab's three starter balls are `reanchormap / pokepic / + -- cry / waitbutton / closepokepic / opentext / writetext / yesorno` + -- (maps/ElmsLab.asm ElmsLabPokeBallScript), and there the press is the + -- ONLY thing holding the pic up: skipping it ran pokepic and closepokepic + -- inside a single Vm:resume, so the starter's pic was built and thrown + -- away without one frame ever drawing it (#911). + if self.picOpen and self.waitButtonFn then + coroutine.yield({ kind = "waitbutton" }) + end + elseif op == "checkevent" then + self.scriptVar = self.events:get(cmd.event) and 1 or 0 + elseif op == "setevent" then + self.events:set(cmd.event, true) + if self.onFlagsChanged then self.onFlagsChanged() end + elseif op == "clearevent" then + self.events:set(cmd.event, false) + if self.onFlagsChanged then self.onFlagsChanged() end + elseif op == "checkflag" then + -- Script_checkflag: EngineFlagAction CHECK_FLAG over an ENGINE_* id, + -- result in c, wScriptVar TRUE only when c is non-zero. The read half of + -- the setflag / clearflag pair below; see setflag for why the ENGINE_* + -- namespace is kept apart from setevent's wEventFlags. + local flag = cmd.flag or wordArg(cmd) + local set + if self.getEngineFlagFn then + set = self.getEngineFlagFn(flag) + else + set = self.engineFlags[flag] + end + self.scriptVar = set and 1 or 0 + elseif op == "setflag" or op == "clearflag" then + -- Script_setflag / Script_clearflag: EngineFlagAction SET_FLAG or + -- RESET_FLAG over an ENGINE_* id (constants/engine_flags.asm). This is a + -- DIFFERENT namespace from setevent's wEventFlags and the two must not + -- share a store: engine flags are the eight Johto and eight Kanto badges, + -- the Pokegear cards, ENGINE_POKEDEX, ENGINE_BUG_CONTEST_TIMER and the + -- fly points, and EngineFlags scatters each one into its own byte + -- (wJohtoBadges, wPokegearFlags, wStatusFlags, wVisitedSpawns...). + -- + -- They never gate object visibility, which is wEventFlags' job, so this + -- deliberately does NOT call onFlagsChanged and cannot make an NPC pop in + -- mid-script. BugContestResultsWarpScript's `clearflag + -- ENGINE_BUG_CONTEST_TIMER` is what stops the contest clock, so a missing + -- clearflag leaves that timer running forever. The local table is the + -- fallback for a VM built without the hook (tests); with the hook, the + -- badge lands in the save. + local flag = cmd.flag or wordArg(cmd) + local value = (op == "setflag") + if self.setEngineFlagFn then + self.setEngineFlagFn(flag, value) + else + self.engineFlags[flag] = value or nil + end + elseif op == "iftrue" then + if self.scriptVar ~= 0 then + runList(self, cmd.script) + return "end" + end + elseif op == "iffalse" then + if self.scriptVar == 0 then + runList(self, cmd.script) + return "end" + end + elseif op == "ifequal" then + if self.scriptVar == (cmd.value or 0) then + runList(self, cmd.script) + return "end" + end + elseif op == "ifnotequal" then + if self.scriptVar ~= (cmd.value or 0) then + runList(self, cmd.script) + return "end" + end + elseif op == "ifgreater" then + -- Script_ifgreater: `ld a, [wScriptVar] / ld b, a / GetScriptByte / cp b`. + -- The compare is OPERAND minus wScriptVar, so carry (and the jump) is set + -- when the operand is BELOW wScriptVar: the branch is taken on + -- scriptVar > value. Spelled out because getting it round the wrong way + -- silently swaps both arms. Unsigned byte compare, which is why addval + -- wraps at 256. Like ifequal, the jump is a tail call. + if (self.scriptVar or 0) > (cmd.value or 0) then + runList(self, cmd.script) + return "end" + end + elseif op == "ifless" then + -- Script_ifless: the operand goes into b FIRST, then + -- `ld a, [wScriptVar] / cp b`, i.e. scriptVar minus operand, so carry + -- (and the jump) means scriptVar < value. Note the operand order is the + -- reverse of ifgreater's: the two are not mirror images in the source and + -- copying one into the other is how the polarity gets lost. + if (self.scriptVar or 0) < (cmd.value or 0) then + runList(self, cmd.script) + return "end" + end + elseif op == "pause" then + self:pauseFrames(cmd.frames or cmd.length or 0) + elseif op == "setscene" then + local scene = cmd.scene or arg1(cmd) or 0 + if self.setSceneFn then self.setSceneFn(scene) end + elseif op == "checkscene" then + self.scriptVar = self.getSceneFn and self.getSceneFn() or 0 + elseif op == "setmapscene" then + local group = cmd.group or (cmd.args and cmd.args[1]) + local map = cmd.map or (cmd.args and cmd.args[2]) + local scene = cmd.scene or (cmd.args and cmd.args[3]) or 0 + if self.setMapSceneFn then self.setMapSceneFn(group, map, scene) end + elseif op == "checkmapscene" then + -- Script_checkmapscene: GetMapSceneID for the `group, map` pair (first + -- byte group, second byte map, the same order setmapscene takes), then + -- wScriptVar = that map's scene byte. A map with no scene_var row comes + -- back with de = 0 and the command answers $ff, NOT 0: an `ifequal 0` + -- after it must not match a map that has no scene at all, which is the + -- whole reason the sentinel exists. checkscene above has the same case + -- for the current map; this is its cross-map twin. + local args = cmd.args + local group = cmd.group or (args and args[1]) + local mapNum = cmd.map or (args and args[2]) + local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum) + self.scriptVar = scene or 0xff + elseif op == "turnobject" then + local facing = Movement.dir(cmd.facing or 0) + if self.turnObjectFn then + self.turnObjectFn(cmd.object or 0, facing) + end + elseif op == "applymovement" or op == "applymovementlasttalked" then + local object = cmd.object or 0 + if op == "applymovementlasttalked" then + object = self.lastTalked or 1 + end + local movKey = cmd.movement + local bytes = movKey and self.movements and self.movements[movKey] + if bytes and self.applyMovementFn then + coroutine.yield({ kind = "move", object = object, bytes = bytes }) + end + elseif op == "yesorno" then + local yes = coroutine.yield({ kind = "yesorno" }) + self.scriptVar = yes and 1 or 0 + elseif op == "disappear" then + -- Script_disappear has a `cp LAST_TALKED` the port was missing: the + -- constant is -2, so the extracted byte is 254 and `disappear LAST_TALKED` + -- was trying to hide object 253. Script_appear has no such check, which + -- is why the branch below has none either. + local object = cmd.object or arg1(cmd) + if object == LAST_TALKED then object = self.lastTalked end + if self.disappearFn then self.disappearFn(object) end + elseif op == "appear" then + -- Script_appear: UnmaskCopyMapObjectStruct, then + -- ApplyEventActionAppearDisappear with b = 0 (CLEAR_FLAG) over the + -- object's MAPOBJECT_EVENT_FLAG. The mirror of `disappear` above, and + -- like it the object list changes NOW rather than at the next map load: + -- the cart respawns the struct inside the same command, so this must not + -- be routed through the deferred onFlagsChanged rebuild. + -- + -- ApplyEventActionAppearDisappear returns without touching anything when + -- the flag word is -1 ($ffff, which the extractor writes as 65535 and + -- Events:objectVisible already reads as "always appear"), so an object + -- with no flag is made visible by the struct copy alone. + local object = cmd.object or arg1(cmd) + if self.appearFn then self.appearFn(object) end + elseif op == "moveobject" then + -- Script_moveobject: `add 4` on x and on y, then CopyDECoordsToMapObject + -- (engine/overworld/player_object.asm) writes them into + -- MAPOBJECT_X_COORD / MAPOBJECT_Y_COORD. That +4 is the same border + -- offset PlayerSpawn_ConvertCoords applies to wXCoord/wYCoord, so the + -- script's own bytes are plain map cells: the coordinate space the + -- object_event macro and the extracted obj.x / obj.y already use. + -- + -- It teleports an object that is normally still hidden; every use in + -- pokegold is a `moveobject` immediately followed by an `appear` + -- (VictoryRoad's rival, Clair in DragonsDenB1F, the Fast Ship crew). + local args = cmd.args or {} + local object = cmd.object or args[1] or 0 + local x = cmd.x or args[2] or 0 + local y = cmd.y or args[3] or 0 + if self.moveObjectFn then self.moveObjectFn(object, x, y) end + elseif op == "variablesprite" then + -- Script_variablesprite: wVariableSprites[byte] = sprite. The macro + -- emits `\1 - SPRITE_VARS`, so the first byte is already a 0-based slot + -- into that table ($f0 SPRITE_CONSOLE .. $fc SPRITE_JANINE_IMPERSONATOR + -- in constants/sprite_constants.asm) and the second is a plain + -- OverworldSprites index, the numbering constants.spriteOrder uses. + -- + -- An object whose sprite IS one of those ids is extracted with a NUMBER + -- in `sprite` rather than a name (Route 36's Sudowoodo carries 244, i.e. + -- $f4 SPRITE_WEIRD_TREE), so World:pooledNpc finds no sheet for it and + -- the object never spawns. Filling the slot is what puts the disguised + -- tree, the Copycat, the Olivine rival and the four Fuchsia Gym Janines + -- on the map at all. + local args = cmd.args or {} + local slot = cmd.slot or args[1] or 0 + local sprite = cmd.sprite or args[2] or 0 + self.variableSprites[slot] = sprite + if self.variableSpriteFn then self.variableSpriteFn(slot, sprite) end + elseif op == "loademote" then + -- Script_loademote: EMOTE_FROM_MEM ($ff, -1 in + -- constants/script_constants.asm) means "the emote already in + -- wScriptVar", anything else is the literal id; LoadEmote then pushes + -- that bubble's tiles into VRAM. ShowEmoteScript is the only caller that + -- uses the FROM_MEM form, and Script_showemote has written its own first + -- byte to wScriptVar by the time it runs. + -- + -- This port picks the sheet at draw time (World:showEmote indexes + -- emoteOrder), so the command only has to remember WHICH bubble, for a + -- later `show_emote` movement byte ($54) that carries no id of its own. + local emote = cmd.emote or arg1(cmd) or 0 + if emote == EMOTE_FROM_MEM then emote = self.scriptVar or 0 end + self.loadedEmote = emote + if self.loadEmoteFn then self.loadEmoteFn(emote) end + elseif op == "pokepic" then + -- Script_pokepic opens a 7x7 window and leaves it standing: the pic is on + -- screen until a `closepokepic` (or a `reanchormap`) takes the window + -- down. The flag is what tells the `waitbutton` above that there is no + -- text box under it to have paid for the press already. + local species = cmd.species or arg1(cmd) + self.picOpen = true + if self.showPicFn then + self.showPicFn(species) + end + elseif op == "getmonname" then + local species = cmd.species or arg1(cmd) + if self.getMonNameFn then + self:setStringBuffer(self.getMonNameFn(species)) + end + elseif op == "getitemname" then + local item = cmd.item or arg1(cmd) or 0 + if self.getItemNameFn then + self:setStringBuffer(self.getItemNameFn(item)) + end + elseif op == "getstring" then + -- The extractor already read the `@`-terminated name the pointer aims + -- at (Script_getstring CopyName1 -> wStringBuffer2), so the following + -- writetext's TX_RAM has something to print. Without this, Mom's + -- "#GEAR" line renders its {STRBUF} as nothing. + self:setStringBuffer(cmd.string) + elseif op == "gettrainername" then + if self.getTrainerNameFn then + self:setStringBuffer(self.getTrainerNameFn(cmd.group, cmd.trainer)) + end + elseif op == "getcurlandmarkname" then + -- Script_getcurlandmarkname: GetWorldMapLocation on wMapGroup/wMapNumber, + -- then GetLandmarkName into a string buffer. The map is implicit, so the + -- one operand byte is only the buffer id and this port has one buffer. + -- landmarks.lua already carries the names, the line break included (the + -- town map draws them two rows deep). + local name = self.getLandmarkNameFn and self.getLandmarkNameFn() + 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 + -- whichever string buffer the operand names. Left-aligned is why there + -- are no padding spaces to reproduce: tostring() is the whole of it. + -- Without this the Bug Contest judge's "You have N minutes left" prints + -- an empty {STRBUF}. + self:setStringBuffer(tostring(self.scriptVar or 0)) + elseif op == "repeattext" then + -- Script_repeattext re-prints the text jumptext / jumptextfaceplayer + -- stashed in wScriptTextBank / wScriptTextAddr, and ONLY when both + -- operand bytes are -1. Read the guard carefully: the HIGH byte lands in + -- a and is compared first (`cp -1 / jr nz, .done`), then the low byte, + -- and any other pointer falls out of the command without printing + -- anything at all. It never prints the pointer it was given. + -- JumpTextScript's `repeattext -1, -1` is the only user in the ROM. + local args = cmd.args + if args and args[1] == 0xff and args[2] == 0xff and self.lastTextKey then + self:showText(self.lastTextKey) + end + elseif op == "givepoke" then + local species = cmd.species or arg1(cmd) + local level = cmd.level or (cmd.args and cmd.args[2]) or 5 + local item = cmd.item or (cmd.args and cmd.args[3]) or 0 + if self.givePokeFn then + self.givePokeFn(species, level, item) + end + elseif op == "checkpoke" then + -- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed + -- mon does not count, which is the point of the checks that gate on + -- carrying a particular species. + local species = cmd.species or arg1(cmd) or 0 + local has = self.hasPokeFn and self.hasPokeFn(species) + self.scriptVar = has and 1 or 0 + elseif op == "giveegg" then + -- Script_giveegg's own comment: 0 when there is no room in the party, + -- 2 when the egg went in. Not 1, so an `iftrue` after it is testing "the + -- party had room" and an `ifequal 2` is the same test spelled out. + local args = cmd.args + local species = cmd.species or (args and args[1]) or 0 + local level = cmd.level or (args and args[2]) or 5 + local given = self.giveEggFn and self.giveEggFn(species, level) + self.scriptVar = given and 2 or 0 + elseif op == "givepokemail" then + -- `givepokemail pointer` reads an item byte plus a MAIL_MSG_LENGTH + -- message from behind a pointer in the script's own bank and hands both + -- to GivePokeMail, which hangs the mail on the LAST party member. + -- The extractor resolves that pointer into `cmd.mail = { item, message }` + -- (RomExtractorGen2's givepokemail arm); the raw word is the fallback for + -- a cache built before it, and an unresolved letter is one the model + -- refuses rather than one it invents. GivePokeMail writes no wScriptVar. + if self.givePokeMailFn then + self.givePokeMailFn(cmd.mail or wordArg(cmd)) + end + elseif op == "checkpokemail" then + -- CheckPokeMail (engine/pokemon/mail.asm) opens the party list and only + -- then answers, so this BLOCKS: the coroutine parks on the yield and the + -- world's own handler resumes it with one of POKEMAIL_WRONG_MAIL 0 / + -- CORRECT 1 / REFUSED 2 / NO_MAIL 3 / LAST_MON 4. + -- + -- With no handler at all, REFUSED is the honest answer: it is the value + -- SelectMonFromParty's carry produces when the player backs out, so the + -- script takes its own cancel path instead of being told a delivery + -- happened. + local expected = cmd.mail or wordArg(cmd) + if self.checkPokeMailFn then + local answer = coroutine.yield({ kind = "pokemail", mail = expected }) + self.scriptVar = tonumber(answer) or POKEMAIL_REFUSED + else + self.scriptVar = POKEMAIL_REFUSED + end + elseif op == "giveitem" or op == "verbosegiveitem" then + local item = cmd.item or arg1(cmd) or 0 + local qty = cmd.quantity or (cmd.args and cmd.args[2]) or 1 + -- 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. + self.curItem = item + local ok = true + if self.giveItemFn then + ok = self.giveItemFn(item, qty) ~= false + end + self.scriptVar = ok and 1 or 0 + if op == "verbosegiveitem" then + local name = self.getItemNameFn and self.getItemNameFn(item) or "?" + self:setStringBuffer(name) + -- GiveItemScript (engine/overworld/scripting.asm:441-449), command for + -- command: `writetext .ReceivedItemText / iffalse .Full / waitsfx / + -- specialsound / waitbutton / itemnotify`. Both messages print into the + -- ONE MapTextbox the caller's `opentext` opened; it comes down at the + -- caller's `closetext` and at no point in between. + -- + -- The `waitsfx` sits ABOVE `specialsound` -- it drains whatever sfx was + -- already sounding so the item jingle starts clean -- and the port had + -- it BELOW, parking the script on the jingle's full length. The port's + -- box waits for its own button and pops itself, so that park happened + -- with NOTHING on the stack: the text box visibly tore down and rebuilt + -- around a second of silence, and Game2's play clock (which only ticks + -- while the overworld is the top state) came off pause for the gap. + -- With the wait back on the cart's side of the sound, the second box is + -- pushed inside the same frame the first one pops -- no frame ever + -- renders the bare overworld, which is the closest this port's + -- box-per-message shape gets to the cart's single MapTextbox. + self:showRaw(Strings("{PLAYER} received\n%s.", name)) + if ok then + -- GiveItemScript's `waitsfx` is NOT ported as a park, and that is the + -- fix rather than an omission. On the cart it drains whatever channel + -- the script before it left sounding, and it runs while the received + -- line is still on screen -- the box has not been touched yet, because + -- the button press is one command further down at `waitbutton`. This + -- port's box takes that press itself and pops on it, so by the time + -- the VM gets here the ONLY thing still sounding is the box's own + -- Press_AB blip, and parking on it left the bare overworld drawing for + -- the length of the blip -- exactly the seam the cart never opens. + -- The received box's typing and its press are the drain point here. + if self.specialSoundFn then + self.specialSoundFn(item) + elseif self.playSoundFn then + self.playSoundFn(1) -- SFX_ITEM + end + -- _PutItemInPocketText's second blank is wStringBuffer3, which + -- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs + -- name their own pocket, not the ITEM one (data/text/common_2.asm + -- :1351, data/items/pocket_names.asm:10-13). + self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", + name, self:pocketName(item))) + else + self:showRaw(Strings("The %s\nis full…", self:pocketName(item))) + end + end + elseif op == "itemnotify" then + -- Script_itemnotify is GetPocketName + CurItemName, both of which read + -- wCurItem (engine/overworld/scripting.asm:460). It touches no string + -- buffer, so the shared stand-in for wStringBuffer1..5 must not be read + -- here: it is stale by design and a plain `giveitem` / `itemnotify` pair + -- would print the last name any script happened to leave in it. Nor is + -- the buffer written back: CurItemName fills wStringBuffer1 while the + -- port's single buffer mostly stands for wStringBuffer2, so mirroring + -- the clobber would corrupt an unrelated {STRBUF} page. + local name = self:curItemName() + if name ~= "" then + self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", + name, self:pocketName(self.curItem))) + end + elseif op == "pocketisfull" then + -- Script_pocketisfull reads wCurItem exactly as Script_itemnotify does + -- (engine/overworld/scripting.asm:468). + self:showRaw(Strings("The %s\nis full…", self:pocketName(self.curItem))) + -- ---- bag, money and coins (engine/events/money.asm) -------------------- + elseif op == "checkitem" then + -- Script_checkitem clears wScriptVar FIRST and only CheckItem's carry + -- turns it TRUE, so a bag the world cannot answer for reads "no item" + -- rather than leaving whatever the command before it left behind. + local item = cmd.item or arg1(cmd) or 0 + local has = self.hasItemFn and self.hasItemFn(item) + self.scriptVar = has and 1 or 0 + elseif op == "takeitem" then + -- `takeitem item, quantity`; the one-argument macro form fills the + -- quantity in as 1 at assembly time, so the ROM always carries both + -- bytes. Script_takeitem puts -1 in wCurItemQuantity so TossItem removes + -- without asking, and wScriptVar is TRUE only when the pack really held + -- that many. + local args = cmd.args + local item = cmd.item or (args and args[1]) or 0 + local qty = cmd.quantity or (args and args[2]) or 1 + local took = self.takeItemFn and self.takeItemFn(item, qty) + self.scriptVar = took and 1 or 0 + elseif op == "checkmoney" then + -- Script_checkmoney -> CompareMoney -> CompareMoneyAction: the answer is + -- HAVE_MORE 0 / HAVE_AMOUNT 1 / HAVE_LESS 2, not a boolean. The broke + -- arm is `ifequal HAVE_LESS`, and an `iffalse` after a checkmoney means + -- the player has MORE than the price, which is why guessing this one + -- would have sent every shopkeeper down the wrong branch. + local account, amount = moneyArgs(cmd) + local have = self.getMoneyFn and self.getMoneyFn(account) or 0 + self.scriptVar = compareFunds(have, amount) + elseif op == "givemoney" or op == "takemoney" then + -- GiveMoney is AddMoney then a CompareMoney against MaxMoney that writes + -- MAX_MONEY back over any overflow; TakeMoney's SubtractMoney leaves the + -- account at 0 on a borrow rather than wrapping. Neither touches + -- wScriptVar, so nothing here may either. + local account, amount = moneyArgs(cmd) + if self.getMoneyFn and self.setMoneyFn then + local have = self.getMoneyFn(account) or 0 + if op == "givemoney" then + self.setMoneyFn(account, math.min(have + amount, MAX_MONEY)) + else + self.setMoneyFn(account, math.max(have - amount, 0)) + end + end + elseif op == "getmoney" then + -- `getmoney string_buffer, account` emits the ACCOUNT byte FIRST + -- (macros/scripts/events.asm swaps the two arguments), which is the order + -- Script_getmoney reads them in: GetMoneyAccount, then GetStringBuffer. + -- PrintNum is PRINTNUM_LEFTALIGN, so no padding survives into the text, + -- and this port has one shared string buffer so the buffer id is read and + -- deliberately ignored. + local account = arg1(cmd) or 0 + local have = self.getMoneyFn and self.getMoneyFn(account) or 0 + self:setStringBuffer(tostring(have)) + elseif op == "checkcoins" then + -- Script_checkcoins -> CheckCoins -> the same CompareMoneyAction ladder, + -- so the answers are the HAVE_* three again. The Goldenrod coin vendor + -- leads with `checkcoins MAX_COINS - 50` / `ifequal HAVE_MORE`, i.e. 0 is + -- the "your case is nearly full" arm. + local have = self.getCoinsFn and self.getCoinsFn() or 0 + self.scriptVar = compareFunds(have, wordArg(cmd)) + elseif op == "givecoins" or op == "takecoins" then + -- GiveCoins caps at MAX_COINS the way GiveMoney caps at MAX_MONEY, and + -- TakeCoins floors at 0 on a borrow. Neither writes wScriptVar. + local amount = wordArg(cmd) + if self.getCoinsFn and self.setCoinsFn then + local have = self.getCoinsFn() or 0 + if op == "givecoins" then + self.setCoinsFn(math.min(have + amount, MAX_COINS)) + else + self.setCoinsFn(math.max(have - amount, 0)) + end + end + elseif op == "getcoins" then + -- Script_getcoins: wCoins through PrintNum into a string buffer. + local have = self.getCoinsFn and self.getCoinsFn() or 0 + self:setStringBuffer(tostring(have)) + elseif op == "pokemart" then + -- `pokemart dialog_id, mart_id` (macros/scripts/events.asm): one + -- MARTTYPE_* byte then a WORD mart id, which the extractor leaves in + -- `args` as dialog, lo, hi. Script_pokemart farcalls OpenMartDialog, + -- which does not return until the shop is closed, so this parks the VM + -- on its resume the same way `startbattle` does. + local args = cmd.args + local martType = cmd.martType or cmd.dialog or (args and args[1]) or 0 + local martId = cmd.mart or cmd.martId + if not martId and args then + martId = (args[2] or 0) + (args[3] or 0) * 0x100 + end + coroutine.yield({ kind = "mart", martType = martType, + martId = martId or 0 }) + elseif op == "addcellnum" then + local phone = cmd.phone or arg1(cmd) or 0 + if self.addCellFn then self.addCellFn(phone) end + elseif op == "delcellnum" then + local phone = cmd.phone or arg1(cmd) or 0 + if self.delCellFn then self.delCellFn(phone) end + elseif op == "checkcellnum" then + local phone = cmd.phone or arg1(cmd) or 0 + local has = self.hasCellFn and self.hasCellFn(phone) + self.scriptVar = has and 1 or 0 + elseif op == "cry" then + if self.cryFn then self.cryFn(cmd.id) end + elseif op == "playsound" then + if self.playSoundFn then self.playSoundFn(cmd.id) end + elseif op == "playmusic" then + if self.playMusicFn then self.playMusicFn(cmd.id) end + elseif op == "playmapmusic" then + -- Script_playmapmusic: PlayMapMusic (home/audio.asm), the song off the + -- map's own header. It compares against wMapMusic first and does nothing + -- when that song is already playing, which Music.play's own dedupe + -- reproduces. Paired with `playmusic` at the end of a cutscene to hand + -- the town its theme back. + if self.playMapMusicFn then self.playMapMusicFn() end + elseif op == "musicfadeout" then + -- Script_musicfadeout: a WORD music id into wMusicFadeID, then a fade + -- byte masked with ~(1 << MUSIC_FADE_IN_F). Clearing bit 7 is what makes + -- it a fade OUT; the low bits are the frames between volume steps + -- (FadeToMapMusic uses 8, every script use in pokegold passes 16), and + -- the queued song starts once the ramp bottoms out. That is the same + -- `control` byte Music.fadeOut already takes. + local args = cmd.args or {} + local music = cmd.id or wordArg(cmd) + local fade = (cmd.fade or args[3] or 0) % 128 -- clear MUSIC_FADE_IN_F + if self.fadeOutMusicFn then self.fadeOutMusicFn(music, fade) end + elseif op == "dontrestartmapmusic" then + -- Script_dontrestartmapmusic: wDontPlayMapMusicOnReload = TRUE. It is a + -- ONE SHOT, and it does not mean "keep playing": TryRestartMapMusic + -- (home/audio.asm) sees the flag, plays MUSIC_NONE instead of the map + -- theme, zeroes wMapMusic and clears the flag again. So the next map + -- reload comes back SILENT, which is how a scripted song or a deliberate + -- silence survives the reload that follows it. + self.dontRestartMapMusic = true + if self.dontRestartMapMusicFn then self.dontRestartMapMusicFn() end + elseif op == "warpsound" then + -- Script_warpsound: GetWarpSFX (engine/overworld/tile_events.asm) then + -- PlaySFX. The choice comes off wPlayerTileCollision at play time: + -- COLL_DOOR ($71) gives SFX_ENTER_DOOR, COLL_WARP_PANEL ($7c) gives + -- SFX_WARP_TO, anything else gives SFX_EXIT_BUILDING. The tile under the + -- player decides, and the World owns that tile. + if self.warpSoundFn then self.warpSoundFn() end + elseif op == "waitsfx" then + coroutine.yield({ kind = "waitsfx" }) + elseif op == "specialsound" then + -- Script_specialsound (scripting.asm:476) is `farcall CheckItemPocket` + -- over wCurItem, so the TM/HM jingle or SFX_ITEM is picked from the item + -- the last giveitem parked there -- the opcode itself carries nothing. + if self.specialSoundFn then + self.specialSoundFn(self.curItem) + elseif self.playSoundFn then + self.playSoundFn(1) -- SFX_ITEM + end + coroutine.yield({ kind = "waitsfx" }) + elseif op == "readvar" then + local id = cmd.var or arg1(cmd) or 0 + if self.readVarFn then + self.scriptVar = self.readVarFn(id) or 0 + else + self.scriptVar = 0 + end + elseif op == "writevar" then + -- Script_writevar: GetVarAction resolves the VAR_* id to an address, then + -- [de] = wScriptVar. The exact mirror of readvar, and the extractor + -- gives both the same cmd.var field. Only a handful of the rows in + -- engine/overworld/variables.asm .VarActionTable are plain addresses + -- (VAR_BATTLETYPE, wPlayerState); the RETVAR_EXECUTE rows resolve to code + -- and writing them is meaningless, which is the World's call, not this + -- file's. + if self.writeVarFn then + self.writeVarFn(cmd.var or arg1(cmd) or 0, (self.scriptVar or 0) % 256) + end + elseif op == "loadvar" then + -- Script_loadvar: GetVarAction on the var id, then [de] = a LITERAL byte + -- (args = {var, value}). writevar takes wScriptVar, loadvar takes the + -- next script byte, and that is the only difference between them. Note + -- the extractor's readvar/writevar branch matches only those two names, + -- so loadvar falls through to the generic `args` arm and does NOT get a + -- cmd.var of its own. + -- + -- This is the command that arms the special battles: + -- `loadvar VAR_BATTLETYPE, BATTLETYPE_FORCEITEM` in front of Lugia, Ho-Oh + -- and the Red Gyarados, BATTLETYPE_FORCESHINY at the Lake of Rage, + -- BATTLETYPE_TRAP in the Rocket base, BATTLETYPE_CANLOSE for the + -- Cherrygrove rival. Skipping it silently is why every one of those + -- currently fights as a plain wild encounter you cannot lose to. + local args = cmd.args + local varId = cmd.var or (args and args[1]) or 0 + local value = (args and args[2]) or 0 + if self.writeVarFn then self.writeVarFn(varId, value % 256) end + elseif op == "readmem" then + -- Script_readmem: wScriptVar = the WRAM byte at args = {lo, hi}. Real + -- scripts use it as a counter the port has nowhere else to keep: the + -- Goldenrod underground switch room reads $d6a8 + -- (wUndergroundSwitchPositions), addvals 1 or -1 and writes it straight + -- back, and Route39Barn reads wMooMooBerries the same way. So the VM + -- carries its own sparse byte store and stays self-consistent. + -- + -- The hook is the seam for the addresses the ENGINE really owns: it + -- returns a number to answer for one, or nil to mean "not mine, use the + -- script's own store". + local addr = wordArg(cmd) + local value = self.readMemFn and self.readMemFn(addr) + if value == nil then value = self.mem[addr] end + self.scriptVar = (value or 0) % 256 + elseif op == "writemem" or op == "loadmem" then + -- Script_writemem takes its byte from wScriptVar; Script_loadmem reads + -- the address FIRST and a literal value LAST (args = {lo, hi, value}). + -- Both share readmem's sparse store: the hook returns truthy when the + -- World has claimed that address, and anything it does not claim lands in + -- the VM's own table so a read / addval / write triple still adds up. + -- + -- Nothing in the extracted cache reaches loadmem yet: the two uses in + -- pokegold are `loadmem hBGMapMode, $0` and trainer_scripts' `loadmem + -- wRunningTrainerBattleScript, -1`, both in engine code the extractor + -- never walks. Implemented anyway so it is not a silent skip the day one + -- becomes reachable. + local addr = wordArg(cmd) + local value + if op == "loadmem" then + value = ((cmd.args and cmd.args[3]) or 0) % 256 + else + value = (self.scriptVar or 0) % 256 + end + local handled = self.writeMemFn and self.writeMemFn(addr, value) + if not handled then self.mem[addr] = value end + elseif op == "jumpstd" then + -- StdScripts entry: the extractor already resolved the id to the same + -- scripts.lua key a map pointer would produce (see extractStdScripts), + -- so a std script runs through this very interpreter. jumpstd is a tail + -- call: nothing after it runs. + if cmd.script then runList(self, cmd.script) end + return "end" + elseif op == "callstd" then + if cmd.script then runList(self, cmd.script) end + elseif op == "special" then + self:runSpecial(cmd.id, cmd) + elseif op == "setval" then + -- setval loads wScriptVar, which the ifequal family then tests. + self.scriptVar = cmd.value or arg1(cmd) or 0 + elseif op == "addval" then + -- Script_addval: `GetScriptByte / ld hl, wScriptVar / add [hl] / + -- ld [hl], a`. The operand is ADDED to wScriptVar and the result wraps + -- at 8 bits, so `addval -1` (args = {255}) is how a script counts DOWN: + -- the Goldenrod underground switch room does readmem / addval 1 / + -- writemem to flick a switch on and readmem / addval -1 / writemem to + -- flick it back off. + self.scriptVar = ((self.scriptVar or 0) + (arg1(cmd) or 0)) % 256 + elseif op == "random" then + -- Script_random: a uniform roll in 0 .. n-1, where n is the operand. The + -- cart gets there the long way (.Divide256byC finds 256 % n, + -- rejection-samples Random() down to a multiple of n, then SimpleDivide + -- takes the remainder) purely so the modulo is unbiased; math.random over + -- the same span is the same distribution. `random 0` returns early on + -- `and a / ret z` with wScriptVar still holding the 0 it just stored, so + -- a zero operand is a zero result and not an error. + local n = arg1(cmd) or 0 + self.scriptVar = (n == 0) and 0 or math.random(0, n - 1) + elseif op == "checkver" then + -- Script_checkver: wScriptVar = GS_VERSION, a byte assembled into the + -- command itself (constants/misc_constants.asm: 0 Gold, 1 Silver). It is + -- a plain value, not a flag, so the `iftrue` that follows is the SILVER + -- arm and Gold falls through. WhirlIslandLugiaChamber uses exactly that + -- to give Gold a level 70 Lugia and Silver a level 40. Defaults to Gold + -- so a VM built without the hook plays the Gold branch. + local version = GS_VERSION_GOLD + if self.gsVersionFn then version = self.gsVersionFn() or version end + self.scriptVar = version + elseif op == "checktime" then + -- Script_checktime: `xor a / ld [wScriptVar], a`, CheckTime hands back + -- the bit for the current wTimeOfDay in c, the script byte is ANDed with + -- it, and wScriptVar is TRUE only when the AND is non-zero. The bits are + -- shift_consts (constants/ram_constants.asm): MORN 1, DAY 2, NITE 4, + -- DARKNESS 8, and ANYTIME is MORN|DAY|NITE = 7. + -- + -- CheckTime.TimeOfDayTable lists MORN_F, DAY_F, NITE_F and then NITE_F + -- again: DARKNESS_F (3) is not in it at all, so IsInArray fails, c comes + -- back 0, and `checktime` is FALSE for every mask inside a pitch-black + -- cave. Transcribed rather than smoothed over, because that is the cart. + local mask = arg1(cmd) or 0 + local time = self.getTimeOfDayFn and self.getTimeOfDayFn() or 0 + local bit = 0 + if time == 0 then bit = 1 -- MORN_F + elseif time == 1 then bit = 2 -- DAY_F + elseif time == 2 then bit = 4 -- NITE_F + end -- DARKNESS_F falls through at 0 + -- Lua 5.1 has no band; the same shift-and-test shape Events:get uses. + local hit = bit ~= 0 and math.floor(mask / bit) % 2 == 1 + self.scriptVar = hit and 1 or 0 + -- ---- trainer battles (engine/events/trainer_scripts.asm) ---------------- + elseif op == "loadtrainer" then + -- `loadtrainer class, member` overrides whatever the object carried, so + -- a rematch script can pick JOEY2 off the same object as JOEY1. + self.trainer = self:lookupTrainer(cmd.class or arg1(cmd), + cmd.member or (cmd.args and cmd.args[2])) + elseif op == "loadtemptrainer" then + -- wTempTrainer is the copy LoadTrainer_continue takes from the struct + -- the object points at; here that is simply the object's own record. + self.trainer = self:lookupTrainer( + self.trainerObject and self.trainerObject.class, + self.trainerObject and self.trainerObject.member) + elseif op == "startbattle" then + -- Resumes with "win" / "lose"; wRunningTrainerBattleScript is set for + -- the endifjustbattled / checkjustbattled pair that follows. + -- + -- Script_startbattle ends `ld a, [wBattleResult] / and + -- ~BATTLERESULT_BITMASK / ld [wScriptVar], a`, and that byte counts up + -- from a WIN: WIN 0, LOSE 1, DRAW 2 (constants/battle_constants.asm). + -- So a win is the FALSE arm, which reads backwards until you look at the + -- scripts: BurnedTower1F's `startbattle / iftrue .next / disappear + -- FIREBREATHER_DICK` only makes the beaten trainer vanish because + -- winning does not take the iftrue, and TrainerHouseB1F's + -- `reloadmapafterbattle / iffalse .End` only stops the second battle + -- because it does. 21 extracted scripts branch straight off this byte. + local outcome = coroutine.yield({ kind = "battle", trainer = self.trainer, + wild = self.wildMon }) + self.wildMon = nil + self.trainer = nil + self.justBattled = true + self.battleOutcome = outcome + self.scriptVar = BATTLE_RESULTS[outcome] or BATTLE_RESULTS.win + elseif op == "loadwildmon" then + -- Script_loadwildmon rewrites wBattleScriptFlags to the WILD shape + -- ((1 << 7), no trainer bit), so the latest load command decides what + -- `startbattle` fights. This VM lives as long as the World, so a + -- trainer left over from an earlier script (a sight trainer fought on + -- the way to the lake) must not shadow the wild mon -- that stale + -- record turned the Red Gyarados A-press into a rematch with the last + -- trainer beaten. + self.trainer = nil + self.wildMon = { species = cmd.species or arg1(cmd), + level = cmd.level or (cmd.args and cmd.args[2]) } + elseif op == "randomwildmon" then + -- Script_randomwildmon: `xor a / ld [wBattleScriptFlags], a`. Clearing + -- the flags IS the command: with neither the wild bit Script_loadwildmon + -- sets ((1 << 7)) nor the trainer bit Script_loadtemptrainer sets + -- ((1 << 7) | 1), the `startbattle` that follows rolls the map's own + -- encounter table. Sweet Scent, the Bug Contest and the rock-smash path + -- all reach a battle this way. + -- + -- The roll happens here rather than inside `startbattle` because this + -- port carries the chosen mon in self.wildMon; nothing between the two + -- commands can move the player or change the map, so the outcome is the + -- same and the existing startbattle branch does not have to change. + self.trainer = nil + self.wildMon = nil + if self.rollWildFn then self.wildMon = self.rollWildFn() end + elseif op == "loadpikachudata" then + -- Script_loadpikachudata: wTempWildMonSpecies = PIKACHU, + -- wCurPartyLevel = 5. It writes the pair `loadwildmon` would but leaves + -- wBattleScriptFlags alone, so on the cart it only turns into a battle + -- when something else has already asked for a wild one. A Yellow + -- leftover with 0 uses in pokegold; here so it stops falling through. + -- 25 is PIKACHU in constants/pokemon_constants.asm, the same index + -- constants.speciesOrder and World's speciesByIndex use. + self.wildMon = { species = 25, level = 5 } + elseif op == "wildon" or op == "wildoff" then + -- Script_wildon / Script_wildoff: res / set + -- STATUSFLAGS_NO_WILD_ENCOUNTERS_F, [wStatusFlags] (bit 5, + -- constants/ram_constants.asm), the gate that keeps grass quiet during an + -- escorted walk. Unreferenced by pokegold's own bytecode (the ASM sets + -- and clears the flag directly around the Bug Contest and the roaming-mon + -- scenes), but the flag is real and World:tryWildEncounter needs the same + -- switch either way. + self.wildEncounters = (op == "wildon") + if self.setWildEncountersFn then + self.setWildEncountersFn(self.wildEncounters) + end + elseif op == "swarm" then + -- Script_swarm: two bytes (a `map_id`: group, then map) handed to + -- StoreSwarmMapIndices (engine/events/specials.asm), which writes + -- wSwarmMapGroup / wSwarmMapNumber and then FALLS THROUGH into + -- 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. + 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 + elseif op == "reloadmapafterbattle" or op == "reloadmap" + or op == "refreshmap" then + -- Losing ENDS the script. Script_reloadmapafterbattle reads wBattleResult + -- and, on LOSE, does `ScriptJump Script_BattleWhiteout` -- it never comes + -- back to the command after it. + -- + -- The port used to fall straight through, and every trainer script in the + -- game is written `startbattle / reloadmapafterbattle / setevent + -- EVENT_BEAT_<whoever>`, so a LOSS ran the win branch: Whitney handed out + -- EVENT_BEAT_WHITNEY to a party that had just been wiped, the Elite Four + -- could be cleared one room at a time by fainting in each, and the route + -- bot reached the Hall of Fame with two Pokemon and four badges. It also + -- quietly desynced the flags from the badges, since the badge itself is + -- given further down the same script after a scene the loser never runs. + -- + -- World's own loss handling has already done the whiteout half (heal, + -- halve the money, warp to the spawn point), which is what + -- Script_BattleWhiteout does; all that was missing is that the script + -- stops here. + -- + -- The hook's argument is "this reload runs a map SETUP script". + -- MapSetupScript_ReloadMap ends on `mapsetup ForceMapMusic` + -- (data/maps/setup_scripts.asm:136), so `reloadmap` / + -- `reloadmapafterbattle` are the ops that consume + -- wDontPlayMapMusicOnReload; Script_refreshmap runs no setup script at + -- all (engine/overworld/scripting.asm:2044), just + -- LoadOverworldTilemapAndAttrmapPals / ApplyTilemap / UpdateSprites. + if op == "reloadmapafterbattle" and self.battleOutcome == "lose" then + self.aborted = true + self.battleOutcome = nil + if self.reloadMapFn then self.reloadMapFn(true) end + return "end" + end + if self.reloadMapFn then self.reloadMapFn(op ~= "refreshmap") end + elseif op == "catchtutorial" then + -- `catchtutorial battle_type` runs the DUDE's catch demo + -- (engine/events/catch_tutorial.asm): the player's name is parked in + -- wMomsName and swapped for DUDE, the DUDE's own pack is loaded, an + -- auto-input stream is armed, and only then is StartBattle farcall'd. + -- The wild mon is the one the `loadwildmon RATTATA, 5` in front of the + -- command left in wTempWildMonSpecies, so it rides along here the same + -- way `startbattle` takes it. + -- + -- The stream armed around StartBattle is `NO_INPUT, $ff`: it exists + -- purely to take the controller away for the length of the demo, and the + -- DUDE's actual presses come from the re-arms in PromptButton, the + -- battle menu and TutorialPack (src/core/gen2/CatchTutorial.lua). + -- + -- The order below is the ASM's exactly: StartAutoInput, the battle, + -- StopAutoInput, and then the `jp Script_reloadmap` the command ends on. + -- It is NOT a terminator: the script really does continue after the + -- reload, and it leaves wScriptVar alone. + local wild = self.wildMon + self.wildMon = nil + if self.autoInputStreamFn then + self.autoInputStreamFn("CATCH_TUTORIAL") + end + if self.catchTutorialFn then + coroutine.yield({ kind = "catchtutorial", + battleType = cmd.battleType or arg1(cmd), wild = wild }) + end + if self.stopAutoInputFn then self.stopAutoInputFn() end + -- `jp Script_reloadmap`, so the setup script (and its ForceMapMusic row) + -- really does run here. + if self.reloadMapFn then self.reloadMapFn(true) end + elseif op == "winlosstext" then + -- Overrides the struct's win/loss text for this battle only. + self.winTextOverride = cmd.winText + self.lossTextOverride = cmd.lossText + elseif op == "trainertext" then + local which = cmd.index or arg1(cmd) or 0 + local obj = self.trainerObject or {} + local key + if which == 1 then + key = self.winTextOverride or obj.winText + elseif which == 2 then + key = self.lossTextOverride or obj.lossText + else + key = obj.seenText + end + self:showText(key) + elseif op == "trainerflagaction" then + -- EventFlagAction over the struct's beat flag; CHECK writes wScriptVar. + local action = cmd.action or arg1(cmd) or 0 + local flag = self.trainerObject and self.trainerObject.event + if not flag then + self.scriptVar = 0 + elseif action == 2 then -- CHECK_FLAG + self.scriptVar = self.events:get(flag) and 1 or 0 + else + self.events:set(flag, action == 1) -- SET_FLAG / RESET_FLAG + if self.onFlagsChanged then self.onFlagsChanged() end + end + elseif op == "scripttalkafter" then + -- Tail call into the struct's after-battle script. + local after = self.trainerObject and self.trainerObject.scriptKey + if after then runList(self, after) end + return "end" + elseif op == "endifjustbattled" then + if self.justBattled then return "end" end + elseif op == "checkjustbattled" then + self.scriptVar = self.justBattled and 1 or 0 + elseif op == "setlasttalked" then + self.lastTalked = cmd.object or arg1(cmd) + elseif op == "encountermusic" then + if self.encounterMusicFn then + self.encounterMusicFn(self.trainerObject and self.trainerObject.class) + end + elseif op == "showemote" then + -- `showemote emote, object, length` -- the ! bubble over a trainer. + local emote = cmd.emote or arg1(cmd) or 0 + local object = cmd.object or (cmd.args and cmd.args[2]) or 0 + local frames = cmd.frames or (cmd.args and cmd.args[3]) or 0 + if self.showEmoteFn then + self.showEmoteFn(emote, object, frames) + end + -- ShowEmoteScript holds on `pause 0`, which is Script_pause reading back + -- the wScriptDelay Script_showemote wrote (scripting.asm:981, 986-991), + -- so the bubble stays up for two frames per operand byte. + self:pauseFrames(frames) + elseif op == "trainerapproach" then + -- SeenByTrainerScript's callasm TrainerWalkToPlayer + the applymovement + -- that follows it, as one step: the World owns the path. + if self.trainerApproachFn then + coroutine.yield({ kind = "approach" }) + end + elseif op == "faceobject" or op == "writeobjectxy" then + -- faceobject PLAYER, LAST_TALKED squares the player up to the trainer. + if op == "faceobject" and self.faceObjectFn then + self.faceObjectFn(cmd.a or (cmd.args and cmd.args[1]), + cmd.b or (cmd.args and cmd.args[2])) + end + elseif op == "follow" or op == "follownotexact" then + -- `follow leader, follower` (macros/scripts/events.asm emits the LEADER + -- first, and Script_follow hands that byte to SetLeaderIfVisible). The + -- follower's movement type becomes SPRITEMOVEDATA_FOLLOWING: it walks + -- into whatever cell the leader has just left, one step behind, for as + -- long as the pairing lasts. + -- + -- Ignoring this used to be harmless-looking and was not: the New Bark + -- Town teacher's `follow NEWBARKTOWN_TEACHER, PLAYER` is what drags the + -- player back off the coord event's tile. Without it she walked home + -- alone, the player was still standing on (1,8), and the scene fired + -- again the moment it ended -- so she was back at her spawn starting the + -- same speech over, forever. + if self.followFn then + self.followFn(cmd.a or (cmd.args and cmd.args[1]), + cmd.b or (cmd.args and cmd.args[2])) + end + elseif op == "stopfollow" then + if self.stopFollowFn then self.stopFollowFn() end + -- ---- map blocks (home/map.asm GetBlockLocation) ------------------------- + elseif op == "changeblock" then + -- Script_changeblock: `add 4` on both bytes, then GetBlockLocation, whose + -- `srl` halves each of them again, so the script's x and y are CELL + -- coordinates and the block it rewrites is (x / 2, y / 2). Checked + -- against MahoganyMart1F, whose `changeblock 6, 2, $1e` is block (3, 1) + -- and whose TEAM_ROCKET_BASE_B1F warp_event sits on cell (7, 3), inside + -- exactly that block; and against BrunosRoom, whose `changeblock 4, 2, + -- $16 ; open door` is block (2, 1) with its warp_events on cells (4, 2) + -- and (5, 2). + -- + -- This is Bruno's door slamming shut, the Ruins of Alph floor giving way, + -- the Mahogany staircase and the Goldenrod underground doors. The hook + -- must drop whatever the renderer has baked for this map. + local args = cmd.args or {} + local x = cmd.x or args[1] or 0 + local y = cmd.y or args[2] or 0 + local block = cmd.block or args[3] or 0 + if self.changeBlockFn then + self.changeBlockFn(math.floor(x / 2), math.floor(y / 2), block) + end + elseif op == "changemapblocks" then + -- Script_changemapblocks: a `dba` (bank, then pointer) into + -- wMapBlocksBank / wMapBlocksPointer, then ChangeMap + BufferScreen. It + -- repaints the WHOLE map from a second copy of its blockdata rather than + -- poking one block the way changeblock does. + -- + -- The three bytes are read in the order GetScriptByte reads them: bank + -- first (`dba` is `dbw bank, address`), then the pointer low byte and + -- high byte. It stays a RAW ROM pointer here -- the importer only walks + -- script pointers, so nothing under data/generated/ is keyed by one -- + -- and World:changeMapBlocks is what places it, against the blockdata + -- bank/address every map's attributes already carry. A pointer no map + -- covers is a no-op there rather than a guess. + -- + -- wScriptVar is untouched, as in the asm. + local args = cmd.args or {} + local bank = cmd.bank or args[1] + local pointer = cmd.address + or ((args[2] or 0) + (args[3] or 0) * 0x100) + if self.changeMapBlocksFn then + self.changeMapBlocksFn(bank, pointer) + end + elseif op == "earthquake" then + -- Script_earthquake copies EarthquakeMovement (step_shake 16 / + -- step_sleep 16 / step_end) into wEarthquakeMovementDataBuffer, + -- overwrites buffer+1 (the step_shake parameter) with the script byte, + -- overwrites buffer+3 (the step_sleep parameter) with `and %00111111` of + -- the same byte, then ScriptCalls `applymovement PLAYER, buffer`. + -- + -- So ONE byte carries two numbers. The full byte is the displacement + -- ShakeScreen hands the SPRITEMOVEDATA_SCREENSHAKE object; byte & $3f is + -- how many frames the movement then sleeps for, and the sleep is what + -- holds the script. `earthquake 80` is a displacement of 80 held for 16 + -- frames, not 80 frames of anything. StepFunction_Sleep decrements + -- OBJECT_STEP_DURATION once per frame, so those are the 60 Hz frames + -- waitFrames already counts. + local param = cmd.param or arg1(cmd) or 0 + local frames = param % 64 + if self.earthquakeFn then self.earthquakeFn(param, frames) end + self:waitFrames(frames) + -- ---- warps (home/map.asm) ---------------------------------------------- + elseif op == "warp" or op == "warpfacing" then + -- Script_warpfacing FALLS THROUGH into Script_warp: it is `warp` with a + -- facing bolted on the front, not a separate jumptable case, so BOTH + -- halves run. Its byte is `maskbits NUM_DIRECTIONS` (& 3, the + -- DOWN/UP/LEFT/RIGHT order Movement.dir already speaks) and goes into + -- wPlayerSpriteSetupFlags with PLAYERSPRITESETUP_CUSTOM_FACING, so the + -- player lands facing it rather than facing wherever arrival would have + -- turned them. + -- + -- Then a `map_id` (group, map) and x and y as plain map cells. Distinct + -- from the warp_events World:takeWarp already handles: those name a + -- destination WARP and take their facing from where that warp sits on the + -- destination map, this one names a raw cell. + -- + -- It does NOT end the script. Script_warp's StopScript only clears + -- SCRIPT_RUNNING for the frame, exactly as Script_reloadmap's does, and + -- the script resumes once the new map is up: std_scripts.asm's + -- BugContestResultsWarpScript is `warp ROUTE_36_NATIONAL_PARK_GATE, 0, 4` + -- followed by an `applymovement PLAYER` that walks the player in. Every + -- other use in pokegold is followed by `end` anyway. + -- + -- Group 0 is the routine's own error arm: it eats the remaining three + -- bytes and enters through MAPSETUP_BADWARP, which is EnterMapSpawnPoint + -- on the map you are already standing on rather than a trip anywhere. + local args = cmd.args or {} + local base = (op == "warpfacing") and 1 or 0 + local facing + if op == "warpfacing" then + facing = Movement.dir(cmd.facing or args[1] or 0) + end + local group = cmd.group or args[base + 1] or 0 + local mapNum = cmd.map or args[base + 2] + local x = cmd.x or args[base + 3] + local y = cmd.y or args[base + 4] + if group == 0 then + -- MAPSETUP_BADWARP, which is a full load of the map already underfoot: + -- HandleNewMap and LoadMapObjects are in its setup script and are NOT + -- in MapSetupScript_ReloadMap, so this is a different hook from the + -- `reloadmap` one above. PlayersHousePCScript is the caller that + -- cares -- the bedroom's decorations are rebuilt by those callbacks. + local reload = self.badWarpFn or self.reloadMapFn + if reload then reload() end + elseif self.warpToFn then + self.warpToFn(group, mapNum, x, y, facing) + end + elseif op == "warpcheck" then + -- Script_warpcheck: WarpCheck (home/map.asm) -> GetDestinationWarpNumber + -- + CopyWarpData, and on a hit `farcall EnableEvents`. It does NOT warp + -- by itself: it notices that the player is standing on a warp tile and + -- lets the overworld loop take it once the script is done, which is why + -- every use sits at the END of a scripted walk. + -- + -- That is what drops the player through the hole a `changeblock` has just + -- opened under them in RuinsOfAlphOmanyteChamber, and what puts them into + -- the Pokecenter 2F link rooms after the receptionist has walked them up. + -- The hook arms it; it must not warp mid-script. + if self.warpCheckFn then self.warpCheckFn() end + elseif op == "warpmod" then + -- Script_warpmod: a warp id, then a `map_id`, into wBackupWarpNumber, + -- wBackupMapGroup, wBackupMapNumber. That triple is where the game + -- believes you came IN from: Elevator's .FindCurrentFloor + -- (engine/events/elevator.asm) reads the backup map to work out which + -- floor you are standing on, and the escape-rope / dig return reads it to + -- put you back outside. + -- + -- Unreferenced by every script in pokegold; the rows the importer reports + -- live in mis-walked regions of bank $45. Ported anyway so the state + -- exists the moment anything writes it. + local args = cmd.args or {} + local warpId = cmd.warp or args[1] + local group = cmd.group or args[2] + local mapNum = cmd.map or args[3] + if self.setWarpModFn then self.setWarpModFn(warpId, group, mapNum) end + elseif op == "blackoutmod" then + -- Script_blackoutmod: a `map_id` into wLastSpawnMapGroup / + -- wLastSpawnMapNumber, which is where a WHITEOUT puts the player rather + -- than the last Pokecenter (engine/events/whiteout.asm reads the same + -- pair; home/map.asm writes it on a normal Pokecenter entry). + -- + -- The S.S. Aqua and Mr. Pokemon's house set it so that losing at sea or + -- out past Cherrygrove does not respawn you somewhere you cannot leave. + -- Distinct from the SPAWN_* id World:warpToSpawn uses today: this names a + -- group/map pair directly, so the hook has to override that lookup. + local args = cmd.args or {} + local group = cmd.group or args[1] + local mapNum = cmd.map or args[2] + if self.setBlackoutMapFn then self.setBlackoutMapFn(group, mapNum) end + elseif op == "newloadmap" then + -- Script_newloadmap: hMapEntryMethod = the byte, then LoadMapStatus + -- MAPSTATUS_ENTER and StopScript. It RE-ENTERS THE CURRENT MAP through + -- one of the MapSetupScripts (constants/map_setup_constants.asm, + -- const_def $f1: $f1 WARP, $f3 RELOADMAP, $f4 TELEPORT, $f5 DOOR, + -- $f6 FALL, $f8 LINKRETURN, $f9 TRAIN...), which is how the magnet train + -- and a link return come back onto their own map with the right fade and + -- sound. Like `warp` it does not end the script; every real use is + -- followed by `end` regardless. + local method = cmd.method or arg1(cmd) or 0 + if self.newLoadMapFn then self.newLoadMapFn(method) end + -- ---- windows and menus (home/menu.asm) --------------------------------- + elseif op == "loadmenu" then + -- `loadmenu menu_header` -> LoadMenuHeader. The extractor follows the + -- pointer now, so cmd.menu is the whole MenuHeader -- flags, the four + -- border coords, the data flags and the item strings behind them. A + -- cache built before that leaves only the raw word, and the menu hook + -- answers 0 for a header it cannot draw. Stashed rather than acted on: + -- LoadMenuHeader only copies it to wMenuHeader, and the verticalmenu / + -- _2dmenu that follows is what opens it. + self.menuHeader = cmd.menu or { address = wordArg(cmd) } + elseif op == "verticalmenu" or op == "_2dmenu" then + -- Script_verticalmenu answers with wMenuCursorY, Script__2dmenu with + -- wMenuCursorPosition, and both `xor a` on the carry the menu returns for + -- B. Those cursors are 1-BASED, so the ifequal ladder after the command + -- starts at 1 and 0 is the cancel arm: the Goldenrod coin vendor is + -- loadmenu / verticalmenu / closewindow / ifequal 1 / ifequal 2 / sjump, + -- and the Day-Care grid is loadmenu / _2dmenu / closewindow / + -- ifequal 1..5. Blocks the way yesorno does: yield the request, resume + -- with the chosen index. + local choice = coroutine.yield({ kind = "menu", + style = (op == "_2dmenu") and "2d" or "vertical", + header = self.menuHeader }) + self.scriptVar = tonumber(choice) or 0 + elseif op == "closewindow" then + -- Script_closewindow: CloseWindow + UpdateSprites, the teardown for the + -- window loadmenu / verticalmenu / _2dmenu put up. The port's menu hook + -- owns its own screen lifetime, so there is nothing left to tear down; + -- kept as its own branch so it stops falling through the unknown-op path + -- and so the teardown has an obvious home when a real window lands. + -- ---- field events ------------------------------------------------------ + elseif op == "fruittree" then + -- `fruittree tree_id` sets wCurFruitTree and JUMPS to FruitTreeScript, so + -- nothing after it in the caller runs. Opcodes.TERMINATORS does not list + -- it, so the extractor kept disassembling the bytes that followed: every + -- one of the 42 extracted fruittree scripts is this one command plus + -- garbage, which is exactly why this branch has to return. + -- + -- FruitTreeScript itself is transcribed here rather than looked up. It + -- is an engine script: nothing in the ROM's bytecode points at it, so + -- neither it nor its text reaches data/generated, the same reason + -- GiveItemScript is inlined in the verbosegiveitem branch above. Text + -- bodies from data/text/common_1.asm. + local tree = cmd.tree or arg1(cmd) or 0 + -- callasm GetCurTreeFruit: FruitTreeItems[tree - 1], FRUITTREE_* being + -- 1-based (constants/script_constants.asm `const FRUITTREE_ROUTE_29 ; 01` + -- and GetCurTreeFruit's own `dec a`). The hook undoes the offset. + local item = self.fruitTreeItemFn and self.fruitTreeItemFn(tree) or 0 + local name = (item ~= 0 and self.getItemNameFn + and self.getItemNameFn(item)) or "BERRY" + -- readmem wCurFruit / getitemname STRING_BUFFER_3, USE_SCRIPT_VAR + self.scriptVar = item + self:setStringBuffer(name) + self:showRaw(Strings("It's a fruit-\nbearing tree.")) + -- callasm TryResetFruitTrees / callasm CheckFruitTree / iffalse .fruit. + -- The reset runs BEFORE the check and gated on ENGINE_ALL_FRUIT_TREES, + -- so the first tree examined after the daily rollover refills the other + -- twenty-nine as well as its own. + if self.fruitTreeResetFn then self.fruitTreeResetFn() end + -- CheckFruitTree is a CHECK_FLAG over wFruitTreeFlags, and the per-tree + -- flag means "already picked" (ResetFruitTrees clears the lot once a + -- day), so a CLEAR flag is the arm with fruit on it. + local picked = self.fruitTreePickedFn and self.fruitTreePickedFn(tree) + if picked then + self:showRaw(Strings("There's nothing\nhere…")) + return "end" + end + self:showRaw(Strings("Hey! It's\n%s!", name)) + -- readmem wCurFruit / giveitem ITEM_FROM_MEM / iffalse .packisfull + local ok = true + if self.giveItemFn then ok = self.giveItemFn(item, 1) ~= false end + self.scriptVar = ok and 1 or 0 + if not ok then + self:showRaw(Strings("But the PACK is\nfull…")) + return "end" + end + self:showRaw(Strings("Obtained\n%s!", name)) + -- callasm PickedFruitTree: the flag is set AFTER the fruit is banked, so + -- a full pack leaves the tree pickable. + if self.fruitTreePickFn then self.fruitTreePickFn(tree) end + if self.specialSoundFn then + self.specialSoundFn(item) + elseif self.playSoundFn then + self.playSoundFn(SFX_ITEM) + end + -- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING + -- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound + -- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483). + -- The port used to park here on a `waitsfx`, which is the same seam + -- GiveItemScript's did: this port's box takes its own button and pops on + -- it, so the park ran with an EMPTY state stack and the bare overworld + -- drew for the length of the jingle (163 frames measured) between the two + -- pages of what the cart prints into ONE MapTextbox -- with Game2's play + -- clock, which only pauses while a state is on the stack, running for + -- every one of them. The obtained box's own press is the drain point. + -- itemnotify. Berries are all ITEM pocket, so nothing here moves; the + -- noun still comes from ItemPocketNames rather than from a third copy of + -- the literal (data/items/pocket_names.asm:10-13). + self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.", + name, self:pocketName(item))) + return "end" + elseif op == "describedecoration" then + -- `describedecoration byte` picks one of five DECODESC_* arms + -- (engine/overworld/decorations.asm) and JUMPS to the script each hands + -- back, so like fruittree nothing after it runs and the bytes the + -- extractor read past it are garbage. + -- + -- Each arm is asm that chooses by what is INSTALLED in the player's + -- room, and the extractor emits the scripts rather than the arms: the + -- poster table plus the `end` it falls to when the wall is bare, the one + -- script the two ornaments and the console share, and the giant + -- ornament's. Which one runs is decided by the wDeco* slot the arm + -- reads, and `decorationSlot` is that read (src/core/gen2/Decorations.lua + -- owns the slots themselves). + -- + -- DecorationDesc_Poster IsInArray over DecorationDesc_ + -- PosterPointers on wDecoPoster, falling + -- to the bare `end` when the wall is bare + -- ..._OrnamentOrConsole one script for all three, with the + -- decoration's NAME in wStringBuffer3 -- + -- "It's an adorable <name>!" + -- ..._GiantOrnament one script, no name + local kind = cmd.decoration or arg1(cmd) or 0 + local descName = cmd.decorationName or "" + if self.describeDecorationFn then self.describeDecorationFn(kind) end + local arm = (self.eventTables.decorations or {})[descName] + local placed, placedName + if self.decorationSlotFn then + placed, placedName = self.decorationSlotFn(descName) + end + if descName == "DECODESC_POSTER" and arm and arm.posters then + for _, row in ipairs(arm.posters) do + if row.decoration == placed then + arm = row + break + end + end + elseif placedName then + self:setStringBuffer(placedName) + end + if arm and arm.script and self.scripts[arm.script] then + runList(self, arm.script) + return "end" + end + return "end" + elseif op == "trade" then + -- `trade trade_id` -> NPCTrade (engine/events/npc_trade.asm): a whole + -- blocking conversation (intro text, YesNoBox, a party pick, the gender + -- and species checks, the trade animation) driven off + -- data/events/npc_trades.asm, which is not extracted. NPCTrade writes no + -- wScriptVar, so with no hook the script simply carries on the way it + -- does when the player backs out. + if self.npcTradeFn then + coroutine.yield({ kind = "trade", trade = cmd.trade or arg1(cmd) or 0 }) + end + elseif op == "elevator" then + -- Script_elevator: wScriptVar = 0 up front, farcall Elevator, and only a + -- NON-carry return raises it to TRUE. Elevator (engine/events/elevator.asm) + -- carries on three paths (the current floor is not in the list, the + -- player pressed B, or the player picked the floor they are already on) + -- and it performs the ride itself, so wScriptVar means only "did we + -- actually move". GoldenrodDeptStoreElevatorScript's `iffalse .Done` + -- right after is the branch that skips the SFX, the earthquake and the + -- B1F crate reshuffle. + -- + -- The extractor follows the operand into the map's own floor list now + -- (db count, then `elevfloor floor, warp, map` rows), so the menu has + -- something to offer. With no list and no hook the answer stays 0, the + -- player-backed-out case: answering 1 would play out a ride that never + -- happened. + self.scriptVar = 0 + if cmd.floors and #cmd.floors > 0 and self.elevatorFn then + -- Elevator_GoToFloor writes wBackupWarpNumber / wBackupMapGroup / + -- wBackupMapNumber and RIDES; the hook owns both halves, and answers + -- the floor row it went to, or nil for a cancel and for "you picked + -- the floor you are already on" (`cp [hl] / jr z, .quit`). + local rode = coroutine.yield({ kind = "elevator", floors = cmd.floors }) + self.scriptVar = rode and 1 or 0 + end + -- ---- phone (engine/phone/phone.asm) ------------------------------------ + elseif op == "askforphonenumber" then + -- Script_askforphonenumber: YesNoBox FIRST, then AddPhoneNumber. The + -- answer is PHONE_CONTACT_GOT 0 / PHONE_CONTACTS_FULL 1 / + -- PHONE_CONTACT_REFUSED 2, so SUCCESS IS ZERO here: an `iftrue` after + -- this command means the number did NOT go in. + local contact = cmd.phone or arg1(cmd) or 0 + local yes = coroutine.yield({ kind = "yesorno" }) + if not yes then + self.scriptVar = PHONE_CONTACT_REFUSED + else + -- AddPhoneNumber returns carry, and so PHONE_CONTACTS_FULL, both when + -- the list is full and when the number is already in it: _CheckCellNum + -- runs before Phone_FindOpenSlot and answers with the same carry. + local added = self.addPhoneNumberFn and self.addPhoneNumberFn(contact) + self.scriptVar = added and PHONE_CONTACT_GOT or PHONE_CONTACTS_FULL + end + elseif op == "phonecall" then + -- `phonecall caller_name` -> PhoneCall: two rings, then the caller's name + -- in the telephone box. docs/bugs_and_glitches.md calls this command out + -- as one that may crash on retail (it reaches BrokenPlaceFarString, which + -- is not in bank 0), and the only occurrence in the cache sits in a run of + -- garbage past a fruittree, so nothing real depends on it. Wired to the + -- phone hook anyway, because the ring is what a script asking for it + -- wants. + if self.phoneCallFn then + coroutine.yield({ kind = "phonecall", + caller = cmd.caller or wordArg(cmd) }) + end + elseif op == "hangup" then + -- HangUp: PhoneClickText with SFX_HANG_UP under it, then the four <……> + -- boops that close the call box (data/text/common_3.asm). The sound is + -- started BEFORE the line here because "Click!" ends in `done`: PrintText + -- returns without waiting on the cart, while this port's text box holds + -- until A, so playing it after would put the beep on an empty screen. + if self.playSoundFn then self.playSoundFn(SFX_HANG_UP) end + self:showRaw(Strings("Click!")) + if self.hangUpFn then self.hangUpFn() end + elseif op == "specialphonecall" then + -- `specialphonecall call_id` only STORES the id; the call itself fires + -- later, from CheckSpecialPhoneCall on an overworld step. No wScriptVar. + -- Script_specialphonecall writes two bytes but wSpecialPhoneCallID is a + -- single `db` in wram, so the high byte lands in padding and only the low + -- byte is ever read back. + local id = cmd.call or wordArg(cmd) + self.specialCall = id + if self.setSpecialCallFn then self.setSpecialCallFn(id) end + elseif op == "checkphonecall" then + -- Script_checkphonecall reads only the LOW byte of wSpecialPhoneCallID + -- (`ld a, [wSpecialPhoneCallID] / and a / jr z`), which is the whole byte + -- the queue actually uses. Transcribed as the low-byte test rather than + -- tidied into a whole-word one. + local id = self.getSpecialCallFn and self.getSpecialCallFn() + or self.specialCall or 0 + self.scriptVar = ((id % 0x100) ~= 0) and 1 or 0 + -- ---- end of game ------------------------------------------------------- + elseif op == "halloffame" then + -- Script_halloffame: the game timer stops, HallOfFame runs, and then + -- ReturnFromCredits does Script_endall + MAPSTATUS_DONE. The script + -- stack is cleared and the overworld is torn down, so this returns + -- whether or not a hook took the screen. + if self.hallOfFameFn then + coroutine.yield({ kind = "halloffame" }) + end + return "end" + elseif op == "credits" then + -- Script_credits: RedCredits, then the same ReturnFromCredits teardown + -- halloffame ends on. + if self.creditsFn then + coroutine.yield({ kind = "credits" }) + end + return "end" + -- ---- commands with no engine behind them yet --------------------------- + elseif op == "deactivatefacing" then + -- Script_deactivatefacing: wScriptDelay = the byte (left ALONE when the + -- byte is 0, the same `and a / jr z` idiom Script_pause uses), then + -- wScriptMode = SCRIPT_WAIT and StopScript. WaitScript ticks that delay + -- down one per frame and calls UnfreezeAllObjects before reading again, + -- so what the command DOES is hold the script for N frames with the map's + -- objects released. This port never freezes them in the first place + -- (World:step keeps updatePeople running while the VM is busy), so the + -- wait is the whole of it and it goes through the same waitFrames `pause` + -- uses. + self:waitFrames(cmd.frames or arg1(cmd) or 0) + elseif op == "writeunusedbyte" then + -- Script_writeunusedbyte stores its operand in wUnusedScriptByte, and + -- nothing in the ROM ever reads it back: the label is pokegold's own name + -- for a dead write. Kept as an explicit branch so the byte is consumed + -- deliberately, and kept on the VM in case a romhack ever does read it. + self.unusedScriptByte = arg1(cmd) or 0 + elseif op == "xycompare" then + -- Script_xycompare does nothing but store a pointer in wXYComparePointer. + -- The work happens much later, in SetXYCompareFlags (home/region.asm), + -- which walks that table against the player's position on every map load + -- and sets wXYCompareFlags, and which has its own famous bug (`ld a, $4` + -- where `add $4` was meant, so the Y coordinate is never compared). + -- + -- No map in Gold uses the command; every occurrence the extractor found + -- is a data region mis-read as code. So the pointer is recorded and + -- nothing reads it, which is precisely what the cart does at this point. + -- wScriptVar is untouched, as in the asm. + self.xyComparePointer = wordArg(cmd) + elseif op == "autoinput" then + -- Script_autoinput hands a bank:pointer to StartAutoInput + -- (home/joypad.asm), which replays a canned button stream through the + -- joypad while the overworld keeps running: the player watches their + -- character move on its own. The operand is a `dba`, so the bytes are + -- bank, then the low and high halves of the address, in that order. + -- + -- The extractor emits those three bytes and nothing behind them, and no + -- map in the ROM actually runs the command (every occurrence in + -- scripts.lua is a data region mis-read as code), so the hook resolves + -- the pointer against the four streams StartAutoInput really has call + -- sites for -- src/core/gen2/AutoInput.lua POINTERS -- and arms nothing + -- for anything else. Script_autoinput does not write wScriptVar, so + -- neither does this. + if self.autoInputFn then + self.autoInputFn(arg1(cmd) or 0, wordArg(cmd, 2)) + end + elseif op == "writecmdqueue" then + -- Script_writecmdqueue copies a five-byte entry (CMDQUEUE_ENTRY_SIZE) out + -- of the script's own bank into the first free wCmdQueue slot; + -- HandleQueuedCommand then polls that queue every frame. Two maps use + -- it, both for CMDQUEUE_STONETABLE: the Ice Path B1F boulder puzzle and + -- the Blackthorn Gym 2F one, where the queue is what makes a pushed + -- boulder fall into the water. + -- + -- The five bytes sit behind a pointer in the script's own bank and the + -- extractor emits only the pointer, so the World resolves the entry from + -- the map instead (src/world/gen2/CmdQueue.lua STONE_TABLES). Like the + -- cart's own Script_writecmdqueue this leaves wScriptVar alone. + if self.writeCmdQueueFn then + self.writeCmdQueueFn(cmd.pointer or wordArg(cmd)) + end + elseif op == "delcmdqueue" then + -- Script_delcmdqueue: `xor a / ld [wScriptVar], a`, then DelCmdQueue over + -- the queue entry whose type byte matches the operand. Read the polarity + -- off the `ret c`: DelCmdQueue's .done arm clears the slot and sets carry, + -- so carry means it FOUND and deleted the entry, and that path returns + -- with wScriptVar still 0. The loop only falls through to + -- `ld a, TRUE / ld [wScriptVar], a` when it ran off the end without a + -- match. So the command answers FALSE on a successful delete and TRUE + -- when there was nothing to delete, which reads backwards until you check. + -- + -- With a real queue behind it that polarity is now observable rather than + -- academic: a map that deletes its own stone table answers FALSE. With + -- no hook (or an empty queue) TRUE is still the cart's answer. + local kind = cmd.queue or arg1(cmd) or 0 + local deleted = false + if self.delCmdQueueFn then deleted = self.delCmdQueueFn(kind) and true end + self.scriptVar = deleted and 0 or 1 + elseif op == "unknown" or op == "truncated" then + -- Not a command: the extractor emits these when the pointer walk ran into + -- a byte that is not an opcode, or off the end of the bank, and both + -- break its disassembly loop so they are always the last row in a list. + -- Recorded separately from the unimplemented-opcode set below, because + -- what they report is a mis-walked ROM region rather than a missing + -- branch. Ending the list is the only safe reading: the cart would be + -- executing data here. + self.badBytes[cmd.code or op] = (self.badBytes[cmd.code or op] or 0) + 1 + return "end" + elseif op == MOD_COMMAND then + -- A mod's verb, dispatched through the shared `commands` registry. Last + -- arm before the unknown-opcode ledger on purpose: no cart row can carry + -- this op, so a stock script has already matched a branch above and never + -- pays even this comparison. + return self:runModCommand(cmd) + else + -- No branch for this opcode. Falling through QUIETLY is the worst thing + -- this interpreter can do: a script that runs `checkitem` and then + -- `iftrue` reads a stale wScriptVar and takes the WRONG arm, which looks + -- like a content bug rather than a missing command. So keep running (a + -- hard error would make the game unplayable over one unported command) + -- but record it and say so once per opcode, and let the suite assert the + -- set is empty for a script built only of implemented commands. + self:noteUnknownOp(op) + end +end + +-- `key` is a scripts.lua key, or a command list itself: the two trainer +-- scripts (engine/events/trainer_scripts.asm) are reached through a player +-- event rather than a map pointer, so nothing extracts them and the World +-- hands them over inline. +-- Assigns the forward declaration above, not a new local: runCmd calls back +-- into this for every tail-call opcode. +function runList(self, key) + local list = type(key) == "table" and key or self.scripts[key] + if not list then return end + local i = 1 + while list[i] do + -- A whiteout replaces the running script rather than returning to it, so + -- the abort has to unwind every nested scall as well as this list. See + -- `reloadmapafterbattle`. + if self.aborted then return end + local cmd = list[i] + local op = cmd.op + -- A row a MOD wrote in the Gen 1 shape, { "mymod:shake", 4, 2 }, normalised + -- to the extension op so everything downstream -- the script.command hook's + -- `name`, runCmd's dispatch -- sees one row kind. The extractor stamps `op` + -- on every row it emits (src/import/RomExtractorGen2.lua:3096, plus its + -- "unknown" / "truncated" pair), so a row without one is never the cart's + -- and the two shapes cannot be confused. Vm:runModCommand has the contract. + if op == nil and type(cmd[1]) == "string" then op = MOD_COMMAND end + -- One-command lookahead, for `writetext`'s missing terminator. A text that + -- ends in `done` (home/text.asm:484) has no PromptButton, one that ends in + -- `prompt` (:470) does, and the extractor throws the terminator away -- so + -- the box cannot tell the two apart on its own. What FOLLOWS the writetext + -- can: a `yesorno` on the next row is InitYesNoTextBoxParameters going up + -- over the box that is still holding the question, which the cart never + -- closed. Vm:showText reads this to keep that box standing. + self.nextOp = list[i + 1] and list[i + 1].op or nil + local jump + if Runtime.wantsHook("script.command") then + -- The SAME hook name and the same (ctx, name, args) argument list the + -- Gen 1 runner passes (src/script/ScriptRunner.lua:164-169), so one mod + -- can log or wrap every command in both generations. What differs is + -- what a "command" IS: Gen 1 dispatches a hand-ported row + -- { "command", arg, ... } through the verb table, this VM dispatches one + -- decoded row of the CART's own bytecode. So `name` is the opcode name + -- out of src/script/gen2/Opcodes.lua and `args` is its raw operand byte + -- list -- which for most opcodes is empty, because the extractor decodes + -- the interesting operands into NAMED fields (cmd.text, cmd.script, + -- cmd.value, cmd.object). The whole decoded row rides along as a fourth + -- argument so a Gen 2 aware mod can read those without re-walking the + -- bytes; a Gen 1 shaped wrapper that only takes three simply ignores it. + -- + -- Hooks:call pcalls every link and the vanilla, and the command under it + -- YIELDS (text, yesorno, movement, battle). That only works because the + -- engine runs on LuaJIT, whose pcall is resumable; stock Lua 5.1 would + -- raise "attempt to yield across a C-call boundary" here. Same contract + -- the Gen 1 runner already relies on (src/script/ScriptRunner.lua:164). + -- A mod's row reports the operands it actually dispatches with, which for + -- a Gen 1 shaped row is the row's own tail rather than an `args` field. + local args = (op == MOD_COMMAND and modArgs(cmd)) or cmd.args or {} + jump = Runtime.call("script.command", function(_, hname, hargs, hcmd) + -- Honour a link that rewrote the operand list on its way down, the way + -- `nextFn(ctx, name, newargs)` does on Gen 1: run a copy of the row + -- carrying the new operands rather than the row the cart wrote. + local row = hcmd or cmd + if hargs ~= nil and hargs ~= args and hargs ~= row.args then + local copy = {} + for k, v in pairs(row) do copy[k] = v end + copy.args = hargs + row = copy + end + -- `op` last, not row.op: a Gen 1 shaped mod row carries no `op` field + -- and it is the normalisation above that made it a modcommand. + return runCmd(self, row, hname or row.op or op) + end, self:scriptCtx(), op, args, cmd) + else + jump = runCmd(self, cmd, op) + end + if jump == "end" then + return + elseif type(jump) == "number" then + -- A hook-returned program counter, same as the Gen 1 runner's. + i = jump + else + -- Gen 1 also lets a jump be a LABEL name; this VM's rows are the cart's + -- own bytecode and carry no labels, so any other string is a mod asking + -- for something that cannot exist here. Say so once and fall through. + if type(jump) == "string" then self:noteBadJump(jump) end + i = i + 1 + end + end +end + +function Vm.new(scripts, text, events, hooks) + hooks = hooks or {} + local movements = (scripts and scripts.movements) or hooks.movements or {} + return setmetatable({ + scripts = scripts or {}, + movements = movements, + text = text or {}, + events = events, + -- data/generated/events.lua: the side tables a command NAMES rather than + -- carries (the trades, the floor labels, the decoration scripts). Not the + -- same thing as `events` above, which is wEventFlags. + eventTables = hooks.eventTables or {}, + -- The `commands` registry as merged into data.commands: verb -> handler, + -- for the mod verbs a mod-authored row can name (Vm:runModCommand). Left + -- ABSENT when the boot supplies none, so Vm.__index falls through to the + -- module-level Vm.setCommands default and a mod-free boot has neither. + commands = hooks.commands, + scriptVar = 0, + stringBuffer = "", + busy = false, + lastTalked = nil, + showTextFn = hooks.showText, + facePlayerFn = hooks.facePlayer, + onFlagsChanged = hooks.onFlagsChanged, + setSceneFn = hooks.setScene, + getSceneFn = hooks.getScene, + setMapSceneFn = hooks.setMapScene, + turnObjectFn = hooks.turnObject, + applyMovementFn = hooks.applyMovement, + yesornoFn = hooks.yesorno, + disappearFn = hooks.disappear, + showPicFn = hooks.showPic, + hidePicFn = hooks.hidePic, + -- WaitButton for the one command that needs a real press of its own, + -- `waitbutton` under an open `pokepic` window. Absent on a headless + -- build, and the opcode then keeps its old free pass rather than parking + -- on a resume nobody will call. + waitButtonFn = hooks.waitButton, + getMonNameFn = hooks.getMonName, + getItemNameFn = hooks.getItemName, + -- CheckItemPocket on an item index -> "ITEM" | "KEY_ITEM" | "BALL" | + -- "TM_HM", which is what GetPocketName indexes ItemPocketNames with. The + -- same lookup the world already makes for specialsound's TM/HM jingle. + getItemPocketFn = hooks.getItemPocket, + getTrainerNameFn = hooks.getTrainerName, + setStringBufferFn = hooks.setStringBuffer, + givePokeFn = hooks.givePoke, + giveItemFn = hooks.giveItem, + addCellFn = hooks.addCell, + delCellFn = hooks.delCell, + hasCellFn = hooks.hasCell, + cryFn = hooks.cry, + playSoundFn = hooks.playSound, + playMusicFn = hooks.playMusic, + specialSoundFn = hooks.specialSound, + waitSfxFn = hooks.waitSfx, + -- StartAutoInput, by script pointer (`autoinput`) and by stream name + -- (CatchTutorial), plus StopAutoInput. See src/core/gen2/AutoInput.lua. + autoInputFn = hooks.autoInput, + autoInputStreamFn = hooks.autoInputStream, + stopAutoInputFn = hooks.stopAutoInput, + readVarFn = hooks.readVar, + -- Optional: the World's own label for the map a run belongs to. Nothing + -- inside the interpreter needs it -- only Vm:scriptCtx, which falls back to + -- the cart's group:number pair when the World does not supply one. + mapIdFn = hooks.mapId, + -- `special` id -> SpecialsPointers label (constants.specialOrder). + specialOrder = hooks.specialOrder, + -- The world half of src/script/gen2/Specials.lua, as ONE sub-table: a + -- special is an independent routine, so giving each its own `xFn` field + -- here would have doubled this constructor for no gain. + specials = hooks.specials, + healPartyFn = hooks.healParty, + healAnimFn = hooks.healAnim, + nameRivalFn = hooks.nameRival, + warpToSpawnFn = hooks.warpToSpawn, + showMoneyFn = hooks.showMoney, + showCoinsFn = hooks.showCoins, + openPcFn = hooks.openPc, + -- OpenMartDialog; nil means the shop is skipped rather than hanging. + openMartFn = hooks.openMart, + -- Trainer battles: the object's `trainer` struct is pushed in by the + -- World before the script runs (LoadTrainer_continue's wTempTrainer). + lookupTrainerFn = hooks.lookupTrainer, + startBattleFn = hooks.startBattle, + -- CatchTutorial's own StartBattle, which is a different entry point: no + -- party mon is sent out and the DUDE plays it (engine/events/ + -- catch_tutorial.asm). + catchTutorialFn = hooks.catchTutorial, + reloadMapFn = hooks.reloadMap, + badWarpFn = hooks.badWarp, + encounterMusicFn = hooks.encounterMusic, + showEmoteFn = hooks.showEmote, + trainerApproachFn = hooks.trainerApproach, + faceObjectFn = hooks.faceObject, + followFn = hooks.follow, + stopFollowFn = hooks.stopFollow, + -- Scene / clock / cartridge identity. + getMapSceneFn = hooks.getMapScene, + getTimeOfDayFn = hooks.getTimeOfDay, + gsVersionFn = hooks.gsVersion, + -- ENGINE_* flags (badges, Pokegear cards, the contest timer): a different + -- namespace from the wEventFlags the setevent / clearevent pair writes. + getEngineFlagFn = hooks.getEngineFlag, + setEngineFlagFn = hooks.setEngineFlag, + -- Raw WRAM bytes and VAR_* slots. + readMemFn = hooks.readMem, + writeMemFn = hooks.writeMem, + writeVarFn = hooks.writeVar, + callAsmFn = hooks.callAsm, + -- Map objects. + appearFn = hooks.appear, + moveObjectFn = hooks.moveObject, + variableSpriteFn = hooks.variableSprite, + loadEmoteFn = hooks.loadEmote, + -- Map blocks and warps. + changeBlockFn = hooks.changeBlock, + changeMapBlocksFn = hooks.changeMapBlocks, + earthquakeFn = hooks.earthquake, + warpToFn = hooks.warpTo, + warpCheckFn = hooks.warpCheck, + warpSoundFn = hooks.warpSound, + newLoadMapFn = hooks.newLoadMap, + writeCmdQueueFn = hooks.writeCmdQueue, + delCmdQueueFn = hooks.delCmdQueue, + setWarpModFn = hooks.setWarpMod, + setBlackoutMapFn = hooks.setBlackoutMap, + -- Encounters. + setSwarmFn = hooks.setSwarm, + setWildEncountersFn = hooks.setWildEncounters, + rollWildFn = hooks.rollWild, + -- Music. + playMapMusicFn = hooks.playMapMusic, + fadeOutMusicFn = hooks.fadeOutMusic, + dontRestartMapMusicFn = hooks.dontRestartMapMusic, + -- Bag, money and coins. + hasItemFn = hooks.hasItem, + takeItemFn = hooks.takeItem, + getMoneyFn = hooks.getMoney, + setMoneyFn = hooks.setMoney, + getCoinsFn = hooks.getCoins, + setCoinsFn = hooks.setCoins, + -- Party. + hasPokeFn = hooks.hasPoke, + giveEggFn = hooks.giveEgg, + givePokeMailFn = hooks.givePokeMail, + checkPokeMailFn = hooks.checkPokeMail, + getLandmarkNameFn = hooks.getLandmarkName, + -- loadmenu stashes a header for the verticalmenu / _2dmenu that follows; + -- openMenu is the blocking half, modelled on yesorno. + openMenuFn = hooks.openMenu, + -- Field events. + fruitTreeItemFn = hooks.fruitTreeItem, + fruitTreeResetFn = hooks.fruitTreeReset, + fruitTreePickedFn = hooks.fruitTreePicked, + fruitTreePickFn = hooks.fruitTreePick, + describeDecorationFn = hooks.describeDecoration, + decorationSlotFn = hooks.decorationSlot, + npcTradeFn = hooks.npcTrade, + elevatorFn = hooks.elevator, + -- Phone. + addPhoneNumberFn = hooks.addPhoneNumber, + phoneCallFn = hooks.phoneCall, + hangUpFn = hooks.hangUp, + setSpecialCallFn = hooks.setSpecialCall, + getSpecialCallFn = hooks.getSpecialCall, + -- End of game. + hallOfFameFn = hooks.hallOfFame, + creditsFn = hooks.credits, + trainerObject = nil, + trainer = nil, + justBattled = false, + lastSpecial = nil, + -- Sparse WRAM store for readmem / writemem / loadmem: the Goldenrod switch + -- room and the MooMoo berries are counters with nowhere else to live, and + -- a read / addval / write triple has to add up even with no World hook. + mem = {}, + -- ENGINE_* flags, when nothing supplies getEngineFlag / setEngineFlag. + engineFlags = {}, + -- wVariableSprites: slot ($f0 SPRITE_CONSOLE .. $fc) -> sprite byte. + variableSprites = {}, + -- Every opcode that reached the final else, and every extractor `unknown` / + -- `truncated` row, so a test can assert both are empty for a real script. + unknownOps = {}, + badBytes = {}, + -- Every map callback that tried to block, by script key. Empty is the + -- invariant: see Vm:runCallback. + blockedCallbacks = {}, + menuHeader = nil, + loadedEmote = nil, + lastTextKey = nil, + unusedScriptByte = nil, + xyComparePointer = nil, + specialCall = nil, + dontRestartMapMusic = false, + wildEncounters = true, + }, Vm) +end + +-- The sparse WRAM store as a plain table for the save file, and back. Same +-- contract as Events:serialize / Events:restore: address -> byte, sparse, and +-- never a dense WRAM image (the cart has 8K of it and a script touches a +-- handful of bytes). Zeroes are dropped on the way out because a missing +-- address already reads back as 0 in the readmem arm above. +-- +-- The addresses the World claims through readMem / writeMem are NOT in here: +-- those belong to whatever engine state answered for them and are persisted by +-- their own owner. This is only the bytes with nowhere else to live -- the +-- Goldenrod underground switches and wMooMooBerries. +function Vm:serializeMem() + local out = {} + for addr, value in pairs(self.mem) do + if value ~= 0 then out[addr] = value end + end + return out +end + +function Vm:restoreMem(bytes) + if type(bytes) ~= "table" then return self end + self.mem = {} + for addr, value in pairs(bytes) do + -- A serialized file can hand these back as strings; the readmem arm + -- indexes by number, so a string key would silently read as 0. + local index, byte = tonumber(addr), tonumber(value) + if index and byte then self.mem[index] = byte % 256 end + end + return self +end + +-- The unknown-opcode ledger. Warn once per opcode name so a script in a loop +-- cannot flood the log, and keep the set so gen2_vm_test can assert it stays +-- empty across the whole extracted cache. +function Vm:noteUnknownOp(op) + if op == nil then return end + if self.unknownOps[op] then + self.unknownOps[op] = self.unknownOps[op] + 1 + return + end + self.unknownOps[op] = 1 + Logger.warn("gen2 script: unimplemented opcode '%s' skipped", tostring(op)) +end + +-- Same ledger shape as noteUnknownOp: a `script.command` wrapper that returned +-- a Gen 1 LABEL name has asked for something the cart's bytecode has no notion +-- of, and a mod that does it once does it on every row, so warn once per name. +function Vm:noteBadJump(name) + self.badJumps = self.badJumps or {} + if self.badJumps[name] then return end + self.badJumps[name] = true + Logger.warn("gen2 script: script.command returned label '%s'; " .. + "this VM has no labels, falling through", tostring(name)) +end + +-- ---- the mod verb table ---------------------------------------------------- +-- +-- THE CONTRACT. Read this before adding a caller. +-- +-- Gen 1 scripts are hand-written row lists ({ "command", args... }) and a mod +-- extends the language by registering a verb into the `commands` registry, which +-- src/script/ScriptRunner.lua:150 resolves by NAME on every row. There is no +-- name to resolve here: this VM runs the CART's bytecode, where a command is an +-- opcode byte and every byte that means anything already means something +-- (src/script/gen2/Opcodes.lua). Handing mods one of the free bytes would be +-- worse than useless -- ROM data that happens to start with it would decode as a +-- mod call instead of ending the pointer walk. +-- +-- So the seam is a row the CART CANNOT WRITE. Opcodes.MOD_COMMAND +-- ("modcommand") is an op name with no byte behind it, and the extractor only +-- ever stamps names out of the byte table plus its own "unknown" / "truncated" +-- pair. A stock Gold boot therefore decodes byte for byte as it did before this +-- function existed and can never reach it; what reaches it is a row a mod wrote, +-- in either of two shapes: +-- +-- { op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native +-- { "mymod:shake", 4, 2 } -- Gen 1 row +-- +-- The second is the Gen 1 row shape verbatim (runList normalises it), so a mod +-- can ship ONE row list for both games as long as every row in it is its own +-- verb. A list like that is runnable today: Vm:start and Vm:runCallback both +-- take a table of rows as well as a scripts.lua key, and `scall` / `sjump` +-- targets are keys into the same pool. +-- +-- The verb resolves against the SAME registry Gen 1 uses -- same registry name +-- `commands`, same record shape, a bare function or the flagged table +-- { fn, foreground, blocking } that src/script/Commands.lua:1381-1390 unpacks -- +-- reached through hooks.commands at Vm.new, or through Vm.setCommands for a boot +-- path with no way into that hooks literal. `foreground` and `blocking` are +-- unpacked and ignored: they exist for Gen 1's parallel ambient runner, and this +-- VM has one script frame, so there is no second runner for them to mean +-- anything against. +-- +-- The handler is called as fn(ctx, unpack(args)) with the same per-run ctx every +-- other mod-facing site in this file hands out (Vm:scriptCtx): ctx.vm where Gen 1 +-- has ctx.runner, and no ctx.game / ctx.save / ctx.overworld, because this VM +-- owns none of them. Its return value speaks runCmd's vocabulary, which is Gen +-- 1's control-command vocabulary: "end" ends the list, a number is a 1-based row +-- to continue at, nil falls through to the next row. A verb may block exactly +-- the way a command does -- ctx.vm:showText, :showRaw and :waitFrames all yield +-- the VM's coroutine and Vm:resume drives them back. +function Vm.setCommands(source) + -- Module-level default rather than per-instance: instances inherit it through + -- Vm.__index, so hooks.commands still wins for a VM that was given one and + -- everybody else sees whatever the boot installed. The intended argument is + -- the merged data.commands table, which is where the `commands` registry + -- lands for both generations. Whoever installs it owns replacing it: the + -- merged table is rebuilt per boot and per mod hot reload, so the install has + -- to happen on the same beat, and nil clears it back to a mod-free VM. + Vm.commands = source + return source +end + +-- verb -> handler, unpacking the record shape Commands.resolve unpacks. The +-- source is a table (data.commands) or a function(verb) for a boot that would +-- rather resolve lazily; nil for a mod-free boot, which is the only check the +-- dispatch path below pays for. +function Vm:resolveVerb(verb) + local source = self.commands + if source == nil or type(verb) ~= "string" then return nil end + local record + if type(source) == "function" then + record = source(verb) + else + record = source[verb] + end + if type(record) == "table" then return record.fn, record end + if type(record) == "function" then return record, nil end + return nil +end + +function Vm:runModCommand(cmd) + local verb = cmd.verb or cmd[1] + local fn = self:resolveVerb(verb) + if type(fn) ~= "function" then + self:noteUnknownVerb(verb) + return nil + end + -- pcall, and then keep going: the same call the unimplemented-opcode arm in + -- runCmd makes, for the same reason -- one bad row out of a third-party mod + -- must not be able to make Gold unplayable. Gen 1's runner lets the error + -- reach the coroutine because a Gen 1 script IS the mod's contribution and + -- dying with it is honest; here the mod's verb is one row inside the cart's + -- own script, and taking the map's script down with it would blame the wrong + -- author. The pcall is resumable because the engine runs on LuaJIT, the same + -- contract the script.command hook above already depends on. + local ok, jump = pcall(fn, self:scriptCtx(), unpack(modArgs(cmd))) + if ok then return jump end + self:noteFailedVerb(verb, jump) + return nil +end + +-- Same one-warning-per-name ledger as noteUnknownOp, kept in its OWN table: a +-- verb nobody registered is a missing mod, not an unported opcode, and +-- gen2_vm_test asserts the opcode ledger stays empty across the whole cache. +function Vm:noteUnknownVerb(verb) + local name = tostring(verb) + self.unknownVerbs = self.unknownVerbs or {} + if self.unknownVerbs[name] then + self.unknownVerbs[name] = self.unknownVerbs[name] + 1 + return + end + self.unknownVerbs[name] = 1 + Logger.warn("gen2 script: no command '%s' in the commands registry; " .. + "row skipped", name) +end + +-- A verb that raised. Reported to the mod manager's feed as well as the log +-- when the verb names its owner: the registries drop the owner at merge time, so +-- the "modid:verb" namespace mods write themselves (mods/examples/ +-- example_lost_parcel: "example_lost_parcel:count_ask") is the only handle on it +-- this side of the loader. A verb with no prefix is logged and blamed on nobody +-- rather than on a guess. +function Vm:noteFailedVerb(verb, err) + local name = tostring(verb) + self.failedVerbs = self.failedVerbs or {} + local modId = name:match("^([^:]+):") + if modId then Runtime.reportError(modId, name .. ": " .. tostring(err)) end + if self.failedVerbs[name] then return end + self.failedVerbs[name] = tostring(err) + Logger.error("gen2 script: command '%s' failed: %s", name, tostring(err)) +end + +-- ---- the mod-facing script lifecycle --------------------------------------- +-- +-- `script.started` / `script.ended` / `script.command` are the SAME three names +-- and the same payload keys the Gen 1 runner raises +-- (src/script/ScriptRunner.lua:119-190): both events carry { ctx = ... } and +-- `script.ended` additionally carries completed = true/false, so a mod that +-- brackets a run works unchanged on Gold. +-- +-- What the names MEAN differs, and pretending otherwise would be the lie: +-- Gen 1's ctx is a bag of engine services (game, overworld, save, runner) plus +-- the row list's own extras, because a Gen 1 script is hand-ported rows this +-- engine wrote. This VM runs the CART's bytecode; it owns no Game and no save, +-- and the facts that identify a run are the cart's own -- which map it belongs +-- to, which script pointer it started at, and which map object it hangs off. +-- Those are what go in, plus `vm` as the analogue of Gen 1's `runner` and +-- `generation` so a shared mod can tell the two apart without guessing. +-- +-- The ctx is built once per run and memoised: a script that runs 200 commands +-- with a `script.command` wrapper installed must not build 200 tables, and the +-- Gen 1 runner likewise hands the same table to every command in a run. +function Vm:scriptCtx() + local ctx = self.ctx + if ctx then return ctx end + local group, number + if self.readVarFn then + group, number = self.readVarFn(VAR_MAPGROUP), self.readVarFn(VAR_MAPNUMBER) + end + ctx = { + vm = self, + generation = 2, + -- The scripts.lua key the run started from: "<bank>:<addr>" out of the ROM + -- walk, or the command list itself for the two inline trainer scripts the + -- World hands over (see runList). + scriptKey = self.ctxKey, + -- "script" for a CallMapScript-style run (Vm:start), "callback" for a + -- MAPCALLBACK_* body (Vm:runCallback), which the cart runs on a nested + -- frame rather than as the player's script. + kind = self.ctxKind or "script", + -- constants/map_constants.asm names a map by group + number, and that pair + -- is all the VM can see; `mapId` is the World's own label when it supplies + -- one (hooks.mapId) and the cart's "<group>:<number>" otherwise. Do not + -- parse it -- read mapGroup / mapNumber for the numbers. + -- string.format, not Strings(): this is a machine id for a mod to key on, + -- not player-facing text a translator should ever see. + mapId = (self.mapIdFn and self.mapIdFn()) + or (group and string.format("%d:%d", group, number or 0)) or nil, + mapGroup = group, + mapNumber = number, + -- hLastTalked: the 1-based map object this run hangs off, as the World + -- stamped it before calling Vm:start. nil for a run no object owns, and + -- stale from the last conversation for a sign or a callback -- which is + -- exactly what wLastTalked is on the cart, so it is reported as-is. + object = self.lastTalked, + } + self.ctx = ctx + return ctx +end + +-- completed = false is an abandoned run, not a short one: a whiteout that +-- unwound the list (self.aborted), a coroutine that died on a Lua error, or a +-- map callback that yielded with nowhere to park. Same distinction the Gen 1 +-- runner draws when its resume fails. +function Vm:emitScriptEnded(completed) + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", + { ctx = self:scriptCtx(), completed = completed and true or false }) + end + self.ctx = nil +end + +-- The `trainer` struct only stores class + member; the roster comes from +-- trainers.lua through the World, so the VM never touches that table itself. +function Vm:lookupTrainer(class, member) + if not (class and member) then return nil end + if not self.lookupTrainerFn then return { class = class, member = member } end + return self.lookupTrainerFn(class, member) +end + +-- `special` handlers. The script command carries an index into +-- SpecialsPointers (data/events/special_pointers.asm), which the extractor +-- turns into a name via constants.specialOrder; keying on the name rather than +-- the number means a repointed table cannot silently call the wrong routine. +-- +-- The table itself lives in src/script/gen2/Specials.lua: 112 independent +-- routines are a different kind of code from one interpreter, and growing them +-- inside this file would have buried runList. Everything there is either a +-- ported handler or a deliberate stub with its reason written down. +Vm.SPECIALS = Specials.ALL + +function Vm:specialName(id) + local order = self.specialOrder + if not order or not id then return nil end + return order[id + 1] +end + +function Vm:runSpecial(id, _cmd) + local name = self:specialName(id) + local handler = name and Vm.SPECIALS[name] + self.lastSpecial = name or id + if handler then handler(self) end +end + +-- wStringBuffer2 in one place: the VM substitutes {STRBUF} itself for the text +-- it yields, and the hook mirrors it onto the game so the shared {STRBUF} token +-- can cover any page the VM did not build (a stale buffer is the cart's own +-- behaviour -- CopyName1 never clears it). +function Vm:setStringBuffer(value) + self.stringBuffer = value or "" + if self.setStringBufferFn then self.setStringBufferFn(self.stringBuffer) end +end + +-- CurItemName (engine/overworld/scripting.asm:507). It reads wCurItem and +-- NOTHING else: the name in the "put the ... in" box comes from the item the +-- last giveitem banked, never from a string buffer. Reading self.stringBuffer +-- there instead was what made Mr. Pokemon's MYSTERY EGG hand-over print +-- whatever name an earlier script had left behind (maps/MrPokemonsHouse.asm +-- :31-35 is a plain `giveitem` / `itemnotify` pair with a getstring before it). +function Vm:curItemName() + if not (self.getItemNameFn and self.curItem) then return "" end + return self.getItemNameFn(self.curItem) or "" +end + +-- GetPocketName (engine/overworld/scripting.asm:488). A VM built without the +-- hook (drivers, tests) keeps printing ITEM POCKET, which is the pocket the +-- overwhelming majority of the items these boxes name really live in. +function Vm:pocketName(item) + local pocket = self.getItemPocketFn and item and self.getItemPocketFn(item) + return POCKET_NAMES[pocket] or POCKET_NAMES.ITEM +end + +function Vm:emitFace(doFace) + if doFace and self.facePlayerFn then self.facePlayerFn() end +end + +-- True when the command after the one being run is the cart's YES/NO prompt, +-- i.e. this box must not pop before the prompt goes up over it. `promptbutton` +-- is deliberately NOT included: its own arm is a no-op here, so a box held open +-- for it would never be taken down, and the player presses A exactly once +-- either way. +function Vm:textStays() + return self.nextOp == "yesorno" +end + +-- `stay` is also settable per command, for the hand-ported scripts that hold +-- the box open over something that is not `yesorno`: HiddenItems' +-- FindItemInBallScript prints the found line, plays SFX_ITEM and then holds on +-- `pause 60` before the itemnotify line goes into the SAME MapTextbox +-- (engine/events/misc_scripts.asm:13-17). A one-command lookahead cannot see +-- that -- the next op is `playsound`, not the hold -- so the transcription says +-- so itself. +-- +-- `hold` is the cart `pause` those held-over commands contain, in frames. It +-- travels with the text rather than being run as a `pause` of its own because +-- the port's world does not tick while a box is on the stack (Game2:update +-- stops at the top state), so the only clock that can count it is the box's; +-- World:showText is where it lands. +function Vm:showRaw(body, stay, hold) + if not body or body == "" then body = "..." end + if self.stringBuffer and self.stringBuffer ~= "" then + body = body:gsub("{STRBUF}", self.stringBuffer) + end + if self.showTextFn then + coroutine.yield({ + kind = "text", + text = body, + stay = (stay or self:textStays()) and true or false, + hold = hold, + }) + end +end + +function Vm:showText(textKey) + local body = textKey and self.text[textKey] + -- wScriptTextAddr: jumptext / jumptextfaceplayer park their pointer there and + -- JumpTextScript's `repeattext -1, -1` is what actually prints it. + if textKey then self.lastTextKey = textKey end + if not body or body == "" then body = "..." end + if self.stringBuffer and self.stringBuffer ~= "" then + -- Only the {STRBUF} marker reads the buffer. The buffer is STALE by + -- design (CopyName1 never clears it, so a berry picked an hour ago is + -- still sitting in wStringBuffer2), which is why a text without the + -- marker must never have the buffer spliced in on a guess: every + -- extracted `received` text is complete, marker or literal. + body = body:gsub("{STRBUF}", self.stringBuffer) + end + if self.showTextFn then + coroutine.yield({ kind = "text", text = body, stay = self:textStays() }) + end +end + +-- Script_pause's frame count, and the same hold `earthquake`, `showemote` and +-- `deactivatefacing` end on. There is deliberately NO world hook here: a wait +-- has to SUSPEND the script, so it yields and Vm:resume parks the count in +-- waitLeft, which Vm:update spends. A hook would have run the wait beside the +-- VM instead of inside it, and the script would have walked on through it. +function Vm:waitFrames(n) + if n and n > 0 then + coroutine.yield({ kind = "wait", frames = n }) + end +end + +-- `pause` and `showemote`, the two commands that go through Script_pause +-- itself (engine/overworld/scripting.asm:2110-2124, and ShowEmoteScript's +-- `pause 0` reading the wScriptDelay Script_showemote just wrote). Its inner +-- loop is `ld c, 2 / call DelayFrames` ONCE PER UNIT, so the hardware holds +-- two frames per operand byte: `pause 60` is 120 frames. +-- +-- The factor lives here and not in waitFrames because the other two waiters do +-- not share the routine. `deactivatefacing` hands the byte to WaitScript, +-- which does one `dec [wScriptDelay]` per frame (:30-42), and `earthquake` +-- spends it as a step_sleep, which StepFunction_Sleep also decrements once per +-- frame -- both are already 1:1 and doubling them would be a new bug. +function Vm:pauseFrames(n) + self:waitFrames(Vm.pauseLength(n)) +end + +-- The same doubling, without the yield: a `pause` that a transcription folded +-- into the text row above it (rawtext's `hold`) is counted by the box, not by +-- the VM, but it is the same Script_pause operand and must not read differently. +function Vm.pauseLength(n) + return (n or 0) * 2 +end + +function Vm:running() + return self.busy +end + +function Vm:start(scriptKey) + if self.busy or not scriptKey then return false end + if type(scriptKey) ~= "table" and not self.scripts[scriptKey] then + return false + end + self.busy = true + self.scriptVar = 0 + -- wRunningTrainerBattleScript and the win/loss overrides are per-run: the + -- next script must not see the last one's battle. + self.justBattled = false + self.battleOutcome = nil + self.winTextOverride = nil + self.lossTextOverride = nil + -- The whiteout abort is per-run too: a script that ended because the player + -- was wiped must not stop the next one before it starts. + self.aborted = false + -- The mod-facing run identity, rebuilt per run (Vm:scriptCtx). Set BEFORE + -- the emit so the event carries this run's key, not the last one's. + self.ctx, self.ctxKey, self.ctxKind = nil, scriptKey, "script" + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = self:scriptCtx() }) + end + self.co = coroutine.create(function() + runList(self, scriptKey) + end) + self:resume() + return true +end + +-- ExecuteCallbackScript (home/map.asm), which is how a MAPCALLBACK_* body runs. +-- +-- Not the same entry as Vm:start. RunMapCallback does NOT check wScriptRunning +-- the way CallMapScript does, and it must not: every warp a SCRIPT takes is a +-- map load with that script still parked (Script_warp calls StopScript, which +-- clears SCRIPT_RUNNING for the frame and leaves wScriptPos where it was), and +-- the four callbacks inside the setup script run before it resumes. So a +-- callback has to be runnable while this VM is busy. +-- +-- What the cart saves around the nested run is wScriptMode and wScriptFlags, +-- and CallCallback pushes the parent's bank/position onto wScriptStack so +-- Script_endcallback's ExitScriptSubroutine pops straight back to it. Here the +-- parked coroutine and the request it is parked on ARE that stack frame, so +-- they are what gets saved and put back. +-- +-- wScriptVar is deliberately NOT saved: the cart does not save it either, so a +-- callback's own `checkevent` really does clobber the parent script's copy. +-- +-- Nothing reachable from an extracted callback yields (no text, no battle, no +-- movement -- ScriptEvents runs inside the map load, with no frame to come back +-- on), and a callback that tried to would have nowhere to park. One that does +-- is abandoned with the parent's frame put back untouched, and recorded, rather +-- than silently overwriting the request the parent is waiting on. +function Vm:runCallback(scriptKey) + if not scriptKey then return false end + if type(scriptKey) ~= "table" and not self.scripts[scriptKey] then + return false + end + local parent = { + busy = self.busy, co = self.co, pending = self.pending, + waitLeft = self.waitLeft, + waitSfx = self.waitSfx, waitSfxLeft = self.waitSfxLeft, + -- The mod-facing ctx is part of the parent's frame: the callback runs as + -- its own `script.started` / `script.ended` pair, and the parent's next + -- command must go back to reporting the parent's run. + ctx = self.ctx, ctxKey = self.ctxKey, ctxKind = self.ctxKind, + } + -- SCRIPT_RUNNING is set for the nested run (EnableScriptMode), so a hook that + -- asks "is a script up?" -- the deferred object rebuild is the one that does + -- -- answers the same yes it would inside any other script. + self.busy = true + self.co, self.pending = nil, nil + self.waitLeft, self.waitSfx, self.waitSfxLeft = nil, nil, nil + local abortedBefore = self.aborted + self.aborted = false + self.ctx, self.ctxKey, self.ctxKind = nil, scriptKey, "callback" + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = self:scriptCtx() }) + end + local co = coroutine.create(function() runList(self, scriptKey) end) + local ok, req = coroutine.resume(co) + local finished = ok and coroutine.status(co) == "dead" + -- Closed out while this run's ctx is still current: a callback that yielded + -- with nowhere to park is abandoned, which is the completed = false case, + -- and so is one that died on a Lua error (re-raised below). + self:emitScriptEnded(finished) + self.busy, self.co, self.pending = parent.busy, parent.co, parent.pending + self.waitLeft = parent.waitLeft + self.waitSfx, self.waitSfxLeft = parent.waitSfx, parent.waitSfxLeft + self.aborted = abortedBefore + self.ctx, self.ctxKey, self.ctxKind = parent.ctx, parent.ctxKey, parent.ctxKind + if not ok then error(req) end + if not finished then + self:noteBlockedCallback(scriptKey, req) + return false + end + return true +end + +-- The blocked-callback ledger, the same shape as the unknown-opcode one: warn +-- once per script key so a callback that runs on every map load cannot flood +-- the log, and keep the set so a test can assert it stays empty. +function Vm:noteBlockedCallback(scriptKey, req) + local key = tostring(scriptKey) + self.blockedCallbacks = self.blockedCallbacks or {} + if self.blockedCallbacks[key] then return end + self.blockedCallbacks[key] = (req and req.kind) or true + Logger.warn("gen2 map callback '%s' blocked on '%s'; abandoned", + key, tostring(req and req.kind or "?")) +end + +-- RunSceneScript's tail (engine/overworld/events.asm:414-429): the scene body +-- has just run to its `end`, and only NOW is the script `sdefer` recorded +-- CallScript'd -- as this pass's player event, so it runs after the whole scene +-- body rather than at the sdefer command's own position. A whiteout unwound +-- the script instead of ending it, so nothing is left to defer to. +function Vm:runDeferred() + local script = self.deferred + self.deferred = nil + if not script or self.aborted then return false end + return self:start(script) +end + +function Vm:resume(resumeValue) + if not self.co then return end + local ok, req = coroutine.resume(self.co, resumeValue) + if not ok then + self.busy = false + self.co = nil + self:emitScriptEnded(false) + error(req) + end + if coroutine.status(self.co) == "dead" then + self.busy = false + self.co = nil + self.pending = nil + -- A whiteout unwound the list rather than running it out, so `aborted` is + -- exactly the completed = false case. Emitted BEFORE runDeferred, which + -- starts a whole new run and would otherwise nest this run's `ended` + -- inside the next run's `started`. + self:emitScriptEnded(not self.aborted) + self:runDeferred() + return + end + self.pending = req + if req and req.kind == "text" and self.showTextFn then + self.showTextFn(req.text, function() + self:resume() + end, req.stay, req.hold) + elseif req and req.kind == "wait" then + self.waitLeft = req.frames or 0 + elseif req and req.kind == "waitbutton" then + -- Only yielded when the hook exists (see the opcode), so no fallback arm: + -- an arm that resumed immediately would put the bug straight back. + self.waitButtonFn(function() self:resume() end) + elseif req and req.kind == "yesorno" and self.yesornoFn then + self.yesornoFn(function(yes) + self:resume(yes and true or false) + end) + elseif req and req.kind == "move" and self.applyMovementFn then + self.applyMovementFn(req.object, req.bytes, function() + self:resume() + end) + elseif req and req.kind == "waitsfx" then + self.waitSfx = true + self.waitSfxLeft = 180 -- safety cap (~3s) if a source never ends + elseif req and req.kind == "battle" then + if self.startBattleFn then + self.startBattleFn(req.trainer, req.wild, function(outcome) + self:resume(outcome) + end) + else + self:resume("win") + end + elseif req and req.kind == "catchtutorial" then + -- farcall StartBattle: the script is parked here until the demo battle is + -- over, because StopAutoInput and the map reload are on the other side of + -- it. Only reached when the hook exists, so there is no fallback arm. + self.catchTutorialFn(req.wild, req.battleType, function() + self:resume() + end) + elseif req and req.kind == "mart" then + if self.openMartFn then + self.openMartFn(req.martType, req.martId, function() self:resume() end) + else + self:resume() + end + elseif req and req.kind == "approach" then + self.trainerApproachFn(function() self:resume() end) + elseif req and req.kind == "menu" then + -- Script_verticalmenu / Script__2dmenu. Same shape as yesorno: the + -- coroutine is parked on the yield and the menu's own callback resumes it + -- with the 1-based index (0 or nil for B). With no hook the script takes + -- the cancel arm rather than hanging on a resume nobody will call. + if self.openMenuFn then + self.openMenuFn(req.header, req.style, function(choice) + self:resume(choice) + end) + else + self:resume(0) + end + elseif req and req.kind == "pokemail" then + -- CheckPokeMail's SelectMonFromParty half. Only reached when the hook + -- exists (the opcode answers REFUSED outright otherwise), so there is no + -- fallback arm here. + self.checkPokeMailFn(req.mail, function(answer) self:resume(answer) end) + elseif req and req.kind == "trade" then + self.npcTradeFn(req.trade, function() self:resume() end) + elseif req and req.kind == "elevator" then + self.elevatorFn(req.floors, function(rode) self:resume(rode) end) + elseif req and req.kind == "phonecall" then + self.phoneCallFn(req.caller, function() self:resume() end) + elseif req and req.kind == "halloffame" then + self.hallOfFameFn(function() self:resume() end) + elseif req and req.kind == "credits" then + self.creditsFn(function() self:resume() end) + end +end + +function Vm:update() + if not self.busy then return end + if self.waitLeft and self.waitLeft > 0 then + self.waitLeft = self.waitLeft - 1 + if self.waitLeft <= 0 then + self.waitLeft = nil + self:resume() + end + return + end + if self.waitSfx then + local done = true + if self.waitSfxFn then + done = self.waitSfxFn() + end + if self.waitSfxLeft then + self.waitSfxLeft = self.waitSfxLeft - 1 + if self.waitSfxLeft <= 0 then done = true end + end + if done then + self.waitSfx = nil + self.waitSfxLeft = nil + self:resume() + end + end +end + +return Vm diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index a7a0e197..ed89cd5e 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -53,6 +53,13 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209) local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) +-- After GBFadeOutToWhite, PlayIntro still DelayFrame's once (intro.asm:20) +-- and DisplayTitleScreen keeps GBPalWhiteOut through two +-- TitleScreenCopyTileMapToVRAM Delay3 waits (title.asm:136, 142) before +-- GBPalNormal and the logo bounce. Without this hold the title drops in +-- the same breath as the fade. +local POST_FADE_WHITE = 1 + 3 + 3 + -- ..(engine/movie/splash.asm ln 211) local LOGO_X, LOGO_Y = 72, 56 local TEXT_X, TEXT_Y = 40, 80 @@ -185,14 +192,24 @@ function IntroMovie.new(game, onDone) return self end +-- PlayIntro never StopAllSounds's: Music_IntroBattle outlasts the fade and +-- keeps playing over the title logo drop until title.asm starts +-- MUSIC_TITLE_SCREEN (same continuity Yellow got in #436). function IntroMovie:finish() if self.finished then return end self.finished = true - pcall(Music.stop) self.game.stack:pop() if self.onDone then self.onDone() end end +-- Solid white beat before the title screen is pushed (see POST_FADE_WHITE). +function IntroMovie:exitToTitle() + if self.finished or self.phase == 4 then return end + self.phase = 4 + self.timer = 0 + self.fade = 1 +end + function IntroMovie:startPhase(phase) self.phase = phase self.timer = 0 @@ -248,7 +265,7 @@ function IntroMovie:fightStep() elseif op.fade then self.opTimer = self.opTimer + 1 self.fade = self.opTimer / op.fade - if self.opTimer >= op.fade then self:finish() end + if self.opTimer >= op.fade then self:exitToTitle() end return end self.opIndex = self.opIndex + 1 @@ -261,10 +278,17 @@ function IntroMovie:update(dt) self:finish() return end + if self.phase == 4 then + self.timer = self.timer + 1 + if self.timer >= POST_FADE_WHITE then self:finish() end + return + end local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then - self:finish() + -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the + -- white hold stands in for that beat before the title is built. + self:exitToTitle() return end self.timer = self.timer + 1 @@ -416,7 +440,10 @@ end function IntroMovie:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - if self.phase == 1 then + if self.phase == 4 then + -- GBPalWhiteOut hold (title.asm DisplayTitleScreen load-in) + return + elseif self.phase == 1 then self:drawCopyright() elseif self.phase == 2 then self:drawSplash() diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 73045b12..e52b8d12 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -13,6 +13,67 @@ local BUILTIN = { ManagerState = "src.mods.ManagerState", } +-- The Gen 2 (Gold) screens, which live under src/ui/gen2/. Half of them share +-- a module name with a Gen 1 screen (PartyMenu, StartMenu, TitleState, +-- OakSpeech, NamingScreen, PokedexMenu, OptionsMenu, TrainerCard, BoxMenu, +-- SummaryMenu, BattleState, SlotMachine, Credits, HallOfFame), so the ids +-- carry a "Gen2" prefix: one registry serves both generations, and a mod that +-- replaces Gold's party menu must not also replace Red's. The prefix is the +-- same namespace the Gold data tables use for the same collision +-- (data.gen2Palettes, data.gen2Icons). +-- +-- Everything listed is a stack state: `new(game, opts)`, pushed and popped. +-- The drawing helpers sitting next to them in src/ui/gen2/ (Chrome, TileSheet, +-- PackGfx, SpriteAnims, BattleAnimView, BattleHud) are not screens and get no +-- id -- a mod reskins those through the asset search path, not through here. +-- +-- Gen2HallOfFame is the screen; the roster behind it is src/core/gen2/ +-- HallOfFame.lua, which is a model and gets no id either. +-- +-- The Game Corner's three PRIZE COUNTERS get no id for the same reason: on the +-- cart they are map script and nothing else (GoldenrodGameCornerTMVendorScript +-- and its Celadon twins are `loadmenu` / `verticalmenu` / `checkcoins` / +-- `giveitem` / `givepoke` / `takecoins`, all of which src/script/gen2/Vm.lua +-- runs), so a prize counter is reached by talking to the vendor and never by +-- pushing a screen. The slot machine and card flip next door ARE engine +-- screens and do have ids. +local GEN2 = { + "BankOfMom", + "BattleState", "BattleTransition", "BoxMenu", "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", + "EggHatchAnim", "ElevatorMenu", "EvolutionAnim", + "GameFreakPresents", "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", + "ItemPcMenu", + "MagnetTrainRide", + -- MAIL, four screens: the compose keyboard, the full-page reader, the + -- READ/TAKE/QUIT row the party submenu opens, and the PC's MAILBOX. + "MailCompose", "MailMenu", "MailRead", "MailboxMenu", + -- Gen2MapRadio is the in-house wall radio (`special MapRadio`), not a card. + "MapRadio", + "MainMenu", "MartMenu", "MoveDeleter", "NamePick", "NamingScreen", "OakSpeech", + "OptionsMenu", "PackMenu", "PartyMenu", "PcMenu", "PhotoStudio", "PokedexMenu", + "Pokegear", "SaveMenu", "ScriptMenu", "SlotMachine", + "StartMenu", "SummaryMenu", "TitleState", "TradeAnim", "TradeMenu", + "TrainerCard", + -- Gen2UnownPrinter is the ALPH RUINS STAMP viewer, not the print itself. + "UnownPrinter", "UnownPuzzle", +} + +-- 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 +end + local cache = {} local function builtinFor(id) @@ -40,7 +101,12 @@ function Screens.get(game, id) return resolve(game, id) end -function Screens.push(game, id, ...) +-- Resolve and construct, without touching the stack. Not every screen is +-- stacked the moment it is built: the Gen 1 battle queue defers its UI rows, +-- and Gold's mart holds a PACK instance for the whole sell flow +-- (src/ui/gen2/MartMenu.lua). Those composers need the registry lookup, the +-- screenId stamp and the same degrade a push gets, so both paths share this. +local function build(game, id, ...) local factory = resolve(game, id) local inst if factory.__modOwned then @@ -58,6 +124,15 @@ function Screens.push(game, id, ...) inst = factory.new(game, ...) end inst.screenId = inst.screenId or id + return inst +end + +function Screens.build(game, id, ...) + return build(game, id, ...) +end + +function Screens.push(game, id, ...) + local inst = build(game, id, ...) game.stack:push(inst) return inst end diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index f628c1db..c83292c0 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -324,7 +324,7 @@ function TitleState:updateSequence() elseif self.phase == "settle" then self.timer = self.timer + 1 if self.timer >= SETTLE_FRAMES then - Sound.play(data, "Intro_Whoosh") + self.whooshSrc = Sound.play(data, "Intro_Whoosh") self.showBubble = true self.phase = self.yellowLayout and "bubble" or "ribbon" self.ribbonOffset = RIBBON_FRAMES[1] @@ -336,7 +336,17 @@ function TitleState:updateSequence() if offset then self.ribbonOffset = offset else + -- title.asm: Delay3 then WaitForSoundToFinish before MUSIC_TITLE_SCREEN self.ribbonOffset = nil + self.phase = "preMusic" + self.timer = 0 + end + elseif self.phase == "preMusic" then + self.timer = self.timer + 1 + local playing = self.whooshSrc and self.whooshSrc.isPlaying + and self.whooshSrc:isPlaying() + if self.timer >= 3 and (not playing or self.timer > 180) then + self.whooshSrc = nil self:startMusic() self.phase = "loop" self.timer = 0 diff --git a/src/ui/gen2/BankOfMom.lua b/src/ui/gen2/BankOfMom.lua new file mode 100644 index 00000000..8ebf1427 --- /dev/null +++ b/src/ui/gen2/BankOfMom.lua @@ -0,0 +1,153 @@ +-- The six-digit money keypad BankOfMom's GET and SAVE both put up +-- (engine/events/mom.asm Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu / +-- Mom_WithdrawDepositMenuJoypad). Not a generic ScriptMenu: nothing else in +-- the game edits a number digit by digit, so this is its own screen the way +-- the naming keyboard and the move-list are theirs. +-- +-- Layout, from the ASM's own coordinates: +-- `hlcoord 0, 0 / lb bc, 6, 18 / call Textbox` -- an interior 18x6 box at +-- (0,0), i.e. a 20x8 outer box. +-- (1,2) "SAVED@" / (12,2) wMomsMoney, PRINTNUM_MONEY | 3 bytes, width 6 +-- (1,4) "HELD@" / (12,4) wMoney, PRINTNUM_MONEY | 3 bytes, width 6 +-- (1,6) "DEPOSIT@" or "WITHDRAW@" / (12,6) the typed amount, +-- PRINTNUM_MONEY | PRINTNUM_LEADINGZEROS | 3, width 6 +-- PRINTNUM_MONEY puts the yen sign right before the field and the six digits +-- after it, so column 12 is the yen and 13..18 are the digits -- which is +-- where Mom_WithdrawDepositMenuJoypad's blinking cursor (`hlcoord 13, 6` plus +-- wMomBankDigitCursorPosition) lands. +-- +-- Mom_WithdrawDepositMenuJoypad's own joypad loop: UP/DOWN add or subtract +-- the place value under the cursor (through GiveMoney/TakeMoney, so a digit +-- clamps at 999999 or 0 rather than wrapping), LEFT/RIGHT move the cursor, +-- A accepts, B cancels. wMomBankDigitCursorPosition starts at 5 -- the ones +-- digit, rightmost -- which is `.DigitQuantities`' own indexing: position 0 +-- is the hundred-thousands digit, position 5 is the ones digit. Only the +-- table's first six entries are ever read (`.getdigitquantity` always starts +-- at `.DigitQuantities` and offsets by the cursor position alone); the two +-- further groups of six the ASM lays down after it are unreachable from this +-- routine and are not carried here. + +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") + +local BankOfMom = {} +BankOfMom.__index = BankOfMom +BankOfMom.isOpaque = false + +local BOX_X, BOX_Y, BOX_W, BOX_H = 0, 0, 20, 8 +local SAVED_LABEL_X, SAVED_Y = 1, 2 +local HELD_LABEL_X, HELD_Y = 1, 4 +local KIND_LABEL_X, KIND_Y = 1, 6 +local MONEY_X = 12 +local DIGIT_X = 13 -- first digit column; DIGIT_X + position is the cursor + +local MAX_MONEY = 999999 + +-- `.DigitQuantities`' first (and only reachable) six entries, 10^5..10^0. +local PLACE_VALUES = { 100000, 10000, 1000, 100, 10, 1 } + +-- No line marker in any of these four (Mom_SavedString, Mon_WithdrawString, +-- Mom_DepositString, Mom_HeldString are one word each), so they are plain +-- literals here the way MartMenu's BUY/SELL/CANCEL labels are. +local SAVED_LABEL = "SAVED" +local HELD_LABEL = "HELD" +local DEPOSIT_LABEL = "DEPOSIT" +local WITHDRAW_LABEL = "WITHDRAW" + +-- charmap.asm: ¥ is the currency glyph, same one MartMenu's moneyText uses. +local YEN = "\xc2\xa5" + +-- PrintNum with PRINTNUM_MONEY, no PRINTNUM_LEADINGZEROS: the ¥ floats to +-- just before the first significant digit and the field stays 6 digits wide. +local function moneyText(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end + +-- PrintNum with PRINTNUM_MONEY | PRINTNUM_LEADINGZEROS: all six digits shown. +local function moneyTextZeroed(amount) + return YEN .. ("%06d"):format(math.max(0, math.floor(amount or 0))) +end + +-- opts: kind ("deposit" | "withdraw"), saved (wMomsMoney), held (wMoney), +-- onDone(amount) -- nil for B +function BankOfMom.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BankOfMom) + self.game = game + self.data = (game and game.data) or {} + self.kind = opts.kind or "deposit" + self.saved = opts.saved or 0 + self.held = opts.held or 0 + self.onDone = opts.onDone + self.amount = 0 + self.position = 5 -- ones digit, rightmost + self.blink = 0 + return self +end + +function BankOfMom:wantsFillScale() return true end + +function BankOfMom:finish(amount) + if self.done then return end + self.done = true + if self.onDone then self.onDone(amount) end +end + +function BankOfMom: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 BankOfMom:update(dt) + if self.done then return end + self.blink = self.blink + (dt or 0) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") then + self.amount = math.min(self.amount + PLACE_VALUES[self.position + 1], + MAX_MONEY) + elseif input:wasPressed("down") then + self.amount = math.max(self.amount - PLACE_VALUES[self.position + 1], 0) + elseif input:wasPressed("left") then + self.position = math.max(0, self.position - 1) + elseif input:wasPressed("right") then + self.position = math.min(5, self.position + 1) + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:finish(self.amount) + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:finish(nil) + end +end + +function BankOfMom:drawPanel() + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + Chrome.print(SAVED_LABEL, SAVED_LABEL_X, SAVED_Y) + Chrome.print(moneyText(self.saved), MONEY_X, SAVED_Y) + Chrome.print(HELD_LABEL, HELD_LABEL_X, HELD_Y) + Chrome.print(moneyText(self.held), MONEY_X, HELD_Y) + Chrome.print(self.kind == "withdraw" and WITHDRAW_LABEL or DEPOSIT_LABEL, + KIND_LABEL_X, KIND_Y) + Chrome.print(moneyTextZeroed(self.amount), MONEY_X, KIND_Y) + -- `hlcoord 13, 6 / ... / ld [hl], ' '` blanks the digit under the cursor + -- for one beat every `hVBlankCounter and $10` window; a plain white square + -- over that one tile every half second reads the same without a real + -- VBlank counter to poll it against. + if math.floor(self.blink * 2) % 2 == 1 then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", + (DIGIT_X + self.position) * 8, KIND_Y * 8, 8, 8) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function BankOfMom:draw() + self:drawPanel() +end + +return BankOfMom diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua new file mode 100644 index 00000000..38f848dd --- /dev/null +++ b/src/ui/gen2/BattleAnimView.lua @@ -0,0 +1,421 @@ +-- Drawing for the Gen 2 battle-animation runtime. +-- +-- src/battle/gen2/AnimRunner.lua produces two things a frame: a list of OAM +-- entries (the OBJ layer) and a set of BG register writes. This turns them +-- into draw calls, and it is the only half of the runtime that touches love. +-- +-- The BG half is the interesting one. A Gen 2 battle animation shakes and +-- sinks its mons through wLYOverridesBackup -- a per-scanline value the LCD +-- STAT interrupt writes into rSCX or rSCY as the beam passes -- so the port +-- draws the battle panel into a canvas and then blits it back one scanline at +-- a time at that scanline's own offset. 144 quads a frame is nothing, and it +-- is the only model that gets Tackle (every row of the attacker moves the same +-- way) and Withdraw (a growing number of rows are pushed off while the rest +-- stay) both right out of the same data. +-- +-- OBJs are NOT affected by SCX/SCY, so they are drawn after the blit, at their +-- own coordinates. + +local bit = require("bit") +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") + +local BattleAnimView = {} +BattleAnimView.__index = BattleAnimView + +local SCREEN_W, SCREEN_H = 160, 144 + +-- An OBJ at OAM (x, y) draws at (x - 8, y - 16). +local OAM_X_BIAS, OAM_Y_BIAS = 8, 16 + +local OAM_YFLIP, OAM_XFLIP = 0x40, 0x20 + +-- Every coordinate the runtime produces is a byte, and the ones that mean +-- "left of / above the origin" arrive as two's complement. +local function signed(value) + value = (value or 0) % 256 + return value < 0x80 and value or value - 256 +end + +-- data: the cache's battle_anims.lua; palettes: the cache's palettes.lua +function BattleAnimView.new(data, palettes) + local self = setmetatable({}, BattleAnimView) + self.data = data or {} + self.palettes = palettes + self.images = {} + self.quads = {} + self.canvas = nil + self.blitQuad = nil + return self +end + +function BattleAnimView:image(path) + if not path then return nil end + local cached = self.images[path] + if cached == nil then + -- `and` would truncate pcall's second return, so this cannot fold into a + -- one-liner: every sheet would come back "unavailable". + local ok, image = pcall(Assets.image, path) + self.images[path] = (ok and image) or false + cached = self.images[path] + end + return cached or nil +end + +function BattleAnimView:quad(sheetName, index, wide, image) + local key = sheetName .. ":" .. index + local quad = self.quads[key] + if not quad then + local w, h = image:getDimensions() + quad = love.graphics.newQuad( + (index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h) + self.quads[key] = quad + end + return quad +end + +-- Which loaded sheet a tile id falls in. The runner's `loaded` list is in +-- load order and each entry knows its base tile and its length, which is the +-- same walk GetBattleAnimTileOffset does in reverse. +local function sheetForTile(runner, tile) + for i = #runner.loaded, 1, -1 do + local entry = runner.loaded[i] + if tile >= entry.tile and tile < entry.tile + math.max(entry.tiles, 1) then + return entry, tile - entry.tile + end + end + return nil +end + +-- PAL_BATTLE_OB_ENEMY and PAL_BATTLE_OB_PLAYER are the two battlers' own +-- colours; the other six are the fixed block from +-- gfx/battle_anims/battle_anims.pal, which the extractor reads into +-- palettes.battleObjects. +function BattleAnimView:objPalette(name, battle) + if name == "PAL_BATTLE_OB_ENEMY" then + local enemy = battle and battle.enemy + return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny) + end + if name == "PAL_BATTLE_OB_PLAYER" then + local player = battle and battle.player + return player and Palettes.monColors(self.palettes, player.species, player.shiny) + end + local set = self.palettes and self.palettes.battleObjects + return set and set[name] or nil +end + +-- One frame's OBJ layer. +function BattleAnimView:drawObjects(runner, battle) + local G = love.graphics + G.setColor(1, 1, 1, 1) + for _, obj in ipairs(runner:oam()) do + local entry, index = sheetForTile(runner, obj.tile) + -- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in + -- the cache holds them as a sheet, so they are simply not drawn rather + -- than drawn from the wrong image. + if entry and not entry.battler then + local sheet = (self.data.gfx or {})[entry.gfx] + local image = sheet and self:image(sheet.image) + if image then + local wide = sheet.wide or 8 + local quad = self:quad(entry.gfx, index, wide, image) + local _, sy = quad:getViewport() + local _, ih = image:getDimensions() + if sy < ih then + local x = obj.x - OAM_X_BIAS + local y = obj.y - OAM_Y_BIAS + -- love flips about the draw origin, so a flipped 8x8 sprite has to + -- be pushed one cell back along that axis. + local sxScale = bit.band(obj.attr, OAM_XFLIP) ~= 0 and -1 or 1 + local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1 + local ox = sxScale < 0 and 8 or 0 + local oy = syScale < 0 and 8 or 0 + local colors = self:objPalette(obj.palette, battle) + local function body() + G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + end + end + end + end +end + +-- True when the BG layer needs the scanline treatment at all; a plain +-- animation (most of them) skips the canvas entirely. +local function needsCanvas(runner) + local bg = runner.bg + if bg.scx ~= 0 or bg.scy ~= 0 then return true end + if not bg.lcdc then return false end + if bg.lyEnd <= bg.lyStart then return false end + for row = bg.lyStart, math.min(bg.lyEnd, SCREEN_H) - 1 do + if (bg.lyBackup[row] or 0) ~= 0 then return true end + end + return false +end + +-- The battle background is BG colour 0 everywhere the two pic boxes and the +-- text box are not, which Chrome.clear draws as plain white; anything the +-- scanline blit exposes has to be that colour and not a hole. +function BattleAnimView:fillBackground() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) +end + +-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn +-- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: +-- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. +function BattleAnimView:blitRow(row, dx, dy) + local canvas = self.canvas + if not self.blitQuad then + self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) + end + local srcX, width, destX = 0, SCREEN_W, dx + if dx > 0 then + width = SCREEN_W - dx + elseif dx < 0 then + srcX, width, destX = -dx, SCREEN_W + dx, 0 + end + if width <= 0 then return end + self.blitQuad:setViewport(srcX, row, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, row + dy) +end + +-- Draw the battle panel into the blit canvas, optionally with an rBGP byte +-- folded into every palette on the way in. +-- +-- The byte is set on GbcPalette rather than applied afterwards, which is what +-- makes it exact: the panel is still being DRAWN, so its palettes can take +-- CopyPals' permutation before they ever reach the shader. That is bit for bit +-- what DmgToCgbBGPals does, and it is why nothing here has to guess which +-- palette entry produced a finished pixel. +-- +-- pcall so a drawBg that throws cannot leave the byte standing on GbcPalette +-- for the rest of the frame. +function BattleAnimView:bake(drawBg, palByte) + local G = love.graphics + if not self.canvas then + self.canvas = G.newCanvas(SCREEN_W, SCREEN_H) + self.canvas:setFilter("nearest", "nearest") + end + local previousCanvas = G.getCanvas() + local previousBgp = GbcPalette.setBgp(palByte) + G.setCanvas(self.canvas) + G.clear(0, 0, 0, 0) + -- A love canvas does NOT reset the transform: without this the panel is + -- drawn at whatever scale and offset the caller was already under, and then + -- scaled again on the way back out. + G.push() + G.origin() + local ok, err = pcall(drawBg) + G.pop() + G.setCanvas(previousCanvas) + GbcPalette.setBgp(previousBgp) + if not ok then error(err, 0) end +end + +-- The rBGP window's scanlines grouped by the byte they hold, identity first. +-- +-- `.SetLYOverridesBackup` writes one value on every SECOND row and leaves the +-- rest on whatever ClearLYOverrides put there, so a beta send-out frame holds +-- two bytes and FadeMonsToBlackRepeating's DMG path holds two bands of one +-- each. Grouping means the panel is re-baked twice a frame rather than 144 +-- times, and the grouping is by VALUE so a table that happens to repeat costs +-- nothing extra. +local function bgpBands(bg) + local order, bands = {}, {} + for row = 0, SCREEN_H - 1 do + local inWindow = row >= bg.lyStart and row < bg.lyEnd + -- Outside the window the register still reads whatever wBGP holds, which + -- for every effect that aims hLCDCPointer at rBGP is the identity. + local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY) + or GbcPalette.BGP_IDENTITY + local band = bands[byte] + if not band then + band = { byte = byte, rows = {} } + bands[byte] = band + order[#order + 1] = band + end + band.rows[#band.rows + 1] = row + end + -- Identity first so the fillBackground below it happens before any blit and + -- the common band is the one drawn from the first bake. + table.sort(order, function(a, b) + if a.byte == b.byte then return false end + if a.byte == GbcPalette.BGP_IDENTITY then return true end + if b.byte == GbcPalette.BGP_IDENTITY then return false end + return a.rows[1] < b.rows[1] + end) + return order +end + +-- Runs `drawBg` (the battle panel) and then puts it on screen through the +-- animation's BG registers. Returns without a canvas when nothing is +-- displacing anything, which is the common case and costs nothing. +function BattleAnimView:present(runner, drawBg) + if not (love and love.graphics) then return end + local bg = runner.bg + if not needsCanvas(runner) then + drawBg() + return + end + local G = love.graphics + + -- hLCDCPointer can also aim at rBGP, in which case each scanline gets its own + -- PALETTE rather than its own scroll (the beta send-out effects, and + -- FadeMonsToBlackRepeating on the DMG path). Nothing scrolls on that path, + -- so the whole of it is: bake the panel once per distinct byte and blit that + -- byte's rows out of it. + if bg.lcdc == "BGP" and GbcPalette.available() then + local baseX, baseY = -signed(bg.scx), -signed(bg.scy) + local bands = bgpBands(bg) + local filled = false + for _, band in ipairs(bands) do + self:bake(drawBg, band.byte) + if not filled then + self:fillBackground() + filled = true + end + G.setColor(1, 1, 1, 1) + for _, row in ipairs(band.rows) do + self:blitRow(row, baseX, baseY) + end + end + return + end + + self:bake(drawBg, nil) + + -- A shifted scanline exposes whatever the BG map holds beside the pic, which + -- outside the two pic boxes is the blank tile. Without this the exposed + -- strip is the canvas's own transparency and every shake shows a seam. + self:fillBackground() + G.setColor(1, 1, 1, 1) + -- hSCX / hSCY move the whole background; the per-scanline overrides only + -- apply inside the effect's own window. + local baseX, baseY = -signed(bg.scx), -signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local dx, dy = baseX, baseY + local inWindow = bg.lcdc and row >= bg.lyStart and row < bg.lyEnd + if inWindow and bg.lcdc ~= "BGP" then + local value = signed(bg.lyBackup[row] or 0) + if bg.lcdc == "SCX" then dx = -value else dy = -value end + end + -- A row scrolled to $90 is showing a blank part of the map: skip it, which + -- is what makes Withdraw and Dig look like the mon sinking out of sight. + if (bg.lyBackup[row] or 0) ~= 0x90 or not bg.lcdc or bg.lcdc == "BGP" + or not inWindow then + self:blitRow(row, dx, dy) + end + end + -- Shaderless boot: the panel is raw grayscale, so there are no palettes to + -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. + if bg.lcdc == "BGP" then + for row = math.max(0, bg.lyStart), math.min(bg.lyEnd, SCREEN_H) - 1 do + local veil = BattleAnimView.palVeil(bg.lyBackup[row]) + if veil ~= 0 then + local shade = veil > 0 and 0 or 1 + G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) + G.rectangle("fill", 0, row, SCREEN_W, 1) + end + end + G.setColor(1, 1, 1, 1) + end +end + +-- A DMG palette byte's mean shade against the identity %11100100, signed: +-- +1 is solid black ($ff), -1 solid white ($00), 0 the identity. The +-- approximation, kept for the shaderless path in `present` above. +function BattleAnimView.palVeil(palByte) + if not palByte then return 0 end + local sum = 0 + for index = 0, 3 do + sum = sum + math.floor(palByte / (4 ^ index)) % 4 + end + return (sum - 6) / 6 +end + +-------------------------------------------------------------------------- +-- The battle intro slide (engine/battle/sliding_intro.asm) +-------------------------------------------------------------------------- + +-- BattleIntroSlidingPics runs 72 frames. It holds three SCX values at once: +-- scanlines 0-$3f take `c`, which starts at $90 and falls by 2 a frame, so +-- the enemy's half enters from the RIGHT; $40-$5f take `b`, which starts at +-- $70 and RISES by 2, wrapping to 0 on the last frame, so the player's half +-- enters from the LEFT; everything from $60 down is already in place. +BattleAnimView.SLIDE_FRAMES = 72 + +function BattleAnimView.slideOffsets(frame) + local step = math.max(0, math.min(BattleAnimView.SLIDE_FRAMES, frame)) + local top = (0x90 - step * 2) % 256 + local middle = (0x70 + step * 2) % 256 + return top, middle +end + +-- The pixels the cart's OAM back-pic copy still has to travel at `frame`: +-- CopyBackpic parks 18 sprites just off the RIGHT edge (x = 168 in OAM +-- terms) and the slide's `.subfunction1` walks every one left 2px a frame, +-- so the whole pic crosses 144px to its resting column in the 72 frames. +function BattleAnimView.slideBackpicOffset(frame) + local step = math.max(0, math.min(BattleAnimView.SLIDE_FRAMES, frame)) + return (BattleAnimView.SLIDE_FRAMES - step) * 2 +end + +-- The same band-at-a-time blit `present` uses, with the intro's own offsets. +-- +-- `drawBackpic(offset)`, when given, draws the player's back pic OVER the +-- bands the way the cart's OAM copy rides over them: InitBattleDisplay clears +-- the pic's top rows out of the BG before the slide (the hlcoord 1, 5 +-- ClearBox) precisely because the pic straddles the $40 scanline where the +-- two bands part ways -- baked into the bands it tears in half there, its top +-- rows riding the enemy's offset and its bottom rows the player's. The +-- caller must leave the pic OUT of drawBg and hand it here instead. +function BattleAnimView:presentSlide(frame, drawBg, drawBackpic) + if not (love and love.graphics) then return end + local top, middle = BattleAnimView.slideOffsets(frame) + if top == 0 and middle == 0 then + drawBg() + if drawBackpic then drawBackpic(0) end + return + end + local G = love.graphics + self:bake(drawBg, nil) + self:fillBackground() + G.setColor(1, 1, 1, 1) + for row = 0, SCREEN_H - 1 do + local scx = 0 + if row < 0x40 then + scx = top + elseif row < 0x60 then + scx = middle + end + -- SCX is unsigned and the map wraps at 256, so a value over half the + -- screen reads as "coming in from the other side" rather than as a jump. + local dx = -scx + if scx > 128 then dx = 256 - scx end + self:blitRow(row, dx, 0) + end + if drawBackpic then + drawBackpic(BattleAnimView.slideBackpicOffset(frame)) + end +end + +-- A DMG palette byte read as a shade remap, so a BG effect that fades a mon +-- through $f8/$fc can be applied to that mon's four CGB colours. The same +-- CopyPals permutation `present` now folds into the whole panel, which is why +-- it lives in GbcPalette and this is a name for it rather than a second copy. +function BattleAnimView.shadeColors(colors, palByte) + return GbcPalette.remap(colors, palByte) +end + +BattleAnimView.SCREEN_W = SCREEN_W +BattleAnimView.SCREEN_H = SCREEN_H + +return BattleAnimView diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua new file mode 100644 index 00000000..00afb61b --- /dev/null +++ b/src/ui/gen2/BattleHud.lua @@ -0,0 +1,291 @@ +-- The battle HUD, drawn from the cart's own tiles. +-- +-- The HUD is not lines and boxes an engine invents: it is tiles the cart loads +-- into fixed VRAM slots (engine/gfx/load_font.asm LoadBattleFontsHPBar / +-- LoadHPBar), placed at fixed tile coordinates. Drawing it from those tiles is +-- what makes it align on the 8px grid by construction rather than by eye, and +-- it is why "HP:" looks like the cart's bold glyph instead of three font +-- letters. +-- +-- FontBattleExtra -> $60 "HP:" is $60/$61; the bar's cells are $62 +-- (empty) through $6a (8 pixels of fill), and +-- $6b is the bar's right end cap +-- EnemyHPBarBorderGFX -> $6c 4 tiles: $6d left side, $6f bottom left +-- HPExpBarBorderGFX -> $73 6 tiles: $73 right side, $74 bottom left, +-- $76 bottom side, $77 / $78 bottom right +-- ExpBarGFX -> $55 9 exp-bar fill cells +-- +-- The HP-bar cells are 2bpp: the bar's rule is shade 3 and its fill shades 1-2, +-- so they are coloured through palettes.hpBar (gfx/battle/hp_bar.pal) the same +-- way the cart colours PAL_BATTLE_BG_PLAYER_HP. The two border sheets are +-- 1bpp and draw black. + +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") +local HpBar = require("src.battle.gen2.HpBar") + +local BattleHud = {} +BattleHud.__index = BattleHud + +-- Tile ids, so the arithmetic below reads as the ASM does. +local TILE_HP_LABEL = 0x60 -- and $61 +local TILE_BAR_EMPTY = 0x62 -- $62..$6a is 0..8 pixels of fill +local TILE_BAR_END = 0x6b +local FIRST_BATTLE_EXTRA = 0x60 + +-- Enemy border sheet ($6c..$6f) and player border sheet ($73..$78). +local ENEMY_BORDER_FIRST = 0x6c +local PLAYER_BORDER_FIRST = 0x73 +local TILE_ENEMY_LEFT = 0x6d +local TILE_ENEMY_BOTTOM_LEFT = 0x74 -- from the player sheet, per the ASM +local TILE_ENEMY_BOTTOM_RIGHT = 0x78 +local TILE_BOTTOM_SIDE = 0x76 +local TILE_PLAYER_RIGHT = 0x73 +local TILE_PLAYER_BOTTOM_RIGHT = 0x77 +local TILE_PLAYER_BOTTOM_LEFT = 0x6f + +-- DrawEnemyHUDBorder's tail: ExpBarGFX's 9th tile, the caught mark +-- (engine/battle/trainer_huds.asm:143-152). +local TILE_CAUGHT = 0x5d + +function BattleHud.new(menuGfx, palettes) + local self = setmetatable({}, BattleHud) + self.gfx = menuGfx and menuGfx.battleHud or nil + self.palettes = palettes + self.images = {} + self.quads = {} + return self +end + +function BattleHud:image(key) + local path = self.gfx and self.gfx[key] + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.images[path] = cached + end + return cached or nil +end + +-- One 8x8 tile out of a horizontal strip, cached per (sheet, index). +function BattleHud:quad(image, index) + local key = tostring(image) .. ":" .. index + local quad = self.quads[key] + if not quad then + local w, h = image:getDimensions() + quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h) + self.quads[key] = quad + end + return quad +end + +function BattleHud:available() + return self:image("hpBar") ~= nil +end + +-- The 4-colour palette the HP-bar cells draw with: white, the bar's own light +-- colour, the state's fill colour, black -- which is HPBarPals' two colours +-- bracketed the way every Gen 2 palette is. +function BattleHud:barColors(key) + local pal = self.palettes and self.palettes.hpBar and self.palettes.hpBar[key] + if not pal then return nil end + return { + { 255, 255, 255 }, + { pal[1][1], pal[1][2], pal[1][3] }, + { pal[2][1], pal[2][2], pal[2][3] }, + { 0, 0, 0 }, + } +end + +-- Draw a run of tiles from a sheet whose first tile is `firstTile`, colouring +-- with `colors` when one is given. +function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror) + local image = self:image(key) + if not image then return false end + local index = tile - firstTile + if index < 0 then return false end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local function body() + if mirror then + -- Flip in place: the origin moves a tile right and x scales by -1. + G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1) + else + G.draw(image, self:quad(image, index), tx * 8, ty * 8) + end + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + G.setColor(0, 0, 0, 1) + body() + G.setColor(1, 1, 1, 1) + end + return true +end + +-- Six bar cells at (tx, ty), no label and no end cap. +-- +-- This is DrawBattleHPBar itself: the party menu calls it with `ld d, $6` and +-- `ld b, $0` (PlacePartyHPBar), so a party row's bar is literally the battle +-- HUD's bar minus the "HP:" prefix -- same tiles, same HPBarPals colour, same +-- one-pixel-at-a-time fill. Sharing this method is what keeps the two screens +-- from ever disagreeing about how full a bar looks. +function BattleHud:drawBar(hp, maxHp, tx, ty) + local pixels = HpBar.pixels(hp, maxHp) + local colors = self:barColors(HpBar.palette(pixels)) + for cell = 0, HpBar.LENGTH_TILES - 1 do + local remaining = pixels - cell * 8 + local filled = math.max(0, math.min(8, remaining)) + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_EMPTY + filled, + tx + cell, ty, colors) + end + return tx + HpBar.LENGTH_TILES +end + +-- "HP:" plus the six bar cells plus the end cap, starting at tile (tx, ty). +-- Returns the column just past the assembly (tx + 9), so the caller can put the +-- frame's vertical stub there. +function BattleHud:drawHpBar(hp, maxHp, tx, ty) + local pixels = HpBar.pixels(hp, maxHp) + local colors = self:barColors(HpBar.palette(pixels)) + -- The "HP:" badge sits inside the bar's own attrmap region, so it wears the + -- HP palette too: its background is HPBarPals' light colour (the cream the + -- cart shows) and its letters are black. Drawing it white-on-black was the + -- one place this HUD invented a colour instead of reading one. + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL, tx, ty, colors) + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL + 1, tx + 1, ty, + colors) + self:drawBar(hp, maxHp, tx + 2, ty) + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_END, + tx + 2 + HpBar.LENGTH_TILES, ty, colors) + return tx + 3 + HpBar.LENGTH_TILES +end + +-- The exp bar, transcribed from FillInExpBar / PlaceExpBar rather than guessed +-- from the tile art: +-- +-- FillInExpBar starts at (10,11), adds 7 to reach the RIGHTMOST tile, then +-- PlaceExpBar writes 8 tiles walking LEFT (ld [hld]): +-- * while at least 8 pixels remain: tile $6a, the HP bar's full cell +-- * the leftover 1..7 pixels: tile $54 + remainder, i.e. $55..$5b from +-- ExpBarGFX -- which is why those cells anchor their fill to the right, +-- against the full cells beside them +-- * every remaining cell: tile $62, the HP bar's empty cell +-- +-- So the bar is eight tiles wide (64 pixels), it grows from the RIGHT, and two +-- of its three tiles come from FontBattleExtra rather than ExpBarGFX. Getting +-- any of those three facts wrong is what makes it look like dashes. +BattleHud.EXP_CELLS = 8 +BattleHud.EXP_LENGTH_PX = BattleHud.EXP_CELLS * 8 +local TILE_EXP_FULL = 0x6a -- FontBattleExtra +local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra +local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX + +function BattleHud:drawExpBar(fraction, tx, ty) + if not self:image("hpBar") then return false end + fraction = math.max(0, math.min(1, fraction or 0)) + local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) + -- The whole row wears the exp bar's palette, full and empty cells included. + local pal = self.palettes and self.palettes.expBar + local colors = pal and { + { 255, 255, 255 }, + { pal[1][1], pal[1][2], pal[1][3] }, + { pal[2][1], pal[2][2], pal[2][3] }, + { 0, 0, 0 }, + } or nil + + local remaining = pixels + for cell = BattleHud.EXP_CELLS - 1, 0, -1 do + local column = tx + cell + if remaining >= 8 then + remaining = remaining - 8 + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_EXP_FULL, column, ty, + colors) + elseif remaining > 0 then + self:drawTile("expBar", self.gfx.expBarFirstTile, + EXP_PARTIAL_BASE + remaining, column, ty, colors) + remaining = 0 + else + self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_EXP_EMPTY, column, ty, + colors) + end + end + return true +end + +-- The mark sits inside the enemy HP block, which the battle attrmap fills with +-- PAL_BATTLE_BG_ENEMY_HP (engine/gfx/cgb_layouts.asm:123-128). +function BattleHud:drawCaughtIcon(tx, ty, hp, maxHp) + local first = self.gfx and self.gfx.expBarFirstTile + if not first or (self.gfx.expBarCells or 0) < 9 then return false end + local colors = self:barColors(HpBar.palette(HpBar.pixels(hp, maxHp))) + if not colors then return false end + return self:drawTile("expBar", first, TILE_CAUGHT, tx, ty, colors) +end + +-- Both frames come out of PlaceHUDBorderTiles, which lays four tiles in a +-- fixed pattern from one starting coordinate: +-- +-- tiles[0] (side) at the start +-- tiles[1] (near corner) one row BELOW it, same column +-- tiles[3] (bottom side) x8, stepping by de (+1 right, -1 left) +-- tiles[2] (far corner) one step past that run +-- +-- Getting the corner's row wrong is what leaves the vertical stub floating +-- clear of the bottom rule instead of joined to it. +function BattleHud:placeBorder(tiles, tx, ty, step) + -- side + self:drawTile(tiles.sideSheet, tiles.sideFirst, tiles.side, tx, ty) + -- near corner, one row down + self:drawTile(tiles.cornerSheet, tiles.cornerFirst, tiles.nearCorner, + tx, ty + 1) + -- eight bottom-side tiles, then the far corner + local x = tx + for _ = 1, 8 do + x = x + step + self:drawTile(tiles.cornerSheet, tiles.cornerFirst, tiles.bottom, + x, ty + 1) + end + x = x + step + self:drawTile(tiles.farSheet or tiles.cornerSheet, + tiles.farFirst or tiles.cornerFirst, tiles.farCorner, x, ty + 1) +end + +-- DrawEnemyHUDBorder: hlcoord 1, 2 stepping right, tiles $6d / $74 / $78 / $76. +function BattleHud:drawEnemyFrame() + self:placeBorder({ + sideSheet = "enemyBorder", sideFirst = ENEMY_BORDER_FIRST, + cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, + side = TILE_ENEMY_LEFT, + nearCorner = TILE_ENEMY_BOTTOM_LEFT, + farCorner = TILE_ENEMY_BOTTOM_RIGHT, + bottom = TILE_BOTTOM_SIDE, + }, 1, 2, 1) +end + +-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f / +-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub +-- is two rows tall. +function BattleHud:drawPlayerFrame() + self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9) + self:placeBorder({ + sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, + cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, + -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet + -- (engine/gfx/load_font.asm:57-65). + farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, + side = TILE_PLAYER_RIGHT, + nearCorner = TILE_PLAYER_BOTTOM_RIGHT, + farCorner = TILE_PLAYER_BOTTOM_LEFT, + bottom = TILE_BOTTOM_SIDE, + }, 18, 10, -1) +end + +BattleHud.TILE_HP_LABEL = TILE_HP_LABEL +BattleHud.TILE_BAR_EMPTY = TILE_BAR_EMPTY +BattleHud.TILE_BAR_END = TILE_BAR_END + +return BattleHud diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua new file mode 100644 index 00000000..80ad5872 --- /dev/null +++ b/src/ui/gen2/BattleState.lua @@ -0,0 +1,3150 @@ +-- The Gen 2 battle screen. +-- +-- All logic lives in src/battle/gen2/Battle.lua; this only draws it and feeds +-- it actions. That split is deliberate: the engine emits an event queue, so a +-- test can assert a whole battle without a window and this file stays about +-- layout and pacing. +-- +-- Layout follows the cart (engine/battle/core.asm's HUD placement): the enemy's +-- name/level and HP bar top-left with its pic top-right, the player's pic +-- bottom-left with its HUD bottom-right, and the message box across the bottom +-- two rows. FIGHT/PACK/POKéMON/RUN sit in that box when it is the player's +-- turn. + +local AnimRunner = require("src.battle.gen2.AnimRunner") +local Assets = require("src.render.Assets") +local Battle = require("src.battle.gen2.Battle") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local BattleHud = require("src.ui.gen2.BattleHud") +local BattleMusic = require("src.battle.gen2.BattleMusic") +local BerryJuice = require("src.battle.gen2.BerryJuice") +local Boxes = require("src.core.gen2.Boxes") +local BugContest = require("src.core.gen2.BugContest") +local CatchTutorial = require("src.core.gen2.CatchTutorial") +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 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 Palettes = require("src.world.gen2.Palettes") +local Pokerus = require("src.core.gen2.Pokerus") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +-- Only for playerPic: the player.sprite raiser both generations share. +local Sprites = require("src.pokemon.Sprites") +local Strings = require("src.core.Strings") +local Unown = require("src.core.gen2.Unown") + +local BattleState = {} +BattleState.__index = BattleState +BattleState.isOpaque = true + +-- How long a message stays before the next event runs, in logic steps. The +-- cart waits for A on most lines; holding A skips faster, same as text boxes. +local MESSAGE_FRAMES = 48 + +-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. +local HM_MOVES = { + CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true, + WATERFALL = true, WHIRLPOOL = true, +} + +-- CheckReceivedDex's ENGINE_POKEDEX (home/flag.asm:97-102), read the way +-- StartMenu:availability reads it out of save.engineFlags. +local ENGINE_POKEDEX = 11 + +-- The message box's own two rows. PrintTextboxText plants the cursor at +-- (TEXTBOX_INNERX, TEXTBOX_INNERY) = tile (1,14) (home/text.asm:143), and +-- LineChar does NOT step one row: it reloads the cursor at TEXTBOX_INNERY + 2 +-- (home/text.asm:397), so a two-line battle string sits on rows 14 and 16 with +-- row 15 left blank. Paragraph's ClearBox wipes exactly rows 14-16 +-- (home/text.asm:411), which is why there is no third row to spill onto. +local TEXT_INNER_X = 1 +local TEXT_INNER_Y = 14 +local TEXT_WIDTH = 18 +local TEXT_ROWS = 2 +local TEXT_ROW_STEP = 2 + +-- SlideBattlePicOut (engine/battle/core.asm:2882) is called with a = 8: eight +-- one-tile steps with `ld c, 2 / call DelayFrames` between them, so the enemy +-- trainer's pic clears the box in 16 frames. +local TRAINER_SLIDE_STEPS = 8 +local TRAINER_SLIDE_FRAMES_PER_STEP = 2 +local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP + +-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation +-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic +-- corner: the pic's tilemap rows are copied DOWN one row per step and the row +-- it vacates is blanked, so what is left standing shrinks from the top while +-- the feet stay on the ground line -- the mon sinks out of the field. The step +-- is the same `ld c, 2 / call DelayFrames` SlideBattlePicOut uses, and the loop +-- runs the pic box's own height (7 rows for the enemy's 7x7 box, 6 for the +-- player's 6x6), so the pic is gone when it ends. +local FAINT_SLIDE_FRAMES_PER_ROW = 2 + +-- BattleText_TheresNoWillToBattle / BattleText_AnEGGCantBattle, the two lines +-- CheckIfCurPartyMonIsFitToFight prints before it returns zero +-- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249). +local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!" +local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!" + +-- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove +-- (data/text/battle.asm:315-322). +local TEXT_NO_PP_LEFT = "There's no PP left for this move!" +local TEXT_MOVE_DISABLED = "The move is DISABLED!" + +-- _MoveAskForgetText, _MoveCantForgetHMText and _StopLearningMoveText +-- (data/text/common_3.asm:124-134). +local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?") +local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.") +local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?") + +-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165). +local TEXT_ASK_FORGET_MOVE = Strings.source( + "%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." + .. "\fDelete an older\nmove to make room\vfor %s?") + +-- engine/battle/menu.asm BattleMenuHeader: a 2x2 grid at menu_coords 8, 12, +-- 19, 17 with 6 tiles of column spacing, filled row-major, so the order on +-- screen is FIGHT / PkMn on top and PACK / RUN below -- not the four-in-a-row +-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap +-- $e1/$e2), which is what makes it fit a six-tile column. +local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" } +local MENU_BOX_X = 8 +local MENU_COL_SPACING = 6 + +-- ContestBattleMenuHeader is the same 2x2 grid moved out to menu_coords 2, 12 +-- with 12 tiles of column spacing, because its third label is "PARKBALL×" and +-- the count PrintNum writes after it (two digits, leading zeros) at (13,16). +local CONTEST_MENU_BOX_X = 2 +local CONTEST_MENU_COL_SPACING = 12 +-- charmap.asm's quantity glyph, spelled the way MartMenu spells it. +local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97" + +-- data/items/heal_status.asm StatusHealingActions: the four rows whose status +-- mask is %11111111. HealStatus's `.not_full_heal` arm is what makes exactly +-- these also clear SUBSTATUS_CONFUSED, and IsItemUsedOnConfusedMon what lets +-- them be spent on a mon whose only complaint IS the confusion. +local FULL_MASK_HEALERS = { + FULL_HEAL = true, FULL_RESTORE = true, HEAL_POWDER = true, + MIRACLEBERRY = true, +} + +-- Collapses runs of spaces and tabs so a text assembled out of several pieces +-- prints as one flowing line. A "\n" is deliberately NOT touched: it is the +-- cart's own `line` control byte and Chrome.wrap honours it as a hard break, so +-- flattening it here would throw away a break the cart authored (the used-move +-- line, data/text/common_2.asm:339). +local function oneLine(text) + return (tostring(text or ""):gsub("[ \t]+", " ")) +end + +-- `para` and `cont` both PromptButton before they redraw, and `cont` scrolls +-- twice so the new page opens on the old page's last line (home/text.asm:403). +local PAGE, SCROLL, LINE = "\f", "\v", "\n" +local SEPARATORS = "([^" .. LINE .. PAGE .. SCROLL .. "]*)([" .. + LINE .. PAGE .. SCROLL .. "])" +local function paginate(text) + local pages, rows = {}, {} + local function flush(scroll) + if #rows > 0 then pages[#pages + 1] = table.concat(rows, LINE) end + rows = scroll and { rows[#rows] or "" } or {} + end + for chunk, sep in (tostring(text or "") .. PAGE):gmatch(SEPARATORS) do + rows[#rows + 1] = chunk + if sep == PAGE then flush(false) + elseif sep == SCROLL then flush(true) end + end + if #pages == 0 then pages[1] = tostring(text or "") end + return pages +end + +function BattleState:wantsFillScale() return true end +function BattleState:drawsWidescreen() return true end + +-- opts: battle (a Battle), onDone(outcome), save +function BattleState.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BattleState) + self.game = game + self.save = opts.save or (game and game.save) + local data = (game and game.data) or {} + self.palettes = data.gen2Palettes + self.pokemon = data.pokemon + self.battle = opts.battle + self.onDone = opts.onDone + -- What PlayVictoryMusic needs to know about the opponent (the class the + -- trainer belongs to); nil for a wild battle. + self.music = opts.music + -- BATTLETYPE_CONTEST (constants/battle_constants.asm): the park ball menu, + -- the caught mon being HELD in wContestMon rather than added to the party, + -- and CheckContestBattleOver's draw on the last ball. Set only by + -- World:tryContestEncounter. + self.contest = opts.contest and true or nil + -- BATTLETYPE_TUTORIAL: the DUDE's demonstration. No mon is sent out + -- (engine/battle/core.asm jumps straight to BattleMenu), the pack is his, + -- the ball cannot fail and nothing it catches is kept. Set only by + -- World:startCatchTutorial; src/core/gen2/CatchTutorial.lua has the rest. + self.tutorial = opts.tutorial and true or nil + self.queue = {} + self.message = nil + self.messageTimer = 0 + self.phase = "intro" -- intro | menu | moves | resolving | evolving | done + -- wEvolvableFlags (ram/wram.asm, one bit per party slot). + -- engine/battle/core.asm sets a mon's bit the moment it levels up, right + -- after its LearnLevelMoves run, and ExitBattle's EvolveAfterBattle sweep + -- only looks at flagged slots. start_battle.asm clears the array at the + -- start of every battle, which is why this lives on the screen and not on + -- the save. + self.evolvable = {} + self.menuIndex = 1 + self.moveIndex = 1 + self.picCache = {} + -- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_ + -- ReturnMon's last row (what swallows a mon into a thrown ball) and + -- MonFaintedAnimation both clear the box and neither puts anything back: it + -- stays blank until something DRAWS a pic into it, which on the cart is only + -- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon). + -- Without this latch the pic came back the instant the animation let go of + -- the screen, so a caught mon stood there through "Gotcha!" and a fainted one + -- popped back up for its own faint line. See stepAnim for why it is latched + -- at those two moments rather than off the runner's own last frame. + self.picHidden = { player = false, enemy = false } + -- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in + -- from opposite sides before the first message. + self.slideFrame = 0 + -- The HUD draws from the cart's own tiles when the cache has them; without + -- them (an older import) drawHpBar falls back to the plain rectangles. + self.hud = BattleHud.new(data.gen2MenuGfx, self.palettes) + -- The battle-animation runtime. Both halves are optional: a cache built + -- before the scripts were extracted simply has no `anims`, and every call + -- site below already guards on that. + self.anims = data.gen2BattleAnims + self.animConstants = data.gen2Constants + if self.anims and self.anims.scripts then + self.animView = BattleAnimView.new(self.anims, self.palettes) + end + self.anim = nil + + -- The intro sequence is the cart's, in the cart's order: + -- BattleIntroSlidingPics both halves slide in, and the player's box + -- holds the TRAINER's back-pic, not a mon + -- BattleStartMessage "Wild X appeared!" + -- SendOutPlayerMon swap in the mon's backpic, play + -- ANIM_SEND_OUT_MON, then "Go! X!" + -- so `showPlayerTrainer` is true for everything up to the send-out. + -- + -- In the tutorial the send-out never comes, so the back-pic stands for the + -- whole battle -- and GetTrainerBackpic's "Special exception for Dude" swaps + -- ChrisBackpic for DudeBackpic to draw it. A cache built before DudeBackpic + -- was extracted has no `dudeBack`, and falls back to the player's own. + self.showPlayerTrainer = true + self.playerBackImage = nil + local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud + local backPath = hudGfx and hudGfx.playerBack + if self.tutorial and hudGfx and hudGfx.dudeBack then + backPath = hudGfx.dudeBack + end + -- player.sprite, the same hook and payload Gen 1 raises for its own back pic + -- (src/pokemon/Sprites.lua): the Dude's stand-in is the `demo` flag there. + backPath = Sprites.playerPic(backPath, { + side = "back", kind = "battle", demo = self.tutorial and true or false, + battle = self.battle, data = data, + }) + if backPath then + local ok, image = pcall(Assets.image, backPath) + if ok then + self.playerBackImage = image + -- Kept so battle_sprite_scales can be looked up for this pic too: it is + -- not a species' pic, so its asset path is the only key it has. + self.playerBackPath = backPath + end + end + + -- Neither HUD exists while the bands slide. InitBattleDisplay blanks the + -- WHOLE tilemap (.BlankBGMap) and draws only the textbox and the two pics + -- before BattleIntroSlidingPics (engine/battle/core.asm:8554/8564), so the + -- names, levels, bars and borders are not on screen to ride in with them. + -- UpdateEnemyHUD runs only after BattleStartMessage returns, and then only + -- for a WILD battle (core.asm:7815-7817); a trainer's comes up at the tail of + -- ShowSetEnemyMonAndSendOutAnimation (core.asm:3384). UpdatePlayerHUD runs + -- at the tail of SendOutPlayerMon, after the send-out anim and the cry + -- (core.asm:3838). + self.showEnemyHud = false + self.showPlayerHud = false + + -- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7 + -- frontpic in the enemy pic box BEFORE the intro slide, and it stays there + -- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The + -- pic is a cache asset, so an import made before the extractor grew that + -- stage has none and the mon stands in for the whole intro. + self.showEnemyTrainer = false + -- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the + -- pic up in are keyed by: menu_gfx's trainerPics is written out of + -- constants.trainerClassOrder, and palettes.trainers out of the same names. + -- Trainers.lookup's `class` field is whatever the CALLER asked with, and the + -- overworld asks with the numeric constant an object_event's trainer struct + -- carries (36, not "BUG_CATCHER") -- so reading `class` here found no pic and + -- no palette for every trainer the world starts, which is all of them. + -- `classId` is the trainers.lua key, i.e. the constant; `className` is the + -- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all. + local enemyTrainer = self.battle and self.battle.trainer + self.enemyTrainerClass = enemyTrainer + and (enemyTrainer.classId or enemyTrainer.class) + local trainerPics = hudGfx and hudGfx.trainerPics + local trainerPath = self.enemyTrainerClass and trainerPics + and trainerPics[self.enemyTrainerClass] + if trainerPath then + local ok, image = pcall(Assets.image, trainerPath) + if ok and image then + self.enemyTrainerImage = image + self.enemyTrainerPath = trainerPath + self.showEnemyTrainer = true + end + end + + local enemy = self.battle and self.battle.enemy + self:noteFirstUnown(enemy) + if enemy then + if self.battle.wild then + -- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's + -- `.Shiny` arm before its cry and its line (core.asm:8705-8715). + if enemy.shiny then self:push({ kind = "shiny-flash" }) end + -- BattleStartMessage's `.wild` arm ends on WildPokemonAppearedText + -- (core.asm:8730); `intro` is what defers the enemy HUD to the step after + -- it, which is where StartBattle's `call z, UpdateEnemyHUD` sits. + self:push({ kind = "message", intro = true, cry = enemy, + text = "Wild " .. self:name(enemy) .. " appeared!" }) + else + local trainerName = (self.battle.trainer and self.battle.trainer.name) + or "Foe" + -- WantsToBattleText (core.asm:8701), read against the trainer's own pic. + self:push({ kind = "message", + text = trainerName .. " wants to battle!" }) + -- ResetEnemyBattleVars' SlideBattlePicOut at the head of EnemySwitch + -- (core.asm:3027) pushes that pic off the right edge before the mon is + -- announced. Nothing to slide when the cache has no trainer pic. + if self.showEnemyTrainer then + self:push({ kind = "trainer-slide" }) + end + -- ShowBattleTextEnemySentOut, then ShowSetEnemyMonAndSendOutAnimation + -- (core.asm:2978-2980, 3354): this is where the mon's frontpic first + -- appears, where ANIM_SEND_OUT_MON plays and where the HUD comes up. + self:push({ kind = "send", side = "enemy", mon = enemy, + text = trainerName .. " sent out " .. self:name(enemy) .. "!" }) + end + end + local player = self.battle and self.battle.player + if player then + self:push({ kind = "sendout", + text = "Go! " .. self:name(player) .. "!" }) + end + -- What the HUD shows chases the real HP one tick at a time + -- (engine/battle/anim_hp_bar.asm), re-armed by each damage/heal event as + -- the queue consumes it. The engine has already finished the whole turn's + -- math by the time the first message shows, so drawing mon.hp directly + -- would spoil every hit before its own line ran -- and leave the bars + -- looking frozen while the messages replay. + self.shownHp = { + player = (player and player.hp) or 0, + enemy = (enemy and enemy.hp) or 0, + } + -- Which mon each side's HUD and pic actually draw, for the same reason: the + -- engine has already rebound battle.enemy by the time the faint line runs, so + -- reading it straight would swap the sprite and the name a beat before + -- "X fainted!" is even displayed. The replacement arrives with its own + -- `send` event, which is where the cart's send-out animation sits. + self.shownMon = { player = player, enemy = enemy } + -- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is + -- only advanced inside its level loop, right after that level's bar has + -- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor + -- the exp fill may be read live off the mon. + self.shownLevel = (player and player.level) or 1 + self.shownExp = player + and self:expPixels(player, player.level, player.experience) or 0 + return self +end + +-- CalcExpBar (engine/battle/core.asm:7555): the bar is 64 pixels of the span +-- between THIS level's exp and the next level's, not a share of the mon's +-- total exp. +function BattleState:expPixels(mon, level, exp) + local growth = self:growthOf(mon) + if not growth then return 0 end + level = math.max(1, math.min(Mon.MAX_LEVEL, level or 1)) + local base = Mon.experienceForLevel(growth, level) + local next_ = Mon.experienceForLevel(growth, level + 1) + if not base or not next_ or next_ <= base then return 0 end + local into = math.max(0, math.min(next_ - base, (exp or base) - base)) + return math.floor(into * BattleHud.EXP_LENGTH_PX / (next_ - base)) +end + +function BattleState:name(mon) + if not mon then return "?" end + return mon.nickname or mon.name or mon.species or "?" +end + +-- wFirstUnownSeen: the letter of the first Unown the player ever MET, written +-- by both enemy send-out paths (`cp UNOWN / ld a, [wFirstUnownSeen] / and a / +-- jr nz / predef GetUnownLetter / ld [wFirstUnownSeen], a`, +-- engine/battle/core.asm:7894-7902 and :3251-3259) and only while it is still +-- zero. Pokedex_LoadSelectedMonTiles copies it into wUnownLetter before +-- GetMonFrontpic (engine/pokedex/pokedex.asm:2364), so the #DEX entry shows the +-- form the player first met -- seeing order, not catching order, which is why +-- an Unown that was fled from still sets it. +function BattleState:noteFirstUnown(mon) + local save = self.save + if not (save and mon and mon.species == Unown.SPECIES) then return end + if (save.firstUnownSeen or 0) ~= 0 then return end + save.firstUnownSeen = Unown.monLetter(mon) +end + +-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the +-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu +-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes +-- here right where this screen starts waiting for the same button. +-- +-- `key` makes the arm idempotent for a wait that spans many steps: the prompt +-- stream must be armed ONCE per message, not re-armed every step, or its 0x51 +-- blank frames restart forever and the A never lands. A nil key arms every +-- time it is called, which is what the one-shot menu and pack arms want. +-- +-- `skipIdle` is the pacing correction, and it is a port decision rather than +-- the cart's: the menu and pack streams are consumed by loops that call +-- GetJoypad with NO frame delay (engine/menus/menu.asm `.loopRTC`, and the +-- pack's own), so their long NO_INPUT runs are loop iterations and are gone in +-- a frame or two. This port polls once per fixed step, so replaying those runs +-- step by step would park the DUDE on the battle menu for seventeen seconds. +-- The presses and their ORDER are what the stream is for, and both survive. +function BattleState:dudeInput(stream, key, skipIdle) + if not self.tutorial then return false end + if key ~= nil and self.dudeArmed == key then return false end + self.dudeArmed = key + local game = self.game + return CatchTutorial.rearm(game and game.autoInput, stream, + game and game.input, skipIdle) +end + +function BattleState:push(event) + self.queue[#self.queue + 1] = event +end + +function BattleState:pushAll(events) + for _, event in ipairs(events or {}) do self:push(event) end +end + +function BattleState:pic(mon, back) + local def = self.pokemon and mon and self.pokemon[mon.species] + local path = def and (back and def.spriteBack or def.spriteFront) + -- `ld hl, wEnemyMonDVs / predef GetUnownLetter / predef GetMonFrontpic`: + -- Unown's pic is picked by FORM, out of UnownPicPointers rather than out of + -- its own PokemonPicPointers row. Everything else reads one row. + local letter + local trueColor = (def and def.trueColor) and true or false + if mon and mon.species == Unown.SPECIES then + letter = Unown.monLetter(mon) + path = Unown.formSprite(self.pokemon, letter, back) or path + end + -- pokemon.sprite, the same name and the same ctx keys Gen 1 resolves its + -- battle pics through (src/pokemon/Sprites.lua:path), so one subscription + -- reskins both games: `side` is "front"/"back", `kind` says which screen is + -- asking, `mon` is the live battler for a per-instance skin and `trueColor` + -- is the mod's way of saying "this art is already coloured, leave the GBC + -- palette off it". The seam sits HERE rather than on Sprites.path because + -- the vanilla answer it has to be given is the one the Unown row above + -- picked -- resolving the species row again would throw the form away. The + -- two extra keys are what Gen 2 genuinely carries more of: the Unown letter + -- and the shiny flag that decides the palette. + if path and Runtime.wantsHook("pokemon.sprite") then + local ctx = { + species = mon.species, + side = back and "back" or "front", + kind = "battle", + mon = mon, + trueColor = (def and def.trueColor) and true or false, + data = (self.game and self.game.data) or nil, + letter = letter, + shiny = mon.shiny and true or false, + } + local hooked = Runtime.call("pokemon.sprite", + function(value) return value end, path, ctx) + if type(hooked) == "string" and hooked ~= "" then path = hooked end + trueColor = ctx.trueColor and true or false + end + if not path then return nil, false end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil, trueColor, path +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 +-- trainer back, the DUDE's, an opponent's frontpic). Same table, same record +-- shape and same resolution order as Gen 1's BattleState.imageBattleScale / +-- resolveBattleScale: image-level first, then the species record's own +-- battleScaleFront / battleScaleBack, then the default. The DEFAULT is where +-- the two generations genuinely differ and why this is not a call into that +-- module: Gen 1's back pics are 32x32 drawn at 2x, Gen 2's are 48x48 and fill +-- their 6x6 box at 1x, so both sides default to 1 here. +function BattleState:imageScale(path) + local data = self.game and self.game.data + local scales = data and data.battle_sprite_scales + if not (scales and path) then return nil end + for id, record in pairs(scales) do + -- `_owners` is the registry's own bookkeeping row, not a record. + if id ~= "_owners" and type(record) == "table" and record.path == path then + return tonumber(record.scale) + end + end + return nil +end + +function BattleState:picScale(path, mon, back) + local scale = self:imageScale(path) + if scale then return scale end + local def = self.pokemon and mon and self.pokemon[mon.species] + local override = def and (back and def.battleScaleBack or def.battleScaleFront) + return tonumber(override) or 1 +end + +-- Where the two pics go, straight out of engine/battle/core.asm: +-- +-- enemy front: hlcoord 12, 0 with lb bc, 7, 7 -- a 7x7 tile box at (96, 0) +-- player back: hlcoord 2, 6 with lb bc, 6, 6 -- a 6x6 tile box at (16, 48) +-- +-- Back pics are always 48x48 so they fill their box exactly. Front pics vary +-- (Cyndaquil is 40x40, Onix 56x56) and are padded into the 7x7 box bottom-first, +-- which is what keeps a small mon standing on the same ground line as a big one +-- instead of floating at the top of the box. +BattleState.ENEMY_PIC_TILE_X = 12 +BattleState.ENEMY_PIC_TILE_Y = 0 +BattleState.ENEMY_PIC_TILES = 7 +BattleState.PLAYER_PIC_TILE_X = 2 +BattleState.PLAYER_PIC_TILE_Y = 6 +BattleState.PLAYER_PIC_TILES = 6 + +-- BattleBGEffect_RunPicResizeScript draws the mon at one of six BG squares: +-- 6x6 / 4x4 / 2x2 tiles for the player and 7x7 / 5x5 / 3x3 for the enemy, in +-- that order. Only the SIZE matters here -- the cart's tile tables are the +-- same pic sampled coarsely -- so a size index becomes a scale about the +-- box's own bottom centre. +local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 } + +function BattleState:drawPic(mon, back) + -- During the intro slide the player-side pic belongs to presentSlide's + -- backpic overlay, not to the baked bands (see BattleAnimView). + if back and self.slidingBackpic then return end + local image, trueColor, path = self:pic(mon, back) + -- Before SendOutPlayerMon the player's box holds ChrisBackpic instead, in + -- the same 6x6 box at hlcoord 2, 6 that the mon's backpic uses. + local trainerBack = back and self.showPlayerTrainer and self.playerBackImage + if trainerBack then image, path = trainerBack, self.playerBackPath end + -- And the enemy's box holds the trainer's own frontpic until EnemySwitch + -- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848). + local enemyTrainer = (not back) and self.showEnemyTrainer + and self.enemyTrainerImage + if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end + if not image then return end + local side = back and "player" or "enemy" + local anim = self:animPicState(side) + -- The box is empty either because the animation running right now has + -- cleared it, or because the last one ENDED with it cleared (picHidden). + if (anim and anim.hidden) or self.picHidden[side] then return end + local G = love.graphics + local w, h = image:getDimensions() + local px, py + local boxTiles + if back then + px = BattleState.PLAYER_PIC_TILE_X * 8 + py = BattleState.PLAYER_PIC_TILE_Y * 8 + boxTiles = BattleState.PLAYER_PIC_TILES + else + -- Bottom-aligned and horizontally centred inside the 7x7 box. + local box = BattleState.ENEMY_PIC_TILES * 8 + px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2) + py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h) + boxTiles = BattleState.ENEMY_PIC_TILES + end + -- One tile per two frames to the right, SlideBattlePicOut's own step. + if enemyTrainer and self.trainerSlide then + px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8 + end + -- The pic's own scale (battle_sprite_scales, then the species record, then + -- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has + -- the mon drawn at this frame. + local scale = self:picScale(path, mon, back) + if anim then + px = px + (anim.slide or 0) + local resized = anim.size and PIC_RESIZE_TILES[anim.size] + if resized then scale = scale * (resized / boxTiles) end + end + if scale ~= 1 then + -- Centred in the same box and standing on the same ground line at every + -- scale: the smaller resize squares, and a mod scale, both compensate the + -- same way. + px = px + math.floor(w * (1 - scale) / 2) + py = py + math.floor(h * (1 - scale)) + end + G.setColor(1, 1, 1, 1) + -- No mon on this side at all in the catching tutorial, where the box holds + -- the DUDE's back-pic and nothing else for the whole battle. + local colors = self.palettes and mon + and Palettes.monColors(self.palettes, mon.species, mon.shiny) + if trainerBack then + -- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of + -- TrainerPalettes (Chris shares Cal's). + colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors + elseif enemyTrainer then + -- The opponent's class row out of the same TrainerPalettes table. + colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass) + or colors + end + if anim and anim.shade then + colors = BattleAnimView.shadeColors(colors, anim.shade) + end + -- MonFaintedAnimation, mid-slide: the rows that have walked past the bottom + -- of the pic box are not on the tilemap any more, so the pic is CROPPED to + -- what is still inside the box rather than drawn over the HUD below it. + local sunk = self:faintSink(side) + local function body() + if sunk > 0 then + local visible = h - math.floor(sunk / scale) + if visible <= 0 then return end + G.draw(image, self:cropQuad(image, visible), px, py + sunk, 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: + -- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands + -- back to its own draw site. + if colors and not trueColor and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- The top `visible` rows of a pic, for the faint slide. One quad, re-aimed, +-- the way BattleAnimView keeps one blit quad rather than a new one per frame. +function BattleState:cropQuad(image, visible) + local w, h = image:getDimensions() + if not self.picQuad then + self.picQuad = love.graphics.newQuad(0, 0, w, visible, w, h) + else + self.picQuad:setViewport(0, 0, w, visible, w, h) + end + return self.picQuad +end + +-- How far this side's pic has sunk, in pixels. MonFaintedAnimation moves one +-- 8px row per FAINT_SLIDE_FRAMES_PER_ROW frames. +function BattleState:faintSink(side) + local slide = self.faintSlide + if not (slide and slide.side == side) then return 0 end + return math.floor(slide.frames / FAINT_SLIDE_FRAMES_PER_ROW) * 8 +end + +-- The whole slide, in frames: the pic box's own height in rows. +function BattleState:faintSlideFrames(side) + local tiles = side == "player" and BattleState.PLAYER_PIC_TILES + or BattleState.ENEMY_PIC_TILES + return tiles * FAINT_SLIDE_FRAMES_PER_ROW +end + +-- One tick of the HP bar chase (_AnimateHPBar): under 48 max HP the bar steps +-- one hit point a frame (ShortAnim_UpdateVariables); from 48 up it moves one +-- PIXEL a frame, which is maxHp/48 hit points at a time +-- (LongAnim_UpdateVariables). Returns true while a step was taken, so the +-- caller holds the queue the way the cart's loop holds the game. +function BattleState:stepHpAnim() + local anim = self.hpAnim + if not anim or not self.shownHp then return false end + -- wCurHPAnimMaxHP is loaded from the battle struct of the mon whose bar is + -- ON SCREEN (wEnemyMonMaxHP -> wHPBuffer1 before `predef AnimateHPBar`, + -- engine/battle/effect_commands.asm:3399-3414), and _AnimateHPBar picks its + -- short/long loop and its pixels off that alone (anim_hp_bar.asm:42-50, + -- :56-82). After a faint that mon is still the OUTGOING one: the engine has + -- already rebound battle.enemy, but its `send` event has not been dequeued + -- yet, so sizing the tick off battle[side] drains the dead mon's bar at the + -- replacement's rate. + local mon = self:activeMon(anim.side) + local maxHp = (mon and (mon.maxHp or (mon.stats and mon.stats.hp))) or 0 + local step = 1 + if maxHp >= HpBar.LENGTH_PX then + step = math.max(1, math.ceil(maxHp / HpBar.LENGTH_PX)) + end + local shown = self.shownHp[anim.side] or 0 + local target = anim.to or 0 + if shown < target then + shown = math.min(target, shown + step) + else + shown = math.max(target, shown - step) + end + self.shownHp[anim.side] = shown + if shown == target then self.hpAnim = nil end + return true +end + +-- .PlayExpBarSound's own two halves (engine/battle/core.asm:7311-7318): the +-- looping SFX_EXP_BAR, then `ld c, 10 / call DelayFrames` before the first +-- pixel moves. TerminateExpBarSound (home/audio.asm:497) cuts it dead at the +-- end of the segment rather than letting it ring on, and the end-of-bar hit +-- only plays where a level was actually crossed. +local SFX_EXP_BAR = "Sfx_ExpBar" +local SFX_END_OF_EXP_BAR = "Sfx_HitEndOfExpBar" +local EXP_SOUND_FRAMES = 10 + +-- One tick of the exp bar crawl. AnimateExpBar walks the bar one PIXEL at a +-- time out of 64 (.LoopBarAnimation, engine/battle/core.asm:7325-7362): the +-- gap starts at three frames a pixel and drops by one after every SECOND +-- pixel, floored at one, so the bar starts slow and finishes fast. A level +-- crossing fills the segment to 64, plays SFX_HIT_END_OF_EXP_BAR, advances the +-- level the HUD prints and restarts the bar at 0 (:7259-7285), which is why +-- the number changes as the bar tops out and not a message earlier. +-- +-- Returns true while the crawl is running so the caller holds the queue the +-- way the cart's loop holds the game. +function BattleState:stepExpAnim() + local anim = self.expAnim + if not anim then return false end + local mon = anim.mon + local toLevel = (mon and mon.level) or self.shownLevel or 1 + local target = BattleHud.EXP_LENGTH_PX + if (self.shownLevel or 1) >= toLevel then + target = self:expPixels(mon, self.shownLevel, mon and mon.experience) + end + if not anim.started then + anim.started = true + anim.frames = 3 + anim.wait = 0 + anim.pixels = 0 + anim.delay = EXP_SOUND_FRAMES + self:playSfx(SFX_EXP_BAR) + end + if anim.delay > 0 then + anim.delay = anim.delay - 1 + return true + end + local shown = self.shownExp or 0 + if shown < target then + anim.wait = anim.wait + 1 + if anim.wait < anim.frames then return true end + anim.wait = 0 + shown = shown + 1 + self.shownExp = shown + anim.pixels = anim.pixels + 1 + if anim.pixels % 2 == 0 then anim.frames = math.max(1, anim.frames - 1) end + if shown < target then return true end + end + -- TerminateExpBarSound at the tail of every segment (:7279 and :7301). + Sound.stop(SFX_EXP_BAR) + if (self.shownLevel or 1) < toLevel then + self.shownLevel = (self.shownLevel or 1) + 1 + self.shownExp = 0 + self:playSfx(SFX_END_OF_EXP_BAR) + anim.started = false + return true + end + self.expAnim = nil + return true +end + +-- The HP a side's HUD prints and fills its bar from: the chased value, not +-- the engine's, which ran a whole turn ahead. +function BattleState:hudHp(mon, side) + local shown = self.shownHp and self.shownHp[side] + if shown == nil then return (mon and mon.hp) or 0 end + return shown +end + +-- The bar is src/battle/gen2/HpBar.lua's: the pixel count comes from +-- ComputeHPBarPixels and the colour from GetHPPal, so this screen and the party +-- list can never disagree about when a mon is in the red. +function BattleState:drawHpBar(mon, side, tx, ty) + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) + local hp = self:hudHp(mon, side) + if self.hud:available() then + return self.hud:drawHpBar(hp, maxHp, tx, ty) + end + return HpBar.drawWithLabel(self.palettes, hp, maxHp, tx, ty, Font) +end + +-- CheckCaughtMon against wPokedexCaught (home/pokedex_flags.asm:48-51). +function BattleState:dexCaught(mon) + local caught = self.save and self.save.pokedex and self.save.pokedex.caught + return (mon and caught and caught[mon.species]) and true or false +end + +-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531) +-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a +-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0, +-- DangerSoundLow ($6ee) at counter 16 -- while audio/engine.asm:244 keeps music +-- channel 1 quiet for as long as the flag is up. CheckDanger +-- (engine/battle/core.asm:4393) sets and clears the flag off wPlayerHPPal == +-- HP_RED, and StopDangerSound (core.asm:2189) zeroes it on a faint and at the +-- end of the battle. src/core/ChipAudio.lua synthesizes that exact pair, which +-- is what Sound.startLoop("Low_Health_Alarm") reaches. +-- +-- Keyed to the DISPLAYED bar, because wPlayerHPPal is what the bar animation +-- updates: the siren starts when the bar drains into the red, not a turn early. +function BattleState:lowHealthAlarmActive() + -- wBattleLowHealthAlarm is the per-battle DISABLE latch, and CheckDanger + -- reads it before anything else (`ld a, [wBattleLowHealthAlarm] / and a / + -- jr nz, .done`, engine/battle/core.asm:4396-4399): once it is set the + -- DANGER_ON_F bit StopDangerSound just cleared is left alone, so the siren + -- cannot come back for the rest of the battle however red the bar stays. + if self.lowHealthAlarmDisabled then return false end + -- The healing item's exception, set by applyPartyItem: wLowHealthAlarm is + -- zeroed before the HP moves, and CheckDanger is not asked again until + -- UpdatePlayerHUD runs at the end of the bar climb, so nothing re-arms the + -- siren while the bar is walking back out of the red. + if self.healSilence then + if self.hpAnim and self.hpAnim.side == "player" then return false end + self.healSilence = nil + end + local player = self.battle and self.battle.player + return (player and (player.hp or 0) > 0 + and HpBar.paletteFor(self:hudHp(player, "player"), + player.maxHp or (player.stats and player.stats.hp)) == "red") and true + or false +end + +function BattleState:updateAlarm() + local data = self.game and self.game.data + -- Mirrors wLowHealthAlarm's DANGER_ON_F bit, under the same field name Gen 1 + -- keeps it in (src/battle/BattleState.lua). + self.lowHealthAlarmOn = self:lowHealthAlarmActive() and data ~= nil + -- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on mirrors + -- self.lowHealthAlarmOn -- the same name and the same ctx keys as the Gen 1 + -- site, so one subscription covers both games and a mod can reshape the + -- toggle (mute it after a budget, swap the loop) before vanilla acts on it. + -- `data` is added because this screen's cache lives on the game rather than + -- on the battle the way Gen 1's does; `battle` is still the battle screen. + if Runtime.wantsHook("battle.low_health_alarm") then + return Runtime.call("battle.low_health_alarm", function(ctx) + if ctx.on and ctx.data then + Sound.startLoop(ctx.data, "Low_Health_Alarm") + else + Sound.stopLoop("Low_Health_Alarm") + end + end, { on = self.lowHealthAlarmOn, battle = self, data = data }) + end + if self.lowHealthAlarmOn then + -- Sound.startLoop returns early when the loop is already sounding, so this + -- can run every step the way PlayDanger runs every frame. + Sound.startLoop(data, "Low_Health_Alarm") + else + self:stopAlarm() + end +end + +-- StopDangerSound (engine/battle/core.asm:2189): the siren cannot outlive the +-- mon that raised it, nor the battle screen. +function BattleState:stopAlarm() + Sound.stopLoop("Low_Health_Alarm") +end + +-------------------------------------------------------------------------- +-- Battle animations +-------------------------------------------------------------------------- + +-- hBattleTurn: 0 while the player is attacking. Every object function and +-- every BG effect keys the side it acts on off this. +function BattleState:turnFor(side) + return side == "enemy" and 1 or 0 +end + +-- Starts an animation script and returns true when there is one to play. +-- `key` is a pool key from battle_anims.lua's `moves` or `ids` map. +function BattleState:startAnim(key, opts) + if not (self.anims and self.anims.scripts and key) then return false end + if not self.anims.scripts[key] then return false end + -- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the + -- move animation entirely, which is the OPTION screen's BATTLE SCENE row. + local options = self.game and self.game.options + if options and options.battleScene == false then return false end + opts = opts or {} + local data = (self.game and self.game.data) or {} + local audio = data.audio or {} + self.anim = AnimRunner.new({ + data = self.anims, + constants = self.animConstants, + battleTurn = opts.turn or 0, + animId = opts.animId, + param = opts.param or 0, + sfxOrder = audio.sfxOrder, + ballPalette = opts.ballPalette, + flying = { + player = self.battle and self.battle.player + and self.battle.player.vanished or false, + enemy = self.battle and self.battle.enemy + and self.battle.enemy.vanished or false, + }, + hooks = { + -- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls + -- PlayStereoSFX (audio/engine.asm:2571), the ONE sfx path with no + -- CheckSFX/wCurSFX comparison: an animation's second sound is never + -- dropped for being outranked by its first. BattleAnim_ThrowPokeBall's + -- SFX_THROW_BALL then SFX_BALL_POOF is the case that goes silent if this + -- goes through the gated Sound.play. + sound = function(name) + if name and audio.sfx and audio.sfx[name] then + Sound.playStereo(data, name) + end + end, + -- The cry is the battler's own, at the pitch/length the command adds; + -- the port's Sound layer has no pitch shift, so the plain cry is what + -- plays. audio.cries is keyed by SPECIES (the same table every other + -- Gen 2 screen plays through Sound.playCry), which is what makes + -- anim_cry moves like GROWL audible at all. + cry = function(side) + local mon = side == "enemy" and self.battle.enemy or self.battle.player + local species = mon and mon.species + if species and audio.cries and audio.cries[species] then + Sound.playCry(data, species) + end + end, + -- GetPokeBallWobble, which BattleAnim_ThrowPokeBall's .Loop calls through + -- anim_checkpokeball once per wobble. + pokeballWobble = function() return self:pokeballWobble() end, + }, + }) + self.anim:start(key) + -- BattleAnimRunScript calls BattleAnimClearHud before a MOVE's script and + -- BattleAnimRestoreHuds after; the `.not_move` path (the shared ANIM_* ids) + -- skips both. ClearActorHud blanks the ATTACKER's own HUD, which is what + -- keeps a Tackle from dragging the name and HP bar along with the pic. + self.anim.clearsHud = opts.isMove and true or false + self.anim.hudSide = (opts.turn or 0) == 0 and "player" or "enemy" + return true +end + +function BattleState:animForMove(moveId, side) + local key = self.anims and self.anims.moves and self.anims.moves[moveId] + return self:startAnim(key, { + turn = self:turnFor(side), animId = moveId, isMove = true, + }) +end + +-- True while BattleAnimClearHud has that side's HUD blanked. +function BattleState:hudCleared(side) + return self.anim ~= nil and self.anim.clearsHud and self.anim.hudSide == side +end + +-- `param` is wBattleAnimParam, which BattleAnim_SendOutMon branches on +-- (data/moves/animations.asm:414-417). +function BattleState:animForId(idName, side, param) + local key = self.anims and self.anims.ids and self.anims.ids[idName] + return self:startAnim(key, { + turn = self:turnFor(side), animId = idName, param = param, + }) +end + +-- One logic frame of a running animation. B cuts it short, the way holding B +-- pages a text box. +function BattleState:stepAnim(input) + if not self.anim then return end + if input and (input:wasPressed("b") or input:wasPressed("start")) then + -- Cut short: the BG effects never reached their own last step, so the + -- tilemap is whatever they had got to and nothing is latched -- the + -- explicit latches (a catch) are the only ones that survive a skip. + self.anim = nil + return self:endSendOutAnim() + end + if not self.anim:step() then + self.anim = nil + return self:endSendOutAnim() + end +end + +-- NOTE on picHidden and the animation runtime. An animation that ENDS with a +-- pic box cleared could latch it here, and the tilemap argument says it should: +-- BattleAnimRestoreHuds redraws the two HUDs and nothing else. It deliberately +-- does not, because BATTLE_BG_EFFECT_REMOVE_MON and _RETURN_MON are also used +-- by moves whose user is still standing there afterwards -- SUBSTITUTE (the +-- doll takes the box over, and nothing in this port draws one yet), SKY_ATTACK, +-- BEAT_UP, BATON_PASS -- and a blanket latch would make those mons invisible +-- for the rest of the fight. The two moments the cart really does leave the +-- box empty for good are latched explicitly instead: a catch (pushCaught) and +-- a faint (MonFaintedAnimation, in update). + +-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and +-- HUD update run the moment its animation is done, cut short or not. +function BattleState:endSendOutAnim() + local after = self.afterSendOut + if not after then return end + self.afterSendOut = nil + self:finishSendOut(after) +end + +-- What the BG effects are doing to a battler's pic this frame. +function BattleState:animPicState(side) + if not self.anim then return nil end + local bg = self.anim.bg + return { + hidden = bg.hidden[side], + size = bg.picSize[side], + slide = bg.slide[side] or 0, + shade = bg.monShade[side], + } +end + +function BattleState:advanceQueue() + local event = table.remove(self.queue, 1) + -- StartBattle runs `call z, UpdateEnemyHUD` AFTER BattleStartMessage returns, + -- and only for a wild battle (engine/battle/core.asm:7808-7817): the appeared + -- line is read against an empty HUD area and the bar comes up on the step + -- after it. A trainer's HUD is turned on by the send-out arm below instead. + if self.introTextShown then + self.showEnemyHud = true + self.introTextShown = nil + end + if not event then + -- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no + -- turn (engine/battle/core.asm:2955-2963). + if self.shiftSwitchIndex then + local index = self.shiftSwitchIndex + self.shiftSwitchIndex = nil + if self.battle:shiftSwitch(index) then + self:pushAll(self.battle:takeEvents()) + return self:advanceQueue() + end + end + -- Nothing left: either the battle ended or it is the player's turn. + if self.battle and self.battle.over then + -- ExitBattle runs the evolution sweep BEFORE it cleans up the battle + -- RAM, so the screens come up while the battle is still notionally on. + return self:startEvolutions() + end + self.phase = "menu" + if self.tutorial then + -- BattleMenu's tutorial arm skips UpdateBattleHuds AND EmptyBattleTextbox, + -- so the box keeps whatever it already said while the menu opens over it, + -- and there is no mon to name in a prompt anyway. The DOWN + A that + -- picks PACK is armed here, where the cart arms it: right before + -- LoadBattleMenu. + self:dudeInput(CatchTutorial.MENU_STREAM, nil, true) + return + end + -- `call CheckPlayerLockedIn / jr c, .skip_iteration` (engine/battle/core.asm + -- :162-176) jumps past `call BattleMenu` ENTIRELY, not just past the move + -- list: a mon partway through a Rollout or a Thrash is offered no menu at + -- all, and ParsePlayerAction's .locked_in arm runs the move it is stuck on. + -- The engine side already forces the move (Battle:forcedMove overrides + -- whatever is submitted, and Battle:usableMoves narrows to the one), so all + -- that is left here is not to draw a menu the cart never draws. Deferred + -- to update() rather than submitted from inside advanceQueue, so a turn + -- that somehow emitted no events cannot recurse. + if not self.tutorial and self.battle and self.battle.player + and self.battle:lockedInMove(self.battle.player) then + -- The message is deliberately NOT cleared: EmptyBattleTextbox lives + -- inside BattleMenu, which this turn never calls, so the box keeps + -- whatever the last line was. + self.phase = "locked-in" + return + end + -- BattleMenu (engine/battle/core.asm) runs EmptyBattleTextbox before + -- LoadBattleMenu: the half of the box beside the 2x2 menu is BLANK on the + -- cart. Gen 2 has no "What will X do?" line, and printing one here only + -- got it clipped mid-word by the menu box drawn over its right half. + self.message = nil + return + end + -- HandleEnemyMonFaint / HandlePlayerMonFaint run their side's + -- MonFaintedAnimation BEFORE the faint text (engine/battle/core.asm): the pic + -- sinks out of the field and only then does "X fainted!" go up. The slide + -- owns the screen the way SlideBattlePicOut does, so the event is put back at + -- the head of the queue and re-runs for its text (and for the alarm latch and + -- the victory jingle below it) once the pic is gone. + if event.kind == "faint" and event.side and not event.slid + and not self.faintSlide then + event.slid = true + table.insert(self.queue, 1, event) + self.faintSlide = { side = event.side, frames = 0 } + -- FaintEnemyPokemon opens on SFX_KINESIS, FaintYourPokemon on the fainting + -- mon's own cry (engine/battle/core.asm:2196-2201, :2210-2212). + if event.side == "enemy" then + self:playSfx("Sfx_Kinesis") + else + self:playCry(self:activeMon("player")) + end + return + end + -- Battle:awardExperience emits one `level` event per mon that grew, which is + -- exactly where the cart sets that slot's wEvolvableFlags bit. + if event.kind == "level" and event.index then + self.evolvable[event.index] = true + -- GiveExperiencePoints' `.skip_active_mon_update` guard + -- (engine/battle/core.asm:6999-7003): only the mon that is OUT copies its + -- recalculated HP, max HP and level into the battle struct, and only then + -- does `callfar UpdatePlayerHUD` (:7034) redraw the bar. That is a + -- REDRAW, not AnimateHPBar, so the shown HP snaps instead of chasing -- + -- without it the bar kept the pre-level-up HP against the new maximum + -- until the next damage or heal event moved it. + local battle = self.battle + local mon = battle and battle.party and battle.party[event.index] + if mon and mon == battle.player then + if self.shownHp then + self.shownHp.player = mon.hp or 0 + if self.hpAnim and self.hpAnim.side == "player" then + self.hpAnim = nil + end + end + -- `ld [wBattleMonLevel], a` in the same guarded block (:7018-7020). + -- AnimateExpBar has already walked the number up one level at a time by + -- the time this runs, so this only catches a level gained with no exp + -- crawl behind it. + self.shownLevel = mon.level or self.shownLevel + end + end + -- EnemySwitch's shift arm asks BEFORE ClearEnemyMonBox and + -- ShowBattleTextEnemySentOut (engine/battle/core.asm:2941-2955), so the send + -- goes back at the head of the queue and re-runs once the prompt is answered. + if event.kind == "send" and event.side == "enemy" and event.replacement + and not event.offered and self:shiftOfferAllowed() then + event.offered = true + table.insert(self.queue, 1, event) + return self:offerShiftSwitch(event.mon) + end + -- A damage or heal event re-arms the HP bar chase (AnimateHPBar runs from + -- UpdateBattleHuds between one battle message and the next), and a send + -- snaps that side's bar straight to the incoming mon. + if (event.kind == "damage" or event.kind == "heal") + and event.side and event.hp and self.shownHp + and self.shownHp[event.side] ~= event.hp then + self.hpAnim = { side = event.side, to = event.hp } + elseif event.kind == "send" and event.side and event.mon and self.shownHp then + self.shownHp[event.side] = event.mon.hp or 0 + if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end + end + -- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE + -- GiveExperiencePoints before the exp is committed (the call at :6888 sits + -- ahead of the commit at :6889-6901), so the bar crawls from the figures + -- the HUD is already showing up to the new ones, filling to 64 and + -- restarting at 0 for every level crossed (:7259-7285). The engine has + -- written mon.experience and mon.level a whole turn earlier here, so the + -- crawl's starting point is the chased state (shownExp / shownLevel) rather + -- than the mon: same reason the HP bar has shownHp. + if event.kind == "experience" and event.index then + local battle = self.battle + local mon = battle and battle.party and battle.party[event.index] + -- AnimateExpBar's own two guards: only the mon that is OUT animates (the + -- wCurBattleMon == wCurPartyMon test at :7194-7197), and nothing animates + -- at MAX_LEVEL (:7199-7201). + if mon and mon == battle.player + and (self.shownLevel or 1) < Mon.MAX_LEVEL then + self.expAnim = { mon = mon, frames = 3, wait = 0, pixels = 0 } + end + end + if event.kind == "send" and event.side and event.mon then + -- The pic and the HUD name follow the queue, so the mon that just fainted + -- is still on screen for its own line and the replacement arrives here. + if self.shownMon then self.shownMon[event.side] = event.mon end + -- The second of the cart's two wFirstUnownSeen writes (core.asm:3251). + if event.side == "enemy" then self:noteFirstUnown(event.mon) end + if event.side == "player" then + -- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back + -- to back (engine/battle/core.asm:3809), so a switched-in mon opens on + -- FIGHT and on its first move. Player side only: the zeroing lives + -- inside SendOutPlayerMon and nothing on the enemy's path touches them. + self.menuIndex = 1 + self.moveIndex = 1 + -- The incoming mon's own level and exp bar: SendOutPlayerMon reloads + -- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its + -- tail (:3838), so both snap here the way shownHp does above. + self.shownLevel = event.mon.level or 1 + self.shownExp = self:expPixels(event.mon, event.mon.level, + event.mon.experience) + self.expAnim = nil + end + end + -- ResetEnemyBattleVars' SlideBattlePicOut (engine/battle/core.asm:3027): + -- eight one-tile steps push the trainer's pic off the right edge, and the + -- queue holds until they are done. + if event.kind == "trainer-slide" then + self.trainerSlide = 0 + return + end + -- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick + -- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715). + if event.kind == "shiny-flash" then + self:animForId("ANIM_SEND_OUT_MON", "enemy", 1) + return + end + -- SendOutPlayerMon: the trainer's back-pic gives way to the mon and + -- ANIM_SEND_OUT_MON plays over the "Go!" line. + if event.kind == "sendout" then + self.showPlayerTrainer = false + self.menuIndex = 1 + self.moveIndex = 1 + self.message = event.text + self.messageTimer = MESSAGE_FRAMES + self:startSendOut("player", self.battle and self.battle.player) + return + end + -- DisplayCaughtContestMonStats, which BugContest_SetCaughtContestMon opens + -- over the battle once a second mon is caught: the stock-versus-this + -- comparison and its yes/no, both of which live in the contest screen. + if event.kind == "contest-switch" then + return self:openContestSwitch(event) + end + -- The failure line is picked from wThrownBallWobbleCount, which only reaches + -- its final value inside the animation (item_effects.asm:414-428). + if event.kind == "ball-result" then + self.message = self:ballFailureText() + self.messageTimer = MESSAGE_FRAMES + return + end + -- `predef NewPokedexEntry` (item_effects.asm:542). + if event.kind == "dex-entry" then + return self:openDexEntry(event.species) + end + if event.kind == "ask-nickname" then + return self:askNickname(event.mon) + end + if event.kind == "choose-switch" then + -- A fainted lead: force a switch before anything else runs. + self.phase = "forced-switch" + self.message = "Choose a POKéMON." + return + end + -- LearnMove's full-moveset arm: the exp queue stops on ForgetMove's own text + -- and the player drops a move or declines (engine/pokemon/learn.asm:29-33). + if event.kind == "choose-forget" then + self.pendingLearn = { index = event.index, move = event.move, + moveName = event.moveName } + return self:askForget() + end + -- PlayVictoryMusic sits in the faint handler, not at the end of the battle: + -- the jingle is already going while "X fainted!" is on screen and it loops + -- through the exp and money lines until the overworld comes back. + if event.kind == "faint" and event.side == "enemy" then + -- UpdateBattleStateAndExperienceAfterEnemyFaint (core.asm:2044) reaches + -- `.wild2` on EVERY wild enemy faint and there calls StopDangerSound and + -- writes 1 to wBattleLowHealthAlarm (:2071-2074), before a single point of + -- experience is awarded; WinTrainerBattle does the same pair when a + -- trainer's last mon drops (:2293-2296). Until this latch existed the + -- siren kept blaring under the victory jingle, the exp bar and the + -- level-up prompts for as long as the player's own bar stayed red. + if self.battle.wild + or (self.battle.over and self.battle.outcome == "win") then + self:stopAlarm() + self.lowHealthAlarmDisabled = true + end + if self.battle.over and self.battle.outcome == "win" then + self:playVictoryMusic() + end + end + if event.text then + self.message = event.text + self.messageTimer = MESSAGE_FRAMES + -- BattleStartMessage's own line: the enemy HUD comes up on the step after + -- it returns (engine/battle/core.asm:7808-7817), not with it. + 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 + -- 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 + self:playSfx(event.sfx) + -- TextCommand_SOUND is `call PlaySFX` followed by `call WaitSFX` + -- (home/text.asm:829-836, its table row at :860), so the cart stays + -- INSIDE the text command until the sound has finished: the box cannot + -- be paged away from mid-jingle, and PokeBallEffect's `.FinishTutorial` + -- tail cannot return under it. + if event.waitSfx then self.waitSfx = event.sfx end + end + end + -- The move's own animation plays over its "used X!" line, which is where + -- PlayBattleAnim sits in the effect command list. A damage event that + -- follows gets the shared hit animation instead. + -- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens + -- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a + -- move that missed burns the delay and plays nothing. Battle:markMissed sets + -- event.missed on every wAttackMissed path. + if event.kind == "move" and not event.missed then + self:animForMove(event.move, event.side) + elseif event.kind == "damage" and event.side then + -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), + -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). + local from = event.animSide + or (event.side == "enemy" and "player" or "enemy") + if event.animMove then + self:animForMove(event.animMove, from) + elseif event.anim ~= false then + self:animForId(event.anim + or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE" + or "ANIM_PLAYER_DAMAGE"), from) + end + elseif event.kind == "send" and event.side then + -- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation + -- (engine/battle/core.asm:3354) -- the faint replacement out of + -- EnemyPartyMonEntrance and the AI's mid-turn rotation alike -- and the + -- player's voluntary switch through SendOutPlayerMon (:3796). Without it + -- the replacement simply appeared, which with two of a species back to back + -- reads as one mon growing a second health bar. + self:startSendOut(event.side, event.mon) + end +end + +-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD +-- come after the animation, not with it. +function BattleState:startSendOut(side, mon) + local after = { side = side, mon = mon } + -- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic + -- into the box before they play the animation, which is the one thing that + -- undoes a cleared box. + self.picHidden[side] = false + self.faintSlide = nil + if self:animForId("ANIM_SEND_OUT_MON", side) then + self.afterSendOut = after + return true + end + -- BattleAnimRunScript is skipped with BATTLE SCENE off (and there are no + -- scripts at all in a cache built before they were extracted), but the cart + -- still runs the cry and the HUD update, so they happen now. + self:finishSendOut(after) + return false +end + +-- `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry / call UpdateEnemyHUD` +-- (engine/battle/core.asm:3380-3384), and the same pair at the tail of +-- SendOutPlayerMon (:3836-3838). +function BattleState:finishSendOut(after) + if not after then return end + self:playCry(after.mon) + if after.side == "enemy" then + self.showEnemyHud = true + else + self.showPlayerHud = true + end +end + +-- PlaySFX with one of the sfx the extractor named, or nothing at all when this +-- cache does not carry it. +function BattleState:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if name and audio and audio.sfx and audio.sfx[name] then + Sound.play(data, name) + end +end + +-- PlayStereoCry with the battler's own species. audio.cries is keyed by +-- SPECIES, the same table the animation runtime's `cry` callback plays through. +function BattleState:playCry(mon) + local species = mon and mon.species + if not species then return end + local data = self.game and self.game.data + local audio = data and data.audio + if audio and audio.cries and audio.cries[species] then + Sound.playCry(data, species) + end +end + +-------------------------------------------------------------------------- +-- EvolveAfterBattle +-------------------------------------------------------------------------- + +-- ExitBattle (engine/battle/core.asm): `ld a, [wBattleResult] / and $f / +-- jr nz, .CleanUpBattleRAM` -- only a WIN reaches `xor a / ld +-- [wForceEvolution], a / predef EvolveAfterBattle`. A loss goes straight to +-- the whiteout, so a mon that leveled on the way down never evolves. +-- +-- wForceEvolution is cleared here, which is what makes B a working cancel and +-- what keeps the EVOLVE_ITEM rows (Eevee's stones) from firing off a battle. +function BattleState:startEvolutions() + self.phase = "evolving" + -- ExitBattle's CleanUpBattleRAM zeroes wLowHealthAlarm; nothing past here + -- runs updateAlarm, so the siren has to be cut before the sweep takes over. + self:stopAlarm() + local battle = self.battle + if not (battle and Evolution.runsAfterBattle(battle.outcome)) then + return self:finishBattle() + end + local stack = self.game and self.game.stack + local party = battle.party or (self.save and self.save.party) or {} + self.evolutions = Evolution.plan((self.game and self.game.data) or {}, + party, self.evolvable, { + -- wTimeOfDay, for the TR_MORNDAY / TR_NITE happiness rows. + timeOfDay = Palettes.clockDaytime(), + }) + self.evolutionIndex = 0 + if #self.evolutions == 0 or not stack then return self:finishBattle() end + return self:nextEvolution() +end + +-- EvolveAfterBattle_MasterLoop, one flagged slot at a time: the screen owns +-- the stack until it reports back, and the next slot only starts once it does. +function BattleState:nextEvolution() + self.evolutionIndex = self.evolutionIndex + 1 + local plan = self.evolutions[self.evolutionIndex] + if not plan then return self:finishBattle() end + local stack = self.game.stack + Screens.push(self.game, "Gen2EvolutionAnim", { + mon = plan.mon, + entry = plan.entry, + index = plan.index, + party = self.battle.party or (self.save and self.save.party), + save = self.save, + onDone = function() + stack:pop() + self:nextEvolution() + end, + }) +end + +-- ExitBattle's `farcall GivePokerusAndConvertBerries`, which sits immediately +-- after `predef EvolveAfterBattle` inside the same WIN arm -- so it runs once +-- per won battle, after every evolution has resolved, and never after a loss. +-- Silent by design: nothing tells the player, and the Pokemon Center nurse is +-- the first thing that ever mentions it (std_scripts.asm PokeCenterNurseScript, +-- through the CheckPokerus special). +function BattleState:givePokerus() + local battle = self.battle + if not (battle and Evolution.runsAfterBattle(battle.outcome)) then return nil end + local party = battle.party or (self.save and self.save.party) + -- GivePokerusAndConvertBerries opens on `call ConvertBerriesToBerryJuice`, + -- so the Shuckle's held BERRY converts before the Pokerus roll runs. + BerryJuice.convertAfterBattle(self.save, party) + return Pokerus.giveAfterBattle(self.save, party) +end + +-- .ReturnToMap, minus the RestartMapMusic the overworld's own onDone already +-- does (src/world/gen2/World.lua calls Music.restoreMap there). +function BattleState:finishBattle() + self.phase = "done" + self:stopAlarm() + self:clearMenuCursors() + self:givePokerus() + -- CleanUpBattleRAM: every substatus the battle wrote goes with the battle. + -- The party tables it wrote them on are the save's own, so this has to run + -- before the overworld (and the next save write) sees them again. + if self.battle then self.battle:clearAllVolatiles() end + if self.onDone then + self.onDone(self.battle and self.battle.outcome, self.battle) + end +end + +-- CleanUpBattleRAM's cursor block (engine/battle/core.asm:7994-8004): the menu +-- bytes that live ACROSS menu openings are zeroed here and nowhere else -- +-- wPartyMenuCursor, wLastPocket, and the ITEM / KEY_ITEM / BALL pocket cursors +-- with their scroll positions. The TM/HM pair is deliberately NOT in that +-- list, so it survives a battle and is left alone here. +function BattleState:clearMenuCursors() + self.shiftIndex, self.shiftSwitchIndex = nil, nil + local game = self.game + if not game then return end + game.partyMenuCursor = nil + local pack = game.packCursor + if not pack then return end + pack.pocket = nil + for _, id in ipairs({ "ITEM", "KEY_ITEM", "BALL" }) do + pack.cursor[id] = nil + pack.scroll[id] = nil + end +end + +-- Whether any mon that took part in the battle is still standing. A wild win +-- with none left plays NO music at all (PlayVictoryMusic's `.lost` path), so +-- the map theme carries straight on -- which is what a mon fainting to its own +-- recoil on the winning blow sounds like. +function BattleState:participantsFainted() + local battle = self.battle + if not battle then return true end + for index in pairs(battle.participants or {}) do + local mon = battle.party and battle.party[index] + if mon and (mon.hp or 0) > 0 then return false end + end + return true +end + +function BattleState:playVictoryMusic() + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.songs) then return nil end + local song = BattleMusic.victorySong({ + class = self.music and self.music.class, + participantsFainted = self:participantsFainted(), + }) + if not (song and audio.songs[song]) then return nil end + require("src.core.Music").play(data, song, true, { reason = "victory" }) + return song +end + +function BattleState:submit(action) + self.phase = "resolving" + self:pushAll(self.battle:takeTurn(action)) + self.message = nil + self.messageTimer = 0 + self:advanceQueue() +end + +function BattleState:playerMoves() + return (self.battle and self.battle.player and self.battle.player.moves) or {} +end + +-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374). +-- SELECT marks a slot, SELECT again swaps the marked slot with the one under +-- the cursor, and A or B clears the mark without swapping (the A arm opens +-- `xor a / ld [wSwappingMove], a`). +-- +-- The cart swaps wBattleMonMoves and wBattleMonPP as two separate byte pairs, +-- then repeats both in the party struct. Here a move IS one record carrying +-- its own pp and maxPp, and Battle:switchIn takes the party table by reference +-- (Battle.player is the party entry), so exchanging the two records does all +-- four of those swaps at once and the party keeps the new order after the +-- battle, which is the point of reordering mid-fight. +-- +-- Two things the cart does that this does NOT need: +-- * `.not_swapping_disabled_move` rewrites wPlayerDisableCount's slot nibble +-- so DISABLE keeps pointing at the same MOVE after the swap. Gold's +-- disable here stores the move id (Battle:volatile(mon).disabled == moveId, +-- src/battle/gen2/Battle.lua:3489), so it follows the move already. +-- * `.swap_moves_in_party_struct` is skipped when SUBSTATUS_TRANSFORMED is +-- set -- the COOLTRAINER glitch fix, which stops a Transformed mon writing +-- borrowed moves into its own party slot. One table per mon means the +-- battle copy and the party slot cannot be written separately, so the swap +-- is refused outright while transformed. A transformed mon's moves are +-- borrowed and vanish on switch-out, so there is nothing to reorder. +function BattleState:swapAllowed() + local player = self.battle and self.battle.player + if not player then return false end + return not (self.battle:volatile(player) or {}).transformed +end + +function BattleState:swapMoves(i, j) + if i == j then return end + local moves = self:playerMoves() + local a, b = moves[i], moves[j] + if not (a and b) then return end + moves[i], moves[j] = b, a +end + +function BattleState:update(_dt) + -- The evolution sweep owns the stack (and the low-HP alarm is long over); + -- 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() + local input = self.game and self.game.input + if not input then return end + + -- The intro slide blocks everything: BattleIntroSlidingPics is a plain + -- 72-frame loop with no input read inside it. + if self.slideFrame < BattleAnimView.SLIDE_FRAMES then + self.slideFrame = self.slideFrame + 1 + return + end + + -- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the + -- screen the same way (engine/battle/core.asm:2882). + if self.trainerSlide then + self.trainerSlide = self.trainerSlide + 1 + if self.trainerSlide >= TRAINER_SLIDE_FRAMES then + self.trainerSlide = nil + self.showEnemyTrainer = false + self:advanceQueue() + end + return + end + + -- MonFaintedAnimation is a plain loop with DelayFrames in it, like + -- SlideBattlePicOut above: it owns the screen until the pic is off the + -- field, and the box it emptied stays empty (only a send-out refills it). + if self.faintSlide then + local slide = self.faintSlide + slide.frames = slide.frames + 1 + if slide.frames >= self:faintSlideFrames(slide.side) then + self.picHidden[slide.side] = true + -- SFX_FAINT follows EnemyMonFaintedAnimation, and ClearBox blanks the + -- fainted side's HUD before its text (core.asm:2213-2218, :2202-2205). + if slide.side == "enemy" then + self:playSfx("Sfx_Faint") + self.showEnemyHud = false + else + self.showPlayerHud = false + end + self.faintSlide = nil + self:advanceQueue() + end + return + end + + -- An animation owns the screen for as long as it runs, exactly the way + -- RunBattleAnimScript owns the main loop. + if self.anim then + self:stepAnim(input) + return + end + + -- The bar drain holds the queue the way AnimateHPBar's loop holds the + -- cart: the next event runs once the shown HP has caught the real one. + if self:stepHpAnim() then return end + + if self.phase == "resolving" or self.phase == "intro" then + -- The `call WaitSFX` half of TextCommand_SOUND (home/text.asm:834-835): + -- the line holds, unskippable, for as long as its own sound is sounding. + -- Without it the DUDE's auto-input tapped straight through the "Gotcha!" + -- line and finishBattle closed the tutorial over the jingle. + if self.waitSfx then + if Sound.isPlaying(self.waitSfx) then return end + self.waitSfx = nil + end + if self.messageTimer > 0 then + if self.tutorial then + -- PromptButton really does wait for the button; MESSAGE_FRAMES is this + -- screen's shortcut for that, and the tutorial cannot take it. The + -- DUDE's A lands on frame 0x51 (DudeAutoInput_A), so a line that timed + -- out at 48 would hand his press to whichever screen came next. + self:dudeInput(CatchTutorial.PROMPT_STREAM, + "prompt:" .. tostring(self.message)) + else + self.messageTimer = self.messageTimer - 1 + end + -- A is the page-advance, exactly like a text box. + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + -- AnimateExpBar is called from GiveExperiencePoints AFTER + -- Text_MonGainedExpPoint has been read (engine/battle/core.asm:6884-6888), + -- so the crawl runs with that line still standing and the "grew to level" + -- line waits behind it. + if self:stepExpAnim() then return end + self:advanceQueue() + return + end + + -- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the + -- cart falls straight into ParsePlayerAction, whose .locked_in arm has no + -- MoveSelectionScreen in front of it. + if self.phase == "locked-in" then + local locked = self.battle and self.battle.player + and self.battle:lockedInMove(self.battle.player) + if locked then + self:submit({ kind = "move", move = locked }) + else + self.phase = "menu" + end + return + end + + if self.phase == "menu" then + -- 2x2 grid: left/right swap the column, up/down the row. + if input:wasPressed("left") or input:wasPressed("right") then + self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1 + or self.menuIndex - 1 + elseif input:wasPressed("up") or input:wasPressed("down") then + self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2 + or self.menuIndex - 2 + elseif input:wasPressed("a") then + local choice = MENU[self.menuIndex] + if choice == "FIGHT" then + -- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm + -- :5058-5059): a mon with nothing to spend never sees the list. + local fighter = self.battle and self.battle.player + if fighter and #self:playerMoves() > 0 + and not self.battle:hasUsableMoves(fighter) then + return self:submit({ kind = "move", move = Battle.STRUGGLE }) + end + self.phase = "moves" + -- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1 + -- (engine/battle/core.asm:5111) and the A-press writes the picked row + -- back, so the list reopens on the move used last turn; only + -- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than + -- reset, for a moveset that shrank (Mimic, a forgotten slot). + local moves = self:playerMoves() + self.moveIndex = math.max(1, + math.min(self.moveIndex or 1, math.max(1, #moves))) + elseif choice == "RUN" then + self:submit({ kind = "run" }) + elseif choice == "PACK" then + if self.tutorial then + self:openTutorialPack() + elseif self.contest then + self:throwParkBall() + else + self:openPack() + end + else + self:openParty() + end + end + return + end + + if self.phase == "moves" then + local moves = self:playerMoves() + if input:wasPressed("up") then + self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves + elseif input:wasPressed("down") then + self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 + elseif input:wasPressed("select") then + if self.moveSwapIndex then + self:swapMoves(self.moveSwapIndex, self.moveIndex) + self.moveSwapIndex = nil + elseif self:swapAllowed() then + self.moveSwapIndex = self.moveIndex + end + elseif input:wasPressed("b") then + -- B leaves the list, and a mark never survives it + self.moveSwapIndex = nil + self.phase = "menu" + elseif input:wasPressed("a") then + -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move + -- cancels a pending swap rather than performing it + self.moveSwapIndex = nil + local move = moves[self.moveIndex] + if not move then return end + -- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen` + -- (engine/battle/core.asm:5213-5246): neither spends the turn. + if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end + if self.battle:moveDisabled(self.battle.player, move.id) then + return self:refuseMove(TEXT_MOVE_DISABLED) + end + self:submit({ kind = "move", move = move.id }) + end + return + end + + if self.phase == "ask-nickname" then + -- AskGiveNicknameText ends on `done`, so the line stands while the box is + -- up rather than paging away from under it. + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.nicknameIndex = self.nicknameIndex == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- YesNoMenuHeader carries no STATICMENU_DISABLE_B: B is NO. + return self:answerNickname(false) + elseif input:wasPressed("a") then + return self:answerNickname(self.nicknameIndex == 1) + end + return + end + + -- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls + -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). + if self.phase == "ask-shift" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.shiftIndex = self.shiftIndex == 1 and 2 or 1 + elseif input:wasPressed("b") then + self.phase = "resolving" + return self:advanceQueue() + elseif input:wasPressed("a") then + if self.shiftIndex == 1 then return self:openShiftParty() end + self.phase = "resolving" + return self:advanceQueue() + end + return + end + + if self.phase == "refuse-shift" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self.message = nil + return self:openShiftParty() + end + + if self.phase == "forced-switch" then + -- Reuse the party list so the layout and controls match the start menu's. + self:openParty(true) + return + end + + -- CheckIfCurPartyMonIsFitToFight said no. Its text is read with the list + -- CLOSED here rather than over it (the party menu is its own screen in this + -- port), and the list comes back the moment the line is done -- which is + -- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry. + if self.phase == "refuse-switch" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + local forced = self.refuseForced + self.refuseForced = nil + self.message = nil + if not self:openParty(forced) then + -- No stack to open a list on (headless): the forced arm falls back to + -- the phase that keeps asking, and a voluntary one to the menu. + self.phase = forced and "forced-switch" or "menu" + end + return + end + + if self.phase == "refuse-move" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self.message = nil + self.phase = "moves" + return + end + + if self.phase == "learn-intro" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + -- YesNoBox follows the last page of the text (engine/pokemon/learn.asm:125). + self:nextPage() + if not self.messagePages then self.phase = "ask-forget" end + return + end + + if self.phase == "ask-forget" or self.phase == "stop-learning" then + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.forgetChoice = self.forgetChoice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- YesNoMenuHeader carries no STATICMENU_DISABLE_B: B is NO. + return self:answerForgetPrompt(false) + elseif input:wasPressed("a") then + return self:answerForgetPrompt(self.forgetChoice == 1) + end + return + end + + if self.phase == "choose-forget" then + -- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints + -- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197). + if self.messageTimer > 0 then + self.messageTimer = self.messageTimer - 1 + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self.message = Strings(TEXT_ASK_FORGET_SLOT) + local learn = self.pendingLearn + local mon = learn and self.battle.party[learn.index] + local moves = (mon and mon.moves) or {} + if input:wasPressed("up") then + self.forgetIndex = self.forgetIndex > 1 and self.forgetIndex - 1 or #moves + elseif input:wasPressed("down") then + self.forgetIndex = self.forgetIndex < #moves and self.forgetIndex + 1 or 1 + elseif input:wasPressed("b") then + -- ForgetMove's .cancel sets carry, which is LearnMove's .cancel + -- (engine/pokemon/learn.asm:187-201). + return self:askStopLearning() + elseif input:wasPressed("a") then + local slot = moves[self.forgetIndex] + if slot and HM_MOVES[slot.id] then + -- MoveCantForgetHMText, then `jr .loop`, which re-seeds wMenuCursorY + -- (engine/pokemon/learn.asm:155-157, 193-197). + self.message = Strings(TEXT_CANT_FORGET_HM) + self.messageTimer = MESSAGE_FRAMES + self.forgetIndex = 1 + return + end + self.battle:resolveForget(learn.index, self.forgetIndex, + learn.move, learn.moveName) + self.pendingLearn = nil + self.phase = "resolving" + self:pushAll(self.battle:takeEvents()) + self:advanceQueue() + end + return + end +end + +-- Returns whether the list actually opened, so a caller that has to do +-- something else when it cannot (no stack at all) can tell. +function BattleState:openParty(forced) + local stack = self.game and self.game.stack + if not stack then return false end + self.phase = "submenu" + Screens.push(self.game, "Gen2PartyMenu", { + -- PARTYMENUACTION_CHOOSE_POKEMON for the voluntary list and + -- PARTYMENUACTION_SWITCH for the forced one (engine/battle/core.asm:4795, + -- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list + -- carries BattleMonMenu; PickPartyMonInBattle has no submenu. + prompt = forced and "which" or "choose", + battleSubmenu = not forced, + onCancel = function() + stack:pop() + -- A forced switch cannot be cancelled. + self.phase = forced and "forced-switch" or "menu" + end, + onChoose = function(index, mon) + stack:pop() + -- TryPlayerSwitch's own order, every arm ending on + -- `jp BattleMenuPKMN_Loop` (engine/battle/core.asm:4863-4888). + if not forced and mon == self.battle.player then + return self:refuseSwitch(false, + self:name(mon) .. " is already out.") + end + if not forced and self.battle:switchLocked() then + return self:refuseSwitch(false, + self:name(self.battle.player) .. " can't be recalled!") + end + -- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456). + if mon.isEgg then + return self:refuseSwitch(forced, TEXT_EGG_CANT_BATTLE) + end + if (mon.hp or 0) <= 0 then + -- CheckIfCurPartyMonIsFitToFight (engine/pokemon/party_menu.asm): + -- a fainted pick prints Text_TheresNoWillToFight and returns carry, so + -- the caller re-opens the list -- BattleMenu_PKMN's own loop for a + -- voluntary switch, ForcePickPartyMonInBattle's `jr c` after a faint. + -- Silently dropping the player back on the list (or, worse, back on + -- the battle menu) is what made the forced switch read as taking two + -- or three presses: the cursor opens on the mon that just fainted, and + -- pressing A on it did nothing a player could see. + return self:refuseSwitch(forced) + end + if forced then + -- ForcePickPartyMonInBattle loops on carry: a pick the engine will not + -- take has to come back as the list again, never as the battle menu + -- with a fainted mon standing on the field. + if not self.battle:switch(index) then + return self:refuseSwitch(true) + end + self:pushAll(self.battle:takeEvents()) + self.phase = "resolving" + self:advanceQueue() + else + self:submit({ kind = "switch", index = index }) + end + end, + }) + return true +end + +-- The refusal itself: the line, then the same list again. `forced` is carried +-- so a faint's list comes back with no CANCEL of its own and a voluntary one +-- keeps its own. +function BattleState:refuseSwitch(forced, text) + self.refuseForced = forced and true or false + self.phase = "refuse-switch" + self.message = text or TEXT_NO_WILL_TO_FIGHT + self.messageTimer = MESSAGE_FRAMES +end + +function BattleState:refuseMove(text) + self.phase = "refuse-move" + self.message = text + self.messageTimer = MESSAGE_FRAMES +end + +-- BattleMenu_Pack: `farcall BattlePack`, which is a different jumptable from +-- the field PACK's -- it dispatches on the item's BATTLE menu nibble and never +-- reaches a field effect. The empty world is the same guard MartMenu:enterSell +-- and ItemPcMenu:enterDeposit carry: PackMenu falls back to game.world when it +-- is nil, and that world is the overworld this battle is suspended over. +function BattleState:openPack() + local stack = self.game and self.game.stack + if not stack then return end + self.phase = "submenu" + Screens.push(self.game, "Gen2PackMenu", { + battle = true, + world = {}, + onClose = function() + stack:pop() + self.phase = "menu" + end, + onChoose = function(itemId) + stack:pop() + self:useItem(itemId) + end, + }) +end + +-- BattleMenu_Pack's `.tutorial` arm: `farcall TutorialPack`, and then POKE_BALL +-- goes into wCurItem and DoItemEffect runs WHATEVER the pack came back with -- +-- TutorialPack's own tail writes FALSE to wPackUsedItem, so its answer is +-- discarded. The pack is real all the same: it is drawn from the DUDE's own +-- buffers (wDudeNumItems / wDudeNumBalls, one POTION and one POKE BALL), and +-- the DUDE_RIGHT_A stream armed with it is what crosses from the ITEM pocket to +-- the BALL pocket and picks the ball, which is the whole point of the demo. +function BattleState:openTutorialPack() + local stack = self.game and self.game.stack + if not stack then return self:useItem(CatchTutorial.BALL) end + self.phase = "submenu" + local function throw() + stack:pop() + self:useItem(CatchTutorial.BALL) + end + Screens.push(self.game, "Gen2PackMenu", { + battle = 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 + -- nothing in them may be spent. PackMenu falls back to game.world when + -- this is nil, so it has to be a table. + world = {}, + onClose = throw, + onChoose = throw, + }) + self:dudeInput(CatchTutorial.PACK_STREAM, nil, true) +end + +-- BattleMenu_Pack's `.contest` arm: it does NOT open the pack. PARK_BALL goes +-- straight into wCurItem and DoItemEffect runs, so the third menu slot IS the +-- throw and nothing else can be used inside the park. +function BattleState:throwParkBall() + self:useItem(BugContest.BALL) +end + +-------------------------------------------------------------------------- +-- PokeBallEffect (engine/items/item_effects.asm:213) +-------------------------------------------------------------------------- + +-- data/battle_anims/ball_colors.asm BallColors, in its own order. Anything +-- not listed falls to the terminator row's PAL_BATTLE_OB_GRAY. +local BALL_COLORS = { + MASTER_BALL = "PAL_BATTLE_OB_GREEN", + ULTRA_BALL = "PAL_BATTLE_OB_YELLOW", + GREAT_BALL = "PAL_BATTLE_OB_BLUE", + POKE_BALL = "PAL_BATTLE_OB_RED", + HEAVY_BALL = "PAL_BATTLE_OB_GRAY", + LEVEL_BALL = "PAL_BATTLE_OB_BROWN", + LURE_BALL = "PAL_BATTLE_OB_BLUE", + FAST_BALL = "PAL_BATTLE_OB_BLUE", + FRIEND_BALL = "PAL_BATTLE_OB_YELLOW", + MOON_BALL = "PAL_BATTLE_OB_GRAY", + LOVE_BALL = "PAL_BATTLE_OB_RED", +} +local BALL_COLOR_DEFAULT = "PAL_BATTLE_OB_GRAY" + +-- POKE_BALL's own item id (constants/item_constants.asm:13), for a cache whose +-- items table has no index on the row. +local POKE_BALL_ID = 5 + +-- data/battle/wobble_probabilities.asm WobbleProbabilities: catch rate, then +-- the chance out of 255 of wobbling again rather than breaking free. +local WOBBLE_PROBABILITIES = { + { 1, 63 }, { 2, 75 }, { 3, 84 }, { 4, 90 }, { 5, 95 }, { 7, 103 }, + { 10, 113 }, { 15, 126 }, { 20, 134 }, { 30, 149 }, { 40, 160 }, + { 50, 169 }, { 60, 177 }, { 80, 191 }, { 100, 201 }, { 120, 211 }, + { 140, 220 }, { 160, 227 }, { 180, 234 }, { 200, 240 }, { 220, 246 }, + { 240, 251 }, { 254, 253 }, { 255, 255 }, +} + +-- GetPokeBallWobble's `cp 3 + 1`: the ball wobbles up to three times and the +-- fourth call is the verdict. +local WOBBLE_LIMIT = 3 + +-- The four failure lines, indexed by wThrownBallWobbleCount exactly the way +-- item_effects.asm:414-428 indexes them (data/text/common_3.asm:239-258). +local BALL_FAILURE_TEXT = { + "Oh no! The POKéMON broke free!", + "Aww! It appeared to be caught!", + "Aargh! Almost had it!", + "Shoot! It was so close too!", +} + +-- Text_BallCaught's own sound_caught_mon (data/text/common_3.asm:265). +local SFX_CAUGHT_MON = "Sfx_CaughtMon" + +-- wBattleAnimParam for the throw: the item's own id, except that everything +-- past POKE_BALL (the Kurt balls) is thrown with POKE_BALL's -- `cp POKE_BALL +-- + 1 / jr c, .not_kurt_ball / ld a, POKE_BALL` (item_effects.asm:396). It is +-- what BattleAnim_ThrowPokeBall's anim_if_param_equal rows branch on +-- (data/moves/animations.asm:305-308). +function BattleState:ballAnimParam(itemId) + local items = ((self.game and self.game.data) or {}).items or {} + local pokeBall = (items.POKE_BALL and items.POKE_BALL.index) or POKE_BALL_ID + local id = (items[itemId] and items[itemId].index) or pokeBall + if id > pokeBall then return pokeBall end + return id +end + +-- GetBallAnimPal (engine/battle_anims/functions.asm:292), which the thrown +-- ball's object function reads out of env.ballPalette. +function BattleState:ballPalette(itemId) + return BALL_COLORS[itemId] or BALL_COLOR_DEFAULT +end + +-- `ld de, ANIM_THROW_POKE_BALL ... xor a / ldh [hBattleTurn], a`: the ball is +-- thrown from the player's side whatever the turn order was. +function BattleState:startBallAnim(param, itemId) + local key = self.anims and self.anims.ids + and self.anims.ids.ANIM_THROW_POKE_BALL + return self:startAnim(key, { + turn = 0, animId = "ANIM_THROW_POKE_BALL", param = param, + ballPalette = self:ballPalette(itemId), + }) +end + +-- GetPokeBallWobble (engine/battle_anims/pokeball_wobble.asm), which +-- anim_checkpokeball loops on: 0 wobble again, 1 click, 2 break free. The +-- counter goes up FIRST, and the fourth call ends the loop -- a caught mon +-- clicks, anything else breaks free. Before that a caught mon always wobbles +-- again and a doomed one re-rolls: the first WobbleProbabilities row whose +-- catch rate is at least the final rate is the one whose byte the roll has to +-- come in under. +function BattleState:pokeballWobble() + local throw = self.ballThrow + if not throw then return 0 end + throw.wobble = (throw.wobble or 0) + 1 + if throw.wobble == WOBBLE_LIMIT + 1 then + return throw.caught and 1 or 2 + end + if throw.caught then return 0 end + local chance = 0 + for _, row in ipairs(WOBBLE_PROBABILITIES) do + if row[1] >= (throw.rate or 0) then chance = row[2] break end + end + local random = self.battle and self.battle.random + local roll = random and random(256) or 0 + return roll < chance and 0 or 2 +end + +-- Which of the four lines the throw earned. With no animation to run (an +-- older cache, or BATTLE SCENE off) nothing ever wobbled, so it is the first. +function BattleState:ballFailureText() + local wobble = (self.ballThrow and self.ballThrow.wobble) or 1 + return BALL_FAILURE_TEXT[math.max(1, math.min(#BALL_FAILURE_TEXT, wobble))] +end + +-- UseBallInTrainerBattle (item_effects.asm:2579). Not a bare refusal: the ball +-- is thrown with wBattleAnimParam = 0, which BattleAnim_ThrowPokeBall's +-- `anim_if_param_equal NO_ITEM` sends to .TheTrainerBlockedTheBall, then BOTH +-- lines print and it falls into UseDisposableItem -- so the ball is spent and +-- the turn goes with it. +function BattleState:throwBallAtTrainer(itemId) + self.queue = {} + self:push({ kind = "message", text = "The trainer blocked the BALL!" }) + self:push({ kind = "message", text = "Don't be a thief!" }) + self:consumeItem(itemId) + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + -- NO_ITEM is 0, the id BattleAnim_ThrowPokeBall's first row tests. + self:startBallAnim(0, itemId) + self.message = nil + self.messageTimer = 0 + self.phase = "resolving" + if not self.anim then self:advanceQueue() end +end + +-- Which box `.SendToPC` writes into. wCurBox is a BYTE the cart masks before +-- it ever indexes with it (`ld a, [wCurBox] / and $f`, and GetBoxCount's own +-- bounds), so no value of it can address a box that is not there; this save +-- field holds the same number 1-based. The mask is worth keeping because +-- Boxes.box answers an index outside 1..NUM_BOXES with a THROWAWAY table +-- rather than an error (src/core/gen2/Boxes.lua), so an out-of-range wCurBox +-- would insert the catch into a table nothing owns while "was sent to BILL's +-- PC" printed -- a silently lost mon. +-- +-- No path writes one today, and the comment that used to stand here was wrong +-- to say a converted cartridge save does: GenSave decodes the cart's 0-based +-- byte as `curBoxNum + 1` clamped to 1..12 before it ever reaches +-- save.currentBox (src/save_convert/GenSave.lua), and SaveConvert refuses a +-- Gen 2 cart save outright anyway; Save.lua defaults the field to 1 and +-- Boxes.setCurrent validates every write. So this is the cart's own mask, +-- not a fix for a reachable state. What it DOES buy every day is that the +-- storage gate (Ball_BoxIsFullMessage), the box-just-filled test and the +-- insert all ask about the SAME box, so those three can never disagree. +function BattleState:currentBox() + local save = self.save + local index = (save and tonumber(save.currentBox)) or 1 + return math.max(1, math.min(Boxes.NUM_BOXES, math.floor(index))) +end + +function BattleState:hasPokedex() + local save = self.save or {} + return (save.engineFlags or {})[ENGINE_POKEDEX] == true + or save.pokedexReceived == true +end + +-- PokeBallEffect's caught tail, in the cart's order (item_effects.asm:514-676): +-- Text_GotchaMonWasCaught, CheckCaughtMon / SetSeenAndCaughtMon, the new-entry +-- line and NewPokedexEntry, the party add or .SendToPC, then +-- AskGiveNicknameText. +function BattleState:pushCaught(enemy, itemId) + local save = self.save + self.battle.over = true + self.battle.outcome = "caught" + -- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught + -- arm ends on the return-mon BG effect, which leaves the enemy pic box + -- cleared, and PokeBallEffect never draws a frontpic again -- there is no + -- send-out left in a battle that is already over. Latched here as well as + -- from the animation's own last step so that a throw the player skipped with + -- B (BattleAnimRunScript has no such skip; this port does) cannot put the + -- caught mon back on the field for the "Gotcha!" line. + self.picHidden.enemy = true + -- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to + -- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's + -- whole effect; its catch rate is a plain ball's. It applies on the box + -- path too (item_effects.asm:620-625). + if itemId == "FRIEND_BALL" then + enemy.happiness = Catching.FRIEND_BALL_HAPPINESS + end + -- Text_BallCaught ends in `sound_caught_mon` (data/text/common_3.asm:260-266), + -- and TX_SOUND holds the text engine until the jingle is done + -- (home/text.asm:834-835), so the line is not dismissable under it. + self:push({ kind = "message", sfx = SFX_CAUGHT_MON, waitSfx = true, + text = "Gotcha! " .. self:name(enemy) .. " was caught!" }) + -- BATTLETYPE_TUTORIAL returns before every one of the steps below + -- (`.FinishTutorial`, and `.return_from_capture: ret z`). + if self.tutorial or not save then return end + -- TryAddMonToParty and BugContest_SetCaughtContestMon both end in the same + -- wPlayerID write (item_effects.asm:548-556, :680; move_mon.asm:143-149). + Mon.stampOT(save, enemy) + save.pokedex = save.pokedex or { seen = {}, caught = {} } + -- CheckCaughtMon answers whether this row was ALREADY owned, and it is asked + -- before SetSeenAndCaughtMon stamps it (item_effects.asm:519-527). Both run + -- BEFORE the `.catch_bug_contest_mon` branch, so the dex is marked even + -- though a contest mon is only being HELD. + local knew = save.pokedex.caught[enemy.species] and true or false + save.pokedex.caught[enemy.species] = true + save.pokedex.seen[enemy.species] = true + -- NewDexDataText and `predef NewPokedexEntry` both run above `.skip_pokedex`, + -- so the contest branch is BELOW them (item_effects.asm:528-546), and + -- CheckReceivedDex gates the pair (:532-533). + if not knew and self:hasPokedex() then + self:push({ kind = "message", + text = self:name(enemy) .. "'s data was newly added to the #DEX." }) + self:push({ kind = "dex-entry", species = enemy.species }) + end + if self.contest then + return self:contestCatch(enemy) + end + save.party = save.party or {} + local toPc = #save.party >= Boxes.PARTY_SIZE + if toPc then + -- `.SendToPC` / `predef SendMonIntoBox` (item_effects.asm:548-550, 604): + -- a full party sends the catch to the current box. Not Boxes.deposit, + -- which is the PC's own party-to-box move and carries the last-healthy-mon + -- and mail refusals that have nothing to do with a capture. + local box = Boxes.box(save, self:currentBox()) + -- SendMonIntoBox inserts at the HEAD: its species loop cascades every + -- entry one slot further down (move_mon.asm:954-965) and ShiftBoxMon does + -- the same for the OT names, nicknames and mon structs (:968, :1074-1085), + -- so the catch lands in slot 1 -- which is what lets the FRIEND_BALL arm + -- write sBoxMon1Happiness unconditionally ("The captured mon is now first + -- in the box", item_effects.asm:624). Boxes.deposit stays an append: the + -- PC's own move is InsertPokemonIntoBox, which inserts at the cursor. + table.insert(box, 1, enemy) + -- `.SendToPC` re-reads sBoxCount AFTER the insert and sets + -- BATTLERESULT_BOX_FULL when the box has just filled + -- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit + -- on the wild arm and rings the player as PHONE_BILL on the first step + -- back in the overworld (engine/overworld/scripting.asm:1097-1104). + if Boxes.isFull(save, self:currentBox()) then + self.battle.boxFilled = true + end + else + save.party[#save.party + 1] = enemy + end + -- AddPartyMon's `.registerunowndex` and SendMonIntoBox's `.not_unown` are the + -- two places the cart appends to wUnownDex, and both are on this path: the + -- form list is what UNOWN MODE and VAR_UNOWNCOUNT read + -- (src/core/gen2/Unown.lua). A contest catch is only HELD, so it returned + -- above without registering. + Unown.registerCatch(save, enemy) + -- Same name and same payload keys as the Gen 1 site + -- (src/battle/BattleState.lua's pokemon.caught), so one subscription covers + -- both games: `isNew` is CheckCaughtMon's answer read BEFORE + -- SetSeenAndCaughtMon stamped it, and `destination` is which of the two + -- homes PokeBallEffect actually used. Emitted after the mon is in that + -- home and after Unown.registerCatch, so a listener reading save.party, + -- the box or the Unown dex sees the settled state. A tutorial catch and a + -- contest catch both returned above without ever owning the mon, which is + -- why neither reaches this line. + Runtime.emit("pokemon.caught", { + battle = self.battle, mon = enemy, species = enemy.species, + isNew = not knew, ball = itemId, + destination = toPc and "box" or "party", game = self.game, + }) + self:push({ kind = "ask-nickname", mon = enemy }) + if toPc then + -- BallSentToPCText, which .SendToPC prints AFTER the nickname prompt + -- (item_effects.asm:672). + self:push({ kind = "message", + text = self:name(enemy) .. " was sent to BILL's PC." }) + end +end + +-- CheckWhetherToAskSwitch: a started battle, more than one mon, no link, the +-- BATTLE_SHIFT bit CLEAR (which is SHIFT), and the active mon not fainted +-- (engine/battle/core.asm:3269-3295, engine/menus/options_menu.asm:249-256). +function BattleState:shiftOfferAllowed() + local battle = self.battle + if not (battle and battle.player and battle.trainer) then return false end + if #(battle.party or {}) < 2 then return false end + if (battle.player.hp or 0) <= 0 then return false end + local options = self.game and self.game.options + return (options and options.battleStyle or "SHIFT") == "SHIFT" +end + +-- OfferSwitch: Battle_GetTrainerName, the prompt, then PlaceYesNoBox +-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). +function BattleState:offerShiftSwitch(mon) + self.shiftIndex = 1 + self.phase = "ask-shift" + local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" + local player = (self.save and self.save.player and self.save.player.name) + or "GOLD" + self.message = trainer .. " is about to use " .. self:name(mon) + .. ". Will " .. player .. " change POKéMON?" + self.messageTimer = MESSAGE_FRAMES +end + +-- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is +-- PARTYMENUACTION_SWITCH and carries no submenu; a cancel is `.canceled_switch` +-- and answers exactly like NO (:3327). +function BattleState:openShiftParty() + local stack = self.game and self.game.stack + if not stack then + self.phase = "resolving" + return self:advanceQueue() + end + self.phase = "submenu" + Screens.push(self.game, "Gen2PartyMenu", { + prompt = "which", + onCancel = function() + stack:pop() + self.phase = "resolving" + self:advanceQueue() + end, + onChoose = function(index, mon) + stack:pop() + if mon == self.battle.player then + return self:refuseShift(self:name(mon) .. " is already out.") + end + if mon.isEgg then return self:refuseShift(TEXT_EGG_CANT_BATTLE) end + if (mon.hp or 0) <= 0 then return self:refuseShift(nil) end + self.shiftSwitchIndex = index + self.phase = "resolving" + self:advanceQueue() + end, + }) +end + +-- PickSwitchMonInBattle loops on carry the way BattleMenuPKMN_Loop does +-- (engine/battle/core.asm:2716-2728). +function BattleState:refuseShift(text) + self.phase = "refuse-shift" + self.message = text or TEXT_NO_WILL_TO_FIGHT + self.messageTimer = MESSAGE_FRAMES +end + +-- AskGiveNicknameText + YesNoBox (item_effects.asm:566-578). B is the NO arm +-- (`jp c, .return_from_capture`), which leaves the species name standing. +function BattleState:askNickname(mon) + self.nicknameMon = mon + -- YesNoBox opens on YES; YesNoMenuHeader sets no STATICMENU_DISABLE_B. + self.nicknameIndex = 1 + self.phase = "ask-nickname" + self.message = "Give a nickname to " .. self:name(mon) .. "?" + self.messageTimer = MESSAGE_FRAMES +end + +-- One prompt page in the box, with the rest held for the presses `para` and +-- `cont` wait on (home/text.asm:403-448). +function BattleState:showPages(text) + local pages = paginate(text) + self.messagePages = #pages > 1 and pages or nil + self.messagePage = 1 + self.message = pages[1] + self.messageTimer = MESSAGE_FRAMES +end + +function BattleState:nextPage() + local pages = self.messagePages + if not pages then return false end + local i = self.messagePage + 1 + self.messagePage = i + self.message = pages[i] + self.messageTimer = MESSAGE_FRAMES + if i >= #pages then self.messagePages = nil end + return true +end + +-- ForgetMove's AskForgetMoveText + YesNoBox (engine/pokemon/learn.asm:123-127); +-- LearnMove's `jp c, .loop` reprints the whole text, so this is the loop head. +function BattleState:askForget() + if not self.pendingLearn then return self:advanceQueue() end + self.forgetIndex = 1 + self.forgetChoice = 1 + local party = (self.battle and self.battle.party) or {} + local name = self:name(party[self.pendingLearn.index]) + local moveName = self.pendingLearn.moveName or "?" + self:showPages(Strings(TEXT_ASK_FORGET_MOVE, name, moveName, name, moveName)) + self.phase = self.messagePages and "learn-intro" or "ask-forget" +end + +-- LearnMove's .cancel: StopLearningMoveText, and a NO is `jp c, .loop` +-- (engine/pokemon/learn.asm:104-108). +function BattleState:askStopLearning() + if not self.pendingLearn then return self:advanceQueue() end + self.forgetChoice = 1 + self.phase = "stop-learning" + self:showPages(Strings(TEXT_STOP_LEARNING, self.pendingLearn.moveName or "?")) +end + +-- DidNotLearnMoveText, then `ld b, 0` and back to the queue (learn.asm:110-113). +function BattleState:finishDecline() + local learn = self.pendingLearn + self.pendingLearn = nil + self.phase = "resolving" + if learn then self.battle:declineForget(learn.index, learn.moveName) end + self:pushAll(self.battle:takeEvents()) + self:advanceQueue() +end + +function BattleState:answerForgetPrompt(yes) + if self.phase == "ask-forget" then + if not yes then return self:askStopLearning() end + -- MoveAskForgetText over the four-slot list (learn.asm:135-146). + self.forgetIndex = 1 + self.phase = "choose-forget" + self.message = Strings(TEXT_ASK_FORGET_SLOT) + self.messageTimer = 0 + return + end + if yes then return self:finishDecline() end + return self:askForget() +end + +function BattleState:answerNickname(yes) + local mon = self.nicknameMon + self.nicknameMon = nil + self.phase = "resolving" + local stack = self.game and self.game.stack + if not (yes and mon and stack) then return self:advanceQueue() end + self.phase = "submenu" + local data = (self.game and self.game.data) or {} + local icons = data.gen2Icons + local iconId = icons and icons.species and icons.species[mon.species] + local entry = iconId and icons.icons and icons.icons[iconId] + local done = function(name) + stack:pop() + -- InitName: an empty entry keeps whatever was already in the buffer, which + -- for a fresh capture is the species name. + if name and #name > 0 then mon.nickname = name end + self.phase = "resolving" + self:advanceQueue() + end + Screens.push(self.game, "Gen2NamingScreen", { + type = "nickname", + monName = mon.name or mon.species, + iconPath = entry and entry.image or nil, + menuGfx = data.gen2MenuGfx, + onDone = done, + onCancel = function() done(nil) end, + }) +end + +-- BugContest_SetCaughtContestMon (engine/events/bug_contest/caught_mon.asm). +-- With nothing in stock the catch is kept outright (`.firstcatch`); with a mon +-- already in stock the player is shown the comparison and asked, and the NO arm +-- -- which is also what B does -- keeps the mon they already had. +function BattleState:contestCatch(mon) + local kind, stock, fresh = BugContest.catch(self.save, mon) + if kind ~= BugContest.ASK_SWITCH then + self:push({ kind = "message", text = "Caught " .. self:name(mon) .. "!" }) + return + end + self:push({ kind = "message", + text = "You already caught a " .. self:name(stock) .. "." }) + self:push({ kind = "contest-switch", stock = stock, caught = fresh }) +end + +function BattleState:openContestSwitch(event) + local stack = self.game and self.game.stack + if not stack then return self:advanceQueue() end + self.phase = "submenu" + Screens.push(self.game, "Gen2ContestMenu", { + save = self.save, + stock = event.stock, + caught = event.caught, + onClose = function() + stack:pop() + self.phase = "resolving" + self:advanceQueue() + end, + }) +end + +-- NewPokedexEntry: the dex opens straight on the new species' entry and pages +-- twice (engine/pokedex/new_pokedex_entry.asm:19-23). +function BattleState:openDexEntry(species) + local stack = self.game and self.game.stack + local dex = ((self.game and self.game.data) or {}).gen2Pokedex + local entry = dex and dex.entries and dex.entries[species] + if not (stack and entry) then return self:advanceQueue() end + self.phase = "submenu" + Screens.push(self.game, "Gen2PokedexMenu", { + entrySpecies = species, + newEntry = true, + onClose = function() + stack:pop() + self.phase = "resolving" + self:advanceQueue() + end, + }) +end + +-- Items in battle: balls try a catch, the stat items apply their stage, and +-- everything with a ported party effect runs the same item_effects.asm routine +-- the field pack runs. Anything else reports that it cannot be used, which is +-- what the cart does for a key item. +function BattleState:useItem(itemId) + local data = self.game and self.game.data or {} + local def = data.items and data.items[itemId] + local pocket = def and def.pocket + local save = self.save + + if pocket == "BALL" then + -- `ld a, [wBattleMode] / dec a / jp nz, UseBallInTrainerBattle`, the very + -- first thing PokeBallEffect does. + if not self.battle.wild then + return self:throwBallAtTrainer(itemId) + end + -- The storage gate, before the ball is spent and before the rate is + -- computed (item_effects.asm:217-226): a full party AND a full current box + -- takes Ball_BoxIsFullMessage, which writes wItemEffectSucceeded = 2 -- + -- "item wasn't used" -- so neither the ball nor the turn goes. + if #((save and save.party) or {}) >= Boxes.PARTY_SIZE + and Boxes.isFull(save, self:currentBox()) then + -- BallBoxFullText (data/text/common_3.asm:427). + self.message = "The POKéMON BOX is full. That can't be used now." + self.messageTimer = MESSAGE_FRAMES + self.phase = "resolving" + return + end + local enemy = self.battle.enemy + local enemyDef = data.pokemon and data.pokemon[enemy.species] + local caught, rate + if self.tutorial then + -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / + -- jp z, .catch_without_fail`, checked BEFORE the Master Ball and before + -- the rate is ever computed. The tail then returns early for a tutorial + -- battle (`.return_from_capture: ret z`), which is why the DUDE's + -- RATTATA is not added to a party, not written to the Pokedex and not + -- registered in wUnownDex, and why the ball is not tossed out of the + -- bag: the bag it came from was never the player's. The THROW still + -- happens: `.catch_without_fail` falls into the shared animation. + caught, rate = true, 255 + else + -- The specialty-ball conditions (BallMultiplierFunctionTable): each one + -- is something this screen already knows. Heavy Ball reads the dex + -- weight, Moon Ball the species' stone row, Love Ball both genders, + -- Level Ball the two levels, Lure Ball wBattleType. + local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species] + local evolveItem + for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do + if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end + end + local player = self.battle.player + caught, rate = Catching.attempt({ + maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), + hp = enemy.hp, + catchRate = enemyDef and enemyDef.catchRate or 45, + ball = itemId, + status = enemy.status, + random = self.battle.random, + weight = dexEntry and dexEntry.weight, + level = enemy.level, + playerLevel = player and player.level, + fishing = self.battle.battleType == "fish", + species = enemy.species, + gender = enemy.gender, + playerSpecies = player and player.species, + playerGender = player and player.gender, + evolveItem = evolveItem, + }) + end + -- wWildMon carries the answer through the animation, and + -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per + -- wobble -- which is what the failure line is picked from afterwards. + self.ballThrow = { caught = caught, rate = rate or 0, wobble = 0 } + -- A PARK BALL is never in the bag: PokeBallEffect's `.used_park_ball` does + -- `dec [hl]` on wParkBallsRemaining instead of tossing an item, so the + -- contest takes its ball off the counter and leaves the pack alone. The + -- tutorial spends nothing at all (`.return_from_capture: ret z`). + if not self.tutorial then + if self.contest then + if not caught then BugContest.useBall(save) end + else + self:consumeItem(itemId) + end + end + self.queue = {} + if caught then + self:pushCaught(enemy, itemId) + else + -- Resolved at drain time, because which of the four lines it is depends + -- on how far the wobble counter got inside the animation. + self:push({ kind = "ball-result" }) + -- A failed ball still costs the turn. + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + -- CheckContestBattleOver: the throw that empties the counter turns the + -- battle into a DRAW there and then, which is what sends the player back + -- to the gate instead of into the next patch of grass. + if self.contest and BugContest.isOver(save) then + self.battle.over = true + self.battle.outcome = "draw" + end + end + -- item_effects.asm:405-412: wBattleAnimParam from wCurItem, hBattleTurn 0, + -- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything + -- pushed above is drained only once the ball has finished wobbling. + self:startBallAnim(self:ballAnimParam(itemId), itemId) + self.message = nil + self.messageTimer = 0 + self.phase = "resolving" + if not self.anim then self:advanceQueue() end + return + end + + -- The battle stat items (XItemEffect, XAccuracyEffect, DireHitEffect, + -- GuardSpecEffect): the engine applies the stage or the substatus bit and + -- this side spends the item and the turn. A refused re-use + -- (WontHaveAnyEffect_NotUsedMessage) costs neither. + if Battle.X_ITEM_STATS[itemId] or Battle.SUBSTATUS_ITEMS[itemId] then + local ok = self.battle:useBattleItem(itemId) + if not ok then + -- _ItemWontHaveEffectText's own `line` break, the same one + -- ItemEffects.TEXT_NO_EFFECT carries (data/text/common_3.asm). + self.message = ItemEffects.TEXT_NO_EFFECT + self.messageTimer = MESSAGE_FRAMES + self.phase = "resolving" + return + end + if save and save.inventory then + save.inventory[itemId] = math.max(0, (save.inventory[itemId] or 1) - 1) + if save.inventory[itemId] == 0 then save.inventory[itemId] = nil end + end + self.queue = {} + self:pushAll(self.battle:takeEvents()) + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + self.phase = "resolving" + self:advanceQueue() + return + end + + -- BattlePack's .ItemFunctionJumptable (engine/items/pack.asm): its first + -- four entries are all .Oak, so an item that is ITEMMENU_NOUSE in a battle + -- does nothing there at all. The gate has to sit here rather than in the + -- pack, because the battle pack has no field-menu filter of its own and the + -- two nibbles disagree: a RARE CANDY is ITEMMENU_PARTY in the FIELD and + -- would otherwise level a mon mid-fight, and a BITTER BERRY is the reverse. + if not (def and def.battleMenu == "ITEMMENU_NOUSE") then + -- BitterBerryEffect: its whole effect is a battle substatus, so it has no + -- field row for src/core/gen2/ItemEffects.lua to carry and it never opens + -- the party list. + if itemId == "BITTER_BERRY" then + return self:cureBattleConfusion(itemId) + end + -- Everything else the pack can spend on a party mon runs the same + -- item_effects.asm routine the field pack runs: the potion line and the + -- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and + -- the ETHER / ELIXER family. + local action = ItemEffects.partyAction(itemId) + if action then + return self:useOnPartyMon(itemId, action) + end + end + + self.message = "That isn't going to help here." + self.messageTimer = MESSAGE_FRAMES + self.phase = "resolving" +end + +-- UseItem_SelectMon (engine/items/item_effects.asm): every party-target item +-- picks its mon FIRST, so a benched mon can be healed, cured or stood back up +-- mid-battle -- ItemRestoreHP, StatusHealingEffect, ReviveEffect and +-- RestorePPEffect all open the list before they do anything. Backing out is +-- the .SelectMon carry path: back to the pack with nothing spent. +function BattleState:useOnPartyMon(itemId, action) + local stack = self.game and self.game.stack + if not stack then + return self:applyPartyItem(itemId, action, self.battle.player) + end + self.phase = "submenu" + Screens.push(self.game, "Gen2PartyMenu", { + prompt = "useItem", + party = self.battle.party or (self.save and self.save.party), + onCancel = function() + stack:pop() + self:openPack() + end, + onChoose = function(_, mon) + -- RestorePPEffect: the ETHER pair needs the move pick first, the ELIXER + -- pair walks every slot without one, and an EGG refuses before the move + -- list ever opens (UseItem_SelectMon's `cp EGG`). + local row = (action == "pp") and ItemEffects.RESTORE_PP[itemId] or nil + if row and not row.each and mon and not mon.isEgg then + return self:pickMoveForItem(itemId, mon) + end + stack:pop() + self:applyPartyItem(itemId, action, mon) + end, + }) +end + +-- RestorePPEffect's "Restore the PP of which move?" pick. MoveSelectionScreen +-- and ChooseMoveToDelete are the same SetUpMoveList box on the cart, so the +-- port serves both with src/ui/gen2/MoveDeleter.lua. Backing out drops only +-- the move list and leaves the party list standing, which is the routine's own +-- `jr nz, .loop`. +function BattleState:pickMoveForItem(itemId, mon) + local stack = self.game.stack + Screens.push(self.game, "Gen2MoveDeleter", { + mon = mon, + moves = self.game.data and self.game.data.moves, + onCancel = function() stack:pop() end, + onChoose = function(slot) + stack:pop() -- the move list + stack:pop() -- the party list + self:applyPartyItem(itemId, "pp", mon, slot) + end, + }) +end + +-- BitterBerryEffect: it reads wPlayerSubStatus3 straight off, so it acts on +-- whoever is out and a mon that is not confused refuses without spending +-- anything. UseItemText falls through into UseDisposableItem, so a cure does +-- cost the berry. +function BattleState:cureBattleConfusion(itemId) + local mon = self.battle.player + local state = mon and self.battle:volatile(mon) + if not (state and state.confuseCount) then + self.message = oneLine(ItemEffects.TEXT_NO_EFFECT) + self.messageTimer = MESSAGE_FRAMES + self.phase = "resolving" + return + end + state.confuseCount = nil + self:consumeItem(itemId) + self.queue = {} + -- ConfusedNoMoreText (data/text/battle.asm). + self:push({ kind = "message", + text = self:name(mon) .. "'s confused no more!" }) + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + self.phase = "resolving" + self:advanceQueue() +end + +-- UseDisposableItem: one copy leaves the pack, and only on a success -- every +-- refusal above returns before this. +function BattleState:consumeItem(itemId) + local save = self.save + if not (save and save.inventory) then return end + save.inventory[itemId] = math.max(0, (save.inventory[itemId] or 1) - 1) + if save.inventory[itemId] == 0 then save.inventory[itemId] = nil end +end + +-- The effect itself. ItemEffects owns the item_effects.asm arithmetic and +-- every refusal it prints (an EGG, a fainted or full-HP heal target, a healthy +-- revive target, a PP slot already full); this side adds the arm that only +-- exists with a battle up, spends the item where UseDisposableItem sits, and +-- pays the turn the pack costs. +function BattleState:applyPartyItem(itemId, action, mon, slot) + local data = (self.game and self.game.data) or {} + local before = (mon and mon.hp) or 0 + local result + if action == "pp" then + result = ItemEffects.usePpItem(itemId, mon, slot) + else + result = ItemEffects.useOnMon(itemId, mon, data) + end + -- HealStatus's `.not_full_heal` and IsItemUsedOnConfusedMon: a $ff-mask item + -- used on whoever is OUT also clears SUBSTATUS_CONFUSED, and clears it even + -- when the status byte was already empty -- which is the one case where a + -- FULL HEAL that the field routine refuses is still spent in battle. + if mon and FULL_MASK_HEALERS[itemId] and mon == self.battle.player + and self.battle:volatile(mon).confuseCount then + self.battle:volatile(mon).confuseCount = nil + if not result.used then + -- PARTYMENUTEXT_HEAL_CONFUSION (_CameToItsSensesText). + result = { used = true, + text = self:name(mon) .. " came to its senses." } + end + end + if not result.used then + self.message = oneLine(result.text) + self.messageTimer = MESSAGE_FRAMES + self.phase = "resolving" + return + end + self:consumeItem(itemId) + self.queue = {} + if mon == self.battle.player and (mon.hp or 0) ~= before then + -- Every HP-restoring effect zeroes wLowHealthAlarm BEFORE it touches the + -- HP or runs HealHP_SFX_GFX (RestoreHPEffect, engine/items/item_effects.asm: + -- 1657-1658; .FullRestore :1580-1581; .skip_to_revive :1542-1543), so the + -- siren dies with the item rather than with the bar animation -- the bar + -- is still in the red for the whole climb. Not a latch: CheckDanger runs + -- again from UpdatePlayerHUD once AnimateHPBar has finished, so a heal + -- that leaves the mon in the red correctly starts the siren back up. + self:stopAlarm() + self.healSilence = true + -- The active mon's HP change carries its new value so the HUD bar refills + -- on screen (HealHP_SFX_GFX runs AnimateHPBar for exactly this case). + self:push({ kind = "heal", side = "player", hp = mon.hp, + text = oneLine(result.text) }) + else + self:push({ kind = "message", text = oneLine(result.text) }) + end + self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId })) + self.phase = "resolving" + self:advanceQueue() +end + +-- The HUD is not a box: engine/battle/core.asm draws an L-shaped frame out of +-- four tiles (DrawEnemyHUDBorder / DrawPlayerHUDBorder) -- a horizontal rule +-- under the whole thing with a short vertical stub at one end, opening left for +-- the enemy and right for the player. Name and level sit on plain background +-- above it, not inside a border. +function BattleState:drawFrame(tx, ty, width, stubRight) + local G = love.graphics + G.setColor(0, 0, 0, 1) + -- The bottom rule ($76 repeated, capped by $74/$78 or $6f/$77) sits at the + -- top of its own tile row, immediately under the bar above it. + G.rectangle("fill", tx * 8, ty * 8, width * 8, 2) + -- The vertical stub ($6d on the enemy's left, $73 on the player's right) + -- climbs from the rule past the bar row. + local stubX = stubRight and ((tx + width - 2) * 8 + 6) or (tx * 8) + G.rectangle("fill", stubX, ty * 8 - 8, 2, 10) +end + +-- LoadBattleFontsHPBar puts FontBattleExtra in the $60 slot for the whole +-- battle, which is why the HUD's level reads as the bold ":L" glyph ($6e) and +-- not the two characters ':' and 'L'. The message box below is ordinary text, +-- so the swap is scoped to the HUD. +-- Which mon a side DRAWS. Battle finishes the whole turn before the first +-- message is displayed, so battle.enemy is already the replacement while the +-- outgoing mon's "fainted!" line is still on screen; the queue's own copy is +-- what keeps the pic and the name where the cart has them. +function BattleState:activeMon(side) + local shown = self.shownMon and self.shownMon[side] + if shown ~= nil then return shown end + return self.battle and self.battle[side] or nil +end + +function BattleState:drawHud() + local wasBattle = Font.useBattleExtra(true) + local enemy, player = self:activeMon("enemy"), self:activeMon("player") + + -- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols): + -- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1); + -- the HP bar's "HP:" at (2,2); the border from (1,2). + -- ClearActorHud blanks this whole block while that side's move animation + -- runs, so a shake or a slide does not drag the HP bar with it. + -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands + -- slide in over a blanked tilemap (core.asm:8554/8564). + if self.showEnemyHud and not self:hudCleared("enemy") then + Chrome.print(self:name(enemy), 1, 0) + -- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns + -- the digits after it, so the glyph is pinned to column 6 whether the level + -- is 5 or 100; only a three-digit level moves, and it does so by eating the + -- <LV> tile. The gender symbol sits past the two digit columns, at (9,1). + -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag + -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops + -- the level entirely while one is up. + Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)), + 6, 1) + local enemyGender = self:genderSymbol(enemy) + if enemyGender then Chrome.print(enemyGender, 9, 1) end + -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at + -- (1,1) (engine/battle/trainer_huds.asm:140-152). + if self.battle and self.battle.wild and self:dexCaught(enemy) then + self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"), + enemy.maxHp or (enemy.stats and enemy.stats.hp)) + end + self:drawHpBar(enemy, "enemy", 2, 2) + -- Stub on the LEFT (tile $6d), rule on the row under the bar. + if self.hud:available() then + self.hud:drawEnemyFrame() + else + self:drawFrame(1, 3, 10, false) + end + end + + self:drawPic(enemy, false) + + -- Player HUD (DrawPlayerHUD clears (9,7) 5 rows x 11 cols): + -- name at (10,7); PrintLevel at (14,8) with the gender at (17,8); + -- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the + -- border from (18,10) going left, and the exp bar at (10,11). + self:drawPic(player, true) + -- No player HUD in the catching tutorial: DrawPlayerHUD lives in + -- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and + -- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's + -- half of the screen is his back-pic and nothing more. + -- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838). + if not player or not self.showPlayerHud or self:hudCleared("player") then + Font.useBattleExtra(wasBattle) + return + end + Chrome.print(self:name(player), 10, 7) + -- PrintPlayerHUD places the same status tag at (14,8) and skips the level + -- while it is up. + Chrome.print(self:statusTag(player) + or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8) + local playerGender = self:genderSymbol(player) + if playerGender then Chrome.print(playerGender, 17, 8) end + self:drawHpBar(player, "player", 10, 9) + local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0 + Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp), + 18, 10) + -- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar + -- at (10,11). + -- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the + -- mon straight put the bar at its post-kill value before the "gained N EXP. + -- Points!" line was even on screen. + local expFraction = (self.shownExp or 0) / BattleHud.EXP_LENGTH_PX + if self.hud:available() then + self.hud:drawPlayerFrame() + -- Eight tiles at (10,11); FillInExpBar's own span. + self.hud:drawExpBar(expFraction, 10, 11) + else + self:drawFrame(9, 11, 10, true) + HpBar.drawExp(self.palettes, expFraction, 10 * 8, 11 * 8 + 4) + end + Font.useBattleExtra(wasBattle) +end + +-- PlaceNonFaintStatus's five strings, checked in its own priority order +-- (PSN, BRN, FRZ, PAR, SLP). Toxic is the PSN bit worn harder, so it shares +-- the tag; confusion is a substatus on the cart and never reaches the HUD. +local STATUS_TAGS = { + poison = "PSN", toxic = "PSN", burn = "BRN", freeze = "FRZ", + paralyze = "PAR", sleep = "SLP", +} + +function BattleState:statusTag(mon) + return mon and STATUS_TAGS[mon.status] or nil +end + +-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD +-- writes a plain space in that case). +function BattleState:genderSymbol(mon) + local gender = mon and mon.gender + if gender == "male" then return "\xe2\x99\x82" end + if gender == "female" then return "\xe2\x99\x80" end + return nil +end + +-- The growth record for a mon's species, for the exp bar's "how far to the next +-- level" fraction. +function BattleState:growthOf(mon) + local def = self.pokemon and mon and self.pokemon[mon.species] + if not def then return nil end + -- Mon.growthFor off the LIVE game.data, so the exp bar's fraction is drawn + -- against the very curve Mon.gainExperience just used; a mod-registered + -- curve must not leave the bar disagreeing with the level it reports. The + -- {pokemon=} fallback is for a screen built with no game (drivers, tests), + -- where there is no merged registry to find anyway. + local data = (self.game and self.game.data) or { pokemon = self.pokemon } + return Mon.growthFor(data, def.growthRate) +end + +-- The four labels the battle menu draws. Inside the contest the third one +-- carries the park ball count, which .PrintParkBallsRemaining writes with +-- PRINTNUM_LEADINGZEROS over two digits. +function BattleState:menuLabels() + if not self.contest then return MENU end + return { MENU[1], MENU[2], + CONTEST_BALL_LABEL .. ("%02d"):format(BugContest.ballsLeft(self.save)), + MENU[4] } +end + +-- The message on the cart's own two rows: 14 and 16, with 15 blank between +-- them (home/text.asm:143 and :397). A string that will not fit two 18-tile +-- lines is cut rather than spilling onto the rows Paragraph clears. +function BattleState:printMessage() + local lines = Chrome.wrap(self.message or "", TEXT_WIDTH) + for i = 1, math.min(#lines, TEXT_ROWS) do + Chrome.print(lines[i], TEXT_INNER_X, + TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP) + end +end + +function BattleState:drawPanel() + Chrome.clear() + -- A tutorial battle legitimately has no player mon, so only the enemy is + -- required there; everywhere else a missing side is a caller bug. + local hasPlayer = self.battle and (self.battle.player or self.tutorial) + if not (self.battle and hasPlayer and self.battle.enemy) then + Chrome.print("NO BATTLE", 1, 1) + return + end + self:drawHud() + + -- Message box across the bottom, with the menu window over its right half -- + -- the cart draws the prompt into the full-width box and then opens the menu + -- on top, so the tail of a long name is simply covered. + Chrome.box(0, 12, 20, 6) + if self.phase == "menu" then + self:printMessage() + local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X + local spacing = self.contest and CONTEST_MENU_COL_SPACING + or MENU_COL_SPACING + Chrome.box(boxX, 12, 20 - boxX, 6) + for i, label in ipairs(self:menuLabels()) do + local col = ((i - 1) % 2) * spacing + local row = math.floor((i - 1) / 2) * 2 + local tx, ty = boxX + 2 + col, 14 + row + if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end + Chrome.print(label, tx, ty) + end + elseif self.phase == "moves" + or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then + -- The forget picker shows the SAME four moves, cursored by forgetIndex -- + -- pick one to drop for the pending move. + local forgetting = self.phase == "choose-forget" + local moves = self:playerMoves() + if forgetting then + local learn = self.pendingLearn + local mon = learn and self.battle.party[learn.index] + moves = (mon and mon.moves) or moves + end + local cursorRow = forgetting and self.forgetIndex or self.moveIndex + for i, move in ipairs(moves) do + local ty = 13 + (i - 1) + -- Cursor in the box's own gutter, not clipped against the border. + if i == cursorRow then Chrome.cursor(1, ty) end + -- The held slot's marker. `.battle_player_moves` writes '▷' into the + -- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move + -- picked up for a swap is visible while the cursor moves off it. It + -- sits a column right of the cursor gutter, where the cart puts it + -- (hlcoord 5, 13 against the cursor's own column), and only while the + -- move list itself is up -- the forget picker has no swapping. + if not forgetting and self.moveSwapIndex == i then + Chrome.print("\u{25B7}", 0, ty) + end + local def = self.game and self.game.data and self.game.data.moves + and self.game.data.moves[move.id] + Chrome.print((def and def.name) or move.id, 2, ty) + Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty) + end + else + -- Battle messages wrap inside the box rather than running off the frame. + self:printMessage() + -- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7`, a 6x5 box at (14,7) with YES at + -- (16,8) and NO at (16,10), drawn over the battle while the question + -- stands. + local asking = self.phase == "ask-nickname" or self.phase == "ask-forget" + or self.phase == "stop-learning" or self.phase == "ask-shift" + if asking and (self.messageTimer or 0) <= 0 then + -- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at + -- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410). + local left = self.phase == "ask-shift" and 1 or 14 + Chrome.box(left, 7, 6, 5) + Chrome.print("YES", left + 2, 8) + Chrome.print("NO", left + 2, 10) + local index = self.phase == "ask-nickname" and self.nicknameIndex + or self.phase == "ask-shift" and self.shiftIndex + or self.forgetChoice + Chrome.cursor(left + 1, index == 1 and 8 or 10) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- The BG layer, plus whatever the animation is doing to it, plus the OBJ +-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn +-- after the scanline blit rather than into the canvas with everything else. +function BattleState:drawScene() + self:drawSceneBody() + -- battle.overlay: shiny sparkles, custom HUD chrome, and so on. Draw-only, + -- and the same name, the same payload (the battle screen) and the same place + -- in the frame as the Gen 1 site (src/battle/BattleState.lua's draw tail): + -- after everything the scene composites, in the 160x144 space, so a mod + -- draws in screen coordinates whichever game it is under. The vanilla link + -- is a no-op, so an empty chain costs one wantsHook. + if Runtime.wantsHook("battle.overlay") then + Runtime.call("battle.overlay", function() end, self) + end +end + +function BattleState:drawSceneBody() + local panel = function() self:drawPanel() end + if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then + -- The back pic is lifted out of the sliding bands and drawn the way the + -- cart's OAM copy is: one intact piece riding in from the right, so it + -- cannot tear at the $40 scanline where the bands part ways. + self.slidingBackpic = true + self.animView:presentSlide(self.slideFrame, panel, function(offset) + self.slidingBackpic = nil + local G = love.graphics + G.push() + G.translate(offset, 0) + self:drawPic(self.battle and self.battle.player, true) + G.pop() + self.slidingBackpic = true + end) + self.slidingBackpic = nil + return + end + if self.anim and self.animView then + self.animView:present(self.anim, panel) + self.animView:drawObjects(self.anim, self.battle) + return + end + panel() +end + +function BattleState:draw() + self:drawScene() +end + +function BattleState: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:drawScene() + G.pop() +end + +BattleState.MENU = MENU +BattleState.STATUS_TAGS = STATUS_TAGS +BattleState.Battle = Battle + +return BattleState diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua new file mode 100644 index 00000000..a3a22df2 --- /dev/null +++ b/src/ui/gen2/BattleTransition.lua @@ -0,0 +1,728 @@ +-- DoBattleTransition (engine/battle/battle_transition.asm): the wipe that +-- takes the overworld off screen before a battle starts. +-- +-- The cart drives it from a jumptable of 33 slots, four consecutive runs +-- through the same five steps with a different outro at the end: +-- +-- LoadPokeBallGraphics a 16x16 Poke Ball stamped over the map -- TRAINER +-- battles only, `and a / jr z` on wOtherTrainerClass +-- SetUpBGMap bookkeeping +-- Flash x3 the 13-entry rBGP table, twice per entry, run +-- three times over +-- NextScene +-- SetUpFor<outro> + the outro itself +-- +-- and the outro is picked by two bits (StartTrainerBattle_DetermineWhichAnimation): +-- +-- | player's lead + 3 >= enemy | enemy stronger +-- CAVE/DUNGEON/5 | SineWave (a growing wobble)| ZoomToBlack +-- anywhere else | SpinToBlack | SpeckleToBlack +-- +-- Note the cart's own bug, kept here: the level test reads wEnemyMonLevel +-- BEFORE the enemy mon is loaded, so "stronger" is decided against whatever +-- the previous battle left there. This port has no such stale byte, so it +-- compares honestly -- the one place the port is deliberately not bug-exact, +-- because the alternative is emulating an uninitialised variable. +-- +-- Everything that decides WHICH tiles go black is a pure function below and is +-- covered by tests; the state at the bottom is the only part that draws. + +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local Runtime = require("src.mods.Runtime") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") +local Tilt = require("src.render.Tilt") + +local BattleTransition = {} +BattleTransition.__index = BattleTransition +BattleTransition.isOpaque = false + +local COLS, ROWS = 20, 18 -- SCREEN_WIDTH x SCREEN_HEIGHT, in tiles + +-------------------------------------------------------------------------- +-- The flash +-------------------------------------------------------------------------- + +-- StartTrainerBattle_Flash's `.pals`: one packed rBGP per entry, colour 3 +-- first, and the run stops at %00000001 (which is why the last row is a +-- terminator rather than a palette). On a CGB DmgToCgbBGPals pushes each +-- of these through every BG palette, so what the player sees is the whole +-- picture darkening to black, coming back, washing out to white, and coming +-- back again. +-- +-- The port draws the overworld into a baked map canvas, so by the time the +-- flash runs there is no four-entry palette left in the frame to permute. +-- GbcPalette's remap shader puts one back: it matches each pixel to the BG +-- palette entry that produced it and substitutes what the byte sends that entry +-- to, which is CopyPals exactly. BattleTransition:drawFlash does that and +-- falls back to flashVeil below -- the entry's mean shade against the identity +-- %11100100 (3,2,1,0), normalised so 3,3,3,3 is solid black and 0,0,0,0 is +-- solid white -- only when the exact pass cannot run. +BattleTransition.FLASH_PALS = { + { 3, 3, 2, 1 }, + { 3, 3, 3, 2 }, + { 3, 3, 3, 3 }, + { 3, 3, 3, 2 }, + { 3, 3, 2, 1 }, + { 3, 2, 1, 0 }, + { 2, 1, 0, 0 }, + { 1, 0, 0, 0 }, + { 0, 0, 0, 0 }, + { 1, 0, 0, 0 }, + { 2, 1, 0, 0 }, + { 3, 2, 1, 0 }, +} +-- `ld a, [hl] / inc [hl] / srl a`: the counter advances every frame and the +-- index is half of it, so each palette is held for two. +BattleTransition.FLASH_HOLD = 2 +-- Three StartTrainerBattle_Flash slots in a row, each running the table once. +BattleTransition.FLASH_CYCLES = 3 + +-- One .pals row as the rBGP byte the cart writes. `dc` emits colour 3 first, +-- so the row packs high bits to low and `dc 3, 2, 1, 0` comes out $e4. +function BattleTransition.flashByte(pal) + return pal[1] * 64 + pal[2] * 16 + pal[3] * 4 + pal[4] +end + +-- Signed veil for one palette row: +1 is black, -1 is white, 0 is untouched. +-- +-- The approximation, kept as the fallback for a boot with no shader (where the +-- world is drawn as raw grayscale and there is nothing to re-index anyway) and +-- for TILT, whose perspective pass resamples with linear filtering so the frame +-- stops holding palette colours. +function BattleTransition.flashVeil(pal) + local sum = 0 + for _, shade in ipairs(pal) do sum = sum + shade end + -- identity (3,2,1,0) sums to 6; the extremes are 12 and 0. + return (sum - 6) / 6 +end + +BattleTransition.FLASH_FRAMES = + #BattleTransition.FLASH_PALS * BattleTransition.FLASH_HOLD + * BattleTransition.FLASH_CYCLES + +-------------------------------------------------------------------------- +-- The Poke Ball overlay (trainer battles only) +-------------------------------------------------------------------------- + +-- `.PokeBallTransition`, 16 bigdw rows of 16 bits, stamped from hlcoord 2, 1. +-- A set bit becomes BATTLETRANSITION_SQUARE; the drawing loop stops early on a +-- byte that has shifted itself empty, which is why the trailing zero columns of +-- a byte are never written (and why it cannot be read as a plain 16-wide +-- bitmap without care). +local POKEBALL_ROWS = { + "......XXXX......", + "....XXXXXXXX....", + "..XXXX....XXXX..", + "..XX........XX..", + ".XX..........XX.", + ".XX...XXXX...XX.", + "XX...XX..XX...XX", + "XXXXXX....XXXXXX", + "XXXXXX....XXXXXX", + "XX...XX..XX...XX", + ".XX...XXXX...XX.", + ".XX..........XX.", + "..XX........XX..", + "..XXXX....XXXX..", + "....XXXXXXXX....", + "......XXXX......", +} +BattleTransition.POKEBALL_X = 2 +BattleTransition.POKEBALL_Y = 1 + +function BattleTransition.pokeballCells() + local cells = {} + for row, bits in ipairs(POKEBALL_ROWS) do + for col = 1, #bits do + if bits:sub(col, col) == "X" then + cells[#cells + 1] = { + BattleTransition.POKEBALL_X + col - 1, + BattleTransition.POKEBALL_Y + row - 1, + } + end + end + end + return cells +end + +-------------------------------------------------------------------------- +-- SpinToBlack +-------------------------------------------------------------------------- + +-- Each wedge is a run-length walk away from its own corner: fill `count` +-- tiles, drop (or climb) a row, then step `shift` tiles back toward the +-- corner. A -1 in the shift slot ends the wedge, so the last pair's fill +-- happens and the walk stops. +local WEDGES = { + wedge1 = { 2, 3, 5, 4, 9, -1 }, + wedge2 = { 1, 1, 2, 2, 4, 2, 4, 2, 3, -1 }, + wedge3 = { 2, 1, 3, 1, 4, 1, 4, 1, 4, 1, 3, 1, 2, 1, 1, 1, 1, -1 }, + wedge4 = { 4, 1, 4, 0, 3, 1, 3, 0, 2, 1, 2, 0, 1, -1 }, + wedge5 = { 4, 0, 3, 0, 3, 0, 2, 0, 2, 0, 1, 0, 1, 0, 1, -1 }, +} + +-- `.spin_quadrants`: quadrant, wedge, and the tile the walk starts on. Twenty +-- steps, five per quadrant, going clockwise from the left edge. +BattleTransition.SPIN_STEPS = { + { "UPPER_LEFT", "wedge1", 1, 6 }, + { "UPPER_LEFT", "wedge2", 0, 3 }, + { "UPPER_LEFT", "wedge3", 1, 0 }, + { "UPPER_LEFT", "wedge4", 5, 0 }, + { "UPPER_LEFT", "wedge5", 9, 0 }, + { "UPPER_RIGHT", "wedge5", 10, 0 }, + { "UPPER_RIGHT", "wedge4", 14, 0 }, + { "UPPER_RIGHT", "wedge3", 18, 0 }, + { "UPPER_RIGHT", "wedge2", 19, 3 }, + { "UPPER_RIGHT", "wedge1", 18, 6 }, + { "LOWER_RIGHT", "wedge1", 18, 11 }, + { "LOWER_RIGHT", "wedge2", 19, 14 }, + { "LOWER_RIGHT", "wedge3", 18, 17 }, + { "LOWER_RIGHT", "wedge4", 14, 17 }, + { "LOWER_RIGHT", "wedge5", 10, 17 }, + { "LOWER_LEFT", "wedge5", 9, 17 }, + { "LOWER_LEFT", "wedge4", 5, 17 }, + { "LOWER_LEFT", "wedge3", 1, 17 }, + { "LOWER_LEFT", "wedge2", 0, 14 }, + { "LOWER_LEFT", "wedge1", 1, 11 }, +} + +-- Each spin step holds for two frames (`call DelayFrame` twice). +BattleTransition.SPIN_HOLD = 2 + +-- Walk one wedge, marking cells in `black` (a [y * COLS + x] set). The +-- quadrant only decides two signs: RIGHT_QUADRANT_F flips the fill direction +-- (and the shift, which always runs back the other way), LOWER_QUADRANT_F +-- flips the row step. +function BattleTransition.spinStep(black, step) + local quadrant, wedgeName, x, y = step[1], step[2], step[3], step[4] + local wedge = WEDGES[wedgeName] + local right = quadrant == "UPPER_RIGHT" or quadrant == "LOWER_RIGHT" + local lower = quadrant == "LOWER_LEFT" or quadrant == "LOWER_RIGHT" + local dx = right and 1 or -1 + local dy = lower and -1 or 1 + local i = 1 + while i <= #wedge do + local count = wedge[i] + i = i + 1 + local cx = x + for _ = 1, count do + -- The cart writes straight into the tilemap and lets a run walk off the + -- end of a row into the next one; clipping instead keeps the wedge the + -- shape the data draws and costs nothing the player can see. + if cx >= 0 and cx < COLS and y >= 0 and y < ROWS then + black[y * COLS + cx] = true + end + cx = cx + dx + end + y = y + dy + local shift = wedge[i] + i = i + 1 + if shift == nil or shift == -1 then return black end + x = x - dx * shift + end + return black +end + +-------------------------------------------------------------------------- +-- ZoomToBlack +-------------------------------------------------------------------------- + +-- `.boxes`: width, height, and the top-left corner, growing out of the middle +-- until the last one is the whole screen. One box per WaitBGMap, i.e. one a +-- frame. +BattleTransition.ZOOM_BOXES = { + { 4, 2, 8, 8 }, + { 6, 4, 7, 7 }, + { 8, 6, 6, 6 }, + { 10, 8, 5, 5 }, + { 12, 10, 4, 4 }, + { 14, 12, 3, 3 }, + { 16, 14, 2, 2 }, + { 18, 16, 1, 1 }, + { 20, 18, 0, 0 }, +} +-- `zoombox width, height, start y, start x` -- the macro's own argument order, +-- which is why the third number is the ROW. +function BattleTransition.zoomStep(black, box) + local width, height, y0, x0 = box[1], box[2], box[3], box[4] + for y = y0, math.min(ROWS, y0 + height) - 1 do + for x = x0, math.min(COLS, x0 + width) - 1 do + black[y * COLS + x] = true + end + end + return black +end + +-------------------------------------------------------------------------- +-- SpeckleToBlack +-------------------------------------------------------------------------- + +-- Sixteen passes of twelve tiles each; a tile that is already black is +-- resampled, so the count is twelve NEW tiles a frame, not twelve rolls. +BattleTransition.SPECKLE_PASSES = 0x10 +BattleTransition.SPECKLE_PER_PASS = 12 + +function BattleTransition.speckleStep(black, random) + local roll = random or function(n) return math.random(n) - 1 end + for _ = 1, BattleTransition.SPECKLE_PER_PASS do + -- The cart rejects an out-of-range Random and rolls again; the modulo a + -- port would reach for first is NOT the same distribution, so the reject + -- loop stays. + local x, y + repeat y = roll(256) until y < ROWS + repeat x = roll(256) until x < COLS + local key = y * COLS + x + if black[key] then + -- `jr z, .y_loop`: a repeat lands on the same pass, so a late pass + -- really does place fewer than twelve tiles. + local tries = 0 + repeat + repeat y = roll(256) until y < ROWS + repeat x = roll(256) until x < COLS + key = y * COLS + x + tries = tries + 1 + until not black[key] or tries > COLS * ROWS + end + black[key] = true + end + return black +end + +-------------------------------------------------------------------------- +-- SineWave (the cave outro) +-------------------------------------------------------------------------- + +-- The amplitude is wBattleTransitionCounter, which grows by the frame index +-- every frame (`counter += offset`, `offset++`), so it runs 0, 0, 1, 3, 6, 10 +-- ... and the outro ends the frame it reaches $60. The phase does not +-- advance: `e` restarts at 0 each frame and steps 2 a scanline, i.e. one full +-- period every 32 rows. +BattleTransition.SINE_LIMIT = 0x60 + +function BattleTransition.sineFrames() + local frames = {} + local counter, offset = 0, 0 + while counter < BattleTransition.SINE_LIMIT do + local amplitude = counter + counter = counter + offset + offset = offset + 1 + local row = {} + for y = 0, 143 do + -- The stored byte is signed; DrawSineWave returns it two's complement. + local value = SpriteAnims.sine(y * 2, amplitude) + row[y] = value >= 128 and value - 256 or value + end + frames[#frames + 1] = row + end + return frames +end + +-------------------------------------------------------------------------- +-- Choosing the animation +-------------------------------------------------------------------------- + +-- StartTrainerBattle_DetermineWhichAnimation: CAVE, ENVIRONMENT_5 and DUNGEON +-- take the cave pair, everything else the other one. +BattleTransition.CAVE_ENVIRONMENTS = { + CAVE = true, ENVIRONMENT_5 = true, DUNGEON = true, +} + +function BattleTransition.pick(opts) + opts = opts or {} + local cave = BattleTransition.CAVE_ENVIRONMENTS[opts.environment] == true + local stronger = (opts.playerLevel or 1) + 3 < (opts.enemyLevel or 1) + if cave then return stronger and "zoom" or "sine" end + return stronger and "speckle" or "spin" +end + +-- The four outros the jumptable can reach. A transition.style hook that names +-- anything else falls back to the two-bit select, the way the Gen 1 site falls +-- back on an unregistered style (src/render/BattleTransition.lua). +BattleTransition.STYLES = { + spin = true, speckle = true, zoom = true, sine = true, +} + +-- The default of the transition.style hook: the caller's explicit pin if there +-- is one, otherwise StartTrainerBattle_DetermineWhichAnimation's own answer. +local function vanillaStyle(ctx) + return ctx.style or BattleTransition.pick(ctx) +end + +-------------------------------------------------------------------------- +-- The state +-------------------------------------------------------------------------- + +function BattleTransition:drawsWidescreen() return true end +function BattleTransition:wantsFillScale() return true end + +-- opts: world, trainer (bool), environment, playerLevel, enemyLevel, +-- random(n), onDone +function BattleTransition.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BattleTransition) + self.game = game + self.world = opts.world or (game and game.world) + self.onDone = opts.onDone + self.random = opts.random + -- transition.style, the same hook name the Gen 1 wipe uses + -- (src/render/BattleTransition.lua), and the same context keys: `trainer`, + -- `stronger` and `dungeon` are the selection bits a Gen 1 mod already reads, + -- `dungeon` being the cave/dungeon environments this generation names + -- CAVE / ENVIRONMENT_5 / DUNGEON. `environment`, `playerLevel`, + -- `enemyLevel` and `style` are Gen 2's own: the raw inputs the two bits are + -- derived from, added rather than swapped in. + local ctx = { + game = game, + trainer = opts.trainer and true or false, + stronger = (opts.playerLevel or 1) + 3 < (opts.enemyLevel or 1), + dungeon = BattleTransition.CAVE_ENVIRONMENTS[opts.environment] == true, + environment = opts.environment, + playerLevel = opts.playerLevel, + enemyLevel = opts.enemyLevel, + style = opts.style, + } + local style = Runtime.call("transition.style", vanillaStyle, ctx) + -- A hook naming an outro that does not exist would freeze on a black screen + -- (no phase ever finishes), so it falls back to the vanilla pick. + if not BattleTransition.STYLES[style] then style = vanillaStyle(ctx) end + self.style = style + self.trainer = opts.trainer and true or false + self.black = {} + self.frame = 0 + self.step = 0 + self.phase = self.trainer and "pokeball" or "flash" + self.sine = nil + return self +end + +-- One logic frame. The phases run in the jumptable's order and the state pops +-- itself when the last one is done, so the battle screen comes up on the black +-- screen the wipe left behind. +function BattleTransition:update(_dt) + self.frame = self.frame + 1 + if self.phase == "pokeball" then + -- Two DelayFrames on the DMG path, one CGBOnly_CopyTilemapAtOnce on the + -- other; either way the ball is on screen for a moment before the flash. + if self.frame >= 2 then + self.phase = "flash" + self.frame = 0 + end + return + end + if self.phase == "flash" then + if self.frame >= BattleTransition.FLASH_FRAMES then + self.phase = "outro" + self.frame = 0 + self.step = 0 + if self.style == "sine" then + self.sine = BattleTransition.sineFrames() + end + end + return + end + if self.phase == "outro" then + self:outroFrame() + return + end + if self.phase == "black" then + if self.frame >= BattleTransition.BLACK_HOLD then self:finish() end + return + end + self:finish() +end + +function BattleTransition:outroFrame() + local style = self.style + if style == "spin" then + if self.frame % BattleTransition.SPIN_HOLD ~= 1 then return end + self.step = self.step + 1 + local step = BattleTransition.SPIN_STEPS[self.step] + if not step then + self:blackOut() + return + end + BattleTransition.spinStep(self.black, step) + elseif style == "zoom" then + self.step = self.step + 1 + local box = BattleTransition.ZOOM_BOXES[self.step] + if not box then + self:blackOut() + return + end + BattleTransition.zoomStep(self.black, box) + elseif style == "speckle" then + self.step = self.step + 1 + if self.step > BattleTransition.SPECKLE_PASSES then + self:blackOut() + return + end + BattleTransition.speckleStep(self.black, self.random) + else -- sine + self.step = self.step + 1 + if not (self.sine and self.sine[self.step]) then + self:blackOut() + return + end + end +end + +-- DoBattleTransition's own `.done`: every BG palette is filled with zero and +-- wBGP set to %11111111, i.e. the screen is solid black, and it stays that way +-- while the battle screen loads its tiles and decompresses its pics. Two of +-- the four outros never black the whole screen out themselves -- the speckle +-- only ever reaches about half the tiles, and the sine wave none of them -- so +-- without this the map would still be showing under the last frame. +-- +-- The hold is a frame budget for a load this port does not have, the same +-- judgement call src/render/BattleTransition.lua documents for Gen 1. +BattleTransition.BLACK_HOLD = 16 + +function BattleTransition:blackOut() + self.phase = "black" + self.frame = 0 +end + +function BattleTransition:finish() + if self.finished then return end + self.finished = true + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone() end +end + +-- The LY overrides this frame, or nil outside the sine outro. +function BattleTransition:lyOverrides() + if self.phase ~= "outro" or self.style ~= "sine" then return nil end + return self.sine and self.sine[self.step] or nil +end + +-- `black` covers the 20x18 tilemap; the window is bigger than that, so a cell +-- outside the map takes its nearest in-range neighbour's state. That is the +-- same idea Renderer:drawBattleWipe uses for Gen 1: continue the pattern with +-- more tiles rather than scale the tiles up, so at 1x this is the cart's grid +-- exactly. +function BattleTransition:blackAt(col, row) + local x = math.max(0, math.min(COLS - 1, col)) + local y = math.max(0, math.min(ROWS - 1, row)) + return self.black[y * COLS + x] == true +end + +function BattleTransition:draw() + local w, h = love.graphics.getDimensions() + self:drawWidescreen(w, h) +end + +-- The .pals row this frame is holding, or nil outside the flash phase. +function BattleTransition:flashPal() + if self.phase ~= "flash" then return nil end + local index = math.floor(self.frame / BattleTransition.FLASH_HOLD) + % #BattleTransition.FLASH_PALS + 1 + return BattleTransition.FLASH_PALS[index] +end + +-- The palette lists the flash's remap needs: the map's eight BG palettes, which +-- DmgToCgbBGPals permutes, and the time of day's eight OBJ palettes, which it +-- does not (that is DmgToCgbObjPals, and the flash never calls it -- the player +-- and the NPCs really do keep their colours while the map flashes, and +-- ClearSprites only runs at StartTrainerBattle_Finish). +function BattleTransition:remapPalettes() + local world = self.world + local def = world and world.map and world.map.def + if not (def and world.palettes) then return nil end + local bg = Palettes.bgSet(world.palettes, def, world.daytime) + if not bg then return nil end + return bg, Palettes.objectSet(world.palettes, world.daytime) +end + +-- Draw the map through this frame's rBGP byte, exactly. Returns false when the +-- exact pass cannot run, which is the caller's cue to draw the world plainly +-- and lay the brightness veil over it instead. +function BattleTransition:drawFlash(w, h, pal) + local byte = BattleTransition.flashByte(pal) + if byte == GbcPalette.BGP_IDENTITY then + -- `dc 3, 2, 1, 0` twice in the table: the picture is simply itself. + self.world:draw() + return true + end + -- TILT projects the finished frame through a linear-filtered canvas, so its + -- pixels are blends of palette colours rather than palette colours; matching + -- them back would posterise the warp instead of flashing it. + if Tilt.active and Tilt.active() then return false end + local bg, obj = self:remapPalettes() + if not bg then return false end + local canvas = self:capture(w, h) + if not canvas then return false end + local applied = GbcPalette.useRemap(bg, byte, obj) + if not applied then return false end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, 0, 0) + GbcPalette.clear() + return true +end + +function BattleTransition:drawWidescreen(w, h) + local G = love.graphics + local world = self.world + local ly = self:lyOverrides() + + if self.phase == "black" then + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, w, h) + G.setColor(1, 1, 1, 1) + return + end + + -- Cleared once the flash has been drawn exactly, so the veil below is only + -- ever the fallback and the two can never both land on one frame. + local pal = self:flashPal() + + if world and world.map then + if ly then + self:drawWavy(w, h, ly) + elseif pal and self:drawFlash(w, h, pal) then + pal = nil + else + world:draw() + end + else + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, w, h) + end + + if self.phase == "pokeball" then + self:drawCells(w, h, BattleTransition.pokeballCells()) + end + + if pal then + local veil = BattleTransition.flashVeil(pal) + if veil ~= 0 then + local shade = veil > 0 and 0 or 1 + G.setColor(shade, shade, shade, math.abs(veil)) + G.rectangle("fill", 0, 0, w, h) + end + end + + self:drawBlack(w, h) + G.setColor(1, 1, 1, 1) +end + +-- The tile grid, anchored on the letterbox and extended outward: `world` is +-- being drawn at the ZOOM scale but the wipe is screen furniture, so it takes +-- the plain integer fit the rest of the UI does. +function BattleTransition:grid(w, h) + local scale = 1 + if self.world and self.world.fitScale then + scale = self.world:fitScale() + else + scale = math.max(1, math.floor(math.min(w / 160, h / 144))) + end + local size = 8 * scale + local ox = math.floor((w - 160 * scale) / 2) + local oy = math.floor((h - 144 * scale) / 2) + return size, ox, oy +end + +function BattleTransition:drawBlack(w, h) + local G = love.graphics + local size, ox, oy = self:grid(w, h) + local first = -math.ceil(ox / size) + local last = math.ceil((w - ox) / size) + local top = -math.ceil(oy / size) + local bottom = math.ceil((h - oy) / size) + G.setColor(0, 0, 0, 1) + for row = top, bottom - 1 do + for col = first, last - 1 do + if self:blackAt(col, row) then + G.rectangle("fill", ox + col * size, oy + row * size, size, size) + end + end + end +end + +-- BATTLETRANSITION_SQUARE, the Poke Ball's own tile: a filled block in the +-- text palette rather than the black the wipe uses, so the ball reads against +-- the map behind it. +function BattleTransition:drawCells(w, h, cells) + local G = love.graphics + local size, ox, oy = self:grid(w, h) + -- Shade 3 of the text palette, through the COLOR mode like every other + -- direct colour read. + local color = GbcPalette.color(nil, 4) + if color then + G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) + else + G.setColor(0, 0, 0, 1) + end + for _, cell in ipairs(cells) do + G.rectangle("fill", ox + cell[1] * size, oy + cell[2] * size, size, size) + end +end + +-- The sine outro shifts whole scanlines, which needs the frame as a texture: +-- the world is captured once and re-blitted a row at a time from then on. +function BattleTransition:drawWavy(w, h, ly) + local G = love.graphics + local canvas = self:capture(w, h) + if not canvas then + self.world:draw() + return + end + local scale = 1 + if self.world.fitScale then scale = self.world:fitScale() end + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, w, h) + G.setColor(1, 1, 1, 1) + local rows = math.ceil(h / scale) + if not self.quad then + self.quad = love.graphics.newQuad(0, 0, w, scale, w, h) + end + for y = 0, rows - 1 do + -- 144 overrides for however many screen rows the window has; a row past + -- the end of the array holds the last value, the way the LCD keeps the + -- final rSCX write. + local shift = (ly[math.min(143, y)] or 0) * scale + self.quad:setViewport(0, y * scale, w, scale, w, h) + -- SCX scrolls the BACKGROUND, so a positive override moves the picture + -- LEFT -- the same sign the battle BG effects take. The hardware BG map + -- WRAPS, so a shifted scanline never shows a hole; the row is drawn again + -- a screen over to stand in for that (the cart wraps at the 256-pixel BG + -- map, this at the window, but either way there is no black gap). + G.draw(canvas, self.quad, -shift, y * scale) + if shift > 0 then + G.draw(canvas, self.quad, -shift + w, y * scale) + elseif shift < 0 then + G.draw(canvas, self.quad, -shift - w, y * scale) + end + end +end + +function BattleTransition:capture(w, h) + if self.canvas then + local cw, ch = self.canvas:getDimensions() + if cw ~= w or ch ~= h then self.canvas = nil end + end + if not self.canvas then + local ok, made = pcall(love.graphics.newCanvas, w, h) + if not ok or not made then return nil end + made:setFilter("nearest", "nearest") + self.canvas = made + self.captured = false + end + if not self.captured then + local G = love.graphics + local previous = G.getCanvas() + -- A canvas does not reset the transform. + G.push() + G.origin() + G.setCanvas(self.canvas) + G.clear(0, 0, 0, 1) + self.world:draw() + G.setCanvas(previous) + G.pop() + self.captured = true + end + return self.canvas +end + +return BattleTransition diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua new file mode 100644 index 00000000..9b8e883b --- /dev/null +++ b/src/ui/gen2/BoxMenu.lua @@ -0,0 +1,785 @@ +-- Bill's PC withdraw / deposit list (engine/pokemon/bills_pc.asm). +-- +-- The screen is transcribed from the ASM's own coordinates rather than laid +-- out by eye, because that is what makes it line up on the 8px grid: +-- +-- BillsPC_BoxName Textbox at (8,0), interior 10x1 -- the box name +-- BillsPC_RefreshTextboxes +-- Textbox at (8,2), interior 10x10, then the two top +-- corners are overwritten with └ and ┘ so the list +-- box reads as hanging off the name box above it +-- .PlaceNickname five nicknames from (9,4), two rows apart +-- PCMonInfo clears (0,0) 8 wide x 15 tall for the left panel, +-- puts the front pic at (1,4) as 7x7 tiles, the level +-- at (1,12), the gender at (5,12) and the species +-- name at (1,14) +-- +-- `mode` picks which list is being browsed: "withdraw" reads the current box, +-- "deposit" reads the party, and "move" walks BOTH -- see below. +-- +-- MOVE POKéMON W/O MAIL (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm:480) +-- is not a one-press operation on the cart and must not be one here. Its +-- jumptable is .Init -> .Joypad -> .PrepSubmenu -> .MoveMonWOMailSubmenu -> +-- .PrepInsertCursor -> .Joypad2, i.e. +-- +-- 1. "Choose a <PK><MN>." over a list that left/right walks across the PARTY +-- (wBillsPC_LoadedBox == 0, BillsPC_BoxName's `.party` arm) and all +-- fourteen boxes, wrapping at both ends (BillsPC_PressLeft/PressRight) +-- 2. A on a mon opens MOVE / STATS / CANCEL under "What's up?" +-- (PCString_WhatsUp, .MoveMonWOMailSubmenu) +-- 3. MOVE backs the position up and asks "Move to where?" +-- (PCString_MoveToWhere) with an insert cursor the player drives to the +-- destination list AND the slot inside it +-- 4. A there is BillsPC_CheckSpaceInDestination then +-- MovePKMNWithoutMail_InsertMon, which prints "Saving… Leave ON!" and +-- writes the mon into its new home; B restores the backed-up position and +-- goes back to step 1 +-- +-- Collapsing that into "A sends the mon to the next box with room" -- which is +-- what this screen used to do -- reads as the PC EATING the mon: it vanishes +-- from the list with no destination named and no line of text, and the player +-- has fourteen boxes to search to find out it still exists. + +local Assets = require("src.render.Assets") +local Boxes = require("src.core.gen2.Boxes") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Mail = require("src.core.gen2.Mail") +local Palettes = require("src.world.gen2.Palettes") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Unown = require("src.core.gen2.Unown") + +local BoxMenu = {} +BoxMenu.__index = BoxMenu +BoxMenu.isOpaque = true + +-- BillsPC_NumMonsOnScreen is 5 for the withdraw/deposit lists. +local VISIBLE_ROWS = 5 +local LIST_X, LIST_Y = 9, 4 +local LIST_SPACING = 2 +local PIC_X, PIC_Y = 1, 4 + +-- PadFrontpic pads a 5x5 or 6x6 pic into the 7x7 block, one blank column then +-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386). +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the +-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list +-- each (BillsPC_BoxName reads the same byte for all three). +local PARTY_BOX = 0 + +-- .MoveMonWOMailSubmenu's .MenuData, verbatim. RELEASE is NOT one of these -- +-- it belongs to BillsPC_WithdrawMenu's four rows -- so nothing on this screen +-- can destroy a mon. +local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" } + +-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while +-- the mon is written into its new home. It stays up here until a button +-- clears it, because it is also the only confirmation the player gets that the +-- mon moved and where it went. +local SAVING_LEAVE_ON = "Saving\xe2\x80\xa6 Leave ON!" + +-- PCString_NoReleasingEGGS, printed by BillsPC_IsMonAnEgg with SFX_WRONG +-- (engine/pokemon/bills_pc.asm:1615-1631, string at :2200). +local NO_RELEASING_EGGS = "No releasing EGGS!" + +function BoxMenu:wantsFillScale() return true end +function BoxMenu:drawsWidescreen() return true end + +-- opts: save, mode ("withdraw" | "deposit" | "move"), onClose() +function BoxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BoxMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.mode = opts.mode or "withdraw" + self.onClose = opts.onClose + local data = game and game.data or {} + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes + -- EggPic has no data.pokemon row; it rides menu_gfx.eggHatch, and ICON_EGG + -- stands in for a cache built before the extractor learned it. + self.menuGfx = opts.menuGfx or data.gen2MenuGfx + self.icons = opts.icons or data.gen2Icons + self.boxIndex = self.save and self.save.currentBox or 1 + self.index = 1 + self.scroll = 0 + self.picCache = {} + self.message = nil + -- nil while the list is being browsed; "submenu" while MOVE/STATS/CANCEL is + -- up, "insert" while the insert cursor is picking a destination. Only the + -- move screen has phases -- the other two lists act on A. + self.phase = nil + self.submenuIndex = 1 + return self +end + +-- The PARTY is box 0 on the move screen and nowhere else. +function BoxMenu:isParty(index) + if index == nil then index = self.boxIndex end + return self.mode == "move" and index == PARTY_BOX +end + +-- The mon list a given loaded box stands for. Boxes.box hands back a live +-- table (and creates it on demand), so an insert here lands on the save. +function BoxMenu:listAt(index) + if self:isParty(index) then + self.save.party = self.save.party or {} + return self.save.party + end + return Boxes.box(self.save, index) +end + +-- PARTY_LENGTH for box 0, MONS_PER_BOX for the rest +-- (BillsPC_CheckSpaceInDestination's `.party` arm). +function BoxMenu:capacityAt(index) + if self:isParty(index) then return Boxes.PARTY_SIZE end + return Boxes.MONS_PER_BOX +end + +-- BillsPC_BoxName: the party's name is a string of its own, a box's comes out +-- of wBoxNames. +function BoxMenu:nameAt(index) + -- .PartyPKMN is "PARTY <PK><MN>@" -- eight tiles, because <PK> and <MN> are + -- one font glyph each. + if self:isParty(index) then return "PARTY <PK><MN>" end + return Boxes.name(self.save, index) +end + +-- Which list this mode browses. +function BoxMenu:list() + if self.mode == "deposit" then return self.save.party or {} end + return self:listAt(self.boxIndex) +end + +function BoxMenu:title() + if self.mode == "deposit" then return "PARTY <PK><MN>" end + -- While the insert cursor is up the header names the DESTINATION: the whole + -- screen has moved there (.PrepInsertCursor calls the same + -- BillsPC_MoveMonWOMail_BoxNameAndArrows with the new wBillsPC_LoadedBox). + return self:nameAt(self.boxIndex) +end + +-- The cart's own prompts (PCString_*): short, because the box they print in +-- is one row of 18 columns. +function BoxMenu:prompt() + if self.mode == "deposit" then return "Deposit which one?" end + if self.mode == "move" then + -- .Init, .PrepSubmenu and .PrepInsertCursor each place their own string. + if self.phase == "insert" then return "Move to where?" end + if self.phase == "submenu" then return "What's up?" end + return "Choose a <PK><MN>." + end + return "Choose a POKéMON." +end + +function BoxMenu:total() + return #self:list() + 1 -- CANCEL +end + +function BoxMenu:isCancel() + return self.index > #self:list() +end + +function BoxMenu:selected() + return self:list()[self.index] +end + +function BoxMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function BoxMenu:clampIndex() + local total = self:total() + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function BoxMenu:act() + if self:isCancel() then + if self.onClose then self.onClose() end + return + end + -- .a_button: the move screen never acts on the list itself. It checks that + -- the row really is a mon and steps to $2, .PrepSubmenu. + if self.mode == "move" then + if not self:selected() then return end + self.phase = "submenu" + -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE. + self.submenuIndex = 1 + return + end + local ok, result + if self.mode == "deposit" then + ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) + else + ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) + end + if not ok then + self.message = result + return + end + self.message = nil + self:clampIndex() +end + +-- ------------------------------------------------------------ MOVE, step 2 + +-- BillsPC_CheckMail_PreventBlackout (engine/pokemon/bills_pc.asm:1575), which +-- .Move runs BEFORE it backs up the cursor: three refusals, all of them about +-- the PARTY, so a boxed mon walks straight past. Returns true, or false and +-- the string the cart places. +function BoxMenu:checkMailPreventBlackout() + -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`. + if not self:isParty() then return true end + local party = self.save.party or {} + -- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send + -- one away at all, however healthy the rest of it is. + if #party < 3 then return false, "It's your last <PK><MN>!" end + -- CheckCurPartyMonFainted (engine/pokemon/bills_pc_top.asm:171) walks the + -- party skipping wCurPartyMon and answers carry when everything ELSE has + -- fainted -- taking this one out would white the player out on the next step. + local othersUsable = false + for i, mon in ipairs(party) do + if i ~= self.index and (mon.hp or 0) > 0 then othersUsable = true break end + end + if not othersUsable then return false, "No more usable <PK><MN>!" end + -- wBillsPC_MonHasMail, the byte PCMonInfo set while drawing the row. The + -- top menu already refused to open this screen at all while any party mon + -- holds a letter (BillsPC_MovePKMNMenu's IsAnyMonHoldingMail, + -- src/ui/gen2/PcMenu.lua), so this is the second of two nets. + if Mail.monHoldsMail(party[self.index]) then + return false, "Remove MAIL." + end + return true +end + +-- .Move: back the position up (so B can restore it), keep the loaded box and +-- step to $4, .PrepInsertCursor. +function BoxMenu:beginMove() + local ok, reason = self:checkMailPreventBlackout() + if not ok then + -- BillsPC_PlaceString + SFX_WRONG + 50 frames, then `dec [hl]` drops back + -- to the submenu; here the message holds until a button clears it and the + -- list comes back, which is the same place the player ends up. + self.phase = nil + self.message = reason + return + end + self.moveFrom = { box = self.boxIndex, slot = self.index } + self.backup = { box = self.boxIndex, index = self.index, scroll = self.scroll } + self.phase = "insert" + self:clampInsert() +end + +-- BillsPC_StatsScreen: the stats screen over the selected mon, then back to +-- the submenu (.Stats ends in PCMonInfo, not in a jumptable step). +function BoxMenu:openStats() + local mon = self:selected() + local game = self.game + if not (mon and game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2SummaryMenu") then return end + Screens.push(game, "Gen2SummaryMenu", { + mon = mon, + save = self.save, + onClose = function() game.stack:pop() end, + }) +end + +function BoxMenu:chooseSubmenu() + local row = MOVE_SUBMENU[self.submenuIndex] + if row == "MOVE" then + self:beginMove() + elseif row == "STATS" then + self:openStats() + else + -- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`. + self.phase = nil + end +end + +-- ------------------------------------------------------------ MOVE, step 3 + +-- How many insert positions the destination offers. BillsPC_PressDown stops +-- at wBillsPC_NumMonsInBox - 1, so the cursor always sits ON a mon (or on row +-- one of an empty list) and InsertSpeciesIntoBoxOrParty pushes the rest down. +function BoxMenu:insertPositions() + return math.max(1, #self:listAt(self.boxIndex)) +end + +function BoxMenu:clampInsert() + self.index = math.max(1, math.min(self.index, self:insertPositions())) + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:insertPositions() - VISIBLE_ROWS))) + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end +end + +-- BillsPC_CheckSpaceInDestination: a move inside one list is always allowed +-- (`.same_box`), and a move into another one needs a free slot there. The +-- ASM compares against MONS_PER_BOX + 1 / PARTY_LENGTH + 1, which can never be +-- reached -- the port refuses at the real capacity instead, because a 21st mon +-- in a box is a mon the save cannot hold. +function BoxMenu:checkSpaceInDestination() + local from = self.moveFrom + if from and from.box == self.boxIndex then return true end + if #self:listAt(self.boxIndex) >= self:capacityAt(self.boxIndex) then + return false, "There's no room!" + end + return true +end + +-- MovePKMNWithoutMail_InsertMon's .Jumptable: .BoxToBox / .PartyToBox / +-- .BoxToParty / .PartyToParty, all four of them .CopyFrom* (which is +-- RemoveMonFromPartyOrBox) followed by .CopyTo* (InsertPokemonIntoBox or +-- InsertPokemonIntoParty). This port keeps a mon as one Lua table in exactly +-- one list, so the four cases are one remove and one insert; what differs is +-- WHICH list each end names, and the mail bookkeeping on the party end. +function BoxMenu:insertMon() + local from = self.moveFrom + if not from then return end + local source = self:listAt(from.box) + local mon = source[from.slot] + if not mon then + self.phase = nil + self.moveFrom, self.backup = nil, nil + return + end + local destIndex = self.boxIndex + local target = math.min(self.index, self:insertPositions()) + table.remove(source, from.slot) + -- sPartyMail is keyed by party SLOT, so a mon leaving the party drags every + -- letter behind it up one (RemoveMonFromPartyOrBox's "Mail time!" tail, the + -- same call Boxes.deposit makes). No letter can actually be in the party + -- here -- IsAnyMonHoldingMail refused the whole screen -- so this keeps the + -- slots honest rather than moving anything. + if self:isParty(from.box) then Mail.removeSlot(self.save, from.slot) end + local dest = self:listAt(destIndex) + if from.box == destIndex and target > from.slot then + -- .CheckTrivialMove: the source was taken out first, so a destination slot + -- below it has already shuffled up one. + target = target - 1 + end + table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) + self.phase = nil + self.moveFrom, self.backup = nil, nil + self.index, self.scroll = 1, 0 + self:clampIndex() + self.message = SAVING_LEAVE_ON +end + +-- .b_button_2: the backed-up scroll, cursor and loaded box all go back, and +-- the screen returns to step 1 with nothing moved. +function BoxMenu:cancelMove() + local backup = self.backup + if backup then + self.boxIndex, self.index, self.scroll = + backup.box, backup.index, backup.scroll + end + self.phase = nil + self.moveFrom, self.backup = nil, nil + self:clampIndex() +end + +-- BillsPC_PressLeft / BillsPC_PressRight. The move screen wraps through box 0 +-- (the PARTY); the withdraw list has no party to walk into, so it wraps inside +-- the fourteen boxes. +function BoxMenu:stepBox(delta) + local low = self.mode == "move" and PARTY_BOX or 1 + local span = Boxes.NUM_BOXES - low + 1 + self.boxIndex = (self.boxIndex - low + delta) % span + low + -- .dpad / .dpad_2: both arms zero the cursor and the scroll. + self.index, self.scroll = 1, 0 + if self.phase == "insert" then self:clampInsert() end +end + +function BoxMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + self.message = nil + end + return + end + + -- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry. + if self.phase == "submenu" then + if input:wasPressed("up") then + self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1 + or #MOVE_SUBMENU + elseif input:wasPressed("down") then + self.submenuIndex = self.submenuIndex < #MOVE_SUBMENU + and self.submenuIndex + 1 or 1 + elseif input:wasPressed("a") then + self:chooseSubmenu() + elseif input:wasPressed("b") then + self.phase = nil + end + return + end + + -- .Joypad2: the insert cursor. Up/down walk the destination's slots, + -- left/right walk the destinations themselves, A inserts, B goes back. + if self.phase == "insert" then + local positions = self:insertPositions() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or positions + self:clampInsert() + elseif input:wasPressed("down") then + self.index = self.index < positions and self.index + 1 or 1 + self:clampInsert() + elseif input:wasPressed("left") then + self:stepBox(-1) + elseif input:wasPressed("right") then + self:stepBox(1) + elseif input:wasPressed("a") then + local ok, reason = self:checkSpaceInDestination() + if not ok then + -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, + -- so the refusal leaves the cursor exactly where it was. + self.message = reason + else + self:insertMon() + end + elseif input:wasPressed("b") then + self:cancelMove() + end + return + end + + local total = self:total() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:ensureVisible() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:ensureVisible() + elseif input:wasPressed("left") and self.mode ~= "deposit" then + self:stepBox(-1) + elseif input:wasPressed("right") and self.mode ~= "deposit" then + self:stepBox(1) + elseif input:wasPressed("a") then + self:act() + -- RELEASE and the nickname keyboard are BillsPC_WithdrawMenu's and the + -- CHANGE BOX menu's rows; .MoveMonWOMailSubmenu has neither, and a stray + -- SELECT on the move screen must not put "Release <PK><MN>?" in front of a + -- player who only meant to reorder a box. + elseif input:wasPressed("select") and self.mode == "withdraw" then + self:askRelease() + elseif input:wasPressed("start") and self.mode == "withdraw" then + self:askNickname() + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end +end + +function BoxMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + +-- BillsPC's RELEASE, which the model has always supported and nothing on +-- screen reached. The cart asks first and starts the prompt on NO, the way +-- every irreversible choice in the game does. +function BoxMenu:askRelease() + if self:isCancel() then return end + local mon = self:selected() + if not mon then return end + -- Both release paths run BillsPC_IsMonAnEgg first, so the question is never + -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). + if mon.isEgg then + self.message = NO_RELEASING_EGGS + self:playSfx("Sfx_Wrong") + return + end + local game = self.game + if not (game and game.stack) then return end + local ChoiceBox = require("src.ui.ChoiceBox") + local name = mon.nickname or mon.name or mon.species or "?" + game.stack:push(ChoiceBox.new(game, function(yes) + if not yes then return end + local ok, err = Boxes.release(self.save, self.boxIndex, self.index) + if not ok then + self.message = err + return + end + self.message = name .. " was released." + self:clampIndex() + end, { defaultNo = true })) +end + +-- The naming screen the cart opens from BillsPC's own nickname option. +function BoxMenu:askNickname() + if self:isCancel() then return end + local mon = self:selected() + if not mon then return end + local game = self.game + if not (game and game.stack) then return end + -- The resolve is guarded rather than the construction: a keyboard that will + -- not even load is a nickname the player cannot type, not a crash. Same + -- shape as the openscreen script command (src/script/Commands.lua). + if not pcall(Screens.get, game, "Gen2NamingScreen") then return end + Screens.push(game, "Gen2NamingScreen", { + -- The "nickname" kind is MON_NAME_LENGTH - 1 wide and takes its header + -- from the mon rather than from a fixed prompt. + type = "nickname", + monName = mon.name or mon.species, + initial = mon.nickname or "", + onDone = function(name) + game.stack:pop() + if name and #name > 0 then mon.nickname = name end + end, + onCancel = function() game.stack:pop() end, + }) +end + +function BoxMenu:image(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +-- The selected mon's front pic, cached per species. +function BoxMenu:picFor(mon) + if not (mon and mon.species and self.pokemon) then return nil end + local def = self.pokemon[mon.species] + local path = def and def.spriteFront + -- BillsPC_LoadMonStats' frontpic (engine/pokemon/bills_pc.asm:1048-1052) runs + -- `ld hl, wTempMonDVs / predef GetUnownLetter` before GetBaseData, and the + -- D-pad reload at :1667-1668 does the same, so a stored Unown previews its + -- OWN form. The species row is letter A's pic, which is what the cart shows + -- only when the DVs actually say A. + if mon.species == Unown.SPECIES then + path = Unown.formSprite(self.pokemon, Unown.monLetter(mon)) or path + end + return self:image(path) +end + +-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4 +-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0. +function BoxMenu:drawPicBlock(image, colors) + if not image then return end + local G = love.graphics + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + + local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7] + G.setColor(1, 1, 1, 1) + local function body() + G.draw(image, (PIC_X + pad[1]) * 8, (PIC_Y + pad[2]) * 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function BoxMenu:drawPic(mon) + local image = self:picFor(mon) + if not image then return end + -- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box + -- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293). + local colors = self.palettes + and Palettes.monColors(self.palettes, mon.species, mon.shiny) + self:drawPicBlock(image, colors) +end + +-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the +-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch, +-- with the party list's ICON_EGG standing in for a cache built before that. +function BoxMenu:drawEggPic(mon) + local G = love.graphics + local colors = self.palettes + and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny) + local gfx = (self.menuGfx or {}).eggHatch + local image = self:image(gfx and gfx.egg) + if image then return self:drawPicBlock(image, colors) end + local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG + image = self:image(entry and entry.image) + if not image then return end + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + -- The ICON_EGG sheet stacks its frames; the first is the egg at rest. + local w = entry.width or 16 + local h = math.min(entry.height or 16, image:getHeight()) + if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end + local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h, + image:getWidth(), image:getHeight()) + if not ok then return end + local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2) + local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2) + G.setColor(1, 1, 1, 1) + local function body() G.draw(image, quad, x, y, 0, 2, 2) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor +-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top +-- left at pixel (71, 31), stepping 16 pixels per row. Those cursor tiles are +-- not extracted, so the frame is drawn as an outline at exactly those pixels, +-- which is what the sprite frame looks like. +function BoxMenu:drawSelectionFrame(row) + local G = love.graphics + local x, y = 71, 31 + (row - 1) * 16 + G.setColor(0, 0, 0, 1) + G.setLineWidth(1) + G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1) + G.setLineWidth(1) +end + +-- BillsPC_UpdateInsertCursor lays a DIFFERENT sprite frame from the selection +-- one -- a wedge between the rows rather than a box around one -- so the +-- insert cursor is drawn as a rule along the top edge of the row the mon is +-- going in front of. +function BoxMenu:drawInsertCursor(row) + local G = love.graphics + local x, y = 71, 31 + (row - 1) * 16 + G.setColor(0, 0, 0, 1) + G.rectangle("fill", x, y, 80, 2) +end + +-- The mon the whole screen is about while the insert cursor is up: .PrepSubmenu +-- ran PCMonInfo over it and .PrepInsertCursor does NOT run it again, so the +-- left panel keeps showing the mon in flight. +function BoxMenu:panelMon() + local from = self.moveFrom + if self.phase == "insert" and from then + return self:listAt(from.box)[from.slot] + end + return self:selected() +end + +function BoxMenu:drawPanel() + Chrome.clear() + + -- Box name header, then the list box hanging off it. BillsPC_BoxName is a + -- Textbox at (8,0) with a 10x1 interior and the name at (10,1). + Chrome.box(8, 0, 12, 3) + Chrome.print(self:title(), 10, 1) + Chrome.box(8, 2, 12, 12) + + local list = self:list() + local inserting = self.phase == "insert" + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = LIST_Y + (row - 1) * LIST_SPACING + if i <= #list then + local mon = list[i] + if i == self.index then + if inserting then + self:drawInsertCursor(row) + else + self:drawSelectionFrame(row) + end + end + -- .PlaceNickname prints the stored nickname bytes verbatim, with no egg + -- check of its own (engine/pokemon/bills_pc.asm:1245-1356). + local label = mon.nickname or mon.name or mon.species or "?" + Chrome.print(label, LIST_X, ty) + elseif inserting then + -- An empty destination: the cursor is the only thing on the list. + if i == self.index then self:drawInsertCursor(row) end + elseif i == self:total() then + if i == self.index then self:drawSelectionFrame(row) end + Chrome.print("CANCEL", LIST_X, ty) + end + end + + -- The left panel: pic, level, gender, species -- blank on CANCEL, the way + -- PCMonInfo clears it when the selection is not a mon. + local mon = self:panelMon() + if mon then + -- `cp EGG / ret z` right after the frontpic: no name, no level, no gender + -- (engine/pokemon/bills_pc.asm:1057-1058). + if mon.isEgg then + self:drawEggPic(mon) + else + self:drawPic(mon) + Chrome.print(":L" .. tostring(mon.level or 1), PIC_X, 12) + if mon.gender == "male" then + Chrome.print("\xe2\x99\x82", 5, 12) + elseif mon.gender == "female" then + Chrome.print("\xe2\x99\x80", 5, 12) + end + Chrome.print(mon.name or mon.species or "?", PIC_X, 14) + end + end + + -- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at + -- (1,16). A refusal is two lines on the cart, so those get a taller box of + -- their own rather than being cut to fit this one. + if self.message then + Chrome.box(0, 12, 20, 6) + -- Two lines, two tile rows apart, the way every other text box lays out. + local line = 14 + for part in (self.message .. "\n"):gmatch("(.-)\n") do + Chrome.print(part, 1, line) + line = line + 2 + end + else + Chrome.box(0, 15, 20, 3) + Chrome.print(self:prompt(), 1, 16) + end + + -- .MoveMonWOMailSubmenu's .MenuHeader is `menu_coords 9, 4, SCREEN_WIDTH - 1, + -- 13` -- it covers the nickname list, which is why the list is still drawn + -- underneath it and not instead of it. STATICMENU_CURSOR with the default + -- top spacing puts MOVE at (11,6), one row per two tiles. + if self.phase == "submenu" then + Chrome.box(9, 4, 11, 10) + for i, label in ipairs(MOVE_SUBMENU) do + local ty = 6 + (i - 1) * 2 + if i == self.submenuIndex then Chrome.cursor(10, ty) end + Chrome.print(label, 11, ty) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function BoxMenu:draw() + self:drawPanel() +end + +function BoxMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return BoxMenu diff --git a/src/ui/gen2/CallerBox.lua b/src/ui/gen2/CallerBox.lua new file mode 100644 index 00000000..37283506 --- /dev/null +++ b/src/ui/gen2/CallerBox.lua @@ -0,0 +1,98 @@ +-- The caller-ID box an incoming phone call puts across the top of the screen: +-- Phone_TextboxWithName (pokegold engine/phone/phone.asm:582), which is +-- +-- Phone_TextboxWithName: +-- push bc +-- call Phone_CallerTextbox +-- hlcoord 1, 1 +-- ld [hl], '☎' +-- inc hl +-- inc hl +-- ld d, h +-- ld e, l +-- pop bc +-- call GetCallerClassAndName +-- ret +-- +-- Phone_CallerTextbox: +-- hlcoord 0, 0 +-- ld b, 2 +-- ld c, SCREEN_WIDTH - 2 +-- call Textbox +-- ret +-- +-- so the box is the top four rows of the screen (b/c are INTERIOR rows and +-- columns, hence 20x4 on screen), the phone icon sits at (1,1), and +-- GetCallerClassAndName places the caller's name from (3,1) -- hl after the +-- two `inc hl` -- with a ':' written straight after it. A trainer contact +-- then gets its class name a further `SCREEN_WIDTH + 3` on, which is (6,2); +-- a non-trainer (Mom, ELM, the wrong number) stops at the colon and has no +-- second line at all (:635-666). +-- +-- WHEN IT IS UP. RingTwice_StartCall (:458-469) flashes the box on and off +-- against Phone_Wait20Frames while SFX_CALL plays, and nothing ever erases it +-- afterwards: it was written straight into wTilemap rather than through a +-- window, so CloseText restores it along with everything else under the +-- speech box and it survives until the overworld redraws its tilemap at the +-- end of the call. This port has no twenty-frame flash to hang the middle +-- beats on (src/core/gen2/PhoneRing.lua explains why: its text box holds for +-- A where the cart's PrintText returns), so the box goes UP with the ring and +-- stays for the whole call, which is the state the player actually reads. +-- +-- A state, not a widget: it rides src/core/StateStack.lua UNDER the call's +-- text pages, so those draw over it exactly as the cart's speech box draws +-- over the tilemap. Deliberately no `update` -- src/core/Game2.lua's fixed +-- step hands the tick to the TOP state and returns, so an update here would +-- stop the world (and with it the script VM that is running the call) the +-- moment the last text page popped. +-- +-- Pushed and popped by the two callasm handlers in src/script/gen2/CallAsm.lua +-- that src/core/gen2/PhoneRing.lua's rows name. + +local Chrome = require("src.ui.gen2.Chrome") +local PhoneRing = require("src.core.gen2.PhoneRing") + +local CallerBox = {} +CallerBox.__index = CallerBox + +-- Phone_CallerTextbox's own coordinates, kept as names so the two draw calls +-- below read like the ASM they came from. +local BOX_X, BOX_Y = 0, 0 +local BOX_INTERIOR_W, BOX_INTERIOR_H = 18, 2 +local ICON_X, ICON_Y = 1, 1 +local NAME_X, NAME_Y = 3, 1 +local CLASS_X, CLASS_Y = 6, 2 + +-- charmap.asm:88 `charmap "☎", $62`. The glyph itself is +-- PokegearPhoneIconGFX, which _LoadFontsExtra copies over vTiles2 tile $62 +-- (engine/gfx/load_font.asm:12-15) on top of the three bold letters FontExtra +-- ships there; a cache whose font_extra page still carries FontExtra's own +-- $62 draws a bold C in its place, which is an extractor gap and not a +-- layout one, so the sequence is written out here the way the cart writes it. +local PHONE_ICON = "\xe2\x98\x8e" + +-- `name` / `className` are what src/core/gen2/Phone.lua contactName answers: +-- the caller's name, and the trainer class under it or nil for a non-trainer. +function CallerBox.new(name, className) + return setmetatable({ + name = name or "", + className = className, + -- The overworld has to keep drawing behind it (this is a strip across the + -- top, not a page), so the stack must not treat it as a base. + isOpaque = false, + }, CallerBox) +end + +function CallerBox:draw() + Chrome.textbox(BOX_X, BOX_Y, BOX_INTERIOR_W, BOX_INTERIOR_H) + Chrome.print(PHONE_ICON, ICON_X, ICON_Y) + -- GetCallerName places the name and then writes ':' into the cell the + -- string ended on, which is what PhoneRing.callerId already composes for + -- the ring page -- same colon, same source, so the two can never drift. + Chrome.print(PhoneRing.callerId(self.name), NAME_X, NAME_Y) + if self.className and self.className ~= "" then + Chrome.print(self.className, CLASS_X, CLASS_Y) + end +end + +return CallerBox diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua new file mode 100644 index 00000000..d410e73b --- /dev/null +++ b/src/ui/gen2/CardFlip.lua @@ -0,0 +1,654 @@ +-- Gold's card flip (engine/games/card_flip.asm _CardFlip), the `special +-- CardFlip` the Goldenrod Game Corner's second machine calls. +-- +-- Three coins a go. A 24 card deck is dealt two at a time; the player picks one +-- of the two face-down cards without seeing it, then bets on a square of a 6x8 +-- board, and the card is turned over. A card is a Pokemon (Pikachu, +-- Jigglypuff, Poliwag, Oddish) and a level (1-6), packed as level * 4 + mon -- +-- which is why every win condition in the ASM is a mask on the card byte and +-- why the exact-card squares can just `cp e` against a literal 0-23. +-- +-- The payout ladder, from CardFlip_CheckWinCondition: +-- +-- 6 a PAIR of Pokemon (Pikachu/Jigglypuff, or Poliwag/Oddish) +-- 9 a PAIR of levels (1-2, 3-4, or 5-6) +-- 12 one Pokemon +-- 18 one level +-- 72 the exact card +-- +-- The four squares where a Pokemon pair meets a level pair are .Impossible: +-- they are on the board, the cursor can sit on them, and they always lose. +-- +-- Everything that decides an outcome is a pure function here, taking a +-- `random(n) -> 0..n-1` the way src/battle/gen2 does, so a test can shuffle and +-- score thousands of hands. The screen half is the only part that touches love. +-- +-- Layout is transcribed from the ASM, not laid out by eye. The cursor OAM +-- table is in OAM space, which sits 8px right and 16px down of the screen, so a +-- `cardflip_cursor 13, 5` anchor is screen tile (12, 3): +-- +-- CardFlip_InitTilemap fills the screen with $29 and copies +-- CardFlipTilemap to hlcoord 9, 0 as 12 rows x 11 +-- columns -- so the odds board is (9,0)-(19,11) +-- .ChooseACard writes CARDFLIP_LIGHT_ON at hlcoord 9, 0 plus +-- wCardFlipNumCardsPlayed rows: twelve lights down +-- column 9, one per hand in the deck +-- CardFlip_InitAttrPals colours 2x2 boxes at (12,1), (14,1), (16,1) and +-- (18,1) -- the four Pokemon headers +-- .Level1 .. .Level6 blank a discarded card at hlcoord 13, 3 / 4 / 6 / +-- 7 / 9 / 10 plus two columns per Pokemon, so the +-- 24 card cells are columns 13, 15, 17, 19 on those +-- six rows +-- GetCoordsOfChosenCard hlcoord 2, 0 and hlcoord 2, 6, each a 6x5 box; the +-- level digit lands at +3 +SCREEN_WIDTH and the 3x3 +-- Pokepic one SCREEN_HEIGHT further on +-- CardFlip_PrintCoinBalance +-- Textbox at (9,15) with a 9x1 interior, "COIN" at +-- (10,16) and four leading-zero digits at (15,16) +-- CardFlip_UpdateCoinBalanceDisplay +-- Textbox at (0,12) with an 18x4 interior +-- +-- The cart's own art (gfx/card_flip/card_flip_1..3.2bpp.lz and +-- gfx/card_flip/card_flip.tilemap) is NOT in the cache: no `cardFlip` entry is +-- written into menu_gfx.lua yet, so the board draws as labelled cells until one +-- appears. + +local Chrome = require("src.ui.gen2.Chrome") +local CoinCase = require("src.core.gen2.CoinCase") +local Sound = require("src.core.Sound") + +local CardFlip = {} +CardFlip.__index = CardFlip +CardFlip.isOpaque = true + +-- constants/misc_constants.asm: CARDFLIP_DECK_SIZE EQU 4 * 6. +CardFlip.DECK_SIZE = 24 +CardFlip.NUM_MONS = 4 +CardFlip.NUM_LEVELS = 6 +-- .DeductCoins: `ld de, -3`. +CardFlip.BET = 3 + +-- The Pokemon order is the one .Deck's pic anchors and .Pikachu/.Jigglypuff/ +-- .Poliwag/.Oddish's `and $3` comparisons agree on. +CardFlip.MONS = { [0] = "PIKACHU", [1] = "JIGGLYPUFF", [2] = "POLIWAG", + [3] = "ODDISH" } +CardFlip.MON_LABELS = { [0] = "PI", [1] = "JI", [2] = "PO", [3] = "OD" } + +-- card = level * 4 + mon. Everywhere the ASM masks the card byte: +-- and $3 the Pokemon +-- and $1c the level, times four +-- and $18 the level PAIR, since bit 2 is the odd/even level +function CardFlip.mon(card) return card % 4 end +function CardFlip.level(card) return math.floor(card / 4) end +function CardFlip.levelPair(card) return math.floor(card / 8) end +function CardFlip.card(level, mon) return level * 4 + mon end + +-- ------------------------------------------------------------------- deck +-- +-- CardFlip_ShuffleDeck, transcribed rather than replaced with a Fisher-Yates: +-- the cart fills the deck with zeroes, then drops the values 23 down to 1 into +-- random empty slots, rejecting a roll of 24-31 (`and $1f / cp +-- CARDFLIP_DECK_SIZE`) and rejecting an occupied slot. Card 0 is never placed +-- -- the single slot still holding zero IS card 0, which is why "empty" and +-- "card 0" can share a value without breaking anything. +-- `random` is injectable, and the rejection loop is unbounded on the cart +-- because hRandomAdd is never degenerate. A source that only ever returns one +-- number would spin here forever, so the retries are capped and the fallback is +-- a linear probe for the next empty slot: unreachable with any real RNG, and +-- the difference between a wrong shuffle and a hung game if one is handed in. +local SHUFFLE_RETRIES = 256 + +function CardFlip.shuffle(random) + local deck = {} + for i = 1, CardFlip.DECK_SIZE do deck[i] = 0 end + local value = CardFlip.DECK_SIZE - 1 + while value > 0 do + local slot, tries = nil, 0 + repeat + slot = random(32) + tries = tries + 1 + until (slot < CardFlip.DECK_SIZE and deck[slot + 1] == 0) + or tries >= SHUFFLE_RETRIES + if slot >= CardFlip.DECK_SIZE or deck[slot + 1] ~= 0 then + slot = 0 + while slot < CardFlip.DECK_SIZE and deck[slot + 1] ~= 0 do + slot = slot + 1 + end + end + deck[slot + 1] = value + value = value - 1 + end + return deck +end + +-- .CheckTheCard: wDeck + numCardsPlayed * 2 + whichCard. `which` is 0 or 1, +-- `played` is 0-based, and the deck runs out after twelve hands (.Continue's +-- `cp 12`), at which point the deck is reshuffled. +function CardFlip.dealt(deck, played, which) + return deck[played * 2 + which + 1] +end + +CardFlip.HANDS_PER_DECK = 12 + +-- ------------------------------------------------------------------ board +-- +-- CardFlip_CheckWinCondition's jumptable, read through CollapseCursorPosition +-- (index = cursorY * 6 + cursorX). Rows and columns are the cart's, 0-based: +-- +-- x = 0 the level PAIR column, one entry per two rows +-- x = 1 the single level column +-- x = 2..5 one column per Pokemon +-- y = 0 the Pokemon PAIR row, one entry per two columns +-- y = 1 the single Pokemon row +-- y = 2..7 one row per level +-- +-- and the four squares at x < 2, y < 2 are .Impossible. +CardFlip.PAYOUT_MON_PAIR = 6 +CardFlip.PAYOUT_LEVEL_PAIR = 9 +CardFlip.PAYOUT_MON = 12 +CardFlip.PAYOUT_LEVEL = 18 +CardFlip.PAYOUT_CARD = 72 + +CardFlip.BOARD_W = 6 +CardFlip.BOARD_H = 8 + +-- BOARD[y][x], 0-based on both axes. +CardFlip.BOARD = {} +for y = 0, CardFlip.BOARD_H - 1 do + local row = {} + for x = 0, CardFlip.BOARD_W - 1 do + local cell + if y < 2 and x < 2 then + cell = { kind = "impossible", payout = 0 } + elseif y == 0 then + -- .PikaJiggly / .PoliOddish: `and $2` splits the four Pokemon in half. + cell = { kind = "monPair", value = math.floor((x - 2) / 2), + payout = CardFlip.PAYOUT_MON_PAIR } + elseif y == 1 then + cell = { kind = "mon", value = x - 2, payout = CardFlip.PAYOUT_MON } + elseif x == 0 then + -- .OneTwo / .ThreeFour / .FiveSix, each reached from two rows. + cell = { kind = "levelPair", value = math.floor((y - 2) / 2), + payout = CardFlip.PAYOUT_LEVEL_PAIR } + elseif x == 1 then + cell = { kind = "level", value = y - 2, payout = CardFlip.PAYOUT_LEVEL } + else + cell = { kind = "card", value = CardFlip.card(y - 2, x - 2), + payout = CardFlip.PAYOUT_CARD } + end + row[x] = cell + end + CardFlip.BOARD[y] = row +end + +function CardFlip.cell(x, y) + local row = CardFlip.BOARD[y] + return row and row[x] or nil +end + +-- What a square pays against a card, or 0. Only one square is ever bet on, so +-- there is no stacking to worry about. +function CardFlip.payout(x, y, card) + local cell = CardFlip.cell(x, y) + if not cell or not card then return 0 end + local kind = cell.kind + if kind == "impossible" then return 0 end + if kind == "monPair" then + return math.floor(CardFlip.mon(card) / 2) == cell.value and cell.payout or 0 + end + if kind == "mon" then + return CardFlip.mon(card) == cell.value and cell.payout or 0 + end + if kind == "levelPair" then + return CardFlip.levelPair(card) == cell.value and cell.payout or 0 + end + if kind == "level" then + return CardFlip.level(card) == cell.value and cell.payout or 0 + end + return card == cell.value and cell.payout or 0 +end + +-- ---------------------------------------------------------- cursor moves +-- +-- ChooseCard_HandleJoypad. The board is not a plain grid: column 0 spans two +-- rows per entry and row 0 spans two columns, so moving off a paired square +-- SNAPS the other axis even before the step (`and $e`), and moving left or up +-- out of a paired square teleports to a fixed neighbour rather than stepping. +-- +-- Returns the new x, y. Transcribed branch for branch; the `and $e` snaps and +-- the two teleports are the whole reason this is not three lines. +function CardFlip.moveCursor(x, y, direction) + if direction == "left" then + if y == 0 then + -- .mon_pair_left: snap x even, then either teleport or step two. + x = x - (x % 2) + if x < 3 then return 1, 2 end -- .left_to_number_gp + return x - 2, y + end + if y == 1 then + -- .mon_group_left + if x < 3 then return 1, 2 end + return x - 1, y + end + if x == 0 then return x, y end + return x - 1, y + end + + if direction == "right" then + if y == 0 then + -- .mon_pair_right + x = x - (x % 2) + if x >= 4 then return x, y end + return x + 2, y + end + if x >= 5 then return x, y end + return x + 1, y + end + + if direction == "up" then + if x == 0 then + -- .num_pair_up + y = y - (y % 2) + if y < 3 then return 2, 1 end -- .up_to_mon_group + return x, y - 2 + end + if x == 1 then + -- .num_gp_up + if y < 3 then return 2, 1 end + return x, y - 1 + end + if y == 0 then return x, y end + return x, y - 1 + end + + if direction == "down" then + if x == 0 then + -- .num_pair_down + y = y - (y % 2) + if y >= 6 then return x, y end + return x, y + 2 + end + if y >= 7 then return x, y end + return x, y + 1 + end + + return x, y +end + +-- ------------------------------------------------------------------ layout +-- +-- Screen tile coordinates, derived from the OAM cursor anchors (minus one tile +-- across and two down) and from the discard-blanking hlcoords. +local LIGHT_X = 9 -- twelve hand lights, rows 0-11 +local MON_COL = { [2] = 12, [3] = 14, [4] = 16, [5] = 18 } +local LEVEL_PAIR_COL, LEVEL_COL = 10, 11 +local CARD_COL = { [2] = 13, [3] = 15, [4] = 17, [5] = 19 } +-- .Level1 .. .Level6's rows. The pairs overlap by a row on the cart, which is +-- the stacked-card look; the anchors are what matter here. +local LEVEL_ROW = { [2] = 3, [3] = 4, [4] = 6, [5] = 7, [6] = 9, [7] = 10 } +local MON_PAIR_ROW, MON_ROW = 0, 1 + +-- GetCoordsOfChosenCard. +local CARD_BOX = { { x = 2, y = 0 }, { x = 2, y = 6 } } +local CARD_BOX_W, CARD_BOX_H = 5, 6 + +local COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W, COIN_BOX_H = 9, 15, 11, 3 +local COIN_LABEL_X, COIN_LABEL_Y = 10, 16 +local COIN_VALUE_X, COIN_VALUE_Y = 15, 16 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +-- ------------------------------------------------------------------- text +-- data/text/common_3.asm; none of these are in the cache's text.lua, because no +-- script bytecode the extractor walks points at them. +CardFlip.TEXTS = { + playWithThree = { "Play with three", "coins?" }, + notEnough = { "Not enough coins…" }, + chooseACard = { "Choose a card." }, + placeYourBet = { "Place your bet." }, + playAgain = { "Want to play", "again?" }, + shuffled = { "The cards have", "been shuffled." }, + yeah = { "Yeah!" }, + darn = { "Darn…" }, +} + +local SFX_TRANSACTION = "Sfx_Transaction" +local SFX_KINESIS = "Sfx_Kinesis" +local SFX_START = "Sfx_SlotMachineStart" +local SFX_CHOOSE = "Sfx_ChooseACard" +local SFX_MOVE = "Sfx_PokeballsPlacedOnTable" +local SFX_WIN = "Sfx_2ndPlace" +local SFX_WRONG = "Sfx_Wrong" +local SFX_PAY_DAY = "Sfx_PayDay" +local SFX_QUIT = "Sfx_QuitSlots" + +-- ------------------------------------------------------------------ screen +function CardFlip:wantsFillScale() return true end +function CardFlip:drawsWidescreen() return true end + +-- opts: save, random(n), onClose() +function CardFlip.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CardFlip) + self.game = game + self.save = opts.save or (game and game.save) + self.onClose = opts.onClose + self.random = opts.random or function(n) + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 + end + -- `ld a, $2 / ld [wCardFlipCursorY], a / ld [wCardFlipCursorX], a`. + self.cursorX, self.cursorY = 2, 2 + self.played = 0 + self.discarded = {} + self.deck = CardFlip.shuffle(self.random) + self:playMusic() + self:enterAsk() + return self +end + +function CardFlip:playMusic() + local data = self.game and self.game.data + if not data then return end + require("src.core.Music").play(data, "Music_GameCorner") +end + +function CardFlip:sfx(name) + local data = self.game and self.game.data + if data then Sound.play(data, name) end +end + +function CardFlip:coins() + return CoinCase.coins(self.save) +end + +-- ---------------------------------------------------------------- phases +-- +-- .AskPlayWithThree / .DeductCoins / .ChooseACard / .PlaceYourBet / +-- .CheckTheCard / .TabulateTheResult / .PlayAgain / .Quit. +function CardFlip:enterAsk() + self.phase = "ask" + self.choice = 1 + self.lines = CardFlip.TEXTS.playWithThree +end + +function CardFlip:deduct() + -- The check is `wCoins high byte non-zero, or low byte >= 3`, which is just + -- "at least three coins". + if self:coins() < CardFlip.BET then + self.phase = "message" + self.lines = CardFlip.TEXTS.notEnough + self.after = function() self:quit() end + return + end + CoinCase.takeCoins(self.save, CardFlip.BET) + self:sfx(SFX_TRANSACTION) + self:enterChoose() +end + +function CardFlip:enterChoose() + self.phase = "choose" + self.which = 0 -- wCardFlipWhichCard + self.faceUp = nil + self.lines = CardFlip.TEXTS.chooseACard +end + +function CardFlip:enterBet() + self.phase = "bet" + self.lines = CardFlip.TEXTS.placeYourBet +end + +-- .CheckTheCard: the dealt card is turned face up and marked on the discard +-- pile, which is what blanks its cell on the odds board. +function CardFlip:flip() + local card = CardFlip.dealt(self.deck, self.played, self.which) + self.faceUp = card + self.discarded[card] = true + self:sfx(SFX_CHOOSE) + self:tabulate() +end + +function CardFlip:tabulate() + local won = CardFlip.payout(self.cursorX, self.cursorY, self.faceUp) + self.payoutLeft = won + self.payoutTick = 0 + if won > 0 then + self.phase = "payout" + self.lines = CardFlip.TEXTS.yeah + self:sfx(SFX_WIN) + else + self.phase = "result" + self.lines = CardFlip.TEXTS.darn + self:sfx(SFX_WRONG) + end +end + +-- .Payout's loop: one coin every two frames, the coin case checked BEFORE each +-- increment so a full case swallows the rest of the win. +function CardFlip:updatePayout() + self.payoutTick = self.payoutTick + 1 + if self.payoutTick % 2 == 1 then return end + if self.payoutLeft <= 0 then + self.phase = "result" + return + end + self.payoutLeft = self.payoutLeft - 1 + if self:coins() < CoinCase.MAX_COINS then + CoinCase.giveCoins(self.save, 1) + self:sfx(SFX_PAY_DAY) + end +end + +-- .PlayAgain -> .Continue: the hand counter advances, and only a twelfth hand +-- reshuffles. Otherwise the card just played is blanked off the board. +function CardFlip:enterAgain() + self.phase = "again" + self.choice = 1 + self.lines = CardFlip.TEXTS.playAgain +end + +function CardFlip:continue() + self.played = self.played + 1 + if self.played >= CardFlip.HANDS_PER_DECK then + self.played = 0 + self.deck = CardFlip.shuffle(self.random) + self.discarded = {} + self.phase = "message" + self.lines = CardFlip.TEXTS.shuffled + self.after = function() self:deduct() end + return + end + self:deduct() +end + +function CardFlip:quit() + self.phase = "quit" + self:sfx(SFX_QUIT) + local data = self.game and self.game.data + if data then require("src.core.Music").restoreMap(data) end + if self.onClose then self.onClose() end +end + +function CardFlip:update(_dt) + local input = self.game and self.game.input + if not input then return end + local phase = self.phase + + if phase == "ask" or phase == "again" then + if input:wasPressed("up") or input:wasPressed("down") then + self.choice = self.choice == 1 and 2 or 1 + return + end + -- YesNoBox returning carry is .SaidNo on the first question and the + -- .Increment into .Quit on the second: B leaves either way. + if input:wasPressed("b") then + self:quit() + return + end + if input:wasPressed("a") then + if self.choice ~= 1 then + self:quit() + elseif phase == "ask" then + self:deduct() + else + self:continue() + end + end + return + end + + if phase == "message" then + if input:wasPressed("a") or input:wasPressed("b") then + local after = self.after + self.after = nil + if after then after() end + end + return + end + + -- .ChooseACard's loop: the highlight alternates between the two face-down + -- cards on its own and A locks whichever is lit. + if phase == "choose" then + if input:wasPressed("a") then + self:sfx(SFX_START) + self:enterBet() + return + end + self.blink = (self.blink or 0) + 1 + if self.blink >= 4 then + self.blink = 0 + self.which = self.which == 0 and 1 or 0 + self:sfx(SFX_KINESIS) + end + return + end + + if phase == "bet" then + for _, dir in ipairs({ "left", "right", "up", "down" }) do + if input:wasPressed(dir) then + local x, y = CardFlip.moveCursor(self.cursorX, self.cursorY, dir) + if x ~= self.cursorX or y ~= self.cursorY then self:sfx(SFX_MOVE) end + self.cursorX, self.cursorY = x, y + return + end + end + if input:wasPressed("a") then self:flip() end + return + end + + if phase == "payout" then + self:updatePayout() + return + end + + if phase == "result" then + -- WaitPressAorB_BlinkCursor. + if input:wasPressed("a") or input:wasPressed("b") then self:enterAgain() end + return + end +end + +-- ------------------------------------------------------------------- draw +function CardFlip:drawBoard() + -- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand + -- being played and every one before it stays off. + for row = 0, CardFlip.HANDS_PER_DECK - 1 do + Chrome.print(row == self.played and "o" or ".", LIGHT_X, row) + end + for x = 2, 5 do + Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW) + -- The pair headers sit above the two Pokemon they cover. + if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end + end + for y = 2, 7 do + local row = LEVEL_ROW[y] + Chrome.print(tostring(y - 1), LEVEL_COL, row) + if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end + for x = 2, 5 do + -- A still-in-the-deck cell stands in for the card back until the art + -- lands. It cannot be '#': that is charmap.asm $54, the text command + -- that places "POKé", not a one-tile glyph. + local card = CardFlip.card(y - 2, x - 2) + Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row) + end + end +end + +function CardFlip:cursorCell() + local x, y = self.cursorX, self.cursorY + local col + if x == 0 then col = LEVEL_PAIR_COL + elseif x == 1 then col = LEVEL_COL + else col = MON_COL[x] end + local row + if y == 0 then row = MON_PAIR_ROW + elseif y == 1 then row = MON_ROW + else row = LEVEL_ROW[y] end + return col, row +end + +-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the +-- 3x3 Pokepic one row further down. +function CardFlip:drawCards() + for slot = 1, 2 do + local box = CARD_BOX[slot] + Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H) + local chosen = (slot - 1) == self.which + if self.faceUp and chosen then + Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3, + box.y + 1) + Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)], + box.x + 1, box.y + 3) + elseif self.phase == "choose" and chosen then + Chrome.cursor(box.x, box.y + 3) + end + end +end + +function CardFlip:drawPanel() + Chrome.clear() + self:drawBoard() + self:drawCards() + Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2) + Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) + Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y) + if self.lines then + Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) + for i, line in ipairs(self.lines) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end + end + if self.phase == "bet" then + local col, row = self:cursorCell() + Chrome.cursor(col - 1, row) + end + if self.phase == "ask" or self.phase == "again" then + -- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10). + Chrome.textbox(14, 7, 4, 3) + Chrome.print("YES", 16, 8) + Chrome.print("NO", 16, 10) + Chrome.cursor(15, 8 + (self.choice - 1) * 2) + end +end + +function CardFlip:draw() + self:drawPanel() +end + +function CardFlip: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return CardFlip diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua new file mode 100644 index 00000000..49d6794a --- /dev/null +++ b/src/ui/gen2/CenterPcMenu.lua @@ -0,0 +1,325 @@ +-- The Pokemon Center PC's whose-PC menu (engine/events/pokecenter_pc.asm +-- PokemonCenterPC). Every Pokecenter reaches it the same way: the PC is a +-- COLL_PC tile, the A press runs PCScript (engine/events/std_scripts.asm) and +-- its `special PokemonCenterPC` opens this screen through World:openPc. +-- +-- .ChooseWhichPCListToUse picks the row list: +-- +-- PCPC_BEFORE_POKEDEX BILL's PC / <PLAYER>'s PC / TURN OFF +-- PCPC_BEFORE_HOF + PROF.OAK's PC (CheckReceivedDex) +-- PCPC_POSTGAME + HALL OF FAME (wHallOfFameCount > 0) +-- +-- BILL's PC opens the storage system (src/ui/gen2/PcMenu.lua, _BillsPC's own +-- five rows), <PLAYER>'s PC the item PC (src/ui/gen2/ItemPcMenu.lua, +-- PLAYERSPC_NORMAL), PROF.OAK's PC the #DEX rating (ProfOaksPC, +-- engine/events/prof_oaks_pc.asm) and HALL OF FAME the roster viewer +-- (src/ui/gen2/HallOfFame.lua "view" mode, _HallOfFamePC). + +local Chrome = require("src.ui.gen2.Chrome") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Specials = require("src.script.gen2.Specials") +local Strings = require("src.core.Strings") + +local CenterPcMenu = {} +CenterPcMenu.__index = CenterPcMenu +CenterPcMenu.isOpaque = true + +-- ENGINE_POKEDEX (constants/engine_flags.asm, index 11): the flag +-- CheckReceivedDex reads and Mr.Pokemon's `setflag ENGINE_POKEDEX` writes. +-- The port lands plain ENGINE_* ids on save.engineFlags +-- (World:setEngineFlag), so the same store answers here. +local ENGINE_POKEDEX = 11 + +function CenterPcMenu:wantsFillScale() return true end +function CenterPcMenu:drawsWidescreen() return true end + +-- A multi-page body: `para` (a blank line in the transcription) is a +-- screenful of its own, two lines to a page. +local function pagesOf(body) + local pages = {} + for chunk in (tostring(body) .. "\n\n"):gmatch("(.-)\n\n") do + local lines = {} + for line in (chunk .. "\n"):gmatch("(.-)\n") do + if line ~= "" then lines[#lines + 1] = line end + end + if #lines > 0 then pages[#pages + 1] = lines end + end + return pages +end + +-- opts: save, events, items (items.lua), onClose() +function CenterPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CenterPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.events = opts.events + self.onClose = opts.onClose + self.index = 1 + self.message = nil + self.confirm = nil + self.closed = false + self:buildEntries() + local party = self.save and self.save.party + if not (party and #party > 0) then + -- PC_CheckPartyForPokemon: SFX_CHOOSE_PC_OPTION, the refusal, and the PC + -- never boots (`ret c` before PC_PlayBootSound). + self:playSfx("Sfx_ChoosePcOption") + self:say({ { "Bzzzzt! You must", "have a #MON to", "use this!" } }, + function() self:close() end) + else + -- PC_PlayBootSound + _PokecenterPCTurnOnText. + self:playSfx("Sfx_BootPc") + self:say({ { "{PLAYER} turned on", "the PC." } }) + end + return self +end + +function CenterPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then + Sound.play(data, name) + end +end + +function CenterPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- .WhichPC, gated the way .ChooseWhichPCListToUse gates it. TURN OFF is +-- always last; the extra rows slot in above it in PCPCITEM_* order. +function CenterPcMenu:buildEntries() + local save = self.save + local hasDex = save and save.engineFlags + and save.engineFlags[ENGINE_POKEDEX] == true + local hofCount = (save and save.hallOfFame and save.hallOfFame.count) or 0 + local entries = { + { id = "bills", label = "BILL's PC" }, + { id = "players", label = self:playerName() .. "'s PC" }, + } + if hasDex then + entries[#entries + 1] = { id = "oaks", label = "PROF.OAK's PC" } + if hofCount > 0 then + entries[#entries + 1] = { id = "hof", label = "HALL OF FAME" } + end + end + entries[#entries + 1] = { id = "turnoff", label = "TURN OFF" } + self.entries = entries +end + +-- pages is a list of pages, each a list of lines; a page may carry `sfx`, +-- played the moment it comes up (FindOakRating hands PlaySFX its fanfare +-- right before the rating text prints). +function CenterPcMenu:say(pages, onDone) + self.message = { pages = pages, page = 1, onDone = onDone } + local first = pages[1] + if first and first.sfx then self:playSfx(first.sfx) end +end + +function CenterPcMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +-- .shutdown: PC_PlayShutdownSound, then the menu is gone. The party refusal +-- never reaches this -- the PC never booted. +function CenterPcMenu:shutdown() + self:playSfx("Sfx_ShutDownPc") + self:close() +end + +-- ProfOaksPC's `.shutdown`: _OakPCText4 either way, then back to the menu +-- loop (`jr nc, .loop` in PokemonCenterPC -- the OaksPC row answers nc). +function CenterPcMenu:oakClosed() + self:say({ { "The link to PROF.", "OAK's PC closed." } }) +end + +-- ProfOaksPCBoot, inside a screen rather than a script: the counts, the +-- rating pick and the texts come from src/script/gen2/Specials.lua so the two +-- callers cannot drift apart. +function CenterPcMenu:oakRate() + local seen, caught = Specials.dexCounts(self.save) + local rating = Specials.findOakRating(caught) + local pages = pagesOf(Strings(Specials.OAK_PC_TEXT.completion)) + for _, page in ipairs(pagesOf( + Strings(Specials.OAK_PC_TEXT.counts, seen, caught))) do + pages[#pages + 1] = page + end + local ratingPages = pagesOf(Strings(rating.text)) + if ratingPages[1] then ratingPages[1].sfx = rating.sfx end + for _, page in ipairs(ratingPages) do pages[#pages + 1] = page end + self:say(pages, function() self:oakClosed() end) +end + +function CenterPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "turnoff" then + -- TurnOffPC: PokecenterPCOaksClosedText, then carry into .shutdown. + self:say({ { "\xe2\x80\xa6", "Link closed\xe2\x80\xa6" } }, + function() self:shutdown() end) + return + end + -- PC_PlayChoosePCSound opens all four of the other rows. + self:playSfx("Sfx_ChoosePcOption") + if entry.id == "bills" then + self:say({ { "BILL's PC", "accessed." }, + { "#MON Storage", "System opened." } }, function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2PcMenu", { + save = self.save, + bills = true, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "players" then + self:say({ { "Accessed own PC." }, + { "Item Storage", "System opened." } }, function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2ItemPcMenu", { + save = self.save, + items = self.items, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "oaks" then + self:say({ { "PROF.OAK's PC", "accessed." }, + { "#DEX Rating", "System opened." } }, function() + -- _OakPCText1's yes/no; NO is the same `.shutdown` as a finished rating. + self.confirm = { + prompt = { "Want to get your", "#DEX rated?" }, + choice = 1, + onYes = function() self:oakRate() end, + onNo = function() self:oakClosed() end, + } + end) + elseif entry.id == "hof" then + if not (game and game.stack) then return end + -- HallOfFamePC: FadeToMenu, _HallOfFamePC, CloseSubmenu. + Screens.push(game, "Gen2HallOfFame", { + save = self.save, + mode = "view", + onDone = function() game.stack:pop() end, + }) + end +end + +function CenterPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + m.page = m.page + 1 + local page = m.pages[m.page] + if page and page.sfx then self:playSfx(page.sfx) end + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- YesNoBox's B is NO. + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry lands straight in .shutdown, no text. + self:shutdown() + end +end + +function CenterPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + local startY = #lines >= 3 and 13 or 14 + for i, line in ipairs(lines) do + Chrome.print((line:gsub("{PLAYER}", name)), 1, startY + (i - 1) * 2) + end +end + +function CenterPcMenu:drawPanel() + Chrome.clear() + + if self.message then + self:drawBottomLines(self.message.pages[self.message.page]) + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- _PokecenterPCWhoseText stays up under the menu + -- (PC_DisplayTextWaitMenu leaves it there); the menu window is drawn on + -- top of it, the way the cart's windows stack. + self:drawBottomLines({ "Access whose PC?" }) + -- .TopMenu is menu_coords 0, 0, 15, 12. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.label, 2, ty) + end + + if self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print("YES", 16, 8) + Chrome.print("NO", 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function CenterPcMenu:draw() + self:drawPanel() +end + +function CenterPcMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +CenterPcMenu.ENGINE_POKEDEX = ENGINE_POKEDEX + +return CenterPcMenu diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua new file mode 100644 index 00000000..a6ccd8a0 --- /dev/null +++ b/src/ui/gen2/Chrome.lua @@ -0,0 +1,374 @@ +-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor +-- list that nearly every Gold screen is built out of. +-- +-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu + +-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at +-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an +-- accept on some menus -- lives here once instead of in each screen. +-- +-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is +-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout +-- transcribed from pokegold land on the same pixels. + +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") + +local Chrome = {} + +-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the +-- text-advance arrow. +Chrome.CURSOR = 0xED +Chrome.CURSOR_HOLLOW = 0xEC +Chrome.DOWN_ARROW = 0xEE + +Chrome.SCREEN_W = 20 +Chrome.SCREEN_H = 18 + +-- charmap.asm "¥", the money field's own prefix tile. +local YEN = "\xc2\xa5" + +function Chrome.clear() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + G.setColor(0, 0, 0, 1) +end + +-- The blit scale every `drawWidescreen` paints its 160x144 panel at. +-- +-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid +-- everything here is laid out on stops landing on tile boundaries: at a +-- fractional scale some rows of a tile get one device pixel and their +-- neighbours get two, which is what breaks a box border into steps and eats +-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua +-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen +-- paints still fills the window, the PANEL is what stays on the grid. +function Chrome.fitScale(winW, winH) + return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8), + (winH or 0) / (Chrome.SCREEN_H * 8)))) +end + +-- The centred origin that goes with it, so a caller does not re-derive it. +function Chrome.fitOrigin(winW, winH, scale) + scale = scale or Chrome.fitScale(winW, winH) + return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2), + math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2) +end + +-- A bordered box, tile coords. Leaves the draw color black for text. +function Chrome.box(tx, ty, tw, th) + Font.drawBox(tx, ty, tw, th) + love.graphics.setColor(0, 0, 0, 1) +end + +-- Gold's Textbox helper takes an interior width/height and draws the border +-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13` +-- box is 6 rows by 15 columns on screen. +function Chrome.textbox(tx, ty, interiorW, interiorH) + Chrome.box(tx, ty, interiorW + 2, interiorH + 2) +end + +function Chrome.print(text, tx, ty) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, tx * 8, ty * 8) +end + +-- Prints a string the way a tilemap screen does: the glyph tiles replace the +-- cells they land on, and both their ink and their blank pixels go through +-- that screen's own BG palette. +-- +-- The font sheets here are black ink (shade 3) on transparent, and setColor +-- multiplies -- black times anything is still black -- so the ink colour +-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the +-- palette's colour 3, and painting colour 0 behind the string first supplies +-- the cell background the sheet does not carry. Without a shader this +-- degrades to the ordinary black print rather than drawing nothing. +-- +-- The palette printThrough actually draws with: COLOR mode resolved, +-- inverted if asked, and the active rBGP byte folded in last -- the same +-- order GbcPalette.use follows. Split out so the fold can be checked +-- without a real shader (GbcPalette.available() is false in a driverless +-- test harness, which used to hide this from every suite). +-- +-- The substitution has to happen before the reversal or an inverted string +-- would come back through an un-reversed grey ramp and print black on white, +-- and the rBGP fold has to come after both: it REORDERS the four entries +-- this palette already settled on (colour 0 in the low bits, so +-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before +-- the invert reversal would permute the wrong four colours. +function Chrome.throughPalette(palette, invert) + local pal = GbcPalette.resolve(palette) + if invert then pal = { pal[4], pal[3], pal[2], pal[1] } end + return GbcPalette.remap(pal, GbcPalette.bgp) +end + +-- `palette` is a 4-colour table of 0-255 triples. `invert` is what +-- Chrome.printInverted passes; see there. +function Chrome.printThrough(text, tx, ty, palette, invert) + if not (palette and GbcPalette.available()) then + return Chrome.print(text, tx, ty) + end + -- `pal` is used below and never touches the caller's palette again -- + -- including useRaw for the draw, since useRaw skips the fold GbcPalette.use + -- would otherwise apply and folding it a second time would undo this. + local pal = Chrome.throughPalette(palette, invert) + local width = Font.width(text) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) + love.graphics.setColor(1, 1, 1, 1) + local previous = love.graphics.getShader() + GbcPalette.useRaw(pal) + Font.draw(text, tx * 8, ty * 8) + love.graphics.setShader(previous) + love.graphics.setColor(0, 0, 0, 1) + return width +end + +-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors +-- both bitplanes of the standard font, so a glyph pixel of shade s becomes +-- shade 3 - s before the palette sees it -- which is exactly the palette read +-- backwards, and is what makes the dex white on black. +function Chrome.printInverted(text, tx, ty, palette) + if not palette then return Chrome.print(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, palette, true) +end + +-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is +-- how Gold prints numbers (PrintNum fills from the right). +function Chrome.printRight(text, txEnd, ty) + local width = Font.width(text) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, txEnd * 8 - width, ty * 8) +end + +-- Wrap text to `width` tiles, measuring with the real font so a proportional +-- page wraps where it actually overflows. Nothing on a 160px screen may print +-- past tile 20: text that does is drawn outside the GB frame entirely. +-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next` +-- control byte, e.g. "<USER>\nused <MOVE>!"), so it is a HARD break: each +-- segment wraps on its own and the break survives, rather than being eaten by +-- the %S+ tokenizer and re-wrapped by width. +function Chrome.wrap(text, width) + local budget = (width or Chrome.SCREEN_W) * 8 + local lines = {} + for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + local line = nil + for word in segment:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and Font.width(candidate) > budget then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then lines[#lines + 1] = line end + end + return lines +end + +-- Print wrapped text from (tx, ty) downward, at most `rows` lines. +function Chrome.printWrapped(text, tx, ty, width, rows) + local lines = Chrome.wrap(text, width) + for i = 1, math.min(#lines, rows or #lines) do + Chrome.print(lines[i], tx, ty + i - 1) + end + return #lines +end + +function Chrome.cursor(tx, ty, hollow) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) +end + +-- The cursor glyph through a palette, the way Chrome.printThrough draws text. +-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont +-- xors both bitplanes, so it runs white on black) needs its cursor inverted +-- with everything else -- Chrome.cursor's flat black is invisible against that +-- ground, which is what made the dex's action arrow look absent. +function Chrome.cursorThrough(tx, ty, palette, invert, hollow) + if not (palette and GbcPalette.available()) then + return Chrome.cursor(tx, ty, hollow) + end + local pal = Chrome.throughPalette(palette, invert) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + local previous = love.graphics.getShader() + GbcPalette.useRaw(pal) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) + love.graphics.setShader(previous) + love.graphics.setColor(0, 0, 0, 1) +end + +-- Gold pads a numeric field with spaces, not zeroes, unless +-- PRINTNUM_LEADINGZEROS is set. +function Chrome.number(value, width, leadingZeros) + local text = tostring(math.floor(value or 0)) + local pad = math.max(0, (width or 0) - #text) + return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text +end + +-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS +-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST +-- significant digit rather than at a fixed column, and the field is six digits +-- wide, so the string is always seven tiles and the yen sign floats. +function Chrome.money(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end + +-- ------------------------------------------------------- balance boxes +-- +-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a +-- `special` and then leaves standing: every one of them is immediately +-- followed by `loadmenu`, so the box is on screen for the whole of the static +-- menu that answers. They live here rather than in one menu module because +-- three different screens print them (the Game Corner prize counters and the +-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen +-- itself) and all three are laid out by the same two routines. +-- +-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at +-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with +-- PRINTNUM_LEADINGZEROS. +function Chrome.coinBalanceBox(coins) + Chrome.textbox(11, 0, 7, 1) + Chrome.print("COIN", 12, 0) + Chrome.print(Chrome.number(coins, 4, true), 13, 1) +end + +-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior +-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and +-- "COIN" at (6,3) with the four-digit count at (15,3). +function Chrome.moneyAndCoinBalanceBox(money, coins) + Chrome.textbox(5, 0, 13, 3) + Chrome.print("MONEY", 6, 1) + Chrome.print(Chrome.money(money), 12, 1) + Chrome.print("COIN", 6, 3) + Chrome.print(Chrome.number(coins, 4, true), 15, 3) +end + +-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, +-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox +-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). +function Chrome.moneyBalanceBox(money) + Chrome.box(11, 0, 9, 3) + Chrome.print(Chrome.money(money), 12, 1) +end + +-- A vertical cursor list. +-- +-- opts: +-- items array of strings, or of { label = , value = , disabled = } +-- x, y tile coords of the first row's *label* (cursor sits at x - 1) +-- spacing tile rows between entries (Gold uses 2 for most menus) +-- rows visible rows; a longer list scrolls (default: all) +-- wrap wrap past the ends (STATICMENU_WRAP) +-- startAccepts treat START like A (STATICMENU_ENABLE_START) +-- onChoose(value, index) +-- onCancel() +-- onMove(value, index) -- for the start menu's description box +local List = {} +List.__index = List +Chrome.List = List + +function List.new(opts) + local self = setmetatable({}, List) + self.items = {} + for i, entry in ipairs(opts.items or {}) do + if type(entry) == "table" then + self.items[i] = entry + else + self.items[i] = { label = tostring(entry), value = entry } + end + end + self.x = opts.x or 1 + self.y = opts.y or 1 + self.spacing = opts.spacing or 2 + self.rows = math.min(opts.rows or #self.items, #self.items) + self.wrap = opts.wrap ~= false + self.startAccepts = opts.startAccepts or false + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.onMove = opts.onMove + self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) + self.scroll = 0 + self:ensureVisible() + return self +end + +function List:current() + return self.items[self.index] +end + +function List:ensureVisible() + if self.rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + self.rows then + self.scroll = self.index - self.rows + end + self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows)) +end + +function List:move(delta) + if #self.items == 0 then return end + local next_ = self.index + delta + if next_ < 1 then + if not self.wrap then return end + next_ = #self.items + elseif next_ > #self.items then + if not self.wrap then return end + next_ = 1 + end + self.index = next_ + self:ensureVisible() + if self.onMove then self.onMove(self:current() and self:current().value, self.index) end +end + +-- Returns true when the press was consumed, so a screen can layer its own +-- handling (left/right on the options rows) behind this. +function List:update(input) + if not input then return false end + if input:wasPressed("up") then + self:move(-1) + return true + elseif input:wasPressed("down") then + self:move(1) + return true + elseif input:wasPressed("a") + or (self.startAccepts and input:wasPressed("start")) then + local item = self:current() + if item and not item.disabled and self.onChoose then + self.onChoose(item.value, self.index) + end + return true + elseif input:wasPressed("b") then + if self.onCancel then self.onCancel() end + return true + end + return false +end + +function List:draw() + for row = 1, self.rows do + local i = row + self.scroll + local item = self.items[i] + if item then + local ty = self.y + (row - 1) * self.spacing + if i == self.index then Chrome.cursor(self.x - 1, ty) end + Chrome.print(item.label, self.x, ty) + end + end + -- Scrolling lists get the ▼ hint Gold shows when there is more below. + if self.rows < #self.items and self.scroll + self.rows < #self.items then + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end +end + +return Chrome diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua new file mode 100644 index 00000000..cc309f6a --- /dev/null +++ b/src/ui/gen2/ContestMenu.lua @@ -0,0 +1,190 @@ +-- The Bug Catching Contest's one screen: the STOCK-versus-THIS comparison the +-- park shows when you catch a second mon, and the "Switch #MON?" it asks over +-- it (engine/events/bug_contest/display_stats.asm +-- DisplayCaughtContestMonStats, driven by +-- engine/events/bug_contest/caught_mon.asm BugContest_SetCaughtContestMon). +-- +-- The layout is transcribed from the ASM's hlcoord values, not laid out by +-- eye: +-- +-- Textbox (0,0) `ld b, 4 / ld c, 13` interior 13x4, so 15x6 on screen +-- Textbox (0,6) `ld b, 4 / ld c, 13` the same box six rows down +-- PlaceString (2,0) " STOCK <PK><MN> " -- ON the top border, spaces and +-- PlaceString (2,6) " THIS <PK><MN> " all, which is what erases the +-- border tiles under the label +-- PlaceString (5,4) "HEALTH" +-- PlaceString (5,10) "HEALTH" +-- PlaceString (1,2) the stock mon's name, then PrintLevel at the coord +-- PlaceString returned in bc -- i.e. immediately after +-- the name, NOT at a fixed column +-- PlaceString (1,8) the new mon's nickname, then PrintLevel likewise +-- PrintNum (11,4) wContestMonMaxHP, `lb bc, 2, 3` -- 2 bytes, 3 digits +-- PrintNum (11,10) wEnemyMonMaxHP, same field +-- PlaceYesNoBox `lb bc, 14, 7` -- the shared 6x5 box at (14,7) +-- +-- Two things the ASM does that are easy to drop. It sets NO_TEXT_SCROLL for +-- the duration and restores wOptions afterwards, so ContestAskSwitchText +-- appears whole instead of scrolling; and PlaceYesNoBox's `ret c` is the NO +-- arm, which means backing out with B keeps the mon already in stock. A +-- player who mashes B therefore never loses their best catch. +-- +-- The RULES are src/core/gen2/BugContest.lua. This screen decides nothing: it +-- shows the two mons, asks, and calls BugContest.switchCaught on a yes. + +local BugContest = require("src.core.gen2.BugContest") +local Chrome = require("src.ui.gen2.Chrome") + +local ContestMenu = {} +ContestMenu.__index = ContestMenu +ContestMenu.isOpaque = true + +-- ---------------------------------------------------------------- layout +local STOCK_BOX_X, STOCK_BOX_Y = 0, 0 +local THIS_BOX_Y = 6 +local BOX_INNER_W, BOX_INNER_H = 13, 4 + +local LABEL_X = 2 +local NAME_X = 1 +local NAME_ROW_OFFSET = 2 -- (1,2) against a box at row 0 +local HEALTH_X = 5 +local HEALTH_ROW_OFFSET = 4 -- (5,4) against a box at row 0 +local HP_X = 11 -- PrintNum fills LEFT from a 3-digit field here +local HP_DIGITS = 3 + +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +-- The text box ContestAskSwitchText prints into is the shared one: Textbox +-- `lb bc, 4, 18` at (0,12), two lines two rows apart starting at row 14. +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y = 1, 14 + +-- ----------------------------------------------------------------- strings +-- +-- display_stats.asm's .Stock / .This / .Health, spelled with the same leading +-- and trailing spaces, and data/text/common_2.asm's _ContestAskSwitchText. +-- <PK> and <MN> are one tile each, so "<PK><MN>" is two tiles and not seven. +ContestMenu.TEXT = { + stock = " STOCK <PK><MN> ", + this = " THIS <PK><MN> ", + health = "HEALTH", + askSwitch = "Switch #MON?", + -- _ContestCaughtMonText and _ContestAlreadyCaughtText, the two lines that + -- bracket this screen in BugContest_SetCaughtContestMon. + caught = function(name) return { ("Caught %s!"):format(name) } end, + alreadyCaught = function(name) + return { "You already caught", ("a %s."):format(name) } + end, +} + +function ContestMenu:wantsFillScale() return true end +function ContestMenu:drawsWidescreen() return true end + +-- PrintLevel writes the <LV> tile at the coordinate it is given and then the +-- number, so a name and its level are one string here rather than two prints +-- at fixed columns. +local function nameAndLevel(mon) + if not mon then return "" end + local name = mon.nickname or mon.name or mon.species or "?" + return ("%s<LV>%d"):format(name, mon.level or 1) +end + +local function maxHp(mon) + if not mon then return 0 end + return mon.maxHp or (mon.stats and mon.stats.hp) or 0 +end + +-- opts: +-- save the save the contest state hangs off +-- stock wContestMon, the mon already caught +-- caught the mon just caught, wEnemyMon's party form +-- onClose(kept) kept is the mon that ends up in stock +function ContestMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ContestMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.stock = opts.stock or BugContest.caughtMon(self.save) + self.caught = opts.caught + self.onClose = opts.onClose + -- PlaceYesNoBox opens on YES, and YesNoMenuHeader has no + -- STATICMENU_DISABLE_B, so B is the same as picking NO. + self.choice = 1 + return self +end + +function ContestMenu:close(kept) + if self.onClose then self.onClose(kept) end +end + +function ContestMenu:answer(yes) + local kept = self.stock + if yes then + kept = BugContest.switchCaught(self.save, self.caught) or self.caught + end + self:close(kept) +end + +function ContestMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.choice = self.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then return self:answer(false) end + if input:wasPressed("a") then return self:answer(self.choice == 1) end +end + +-- ------------------------------------------------------------------- draw + +function ContestMenu:drawMonBox(boxY, label, mon) + Chrome.textbox(STOCK_BOX_X, boxY, BOX_INNER_W, BOX_INNER_H) + Chrome.print(label, LABEL_X, boxY) + Chrome.print(nameAndLevel(mon), NAME_X, boxY + NAME_ROW_OFFSET) + Chrome.print(ContestMenu.TEXT.health, HEALTH_X, boxY + HEALTH_ROW_OFFSET) + -- `lb bc, 2, 3`: two source bytes into a three-digit field, space padded + -- because PRINTNUM_LEADINGZEROS is not set, laid down FROM hlcoord 11 -- + -- so the field starts at HP_X and the padding is part of the string. + Chrome.print(Chrome.number(maxHp(mon), HP_DIGITS), HP_X, + boxY + HEALTH_ROW_OFFSET) +end + +function ContestMenu:drawYesNo() + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + -- GetMenuTextStartCoord: the box corner, + 1 for the border, + 1 for + -- STATICMENU_CURSOR, and YesNoMenuHeader sets STATICMENU_NO_TOP_SPACING so + -- there is no third row of padding -- YES at (16,8), NO at (16,10), cursor + -- column 15. + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3)) +end + +function ContestMenu:drawPanel() + Chrome.clear() + self:drawMonBox(STOCK_BOX_Y, ContestMenu.TEXT.stock, self.stock) + self:drawMonBox(THIS_BOX_Y, ContestMenu.TEXT.this, self.caught) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + Chrome.print(ContestMenu.TEXT.askSwitch, TEXT_X, TEXT_Y) + self:drawYesNo() + love.graphics.setColor(1, 1, 1, 1) +end + +function ContestMenu:draw() + self:drawPanel() +end + +function ContestMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return ContestMenu diff --git a/src/ui/gen2/CopyrightSplash.lua b/src/ui/gen2/CopyrightSplash.lua new file mode 100644 index 00000000..e5271b1b --- /dev/null +++ b/src/ui/gen2/CopyrightSplash.lua @@ -0,0 +1,118 @@ +-- The boot copyright card (pokegold SplashScreen -> Copyright), ~100 frames +-- then on to the GAME FREAK splash; A/B/START skip it. +-- +-- The cart's card is the three Nintendo / Creatures / GAME FREAK lines from +-- CopyrightGFX. RomExtractorGen2 composes that into title/copyright_splash.png +-- and this screen draws it. + +-- src/render/Assets.lua is the mod-override choke point: a raw +-- love.graphics.newImage skips overrides/ and AssetTransform output. +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") + +local CopyrightSplash = {} +CopyrightSplash.__index = CopyrightSplash +CopyrightSplash.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local HOLD_FRAMES = 100 + +-- Rows are the Gen 1 card's, which sit where the cart's own three lines do. +-- Only used when the extracted splash image is missing. +local LINE_Y = { 48, 64, 80 } + +local function tryImage(path) + if not path then return nil end + local ok, image = pcall(Assets.image, path) + if ok then return image end + return nil +end + +function CopyrightSplash:wantsFillScale() return true end +function CopyrightSplash:drawsWidescreen() return true end + +function CopyrightSplash.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CopyrightSplash) + self.game = game + self.onDone = opts.onDone + local title = opts.title or {} + -- Prefer an explicit override, then the extracted splash from title.lua. + self.image = tryImage(opts.image) + or tryImage(title.copyrightSplash) + or tryImage("assets/generated/title/copyright_splash.png") + -- Text fallback only when the ROM extract is missing (tests / bare boots). + self.lines = opts.lines + self.frames = 0 + self.done = false + return self +end + +-- intro.boot.copyright: the first card of the GS boot cinema is on screen. +-- +-- The four intro.boot.* names are Gen 2 ONLY, and deliberately new rather than +-- borrowed: Red boots straight into IntroMovie and has no copyright card, no +-- GAME FREAK splash and no attract movie, so there is no Gen 1 moment for any +-- of them to share a name with (see docs/mod-api-gen2-compat.md). One name per +-- card, emitted the frame the card comes up, because that is the moment a mod +-- can act on -- swap the art, start its own jingle, or count the boot. The +-- card's END needs no name of its own: Game2:showCopyright chains straight into +-- the next card, so intro.boot.gamefreak IS this card's end. +function CopyrightSplash:enter() + if Runtime.wants("intro.boot.copyright") then + Runtime.emit("intro.boot.copyright", { screen = self, game = self.game }) + end +end + +function CopyrightSplash:update(_dt) + self.frames = self.frames + 1 + local input = self.game.input + local skip = input and (input:wasPressed("a") or input:wasPressed("start") + or input:wasPressed("b") or input:wasPressed("select")) + if skip or self.frames >= HOLD_FRAMES then + if self.done then return end + self.done = true + if self.onDone then self.onDone() end + end +end + +function CopyrightSplash:drawPanel() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + if self.image then + G.draw(self.image, 0, 0) + return + end + if not self.lines then return end + -- The Gen 2 font is a fixed 8px cell, so centring is a character count. + G.setColor(0, 0, 0, 1) + for index, line in ipairs(self.lines) do + local y = LINE_Y[index] + if y then + Font.draw(line, math.floor((SCREEN_W - #line * 8) / 2), y) + end + end + G.setColor(1, 1, 1, 1) +end + +function CopyrightSplash:draw() + self:drawPanel() +end + +function CopyrightSplash: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(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return CopyrightSplash diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua new file mode 100644 index 00000000..8a04726b --- /dev/null +++ b/src/ui/gen2/Credits.lua @@ -0,0 +1,862 @@ +-- The credits roll (pokegold engine/movie/credits.asm), built the way +-- src/ui/gen2/GoldSilverIntro.lua is built: the cart's own machinery, driven +-- by a scene script, transcribed rather than approximated. +-- +-- THE MACHINERY. Credits is a 13-entry jumptable stepped once per frame, and +-- almost nothing on screen is per-frame work: +-- +-- 0 ParseCredits read the script, if the timer is out +-- 1-3 Credits_Next idle +-- 4 Credits_PrepBGMapUpdate idle +-- 5 Credits_UpdateGFXRequestPath advance the banner's animation frame +-- 6 Credits_RequestGFX idle +-- 7 Credits_LYOverride slide the two border strips 2px +-- 8-11 Credits_Next idle +-- 12 Credits_LoopBack index &= $f0, back to 0 +-- +-- so ONE pass is 13 frames, and every `db CREDITS_WAIT, 12` in the script is +-- 12 passes -- 156 frames, about 2.6 seconds. That cadence is the credits; +-- getting it from a timer in seconds would drift against the music. +-- +-- THE TILEMAP IS PUSHED ONLY BY `.wait`. ParseCredits blanks rows 5-12 and +-- then writes the next group's strings, but nothing reaches VRAM until the +-- `.wait` arm sets hBGMapMode. CREDITS_WAIT2 and CREDITS_END do not, which is +-- why "THE END" survives the blank that runs immediately before CREDITS_END is +-- read. Two buffers here, `pending` and `shown`, are that hBGMapMode. +-- +-- <NEXT> IS TWO ROWS. A multi-line credits string ("#MON / GOLD VERSION / +-- STAFF", the copyright) uses `next`, which is $4e: two tile rows down at the +-- same column. And `.print` adds SCREEN_WIDTH * 2 per line index, so the four +-- lines of a group land on rows 6, 8, 10 and 12. Everything is spaced two. +-- +-- THE BANNER is four 4x4-tile mon graphics repeated five times across rows 0-3 +-- and 14-17, with a scrolling strip of border tiles on rows 4 and 13. +-- CREDITS_SCENE picks which mon and which of the four palettes; the frame +-- cycles 0,1,2,3 once per pass through Credits_LoadBorderGFX's .Frames table, +-- and CREDITS_CLEAR sets the frame to $ff, which is the solid dark block +-- wCreditsBlankFrame2bpp holds. +-- +-- THE GRAPHICS come out of the cache as `data.gen2Credits` (extracted by +-- RomExtractorGen2:extractCredits from CreditsBorderGFX, the four +-- Credits<Mon>GFX sheets, TheEndGFX and CreditsPalettes). The strip on rows 4 +-- and 13 is drawn from the real 9-tile border: DrawCreditsBorder starts at +-- tile $24 on row 4 and $20 on row 13, so the two rows are DIFFERENT halves of +-- that strip, not one strip drawn twice. A cache that predates the extractor +-- change has no such table, and then the banner falls back to the extracted +-- 16x16 mon ICONS, centred in each 4x4 cell and flipping between their two +-- frames on the cadence the real ones would. +-- +-- THE TEXT IS OURS. This project replaces GAME FREAK's branding with its own +-- everywhere else it appears (the boot card, the title screen row), and a +-- staff roll is branding at its purest, so the script below drives the cart's +-- machinery with this port's own credits. The command vocabulary, the waits, +-- the scene changes and the copyright/THE END tail are the cart's. + +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Logger = require("src.core.Logger") +local Music = require("src.core.Music") + +local Credits = {} +Credits.__index = Credits +Credits.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local TILES_W = 20 + +-- constants/credits_constants.asm, `const_def -1, -1`: the command block +-- counts DOWN from $ff. +Credits.END = 0xff +Credits.WAIT = 0xfe +Credits.SCENE = 0xfd +Credits.CLEAR = 0xfc +Credits.MUSIC = 0xfb +Credits.WAIT2 = 0xfa +Credits.THEEND = 0xf9 + +-- constants/ram_constants.asm / engine/movie/credits.asm. +local ALLOW_SKIPPING_CREDITS_F = 6 +local JUMPTABLE_EXIT_F = 7 + +-- One trip round Credits_Jumptable. +Credits.PASS_FRAMES = 13 +-- The jumptable slots that do something. Named so the frame loop reads as the +-- table rather than as three magic numbers. +local STEP_PARSE = 0 +local STEP_GFX = 5 +local STEP_LY = 7 + +-- ConstructCreditsTilemap's rows. The banner is rows 0-3 and 14-17, the two +-- border strips rows 4 and 13, and rows 5-12 are the blank $7f text field. +local BANNER_ROWS = { 0, 14 } +local BANNER_TILES = 4 -- a mon graphic is 4x4 tiles +local BANNER_REPEATS = 5 -- .InitTopPortion's `ld b, 5` +local BORDER_ROWS = { 4, 13 } +local TEXT_TOP_ROW = 5 -- ParseCredits clears from hlcoord 0, 5 +local TEXT_ROWS = 8 -- ...for SCREEN_WIDTH * 8 +local TEXT_FIRST_ROW = 6 -- .print's hlcoord 0, 6 +local LINE_SPACING = 2 -- `ld bc, SCREEN_WIDTH * 2` + +-- Credits_TheEnd: tiles $40.. at hlcoord 6, 8 and 6, 9, eight apiece. +local THEEND_X, THEEND_Y, THEEND_W = 6, 8, 8 + +-- Credits_HandleBButton: the fast-forward only unlocks once wCreditsPos has +-- passed $d, i.e. thirteen script bytes in. +local SKIP_AFTER_POS = 0xd + +local CREDITS_MUSIC = "Music_Credits" +local POST_CREDITS_MUSIC = "Music_PostCredits" +-- `.end`: `ld a, 32 / ld [wMusicFade], a`. +local POST_CREDITS_FADE = 32 + +-------------------------------------------------------------------------- +-- Palettes +-------------------------------------------------------------------------- + +-- gfx/credits/credits.pal, the first four sets (GetCreditsPalette masks the +-- scene with %11 and each set is 8 bytes). RGB555 scaled the way the importer +-- scales it, `round(v * 255 / 31)`, so these sit in the same 0-255 space every +-- other palette in the port does. +local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end + +local function pal555(...) + local out, args = {}, { ... } + for index = 1, 4 do + local base = (index - 1) * 3 + out[index] = { scale5(args[base + 1]), scale5(args[base + 2]), + scale5(args[base + 3]) } + end + return out +end + +Credits.PALETTES = { + [0] = pal555(31, 31, 31, 29, 08, 27, 15, 24, 12, 07, 07, 07), -- Bellossom + [1] = pal555(31, 31, 31, 30, 26, 11, 31, 11, 27, 07, 07, 07), -- Togepi + [2] = pal555(31, 31, 31, 31, 31, 05, 17, 23, 31, 07, 07, 07), -- Elekid + [3] = pal555(31, 31, 31, 22, 15, 10, 31, 19, 09, 07, 07, 07), -- Sentret +} + +-- Credits_LoadBorderGFX's .Frames, as which of a mon's graphics each of the +-- four animation frames uses. Three of the four mons repeat their first +-- graphic on frame 2; Sentret is the one with four distinct frames. +Credits.BORDER_FRAMES = { + [0] = { 1, 2, 1, 3 }, -- Bellossom + [1] = { 1, 2, 1, 3 }, -- Togepi + [2] = { 1, 2, 1, 3 }, -- Elekid + [3] = { 1, 2, 3, 4 }, -- Sentret +} + +-- The four species the banner parades, in scene order. +Credits.SCENE_SPECIES = { [0] = "BELLOSSOM", "TOGEPI", "ELEKID", "SENTRET" } + +-------------------------------------------------------------------------- +-- The strings +-------------------------------------------------------------------------- +-- +-- data/credits_strings.asm's shape: one table of fixed strings, each padded +-- with leading spaces to sit where it should on a 20-tile row, and an index +-- block (constants/credits_constants.asm) that the script refers to them by. +-- The two comparisons ParseCredits makes on that index are what force the +-- ordering: everything below STAFF is a person, COPYRIGHT prints at column 2 +-- instead of column 0. Both are transcribed even though only COPYRIGHT +-- changes anything, because .staff and the default arm really do land on the +-- same hlcoord. + +local STRINGS = {} +local ID = {} +local nextId = 0 + +local function defineString(name, text) + ID[name] = nextId + STRINGS[nextId] = text + nextId = nextId + 1 + return ID[name] +end + +-- The people, first, exactly as the cart orders them. +defineString("BRYANTHABOI", " BRYANTHABOI") +defineString("BOIS_CLUB", " BOIS CLUB GAMES") +defineString("THE_BOIS_CLUB", " THE BOIS CLUB") +defineString("PRET_POKEGOLD", " PRET POKEGOLD") +defineString("PRET_PROJECT", " THE PRET PROJECT") +defineString("LOVE2D", " LOVE2D") +defineString("LUAJIT", " LUAJIT") +defineString("CHIP_SYNTH", " CHIP SYNTH") +defineString("EVERY_TESTER", " EVERY TESTER") +defineString("AND_YOU", " AND YOU") +defineString("CREDIT_END", "END") + +-- STAFF and everything after it is a heading. Anything BELOW this id is a +-- person as far as ParseCredits is concerned. +Credits.STAFF = defineString("STAFF", { + " #MON", + " GOLD VERSION", + " PORT STAFF", +}) +defineString("DIRECTOR", " DIRECTOR") +defineString("PROGRAMMING", " PROGRAMMING") +defineString("ENGINE_DESIGN", " ENGINE DESIGN") +defineString("BATTLE_ENGINE", " BATTLE ENGINE") +defineString("SCRIPT_ENGINE", " SCRIPT ENGINE") +defineString("WORLD_ENGINE", " WORLD ENGINE") +defineString("AUDIO_ENGINE", " AUDIO ENGINE") +defineString("GRAPHICS", " GRAPHICS") +defineString("USER_INTERFACE", " USER INTERFACE") +defineString("ROM_IMPORTER", " ROM IMPORTER") +defineString("SAVE_EDITOR", " SAVE EDITOR") +defineString("MOD_SDK", " MOD SDK") +defineString("TOOLS", " TOOLS") +defineString("TESTING", " TESTING") +defineString("BUILT_WITH", " BUILT WITH") +defineString("BASED_ON", " BASED ON") +defineString("SPECIAL_THANKS", " SPECIAL THANKS") +defineString("PRODUCER", " PRODUCER") + +-- Credits_Copyright, the one string with an hlcoord of its own. The cart's is +-- three © lines out of data/copyright.asm; this is the port's, in the same +-- three-line shape the boot card prints. +Credits.COPYRIGHT = defineString("COPYRIGHT", { + "bois club games", + "bryanthaboi 2026", + "a fan-made port", +}) + +Credits.STRINGS = STRINGS +Credits.ID = ID + +-------------------------------------------------------------------------- +-- The script +-------------------------------------------------------------------------- +-- +-- data/credits_script.asm's shape, byte for byte: a flat stream read one byte +-- at a time by `.get`, where a string id is followed by its LINE INDEX (0-3, +-- multiplied by two rows) and each command carries its own operands. Kept +-- flat rather than turned into a list of groups so that `.get`, wCreditsPos +-- and the B-button fast-forward's `cp $d` all still mean what they mean. + +local function group(script, ...) + local rows = { ... } + for index, id in ipairs(rows) do + script[#script + 1] = id + script[#script + 1] = index - 1 + end + script[#script + 1] = Credits.WAIT + script[#script + 1] = 12 + return script +end + +local function build() + local s = {} + -- Clear the banner, put the heading up, and let the music start under it. + s[#s + 1] = Credits.CLEAR + s[#s + 1] = ID.STAFF + s[#s + 1] = 0 + s[#s + 1] = Credits.WAIT + s[#s + 1] = 8 + s[#s + 1] = Credits.MUSIC + s[#s + 1] = Credits.WAIT2 + s[#s + 1] = 10 + s[#s + 1] = Credits.WAIT + s[#s + 1] = 1 + + local function scene(n) + s[#s + 1] = Credits.SCENE + s[#s + 1] = n + end + -- The cart ends each act with a bare `CREDITS_WAIT, 0`, then CREDITS_CLEAR + -- and a one-tick wait before the next scene: the blank frame is what stops + -- one mon's banner cutting straight to the next. + local function endAct() + s[#s + 1] = Credits.WAIT + s[#s + 1] = 0 + s[#s + 1] = Credits.CLEAR + s[#s + 1] = Credits.WAIT + s[#s + 1] = 1 + end + + scene(0) -- Bellossom + group(s, ID.DIRECTOR, ID.BRYANTHABOI) + group(s, ID.PROGRAMMING, ID.BRYANTHABOI, ID.BOIS_CLUB) + group(s, ID.ENGINE_DESIGN, ID.BOIS_CLUB) + group(s, ID.BATTLE_ENGINE, ID.BOIS_CLUB) + group(s, ID.SCRIPT_ENGINE, ID.BOIS_CLUB) + endAct() + + scene(1) -- Togepi + group(s, ID.WORLD_ENGINE, ID.BOIS_CLUB) + group(s, ID.AUDIO_ENGINE, ID.CHIP_SYNTH, ID.BOIS_CLUB) + group(s, ID.GRAPHICS, ID.BOIS_CLUB) + group(s, ID.USER_INTERFACE, ID.BOIS_CLUB) + group(s, ID.ROM_IMPORTER, ID.BOIS_CLUB) + endAct() + + scene(2) -- Elekid + group(s, ID.SAVE_EDITOR, ID.BOIS_CLUB) + group(s, ID.MOD_SDK, ID.BOIS_CLUB) + group(s, ID.TOOLS, ID.BRYANTHABOI) + group(s, ID.TESTING, ID.THE_BOIS_CLUB) + group(s, ID.BUILT_WITH, ID.LOVE2D, ID.LUAJIT) + endAct() + + scene(3) -- Sentret + group(s, ID.BASED_ON, ID.PRET_POKEGOLD) + group(s, ID.SPECIAL_THANKS, ID.PRET_PROJECT, ID.EVERY_TESTER) + group(s, ID.SPECIAL_THANKS, ID.THE_BOIS_CLUB, ID.AND_YOU) + group(s, ID.PRODUCER, ID.BRYANTHABOI) + + -- The tail, which is the cart's exactly: the copyright, two long waits, the + -- graphic, one more wait, stop. + s[#s + 1] = ID.COPYRIGHT + s[#s + 1] = 0 + s[#s + 1] = Credits.WAIT + s[#s + 1] = 20 + s[#s + 1] = Credits.WAIT + s[#s + 1] = 19 + s[#s + 1] = Credits.THEEND + s[#s + 1] = Credits.WAIT + s[#s + 1] = 20 + s[#s + 1] = Credits.END + return s +end + +Credits.SCRIPT = build() + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function Credits:wantsFillScale() return true end +function Credits:drawsWidescreen() return true end + +-- opts: +-- allowSkip the ALLOW_SKIPPING_CREDITS_F bit. `HallOfFame::` pushes +-- wStatusFlags BEFORE it sets STATUSFLAGS_HALL_OF_FAME_F and +-- hands the pushed copy to Credits, so a first-time champion +-- gets false here and every later viewing gets true. +-- script an override, for tests and for RedCredits +-- onDone() +function Credits.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Credits) + self.game = game + local data = (game and game.data) or {} + self.data = data + self.onDone = opts.onDone + self.script = opts.script or Credits.SCRIPT + self.allowSkip = opts.allowSkip and true or false + self.icons = opts.icons or data.gen2Icons + self.gfx = opts.gfx or data.gen2Credits + if not (self.gfx and self.gfx.scenes) and not Credits.warned then + Credits.warned = true + Logger.info("gold credits: gfx/credits/ is not in the cache -- the " + .. "banner falls back to the mon icons") + end + self.images = {} + + -- wJumptableIndex's low nibble, and its two flags kept as their own fields + -- so a reader is never masking. + self.step = 0 + self.exiting = false + -- wCreditsPos, 1-based here because a Lua array is; the `cp $d` comparison + -- below subtracts the difference back out. + self.pos = 1 + self.timer = 0 + self.frames = 0 + self.passes = 0 + self.done = false + + -- wCreditsBorderMon / wCreditsBorderFrame. $ff is the blank frame. + self.scene = 0 + self.borderFrame = 0xff + self.lyOverride = 0 + + -- The tilemap being built and the one on screen; see the header. + self.pending = {} + self.shown = {} + return self +end + +-------------------------------------------------------------------------- +-- ParseCredits +-------------------------------------------------------------------------- + +-- `.get`: read the byte at wCreditsPos and step past it. +function Credits:get() + local byte = self.script[self.pos] + self.pos = self.pos + 1 + return byte +end + +local function clearPending(self) + self.pending = {} +end + +-- One text write into the pending tilemap. Multi-line strings are laid out +-- with <NEXT>'s two-row step. +local function place(self, id, line) + local text = STRINGS[id] + if text == nil then return end + -- `cp COPYRIGHT / jr z, .copyright` is hlcoord 2, 6; `cp STAFF / + -- jr c, .staff` and the default arm are both hlcoord 0, 6. + local x = (id == Credits.COPYRIGHT) and 2 or 0 + local y = TEXT_FIRST_ROW + (line or 0) * LINE_SPACING + if type(text) == "table" then + for index, row in ipairs(text) do + self.pending[#self.pending + 1] = + { text = row, x = x, y = y + (index - 1) * LINE_SPACING } + end + return + end + self.pending[#self.pending + 1] = { text = text, x = x, y = y } +end + +-- Credits_TheEnd: two rows of eight running tile ids at hlcoord 6, 8. Without +-- gfx/credits/theend.2bpp the same eight columns carry the words instead. +local function theEnd(self) + self.pending[#self.pending + 1] = + { theEnd = true, x = THEEND_X, y = THEEND_Y, width = THEEND_W } +end + +-- The `.wait` arm's hBGMapMode: everything written since the last parse +-- becomes what is on screen. +local function pushTilemap(self) + local copy = {} + for index, entry in ipairs(self.pending) do copy[index] = entry end + self.shown = copy +end + +function Credits:parse() + if self.exiting then return end + if self.timer > 0 then + self.timer = self.timer - 1 + return + end + + -- `.parse` clears rows 5-12 of the tilemap first, every time. + clearPending(self) + + while true do + local byte = self:get() + if byte == nil or byte == Credits.END then + -- `.end`: set the exit flag and queue the post-credits theme behind a + -- 32-step fade. The screen stays up until A is pressed. + self.exiting = true + self:fadeToPostCredits() + return + elseif byte == Credits.WAIT then + self.timer = self:get() or 0 + pushTilemap(self) + return + elseif byte == Credits.WAIT2 then + -- Same timer, no hBGMapMode: whatever is on screen stays there. + self.timer = self:get() or 0 + return + elseif byte == Credits.SCENE then + self.scene = (self:get() or 0) % 4 + self.borderFrame = 0 + elseif byte == Credits.CLEAR then + self.borderFrame = 0xff + elseif byte == Credits.MUSIC then + self:playMusic(CREDITS_MUSIC) + elseif byte == Credits.THEEND then + theEnd(self) + else + place(self, byte, self:get()) + end + end +end + +-------------------------------------------------------------------------- +-- The other jumptable entries +-------------------------------------------------------------------------- + +-- Credits_LoadBorderGFX: frame $ff is the blank block and stays blank; +-- otherwise the frame cycles 0,1,2,3 and the pair (scene, frame) picks the +-- graphic out of .Frames. +function Credits:advanceBorder() + if self.borderFrame == 0xff then return end + self.borderFrame = (self.borderFrame + 1) % 4 +end + +-- Which of the current mon's graphics is showing, 1-based, or nil while the +-- banner is cleared. +function Credits:borderGraphic() + if self.borderFrame == 0xff then return nil end + local frames = Credits.BORDER_FRAMES[self.scene] or Credits.BORDER_FRAMES[0] + return frames[self.borderFrame + 1] +end + +-- Credits_LYOverride: two more pixels every pass, written into the eight +-- scanlines at $1f and the eight at $67 -- which, with hLCDCPointer pointing +-- at rSCX, is a horizontal slide of the two border strips and nothing else. +function Credits:advanceLY() + self.lyOverride = (self.lyOverride + 2) % 256 +end + +-------------------------------------------------------------------------- +-- Audio +-------------------------------------------------------------------------- + +function Credits:playMusic(song) + local audio = self.data and self.data.audio + if audio and audio.songs and audio.songs[song] then + -- HallOfFame_PlayMusicDE: MUSIC_NONE for a frame, then the song. + Music.stop() + Music.play(self.data, song, true, { reason = "credits" }) + end +end + +function Credits:fadeToPostCredits() + local audio = self.data and self.data.audio + if not (audio and audio.songs and audio.songs[POST_CREDITS_MUSIC]) then + Music.fadeOut(POST_CREDITS_FADE) + return + end + Music.fadeOut(POST_CREDITS_FADE) + self.postCredits = POST_CREDITS_MUSIC +end + +-------------------------------------------------------------------------- +-- Frame loop +-------------------------------------------------------------------------- + +-- Credits_HandleBButton, whose whole job is to tick the timer down an EXTRA +-- time per frame while B is held: the credits do not skip, they hurry. +function Credits:handleB(input) + if not (input and input.isDown and input:isDown("b")) then return end + if not self.allowSkip then return end + -- `cp $d` against the low byte of wCreditsPos, which is 0-based. + if (self.pos - 1) < SKIP_AFTER_POS then return end + if self.timer <= 0 then return end + self.timer = self.timer - 1 +end + +-- Credits_HandleAButton: A only leaves once ParseCredits has set the exit bit. +function Credits:handleA(input) + if self.exiting and input and input.isDown and input:isDown("a") then + return true + end + return false +end + +-- One frame of `.execution_loop`. Returns true when the credits are over. +function Credits:step1() + if self.done then return true end + self.frames = self.frames + 1 + local step = self.step + if step == STEP_PARSE then + self:parse() + elseif step == STEP_GFX then + self:advanceBorder() + elseif step == STEP_LY then + self:advanceLY() + end + if step >= Credits.PASS_FRAMES - 1 then + -- Credits_LoopBack: `and $f0`, so only the low nibble is reset. + self.step = 0 + self.passes = self.passes + 1 + else + self.step = step + 1 + end + return false +end + +function Credits:finish() + if self.done then return end + self.done = true + if self.postCredits then self:playMusic(self.postCredits) end + if self.onDone then self.onDone() end +end + +function Credits:update(_dt) + if self.done then return end + local input = self.game and self.game.input + self:handleB(input) + if self:handleA(input) then return self:finish() end + self:step1() +end + +-- Run the whole movie without a graphics device, for tests and drivers. +-- Returns the number of frames it took, or nil if it never reached the end. +function Credits:runToEnd(limit) + limit = limit or 60000 + for frame = 1, limit do + self:step1() + if self.exiting then return frame end + end + return nil +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +-- GetCreditsPalette masks the scene with %11 and takes the 8-byte set at that +-- index. The extracted table is 1-based, so scene 0 is set 1; the transcribed +-- PALETTES above stay as the fallback for a cache without it. +function Credits:palette() + local sets = self.gfx and self.gfx.palettes + local extracted = sets and sets[self.scene % 4 + 1] + if extracted then return extracted end + return Credits.PALETTES[self.scene] or Credits.PALETTES[0] +end + +function Credits:image(path) + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local Assets = require("src.render.Assets") + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + if cached then cached:setFilter("nearest", "nearest") end + self.images[path] = cached + end + return cached or nil +end + +-- The icon standing in for this scene's 4x4 mon graphic, and which of its two +-- 16x16 frames the border frame maps onto. +function Credits:sceneIcon() + local species = Credits.SCENE_SPECIES[self.scene] + local icons = self.icons + if not (species and icons and icons.species and icons.icons) then return nil end + local entry = icons.icons[icons.species[species]] + if not entry then return nil end + return self:image(entry.image), entry +end + +-- An icon sheet is one column of `frames` 16x16 cells, so the four border +-- frames fold onto the two the icon has: 1,2,1,3 becomes 1,2,1,2. Quads are +-- built once per (sheet, frame) rather than per draw. +function Credits:iconQuad(image, entry, graphic) + local frames = entry.frames or 2 + local index = (graphic - 1) % frames + self.quads = self.quads or {} + local key = tostring(entry.id) .. ":" .. index + local quad = self.quads[key] + if not quad then + local height = math.floor((entry.height or 32) / frames) + quad = love.graphics.newQuad(0, index * height, entry.width or 16, height, + image:getDimensions()) + self.quads[key] = quad + end + return quad +end + +-- The real 4x4 sheet for this scene, if the cache has one. Credits<Mon>GFX +-- is `frames` 32x32 blocks stacked, and .Frames' offsets are in whole blocks, +-- so `graphic` indexes straight into it (clamped, because three of the four +-- mons only have three blocks and .Frames never asks them for a fourth). +function Credits:sceneSheet() + local scenes = self.gfx and self.gfx.scenes + local entry = scenes and scenes[self.scene % 4 + 1] + if not entry then return nil end + return self:image(entry.image), entry +end + +function Credits:sheetQuad(image, entry, graphic) + local frames = entry.frames or 1 + local index = math.min(graphic, frames) - 1 + local w, h = entry.width or 32, entry.height or 32 + self.quads = self.quads or {} + local key = "sheet:" .. tostring(entry.species) .. ":" .. index + local quad = self.quads[key] + if not quad then + quad = love.graphics.newQuad(0, index * h, w, h, image:getDimensions()) + self.quads[key] = quad + end + return quad +end + +-- TheEndGFX, or nil while the cache is without it. +function Credits:theEndImage() + local path = self.gfx and self.gfx.theEnd + return path and self:image(path) or nil +end + +-- One 8x8 tile out of the 9-tile border strip, 1-based the way +-- DrawCreditsBorder's start ids read after they are turned back into columns. +function Credits:borderQuad(image, tile) + self.quads = self.quads or {} + local key = "border:" .. tile + local quad = self.quads[key] + if not quad then + quad = love.graphics.newQuad((tile - 1) * 8, 0, 8, 8, image:getDimensions()) + self.quads[key] = quad + end + return quad +end + +local function fill(color, x, y, w, h) + local G = love.graphics + G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) + G.rectangle("fill", x, y, w, h) + G.setColor(1, 1, 1, 1) +end + +-- Rows 0-3 and 14-17: five copies of a 4x4-tile block across the screen. +function Credits:drawBanner() + local pal = GbcPalette.resolve(self:palette()) + local graphic = self:borderGraphic() + local cell = BANNER_TILES * 8 + for _, row in ipairs(BANNER_ROWS) do + -- The cleared banner really is one flat colour: wCreditsBlankFrame2bpp is + -- sixteen tiles of solid colour 2. + local backdrop = graphic and (GbcPalette.color(pal, 1) or pal[1]) + or (GbcPalette.color(pal, 3) or pal[3]) + fill(backdrop, 0, row * 8, SCREEN_W, cell) + if graphic then + -- The real sheet first: a 32x32 block is exactly the 4x4 cell, so it + -- tiles the row with no centring and no backdrop showing through. + local image, entry = self:sceneSheet() + local quad = image and entry and self:sheetQuad(image, entry, graphic) + local px, py = 0, row * 8 + if not quad then + image, entry = self:sceneIcon() + quad = image and entry and self:iconQuad(image, entry, graphic) + if quad then + local iconW, iconH = entry.width or 16, + math.floor((entry.height or 32) / (entry.frames or 2)) + px = math.floor((cell - iconW) / 2) + py = row * 8 + math.floor((cell - iconH) / 2) + end + end + if quad then + love.graphics.setColor(1, 1, 1, 1) + local function body() + for repeatIndex = 0, BANNER_REPEATS - 1 do + love.graphics.draw(image, quad, repeatIndex * cell + px, py) + end + end + if GbcPalette.available() then + GbcPalette.useRaw(pal) + body() + GbcPalette.clear() + else + body() + end + end + end + end +end + +-- Rows 4 and 13: the border strip, slid sideways by the LY override. The +-- override goes into rSCX, and a rising SCX moves the picture LEFT, which is +-- why the strip is drawn at `-shift`. DrawCreditsBorder lays four running +-- tiles five times across the row, so the pattern repeats every 32px and +-- `lyOverride % 32` is the whole of the slide. Row 4 starts at $24 and row 13 +-- at $20: the two rows are different quarters of the 9-tile strip. +function Credits:drawBorderStrips() + local pal = GbcPalette.resolve(self:palette()) + local color = GbcPalette.color(pal, 3) or pal[3] + local shift = self.lyOverride % 32 + local gfx = self.gfx + local image = gfx and gfx.border and self:image(gfx.border) + local starts = { [BORDER_ROWS[1]] = (gfx and gfx.borderTopTile) or 5, + [BORDER_ROWS[2]] = (gfx and gfx.borderBottomTile) or 1 } + for _, row in ipairs(BORDER_ROWS) do + fill(color, 0, row * 8, SCREEN_W, 8) + if image then + local first = starts[row] + love.graphics.setColor(1, 1, 1, 1) + local function body() + for x = -32, SCREEN_W, 32 do + for tile = 0, 3 do + love.graphics.draw(image, self:borderQuad(image, first + tile), + x + tile * 8 - shift, row * 8) + end + end + end + if GbcPalette.available() then + GbcPalette.useRaw(pal) + body() + GbcPalette.clear() + else + body() + end + else + -- Without the real strip art the slide has nothing to move, so the notch + -- below is what carries it: one lighter cell per four, at the offset the + -- LY override is holding. + local light = GbcPalette.color(pal, 2) or pal[2] + for x = -32, SCREEN_W, 32 do + fill(light, x + shift, row * 8, 8, 8) + end + end + end +end + +function Credits:drawText() + local pal = self:palette() + for _, entry in ipairs(self.shown) do + if entry.theEnd then + local image = self:theEndImage() + if image then + -- TheEndGFX is 16 tiles, placed 8 wide on rows 8 and 9: the sheet's + -- own 64x16 layout, so it draws whole at the tilemap coordinate. + local shaded = GbcPalette.resolve(pal) + love.graphics.setColor(1, 1, 1, 1) + if GbcPalette.available() then + GbcPalette.useRaw(shaded) + love.graphics.draw(image, entry.x * 8, entry.y * 8) + GbcPalette.clear() + else + love.graphics.draw(image, entry.x * 8, entry.y * 8) + end + else + -- Centred in the eight columns the graphic occupies. + local text = "THE END" + local width = Font.width(text) + Chrome.printThrough(text, + entry.x + math.floor((entry.width * 8 - width) / 16), entry.y, pal) + end + else + Chrome.printThrough(entry.text, entry.x, entry.y, pal) + end + end +end + +function Credits:drawPanel() + local pal = GbcPalette.resolve(self:palette()) + -- Rows 4-13 are $7f, the blank tile, which reads as colour 0. + local paper = GbcPalette.color(pal, 1) or pal[1] + fill(paper, 0, 0, SCREEN_W, SCREEN_H) + self:drawBanner() + self:drawBorderStrips() + self:drawText() + love.graphics.setColor(1, 1, 1, 1) +end + +function Credits:draw() + self:drawPanel() +end + +function Credits:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +Credits.TILES_W = TILES_W +Credits.TEXT_TOP_ROW = TEXT_TOP_ROW +Credits.TEXT_ROWS = TEXT_ROWS +Credits.TEXT_FIRST_ROW = TEXT_FIRST_ROW +Credits.LINE_SPACING = LINE_SPACING +Credits.BANNER_ROWS = BANNER_ROWS +Credits.BORDER_ROWS = BORDER_ROWS +Credits.SKIP_AFTER_POS = SKIP_AFTER_POS +Credits.ALLOW_SKIPPING_CREDITS_F = ALLOW_SKIPPING_CREDITS_F +Credits.JUMPTABLE_EXIT_F = JUMPTABLE_EXIT_F +Credits.STEP_PARSE = STEP_PARSE +Credits.STEP_GFX = STEP_GFX +Credits.STEP_LY = STEP_LY + +return Credits diff --git a/src/ui/gen2/DayCareMenu.lua b/src/ui/gen2/DayCareMenu.lua new file mode 100644 index 00000000..aba79844 --- /dev/null +++ b/src/ui/gen2/DayCareMenu.lua @@ -0,0 +1,574 @@ +-- The Day-Care conversation: deposit, withdraw, and the egg handover outside +-- (engine/events/daycare.asm DayCareMan / DayCareLady / DayCareManOutside). +-- +-- There is no menu of the Day-Care's own on the cart. All three routines are +-- PrintDayCareText + YesNoBox + SelectTradeOrDayCareMon, so this screen is a +-- speech box, a yes/no box and a push of the party list -- which is why it +-- reads as a phase machine rather than as a list with a cursor, and why every +-- coordinate below is one of the shared ones: +-- +-- THIS SCREEN DRAWS OVER THE LIVE MAP AND MUST NEVER CLEAR THE FIELD. Both +-- NPC scripts are `faceplayer / opentext / special DayCareMan / waitbutton / +-- closetext` (maps/DayCare.asm DayCareManScript_Inside, DayCareLadyScript), +-- and nothing the special reaches -- DayCareMan, DayCareLady, PrintDayCareText +-- or YesNoBox -- blanks the screen, so the Day-Care room stays visible behind +-- the textbox exactly the way any other `opentext` conversation does. Hence +-- isOpaque = false (StateStack keeps the overworld in the frame), no +-- drawsWidescreen/drawWidescreen (Game2:drawScene must fall through to +-- world:draw + stack:drawCanvas at the plain integer letterbox fit) and no +-- Chrome.clear in drawPanel: Chrome.box paints an opaque box on its own. This +-- is the same shape every screen that overlays the map uses (BankOfMom, +-- ScriptMenu, ElevatorMenu, MailMenu, StartMenu). +-- +-- Textbox `lb bc, 4, 18` at (0,12) -- a 20x6 box. TEXTBOX_INNERY is +-- TEXTBOX_Y + 2 and LineChar targets INNERY + 2, so the two +-- lines are at rows 14 and 16, TWO apart. +-- YesNoBox `lb bc, SCREEN_WIDTH - 6, 7` -- a 6x5 box at (14,7). +-- YesNoMenuHeader sets STATICMENU_CURSOR *and* +-- STATICMENU_NO_TOP_SPACING, so GetMenuTextStartCoord puts +-- YES at (16,8) and NO at (16,10) with the cursor column at +-- 15. +-- LoadBlinkingCursor the ▼ at (18,17) while a page waits for a button. +-- +-- The party list is opened through Screens ("Gen2PartyMenu") with +-- PARTYMENUACTION_GIVE_MON, whose PartyMenuStrings row is ChooseAMonString -- +-- the plain "Choose a #MON." prompt, not one of the item-flavoured ones. +-- +-- Every string is transcribed from data/text/common_1.asm's _DayCare* / +-- _Breed* block, and paired with its pokegold label in LABELS below. No +-- script bytecode points at any of them -- PrintDayCareText is asm -- so the +-- extractor seeds its text walker at the block by name (RomExtractorGen2's +-- NAMED_TEXT) and the screen prefers the cache's own characters, falling back +-- to the transcription for a cache built before that seed. +-- +-- The MODEL is src/core/gen2/Breeding.lua -- every refusal, price, deposit and +-- egg roll below is one call into it, so the conversation can be wrong about +-- its layout without ever being wrong about the rules. + +local Breeding = require("src.core.gen2.Breeding") +local Chrome = require("src.ui.gen2.Chrome") +local CommonText = require("src.core.gen2.CommonText") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") + +local DayCareMenu = {} +DayCareMenu.__index = DayCareMenu +DayCareMenu.isOpaque = false + +-- charmap.asm's currency glyph and the text-advance arrow (font code $ee), +-- spelled the same way MartMenu spells them so both go through Font.split's +-- charmap match rather than through four ASCII tiles. +local YEN = "\xc2\xa5" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- ---------------------------------------------------------------- layout +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local ARROW_X, ARROW_Y = 18, 17 + +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +-- ------------------------------------------------------------------ sfx +-- +-- Named the pokegold way; Sound.GEN2_ALIASES is what maps the shared UI's own +-- labels onto these. SFX_TRANSACTION rings in RetrieveMonFromDayCareMan +-- BEFORE the money is taken (it is the mon coming back, not the payment), and +-- SFX_GET_EGG in DayCareManOutside right after the egg lands in the party. +local SFX_TRANSACTION = "Sfx_Transaction" +local SFX_GET_EGG = "Sfx_GetEgg" + +-- `ld c, 120 / call DelayFrames` between _ReceivedEggText and +-- _TakeGoodCareOfEggText: two full seconds of the jingle before he speaks +-- again. +local GET_EGG_FRAMES = 120 + +-- ----------------------------------------------------------------- text +-- +-- A "page" is one screenful: up to two lines, TEXT_LINE rows apart. A `para` +-- in the ASM starts a new page; a `cont` scrolls one line, which shows as a +-- page whose first line is the previous page's second. +local function page(...) return { ... } end +local function pages(...) return { ... } end + +local TEXT = { + -- _DayCareManIntroText / _DayCareManIntroEggText. The "_EGG" one is the + -- LONGER first-meeting script -- DayCareIntroText's `inc a` picks it the one + -- time DAYCARE_INTRO_SEEN_F is clear, so it explains what eggs are rather + -- than announcing that you have one. + manIntro = pages( + page("I'm the DAY-CARE", "MAN. Want me to"), + page("MAN. Want me to", "raise a #MON?")), + manIntroEgg = pages( + page("I'm the DAY-CARE", "MAN. Do you know"), + page("MAN. Do you know", "about EGGS?"), + page("I was raising", "#MON with my"), + page("#MON with my", "wife, you see."), + page("We were shocked to", "find an EGG!"), + page("How incredible is", "that?"), + page("So, want me to", "raise a #MON?")), + ladyIntro = pages( + page("I'm the DAY-CARE", "LADY."), + page("Should I raise a", "#MON for you?")), + ladyIntroEgg = pages( + page("I'm the DAY-CARE", "LADY. Do you know"), + page("LADY. Do you know", "about EGGS?"), + page("My husband and I", "were raising some"), + page("were raising some", "#MON, you see."), + page("We were shocked to", "find an EGG!"), + page("How incredible", "could that be?"), + page("Should I raise a", "#MON for you?")), + + -- DAYCARETEXT_WHICH_ONE, a `prompt`: it waits for a button and only then + -- does SelectTradeOrDayCareMon open the party list over it. + whichOne = pages(page("What should I", "raise for you?")), + + -- DayCareAskDepositPokemon's refusals, keyed by the Breeding.REFUSE_* the + -- model hands back. + lastMon = pages(page("Oh? But you have", "just one #MON.")), + cantAcceptEgg = pages(page("Sorry, but I can't", "accept an EGG.")), + removeMail = pages(page("Remove MAIL before", "you come see me.")), + lastAliveMon = pages( + page("If you give me", "that, what will"), + page("that, what will", "you battle with?")), + partyFull = pages(page("You have no room", "for it.")), + notEnoughMoney = pages(page("You don't have", "enough money.")), + ohFine = pages(page("Oh, fine then.")), + comeAgain = pages(page("Come again.")), + comeBackLater = pages(page("Come back for it", "later.")), + + -- _IllRaiseYourMonText: the nickname is a text_ram field, so it is spliced + -- rather than being part of the string. + deposit = function(name) + return pages(page("OK. I'll raise", ("your %s."):format(name))) + end, + geniuses = function(name) + return pages( + page("Are we geniuses or", "what? Want to see"), + page("what? Want to see", ("your %s?"):format(name))) + end, + -- _YourMonHasGrownText: `text_decimal wStringBuffer2 + 1, 1, 3` is the + -- levels grown as a 3-wide left-aligned field, and + -- `text_decimal wStringBuffer2 + 2, 3, 4` the price as a 4-wide one -- both + -- LEFTALIGN, so neither is space padded. + hasGrown = function(name, grown, price) + return pages( + page(("Your %s"):format(name), "has grown a lot."), + page("By level, it's", ("grown by %d."):format(grown)), + page("If you want your", "#MON back, it"), + page("#MON back, it", ("will cost %s%d."):format(YEN, price))) + end, + -- _BackAlreadyText, the "grew nothing" branch. Its price is a LITERAL + -- ¥100 in the string, not a decimal field, which is the same number + -- GetPriceToRetrieveBreedmon computes for zero levels grown. + backAlready = function(name) + return pages( + page("Huh? Back already?", ("Your %s"):format(name)), + page("needs a little", "more time with us."), + page("If you want your", "#MON back, it"), + page("#MON back, it", ("will cost %s100."):format(YEN))) + end, + withdraw = pages(page("Perfect! Here's", "your #MON.")), + gotBack = function(player, name) + return pages(page(("%s got back"):format(player), ("%s."):format(name))) + end, + + -- DayCareManOutside. + notYet = pages(page("Not yet…")), + foundAnEgg = pages( + page("Ah, it's you!"), + page("We were raising", "your #MON, and"), + page("my goodness, were", "we surprised!"), + page("Your #MON had", "an EGG!"), + page("We don't know how", "it got there, but"), + page("your #MON had", "it. You want it?")), + receivedEgg = function(player) + return pages(page(("%s received"):format(player), "the EGG!")) + end, + takeGoodCare = pages(page("Take good care of", "it.")), + illKeepIt = pages(page("Well then, I'll", "keep it. Thanks!")), + noRoomForEgg = pages( + page("You have no room", "in your party."), + page("in your party.", "Come back later.")), +} + +DayCareMenu.TEXT = TEXT + +-- ...and the data/text/common_1.asm label each of those entries transcribes. +-- The extractor seeds its text walker at this whole block now +-- (RomExtractorGen2's NAMED_TEXT), so the cache carries the cart's own +-- characters and `self.TEXT` below prefers them; the transcription above is +-- what a cache built before that seed falls back to. +local LABELS = { + manIntro = "_DayCareManIntroText", + manIntroEgg = "_DayCareManIntroEggText", + ladyIntro = "_DayCareLadyIntroText", + ladyIntroEgg = "_DayCareLadyIntroEggText", + whichOne = "_WhatShouldIRaiseText", + lastMon = "_OnlyOneMonText", + cantAcceptEgg = "_CantAcceptEggText", + removeMail = "_RemoveMailText", + lastAliveMon = "_LastHealthyMonText", + partyFull = "_HaveNoRoomText", + notEnoughMoney = "_NotEnoughMoneyText", + ohFine = "_OhFineThenText", + comeAgain = "_ComeAgainText", + comeBackLater = "_ComeBackLaterText", + deposit = "_IllRaiseYourMonText", + geniuses = "_AreWeGeniusesText", + hasGrown = "_YourMonHasGrownText", + backAlready = "_BackAlreadyText", + withdraw = "_PerfectHeresYourMonText", + gotBack = "_GotBackMonText", + notYet = "_NotYetText", + foundAnEgg = "_FoundAnEggText", + receivedEgg = "_ReceivedEggText", + takeGoodCare = "_TakeGoodCareOfEggText", + illKeepIt = "_IllKeepItThanksText", + noRoomForEgg = "_NoRoomForEggText", +} + +DayCareMenu.LABELS = LABELS + +-- The six entries that take arguments hand them to CommonText.fill in the +-- order their string names its markers -- the text_ram nickname, then the +-- levels grown, then the price -- so each formatted entry keeps the signature +-- its transcription above already has. +local FILL = { + deposit = function(name) return { name } end, + geniuses = function(name) return { name } end, + hasGrown = function(name, grown, price) return { name, grown, price } end, + backAlready = function(name) return { name } end, + gotBack = function(player, name) return { name, player = player } end, + receivedEgg = function(player) return { player = player } end, +} + +-- TEXT with every entry the cache carries replaced by the extracted string. +-- Anything the cache is missing falls through the metatable to the +-- transcription, so a partial cache is a mix rather than a hole. +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then + local fill = FILL[key] + if fill then + out[key] = function(...) return CommonText.fill(list, fill(...)) end + else + out[key] = list + end + end + end + return out +end + +function DayCareMenu:wantsFillScale() return true end + +-- The nickname the text_ram fields splice in: a nicknamed mon by its +-- nickname, anything else by its species name. +local function monName(mon) + if not mon then return "#MON" end + return mon.nickname or mon.name or mon.species or "#MON" +end + +-- opts: save, side ("man" | "lady" | "outside"), text (text.lua, for the +-- extracted strings), onClose(scriptVar) +function DayCareMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, DayCareMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.data = (game and game.data) or {} + -- text.lua rides on the world, the way the Pokegear's phone strings do: + -- this screen is pushed over the overworld and never without one. + self.textData = opts.text or (game and game.world and game.world.text) + self.TEXT = extractedText(self.textData) + self.side = opts.side or "man" + self.onClose = opts.onClose + self.rng = opts.rng + -- wScriptVar: DayCareManOutside is the only branch that writes one, and TRUE + -- means "no room, ask again". + self.scriptVar = 0 + self.delay = 0 + + if self.side == "outside" then + self:startOutside() + elseif (Breeding.side(self.save, self.side) or {}).mon then + self:startWithdraw() + else + self:startIntro() + end + return self +end + +-- --------------------------------------------------------------- overlays +-- +-- `say` is PrintText plus the button wait every one of these strings ends on +-- (`prompt` and `done` both park until A or B here, because the script that +-- calls the special does `waitbutton` straight after). +function DayCareMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function DayCareMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function DayCareMenu:close() + if self.onClose then self.onClose(self.scriptVar) end +end + +-- `.print_text` then `.cancel`: the refusal, and then COME_AGAIN. +function DayCareMenu:refuse(key) + local list = self.TEXT[key] or self.TEXT.ohFine + self:say(list, function() self:comeAgain() end) +end + +function DayCareMenu:comeAgain() + self:say(self.TEXT.comeAgain, function() self:close() end) +end + +function DayCareMenu: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 + +-- PlayMonCry, which is silent for a species with no extracted cry rather than +-- raising. +function DayCareMenu:playCry(species) + if not species then return end + local cries = self.data.audio and self.data.audio.cries + if cries and cries[species] then Sound.playCry(self.data, species) end +end + +-- ----------------------------------------------------------------- intro +-- +-- DayCareIntroText: test DAYCARE_INTRO_SEEN_F, set it, and `inc a` to the +-- longer script the one time it was clear. Declining goes straight to +-- `.cancel` -- COME_AGAIN with no "Oh, fine then." in front of it, which is +-- the difference between saying no here and cancelling the party list. +function DayCareMenu:startIntro() + local first = Breeding.takeIntro(self.save, self.side) + local lady = self.side == "lady" + local list + if lady then + list = first and self.TEXT.ladyIntroEgg or self.TEXT.ladyIntro + else + list = first and self.TEXT.manIntroEgg or self.TEXT.manIntro + end + self:ask(list, function() self:askDeposit() end, + function() self:comeAgain() end) +end + +function DayCareMenu:askDeposit() + local ok, reason = Breeding.canOpenDeposit(self.save) + if not ok then return self:refuse(reason) end + self:say(self.TEXT.whichOne, function() self:openParty() end) +end + +function DayCareMenu:openParty() + local game = self.game + -- No stack means no party list to open; back out the way cancelling it does + -- rather than pretending a mon was handed over. + if not (game and game.stack) then return self:comeAgain() end + self.picking = true + Screens.push(game, "Gen2PartyMenu", { + party = self.save.party, + -- PARTYMENUACTION_GIVE_MON's PartyMenuStrings row is ChooseAMonString. + prompt = "choose", + onChoose = function(index) + game.stack:pop() + self.picking = false + self:chose(index) + end, + onCancel = function() + game.stack:pop() + self.picking = false + -- .Declined + self:refuse("ohFine") + end, + }) +end + +function DayCareMenu:chose(index) + local ok, reason = Breeding.canDeposit(self.data, self.save, self.side, index) + if not ok then return self:refuse(reason) end + local _, mon = Breeding.deposit(self.data, self.save, self.side, index, + { rng = self.rng }) + -- DayCare_DepositPokemonText: the line, the cry, and then COME_BACK_LATER. + -- The deposit path `ret`s rather than falling into `.cancel`, so there is + -- deliberately no COME_AGAIN after it. + self:say(self.TEXT.deposit(monName(mon)), function() + self:playCry(mon and mon.species) + self:say(self.TEXT.comeBackLater, function() self:close() end) + end) +end + +-- -------------------------------------------------------------- withdraw +-- +-- DayCare_AskWithdrawBreedMon: no growth is ONE yes/no over _BackAlreadyText; +-- any growth is TWO, "Are we geniuses" and then the price. +function DayCareMenu:startWithdraw() + local slot = Breeding.side(self.save, self.side) + local _, _, grown = Breeding.levelGrowth(self.data, slot) + local price = Breeding.retrievePrice(grown) + local name = monName(slot.mon) + self.grown, self.price = grown, price + local decline = function() self:refuse("ohFine") end + if grown == 0 then + self:ask(self.TEXT.backAlready(name), function() self:takeMon() end, decline) + return + end + self:ask(self.TEXT.geniuses(name), function() + self:ask(self.TEXT.hasGrown(name, grown, price), + function() self:takeMon() end, decline) + end, decline) +end + +function DayCareMenu:takeMon() + local ok, reason = Breeding.canWithdraw(self.data, self.save, self.side) + if not ok then return self:refuse(reason) end + -- RetrieveMonFromDayCareMan rings the till and waits for it BEFORE + -- GetBreedMon1LevelGrowth; the money changes hands afterwards, in + -- DayCare_GetBackMonForMoney. + self:playSfx(SFX_TRANSACTION) + local _, mon = Breeding.withdraw(self.data, self.save, self.side) + local player = (self.save.player and self.save.player.name) or "<PLAYER>" + self:say(self.TEXT.withdraw, function() + self:playCry(mon and mon.species) + self:say(self.TEXT.gotBack(player, monName(mon)), function() + self:comeAgain() + end) + end) +end + +-- --------------------------------------------------------------- outside +-- +-- DayCareManOutside. The party-space check happens AFTER the yes, which is +-- what makes "You have no room in your party" a thing you can be told while +-- already holding out your hands for the egg. +function DayCareMenu:startOutside() + local dc = Breeding.dayCare(self.save) + if not (dc and dc.hasEgg) then + return self:say(self.TEXT.notYet, function() self:close() end) + end + self:ask(self.TEXT.foundAnEgg, function() self:takeEgg() end, function() + -- .Declined -> .Load0: wScriptVar stays FALSE and he keeps the egg. + self:say(self.TEXT.illKeepIt, function() self:close() end) + end) +end + +function DayCareMenu:takeEgg() + local ok, reason = Breeding.collectEgg(self.data, self.save, + { rng = self.rng }) + if not ok then + -- .PartyFull is the ONE branch that sets wScriptVar to TRUE. + if reason == Breeding.REFUSE_PARTY_FULL then self.scriptVar = 1 end + return self:say(self.TEXT.noRoomForEgg, function() self:close() end) + end + local player = (self.save.player and self.save.player.name) or "<PLAYER>" + self:say(self.TEXT.receivedEgg(player), function() + self:playSfx(SFX_GET_EGG) + self.delay = GET_EGG_FRAMES + self:say(self.TEXT.takeGoodCare, function() self:close() end) + end) +end + +-- ---------------------------------------------------------------- update + +function DayCareMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function DayCareMenu:updateConfirm(input) + local confirm = self.confirm + -- The yes/no box only comes up on the string's LAST page. + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + -- YesNoMenuHeader has no STATICMENU_DISABLE_B, so B is NO. + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function DayCareMenu:update(_dt) + -- The party list is on top of the stack; it owns input until it pops. + if self.picking then return end + if self.delay > 0 then + self.delay = self.delay - 1 + return + end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end +end + +-- ------------------------------------------------------------------ draw + +function DayCareMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function DayCareMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function DayCareMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + elseif self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + else + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + else + self:drawTextBox(nil) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function DayCareMenu:draw() + self:drawPanel() +end + +return DayCareMenu diff --git a/src/ui/gen2/DecorationMenu.lua b/src/ui/gen2/DecorationMenu.lua new file mode 100644 index 00000000..fedc2db3 --- /dev/null +++ b/src/ui/gen2/DecorationMenu.lua @@ -0,0 +1,279 @@ +-- The bedroom PC's DECORATION option: _PlayerDecorationMenu +-- (engine/overworld/decorations.asm), reached from PLAYERSPCITEM_DECORATION in +-- the PLAYERSPC_HOUSE list (engine/events/pokecenter_pc.asm). +-- +-- Two menus stacked, plus one question: +-- +-- .MenuHeader the categories the player owns something in, at +-- menu_coords 5, 0, 19, 17, with EXIT always last +-- .NonscrollingMenuHeader / .ScrollingMenuHeader +-- that category's decorations, then its PUT IT AWAY +-- row, then CANCEL. The cart swaps to the scrolling +-- menu past eight rows; one list that scrolls covers +-- both, since the rows are identical either way +-- DecoSideMenuHeader RIGHT SIDE / LEFT SIDE / CANCEL, for the two +-- ornament slots +-- +-- Nothing here touches the map. wChangedDecorations is carried back through +-- onDone so the PC can answer TRUE to `special PlayersHousePC`, whose script +-- then does `warp NONE, 0, 0` -- the map reload that runs the two callbacks +-- and is the only thing that makes a placement visible. + +local Chrome = require("src.ui.gen2.Chrome") +local Decorations = require("src.core.gen2.Decorations") +local Strings = require("src.core.Strings") + +local DecorationMenu = {} +DecorationMenu.__index = DecorationMenu +DecorationMenu.isOpaque = true + +-- .category_pointers' eighth row, which is not a category and is always shown. +local EXIT = Strings.source("EXIT") +-- DecoSideMenuHeader's three items, in its order. +local SIDES = { + { id = "right", label = Strings.source("RIGHT SIDE") }, + { id = "left", label = Strings.source("LEFT SIDE") }, + { id = nil, label = Strings.source("CANCEL") }, +} + +-- The scrolling list shows eight rows (.ScrollingMenuData's `db 8, 0`). +local VISIBLE = 8 + +function DecorationMenu:wantsFillScale() return true end + +-- opts: save, events (the wEventFlags bitfield), onDone(changed) +function DecorationMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, DecorationMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.events = opts.events + self.onDone = opts.onDone + self.state = Decorations.state(self.save) + -- wChangedDecorations, cleared once on the way in. + self.changed = false + self.mode = "category" + self.index = 1 + self.scroll = 0 + self.pages = nil + self:buildCategories() + return self +end + +function DecorationMenu:buildCategories() + self.categories = Decorations.ownedCategories(self.events) + self.index = math.min(self.index, #self.categories + 1) +end + +function DecorationMenu:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone(self.changed) end +end + +-- The name of one row of the item list. Row 0 is CANCEL and the category's +-- own row is PUT IT AWAY; both are plain DecorationAttributes rows, so they +-- spell themselves. +function DecorationMenu:rowName(decoId) + return Decorations.name(decoId) +end + +function DecorationMenu:openCategory(category) + local rows = Decorations.rows(self.events, category) + if #rows == 0 then + -- PopulateDecoCategoryMenu .empty. Cannot happen from the filtered + -- category list, but the routine is reachable from a driver that sets a + -- flag by hand, and the cart answers rather than opening an empty menu. + self:say({ Strings(Decorations.NOTHING_TO_CHOOSE) }) + return + end + self.category = category + self.rows = rows + self.mode = "items" + self.index = 1 + self.scroll = 0 +end + +function DecorationMenu:say(pages) + if not pages or #pages == 0 then return end + self.pages = pages + self.pageIndex = 1 +end + +-- MenuTextboxBackup returns to whatever menu was underneath, so a message is +-- not a mode of its own: it sits over the list it was printed from. +function DecorationMenu:advanceMessage() + self.pageIndex = self.pageIndex + 1 + if self.pageIndex <= #self.pages then return end + self.pages = nil +end + +-- One row of the item list chosen: DoDecorationAction2. An ornament row asks +-- which side first, and only then applies. +function DecorationMenu:chooseRow(decoId) + local attr = Decorations.attributes(decoId) + local action = attr and attr.action and Decorations.ACTIONS[attr.action] + if not action then + -- DecoAction_nothing sets carry, which drops the player back to the + -- category list without a word. + self.mode = "category" + self:buildCategories() + return + end + if action.ornament then + self.mode = "side" + self.pendingDeco = decoId + self.sideIndex = 1 + return + end + self:applyRow(decoId, nil) +end + +function DecorationMenu:applyRow(decoId, side) + local changed, pages = Decorations.apply(self.state, decoId, side) + if changed then + self.changed = true + local attr = Decorations.attributes(decoId) + local action = attr and attr.action and Decorations.ACTIONS[attr.action] + if action and action.ornament and not action.put then + Decorations.clearOtherSide(self.state, decoId, side) + end + end + self:say(pages) +end + +function DecorationMenu:update(_dt) + local input = self.game and self.game.input + if not (input and not self.done) then return end + + if self.pages then + if input:wasPressed("a") or input:wasPressed("b") then + self:advanceMessage() + end + return + end + + if self.mode == "side" then + if input:wasPressed("up") then + self.sideIndex = self.sideIndex > 1 and self.sideIndex - 1 or #SIDES + elseif input:wasPressed("down") then + self.sideIndex = self.sideIndex < #SIDES and self.sideIndex + 1 or 1 + elseif input:wasPressed("a") then + local pick = SIDES[self.sideIndex] + self.mode = "items" + -- .nope: CANCEL and B are the same arm, and neither prints anything. + self:applyRow(self.pendingDeco, pick and pick.id) + self.pendingDeco = nil + elseif input:wasPressed("b") then + self.mode = "items" + self.pendingDeco = nil + end + return + end + + if self.mode == "items" then + local total = #self.rows + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:chooseRow(self.rows[self.index]) + elseif input:wasPressed("b") then + self.mode = "category" + self:buildCategories() + end + self.scroll = math.max(0, + math.min(self.scroll, math.max(0, total - VISIBLE))) + if self.index - 1 < self.scroll then self.scroll = self.index - 1 end + if self.index > self.scroll + VISIBLE then + self.scroll = self.index - VISIBLE + end + return + end + + local total = #self.categories + 1 + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + local category = self.categories[self.index] + if category then + self:openCategory(category) + else + self:finish() -- DecoExitMenu: `scf`, which leaves the whole menu + end + elseif input:wasPressed("b") then + self:finish() + end +end + +function DecorationMenu:drawList(x, y, w, h, labels, index, scroll) + Chrome.box(x, y, w, h) + -- GetMenuTextStartCoord: the first label sits one row inside the border and + -- one more down for STATICMENU_CURSOR, and rows are two apart -- the same + -- spacing every other Gold list menu in this port draws with. + for row = 1, math.min(#labels - scroll, VISIBLE) do + local i = row + scroll + local ty = y + row * 2 + if i == index then Chrome.cursor(x + 1, ty) end + Chrome.print(labels[i], x + 2, ty) + end +end + +function DecorationMenu:drawPanel() + Chrome.clear() + + if self.mode == "category" then + local labels = {} + for i, category in ipairs(self.categories) do labels[i] = category.label end + labels[#labels + 1] = Strings(EXIT) + -- menu_coords 5, 0, 19, 17: the list hugs the right edge and the room + -- stays visible down the left, which is the whole point of the offset. + self:drawList(5, 0, 15, 18, labels, self.index, 0) + elseif self.mode == "items" then + local labels = {} + for i, decoId in ipairs(self.rows) do labels[i] = self:rowName(decoId) end + self:drawList(0, 0, 20, 18, labels, self.index, self.scroll) + end + + if self.mode == "side" then + local labels = {} + for i, side in ipairs(SIDES) do labels[i] = Strings(side.label) end + -- menu_coords 0, 0, 12, 7 + self:drawList(0, 0, 13, 8, labels, self.sideIndex, 0) + end + + if self.pages then + Chrome.box(0, 12, 20, 6) + local line = 14 + for part in ((self.pages[self.pageIndex] or "") .. "\n"):gmatch("(.-)\n") do + Chrome.print(part, 1, line) + line = line + 2 + end + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function DecorationMenu:draw() + self:drawPanel() +end + +function DecorationMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +function DecorationMenu:drawsWidescreen() return true end + +return DecorationMenu diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua new file mode 100644 index 00000000..3bc80928 --- /dev/null +++ b/src/ui/gen2/Diploma.lua @@ -0,0 +1,195 @@ +-- The #DEX-completion diploma (engine/events/diploma.asm PlaceDiplomaOnScreen, +-- 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. +-- +-- THE CERTIFICATE IS A TILEMAP, NOT A TEXT BOX. PlaceDiplomaOnScreen +-- decompresses DiplomaGFX into vTiles2 and then CopyBytes' DiplomaPage1Tilemap +-- (a whole SCREEN_AREA of tile ids) straight over the background before a +-- single string is placed, so the border, the seal and the ribbon are cart +-- art. Both come out of the cache as `data.gen2Diploma` +-- (RomExtractorGen2:extractDiploma); a cache that predates that stage falls +-- back to the plain Chrome.box frame below, which is a placeholder and not +-- the real seal. +-- +-- Positions below are the literal hlcoord operands PlaceDiplomaOnScreen +-- calls PlaceString with, not a layout guessed from a screenshot: +-- +-- hlcoord 2, 5 "PLAYER" (.Player, "PLAYER@") +-- hlcoord 15, 5 .EmptyString ("@") -- a bare terminator, nothing to draw +-- hlcoord 9, 5 wPlayerName, dropped in over the row .Player/.EmptyString +-- bracket +-- hlcoord 2, 8 .Certification, five `next`-joined lines that PlaceString +-- walks one row down at column 2 apiece: rows 8-12 +-- +-- COLOUR. _CGB_Diploma (engine/gfx/cgb_layouts.asm) loads all eight +-- DiplomaPalettes sets and then WipeAttrmap zeroes the attrmap, so every tile +-- on the screen -- art and text alike -- draws through set 0. The strings go +-- down with Chrome.printThrough rather than Chrome.print for that reason: a +-- black print over the art would be the one thing on screen not going through +-- the palette. +-- +-- WaitPressAorB_BlinkCursor just parks on A or B; there is no menu here. + +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Strings = require("src.core.Strings") + +local Diploma = {} +Diploma.__index = Diploma +Diploma.isOpaque = true + +function Diploma:wantsFillScale() return true end + +local CERTIFICATION = { + Strings.source("This certifies"), + Strings.source("that you have"), + Strings.source("completed the"), + Strings.source("new #DEX."), + Strings.source("Congratulations!"), +} + +-- opts: playerName, gfx, onClose() +function Diploma.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Diploma) + self.game = game + local save = game and game.save + self.playerName = opts.playerName + or (save and save.player and save.player.name) or "?" + self.onClose = opts.onClose + self.gfx = opts.gfx or ((game and game.data) or {}).gen2Diploma + self.images = {} + self.done = false + return self +end + +function Diploma:finish() + if self.done then return end + self.done = true + if self.onClose then self.onClose() end +end + +function Diploma: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 + +-- Set 0 of DiplomaPalettes, the one WipeAttrmap leaves the whole screen on. +function Diploma:palette() + local palettes = self.gfx and self.gfx.palettes + return palettes and palettes[1] or nil +end + +function Diploma:image(path) + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local Assets = require("src.render.Assets") + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + if cached then cached:setFilter("nearest", "nearest") end + self.images[path] = cached + end + return cached or nil +end + +-- The page as one batch of 8x8 tiles. Built once, because the tilemap is +-- copied once and nothing on this screen ever moves; the palette is applied at +-- draw time instead of baked in, so a COLOR mode change needs no rebuild. +function Diploma:batch() + if self.tilemap ~= nil then return self.tilemap or nil end + local gfx = self.gfx + local image = gfx and gfx.page1 and self:image(gfx.image) + if not image then + self.tilemap = false + return nil + end + local across = gfx.sheetTiles or 16 + local width = gfx.width or Chrome.SCREEN_W + local height = gfx.height or Chrome.SCREEN_H + local batch = love.graphics.newSpriteBatch(image, width * height) + local quads = {} + for index = 0, width * height - 1 do + local tile = gfx.page1[index + 1] or 0 + local quad = quads[tile] + if not quad then + quad = love.graphics.newQuad(tile % across * 8, + math.floor(tile / across) * 8, 8, 8, image:getDimensions()) + quads[tile] = quad + end + batch:add(quad, index % width * 8, math.floor(index / width) * 8) + end + self.tilemap = batch + return batch +end + +function Diploma:drawPanel() + local palette = self:palette() + local batch = self:batch() + + if batch then + -- ClearTilemap leaves the screen on colour 0 of the loaded set, and the + -- page covers all of it, but the fill is what a letterboxed fill-scale + -- draw shows outside the 160x144 page. + local paper = GbcPalette.color(palette, 1) + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", 0, 0, + Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + love.graphics.setColor(1, 1, 1, 1) + local function blit() love.graphics.draw(batch, 0, 0) end + -- A palette-less cache would send the shader four black entries, so the + -- sheet's own grey shades are the fallback rather than a black page. + if palette then GbcPalette.with(palette, blit) else blit() end + love.graphics.setColor(1, 1, 1, 1) + else + -- No gfx/diploma in the cache: a placeholder frame, not the real seal. + Chrome.clear() + Chrome.box(0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + end + + Chrome.printThrough(Strings("PLAYER"), 2, 5, palette) + Chrome.printThrough(self.playerName, 9, 5, palette) + + for i, line in ipairs(CERTIFICATION) do + Chrome.printThrough(Strings(line), 2, 7 + i, palette) + end +end + +function Diploma:draw() + self:drawPanel() +end + +-- PlaceDiplomaOnScreen opens on ClearBGPalettes / ClearTilemap +-- (engine/events/diploma.asm:13-14), so the map is gone and the diploma's own +-- paper is the whole screen. Colour 1 of the page palette is that paper (a +-- pale green), which is why the surround cannot be the generic white the +-- fallback paints: the page would sit in a white field instead of running to +-- the window edge. +function Diploma:drawsWidescreen() return true end + +function Diploma:drawWidescreen(winW, winH) + local G = love.graphics + -- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the + -- Chrome.clear() the panel falls back to when the cache has no gfx/diploma. + local paper = GbcPalette.color(self:palette(), 1) + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + G.setColor(1, 1, 1, 1) + 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 Diploma diff --git a/src/ui/gen2/EggHatchAnim.lua b/src/ui/gen2/EggHatchAnim.lua new file mode 100644 index 00000000..6b62db6d --- /dev/null +++ b/src/ui/gen2/EggHatchAnim.lua @@ -0,0 +1,456 @@ +-- The egg hatch cutscene: engine/pokemon/breeding.asm +-- EggHatch_AnimationSequence, the beat HatchEggs runs between "Huh?" and +-- "<NAME> came out of its EGG!". +-- +-- The sequence, in the cart's own order: +-- +-- 1. `ld de, MUSIC_NONE / call PlayMusic`, then BlankScreen and DisableLCD. +-- isOpaque is therefore correct: the map really is torn down. +-- 2. EggHatchGFX's two tiles to vTiles0, the hatchling's frontpic to +-- vTiles2 $00 and the EGG's to $31. +-- 3. MUSIC_EVOLUTION, EnableLCD, and the EGG pic laid at hlcoord 7, 4. +-- 4. `ld c, 80 / call DelayFrames`: the egg just sits there. +-- 5. Eight rounds. Round r wobbles r times -- each wobble is hSCX = +2 for +-- two frames then -2 for two frames, with wGlobalAnimXOffset carrying the +-- sprites the same way so the cracks stay on the shell -- then sixteen +-- still frames, then EggHatch_CrackShell. +-- 6. SFX_EGG_HATCH, ten shell fragments, the HATCHLING's pic at hlcoord 6, 3, +-- then Hatch_ShellFragmentLoop's 129 frames, WaitSFX and PlayMonCry. +-- +-- The two sprite objects are built here rather than in +-- src/ui/gen2/SpriteAnims.lua because both are one 8x8 tile with a fixed +-- frameset (data/sprite_anims/framesets.asm .Frameset_EggCrack and +-- .Frameset_EggHatch1..4 are each a single `oamframe ..., 32 / oamend`) and +-- only ONE of the two moves at all. Coordinates follow the same convention +-- every other caller of _InitSpriteAnimStruct uses (engine/sprite_anims/ +-- core.asm:113, "at pixel x=e, y=d"), and OAM's own -8 / -16 bias is applied +-- at draw time exactly as src/ui/gen2/GoldSilverIntro.lua does it. +-- +-- Every asset is optional. A cache built before the extractor grew +-- menu_gfx.eggHatch has no egg pic and no shell tiles, and the screen then +-- runs the same clock with whatever it does have -- which is the timing beat +-- src/world/gen2/World.lua's hatch path used to stand in for on its own. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local EggHatchAnim = {} +EggHatchAnim.__index = EggHatchAnim +EggHatchAnim.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords: +-- the egg sits at (7,4) and the hatchling at (6,3). Both are `lb bc, 7, 7` +-- PlaceGraphic boxes, and the pic inside that box has already been padded to +-- 7x7 in VRAM, so a 40x40 egg and a 56x56 mon share a ground line. +local EGG_TILE_X, EGG_TILE_Y = 7, 4 +local MON_TILE_X, MON_TILE_Y = 6, 3 + +-- PadFrontpic (engine/gfx/load_pics.asm:342) fills the 7x7 box column by +-- column: one whole blank column first, then per pic column a fixed run of +-- blank tiles above the pic tiles. `.six` fills one tile per column and +-- `.five` two, so the pic's top-left tile lands at (1,1) and (1,2) +-- respectively and a 7-wide pic fills the box. Same table, for the same +-- reason, as src/ui/gen2/SummaryMenu.lua:120. +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +-- `ld c, 80 / call DelayFrames` between the egg appearing and the first wobble. +local HOLD_FRAMES = 80 +-- `.outerloop`'s `cp 8`: rounds 0..7, so eight of them, and round r (1-based) +-- wobbles r times because `ld e, [hl]` reads the counter AFTER `inc [hl]`. +local ROUNDS = 8 +-- Each half of a wobble is `ld c, 2 / call DelayFrames`, i.e. two frames. +local WOBBLE_HALF = 2 +-- `ld a, 2 / ldh [hSCX]` then `ld a, -2`: the screen shifts two pixels. +local SHAKE = 2 +-- `ld c, 16 / call DelayFrames` after the wobbles of a round. +local STILL_FRAMES = 16 +-- Hatch_ShellFragmentLoop's `ld c, 129`. +local FRAGMENT_FRAMES = 129 + +-- Hatch_InitShellFragments' .SpriteData, one row per `shell_fragment`. The +-- macro emits two coordinate bytes and Hatch_InitShellFragments loads the first +-- into e and the second into d, which _InitSpriteAnimStruct reads as x and y +-- respectively -- the same pairing `depixel` produces for every other object. +-- `frameset` is the offset from SPRITE_ANIM_FRAMESET_EGG_HATCH_1, and the four +-- framesets differ only in their OAM flip flags (data/sprite_anims/framesets +-- .asm:360-374): 1 plain, 2 X-flipped, 3 Y-flipped, 4 both. +local FRAGMENTS = { + { x = 10 * 8 + 4, y = 9 * 8, flipX = false, flipY = false, angle = 0x3c }, + { x = 11 * 8 + 4, y = 9 * 8, flipX = true, flipY = false, angle = 0x04 }, + { x = 10 * 8 + 4, y = 10 * 8, flipX = false, flipY = false, angle = 0x30 }, + { x = 11 * 8 + 4, y = 10 * 8, flipX = true, flipY = false, angle = 0x10 }, + { x = 10 * 8 + 4, y = 11 * 8, flipX = false, flipY = true, angle = 0x24 }, + { x = 11 * 8 + 4, y = 11 * 8, flipX = true, flipY = true, angle = 0x1c }, + { x = 10 * 8, y = 9 * 8 + 4, flipX = false, flipY = false, angle = 0x36 }, + { x = 12 * 8, y = 9 * 8 + 4, flipX = true, flipY = false, angle = 0x0a }, + { x = 10 * 8, y = 10 * 8 + 4, flipX = false, flipY = true, angle = 0x2a }, + { x = 12 * 8, y = 10 * 8 + 4, flipX = true, flipY = true, angle = 0x16 }, +} + +-- AnimSeq_RevealNewMon: var1 starts at 0 and grows by 8 a frame until it +-- reaches $80, at which point DeinitializeSprite drops the fragment. Sixteen +-- frames of flight, then the hatchling stands alone for the rest of the loop. +local FRAGMENT_STEP = 8 +local FRAGMENT_LIMIT = 0x80 + +-- Both egg objects use .OAMData_1x1_Palette0, whose single entry is +-- `dbsprite -1, -1, 4, 4, $00, 0` (data/sprite_anims/oam.asm:94-95, 112-114). +-- dbsprite folds tile and pixel into one byte, so that is a per-object -4 on +-- each axis, added by UpdateAnimFrame through AddOrSubtractX/Y on top of the +-- struct coordinate (engine/sprite_anims/core.asm:240-266). The flip arm +-- computes `-8 - a`, which for a = -4 is -4 again, so the one constant covers +-- all four framesets. The remaining -8 / -16 is the hardware's own OAM bias, +-- the same one every other Gold screen applies on its way out. +local OAM_X = -8 - 4 +local OAM_Y = -16 - 4 + +function EggHatchAnim:wantsFillScale() return true end +function EggHatchAnim:drawsWidescreen() return true end + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +-- opts: +-- mon the hatchling's party record (species, shiny, nickname) +-- species the hatchling's species, when there is no record to hand +-- onDone() the beat after PlayMonCry +function EggHatchAnim.new(game, opts) + opts = opts or {} + local self = setmetatable({}, EggHatchAnim) + self.game = game + self.data = (game and game.data) or {} + self.palettes = self.data.gen2Palettes + self.mon = opts.mon + self.species = opts.species or (self.mon and self.mon.species) + self.onDone = opts.onDone + + local gfx = (opts.menuGfx or self.data.gen2MenuGfx or {}).eggHatch or {} + self.eggPath = gfx.egg + self.shellPath = gfx.shell + self.picCache = {} + self.shell = nil + self.shellQuads = nil + + -- The screen starts on the egg and only swaps to the hatchling at `.done`, + -- which is the one thing a viewer has to be able to see happen. + self.showMon = false + self.shakeX = 0 + self.sprites = {} + + -- `ld de, MUSIC_NONE / call PlayMusic` and then MUSIC_EVOLUTION, with the + -- map theme handed back by HatchEggs' own RestartMapMusic afterwards. + Music.stop() + local songs = self.data.audio and self.data.audio.songs + if songs and songs.Music_Evolution then + Music.play(self.data, "Music_Evolution", true, { reason = "hatch" }) + end + + self.beats = self:buildBeats() + self.beatIndex = 1 + self.beatLeft = self.beats[1] and self.beats[1].frames or 0 + self:runBeat(self.beats[1]) + return self +end + +-- The whole sequence as a flat list of { frames, enter }. Building it up front +-- rather than nesting phase machines is what keeps the frame counts readable +-- against the ASM: every number here is one `DelayFrames` operand. +function EggHatchAnim:buildBeats() + local beats = {} + local function beat(frames, enter) + beats[#beats + 1] = { frames = frames, enter = enter } + end + + beat(HOLD_FRAMES, function() self.shakeX = 0 end) + + for round = 1, ROUNDS do + for _ = 1, round do + beat(WOBBLE_HALF, function() self.shakeX = SHAKE end) + beat(WOBBLE_HALF, function() self.shakeX = -SHAKE end) + end + beat(STILL_FRAMES, function() self.shakeX = 0 end) + beat(0, function() self:crackShell(round) end) + end + + -- `.done`: hSCX and wGlobalAnimXOffset are zeroed, ClearSprites drops the + -- cracks, the fragments go up and the pic becomes the hatchling's. + beat(FRAGMENT_FRAMES, function() + self.shakeX = 0 + self.sprites = {} + self:playSfx("Sfx_EggHatch") + self:initFragments() + self.showMon = true + end) + -- WaitSFX then `ld a, [wJumptableIndex] / call PlayMonCry`. The port has no + -- channel state to poll, so the cry simply follows the loop. + beat(0, function() self:playCry() end) + return beats +end + +-------------------------------------------------------------------------- +-- Sound +-------------------------------------------------------------------------- + +function EggHatchAnim: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 EggHatchAnim:playCry() + local species = self.species + if not species then return end + local cries = self.data.audio and self.data.audio.cries + if cries and cries[species] then Sound.playCry(self.data, species) end +end + +-------------------------------------------------------------------------- +-- The two sprite objects +-------------------------------------------------------------------------- + +-- EggHatch_CrackShell (breeding.asm:756). wFrameCounter has already been +-- incremented, so `dec a / and $7` is the round index 0..7; `cp $7 / ret z` +-- drops the last round and `srl a / ret nc` drops every even one, leaving +-- rounds 2, 4 and 6. The surviving index picks the y coordinate: +-- `swap a / srl a` is a multiply by eight, and `add 9 * TILE_WIDTH` puts the +-- first crack on tile row 9. x is the fixed `ld e, 11 * TILE_WIDTH`. +function EggHatchAnim:crackShell(round) + local a = (round - 1) % 8 + if a == 7 then return end + if a % 2 == 0 then return end + local step = math.floor(a / 2) + self.sprites[#self.sprites + 1] = { + kind = "crack", + x = 11 * 8, + y = step * 8 + 9 * 8, + } + self:playSfx("Sfx_EggCrack") +end + +-- Hatch_InitShellFragments, then the SFX_EGG_HATCH it ends on (which the +-- caller's `.done` has already played -- the cart plays it twice). +function EggHatchAnim:initFragments() + for _, row in ipairs(FRAGMENTS) do + self.sprites[#self.sprites + 1] = { + kind = "fragment", + x = row.x, y = row.y, + flipX = row.flipX, flipY = row.flipY, + -- SPRITEANIMSTRUCT_JUMPTABLE_INDEX carries the fragment's angle, which + -- AnimSeq_RevealNewMon flips by $20 every frame. + angle = row.angle, + var1 = 0, + xOffset = 0, yOffset = 0, + } + end +end + +-- AnimSeq_RevealNewMon (engine/sprite_anims/functions.asm:1270), one frame: +-- the amplitude grows by eight, the angle is XORed with $20, and the sine and +-- cosine of that pair become the fragment's y and x offsets. So each shard +-- alternates between two opposite headings while drifting further out, which +-- is what reads as tumbling. +local function stepFragment(sprite) + if sprite.var1 >= FRAGMENT_LIMIT then return false end + local amplitude = sprite.var1 + sprite.var1 = sprite.var1 + FRAGMENT_STEP + -- `xor $20` toggles bit 5, i.e. half a period of the six-bit angle. Written + -- as a bit test rather than an operator because the engine targets LuaJIT's + -- 5.1 semantics, where there is none. + local angle = sprite.angle % 256 + local bit5 = math.floor(angle / 0x20) % 2 + sprite.angle = bit5 == 1 and (angle - 0x20) or (angle + 0x20) + sprite.yOffset = SpriteAnims.sine(sprite.angle, amplitude) + sprite.xOffset = SpriteAnims.cosine(sprite.angle, amplitude) + return true +end + +-------------------------------------------------------------------------- +-- Update +-------------------------------------------------------------------------- + +function EggHatchAnim:runBeat(beat) + if beat and beat.enter then beat.enter() end +end + +function EggHatchAnim:finish() + self.done = true + local cb = self.onDone + self.onDone = nil + if cb then cb() end +end + +function EggHatchAnim:update(_dt) + if self.done then return end + + -- EggHatch_DoAnimFrame is PlaySpriteAnimations + DelayFrame, so the sprites + -- advance on every frame the sequence spends anywhere. A fragment that has + -- run out its amplitude leaves the screen: AnimSeq_RevealNewMon's + -- `.finish_EggShell` is a DeinitializeSprite (engine/sprite_anims/functions + -- .asm:1303), sixteen frames into a 129-frame loop, so the shards do not + -- hang in mid-air for the rest of it. Cracks are never dropped here; the + -- ClearSprites at `.done` has already emptied the list by then. + local live = {} + for _, sprite in ipairs(self.sprites) do + if sprite.kind ~= "fragment" or stepFragment(sprite) then + live[#live + 1] = sprite + end + end + self.sprites = live + + while self.beatLeft <= 0 do + self.beatIndex = self.beatIndex + 1 + local beat = self.beats[self.beatIndex] + if not beat then return self:finish() end + self.beatLeft = beat.frames + self:runBeat(beat) + end + self.beatLeft = self.beatLeft - 1 +end + +-------------------------------------------------------------------------- +-- Draw +-------------------------------------------------------------------------- + +function EggHatchAnim:image(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall stands alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +function EggHatchAnim:pic() + if self.showMon then + local def = self.species and self.data.pokemon + and self.data.pokemon[self.species] + return self:image(def and def.spriteFront) + end + return self:image(self.eggPath) +end + +-- Hatch_LoadFrontpicPal is SCGB_EVOLUTION with c = 0, i.e. the pic's own +-- palette, and the species it is handed is whatever +-- Hatch_UpdateFrontpicBGMapCenter was called with: EGG while the shell is up. +-- PokemonPalettes carries a real EGG row (data/pokemon/palettes.asm:530) and +-- _CGB_Evolution indexes straight into it through GetPlayerOrMonPalettePointer +-- (engine/gfx/color.asm:620), so the egg gets its own cream and brown. A +-- cache built before the extractor grew that row has no "EGG" key, and +-- monColors returning nil then leaves the shader off and the raw shades in +-- place, exactly as before. +function EggHatchAnim:picColors() + local species = self.showMon and self.species or "EGG" + return Palettes.monColors(self.palettes, species, + self.mon and self.mon.shiny) +end + +function EggHatchAnim:drawPic() + local image = self:pic() + if not image then return end + local G = love.graphics + local w = image:getWidth() + local tx = self.showMon and MON_TILE_X or EGG_TILE_X + local ty = self.showMon and MON_TILE_Y or EGG_TILE_Y + -- PadFrontpic's own placement, not a centring rule: the two agree at 7 and + -- 5 wide but a 6-wide pic sits a whole tile in, not half of one. + local pad = PIC_PAD[math.floor(w / 8)] or PIC_PAD[7] + local px = tx * 8 + pad[1] * 8 + local py = ty * 8 + pad[2] * 8 + G.setColor(1, 1, 1, 1) + local colors = self:picColors() + local function body() G.draw(image, px, py) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- EggHatchGFX is one tile wide and two tall: tile $00 is the crack +-- (SPRITE_ANIM_OAMSET_EGG_CRACK's `spriteanimoam $00`) and tile $01 the shell +-- fragment (`spriteanimoam $01`), both 1x1 on OBJ palette 0. +function EggHatchAnim:shellQuad(index) + local image = self:image(self.shellPath) + if not image then return nil, nil end + if not self.shellQuads then + self.shellQuads = { + love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()), + love.graphics.newQuad(0, 8, 8, 8, image:getDimensions()), + } + end + return image, self.shellQuads[index] +end + +function EggHatchAnim:drawSprites() + if #self.sprites == 0 then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + for _, sprite in ipairs(self.sprites) do + local slot = sprite.kind == "crack" and 1 or 2 + local image, quad = self:shellQuad(slot) + if image and quad then + -- OAM_X / OAM_Y carry both the hardware bias and .OAMData_1x1_Palette0's + -- own -4, and the draw call below adds the flip origin correction. + local x = sprite.x + (sprite.xOffset or 0) + OAM_X + local y = sprite.y + (sprite.yOffset or 0) + OAM_Y + -- The two offsets are the bytes the ASM leaves in a, i.e. two's + -- complement, so anything past $7f is a negative drift. + if (sprite.xOffset or 0) > 0x7f then x = x - 256 end + if (sprite.yOffset or 0) > 0x7f then y = y - 256 end + local flipX, flipY = sprite.flipX, sprite.flipY + G.draw(image, quad, + x + (flipX and 8 or 0), y + (flipY and 8 or 0), 0, + flipX and -1 or 1, flipY and -1 or 1) + end + end +end + +-- BlankScreen leaves the whole tilemap on the palette's colour 0, and nothing +-- prints during the sequence: HatchEggs' text boxes are on either side of it. +function EggHatchAnim:drawPanel() + Chrome.clear() + local G = love.graphics + G.push() + -- Both layers move the SAME way each wobble half (breeding.asm:707-719): + -- `ldh [hSCX]` of +2 scrolls the viewport right, i.e. slides the background + -- two pixels left on screen, and the paired `ld [wGlobalAnimXOffset], -2` is + -- summed into every object's OAM x byte (engine/sprite_anims/core.asm:253 + -- -266), sliding the sprites two pixels left as well. So the cracks stay + -- put on the shell and the whole picture shakes as one. + G.translate(-(self.shakeX or 0), 0) + self:drawPic() + self:drawSprites() + G.pop() + G.setColor(1, 1, 1, 1) +end + +function EggHatchAnim:draw() + self:drawPanel() +end + +function EggHatchAnim: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(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return EggHatchAnim diff --git a/src/ui/gen2/ElevatorMenu.lua b/src/ui/gen2/ElevatorMenu.lua new file mode 100644 index 00000000..96fab140 --- /dev/null +++ b/src/ui/gen2/ElevatorMenu.lua @@ -0,0 +1,154 @@ +-- The elevator's floor list (engine/events/elevator.asm Elevator_AskWhichFloor). +-- +-- Three maps have one -- Celadon's and Goldenrod's dept stores, and the Radio +-- Tower -- and each names its floors in its own script bank with +-- `elevfloor floor, warp, map`. The extractor follows that list now, so this +-- screen has floors to offer; before it did, `elevator` answered 0 and the +-- doors never opened. +-- +-- Two panels, both transcribed: +-- +-- "Now on:" Elevator_GetCurrentFloorText -- `ld b, 4 / ld c, 8` at +-- hlcoord 0,0, i.e. a Textbox whose INTERIOR is 8 wide by 4 +-- tall. The label goes at (1,2) and the floor name at (4,4). +-- the list Elevator_MenuHeader, `menu_coords 12, 1, 18, 9`, a SCROLLING +-- menu of `db 4, 0` -- four visible rows -- with +-- SCROLLINGMENU_DISPLAY_ARROWS. Rows are two apart and the +-- first sits one below the border, which is the arithmetic +-- ScrollingMenu_PlaceCursor spells out: `dec a / add a / add $1` +-- added to wMenuBorderTopCoord. +-- +-- The ride itself is Elevator_GoToFloor, which does NOT warp: it writes the +-- chosen row's warp number and destination map into wBackupWarpNumber / +-- wBackupMapGroup / wBackupMapNumber, and the elevator's own door warp -- a +-- `warp_event` whose destination warp is -1 -- reads them when the player +-- walks out. World:takeWarp owns that half. + +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local ElevatorMenu = {} +ElevatorMenu.__index = ElevatorMenu +ElevatorMenu.isOpaque = false + +-- Elevator_GetCurrentFloorText's Textbox: interior 8x4 at (0,0). +local NOW_X, NOW_Y, NOW_W, NOW_H = 0, 0, 8, 4 +local NOW_LABEL_X, NOW_LABEL_Y = 1, 2 +local NOW_FLOOR_X, NOW_FLOOR_Y = 4, 4 + +-- Elevator_MenuHeader's `menu_coords 12, 1, 18, 9`, and +-- GetMenuTextStartCoord's border + cursor offsets on it. +local LIST_X, LIST_Y, LIST_W, LIST_H = 12, 1, 7, 9 +local ITEM_X, ITEM_Y = 14, 2 +local VISIBLE = 4 + +local NOW_ON = Strings.source("Now on:") + +-- FloorToString hands back the FLOOR_* name; the cache's floorNames carries +-- the same strings out of ElevatorFloorNames. +local FALLBACK_FLOORS = { + "B4F", "B3F", "B2F", "B1F", "1F", "2F", "3F", "4F", "5F", "6F", "7F", + "8F", "9F", "10F", "11F", "ROOF", +} + +function ElevatorMenu.floorName(floors, row) + if type(floors) == "table" and row and floors[row + 1] then + return floors[row + 1] + end + return FALLBACK_FLOORS[(row or 0) + 1] or "?" +end + +-- The scroll window a four-row list shows: the cursor stays inside it and the +-- window follows, which is what SCROLLINGMENU_DISPLAY_ARROWS' arrows mark. +function ElevatorMenu.scrollFor(index, count, scroll) + scroll = scroll or 0 + if index - 1 < scroll then scroll = index - 1 end + if index > scroll + VISIBLE then scroll = index - VISIBLE end + return math.max(0, math.min(scroll, math.max(0, count - VISIBLE))) +end + +-- opts: floors (the extracted elevfloor rows), currentMap (wBackupMapNumber's +-- map, i.e. the floor the player got in on), floorNames, onDone(row) +function ElevatorMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ElevatorMenu) + self.game = game + self.data = (game and game.data) or {} + self.floors = opts.floors or {} + self.floorNames = opts.floorNames + self.onDone = opts.onDone + -- .FindCurrentFloor: the row whose destination map is the one the player + -- came in from. A miss is `scf` -- the whole command quits without a menu, + -- which the caller checks before building this screen. + self.origin = nil + for i, row in ipairs(self.floors) do + if row.destMap == opts.currentMap then self.origin = i break end + end + self.index = 1 + self.scroll = ElevatorMenu.scrollFor(self.index, #self.floors, 0) + return self +end + +function ElevatorMenu:wantsFillScale() return true end + +function ElevatorMenu: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 ElevatorMenu:finish(row) + if self.done then return end + self.done = true + if self.onDone then self.onDone(row) end +end + +function ElevatorMenu: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("up") and self.index > 1 then + self.index = self.index - 1 + elseif input:wasPressed("down") and self.index < #self.floors then + self.index = self.index + 1 + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + return self:finish(nil) + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + -- `ld hl, wElevatorOriginFloor / cp [hl] / jr z, .quit`: picking the floor + -- you are already on quits with carry, which is the same FALSE the cancel + -- gives. So the doors do not close and the script skips its own SFX. + if self.index == self.origin then return self:finish(nil) end + return self:finish(self.floors[self.index]) + end + self.scroll = ElevatorMenu.scrollFor(self.index, #self.floors, self.scroll) +end + +function ElevatorMenu:drawPanel() + Chrome.textbox(NOW_X, NOW_Y, NOW_W, NOW_H) + Chrome.print(NOW_ON, NOW_LABEL_X, NOW_LABEL_Y) + local origin = self.origin and self.floors[self.origin] + Chrome.print( + ElevatorMenu.floorName(self.floorNames, origin and origin.floorId), + NOW_FLOOR_X, NOW_FLOOR_Y) + + Chrome.box(LIST_X, LIST_Y, LIST_W, LIST_H) + for slot = 1, math.min(VISIBLE, #self.floors) do + local row = self.floors[self.scroll + slot] + if row then + Chrome.print(ElevatorMenu.floorName(self.floorNames, row.floorId), + ITEM_X, ITEM_Y + (slot - 1) * 2) + end + end + Chrome.cursor(ITEM_X - 1, ITEM_Y + (self.index - self.scroll - 1) * 2) + love.graphics.setColor(1, 1, 1, 1) +end + +function ElevatorMenu:draw() + self:drawPanel() +end + +return ElevatorMenu diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua new file mode 100644 index 00000000..dff80f5d --- /dev/null +++ b/src/ui/gen2/EvolutionAnim.lua @@ -0,0 +1,563 @@ +-- The Gen 2 evolution screen: engine/movie/evolution_animation.asm plus the +-- text beats around it in engine/pokemon/evolve.asm's EvolveAfterBattle. +-- +-- All of the arithmetic (which species, whether the condition is met, what the +-- party record becomes, and every frame count below) is +-- src/core/gen2/Evolution.lua's; this file is the only half that draws, so an +-- evolution can be asserted end to end with no window. +-- +-- The animation is NOT a cross fade. The cart loads BOTH frontpics into +-- vTiles2 -- the new species' 7x7 = 49 tiles at tile $31, the old one's at +-- tile $00 -- and then adds or subtracts 49 to every tilemap entry of the +-- 7x7 box at hlcoord 7, 2. That is a hard swap between two pics, once per +-- WaitBGMap, run in accelerating bursts: +-- +-- `lb bc, 1, 16` then, per round, `inc b / dec c / dec c` +-- round 1: hold the old pic 16 frames, then flash 1 time +-- round 2: hold 14, flash 2 times +-- ... eight rounds, ending 2 / 8, and each "flash" is new-then-old. +-- +-- The whole flashing half runs under PREDEFPAL_BLACKOUT (_CGB_Evolution's +-- `ld c` = TRUE branch), which is what makes both pics read as one silhouette; +-- the new species' real colours only arrive on the last swap. +-- +-- Layout: hlcoord 7, 2 with `lb bc, 7, 7` (PrepMonFrontpic), i.e. a 7x7 tile +-- box at (56, 16). The text box below it is never cleared -- EvolveAfterBattle +-- clears rows 0..11 only -- so "What? <NICK> is evolving!" stays under the pic +-- for the whole animation. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Evolution = require("src.core.gen2.Evolution") +local GbcPalette = require("src.render.GbcPalette") +local Mon = require("src.battle.gen2.Mon") +local Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local EvolutionAnim = {} +EvolutionAnim.__index = EvolutionAnim +EvolutionAnim.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`. +local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 + +-- TEXTBOX_Y is SCREEN_HEIGHT - TEXTBOX_HEIGHT and TEXTBOX_INNERY is +-- TEXTBOX_Y + 2; `line` ($4f) targets the box's absolute second line, which is +-- two rows below the first. +local BOX_X, BOX_Y, BOX_W, BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +-- PromptButton pages: the port holds a prompt for this long and takes A or B +-- as the press, the same deal src/ui/gen2/BattleState.lua's MESSAGE_FRAMES +-- gives every other Gold text box. +local PROMPT_FRAMES = 48 + +-- Paragraph's own `ld c, 20 / call DelayFrames` between clearing the box and +-- printing the next page. +local PARAGRAPH_FRAMES = 20 + +-- gfx/sgb/predef.pal PREDEFPAL_BLACKOUT, through the extractor's own 5-bit to +-- 8-bit scale (floor(v * 255 / 31 + 0.5)). +local BLACKOUT = { + { 255, 255, 255 }, { 58, 58, 58 }, { 16, 25, 25 }, { 0, 0, 0 }, +} + +-- NUM_SPRITE_ANIM_STRUCTS. InitSpriteAnimStruct refuses once the pool is +-- full, so the balls of light cap out at ten on screen no matter how many +-- .GenerateBallOfLight would like to make. +local BALL_LIMIT = 10 + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function EvolutionAnim:wantsFillScale() return true end +function EvolutionAnim:drawsWidescreen() return true end + +-- opts: +-- mon the party record as it stands BEFORE evolving +-- entry the EvosAttacks row Evolution.check picked +-- index its party slot, so the new record lands in the right one +-- party the party table to write back into (defaults to save.party) +-- save for SetSeenAndCaughtMon +-- force wForceEvolution: a stone evolution cannot be cancelled with B +-- onDone(result) result = { canceled, evolved, learned, full } +function EvolutionAnim.new(game, opts) + opts = opts or {} + local self = setmetatable({}, EvolutionAnim) + self.game = game + self.data = (game and game.data) or {} + self.palettes = self.data.gen2Palettes + self.save = opts.save or (game and game.save) + self.party = opts.party or (self.save and self.save.party) or {} + self.index = opts.index or 1 + self.mon = opts.mon or self.party[self.index] + self.entry = opts.entry + self.force = opts.force or false + self.onDone = opts.onDone + + self.oldSpecies = self.mon and self.mon.species + self.newSpecies = self.entry and self.entry.into + -- wStringBuffer2 is filled with GetNickname BEFORE the animation, so every + -- line below (including "stopped evolving!") names the mon by the name it + -- had going in. + self.nick = (self.mon and (self.mon.nickname or self.mon.name)) + or self.oldSpecies or "?" + self.newName = Evolution.speciesName(self.data, self.newSpecies) + + self.picCache = {} + self.rounds = Evolution.flashRounds() + self.canceled = false + self.learned = {} + self.full = {} + self.balls = {} + self.ballFrame = 0 + -- Which of the two pics the 7x7 box is showing. The box starts on the OLD + -- one and every round of flashing ends back on it, which is why a B press + -- during a hold always leaves the old mon on screen. + self.showNew = false + self.blackout = false + + self:setPhase("evolving") + return self +end + +-------------------------------------------------------------------------- +-- Phases +-------------------------------------------------------------------------- + +function EvolutionAnim:playCry(species) + if not species then return end + local cries = self.data.audio and self.data.audio.cries + if cries and cries[species] then Sound.playCry(self.data, species) end +end + +function EvolutionAnim: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 + +-- NOT `enter`. `enter` is the stack's own lifecycle hook -- StateStack:push +-- calls `state:enter(...)` (src/core/StateStack.lua:18), and Gold runs that +-- same stack (src/core/Game2.lua:makeStack) -- so a screen that also used +-- `enter` for its internal state machine had that machine reset the instant +-- it was pushed: +-- Screens.push passes no extra arguments, so the hook arrived as `enter(nil)`, +-- phase became nil and every branch of update() then missed. The screen sat +-- there decrementing its timer forever with the world still marked busy, which +-- made a post-battle evolution an unrecoverable hang -- the Gold route bot hit +-- it the first time its starter reached level 14. +function EvolutionAnim:setPhase(phase) + self.phase = phase + self.timer = 0 + + if phase == "evolving" then + -- PrintText EvolvingText, then `ld c, 50 / call DelayFrames`. The pics are + -- not placed yet: on the cart the battle screen is still up behind this + -- line and ClearBox only wipes rows 0..11 once the delay is over. + self.lines = { "What? " .. self.nick, "is evolving!" } + self.timer = Evolution.EVOLVING_FRAMES + return + end + + if phase == "cry" then + -- PlayMonCry of the OLD species, then MUSIC_EVOLUTION, then 80 frames. + -- PlayMusic MUSIC_NONE ran first, so nothing else is sounding. + Music.stop() + self:playCry(self.oldSpecies) + local songs = self.data.audio and self.data.audio.songs + if songs and songs.Music_Evolution then + Music.play(self.data, "Music_Evolution", true, { reason = "evolution" }) + end + self.timer = Evolution.MUSIC_FRAMES + return + end + + if phase == "flash" then + -- GetSGBLayout with c = TRUE: PREDEFPAL_BLACKOUT for the whole burst. + self.blackout = true + self.round = 1 + self.step = "wait" + self.timer = self.rounds[1].wait + self.swapsLeft = 0 + return + end + + if phase == "reveal" then + -- The final `ld a, 7 * 7 / call .ReplaceFrontpic` commits the new pic, then + -- wPlayerHPPal takes the new species and GetSGBLayout comes back with + -- c = FALSE, so the colours arrive on the same beat. + self.blackout = false + self.showNew = not self.canceled + if self.canceled then + -- .PlayEvolvedSFX returns immediately once wEvolutionCanceled is set: + -- no SFX_EVOLVED and no balls of light, straight to the cry. + self:playCry(self.oldSpecies) + return self:setPhase("stopped") + end + self:playSfx("Sfx_Evolved") + self.timer = Evolution.BALL_SPAWN_FRAMES + Evolution.BALL_TAIL_FRAMES + return + end + + if phase == "stopped" then + -- CancelEvolution: StoppedEvolvingText over the pic, then ClearTilemap. + self.lines = { "Huh? " .. self.nick, "stopped evolving!" } + self.timer = PROMPT_FRAMES + return + end + + if phase == "congrats" then + self.lines = { "Congratulations!", "Your " .. self.nick } + self.timer = PROMPT_FRAMES + return + end + + if phase == "paragraph" then + -- Paragraph clears the box and waits 20 frames before the next page. + self.lines = nil + self.timer = PARAGRAPH_FRAMES + return + end + + if phase == "evolved" then + -- EvolvedIntoText, then MUSIC_NONE / SFX_CAUGHT_MON / WaitSFX and + -- `ld c, 40 / call DelayFrames`. + self.lines = { "evolved into", self.newName .. "!" } + Music.stop() + self:playSfx("Sfx_CaughtMon") + self.timer = Evolution.CONGRATS_FRAMES + return + end + + if phase == "learn" then + -- ClearTilemap, then the party slot is rewritten and LearnLevelMoves runs. + self:commit() + self.learnIndex = 0 + return self:nextLearn() + end + + if phase == "done" then + if self.onDone then + self.onDone({ + canceled = self.canceled, + evolved = self.evolved, + learned = self.learned, + full = self.full, + }) + end + return + end +end + +-- The writeback: `pop de / pop hl / ld a, [wTempMonSpecies] / ld [hl], a` +-- after CalcMonStats has already rebuilt the struct. Everything the record +-- becomes is Evolution.apply's, which builds it through +-- src/battle/gen2/Mon.lua so there is still exactly one builder. +function EvolutionAnim:commit() + local evolved = Evolution.apply(self.data, self.mon, self.entry) + if not evolved then return end + self.evolved = evolved + self.party[self.index] = evolved + Evolution.markPokedex(self.save, evolved.species) + -- LearnLevelMoves at wCurPartyLevel, which the .proceed block set to the + -- level the mon already had. + self.pending = Evolution.learnedOnEvolve(self.data, evolved.species, + evolved.level, evolved) +end + +-- One LearnMove call per move the new species picks up at this level. There +-- is no forget screen in the Gold port yet, so a full moveset reports the way +-- src/battle/gen2/Battle.lua's own level-up path does instead of prompting. +function EvolutionAnim:nextLearn() + self.learnIndex = (self.learnIndex or 0) + 1 + local moveId = self.pending and self.pending[self.learnIndex] + if not (moveId and self.evolved) then + self.lines = nil + return self:setPhase("done") + end + local moveDef = self.data.moves and self.data.moves[moveId] + local moveName = (moveDef and moveDef.name) or moveId + local ok, reason = Mon.learnMove(self.evolved, moveId, self.data) + if ok then + self.learned[#self.learned + 1] = moveId + self.lines = { self.nick .. " learned", moveName .. "!" } + elseif reason == "full" then + self.full[#self.full + 1] = moveId + self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" } + else + return self:nextLearn() + end + self.phase = "learn" + self.timer = PROMPT_FRAMES +end + +-------------------------------------------------------------------------- +-- Update +-------------------------------------------------------------------------- + +-- .WaitFrames_CheckPressedB reads the joypad every frame of a hold, and +-- .pressed_b honours it only while wForceEvolution is clear: a stone +-- evolution cannot be stopped. +function EvolutionAnim:cancelPressed(input) + if self.force then return false end + return input and input:wasPressed("b") or false +end + +function EvolutionAnim:update(_dt) + local input = self.game and self.game.input + local phase = self.phase + -- onDone has already fired; the caller pops this state on its own beat. + if phase == "done" then return end + + if phase == "flash" then + return self:updateFlash(input) + end + + if phase == "reveal" then + self:updateBalls() + self.timer = self.timer - 1 + if self.timer <= 0 then + -- ClearSpriteAnims, then PlayMonCry of the species the screen is now + -- showing. + self:playCry(self.newSpecies) + self:setPhase("congrats") + end + return + end + + self.timer = (self.timer or 0) - 1 + local prompt = phase == "stopped" or phase == "congrats" or phase == "learn" + if prompt and input + and (input:wasPressed("a") or input:wasPressed("b")) then + self.timer = 0 + end + if self.timer > 0 then return end + + if phase == "evolving" then return self:setPhase("cry") end + if phase == "cry" then return self:setPhase("flash") end + if phase == "stopped" then return self:setPhase("done") end + if phase == "congrats" then return self:setPhase("paragraph") end + if phase == "paragraph" then return self:setPhase("evolved") end + if phase == "evolved" then return self:setPhase("learn") end + if phase == "learn" then return self:nextLearn() end +end + +-- One frame of the flash loop. A round is `.WaitFrames_CheckPressedB` for its +-- own `c` frames and then `.Flash` b times, each flash being two +-- .ReplaceFrontpic calls (new, then back to old) a WaitBGMap apart. +function EvolutionAnim:updateFlash(input) + local round = self.rounds[self.round] + if not round then + return self:setPhase("reveal") + end + + if self.step == "wait" then + if self:cancelPressed(input) then + -- .cancel_evo: wEvolutionCanceled, and the pic stays on the old stage. + self.canceled = true + self.showNew = false + return self:setPhase("reveal") + end + self.timer = self.timer - 1 + if self.timer <= 0 then + self.step = "flash" + -- Two swaps per flash: to the new pic and back. + self.swapsLeft = round.flashes * 2 + self.timer = Evolution.SWAP_FRAMES + self.showNew = true + end + return + end + + -- Each swap holds for one WaitBGMap. + self.timer = self.timer - 1 + if self.timer > 0 then return end + self.swapsLeft = self.swapsLeft - 1 + if self.swapsLeft <= 0 then + self.showNew = false + self.round = self.round + 1 + local nextRound = self.rounds[self.round] + if not nextRound then return self:setPhase("reveal") end + self.step = "wait" + self.timer = nextRound.wait + return + end + self.showNew = not self.showNew + self.timer = Evolution.SWAP_FRAMES +end + +-------------------------------------------------------------------------- +-- Balls of light +-------------------------------------------------------------------------- + +-- .balls_of_light spawns two balls on every EVEN wJumptableIndex over the 32 +-- spawn frames. The angle it writes is read AFTER `inc [hl]`, so it is the +-- POST-increment index masked to %1110 and shifted left; the second ball of a +-- pair is a further $10 round the table. +-- +-- AnimSeq_RevealNewMon then walks each ball out: VAR1 is the radius, starting +-- at $10 and stepping $08 a frame until it passes $80, and the angle is xor'd +-- with $20 every frame so a ball alternates between the two ends of its own +-- diameter. y comes from Sine and x from Cosine. +function EvolutionAnim:updateBalls() + local frame = self.ballFrame + if frame < Evolution.BALL_SPAWN_FRAMES and frame % 2 == 0 then + -- `ld a, [hl] / inc [hl]` leaves the PRE-increment index in a, which is + -- what the `and $1` even/odd test sees; .GenerateBallOfLight then re-reads + -- wJumptableIndex and gets the POST-increment one. So the angle is + -- (post & %1110) << 1, with post always odd here. + local post = frame + 1 + local base = (((post % 16) - (post % 2)) * 2) % 256 + for _, offset in ipairs({ 0x00, 0x10 }) do + if #self.balls < BALL_LIMIT then + self.balls[#self.balls + 1] = { + angle = (base + offset) % 256, + radius = Evolution.BALL_RADIUS_START, + age = 0, + } + end + end + end + self.ballFrame = frame + 1 + + local alive = {} + for _, ball in ipairs(self.balls) do + if ball.radius < Evolution.BALL_RADIUS_END then + local radius = ball.radius + ball.radius = radius + Evolution.BALL_RADIUS_STEP + -- `xor $20` on an angle the sine table masks to six bits is the same as + -- half a period, and Lua 5.1 has no bitwise xor to spell it with. + ball.angle = (ball.angle + 0x20) % 0x40 + ball.y = EvolutionAnim.signed(SpriteAnims.sine(ball.angle, radius)) + ball.x = EvolutionAnim.signed(SpriteAnims.cosine(ball.angle, radius)) + ball.age = ball.age + 1 + alive[#alive + 1] = ball + end + end + self.balls = alive +end + +-- The sine helpers hand back the byte the ASM leaves in a, so a negative +-- offset arrives in two's complement. +function EvolutionAnim.signed(value) + value = value % 256 + if value >= 128 then return value - 256 end + return value +end + +-------------------------------------------------------------------------- +-- Draw +-------------------------------------------------------------------------- + +function EvolutionAnim:pic(species) + local def = species and self.data.pokemon and self.data.pokemon[species] + local path = def and def.spriteFront + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- "and" would truncate the pcall's second return, so the call stands on + -- its own line. + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil +end + +-- The four colours the box draws through right now: the mon's own while the +-- SGB layout is c = FALSE, PREDEFPAL_BLACKOUT while it is TRUE. +-- Shininess is a DV pattern, and the DVs survive the evolution, so both stages +-- read the same flag off the record that walked in. +function EvolutionAnim:picColors(species) + if self.blackout then return BLACKOUT end + return Palettes.monColors(self.palettes, species, + self.mon and self.mon.shiny) +end + +function EvolutionAnim:drawPic() + local species = self.showNew and self.newSpecies or self.oldSpecies + local image = self:pic(species) + if not image then return end + local G = love.graphics + local w, h = image:getDimensions() + -- PlaceGraphic pads the pic into the 7x7 box bottom-first, so a 40x40 + -- Cyndaquil stands on the same ground line a 56x56 Onix does. + local box = PIC_TILES * 8 + local px = PIC_TILE_X * 8 + math.floor((box - w) / 2) + local py = PIC_TILE_Y * 8 + (box - h) + G.setColor(1, 1, 1, 1) + local colors = self:picColors(species) + local function body() G.draw(image, px, py) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- gfx/evo/bubble_large.2bpp and gfx/evo/bubble.2bpp are not in the Gold cache +-- (the extractor does not pull gfx/evo/), so the ball is drawn as the disc +-- those two 8x8 tiles are, at the two sizes .Frameset_EvolutionBallOfLight +-- alternates between every two frames. Its colour goes through +-- GbcPalette.color so the COLOR option still reaches it. +function EvolutionAnim:drawBalls() + if #self.balls == 0 then return end + local G = love.graphics + local colors = Palettes.monColors(self.palettes, self.newSpecies, + self.evolved and self.evolved.shiny) + local rgb = GbcPalette.color(colors, 3) or { 0, 0, 0 } + G.setColor(rgb[1] / 255, rgb[2] / 255, rgb[3] / 255, 1) + for _, ball in ipairs(self.balls) do + local radius = (math.floor(ball.age / 2) % 2 == 0) and 4 or 3 + G.circle("fill", Evolution.BALL_ORIGIN_X + (ball.x or 0) + 4, + Evolution.BALL_ORIGIN_Y + (ball.y or 0) + 4, radius) + end + G.setColor(1, 1, 1, 1) +end + +-- The pic is on screen from the moment .PlaceFrontpic runs until ClearTilemap, +-- which is only reached once the "evolved into" page has had its 40 frames -- +-- so the congratulation text prints OVER the new mon, and CancelEvolution's +-- "stopped evolving!" prints over the old one. The two phases without a pic +-- are the 50 frames before the animation starts and the LearnLevelMoves run +-- after the tilemap is cleared. +function EvolutionAnim:drawPanel() + Chrome.clear() + if self.phase ~= "evolving" and self.phase ~= "learn" then + self:drawPic() + self:drawBalls() + end + if self.lines then + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + for index, line in ipairs(self.lines) do + Chrome.print(line, TEXT_X, TEXT_Y + (index - 1) * TEXT_LINE) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function EvolutionAnim:draw() + self:drawPanel() +end + +function EvolutionAnim: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(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return EvolutionAnim diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua new file mode 100644 index 00000000..668edfcb --- /dev/null +++ b/src/ui/gen2/GameFreakPresents.lua @@ -0,0 +1,385 @@ +-- The GAME FREAK splash (pokegold engine/movie/splash.asm). +-- +-- This is the six-scene jumptable GameFreakPresentsScene walks, not a hold on +-- a logo image: a star spirals in from the edge of the screen, the logo is +-- placed where the star died, sparkles fly off it for 128 frames while the +-- words "GAME FREAK" appear halfway through, then "presents", then a last +-- 128-frame hold. Any button skips the whole thing. +-- +-- scene 0 Star spawn the star, play SFX_GAME_FREAK_LOGO_GS +-- scene 1 PlaceLogo wait for the star to die, then place the logo +-- scene 2 LogoSparkles 128 frames of sparkles; "GAME FREAK" at 63 +-- scene 3 PlacePresents "presents", reset the timer +-- scene 4 WaitForTimer 128 frames +-- scene 5 SetDoneFlag finished +-- +-- GRAPHICS. GameFreakLogoGFX is two INCBINs run together and the extractor +-- splits them (RomExtractorGen2:splashGfx): a 13-tile letter strip at VRAM +-- $80, the 3x5 logo at $8d, the star at $9c and three sparkle frames at $9e. +-- GameFreakPresentsInit points wSpriteAnimDict at $8d, so every OAM set's +-- vtile offset is relative to the logo -- which is why the system's +-- vtileBase is set here and nowhere else. +-- +-- COLOUR. _CGB_GamefreakLogo loads PREDEFPAL_GAMEFREAK_LOGO_OB into OBJ +-- palettes 0 AND 1 (white, white, yellow, yellow) and the BG palette runs +-- black to white. The two OBJ palettes differ only in the DMG byte indexing +-- them: OBP0 stays %11111000 (star and sparkles are yellow) while OBP1 starts +-- at %00100100 and GameFreakPresents_UpdateLogoPal rotates it right two bits +-- every 16 frames until it reaches %10010000 -- so the logo is white for the +-- first 48 frames and yellow after that. + +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 TileSheet = require("src.ui.gen2.TileSheet") + +local GameFreakPresents = {} +GameFreakPresents.__index = GameFreakPresents +GameFreakPresents.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- wSpriteAnimDict[SPRITE_ANIM_DICT_GS_SPLASH] = $8d (GameFreakPresentsInit). +local DICT_VTILE = 0x8d + +-- `depixel 10, 11, 4, 0` and `depixel 11, 11`: the macro is +-- (x tile, y tile, x pixel, y pixel) and lands in de as x, y. +local LOGO_X, LOGO_Y = 10 * 8 + 4, 11 * 8 + 0 +local SPARKLE_X, SPARKLE_Y = 11 * 8, 11 * 8 + +-- GameFreakPresents_PlaceGameFreak / _PlacePresents. $8d is the logo's own +-- first tile borrowed as a blank, which is why "GAME FREAK" reads as ten +-- tiles for nine letters and a space. +local GAME_FREAK = { 0x80, 0x81, 0x82, 0x83, 0x8d, 0x84, 0x85, 0x83, 0x81, 0x86 } +local GAME_FREAK_X, GAME_FREAK_Y = 5, 12 +local PRESENTS = { 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c } +local PRESENTS_X, PRESENTS_Y = 7, 13 + +-- GameFreakPresents_Sparkle .sparkle_vectors: angle (6 bits), distance. +local SPARKLE_VECTORS = { + { 0x00, 0x03 }, { 0x08, 0x04 }, { 0x04, 0x03 }, { 0x0c, 0x02 }, + { 0x10, 0x02 }, { 0x18, 0x03 }, { 0x14, 0x04 }, { 0x1c, 0x03 }, + { 0x20, 0x02 }, { 0x28, 0x02 }, { 0x24, 0x03 }, { 0x2c, 0x04 }, + { 0x30, 0x04 }, { 0x38, 0x03 }, { 0x34, 0x02 }, { 0x3c, 0x04 }, +} + +local SCENE_TIMER = 128 +-- The `ld c, 16 / call DelayFrames` every exit path runs before returning. +local EXIT_FRAMES = 16 + +-- DmgToCgbObjPals / DmgToCgbObjPal1: the DMG palette byte is four 2-bit +-- indices into the real four-colour palette, colour 0 in the low bits. +local OBP0 = 0xf8 -- %11111000, the star and the sparkles +local OBP1_START = 0x24 -- %00100100, the logo before the rotation runs +local OBP1_FINAL = 0x90 -- %10010000, where UpdateLogoPal stops + +local BLACK = { 0, 0, 0 } +local WHITE = { 255, 255, 255 } +local YELLOW = { 206, 247, 0 } -- RGB 25,30,00 out of gfx/sgb/predef.pal + +local DEFAULT_OB = { WHITE, WHITE, YELLOW, YELLOW } +local DEFAULT_BG = { BLACK, { 66, 90, 90 }, { 173, 173, 173 }, WHITE } + +local function permute(colors, byte) + local out = {} + for index = 0, 3 do + out[index + 1] = colors[math.floor(byte / 4 ^ index) % 4 + 1] or BLACK + end + return out +end + +function GameFreakPresents:wantsFillScale() return true end +function GameFreakPresents:drawsWidescreen() return true end + +-- opts: oakSpeech (data/generated/oak_speech.lua, for its `splash` table), +-- onDone +function GameFreakPresents.new(game, opts) + opts = opts or {} + local self = setmetatable({}, GameFreakPresents) + self.game = game + self.onDone = opts.onDone + local splash = (opts.oakSpeech or {}).splash or {} + self.obColors = splash.obPalette or DEFAULT_OB + self.bgColors = splash.bgPalette or DEFAULT_BG + + -- One sheet per INCBIN, each addressed by the VRAM id its tiles were + -- loaded at, so an OAM entry's tile id picks its own sheet. + self.sheets = { + TileSheet.new({ path = splash.presents + or "assets/generated/splash/presents.png", wide = 13, firstTile = 0x80 }), + TileSheet.new({ path = splash.logo + or "assets/generated/splash/logo.png", wide = 3, firstTile = 0x8d }), + TileSheet.new({ path = splash.star + or "assets/generated/splash/star.png", wide = 1, firstTile = 0x9c }), + TileSheet.new({ path = splash.sparkle + or "assets/generated/splash/sparkle.png", wide = 3, firstTile = 0x9e }), + } + + self.anims = SpriteAnims.new() + self.anims.vtileBase = DICT_VTILE + self.scene = 0 + self.timer = 0 + self.obp1 = OBP1_START + self.tiles = {} -- the BG tilemap, sparse: [y][x] = tile id + self.frames = 0 + -- exitTail, not `exit`: enter/exit are the StateStack's own callback names + -- and a state is an ordinary table, so a field there is a call the stack + -- would make on a pop. + self.exitTail = nil + self.done = false + self.sfxPlayed = false + return self +end + +function GameFreakPresents:enter() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime then + Music.stop() + end + -- intro.boot.gamefreak: scene 0 of GameFreakPresentsScene is about to run, + -- i.e. the splash is up and its star has not been spawned yet. Gen 2 only: + -- Red has no splash.asm counterpart, so this is a new name rather than a + -- borrowed one (see CopyrightSplash for the set). It also marks the end of + -- the copyright card, which is why that card has no `ended` name. + if Runtime.wants("intro.boot.gamefreak") then + Runtime.emit("intro.boot.gamefreak", { screen = self, game = self.game }) + end +end + +function GameFreakPresents:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone() end +end + +-- PlaceString into the sparse tilemap. +function GameFreakPresents:placeString(tiles, tx, ty) + local row = self.tiles[ty] + if not row then + row = {} + self.tiles[ty] = row + end + for index, tile in ipairs(tiles) do + row[tx + index - 1] = tile + end +end + +-------------------------------------------------------------------------- +-- GameFreakPresentsScene +-------------------------------------------------------------------------- + +function GameFreakPresents:sceneStar() + self.anims.flag = 0 -- wIntroSceneFrameCounter + local st = self.anims:init("GS_GAMEFREAK_LOGO_STAR", LOGO_X, LOGO_Y) + if st then st.var1 = 0x80 end + local data = self.game and self.game.data + if data and data.audio and data.audio.sfx + and data.audio.sfx.Sfx_GameFreakLogoGs then + Sound.play(data, "Sfx_GameFreakLogoGs") + end + self.scene = self.scene + 1 +end + +function GameFreakPresents:scenePlaceLogo() + -- The star's own sequence sets the flag when it reaches the middle. + if self.anims.flag == 0 then return end + self.anims:init("GAMEFREAK_LOGO", LOGO_X, LOGO_Y) + -- UpdateLogoPal is called out of the LOGO's own AnimSeq, so its clock only + -- runs once the logo exists. Without this the rotation would fire on every + -- frame of the star scene (the timer is still 0 there, and 0 % 16 == 0) and + -- the logo would be yellow before it was ever placed. + self.logoAlive = true + self.scene = self.scene + 1 + self.timer = SCENE_TIMER +end + +-- GameFreakPresents_Sparkle: one new sparkle on every second frame, its +-- direction taken from the low four bits of half the timer. +function GameFreakPresents:sparkle(counter) + if counter % 2 ~= 0 then return end + local st = self.anims:init("GS_GAMEFREAK_LOGO_SPARKLE", + SPARKLE_X, SPARKLE_Y) + if not st then return end -- all ten structs busy, as on hardware + local vector = SPARKLE_VECTORS[math.floor(counter / 2) % 16 + 1] + st.jt = vector[1] + st.var1 = 0 + st.var2 = vector[2] +end + +function GameFreakPresents:sceneLogoSparkles() + local counter = self.timer + if counter == 0 then + self.timer = SCENE_TIMER + self.scene = self.scene + 1 + return + end + self.timer = self.timer - 1 + if counter == 63 then + self:placeString(GAME_FREAK, GAME_FREAK_X, GAME_FREAK_Y) + end + self:sparkle(counter) +end + +function GameFreakPresents:scenePlacePresents() + self:placeString(PRESENTS, PRESENTS_X, PRESENTS_Y) + self.scene = self.scene + 1 + self.timer = SCENE_TIMER +end + +function GameFreakPresents:sceneWaitForTimer() + if self.timer == 0 then + self.scene = self.scene + 1 + return + end + self.timer = self.timer - 1 +end + +local SCENES = { + GameFreakPresents.sceneStar, + GameFreakPresents.scenePlaceLogo, + GameFreakPresents.sceneLogoSparkles, + GameFreakPresents.scenePlacePresents, + GameFreakPresents.sceneWaitForTimer, + -- GameFreakPresents_SetDoneFlag. + function(self) self:beginExit() end, +} + +-- The tail both exit paths share: ClearSpriteAnims, ClearTilemap and +-- ClearSprites, then `ld c, 16 / call DelayFrames` on an empty screen. +function GameFreakPresents:beginExit() + if self.exitTail then return end + self.anims:clear() + self.tiles = {} + self.logoAlive = false + self.exitTail = 0 +end + +-- GameFreakPresents_UpdateLogoPal, called out of the logo's AnimSeq: rotate +-- OBP1 right by one colour slot every 16 frames until it reaches its final +-- state, then leave it alone. +function GameFreakPresents:updateLogoPal() + if not self.logoAlive then return end + if self.obp1 == OBP1_FINAL then return end + if self.timer % 16 ~= 0 then return end + local low = self.obp1 % 4 + self.obp1 = math.floor(self.obp1 / 4) + low * 64 +end + +function GameFreakPresents:update(_dt) + self.frames = self.frames + 1 + -- The 16-frame tail after the sequence finishes or is skipped. + if self.exitTail then + self.exitTail = self.exitTail + 1 + if self.exitTail > EXIT_FRAMES then self:finish() end + return + end + local input = self.game and self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") or input:wasPressed("select")) then + -- .pressed_button: everything is torn down and the splash is over. + self:beginExit() + return + end + self.anims:playFrame() + self:updateLogoPal() + local scene = SCENES[self.scene + 1] + if scene then scene(self) end +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function GameFreakPresents:sheetFor(tile) + for _, sheet in ipairs(self.sheets) do + if tile >= sheet.firstTile and sheet:available() then + local index = tile - sheet.firstTile + local image = sheet:image() + local _, height = image:getDimensions() + if index < sheet.wide * (height / 8) then return sheet end + end + end + return nil +end + +function GameFreakPresents:drawTile(tile, tx, ty, colors) + local sheet = self:sheetFor(tile) + if not sheet then return end + sheet.palette = colors + sheet:draw(tile, tx, ty) +end + +-- One pass over wShadowOAM. The palette is the OAM attribute's low bits: +-- slot 1 is the logo, on the OBP1 that rotates, and everything else is the +-- fixed OBP0 the star and sparkles share. +function GameFreakPresents:drawObjects() + local G = love.graphics + local logoPal = permute(self.obColors, self.obp1) + local objPal = permute(self.obColors, OBP0) + local oam = self.anims.oam + for index = #oam, 1, -1 do + local entry = oam[index] + -- OBJ palette 1 is the logo and nothing else. + local isLogo = entry.attr % 8 == 1 + local sheet = self:sheetFor(entry.tile) + local quad = sheet and sheet:quad(entry.tile - sheet.firstTile) + if quad then + local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1 + local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1 + local colors = isLogo and logoPal or objPal + local function body() + 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 + if GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + end + end +end + +function GameFreakPresents:drawPanel() + local G = love.graphics + -- BG colour 0 is the backdrop the cleared tilemap shows. + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) + for ty, row in pairs(self.tiles) do + for tx, tile in pairs(row) do + self:drawTile(tile, tx, ty, self.bgColors) + end + end + self:drawObjects() + G.setColor(1, 1, 1, 1) +end + +function GameFreakPresents:draw() + self:drawPanel() +end + +function GameFreakPresents:drawWidescreen(winW, winH) + local G = love.graphics + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +GameFreakPresents.SPARKLE_VECTORS = SPARKLE_VECTORS +GameFreakPresents.permute = permute + +return GameFreakPresents diff --git a/src/ui/gen2/GoldSilverIntro.lua b/src/ui/gen2/GoldSilverIntro.lua new file mode 100644 index 00000000..4d9b6ac3 --- /dev/null +++ b/src/ui/gen2/GoldSilverIntro.lua @@ -0,0 +1,1053 @@ +-- The Gold/Silver intro movie (pokegold engine/movie/intro.asm +-- GoldSilverIntro), transcribed rather than approximated. +-- +-- The cart runs the whole thing off one 17-entry jumptable stepped once per +-- frame, and almost everything on screen is a side effect of four bytes: +-- hSCX, hSCY and the two intro frame counters. So this module keeps those +-- bytes, keeps a real 32x32 BG map, and runs the same scene functions over +-- them. What that buys is the movement the old timed-fade version had no way +-- to express: +-- +-- 1-5 underwater. Shellders drift up out of frame while bubbles rise, +-- then the camera climbs to the surface -- and it climbs by streaming +-- one fresh metatile row into the top of the BG map every 16 pixels +-- (Intro_UpdateTilemapAndBGMap), which is why the act ships a tilemap +-- twice as tall as the map. Magikarp jump, Lapras surfaces, fade. +-- 6-9 grass. Scroll left to Jigglypuff with notes rising, Pikachu +-- charges in from the right, then the camera drops and fades. +-- 10-16 fire. Climb a black field to the Charizard silhouette while the +-- three Johto starters flash across it, open its mouth in three +-- tilemap redraws, breathe a fireball that spirals outward. +-- 17 64 frames of black, then the title screen. +-- +-- The ocean's wobble is not a sprite effect: hLCDCPointer points at rSCY and +-- wLYOverrides holds a per-scanline SCY, so the water bends line by line +-- (Intro_InitSineLYOverrides / Intro_UpdateLYOverrides). That is drawn here +-- as one quad per scanline. +-- +-- Any button skips the whole thing, exactly as .PlayFrame does on PAD_BUTTONS. + +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 Palettes = require("src.world.gen2.Palettes") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local GoldSilverIntro = {} +GoldSilverIntro.__index = GoldSilverIntro +GoldSilverIntro.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local BG_TILES = 32 -- TILEMAP_WIDTH / TILEMAP_HEIGHT +local BG_PIXELS = BG_TILES * 8 -- the BG map wraps every 256 pixels +local META_COLS = 16 -- TILEMAP_WIDTH / 2 +local TILEMAP_W, TILEMAP_H = 20, 18 -- SCREEN_WIDTH / SCREEN_HEIGHT + +local INTRO_MUSIC = "Music_GoldSilverOpening" +local INTRO_MUSIC_2 = "Music_GoldSilverOpening2" +local SFX_FIREBALL = "Sfx_GsIntroCharizardFireball" +local SFX_APPEARS = "Sfx_GsIntroPokemonAppears" + +-- Intro_AnimateOceanWaves' .wave_tiles: four four-tile cycles, each repeated +-- across the whole 32-tile BG row. +local WAVE_TILES = { + { 0x70, 0x71, 0x72, 0x73 }, + { 0x74, 0x75, 0x76, 0x77 }, + { 0x78, 0x79, 0x7a, 0x7b }, + { 0x7c, 0x7d, 0x7e, 0x7f }, +} +-- `vBGMap0 tile $1e` is 480 bytes in, i.e. the whole of BG row 15. +local WAVE_ROW = 15 + +-- DrawIntroCharizardGraphic .charizard_data: vtile offset, width, height and +-- the tilemap coordinate the rectangle of running tile ids starts at. +local CHARIZARD_GFX = { + { tile = 0x00, width = 8, height = 8, x = 10, y = 6 }, -- mouth closed + { tile = 0x40, width = 9, height = 8, x = 9, y = 6 }, -- mouth open + { tile = 0x88, width = 9, height = 8, x = 8, y = 6 }, -- breathing fire +} + +-- IntroScene5 / IntroScene9 / IntroScene12 / IntroScene16 palette ladders. +-- Each is a DMG palette register that DmgToCgbBGPals reorders the loaded CGB +-- colours through, so %11100100 is the identity and %00000000 collapses every +-- shade onto colour 0. +local WATER_FADE = { 0xe4, 0xe4, 0x90, 0x40, 0x00 } +local GRASS_FADE = { 0xe4, 0xe4, 0xe4, 0xe4, 0xe4, 0x90, 0x40, 0x00 } +local CHARIZARD_PALS = { 0x6a, 0xa5, 0xe4, 0x00 } +local FIRE_FADE = { 0xe4, 0x90, 0x40, 0x00 } + +-- Intro_CheckSCYEvent .scy_jumptable: the SCY values the fire act's climb +-- fires its palette flashes and starter entrances on. +local SCY_EVENTS = { + [0x86] = "loadChikorita", + [0x87] = "chikoritaAppears", + [0x88] = "flashMonPalette", + [0x98] = "flashSilhouette", + [0x99] = "loadCyndaquil", + [0xaf] = "cyndaquilAppears", + [0xb0] = "flashMonPalette", + [0xc0] = "flashSilhouette", + [0xc1] = "loadTotodile", + [0xd7] = "totodileAppears", + [0xd8] = "flashMonPalette", + [0xe8] = "flashSilhouette", + [0xe9] = "loadCharizard", +} + +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +-------------------------------------------------------------------------- +-- Palette plumbing +-------------------------------------------------------------------------- + +-- CopyPals: colour i of the displayed palette is colour (reg >> 2i) & 3 of the +-- loaded one. DmgToCgbBGPals runs it over every BG palette with rBGP and +-- DmgToCgbObjPals over every OBJ palette with rOBP0, which is what makes the +-- fades work without touching the artwork. +local function remap(palette, register) + local out = {} + for index = 0, 3 do + local slot = math.floor(register / 4 ^ index) % 4 + out[index + 1] = (palette and palette[slot + 1]) or BLACK + end + return out +end + +local function copyPalette(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 + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function GoldSilverIntro:wantsFillScale() return true end +function GoldSilverIntro:drawsWidescreen() return true end + +function GoldSilverIntro.new(game, opts) + opts = opts or {} + local self = setmetatable({}, GoldSilverIntro) + self.game = game + self.onDone = opts.onDone + local data = (game and game.data) or {} + self.monPalettes = data.gen2Palettes + self.assets = opts.intro or data.gen2Intro or (game and game.introData) or nil + if not self.assets then + -- The scene script runs either way, so a cache with no intro.lua plays + -- the movie's full 2335 frames over an empty screen and reads exactly + -- like "the intro does not work". Say so instead: the fix is a + -- re-import, and nothing else in the boot chain will mention it. + Logger.warn("gold intro: no intro.lua in the cache -- re-import Gold " + .. "or the movie plays blank") + end + self.images = {} + self.sheets = {} + + self.anims = SpriteAnims.new() + self.scene = 1 + self.done = false + self.frames = 0 + + -- Hardware registers and the movie's own WRAM. + self.scx, self.scy = 0, 0 + self.counter1, self.counter2 = 0, 0 + self.bgp, self.obp0 = 0xe4, 0xe4 + self.lyActive = false + self.lyOverrides = {} + self.lySine = {} + for line = 1, SCREEN_H do + self.lyOverrides[line] = 0 + self.lySine[line] = 0 + end + + self.bgPals = { copyPalette(nil) } + self.obPals = { copyPalette(nil), copyPalette(nil) } + self.bgmap = {} + for index = 1, BG_TILES * BG_TILES do self.bgmap[index] = 0 end + self.tilemap = {} + for index = 1, TILEMAP_W * TILEMAP_H do self.tilemap[index] = 0 end + self.act = nil + self.mapDirty = true + return self +end + +-------------------------------------------------------------------------- +-- BG map +-------------------------------------------------------------------------- + +local function mapGet(self, col, row) + return self.bgmap[(row % BG_TILES) * BG_TILES + (col % BG_TILES) + 1] +end + +local function mapSet(self, col, row, tile) + self.bgmap[(row % BG_TILES) * BG_TILES + (col % BG_TILES) + 1] = tile + self.mapDirty = true +end + +local function actData(self) + return self.assets and self.act and self.assets[self.act] or nil +end + +-- Intro_Draw2x2Tiles: metatile `index` of the act's table is four tile ids in +-- reading order, laid into the 2x2 block whose top-left corner is (col, row). +local function draw2x2(self, source, index, col, row) + local meta = source.meta + for quad = 0, 3 do + local tile = meta[index * 4 + quad + 1] or 0 + mapSet(self, col + quad % 2, row + math.floor(quad / 2), tile) + end +end + +-- Intro_DrawBackground: 16 metatile rows of 16, starting at tilemap row +-- `firstRow`, laid across the whole BG map from BG row 0. +local function drawBackground(self, source, firstRow) + for metaRow = 0, BG_TILES / 2 - 1 do + for metaCol = 0, META_COLS - 1 do + local index = source.tilemap[(firstRow + metaRow) * META_COLS + metaCol + 1] + draw2x2(self, source, index or 0, metaCol * 2, metaRow * 2) + end + end +end + +-- Intro_UpdateTilemapAndBGMap: step the tilemap pointer back one metatile row +-- and the BG pointer back two tile rows, then draw the new row in at the top +-- of the map -- which, because the map wraps, is the row that just scrolled +-- off the bottom. It also ticks counter1 down, and that counter is what ends +-- the climb. +local function updateTilemapAndBGMap(self, source) + self.tilemapRow = self.tilemapRow - 1 + self.bgRow = (self.bgRow - 2) % BG_TILES + for metaCol = 0, META_COLS - 1 do + local index = source.tilemap[self.tilemapRow * META_COLS + metaCol + 1] + draw2x2(self, source, index or 0, metaCol * 2, self.bgRow) + end + self.counter1 = (self.counter1 - 1) % 256 +end + +-- Intro_AnimateOceanWaves. Real hardware queues this as a 2bpp request that +-- lands in the next VBlank; there is no queue here, so the row changes on the +-- frame that asks for it. +local function animateOceanWaves(self) + if self.counter2 % 4 == 3 then return end + local cycle = WAVE_TILES[math.floor(self.counter2 % 0x40 / 0x10) + 1] + for col = 0, BG_TILES - 1 do + mapSet(self, col, WAVE_ROW, cycle[col % 4 + 1]) + end +end + +-------------------------------------------------------------------------- +-- LY overrides +-------------------------------------------------------------------------- + +-- Intro_InitSineLYOverrides fills a 144-entry table with sin(line * pi/32) at +-- amplitude 4; Intro_UpdateLYOverrides then rotates that table by one entry a +-- frame and adds hSCY, so the wave travels down the screen. The top 16 +-- scanlines are held flat at hSCY. +local function initSineLYOverrides(self) + for line = 0, SCREEN_H - 1 do + self.lySine[line + 1] = SpriteAnims.sine(line, 4) + end +end + +local function resetLYOverrides(self) + for line = 1, SCREEN_H do self.lyOverrides[line] = 0 end + self.lyActive = false +end + +local function updateLYOverrides(self) + for line = 1, 16 do self.lyOverrides[line] = self.scy end + local first = self.lySine[1] + for offset = 0, 0x7f do + local value = self.lySine[offset + 2] + self.lySine[offset + 1] = value + self.lyOverrides[17 + offset] = (value + self.scy) % 256 + end + self.lySine[0x81] = first +end + +-------------------------------------------------------------------------- +-- Audio +-------------------------------------------------------------------------- + +function GoldSilverIntro: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 GoldSilverIntro: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 + +-------------------------------------------------------------------------- +-- Scenes +-------------------------------------------------------------------------- + +local Scenes = {} + +-- IntroScene1: set up the water cutscene. +Scenes[1] = function(self) + self.scene = 2 + self.act = "water" + local source = actData(self) + if source then + self.tilemapRow = source.firstRow or 15 + self.bgRow = 0 + drawBackground(self, source, self.tilemapRow) + end + self.anims:clear() + self.scy = 0 + self.anims.globalY, self.anims.globalX = 0, 0 + self.scx = 0x58 + self.counter2 = 0 + self.counter1 = 0x80 + self.lyActive = true + initSineLYOverrides(self) + self.anims.flag = 0 + + -- GetSGBLayout SCGB_GS_INTRO 0 -> _CGB_GSIntro.ShellderLaprasScene. + local palettes = self.assets and self.assets.palettes or {} + self.bgPals[1] = copyPalette(palettes.waterBg) + self.obPals[1] = copyPalette(palettes.waterOb and palettes.waterOb[1]) + self.obPals[2] = copyPalette(palettes.waterOb and palettes.waterOb[2]) + self.bgp, self.obp0 = 0xe4, 0xe4 + + -- Intro_InitShellders. + self.anims:init("GS_INTRO_SHELLDER", 7 * 8, 18 * 8) + self.anims:init("GS_INTRO_SHELLDER", 10 * 8, 14 * 8) + self.anims:init("GS_INTRO_SHELLDER", 15 * 8, 16 * 8) + self:playMusic(INTRO_MUSIC) +end + +-- Intro_InitBubble .pixel_table, as {x, y} pairs. The counter picks a slot +-- 0-7 out of a six-entry table, so on hardware two of the nine bubbles read +-- past its end and surface at whatever the following code bytes say; those two +-- are dropped here rather than reproduced. +local BUBBLE_SPOTS = { + { 6 * 8, 14 * 8 + 4 }, { 14 * 8, 18 * 8 + 4 }, { 10 * 8, 16 * 8 + 4 }, + { 12 * 8, 15 * 8 }, { 4 * 8, 13 * 8 }, { 8 * 8, 17 * 8 }, +} + +local function initBubble(self) + if self.counter1 % 16 ~= 0 then return end + local spot = BUBBLE_SPOTS[math.floor(self.counter1 % 0x80 / 0x10) + 1] + if not spot then return end + self.anims:init("GS_INTRO_BUBBLE", spot[1], spot[2]) +end + +-- IntroScene2: Shellders drift, bubbles rise, for $80 frames. +Scenes[2] = function(self) + updateLYOverrides(self) + if self.counter1 ~= 0 then + self.counter1 = self.counter1 - 1 + initBubble(self) + return + end + self.counter1 = 0x10 + self.scene = 3 + return Scenes[3](self) +end + +-- Intro_InitMagikarps: three at once, alternating between two sets of spots +-- every 64 frames. Its rate and phase masks come out of a `depixel` used as a +-- pair of constants (8, 7 -> $40 and $3f). +local MAGIKARP_SPOTS = { + { { 28 * 8, 29 * 8 }, { 0 * 8, 26 * 8 }, { 24 * 8, 0 * 8 } }, + { { 30 * 8, 28 * 8 }, { 24 * 8, 31 * 8 }, { 28 * 8, 2 * 8 } }, +} + +local function initMagikarps(self) + if self.counter2 % 0x40 ~= 0 then return end + local set = MAGIKARP_SPOTS[self.counter2 % 0x80 ~= 0 and 2 or 1] + for _, spot in ipairs(set) do + self.anims:init("GS_INTRO_MAGIKARP", spot[1], spot[2]) + end +end + +local function initLapras(self) + if self.counter2 % 0x20 ~= 0 then return end + self.anims:init("GS_INTRO_LAPRAS", 24 * 8, 16 * 8) +end + +-- IntroScene3_Jumper's 17 entries, indexed by counter1 -- which counts the +-- remaining metatile rows down from $10, so this table reads bottom-up: the +-- wobble runs while the camera is still deep, then Magikarp jump, then Lapras +-- arrives just before the surface. +local SCENE3_STEPS = { + [0] = "waves", "waves", "waves", "lapras", "waves", "waves", + "magikarp", "magikarp", "magikarp", "palettes", "noLY", + "ly", "ly", "ly", "ly", "ly", "ly", +} + +local function scene3Jumper(self) + local step = SCENE3_STEPS[self.counter1] + if step == "lapras" then + initLapras(self) + self.obp0 = 0xe4 -- DmgToCgbObjPals with depixel 28, 28, 4, 4 + animateOceanWaves(self) + elseif step == "waves" then + animateOceanWaves(self) + elseif step == "magikarp" then + initMagikarps(self) + animateOceanWaves(self) + elseif step == "palettes" then + if self.counter2 % 0x20 == 0 then + -- Intro_LoadMagikarpPalettes swaps in the school's own colours. + local palettes = self.assets and self.assets.palettes or {} + self.bgPals[1] = copyPalette(palettes.magikarpBg) + self.obPals[1] = copyPalette(palettes.magikarpOb) + else + initMagikarps(self) + end + elseif step == "noLY" then + self.lyActive = false + elseif step == "ly" then + updateLYOverrides(self) + end +end + +-- IntroScene3_ScrollToSurface: hSCX creeps left a quarter as fast as the climb, +-- hSCY steps up every other frame, and a new metatile row streams in every 16 +-- pixels. Carry (the act ending) is counter1 reaching zero. +local function scrollToSurface(self) + self.counter2 = (self.counter2 + 1) % 256 + if self.counter2 % 4 == 0 then + self.scx = (self.scx - 1) % 256 + end + if self.counter2 % 2 ~= 0 then return false end + self.anims.globalY = (self.anims.globalY + 1) % 256 + local before = self.scy + self.scy = (self.scy - 1) % 256 + if before % 16 == 0 then + local source = actData(self) + if source then updateTilemapAndBGMap(self, source) end + end + return self.counter1 == 0 +end + +-- IntroScene3: rise towards the surface. +Scenes[3] = function(self) + scene3Jumper(self) + if not scrollToSurface(self) then return end + resetLYOverrides(self) + self.scy = (self.scy + 1) % 256 + self.scene = 4 + return Scenes[4](self) +end + +-- IntroScene4: at the surface; hold until Lapras has swum off to the left. +Scenes[4] = function(self) + if self.anims.flag == 0 then + self.counter2 = (self.counter2 + 1) % 256 + if self.counter2 % 16 == 0 then + self.scx = (self.scx - 2) % 256 + end + animateOceanWaves(self) + return + end + self.scene = 5 + self.counter1 = 0 + return Scenes[5](self) +end + +-- IntroScene5: fade out, one palette step every 16 frames. +Scenes[5] = function(self) + local step = math.floor(self.counter1 / 16) + 1 + self.counter1 = (self.counter1 + 1) % 256 + local palette = WATER_FADE[step] + if not palette then + self.scene = 6 + return + end + self.bgp = palette + animateOceanWaves(self) + self.scx = (self.scx - 2) % 256 +end + +-- IntroScene6: set up the grass cutscene. +Scenes[6] = function(self) + self.scene = 7 + self.act = "grass" + self.anims:clear() + resetLYOverrides(self) + local source = actData(self) + if source then + self.tilemapRow = source.firstRow or 0 + self.bgRow = 0 + drawBackground(self, source, self.tilemapRow) + end + self.scy = 0 + self.anims.globalY = 0 + self.scx = 0x60 + self.anims.globalX = 0xa0 + self.counter2 = 0 + + local palettes = self.assets and self.assets.palettes or {} + self.bgPals[1] = copyPalette(palettes.grassBg) + self.obPals[1] = copyPalette(palettes.grassOb) + self.obPals[2] = copyPalette(palettes.grassOb) + self.bgp, self.obp0 = 0xe4, 0xe4 + + -- Intro_InitJigglypuff. + self.anims:init("GS_INTRO_JIGGLYPUFF", 6 * 8, 14 * 8) + self.anims.flag = 0 +end + +-- Intro_InitNote: one note every 64 frames, and every other one is the +-- invisible variant that only leaves the little sparkle tile. +local function initNote(self) + if self.anims.flag ~= 0 then return end + if self.counter2 % 0x40 ~= 0 then return end + if self.counter2 % 0x80 ~= 0 then + self.anims:init("GS_INTRO_NOTE", 6 * 8, 11 * 8 + 4) + else + self.anims:init("GS_INTRO_INVISIBLE_NOTE", 6 * 8, 10 * 8 + 4) + end +end + +-- IntroScene7: scroll left to Jigglypuff. wGlobalAnimXOffset counts up as +-- hSCX counts down so the sprites hold still while the field slides. +Scenes[7] = function(self) + initNote(self) + local before = self.counter2 + self.counter2 = (self.counter2 + 1) % 256 + -- `and 3 / ret z`: the camera holds still one frame in four. + if before % 4 == 0 then return end + if self.scx ~= 0 then + self.scx = self.scx - 1 + self.anims.globalX = (self.anims.globalX + 1) % 256 + return + end + self.counter1 = 0xff + -- Intro_InitPikachu: body and tail are two objects at the same spot. + self.anims:init("GS_INTRO_PIKACHU", 24 * 8, 14 * 8) + self.anims:init("GS_INTRO_PIKACHU_TAIL", 24 * 8, 14 * 8) + self.scene = 8 +end + +-- IntroScene8: stop scrolling; Pikachu runs in and attacks. +Scenes[8] = function(self) + if self.counter1 ~= 0 then + self.counter1 = self.counter1 - 1 + initNote(self) + self.counter2 = (self.counter2 + 1) % 256 + return + end + self.counter1 = 0 + self.scene = 9 +end + +-- IntroScene9: scroll down and fade, one palette step every 8 frames. +Scenes[9] = function(self) + local step = math.floor(self.counter1 / 8) + 1 + self.counter1 = (self.counter1 + 1) % 256 + local palette = GRASS_FADE[step] + if not palette then + self.scene = 10 + return + end + self.bgp = palette + self.scy = (self.scy + 1) % 256 + self.anims.globalY = (self.anims.globalY - 1) % 256 +end + +-- DrawIntroCharizardGraphic: wipe tilemap rows 6-13, then fill a rectangle +-- with running tile ids and push the tilemap to the BG map. +local function drawCharizard(self, stage) + for row = 6, 13 do + for col = 0, TILEMAP_W - 1 do + self.tilemap[row * TILEMAP_W + col + 1] = 0 + end + end + local gfx = CHARIZARD_GFX[stage + 1] + local tile = gfx.tile + for row = 0, gfx.height - 1 do + for col = 0, gfx.width - 1 do + self.tilemap[(gfx.y + row) * TILEMAP_W + gfx.x + col + 1] = tile % 256 + tile = tile + 1 + end + end + for row = 0, TILEMAP_H - 1 do + for col = 0, TILEMAP_W - 1 do + mapSet(self, col, row, self.tilemap[row * TILEMAP_W + col + 1]) + end + end +end + +-- IntroScene10: set up the fireball cutscene. +Scenes[10] = function(self) + self.scene = 11 + self.act = "fire" + self.anims:clear() + resetLYOverrides(self) + for index = 1, BG_TILES * BG_TILES do self.bgmap[index] = 0 end + for index = 1, TILEMAP_W * TILEMAP_H do self.tilemap[index] = 0 end + self.mapDirty = true + drawCharizard(self, 0) + + self.scy = 0x80 + self.scx = 0 + self.anims.globalY, self.anims.globalX = 0, 0 + self.counter2 = 0 + + local palettes = self.assets and self.assets.palettes or {} + self.bgPals[1] = copyPalette(palettes.fireBg and palettes.fireBg[1]) + self.obPals[1] = copyPalette(palettes.startersOb) + self.obPals[2] = copyPalette(palettes.startersOb) + -- %00111111 paints the silhouette flat: every shade takes colour 3. + self.bgp = 0x3f + self.obp0 = 0xff + self:playMusic(INTRO_MUSIC_2) +end + +local function monColors(self, species) + return self.monPalettes and Palettes.monColors(self.monPalettes, species) + or nil +end + +-- Intro_LoadMonPalette (CGB): white, the mon's two colours, black, into OBJ +-- palette 0. Intro_LoadCharizardPalette deliberately uses Cyndaquil's on a +-- CGB and only reaches for Charizard's on a DMG. +local function loadMonPalette(self, species) + local colors = monColors(self, species) + if not colors then return end + self.obPals[1] = { WHITE, colors[2] or BLACK, colors[3] or BLACK, BLACK } +end + +local SCY_HANDLERS = { + loadChikorita = function(self) loadMonPalette(self, "CHIKORITA") end, + loadCyndaquil = function(self) loadMonPalette(self, "CYNDAQUIL") end, + loadTotodile = function(self) loadMonPalette(self, "TOTODILE") end, + loadCharizard = function(self) loadMonPalette(self, "CYNDAQUIL") end, + chikoritaAppears = function(self) + self:playSfx(SFX_APPEARS) + self.anims:init("GS_INTRO_CHIKORITA", 1 * 8, 22 * 8) + end, + cyndaquilAppears = function(self) + self:playSfx(SFX_APPEARS) + self.anims:init("GS_INTRO_CYNDAQUIL", 20 * 8, 22 * 8) + end, + totodileAppears = function(self) + self:playSfx(SFX_APPEARS) + self.anims:init("GS_INTRO_TOTODILE", 1 * 8, 22 * 8) + end, + -- Intro_FlashMonPalette shows the starter and blacks the silhouette out; + -- Intro_FlashSilhouette does the reverse. + flashMonPalette = function(self) + self.obp0 = 0xe4 + self.bgp = 0x00 + end, + flashSilhouette = function(self) + self.obp0 = 0xff + self.bgp = 0x3f + end, +} + +-- IntroScene11: climb to the silhouette, every other frame, firing the events +-- above as hSCY passes them. +Scenes[11] = function(self) + local before = self.counter2 + self.counter2 = (self.counter2 + 1) % 256 + if before % 2 == 0 then return end + local handler = SCY_HANDLERS[SCY_EVENTS[self.scy] or ""] + if handler then handler(self) end + if self.scy ~= 0 then + self.scy = (self.scy + 1) % 256 + return + end + self.scene = 12 + self.counter1 = 0 + return Scenes[12](self) +end + +-- IntroScene12: four Charizard palettes, four frames apiece, ending on the +-- one that reads as $00 and moves the scene on. +Scenes[12] = function(self) + local step = math.floor(self.counter1 / 4) % 4 + 1 + self.counter1 = (self.counter1 + 1) % 256 + local palette = CHARIZARD_PALS[step] + if palette == 0 then + self.scene = 13 + self.counter1 = 0x80 + return + end + self.bgp = palette + self.obp0 = palette +end + +-- IntroScene13: hold, then open the mouth. +Scenes[13] = function(self) + if self.counter1 ~= 0 then + self.counter1 = self.counter1 - 1 + return + end + self.scene = 14 + drawCharizard(self, 1) + self.counter1 = 4 +end + +-- IntroScene14: hold four frames, then breathe. +Scenes[14] = function(self) + if self.counter1 ~= 0 then + self.counter1 = self.counter1 - 1 + return + end + self.scene = 15 + drawCharizard(self, 2) + self.counter1 = 64 + self.counter2 = 0 + self:playSfx(SFX_FIREBALL) + return Scenes[15](self) +end + +-- Intro_AnimateFireball: a new fireball every 4 frames, each one leaving the +-- mouth on its own angle, while the field slides left underneath them. +local function animateFireball(self) + local before = self.counter2 + self.counter2 = (self.counter2 + 1) % 256 + if before % 4 ~= 0 then return end + self.anims:init("GS_INTRO_FIREBALL", 10 * 8 + 4, 12 * 8 + 4) + self.scx = (self.scx - 1) % 256 + self.anims.globalX = (self.anims.globalX + 1) % 256 +end + +-- IntroScene15: 64 frames of fireball. +Scenes[15] = function(self) + animateFireball(self) + if self.counter1 ~= 0 then + self.counter1 = self.counter1 - 1 + return + end + self.scene = 16 + self.counter1 = 0 +end + +-- IntroScene16: keep the fireball going while the palettes fade out. +Scenes[16] = function(self) + animateFireball(self) + local step = math.floor(self.counter1 / 16) % 8 + 1 + self.counter1 = (self.counter1 + 1) % 256 + local palette = FIRE_FADE[step] + if not palette then + self.scene = 17 + self.hold = 0 + return + end + self.bgp = palette + self.obp0 = palette +end + +-- IntroScene17: 64 frames of black, then the done flag. The cart spends them +-- in a blocking `ld c, 64` loop rather than a counter, so this one gets a +-- field of its own. +Scenes[17] = function(self) + self.hold = (self.hold or 0) + 1 + if self.hold >= 64 then self.done = true end +end + +-------------------------------------------------------------------------- +-- Frame loop +-------------------------------------------------------------------------- + +-- GoldSilverIntro.PlayFrame, minus the joypad read: sprite animations first, +-- then the scene. Returns true once the movie is over. +function GoldSilverIntro:step() + if self.done then return true end + self.frames = self.frames + 1 + self.anims:playFrame() + local scene = Scenes[self.scene] + if scene then scene(self) end + return self.done +end + +function GoldSilverIntro:enter() + -- IntroScene1 is what starts the music, and it has not run yet. + -- + -- intro.boot.movie: the attract movie is up, on frame zero of scene 1. Gen 2 + -- only, like the rest of intro.boot.* (see src/ui/gen2/CopyrightSplash.lua): + -- Red's IntroMovie is a different sequence on a different screen and shares + -- no moment with this one. + if Runtime.wants("intro.boot.movie") then + Runtime.emit("intro.boot.movie", { screen = self, game = self.game }) + end +end + +function GoldSilverIntro:finish() + if self.finished then return end + self.finished = true + self.done = true + -- intro.boot.movie_ended is the one card end that earns a name of its own. + -- The other three hand off to a card that announces itself, but this one has + -- a fact nothing downstream carries: whether the player sat through all 2335 + -- frames or cut it short (GoldSilverIntro.PlayFrame's PAD_BUTTONS exit). + 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 + +function GoldSilverIntro:skip() + self.skipped = true + self:finish() +end + +function GoldSilverIntro: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 GoldSilverIntro: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 + +-- A tile sheet is 16 tiles wide, so tile id N is at (N % 16, N / 16). Quads +-- are built once per sheet. +function GoldSilverIntro: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 + +local function bgPalette(self) + return remap(self.bgPals[1], self.bgp) +end + +local function objPalette(self, slot) + return remap(self.obPals[slot + 1] or self.obPals[1], self.obp0) +end + +-- The BG map is rendered into a 256x256 canvas and only redrawn when a scene +-- edits the map, which is what keeps the per-scanline present cheap. +function GoldSilverIntro:bgCanvas() + local source = actData(self) + local sheet = source and self:sheet(source.tiles) + if not sheet then return nil end + if self.canvasSheet ~= sheet then + self.canvas = nil + self.canvasSheet = sheet + self.mapDirty = true + end + if not self.canvas then + local ok, canvas = pcall(love.graphics.newCanvas, BG_PIXELS, BG_PIXELS) + if not ok then return nil end + canvas:setFilter("nearest", "nearest") + self.canvas = canvas + self.mapDirty = true + end + if self.mapDirty then + local G = love.graphics + local previous = G.getCanvas() + -- The caller is inside the renderer's letterbox transform; a canvas does + -- not reset it, so the map would land scaled and off the edge. + G.push() + G.origin() + G.setCanvas(self.canvas) + G.clear(0, 0, 0, 0) + G.setColor(1, 1, 1, 1) + for row = 0, BG_TILES - 1 do + for col = 0, BG_TILES - 1 do + local quad = sheet.quads[mapGet(self, col, row)] + if quad then G.draw(sheet.image, quad, col * 8, row * 8) end + end + end + G.setCanvas(previous) + G.pop() + self.mapDirty = false + end + return self.canvas +end + +-- Present the canvas at (hSCX, hSCY), wrapping both ways. With the LY +-- overrides live each scanline gets its own hSCY, which is the water's bend. +function GoldSilverIntro:drawBackground() + local canvas = self:bgCanvas() + if not canvas then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local scx = self.scx % BG_PIXELS + if not self.lyActive then + local scy = 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 scy = (self.lyOverrides[line + 1] + line) % BG_PIXELS + self.lineQuad:setViewport(0, scy, 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 + +-- One pass over wShadowOAM. `priority` picks the objects that go behind the +-- BG's opaque pixels (OAM_PRIO), which is what half-submerges Lapras. +function GoldSilverIntro:drawObjects(priority) + local source = actData(self) + local sheet = source and self:sheet(source.sprites) + if not sheet 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 = entry.attr >= SpriteAnims.OAM_PRIO + if behind == priority then + local quad = sheet.quads[entry.tile] + if quad then + local slot = entry.attr % 8 + if shader and current ~= slot then + GbcPalette.use(objPalette(self, slot)) + current = slot + end + local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1 + local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1 + 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 + +-- Compose one 160x144 frame. It goes through a canvas of its own because the +-- BG is a 256x256 map drawn at a scroll offset and OBJs hang off both edges: +-- on hardware the LCD simply stops at the screen, and this is what stops. +function GoldSilverIntro:renderFrame() + local G = love.graphics + local pal = bgPalette(self) + -- Priming the BG canvas first keeps the two setCanvas calls from nesting. + self:bgCanvas() + 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) + -- BG colour 0 is the backdrop; the tile sheets are written with shade 0 + -- transparent so a priority OBJ shows through exactly where the hardware + -- would let it. + local backdrop = GbcPalette.color(pal, 1) or BLACK + G.clear(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.setColor(1, 1, 1, 1) + self:drawObjects(true) + if GbcPalette.available() then + GbcPalette.with(pal, function() self:drawBackground() end) + else + self:drawBackground() + end + self:drawObjects(false) + G.setCanvas(previous) + G.pop() + return self.frameCanvas +end + +function GoldSilverIntro: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 GoldSilverIntro:draw() + self:drawPanel() +end + +function GoldSilverIntro:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +GoldSilverIntro.Scenes = Scenes +GoldSilverIntro.WATER_FADE = WATER_FADE +GoldSilverIntro.GRASS_FADE = GRASS_FADE +GoldSilverIntro.CHARIZARD_PALS = CHARIZARD_PALS +GoldSilverIntro.FIRE_FADE = FIRE_FADE +GoldSilverIntro.SCY_EVENTS = SCY_EVENTS +GoldSilverIntro.WAVE_TILES = WAVE_TILES +GoldSilverIntro.CHARIZARD_GFX = CHARIZARD_GFX +GoldSilverIntro.remap = remap + +return GoldSilverIntro diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua new file mode 100644 index 00000000..6f8e3947 --- /dev/null +++ b/src/ui/gen2/HallOfFame.lua @@ -0,0 +1,676 @@ +-- The Hall of Fame (pokegold engine/events/halloffame.asm), both halves of it: +-- the induction the champion script runs, and the roster viewer the PC opens. +-- +-- Two entry points on the cart, one screen here because they share every +-- drawing routine: +-- +-- AnimateHallOfFame each party member enters (AnimateHOFMonEntrance), +-- gets DisplayHOFMon plus "New Hall of Famer!", its cry +-- and 180 frames, then HOF_AnimatePlayerPic ends on the +-- player's own card +-- _HallOfFamePC LoadHOFTeam walks the roster newest first and +-- DisplayHOFMon shows one mon at a time; A is next mon, +-- START is next team, B leaves +-- +-- THE ENTRANCE IS A SCROLL, not a sprite move. AnimateHOFMonEntrance blanks +-- the whole tilemap, lays ONE pic into it, and then animates hSCX and hSCY: +-- because nothing else is on screen, scrolling the background IS sliding the +-- pic, and that is how it is drawn here. The two loops are exact -- +-- +-- HOF_SlideBackpic hSCX $90, +4 a frame until it reads $70 (56 frames, +-- the long way round the byte) +-- HOF_SlideFrontpic hSCX -2 a frame until it reads 0 +-- +-- -- so the mon's BACK pic sweeps across the screen and off the left, and then +-- the pan reverses and its FRONT pic comes back in from the left and settles +-- at hlcoord 6, 5. hSCY goes $d0 -> $00 between the two, which is the same +-- wrap-around trick vertically. +-- +-- COORDINATES are taken literally off the hlcoord lines, never laid out by +-- eye, and the placements are built as data so tests/gen2_halloffame_test.lua +-- 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 +-- 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. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +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 Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") +local TileSheet = require("src.ui.gen2.TileSheet") +local Unown = require("src.core.gen2.Unown") + +local HallOfFame = {} +HallOfFame.__index = HallOfFame +HallOfFame.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- Where each pic lands. AnimateHOFMonEntrance puts the backpic at hlcoord +-- 6, 6 and the frontpic at hlcoord 6, 5; HOF_AnimatePlayerPic puts the +-- player's backpic at 6, 6 and the trainer pic at 12, 5. +local BACKPIC_X, BACKPIC_Y = 6, 6 +local FRONTPIC_X, FRONTPIC_Y = 6, 5 +local TRAINERPIC_X, TRAINERPIC_Y = 12, 5 +local PIC_TILES = 7 + +-- The scroll registers the two slide loops walk. +local SCY_START = 0xd0 +local BACKPIC_SCX_START, BACKPIC_SCX_END, BACKPIC_STEP = 0x90, 0x70, 4 +local FRONTPIC_STEP = 2 +local TRAINER_SCX_START = 0xc0 + +-- .DisplayNewHallOfFamer: `ld c, 180 / call DelayFrames` after the cry. +local FAMER_FRAMES = 180 +-- AnimateHallOfFame .done: RotateThreePalettesRight, then `ld c, 8`. +local END_FRAMES = 8 + +local HOF_MUSIC = "Music_HallOfFame" + +-- The three strings the two modes print at hlcoord 1, 2. Their leading +-- spaces are load bearing: PrintNum writes the count over the first of them +-- at hlcoord 2, 2, so " -Time Famer" becomes " 12-Time Famer". +HallOfFame.NEW_FAMER = "New Hall of Famer!" +HallOfFame.TIME_FAMER = " -Time Famer" +HallOfFame.HOF_MASTER = " HOF Master!" + +-- ...and the labels they live under in the cart. All three are plain +-- `db "…@"` inside engine/events/halloffame.asm rather than text streams, so +-- nothing but the routine that prints them names them. +local LABELS = { + NEW_FAMER = "AnimateHallOfFame.String_NewHallOfFamer", + TIME_FAMER = "_HallOfFamePC.TimeFamer", + HOF_MASTER = "_HallOfFamePC.HOFMaster", +} + +HallOfFame.LABELS = LABELS + +-- PadFrontpic centres a 5x5 or 6x6 pic inside the 7x7 block, same table the +-- stats screen and the dex use. +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +local WHITE = { 255, 255, 255 } + +-------------------------------------------------------------------------- +-- Placements +-------------------------------------------------------------------------- + +local function put(list, text, x, y) + if text == nil then return list end + list[#list + 1] = { text = tostring(text), x = x, y = y } + return list +end + +-- The text a placement list writes at a coordinate, or nil. +function HallOfFame.at(placements, x, y) + for _, entry in ipairs(placements or {}) do + if entry.x == x and entry.y == y then return entry.text end + end + return nil +end + +local function levelText(level) + level = math.max(1, math.floor(tonumber(level) or 1)) + -- PrintLevel: a three-digit level does `dec hl` first so the digits + -- overwrite the <LV> tile and the field starts at the same column. + if level >= 100 then return tostring(level) end + return "<LV>" .. tostring(level) +end + +-- GetGender's three answers: carry for a genderless species (a space is +-- written), then non-zero male, zero female. +local function genderGlyph(gender) + if gender == "male" then return "♂" end + if gender == "female" then return "♀" end + return nil +end + +-- DisplayHOFMon, hlcoord for hlcoord. `def` is the species row out of +-- pokemon.lua, for the dex number and the base name the routine gets from +-- GetBasePokemonName -- the roster keeps the NICKNAME, so the species name +-- has to be looked up rather than stored. +function HallOfFame.monPlacements(mon, def) + mon = mon or {} + local out = {} + -- `.print_id_no` is jumped to for an EGG, so everything above it is skipped + -- and only the ID line prints. The roster never stores an egg + -- (GetHallOfFameParty skips them), but the branch is the routine's. + if mon.species ~= "EGG" then + -- (1,13) '№' and (2,13) '.' are two `ld [hli]` writes. + put(out, "№.", 1, 13) + put(out, Chrome.number(def and def.dex or 0, 3, true), 3, 13) + put(out, (def and def.name) or mon.species, 7, 13) + put(out, genderGlyph(mon.gender), 18, 13) + -- (8,14) is a bare '/', so the nickname starts at (9,14). + put(out, "/", 8, 14) + put(out, mon.nickname or mon.species, 9, 14) + put(out, levelText(mon.level), 1, 16) + end + -- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16). + put(out, "<ID>№/", 7, 16) + put(out, Chrome.number(mon.otId or 0, 5, true), 10, 16) + return out +end + +-- The header line, which is the only thing that differs between the induction +-- and the PC's viewer. +-- +-- BUG (docs/bugs_and_glitches.md): "HOF Master!" is compared against +-- HOF_MASTER_COUNT + 1 while the counter itself stops AT HOF_MASTER_COUNT, so +-- the title can never print. Transcribed with the off-by-one intact. +-- +-- `text` is data/generated/text.lua: the three strings are seeded into it by +-- name (RomExtractorGen2's NAMED_TEXT) because nothing points at them, and the +-- constants above are what a cache built before that seed falls back to. +function HallOfFame.headerPlacements(mode, winCount, text) + local out = {} + local function line(label, fallback) + local extracted = CommonText.get(text, label) + return extracted or fallback + end + if mode == "induct" then + put(out, line(LABELS.NEW_FAMER, HallOfFame.NEW_FAMER), 1, 2) + return out + end + winCount = tonumber(winCount) or 0 + if winCount >= Core.MASTER_COUNT + 1 then + put(out, line(LABELS.HOF_MASTER, HallOfFame.HOF_MASTER), 1, 2) + return out + end + put(out, line(LABELS.TIME_FAMER, HallOfFame.TIME_FAMER), 1, 2) + put(out, Chrome.number(winCount, 3), 2, 2) + return out +end + +-- HOF_AnimatePlayerPic's text, once both pics have finished sliding. +function HallOfFame.playerPlacements(save) + save = save or {} + local player = save.player or {} + local time = save.playTime or {} + local out = {} + put(out, player.name or "GOLD", 2, 4) + put(out, "<ID>№/", 1, 6) + put(out, Chrome.number(player.id or 0, 5, true), 4, 6) + put(out, "PLAY TIME", 1, 8) + -- `ld de, wGameTimeHours / lb bc, 2, 3`: a two-byte value in three columns, + -- then HALLOFFAME_COLON, then the minutes with leading zeros in two. + put(out, Chrome.number(time.hours or 0, 3), 3, 9) + put(out, ":", 6, 9) + put(out, Chrome.number(time.minutes or 0, 2, true), 7, 9) + return out +end + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function HallOfFame:wantsFillScale() return true end +function HallOfFame:drawsWidescreen() return true end + +-- opts: +-- mode "induct" (default) or "view" +-- save the record; the roster and the player card come off it +-- entry induct only: the row HallOfFame.induct just built. Passed in +-- rather than re-read so the screen shows the party that walked +-- in even if a later save rewrites the roster. +-- text text.lua, for the extracted header strings +-- onDone() induct: the credits follow. view: the PC menu comes back. +function HallOfFame.new(game, opts) + opts = opts or {} + local self = setmetatable({}, HallOfFame) + self.game = game + local data = (game and game.data) or {} + self.data = data + self.save = opts.save or (game and game.save) + self.mode = opts.mode or "induct" + self.onDone = opts.onDone + -- text.lua, for the three header strings. The induction is pushed from the + -- champion's script and the viewer from the PC, so the world is under both. + self.textData = opts.text or (game and game.world and game.world.text) + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes + self.picCache = {} + self.frames = 0 + self.done = false + + -- Hardware registers, the only state the entrance animation has. + self.scx, self.scy = 0, 0 + + -- 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 {} + -- 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, + { side = "back", kind = "hof", data = data }) + local card = menuGfx.trainerCard + if card and card.card then + self.portrait = TileSheet.new({ + path = card.card, wide = card.cardTilesWide or 16, firstTile = 0, + }) + self.portraitWide = card.portraitWide or 5 + self.portraitTiles = card.portraitTiles or 35 + end + + if self.mode == "view" then + -- _HallOfFamePC: wJumptableIndex is the team, wHallOfFameMonCounter the + -- mon inside it, and both start at zero. An empty roster makes LoadHOFTeam + -- return carry on its first call, which ends the screen -- but onDone + -- usually pops this state off the stack, and it has not been pushed yet, so + -- the callback is deferred to the first update. + self.team = 1 + self.index = 1 + self.constructing = true + self:enterView() + self.constructing = nil + else + self.entry = opts.entry or Core.team(self.save, 1) + self.index = 1 + self:playMusic(HOF_MUSIC) + self:enterMon() + end + return self +end + +-------------------------------------------------------------------------- +-- Audio +-------------------------------------------------------------------------- + +function HallOfFame:playMusic(song) + local audio = self.data and self.data.audio + if audio and audio.songs and audio.songs[song] then + -- HallOfFame_PlayMusicDE plays MUSIC_NONE for a frame first, so nothing + -- of the previous song survives the switch. + Music.stop() + Music.play(self.data, song, true, { reason = "halloffame" }) + end +end + +function HallOfFame:playCry(species) + if not species then return end + local cries = self.data and self.data.audio and self.data.audio.cries + if cries and cries[species] then Sound.playCry(self.data, species) end +end + +-------------------------------------------------------------------------- +-- Phases +-------------------------------------------------------------------------- + +-- The mon the current phase is about, whichever mode is running. +function HallOfFame:currentMon() + local entry = self.entry + return entry and entry.mons and entry.mons[self.index] or nil +end + +-- AnimateHOFMonEntrance: blank the screen, load the backpic, hSCY $d0 and +-- hSCX $90, then HOF_SlideBackpic. +function HallOfFame:enterMon() + local mon = self:currentMon() + if not mon then return self:enterPlayer() end + self.phase = "backpic" + self.scy = SCY_START + self.scx = BACKPIC_SCX_START + self.timer = 0 +end + +-- _HallOfFamePC .DisplayMonAndStrings: no entrance at all, the mon is simply +-- displayed. A team that has run out, or a roster that has, ends the screen. +function HallOfFame:enterView() + self.entry = Core.team(self.save, self.team) + if not self.entry then return self:finish() end + local mon = self:currentMon() + if not mon then + -- `.fail` -> carry -> .start_button -> the next team. + self.team = self.team + 1 + self.index = 1 + return self:enterView() + end + self.phase = "display" + self.scx, self.scy = 0, 0 + self:playCry(mon.species) +end + +function HallOfFame:enterDisplay() + self.phase = "display" + self.scx, self.scy = 0, 0 + self.timer = FAMER_FRAMES + self:playCry((self:currentMon() or {}).species) +end + +-- HOF_AnimatePlayerPic, which is where AnimateHallOfFame's .done arm lands +-- once the party has run out. +function HallOfFame:enterPlayer() + self.phase = "playerBack" + self.scy = SCY_START + self.scx = BACKPIC_SCX_START + -- `ld a, $4 / ld [wMusicFade], a` happens at .done, after the player pic; + -- the music runs under the whole card until then. +end + +function HallOfFame:finish() + if self.done then return end + self.done = true + if self.constructing then + self.pendingDone = true + return + end + if self.onDone then self.onDone() end +end + +-------------------------------------------------------------------------- +-- Frame loop +-------------------------------------------------------------------------- + +-- One frame of whichever slide is running. Returns true when it is over. +local function slideBackpic(self) + if self.scx == BACKPIC_SCX_END then return true end + self.scx = (self.scx + BACKPIC_STEP) % 256 + return self.scx == BACKPIC_SCX_END +end + +local function slideFrontpic(self) + if self.scx == 0 then return true end + self.scx = (self.scx - FRONTPIC_STEP) % 256 + return self.scx == 0 +end + +-- The induction's own step, one frame at a time. Split out so the driver and +-- the test can run the whole cinematic without a graphics device. +function HallOfFame:step() + if self.done then return true end + self.frames = self.frames + 1 + + if self.phase == "backpic" then + if slideBackpic(self) then + -- The frontpic is prepared and hSCY zeroed before HOF_SlideFrontpic. + self.phase = "frontpic" + self.scy = 0 + end + return false + end + + if self.phase == "frontpic" then + if slideFrontpic(self) then self:enterDisplay() end + return false + end + + if self.phase == "display" then + if self.mode == "view" then return false end + self.timer = self.timer - 1 + if self.timer > 0 then return false end + -- `inc [hl]` on wHallOfFameMonCounter, then round the loop; a counter that + -- reaches PARTY_LENGTH or a -1 species ends it. + self.index = self.index + 1 + if self.index > Core.PARTY_LENGTH or not self:currentMon() then + self:enterPlayer() + else + self:enterMon() + end + return false + end + + if self.phase == "playerBack" then + if slideBackpic(self) then + self.phase = "playerFront" + self.scy = 0 + self.scx = TRAINER_SCX_START + end + return false + end + + if self.phase == "playerFront" then + if slideFrontpic(self) then + self.phase = "player" + self.timer = END_FRAMES + -- wMusicFade = 4: the Hall of Fame theme rings out under the card. + Music.fadeOut(4) + end + return false + end + + if self.phase == "player" then + self.timer = self.timer - 1 + if self.timer <= 0 then self:finish() end + return self.done + end + + return false +end + +-- _HallOfFamePC's joypad arms. A is the next mon, START the next team, B out. +function HallOfFame:viewInput(input) + if not input then return end + if input:wasPressed("b") then return self:finish() end + if input:wasPressed("start") then + self.team = self.team + 1 + self.index = 1 + return self:enterView() + end + if input:wasPressed("a") then + self.index = self.index + 1 + if self.index > Core.PARTY_LENGTH or not self:currentMon() then + self.team = self.team + 1 + self.index = 1 + end + return self:enterView() + end +end + +function HallOfFame:update(_dt) + if self.pendingDone then + self.pendingDone = nil + if self.onDone then self.onDone() end + return + end + if self.done then return end + if self.mode == "view" then + self.frames = self.frames + 1 + return self:viewInput(self.game and self.game.input) + end + self:step() +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function HallOfFame:image(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil +end + +function HallOfFame:speciesDef(species) + return species and self.pokemon and self.pokemon[species] or nil +end + +-- Takes the RECORD, not the species: the entry carries the two DV bytes +-- precisely so the viewer can name the form. HOF_ShowMonOrPlayer copies them +-- into wTempMonDVs and runs `predef GetUnownLetter` before GetMonBackpic +-- (engine/events/halloffame.asm:225-238); the frontpic path at :458-468 does +-- the same before _PrepMonFrontpic. +function HallOfFame:monPic(mon, back) + local def = self:speciesDef(mon and mon.species) + if not def then return nil end + local path = back and def.spriteBack or def.spriteFront + if mon.species == Unown.SPECIES then + path = Unown.formSprite(self.pokemon, Unown.monLetter(mon), back) or path + end + return self:image(path) +end + +function HallOfFame:monColors(mon) + if not (self.palettes and mon and mon.species) then return nil end + return Palettes.monColors(self.palettes, mon.species, mon.shiny) +end + +-- hSCX / hSCY applied to one screen-space pixel coordinate. The BG map wraps +-- every 256 pixels, so a pic mid-sweep can be visible on both edges at once +-- and both copies are drawn. +local function scrolled(base, register) + local value = (base - register) % 256 + return value, value - 256 +end + +-- Draw an image at a tile coordinate through the current scroll, padded into +-- the 7x7 block the way PlaceGraphic pads it. +function HallOfFame:drawScrolled(image, tileX, tileY, colors) + if not image then return end + local G = love.graphics + local wide = math.floor(image:getWidth() / 8) + local pad = PIC_PAD[wide] or PIC_PAD[PIC_TILES] + local baseX = tileX * 8 + pad[1] * 8 + local baseY = tileY * 8 + pad[2] * 8 + local x1, x2 = scrolled(baseX, self.scx) + local y1, y2 = scrolled(baseY, self.scy) + G.setColor(1, 1, 1, 1) + local function body() + for _, x in ipairs({ x1, x2 }) do + for _, y in ipairs({ y1, y2 }) do + G.draw(image, x, y) + end + end + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- The player's front picture: the trainer card's 5x7 portrait, laid out as the +-- run of tile ids TrainerCard_PrintTopHalfOfCard uses, translated by the +-- scroll rather than drawn at a tile coordinate. +function HallOfFame:drawPortrait(tileX, tileY) + if not (self.portrait and self.portrait:available()) then return end + local G = love.graphics + local wide = self.portraitWide + local high = math.floor((self.portraitTiles or 35) / wide) + -- A 5x7 portrait standing in for a 7x7 pic sits on the same ground line and + -- centred, which is what PadFrontpic would have done to it. + local padX = math.floor((PIC_TILES - wide) / 2) + local padY = PIC_TILES - high + local baseX = (tileX + padX) * 8 + local baseY = (tileY + padY) * 8 + local x1, x2 = scrolled(baseX, self.scx) + local y1, y2 = scrolled(baseY, self.scy) + for _, x in ipairs({ x1, x2 }) do + for _, y in ipairs({ y1, y2 }) do + G.push() + G.translate(x, y) + self.portrait:block(0, wide, high, 0, 0) + G.pop() + end + end +end + +function HallOfFame:drawPlacements(list) + for _, entry in ipairs(list or {}) do + Chrome.print(entry.text, entry.x, entry.y) + end +end + +-- The two boxes DisplayHOFMon draws: `lb bc, 3, SCREEN_WIDTH - 2` at (0,0) and +-- `lb bc, 4, 18` at (0,12). Textbox takes INTERIOR rows and columns, so those +-- are 20x5 and 20x6 on screen. +function HallOfFame:drawMonPanel() + local mon = self:currentMon() + Chrome.clear() + Chrome.textbox(0, 0, 18, 3) + Chrome.textbox(0, 12, 18, 4) + local def = mon and self:speciesDef(mon.species) + self:drawScrolled(self:monPic(mon, false), + FRONTPIC_X, FRONTPIC_Y, self:monColors(mon)) + self:drawPlacements(HallOfFame.headerPlacements(self.mode, + self.entry and self.entry.winCount, self.textData)) + self:drawPlacements(HallOfFame.monPlacements(mon, def)) +end + +-- HOF_AnimatePlayerPic's card: `lb bc, 8, 9` at (0,2) and `lb bc, 4, 18` at +-- (0,12). The bottom box is left empty; ProfOaksPCRating is what fills it. +function HallOfFame:drawPlayerPanel() + Chrome.clear() + Chrome.textbox(0, 2, 9, 8) + Chrome.textbox(0, 12, 18, 4) + self:drawPortrait(TRAINERPIC_X, TRAINERPIC_Y) + self:drawPlacements(HallOfFame.playerPlacements(self.save)) +end + +function HallOfFame:drawPanel() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + + -- Both entry points put FontBattleExtra in the $60 slot before anything is + -- placed: InitDisplayForHallOfFame (engine/movie/init_hof_credits.asm) for + -- the induction, LoadFontsBattleExtra at the top of _HallOfFamePC for the + -- viewer. '№' ($74) and '<ID>' ($73) are that sheet's glyphs, and on the + -- normal extra sheet those codes are a middle dot and a closing quote. + local wasBattle = Font.useBattleExtra(true) + + if self.phase == "backpic" then + local mon = self:currentMon() + self:drawScrolled(self:monPic(mon, true), + BACKPIC_X, BACKPIC_Y, self:monColors(mon)) + elseif self.phase == "frontpic" then + local mon = self:currentMon() + self:drawScrolled(self:monPic(mon, false), + FRONTPIC_X, FRONTPIC_Y, self:monColors(mon)) + elseif self.phase == "display" then + self:drawMonPanel() + elseif self.phase == "playerBack" then + self:drawScrolled(self:image(self.playerBackPath), BACKPIC_X, BACKPIC_Y, + nil) + elseif self.phase == "playerFront" then + self:drawPortrait(TRAINERPIC_X, TRAINERPIC_Y) + elseif self.phase == "player" then + self:drawPlayerPanel() + end + Font.useBattleExtra(wasBattle) + G.setColor(1, 1, 1, 1) +end + +function HallOfFame:draw() + self:drawPanel() +end + +function HallOfFame:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +HallOfFame.WHITE = WHITE +HallOfFame.PIC_PAD = PIC_PAD +HallOfFame.FAMER_FRAMES = FAMER_FRAMES +HallOfFame.END_FRAMES = END_FRAMES +HallOfFame.SCY_START = SCY_START +HallOfFame.BACKPIC_SCX_START = BACKPIC_SCX_START +HallOfFame.BACKPIC_SCX_END = BACKPIC_SCX_END +HallOfFame.TRAINER_SCX_START = TRAINER_SCX_START + +return HallOfFame diff --git a/src/ui/gen2/HeldItemMenu.lua b/src/ui/gen2/HeldItemMenu.lua new file mode 100644 index 00000000..559412dc --- /dev/null +++ b/src/ui/gen2/HeldItemMenu.lua @@ -0,0 +1,399 @@ +-- GiveTakePartyMonItem (engine/pokemon/mon_menu.asm): the GIVE / TAKE menu the +-- party submenu's ITEM row opens, and the two routines behind it. +-- +-- It is here because MAIL cannot be reached without it. Attaching a letter to +-- a mon is GivePartyItem -> ComposeMailMessage and nothing else on the cart -- +-- the PACK has no "use" for mail (ItemAttributes gives it ITEMMENU_NOUSE in +-- both menus), the MAILBOX only ever MOVES a letter that already exists, and +-- `givepokemail` is one scripted gift on Route 35. So the compose keyboard's +-- only door is this one. +-- +-- Drawn over the party list (`menu_coords 12, 12, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1`), so this state is not opaque. +-- +-- GIVE .GiveItem: DepositSellPack, then +-- - a KEY_ITEM or an untossable item is ItemCantHeldText and the PACK +-- comes straight back (`jr .next` -> `.loop`) +-- - an empty-handed mon takes it (GiveItemToPokemon), and if the item +-- is MAIL the compose keyboard opens on top (GivePartyItem's +-- `farcall ItemIsMail / call ComposeMailMessage`) +-- - a mon already holding MAIL is refused with +-- _PokemonRemoveMailText, BEFORE the swap question is asked -- so +-- there is no way to knock a letter off a mon by accident +-- - anything else is the swap question +-- TAKE TakePartyItem: the item goes back to the bag, or +-- _ItemStorageFullText when it will not fit +-- +-- An EGG never gets here at all: GiveTakePartyMonItem's first two lines are +-- `cp EGG / jr z, .cancel`. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local CommonText = require("src.core.gen2.CommonText") +local Mail = require("src.core.gen2.Mail") +local Screens = require("src.ui.Screens") + +local HeldItemMenu = {} +HeldItemMenu.__index = HeldItemMenu +HeldItemMenu.isOpaque = false + +-- GiveTakeItemMenuData: menu_coords 12, 12, 19, 17, STATICMENU_CURSOR and no +-- NO_TOP_SPACING, so GIVE is at (14,14) and TAKE two rows under it. +local MENU_X, MENU_Y, MENU_W, MENU_H = 12, 12, 8, 6 +local MENU_LABEL_X, MENU_LABEL_Y = MENU_X + 2, MENU_Y + 2 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +local ENTRIES = { + { id = "give", label = "GIVE" }, + { id = "take", label = "TAKE" }, +} + +local function page(...) return { ... } end +local function pages(...) return { ... } end + +-- data/text/common_2.asm. The {STRBUF} markers are text_ram fields, spliced +-- here rather than being part of the string. +local TEXT = { + hold = function(mon, item) + return pages(page(("Made %s"):format(mon), ("hold %s."):format(item))) + end, + removeMail = pages(page("Please remove the", "MAIL first.")), + notHolding = function(mon) + return pages(page(("%s isn't"):format(mon), "holding anything.")) + end, + storageFull = pages(page("Item storage space", "full.")), + tookItem = function(item, mon) + return pages(page(("Took %s"):format(item), ("from %s."):format(mon))) + end, + askSwap = function(mon, item) + return pages( + page(("%s is"):format(mon), "already holding"), + page(("%s."):format(item), "Switch items?")) + end, + swapped = function(mon, old, new) + return pages( + page(("Took %s's"):format(mon), ("%s and"):format(old)), + page("made it hold", ("%s."):format(new))) + end, + cantHold = pages(page("This item can't be", "held.")), +} + +local LABELS = { + hold = "_PokemonHoldItemText", + removeMail = "_PokemonRemoveMailText", + notHolding = "_PokemonNotHoldingText", + storageFull = "_ItemStorageFullText", + tookItem = "_PokemonTookItemText", + askSwap = "_PokemonAskSwapItemText", + swapped = "_PokemonSwapItemText", + cantHold = "_ItemCantHeldText", +} + +-- Each formatted entry's markers, in the order the ASM string names them. +local FILL = { + hold = function(mon, item) return { mon, item } end, + notHolding = function(mon) return { mon } end, + tookItem = function(item, mon) return { item, mon } end, + askSwap = function(mon, item) return { mon, item } end, + swapped = function(mon, old, new) return { mon, old, new } end, +} + +HeldItemMenu.TEXT = TEXT +HeldItemMenu.LABELS = LABELS +HeldItemMenu.ENTRIES = ENTRIES + +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then + local fill = FILL[key] + if fill then + out[key] = function(...) return CommonText.fill(list, fill(...)) end + else + out[key] = list + end + end + end + return out +end + +-- opts: save, slot, items (items.lua), text (text.lua), onClose() +function HeldItemMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, HeldItemMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.slot = opts.slot or 1 + self.items = opts.items or (game and game.data and game.data.items) + self.textData = opts.text or (game and game.world and game.world.text) + self.TEXT = extractedText(self.textData) + self.onClose = opts.onClose + self.index = 1 + self.busy = false + return self +end + +function HeldItemMenu:mon() + return self.save and self.save.party and self.save.party[self.slot] +end + +function HeldItemMenu:monName() + local mon = self:mon() + if not mon then return "#MON" end + return mon.nickname or mon.name or mon.species or "#MON" +end + +function HeldItemMenu:itemName(id) + local def = id and self.items and self.items[id] + return (def and def.name) or id or "?" +end + +function HeldItemMenu:close() + if self.onClose then self.onClose() end +end + +function HeldItemMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function HeldItemMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +-- ------------------------------------------------------------------- GIVE + +-- .GiveItem's `cp KEY_ITEM_POCKET` and CheckTossableItem: a key item or an +-- item whose attributes forbid tossing cannot be held, and the PACK reopens +-- rather than the menu closing. +function HeldItemMenu:canHold(itemId) + local def = itemId and self.items and self.items[itemId] + if def and def.pocket == "KEY_ITEM" then return false end + if def and def.canToss == false then return false end + return true +end + +function HeldItemMenu:openPack() + local game = self.game + if not (game and game.stack) then return self:close() end + self.busy = true + Screens.push(game, "Gen2PackMenu", { + save = self.save, + -- DepositSellPack: the PACK is a chooser here, so a field item must not + -- run its effect on the way past. + give = true, + onChoose = function(itemId) + game.stack:pop() + self.busy = false + self:giveItem(itemId) + end, + onClose = function() + game.stack:pop() + self.busy = false + -- `.quit`: backing out of the PACK ends the whole GIVE. + self:close() + end, + }) +end + +-- TryGiveItemToPartymon, in its own order. +function HeldItemMenu:giveItem(itemId) + local mon = self:mon() + if not (mon and itemId) then return self:close() end + if not self:canHold(itemId) then + return self:say(self.TEXT.cantHold, function() self:openPack() end) + end + local held = mon.item + if held and Mail.isMail(held) then + -- .please_remove_mail: a `ret`, so the whole GIVE ends here. + return self:say(self.TEXT.removeMail, function() self:close() end) + end + if not held then + Bag.remove(self.save, itemId, 1) + mon.item = itemId + local name = self:itemName(itemId) + return self:say(self.TEXT.hold(self:monName(), name), function() + self:composeIfMail(itemId) + end) + end + -- .already_holding_item: the swap question, then ReceiveItemFromPokemon for + -- the old one. A bag that cannot take it back puts the old item straight + -- back on the mon (.bag_full), so nothing is ever destroyed. + self:ask(self.TEXT.askSwap(self:monName(), self:itemName(held)), function() + Bag.remove(self.save, itemId, 1) + if not Bag.add(self.save, held, 1, self.game and self.game.data) then + Bag.add(self.save, itemId, 1, self.game and self.game.data) + return self:say(self.TEXT.storageFull, function() self:close() end) + end + mon.item = itemId + self:say(self.TEXT.swapped(self:monName(), self:itemName(held), + self:itemName(itemId)), function() self:composeIfMail(itemId) end) + end, function() self:close() end) +end + +-- GivePartyItem's tail: `ld d, a / farcall ItemIsMail / call +-- ComposeMailMessage`. The keyboard has no cancel -- the item is already on +-- the mon by the time it opens -- so an empty message is a blank letter, not +-- a refusal. +function HeldItemMenu:composeIfMail(itemId) + if not Mail.isMail(itemId) then return self:close() end + local game = self.game + if not (game and game.stack) then return self:close() end + self.busy = true + Screens.push(game, "Gen2MailCompose", { + onDone = function(message) + game.stack:pop() + self.busy = false + Mail.compose(self.save, self.slot, message, self:mon(), itemId) + self:close() + end, + }) +end + +-- ------------------------------------------------------------------- TAKE + +function HeldItemMenu:takeItem() + local mon = self:mon() + if not mon then return self:close() end + local held = mon.item + if not held then + return self:say(self.TEXT.notHolding(self:monName()), + function() self:close() end) + end + if not Bag.add(self.save, held, 1, self.game and self.game.data) then + return self:say(self.TEXT.storageFull, function() self:close() end) + end + mon.item = nil + -- Taking a mail item back leaves no letter behind it: the struct has nowhere + -- to live once the stationery is gone. The MAIL row's own TAKE is the path + -- that asks about the PC first (src/ui/gen2/MailMenu.lua); this one is only + -- reachable for a mon that is NOT holding mail, because the submenu shows + -- MAIL instead of ITEM in that case. + Mail.clear(self.save, self.slot) + self:say(self.TEXT.tookItem(self:itemName(held), self:monName()), + function() self:close() end) +end + +function HeldItemMenu:choose() + local entry = ENTRIES[self.index] + if not entry then return end + if entry.id == "give" then return self:openPack() end + self:takeItem() +end + +-- ------------------------------------------------------------------ update + +function HeldItemMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function HeldItemMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function HeldItemMenu:update(_dt) + -- The PACK or the keyboard is on top of the stack; it owns input. + if self.busy then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end + + local total = #ENTRIES + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + self:close() + end +end + +-- -------------------------------------------------------------------- draw + +function HeldItemMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function HeldItemMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function HeldItemMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + else + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(MENU_X, MENU_Y, MENU_W, MENU_H) + for row, entry in ipairs(ENTRIES) do + local ty = MENU_LABEL_Y + (row - 1) * 2 + if row == self.index then Chrome.cursor(MENU_LABEL_X - 1, ty) end + Chrome.print(entry.label, MENU_LABEL_X, ty) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function HeldItemMenu:draw() + self:drawPanel() +end + +return HeldItemMenu diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua new file mode 100644 index 00000000..d06bd81c --- /dev/null +++ b/src/ui/gen2/InitClock.lua @@ -0,0 +1,364 @@ +-- The clock-setting screens (pokegold engine/rtc/timeset.asm). +-- +-- Two screens out of one file, because the cart builds them out of one set of +-- pieces: a Textbox with an up arrow above the value and a down arrow below +-- it, the d-pad walking the value with wraparound, A confirming, and a YES/NO +-- box that either takes the answer or drops back to the picker. +-- +-- mode "clock" InitClock, the first thing OakSpeech does +-- (engine/menus/intro_menu.asm OakSpeech: `farcall InitClock`). +-- Oak asks the hour, confirms it, asks the minutes, confirms +-- them, and reads the whole time back. +-- mode "day" SetDayOfWeek, the wheel Mom puts up when she hands over the +-- POKeGEAR (maps/PlayersHouse1F.asm `special SetDayOfWeek`). +-- +-- Layout, transcribed from the hlcoord calls: +-- clock hour Textbox (3,7) 2x15, up arrow (11,7), down (11,10), +-- "<hour> o'clock" at (4,9) +-- clock minutes Textbox (11,7) 2x7, up arrow (15,7), down (15,10), +-- "<mm> min." at (12,9) +-- day Textbox (9,3) 2x9, up arrow (14,3), down (14,6), +-- the weekday at (10,5), question in the (0,12) 4x18 box +-- +-- The answer is written through src/core/gen2/Clock.lua, which stores the same +-- wStartHour / wStartMinute / wStartDay base the cart does. + +local Chrome = require("src.ui.gen2.Chrome") +local Clock = require("src.core.gen2.Clock") +local Strings = require("src.core.Strings") + +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true + +-- data/text/common_1.asm. None of these are extracted: PrintText reaches them +-- from engine code, so no script pointer walks them and the extractor never +-- sees them. +local TEXT = { + wokeUp = Strings.source( + "Zzz... Hm? Wha...?\nYou woke me up!" + .. "\fWill you check the\nclock for me?"), + whatTime = Strings.source("What time is it?"), + whatHours = Strings.source("What?\n%s?"), + howManyMinutes = Strings.source("How many minutes?"), + whoaMinutes = Strings.source("Whoa!\n%d min.?"), + -- OakText_ResponseToSetTime prints the time it has just been given and then + -- picks its line off the hour: NITE and before MORN_HOUR is "So dark...", + -- through DAY_HOUR is "I overslept!", and the rest of the day is "Yikes!". + soDark = Strings.source("%s!\nIt's so dark!"), + overslept = Strings.source("%s!\nI overslept!"), + yikes = Strings.source("%s!\nYikes! I over-\nslept!"), + whatDay = Strings.source("What day is it?"), + confirmDay = Strings.source("%s, is that right?"), +} +InitClock.TEXT = TEXT + +-- constants/misc_constants.asm:37-39. DAY_HOUR is 10, not 9: it read 9 here, +-- which moved Oak's line an hour early -- 10 o'clock answered "Yikes! I +-- overslept!" where OakText_ResponseToSetTime's `cp DAY_HOUR + 1` still puts +-- it in the plain "I overslept!" arm. src/world/gen2/Palettes.lua carries the +-- same three and has always had them right. +local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 + +-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0. +local DAYS = { + "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", +} +InitClock.DAYS = DAYS + +function InitClock:wantsFillScale() return true end +function InitClock:drawsWidescreen() return true end + +-- PrintHour (engine/rtc/timeset.asm:672) is GetTimeOfDayString + PlaceString, +-- then AdjustHourForAMorPM as a left-aligned two-digit number. So the cart +-- prints the time-of-day WORD and a 1-12 hour -- "MORN 5" -- and never an +-- AM/PM suffix. Writing it as "5 AM" was what put the meridiem in the middle +-- of the clock-set line, because InitClock.timeString appends ":mm" to this +-- and Oak came out saying "5 AM:30". +-- +-- AdjustHourForAMorPM still governs the number: 0 shows as 12, 13-23 lose 12, +-- so midnight is "NITE 12" and not "NITE 0". +-- +-- The word comes from src/world/gen2/Palettes.lua:clockDaytime, which already +-- transcribes GetTimeOfDayString's own ladder (NITE below MORN_HOUR, MORN +-- below DAY_HOUR, DAY below NITE_HOUR, NITE after) off the real constants. +-- One source for it means the clock Oak reads out cannot disagree with the +-- palette the world is lit by. +function InitClock.hourString(hour) + local h = math.floor(hour or 0) % 24 + local display = h % 12 + if display == 0 then display = 12 end + local word = require("src.world.gen2.Palettes").clockDaytime(h) + return ("%s %d"):format(word, display) +end + +function InitClock.oclockString(hour) + return InitClock.hourString(hour) .. " o'clock" +end + +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), + math.floor(minute or 0) % 60) +end + +-- .OakTimeSoDarkText / .OakTimeOversleptText / .OakTimeYikesText, in the ladder +-- OakText_ResponseToSetTime walks them in. +function InitClock.responseKey(hour) + local h = math.floor(hour or 0) % 24 + if h < MORN_HOUR then return "soDark" end + if h <= DAY_HOUR then return "overslept" end + if h < NITE_HOUR then return "yikes" end + return "soDark" +end + +-- opts: mode ("clock" | "day"), save, onDone(hour, minute) / onDone(day), +-- autoConfirm (a driver's deterministic path: every step takes its default and +-- answers YES). +function InitClock.new(game, opts) + opts = opts or {} + local self = setmetatable({}, InitClock) + self.game = game + self.mode = opts.mode == "day" and "day" or "clock" + self.save = opts.save or (game and game.save) + self.onDone = opts.onDone + self.autoConfirm = opts.autoConfirm or false + self.hour = opts.hour or Clock.DEFAULT_HOUR + self.minute = opts.minute or Clock.DEFAULT_MINUTE + -- `xor a / ld [wTempDayOfWeek], a`: the wheel opens on SUNDAY. + self.day = opts.day or 0 + -- YesNoBox's cursor, which opens on YES. + self.yesNo = 1 + -- The cart opens on the "you woke me up" page and only then starts asking; + -- the day wheel has no preamble. + self.phase = self.mode == "day" and "day" or "intro" + -- Which page of the current question is showing. PrintText pages a `para` + -- (a \f here) on a button press like any other text box, and Oak's opening + -- is three lines long over two of them. + self.page = 1 + return self +end + +-- The current question, split into its pages. +function InitClock:pages() + local out = {} + for page in (self:question() .. "\f"):gmatch("(.-)\f") do + if page ~= "" then out[#out + 1] = page end + end + if #out == 0 then out[1] = "" end + return out +end + +function InitClock:pageText() + local pages = self:pages() + return pages[math.min(self.page, #pages)] or "" +end + +-- True while there is another page of the same question to show. +function InitClock:morePages() + return self.page < #self:pages() +end + +-- The value the picker is walking right now, and its wrap limit. +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end + +function InitClock:step(delta) + local value, last = self:value() + -- .AdvanceThroughMidnight / .DecreaseThroughMidnight: both ends wrap. + value = (value + delta) % (last + 1) + if self.phase == "hour" then self.hour = value + elseif self.phase == "minute" then self.minute = value + else self.day = value end +end + +-- The line printed above the picker. +function InitClock:question() + if self.phase == "intro" then return Strings(TEXT.wokeUp) end + if self.phase == "hour" then return Strings(TEXT.whatTime) end + if self.phase == "minute" then return Strings(TEXT.howManyMinutes) end + if self.phase == "day" then return Strings(TEXT.whatDay) end + if self.phase == "confirm-hour" then + return Strings(TEXT.whatHours, InitClock.oclockString(self.hour)) + end + if self.phase == "confirm-minute" then + return Strings(TEXT.whoaMinutes, self.minute) + end + if self.phase == "confirm-day" then + return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?") + end + if self.phase == "response" then + return Strings(TEXT[InitClock.responseKey(self.hour)], + InitClock.timeString(self.hour, self.minute)) + end + return "" +end + +-- The value the picker box shows, or nil while a page is up with no picker. +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return ("%d min."):format(self.minute) end + if self.phase == "day" then return DAYS[self.day + 1] or "?" end + return nil +end + +function InitClock:confirming() + return self.phase == "confirm-hour" or self.phase == "confirm-minute" + or self.phase == "confirm-day" +end + +function InitClock:finish() + if self.mode == "day" then + Clock.setWeekday(self.save, self.day) + if self.onDone then self.onDone(self.day) end + return + end + Clock.setTime(self.save, self.hour, self.minute) + if self.onDone then self.onDone(self.hour, self.minute) end +end + +-- A on a picker confirms it, YES on a confirmation takes it, NO drops back to +-- the picker it came from (`jr c, .loop` / `jr nc, .HourIsSet`). +function InitClock:accept() + -- A on a page that has more behind it turns the page, the way `para` does. + if self:morePages() then + self.page = self.page + 1 + return + end + self.page = 1 + if self.phase == "intro" then + self.phase = "hour" + elseif self.phase == "hour" then + self.phase = "confirm-hour" + elseif self.phase == "confirm-hour" then + self.phase = "minute" + elseif self.phase == "minute" then + self.phase = "confirm-minute" + elseif self.phase == "confirm-minute" then + self.phase = "response" + elseif self.phase == "response" then + self:finish() + elseif self.phase == "day" then + self.phase = "confirm-day" + elseif self.phase == "confirm-day" then + self:finish() + end +end + +function InitClock:decline() + if self.phase == "confirm-hour" then + self.phase = "hour" + elseif self.phase == "confirm-minute" then + self.phase = "minute" + elseif self.phase == "confirm-day" then + self.phase = "day" + end +end + +function InitClock:update(_dt) + -- The driver path: no screen this new may be allowed to stall a scripted + -- run, so it walks itself to the end taking every default. + if self.autoConfirm then + self:accept() + return + end + local input = self.game and self.game.input + if not input then return end + if self:confirming() and not self:morePages() then + -- YesNoBox: the cursor walks two rows and B is NO. + if input:wasPressed("up") or input:wasPressed("down") then + self.yesNo = self.yesNo == 1 and 2 or 1 + elseif input:wasPressed("a") then + if self.yesNo == 1 then self:accept() else self:decline() end + self.yesNo = 1 + elseif input:wasPressed("b") then + self:decline() + self.yesNo = 1 + end + return + end + if input:wasPressed("up") then + self:step(1) + elseif input:wasPressed("down") then + self:step(-1) + elseif input:wasPressed("a") then + self:accept() + end +end + +-- ------------------------------------------------------------------- drawing + +-- The picker box: a Textbox with the value inside it, TIMESET_UP_ARROW on its +-- top border and TIMESET_DOWN_ARROW three rows below. +function InitClock:pickerBox() + if self.phase == "hour" then return 3, 7, 15, 2, 11, 4, 9 end + if self.phase == "minute" then return 11, 7, 7, 2, 15, 12, 9 end + if self.phase == "day" then return 9, 3, 9, 2, 14, 10, 5 end + return nil +end + +-- TimeSetUpArrowGFX / TimeSetDownArrowGFX are two 1bpp tiles the cart loads +-- OVER the ♂ and ♀ font cells for this screen only; the extractor carries the +-- font page, not the replacements, so the pair are drawn rather than printed. +-- Four pixel rows, widest at the base, which is what the two 1bpp tiles are. +local ARROW_ROWS = { 1, 3, 5, 7 } + +local function arrow(tx, ty, up) + local G = love.graphics + local x, y = tx * 8, ty * 8 + -- The arrow tile REPLACES the border tile it lands on (hlcoord 11, 7 is the + -- box's own top row), so the cell is cleared before it is drawn. + G.setColor(1, 1, 1, 1) + G.rectangle("fill", x, y, 8, 8) + G.setColor(0, 0, 0, 1) + for i, width in ipairs(ARROW_ROWS) do + local row = up and (i - 1) or (#ARROW_ROWS - i) + G.rectangle("fill", x + math.floor((8 - width) / 2), y + 2 + row, width, 1) + end + G.setColor(1, 1, 1, 1) +end + +function InitClock:drawPanel() + Chrome.clear() + local value = self:display() + if value then + local bx, by, bw, bh, arrowX, tx, ty = self:pickerBox() + Chrome.textbox(bx, by, bw, bh) + -- The two arrows sit ON the border rows, which is why they are placed + -- after the box rather than inside it. + arrow(arrowX, by, true) + arrow(arrowX, by + bh + 1, false) + Chrome.print(value, tx, ty) + end + -- The question (and the confirmations) share the bottom textbox every other + -- Gold prompt uses. + Chrome.textbox(0, 12, 18, 4) + Chrome.printWrapped(self:pageText(), 1, 14, 18, 3) + if self:confirming() then + Chrome.box(14, 6, 6, 5) + Chrome.print("YES", 16, 7) + Chrome.print("NO", 16, 9) + Chrome.cursor(15, self.yesNo == 1 and 7 or 9) + end +end + +function InitClock:draw() + self:drawPanel() +end + +function InitClock: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 InitClock diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua new file mode 100644 index 00000000..2ad76be9 --- /dev/null +++ b/src/ui/gen2/ItemPcMenu.lua @@ -0,0 +1,604 @@ +-- The player's item PC: _PlayersPC and the three item rows behind it +-- (engine/events/pokecenter_pc.asm PlayerWithdrawItemMenu / +-- PlayerDepositItemMenu / PlayerTossItemMenu), plus TossItemFromPC +-- (engine/pokemon/mon_menu.asm). Both of the cart's callers land here: +-- +-- PlayersPC PLAYERSPC_NORMAL -- the <PLAYER>'s PC row of the +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua): +-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / MAIL BOX / +-- LOG OFF +-- _PlayersHousePC PLAYERSPC_HOUSE -- the bedroom PC's whole screen: the +-- boot sound and PlayersPCTurnOnText first, DECORATION on +-- the list, TURN OFF instead of LOG OFF, and the answer +-- carried back out (TRUE only when a decoration moved) so +-- PlayersHousePCScript can take its `.Warp` arm +-- +-- The menu's rows run through the ui.pc.items hook -- the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC) -- with the exit row appended +-- after it, the way that site appends LOG OFF. +-- +-- Items live on save.pcItems, the id -> count map ReceiveItem's PC half +-- already writes (src/core/gen2/MomShopping.lua receiveItemToPc): fifty +-- distinct stacks of at most 99, like wPCItems. DEPOSIT opens the PACK as a +-- chooser held by this screen, exactly the arrangement the mart's sell flow +-- uses (src/ui/gen2/MartMenu.lua enterSell), because DepositSellPack is the +-- same routine on the cart. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") + +local ItemPcMenu = {} +ItemPcMenu.__index = ItemPcMenu +ItemPcMenu.isOpaque = true + +-- MAX_PC_ITEMS stacks of at most MAX_ITEM_STACK (constants/item_constants.asm), +-- the same pair src/core/gen2/MomShopping.lua enforces for Mom's deliveries. +local PC_ITEM_CAPACITY = 50 +local MAX_STACK = 99 + +-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks +-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE +-- carries DECORATION and ends on TURN OFF. +local ENTRIES = { + { id = "withdraw", label = "WITHDRAW ITEM" }, + { id = "deposit", label = "DEPOSIT ITEM" }, + { id = "toss", label = "TOSS ITEM" }, + { id = "mailbox", label = "MAIL BOX" }, +} +local LOG_OFF = { id = "logoff", label = "LOG OFF" } +local DECORATION = { id = "decoration", label = "DECORATION" } +local TURN_OFF = { id = "turnoff", label = "TURN OFF" } + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- PCItemsJoypad's ScrollingMenu is `db 4, 8 ; rows, columns`. +local VISIBLE_ROWS = 4 + +-- charmap.asm: the quantity glyph. +local TIMES = "\xc3\x97" + +function ItemPcMenu:wantsFillScale() return true end +function ItemPcMenu:drawsWidescreen() return true end + +-- opts: save, items (items.lua), house (PLAYERSPC_HOUSE: boot text, +-- DECORATION row, TURN OFF), events (wEventFlags, for the decoration +-- menu), onClose(changedDecorations) +function ItemPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ItemPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.onClose = opts.onClose + self.house = opts.house and true or false + self.events = opts.events + -- wChangedDecorations, carried out so `special PlayersHousePC` can answer + -- TRUE and PlayersHousePCScript can take its `.Warp` arm. + self.changedDecorations = false + if self.save then self.save.pcItems = self.save.pcItems or {} end + local entries = {} + for i, entry in ipairs(ENTRIES) do entries[i] = entry end + if self.house then entries[#entries + 1] = DECORATION end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- LOG OFF / TURN OFF is appended AFTER the hook, exactly as the Gen 1 site + -- appends LOG OFF: a mod cannot orphan the way out of the PC. + entries[#entries + 1] = self.house and TURN_OFF or LOG_OFF + self.entries = entries + self.index = 1 + self.phase = "menu" + self.rows = {} + self.listIndex = 1 + self.scroll = 0 + self.message = nil + self.qtyState = nil + self.confirm = nil + if self.house then + -- _PlayersHousePC: PC_PlayBootSound, then PlayersPCTurnOnText. + self:playSfx("Sfx_BootPc") + self:say({ { "{PLAYER} turned on", "the PC." } }) + end + return self +end + +function ItemPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then + Sound.play(data, name) + end +end + +function ItemPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- A queue of text pages, each a list of lines; A or B turns them, and the last +-- one runs onDone. The item PC's messages never log off by themselves, which +-- is _PlayersPC's `.loop`: a refusal drops back into the same menu. +function ItemPcMenu:say(pages, onDone) + self.message = { pages = pages, page = 1, onDone = onDone } +end + +function ItemPcMenu:close() + -- _PlayersHousePC plays PC_PlayShutdownSound only on the unchanged arm; + -- `.changed_deco_tiles` leaves for the map reload without it. + if self.house and not self.changedDecorations then + self:playSfx("Sfx_ShutDownPc") + end + if self.onClose then self.onClose(self.changedDecorations) end +end + +-- ---------------------------------------------------------------- the items + +function ItemPcMenu:def(id) + return self.items and self.items[id] +end + +-- _CheckTossableItem: KEY ITEMs and HMs answer non-zero. The extractor +-- carries that as `canToss = false`; an id with no attributes row counts as +-- tossable, the way the PACK's sell gate treats it. +function ItemPcMenu:cantToss(id) + local def = self:def(id) + return def ~= nil and def.canToss == false +end + +function ItemPcMenu:rebuild() + local pc = (self.save and self.save.pcItems) or {} + local rows = {} + for id, count in pairs(pc) do + if (count or 0) > 0 then + local def = self:def(id) + rows[#rows + 1] = { + id = id, count = count, + name = (def and def.name) or id, + index = def and def.index or math.huge, + } + end + end + -- wPCItems keeps acquisition order; without that recorded, item id order is + -- the stable choice, the same sort the PACK uses. + table.sort(rows, function(a, b) + if a.index ~= b.index then return a.index < b.index end + return a.id < b.id + end) + self.rows = rows + if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end + if self.listIndex < 1 then self.listIndex = 1 end + self:ensureVisible() +end + +function ItemPcMenu:listTotal() + return #self.rows + 1 -- CANCEL +end + +function ItemPcMenu:ensureVisible() + if self.listIndex <= self.scroll then + self.scroll = self.listIndex - 1 + elseif self.listIndex > self.scroll + VISIBLE_ROWS then + self.scroll = self.listIndex - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:listTotal() - VISIBLE_ROWS))) +end + +-- ReceiveItem over wPCItems: a new id needs one of the fifty stacks, a grown +-- one may not pass 99. False is the no-carry the deposit turns into +-- _PlayersPCNoRoomDepositText. +function ItemPcMenu:pcAdd(id, qty) + local pc = self.save.pcItems + local held = pc[id] or 0 + if held == 0 then + local stacks = 0 + for _, count in pairs(pc) do + if (count or 0) > 0 then stacks = stacks + 1 end + end + if stacks >= PC_ITEM_CAPACITY then return false end + end + if held + qty > MAX_STACK then return false end + pc[id] = held + qty + return true +end + +function ItemPcMenu:pcRemove(id, qty) + local pc = self.save.pcItems + local held = (pc[id] or 0) - qty + pc[id] = held > 0 and held or nil +end + +-- HasNoItems (engine/pokemon/mon_menu.asm): every pocket, TM/HMs included -- +-- which the flat inventory answers in one walk. Badges share the table but +-- are not bag items. +local function bagIsEmpty(save) + for id, count in pairs((save and save.inventory) or {}) do + if (count or 0) > 0 and not Bag.isBadge(id) then return false end + end + return true +end + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- prompt is the two lines under the selector; onAccept(qty) commits. +function ItemPcMenu:askQuantity(max, prompt, onAccept) + self.qtyState = { qty = 1, max = max, prompt = prompt, onAccept = onAccept } +end + +-- ------------------------------------------------------------ the three rows + +function ItemPcMenu:withdraw(row, qty) + -- PlayerWithdrawItemMenu .withdraw: ReceiveItem into the bag first; only a + -- carry tosses the stack out of the PC. + if not Bag.add(self.save, row.id, qty, self.data) then + self:say({ { "There's no room", "for more items." } }) + return + end + self:pcRemove(row.id, qty) + self:rebuild() + self:say({ { ("Withdrew %d"):format(qty), row.name .. "(S)." } }) +end + +function ItemPcMenu:chooseWithdraw() + local row = self.rows[self.listIndex] + if not row then + self.phase = "menu" + return + end + -- .Submenu: an item without a quantity attribute (a KEY ITEM in the PC) is + -- always x1; everything else asks _PlayersPCHowManyWithdrawText. + if self:cantToss(row.id) then + self:withdraw(row, 1) + return + end + self:askQuantity(row.count, + { "How many do you", "want to withdraw?" }, + function(qty) self:withdraw(row, qty) end) +end + +function ItemPcMenu:deposit(id, name, qty) + if not self:pcAdd(id, qty) then + self:say({ { "There's no room to", "store items." } }) + return + end + Bag.remove(self.save, id, qty) + if self.pack then self.pack:rebuild() end + self:say({ { ("Deposited %d"):format(qty), name .. "(S)." } }) +end + +function ItemPcMenu:enterDeposit() + -- .CheckItemsInBag: an empty bag never opens the PACK. + if bagIsEmpty(self.save) then + self:say({ { "No items here!" } }) + return + end + self.phase = "deposit" + -- DepositSellPack: the PACK as a chooser, held and drawn by this screen the + -- way the mart holds its sell PACK. `world = {}` keeps field items inert. + self.pack = Screens.build(self.game, "Gen2PackMenu", { + save = self.save, + items = self.items, + world = {}, + onChoose = function(id, count) self:offerToDeposit(id, count) end, + onClose = function() self:leaveDeposit() end, + }) +end + +function ItemPcMenu:leaveDeposit() + self.pack = nil + self.phase = "menu" +end + +function ItemPcMenu:offerToDeposit(id, count) + -- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in + -- the bag with no message at all. + if self:cantToss(id) then return end + if (count or 0) < 1 then return end + local def = self:def(id) + local name = (def and def.name) or id + self:askQuantity(count, + { "How many do you", "want to deposit?" }, + function(qty) self:deposit(id, name, qty) end) +end + +function ItemPcMenu:chooseToss() + local row = self.rows[self.listIndex] + if not row then + self.phase = "menu" + return + end + -- TossItemFromPC .key_item -> .CantToss. + if self:cantToss(row.id) then + self:say({ { "That's too impor-", "tant to toss out!" } }) + return + end + self:askQuantity(row.count, + { "Toss out how many", row.name .. "(S)?" }, + function(qty) + -- .ItemsThrowAwayText's yes/no sits between the count and the toss. + self.confirm = { + prompt = { ("Throw away %d"):format(qty), row.name .. "(S)?" }, + choice = 1, + onYes = function() + self:pcRemove(row.id, qty) + self:rebuild() + self:say({ { "Discarded", row.name .. "(S)." } }) + end, + } + end) +end + +-- ------------------------------------------------------------------ the menu + +function ItemPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "withdraw" or entry.id == "toss" then + self.phase = entry.id + self.listIndex = 1 + self.scroll = 0 + self:rebuild() + return + end + if entry.id == "deposit" then + self:enterDeposit() + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + -- logoff / turnoff: PlayerLogOffMenu serves both rows. + self:close() +end + +-- ------------------------------------------------------------------- update + +function ItemPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + m.page = m.page + 1 + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.qtyState then + local q = self.qtyState + if input:wasPressed("up") then + q.qty = qtyStep(q.qty, q.max, 1) + elseif input:wasPressed("down") then + q.qty = qtyStep(q.qty, q.max, -1) + elseif input:wasPressed("right") then + q.qty = qtyStep(q.qty, q.max, 10) + elseif input:wasPressed("left") then + q.qty = qtyStep(q.qty, q.max, -10) + elseif input:wasPressed("b") then + self.qtyState = nil + elseif input:wasPressed("a") then + self.qtyState = nil + q.onAccept(q.qty) + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if self.phase == "deposit" then + if self.pack then + self.pack:update(_dt) + else + self.phase = "menu" + end + return + end + + if self.phase == "withdraw" or self.phase == "toss" then + if input:wasPressed("up") then + self.listIndex = self.listIndex > 1 and self.listIndex - 1 + or self:listTotal() + self:ensureVisible() + elseif input:wasPressed("down") then + self.listIndex = self.listIndex < self:listTotal() and self.listIndex + 1 + or 1 + self:ensureVisible() + elseif input:wasPressed("b") then + self.phase = "menu" + elseif input:wasPressed("a") then + if self.phase == "withdraw" then + self:chooseWithdraw() + else + self:chooseToss() + end + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry is `.turn_off`. + self:close() + end +end + +-- --------------------------------------------------------------------- draw + +function ItemPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + local startY = #lines >= 3 and 13 or 14 + for i, line in ipairs(lines) do + Chrome.print((line:gsub("{PLAYER}", name)), 1, startY + (i - 1) * 2) + end +end + +function ItemPcMenu:drawList() + Chrome.box(0, 0, 20, 12) + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = row * 2 + if i <= #self.rows then + local entry = self.rows[i] + if i == self.listIndex then Chrome.cursor(5, ty) end + Chrome.print(entry.name, 6, ty) + -- PlaceMenuItemQuantity: the xNN is the entry's second line. + Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1) + elseif i == self:listTotal() then + if i == self.listIndex then Chrome.cursor(5, ty) end + Chrome.print("CANCEL", 6, ty) + end + end + -- UpdateItemDescription under the list. + local row = self.rows[self.listIndex] + local def = row and self:def(row.id) + local description = def and def.description + if description then + local first, second = description:match("^(.-)<NEXT>(.*)$") + if not first then first, second = description:match("^(.-)\n(.*)$") end + Chrome.box(0, 12, 20, 6) + Chrome.print(first or description, 1, 14) + if second then Chrome.print(second, 1, 16) end + else + Chrome.box(0, 12, 20, 6) + end +end + +function ItemPcMenu:drawPanel() + Chrome.clear() + + if self.phase == "deposit" and self.pack then + self.pack:drawPanel() + elseif self.phase == "withdraw" or self.phase == "toss" then + self:drawList() + else + -- _PlayersPCAskWhatDoText, printed under the list the whole time. The + -- box goes down first: the menu window overlays it where the house's + -- six-row list runs past row 12, the way the cart's windows stack. + self:drawBottomLines({ "What do you want", "to do?" }) + -- PlayersPCMenuData is menu_coords 0, 0, 15, 12; the house list is one + -- row taller than that box, so size it to the entries. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.label, 2, ty) + end + end + + if self.qtyState then + local q = self.qtyState + self:drawBottomLines(q.prompt) + Chrome.box(7, 15, 13, 3) + Chrome.print(TIMES, 8, 16) + Chrome.print(Chrome.number(q.qty, 2, true), 9, 16) + elseif self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print("YES", 16, 8) + Chrome.print("NO", 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + elseif self.message then + self:drawBottomLines(self.message.pages[self.message.page]) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function ItemPcMenu:draw() + self:drawPanel() +end + +function ItemPcMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +ItemPcMenu.ENTRIES = ENTRIES +ItemPcMenu.PC_ITEM_CAPACITY = PC_ITEM_CAPACITY + +return ItemPcMenu diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua new file mode 100644 index 00000000..ce543a7a --- /dev/null +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -0,0 +1,333 @@ +-- The Magnet Train ride, drawn (pokegold engine/events/magnet_train.asm). +-- +-- src/core/gen2/MagnetTrain.lua is the routine: the jumptable, the three SCX +-- bands and the player's frameset, all love-free. This is the presentation, +-- and it is the cart's presentation rather than an approximation of it: +-- +-- * MagnetTrain_LoadGFX_PlayMusic loads NO background tiles. The train, the +-- bushes and the window are all drawn out of whatever the map's tileset +-- already had in VRAM, which for both stations is TILESET_TRAIN_STATION -- +-- so the sheet this screen bakes from is the same one the overworld was +-- drawing a frame earlier. +-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18 +-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap, +-- the 20x4 train laid over rows 6-9) come from the extracted cache at +-- data.field.magnetTrain. A cache built before the extractor learned to +-- follow them has neither, and then the ride runs with a blank screen +-- rather than with invented art. +-- * The background is baked into a 256x144 canvas once, because the only +-- thing that changes per frame is the per-band SCX. +-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows +-- PAL_BG_GREEN, the ten train rows PAL_BG_GRAY and the six window tiles +-- PAL_BG_YELLOW, out of a TOWN palette set at the current time of day +-- (the routine pushes wEnvironment, forces TOWN, and pops it back). +-- +-- Two hardware details are not reproduced. The four player OBJs all carry +-- OAM_PRIO, so on the cart they sit behind background colours 1-3 and only +-- show through the window's colour 0; here they are drawn straight over the +-- background, which looks the same everywhere the window is transparent and +-- differs only if the sprite drifts over solid train tiles. And the ride +-- cannot be skipped, exactly as on the cart: the loop reads no input at all. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local MagnetTrain = require("src.core.gen2.MagnetTrain") +local Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") + +local MagnetTrainRide = {} +MagnetTrainRide.__index = MagnetTrainRide +MagnetTrainRide.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 +local BG_W = 256 -- TILEMAP_WIDTH * 8 +local TILES_PER_ROW = 16 -- every 2bpp sheet the importer writes +local BLACK = { 0, 0, 0 } + +function MagnetTrainRide:wantsFillScale() return true end + +-- opts: toGoldenrod (the wScriptVar the officer's script set), onDone() +function MagnetTrainRide.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MagnetTrainRide) + self.game = game + self.data = game and game.data + self.onDone = opts.onDone + self.finished = false + + local field = self.data and self.data.field + local gfx = field and field.magnetTrain + self.ride = MagnetTrain.new({ + toGoldenrod = opts.toGoldenrod, + bgTiles = gfx and gfx.bgTiles, + fgTilemap = gfx and gfx.tilemap, + }) + + self.tileset = self.data and self.data.tilesets + and self.data.tilesets.TILESET_TRAIN_STATION + self.palettes = self:bgPalettes() + self.spriteSheet = self:playerSheet() + + -- PlayMusic2 MUSIC_MAGNET_TRAIN, the last thing the GFX load does. + local songs = self.data and self.data.audio and self.data.audio.songs + if songs and songs.Music_MagnetTrain then + Music.stop() + Music.play(self.data, "Music_MagnetTrain", true, { reason = "magnettrain" }) + end + return self +end + +-- GetSGBLayout with wEnvironment forced to TOWN and wTimeOfDayPal taken from +-- the real clock, so the bushes are the colour the outside world is right now +-- even though both stations are INDOOR maps. +function MagnetTrainRide:bgPalettes() + local data = self.data + local palettes = data and data.palettes + if not palettes then return nil end + return Palettes.bgSet(palettes, { environment = "TOWN" }, + Palettes.clockDaytime()) +end + +function MagnetTrainRide:palette(slot) + local set = self.palettes + local colors = set and (set[slot] or set[1]) + return colors +end + +-------------------------------------------------------------------------- +-- Sheets +-------------------------------------------------------------------------- + +local function sheetFor(path) + if not path then return nil end + -- `and` would truncate pcall's second return, so this cannot fold into one + -- expression. + local ok, image = pcall(Assets.image, path) + if not (ok and image) then return nil end + image:setFilter("nearest", "nearest") + 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 % TILES_PER_ROW * 8, math.floor(tile / TILES_PER_ROW) * 8, + 8, 8, width, height) + end + return { image = image, quads = quads, width = width, height = height } +end + +function MagnetTrainRide:tileSheet() + if self.sheet == nil then + self.sheet = sheetFor(self.tileset and self.tileset.image) or false + end + return self.sheet or nil +end + +-- The player's own overworld sheet: six 16x16 frames stacked vertically, of +-- which MagnetTrain.SHEET_FRAME names the two the cutscene requests. Quads +-- 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.sprites + local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS) + local path = def and def.image + if not path then return nil end + local ok, image = pcall(Assets.image, path) + if not (ok and image) then return nil end + image:setFilter("nearest", "nearest") + return { image = image } +end + +-- The quad for one vtile of the two loaded blocks. A 2x2 frame is stored +-- top-left, top-right, bottom-left, bottom-right, which is the order +-- .OAMData_MagnetTrainRed's four `dbsprite` rows walk. +function MagnetTrainRide:playerQuad(vtile) + local sheet = self.spriteSheet + if not sheet then return nil end + local frame = MagnetTrain.SHEET_FRAME[vtile - (vtile % 4)] + if not frame then return nil end + self.playerQuads = self.playerQuads or {} + local quad = self.playerQuads[vtile] + if not quad then + local sub = vtile % 4 + local w, h = sheet.image:getDimensions() + quad = love.graphics.newQuad( + (sub % 2) * 8, frame * 16 + math.floor(sub / 2) * 8, 8, 8, w, h) + self.playerQuads[vtile] = quad + end + return quad +end + +-------------------------------------------------------------------------- +-- The baked background +-------------------------------------------------------------------------- + +-- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into a 256x144 +-- canvas. Tiles are drawn palette group by palette group so the three +-- palettes cost three shader switches rather than one per tile. +function MagnetTrainRide:background() + if self.bgCanvas ~= nil then return self.bgCanvas or nil end + self.bgCanvas = false + local rows = self.ride:tilemap() + local sheet = self:tileSheet() + if not (rows and sheet) then return nil end + local ok, canvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H) + if not ok then return nil end + canvas:setFilter("nearest", "nearest") + + local groups = {} + for row = 1, #rows do + for col = 1, #rows[row] do + local slot = MagnetTrain.paletteSlot(col - 1, row - 1) + groups[slot] = groups[slot] or {} + local list = groups[slot] + list[#list + 1] = { rows[row][col], (col - 1) * 8, (row - 1) * 8 } + end + end + + local G = love.graphics + local previous = G.getCanvas() + -- A canvas does not reset the transform: without this the map lands under + -- the renderer's letterbox scale and off the edge. + G.push() + G.origin() + G.setCanvas(canvas) + G.clear(0, 0, 0, 1) + G.setColor(1, 1, 1, 1) + local shader = GbcPalette.available() + for slot, list in pairs(groups) do + local colors = self:palette(slot) + if shader and colors then GbcPalette.use(colors) end + for _, cell in ipairs(list) do + local quad = sheet.quads[cell[1]] + if quad then G.draw(sheet.image, quad, cell[2], cell[3]) end + end + end + if shader then GbcPalette.clear() end + G.setCanvas(previous) + G.pop() + self.bgCanvas = canvas + return canvas +end + +-------------------------------------------------------------------------- +-- Frame +-------------------------------------------------------------------------- + +function MagnetTrainRide:playSfx(name) + local audio = self.data and self.data.audio + local sfx = audio and audio.sfx + if sfx and sfx[Sound.resolve(self.data, name)] then + Sound.play(self.data, name) + end +end + +function MagnetTrainRide:finish() + if self.finished then return end + self.finished = true + if self.onDone then self.onDone() end +end + +function MagnetTrainRide:update(_dt) + if self.finished then return end + local sfx = self.ride:update() + if sfx then self:playSfx(sfx) end + if self.ride:done() then + -- MagnetTrain's .done tears the screen back down and returns to the + -- script, which then runs `warpcheck` and `newloadmap MAPSETUP_TRAIN`. + self:finish() + end +end + +function MagnetTrainRide:drawBackground() + local canvas = self:background() + if not canvas then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + self.bandQuad = self.bandQuad + or love.graphics.newQuad(0, 0, BG_W, 1, BG_W, SCREEN_H) + for _, band in ipairs(self.ride:bands()) do + local top, bottom, scx = band[1], band[2], band[3] % BG_W + local height = bottom - top + 1 + self.bandQuad:setViewport(0, top, BG_W, height, BG_W, SCREEN_H) + G.draw(canvas, self.bandQuad, -scx, top) + G.draw(canvas, self.bandQuad, -scx + BG_W, top) + end +end + +-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry +-- names. +function MagnetTrainRide:playerPalette() + local palettes = self.data and self.data.palettes + local sprites = self.data and self.data.sprites + if not palettes then return nil end + return Palettes.spritePalette(palettes, Palettes.clockDaytime(), + sprites and sprites.SPRITE_CHRIS) +end + +function MagnetTrainRide:drawPlayer() + local sheet = self.spriteSheet + if not sheet then return end + local oam = self.ride:playerOam() + if #oam == 0 then return end + local G = love.graphics + local shader = GbcPalette.available() + local colors = shader and self:playerPalette() + if colors then GbcPalette.use(colors) end + G.setColor(1, 1, 1, 1) + for _, entry in ipairs(oam) do + local quad = self:playerQuad(entry.tile) + if quad then + G.draw(sheet.image, quad, + entry.x + (entry.xflip and 8 or 0), entry.y, 0, + entry.xflip and -1 or 1, 1) + end + end + if colors then GbcPalette.clear() end +end + +-- The backdrop is BG colour 0 of the gray palette the train body uses; on the +-- cart it is what shows wherever nothing was drawn. +function MagnetTrainRide:backdrop() + return GbcPalette.color(self:palette(MagnetTrain.PAL_BG_GRAY), 1) or BLACK +end + +function MagnetTrainRide:drawPanel() + local G = love.graphics + local backdrop = self:backdrop() + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) + self:drawBackground() + self:drawPlayer() + G.setColor(1, 1, 1, 1) +end + +function MagnetTrainRide:draw() + self:drawPanel() +end + +-- MagnetTrain_LoadGFX_PlayMusic opens on ClearBGPalettes / ClearSprites +-- (engine/events/magnet_train.asm:101-103), so nothing of the station is left +-- behind the ride. The surround has to be the same gray backdrop the panel +-- computes: a white letterbox would frame the train in a colour the cart never +-- puts on this screen. +function MagnetTrainRide:drawsWidescreen() return true end + +function MagnetTrainRide:drawWidescreen(winW, winH) + local G = love.graphics + local backdrop = self:backdrop() + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + G.setColor(1, 1, 1, 1) + 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 MagnetTrainRide diff --git a/src/ui/gen2/MailCompose.lua b/src/ui/gen2/MailCompose.lua new file mode 100644 index 00000000..409f6a93 --- /dev/null +++ b/src/ui/gen2/MailCompose.lua @@ -0,0 +1,362 @@ +-- Writing a letter: _ComposeMailMessage (engine/menus/naming_screen.asm) and +-- its own charset, data/text/mail_input_chars.asm. +-- +-- It is the naming screen's cousin, not the naming screen: the grid is TEN +-- columns wide instead of nine, the entry field is two MAIL_LINE_LENGTH rows +-- instead of one, and the charsets are different in both cases (mail gets the +-- digits, the four POKé glyphs, the quote marks and the apostrophe pairs; a +-- nickname gets the dakuten pairs mail has no room for). +-- +-- Layout, transcribed from .InitCharset's own hlcoords: +-- rows 0-5 NAMINGSCREEN_BORDER, with (1,1) 4x18 cleared for the letter +-- .Update ClearBox (1,1) 4x18, then PlaceString at (2,2) -- so the first +-- line of the message is row 2 and the '<NEXT>' stored at offset +-- MAIL_LINE_LENGTH puts the second on row 3 +-- rows 6-17 blank, with .PlaceMailCharset writing each 19-character row +-- from x = 1 and stepping SCREEN_WIDTH + 1, i.e. TWO rows: the +-- charset sits at y = 7, 9, 11, 13, 15 and the case/DEL/END strip +-- at y = 17 +-- +-- Cursor: ComposeMail_AnimateCursor's .GetDPad is a 10x6 grid that wraps in +-- both axes, and row 5 (the strip) collapses to three targets at columns +-- 0-2 / 3-5 / 6-9 -- ComposeMail_GetCursorPosition's `cp $3 / cp $6` split, +-- which is why RIGHT from END wraps to the case switch rather than stepping. +-- +-- SELECT toggles case anywhere, START parks the cursor on END, B deletes. +-- Filling both lines does NOT end entry the way a nickname's last slot does: +-- .a only bumps the length past the stored line break, so the player still has +-- to press END. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local Mail = require("src.core.gen2.Mail") + +local MailCompose = {} +MailCompose.__index = MailCompose +MailCompose.isOpaque = true + +-- data/text/mail_input_chars.asm, cell for cell. Each ASM row is twenty +-- columns with the character on the even ones, so cell N is index N*2-1; the +-- rows carrying multi-byte glyphs are written out as arrays instead, the same +-- way src/ui/gen2/NamingScreen.lua writes its symbol rows. +local function rowCells(row) + local out = {} + for i = 1, 10 do + out[i] = row:sub(i * 2 - 1, i * 2 - 1) + end + return out +end + +local MAIL_INPUT_UPPER = { + rowCells("A B C D E F G H I J"), + rowCells("K L M N O P Q R S T"), + rowCells("U V W X Y Z , ? !"), + rowCells("1 2 3 4 5 6 7 8 9 0"), + -- "<PK> <MN> <PO> <KE> é ♂ ♀ ¥ … ×". All ten are single font glyphs and + -- Font.split matches charmap sequences, so <PO>/<KE> draw one tile each. + { "<PK>", "<MN>", "<PO>", "<KE>", "\xc3\xa9", "\xe2\x99\x82", + "\xe2\x99\x80", "\xc2\xa5", "\xe2\x80\xa6", "\xc3\x97" }, +} +local MAIL_INPUT_LOWER = { + rowCells("a b c d e f g h i j"), + rowCells("k l m n o p q r s t"), + rowCells("u v w x y z . - /"), + -- "'d 'l 'm 'r 's 't 'v & ( )": the seven apostrophe pairs are one glyph + -- each ($d0-$d6), not two characters. + { "'d", "'l", "'m", "'r", "'s", "'t", "'v", "&", "(", ")" }, + -- "“ ” [ ] ' : ; " + { "\xe2\x80\x9c", "\xe2\x80\x9d", "[", "]", "'", ":", ";", " ", " ", " " }, +} + +-- "lower DEL END " / "UPPER DEL END ", written raw from x = 1: the +-- labels land on columns 1, 8 and 14. The cursor is a sprite on the cart +-- (.CaseDelEnd's $00/$30/$60 x offsets); here it is the same ▶ the naming +-- screen falls back to, one column left of each label. +local BOTTOM_LABELS = { "lower", "DEL", "END" } +local BOTTOM_UPPER_LABELS = { "UPPER", "DEL", "END" } +local BOTTOM_LABEL_TX = { 1, 8, 14 } +local BOTTOM_CURSOR_TX = { 0, 7, 13 } + +-- .PlaceMailCharset: first row at (1,7), stepping two rows. +local KEYBOARD_TOP = 7 +local KEYBOARD_X = 1 +local BOTTOM_ROW = 5 +-- .Update's PlaceString target. +local ENTRY_X, ENTRY_Y = 2, 2 + +function MailCompose:wantsFillScale() return true end +function MailCompose:drawsWidescreen() return true end + +-- opts: initial (a message being edited), menuGfx (gen2MenuGfx, for the +-- border and line tiles), onDone(message), onCancel(). +-- +-- There is no cancel on the cart: _ComposeMailMessage loops until END, and the +-- caller has already committed the item. onCancel is here for a driver and +-- for a mod screen that wants one; nothing in the game presses it. +function MailCompose.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailCompose) + self.game = game + self.onDone = opts.onDone + self.onCancel = opts.onCancel + self.lower = false -- wNamingScreenLetterCase; upper first + self.text = Mail.trim(opts.initial or "") + self.col = 0 + self.row = 0 + self.gfx = opts.menuGfx or (game and game.data and game.data.gen2MenuGfx) + self.tiles = {} + if self.gfx then + for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do + if self.gfx[key] then + local ok, img = pcall(Assets.image, self.gfx[key]) + if ok then self.tiles[key] = img end + end + end + end + return self +end + +function MailCompose:rows() + return self.lower and MAIL_INPUT_LOWER or MAIL_INPUT_UPPER +end + +function MailCompose:onBottomRow() + return self.row == BOTTOM_ROW +end + +-- ComposeMail_GetCursorPosition: columns 0-2 are the case switch, 3-5 DEL, +-- 6-9 END. +function MailCompose:bottomTarget() + if self.col < 3 then return 1 end + if self.col < 6 then return 2 end + return 3 +end + +function MailCompose:characterAt(col, row) + local line = self:rows()[row + 1] + local ch = line and line[col + 1] + if not ch or ch == " " or ch == "" then return nil end + return ch +end + +function MailCompose:length() + return #Mail.characters(self.text) +end + +function MailCompose:addCharacter(ch) + if not ch then return end + if self:length() >= Mail.MAIL_MSG_LENGTH then return end + self.text = self.text .. ch +end + +function MailCompose:deleteCharacter() + local chars = Mail.characters(self.text) + if #chars == 0 then return end + self.text = table.concat(chars, "", 1, #chars - 1) +end + +function MailCompose:toggleCase() + self.lower = not self.lower +end + +-- .finished: NamingScreen_StoreEntry writes '@' over the first line/underline +-- glyph, so the message is exactly what was typed and the rest of the buffer +-- is terminator. +function MailCompose:accept() + if self.onDone then self.onDone(self.text) end +end + +-- .right / .left. A letter row steps one column and wraps at 9/0; the strip +-- steps one TARGET and wraps at 3/1, which the ASM does by multiplying the +-- target back out by three. +function MailCompose:moveHorizontal(delta) + if self:onBottomRow() then + local target = self:bottomTarget() + delta + if target < 1 then target = 3 end + if target > 3 then target = 1 end + self.col = (target - 1) * 3 + return + end + self.col = self.col + delta + if self.col < 0 then self.col = 9 end + if self.col > 9 then self.col = 0 end +end + +function MailCompose:moveVertical(delta) + self.row = self.row + delta + if self.row < 0 then self.row = BOTTOM_ROW end + if self.row > BOTTOM_ROW then self.row = 0 end + if self:onBottomRow() then + self.col = (self:bottomTarget() - 1) * 3 + end +end + +function MailCompose:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if input:wasPressed("left") then + self:moveHorizontal(-1) + elseif input:wasPressed("right") then + self:moveHorizontal(1) + elseif input:wasPressed("up") then + self:moveVertical(-1) + elseif input:wasPressed("down") then + self:moveVertical(1) + elseif input:wasPressed("select") then + self:toggleCase() + elseif input:wasPressed("start") then + -- .start puts VAR1 = $9 / VAR2 = $5, i.e. the cursor onto END. + self.row = BOTTOM_ROW + self.col = 9 + elseif input:wasPressed("b") then + -- .b is NamingScreen_DeleteCharacter, not a way out. + self:deleteCharacter() + elseif input:wasPressed("a") then + if self:onBottomRow() then + local target = self:bottomTarget() + if target == 1 then + self:toggleCase() + elseif target == 2 then + self:deleteCharacter() + else + self:accept() + end + return + end + self:addCharacter(self:characterAt(self.col, self.row)) + end +end + +-- The patterned backdrop the border tile fills rows 0-5 with. Without +-- menu_gfx.lua (an older cache) a flat mid grey keeps the cleared panel +-- readable, the same fallback src/ui/gen2/NamingScreen.lua takes. +function MailCompose:drawBackdrop() + local G = love.graphics + local tile = self.tiles.border + if not tile then + G.setColor(0.62, 0.62, 0.62, 1) + G.rectangle("fill", 0, 0, 160, 6 * 8) + G.setColor(1, 1, 1, 1) + return + end + G.setColor(1, 1, 1, 1) + for ty = 0, 5 do + for tx = 0, Chrome.SCREEN_W - 1 do + G.draw(tile, tx * 8, ty * 8) + end + end +end + +function MailCompose:clearPanel(tx, ty, tw, th) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + G.setColor(0, 0, 0, 1) +end + +-- One of the two entry rows: the characters that landed on it, then the +-- underline in the next slot and middle lines for the rest -- exactly what +-- NamingScreen_InitNameEntry lays into the buffer before the first keypress. +function MailCompose:drawEntryRow(chars, first, ty, cursorAt) + local G = love.graphics + for i = 1, Mail.MAIL_LINE_LENGTH do + local index = first + i - 1 + local pen = (ENTRY_X + i - 1) * 8 + local ch = chars[index] + if ch then + G.setColor(0, 0, 0, 1) + Font.draw(ch, pen, ty * 8) + else + local isNext = index == cursorAt + local glyph = isNext and self.tiles.underLine or self.tiles.middleLine + G.setColor(0, 0, 0, 1) + if glyph then + G.draw(glyph, pen, ty * 8) + else + -- No extracted line tiles: draw them. The underline sits on the + -- cell's baseline, the middle line halfway up, same as the 1bpp art. + G.rectangle("fill", pen + 1, ty * 8 + (isNext and 7 or 4), 6, 1) + end + end + end + G.setColor(1, 1, 1, 1) +end + +function MailCompose:drawPanel() + local G = love.graphics + self:drawBackdrop() + -- rows 6-17 are ByteFilled with ' ', which is the blank tile. + self:clearPanel(0, 6, Chrome.SCREEN_W, 12) + -- .InitCharset's ClearBox (1,1) 4x18, which .Update repeats every frame. + self:clearPanel(1, 1, 18, 4) + + local chars = Mail.characters(self.text) + local cursorAt = #chars + 1 + self:drawEntryRow(chars, 1, ENTRY_Y, cursorAt) + self:drawEntryRow(chars, Mail.MAIL_LINE_LENGTH + 1, ENTRY_Y + 1, cursorAt) + + local grid = self:rows() + for row = 0, BOTTOM_ROW - 1 do + local line = grid[row + 1] or {} + for col = 0, 9 do + local ch = line[col + 1] + if ch and ch ~= " " and ch ~= "" then + Chrome.print(ch, KEYBOARD_X + col * 2, KEYBOARD_TOP + row * 2) + end + end + end + + local labels = self.lower and BOTTOM_UPPER_LABELS or BOTTOM_LABELS + local bottomY = KEYBOARD_TOP + BOTTOM_ROW * 2 + for i, label in ipairs(labels) do + Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY) + end + + local cursorTx, cursorTy + if self:onBottomRow() then + cursorTx, cursorTy = BOTTOM_CURSOR_TX[self:bottomTarget()], bottomY + else + cursorTx = KEYBOARD_X - 1 + self.col * 2 + cursorTy = KEYBOARD_TOP + self.row * 2 + end + if self.tiles.cursor then + G.setColor(1, 1, 1, 1) + G.draw(self.tiles.cursor, cursorTx * 8, cursorTy * 8) + else + Chrome.cursor(cursorTx, cursorTy) + end + G.setColor(1, 1, 1, 1) +end + +function MailCompose:draw() + self:drawPanel() +end + +function MailCompose:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0.62, 0.62, 0.62, 1) + G.rectangle("fill", 0, 0, winW, winH) + G.setColor(1, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +-- Exported for tests: what the cursor is over right now. +function MailCompose:cursorCharacter() + if self:onBottomRow() then + return ({ "CASE", "DEL", "END" })[self:bottomTarget()] + end + return self:characterAt(self.col, self.row) +end + +MailCompose.MAIL_INPUT_UPPER = MAIL_INPUT_UPPER +MailCompose.MAIL_INPUT_LOWER = MAIL_INPUT_LOWER + +return MailCompose diff --git a/src/ui/gen2/MailMenu.lua b/src/ui/gen2/MailMenu.lua new file mode 100644 index 00000000..2fe11ffb --- /dev/null +++ b/src/ui/gen2/MailMenu.lua @@ -0,0 +1,315 @@ +-- MonMailAction (engine/pokemon/mon_menu.asm): the READ / TAKE / QUIT menu the +-- party submenu's MAIL row opens. +-- +-- It is drawn OVER the party list rather than replacing it -- MENU_BACKUP_TILES +-- with `menu_coords 9, 10, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1` -- so this +-- state is not opaque and the list underneath keeps drawing. +-- +-- TAKE is the interesting half, and its two questions are asked in this order +-- for a reason: "Send the removed MAIL to your PC?" comes FIRST, and only +-- saying no to it drops into "The MAIL will lose its message. OK?". So the +-- destructive answer is two deliberate presses away, and the mailbox being +-- full (.MailboxFull) ends the whole thing rather than falling through to the +-- bag. +-- +-- READ ReadPartyMonMail, which is the Gen2MailRead screen +-- TAKE yes -> SendMailToPC -> _MailSentToPCText / _MailboxFullText +-- no -> ReceiveItemFromPokemon on the mail ITEM +-- -> _MailDetachedText / _MailNoSpaceText +-- QUIT `ld a, $3`, i.e. redraw the list and stay in it +-- +-- Every string below is transcribed from data/text/common_2.asm and paired +-- with its pokegold label in LABELS, the same way src/ui/gen2/DayCareMenu.lua +-- pairs the Day-Care's: the cache's own characters win when the extractor has +-- seeded that label, and the transcription is what an older cache falls back +-- to. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local CommonText = require("src.core.gen2.CommonText") +local Mail = require("src.core.gen2.Mail") +local Screens = require("src.ui.Screens") + +local MailMenu = {} +MailMenu.__index = MailMenu +-- Drawn over the party list; see the header. +MailMenu.isOpaque = false + +-- .MenuHeader: menu_coords 9, 10, 19, 17. GetMenuTextStartCoord with +-- STATICMENU_CURSOR and no NO_TOP_SPACING puts the first label at +-- (left + 2, top + 2) and steps two rows, with the cursor one column left. +local MENU_X, MENU_Y, MENU_W, MENU_H = 9, 10, 11, 8 +local MENU_LABEL_X, MENU_LABEL_Y = MENU_X + 2, MENU_Y + 2 + +-- The shared speech box and YES/NO box, at the coordinates every Gold screen +-- draws them at (Textbox `lb bc, 4, 18` at (0,12); YesNoBox at (14,7)). +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +-- .MenuData's three rows, verbatim. +local ENTRIES = { + { id = "read", label = "READ" }, + { id = "take", label = "TAKE" }, + { id = "quit", label = "QUIT" }, +} + +local function page(...) return { ... } end +local function pages(...) return { ... } end + +local TEXT = { + askSendToPc = pages(page("Send the removed", "MAIL to your PC?")), + mailboxFull = pages(page("Your PC's MAILBOX", "is full.")), + sentToPc = pages(page("The MAIL was sent", "to your PC.")), + loseMessage = pages(page("The MAIL will lose", "its message. OK?")), + -- _MailDetachedText's second line is a text_ram nickname, so it is spliced + -- rather than being part of the string. + detached = function(name) + return pages(page("MAIL detached from", ("%s."):format(name))) + end, + noSpace = pages(page("There's no space", "for removing MAIL.")), +} + +local LABELS = { + askSendToPc = "_MailAskSendToPCText", + mailboxFull = "_MailboxFullText", + sentToPc = "_MailSentToPCText", + loseMessage = "_MailLoseMessageText", + detached = "_MailDetachedText", + noSpace = "_MailNoSpaceText", +} + +local FILL = { + detached = function(name) return { name } end, +} + +MailMenu.TEXT = TEXT +MailMenu.LABELS = LABELS +MailMenu.ENTRIES = ENTRIES + +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then + local fill = FILL[key] + if fill then + out[key] = function(...) return CommonText.fill(list, fill(...)) end + else + out[key] = list + end + end + end + return out +end + +-- opts: save, slot (the party index the list landed on), text (text.lua), +-- onClose() +function MailMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.slot = opts.slot or 1 + self.textData = opts.text or (game and game.world and game.world.text) + self.TEXT = extractedText(self.textData) + self.onClose = opts.onClose + self.index = 1 + self.message = nil + self.confirm = nil + self.reading = false + return self +end + +function MailMenu:mon() + return self.save and self.save.party and self.save.party[self.slot] +end + +function MailMenu:monName() + local mon = self:mon() + if not mon then return "#MON" end + return mon.nickname or mon.name or mon.species or "#MON" +end + +function MailMenu:close() + if self.onClose then self.onClose() end +end + +function MailMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function MailMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +-- .read: ReadPartyMonMail over the party list. The read screen is opaque and +-- full-page, so it goes on the stack rather than being drawn from here. +function MailMenu:read() + local game = self.game + if not (game and game.stack) then return self:close() end + self.reading = true + Screens.push(game, "Gen2MailRead", { + entry = Mail.get(self.save, self.slot), + onClose = function() + game.stack:pop() + self.reading = false + -- `ld a, $0` returns to the party list rather than staying in the menu. + self:close() + end, + }) +end + +-- .take: the first question. +function MailMenu:take() + self:ask(self.TEXT.askSendToPc, function() self:sendToPc() end, + function() self:removeToBag() end) +end + +function MailMenu:sendToPc() + if not Mail.sendToPc(self.save, self.slot) then + return self:say(self.TEXT.mailboxFull, function() self:close() end) + end + self:say(self.TEXT.sentToPc, function() self:close() end) +end + +-- .RemoveMailToBag: the second question, then ReceiveItemFromPokemon. The +-- item only leaves the mon once the bag has actually taken it, which is why a +-- full bag prints _MailNoSpaceText and the letter is still on the mon +-- afterwards. +function MailMenu:removeToBag() + self:ask(self.TEXT.loseMessage, function() + local mon = self:mon() + if not (mon and Mail.monHoldsMail(mon)) then return self:close() end + local data = self.game and self.game.data + if not Bag.add(self.save, mon.item, 1, data) then + return self:say(self.TEXT.noSpace, function() self:close() end) + end + local name = self:monName() + mon.item = nil + Mail.clear(self.save, self.slot) + self:say(self.TEXT.detached(name), function() self:close() end) + end, function() + -- `jr c, .done`: saying no here leaves everything alone. + self:close() + end) +end + +function MailMenu:choose() + local entry = ENTRIES[self.index] + if not entry then return end + if entry.id == "read" then return self:read() end + if entry.id == "take" then return self:take() end + self:close() +end + +function MailMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function MailMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function MailMenu:update(_dt) + -- The read screen is on top of the stack; it owns input until it pops. + if self.reading then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end + + local total = #ENTRIES + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + -- `jp c, .done`: B is QUIT. + self:close() + end +end + +function MailMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function MailMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function MailMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + else + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(MENU_X, MENU_Y, MENU_W, MENU_H) + for row, entry in ipairs(ENTRIES) do + local ty = MENU_LABEL_Y + (row - 1) * 2 + if row == self.index then Chrome.cursor(MENU_LABEL_X - 1, ty) end + Chrome.print(entry.label, MENU_LABEL_X, ty) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailMenu:draw() + self:drawPanel() +end + +return MailMenu diff --git a/src/ui/gen2/MailRead.lua b/src/ui/gen2/MailRead.lua new file mode 100644 index 00000000..d3b22235 --- /dev/null +++ b/src/ui/gen2/MailRead.lua @@ -0,0 +1,104 @@ +-- Reading a letter: ReadAnyMail / ReadPartyMonMail (engine/pokemon/mail_2.asm). +-- +-- The screen is one full-page piece of stationery -- ten of them, one per mail +-- item, each its own Load*MailGFX routine painting a border, a mon and a +-- scattering of icons out of gfx/mail.asm's 1bpp art. None of that art is in +-- the cache (the extractor has never followed gfx/mail.asm), so this draws the +-- page as a plain full-screen box and puts the two pieces that are DATA rather +-- than tiles exactly where MailGFX_PlaceMessage puts them: +-- +-- message hlcoord 2, 7 -- one PlaceString, so the '<NEXT>' stored at offset +-- MAIL_LINE_LENGTH lands the second line on row 8 +-- author hlcoord 5, 14, except hlcoord 8, 14 for PORTRAITMAIL_INDEX and +-- hlcoord 6, 14 for MORPH_MAIL_INDEX -- those two stationeries have +-- art where the name would otherwise sit +-- +-- .loop reads A, B and START and exits on any of them (START is the printer, +-- which this port has no path for; the VC builds mask it off entirely with +-- the Forbid_printing_mail patch, so treating it as an exit is the VC +-- behaviour rather than an invention). + +local Chrome = require("src.ui.gen2.Chrome") +local Mail = require("src.core.gen2.Mail") + +local MailRead = {} +MailRead.__index = MailRead +MailRead.isOpaque = true + +local MESSAGE_X, MESSAGE_Y = 2, 7 +local AUTHOR_Y = 14 + +function MailRead:wantsFillScale() return true end +function MailRead:drawsWidescreen() return true end + +-- opts: entry (a `mailmsg` from src/core/gen2/Mail.lua), onClose() +function MailRead.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailRead) + self.game = game + self.entry = opts.entry + self.onClose = opts.onClose + return self +end + +-- MailGFX_PlaceMessage's three author columns, picked by wCurMailIndex -- +-- which .LoadGFX set from the mail's TYPE byte by walking MailGFXPointers, so +-- it is the *_MAIL_INDEX of the stationery and nothing else. +function MailRead:authorColumn() + local index = Mail.INDEX[self.entry and self.entry.type or ""] + if index == Mail.INDEX.PORTRAITMAIL then return 8 end + if index == Mail.INDEX.MORPH_MAIL then return 6 end + return 5 +end + +function MailRead:close() + if self.onClose then self.onClose() end +end + +function MailRead:update(_dt) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") then + self:close() + end +end + +function MailRead:drawPanel() + Chrome.clear() + -- DrawMailBorder frames the whole 20x18 page; without the stationery tiles + -- the shared box frame is the honest stand-in. + Chrome.box(0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + + local top, bottom = Mail.lines(self.entry) + Chrome.print(top, MESSAGE_X, MESSAGE_Y) + if bottom ~= "" then Chrome.print(bottom, MESSAGE_X, MESSAGE_Y + 1) end + + -- MailGFX_PlaceMessage returns early when the author field is empty + -- (`ld a, [de] / and a / ret z`), so a blank author draws nothing at all + -- rather than an empty line under the message. + local author = self.entry and self.entry.author or "" + if author ~= "" then + Chrome.print(author, self:authorColumn(), AUTHOR_Y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailRead:draw() + self:drawPanel() +end + +function MailRead: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MailRead diff --git a/src/ui/gen2/MailboxMenu.lua b/src/ui/gen2/MailboxMenu.lua new file mode 100644 index 00000000..b5f99ca0 --- /dev/null +++ b/src/ui/gen2/MailboxMenu.lua @@ -0,0 +1,415 @@ +-- The MAILBOX in the player's PC: _PlayerMailBoxMenu and MailboxPC +-- (engine/pokemon/mail.asm). +-- +-- InitMail runs first and answers z when sMailboxCount is 0, which is the one +-- branch that never opens a menu at all -- an empty MAILBOX is +-- _EmptyMailboxText and nothing else. +-- +-- Layout, from the two headers: +-- .TopMenuHeader menu_coords 8, 1, 18, 10 -- a scrolling menu, four rows, +-- each printed by MailboxPC_PrintMailAuthor, i.e. the +-- AUTHOR of the letter and not its message +-- .SubMenuHeader menu_coords 0, 0, 13, 9 with STATICMENU_CURSOR, so its +-- four labels start at (2,2) and step two rows +-- +-- The submenu's four rows are .Jumptable's four routines: +-- +-- READ MAIL ReadMailMessage, which is the Gen2MailRead screen +-- PUT IN PACK "message will be lost. OK?" -> ReceiveItem, then +-- DeleteMailFromPC. The letter is destroyed and the +-- STATIONERY goes back in the bag, which is why the question +-- is asked before the bag is even checked +-- ATTACH MAIL the party list, refusing an EGG and a mon that is already +-- holding anything, then MoveMailFromPCToParty. The cart +-- LOOPS on both refusals (`jr .try_again`) rather than backing +-- out, so the list comes straight back up +-- CANCEL `ret` +-- +-- Drawn over whatever opened the PC, so this state is not opaque. + +local Bag = require("src.inventory.Bag") +local Breeding = require("src.core.gen2.Breeding") +local Chrome = require("src.ui.gen2.Chrome") +local CommonText = require("src.core.gen2.CommonText") +local Mail = require("src.core.gen2.Mail") +local Screens = require("src.ui.Screens") + +local MailboxMenu = {} +MailboxMenu.__index = MailboxMenu +MailboxMenu.isOpaque = false + +local LIST_X, LIST_Y, LIST_W, LIST_H = 8, 1, 11, 10 +-- InitScrollingMenu lays its rows two inside the box's corner and steps two. +local ROW_X, ROW_Y, ROW_STEP = LIST_X + 2, LIST_Y + 2, 2 +local VISIBLE_ROWS = 4 + +local SUB_X, SUB_Y, SUB_W, SUB_H = 0, 0, 14, 10 +local SUB_LABEL_X, SUB_LABEL_Y = SUB_X + 2, SUB_Y + 2 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +-- .SubMenuData, verbatim. +local SUB_ENTRIES = { + { id = "read", label = "READ MAIL" }, + { id = "pack", label = "PUT IN PACK" }, + { id = "attach", label = "ATTACH MAIL" }, + { id = "cancel", label = "CANCEL" }, +} + +local function page(...) return { ... } end +local function pages(...) return { ... } end + +-- data/text/common_2.asm's _EmptyMailboxText .. _MailMovedFromBoxText block. +local TEXT = { + empty = pages(page("There's no MAIL", "here.")), + messageLost = pages(page("The MAIL's message", "will be lost. OK?")), + packFull = pages(page("The PACK is full.")), + putAway = pages(page("The cleared MAIL", "was put away.")), + alreadyHolding = pages(page("It's already hold-", "ing an item.")), + egg = pages(page("An EGG can't hold", "any MAIL.")), + moved = pages(page("The MAIL was moved", "from the MAILBOX.")), +} + +local LABELS = { + empty = "_EmptyMailboxText", + messageLost = "_MailMessageLostText", + packFull = "_MailPackFullText", + putAway = "_MailClearedPutAwayText", + alreadyHolding = "_MailAlreadyHoldingItemText", + egg = "_MailEggText", + moved = "_MailMovedFromBoxText", +} + +MailboxMenu.TEXT = TEXT +MailboxMenu.LABELS = LABELS +MailboxMenu.SUB_ENTRIES = SUB_ENTRIES + +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then out[key] = list end + end + return out +end + +-- opts: save, text (text.lua), onClose() +function MailboxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailboxMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.textData = opts.text or (game and game.world and game.world.text) + self.TEXT = extractedText(self.textData) + self.onClose = opts.onClose + -- wCurMessageIndex / wCurMessageScrollPosition, both reset by MailboxPC + -- before its loop. + self.index = 1 + self.scroll = 0 + self.submenu = nil + self.message = nil + self.confirm = nil + self.picking = false + -- InitMail's z branch: no menu, one line, gone. + if Mail.mailboxCount(self.save) == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + return self +end + +function MailboxMenu:box() + return Mail.mailbox(self.save) +end + +function MailboxMenu:count() + return Mail.mailboxCount(self.save) +end + +function MailboxMenu:selected() + return self:box()[self.index] +end + +function MailboxMenu:close() + if self.onClose then self.onClose() end +end + +function MailboxMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function MailboxMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function MailboxMenu:clampIndex() + local total = self:count() + if total == 0 then + self.index, self.scroll = 1, 0 + return + end + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, total - VISIBLE_ROWS))) +end + +-- ------------------------------------------------------------- submenu rows + +function MailboxMenu:readMail() + local game = self.game + local entry = self:selected() + if not (game and game.stack and entry) then return end + self.picking = true + Screens.push(game, "Gen2MailRead", { + entry = entry, + onClose = function() + game.stack:pop() + self.picking = false + self.submenu = nil + end, + }) +end + +-- .PutInPack. The yes/no comes first, then ReceiveItem -- and only a bag that +-- accepted the stationery gets as far as DeleteMailFromPC, so a full PACK +-- leaves the letter in the MAILBOX intact. +function MailboxMenu:putInPack() + self:ask(self.TEXT.messageLost, function() + local entry = self:selected() + if not entry then + self.submenu = nil + return + end + local data = self.game and self.game.data + if not Bag.add(self.save, entry.type, 1, data) then + return self:say(self.TEXT.packFull, function() self.submenu = nil end) + end + Mail.deleteFromPc(self.save, self.index) + self:clampIndex() + self:say(self.TEXT.putAway, function() + self.submenu = nil + if self:count() == 0 then + -- The next .loop calls InitMail again, which now answers z. + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, function() + -- `ret c`: the question was the whole thing. + self.submenu = nil + end) +end + +-- .AttachMail's loop. Both refusals go back to the party list rather than out +-- of the submenu, which is exactly what `jr .try_again` does. +function MailboxMenu:attachMail() + local game = self.game + if not (game and game.stack) then + self.submenu = nil + return + end + self.picking = true + Screens.push(game, "Gen2PartyMenu", { + save = self.save, + party = self.save.party, + prompt = "choose", + onChoose = function(slot, mon) + game.stack:pop() + self.picking = false + if Breeding.isEgg(mon) then + return self:say(self.TEXT.egg, function() self:attachMail() end) + end + if mon and mon.item then + return self:say(self.TEXT.alreadyHolding, + function() self:attachMail() end) + end + if not Mail.moveFromPcToParty(self.save, self.index, slot) then + self.submenu = nil + return + end + self:clampIndex() + self:say(self.TEXT.moved, function() + self.submenu = nil + if self:count() == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, + onCancel = function() + game.stack:pop() + self.picking = false + -- `.exit2` -> CloseSubmenu: back to the letter list, not out of the PC. + self.submenu = nil + end, + }) +end + +function MailboxMenu:chooseSub() + local entry = SUB_ENTRIES[self.submenu.index] + if not entry then return end + if entry.id == "read" then return self:readMail() end + if entry.id == "pack" then return self:putInPack() end + if entry.id == "attach" then return self:attachMail() end + self.submenu = nil +end + +-- ------------------------------------------------------------------ update + +function MailboxMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function MailboxMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function MailboxMenu:updateSubmenu(input) + local total = #SUB_ENTRIES + if input:wasPressed("up") then + self.submenu.index = self.submenu.index > 1 and self.submenu.index - 1 + or total + elseif input:wasPressed("down") then + self.submenu.index = self.submenu.index < total and self.submenu.index + 1 + or 1 + elseif input:wasPressed("a") then + self:chooseSub() + elseif input:wasPressed("b") then + self.submenu = nil + end +end + +function MailboxMenu:update(_dt) + if self.picking then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end + if self.submenu then return self:updateSubmenu(input) end + + local total = self:count() + if total == 0 then return self:close() end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:clampIndex() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:clampIndex() + elseif input:wasPressed("a") then + self.submenu = { index = 1 } + elseif input:wasPressed("b") then + -- .exit: PAD_B out of the scrolling menu ends _PlayerMailBoxMenu. + self:close() + end +end + +-- -------------------------------------------------------------------- draw + +function MailboxMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function MailboxMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function MailboxMenu:drawList() + Chrome.box(LIST_X, LIST_Y, LIST_W, LIST_H) + local box = self:box() + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local entry = box[i] + if entry then + local ty = ROW_Y + (row - 1) * ROW_STEP + if i == self.index then Chrome.cursor(ROW_X - 1, ty) end + -- MailboxPC_PrintMailAuthor copies NAME_LENGTH - 1 bytes out of the + -- struct's Author field and terminates it; a blank author draws a blank + -- row rather than the message. + Chrome.print(entry.author or "", ROW_X, ty) + end + end +end + +function MailboxMenu:drawSubmenu() + Chrome.box(SUB_X, SUB_Y, SUB_W, SUB_H) + for row, entry in ipairs(SUB_ENTRIES) do + local ty = SUB_LABEL_Y + (row - 1) * 2 + if row == self.submenu.index then Chrome.cursor(SUB_LABEL_X - 1, ty) end + Chrome.print(entry.label, SUB_LABEL_X, ty) + end +end + +function MailboxMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + else + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + self:drawList() + if self.submenu then self:drawSubmenu() end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailboxMenu:draw() + self:drawPanel() +end + +return MailboxMenu diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua new file mode 100644 index 00000000..ac3ed88e --- /dev/null +++ b/src/ui/gen2/MainMenu.lua @@ -0,0 +1,229 @@ +-- Gold's intro menu (engine/menus/main_menu.asm MainMenu). +-- +-- Which entries appear depends on whether a save exists +-- (MainMenu_GetWhichMenu reads wSaveFileExists): +-- no save -> NEW GAME, OPTION +-- save -> CONTINUE, NEW GAME, OPTION +-- MYSTERY GIFT is a third case on a CGB with an unlocked SRAM counter; it +-- needs the link cable, so it is not offered here. +-- +-- With a save present the menu also shows the clock box +-- (MainMenu_PrintCurrentTimeAndDay): a 4x13 textbox at (0,12) with the day of +-- the week and the current time. Its .PlaceTime calls UpdateTime before it +-- reads hHours, so it prints the GAME clock -- the RTC through the save's own +-- wStartHour / wStartMinute base -- and not the raw RTC. That is the same +-- read the overworld makes (World:hour), so the time on this screen and the +-- light outside the door always agree. +-- +-- Choosing CONTINUE shows the save panel (DisplaySaveInfoOnContinue) and waits +-- for A to confirm or B to back out (ConfirmContinue). + +local Chrome = require("src.ui.gen2.Chrome") +local Clock = require("src.core.gen2.Clock") +local Logger = require("src.core.Logger") +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Save = require("src.core.gen2.Save") + +local MainMenu = {} +MainMenu.__index = MainMenu +MainMenu.isOpaque = true + +-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. +local DAYS = { + "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", +} + +-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet. +local MENU_MUSIC = "Music_MainMenu" + +function MainMenu:wantsFillScale() return true end +function MainMenu:drawsWidescreen() return true end + +-- opts: onNewGame(), onContinue(save), onOption(), hasSave (override for +-- tests), save (a pre-loaded save table, so the menu does not read the disk +-- twice), clock ({ hour, minute, weekday }) to pin the clock box. +function MainMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MainMenu) + self.game = game + self.onNewGame = opts.onNewGame + self.onContinue = opts.onContinue + self.onOption = opts.onOption + self.onExit = opts.onExit + self.clock = opts.clock + + self.save = opts.save + if self.save == nil and opts.hasSave ~= false then + local loaded = Save.load("gold") + self.save = loaded + end + self.hasSave = opts.hasSave + if self.hasSave == nil then self.hasSave = self.save ~= nil end + + self.phase = "menu" -- menu | confirm + self:buildList() + return self +end + +-- ui.title_menu.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +function MainMenu:buildList() + local items = {} + if self.hasSave then + items[#items + 1] = { label = "CONTINUE", value = "continue" } + end + items[#items + 1] = { label = "NEW GAME", value = "new" } + items[#items + 1] = { label = "OPTION", value = "option" } + -- Not on the cart: a cartridge is left by switching the console off, and + -- there is no console here. Mirrors the Gen 1 port's title menu + -- (src/ui/TitleState.lua), which adds the same row for the same reason. + items[#items + 1] = { label = "EXIT GAME", value = "exit" } + -- The same hook name and the same (game, items) payload the Gen 1 title + -- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows + -- serve both games; only the row shape differs, because Chrome.List reads + -- { label, value } where the Gen 1 Menu reads { label, onSelect }. A hook + -- that answers with anything but a table is degraded to the vanilla list + -- rather than leaving the player with no way into the game. + local hooked = Runtime.call("ui.title_menu.items", sameItems, self.game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.title_menu.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- MenuHeader's "db 1 ; default option": the first entry, so CONTINUE when + -- there is a save and NEW GAME when there is not. + self.list = Chrome.List.new({ + items = items, + x = 2, y = 2, spacing = 2, + wrap = true, + index = 1, + onChoose = function(value) self:choose(value) end, + }) +end + +function MainMenu:choose(value) + if value == "continue" then + self.phase = "confirm" + self.confirmDelay = 20 -- ld c, 20 / DelayFrames before input is read + elseif value == "new" then + if self.onNewGame then self.onNewGame() end + elseif value == "option" then + if self.onOption then self.onOption() end + elseif value == "exit" then + if self.onExit then + self.onExit() + elseif love.event and love.event.quit then + love.event.quit() + end + end +end + +function MainMenu:enter() + 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[MENU_MUSIC] then + Music.play(data, MENU_MUSIC) + end +end + +function MainMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.phase == "confirm" then + if self.confirmDelay and self.confirmDelay > 0 then + self.confirmDelay = self.confirmDelay - 1 + return + end + if input:wasPressed("a") then + if self.onContinue then self.onContinue(self.save) end + elseif input:wasPressed("b") then + self.phase = "menu" + end + return + end + self.list:update(input) +end + +-- .PlaceTime's three reads: GetWeekday, hHours and hMinutes, all of them after +-- UpdateTime. `weekday` is returned 1-based for the DAYS table above, which is +-- os.date's wday numbering and NOT wCurDay's (Clock.weekday counts SUNDAY 0). +-- opts.clock still pins the box outright for a driver's screenshots. +function MainMenu:clockParts() + if self.clock then + return self.clock.hour or 0, self.clock.minute or 0, + self.clock.weekday or 1 + end + local save = self.save + return Clock.hour(save), Clock.minute(save), Clock.weekday(save) + 1 +end + +function MainMenu:drawClockBox() + -- Textbox at (0,12) with 4 interior rows and 13 interior columns. + Chrome.textbox(0, 12, 13, 4) + local hour, minute, weekday = self:clockParts() + Chrome.print(DAYS[weekday] or "DAY", 1, 14) + -- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded + -- minutes; the AM/PM half is drawn by PrintHour itself. + local display = hour % 12 + if display == 0 then display = 12 end + local half = hour < 12 and "AM" or "PM" + Chrome.print(("%s:%s %s"):format( + Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16) +end + +function MainMenu:drawSavePanel() + local summary = Save.summary(self.save) + -- DisplaySaveInfoOnContinue: a box down the right side listing the trainer. + Chrome.textbox(4, 0, 14, 9) + if not summary then + Chrome.print("NO SAVE FILE", 5, 2) + return + end + Chrome.print("PLAYER " .. summary.name, 5, 2) + Chrome.print("BADGES", 5, 4) + Chrome.printRight(tostring(summary.badges), 17, 4) + Chrome.print("POKéDEX", 5, 6) + Chrome.printRight(tostring(summary.caught), 17, 6) + Chrome.print("TIME", 5, 8) + Chrome.printRight(("%d:%s"):format( + summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8) +end + +function MainMenu:drawPanel() + Chrome.clear() + if self.phase == "confirm" then + self:drawSavePanel() + return + end + -- MenuHeader: menu_coords 0, 0, 14, 7 -- a box from (0,0) to (14,7), which + -- is exactly two rows per entry plus the border. The extra EXIT GAME row + -- grows it the way AutomaticGetMenuBottomCoord would. + Chrome.box(0, 0, 15, + math.min(#self.list.items * 2 + 2, Chrome.SCREEN_H)) + self.list:draw() + if self.hasSave then self:drawClockBox() end +end + +function MainMenu:draw() + self:drawPanel() +end + +function MainMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +MainMenu.DAYS = DAYS + +return MainMenu diff --git a/src/ui/gen2/MapRadio.lua b/src/ui/gen2/MapRadio.lua new file mode 100644 index 00000000..25e8f792 --- /dev/null +++ b/src/ui/gen2/MapRadio.lua @@ -0,0 +1,204 @@ +-- The wall radios (engine/pokegear/pokegear.asm PlayRadio). A house radio's +-- bg event runs `jumpstd Radio1Script` -- `setval MAPRADIO_*` then `special +-- MapRadio` (engine/events/std_scripts.asm, engine/events/specials.asm) -- +-- and PlayRadio owns the joypad from there: it resolves the MAPRADIO_* index +-- through PlayRadioStationPointers, prints the station's name in quotes in +-- the caller's text box, waits 100 frames, then runs one PlayRadioShow frame +-- per loop until A or B. The show is the SAME state machine the Pokegear's +-- radio card runs (src/ui/gen2/Pokegear.lua's Radio), and the exit music is +-- the same ExitPokegearRadio_HandleMusic: the channel song the show started +-- stays playing as the map music. +-- +-- Pushed by src/script/gen2/Specials.lua H.MapRadio while the script VM is +-- parked on the special; `onDone` resumes it, and `closetext` follows. + +local Chrome = require("src.ui.gen2.Chrome") +local Nests = require("src.core.gen2.Nests") +local Pokegear = require("src.ui.gen2.Pokegear") +local Runtime = require("src.mods.Runtime") + +local MapRadio = {} +MapRadio.__index = MapRadio +-- The map stays on screen under the text box, exactly as the cart leaves it. +MapRadio.isOpaque = false + +-- PlayRadioStationPointers, in MAPRADIO_* order (constants/ +-- radio_constants.asm). Index 0, LoadStation_PokemonChannel, resolves by +-- region and time of day below rather than to a fixed station. +local STATIONS = { + [1] = "OAKS_POKEMON_TALK", + [2] = "POKEDEX_SHOW", + [3] = "POKEMON_MUSIC", + [4] = "LUCKY_CHANNEL", + [5] = "UNOWN_RADIO", + [6] = "PLACES_AND_PEOPLE", + [7] = "LETS_ALL_SING", + [8] = "ROCKET_RADIO", +} + +-- The same eight as the `radio_channels` registry sees them (src/mods/ +-- Schemas.lua), one of the Gen 2-only six: Red has no radio, so the name is +-- gated under Gen 1 and routed to data.gen2RadioChannels under Gen 2. Id = +-- the station the dial lands on, which is the LoadStation_* id +-- src/ui/gen2/Pokegear.lua's show state machine is keyed by; `channel` is its +-- MAPRADIO_* position, the byte a wall radio's `setval` passes the special. +-- Position 0 is deliberately unregistered: it is not a station, it resolves by +-- region and time of day in resolveStation below. +-- +-- src/mods/Builtins.lua seeds these engine-owned, so a mod's register of +-- ROCKET_RADIO collides and has to say override. +function MapRadio.registerInto(registry, _, owner) + local count = 0 + for channel, station in pairs(STATIONS) do + registry:register(station, { channel = channel, + name = Pokegear.STATION_NAMES[station] }, owner) + count = count + 1 + end + return count +end + +-- The merged record for a dial position, or nil. Read through data rather +-- than through STATIONS so a registered station is on the dial for real; the +-- module's own table is the fallback for a boot with no loader. +function MapRadio.channelRecord(data, channel) + local rows = data and data.gen2RadioChannels + if type(rows) == "table" then + for station, record in pairs(rows) do + if type(record) == "table" and record.channel == channel then + return record, station + end + end + end + local station = STATIONS[channel] + if not station then return nil end + return { channel = channel, name = Pokegear.STATION_NAMES[station] }, station +end + +-- The player's landmark, the way Game2:currentLandmark reads it: the map +-- header's landmark byte resolved through the `landmarks` registry +-- (src/core/gen2/Nests.lua). IsInJohto and the Rocket takeover both branch +-- on it. +local function landmarkOf(game) + local world = game and game.world + local map = world and world.map and world.map.def + return Nests.landmarkId(game and game.data, map and map.landmark) +end + +-- opts: channel (the MAPRADIO_* index setval left in wScriptVar), onDone(), +-- and for tests: save, currentLandmark, radioData, radioRng. +function MapRadio.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MapRadio) + self.game = game + self.onDone = opts.onDone + -- The Pokegear owns the radio data assembly (Pokegear:radioData) and the + -- region/time reads; a gear instance that is never drawn keeps one copy of + -- that wiring. + self.gear = Pokegear.new(game, { + save = opts.save or (game and game.save), + currentLandmark = opts.currentLandmark or landmarkOf(game), + radioData = opts.radioData, + radioRng = opts.radioRng, + }) + self.station, self.stationName = self:resolveStation(opts.channel or 0) + self.radio = Pokegear.Radio.new({ + data = self.gear:radioData(), rng = opts.radioRng, + }) + self.radio:tune(self.station) + -- radio.channel, a Gen 2 invention: Gen 1 has no radio, so there is no name + -- to share. Raised on the tune rather than per show line, because the + -- station is what a mod reasons about -- the show under it is the same state + -- machine the Pokegear's radio card runs and reports its song through + -- music.started like everything else. + -- + -- station the LoadStation_* id the dial landed on + -- channel the MAPRADIO_* index the script's setval passed in; 0 is the + -- Pokemon Channel position, which resolves by region and hour + -- name the station's display name, the one quoted in the text box + -- source "map_radio" -- the wall radio, as against the Pokegear card + if Runtime.wants("radio.channel") then + Runtime.emit("radio.channel", { + station = self.station, channel = opts.channel or 0, + name = self:name(), + source = "map_radio", + }) + end + -- .PlayStation parks ENTER_MAP_MUSIC before the show's own + -- RadioMusicRestartDE lands the channel song, so backing out before the + -- show starts still restores the map theme. + self.radioMusicPlaying = "enterMap" + self.radioSong = nil + -- `ld c, 100 / call DelayFrames`: the name sits alone in the box before + -- the show's first line, and no button is read during the delay. + self.hold = 100 + return self +end + +-- LoadStation_PokemonChannel: in Johto the morning airs the Pokedex Show and +-- the rest of the day Oak's Pokemon Talk; from Kanto the same dial position +-- carries Places & People. Answers the station and, when the record carries +-- one, its own display name. +function MapRadio:resolveStation(channel) + if channel ~= 0 then + local record, station = MapRadio.channelRecord( + self.game and self.game.data, channel) + if station then return station, record and record.name end + return "OAKS_POKEMON_TALK", nil + end + if self.gear:region() ~= "johto" then return "PLACES_AND_PEOPLE" end + if (self.gear:timeOfDayIndex() or 0) == 0 then return "POKEDEX_SHOW" end + return "OAKS_POKEMON_TALK" +end + +-- The name quoted in the text box: the registry record's own where it has one, +-- the Pokegear's STATION_NAMES row otherwise (which is where the vanilla eight +-- get theirs, so this is the same string the cart prints). +function MapRadio:name() + return self.stationName or Pokegear.STATION_NAMES[self.station] +end + +function MapRadio:close() + Pokegear.exitRadioMusic(self.game, self.radioMusicPlaying) + self.radioMusicPlaying = nil + local stack = self.game and self.game.stack + if stack then stack:pop() end + if self.onDone then self.onDone() end +end + +function MapRadio:update(_dt) + if self.hold > 0 then + self.hold = self.hold - 1 + return + end + local input = self.game and self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b")) then + self:close() + return + end + self.radio:step() + local song = self.radio.music + if song and song ~= self.radioSong then + self.radioSong = song + self.radioMusicPlaying = Pokegear.radioPlayingValue(song) + local data = self.game and self.game.data + if data then pcall(require("src.core.Music").play, data, song) end + end +end + +function MapRadio:draw() + -- PlayRadio's own frame: Textbox at (0,12), 4 rows by 18, with the station + -- name quoted at (2,14) until the show's first line scrolls in. + Chrome.textbox(0, 12, 18, 4) + local radio = self.radio + if radio.top == "" and radio.bottom == "" then + local name = self:name() or "" + Chrome.print("“" .. name .. "”", 1, 14) + return + end + if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end + if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end +end + +MapRadio.STATIONS = STATIONS + +return MapRadio diff --git a/src/ui/gen2/MartMenu.lua b/src/ui/gen2/MartMenu.lua new file mode 100644 index 00000000..4b733c76 --- /dev/null +++ b/src/ui/gen2/MartMenu.lua @@ -0,0 +1,1019 @@ +-- Gen 2 POKeMART: the whole clerk conversation (engine/items/mart.asm). +-- +-- `pokemart dialog_id, mart_id` (macros/scripts/events.asm) is a BLOCKING +-- script command. Script_pokemart farcalls OpenMartDialog, which runs the top +-- menu, the buy list and the sell list to completion before the script's next +-- byte is read, so this screen owns the stack while it is up and the VM is +-- parked on its resume exactly the way it is for a battle. +-- +-- Four dialog kinds share one buy list (MartTypeDialogs): +-- MARTTYPE_STANDARD MartDialog BUY / SELL / QUIT, StandardMart's loop +-- MARTTYPE_BITTER HerbShop intro -> BuyMenu -> come again +-- MARTTYPE_BARGAIN BargainShop one of each item, at its own prices +-- MARTTYPE_PHARMACY Pharmacist intro -> BuyMenu -> come again +-- Only STANDARD sells: SellMenu is reached from its top menu and nowhere else. +-- +-- The screen is transcribed from the ASM's own coordinates rather than laid +-- out by eye, because that is what makes it land on the 8px grid: +-- +-- MenuHeader_BuySell menu_coords 0, 0, 11, 8 -- a 12x9 box. Its flags +-- are STATICMENU_CURSOR with no +-- STATICMENU_NO_TOP_SPACING, so GetMenuTextStartCoord +-- derives BUY at (2,2) with the cursor column at 1, +-- and the three labels are two rows apart. +-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and +-- PlaceMoneyTextbox writes the amount at +-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1) +-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4 +-- entries of two rows. ScrollingMenu_UpdateDisplay +-- starts at (2,4); PlaceMenuItemName prints the name +-- there and .PrintBCDPrices is handed that origin +-- plus the menu's own width (8) plus SCREEN_WIDTH, so +-- the price lands at (10,5). The ▲ sits on the box's +-- top right corner (19,3) and the ▼ on its bottom +-- right (19,11). +-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with +-- the description at (1,14) +-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and +-- SellItem_MenuHeader the same box. BuySellToss_UpdateQuantityDisplay +-- writes '×' at (8,16) and two leading-zero digits +-- after it; BuySell_DisplaySubtotal's `inc hl` then +-- puts the running total at (12,16). +-- YesNoBox `lb bc, SCREEN_WIDTH - 6, 7` -- a 6x5 box at +-- (14,7). YesNoMenuHeader sets STATICMENU_CURSOR and +-- STATICMENU_NO_TOP_SPACING, so YES is at (16,8) and +-- NO at (16,10), cursor column 15. +-- MoneyBottomLeftMenuHeader +-- menu_coords 0, 11, 8, 13 -- a 9x3 box with the +-- amount at (1,12). The sell flow's two money boxes +-- are both this rect: PlaceMoneyAtTopLeftOfTextbox +-- offsets the top-right header by lb de, 0, 11, which +-- lands on exactly these coordinates. +-- +-- Every string below is transcribed literally from data/text/common_2.asm and +-- paired with its pokegold label in LABELS. Nothing in the ROM's script +-- bytecode points at any of them -- the clerk's lines are printed by the +-- engine, not by a `writetext` -- so the extractor seeds its text walker at +-- the block by name (RomExtractorGen2's NAMED_TEXT) and this screen prefers +-- the cache's own characters, falling back to the transcription for a cache +-- built before that seed. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local CommonText = require("src.core.gen2.CommonText") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") + +-- PlayTransactionSound (engine/items/mart.asm): `call WaitSFX` then +-- SFX_TRANSACTION. Both tills ring it -- the buy flow at BuyMenuLoop's +-- .proceed, just before TakeMoney and MARTTEXT_HERE_YOU_GO, and the sell flow +-- right after MartBoughtText -- so it is the money changing hands rather than +-- either message. Named the pokegold way; Sound.GEN2_ALIASES is what maps the +-- shared UI's own "Purchase" onto this same label. +local SFX_TRANSACTION = "Sfx_Transaction" + +local MartMenu = {} +MartMenu.__index = MartMenu +MartMenu.isOpaque = true + +-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte +-- ahead of the word mart id, and MartTypeDialogs is indexed by it. +local MART_TYPES = { [0] = "STANDARD", [1] = "BITTER", [2] = "BARGAIN", + [3] = "PHARMACY" } +local NUM_MARTS = 34 -- constants/mart_constants.asm, MART_UNDERGROUND is 33 + +-- GetMart: an id at or past NUM_MARTS is not a mart at all and the clerk sells +-- DefaultMart instead (data/items/marts.asm). +local DEFAULT_MART = { "POKE_BALL", "POTION" } + +-- MAX_ITEM_STACK, the ceiling StandardMartAskPurchaseQuantity loads into +-- wItemQuantity before the selector runs. +local MAX_ITEM_STACK = 99 + +-- ---------------------------------------------------------------- layout +local TOP_BOX_X, TOP_BOX_Y, TOP_BOX_W, TOP_BOX_H = 0, 0, 12, 9 +local TOP_LABEL_X, TOP_LABEL_Y, TOP_SPACING = 2, 2, 2 + +local MONEY_BOX_X, MONEY_BOX_Y, MONEY_BOX_W, MONEY_BOX_H = 11, 0, 9, 3 +local MONEY_X, MONEY_Y = 12, 1 + +local MONEY_LOW_BOX_X, MONEY_LOW_BOX_Y = 0, 11 +local MONEY_LOW_X, MONEY_LOW_Y = 1, 12 + +local LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H = 1, 3, 19, 9 +local LIST_X, LIST_Y, LIST_SPACING = 2, 4, 2 +local PRICE_X = LIST_X + 8 -- wMenuData_ScrollingMenuWidth +local VISIBLE_ROWS = 4 -- MenuHeader_Buy's `db 4, 8 ; rows, columns` +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 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +-- TEXTBOX_INNERY is TEXTBOX_Y + 2 and LineChar (`line`, $4f) targets +-- TEXTBOX_INNERY + 2, so a text box's two lines are TWO rows apart -- which is +-- also where <NEXT> ($4e, SCREEN_WIDTH * 2) puts an item description's second +-- line. One rule covers both. +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +local QTY_BOX_X, QTY_BOX_Y, QTY_BOX_W, QTY_BOX_H = 7, 15, 13, 3 +local QTY_X, QTY_Y = 8, 16 +local QTY_PRICE_X = 12 + +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +-- charmap.asm: × is the quantity glyph, ¥ the currency one, and ▲ / ▼ are the +-- scrolling menu's own arrow tiles (font codes $61 and $ee). +local TIMES = "\xc3\x97" +local YEN = "\xc2\xa5" +local UP_ARROW = "\xe2\x96\xb2" +local DOWN_ARROW = "\xe2\x96\xbc" + +-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS +-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST +-- significant digit rather than at a fixed column, and the field is six digits +-- wide -- so the string is always seven tiles and the yen sign floats. +-- PrintBCDNumber, which the buy list's prices go through, prints the same +-- shape: a leading zero becomes a space and still advances the pointer. +local function moneyText(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end +MartMenu.moneyText = moneyText + +-- ----------------------------------------------------------------- text +-- +-- A "page" is one screenful of the speech text box: up to two lines, `line` +-- rows apart. A `para` in the ASM starts a new page; a `cont` scrolls one +-- line, which shows as a page whose first line is the previous page's second. +local function pages(...) return { ... } end +local function page(...) return { ... } end + +local TEXTS = { + -- MartDialog / StandardMart (engine/items/mart.asm). + STANDARD = { + welcome = page("Welcome! How may I", "help you?"), -- MartWelcomeText + askMore = page("Can I do anything", "else for you?"), -- MartAskMoreText + comeAgain = pages(page("Please come again!")), + howMany = page("How many?"), + thanks = pages(page("Here you are.", "Thank you!")), + noMoney = pages(page("You don't have", "enough money.")), + packFull = pages(page("You can't carry", "any more items.")), + -- MartFinalPriceText: the quantity and the total are text_decimal, which + -- sets PRINTNUM_LEFTALIGN, so neither is padded; the ¥ is a literal in the + -- string rather than PrintNum's floating one. + finalPrice = function(qty, name, total) + return pages(page(("%d %s(S)"):format(qty, name), + ("will be %s%d."):format(YEN, total))) + end, + }, + -- HerbShop (MARTTYPE_BITTER): the Goldenrod Underground herb lady. + BITTER = { + intro = pages( + page("Hello, dear."), + page("I sell inexpensive", "herbal medicine."), + page("They're good, but", "a trifle bitter."), + -- `#` is the four-tile POKé compression byte, which the extractor + -- expands everywhere else in the cache; spelled out it is the same + -- sixteen columns. + page("Your POKéMON may", "not like them."), + page("Hehehehe…")), + comeAgain = pages(page("Come again, dear.", "Hehehehe…")), + howMany = page("How many?"), + thanks = pages(page("Thank you, dear.", "Hehehehe…")), + noMoney = pages(page("Hehehe… You don't", "have the money.")), + packFull = pages(page("Oh? Your PACK is", "full, dear.")), + finalPrice = function(qty, name, total) + return pages(page(("%d %s(S)"):format(qty, name), + ("will be %s%d."):format(YEN, total))) + end, + }, + -- BargainShop (MARTTYPE_BARGAIN): one of each item, at prices carried by + -- BargainShopData rather than by ItemAttributes. + BARGAIN = { + intro = pages( + page("Hiya! Care to see", "some bargains?"), + page("I sell rare items", "that nobody else"), + page("carries--but only", "one of each item.")), + comeAgain = pages(page("Come by again", "sometime.")), + thanks = pages(page("Thanks.")), + noMoney = pages(page("Uh-oh, you're", "short on funds.")), + packFull = pages(page("Uh-oh, your PACK", "is chock-full.")), + -- `cont` scrolls the box one line instead of clearing it, so the second + -- page opens on the first page's second line. + soldOut = pages( + page("You bought that", "already. I'm all"), + page("already. I'm all", "sold out of it.")), + finalPrice = function(_qty, name, total) + return pages(page(("%s costs"):format(name), + ("%s%d. Want it?"):format(YEN, total))) + end, + }, + -- Pharmacist (MARTTYPE_PHARMACY): Cianwood. + PHARMACY = { + intro = pages(page("What's up? Need", "some medicine?")), + comeAgain = pages(page("All right.", "See you around.")), + howMany = page("How many?"), + thanks = pages(page("Thanks much!")), + noMoney = pages(page("Huh? That's not", "enough money.")), + packFull = pages(page("You don't have any", "more space.")), + finalPrice = function(qty, name, total) + return pages(page(("%d %s(S)"):format(qty, name), + ("will cost %s%d."):format(YEN, total))) + end, + }, +} + +-- SellMenu's own strings. Only MARTTYPE_STANDARD ever reaches them. +local SELL_TEXTS = { + cantBuy = pages(page("Sorry, I can't buy", "that from you.")), + howMany = page("How many?"), + -- MartSellPriceText's `para` is a real page break, and the YES/NO box comes + -- up on the second page without a further press. + price = function(total) + return pages(page("I can pay you", ("%s%d."):format(YEN, total)), + page("Is that OK?")) + end, + bought = function(name, total) + return pages(page(("Got %s%d for"):format(YEN, total), + ("%s(S)."):format(name))) + end, +} + +MartMenu.MART_TYPES = MART_TYPES +MartMenu.TEXTS = TEXTS + +-- ...and the data/text/common_2.asm label behind each of those entries. +-- Nothing in the ROM's bytecode points at any of them -- the clerk's lines are +-- printed by engine/items/mart.asm itself -- so the extractor seeds its text +-- walker at the whole block by name (RomExtractorGen2's NAMED_TEXT) and this +-- screen prefers the cache's own characters. The transcriptions above are +-- what a cache built before that seed falls back to. +local LABELS = { + STANDARD = { + welcome = "_MartWelcomeText", askMore = "_MartAskMoreText", + comeAgain = "_MartComeAgainText", howMany = "_MartHowManyText", + thanks = "_MartThanksText", noMoney = "_MartNoMoneyText", + packFull = "_MartPackFullText", finalPrice = "_MartFinalPriceText", + }, + BITTER = { + intro = "_HerbShopLadyIntroText", comeAgain = "_HerbalLadyComeAgainText", + howMany = "_HerbalLadyHowManyText", thanks = "_HerbalLadyThanksText", + noMoney = "_HerbalLadyNoMoneyText", packFull = "_HerbalLadyPackFullText", + finalPrice = "_HerbalLadyFinalPriceText", + }, + BARGAIN = { + intro = "_BargainShopIntroText", comeAgain = "_BargainShopComeAgainText", + thanks = "_BargainShopThanksText", noMoney = "_BargainShopNoFundsText", + packFull = "_BargainShopPackFullText", soldOut = "_BargainShopSoldOutText", + finalPrice = "_BargainShopFinalPriceText", + }, + PHARMACY = { + intro = "_PharmacyIntroText", comeAgain = "_PharmacyComeAgainText", + howMany = "_PharmacyHowManyText", thanks = "_PharmacyThanksText", + noMoney = "_PharmacyNoMoneyText", packFull = "_PharmacyPackFullText", + finalPrice = "_PharmacyFinalPriceText", + }, + SELL = { + cantBuy = "_MartCantBuyText", howMany = "_MartSellHowManyText", + price = "_MartSellPriceText", bought = "_MartBoughtText", + }, +} + +MartMenu.LABELS = LABELS + +-- The three entries that are ONE screenful of lines rather than a list of +-- pages, because they sit under a menu instead of paging: the welcome line +-- the BUY/SELL/QUIT box opens over, the one it reopens over, and the "How +-- many?" prompt the quantity box sits under. +local SINGLE_PAGE = { welcome = true, askMore = true, howMany = true } + +-- The formatted entries, keyed by LABEL rather than by entry name, because +-- the markers come in the string's order and the bargain shop names its item +-- FIRST ("X costs ¥N. Want it?") where the other three clerks lead with the +-- quantity. +local FILL = { + _MartFinalPriceText = function(qty, name, total) + return { qty, name, total } + end, + _HerbalLadyFinalPriceText = function(qty, name, total) + return { qty, name, total } + end, + _PharmacyFinalPriceText = function(qty, name, total) + return { qty, name, total } + end, + _BargainShopFinalPriceText = function(_qty, name, total) + return { name, total } + end, + _MartSellPriceText = function(total) return { total } end, + _MartBoughtText = function(name, total) return { total, name } end, +} + +-- One dialog's table with every entry the cache carries replaced by the +-- extracted string. Anything missing falls through the metatable to the +-- transcription, so a partial cache is a mix rather than a hole. +local function extractedText(text, base, labels) + local out = setmetatable({}, { __index = base }) + for key, label in pairs(labels or {}) do + local list = CommonText.of(text, label) + if list then + local fill = FILL[label] + if fill then + out[key] = function(...) return CommonText.fill(list, fill(...)) end + elseif SINGLE_PAGE[key] then + out[key] = list[1] + else + out[key] = list + end + end + end + return out +end + +function MartMenu:wantsFillScale() return true end +function MartMenu:drawsWidescreen() return true end + +-- data/generated/marts.lua is what the ROM extractor will write out of `Marts` +-- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each +-- entry an array of item ids, and `bargain` is BargainShopData's own +-- item/price rows. A flat top-level array is accepted too, because that is +-- the shape items.lua uses and an extractor may well write it the same way. +-- +-- Nothing is guessed when the table is absent: an id inside the table's range +-- with no row falls back to DefaultMart the way GetMart does, and a missing +-- table leaves the shelf empty rather than inventing stock. +function MartMenu.inventory(marts, martId) + martId = martId or 0 + if martId >= NUM_MARTS then return DEFAULT_MART end + if type(marts) ~= "table" then return {} end + local lists = marts.lists or marts + local list = lists[martId + 1] + if type(list) ~= "table" then return DEFAULT_MART end + return list +end + +-- BargainShopData is loaded by BargainShop itself, not by the mart id, so the +-- opcode's mart id is ignored for MARTTYPE_BARGAIN. +function MartMenu.bargainRows(marts) + local rows = type(marts) == "table" and marts.bargain + if type(rows) ~= "table" then return {} end + return rows +end + +-- opts: save, items (items.lua), marts (marts.lua), martType (MARTTYPE_* +-- number or name), martId (number), text (text.lua, for the extracted +-- strings), onClose() +function MartMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MartMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.marts = opts.marts or (game and game.data and game.data.gen2Marts) + -- The dialog byte, or the MARTTYPE_* name spelled out (which is what a test + -- reads more clearly). Anything unrecognised is MartDialog, the way + -- MartTypeDialogs' jumptable would land on entry 0. + local kind = opts.martType or 0 + if type(kind) == "string" then + self.martType = TEXTS[kind] and kind or "STANDARD" + else + self.martType = MART_TYPES[kind] or "STANDARD" + end + self.martId = opts.martId or 0 + self.onClose = opts.onClose + -- text.lua rides on the world, the way the Pokegear's phone strings do: the + -- mart is opened by a script running on a map and never without one. + self.textData = opts.text or (game and game.world and game.world.text) + self.text = extractedText(self.textData, + TEXTS[self.martType], LABELS[self.martType]) + self.sellText = extractedText(self.textData, SELL_TEXTS, LABELS.SELL) + self.index = 1 + self.scroll = 0 + self:buildEntries() + if self.martType == "STANDARD" then + -- .HowMayIHelpYou prints into the speech box and returns TOPMENU without + -- waiting: the welcome line is still on screen under the BUY/SELL/QUIT + -- menu, which is what the mart looks like on the cart. + self.phase = "top" + self.topLines = self.text.welcome + self.topIndex = 1 + else + -- HerbShop / BargainShop / Pharmacist: intro, then straight into BuyMenu. + self.phase = "intro" + self:say(self.text.intro, function() self:enterBuy() end) + end + return self +end + +-- The shelf. A standard mart prices each row out of ItemAttributes +-- (GetMartItemPrice -> GetItemPrice); the bargain shop carries its own price +-- per row and sells one of each, tracked by wBargainShopFlags. +function MartMenu:buildEntries() + local entries = {} + if self.martType == "BARGAIN" then + local sold = (self.save and self.save.bargainShop) or {} + for _, row in ipairs(MartMenu.bargainRows(self.marts)) do + local id = row.item or row.id or row[1] + local def = id and self.items and self.items[id] + entries[#entries + 1] = { + id = id, + name = (def and def.name) or id or "?", + price = row.price or row[2] or 0, + soldOut = sold[id] == true, + } + end + else + for _, id in ipairs(MartMenu.inventory(self.marts, self.martId)) do + local def = self.items and self.items[id] + entries[#entries + 1] = { + id = id, + name = (def and def.name) or id, + price = (def and def.price) or 0, + } + end + end + self.entries = entries +end + +-- ----------------------------------------------------------------- money +function MartMenu:money() + local player = self.save and self.save.player + return (player and player.money) or 0 +end + +-- GiveMoney clamps at MaxMoney and TakeMoney clamps at zero +-- (engine/events/money.asm); one clamp covers both directions. +function MartMenu:setMoney(amount) + local player = self.save and self.save.player + if not player then return end + player.money = math.max(0, math.min(math.floor(amount or 0), Save.MAX_MONEY)) +end + +-- BuySell_MultiplyPrice: the running total is unit * quantity. +function MartMenu.buyPrice(unit, qty) + return (unit or 0) * (qty or 1) +end + +-- Sell_HalvePrice shifts the 24-bit PRODUCT right once, so the halving happens +-- after the multiply -- selling two of an odd-priced item is not the same as +-- twice half its price, and this is the half a player notices. +function MartMenu.sellPrice(unit, qty) + return math.floor((unit or 0) * (qty or 1) / 2) +end + +-- ---------------------------------------------------------------- overlays +-- +-- `say` is PrintText plus the JoyWaitAorB that follows it everywhere in +-- mart.asm: the box holds until a button, and multi-page strings advance a +-- page per press. +function MartMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function MartMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +-- MartConfirmPurchase / the sell flow's YesNoBox. The prompt's last page is +-- the one the box sits on; earlier pages advance on a press first. +function MartMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function MartMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +-- ---------------------------------------------------------------- top menu +local TOP_ITEMS = { "BUY", "SELL", "QUIT" } + +-- .TopMenu copies MenuHeader_BuySell fresh every pass, and its `db 1 ; default +-- option` means the cursor is back on BUY each time the loop returns here. +function MartMenu:enterTop(lines) + self.phase = "top" + self.topLines = lines or self.text.askMore + self.topIndex = 1 +end + +function MartMenu:updateTop(input) + if input:wasPressed("up") then + self.topIndex = self.topIndex > 1 and self.topIndex - 1 or #TOP_ITEMS + return + elseif input:wasPressed("down") then + self.topIndex = self.topIndex < #TOP_ITEMS and self.topIndex + 1 or 1 + return + elseif input:wasPressed("b") then + self:quit() + return + elseif input:wasPressed("a") then + if self.topIndex == 1 then + self:enterBuy() + elseif self.topIndex == 2 then + self:enterSell() + else + self:quit() + end + end +end + +-- .Quit: the come-again line, then STANDARDMART_EXIT. +function MartMenu:quit() + self.phase = "outro" + self:say(self.text.comeAgain, function() + if self.onClose then self.onClose() end + end) +end + +-- ---------------------------------------------------------------- buy list +-- +-- BuyMenu resets wMenuCursorPositionBackup / wMenuScrollPositionBackup on +-- entry and restores them around every purchase, so the cursor survives a sale +-- but not a trip back through the top menu. +function MartMenu:enterBuy() + self.phase = "buy" + self.index = 1 + self.scroll = 0 +end + +function MartMenu:total() + return #self.entries + 1 -- the -1 terminator draws as CANCEL +end + +function MartMenu:isCancel() + return self.index > #self.entries +end + +function MartMenu:selected() + return self.entries[self.index] +end + +function MartMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function MartMenu:updateBuy(input) + -- _2DMENU_EXIT_UP / _2DMENU_EXIT_DOWN with .d_up refusing to move at scroll + -- zero: the buy list does NOT wrap the way the PACK's does. + if input:wasPressed("up") then + if self.index > 1 then + self.index = self.index - 1 + self:ensureVisible() + end + return + elseif input:wasPressed("down") then + if self.index < self:total() then + self.index = self.index + 1 + self:ensureVisible() + end + return + elseif input:wasPressed("b") then + self:leaveBuy() + return + elseif input:wasPressed("a") then + -- .a_button treats a -1 selection as B, so A on CANCEL leaves the list. + if self:isCancel() then + self:leaveBuy() + else + self:offerToBuy() + end + end +end + +-- BuyMenu returns into StandardMart .Buy, which falls through to +-- .AnythingElse; the other three dialog kinds end on their come-again line. +function MartMenu:leaveBuy() + if self.martType == "STANDARD" then + self:enterTop(self.text.askMore) + else + self:quit() + end +end + +function MartMenu:offerToBuy() + local entry = self:selected() + if not entry then return end + if self.martType == "BARGAIN" then + -- BargainShopAskPurchaseQuantity: one of each, no quantity selector, and a + -- CHECK_FLAG on wBargainShopFlags ahead of everything else. + if entry.soldOut then + self:say(self.text.soldOut) + return + end + self.qtyItem = entry + self.qty = 1 + self.qtyMax = 1 + self:confirmPurchase() + return + end + self.qtyItem = entry + self.qty = 1 + self.qtyMax = MAX_ITEM_STACK + self.phase = "buyQuantity" +end + +function MartMenu:confirmPurchase() + local entry = self.qtyItem + local total = MartMenu.buyPrice(entry.price, self.qty) + self:ask(self.text.finalPrice(self.qty, entry.name, total), + function() self:completePurchase(total) end, + function() self:cancelPurchase() end) +end + +-- .cancel simply redraws the speech box and drops back into the buy loop with +-- the cursor where it was, so this keeps index/scroll. +function MartMenu:cancelPurchase() + self.phase = "buy" +end + +-- PlayTransactionSound. A driver can run this screen with no audio table at +-- all, so a missing one is silence rather than an error. +function MartMenu:playTransaction() + local data = self.game and self.game.data + if data then Sound.play(data, SFX_TRANSACTION) end +end + +-- BuyMenuLoop's order matters: money is compared BEFORE the bag is asked for +-- room, so a broke player is told about the money and never about the PACK. +function MartMenu:completePurchase(total) + local entry = self.qtyItem + if self:money() < total then + self.phase = "buy" + self:say(self.text.noMoney) + return + end + local ok = Bag.add(self.save, entry.id, self.qty, + self.game and self.game.data) + if not ok then + self.phase = "buy" + self:say(self.text.packFull) + return + end + if self.martType == "BARGAIN" then + -- FlagAction SET_FLAG on wBargainShopFlags. The cart mirrors this into + -- DAILYFLAGS1_GOLDENROD_UNDERGROUND_BARGAIN on the way out so the stock + -- comes back with the day; the daily rollover is not modelled here, so the + -- flag simply lives on the save. + self.save.bargainShop = self.save.bargainShop or {} + self.save.bargainShop[entry.id] = true + entry.soldOut = true + end + -- .proceed's order: the bargain flag, PlayTransactionSound, TakeMoney, and + -- only then MARTTEXT_HERE_YOU_GO. The till rings on the money moving, not + -- on the clerk's line. + self:playTransaction() + self:setMoney(self:money() - total) + self.phase = "buy" + self:say(self.text.thanks) +end + +-- --------------------------------------------------------- quantity picker +-- +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm). Up and down +-- WRAP through the ends; left and right step by ten and CLAMP instead -- left +-- past 1 lands on 1, right past the ceiling lands on the ceiling. +function MartMenu:quantityStep(delta) + local n = self.qty + delta + if delta == 1 then + if n > self.qtyMax then n = 1 end + elseif delta == -1 then + if n < 1 then n = self.qtyMax end + elseif delta > 0 then + if n > self.qtyMax then n = self.qtyMax end + else + if n <= 0 then n = 1 end + end + self.qty = n +end + +function MartMenu:updateQuantity(input, onAccept, onCancel) + if input:wasPressed("up") then + self:quantityStep(1) + elseif input:wasPressed("down") then + self:quantityStep(-1) + elseif input:wasPressed("right") then + self:quantityStep(10) + elseif input:wasPressed("left") then + self:quantityStep(-10) + elseif input:wasPressed("b") then + onCancel() + elseif input:wasPressed("a") then + onAccept() + end +end + +-- --------------------------------------------------------------- sell flow +-- +-- SellMenu opens DepositSellPack -- the PACK itself, in a mode where A means +-- "sell this" rather than "use this". `world = {}` is a world with no +-- useFieldItem, so PackMenu:useSelected falls straight through to onChoose and +-- no field effect can fire from inside a shop. +-- +-- The PACK is held rather than stacked, so this is Screens.build, not +-- Screens.push: same id, same registry lookup and same mod-screen degrade, but +-- the mart keeps drawing and updating it itself. +function MartMenu:enterSell() + self.phase = "sell" + self.pack = Screens.build(self.game, "Gen2PackMenu", { + save = self.save, + items = self.items, + world = {}, + onChoose = function(itemId, count) self:offerToSell(itemId, count) end, + onClose = function() self:leaveSell() end, + }) +end + +function MartMenu:leaveSell() + self.pack = nil + self:enterTop(self.text.askMore) +end + +-- .TryToSellItem: CheckItemMenu's field-menu value routes everything the PACK +-- can hold to .try_sell, and _CheckTossableItem is the real gate -- a CANT_TOSS +-- item (every KEY ITEM, and the HMs) gets MartCantBuyText and nothing else. +function MartMenu:offerToSell(itemId, count) + local def = self.items and self.items[itemId] + if not def or def.canToss == false then + self:say(self.sellText.cantBuy) + return + end + -- Nothing in stock is not a sale: the PACK only lists rows it holds, and a + -- zero count would otherwise walk the selector's ceiling down to nothing. + if (count or 0) < 1 then + self:say(self.sellText.cantBuy) + return + end + -- ScrollingMenu's .a_button copies the row's own quantity into + -- wItemQuantity, so the selector's ceiling is how many you hold. + self.qtyItem = { id = itemId, name = def.name or itemId, + price = def.price or 0 } + self.qty = 1 + self.qtyMax = count + self.phase = "sellQuantity" +end + +function MartMenu:confirmSale() + local total = MartMenu.sellPrice(self.qtyItem.price, self.qty) + self:ask(self.sellText.price(total), + function() self:completeSale(total) end, + function() self.phase = "sell" end) +end + +function MartMenu:completeSale(total) + local entry = self.qtyItem + self:setMoney(self:money() + total) + Bag.remove(self.save, entry.id, self.qty) + if self.pack then self.pack:rebuild() end + self.phase = "sell" + -- The sell side calls PlayTransactionSound after MartBoughtText rather than + -- before it, but both land on the same frame here: the say() only queues the + -- page the update loop draws. + self:playTransaction() + self:say(self.sellText.bought(entry.name, total)) +end + +-- ----------------------------------------------------------------- update +function MartMenu:update(dt) + local input = self.game and self.game.input + if not input then return end + if self.message then + self:updateMessage(input) + return + end + if self.confirm then + self:updateConfirm(input) + return + end + local phase = self.phase + if phase == "top" then + self:updateTop(input) + elseif phase == "buy" then + self:updateBuy(input) + elseif phase == "buyQuantity" then + self:updateQuantity(input, + function() self:confirmPurchase() end, + function() self.phase = "buy" end) + elseif phase == "sell" then + if self.pack then self.pack:update(dt) end + elseif phase == "sellQuantity" then + self:updateQuantity(input, + function() self:confirmSale() end, + function() self.phase = "sell" end) + end +end + +-- ------------------------------------------------------------------- draw +function MartMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function MartMenu:drawMoneyBox() + Chrome.box(MONEY_BOX_X, MONEY_BOX_Y, MONEY_BOX_W, MONEY_BOX_H) + Chrome.print(moneyText(self:money()), MONEY_X, MONEY_Y) +end + +-- PlaceMoneyBottomLeft / PlaceMoneyAtTopLeftOfTextbox land on the same rect. +function MartMenu:drawMoneyBoxLow() + Chrome.box(MONEY_LOW_BOX_X, MONEY_LOW_BOX_Y, MONEY_BOX_W, MONEY_BOX_H) + Chrome.print(moneyText(self:money()), MONEY_LOW_X, MONEY_LOW_Y) +end + +function MartMenu:drawTopMenu() + Chrome.box(TOP_BOX_X, TOP_BOX_Y, TOP_BOX_W, TOP_BOX_H) + for i, label in ipairs(TOP_ITEMS) do + local ty = TOP_LABEL_Y + (i - 1) * TOP_SPACING + if i == self.topIndex then Chrome.cursor(TOP_LABEL_X - 1, ty) end + Chrome.print(label, TOP_LABEL_X, ty) + end +end + +-- The description under the buy list. A TM prints the MOVE's description on +-- the cart (PrintItemDescription branches at TM01 into PrintMoveDescription), +-- which is the same rule the PACK follows. +function MartMenu:description() + local entry = self:selected() + if not entry then return nil end + local def = self.items and self.items[entry.id] + if def and def.teaches then + local moves = self.game and self.game.data and self.game.data.moves + local moveDef = moves and moves[def.teaches] + if moveDef and moveDef.description then return moveDef.description end + end + return def and def.description or nil +end + +-- The bottom visible entry's price row IS the box's bottom border row +-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a +-- GB glyph REPLACES the tile it prints over, border and all. White under +-- the seven money tiles first is that replacement; on the three rows clear +-- of the border it repaints white over white. +local function printPriceOpaque(amount, ty) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", PRICE_X * 8, ty * 8, 7 * 8, 8) + G.setColor(0, 0, 0, 1) + Chrome.print(moneyText(amount), PRICE_X, ty) +end + +function MartMenu:drawBuyList() + 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 ty = LIST_Y + (row - 1) * LIST_SPACING + if i <= #self.entries then + local entry = self.entries[i] + if i == self.index then Chrome.cursor(LIST_X - 1, ty) end + Chrome.print(entry.name, LIST_X, ty) + printPriceOpaque(entry.price, ty + 1) + elseif i == self:total() then + if i == self.index then Chrome.cursor(LIST_X - 1, ty) end + Chrome.print("CANCEL", LIST_X, ty) + end + end + -- SCROLLINGMENU_DISPLAY_ARROWS: the ▲ only appears once the list has been + -- scrolled, but the ▼ is written every pass whether or not there is more + -- below it. + if self.scroll > 0 then + Chrome.print(UP_ARROW, ARROW_X, ARROW_UP_Y) + end + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_DOWN_Y) +end + +function MartMenu:drawDescription() + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + if self:isCancel() then return end + local description = self:description() + if not description then return end + -- The extractor leaves the description's own '<NEXT>' in place, and $4e + -- steps SCREEN_WIDTH * 2 from the line's start (home/text.asm + -- NextLineChar) -- which is the TEXT_LINE both rows already use. '\n' + -- covers hand-written data. + local first, second = description:match("^(.-)<NEXT>(.*)$") + if not first then first, second = description:match("^(.-)\n(.*)$") end + Chrome.print(first or description, TEXT_X, TEXT_Y) + if second then Chrome.print(second, TEXT_X, TEXT_Y + TEXT_LINE) end +end + +function MartMenu:drawQuantityBox(total) + Chrome.box(QTY_BOX_X, QTY_BOX_Y, QTY_BOX_W, QTY_BOX_H) + Chrome.print(TIMES, QTY_X, QTY_Y) + -- `lb bc, PRINTNUM_LEADINGZEROS | 1, 2`: two digits, zero padded. + Chrome.print(Chrome.number(self.qty, 2, true), QTY_X + 1, QTY_Y) + Chrome.print(moneyText(total), QTY_PRICE_X, QTY_Y) +end + +function MartMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +-- Whatever the overlays sit on top of. +function MartMenu:drawUnder() + local phase = self.phase + if phase == "top" then + Chrome.clear() + self:drawTopMenu() + self:drawTextBox(self.topLines) + elseif phase == "buy" or phase == "buyQuantity" then + Chrome.clear() + self:drawMoneyBox() + self:drawBuyList() + self:drawDescription() + elseif phase == "sell" or phase == "sellQuantity" then + if self.pack then self.pack:drawPanel() end + else + Chrome.clear() + end +end + +function MartMenu:drawPanel() + self:drawUnder() + + if self.phase == "buyQuantity" and not (self.message or self.confirm) then + -- StandardMartAskPurchaseQuantity prints MARTTEXT_HOW_MANY first, then the + -- selector is drawn over the bottom of that box. + self:drawTextBox(self.text.howMany) + self:drawQuantityBox(MartMenu.buyPrice(self.qtyItem.price, self.qty)) + elseif self.phase == "sellQuantity" and not (self.message or self.confirm) then + self:drawTextBox(self.sellText.howMany) + -- .okay_to_sell prints the question, THEN drops the money box on its top + -- left corner, so the box wins where they overlap. + self:drawMoneyBoxLow() + self:drawQuantityBox(MartMenu.sellPrice(self.qtyItem.price, self.qty)) + end + + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + -- LoadBlinkingCursor puts the ▼ at hlcoord 18, 17 while a `para` waits. + if self.message.page < #self.message.pages then + Chrome.print(DOWN_ARROW, 18, 17) + end + elseif self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function MartMenu:draw() + self:drawPanel() +end + +function MartMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MartMenu diff --git a/src/ui/gen2/MoveDeleter.lua b/src/ui/gen2/MoveDeleter.lua new file mode 100644 index 00000000..e429a4ae --- /dev/null +++ b/src/ui/gen2/MoveDeleter.lua @@ -0,0 +1,113 @@ +-- The Blackthorn move deleter's move list (engine/events/move_deleter.asm +-- ChooseMoveToDelete, engine/pokemon/mon_menu.asm). ChooseMoveToDelete shares +-- SetUpMoveScreenBG / SetUpMoveList with the summary screen's move page +-- (src/ui/gen2/SummaryMenu.lua moveDetailPlacements), so the four-slot list +-- geometry is the same box: names at (2, 3 + 2*slot), PP at +-- (10/13/15/16, nameY + 1). What ChooseMoveToDelete does NOT share is the +-- type/power/accuracy plaque under it -- DeleteMoveScreen2DMenuData is a bare +-- `db 3, 1` / `dn 2, 0` menu (3 rows, cursor offset column 2), so only the +-- move list itself is drawn here. +-- +-- The special that opens this (src/script/gen2/Specials.lua H.MoveDeletion) +-- has already run the "which move should it forget" line and refused a mon +-- with only one move before this screen is ever pushed, so this only has to +-- answer a row. + +local Chrome = require("src.ui.gen2.Chrome") +local Sound = require("src.core.Sound") + +local MoveDeleter = {} +MoveDeleter.__index = MoveDeleter +MoveDeleter.isOpaque = false + +function MoveDeleter:wantsFillScale() return true end + +-- opts: mon (the party mon whose moves are listed), moves (data.moves), +-- onChoose(index) -- 1-based move slot, onCancel() +function MoveDeleter.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MoveDeleter) + self.game = game + local data = (game and game.data) or {} + self.mon = opts.mon + self.moves = opts.moves or data.moves + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.list = (self.mon and self.mon.moves) or {} + self.row = 1 + return self +end + +function MoveDeleter:moveName(entry) + if not entry then return "-" end + local def = self.moves and self.moves[entry.id] + return (def and def.name) or entry.id +end + +function MoveDeleter:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then + Sound.play(data, name) + end +end + +function MoveDeleter:finish(index) + if self.done then return end + self.done = true + if index then + if self.onChoose then self.onChoose(index) end + elseif self.onCancel then + self.onCancel() + end +end + +-- ScrollingMenuJoypad, no wrap: DeleteMoveScreen2DMenuData sets no +-- _2DMENU_WRAP bit, so the cursor stops at the ends the way every other +-- unwrapped 2D menu in Gold does. +function MoveDeleter:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local n = #self.list + if n == 0 then + if input:wasPressed("a") or input:wasPressed("b") then self:finish(nil) end + return + end + if input:wasPressed("up") and self.row > 1 then + self.row = self.row - 1 + elseif input:wasPressed("down") and self.row < n then + self.row = self.row + 1 + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:finish(self.row) + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:finish(nil) + end +end + +function MoveDeleter:draw() + Chrome.textbox(0, 1, 18, 9) + for slot = 1, 4 do + local nameY = 3 + (slot - 1) * 2 + local ppY = nameY + 1 + local entry = self.list[slot] + if entry then + Chrome.print(self:moveName(entry), 2, nameY) + Chrome.print("PP", 10, ppY) + Chrome.print(Chrome.number(entry.pp, 2, true), 13, ppY) + Chrome.print("/", 15, ppY) + Chrome.print(Chrome.number(entry.maxPp or entry.pp, 2, true), 16, ppY) + else + Chrome.print("-", 2, nameY) + Chrome.print("--", 10, ppY) + end + end + if self.row >= 1 and self.row <= #self.list then + Chrome.cursor(1, 3 + (self.row - 1) * 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return MoveDeleter diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua new file mode 100644 index 00000000..e9f1011d --- /dev/null +++ b/src/ui/gen2/NamePick.lua @@ -0,0 +1,231 @@ +-- The player-name menu (pokegold engine/menus/intro_menu.asm NamePlayer, +-- data/player_names.asm NameMenuHeader). +-- +-- Transcribed from the header rather than laid out by eye, because almost +-- every coordinate here comes out of one: +-- +-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 the box: (0,0) to (10,11) +-- STATICMENU_CURSOR | STATICMENU_PLACE_TITLE | STATICMENU_DISABLE_B +-- 5 items, default option 1, title indent 2, title "NAME" +-- +-- GetMenuTextStartCoord turns those flags into the label origin: the border +-- costs a row and a column, STATICMENU_CURSOR costs another column, and the +-- absence of STATICMENU_NO_TOP_SPACING costs another row -- so the labels +-- start at (2,2) and step TWO rows each (PlaceVerticalMenuItems adds +-- 2 * SCREEN_WIDTH per item). The cursor sits one column left of the labels, +-- at column 1. The title is placed from MenuBoxCoord2Tile plus the indent, +-- which lands it ON the box's top border at (2,0). +-- +-- The other half of the screen is the pic. NamePlayer opens by calling +-- MovePlayerPicRight, which walks the 7x7 CAL frontpic OakSpeech left at +-- hlcoord 6,4 one tile per frame until it sits at hlcoord 13,4 -- so the menu +-- is not on its own, it slides in beside the player. Picking a preset runs +-- MovePlayerPicLeft to walk it back before returning to the speech. +-- +-- STATICMENU_DISABLE_B is why B does nothing here: there is no way out of +-- this menu except choosing a name. + +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Screens = require("src.ui.Screens") + +local NamePick = {} +NamePick.__index = NamePick +NamePick.isOpaque = true + +-- data/player_names.asm PlayerNameArray, Gold's half of the IF. +local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" } + +-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12). +local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11 +-- GetMenuTextStartCoord's answer for this header's flags. +local TEXT_X, TEXT_Y = 2, 2 +local CURSOR_X = TEXT_X - 1 +local TITLE, TITLE_X, TITLE_Y = "NAME", 2, 0 + +-- Intro_PrepTrainerPic puts the 7x7 pic at hlcoord 6,4; MovePlayerPic walks +-- it to 13,4 one tile per frame. +local PIC_X_LEFT, PIC_X_RIGHT, PIC_Y = 6, 13, 4 +local PIC_TILES = 7 + +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 +function NamePick.new(game, opts) + opts = opts or {} + local self = setmetatable({}, NamePick) + self.game = game + self.onDone = opts.onDone + self.items = { "NEW NAME" } + for _, name in ipairs(opts.presets or PRESETS) do + self.items[#self.items + 1] = name + end + -- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset. + self.cursor = 1 + self.pic = opts.pic + self.picColors = opts.picColors + self.fontOk = false + local font = opts.font + if font then + self.fontOk = pcall(Font.load, { font = font }) + end + -- The approach walk, and then the walk back once a preset is taken. picX + -- is the pic's live tile column rather than a step count: MovePlayerPic + -- walks it one tile per frame and both directions end where they end, so + -- the column IS the state. + self.picX = PIC_X_LEFT + self.slide = "in" + return self +end + +function NamePick:choose(name) + if self.onDone then self.onDone(name) end +end + +-- NEW NAME opens Gold's own keyboard (src/ui/gen2/NamingScreen.lua), not +-- Gen 1's: the two differ in grid size, the case switch, and where DEL/END sit. +function NamePick:openNaming() + local data = self.game and self.game.data or {} + local sprites = data.gen2Sprites + local chris = sprites and sprites.SPRITE_CHRIS + local Palettes = require("src.world.gen2.Palettes") + Screens.push(self.game, "Gen2NamingScreen", { + type = "player", + menuGfx = data.gen2MenuGfx, + iconPath = chris and chris.image or nil, + -- Chris is PAL_OW_RED; the naming screen is lit like day. + iconColors = data.gen2Palettes + and Palettes.spritePalette(data.gen2Palettes, "DAY", chris) or nil, + onDone = function(name) + -- An empty name keeps the default, the way ending entry with nothing + -- typed leaves wPlayerName at its preset. + self.game.stack:pop() -- the naming screen + if name and #name > 0 then + self:choose(name) + else + self:choose(self.items[2] or "GOLD") + end + end, + }) +end + +function NamePick:update(_dt) + -- MovePlayerPic is a blocking DelayFrame loop on the cart: nothing reads + -- the joypad until the pic has finished walking. + if self.slide == "in" then + if self.picX < PIC_X_RIGHT then + self.picX = self.picX + 1 + return + end + self.slide = nil + return + elseif self.slide == "out" then + if self.picX > PIC_X_LEFT then + self.picX = self.picX - 1 + return + end + self.slide = nil + self:choose(self.pendingName) + return + end + + local input = self.game.input + if not input then return end + if input:wasPressed("up") then + self.cursor = self.cursor > 1 and self.cursor - 1 or #self.items + elseif input:wasPressed("down") then + self.cursor = self.cursor < #self.items and self.cursor + 1 or 1 + elseif input:wasPressed("a") or input:wasPressed("start") then + -- `ld a, [wMenuCursorY]; dec a; jr z, .NewName`: item 1 is NEW NAME and + -- everything else is a preset. B is deliberately not handled -- + -- STATICMENU_DISABLE_B. + if self.cursor == 1 then + if self.fontOk then + self:openNaming() + else + self:choose(self.items[2] or "GOLD") + end + else + -- A preset returns through MovePlayerPicLeft, so the pic walks back + -- before the speech resumes. + self.pendingName = self.items[self.cursor] + self.slide = "out" + end + end +end + +function NamePick:drawPic() + if not self.pic then return end + local G = love.graphics + local w, h = self.pic:getDimensions() + -- PlaceGraphic lays a 7x7 block from the coordinate; a pic smaller than + -- that sits in the block's bottom-left, as PadFrontpic leaves it. + local x = self.picX * 8 + local y = PIC_Y * 8 + (PIC_TILES - h / 8) * 8 + local function body() + G.setColor(1, 1, 1, 1) + G.draw(self.pic, x, y) + end + if self.picColors and GbcPalette.available() then + GbcPalette.with(self.picColors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function NamePick:drawPanel() + local G = love.graphics + Chrome.clear() + self:drawPic() + -- The menu is only up once the pic has finished walking over: MenuBox is + -- drawn by ShowPlayerNamingChoices, which NamePlayer calls after the walk. + if self.slide == "in" then return end + if not self.fontOk then + G.setColor(0, 0, 0, 1) + for index, label in ipairs(self.items) do + local prefix = (index == self.cursor) and "> " or " " + G.print(prefix .. label, TEXT_X * 8, (TEXT_Y + (index - 1) * 2) * 8) + end + G.setColor(1, 1, 1, 1) + return + end + -- MenuBox: GetMenuBoxDims then `dec b / dec c`, so the interior is one less + -- than the span in each direction. + Chrome.textbox(BOX_X1, BOX_Y1, BOX_X2 - BOX_X1 - 1, BOX_Y2 - BOX_Y1 - 1) + -- PlaceString REPLACES the tilemap cells it lands on, and the title lands + -- on the box's top border -- so the border tiles under it have to go, or + -- the letters sit on a line the cart does not draw there. + G.setColor(1, 1, 1, 1) + G.rectangle("fill", TITLE_X * 8, TITLE_Y * 8, #TITLE * 8, 8) + Chrome.print(TITLE, TITLE_X, TITLE_Y) + for index, label in ipairs(self.items) do + Chrome.print(label, TEXT_X, TEXT_Y + (index - 1) * 2) + end + Chrome.cursor(CURSOR_X, TEXT_Y + (self.cursor - 1) * 2) + G.setColor(1, 1, 1, 1) +end + +function NamePick:draw() + self:drawPanel() +end + +function NamePick: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +NamePick.PRESETS = PRESETS + +return NamePick diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua new file mode 100644 index 00000000..01e07c07 --- /dev/null +++ b/src/ui/gen2/NamingScreen.lua @@ -0,0 +1,553 @@ +-- Gen 2 naming screen: the on-screen keyboard Gold uses for the player, the +-- rival, mom, a box, and mon nicknames. Ported from +-- engine/menus/naming_screen.asm. +-- +-- Layout (tile coords, transcribed from the ASM's hlcoord calls): +-- whole screen filled with NAMINGSCREEN_BORDER +-- (1,1) 6x18 cleared -- header: icon, prompt at (5,2), entry field at (5,6) +-- (1,8) 7x18 cleared -- keyboard: 5 rows at y = 8,10,12,14,16 +-- letters at x = 2,4,...,18 (nine per row) +-- NAME_BOX gets a sixth row and a shorter header (4x18 / 9x18 at y=6) +-- +-- Cursor grid is 9 wide by 5 rows (6 for a box). The bottom row is not nine +-- letters but three fat targets -- case switch, DEL, END -- which is why the +-- ASM keeps a separate x-offset table for it (.CaseDelEnd: $00,$00,$00, +-- $30,$30,$30,$60,$60,$60) instead of the even $10 steps letters use. +-- +-- SELECT toggles case anywhere; START jumps the cursor onto END; B deletes. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Runtime = require("src.mods.Runtime") + +local NamingScreen = {} +NamingScreen.__index = NamingScreen +NamingScreen.isOpaque = true + +-- data/text/name_input_chars.asm. Each row is a 17-character string with the +-- letters on the even columns, so cell N is the character at index N*2 - 1. +-- Rows whose cells are not single characters (the box screen's dakuten pairs +-- and multi-byte glyphs) are written out as arrays instead. +local function rowCells(row) + local out = {} + for i = 1, 9 do + local ch = row:sub(i * 2 - 1, i * 2 - 1) + out[i] = ch + end + return out +end + +local NAME_INPUT_UPPER = { + rowCells("A B C D E F G H I"), + rowCells("J K L M N O P Q R"), + rowCells("S T U V W X Y Z "), + rowCells("- ? ! / . , "), +} +local NAME_INPUT_LOWER = { + rowCells("a b c d e f g h i"), + rowCells("j k l m n o p q r"), + rowCells("s t u v w x y z "), + { "\xc3\x97", "(", ")", ":", ";", "[", "]", "<PK>", "<MN>" }, +} +-- BOX_NAME gets one extra symbol row in each case (BoxNameInput*). +local BOX_INPUT_UPPER = { + NAME_INPUT_UPPER[1], NAME_INPUT_UPPER[2], NAME_INPUT_UPPER[3], + { "\xc3\x97", "(", ")", ":", ";", "[", "]", "<PK>", "<MN>" }, + { "-", "?", "!", "\xe2\x99\x82", "\xe2\x99\x80", "/", ".", ",", "&" }, +} +local BOX_INPUT_LOWER = { + NAME_INPUT_LOWER[1], NAME_INPUT_LOWER[2], NAME_INPUT_LOWER[3], + { "\xc3\xa9", "'d", "'l", "'m", "'r", "'s", "'t", "'v", "0" }, + { "1", "2", "3", "4", "5", "6", "7", "8", "9" }, +} + +-- The bottom row's three targets and the columns their labels start at. The +-- case target names the board it SWITCHES TO, not the one it is on: the last +-- row of NameInputUpper is "lower DEL END" and the last row of +-- NameInputLower is "UPPER DEL END" (data/text/name_input_chars.asm). +local BOTTOM_UPPER_LABELS = { "lower", "DEL", "END" } +local BOTTOM_LOWER_LABELS = { "UPPER", "DEL", "END" } +-- Cursor tile for each target: NamingScreen_AnimateCursor's .CaseDelEnd adds +-- pixel $00 / $30 / $60 to the cursor's own XCOORD of 24 (`depixel 10, 3`), +-- which is OAM x 24 / 72 / 120 and so screen tile 2 / 8 / 14. The bracket is +-- five tiles wide there, so it wraps the whole label rather than sitting left +-- of it. +local BOTTOM_CURSOR_TX = { 2, 8, 14 } +local BOTTOM_LABEL_TX = { 2, 9, 15 } +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" }, + 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 }, +} + +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(). +function NamingScreen.new(game, opts) + opts = opts or {} + local self = setmetatable({}, NamingScreen) + self.game = game + local kind = NamingScreen.TYPES[opts.type or "player"] + or NamingScreen.TYPES.player + self.kind = kind + 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?" + self.monName = opts.monName + self.onDone = opts.onDone + self.onCancel = opts.onCancel + self.lower = false -- wNamingScreenLetterCase; upper first + self.text = opts.initial or "" + self.col = 0 + self.row = 0 + self.iconImage = nil + if opts.iconPath then + local ok, img = pcall(Assets.image, opts.iconPath) + if ok then self.iconImage = img end + end + -- The header icon is an OBJ on the cart, so it wears a real palette; without + -- one it would draw in raw DMG shades next to a colored world. + self.iconColors = opts.iconColors + self.gfx = opts.menuGfx + self.tiles = {} + if self.gfx then + for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do + if self.gfx[key] then + local ok, img = pcall(Assets.image, self.gfx[key]) + if ok then self.tiles[key] = img end + end + end + end + return self +end + +-- ui.naming.grid identity: unhooked, the board is the one the cart ships. +local function sameGrid(grid) return grid end + +-- The letter rows of the page that is showing, through the ui.naming.grid hook +-- -- the same name and the same (grid, ctx) payload the Gen 1 screen uses +-- (src/ui/NamingScreen.lua), so a mod that splices digits onto a row does it +-- once for both generations. Guarded with wantsHook because update and draw +-- both read the board every frame; unhooked this is the module's own table. +-- +-- Only the LETTER rows travel through the hook. Gold keeps the case / DEL / +-- END targets off the board entirely, in a fat bottom row of their own +-- (NamingScreen_AnimateCursor's .CaseDelEnd), so unlike Gen 1 there is no meta +-- cell a hooked grid could drop -- but bottomRow() counts the rows it answers +-- with, so a page carrying an extra row still keeps those targets under it. +function NamingScreen:rows() + local base + if self.isBox then + base = self.lower and BOX_INPUT_LOWER or BOX_INPUT_UPPER + else + base = self.lower and NAME_INPUT_LOWER or NAME_INPUT_UPPER + end + if not Runtime.wantsHook("ui.naming.grid") then return base end + local hooked = Runtime.call("ui.naming.grid", sameGrid, base, { + lower = self.lower and true or false, + -- `title` is the Gen 1 key for the line above the entry field; here that + -- is the prompt ("YOUR NAME?"). `box` is Gen 2's own: a BOX NAME page has + -- a row the name pages do not. + title = self.prompt, + maxLen = self.maxLength, + box = self.isBox, + game = self.game, + }) + if type(hooked) ~= "table" or #hooked == 0 then return base end + return hooked +end + +-- The keyboard's first row, and the row index the bottom (case/DEL/END) row +-- sits at. A box screen shifts everything up two rows to fit its sixth row. +function NamingScreen:keyboardTop() + return self.isBox and 6 or 8 +end + +-- One past the last letter row: four on a name page, five on a box one, and +-- whatever a hooked grid answers with (see rows()). +function NamingScreen:bottomRow() + return #self:rows() +end + +function NamingScreen:onBottomRow() + return self.row == self:bottomRow() +end + +-- Which of the three fat targets the cursor is on (1 case, 2 delete, 3 end), +-- mirroring NamingScreen_GetCursorPosition's `cp $3 / cp $6` split. +function NamingScreen:bottomTarget() + if self.col < 3 then return 1 end + if self.col < 6 then return 2 end + return 3 +end + +-- A blank cell is a real SPACE, not a dead key. ApplyTextInputMode writes the +-- NameInput* rows straight into the tilemap and NamingScreen_GetLastCharacter +-- reads the tile UNDER the cursor back out of it (hlcoord 0,0 + row*SCREEN_WIDTH +-- + col), so the trailing blanks in "S T U V W X Y Z " and "- ? ! / . , " +-- (data/text/name_input_chars.asm) type a space like any other character. Only +-- a cell that is not on the board at all answers nil. Trimming is not this +-- screen's job either: it stores what was typed and InitName decides whether +-- the result counts as blank (home/string.asm:6-30). +function NamingScreen:characterAt(col, row) + local grid = self:rows() + local line = grid[row + 1] + local ch = line and line[col + 1] + if not ch or ch == "" then return nil end + return ch +end + +function NamingScreen:addCharacter(ch) + if not ch then return end + if #self.text >= self.maxLength then return end + self.text = self.text .. ch +end + +function NamingScreen:deleteCharacter() + if #self.text == 0 then return end + self.text = self.text:sub(1, #self.text - 1) +end + +function NamingScreen:toggleCase() + self.lower = not self.lower +end + +function NamingScreen:accept() + local name = self.text + if self.onDone then self.onDone(name) end +end + +-- Cursor movement. On the letter rows the columns step one at a time and wrap +-- (.right / .wrap_left); on the bottom row left/right hop between the three +-- targets, which the ASM does by rounding the column to a multiple of three. +function NamingScreen:moveHorizontal(delta) + if self:onBottomRow() then + local target = self:bottomTarget() + delta + if target < 1 then target = 3 end + if target > 3 then target = 1 end + self.col = (target - 1) * 3 + return + end + self.col = self.col + delta + if self.col < 0 then self.col = 8 end + if self.col > 8 then self.col = 0 end +end + +function NamingScreen:moveVertical(delta) + local last = self:bottomRow() + self.row = self.row + delta + if self.row < 0 then self.row = last end + if self.row > last then self.row = 0 end + -- Coming onto the bottom row from a letter column lands on a target rather + -- than between two. + if self:onBottomRow() then + self.col = (self:bottomTarget() - 1) * 3 + end +end + +function NamingScreen:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if input:wasPressed("left") then + self:moveHorizontal(-1) + return + elseif input:wasPressed("right") then + self:moveHorizontal(1) + return + elseif input:wasPressed("up") then + self:moveVertical(-1) + return + elseif input:wasPressed("down") then + self:moveVertical(1) + return + elseif input:wasPressed("select") then + self:toggleCase() + return + elseif input:wasPressed("start") then + -- .start parks the cursor on END (var1 = $8, var2 = last row). + self.row = self:bottomRow() + self.col = 6 + return + elseif input:wasPressed("b") then + -- B is delete, not cancel: the only way out is END (or an empty name, + -- which callers treat as "keep the default"). + self:deleteCharacter() + return + elseif input:wasPressed("a") then + if self:onBottomRow() then + local target = self:bottomTarget() + if target == 1 then + self:toggleCase() + elseif target == 2 then + self:deleteCharacter() + else + self:accept() + end + return + end + self:addCharacter(self:characterAt(self.col, self.row)) + -- Filling the last slot does NOT end entry. `.a` is + -- `call NamingScreen_TryAddCharacter / ret nc`, and + -- NamingScreen_AdvanceCursor_CheckEndOfString returns CARRY when the buffer + -- just filled, so the handler falls through into `.start` and parks the + -- cursor on END (VAR1 $8, VAR2 the bottom row) with the screen still + -- running (engine/menus/naming_screen.asm:401-410). Only `.end`, i.e. A + -- pressed on END, stores the entry and sets JUMPTABLE_EXIT_F. Pressing A + -- on a letter again is already a no-op here, matching + -- MailComposition_TryAddCharacter's `cp c / ret nc`. + if #self.text >= self.maxLength then + self.row = self:bottomRow() + self.col = 6 + end + return + end +end + +-- The backdrop: one patterned tile repeated over the whole screen. Without +-- menu_gfx.lua (older cache) fall back to a flat mid gray, which keeps the +-- cleared panels readable. +function NamingScreen:drawBackdrop() + local G = love.graphics + local tile = self.tiles.border + if not tile then + G.setColor(0.62, 0.62, 0.62, 1) + G.rectangle("fill", 0, 0, 160, 144) + G.setColor(1, 1, 1, 1) + return + end + G.setColor(1, 1, 1, 1) + for ty = 0, Chrome.SCREEN_H - 1 do + for tx = 0, Chrome.SCREEN_W - 1 do + G.draw(tile, tx * 8, ty * 8) + end + end +end + +-- The cursor (data/sprite_anims/oam.asm .OAMData_TextEntryCursor and +-- .OAMData_TextEntryCursorBig). It is not an arrow beside the character: it is +-- a box drawn AROUND the cell, stamped out of gfx/naming_screen/cursor.2bpp +-- tile $00 -- one corner carrying a top edge and a left edge -- four times with +-- X/Y flips. The wide bottom-row bracket repeats tile $01 (top edge alone) +-- between the two corner pairs. +-- +-- The frame offsets sit the top and left edges one pixel outside the cell +-- (`dbsprite -1, -1, 7, 7` is dy/dx = -1), while the flipped copies put the +-- bottom and right edges on the cell's last pixel row and column. Sprite +-- coordinates are OAM's, so XCOORD 24 / YCOORD 80 is screen (16, 64): the +-- first letter's own tile, not the gutter left of it. +local CURSOR_TILE_H = 8 + +-- Where the bracket sits, in tiles, plus how many tiles wide it is: +-- .LetterEntries steps XOFFSET by $10 a column and AnimateCursor swaps the row +-- into YOFFSET (row * $10), so the letter rows land on the character's own +-- tile. Split out from the draw so a test can read it without a canvas. +function NamingScreen:cursorTile() + if self:onBottomRow() then + return BOTTOM_CURSOR_TX[self:bottomTarget()], + self:keyboardTop() + self:bottomRow() * 2, BOTTOM_CURSOR_TILES + end + return 2 + self.col * 2, self:keyboardTop() + self.row * 2, 1 +end + +function NamingScreen:drawCursorTile(quad, x, y, flipX, flipY) + love.graphics.draw(self.tiles.cursor, quad, x, y, 0, + flipX and -1 or 1, flipY and -1 or 1, + flipX and 8 or 0, flipY and CURSOR_TILE_H or 0) +end + +function NamingScreen:drawCursorBox(tx, ty, tilesWide) + local G = love.graphics + local sheet = self.tiles.cursor + if not sheet then + -- No extracted cursor art: the shared ▶ in the gutter left of the cell. + Chrome.cursor(tx - 1, ty) + return + end + G.setColor(1, 1, 1, 1) + -- Both quads are cut once and kept: this runs every frame the keyboard is + -- up, and a fresh Quad per draw churns the GC. + if not self.cursorQuads then + local sw, sh = sheet:getDimensions() + local corner = love.graphics.newQuad(0, 0, 8, CURSOR_TILE_H, sw, sh) + self.cursorQuads = { + corner = corner, + edge = sh >= 2 * CURSOR_TILE_H + and love.graphics.newQuad(0, CURSOR_TILE_H, 8, CURSOR_TILE_H, sw, sh) + or corner, + } + end + local corner, edge = self.cursorQuads.corner, self.cursorQuads.edge + local x0, y0 = tx * 8, ty * 8 + if (tilesWide or 1) <= 1 then + self:drawCursorTile(corner, x0 - 1, y0 - 1, false, false) + self:drawCursorTile(corner, x0, y0 - 1, true, false) + self:drawCursorTile(corner, x0 - 1, y0, false, true) + self:drawCursorTile(corner, x0, y0, true, true) + return + end + local right = x0 + (tilesWide - 1) * 8 + self:drawCursorTile(corner, x0, y0 - 1, false, false) + self:drawCursorTile(corner, right, y0 - 1, true, false) + self:drawCursorTile(corner, x0, y0, false, true) + self:drawCursorTile(corner, right, y0, true, true) + for i = 1, tilesWide - 2 do + self:drawCursorTile(edge, x0 + i * 8, y0 - 1, false, false) + self:drawCursorTile(edge, x0 + i * 8, y0, false, true) + end +end + +function NamingScreen:clearPanel(tx, ty, tw, th) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + G.setColor(0, 0, 0, 1) +end + +-- The entry field: typed characters, then an underline in the next slot, then +-- middle lines for the rest (NamingScreen_InitNameEntry). +function NamingScreen:drawEntry(tx, ty) + local G = love.graphics + local pen = tx * 8 + local length = #self.text + for i = 1, self.maxLength do + if i <= length then + G.setColor(0, 0, 0, 1) + Font.draw(self.text:sub(i, i), pen, ty * 8) + else + local isNext = i == length + 1 + local glyph = isNext and self.tiles.underLine or self.tiles.middleLine + if glyph then + G.setColor(0, 0, 0, 1) + G.draw(glyph, pen, ty * 8) + else + -- No extracted line tiles: draw them. Underline sits on the cell's + -- baseline, the middle line halfway up, same as the 1bpp art. + G.setColor(0, 0, 0, 1) + G.rectangle("fill", pen + 1, ty * 8 + (isNext and 7 or 4), 6, 1) + end + end + pen = pen + 8 + end + G.setColor(1, 1, 1, 1) +end + +function NamingScreen:drawPanel() + local G = love.graphics + self:drawBackdrop() + + local headerH = self.isBox and 4 or 6 + self:clearPanel(1, 1, 18, headerH) + local keyboardTop = self:keyboardTop() + local keyboardH = self.isBox and 9 or 7 + self:clearPanel(1, keyboardTop, 18, keyboardH) + -- NamingScreen_ApplyTextInputMode clears the bottom row separately + -- (hlcoord 1, 16 / lb bc, 1, 18), leaving one patterned row between the + -- letters and the case/DEL/END strip. + self:clearPanel(1, 16, 18, 1) + + -- Header: the standing-down frame of a 16x96 OW sheet (or the first 16x16 of + -- a mon icon) on the left, and the prompt at (5,2). Quad it: blitting the + -- whole sheet paints every walk frame down the screen. + if self.iconImage then + G.setColor(1, 1, 1, 1) + local w, h = self.iconImage:getDimensions() + local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h), + w, h) + if self.iconColors and GbcPalette.available() then + GbcPalette.with(self.iconColors, + function() G.draw(self.iconImage, quad, 16, 16) end) + else + G.draw(self.iconImage, quad, 16, 16) + end + end + if self.monName then + -- Nickname header is two lines: "<MON>'S" then "NICKNAME?". + Chrome.print(self.monName .. "'S", 5, 2) + Chrome.print("NICKNAME?", 5, 4) + else + Chrome.print(self.prompt, 5, 2) + end + self:drawEntry(5, self.isBox and 4 or 6) + + -- Keyboard rows. + local grid = self:rows() + local bottom = self:bottomRow() + for row = 0, bottom - 1 do + local line = grid[row + 1] or {} + for col = 0, 8 do + local ch = line[col + 1] + if ch and ch ~= " " and ch ~= "" then + Chrome.print(ch, 2 + col * 2, keyboardTop + row * 2) + end + end + end + local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS + local bottomY = keyboardTop + bottom * 2 + for i, label in ipairs(labels) do + Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY) + end + + self:drawCursorBox(self:cursorTile()) + G.setColor(1, 1, 1, 1) +end + +function NamingScreen:draw() + self:drawPanel() +end + +function NamingScreen:drawWidescreen(winW, winH) + local G = love.graphics + -- The naming screen's own patterned backdrop is the surround: extend it to + -- the window edges so a widescreen boot has no black pillarbox. + self:drawBackdrop() + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.setColor(0.62, 0.62, 0.62, 1) + G.rectangle("fill", 0, 0, winW, winH) + G.setColor(1, 1, 1, 1) + if self.tiles.border then + local tilesX = math.ceil(winW / (8 * scale)) + local tilesY = math.ceil(winH / (8 * scale)) + G.push() + G.scale(scale, scale) + for ty = 0, tilesY do + for tx = 0, tilesX do + G.draw(self.tiles.border, tx * 8, ty * 8) + end + end + G.pop() + end + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +-- Exported for tests: the character the cursor is over right now. +function NamingScreen:cursorCharacter() + if self:onBottomRow() then + local target = self:bottomTarget() + return ({ "CASE", "DEL", "END" })[target] + end + return self:characterAt(self.col, self.row) +end + +NamingScreen.NAME_INPUT_UPPER = NAME_INPUT_UPPER +NamingScreen.NAME_INPUT_LOWER = NAME_INPUT_LOWER +NamingScreen.BOX_INPUT_UPPER = BOX_INPUT_UPPER +NamingScreen.BOX_INPUT_LOWER = BOX_INPUT_LOWER + +return NamingScreen diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua new file mode 100644 index 00000000..00684b3a --- /dev/null +++ b/src/ui/gen2/OakSpeech.lua @@ -0,0 +1,648 @@ +-- Gen 2 Oak speech (pokegold OakSpeech in engine/menus/intro_menu.asm). +-- Retail order: InitClock → Oak → Marill wipe (+ cry) → Oak → player pic → +-- NamePlayer → ready → ShrinkPlayer. Gender select is Crystal-later; +-- InitClock is not, it is the very first thing OakSpeech farcalls +-- (engine/menus/intro_menu.asm) and it is src/ui/gen2/InitClock.lua. +-- +-- The beats are a DATA TABLE for the same reason Gen 1's are +-- (src/ui/OakSpeech.lua): Gold has a real Oak speech, so it is the same +-- extension point under the same names rather than a second one. Concretely +-- this screen carries: +-- +-- hook intro.oak_speech.build (steps, speech) -> steps +-- event intro.oak_speech.started { speech, steps } +-- event intro.oak_speech.step { speech, step, index } +-- event intro.oak_speech.answered { speech, step, index, label, value, +-- saveKey } +-- event intro.oak_speech.finished { speech, answers } +-- +-- Same names, same payload keys, same moments in the sequence as the Gen 1 +-- site: `started` before the first beat runs, `step` immediately before each +-- beat, `answered` whenever a beat produces a value (the name menu, and any +-- choice a mod inserted), `finished` once, on the way out. Step ids match +-- Gen 1's wherever the moment is the same one -- oak_welcome, demo_mon, +-- world_spiel, ask_player_name, name_player, legend, shrink -- so a mod that +-- does insertBefore("name_player", ...) lands in the right place in both +-- games. The two ids with no Gen 1 counterpart are Gold's own beats: +-- `init_clock` (the farcall InitClock this speech opens with) and `oak_study` +-- (the return to Oak for _OakText5, which Red's speech does not have). +-- +-- Gen 1 has no confirm_player_name / ask_rival_name / name_rival / +-- confirm_rival_name equivalents here on purpose: Gold's Oak never says the +-- name back and never asks for the rival's, so inventing those anchors would +-- promise moments that do not exist (the rival is named by the CopScript in +-- maps/ElmsLab.asm, hours later). + +-- src/render/Assets.lua is the mod-override choke point: a raw +-- love.graphics.newImage skips overrides/ and AssetTransform output. +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Logger = require("src.core.Logger") +local Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") + +local OakSpeech = {} +OakSpeech.__index = OakSpeech +OakSpeech.isOpaque = true + +local FADE_FRAMES = 24 +local WIPE_FRAMES = 32 + +local FALLBACKS = { + _OakText1 = Strings("Hello! Sorry to\nkeep you waiting!\fWelcome to the\nworld of POKéMON!\fMy name is OAK.\fPeople call me the\nPOKéMON PROF."), + _OakText2 = Strings("This world is in-\nhabited by crea-\vtures that we call\vPOKéMON."), + _OakText4 = Strings("People and POKéMON\nlive together by\fsupporting each\nother.\fSome people play\nwith POKéMON, some\vbattle with them."), + _OakText5 = Strings("But we don't know\neverything about\vPOKéMON yet.\fThere are still\nmany mysteries to\vsolve.\fThat's why I study\nPOKéMON every day."), + _OakText6 = Strings("Now, what did you\nsay your name was?"), + _OakText7 = Strings("{PLAYER}, are you\nready?\fYour very own\nPOKéMON story is\vabout to unfold.\fYou'll face fun\ntimes and tough\vchallenges.\fA world of dreams\nand adventures\fwith POKéMON\nawaits! Let's go!\fI'll be seeing you\nlater!"), +} + +local function tryImage(path) + if not path then return nil end + local ok, image = pcall(Assets.image, path) + return ok and image or nil +end + +-- Naming presets are boot config the same way Gen 1 reads them +-- (field.boot.namePresets), so a total conversion that replaces the list once +-- replaces it for both games; NamePick.PRESETS (data/player_names.asm +-- PlayerNameArray) is Gold's fallback. +local function namePresets(game, who, fallback) + local boot = game and game.data and game.data.field + and game.data.field.boot + local presets = boot and boot.namePresets and boot.namePresets[who] + if type(presets) == "table" and #presets > 0 then return presets end + return fallback +end + +function OakSpeech:wantsFillScale() return true end +function OakSpeech:drawsWidescreen() return true end + +function OakSpeech.new(game, opts) + opts = opts or {} + local self = setmetatable({}, OakSpeech) + self.game = game + self.onDone = opts.onDone + local data = opts.data or {} + self.cfg = data + self.texts = data.text or {} + self.oakPic = tryImage(data.oakPic or "assets/generated/intro/oak.png") + -- player.sprite, where Gen 1's Oak speech raises it (src/ui/OakSpeech.lua:121). + 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 })) + self.marillPic = tryImage(data.marillPic + or "assets/generated/battle/front/marill.png") + self.shrinkPic1 = tryImage(data.shrink1 or "assets/generated/intro/shrink1.png") + self.shrinkPic2 = tryImage(data.shrink2 or "assets/generated/intro/shrink2.png") + self.music = data.music or "Music_Route30" + self.demoSpecies = data.demoSpecies or "MARILL" + -- Every pic on this screen is loaded under SCGB_TRAINER_OR_MON_FRONTPIC_PALS, + -- which is _CGB_PlayerOrMonFrontpicPals -- the pic's own two shipped colours + -- bracketed by white and black, exactly as a battle pic gets them. The + -- speech names the class the ASM names: POKEMON_PROF for Oak, CAL for the + -- player (Chris shares Cal's colours, which is why the extractor calls row 0 + -- PLAYER). The shrink frames are the player still, so they wear his. + local palettes = (game and game.data and game.data.gen2Palettes) or nil + self.palettes = palettes + self.oakColors = Palettes.trainerColors(palettes, "POKEMON_PROF") + self.playerColors = Palettes.trainerColors(palettes, "CAL") + self.marillColors = Palettes.monColors(palettes, self.demoSpecies) + self.picColors = nil + self.fontOk = false + local font = opts.font + if font then + local ok = pcall(Font.load, { font = font }) + self.fontOk = ok + end + self.step = 0 + self.steps = nil + self.answers = {} + self.pic = nil + self.picFlip = false + self.picReveal = nil + self.busy = false + self.shrink = nil + self.shrinkText = nil + return self +end + +function OakSpeech:text(key) + local t = self.texts[key] + if type(t) == "string" and #t > 0 then return t end + return FALLBACKS[key] or "" +end + +-- ------------------------------------------------------------------ steps + +-- 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 { + -- `farcall InitClock` is the first line of OakSpeech. + { id = "init_clock", kind = "initclock" }, + -- Intro_PrepTrainerPic POKEMON_PROF, FadeInIntroPic, OakText1. + { id = "oak_welcome", kind = "say", textKey = "_OakText1", + pic = "oak", reveal = "fade" }, + -- The Marill show-off: MovePicRight wipes it in, then its cry, then + -- OakText2. Gen 1's demo_mon beat with a different mon. + { id = "demo_mon", kind = "demo" }, + -- OakText4 over the same pic, exactly as Gen 1's world_spiel prints + -- OakSpeechText2B over the NIDORINO already on screen. + { id = "world_spiel", kind = "say", textKey = "_OakText4" }, + -- Back to Oak for OakText5. Red's speech never returns to him, so this + -- id is Gold's own. + { id = "oak_study", kind = "say", textKey = "_OakText5", + pic = "oak", reveal = "fade" }, + -- The CAL frontpic comes up under the question NamePlayer answers. + { id = "ask_player_name", kind = "say", textKey = "_OakText6", + pic = "player", reveal = "fade" }, + { id = "name_player", kind = "name", who = "player", saveKey = "name" }, + -- OakText7 with the pic already up: NamePlayer walked it back itself + -- (MovePlayerPicLeft), so there is nothing to reveal here. + { id = "legend", kind = "say", textKey = "_OakText7", pic = "player" }, + { id = "shrink", kind = "shrink", textKey = "_OakText7" }, + } +end + +local function sameSteps(steps) return steps end + +-- intro.oak_speech.build, the same hook Gen 1 offers and with the same +-- contract: a wrapper is handed the step list and the speech and returns a +-- step list. Anything else degrades to vanilla with a logged line rather +-- than dropping the player into a speech that cannot run. +function OakSpeech:buildSteps() + local steps = OakSpeech.defaultSteps(self) + if not Runtime.wantsHook("intro.oak_speech.build") then return steps end + local hooked = Runtime.call("intro.oak_speech.build", sameSteps, steps, self) + if type(hooked) ~= "table" then + Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps", + type(hooked)) + return steps + end + return hooked +end + +function OakSpeech:enter() + 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 + self.step = 0 + self.answers = {} + self.steps = self:buildSteps() + if Runtime.wants("intro.oak_speech.started") then + Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) + end + self:advance() +end + +-- ------------------------------------------------------------------ pics + +-- A step's `pic`. The three names are this speech's own art; the table forms +-- are for a build wrapper bringing its own, and they carry their palette the +-- 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 == "demo" then return self.marillPic, self.marillColors end + if type(desc) ~= "table" then return nil, nil end + if desc.type == "pokemon" then + local mon = self.game and self.game.data and self.game.data.pokemon + local def = mon and mon[desc.id] + return tryImage(def and def.spriteFront), + desc.colors or Palettes.monColors(self.palettes, desc.id) + end + if desc.type == "trainer" then + return tryImage(desc.path), + desc.colors or Palettes.trainerColors(self.palettes, desc.id) + end + -- { type = "image", path = ..., colors = ... }, and a pre-loaded image. + if desc.path then return tryImage(desc.path), desc.colors end + return desc.image, desc.colors +end + +-- picFlip belongs to the pic and not to the step: only a beat that changes +-- the pic may change it, so a text-only beat leaves whatever is on screen +-- exactly as the beat before it left it. +function OakSpeech:applyPic(step) + if step.pic == nil then return end + local img, colors = self:resolvePic(step.pic) + self.pic = img + self.picColors = colors + self.picFlip = step.flip and true or false +end + +function OakSpeech:reveal(kind, next) + self.picReveal = { + kind = kind, + t = 0, + dur = kind == "wipe" and WIPE_FRAMES or FADE_FRAMES, + next = next, + } +end + +function OakSpeech:afterReveal(step, fn) + if step.reveal then + self:reveal(step.reveal, fn) + else + fn() + end +end + +function OakSpeech:showPic(img, reveal, next, colors) + self.pic = img + self.picColors = colors + self.picFlip = false + if reveal then + self:reveal(reveal, next) + elseif next then + next() + end +end + +function OakSpeech:playCry(species) + local data = self.game and self.game.data + if data and data.audio and data.audio.cries and data.audio.cries[species] then + Sound.playCry(data, species) + end +end + +function OakSpeech:playMarillCry() + self:playCry(self.demoSpecies) +end + +-- A step's own cry: `cry = true` means "the mon this step's pic shows". +function OakSpeech:runCry(step) + local cry = step.cry + if not cry then return end + if cry == true then + if type(step.pic) == "table" and step.pic.type == "pokemon" then + cry = step.pic.id + elseif step.pic == "demo" then + cry = self.demoSpecies + else + return + end + end + self:playCry(cry) +end + +-- ------------------------------------------------------------------ beats + +function OakSpeech:stepText(step) + if step.text then return step.text end + if step.textKey then return self:text(step.textKey) end + return "" +end + +function OakSpeech:sayText(text, next, opts) + self.busy = true + self.game.stack:push(TextBox.new(self.game, text, function() + self.busy = false + if next then next() end + end, opts)) +end + +function OakSpeech:say(key, next) + self:sayText(self:text(key), next) +end + +-- `farcall InitClock` does not return until both halves are confirmed, so the +-- port pushes the screen and only advances on its way out. A build with no +-- stack (a headless logic test) skips straight to Oak, the way every other +-- pushed screen here does. +function OakSpeech:openInitClock() + local game = self.game + if not (game and game.stack) then return self:advance() end + self.busy = true + local pushed = Screens.push(game, "Gen2InitClock", { + mode = "clock", + save = game.save, + autoConfirm = self.autoConfirm, + onDone = function() + game.stack:pop() + self.busy = false + self:advance() + end, + }) + if not pushed then + self.busy = false + self:advance() + end +end + +function OakSpeech:openNamePick(step) + self.busy = true + local NamePick = require("src.ui.gen2.NamePick") + Screens.push(self.game, "Gen2NamePick", { + font = self.game.fontData, + -- 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, + presets = step.presets + or namePresets(self.game, step.presetsWho or step.who or "player", + step.presetsFallback or NamePick.PRESETS), + onDone = function(name) + name = name or "GOLD" + self.game.save.player.name = name + self.game.stack:pop() -- NamePick + self.busy = false + self:recordAnswer(step, 1, name, name) + self:advance() + end, + }) +end + +function OakSpeech:lastPageLines(key) + local body = self:text(key) + local pages = {} + for page in (body .. "\f"):gmatch("(.-)\f") do + pages[#pages + 1] = page + end + local last = pages[#pages] or body + local lines = {} + for line in (last .. "\n"):gmatch("(.-)\n") do + if line ~= "" then lines[#lines + 1] = line end + end + if #lines == 0 and last ~= "" then lines[1] = last end + return lines +end + +function OakSpeech:startShrink(step) + self.shrinkText = self:lastPageLines((step and step.textKey) or "_OakText7") + self.shrink = { frame = 0 } + local data = self.game and self.game.data + if data and data.audio and data.audio.sfx + and data.audio.sfx.Sfx_EscapeRope then + Sound.play(data, "Sfx_EscapeRope") + end +end + +-- A beat that produced a value: the name menu, and any choice/yesno a build +-- wrapper inserted. Same store and same event payload as Gen 1's, so a mod +-- reading answers off intro.oak_speech.answered needs no second listener. +function OakSpeech:recordAnswer(step, index, label, value) + if value == nil then value = label end + if step.saveKey then + self.answers[step.saveKey] = value + end + if Runtime.wants("intro.oak_speech.answered") then + Runtime.emit("intro.oak_speech.answered", { + speech = self, + step = step, + index = index, + label = label, + value = value, + saveKey = step.saveKey, + }) + end +end + +-- The choice menu a build wrapper's `choice` step opens: VerticalMenu, which +-- is Gold's generic list (src/ui/gen2/ScriptMenu.lua) and so already carries +-- the shared ui.list_menu hook. Vanilla Gold has no such beat -- this is the +-- Gen 1 step kind honoured on Gold rather than skipped with a warning. +function OakSpeech:openChoice(step) + local labels = step.choices or {} + local left, top = step.tx or 0, step.ty or 0 + local width = step.tw or 10 + local pushed = Screens.push(self.game, "Gen2ScriptMenu", { + header = { + left = left, top = top, + right = left + width, bottom = top + #labels * 2 + 1, + items = labels, + -- STATICMENU_CURSOR; B is left enabled only when the step asks for it. + dataFlags = 0x80, + cursor = 1, + }, + style = "vertical", + onChoose = function(index) + self.game.stack:pop() + self.busy = false + if index == 0 or index == nil then + if step.cancelable then return self:advance() end + index = 1 + end + local label = labels[index] + local value = label + if step.values and step.values[index] ~= nil then + value = step.values[index] + end + self:recordAnswer(step, index, label, value) + self:advance() + end, + }) + if not pushed then + self.busy = false + self:advance() + end +end + +function OakSpeech:runStep(step) + local kind = step.kind or "say" + if kind == "initclock" then + self:openInitClock() + elseif kind == "say" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:sayText(self:stepText(step), function() self:advance() end) + end) + elseif kind == "demo" then + -- Intro_PrepMonFrontpic + MovePicRight + the cry, then OakText2. + self:showPic(self.marillPic, "wipe", function() + self:playMarillCry() + self:say("_OakText2", function() self:advance() end) + end, self.marillColors) + elseif kind == "pic" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:advance() + end) + elseif kind == "name" then + self:openNamePick(step) + elseif kind == "yesno" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self.busy = true + self:sayText(self:stepText(step), nil, { + instant = true, + choice = function(yes) + self.busy = false + local label = yes and "YES" or "NO" + local value = yes + if step.values then + value = yes and step.values[1] or step.values[2] + end + self:recordAnswer(step, yes and 1 or 2, label, value) + self:advance() + end, + }) + end) + elseif kind == "choice" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + local text = self:stepText(step) + if text ~= "" then + self:sayText(text, function() self:openChoice(step) end) + else + self:openChoice(step) + end + end) + elseif kind == "shrink" then + self:startShrink(step) + elseif kind == "fn" then + -- full escape hatch: step.run(speech, done) + if type(step.run) == "function" then + step.run(self, function() self:advance() end) + else + self:advance() + end + else + Logger.warn("oak speech unknown step kind %s (id=%s); skipping", + tostring(kind), tostring(step.id)) + self:advance() + end +end + +function OakSpeech:advance() + self.step = self.step + 1 + local steps = self.steps + if not steps then + -- enter() builds the list; keep a path for callers that advance early. + steps = self:buildSteps() + self.steps = steps + end + local step = steps[self.step] + if not step then return self:finish() end + if Runtime.wants("intro.oak_speech.step") then + Runtime.emit("intro.oak_speech.step", { + speech = self, step = step, index = self.step, + }) + end + self:runStep(step) +end + +function OakSpeech:finish() + -- Guarded like the Gen 1 site (#308): the shrink timeline keeps ticking + -- while a `finished` listener does whatever it does, and a second emit from + -- the same run would look like a second speech. + if self.finished then return end + self.finished = true + if Runtime.wants("intro.oak_speech.finished") then + Runtime.emit("intro.oak_speech.finished", { + speech = self, answers = self.answers, + }) + end + if self.onDone then self.onDone() end +end + +function OakSpeech:update(_dt) + local r = self.picReveal + if r then + r.t = r.t + 1 + if r.t >= r.dur then + self.picReveal = nil + if r.next then r.next() end + end + return + end + -- ShrinkPlayer timeline (intro_menu.asm ShrinkPlayer): pic1 → pic2 → + -- clear → chris sprite beat → fade music → overworld. + local s = self.shrink + if not s then return end + s.frame = s.frame + 1 + if s.frame == 8 then + self.pic = self.shrinkPic1 or self.pic + elseif s.frame == 16 then + self.pic = self.shrinkPic2 or self.pic + elseif s.frame == 24 then + self.pic = nil + elseif s.frame == 32 then + Music.fadeOut(10) + elseif s.frame >= 80 then + self.shrink = nil + self.shrinkText = nil + self:finish() + end +end + +function OakSpeech:drawPic() + if not self.pic then return end + local G = love.graphics + local w, h = self.pic:getDimensions() + -- Intro_PrepTrainerPic / PrepMonFrontpic: 7x7 cell at hlcoord 6,4. + local x = 48 + math.floor((8 - w / 8) / 2) * 8 + local y = 32 + (7 - h / 8) * 8 + local reveal = self.picReveal + local off = 0 + if reveal and reveal.kind == "fade" then + G.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur)) + elseif reveal and reveal.kind == "wipe" then + off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur))) + else + G.setColor(1, 1, 1, 1) + end + local function body() + if self.picFlip then + G.draw(self.pic, x + off + w, y, 0, -1, 1) + else + G.draw(self.pic, x + off, y) + end + end + -- A "fade" reveal is an alpha ramp, and GbcPalette multiplies the tint into + -- its own output, so the two compose: the shader picks the colour and + -- setColor's alpha still fades it in. + if self.picColors and GbcPalette.available() then + GbcPalette.with(self.picColors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function OakSpeech:drawPanel() + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, 160, 144) + self:drawPic() + if self.shrinkText and self.fontOk then + G.setColor(0, 0, 0, 1) + for i, line in ipairs(self.shrinkText) do + Font.draw(line, 16, 104 + (i - 1) * 16) + end + G.setColor(1, 1, 1, 1) + end +end + +function OakSpeech:draw() + self:drawPanel() +end + +function OakSpeech: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 OakSpeech diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua new file mode 100644 index 00000000..2bbba54c --- /dev/null +++ b/src/ui/gen2/OptionsMenu.lua @@ -0,0 +1,389 @@ +-- Gold's OPTION screen (engine/menus/options_menu.asm _Option). +-- +-- Layout is transcribed from the ASM: one full-screen textbox, StringOptions +-- placed at (2,2) as label / " :" pairs, each option's value printed at +-- (11, labelRow + 1) -- except FRAME, whose number goes at (16,15) after the +-- literal "TYPE". The cursor is a ▶ in column 1 at row 2 + index * 2. +-- +-- Up/down move between rows, left/right change the value under the cursor, and +-- START or B leaves. CANCEL is a row like any other that simply exits. +-- +-- Values are stored on the save's options table by name (see +-- src/core/gen2/Save.lua DEFAULT_OPTIONS) rather than as the packed wOptions +-- byte, so a save stays readable and reordering an enum cannot silently +-- repoint a setting. +-- +-- The row list a screen opens with runs through the ui.options.rows hook, the +-- same name and the same (game, rows) payload the Gen 1 screen uses +-- (src/ui/OptionsMenu.lua), so one mod source can add or drop a row on both +-- generations. + +local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Save = require("src.core.gen2.Save") + +local OptionsMenu = {} +OptionsMenu.__index = OptionsMenu +OptionsMenu.isOpaque = true + +-- Music.setFilterLevel's ladder. +local FILTERS = { "OFF", "1X", "2X", "3X" } + +local function volLabel(v) + v = v or 7 + return v == 0 and "OFF" or tostring(v) +end + +local function stepVolume(v, delta) + return math.max(0, math.min(7, (v or 7) + delta)) +end + +-- Each row: the label, the option key it edits, and the cycle of values with +-- the exact strings the cart prints (trailing spaces included -- they are what +-- blank the longer previous value, e.g. "MID " over "SLOW"). +local ROWS = { + { + label = "TEXT SPEED", key = "textSpeed", + values = { "FAST", "MID", "SLOW" }, + display = { FAST = "FAST", MID = "MID ", SLOW = "SLOW" }, + }, + { + label = "BATTLE SCENE", key = "battleScene", + values = { true, false }, + display = { [true] = "ON ", [false] = "OFF" }, + }, + { + label = "BATTLE STYLE", key = "battleStyle", + values = { "SHIFT", "SET" }, + display = { SHIFT = "SHIFT", SET = "SET " }, + }, + { + label = "SOUND", key = "sound", + values = { "MONO", "STEREO" }, + display = { MONO = "MONO ", STEREO = "STEREO" }, + }, + { + label = "PRINT", key = "print", + values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }, + display = { + LIGHTEST = "LIGHTEST", LIGHTER = "LIGHTER ", NORMAL = "NORMAL ", + DARKER = "DARKER ", DARKEST = "DARKEST ", + }, + }, + { + label = "MENU ACCOUNT", key = "menuAccount", + values = { false, true }, + display = { [false] = "OFF", [true] = "ON " }, + }, + -- FRAME is the textbox border style, 1-8, and prints its number after the + -- word TYPE rather than in the shared value column. + { label = "FRAME", key = "frame", frame = true }, + -- Everything from here down is the port's, not the cart's. They are the + -- same settings the Gen 1 OPTION screen carries and they drive the same + -- shared modules, so a player who learns them in Red knows them here. The + -- cart's screen has no room for them, which is why this one scrolls. + -- + -- The two volume rows clamp at the ends rather than wrapping, the way + -- pokered's text-speed cursor does, so holding left reaches OFF and stays. + { label = "MUSIC VOL", key = "musicVol", port = true, + cycle = function(options, delta) + options.musicVol = stepVolume(options.musicVol, delta) + require("src.core.Music").setVolumeLevel(options.musicVol) + end, + text = function(options) return volLabel(options.musicVol) end }, + { label = "SFX VOL", key = "sfxVol", port = true, + cycle = function(options, delta) + options.sfxVol = stepVolume(options.sfxVol, delta) + require("src.core.Sound").setVolumeLevel(options.sfxVol) + end, + text = function(options) return volLabel(options.sfxVol) end }, + -- Each filter step keeps 40% of the previous step's treble, so 2X and 3X + -- are the 1X low-pass applied twice and three times over. + { label = "MUSIC FILTER", key = "musicFilter", port = true, + cycle = function(options, delta) + options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS + require("src.core.Music").setFilterLevel(options.musicFilter) + end, + text = function(options) + return FILTERS[(options.musicFilter or 0) + 1] + end }, + { label = "GAME SPEED", key = "speed", port = true, + cycle = function(options, delta) + local GameSpeed = require("src.core.GameSpeed") + options.speed = GameSpeed.cycle(options.speed, delta) + end, + text = function(options) + return require("src.core.GameSpeed").levelLabel(options.speed) + end }, + { label = "ZOOM", key = "zoom", port = true, + cycle = function(options, delta, game) + local Zoom = require("src.render.Zoom") + local scale = (game and game.world and game.world.fitScale + and game.world:fitScale()) or 1 + local lo, hi = Zoom.offsetRange(scale) + local offset = (options.zoom or 0) + delta + if offset > hi then offset = lo elseif offset < lo then offset = hi end + options.zoom = offset + Zoom.offset = offset + end, + text = function(options) + return require("src.render.Zoom").offsetLabel(options.zoom or 0) + end }, + { label = "TILT", key = "tilt", port = true, + cycle = function(options, delta) + local Tilt = require("src.render.Tilt") + -- Four levels (OFF, 15, 35, 50); left steps back through them. + local level = ((options.tilt or 0) + delta) % 4 + options.tilt = level + Tilt.setLevel(level) + end, + text = function(options) + return require("src.render.Tilt").levelLabel(options.tilt or 0) + end }, + -- COLOR is the Gen 2 answer to the Gen 1 screen's COLORS row. Gold is a + -- CGB game whose colour comes from its own palettes, so there are no packs + -- to swap -- what there is instead is the choice to turn that colour OFF, + -- down to the grey Game Boy or the green one. GBC is the default. + { label = "COLOR", key = "color", port = true, + cycle = function(options, delta) + local GbcPalette = require("src.render.GbcPalette") + GbcPalette.setMode(options.color or "gbc") + options.color = GbcPalette.cycle(delta) + end, + text = function(options) + return require("src.render.GbcPalette").modeLabel(options.color or "gbc") + end }, + { label = "GBC FX", key = "gbcfx", port = true, + cycle = function(options, delta) + local GBCFX = require("src.render.GBCFX") + if not GBCFX.isSupported() then + options.gbcfx = 0 + return + end + local level = ((options.gbcfx or 0) + delta) % 5 + options.gbcfx = level + GBCFX.setLevel(level) + end, + text = function(options) + return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) + end }, + { label = "CANCEL", cancel = true }, +} + +-- The cart's screen is one full-height textbox with every row on it. This one +-- carries four more, so it shows a window of rows and scrolls: labels start at +-- (2,2) and step two rows, exactly as _Option lays them, and the window moves +-- only when the cursor would leave it. +local VISIBLE_ROWS = 7 + +-- ui.options.rows identity: an unhooked build hands its own rows back. +local function sameRows(_, rows) return rows end + +-- The descriptors one opening of the screen works from. They are shallow +-- copies of ROWS, so a mod that edits a row inside the hook cannot leak that +-- edit into the next opening -- the Gen 1 site rebuilds its descriptors for the +-- same reason (src/ui/OptionsMenu.lua buildRows). +-- +-- `id` is the key a Gen 1 mod filters a row on ("gbcfx", "speed", "musicVol") +-- and is added ALONGSIDE this file's own `key`, never in place of it: a mod +-- written against Red's OPTION screen finds the shared rows where it expects +-- them, and the rows Gold has that Red does not (PRINT, MENU ACCOUNT, FRAME, +-- COLOR) simply appear in the list the hook receives. +local function buildRows() + local rows = {} + for i, row in ipairs(ROWS) do + local copy = {} + for key, value in pairs(row) do copy[key] = value end + copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil + rows[i] = copy + end + return rows +end + +function OptionsMenu:wantsFillScale() return true end +function OptionsMenu:drawsWidescreen() return true end + +-- opts: options (the table to edit in place), onDone(options) +function OptionsMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, OptionsMenu) + self.game = game + -- Unguarded, like the Gen 1 site: the screen is built once per opening. A + -- hook that answers with anything but a table is degraded to the vanilla + -- rows rather than leaving the player with an empty OPTION screen. + local rows = buildRows() + local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) + if type(hooked) == "table" then + rows = hooked + else + Logger.error("ui.options.rows returned %s; keeping the vanilla rows", + type(hooked)) + end + self.rows = rows + self.options = opts.options or Save.defaultOptions() + -- Fill in anything missing so a row can never read nil and lose the + -- player's other settings when it writes back. + for key, value in pairs(Save.DEFAULT_OPTIONS) do + if self.options[key] == nil then self.options[key] = value end + end + self.onDone = opts.onDone + self.index = 1 + self.scroll = 0 + return self +end + +function OptionsMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, + math.min(self.scroll, math.max(0, #self.rows - VISIBLE_ROWS))) +end + +function OptionsMenu:row() + return self.rows[self.index] +end + +function OptionsMenu:cycle(row, delta) + -- The Gen 1 row vocabulary (src/ui/OptionRows.lua:3-7), answered first so a + -- mod's row written against Red's OPTION screen steps here too. `step` takes + -- the game, not the options table, because that is the handle it is given on + -- the other generation. + if row.step then + row.step(self.game, delta) + return + end + -- A port row owns its own ladder (and its own live module), so it steps + -- itself rather than walking a `values` list. + if row.cycle then + row.cycle(self.options, delta, self.game) + return + end + if row.frame then + -- Eight frames, wrapping (UpdateFrame masks to 3 bits). + local frame = ((self.options.frame or 1) - 1 + delta) % 8 + 1 + self.options.frame = frame + -- options_menu.asm:475 UpdateFrame reloads the tiles as the value changes, + -- so this screen's own border restyles under the cursor. + require("src.render.Font").setFrame(frame) + return + end + if not row.values then return end + local current = self.options[row.key] + local at = 1 + for i, value in ipairs(row.values) do + if value == current then at = i break end + end + local next_ = at + delta + -- Left at the first entry wraps to the last and vice versa, matching + -- Options_TextSpeed's .LeftPressed / .Increase clamps. + if next_ < 1 then next_ = #row.values end + if next_ > #row.values then next_ = 1 end + self.options[row.key] = row.values[next_] +end + +function OptionsMenu:leave_() + if self.onDone then self.onDone(self.options) end +end + +function OptionsMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("start") or input:wasPressed("b") then + self:leave_() + return + end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.rows + self:ensureVisible() + return + elseif input:wasPressed("down") then + self.index = self.index < #self.rows and self.index + 1 or 1 + self:ensureVisible() + return + end + local row = self:row() + if not row then return end + if input:wasPressed("a") then + if row.cancel then + self:leave_() + elseif row.activate then + -- the A-press action for a row that opens something instead of cycling + -- a value (src/ui/OptionsMenu.lua:556) + row.activate(self.game) + else + -- A on a value row advances it, which is what the ASM's shared + -- right-press path does when A is held on a non-CANCEL row. + self:cycle(row, 1) + end + return + end + if input:wasPressed("left") then + self:cycle(row, -1) + elseif input:wasPressed("right") then + self:cycle(row, 1) + end +end + +function OptionsMenu:drawPanel() + Chrome.clear() + -- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 2. + Chrome.textbox(0, 0, Chrome.SCREEN_W - 2, Chrome.SCREEN_H - 2) + for slot = 1, math.min(VISIBLE_ROWS, #self.rows) do + local i = slot + self.scroll + local row = self.rows[i] + if row then + local labelY = 2 + (slot - 1) * 2 + Chrome.print(row.label, 2, labelY) + if row.frame then + Chrome.print(":TYPE", 10, labelY + 1) + Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) + elseif row.text then + Chrome.print(":", 10, labelY + 1) + Chrome.print(row.text(self.options), 11, labelY + 1) + elseif row.values then + Chrome.print(":", 10, labelY + 1) + local value = self.options[row.key] + local text = row.display and row.display[value] or tostring(value) + Chrome.print(text, 11, labelY + 1) + elseif type(row.value) == "function" then + -- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row + -- shows its setting here instead of drawing a bare label + local ok, text = pcall(row.value, self.game) + Chrome.print(":", 10, labelY + 1) + Chrome.print(ok and tostring(text) or "?", 11, labelY + 1) + end + end + end + Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2) + -- The ▼ hint every scrolling Gen 2 list shows when there is more below. + if self.scroll + VISIBLE_ROWS < #self.rows then + local Font = require("src.render.Font") + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8) + end +end + +function OptionsMenu:draw() + self:drawPanel() +end + +function OptionsMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +OptionsMenu.ROWS = ROWS + +return OptionsMenu diff --git a/src/ui/gen2/PackGfx.lua b/src/ui/gen2/PackGfx.lua new file mode 100644 index 00000000..bb8ed195 --- /dev/null +++ b/src/ui/gen2/PackGfx.lua @@ -0,0 +1,159 @@ +-- The PACK screen's chrome, drawn from the cart's own tiles. +-- +-- engine/items/pack.asm builds this screen out of tiles, not rectangles, and +-- transcribing it that way is what makes the bag picture, the pocket plaque and +-- the ◀▶/▼▲ header land on the 8px grid by construction: +-- +-- Pack_InitGFX copies PackMenuGFX ($60 tiles) to vTiles2 tile $00, fills +-- rows 1-11 with the background tile $24, clears the item +-- area at (5,1) 15x11, and lays the header at (0,0) as the +-- 20 running tiles $28..$3b +-- PlacePackGFX lays $50..$5e as a 5x3 block at (0,3) -- the bag picture, +-- which DrawPackGFX swaps per pocket out of PackGFX +-- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap +-- _CGB_PackPals loads six BG palettes and colours five rectangles with +-- them: the two header halves, the quantity column, the +-- pocket plaque (red) and the bag picture (green) +-- +-- A cache from before the pack stage simply has no `pack` table; PackMenu +-- falls back to its plain boxes then. + +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") + +local PackGfx = {} +PackGfx.__index = PackGfx + +local SCREEN_W, SCREEN_H = 20, 18 +-- Textbox(0, TEXTBOX_Y - 2) with a 4-row interior: rows 12..17. +local DESCRIPTION_Y = 12 + +function PackGfx.new(menuGfx) + local self = setmetatable({}, PackGfx) + self.gfx = menuGfx and menuGfx.pack or nil + self.images = {} + self.quads = {} + if self.gfx then + -- paletteZones is a list of rectangles; flatten it to a per-cell lookup so + -- drawing a tile is one table read rather than a scan. + self.zone = {} + for _, z in ipairs(self.gfx.paletteZones or {}) do + local x0, y0, w, h, pal = z[1], z[2], z[3], z[4], z[5] + for y = y0, y0 + h - 1 do + for x = x0, x0 + w - 1 do + self.zone[y * SCREEN_W + x] = pal + end + end + end + end + return self +end + +function PackGfx:available() + return self.gfx ~= nil and self:image("menu") ~= nil +end + +function PackGfx:image(key) + local path = self.gfx and self.gfx[key] + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.images[path] = cached + end + return cached or nil +end + +-- One 8x8 tile out of a sheet `tilesWide` tiles across. +function PackGfx:quad(image, tilesWide, index) + local key = tostring(image) .. ":" .. tilesWide .. ":" .. index + local quad = self.quads[key] + if not quad then + local w, h = image:getDimensions() + quad = love.graphics.newQuad( + (index % tilesWide) * 8, math.floor(index / tilesWide) * 8, 8, 8, w, h) + self.quads[key] = quad + end + return quad +end + +-- The palette a screen cell draws with: its attrmap zone, else palette 0. +function PackGfx:colorsAt(tx, ty) + local pals = self.gfx and self.gfx.palettes + if not pals then return nil end + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 1 + return pals[index] +end + +function PackGfx:blit(image, tilesWide, index, tx, ty) + if not image then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local colors = self:colorsAt(tx, ty) + local function body() + G.draw(image, self:quad(image, tilesWide, index), tx * 8, ty * 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- A tile out of PackMenuGFX, addressed by its VRAM id ($00 is the sheet's +-- first tile, so id and index are the same here). +function PackGfx:menuTile(tile, tx, ty) + self:blit(self:image("menu"), self.gfx.menuTilesWide or 16, tile, tx, ty) +end + +-- Everything behind the item list: the header strip, the background column, +-- the bag picture for this pocket, and the pocket plaque. +function PackGfx:draw(pocketId) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + + -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. + local header = self.gfx.headerFirstTile or 0x28 + for tx = 0, SCREEN_W - 1 do + self:menuTile(header + tx, tx, 0) + end + + -- Rows 1-11 are filled with $24; the item area at (5,1) is then cleared, so + -- only the left five columns keep the pattern. + local background = self.gfx.backgroundTile or 0x24 + for ty = 1, 11 do + for tx = 0, 4 do + self:menuTile(background, tx, ty) + end + end + + -- The bag picture: 15 tiles, 5 across, from this pocket's row in PackGFX. + local packImage = self:image("pack") + local firstRow = self.gfx.pocketPicture and self.gfx.pocketPicture[pocketId] + if packImage and firstRow then + local wide = self.gfx.packTilesWide or 5 + for i = 0, wide * (self.gfx.packTilesHigh or 3) - 1 do + self:blit(packImage, wide, firstRow + i, + i % wide, 3 + math.floor(i / wide)) + end + end + + -- The pocket plaque, from DrawPocketName's own tilemap. + local order = self.gfx.pocketOrder or {} + local block + for i, id in ipairs(order) do + if id == pocketId then block = self.gfx.pocketName[i] end + end + if block then + for i, tile in ipairs(block) do + self:menuTile(tile, (i - 1) % 5, 7 + math.floor((i - 1) / 5)) + end + end +end + +PackGfx.DESCRIPTION_Y = DESCRIPTION_Y +PackGfx.SCREEN_W = SCREEN_W + +return PackGfx diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua new file mode 100644 index 00000000..1b0e019c --- /dev/null +++ b/src/ui/gen2/PackMenu.lua @@ -0,0 +1,899 @@ +-- Gen 2 PACK: four pockets instead of Gen 1's one bag. +-- +-- constants/item_data_constants.asm orders them ITEM, KEY_ITEM, BALL, TM_HM, +-- and each item's own ItemAttributes row says which pocket it lives in -- so +-- the flat id->count inventory the engine already keeps is bucketed here at +-- draw time rather than stored four ways. +-- +-- Left/right switch pockets, up/down scroll the list, A selects, B closes. +-- A TM/HM row shows the move it teaches (attributes carry `teaches`), which is +-- the whole reason the TM pocket is readable at all -- the item names are just +-- "TM01".."HM07". + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local PackGfx = require("src.ui.gen2.PackGfx") +local Screens = require("src.ui.Screens") +local Strings = require("src.core.Strings") + +local PackMenu = {} +PackMenu.__index = PackMenu +PackMenu.isOpaque = true + +-- ItemsPocketMenuHeader (engine/items/pack.asm): menu_coords 7, 1, 19, 11 -- +-- so the list body starts one row and one column inside that box, five rows of +-- two, with the quantity on each entry's second line. +local LIST_X = 8 +local LIST_Y = 2 +local LIST_SPACING = 2 + +-- Display order and titles. The cart shows the pocket name in a tab strip +-- across the top; these are the strings it uses. +local POCKETS = { + { id = "ITEM", label = "ITEMS" }, + { id = "BALL", label = "POKé BALLS" }, + { id = "KEY_ITEM", label = "KEY ITEMS" }, + { id = "TM_HM", label = "TM/HM" }, +} + +-- Five item rows fit under the tab strip, two lines each. +local VISIBLE_ROWS = 5 + +-- The item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243). +-- A on a row does NOT use the item on the cart: it opens a menu whose rows are +-- picked from the item's own ITEMATTR_PERMISSIONS bits and its field-menu +-- nibble, and the six headers between MenuHeader_UsableKeyItem and +-- MenuHeader_HoldableItem are every combination of them: +-- +-- CAN toss + CAN select + usable USE / GIVE / TOSS / SEL / QUIT +-- CAN toss + CAN select + NOUSE GIVE / TOSS / SEL / QUIT +-- CAN toss + cant select + usable USE / GIVE / TOSS / QUIT +-- CAN toss + cant select + NOUSE GIVE / TOSS / QUIT +-- cant toss + cant select USE / QUIT +-- cant toss + CAN select USE / SEL / QUIT +-- +-- (the labels read backwards against the header names -- _CheckTossableItem +-- and CheckSelectableItem both answer NON-zero for the item that CANNOT, so +-- pack.asm's `.tossable` arm is the untossable one.) The TM/HM pocket has a +-- pair of its own, .MenuHeader1 / .MenuHeader2 at pack.asm:160. +-- +-- Without this menu a TOSS is unreachable and the PACK is a one-verb screen, +-- which is what "the pack only offers USE" is. +local SUBMENU_LABEL = { + use = "USE", give = "GIVE", toss = "TOSS", sel = "SEL", quit = "QUIT", +} + +-- _AskThrowAwayText / _AskQuantityThrowAwayText / _ThrewAwayText +-- (data/text/common_2.asm), the three lines TossMenu prints in order. +local TOSS_HOW_MANY = { "Throw away how", "many?" } + +-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals. +local NO_POKEMON = { "You don't have a", "#MON!" } +local EGG_CANT_HOLD = { "An EGG can't hold", "an item." } + +-- 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] +-- / ld [wItemsPocketCursor], a` (engine/items/pack.asm:76), and the same pair +-- for wKeyItemsPocketCursor, wBallsPocketCursor and wTMHMPocketCursor -- while +-- InitPackBuffers opens the PACK on wLastPocket, which Pack's own exit path +-- stored. They are WRAM, not save data: they last for the session and must not +-- survive a reload, and CleanUpBattleRAM is the only thing that clears them +-- (engine/battle/core.asm:7994, which pointedly leaves the TM/HM pair in place). +local function cursorStore(game) + if not game then return nil end + local mem = game.packCursor + if not mem then + mem = { cursor = {}, scroll = {} } + game.packCursor = mem + end + return mem +end + +-- OakThisIsntTheTimeText (data/text/common_2.asm), as the three rows it +-- prints: `text` / `line` / `cont`. On the cart that is a two-row text box +-- that scrolls once; the PACK's description box here is four rows tall, so all +-- three fit at once and nothing has to scroll. {PLAYER} is filled in from the +-- save, the way TextBox fills it everywhere else. +local OAK_THIS_ISNT_THE_TIME = { + "OAK: {PLAYER}!", + "This isn't the", + "time to use that!", +} + +-- RepelUsedEarlierIsStillInEffectText (data/text/common_3.asm): static, and +-- names REPEL no matter which of the three repel items is the one actually +-- still ticking down -- the cart never reads the active item back out to +-- print it. +local REPEL_STILL_ACTIVE = { + "The REPEL used", + "earlier is still", + "in effect.", +} + +function PackMenu:wantsFillScale() return true end +function PackMenu:drawsWidescreen() return true end + +-- opts: save, items (items.lua), onChoose(itemId, count), onClose(), +-- pocket (starting pocket id), world (the overworld a field item acts on; +-- defaults to game.world, which is where Game2 keeps it), +-- give (DepositSellPack: the PACK is a CHOOSER, so selecting a row hands the +-- id back instead of running the item's field effect), +-- battle (BattlePack rather than the field Pack: a different jumptable, which +-- dispatches on the item's BATTLE menu nibble and never runs a field effect) +function PackMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PackMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.world = opts.world or (game and game.world) + self.onChoose = opts.onChoose + self.onClose = opts.onClose + -- DepositSellPack rather than the PACK's own UseItem: a chooser must not run + -- a rod or the ITEMFINDER on the way past (src/ui/gen2/HeldItemMenu.lua). + self.give = opts.give and true or false + self.battle = opts.battle and true or false + self.cursorStore = cursorStore(game) + self.pocketIndex = 1 + -- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes + -- ITEM_POCKET over it, so an explicit pocket still wins. + local startPocket = opts.pocket + or (self.cursorStore and self.cursorStore.pocket) + if startPocket then + for i, p in ipairs(POCKETS) do + if p.id == startPocket then self.pocketIndex = i break end + end + 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:rebuild() + return self +end + +function PackMenu:pocket() + return POCKETS[self.pocketIndex] +end + +-- The pair of loads each pocket menu runs before ScrollingMenu. rebuild() +-- clamps the row afterwards, so a pocket that shrank while the PACK was closed +-- lands on its last entry rather than past it. +function PackMenu:restoreCursor() + local mem = self.cursorStore + local pocketId = self:pocket().id + self.index = (mem and mem.cursor[pocketId]) or 1 + self.scroll = (mem and mem.scroll[pocketId]) or 0 +end + +-- And the pair of stores after it, plus Pack's `.done` writing wCurPocket into +-- wLastPocket. +function PackMenu:storeCursor() + local mem = self.cursorStore + if not mem then return end + local pocketId = self:pocket().id + mem.cursor[pocketId] = self.index + mem.scroll[pocketId] = self.scroll + mem.pocket = pocketId +end + +-- Which pocket an item belongs to. Items imported before attributes existed +-- have no `pocket`; treat those as general items rather than dropping them, +-- so an older cache still shows a full bag. +function PackMenu:pocketOf(itemId) + local def = self.items and self.items[itemId] + return (def and def.pocket) or "ITEM" +end + +-- The name on the row. An inventory key with no ItemAttributes row behind it +-- (an older cache, a mod's own item, a driver seeding an id that is not in +-- items.lua) still has to draw something a person can read, so the id stands +-- in for the name with its underscores opened out. +function PackMenu.label(itemId, def) + if def and def.name then return def.name end + return (tostring(itemId):gsub("_", " ")) +end + +-- TMHMPocket (engine/items/tmhm.asm) writes GetMoveName's string under the +-- TM's own name, so the second line of a TM row is the MOVE's name and not the +-- constant the attributes row carries. +function PackMenu:moveLabel(moveId) + if not moveId then return nil end + local moves = self.game and self.game.data and self.game.data.moves + local def = moves and moves[moveId] + return (def and def.name) or (tostring(moveId):gsub("_", " ")) +end + +function PackMenu:rebuild() + local pocket = self:pocket().id + local rows = {} + for itemId, raw in pairs((self.save and self.save.inventory) or {}) do + -- A count that is not a number at all (a hand-written save, a mod, an old + -- migration) counts as one rather than raising out of the draw. + local count = tonumber(raw) or (raw and 1) or 0 + if count > 0 and self:pocketOf(itemId) == pocket then + local def = self.items and self.items[itemId] + rows[#rows + 1] = { + id = itemId, + count = count, + name = PackMenu.label(itemId, def), + teaches = self:moveLabel(def and def.teaches), + tmNumber = def and def.tmNumber, + -- KEY_ITEM and TM_HM rows do not show a quantity on the cart. + showCount = pocket == "ITEM" or pocket == "BALL", + index = def and def.index or math.huge, + } + end + end + -- Bag order on the cart is acquisition order; without that recorded, item id + -- order is the stable, reproducible choice. + table.sort(rows, function(a, b) + if a.index ~= b.index then return a.index < b.index end + return a.id < b.id + end) + self.rows = rows + self.index = math.min(self.index, #rows + 1) + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function PackMenu:total() + return #self.rows + 1 -- CANCEL +end + +function PackMenu:isCancel() + return self.index > #self.rows +end + +function PackMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function PackMenu:switchPocket(delta) + -- The pocket being left keeps its own cursor and scroll; the one being + -- entered restores its own (pack.asm:76). + self:storeCursor() + self.pocketIndex = (self.pocketIndex - 1 + delta) % #POCKETS + 1 + self:restoreCursor() + self:rebuild() + self:storeCursor() +end + +-- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE +-- screen uses when a driver runs without a named save. +function PackMenu:playerName() + return (self.save and self.save.player and self.save.player.name) or "GOLD" +end + +-- .Field (engine/items/pack.asm UseItem): a field-usable item runs its effect, +-- and only a NON-ZERO wItemEffectSucceeded sets PACKSTATE_QUITRUNSCRIPT -- +-- which quits the PACK, and with it the START menu it was opened from, so the +-- script the effect queued can run in the overworld. A zero drops into .Oak +-- instead, which prints inside the PACK and leaves it exactly where it was. +function PackMenu:exitToField() + self:storeCursor() + local stack = self.game and self.game.stack + if stack and stack.clear then + stack:clear() + elseif self.onClose then + -- No clear on this stack (a test harness, or a screen pushed on its own): + -- at least give the pack back. + self.onClose() + end +end + +-- Whether this pack is BattlePack (engine/items/pack.asm:627) rather than the +-- field Pack. The two are separate jumptables: nothing opened over a battle +-- may reach a field effect, so the live overworld's own flag counts as well as +-- the caller saying so. +function PackMenu:inBattle() + if self.battle then return true end + return (self.world and self.world.battleActive) and true or false +end + +-- A on a row. A field item the world claims never reaches onChoose: the world +-- has already run its effect, and all that is left is which of UseItem's two +-- endings the PACK takes. +function PackMenu:useSelected() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time a row is acted on, so the pocket's + -- cursor bytes are already written back before the submenu opens. + self:storeCursor() + if self.give then + if self.onChoose then self.onChoose(row.id, row.count) end + return + end + -- BattlePack's .Use dispatches on the item's BATTLE menu nibble, and the + -- first four entries of its .ItemFunctionJumptable are all .Oak: a battle- + -- NOUSE item prints OakThisIsntTheTimeText inside the pack and goes nowhere. + -- Everything else is handed to the screen that opened this one + -- (src/ui/gen2/BattleState.lua), which owns the balls, the X items and the + -- party-target heals. The FIELD jumptable is not on this path at all. + if self:inBattle() then + local def = self.items and self.items[row.id] + if def and def.battleMenu == "ITEMMENU_NOUSE" then + self.message = OAK_THIS_ISNT_THE_TIME + return + end + if self.onChoose then + self.staleRows = true + self.onChoose(row.id, row.count) + end + return + end + local world = self.world + local result = world and world.useFieldItem and world:useFieldItem(row.id) + if result then + if result == "nowhere" then + self.message = OAK_THIS_ISNT_THE_TIME + elseif result == "repel_used" then + -- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>." + -- World already wrote the counter and took the item out of the bag, so + -- the row list is rebuilt under the message the way a TOSS would. + self.message = { Strings("{PLAYER} used the"), row.name .. "." } + self:rebuild() + elseif result == "repel_active" then + self.message = REPEL_STILL_ACTIVE + elseif result == "trophy_sent" then + -- _SentTrophyHomeText (data/text/common_3.asm). Two pages on the cart + -- with sound_dex_fanfare_50_79 between them; the PACK's box here holds + -- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit. + -- World has already set the decoration's flag and taken the box. + self.message = { "There was a trophy", "inside!", + "{PLAYER} sent the", "trophy home." } + self:rebuild() + else + self:exitToField() + end + return + end + -- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first + -- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the + -- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the + -- FIELD pack owns that refusal: the battle pack returned above, and the + -- catch tutorial's DUDE pack carries a stub world with no useFieldItem at + -- all -- its POKE BALL is field-NOUSE and must still reach the throw. + if world and world.useFieldItem then + local def = self.items and self.items[row.id] + if def and def.fieldMenu == "ITEMMENU_NOUSE" then + self.message = OAK_THIS_ISNT_THE_TIME + return + end + end + if self.onChoose then + -- The .Party flow runs OVER this pack and UseDisposableItem spends the + -- item out from under the row list; rebuild on the first frame the pack + -- owns again, which is UseItem .Party's own Pack_InitGFX redraw. + self.staleRows = true + self.onChoose(row.id, row.count) + end +end + +-- ------------------------------------------------------------- the submenu + +-- Whether A on a row opens the item submenu. Three packs on the cart skip it +-- and hand their row straight back, and all three are here: +-- +-- DepositSellPack (pack.asm:931) -- the mart's SELL, the item PC's DEPOSIT +-- and HeldItemMenu's GIVE. Its jumptable is four ScrollingMenus and +-- nothing else, which is why `give` and the empty-world callers answer +-- their chooser directly. +-- TutorialPack (pack.asm:1068) -- the DUDE's pack, same shape. +-- BattlePack (pack.asm:627) -- this one DOES have a submenu on the cart +-- (ItemSubmenu, USE / QUIT or QUIT alone), but it can neither toss, give +-- nor register, so the row it would add over this port's direct dispatch +-- is a second A press on the way to the same item effect. The field +-- PACK is the one this bug is about; see src/ui/gen2/BattleState.lua for +-- the battle side. +-- +-- The test is the world rather than a flag because that is what already tells +-- a field PACK from a chooser here: MartMenu:enterSell and +-- ItemPcMenu:enterDeposit both pass `world = {}` precisely so no field effect +-- can fire, and Game2's START-menu PACK passes the real overworld. +function PackMenu:hasSubmenu() + if self.give then return false end + if self:inBattle() then return false end + local world = self.world + return (world and world.useFieldItem) and true or false +end + +-- ITEMATTR_PERMISSIONS' two bits and the field-menu nibble, as the cart reads +-- them. An id with no attributes row at all (an older cache, a mod's item) +-- counts as tossable and unusable-for-SEL, which is the same lean the sell +-- gate and ItemPcMenu:cantToss take. +function PackMenu:submenuRows(itemId) + local def = self.items and self.items[itemId] + local canToss = not (def and def.canToss == false) + local canSelect = def ~= nil and def.canSelect == true + local usable = not (def and def.fieldMenu == "ITEMMENU_NOUSE") + local rows = {} + local function add(id) rows[#rows + 1] = id end + if self:pocket().id == "TM_HM" then + -- .TMHMPocketMenu's own pair: an HM cannot be tossed and gets USE / QUIT, + -- a TM gets USE / GIVE / QUIT. Neither has a TOSS row. + add("use") + if canToss then add("give") end + add("quit") + return rows + end + if not canToss then + -- MenuHeader_UnusableItem / MenuHeader_UnusableKeyItem: the untossable arm + -- never looks at the menu nibble, so a key item always offers USE. + add("use") + if canSelect then add("sel") end + add("quit") + return rows + end + if usable then add("use") end + add("give") + add("toss") + if canSelect then add("sel") end + add("quit") + return rows +end + +function PackMenu:openSubmenu() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time the submenu opens, so the pocket's + -- cursor bytes are written back first (pack.asm:76). + self:storeCursor() + self.submenu = { + row = row, + rows = self:submenuRows(row.id), + index = 1, -- `db 1 ; default option` + } +end + +function PackMenu:closeSubmenu() + self.submenu = nil +end + +function PackMenu:chooseSubmenu() + local menu = self.submenu + if not menu then return end + local id = menu.rows[menu.index] + local row = menu.row + if id == "quit" then + -- QuitItemSubmenu: a bare `ret`, back to the pocket list. + self:closeSubmenu() + elseif id == "use" then + self:closeSubmenu() + self:useSelected() + elseif id == "sel" then + self:closeSubmenu() + self:registerSelected() + elseif id == "toss" then + self:closeSubmenu() + self:tossItem(row) + elseif id == "give" then + self:closeSubmenu() + self:giveItem(row) + end +end + +-- ------------------------------------------------------------------- TOSS + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. The same +-- stepper src/ui/gen2/ItemPcMenu.lua uses, because it is the same loop. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- TossMenu (engine/items/pack.asm:477): "Throw away how many?" over +-- SelectQuantityToToss, then the count in a yes/no, then TossItem and +-- "Threw away <ITEM>(S)." Backing out of either question is `jr c, .finish` +-- -- the item is untouched and the PACK is exactly where it was. +function PackMenu:tossItem(row) + if not row then return end + self.message = TOSS_HOW_MANY + self.qtyState = { + row = row, + qty = 1, + max = row.count or 1, + } +end + +function PackMenu:confirmToss() + local state = self.qtyState + if not state then return end + self.qtyState = nil + local row, qty = state.row, state.qty + self.confirm = { + prompt = { ("Throw away %d"):format(qty), row.name .. "(S)?" }, + -- YesNoBox opens on YES; B and NO are the same `jr c, .finish`. + choice = 1, + onYes = function() + Bag.remove(self.save, row.id, qty) + self:rebuild() + self.message = { "Threw away", row.name .. "(S)." } + end, + } +end + +-- ------------------------------------------------------------------- GIVE + +-- GiveItem (engine/items/pack.asm:562): the party list under +-- PARTYMENUACTION_GIVE_ITEM ("To which <PK><MN>?"), an EGG refused with +-- .AnEggCantHoldAnItemText, and everything else handed to +-- TryGiveItemToPartymon -- which is exactly what the party's own GIVE row runs +-- (src/ui/gen2/HeldItemMenu.lua), so the two doors share one routine rather +-- than each growing a copy of the swap question and the mail keyboard. +function PackMenu:giveItem(row) + local game = self.game + local party = (self.save and self.save.party) or {} + if #party == 0 then + self.message = NO_POKEMON + return + end + if not (game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2PartyMenu") then return end + Screens.push(game, "Gen2PartyMenu", { + save = self.save, + prompt = "toWhich", + onChoose = function(slot) self:giveToSlot(slot, row) end, + onCancel = function() + -- `.finish` / PartyMenuSelect's carry: back to the PACK. + game.stack:pop() + self:rebuild() + end, + }) +end + +function PackMenu:giveToSlot(slot, row) + local game = self.game + local mon = self.save and self.save.party and self.save.party[slot] + if not (mon and game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2HeldItemMenu") then return end + -- The GIVE/TAKE menu's own machinery, opened past its two rows: it already + -- owns the text box over the party list, the swap question and the mail + -- keyboard, and this is the same TryGiveItemToPartymon call its GIVE row + -- makes. + local held = Screens.build(game, "Gen2HeldItemMenu", { + save = self.save, + slot = slot, + items = self.items, + onClose = function() + game.stack:pop() + self:rebuild() + end, + }) + game.stack:push(held) + if mon.isEgg then + -- `cp EGG / jr nz, .give`: the refusal prints over the party list, which + -- stays up (`jr .loop`) for another pick. + held:say({ EGG_CANT_HOLD }, function() game.stack:pop() end) + return + end + held:giveItem(row.id) +end + +function PackMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.staleRows then + self.staleRows = nil + self:rebuild() + end + -- Pack_PrintTextNoScroll ends on a `prompt`, so the message holds the PACK + -- until a button clears it and the list is untouchable underneath. The + -- quantity selector is the one thing drawn OVER a message rather than under + -- it: "Throw away how many?" is printed and SelectQuantityToToss runs on top + -- of it, so that pair is stepped before the message is cleared. + if self.qtyState then + self:updateQuantity(input) + return + end + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + self.message = nil + end + return + end + if self.confirm then + self:updateConfirm(input) + return + end + if self.submenu then + self:updateSubmenu(input) + return + end + if input:wasPressed("left") then + self:switchPocket(-1) + return + elseif input:wasPressed("right") then + self:switchPocket(1) + return + elseif input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or self:total() + self:ensureVisible() + return + elseif input:wasPressed("down") then + self.index = self.index < self:total() and self.index + 1 or 1 + self:ensureVisible() + return + elseif input:wasPressed("b") then + self:storeCursor() + if self.onClose then self.onClose() end + return + elseif input:wasPressed("a") then + if self:isCancel() then + self:storeCursor() + if self.onClose then self.onClose() end + elseif self:hasSubmenu() then + -- Pack_InterpretJoypad's A falls through to .ItemBallsKey_LoadSubmenu: + -- the row is chosen, not used. + self:openSubmenu() + else + self:useSelected() + end + return + elseif input:wasPressed("select") then + self:registerSelected() + return + end +end + +-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry +-- that ExitMenu answers with (`ret c`), which is QUIT by another name. +function PackMenu:updateSubmenu(input) + local menu = self.submenu + local total = #menu.rows + if input:wasPressed("up") then + menu.index = menu.index > 1 and menu.index - 1 or total + elseif input:wasPressed("down") then + menu.index = menu.index < total and menu.index + 1 or 1 + elseif input:wasPressed("a") then + self:chooseSubmenu() + elseif input:wasPressed("b") then + self:closeSubmenu() + end +end + +-- Toss_Sell_Loop: the count is stepped until A takes it or B backs out, and +-- backing out is the whole toss cancelled. +function PackMenu:updateQuantity(input) + local state = self.qtyState + if input:wasPressed("up") then + state.qty = qtyStep(state.qty, state.max, 1) + elseif input:wasPressed("down") then + state.qty = qtyStep(state.qty, state.max, -1) + elseif input:wasPressed("right") then + state.qty = qtyStep(state.qty, state.max, 10) + elseif input:wasPressed("left") then + state.qty = qtyStep(state.qty, state.max, -10) + elseif input:wasPressed("a") then + self.message = nil + self:confirmToss() + elseif input:wasPressed("b") then + self.qtyState = nil + self.message = nil + end +end + +-- YesNoBox: up/down flip, A takes the highlighted row, B is NO. +function PackMenu:updateConfirm(input) + local confirm = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + elseif input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the +-- highlighted row reaches the same routine: the cart's SELECT is the bag's own +-- item shuffle, which this port does not have, so the button is free and a +-- player who knows Gen 1's registration shortcut gets it. World:registerItem +-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F +-- refuses), so neither door can register what the cart would not. +function PackMenu:registerSelected() + if self:isCancel() then return end + local row = self.rows[self.index] + if not row then return end + -- BattlePack shares Pack_InterpretJoypad, whose SELECT arm is the bag's own + -- item shuffle rather than the field pack's item submenu, so nothing over a + -- battle registers anything. + local world = not self:inBattle() and self.world or nil + local ok = world and world.registerItem and world:registerItem(row.id) + if ok then + -- RegisteredItemText: "Registered the\n<item>." + self.message = { Strings("Registered the"), row.name .. "." } + else + -- CantRegisterText: "You can't register\nthat item." + self.message = { Strings("You can't register"), Strings("that item.") } + end +end + +-- A TM or HM's `move` is what it teaches; the extractor carries it on the item +-- record, and moves.lua carries that move's own description. +function PackMenu:moveOf(itemId) + local def = itemId and self.items and self.items[itemId] + -- The extractor calls it `teaches`. + return def and def.teaches or nil +end + +-- The description under the list. A TM shows the MOVE's description rather +-- than the item's -- which is what the cart's TM pocket does, and the whole +-- reason move descriptions are worth extracting. +function PackMenu:description() + if self:isCancel() then return nil end + local row = self.rows[self.index] + if not row then return nil end + local moveId = self:moveOf(row.id) + if moveId then + local moves = self.game and self.game.data and self.game.data.moves + local moveDef = moves and moves[moveId] + if moveDef and moveDef.description then return moveDef.description end + end + local def = self.items and self.items[row.id] + return def and def.description or nil +end + +-- The list, description and cursor, on top of whatever chrome was drawn. +-- +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN +-- and one column RIGHT of the name -- the quantity is the entry's second line, +-- not a right-aligned column, which is why every PACK row is two tiles tall. +function PackMenu:drawList(listX, listY) + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = listY + (row - 1) * LIST_SPACING + if i <= #self.rows then + local entry = self.rows[i] + if i == self.index then Chrome.cursor(listX - 1, ty) end + Chrome.print(entry.name, listX, ty) + if entry.teaches then + -- The TM pocket puts the move the TM teaches on that second line. + Chrome.print(entry.teaches, listX + 1, ty + 1) + elseif entry.showCount then + Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1) + end + elseif i == self:total() then + if i == self.index then Chrome.cursor(listX - 1, ty) end + Chrome.print("CANCEL", listX, ty) + end + end +end + +function PackMenu:drawDescription(ty) + -- Pack_PrintTextNoScroll writes over the same box the description lives in, + -- so while a message is up it IS the box's contents. TossMenu's yes/no + -- (AskQuantityThrowAwayText through MenuTextbox) is the same box: the + -- question is printed there and the YES/NO window opens over the list. + local lines = self.message or (self.confirm and self.confirm.prompt) + if lines then + local name = self:playerName() + for i, line in ipairs(lines) do + Chrome.print((line:gsub("{PLAYER}", name)), 1, ty + i - 2) + end + return + end + local description = self:description() + if not description then return end + -- Item descriptions join their two lines with the '<NEXT>' the extractor + -- leaves in place, and $4e steps SCREEN_WIDTH * 2 from the line's own + -- start (home/text.asm NextLineChar) -- TWO rows, the same metric every + -- text box uses. PrintItemDescription writes them from decoord 1, 14, so + -- the second line is row 16. '\n' covers hand-written data. + local first, second = description:match("^(.-)<NEXT>(.*)$") + if not first then first, second = description:match("^(.-)\n(.*)$") end + Chrome.print(first or description, 1, ty) + if second then Chrome.print(second, 1, ty + 2) end +end + +-- The submenu box. Every one of the seven headers is `menu_coords 0, top, +-- SCREEN_WIDTH - 14, TEXTBOX_Y - 1` -- the left six columns, growing UPWARD +-- from the description box so its bottom edge never moves. The five-row +-- header is the one exception, reaching one row further down (TEXTBOX_Y), so +-- the bottom is 12 there and 11 otherwise; either way the first label sits one +-- row inside (STATICMENU_NO_TOP_SPACING) with the cursor a column left of it. +function PackMenu:drawSubmenu() + local menu = self.submenu + local count = #menu.rows + local bottom = count >= 5 and 12 or 11 + local top = bottom - count * 2 + Chrome.box(0, top, 7, bottom - top + 1) + for i, id in ipairs(menu.rows) do + local ty = top + 1 + (i - 1) * 2 + if i == menu.index then Chrome.cursor(1, ty) end + Chrome.print(SUBMENU_LABEL[id] or id, 2, ty) + end +end + +-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1` +-- with NoPriceToDisplay behind it: a small box in the bottom right holding +-- nothing but the count. +function PackMenu:drawQuantity() + Chrome.box(15, 9, 5, 3) + Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10) +end + +-- YesNoBox's own coords, the same box every other Gen 2 screen here draws. +function PackMenu:drawYesNo() + Chrome.box(14, 7, 6, 5) + Chrome.print("YES", 16, 8) + Chrome.print("NO", 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) +end + +function PackMenu:drawOverlays() + if self.submenu then self:drawSubmenu() end + if self.qtyState then self:drawQuantity() end + if self.confirm then self:drawYesNo() end +end + +function PackMenu:drawPanel() + if self.gfx:available() then + -- Pack_InitGFX's screen: the header strip, the patterned left column, the + -- bag picture for this pocket and the pocket plaque, then Textbox at + -- (0,12) for the item description. + self.gfx:draw(self:pocket().id) + Chrome.box(0, PackGfx.DESCRIPTION_Y, 20, 6) + self:drawList(LIST_X, LIST_Y) + self:drawDescription(PackGfx.DESCRIPTION_Y + 2) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- No pack tiles in the cache (an import from before the pack stage): plain + -- boxes, the layout this screen shipped with. + Chrome.clear() + Chrome.box(0, 0, 20, 3) + Chrome.print(self:pocket().label, 2, 1) + Chrome.box(0, 3, 20, 12) + self:drawList(2, 4) + Chrome.box(0, 12, 20, 6) + self:drawDescription(14) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) +end + +function PackMenu:draw() + self:drawPanel() +end + +function PackMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PackMenu.POCKETS = POCKETS + +return PackMenu diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua new file mode 100644 index 00000000..ffcb0488 --- /dev/null +++ b/src/ui/gen2/PartyMenu.lua @@ -0,0 +1,785 @@ +-- Gen 2 party list (engine/pokemon/party_menu.asm). +-- +-- Six rows plus CANCEL. Each row is two lines: an animated 16x16 menu icon +-- with the nickname and gender on the first, level and HP bar on the second -- +-- the layout Gold uses everywhere it asks "which #MON?", which is why the +-- prompt text is a parameter (.Strings: "Choose a #MON.", "Use on which +-- <PK><MN>?", "Teach which <PK><MN>?", ...). +-- +-- Icons come from icons.lua: one 16x32 sheet per ICON_*, two 16x16 frames that +-- alternate roughly twice a second, and MonMenuIcons maps species -> icon. +-- +-- Choosing a mon from the FIELD list opens the action submenu (MonSubmenu, +-- engine/pokemon/mon_submenu.asm) rather than answering straight away; every +-- other flavour of the list -- "Use on which <PK><MN>?", "Teach which +-- <PK><MN>?", a battle switch -- goes directly to its caller, which is why +-- the submenu is opt-in through `opts.submenu` and not the default. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local HpBar = require("src.battle.gen2.HpBar") +local Logger = require("src.core.Logger") +local Mail = require("src.core.gen2.Mail") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") + +local PartyMenu = {} +PartyMenu.__index = PartyMenu +PartyMenu.isOpaque = true + +-- PartyMenuStrings, verbatim. <PK>/<MN> are single font glyphs ($e1/$e2) and +-- Font.split matches charmap sequences, so writing them the way the ASM does +-- is both faithful and two tiles narrower than spelling POKéMON out -- which +-- is what keeps "Use on which <PK><MN>?" inside its 18-column text box. +PartyMenu.PROMPTS = { + choose = "Choose a POKéMON.", + useItem = "Use on which <PK><MN>?", + which = "Which <PK><MN>?", + teach = "Teach which <PK><MN>?", + moveTo = "Move to where?", + toWhich = "To which <PK><MN>?", + none = "You have no <PK><MN>!", +} + +-- The icon's two frames swap every 16 logic steps, close to the cart's +-- SPRITE_ANIM cadence. +local ICON_FRAME_STEPS = 16 + +-- data/mon_menu.asm MonMenuOptions' MONMENU_FIELD_MOVE rows, in table order: +-- a move the mon knows that appears here gets a row of its own above the +-- fixed options, named with GetMoveName rather than a menu string. +PartyMenu.FIELD_MOVES = { + "CUT", "FLY", "SURF", "STRENGTH", "FLASH", "WATERFALL", "WHIRLPOOL", "DIG", + "TELEPORT", "SOFTBOILED", "HEADBUTT", "ROCK_SMASH", "MILK_DRINK", + "SWEET_SCENT", +} + +-- MonMenuOptionStrings, in MONMENUVALUE_* order. GetMonSubmenuItems adds +-- STATS, SWITCH, MOVE and then ITEM (or MAIL, when the held item is mail), +-- and only appends CANCEL while the list is still under NUM_MONMENU_ITEMS. +local NUM_MONMENU_ITEMS = 8 + +-- MonSubmenu's .MenuHeader is `menu_coords 6, 0, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1`, and .GetTopCoord then pulls the top edge up to +-- 1 + bottom - 2 * (count + 1) so the box grows downward from a fixed bottom. +local SUBMENU_LEFT = 6 +local SUBMENU_RIGHT = 19 +local SUBMENU_BOTTOM = 17 + +-- BattleMonMenu's .MenuHeader is `menu_coords 11, 11, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1` (engine/pokemon/mon_submenu.asm:277-292). +local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11 + +-- HP bar is 6 tiles wide (48px) in the party list. + +function PartyMenu:wantsFillScale() return true end +function PartyMenu:drawsWidescreen() return true end + +-- opts: party, prompt (key or literal), onChoose(index), onCancel(), +-- icons (icons.lua), palettes (palettes.lua), pokemon (pokemon.lua), +-- submenu (the field MonSubmenu), battleSubmenu (BattleMonMenu) +function PartyMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PartyMenu) + self.game = game + local save = opts.save or (game and game.save) + -- The MAIL row writes to sPartyMail, which lives on the save rather than on + -- the mon, so the list keeps the save it was opened over. + self.save = save + self.party = opts.party or (save and save.party) or {} + local data = game and game.data or {} + self.icons = opts.icons or data.gen2Icons + self.palettes = opts.palettes or data.gen2Palettes + self.pokemon = opts.pokemon or data.pokemon + self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] or opts.prompt + or PartyMenu.PROMPTS.choose + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.moves = opts.moves or data.moves + self.items = opts.items or data.items + -- PokemonActionSubmenu is only reached from the field list; see the header. + self.wantsSubmenu = opts.submenu == true + -- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810). + self.wantsBattleSubmenu = opts.battleSubmenu == true + self.submenu = nil + -- The held slot while SwitchPartyMons' second pick is open; nil otherwise. + self.switchFrom = nil + -- wPartyMenuCursor lives ACROSS openings: InitPartyMenuWithCancel / + -- InitPartyMenuNoCancel seed wMenuCursorY from it and fall back to row 1 only + -- when it is zero or no longer inside the party (`and a / jr z, .skip / inc b + -- / cp b / jr c, .done`, engine/pokemon/party_menu.asm:546), so the list -- in + -- battle and in the field alike -- reopens on the mon last picked. It is a + -- WRAM byte, not save data, so it hangs off the game and CleanUpBattleRAM is + -- what clears it (src/ui/gen2/BattleState.lua's clearMenuCursors). + local stored = game and game.partyMenuCursor or 0 + self.index = (stored >= 1 and stored <= #self.party) and stored or 1 + self.clock = 0 + self.iconCache = {} + -- PlacePartyHPBar draws through DrawBattleHPBar, so the party list uses the + -- battle HUD's tile sheet rather than a bar of its own. + local BattleHud = require("src.ui.gen2.BattleHud") + self.hud = BattleHud.new(data.gen2MenuGfx, self.palettes) + return self +end + +function PartyMenu:count() + -- CANCEL is one past the last mon. SwitchPartyMons reopens the list + -- through InitPartyMenuNoCancel, which caps the cursor at the last mon. + if self.switchFrom then return #self.party end + return #self.party + 1 +end + +function PartyMenu:isCancel() + return self.index > #self.party +end + +-- ------------------------------------------------------------- mon submenu + +-- GetMonSubmenuItems, in its own order: every field move the mon knows first, +-- then STATS, SWITCH, MOVE, and ITEM (MAIL when the held item is mail). +-- CANCEL is appended only while the list is still short of NUM_MONMENU_ITEMS, +-- which is why a mon with five field moves has no CANCEL row and has to be +-- backed out of with B. +-- +-- Every PokemonActionSubmenu branch is wired up here: STATS, SWITCH +-- (SwitchPartyMons), the field moves, ITEM (GiveTakePartyMonItem, +-- src/ui/gen2/HeldItemMenu.lua), MAIL (MonMailAction, +-- src/ui/gen2/MailMenu.lua) and MOVE (ManagePokemonMoves, which opens +-- MoveScreenLoop's screen -- src/ui/gen2/SummaryMenu.lua's `moveScreen`). + +-- ui.party.submenu identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +local function buildSubmenuItems(self, mon) + -- GetMonSubmenuItems' .egg arm: an EGG offers STATS, SWITCH and CANCEL -- + -- no field moves, no MOVE row, and no ITEM (GiveTakePartyMonItem's first + -- test is `cp EGG`). + if mon and mon.isEgg then + return { + { id = "STATS", label = "STATS" }, + { id = "SWITCH", label = "SWITCH" }, + { id = "CANCEL", label = "CANCEL" }, + } + end + local items = {} + local known = {} + for _, entry in ipairs((mon and mon.moves) or {}) do + if entry.id then known[entry.id] = entry end + end + for _, id in ipairs(PartyMenu.FIELD_MOVES) do + if known[id] then + local def = self.moves and self.moves[id] + items[#items + 1] = { + id = id, label = (def and def.name) or id, fieldMove = true, + } + end + end + items[#items + 1] = { id = "STATS", label = "STATS" } + items[#items + 1] = { id = "SWITCH", label = "SWITCH" } + items[#items + 1] = { id = "MOVE", label = "MOVE" } + -- ItemIsMail, not a pocket test: mail lives in the ordinary ITEM pocket + -- (ItemAttributes gives FLOWER_MAIL pocketId 1), so the only thing that + -- says "this is mail" is data/items/mail_items.asm's own list. + local isMail = Mail.monHoldsMail(mon) + items[#items + 1] = isMail and { id = "MAIL", label = "MAIL" } + or { id = "ITEM", label = "ITEM" } + if #items < NUM_MONMENU_ITEMS then + items[#items + 1] = { id = "CANCEL", label = "CANCEL" } + end + return items +end + +-- BattleMonMenu's .MenuData: three rows, SWITCH first +-- (engine/pokemon/mon_submenu.asm:286-292). +local function buildBattleSubmenuItems() + return { + { id = "SWITCH", label = "SWITCH" }, + { id = "STATS", label = "STATS" }, + { id = "CANCEL", label = "CANCEL" }, + } +end + +-- The assembled list runs through ui.party.submenu -- the same hook name and +-- the same (game, items, mon, ctx) payload the Gen 1 site uses +-- (src/ui/PartyMenu.lua) -- so one mod source can add, drop or reorder rows on +-- both generations. Gold's MAIL row and its field moves are simply entries in +-- the list the hook receives; they need no name of their own. +-- +-- ctx.battle marks the BattleMonMenu list (engine/pokemon/mon_submenu.asm:247), +-- the SWITCH/STATS/CANCEL box BattleMenu_PKMN opens over the battle party list; +-- it is false for PokemonActionSubmenu, the FIELD list (see the header). +-- ctx.overworld is Gold's World, the Gen 1 key's counterpart. +function PartyMenu:submenuItems(mon) + local battle = self.wantsBattleSubmenu == true + local items = battle and buildBattleSubmenuItems() + or buildSubmenuItems(self, mon) + local ctx = { battle = battle, overworld = self.game and self.game.world } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then return hooked end + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + return items +end + +-- .GetTopCoord: top = 1 + bottom - 2 * (count + 1). PopulateMonMenu then +-- starts writing at MenuBoxCoord2Tile + 2 * SCREEN_WIDTH + 2, so the labels +-- are two rows and two columns inside the box's top-left corner and step two +-- rows each -- the cursor sits one column left of them, at left + 1. +function PartyMenu.submenuTop(count) + return 1 + SUBMENU_BOTTOM - 2 * (count + 1) +end + +function PartyMenu.submenuLabelCoord(count, row) + return SUBMENU_LEFT + 2, PartyMenu.submenuTop(count) + 2 + (row - 1) * 2 +end + +function PartyMenu:openSubmenu() + local mon = self.party[self.index] + if not mon then return end + self.submenu = { items = self:submenuItems(mon), index = 1, mon = mon, + slot = self.index, battle = self.wantsBattleSubmenu or nil } +end + +function PartyMenu:closeSubmenu() + self.submenu = nil +end + +-- SwitchPartyMons (engine/pokemon/mon_menu.asm). `cp 2 / jr c, .DontSwitch`: +-- one mon is nothing to switch with, and the row backs out the way +-- CancelPokemonAction does. Otherwise the list reopens through +-- InitPartyMenuNoCancel in PARTYMENUACTION_MOVE dress -- "Move to where?", +-- a '▷' parked on the held row -- and PartyMenuSelect picks the other end. +function PartyMenu:beginSwitch(slot) + if #self.party < 2 then return end + self.switchFrom = slot +end + +-- _SwitchPartyMons (engine/pokemon/switchpartymons.asm): the two party +-- structs swap whole (nickname and OT ride inside the port's mon record), and +-- the sPartyMail structs are copied across with them -- Mail.swapSlots is +-- that CopyBytes pair. Picking the held slot again is the `.skip` arm: +-- nothing moves. +function PartyMenu:finishSwitch() + local from, to = self.switchFrom, self.index + self.switchFrom = nil + if not (from and to) or from == to then return end + local party = self.party + party[from], party[to] = party[to], party[from] + -- sPartyMail is keyed by party slot on the save; a list opened over some + -- other table (a battle copy, a day-care pick) has no mail to carry. + if self.save and self.save.party == party then + Mail.swapSlots(self.save, from, to) + end +end + +-- The reopened list: InitPartyMenuNoCancel caps the cursor at the last mon, +-- and PartyMenuSelect reads only A (hand both slots to _SwitchPartyMons) and +-- B (the .DontSwitch arm -- back to the ordinary list, nothing moved). +function PartyMenu:updateSwitch(input) + local total = #self.party + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("b") then + self.switchFrom = nil + elseif input:wasPressed("a") then + self:finishSwitch() + end +end + +-- OpenPartyStats (engine/pokemon/mon_menu.asm): wMonType is cleared to +-- PARTYMON, the volume is dropped for the cry, and StatsScreenInit runs over +-- the party list -- so the summary is pushed on top rather than replacing it, +-- and closing it lands back on the same row. +function PartyMenu:openStats() + local stack = self.game and self.game.stack + if not stack then return end + Screens.push(self.game, "Gen2SummaryMenu", { + party = self.party, + index = self.index, + onClose = function() stack:pop() end, + }) +end + +-- MonMenu_Cut and its thirteen siblings (engine/pokemon/mon_menu.asm) are one +-- routine each and all the same shape: farcall the move's *Function, then read +-- wFieldMoveSucceeded. $1 returns $2 from PokemonActionSubmenu, which closes +-- the party list AND the START menu behind it so the script the function +-- queued can run in the overworld; anything else returns $3 and redraws the +-- list where it was, with the refusal already printed over it by +-- MenuTextboxBackup. +-- +-- The refusal text and the queueing both live in the world +-- (src/world/gen2/FieldMoves.lua and World:useFieldMove), because a field move +-- is a question about the map, not about the menu. All that is left here is +-- the $2 / $3 branch. +function PartyMenu:useFieldMove(moveId, mon) + local world = self.game and self.game.world + if not (world and world.useFieldMove) then return end + local result = world:useFieldMove(moveId, mon) + if result and result.ok then self:exitToField() end +end + +-- ManagePokemonMoves (engine/pokemon/mon_menu.asm:858-873), the MOVE row: an +-- EGG returns at once (`cp EGG / jr z, .egg`), and every other mon gets +-- MoveScreenLoop -- the move list with its descriptions, and A to lift a move +-- and drop it on another row. The screen clears the tilemap for itself +-- (SetUpMoveScreenBG), so it is pushed on top rather than replacing this list. +-- +-- MoveScreenLoop opens with `ld a, [wCurPartyMon] / inc a / ld +-- [wPartyMenuCursor], a` and does it again on every left/right cycle, so the +-- slot it was last showing is the row this list comes back on. +function PartyMenu:openMoveManager(slot, mon) + local game = self.game + if not (game and game.stack) then return end + if mon and mon.isEgg then return end + local screen + screen = Screens.push(game, "Gen2SummaryMenu", { + party = self.party, + index = slot, + moveScreen = true, + onClose = function() + game.stack:pop() + local landed = screen and screen.index or slot + self.index = math.max(1, math.min(landed, #self.party)) + self:storeCursor() + end, + }) +end + +-- GiveTakePartyMonItem, the ITEM row. Its own menu is drawn over this list +-- (menu_coords 12, 12, 19, 17), so it is a non-opaque state too. An EGG never +-- reaches it: `cp EGG / jr z, .cancel` is the routine's first test. +function PartyMenu:openHeldItemMenu(slot, mon) + local game = self.game + if not (game and game.stack and self.save) then return end + if mon and mon.isEgg then return end + Screens.push(game, "Gen2HeldItemMenu", { + save = self.save, + slot = slot, + items = self.items, + onClose = function() game.stack:pop() end, + }) +end + +-- MonMailAction (engine/pokemon/mon_menu.asm), the MAIL row. The READ / TAKE +-- / QUIT menu is drawn OVER this list (MENU_BACKUP_TILES at menu_coords 9, 10, +-- 19, 17), so it is a non-opaque state pushed on top rather than a submenu of +-- this screen's own. +function PartyMenu:openMailMenu(slot) + local game = self.game + if not (game and game.stack and self.save) then return end + Screens.push(game, "Gen2MailMenu", { + save = self.save, + slot = slot, + onClose = function() game.stack:pop() end, + }) +end + +-- The $2 return. Same exit PackMenu's PACKSTATE_QUITRUNSCRIPT takes, for the +-- same reason: only an empty stack lets the overworld run the queued script. +function PartyMenu:exitToField() + local stack = self.game and self.game.stack + if stack and stack.clear then + stack:clear() + elseif self.onCancel then + self.onCancel() + end +end + +-- The ids updateSubmenu dispatches itself; anything else is a mod's row. +local VANILLA_SUBMENU_IDS = { + STATS = true, SWITCH = true, MAIL = true, ITEM = true, MOVE = true, + CANCEL = true, +} + +-- MonMenuLoop: A selects, B is MONMENUITEM_CANCEL, and nothing else is read. +function PartyMenu:updateSubmenu(input) + local menu = self.submenu + local total = #menu.items + if input:wasPressed("up") then + menu.index = menu.index > 1 and menu.index - 1 or total + elseif input:wasPressed("down") then + menu.index = menu.index < total and menu.index + 1 or 1 + elseif input:wasPressed("b") then + self:closeSubmenu() + elseif input:wasPressed("a") then + local item = menu.items[menu.index] + local mon = menu.mon + local slot = menu.slot or self.index + local battle = menu.battle + self:closeSubmenu() + if not item then return end + -- `cp $1 ; SWITCH / jp z, TryPlayerSwitch` and `.Cancel: jp BattleMenu` + -- (engine/battle/core.asm:4811-4816). + if battle and item.id == "SWITCH" then + if self.onChoose then self.onChoose(slot, mon) end + return + elseif battle and item.id == "CANCEL" then + if self.onCancel then self.onCancel() end + return + end + -- A hook-injected entry carries a callback instead of one of the ids this + -- chain knows, and it is answered first for the reason the Gen 1 site + -- answers it first (src/ui/PartyMenu.lua:346): a mod cannot mint new ids + -- into the chain, so without the arm its row draws and does nothing. + if item.onSelect and not VANILLA_SUBMENU_IDS[item.id] and not item.fieldMove then + item.onSelect(mon, self.game) + elseif item.id == "STATS" then + self:openStats() + elseif item.id == "SWITCH" then + self:beginSwitch(slot) + elseif item.id == "MAIL" then + self:openMailMenu(slot) + elseif item.id == "ITEM" then + self:openHeldItemMenu(slot, mon) + elseif item.id == "MOVE" then + self:openMoveManager(slot, mon) + elseif item.fieldMove then + self:useFieldMove(item.id, mon) + end + end +end + +function PartyMenu:update(_dt) + self.clock = self.clock + 1 + local input = self.game and self.game.input + if not input then return end + if self.submenu then + self:updateSubmenu(input) + return + end + if self.switchFrom then + self:updateSwitch(input) + return + end + local total = self:count() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:storeCursor() + if self:isCancel() then + if self.onCancel then self.onCancel() end + elseif self.wantsSubmenu or self.wantsBattleSubmenu then + self:openSubmenu() + elseif self.onChoose then + self.onChoose(self.index, self.party[self.index]) + end + elseif input:wasPressed("b") then + self:storeCursor() + if self.onCancel then self.onCancel() end + end +end + +-- PartyMenuSelect's `ld [wPartyMenuCursor], a` sits after the CANCEL test but +-- BEFORE the B-button test (engine/pokemon/party_menu.asm:600), so backing out +-- of a mon's row still records it and only the CANCEL row leaves the byte +-- alone. 1-based over the party, the same numbering PartyMenu:count() uses. +function PartyMenu:storeCursor() + local game = self.game + if not game or self:isCancel() then return end + game.partyMenuCursor = self.index +end + +-- ReadMonMenuIcon (engine/gfx/mon_icons.asm): an EGG slot draws ICON_EGG -- +-- the `cp EGG / jr z, .egg` arm -- and any other species reads MonMenuIcons. +function PartyMenu:iconIdFor(mon) + if not mon then return nil end + if mon.isEgg then return "ICON_EGG" end + return self.icons and self.icons.species and mon.species + and self.icons.species[mon.species] or nil +end + +-- The icon image for a mon, plus which 16x16 frame to show. +-- +-- The path goes out through pokemon.icon before it is loaded -- the SAME hook +-- name, the same (data, mon, vanillaPath, { name = iconId }) call and the same +-- ctx the Gen 1 party list makes (src/ui/PartyMenu.lua:186 through +-- src/pokemon/Sprites.lua iconPath), so one skin mod repaints the party icons +-- in both games. Sprites.iconPath does its own Runtime.wantsHook check and +-- hands the vanilla path straight back on an unhooked boot, so this costs a +-- table lookup per row; the cache is keyed on the RESOLVED path so a mod's +-- image does not ride the built-in's entry. +function PartyMenu:iconFor(mon) + local iconId = self:iconIdFor(mon) + local entry = iconId and self.icons and self.icons.icons + and self.icons.icons[iconId] + local path = entry and entry.image + path = require("src.pokemon.Sprites").iconPath( + self.game and self.game.data, mon, path, { name = iconId }) + if not path then return nil end + local cached = self.iconCache[path] + if cached == nil then + local ok, img = pcall(Assets.image, path) + cached = ok and img or false + self.iconCache[path] = cached + end + if not cached then return nil end + local frame = math.floor(self.clock / ICON_FRAME_STEPS) % 2 + return cached, frame +end + +-- .SpawnItemIcon (engine/gfx/mon_icons.asm): a mon carrying something does not +-- get a word of text, it gets its ICON's bottom-left tile swapped. A zero +-- MON_ITEM returns early; otherwise ItemIsMail picks +-- SPRITE_ANIM_FRAMESET_PARTY_MON_WITH_MAIL over ..._WITH_ITEM. Returns the row +-- of the HeldItemIcons sheet to draw -- 0 is mail.2bpp, 1 is item.2bpp, the +-- order the two tiles are INCBIN'd in -- or nil for an empty hand. There is +-- deliberately no EGG arm: the cart has none, because an egg's item byte is +-- always zero (Breeding.hatchEgg/DayCare_GiveEgg never write one). +function PartyMenu.heldMarkerRow(mon) + if type(mon) ~= "table" then return nil end + local item = mon.item + if item == nil or item == 0 or item == "" then return nil end + return Mail.monHoldsMail(mon) and 0 or 1 +end + +-- The HeldItemIcons sheet, which GetIconGFX uploads as the two tiles straight +-- after an icon's eight (`ld de, 8 tiles / add hl, de`), so it rides the same +-- cache entry as the icons themselves. A cache built before the extractor read +-- the symbol simply has no `heldItem` row: the marker goes undrawn and the list +-- looks exactly as it did, rather than erroring on a missing image. +function PartyMenu:heldMarkerImage() + local entry = self.icons and self.icons.heldItem + if not (entry and entry.image) then return nil end + local cached = self.iconCache[entry.image] + if cached == nil then + local ok, img = pcall(Assets.image, entry.image) + cached = ok and img or false + self.iconCache[entry.image] = cached + end + return cached or nil +end + +-- AnimSeq_PartyMon rewrites the icon's x every frame: an unselected row sits at +-- 8 * 2 and the highlighted one at 8 * 3, so the icons rest against the left +-- wall and the one under the cursor slides a tile right to make room for it. +-- Screen x is the struct's x minus 16 (the OAM template's -1 tile, then the +-- hardware's -8), which is 0 and 8. +function PartyMenu:iconX(index) + return index == self.index and 8 or 0 +end + +-- ...and AnimSeq_PartyMonSwitch bobs that one: VAR1 counts frames and, every +-- sixteenth, bit 4 decides whether YOFFSET is 0 or negative. So the selected +-- icon rides two pixels high for half of each 32-frame cycle. +function PartyMenu:iconBob(index) + if index ~= self.index then return 0 end + return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0 +end + +function PartyMenu:drawIcon(mon, px, py) + local image, frame = self:iconFor(mon) + if not image then return end + local G = love.graphics + local iw, ih = image:getDimensions() + local markerRow = PartyMenu.heldMarkerRow(mon) + local marker = markerRow and self:heldMarkerImage() or nil + local paint + if marker then + -- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the + -- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom + -- left -- reading tile $08/$09 instead of the icon's own. So three of the + -- icon's 8x8 tiles are drawn and the fourth is REPLACED, not covered: the + -- marker tile is transparent in places and the icon would show through it. + -- Both of the frameset's oamframes name the same marker tile, so it must + -- not be indexed by `frame`: the icon bobs, the marker does not. + local mw, mh = marker:getDimensions() + local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih) + local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih) + local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih) + local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh) + paint = function() + G.draw(image, topLeft, px, py) + G.draw(image, topRight, px + 8, py) + G.draw(image, bottomRight, px + 8, py + 8) + G.draw(marker, held, px, py + 8) + end + else + local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih) + paint = function() G.draw(image, quad, px, py) end + end + G.setColor(1, 1, 1, 1) + -- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355) + -- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list, + -- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229). + local pals = self.palettes and self.palettes.partyMenu + local colors = pals and pals[1] or nil + if colors and GbcPalette.available() then + GbcPalette.with(colors, paint) + else + paint() + end +end + +-- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and +-- that routine lays the WHOLE assembly, not just the bar: "HP:" is $60/$61 at +-- the coordinate it is given, then six $62 bar cells, then the $6b end cap. So +-- a party row's bar is the battle HUD's bar, tile for tile -- which is why this +-- goes through BattleHud instead of drawing a rectangle. +function PartyMenu:drawHpBar(mon, tx, ty) + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) + if self.hud and self.hud:available() then + return self.hud:drawHpBar(mon.hp, maxHp, tx, ty) + end + -- No battle-HUD sheet in the cache: the plain bar, two tiles in, so the + -- fallback still lands where the cells would. + HpBar.draw(self.palettes, mon.hp, maxHp, (tx + 2) * 8, ty * 8 + 2) + return tx + 2 + HpBar.LENGTH_TILES +end + +-- PrintNum with `lb bc, 2, 3`: three columns, space-padded from the right. +local function num3(value) + local text = tostring(math.max(0, math.floor(value or 0))) + if #text > 3 then text = text:sub(-3) end + return (" "):rep(3 - #text) .. text +end + +-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon +-- with no HP reads FNT whatever its status byte says. +local STATUS_STRING = { + slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", + poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", + sleep = "SLP", toxic = "PSN", +} + +local function statusString(mon) + if (mon.hp or 0) <= 0 then return "FNT" end + local status = mon.status + if not status then return nil end + return STATUS_STRING[tostring(status):lower()] +end + +-- One list row's strings, exactly what WritePartyMenuTilemap's quality +-- routines write. Every routine but PlacePartyNicknames begins with +-- PartyMenuCheckEgg (`cp EGG`) and skips its row for an egg, so an egg is a +-- name and an icon alone: no HP digits, no bar, no level, no FNT. The name +-- itself is String_Egg -- GiveEgg writes "EGG" over the nickname slot -- so +-- it never reads as the species hiding inside. +function PartyMenu.rowFor(mon) + if mon.isEgg then return { name = "EGG" } end + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + return { + name = mon.nickname or mon.name or mon.species or "?", + hp = num3(mon.hp) .. "/" .. num3(maxHp), + status = statusString(mon), + -- <LV> is one font glyph ($6e), not the two characters ":L". + level = "<LV>" .. tostring(mon.level or 1), + } +end + +-- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate +-- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each +-- steps 2 * SCREEN_WIDTH per mon: +-- +-- PlacePartyNicknames (3, 1) CANCEL two columns left of the row +-- after the last mon +-- PlacePartyMenuHPDigits (13,1) "%3d" "/" "%3d" +-- PlacePartyMonStatus (5, 2) +-- PlacePartyMonLevel (8, 2) <LV> then a left-aligned number +-- PlacePartyHPBar (11,2) DrawBattleHPBar: "HP:" + 6 cells + cap +-- PartyMenu_InitAnimatedMonIcon sprite anim at y $1c + $10 * i; +-- .OAMData_RedWalk's first entry is +-- dbsprite -1,-1, so the icon's top-left +-- OAM is (x - 8, y - 8) and, minus the +-- (8,16) hardware offset, pixel +-- (x - 16, 4 + 16 * i) +-- PartyMenu2DMenuData cursor at column 0, rows 1, 3, 5 ... +-- PlacePartyMenuText Textbox at (0,14) 18x2 interior, string at (1,16) +function PartyMenu:drawPanel() + -- LoadPartyMenuGFX starts with LoadFontsBattleExtra, so tiles $60-$6f on this + -- screen are FontBattleExtra's: <LV> ($6e) is the bold ":L" here, not + -- FontExtra's "Lv", and the HP bar's cells come from the same sheet. + local wasBattle = Font.useBattleExtra(true) + Chrome.clear() + + for i, mon in ipairs(self.party) do + local nameY = 1 + (i - 1) * 2 + local dataY = nameY + 1 + if i == self.index then + Chrome.cursor(0, nameY) + elseif self.switchFrom == i then + -- SwitchPartyMons parks '▷' on the held row (`ld [hl], '▷'`); the live + -- cursor overwrites it whenever it sits there. + Chrome.cursor(0, nameY, true) + end + self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i)) + local row = PartyMenu.rowFor(mon) + Chrome.print(row.name, 3, nameY) + if row.hp then Chrome.print(row.hp, 13, nameY) end + if row.status then Chrome.print(row.status, 5, dataY) end + if row.level then Chrome.print(row.level, 8, dataY) end + if row.hp then self:drawHpBar(mon, 11, dataY) end + end + + -- .end does `dec hl` twice from the row past the last nickname, so CANCEL + -- starts two columns left of where the nicknames do. + local cancelY = 1 + #self.party * 2 + if self:isCancel() then Chrome.cursor(0, cancelY) end + Chrome.print("CANCEL", 1, cancelY) + + -- PlacePartyMenuText: Textbox at (0,14) with a 2x18 interior, string at (1,16). + -- ReturnToMapWithSpeechTextbox restores the normal font afterwards, and so + -- does this: the prompt is ordinary text. + Font.useBattleExtra(wasBattle) + Chrome.box(0, 14, 20, 4) + -- SwitchPartyMons swaps the prompt for PARTYMENUACTION_MOVE's string while + -- the second pick is open, then puts the caller's own back. + local prompt = self.switchFrom and PartyMenu.PROMPTS.moveTo or self.prompt + Chrome.print(#self.party == 0 and PartyMenu.PROMPTS.none or prompt, 1, 16) + -- PokemonActionSubmenu clears (1,15) 2x18 before MonSubmenu draws, so the + -- prompt is gone behind the box rather than showing through it. + if self.submenu then self:drawSubmenu() end + love.graphics.setColor(1, 1, 1, 1) +end + +-- MenuBox over the bottom right of the list, with the labels two rows and two +-- columns inside it and the cursor one column left of them. +function PartyMenu:drawSubmenu() + local menu = self.submenu + -- GetMenuTextStartCoord puts the labels at (13, 12) two rows apart, cursor + -- one column left (home/menu.asm:199-226). + if menu.battle then + Chrome.box(BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP, 9, 7) + for row, item in ipairs(menu.items) do + local ty = BATTLE_SUBMENU_TOP + 1 + (row - 1) * 2 + if row == menu.index then Chrome.cursor(BATTLE_SUBMENU_LEFT + 1, ty) end + Chrome.print(item.label, BATTLE_SUBMENU_LEFT + 2, ty) + end + return + end + local count = #menu.items + local top = PartyMenu.submenuTop(count) + Chrome.box(SUBMENU_LEFT, top, SUBMENU_RIGHT - SUBMENU_LEFT + 1, + SUBMENU_BOTTOM - top + 1) + for row, item in ipairs(menu.items) do + local tx, ty = PartyMenu.submenuLabelCoord(count, row) + if row == menu.index then Chrome.cursor(tx - 1, ty) end + Chrome.print(item.label, tx, ty) + end +end + +function PartyMenu:draw() + self:drawPanel() +end + +function PartyMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return PartyMenu diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua new file mode 100644 index 00000000..365481b1 --- /dev/null +++ b/src/ui/gen2/PcMenu.lua @@ -0,0 +1,340 @@ +-- The Pokemon PC's top menu (engine/pokemon/bills_pc_top.asm _BillsPC). +-- +-- WITHDRAW POKéMON / DEPOSIT POKéMON / CHANGE BOX / +-- MOVE POKéMON W/O MAIL / MAIL BOX / SEE YA! +-- +-- MAIL BOX is the item PC's PLAYERSPCITEM_MAIL_BOX row rather than one of +-- _BillsPC's five; it is here because this port used to fold both PCs into +-- one menu, and a directly-constructed PcMenu still folds them. The +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua) opens this as +-- BILL's PC with `bills = true`, which shows the cart's own five rows and +-- leaves the MAIL BOX to <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua). +-- +-- .MenuHeader is menu_coords 0, 0, 19, 17 -- the menu owns the whole screen, +-- with "What do you want to do?" in a text box along the bottom. Choosing an +-- entry pushes BoxMenu (the withdraw/deposit list) or the box picker. +-- +-- The assembled rows run through the ui.pc.items hook, the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC), with SEE YA! appended after it +-- the way that site appends LOG OFF. + +local Boxes = require("src.core.gen2.Boxes") +local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") +local Mail = require("src.core.gen2.Mail") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Strings = require("src.core.Strings") + +-- _PCMonHoldingMailText (data/text/common_2.asm), the refusal +-- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because +-- the ASM has a `para` in the middle of it. Declared up here and looked up at +-- the call site, so Strings.source is what puts both in the catalog. +local MON_HOLDING_MAIL = { + Strings.source("There is a POKéMON\nholding MAIL."), + Strings.source("Please remove the\nMAIL."), +} + +local PcMenu = {} +PcMenu.__index = PcMenu +PcMenu.isOpaque = true + +-- .strings, verbatim. <PK> and <MN> are real font glyphs (codes $e1/$e2) and +-- Font.split matches charmap sequences, so writing them the way the ASM does +-- draws two tiles rather than seven -- which is the only reason "MOVE <PK><MN> +-- W/O MAIL" fits inside a 20-tile screen. +local ENTRIES = { + { id = "withdraw", label = "WITHDRAW <PK><MN>" }, + { id = "deposit", label = "DEPOSIT <PK><MN>" }, + { id = "changebox", label = "CHANGE BOX" }, + { id = "move", label = "MOVE <PK><MN> W/O MAIL" }, + -- PLAYERSPCITEM_MAIL_BOX (engine/events/pokecenter_pc.asm), which BOTH + -- .WhichPC lists carry: the MAILBOX is on the item PC in a Pokecenter and in + -- the bedroom alike, unlike DECORATION below. It sits here because this + -- port folds the item PC's menu into the storage one. + { id = "mailbox", label = "MAIL BOX" }, + { id = "seeya", label = "SEE YA!" }, +} + +-- PLAYERSPCITEM_DECORATION, the one row the bedroom's PC has that a +-- Pokecenter's does not (engine/events/pokecenter_pc.asm: PLAYERSPC_HOUSE +-- carries it, PLAYERSPC_NORMAL does not). It belongs to the item PC's menu on +-- the cart, which this port folds into the storage menu the same way both PCs +-- are folded -- so it hangs off the same list, gated on `house`. +local DECORATION = { id = "decoration", label = "DECORATION" } + +-- The exit row. It is a member of ENTRIES (it is one of _BillsPC's five), but +-- the list is assembled without it and it is put back on the end AFTER the +-- ui.pc.items hook has run, exactly as the Gen 1 site appends LOG OFF +-- (src/world/OverworldController.lua openPC): a mod may add, drop or reorder +-- anything it likes and still cannot orphan the way out. +local EXIT_ID = "seeya" + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +function PcMenu:wantsFillScale() return true end +function PcMenu:drawsWidescreen() return true end + +-- opts: save, house (the bedroom's PC, which also does decorations), events +-- (the wEventFlags bitfield the decoration menu reads ownership from), +-- bills (_BillsPC's own five rows, no MAIL BOX: what the whose-PC +-- menu's BILL's PC entry opens, src/ui/gen2/CenterPcMenu.lua), +-- onClose(changedDecorations) +function PcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onClose = opts.onClose + self.house = opts.house and true or false + self.events = opts.events + -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu + -- reaches that through <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua), so + -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the + -- MAILBOX is on both .WhichPC lists. + local dropMailbox = opts.bills and not self.house + local entries = {} + for _, entry in ipairs(ENTRIES) do + local drop = entry.id == EXIT_ID + or (dropMailbox and entry.id == "mailbox") + if not drop then entries[#entries + 1] = entry end + end + if self.house then + entries[#entries + 1] = DECORATION + end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- SEE YA! goes back on last: it is the row B lands on, and TURN OFF sits at + -- the bottom of the cart's house list too. + for _, entry in ipairs(ENTRIES) do + if entry.id == EXIT_ID then entries[#entries + 1] = entry end + end + self.entries = entries + -- wChangedDecorations, carried out to `special PlayersHousePC` so its + -- script's `iftrue` can reload the map. + self.changedDecorations = false + self.index = 1 + self.message = nil + -- Whether clearing the message also logs off. .CheckCanUsePC's does (the PC + -- never opened at all); BillsPC_MovePKMNMenu's mail refusal does not -- its + -- `.quit` returns with the carry clear, which drops back into the _BillsPC + -- loop and redraws this menu. + self.messageCloses = true + -- .CheckCanUsePC: an empty party gets the "You'll need a POKéMON" line and + -- the PC never opens. Kept here rather than at the call site so every route + -- into the PC (the overworld script, a driver, a mod) gets the same gate. + local ok, reason = Boxes.canUsePc(self.save) + if not ok then self.message = reason end + return self +end + +-- A refusal that leaves the PC open: the message replaces the menu until a +-- button clears it, and then the menu is back exactly where it was. `pages` +-- is a list because a `para` in the ASM is a screenful of its own -- the text +-- box holds two rows and the fourth line of _PCMonHoldingMailText would +-- otherwise be drawn off the bottom of the screen. +function PcMenu:notice(pages) + self.message = pages[1] + self.messagePages = pages + self.messagePage = 1 + self.messageCloses = false +end + +function PcMenu:close() + if self.onClose then self.onClose(self.changedDecorations) end +end + +function PcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + -- A hook- or monkey-patch-injected row carries label + onSelect and no id + -- the ladder below knows, so without this arm it draws and does nothing. + -- Same shape and same argument order as src/ui/gen2/PartyMenu.lua:433. + if type(entry.onSelect) == "function" then + entry.onSelect(self, game) + return + end + if entry.id == "seeya" then + self:close() + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "changebox" then + self.picking = true + self.pickIndex = self.save.currentBox or 1 + return + end + -- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail BEFORE it opens the list and + -- refuses outright: MOVE POKéMON W/O MAIL is a whole-party operation, so one + -- letter anywhere in the party stops it. + if entry.id == "move" and Mail.anyMonHoldingMail(self.save) then + self:notice({ Strings(MON_HOLDING_MAIL[1]), Strings(MON_HOLDING_MAIL[2]) }) + return + end + if not (game and game.stack) then return end + Screens.push(game, "Gen2BoxMenu", { + save = self.save, + mode = entry.id, -- "withdraw" | "deposit" | "move" + onClose = function() game.stack:pop() end, + }) +end + +function PcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local pages = self.messagePages + if pages and self.messagePage < #pages then + self.messagePage = self.messagePage + 1 + self.message = pages[self.messagePage] + return + end + local closes = self.messageCloses + self.message, self.messagePages, self.messagePage = nil, nil, nil + self.messageCloses = true + if closes then self:close() end + end + return + end + + if self.picking then + local total = Boxes.NUM_BOXES + if input:wasPressed("up") then + self.pickIndex = self.pickIndex > 1 and self.pickIndex - 1 or total + elseif input:wasPressed("down") then + self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 + elseif input:wasPressed("a") then + Boxes.setCurrent(self.save, self.pickIndex) + self.picking = false + elseif input:wasPressed("b") then + self.picking = false + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + self:close() + end +end + +function PcMenu:drawPanel() + Chrome.clear() + if self.message then + Chrome.box(0, 12, 20, 6) + -- A text box's two lines sit two tile rows apart, not one -- the same + -- spacing src/render/TextBox.lua uses (line1 = ty+2, line2 = ty+4). + local line = 14 + for part in (self.message .. "\n"):gmatch("(.-)\n") do + Chrome.print(part, 1, line) + line = line + 2 + end + love.graphics.setColor(1, 1, 1, 1) + return + end + + if self.picking then + -- CHANGE BOX: the 14 box names with how full each one is, six at a time. + Chrome.box(0, 0, 20, 14) + local rows = 6 + local scroll = math.max(0, math.min(self.pickIndex - rows, + Boxes.NUM_BOXES - rows)) + for row = 1, rows do + local i = row + scroll + local ty = row * 2 - 1 + if i == self.pickIndex then Chrome.cursor(1, ty) end + Chrome.print(Boxes.name(self.save, i), 2, ty) + Chrome.printRight( + ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), + 18, ty) + end + Chrome.box(0, 14, 20, 4) + Chrome.print("Which BOX?", 1, 16) + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- .LogIn prints _PCWhatText into the (0,12) box, and that text is one word: + -- "What?". PrintText starts at (1,14). The box goes down first because + -- the menu window is drawn over it wherever a folded list runs past row 12, + -- the way the cart's windows stack (src/ui/gen2/ItemPcMenu.lua does the + -- same with the house's six-row item list). + Chrome.box(0, 12, 20, 6) + Chrome.print("What?", 1, 14) + + -- ClearPCItemScreen: Textbox at (0,0) with a 10x18 interior, and a second + -- at (0,12) with a 4x18 one. GetMenuTextStartCoord then puts the first + -- label at (left+1+1, top+1+1) = (2,2) because STATICMENU_CURSOR is set and + -- the menu does not ask for NO_TOP_SPACING; rows are two apart and the + -- cursor sits one column left of the label. _BillsPC's own five rows end + -- at row 10 and fit that box exactly; the folded MAIL BOX (and the + -- bedroom's DECORATION) put rows below it, so the window is sized to the + -- list rather than to the five the cart ships. + Chrome.box(0, 0, 20, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.label, 2, ty) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function PcMenu:draw() + self:drawPanel() +end + +function PcMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PcMenu.ENTRIES = ENTRIES + +return PcMenu diff --git a/src/ui/gen2/PhotoStudio.lua b/src/ui/gen2/PhotoStudio.lua new file mode 100644 index 00000000..2be2d785 --- /dev/null +++ b/src/ui/gen2/PhotoStudio.lua @@ -0,0 +1,216 @@ +-- The Cianwood photo studio's portrait card (engine/printer/print_party.asm +-- PrintPartyMonPage1), reached through `special PhotoStudio` (src/script/ +-- gen2/Specials.lua H.PhotoStudio) once the fishing guru's own yes/no gate +-- (CianwoodPhotoStudioFishingGuruScript, maps/CianwoodPhotoStudio.asm) has +-- been answered yes and a party mon picked. +-- +-- On the cart, farcall PrintPartymon draws PrintPartyMonPage1 to the +-- background so SendScreenToPrinter has pixels to walk out the serial port, +-- 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 +-- -- 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 +-- pushes this screen for the "hold still" beat and then always lands on +-- the cancel branch afterward, exactly as a cartridge with nothing plugged +-- into its link port would. +-- +-- Coordinates below are the literal hlcoord operands +-- PrintPartyMonPage1 writes at, not a layout guessed from a screenshot: +-- +-- hlcoord 0, 0 PrepMonFrontpic, a 7x7 block +-- hlcoord 8, 0 "№." then the dex number, 3 digits, leading zeros +-- hlcoord 8, 2 the level (PrintLevel_Force3Digits) +-- hlcoord 12, 2 the HP icon then the max HP, 3 digits -- one field, no +-- separate current-HP column +-- hlcoord 8, 4 the nickname +-- hlcoord 9, 6 a bare '/' then the species name at hlcoord 10, 6 +-- hlcoord 0, 7 Textbox, 9 rows by 18 columns -- the border the fields +-- below sit inside +-- hlcoord 1, 9 "OT/" then the OT name at hlcoord 4, 9 +-- hlcoord 1, 11 "<ID>№" then the id number at hlcoord 4, 11, 5 digits +-- hlcoord 1, 14 "MOVE" then the first move's name at hlcoord 7, 14 +-- PlaceGenderAndShininess: gender at hlcoord 17, 2, the shiny ⁂ at 18, 2 +-- +-- The pic-drawing and palette plumbing (picFor/drawPic, PIC_PAD) is the same +-- shape src/ui/gen2/SummaryMenu.lua uses for its own PrepMonFrontpic block; +-- kept as its own small copy here rather than reached through SummaryMenu so +-- this screen has no dependency on the stats screen's paging state. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local Strings = require("src.core.Strings") + +local PhotoStudio = {} +PhotoStudio.__index = PhotoStudio +PhotoStudio.isOpaque = true + +function PhotoStudio:wantsFillScale() return true end + +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +local function num(value, width, leadingZeros) + return Chrome.number(value, width, leadingZeros) +end + +-- opts: mon, playerName, pokemon, moves, palettes, onClose() +function PhotoStudio.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PhotoStudio) + self.game = game + local data = (game and game.data) or {} + local save = game and game.save + self.mon = opts.mon + self.playerName = opts.playerName + or (save and save.player and save.player.name) or "?" + self.pokemon = opts.pokemon or data.pokemon + self.moves = opts.moves or data.moves + self.palettes = opts.palettes or data.gen2Palettes + self.onClose = opts.onClose + self.done = false + self.picCache = {} + return self +end + +function PhotoStudio:speciesDef() + local mon = self.mon + return mon and self.pokemon and self.pokemon[mon.species] +end + +function PhotoStudio:finish() + if self.done then return end + self.done = true + if self.onClose then self.onClose() end +end + +function PhotoStudio: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 PhotoStudio:picFor(species) + local def = species and self.pokemon and self.pokemon[species] + local path = def and def.spriteFront + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +-- PrepMonFrontpic at hlcoord 0, 0: a 7x7 block with the pic centred in it. +function PhotoStudio:drawPic() + local mon = self.mon + local image = mon and self:picFor(mon.species) + if not image then return end + local G = love.graphics + local colors = self.palettes and mon.species + and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + 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 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 + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function PhotoStudio:moveName() + local mon = self.mon + local entry = mon and mon.moves and mon.moves[1] + if not entry then return "-" end + local def = self.moves and self.moves[entry.id] + return (def and def.name) or entry.id +end + +function PhotoStudio:drawPanel() + -- PrintPartyMonPage1 opens with LoadFontsBattleExtra, and the card is + -- written in that sheet's glyphs: '№' ($74), '<ID>' ($73) and '<LV>' ($6e) + -- are a middle dot, a closing quote and a kana on the normal extra sheet. + local wasBattle = Font.useBattleExtra(true) + Chrome.clear() + local mon = self.mon or {} + local def = self:speciesDef() + + self:drawPic() + + Chrome.print(Strings("№."), 8, 0) + Chrome.print(num((def and def.dex) or 0, 3, true), 10, 0) + + Chrome.print(Strings("<LV>") .. num(mon.level or 1, 2), 8, 2) + -- `ld de, wTempMonMaxHP / lb bc, 2, 3 / call PrintNum`: one field, the + -- mon's max HP -- the card has no separate current-HP column, unlike the + -- stats screen's pink page. + Chrome.print(Strings("HP"), 12, 2) + Chrome.print(num(mon.maxHp or mon.hp or 0, 3), 14, 2) + + -- PlaceGenderAndShininess: gender at hlcoord 17, 2, the shiny ⁂ at 18, 2. + if mon.gender == "male" then + Chrome.print(Strings("♂"), 17, 2) + elseif mon.gender == "female" then + Chrome.print(Strings("♀"), 17, 2) + end + if mon.shiny then Chrome.print(Strings("⁂"), 18, 2) end + + Chrome.print(mon.nickname or mon.name or mon.species or "?", 8, 4) + + Chrome.print(Strings("/"), 9, 6) + Chrome.print((def and def.name) or mon.species or "?", 10, 6) + + Chrome.textbox(0, 7, 18, 9) + + Chrome.print(Strings("OT/"), 1, 9) + Chrome.print(mon.ot or self.playerName, 4, 9) + + Chrome.print(Strings("<ID>№"), 1, 11) + Chrome.print(num(mon.otId or 0, 5, true), 4, 11) + + Chrome.print(Strings("MOVE"), 1, 14) + Chrome.print(self:moveName(), 7, 14) + Font.useBattleExtra(wasBattle) +end + +function PhotoStudio:draw() + self:drawPanel() +end + +-- PrintPartyMonPage1 opens on ClearBGPalettes / ClearTilemap +-- (engine/printer/print_party.asm:134-135), so the card owns the screen +-- outright. The panel itself starts from Chrome.clear(), so white IS the +-- surround here and the fill just carries it to the window edge. +function PhotoStudio:drawsWidescreen() return true end + +function PhotoStudio: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 PhotoStudio diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua new file mode 100644 index 00000000..089f7048 --- /dev/null +++ b/src/ui/gen2/PokedexMenu.lua @@ -0,0 +1,1460 @@ +-- Gen 2 #DEX, transcribed from engine/pokedex/pokedex.asm. +-- +-- The main screen is two layers, which is the thing to understand before any +-- coordinate in here makes sense: +-- +-- * The **background** holds the frontpic box, the SEEN/OWN box, the +-- vertical rule at column 8 and the bottom caption, and it is scrolled +-- left by POKEDEX_SCX (5 pixels). So background tile column 0 lands at +-- screen x -5. +-- * The **window** holds the listing. Pokedex_InitMainScreen sets hWX to +-- $47 (or $4a in OLD mode) and hWY to 0, so the window's own column 0 is +-- at screen x 64 (67 in OLD mode) and it covers the full height. Twelve +-- of its columns fit on screen, which is exactly the 11-wide list plus +-- its scroll bar. +-- +-- Both layers are written through the same wTilemap buffer and copied to +-- vBGMap0 / vBGMap1 at different times, which is why every hlcoord in the ASM +-- reads as if it were the only screen. +-- +-- Everything the screens draw comes out of one 64-tile sheet decompressed +-- over vTiles2 tile $31, plus an *inverted* font: Pokedex_LoadInvertedFont +-- flips both bitplanes of the standard font, so under PREDEFPAL_POKEDEX +-- (white, orange, dark red, black) the dex prints white on black. Here that +-- is Chrome.printInverted, which draws the ordinary font page through the +-- reversed palette; the inverted ' ' cell is a solid black tile no sheet +-- carries, so PokedexMenu:blank paints it. +-- +-- Gold's dex sorts three ways -- NEW (Johto order), OLD (national, and the +-- only mode that prints numbers) and A-Z. SELECT opens the option screen on +-- the cart; here it cycles the mode directly. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local TileSheet = require("src.ui.gen2.TileSheet") +local Nests = require("src.core.gen2.Nests") +local Sound = require("src.core.Sound") +local Unown = require("src.core.gen2.Unown") + +local PokedexMenu = {} +PokedexMenu.__index = PokedexMenu +PokedexMenu.isOpaque = true + +-- wDexListingHeight is set to 7 by Pokedex_InitMainScreen. +local VISIBLE_ROWS = 7 +local MODES = { "NEW", "OLD", "A-Z" } + +-- DexEntryScreen_ArrowCursorData, in its own order. PRNT is listed because the +-- cursor stops on it; the port has no Game Boy Printer to send anything to. +local ENTRY_ACTIONS = { "PAGE", "AREA", "CRY", "PRNT" } +-- The four dwcoord columns the arrow parks in, row 17. +local ENTRY_ACTION_X = { 1, 6, 11, 15 } + +-- engine/pokedex/pokedex.asm POKEDEX_SCX. +local SCX = 5 +-- hWX values, less the hardware's 7-pixel bias. +local WINDOW_X = { NEW = 0x47 - 7, OLD = 0x4a - 7, ["A-Z"] = 0x47 - 7 } + +-- Tile ids out of the dex sheet, named after what the routines use them for. +local TILE_BG = 0x32 +local TILE_BORDER = { -- Pokedex_PlaceBorder + topLeft = 0x33, top = 0x34, topRight = 0x35, + left = 0x36, right = 0x37, + bottomLeft = 0x38, bottom = 0x39, bottomRight = 0x3a, +} +local TILE_CAUGHT = 0x4f +local TILE_FOOT = 0x5e +local TILE_INCH = 0x5f +local TILE_NO = { 0x5c, 0x5d } +local TILE_DIVIDER = 0x61 +local TILE_PAGE_TOP = 0x55 +local TILE_PAGE_P = 0x56 +local TILE_PAGE_DIGIT = { 0x57, 0x58 } + +-- String_SELECT_OPTION falls through into String_START_SEARCH with no +-- terminator between them, so placing it at (1,17) writes all 18 tiles. +local BOTTOM_CAPTION = { + 0x3b, 0x48, 0x49, 0x4a, 0x44, 0x45, 0x46, 0x47, + 0x3c, 0x3b, 0x41, 0x42, 0x43, 0x4b, 0x4c, 0x4d, 0x4e, 0x3c, +} +-- ...and the window gets its own copy of just the START > SEARCH half. +local WINDOW_CAPTION = { 0x3c, 0x3b, 0x41, 0x42, 0x43, 0x4b, 0x4c, 0x4d, 0x4e, 0x3c } + +-- Pokedex_PutNewModeABCModeCursorOAM / Pokedex_PutOldModeCursorOAM, as +-- { screen x, screen y, tile, xflip, yflip }. dbsprite emits y first and the +-- values are OAM coordinates, so screen space is (x - 8, y - 16); the cursor +-- row adds 16 pixels per step, which Pokedex_LoadCursorOAM does with a +-- `swap a` on the low three bits. +local function sprite(xTile, yTile, xPixel, yPixel, tile, xflip, yflip) + return { + x = xTile * 8 + xPixel - 8, + y = yTile * 8 + yPixel - 16, + tile = tile, xflip = xflip, yflip = yflip, + } +end + +local CURSOR_OAM = { + sprite(9, 3, -1, 3, 0x30), sprite(9, 2, -1, 3, 0x31), + sprite(10, 2, -1, 3, 0x32), sprite(11, 2, -1, 3, 0x32), + sprite(12, 2, -1, 3, 0x33), + sprite(16, 2, 0, 3, 0x33, true), sprite(17, 2, 0, 3, 0x32, true), + sprite(18, 2, 0, 3, 0x32, true), sprite(19, 2, 0, 3, 0x31, true), + sprite(19, 3, 0, 3, 0x30, true), + sprite(9, 4, -1, 3, 0x30, false, true), sprite(9, 5, -1, 3, 0x31, false, true), + sprite(10, 5, -1, 3, 0x32, false, true), sprite(11, 5, -1, 3, 0x32, false, true), + sprite(12, 5, -1, 3, 0x33, false, true), + sprite(16, 5, 0, 3, 0x33, true, true), sprite(17, 5, 0, 3, 0x32, true, true), + sprite(18, 5, 0, 3, 0x32, true, true), sprite(19, 5, 0, 3, 0x31, true, true), + sprite(19, 4, 0, 3, 0x30, true, true), +} + +local CURSOR_OAM_OLD = { + sprite(9, 3, -1, 0, 0x30), sprite(9, 2, -1, 0, 0x31), + sprite(10, 2, -1, 0, 0x32), sprite(11, 2, -1, 0, 0x32), + sprite(12, 2, -1, 0, 0x32), sprite(13, 2, -1, 0, 0x33), + sprite(16, 2, -2, 0, 0x33, true), sprite(17, 2, -2, 0, 0x32, true), + sprite(18, 2, -2, 0, 0x32, true), sprite(19, 2, -2, 0, 0x32, true), + sprite(20, 2, -2, 0, 0x31, true), sprite(20, 3, -2, 0, 0x30, true), + sprite(9, 4, -1, 0, 0x30, false, true), sprite(9, 5, -1, 0, 0x31, false, true), + sprite(10, 5, -1, 0, 0x32, false, true), sprite(11, 5, -1, 0, 0x32, false, true), + sprite(12, 5, -1, 0, 0x32, false, true), sprite(13, 5, -1, 0, 0x33, false, true), + sprite(16, 5, -2, 0, 0x33, true, true), sprite(17, 5, -2, 0, 0x32, true, true), + sprite(18, 5, -2, 0, 0x32, true, true), sprite(19, 5, -2, 0, 0x32, true, true), + sprite(20, 5, -2, 0, 0x31, true, true), sprite(20, 4, -2, 0, 0x30, true, true), +} + +-- Pokedex_PutScrollbarOAM: one OBJ, tile $0f, x 161, y from 20 to 141. +local SCROLLBAR_TILE = 0x0f +local SCROLLBAR_X = 161 - 8 +local SCROLLBAR_TOP = 20 - 16 +local SCROLLBAR_TRAVEL = 121 + +function PokedexMenu:wantsFillScale() return true end +function PokedexMenu:drawsWidescreen() return true end + +-- opts: save, pokedex (pokedex.lua), pokemon (pokemon.lua), palettes, +-- menuGfx (menu_gfx.lua), onClose(), entrySpecies (open straight on that +-- species' ENTRY screen, the way `predef NewPokedexEntry` does), newEntry (the +-- two-page NewPokedexEntry viewing, no action bar) +function PokedexMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PokedexMenu) + self.game = game + self.save = opts.save or (game and game.save) + local data = game and game.data or {} + -- Held, not just read into the fields below. CRY resolves its sample + -- through data.audio.cries and AREA resolves nests and landmark names + -- through data.maps / data.landmarks, and every one of those reads + -- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry + -- returned before it reached Sound, and the button did nothing at all. + -- Taken by reference so a mod's merged cry or landmark is the one used. + self.data = data + self.dex = opts.pokedex or data.gen2Pokedex + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes + self.onClose = opts.onClose + self.modeIndex = 1 + self.index = 1 + self.scroll = 0 + self.view = "list" -- list | entry | area | option | search | results | unown + self.page = 1 + self.entryAction = 1 + self.picCache = {} + -- The OPTION and SEARCH screens' own cursors, and wDexCurUnownIndex -- the + -- slot UNOWN MODE's cursor sits on, 0-based like the cart's. + self.optionIndex = 1 + self.searchIndex = 1 + self.unownIndex = 0 + -- wDexSearchMonType1 starts at NORMAL + 1 and TYPE2 at 0 ("-----"). + self.searchType = { 1, 0 } + self.searchResults = nil + + local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokedex + self.gfx = gfx + if gfx then + self.sheet = TileSheet.new({ + path = gfx.tiles, wide = gfx.tilesWide or 16, + firstTile = gfx.firstTile or 0x31, palette = gfx.palette, + }) + self.objs = TileSheet.new({ + path = gfx.objs, wide = gfx.objsWide or 16, + firstTile = 0, palette = gfx.cursorPalette, + }) + -- Kept as the raw palette, not two colours: the COLOR option substitutes + -- palettes at draw time, so picking colours out of one here would freeze + -- the dex's ink and paper at whatever mode was live when it opened. + self.dexPalette = gfx.palette + end + + -- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live + -- only while UNOWN MODE is on screen. It is a sheet rather than a font + -- page (see PokedexMenu:unownGlyph), and it draws through the dex palette + -- REVERSED, because the routine inverts the tiles on their way into VRAM + -- the same way Pokedex_LoadInvertedFont inverts the ordinary font. + local font = opts.font or data.font + if font and font.imageUnown and gfx then + self.unownFontBase = font.unownBase or 0x40 + self.unownFont = TileSheet.new({ + path = font.imageUnown, wide = font.unownWide or 3, + firstTile = font.unownBase or 0x40, + paletteFor = function() + local pal = self.dexPalette + if not pal then return nil end + return { pal[4], pal[3], pal[2], pal[1] } + end, + }) + end + self:rebuild() + -- NewPokedexEntry (engine/items/item_effects.asm:534-542): catching a mon the + -- player did not already own runs the dex straight into that species' ENTRY + -- screen rather than the listing. Set after rebuild because the row index is + -- only meaningful once the rows exist. + if opts.entrySpecies then + for index, row in ipairs(self.rows) do + if row.species == opts.entrySpecies then + self.index = index + self.view = "entry" + self.page = 1 + self:ensureVisible() + break + end + end + -- _NewPokedexEntry's own tail: `ld a, [wCurPartySpecies] / call PlayMonCry` + -- (engine/pokedex/pokedex.asm:2554-2555). + if opts.newEntry and self.view == "entry" then + self.newEntry = true + self:playCry(opts.entrySpecies) + end + end + return self +end + +function PokedexMenu:mode() + return MODES[self.modeIndex] +end + +function PokedexMenu:styled() + return self.sheet ~= nil and self.sheet:available() +end + +-- The species list in the current sort order. OLD is the national (index) +-- order, which for Gen 2 is just speciesOrder. +function PokedexMenu:order() + local dex = self.dex + if not dex then return {} end + local mode = self:mode() + if mode == "NEW" and dex.newOrder then return dex.newOrder end + if mode == "A-Z" and dex.alphabeticalOrder then return dex.alphabeticalOrder end + local out = {} + for species, entry in pairs(dex.entries or {}) do + out[entry.dex or #out + 1] = species + end + return out +end + +function PokedexMenu:rebuild() + local seen = (self.save and self.save.pokedex and self.save.pokedex.seen) or {} + local caught = (self.save and self.save.pokedex and self.save.pokedex.caught) + or {} + local rows = {} + for _, species in ipairs(self:order()) do + local entry = self.dex and self.dex.entries and self.dex.entries[species] + if entry then + rows[#rows + 1] = { + species = species, + dex = entry.dex, + seen = seen[species] == true, + caught = caught[species] == true, + } + end + end + self.rows = rows + self.index = math.max(1, math.min(self.index, math.max(1, #rows))) + self:ensureVisible() +end + +function PokedexMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, #self.rows - VISIBLE_ROWS))) +end + +function PokedexMenu:current() + return self.rows[self.index] +end + +function PokedexMenu:totals() + local seen, caught = 0, 0 + for _, entry in ipairs(self.rows) do + if entry.seen then seen = seen + 1 end + if entry.caught then caught = caught + 1 end + end + return seen, caught +end + +-- The entry bar's arrow flashes. Counted here rather than in the entry +-- branch so it keeps ticking on a frame with no input, and on the same 32-step +-- period the AREA map's own blink uses (PokedexMenu:updateArea) so the two +-- cannot drift out of step on screens that show both. +function PokedexMenu:cursorVisible() + return ((self.entryBlink or 0) % 32) < 20 +end + +function PokedexMenu:update(_dt) + self.entryBlink = (self.entryBlink or 0) + 1 + local input = self.game and self.game.input + if not input then return end + if self.view == "entry" then + -- NewPokedexEntry is two WaitPressAorB_BlinkCursor pages and then out + -- (engine/pokedex/new_pokedex_entry.asm:19-23). + if self.newEntry then + if input:wasPressed("a") or input:wasPressed("b") then + if self.page == 1 then + self.page = 2 + elseif self.onClose then + self.onClose() + end + end + return + end + -- DexEntryScreen_MenuActionJumptable's PAGE flips between the entry's two + -- pages; B and A back out to the listing. + -- DexEntryScreen_ArrowCursorData: LEFT/RIGHT walk an arrow across four + -- actions at (1,17) (6,17) (11,17) (15,17), and A runs the one under it. + -- This used to flip the page on LEFT/RIGHT directly, which is a shortcut + -- that reads fine and quietly makes three of the four actions unreachable + -- -- AREA among them, so the nest map could never be opened at all. + if input:wasPressed("right") then + self.entryAction = (self.entryAction % #ENTRY_ACTIONS) + 1 + elseif input:wasPressed("left") then + self.entryAction = (self.entryAction - 2) % #ENTRY_ACTIONS + 1 + elseif input:wasPressed("a") then + local action = ENTRY_ACTIONS[self.entryAction] + if action == "PAGE" then + self.page = self.page == 1 and 2 or 1 + elseif action == "AREA" then + self.view = "area" + self.areaRegion = nil + elseif action == "CRY" then + self:playCry(self:current() and self:current().species) + elseif action == "PRNT" then + self:printEntry() + end + elseif input:wasPressed("b") then + self.view = "list" + end + return + end + if self.view == "area" then return self:updateArea(input) end + if self.view == "option" then return self:updateOption(input) end + if self.view == "search" then return self:updateSearch(input) end + if self.view == "unown" then return self:updateUnown(input) end + if input:wasPressed("b") then + if self.onClose then self.onClose() end + return + elseif input:wasPressed("select") then + -- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the + -- SEARCH screen; neither cycles anything in place. + self.view = "option" + self.optionIndex = self.modeIndex + return + elseif input:wasPressed("start") then + self.view = "search" + self.searchIndex = 1 + self.searchType = self.searchType or { 1, 0 } + self.searchResults = nil + return + elseif input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.rows + self:ensureVisible() + return + elseif input:wasPressed("down") then + self.index = self.index < #self.rows and self.index + 1 or 1 + self:ensureVisible() + return + elseif input:wasPressed("a") then + local row = self:current() + -- Pokedex_UpdateMainScreen's .a returns unless the mon has been seen. + if row and row.seen then + self.view = "entry" + self.page = 1 + -- Pokedex_InitDexEntryScreen's tail cries the selected mon + -- (pokedex.asm:345-347); PAGE itself does not (:386-394). + self:playCry(row.species) + end + return + end +end + +function PokedexMenu:monName(species) + local def = self.pokemon and self.pokemon[species] + return (def and def.name) or species +end + +function PokedexMenu:picFor(species) + local def = self.pokemon and self.pokemon[species] + local path = def and def.spriteFront + -- Pokedex_LoadSelectedMonTiles (engine/pokedex/pokedex.asm:2364) copies + -- wFirstUnownSeen into wUnownLetter before GetMonFrontpic, so the #DEX shows + -- the form the player FIRST met. The species' own row is letter A's pic + -- (src/import/RomExtractorGen2.lua fills it that way), which is exactly what + -- the cart draws while the byte is still 0. + if species == Unown.SPECIES then + local first = self.save and tonumber(self.save.firstUnownSeen) or 0 + if first ~= 0 then + path = Unown.formSprite(self.pokemon, first, false) or path + end + end + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil +end + +function PokedexMenu:questionMark() + local path = self.gfx and self.gfx.questionMark + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil +end + +-- Pokedex_PlaceFrontpicTopLeftCorner lays a 7x7 block of tiles at (1,1) on +-- every dex screen, and Pokedex_LoadSelectedMonTiles fills those 49 tiles -- +-- with the mon's frontpic if it has been seen, with LoadQuestionMarkPic's if +-- not. +-- +-- Gen 2 pics are 5x5, 6x6 or 7x7, and PadFrontpic centres the small ones in +-- that block: a 6x6 goes one tile in from the top left, a 5x5 one across and +-- two down. Everything else in the block stays blank, which under the +-- palette in force is its colour 0 -- so the pic sits on a solid square of +-- that colour rather than on the panel. +-- +-- `ownColors` picks which palette that is. _CGB_Pokedex_Init branches on +-- wCurPartySpecies: Pokedex_InitMainScreen sets it to -1, so the listing +-- draws every mon through PokedexQuestionMarkPalette (which is why the cart's +-- main screen shows a green mon on green), while Pokedex_InitDexEntryScreen +-- sets the real species and gets its own two colours. +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +function PokedexMenu:drawPic(row, tx, ty, ownColors) + local G = love.graphics + local image, colors + if row and row.seen then + image = self:picFor(row.species) + if ownColors then + colors = self.palettes and Palettes.monColors(self.palettes, row.species) + else + colors = self.gfx and self.gfx.questionMarkPalette + end + else + image = self:questionMark() + colors = self.gfx and self.gfx.questionMarkPalette + end + if not image then return end + + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", tx * 8, ty * 8, 7 * 8, 7 * 8) + + local tiles = math.floor(image:getWidth() / 8) + local pad = PIC_PAD[tiles] or PIC_PAD[7] + G.setColor(1, 1, 1, 1) + local function body() + G.draw(image, (tx + pad[1]) * 8, (ty + pad[2]) * 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- Pokedex_DrawFootprint: $62..$65 at (18,1), a 2x2 out of the strip +-- Pokedex_LoadAnyFootprint would have requested into those tiles. +function PokedexMenu:drawFootprint(species, tx, ty) + local path = self.gfx and self.gfx.footprints + local order = self.gfx and self.gfx.footprintOrder + if not (path and order) then return end + local index + for i, id in ipairs(order) do + if id == species then index = i break end + end + if not index then return end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + if not cached then return end + local quad = self.footprintQuad + if not quad then + quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions()) + self.footprintQuad = quad + end + quad:setViewport(0, (index - 1) * 16, 16, 16, cached:getDimensions()) + love.graphics.setColor(1, 1, 1, 1) + local function body() love.graphics.draw(cached, quad, tx * 8, ty * 8) end + if self.gfx.palette and GbcPalette.available() then + GbcPalette.with(self.gfx.palette, body) + else + body() + end +end + +function PokedexMenu:tile(id, tx, ty) + if self.sheet then self.sheet:draw(id, tx, ty) end +end + +function PokedexMenu:fill(id, tx, ty, wide, high) + for y = ty, ty + high - 1 do + for x = tx, tx + wide - 1 do + self:tile(id, x, y) + end + end +end + +function PokedexMenu:text(str, tx, ty) + Chrome.printInverted(str, tx, ty, self.gfx and self.gfx.palette) +end + +-- The inverted font's ' ' cell. Uninverted it is shade 0 throughout, so +-- inverted it is a solid shade 3 -- black under PREDEFPAL_POKEDEX -- and +-- every ClearBox and box interior on these screens is made of it. +function PokedexMenu:blank(tx, ty, wide, high) + -- Colour 3, which is what an inverted shade-0 cell resolves to and the same + -- colour Chrome.printThrough paints behind an inverted string. + local paper = self.dexPalette and GbcPalette.color(self.dexPalette, 4) + or { 0, 0, 0 } + local G = love.graphics + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", tx * 8, ty * 8, wide * 8, high * 8) + G.setColor(1, 1, 1, 1) +end + +-- Pokedex_PlaceBorder: b interior rows by c interior columns, so the box on +-- screen is (c + 2) wide and (b + 2) tall. The interior is ' ' ($7f), which +-- in the inverted font page is a solid black cell. +function PokedexMenu:border(tx, ty, interiorRows, interiorCols) + local B = TILE_BORDER + self:tile(B.topLeft, tx, ty) + for i = 1, interiorCols do self:tile(B.top, tx + i, ty) end + self:tile(B.topRight, tx + interiorCols + 1, ty) + self:blank(tx + 1, ty + 1, interiorCols, interiorRows) + for row = 1, interiorRows do + self:tile(B.left, tx, ty + row) + self:tile(B.right, tx + interiorCols + 1, ty + row) + end + local bottom = ty + interiorRows + 1 + self:tile(B.bottomLeft, tx, bottom) + for i = 1, interiorCols do self:tile(B.bottom, tx + i, bottom) end + self:tile(B.bottomRight, tx + interiorCols + 1, bottom) +end + +-- PrintNum: a right-aligned field of `digits` characters, space-padded unless +-- PRINTNUM_LEADINGZEROS was set. `before` splits the field into an integer +-- part and a fraction with a '.' between them. +local function printNumString(value, digits, leadingZeros, before) + local text = ("%0" .. digits .. "d"):format(math.max(0, math.floor(value or 0))) + if not leadingZeros then + local kept = false + local out = {} + for i = 1, #text do + local ch = text:sub(i, i) + -- .PrintDigit stops suppressing once e runs out, which is the digit + -- immediately before the decimal point (and the units digit when there + -- is none), so those always print even as a zero. + local forced = i == #text or (before and i >= before) + if ch ~= "0" or kept or forced then + kept = true + out[#out + 1] = ch + else + out[#out + 1] = " " + end + end + text = table.concat(out) + end + if before then + return text:sub(1, before) .. "." .. text:sub(before + 1) + end + return text +end + +-- ---------------------------------------------------------------- main screen + +-- Pokedex_DrawMainScreenBG, drawn behind the window and scrolled by SCX. +function PokedexMenu:drawMainBackground() + local G = love.graphics + G.push() + G.translate(-SCX, 0) + + -- One column past the screen: SCX uncovers five pixels of column 20, which + -- the BG map holds the same background fill in. + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H) + self:border(0, 0, 7, 7) + self:border(0, 9, 6, 7) + + local seen, caught = self:totals() + self:text("SEEN", 1, 11) + self:text(printNumString(seen, 3), 5, 12) + self:text("OWN", 1, 14) + self:text(printNumString(caught, 3), 5, 15) + + for i, id in ipairs(BOTTOM_CAPTION) do self:tile(id, i, 17) end + + -- The rule between the two halves of the background and the window. + self:tile(0x59, 8, 0) + for y = 1, 7 do self:tile(0x5a, 8, y) end + self:tile(0x53, 8, 8) + self:tile(0x54, 8, 9) + for y = 10, 15 do self:tile(0x5a, 8, y) end + self:tile(0x5b, 8, 16) + + self:drawPic(self:current(), 1, 1) + G.pop() +end + +-- DrawPokedexListWindow + Pokedex_PrintListing, on the window layer. hWX +-- puts the window's column 0 at screen x 64 (67 in OLD mode) and only twelve +-- of its columns fit, so everything past column 11 is off screen anyway. +function PokedexMenu:drawMainWindow() + local G = love.graphics + local old = self:mode() == "OLD" + G.push() + G.translate(WINDOW_X[self:mode()] or 64, 0) + + -- ClearBox(0, 1) 15 rows by 11 columns, then the top and bottom edges. + self:blank(0, 1, 11, 15) + for x = 0, 10 do + self:tile(TILE_BORDER.top, x, 0) + self:tile(TILE_BORDER.bottom, x, 16) + end + self:tile(0x3f, 5, 0) + self:tile(0x40, 5, 16) + + -- The scroll bar column, or its flat OLD-mode replacement. + local top, mid, bottom = 0x50, 0x51, 0x52 + if old then top, mid, bottom = 0x66, 0x67, 0x68 end + self:tile(top, 11, 0) + for y = 1, 15 do self:tile(mid, 11, y) end + self:tile(bottom, 11, 16) + + self:fill(TILE_BG, 0, 17, 12, 1) + for i, id in ipairs(WINDOW_CAPTION) do self:tile(id, i - 1, 17) end + + for row = 1, VISIBLE_ROWS do + local entry = self.rows[row + self.scroll] + if entry then + local ty = row * 2 + if old then + self:text(printNumString(entry.dex or 0, 3, true), 0, ty - 1) + end + if entry.seen then + if entry.caught then self:tile(TILE_CAUGHT, 0, ty) end + self:text(self:monName(entry.species), 1, ty) + else + self:text("-----", 1, ty) + end + end + end + G.pop() +end + +function PokedexMenu:drawCursorObjs() + if not (self.objs and self.objs:available()) then + -- Without the OBJ sheet, mark the row the way every other Gen 2 list does. + local row = self.index - self.scroll + Chrome.cursor(math.floor((WINDOW_X[self:mode()] or 64) / 8) - 1, row * 2) + return + end + local G = love.graphics + local table_ = self:mode() == "OLD" and CURSOR_OAM_OLD or CURSOR_OAM + -- Pokedex_LoadCursorOAM adds (cursor & 7) * 16 to every y. + local offset = ((self.index - self.scroll - 1) % 8) * 16 + for _, obj in ipairs(table_) do + local quad = self.objs:quad(obj.tile) + local image = self.objs:image() + if quad and image then + G.setColor(1, 1, 1, 1) + local sx = obj.xflip and -1 or 1 + local sy = obj.yflip and -1 or 1 + local ox = obj.xflip and 8 or 0 + local oy = obj.yflip and 8 or 0 + local function body() + G.draw(image, quad, obj.x + ox, obj.y + offset + oy, 0, sx, sy) + end + if self.gfx.cursorPalette and GbcPalette.available() then + GbcPalette.with(self.gfx.cursorPalette, body) + else + body() + end + end + end + + -- The scroll bar thumb rides the whole list, not the page. Its OAM + -- attribute byte is 0, so unlike the cursor it wears OBJ palette 0, which + -- _CGB_Pokedex_Resume left as InitPartyMenuOBPals' first entry. + local total = math.max(1, #self.rows - 1) + local travel = math.floor((self.index - 1) * SCROLLBAR_TRAVEL / total) + local quad = self.objs:quad(SCROLLBAR_TILE) + if quad and self:mode() ~= "OLD" then + G.setColor(1, 1, 1, 1) + local pals = self.palettes and self.palettes.partyMenu + local colors = pals and pals[1] + local function body() + G.draw(self.objs:image(), quad, SCROLLBAR_X, SCROLLBAR_TOP + travel) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + end +end + +function PokedexMenu:drawList() + self:drawMainBackground() + self:drawMainWindow() + self:drawCursorObjs() +end + +-- --------------------------------------------------------------- entry screen + +-- Pokedex_DrawDexEntryScreenBG + DisplayDexEntry. +-- +-- Pokedex_InitDexEntryScreen only pushes the window off screen (hWX $a7); it +-- never resets hSCX, so this screen inherits the main screen's 5-pixel scroll +-- and the rightmost five pixels are whatever the BG map holds past column 19. +-- PlayMonCry, silent for a species with no extracted cry rather than raising. +-- PRNT. Pokedex_Print (engine/pokedex/pokedex.asm) hands the entry to the +-- Game Boy Printer over the link port, which is a device this port cannot +-- have; Gen 1's Yellow dex answers the same problem the same way +-- (src/ui/PokedexMenu.lua's PRNT arm), so Gold does too rather than leaving +-- the fourth action inert: the page the printer would have printed is written +-- out as a PNG under prints/ in the save folder, at the printer's own 160x144. +-- +-- The shot is THIS screen's own entry draw, so what lands in the file is what +-- was on the dex when PRNT was pressed, page and all. +function PokedexMenu:printEntry() + local row = self:current() + if not row then return end + local Printer = require("src.core.Printer") + local Strings = require("src.core.Strings") + local TextBox = require("src.render.TextBox") + local name = (self.pokemon and self.pokemon[row.species] + and self.pokemon[row.species].name) or tostring(row.species) + local saved, err = Printer.save("dex_" .. tostring(row.species), 160, 144, + function() self:drawEntry() end) + -- Word for word what Yellow's PRNT says (src/ui/PokedexMenu.lua), so the two + -- generations share one catalog entry and a translation covers both. + local text = saved + and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", + name, tostring(saved)) + or Strings("Printer error!\n%s", tostring(err)) + if self.game and self.game.stack then + self.game.stack:push(TextBox.new(self.game, text)) + end +end + +function PokedexMenu:playCry(species) + if not species then return end + local cries = self.data and self.data.audio and self.data.audio.cries + if cries and cries[species] then Sound.playCry(self.data, species) end +end + +-- ------------------------------------------------------------------- AREA +-- +-- Pokedex_GetArea (engine/pokegear/pokegear.asm) borrows the Pokegear's town +-- map and overlays FindNest's landmarks. The region shown starts as the one the +-- player is standing in; LEFT/RIGHT swap it, which is how you see a Kanto mon's +-- nests from Johto. B goes back to the entry. +function PokedexMenu:areaRegionName() + if self.areaRegion then return self.areaRegion end + local landmark = self:playerLandmark() + return Nests.regionOf(landmark) or "johto" +end + +-- The player's landmark index, which is what decides the starting region. +function PokedexMenu:playerLandmark() + local save = self.game and self.game.save + local mapId = save and save.position and save.position.map + local def = mapId and self.data and self.data.maps and self.data.maps[mapId] + return def and def.landmark +end + +-- The Pokegear's own tilemap blit: a flat list of tile ids, row-major over the +-- 20x18 screen. Reimplemented here rather than reached for across modules +-- because the dex draws through its own sheet and palette (the COLOR option +-- substitutes them), and borrowing Pokegear's would freeze the map's ink. +function PokedexMenu:drawTilemap(cells) + if type(cells) ~= "table" then return end + local i = 1 + for ty = 0, Chrome.SCREEN_H - 1 do + for tx = 0, Chrome.SCREEN_W - 1 do + local id = cells[i] + if id then self:tile(id, tx, ty) end + i = i + 1 + end + end +end + +function PokedexMenu:updateArea(input) + self.areaBlink = (self.areaBlink or 0) + 1 + if input:wasPressed("b") or input:wasPressed("a") then + self.view = "entry" + return + end + if input:wasPressed("left") or input:wasPressed("right") then + self.areaRegion = (self:areaRegionName() == "johto") and "kanto" or "johto" + end +end + +function PokedexMenu:drawArea() + local row = self:current() + if not row then return end + local region = self:areaRegionName() + local save = self.game and self.game.save + local nests = Nests.find(self.data, row.species, region, save) + + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H) + + -- The map itself is the Pokegear's, drawn through the same gfx the MAP card + -- uses. Without it (a cache imported before the town map was extracted) the + -- page still lists the landmark NAMES, which is the information the screen + -- exists to convey. + local maps = self.gfx and self.gfx.maps + local cells = maps and maps[region] + if cells then + self:drawTilemap(cells) + end + + self:blank(0, 0, Chrome.SCREEN_W, 2) + self:text(self:monName(row.species) .. "'S NEST", 1, 0) + self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1) + + local G = love.graphics + local table_ = self.data and self.data.landmarks + local byIndex = self.landmarkByIndex + if not byIndex then + byIndex = {} + for _, entry in pairs((table_ and table_.landmarks) or {}) do + if entry and entry.index then byIndex[entry.index] = entry end + end + self.landmarkByIndex = byIndex + end + + if #nests == 0 then + -- A species with no grass, water or roamer entry in this region. The cart + -- simply shows the map with nothing blinking on it. + self:text("AREA UNKNOWN", 4, 16) + return + end + + -- Blinking markers, the way the cart flashes its OBJs. + local on = ((self.areaBlink or 0) % 32) < 20 + if cells and on then + for _, index in ipairs(nests) do + local mark = byIndex[index] + if mark and mark.x and mark.y then + G.setColor(0, 0, 0, 1) + G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5) + G.setColor(1, 1, 1, 1) + G.rectangle("fill", mark.x - 1, mark.y - 1, 3, 3) + end + end + end + + -- Name the first one in words as well as on the map: the flashing dot is + -- unreadable at this size on a modern display, and the landmark name is what + -- a player actually wants off this screen. + local first = byIndex[nests[1]] + if first and first.name then + local name = tostring(first.name):gsub("\n", " ") + self:text(name, 1, 16) + if #nests > 1 then + self:text(("+%d"):format(#nests - 1), 17, 16) + end + end +end + +function PokedexMenu:drawEntry() + local row = self:current() + if not row then return end + local entry = self.dex and self.dex.entries and self.dex.entries[row.species] + if not entry then return end + + local G = love.graphics + G.push() + G.translate(-SCX, 0) + local ok, err = pcall(function() self:drawEntryBody(row, entry) end) + G.pop() + if not ok then error(err, 0) end +end + +function PokedexMenu:drawEntryBody(row, entry) + -- One column past the screen, because the scroll exposes it: the BG map is + -- 32 tiles wide and the fill runs on past the 20 the screen shows, so the + -- five pixels SCX uncovers on the right are the same background colour. + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H) + self:border(0, 0, 15, 18) + -- The right border column is then erased: (19,0) becomes the top edge, + -- rows 1-15 blank, and (19,16) the bottom edge. + self:tile(TILE_BORDER.top, 19, 0) + self:blank(19, 1, 1, 15) + self:tile(TILE_BORDER.bottom, 19, 16) + + for x = 1, 19 do self:tile(TILE_DIVIDER, x, 10) end + -- Pokedex_DrawDexEntryScreenBG blanks 18 columns, _NewPokedexEntry's own + -- ByteFill 19 (pokedex.asm:1155-1157, :2540-2545). + self:blank(1, 17, self.newEntry and 19 or 18, 1) + self:tile(0x3b, 0, 17) + -- _NewPokedexEntry ByteFills the action row away (pokedex.asm:2540-2545). + if not self.newEntry then + self:text(" PAGE AREA CRY PRNT", 1, 17) + -- Pokedex_InitArrowCursor parks an arrow on the selected action; without it + -- the four words are decoration and there is no way to tell what A will do. + -- + -- ENTRY_ACTION_X holds the ARROW's own dwcoord columns, so they are used as + -- they are. Subtracting one put the arrow a column early every time: on + -- PAGE it landed at column 0, outside the bar against the border, and on + -- CRY it landed on the last "A" of AREA and ate it. The words sit one + -- column right of each arrow slot -- the leading space in the string below + -- is what makes that true -- so the raw value is already the gap in front of + -- each word. + -- + -- Drawn through the dex's inverted palette and blinking, because this arrow + -- is white on the dark bar rather than the black one every other Gold menu + -- uses, and it flashes. The half-second period is chosen to read like the + -- cart; the exact frame count is not cited here. + if self:cursorVisible() then + Chrome.cursorThrough(ENTRY_ACTION_X[self.entryAction or 1] or 1, 17, + self.gfx and self.gfx.palette, true) + end + end + + self:drawPic(row, 1, 1, true) + self:drawFootprint(row.species, 18, 1) + + self:text(self:monName(row.species), 9, 3) + self:text(entry.kind or "", 9, 5) + self:tile(TILE_NO[1], 2, 8) + self:tile(TILE_NO[2], 3, 8) + self:text(printNumString(entry.dex or 0, 3, true), 4, 8) + + -- .Height / .Weight are placeholder strings until the mon is caught: + -- "HT ?'??"" at (9,7) and "WT ???lb" at (9,9). + self:text("HT", 9, 7) + self:text("WT", 9, 9) + self:tile(TILE_FOOT, 14, 7) + self:text("lb", 17, 9) + + if not row.caught then + self:text(" ?", 11, 7) + self:text("??", 15, 7) + self:tile(TILE_INCH, 17, 7) + self:text(" ???", 11, 9) + return + end + + -- The height word is four digits with two in front of the point and the + -- point replaced by the foot mark; the weight word is five with four in + -- front. Both are already the digits the cart prints. + local height = printNumString(entry.height or 0, 4, false, 2) + self:text(height:sub(1, 2), 12, 7) + self:text(height:sub(4), 15, 7) + self:tile(TILE_INCH, 17, 7) + self:text(printNumString(entry.weight or 0, 5, false, 4), 11, 9) + + -- Page marker, then the description. ClearBox(2,11) is 5 rows by 18 + -- columns and <NEXT> steps two rows, so the three lines land on 11/13/15. + self:tile(TILE_PAGE_TOP, 1, 9) + self:tile(TILE_PAGE_TOP, 2, 9) + self:tile(TILE_PAGE_P, 1, 10) + self:tile(TILE_PAGE_DIGIT[self.page] or TILE_PAGE_DIGIT[1], 2, 10) + + local text = self.page == 2 and entry.text2 or entry.text + local ty = 11 + for part in (tostring(text or "") .. "<NEXT>"):gmatch("(.-)<NEXT>") do + if ty > 15 then break end + self:text(part, 2, ty) + ty = ty + 2 + end +end + +-- ------------------------------------------------------------------ fallbacks + +-- A cache from before the dex sheet was extracted has no `pokedex` table. +-- Rather than draw nothing, fall back to the plain boxes this screen used +-- before the sweep. +function PokedexMenu:drawPlain() + Chrome.clear() + if self.view == "unown" then + -- Without the dex sheet there are no border tiles, so the ring of letters + -- is printed straight onto a plain box; the slot coordinates are the same + -- ones the styled screen uses. + Chrome.box(2, 1, 15, 13) + local list = self:unownDex() + local slot = math.min(self.unownIndex or 0, math.max(0, #list - 1)) + for index, value in ipairs(list) do + local coords = PokedexMenu.UNOWN_COORDS[index] + if coords then + Chrome.print(Unown.name(value) or "?", coords[1], coords[2]) + if index == slot + 1 then Chrome.cursor(coords[3], coords[4]) end + end + end + local letter = list[slot + 1] + if letter then + self:drawUnownPic(letter, 6, 5) + Chrome.print(Unown.word(letter) or "", 4, 15) + end + return + end + if self.view == "entry" then + local row = self:current() + local entry = row and self.dex and self.dex.entries + and self.dex.entries[row.species] + Chrome.box(0, 0, 20, 10) + if entry then + Chrome.print(("%s %s"):format( + Chrome.number(entry.dex or 0, 3, true), self:monName(row.species)), 1, 1) + Chrome.print(entry.kind or "", 1, 3) + Chrome.print("HT " .. printNumString(entry.height or 0, 4, false, 2), 1, 5) + Chrome.print("WT " .. printNumString(entry.weight or 0, 5, false, 4), 1, 7) + self:drawPic(row, 12, 1, true) + Chrome.box(0, 10, 20, 8) + local ty = 11 + for part in ((entry.text or "") .. "<NEXT>"):gmatch("(.-)<NEXT>") do + if ty > 16 then break end + Chrome.print(part, 1, ty) + ty = ty + 1 + end + end + return + end + Chrome.box(0, 0, 13, 18) + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local entry = self.rows[i] + if entry then + local ty = 1 + (row - 1) * 2 + if i == self.index then Chrome.cursor(0, ty) end + Chrome.print(Chrome.number(entry.dex or 0, 3, true), 1, ty) + Chrome.print(entry.seen and self:monName(entry.species) or "-----", 5, ty) + end + end + Chrome.box(13, 0, 7, 11) + self:drawPic(self:current(), 13, 1) + Chrome.box(13, 11, 7, 7) + local seen, caught = self:totals() + Chrome.print(self:mode(), 14, 12) + Chrome.print("SEEN", 14, 14) + Chrome.printRight(tostring(seen), 19, 15) + Chrome.print("OWN", 14, 16) + Chrome.printRight(tostring(caught), 19, 17) +end + +-- ---------------------------------------------------------- OPTION / SEARCH +-- +-- Pokedex_DrawOptionScreenBG and Pokedex_DrawSearchScreenBG, transcribed at +-- their own hlcoords. Both draw over the dex's own background fill rather +-- than into a text box, which is why they use `border` and `text` here. + +-- The SEARCH screen's type wheel: "-----" is index 0 and the eighteen real +-- types follow, in the order data/types/names.asm lists them. +PokedexMenu.SEARCH_TYPES = { + "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", + "STEEL", "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", + "DARK", +} + +-- .Modes, and the two-line description Pokedex_DisplayModeDescription prints +-- under each one. +-- `#` is the compression byte for POKé, four tiles either way, so the label +-- is spelled out here the way every other Gen 2 screen in the port spells it. +PokedexMenu.OPTION_MODES = { + { label = "NEW POKéDEX MODE", mode = "NEW", + lines = { "<PK><MN> are listed by", "evolution type." } }, + { label = "OLD POKéDEX MODE", mode = "OLD", + lines = { "<PK><MN> are listed by", "official type." } }, + { label = "A to Z MODE", mode = "A-Z", + lines = { "<PK><MN> are listed", "alphabetically." } }, + { label = "UNOWN MODE", mode = "UNOWN", unown = true, + lines = { "UNOWN are listed", "in catching order." } }, +} + +-- Pokedex_CheckUnlockedUnownMode: `ld a, [wStatusFlags] / bit +-- STATUSFLAGS_UNOWN_DEX_F`, which is ENGINE_UNOWN_DEX -- the flag the Ruins of +-- Alph researcher sets when he upgrades the #DEX, NOT "an Unown has been +-- caught". The two come apart in play: the researcher only turns up once a +-- puzzle has been solved, so a player who catches an Unown before talking to +-- him has the mode hidden until he has upgraded the machine. +function PokedexMenu:unownUnlocked() + local flags = (self.save and self.save.engineFlags) or {} + return flags[Unown.ENGINE_UNOWN_DEX] == true +end + +function PokedexMenu:optionRows() + local rows = {} + for _, entry in ipairs(PokedexMenu.OPTION_MODES) do + if not entry.unown or self:unownUnlocked() then rows[#rows + 1] = entry end + end + return rows +end + +function PokedexMenu:updateOption(input) + local rows = self:optionRows() + if input:wasPressed("up") then + self.optionIndex = self.optionIndex > 1 and self.optionIndex - 1 or #rows + elseif input:wasPressed("down") then + self.optionIndex = self.optionIndex < #rows and self.optionIndex + 1 or 1 + elseif input:wasPressed("b") or input:wasPressed("select") then + -- .return_to_main_screen: SELECT and B both back out without changing + -- anything. + self.view = "list" + elseif input:wasPressed("a") then + local row = rows[self.optionIndex] + if row and row.unown then + -- .MenuAction_UnownMode is the one option row that does NOT go back to + -- the listing: it sets DEXSTATE_UNOWN_MODE, its own screen. + self.view = "unown" + self.unownIndex = 0 + return + end + self.view = "list" + if row then + for index, name in ipairs(MODES) do + if name == row.mode then + -- .ChangeMode resets the listing to the top when the mode actually + -- changes, and does nothing at all when it does not. + if index ~= self.modeIndex then + self.modeIndex = index + self.index, self.scroll = 1, 0 + self:rebuild() + end + end + end + end + end +end + +-- ------------------------------------------------------------- UNOWN MODE +-- +-- Pokedex_InitUnownMode / Pokedex_UpdateUnownMode (engine/pokedex/pokedex.asm) +-- plus PrintUnownWord (engine/pokedex/unown_dex.asm). +-- +-- The screen is the FORM list, not the species list: wUnownDex holds the +-- letters in the order they were first caught, wDexUnownCount is how many of +-- them there are, and wDexCurUnownIndex walks that list with LEFT and RIGHT +-- only. A or B leaves, back to the OPTION screen. +-- +-- The letters are laid out in a ring around the picture and the ring is +-- addressed by SLOT, not by letter: UnownModeLetterAndCursorCoords row 0 is +-- (4,11) whatever letter was caught first. +function PokedexMenu:unownDex() + return Unown.dex(self.save) +end + +function PokedexMenu:updateUnown(input) + local list = self:unownDex() + if input:wasPressed("a") or input:wasPressed("b") then + -- .a_b returns to DEXSTATE_OPTION_SCR, not to the listing. + self.view = "option" + return + end + if input:wasPressed("right") then + -- `.right`: `inc a / cp e / ret nc` -- the last slot cannot advance, and + -- there is no wrap. + if self.unownIndex + 1 < #list then + self.unownIndex = self.unownIndex + 1 + end + elseif input:wasPressed("left") then + if self.unownIndex > 0 then self.unownIndex = self.unownIndex - 1 end + end +end + +-- UnownModeLetterAndCursorCoords, transcribed: the letter cell and the cursor +-- cell for each of the 26 SLOTS, running up the left column, along the top and +-- back down the right. +PokedexMenu.UNOWN_COORDS = { + { 4, 11, 3, 11 }, { 4, 10, 3, 10 }, { 4, 9, 3, 9 }, { 4, 8, 3, 8 }, + { 4, 7, 3, 7 }, { 4, 6, 3, 6 }, { 4, 5, 3, 5 }, { 4, 4, 3, 4 }, + { 4, 3, 3, 2 }, { 5, 3, 5, 2 }, { 6, 3, 6, 2 }, { 7, 3, 7, 2 }, + { 8, 3, 8, 2 }, { 9, 3, 9, 2 }, { 10, 3, 10, 2 }, { 11, 3, 11, 2 }, + { 12, 3, 12, 2 }, { 13, 3, 13, 2 }, { 14, 3, 15, 2 }, { 14, 4, 15, 4 }, + { 14, 5, 15, 5 }, { 14, 6, 15, 6 }, { 14, 7, 15, 7 }, { 14, 8, 15, 8 }, + { 14, 9, 15, 9 }, { 14, 10, 15, 10 }, +} + +-- Pokedex_DrawUnownModeBG: FillBackgroundColor2, a 10x13 box at (2,1), a 1x13 +-- box at (2,14), the two arrow tiles at (2,15) and (16,15), and the frontpic +-- at (6,5). The word goes at hlcoord 4, 15, twelve cells wide. +function PokedexMenu:drawUnown() + local list = self:unownDex() + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + self:border(2, 1, 10, 13) + self:border(2, 14, 1, 13) + self:tile(0x3d, 2, 15) + self:tile(0x3e, 16, 15) + + local slot = math.min(self.unownIndex or 0, math.max(0, #list - 1)) + local letter = list[slot + 1] + if letter then + -- Pokedex_LoadUnownFrontpicTiles: the pic is the FORM's, which is why + -- pokemon.lua's UNOWN entry carries letters.A..Z. + self:drawUnownPic(letter, 6, 5) + self:unownText(Unown.word(letter) or "", 4, 15) + end + for index, value in ipairs(list) do + local coords = PokedexMenu.UNOWN_COORDS[index] + if coords then + self:unownText(Unown.name(value) or "?", coords[1], coords[2]) + if index == slot + 1 then + self:unownCursor(coords[3], coords[4]) + end + end + end +end + +-- ------------------------------------------------------------- Unown font +-- +-- Everything UNOWN MODE prints -- the ring of letters AND the word under the +-- picture -- is written in the Unown font, not in the ordinary one. The +-- ring is `ld a, [hl] / add FIRST_UNOWN_CHAR - 1` (Pokedex_UpdateUnownMode), +-- and data/pokemon/unown_words.asm's own `unownword` macro spells each word +-- as `CHARVAL(...) - 'A' + FIRST_UNOWN_CHAR`, so a word is 26 letter tiles +-- too. A cache built before UnownFont was extracted has no sheet, and both +-- fall back to the inverted ordinary font, which is what this screen printed +-- before. + +-- One letter of the Unown font: 'A' is tile FIRST_UNOWN_CHAR. Anything that +-- is not a letter (the '?' a corrupt slot would print) has no tile at all and +-- goes through the ordinary font instead. +function PokedexMenu:unownGlyph(char, tx, ty) + local sheet = self.unownFont + if not (sheet and sheet:available()) then return false end + local index = Unown.index(char) + if not index then return false end + return sheet:draw((self.unownFontBase or 0x40) + index - 1, tx, ty) +end + +function PokedexMenu:unownText(str, tx, ty) + local text = tostring(str or "") + for i = 1, #text do + local char = text:sub(i, i) + if not self:unownGlyph(char, tx + i - 1, ty) then + self:text(char, tx + i - 1, ty) + end + end +end + +-- FIRST_UNOWN_CHAR + NUM_UNOWN, the diamond the ring is pointed at with: the +-- 27th tile of the sheet, which is why the font is one tile longer than the +-- alphabet. Without the sheet the shared cursor glyph stands in. +function PokedexMenu:unownCursor(tx, ty) + local sheet = self.unownFont + if sheet and sheet:available() + and sheet:draw((self.unownFontBase or 0x40) + Unown.NUM_UNOWN, tx, ty) then + return + end + self:text("\xe2\x96\xb6", tx, ty) +end + +function PokedexMenu:drawUnownPic(letter, tx, ty) + local path = Unown.formSprite(self.pokemon, letter, false) + if not path then return end + local cached = self.picCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + if not cached then return end + local colors = self.palettes + and Palettes.monColors(self.palettes, Unown.SPECIES) + local G = love.graphics + G.setColor(1, 1, 1, 1) + local function body() G.draw(cached, tx * 8, ty * 8) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- Pokedex_UpdateSearchScreen: four rows, and left/right walk the type wheel +-- on the two that carry one. +function PokedexMenu:updateSearch(input) + local row = self.searchIndex + if input:wasPressed("up") then + self.searchIndex = row > 1 and row - 1 or 4 + elseif input:wasPressed("down") then + self.searchIndex = row < 4 and row + 1 or 1 + elseif row <= 2 and (input:wasPressed("left") or input:wasPressed("right")) then + local delta = input:wasPressed("right") and 1 or -1 + local count = #PokedexMenu.SEARCH_TYPES + self.searchType[row] = (self.searchType[row] + delta) % (count + 1) + elseif input:wasPressed("b") or input:wasPressed("start") then + self.view = "list" + elseif input:wasPressed("a") then + if row <= 2 then + -- .MenuAction_MonSearchType: A steps the wheel too. + local count = #PokedexMenu.SEARCH_TYPES + self.searchType[row] = (self.searchType[row] + 1) % (count + 1) + elseif row == 3 then + self:beginSearch() + else + self.view = "list" + end + end +end + +function PokedexMenu:searchTypeName(slot) + local index = self.searchType[slot] or 0 + if index == 0 then return "-----" end + return PokedexMenu.SEARCH_TYPES[index] or "-----" +end + +-- Pokedex_SearchForMons: a mon matches when its two types cover both of the +-- wanted ones, in either order; "-----" matches anything. Only SEEN mon are +-- searched, which is what makes the count meaningful. +function PokedexMenu:beginSearch() + local want1 = self.searchType[1] ~= 0 and self:searchTypeName(1) or nil + local want2 = self.searchType[2] ~= 0 and self:searchTypeName(2) or nil + local results = {} + for _, entry in ipairs(self.rows) do + if entry.seen then + local def = self.pokemon and self.pokemon[entry.species] + local types = (def and def.types) or {} + local a, b = types[1], types[2] or types[1] + local ok = true + if want1 then ok = ok and (a == want1 or b == want1) end + if want2 then ok = ok and (a == want2 or b == want2) end + if ok then results[#results + 1] = entry end + end + end + self.searchResults = results + if #results == 0 then + -- .MenuAction_BeginSearch redraws the search screen and stays put when + -- nothing matched. + self.searchMessage = "No <PK><MN> found!" + return + end + self.searchMessage = nil + self.rows = results + self.index, self.scroll = 1, 0 + self:ensureVisible() + self.view = "list" +end + +-- Pokedex_DrawOptionScreenBG: two bordered boxes, the title on the top rule, +-- the mode list from (3,4) two rows apart and the description at (1,14). +function PokedexMenu:drawOption() + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + self:border(0, 2, 8, 18) + self:border(0, 12, 4, 18) + -- `db $3b, " OPTION ", $3c`: the two end-cap tiles are the dex sheet's, not + -- font glyphs. + self:tile(0x3b, 0, 1) + self:text(" OPTION ", 1, 1) + self:tile(0x3c, 9, 1) + local rows = self:optionRows() + for i, row in ipairs(rows) do + self:text(row.label, 3, 2 + i * 2) + if i == self.optionIndex then self:text("\xe2\x96\xb6", 2, 2 + i * 2) end + end + local current = rows[self.optionIndex] + if current then + self:text(current.lines[1], 1, 14) + self:text(current.lines[2], 1, 15) + end +end + +-- Pokedex_DrawSearchScreenBG: one tall box, TYPE1/TYPE2 at (3,4) and (3,6) +-- with their left/right arrows at column 8, and the two-row menu at (3,13). +function PokedexMenu:drawSearch() + self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + self:border(0, 2, 14, 18) + self:tile(0x3b, 0, 1) + self:text(" SEARCH ", 1, 1) + self:tile(0x3c, 9, 1) + self:text("TYPE1", 3, 4) + self:text("TYPE2", 3, 6) + self:text(self:searchTypeName(1), 10, 4) + self:text(self:searchTypeName(2), 10, 6) + -- `.TypeLeftRightArrows: db $3d, " ", $3e` -- two of the dex sheet's + -- own tiles at columns 8 and 17, not font glyphs. + for _, y in ipairs({ 4, 6 }) do + self:tile(0x3d, 8, y) + self:tile(0x3e, 17, y) + end + self:text("BEGIN SEARCH!!", 3, 13) + self:text("CANCEL", 3, 15) + if self.searchMessage then self:text(self.searchMessage, 3, 10) end + local rows = { 4, 6, 13, 15 } + local y = rows[self.searchIndex] or 4 + self:text("\xe2\x96\xb6", 2, y) +end + +function PokedexMenu:drawPanel() + if not self:styled() then + self:drawPlain() + love.graphics.setColor(1, 1, 1, 1) + return + end + local G = love.graphics + -- Every dex screen starts from a full-screen fill, so the frame under it + -- is the sheet's own background rather than the white a text box wants. + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + if self.view == "entry" then + self:drawEntry() + elseif self.view == "area" then + self:drawArea() + elseif self.view == "option" then + self:drawOption() + elseif self.view == "search" then + self:drawSearch() + elseif self.view == "unown" then + self:drawUnown() + else + self:drawList() + end + G.setColor(1, 1, 1, 1) +end + +function PokedexMenu:draw() + self:drawPanel() +end + +function PokedexMenu:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PokedexMenu.MODES = MODES +PokedexMenu.printNumString = printNumString + +return PokedexMenu diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua new file mode 100644 index 00000000..ccedc9ad --- /dev/null +++ b/src/ui/gen2/Pokegear.lua @@ -0,0 +1,2291 @@ +-- The POKeGEAR (engine/pokegear/pokegear.asm): Gen 2's clock, town map, radio +-- and phone in one device. +-- +-- Transcribed rather than laid out by eye. A Pokegear card is not a layout at +-- all -- it is a tilemap. InitPokegearTilemap fills the screen with $4f, runs +-- the card's own entry, and then Pokegear_FinishTilemap lays the card strip +-- across the top two rows. Three of the four cards are stored as RLE +-- tilemaps in the ROM (Pokegear_LoadTilemapRLE, whose comment has its own +-- format backwards: the first byte is the tile and the second the count) and +-- the MAP card is the painted region map, JohtoMap / KantoMap. +-- +-- Both sheets live in one 96-tile block: Pokegear_LoadGFX puts TownMapGFX at +-- vTiles2 $00 and PokegearGFX at $30, so a tilemap byte under $30 is town map +-- art and anything above it is gear chrome. Colour is by tile id rather than +-- by rectangle -- TownMapPals walks the tilemap and reads a nybble per tile +-- out of its PalMap, with $60 and up falling back to palette 0. +-- +-- Card strip (Pokegear_FinishTilemap): the two rows are cleared to $4f, then +-- each owned card's 2x2 icon is laid as n, n+1 / n+$10, n+$11 -- MAP at (2,0) +-- from $40, PHONE at (4,0) from $44, RADIO at (6,0) from $42, and the gear +-- itself at (0,0) from $46. + +local Chrome = require("src.ui.gen2.Chrome") +local Clock = require("src.core.gen2.Clock") +local Font = require("src.render.Font") +local Palettes = require("src.world.gen2.Palettes") +local Phone = require("src.core.gen2.Phone") +local SpriteRenderer = require("src.render.SpriteRenderer") +local TileSheet = require("src.ui.gen2.TileSheet") + +local Pokegear = {} +Pokegear.__index = Pokegear +Pokegear.isOpaque = true + +local SCREEN_W, SCREEN_H = 20, 18 +local BLANK_TILE = 0x4f + +-- POKEGEARCARD_* order (constants/pokegear_constants.asm: CLOCK, MAP, PHONE, +-- RADIO), with the icon each one contributes to the strip. The order is load +-- bearing twice over: paging steps this list, and every joypad routine walks +-- the cards in exactly this sequence (PokegearClock_Joypad right -> MAP else +-- PHONE else RADIO, PokegearRadio_Joypad left -> PHONE else MAP else CLOCK), +-- while AnimatePokegearModeIndicatorArrow indexes its $00/$10/$20/$30 x offsets +-- by wPokegearCard. Listing RADIO before PHONE made the arrow jump column 2 -> +-- 6 -> 4. +local CARDS = { + { id = "clock", label = "CLOCK", icon = 0x46, iconX = 0 }, + { id = "map", label = "MAP", flag = "map", icon = 0x40, iconX = 2 }, + { id = "phone", label = "PHONE", flag = "phone", icon = 0x44, iconX = 4 }, + { id = "radio", label = "RADIO", flag = "radio", icon = 0x42, iconX = 6 }, +} + +-- _FlyMap draws the SAME town map, but it is not the MAP card: it is its own +-- screen (LoadTownMapGFX / FlyMap / TownMapBubble), with no card strip and no +-- ENGINE_MAP_CARD gate, which is why FLY works before the Guide Gent hands the +-- card over. One row, so `#self.cards` stays 1 and nothing pages. +local FLY_MAP_CARD = { id = "map", label = "FLY" } + +local DAYS = { + "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", +} + +-- ---------------------------------------------------------------- the radio +-- +-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code. +-- Every station is a little state machine whose segments each print ONE line +-- and name the segment that prints the next, so the only way to get the shows +-- right is to port the shows. What follows transcribes RadioJumptable row by +-- row; the segment names are the constants/radio_constants.asm ones so a row +-- here can be read against the routine it came from. +-- +-- Three pieces of RAM drive all of it (ram/wram.asm): +-- wCurRadioLine which segment runs this frame +-- wNextRadioLine which segment RADIO_SCROLL hands control to +-- wRadioTextDelay frames left before RADIO_SCROLL proceeds +-- wNumRadioLinesPrinted 0, 1 or 2 -- how much of the box is filled yet +-- and the display is the bottom text box's two lines, which +-- CopyBottomLineToTopLine scrolls up as each new line lands. + +-- Channel ids. The first ten are stations (NUM_RADIO_CHANNELS); everything +-- above is an internal segment. The numbers matter in exactly one place -- +-- PlayRadioShow's `cp POKE_FLUTE_RADIO` Rocket override -- so they are kept. +local RADIO_ID = { + OAKS_POKEMON_TALK = 0x00, POKEDEX_SHOW = 0x01, POKEMON_MUSIC = 0x02, + LUCKY_CHANNEL = 0x03, PLACES_AND_PEOPLE = 0x04, LETS_ALL_SING = 0x05, + ROCKET_RADIO = 0x06, POKE_FLUTE_RADIO = 0x07, UNOWN_RADIO = 0x08, + EVOLUTION_RADIO = 0x09, +} + +-- data/radio/channel_music.asm, in the port's own song labels: the port names +-- songs the pokegold way (Music_*), not by the MUSIC_* constant. +local RADIO_CHANNEL_SONGS = { + OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", -- MUSIC_POKEMON_TALK + POKEDEX_SHOW = "Music_PokemonCenter", -- MUSIC_POKEMON_CENTER + POKEMON_MUSIC = "Music_TitleScreen", -- MUSIC_TITLE + LUCKY_CHANNEL = "Music_GameCorner", -- MUSIC_GAME_CORNER + PLACES_AND_PEOPLE = "Music_ViridianCity", -- MUSIC_VIRIDIAN_CITY + LETS_ALL_SING = "Music_Bicycle", -- MUSIC_BICYCLE + ROCKET_RADIO = "Music_RocketTheme", -- MUSIC_ROCKET_OVERTURE + POKE_FLUTE_RADIO = "Music_PokeFluteChannel", + UNOWN_RADIO = "Music_RuinsOfAlphRadio", + EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio", +} + +-- The station names LoadStation_* hands the tuner (the *Name labels at the +-- bottom of pokegear.asm). `#` is the four-tile POKé compression byte, so +-- spelling it out costs the same tiles; `<PKMN>` is NOT -- it is the two-tile +-- <PK><MN> ligature the font already carries, so it stays a ligature. +-- LoadStation_RocketRadio really does reuse LetsAllSingName, and +-- LoadStation_EvolutionRadio really does reuse UnownStationName. +local STATION_NAMES = { + OAKS_POKEMON_TALK = "OAK's <PK><MN> Talk", + POKEDEX_SHOW = "POKéDEX Show", + POKEMON_MUSIC = "POKéMON Music", + LUCKY_CHANNEL = "Lucky Channel", + PLACES_AND_PEOPLE = "Places & People", + LETS_ALL_SING = "Let's All Sing!", + ROCKET_RADIO = "Let's All Sing!", + POKE_FLUTE_RADIO = "POKé FLUTE", + UNOWN_RADIO = "?????", + EVOLUTION_RADIO = "?????", +} + +-- OaksPKMNTalk8.Adverbs, in table order: `maskbits 16` makes every roll valid, +-- which is why there is no retry loop around it. +local OPT_ADVERBS = { + "sweet and adorably", "wiggly and slickly", "aptly named and", + "undeniably kind of", "so, so unbearably", "wow, impressively", + "almost poisonously", "ooh, so sensually", "so mischievously", + "so very topically", "sure addictively", "looks in water is", + "evolution must be", "provocatively", "so flipped out and", + "heart-meltingly", +} + +-- OaksPKMNTalk9.Adjectives. +local OPT_ADJECTIVES = { + "cute.", "weird.", "pleasant.", "bold, sort of.", "frightening.", + "suave & debonair!", "powerful.", "exciting.", "now!", "inspiring.", + "friendly.", "hot, hot, hot!", "stimulating.", "guarded.", "lovely.", + "speedy.", +} + +-- PeoplePlaces5.Adjectives and PeoplePlaces7.Adjectives are the same sixteen +-- rows in the same order, so one table serves both. +local PNP_ADJECTIVES = { + "is cute.", "is sort of lazy.", "is always happy.", "is quite noisy.", + "is precocious.", "is somewhat bold.", "is too picky!", "is sort of OK.", + "is just so-so.", "is actually great.", "is just my type.", + "is so cool, no?", "is inspiring!", "is kind of weird.", + "is right for me?", "is definitely odd!", +} + +-- RocketRadioText1..10. The text_pause bytes inside 7-10 only stall the +-- printer, so the port carries the words either side of them as one line. +local ROCKET_LINES = { + "… …Ahem, we are", "TEAM ROCKET!", "After three years", + "of preparation, we", "have risen again", "from the ashes!", + "GIOVANNI! Can you", "hear? We did it!", "Where is our Boss?", + "Is he listening?", +} + +-- data/radio/oaks_pkmn_talk_routes.asm: the fifteen maps Oak's Pokemon Talk +-- draws its wild mon from. `and %11111` then `cp 15` is a rejection roll, so +-- a byte above 14 is thrown away rather than folded. +local OPT_ROUTES = { + "ROUTE_29", "ROUTE_46", "ROUTE_30", "ROUTE_32", "ROUTE_34", "ROUTE_35", + "ROUTE_37", "ROUTE_38", "ROUTE_39", "ROUTE_42", "ROUTE_43", "ROUTE_44", + "ROUTE_45", "ROUTE_36", "ROUTE_31", +} + +-- data/radio/pnp_places.asm. Two of the nine are interiors picked purely for +-- the landmark they sit in (the Cerulean police station stands in for +-- CERULEAN CITY, the beta Cinnabar centre for CINNABAR ISLAND). +local PNP_PLACES = { + "PALLET_TOWN", "ROUTE_22", "PEWTER_CITY", "CERULEAN_POLICE_STATION", + "ROUTE_12", "ROUTE_11", "ROUTE_16", "ROUTE_14", + "CINNABAR_POKECENTER_2F_BETA", +} + +-- data/radio/pnp_hidden_people.asm is one list with two interior labels, and +-- the fallthrough is load bearing: PnP_HiddenPeople runs into +-- PnP_HiddenPeople_BeatE4, which runs into PnP_HiddenPeople_BeatKanto. So +-- beating the Elite Four un-hides the E4 themselves, and sweeping Kanto's +-- badges un-hides its gym leaders too. +local PNP_HIDDEN = { + "WILL", "BRUNO", "KAREN", "KOGA", "CHAMPION", + -- PnP_HiddenPeople_BeatE4 + "BROCK", "MISTY", "LT_SURGE", "ERIKA", "JANINE", "SABRINA", "BLAINE", + "BLUE", + -- PnP_HiddenPeople_BeatKanto + "RIVAL1", "POKEMON_PROF", "CAL", "RIVAL2", "RED", +} +local PNP_HIDDEN_BEAT_E4 = 6 -- first index of PnP_HiddenPeople_BeatE4 +local PNP_HIDDEN_BEAT_KANTO = 14 -- first index of PnP_HiddenPeople_BeatKanto + +-- TextCommand_DAY's .Days table, plus its "DAY" suffix. GetWeekday counts +-- from Sunday = 0, which is NOT os.date's 1-based wday. +local RADIO_DAYS = { + [0] = "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", + "SATURDAY", +} + +-- macros/data.asm: `percent` is `* $ff / 100`, so these are the two literal +-- thresholds PeoplePlaces rolls against. +local PNP_PEOPLE_CHANCE = math.floor(49 * 255 / 100) - 1 -- 49 percent - 1 +local PNP_RESTART_CHANCE = math.floor(4 * 255 / 100) -- 4 percent + +-- A landmark name is stored with its town-map line break in it +-- ("BLACKTHORN\nCITY"). GetLandmarkName hands that to the radio verbatim, +-- break byte and all, which on hardware throws the rest of the sentence onto +-- the box's second line; the port spends the break as a space instead so the +-- sentence stays one radio line. +local function flatName(name) + return (tostring(name or ""):gsub("\n", " ")) +end + +-- An hlcoord is a column of TILES, and the port stores its strings as UTF-8, +-- so "é" is two bytes and one tile. Counting lead bytes gets the column +-- arithmetic right; it is only ever used on plain text, never on the <PK><MN> +-- ligature markers Font.split expands. +local function tileWidth(text) + local width = 0 + for _ in tostring(text or ""):gmatch("[^\128-\191]") do width = width + 1 end + return width +end + +-- --------------------------------------------------------- the show machine +-- +-- Radio is deliberately free of love, of Game and of drawing: it is the +-- jumptable and its four bytes of RAM, stepped one frame at a time, so a test +-- can seed `rng` and assert the exact line sequence a station produces. +-- +-- `data` is the cache read the shows need, gathered by Pokegear:radioData(): +-- landmarks[index] = { name } GetLandmarkName +-- mapLandmark[map] = landmark index GetWorldMapLocation +-- grass[map] = { [0]=morn, [1]=day, [2]=nite } lists of species +-- species[index] = name GetPokemonName +-- dex[name] = { kind, lines } the Pokedex entry, split at <NEXT> +-- classes[index] = { name, trainer } GetTrainerClassName / GetTrainerName +-- hidden[index] = true the resolved PnP_HiddenPeople list +-- caught(name) -> boolean CheckCaughtMon +-- weekday = 0..6 GetWeekday +-- luckyNumber = wLuckyIDNumber +local Radio = {} +Radio.__index = Radio + +-- PlaceRadioString and PrintRadioLine both set wRadioTextDelay to 100; the +-- Pokemon Channel jingle's last hop uses 10. +local RADIO_LINE_FRAMES = 100 +local RADIO_JINGLE_FRAMES = 10 + +local RadioJumptable = {} + +function Radio.new(opts) + opts = opts or {} + local self = setmetatable({}, Radio) + self.data = opts.data or {} + -- `call Random` yields one byte. Without a supplied source the port falls + -- back to math.random, which is fine for play and useless for a test. + self.rng = opts.rng or function() return math.random(0, 255) end + -- wCurRadioLine / wNextRadioLine / wRadioTextDelay / wNumRadioLinesPrinted. + self.cur = nil + self.next = nil + self.delay = 0 + self.printed = 0 + -- The bottom text box's two lines. + self.top, self.bottom = "", "" + -- Every line this station has printed, oldest first: the scroll throws the + -- top one away, and a test wants the whole sequence. + self.log = {} + -- wCurPartySpecies, wStringBuffer1/2 and wOaksPKMNTalkSegmentCounter, which + -- survive across segments because the shows read them a segment later. + self.vars = {} + -- The label RadioMusicRestartDE was last asked for, so the caller can play + -- it without the machine knowing what an audio system is. + self.music = nil + return self +end + +-- `call Random`: one byte, 0..255. +function Radio:random() + return math.floor(self.rng() or 0) % 256 +end + +-- Half the shows sample by rejection: roll a byte, throw it away if it is out +-- of range, roll again. On hardware those loops are unbounded because +-- `call Random` never repeats itself; the port cannot promise that of an +-- injected roll source, so every sampler is capped. A sampler that runs out +-- of tries leaves its segment without printing, and the next frame retries the +-- whole segment -- which is what the cart would do with a stuck RNG anyway. +local RADIO_SAMPLE_TRIES = 512 + +function Radio:sample(pick) + for _ = 1, RADIO_SAMPLE_TRIES do + local value = pick(self:random()) + if value ~= nil then return value end + end + return nil +end + +-- LoadStation_*: every one of them parks the station id in wCurRadioLine and +-- zeroes wNumRadioLinesPrinted, which is what makes StartRadioStation fire +-- once and once only. +function Radio:tune(station) + self.cur = station + self.next = nil + self.delay = 0 + self.printed = 0 + self.top, self.bottom = "", "" + self.log = {} + self.vars = {} + self.music = nil +end + +-- StartRadioStation: on the first frame of a station only, clear the box and +-- start RadioChannelSongs[wCurRadioLine]. +function Radio:startStation() + if self.printed ~= 0 then return end + self.top, self.bottom = "", "" + self.music = RADIO_CHANNEL_SONGS[self.cur] +end + +-- PrintRadioLine. The first line lands on the box's top row, the second on +-- its bottom row (`bccoord 1, 16`), and every line after that on the bottom +-- row with RADIO_SCROLL having moved the previous one up first. +function Radio:printLine(text, nextLine) + self.next = nextLine + -- wRadioText itself: the buffer keeps the last line composed into it, which + -- is what OaksPKMNTalk4's .overflow branch reprints. + self.text = text + if self.printed < 2 then + self.printed = self.printed + 1 + if self.printed == 1 then self.top = text else self.bottom = text end + else + self.bottom = text + end + self.log[#self.log + 1] = text + self.cur = "RADIO_SCROLL" + self.delay = RADIO_LINE_FRAMES +end + +-- NextRadioLine is PrintRadioLine with a CopyRadioTextToRAM in front of it. +-- The copy is invisible here (the port passes the composed line straight in), +-- but the two are kept apart because several segments compose the text a +-- segment early and then reach PrintRadioLine directly. +Radio.nextLine = Radio.printLine + +-- PlaceRadioString: no scroll, no line counter -- it stamps a string into the +-- box where it stands and waits 100 frames. Only the Pokemon Channel jingle +-- uses it, which is why the jingle does not scroll. +function Radio:placeString(nextLine) + self.cur = nextLine + self.delay = RADIO_LINE_FRAMES +end + +-- One frame of PlayRadioShow. +function Radio:step() + -- Team Rocket broadcasts on every station: a station id below + -- POKE_FLUTE_RADIO, in Johto, with the tower occupied, is overwritten. The + -- comparison is against the id, so mid-show segments (all $0a and up) never + -- trigger it -- the takeover can only happen between shows. + local id = RADIO_ID[self.cur] + if id and id < RADIO_ID.POKE_FLUTE_RADIO + and self.data.rocketsInRadioTower and self.data.inJohto then + self.cur = "ROCKET_RADIO" + end + local segment = RadioJumptable[self.cur] + if segment then segment(self) end +end + +-- RadioScroll: burn the delay, then hand over to wNextRadioLine. The +-- `cp 1` skips the copy for the very first line, which is still sitting on +-- the top row and has nothing under it to scroll up. +RadioJumptable["RADIO_SCROLL"] = function(R) + if R.delay ~= 0 then + R.delay = R.delay - 1 + return + end + R.cur = R.next + if R.printed ~= 1 then R.top = R.bottom end + R.bottom = "" +end + +-- ----------------------------------------------------- Oak's Pokemon Talk +-- +-- Five wild-mon segments (wOaksPKMNTalkSegmentCounter) and then the Pokemon +-- Channel jingle, which resets the counter and drops back into the fourth +-- segment for another five. + +RadioJumptable["OAKS_POKEMON_TALK"] = function(R) + R.vars.segmentCounter = 5 + R:startStation() + R:nextLine("MARY: PROF.OAK'S", "OAKS_POKEMON_TALK_2") +end + +RadioJumptable["OAKS_POKEMON_TALK_2"] = function(R) + R:nextLine("POKéMON TALK!", "OAKS_POKEMON_TALK_3") +end + +RadioJumptable["OAKS_POKEMON_TALK_3"] = function(R) + R:nextLine("With me, MARY!", "OAKS_POKEMON_TALK_4") +end + +-- OaksPKMNTalk4: roll a route, roll a time of day, roll one of the middle +-- three grass slots, and name the mon and the route it walks. +RadioJumptable["OAKS_POKEMON_TALK_4"] = function(R) + -- .sample: `and %11111` then `cp 15`, so a byte above the table's length is + -- rerolled rather than wrapped. + local map = R:sample(function(roll) + roll = roll % 32 + if roll < #OPT_ROUTES then return OPT_ROUTES[roll + 1] end + return nil + end) + if not map then return end + local slots = R.data.grass and R.data.grass[map] + if not slots then + -- .overflow: a map with no row in JohtoGrassWildMons restarts the show + -- from its intro, reprinting whatever wRadioText still holds. + return R:printLine(R.text or "", "OAKS_POKEMON_TALK") + end + -- .loop2: `maskbits NUM_DAYTIMES` then reject DARKNESS_F, so 0, 1 or 2. + local daytime = R:sample(function(roll) + roll = roll % 4 + return roll ~= 3 and roll or nil + end) + -- .loop3: `maskbits NUM_GRASSMON` then reject below 2 and 5 or above, which + -- leaves the middle three of the seven slots. + local slot = R:sample(function(roll) + roll = roll % 8 + return (roll >= 2 and roll < 5) and roll or nil + end) + if not (daytime and slot) then return end + local list = slots[daytime] or slots[0] or {} + local species = list[slot + 1] + R.vars.species = species + R.vars.landmark = R.data.mapLandmark and R.data.mapLandmark[map] + -- _OPT_OakText1 is "OAK: @" plus the mon name, with no punctuation: the + -- sentence is finished by the next two segments. + R:printLine("OAK: " .. tostring(species or ""), "OAKS_POKEMON_TALK_5") +end + +RadioJumptable["OAKS_POKEMON_TALK_5"] = function(R) + R:nextLine("may be seen around", "OAKS_POKEMON_TALK_6") +end + +RadioJumptable["OAKS_POKEMON_TALK_6"] = function(R) + -- _OPT_OakText3 is the landmark name with a full stop welded on. + local entry = R.data.landmarks and R.data.landmarks[R.vars.landmark] + R:nextLine(flatName(entry and entry.name) .. ".", "OAKS_POKEMON_TALK_7") +end + +RadioJumptable["OAKS_POKEMON_TALK_7"] = function(R) + R:nextLine("MARY: " .. tostring(R.vars.species or "") .. "'s", + "OAKS_POKEMON_TALK_8") +end + +RadioJumptable["OAKS_POKEMON_TALK_8"] = function(R) + local adverb = OPT_ADVERBS[R:random() % 16 + 1] + R:nextLine(adverb, "OAKS_POKEMON_TALK_9") +end + +-- OaksPKMNTalk9 rolls the adjective FIRST and only then spends the segment +-- counter, so the roll happens on the fifth pass too. +RadioJumptable["OAKS_POKEMON_TALK_9"] = function(R) + local adjective = OPT_ADJECTIVES[R:random() % 16 + 1] + R.vars.segmentCounter = (R.vars.segmentCounter or 1) - 1 + local nextLine = "OAKS_POKEMON_TALK_4" + if R.vars.segmentCounter == 0 then + R.vars.segmentCounter = 5 + nextLine = "OAKS_POKEMON_TALK_10" + end + R:nextLine(adjective, nextLine) +end + +-- The Pokemon Channel jingle. OaksPKMNTalk10 calls PrintText rather than +-- PrintRadioLine, so it bypasses the scroll entirely: the box is redrawn with +-- "POKéMON" on its top row and the three PlaceRadioString hops then stamp the +-- rest in over 100 frames each. +RadioJumptable["OAKS_POKEMON_TALK_10"] = function(R) + R.music = "Music_PokemonChannel" -- RadioMusicRestartPokemonChannel + R.top, R.bottom = "POKéMON", "" + R.log[#R.log + 1] = R.top + R.cur = "OAKS_POKEMON_TALK_11" + R.delay = RADIO_LINE_FRAMES +end + +-- `hlcoord 9, 14` is the top row, column 9; the box's text starts at column 1, +-- so the stamp lands eight cells in from what is already there. +RadioJumptable["OAKS_POKEMON_TALK_11"] = function(R) + R.delay = R.delay - 1 + if R.delay ~= 0 then return end + R.top = R.top .. string.rep(" ", math.max(0, 8 - tileWidth(R.top))) .. "POKéMON" + R.log[#R.log + 1] = R.top + R:placeString("OAKS_POKEMON_TALK_12") +end + +RadioJumptable["OAKS_POKEMON_TALK_12"] = function(R) + R.delay = R.delay - 1 + if R.delay ~= 0 then return end + R.bottom = "POKéMON Channel" -- hlcoord 1, 16 + R.log[#R.log + 1] = R.bottom + R:placeString("OAKS_POKEMON_TALK_13") +end + +-- `hlcoord 12, 16` with a bare "@": nothing is drawn, the hop only buys +-- another 100 frames of the jingle. +RadioJumptable["OAKS_POKEMON_TALK_13"] = function(R) + R.delay = R.delay - 1 + if R.delay ~= 0 then return end + R:placeString("OAKS_POKEMON_TALK_14") +end + +-- Back to the talk: the music restarts, PrintText with a bare terminator wipes +-- the box, and wNumRadioLinesPrinted goes back to zero so the next two lines +-- fill the box from the top again. +RadioJumptable["OAKS_POKEMON_TALK_14"] = function(R) + R.delay = R.delay - 1 + if R.delay ~= 0 then return end + R.music = "Music_ProfOaksPokemonTalk" -- MUSIC_POKEMON_TALK + R.top, R.bottom = "", "" + R.next = "OAKS_POKEMON_TALK_4" + R.printed = 0 + R.cur = "RADIO_SCROLL" + R.delay = RADIO_JINGLE_FRAMES +end + +-- ------------------------------------------------------------ Pokedex Show +-- +-- One caught species, then its Pokedex entry read out a line at a time: the +-- kind ("TINY BIRD"), then the six description lines, then a new species. + +RadioJumptable["POKEDEX_SHOW"] = function(R) + R:startStation() + -- `cp NUM_POKEMON` rejects a byte of 251 or more, then CheckCaughtMon + -- rejects anything the player has not caught. The index is still zero-based + -- there; the `inc c` after the loop is what makes it a species number. A + -- Pokedex with nothing in it spins this loop forever on hardware, which is + -- why the port's sampler is the capped one. + local species = R:sample(function(roll) + if roll >= 251 then return nil end + local name = R.data.species and R.data.species[roll + 1] + if not name then return nil end + if R.data.caught and not R.data.caught(name) then return nil end + return name + end) + if not species then return end + R.vars.species = species + R:nextLine(species, "POKEDEX_SHOW_2") +end + +-- PokedexShow2 prints from the entry's own start, which is the species kind, +-- and then steps the read pointer past the height/weight words. +RadioJumptable["POKEDEX_SHOW_2"] = function(R) + local entry = R.data.dex and R.data.dex[R.vars.species] + R:printLine(entry and entry.kind or "", "POKEDEX_SHOW_3") +end + +-- PokedexShow3..8 are the same routine six times over: copy the next line of +-- the entry, print it, name the next segment. The eighth hands back to +-- POKEDEX_SHOW, which rolls a fresh species. +local POKEDEX_SHOW_SEGMENTS = { + "POKEDEX_SHOW_3", "POKEDEX_SHOW_4", "POKEDEX_SHOW_5", "POKEDEX_SHOW_6", + "POKEDEX_SHOW_7", "POKEDEX_SHOW_8", +} +for index, segment in ipairs(POKEDEX_SHOW_SEGMENTS) do + RadioJumptable[segment] = function(R) + local entry = R.data.dex and R.data.dex[R.vars.species] + local lines = entry and entry.lines or {} + R:printLine(lines[index] or "", + POKEDEX_SHOW_SEGMENTS[index + 1] or "POKEDEX_SHOW") + end +end + +-- --------------------------------------- Pokemon Music / Let's All Sing +-- +-- Two stations that meet: BenMonMusic is Johto's, FernMonMusic is Kanto's, and +-- LETS_ALL_SING_2 jumps straight into POKEMON_MUSIC_4 so both DJs read the +-- same three closing lines. + +-- StartPokemonMusicChannel picks the song off the weekday's low bit, and +-- BenFernMusic5/6 read the same bit again for the words that go with it. +local function startPokemonMusicChannel(R) + R.top, R.bottom = "", "" + local odd = (R.data.weekday or 0) % 2 == 1 + R.music = odd and "Music_PokemonLullaby" or "Music_PokemonMarch" +end + +RadioJumptable["POKEMON_MUSIC"] = function(R) + startPokemonMusicChannel(R) + R:nextLine("BEN: POKéMON MUSIC", "POKEMON_MUSIC_2") +end + +RadioJumptable["POKEMON_MUSIC_2"] = function(R) + R:nextLine("CHANNEL!", "POKEMON_MUSIC_3") +end + +RadioJumptable["POKEMON_MUSIC_3"] = function(R) + R:nextLine("It's me, DJ BEN!", "POKEMON_MUSIC_4") +end + +RadioJumptable["LETS_ALL_SING"] = function(R) + startPokemonMusicChannel(R) + R:nextLine("FERN: POKéMUSIC!", "LETS_ALL_SING_2") +end + +-- FernMonMusic2 names POKEMON_MUSIC_4, not a LETS_ALL_SING segment: this is +-- the handoff, and from here Kanto's station is running Johto's code. +RadioJumptable["LETS_ALL_SING_2"] = function(R) + R:nextLine("With DJ FERN!", "POKEMON_MUSIC_4") +end + +RadioJumptable["POKEMON_MUSIC_4"] = function(R) + local day = RADIO_DAYS[(R.data.weekday or 0) % 7] or "" + R:nextLine("Today's " .. day .. ",", "POKEMON_MUSIC_5") +end + +RadioJumptable["POKEMON_MUSIC_5"] = function(R) + local odd = (R.data.weekday or 0) % 2 == 1 + R:nextLine(odd and "so chill out to" or "so let us jam to", + "POKEMON_MUSIC_6") +end + +RadioJumptable["POKEMON_MUSIC_6"] = function(R) + local odd = (R.data.weekday or 0) % 2 == 1 + R:nextLine(odd and "POKéMON Lullaby!" or "POKéMON March!", "POKEMON_MUSIC_7") +end + +-- BenFernMusic7 is a bare `ret`. Both music stations really do stop talking +-- here and play out, and nothing ever leaves this segment. +RadioJumptable["POKEMON_MUSIC_7"] = function() end + +-- ------------------------------------------------------- Lucky Number Show +-- +-- Eleven fixed lines, the week's number read out twice, and a 1-in-256 chance +-- of REED admitting he is bored before he starts over. + +local LUCKY_LINES = { + LUCKY_CHANNEL = { "REED: Yeehaw! How", "LUCKY_NUMBER_SHOW_2" }, + LUCKY_NUMBER_SHOW_2 = { "y'all doin' now?", "LUCKY_NUMBER_SHOW_3" }, + LUCKY_NUMBER_SHOW_3 = { "Whether you're up", "LUCKY_NUMBER_SHOW_4" }, + LUCKY_NUMBER_SHOW_4 = { "or way down low,", "LUCKY_NUMBER_SHOW_5" }, + LUCKY_NUMBER_SHOW_5 = { "don't you miss the", "LUCKY_NUMBER_SHOW_6" }, + LUCKY_NUMBER_SHOW_6 = { "LUCKY NUMBER SHOW!", "LUCKY_NUMBER_SHOW_7" }, + LUCKY_NUMBER_SHOW_7 = { "This week's Lucky", "LUCKY_NUMBER_SHOW_8" }, + LUCKY_NUMBER_SHOW_9 = { "I'll repeat that!", "LUCKY_NUMBER_SHOW_10" }, + -- LC_Text7 and LC_Text8 again: REED reads the number out a second time. + LUCKY_NUMBER_SHOW_10 = { "This week's Lucky", "LUCKY_NUMBER_SHOW_11" }, + LUCKY_NUMBER_SHOW_12 = { "Match it and go to", "LUCKY_NUMBER_SHOW_13" }, + LUCKY_NUMBER_SHOW_14 = { "…Repeating myself", "LUCKY_NUMBER_SHOW_15" }, + LUCKY_NUMBER_SHOW_15 = { "gets to be a drag…", "LUCKY_CHANNEL" }, +} +for segment, row in pairs(LUCKY_LINES) do + RadioJumptable[segment] = function(R) + if segment == "LUCKY_CHANNEL" then R:startStation() end + R:nextLine(row[1], row[2]) + end +end + +-- LuckyNumberShow8 prints wLuckyIDNumber with PRINTNUM_LEADINGZEROS over five +-- digits, so a low number reads as "00042". +local function luckyNumberLine(R) + return ("Number is %05d!"):format(math.floor(R.data.luckyNumber or 0) % 100000) +end + +RadioJumptable["LUCKY_NUMBER_SHOW_8"] = function(R) + R:nextLine(luckyNumberLine(R), "LUCKY_NUMBER_SHOW_9") +end + +RadioJumptable["LUCKY_NUMBER_SHOW_11"] = function(R) + R:nextLine(luckyNumberLine(R), "LUCKY_NUMBER_SHOW_12") +end + +-- `call Random / and a`: only a rolled zero takes the drag lines, so REED +-- complains about once every 256 times round. +RadioJumptable["LUCKY_NUMBER_SHOW_13"] = function(R) + local roll = R:random() + R:nextLine("the RADIO TOWER!", + roll ~= 0 and "LUCKY_CHANNEL" or "LUCKY_NUMBER_SHOW_14") +end + +-- ------------------------------------------------------- Places and People +-- +-- DJ LILY alternates between a trainer ("People") and a landmark ("Places"), +-- with a 4% chance after each of restarting the show from its own intro. + +RadioJumptable["PLACES_AND_PEOPLE"] = function(R) + R:startStation() + R:nextLine("PLACES AND PEOPLE!", "PLACES_AND_PEOPLE_2") +end + +RadioJumptable["PLACES_AND_PEOPLE_2"] = function(R) + R:nextLine("Brought to you by", "PLACES_AND_PEOPLE_3") +end + +-- `cp 49 percent - 1` with `jr c` taking People, so the split is 123/256 to +-- People and the rest to Places. +local function peopleOrPlaces(R) + return R:random() < PNP_PEOPLE_CHANCE and "PLACES_AND_PEOPLE_4" + or "PLACES_AND_PEOPLE_6" +end + +RadioJumptable["PLACES_AND_PEOPLE_3"] = function(R) + R:nextLine("me, DJ LILY!", peopleOrPlaces(R)) +end + +-- PeoplePlaces4: roll a trainer class, reject the ones the hidden list is +-- covering this playthrough, and name its first trainer. +RadioJumptable["PLACES_AND_PEOPLE_4"] = function(R) + local classes = R.data.classes or {} + local hidden = R.data.hidden or {} + -- `maskbits NUM_TRAINER_CLASSES` is a 128 mask, `inc a` makes it one-based, + -- and `cp NUM_TRAINER_CLASSES + 1` throws away anything past the sixty-six + -- real classes. IsInArray against PnP_HiddenPeople is a third rejection, + -- not a skip: the roll is spent and another one is taken. + local index = R:sample(function(roll) + roll = roll % 128 + 1 + if roll > 66 or hidden[roll] or not classes[roll] then return nil end + return roll + end) + local class = index and classes[index] + if not class then return end + R.vars.class = class.name + R.vars.trainer = class.trainer + R.vars.classIndex = index + -- _PnP_Text4 is the class name and the trainer name with one space between. + R:nextLine(tostring(class.name or "") .. " " .. tostring(class.trainer or ""), + "PLACES_AND_PEOPLE_5") +end + +-- PeoplePlaces5 rolls the adjective, then a 4% restart, then the People/Places +-- coin again. Three rolls, always in that order. +RadioJumptable["PLACES_AND_PEOPLE_5"] = function(R) + local adjective = PNP_ADJECTIVES[R:random() % 16 + 1] + local nextLine = "PLACES_AND_PEOPLE" + if R:random() >= PNP_RESTART_CHANCE then nextLine = peopleOrPlaces(R) end + R:nextLine(adjective, nextLine) +end + +-- PeoplePlaces6: roll one of the nine PnP_Places maps and name its landmark. +RadioJumptable["PLACES_AND_PEOPLE_6"] = function(R) + -- `cp (PnP_Places.End - PnP_Places) / 2`: a byte past the nine rows is + -- rerolled, not folded. + local map = R:sample(function(roll) + if roll < #PNP_PLACES then return PNP_PLACES[roll + 1] end + return nil + end) + if not map then return end + local index = R.data.mapLandmark and R.data.mapLandmark[map] + local entry = R.data.landmarks and R.data.landmarks[index] + R.vars.landmark = index + R:nextLine(flatName(entry and entry.name), "PLACES_AND_PEOPLE_7") +end + +-- PeoplePlaces7 is PeoplePlaces5 with the same three rolls in the same order. +RadioJumptable["PLACES_AND_PEOPLE_7"] = function(R) + local adjective = PNP_ADJECTIVES[R:random() % 16 + 1] + local nextLine = "PLACES_AND_PEOPLE" + if R:random() >= PNP_RESTART_CHANCE then nextLine = peopleOrPlaces(R) end + R:printLine(adjective, nextLine) +end + +-- ------------------------------------------------------------ Rocket Radio +-- +-- Ten fixed lines on a loop. Nothing rolls, nothing branches. + +RadioJumptable["ROCKET_RADIO"] = function(R) + R:startStation() + R:nextLine(ROCKET_LINES[1], "ROCKET_RADIO_2") +end +for index = 2, 10 do + RadioJumptable["ROCKET_RADIO_" .. index] = function(R) + R:nextLine(ROCKET_LINES[index], + index < 10 and ("ROCKET_RADIO_" .. (index + 1)) or "ROCKET_RADIO") + end +end + +-- ------------------------------------------------- the three music stations +-- +-- PokeFluteRadio, UnownRadio and EvolutionRadio start their song, set +-- wNumRadioLinesPrinted to 1 so StartRadioStation never fires again, and +-- return. They print nothing at all, ever: the box stays empty. +for _, station in ipairs({ "POKE_FLUTE_RADIO", "UNOWN_RADIO", + "EVOLUTION_RADIO" }) do + RadioJumptable[station] = function(R) + R:startStation() + R.printed = 1 + end +end + +-- ------------------------------------------------------------- the tuner +-- +-- RadioChannels (engine/pokegear/pokegear.asm) is the dial: a tuning-knob +-- value and the routine that decides whether anything is on it. The comment +-- there gives the arithmetic: frequency value = 4 x ingame frequency - 2, so +-- knob 16 is the 04.5 mark. A knob position whose test fails is not a station +-- at all -- NoRadioStation wipes the name and the box and plays nothing. +-- +-- `signal(ctx)` returns the station to load, or nil for dead air. ctx carries +-- inJohto, timeOfDay, landmark (the player's, not the cursor's), expnCard and +-- rocketSignal. +local RADIO_CHANNELS = { + -- .PKMNTalkAndPokedexShow: the Pokedex Show airs in the morning and Oak's + -- Pokemon Talk the rest of the day, off the same frequency. + { knob = 16, frequency = "04.5", signal = function(ctx) + if not ctx.inJohto then return nil end + if (ctx.timeOfDay or 0) == 0 then return "POKEDEX_SHOW" end + return "OAKS_POKEMON_TALK" + end }, + { knob = 28, frequency = "07.5", signal = function(ctx) + return ctx.inJohto and "POKEMON_MUSIC" or nil + end }, + { knob = 32, frequency = "08.5", signal = function(ctx) + return ctx.inJohto and "LUCKY_CHANNEL" or nil + end }, + -- .RuinsOfAlphRadio is a one-landmark station: the static only resolves + -- standing in the Ruins of Alph themselves. + { knob = 52, frequency = "13.5", signal = function(ctx) + return ctx.landmark == "LANDMARK_RUINS_OF_ALPH" and "UNOWN_RADIO" or nil + end }, + { knob = 64, frequency = "16.5", signal = function(ctx) + return (not ctx.inJohto) and "PLACES_AND_PEOPLE" or nil + end }, + { knob = 72, frequency = "18.5", signal = function(ctx) + return (not ctx.inJohto) and "LETS_ALL_SING" or nil + end }, + -- .PokeFluteRadio also wants the EXPN card, which is the Kanto radio + -- upgrade. + { knob = 78, frequency = "20.0", signal = function(ctx) + if ctx.inJohto or not ctx.expnCard then return nil end + return "POKE_FLUTE_RADIO" + end }, + -- .EvolutionRadio only airs while Team Rocket is still in Mahogany, and only + -- within earshot of the Lake of Rage. + { knob = 80, frequency = "20.5", signal = function(ctx) + if not ctx.rocketSignal then return nil end + local here = ctx.landmark + if here == "LANDMARK_MAHOGANY_TOWN" or here == "LANDMARK_ROUTE_43" + or here == "LANDMARK_LAKE_OF_RAGE" then + return "EVOLUTION_RADIO" + end + return nil + end }, +} + +-- PHONE_DISPLAY_HEIGHT. +local PHONE_ROWS = 4 + +-- data/text/common_3.asm. Every one of these lives in ROM bank $66, which the +-- importer does not reach yet (it follows map script pointers and nothing on a +-- map points into the phone banks), so they are transcribed here with the +-- "bank:addr" key their extracted form will have -- Pokegear:phoneText prefers +-- the extracted string and only falls back to the transcription. +local PHONE_TEXT = { + GearEllipse = { key = "66:4066", body = "……" }, + GearOutOfService = { key = "66:4069", + body = "You're out of the service area." }, + AskWhoCall = { key = "66:4089", body = "Whom do you want to call?" }, + -- _PokegearPressButtonText, the CLOCK card's bottom-box prompt. + PressButton = { key = "66:40a4", body = "Press any button to exit." }, + AskDelete = { key = "66:40bf", body = "Delete this stored phone number?" }, + WrongNumber = { key = "66:40e1", body = "Huh? Sorry, wrong number!" }, + Click = { key = "66:40fc", body = "Click!" }, + PhoneEllipse = { key = "66:4104", body = "……" }, + OutOfArea = { key = "66:4107", body = "That number is out of the area." }, + JustTalkToThem = { key = "66:4128", body = "Just go talk to that person!" }, +} + +function Pokegear:wantsFillScale() return true end +function Pokegear:drawsWidescreen() return true end + +-- opts: save, landmarks (landmarks.lua), currentLandmark, clock, menuGfx, +-- radioData (a prebuilt Radio data table, for tests), radioRng, onClose(), +-- mapDef (the maps.lua record the player is standing on, for the phone's +-- signal / same-map tests), trainers (trainers.lua, for contact names), text +-- (text.lua) and onCall(descriptor), which hands a placed call out to whoever +-- can run its script +function Pokegear.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Pokegear) + self.game = game + self.save = opts.save or (game and game.save) + local data = game and game.data or {} + self.landmarks = opts.landmarks or data.gen2Landmarks + self.currentLandmark = opts.currentLandmark + self.clock = opts.clock + self.onClose = opts.onClose + self.cards = self:visibleCards() + self.cardIndex = 1 + self.mode = "strip" -- strip | card + -- Which RadioChannels row the tuning knob sits on. The knob itself runs + -- 0..80 in steps of two; the port steps the row instead, because every + -- position between two stations is the same dead air. + self.station = 1 + -- wPokegearPhoneCursorPosition / wPokegearPhoneScrollPosition, both of which + -- are ZERO based on the cart: the cursor runs 0..PHONE_DISPLAY_HEIGHT - 1 + -- inside the visible window and the scroll runs 0..CONTACT_LIST_SIZE - + -- PHONE_DISPLAY_HEIGHT. Neither wraps. + self.phoneCursor = 0 + self.phoneScroll = 0 + -- wPokegearPhoneSubmenuCursor, and which submenu is open at all. + self.phoneSubmenu = nil + self.phoneSubmenuCursor = 0 + self.mapDef = opts.mapDef + self.trainers = opts.trainers or data.trainers or data.gen2Trainers + -- text.lua, for the phone strings; `text` is already a method name here. + self.textData = opts.text + self.onCall = opts.onCall + -- The show that the tuned frequency resolved to, and its state machine. + -- Both are nil while the knob sits on dead air (NoRadioStation). + self.radioShow = nil + self.radio = nil + self.radioDataOverride = opts.radioData + self.radioRng = opts.radioRng + self.radioOn = false + -- TownMap_InitCursorAndPlayerIconPositions seeds both the player icon and + -- the cursor from the landmark the player is standing in; the d-pad then + -- walks the CURSOR's landmark index while the icon stays put. + self.mapCursor = nil + -- The call the phone card is showing, if any. + self.call = nil + + -- _FlyMap's own state. `fly` is FieldMoves.flyPoints' answer: this region's + -- half of the Flypoints table with every row CheckIfVisitedFlypoint would + -- reject already dropped, so the cursor's skip loop is just "next row". + self.fly = opts.fly + self.onFly = opts.onFly + if self.fly and #self.fly > 0 then + self.cards = { FLY_MAP_CARD } + self.cardIndex = 1 + self.mode = "card" + -- FlyMap's defaults: the Johto map opens on JOHTO_FLYPOINT (New Bark + -- Town) and the Kanto one on NUM_FLYPOINTS - 1 (Indigo Plateau), not on + -- wherever the player is standing. + self.flyIndex = (self:region() == "kanto") and #self.fly or 1 + 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 + self.palettes = opts.palettes or data.gen2Palettes + + local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear + self.gfx = gfx + if gfx then + self.sheet = TileSheet.new({ + path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, + paletteFor = function(tile) return self:colorsFor(tile) end, + }) + end + return self +end + +function Pokegear:styled() + return self.sheet ~= nil and self.sheet:available() +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] +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 + return Chrome.printThrough(str, tx, ty, pals and pals[1]) +end + +-- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are +-- POKEGEAR_RADIO/MAP/PHONE/EXPN_CARD_F (pokegold data/events/engine_flags.asm, +-- constants/engine_flags.asm const order), so the scripts' `setflag` -- the +-- Radio Tower quiz's ENGINE_RADIO_CARD, the Guide Gent's ENGINE_MAP_CARD, +-- Mom's ENGINE_PHONE_CARD and the Lavender tower's ENGINE_EXPN_CARD -- lands +-- each id in save.engineFlags through World:setEngineFlag. The string-keyed +-- save.pokegearFlags overlay stays readable so a test can seed a card without +-- a world. +local CARD_ENGINE_FLAGS = { radio = 0, map = 1, phone = 2, expn = 3 } + +function Pokegear:flags() + local save = self.save or {} + local flags = {} + for key, value in pairs(save.pokegearFlags or {}) do flags[key] = value end + local engine = save.engineFlags or {} + for key, id in pairs(CARD_ENGINE_FLAGS) do + if engine[id] == true then flags[key] = true end + end + return flags +end + +function Pokegear:visibleCards() + local flags = self:flags() + local out = {} + for _, card in ipairs(CARDS) do + if not card.flag or flags[card.flag] then out[#out + 1] = card end + end + return out +end + +function Pokegear:card() + return self.cards[self.cardIndex] +end + +-- PokegearClock_Init / UpdateClock read hHours, hMinutes and GetWeekday right +-- after UpdateTime, so the CLOCK card shows the GAME clock: the RTC through the +-- save's wStartHour / wStartMinute base. The gear is pushed over a live world, +-- and that world already owns the read (World:hour, plus the POKEPORT_GOLD_HOUR +-- pin a driver sets), so prefer it and fall back to the save's own base when +-- there is no world underneath (the Pokegear opened from a test). +-- +-- `weekday` comes back 1-based for the DAYS table; wCurDay counts SUNDAY 0. +function Pokegear:clockParts() + if self.clock then + return self.clock.hour or 0, self.clock.minute or 0, + self.clock.weekday or 1 + end + local world = self.game and self.game.world + if world and world.hour then + return world:hour(), world:minute(), (world:weekday() % 7) + 1 + end + local save = self.save + return Clock.hour(save), Clock.minute(save), Clock.weekday(save) + 1 +end + +-- wPhoneList itself: ten ordered slots, 0 for an empty one. The empty slots +-- are part of the display (PokegearPhone_UpdateDisplayList draws four rows +-- unconditionally, and slot 0 renders as NonTrainerCallerNames' "----------"), +-- so this does not compact them away. +function Pokegear:phoneList() + return Phone.contacts(self.save) +end + +-- The contact under the cursor, or 0 for an empty slot. `.a` on the phone +-- card reads wPhoneList + scroll + cursor and returns straight back out when +-- that byte is zero. +function Pokegear:phoneSelection() + return self:phoneList()[self.phoneScroll + self.phoneCursor + 1] or 0 +end + +-- What the phone model needs to know about where the player is standing: +-- GetMapPhoneService reads the map header, and both the same-map test and +-- SpecialCallOnlyWhenOutside read the map record too. The Pokegear is pushed +-- from the start menu with the world still underneath it, so the record is +-- either handed in or read off that world. +function Pokegear:phoneContext() + local map = self.mapDef + if not map then + local world = self.game and self.game.world + map = world and world.map and world.map.def + end + local hour, minute = self:clockParts() + return { map = map, clock = { hour = hour, minute = minute } } +end + +-- A common phone string: the extracted text when bank $66 finally arrives, +-- and the transcription from data/text/common_3.asm until then. +function Pokegear:phoneText(name) + local entry = PHONE_TEXT[name] + if not entry then return "" end + local text = self.textData + or (self.game and self.game.world and self.game.world.text) + local extracted = text and text[entry.key] + if extracted and extracted ~= "" then return extracted end + return entry.body +end + +-- GetCallerClassAndName: a trainer contact is "<name>:" over the class name, a +-- non-trainer is its NonTrainerCallerNames string and nothing under it. +function Pokegear:contactRow(id) + local name, className = Phone.contactName(id, self.trainers) + return (name or "----------") .. ":", className +end + +function Pokegear:update(_dt) + -- .Frameset_RedWalk (data/sprite_anims/framesets.asm:81): four 8-frame + -- beats, so the map card's player icon walks in place. + self.iconTimer = ((self.iconTimer or 0) + 1) % 32 + local input = self.game and self.game.input + if not input then return end + -- 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.mode == "strip" then + if input:wasPressed("left") then + self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards + elseif input:wasPressed("right") then + self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1 + elseif input:wasPressed("a") then + self.mode = "card" + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end + return + end + -- Inside a card. + local card = self:card() + -- The phone card owns B while it is showing a call or a submenu: on the cart + -- those are their own jumptable states (POKEGEARSTATE_PHONE*), and B closes + -- the state, not the card. + -- The busy test comes FIRST: wasPressed consumes the press, so asking about + -- B before knowing whether the phone wants it would eat the button the + -- submenu is waiting for. + local phoneBusy = card and card.id == "phone" + and (self.call ~= nil or self.phoneSubmenu ~= nil) + if not phoneBusy and input:wasPressed("b") then + self.mode = "strip" + self:stopRadio() + self.call = nil + self.phoneSubmenu = nil + return + end + if card and card.id == "radio" then + self:ensureTuned() + -- AnimateTuningKnob.TuningKnob: up winds the knob towards 80 and down + -- back towards 0, and it stops dead at either end rather than wrapping. + -- The port steps RadioChannels rows, so "stops dead" is a clamp. + if input:wasPressed("up") then + if self.station < #RADIO_CHANNELS then + self.station = self.station + 1 + self:tuneRadio() + end + elseif input:wasPressed("down") then + if self.station > 1 then + self.station = self.station - 1 + self:tuneRadio() + end + end + self:tickRadio() + elseif card and card.id == "phone" then + self:updatePhone(input) + elseif card and card.id == "map" then + self:moveMapCursor(input) + end +end + +-- --------------------------------------------------------------------- radio +-- +-- .InJohto: the S.S. Aqua counts as Johto and so does anything below +-- KANTO_LANDMARK ($2e = 46). This reads the PLAYER's landmark, never the map +-- cursor's -- the two are separate bytes for exactly this reason. +function Pokegear:region() + local landmarks = self.landmarks and self.landmarks.landmarks + local current = landmarks and self.currentLandmark + 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 + return "johto" +end + +-- The context RadioChannels' per-frequency tests read. +function Pokegear:radioContext() + local save = self.save or {} + local flags = self:flags() + local world = self.game and self.game.world + return { + inJohto = self:region() == "johto", + landmark = self.currentLandmark, + -- wTimeOfDay: MORN is 0, which is the only value that swaps Oak's + -- Pokemon Talk out for the Pokedex Show. Palettes.DAYTIME_ID is 1-based + -- for Lua's sake, so it comes back down a step here. + timeOfDay = self.timeOfDay or self:timeOfDayIndex(), + -- POKEGEAR_EXPN_CARD_F, the Kanto radio upgrade. + expnCard = flags.expn or false, + -- STATUSFLAGS_ROCKET_SIGNAL_F, set while Team Rocket holds Mahogany. + rocketSignal = (save.flags or {}).ROCKET_SIGNAL or false, + } +end + +-- Every RadioChannels row, with the station its test resolves to right now +-- (nil where NoRadioStation would fire). +function Pokegear:stations() + local ctx = self:radioContext() + local out = {} + for index, row in ipairs(RADIO_CHANNELS) do + local station = row.signal(ctx) + out[index] = { + knob = row.knob, frequency = row.frequency, station = station, + name = station and STATION_NAMES[station] or nil, + } + end + return out +end + +function Pokegear:currentStation() + return self:stations()[self.station] +end + +-- UpdateRadioStation: the knob moved, so resolve the frequency, hand the show +-- machine the station it landed on, and let RadioChannelSongs replace the +-- map's music. Dead air is NoRadioStation: no name, no box, no song. +function Pokegear:tuneRadio() + local row = self:currentStation() + local station = row and row.station + self.radioTuned = true + self.radioShow = station + if not station then + self.radio = nil + self.radioOn = false + -- NoRadioStation: MUSIC_NONE now, and ENTER_MAP_MUSIC parked in + -- wPokegearRadioMusicPlaying so leaving the radio on dead air brings the + -- map's own theme back (ExitPokegearRadio_HandleMusic). + self.radioMusicPlaying = "enterMap" + local data = self.game and self.game.data + if data then pcall(require("src.core.Music").stop) end + return + end + self.radio = Radio.new({ data = self:radioData(), rng = self.radioRng }) + self.radio:tune(station) + self.radioOn = true + self:playRadioMusic() +end + +-- PokegearRadio_Init resolves the knob before the card is ever drawn, so a +-- card that arrives already selected (a driver setting cardIndex by hand, or +-- a save resumed on the radio) still knows what it is playing. +function Pokegear:ensureTuned() + if self.radioTuned then return end + self:tuneRadio() +end + +-- One frame of PlayRadioShow, plus whatever song the show asked for on the +-- way through (RadioMusicRestartDE is a call, not a table lookup, so the +-- Pokemon Channel jingle really does change the music mid-show). +function Pokegear:tickRadio() + if not self.radio then return end + self.radio:step() + self:playRadioMusic() +end + +-- What a started song leaves in wPokegearRadioMusicPlaying. Every station +-- start goes through RadioMusicRestartDE, which parks the SONG there (and in +-- wMapMusic); the one exception is the Pokemon Channel jingle, whose +-- RadioMusicRestartPokemonChannel parks RESTART_MAP_MUSIC instead -- closing +-- the gear mid-jingle gives the map its music back. +function Pokegear.radioPlayingValue(song) + if song == "Music_PokemonChannel" then return "restartMap" end + return song +end + +function Pokegear:playRadioMusic() + local song = self.radio and self.radio.music + if not song or song == self.radioSong then return end + self.radioSong = song + self.radioMusicPlaying = Pokegear.radioPlayingValue(song) + local data = self.game and self.game.data + if not data then return end + pcall(require("src.core.Music").play, data, song) +end + +-- The cache reads the shows need, gathered once per Pokegear. Everything +-- here is a lookup the cart does with a farcall (GetLandmarkName, +-- GetWorldMapLocation, GetPokemonName, GetTrainerClassName); the port hands +-- the show machine tables instead so it stays testable. +function Pokegear:radioData() + if self.radioDataOverride then return self.radioDataOverride end + if self.radioDataCache then return self.radioDataCache end + local data = (self.game and self.game.data) or {} + local save = self.save or {} + local out = { inJohto = self:region() == "johto" } + + -- Landmarks by index, which is how GetLandmarkName and GetWorldMapLocation + -- both address them. + out.landmarks = {} + for _, entry in pairs((self.landmarks or {}).landmarks or {}) do + out.landmarks[entry.index or 0] = entry + end + + -- GetWorldMapLocation is a map -> landmark lookup; the extracted maps carry + -- the landmark index the cart's table would have returned. + out.mapLandmark = {} + local maps = (self.game and self.game.world and self.game.world.maps) + or data.gen2Maps or {} + for id, def in pairs(maps) do out.mapLandmark[id] = def.landmark end + + -- JohtoGrassWildMons, keyed by map and then by the time-of-day block Oak's + -- Pokemon Talk indexes with `AddNTimes 2 * NUM_GRASSMON`. + out.grass = {} + for id, row in pairs((data.gen2Encounters or {}).grass or {}) do + local slots = row.slots or {} + local block = {} + for index, key in ipairs({ "MORN", "DAY", "NITE" }) do + local list = {} + for slot, entry in ipairs(slots[key] or {}) do list[slot] = entry.species end + block[index - 1] = list + end + out.grass[id] = block + end + + -- Species by internal index, which is what the Pokedex Show rolls. + out.species = {} + for name, def in pairs(data.pokemon or {}) do + if def.index then out.species[def.index] = name end + end + local caught = (save.pokedex or {}).caught or {} + out.caught = function(name) return caught[name] == true end + + -- Pokedex entries, split the way CopyDexEntryPart1 walks them: the kind + -- name first, then one line per <NEXT>, with the page break ('@') simply + -- joining the two pages into one run of six. + out.dex = {} + for name, entry in pairs((data.gen2Pokedex or {}).entries or {}) do + local lines = {} + for _, page in ipairs({ entry.text, entry.text2 }) do + for line in (tostring(page or "") .. "<NEXT>"):gmatch("(.-)<NEXT>") do + if line ~= "" then lines[#lines + 1] = line end + end + end + out.dex[name] = { kind = entry.kind, lines = lines } + end + + -- TrainerClassNames and the class's first trainer, by class index. + out.classes = {} + for _, class in pairs((data.gen2Trainers or {}).classes or {}) do + if class.index then + out.classes[class.index] = { + name = class.name, + trainer = class.trainers and class.trainers[1] + and class.trainers[1].name, + } + end + end + out.hidden = self:hiddenPeople() + + out.weekday = self:radioWeekday() + -- wLuckyIDNumber, rolled by src/script/gen2/Specials.lua's + -- ResetLuckyNumberShowFlag (engine/events/lucky_number.asm's + -- LoadOrRegenerateLuckyIDNumber). A save that has never visited the Lucky + -- Number Man in Radio Tower has never rolled one, so 00000 is the honest + -- reading, not a stand-in for unfinished work. + out.luckyNumber = save.luckyNumber or 0 + out.rocketsInRadioTower = (save.flags or {}).ROCKETS_IN_RADIO_TOWER or false + self.radioDataCache = out + return out +end + +-- PnP_HiddenPeople, resolved to class indices against this save's progress. +-- The list is walked from one of three entry points, so the further along the +-- player is, the shorter it gets. +function Pokegear:hiddenPeople() + local save = self.save or {} + local first = 1 + if (save.flags or {}).HALL_OF_FAME then + first = PNP_HIDDEN_BEAT_E4 + local badges = (save.player or {}).kantoBadges or {} + local count = 0 + for _, has in pairs(badges) do if has then count = count + 1 end end + if count >= 8 then first = PNP_HIDDEN_BEAT_KANTO end + end + local hidden = {} + for index = first, #PNP_HIDDEN do + local classIndex = self:trainerClassIndex(PNP_HIDDEN[index]) + if classIndex then hidden[classIndex] = true end + end + return hidden +end + +function Pokegear:trainerClassIndex(id) + local classes = (self.game and self.game.data and self.game.data.gen2Trainers + or {}).classes or {} + local class = classes[id] + return class and class.index or nil +end + +-- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3. +function Pokegear:timeOfDayIndex() + local world = self.game and self.game.world + local daytime = (world and world.daytime) + or Palettes.clockDaytime(self.clock and self.clock.hour or nil) + return (Palettes.DAYTIME_ID[daytime] or 2) - 1 +end + +-- GetWeekday counts from Sunday = 0; clockParts answers in the 1-based DAYS +-- numbering the clock card draws with, so the radio's day is that same read +-- shifted down rather than a second clock. +function Pokegear:radioWeekday() + local _, _, weekday = self:clockParts() + return ((weekday or 1) - 1) % 7 +end + +-- ExitPokegearRadio_HandleMusic (pokegold engine/pokegear/pokegear.asm): what +-- leaving the radio does to the music is decided by wPokegearRadioMusicPlaying, +-- not done unconditionally. A tuned station's song was written into wMapMusic +-- by RadioMusicRestartDE, so it KEEPS PLAYING and is the map music from then +-- on -- Music.setMapSong makes a battle's restore replay it, and only a map +-- change replaces it. ENTER_MAP_MUSIC (dead air) and RESTART_MAP_MUSIC (the +-- Pokemon Channel jingle) are the two arms that bring the map theme back. +-- Shared with src/ui/gen2/MapRadio.lua, whose PlayRadio exit runs the same +-- routine. +function Pokegear.exitRadioMusic(game, playing) + if not playing then return end + local data = game and game.data + if not data then return end + local Music = require("src.core.Music") + if playing ~= "enterMap" and playing ~= "restartMap" then + Music.setMapSong(playing) + return + end + local world = game and game.world + local song = world and world.map and world.map.def and world.map.def.music + if song then pcall(Music.play, data, song) else pcall(Music.stop) end +end + +function Pokegear:stopRadio() + local playing = self.radioMusicPlaying + self.radio = nil + self.radioShow = nil + self.radioSong = nil + self.radioTuned = nil + self.radioMusicPlaying = nil + self.radioOn = false + Pokegear.exitRadioMusic(self.game, playing) +end + +-- --------------------------------------------------------------------- phone +-- +-- The phone card is four jumptable states on the cart, and they are separate +-- states because each one owns the buttons outright: +-- +-- PHONEJOYPAD the list. PokegearPhone_GetDPad walks the cursor, A opens +-- the contact submenu. +-- (submenu) PokegearPhoneContactSubmenu, a blocking loop rather than a +-- state: CALL / DELETE / CANCEL, with DELETE withheld from a +-- contact CheckCanDeletePhoneNumber refuses (MOM and ELM). +-- MAKEPHONECALL PokegearPhone_MakePhoneCall -- two rings, then +-- MakePhoneCallFromPokegear. +-- FINISHCALL any button hangs up. + +-- PokegearPhoneContactSubmenu's two string tables. The three-entry menu draws +-- its box at (9,4) and the two-entry one at (9,6), because the box origin is +-- computed from the STRING coordinate by `bccoord -1, -2, 0`; entries are one +-- <NEXT> apart, which is TWO tile rows, and the cursor column steps by the +-- same two rows. +local PHONE_SUBMENUS = { + callDeleteCancel = { x = 9, y = 4, rows = 3, textX = 11, textY = 6, + entries = { "CALL", "DELETE", "CANCEL" } }, + callCancel = { x = 9, y = 6, rows = 2, textX = 11, textY = 8, + entries = { "CALL", "CANCEL" } }, +} + +Pokegear.PHONE_SUBMENUS = PHONE_SUBMENUS + +-- PokegearPhone_GetDPad, then `.a`. Neither the cursor nor the scroll wraps: +-- the cursor stops at the top and bottom of the four visible rows and hands +-- over to the scroll, which stops at 0 and at CONTACT_LIST_SIZE - 4. +function Pokegear:updatePhone(input) + -- A placed call owns the buttons: PokegearPhone_FinishPhoneCall takes A or B + -- and hangs up. + if self.call then + if input:wasPressed("a") or input:wasPressed("b") then + self:hangUp() + end + return + end + if self.phoneSubmenu then + self:updatePhoneSubmenu(input) + return + end + if input:wasPressed("a") then + -- `ld a, [hl] / and a / ret z`: an empty slot is not a contact. + if self:phoneSelection() ~= 0 then self:openPhoneSubmenu() end + return + end + if input:wasPressed("up") then + if self.phoneCursor > 0 then + self.phoneCursor = self.phoneCursor - 1 + elseif self.phoneScroll > 0 then + self.phoneScroll = self.phoneScroll - 1 + end + elseif input:wasPressed("down") then + if self.phoneCursor < PHONE_ROWS - 1 then + self.phoneCursor = self.phoneCursor + 1 + elseif self.phoneScroll < Phone.CONTACT_LIST_SIZE - PHONE_ROWS then + self.phoneScroll = self.phoneScroll + 1 + end + end +end + +-- CheckCanDeletePhoneNumber picks which of the two menus opens. +function Pokegear:openPhoneSubmenu() + local id = self:phoneSelection() + self.phoneSubmenu = Phone.canDelete(id) and "callDeleteCancel" or "callCancel" + self.phoneSubmenuCursor = 0 +end + +function Pokegear:updatePhoneSubmenu(input) + local menu = PHONE_SUBMENUS[self.phoneSubmenu] + if not menu then + self.phoneSubmenu = nil + return + end + -- `.d_up` refuses to move off entry 0 and `.d_down` off the last one; the + -- cart's own loop simply keeps looping, so this is a clamp, not a wrap. + if input:wasPressed("up") then + if self.phoneSubmenuCursor > 0 then + self.phoneSubmenuCursor = self.phoneSubmenuCursor - 1 + end + return + end + if input:wasPressed("down") then + if self.phoneSubmenuCursor < menu.rows - 1 then + self.phoneSubmenuCursor = self.phoneSubmenuCursor + 1 + end + return + end + -- `.a_b`: B always means Cancel, whatever the cursor is on. + if input:wasPressed("b") then + self.phoneSubmenu = nil + return + end + if not input:wasPressed("a") then return end + local choice = menu.entries[self.phoneSubmenuCursor + 1] + self.phoneSubmenu = nil + if choice == "CALL" then + self:callContact(self:phoneSelection()) + elseif choice == "DELETE" then + -- The cart asks first (PokegearAskDeleteText + YesNoBox). There is no + -- yes/no box inside a Pokegear card in this port, so the submenu entry is + -- the confirmation; the prompt is what the deletion state shows. + Phone.deleteContactAt(self.save, self.phoneScroll + self.phoneCursor + 1) + end +end + +-- PokegearPhone_MakePhoneCall. The no-signal branch never reaches +-- MakePhoneCallFromPokegear at all: it plays SFX_NO_SIGNAL and prints +-- _GearOutOfServiceText, which is a DIFFERENT string from the "that number is +-- out of the area" the engine's own out-of-area path prints. +function Pokegear:callContact(id) + if not id or id == 0 then return end + local context = self:phoneContext() + if not Phone.mapHasService(context) then + self.call = { contact = id, kind = "nosignal", + text = self:phoneText("GearOutOfService") } + return + end + local call = Phone.call(self.save, id, context) + local name, className = Phone.contactName(id, self.trainers) + call.name, call.className = name, className + if call.kind == "outofarea" then + call.text = self:phoneText("OutOfArea") + elseif call.kind == "justtalk" then + call.text = self:phoneText("JustTalkToThem") + elseif call.wrongNumber then + call.text = self:phoneText("WrongNumber") + else + -- What the cart shows while a call connects: Phone_TextboxWithName's + -- "NAME:" and PhoneEllipseText. The handler below runs the callee script + -- over it; the ellipsis stays under its pages the way the cart's does. + call.text = (name or "") .. ": " .. self:phoneText("PhoneEllipse") + end + self.call = call + -- Hand the descriptor out. Game2:runPokegearCall is the live handler: + -- the contact's extracted SCRIPT1 runs through the overworld VM and its + -- pages ride the state stack over this card. With no handler (a bare + -- test harness) the card still shows the ring and the caller's name. + if self.onCall then self.onCall(call) end +end + +-- HangUp: the click, the boops, and back to "Whom do you want to call?". +function Pokegear:hangUp() + self.call = nil +end + +-- ----------------------------------------------------------------------- map +-- +-- PokegearMap_JohtoMap / PokegearMap_KantoMap. The cursor is a landmark +-- INDEX, not a position: up steps to the next landmark id and down to the +-- previous one, and the two limit registers d (the last landmark of the +-- region) and e (the first) are what it wraps between. +-- +-- LANDMARK_* indices, from constants/landmark_constants.asm: +-- NEW_BARK_TOWN $01 SILVER_CAVE $2d PALLET_TOWN $2e +-- VICTORY_ROAD $57 ROUTE_28 $5d FAST_SHIP $5e +-- LANDMARK_SPECIAL ($00) and LANDMARK_FAST_SHIP ($5e) sit outside both +-- ranges, so the cursor can never land on either. +local LANDMARK_NEW_BARK_TOWN = 0x01 +local LANDMARK_SILVER_CAVE = 0x2d +local LANDMARK_PALLET_TOWN = 0x2e +local LANDMARK_VICTORY_ROAD = 0x57 +local LANDMARK_ROUTE_28 = 0x5d + +-- 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 + end + if ((self.save or {}).flags or {}).HALL_OF_FAME then + return LANDMARK_ROUTE_28, LANDMARK_PALLET_TOWN + end + return LANDMARK_ROUTE_28, LANDMARK_VICTORY_ROAD +end + +-- The cursor's landmark index. Unset, it is the player's own, which is what +-- TownMap_InitCursorAndPlayerIconPositions writes into both bytes. +function Pokegear:mapCursorIndex() + -- On the fly screen the cursor IS the flypoint row: _FlyMap walks the + -- Flypoints table and reads the landmark out of it, so the name plate and + -- the arrow both follow the row rather than a free landmark index. + local flyRow = self:flyRow() + if flyRow and flyRow.index then return flyRow.index end + if self.mapCursor then return self.mapCursor end + local landmarks = self.landmarks and self.landmarks.landmarks + local current = landmarks and self.currentLandmark + and landmarks[self.currentLandmark] + local _, first = self:cursorLimits() + return current and current.index or first +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) + local last, first = self:cursorLimits() + local cursor = self:mapCursorIndex() + if input:wasPressed("up") 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 + -- `cp e / jr nz, .wrap_around_down`: only the first landmark wraps. + if cursor == first then cursor = last + 1 end + cursor = cursor - 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 + return + end + self.mapCursor = cursor +end + +-- ------------------------------------------------------------------ fly map +-- +-- _FlyMap's `.loop` (engine/pokegear/pokegear.asm:1978): A takes the flypoint +-- the cursor is on, B answers -1, and .HandleDPad walks the Flypoints table +-- with up/down, wrapping between wStartFlypoint and wEndFlypoint and skipping +-- every row CheckIfVisitedFlypoint rejects. Left and right do nothing at all +-- here -- there is no card to page to. +function Pokegear:updateFlyMap(input) + local rows = self.fly or {} + local count = #rows + if count == 0 then + if self.onClose then self.onClose() end + return + end + if input:wasPressed("up") then + self.flyIndex = ((self.flyIndex or 1) % count) + 1 + elseif input:wasPressed("down") then + self.flyIndex = ((self.flyIndex or 1) - 2) % count + 1 + elseif input:wasPressed("a") then + local row = rows[self.flyIndex or 1] + if row and self.onFly then self.onFly(row.spawn) end + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end +end + +-- The flypoint under the cursor. +function Pokegear:flyRow() + if not self.fly then return nil end + return self.fly[self.flyIndex or 1] +end + +-- Pokegear_SwitchPage: take the first of the named cards the player owns. +function Pokegear:switchCard(...) + for _, id in ipairs({ ... }) do + for index, card in ipairs(self.cards) do + if card.id == id then + self.cardIndex = index + if id == "radio" then self:tuneRadio() end + return true + end + end + end + return false +end + +-- The landmark the map card is naming and parking the cursor sprite on: +-- PokegearMap_UpdateCursorPosition reads the CURSOR's landmark, never the +-- player icon's. +function Pokegear:mapLandmark() + local index = self:mapCursorIndex() + for _, entry in pairs((self.landmarks or {}).landmarks or {}) do + if entry.index == index then return entry end + end + return nil +end + +-- The landmark the player icon sits on, which the d-pad never moves. +function Pokegear:playerLandmark() + local landmarks = self.landmarks and self.landmarks.landmarks + return landmarks and self.currentLandmark and landmarks[self.currentLandmark] + or nil +end + +-- ---------------------------------------------------------------- tile layer + +-- A card's tilemap uses exactly two "empty" cells and they are NOT the same +-- colour, which is the whole reason the gear reads as a lit panel on black: +-- +-- $4f the ground InitPokegearTilemap ByteFills SCREEN_AREA with. A solid +-- colour-3 tile, so it is BLACK (Pokegear:groundColor), and it is what +-- every cell outside a card's art stays. +-- $7f a font-page SPACE. Every pixel is colour 0, so it is the CREAM +-- plate, and it is what fills the SWITCH box, the day/time window and +-- the map's label strip. The clock card's own tilemap spells both out: +-- `30 7f 7f 7f 7f 7f 7f 31` is the SWITCH box's top row (rounded +-- corners around six spaces), and the window interior is nothing but +-- $7f between the $16 sides. +-- +-- Neither is in the gear's tile sheet -- they are font-page ids -- so both are +-- painted here rather than blitted. Before this, a $7f drew nothing and the +-- black ground showed through, which took the cream out of the SWITCH box and +-- left the clock window an empty black rectangle. +local SPACE_TILE = 0x7f + +function Pokegear:tile(id, tx, ty) + if id == SPACE_TILE then + local paper = self:paperColor() + local G = love.graphics + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", tx * 8, ty * 8, 8, 8) + G.setColor(1, 1, 1, 1) + return + end + if id == BLANK_TILE then + local ground = self:groundColor() + local G = love.graphics + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", tx * 8, ty * 8, 8, 8) + G.setColor(1, 1, 1, 1) + return + end + if self.sheet then self.sheet:draw(id, tx, ty) end +end + +function Pokegear:drawTilemap(cells) + if not cells then return end + for index = 1, SCREEN_W * SCREEN_H do + local tile = cells[index] + if tile then + self:tile(tile, (index - 1) % SCREEN_W, + math.floor((index - 1) / SCREEN_W)) + end + end +end + +-- Pokegear_FinishTilemap. +-- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town +-- map cursor ($04). Loaded once and remembered as `false` when there is no +-- sheet at all, so a missing asset is not retried every frame. +function Pokegear:loadArrowSheet() + if self.arrow ~= nil then return end + self.arrow = false + local gfx = self.gfx + if gfx and gfx.sprites then + self.arrow = TileSheet.new({ + path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0, + -- The icon strip's palette (cream / orange / brown / black), not BG + -- palette 0's greys: the arrow is an OBJ and the cart tints it to match + -- the card icons it points at. The extract carries the gear's BG + -- palettes only, and palMap gives every icon-strip tile this same index, + -- so it is the one that reproduces the cart rather than a guess. + palette = gfx.palettes and (gfx.palettes[4] or gfx.palettes[1]), + }) + end +end + +function Pokegear:drawStrip() + for x = 0, 7 do + self:tile(BLANK_TILE, x, 0) + self:tile(BLANK_TILE, x, 1) + end + for _, card in ipairs(self.cards) do + local n, x = card.icon, card.iconX + self:tile(n, x, 0) + self:tile(n + 1, x + 1, 0) + self:tile(n + 0x10, x, 1) + self:tile(n + 0x11, x + 1, 1) + end +end + +-- The mode indicator arrow. Two things about it follow from its being an OBJ +-- rather than part of the tilemap, and both were wrong when it was drawn +-- inside drawStrip: +-- +-- * It is ABOVE everything. A card's own art goes down after the strip, so +-- drawing the arrow with the icons put the phone list's window frame over +-- its tip and the list's plate behind its stem. On hardware an OBJ is +-- composited over the BG whatever the BG is, so this draws LAST, from +-- drawPanel, after whichever card has finished. +-- * It sits directly under the strip. The icons are rows 0 and 1, so the +-- arrow's top is row 2 -- its tip touches the selected icon's bottom edge, +-- which is the whole point of an indicator. Four pixels lower and it +-- reads as floating in the card rather than hanging off the icon. +-- +-- AnimatePokegearModeIndicatorArrow slides it $10 pixels per card, which is +-- exactly one icon's width, so the x follows the selected card's own column. +-- +-- Tiles $00-$03: a 16x16 up-triangle with a short stem, which is why the town +-- map cursor starts at $04 and why PokegearSpritesGFX is two tiles wide. +-- $00/$01 are its top row and $02/$03 its bottom; drawing $00 alone put the +-- triangle's top-left corner on screen and nothing else, which is the thin +-- diagonal sliver that read as a broken cursor. +function Pokegear:drawModeArrow() + local card = self:card() + local G = love.graphics + -- CENTRED on the icon, not hung off its corner. An icon is two tiles wide + -- (iconX, iconX + 1) and the arrow is two tiles wide too, so their left + -- edges are the same column: starting a tile further right put the arrow's + -- centre over the icon's right-hand edge, which reads as belonging to the + -- gap between two cards rather than to either one. + local iconX = (card and card.iconX or 0) * 8 + self:loadArrowSheet() + if self.arrow and self.arrow:available() then + G.setColor(1, 1, 1, 1) + -- The sprite's first two pixel rows are all but empty (the triangle proper + -- starts on row 2 of tile $00), so the block is lifted half a tile: the + -- apex then meets the icon's bottom edge and tucks under it instead of + -- floating in the black gap below the strip. + local tx, ty = iconX / 8, 1.5 + self.arrow:draw(0, tx, ty) + self.arrow:draw(1, tx + 1, ty) + self.arrow:draw(2, tx, ty + 1) + self.arrow:draw(3, tx + 1, ty + 1) + else + Chrome.cursor(math.floor(iconX / 8), 2) + end +end + +-- ---------------------------------------------------------------- the cards + +function Pokegear:drawClock() + local hour, minute, weekday = self:clockParts() + self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.clock) + self:drawStrip() + self:text("SWITCH", 13, 1) + Chrome.cursor(19, 1) + + -- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and + -- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at + -- column 12. + self:text(DAYS[weekday] or "", 6, 6) + local display = hour % 12 + if display == 0 then display = 12 end + self:text(Chrome.number(display, 2), 6, 8) + self:text(":", 8, 8) + self:text(Chrome.number(minute, 2, true), 9, 8) + self:text(hour < 12 and "AM" or "PM", 12, 8) + + -- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and + -- PokegearClock_Init prints PokegearPressButtonText straight into it + -- (engine/pokegear/pokegear.asm PokegearClock_Init), the same way + -- PokegearPhone_Init fills it with PokegearAskWhoCallText -- the box is the + -- exit prompt, not spare room. `line` starts the second row two tile rows + -- below the first (data/text/common_3.asm _PokegearPressButtonText), which is + -- why this steps 14 -> 16 rather than printing consecutive rows. + self:textbox(0, 12, 18, 4) + self:printBoxText(self:phoneText("PressButton")) +end + +-- One of these bottom-box strings, laid out the way PrintText lays a two-line +-- text out: `text` on the box's first interior row and `line` two tile rows +-- under it, which is the same (1,14)/(1,16) pair the port's other Gold text +-- boxes use. The box only has room for those two rows. +function Pokegear:printBoxText(text) + local lines = Chrome.wrap(text, 18) + for i = 1, math.min(#lines, 2) do + Chrome.print(lines[i], 1, 14 + (i - 1) * 2) + end +end + +-- The Pokegear's paper: BG palette 0's colour 0. _CGB_PokegearPals copies the +-- six PokegearPals entries straight into wBGPals1 (engine/gfx/cgb_layouts.asm +-- _CGB_PokegearPals), and TownMapPals hands every tile id >= $60 -- the whole +-- font page, so every blank $7f and every glyph cell -- palette 0 +-- (engine/pokegear/pokegear.asm TownMapPals). That first entry is +-- `RGB 28, 31, 20` (gfx/pokegear/pokegear.pal), a pale cream, NOT white: a card +-- 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 + return (pals and pals[1] and pals[1][1]) or { 255, 255, 255 } +end + +-- The colour the gear's own ground reads as. InitPokegearTilemap ByteFills +-- SCREEN_AREA with $4f, and $4f is not a blank cell: it is a SOLID tile, every +-- pixel colour index 3, so the fill lands as BG palette 0's LAST colour and +-- the gear sits on black. Reading colour 0 instead put the screen on the +-- cream plate, which is why the card art appeared as a black box floating on +-- pale green rather than as a lit panel on black. +-- +-- Colour 0 is still right for a plate of ' ' cells (Pokegear:drawPlate): a +-- 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 pal = pals and pals[1] + return (pal and pal[#pal]) or { 0, 0, 0 } +end + +-- A run of ' ' cells over the map art. A space is a font-page cell, so it +-- reads as BG palette 0's colour 0 -- the cream plate -- rather than as +-- whatever town-map tile was underneath. +function Pokegear:drawPlate(tx, ty, tw, th) + local paper = self:paperColor() + local G = love.graphics + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8) + G.setColor(1, 1, 1, 1) +end + +-- Textbox on this screen. Its frame is TextBoxBorder's $79-$7e and its +-- interior is ' ' ($7f), all of them font-page tiles, so the WHOLE box -- the +-- ring as much as the middle -- reads as palette 0's colour 0 here +-- (engine/pokegear/pokegear.asm TownMapPals). Chrome.textbox cannot be used +-- as-is: Font.drawBox hard-fills its rect white, which is right on every other +-- Gold screen (their BG palette 0 colour 0 IS white) and wrong on the gear, so +-- lay the gear's own paper down and draw only the frame glyphs over it. b/c in +-- the ASM are interior rows/columns, same as Chrome.textbox. +function Pokegear:textbox(tx, ty, interiorW, interiorH) + local tw, th = interiorW + 2, interiorH + 2 + self:drawPlate(tx, ty, tw, th) + local B = Font.BORDER + local G = love.graphics + G.setColor(0, 0, 0, 1) + Font.drawCode(B.tl, tx * 8, ty * 8) + Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8) + Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8) + Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8) + for i = 1, tw - 2 do + Font.drawCode(B.h, (tx + i) * 8, ty * 8) + Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8) + end + for j = 1, th - 2 do + Font.drawCode(B.v, tx * 8, (ty + j) * 8) + Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8) + end + G.setColor(0, 0, 0, 1) +end + +-- TownMapBubble: the plate the fly screen wears instead of the card strip. +-- Three rows from (1,0) to (18,2), "Where?" at (2,0), the flypoint's landmark +-- name at (2,1) and the up/down scroller at (18,1). The four rounded corners +-- come from FlyMapLabelBorderGFX, a six-tile 1bpp set loaded over vTiles2 tile +-- $30 for this screen only -- the extractor carries the town map's own $30-$33 +-- instead, so the plate is drawn square rather than with the wrong art in its +-- corners. +function Pokegear:drawFlyBubble() + self:drawPlate(1, 0, 18, 3) + self:text("Where?", 2, 0) + local row = self:flyRow() + self:text(flatName(row and row.name), 2, 1) + Chrome.cursor(18, 1) +end + +function Pokegear:drawMap() + -- The REGION follows the player (`cp KANTO_LANDMARK` in + -- PokegearMap_CheckRegion); the name box follows the CURSOR, which the + -- d-pad may have walked somewhere else entirely. + local region = self:region() + local current = self:mapLandmark() + self:drawTilemap(self.gfx and self.gfx.maps and self.gfx.maps[region]) + local G = love.graphics + 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) + + -- 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 + -- colour 0, the cream plate, rather than the strip's black $4f -- then + -- $34, the ▲▼ scroller, at (8,0). The name itself is placed at (9,0) by + -- TownMap_ConvertLineBreakCharacters, and the word break it rewrites is + -- <LF>, which steps one row rather than <NEXT>'s two. + G.setColor(1, 1, 1, 1) + self:drawPlate(8, 0, 12, 2) + self:tile(0x34, 8, 0) + local name = current and current.name or "" + local row = 0 + for line in (tostring(name) .. "\n"):gmatch("(.-)\n") do + if row < 2 then self:text(line, 9, row) end + row = row + 1 + end + end + + -- Two OBJs, not one. PokegearMap_InitPlayerIcon parks RED_WALK on the + -- PLAYER's landmark and PokegearMap_InitCursor parks the POKEGEAR_ARROW + -- (sprite tile $04) on the CURSOR's, and only the second one moves. The + -- landmark macro stores x + 8 / y + 16, which is OAM space; the extractor + -- already took the offsets back off, so these coordinates are screen ones. + local player = self:playerLandmark() + if player and player.x and player.y then + if not self:drawPlayerIcon(player.x, player.y) then + G.setColor(0, 0, 0, 1) + G.rectangle("fill", player.x - 2, player.y - 2, 5, 5) + G.setColor(1, 1, 1, 1) + G.rectangle("fill", player.x - 1, player.y - 1, 3, 3) + end + end + if current and current.x and current.y then + self:mapCursorSprite(current.x, current.y) + end +end + +-- ChrisSpriteGFX, the sheet Pokegear_LoadGFX copies into vTiles0 $10 and $14 +-- (engine/pokegear/pokegear.asm:135-144). `false` means no gen2 sprites. +function Pokegear:loadPlayerIcon() + if self.playerIcon ~= nil then return end + self.playerIcon = false + local def = self.sprites and self.sprites.SPRITE_CHRIS + 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 + local world = self.game and self.game.world + local daytime = (world and world.daytime) + or Palettes.clockDaytime(self.clock and self.clock.hour or nil) + local colors = self.palettes + and Palettes.spritePalette(self.palettes, daytime, def) + if colors then + icon:setObjPalette(colors, + ("gen2:%s:%d"):format(tostring(daytime), def.paletteId or 0)) + end + self.playerIcon = icon +end + +function Pokegear:drawPlayerIcon(x, y) + self:loadPlayerIcon() + if not self.playerIcon then return false end + -- .Frameset_RedWalk beats (data/sprite_anims/framesets.asm:82-85): stand, + -- walk, stand, walk B_OAM_XFLIP, as FacingStepDown0-3 in facings.asm. + local beat = math.floor((self.iconTimer or 0) / 8) + -- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles + -- at -8,-8; camY of -4 undoes the world's sprite lift. + self.playerIcon:draw(x - 8, y - 8, 0, -4, "down", + beat % 2, beat == 3) + return true +end + +-- The cursor arrow. It is the same PokegearSpritesGFX sheet the mode +-- indicator uses, at tile $04; without the sheet the port falls back to +-- Chrome's own cursor glyph so the card is still navigable. +function Pokegear:mapCursorSprite(x, y) + self:loadArrowSheet() + local G = love.graphics + if self.arrow and self.arrow:available() then + G.setColor(1, 1, 1, 1) + -- Tiles $04-$07 are one 16x16 OBJ, and .OAMData_RedWalk (the STILL_CURSOR + -- oamset, data/sprite_anims/oam.asm:57) centres it on the landmark. + local tx, ty = (x - 8) / 8, (y - 8) / 8 + self.arrow:draw(0x04, tx, ty) + self.arrow:draw(0x05, tx + 1, ty) + self.arrow:draw(0x06, tx, ty + 1) + self.arrow:draw(0x07, tx + 1, ty + 1) + return + end + Chrome.cursor(math.floor(x / 8), math.floor(y / 8)) +end + +function Pokegear:drawRadio() + self:ensureTuned() + self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio) + self:drawStrip() + local station = self:currentStation() + -- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air + -- prints nothing: NoRadioStation clears the box and leaves it clear. + self:text(station and station.name or "", 2, 9) + -- The show owns the bottom text box's two lines. PrintRadioLine fills them + -- from the top the first time round and CopyBottomLineToTopLine scrolls + -- afterwards, so `top` is always the line before `bottom`. + self:textbox(0, 12, 18, 4) + local radio = self.radio + if not (station and station.station and radio) then return end + if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end + if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end +end + +function Pokegear:drawPhone() + self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.phone) + self:drawStrip() + -- .PlacePhoneBars: the signal meter at (17,1)/(18,1)/(17,2), and the fourth + -- tile at (18,2) ONLY when GetMapPhoneService comes back zero -- the missing + -- corner is how the card says "no signal here". + self:tile(0x3c, 17, 1) + self:tile(0x3d, 18, 1) + self:tile(0x3e, 17, 2) + if Phone.mapHasService(self:phoneContext()) then + self:tile(0x3f, 18, 2) + end + + self:textbox(0, 12, 18, 4) + -- A call in progress replaces the prompt with what the caller is saying; + -- otherwise the box holds PokegearAskWhoCallText the whole time. + if self.call then + Chrome.printWrapped(self.call.text or self:phoneText("GearEllipse"), + 1, 14, 18, 3) + else + self:printBoxText(self:phoneText("AskWhoCall")) + end + -- PokegearPhone_UpdateDisplayList: every one of the four visible slots is + -- drawn, empty or not, from (2,4) two rows apart. GetCallerClassAndName + -- puts the name (with its trailing colon) on that row and the trainer class + -- one row down and three columns in; a non-trainer has no second line. + local list = self:phoneList() + for row = 1, PHONE_ROWS do + local id = list[row + self.phoneScroll] or 0 + local ty = 4 + (row - 1) * 2 + local label, className = self:contactRow(id) + self:text(label, 2, ty) + if className then self:text(className, 5, ty + 1) end + end + -- PokegearPhone_UpdateCursor draws the cursor at (1, 4 + 2 * cursor). + Chrome.cursor(1, 4 + self.phoneCursor * 2) + self:drawPhoneSubmenu() +end + +-- PokegearPhoneContactSubmenu's box and its entries, laid where the ASM's +-- coordinates put them rather than by eye. +function Pokegear:drawPhoneSubmenu() + local menu = PHONE_SUBMENUS[self.phoneSubmenu or ""] + if not menu then return end + -- `ld a, [de] / sla a` -> b is twice the entry count, and Textbox's b/c are + -- interior rows/columns, so the box is (rows * 2 + 2) tall and 10 wide. + self:textbox(menu.x, menu.y, 8, menu.rows * 2) + for index, label in ipairs(menu.entries) do + local ty = menu.textY + (index - 1) * 2 + self:text(label, menu.textX, ty) + end + Chrome.cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2) +end + +-- ------------------------------------------------------------------ fallback + +function Pokegear:drawPlain() + Chrome.clear() + if self.fly then + -- No town-map art in this cache, so the bubble's "Where?" and the rows it + -- scrolls between are the whole screen. + Chrome.box(0, 0, 20, 4) + Chrome.print("Where?", 2, 1) + Chrome.box(0, 4, 20, 14) + local rows = self.fly + local top = math.max(1, math.min((self.flyIndex or 1) - 3, #rows - 5)) + for slot = 0, 5 do + local index = top + slot + local row = rows[index] + if row then + local ty = 5 + slot * 2 + if index == (self.flyIndex or 1) then Chrome.cursor(1, ty) end + Chrome.print(flatName(row.name), 2, ty) + end + end + return + end + Chrome.box(0, 0, 20, 4) + local card = self:card() + Chrome.print(card and card.label or "", 2, 1) + if #self.cards > 1 then Chrome.cursor(17, 1) end + local id = card and card.id + if id == "clock" then + local hour, minute, weekday = self:clockParts() + Chrome.box(1, 5, 18, 7) + Chrome.print(DAYS[weekday] or "DAY", 3, 7) + local display = hour % 12 + if display == 0 then display = 12 end + Chrome.print(("%s:%s %s"):format( + Chrome.number(display, 2), Chrome.number(minute, 2, true), + hour < 12 and "AM" or "PM"), 5, 9) + Chrome.print(Palettes.clockDaytime(hour), 5, 11) + elseif id == "radio" then + -- Without the gear sheet there is no dial art, so the frequencies go down + -- the screen as a list. A frequency whose test failed still gets a row: + -- the knob really does stop there, it just finds nothing. + Chrome.box(0, 4, 20, 14) + for i, row in ipairs(self:stations()) do + local ty = 5 + (i - 1) * 2 + if ty < 17 then + if i == self.station then Chrome.cursor(1, ty) end + Chrome.print(row.frequency .. " " .. (row.name or ""), 2, ty) + end + end + elseif id == "phone" then + -- No card art, so no signal meter and no tilemap: the list and the call + -- box are the whole card, at the same coordinates the styled one uses so + -- the two read the same way. + Chrome.box(0, 3, 20, 9) + local list = self:phoneList() + for row = 1, PHONE_ROWS do + local ty = 4 + (row - 1) * 2 + local label, className = self:contactRow(list[row + self.phoneScroll] or 0) + Chrome.print(label, 2, ty) + if className then Chrome.print(className, 5, ty + 1) end + end + Chrome.cursor(1, 4 + self.phoneCursor * 2) + Chrome.textbox(0, 12, 18, 4) + Chrome.printWrapped(self.call and (self.call.text or "") + or self:phoneText("AskWhoCall"), 1, 14, 18, 3) + self:drawPhoneSubmenu() + else + Chrome.box(0, 4, 20, 14) + Chrome.print("NO CARD DATA", 2, 6) + end +end + +function Pokegear:drawPanel() + if not self:styled() then + self:drawPlain() + love.graphics.setColor(1, 1, 1, 1) + return + end + local G = love.graphics + -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the + -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), + -- and every cell the card leaves blank is a font-page tile on palette 0, so + -- the ground under a card is the gear's paper, not white. See + -- Pokegear:paperColor. + local ground = self:groundColor() + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + local id = self:card() and self:card().id + if id == "map" then + self:drawMap() + elseif id == "radio" then + self:drawRadio() + elseif id == "phone" then + self:drawPhone() + else + self:drawClock() + end + -- Last: the arrow is an OBJ and composites over whatever the card drew. + self:drawModeArrow() + G.setColor(1, 1, 1, 1) +end + +function Pokegear:draw() + self:drawPanel() +end + +function Pokegear:drawWidescreen(winW, winH) + local G = love.graphics + -- The surround takes the gear's own ground, so the panel does not read as a + -- black card sitting inside a cream frame. It follows groundColor for the + -- same reason drawPanel does: $4f is what the screen is filled with. + local ground = self:groundColor() + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 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 + +-- The gate World:openFlyMap reads before it pushes this screen instead of +-- falling back to its yes/no chain. +Pokegear.FLY_MAP = true + +Pokegear.CARDS = CARDS +-- Exported for tests and drivers: the dial, the show machine, and the tables +-- the shows read out of. +Pokegear.RADIO_CHANNELS = RADIO_CHANNELS +Pokegear.STATION_NAMES = STATION_NAMES +Pokegear.Radio = Radio +Pokegear.OPT_ADVERBS = OPT_ADVERBS +Pokegear.OPT_ADJECTIVES = OPT_ADJECTIVES +Pokegear.PNP_ADJECTIVES = PNP_ADJECTIVES +Pokegear.ROCKET_LINES = ROCKET_LINES +Pokegear.OPT_ROUTES = OPT_ROUTES +Pokegear.PNP_PLACES = PNP_PLACES + +return Pokegear diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua new file mode 100644 index 00000000..d3b166b3 --- /dev/null +++ b/src/ui/gen2/PrizeMenu.lua @@ -0,0 +1,570 @@ +-- The Game Corner's three prize counters, and the coin case they all read. +-- +-- Unlike the slot machine and card flip, these are not engine screens on the +-- cart: each counter is a MAP SCRIPT that opens a static menu, and the whole +-- transaction is script commands the VM already has opcodes for. The three +-- kinds are: +-- +-- coins GameCornerCoinVendorScript (engine/events/std_scripts.asm) -- the +-- attendant who sells 50 coins for ¥1000 and 500 for ¥10000 +-- item the TM counters (CeladonGameCornerPrizeRoomTMVendor, +-- GoldenrodGameCornerTMVendorScript) +-- mon the Pokemon counters (CeladonGameCornerPokemonVendor, +-- GoldenrodGameCornerPrizeMonVendorScript) +-- +-- Because of that this module is NOT a stack state the game ever pushes and +-- carries no src/ui/Screens.lua id: talking to a vendor runs the extracted +-- script through src/script/gen2/Vm.lua, which has every opcode the three +-- counters use. What is still read from here is the coin case at the top, +-- which the slot machine and card flip share. +-- +-- The rest is kept as a transcription of the counters' own tables, because the +-- ORDER of the checks is what a player notices and what a test can pin down. +-- Those orders are NOT the same: +-- +-- item counter coins are checked, then the player is asked, and only then +-- is the PACK asked for room, so a full bag is discovered +-- AFTER saying yes +-- mon counter coins are checked, then the PARTY is checked, and only then +-- is the player asked -- a full party never gets the question +-- coin counter the case is checked for room FIRST and the wallet second, so +-- a full case is reported even when you could not afford it +-- +-- Coordinates are transcribed, never eyeballed. Every counter's menu header is +-- `menu_coords 0, 2, W, TEXTBOX_Y - 1` (the coin vendor's is `0, 4, 15, 11`) +-- with STATICMENU_CURSOR and no STATICMENU_NO_TOP_SPACING, so +-- GetMenuTextStartCoord (home/menu.asm) puts the first label at box + 1 for the +-- border, + 1 more row for the top spacing and + 1 more column for the cursor: +-- (2,4) for the prize counters, (2,6) for the coin vendor, cursor in column 1, +-- labels two rows apart (PlaceMenuStrings adds 2 * SCREEN_WIDTH per item). +-- +-- DisplayCoinCaseBalance (engine/menus/menu_2.asm) is the little box every +-- counter's loop re-prints: Textbox at (11,0) with a 7x1 interior, "COIN" at +-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1). + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.gen2.Chrome") +local CoinCase = require("src.core.gen2.CoinCase") +local Save = require("src.core.gen2.Save") +local Sound = require("src.core.Sound") + +local PrizeMenu = {} +PrizeMenu.__index = PrizeMenu +PrizeMenu.isOpaque = true + +-- --------------------------------------------------------------- coin case +-- +-- engine/events/money.asm GiveCoins / TakeCoins / CheckCoins now live in +-- src/core/gen2/CoinCase.lua: the slot machine and card flip read the same +-- case, and neither of those is a prize counter, so the case is model, not +-- menu. These names stay here as aliases because they are this module's +-- public API and the coin vendor counter is still the thing that calls them. +PrizeMenu.MAX_COINS = CoinCase.MAX_COINS +PrizeMenu.coins = CoinCase.coins +PrizeMenu.giveCoins = CoinCase.giveCoins +PrizeMenu.takeCoins = CoinCase.takeCoins +PrizeMenu.HAVE_MORE = CoinCase.HAVE_MORE +PrizeMenu.HAVE_AMOUNT = CoinCase.HAVE_AMOUNT +PrizeMenu.HAVE_LESS = CoinCase.HAVE_LESS +PrizeMenu.checkCoins = CoinCase.checkCoins + +local function money(save) + local player = save and save.player + return (player and player.money) or 0 +end + +local function setMoney(save, amount) + local player = save and save.player + if not player then return end + player.money = math.max(0, math.min(math.floor(amount or 0), Save.MAX_MONEY)) +end + +-- ---------------------------------------------------------------- counters +-- +-- Prices are the map scripts' own EQU block, kept as the constants they are +-- named after so a reader can find them in the .asm. +-- +-- CeladonGameCornerPrizeRoom.asm: +-- TM32 1500 TM29 3500 TM15 7500 +-- MR.MIME 3333 EEVEE 6666 PORYGON 9999 +-- GoldenrodGameCorner.asm: +-- TM25 5500 TM14 5500 TM38 5500 +-- ABRA 200 SANDSHREW 700 EKANS 700 DRATINI 2100 +-- +-- The Goldenrod Pokemon counter branches on `checkver`: Gold sells EKANS where +-- Silver sells SANDSHREW, which is the only version difference in the room. +-- The labels are the menu data's literal strings, spaces and all, because their +-- padding is what right-aligns the price column. +PrizeMenu.COUNTERS = { + CELADON_TM = { + kind = "item", + menu = { x = 0, y = 2, w = 16, h = 10 }, + prizes = { + { id = "TM_DOUBLE_TEAM", label = "TM32 1500", cost = 1500 }, + { id = "TM_PSYCHIC_M", label = "TM29 3500", cost = 3500 }, + { id = "TM_HYPER_BEAM", label = "TM15 7500", cost = 7500 }, + }, + }, + CELADON_MON = { + kind = "mon", + menu = { x = 0, y = 2, w = 18, h = 10 }, + prizes = { + { id = "MR__MIME", label = "MR.MIME 3333", cost = 3333, level = 15 }, + { id = "EEVEE", label = "EEVEE 6666", cost = 6666, level = 15 }, + { id = "PORYGON", label = "PORYGON 9999", cost = 9999, level = 20 }, + }, + }, + GOLDENROD_TM = { + kind = "item", + menu = { x = 0, y = 2, w = 16, h = 10 }, + prizes = { + { id = "TM_THUNDER", label = "TM25 5500", cost = 5500 }, + { id = "TM_BLIZZARD", label = "TM14 5500", cost = 5500 }, + { id = "TM_FIRE_BLAST", label = "TM38 5500", cost = 5500 }, + }, + }, + GOLDENROD_MON = { + kind = "mon", + menu = { x = 0, y = 2, w = 18, h = 10 }, + prizes = { + { id = "ABRA", label = "ABRA 200", cost = 200, level = 10 }, + { id = "EKANS", label = "EKANS 700", cost = 700, level = 10, + silver = { id = "SANDSHREW", label = "SANDSHREW 700" } }, + { id = "DRATINI", label = "DRATINI 2100", cost = 2100, level = 10 }, + }, + }, + -- GameCornerCoinVendorScript's own menu: `menu_coords 0, 4, 15, TEXTBOX_Y - 1` + -- and only two entries plus CANCEL. `checkcoins MAX_COINS - 50` with + -- HAVE_MORE meaning "full" is how the cart asks whether 50 more will fit. + COIN_VENDOR = { + kind = "coins", + menu = { x = 0, y = 4, w = 16, h = 8 }, + prizes = { + { amount = 50, cost = 1000, label = " 50 : \xc2\xa51000" }, + { amount = 500, cost = 10000, label = "500 : \xc2\xa510000" }, + }, + }, +} + +-- ------------------------------------------------------------------- text +-- +-- The Celadon room and the Goldenrod room use DIFFERENT wording for the same +-- five beats, and the coin vendor a third set again. All three are transcribed +-- from the map scripts / data/text/std_text.asm; none are in the cache's +-- text.lua, because no script bytecode the extractor walks points at them. +local function page(...) return { ... } end +local function pages(...) return { ... } end + +PrizeMenu.TEXTS = { + CELADON = { + intro = pages(page("Welcome!"), + page("We exchange your", "coins for fabulous"), + page("prizes!")), + which = page("Which prize would", "you like?"), + confirm = function(name) + return pages(page("OK, so you wanted", ("a %s?"):format(name))) + end, + hereYouGo = pages(page("Here you go!")), + notEnoughCoins = pages(page("You don't have", "enough coins.")), + noRoom = pages(page("You have no room", "for it.")), + comeAgain = pages(page("Oh. Please come", "back with coins!")), + noCoinCase = pages(page("Oh? You don't have", "a COIN CASE.")), + }, + GOLDENROD = { + intro = pages(page("Welcome!"), + page("We exchange your", "game coins for"), + page("fabulous prizes!")), + which = page("Which prize would", "you like?"), + confirm = function(name) + return pages(page(("%s."):format(name), "Is that right?")) + end, + hereYouGo = pages(page("Here you go!")), + notEnoughCoins = pages(page("Sorry! You need", "more coins.")), + noRoom = pages(page("Sorry. You can't", "carry any more.")), + comeAgain = pages(page("OK. Please save", "your coins and"), + page("come again!")), + noCoinCase = pages(page("Oh? You don't have", "a COIN CASE.")), + }, + COIN_VENDOR = { + intro = pages(page("Welcome to the", "GAME CORNER.")), + which = page("Do you need some", "game coins?"), + bought = { + [50] = pages(page("Thank you!", "Here are 50 coins.")), + [500] = pages(page("Thank you! Here", "are 500 coins.")), + }, + notEnoughMoney = pages(page("You don't have", "enough money.")), + caseFull = pages(page("Whoops! Your COIN", "CASE is full.")), + comeAgain = pages(page("No coins for you?", "Come again!")), + noCoinCase = pages(page("Do you need game", "coins?"), + page("Oh, you don't have", "a COIN CASE for"), + page("your coins.")), + }, +} + +-- --------------------------------------------------------- pure purchase +-- +-- Everything a counter refuses, decided without a screen so a test can drive +-- it. Returns "ok" or the reason, and never mutates. +-- +-- PARTY_LENGTH is 6 (constants/pokemon_data_constants.asm); `readvar +-- VAR_PARTYCOUNT / ifequal PARTY_LENGTH` is the mon counter's room check. +PrizeMenu.PARTY_LENGTH = Save.PARTY_SIZE + +PrizeMenu.canAfford = CoinCase.canAfford + +-- The room check, split out because the two prize kinds ask different +-- questions and ask them at different points in the conversation. +function PrizeMenu.hasRoom(save, counter, prize, data) + if counter.kind == "mon" then + return #((save and save.party) or {}) < PrizeMenu.PARTY_LENGTH + end + if counter.kind == "coins" then + -- `checkcoins MAX_COINS - amount / ifequal HAVE_MORE` -- strictly more than + -- the headroom is a full case, exactly the headroom still fits. + return PrizeMenu.checkCoins(save, PrizeMenu.MAX_COINS - prize.amount) + ~= PrizeMenu.HAVE_MORE + end + -- giveitem's own failure: AddItemToInventory refuses when the pocket is full + -- or the stack would pass 99. Bag.add is that routine, so ask it directly + -- against a scratch copy rather than reimplementing the rule. + local inv = (save and save.inventory) or {} + local scratch = { inventory = {} } + for id, count in pairs(inv) do scratch.inventory[id] = count end + return Bag.add(scratch, prize.id, 1, data or {}) == true +end + +-- The whole refusal ladder for one counter, in the cart's order. +-- Returns "ok", "coins", "room", or "money" (the coin vendor only). +function PrizeMenu.check(save, counter, prize, data) + if counter.kind == "coins" then + if not PrizeMenu.hasRoom(save, counter, prize, data) then return "room" end + if money(save) < prize.cost then return "money" end + return "ok" + end + if not PrizeMenu.canAfford(save, prize.cost) then return "coins" end + if counter.kind == "mon" + and not PrizeMenu.hasRoom(save, counter, prize, data) then + return "room" + end + return "ok" +end + +-- The transaction itself, once the player has said yes. Item counters do the +-- giveitem BEFORE the takecoins, so a bag that turns out to be full costs +-- nothing; that ordering is what makes "room" a possible answer here too. +function PrizeMenu.buy(save, counter, prize, data) + if counter.kind == "coins" then + local reason = PrizeMenu.check(save, counter, prize, data) + if reason ~= "ok" then return reason end + PrizeMenu.giveCoins(save, prize.amount) + setMoney(save, money(save) - prize.cost) + return "ok" + end + if not PrizeMenu.canAfford(save, prize.cost) then return "coins" end + if counter.kind == "mon" then + if not PrizeMenu.hasRoom(save, counter, prize, data) then return "room" end + local Mon = require("src.battle.gen2.Mon") + local mon = Mon.new(data, prize.id, prize.level) + if not mon then return "room" end + save.party = save.party or {} + -- The prize is a `givepoke`, so it takes AddPartyMon's stamp (move_mon.asm:143-149). + Mon.stampOT(save, mon) + save.party[#save.party + 1] = mon + -- `special GameCornerPrizeMonCheckDex` right before the givepoke: the prize + -- mon is registered as seen and caught even though it never appeared in a + -- battle. + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[prize.id] = true + save.pokedex.caught[prize.id] = true + PrizeMenu.takeCoins(save, prize.cost) + return "ok" + end + if not Bag.add(save, prize.id, 1, data or {}) then return "room" end + PrizeMenu.takeCoins(save, prize.cost) + return "ok" +end + +-- ------------------------------------------------------------------ layout +local COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W, COIN_BOX_H = 11, 0, 9, 3 +local COIN_LABEL_X, COIN_LABEL_Y = 12, 0 +local COIN_VALUE_X, COIN_VALUE_Y = 13, 1 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +-- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7` puts a 6x5 box at (14,7), and +-- YesNoMenuHeader carries STATICMENU_CURSOR | STATICMENU_NO_TOP_SPACING, so YES +-- lands at (16,8) and NO at (16,10) with the cursor in column 15. +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +local SFX_TRANSACTION = "Sfx_Transaction" + +-- ------------------------------------------------------------------ screen +function PrizeMenu:wantsFillScale() return true end +function PrizeMenu:drawsWidescreen() return true end + +-- opts: save, counter (a COUNTERS key or a table), texts (a TEXTS key), +-- version ("gold"/"silver"), data, hasCoinCase (defaults to the bag), +-- onClose() +function PrizeMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PrizeMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.data = opts.data or (game and game.data) + self.onClose = opts.onClose + local counter = opts.counter or "CELADON_TM" + self.counter = type(counter) == "table" and counter + or PrizeMenu.COUNTERS[counter] or PrizeMenu.COUNTERS.CELADON_TM + local textKey = opts.texts + or (self.counter.kind == "coins" and "COIN_VENDOR" or "CELADON") + self.text = PrizeMenu.TEXTS[textKey] or PrizeMenu.TEXTS.CELADON + self.version = opts.version or (self.save and self.save.version) or "gold" + self:buildPrizes() + self.index = 1 + -- `checkitem COIN_CASE / iffalse` is the very first thing after the intro. + if opts.hasCoinCase ~= nil then + self.hasCoinCase = opts.hasCoinCase + else + local inv = (self.save and self.save.inventory) or {} + self.hasCoinCase = (inv.COIN_CASE or 0) > 0 + end + self:say(self.text.intro, function() + if not self.hasCoinCase then + self:say(self.text.noCoinCase, function() self:close() end) + else + self:enterMenu() + end + end) + return self +end + +-- The Goldenrod mon counter's `checkver` swap, applied once at construction so +-- the menu rows and the purchase agree. +function PrizeMenu:buildPrizes() + local silver = self.version == "silver" + local out = {} + for i, prize in ipairs(self.counter.prizes) do + if silver and prize.silver then + local swapped = {} + for k, v in pairs(prize) do swapped[k] = v end + swapped.id = prize.silver.id + swapped.label = prize.silver.label + swapped.silver = nil + out[i] = swapped + else + out[i] = prize + end + end + -- Every counter's menu data ends with a literal CANCEL row. + out[#out + 1] = { cancel = true, label = "CANCEL" } + self.prizes = out +end + +function PrizeMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function PrizeMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function PrizeMenu:enterMenu() + self.phase = "menu" + -- `db 1 ; default option`: the cursor is back on the first prize every pass + -- through the loop. + self.index = 1 + self.message = nil +end + +function PrizeMenu:close() + if self.onClose then self.onClose() end +end + +function PrizeMenu:cancel() + self:say(self.text.comeAgain, function() self:close() end) +end + +function PrizeMenu:sfx(name) + if self.data then Sound.play(self.data, name) end +end + +function PrizeMenu:prizeName(prize) + if self.counter.kind == "coins" then + return ("%d coins"):format(prize.amount) + end + local items = self.data and self.data.items + local mons = self.data and self.data.pokemon + local def = (self.counter.kind == "mon" and mons or items) + local entry = def and def[prize.id] + return (entry and entry.name) or prize.id +end + +function PrizeMenu:refuse(reason) + local text = self.text + if reason == "coins" then + self:say(text.notEnoughCoins, function() self:close() end) + elseif reason == "money" then + self:say(text.notEnoughMoney, function() self:close() end) + elseif reason == "room" then + self:say(text.noRoom or text.caseFull, function() self:close() end) + end +end + +function PrizeMenu:choose() + local prize = self.prizes[self.index] + if not prize or prize.cancel then + self:cancel() + return + end + -- The pre-confirm ladder: coins first for a prize counter, room first for the + -- coin vendor. A refusal here ends the conversation, it does not loop. + local reason = PrizeMenu.check(self.save, self.counter, prize, self.data) + if reason ~= "ok" then + self:refuse(reason) + return + end + if self.counter.kind == "coins" then + -- The coin vendor never asks; it buys straight off the menu. + self:complete(prize) + return + end + self:ask(self.text.confirm(self:prizeName(prize)), + function() self:complete(prize) end, + function() self:cancel() end) +end + +function PrizeMenu:complete(prize) + local reason = PrizeMenu.buy(self.save, self.counter, prize, self.data) + if reason ~= "ok" then + self:refuse(reason) + return + end + self:sfx(SFX_TRANSACTION) + local bought = self.text.bought and self.text.bought[prize.amount] + self:say(bought or self.text.hereYouGo, function() self:enterMenu() end) +end + +function PrizeMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function PrizeMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function PrizeMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.confirm then + self:updateConfirm(input) + return + end + if self.message then + self:updateMessage(input) + return + end + if self.phase ~= "menu" then return end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.prizes + elseif input:wasPressed("down") then + self.index = self.index < #self.prizes and self.index + 1 or 1 + elseif input:wasPressed("b") then + self:cancel() + elseif input:wasPressed("a") then + self:choose() + end +end + +-- ------------------------------------------------------------------- draw +function PrizeMenu:drawCoinBox() + Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2) + Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y) + Chrome.print(Chrome.number(PrizeMenu.coins(self.save), 4, true), + COIN_VALUE_X, COIN_VALUE_Y) +end + +function PrizeMenu:drawPanel() + Chrome.clear() + self:drawCoinBox() + if self.phase == "menu" or self.confirm then + local menu = self.counter.menu + Chrome.textbox(menu.x, menu.y, menu.w - 2, menu.h - 2) + for i, prize in ipairs(self.prizes) do + local ty = menu.y + 2 + (i - 1) * 2 + if i == self.index then Chrome.cursor(menu.x + 1, ty) end + Chrome.print(prize.label, menu.x + 2, ty) + end + end + local lines = nil + if self.confirm then + lines = self.confirm.pages[self.confirm.page] + elseif self.message then + lines = self.message.pages[self.message.page] + elseif self.phase == "menu" then + lines = self.text.which + end + if lines then + Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) + for i, line in ipairs(lines) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end + end + if self.confirm and self.confirm.page >= #self.confirm.pages then + Chrome.textbox(YESNO_X, YESNO_Y, YESNO_W - 2, YESNO_H - 2) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + 1 + (self.confirm.choice - 1) * 2) + end +end + +function PrizeMenu:draw() + self:drawPanel() +end + +function PrizeMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return PrizeMenu diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua new file mode 100644 index 00000000..3c8868c7 --- /dev/null +++ b/src/ui/gen2/SaveMenu.lua @@ -0,0 +1,229 @@ +-- SAVE from the start menu (engine/menus/save.asm SaveMenu). +-- +-- Transcribed rather than laid out by eye. SaveMenu is four calls: +-- +-- DisplayNormalContinueData with `lb de, 4, 0`, which is the *same* panel +-- CONTINUE shows, moved: _OffsetMenuHeader keeps the header's 15x9 size +-- but puts its left edge at 4 and its top at 0, so MenuBox draws a 16x10 +-- box over (4,0)..(19,9). GetMenuTextStartCoord then lands the four +-- labels at (5,2), (5,4), (5,6), (5,8) -- border + 1, plus one more row +-- because the header does not set STATICMENU_NO_TOP_SPACING, and no extra +-- column because it does not set STATICMENU_CURSOR. +-- SpeechTextbox, the ordinary 18x4 box over rows 12-17. +-- SaveTheGame_yesorno, which prints into that box and puts the yes/no at +-- `lb bc, 0, 7` -- left 0, top 7, so a 6x5 box at (0,7) with YES at (2,8) +-- and NO at (2,10). YesNoMenuHeader sets STATICMENU_CURSOR and +-- STATICMENU_NO_TOP_SPACING, which is what puts the labels one column in +-- from the cursor and skips the blank row. +-- SavingDontTurnOffThePower, which is a timed sequence and not a prompt: +-- "SAVING… DON'T TURN / OFF THE POWER." for 16 frames, the write, 32 +-- frames, "<PLAYER> saved / the game.", SFX_SAVE, then 30 more. +-- +-- Overwriting an existing file gets a second yes/no first +-- (AskOverwriteSaveFile), which is the whole reason this is a state and not a +-- one-line call. + +local Chrome = require("src.ui.gen2.Chrome") +local Save = require("src.core.gen2.Save") +local Sound = require("src.core.Sound") + +local SaveMenu = {} +SaveMenu.__index = SaveMenu +SaveMenu.isOpaque = true + +-- SFX_SAVE ($25, constants/sfx_constants.asm:40) is what plays as the file is +-- written: `ld de, SFX_SAVE / call PlaySFX` right after ResumeGameLogic in +-- SaveGameData (engine/menus/save.asm:110), and again under SavedTheGameText +-- (:265). It is an index into the sfx pointer table, so an id that is off by +-- anything plays a different sound rather than nothing -- $1f is +-- SFX_ENTER_DOOR, which is what saving used to creak with. +local SFX_SAVE = 0x25 + +-- SavingDontTurnOffThePower's DelayFrames counts, at the 60 Hz logic clock. +local SAVING_FRAMES = 16 +local SAVED_FRAMES = 32 + 30 + +-- MenuBox coordinates after _OffsetMenuHeader(4, 0). +local PANEL_X, PANEL_Y, PANEL_W, PANEL_H = 4, 0, 16, 10 +local LABEL_X, LABEL_Y = 5, 2 +-- Continue_DisplayBadgesDex / Continue_PrintGameTime add these to the box's +-- own origin, so they are (4,0) + (13,4) / (12,6) / (9,8). +local BADGES_X, BADGES_Y = 17, 4 +local DEX_X, DEX_Y = 16, 6 +local TIME_X, TIME_Y = 13, 8 + +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 + +function SaveMenu:wantsFillScale() return true end +function SaveMenu:drawsWidescreen() return true end + +-- opts: save, onDone(saved), existed (override), writer (injected for tests) +function SaveMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, SaveMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onDone = opts.onDone + self.writer = opts.writer or Save.save + local existed = opts.existed + if existed == nil then existed = Save.exists("gold") end + self.existed = existed + -- confirm -> overwrite (only when a file exists) -> saving -> done + self.phase = "confirm" + self.choice = 1 -- 1 YES, 2 NO + self.timer = 0 + return self +end + +function SaveMenu:playSfx(id) + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.sfxOrder) then return end + local name = audio.sfxOrder[id + 1] + if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end +end + +function SaveMenu:finish(saved) + if self.onDone then self.onDone(saved) end +end + +function SaveMenu:playerName() + return (self.save and self.save.player and self.save.player.name) or "GOLD" +end + +-- The write itself, which the cart does between the two messages. +function SaveMenu:writeNow() + local ok = self.writer(self.save) + self.saved = ok and true or false + if ok then self:playSfx(SFX_SAVE) end +end + +function SaveMenu:accept() + if self.phase == "confirm" then + if self.choice == 2 then + self:finish(false) + return + end + if self.existed then + self.phase = "overwrite" + self.choice = 1 + return + end + self.phase = "saving" + self.timer = 0 + return + end + if self.phase == "overwrite" then + if self.choice == 2 then + self:finish(false) + return + end + self.phase = "saving" + self.timer = 0 + end +end + +function SaveMenu:update(_dt) + -- The saving and saved messages are DelayFrames, not prompts: no button + -- does anything until the sequence runs out. + if self.phase == "saving" then + self.timer = self.timer + 1 + if self.timer >= SAVING_FRAMES then + self:writeNow() + self.phase = "done" + self.timer = 0 + end + return + end + if self.phase == "done" then + self.timer = self.timer + 1 + if self.timer >= SAVED_FRAMES then self:finish(self.saved) end + return + end + + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.choice = self.choice == 1 and 2 or 1 + return + end + if input:wasPressed("a") then + self:accept() + elseif input:wasPressed("b") then + -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:finish(false) + end +end + +-- The two lines the speech box holds, in the cart's own wording. `line` puts +-- the second one on the box's lower line; `cont` scrolls, which the overwrite +-- prompt uses for its third line and which this shows as a second page. +function SaveMenu:prompt() + if self.phase == "overwrite" then + -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText + -- when the ID differs. Only the first can happen here. + return { "There is already a", "save file. Is it" } + end + if self.phase == "saving" then + return { "SAVING… DON'T TURN", "OFF THE POWER." } + end + if self.phase == "done" then + if self.saved then + return { self:playerName() .. " saved", "the game." } + end + return { "Could not save.", "" } + end + return { "Would you like to", "save the game?" } +end + +function SaveMenu:drawPanel() + Chrome.clear() + local summary = Save.summary(self.save) + Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H) + if summary then + Chrome.print("PLAYER " .. summary.name, LABEL_X, LABEL_Y) + Chrome.print("BADGES", LABEL_X, LABEL_Y + 2) + Chrome.print("POKéDEX", LABEL_X, LABEL_Y + 4) + Chrome.print("TIME", LABEL_X, LABEL_Y + 6) + -- PrintNum fills its field from the left, space padded. + Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y) + Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y) + Chrome.print(Chrome.number(summary.hours, 3), TIME_X, TIME_Y) + Chrome.print(":", TIME_X + 3, TIME_Y) + Chrome.print(Chrome.number(summary.minutes, 2, true), TIME_X + 4, TIME_Y) + end + + -- SpeechTextbox: interior 18x4 at (0,12), so the two lines are at (1,14) + -- and (1,16) -- `line` is the box's lower line, two rows down. + Chrome.textbox(0, 12, 18, 4) + local lines = self:prompt() + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) + + if self.phase == "confirm" or self.phase == "overwrite" then + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3)) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function SaveMenu:draw() + self:drawPanel() +end + +function SaveMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return SaveMenu diff --git a/src/ui/gen2/ScriptMenu.lua b/src/ui/gen2/ScriptMenu.lua new file mode 100644 index 00000000..321f7660 --- /dev/null +++ b/src/ui/gen2/ScriptMenu.lua @@ -0,0 +1,303 @@ +-- The static menu a script puts up: `loadmenu` then `verticalmenu` or +-- `_2dmenu` (home/menu.asm VerticalMenu / _2DMenu, engine/menus/menu.asm +-- _2DMenu_). Nine sites in the game use one -- the vending machines, the Game +-- Corner prize counters, the coin vendor, Earl's blackboard -- and until the +-- extractor followed the MenuHeader pointer every one of them took the cancel +-- arm, because the VM had a raw address and nothing to draw. +-- +-- The whole layout comes out of two routines, so nothing here is laid out by +-- eye: +-- +-- MenuBox draws the border from (left, top) to (right, bottom) +-- inclusive, which is the four bytes `menu_coords` lays down +-- Y FIRST (macros/coords.asm is `db \2, \1` twice). +-- GetMenuTextStartCoord decides where the labels and the cursor go: +-- box + 1 for the border, + 1 more row unless +-- STATICMENU_NO_TOP_SPACING, + 1 more column if +-- STATICMENU_CURSOR. The cursor sits in the column that last +-- + 1 skipped over. +-- +-- A vertical menu's items are TWO rows apart (`ld bc, 2 * SCREEN_WIDTH` in +-- PlaceMenuStrings) and its answer is wMenuCursorY. A 2D menu lays its items +-- out row by row, `spacing` tiles apart across and the same two rows down, and +-- its answer is `(cursorY - 1) * cols + cursorX` (_2DMenu_'s SimpleMultiply). +-- +-- Both are ONE-BASED and both answer 0 for B, which is why the `ifequal` +-- ladder after the command starts at 1 and the fall-through is the cancel arm. +-- +-- These two routines are Gold's generic menu, so this is where the generic +-- ui.list_menu hook lands: it takes the same opts (wrap, pageJump, keyRepeat, +-- repeatDelay, repeatRate) and the same ctx the Gen 1 list takes +-- (src/ui/ListMenu.lua), and a mod that asks for wrapping once has it on every +-- script menu in the game. + +local Chrome = require("src.ui.gen2.Chrome") +local CoinCase = require("src.core.gen2.CoinCase") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") + +local ScriptMenu = {} +ScriptMenu.__index = ScriptMenu +ScriptMenu.isOpaque = false + +-- constants/menu_constants.asm, the wMenuDataFlags bits this screen reads. +local STATICMENU_DISABLE_B = 0x01 +-- STATICMENU_WRAP (bit 2): the cursor runs off one end onto the other. No +-- header a script loads sets it, so vanilla Gold never wraps -- it is read here +-- because it is the flag a ui.list_menu hook is turning on when it asks for +-- `wrap`, and a header that ever did set it must not need a second switch. +local STATICMENU_WRAP = 0x04 +local STATICMENU_NO_TOP_SPACING = 0x40 +local STATICMENU_CURSOR = 0x80 + +-- Key-repeat cadence, the same pair the Gen 1 generic list uses +-- (src/ui/ListMenu.lua): frames to wait before repeats start, then between +-- them. Both are inert until a ui.list_menu hook turns keyRepeat on. +local REPEAT_DELAY = 16 +local REPEAT_RATE = 4 + +-- ui.list_menu identity: unhooked opts pass through unchanged. +local function sameOpts(opts) return opts end + +local function hasFlag(flags, bit) + return math.floor((tonumber(flags) or 0) / bit) % 2 == 1 +end + +-- GetMenuTextStartCoord, exactly. Answers the label origin in tile coords; +-- the cursor column is one to the left of it when there is a cursor at all. +function ScriptMenu.startCoord(header) + local flags = (header and header.dataFlags) or 0 + local y = ((header and header.top) or 0) + 1 + local x = ((header and header.left) or 0) + 1 + if not hasFlag(flags, STATICMENU_NO_TOP_SPACING) then y = y + 1 end + if hasFlag(flags, STATICMENU_CURSOR) then x = x + 1 end + return x, y +end + +-- The item list and the grid shape, whichever of the two the header carries. +-- A vertical menu is n rows by one column; a 2D menu names its own. +function ScriptMenu.layout(header, style) + local grid = header and header.grid + if style == "2d" and grid then + return header.gridItems or {}, grid.rows, grid.cols, grid.spacing or 0 + end + local items = (header and header.items) or {} + return items, #items, 1, 0 +end + +-- The 1-based answer _2DMenu_ computes. A vertical menu is one column, so +-- this collapses to the row index -- which is what wMenuCursorY holds. +function ScriptMenu.choiceIndex(row, col, cols) + return (row - 1) * (cols or 1) + col +end + +-- opts: header (the extracted MenuHeader), style ("vertical" | "2d"), +-- balance ("coins" | "money" | "moneycoins"), save, +-- onChoose(index) -- 0 for a cancel, +-- title / kind -- context for the ui.list_menu hook, nothing else reads +-- them (a script menu has neither on the cart) +function ScriptMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ScriptMenu) + self.game = game + self.data = (game and game.data) or {} + -- The balance box the `special` before the `loadmenu` put up: every + -- DisplayCoinCaseBalance / DisplayMoneyAndCoinBalance / PlaceMoneyTopRight in + -- the game is followed straight away by loadmenu, and the box stays on + -- screen under the menu until CloseWindow. See Chrome's balance boxes. + self.balance = opts.balance + self.save = opts.save or (game and game.save) + self.header = opts.header or {} + self.style = opts.style or "vertical" + self.onChoose = opts.onChoose + self.items, self.rows, self.cols, self.spacing = + ScriptMenu.layout(self.header, self.style) + self.textX, self.textY = ScriptMenu.startCoord(self.header) + self.showCursor = hasFlag(self.header.dataFlags, STATICMENU_CURSOR) + -- MenuHeader's last byte is wMenuCursorPosition, the option the cursor opens + -- on. It is 1-based and every real header sets it to 1. + local start = math.max(1, math.min(#self.items, + tonumber(self.header.cursor) or 1)) + self.row = math.floor((start - 1) / self.cols) + 1 + self.col = (start - 1) % self.cols + 1 + + -- ui.list_menu: the same hook name and the same (opts, ctx) payload the Gen 1 + -- generic list uses (src/ui/ListMenu.lua), wrapped around Gold's own generic + -- menu -- VerticalMenu / _2DMenu, the routine every static script menu in the + -- game is drawn and driven by -- so a mod that asks for wrapping or + -- hold-to-scroll once gets it on both generations' list-shaped menus. + -- Guarded with wantsHook: a mod-free boot builds no ctx table and keeps the + -- flags the header itself carries. + self.wrap = hasFlag(self.header.dataFlags, STATICMENU_WRAP) + self.pageJump = false + self.keyRepeat = false + self.repeatDelay = REPEAT_DELAY + self.repeatRate = REPEAT_RATE + self.holdDir, self.holdFrames = nil, 0 + if Runtime.wantsHook("ui.list_menu") then + local hooked = Runtime.call("ui.list_menu", sameOpts, { + wrap = self.wrap, + pageJump = self.pageJump, + keyRepeat = self.keyRepeat, + repeatDelay = self.repeatDelay, + repeatRate = self.repeatRate, + }, { + game = game, + title = opts.title, + -- `kind` is what a Gen 1 hook switches on ("bag", "shop", ...); a script + -- menu has no title to fall back to, so it names the shape it is. + kind = opts.kind or ("script_" .. self.style), + itemCount = #self.items, + }) + if type(hooked) == "table" then + if hooked.wrap ~= nil then self.wrap = hooked.wrap and true or false end + if hooked.pageJump ~= nil then + self.pageJump = hooked.pageJump and true or false + end + if hooked.keyRepeat ~= nil then + self.keyRepeat = hooked.keyRepeat and true or false + end + self.repeatDelay = tonumber(hooked.repeatDelay) or self.repeatDelay + self.repeatRate = math.max(1, tonumber(hooked.repeatRate) + or self.repeatRate) + end + end + return self +end + +function ScriptMenu:wantsFillScale() return true end + +function ScriptMenu:finish(index) + if self.done then return end + self.done = true + if self.onChoose then self.onChoose(index) end +end + +function ScriptMenu: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 + +-- One cursor step along an axis. Without `wrap` the cursor stops at the ends +-- the way _2DMenu_'s exit flags leave it, which is every header the game ships. +local function step(current, total, delta, wrap) + if total <= 0 then return current end + local next_ = current + delta + if wrap then return ((next_ - 1) % total) + 1 end + return math.max(1, math.min(total, next_)) +end + +-- An edge press or a key-repeat tick for a held direction. Left and right are +-- the 2D grid's own axis wherever there is more than one column; on a vertical +-- menu they are free, so `pageJump` gives them the ends of the list -- the page +-- the Gen 1 list moves by is its whole visible window, and this menu draws +-- every row it has (src/ui/ListMenu.lua navPressed). +function ScriptMenu:nav(dir) + if dir == "up" then + self.row = step(self.row, self.rows, -1, self.wrap) + elseif dir == "down" then + self.row = step(self.row, self.rows, 1, self.wrap) + elseif self.cols > 1 and dir == "left" then + self.col = step(self.col, self.cols, -1, self.wrap) + elseif self.cols > 1 and dir == "right" then + self.col = step(self.col, self.cols, 1, self.wrap) + elseif self.pageJump and dir == "left" then + self.row = step(self.row, self.rows, -self.rows, self.wrap) + elseif self.pageJump and dir == "right" then + self.row = step(self.row, self.rows, self.rows, self.wrap) + end +end + +-- StaticMenuJoypad, and then MenuClickSound on the way out. +function ScriptMenu:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local pressed = nil + if input:wasPressed("up") then + pressed = "up" + elseif input:wasPressed("down") then + pressed = "down" + elseif input:wasPressed("left") then + pressed = "left" + elseif input:wasPressed("right") then + pressed = "right" + end + if pressed then + self:nav(pressed) + self.holdDir, self.holdFrames = pressed, 0 + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:finish(ScriptMenu.choiceIndex(self.row, self.col, self.cols)) + elseif input:wasPressed("b") + and not hasFlag(self.header.dataFlags, STATICMENU_DISABLE_B) then + self:playSfx("Sfx_ReadText2") + self:finish(0) + end + if self.done then return end + + -- Hold-to-scroll, opt-in through ui.list_menu's keyRepeat and driven exactly + -- as the Gen 1 list drives it (src/ui/ListMenu.lua): the held direction + -- repeats once repeatDelay frames have passed, then every repeatRate frames. + if not self.keyRepeat then return end + local dir = self.holdDir + if dir and input.isDown and input:isDown(dir) then + self.holdFrames = self.holdFrames + 1 + local afterDelay = self.holdFrames - self.repeatDelay + if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then + self:nav(dir) + end + else + self.holdDir, self.holdFrames = nil, 0 + end +end + +function ScriptMenu:itemPosition(index) + local row = math.floor((index - 1) / self.cols) + local col = (index - 1) % self.cols + return self.textX + col * self.spacing, self.textY + row * 2 +end + +-- Drawn BEFORE the menu box, which is the order the cart draws them in: the +-- Celadon TM counter's menu runs to column 15 on row 2 and the coin box's +-- bottom border is on that row, so the menu's own border is what survives the +-- overlap. +function ScriptMenu:drawBalance() + local kind = self.balance + if not kind then return end + local player = self.save and self.save.player + local money = (player and player.money) or 0 + if kind == "coins" then + Chrome.coinBalanceBox(CoinCase.coins(self.save)) + elseif kind == "moneycoins" then + Chrome.moneyAndCoinBalanceBox(money, CoinCase.coins(self.save)) + else + Chrome.moneyBalanceBox(money) + end +end + +function ScriptMenu:drawPanel() + self:drawBalance() + local h = self.header + local left, top = h.left or 0, h.top or 0 + Chrome.box(left, top, + (h.right or left) - left + 1, (h.bottom or top) - top + 1) + for index, label in ipairs(self.items) do + local x, y = self:itemPosition(index) + Chrome.print(label, x, y) + end + if self.showCursor then + local x, y = self:itemPosition( + ScriptMenu.choiceIndex(self.row, self.col, self.cols)) + Chrome.cursor(x - 1, y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function ScriptMenu:draw() + self:drawPanel() +end + +return ScriptMenu diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua new file mode 100644 index 00000000..46716336 --- /dev/null +++ b/src/ui/gen2/SlotMachine.lua @@ -0,0 +1,1078 @@ +-- Gold's slot machine (engine/games/slot_machine.asm _SlotMachine), reached by +-- the `special SlotMachine` every Game Corner machine's tile script calls. +-- +-- The machine is not a coin flip with a spinning picture over it. Every spin +-- rolls a BIAS symbol out of a weighted table (Slots_InitBias), and each reel's +-- stop is then MANIPULATED toward that symbol -- or, when the spin is unbiased, +-- deliberately away from every symbol -- for up to four slots past where the +-- player's A press landed. Reel 3 additionally has three near-miss theatres +-- (the slow advance, the Golem drops and the Chansey egg) that only ever run +-- when the first two reels already show matching SEVENs, which is what makes +-- the machine feel like it nearly paid 300 far more often than it can. +-- +-- Everything that decides an outcome is a pure function here, taking a +-- `random(n) -> 0..n-1` the way src/battle/gen2 does, so a test can drive +-- thousands of seeded spins and assert the distribution. The screen half is +-- the only part that touches love. +-- +-- Layout is transcribed from the ASM's own coordinates, never laid out by eye: +-- +-- .PrintCoinsAndPayout hlcoord 5, 1 and hlcoord 11, 1, each PrintNum with +-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits +-- Slots_Lights*OnOff hlcoord 3, 2 / 3, 4 / 3, 6 / 3, 8 / 3, 10, and +-- Slots_TurnLightsOnOrOff writes the second tile of +-- each light at +SCREEN_WIDTH/2+3 (column 16, same +-- row) and the pair below it one row down +-- Slots_InitReelTiles REEL_X_COORD 6, 10 and 14 * TILE_WIDTH -- so the +-- three reels sit in tile columns 6-7, 10-11, 14-15 +-- Slots_UpdateReelPositionAndOAM +-- wCurReelYCoord starts at 10 * TILE_WIDTH and steps +-- up two tiles per symbol. OAM y is 16px above the +-- screen, so the bottom symbol covers tile rows 8-9, +-- the middle 6-7, the top 4-5, and a fourth symbol +-- peeks in at rows 2-3 +-- Slots_AskBet menu_coords 14, 10, 19, 17 with STATICMENU_CURSOR and +-- no STATICMENU_NO_TOP_SPACING, so GetMenuTextStartCoord +-- puts " 3" at (16,12) with the cursor in column 15 and +-- the three labels two rows apart +-- Slots_PayoutText .Text_PrintPayout lays the matched symbol's four +-- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at +-- (18,17) +-- +-- The cart's own reel art (gfx/slots/slots_1..3.2bpp.lz plus +-- gfx/slots/slots.tilemap) is NOT in the cache: src/import/RomExtractorGen2.lua +-- writes no `slots` entry into menu_gfx.lua yet. SlotMachine:sheet() reads one +-- the moment it appears and falls back to labelled cells until then, the same +-- way src/ui/gen2/PackGfx.lua degrades. + +local Chrome = require("src.ui.gen2.Chrome") +local CoinCase = require("src.core.gen2.CoinCase") +local Sound = require("src.core.Sound") + +local SlotMachine = {} +SlotMachine.__index = SlotMachine +SlotMachine.isOpaque = true + +-- ------------------------------------------------------------------ symbols +-- +-- The wSlotMatched constants are a `const_def 0, 4` block, so they step by four +-- and double as the index into every table that is `srl a`'d first (the payout +-- table, the payout strings). Keeping the cart's values rather than 1..6 means +-- those halvings stay literal. +SlotMachine.SEVEN = 0x00 +SlotMachine.POKEBALL = 0x04 +SlotMachine.CHERRY = 0x08 +SlotMachine.PIKACHU = 0x0c +SlotMachine.SQUIRTLE = 0x10 +SlotMachine.STARYU = 0x14 + +-- SLOTS_NO_MATCH and SLOTS_NO_BIAS are both -1 ($ff in the byte). They are +-- different things sharing a value, so they get different names here. +SlotMachine.NO_MATCH = -1 +SlotMachine.NO_BIAS = -1 + +SlotMachine.NAMES = { + [0x00] = "SEVEN", [0x04] = "POKEBALL", [0x08] = "CHERRY", + [0x0c] = "PIKACHU", [0x10] = "SQUIRTLE", [0x14] = "STARYU", +} + +-- What a symbol cell shows while the cart's own 2x2 reel tiles are unextracted. +SlotMachine.LABELS = { + [0x00] = "7", [0x04] = "()", [0x08] = "CH", + [0x0c] = "PI", [0x10] = "SQ", [0x14] = "ST", +} + +-- Slots_GetPayout .PayoutTable, indexed by wSlotMatched srl'd once. The payout +-- does NOT scale with the bet in Gen 2 -- the bet buys extra LINES, not a +-- multiplier, which is the single biggest difference from Red's slots. +SlotMachine.PAYOUTS = { + [0x00] = 300, -- SLOTS_SEVEN + [0x04] = 50, -- SLOTS_POKEBALL + [0x08] = 6, -- SLOTS_CHERRY + [0x0c] = 8, -- SLOTS_PIKACHU + [0x10] = 10, -- SLOTS_SQUIRTLE + [0x14] = 15, -- SLOTS_STARYU +} + +function SlotMachine.payout(matched) + if not matched or matched == SlotMachine.NO_MATCH then return 0 end + return SlotMachine.PAYOUTS[matched] or 0 +end + +-- ------------------------------------------------------------------- reels +-- +-- Reel1Tilemap / Reel2Tilemap / Reel3Tilemap. REEL_SIZE is 15; the first three +-- entries are repeated at the end so Slots_GetCurrentReelState can read three +-- consecutive bytes without wrapping, which is also why the `and $f` mask below +-- is harmless for a slot the reel can actually spin to. +SlotMachine.REEL_SIZE = 15 + +local SEVEN, POKEBALL = 0x00, 0x04 +local CHERRY, PIKACHU, SQUIRTLE, STARYU = 0x08, 0x0c, 0x10, 0x14 + +SlotMachine.REELS = { + -- Reel1Tilemap: three SEVENs' worth of structure -- SEVEN at 0 and 5, and a + -- POKEBALL where the third SEVEN would be, at 10. + { SEVEN, CHERRY, STARYU, PIKACHU, SQUIRTLE, + SEVEN, CHERRY, STARYU, PIKACHU, SQUIRTLE, + POKEBALL, CHERRY, STARYU, PIKACHU, SQUIRTLE, + SEVEN, CHERRY, STARYU }, + -- Reel2Tilemap: one SEVEN, at 0, and POKEBALLs at 5 and 10. + { SEVEN, PIKACHU, CHERRY, SQUIRTLE, STARYU, + POKEBALL, PIKACHU, CHERRY, SQUIRTLE, STARYU, + POKEBALL, PIKACHU, CHERRY, SQUIRTLE, STARYU, + SEVEN, PIKACHU, CHERRY }, + -- Reel3Tilemap: one SEVEN at 0 and one POKEBALL at 10. + { SEVEN, PIKACHU, CHERRY, SQUIRTLE, STARYU, + PIKACHU, CHERRY, SQUIRTLE, STARYU, PIKACHU, + POKEBALL, CHERRY, SQUIRTLE, STARYU, PIKACHU, + SEVEN, PIKACHU, CHERRY }, +} + +-- Slots_GetCurrentReelState, byte for byte: +-- +-- ld a, [REEL_POSITION] / and a / jr nz, .okay / ld a, $f +-- .okay: dec a / and $f +-- +-- so slot 0 reads as if it were 15. The mask is $f (16) while the strip is 15 +-- long, which is only harmless because entries 16-18 repeat entries 1-3: a +-- position of 16 reads the same window a position of 1 does. A SEARCH that +-- walks the position past 16 (Slots_GetNumberOfGolems does) reads a window that +-- is one slot off, and the cart depends on whatever falls out of that. +-- +-- Returns bottom, middle, top -- index 0 is the BOTTOM row, which is what +-- .CheckBottomRow reading wReel1Stopped + 0 establishes. +function SlotMachine.window(strip, position) + local a = position + if a == 0 then a = 0x0f end + a = (a - 1) % 16 + return strip[a + 1], strip[a + 2], strip[a + 3] +end + +-- Slots_UpdateReelPositionAndOAM's tail: inc a / and $f / cp REEL_SIZE / xor a. +function SlotMachine.advance(position) + local a = (position + 1) % 16 + if a == SlotMachine.REEL_SIZE then a = 0 end + return a +end + +-- ------------------------------------------------------------------- lines +-- +-- Slots_CheckMatchedAllThreeReels' jumptable is indexed by `wSlotBet and 3`, +-- and .three FALLS THROUGH into .two, which falls through into .one. ASM +-- fallthrough is not a branch: a bet of 3 runs all five checks on the same +-- frame, a bet of 2 runs three, a bet of 1 runs one. +-- +-- Every check that hits calls .StoreResult, which OVERWRITES wSlotMatched, so +-- the LAST line checked is the one that pays. In fallthrough order that is +-- upward diagonal, downward diagonal, bottom, top, middle -- meaning the middle +-- row wins any tie and only ONE line is ever paid. +-- +-- Each row is { reel1 index, reel2 index, reel3 index }, 1 = bottom. +local UP_DIAG = { 1, 2, 3 } +local DOWN_DIAG = { 3, 2, 1 } +local BOTTOM = { 1, 1, 1 } +local TOP = { 3, 3, 3 } +local MIDDLE = { 2, 2, 2 } + +SlotMachine.LINES = { + [0] = {}, + [1] = { MIDDLE }, + [2] = { BOTTOM, TOP, MIDDLE }, + [3] = { UP_DIAG, DOWN_DIAG, BOTTOM, TOP, MIDDLE }, +} + +local function betLines(bet) + return SlotMachine.LINES[(bet or 0) % 4] or {} +end + +-- Slots_CheckMatchedAllThreeReels. r1/r2/r3 are three-entry windows. +-- Returns the matched symbol, or NO_MATCH. +function SlotMachine.matchAll(bet, r1, r2, r3) + local matched = SlotMachine.NO_MATCH + for _, line in ipairs(betLines(bet)) do + local a = r1[line[1]] + if a == r3[line[3]] and a == r2[line[2]] then matched = a end + end + return matched +end + +-- Slots_CheckMatchedFirstTwoReels. Same jumptable, same fallthrough, but the +-- comparisons are DIFFERENT rows: with only two reels down there is no third +-- column to close a diagonal, so both diagonals and the middle row all test +-- reel 2's middle symbol. +-- +-- .CheckBottomRow r1 bottom vs r2 bottom +-- .CheckUpwardsDiag r1 bottom vs r2 middle +-- .CheckMiddleRow r1 middle vs r2 middle +-- .CheckDownwardsDiag r1 top vs r2 middle +-- .CheckTopRow r1 top vs r2 top +-- +-- Returns the building symbol (or NO_MATCH) and whether it is a SEVEN, which is +-- wFirstTwoReelsMatchingSevens -- the flag every reel-3 theatre gates on. +local TWO_LINES = { + [0] = {}, + [1] = { { 2, 2 } }, + [2] = { { 1, 1 }, { 3, 3 }, { 2, 2 } }, + [3] = { { 1, 2 }, { 3, 2 }, { 1, 1 }, { 3, 3 }, { 2, 2 } }, +} + +function SlotMachine.matchFirstTwo(bet, r1, r2) + local building = SlotMachine.NO_MATCH + for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do + if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end + end + return building, building == SlotMachine.SEVEN +end + +-- ------------------------------------------------------------------- bias +-- +-- Slots_InitBias. `percent` is EQUS "* $ff / 100" (macros/data.asm) with +-- rgbasm's integer division, so "19 percent" is 48 and not 48.45 -- the tables +-- below are the bytes the assembler emits, not the percentages they read as. +-- +-- The scan is `ld a, [hli] / cp c / jr nc, .done`: the first row whose +-- threshold is >= the random byte wins, so the thresholds are cumulative and +-- the last row is 100 percent = 255. +SlotMachine.BIAS_NORMAL = { + { 1, SEVEN }, -- 1 percent - 1 + { 3, POKEBALL }, -- 1 percent + 1 + { 10, STARYU }, -- 4 percent + { 20, SQUIRTLE }, -- 8 percent + { 40, PIKACHU }, -- 16 percent + { 48, CHERRY }, -- 19 percent + { 255, SlotMachine.NO_BIAS }, +} + +-- The luckier table, picked when wScriptVar is non-zero on entry: the Game +-- Corner's scripts pass one machine per room in as the lucky one. +SlotMachine.BIAS_LUCKY = { + { 2, SEVEN }, -- 1 percent + { 3, POKEBALL }, -- 1 percent + 1 + { 8, STARYU }, -- 3 percent + 1 + { 16, SQUIRTLE }, -- 6 percent + 1 + { 30, PIKACHU }, -- 12 percent + { 80, CHERRY }, -- 31 percent + 1 + { 255, SlotMachine.NO_BIAS }, +} + +-- `ld a, [wSlotBias] / and a / ret z` at the top of Slots_InitBias: a spin that +-- is ALREADY biased to SEVEN (value 0) keeps that bias without rerolling. That +-- one instruction is the whole seven streak -- see keepSevenBias below for what +-- ends it. +function SlotMachine.initBias(currentBias, lucky, random) + if currentBias == SlotMachine.SEVEN then return SlotMachine.SEVEN end + local table_ = lucky and SlotMachine.BIAS_LUCKY or SlotMachine.BIAS_NORMAL + local roll = random(256) + for _, row in ipairs(table_) do + if row[1] >= roll then return row[2] end + end + return SlotMachine.NO_BIAS +end + +-- .InitGFX's tail rolls wKeepSevenBiasChance once for the whole session: +-- `call Random / and %00101010 / ret nz` leaves it FALSE 87.5% of the time. +-- Lua 5.1 has no bitwise operators, so a mask is spelled out as the bits it +-- names -- %00101010 is $2a, bits 1, 3 and 5. +local function maskIsZero(value, bits) + for _, bit in ipairs(bits) do + if math.floor(value / bit) % 2 == 1 then return false end + end + return true +end + +function SlotMachine.rollKeepSevenChance(random) + return maskIsZero(random(256), { 2, 8, 32 }) +end + +-- .LinedUpSevens, after the 300-coin fanfare. A SEVEN payout usually DROPS the +-- seven bias; the chance it survives into the next spin is what makes a streak. +-- +-- Oddly, the rarer session flag (wKeepSevenBiasChance = TRUE, 12.5% of visits) +-- is the one with the WORSE streak odds, 12.5% against 25% -- the ASM's own +-- comment flags this as probably-inverted, and it is transcribed as written. +function SlotMachine.keepSevenBias(keepSevenChance, random) + -- keepSevenChance: and %0011100 ($1c, three bits) -> 1 in 8 + -- otherwise: and %0010100 ($14, two bits) -> 1 in 4 + local mask = keepSevenChance and { 4, 8, 16 } or { 4, 16 } + return maskIsZero(random(256), mask) +end + +-- ------------------------------------------------------------- reel stops +-- +-- Each ReelAction_StopReel* runs once per SLOT (Slots_SpinReel only calls the +-- action jumptable when the spin distance's low nibble is zero), and either +-- stops the reel there or lets it turn one more slot and asks again. Running +-- that decision to a fixed point up front gives exactly the slot the cart +-- reaches, so the screen spins toward a known stop instead of carrying the +-- whole ReelAction jumptable. +-- +-- REEL_MANIP_COUNTER starts at 4 for every reel (SlotsAction_BetAndStart). +SlotMachine.MANIP_COUNTER = 4 + +-- The loop is bounded on the cart by the reel coming back around; the guard +-- here is a full strip plus the manipulation budget, and reaching it stops the +-- reel the way Slots_StopReel would. +local SEARCH_LIMIT = SlotMachine.REEL_SIZE * 4 + +-- ReelAction_StopReel1: with no bias, stop where the player pressed. With a +-- bias, walk up to four slots looking for the biased symbol ANYWHERE in reel +-- one's three-symbol window -- even on a line the current bet does not buy. +function SlotMachine.stopReel1(position, bias) + local strip = SlotMachine.REELS[1] + local manip = SlotMachine.MANIP_COUNTER + for _ = 1, SEARCH_LIMIT do + if bias == SlotMachine.NO_BIAS or manip == 0 then return position end + manip = manip - 1 + local a, b, c = SlotMachine.window(strip, position) + if a == bias or b == bias or c == bias then return position end + position = SlotMachine.advance(position) + end + return position +end + +-- ReelAction_StopReel2: stop early once reels one and two are already building +-- the biased symbol on a line this bet buys, otherwise burn the four slots. +function SlotMachine.stopReel2(position, bias, bet, stopped1) + local strip = SlotMachine.REELS[2] + local manip = SlotMachine.MANIP_COUNTER + for _ = 1, SEARCH_LIMIT do + local window = { SlotMachine.window(strip, position) } + local building = SlotMachine.matchFirstTwo(bet, stopped1, window) + if building ~= SlotMachine.NO_MATCH and building == bias then + return position + end + if bias == SlotMachine.NO_BIAS or manip == 0 then return position end + manip = manip - 1 + position = SlotMachine.advance(position) + end + return position +end + +-- ReelAction_StopReel3, and the one place the "no bias means no win" rule is +-- actually enforced: +-- +-- * a line that matches the bias stops the reel dead +-- * a line that matches ANYTHING ELSE keeps the reel turning, manip counter +-- or not (`ret z` returns without stopping, it does not fall through) +-- * no line at all stops the reel, unless a bias is still being hunted and +-- the four-slot budget has not run out +function SlotMachine.stopReel3(position, bias, bet, stopped1, stopped2) + local strip = SlotMachine.REELS[3] + local manip = SlotMachine.MANIP_COUNTER + for _ = 1, SEARCH_LIMIT do + local window = { SlotMachine.window(strip, position) } + local matched = SlotMachine.matchAll(bet, stopped1, stopped2, window) + if matched ~= SlotMachine.NO_MATCH then + if matched == bias then return position end + if manip > 0 then manip = manip - 1 end + else + if bias == SlotMachine.NO_BIAS or manip == 0 then return position end + manip = manip - 1 + end + position = SlotMachine.advance(position) + end + return position +end + +-- ---------------------------------------------------- reel 2's skip-to-seven +-- +-- Slots_StopReel2's alternative: with a bet of 2 or more, a SEVEN visible +-- anywhere in reel one, and a spin that is either unbiased or biased to SEVEN, +-- there is a 31.25% chance (`cp 31 percent + 1 / jr nc` = 80/256) that reel two +-- ignores the player entirely, pauses, and then fast-spins until the two reels +-- line up SEVENs. +-- +-- It is almost always a tease: lining up two SEVENs is what UNLOCKS the reel-3 +-- theatres below, and those only pay when the bias was SEVEN to begin with. +function SlotMachine.reel2SkipsToSeven(bet, bias, stopped1, random) + if (bet or 0) < 2 then return false end + if bias ~= SlotMachine.SEVEN and bias ~= SlotMachine.NO_BIAS then + return false + end + -- .CheckReel1ForASeven returns z only when one of the three is SLOTS_SEVEN, + -- which is zero -- so the test really is "any zero in the window". + if not (stopped1[1] == SlotMachine.SEVEN or stopped1[2] == SlotMachine.SEVEN + or stopped1[3] == SlotMachine.SEVEN) then + return false + end + return random(256) < 80 +end + +-- ReelAction_FastSpinReel2UntilLinedUp7s: keep turning until the two reels show +-- matching SEVENs on a line this bet buys. +function SlotMachine.spinReel2ToSevens(position, bet, stopped1) + local strip = SlotMachine.REELS[2] + for _ = 1, SEARCH_LIMIT do + local window = { SlotMachine.window(strip, position) } + local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window) + if building ~= SlotMachine.NO_MATCH and sevens then return position end + position = SlotMachine.advance(position) + end + return position +end + +-- ------------------------------------------------------- reel 3's theatre +-- +-- Slots_StopReel3's action roll, which only happens when the first two reels +-- already show matching SEVENs. The ASM's `.biased` label is misleading: it is +-- reached when the bias is NOT SEVEN (including no bias at all), and the +-- fallthrough above it is the bias-to-SEVEN case. The ASM's own comment block +-- confirms which set of odds belongs to which. +SlotMachine.REEL3_STOP = "stop" +SlotMachine.REEL3_SLOW = "slowAdvance" +SlotMachine.REEL3_GOLEM = "golem" +SlotMachine.REEL3_EGG = "chansey" + +function SlotMachine.reel3Action(matchingSevens, bias, random) + if not matchingSevens then return SlotMachine.REEL3_STOP end + local r = random(256) + if bias == SlotMachine.SEVEN then + -- cp 71 percent - 1 (180) / cp 47 percent + 1 (120) / cp 24 percent - 1 (60) + if r >= 180 then return SlotMachine.REEL3_STOP end -- 29.7% + if r >= 120 then return SlotMachine.REEL3_SLOW end -- 23.4% + if r >= 60 then return SlotMachine.REEL3_GOLEM end -- 23.4% + return SlotMachine.REEL3_EGG -- 23.4% + end + -- cp 63 percent (160) / cp 31 percent + 1 (80). Chansey is unreachable here, + -- which is why the egg is the rarest thing on the machine. + if r >= 160 then return SlotMachine.REEL3_STOP end -- 37.5% + if r >= 80 then return SlotMachine.REEL3_SLOW end -- 31.25% + return SlotMachine.REEL3_GOLEM -- 31.25% +end + +-- The predicate every theatre spins toward, from ReelAction_WaitSlowAdvanceReel3 +-- .check1 / .check2: biased to SEVEN means "keep going until SEVENs line up", +-- anything else means "keep going until NOTHING lines up". +local function theatreSatisfied(bias, matched) + if bias == SlotMachine.SEVEN then return matched == SlotMachine.SEVEN end + return matched == SlotMachine.NO_MATCH +end + +function SlotMachine.slowAdvance(position, bias, bet, stopped1, stopped2) + local strip = SlotMachine.REELS[3] + for _ = 1, SEARCH_LIMIT do + local window = { SlotMachine.window(strip, position) } + local matched = SlotMachine.matchAll(bet, stopped1, stopped2, window) + if theatreSatisfied(bias, matched) then return position end + position = SlotMachine.advance(position) + end + return position +end + +-- Slots_GetNumberOfGolems. Two different searches, and only one of them is +-- honest: +-- +-- biased to SEVEN the position is stepped ONE slot per Golem until SEVENs +-- line up, and the count returned is exactly that many +-- steps -- so the reel really does land where the search +-- looked (1 to 14 Golems) +-- anything else the search steps by a growing stride (a random 4..7, then +-- 5, 6, ...) while the returned count is the FINAL stride, +-- and each Golem only advances the reel one slot. The reel +-- therefore lands somewhere the search never checked, and +-- ReelAction_WaitGolem `.two` stops it there regardless. +-- That mismatch is the cart's, not a shortcut here. +-- +-- Returns the Golem count; the reel ends up `count` slots on from where it was. +function SlotMachine.golemCount(position, bias, bet, stopped1, stopped2, random) + local strip = SlotMachine.REELS[3] + if bias == SlotMachine.SEVEN then + local walk, count = position, 0 + for _ = 1, SEARCH_LIMIT do + walk = walk + 1 + count = count + 1 + local window = { SlotMachine.window(strip, walk) } + local matched = SlotMachine.matchAll(bet, stopped1, stopped2, window) + if matched == SlotMachine.SEVEN then return count end + end + return count + end + -- `call Random / and $7 / cp $8 / 2 / jr c` rerolls until the low three bits + -- are 4..7. + local stride = random(8) + while stride < 4 do stride = random(8) end + local walk = position + for _ = 1, SEARCH_LIMIT do + walk = walk + stride + stride = stride + 1 + local window = { SlotMachine.window(strip, walk) } + local matched = SlotMachine.matchAll(bet, stopped1, stopped2, window) + if matched == SlotMachine.NO_MATCH then return stride end + end + return stride +end + +-- ReelAction_DropReel: Chansey's egg drops the reel 17 slots at a time and +-- re-checks, over and over, until SEVENs are lined up. 17 slots is two slots +-- of net movement per egg on a 15-slot reel, which is why the egg keeps +-- falling for so long. +SlotMachine.EGG_DROP = 17 + +function SlotMachine.eggDrops(position, bet, stopped1, stopped2) + local strip = SlotMachine.REELS[3] + local drops = 0 + for _ = 1, SEARCH_LIMIT do + for _ = 1, SlotMachine.EGG_DROP do + position = SlotMachine.advance(position) + end + drops = drops + 1 + local window = { SlotMachine.window(strip, position) } + local matched = SlotMachine.matchAll(bet, stopped1, stopped2, window) + -- `.check_match: jr nc, .EggAgain / and a / jr nz, .EggAgain` -- it takes a + -- match AND that match being SEVEN to settle. + if matched == SlotMachine.SEVEN then return position, drops end + end + return position, drops +end + +-- ------------------------------------------------------------- whole spin +-- +-- One spin resolved end to end, for the tests and for anything that wants the +-- machine's numbers without its animation. `stops` is where the player's three +-- A presses landed (0-based slot per reel, the way REEL_POSITION reads). +-- +-- Returns { matched, payout, bias, positions, reel3Action }. +function SlotMachine.spin(opts) + local random = opts.random + local bet = opts.bet or 1 + local bias = SlotMachine.initBias(opts.bias or SlotMachine.NO_BIAS, + opts.lucky, random) + local stops = opts.stops or { 0, 0, 0 } + + local p1 = SlotMachine.stopReel1(stops[1], bias) + local r1 = { SlotMachine.window(SlotMachine.REELS[1], p1) } + + local p2 + if SlotMachine.reel2SkipsToSeven(bet, bias, r1, random) then + p2 = SlotMachine.spinReel2ToSevens(stops[2], bet, r1) + else + p2 = SlotMachine.stopReel2(stops[2], bias, bet, r1) + end + local r2 = { SlotMachine.window(SlotMachine.REELS[2], p2) } + local _, matchingSevens = SlotMachine.matchFirstTwo(bet, r1, r2) + + local action = SlotMachine.reel3Action(matchingSevens, bias, random) + local p3 = stops[3] + if action == SlotMachine.REEL3_STOP then + p3 = SlotMachine.stopReel3(p3, bias, bet, r1, r2) + elseif action == SlotMachine.REEL3_SLOW then + p3 = SlotMachine.slowAdvance(p3, bias, bet, r1, r2) + elseif action == SlotMachine.REEL3_GOLEM then + local count = SlotMachine.golemCount(p3, bias, bet, r1, r2, random) + for _ = 1, count do p3 = SlotMachine.advance(p3) end + else + p3 = SlotMachine.eggDrops(p3, bet, r1, r2) + end + local r3 = { SlotMachine.window(SlotMachine.REELS[3], p3) } + + local matched = SlotMachine.matchAll(bet, r1, r2, r3) + return { + matched = matched, + payout = SlotMachine.payout(matched), + bias = bias, + reel3Action = action, + positions = { p1, p2, p3 }, + windows = { r1, r2, r3 }, + } +end + +-- ------------------------------------------------------------------ layout +local COINS_X, COINS_Y = 5, 1 +local PAYOUT_X, PAYOUT_Y = 11, 1 +-- REEL_X_COORD / TILE_WIDTH. +local REEL_X = { 6, 10, 14 } +-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which +-- sits 16px above the screen) to tile rows: bottom, middle, top, and the +-- fourth symbol that only half shows. +local REEL_ROW = { 8, 6, 4, 2 } +-- Slots_Lights3OnOff / 2 / 1: the row each bet's pair of lights is drawn on, +-- and the two columns Slots_TurnLightsOnOrOff writes. +local LIGHT_ROWS = { [3] = { 2, 10 }, [2] = { 4, 8 }, [1] = { 6 } } +local LIGHT_COLS = { 3, 16 } +-- Slots_AskBet's MenuHeader through GetMenuTextStartCoord. +local BET_BOX_X, BET_BOX_Y, BET_BOX_W, BET_BOX_H = 14, 10, 6, 8 +local BET_LABEL_X, BET_LABEL_Y, BET_SPACING = 16, 12, 2 +-- .Text_PrintPayout's four ldcoord_a writes. +local PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y = 2, 13 +-- Textbox(0, TEXTBOX_Y) -- the standard speech box every PrintText here uses. +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +-- ------------------------------------------------------------------- text +-- +-- data/text/common_2.asm and common_3.asm. None of these are in the cache's +-- text.lua: no script bytecode points at them, so the extractor -- which walks +-- reachable script pointers -- never reaches them. +local TEXTS = { + betHowMany = { "Bet how many", "coins?" }, -- _SlotsBetHowManyCoinsText + start = { "Start!" }, -- _SlotsStartText + notEnough = { "Not enough", "coins." }, -- _SlotsNotEnoughCoinsText + ranOut = { "Darn… Ran out of", "coins…" }, -- _SlotsRanOutOfCoinsText + playAgain = { "Play again?" }, -- _SlotsPlayAgainText + darn = { "Darn!" }, -- _SlotsDarnText +} +SlotMachine.TEXTS = TEXTS + +-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the +-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout. +local function linedUpLines(payout) + return { "lined up!", ("Won %d coins!"):format(payout) } +end + +-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what +-- maps the shared UI's own names onto these, never the other way round). +local SFX_START = "Sfx_SlotMachineStart" +local SFX_STOP = "Sfx_StopSlot" +local SFX_PAY_DAY = "Sfx_PayDay" +local SFX_COIN = "Sfx_GetCoinFromSlots" +local SFX_QUIT = "Sfx_QuitSlots" +local SFX_SEVENS = "Sfx_2ndPlace" +local SFX_POKEBALLS = "Sfx_3rdPlace" +local SFX_SMALL_WIN = "Sfx_Present" + +-- ------------------------------------------------------------------ screen +function SlotMachine:wantsFillScale() return true end +function SlotMachine:drawsWidescreen() return true end + +-- opts: save, lucky (wScriptVar on entry), random(n), onClose() +function SlotMachine.new(game, opts) + opts = opts or {} + local self = setmetatable({}, SlotMachine) + self.game = game + self.save = opts.save or (game and game.save) + self.lucky = opts.lucky or false + self.onClose = opts.onClose + self.random = opts.random or function(n) + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 + end + -- .InitGFX's tail, rolled once for the whole visit to the machine. + self.keepSevenChance = SlotMachine.rollKeepSevenChance(self.random) + self.bias = SlotMachine.NO_BIAS + self.payoutLeft = 0 + self.positions = { 0, 0, 0 } + self.distance = { 0, 0, 0 } + self.rate = { 0, 0, 0 } + self.stops = { nil, nil, nil } + self:playMusic() + self:enterInit() + return self +end + +function SlotMachine:playMusic() + local data = self.game and self.game.data + if not data then return end + require("src.core.Music").play(data, "Music_GameCorner") +end + +function SlotMachine:sfx(name) + local data = self.game and self.game.data + if data then Sound.play(data, name) end +end + +function SlotMachine:coins() + return CoinCase.coins(self.save) +end + +-- ---------------------------------------------------------------- phases +-- +-- SlotsAction_Init: clear the match flags, then straight into the bet menu. +function SlotMachine:enterInit() + self.matched = SlotMachine.NO_MATCH + self.matchingSevens = false + self.phase = "bet" + self.betIndex = 1 -- `db 1 ; default option`, which is the " 3" row + self.message = nil +end + +-- Slots_AskBet: `ld a, 4 / sub b` turns the cursor row into the bet, so the +-- top row is three coins and the bottom is one. +local BET_ROWS = { " 3", " 2", " 1" } + +function SlotMachine:updateBet(input) + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then self.message = nil end + return + end + if input:wasPressed("up") then + self.betIndex = self.betIndex > 1 and self.betIndex - 1 or #BET_ROWS + return + elseif input:wasPressed("down") then + self.betIndex = self.betIndex < #BET_ROWS and self.betIndex + 1 or 1 + return + elseif input:wasPressed("b") then + -- VerticalMenu returning carry is what SlotsAction_BetAndStart reads as + -- SLOTS_QUIT. + self:quit() + return + elseif input:wasPressed("a") then + local bet = 4 - self.betIndex + if self:coins() < bet then + self.message = TEXTS.notEnough + return + end + self.bet = bet + CoinCase.takeCoins(self.save, bet) + self:sfx(SFX_PAY_DAY) + self:startSpin() + end +end + +-- SlotsAction_BetAndStart's tail: the bias for THIS spin, all three reels at +-- REEL_ACTION_NORMAL_RATE, and 32 frames of wSlotsDelay before A does anything. +function SlotMachine:startSpin() + self.bias = SlotMachine.initBias(self.bias, self.lucky, self.random) + self.phase = "spinning" + self.reel = 1 + self.delay = 32 + self.message = TEXTS.start + for i = 1, 3 do + self.rate[i] = 4 -- ReelAction_NormalRate + self.stops[i] = nil + end + self:sfx(SFX_START) +end + +-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot +-- boundary, and the position advances when the distance's low nibble wraps. +function SlotMachine:spinReels() + for i = 1, 3 do + local rate = self.rate[i] + if rate > 0 then + self.distance[i] = (self.distance[i] + rate) % 256 + if self.distance[i] % 16 == 0 then + self.positions[i] = SlotMachine.advance(self.positions[i]) + -- A reel with a resting slot chosen stops the moment it reaches it. + if self.stops[i] and self.positions[i] == self.stops[i] then + self.rate[i] = 0 + self.stopped = self.stopped or {} + self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i], + self.positions[i]) } + self:sfx(SFX_STOP) + self:reelStopped(i) + end + end + end + end +end + +-- The three symbols reel `i` is currently showing. Named apart from +-- SlotMachine.window so the instance method cannot shadow the pure one. +function SlotMachine:reelWindow(i) + return { SlotMachine.window(SlotMachine.REELS[i], self.positions[i]) } +end + +-- SlotsAction_WaitReel1 / 2 / 3: A picks the slot, and the reel then turns to +-- wherever the manipulation put it. +function SlotMachine:pressStop() + local i = self.reel + if self.stops[i] then return end + local here = self.positions[i] + if i == 1 then + self.stops[1] = SlotMachine.stopReel1(here, self.bias) + elseif i == 2 then + local r1 = self.stopped[1] + if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then + -- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then + -- fast-spins it at double rate; the pause is the tell. + self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1) + self.rate[2] = 8 + else + self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1) + end + else + local r1, r2 = self.stopped[1], self.stopped[2] + local _, sevens = SlotMachine.matchFirstTwo(self.bet, r1, r2) + self.matchingSevens = sevens + local action = SlotMachine.reel3Action(sevens, self.bias, self.random) + self.reel3Action = action + if action == SlotMachine.REEL3_STOP then + self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2) + elseif action == SlotMachine.REEL3_SLOW then + self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2) + self.rate[3] = 1 -- ReelAction_QuarterRate + elseif action == SlotMachine.REEL3_GOLEM then + local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2, + self.random) + local target = here + for _ = 1, count do target = SlotMachine.advance(target) end + self.stops[3] = target + self.golems = count + self.rate[3] = 8 + else + local target = SlotMachine.eggDrops(here, self.bet, r1, r2) + self.stops[3] = target + self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop + end + end + -- A reel already sitting on its resting slot has nowhere to turn. + if self.stops[i] == self.positions[i] then + self.rate[i] = 0 + self.stopped = self.stopped or {} + self.stopped[i] = self:reelWindow(i) + self:sfx(SFX_STOP) + self:reelStopped(i) + end +end + +function SlotMachine:reelStopped(i) + if i < 3 then + self.reel = i + 1 + return + end + -- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames + -- before the payout is counted out; a loss skips straight past it. + local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3] + self.matched = SlotMachine.matchAll(self.bet, r1, r2, r3) + if self.matched == SlotMachine.NO_MATCH then + self.phase = "payoutText" + self.message = TEXTS.darn + return + end + self.phase = "flash" + self.flash = 16 +end + +-- SlotsAction_GiveEarnedCoins / SlotsAction_PayoutTextAndAnim: the payout +-- counter is filled from the table, the fanfare plays, and then the coins tick +-- across one per two frames. +function SlotMachine:beginPayout() + self.payoutLeft = SlotMachine.payout(self.matched) + self.payoutTick = 0 + self.phase = "payoutText" + self.message = linedUpLines(self.payoutLeft) + if self.matched == SlotMachine.SEVEN then + self:sfx(SFX_SEVENS) + -- .LinedUpSevens decides here whether the seven streak survives. + if not SlotMachine.keepSevenBias(self.keepSevenChance, self.random) then + self.bias = SlotMachine.NO_BIAS + end + elseif self.matched == SlotMachine.POKEBALL then + self:sfx(SFX_POKEBALLS) + else + self:sfx(SFX_SMALL_WIN) + end +end + +-- SlotsAction_PayoutAnim: `ld a, [hl] / inc [hl] / and $1` -- one coin every +-- other frame, and the coin case clamp is checked BEFORE the increment, so a +-- full case eats the rest of the payout. +function SlotMachine:updatePayoutAnim() + self.payoutTick = (self.payoutTick or 0) + 1 + if self.payoutTick % 2 == 1 then return end + if self.payoutLeft <= 0 then + self.phase = "again" + return + end + self.payoutLeft = self.payoutLeft - 1 + CoinCase.giveCoins(self.save, 1) + if self.payoutTick % 8 == 0 then self:sfx(SFX_COIN) end +end + +-- Slots_AskPlayAgain: no coins left is not a question, it is the exit. +function SlotMachine:enterAgain() + if self:coins() <= 0 then + self.phase = "ranOut" + self.message = TEXTS.ranOut + self.ranOutDelay = 60 -- `ld c, 60 / call DelayFrames` + return + end + self.phase = "again" + self.againChoice = 1 + self.message = TEXTS.playAgain +end + +function SlotMachine:quit() + self.phase = "quit" + self:sfx(SFX_QUIT) + local data = self.game and self.game.data + if data then require("src.core.Music").restoreMap(data) end + if self.onClose then self.onClose() end +end + +function SlotMachine:update(_dt) + local input = self.game and self.game.input + if not input then return end + local phase = self.phase + + if phase == "bet" then + self:updateBet(input) + return + end + + if phase == "spinning" then + -- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the + -- bet menu cannot stop reel one. + if self.delay > 0 then + self.delay = self.delay - 1 + self:spinReels() + return + end + self.message = nil + if input:wasPressed("a") then self:pressStop() end + self:spinReels() + return + end + + if phase == "flash" then + self.flash = self.flash - 1 + if self.flash <= 0 then self:beginPayout() end + return + end + + if phase == "payoutText" then + if self.matched == SlotMachine.NO_MATCH then + if input:wasPressed("a") or input:wasPressed("b") then + self:enterAgain() + end + return + end + self:updatePayoutAnim() + if self.phase == "again" then self:enterAgain() end + return + end + + if phase == "again" then + if input:wasPressed("up") or input:wasPressed("down") then + self.againChoice = self.againChoice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self:quit() + return + end + if input:wasPressed("a") then + if self.againChoice == 1 then self:enterInit() else self:quit() end + end + return + end + + if phase == "ranOut" then + self.ranOutDelay = self.ranOutDelay - 1 + if self.ranOutDelay <= 0 then self:quit() end + return + end +end + +-- ------------------------------------------------------------------- draw +-- +-- The cart's reel art is unextracted (see the header), so a symbol draws as its +-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches +-- this to the real tiles without any other change. +function SlotMachine:sheet() + if self.sheetCache == nil then + local data = self.game and self.game.data + local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots + if gfx and gfx.image then + local TileSheet = require("src.ui.gen2.TileSheet") + self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16, + firstTile = gfx.firstTile or 0 }) + else + self.sheetCache = false + end + end + return self.sheetCache or nil +end + +local function cell(tx, ty, label) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("line", tx * 8, ty * 8, 16, 16) + Chrome.print(label, tx, ty + 1) +end + +function SlotMachine:drawReels() + for i = 1, 3 do + local strip = SlotMachine.REELS[i] + local position = self.positions[i] + -- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays + -- them bottom upward, which is what the three repeated entries at the end + -- of each strip are for: position 14 reads indices 14, 15, 16 and 17 + -- without wrapping. Only the lower three are on a pay line; the fourth + -- half-shows at the top of the window. + for row = 1, 4 do + local symbol = strip[position + row] + cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?") + end + end +end + +function SlotMachine:drawLights() + local lit = {} + -- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller + -- one: `dec a / jr z` falls through from three to two to one. + for bet = 1, (self.bet or 0) do + for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end + end + for _, row in ipairs({ 2, 4, 6, 8, 10 }) do + for _, col in ipairs(LIGHT_COLS) do + Chrome.print(lit[row] and "*" or "-", col, row) + end + end +end + +function SlotMachine:drawMessage() + if not self.message then return end + Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) + for i, line in ipairs(self.message) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end + if self.matched and self.matched ~= SlotMachine.NO_MATCH + and self.phase == "payoutText" then + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, + SlotMachine.LABELS[self.matched] or "?") + end +end + +function SlotMachine:drawPanel() + Chrome.clear() + self:drawLights() + -- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters. + Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y) + Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y) + self:drawReels() + if self.phase == "bet" then + Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2) + for i, label in ipairs(BET_ROWS) do + local ty = BET_LABEL_Y + (i - 1) * BET_SPACING + if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end + Chrome.print(label, BET_LABEL_X, ty) + end + if not self.message then + Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2) + for i, line in ipairs(TEXTS.betHowMany) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end + end + end + self:drawMessage() + if self.phase == "again" then + -- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13). + Chrome.textbox(14, 12, 4, 3) + Chrome.print("YES", 16, 13) + Chrome.print("NO", 16, 15) + Chrome.cursor(15, 13 + (self.againChoice - 1) * 2) + end +end + +function SlotMachine:draw() + self:drawPanel() +end + +function SlotMachine: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return SlotMachine diff --git a/src/ui/gen2/SpriteAnims.lua b/src/ui/gen2/SpriteAnims.lua new file mode 100644 index 00000000..4129d9ee --- /dev/null +++ b/src/ui/gen2/SpriteAnims.lua @@ -0,0 +1,938 @@ +-- The Gen 2 sprite-animation runtime (pokegold engine/sprite_anims/core.asm) +-- plus the slice of its data tables the Gold/Silver intro movie uses. +-- +-- The cart draws every moving thing in the intro as OBJs driven by this one +-- system, and the movie's character comes from it: Lapras's bob, Pikachu's +-- charge, the fireball spiralling outward. So this is a transcription rather +-- than an approximation -- ten struct slots, a frameset that steps OAM sets on +-- a per-frame duration, and one `AnimSeq_*` function per object that moves the +-- struct around between frames. +-- +-- * data/sprite_anims/objects.asm -> OBJECTS (frameset + sequence) +-- * data/sprite_anims/framesets.asm-> FRAMESETS (oamframe lists) +-- * data/sprite_anims/oam.asm -> OAMSETS (vtile offset + dbsprites) +-- * engine/sprite_anims/functions.asm -> SEQUENCES +-- +-- Coordinates are hardware OAM coordinates: a struct's x/y are the byte the +-- `depixel` macro built, and a drawn object sits at (x - 8, y - 16) on screen. +-- Everything is 8-bit and wraps, which several of the sequences rely on -- +-- Lapras walks its x down past 0 to $d0 to decide it has left the screen. +-- +-- The intro never populates wSpriteAnimDict with a live entry (IntroScene1/6/10 +-- write SPRITE_ANIM_DICT_* -> $00), so GetSpriteAnimVTile always resolves to a +-- base vtile of 0 and the struct's TILE_ID is dropped here. + +local bit = require("bit") + +local SpriteAnims = {} + +local NUM_STRUCTS = 10 -- NUM_SPRITE_ANIM_STRUCTS +-- wShadowOAM is 40 objects; UpdateAnimFrame returns carry once it is full and +-- DoNextFrameForAllSprites stops walking the structs. +local OAM_LIMIT = 40 + +local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20 +local OAM_FLAG_MASK = 0xe0 -- OAM_PRIO | OAM_YFLIP | OAM_XFLIP + +-------------------------------------------------------------------------- +-- Sine +-------------------------------------------------------------------------- + +-- engine/math/sine.asm `sine_table 32`: sin(x * pi/32) in Q8.8, so entry 16 +-- is exactly $100. calc_sine_wave masks the angle to six bits and mirrors the +-- upper half, giving a full period over 64 steps. +local SINE = {} +for index = 0, 31 do + SINE[index] = math.floor(math.sin(index * math.pi / 32) * 256 + 0.5) +end +SpriteAnims.SINE = SINE + +-- a = d * sin(a * pi/32), returned as the byte the ASM leaves in a (so a +-- negative result comes back in two's complement, ready to be added to a +-- coordinate). +function SpriteAnims.sine(angle, amplitude) + angle = angle % 64 + local negative = angle >= 32 + if negative then angle = angle - 32 end + -- The multiply accumulates into hl and only the high byte survives. + local product = (amplitude % 256) * SINE[angle] % 0x10000 + local result = math.floor(product / 256) + if negative then result = -result end + return result % 256 +end + +-- Sprites_Cosine: cos(x) = sin(x + pi/2), i.e. eight table steps on. +function SpriteAnims.cosine(angle, amplitude) + return SpriteAnims.sine(angle + 0x10, amplitude) +end + +-------------------------------------------------------------------------- +-- data/sprite_anims/oam.asm +-------------------------------------------------------------------------- + +-- `dbsprite x tile, y tile, x pixel, y pixel, vtile offset, attributes` +-- emits the y byte first, and both tile counts are taken mod $100 so a +-- negative column becomes $f8. +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 + +local OAMDATA = { + OAMData_1x1_Palette0 = { + s( -1, -1, 4, 4, 0x00, 0x00), + }, + OAMData_GSIntroShellder = { + s( -1, -1, 0, 0, 0x00, 0x00), + s( 0, -1, 0, 0, 0x01, 0x00), + s( -1, 0, 0, 0, 0x10, 0x00), + s( 0, 0, 0, 0, 0x11, 0x00), + }, + OAMData_GSIntroMagikarp = { + s( -2, -1, 4, 0, 0x00, 0x01), + s( -1, -1, 4, 0, 0x01, 0x01), + s( 0, -1, 4, 0, 0x02, 0x01), + s( -2, 0, 4, 0, 0x10, 0x01), + s( -1, 0, 4, 0, 0x11, 0x01), + s( 0, 0, 4, 0, 0x12, 0x01), + }, + OAMData_GSIntroLapras1 = { + s( -3, -3, 0, 0, 0x00, 0x00), + s( -2, -3, 0, 0, 0x01, 0x00), + s( -1, -3, 0, 0, 0x02, 0x00), + s( -3, -2, 0, 0, 0x10, 0x00), + s( -2, -2, 0, 0, 0x11, 0x00), + s( -1, -2, 0, 0, 0x12, 0x00), + s( -3, -1, 0, 0, 0x20, 0x00), + s( -2, -1, 0, 0, 0x21, 0x00), + s( -1, -1, 0, 0, 0x22, 0x00), + s( 0, -1, 0, 0, 0x23, 0x00), + s( -3, 0, 0, 0, 0x30, 0x80), + s( -2, 0, 0, 0, 0x31, 0x80), + s( -1, 0, 0, 0, 0x32, 0x80), + s( 0, 0, 0, 0, 0x33, 0x80), + s( 1, 0, 0, 0, 0x34, 0x80), + s( -3, 1, 0, 0, 0x40, 0x80), + s( -2, 1, 0, 0, 0x41, 0x80), + s( -1, 1, 0, 0, 0x42, 0x80), + s( 0, 1, 0, 0, 0x43, 0x80), + s( 1, 1, 0, 0, 0x44, 0x80), + s( 2, 1, 0, 0, 0x45, 0x80), + s( -3, 2, 0, 0, 0x50, 0x80), + s( -2, 2, 0, 0, 0x51, 0x80), + s( -1, 2, 0, 0, 0x52, 0x80), + s( 0, 2, 0, 0, 0x53, 0x80), + s( 1, 2, 0, 0, 0x54, 0x80), + s( 2, 2, 0, 0, 0x55, 0x80), + }, + OAMData_GSIntroLapras2 = { + s( -3, -3, 0, 0, 0x0d, 0x00), + s( -2, -3, 0, 0, 0x0e, 0x00), + s( -1, -3, 0, 0, 0x0f, 0x00), + s( -3, -2, 0, 0, 0x1d, 0x00), + s( -2, -2, 0, 0, 0x1e, 0x00), + s( -1, -2, 0, 0, 0x1f, 0x00), + s( -3, -1, 0, 0, 0x20, 0x00), + s( -2, -1, 0, 0, 0x21, 0x00), + s( -1, -1, 0, 0, 0x22, 0x00), + s( 0, -1, 0, 0, 0x23, 0x00), + s( -3, 0, 0, 0, 0x30, 0x80), + s( -2, 0, 0, 0, 0x31, 0x80), + s( -1, 0, 0, 0, 0x32, 0x80), + s( 0, 0, 0, 0, 0x33, 0x80), + s( 1, 0, 0, 0, 0x34, 0x80), + s( -3, 1, 0, 0, 0x40, 0x80), + s( -2, 1, 0, 0, 0x41, 0x80), + s( -1, 1, 0, 0, 0x42, 0x80), + s( 0, 1, 0, 0, 0x43, 0x80), + s( 1, 1, 0, 0, 0x44, 0x80), + s( 2, 1, 0, 0, 0x45, 0x80), + s( -3, 2, 0, 0, 0x50, 0x80), + s( -2, 2, 0, 0, 0x51, 0x80), + s( -1, 2, 0, 0, 0x52, 0x80), + s( 0, 2, 0, 0, 0x53, 0x80), + s( 1, 2, 0, 0, 0x54, 0x80), + s( 2, 2, 0, 0, 0x55, 0x80), + }, + OAMData_GSIntroLapras3 = { + s( -3, -3, 0, 0, 0x00, 0x00), + s( -2, -3, 0, 0, 0x01, 0x00), + s( -1, -3, 0, 0, 0x02, 0x00), + s( 0, -3, 0, 0, 0x03, 0x00), + s( -3, -2, 0, 0, 0x10, 0x00), + s( -2, -2, 0, 0, 0x11, 0x00), + s( -1, -2, 0, 0, 0x12, 0x00), + s( 0, -2, 0, 0, 0x13, 0x00), + s( -3, -1, 0, 0, 0x20, 0x00), + s( -2, -1, 0, 0, 0x21, 0x00), + s( -1, -1, 0, 0, 0x22, 0x00), + s( 0, -1, 0, 0, 0x23, 0x00), + s( 1, -1, 0, 0, 0x24, 0x00), + s( -3, 0, 0, 0, 0x30, 0x80), + s( -2, 0, 0, 0, 0x31, 0x80), + s( -1, 0, 0, 0, 0x32, 0x80), + s( 0, 0, 0, 0, 0x33, 0x80), + s( 1, 0, 0, 0, 0x34, 0x80), + s( -3, 1, 0, 0, 0x40, 0x80), + s( -2, 1, 0, 0, 0x41, 0x80), + s( -1, 1, 0, 0, 0x42, 0x80), + s( 0, 1, 0, 0, 0x43, 0x80), + s( 1, 1, 0, 0, 0x44, 0x80), + s( 2, 1, 0, 0, 0x45, 0x80), + s( -2, 2, 0, 0, 0x51, 0x80), + s( -1, 2, 0, 0, 0x52, 0x80), + s( 0, 2, 0, 0, 0x53, 0x80), + s( 1, 2, 0, 0, 0x54, 0x80), + s( 2, 2, 0, 0, 0x55, 0x80), + }, + OAMData_GSIntroNote = { + s( -1, -1, 4, 0, 0x00, 0x00), + s( -1, 0, 4, 0, 0x10, 0x00), + }, + OAMData_GSIntroJigglypuffPikachu = { + s( -2, -2, 0, 0, 0x00, 0x00), + s( -1, -2, 0, 0, 0x01, 0x00), + s( 0, -2, 0, 0, 0x02, 0x00), + s( 1, -2, 0, 0, 0x03, 0x00), + s( -2, -1, 0, 0, 0x10, 0x00), + s( -1, -1, 0, 0, 0x11, 0x00), + s( 0, -1, 0, 0, 0x12, 0x00), + s( 1, -1, 0, 0, 0x13, 0x00), + s( -2, 0, 0, 0, 0x20, 0x00), + s( -1, 0, 0, 0, 0x21, 0x00), + s( 0, 0, 0, 0, 0x22, 0x00), + s( 1, 0, 0, 0, 0x23, 0x00), + s( -2, 1, 0, 0, 0x30, 0x00), + s( -1, 1, 0, 0, 0x31, 0x00), + s( 0, 1, 0, 0, 0x32, 0x00), + s( 1, 1, 0, 0, 0x33, 0x00), + }, + OAMData_GSIntroPikachuTail = { + s( 3, -2, 0, 0, 0x00, 0x00), + s( 4, -2, 0, 0, 0x01, 0x00), + s( 2, -1, 0, 0, 0x02, 0x00), + s( 3, -1, 0, 0, 0x03, 0x00), + s( 2, 0, 0, 0, 0x04, 0x00), + }, + OAMData_GSIntroSmallFireball = { + s( -1, -1, 0, 0, 0x00, 0x00), + s( 0, -1, 0, 0, 0x00, 0x20), + s( -1, 0, 0, 0, 0x00, 0x40), + s( 0, 0, 0, 0, 0x00, 0x60), + }, + OAMData_TradePoofBubble = { + s( -2, -2, 0, 0, 0x00, 0x00), + s( -1, -2, 0, 0, 0x01, 0x00), + s( -2, -1, 0, 0, 0x02, 0x00), + s( -1, -1, 0, 0, 0x03, 0x00), + s( 0, -2, 0, 0, 0x01, 0x20), + s( 1, -2, 0, 0, 0x00, 0x20), + s( 0, -1, 0, 0, 0x03, 0x20), + s( 1, -1, 0, 0, 0x02, 0x20), + s( -2, 0, 0, 0, 0x02, 0x40), + s( -1, 0, 0, 0, 0x03, 0x40), + s( -2, 1, 0, 0, 0x00, 0x40), + s( -1, 1, 0, 0, 0x01, 0x40), + s( 0, 0, 0, 0, 0x03, 0x60), + s( 1, 0, 0, 0, 0x02, 0x60), + s( 0, 1, 0, 0, 0x01, 0x60), + s( 1, 1, 0, 0, 0x00, 0x60), + }, + OAMData_GSIntroBigFireball = { + s( -3, -3, 0, 0, 0x00, 0x00), + s( -2, -3, 0, 0, 0x01, 0x00), + s( -1, -3, 0, 0, 0x02, 0x00), + s( -3, -2, 0, 0, 0x03, 0x00), + s( -2, -2, 0, 0, 0x04, 0x00), + s( -1, -2, 0, 0, 0x05, 0x00), + s( -3, -1, 0, 0, 0x06, 0x00), + s( -2, -1, 0, 0, 0x05, 0x00), + s( -1, -1, 0, 0, 0x05, 0x00), + s( 0, -3, 0, 0, 0x02, 0x20), + s( 1, -3, 0, 0, 0x01, 0x20), + s( 2, -3, 0, 0, 0x00, 0x20), + s( 0, -2, 0, 0, 0x05, 0x20), + s( 1, -2, 0, 0, 0x04, 0x20), + s( 2, -2, 0, 0, 0x03, 0x20), + s( 0, -1, 0, 0, 0x05, 0x20), + s( 1, -1, 0, 0, 0x05, 0x20), + s( 2, -1, 0, 0, 0x06, 0x20), + s( -3, 0, 0, 0, 0x06, 0x40), + s( -2, 0, 0, 0, 0x05, 0x40), + s( -1, 0, 0, 0, 0x05, 0x40), + s( -3, 1, 0, 0, 0x03, 0x40), + s( -2, 1, 0, 0, 0x04, 0x40), + s( -1, 1, 0, 0, 0x05, 0x40), + s( -3, 2, 0, 0, 0x00, 0x40), + s( -2, 2, 0, 0, 0x01, 0x40), + s( -1, 2, 0, 0, 0x02, 0x40), + s( 0, 0, 0, 0, 0x05, 0x60), + s( 1, 0, 0, 0, 0x05, 0x60), + s( 2, 0, 0, 0, 0x06, 0x60), + s( 0, 1, 0, 0, 0x05, 0x60), + s( 1, 1, 0, 0, 0x04, 0x60), + s( 2, 1, 0, 0, 0x03, 0x60), + s( 0, 2, 0, 0, 0x02, 0x60), + s( 1, 2, 0, 0, 0x01, 0x60), + s( 2, 2, 0, 0, 0x00, 0x60), + }, + OAMData_GSIntroStarter = { + s( -3, -3, 4, 4, 0x00, 0x00), + s( -3, -2, 4, 4, 0x01, 0x00), + s( -3, -1, 4, 4, 0x02, 0x00), + s( -3, 0, 4, 4, 0x03, 0x00), + s( -3, 1, 4, 4, 0x04, 0x00), + s( -2, -3, 4, 4, 0x05, 0x00), + s( -2, -2, 4, 4, 0x06, 0x00), + s( -2, -1, 4, 4, 0x07, 0x00), + s( -2, 0, 4, 4, 0x08, 0x00), + s( -2, 1, 4, 4, 0x09, 0x00), + s( -1, -3, 4, 4, 0x0a, 0x00), + s( -1, -2, 4, 4, 0x0b, 0x00), + s( -1, -1, 4, 4, 0x0c, 0x00), + s( -1, 0, 4, 4, 0x0d, 0x00), + s( -1, 1, 4, 4, 0x0e, 0x00), + s( 0, -3, 4, 4, 0x0f, 0x00), + s( 0, -2, 4, 4, 0x10, 0x00), + s( 0, -1, 4, 4, 0x11, 0x00), + s( 0, 0, 4, 4, 0x12, 0x00), + s( 0, 1, 4, 4, 0x13, 0x00), + s( 1, -3, 4, 4, 0x14, 0x00), + s( 1, -2, 4, 4, 0x15, 0x00), + s( 1, -1, 4, 4, 0x16, 0x00), + s( 1, 0, 4, 4, 0x17, 0x00), + s( 1, 1, 4, 4, 0x18, 0x00), + }, + -- The GameFreak splash (engine/movie/splash.asm). The logo is a 3x5 block + -- of tiles laid row-major and it is the only OAM set in this file that asks + -- for OBJ palette 1 (`1 | OAM_PAL1`) -- which is the whole point, because + -- GameFreakPresents_UpdateLogoPal rotates OBP1 out from under it while + -- the star and sparkles stay on palette 0. + OAMData_GSGameFreakLogo = { + s( -2, -3, 4, 4, 0x00, 0x11), + s( -1, -3, 4, 4, 0x01, 0x11), + s( 0, -3, 4, 4, 0x02, 0x11), + s( -2, -2, 4, 4, 0x03, 0x11), + s( -1, -2, 4, 4, 0x04, 0x11), + s( 0, -2, 4, 4, 0x05, 0x11), + s( -2, -1, 4, 4, 0x06, 0x11), + s( -1, -1, 4, 4, 0x07, 0x11), + s( 0, -1, 4, 4, 0x08, 0x11), + s( -2, 0, 4, 4, 0x09, 0x11), + s( -1, 0, 4, 4, 0x0a, 0x11), + s( 0, 0, 4, 4, 0x0b, 0x11), + s( -2, 1, 4, 4, 0x0c, 0x11), + s( -1, 1, 4, 4, 0x0d, 0x11), + s( 0, 1, 4, 4, 0x0e, 0x11), + }, + -- Two tiles mirrored into a 16x16 star: only the left half is in the ROM. + OAMData_GSGameFreakLogoStar = { + s( -1, -1, 0, 0, 0x00, 0x00), + s( 0, -1, 0, 0, 0x00, OAM_XFLIP), + s( -1, 0, 0, 0, 0x01, 0x00), + s( 0, 0, 0, 0, 0x01, OAM_XFLIP), + }, +} + +-- SpriteAnimOAMData rows: `spriteanimoam <vtile offset>, <data>`. Only the +-- GS intro's entries are here; the vtile offsets are what put Pikachu at $40 +-- and his tail at $80 inside one 16-tile-wide OBJ sheet. +local OAMSETS = { + GS_INTRO_BUBBLE_1 = { 0x4c, OAMDATA.OAMData_1x1_Palette0 }, + GS_INTRO_BUBBLE_2 = { 0x5c, OAMDATA.OAMData_1x1_Palette0 }, + GS_INTRO_SHELLDER_1 = { 0x6c, OAMDATA.OAMData_GSIntroShellder }, + GS_INTRO_SHELLDER_2 = { 0x6e, OAMDATA.OAMData_GSIntroShellder }, + GS_INTRO_MAGIKARP_1 = { 0x2d, OAMDATA.OAMData_GSIntroMagikarp }, + GS_INTRO_MAGIKARP_2 = { 0x4d, OAMDATA.OAMData_GSIntroMagikarp }, + GS_INTRO_LAPRAS_1 = { 0x00, OAMDATA.OAMData_GSIntroLapras1 }, + GS_INTRO_LAPRAS_2 = { 0x00, OAMDATA.OAMData_GSIntroLapras2 }, + GS_INTRO_LAPRAS_3 = { 0x06, OAMDATA.OAMData_GSIntroLapras3 }, + GS_INTRO_NOTE = { 0x0c, OAMDATA.OAMData_GSIntroNote }, + GS_INTRO_INVISIBLE_NOTE = { 0x0d, OAMDATA.OAMData_1x1_Palette0 }, + GS_INTRO_JIGGLYPUFF_1 = { 0x00, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_JIGGLYPUFF_2 = { 0x04, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_JIGGLYPUFF_3 = { 0x08, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_PIKACHU_1 = { 0x40, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_PIKACHU_2 = { 0x44, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_PIKACHU_3 = { 0x48, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_PIKACHU_4 = { 0x4c, OAMDATA.OAMData_GSIntroJigglypuffPikachu }, + GS_INTRO_PIKACHU_TAIL_1 = { 0x80, OAMDATA.OAMData_GSIntroPikachuTail }, + GS_INTRO_PIKACHU_TAIL_2 = { 0x85, OAMDATA.OAMData_GSIntroPikachuTail }, + GS_INTRO_PIKACHU_TAIL_3 = { 0x8a, OAMDATA.OAMData_GSIntroPikachuTail }, + GS_INTRO_SMALL_FIREBALL = { 0x00, OAMDATA.OAMData_GSIntroSmallFireball }, + GS_INTRO_MED_FIREBALL = { 0x01, OAMDATA.OAMData_TradePoofBubble }, + GS_INTRO_BIG_FIREBALL = { 0x09, OAMDATA.OAMData_GSIntroBigFireball }, + GS_INTRO_CHIKORITA = { 0x10, OAMDATA.OAMData_GSIntroStarter }, + GS_INTRO_CYNDAQUIL = { 0x29, OAMDATA.OAMData_GSIntroStarter }, + GS_INTRO_TOTODILE = { 0x42, OAMDATA.OAMData_GSIntroStarter }, + -- Splash offsets are relative to the dict base the splash sets ($8d, see + -- System.vtileBase), so the logo lands on $8d, the star on $9c and the + -- three sparkle frames on $9e-$a0. + GS_GAMEFREAK_LOGO = { 0x00, OAMDATA.OAMData_GSGameFreakLogo }, + GS_GAMEFREAK_LOGO_STAR = { 0x0f, OAMDATA.OAMData_GSGameFreakLogoStar }, + GS_GAMEFREAK_LOGO_SPARKLE_1 = { 0x11, OAMDATA.OAMData_1x1_Palette0 }, + GS_GAMEFREAK_LOGO_SPARKLE_2 = { 0x12, OAMDATA.OAMData_1x1_Palette0 }, + GS_GAMEFREAK_LOGO_SPARKLE_3 = { 0x13, OAMDATA.OAMData_1x1_Palette0 }, +} + +-------------------------------------------------------------------------- +-- data/sprite_anims/framesets.asm +-------------------------------------------------------------------------- + +-- `oamframe <oam set>, <duration>[, <flip>]`. The flip argument lands in the +-- duration byte two bits up and GetSpriteAnimFrame shifts it back down into +-- wCurSpriteOAMFlags, so it is stored here as the OAM flag it becomes. +local function f(oamset, duration, flags) + return { oamset = oamset, duration = duration, flags = flags or 0 } +end + +-- "restart" is `oamrestart`, "end" is `oamend` (hold the last frame forever). +-- `oamdelete` is not a terminator: GetSpriteAnimFrame hands it back like an +-- OAM set and UpdateAnimFrame deinitializes the struct, so it is a frame. +local DELETE = f("delete", 0) + +local FRAMESETS = { + GSIntroBubble = { + f("GS_INTRO_BUBBLE_1", 8), f("GS_INTRO_BUBBLE_2", 8), "restart", + }, + GSIntroShellder = { + f("GS_INTRO_SHELLDER_1", 8), f("GS_INTRO_SHELLDER_2", 8), "restart", + }, + GSIntroMagikarp = { + f("GS_INTRO_MAGIKARP_1", 1, OAM_XFLIP), + f("GS_INTRO_MAGIKARP_2", 1, OAM_XFLIP), + "restart", + }, + GSIntroLapras = { + f("GS_INTRO_LAPRAS_1", 7), f("GS_INTRO_LAPRAS_2", 7), + f("GS_INTRO_LAPRAS_3", 7), f("GS_INTRO_LAPRAS_1", 7), + "restart", + }, + GSIntroNote = { f("GS_INTRO_NOTE", 8), "end" }, + GSIntroInvisibleNote = { f("GS_INTRO_INVISIBLE_NOTE", 8), "end" }, + GSIntroJigglypuff = { + f("GS_INTRO_JIGGLYPUFF_1", 25, OAM_XFLIP), + f("GS_INTRO_JIGGLYPUFF_3", 9), + f("GS_INTRO_JIGGLYPUFF_1", 25), + f("GS_INTRO_JIGGLYPUFF_3", 9), + "restart", + }, + GSIntroJigglypuff2 = { f("GS_INTRO_JIGGLYPUFF_2", 32), "end" }, + GSIntroPikachu = { + f("GS_INTRO_PIKACHU_1", 4), f("GS_INTRO_PIKACHU_2", 5), + f("GS_INTRO_PIKACHU_4", 4), "restart", + }, + GSIntroPikachu2 = { f("GS_INTRO_PIKACHU_2", 8), "end" }, + GSIntroPikachu3 = { f("GS_INTRO_PIKACHU_3", 32), "end" }, + GSIntroPikachuTail = { + f("GS_INTRO_PIKACHU_TAIL_1", 3), f("GS_INTRO_PIKACHU_TAIL_2", 3), + f("GS_INTRO_PIKACHU_TAIL_3", 3), f("GS_INTRO_PIKACHU_TAIL_2", 3), + "restart", + }, + GSIntroPikachuTail2 = { f("GS_INTRO_PIKACHU_TAIL_1", 31), "end" }, + GSIntroFireball = { + f("GS_INTRO_SMALL_FIREBALL", 1), f("GS_INTRO_MED_FIREBALL", 1), + f("GS_INTRO_BIG_FIREBALL", 1), DELETE, + }, + GSIntroChikorita = { f("GS_INTRO_CHIKORITA", 24), DELETE }, + GSIntroCyndaquil = { f("GS_INTRO_CYNDAQUIL", 24, OAM_XFLIP), DELETE }, + GSIntroTotodile = { f("GS_INTRO_TOTODILE", 24), DELETE }, + -- The logo holds one frame forever; the star flips vertically every three + -- frames, which is what makes it twinkle as it spirals. + GameFreakLogo = { f("GS_GAMEFREAK_LOGO", 8), "end" }, + GSGameFreakLogoStar = { + f("GS_GAMEFREAK_LOGO_STAR", 3), + f("GS_GAMEFREAK_LOGO_STAR", 3, OAM_YFLIP), + "restart", + }, + GSGameFreakLogoSparkle = { + f("GS_GAMEFREAK_LOGO_SPARKLE_1", 2), f("GS_GAMEFREAK_LOGO_SPARKLE_2", 2), + f("GS_GAMEFREAK_LOGO_SPARKLE_3", 2), f("GS_GAMEFREAK_LOGO_SPARKLE_2", 2), + "restart", + }, +} + +-------------------------------------------------------------------------- +-- data/sprite_anims/objects.asm +-------------------------------------------------------------------------- + +local OBJECTS = { + GS_INTRO_BUBBLE = { "GSIntroBubble", "GSIntroBubble" }, + GS_INTRO_SHELLDER = { "GSIntroShellder", "GSIntroShellder" }, + GS_INTRO_MAGIKARP = { "GSIntroMagikarp", "GSIntroMagikarp" }, + GS_INTRO_LAPRAS = { "GSIntroLapras", "GSIntroLapras" }, + GS_INTRO_NOTE = { "GSIntroNote", "GSIntroNote" }, + GS_INTRO_INVISIBLE_NOTE = { "GSIntroInvisibleNote", "GSIntroNote" }, + GS_INTRO_JIGGLYPUFF = { "GSIntroJigglypuff", "GSIntroJigglypuff" }, + GS_INTRO_PIKACHU = { "GSIntroPikachu", "GSIntroPikachu" }, + GS_INTRO_PIKACHU_TAIL = { "GSIntroPikachuTail", "GSIntroPikachuTail" }, + GS_INTRO_FIREBALL = { "GSIntroFireball", "GSIntroFireball" }, + GS_INTRO_CHIKORITA = { "GSIntroChikorita", "GSIntroChikoritaTotodile" }, + GS_INTRO_CYNDAQUIL = { "GSIntroCyndaquil", "GSIntroCyndaquil" }, + GS_INTRO_TOTODILE = { "GSIntroTotodile", "GSIntroChikoritaTotodile" }, + GAMEFREAK_LOGO = { "GameFreakLogo", "GameFreakLogo" }, + GS_GAMEFREAK_LOGO_STAR = { "GSGameFreakLogoStar", "GSGameFreakLogoStar" }, + GS_GAMEFREAK_LOGO_SPARKLE = + { "GSGameFreakLogoSparkle", "GSGameFreakLogoSparkle" }, +} + +-------------------------------------------------------------------------- +-- engine/sprite_anims/core.asm +-------------------------------------------------------------------------- + +local System = {} +System.__index = System + +local function newStruct() + return { + index = 0, framesetId = nil, seqId = nil, + x = 0, y = 0, xOffset = 0, yOffset = 0, + duration = 0, durationOffset = 0, frame = -1, + jt = 0, var1 = 0, var2 = 0, var3 = 0, var4 = 0, + oamFlags = 0, + } +end + +-- `flag` is wIntroSpriteStateFlag, which the movie and three of the sequences +-- hand back and forth (Lapras sets it when it leaves; Jigglypuff waits on it). +function SpriteAnims.new() + local self = setmetatable({ + structs = {}, + animCount = 0, + globalX = 0, -- wGlobalAnimXOffset + globalY = 0, -- wGlobalAnimYOffset + flag = 0, + -- GetSpriteAnimVTile's answer for whatever wSpriteAnimDict holds. The + -- intro leaves the dict empty and so resolves to 0; the GameFreak splash + -- writes SPRITE_ANIM_DICT_GS_SPLASH -> $8d and every OAM set's offset is + -- relative to that. + vtileBase = 0, + oam = {}, + }, System) + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + return self +end + +-- ClearSpriteAnims zeroes the whole block, counter included. +function System:clear() + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.animCount = 0 + self.oam = {} +end + +-- _InitSpriteAnimStruct: first free slot wins, nil if all ten are busy. +function System:init(objectId, x, y) + local object = OBJECTS[objectId] + if not object then error("unknown sprite anim object: " .. tostring(objectId)) end + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == 0 then + -- wSpriteAnimCount increments and skips 0, so the index doubles as the + -- per-object variation several sequences read out of field 0. + self.animCount = (self.animCount + 1) % 256 + if self.animCount == 0 then self.animCount = 1 end + st.index = self.animCount + st.framesetId = object[1] + st.seqId = object[2] + st.x, st.y = x % 256, y % 256 + st.xOffset, st.yOffset = 0, 0 + st.duration, st.durationOffset = 0, 0 + st.frame = -1 + st.jt, st.var1, st.var2, st.var3, st.var4 = 0, 0, 0, 0, 0 + st.oamFlags = 0 + return st + end + end + return nil +end + +local function deinit(st) + st.index = 0 +end + +-- _ReinitSpriteAnimFrame: swap framesets and restart the frame walk. +local function reinit(st, framesetId) + st.framesetId = framesetId + st.duration = 0 + st.frame = -1 +end + +-- GetSpriteAnimFrame. Returns the OAM set name for this frame; "wait" and +-- "delete" come back as themselves because UpdateAnimFrame, not this, is what +-- acts on them. +local function getFrame(st) + local frames = FRAMESETS[st.framesetId] + for _ = 1, 64 do + if st.duration ~= 0 then + st.duration = st.duration - 1 + local entry = frames[st.frame + 1] + st.oamFlags = entry.flags + return entry.oamset + end + st.frame = st.frame + 1 + local entry = frames[st.frame + 1] + if entry == "restart" then + st.duration = 0 + st.frame = -1 + elseif entry == "end" then + -- Step back two so the next pass lands on the frame before this one. + st.duration = 0 + st.frame = st.frame - 2 + else + st.duration = (entry.duration + st.durationOffset) % 256 + st.oamFlags = entry.flags + return entry.oamset + end + end + error("sprite anim frameset never yields a frame: " .. tostring(st.framesetId)) +end + +-- AddOrSubtractY / AddOrSubtractX: a flipped object mirrors around its own +-- 8-pixel cell, which is `-8 - offset`. +local function mirror(value, flip) + if not flip then return value end + return (-(value + 8)) % 256 +end + +-- GetSpriteOAMAttr: the frame's flip flags toggle the entry's, everything +-- else (palette, bank) passes through. +local function attrOf(attr, flags) + local toggled = bit.band(bit.bxor(attr, flags), OAM_FLAG_MASK) + return bit.band(attr, 0xff - OAM_FLAG_MASK) + toggled +end + +-- UpdateAnimFrame. Returns true once wShadowOAM is full, which is the carry +-- that stops DoNextFrameForAllSprites. +function System:updateAnimFrame(st) + -- InitSpriteAnimBuffer + st.oamFlags = 0 + local oamset = getFrame(st) + if oamset == "wait" then return false end + if oamset == "delete" then + deinit(st) + return false + end + local set = OAMSETS[oamset] + if not set then error("unknown OAM set: " .. tostring(oamset)) end + local vtile = set[1] % 256 + local yFlip = bit.band(st.oamFlags, OAM_YFLIP) ~= 0 + local xFlip = bit.band(st.oamFlags, OAM_XFLIP) ~= 0 + for _, entry in ipairs(set[2]) do + if #self.oam >= OAM_LIMIT then return true end + self.oam[#self.oam + 1] = { + y = (st.y + st.yOffset + self.globalY + mirror(entry.y, yFlip)) % 256, + x = (st.x + st.xOffset + self.globalX + mirror(entry.x, xFlip)) % 256, + tile = (self.vtileBase + vtile + entry.tile) % 256, + attr = attrOf(entry.attr, st.oamFlags), + } + end + return false +end + +-------------------------------------------------------------------------- +-- engine/sprite_anims/functions.asm +-------------------------------------------------------------------------- + +local SEQUENCES = {} + +SEQUENCES.GSIntroBubble = function(_, st) + local age = st.var2 + st.var2 = (st.var2 + 1) % 256 + if age >= 0x40 then + deinit(st) + return + end + st.yOffset = (st.yOffset - 1) % 256 + st.var1 = (st.var1 + 2) % 256 + st.xOffset = SpriteAnims.sine(st.var1, 8) +end + +-- A Shellder is deleted once the rising camera has carried it past the bottom +-- of the screen; wGlobalAnimYOffset is the climb, and it is only read here. +SEQUENCES.GSIntroShellder = function(sys, st) + if (sys.globalY + st.y) % 256 >= 0xb0 then deinit(st) end +end + +SEQUENCES.GSIntroMagikarp = function(_, st) + if st.jt == 0 then + st.jt = st.jt + 1 + -- swap of a 0-3 value: the struct index spreads the school's phases out. + st.var1 = st.index % 4 * 16 + end + -- lb de, 2, 1 on a CGB; the SGB branch doubles both. + local dx, dphase = 2, 1 + if st.xOffset >= 0xf0 then + deinit(st) + return + end + st.xOffset = (st.xOffset + dx) % 256 + st.var1 = (st.var1 + dphase) % 256 + st.yOffset = SpriteAnims.sine(st.var1, 8) +end + +-- Lapras bobs on every frame and only moves on the odd ones; .update_y_offset +-- returns zero on the even ones, which is what `ret z` skips on. +local function laprasBob(st) + local phase = st.var1 + st.var1 = (st.var1 + 1) % 256 + st.yOffset = SpriteAnims.sine(phase, 4) + return st.var1 % 2 == 0 +end + +SEQUENCES.GSIntroLapras = function(sys, st) + if st.jt == 0 then + if laprasBob(st) then return end + if st.x < 0x58 then + st.jt = st.jt + 1 + st.var2 = 0xb0 + return + end + st.x = (st.x - 1) % 256 + elseif st.jt == 1 then + laprasBob(st) + if st.var2 == 0 then + st.jt = st.jt + 1 + return + end + st.var2 = st.var2 - 1 + else + if laprasBob(st) then return end + if st.x == 0xd0 then + deinit(st) + sys.flag = 1 + return + end + st.x = (st.x - 1) % 256 + end +end + +SEQUENCES.GSIntroNote = function(_, st) + if st.jt == 0 then + st.jt = st.jt + 1 + -- (index & 1) swapped then doubled: alternate notes start half a period on. + st.var1 = st.index % 2 * 0x20 + end + if st.xOffset >= 0x80 then + deinit(st) + return + end + st.xOffset = (st.xOffset + 1) % 256 + st.var1 = (st.var1 + 2) % 256 + local wobble = SpriteAnims.sine(st.var1, 4) + st.yOffset = wobble + -- The `and $2` tests the sine result, not var1 -- the hl it reloads first is + -- thrown away -- so the note drifts up on two frames out of every four. + if wobble % 4 >= 2 then st.y = (st.y - 1) % 256 end +end + +SEQUENCES.GSIntroJigglypuff = function(sys, st) + if st.jt == 0 then + if sys.flag == 0 then return end + st.jt = st.jt + 1 + reinit(st, "GSIntroJigglypuff2") + end + if st.x == 0xd0 then + deinit(st) + return + end + st.x = (st.x - 2) % 256 +end + +SEQUENCES.GSIntroPikachu = function(sys, st) + if st.jt == 0 then + if st.x == 0x80 then + st.jt = st.jt + 1 + st.var2 = 0x30 + reinit(st, "GSIntroPikachu2") + return + end + st.x = (st.x - 1) % 256 + elseif st.jt == 1 then + if st.var2 == 0 then + st.jt = st.jt + 1 + reinit(st, "GSIntroPikachu3") + return + end + st.var2 = st.var2 - 1 + elseif st.jt == 2 then + st.var1 = (st.var1 + 4) % 256 + st.yOffset = SpriteAnims.sine(st.var1, 4) + if st.x == 0x50 then + sys.flag = 1 + st.jt = st.jt + 1 + return + end + st.x = (st.x - 4) % 256 + else + if st.x == 0xd0 then + deinit(st) + return + end + st.x = (st.x - 2) % 256 + end +end + +SEQUENCES.GSIntroPikachuTail = function(sys, st) + if st.jt == 0 then + if st.x == 0x80 then + st.jt = st.jt + 1 + st.var2 = 0x30 + reinit(st, "GSIntroPikachuTail2") + return + end + st.x = (st.x - 1) % 256 + elseif st.jt == 1 then + if st.var2 == 0 then + st.jt = st.jt + 1 + return + end + local before = st.var2 + st.var2 = st.var2 - 1 + -- Two thirds of the way through the wind-up the tail starts swishing again. + if before == 0x20 then reinit(st, "GSIntroPikachuTail") end + else + st.var1 = (st.var1 + 4) % 256 + st.yOffset = SpriteAnims.sine(st.var1, 4) + if st.x == 0xd0 then + deinit(st) + return + end + st.x = (st.x - 2) % 256 + -- Before Pikachu himself has left, the tail runs at double speed to catch + -- up with the body it belongs to. + if sys.flag ~= 0 then return end + st.x = (st.x - 2) % 256 + end +end + +SEQUENCES.GSIntroFireball = function(_, st) + if st.jt == 0 then + st.jt = st.jt + 1 + -- Two slices of the struct index become the launch angle, so consecutive + -- fireballs leave the mouth in different directions. + st.var1 = (st.index % 4 * 16 + bit.band(st.index, 4) * 2) % 256 + return + end + st.x = (st.x - 4) % 256 + local amplitude = st.var2 + st.var2 = (st.var2 + 8) % 256 + st.yOffset = SpriteAnims.sine(st.var1, amplitude) + st.xOffset = SpriteAnims.cosine(st.var1, amplitude) +end + +-- The starters flash in from off-screen along a quarter arc: var1/var2 are two +-- angles a sixth of a period apart, and the amplitude is a flat $90 pixels. +local function starterFlash(st, xPhase) + if st.jt == 0 then + st.jt = st.jt + 1 + st.var1 = 0x30 + st.var2 = xPhase + return + end + if st.var1 >= 0x3c then return end + -- `inc [hl]` twice leaves a holding the value from before, so both offsets + -- lag their counter by one step. + local yPhase = st.var1 + st.var1 = (st.var1 + 2) % 256 + st.yOffset = SpriteAnims.sine(yPhase, 0x90) + local xAngle = st.var2 + st.var2 = (st.var2 + 2) % 256 + st.xOffset = SpriteAnims.cosine(xAngle, 0x90) +end + +SEQUENCES.GSIntroChikoritaTotodile = function(_, st) starterFlash(st, 0x30) end +SEQUENCES.GSIntroCyndaquil = function(_, st) starterFlash(st, 0x10) end + +-- AnimSeq_GameFreakLogo does nothing but call GameFreakPresents_UpdateLogoPal, +-- which is a palette clock rather than a struct move; the splash screen owns +-- that clock, so the struct itself just sits where it was placed. +SEQUENCES.GameFreakLogo = function() end + +-- The star spirals inward: VAR1 is the radius, dropping 2 a frame from $80 to +-- 0 over 64 frames, and every time it crosses a multiple of $20 the per-frame +-- angle step (VAR2, counting DOWN from 0 through $ff, $fe...) gets one bigger, +-- so it whips round faster the tighter it gets. The sine reads the radius +-- from BEFORE the decrement -- `ld d, a` happens first. +-- +-- Its death is the scene's cue: .delete writes wIntroSceneFrameCounter, which +-- is what GameFreakPresents_PlaceLogo is waiting on, and `flag` carries that +-- here the same way it carries wIntroSpriteStateFlag for the movie. +SEQUENCES.GSGameFreakLogoStar = function(sys, st) + local radius = st.var1 + if radius == 0 then + sys.flag = 1 + deinit(st) + return + end + st.var1 = (st.var1 - 2) % 256 + if radius % 0x20 == 0 then st.var2 = (st.var2 - 1) % 256 end + local angle = st.jt + st.yOffset = SpriteAnims.sine(angle, radius) + st.xOffset = SpriteAnims.cosine(angle, radius) + st.jt = (st.jt + st.var2) % 256 +end + +-- A sparkle flies out along the angle its spawn picked. Two 16-bit values +-- share the four VAR slots: VAR1/VAR2 is a speed that bleeds off by $10 a +-- frame, VAR3/VAR4 the distance it has accumulated -- and VAR4, the HIGH byte +-- of that distance, is read back as the sine amplitude, which is what turns a +-- 16-bit accumulator into a smooth 8-bit radius. The angle flips half a turn +-- every frame (`xor $20`), so one struct reads as a pair of opposed sparkles. +SEQUENCES.GSGameFreakLogoSparkle = function(_, st) + local speed = st.var1 + st.var2 * 256 + if speed == 0 then + deinit(st) + return + end + local amplitude, angle = st.var4, st.jt + st.yOffset = SpriteAnims.sine(angle, amplitude) + st.xOffset = SpriteAnims.cosine(angle, amplitude) + local travelled = (st.var3 + st.var4 * 256 + speed) % 0x10000 + st.var3, st.var4 = travelled % 256, math.floor(travelled / 256) + speed = (speed - 0x10) % 0x10000 + st.var1, st.var2 = speed % 256, math.floor(speed / 256) + st.jt = bit.bxor(st.jt, 0x20) +end + +-- PlaySpriteAnimations / DoNextFrameForAllSprites: run every live struct's +-- sequence, then let it write its OAM entries. A struct that deinitializes +-- itself still draws this frame -- the ASM calls UpdateAnimFrame either way. +function System:playFrame() + self.oam = {} + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index ~= 0 then + local sequence = SEQUENCES[st.seqId] + if not sequence then + error("unknown sprite anim sequence: " .. tostring(st.seqId)) + end + sequence(self, st) + if self:updateAnimFrame(st) then break end + end + end + return self.oam +end + +function System:activeCount() + local count = 0 + for slot = 1, NUM_STRUCTS do + if self.structs[slot].index ~= 0 then count = count + 1 end + end + return count +end + +SpriteAnims.OAMSETS = OAMSETS +SpriteAnims.FRAMESETS = FRAMESETS +SpriteAnims.OBJECTS = OBJECTS +SpriteAnims.SEQUENCES = SEQUENCES +SpriteAnims.NUM_STRUCTS = NUM_STRUCTS +SpriteAnims.OAM_LIMIT = OAM_LIMIT +SpriteAnims.OAM_PRIO = OAM_PRIO +SpriteAnims.OAM_YFLIP = OAM_YFLIP +SpriteAnims.OAM_XFLIP = OAM_XFLIP + +return SpriteAnims diff --git a/src/ui/gen2/StartMenu.lua b/src/ui/gen2/StartMenu.lua new file mode 100644 index 00000000..b3d90d8c --- /dev/null +++ b/src/ui/gen2/StartMenu.lua @@ -0,0 +1,307 @@ +-- Gold's START menu (engine/menus/start_menu.asm StartMenu). +-- +-- The box is right-aligned -- menu_coords 10, 0, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1 -- and grows to fit however many entries are currently +-- unlocked. #DEX and POKEGEAR only appear once the player owns them, so early +-- in the game the menu is short; the ASM builds wMenuItemsList each time it +-- opens for exactly that reason (.SetUpMenuItems). +-- +-- With MENU ACCOUNT on, a second box at the bottom-left describes the +-- highlighted entry (.MenuDesc), which is why every item carries two lines of +-- description text here. +-- +-- The cursor position is remembered between openings +-- (wBattleMenuCursorPosition), so reopening the menu lands where you left it. +-- +-- The assembled list runs through the ui.start_menu.items hook before the menu +-- opens, exactly as the Gen 1 port's does (src/ui/StartMenu.lua), so mods +-- insert, drop or reorder rows without patching this file. It is the SAME +-- hook name and the same (game, items) payload: Gold's POKEGEAR row is simply +-- one more entry in the list the hook receives. + +local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") + +local StartMenu = {} +StartMenu.__index = StartMenu +-- Not opaque: the overworld keeps drawing underneath, the way the real menu +-- slides a window over the map. +StartMenu.isOpaque = false + +-- SFX_MENU on open, SFX_READ_TEXT_2 (the click) on select +-- (StartMenu_Show's `ld de, SFX_MENU / call PlaySFX` and .Select's +-- PlayClickSFX). By LABEL, not index: the old numeric ids (2 and 3) landed +-- on whatever the sfx table had there -- the item-get jingle on every menu +-- open -- and a repointed table would drift again. + +-- STARTMENUITEM_* in the order .SetUpMenuItems appends them. `need` is the +-- save flag that unlocks the entry; nil means always shown. +-- +-- Labels are the ASM's own strings, with the compression bytes expanded the +-- way the cart expands them at print time: "#DEX" is POKé + DEX (seven tiles), +-- while "<POKE>GEAR" is <PO><KE>GEAR -- the <PO> and <KE> glyphs ($70/$71) are +-- one tile each, so that row is six tiles wide, not eight. That difference is +-- exactly what kept POKéGEAR hanging off the menu box's right edge. +local ITEMS = { + { + id = "pokedex", label = "POKéDEX", need = "pokedex", + desc = { "POKéMON", "database" }, + }, + { + id = "pokemon", label = "POKéMON", need = "party", + desc = { "Party <PK><MN>", "status" }, + }, + { + id = "pack", label = "PACK", need = "pack", + desc = { "Contains", "items" }, + }, + { + id = "pokegear", label = "<PO><KE>GEAR", need = "pokegear", + desc = { "Trainer's", "key device" }, + }, + { + -- The player's own name is the label (.StatusString is "<PLAYER>"). + id = "status", label = nil, + desc = { "Your own", "status" }, + }, + { + id = "save", label = "SAVE", + desc = { "Save your", "progress" }, + }, + { + id = "option", label = "OPTION", + desc = { "Change", "settings" }, + }, + { + -- The mod manager's discoverable home, exactly as the Gen 1 start menu + -- carries it: the row only appears once at least one mod has been + -- discovered, so a vanilla install's menu is the cart's. + id = "mods", label = "MODS", need = "mods", + desc = { "Installed", "add-ons" }, + }, + { + -- The cart's EXIT just closed the menu (CloseStartMenu). A window with a + -- close button already covers that, so -- exactly as the Gen 1 port does + -- (src/ui/StartMenu.lua) -- this row is QUIT and power-cycles back to the + -- title after a confirmation that defaults to NO. + id = "quit", label = "QUIT", + desc = { "Return to", "the title" }, + }, +} + +-- The confirmation's yes/no box. A bare YesNoBox lands at YesNoMenuHeader's +-- own menu_coords 10, 5, 15, 9, which is exactly where the start menu is, so +-- this uses the other position the cart already places one at: +-- SaveTheGame_yesorno's `lb bc, 0, 7`, a 6x5 box at (0,7) clear of the menu. +-- Labels sit at (2,8) and (2,10) -- border + 1, plus a column for +-- STATICMENU_CURSOR and no extra row because STATICMENU_NO_TOP_SPACING is set. +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 + +-- Persisted across openings, like wBattleMenuCursorPosition. +StartMenu.lastIndex = 1 + +-- ui.start_menu.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- opts: save, onChoose(id), onClose(), unlocked (override table for tests) +function StartMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, StartMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onChoose = opts.onChoose + self.onClose = opts.onClose + self.unlocked = opts.unlocked + local items = self:visibleItems() + -- Unguarded, like the Gen 1 site: the menu is built once per opening, not + -- per frame, so there is nothing here worth a wantsHook fast path. A hook + -- that answers with anything but a table is degraded to the vanilla list + -- rather than emptying the player's menu. + local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.start_menu.items returned %s; keeping the vanilla items", + type(hooked)) + end + self.items = items + local options = (self.save and self.save.options) or {} + self.showDescription = options.menuAccount ~= false + + self.list = Chrome.List.new({ + items = self.items, + -- GetMenuTextStartCoord: the box's left/top plus one for the border, plus + -- one more for the cursor column (STATICMENU_CURSOR) and one more for the + -- top spacing this menu does not opt out of -- so (10,0) becomes (12,2), + -- and Chrome.List puts the cursor at x - 1 = 11. + x = 12, y = 2, spacing = 2, + -- Two rows per entry inside an 18-row screen leaves room for eight, which + -- is exactly the vanilla count. A mod that adds a row scrolls rather than + -- drawing off the bottom of the frame; Chrome.List puts the ▼ hint on. + rows = math.min(#self.items, 8), + wrap = true, + startAccepts = true, + index = math.min(StartMenu.lastIndex, math.max(1, #self.items)), + onChoose = function(value, index) self:choose(value, index) end, + onCancel = function() self:close() end, + }) + return self +end + +-- Which entries the player has unlocked. Derived from the save so the menu +-- grows as the game does; `unlocked` overrides it wholesale for tests. +-- .SetUpMenuItems tests two ENGINE flag bits, and both arrive as `setflag` +-- ids in save.engineFlags (constants/engine_flags.asm const order, via +-- World:setEngineFlag): `bit STATUSFLAGS_POKEDEX_F, [wStatusFlags]` is +-- ENGINE_POKEDEX = 11, written by Oak at Mr. Pokemon's house, and +-- `bit POKEGEAR_OBTAINED_F, [wPokegearFlags]` is ENGINE_POKEGEAR = 4, +-- written by Mom on the way out the door. +local ENGINE_POKEGEAR, ENGINE_POKEDEX = 4, 11 + +function StartMenu:availability() + if self.unlocked then return self.unlocked end + local save = self.save or {} + local inventory = save.inventory or {} + local engine = save.engineFlags or {} + local status = self.game and self.game.modStatus + return { + mods = status ~= nil and #(status.available or {}) > 0, + -- ENGINE_POKEDEX first; pokedexReceived stays as the test/driver override. + pokedex = engine[ENGINE_POKEDEX] == true or save.pokedexReceived == true, + party = #(save.party or {}) > 0, + -- The PACK exists from the start; the cart gates it on nothing. + pack = true, + pokegear = engine[ENGINE_POKEGEAR] == true + or (inventory.POKEGEAR or 0) > 0 or save.pokegearReceived == true, + } +end + +function StartMenu:visibleItems() + local available = self:availability() + local playerName = (self.save and self.save.player and self.save.player.name) + or "GOLD" + local out = {} + for _, item in ipairs(ITEMS) do + if not item.need or available[item.need] then + out[#out + 1] = { + label = item.label or playerName, + value = item.id, + desc = item.desc, + } + end + end + return out +end + +function StartMenu:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if not audio then return end + if audio.sfx and audio.sfx[name] then Sound.play(data, name) end +end + +function StartMenu:enter() + self:playSfx("Sfx_Menu") +end + +-- index comes from Chrome.List (src/ui/gen2/Chrome.lua:346) so a hook-injected +-- entry can be found by position: a mod's row carries an onSelect callback and +-- no `value`, the way the Gen 1 menu's rows do (src/ui/StartMenu.lua:30), and +-- without this arm it falls off the end of the id chain in Game2:switch. +function StartMenu:choose(id, index) + StartMenu.lastIndex = self.list.index + local item = index and self.items[index] + if item and item.onSelect and item.value == nil then + self:playSfx("Sfx_ReadText2") + item.onSelect(self.game) + return + end + self:playSfx("Sfx_ReadText2") + if id == "quit" then + -- Ask before throwing away everything since the last save. NO is the + -- default, the way the Gen 1 port's QUIT is. + self.phase = "confirm" + self.confirmChoice = 2 + return + end + if self.onChoose then self.onChoose(id) end +end + +function StartMenu:confirmQuit() + self.phase = nil + if self.onQuit then + self.onQuit() + elseif self.game and self.game.returnToTitle then + self.game:returnToTitle() + end +end + +function StartMenu:close() + StartMenu.lastIndex = self.list.index + if self.onClose then self.onClose() end +end + +function StartMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.phase == "confirm" then + if input:wasPressed("up") or input:wasPressed("down") then + self.confirmChoice = self.confirmChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + if self.confirmChoice == 1 then + self:confirmQuit() + else + self.phase = nil + end + elseif input:wasPressed("b") or input:wasPressed("start") then + self.phase = nil + end + return + end + -- START closes the menu as well as opening it. + if input:wasPressed("start") then + self:close() + return + end + self.list:update(input) +end + +function StartMenu:draw() + -- AutomaticGetMenuBottomCoord: bottom = top + 2 * items + 1, so the box is + -- two rows per entry plus its two border rows. A menu that a mod has grown + -- past the screen scrolls instead of overflowing (Chrome.List draws the ▼ + -- hint when there is more below). + local height = math.min(#self.items * 2 + 2, Chrome.SCREEN_H) + Chrome.box(10, 0, 10, height) + self.list:draw() + + if self.phase == "confirm" then + Chrome.textbox(0, 12, 18, 4) + Chrome.print("Return to the", 1, 14) + Chrome.print("title screen?", 1, 16) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.confirmChoice == 1 and 1 or 3)) + return + end + + if not self.showDescription then return end + local item = self.list:current() + local desc = item and item.desc + if not desc then return end + -- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord + -- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382). + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 13 * 8, 10 * 8, 5 * 8) + Chrome.print(desc[1] or "", 0, 14) + Chrome.print(desc[2] or "", 0, 16) +end + +StartMenu.ITEMS = ITEMS + +return StartMenu diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua new file mode 100644 index 00000000..d65ad8e6 --- /dev/null +++ b/src/ui/gen2/SummaryMenu.lua @@ -0,0 +1,1137 @@ +-- Gold's mon SUMMARY, transcribed from engine/pokemon/stats_screen.asm. +-- +-- Three pages, named after the palette each one wears rather than after what +-- it shows: PINK_PAGE (1), GREEN_PAGE (2), BLUE_PAGE (3). It is worth saying +-- out loud which is which, because Gen 1's stats screen split the same +-- information differently and the temptation is to lay this out from memory: +-- +-- PINK HP bar, HP digits, STATUS/TYPE, and the EXP POINTS / LEVEL UP TO +-- block down the right of a vertical rule at column 9 +-- GREEN the held ITEM, then the four moves with their PP +-- BLUE OT / <ID>№ down the left of a vertical rule at column 10, and the +-- five non-HP stats down its right +-- +-- None of the three is a text box. StatsScreenMain calls ClearTilemap and +-- then every routine writes tiles at its own hlcoord, so every coordinate in +-- this file is that hlcoord and nothing here is placed by eye. +-- +-- StatsScreen_InitUpperHalf draws rows 0-7 once and no page redraws them, so +-- the pic, dex number, nickname, level, gender, species and the row-7 rule +-- stay put while the lower half changes. That is why `upperPlacements` is +-- separate from the per-page ones and why switching pages does not replay the +-- cry: LoadPinkPage only jumps to StatsScreen_PlaceFrontpic (which calls +-- PlayMonCry) when b is 0, and b is 0 only on the entry from StatsScreenMain +-- -- a page switch comes back through .done_loading with `ld b, 1`. +-- +-- Tiles that are not glyphs +-- ------------------------- +-- StatsScreen_LoadFont (engine/gfx/load_font.asm) is _LoadFontsBattleExtra +-- plus ExpBarGFX at $55, and LoadStatsScreenPageTilesGFX puts the 17 tiles of +-- gfx/stats/stats_tiles.png at $31: +-- +-- $31 the vertical divider column +-- $36-$39 the small (inactive) page square, 2x2 +-- $3a-$3d the large (active) page square, 2x2 +-- $3e the "P" of the PP label +-- $3f the shiny ⁂ icon (stats_tiles tile 14) +-- $40 / $41 the left and right HP/exp bar end caps +-- +-- The extractor does not carry that sheet yet, so `pageTile` draws those seven +-- shapes directly and takes the sheet the moment menu_gfx grows a `stats` +-- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed), +-- № ($74), <ID> ($73), <LV> ($6e) and the row-7 rule's $62 (the empty HP/exp +-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true) +-- around the whole screen, exactly as the party menu does). +-- +-- Move descriptions +-- ----------------- +-- The three stats pages have no room for one: LoadGreenPage's ClearBox is +-- rows 8-17 and the move names and PP already fill every one of them. Where +-- the cart shows a move description is PlaceMoveData (engine/pokemon/ +-- mon_menu.asm), the screen MoveScreenLoop puts up, and that whole screen is +-- transcribed here as `moveDetailPlacements`. SELECT opens it from the green +-- page; SELECT is not in the stats screen's accepted-button mask +-- (PAD_CTRL_PAD | PAD_A | PAD_B), so borrowing it leaves every button the ASM +-- does handle behaving exactly as it does. +-- +-- ManagePokemonMoves (engine/pokemon/mon_menu.asm:858) opens that same screen +-- on its own, which is what the party submenu's MOVE row does: `moveScreen` +-- says the move list IS the screen, so B exits instead of dropping onto a page. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local GbcPalette = require("src.render.GbcPalette") +local HpBar = require("src.battle.gen2.HpBar") +local Mon = require("src.battle.gen2.Mon") +local Palettes = require("src.world.gen2.Palettes") +local Pokerus = require("src.core.gen2.Pokerus") +local Unown = require("src.core.gen2.Unown") + +local SummaryMenu = {} +SummaryMenu.__index = SummaryMenu +SummaryMenu.isOpaque = true + +-- The *_PAGE constants at the top of stats_screen.asm. +SummaryMenu.PINK_PAGE = 1 +SummaryMenu.GREEN_PAGE = 2 +SummaryMenu.BLUE_PAGE = 3 +SummaryMenu.NUM_STAT_PAGES = 3 + +local PINK_PAGE = SummaryMenu.PINK_PAGE +local GREEN_PAGE = SummaryMenu.GREEN_PAGE +local BLUE_PAGE = SummaryMenu.BLUE_PAGE + +-- Tile ids from StatsScreenPageTilesGFX, kept named so `pageTile` reads like +-- the ASM that asks for them. +local TILE_VERTICAL_DIVIDER = 0x31 +local TILE_SQUARE_SMALL = 0x36 +local TILE_SQUARE_LARGE = 0x3a +local TILE_SHINY = 0x3f +local TILE_BAR_CAP_LEFT = 0x40 +local TILE_BAR_CAP_RIGHT = 0x41 +-- FontBattleExtra's empty HP/exp bar cell, which is what the row-7 rule is +-- made of (StatsScreen_PlaceHorizontalDivider). +local TILE_HORIZONTAL_DIVIDER = 0x62 + +-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside +-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the +-- values start one row below the first label. +local STAT_LABELS = { "ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED" } +local STAT_KEYS = { + "attack", "defense", "specialAttack", "specialDefense", "speed", +} + +-- data/types/names.asm. Every type constant prints as its own name except +-- the two the extractor has to disambiguate against Lua-unfriendly ids. +local TYPE_NAMES = { + PSYCHIC_TYPE = "PSYCHIC", + CURSE_TYPE = "???", +} + +-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon +-- with no HP reads FNT whatever its status byte says. Same table the party +-- list uses; both screens call the same routine on the cart. +local STATUS_STRING = { + slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", + poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", + sleep = "SLP", toxic = "PSN", +} + +-- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in +-- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex +-- uses, for the same reason. +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +-- Matches src/core/gen2/Breeding.lua's isEgg without requiring it, the same +-- arms-length test src/core/gen2/Happiness.lua carries. +local function isEggMon(mon) + return type(mon) == "table" and mon.isEgg == true +end + +-- EggStatsScreen's four flavour strings, picked off wTempMonHappiness -- +-- which on an egg is not happiness at all but the remaining hatch cycles +-- DoEggStep counts down (256 steps each, `eggSteps` here). The thresholds +-- are the ASM's own `cp $6 / cp $b / cp $29` ladder, and the lines join with +-- <NEXT> exactly as the db/next strings do. +local EGG_FLAVOR = { + { below = 0x6, text = "It's making sounds<NEXT>inside. It's going" + .. "<NEXT>to hatch soon!" }, + { below = 0xb, text = "It moves around<NEXT>inside sometimes." + .. "<NEXT>It must be close<NEXT>to hatching." }, + { below = 0x29, text = "Wonder what's<NEXT>inside? It needs" + .. "<NEXT>more time, though." }, + { text = "This EGG needs a<NEXT>lot more time to<NEXT>hatch." }, +} + +local function eggFlavor(cycles) + for _, entry in ipairs(EGG_FLAVOR) do + if not entry.below or cycles < entry.below then return entry.text end + end +end + +-- ------------------------------------------------------------- placements +-- +-- Every page is built as a list of { text, x, y } tilemap writes before +-- anything is drawn, so the layout can be asserted without a graphics device +-- (tests/gen2_summary_test.lua) and `drawPanel` stays a loop. + +local function put(list, text, x, y) + if text == nil then return list end + list[#list + 1] = { text = tostring(text), x = x, y = y } + return list +end + +-- The text a placement list writes at a coordinate, or nil. +function SummaryMenu.at(placements, x, y) + for _, entry in ipairs(placements or {}) do + if entry.x == x and entry.y == y then return entry.text end + end + return nil +end + +-- PrintNum: a right-aligned field, space-padded unless PRINTNUM_LEADINGZEROS. +local function num(value, width, leadingZeros) + return Chrome.number(value, width, leadingZeros) +end + +-- How many tiles a string occupies, which is what PlaceString advances by -- +-- not its pixel width. "<PK>" and "é" are one tile each, and Font.split is +-- the only thing in the port that knows that. +local function tiles(text) + return #Font.split(tostring(text or "")) +end + +-- GetNickname reads wPartyMonNicknames, so a party mon always has a name of +-- its own; a directly-built mon may only carry a species. +local function monName(mon) + return mon.nickname or mon.name or mon.species or "?" +end + +-- Blank whole tile cells. PlaceString writes tilemap cells, so a string put +-- over a box border REPLACES it; here the border is pixels and a glyph sheet +-- is transparent, so without this the frame runs straight through the letters +-- of any string the ASM places on a border row. +local function clearCells(tx, ty, tw, th) + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", tx * 8, ty * 8, tw * 8, (th or 1) * 8) + G.setColor(0, 0, 0, 1) +end + +-- PrintLevel (home/pokemon.asm): '<LV>' then the number left-aligned in two +-- cells. A three-digit level does `dec hl` first, so the digits overwrite the +-- <LV> and the field starts at the same column either way. +local function levelText(level) + level = math.max(1, math.floor(tonumber(level) or 1)) + if level >= 100 then return tostring(level) end + return "<LV>" .. tostring(level) +end + +local function statusText(mon) + if (mon.hp or 0) <= 0 then return "FNT" end + local status = mon.status + if not status then return nil end + return STATUS_STRING[tostring(status):lower()] +end + +-- wTempMonPokerusStatus is one byte: the low nibble counts the days left and +-- the high nibble holds the strain. A cured mon keeps a non-zero high nibble +-- forever, and that is what the '.' at (8,8) marks -- so "infected" and +-- "immune" really are two different tests of the same byte, in that order. +local function pokerusState(mon) + if Pokerus.isInfected(mon) then return "infected" end + if Pokerus.isImmune(mon) then return "immune" end + return nil +end + +-- ------------------------------------------------------------------ screen + +function SummaryMenu:wantsFillScale() return true end +function SummaryMenu:drawsWidescreen() return true end + +-- opts: mon, party, index, page, moveScreen, onClose(), save, pokemon, moves, +-- items, palettes, menuGfx +function SummaryMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, SummaryMenu) + self.game = game + local data = (game and game.data) or {} + self.save = opts.save or (game and game.save) + self.pokemon = opts.pokemon or data.pokemon + self.moves = opts.moves or data.moves + self.items = opts.items or data.items + self.palettes = opts.palettes or data.gen2Palettes + -- The egg page's pic does not live in data.pokemon; it comes off the same + -- menu_gfx.eggHatch entry the hatch cutscene draws. + self.menuGfx = opts.menuGfx or data.gen2MenuGfx + -- ...and icons.lua carries ICON_EGG, which is what stands in when a cache + -- was built before the extractor learned EggPic. See drawEggPic. + self.icons = opts.icons or data.gen2Icons + self.onClose = opts.onClose + if opts.mon and not opts.party then + -- One mon on its own: the wMonType == TEMPMON / BOXMON path, where the + -- `down` and `.d_up` arms have nothing to scroll to. + self.party = { opts.mon } + self.index = 1 + self.mon = opts.mon + else + self.party = opts.party or (self.save and self.save.party) or {} + self.index = math.max(1, + math.min(opts.index or 1, math.max(1, #self.party))) + self.mon = self.party[self.index] + end + self.page = opts.page or PINK_PAGE + -- ManagePokemonMoves opens straight onto MoveScreenLoop's screen; SELECT off + -- the green page reaches the same view with the stats pages still behind it. + self.moveScreen = opts.moveScreen == true + self.moveDetail = self.moveScreen + self.moveIndex = 1 + -- wSwappingMove: the row A picked a move up from, nil while nothing is held. + self.swapFrom = nil + self.picCache = {} + + -- The HP and exp bars are the battle HUD's, tile for tile: DrawPlayerHP is + -- DrawBattleHPBar and the exp bar is FillInExpBar, so both go through + -- BattleHud rather than being drawn a second time here. + local BattleHud = require("src.ui.gen2.BattleHud") + self.hud = BattleHud.new(data.gen2MenuGfx, self.palettes) + self:playCry() + return self +end + +-- StatsScreen_PlaceFrontpic ends in PlayMonCry, and it only runs when b is 0 +-- -- the entry from StatsScreenMain. So the cry fires on open and on a mon +-- switch, never on a page switch. +function SummaryMenu:playCry() + local mon = self.mon + -- SetUpMoveScreenBG only loads the menu icon (engine/pokemon/mon_menu.asm: + -- 1106-1107); MoveScreenLoop has no PlayMonCry at all, on entry or on cycle. + if self.moveScreen then return end + -- StatsScreenInit routes an EGG to EggStatsInit before any pic or cry, so + -- an egg never plays the hidden species' voice. EggStatsScreen ends on its + -- own cue instead (engine/pokemon/stats_screen.asm:788): `ld a, + -- [wTempMonHappiness] / cp 6 / ret nc / ld de, SFX_2_BOOPS`, i.e. the boops + -- sound only once the egg is nearly ready, pairing with the "It's making + -- sounds inside" line. The threshold is the same $6 EGG_FLAVOR's first row + -- reads off eggSteps, so the two cannot drift apart. + if isEggMon(mon) then + if (mon.eggSteps or 0) >= 0x6 then return end + if not (self.game and self.game.data) then return end + local ok, Sound = pcall(require, "src.core.Sound") + if ok and Sound and Sound.play then + pcall(Sound.play, self.game.data, "Sfx_2Boops") + end + return + end + if not (mon and mon.species and self.game and self.game.data) then return end + local ok, Sound = pcall(require, "src.core.Sound") + if ok and Sound and Sound.playCry then + pcall(Sound.playCry, self.game.data, mon.species) + end +end + +function SummaryMenu:speciesDef() + local mon = self.mon + return mon and self.pokemon and self.pokemon[mon.species] or nil +end + +function SummaryMenu:moveDef(id) + return id and self.moves and self.moves[id] or nil +end + +-- The mon's four move slots, empty ones left as holes so the '-' rows land on +-- the right lines. +function SummaryMenu:moveList() + return (self.mon and self.mon.moves) or {} +end + +function SummaryMenu:moveName(entry) + if not entry then return nil end + local def = self:moveDef(entry.id) + return (def and def.name) or entry.id +end + +-- The EXP page's "to next level" gap reads the same curve the battle does, so +-- it goes through Mon.growthFor. self.pokemon IS data.pokemon, so a synthetic +-- data table with just that key is what the accessor needs. +function SummaryMenu:growth() + local def = self:speciesDef() + if not (self.pokemon and def) then return nil end + local data = (self.game and self.game.data) or { pokemon = self.pokemon } + return require("src.battle.gen2.Mon").growthFor(data, def.growthRate) +end + +-- .CalcExpToNextLevel: zero at MAX_LEVEL, otherwise the gap to the next +-- level's threshold. +function SummaryMenu:expToNext() + local mon = self.mon or {} + local level = mon.level or 1 + if level >= Mon.MAX_LEVEL then return 0 end + return math.max(0, Mon.experienceForLevel(self:growth(), level + 1) + - (mon.experience or 0)) +end + +function SummaryMenu:otName() + local mon = self.mon or {} + if mon.otName then return mon.otName end + local player = (self.save and self.save.player) or {} + return player.name or "GOLD" +end + +function SummaryMenu:otId() + local mon = self.mon or {} + if mon.otId then return mon.otId end + local player = (self.save and self.save.player) or {} + return player.id or 0 +end + +-- .PlaceOTInfo's closing block: `lb bc, 0, -1` counts the characters up to the +-- '@', then `ld a, NAME_LENGTH - 1; sub c` and, unless that came out under +-- NAME_LENGTH - PLAYER_NAME_LENGTH (3), clamps to +-- NAME_LENGTH - PLAYER_NAME_LENGTH - 1 (2). So an ordinary name gets two +-- spaces of left padding and only a 9 or 10 character one is pulled left. +function SummaryMenu.otColumn(name) + local pad = 10 - tiles(name) + if pad >= 3 or pad < 0 then pad = 2 end + return pad +end + +function SummaryMenu:itemName() + local mon = self.mon or {} + if not mon.item then return nil end + local def = self.items and self.items[mon.item] + return (def and def.name) or mon.item +end + +function SummaryMenu:typeNames() + local def = self:speciesDef() + local types = (self.mon and self.mon.types) or (def and def.types) or {} + local first = types[1] + local second = types[2] or first + local function name(id) + if not id then return nil end + return TYPE_NAMES[id] or id + end + -- PrintMonTypes' .hide_type_2: a single-typed mon really has two of the same + -- type, and the second name is blanked rather than printed twice. + if first and second and first == second then return name(first), nil end + return name(first), name(second) +end + +-- ------------------------------------------------------------ upper half + +-- StatsScreen_InitUpperHalf, coordinate for coordinate. +function SummaryMenu:upperPlacements() + local mon = self.mon or {} + local def = self:speciesDef() + local out = {} + -- (8,0) '№' and (9,0) '.' are two `ld [hl]` writes, then PrintNum puts the + -- dex number in three leading-zero digits at (10,0). + put(out, "№.", 8, 0) + put(out, num(def and def.dex or 0, 3, true), 10, 0) + put(out, levelText(mon.level), 14, 0) + put(out, mon.nickname or mon.name or mon.species, 8, 2) + -- GetGender returns carry for a genderless species, and nothing is written. + if mon.gender == "male" then + put(out, "♂", 18, 0) + elseif mon.gender == "female" then + put(out, "♀", 18, 0) + end + -- (9,4) is a bare '/' written with `ld [hli], a`, so the species name that + -- follows starts at (10,4). + put(out, "/", 9, 4) + put(out, (def and def.name) or mon.species, 10, 4) + return out +end + +-- ------------------------------------------------------------- pink page + +function SummaryMenu:pinkPlacements() + local mon = self.mon or {} + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + local out = {} + + -- DrawPlayerHP puts the bar at (0,9) and then, from that same hl, + -- `bccoord 1, 1, 0` steps to (1,10) for the digits: current HP in three + -- columns, the '/' it writes with `ld [hli]`, then max HP in three more. + put(out, num(mon.hp, 3), 1, 10) + put(out, "/", 4, 10) + put(out, num(maxHp, 3), 5, 10) + + -- .Status_Type is "STATUS/" <NEXT> "TYPE/", and <NEXT> is two rows down at + -- the same column -- so the second label is at row 14, not row 13. + put(out, "STATUS/", 0, 12) + put(out, "TYPE/", 0, 14) + + local pokerus = pokerusState(mon) + if pokerus == "infected" then + -- .PkrsStr is "#RUS", and '#' is the four-tile POKé compression byte. + put(out, "POKéRUS", 1, 13) + else + if pokerus == "immune" then put(out, ".", 8, 8) end + put(out, statusText(mon) or "OK", 6, 13) + end + + -- PrintMonTypes writes type 1 at (1,15) and type 2 two rows below it, and + -- then LoadPinkPage copies the nine bytes of row 17 up onto row 16 and + -- blanks row 17 -- so the second type ends up one row under the first. + local type1, type2 = self:typeNames() + put(out, type1, 1, 15) + put(out, type2, 1, 16) + + put(out, "EXP POINTS", 10, 9) + -- `lb bc, 3, 7`: a three-byte value in seven columns, so the field runs + -- (13,10) to (19,10). + put(out, num(mon.experience, 7), 13, 10) + put(out, "LEVEL UP", 10, 12) + put(out, num(self:expToNext(), 7), 13, 13) + put(out, "TO", 14, 14) + -- The level printed at (17,14) is the NEXT one: LoadPinkPage bumps + -- wTempMonLevel, calls PrintLevel, and puts it back. MAX_LEVEL stays put. + local level = mon.level or 1 + put(out, levelText(math.min(Mon.MAX_LEVEL, level + 1)), 17, 14) + return out +end + +-- ------------------------------------------------------------ green page + +function SummaryMenu:greenPlacements() + local out = {} + put(out, "ITEM", 0, 8) + put(out, self:itemName() or "---", 6, 8) + put(out, "MOVE", 0, 10) + + -- ListMoves runs from (8,10) with wListMovesLineSpacing = SCREEN_WIDTH * 2, + -- so the four names are two rows apart; ListMovePP runs from (12,11) with + -- the same spacing, one row below each name. An empty slot gets a single + -- '-' for the name and the PP label's two cells get '-' as well. + local moves = self:moveList() + for slot = 1, 4 do + local nameY = 10 + (slot - 1) * 2 + local ppY = nameY + 1 + local entry = moves[slot] + if entry then + put(out, self:moveName(entry), 8, nameY) + -- Two $3e "P" tiles: `ld [hli], a` then `ld [hld], a` writes the same + -- tile at (12,y) and (13,y). + put(out, "PP", 12, ppY) + -- `pop hl` then three `inc hl` lands the numbers at (15,y): two digits, + -- the '/' PrintNum's caller writes, then two more. + put(out, num(entry.pp, 2), 15, ppY) + put(out, "/", 17, ppY) + put(out, num(entry.maxPp or entry.pp, 2), 18, ppY) + else + put(out, "-", 8, nameY) + put(out, "--", 12, ppY) + end + end + return out +end + +-- ------------------------------------------------------------- blue page + +function SummaryMenu:bluePlacements() + local mon = self.mon or {} + local out = {} + -- IDNoString is "<ID>№." -- three single tiles, not the seven letters of + -- "ID No." -- and OTString is "OT/". + put(out, "<ID>№.", 0, 9) + put(out, num(self:otId(), 5, true), 2, 10) + put(out, "OT/", 0, 12) + local ot = self:otName() + put(out, ot, SummaryMenu.otColumn(ot), 13) + + -- PrintTempMonStats is called at (11,8) with bc = 6: the labels go in at hl + -- two rows apart, then `add hl, bc` and one more SCREEN_WIDTH puts the first + -- value at (17,9) -- three columns wide, so every value ends at column 19. + for i, label in ipairs(STAT_LABELS) do + put(out, label, 11, 8 + (i - 1) * 2) + local value = (mon.stats or {})[STAT_KEYS[i]] + put(out, num(value, 3), 17, 9 + (i - 1) * 2) + end + return out +end + +-- ------------------------------------------------------- move detail view +-- +-- SetUpMoveScreenBG + SetUpMoveList + PlaceMoveData (engine/pokemon/ +-- mon_menu.asm). Two text boxes: Textbox (0,1) with a 9x18 interior, so rows +-- 1-11, and Textbox (0,11) with a 5x18 interior, so rows 11-17 -- the two +-- share row 11, which is why the TYPE plaque's bottom line sits on it. + +function SummaryMenu:moveDetailPlacements() + local mon = self.mon or {} + local out = {} + local name = monName(mon) + put(out, name, 5, 1) + -- PlaceString leaves bc one tile past the string and MoveScreenLoop pops + -- that straight into hl for PrintLevel, so the level butts up against the + -- nickname rather than sitting in a column. + put(out, levelText(mon.level), 5 + tiles(name), 1) + + local moves = self:moveList() + for slot = 1, 4 do + local nameY = 3 + (slot - 1) * 2 + local ppY = nameY + 1 + local entry = moves[slot] + if entry then + put(out, self:moveName(entry), 2, nameY) + put(out, "PP", 10, ppY) + put(out, num(entry.pp, 2), 13, ppY) + put(out, "/", 15, ppY) + put(out, num(entry.maxPp or entry.pp, 2), 16, ppY) + else + put(out, "-", 2, nameY) + put(out, "--", 10, ppY) + end + end + + -- .moving_move: five spaces over "TYPE/" at (1,11), ClearBox (1,12) 5x18, + -- then String_MoveWhere at (1,12) -- so the data half reads "Where?" alone. + if self.swapFrom then + put(out, "┌─────┐", 0, 10) + put(out, "│", 0, 11) + put(out, "└", 6, 11) + put(out, "Where?", 1, 12) + return out + end + + -- String_MoveType_Top / _Bottom are box-drawing glyphs, and the plaque is + -- open on its right: "┌─────┐" over "│TYPE/└". + put(out, "┌─────┐", 0, 10) + put(out, "│TYPE/└", 0, 11) + put(out, "ATTK/", 11, 12) + + local entry = moves[self.moveIndex] + local def = entry and self:moveDef(entry.id) + local moveType = def and def.type + put(out, moveType and (TYPE_NAMES[moveType] or moveType) or "---", 2, 12) + -- `cp 2; jr c, .no_power`: a move with power 0 or 1 prints String_MoveNoPower + -- rather than a number. + local power = (def and def.power) or 0 + if power >= 2 then + put(out, num(power, 3), 16, 12) + else + put(out, "---", 16, 12) + end + + -- PrintMoveDescription at (1,14). Descriptions join their lines with + -- <NEXT>, which is two rows down at the same column, so the second line is + -- at row 16 and not row 15. + local description = def and def.description or "" + local ty = 14 + for line in (tostring(description) .. "<NEXT>"):gmatch("(.-)<NEXT>") do + if ty > 16 then break end + if line ~= "" then put(out, line, 1, ty) end + ty = ty + 2 + end + return out +end + +-- --------------------------------------------------------------- egg page +-- +-- EggStatsScreen (engine/pokemon/stats_screen.asm). One screen, no pages, +-- no arrows, no gender glyph: "EGG" where the nickname block sits, five +-- question marks for both the ID and the OT, and a flavour line that reads +-- the hatch counter. Everything else about the egg -- species, level, +-- stats, moves -- stays a secret. + +function SummaryMenu:eggPlacements() + local mon = self.mon or {} + local out = {} + put(out, "EGG", 8, 1) + -- IDNoString / OTString, the same strings the blue page prints, with + -- FiveQMarkString beside each: an egg's OT and ID are hidden. + put(out, "<ID>№.", 8, 3) + put(out, "?????", 11, 3) + put(out, "OT/", 8, 5) + put(out, "?????", 11, 5) + local ty = 9 + for line in ((eggFlavor(mon.eggSteps or 0) or "") .. "<NEXT>") + :gmatch("(.-)<NEXT>") do + if line ~= "" then put(out, line, 1, ty) end + ty = ty + 2 + end + return out +end + +-- Everything the current view writes, upper half included. The move detail +-- clears the tilemap for itself (SetUpMoveScreenBG), so it does not carry it. +function SummaryMenu:placements() + if isEggMon(self.mon) then return self:eggPlacements() end + if self.moveDetail then return self:moveDetailPlacements() end + local out = self:upperPlacements() + local page + if self.page == GREEN_PAGE then + page = self:greenPlacements() + elseif self.page == BLUE_PAGE then + page = self:bluePlacements() + else + page = self:pinkPlacements() + end + for _, entry in ipairs(page) do out[#out + 1] = entry end + return out +end + +-- ------------------------------------------------------------------- input + +function SummaryMenu:close() + if self.onClose then self.onClose() end +end + +-- .d_right / .d_left. Right adds one and wraps past BLUE_PAGE back to +-- PINK_PAGE; left subtracts one and wraps from zero to BLUE_PAGE. +function SummaryMenu:turnPage(delta) + local page = self.page + delta + if page > BLUE_PAGE then page = PINK_PAGE end + if page < PINK_PAGE then page = BLUE_PAGE end + self.page = page +end + +-- The `down` and `.d_up` arms: no wrap at either end (both `jr z, .joypad_loop` +-- out rather than rolling round), and the page survives the switch because +-- StatsScreenMain pushes bc on the way in and pops it before jumping to the +-- page loader. +function SummaryMenu:switchMon(delta) + local next_ = self.index + delta + if next_ < 1 or next_ > #self.party then return false end + self.index = next_ + self.mon = self.party[next_] + self.moveIndex = 1 + self:playCry() + return true +end + +-- MoveScreenLoop's .cycle_right_loop / .cycle_left_loop: the move screen +-- walks the party but steps over EGG slots (`cp EGG / ret nz` inverted), so +-- it never opens on one. No wrap at either end, like switchMon. +function SummaryMenu:switchMonPastEggs(delta) + local next_ = self.index + delta + while self.party[next_] and isEggMon(self.party[next_]) do + next_ = next_ + delta + end + if next_ < 1 or next_ > #self.party then return false end + self.index = next_ + self.mon = self.party[next_] + self.moveIndex = 1 + self:playCry() + return true +end + +-- .place_move's `.copy_move` pair swaps the move byte and then the PP byte, so +-- a slot travels whole -- id, PP and the PP-Up ceiling ride in one entry here. +function SummaryMenu:swapMoves(from, to) + local moves = self.mon and self.mon.moves + if not (moves and from and to) or from == to then return false end + if not (moves[from] and moves[to]) then return false end + moves[from], moves[to] = moves[to], moves[from] + return true +end + +-- .swap_moves plays SFX_SWITCH_POKEMON twice (mon_menu.asm:1036-1041); a cache +-- without that cue simply makes no sound, the way the box menu guards its own. +function SummaryMenu:playSwapSfx() + local data = self.game and self.game.data + local ok, Sound = pcall(require, "src.core.Sound") + if not (ok and data and Sound and Sound.play) then return end + local sfx = data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, "Sfx_SwitchPokemon")] then + pcall(Sound.play, data, "Sfx_SwitchPokemon") + end +end + +-- MoveScreenLoop's .joy_loop. A picks a move up (.a_button stores wMenuCursorY +-- in wSwappingMove and draws the hollow cursor) and puts it down (.place_move); +-- B drops it back on the row it came from and only then exits. +function SummaryMenu:updateMoveDetail(input) + local moves = self:moveList() + local count = math.max(1, #moves) + if input:wasPressed("up") then + self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or count + elseif input:wasPressed("down") then + self.moveIndex = self.moveIndex < count and self.moveIndex + 1 or 1 + elseif input:wasPressed("a") then + if self.swapFrom then + if self:swapMoves(self.swapFrom, self.moveIndex) then self:playSwapSfx() end + self.swapFrom = nil + elseif moves[self.moveIndex] then + self.swapFrom = self.moveIndex + end + elseif input:wasPressed("right") then + -- MoveScreenLoop's .d_right / .d_left walk the party rather than the page, + -- and both `jp nz, .joy_loop` straight back out while a move is held. + if not self.swapFrom then self:switchMonPastEggs(1) end + elseif input:wasPressed("left") then + if not self.swapFrom then self:switchMonPastEggs(-1) end + elseif input:wasPressed("b") or input:wasPressed("select") then + if self.swapFrom then + self.moveIndex = self.swapFrom + self.swapFrom = nil + elseif self.moveScreen then + -- .exit: ManagePokemonMoves' whole screen goes, back to the party list. + self:close() + else + self.moveDetail = false + end + end +end + +function SummaryMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.moveDetail then + self:updateMoveDetail(input) + return + end + + -- EggStats_JoypadLoop masks PAD_DOWN | PAD_UP | PAD_A | PAD_B: A and B + -- both exit (StatsScreen_Exit), up and down walk the party, and there are + -- no pages to turn. + if isEggMon(self.mon) then + if input:wasPressed("a") or input:wasPressed("b") then + self:close() + elseif input:wasPressed("up") then + self:switchMon(-1) + elseif input:wasPressed("down") then + self:switchMon(1) + end + return + end + + -- .joypad_action masks with PAD_CTRL_PAD | PAD_A | PAD_B and tests B first. + if input:wasPressed("b") then + self:close() + return + end + if input:wasPressed("left") then + self:turnPage(-1) + return + end + if input:wasPressed("right") then + self:turnPage(1) + return + end + if input:wasPressed("a") then + -- .a_button quits on the last page and otherwise FALLS THROUGH into + -- .d_right; the fallthrough is the whole behaviour, not a missing branch. + if self.page == BLUE_PAGE then + self:close() + else + self:turnPage(1) + end + return + end + if input:wasPressed("up") then + self:switchMon(-1) + return + end + if input:wasPressed("down") then + self:switchMon(1) + return + end + -- The port's own hook onto MoveScreenLoop; see the header. + if input:wasPressed("select") and self.page == GREEN_PAGE then + self.moveDetail = true + self.moveIndex = 1 + end +end + +-- ----------------------------------------------------------------- drawing + +-- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that +-- sheet, so each of the seven shapes it needs is drawn here; the moment +-- menu_gfx grows a `stats` entry this can take the real tiles instead. +function SummaryMenu:pageTile(id, tx, ty) + local G = love.graphics + local px, py = tx * 8, ty * 8 + G.setColor(0, 0, 0, 1) + if id == TILE_VERTICAL_DIVIDER then + G.rectangle("fill", px + 3, py, 2, 8) + elseif id == TILE_BAR_CAP_LEFT then + G.rectangle("fill", px + 6, py + 1, 2, 6) + elseif id == TILE_BAR_CAP_RIGHT then + G.rectangle("fill", px, py + 1, 2, 6) + elseif id == TILE_SHINY then + -- ⁂ is an asterism: three dots, two low and one high. + G.rectangle("fill", px + 2, py + 1, 2, 2) + G.rectangle("fill", px, py + 4, 2, 2) + G.rectangle("fill", px + 4, py + 4, 2, 2) + end + G.setColor(1, 1, 1, 1) +end + +-- StatsScreen_LoadPageIndicators: three 2x2 squares at (13,5), (15,5) and +-- (17,5), all small ($36) first, then the one for this page redrawn large +-- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then +-- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip. +function SummaryMenu:drawPageSquare(tx, ty, large) + local G = love.graphics + local px, py = tx * 8, ty * 8 + -- $3a..$3d for the page that is up, $36..$39 for the other two. + local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL + local inset = first == TILE_SQUARE_LARGE and 2 or 5 + local size = 16 - inset * 2 + G.setColor(0, 0, 0, 1) + G.rectangle("fill", px + inset, py + inset, size, size) + G.setColor(1, 1, 1, 1) +end + +function SummaryMenu:drawPageIndicators() + local columns = { 13, 15, 17 } + for i, tx in ipairs(columns) do + self:drawPageSquare(tx, 5, i == self.page) + end +end + +function SummaryMenu:picImage(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +function SummaryMenu:picFor(mon) + local def = mon and self.pokemon and self.pokemon[mon.species] + local path = def and def.spriteFront + -- StatsScreen_PlaceFrontpic (engine/pokemon/stats_screen.asm:722): `ld hl, + -- wTempMonDVs / call GetUnownLetter` runs before the frontpic, so a party + -- Unown's page shows its own form, not letter A. + if mon and mon.species == Unown.SPECIES then + path = Unown.formSprite(self.pokemon, Unown.monLetter(mon)) or path + end + return self:picImage(path) +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) + 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 + -- through GbcPalette.color rather than off the raw table. + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + 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 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 + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function SummaryMenu:drawPic() + local mon = self.mon + local image = mon and self:picFor(mon) + if not image then return end + local colors = self.palettes and mon.species + and Palettes.monColors(self.palettes, mon.species, mon.shiny) or nil + self:drawPicBlock(image, colors) +end + +-- EggStatsScreen ends on `hlcoord 0, 0 / call PrepMonFrontpic` as well +-- (engine/pokemon/stats_screen.asm:786), and for an egg wCurPartySpecies is +-- EGG, so GetBaseData's `.egg` arm hands GetFrontpic EggPic at 5x5 +-- (home/pokemon.asm:239-245) rather than the hidden hatchling's pic. That +-- pic has no data.pokemon row of its own: the extractor writes it once as +-- menu_gfx.eggHatch.egg, the same file src/ui/gen2/EggHatchAnim.lua draws. +-- _CGB_StatsScreenHPPals colours it off the EGG palette row for the same +-- reason (`ld a, [wCurPartySpecies] / call GetPlayerOrMonPalettePointer`, +-- engine/gfx/cgb_layouts.asm:177-180), shininess read from the DVs, so a +-- shiny hatchling's egg takes EGG's shiny row. +-- +-- A cache built before the extractor grew menu_gfx.eggHatch.egg has no such +-- file, and every cache imported before that stage is one -- which is the +-- whole of "the SUMMARY shows no egg picture": the 7x7 block was simply left +-- blank. ICON_EGG is in icons.lua all the way back (ReadMonMenuIcon's +-- `cp EGG / jr z, .egg` arm, engine/gfx/mon_icons.asm), so the party list's +-- own egg stands in at 2x rather than the page showing nothing. It is a +-- fallback, not the layout: a cache with EggPic in it never reaches this. +function SummaryMenu:drawEggPic() + local gfx = (self.menuGfx or {}).eggHatch + local colors = Palettes.monColors(self.palettes, "EGG", + self.mon and self.mon.shiny) + local image = self:picImage(gfx and gfx.egg) + if image then return self:drawPicBlock(image, colors) end + self:drawEggIconFallback(colors) +end + +-- The ICON_EGG sheet is two 16x16 frames stacked into one 16x32 image +-- (src/ui/gen2/PartyMenu.lua reads the same entry); the first frame is the +-- egg at rest, which is the one the party list shows while nothing is moving. +function SummaryMenu:drawEggIconFallback(colors) + local icons = self.icons or {} + local entry = icons.icons and icons.icons.ICON_EGG + local image = self:picImage(entry and entry.image) + if not image then return end + local G = love.graphics + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", 0, 0, 7 * 8, 7 * 8) + local w = entry.width or 16 + local h = math.min(entry.height or 16, image:getHeight()) + if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end + local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h, + image:getWidth(), image:getHeight()) + if not ok then return end + -- Centred in the block at 2x: a 16x16 icon inside 7x7 tiles. + local x = math.floor((7 * 8 - w * 2) / 2) + local y = math.floor((7 * 8 - h * 2) / 2) + G.setColor(1, 1, 1, 1) + local function body() G.draw(image, quad, x, y, 0, 2, 2) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function SummaryMenu:drawPlacements(list) + for _, entry in ipairs(list) do + Chrome.print(entry.text, entry.x, entry.y) + end +end + +-- StatsScreen_PlaceHorizontalDivider: twenty $62 cells across row 7. $62 is +-- FontBattleExtra's empty HP/exp bar cell, so it resolves through the font +-- while useBattleExtra is on. +function SummaryMenu:drawHorizontalDivider() + love.graphics.setColor(0, 0, 0, 1) + for x = 0, Chrome.SCREEN_W - 1 do + Font.drawCode(TILE_HORIZONTAL_DIVIDER, x * 8, 7 * 8) + end +end + +function SummaryMenu:drawVerticalDivider(tx) + for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y) end +end + +function SummaryMenu:drawUpperHalf() + local mon = self.mon or {} + self:drawPic() + self:drawPlacements(self:upperPlacements()) + self:drawHorizontalDivider() + -- StatsScreen_PlacePageSwitchArrows, then StatsScreen_PlaceShinyIcon. + Chrome.print("◀", 12, 6) + Chrome.print("▶", 19, 6) + if mon.shiny then self:pageTile(TILE_SHINY, 19, 0) end + self:drawPageIndicators() +end + +function SummaryMenu:drawPinkPage() + local mon = self.mon or {} + local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + -- DrawPlayerHP is DrawBattleHPBar with d = 6 and b = 0: "HP:" at (0,9), six + -- bar cells, and the end cap at (8,9) -- which LoadPinkPage then rewrites as + -- $41, the same shape from the stats sheet. + if self.hud and self.hud:available() then + self.hud:drawHpBar(mon.hp, maxHp, 0, 9) + else + HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font) + end + self:drawVerticalDivider(9) + self:drawPlacements(self:pinkPlacements()) + + -- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and + -- fills eight of them walking left, with the $40/$41 caps outside at (10,16) + -- and (19,16). + local fraction = HpBar.expFraction(mon, self:growth(), Mon.experienceForLevel) + if self.hud and self.hud:available() then + self.hud:drawExpBar(fraction, 11, 16) + else + -- No HUD sheet in the cache: the plain rule, which is HP_BAR_LENGTH_PX + -- (48) wide rather than the exp bar's 64, so it stops two tiles short of + -- the $41 cap. A cache old enough to hit this has no bar tiles at all. + HpBar.drawExp(self.palettes, fraction, 11 * 8, 16 * 8 + 3) + end + self:pageTile(TILE_BAR_CAP_LEFT, 10, 16) + self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16) +end + +function SummaryMenu:drawGreenPage() + self:drawPlacements(self:greenPlacements()) +end + +function SummaryMenu:drawBluePage() + self:drawVerticalDivider(10) + self:drawPlacements(self:bluePlacements()) +end + +function SummaryMenu:drawMoveDetail() + local mon = self.mon or {} + Chrome.clear() + -- Textbox (0,1) with a 9x18 interior and Textbox (0,11) with a 5x18 one. + Chrome.textbox(0, 1, 18, 9) + Chrome.textbox(0, 11, 18, 5) + -- SetUpMoveScreenBG draws both boxes and only THEN places the nickname at + -- (5,1) -- the upper box's own top border row -- and PlaceMoveData writes + -- the TYPE plaque at (0,10)/(0,11), the lower box's top border row. Those + -- cells belong to the strings, not to the frames. + clearCells(5, 1, tiles(monName(mon)) + tiles(levelText(mon.level)), 1) + clearCells(0, 10, 7, 2) + -- PlaceMoveScreenLeftArrow / RightArrow only draw when there is a party mon + -- that way; both sit on row 0, above the list box. + if self.index > 1 then Chrome.print("◀", 16, 0) end + if self.index < #self.party then Chrome.print("▶", 18, 0) end + self:drawPlacements(self:moveDetailPlacements()) + -- MoveScreen2DMenuData: cursor column 1, first row 3, two rows per step. + local moves = self:moveList() + if #moves > 0 then + -- .a_button's PlaceHollowCursor parks a hollow '▷' on the held row. + if self.swapFrom then + Chrome.cursor(1, 3 + (math.min(self.swapFrom, #moves) - 1) * 2, true) + end + Chrome.cursor(1, 3 + (math.min(self.moveIndex, #moves) - 1) * 2) + end +end + +-- EggStatsScreen's draw: the row-7 divider, the fixed strings, and +-- PrepMonFrontpic over the EGG pic (engine/pokemon/stats_screen.asm:786). +-- The pic comes from menu_gfx.eggHatch.egg, never from the hidden species' +-- battle/front entry, which is why it goes through drawEggPic. +function SummaryMenu:drawEggPage() + Chrome.clear() + self:drawEggPic() + self:drawHorizontalDivider() + self:drawPlacements(self:eggPlacements()) +end + +function SummaryMenu:drawPanel() + -- StatsScreen_LoadFont is _LoadFontsBattleExtra, so $60-$7f on this screen + -- is the battle sheet: <LV> is the bold ":L" and $62 is the bar cell the + -- row-7 rule is made of. + local wasBattle = Font.useBattleExtra(true) + if isEggMon(self.mon) then + self:drawEggPage() + elseif self.moveDetail then + self:drawMoveDetail() + else + Chrome.clear() + self:drawUpperHalf() + if self.page == GREEN_PAGE then + self:drawGreenPage() + elseif self.page == BLUE_PAGE then + self:drawBluePage() + else + self:drawPinkPage() + end + end + Font.useBattleExtra(wasBattle) + love.graphics.setColor(1, 1, 1, 1) +end + +function SummaryMenu:draw() + self:drawPanel() +end + +function SummaryMenu: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +SummaryMenu.STAT_LABELS = STAT_LABELS +SummaryMenu.STAT_KEYS = STAT_KEYS +SummaryMenu.TYPE_NAMES = TYPE_NAMES +SummaryMenu.levelText = levelText + +return SummaryMenu diff --git a/src/ui/gen2/TileSheet.lua b/src/ui/gen2/TileSheet.lua new file mode 100644 index 00000000..d2907d68 --- /dev/null +++ b/src/ui/gen2/TileSheet.lua @@ -0,0 +1,106 @@ +-- A screen's own tile sheet, addressed the way the cart addresses it. +-- +-- The Gen 2 menus that are not built out of text boxes -- the #DEX, the +-- POKeGEAR, the trainer card -- work by copying a sheet into VRAM at a known +-- tile id and then writing tile ids into the tilemap. Transcribing one of +-- those screens means writing the same ids at the same hlcoords, so the only +-- primitive needed is "draw sheet tile $NN at (tx, ty)". +-- +-- `firstTile` is the VRAM id the sheet's tile 0 was loaded at (vTiles2 tile +-- $31 for the dex, $00 for the town map), so a tile id maps to a sheet index +-- by subtracting it. A sheet is `wide` tiles across, which is how the +-- extractor writes them. + +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") + +local TileSheet = {} +TileSheet.__index = TileSheet + +-- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a +-- paletteFor(tile, tx, ty) hook, transparent +function TileSheet.new(opts) + local self = setmetatable({}, TileSheet) + self.path = opts and opts.path + self.wide = (opts and opts.wide) or 16 + self.firstTile = (opts and opts.firstTile) or 0 + self.palette = opts and opts.palette + self.paletteFor = opts and opts.paletteFor + self.quads = {} + return self +end + +function TileSheet:image() + if self.loaded == nil then + self.loaded = false + if self.path then + -- `and` would truncate pcall's second return, so this cannot fold into + -- a one-liner. + local ok, image = pcall(Assets.image, self.path) + if ok and image then self.loaded = image end + end + end + return self.loaded or nil +end + +function TileSheet:available() + return self:image() ~= nil +end + +function TileSheet:quad(index) + local quad = self.quads[index] + if not quad then + local image = self:image() + if not image then return nil end + local w, h = image:getDimensions() + quad = love.graphics.newQuad( + (index % self.wide) * 8, math.floor(index / self.wide) * 8, 8, 8, w, h) + self.quads[index] = quad + end + return quad +end + +-- Draws VRAM tile `tile` at tile coordinates (tx, ty). A tile outside the +-- sheet is silently skipped, which is what lets a screen name a tile that +-- belongs to the font page instead. +function TileSheet:draw(tile, tx, ty) + local image = self:image() + if not image then return false end + local index = tile - self.firstTile + if index < 0 then return false end + local quad = self:quad(index) + if not quad then return false end + local _, sy = quad:getViewport() + local _, ih = image:getDimensions() + if sy >= ih then return false end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local colors = self.palette + if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end + local function body() G.draw(image, quad, tx * 8, ty * 8) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + return true +end + +-- A run of consecutive ids, left to right: the shape most of these routines +-- use for a header strip or a caption. +function TileSheet:run(first, count, tx, ty) + for i = 0, count - 1 do + self:draw(first + i, tx + i, ty) + end +end + +-- A rectangle of consecutive ids, row-major. +function TileSheet:block(first, wide, high, tx, ty) + for row = 0, high - 1 do + for col = 0, wide - 1 do + self:draw(first + row * wide + col, tx + col, ty + row) + end + end +end + +return TileSheet diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua new file mode 100644 index 00000000..c32ae18f --- /dev/null +++ b/src/ui/gen2/TitleState.lua @@ -0,0 +1,283 @@ +-- Gen 2 title: colored TitleScreenTilemap BG, scrolling clouds, Ho-Oh +-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue. +-- drawWidescreen fills the window with sky/clouds so widescreen has no +-- pillarbox voids; the 160x144 art stays aspect-centered on top. + +-- src/render/Assets.lua is the mod-override choke point: a raw +-- love.graphics.newImage skips overrides/ and AssetTransform output. +local Assets = require("src.render.Assets") +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 SpriteAnims = require("src.ui.gen2.SpriteAnims") + +local TitleState = {} +TitleState.__index = TitleState +TitleState.isOpaque = true + +-- title_bg_gold.pal mid-sky shade (sampled from composed title_screen.png). +local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 } +-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the +-- one thing in the port baked with its colours in (see the extractor), so the +-- window fill that matches it has to be picked the same way the sheet is. +-- The sky is BG colour 2, and LoadTitleScreenPals' rBGP (%11011000) sends +-- that colour to shade 1, not to shade 2 -- the grey sheet is baked through +-- the same register, so the fill has to follow it or the surround comes out +-- darker than the screen it surrounds. +local SKY_GRAY = { 170 / 255, 170 / 255, 170 / 255, 1 } + +local function tryImage(path) + if not path then return nil end + local ok, image = pcall(Assets.image, path) + if ok then return image end + return nil +end + +function TitleState:wantsFillScale() return true end +function TitleState:drawsWidescreen() return true end + +function TitleState.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TitleState) + self.game = game + self.onContinue = opts.onContinue + local title = opts.title or {} + self.title = title + self.screenColor = tryImage(title.screen + or "assets/generated/title/title_screen.png") + self.cloudsColor = tryImage(title.clouds + or "assets/generated/title/clouds.png") + self.trailColor = tryImage(title.trail or "assets/generated/title/trail.png") + -- The uncoloured set, absent from a cache built before COLOR existed -- in + -- which case pickArt falls back to the colour one and DMG simply looks the + -- way it did before rather than failing to draw. + self.screenGray = tryImage(title.screenGray) + self.cloudsGray = tryImage(title.cloudsGray) + self.trailGray = tryImage(title.trailGray) + -- `depixel 12, 11` less the OAM bias and the pose's own origin; see the + -- extractor, which writes the same pair into title.lua. + self.hoohX = tonumber(title.hoohX) or 48 + self.hoohY = tonumber(title.hoohY) or 56 + self.cloudY = tonumber(title.cloudY) or 88 + self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8 + + self.hoohColor, self.hoohGray = {}, {} + local paths = title.hoohFrames + if type(paths) == "table" then + for i, path in ipairs(paths) do + self.hoohColor[i] = tryImage(path) + end + end + if #self.hoohColor == 0 then + self.hoohColor[1] = tryImage(title.hooh or "assets/generated/title/hooh.png") + end + if type(title.hoohFramesGray) == "table" then + for i, path in ipairs(title.hoohFramesGray) do + self.hoohGray[i] = tryImage(path) + end + end + self.sequence = title.hoohSequence or { + { 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 }, + } + self.seqIndex = 1 + self.seqLeft = self.sequence[1] and self.sequence[1][2] or 10 + self.frame = 1 + + -- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1. + self.hoohPhase = 0 + self.frameCounter = 0 + self.cloudScroll = 0 + self.trails = {} + -- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm), in pixels. + self.trailSpawns = { + { 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 }, + { 120, 88 }, { 88, 88 }, + } + self.trailSpawnIndex = 1 + -- How far past the 160px frame trails may fly (GB pixels); set each draw. + self.trailMaxX = 200 + self.musicStarted = false + return self +end + +function TitleState:enter() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime and not self.musicStarted then + Music.play(data, "Music_TitleScreen", true, { reason = "title" }) + self.musicStarted = true + 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). + -- Its end is the main menu opening, which src/ui/gen2/MainMenu.lua owns, so + -- there is no `ended` name here. The title's MENU is a different seam again: + -- that is Gen 1's `ui.title_menu.items`, which Gold reaches through the main + -- menu rather than through this screen. + if Runtime.wants("intro.boot.title") then + Runtime.emit("intro.boot.title", { screen = self, game = self.game }) + end +end + +-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm): VAR1 counts up +-- one per frame and the struct's Y offset becomes `d * sin(VAR1 * pi/32)` with +-- d = 2 on Gold (Silver counts DOWN with d = 8). Sprites_Sine hands back the +-- byte the ASM leaves in a, so the down half of the wave arrives in two's +-- complement and has to be read as a signed pixel delta here. +function TitleState:hoohBob() + local value = SpriteAnims.sine(self.hoohPhase, 2) + if value >= 0x80 then value = value - 0x100 end + return value +end + +function TitleState:advanceHooh() + self.hoohPhase = (self.hoohPhase + 1) % 256 + self.seqLeft = self.seqLeft - 1 + if self.seqLeft > 0 then return end + self.seqIndex = self.seqIndex + 1 + if self.seqIndex > #self.sequence then self.seqIndex = 1 end + local step = self.sequence[self.seqIndex] + self.frame = step[1] + self.seqLeft = step[2] +end + +function TitleState:spawnTrail() + if not (self.trailColor or self.trailGray) then return end + if self.frameCounter % 4 ~= 0 then return end + local spawn = self.trailSpawns[self.trailSpawnIndex] + self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1 + if not spawn then return end + self.trails[#self.trails + 1] = { + x = spawn[1], y = spawn[2], phase = love.math.random(0, 255), + } +end + +function TitleState:stepTrails() + local alive = {} + local maxX = self.trailMaxX or 200 + for _, t in ipairs(self.trails) do + t.x = t.x + 4 + t.y = t.y + 1 + t.phase = t.phase + 3 + t.drawY = t.y + math.floor(math.sin(t.phase / 16) * 2) + if t.x < maxX then alive[#alive + 1] = t end + end + self.trails = alive +end + +function TitleState:update(_dt) + self.frameCounter = self.frameCounter + 1 + self:advanceHooh() + if self.frameCounter % self.cloudScrollEvery == 0 then + self.cloudScroll = (self.cloudScroll + 1) % 160 + end + self:spawnTrail() + self:stepTrails() + + local input = self.game.input + if input and (input:wasPressed("a") or input:wasPressed("start")) then + if self.onContinue then self.onContinue() end + end +end + +-- Which of the two baked sets is showing. GBC is the cart's colour; every +-- other COLOR mode wants the grey source, and CLASSIC's green comes from the +-- present pass over the finished frame rather than from a third set of art. +function TitleState:gray() + return GbcPalette.mode ~= "gbc" +end + +function TitleState:art() + if self:gray() then + return self.screenGray or self.screenColor, + self.cloudsGray or self.cloudsColor, + self.trailGray or self.trailColor, + (#self.hoohGray > 0) and self.hoohGray or self.hoohColor + end + return self.screenColor, self.cloudsColor, self.trailColor, self.hoohColor +end + +-- Tile the cloud strip across [x0, x1) in GB pixel space (y = 0 of strip). +function TitleState:drawCloudSpan(x0, x1) + local _, clouds = self:art() + if not clouds then return end + local G = love.graphics + local s = self.cloudScroll % 160 + -- First tile origin such that the scroll lines up with the 160px frame. + local start = math.floor((x0 + s) / 160) * 160 - s + for x = start, x1, 160 do + G.draw(clouds, x, 0) + end +end + +function TitleState:drawContent() + local G = love.graphics + local screen, _, trail, hoohFrames = self:art() + G.setColor(1, 1, 1, 1) + if screen then + -- title_screen.png already carries the cart's © GAME FREAK line on row 17. + G.draw(screen, 0, 0) + else + G.rectangle("fill", 0, 0, 160, 144) + end + + -- Center cloud scroll (matches the side tiles from drawWidescreen). + G.push() + G.translate(0, self.cloudY) + self:drawCloudSpan(0, 160) + G.pop() + + local bob = self:hoohBob() + local hooh = hoohFrames[self.frame] or hoohFrames[1] + if hooh then + G.draw(hooh, self.hoohX, self.hoohY + bob) + end + + if trail then + for _, t in ipairs(self.trails) do + G.draw(trail, t.x, t.drawY or t.y) + end + end +end + +-- Letterboxed fallback (non-widescreen hosts). +function TitleState:draw() + self:drawContent() +end + +-- Full-window sky + cloud wrap, then centered 160x144 art. +function TitleState:drawWidescreen(winW, winH) + local G = love.graphics + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + local cloudTop = oy + self.cloudY * scale + -- Let trails fly into the side bands. + self.trailMaxX = math.ceil((winW - ox) / scale) + 16 + + -- Sky above the cloud line, paper white below : edge to edge. The fill has + -- to match whichever baked set is showing, or the surround would stay blue + -- around a grey screen. + local sky = self:gray() and SKY_GRAY or SKY + G.setColor(sky[1], sky[2], sky[3], 1) + G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop)) + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop) + + -- Clouds across the full window width, aligned to the GB cloud band. + G.push() + G.translate(ox, cloudTop) + G.scale(scale, scale) + local left = -math.ceil(ox / scale) - 160 + local right = math.ceil((winW - ox) / scale) + 160 + self:drawCloudSpan(left, right) + G.pop() + + -- Centered original composition (logo / Ho-Oh / copyright / trails). + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + self:drawContent() + G.pop() +end + +return TitleState diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua new file mode 100644 index 00000000..9f34d5c1 --- /dev/null +++ b/src/ui/gen2/TradeAnim.lua @@ -0,0 +1,855 @@ +-- The trade animation's screen (engine/movie/trade_animation.asm). +-- +-- The script and every frame count is src/core/gen2/TradeAnim.lua's; this file +-- is the half that draws, so the sequence can be walked in a test with no +-- window. NPCTrade runs it between DoNPCTrade and TradedForText and nothing +-- about the trade depends on it, so B skips straight to the end -- the cart +-- has no skip, but it also has no player who has seen this thirty seconds of +-- cable six times in one save. +-- +-- gfx/trade/ comes out of the cache as data.gen2Trade +-- (RomExtractorGen2:extractTrade): one 49-tile sheet the two tilemaps index, +-- plus the ball, the poof, the tube bulge and the bubble as OAM sheets. A +-- cache built before that stage leaves every one of them nil, and then +-- everything below the frontpics falls back to the shapes those tiles are, +-- drawn at the coordinates the tilemap puts them, the same way +-- src/ui/gen2/EvolutionAnim.lua draws its balls of light. Art and shapes +-- alike go through GbcPalette so the COLOR option still reaches them. +-- +-- THE OBJECTS ARE QUADRANTS. data/sprite_anims/oam.asm builds the poof and +-- the bubble out of a 2x2 block drawn four times (X-flipped, Y-flipped, both) +-- into a 32x32 sprite, and the bulge out of a single tile the same way; the +-- ball's first wobble frame is its left half with the right half X-flipped. +-- So a sheet of 4 tiles really is a 32x32 puff, and :drawQuadrant is that +-- mirroring rather than a shortcut. +-- +-- A sprite anim's x, y is its ORIGIN, which for these centred objects is the +-- middle of the sprite: an OAM entry at dbsprite -1, -1 lands 8 pixels up and +-- left of it. The positions below are origins for that reason. +-- +-- Coordinates worth keeping, all from the ASM: +-- +-- * the frontpic is PlaceGraphic's 7x7 box at hlcoord 7, 2, i.e. (56, 16). +-- * the stats panel is a `Textbox` at hlcoord 3, 0 with `lb bc, 6, 13` -- +-- 15x8 tiles -- drawn into vBGMap1, which is the WINDOW map, and the +-- window sits at hWY $50. So it lands at (24, 80), under the pic. +-- * the link tube is a 12x3 tilemap at hlcoord 8, 2: (64, 16), 96x24. +-- * TradeAnim_RockingBall's `depixel 10, 11, 4, 0` is y first, and an OAM +-- object draws at (x - 8, y - 16), so the ball is at (80, 68). +-- * the tube bulge's `depixel 5, 11` is (80, 24), inside the tube. +-- +-- SCX scrolls the BACKGROUND, so a positive hSCX moves the picture LEFT: the +-- give-mon pic arrives from the left while its window arrives from the right, +-- and the link tube slides in from the left and back out the same way. + +local Anim = require("src.core.gen2.TradeAnim") +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Music = require("src.core.Music") +local Palettes = require("src.world.gen2.Palettes") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local TradeMenu = require("src.ui.gen2.TradeMenu") +local Unown = require("src.core.gen2.Unown") + +local TradeAnimView = {} +TradeAnimView.__index = TradeAnimView +TradeAnimView.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- PlaceGraphic's box and the stats panel's Textbox, in tiles. +local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 +local PANEL_X, PANEL_Y = 3, 0 +local PANEL_INNER_W, PANEL_INNER_H = 13, 6 +local WINDOW_Y = 0x50 + +-- TradeLinkTubeTilemap at hlcoord 8, 2. +local TUBE_X, TUBE_Y, TUBE_W, TUBE_H = 64, 16, 96, 24 + +-- The ball, the poof and the bulge, from their depixel rows. +local BALL_X, BALL_Y = 80, 68 +local BULGE_Y = 24 +-- TradeAnim_DropBall's SPRITEANIMSTRUCT_YOFFSET is $dc, i.e. -36: the ball +-- starts that far above its resting place and falls into it. +local DROP_OFFSET = -36 + +-- TradeAnim_Poof's wFrameCounter. +local POOF_FRAMES = 16 + +-- The unrolled Game Boy scene, in the pixels the three tilemap states put it +-- at once the 256-wide wrap is laid out flat. State 0 has the Game Boy at +-- hlcoord 3, 2 with the cable starting at hlcoord 9, 3; state 2 has the second +-- one at hlcoord 10, 6 with the cable turning down at column 17 -- and state 2 +-- is only ever seen after the window has wrapped, so its columns sit a full +-- $100 further along. +local GB_W, GB_H = 48, 64 +local GB_A_X, GB_A_Y = 24, 16 +local GB_B_X, GB_B_Y = 0x100 + 80, 48 +local CABLE_Y = 3 * 8 + 4 +local CABLE_FROM = 72 +local CABLE_TURN = 0x100 + 17 * 8 +local CABLE_DOWN_TO = 7 * 8 +local CABLE_IN = 0x100 + 16 * 8 + +-- The same scene as tiles. TradeAnim_TubeAnimJumptable ByteFills the cable +-- around the two TradeGameBoyTilemap stamps rather than drawing it from a +-- tilemap of its own: $5b is the plug that meets a Game Boy, $60 the +-- horizontal run, $5d the corner turning down, $61 the vertical run and $5f +-- the corner turning back left. Unrolled, columns 0-31 are state 0's map and +-- 32-51 are state 2's, which is the same 256-pixel wrap the pixel constants +-- above lay out flat; state 1 is only ever the middle of that run. +-- +-- Columns 20-31 of row 3 are TradeAnim_InitTubeAnim's own +-- `hlbgcoord 20, 3 / ld bc, 12 / ld a, $60`, off the right of the 20-wide +-- tilemap, which is what keeps the cable unbroken across the seam. +local CABLE_PLUG, CABLE_RUN = 0x5b, 0x60 +local CABLE_CORNER_DOWN, CABLE_DROP, CABLE_CORNER_IN = 0x5d, 0x61, 0x5f +local CABLE_CELLS = {} +do + local function cell(column, row, id) + CABLE_CELLS[#CABLE_CELLS + 1] = { column, row, id } + end + cell(9, 3, CABLE_PLUG) + for column = 10, 48 do cell(column, 3, CABLE_RUN) end + cell(49, 3, CABLE_CORNER_DOWN) + for row = 4, 6 do cell(49, row, CABLE_DROP) end + cell(49, 7, CABLE_CORNER_IN) + cell(48, 7, CABLE_PLUG) +end + +-- The strip TradeAnim_PlaceTrademonStatsOnTubeAnim leaves under the two pans. +-- It is written into vBGMap1, which is the WINDOW map, and +-- TradeAnim_InitTubeAnim then parks the window at hWX $7 / hWY $70: window +-- column 0 lands at screen x 0 and window row 0 at y 112, so four rows show. +-- +-- row 0 SCREEN_WIDTH of '─' +-- row 1 wLinkPlayer1Name at hlcoord 0, 1 -- TradeAnimation loads that from +-- wPlayerTrademonSenderName, so it is the player +-- row 2 six arrow tiles ByteFilled at hlcoord 7, 2, pointing the way the +-- trade is going: TradeAnim_TubeToOT1 passes the right arrow, +-- TradeAnim_TubeToPlayer1 the left one +-- row 3 wLinkPlayer2Name, right-aligned. `hlcoord 0, 4 / add hl, de` with +-- de = -(name length) is one linear subtraction across the row +-- boundary, which lands it at column 20 - length of row 3. +local STRIP_Y = 0x70 +local STRIP_RULE_ROW, STRIP_NAME_ROW = 0, 1 +local STRIP_ARROW_ROW, STRIP_OT_ROW = 2, 3 +local STRIP_ARROW_X, STRIP_ARROWS = 7, 6 + +-- The speech box every text beat prints into, and its two lines. +local BOX_X, BOX_Y, BOX_W, BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 + +-- Which beats hold the speech box open. The cart reaches +-- TradeAnim_SentToOTText with a cleared tilemap and prints into it from there; +-- TradeAnim_ScrollOutRight clears it again before each pan, and +-- TradeAnim_TakeCareOfText opens a last one over the received mon. +local BOX_BEATS = { + sent_blank = true, sent_text = true, ot_sends_a = true, ot_sends_b = true, + farewell_a = true, farewell_b = true, take_care = true, +} + +-- The beats that show the give-mon panel, the ones that show the tube, and the +-- ones that show the received mon. +local GIVE_BEATS = { givemon_scroll = true, givemon_hold = true } +local TUBE_BEATS = { + tube_in = true, tube_hold = true, ball_rock = true, bulge = true, + tube_in2 = true, tube_hold2 = true, tube_out = true, ball_wait = true, +} +local GET_BEATS = { + getmon_poof = true, getmon_hold = true, take_care = true, +} + +-- Fallbacks for a cache built before the extractor reached the animation's own +-- lines (data/text/common_1.asm). Each is transcribed with the buffer list +-- its text_ram rows name, so the two mons and the two trainers cannot swap. +local FALLBACK = { + _MonWasSentToText = { + text = Strings.source("{STRBUF} was\nsent to {STRBUF}."), + buffers = { "wPlayerTrademonSpeciesName", "wOTTrademonSenderName" }, + }, + _ForYourMonSendsText = { + text = Strings.source("For {STRBUF}'s\n{STRBUF},"), + buffers = { "wPlayerTrademonSenderName", "wPlayerTrademonSpeciesName" }, + }, + _OTSendsText = { + text = Strings.source("{STRBUF} sends\n{STRBUF}."), + buffers = { "wOTTrademonSenderName", "wOTTrademonSpeciesName" }, + }, + _BidsFarewellToMonText = { + text = Strings.source("{STRBUF} bids\nfarewell to"), + buffers = { "wOTTrademonSenderName" }, + }, + _MonNameBidsFarewellText = { + text = Strings.source("{STRBUF}."), + buffers = { "wOTTrademonSpeciesName" }, + }, + _TakeGoodCareOfMonText = { + text = Strings.source("Take good care of\n{STRBUF}."), + buffers = { "wOTTrademonSpeciesName" }, + }, +} + +-- TrademonStats_MonTemplate's own string, placed at hlcoord 4, 0. `next` in a +-- text box steps TWO rows, which is why the template's four lines land on the +-- rows PrintSpeciesName / PrintOTName / PrintTrademonID write into (0, 2, 4 +-- and 6). +local TEMPLATE_ROWS = { + { row = 0, text = "─── №." }, + { row = 4, text = "OT/" }, + { row = 6, text = "<ID>№." }, +} + +-------------------------------------------------------------------------- +-- Construction +-------------------------------------------------------------------------- + +function TradeAnimView:wantsFillScale() return true end +function TradeAnimView:drawsWidescreen() return true end + +-- opts: +-- row the npc_trades.asm row, for the OT name and id +-- given the party record that just left (NpcTrade.perform's first answer) +-- received the one that arrived (its second) +-- save for the player's own name and id +-- eventTables data/generated/events.lua, for the animation's lines +-- onDone() fired once, on the last frame or on a skip +function TradeAnimView.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TradeAnimView) + self.game = game + self.data = (game and game.data) or {} + self.palettes = self.data.gen2Palettes + self.save = opts.save or (game and game.save) + self.row = opts.row + self.eventTables = opts.eventTables or {} + self.onDone = opts.onDone + self.give, self.get = Anim.records(self.data, self.save, self.row, + opts.given, opts.received) + self.frame = -1 + self.beatIndex = nil + self.picCache = {} + self.iconCache = {} + self.gfx = opts.gfx or self.data.gen2Trade + self.imageCache = {} + self.quadCache = {} + -- RunTradeAnimScript's `ld de, MUSIC_EVOLUTION / call PlayMusic2`. The map + -- theme comes back on the way out, which is NPCTrade's own RestartMapMusic. + local songs = self.data.audio and self.data.audio.songs + if songs and songs.Music_Evolution then + Music.play(self.data, "Music_Evolution", true, { reason = "trade" }) + end + self:step() + return self +end + +function TradeAnimView: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 TradeAnimView:playCry(species) + local cries = self.data.audio and self.data.audio.cries + if species and cries and cries[species] then + Sound.playCry(self.data, species) + end +end + +-------------------------------------------------------------------------- +-- Clock +-------------------------------------------------------------------------- + +-- One frame of DoTradeAnimation: pick the beat this frame lands in and, when +-- that is a new one, fire the setup the commands before it did. +function TradeAnimView:step() + self.frame = self.frame + 1 + local beat, offset, index = Anim.beatAt(self.frame) + self.beat, self.offset = beat, offset + if index ~= self.beatIndex then + self.beatIndex = index + self:cue(beat.cue) + end +end + +function TradeAnimView:cue(cue) + if cue == "show_give" then + -- TradeAnim_ShowGivemonData: the stats, the frontpic, then the cry. + self:playCry(self.give.species) + elseif cue == "poof" then + -- TradeAnim_Poof's SFX_BALL_POOF, then EnterLinkTube1's SFX_POTION. + self:playSfx("Sfx_BallPoof") + self:playSfx("Sfx_Potion") + elseif cue == "tube" then + self:playSfx("Sfx_Potion") + elseif cue == "give_sfx" then + self:playSfx("Sfx_GiveTrademon") + elseif cue == "get_sfx" then + self:playSfx("Sfx_GetTrademon") + elseif cue == "drop" then + self:playSfx("Sfx_BallPoof") + elseif cue == "show_get" then + self:playSfx("Sfx_BallPoof") + self:playCry(self.get.species) + end +end + +function TradeAnimView:finish() + if self.done then return end + self.done = true + -- NPCTrade's RestartMapMusic, which runs before TradedForText. + Music.restoreMap(self.data) + if self.onDone then self.onDone() end +end + +function TradeAnimView:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if input and (input:wasPressed("b") or input:wasPressed("start")) then + return self:finish() + end + if self.frame + 1 >= Anim.TOTAL then return self:finish() end + self:step() +end + +-------------------------------------------------------------------------- +-- Text +-------------------------------------------------------------------------- + +-- The four buffers the animation's lines name. The port decodes every +-- text_ram to the same {STRBUF}, so which mon or trainer a marker meant comes +-- out of the buffer list the extractor recorded beside the text. +function TradeAnimView:buffers() + return { + wPlayerTrademonSpeciesName = self.give.name, + wPlayerTrademonSenderName = self.give.senderName, + wOTTrademonSpeciesName = self.get.name, + wOTTrademonSenderName = self.get.senderName, + } +end + +function TradeAnimView.fill(body, names, buffers) + local index = 0 + return (tostring(body or ""):gsub("{STRBUF}", function() + index = index + 1 + local slot = (buffers or {})[index] + return (slot and names[slot]) or names.wOTTrademonSpeciesName or "" + end)) +end + +-- The line a beat prints, as up to two rows. The cache wins; the fallback is +-- the same string with the same buffer order. +function TradeAnimView:lines(id) + local label = Anim.TEXT[id] + if not label then return nil end + local texts = self.eventTables.tradeTexts or {} + local body = texts[label] + local buffers = (self.eventTables.tradeBuffers or {})[label] + if type(body) ~= "string" then + local fallback = FALLBACK[label] + if not fallback then return nil end + body, buffers = fallback.text, fallback.buffers + end + local pages = TradeMenu.paginate( + TradeAnimView.fill(body, self:buffers(), buffers)) + return pages[1] +end + +-------------------------------------------------------------------------- +-- Draw helpers +-------------------------------------------------------------------------- + +-- Every shape on this screen is one of the four colours of the text palette, +-- which is what SCGB_TRADE_TUBE and TradeAnim_NormalPals leave the background +-- reading through. +-- `index` is a GB shade, 0 (white) to 3 (black); GbcPalette.color counts from +-- 1, as the palettes themselves do. +function TradeAnimView:shade(index) + local colors = Palettes.textColors(self.palettes) + local rgb = GbcPalette.color(colors, index + 1) + or ({ { 255, 255, 255 }, { 168, 168, 168 }, { 96, 96, 96 }, + { 0, 0, 0 } })[index + 1] + return rgb[1] / 255, rgb[2] / 255, rgb[3] / 255 +end + +function TradeAnimView:setShade(index) + love.graphics.setColor(self:shade(index)) +end + +-- Art and shapes read through the same four colours, so a sheet is blitted +-- inside the palette the way a mon pic is: the PNG's own greys are shades 0-3 +-- and GbcPalette maps them. A driver with no shader draws the greys, which +-- is the DMG ramp and not a black frame. +function TradeAnimView:through(body) + local colors = Palettes.textColors(self.palettes) + love.graphics.setColor(1, 1, 1, 1) + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnimView:image(path) + if not path then return nil end + local cached = self.imageCache[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + if cached then cached:setFilter("nearest", "nearest") end + self.imageCache[path] = cached + end + return cached or nil +end + +-- One of the object sheets (ball, poof, bulge, bubble, arrows), as its image +-- and how many tiles across it is. nil for a cache that predates +-- RomExtractorGen2:extractTrade, which is what puts every caller on its +-- fallback shape. +function TradeAnimView:art(key) + local entry = self.gfx and self.gfx[key] + if type(entry) ~= "table" then return nil end + local image = self:image(entry.image) + if not image then return nil end + return image, entry.sheetTiles or 1 +end + +-- TradeGameBoyLZ's 49 tiles, plus the base tile id every tilemap byte counts +-- from ($31, because the BG runs in $8800 mode and the sheet went to vTiles2). +function TradeAnimView:sheet() + local gfx = self.gfx + if not (gfx and gfx.image) then return nil end + local image = self:image(gfx.image) + if not image then return nil end + return image, gfx.sheetTiles or 7, gfx.baseTile or 0x31 +end + +function TradeAnimView:quad(image, across, index) + local perImage = self.quadCache[image] + if not perImage then + perImage = {} + self.quadCache[image] = perImage + end + local quad = perImage[index] + if not quad then + quad = love.graphics.newQuad(index % across * 8, + math.floor(index / across) * 8, 8, 8, image:getDimensions()) + perImage[index] = quad + end + return quad +end + +-- One 8x8 tile, optionally mirrored the way an OAM attribute mirrors it. A +-- flipped draw scales by -1, so the anchor moves a tile along that axis. +function TradeAnimView:blit(image, across, index, x, y, flipX, flipY) + love.graphics.draw(image, self:quad(image, across, index), + x + (flipX and 8 or 0), y + (flipY and 8 or 0), 0, + flipX and -1 or 1, flipY and -1 or 1) +end + +-- A tilemap stamp (TradeAnim_CopyBoxFromDEtoHL), at the pixel its hlcoord +-- lands on. false when there is no cache art, so the caller can fall back. +function TradeAnimView:drawTilemap(map, x, y) + if type(map) ~= "table" or type(map.tiles) ~= "table" then return false end + local image, across, base = self:sheet() + if not image then return false end + self:through(function() + for index = 0, map.width * map.height - 1 do + local id = map.tiles[index + 1] + if id and id >= base then + self:blit(image, across, id - base, + x + index % map.width * 8, y + math.floor(index / map.width) * 8) + end + end + end) + return true +end + +-- An object built out of one mirrored quadrant: `side` tiles square, drawn +-- four times into a sprite twice that size, centred on the origin x, y. The +-- flipped halves count their tiles back the other way, which is exactly what +-- AddOrSubtractX's `-8 - a` does to each OAM entry. +function TradeAnimView:drawQuadrant(image, across, first, side, x, y) + for quadY = 0, 1 do + for quadX = 0, 1 do + for tileY = 0, side - 1 do + for tileX = 0, side - 1 do + local dx = (quadX == 0) and (tileX - side) or (side - 1 - tileX) + local dy = (quadY == 0) and (tileY - side) or (side - 1 - tileY) + self:blit(image, across, first + tileY * side + tileX, + x + dx * 8, y + dy * 8, quadX == 1, quadY == 1) + end + end + end + end +end + +function TradeAnimView:pic(record) + local species = record and record.species + local def = species and self.data.pokemon and self.data.pokemon[species] + local path = def and def.spriteFront + -- TradeAnim_GetFrontpic (engine/movie/trade_animation.asm:795-804) runs + -- `predef GetUnownLetter` before GetBaseData and GetMonFrontpic, so the mon + -- in the tube is the form that was actually traded. TradeAnim.records + -- carries the DVs across for exactly this. + if species == Unown.SPECIES then + path = Unown.formSprite(self.data.pokemon, Unown.monLetter(record)) or path + end + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- "and" would truncate the pcall's second return, so the call stands on + -- its own line. + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.picCache[path] = cached + end + return cached or nil +end + +-- TradeAnim_ShowFrontpic's PlaceGraphic: the pic is padded into the 7x7 box +-- bottom-first, so a short mon still stands on the box's floor. +function TradeAnimView:drawPic(record, offset) + local image = self:pic(record) + if not image then return end + local G = love.graphics + local w, h = image:getDimensions() + local box = PIC_TILES * 8 + local px = PIC_TILE_X * 8 + math.floor((box - w) / 2) - offset + local py = PIC_TILE_Y * 8 + (box - h) + G.setColor(1, 1, 1, 1) + local colors = Palettes.monColors(self.palettes, record.species, + record.shiny) + local function body() G.draw(image, px, py) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- ShowPlayerTrademonStats / ShowOTTrademonStats, in the window. +function TradeAnimView:drawStats(record, offset) + local G = love.graphics + G.push() + G.translate(offset, WINDOW_Y) + Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H) + for _, row in ipairs(TEMPLATE_ROWS) do + Chrome.print(row.text, PANEL_X + 1, row.row) + end + Chrome.print(Chrome.number(record.dex or 0, 3, true), PANEL_X + 7, 0) + Chrome.print(record.name, PANEL_X + 1, 2) + Chrome.print(record.otName, PANEL_X + 4, 4) + Chrome.print(Chrome.number(record.id or 0, 5, true), PANEL_X + 4, 6) + G.pop() +end + +-- TradeLinkTubeTilemap: a flat tube with a rounded cap at each end. The +-- middle rows are hollow, which is what lets the bulge read as something +-- moving INSIDE it. +function TradeAnimView:drawTube(offset) + local G = love.graphics + local x = TUBE_X - offset + if self:drawTilemap(self.gfx and self.gfx.tube, x, TUBE_Y) then return end + self:setShade(3) + G.rectangle("line", x + 0.5, TUBE_Y + 0.5, TUBE_W - 1, TUBE_H - 1, 6, 6) + self:setShade(2) + G.rectangle("fill", x + 4, TUBE_Y + 4, TUBE_W - 8, 2) + G.rectangle("fill", x + 4, TUBE_Y + TUBE_H - 6, TUBE_W - 8, 2) + G.setColor(1, 1, 1, 1) +end + +-- TradeBallGFX, on .Frameset_TradePokeBallWobble: four frames of 3 ticks +-- each, frame 1 / frame 2 / frame 1 / frame 2 X-flipped, which is the wobble. +-- Frame 1 is tiles 0 and 1 as the ball's left half with the right half +-- mirrored; frame 2 is tiles 2-5 in a plain 2x2. x, y is the origin, so the +-- object hangs 8 pixels up and left of it. +function TradeAnimView:drawBall(x, y, rocking) + local G = love.graphics + local image, across = self:art("ball") + if image then + local step = rocking and math.floor((self.offset or 0) / 3) % 4 or 0 + self:through(function() + if step % 2 == 0 then + self:blit(image, across, 0, x - 8, y - 8) + self:blit(image, across, 0, x, y - 8, true) + self:blit(image, across, 1, x - 8, y) + self:blit(image, across, 1, x, y, true) + else + -- The fourth frame's B_OAM_XFLIP mirrors the whole object, so the + -- two columns swap places as well as flipping. + local flip = (step == 3) + for index = 0, 3 do + local column = index % 2 + if flip then column = 1 - column end + self:blit(image, across, 2 + index, + x - 8 + column * 8, y - 8 + math.floor(index / 2) * 8, flip) + end + end + end) + return + end + local lean = 0 + if rocking then lean = (math.floor(self.offset / 8) % 2 == 0) and -1 or 1 end + local cx, cy = x + 4 + lean, y + 4 + self:setShade(3) + G.circle("fill", cx, cy, 5) + self:setShade(0) + G.circle("fill", cx, cy, 4) + self:setShade(3) + G.rectangle("fill", cx - 4, cy - 1, 9, 2) + G.circle("fill", cx, cy, 1.5) + G.setColor(1, 1, 1, 1) +end + +-- TradePoofGFX on .Frameset_TradePoof: three 4-tile frames at 4 ticks each, +-- then oamdelete. Each frame is a 2x2 quadrant mirrored into a 32x32 puff. +function TradeAnimView:drawPoof(x, y, t) + local G = love.graphics + local image, across = self:art("poof") + if image then + local frame = math.min(2, math.floor(t / 4)) + self:through(function() + self:drawQuadrant(image, across, frame * 4, 2, x, y) + end) + return + end + local step = math.floor(t / 4) + local radius = 4 + step * 3 + self:setShade(3 - math.min(2, step)) + G.setLineWidth(2) + G.circle("line", x + 4, y + 4, radius) + G.setLineWidth(1) + G.setColor(1, 1, 1, 1) +end + +-- The unrolled Game Boy scene the two pans travel: the player's Game Boy, the +-- cable, the turn down and the other Game Boy. +function TradeAnimView:drawScene(pan) + local G = love.graphics + G.push() + G.translate(-pan, 0) + local image, across, base = self:sheet() + if image and self.gfx.gameBoy then + self:through(function() + for _, cell in ipairs(CABLE_CELLS) do + self:blit(image, across, cell[3] - base, cell[1] * 8, cell[2] * 8) + end + end) + self:drawGameBoy(GB_A_X, GB_A_Y) + self:drawGameBoy(GB_B_X, GB_B_Y) + G.pop() + G.setColor(1, 1, 1, 1) + return + end + self:setShade(3) + G.rectangle("fill", CABLE_FROM, CABLE_Y, CABLE_TURN - CABLE_FROM, 2) + G.rectangle("fill", CABLE_TURN, CABLE_Y, 2, CABLE_DOWN_TO - CABLE_Y) + G.rectangle("fill", CABLE_IN, CABLE_DOWN_TO, CABLE_TURN - CABLE_IN + 2, 2) + self:drawGameBoy(GB_A_X, GB_A_Y) + self:drawGameBoy(GB_B_X, GB_B_Y) + G.pop() + G.setColor(1, 1, 1, 1) +end + +-- TradeGameBoyTilemap, 6x8 tiles: a body with a screen in its top half. +function TradeAnimView:drawGameBoy(x, y) + local G = love.graphics + if self:drawTilemap(self.gfx and self.gfx.gameBoy, x, y) then return end + self:setShade(3) + G.rectangle("fill", x, y, GB_W, GB_H, 5, 5) + self:setShade(1) + G.rectangle("fill", x + 4, y + 4, GB_W - 8, GB_H - 26, 2, 2) + self:setShade(0) + G.rectangle("fill", x + 8, y + 8, GB_W - 16, GB_H - 34) + self:setShade(1) + G.circle("fill", x + GB_W - 12, y + GB_H - 16, 3) + G.circle("fill", x + GB_W - 22, y + GB_H - 12, 3) + G.rectangle("fill", x + 8, y + GB_H - 17, 8, 3) + G.rectangle("fill", x + 10, y + GB_H - 19, 3, 8) + G.setColor(1, 1, 1, 1) +end + +-- TradeCableGFX on .Frameset_TradeTubeBulge: two one-tile frames at 3 ticks +-- apiece, each mirrored into the 16x16 bulge that travels inside the tube. +-- It is called "cable" in gfx/trade/ but loaded at the tile the bulge's +-- $12/$13 dictionary offsets point at, which is the only thing that reads it. +function TradeAnimView:drawBulge(x, y, t) + local image, across = self:art("bulge") + if not image then return self:drawBall(x, y, false) end + local frame = math.floor((t or 0) / 3) % 2 + self:through(function() + self:drawQuadrant(image, across, frame, 1, x, y) + end) +end + +-- The bubble the mon icon rides in (TradeBubbleGFX plus MONICON_TRADE). The +-- bubble is a 2x2 quadrant mirrored into 32x32 and the icon is a plain 2x2 +-- (.OAMData_RedWalk), both centred on the same origin; the icon is the party +-- icon sheet's first frame, which is what LoadMenuMonIcon hands the sprite. +function TradeAnimView:drawBubble(record, x, y) + local G = love.graphics + local image, across = self:art("bubble") + if image then + self:through(function() + self:drawQuadrant(image, across, 0, 2, x, y) + end) + else + self:setShade(0) + G.circle("fill", x, y, 11) + self:setShade(3) + G.circle("line", x + 0.5, y + 0.5, 11) + G.setColor(1, 1, 1, 1) + end + self:drawIcon(record, x - 8, y - 8) +end + +-- The window strip the two pans run over: the rule, the two trainers and the +-- six arrows between them. The arrows are the only part that is art, so a +-- cache with no gfx/trade still gets the names. +function TradeAnimView:drawTubeStrip(sending) + local G = love.graphics + G.push() + G.translate(0, STRIP_Y) + Chrome.print(string.rep("─", Chrome.SCREEN_W), 0, STRIP_RULE_ROW) + Chrome.print(self.give.senderName, 0, STRIP_NAME_ROW) + Chrome.printRight(self.get.senderName, Chrome.SCREEN_W, STRIP_OT_ROW) + local image, across = self:art("arrows") + if image then + self:through(function() + for column = 0, STRIP_ARROWS - 1 do + self:blit(image, across, sending and 0 or 1, + (STRIP_ARROW_X + column) * 8, STRIP_ARROW_ROW * 8) + end + end) + end + G.pop() + G.setColor(1, 1, 1, 1) +end + +function TradeAnimView:drawIcon(record, x, y) + local icons = self.data.gen2Icons + local iconId = icons and icons.species and record.species + and icons.species[record.species] + local entry = iconId and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then return end + local cached = self.iconCache[entry.image] + if cached == nil then + local ok, img = pcall(Assets.image, entry.image) + cached = ok and img or false + self.iconCache[entry.image] = cached + end + if not cached then return end + local G = love.graphics + local quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions()) + G.setColor(1, 1, 1, 1) + local colors = Palettes.monColors(self.palettes, record.species, + record.shiny) + if colors and GbcPalette.available() then + GbcPalette.with(colors, function() G.draw(cached, quad, x, y) end) + else + G.draw(cached, quad, x, y) + end +end + +-------------------------------------------------------------------------- +-- Draw +-------------------------------------------------------------------------- + +function TradeAnimView:drawPanel() + local id = (self.beat or {}).id + local t = self.offset or 0 + Chrome.clear() + + if GIVE_BEATS[id] then + local offset = (id == "givemon_scroll") and Anim.givemonOffset(t) or 0 + self:drawPic(self.give, offset) + -- The window comes in from the other side: hWX is $88 out when hSCX is. + self:drawStats(self.give, offset) + elseif TUBE_BEATS[id] then + self:drawTubeBeat(id, t) + elseif GET_BEATS[id] then + self:drawPic(self.get, 0) + if id == "getmon_poof" then self:drawPoof(BALL_X, BALL_Y, t) end + -- FrontpicScrollStart puts the window back up for Wait80, and + -- TextboxScrollStart takes it away again for the last line. + if id == "getmon_hold" then self:drawStats(self.get, 0) end + else + local pan = Anim.pan(id, t) + if pan then self:drawPanBeat(id, t, pan) end + end + + if BOX_BEATS[id] then + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + for index, line in ipairs(self:lines(id) or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (index - 1) * TEXT_LINE) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnimView:drawTubeBeat(id, t) + local offset = 0 + if id == "tube_in" or id == "tube_in2" or id == "tube_out" then + offset = Anim.tubeOffset(id, t) + end + self:drawTube(offset) + if id == "tube_in" then + -- The poof is still running while the cable slides over it, and the ball + -- RockingBall spawned is already underneath. + if t < POOF_FRAMES then + self:drawPoof(BALL_X, BALL_Y, t) + else + self:drawBall(BALL_X, BALL_Y, true) + end + elseif id == "tube_hold" or id == "ball_rock" then + self:drawBall(BALL_X, BALL_Y, true) + elseif id == "bulge" then + -- TradeAnim_AnimateTrademonInTube walks the bulge from one cap to the + -- other over its 128 frames. + local span = TUBE_W - 16 + local x = TUBE_X + 8 + math.floor(span * t / 128) + self:drawBulge(x, BULGE_Y, t) + elseif id == "tube_out" then + -- DropBall starts the ball $dc (-36) above its rest and lets it fall. + local drop = math.min(0, DROP_OFFSET + t) + self:drawBall(BALL_X, BALL_Y + drop, false) + elseif id == "ball_wait" then + self:drawBall(BALL_X, BALL_Y, true) + end +end + +function TradeAnimView:drawPanBeat(id, t, pan) + self:drawScene(pan) + local sending = id:sub(1, 4) == "send" + -- TradeAnim_AnimateTrademonInTube walks the object along the cable and then + -- despawns it; it is only parked while the pan itself runs. + local x, y = Anim.tubeIcon(id, t) + if x then + self:drawBubble(sending and self.give or self.get, x, y) + end + self:drawTubeStrip(sending) +end + +function TradeAnimView:draw() + self:drawPanel() +end + +function TradeAnimView: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(math.floor((winW - SCREEN_W * scale) / 2), + math.floor((winH - SCREEN_H * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return TradeAnimView diff --git a/src/ui/gen2/TradeMenu.lua b/src/ui/gen2/TradeMenu.lua new file mode 100644 index 00000000..f6061725 --- /dev/null +++ b/src/ui/gen2/TradeMenu.lua @@ -0,0 +1,357 @@ +-- The in-game trade conversation (engine/events/npc_trade.asm NPCTrade). +-- +-- NPCTrade is a straight line, and its ORDER is the whole of it: +-- +-- 1. the trade's flag is already set -> TRADE_DIALOG_AFTER, and stop. This +-- is checked before the intro, so a completed trade never asks again. +-- 2. TRADE_DIALOG_INTRO, then YesNoBox. A no is TRADE_DIALOG_CANCEL. +-- 3. SelectTradeOrDayCareMon with PARTYMENUACTION_GIVE_MON. Backing out of +-- the party list is the SAME TRADE_DIALOG_CANCEL. +-- 4. the picked mon's species against NPCTRADE_GIVEMON, then +-- CheckTradeGender. Either miss is TRADE_DIALOG_WRONG. +-- 5. set the flag, NPCTradeCableText, DoNPCTrade, the trade animation, +-- TradedForText, RestartMapMusic, TRADE_DIALOG_COMPLETE. +-- +-- The lines come out of the cache: PrintTradeText indexes TradeTexts by dialog +-- and then by the row's own TRADE_DIALOGSET_*, so the three NPC personalities +-- share one script and differ only in wording. {STRBUF} is the mon name the +-- line splices in -- GetTradeMonNames puts the mon you GET in wStringBuffer2 +-- and the one you GIVE in wMonOrItemNameBuffer, and appends ♂/♀ to the first +-- when the row wants a particular gender. +-- +-- Step 5's `predef TradeAnimation` -- the cable-and-ball sequence -- is +-- Gen2TradeAnim, pushed between DoNPCTrade and TradedForText the way NPCTrade +-- runs it. DoNPCTrade has already happened by then and nothing about the +-- outcome depends on it, so the screen can be skipped and its onDone is the +-- only thing that carries the conversation on. + +local Chrome = require("src.ui.gen2.Chrome") +local NpcTrade = require("src.core.gen2.NpcTrade") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local TradeMenu = {} +TradeMenu.__index = TradeMenu +TradeMenu.isOpaque = false + +-- The shared speech box: `lb bc, 4, 18` at (0,12), text at (1,14) with the +-- second line two rows down. +local BOX_X, BOX_Y, BOX_W, BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local ARROW_X, ARROW_Y = 18, 17 + +-- InitYesNoTextBoxParameters' default: a 6x5 box at (14,7), YES at (16,8) and +-- NO at (16,10) with the cursor column at 15. +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +local GENDER_GLYPH = { + TRADE_GENDER_MALE = "\xe2\x99\x82", + TRADE_GENDER_FEMALE = "\xe2\x99\x80", +} + +-- Fallbacks for a cache with no tradeTexts (one built before the extractor +-- reached TradeTexts). Transcribed from the COLLECTOR set, which is the one +-- the first trade in the game uses. +local FALLBACK = { + TRADE_DIALOG_INTRO = Strings.source( + "I collect #MON.\nDo you have\v{STRBUF}?\fWant to trade it\nfor my {STRBUF}?"), + TRADE_DIALOG_CANCEL = Strings.source("You don't want to\ntrade? Aww…"), + TRADE_DIALOG_WRONG = Strings.source( + "Huh? That's not\n{STRBUF}. What a letdown…"), + TRADE_DIALOG_COMPLETE = Strings.source( + "Yay! I got myself\n{STRBUF}!\vThanks!"), + TRADE_DIALOG_AFTER = Strings.source("Hi, how's my old\n{STRBUF} doing?"), +} + +-- The markers the extractor writes for the cart's own text controls: `para` +-- ($51) starts a new page, `cont` ($55) scrolls one line so the new page opens +-- on the previous last one, and `line` / `next` ($4f / $4e) is the second line +-- of the page it is in. Named rather than spelled inline so the string gate +-- does not read them as player-visible text. +local PAGE, SCROLL, LINE = "\f", "\v", "\n" +local SEPARATORS = "([^" .. LINE .. PAGE .. SCROLL .. "]*)([" .. + LINE .. PAGE .. SCROLL .. "])" + +-- Split a decoded text stream into pages of up to two lines. +function TradeMenu.paginate(body) + local pages, current = {}, {} + local function flush(scroll) + if #current > 0 then pages[#pages + 1] = current end + current = scroll and { current[#current] or "" } or {} + end + for chunk, sep in (tostring(body or "") .. PAGE):gmatch(SEPARATORS) do + current[#current + 1] = chunk + if sep == PAGE then flush(false) + elseif sep == SCROLL then flush(true) end + end + if #pages == 0 then pages[1] = { tostring(body or "") } end + return pages +end + +-- GetTradeMonNames fills three of the cart's six string buffers: +-- +-- wStringBuffer1 the mon you HAND OVER, with the row's ♂/♀ appended +-- when it wants a particular gender +-- wStringBuffer2 the mon you GET +-- wMonOrItemNameBuffer the mon you hand over again, without the glyph +-- +-- This port has ONE shared buffer, so the extractor writes all three as the +-- same `{STRBUF}` and records which was which alongside the text +-- (events.tradeBuffers). A line like the collector's intro names two +-- different ones -- "do you have DROWZEE?" then "for my MACHOP?" -- so filling +-- them in order out of that list is what keeps the two mons from swapping. +-- +-- With no buffer list the fallback is wStringBuffer1, the commonest of the +-- three and the right answer for every single-marker line. +function TradeMenu.fill(body, row, data, buffers) + local function speciesName(id) + local def = data and data.pokemon and data.pokemon[id] + return (def and def.name) or id or "#MON" + end + local give, get = speciesName(row and row.give), speciesName(row and row.get) + local byBuffer = { + wStringBuffer1 = give .. (GENDER_GLYPH[row and row.gender] or ""), + wStringBuffer2 = get, + wMonOrItemNameBuffer = give, + } + local n = 0 + return (tostring(body or ""):gsub("{STRBUF}", function() + n = n + 1 + return byBuffer[(buffers or {})[n] or "wStringBuffer1"] + or byBuffer.wStringBuffer1 + end)) +end + +-- opts: trade (the NPC_TRADE_* id), save, eventTables, onClose +function TradeMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TradeMenu) + self.game = game + self.data = (game and game.data) or {} + self.save = opts.save or (game and game.save) + self.eventTables = opts.eventTables or {} + self.id = tonumber(opts.trade) or 0 + self.onClose = opts.onClose + self.row = NpcTrade.row(self.eventTables, self.id) + if not self.row then + self:close() + elseif NpcTrade.done(self.save, self.id) then + self:say(NpcTrade.DIALOG_AFTER, function() self:close() end) + else + self:ask(NpcTrade.DIALOG_INTRO, + function() self:openParty() end, + function() self:refuse(NpcTrade.DIALOG_CANCEL) end) + end + return self +end + +function TradeMenu:wantsFillScale() return true end + +function TradeMenu:lineFor(dialog) + local texts = self.eventTables.tradeTexts + local row = texts and texts[dialog] + local set = self.row and self.row.dialog + local body = (row and set and row[set]) or FALLBACK[dialog] or "" + local bufRow = (self.eventTables.tradeBuffers or {})[dialog] + local buffers = bufRow and set and bufRow[set] + return TradeMenu.paginate( + self:expand(TradeMenu.fill(body, self.row, self.data, buffers))) +end + +function TradeMenu:say(dialog, onDone) + self.message = { pages = self:lineFor(dialog), page = 1, onDone = onDone } +end + +-- TradedForText is the one line here that names something other than the two +-- mons: it opens on {PLAYER}. The shared TextBox resolves that token for a +-- speech box, but this screen prints through Chrome, so the same substitution +-- has to happen before the text reaches the tile grid -- otherwise the braces +-- go looking for glyphs that do not exist. +function TradeMenu:expand(body) + local game = self.game + if not (game and game.save) then return body end + local ok, out = pcall(require("src.render.TextBox").substitute, game, body) + return ok and out or body +end + +function TradeMenu:sayRaw(body, buffers, onDone) + self.message = { + pages = TradeMenu.paginate( + self:expand(TradeMenu.fill(body, self.row, self.data, buffers))), + page = 1, onDone = onDone, + } +end + +function TradeMenu:ask(dialog, onYes, onNo) + self.confirm = { pages = self:lineFor(dialog), page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function TradeMenu:refuse(dialog) + self:say(dialog, function() self:close() end) +end + +function TradeMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function TradeMenu: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 TradeMenu:openParty() + local game = self.game + if not (game and game.stack) then + return self:refuse(NpcTrade.DIALOG_CANCEL) + end + self.picking = true + Screens.push(game, "Gen2PartyMenu", { + party = self.save and self.save.party, + prompt = "choose", + onChoose = function(index) + game.stack:pop() + self.picking = false + self:chose(index) + end, + onCancel = function() + game.stack:pop() + self.picking = false + self:refuse(NpcTrade.DIALOG_CANCEL) + end, + }) +end + +function TradeMenu:chose(index) + local mon = self.save and self.save.party and self.save.party[index] + local refusal = NpcTrade.check(self.row, mon) + if refusal then return self:refuse(refusal) end + -- The flag is set BEFORE the swap, and the cable line before that. + NpcTrade.markDone(self.save, self.id) + local texts = self.eventTables.tradeTexts or {} + local bufs = self.eventTables.tradeBuffers or {} + -- NPCTradeCableText is a bare PrintText (engine/events/npc_trade.asm:37-41): + -- nothing rings under the cable line. The sounds all belong to the + -- animation past it (SFX_GIVE_TRADEMON / SFX_GET_TRADEMON). + self:sayRaw(texts.NPCTradeCableText + or Strings.source("OK, connect the\nGame Link Cable."), + bufs.NPCTradeCableText, + function() + local given, received = + NpcTrade.perform(self.data, self.save, self.row, index) + self:playAnim(given, received, function() + self:sayRaw(texts.TradedForText + or Strings.source("{PLAYER} traded\n{STRBUF} for\v{STRBUF}."), + bufs.TradedForText, + function() self:refuse(NpcTrade.DIALOG_COMPLETE) end) + end) + end) +end + +-- `predef TradeAnimation`. It is a whole screen of its own, so it goes on the +-- stack; with no stack to push onto (a headless test) the conversation just +-- carries on, which is what makes the animation skippable in the first place. +function TradeMenu:playAnim(given, received, onDone) + local game = self.game + if not (game and game.stack and given) then return onDone() end + self.animating = true + Screens.push(game, "Gen2TradeAnim", { + row = self.row, + given = given, + received = received, + save = self.save, + eventTables = self.eventTables, + onDone = function() + game.stack:pop() + self.animating = false + onDone() + end, + }) +end + +function TradeMenu:updateMessage(input) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function TradeMenu:updateConfirm(input) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function TradeMenu:update(_dt) + if self.picking or self.animating then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end +end + +function TradeMenu:drawTextBox(lines) + Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function TradeMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function TradeMenu:draw() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages then + Chrome.print("\xe2\x96\xbc", ARROW_X, ARROW_Y) + end + elseif self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + else + Chrome.print("\xe2\x96\xbc", ARROW_X, ARROW_Y) + end + else + self:drawTextBox(nil) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return TradeMenu diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua new file mode 100644 index 00000000..f3bdb4ee --- /dev/null +++ b/src/ui/gen2/TrainerCard.lua @@ -0,0 +1,452 @@ +-- Gold's trainer card (engine/menus/trainer_card.asm), transcribed from the +-- routines that draw it rather than laid out by eye. +-- +-- The card is not a text box. TrainerCard_InitBorder writes the frame out of +-- the sheet's own tiles: a row of $23, then a row of $23 + 17 spaces + $04 + +-- $23, then d rows of $23 + 18 spaces + $23, then $23 + $24 + 17 spaces + +-- $23, then a closing row of $23 -- d + 4 rows in all. It is called twice, +-- at (0,0) with d = 5 (rows 0-8) and at (0,8) with d = 6 (rows 8-17), so the +-- two halves share row 8. +-- +-- Page 1 (TrainerCard_PrintTopHalfOfCard + _Page1_PrintDexCaught_GameTime): +-- (2,2) "NAME/", <NEXT> twice to (2,6) "MONEY"; the blank middle line is +-- then overwritten by the $27/$28 "ID No" tiles at (2,4) +-- (7,2) player name, (5,4) ID as 5 digits with leading zeros, +-- (7,6) money as PRINTNUM_MONEY 6 digits +-- (1,3) the $25 x12 + $26 divider +-- (14,1) the player's portrait: a 5x7 block of running tile ids from $00 +-- (2,8) the $29..$2d status caption +-- (2,10) "#DEX" and (2,12) "PLAY TIME"; (15,10) caught count, +-- (11,12) hours as 4 digits then (16,12) minutes, with the colon at +-- (15,12) blinking every 32 frames +-- (12,15) "BADGES▶" +-- +-- Pages 2 and 3 (TrainerCard_Page2_3_InitObjectsAndStrings): the $79..$7d +-- "BADGES" caption at (2,8), then eight gym leader faces from LeaderGFX -- +-- four at row 10 and four at row 13, each ten tiles laid 4 across then two +-- rows of 3 offset one column in -- with the badges themselves as OBJs out of +-- BadgeGFX at TrainerCard_JohtoBadgesOAM's coordinates. Page 3's LoadGFX +-- requests LeaderGFX2/BadgeGFX2, but those INCBIN the identical .2bpp files, +-- and TrainerCard_Page3_Joypad still hands TrainerCard_Page2_3_AnimateBadges +-- the Johto table -- whose header word is `dw wJohtoBadges` -- so the "Kanto" +-- page is really the Johto page redrawn: same faces, same badges, gated on +-- the same flags. We match that rather than wire it to wKantoBadges. +-- +-- Colour comes from _CGB_TrainerCard: the whole attrmap is palette 1 +-- (Falkner's trainer colours, which is what tints the frame), the portrait +-- box is palette 0 (the player's), and each leader's lower two rows get that +-- leader's own palette. + +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local TileSheet = require("src.ui.gen2.TileSheet") + +local TrainerCard = {} +TrainerCard.__index = TrainerCard +TrainerCard.isOpaque = true + +local SCREEN_W, SCREEN_H = 20, 18 + +-- Frame tiles, which live at $23 because ChrisPicAndTrainerCardGFX is the +-- 35-tile portrait followed by the 6-tile frame. +local TILE_FRAME = 0x23 +local TILE_NOTCH_LOW = 0x24 -- the (1, bottom-1) notch +local TILE_NOTCH_HIGH = 0x04 -- the (18, top+1) one, from the portrait sheet +local TILE_DIVIDER = 0x25 +local TILE_DIVIDER_END = 0x26 +local TILE_ID_NO = { 0x27, 0x28 } +local TILE_STATUS = { 0x29, 0x2a, 0x2b, 0x2c, 0x2d } +local TILE_BADGES_CAPTION = { 0x79, 0x7a, 0x7b, 0x7c, 0x7d } +local TILE_COLON = 0x2e + +-- Johto then Kanto, in badge order. +local JOHTO_BADGES = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "STORM", "MINERAL", "GLACIER", "RISING", +} +local KANTO_BADGES = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", + "EARTH", +} + +-- 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 = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} + +function TrainerCard:wantsFillScale() return true end +function TrainerCard:drawsWidescreen() return true end + +-- opts: save, onClose(), sprites (sprites.lua), palettes, menuGfx +function TrainerCard.new(game, opts) + opts = opts or {} + local self = setmetatable({}, TrainerCard) + self.game = game + self.save = opts.save or (game and game.save) + local data = game and game.data or {} + self.palettes = opts.palettes or data.gen2Palettes + self.sprites = opts.sprites or data.gen2Sprites + self.onClose = opts.onClose + self.page = 1 -- 1 card, 2 Johto badges, 3 Kanto badges + self.frames = 0 + + local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).trainerCard + self.gfx = gfx + 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 = {} + 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 + self.zone[y * SCREEN_W + x] = z[5] + 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, + paletteFor = paletteFor, + }) + self.status = TileSheet.new({ + path = gfx.status, wide = gfx.statusWide or 6, + firstTile = gfx.statusFirstTile or 0x29, paletteFor = paletteFor, + }) + self.leaders = TileSheet.new({ + path = gfx.leaders, wide = gfx.leadersWide or 10, + firstTile = gfx.leadersFirstTile or 0x29, paletteFor = paletteFor, + }) + self.badges = TileSheet.new({ + path = gfx.badges, wide = gfx.badgesWide or 2, firstTile = 0, + palette = gfx.badgePalette, + }) + end + return self +end + +function TrainerCard:styled() + return self.card ~= nil and self.card:available() +end + +-- The eight BG palettes _CGB_TrainerCard loads: the player's, then the seven +-- leaders whose classes have a pic palette of their own. +function TrainerCard:palette(index) + local pals = self.palettes + if not pals then return nil end + if index == 1 then + return self:pair(pals.trainers and pals.trainers.PLAYER) + end + local classes = self.gfx and self.gfx.leaderClasses or {} + return self:pair(pals.trainers and pals.trainers[classes[index - 1]]) +end + +-- LoadPalette_White_Col1_Col2_Black: the two stored colours sit between white +-- and black, which is how every trainer palette in the game is used. +function TrainerCard:pair(colors) + if not (colors and colors[1] and colors[2]) then return nil end + return { + { 255, 255, 255 }, colors[1], colors[2], { 0, 0, 0 }, + } +end + +function TrainerCard:colorsAt(tx, ty) + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 2 + return self:palette(index) +end + +function TrainerCard:pages() + -- Kanto's page only exists once the player has been there; the cart gates it + -- on the Kanto badges having started. + local kanto = self.save and self.save.player + and next(self.save.player.kantoBadges or {}) ~= nil + return kanto and 3 or 2 +end + +function TrainerCard:update(_dt) + self.frames = self.frames + 1 + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("b") or input:wasPressed("start") then + if self.onClose then self.onClose() end + return + end + local pages = self:pages() + if input:wasPressed("right") then + self.page = self.page < pages and self.page + 1 or 1 + elseif input:wasPressed("left") then + self.page = self.page > 1 and self.page - 1 or pages + elseif input:wasPressed("a") then + -- Page 1's A is "turn the page"; page 2's A quits (TrainerCard_Page2_Joypad). + if self.page == 1 then + self.page = 2 + elseif self.onClose then + self.onClose() + end + end +end + +function TrainerCard:caughtCount() + local caught = 0 + for _, has in pairs((self.save and self.save.pokedex + and self.save.pokedex.caught) or {}) do + if has then caught = caught + 1 end + end + return caught +end + +-- PRINTNUM_MONEY prints a ¥ in front of the first significant digit rather +-- than at a fixed column, and the field is six digits wide. +local function moneyText(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. "\xc2\xa5" .. digits:sub(first) +end + +function TrainerCard:tile(sheet, id, tx, ty) + if sheet then sheet:draw(id, tx, ty) end +end + +-- TrainerCard_InitBorder, literally. +function TrainerCard:frame(ty, interiorRows) + local sheet = self.card + for x = 0, SCREEN_W - 1 do self:tile(sheet, TILE_FRAME, x, ty) end + self:tile(sheet, TILE_FRAME, 0, ty + 1) + self:tile(sheet, TILE_NOTCH_HIGH, 18, ty + 1) + self:tile(sheet, TILE_FRAME, 19, ty + 1) + for row = 2, interiorRows + 1 do + self:tile(sheet, TILE_FRAME, 0, ty + row) + self:tile(sheet, TILE_FRAME, 19, ty + row) + end + local notch = ty + interiorRows + 2 + self:tile(sheet, TILE_FRAME, 0, notch) + self:tile(sheet, TILE_NOTCH_LOW, 1, notch) + self:tile(sheet, TILE_FRAME, 19, notch) + for x = 0, SCREEN_W - 1 do + self:tile(sheet, TILE_FRAME, x, ty + interiorRows + 3) + end +end + +-- The 5x7 portrait: running tile ids from $00, row-major, at (14,1). +function TrainerCard:drawPortrait() + local wide = self.gfx and self.gfx.portraitWide or 5 + local high = math.floor((self.gfx and self.gfx.portraitTiles or 35) / wide) + for row = 0, high - 1 do + for col = 0, wide - 1 do + self:tile(self.card, row * wide + col, 14 + col, 1 + row) + end + end +end + +-- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws +-- it -- so the name, ID, money and portrait stay on screen behind the badge +-- pages too. +function TrainerCard:drawTopHalf() + local player = (self.save or {}).player or {} + self:frame(0, 5) + Chrome.print("NAME/", 2, 2) + Chrome.print(player.name or "GOLD", 7, 2) + self:tile(self.card, TILE_ID_NO[1], 2, 4) + self:tile(self.card, TILE_ID_NO[2], 3, 4) + Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) + Chrome.print("MONEY", 2, 6) + Chrome.print(moneyText(player.money), 7, 6) + for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end + self:tile(self.card, TILE_DIVIDER_END, 13, 3) + self:drawPortrait() +end + +function TrainerCard:drawCard() + local save = self.save or {} + self:drawTopHalf() + self:frame(8, 6) + + -- The $29..$2d caption plaque, which sits on the row the two halves share. + for i, id in ipairs(TILE_STATUS) do + self:tile(self.status, id, 1 + i, 8) + end + + -- `#` is the compression byte for POKé, four tiles, so spelling it out is + -- what the cart actually draws. + Chrome.print("POKéDEX", 2, 10) + Chrome.print("PLAY TIME", 2, 12) + Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) + + local time = save.playTime or {} + Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) + -- The colon is $2e, which belongs to CardStatusGFX rather than the card + -- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32 + -- frames -- which is what makes the clock look like it is running. + if math.floor(self.frames / 32) % 2 == 0 then + self:tile(self.status, TILE_COLON, 15, 12) + end + Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) + + Chrome.print("BADGES", 12, 15) + Chrome.cursor(18, 15) +end + +-- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then +-- two rows of three offset one column in, ten tiles per face with the id +-- running on across all eight. +function TrainerCard:drawLeaderFace(first, tx, ty) + local id = first + for col = 0, 3 do + self:tile(self.leaders, id, tx + col, ty) + id = id + 1 + end + for row = 1, 2 do + for col = 1, 3 do + self:tile(self.leaders, id, tx + col, ty + row) + id = id + 1 + end + end + return id +end + +function TrainerCard:drawBadgeSprites(owned, names) + local list = self.gfx and self.gfx.badgeOam + if not (list and self.badges and self.badges:available()) then return end + local G = love.graphics + -- Eight frames on a 3-bit counter, stepped every 8 VBlanks. + local frame = math.floor(self.frames / 8) % 8 + for i, obj in ipairs(list) do + -- The OAM table is in wJohtoBadges bit order, which is not the drawing + -- order (Mineral is listed before Storm), so the earned flag is looked up + -- by name -- or by that name's position, since a save may key the array + -- either way. + local name = BADGE_OAM_ORDER[i] + local slot + for index, badge in ipairs(names) do + if badge == name then slot = index end + end + if owned[name] or (slot and owned[slot]) then + local tile = obj.frames[frame + 1] or 0 + -- Bit 7 of the tile id is an x-flip, which is how Risingbadge turns. + local flip = tile >= 0x80 + local base = flip and (tile - 0x80) or tile + local sx = flip and -1 or 1 + local function body() + for _, cell in ipairs({ { 0, 0, 0 }, { 1, 0, 1 }, { 0, 1, 2 }, + { 1, 1, 3 } }) do + local quad = self.badges:quad(base + cell[3]) + if quad then + local px = obj.x + (flip and (1 - cell[1]) or cell[1]) * 8 + G.draw(self.badges:image(), quad, + px + (flip and 8 or 0), obj.y + cell[2] * 8, 0, sx, 1) + end + end + end + G.setColor(1, 1, 1, 1) + if self.gfx.badgePalette and GbcPalette.available() then + GbcPalette.with(self.gfx.badgePalette, body) + else + body() + end + end + end +end + +function TrainerCard:drawBadges(names, owned) + self:drawTopHalf() + self:frame(8, 6) + for i, id in ipairs(TILE_BADGES_CAPTION) do + self:tile(self.leaders, id, 1 + i, 8) + end + local id = self.gfx and self.gfx.leadersFirstTile or 0x29 + for face = 0, 3 do id = self:drawLeaderFace(id, 2 + face * 4, 10) end + for face = 0, 3 do id = self:drawLeaderFace(id, 2 + face * 4, 13) end + self:drawBadgeSprites(owned, names) +end + +-- ------------------------------------------------------------------ fallback + +function TrainerCard:drawPlain() + local save = self.save or {} + local player = save.player or {} + Chrome.clear() + if self.page == 1 then + Chrome.box(0, 0, 20, 9) + Chrome.print("NAME/", 2, 2) + Chrome.print(player.name or "GOLD", 7, 2) + Chrome.print("ID No", 2, 4) + Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) + Chrome.print("MONEY", 2, 6) + Chrome.print(moneyText(player.money), 7, 6) + Chrome.box(0, 8, 20, 10) + Chrome.print("POKéDEX", 2, 10) + Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) + Chrome.print("PLAY TIME", 2, 12) + local time = save.playTime or {} + Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) + Chrome.print(":", 15, 12) + Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) + Chrome.print("BADGES", 12, 15) + Chrome.cursor(18, 15) + return + end + local names = self.page == 2 and JOHTO_BADGES or KANTO_BADGES + -- TrainerCard_Page3_Joypad hands TrainerCard_Page2_3_AnimateBadges the + -- exact same TrainerCard_JohtoBadgesOAM pointer page 2 uses, and that + -- table's own header word is `dw wJohtoBadges` -- so page 3 never once + -- reads wKantoBadges, it just relabels the Johto flags. held stays + -- player.badges on both pages to match. + local held = player.badges or {} + Chrome.box(0, 0, 20, 9) + Chrome.print(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2) + Chrome.box(0, 8, 20, 10) + for i, name in ipairs(names) do + local tx = 2 + ((i - 1) % 4) * 4 + local ty = 10 + math.floor((i - 1) / 4) * 3 + Chrome.print((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty) + end +end + +function TrainerCard:drawPanel() + if not self:styled() then + self:drawPlain() + love.graphics.setColor(1, 1, 1, 1) + return + end + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + local player = (self.save and self.save.player) or {} + if self.page == 1 then + self:drawCard() + elseif self.page == 2 then + self:drawBadges(JOHTO_BADGES, player.badges or {}) + else + -- Page 3 reuses TrainerCard_JohtoBadgesOAM wholesale, header word and + -- all, so it is really the Johto page again: JOHTO_BADGES here keeps + -- drawBadgeSprites' name/position fallback matched to player.badges, + -- the same table page 2 reads. + self:drawBadges(JOHTO_BADGES, player.badges or {}) + end + G.setColor(1, 1, 1, 1) +end + +function TrainerCard:draw() + self:drawPanel() +end + +function TrainerCard: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(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +TrainerCard.JOHTO_BADGES = JOHTO_BADGES +TrainerCard.KANTO_BADGES = KANTO_BADGES +TrainerCard.BADGE_OAM_ORDER = BADGE_OAM_ORDER +TrainerCard.moneyText = moneyText + +return TrainerCard diff --git a/src/ui/gen2/UnownPrinter.lua b/src/ui/gen2/UnownPrinter.lua new file mode 100644 index 00000000..473a6061 --- /dev/null +++ b/src/ui/gen2/UnownPrinter.lua @@ -0,0 +1,205 @@ +-- The ALPH RUINS STAMP viewer (engine/events/print_unown.asm _UnownPrinter), +-- reached through `special UnownPrinter` (src/script/gen2/Specials.lua +-- H.UnownPrinter) from the Ruins of Alph research centre's printer once every +-- Unown form has been caught (RuinsOfAlphResearchCenterPrinter's `readvar +-- VAR_UNOWNCOUNT / ifequal NUM_UNOWN`). +-- +-- _UnownPrinter is two halves and only one of them needs a peripheral: the +-- VIEWER -- the stamp sheet the player scrolls through with LEFT and RIGHT -- +-- 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 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. +-- +-- RotateUnownFrontpic (engine/events/print_unown_2.asm) belongs to that +-- stubbed half and is deliberately not ported: it writes a 90-degree rotated +-- copy of the pic to vTiles2 tile $31, which nothing but +-- PlaceUnownPrinterFrontpic -- the PRINTED page -- ever reads. The pic on +-- screen is the upright one at tiles $00-$30. +-- +-- Coordinates below are the literal hlcoord operands _UnownPrinter writes at: +-- +-- hlcoord 0, 0 Textbox, 3 rows by 18 columns +-- hlcoord 0, 5 Textbox, 7 by 7 -- the frame round the stamp +-- hlcoord 0, 14 Textbox, 2 by 18 +-- hlcoord 1, 2 " ALPH RUINS STAMP" +-- hlcoord 1, 16 "Do what?" +-- hlcoord 10, 6 the four-row menu, one row per `next` +-- hlcoord 1, 6 the 7x7 frontpic, or ClearBox + "VACANT" at hlcoord 1, 9 +-- +-- The menu's first two rows start with the bold A and B tiles +-- (gfx/printer/bold_a.1bpp / bold_b.1bpp, requested into vTiles0 at the ♂/♀ +-- character cells). Those two tiles are not extracted -- they exist only for +-- this screen -- so the ordinary font's A and B stand in for them, which is +-- the same pair of letters in a lighter weight. +-- +-- THE SHEET IS ALL 26 FORMS, not the caught ones: .UpdateUnownFrontpic loads +-- `wJumptableIndex + 1` as the letter whatever the #DEX holds, and the 27th +-- slot is the blank stamp the cart labels VACANT. The gate is on the way in +-- instead (H.UnownPrinter's `ld a, [wUnownDex] / and a / ret z`). + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") +local Strings = require("src.core.Strings") +local Unown = require("src.core.gen2.Unown") + +local UnownPrinter = {} +UnownPrinter.__index = UnownPrinter +UnownPrinter.isOpaque = true + +function UnownPrinter:wantsFillScale() return true end + +-- AlphRuinsStampString keeps its leading space: the string starts at column 1 +-- and the space is what puts the S of STAMP under the box's own edge. +local TEXT = { + title = Strings.source(" ALPH RUINS STAMP"), + doWhat = Strings.source("Do what?"), + vacant = Strings.source("VACANT"), + menu = { + Strings.source("A▶PRINT"), + Strings.source("B▶CANCEL"), + Strings.source("L▶BEFORE"), + Strings.source("R▶NEXT"), + }, +} + +-- opts: pokemon, palettes, onClose() +function UnownPrinter.new(game, opts) + opts = opts or {} + local self = setmetatable({}, UnownPrinter) + self.game = game + local data = (game and game.data) or {} + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes + self.onClose = opts.onClose + -- wJumptableIndex: 0..NUM_UNOWN - 1 are the letters and NUM_UNOWN is the + -- vacant slot, so the wheel is 27 long. + self.index = 0 + self.done = false + self.picCache = {} + return self +end + +function UnownPrinter:slots() + return Unown.NUM_UNOWN + 1 +end + +-- The letter this slot shows, or nil for the vacant one. +function UnownPrinter:letter() + if self.index >= Unown.NUM_UNOWN then return nil end + return self.index + 1 +end + +function UnownPrinter:finish() + if self.done then return end + self.done = true + if self.onClose then self.onClose() end +end + +-- .LeftRight, and the two wraps below it: RIGHT past the vacant slot comes +-- back to A, LEFT off A goes to the vacant slot. The cart reads hJoyLast +-- rather than hJoyPressed for these two, so holding the pad scrolls; edge +-- detection is close enough here and is what every other Gold menu in the +-- port uses. +function UnownPrinter: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("b") then + -- .pressed_b: restore hInMenu/wOptions and ReturnToMapFromSubmenu. + self:finish() + return + end + if input:wasPressed("a") then + -- .pressed_a is `farcall PrintUnownStamp / call RestartMapMusic` and then + -- straight back into the joypad loop. With no printer there is nothing + -- to send and nothing to restart, so this is deliberately a no-op rather + -- than a screen the player has to back out of twice. + return + end + if input:wasPressed("right") then + self.index = (self.index + 1) % self:slots() + elseif input:wasPressed("left") then + self.index = (self.index - 1) % self:slots() + end +end + +function UnownPrinter:picFor(letter) + local path = Unown.formSprite(self.pokemon, letter, false) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +-- .UpdateUnownFrontpic: PlaceGraphic of the form's 7x7 pic at hlcoord 1, 6. +function UnownPrinter:drawPic(letter) + local image = self:picFor(letter) + if not image then return end + local colors = self.palettes + and Palettes.monColors(self.palettes, Unown.SPECIES) + local G = love.graphics + G.setColor(1, 1, 1, 1) + local function body() G.draw(image, 1 * 8, 6 * 8) end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function UnownPrinter:drawPanel() + Chrome.clear() + Chrome.textbox(0, 0, 18, 3) + Chrome.textbox(0, 5, 7, 7) + Chrome.textbox(0, 14, 18, 2) + + Chrome.print(Strings(TEXT.title), 1, 2) + Chrome.print(Strings(TEXT.doWhat), 1, 16) + for i, row in ipairs(TEXT.menu) do + Chrome.print(Strings(row), 10, 5 + i) + end + + local letter = self:letter() + if letter then + self:drawPic(letter) + else + -- .vacant: ClearBox over the 7x7 block, then VACANT across its middle. + Chrome.print(Strings(TEXT.vacant), 1, 9) + end +end + +function UnownPrinter:draw() + self:drawPanel() +end + +-- _PrintUnown opens on ClearBGPalettes / ClearTilemap +-- (engine/events/print_unown.asm:17-18), so the #DEX screen underneath is +-- gone. The panel starts from Chrome.clear(), so white IS the surround. +function UnownPrinter:drawsWidescreen() return true end + +function UnownPrinter: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 UnownPrinter diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua new file mode 100644 index 00000000..1b707f3b --- /dev/null +++ b/src/ui/gen2/UnownPuzzle.lua @@ -0,0 +1,590 @@ +-- The Ruins of Alph sliding-panel puzzle (engine/games/unown_puzzle.asm +-- _UnownPuzzle), opened by `special UnownPuzzle` from each chamber's `bg_event +-- BGEVENT_UP` wall. +-- +-- Sixteen panels of a picture start on the OUTER ring of a 6x6 board and the +-- interior 4x4 is empty; A picks a panel up, A puts it down on an empty cell, +-- and the puzzle is solved when the interior holds panels 1..16 in reading +-- order. START quits at any time. The `setval UNOWNPUZZLE_*` in front of the +-- special picks which of the four pictures is being assembled (Kabuto, +-- Omanyte, Aerodactyl, Ho-Oh), and `wSolvedUnownPuzzle` comes back out in +-- wScriptVar so the chamber's `iftrue` can drop the floor. +-- +-- The board is a TILEMAP, so every coordinate below is the hlcoord the ASM +-- writes and not a number picked to look right: +-- +-- hlcoord 0, 0 + SCREEN_AREA filled with PUZZLE_BORDER ($ee) +-- hlcoord 4, 3, lb bc 12, 12 filled with PUZZLE_VOID ($ef) -- the interior +-- UnownPuzzleCoordData cell i sits at (1 + 3*(i%6), 3*(i/6)), so the +-- 6x6 board of 3x3-tile cells spans (1,0)..(18,17) +-- PlaceStartCancelBoxBorder the box at rows 15-17, columns 4-15 +-- PlaceStartCancelBox $f6..$ff, the ten caption tiles, at (5,16) +-- +-- The 16 initial positions are exactly the ring cells (.PuzzlePieceInitialPositions +-- lists all six of row 0, both ends of rows 1-4, and both ends of row 5), which +-- is why a fresh board is full on the outside and empty in the middle. +-- +-- The cursor is an OBJ, four tiles mirrored into a 3x3 bracket, and it BLINKS: +-- RedrawUnownPuzzlePieces only runs when `hVBlankCounter and $10` is set, which +-- is 16 frames on and 16 off -- except while a panel is held, when the panel +-- itself rides the cursor and is drawn every frame. +-- +-- The art comes out of the cache as menu_gfx.unownPuzzle (the extractor already +-- does ConvertLoadedPuzzlePieces' 2x enlargement and the border pass, so a +-- picture is a plain 96x96 sheet of 4x4 panels). Without it the board still +-- plays: panels draw as their numbers, which is enough to solve and enough for +-- a test, and is the same degrade src/ui/gen2/CardFlip.lua takes. + +local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local TileSheet = require("src.ui.gen2.TileSheet") + +local UnownPuzzle = {} +UnownPuzzle.__index = UnownPuzzle +UnownPuzzle.isOpaque = true + +-- DEF PUZZLE_BORDER EQU $ee / DEF PUZZLE_VOID EQU $ef. +UnownPuzzle.BORDER_TILE = 0xee +UnownPuzzle.VOID_TILE = 0xef + +-- PREDEFPAL_UNOWN_PUZZLE (gfx/sgb/predef.pal), transcribed at the extractor's +-- own 5-bit to 8-bit scale so a re-imported cache produces these same bytes: +-- RGB 31,31,31 / 24,20,11 / 18,13,11 / 00,00,00, i.e. white, tan, dark brown, +-- black. Used when the cache predates the extractor emitting the palette; the +-- same inline transcription src/ui/gen2/GameFreakPresents.lua keeps. +UnownPuzzle.PALETTE = { + { 255, 255, 255 }, { 197, 165, 90 }, { 148, 107, 90 }, { 0, 0, 0 }, +} +-- _CGB_UnownPuzzle loads the same palette into wOBPals1 and then overwrites +-- OBJ colour 0 with `palred 31` (pure red); _UnownPuzzle's `ld a, $24 / call +-- DmgToCgbObjPal0` reorders that to entries 0, 1, 2, 0. The cursor sheet uses +-- colours 0 and 3, so the bracket comes out red on both. +UnownPuzzle.CURSOR_PALETTE = { + { 255, 0, 0 }, { 197, 165, 90 }, { 148, 107, 90 }, { 255, 0, 0 }, +} + +UnownPuzzle.COLUMNS = 6 +UnownPuzzle.ROWS = 6 +UnownPuzzle.CELLS = 36 +UnownPuzzle.PIECES = 16 +-- A cell is 3x3 tiles, and the sheet lays the sixteen panels 4 across. +UnownPuzzle.PIECE_TILES = 3 +UnownPuzzle.PIECES_WIDE = 4 + +-- `DEF puzcoord EQUS "* 6 +"`: the cart addresses a cell as row * 6 + column, +-- 0-based, and every cursor rule below is a comparison against one of those. +local function puzcoord(row, col) return row * UnownPuzzle.COLUMNS + col end +UnownPuzzle.puzcoord = puzcoord + +-- .PuzzlePieceInitialPositions: the sixteen cells a panel may start on. All of +-- row 0, then both ends of rows 1 through 5. +UnownPuzzle.START_CELLS = (function() + local out = {} + for col = 0, 5 do out[#out + 1] = puzcoord(0, col) end + for row = 1, 5 do + out[#out + 1] = puzcoord(row, 0) + out[#out + 1] = puzcoord(row, 5) + end + return out +end)() + +-- .SolvedPuzzleConfiguration, written out the way the ASM writes it: the ring +-- empty, 1..16 filling the interior in reading order. +UnownPuzzle.SOLVED = { + 0, 0, 0, 0, 0, 0, + 0, 1, 2, 3, 4, 0, + 0, 5, 6, 7, 8, 0, + 0, 9, 10, 11, 12, 0, + 0, 13, 14, 15, 16, 0, + 0, 0, 0, 0, 0, 0, +} + +-- SFX_*, in pokegold's own labels; Gold's sfx table is keyed by those. +local SFX_MOVE_CURSOR = "Sfx_Pound" +local SFX_MOVE_PIECE = "Sfx_MovePuzzlePiece" +local SFX_PICK_UP = "Sfx_MegaKick" +local SFX_PUT_DOWN = "Sfx_PlacePuzzlePieceDown" +local SFX_WRONG = "Sfx_Wrong" +local SFX_SOLVED = "Sfx_1stPlace" + +-- JoyTextDelay with hInMenu set reads hJoyDown, so the d-pad REPEATS: a fresh +-- press reloads wTextDelayFrames with 15 and every repeat after that with 5. +local REPEAT_DELAY = 15 +local REPEAT_RATE = 5 + +-- The ten caption tiles ($f6..$ff) spell START▶CANCEL; the same words are +-- printed with the font when the sheet is not in the cache. Declared here and +-- looked up at the draw site, which is what Strings.source marks. +UnownPuzzle.CAPTION = Strings.source("START>CANCEL") + +-- ---------------------------------------------------------------- the board +-- +-- InitUnownPuzzlePiecePositions: sixteen panels are dealt, `call Random / and +-- $f` into the initial-position list, rerolling while the cell it picks is +-- already taken. `random(n)` is 0..n-1, the src/battle/gen2 convention. +-- +-- The cart's reroll is unbounded; it cannot hang because hRandomAdd is never +-- degenerate. Here the retries are capped and the fallback drops the panel in +-- the first free start cell, so a fixed RNG in a test deals a legal board +-- instead of spinning. +local DEAL_RETRIES = 512 + +function UnownPuzzle.deal(random) + local pieces = {} + for cell = 1, UnownPuzzle.CELLS do pieces[cell] = 0 end + for piece = 1, UnownPuzzle.PIECES do + local cell, tries = nil, 0 + repeat + cell = UnownPuzzle.START_CELLS[random(16) + 1] + tries = tries + 1 + until pieces[cell + 1] == 0 or tries >= DEAL_RETRIES + if pieces[cell + 1] ~= 0 then + for _, candidate in ipairs(UnownPuzzle.START_CELLS) do + if pieces[candidate + 1] == 0 then cell = candidate break end + end + end + pieces[cell + 1] = piece + end + return pieces +end + +-- CheckSolvedUnownPuzzle: a byte-for-byte compare against +-- .SolvedPuzzleConfiguration. With exactly sixteen panels on the board this is +-- the same thing as "the interior is 1..16", but the compare is transcribed +-- rather than replaced so a board that ever held a different number of panels +-- would still answer correctly. +function UnownPuzzle.isSolved(pieces) + for cell = 1, UnownPuzzle.CELLS do + if (pieces[cell] or 0) ~= UnownPuzzle.SOLVED[cell] then return false end + end + return true +end + +-- .d_up / .d_down / .d_left / .d_right, each transcribed as its own list of +-- refusals. They are NOT a rectangle: row 5 has only its two end cells (the +-- START>CANCEL box occupies the middle of it), so left from cell 35 jumps +-- straight to 30 and right from 30 jumps straight to 35, and row 4's four +-- interior cells cannot go down at all. +-- +-- Returns the new position, or nil when the move is refused (`ret` in the ASM, +-- which also skips the cursor SFX). +function UnownPuzzle.moveCursor(position, direction) + local pos = position or 0 + if direction == "up" then + if pos < puzcoord(1, 0) then return nil end + return pos - UnownPuzzle.COLUMNS + elseif direction == "down" then + for col = 1, 4 do + if pos == puzcoord(4, col) then return nil end + end + if pos >= puzcoord(5, 0) then return nil end + return pos + UnownPuzzle.COLUMNS + elseif direction == "left" then + if pos == 0 then return nil end + for row = 1, 5 do + if pos == puzcoord(row, 0) then return nil end + end + if pos == puzcoord(5, 5) then return puzcoord(5, 0) end + return pos - 1 + elseif direction == "right" then + for row = 0, 5 do + if pos == puzcoord(row, 5) then return nil end + end + if pos == puzcoord(5, 0) then return puzcoord(5, 5) end + return pos + 1 + end + return nil +end + +-- UnownPuzzleCoordData's `dwcoord` column: cell i is a 3x3 tile block whose +-- top-left corner is (1 + 3 * column, 3 * row). +function UnownPuzzle.cellTile(cell) + local row = math.floor(cell / UnownPuzzle.COLUMNS) + local col = cell % UnownPuzzle.COLUMNS + return 1 + col * UnownPuzzle.PIECE_TILES, row * UnownPuzzle.PIECE_TILES +end + +-- The same table's "vacant tile" column: the interior 4x4 clears to +-- PUZZLE_VOID, everything on the ring clears back to PUZZLE_BORDER. +function UnownPuzzle.vacantTile(cell) + local row = math.floor(cell / UnownPuzzle.COLUMNS) + local col = cell % UnownPuzzle.COLUMNS + if row >= 1 and row <= 4 and col >= 1 and col <= 4 then + return UnownPuzzle.VOID_TILE + end + return UnownPuzzle.BORDER_TILE +end + +-- ------------------------------------------------------------- the screen +-- +-- opts: puzzle (the UNOWNPUZZLE_* id the script's `setval` carried), save, +-- random(n) -> 0..n-1, onClose(solved). +function UnownPuzzle.new(game, opts) + opts = opts or {} + local self = setmetatable({}, UnownPuzzle) + self.game = game + self.onClose = opts.onClose + self.puzzle = opts.puzzle or 0 + self.random = opts.random or function(n) + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 + end + -- `ld hl, STARTOF("Miscellaneous") ... call ByteFill` zeroes the whole block + -- wPuzzlePieces lives in, so every run starts from a clean board and a + -- cursor at cell 0. + self.pieces = UnownPuzzle.deal(self.random) + self.cursor = 0 + self.holding = false + self.held = 0 + self.solved = false + self.frame = 0 + self.repeatLeft = 0 + self.lastDirection = nil + -- The solve fanfare parks on SimpleWaitPressAorB before the screen closes. + self.waiting = false + self:loadGfx() + return self +end + +function UnownPuzzle:gfx() + local data = self.game and self.game.data + local menu = data and data.gen2MenuGfx + return menu and menu.unownPuzzle or nil +end + +function UnownPuzzle:loadGfx() + local gfx = self:gfx() + -- The palettes stand on their own: the board is coloured even on the + -- art-less degrade, because the cart's colour comes from _CGB_UnownPuzzle + -- and not from any sheet. + self.palette = (gfx and gfx.palette) or UnownPuzzle.PALETTE + self.cursorPalette = (gfx and gfx.cursorPalette) + or UnownPuzzle.CURSOR_PALETTE + if not gfx then return end + self.chrome = TileSheet.new({ + path = gfx.chrome, + wide = gfx.chromeTiles or 19, + firstTile = gfx.chromeFirstTile or 0xed, + palette = self.palette, + }) + -- `and` would truncate pcall's second return, so the two loads cannot fold + -- into one-liners. + local path = gfx.pictures and gfx.pictures[(self.puzzle % 4) + 1] + if path then + local ok, image = pcall(Assets.image, path) + if ok and image then self.picture = image end + end + if gfx.cursor then + local ok, image = pcall(Assets.image, gfx.cursor) + if ok and image then self.cursorSheet = image end + end +end + +function UnownPuzzle:sfx(name) + local data = self.game and self.game.data + if data then Sound.play(data, name) end +end + +-- UnownPuzzle_CheckCurrentTileOccupancy: `wPuzzlePieces + wUnownPuzzleCursorPosition`. +function UnownPuzzle:occupant(cell) + return self.pieces[(cell or self.cursor) + 1] or 0 +end + +-- UnownPuzzle_A. Two arms, and both of them refuse loudly rather than +-- silently: an empty cell with nothing held, or an occupied cell with a panel +-- held, is UnownPuzzle_InvalidAction (SFX_WRONG) and nothing moves. +function UnownPuzzle:pressA() + if not self.holding then + local piece = self:occupant() + if piece == 0 then + self:sfx(SFX_WRONG) + return + end + self:sfx(SFX_PICK_UP) + self.pieces[self.cursor + 1] = 0 + self.held = piece + self.holding = true + return + end + if self:occupant() ~= 0 then + self:sfx(SFX_WRONG) + return + end + self:sfx(SFX_PUT_DOWN) + self.pieces[self.cursor + 1] = self.held + self.held = 0 + self.holding = false + if not UnownPuzzle.isSolved(self.pieces) then return end + -- The solve: the caption is blanked (PlaceStartCancelBoxBorder is called + -- again WITHOUT PlaceStartCancelBox), the cursor is cleared, and the screen + -- holds on the fanfare until A or B. + self.solved = true + self.waiting = true + self:sfx(SFX_SOLVED) +end + +function UnownPuzzle:quit() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose(self.solved) end +end + +-- .done_joypad: the SFX depends on whether a panel is riding the cursor, and +-- it only plays on a move that actually happened. +function UnownPuzzle:step(direction) + local target = UnownPuzzle.moveCursor(self.cursor, direction) + if not target then return false end + self.cursor = target + self:sfx(self.holding and SFX_MOVE_PIECE or SFX_MOVE_CURSOR) + return true +end + +local DIRECTIONS = { "up", "down", "left", "right" } + +function UnownPuzzle:update(_dt) + self.frame = self.frame + 1 + local input = self.game and self.game.input + if not input then return end + if self.waiting then + -- SimpleWaitPressAorB, then UnownPuzzle_Quit falls through with + -- wSolvedUnownPuzzle already TRUE. + if input:wasPressed("a") or input:wasPressed("b") then self:quit() end + return + end + if input:wasPressed("start") then + self:quit() + return + end + if input:wasPressed("a") then + self:pressA() + return + end + -- The d-pad repeat, on JoyTextDelay's own 15-then-5 frame counts. + local held = nil + for _, direction in ipairs(DIRECTIONS) do + if input:wasPressed(direction) then + held = direction + self.lastDirection = direction + self.repeatLeft = REPEAT_DELAY + self:step(direction) + return + end + end + for _, direction in ipairs(DIRECTIONS) do + if input.isDown and input:isDown(direction) then held = held or direction end + end + if held ~= self.lastDirection then + self.lastDirection = held + self.repeatLeft = REPEAT_DELAY + return + end + if not held then return end + self.repeatLeft = self.repeatLeft - 1 + if self.repeatLeft > 0 then return end + self.repeatLeft = REPEAT_RATE + self:step(held) +end + +-- ---------------------------------------------------------------- drawing +-- +-- Everything here is 8px tile coordinates, and everything without art falls +-- back to a labelled cell rather than to nothing. +local BOX_X, BOX_Y = 4, 15 +local BOX_RIGHT = 15 +local CAPTION_X, CAPTION_Y = 5, 16 +local CAPTION_TILES = 10 +local CAPTION_FIRST = 0xf6 +-- PlaceStartCancelBoxBorder's six corner and edge tiles. +local BOX_TOP_LEFT, BOX_TOP, BOX_TOP_RIGHT = 0xf0, 0xf1, 0xf2 +local BOX_SIDE, BOX_BOTTOM_LEFT, BOX_BOTTOM_RIGHT = 0xf3, 0xf4, 0xf5 + +-- The board is BROWN, not grey. _CGB_UnownPuzzle (engine/gfx/cgb_layouts.asm) +-- runs CopyFourPalettes over PalPacket_UnownPuzzle, which is +-- PREDEFPAL_UNOWN_PUZZLE four times, and then WipeAttrmap puts every tile on BG +-- palette 0 -- so the whole screen wears white / tan / dark brown / black. The +-- later `ld a, $e4 / call DmgToCgbBGPals` is the IDENTITY reorder of that +-- palette (home/palettes.asm CopyPals), not a grey ramp of its own. +local function paletteColor(colors, index) + local c = GbcPalette.color(colors, index) + return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 +end + +function UnownPuzzle:drawTile(tile, tx, ty) + if self.chrome and self.chrome:draw(tile, tx, ty) then return true end + local G = love.graphics + if tile == UnownPuzzle.VOID_TILE then + G.setColor(paletteColor(self.palette, 1)) + else + G.setColor(paletteColor(self.palette, 3)) + end + G.rectangle("fill", tx * 8, ty * 8, 8, 8) + G.setColor(0, 0, 0, 1) + return false +end + +function UnownPuzzle:fillCell(cell, tile) + local tx, ty = UnownPuzzle.cellTile(cell) + for row = 0, UnownPuzzle.PIECE_TILES - 1 do + for col = 0, UnownPuzzle.PIECE_TILES - 1 do + self:drawTile(tile, tx + col, ty + row) + end + end +end + +-- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is +-- just the panel's row and column in the 4x4 picture. +function UnownPuzzle:drawPiece(piece, tx, ty) + local size = UnownPuzzle.PIECE_TILES * 8 + if self.picture then + local index = piece - 1 + local sx = (index % UnownPuzzle.PIECES_WIDE) * size + local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size + local w, h = self.picture:getDimensions() + local quad = love.graphics.newQuad(sx, sy, size, size, w, h) + love.graphics.setColor(1, 1, 1, 1) + -- The four pictures are drawn out of colours 1 and 2 alone, so binding BG + -- palette 0 here is what turns the panels tan and dark brown. + local function body() + love.graphics.draw(self.picture, quad, tx * 8, ty * 8) + end + if self.palette and GbcPalette.available() then + GbcPalette.with(self.palette, body) + else + body() + end + love.graphics.setColor(0, 0, 0, 1) + return + end + local G = love.graphics + G.setColor(paletteColor(self.palette, 2)) + G.rectangle("fill", tx * 8, ty * 8, size, size) + G.setColor(0, 0, 0, 1) + G.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5, size - 1, size - 1) + Chrome.print(tostring(piece), tx + 1, ty + 1) +end + +-- The cursor's four OBJ tiles, mirrored into a 3x3 bracket by +-- .OAM_NotHoldingPiece: tile 0 is the corner, 1 the top and bottom edge, 2 the +-- left and right edge, 3 the centre. +local CURSOR_CELLS = { + { 0, 0, 0, false, false }, { 1, 1, 0, false, false }, + { 2, 0, 0, true, false }, + { 0, 0, 1, false, false }, { 1, 3, 1, false, false }, + { 2, 0, 1, true, false }, + { 0, 0, 2, false, true }, { 1, 1, 2, false, true }, + { 2, 0, 2, true, true }, +} + +function UnownPuzzle:drawCursor(tx, ty) + local G = love.graphics + if not self.cursorSheet then + G.setColor(paletteColor(self.cursorPalette, 4)) + G.rectangle("line", tx * 8 + 0.5, ty * 8 + 0.5, 23, 23) + G.setColor(0, 0, 0, 1) + return + end + local w, h = self.cursorSheet:getDimensions() + G.setColor(1, 1, 1, 1) + local function body() + for _, spec in ipairs(CURSOR_CELLS) do + local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4], + spec[5] + local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h) + local sx, sy = flipX and -1 or 1, flipY and -1 or 1 + local ox = (tx + col) * 8 + (flipX and 8 or 0) + local oy = (ty + row) * 8 + (flipY and 8 or 0) + G.draw(self.cursorSheet, quad, ox, oy, 0, sx, sy) + end + end + -- OBJ palette 0, whose colours 0 and 3 are both the red _CGB_UnownPuzzle + -- forces in: the bracket's sheet uses only those two, so it draws red. + if self.cursorPalette and GbcPalette.available() then + GbcPalette.with(self.cursorPalette, body) + else + body() + end + G.setColor(0, 0, 0, 1) +end + +function UnownPuzzle:drawStartCancel() + self:drawTile(BOX_TOP_LEFT, BOX_X, BOX_Y) + for tx = BOX_X + 1, BOX_RIGHT - 1 do self:drawTile(BOX_TOP, tx, BOX_Y) end + self:drawTile(BOX_TOP_RIGHT, BOX_RIGHT, BOX_Y) + self:drawTile(BOX_SIDE, BOX_X, BOX_Y + 1) + for tx = BOX_X + 1, BOX_RIGHT - 1 do + self:drawTile(UnownPuzzle.VOID_TILE, tx, BOX_Y + 1) + end + self:drawTile(BOX_SIDE, BOX_RIGHT, BOX_Y + 1) + self:drawTile(BOX_BOTTOM_LEFT, BOX_X, BOX_Y + 2) + for tx = BOX_X + 1, BOX_RIGHT - 1 do self:drawTile(BOX_TOP, tx, BOX_Y + 2) end + self:drawTile(BOX_BOTTOM_RIGHT, BOX_RIGHT, BOX_Y + 2) + -- On the solve the border is redrawn without the caption, so the box empties. + if self.solved then return end + local drew = false + if self.chrome then + drew = self.chrome:available() + for i = 0, CAPTION_TILES - 1 do + self.chrome:draw(CAPTION_FIRST + i, CAPTION_X + i, CAPTION_Y) + end + end + if not drew then + Chrome.print(Strings(UnownPuzzle.CAPTION), CAPTION_X, CAPTION_Y) + end +end + +function UnownPuzzle:drawPanel() + Chrome.clear() + for ty = 0, Chrome.SCREEN_H - 1 do + for tx = 0, Chrome.SCREEN_W - 1 do + self:drawTile(UnownPuzzle.BORDER_TILE, tx, ty) + end + end + for cell = 0, UnownPuzzle.CELLS - 1 do + local tx, ty = UnownPuzzle.cellTile(cell) + local piece = self:occupant(cell) + if piece ~= 0 then + self:drawPiece(piece, tx, ty) + else + self:fillCell(cell, UnownPuzzle.vacantTile(cell)) + end + end + self:drawStartCancel() + if self.solved then return end + local tx, ty = UnownPuzzle.cellTile(self.cursor) + if self.holding then + -- .OAM_HoldingPiece: the panel rides the cursor and is drawn every frame. + if self.held ~= 0 then self:drawPiece(self.held, tx, ty) end + elseif math.floor(self.frame / 16) % 2 == 0 then + -- `ldh a, [hVBlankCounter] / and $10`: sixteen frames on, sixteen off. + self:drawCursor(tx, ty) + end +end + +function UnownPuzzle:draw() + self:drawPanel() +end + +-- The puzzle owns the whole screen: engine/games/unown_puzzle.asm:11 is +-- ClearBGPalettes / ClearTilemap, so nothing of the map survives behind it and +-- the PUZZLE_BORDER black below is the real surround, not a letterbox filler. +function UnownPuzzle:drawsWidescreen() return true end + +function UnownPuzzle:drawWidescreen(winW, winH) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(math.floor((winW - 160 * scale) / 2), + math.floor((winH - 144 * scale) / 2)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return UnownPuzzle diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 426f16da..185c6aef 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -59,10 +59,11 @@ local PAL = { red = { 255, 80, 90 }, -- destructive blue = { 90, 190, 255 }, -- links, in-panel navigation steel = { 120, 120, 120 }, -- disabled - -- the tri-colour version rail is the one piece of brand colour that stays + -- the version rail is the one piece of brand colour that stays railRed = { 255, 60, 72 }, railBlue = { 70, 150, 255 }, - railGold = { 255, 203, 5 }, + railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) + railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line @@ -244,12 +245,12 @@ function Theme.meter(x, y, w, h, pct, c) end end --- The 4px tri-colour rail across the top of both windows: the only brand --- colour on screen, and the one thing that says "this is the Gen 1 launcher". +-- The 4px version rail across the top of both windows: the only brand +-- colour on screen (Red / Blue / Yellow / Gold cartridge colours). function Theme.versionRail(x, y, w, h) if not G then return end - local bars = { PAL.railRed, PAL.railBlue, PAL.railGold } - local seg = w / 3 + local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber } + local seg = w / #bars for i, c in ipairs(bars) do Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1) end diff --git a/src/world/gen2/Bike.lua b/src/world/gen2/Bike.lua new file mode 100644 index 00000000..359ff4d3 --- /dev/null +++ b/src/world/gen2/Bike.lua @@ -0,0 +1,233 @@ +-- The BICYCLE: BikeFunction (engine/events/overworld.asm), the PLAYER_BIKE +-- half of DoPlayerMovement (engine/overworld/player_movement.asm), the map +-- load's CheckUpdatePlayerSprite (engine/overworld/map_setup.asm) and the +-- bike shop's phone call (maps/GoldenrodBikeShop.asm -> +-- engine/overworld/events.asm DoBikeStep). +-- +-- DoBikeStep was already written and running in src/world/gen2/StepEvents.lua +-- and could never fire, because nothing put the player in PLAYER_BIKE and +-- nothing set the flag the clerk sets. Both ends live here. +-- +-- love-free: every routine takes the map environment, the collision under the +-- player and the current wPlayerState as plain values, so the whole decision +-- tree is testable without a world. +-- +-- The three scripts BikeFunction queues are built here too, in the shape +-- src/world/gen2/HiddenItems.lua uses: a `hiddenitem` pickup and a BICYCLE +-- mount are both hand-assembled command lists for the same VM. + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") +local Strings = require("src.core.Strings") + +local Bike = {} + +-- constants/engine_flags.asm. wBikeFlags' three bits and wStatusFlags2's +-- BIKE_SHOP_CALL bit are all reachable from a script as ENGINE_* ids, which is +-- the namespace Vm's setflag / clearflag writes onto save.engineFlags. +Bike.ENGINE_BIKE_SHOP_CALL_ENABLED = 19 +Bike.ENGINE_STRENGTH_ACTIVE = 23 +Bike.ENGINE_ALWAYS_ON_BIKE = 24 +Bike.ENGINE_DOWNHILL = 25 + +-- engine/overworld/variables.asm .VarActionTable, and wPlayerState as +-- VAR_MOVEMENT writes it raw. Script_GetOnBike is `loadvar VAR_MOVEMENT, +-- PLAYER_BIKE`, so the mount is literally one variable write plus a sprite +-- reload. +Bike.VAR_MOVEMENT = 0x08 +Bike.PLAYER_NORMAL_ID = 0 +Bike.PLAYER_BIKE_ID = 1 + +-- MUSIC_BICYCLE. .GetOnBike does not use the outdoor-song override machinery +-- Gen 1 has: it fades the current song out, plays this one and writes it into +-- wMapMusic, so the bike theme IS the map's music until the map changes or the +-- player gets off (`special PlayMapMusic`). +Bike.MUSIC_BICYCLE = "Music_Bicycle" + +-- StepVectors (engine/overworld/map_objects.asm): a normal step is 8 frames of +-- 2 pixels and a fast step 4 frames of 4, so the bike is exactly half the +-- duration of a walk. The port walks a 16-pixel cell in 16 frames, so the +-- ratio is what carries over rather than the count. +function Bike.stepFramesFor(walkFrames) + return math.max(1, math.floor((walkFrames or 16) / 2)) +end + +-- .CheckEnvironment's first half: CheckOutdoorMap (ROUTE or TOWN), plus CAVE +-- and GATE by name. INDOOR, ENVIRONMENT_5 and DUNGEON are the three that +-- refuse -- which is why you cannot ride inside a Gym but can ride through +-- Union Cave and the Route 32 gatehouse. +local RIDEABLE_ENVIRONMENT = { + TOWN = true, ROUTE = true, CAVE = true, GATE = true, +} + +function Bike.environmentAllows(environment) + return RIDEABLE_ENVIRONMENT[environment] == true +end + +-- .CheckEnvironment in full: the environment, then GetPlayerTilePermission +-- `and $f` -- the tile the player is STANDING on has to be a plain LAND_TILE. +-- CollisionPermissionTable calls doors and stairs LAND, so the tiles this +-- actually rejects are water and walls: it is the gate that stops a bike being +-- got on mid-surf. +function Bike.canUseHere(environment, collision) + if not Bike.environmentAllows(environment) then return false end + return Permissions.isLand(collision) +end + +-- .TryBike. Three answers plus a nil, in the cart's own order: +-- +-- nil .CannotUseBike -- `ld a, $0`, so wFieldMoveSucceeded is 0 +-- and the PACK prints OakThisIsntTheTimeText and stays open. +-- "mount" PLAYER_NORMAL and the environment allows it. +-- "dismount" PLAYER_BIKE, and wBikeFlags' ALWAYS_ON_BIKE is clear. +-- "cant_get_off" PLAYER_BIKE on a forced stretch (the Cycling Road's +-- ENGINE_ALWAYS_ON_BIKE). Still returns 1 on the cart, so +-- the PACK closes and the refusal prints in the overworld. +-- +-- A surfing player falls through every `cp` and lands on .CannotUseBike, which +-- is why nil covers PLAYER_SURF without a test of its own. +function Bike.tryBike(ctx) + ctx = ctx or {} + if not Bike.canUseHere(ctx.environment, ctx.collision) then return nil end + local state = ctx.state or FieldMoves.PLAYER_NORMAL + if state == FieldMoves.PLAYER_NORMAL then return "mount" end + if state == FieldMoves.PLAYER_BIKE then + if ctx.alwaysOnBike then return "cant_get_off" end + return "dismount" + end + return nil +end + +-- CheckUpdatePlayerSprite (engine/overworld/map_setup.asm), run on every map +-- load, in the cart's own order: +-- +-- .CheckForcedBiking ALWAYS_ON_BIKE puts the player ON the bike, +-- whatever they walked in as, and wins outright. +-- .CheckSurfing CheckOnWater reads the tile the player is +-- STANDING on: a load that lands on water is a +-- surfing load, and one that already was keeps +-- the sprite it had. +-- .ResetSurfingOrBikingState the two ways the state is taken away: surfing +-- and NOT on water (the arm above has already +-- failed by the time this one runs), or riding +-- into an INDOOR, ENVIRONMENT_5 or DUNGEON map. +-- +-- `onWater` is CheckOnWater's answer. nil means the caller could not read the +-- tile at all -- a world with no map up yet -- and the two surf arms are +-- skipped rather than guessed at, because guessing wrong either strands a +-- player on land aboard a Lapras or drops them into the sea on foot. +function Bike.mapSetupState(state, environment, alwaysOnBike, onWater) + if alwaysOnBike then return FieldMoves.PLAYER_BIKE end + if onWater ~= nil then + if onWater then + if FieldMoves.isSurfing(state) then return state end + return FieldMoves.PLAYER_SURF + end + if FieldMoves.isSurfing(state) then return FieldMoves.PLAYER_NORMAL end + end + if state ~= FieldMoves.PLAYER_BIKE then return state end + if environment == "INDOOR" or environment == "ENVIRONMENT_5" + or environment == "DUNGEON" then + return FieldMoves.PLAYER_NORMAL + end + return state +end + +-- .GetDPad: on a DOWNHILL map (the Cycling Road), a frame with no direction +-- held is a frame moving DOWN -- the bike rolls on its own. A held direction, +-- any held direction, wins. +function Bike.forcedDirection(dir, downhill) + if dir then return dir end + if downhill then return "down" end + return nil +end + +-- .DoStep's pick between STEP_BIKE and STEP_WALK. .BikeCheck is what makes it +-- a bike step at all, and the DOWNHILL exception is the cart's own: coasting +-- across a slope is SLOWER than coasting down it, so every direction but DOWN +-- gets the walking duration back. +function Bike.stepFrames(state, dir, downhill, walkFrames) + walkFrames = walkFrames or 16 + if state ~= FieldMoves.PLAYER_BIKE then return walkFrames end + if downhill and dir ~= "down" then return walkFrames end + return Bike.stepFramesFor(walkFrames) +end + +-- ------------------------------------------------------------ the scripts +-- +-- data/text/common_2.asm. None of the three hangs off a script pointer -- they +-- are `text_far` targets inside engine/events/overworld.asm -- so the extractor +-- never saw them and there is no text.lua key to name them by. Strings.source +-- declares them, Strings() resolves them at the call: the split a module-level +-- table has to use. +-- +-- {STRBUF} is wStringBuffer2, which _DoItemEffect filled with the item's name +-- before it ever reached BikeFunction; `getitemname` is that same fill here. +local TEXT_GOT_ON_BIKE = Strings.source("{PLAYER} got on the\n{STRBUF}.") +local TEXT_GOT_OFF_BIKE = Strings.source("{PLAYER} got off\nthe {STRBUF}.") +local TEXT_CANT_GET_OFF = Strings.source("You can't get off\nhere!") + +-- Script_GetOnBike and Script_GetOffBike, which are the same six commands with +-- a different PLAYER_* byte and a `special PlayMapMusic` on the way out (the +-- bike theme was written into wMapMusic on the way in, so only the dismount +-- has to put the map's own song back). +-- +-- The cart's `refreshmap` and `special UpdateTimePals` are dropped for the same +-- reason HiddenItems.itemfinderScript drops them: both repair the tilemap and +-- the palettes the PACK overwrote, and the port draws the PACK as a state over +-- an untouched world. The `opentext` in front is the mirror of that: the cart +-- inherits the PACK's open text box and this port's queued script does not, so +-- it opens one of its own, exactly as the itemfinder script does. +-- +-- `specialId(name)` resolves a special by LABEL through the cache's own +-- specialOrder; a nil answer just leaves that line out rather than dispatching +-- some other special by a counted index. +local function stateScript(item, varValue, text, specialId, restoreMusic, silent) + local script = {} + if not silent then + -- .CheckIfRegistered: with wUsingItemWithSelect set, the cart swaps in + -- Script_GetOnBike_Register / Script_GetOffBike_Register, which are the + -- same state change with the line and the box taken out -- a SELECT press + -- gets on the bike with no text at all. + script[#script + 1] = { op = "opentext" } + script[#script + 1] = { op = "getitemname", item = item } + end + script[#script + 1] = { op = "loadvar", args = { Bike.VAR_MOVEMENT, varValue } } + if not silent then + script[#script + 1] = { op = "rawtext", text = text } + script[#script + 1] = { op = "waitbutton" } + script[#script + 1] = { op = "closetext" } + end + local update = specialId and specialId("UpdatePlayerSprite") + if update then script[#script + 1] = { op = "special", id = update } end + if restoreMusic then + local play = specialId and specialId("PlayMapMusic") + if play then script[#script + 1] = { op = "special", id = play } end + end + script[#script + 1] = { op = "end" } + return script +end + +function Bike.mountScript(item, specialId, silent) + return stateScript(item, Bike.PLAYER_BIKE_ID, TEXT_GOT_ON_BIKE, + specialId, false, silent) +end + +function Bike.dismountScript(item, specialId, silent) + return stateScript(item, Bike.PLAYER_NORMAL_ID, TEXT_GOT_OFF_BIKE, + specialId, true, silent) +end + +-- Script_CantGetOffBike: no loadvar at all, so wPlayerState is left exactly as +-- it was and the player is still riding when the box closes. +function Bike.cantGetOffScript() + return { + { op = "opentext" }, + { op = "rawtext", text = TEXT_CANT_GET_OFF }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "end" }, + } +end + +return Bike diff --git a/src/world/gen2/BorderFill.lua b/src/world/gen2/BorderFill.lua new file mode 100644 index 00000000..59718b36 --- /dev/null +++ b/src/world/gen2/BorderFill.lua @@ -0,0 +1,213 @@ +-- The border block that surrounds a map. +-- +-- home/map.asm LoadBlockData byte-fills wOverworldMapBlocks with 0 before +-- ChangeMap copies the map's own blocks into the middle of it, and then +-- LoadMetatiles resolves every block it reads: +-- +-- ; If the current map block is a border block, load the border block. +-- ld a, [de] +-- and a +-- jr nz, .ok +-- ld a, [wMapBorderBlock] +-- +-- So block id 0 is not "tileset block 0": it is a stand-in for the map +-- header's border block, both in the margin ChangeMap never wrote to and +-- anywhere inside the map's own block list. A map smaller than the 20x18 +-- viewport (GOLDENROD_DEPT_STORE_ELEVATOR is 2x2 blocks) is almost all +-- margin, which is why it showed as black around a postage stamp instead of +-- the wall block the cart tiles across the whole screen. +-- +-- The fill is one 32x32 bake wrap-tiled over the view, drawn under the map +-- and the connection strips, so it costs a single quad however small the map +-- is. Gen 1 already does this in src/render/TileRenderer.lua (its border +-- block comes straight from the map header and block 0 means nothing there), +-- so this is the Gen 2 half rather than a change to the shared renderer. + +local GbcPalette = require("src.render.GbcPalette") +local PixelCanvas = require("src.render.PixelCanvas") + +local BorderFill = {} + +-- One block, in pixels: 4x4 tiles of 8. +BorderFill.SIZE = 32 + +-- LoadMetatiles' `and a / jr nz` in one place: id 0 (or a hole in the block +-- list) reads as the map header's border block. +function BorderFill.blockFor(blockId, borderBlock) + if blockId == nil or blockId == 0 then return borderBlock or 0 end + return blockId +end + +-- Bakes are cached alongside the map canvases and share their key, so the +-- daytime rollover, the COLOR option and the cave flicker all invalidate the +-- border with the map it belongs to. The suffix keeps World:dropMapImages' +-- "<mapId>|" prefix sweep working on it. +function BorderFill.cacheKey(mapKey) + return tostring(mapKey) .. "|border" +end + +-- Where to put the wrap-tiled quad for a camera at (camX, camY) world pixels +-- filling a w x h screen at scale s. The source origin is floored so the +-- 32x32 texture is sampled on whole texels (a fractional offset would soften +-- the wall against the map's own pixels), and the draw position takes the +-- fraction back so the tiling still meshes with the map canvas next to it. +-- The extra block of width/height covers that shift. +function BorderFill.viewport(camX, camY, w, h, s) + s = (s and s > 0) and s or 1 + local ix, iy = math.floor(camX), math.floor(camY) + local vw = math.ceil(w / s) + BorderFill.SIZE + local vh = math.ceil(h / s) + BorderFill.SIZE + local sx = math.floor((ix - camX) * s) + local sy = math.floor((iy - camY) * s) + return ix, iy, vw, vh, sx, sy +end + +-- Bake `blockId` of `tileset` into a 32x32 repeat-wrapped image. +-- +-- Same palette pass as World:bakeMapImage: a tile's four colors come from its +-- PalMap slot inside the eight BG palettes of `bgSet`, so the walk is by slot +-- and not by tile. 32x32 real pixels through PixelCanvas -- a DPI-scaled +-- canvas would bake the block at a fractional texel size and the repeat wrap +-- would then tile at non-square pixels (#208). +-- +-- `waterFrame` is the optional { image, row, tile, slot } descriptor of this +-- frame's AnimateWaterTile graphic (engine/tilesets/tileset_anims.asm:167). +-- The fill is a wrap-tiled 32x32 texture and cannot be overlaid, so a border +-- block made of water is re-baked per frame instead. +function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame) + if not (atlas and tileset and love and love.graphics) then return nil end + local block = tileset.blocks and tileset.blocks[(blockId or 0) + 1] + if not block then return nil end + local tilesPerRow = tileset.tilesPerRow or 16 + local tilePalettes = tileset.tilePalettes + local colored = bgSet and tilePalettes and GbcPalette.available() + local aw, ah = atlas:getDimensions() + local quads = {} + local function quadFor(tile) + local q = quads[tile] + if q then return q end + q = love.graphics.newQuad((tile % tilesPerRow) * 8, + math.floor(tile / tilesPerRow) * 8, 8, 8, aw, ah) + quads[tile] = q + return q + end + local function drawTiles(slot) + for i = 0, 15 do + local tile = block[i + 1] or 0 + -- tilePalettes is 1-based over the sheet tiles; anything past it takes + -- slot 1, exactly as the map bake does. + local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + if not slot or tileSlot == slot then + love.graphics.draw(atlas, quadFor(tile), + (i % 4) * 8, math.floor(i / 4) * 8) + end + end + end + local canvas = PixelCanvas.new(BorderFill.SIZE, BorderFill.SIZE, "nearest") + if not canvas then return nil end + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + -- A LOVE canvas does not reset the transform, and this bake is reachable + -- from inside World:draw (the first frame after a COLOR change), so the + -- renderer's world transform would otherwise be baked in and then cached. + love.graphics.push() + love.graphics.origin() + if colored then + for slot = 1, 8 do + GbcPalette.with(bgSet[slot], function() drawTiles(slot) end) + end + else + drawTiles(nil) + end + if waterFrame and waterFrame.image and waterFrame.tile then + local quad = love.graphics.newQuad(0, ((waterFrame.row or 1) - 1) * 8, + 8, 8, 8, 32) + local function frames() + love.graphics.setColor(1, 1, 1, 1) + for i = 0, 15 do + if (block[i + 1] or 0) == waterFrame.tile then + love.graphics.draw(waterFrame.image, quad, + (i % 4) * 8, math.floor(i / 4) * 8) + end + end + end + local set = colored and bgSet[waterFrame.slot or 1] + if set then GbcPalette.with(set, frames) else frames() end + end + love.graphics.pop() + love.graphics.setCanvas() + love.graphics.pop() + local img = canvas + if canvas.newImageData then + local ok, data = pcall(canvas.newImageData, canvas) + if ok and data then + local okImg, made = pcall(love.graphics.newImage, data) + if okImg and made then img = made end + end + end + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + return img +end + +-- Each map header carries its OWN border block, so crossing a boundary swaps +-- the whole void from one block to another: Cherrygrove's water becomes Route +-- 30's trees between one frame and the next. On a 20x18 viewport that is a few +-- pixels at the screen edge and nobody sees it; under survey zoom the void is +-- most of the window, and the swap reads as the background popping. +-- +-- So the swap is dissolved rather than cut. `key` is the map the image belongs +-- to, not the image itself: the same block gets re-baked by the daytime +-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on +-- any of those would smear the flicker into mush. +BorderFill.CROSSFADE_FRAMES = 20 + +-- The bookkeeping half, love-free so it can be checked without a canvas. +-- Returns the image to draw underneath (nil on the first fill and once the +-- dissolve is over) and the alpha the incoming image draws at. +function BorderFill.crossfade(owner, image, key) + if not owner or key == nil then return nil, 1 end + if owner.borderKey ~= key then + -- Nothing to dissolve from on the first map of a session. + owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil + owner.borderKey = key + owner.borderFade = owner.borderFrom and 0 or nil + end + owner.borderLast = image + if not owner.borderFade then return nil, 1 end + owner.borderFade = owner.borderFade + 1 + if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then + owner.borderFade, owner.borderFrom = nil, nil + return nil, 1 + end + return owner.borderFrom, owner.borderFade / BorderFill.CROSSFADE_FRAMES +end + +-- Tile `image` across the whole view, world-aligned so it meshes with the map +-- canvas drawn over it. One reused Quad per caller table: this runs every +-- overworld frame, and a fresh Quad here churns the GC. +function BorderFill.draw(owner, image, camX, camY, w, h, s, key) + if not (image and love and love.graphics) then return false end + local ix, iy, vw, vh, sx, sy = BorderFill.viewport(camX, camY, w, h, s) + local q = owner and owner.borderQuad + if q then + q:setViewport(ix, iy, vw, vh, BorderFill.SIZE, BorderFill.SIZE) + else + q = love.graphics.newQuad(ix, iy, vw, vh, + BorderFill.SIZE, BorderFill.SIZE) + if owner then owner.borderQuad = q end + end + local from, alpha = BorderFill.crossfade(owner, image, key) + if from then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(from, q, sx, sy, 0, s, s) + end + love.graphics.setColor(1, 1, 1, alpha) + love.graphics.draw(image, q, sx, sy, 0, s, s) + love.graphics.setColor(1, 1, 1, 1) + return true +end + +return BorderFill diff --git a/src/world/gen2/CmdQueue.lua b/src/world/gen2/CmdQueue.lua new file mode 100644 index 00000000..1f7e4dc4 --- /dev/null +++ b/src/world/gen2/CmdQueue.lua @@ -0,0 +1,263 @@ +-- wCmdQueue: engine/overworld/cmd_queue.asm and home/stone_queue.asm. +-- +-- Four five-byte slots, polled once a frame by HandleCmdQueue, written by the +-- `writecmdqueue` script command and cleared by `delcmdqueue`. The port had +-- neither: both commands were explicit no-ops, and `delcmdqueue` answering TRUE +-- was correct only because the queue it reported on was permanently empty. +-- +-- Only one of the five queue types does anything a player can see, and it is +-- the one that matters most: CMDQUEUE_STONETABLE is what makes a boulder pushed +-- onto a hole fall through it. Two maps use it -- Ice Path B1F and Blackthorn +-- Gym 2F -- and Ice Path gates Blackthorn, so without this the eighth badge is +-- unreachable. +-- +-- CmdQueue_Null ret +-- CmdQueue_Type1 SetXYCompareFlags +-- CmdQueue_StoneTable the boulder check below +-- CmdQueue_Type3 ret +-- CmdQueue_Type4 an hSCY shake, unreferenced by any map +-- +-- love-free: the caller supplies the objects, the warps and a collision lookup. +local Strings = require("src.core.Strings") + +local CmdQueue = {} + +CmdQueue.CAPACITY = 4 + +-- HandleQueuedCommand.Jumptable order (constants/script_constants.asm). +CmdQueue.NULL = 0 +CmdQueue.TYPE1 = 1 +CmdQueue.STONETABLE = 2 +CmdQueue.TYPE3 = 3 +CmdQueue.TYPE4 = 4 +CmdQueue.NUM_TYPES = 5 + +-- CheckPitTile (home/map_objects.asm): COLL_PIT and COLL_PIT_68. +local PIT = { [0x60] = true, [0x68] = true } + +-- SPRITEMOVEDATA_STRENGTH_BOULDER. The check is on the MOVEMENT type, not on +-- SPRITE_BOULDER: Blackthorn Gym 2F has six boulders and only three of them are +-- in its stone table, but all six carry this movedata. +CmdQueue.BOULDER_MOVEDATA = 0x19 + +function CmdQueue.new() + return {} +end + +-- ClearCmdQueue: every slot's TYPE byte zeroed. Called on a map load, which is +-- why a queue never survives a warp and every map that needs one writes it back +-- from a MAPCALLBACK_CMDQUEUE callback. +function CmdQueue.clear(queue) + for i = 1, CmdQueue.CAPACITY do queue[i] = nil end + return queue +end + +-- WriteCmdQueue -> .GetNextEmptyEntry. A full queue sets carry and the write is +-- simply DROPPED; there is no error path and no overwrite. +function CmdQueue.write(queue, entry) + if type(entry) ~= "table" or not entry.kind then return nil end + for i = 1, CmdQueue.CAPACITY do + if queue[i] == nil then + queue[i] = entry + return i + end + end + return nil +end + +-- DelCmdQueue. Answers whether it FOUND and deleted an entry of that type -- +-- which is the opposite of what `delcmdqueue` writes to wScriptVar, because +-- Script_delcmdqueue's `ret c` returns on the delete with wScriptVar still 0 +-- and only falls through to TRUE when the loop ran off the end. +function CmdQueue.delete(queue, kind) + for i = 1, CmdQueue.CAPACITY do + local entry = queue[i] + if entry and entry.kind == kind then + queue[i] = nil + return true + end + end + return false +end + +function CmdQueue.count(queue) + local n = 0 + for i = 1, CmdQueue.CAPACITY do + if queue[i] then n = n + 1 end + end + return n +end + +-- .IsObjectOnWarp's `.check_on_warp`: a linear walk of the map's warp_events +-- for one at the object's cell, answering the warp NUMBER rather than a +-- boolean. The number is 1-based (`ld a, [wCurMapWarpEventCount] / sub d / +-- inc a`), which is the same numbering `stonetable`'s first byte uses. +-- +-- The cart subtracts 4 from the object's stored coordinates because +-- OBJECT_MAP_X / _Y carry the map border's offset; the port stores plain map +-- cells, so there is nothing to subtract. +function CmdQueue.warpNumberAt(warps, x, y) + for index, warp in ipairs(warps or {}) do + if warp.x == x and warp.y == y then return index end + end + return nil +end + +-- .IsObjectInStoneTable: walk `db warp, object / dw script` rows until $ff. +-- BOTH bytes have to match, which is what keeps a boulder pushed onto the wrong +-- hole from falling through it. +function CmdQueue.stoneRow(rows, warpNumber, objectId) + for _, row in ipairs(rows or {}) do + if row.warp == warpNumber and row.object == objectId then return row end + end + return nil +end + +-- CmdQueue_StoneTable. Four gates on the object before HandleStoneQueue is +-- even called, and they are all load bearing: +-- +-- OBJECT_SPRITE non-zero -- a disappeared boulder has no struct left +-- OBJECT_MOVEMENT_TYPE -- SPRITEMOVEDATA_STRENGTH_BOULDER +-- CheckPitTile -- the tile UNDER the boulder is a hole +-- OBJECT_WALKING STANDING -- not mid-push, or it would fall a step early +-- +-- The loop returns on the FIRST boulder that falls (`jr c, .fall_down_hole` +-- pops and rets), so two boulders never drop on the same frame. +function CmdQueue.stoneFall(entry, ctx) + local rows = entry and entry.rows + if not rows then return nil end + for _, obj in ipairs((ctx and ctx.objects) or {}) do + if obj.visible ~= false + and obj.movement == CmdQueue.BOULDER_MOVEDATA + and not obj.moving + and PIT[ctx.collisionAt(obj.cellX, obj.cellY)] then + local warp = CmdQueue.warpNumberAt(ctx.warps, obj.cellX, obj.cellY) + local row = warp and CmdQueue.stoneRow(rows, warp, obj.id) + if row then return row, obj end + end + end + return nil +end + +-- HandleCmdQueue: every slot, in order, once a frame. Only STONETABLE produces +-- anything for the caller to act on; the other four are the cart's own `ret`s +-- and its unreferenced hSCY shake, written out so the jumptable is complete +-- rather than implied. +function CmdQueue.poll(queue, ctx) + for i = 1, CmdQueue.CAPACITY do + local entry = queue[i] + if entry and entry.kind == CmdQueue.STONETABLE then + local row, obj = CmdQueue.stoneFall(entry, ctx) + if row then return row, obj, i end + end + end + return nil +end + +-------------------------------------------------------------------------- +-- The two stone tables +-------------------------------------------------------------------------- +-- +-- These are DATA the extractor cannot reach yet. A stone table hangs off a +-- MAPCALLBACK_CMDQUEUE callback, maps.lua carries no callbacks at all, and the +-- per-boulder scripts are reachable only through the table -- so none of it is +-- in scripts.lua. There are exactly two of them in the whole game and both are +-- eight lines of pokegold, so they are hand-ported here with their source +-- cited, the same standing arrangement the Pokegear's radio lines have. +-- +-- When the extractor grows map callbacks these become the fallback rather than +-- the source: World:writeCmdQueue prefers an extracted entry. +-- +-- Object ids are the cart's own (`object_const_def` is `const_def 2`, so the +-- first object_event of a map is id 2), which is the numbering `disappear` +-- already speaks. Warp numbers are 1-based into the map's warp_events. +-- +-- Event flags are the numbers this cache assigns: +-- EVENT_BOULDER_IN_ICE_PATH_1..4 1801..1804 (the B1F boulders themselves) +-- EVENT_BOULDER_IN_ICE_PATH_1A..4A 1805..1808 (their twins one floor down, +-- on ICE_PATH_B2F_MAHOGANY_SIDE -- clearing one is what makes the fallen +-- boulder appear down there) +-- They are consecutive `const`s in constants/event_flags.asm, and +-- tests/gen2_world_test.lua pins the four the cache actually emits. +local ICE_PATH_BOULDER_EVENT = { 1805, 1806, 1807, 1808 } + +-- maps/IcePathB1F.asm .FinishBoulder, shared by all four rows: +-- pause 30 / scall .BoulderFallsThrough / opentext / writetext / waitbutton / +-- closetext / end, where .BoulderFallsThrough is playsound SFX_STRENGTH + +-- earthquake 80 (two pixels for sixteen frames -- one byte, two numbers). +local function boulderScript(objectId, clearEvent, text) + local script = { + { op = "disappear", object = objectId }, + } + if clearEvent then + script[#script + 1] = { op = "clearevent", event = clearEvent } + end + script[#script + 1] = { op = "pause", frames = 30 } + script[#script + 1] = { op = "playsound", id = 27 } -- SFX_STRENGTH + script[#script + 1] = { op = "earthquake", param = 80 } + script[#script + 1] = { op = "opentext" } + -- `rawtext` is the port's own command, not the cart's: `writetext` names a + -- key into text.lua and this string was never extracted (see the note above). + script[#script + 1] = { op = "rawtext", text = text } + script[#script + 1] = { op = "waitbutton" } + script[#script + 1] = { op = "closetext" } + script[#script + 1] = { op = "end" } + return script +end + +local ICE_PATH_TEXT = Strings.source("The boulder fell\nthrough.") +local BLACKTHORN_TEXT = Strings.source("The boulder fell\nthrough!") + +CmdQueue.STONE_TABLES = { + -- maps/IcePathB1F.asm IcePathB1FSetUpStoneTableCallback. + ICE_PATH_B1F = { + { warp = 3, object = 2, + script = boulderScript(2, ICE_PATH_BOULDER_EVENT[1], ICE_PATH_TEXT) }, + { warp = 4, object = 3, + script = boulderScript(3, ICE_PATH_BOULDER_EVENT[2], ICE_PATH_TEXT) }, + { warp = 5, object = 4, + script = boulderScript(4, ICE_PATH_BOULDER_EVENT[3], ICE_PATH_TEXT) }, + { warp = 6, object = 5, + script = boulderScript(5, ICE_PATH_BOULDER_EVENT[4], ICE_PATH_TEXT) }, + }, + -- maps/BlackthornGym2F.asm. Note the warp order: BOULDER1 goes to warp 5, + -- BOULDER2 to warp 3 and BOULDER3 to warp 4, which is not the order the rows + -- are written in and is transcribed rather than tidied. These three clear no + -- event: nothing appears on the floor below, the boulder is simply gone. + BLACKTHORN_GYM_2F = { + { warp = 5, object = 4, script = boulderScript(4, nil, BLACKTHORN_TEXT) }, + { warp = 3, object = 5, script = boulderScript(5, nil, BLACKTHORN_TEXT) }, + { warp = 4, object = 6, script = boulderScript(6, nil, BLACKTHORN_TEXT) }, + }, +} + +-- MAPCALLBACK_CMDQUEUE's whole job on both maps: `writecmdqueue .CommandQueue` +-- where the entry is `cmdqueue CMDQUEUE_STONETABLE, .StoneTable`. +function CmdQueue.mapEntry(mapId) + local rows = CmdQueue.STONE_TABLES[mapId] + if not rows then return nil end + return { kind = CmdQueue.STONETABLE, rows = rows, mapId = mapId } +end + +-- The same entry taken from the cache instead of from the table above: the +-- extractor now follows `writecmdqueue`'s operand through the cmdqueue struct +-- into the stonetable, so a row arrives naming a scripts.lua key rather than +-- carrying an inlined command list. Answers nil for a cache that predates +-- that, or for any of the four queue types nothing acts on, so the caller +-- falls back to STONE_TABLES rather than writing an entry with no rows. +function CmdQueue.fromExtracted(entry, mapId) + if type(entry) ~= "table" then return nil end + if entry.type ~= CmdQueue.STONETABLE then return nil end + local rows = {} + for _, row in ipairs(entry.rows or {}) do + if row.warp and row.object and row.scriptKey then + rows[#rows + 1] = + { warp = row.warp, object = row.object, script = row.scriptKey } + end + end + if #rows == 0 then return nil end + return { kind = CmdQueue.STONETABLE, rows = rows, mapId = mapId, + extracted = true } +end + +return CmdQueue diff --git a/src/world/gen2/Events.lua b/src/world/gen2/Events.lua new file mode 100644 index 00000000..589c2dbc --- /dev/null +++ b/src/world/gen2/Events.lua @@ -0,0 +1,102 @@ +-- Gen 2 event-flag bitfield (wEventFlags). Flag SET → object with that +-- eventFlag is hidden (CheckObjectFlag in map_objects_2.asm). + +local Runtime = require("src.mods.Runtime") + +local Events = {} +Events.__index = Events + +local FLAGS_PER_BYTE = 8 + +function Events.new(initial) + local self = setmetatable({ flags = {} }, Events) + if type(initial) == "table" then + for _, id in ipairs(initial) do self:set(id, true) end + end + return self +end + +function Events:get(id) + if not id or id < 0 then return false end + local byte = math.floor(id / FLAGS_PER_BYTE) + local bitn = id % FLAGS_PER_BYTE + local row = self.flags[byte] or 0 + return math.floor(row / (2 ^ bitn)) % 2 == 1 +end + +-- flag.changed carries the SAME name and the same two payload keys Gen 1's +-- src/script/Flags.lua emits, and fires on the same condition: only a real +-- transition, so a redundant set of an already-set flag is silent. +-- +-- One difference, and it is a difference in the data rather than in the API: +-- `name` holds a NUMBER here. Gen 2 flags are bits in wEventFlags and the +-- cart's EVENT_* constants are indices into that bitfield, where Gen 1 flags +-- are string keys in save.flags. src/world/gen2/WorldAPI.lua's setFlag tells +-- a mod the same thing from the other side, so a dual-generation listener +-- branches on type(payload.name) rather than on the generation. +-- +-- Events:restore and Events:resetMapBuffer deliberately do NOT come through +-- here: a save load and HandleNewMap's one-byte wipe are not script writes, +-- and Gen 1 does not emit for its own save load either. +function Events:set(id, value) + if not id or id < 0 then return end + local watched = Runtime.wants("flag.changed") + local before = watched and self:get(id) or false + local byte = math.floor(id / FLAGS_PER_BYTE) + local bitn = id % FLAGS_PER_BYTE + local mask = 2 ^ bitn + local row = self.flags[byte] or 0 + if value then + self.flags[byte] = row + (math.floor(row / mask) % 2 == 0 and mask or 0) + else + if math.floor(row / mask) % 2 == 1 then + self.flags[byte] = row - mask + end + end + if watched then + local after = value and true or false + if before ~= after then + Runtime.emit("flag.changed", { name = id, value = after }) + end + end +end + +-- ResetMapBufferEventFlags (home/flag.asm): `xor a / ld hl, wEventFlags / +-- ld [hli], a` zeroes exactly ONE byte -- flags 0-7, the +-- EVENT_TEMPORARY_UNTIL_MAP_RELOAD block -- and HandleNewMap runs it on every +-- map load. This is what re-arms every "once per visit" script latch: Bill's +-- grandpa hands out one stone per house entry because his script sets flag 0 +-- and checks it at the top, and Kurt's house, the ports, Dragon's Den B1F and +-- the National Park gate all lean on the same byte the same way. +function Events:resetMapBuffer() + self.flags[0] = nil +end + +function Events:objectVisible(eventFlag) + -- Extracted maps may keep 0xFFFF instead of nil for "always appear". + if eventFlag == nil or eventFlag == 0xFFFF then return true end + return not self:get(eventFlag) +end + +-- The bitfield as a plain table for the save file, and back. Stored as +-- byte -> value rather than a flag list because that is what the cart's SRAM +-- holds, and because a sparse map keeps a save small (most bytes are 0). +function Events:serialize() + local out = {} + for byte, value in pairs(self.flags) do + if value ~= 0 then out[byte] = value end + end + return out +end + +function Events:restore(bytes) + if type(bytes) ~= "table" then return self end + self.flags = {} + for byte, value in pairs(bytes) do + local index = tonumber(byte) + if index then self.flags[index] = value end + end + return self +end + +return Events diff --git a/src/world/gen2/FieldMoves.lua b/src/world/gen2/FieldMoves.lua new file mode 100644 index 00000000..a9db2afc --- /dev/null +++ b/src/world/gen2/FieldMoves.lua @@ -0,0 +1,810 @@ +-- The seven HM field moves, as the love-free half of +-- engine/events/overworld.asm: "may this move be used from here", "what does +-- it do to the tile", and "which line does it print when it can't". +-- +-- Every routine in that file comes in two flavours and the port needs both, +-- because they are not the same routine with a different caller: +-- +-- *Function / Try*FromMenu the PACK / party-submenu path. Checks the +-- BADGE first (CheckBadge, which prints +-- "Sorry! A new BADGE is required." itself), and +-- assumes the mon is already chosen, because the +-- party list is what chose it. +-- Try*OW the A-press path out of TryTileCollisionEvent. +-- Checks the MOVE first (CheckPartyMove), then +-- the badge through CheckEngineFlag -- which is +-- CheckBadge with the text stripped off, so a +-- badgeless A press is silent, not a refusal. +-- +-- The two orders are the reason a tree you cannot cut says "This tree can be +-- CUT!" and stops, while CUT off the menu with no HIVEBADGE says "Sorry! A new +-- BADGE is required." Keep them apart. +-- +-- Nothing here touches love, the save writer, or the map: a routine is handed +-- a context table (World:fieldContext builds it) and hands back a result the +-- caller acts on. That is what lets tests drive all seven with a bare table. +-- +-- Result shape: +-- { ok = false } the event is declined outright and +-- NOTHING is printed (TryHeadbuttOW's +-- `ret nc`, TrySurfOW's `.quit`) +-- { ok = false, text = ..., badge = } a refusal with a line, `badge` set +-- when it was CheckBadge that refused +-- { ok = true, ask = ..., ... } a yesorno first, then the action +-- { ok = true, action = "cut", ... } run it now +-- +-- `action` names the World method that carries it out; everything else in the +-- table is that action's argument. + +local Permissions = require("src.world.gen2.Permissions") +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") + +local FieldMoves = {} + +-- ---------------------------------------------------------------- text +-- +-- data/text/common_1.asm and common_2.asm, in the port's own TextBox markers: +-- \n is the text box's second line (`line`), \f is a page break (`para`), and +-- {STRBUF} is the shared wStringBuffer2 token that `text_ram wStringBuffer2` +-- expands to -- GetPartyNickname is what fills it, so it is always the +-- nickname of the mon CheckPartyMove picked. +-- +-- These are transcribed rather than looked up for the same reason the headbutt +-- lines in World.lua are: engine/events/overworld.asm names each label +-- directly, the extractor only walks reachable SCRIPT pointers, and so +-- data/generated/text.lua has no key for any of them. `#` is the four-tile +-- POKé compression byte. +-- Each one is wrapped in Strings.source so the catalog generator harvests it: +-- this table is built at require time, long before Strings.load has a catalog, +-- so the lookup itself happens at the World call sites through Strings(...). +FieldMoves.TEXT = { + BADGE_REQUIRED = Strings.source("Sorry! A new BADGE\nis required."), + CANT_USE_HERE = Strings.source("Can't use that\nhere."), + + USE_CUT = Strings.source("{STRBUF} used\nCUT!"), + CUT_NOTHING = Strings.source("There's nothing to\nCUT here."), + ASK_CUT = Strings.source("This tree can be\nCUT!" + .. "\fWant to use CUT?"), + CAN_CUT = Strings.source("This tree can be\nCUT!"), + + BLINDING_FLASH = Strings.source("A blinding FLASH\nlights the area!"), + + USED_SURF = Strings.source("{STRBUF} used\nSURF!"), + CANT_SURF = Strings.source("You can't SURF\nhere."), + ALREADY_SURFING = Strings.source("You're already\nSURFING."), + ASK_SURF = Strings.source("The water is calm.\nWant to SURF?"), + + USE_WATERFALL = Strings.source("{STRBUF} used\nWATERFALL!"), + HUGE_WATERFALL = Strings.source("Wow, it's a huge\nwaterfall."), + ASK_WATERFALL = Strings.source("Do you want to use\nWATERFALL?"), + + USE_STRENGTH = Strings.source("{STRBUF} used\nSTRENGTH!"), + MOVE_BOULDER = Strings.source("{STRBUF} can\nmove boulders."), + ASK_STRENGTH = Strings.source("A #MON may be\nable to move this." + .. "\fWant to use\nSTRENGTH?"), + BOULDERS_MOVE = Strings.source("Boulders may now\nbe moved!"), + BOULDERS_MAY_MOVE = Strings.source("A #MON may be\nable to move this."), + + -- EscapeRopeOrDig's three lines (engine/events/overworld.asm): _UseDigText + -- and _UseEscapeRopeText open the shared warp, _CantUseDigText is DIG's own + -- refusal (the rope's .FailDig arm prints nothing and leaves the PACK to + -- its .Oak line). + USE_DIG = Strings.source("{STRBUF} used\nDIG!"), + USE_ESCAPE_ROPE = Strings.source("{PLAYER} used an\nESCAPE ROPE."), + -- TeleportFunction: _TeleportReturnText on the way out, _CantUseTeleportText + -- indoors. + TELEPORT_RETURN = Strings.source("Return to the last\n#MON CENTER."), + + USE_WHIRLPOOL = Strings.source("{STRBUF} used\nWHIRLPOOL!"), + MAY_PASS_WHIRLPOOL = Strings.source("It's a vicious\nwhirlpool!" + .. "\fA #MON may be\nable to pass it."), + ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way." + .. "\fWant to use\nWHIRLPOOL?"), + -- Not a cart line: the stand-in destination prompt World:askFlyPoint uses + -- until the POKeGEAR's MAP card grows _FlyMap's cursor mode. + ASK_FLY_TO = Strings.source("Fly to %s?"), +} + +-- ---------------------------------------------------------------- badges +-- +-- The ENGINE_*BADGE each Function passes to CheckBadge. There is no pattern +-- to it -- CUT is the HIVEBADGE, SURF is the FOGBADGE, FLASH is the very first +-- badge in the game -- so it is a table, not a formula. +FieldMoves.BADGE = { + CUT = "HIVE", -- CutFunction.CheckAble + FLASH = "ZEPHYR", -- FlashFunction.CheckUseFlash + SURF = "FOG", -- SurfFunction.TrySurf / TrySurfOW + FLY = "STORM", -- FlyFunction.TryFly + STRENGTH = "PLAIN", -- StrengthFunction.TryStrength / TryStrengthOW + WHIRLPOOL = "GLACIER", -- WhirlpoolFunction.TryWhirlpool / TryWhirlpoolOW + WATERFALL = "RISING", -- WaterfallFunction.TryWaterfall / TryWaterfallOW +} + +-- wJohtoBadges bit order, which is also the order src/ui/gen2/TrainerCard.lua +-- lists them in. A save may key `player.badges` by name or by that position, +-- and the trainer card already reads it both ways; this is the same read, so +-- the two screens can never disagree about who owns what. +-- NOTE the order: MINERAL is bit 4 and STORM bit 5, which is NOT the order a +-- player earns them (Chuck's STORMBADGE comes before Jasmine's MINERALBADGE). +-- constants/engine_flags.asm:38-45 is the authority and this follows it; the +-- two used to be swapped here, which silently mapped SURF's gate onto the wrong +-- bit. +FieldMoves.JOHTO_BADGES = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} + +FieldMoves.KANTO_BADGES = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", + "SOUL", "MARSH", "VOLCANO", "EARTH", +} + +-- ENGINE_* id -> which badge store and which name. +-- +-- On the cart these are not two things: ENGINE_ZEPHYRBADGE *is* bit 0 of +-- wJohtoBadges (constants/engine_flags.asm's "; wJohtoBadges" block), so +-- `setflag ENGINE_ZEPHYRBADGE` and "the player owns the Zephyr Badge" are the +-- same write. The port had split them -- scripts wrote save.engineFlags while +-- hasBadge, VAR_BADGES, the trainer card and the save summary all read +-- save.player.badges, which nothing ever wrote. The visible effect was that no +-- field move could EVER be used: Cut, Surf, Strength and Fly all refused with +-- "Sorry! A new BADGE is required" no matter how many gyms were cleared. +-- 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 } +end + +function FieldMoves.hasBadge(save, badge) + if not badge then return true end + local owned = save and save.player and save.player.badges + if type(owned) ~= "table" then return false end + if owned[badge] then return true end + for index, name in ipairs(FieldMoves.JOHTO_BADGES) do + if name == badge then return owned[index] == true end + end + return false +end + +-- CheckPartyMove (engine/events/overworld.asm): the first party slot holding +-- move `moveId`. The cart leaves that slot in wCurPartyMon and every caller +-- reads it back through GetPartyNickname, so the mon comes back with its +-- index rather than a bare yes/no. +-- +-- EGG slots are skipped by the cart's `.next`; the port has no egg state, so +-- there is nothing to skip yet and a mon with an `egg` field is refused here +-- for whenever there is. +-- +-- fieldmove.eligibility wraps it, exactly as it wraps OverworldState:partyKnows +-- under Gen 1: the vanilla link runs first, so a mod that unlocks a field move +-- another way (an HM in the bag, a rental mon) still loses to a party that +-- really knows it. The chain is (moveId, ctx) -> mon, the Gen 1 signature; the +-- second return (the party slot) survives an empty chain but a wrapper that +-- returns one value drops it, which is why every caller here reads only `mon`. +-- +-- ctx keeps Gen 1's `save` and `data` keys and adds the two this arm has that +-- Gen 1's does not: `party` (this module is love-free and takes its state as +-- arguments, so the list is not reachable from a Game) and `moveId`. +local function findMoveUser(party, moveId) + for index, mon in ipairs(party or {}) do + if not mon.egg then + for _, move in ipairs(mon.moves or {}) do + local id = type(move) == "table" and move.id or move + if id == moveId then return mon, index end + end + end + end + return nil +end + +local function partyMoveUserVanilla(moveId, ctx) + return findMoveUser(ctx and ctx.party, moveId) +end + +-- `fieldCtx` is World:fieldContext's table when the caller has one; it is only +-- read for the hook's ctx, so the two-argument Gen 2 callers keep working. +function FieldMoves.partyMoveUser(party, moveId, fieldCtx) + if not Runtime.wantsHook("fieldmove.eligibility") then + return findMoveUser(party, moveId) + end + return Runtime.call("fieldmove.eligibility", partyMoveUserVanilla, moveId, { + save = fieldCtx and fieldCtx.save, + data = fieldCtx and fieldCtx.data, + party = party, + moveId = moveId, + }) +end + +-- --------------------------------------------------------- encounter gate +-- +-- CanEncounterWildMon (engine/overworld/events.asm). The branch that matters +-- is the one in the middle: a CAVE or DUNGEON map jumps STRAIGHT to the ice +-- check, skipping CheckGrassCollision entirely, which is why every walkable +-- tile of Dark Cave and Union Cave is an encounter tile and why the port -- +-- which only ever asked Permissions.isGrass -- gave those maps none at all. +-- +-- `noWildEncounters` is STATUSFLAGS_NO_WILD_ENCOUNTERS_F, the flag the +-- `wildoff` / `wildon` script commands drive. +function FieldMoves.canEncounterWildMon(environment, playerColl, noWild) + if noWild then return false end + if environment ~= "CAVE" and environment ~= "DUNGEON" then + if not Permissions.isEncounterCollision(playerColl) then return false end + end + -- .ice_check: shared by both arms, so an ice floor in a cave is as free of + -- encounters as an ice floor on a route. + if Permissions.isIce(playerColl) then return false end + return true +end + +-- ChooseWildEncounter picks its table off CheckOnWater, i.e. the PERMISSION of +-- the tile the player stands on, not off the grass array that let the roll +-- happen. Standing in a cave rolls the grass list; surfing rolls the water +-- one, on a route and in a cave alike. +function FieldMoves.encounterTable(playerColl) + return Permissions.isWater(playerColl) and "water" or "grass" +end + +-- ------------------------------------------------------------ cut blocks +-- +-- data/collision/field_move_blocks.asm, verbatim. A row is +-- { facing block, replacement block, animation type }: CUT and WHIRLPOOL do +-- not edit tiles, they swap the whole 32x32 BLOCK the facing tile belongs to +-- for another block out of the same tileset, which is why one swing clears a +-- 2x2 patch of grass. +-- +-- Animation type 1 is the grass swirl, 0 the falling tree +-- (OWCutAnimation reads it out of wCutWhirlpoolAnimationType). +FieldMoves.CUT_BLOCKS = { + TILESET_JOHTO = { + [0x03] = { 0x02, 1 }, -- grass + [0x5b] = { 0x3c, 0 }, -- tree + [0x5f] = { 0x3d, 0 }, -- tree + [0x63] = { 0x3f, 0 }, -- tree + [0x67] = { 0x3e, 0 }, -- tree + }, + TILESET_JOHTO_MODERN = { + [0x03] = { 0x02, 1 }, -- grass + }, + TILESET_KANTO = { + [0x0b] = { 0x0a, 1 }, -- grass + [0x32] = { 0x6d, 0 }, -- tree + [0x33] = { 0x6c, 0 }, -- tree + [0x34] = { 0x6f, 0 }, -- tree + [0x35] = { 0x4c, 0 }, -- tree + [0x60] = { 0x6e, 0 }, -- tree + }, + TILESET_PARK = { + [0x13] = { 0x03, 1 }, -- grass + [0x03] = { 0x04, 1 }, -- grass + }, + TILESET_FOREST = { + [0x0f] = { 0x17, 0 }, + }, +} + +FieldMoves.WHIRLPOOL_BLOCKS = { + TILESET_JOHTO = { + [0x07] = { 0x36, 0 }, + }, +} + +-- CheckOverworldTileArrays: the tileset has to be in the dictionary AND the +-- facing block has to be in that tileset's list, or the whole thing fails +-- (both `.nope` arms clear carry). Returns replacement block, animation. +function FieldMoves.blockReplacement(table_, tileset, blockId) + local rows = table_ and tileset and table_[tileset] + local row = rows and blockId and rows[blockId] + if not row then return nil end + return row[1], row[2] +end + +-- CheckMapForSomethingToCut: the facing tile's collision has to be cuttable +-- AND the block it sits in has to have a replacement. Both halves are needed +-- -- a COLL_CUT_TREE in a tileset with no CutTreeBlockPointers row is the +-- cart's own "nothing to cut". +function FieldMoves.somethingToCut(ctx) + if not Permissions.isCuttable(ctx.facingColl) then return nil end + return FieldMoves.blockReplacement( + FieldMoves.CUT_BLOCKS, ctx.tileset, ctx.facingBlock) +end + +-- TryWhirlpoolMenu, which is CheckMapForSomethingToCut with CheckWhirlpoolTile +-- in place of CheckCutCollision. +function FieldMoves.somethingToWhirlpool(ctx) + if not Permissions.isWhirlpool(ctx.facingColl) then return nil end + return FieldMoves.blockReplacement( + FieldMoves.WHIRLPOOL_BLOCKS, ctx.tileset, ctx.facingBlock) +end + +-- CheckMapCanWaterfall: facing UP, and the tile ABOVE the player (wTileUp, not +-- the facing tile the A press found) is a waterfall. Those are the same cell +-- while the player faces up, which is exactly why the routine gets away with +-- reading wTileUp -- but the menu path has no facing tile at all, so it must +-- be wTileUp there too. +function FieldMoves.canWaterfall(ctx) + if ctx.facing ~= "up" then return false end + return Permissions.isWaterfall(ctx.upColl) +end + +-- .CheckContinueWaterfall: the climb keeps applying turn_waterfall UP for as +-- long as the tile the player is STANDING on is still a waterfall tile. +function FieldMoves.waterfallContinues(playerColl) + return Permissions.isWaterfall(playerColl) +end + +-- ------------------------------------------------------- player state +-- +-- constants/ram_constants.asm wPlayerState. Held as strings so a save that +-- round-trips one is readable, and mapped to ChrisStateSprites +-- (data/sprites/player_sprites.asm) on the way to the renderer. +-- +-- PLAYER_SKATE (2) is the one wPlayerState value with no string here: nothing +-- in Gold ever writes it, and ChrisStateSprites has no row for it either. +FieldMoves.PLAYER_NORMAL = "normal" +FieldMoves.PLAYER_BIKE = "bike" +FieldMoves.PLAYER_SURF = "surf" +FieldMoves.PLAYER_SURF_PIKA = "surf_pika" + +FieldMoves.STATE_SPRITE = { + normal = "SPRITE_CHRIS", + bike = "SPRITE_CHRIS_BIKE", + surf = "SPRITE_SURF", + surf_pika = "SPRITE_SURFING_PIKACHU", +} + +function FieldMoves.isBiking(state) + return state == FieldMoves.PLAYER_BIKE +end + +function FieldMoves.isSurfing(state) + return state == FieldMoves.PLAYER_SURF + or state == FieldMoves.PLAYER_SURF_PIKA +end + +-- GetSurfType: the mon CheckPartyMove picked decides the sprite, and PIKACHU +-- is the one species that rides its own. +function FieldMoves.surfType(mon) + local species = mon and (mon.species or mon.id) + if species == "PIKACHU" then return FieldMoves.PLAYER_SURF_PIKA end + return FieldMoves.PLAYER_SURF +end + +-- CheckDirection: refuse to start surfing when the tile permissions already +-- block a step in the direction the player faces. wTilePermissions is the +-- four-way "can I leave this tile" mask built by GetMovementPermissions, and +-- the port has no such mask -- but the thing it is guarding against is +-- surfing off a ledge or through a side wall, so the check is the same +-- question asked of the tile under the player. +local BLOCKED_BY = { + -- COLL_RIGHT_WALL / LEFT / UP, the HI_NYBBLE_SIDE_WALLS rows that are + -- actually used, plus the unused remainder of the block for completeness. + [0xb0] = { right = true }, + [0xb1] = { left = true }, + [0xb2] = { up = true }, + [0xb3] = { down = true }, + [0xb4] = { down = true, right = true }, + [0xb5] = { down = true, left = true }, + [0xb6] = { up = true, right = true }, + [0xb7] = { up = true, left = true }, +} + +function FieldMoves.directionBlocked(playerColl, facing) + local row = playerColl and BLOCKED_BY[playerColl % 256] + return (row and row[facing]) == true +end + +-- ------------------------------------------------------------------- fly +-- +-- data/maps/flypoints.asm, verbatim and in order: FlyMap walks this table by +-- index, so the order is the order the picker scrolls in. Nothing in the ROM +-- points at it as script data, so it is not in landmarks.lua and is +-- transcribed here; FieldMoves.flyPoints reads landmarks.lua for the index and +-- the printed name, and simply drops any row the cache has no landmark for. +-- +-- `flag` is the row's ENGINE_FLYPOINT_* id, constants/engine_flags.asm's +-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own +-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in, +-- and what FieldMoves.hasVisitedSpawn below actually reads. +FieldMoves.FLYPOINTS = { + -- Johto + { landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 }, + { landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 }, + { landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 }, + { landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 }, + { landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 }, + { landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 }, + { landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 }, + { landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 }, + { landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 }, + { landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 }, + { landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 }, + { landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 }, + -- Kanto + { landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 }, + { landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 }, + { landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 }, + { landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 }, + { landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 }, + { landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 }, + { landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 }, + { landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 }, + { landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 }, + { landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 }, + { landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 }, + { landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 }, +} + +-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole +-- table on every call. +local FLYPOINT_BY_SPAWN = {} +for _, row in ipairs(FieldMoves.FLYPOINTS) do + FLYPOINT_BY_SPAWN[row.spawn] = row +end + +-- KANTO_FLYPOINT: the first Kanto row, 1-based here. FlyMap splits the table +-- at it and shows one region's half or the other, never both. +FieldMoves.KANTO_FLYPOINT = 13 + +-- HasVisitedSpawn is a bit in wVisitedSpawns, which the ENGINE_FLYPOINT_* +-- engine flags drive: a town's own MAPCALLBACK_NEWMAP callback runs `setflag +-- ENGINE_FLYPOINT_<X>` the first time the map loads, and Script_setflag +-- (Vm.lua) lands that on save.engineFlags[id] the same way ENGINE_ZEPHYRBADGE +-- and the rest of the namespace do -- see FieldMoves.FLYPOINTS' `flag` +-- column for the id. +-- +-- A save from before this read the engine flags is missing that entry +-- entirely (engineFlags[id] == nil, not false), so the old bookkeeping -- +-- save.visitedSpawns, a plain spawn-name set World used to write by hand -- +-- is kept as the fallback for exactly that case. A save that has both +-- trusts the engine flag; a fresh save never touches visitedSpawns again. +function FieldMoves.hasVisitedSpawn(save, spawn) + if not (save and spawn) then return false end + local row = FLYPOINT_BY_SPAWN[spawn] + local engine = save.engineFlags + if row and type(engine) == "table" then + local set = engine[row.flag] + if set ~= nil then return set == true end + end + return (save.visitedSpawns or {})[spawn] == true +end + +-- The rows FlyMap would actually let the cursor stop on: this region's half of +-- the table, minus every spawn CheckIfVisitedFlypoint rejects. +-- +-- The Kanto half is withheld until SPAWN_INDIGO is visited (.KantoFlyMap's +-- HasVisitedSpawn gate), because with no Kanto flypoint enabled the cart's own +-- picker crashes; standing in Kanto before that shows the Johto map. +function FieldMoves.flyPoints(save, landmarks, region) + local first, last = 1, FieldMoves.KANTO_FLYPOINT - 1 + if region == "kanto" + and FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO") then + first, last = FieldMoves.KANTO_FLYPOINT, #FieldMoves.FLYPOINTS + end + local out = {} + local table_ = landmarks and landmarks.landmarks + for i = first, last do + local row = FieldMoves.FLYPOINTS[i] + if FieldMoves.hasVisitedSpawn(save, row.spawn) then + local entry = table_ and table_[row.landmark] + out[#out + 1] = { + landmark = row.landmark, + spawn = row.spawn, + index = entry and entry.index or nil, + name = entry and entry.name or row.landmark, + } + end + end + return out +end + +-- ------------------------------------------------------------ menu paths +-- +-- The *Function routines, each in its own jumptable order. A menu use has +-- already picked the mon, so `ctx.mon` is that mon and CheckPartyMove is not +-- run again; what these decide is the badge and the situation. + +local function badgeGate(ctx, move) + local badge = FieldMoves.BADGE[move] + if FieldMoves.hasBadge(ctx.save, badge) then return nil end + -- CheckBadge queues .BadgeRequiredText itself and every caller then exits + -- the jumptable, so this is a refusal WITH a line even from the OW paths + -- that use CheckEngineFlag -- those call the flag check, not this. + return { ok = false, badge = badge, text = FieldMoves.TEXT.BADGE_REQUIRED } +end + +-- CutFunction: .CheckAble (badge, then CheckMapForSomethingToCut), .DoCut, +-- .FailCut. +function FieldMoves.cutFromMenu(ctx) + local refused = badgeGate(ctx, "CUT") + if refused then return refused end + local replacement, animation = FieldMoves.somethingToCut(ctx) + if not replacement then + return { ok = false, text = FieldMoves.TEXT.CUT_NOTHING } + end + return { + ok = true, action = "cut", + replacement = replacement, animation = animation, + text = FieldMoves.TEXT.USE_CUT, + } +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." +function FieldMoves.flashFromMenu(ctx) + local refused = badgeGate(ctx, "FLASH") + if refused then return refused end + if not ctx.dark then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH } +end + +-- SurfFunction: .TrySurf, .DoSurf, .FailSurf, .AlreadySurfing. Note the +-- order -- already-surfing is checked BEFORE the facing tile, which is why +-- surfing up to a shore and pressing SURF says "You're already SURFING." +-- rather than "You can't SURF here." +function FieldMoves.surfFromMenu(ctx) + local refused = badgeGate(ctx, "SURF") + if refused then return refused end + if ctx.alwaysOnBike then + return { ok = false, text = FieldMoves.TEXT.CANT_SURF } + end + if FieldMoves.isSurfing(ctx.playerState) then + return { ok = false, text = FieldMoves.TEXT.ALREADY_SURFING } + end + if not Permissions.isWater(ctx.facingColl) + or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then + return { ok = false, text = FieldMoves.TEXT.CANT_SURF } + end + return { + ok = true, action = "surf", + state = FieldMoves.surfType(ctx.mon), + text = FieldMoves.TEXT.USED_SURF, + } +end + +-- FlyFunction.TryFly: badge, then CheckOutdoorMap -- ROUTE or TOWN and nothing +-- else, so a Pokecenter counts as indoors. The picker itself is the caller's +-- job; this only says whether it may open. +function FieldMoves.flyFromMenu(ctx) + local refused = badgeGate(ctx, "FLY") + if refused then return refused end + if ctx.environment ~= "ROUTE" and ctx.environment ~= "TOWN" then + -- .indoors falls to .FailFly, which is FieldMoveFailed. + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { ok = true, action = "fly" } +end + +-- StrengthFunction.TryStrength is the shortest of the seven: the badge, and +-- nothing else. STRENGTH from the menu always succeeds once the PLAINBADGE is +-- in, wherever the player is standing, because all it does is set +-- BIKEFLAGS_STRENGTH_ACTIVE and say so. +function FieldMoves.strengthFromMenu(ctx) + local refused = badgeGate(ctx, "STRENGTH") + if refused then return refused end + return { + ok = true, action = "strength", + text = FieldMoves.TEXT.USE_STRENGTH, + after = FieldMoves.TEXT.MOVE_BOULDER, + } +end + +-- WaterfallFunction.TryWaterfall: badge, then CheckMapCanWaterfall. +function FieldMoves.waterfallFromMenu(ctx) + local refused = badgeGate(ctx, "WATERFALL") + if refused then return refused end + if not FieldMoves.canWaterfall(ctx) then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { + ok = true, action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL, + } +end + +-- WhirlpoolFunction: .TryWhirlpool (badge, TryWhirlpoolMenu), .DoWhirlpool, +-- .FailWhirlpool. +function FieldMoves.whirlpoolFromMenu(ctx) + local refused = badgeGate(ctx, "WHIRLPOOL") + if refused then return refused end + local replacement, animation = FieldMoves.somethingToWhirlpool(ctx) + if not replacement then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { + ok = true, action = "whirlpool", + replacement = replacement, animation = animation, + text = FieldMoves.TEXT.USE_WHIRLPOOL, + } +end + +-- TryHeadbuttFromMenu: no badge at all (HEADBUTT is a TM, not an HM), just the +-- facing tile. +function FieldMoves.headbuttFromMenu(ctx) + if not Permissions.isHeadbuttTree(ctx.facingColl) then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { ok = true, action = "headbutt" } +end + +-- SweetScentFromMenu (engine/events/sweet_scent.asm): QueueScript then an +-- unconditional `ld a, $1 / ld [wFieldMoveSucceeded], a` -- no badge, no +-- tile test, nothing that can refuse the press. Whether anything actually +-- turns up is answered later, by the queued script itself +-- (World:sweetScentEncounter), the same way a failed HEADBUTT still shakes +-- the tree before coming up empty. +function FieldMoves.sweetScentFromMenu(_ctx) + return { ok = true, action = "sweetscent" } +end + +-- DigFunction (engine/events/overworld.asm EscapeRopeOrDig): no badge -- DIG +-- is a TM -- just .CheckCanDig's CAVE / DUNGEON environment and a live dig +-- triple, which the world hands in as ctx.canEscapeRope. .FailDig prints +-- _CantUseDigText for the move (the rope shares the check but fails silent). +function FieldMoves.digFromMenu(ctx) + local env = ctx.environment + if (env ~= "CAVE" and env ~= "DUNGEON") or not ctx.canEscapeRope then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { ok = true, action = "dig", text = FieldMoves.TEXT.USE_DIG } +end + +-- TeleportFunction .TryTeleport: CheckOutdoorMap (TOWN or ROUTE), then the +-- last spawn pair; World:warpToSpawn already resolves that pair with the +-- bedroom fallback the port boots with, so the outdoor test is the whole +-- refusal here. +function FieldMoves.teleportFromMenu(ctx) + local env = ctx.environment + if env ~= "TOWN" and env ~= "ROUTE" then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return { + ok = true, action = "teleport", text = FieldMoves.TEXT.TELEPORT_RETURN, + } +end + +FieldMoves.FROM_MENU = { + CUT = FieldMoves.cutFromMenu, + FLASH = FieldMoves.flashFromMenu, + SURF = FieldMoves.surfFromMenu, + FLY = FieldMoves.flyFromMenu, + STRENGTH = FieldMoves.strengthFromMenu, + WATERFALL = FieldMoves.waterfallFromMenu, + WHIRLPOOL = FieldMoves.whirlpoolFromMenu, + HEADBUTT = FieldMoves.headbuttFromMenu, + SWEET_SCENT = FieldMoves.sweetScentFromMenu, + DIG = FieldMoves.digFromMenu, + TELEPORT = FieldMoves.teleportFromMenu, +} + +-- The party submenu's field-move row. Anything the port has no routine for +-- (SOFTBOILED, ROCK_SMASH, MILK_DRINK) lands on FieldMoveFailed's line, which +-- is what the cart's own unimplemented-here branches print. +function FieldMoves.fromMenu(moveId, ctx) + local fn = FieldMoves.FROM_MENU[moveId] + if not fn then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + return fn(ctx) +end + +-- -------------------------------------------------------------- OW paths +-- +-- Try*OW, reached from TryTileCollisionEvent. The tile has already been +-- matched by the caller (that is what picked which of these to run), so these +-- start at CheckPartyMove and the badge is CheckEngineFlag -- silent. + +-- TryCutOW: no mon or no badge is NOT silent here, it is CantCutScript, so an +-- uncuttable tree still tells you it can be cut. The map check happens after +-- the YES, inside AskCutScript's `callasm .CheckMap`, which is why answering +-- YES to a tree in a tileset with no replacement block simply closes the box. +function FieldMoves.tryCutOW(ctx) + local mon = FieldMoves.partyMoveUser(ctx.party, "CUT", ctx) + if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.CUT) then + return { ok = false, text = FieldMoves.TEXT.CAN_CUT, took = true } + end + local replacement, animation = FieldMoves.somethingToCut(ctx) + return { + ok = true, took = true, mon = mon, + ask = FieldMoves.TEXT.ASK_CUT, + action = replacement and "cut" or nil, + replacement = replacement, animation = animation, + text = FieldMoves.TEXT.USE_CUT, + } +end + +-- TryWhirlpoolOW. Unlike CUT, TryWhirlpoolMenu runs BEFORE the ask, so a +-- whirlpool in a tileset with no replacement block gets the refusal line. +function FieldMoves.tryWhirlpoolOW(ctx) + local mon = FieldMoves.partyMoveUser(ctx.party, "WHIRLPOOL", ctx) + local replacement, animation = FieldMoves.somethingToWhirlpool(ctx) + if not mon + or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WHIRLPOOL) + or not replacement then + return { + ok = false, took = true, text = FieldMoves.TEXT.MAY_PASS_WHIRLPOOL, + } + end + return { + ok = true, took = true, mon = mon, + ask = FieldMoves.TEXT.ASK_WHIRLPOOL, + action = "whirlpool", + replacement = replacement, animation = animation, + text = FieldMoves.TEXT.USE_WHIRLPOOL, + } +end + +-- TryWaterfallOW. +function FieldMoves.tryWaterfallOW(ctx) + local mon = FieldMoves.partyMoveUser(ctx.party, "WATERFALL", ctx) + if not mon + or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WATERFALL) + or not FieldMoves.canWaterfall(ctx) then + return { ok = false, took = true, text = FieldMoves.TEXT.HUGE_WATERFALL } + end + return { + ok = true, took = true, mon = mon, + ask = FieldMoves.TEXT.ASK_WATERFALL, + action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL, + } +end + +-- TrySurfOW. Every failure arm is `.quit` -- `xor a`, no script, no text -- +-- so a shore with no SURF mon is a dead A press, not a refusal. It is also +-- the LAST thing TryTileCollisionEvent tries, so it can afford to be silent. +function FieldMoves.trySurfOW(ctx) + if FieldMoves.isSurfing(ctx.playerState) then return { ok = false } end + if not Permissions.isWater(ctx.facingColl) then return { ok = false } end + if FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then + return { ok = false } + end + if not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.SURF) then + return { ok = false } + end + local mon = FieldMoves.partyMoveUser(ctx.party, "SURF", ctx) + if not mon then return { ok = false } end + if ctx.alwaysOnBike then return { ok = false } end + return { + ok = true, took = true, mon = mon, + ask = FieldMoves.TEXT.ASK_SURF, + action = "surf", state = FieldMoves.surfType(mon), + text = FieldMoves.TEXT.USED_SURF, + } +end + +-- TryStrengthOW, which is a callasm inside AskStrengthScript rather than a +-- tile event: walking into a boulder runs the boulder's own script, and that +-- script asks this which of its three lines to print. The three wScriptVar +-- values are transcribed as strings: +-- +-- 0 "already" STRENGTH is already active -> BouldersMoveText +-- 1 "nope" no mon / no PLAINBADGE -> BouldersMayMoveText +-- 2 "ask" may be turned on right now -> AskStrengthScript +-- +-- Note the inversion in the cart: `bit BIKEFLAGS_STRENGTH_ACTIVE_F` jumps to +-- .already_using when the bit is CLEAR, so 2 is the not-yet case and 0 the +-- already-on one. Reading that backwards swaps the two lines. +function FieldMoves.tryStrengthOW(ctx) + local mon = FieldMoves.partyMoveUser(ctx.party, "STRENGTH", ctx) + if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.STRENGTH) then + return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MAY_MOVE } + end + if ctx.strengthActive then + return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MOVE } + end + return { + ok = true, took = true, mon = mon, + ask = FieldMoves.TEXT.ASK_STRENGTH, + action = "strength", + text = FieldMoves.TEXT.USE_STRENGTH, + after = FieldMoves.TEXT.MOVE_BOULDER, + } +end + +return FieldMoves diff --git a/src/world/gen2/Follower.lua b/src/world/gen2/Follower.lua new file mode 100644 index 00000000..9498df73 --- /dev/null +++ b/src/world/gen2/Follower.lua @@ -0,0 +1,248 @@ +-- Gen 2 party follower: the entity and trail loop Gold's cart has no +-- counterpart for, shaped like src/world/PikachuFollower.lua because that is +-- the surface Gen 1 follower mods drive (docs/mod-api-gen2-compat.md). + +local Logger = require("src.core.Logger") +local Map = require("src.world.gen2.Map") +local NPC = require("src.world.gen2.Npc") + +local Follower = {} + +-- above every extracted object_event index, so the `<map>_obj_<n>` id +-- src/world/gen2/Npc.lua:165 seeds from can never collide with a map object +local INDEX = 250 + +-- Gold ships no such record: a mod patches the `sprites` registry (routed to +-- data.gen2Sprites, src/mods/Schemas.lua:475) before shouldSpawn says yes. +Follower.SPRITE = "SPRITE_PIKACHU" + +local warnedSprite = false + +-- Gold has no companion, so vanilla answers no. A local VARIABLE, not a +-- function: setShouldSpawn writes the same cell debug.setupvalue reaches. +local shouldSpawn + +shouldSpawn = function(_game, _world) + return false +end + +function Follower.setShouldSpawn(fn) + local previous = shouldSpawn + shouldSpawn = fn or previous + return previous +end + +local function spriteDefFor(world) + local sprites = world.sprites or {} + local def = sprites[Follower.SPRITE] + if def then return def end + -- Loud, then the player's sheet: a mod overwrites npc.sprite the line after + -- NPC.new, so a missing record must not decide whether the entity exists. + if not warnedSprite then + warnedSprite = true + Logger.warn("gen2 follower: no %s sprite record; using the player sheet", + Follower.SPRITE) + end + return world.player and world.player.spriteDef +end + +local function makeFollower(_game, world, x, y, facing) + local def = spriteDefFor(world) + if not def then return nil end + local npc = NPC.new(world.map.id, { + -- STANDING_DOWN, not STILL: STILL carries FIXED_FACING + -- (src/world/gen2/Npc.lua:61) and a follower has to turn. + index = INDEX, name = "FOLLOWER", sprite = Follower.SPRITE, + movement = NPC.MOVE.STANDING_DOWN, x = x, y = y, + }, def) + npc.follower = true + -- the Gen 1 name a mod tests when it hunts the stock companion + -- (src/world/PikachuFollower.lua:141) + npc.pikachuFollower = true + npc.passable = true -- never blocks a step (src/world/gen2/Player.lua verdict) + npc.facing = facing or "down" + return npc +end + +local function findFollower(world) + for i, npc in ipairs(world.npcs or {}) do + if npc.pikachuFollower then return npc, i end + end + return nil +end + +local function remove(world) + local npc, i = findFollower(world) + if not npc then return end + table.remove(world.npcs, i) + for j, e in ipairs(world.entities or {}) do + if e == npc then table.remove(world.entities, j) break end + end + world.follower = nil +end + +-- behind the player's facing when walkable, else his own cell: it trails out +-- on the next step (src/world/PikachuFollower.lua:169) +local function spawnCell(world) + local p = world.player + local d = Map.DELTA[p.facing] or Map.DELTA.down + local bx, by = p.cellX - d[1], p.cellY - d[2] + if world.map:inBounds(bx, by) and world.map:isWalkableCell(bx, by) then + return bx, by + end + return p.cellX, p.cellY +end + +function Follower.current(world) + return (findFollower(world)) +end + +function Follower.onMapEntered(game, world, opts, viaMapLoad) + if not (world and world.map and world.player) then return end + remove(world) + if not shouldSpawn(game, world) then return end + -- keepPikachu is Gen 1's spelling of the same opt (src/world/PikachuFollower + -- .lua:191); a mod passing it must not get a fresh spawn at every seam. + local keep = opts and (opts.keepFollower or opts.keepPikachu) + if keep then + table.insert(world.npcs, keep) + table.insert(world.entities, keep) + world.follower = keep + return + end + local x, y = spawnCell(world) + -- a fresh load parks it under the player and it walks out as the trail + -- opens; a mid-map respawn keeps the behind-the-facing cell (#863) + if viaMapLoad then x, y = world.player.cellX, world.player.cellY end + local npc = makeFollower(game, world, x, y, world.player.facing) + if not npc then return end + table.insert(world.npcs, npc) + table.insert(world.entities, npc) + world.follower = npc + world.followerTrail = { x = world.player.cellX, y = world.player.cellY } + -- Gen 1's name for the same table, by reference: rebase mutates it in + -- place, so a mod that resets ow.pikachuTrail still moves the live trail. + world.pikachuTrail = world.followerTrail +end + +-- One follow step per logic frame, called from World:step after +-- World:updatePeople -- src/world/OverworldController.lua:1039's position. +function Follower.update(game, world) + if not (world and world.map and world.player) then return end + local npc = findFollower(world) + if not npc then + if shouldSpawn(game, world) then Follower.onMapEntered(game, world) end + return + end + if not shouldSpawn(game, world) then + remove(world) + return + end + world.follower = npc + local p = world.player + local trail = world.followerTrail + if not trail then + trail = { x = p.cellX, y = p.cellY } + world.followerTrail = trail + world.pikachuTrail = trail + end + -- the commit, not the landing: targetX/Y is the live destination, which is + -- what keeps the gap at one cell (src/world/PikachuFollower.lua:429, #410) + local destX = p.targetX or p.cellX + local destY = p.targetY or p.cellY + if destX ~= trail.x or destY ~= trail.y then + npc.goalX, npc.goalY = trail.x, trail.y + trail.x, trail.y = destX, destY + end + if npc.moving then return end + if not npc.goalX then return end + local gx, gy = npc.goalX, npc.goalY + if npc.cellX == gx and npc.cellY == gy then + npc.goalX, npc.goalY = nil, nil + return + end + -- more than a screen behind (a warp, a scripted move): snap, do not walk + local far = math.abs(npc.cellX - gx) + math.abs(npc.cellY - gy) + if far > 6 then + npc.cellX, npc.cellY = gx, gy + npc.px, npc.py = gx * 16, gy * 16 + npc.goalX, npc.goalY = nil, nil + return + end + local dir + if npc.cellX < gx then dir = "right" + elseif npc.cellX > gx then dir = "left" + elseif npc.cellY < gy then dir = "down" + else dir = "up" end + npc.facing = dir + npc.stepDir = dir + local d = Map.DELTA[dir] + npc.targetX, npc.targetY = npc.cellX + d[1], npc.cellY + d[2] + -- the player's own step length, halved while more than a cell behind: + -- FastPikachuFollow (src/world/PikachuFollower.lua:509) + local stepLen = p.stepFrames or 16 + if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end + npc.stepFrames = stepLen + npc.moving = true + npc.progress = 0 + -- World:updatePeople already ran, so burn the first frame here or the + -- follower loses a pixel a tile (src/world/PikachuFollower.lua:520) + npc:update(world.map, world.entities) +end + +-- The two Gen 1 members a follower mod replaces outright. Gold has neither a +-- companion to talk to nor a walking starter, so both answer honestly nil. +function Follower.talk(_game, _world, _npc, _done) + return false +end + +function Follower.starterInParty(_save, _needHealthy) + return nil +end + +-- Drop the follower from the DRAW list while leaving it in the UPDATE list, +-- so it hides in place and keeps trailing (src/world/PikachuFollower.lua:952). +-- Re-adding faces it down, as the Gen 1 arm does. +function Follower.setVisible(world, visible) + local npc = findFollower(world) + if not npc then return end + local entities = world.entities or {} + for i, e in ipairs(entities) do + if e == npc then + if visible then return end + table.remove(entities, i) + return + end + end + if not visible then return end + npc.facing = "down" + table.insert(entities, npc) +end + +-- The follower when it is STANDING on that cell, which is the test an +-- interact hook wants: mid-step it is between two (src/world/PikachuFollower +-- .lua:965). +function Follower.at(world, cx, cy) + local npc = findFollower(world) + if not npc or npc.moving then return nil end + if npc.cellX == cx and npc.cellY == cy then return npc end + return nil +end + +-- slide into a connected map's frame by the seam's delta, the way +-- src/world/PikachuFollower.lua:386 rebases the Gen 1 arm +function Follower.rebase(world, dx, dy) + local npc = findFollower(world) + if npc then + npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy + npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16 + if npc.targetX then npc.targetX = npc.targetX + dx end + if npc.targetY then npc.targetY = npc.targetY + dy end + if npc.goalX then npc.goalX = npc.goalX + dx end + if npc.goalY then npc.goalY = npc.goalY + dy end + end + local trail = world.followerTrail + if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end +end + +return Follower diff --git a/src/world/gen2/HiddenItems.lua b/src/world/gen2/HiddenItems.lua new file mode 100644 index 00000000..cf11c0e8 --- /dev/null +++ b/src/world/gen2/HiddenItems.lua @@ -0,0 +1,289 @@ +-- Hidden items: engine/events/checkforhiddenitems.asm (the ITEMFINDER sweep) +-- and the BGEVENT_ITEM arm of the bg event dispatch (`.itemifset` in +-- engine/overworld/events.asm, reached from home/map.asm +-- CheckIfFacingTileCoordIsBGEvent), whose body is HiddenItemScript +-- (engine/events/hidden_item.asm). +-- +-- A `bg_event x, y, BGEVENT_ITEM, Label` does NOT name a script. Its operand +-- points at `hiddenitem item, flag`, three bytes laid down by `dwb flag, item` +-- (macros/scripts/maps.asm), and the extractor now carries those two numbers on +-- the bg event row as `hiddenItem = { item, event }` instead of disassembling +-- them. Eighty-seven of them exist; nothing in the port could reach one, +-- because World:bgEventAt only ever answered for BGEVENT_READ. +-- +-- love-free: the caller supplies the map def, the player cell, the flag store +-- and a name-to-id sfx resolver, and gets back a command list for the VM. + +local Strings = require("src.core.Strings") + +local HiddenItems = {} + +-- constants/script_constants.asm BGEVENT_*. +HiddenItems.BGEVENT_ITEM = 7 + +-- constants/hardware.inc: the screen is 20x18 TILES, and a walk cell is two +-- tiles on a side, so SCREEN_WIDTH / 4 and SCREEN_HEIGHT / 4 are half a screen +-- in the cell units bg_event coordinates and wXCoord/wYCoord both use. RGBDS +-- divides integers, so 18 / 4 is 4 and 18 / 2 is 9: the sweep box is NOT +-- symmetric about the player and transcribing it as one loses a row. +local HALF_SCREEN_X, HALF_SCREEN_Y = 5, 4 +local SCREEN_CELLS_X, SCREEN_CELLS_Y = 10, 9 + +-- constants/sfx_constants.asm, resolved by LABEL at call time; the ids here are +-- only the fallback for a cache whose sfx table sits somewhere else. +local SFX_SECOND_PART_OF_ITEMFINDER = { "Sfx_SecondPartOfItemfinder", 18 } +local SFX_TRANSACTION = { "Sfx_Transaction", 34 } +local SFX_ITEM = { "Sfx_Item", 1 } + +-- data/text/common_1.asm and common_2.asm. None of these four is reachable +-- from a script pointer -- the itemfinder's two hang off `text_far` inside +-- engine/items/itemfinder.asm and the pickup's two off HiddenItemScript's own +-- ASM -- so the extractor never saw them and there is no text.lua key to name +-- them by. Strings.source declares them here and Strings() resolves them at +-- the call, which is the split a module-level table has to use. +local TEXT_PLAYER_FOUND = Strings.source("{PLAYER} found\n{STRBUF}.") +local TEXT_BUT_NO_SPACE = Strings.source("But {PLAYER} has\nno space left…") +local TEXT_ITEMFINDER_NEARBY = Strings.source( + "Yes! ITEMFINDER\nindicates there's\nan item nearby.") +local TEXT_ITEMFINDER_NOPE = Strings.source( + "Nope! ITEMFINDER\nisn't responding.") + +-- The ITEMBALL pair is NOT the hidden item's pair, and the two read almost the +-- same, which is exactly why they get confused. FindItemInBallScript writes +-- _FoundItemText and _CantCarryItemText (data/text/common_2.asm:199 and :206); +-- the found line ends on "!" where the hidden item's _PlayerFoundItemText ends +-- on "." (common_1.asm:998), and the full-pocket line is three lines of "But +-- {PLAYER} can't / carry any more / items!" where _ButNoSpaceText is two. The +-- `\v` is the `cont` in that third line, the same scroll the extracted text +-- uses. Declared here for the same reason as the four above: no script pointer +-- reaches them, so the extractor never saw them and there is no text.lua key. +local TEXT_FOUND_ITEM = Strings.source("{PLAYER} found\n{STRBUF}!") +local TEXT_CANT_CARRY = Strings.source( + "But {PLAYER} can't\ncarry any more\vitems!") + +-- The `hiddenitem` pair on a bg event row, or nil when the row is not one. +function HiddenItems.dataOf(bgEvent) + if type(bgEvent) ~= "table" then return nil end + if bgEvent.kind ~= HiddenItems.BGEVENT_ITEM then return nil end + local data = bgEvent.hiddenItem + if type(data) ~= "table" or not data.item then return nil end + return data +end + +-- Every still-unfound hidden item on a map, in bg_event order. `events` is the +-- wEventFlags store (src/world/gen2/Events.lua); a nil one means "nothing found +-- yet", which is what a test harness without a save wants. +function HiddenItems.unfound(mapDef, events) + local out = {} + for _, ev in ipairs((mapDef and mapDef.bgEvents) or {}) do + local data = HiddenItems.dataOf(ev) + if data and not (events and events:get(data.event)) then + out[#out + 1] = { x = ev.x, y = ev.y, item = data.item, event = data.event } + end + end + return out +end + +-- CheckForHiddenItems, spelled out because the box is easy to get wrong. +-- +-- The cart takes the BOTTOM RIGHT corner of the screen (player + half a screen +-- on each axis) and, for each bg event, computes corner minus event coordinate +-- as an unsigned byte. Carry -- the event is past the corner -- skips it, and +-- so does a difference of a whole screen or more. So the surviving box is +-- +-- x in [player - 4 .. player + 5] (10 cells, the player left of centre) +-- y in [player - 4 .. player + 4] (9 cells, the player centred) +-- +-- which is the visible screen, not a radius: this is the same "is it on +-- screen" test the object engine uses, and the ITEMFINDER really does answer +-- for an item the player can see but has walked past. +function HiddenItems.onScreen(px, py, ex, ey) + local dx = (px + HALF_SCREEN_X) - ex + local dy = (py + HALF_SCREEN_Y) - ey + if dx < 0 or dx >= SCREEN_CELLS_X then return false end + if dy < 0 or dy >= SCREEN_CELLS_Y then return false end + return true +end + +-- The whole of CheckForHiddenItems: the first unfound hidden item on screen, or +-- nil. The cart returns a bare carry; the row itself is returned here because +-- nothing else needs the coordinates and a caller that wants the boolean can +-- test for nil. +function HiddenItems.nearby(mapDef, px, py, events) + if not (mapDef and px and py) then return nil end + for _, row in ipairs(HiddenItems.unfound(mapDef, events)) do + if HiddenItems.onScreen(px, py, row.x, row.y) then return row end + end + return nil +end + +-- The hidden item at a cell, if the player is facing one that is still unfound. +-- `.itemifset` checks the flag FIRST and jumps to `.dontread` when it is set, +-- which is why an already-taken hidden item does not eat the A press: the +-- press falls through to TryTileCollisionEvent exactly as if the bg event were +-- not there at all. +function HiddenItems.at(mapDef, cx, cy, events) + for _, ev in ipairs((mapDef and mapDef.bgEvents) or {}) do + if ev.x == cx and ev.y == cy then + local data = HiddenItems.dataOf(ev) + if data and not (events and events:get(data.event)) then + return { x = ev.x, y = ev.y, item = data.item, event = data.event } + end + return nil + end + end + return nil +end + +-- HiddenItemScript (engine/events/hidden_item.asm), command for command: +-- +-- opentext / readmem wHiddenItemID / getitemname STRING_BUFFER_3, +-- USE_SCRIPT_VAR / writetext .PlayerFoundItemText / giveitem ITEM_FROM_MEM / +-- iffalse .bag_full / callasm SetMemEvent / specialsound / itemnotify / +-- sjump .finish +-- +-- The wHiddenItemData copy `.itemifset` makes before it calls the script is +-- what the readmem and the ITEM_FROM_MEM both read; here the item is baked into +-- the list instead, because the list is built per pickup. `callasm +-- SetMemEvent` is the flag write, and it lands only on the arm where the item +-- was really taken -- a full pack leaves the item where it is, findable again. +-- +-- `rawtext` is the port's own command: `writetext` names a key into text.lua +-- and these two lines were never extracted (see the note on the strings above). +function HiddenItems.pickupScript(item, event) + local bagFull = { + { op = "promptbutton" }, + { op = "rawtext", text = TEXT_BUT_NO_SPACE }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "end" }, + } + return { + { op = "opentext" }, + { op = "getitemname", item = item }, + { op = "rawtext", text = TEXT_PLAYER_FOUND }, + { op = "giveitem", item = item, quantity = 1 }, + { op = "iffalse", script = bagFull }, + { op = "setevent", event = event }, + { op = "specialsound" }, + { op = "itemnotify" }, + { op = "closetext" }, + { op = "end" }, + } +end + +-- FindItemInBallScript (engine/events/misc_scripts.asm:9), command for command: +-- +-- callasm .TryReceiveItem / iffalse .no_room / disappear LAST_TALKED / +-- opentext / writetext .FoundItemText / playsound SFX_ITEM / pause 60 / +-- itemnotify / closetext / end +-- +-- .no_room: opentext / writetext .FoundItemText / waitbutton / +-- writetext .CantCarryItemText / waitbutton / closetext / end +-- +-- The A-press arm for an OBJECTTYPE_ITEMBALL object. The pointer under such an +-- object is two raw bytes -- item, quantity -- so there is no bytecode for the +-- VM to start; the extractor read the pair into `def.itemball` and this list is +-- the script the cart would have run. +-- +-- It is NOT HiddenItemScript with a `disappear` swapped in for the flag write, +-- which is how it read before. `.TryReceiveItem` (misc_scripts.asm:38) does +-- BOTH the GetItemName into wStringBuffer3 and the ReceiveItem, in one callasm, +-- before a single box is drawn -- so the getitemname and the give both come +-- first here, the full-pocket branch is taken with nothing yet on screen, and +-- its arm prints the found line, waits, and only then prints the "can't carry +-- any more" line. Three further things the hidden-item shape got wrong: the +-- sound is an unconditional `playsound SFX_ITEM`, not `specialsound` (which +-- would ring SFX_GET_TM for a TM, scripting.asm:476); `disappear` lands BEFORE +-- the text, not after the give; and the success arm holds on `pause 60` under +-- the found line before itemnotify, which is the freeze a pickup is supposed to +-- have and which was missing entirely. +-- +-- `disappear` is what stands in for the hidden item's flag write: +-- World:disappearObject sets the object's own event flag +-- (EVENT_GOT_HM07_WATERFALL on the Ice Path ball is the one a run cannot do +-- without), which is what keeps the ball gone across a reload and what a route +-- row's `expect` reads. The `.no_room` arm never reaches it, so a full pocket +-- leaves the ball findable. +-- +-- The pause operand is the cart's literal 60, the same reading every other +-- transcription in the port uses (CmdQueue's `pause 30`, the fishing `pause 40` +-- in World). Script_pause loops `ld c, 2 / call DelayFrames` per unit, so the +-- hardware holds twice the operand; that factor lives on Vm:pauseFrames, where +-- it fixes every pause at once, rather than being pre-multiplied here. +function HiddenItems.ballPickupScript(item, quantity, objectId, sfxId) + local noRoom = { + { op = "opentext" }, + { op = "rawtext", text = TEXT_FOUND_ITEM }, + { op = "waitbutton" }, + { op = "rawtext", text = TEXT_CANT_CARRY }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "end" }, + } + return { + { op = "getitemname", item = item }, + { op = "giveitem", item = item, quantity = quantity or 1 }, + { op = "iffalse", script = noRoom }, + { op = "disappear", object = objectId }, + { op = "opentext" }, + -- `playsound` leads the text rather than trailing it, and the `pause 60` + -- rides the text row as `hold`, because of the one thing this port's box + -- does that a MapTextbox does not: it takes its own button and pops on it. + -- The cart prints the found line and the itemnotify line into the SAME box + -- with `playsound SFX_ITEM / pause 60` between them and nothing that takes + -- a box down (misc_scripts.asm:13-17). Written straight, the port popped + -- the found box on the press, spent the pause with an EMPTY state stack -- + -- 120 frames of bare overworld inside a single cart textbox, with Game2's + -- play clock (only paused while a state is on the stack) counting every one + -- of them -- and then built a second box. `stay` + `hold` keeps the one + -- box up for the jingle and the pause; World:showText hands it straight + -- over to the itemnotify page in the frame the hold drains. + { op = "playsound", + id = sfxId and sfxId(SFX_ITEM[1], SFX_ITEM[2]) or SFX_ITEM[2] }, + { op = "rawtext", text = TEXT_FOUND_ITEM, stay = true, hold = 60 }, + { op = "itemnotify" }, + { op = "closetext" }, + { op = "end" }, + } +end + +-- ItemFinder's two queued scripts (engine/items/itemfinder.asm). +-- +-- `sfxId(label, fallback)` resolves a pokegold sfx label against this cache's +-- own table. .ItemfinderSound is `ld c, 4` around WaitPlaySFX +-- SFX_SECOND_PART_OF_ITEMFINDER then WaitPlaySFX SFX_TRANSACTION, and +-- WaitPlaySFX waits BEFORE it plays, so the wait leads each of the eight +-- sounds rather than trailing it -- the last one is deliberately left ringing +-- under the text box. +-- +-- The cart's `refreshmap` and `special UpdateTimePals` are dropped: both repair +-- the tilemap and the palettes the PACK overwrote, and the port draws the PACK +-- as a state over an untouched world. Running the port's `refreshmap` here +-- would be a real map reload, which is a much bigger thing than the cart is +-- doing. +function HiddenItems.itemfinderScript(found, sfxId) + local script = {} + if found then + for _ = 1, 4 do + for _, sfx in ipairs({ SFX_SECOND_PART_OF_ITEMFINDER, SFX_TRANSACTION }) do + script[#script + 1] = { op = "waitsfx" } + script[#script + 1] = { + op = "playsound", + id = sfxId and sfxId(sfx[1], sfx[2]) or sfx[2], + } + end + end + end + script[#script + 1] = { op = "opentext" } + script[#script + 1] = { + op = "rawtext", + text = found and TEXT_ITEMFINDER_NEARBY or TEXT_ITEMFINDER_NOPE, + } + script[#script + 1] = { op = "waitbutton" } + script[#script + 1] = { op = "closetext" } + script[#script + 1] = { op = "end" } + return script +end + +return HiddenItems diff --git a/src/world/gen2/Map.lua b/src/world/gen2/Map.lua new file mode 100644 index 00000000..2986afb7 --- /dev/null +++ b/src/world/gen2/Map.lua @@ -0,0 +1,304 @@ +-- Gen 2 runtime map: block grid + COLL_* quads (not Gen 1 walkable lists). +-- Coordinates are unpadded cells (extract stores width×height blocks as-is; +-- WRAM's 3-block border is not mirrored here). + +local Permissions = require("src.world.gen2.Permissions") + +local Map = {} +Map.__index = Map + +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +Map.DELTA = DELTA + +function Map.new(def, tileset) + local self = setmetatable({}, Map) + self.def = def + self.id = def.id + self.tileset = tileset + self.width = def.width + self.height = def.height + self.widthCells = def.width * 2 + self.heightCells = def.height * 2 + self.blocks = def.blocks + self.borderBlock = def.borderBlock or 0 + self.collision = tileset.collision + self.warps = def.warps or {} + self.connections = def.connections or {} + -- A Gen 1 mod reads conn.map; a Gold extraction may only carry conn.mapId, + -- which is why World.computeNeighbors:514 reads both. Normalise here so + -- one read answers on either cache. + for _, conn in pairs(self.connections) do + if type(conn) == "table" and conn.map == nil then conn.map = conn.mapId end + end + -- Warp lookup by cell. + self._warpAt = {} + for i, w in ipairs(self.warps) do + self._warpAt[w.y * 1024 + w.x] = { index = i, def = w } + end + return self +end + +function Map:inBounds(cx, cy) + return cx >= 0 and cy >= 0 + and cx < self.widthCells and cy < self.heightCells +end + +function Map:blockId(bx, by) + if bx < 0 or by < 0 or bx >= self.width or by >= self.height then + return self.borderBlock + end + return self.blocks[by * self.width + bx + 1] or 0 +end + +-- COLL_* byte for cell (cx, cy). Block id 0 is impassable sentinel +-- (GetCoordTileCollision .nope → $ff). +function Map:cellCollision(cx, cy) + local bx, by = math.floor(cx / 2), math.floor(cy / 2) + local id = self:blockId(bx, by) + if id == 0 then return 0xff end + local quad = self.collision and self.collision[id + 1] + if not quad then return 0xff end + local lx, ly = cx % 2, cy % 2 + return quad[ly * 2 + lx + 1] or 0xff +end + +function Map:isWalkable(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isWalkable(self:cellCollision(cx, cy)) +end + +-- ------- the shared cell vocabulary a mod binds to +-- +-- Same four names src/world/Map.lua answers, so one mod's placement and +-- behaviour code reads either generation's map (mod.world hands this object +-- out, and a mod that guards on `map.isWalkableCell and ...` otherwise +-- silently concludes every cell is walkable, dry and grassless). Gen 1 +-- answers from tile ids and a per-tileset set; Gold answers from the COLL_* +-- byte, which is the same question asked of a different grid. + +-- The Gen 1 name for "the byte that decides this cell". Gold's is the +-- collision quad entry, not a tile id, so the ids are NOT comparable across +-- generations: use the predicates, not the number. +local warnedCellTile = false + +function Map:cellTile(cx, cy) + -- Loud once, because the call SUCCEEDS and the number is plausible: a mod + -- comparing it to 0x52 (Gen 1 grass) or 0x14 (water) gets a wrong answer + -- with nothing to show for it. No Gen 2 caller reaches this name. + if not warnedCellTile then + warnedCellTile = true + require("src.core.Logger").warn( + "Map:cellTile on Gold returns a COLL_* byte, not a Gen 1 tile id; the " + .. "two number spaces are unrelated -- use the cell predicates") + end + return self:cellCollision(cx, cy) +end + +function Map:isWalkableCell(cx, cy) + return self:isWalkable(cx, cy) +end + +function Map:isWaterCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isWater(self:cellCollision(cx, cy)) +end + +-- Off-map cells never count as grass, for the reason Gen 1 guards the same +-- way (src/world/Map.lua:224): the border block is filler scenery. +function Map:isGrassCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isGrass(self:cellCollision(cx, cy)) +end + +function Map:warpAt(cx, cy) + return self._warpAt[cy * 1024 + cx] +end + +-- Three more Gen 1 spellings, for the reason the four above exist: a mod +-- guarding on `map.isCounterCell and ...` otherwise sees no counter anywhere. +function Map:warpAtCell(cx, cy) + return self:warpAt(cx, cy) +end + +function Map:isCounterCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isCounter(self:cellCollision(cx, cy)) +end + +-- Gen 1 asks this of a def plus the outdoor tileset set; Gold's header says +-- so outright, so the second argument is ignored. +function Map.isOutside(def, _tilesets) + local env = def and def.environment + return env == "TOWN" or env == "ROUTE" +end + +-- Gen 1 keeps isOutdoor NARROWER than isOutside (src/world/Map.lua:148, :155); +-- Gold decides both from the header's environment byte, so they collapse. +function Map.isOutdoor(def) + if def and def.outdoor ~= nil then return def.outdoor end + return Map.isOutside(def) +end + +-- src/world/Map.lua:176 verbatim: def.region, else the id prefix. Gold's +-- defs carry no region, so the prefix arm is the live one. +function Map.inRegion(def, region, prefix) + if not def then return false end + if def.region ~= nil then return def.region == region end + return prefix ~= nil and def.id ~= nil and def.id:find(prefix, 1, true) == 1 +end + +-- Gen 1 asks the SPRITE NAME (src/world/Map.lua:191); on Gold a boulder is +-- the STRENGTH_BOULDER movement byte (src/world/gen2/Npc.lua:56), so a +-- sprite-name test here would answer false for every real boulder. +function Map.isPushable(objDef) + if not objDef then return false end + if objDef.pushable ~= nil then return objDef.pushable end + return objDef.movement == 0x19 +end + +-- ------- the Gen 1 spellings that read a raw def +-- +-- Gen 1 answers these from tile ids and the tileset's walkable list; Gold +-- answers from the COLL_* quad, which lives on the TILESET either way, so an +-- unloaded neighbour (a connection crossing) can be asked without a Map. + +-- The cell's COLL_* byte off a raw def. NOT a tile id: the number space is +-- unrelated to Gen 1's, so compare with the predicates below, never a literal. +function Map.defCellTile(def, tilesetDef, cx, cy) + if not (def and tilesetDef and tilesetDef.collision and def.blocks) then + return nil + end + local bx, by = math.floor(cx / 2), math.floor(cy / 2) + local id + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then + id = def.borderBlock or 0 + else + id = def.blocks[by * def.width + bx + 1] or 0 + end + if id == 0 then return 0xff end + local quad = tilesetDef.collision[id + 1] + if not quad then return 0xff end + return quad[(cy % 2) * 2 + (cx % 2) + 1] or 0xff +end + +function Map.defIsWalkableCell(def, tilesetDef, cx, cy) + local coll = Map.defCellTile(def, tilesetDef, cx, cy) + if coll == nil then return false end + return Permissions.isWalkable(coll) +end + +function Map.defIsWaterCell(def, tilesetDef, cx, cy) + local coll = Map.defCellTile(def, tilesetDef, cx, cy) + if coll == nil then return false end + return Permissions.isWater(coll) +end + +-- Fails CLOSED on missing data, for the reason src/world/Map.lua:104 does: +-- no data means we cannot prove the landing is safe, so the step bumps. +function Map.defPassable(def, tilesetDef, cx, cy, surfing) + if not (def and tilesetDef and tilesetDef.collision and def.blocks) then + return false + end + if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end + if surfing then + local coll = Map.defCellTile(def, tilesetDef, cx, cy) + return coll ~= nil and Permissions.surfable(coll) ~= nil + end + return false +end + +-- ------- the Gen 1 instance spellings a mod calls on world.map + +-- Gen 1's name for blockId, same border extension (src/world/Map.lua:196). +function Map:blockAt(bx, by) + return self:blockId(bx, by) +end + +-- Writes the block grid in place and nothing else, exactly as +-- src/world/Map.lua:247 does. The VISIBLE edit is World:changeBlock, which +-- also records the blockEdits undo a map reload restores from -- a bare write +-- here leaves a Cut tree gone forever. +function Map:setBlock(bx, by, block) + if bx < 0 or by < 0 or bx >= self.width or by >= self.height then return end + self.blocks[by * self.width + bx + 1] = block +end + +-- Graphics tile id on the 8px grid, border-extended: the same math +-- World:bgTileAt (src/world/gen2/World.lua:7728) does in pixels. Gen 2 +-- blocks are 4x4 tiles too, so src/world/Map.lua:204's index math ports +-- unchanged. Border cells read the border block, not BorderFill.blockFor. +function Map:tileAt(tx, ty) + local blocks = self.tileset and self.tileset.blocks + if not blocks then return nil end + local id = self:blockId(math.floor(tx / 4), math.floor(ty / 4)) + local block = blocks[id + 1] + if not block then return nil end + return block[(ty % 4) * 4 + (tx % 4) + 1] +end + +-- Gold has no door TILE set: a door is a warp collision kind. This is the +-- narrow arm (a door walked INTO), so a floor mat does not answer true. +function Map:isDoorTileCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isImmediateWarp(self:cellCollision(cx, cy)) +end + +-- Gen 1 spells this as two sets (doors OR warp-activating tiles); Gold's +-- warp collision kinds are that union already. +function Map:isWarpTileCell(cx, cy) + if not self:inBounds(cx, cy) then return false end + return Permissions.isWarpCollision(self:cellCollision(cx, cy)) +end + +-- Coordinate-only, so it cannot apply the facing and event filters +-- World:bgEventAt (src/world/gen2/World.lua:7084) applies: it reports bg +-- events the engine would not read. The record is a bgEvent, not a Gen 1 +-- sign -- sign.text is nil on Gold. +function Map:signAtCell(cx, cy) + for _, ev in ipairs(self.def and self.def.bgEvents or {}) do + if ev.x == cx and ev.y == cy then return ev end + end + return nil +end + +-- GetMovementPermissions (home/map.asm): may a step `dir` LEAVE (cx, cy)? +-- +-- Two refusals, both invisible to a plain walkable test. The STANDING tile's +-- side-wall kind blocks its own directions (on an UP_WALL you cannot move up). +-- And Gold's four neighbour arms each set the FACE_DOWN bit when the adjacent +-- tile is their wall kind, so a matching neighbour -- on real maps, an UP_WALL +-- below -- forbids the DOWN step. This is what ends an Ice Path slide on the +-- last ice cell above the $b2 strip instead of gliding onto it, and that rest +-- chain is the only route to HM07 WATERFALL. See Permissions.sideBlocks / +-- neighborBlocksDown for the cart derivation. +function Map:stepPermitted(cx, cy, dir) + return Permissions.stepPermitted( + function(x, y) return self:cellCollision(x, y) end, cx, cy, dir) +end + +function Map:connection(dir) + return self.connections[dir] +end + +-- Destination cell after stepping off this edge onto a connected map. +-- Same strip math as Gen 1 (offset is in blocks). Returns nil if no conn. +function Map.connectionLanding(def, conn, dir, fromCx, fromCy) + if not (def and conn) then return nil end + local destW, destH = def.width * 2, def.height * 2 + local offset = conn.offset or 0 + local x, y + if dir == "up" then + x, y = fromCx - offset * 2, destH - 1 + elseif dir == "down" then + x, y = fromCx - offset * 2, 0 + elseif dir == "left" then + x, y = destW - 1, fromCy - offset * 2 + else + x, y = 0, fromCy - offset * 2 + end + x = math.max(0, math.min(destW - 1, x)) + y = math.max(0, math.min(destH - 1, y)) + return x, y +end + +return Map diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua new file mode 100644 index 00000000..8d1dfd75 --- /dev/null +++ b/src/world/gen2/Npc.lua @@ -0,0 +1,740 @@ +-- Gen 2 map object: walks / spins from SPRITEMOVEDATA_* + object_event +-- radius (not Gen 1's WALK/STAY + range strings). Drawn via SpriteRenderer. + +local Logger = require("src.core.Logger") +local Map = require("src.world.gen2.Map") +local Movement = require("src.script.gen2.Movement") +local Permissions = require("src.world.gen2.Permissions") +local Runtime = require("src.mods.Runtime") +local SpriteRenderer = require("src.render.SpriteRenderer") + +local NPC = {} +NPC.__index = NPC + +local STEP_FRAMES = 16 + +-- OBJECT_ACTION_SPIN's own cadence: the spin frameset turns the sprite a +-- quarter every four frames, which is what makes a teleporting object read as +-- spinning rather than as facing one way while it rises. +local SPIN_FACINGS = { "down", "left", "up", "right" } +local SPIN_FRAMES_PER_FACING = 4 + +-- constants/map_object_constants.asm +local MOVE = { + STILL = 1, + WANDER = 2, + SPINRANDOM_SLOW = 3, + WALK_UP_DOWN = 4, + WALK_LEFT_RIGHT = 5, + STANDING_DOWN = 6, + STANDING_UP = 7, + STANDING_LEFT = 8, + STANDING_RIGHT = 9, + SPINRANDOM_FAST = 10, + SPINCOUNTERCLOCKWISE = 0x1e, + SPINCLOCKWISE = 0x1f, + -- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT` + -- (data/sprites/map_objects.asm). BIG_OBJECT is the bit IsNPCAtCoord tests + -- before handing the coordinate to WillObjectIntersectBigObject -- so the + -- object is TWO cells wide and two tall for collision and for an A press + -- alike. Gold puts exactly two of them on maps: the sleeping Snorlax + -- outside Vermilion ($15) and PLAYERS_HOUSE_2F's big doll decoration ($21). + BIGDOLLSYM = 0x15, + BIGDOLLASYM = 0x20, + BIGDOLL = 0x21, + SWIM_WANDER = 0x24, +} + +local BIG_OBJECT = { + [MOVE.BIGDOLLSYM] = true, + [MOVE.BIGDOLLASYM] = true, + [MOVE.BIGDOLL] = true, +} + +-- Every SPRITEMOVEDATA row whose flags1 byte carries FIXED_FACING +-- (data/sprites/map_objects.asm): STILL $01, BIGDOLLSYM $15, POKEMON $16, +-- SUDOWOODO $17, SMASHABLE_ROCK $18, STRENGTH_BOULDER $19, SHADOW $1b, +-- EMOTE $1c, BIGDOLLASYM $20, BIGDOLL $21, BOULDERDUST $22, GRASS $23. +-- CopySpriteMovementData rewrites OBJECT_FLAGS1 out of the row on every +-- spawn, so this is re-seeded in NPC.new rather than latched: a scripted +-- `fix_facing` correctly dies at the next rebuildPeople, exactly as a +-- respawned object loses it on the cart. +local FIXED_FACING_MOVE = { + [0x01] = true, [0x15] = true, [0x16] = true, [0x17] = true, + [0x18] = true, [0x19] = true, [0x1b] = true, [0x1c] = true, + [0x20] = true, [0x21] = true, [0x22] = true, [0x23] = true, +} + +-- SetFacingBigDoll (engine/overworld/map_object_action.asm): $15 always draws +-- through FacingBigDollSymmetric and $20 always through +-- FacingBigDollAsymmetric, but $21 reads wVariableSprites[SPRITE_BIG_DOLL] and +-- takes the symmetric table only for SPRITE_BIG_SNORLAX and SPRITE_BIG_LAPRAS. +-- SPRITE_BIG_ONIX is the doll a mirrored left half would draw wrong. +local BIG_DOLL_SYM_SPRITES = { + SPRITE_BIG_SNORLAX = true, + SPRITE_BIG_LAPRAS = true, +} + +function NPC.bigFacing(movement, spriteId) + if movement == MOVE.BIGDOLLSYM then return "sym" end + if movement == MOVE.BIGDOLLASYM then return "asym" end + if movement ~= MOVE.BIGDOLL then return nil end + return BIG_DOLL_SYM_SPRITES[spriteId] and "sym" or "asym" +end + +local FACING_FROM_MOVE = { + [MOVE.STILL] = "down", + [MOVE.WANDER] = "down", + [MOVE.SPINRANDOM_SLOW] = "down", + [MOVE.WALK_UP_DOWN] = "down", + [MOVE.WALK_LEFT_RIGHT] = "left", + [MOVE.STANDING_DOWN] = "down", + [MOVE.STANDING_UP] = "up", + [MOVE.STANDING_LEFT] = "left", + [MOVE.STANDING_RIGHT] = "right", + [MOVE.SPINRANDOM_FAST] = "down", + [MOVE.SWIM_WANDER] = "down", + -- The two spin rows are the only ones in the table that do NOT start facing + -- down: `db LEFT ; facing` and `db RIGHT ; facing` (data/sprites/map_objects + -- .asm:245-256). They are the first quarter of their own cycle. + [MOVE.SPINCOUNTERCLOCKWISE] = "left", + [MOVE.SPINCLOCKWISE] = "right", +} + +local DIRS_Y = { "up", "down" } +local DIRS_X = { "left", "right" } +local DIRS_ANY = { "up", "down", "left", "right" } + +-- _MovementSpinTurnRight / _MovementSpinTurnLeft's two facing tables +-- (engine/overworld/map_objects.asm:826-843), read as "from this facing, next +-- this one". The cart indexes them by OBJECT_DIRECTION >> 2, i.e. the OW_DOWN +-- / OW_UP / OW_LEFT / OW_RIGHT order; this is the same four rows by name. +-- +-- Unlike the two RANDOM spins these are DETERMINISTIC quarter turns -- which is +-- the whole point of them, because a spinner whose facing is the puzzle (the +-- Team Rocket base's guard patterns) has to be predictable. +local SPIN_NEXT = { + clockwise = { down = "left", up = "right", left = "up", right = "down" }, + counterclockwise = { down = "right", up = "left", left = "down", right = "up" }, +} + +-- `ld a, $10 / ld [OBJECT_STEP_DURATION]` then STEP_TYPE_SLEEP +-- (_MovementSpinRepeat, map_objects.asm:809-823): a fixed sixteen frames on +-- each quarter, no Random anywhere in the loop. +local SPIN_TURN_FRAMES = 16 + +local function rand(a, b) + if love and love.math and love.math.random then + return love.math.random(a, b) + end + return math.random(a, b) +end + +local function randf() + if love and love.math and love.math.random then + return love.math.random() + end + return math.random() +end + +local function patternFor(movement) + if movement == MOVE.WALK_UP_DOWN then + return "walk", DIRS_Y + elseif movement == MOVE.WALK_LEFT_RIGHT then + return "walk", DIRS_X + elseif movement == MOVE.WANDER or movement == MOVE.SWIM_WANDER then + return "walk", DIRS_ANY + elseif movement == MOVE.SPINRANDOM_SLOW then + return "spin", DIRS_ANY, 60, 180 + elseif movement == MOVE.SPINRANDOM_FAST then + return "spin", DIRS_ANY, 20, 60 + elseif movement == MOVE.SPINCLOCKWISE then + return "turn", SPIN_NEXT.clockwise, SPIN_TURN_FRAMES, SPIN_TURN_FRAMES + elseif movement == MOVE.SPINCOUNTERCLOCKWISE then + return "turn", SPIN_NEXT.counterclockwise, + SPIN_TURN_FRAMES, SPIN_TURN_FRAMES + end + return "stand", nil +end + +-- Gen 1's two behaviour strings onto the cart's SPRITEMOVEDATA byte. STAY is +-- STANDING_*, never STILL: STILL carries FIXED_FACING (above) and an object +-- that cannot turn is not a trailer. WALK gets a radius default because Gen 2 +-- refuses every step outside it and radius 0 would freeze the object. +local GEN1_STAY = { + UP = MOVE.STANDING_UP, DOWN = MOVE.STANDING_DOWN, + LEFT = MOVE.STANDING_LEFT, RIGHT = MOVE.STANDING_RIGHT, +} + +-- The sheet a Gen 1 NPC.new falls back to when the SPRITE_* id it names is +-- not in Gold's table. src/mods/Gen2Compat.lua points this at the live +-- player's; a mod overwrites npc.sprite the line after the call, so a missing +-- record must not decide whether the entity exists. +NPC.fallbackSpriteDef = nil + +local warnedGen1Sprite = {} + +-- src/world/NPC.lua:23's shape: (data, mapId, objDef). Sniffed rather than +-- given its own name so `getmetatable(npc) == require("src.world.NPC")` holds +-- for a gen2compat mod -- the facade IS this table (src/mods/Gen2Compat.lua), +-- and a constructor that lived beside it would hand back objects carrying a +-- different metatable than the module the mod holds. +local function fromGen1(data, mapId, objDef) + local movement, range = objDef.movement, objDef.range + local mv, radius + if type(movement) == "number" then + mv, radius = movement, objDef.radius + elseif movement == "WALK" then + if range == "UP_DOWN" then mv = MOVE.WALK_UP_DOWN + elseif range == "LEFT_RIGHT" then mv = MOVE.WALK_LEFT_RIGHT + else mv = MOVE.WANDER end + radius = objDef.radius or { x = 3, y = 3 } + else + mv = GEN1_STAY[range] or MOVE.STANDING_DOWN + end + local sprites = data and (rawget(data, "gen2Sprites") or data.sprites) + local def = sprites and objDef.sprite and sprites[objDef.sprite] + if not def then + local key = tostring(objDef.sprite) + if not warnedGen1Sprite[key] then + warnedGen1Sprite[key] = true + Logger.warn("src.world.NPC: no %s in Gold's sprite table; using the " + .. "player sheet", key) + end + local fallback = NPC.fallbackSpriteDef + def = type(fallback) == "function" and fallback() or fallback + end + if not def then + error("src.world.NPC: no sprite record for " .. tostring(objDef.sprite), 0) + end + local npc = NPC.new(mapId, { + index = objDef.index, name = objDef.name, sprite = objDef.sprite, + movement = mv, radius = radius, x = objDef.x, y = objDef.y, + }, def) + -- the Gen 1 SPRITE_* id the caller passed in, which Gold's objDef does not + -- keep once the sheet is resolved + npc.spriteId = objDef.sprite + return npc +end + +function NPC.new(mapId, objDef, spriteDef) + -- Gen 1 passes the DATA table first; Gold's first argument is always the + -- map id string. + if type(mapId) == "table" then return fromGen1(mapId, objDef, spriteDef) end + local movement = objDef.movement or MOVE.STILL + local kind, dirs, spinLo, spinHi = patternFor(movement) + local radius = objDef.radius or {} + local self = setmetatable({ + def = objDef, + id = string.format("%s_obj_%d", mapId, objDef.index or 0), + mapId = mapId, + cellX = objDef.x, + cellY = objDef.y, + homeX = objDef.x, + homeY = objDef.y, + px = objDef.x * 16, + py = objDef.y * 16, + facing = FACING_FROM_MOVE[movement] or "down", + moving = false, + progress = 0, + stepFlip = false, + -- OBJECT_FLAGS2's IN_GRASS_F (engine/overworld/map_objects.asm:247), set + -- from the spawn tile by the STEP_TYPE_RESET latch in NPC:update. + inGrass = false, + spawnLatched = false, + frozen = false, + kind = kind, + roamDirs = dirs, + radiusX = radius.x or 0, + radiusY = radius.y or 0, + spinLo = spinLo, + spinHi = spinHi, + bigObject = BIG_OBJECT[movement] == true, + bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id), + fixedFacing = FIXED_FACING_MOVE[movement] or nil, + timer = rand(30, 120), + sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)), + -- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the + -- time of day decides the real colors AND which pixels are transparent. + -- World:applyPalettes pushes them into the SpriteRenderer and refreshes + -- them when the clock rolls over, so a pooled NPC never keeps yesterday's. + spriteDef = spriteDef, + }, NPC) + return self +end + +-- `variablesprite` on a slot this object reads through: the sheet changes and +-- NOTHING else does. Script_variablesprite writes one byte of wVariableSprites +-- (engine/overworld/scripting.asm:869) and the `special LoadUsedSpritesGFX` +-- beside it reloads the tiles -- the object STRUCT is never touched, so its +-- coordinates, its facing, its FROZEN_F and its identity as wLastTalked all +-- survive. Two map scripts depend on that: LassAliceScript's +-- `applymovement ... Movement_NinjaSpin / faceplayer / variablesprite / special +-- LoadUsedSpritesGFX / faceplayer` (maps/FuchsiaGym.asm:61-66, and the same +-- shape for Linda, Cindy and Barry) and CopycatsHouse2F.asm:23-48. +-- +-- So this repaints in place rather than the World retiring the NPC and letting +-- rebuildPeople make a new one: a new table would strand World.talkNpc, +-- .trainerNpc, .followState and any running moveState on an object that is no +-- longer on the map, and drop the ninja back to her map-def cell and default +-- facing halfway through unmasking. +function NPC:setSpriteDef(spriteDef) + if not spriteDef or spriteDef == self.spriteDef then return false end + self.spriteDef = spriteDef + self.sprite = SpriteRenderer.new(spriteDef, self.id) + -- bigFacing is derived from the SHEET (NPC.bigFacing keys off spriteDef.id), + -- so it is the one cached field that has to be recomputed with it. + self.bigFacing = NPC.bigFacing(self.def and self.def.movement, spriteDef.id) + return true +end + +function NPC:inRadius(tx, ty) + return math.abs(tx - self.homeX) <= self.radiusX + and math.abs(ty - self.homeY) <= self.radiusY +end + +-- WillObjectIntersectBigObject (engine/overworld/npc_movement.asm): the object's +-- own coordinates are the TOP LEFT of the blob, and a cell belongs to it when +-- both `coord - object` land in 0..1 (`sub [hl] / jr c, .nope / cp 2 / jr nc`). +-- Every other object is the one cell it stands on, which is what the fall +-- through to a plain compare says. +function NPC:covers(cx, cy) + if not self.bigObject then + return self.cellX == cx and self.cellY == cy + end + local dx, dy = cx - self.cellX, cy - self.cellY + return dx >= 0 and dx < 2 and dy >= 0 and dy < 2 +end + +function NPC:facePlayer(player) + -- ApplyObjectFacing (engine/overworld/scripting.asm:856) refuses a + -- fixed-facing object outright, and _DoesSpriteHaveFacings + -- (engine/overworld/overworld.asm:343) returns carry for a STILL_SPRITE, + -- whose sheet has only the one pose to turn to. + if self.fixedFacing then return end + if self.spriteDef and (self.spriteDef.frames or 0) <= 1 then return end + local dx = player.cellX - self.cellX + local dy = player.cellY - self.cellY + if math.abs(dx) > math.abs(dy) then + self.facing = dx > 0 and "right" or "left" + else + self.facing = dy > 0 and "down" or "up" + end +end + +function NPC:scriptFace(dir) + if self.fixedFacing then return end + if dir then self.facing = dir end +end + +-- The direction the object MOVES in and the direction it is DRAWN facing are +-- two different bytes. InitStep skips the OBJECT_DIRECTION write while +-- FIXED_FACING_F is set (engine/overworld/map_objects.asm:284-294) and +-- SetFacingStepAction bails to SetFacingCurrent while SLIDING_F is set +-- (engine/overworld/map_object_action.asm:48), so either flag walks the object +-- across the map without turning it. +function NPC:scriptStep(dir) + if self.moving then return false end + self.stepDir = dir or self.facing + if not self.fixedFacing and not self.sliding then + self.facing = self.stepDir + end + local d = Map.DELTA[self.stepDir] + if not d then + self.stepDir = nil + return false + end + self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2] + self.moving = true + self.progress = 0 + self.frozen = true + return true +end + +-- StepFunction_TeleportFrom / _TeleportTo (engine/overworld/map_objects.asm). +-- `from` is sixteen frames of OBJECT_ACTION_SPIN on the spot and then sixteen +-- more spinning while OBJECT_JUMP_HEIGHT walks OBJECT_SPRITE_Y_OFFSET up a +-- sine, so the object lifts off its tile before `disappear` takes it away. +-- `to` is the same three beats in reverse: a still wait, a spinning descent +-- from the same curve, and one last spin once it has landed. +-- +-- The step type owns the object until it is done, which is why this sets +-- `frozen`: World:beginMovement's own sleep counter is what waits it out. +function NPC:scriptTeleport(mode, frames) + local beat = Movement.TELEPORT_BEAT_FRAMES + self.teleport = { + mode = mode == "to" and "to" or "from", + frame = 0, + frames = frames or ((mode == "to") and 3 * beat or 2 * beat), + } + self.frozen = true + self.spriteYOffset = 0 + return true +end + +-- One frame of that step type. Returns false once the last beat is over, so +-- the caller can drop the state. +function NPC:updateTeleport() + local st = self.teleport + if not st then return false end + local beat = Movement.TELEPORT_BEAT_FRAMES + st.frame = st.frame + 1 + local spinning = true + if st.mode == "from" then + if st.frame <= beat then + -- .DoSpin: the object is still on its tile for the first beat. + self.spriteYOffset = 0 + else + -- .DoSpinRise: OBJECT_JUMP_HEIGHT starts at $10 and is incremented once + -- a frame, so the sine walks the sprite off the top of the tile. + self.spriteYOffset = Movement.teleportYOffset( + Movement.TELEPORT_RISE_HEIGHT + (st.frame - beat)) + end + elseif st.frame <= beat then + -- .DoWait holds OBJECT_ACTION_00, so nothing spins: the object simply + -- waits at the far end of the descent curve. + spinning = false + self.spriteYOffset = Movement.teleportYOffset( + Movement.TELEPORT_FALL_HEIGHT) + elseif st.frame <= 2 * beat then + -- .DoDescent, the rise's curve read backwards down to the tile. + self.spriteYOffset = Movement.teleportYOffset(st.frame - beat) + else + -- .DoFinalSpin, back on the ground. + self.spriteYOffset = 0 + end + if spinning then + self.facing = SPIN_FACINGS[ + (math.floor(st.frame / SPIN_FRAMES_PER_FACING) % #SPIN_FACINGS) + 1] + end + if st.frame >= st.frames then + self.teleport = nil + self.spriteYOffset = 0 + return false + end + return true +end + +-- Movement_tree_shake (engine/overworld/movement.asm:334): OBJECT_ACTION is +-- set to OBJECT_ACTION_WEIRD_TREE and the object is parked on +-- STEP_TYPE_SLEEP for 24 frames. Same lifetime as scriptTeleport above -- +-- the step type owns the object, and World:beginMovement waits it out on the +-- sleep counter -- so it sets `frozen` the same way. +function NPC:scriptTreeShake(frames) + self.treeShake = { + frame = 0, + frames = frames or Movement.TREE_SHAKE_FRAMES, + } + self.frozen = true + return true +end + +-- One frame of it. Returns false on the last beat so the caller can drop the +-- state, matching NPC:updateTeleport. +function NPC:updateTreeShake() + local st = self.treeShake + if not st then return false end + st.frame = st.frame + 1 + if st.frame >= st.frames then + self.treeShake = nil + return false + end + return true +end + +-- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same +-- name and meaning src/world/Collision.lua:20 gives it under Gen 1. +local function occupied(entities, tx, ty, self) + if not entities then return false end + for _, e in ipairs(entities) do + if e ~= self and not e.passable then + if e.cellX == tx and e.cellY == ty then return true end + if e.moving and e.targetX == tx and e.targetY == ty then return true end + end + end + return false +end + +-- the movement.collision chain's vanilla link and the verdict it wraps, both +-- hoisted so an empty chain allocates nothing (src/world/gen2/Player.lua and +-- src/world/Collision.lua are the same shape) +local function passthrough(allowed) return allowed end + +local function wanderVerdict(self, map, entities, tx, ty) + if not self:inRadius(tx, ty) then return false, "radius" end + if not map:isWalkable(tx, ty) then return false, "tile" end + -- don't walk out through doors + if map:warpAt(tx, ty) then return false, "warp" end + if occupied(entities, tx, ty, self) then return false, "entity" end + return true +end + +function NPC:walkPhase() + -- SetFacingStepAction bails to SetFacingCurrent BEFORE it increments + -- OBJECT_STEP_FRAME (engine/overworld/map_object_action.asm:48), so a + -- sliding object holds its step frame as well as its facing: it glides. + if self.sliding then return 0 end + if not self.moving then return 0 end + local frames = self.stepFrames or STEP_FRAMES + local p = self.progress % frames + return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0 +end + +-- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a +-- mod poses the object it is FOLLOWING, not only one it built itself. +function NPC:pose() + return self.sprite, self.px, self.py + (self.spriteYOffset or 0), + self.facing, self:walkPhase(), self.stepFlip, false +end + +-- SetTallGrassFlags' test (engine/overworld/map_objects.asm:247). +function NPC.grassAt(map, cx, cy) + if not (map and map.cellCollision and cx and cy) then return false end + local coll = map:cellCollision(cx, cy) + return Permissions.isSuperTallGrass(coll) or Permissions.isGrass(coll) +end + +function NPC:update(map, entities) + -- STEP_TYPE_RESET's StepFunction_Reset reads the object's OWN tile into + -- SetTallGrassFlags (engine/overworld/map_objects.asm:498-511, :196-208). + if not self.spawnLatched and map then + self.spawnLatched = true + self.inGrass = NPC.grassAt(map, self.cellX, self.cellY) + end + -- The teleport step type owns the object outright (it replaces + -- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen + -- gate the way the walk interpolation does. + if self.teleport then + self:updateTeleport() + return + end + -- STEP_TYPE_SLEEP with OBJECT_ACTION_WEIRD_TREE owns the object the same + -- way the teleport step type does, so it sits in the same position. + if self.treeShake then + self:updateTreeShake() + return + end + -- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no + -- translation. Above the moving arm because it has no targetX to reach, + -- and the arm below would assign cellX = nil a frame later. + if self.marching then + self.moving = true + self.progress = self.progress + 1 + if self.progress >= (self.stepFrames or STEP_FRAMES) then + self.progress = 0 + self.moving = false + self.marching = false + self.stepFlip = not self.stepFlip + end + return + end + if self.moving then + -- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674); + -- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226). + if self.progress == 0 and map then + local grass = NPC.grassAt(map, self.targetX, self.targetY) + if self.inGrass then self.inGrass = grass end + self.grassShake = grass or nil + end + self.progress = self.progress + 1 + -- Toward the TARGET, not one cell along stepDir: a follower's ledge hop is + -- a two-cell move over one step (src/world/gen2/Player.lua:150 does the + -- same), and `stepFrames` is what lets it keep pace with a bike. + local frames = self.stepFrames or STEP_FRAMES + local moved = math.floor(self.progress * 16 / frames) + local dx = (self.targetX or self.cellX) - self.cellX + local dy = (self.targetY or self.cellY) - self.cellY + self.px = self.cellX * 16 + dx * moved + self.py = self.cellY * 16 + dy * moved + if self.progress >= frames then + self.cellX, self.cellY = self.targetX, self.targetY + self.targetX, self.targetY = nil, nil + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.moving = false + self.stepDir = nil + self.stepFlip = not self.stepFlip + -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags at the step's end + -- (map_objects.asm:196-208, :247). + if map then + self.inGrass = NPC.grassAt(map, self.cellX, self.cellY) + end + end + return + end + + if self.frozen or self.kind == "stand" then return end + + self.timer = self.timer - 1 + if self.timer > 0 then return end + + if self.kind == "spin" then + self.timer = rand(self.spinLo or 60, self.spinHi or 180) + self.facing = self.roamDirs[rand(1, #self.roamDirs)] + return + end + + -- SPINCLOCKWISE / SPINCOUNTERCLOCKWISE: one quarter turn in a FIXED order + -- every sixteen frames, never a re-roll (see SPIN_NEXT). Route 32's + -- Youngster Gordon, Route 35's Firebreather Walt, RadioTower4F's GruntM10 and + -- the Route 40/41 swimmers all carry one of these two rows and used to fall + -- through to "stand", so none of them turned at all. + if self.kind == "turn" then + self.timer = self.spinLo or SPIN_TURN_FRAMES + self.facing = self.roamDirs[self.facing] or self.facing + return + end + + -- walk + self.timer = rand(30, 180) + local dir = self.roamDirs[rand(1, #self.roamDirs)] + self.facing = dir + if randf() < 0.5 then return end -- sometimes just turn, like Gen 1 + local d = Map.DELTA[dir] + local tx, ty = self.cellX + d[1], self.cellY + d[2] + local allowed, why = wanderVerdict(self, map, entities, tx, ty) + -- movement.collision serves every mover, not just the player: Gen 1 runs the + -- NPC wander through the same src/world/Collision.lua canMove the player + -- uses, so a mod that widens or narrows movement sees both here too. Guarded + -- like the player's site; a mod-free boot pays one table lookup. The two + -- extra reasons ("radius", "warp") are Gen 2's own refusals -- an object_event + -- may not leave its radius and never walks out through a door -- and are + -- additions to Gen 1's bounds / tile / entity, never renames of them. + if Runtime.wantsHook("movement.collision") then + local ctx = { map = map, mover = self, dir = dir, + fromX = self.cellX, fromY = self.cellY, + toX = tx, toY = ty, reason = why } + allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) + end + if not allowed then return end + self.targetX, self.targetY = tx, ty + self.moving = true + self.progress = 0 +end + +-- FacingBigDollSymmetric (data/sprites/facings.asm): sixteen OAM entries over a +-- 32x32 square, and the right half is the left half X-FLIPPED -- the sheet only +-- carries the eight tiles of one side. Those eight are the sheet's first two +-- 16x16 frames stacked, so the doll is frame 0 over frame 1, mirrored across +-- the middle. Drawn from the sprite's resolved (palette-baked) image so it +-- wears the same OBJ palette every other Gen 2 sprite does. +-- +-- The 4px lift is the one SpriteRenderer:draw applies to every overworld +-- sprite; the object's own cell is the doll's top left, so the square lands on +-- the 2x2 blob NPC:covers describes. +function NPC:drawBig() + local image = self.sprite.resolveImage and self.sprite:resolveImage() + if not image then return end + local G = love.graphics + local x, y = math.floor(self.px), math.floor(self.py) - 4 + for half = 0, 1 do + local quad = self.sprite.frames and self.sprite.frames[half] + if quad then + G.draw(image, quad, x, y + half * 16) + G.draw(image, quad, x + 32, y + half * 16, 0, -1, 1) + end + end +end + +-- FacingBigDollAsymmetric (data/sprites/facings.asm), transcribed as its own +-- `db y, x, attributes, tile index` rows: fourteen 8x8 tiles over the same +-- 32x32 square, with the lower left two cells left empty and two tiles reused +-- X-flipped. A doll with no mirror line (SPRITE_BIG_ONIX) cannot be drawn by +-- doubling one half the way drawBig does. +local BIG_DOLL_ASYM = { + { 0, 0, false, 0x00 }, + { 0, 8, false, 0x01 }, + { 8, 0, false, 0x04 }, + { 8, 8, false, 0x05 }, + { 16, 8, false, 0x07 }, + { 24, 8, false, 0x0a }, + { 0, 24, false, 0x03 }, + { 0, 16, false, 0x02 }, + { 8, 24, true, 0x02 }, + { 8, 16, false, 0x06 }, + { 16, 24, false, 0x09 }, + { 16, 16, false, 0x08 }, + { 24, 24, true, 0x04 }, + { 24, 16, false, 0x0b }, +} + +NPC.BIG_DOLL_ASYM = BIG_DOLL_ASYM + +-- A tile index is four to a 16x16 sheet frame, row major, the way +-- FacingStepDown0's $00..$03 read off the standing-down frame -- so tile t +-- sits at ((t % 2) * 8, (t // 4) * 16 + ((t % 4) // 2) * 8) in the sheet. +function NPC.bigDollTileRect(tile) + return (tile % 2) * 8, + math.floor(tile / 4) * 16 + math.floor((tile % 4) / 2) * 8 +end + +function NPC:bigDollQuads(image) + if self.bigDollQuadCache then return self.bigDollQuadCache end + local iw, ih = image:getDimensions() + local quads = {} + for tile = 0, 11 do + local sx, sy = NPC.bigDollTileRect(tile) + quads[tile] = love.graphics.newQuad(sx, sy, 8, 8, iw, ih) + end + self.bigDollQuadCache = quads + return quads +end + +function NPC:drawBigAsym() + local image = self.sprite.resolveImage and self.sprite:resolveImage() + if not image then return end + local G = love.graphics + local x, y = math.floor(self.px), math.floor(self.py) - 4 + local quads = self:bigDollQuads(image) + for _, row in ipairs(BIG_DOLL_ASYM) do + local quad = quads[row[4]] + if quad then + if row[3] then + G.draw(image, quad, x + row[2] + 8, y + row[1], 0, -1, 1) + else + G.draw(image, quad, x + row[2], y + row[1]) + end + end + end +end + +function NPC:draw(ox, oy, scale) + -- Gen 1 spells this draw(camX, camY) and SpriteRenderer subtracts them + -- (src/world/NPC.lua:129). Two arguments means that call, not a missing + -- scale: G.scale(nil, nil) would either raise or draw unscaled at an + -- offset, which is the silent wrong answer. + if scale == nil then return self:draw(-(ox or 0), -(oy or 0), 1) end + local G = love.graphics + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + -- OBJECT_SPRITE_Y_OFFSET is added to the OBJ's y when it is written to OAM, + -- so it moves the sprite without moving the object off its tile. + local yOffset = self.spriteYOffset or 0 + if self.bigObject then + G.push() + G.translate(0, yOffset) + if self.bigFacing == "asym" then self:drawBigAsym() else self:drawBig() end + G.pop() + elseif self.treeShake then + -- SetFacingWeirdTree cycles four quarters off FacingWeirdTree0-3 + -- (data/sprites/facings.asm:46-52, :185-190, :192-197): quarters 0 and 2 + -- are FacingStepDown0's tiles $00-$03, quarter 1 is $04-$07 (the "up" + -- frame's tiles) and quarter 3 is that same frame mirrored. So the tree + -- rocks right, upright, left, upright rather than turning to face. + local q = Movement.treeShakeIndex(self.treeShake.frame) + local facing = (q == 1 or q == 3) and "up" or "down" + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, + facing, 0, false, false, q == 3) + else + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, + self.facing, self:walkPhase(), self.stepFlip) + end + G.pop() +end + +NPC.MOVE = MOVE +NPC.patternFor = patternFor + +return NPC diff --git a/src/world/gen2/Palettes.lua b/src/world/gen2/Palettes.lua new file mode 100644 index 00000000..74d30fa9 --- /dev/null +++ b/src/world/gen2/Palettes.lua @@ -0,0 +1,275 @@ +-- Gen 2 GBC palette resolution: which four colors each tile, sprite and pic +-- draws with right now. Pure table math over data/generated/palettes.lua -- +-- no love calls -- so tests and tools can ask the same questions the renderer +-- does. src/render/GbcPalette.lua turns the answers into draw calls. +-- +-- Ported from engine/gfx/color.asm LoadMapPals and +-- engine/tilesets/timeofday_pals.asm ReplaceTimeOfDayPals: +-- +-- real clock hour -> wTimeOfDay (GetTimeOfDay, engine/rtc/rtc.asm) +-- + map header -> wMapTimeOfDay (PALETTE_* override) +-- = wTimeOfDayPal -> the daytime whose colors actually load +-- +-- and then, for that daytime: +-- +-- EnvironmentColorsPointers[environment][daytime] -> 8 TilesetBGPalette ids +-- RoofPals[mapGroup] -> PAL_BG_ROOF colors 1-2 +-- MapObjectPals[daytime] -> the 8 OBJ palettes +-- +-- A tile's slot within that 8-palette set comes from its tileset's PalMap +-- (tilesets.lua `tilePalettes`, 1-based); a sprite's comes from +-- sprites.lua `paletteId` (PAL_OW_*). + +local Palettes = {} + +-- wTimeOfDay order (the GetTimePalette jumptable): MORN_F, DAY_F, NITE_F, +-- DARKNESS_F. bg_tiles.pal, npc_sprites.pal and every environment_colors row +-- are laid out in this same order, which is why one index works for all three. +Palettes.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +Palettes.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + +-- engine/rtc/rtc.asm TimesOfDay: 0400-0959 morn, 1000-1759 day, 1800-0359 +-- nite. The table is a run of "hour < N -> this daytime" rows, so the last +-- row wrapping back to NITE is what makes midnight-to-4am night. +local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 + +-- PAL_OW_* (constants/sprite_data_constants.asm), 1-based for Lua indexing. +Palettes.OW_PALETTE_ID = { + PAL_OW_RED = 1, PAL_OW_BLUE = 2, PAL_OW_GREEN = 3, PAL_OW_BROWN = 4, + PAL_OW_PINK = 5, PAL_OW_EMOTE = 6, PAL_OW_TREE = 7, PAL_OW_ROCK = 8, +} + +-- Roofs are only recolored outdoors; LoadMapPals returns early for anything +-- that is not TOWN or ROUTE, so an indoor map keeps the pool's roof palette. +local ROOF_ENVIRONMENTS = { TOWN = true, ROUTE = true } + +-- ReplaceTimeOfDayPals.BrightnessLevels, read out as a plain lookup: +-- the map's PALETTE_* either follows the clock (AUTO) or pins one daytime. +local FORCED_DAYTIME = { + PALETTE_AUTO = nil, + PALETTE_DAY = "DAY", + PALETTE_NITE = "NITE", + PALETTE_MORN = "MORN", + PALETTE_DARK = "DARK", +} + +-- White and black bracket the two colors a mon/trainer pic actually ships +-- (data/pokemon/palettes.asm: "only the middle two colors are included"). +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +-- The daytime the game clock is in, ignoring any map override. +-- +-- `hour` is hHours, the value UpdateTime leaves after FixTime has added the +-- save's wStartHour base -- World:hour, or the World.clockHour pin a driver +-- sets. Every caller that can reach a world or a save must pass it: the +-- no-argument fallback is the raw host clock, which is the cart's RTC WITHOUT +-- the base, so a save whose owner answered Oak reads a different hour there +-- than the rest of the game does. +function Palettes.clockDaytime(hour) + if not hour then + hour = tonumber(os.date("%H")) or 12 + end + hour = math.floor(hour) % 24 + if hour < MORN_HOUR then return "NITE" end + if hour < DAY_HOUR then return "MORN" end + if hour < NITE_HOUR then return "DAY" end + return "NITE" +end + +-- The daytime whose colors load on this map: the clock, unless the map header +-- pins one. PALETTE_DARK maps are pitch black until FLASH is used, at which +-- point they read as night (ReplaceTimeOfDayPals.UsedFlash). +function Palettes.daytimeFor(mapDef, hour, flashUsed) + local clock = Palettes.clockDaytime(hour) + local forced = mapDef and FORCED_DAYTIME[mapDef.palette] + if forced == "DARK" then + return flashUsed and "NITE" or "DARK" + end + return forced or clock +end + +-- True when this map is lit by DARKNESS_PALSET right now, i.e. it is a +-- PALETTE_DARK map and FLASH has not been used. That is the exact condition +-- FlashFunction.CheckUseFlash tests (`ld a, [wTimeOfDayPalset] / cp +-- DARKNESS_PALSET`), so FLASH is allowed here and refused everywhere else -- +-- including inside a cave that is merely PALETTE_NITE, like Union Cave. +function Palettes.isDarkness(mapDef, hour, flashUsed) + return Palettes.daytimeFor(mapDef, hour, flashUsed) == "DARK" +end + +-- There is no vision mask in Gen 2. A dark map is dark because the `dark` +-- rows of gfx/tilesets/bg_tiles.pal are colors 1-3 black on a color 0 of +-- RGB 01,01,02, and every environment's dark row ($18-$1f) points at them -- +-- so the whole screen resolves to near-black through the ordinary bake, with +-- no second pass and nothing masked out. The one exception is what actually +-- makes a dark cave navigable: +-- +-- FlickeringCaveEntrancePalette (engine/tilesets/tileset_anims.asm) runs every +-- VBlank while wTimeOfDayPalset is DARKNESS_PALSET and rewrites PAL_BG_YELLOW +-- color 0 from either its own color 0 or its color 1, picked by bit 1 of +-- hVBlankCounter. In the dark row those two are RGB 30,30,11 and black, so +-- the cave entrance blinks yellow-black-yellow on a four-frame cycle while +-- everything else stays flat. That blink is the "where is the way out" +-- signal, and it is the only light in the map. +Palettes.PAL_BG_YELLOW = 5 -- 1-based slot, PAL_BG_YELLOW is $04 +-- `and %10`: two frames on, two frames off. +Palettes.FLICKER_PERIOD = 4 + +-- Which of PAL_BG_YELLOW's own colors is copied into its color 0 this frame. +-- 1 is "leave color 0 alone", 2 is "use color 1", both 1-based. +function Palettes.caveFlickerSource(frame) + return (math.floor((frame or 0) / 2) % 2 == 1) and 2 or 1 +end + +-- A copy of `set` with that copy already made. `sourceIndex` is 1 or 2, the +-- value caveFlickerSource returns. Only the yellow slot is rebuilt, so the +-- other seven palettes stay shared with the caller's set. +function Palettes.withCaveFlicker(set, sourceIndex) + if not set then return set end + local slot = set[Palettes.PAL_BG_YELLOW] + if not slot then return set end + local source = slot[sourceIndex == 2 and 2 or 1] + if not source then return set end + local out = {} + for i = 1, 8 do out[i] = set[i] end + local yellow = {} + for i = 1, 4 do + local c = slot[i] + yellow[i] = c and { c[1], c[2], c[3] } or nil + end + yellow[1] = { source[1], source[2], source[3] } + out[Palettes.PAL_BG_YELLOW] = yellow + return out +end + +-- The eight BG palettes loaded for this map, each { {r,g,b} x4 }, with the +-- roof override already folded in. Index with a tileset's tilePalettes value. +function Palettes.bgSet(data, mapDef, daytime) + if not (data and data.bg and data.environments) then return nil end + local env = mapDef and mapDef.environment + local row = env and data.environments[env] + -- ENVIRONMENT_5 and friends fall back to the outdoor table, same as the + -- pointer table's duplicate entries do. + row = row or data.environments.TOWN + if not row then return nil end + local indices = row[daytime] or row.DAY + if not indices then return nil end + + local set = {} + for slot = 1, 8 do + local pool = data.bg[indices[slot]] + -- Copy: the roof override below mutates one slot, and the pool entry is + -- shared by every map that picks it. + local colors = {} + for i = 1, 4 do + local c = pool and pool[i] or BLACK + colors[i] = { c[1], c[2], c[3] } + end + set[slot] = colors + end + + local roofSlot = data.roofSlot or 7 + if mapDef and ROOF_ENVIRONMENTS[env] and data.roofs then + local roof = data.roofs[mapDef.group] + if roof then + -- Colors 1 and 2 only; the pool keeps the roof palette's 0 and 3. + local pair = (daytime == "MORN" or daytime == "DAY") + and roof.mornDay or roof.nite + if pair and pair[1] and pair[2] then + set[roofSlot][2] = { pair[1][1], pair[1][2], pair[1][3] } + set[roofSlot][3] = { pair[2][1], pair[2][2], pair[2][3] } + end + end + end + return set +end + +-- The eight OBJ palettes for OW sprites at this time of day. +function Palettes.objectSet(data, daytime) + if not (data and data.objects) then return nil end + return data.objects[daytime] or data.objects.DAY +end + +-- The object_event palette byte's own OBJ palette slot, or nil for "use the +-- sprite's default". AddMapObject's tail (engine/overworld/player_object.asm +-- :187-194) is `ld hl, MAPOBJECT_PALETTE / add hl, bc / ld a, [hl] / and +-- MAPOBJECT_PALETTE_MASK / jr z, .skip_color_override / swap a / and +-- OAM_PALETTE`: a NON-ZERO field wins over whatever GetSpritePalette answered, +-- and only its low three bits reach OAM. +-- +-- The PAL_NPC_* block is `const_def 1 << 3` over the same eight names as +-- PAL_OW_* (constants/sprite_data_constants.asm:15-38) -- bit 3 is nothing but +-- the "not the default" marker, which is why `and OAM_PALETTE` drops it and +-- PAL_NPC_BLUE lands on the same colors as PAL_OW_BLUE. The extractor stores +-- the field already unswapped, as the plain constant. +function Palettes.objectPaletteId(objDef) + local p = objDef and objDef.palette + if not p or p == 0 then return nil end + return p % 8 +end + +-- A sprite definition's OBJ palette (sprites.lua stores both the PAL_OW_* +-- name and the raw id; either resolves), unless the object_event standing on +-- the map overrode it -- see Palettes.objectPaletteId. The override is what +-- makes the three legendary beasts three different animals: BurnedTowerB1F's +-- Raikou, Entei and Suicune are all SPRITE_GROWLITHE, whose own palette is +-- PAL_OW_RED, and only PAL_NPC_BROWN / PAL_NPC_RED / PAL_NPC_BLUE on the three +-- object_events tell them apart (maps/BurnedTowerB1F.asm:152-154). +function Palettes.spritePalette(data, daytime, spriteDef, objDef) + local set = Palettes.objectSet(data, daytime) + if not set then return nil end + local id = Palettes.objectPaletteId(objDef) + if not id then + id = spriteDef and spriteDef.paletteId + if not id then + local name = spriteDef and spriteDef.palette + id = name and (Palettes.OW_PALETTE_ID[name] or + Palettes.OW_PALETTE_ID["PAL_OW_" .. tostring(name)]) + id = id and id - 1 or 0 + end + end + return set[(id or 0) + 1] or set[1] +end + +-- A battle pic's four colors: white, the two shipped middle colors, black. +function Palettes.monColors(data, speciesId, shiny) + local entry = data and data.pokemon and data.pokemon[speciesId] + if not entry then return nil end + local pair = (shiny and entry.shiny) or entry.normal + if not (pair and pair[1] and pair[2]) then return nil end + return { + { WHITE[1], WHITE[2], WHITE[3] }, + { pair[1][1], pair[1][2], pair[1][3] }, + { pair[2][1], pair[2][2], pair[2][3] }, + { BLACK[1], BLACK[2], BLACK[3] }, + } +end + +-- Trainer pics work the same way; row 0 is PLAYER (Chris shares Cal's colors). +function Palettes.trainerColors(data, className) + local pair = data and data.trainers and data.trainers[className or "PLAYER"] + if not (pair and pair[1] and pair[2]) then return nil end + return { + { WHITE[1], WHITE[2], WHITE[3] }, + { pair[1][1], pair[1][2], pair[1][3] }, + { pair[2][1], pair[2][2], pair[2][3] }, + { BLACK[1], BLACK[2], BLACK[3] }, + } +end + +-- PAL_BG_TEXT is white/white/white/black in every set, which is what makes +-- text boxes readable at night; menus and the naming screen draw with it. +function Palettes.textColors(data) + if not (data and data.bg) then return nil end + local pool = data.bg[8] -- $07, the morn "text" row + if not pool then return nil end + return { + { pool[1][1], pool[1][2], pool[1][3] }, + { pool[2][1], pool[2][2], pool[2][3] }, + { pool[3][1], pool[3][2], pool[3][3] }, + { pool[4][1], pool[4][2], pool[4][3] }, + } +end + +return Palettes diff --git a/src/world/gen2/Permissions.lua b/src/world/gen2/Permissions.lua new file mode 100644 index 00000000..22b79a16 --- /dev/null +++ b/src/world/gen2/Permissions.lua @@ -0,0 +1,348 @@ +-- Gen 2 COLL_* → permission (pokegold CollisionPermissionTable lo-nybble, +-- home/map_objects.asm GetTilePermission). LAND=0, WATER=1, WALL=0x0f. + +local Permissions = {} + +Permissions.LAND = 0x00 +Permissions.WATER = 0x01 +Permissions.WALL = 0x0f + +-- CollisionPermissionTable, lo nybble only (256 entries). +local TABLE = { + 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, + 0, 0, 15, 0, 0, 15, 0, 0, 0, 0, 15, 0, 0, 15, 0, 0, + 1, 1, 1, 0, 1, 1, 1, 15, 1, 1, 1, 0, 1, 1, 1, 15, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 15, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 15, 0, 0, 0, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, +} + +function Permissions.of(coll) + if coll == nil or coll < 0 then return Permissions.WALL end + return TABLE[(coll % 256) + 1] or Permissions.WALL +end + +function Permissions.isLand(coll) + return Permissions.of(coll) == Permissions.LAND +end + +function Permissions.isWater(coll) + return Permissions.of(coll) == Permissions.WATER +end + +function Permissions.isWall(coll) + return Permissions.of(coll) == Permissions.WALL +end + +-- Walkable on foot: DoPlayerMovement's .CheckWalkable, which is nothing more +-- than "the permission is LAND_TILE". +function Permissions.isWalkable(coll) + return Permissions.of(coll) == Permissions.LAND +end + +-- .CheckSurfable (engine/overworld/player_movement.asm): the same test a +-- surfing player's step runs, and the reason it is three-valued rather than a +-- yes/no is that the LAND answer is what ends the surf. +-- +-- "water" the permission is WATER_TILE, keep surfing +-- "land" the permission is LAND_TILE, this step is .ExitWater +-- nil neither, the step bumps +function Permissions.surfable(coll) + local perm = Permissions.of(coll) + if perm == Permissions.WATER then return "water" end + if perm == Permissions.LAND then return "land" end + return nil +end + +-- Tall / long grass, the collisions a step can roll a wild encounter on +-- (constants/collision_constants.asm; the _10 and _1C aliases are unused on +-- the cart but land in the same permission). +local GRASS = { + [0x10] = true, -- COLL_TALL_GRASS_10 (unused) + [0x14] = true, -- COLL_LONG_GRASS + [0x18] = true, -- COLL_TALL_GRASS + [0x1c] = true, -- COLL_LONG_GRASS_1C (unused) +} + +function Permissions.isGrass(coll) + if coll == nil then return false end + return GRASS[coll % 256] == true +end + +-- CheckSuperTallGrassTile (home/map_objects.asm): the LONG grass pair only, not +-- the tall grass one. It is what doubles the Bug Contest encounter rate (40 +-- percent against 20) and what makes the grass rustle animation play. +local SUPER_TALL_GRASS = { + [0x14] = true, -- COLL_LONG_GRASS + [0x1c] = true, -- COLL_LONG_GRASS_1C (unused) +} + +function Permissions.isSuperTallGrass(coll) + if coll == nil then return false end + return SUPER_TALL_GRASS[coll % 256] == true +end + +-- CheckGrassCollision (engine/overworld/tile_events.asm) -- NOT the same list +-- as GRASS above, and the difference is load bearing: +-- +-- * COLL_WATER is in it, which is what lets a surfing step roll an +-- encounter at all (CanEncounterWildMon runs this, then +-- ChooseWildEncounter picks the water table off CheckOnWater); +-- * the unused $10 / $1c grass aliases are NOT in it, so a tile the graphics +-- call tall grass but the array does not is a free step. +-- +-- The garbage rows ($08, $28, $48-$4c) are in the cart's array verbatim and +-- are kept here for the same reason: a romhack tileset that uses one gets the +-- cart's behaviour rather than the tidy one. +local ENCOUNTER_COLLISION = { + [0x08] = true, -- COLL_CUT_08 + [0x18] = true, -- COLL_TALL_GRASS + [0x14] = true, -- COLL_LONG_GRASS + [0x28] = true, -- COLL_CUT_28 + [0x29] = true, -- COLL_WATER + [0x48] = true, -- COLL_GRASS_48 + [0x49] = true, -- COLL_GRASS_49 + [0x4a] = true, -- COLL_GRASS_4A + [0x4b] = true, -- COLL_GRASS_4B + [0x4c] = true, -- COLL_GRASS_4C +} + +function Permissions.isEncounterCollision(coll) + if coll == nil then return false end + return ENCOUNTER_COLLISION[coll % 256] == true +end + +-- The single-collision predicates out of home/map_objects.asm. Every one of +-- them is a `cp COLL_x / ret z` pair over a real constant and its unused +-- alias, so they are pairs here too. +local ICE = { [0x23] = true, [0x2b] = true } -- CheckIceTile +local WHIRLPOOL = { [0x24] = true, [0x2c] = true } -- CheckWhirlpoolTile +local CUT_TREE = { [0x12] = true, [0x1a] = true } -- CheckCutTreeTile +local HEADBUTT_TREE = { [0x15] = true, [0x1d] = true } -- CheckHeadbuttTreeTile +-- CheckWaterfallTile pairs COLL_WATERFALL with COLL_CURRENT_DOWN, not with a +-- $3x alias of itself: the downward current is the tile at the TOP of a +-- waterfall, and the climb has to keep going while the player is on one. +local WATERFALL = { [0x33] = true, [0x3b] = true } +-- CheckCounterTile. The counter is not walkable, so it never shows up in a +-- movement test -- its whole job is in CheckFacingObject, which DOUBLES the +-- reach of an A press over one so the player can talk to the nurse or the +-- clerk standing on the far side. See World:interact. +local COUNTER = { [0x90] = true, [0x98] = true } + +local function member(set, coll) + if coll == nil or coll < 0 then return false end + return set[coll % 256] == true +end + +function Permissions.isIce(coll) return member(ICE, coll) end +function Permissions.isWhirlpool(coll) return member(WHIRLPOOL, coll) end +function Permissions.isCutTree(coll) return member(CUT_TREE, coll) end +function Permissions.isHeadbuttTree(coll) return member(HEADBUTT_TREE, coll) end +function Permissions.isWaterfall(coll) return member(WATERFALL, coll) end +function Permissions.isCounter(coll) return member(COUNTER, coll) end + +-- DoPlayerMovement's .CheckTile, HI_NYBBLE_CURRENT arm +-- (engine/overworld/player_movement.asm). Every $3x collision is a CURRENT +-- tile, and the direction it forces is its LOW TWO BITS indexed into +-- .water_table (`maskbits NUM_DIRECTIONS`), so COLL_WATERFALL $33 and +-- COLL_CURRENT_DOWN $3b both come out DOWN. +-- +-- The arm runs ABOVE .CheckTurning and .TryStep and returns +-- PLAYERMOVEMENT_CONTINUE, which is the whole mechanic: a current OVERRIDES +-- the d-pad rather than being refused by it. That is what makes the plunge +-- down a waterfall automatic and what makes a waterfall column unclimbable by +-- walking -- HM07's scripted climb (World:runWaterfall) moves the player with +-- its own steps, and it runs under World:busy where this never gets a look in. +-- +-- COLL_WHIRLPOOL is tested before the nybble and takes PLAYERMOVEMENT_FORCE_TURN +-- instead, so it is not one of these. +local CURRENT_DIR = { [0] = "right", "left", "up", "down" } + +function Permissions.currentDirection(coll) + if coll == nil or coll < 0 then return nil end + coll = coll % 256 + if coll - (coll % 16) ~= 0x30 then return nil end + return CURRENT_DIR[coll % 4] +end + +-- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is +-- allowed to swing at. Both grasses are in it, which is why CUT mows a patch +-- of tall grass down to bare ground and not only trees. +local CUTTABLE = { + [0x12] = true, -- COLL_CUT_TREE + [0x1a] = true, -- COLL_CUT_TREE_1A + [0x10] = true, -- COLL_TALL_GRASS_10 + [0x18] = true, -- COLL_TALL_GRASS + [0x14] = true, -- COLL_LONG_GRASS + [0x1c] = true, -- COLL_LONG_GRASS_1C +} + +function Permissions.isCuttable(coll) return member(CUTTABLE, coll) end + +-- Ledges (HI_NYBBLE_LEDGES, $a0-$a7) and the facings that hop them. +-- +-- `.TryJump` (engine/overworld/player_movement.asm) runs after `.TryStep` +-- fails: standing ON a ledge tile, facing a direction its `.ledge_table` row +-- allows, the refused step becomes a STEP_LEDGE -- a two-cell jump. The tile +-- itself is LAND in the permission table (walk onto it from any side); the +-- one-way-ness comes from the tile past it being refused, which on every cart +-- map it is (a wall or a side-wall tile). Gold's direction order differs from +-- Crystal's: $a0 is HOP_RIGHT here, not HOP_DOWN. +local LEDGE_FACINGS = { + [0x0] = { right = true }, -- COLL_HOP_RIGHT + [0x1] = { left = true }, -- COLL_HOP_LEFT + [0x2] = { up = true }, -- COLL_HOP_UP (unused) + [0x3] = { down = true }, -- COLL_HOP_DOWN + [0x4] = { down = true, right = true }, -- COLL_HOP_DOWN_RIGHT + [0x5] = { down = true, left = true }, -- COLL_HOP_DOWN_LEFT + [0x6] = { up = true, right = true }, -- COLL_HOP_UP_RIGHT (unused) + [0x7] = { up = true, left = true }, -- COLL_HOP_UP_LEFT (unused) +} + +function Permissions.isLedge(coll) + if coll == nil or coll < 0 then return false end + return math.floor((coll % 256) / 16) == 0xa +end + +-- The facings that jump this ledge, or nil for a non-ledge. +function Permissions.ledgeFacings(coll) + if not Permissions.isLedge(coll) then return nil end + return LEDGE_FACINGS[coll % 8] +end + +-- One-way walls (HI_NYBBLE_SIDE_WALLS $b0, HI_NYBBLE_SIDE_BUOYS $c0). +-- +-- GetMovementPermissions (home/map.asm) builds wTilePermissions from two +-- sources, and `.CheckLandPerms` / `.CheckSurfPerms` refuse a step whose +-- facing bit is set: +-- +-- * the STANDING tile: `.MovementPermissionsData[coll & 7]`. The stored +-- masks are DOWN/UP/LEFT/RIGHT_MASK (1/2/4/8) but they are compared +-- against FACE_* bits (FACE_DOWN=8, FACE_UP=4, FACE_LEFT=2, FACE_RIGHT=1), +-- so the row that reads DOWN_MASK blocks FACE_RIGHT -- which lands each +-- COLL_x_WALL on blocking exactly direction x. Standing on an UP_WALL, +-- you cannot leave upward. +-- * the four NEIGHBOUR tiles. Each arm matches its own wall kinds (below: +-- UP/UP_RIGHT/UP_LEFT; above: DOWN/DOWN_*; right: LEFT/*_LEFT; left: +-- RIGHT/*_RIGHT) -- but on GOLD all four `.ok_*` arms are `set RIGHT, [hl]` +-- (bit 3 = FACE_DOWN), so every match forbids stepping DOWN. Only +-- COLL_UP_WALL below the player occurs on real maps, where the quirk and +-- the intent agree: you cannot step DOWN onto an UP_WALL. The other three +-- arms are transcribed faithfully anyway. +local SIDE_BLOCKS = { + [0x0] = { right = true }, -- COLL_RIGHT_WALL / RIGHT_BUOY + [0x1] = { left = true }, -- COLL_LEFT_WALL / LEFT_BUOY + [0x2] = { up = true }, -- COLL_UP_WALL / UP_BUOY + [0x3] = { down = true }, -- COLL_DOWN_WALL (unused) + [0x4] = { down = true, right = true }, -- COLL_DOWN_RIGHT_WALL (unused) + [0x5] = { down = true, left = true }, -- COLL_DOWN_LEFT_WALL (unused) + [0x6] = { up = true, right = true }, -- COLL_UP_RIGHT_WALL (unused) + [0x7] = { up = true, left = true }, -- COLL_UP_LEFT_WALL (unused) +} + +function Permissions.isSideWall(coll) + if coll == nil or coll < 0 then return false end + local hi = math.floor((coll % 256) / 16) + return hi == 0xb or hi == 0xc +end + +-- Directions a player STANDING on this tile may not move, or nil. +function Permissions.sideBlocks(coll) + if not Permissions.isSideWall(coll) then return nil end + return SIDE_BLOCKS[coll % 8] +end + +-- The neighbour arms of GetMovementPermissions. `neighborDir` names where +-- the tile sits relative to the player ("down" = the tile below). A true +-- answer forbids the player's DOWN step -- the Gold `set RIGHT` quirk above -- +-- whichever arm matched. +local NEIGHBOR_ARM = { + down = { [0x2] = true, [0x6] = true, [0x7] = true }, -- UP_WALL kinds below + up = { [0x3] = true, [0x4] = true, [0x5] = true }, -- DOWN_WALL kinds above + right = { [0x1] = true, [0x5] = true, [0x7] = true }, -- LEFT_WALL kinds right + left = { [0x0] = true, [0x4] = true, [0x6] = true }, -- RIGHT_WALL kinds left +} + +function Permissions.neighborBlocksDown(neighborDir, coll) + if not Permissions.isSideWall(coll) then return false end + local arm = NEIGHBOR_ARM[neighborDir] + return (arm and arm[coll % 8]) == true +end + +-- The whole GetMovementPermissions verdict: may a step `dir` leave (cx, cy)? +-- `collOf(x, y)` answers the collision byte, so the same rule serves the live +-- Map, the bot's planner and the offline graph without three copies drifting. +local NEIGHBOR_DELTA = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + +function Permissions.stepPermitted(collOf, cx, cy, dir) + local standing = Permissions.sideBlocks(collOf(cx, cy)) + if standing and standing[dir] then return false end + if dir == "down" then + for nd, d in pairs(NEIGHBOR_DELTA) do + if Permissions.neighborBlocksDown(nd, collOf(cx + d[1], cy + d[2])) then + return false + end + end + end + return true +end + +function Permissions.isWarpCollision(coll) + if coll == nil or coll < 0 then return false end + -- COLL_PIT / COLL_PIT_68 plus high-nybble $7 (CheckWarpCollision / + -- HI_NYBBLE_WARPS in engine/overworld/tile_events.asm). + if coll == 0x60 or coll == 0x68 then return true end + return math.floor(coll / 16) == 7 +end + +-- Carpet warps need a press in their direction (CheckDirectionalWarp). +local CARPET_DIR = { + [0x70] = "down", -- COLL_WARP_CARPET_DOWN + [0x76] = "left", -- COLL_WARP_CARPET_LEFT + [0x78] = "up", -- COLL_WARP_CARPET_UP + [0x7e] = "right", -- COLL_WARP_CARPET_RIGHT +} + +function Permissions.carpetDirection(coll) + return CARPET_DIR[coll] +end + +-- CheckWarpFacingDown (engine/overworld/tile_events.asm). RefreshPlayerSprite +-- runs this against the tile the player ARRIVES on and calls SpawnInFacingDown +-- when it hits; every other arrival tile keeps the facing they walked in with. +-- The four `; unused` rows are transcribed rather than dropped -- the array is +-- what the cart tests against, and a tileset that ever emitted $73 would find +-- them. +local WARP_FACING_DOWN = { + [0x71] = true, -- COLL_DOOR + [0x79] = true, -- COLL_DOOR_79 (unused) + [0x7a] = true, -- COLL_STAIRCASE + [0x73] = true, -- COLL_STAIRCASE_73 (unused) + [0x7b] = true, -- COLL_CAVE + [0x74] = true, -- COLL_CAVE_74 (unused) + [0x7c] = true, -- COLL_WARP_PANEL + [0x75] = true, -- COLL_DOOR_75 (unused) + [0x7d] = true, -- COLL_DOOR_7D (unused) +} + +function Permissions.warpFacesDown(coll) return member(WARP_FACING_DOWN, coll) end + +-- Immediate warp on landing (doors, stairs, caves, panels) vs carpet. +function Permissions.isImmediateWarp(coll) + if not Permissions.isWarpCollision(coll) then return false end + return CARPET_DIR[coll] == nil +end + +return Permissions diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua new file mode 100644 index 00000000..43f3106a --- /dev/null +++ b/src/world/gen2/Player.lua @@ -0,0 +1,225 @@ +-- Minimal Gen 2 overworld player: tile-grid steps at 16 frames/cell. +-- Draws via shared SpriteRenderer (same 16x96 facing layout as Gen 1). + +local Map = require("src.world.gen2.Map") +local Runtime = require("src.mods.Runtime") +local SpriteRenderer = require("src.render.SpriteRenderer") + +local Player = {} +Player.__index = Player + +local STEP_FRAMES = 16 +local TURN_FRAMES = 4 + +-- The walking duration, exported so World can halve it for a bike step +-- (.DoStep's STEP_BIKE arm, engine/overworld/player_movement.asm). The leg +-- cadence below deliberately does NOT scale with it: animClock keeps counting +-- at the walking rate, which is what stops a bike step flickering the legs. +Player.STEP_FRAMES = STEP_FRAMES + +function Player.new(cx, cy, facing, spriteDef) + local self = setmetatable({ + cellX = cx, cellY = cy, + px = cx * 16, py = cy * 16, + facing = facing or "down", + moving = false, + progress = 0, + turnTimer = 0, + turnArmed = true, + stepFlip = false, + -- OBJECT_FLAGS2's IN_GRASS_F (engine/overworld/map_objects.asm:247). + inGrass = false, + animClock = 0, + -- Frames this cell takes; World rewrites it per step from the STEP_* the + -- player's state picks, and a step already under way keeps the one it + -- started with. + stepFrames = STEP_FRAMES, + sprite = nil, + spriteDef = spriteDef, + }, Player) + if spriteDef then + self.sprite = SpriteRenderer.new(spriteDef, "player") + end + return self +end + +function Player:setSprite(spriteDef) + if spriteDef then + self.spriteDef = spriteDef + self.sprite = SpriteRenderer.new(spriteDef, "player") + end +end + +-- the movement.collision chain sees the boolean; a wrapper that flips it +-- rewrites ctx.reason to say why (the engine's own reasons are bounds / tile / +-- entity, the same three src/world/Collision.lua names under Gen 1), so the +-- hook stays a single-value middleware. +local function passthrough(allowed) return allowed end + +-- The verdict on one step, hoisted so the hooked and unhooked paths cannot +-- drift. World:movePlayer has already vetoed the direction by handing us a +-- refusingMap when GetMovementPermissions says no, so a side-wall veto arrives +-- here as "tile" exactly like a wall does. +local function verdict(self, map, entities, tx, ty) + if not map:inBounds(tx, ty) then return false, "bounds" end + if not map:isWalkable(tx, ty) then return false, "tile" end + if entities then + for _, e in ipairs(entities) do + -- `passable` is the follower's escape, src/world/Collision.lua:20's + -- name and meaning: the player walks straight through it. + if e ~= self and not e.passable then + if e.cellX == tx and e.cellY == ty then return false, "entity" end + if e.moving and e.targetX == tx and e.targetY == ty then + return false, "entity" + end + end + end + end + return true +end + +function Player:tryMove(dir, map, entities) + if self.moving then return nil end + if self.facing ~= dir then + self.facing = dir + if self.turnArmed then + self.turnArmed = false + self.turnTimer = TURN_FRAMES + return "turned" + end + end + if self.turnTimer > 0 then return nil end + + local d = Map.DELTA[dir] + local tx, ty = self.cellX + d[1], self.cellY + d[2] + local allowed, why = verdict(self, map, entities, tx, ty) + -- Per-step hot path, guarded the way src/world/Collision.lua's canMove is: + -- with an empty chain this costs one table lookup and no ctx allocation. + if Runtime.wantsHook("movement.collision") then + local ctx = { map = map, mover = self, dir = dir, + fromX = self.cellX, fromY = self.cellY, + toX = tx, toY = ty, reason = why } + allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) + why = ctx.reason + end + if not allowed then + -- World:movePlayer tells the two refusals apart: "edge" is what asks the + -- connection table for the neighbouring map, "blocked" is a bump. + return why == "bounds" and "edge" or "blocked" + end + self.targetX, self.targetY = tx, ty + self.moving = true + self.progress = 0 + return "moved" +end + +-- Cutscene step: ignores collision so Elm walk-up / after-pick paths play. +function Player:scriptFace(dir) + if dir then self.facing = dir end +end + +function Player:scriptStep(dir) + if self.moving then return false end + -- A scripted step names its own STEP_* on the cart (SurfStartStep is a slow + -- step), so it never inherits the bike's shorter one. + self.stepFrames = STEP_FRAMES + self.facing = dir or self.facing + local d = Map.DELTA[self.facing] + if not d then return false end + self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2] + self.moving = true + self.progress = 0 + return true +end + +-- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that +-- wraps World:interact asks one question of either generation. +function Player:facingCell() + local d = Map.DELTA[self.facing] or Map.DELTA.down + return self.cellX + d[1], self.cellY + d[2] +end + +function Player:walkPhase() + if not self.moving then return 0 end + local p = self.animClock % STEP_FRAMES + return (p >= 4 and p < 12) and 1 or 0 +end + +function Player:update() + if self.turnTimer > 0 then + self.turnTimer = self.turnTimer - 1 + end + if not self.moving then + -- Re-arm turn-in-place once a poll finds no held direction (caller + -- clears this while a dir is held; we only set it from idle). + return false + end + self.progress = self.progress + 1 + self.animClock = self.animClock + 1 + -- Interpolate toward the TARGET cell rather than one cell along the facing: + -- a ledge hop (World:tryLedgeJump, the cart's STEP_LEDGE) is a two-cell move + -- and the facing-delta math walked only half of it, leaving the sprite a + -- cell behind where the grid said the player was. + local frames = self.stepFrames or STEP_FRAMES + local adv = math.floor(self.progress * 16 / frames) + local dx = (self.targetX or self.cellX) - self.cellX + local dy = (self.targetY or self.cellY) - self.cellY + self.px = self.cellX * 16 + dx * adv + self.py = self.cellY * 16 + dy * adv + if self.jumping then + -- The hop arc. Cosmetic: the grid position is the straight-line + -- interpolation above, only the drawn pixels rise. + self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames)) + end + if self.progress >= frames then + self.cellX, self.cellY = self.targetX, self.targetY + self.targetX, self.targetY = nil, nil + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.moving = false + self.jumping = nil + self.stepFlip = not self.stepFlip + return true + end + return false +end + +function Player:draw(ox, oy, scale) + local G = love.graphics + -- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so + -- it moves the sprite without moving the player off the tile they are + -- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off + -- lift both ride this one byte. + local yOffset = self.spriteYOffset or 0 + if self.sprite then + G.push() + G.translate(ox, oy) + G.scale(scale, scale) + -- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's + -- OBJ palette current. + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, + self.facing, self:walkPhase(), self.stepFlip) + G.pop() + return + end + -- Fallback rectangle if sprites.lua is missing from an old cache. + local x = ox + self.px * scale + local y = oy + (self.py + yOffset) * scale + local s = 16 * scale + G.setColor(0.95, 0.35, 0.25, 1) + G.rectangle("fill", x + s * 0.15, y + s * 0.1, s * 0.7, s * 0.85, 2, 2) + G.setColor(1, 0.9, 0.55, 1) + local notch = s * 0.22 + if self.facing == "up" then + G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.05, notch, notch) + elseif self.facing == "down" then + G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.7, notch, notch) + elseif self.facing == "left" then + G.rectangle("fill", x + s * 0.05, y + s * 0.4, notch, notch) + else + G.rectangle("fill", x + s * 0.75, y + s * 0.4, notch, notch) + end + G.setColor(1, 1, 1, 1) +end + +return Player diff --git a/src/world/gen2/StepEvents.lua b/src/world/gen2/StepEvents.lua new file mode 100644 index 00000000..765d36be --- /dev/null +++ b/src/world/gen2/StepEvents.lua @@ -0,0 +1,200 @@ +-- CountStep (engine/overworld/events.asm), the block that runs on every +-- overworld footfall, and the four routines it calls that had no port at all. +-- +-- This is the chain the world was missing entirely: `World` kept no step count, +-- so `Happiness.step` and `Breeding.step` were written and tested and nothing +-- ever called either. Eggs never hatched and scripted phone calls never fired, +-- both main quest. +-- +-- CheckTileEvent runs it between the coord events and the wild encounter roll, +-- and a CARRY out of it means a player event is queued -- which is why a step +-- that hatches an egg or drops a poisoned mon never also starts a battle. +-- +-- CountStep: +-- ret if wLinkMode ; not modelled: no link overworld +-- CheckSpecialPhoneCall -> c: .doscript +-- DoRepelStep -> c: .doscript +-- inc wPoisonStepCount +-- inc wStepCount -> z (the wrap): StepHappiness +-- wStepCount == $80 : DoEggStep -> nz: .hatch +-- DayCareStep +-- wPoisonStepCount >= 4 : reset, DoPoisonStep -> c: .doscript +-- DoBikeStep +-- +-- Everything here is love-free and takes its state as arguments so the whole +-- chain is testable without a world. +local Bike = require("src.world.gen2.Bike") +local Breeding = require("src.core.gen2.Breeding") +local Happiness = require("src.core.gen2.Happiness") +local Phone = require("src.core.gen2.Phone") + +local StepEvents = {} + +-- constants/pokemon_data_constants.asm: `1 << PSN`. The port stores a status +-- as a lowercase name on the mon; the battle writes "poison"/"toxic" +-- (Battle.STATUS_EFFECTS), older saves may carry "psn"/"tox". +local function isPoisoned(mon) + local status = mon and mon.status + return status == "psn" or status == "tox" or status == "poison" + or status == "toxic" +end + +-- .DamageMonIfPoisoned's two answers, kept as the cart's own bit pair so the +-- "someone fainted beats someone hurt" test below reads like `and %10`. +StepEvents.POISON_HURT = 1 +StepEvents.POISON_FAINTED = 2 + +-- Every 4 steps (wPoisonStepCount `cp 4 / jr c`). +StepEvents.POISON_PERIOD = 4 + +-- DoBikeStep's threshold is `cp HIGH(1024)` on the counter's HIGH byte, so it +-- is 1024 steps and the counter saturates at $ffff rather than wrapping. +StepEvents.BIKE_CALL_STEPS = 1024 +StepEvents.BIKE_STEP_MAX = 0xffff + +-- DoPoisonStep. One HP off every poisoned mon that is still standing, and the +-- mon that runs out has its status CLEARED on the way down -- so a party wiped +-- by poison walks into the Pokemon Center with no status left to cure. +-- +-- The two flags are collected across the WHOLE party before either branch is +-- taken (wPoisonStepFlagSum), which is why one faint anywhere in the party +-- outranks five mons merely taking damage. +function StepEvents.poisonStep(party) + party = party or {} + local hurt, fainted = {}, {} + for index, mon in ipairs(party) do + if isPoisoned(mon) and (mon.hp or 0) > 0 then + mon.hp = mon.hp - 1 + if mon.hp <= 0 then + mon.hp = 0 + mon.status = nil + fainted[#fainted + 1] = index + else + hurt[#hurt + 1] = index + end + end + end + if #fainted > 0 then + return { kind = "poisonFaint", fainted = fainted, hurt = hurt, blocks = true } + end + if #hurt > 0 then + -- .PlayPoisonSFX and the two-frame red flash, then `xor a`: no carry, so + -- the step still counts and the wild roll still happens. + return { kind = "poisonHurt", hurt = hurt, blocks = false } + end + return nil +end + +-- .CheckWhitedOut's tail: `predef CheckPlayerPartyForFitMon`, whose answer is +-- what decides between closing the text box and jumping to +-- OverworldWhiteoutScript. An egg is not a fit mon (DayCare_GiveEgg zeroes its +-- HP), which Breeding.healthyCount already says out loud. +function StepEvents.whitedOut(party) + return Breeding.healthyCount(party) == 0 +end + +-- DoRepelStep. `dec a / ret nz`: the wear-off lands on the step that takes the +-- counter to zero, and that step is NOT counted -- so the last repel step never +-- ticks the egg or the day care. +function StepEvents.repelStep(save) + local left = save.repelSteps or 0 + if left <= 0 then return false end + save.repelSteps = left - 1 + return save.repelSteps == 0 +end + +-- DoBikeStep. Four gates before the counter even moves, and then a quirk worth +-- keeping: `scf` at the end is thrown away by CountStep's `.done`, which does +-- `xor a / ret`. So queueing the bike shop's call does NOT stop the step being +-- counted and does NOT produce a player event -- the call goes out on the NEXT +-- footfall, through CheckSpecialPhoneCall at the top of this same block. +-- +-- wStatusFlags2's BIKE_SHOP_CALL bit is not a byte nobody else reads: the +-- Goldenrod bike shop clerk's own `setflag ENGINE_BIKE_SHOP_CALL_ENABLED` +-- (maps/GoldenrodBikeShop.asm) is what turns it on, and Vm's setflag lands +-- that on save.engineFlags under the ENGINE_* id. save.bikeShopCall is kept +-- as the fallback for a save written before that was wired up, and is cleared +-- alongside the flag so the two can never disagree. +local function bikeShopCallEnabled(save) + local flags = save.engineFlags + if type(flags) == "table" then + local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] + if set ~= nil then return set == true end + end + return save.bikeShopCall == true +end + +function StepEvents.bikeStep(save, opts) + opts = opts or {} + if not bikeShopCallEnabled(save) then return false end + if opts.playerState ~= "bike" then return false end + if opts.phoneService == false then return false end + local steps = math.min((save.bikeStep or 0) + 1, StepEvents.BIKE_STEP_MAX) + save.bikeStep = steps + if steps < StepEvents.BIKE_CALL_STEPS then return false end + -- "If a call has already been queued, don't overwrite that call." + if Phone.hasSpecialCall(save) then return false end + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP) + -- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever. + if type(save.engineFlags) == "table" then + save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil + end + save.bikeShopCall = false + return true +end + +-- The whole block, in the cart's order. +-- +-- `ctx` carries what the routines need from outside the save: `data` for the +-- day care's species lookups, `rng` for its egg roll, `phone` for +-- CheckSpecialPhoneCall's map/time context, `playerState` and `phoneService` +-- for DoBikeStep. +-- +-- Returns an event table or nil, plus whether the step was COUNTED. The +-- event's `blocks` field is CountStep's CARRY: the caller owes the matching +-- player-event script and must not roll a wild encounter on that step. Only +-- `poisonHurt` reports an event without one -- DoPoisonStep's .PlayPoisonSFX +-- arm ends `xor a`, so a party that merely takes damage still walks into grass. +function StepEvents.count(save, ctx) + ctx = ctx or {} + if type(save) ~= "table" then return nil, false end + if ctx.linkMode then return nil, false end + + -- Neither of the next two counts the step. + local call = Phone.checkSpecialCall(save, ctx.phone) + if call then return { kind = "phoneCall", call = call, blocks = true }, false end + if StepEvents.repelStep(save) then + return { kind = "repel", blocks = true }, false + end + + save.poisonStepCount = ((save.poisonStepCount or 0) + 1) % 256 + + -- Breeding.step owns wStepCount: it increments, ticks the eggs at $80 and + -- runs DayCareStep, all in the cart's order. StepHappiness sits between the + -- increment and the egg tick on the cart and is called after both here, which + -- is safe rather than sloppy: the wrap ($00) and the egg phase ($80) can + -- never be the same step, so the two never run on the same footfall at all. + local bred = Breeding.step(ctx.data, save, ctx.rng) + Happiness.step(save) + if bred == "hatch" then return { kind = "hatch", blocks = true }, true end + + if save.poisonStepCount >= StepEvents.POISON_PERIOD then + save.poisonStepCount = 0 + local poison = StepEvents.poisonStep(save.party) + if poison and poison.kind == "poisonFaint" then + poison.whiteout = StepEvents.whitedOut(save.party) + return poison, true + end + if poison then + -- .PlayPoisonSFX only: no carry, so the caller plays the sound and the + -- step carries on into the wild roll. + StepEvents.bikeStep(save, ctx) + return poison, true + end + end + + StepEvents.bikeStep(save, ctx) + return nil, true +end + +return StepEvents diff --git a/src/world/gen2/TrainerHouse.lua b/src/world/gen2/TrainerHouse.lua new file mode 100644 index 00000000..79968871 --- /dev/null +++ b/src/world/gen2/TrainerHouse.lua @@ -0,0 +1,122 @@ +-- The Viridian Trainer House: the one battle a day against CAL in the +-- basement's TRAINING HALL (maps/TrainerHouseB1F.asm). +-- +-- The conversation itself is script bytecode and stays in the cache: the +-- coord_event on the doorway cell runs TrainerHouseReceptionistScript, which +-- checks ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY, asks `special TrainerHouse` +-- whose opponent it is, walks the player into the room and starts the battle. +-- What that script needs from the port is the three compiled routines behind +-- it, and all three are about the SAME question: whether a Mystery Gift trade +-- has left a custom trainer in SRAM. +-- +-- TrainerHouse engine/events/specials.asm -- reads +-- sMysteryGiftTrainerHouseFlag into wScriptVar, so +-- the script picks CAL2 (the visitor) or CAL3 (the +-- house's own trainer). +-- ReadTrainerParty engine/battle/read_trainer_party.asm -- CAL2 is +-- the ONLY trainer in the game whose party does not +-- come from data/trainers/parties.asm. Its `.cal2` +-- arm reads sMysteryGiftTrainer as a +-- TRAINERTYPE_MOVES party instead. +-- GetTrainerName same file -- and CAL is the only class whose name +-- is not read from the parties table either: with +-- the flag set it is copied out of +-- sMysteryGiftPartnerName. +-- +-- MYSTERY GIFT IS OUT OF SCOPE (it is one of the six peripheral stubs in +-- src/script/gen2/Specials.lua: the trade rides the Game Boy's infrared port +-- into a second cartridge, and there is no second cartridge here). So the +-- flag is permanently clear, which is exactly the state of a cartridge that +-- has never been linked, and every one of the three routines above takes its +-- own no-custom-data arm. That fallback is what this module ports: +-- +-- * the script asks once before the walk-in and once at the battle, gets +-- FALSE both times, and fights CAL3 -- MEGANIUM, TYPHLOSION and FERALIGATR +-- at level 50, the strongest of the three CAL rows; +-- * a CAL2 lookup that reaches here anyway is answered with CAL3 rather than +-- with parties.asm's CAL (2) row, because that row is DEAD DATA on the +-- cart: `ReadTrainerParty` branches to SRAM before it ever indexes the +-- table, so handing back BAYLEEF/QUILAVA/CROCONAW at level 30 would be a +-- team no cartridge ever fields; +-- * the name is the parties table's own "CAL". +-- +-- The once-a-day gate is not here: ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is a +-- wDailyFlags1 bit like Kurt's, so the script's own setflag is the whole of +-- the write and src/core/gen2/Apricorns.lua's daily reset is the whole of the +-- clear. The id below is for readers and for the test that pins the pair. + +local Trainers = require("src.world.gen2.Trainers") + +local TrainerHouse = {} + +-- constants/trainer_constants.asm: the CAL class and its three members. CAL1 +-- is the Route 27 battle, CAL2 the Mystery Gift visitor, CAL3 the house's own. +TrainerHouse.CAL = 12 +TrainerHouse.CAL1, TrainerHouse.CAL2, TrainerHouse.CAL3 = 1, 2, 3 + +-- constants/engine_flags.asm index 86, wDailyFlags1 bit +-- DAILYFLAGS1_FOUGHT_IN_TRAINER_HALL_TODAY. Cleared by +-- Apricorns.dailyReset, which wipes both daily bytes whole. +TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY = 86 + +-- sMysteryGiftTrainerHouseFlag (ram/sram.asm), the byte a completed Mystery +-- Gift trade leaves behind. STUB, and a deliberate one: nothing in this port +-- can set it, because nothing in this port can run the infrared trade that +-- writes it (engine/link/mystery_gift.asm). Kept as a function rather than as +-- a constant `false` so the day Mystery Gift lands there is one place to teach +-- about save.mysteryGift, and so the two readers below cannot drift apart. +function TrainerHouse.hasCustomTrainer(save) + local gift = type(save) == "table" and save.mysteryGift or nil + return (gift and gift.trainerHouse) and true or false +end + +-- ReadTrainerParty's `cp CAL / cp CAL2` pair, as the question a caller with a +-- class and a member can ask: which member should actually be loaded. Only +-- CAL2 is ever redirected, and only when there is no custom trainer to redirect +-- it to -- with one in SRAM the cart reads the party out of SRAM and this +-- would have nothing to say about it either. +function TrainerHouse.resolveMember(save, class, member) + if class == TrainerHouse.CAL and member == TrainerHouse.CAL2 + and not TrainerHouse.hasCustomTrainer(save) then + return TrainerHouse.CAL3 + end + return member +end + +-- GetTrainerName's CAL arm. Returns the name the SRAM copy would have +-- supplied, or nil for "fall through to the parties table", which is what +-- `.not_cal2` does. nil rather than "CAL" on purpose: the caller already has +-- the table, and inventing the answer here would hide a lookup that failed. +function TrainerHouse.customName(save, class) + if class ~= TrainerHouse.CAL then return nil end + if not TrainerHouse.hasCustomTrainer(save) then return nil end + local gift = save and save.mysteryGift + return gift and gift.partnerName or nil +end + +-- The pair of lookups the World hands to the VM, with the CAL2 redirect +-- applied. `trainerData` is the cache's trainers table. +function TrainerHouse.lookup(trainerData, save, class, member) + return Trainers.lookup(trainerData, class, + TrainerHouse.resolveMember(save, class, member)) +end + +function TrainerHouse.name(trainerData, save, class, member) + local custom = TrainerHouse.customName(save, class) + if custom then return custom end + local entry = TrainerHouse.lookup(trainerData, save, class, member) + return entry and entry.name or nil +end + +-- The daily gate, for a reader holding nothing but a save file. The script +-- owns both sides of it in game (checkflag / setflag), so neither of these has +-- a call site in the engine: they exist so the test can state the rule, and so +-- a future rematch feature has one name for the bit rather than the number 86 +-- written out again. +function TrainerHouse.foughtToday(save) + local flags = type(save) == "table" and save.engineFlags or nil + return (flags and flags[TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY]) + == true +end + +return TrainerHouse diff --git a/src/world/gen2/Trainers.lua b/src/world/gen2/Trainers.lua new file mode 100644 index 00000000..98b9ee64 --- /dev/null +++ b/src/world/gen2/Trainers.lua @@ -0,0 +1,130 @@ +-- Gen 2 overworld trainers: the `trainer` struct an object_event points at +-- (macros/scripts/maps.asm), the eyesight test from home/trainers.asm, and the +-- party build that turns trainers.lua's level/species rows into battle mons. +-- +-- The extractor already has every class's members; this is the layer between +-- that table and a battle, so nothing here reads the ROM. + +local Mon = require("src.battle.gen2.Mon") +local Map = require("src.world.gen2.Map") + +local Trainers = {} + +-- constants/script_constants.asm +Trainers.TEXT_SEEN, Trainers.TEXT_WIN, Trainers.TEXT_LOSS = 0, 1, 2 +-- constants/misc_constants.asm +Trainers.RESET_FLAG, Trainers.SET_FLAG, Trainers.CHECK_FLAG = 0, 1, 2 + +-- trainers.lua keys classes by name; the `trainer` struct and `loadtrainer` +-- both carry the class's numeric constant, so index once per data table. +local function classIndex(trainerData) + if not (trainerData and trainerData.classes) then return {} end + local cache = rawget(trainerData, "_byIndex") + if cache then return cache end + cache = {} + for id, class in pairs(trainerData.classes) do + if type(class) == "table" and class.index then + cache[class.index] = class + class.id = class.id or id + end + end + rawset(trainerData, "_byIndex", cache) + return cache +end + +Trainers.classIndex = classIndex + +-- class constant + member number -> a flat record the battle screen can use. +-- `name` is the trainer's own name (JOEY), `className` the class's display +-- name (YOUNGSTER) -- the HUD wants "YOUNGSTER JOEY". +function Trainers.lookup(trainerData, class, member) + local entry = classIndex(trainerData)[class] + if not entry then return nil end + local row = entry.trainers and entry.trainers[member] + if not row then return nil end + return { + class = class, + classId = entry.id, + className = entry.name, + member = member, + id = row.id, + name = row.name, + trainerType = row.trainerType, + roster = row.party or {}, + attributes = entry.attributes, + -- TRNATTR_ITEM1/ITEM2: what AI_TryItem may reach for. A copy, so using + -- one up in a battle does not empty the class record for the next + -- trainer of that class. + items = (function() + local out = {} + for _, id in ipairs(entry.items or {}) do out[#out + 1] = id end + return out + end)(), + baseMoney = entry.baseMoney, + } +end + +-- Build the battle party. TRAINERTYPE_MOVES / _ITEM_MOVES rows carry an +-- explicit move list; the rest take whatever the species knows at that level, +-- which is what MakeTrainerPartyMon does via LearnLevelMoves. +function Trainers.party(data, entry) + local party = {} + for _, row in ipairs((entry and entry.roster) or {}) do + local moves = nil + if row.moves and #row.moves > 0 then + moves = {} + for _, id in ipairs(row.moves) do + local def = data and data.moves and data.moves[id] + moves[#moves + 1] = { id = id, pp = def and def.pp or 0, + maxPp = def and def.pp or 0 } + end + end + -- Trainer mons roll no DVs: the cart gives every one of them 9/8/8/8/8 + -- (wEnemyMonDVs is fixed in MakeTrainerPartyMon), which is why a trainer's + -- Rattata is always the same Rattata. + local mon = Mon.new(data, row.species, row.level, { + moves = moves, + item = row.item, + dvs = { attack = 9, defense = 8, speed = 8, special = 8 }, + }) + if mon then party[#party + 1] = mon end + end + return party +end + +-- FacingPlayerDistance (home/trainers.asm): the trainer must share a row or +-- column with the player, be facing along it, and the gap must be at least 1 +-- and no more than its sight range. Returns distance, direction. +function Trainers.sees(npc, player, sight) + if not (npc and player) or (sight or 0) <= 0 then return nil end + if npc.cellX == player.cellX then + local d = player.cellY - npc.cellY + if d == 0 then return nil end + local dir = d > 0 and "down" or "up" + d = math.abs(d) + if npc.facing ~= dir or d > sight then return nil end + return d, dir + elseif npc.cellY == player.cellY then + local d = player.cellX - npc.cellX + if d == 0 then return nil end + local dir = d > 0 and "right" or "left" + d = math.abs(d) + if npc.facing ~= dir or d > sight then return nil end + return d, dir + end + return nil +end + +-- TrainerWalkToPlayer: the trainer closes to one cell short of the player, +-- so a distance of 1 means it never moves. +function Trainers.approach(distance, dir) + local steps = {} + for _ = 1, math.max(0, (distance or 1) - 1) do + steps[#steps + 1] = dir + end + return steps +end + +Trainers.DELTA = Map.DELTA + +return Trainers diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua new file mode 100644 index 00000000..18853bee --- /dev/null +++ b/src/world/gen2/World.lua @@ -0,0 +1,9927 @@ +-- Gen 2 overworld vertical slice: COLL_* collision, warps, connected +-- neighbor strips (RBY-style), seamless edge crossings, survey zoom, +-- OW sprites + SPRITEMOVEDATA walk/spin paths on current map and neighbor +-- strips. Mounted from Game2; leaves Gen 1 Map.lua alone. + +local Apricorns = require("src.core.gen2.Apricorns") +-- The mod-override choke point: every generated-cache path a mod can shadow +-- with overrides/ or an AssetTransform output has to be resolved through here +-- (src/render/Assets.lua:33), which a raw love.graphics.newImage bypasses. +local Assets = require("src.render.Assets") +local Bag = require("src.inventory.Bag") +local Battle = require("src.battle.gen2.Battle") +local BattleMusic = require("src.battle.gen2.BattleMusic") +local Bike = require("src.world.gen2.Bike") +local BorderFill = require("src.world.gen2.BorderFill") +local Boxes = require("src.core.gen2.Boxes") +local Breeding = require("src.core.gen2.Breeding") +local BugContest = require("src.core.gen2.BugContest") +local CallAsm = require("src.script.gen2.CallAsm") +local Camera = require("src.render.Camera") +local Clock = require("src.core.gen2.Clock") +local CatchTutorial = require("src.core.gen2.CatchTutorial") +local Decorations = require("src.core.gen2.Decorations") +local MomShopping = require("src.core.gen2.MomShopping") +local CmdQueue = require("src.world.gen2.CmdQueue") +local Encounter = require("src.battle.gen2.Encounter") +local ChoiceBox = require("src.ui.ChoiceBox") +local Events = require("src.world.gen2.Events") +local FieldMoves = require("src.world.gen2.FieldMoves") +local FixedStep = require("src.core.FixedStep") +local Follower = require("src.world.gen2.Follower") +local Font = require("src.render.Font") +-- Two call sites only (World:step's tail, World:interact), both no-ops until +-- a mod has taken a facade (src/mods/Gen2Compat.lua). +local Gen1Facade = require("src.mods.Gen2Compat") +local GbcPalette = require("src.render.GbcPalette") +local Gen2Save = require("src.core.gen2.Save") +local HallOfFame = require("src.core.gen2.HallOfFame") +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 Mon = require("src.battle.gen2.Mon") +local Movement = require("src.script.gen2.Movement") +local Music = require("src.core.Music") +local NPC = require("src.world.gen2.Npc") +local Party = require("src.pokemon.Party") +local Permissions = require("src.world.gen2.Permissions") +local Player = require("src.world.gen2.Player") +local Pokerus = require("src.core.gen2.Pokerus") +local Roamers = require("src.core.gen2.Roamers") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local StepEvents = require("src.world.gen2.StepEvents") +local Tilt = require("src.render.Tilt") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local TrainerHouse = require("src.world.gen2.TrainerHouse") +local Trainers = require("src.world.gen2.Trainers") +local Unown = require("src.core.gen2.Unown") +local Vm = require("src.script.gen2.Vm") +local Zoom = require("src.render.Zoom") + +-- SFX_* indices from audio/sfx_pointers.asm (constants.sfxOrder). +local SFX_ITEM = 1 +-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: +-- it farcalls CheckItemPocket over wCurItem and rings SFX_GET_TM for the TM/HM +-- pocket, SFX_ITEM for every other one. That is the sound inside GiveItemScript, +-- so it is what every `verbosegiveitem` plays. +local SFX_GET_TM = 0x9b +-- SFX_READ_TEXT_2, the blip PlayTalkObject opens every bg event read on +-- (engine/overworld/events.asm). +local SFX_READ_TEXT_2 = 8 +-- SFX_SECOND_PART_OF_ITEMFINDER, the ding the heal machine rings as each +-- ball lands on it (engine/events/heal_machine_anim.asm .party_loop). Gen 2 +-- has no SFX_HEAL_MACHINE: the rising chime over the flashing is MUSIC_HEAL, +-- a song, not an sfx. +local SFX_SECOND_PART_OF_ITEMFINDER = 0x12 +-- .HOF_PlaySFX's pair: the Game Freak chime over the Hall of Fame machine's +-- flashing, then SFX_BOOT_PC as it settles. +local SFX_GAME_FREAK_LOGO_GS = 0xaa +local SFX_BOOT_PC = 0x0d +-- SFX_SANDSTORM, the rattle ShakeHeadbuttTree plays over a shaking tree +-- (engine/events/field_moves.asm, right after its WaitSFX). +local SFX_SANDSTORM = 0x6d +-- The field moves' own sounds, by their index in sfxOrder: +-- SFX_STRENGTH MovementFunction_Strength, as the boulder goes +-- SFX_PLACE_PUZZLE_PIECE_DOWN OWCutAnimation, the snip +-- SFX_SURF PlayWhirlpoolSound, which is a bare SFX_SURF +-- SFX_BUBBLEBEAM Script_UsedWaterfall's playsound +-- SFX_FLASH UseFlashTextScript's text_asm +local SFX_STRENGTH = 27 +local SFX_PLACE_PUZZLE_PIECE_DOWN = 30 +local SFX_BUBBLEBEAM = 81 +local SFX_SURF = 83 +local SFX_FLASH = 169 +-- EMOTE_SHOCK, emote 0 in constants/script_constants.asm. Script_FishCastRod +-- loads it over EMOTE_ROD, so the bubble that pops on a bite is the shock one. +local EMOTE_SHOCK = 0 +-- GetWarpSFX (home/map.asm) picks one of three by the tile the player is +-- standing on when the warp is taken; these are their sfxOrder indices in this +-- cache (Sfx_EnterDoor, Sfx_WarpTo, Sfx_ExitBuilding), resolved by NAME at the +-- call site so a cache with a different table still finds them. +local SFX_ENTER_DOOR = 31 +local SFX_WARP_TO = 19 +local SFX_EXIT_BUILDING = 35 +local SFX_JUMP_OVER_LEDGE = 0x16 + +local World = {} +World.__index = World + +-- Movement direction → map.connections key. +local DIR_CONN = { up = "north", down = "south", left = "west", right = "east" } +local FACING_ID = { down = 0, up = 1, left = 2, right = 3 } +local NEIGHBOR_HOPS = 2 +-- constants/script_constants.asm +local VAR_FACING = 0x09 +-- VAR_WEEKDAY, whose .DayOfWeek arm is `call GetWeekday` -> wCurDay. 39 of the +-- 40 `readvar` sites reachable from a map callback are this one: it is what +-- decides which of the seven travelling siblings is standing on their route, +-- which haircut brother is in, and which day the Goldenrod underground +-- MAPCALLBACK_OBJECTS lets through. +local VAR_WEEKDAY = 0x0b +-- VAR_BATTLETYPE, the one VAR_* slot a script writes that anything reads back: +-- `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_FORCEITEM` is what makes Lugia, +-- Ho-Oh and the Red Gyarados hold their item, FORCESHINY what makes the +-- Gyarados red, and CANLOSE what lets the Cherrygrove rival beat you. +local VAR_BATTLETYPE = 0x03 +-- constants/battle_constants.asm BATTLETYPE_FORCEITEM: InitEnemyMon's +-- `.WildItem` reads wBaseItem1 unconditionally for this type instead of +-- rolling the ordinary 25%/8% chance, which is how Ho-Oh's SACRED_ASH (and +-- Lugia's, and the Red Gyarados' held item) is guaranteed rather than random. +local BATTLETYPE_FORCEITEM = 10 +-- constants/battle_constants.asm BATTLETYPE_FORCESHINY: the Lake of Rage +-- Gyarados. InitEnemyMon's `.NotRoaming` arm (engine/battle/core.asm:5876) +-- swaps the rolled DVs for ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, and +-- TryToRunAwayFromBattle refuses to run for this type, which the battle +-- reads off opts.battleType. +local BATTLETYPE_FORCESHINY = 7 +-- constants/battle_constants.asm BATTLETYPE_CANLOSE: the Cherrygrove rival's +-- three arms are the only `loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE` in the +-- game. LostBattle (engine/battle/core.asm) answers this type by sliding the +-- winner's pic in and printing the loss text, then RETURNS -- no grayscale, no +-- whiteout -- and the script that armed it follows `startbattle` with a bare +-- `reloadmap`, never `reloadmapafterbattle`, so Script_BattleWhiteout is +-- unreachable from this battle on either path. +local BATTLETYPE_CANLOSE = 1 +local VAR_PARTYCOUNT = 0x01 +local VAR_BATTLERESULT = 0x02 +local VAR_TIMEOFDAY = 0x04 +local VAR_DEXCAUGHT = 0x05 +local VAR_DEXSEEN = 0x06 +local VAR_BADGES = 0x07 +local VAR_MOVEMENT = 0x08 +local VAR_HOUR = 0x0a +local VAR_MAPGROUP = 0x0c +local VAR_MAPNUMBER = 0x0d +local VAR_UNOWNCOUNT = 0x0e +local VAR_ENVIRONMENT = 0x0f +local VAR_BOXSPACE = 0x10 +local VAR_CONTESTMINUTES = 0x11 +local VAR_XCOORD = 0x12 +local VAR_YCOORD = 0x13 +local VAR_SPECIALPHONECALL = 0x14 + +-- wPlayerState (constants/ram_constants.asm) as VAR_MOVEMENT reads it raw: +-- NORMAL 0, BIKE 1, SKATE 2, SURF 4, SURF_PIKA 8. FieldMoves only models the +-- four states the port can actually enter; PLAYER_SKATE is written by nothing +-- in Gold. +local PLAYER_STATE_ID = { + [FieldMoves.PLAYER_NORMAL] = 0, + [FieldMoves.PLAYER_BIKE] = 1, + [FieldMoves.PLAYER_SURF] = 4, + [FieldMoves.PLAYER_SURF_PIKA] = 8, +} + +-- The same table backwards, for `loadvar VAR_MOVEMENT, PLAYER_BIKE`: the mount +-- and the dismount are a variable write on the cart, so writeVar has to be +-- able to turn one back into a state name. +local PLAYER_STATE_BY_ID = {} +for state, id in pairs(PLAYER_STATE_ID) do PLAYER_STATE_BY_ID[id] = state end + +-- constants/collision_constants.asm, for GetWarpSFX below. +local COLL_DOOR = 0x71 +local COLL_WARP_PANEL = 0x7c + +-- constants/sprite_constants.asm: wVariableSprites is indexed from SPRITE_VARS, +-- so an object whose `sprite` is one of $f0..$fc names a SLOT rather than a +-- sheet and only `variablesprite` can say what stands there. +local SPRITE_VARS = 0xf0 + +-- constants/sprite_constants.asm:143-145. Neither byte names a sheet: GetMonSprite +-- (engine/overworld/overworld.asm:279-305) tests them BEFORE the SPRITE_VARS +-- range and answers with LoadOverworldMonIcon of wBreedMon1Species / +-- wBreedMon2Species, i.e. the deposited mon's own party-menu icon. Route 34's +-- two yard objects carry them. +local SPRITE_DAY_CARE_MON_1 = 0xe0 +local SPRITE_DAY_CARE_MON_2 = 0xe1 + +-- constants/engine_flags.asm, const_def, with the five-wide pokegear block +-- first. These three are bits of wDayCareMan / wDayCareLady rather than slots +-- of their own (data/events/engine_flags.asm:18-20), so World:engineFlag reads +-- them straight out of save.dayCare. +local ENGINE_DAY_CARE_MAN_HAS_EGG = 5 +local ENGINE_DAY_CARE_MAN_HAS_MON = 6 +local ENGINE_DAY_CARE_LADY_HAS_MON = 7 + +-- constants/map_setup_constants.asm (const_def $f1). The byte picks a row of +-- MapSetupScripts (data/maps/setup_scripts.asm); the port has one map load, so +-- what survives of each script is which fades it is bracketed by. +local MAPSETUP_WARP = 0xf1 +local MAPSETUP_CONTINUE = 0xf2 +local MAPSETUP_RELOADMAP = 0xf3 +local MAPSETUP_TELEPORT = 0xf4 +local MAPSETUP_DOOR = 0xf5 +local MAPSETUP_FALL = 0xf6 +local MAPSETUP_CONNECTION = 0xf7 +local MAPSETUP_LINKRETURN = 0xf8 +local MAPSETUP_TRAIN = 0xf9 +local MAPSETUP_SUBMENU = 0xfa +local MAPSETUP_BADWARP = 0xfb + +-- Which of the eleven setup scripts fades, read off data/maps/setup_scripts.asm +-- with its FALLTHROUGHS honoured -- MapSetupScript_Fall drops into _Door, which +-- drops into _Train, and _Teleport drops into _Warp, so FALL fades out because +-- DOOR's FadeOutToWhite is the next command and not because FALL names one. +-- +-- fade out then in : DOOR, FALL, TELEPORT (a FadeOutToWhite opens the list) +-- fade in only : WARP, BADWARP, TRAIN, LINKRETURN, CONTINUE, RELOADMAP +-- neither : CONNECTION, SUBMENU (an edge cross must not hitch) +local MAPSETUP_FADE_OUT = { + [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, +} +local MAPSETUP_FADE_IN = { + [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, + [MAPSETUP_WARP] = true, [MAPSETUP_BADWARP] = true, [MAPSETUP_TRAIN] = true, + [MAPSETUP_LINKRETURN] = true, [MAPSETUP_CONTINUE] = true, + [MAPSETUP_RELOADMAP] = true, +} +-- MapSetupScript_Connection and _Submenu are the two with no FadeInFromWhite; +-- naming them keeps the table above readable as the whole eleven-row set. +local MAPSETUP_NO_FADE = { + [MAPSETUP_CONNECTION] = true, [MAPSETUP_SUBMENU] = true, +} + +-- MapSetupCommands $26 UpdateRoamMons and $27 JumpRoamMons, read off the same +-- eleven scripts with the same fallthroughs honoured. This is the ONLY thing +-- that moves the three legendary beasts around Johto, and where each sits in +-- its script decides which map "the player's map" means: +-- +-- UpdateRoamMons is the tail of MapSetupScript_Train, and _Fall drops into +-- _Door drops into _Train -- so a door warp, a fall and a train ride all +-- nudge each beast one connection along. _Connection names it too. It runs +-- AFTER the load, so the map it avoids putting them on is the NEW one. +-- +-- JumpRoamMons is the third row of MapSetupScript_Teleport, BEFORE that +-- script falls into _Warp -- so it runs before the load, and it scatters +-- every beast to a random roam map. Flying across Johto shuffles them; +-- walking through a door does not. +-- +-- A plain MAPSETUP_WARP names neither, which is why warping between two floors +-- of a building leaves them where they were. +local MAPSETUP_ROAM_UPDATE = { + [MAPSETUP_CONNECTION] = true, [MAPSETUP_DOOR] = true, + [MAPSETUP_FALL] = true, [MAPSETUP_TRAIN] = true, +} +local MAPSETUP_ROAM_JUMP = { [MAPSETUP_TELEPORT] = true } + +-- FadeOutToWhite / FadeInFromWhite (engine/tilesets/timeofday_pals.asm) are +-- `ld b, $4` steps of ConvertTimePalsIncHL / .DecHL, each followed by +-- `DelayFrames 2`: four steps, eight frames, per half. The port draws the +-- overworld with its colours already baked in and has no four-entry palette +-- left to rotate, so the four steps become four levels of the flat sheet +-- World:draw already holds for the fade specials. +local FADE_STEPS = 4 +local FADE_STEP_FRAMES = 2 + +-- A New Game starts in the bedroom, not outside: engine/menus/intro_menu.asm +-- NewGame sets wDefaultSpawnpoint = SPAWN_HOME and warps there, and +-- data/maps/spawn_points.asm puts SPAWN_HOME at PLAYERS_HOUSE_2F (3,3). +-- landmarks.lua carries the real table; these are the fallback for a cache +-- imported before the spawn table existed. +local SPAWN_HOME = "SPAWN_HOME" +local START_MAP = "PLAYERS_HOUSE_2F" +local START_X, START_Y, START_FACING = 3, 3, "down" +local PLAYER_SPRITE = "SPRITE_CHRIS" + +-- constants/event_flags.asm. HatchEggs sets this one by hand, for exactly one +-- species, right after SetSeenAndCaughtMon. wEventFlags is keyed by NUMBER +-- here (that is what the extractor emits), so the constant lives at the call +-- site rather than as a string the Events store would refuse to compare. +local EVENT_TOGEPI_HATCHED = 84 + +-- The last flag InitializeEventsScript sets (engine/events/std_scripts.asm), +-- and the one PlayersHouse2FInitializeRoomCallback checks before jumping to +-- it: once it is set the seed never runs again for that save file. Numbered +-- for the same reason as the one above. +local EVENT_INITIALIZED_EVENTS = 54 + +-- SPRITEMOVEDATA_STRENGTH_BOULDER, $19 in constants/map_object_constants.asm. +-- The push looks for this on the object, not for SPRITE_BOULDER: the sprite is +-- shared with the immovable scenery rocks and the movement data is what tells +-- the two apart (data/sprites/map_objects.asm gives only this row the +-- STRENGTH_BOULDER palette flag .CheckStrengthBoulder tests). +local SPRITEMOVEDATA_STRENGTH_BOULDER = 0x19 + +-- Script_UsedStrength's `pause 3` between the cry and MoveBoulderText, and +-- Script_UsedWaterfall's per-cell climb; both are frames at 60 Hz. +local STRENGTH_PAUSE_FRAMES = 3 + +-- The four field-move / field-item strings this file prints, in the port's own +-- TextBox markers (\n = second line, \f = page break, {STRBUF} = the shared +-- wStringBuffer2 token, which TextBox fills from game.stringBuffer). +-- +-- They are transcribed rather than looked up because nothing in the ROM's +-- script bytecode points at them: engine/events/overworld.asm names each label +-- directly, so the extractor -- which only walks reachable script pointers -- +-- never sees them and data/generated/text.lua has no key for them. Bodies +-- from data/text/common_2.asm; `#` is the four-tile POKé compression byte, so +-- "POKéMON" here is the same seven tiles the cart draws. +-- +-- Strings.source marks them for the catalog generator without translating +-- them here: the table is built at require time and Strings.load has no +-- catalog yet, so the lookup happens at the showText call sites below -- +-- the same arrangement src/world/gen2/FieldMoves.lua uses for the other +-- twenty-odd lines engine/events/overworld.asm names directly. +local TEXT_ASK_HEADBUTT = Strings.source( + "A POKéMON could be\nin this tree.\fWant to HEADBUTT\nit?") +local TEXT_USE_HEADBUTT = Strings.source("{STRBUF} did a\nHEADBUTT!") +local TEXT_HEADBUTT_NOTHING = Strings.source("Nope. Nothing…") +local TEXT_ROD_BITE = Strings.source("Oh!\nA bite!") +local TEXT_ROD_NOTHING = Strings.source("Not even a nibble!") +-- _UseSweetScentText / _SweetScentNothingText (data/text/common_2.asm). +-- GetPartyNickname leaves the same name in wStringBuffer1-3, so {STRBUF} +-- (wStringBuffer2) reads back the text_ram wStringBuffer3 line just as well. +local TEXT_USE_SWEET_SCENT = Strings.source("{STRBUF} used\nSWEET SCENT!") +local TEXT_SWEET_SCENT_NOTHING = + Strings.source("Looks like there's\nnothing here…") +-- _UseSacredAshText (data/text/common_2.asm), hung off SacredAshScript's own +-- `text_far` and never reached through a script pointer the extractor walks, +-- same reason the itemfinder pair above needs Strings.source instead of a +-- text.lua key. +local TEXT_USE_SACRED_ASH = Strings.source( + "{PLAYER}'s POKéMON\nwere all healed!") + +-- CheckHeadbuttTreeTile (home/map_objects.asm) compares the facing tile's +-- collision against COLL_HEADBUTT_TREE and its unused $1d alias +-- (constants/collision_constants.asm). Both sit at permission $0f, so a tree +-- blocks a step like any other wall and only the A press tells them apart -- +-- which is why this is a collision test and not a tile-graphic one. Same +-- shape as Permissions.isGrass, and it belongs beside it. +local HEADBUTT_TREE = { [0x15] = true, [0x1d] = true } + +-- TryHeadbuttOW's `ld d, HEADBUTT / call CheckPartyMove`; moves.lua keys the +-- move by its own constant name. +local MOVE_HEADBUTT = "HEADBUTT" + +-- The three fishing rods, keyed the way items.lua keys them so a call site +-- names an item rather than a byte. Indices from constants/item_constants.asm +-- (OLD_ROD $3a, GOOD_ROD $3b, SUPER_ROD $3d); data/items/attributes.asm gives +-- all three KEY_ITEM, ITEMMENU_CLOSE in the field and ITEMMENU_NOUSE in +-- battle, i.e. a rod is a field-only item and the battle PACK must refuse it. +local ROD_INDEX = { OLD_ROD = 0x3a, GOOD_ROD = 0x3b, SUPER_ROD = 0x3d } + +-- RepelEffect / SuperRepelEffect / MaxRepelEffect (engine/items/item_effects.asm): +-- each is a bare `ld b, <steps>` into the shared UseRepel, so the step count is +-- the only thing that differs between the three items. wRepelEffect is +-- save.repelSteps, ticked down by StepEvents.repelStep on every footfall. +local REPEL_STEPS = { REPEL = 100, SUPER_REPEL = 200, MAX_REPEL = 250 } + +-- NormalBoxEffect / GorgeousBoxEffect: the item -> DECOFLAG_* it opens on. +-- The pairing is crossed on purpose -- the NORMAL BOX holds the SILVER trophy +-- and the GORGEOUS BOX the GOLD one, exactly as the asm reads. +local TROPHY_BOXES = { + NORMAL_BOX = Decorations.DECOFLAG_SILVER_TROPHY_DOLL, + GORGEOUS_BOX = Decorations.DECOFLAG_GOLD_TROPHY_DOLL, +} + +-- Script_FishCastRod ends on `pause 40`, and Script_GotABite pauses another 40 +-- over the bobbing rod before the text lands. ShakeHeadbuttTree counts down +-- wFrameCounter from 32. All three are frames at 60 Hz, which is the same +-- clock World:step runs on. +local FISH_CAST_FRAMES = 40 +local FISH_BITE_FRAMES = 40 +local HEADBUTT_SHAKE_FRAMES = 32 + +-- The vanilla links the two encounter chains wrap (World:rollEncounter, below), +-- hoisted here so an empty chain allocates no closure and so every caller -- +-- the step roll, `randomwildmon`, the Bug Contest and SWEET SCENT -- can see +-- them. Each takes the piped value (the wild tables the roll is made against) +-- and the ctx, and answers { species, level } or nil. +local function rollGrassVanilla(tables, ctx) + return Encounter.grassSlot(tables, ctx.mapId, ctx.daytime, nil) +end + +local function rollWaterVanilla(tables, ctx) + return Encounter.waterSlot(tables, ctx.mapId, nil) +end + +local function rollContestVanilla(_, ctx) + return BugContest.chooseWild(ctx.data) +end + +local function sameEncounter(enc) return enc end + +-- The encounter.fishing chain's vanilla link. Encounter.fishSlot's own rod +-- mapping, and then Encounter.fish over whatever candidate row the chain handed +-- down -- routed back through Encounter.fish rather than re-rolled here, so a +-- mod that only inspected the list gets the cart's cumulative-chance walk +-- (engine/events/fish.asm Fish) byte for byte. +local FISH_ROD_KEY = { OLD_ROD = "old", GOOD_ROD = "good", SUPER_ROD = "super" } + +local function fishVanilla(rod, _mapId, candidates) + if not candidates then return nil end + return Encounter.fish({ fishGroups = { hooked = candidates } }, "hooked", + FISH_ROD_KEY[rod] or rod or "old", nil) +end + +local function speciesByIndex(pokemon, index) + if not pokemon or not index then return nil end + for id, def in pairs(pokemon) do + if type(def) == "table" and def.index == index then return id, def end + end + return nil +end + +local function itemByIndex(items, index) + if not items or not index or index == 0 then return nil end + for id, def in pairs(items) do + if type(def) == "table" and def.index == index then return id, def end + end + return nil +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) + if not flags then return 0 end + local n = 0 + for _, has in pairs(flags) do + if has then n = n + 1 end + end + return n +end + +-- `givepoke` builds the same record a caught wild mon does, through +-- src/battle/gen2/Mon.lua. It used to go through Gen 1's Pokemon.new with a +-- hand-adapted base-stat block, and that adapter handed it `level1Moves = {}` +-- and `learnset = {}` -- fields the Gen 2 extractor does not write, because a +-- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift, +-- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the +-- battle had no legal action left in it. +local function givePokeMon(data, speciesIndex, level, itemIndex) + local id = speciesByIndex(data.pokemon, speciesIndex) + if not id then return nil end + return Mon.new(data, id, level or 5, { + item = itemIndex and itemIndex ~= 0 + and itemByIndex(data.items, itemIndex) or nil, + }) +end + +local function loadGenerated(path) + local chunk, err = love.filesystem.load(path) + if not chunk then return nil, err end + local ok, value = pcall(chunk) + if not ok then return nil, value end + return value +end + +-- Paste the 9-tile roof sheet over atlas tiles $0a-$12. +local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow) + -- Assets.resolve, not the raw path: an overrides/ file or an AssetTransform + -- output has to feed the derivation rather than be bypassed by it. + local atlasData = love.image.newImageData(Assets.resolve(atlasPath)) + local roofData = love.image.newImageData(Assets.resolve(roofPath)) + for t = 0, 8 do + local destId = 0x0a + t + local dx = (destId % tilesPerRow) * 8 + local dy = math.floor(destId / tilesPerRow) * 8 + local sx = t * 8 + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = roofData:getPixel(sx + x, y) + atlasData:setPixel(dx + x, dy + y, r, g, b, a) + end + end + end + local image = love.graphics.newImage(atlasData) + image:setFilter("nearest", "nearest") + return image +end + +-- Gen 2 connections use mapId (name); offsets are in blocks (32 px), +-- same strip math as Gen 1 OverworldState.computeNeighbors. +function World.computeNeighbors(maps, rootId, hops, reachW, reachH) + local out = {} + local rootDef = maps[rootId] + if not rootDef then return out end + local placed = { [rootId] = true } + local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } } + local qi = 1 + local function inReach(def, ox, oy) + if not (reachW and reachH and rootDef) then return false end + return ox + def.width * 32 > -reachW + and ox < rootDef.width * 32 + reachW + and oy + def.height * 32 > -reachH + and oy < rootDef.height * 32 + reachH + end + while queue[qi] do + local cur = queue[qi] + qi = qi + 1 + for dir, conn in pairs(cur.def.connections or {}) do + local destId = conn.mapId or conn.map + local destDef = type(destId) == "string" and maps[destId] or nil + if destDef and not placed[destId] then + placed[destId] = true + local offset = conn.offset or 0 + if offset == 0 then offset = 0 end -- squash signed-zero + local ox, oy + if dir == "north" then + ox, oy = offset * 32, -destDef.height * 32 + elseif dir == "south" then + ox, oy = offset * 32, cur.def.height * 32 + elseif dir == "west" then + ox, oy = -destDef.width * 32, offset * 32 + else + ox, oy = cur.def.width * 32, offset * 32 + end + ox, oy = cur.ox + ox, cur.oy + oy + if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then + table.insert(out, { id = destId, ox = ox, oy = oy }) + if cur.hops + 1 < hops or inReach(destDef, ox, oy) then + table.insert(queue, { + def = destDef, ox = ox, oy = oy, hops = cur.hops + 1, + }) + end + end + end + end + end + return out +end + +function World.new(game) + local self = setmetatable({ + game = game, + status = nil, + maps = nil, + tilesets = nil, + roofs = nil, + sprites = nil, + scripts = nil, + text = nil, + constants = nil, + events = Events.new(), + mapScenes = {}, -- [mapId] = sceneId + -- wCmdQueue: four slots, refilled from the map's MAPCALLBACK_CMDQUEUE on + -- every load and polled once a frame (engine/overworld/cmd_queue.asm). + cmdQueue = CmdQueue.new(), + vm = nil, + map = nil, + player = nil, + mapImage = nil, + mapImages = {}, + -- _AnimateTileset's two counters (engine/tilesets/tileset_anims.asm:11, + -- :57), plus the per-bake cell lists and palettes the frames draw with. + animClock = 0, + animTimer = 0, + animCells = {}, + bgSets = {}, + neighbors = {}, + atlasCache = {}, + npcPool = {}, + npcs = {}, + ghosts = {}, + entities = {}, + talkNpc = nil, + camera = Camera.new(), + heldDir = nil, + -- wPlayerTurningDirection's useful half: the direction a STEP latched via + -- .FinishFacing. CheckStandingOnIce + .CheckForced re-inject it as the + -- d-pad while the tile underfoot is COLL_ICE, which is the whole ice-slide + -- rule (engine/overworld/player_movement.asm). Nil means "standing". + turningDirection = nil, + viewW = 160, + viewH = 144, + moveState = nil, + lastSfx = nil, + pokePic = nil, + pendingSceneScript = false, + -- GBC color state (engine/gfx/color.asm). `daytime` is the resolved + -- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides + -- World:hour for drivers and tests, so the palette, the hour windows and + -- VAR_HOUR all move together; flashUsed lifts PALETTE_DARK maps. + palettes = nil, + daytime = nil, + clockHour = nil, + -- wCurDay, SUNDAY 0 .. SATURDAY 6, when something wants to pin it; nil + -- reads the host clock. See World:weekday. + clockDay = nil, + flashUsed = false, + paletteClock = 0, + -- FlickeringCaveEntrancePalette's frame counter: only a DARKNESS_PALSET + -- map reads it, and only to decide which of two baked canvases is up. + flickerClock = 0, + flickerPhase = 1, + -- wPlayerState (constants/ram_constants.asm), as FieldMoves names it. + playerState = FieldMoves.PLAYER_NORMAL, + -- BIKEFLAGS_STRENGTH_ACTIVE_F. ResetBikeFlags clears the whole byte on + -- every map load, which is why STRENGTH has to be used again in the next + -- room. + strengthActive = false, + -- STATUSFLAGS_NO_WILD_ENCOUNTERS_F, driven by the `wildoff` / `wildon` + -- script commands. + noWildEncounters = false, + -- Blocks CUT and WHIRLPOOL have swapped out on the loaded map, as + -- { mapId = { [index] = original } }. The cart edits wOverworldMapBlocks, + -- a BUFFER, and LoadMapAttributes refills it from ROM on every map load -- + -- which is why a cut tree is back the next time you walk in. Restoring + -- these at the top of setMap is that refill. + blockEdits = {}, + -- A field move that is mid-flow (the used-X text, then its effect). + fieldMove = nil, + -- ---- state the script VM owns ------------------------------------------ + -- wVariableSprites (ram/wram.asm), indexed from SPRITE_VARS: slot -> plain + -- OverworldSprites byte. Cleared on a map load the way the cart's copy is + -- not -- it is real WRAM that survives -- so this one survives too, and + -- every map that needs a slot filled sets it from its own scene script. + variableSprites = {}, + -- The VAR_* slots `writevar` / `loadvar` write. Only VAR_BATTLETYPE is + -- read back today, by the next startbattle. + scriptVars = {}, + -- WarpCheck's find. A script that ends standing on a warp tile must not + -- warp INSIDE the command: the commands queued behind it would run with the + -- map pulled out from under them, so the destination waits here until + -- World:step sees the VM go idle. + pendingWarp = nil, + -- ShakeScreen's live wPlayerStepVectorY offset (`earthquake`). + shake = nil, + -- wDontPlayMapMusicOnReload: one shot, consumed by the next map reload. + dontRestartMusic = false, + -- FadeOutToWhite / FadeOutToBlack's sheet, until a FadeInFrom* lifts it. + fade = nil, + -- A `musicfadeout` whose ramp still has frames left, plus the label queued + -- underneath it. + pendingMusic = nil, + showDebugHud = os.getenv("POKEPORT_DEV") == "1", + }, World) + -- ow.runner under the Gen 1 name (src/world/OverworldController.lua:216). + -- A mod guards with `ow.runner and ow.runner:isRunning()` before acting; + -- nil there is FALSEY, so the mod concludes no script is running while one + -- is and acts mid-cutscene. Gold's frame is self.vm, so this is the query + -- half of it and nothing else. + self.runner = setmetatable( + { isRunning = function() return self:scriptRunning() end }, + { __index = function(_, key) + if key == "vm" then return self.vm end + if key == "co" then return self.vm and self.vm.co end + if key == "ctx" then return self.vm and self.vm.ctx end + return nil + end }) + return self +end + +-- LoadPlayerData (engine/menus/save.asm) copies sPlayerData straight back over +-- wPlayerData, and ALL THREE of the things this restores live inside that +-- region (ram/wram.asm): wEventFlags, the block of w<Map>SceneID bytes above +-- it, and wPlayerState below them. +-- That is why a cartridge reload comes back with every flag the player set and +-- with each map still on the scene it had been advanced to. MeetMomScript +-- (maps/PlayersHouse1F.asm) needs both halves at once: it ends on `setscene +-- SCENE_PLAYERSHOUSE1F_NOOP` plus a setevent/clearevent pair, so talk to MOM, +-- save, reload, and she must NOT play her first-time scene again. +-- +-- `save.events` is the serialized bitfield src/world/gen2/Events.lua writes +-- (byte index -> byte value, keyed by NUMBER) and `save.mapScenes` is map id -> +-- scene id; src/core/gen2/Save.lua has already scrubbed both by the time this +-- runs. The restore REPLACES the seed rather than merging with it: a script +-- that CLEARED one of InitializeEventsScript's flags (MeetMomScript clears +-- EVENT_PLAYERS_HOUSE_MOM_2 on its way out) has to stay cleared across a +-- reload, and an OR would set it again on every boot. +-- +-- Called from World:load BEFORE the first setMap, because the object list is +-- only re-read when a map loads (RefreshMapSprites): a flag restored after the +-- load would leave the wrong people standing on the first map until the player +-- walked into the next one. +function World:loadPlayerData(save) + self.events = Events.new() + self.mapScenes = {} + if type(save) == "table" then + if type(save.events) == "table" then + self.events:restore(save.events) + end + if type(save.mapScenes) == "table" then + for mapId, scene in pairs(save.mapScenes) do + self.mapScenes[mapId] = tonumber(scene) or 0 + end + end + end + -- PlayersHouse2FInitializeRoomCallback (maps/PlayersHouse2F.asm) jumps to + -- InitializeEventsScript only while EVENT_INITIALIZED_EVENTS is still clear, + -- and the script sets that flag last. So a save that has never had the seed + -- gets it here -- a brand new game, or one whose world never ran -- and a + -- save that has keeps exactly the bitfield it was written with. + if not self.events:get(EVENT_INITIALIZED_EVENTS) then + for _, id in ipairs(self.initialEvents or {}) do + self.events:set(id, true) + end + for _, id in ipairs(self.initialEngineFlags or {}) do + self:setEngineFlag(id, true) + end + end + -- Variable sprites: seed any slot the save does not already carry. + -- + -- Seeding only on a fresh game would be the cart's own behaviour, and the + -- cart gets away with it because wVariableSprites is WRAM that is never + -- reloaded mid-session. This port rebuilds the World on every CONTINUE, so + -- "only on a fresh game" would mean an empty slot -- and an empty slot is an + -- object that does not spawn. Filling only what is missing keeps a later + -- `variablesprite` (Route 36 swaps the tree for a TWIN once Sudowoodo is + -- beaten, reusing the same slot) intact across a save and reload. + local saved = (self.game and self.game.save and self.game.save.variableSprites) + if type(saved) == "table" then + for slot, sprite in pairs(saved) do + if type(slot) == "number" then self.variableSprites[slot] = sprite end + end + end + for _, row in ipairs(self.initialSprites or {}) do + if row.slot and row.sprite and not self.variableSprites[row.slot] then + self.variableSprites[row.slot] = row.sprite + end + end + -- wPlayerState, the third member of the block. Everything the state decides + -- follows from this one field: UpdatePlayerSprite picks the sheet off it, + -- .DoStep picks STEP_BIKE or STEP_WALK off it, and .TranslateIntoMovement + -- picks .CheckLandPerms or .CheckSurfPerms off it -- so a save made on the + -- BICYCLE that came back on foot was not just wearing the wrong sprite, it + -- was walking at the wrong speed over a different set of tiles. + -- + -- Only a name Save.PLAYER_STATES vouches for is taken; anything else is + -- PLAYER_NORMAL, which is where Save.validate has already put it and is the + -- cart's own zero byte. applyPlayerState rather than a bare assignment so a + -- world that already has a player repaints them on the spot; on the boot + -- path there is no player yet and the first setMap's CheckUpdatePlayerSprite + -- is what puts the sprite on. + local state = type(save) == "table" and save.playerState or nil + self:applyPlayerState(Gen2Save.PLAYER_STATES[state] and state or nil) + -- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber, the triple a -1 + -- warp destination resolves through (home/map.asm CopyWarpData). On the + -- cart it sits in the same saved WRAM block as wPlayerState, so a save made + -- on POKECENTER_2F still knows which centre's stairs lead back down after a + -- reload; this port rebuilds the World on every CONTINUE, so the triple has + -- to ride the save the same way wPlayerState does. + local backup = type(save) == "table" and save.backupWarp or nil + if type(backup) == "table" and backup.map and backup.warp then + self.backupWarp = { warp = backup.warp, map = backup.map } + end + return self.events +end + +-- The Gen 2 content tables come off game.data rather than off disk. +-- src/core/Game2.lua:load reads every one of them into self.data BEFORE it +-- calls mods:load(self.data), so what this hands back is the merged table: a +-- registry that targets data.gen2Maps has somewhere to write, and the world +-- walks what it wrote. Held by reference on purpose: a copy here would +-- silently un-merge every one of them. +-- +-- The on-disk fallback covers a World built without a Game2 behind it: +-- the stub-game worlds in tests/gen2_*_test.lua set the fields they need +-- straight onto the world and never call :load, but a driver or tool that does +-- call it with a bare game table still has to boot. Nothing merges mods on +-- that path, so reading the cache directly reaches the same table by another +-- route; the read is cached back into game.data so a later reader (MartMenu's +-- data.gen2Marts, Pokegear's data.gen2Landmarks) sees the same one table. +function World:dataTable(key, path) + local data = self.game and self.game.data + local held = data and data[key] + if held ~= nil then return held end + local value, err = loadGenerated(path) + if value ~= nil and data then data[key] = value end + return value, err +end + +function World:load() + local maps, mapsErr = self:dataTable("gen2Maps", "data/generated/maps.lua") + local tilesets, tilesErr = + self:dataTable("gen2Tilesets", "data/generated/tilesets.lua") + if not maps or not tilesets then + self.status = Strings("Gold cache incomplete:\n%s", + tostring(mapsErr or tilesErr)) + return false + end + self.maps = maps + self.tilesets = tilesets + self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua") + self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua") + -- A cache from before the palette stage existed simply has no palettes.lua; + -- everything below falls back to the grayscale path rather than failing. + self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua") + -- Town-map landmarks for the Pokegear, and the SPAWN_* table that decides + -- where a New Game and every Pokecenter respawn start. + self.landmarks = + self:dataTable("gen2Landmarks", "data/generated/landmarks.lua") + self.encounters = + self:dataTable("gen2Encounters", "data/generated/encounters.lua") + self.stdScripts = + self:dataTable("gen2StdScripts", "data/generated/std_scripts.lua") + self.trainers = self:dataTable("gen2Trainers", "data/generated/trainers.lua") + -- data.trainers is the second name the Gen 2 code reads this same table by + -- (World:trainerParty, BugContest, Palettes.trainerPalette). The two keys + -- are aliased in Game2 before the merge; this line only has to catch the + -- fallback path above, and assigns the same reference either way. + if self.game and self.game.data and self.trainers then + self.game.data.trainers = self.trainers + end + -- Mart shelves (data/items/marts.asm). A cache from before the mart stage + -- has no marts.lua at all; MartMenu treats that as an empty shelf rather + -- than inventing stock, so a clerk still opens and still says his lines. + self.marts = self:dataTable("gen2Marts", "data/generated/marts.lua") + -- showemote's bubbles. Loaded here rather than at draw time so a missing + -- sheet (a cache from before the emote stage) just means no bubble. + local menuGfx = self:dataTable("gen2MenuGfx", "data/generated/menu_gfx.lua") + local emotes = menuGfx and menuGfx.emotes + if emotes then + self.emoteOrder = emotes.order + self.emoteImages = {} + for _, key in ipairs(emotes.order or {}) do + local path = emotes[key] + if path then + local okImg, img = pcall(Assets.image, path) + if okImg then self.emoteImages[key] = img end + end + end + -- ShakeGrass' one tile (data/sprites/emotes.asm:22). A cache from before + -- it was extracted simply has no rustle. + if emotes.grassRustle then + local okImg, img = pcall(Assets.image, emotes.grassRustle) + if okImg then self.grassRustleImage = img end + end + end + -- The heal machine's two OBJ tiles and their CGB palette, for the + -- Pokecenter light show (World:startHealMachineAnim). A cache from before + -- the sheet existed just has no entry, and the anim degrades to its sounds. + local healMachine = menuGfx and menuGfx.healMachine + if healMachine and healMachine.sheet then + local okImg, img = pcall(Assets.image, healMachine.sheet) + if okImg then + self.healMachineImage = img + self.healMachinePalette = healMachine.palette + end + end + -- POKEPORT_GOLD_HOUR pins the clock so a driver's screenshots are stable and + -- a reviewer can look at any time of day on demand. + local forcedHour = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "") + if forcedHour then self.clockHour = forcedHour end + -- POKEPORT_GOLD_DAY pins wCurDay (SUNDAY 0 .. SATURDAY 6) for the same + -- reason: the day-of-week map callbacks put a different NPC on seven routes, + -- and a driver that only ever sees today's cannot check the other six. + local forcedDay = tonumber(os.getenv("POKEPORT_GOLD_DAY") or "") + if forcedDay then self.clockDay = forcedDay end + self.scripts = self:dataTable("gen2Scripts", "data/generated/scripts.lua") + or {} + self.text = self:dataTable("gen2Text", "data/generated/text.lua") or {} + self.constants = + self:dataTable("gen2Constants", "data/generated/constants.lua") or {} + -- 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 + -- events.lua at all, so every reader treats it as optional. + self.eventTables = + self:dataTable("gen2EventTables", "data/generated/events.lua") or {} + -- Take the phone book off the cache when it has one; src/core/gen2/Phone.lua + -- keeps its transcribed tables as the fallback for an older cache. + require("src.core.gen2.Phone").useExtracted(self.eventTables) + -- data.pokemon and data.items keep the SHARED Gen 1 keys: both registries + -- route to their Gen 1 target under Gen 2, so Game2 already has them + -- loaded and merged. Going through dataTable is what makes that hold + -- -- the old unconditional re-read overwrote both with a fresh copy off the + -- cache and threw away every `pokemon` and `items` merge a mod had made. + self:dataTable("pokemon", "data/generated/pokemon.lua") + self:dataTable("items", "data/generated/items.lua") + if self.game and self.game.save then + local save = self.game.save + save.party = save.party or {} + save.inventory = save.inventory or {} + save.phoneContacts = save.phoneContacts or {} + end + -- Retail Gold hides story NPCs (lab cop, rivals, etc.) via + -- InitializeEventsScript's setevent list -- apply before spawning people. + local initial = + self:dataTable("gen2InitialEvents", "data/generated/initial_events.lua") + self.initialEvents = (initial and initial.flags) or {} + self.initialEngineFlags = (initial and initial.engineFlags) or {} + -- InitializeEventsScript does not only `setevent`. It ends with nine + -- `variablesprite` assignments, and SPRITE_WEIRD_TREE ($f4) and friends are + -- wVariableSprites SLOTS rather than sheets -- so until the slot is filled, + -- World:resolveSprite answers nil and World:pooledNpc spawns NOTHING. The + -- seed used to carry the setevent list alone, which meant the Sudowoodo on + -- Route 36 was simply absent from a new game, and with it TM08 ROCK SMASH, + -- the Burned Tower, Morty, FOGBADGE and SURF; likewise the Olivine rival, + -- the Azalea Rocket, the four Fuchsia Gym Janines, the Copycat and the + -- Janine impersonator. + -- + -- The extractor now writes them (`sprites`). The fallback finds the same + -- script in scripts.lua for a cache written before it did -- the script is + -- the one whose setevent ids ARE this flag list -- so an existing cache does + -- not have to be rebuilt for the tree to come back. + self.initialSprites = (initial and initial.sprites) + or self:findInitialSprites() + -- wEventFlags and the per-map scene ids off the save, with the seed above as + -- the fallback for a file that has never had it. Both are back before the + -- VM is built and long before the first setMap below. + self:loadPlayerData(self.game and self.game.save) + -- Font.load expects the Gen 1 Data shape: data.font = font.lua table. The + -- `font` registry keeps its Gen 1 target under Gen 2, so data.font is + -- already the merged table by the time we get here -- a second disk read + -- would put the stock glyphs back over a font mod's. + local font = self:dataTable("font", "data/generated/font.lua") + if font then + local okFont, fontErr = pcall(Font.load, { font = font }) + if not okFont then + self.status = Strings("Font load failed:\n%s", tostring(fontErr)) + return false + end + end + + self.vm = Vm.new(self.scripts, self.text, self.events, { + eventTables = self.eventTables, + -- The `commands` registry as merged into data.commands, which is the + -- shared Gen 1 target (it is absent from Schemas.GEN2 on purpose). The + -- VM resolves a mod-authored `modcommand` row's verb out of this table + -- (src/script/gen2/Vm.lua:runModCommand); a cart row never reaches it. + -- Read straight off game.data rather than through dataTable: there is no + -- data/generated/commands.lua to fall back to, and nil here is the honest + -- answer for a mod-free boot -- the only check the dispatch arm pays. + commands = self.game and self.game.data and self.game.data.commands, + -- Vm:resume hands the one-command lookahead through as the third argument + -- (Vm:textStays): the next row is `yesorno`, so this text ended in `done` + -- and the cart never took the box down before YesNoBox went up over it + -- (home/text.asm:484 DoneText returns with no PromptButton, unlike + -- PromptText). Dropping the argument here left World:showText's `stay` + -- branch and World:askYesNo's held arm unreachable, which cost a button + -- press the cart never asks for and re-printed the question under the + -- prompt. + -- `hold` is the same story one argument along: the cart `pause` a held box + -- stands through (FindItemInBallScript's `pause 60`). Dropping it made the + -- box hand back the instant it finished typing. + showText = function(body, onDone, stay, hold) + self:showText(body, onDone, stay, hold) + end, + facePlayer = function() + if self.talkNpc and self.player then + self.talkNpc:facePlayer(self.player) + end + end, + onFlagsChanged = function() + -- A flag set MID-SCRIPT must not change which objects are on the map. + -- The cart only re-reads the object list when the map loads + -- (RefreshMapSprites, after a warp or a scene change), so a script that + -- sets an object's event flag halfway through -- MeetMomScript sets + -- EVENT_PLAYERS_HOUSE_MOM_1 and clears MOM_2 while Mom is still + -- standing next to the player -- would otherwise swap the standing Mom + -- for the sitting one on the spot, which reads as her teleporting into + -- her chair mid-sentence. Deferred to the end of the script instead. + if self:scriptRunning() then + self.peopleDirty = true + return + end + self:rebuildPeople({ seamless = true }) + end, + setScene = function(scene) + if self.map then self.mapScenes[self.map.id] = scene or 0 end + end, + getScene = function() + return self.map and (self.mapScenes[self.map.id] or 0) or 0 + end, + setMapScene = function(group, mapNum, scene) + local mapId = self:mapIdByGroupMap(group, mapNum) + if mapId then self.mapScenes[mapId] = scene or 0 end + end, + turnObject = function(objectId, facing) + self:turnObject(objectId, facing) + end, + applyMovement = function(objectId, bytes, onDone) + self:beginMovement(objectId, bytes, onDone) + end, + follow = function(leader, follower) + self:startFollow(leader, follower) + end, + stopFollow = function() self:stopFollow() end, + -- StartAutoInput / StopAutoInput (home/joypad.asm). The ring itself lives + -- on the game so it outlives a map load, and it is stepped once per fixed + -- step ahead of Input:step; see src/core/gen2/AutoInput.lua. + autoInput = function(bank, address) + local ring = self.game and self.game.autoInput + if ring then ring:startPointer(bank, address, self.game.input) end + end, + autoInputStream = function(name) + local ring = self.game and self.game.autoInput + if ring then ring:start(name, self.game.input) end + end, + stopAutoInput = function() + local ring = self.game and self.game.autoInput + if ring then ring:stop(self.game.input) end + end, + yesorno = function(onChoose) + self:askYesNo(onChoose) + end, + disappear = function(objectId) + self:disappearObject(objectId) + end, + showPic = function(speciesIndex) + self:showPokePic(speciesIndex) + end, + hidePic = function() + self.pokePic = nil + end, + -- WaitButton (home/text.asm), for the `waitbutton` that sits under an open + -- `pokepic` window and so has no text box to have taken the press for it. + -- See World:waitForButton. + waitButton = function(done) + self:waitForButton(done) + end, + getMonName = function(speciesIndex) + local id, def = speciesByIndex( + self.game and self.game.data and self.game.data.pokemon, + speciesIndex) + return (def and def.name) or id or "?" + end, + getItemName = function(itemIndex) + local id, def = itemByIndex( + self.game and self.game.data and self.game.data.items, itemIndex) + if def and def.name then return def.name end + return id or ("ITEM" .. tostring(itemIndex)) + end, + -- CheckItemPocket (engine/items/items.asm) on wCurItem: the pocket id + -- behind ItemPocketNames, which GetPocketName copies into wStringBuffer3 + -- for _PutItemInPocketText and _PocketIsFullText. Same lookup + -- World:specialSound already makes for the TM/HM jingle. + getItemPocket = function(itemIndex) + local _, def = itemByIndex( + self.game and self.game.data and self.game.data.items, itemIndex) + return def and def.pocket or nil + end, + -- GetTrainerName, not Battle_GetTrainerName: the operand pair IS the + -- class and member, so nothing here reads wOtherTrainer*. CAL takes its + -- own arm before the table is touched (src/world/gen2/TrainerHouse.lua). + getTrainerName = function(group, index) + return TrainerHouse.name(self.game and self.game.data + and self.game.data.trainers, self.game and self.game.save, + group, index) + end, + -- Mirror wStringBuffer2 onto the game so the shared {STRBUF} token can + -- fill any page the VM itself did not build. + setStringBuffer = function(value) + if self.game then self.game.stringBuffer = value end + end, + givePoke = function(speciesIndex, level, item) + local data = self.game and self.game.data + local save = self.game and self.game.save + if not (data and save) then return end + save.party = save.party or {} + local mon = givePokeMon(data, speciesIndex, level, item) + if mon then + -- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149). + Mon.stampOT(save, mon) + Party.add(save.party, mon) + -- GivePoke ends in SetSeenAndCaughtMon, which is why the STARTER is + -- already ticked off in the #DEX before the first battle. + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[mon.species] = true + save.pokedex.caught[mon.species] = true + -- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted + -- Unown lands in the form list too (nothing in Gold gives one, but the + -- cart's check is on the species, not on where it came from). + Unown.registerCatch(save, mon) + end + end, + giveItem = function(itemIndex, qty) + local data = self.game and self.game.data + local save = self.game and self.game.save + if not save then return false end + save.inventory = save.inventory or {} + local id = itemByIndex(data and data.items, itemIndex) + if not id then + id = "ITEM_" .. tostring(itemIndex) + end + return Bag.add(save, id, qty or 1, data) + end, + addCell = function(phone) + local save = self.game and self.game.save + if not save then return end + save.phoneContacts = save.phoneContacts or {} + save.phoneContacts[phone] = true + end, + delCell = function(phone) + local save = self.game and self.game.save + if not save or not save.phoneContacts then return end + save.phoneContacts[phone] = nil + end, + hasCell = function(phone) + local save = self.game and self.game.save + return save and save.phoneContacts and save.phoneContacts[phone] == true + end, + cry = function(speciesIndex) + self:playCry(speciesIndex) + end, + playSound = function(sfxId) + self:playSfx(sfxId) + end, + playMusic = function(musicId) + self:playMusicId(musicId) + end, + specialSound = function(itemIndex) self:specialSound(itemIndex) end, + -- WaitSFX is `call CheckSFX / jr c, WaitSFX` on wCurSFX, so it waits on + -- WHATEVER sound is on the channels, not only on the ones a script + -- started. Phone_StartRinging (engine/phone/phone.asm:564) is the caller + -- that needs the wider reading: the A-press beep that dismissed the box + -- before the call is the sound most likely to still be running, and + -- SFX_CALL ($6a) is quiet enough that the priority gate drops the ring if + -- it fires over one. Sound.sfxBusy() is that wCurSFX; World.lastSfx only + -- covers the sounds World itself started. + waitSfx = function() + if require("src.core.Sound").sfxBusy() then return false end + local src = self.lastSfx + if not src then return true end + local ok, playing = pcall(src.isPlaying, src) + return not (ok and playing) + end, + readVar = function(varId) + return self:readVar(varId) + end, + -- `special` ids resolve through the extracted SpecialsPointers order. + specialOrder = self.constants and self.constants.specialOrder, + lookupTrainer = function(class, member) + return self:trainerParty(class, member) + end, + startBattle = function(trainer, wild, onDone) + self:startScriptedBattle(trainer, wild, onDone) + end, + catchTutorial = function(wild, battleType, onDone) + self:startCatchTutorial(wild, battleType, onDone) + end, + -- `setup` is true for the ops that run a map SETUP script (`reloadmap`, + -- `reloadmapafterbattle`), false for `refreshmap`, which is only + -- LoadOverworldTilemapAndAttrmapPals / ApplyTilemap / UpdateSprites and + -- runs no setup script at all (engine/overworld/scripting.asm:2044). Only + -- the setup arm carries the music row, and it runs BEFORE the deferral + -- below so a reload mid-scene still consumes wDontPlayMapMusicOnReload. + reloadMap = function(setup) + if setup then + self:forceMapMusic() + -- Script_reloadmap re-enters through MAPSTATUS_ENTER (engine/overworld/ + -- scripting.asm:1108-1116), so a wild battle re-arms EnterMap's cooldown. + self.wildCooldown = 5 + end + -- MapSetupScript_ReloadMap (data/maps/setup_scripts.asm:124) has NO + -- LoadMapObjects: `reloadmap` / `reloadmapafterbattle` reload blocks, + -- graphics, palettes and music and leave the object structs standing. + -- So a flag a script set BEFORE the battle must not cull anybody + -- mid-scene: AzaleaTownRivalBattleScript sets EVENT_RIVAL_AZALEA_TOWN at + -- maps/AzaleaTown.asm:57, well before `startbattle`, and only + -- `disappear`s the rival after the after-battle text and his exit walk. + -- Same deferral onFlagsChanged uses above, for the same reason. + if self:scriptRunning() then + self.peopleDirty = true + return + end + self:rebuildPeople({ seamless = true }) + end, + -- `warp NONE, 0, 0`, Script_warp's own group-0 arm. A different thing + -- from `reloadmap` above: MapSetupScript_BadWarp carries HandleNewMap and + -- LoadMapObjects, MapSetupScript_ReloadMap carries neither, and it is + -- precisely the callbacks in HandleNewMap that the bedroom PC's warp is + -- there to re-run. + badWarp = function() self:reloadMapBadWarp("bad_warp") end, + encounterMusic = function(class) + self:playTrainerEncounterMusic(class) + end, + showEmote = function(emote, object, frames) + self:showEmote(emote, object, frames) + end, + trainerApproach = function(onDone) self:trainerApproach(onDone) end, + faceObject = function(a, b) + -- faceobject PLAYER, LAST_TALKED: PLAYER is 0, LAST_TALKED is -1/$fe, + -- and only the player-turns-to-trainer case is ever scripted here. + if (a or 0) == 0 and self.player and self.trainerNpc then + local dx = self.trainerNpc.cellX - self.player.cellX + local dy = self.trainerNpc.cellY - self.player.cellY + if math.abs(dx) > math.abs(dy) then + self.player.facing = dx > 0 and "right" or "left" + elseif dy ~= 0 then + self.player.facing = dy > 0 and "down" or "up" + end + end + local _ = b + end, + openPc = function() self:openPc() end, + -- engine/menus/menu_2.asm's three balance boxes. Each is a `special` that + -- draws a box and RETURNS, and every one of the 23 calls in the game is + -- followed straight away by `loadmenu` -- the Game Corner prize counters + -- and the coin vendor, the vending machines -- so the box belongs to the + -- static menu that answers. Remembered here and handed to that screen + -- rather than drawn on the spot, because the menu is what owns the frame. + showCoins = function() self.scriptBalance = "coins" end, + showMoney = function(kind) self.scriptBalance = kind or "money" end, + openMart = function(martType, martId, onDone) + self:openMart(martType, martId, onDone) + end, + openMenu = function(header, style, onChoose) + self:openScriptMenu(header, style, onChoose) + end, + elevator = function(floors, onDone) + self:openElevator(floors, onDone) + end, + npcTrade = function(id, onDone) + self:openNpcTrade(id, onDone) + end, + -- Script_wildoff / Script_wildon (engine/overworld/scripting.asm), which + -- set and clear STATUSFLAGS_NO_WILD_ENCOUNTERS_F. Wired from this side + -- ahead of the VM opcodes so the gate is honoured the moment they land. + setWildEncounters = function(on) + self.noWildEncounters = not on + end, + healParty = function() self:healParty() end, + healAnim = function(animType, onDone) + self:startHealMachineAnim(animType, onDone) + end, + nameRival = function(onDone) self:nameRival(onDone) end, + warpToSpawn = function() self:warpToSpawn() end, + + -- ---- scene, clock, cartridge ------------------------------------------- + getMapScene = function(group, mapNum) + return self:mapSceneOf(group, mapNum) + end, + getTimeOfDay = function() return self:timeOfDayId() end, + gsVersion = function() return self:gsVersion() end, + + -- ---- ENGINE_* flags ---------------------------------------------------- + -- A DIFFERENT namespace from `setevent`'s wEventFlags: badges, the Pokegear + -- cards, ENGINE_POKEDEX and the Bug Contest timer live here, and none of + -- them decides whether an object is on the map, which is why neither hook + -- touches onFlagsChanged. + getEngineFlag = function(flag) return self:engineFlag(flag) end, + setEngineFlag = function(flag, value) self:setEngineFlag(flag, value) end, + + -- ---- vars -------------------------------------------------------------- + writeVar = function(varId, value) self:writeVar(varId, value) end, + -- `callasm` / `memcallasm`: raw GB code at bank:addr. The importer does + -- not resolve the pair against pokegold-symbols/pokegold.sym, so `label` is + -- nil and the ADDRESS is what dispatches -- which is why + -- src/script/gen2/CallAsm.lua keys on it. A nil back leaves wScriptVar + -- alone, the answer for every routine whose asm does not write it. + callAsm = function(label, bank, addr) + return self:callAsm(label, bank, addr) + end, + + -- ---- map objects ------------------------------------------------------- + appear = function(objectId) self:appearObject(objectId) end, + moveObject = function(objectId, cx, cy) + self:moveObject(objectId, cx, cy) + end, + variableSprite = function(slot, sprite) + self:setVariableSprite(slot, sprite) + end, + -- DescribeDecoration's read of the wDeco* byte its arm names, plus + -- GetDecorationName_c_de's wStringBuffer3 for the three arms that print it. + decorationSlot = function(descName) + local desc = Decorations.DESC_SLOTS[descName or ""] + if not desc then return nil end + local state = Decorations.state(self.game and self.game.save) + local decoId = state[desc.slot] or 0 + if decoId == 0 then return 0, nil end + return decoId, desc.named and Decorations.name(decoId) or nil + end, + -- LoadEmote is a VRAM preload; World:showEmote picks the sheet by index at + -- draw time, so there is nothing to warm up. The VM keeps its own + -- `loadedEmote` for the movement byte that carries no id. + + -- ---- map blocks and warps ---------------------------------------------- + changeBlock = function(bx, by, blockId) + self:changeBlock(bx, by, blockId) + end, + changeMapBlocks = function(bank, address) + return self:changeMapBlocks(bank, address) + end, + earthquake = function(displacement, frames) + self:earthquake(displacement, frames) + end, + warpTo = function(group, mapNum, cx, cy, facing) + self:warpTo(group, mapNum, cx, cy, facing) + end, + warpCheck = function() self:armWarpCheck() end, + warpSound = function() self:warpSound() end, + writeCmdQueue = function() return self:writeCmdQueue() end, + delCmdQueue = function(kind) return self:delCmdQueue(kind) end, + newLoadMap = function(method) self:newLoadMap(method) end, + setWarpMod = function(warpId, group, mapNum) + self:setWarpMod(warpId, group, mapNum) + end, + setBlackoutMap = function(group, mapNum) + self:setBlackoutMap(group, mapNum) + end, + + -- ---- encounters -------------------------------------------------------- + setSwarm = function(group, mapNum) self:setSwarm(group, mapNum) 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. + readMem = function(addr) return self:scriptReadMem(addr) end, + + -- ---- music ------------------------------------------------------------- + playMapMusic = function() self:playMapMusic() end, + fadeOutMusic = function(musicId, fade) self:fadeOutMusic(musicId, fade) end, + dontRestartMapMusic = function() self.dontRestartMusic = true end, + + -- ---- bag, money and coins ---------------------------------------------- + hasItem = function(itemIndex) return self:hasItem(itemIndex) end, + takeItem = function(itemIndex, qty) + return self:takeItem(itemIndex, qty) + end, + getMoney = function(account) return self:money(account) end, + setMoney = function(account, value) self:setMoney(account, value) end, + getCoins = function() return self:coins() end, + setCoins = function(value) self:setCoins(value) end, + + -- ---- party ------------------------------------------------------------- + hasPoke = function(speciesIndex) return self:hasPoke(speciesIndex) end, + giveEgg = function(speciesIndex, level) + return self:giveEgg(speciesIndex, level) + end, + -- `givepokemail` and `checkpokemail` are script COMMANDS ($ea, $eb), not + -- SpecialsPointers rows, so they belong on the VM's own hook table beside + -- giveegg the way Vm.new reads them. They are also listed in + -- `specials` below, which is where a handler that wanted the same seam + -- would find them; the command arm cannot see that sub-table. + givePokeMail = function(mail) return self:givePokeMail(mail) end, + checkPokeMail = function(mail, onDone) self:checkPokeMail(mail, onDone) end, + getLandmarkName = function() return self:landmarkName() end, + + -- ---- field events ------------------------------------------------------ + fruitTreeItem = function(tree) return self:fruitTreeItem(tree) end, + fruitTreeReset = function() return self:fruitTreeReset() end, + fruitTreePicked = function(tree) return self:fruitTreePicked(tree) end, + fruitTreePick = function(tree) self:fruitTreePick(tree) end, + + -- ---- phone ------------------------------------------------------------- + addPhoneNumber = function(contact) return self:addPhoneNumber(contact) end, + setSpecialCall = function(id) self:setSpecialCall(id) end, + getSpecialCall = function() return self:specialCall() end, + + -- ---- end of game ------------------------------------------------------- + -- Script_halloffame and Script_credits both end on ReturnFromCredits + -- (Script_endall + MAPSTATUS_DONE), so the VM returns out of the script the + -- moment either hook is present and neither callback resumes anything the + -- script still needs. They are here rather than in `specials` because both + -- really are script COMMANDS ($9f, $a0), not SpecialsPointers rows. + hallOfFame = function(onDone) self:hallOfFame(onDone) end, + credits = function(onDone) self:credits(onDone) end, + + -- ---- the specials ------------------------------------------------------ + -- Everything under src/script/gen2/Specials.lua that has to touch the + -- world reaches it through this ONE sub-table rather than through a + -- hundred more `xFn` fields on the VM: a special is an independent routine + -- and the table is its whole surface, so a handler stays a description of + -- the cart routine and the World keeps its own seams. + specials = self:specialHooks(), + }) + + -- The readmem / writemem bytes a script owns outright (the Goldenrod + -- underground switches, wMooMooBerries) ride the save under `scriptMem`, so + -- hand them back before any script runs. Without this the switch room and + -- the barn silently reset every time the game is reloaded. + local savedMem = self.game and self.game.save and self.game.save.scriptMem + if savedMem then self.vm:restoreMem(savedMem) end + + -- Where to start: a restored save's own position, else SPAWN_HOME. + local startMap, startX, startY, startFacing = + START_MAP, START_X, START_Y, START_FACING + local spawn = self.landmarks and self.landmarks.spawns + and self.landmarks.spawns[SPAWN_HOME] + if spawn and spawn.map and maps[spawn.map] then + startMap, startX, startY = spawn.map, spawn.x, spawn.y + end + local saved = self.game and self.game.save and self.game.save.position + -- Which map setup script this load is (engine/menus/intro_menu.asm): a file + -- with a recorded position is CONTINUE, a New Game is the SPAWN_HOME warp. + -- setMap reads it back to decide whether HandleNewMap's temporary-flag reset + -- runs. + local isContinue = false + if saved and saved.map and maps[saved.map] then + startMap, startX, startY = saved.map, saved.x, saved.y + startFacing = saved.facing or startFacing + isContinue = true + end + -- `farcall JumpRoamMons`, three lines above that same read: EVERY load of a + -- save scatters the three beasts to random roam maps before the map comes + -- back, which is what makes re-finding one the price of reloading after a + -- failed catch. The map the jump avoids is the one the save was written on, + -- so this runs while startMap is still the SAVED position and not the + -- post-credits spawn below. A file with no InitRoamMons behind it has no + -- save.roamers and Roamers.jumpAll leaves it that way. + self:roamMonsOnContinue(startMap) + -- Continue (engine/menus/intro_menu.asm): `ld a, [wSpawnAfterChampion]` is + -- read BEFORE the saved position is honoured, and a pending value replaces + -- it outright -- .SpawnAfterE4 / SpawnAfterRed write wDefaultSpawnpoint and + -- enter through PostCreditsSpawn's MAPSETUP_WARP instead of + -- MAPSETUP_CONTINUE. So the champion whose induction saved them standing + -- in the Hall of Fame continues in New Bark Town, not in a room whose only + -- exit is sealed. + local post = self:consumePostGameSpawn() + if post then + startMap, startX, startY, startFacing = post.map, post.x, post.y, "down" + isContinue = false + end + + if not maps[startMap] then + self.status = Strings("%s is missing.\nRe-import the Gold ROM.", + tostring(startMap)) + return false + end + local ok, err = pcall(function() + self:setMap(startMap, startX, startY, startFacing, + { continue = isContinue }) + end) + if not ok then + self.status = Strings("Failed to boot %s:\n%s", + tostring(startMap), tostring(err)) + return false + end + return self.map ~= nil +end + +-- Just the VM, not everything World:busy covers: a deferred object rebuild +-- has to wait for the SCRIPT, not for the text box that is showing its line. +function World:scriptRunning() + return (self.vm and self.vm:running()) and true or false +end + +function World:busy() + return (self.vm and self.vm:running()) + -- The map setup script is a blocking call inside the cart's overworld loop + -- (RunMapSetupScript, with the fades and the load inside it), so nothing + -- else may run while it does -- least of all a step from a direction the + -- player is still holding from before the warp. + or self.mapSetup ~= nil + or self.textbox ~= nil + or self.moveState ~= nil + or self.choicebox ~= nil + -- The rod cast and the tree shake are frame counters with no text box up + -- for part of their run; on the cart they are script commands, so the + -- world is frozen for them too and the player cannot walk out from under + -- the animation. + or self.fishing ~= nil + or self.headbutt ~= nil + -- Same again for a field move's tail: the surf step, the STRENGTH pause + -- and the waterfall climb are all applymovement / pause commands inside a + -- queued script, so nothing else may run under them. + or self.fieldMove ~= nil +end + +-- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput, +-- and OWPlayerInput is only reached from PlayerEvents, which returns straight +-- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates +-- sit above it even with no script up: PlayerMovement answering +-- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477), +-- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are +-- read NOWHERE else in the overworld, so a press that arrives while any of +-- those hold is not queued or deferred, it is never read at all. +function World:acceptsMenuInput() + if self.battleActive or self:busy() then return false end + if self.player and self.player.moving then return false end + -- The same latch pair World:step's slide uses: a latched direction on an ice + -- tile is CheckStandingOnIce's carry. + if self.turningDirection and Permissions.isIce(self:playerCollision()) then + return false + end + return true +end + +function World:mapIdByGroupMap(group, mapNum) + if not self.maps then return nil end + for id, def in pairs(self.maps) do + if type(def) == "table" and def.group == group and def.map == mapNum then + return id + end + end + return nil +end + +function World:scene() + if not self.map then return 0 end + return self.mapScenes[self.map.id] or 0 +end + +-- ---- the script VM's world hooks ------------------------------------------- +-- +-- Everything from here to World:specialHooks is one script command or one +-- special reaching into the world. They are gathered rather than scattered +-- because they share one property: each is the WORLD half of a routine whose +-- other half is transcribed in src/script/gen2/Vm.lua or Specials.lua, and the +-- VM guards every call with `if self.xFn then`, so the interpreter stays +-- correct when a hook is missing and only stops being able to SHOW the result. + +-- GetMapSceneID (engine/overworld/scripting.asm): a map with no `scene_var` +-- row at all leaves de = 0 and Script_checkmapscene answers $ff, which is what +-- nil means here. A map that HAS scene scripts but has never been given a +-- scene is scene 0. +function World:mapSceneOf(group, mapNum) + local mapId = self:mapIdByGroupMap(group, mapNum) + if not mapId then return nil end + local def = self.maps and self.maps[mapId] + if not (def and def.sceneScripts) then return nil end + return self.mapScenes[mapId] or 0 +end + +-- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2, +-- DARKNESS_F 3. Palettes.daytimeFor has already resolved the clock and the +-- map's own PALETTE_* override into one of four names, so this is a rename +-- rather than a second clock. +local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 } + +function World:timeOfDayId() + return TIME_OF_DAY_ID[self.daytime or "DAY"] or 1 +end + +-- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the +-- same numbering os.date("%w") answers, so no remap. `clockDay` overrides the +-- host clock the way `clockHour` overrides the hour, so a driver can stand on +-- Route 29 on a Tuesday and see Tuscany. +function World:weekday() + if self.clockDay then return math.floor(self.clockDay) % 7 end + -- Through the base InitDayOfWeek stored, not off the host clock raw: Mom's + -- wheel is what decides which day the game is on (src/core/gen2/Clock.lua). + return Clock.weekday(self.game and self.game.save) +end + +-- hHours, which VAR_HOUR reads straight off: RTC hour 0..23. `clockHour` +-- overrides the host clock the same way it does for the daytime palette. +function World:hour() + if self.clockHour then return math.floor(self.clockHour) % 24 end + -- CalcNSecsHoursDaysSince reads the RTC through wStartHour / wStartMinute, + -- the base InitClock wrote when the player answered Oak; a save from before + -- that screen existed has no base and reads the host clock straight through. + return Clock.hour(self.game and self.game.save) +end + +-- hMinutes, the other half of the same read. The Pokegear clock card and the +-- DST confirmations are what want it. +function World:minute() + return Clock.minute(self.game and self.game.save) +end + +-- engine/overworld/variables.asm .VarActionTable, walked in order. readVar +-- and writevar/loadvar share the id space (GetVarAction resolves both), but +-- only the handful of ADDR_DE rows (VAR_BATTLETYPE, VAR_MOVEMENT) are ever +-- written back through writeVar/self.scriptVars; the rest are RETVAR_EXECUTE +-- or RETVAR_STRBUF2 rows that just read state the engine already owns. +function World:readVar(varId) + if varId == VAR_FACING and self.player then + return FACING_ID[self.player.facing] or 0 + end + if varId == VAR_WEEKDAY then return self:weekday() end + if varId == VAR_BATTLETYPE then return self.scriptVars[VAR_BATTLETYPE] or 0 end + local save = self.game and self.game.save + if varId == VAR_PARTYCOUNT then + return save and #(save.party or {}) or 0 + end + if varId == VAR_BATTLERESULT then + -- wBattleResult masked with ~BATTLERESULT_BITMASK (the box-full flag); + -- the port never sets that bit, so the stored value already matches. + return self.lastBattleResult or 0 + end + if varId == VAR_TIMEOFDAY then return self:timeOfDayId() end + if varId == VAR_DEXCAUGHT then + return countFlags(save and save.pokedex and save.pokedex.caught) + end + if varId == VAR_DEXSEEN then + return countFlags(save and save.pokedex and save.pokedex.seen) + end + if varId == VAR_BADGES then + -- wBadges is TWO bytes (Johto then Kanto); CountSetBits walks both. + local player = save and save.player + return countFlags(player and player.badges) + + countFlags(player and player.kantoBadges) + end + if varId == VAR_MOVEMENT then + return PLAYER_STATE_ID[self.playerState] or 0 + end + if varId == VAR_HOUR then return self:hour() end + if varId == VAR_MAPGROUP then + return (self.map and self.map.def and self.map.def.group) or 0 + end + if varId == VAR_MAPNUMBER then + return (self.map and self.map.def and self.map.def.map) or 0 + end + if varId == VAR_UNOWNCOUNT then + -- CountUnown walks wUnownDex, a list of the distinct Unown FORMS caught in + -- catching order. save.pokedex still only knows the SPECIES; the form list + -- is its own record (save.unownDex, src/core/gen2/Unown.lua), written by + -- the same two events the cart writes it on. + return Unown.count(save) + end + if varId == VAR_ENVIRONMENT then + return (self.map and self.map.def and self.map.def.environmentId) or 0 + end + if varId == VAR_BOXSPACE then + if not save then return 0 end + return Boxes.MONS_PER_BOX - Boxes.count(save, save.currentBox) + end + if varId == VAR_CONTESTMINUTES then + if not save then return 0 end + local minutes = BugContest.timeLeft(save) + return minutes + end + if varId == VAR_XCOORD then + return (self.player and self.player.cellX) or 0 + end + if varId == VAR_YCOORD then + return (self.player and self.player.cellY) or 0 + end + if varId == VAR_SPECIALPHONECALL then + return self:specialCall() + end + return 0 +end + +-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm +-- GS_VERSION). Only the Goldenrod prize counters and a handful of gift mons +-- read it, and the port has no Silver cache yet, so an unset version is Gold. +function World:gsVersion() + local save = self.game and self.game.save + local version = (save and save.version) or "gold" + return version == "silver" and 1 or 0 +end + +-- ENGINE_* flags (data/events/engine_flags.asm), the namespace `setflag` / +-- `clearflag` / `checkflag` write. Kept on the save under its own key rather +-- than merged into `events`, because the two tables index different arrays on +-- the cart (wEngineBuffer / wBadges / wPokegearFlags vs wEventFlags) and a +-- collision would have BADGE_ZEPHYR hide an NPC. +-- +-- It deliberately does NOT rebuild the map's people: no engine flag names an +-- object's MAPOBJECT_EVENT_FLAG. +function World:engineFlags() + local save = self.game and self.game.save + if not save then return self._engineFlags or {} end + save.engineFlags = save.engineFlags or {} + return save.engineFlags +end + +function World:engineFlag(flag) + if flag == nil then return false end + local save = self.game and self.game.save + -- ENGINE_BUG_CONTEST_TIMER is not a bit of its own here: it IS + -- save.bugContest.active, because that is what CheckTimeEvents polls and what + -- RandomEncounter branches on. Keeping a second copy in the flag table is + -- how the two would come apart -- the officer's `setflag` and the results + -- script's `clearflag` are the only writers, and both go through the pair + -- below. + if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + return BugContest.isActive(save) + end + -- Badges live in save.player.badges, not in the flag table: on the cart the + -- ENGINE_*BADGE ids ARE the bits of wJohtoBadges/wKantoBadges, so there is + -- only one store and everything that asks (field moves, VAR_BADGES, the + -- trainer card) has to see the same answer. See FieldMoves.BADGE_FLAG. + local badge = FieldMoves.BADGE_FLAG[flag] + if badge and save then + local player = save.player + local owned = player and player[badge.store] + return type(owned) == "table" and owned[badge.name] == true + 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, + -- DayCareStep and the deposit/withdraw routines write, so `checkflag` reads + -- the deposit state directly. Route34EggCheckCallback branches on all three + -- (maps/Route34.asm:21-49) to put the gramps in the yard and to un-hide the + -- two day-care mon objects; a second copy in save.engineFlags is exactly how + -- the yard stayed empty forever. + if save then + if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then + return Breeding.dayCare(save).hasEgg == true + elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON then + return (Breeding.side(save, "man") or {}).mon ~= nil + elseif flag == ENGINE_DAY_CARE_LADY_HAS_MON then + return (Breeding.side(save, "lady") or {}).mon ~= nil + end + end + return self:engineFlags()[flag] == true +end + +function World:setEngineFlag(flag, value) + if flag == nil then return end + local save = self.game and self.game.save + if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + -- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special + -- GiveParkBalls`, so starting here and starting again there is the cart's + -- own order and the second start is what puts the balls on the counter. + -- BugContestResultsScript's clearflag is the stop, and it deliberately + -- leaves the caught mon alone: CheckPartyFullAfterContest runs after it. + if value then BugContest.start(save) else BugContest.stop(save) end + return + end + local badge = FieldMoves.BADGE_FLAG[flag] + if badge and save then + save.player = save.player or {} + save.player[badge.store] = save.player[badge.store] or {} + save.player[badge.store][badge.name] = value and true or nil + 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 + -- DayCareManOutside already did `res DAYCAREMAN_HAS_EGG_F, [hl]` + -- (engine/events/daycare.asm:393), which is Breeding.collectEgg here. The + -- two HAS_MON bits belong to the deposit/withdraw routines, so a script + -- write to them would be a second store: swallow it. + if save then + if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then + Breeding.dayCare(save).hasEgg = value and true or false + return + elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON + or flag == ENGINE_DAY_CARE_LADY_HAS_MON then + return + end + end + local flags = self:engineFlags() + flags[flag] = value and true or nil +end + +-- Script_writevar / Script_loadvar. VAR_BATTLETYPE is the only slot anything +-- reads BACK out of scriptVars today, and startScriptedBattle is where it is +-- consumed. +-- +-- VAR_MOVEMENT is the exception, and it is not a stored value at all: its row +-- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR_MOVEMENT, +-- PLAYER_BIKE` changes the player's state outright. That is the whole of +-- Script_GetOnBike -- the `special UpdatePlayerSprite` after it only reloads +-- the sheet applyPlayerState has already picked. +function World:writeVar(varId, value) + if varId == nil then return end + self.scriptVars[varId] = value or 0 + if varId == VAR_MOVEMENT then + local state = PLAYER_STATE_BY_ID[value or 0] + if state then self:applyPlayerState(state) end + end +end + +function World:battleType() + return self.scriptVars[VAR_BATTLETYPE] or 0 +end + +-- Script_callasm / Script_memcallasm: a bank:address into raw GB code. The +-- pair is resolved against pokegold-symbols/pokegold.sym in +-- src/script/gen2/CallAsm.lua, which is where the routines themselves are +-- ported; a site that is not in its table answers nil, and so does a routine +-- whose asm writes no wScriptVar. Either way the VM leaves wScriptVar alone +-- rather than picking a branch at random for the `callasm` / `iffalse` pairs. +-- +-- Nothing in the cache dispatches through here. The thirty-eight rows that +-- used to carry one of the four opcodes all sat inside keys the extractor had +-- made out of three-byte `hiddenitem` bg_event operands, so their addresses +-- were noise, and a cache built since that decode landed has none at all. The +-- table is reached through CallAsm.run from the hand-ported engine flows +-- instead -- countStep's hatch and poison arms, and whiteOut. +function World:callAsm(label, bank, addr) + return CallAsm.dispatch(self, label, bank, addr) +end + +-- Script_appear: the mirror of World:disappearObject. Both halves have to +-- come off, or a disappear/appear pair is one-way: the event flag AND the +-- synthetic hide the flagless path wrote under the same key. +-- +-- The rebuild is immediate rather than deferred through peopleDirty, because +-- ApplyEventActionAppearDisappear respawns the object struct inside the +-- command -- unlike a plain `setevent`, which the cart only reads back on the +-- next map load. +function World:appearObject(objectId) + local index = (objectId or 0) - 1 + local def = self.map and self.map.def + local obj = def and def.objects and def.objects[index] + if not obj then return end + if obj.eventFlag and obj.eventFlag ~= 0xFFFF then + self.events:set(obj.eventFlag, false) + end + -- UnmaskObject (home/map.asm:1548) clears exactly ONE byte of wObjectMasks, + -- this object's, and NOTHING re-reads the event flag until the next + -- LoadObjectMasks at map load. Objects sharing one MAPOBJECT_EVENT_FLAG are + -- ordinary -- the three animated Burned Tower beasts all carry + -- EVENT_BURNED_TOWER_B1F_BEASTS_1 (maps/BurnedTowerB1F.asm:152) and + -- ReleaseTheBeasts `appear`s them one at a time (:27, :33, :39) -- so the + -- flag alone must not put the other two on the map with this one. + self:setObjectMask(obj, index, false) + -- Script_appear RESPAWNS the object struct out of the MAP object + -- (UnmaskCopyMapObjectStruct, home/map_objects.asm:309 -> CopyObjectStruct -> + -- CopyMapObjectToObjectStruct, engine/overworld/player_object.asm:207-215, + -- which re-seeds the struct's X/Y from MAPOBJECT_X_COORD/MAPOBJECT_Y_COORD), + -- so an `appear` takes the cell a preceding `moveobject` wrote. The pooled + -- NPC is the OLD struct: keeping it is what left Kurt standing at the well + -- entrance after `moveobject SLOWPOKEWELLB1F_KURT, 11, 6` and sent his + -- victory walk off from the wrong cell. Dropping it is the literal port of + -- the respawn -- pooledNpc rebuilds from the def on the next pass. + if self.npcPool then + self.npcPool[string.format("%s_obj_%d", self.map.id, obj.index or 0)] = nil + end + self:rebuildPeople({ seamless = true }) +end + +-- Script_moveobject: MAPOBJECT_X_COORD / MAPOBJECT_Y_COORD, in plain map +-- cells. It nearly always names an object that is still HIDDEN (the pairing is +-- `moveobject` then `appear`), so the def is written first and the live NPC +-- second: a rebuild that has not happened yet must still find the new cell. +function World:moveObject(objectId, cellX, cellY) + local index = (objectId or 0) - 1 + local def = self.map and self.map.def + local obj = def and def.objects and def.objects[index] + if not (obj and cellX and cellY) then return end + obj.x, obj.y = cellX, cellY + local npc = self:objectEntity(objectId) + if npc and npc ~= self.player then + npc.cellX, npc.cellY = cellX, cellY + npc.px, npc.py = cellX * 16, cellY * 16 + npc.moving = false + npc.progress = 0 + npc.targetX, npc.targetY = nil, nil + -- The anim path is anchored on where the object was placed, so a teleported + -- NPC that walks a radius has to take its home with it. + npc.homeX, npc.homeY = cellX, cellY + -- The `appear` beside it re-runs StepFunction_Reset, which re-reads the + -- object's own tile (engine/overworld/map_objects.asm:498-511, :196-208). + npc.inGrass = self:grassAt(cellX, cellY) + npc.grassShake = nil + end +end + +-- Every POOLED object whose `sprite` is the SPRITE_VARS byte for `slot`, handed +-- the sheet the slot now names -- `special LoadUsedSpritesGFX`, which is the +-- command that sits beside `variablesprite` at every one of its four call sites +-- (maps/Route36.asm:71, FuchsiaGym.asm:36 and :66, CopycatsHouse2F.asm:24). +-- +-- The pool is keyed `<mapId>_obj_<index>` and an NPC holds the SpriteRenderer it +-- was created with, so a pooled object otherwise keeps whatever sheet the slot +-- held when it was first built. On the cart that cannot happen: LoadUsedSpritesGFX +-- and the LoadMapObjects every map load runs both re-read wVariableSprites, and a +-- connected map's objects are not loaded at all until the seam crossing loads +-- them. This port keeps the neighbor strips' objects pooled as ghosts and +-- crosses a connection SEAMLESSLY (World:tryConnection -> setMap{ seamless = +-- true }, which keeps npcPool), so without this the pair of TWINS on Route 37 -- +-- both SPRITE_WEIRD_TREE, maps/Route37.asm:237-238 -- keep the SPRITE_SUDOWOODO +-- sheet they were pooled with while the player was still on Route 36, for the +-- whole rest of the visit. Walk north out of the Sudowoodo fight and Ann and +-- Anne are two Sudowoodo. +-- +-- The repaint is in place (NPC:setSpriteDef) and not a retire-and-rebuild. Two +-- of the four call sites run with the object standing in front of the player +-- mid-conversation -- LassAliceScript is `applymovement FUCHSIAGYM_FUCHSIA_GYM_1, +-- Movement_NinjaSpin / faceplayer / variablesprite / special LoadUsedSpritesGFX / +-- faceplayer` (FuchsiaGym.asm:61-66) -- and the cart touches no part of the +-- object struct there. A fresh NPC table would strand World.talkNpc, .trainerNpc, +-- .followState and any live moveState on an object no longer on the map, and +-- would drop the ninja back to her map-def cell, facing and unfrozen state +-- halfway through unmasking. +-- +-- An emptied slot is the one case that DOES retire: resolveSprite answers nil, +-- nothing can be drawn, and World:pooledNpc is the gate that keeps the object +-- off the map until the slot is filled again. +function World:repaintVariableSpritePool(slot) + if not self.npcPool then return end + local byte = SPRITE_VARS + slot + for key, npc in pairs(self.npcPool) do + if npc.def and npc.def.sprite == byte then + local name = self:resolveSprite(byte) + local spriteDef = type(name) == "table" and name + or (name and self.sprites and self.sprites[name]) + if spriteDef then + if npc:setSpriteDef(spriteDef) then self:applySpritePalette(npc) end + else + self.npcPool[key] = nil + end + end + end +end + +-- Script_variablesprite: wVariableSprites[slot] = sprite byte. Filling the +-- slot is what puts the Sudowoodo, the Copycat, the Olivine rival and the four +-- Fuchsia Gym Janines on the map at all -- their objects carry a NUMBER in +-- `sprite` ($f0..$fc) and World:pooledNpc finds no sheet for one until here. +-- +-- REFILLING it is the other half, and it is what Route 36 does: the slot holds +-- SPRITE_SUDOWOODO from InitializeEventsScript until the fight, and +-- WateredWeirdTreeScript's `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN` +-- (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. +function World:setVariableSprite(slot, spriteIndex) + if slot == nil then return end + self.variableSprites[slot] = spriteIndex + self:repaintVariableSpritePool(slot) + self:rebuildPeople({ seamless = true }) +end + +-- The other half of the above: an object whose `sprite` is a SPRITE_VARS byte +-- resolves through the slot table and constants.spriteOrder (1-based, because +-- sprite_constants.asm's block is `const_def 1`). An unfilled slot answers nil +-- and the object simply does not spawn, which is the cart's behaviour too. +-- InitializeEventsScript's `variablesprite` list, recovered from scripts.lua. +-- +-- Only needed for a cache written before the extractor learned to record them; +-- `initial_events.sprites` is the direct answer once one is rebuilt. The +-- script is identified by its content rather than by a symbol, which the +-- runtime does not have: it is the one whose `setevent` ids are exactly the +-- seed list this same file already trusts. +function World:findInitialSprites() + local wanted = {} + local count = 0 + for _, id in ipairs(self.initialEvents or {}) do + wanted[id] = true + count = count + 1 + end + if count == 0 or type(self.scripts) ~= "table" then return {} end + for key, list in pairs(self.scripts) do + if type(key) == "string" and type(list) == "table" then + local hits, sprites = 0, {} + for _, cmd in ipairs(list) do + if type(cmd) == "table" then + if cmd.op == "setevent" then + local id = cmd.event or (cmd.args and cmd.args[1]) + if id and wanted[id] then hits = hits + 1 end + elseif cmd.op == "variablesprite" then + local args = cmd.args or {} + local slot = cmd.slot or args[1] + local sprite = cmd.sprite or args[2] + if slot and sprite then + sprites[#sprites + 1] = { slot = slot, sprite = sprite } + end + end + end + end + if hits == count and #sprites > 0 then return sprites end + end + end + return {} +end + +-- GetMonSprite's .BreedMon1 / .BreedMon2 tail (engine/overworld/overworld.asm: +-- 279-305): both arms fall into .Mon, which is LoadOverworldMonIcon of the +-- deposited species. There is no sprites.lua row to name here -- the species +-- is whatever the player left in the day care -- so the def is built in the +-- shape RomExtractorGen2:extractMonSprites emits for every other +-- POKEMON_SPRITE row and World:pooledNpc takes it directly. +-- +-- Keyed on species and cached, which is safe because World.npcPool is keyed +-- `<mapId>_obj_<index>` only: a swap in the day care while the pool is warm +-- would otherwise leave the old icon standing. rebuildPeople empties the pool +-- on map entry, which is exactly when the cart reloads the sprite too +-- (LoadMapObjects), so there is nothing further to invalidate. +function World:breedmonSpriteDef(species) + if not species then return nil end + self.breedmonSprites = self.breedmonSprites or {} + local hit = self.breedmonSprites[species] + if hit ~= nil then return hit or nil end + local icons = self.game and self.game.data and self.game.data.gen2Icons + local iconId = icons and icons.species and icons.species[species] + local entry = iconId and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then + self.breedmonSprites[species] = false + return nil + end + local def = { + id = "SPRITE_DAY_CARE_MON", + image = entry.image, + frames = 1, + walker = false, + spriteType = "POKEMON_SPRITE", + palette = "PAL_OW_RED", + paletteId = 0, + species = species, + icon = iconId, + } + self.breedmonSprites[species] = def + return def +end + +function World:resolveSprite(sprite) + if type(sprite) ~= "number" then return sprite end + -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE_VARS range, so + -- they must never reach the wVariableSprites arm. .NoBreedmon answers sprite + -- 1 for an empty slot; nil is the honest port, because an empty slot leaves + -- the object's own event flag (EVENT_DAY_CARE_MON_1/2) set and + -- Route34EggCheckCallback only clears it once checkflag says a mon is there. + if sprite == SPRITE_DAY_CARE_MON_1 or sprite == SPRITE_DAY_CARE_MON_2 then + local save = self.game and self.game.save + local slot = save and Breeding.side(save, + sprite == SPRITE_DAY_CARE_MON_1 and "man" or "lady") + local mon = slot and slot.mon + return mon and self:breedmonSpriteDef(mon.species) or nil + end + if sprite < SPRITE_VARS then return nil end + local byte = self.variableSprites[sprite - SPRITE_VARS] + if not byte or byte == 0 then return nil end + local order = self.constants and self.constants.spriteOrder + return order and order[byte] or nil +end + +-- Script_changeblock. The VM has already halved the script's cell coords into +-- block coords; replaceBlock is the same buffer edit CUT and WHIRLPOOL make, +-- which means the change is undone by the next LoadMapAttributes (World:setMap +-- calls restoreBlocks) exactly as it is on the cart, and the shared maps.lua +-- table never keeps a script's edit across a New Game. +function World:changeBlock(blockX, blockY, blockId) + local map = self.map + if not (map and blockX and blockY and blockId) then return false end + if blockX < 0 or blockY < 0 or blockX >= map.width or blockY >= map.height then + return false + end + return self:replaceBlock(blockY * map.width + blockX + 1, blockId) +end + +-- Resolve a raw ROM blockdata pointer (bank + address, as a `dba` writes into +-- wMapBlocksBank / wMapBlocksPointer) to the blocks a cache actually holds. +-- Every map's attributes name the bank and address of its own blockdata, and +-- RomExtractorGen2 records that pair as def.blockdata, so a pointer is found by +-- the array it lands IN: `address` may sit part way through one, which is how +-- the cart names a sub-rectangle of a bigger map's data. Returns the source +-- block array and a 0-based offset into it, or nothing when no array covers the +-- address (an older cache, whose maps carry no `blockdata`, always answers +-- nothing rather than guessing). +function World:blockdataAt(bank, address) + if not (bank and address) then return nil end + for _, def in pairs(self.maps or {}) do + local bd = def.blockdata + if bd and bd.bank == bank and bd.address and def.blocks + and address >= bd.address and address < bd.address + #def.blocks then + return def.blocks, address - bd.address + end + end + return nil +end + +-- Script_changemapblocks: a `dba` into wMapBlocksBank / wMapBlocksPointer, then +-- ChangeMap and BufferScreen. ChangeMap refills the WHOLE overworld buffer +-- from a second copy of the blockdata -- wMapWidth bytes a row for wMapHeight +-- rows, read as one flat run off the pointer -- so the amount copied is decided +-- by the map standing on screen and not by whichever array the pointer names. +-- +-- The edits are recorded the way replaceBlock records a CUT so restoreBlocks +-- puts the map back on the next load, and BufferScreen is refreshMapImages: a +-- baked canvas is keyed by map and daytime and knows nothing about the blocks +-- it came from, so leaving it up would keep showing the map as it was first +-- baked. +-- +-- A pointer this cache cannot place is a no-op rather than a guess, and so is a +-- run that would read off the end of the source array (the cart would happily +-- read into whatever data follows it). Nothing in pokegold reaches the command +-- -- 0 hits across maps/ and engine/, it is a Crystal-era path -- so a script +-- that does is by definition one this port has never seen. +function World:changeMapBlocks(bank, address) + local map = self.map + local blocks = map and map.def and map.def.blocks + if not (map and blocks and map.width and map.height) then return false end + local src, offset = self:blockdataAt(bank, address) + if not src then return false end + local count = map.width * map.height + if offset + count > #src then return false end + -- Read the run out first: the pointer is allowed to name the loaded map's own + -- blockdata, and at a non-zero offset an in-place copy would overwrite the + -- bytes it is still reading. + local run = {} + for i = 1, count do run[i] = src[offset + i] end + local edits = self.blockEdits[map.id] + if not edits then + edits = {} + self.blockEdits[map.id] = edits + end + for i = 1, count do + if edits[i] == nil then edits[i] = blocks[i] end + blocks[i] = run[i] + end + map.blocks = blocks + self:refreshMapImages() + return true +end + +-- Script_earthquake -> ShakeScreen. ONE byte carries two numbers +-- (MovementFunction_ScreenShake .GetDurationAndField1e): the low six bits are +-- the duration in frames, and the top two pick an amplitude of 1 << bits, which +-- StepFunction_ScreenShake adds to and subtracts from wPlayerStepVectorY on +-- alternate frames. `earthquake 80` ($50) is therefore two pixels for sixteen +-- frames, not eighty of anything. +-- +-- This starts the shake and returns AT ONCE: the VM holds the script for the +-- frames itself, so blocking here would count them twice. +function World:earthquake(displacement, frames) + local byte = displacement or 0 + local amplitude = 2 ^ (math.floor(byte / 64) % 4) + self.shake = { left = frames or (byte % 64), amplitude = amplitude, phase = 0 } +end + +function World:updateShake() + local shake = self.shake + if not shake then return end + shake.left = shake.left - 1 + if shake.left <= 0 then + self.shake = nil + return + end + -- `.GetSign`: the offset flips with the parity of the frames left. + shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude +end + +-- Script_warp / Script_warpfacing: a raw destination CELL, distinct from the +-- warp_events World:takeWarp follows. `facing` is nil for `warp` and a +-- Movement direction for `warpfacing` (PLAYERSPRITESETUP_CUSTOM_FACING). A +-- group/map pair this cache cannot resolve is a silent no-op rather than a +-- crash, the same way Script_warp's own group-0 arm goes nowhere. +-- +-- Script_warp's own entry method is MAPSETUP_WARP, whose script opens on +-- DisableLCD rather than on a FadeOutToWhite: the screen goes at once and only +-- the way back in is a fade. A `warpfacing` byte is PLAYERSPRITESETUP_CUSTOM_ +-- FACING, which SpawnInCustomFacing applies INSTEAD of SpawnInFacingDown, so a +-- custom facing skips World:spawnFacing rather than being overridden by it. +function World:warpTo(group, mapNum, cellX, cellY, facing) + local mapId = self:mapIdByGroupMap(group, mapNum) + if not mapId then return false end + return self:warpToMapId(mapId, cellX, cellY, facing) +end + +-- The same warp addressed by map id instead of the cart's group/map pair. +-- Script code always has the pair; mod.world:warpTo (src/world/gen2/ +-- WorldAPI.lua) and anything else holding a maps[] key comes in here, so both +-- run one body and a warp from a mod is indistinguishable from a scripted one. +function World:warpToMapId(mapId, cellX, cellY, facing) + if not (mapId and cellX and cellY) then return false end + return self:runMapSetup(MAPSETUP_WARP, function() + local ok = self:setMap(mapId, cellX, cellY, + facing or (self.player and self.player.facing) or "down") + if ok and not facing then self:spawnFacing() end + return ok + end) +end + +-- Script_warp's group-0 arm: `warp NONE, 0, 0`. wDefaultSpawnpoint is +-- SPAWN_N_A, and EnterMapSpawnPoint leaves the map and the coordinates alone +-- when it reads that, so MAPSETUP_BADWARP is a full load of the map the player +-- is already standing on -- HandleNewMap, LoadBlockData and LoadMapObjects +-- included. That is what PlayersHousePCScript's `.Warp` is for: the bedroom's +-- decorations only move when the map is loaded again. +-- `reason` is the map.reloaded payload's own field and doubles as the emit +-- gate: src/world/gen2/WorldAPI.lua's invalidateMap calls this with NO reason +-- and raises the event itself with "invalidate", so passing one here is how an +-- engine-side reload says "nobody else is announcing this one". Without that +-- split a mod calling mod.world:invalidateMap would see the event twice. +function World:reloadMapBadWarp(reason) + local map = self.map + local p = self.player + if not (map and p) then return false end + local mapId = map.id + local cx, cy, facing = p.cellX, p.cellY, p.facing + local ok = self:runMapSetup(MAPSETUP_BADWARP, function() + return self:setMap(mapId, cx, cy, facing) + end) + if ok and reason then + Runtime.emit("map.reloaded", { mapId = mapId, reason = reason }) + end + return ok +end + +-- Script_warpcheck -> WarpCheck. It does NOT warp: it notices that the player +-- is standing on a warp tile and lets the overworld loop take it once the +-- script is done, which is why every use sits at the end of a scripted walk. +function World:armWarpCheck() + local p = self.player + if not (self.map and p) then return false end + local entry = self.map:warpAt(p.cellX, p.cellY) + if not entry then return false end + self.pendingWarp = entry.def + return true +end + +-- The drain, from World:step. Deliberately gated on the SCRIPT rather than on +-- World:busy: the text box that is still showing the last line belongs to the +-- script that armed this, and the cart takes the warp the moment the script +-- ends. +function World:takePendingWarp() + local warp = self.pendingWarp + if not warp then return false end + self.pendingWarp = nil + return self:takeWarp(warp) +end + +-- Script_warpsound -> GetWarpSFX (home/map.asm): which of three sounds a warp +-- makes is decided by the tile the player is STANDING on, not by the +-- destination. Looked up by name so a cache whose sfx table sits at other +-- indices still finds them. +local WARP_SFX_NAME = { + [SFX_ENTER_DOOR] = "Sfx_EnterDoor", + [SFX_WARP_TO] = "Sfx_WarpTo", + [SFX_EXIT_BUILDING] = "Sfx_ExitBuilding", +} + +-- Play an sfx by its pokegold LABEL, falling back to the index this cache +-- happened to have when the constant above was written. A repointed sfx table +-- would otherwise play whatever now sits at the old index. +function World:sfxIdNamed(want, fallbackId) + local audio = self.game and self.game.data and self.game.data.audio + local order = audio and audio.sfxOrder + local id = fallbackId + if order and want then + for i, name in ipairs(order) do + if name == want then id = i - 1 break end + end + end + return id +end + +function World:playSfxNamed(want, fallbackId) + self:playSfx(self:sfxIdNamed(want, fallbackId)) +end + +-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: +-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes +-- wCurItem's pocket into wItemAttributeValue, and rings SFX_GET_TM for the +-- TM/HM pocket, SFX_ITEM for every other one. It is the sound inside +-- GiveItemScript, so every `verbosegiveitem` runs through it -- Sage Li's +-- `verbosegiveitem HM_FLASH` and every gym leader's TM included, all of which +-- rang the ordinary item jingle while the item argument was thrown away. An +-- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX_ITEM. +function World:specialSound(itemIndex) + local id = itemIndex and self:itemIdByIndex(itemIndex) + local items = self.game and self.game.data and self.game.data.items + local def = id and items and items[id] + if def and def.pocket == "TM_HM" then + self:playSfxNamed("Sfx_GetTm", SFX_GET_TM) + else + self:playSfxNamed("Sfx_Item", SFX_ITEM) + end +end + +function World:warpSound() + local p = self.player + if not (self.map and p) then return end + local coll = self.map:cellCollision(p.cellX, p.cellY) + local id = SFX_EXIT_BUILDING + if coll == COLL_DOOR then + id = SFX_ENTER_DOOR + elseif coll == COLL_WARP_PANEL then + id = SFX_WARP_TO + end + self:playSfxNamed(WARP_SFX_NAME[id], id) +end + +-- Script_newloadmap: hMapEntryMethod, then MAPSTATUS_ENTER on the CURRENT map. +-- The port has one map load, so the reload itself is a setMap onto the cell the +-- player is already standing on, which is what puts the magnet train and a link +-- return back on their feet; the MAPSETUP_* byte picks which setup script the +-- load is wrapped in. +-- +-- It plays NO sound: Script_newloadmap is four lines and none of them is a +-- PlaySFX. Where the cart wants one it writes the separate `warpsound` command +-- in front (WarpToNewMapScript is exactly that pair), so inventing one here rang +-- a door bell over every scripted re-entry that has none. +-- +-- A load that a `warpcheck` armed goes to the DESTINATION, not back onto the +-- current cell. MapSetupScript_Train opens `mapsetup EnterMapWarp` / +-- `mapsetup GetWarpDestCoords` (data/maps/setup_scripts.asm), and EnterMapWarp +-- copies wNextWarp / wNextMapGroup / wNextMapNumber -- the triple CopyWarpData +-- wrote when warpcheck found the player on a warp tile -- into wWarpNumber and +-- the map pair. That pairing is the Magnet Train and nothing else: the two +-- station scripts are the only `warpcheck` in the game followed by a +-- `newloadmap` rather than by a bare `end`, so every other caller still +-- re-enters the map it is standing on. +-- +-- MapSetupScript_Train has no SpawnInFacingDown, unlike _Warp and _Door, so the +-- player steps off the train still facing the way they walked onto it. +-- +-- The arrival cell is the destination `warp_event`'s own cell and nothing else. +-- Both stations land the player in the train doorway at (11,5) while their +-- single `coord_event` sits at (11,6), which reads like an off-by-one until the +-- block data is checked: TILESET_TRAIN_STATION block $12 is `tilecoll WALL, +-- WALL, WALL, DOOR`, so (10,5), (12,5) and (11,4) are all wall and SOUTH is the +-- only step there is. Script_ArriveFromGoldenrod is reached by that one forced +-- step, the same way the cart reaches it -- EnterMap runs DisableEvents, and +-- CheckPlayerState only turns player events back on once a step finishes, so +-- no coord event can fire on the load's own frame however the spawn is placed. +-- Nudging the spawn onto the coord_event would also walk the player into the +-- officer: the arrival movement is `left left down down down down`, which ends +-- at (9,10) from (11,6) but at (9,9) from (11,5), and (9,9) is the boarding +-- gate the officer's own return movement ends on. +-- Locked by tests/gen2_magnet_train_test.lua. +function World:newLoadMap(method) + local p = self.player + if not (self.map and p) then return false end + local armed = self.pendingWarp + if armed then + self.pendingWarp = nil + local destMapId, destWarpNumber = self:resolveWarp(armed) + local dest = self.maps[destMapId] + local destWarp = dest and dest.warps and dest.warps[destWarpNumber] + if destWarp then + self.backupMapId = self.map.id + -- CopyWarpData ran when `warpcheck` found the player on the tile, so + -- this take carries the same wPrevWarp bookkeeping as a walked warp. + local prevMapId = self.map.id + local prevWarpIndex = self:warpIndexOf(armed) + return self:runMapSetup(method, function() + local ok = self:setMap(destMapId, destWarp.x, destWarp.y, p.facing) + if ok then + self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) + end + return ok + end) + end + end + return self:runMapSetup(method, function() + return self:setMap(self.map.id, p.cellX, p.cellY, p.facing) + end) +end + +-- Script_warpmod: wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber, the +-- triple that says where the game believes you came IN from. Elevator's +-- .FindCurrentFloor and the dig / escape-rope return are what read it back; +-- neither exists yet, so STORING it is the whole point. +function World:setWarpMod(warpId, group, mapNum) + local save = self.game and self.game.save + if not save then return end + save.warpMod = { + warp = warpId, + map = self:mapIdByGroupMap(group, mapNum), + group = group, mapNumber = mapNum, + } +end + +-- Script_blackoutmod: wLastSpawnMapGroup / wLastSpawnMapNumber. The S.S. Aqua +-- and Mr. Pokemon's house set it so that losing at sea does not respawn you +-- somewhere you cannot leave, so World:warpToSpawn has to prefer it over the +-- SPAWN_* landmark lookup, and it has to survive a save. +function World:setBlackoutMap(group, mapNum) + local save = self.game and self.game.save + if not save then return end + save.blackoutMap = self:mapIdByGroupMap(group, mapNum) +end + +-- Script_swarm -> StoreSwarmMapIndices, which FALLS THROUGH into SetSwarmFlag: +-- 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) + 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) +end + +-- Script_loadwildmon's other half: roll the CURRENT map's own table the way a +-- step would, and hand it to the startbattle that follows. nil is fine -- +-- startScriptedBattle answers "win" rather than hanging when there is no mon. +function World:rollWild() + local map = self.map + if not (map and self.encounters and self.player) then return nil end + -- Script_randomwildmon only clears wBattleScriptFlags; the pair `startbattle` + -- then fights is whatever last wrote wTempWildMonSpecies / wCurPartyLevel. + -- RockMonEncounter is the one routine in this port that writes it ahead of a + -- `randomwildmon`, and its mon comes from TREEMON_SET_ROCK rather than from + -- the map's grass list, so it is consumed here rather than rolled over. + local pending = self.tempWildMon + self.tempWildMon = nil + if pending then return pending end + local tables = self:wildTables() + local collision = map:cellCollision(self.player.cellX, self.player.cellY) + local onWater = FieldMoves.encounterTable(collision) == "water" + -- kind "script": a `randomwildmon` the VM asked for, not a step's roll. + local roll = self:rollEncounter("script", onWater and "water" or "grass", + tables, onWater and rollWaterVanilla or rollGrassVanilla) + if not roll then return nil end + -- The encounter tables name a species by ID; a `loadwildmon` pair is a + -- SPECIES INDEX, and startScriptedBattle reads the index, so the roll is + -- translated here rather than at the battle seam. + local pokemon = self.game and self.game.data and self.game.data.pokemon + local def = pokemon and pokemon[roll.species] + if not (def and def.index) then return nil end + return { species = def.index, level = roll.level } +end + +function World:playMapMusic() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime and self.map then + Music.playMap(data, self.map.id) + end +end + +-- RestartMapMusic, which replays wMapMusic -- and wMapMusic is MUSIC_BICYCLE +-- for as long as the player is riding (.GetOnBike writes it there). So a +-- battle, a hatch or a menu that owned the sound gives the BIKE theme back, +-- not the map's own song. +function World:restoreMapMusic() + local data = self.game and self.game.data + if not data then return end + if FieldMoves.isBiking(self.playerState) and self:playBikeMusic() then + return + end + Music.restoreMap(data) +end + +-- ForceMapMusic (engine/overworld/map_setup.asm:201), the music row every +-- MapSetupScript_ReloadMap ends on (data/maps/setup_scripts.asm:136), and +-- TryRestartMapMusic (home/audio.asm:366) under it. wDontPlayMapMusicOnReload +-- is consumed HERE, at the reload, not at the end of the battle in front of it: +-- every scripted battle is written `startbattle / dontrestartmapmusic / +-- reloadmap` (maps/CherrygroveCity.asm:124), so the flag is not even set yet +-- while the battle screen is closing. Set, the cart plays MUSIC_NONE, zeroes +-- wMapMusic and clears the flag; that zeroed wMapMusic is why the theme does +-- not creep back on the next restore either. +function World:forceMapMusic() + if self.dontRestartMusic then + self.dontRestartMusic = false + Music.setMapSong(nil) -- `xor a / ld [wMapMusic], a` + Music.stop() + return + end + self:restoreMapMusic() +end + +-- Script_musicfadeout: the ramp, and then the song underneath it. The VM has +-- already masked MUSIC_FADE_IN_F off the control byte, so `fade` is the number +-- of frames the ramp holds each volume step; a musicId of 0 (MUSIC_NONE) is a +-- fade to silence and queues nothing. +-- +-- Music.fadeOut steps rAUDVOL's level 7 -> 0 one notch every `control` frames +-- and stops the song at the bottom, so the queued label starts control * 7 +-- frames later. Counted here rather than polled, because Music keeps its ramp +-- state module-local. +function World:fadeOutMusic(musicId, fadeControl) + local control = math.max(1, fadeControl or 10) + Music.fadeOut(control) + local data = self.game and self.game.data + local audio = data and data.audio + local order = audio and audio.musicOrder + local name = order and order[(musicId or 0) + 1] + if name and name ~= "Music_Nothing" and audio.songs and audio.songs[name] then + self.pendingMusic = { name = name, left = control * 7 } + else + self.pendingMusic = nil + end +end + +-- The fade's tail, ticked from World:step: the queued song starts the frame the +-- ramp reaches the bottom, which is what makes a `musicfadeout` read as one +-- cross-fade rather than as a cut. +function World:updateMusicFade() + local pending = self.pendingMusic + if not pending then return end + pending.left = pending.left - 1 + if pending.left > 0 then return end + self.pendingMusic = nil + local data = self.game and self.game.data + if data then + Music.play(data, pending.name, true, { reason = "script_fadeout" }) + end +end + +-- `checkitem` and `takeitem`, over the same Bag the PACK reads. +function World:itemIdByIndex(itemIndex) + local items = self.game and self.game.data and self.game.data.items + return (itemByIndex(items, itemIndex)) +end + +function World:hasItem(itemIndex) + local save = self.game and self.game.save + local id = self:itemIdByIndex(itemIndex) + if not (save and id) then return false end + return (save.inventory and (save.inventory[id] or 0) > 0) or false +end + +function World:takeItem(itemIndex, qty) + local save = self.game and self.game.save + local id = self:itemIdByIndex(itemIndex) + if not (save and id) then return false end + save.inventory = save.inventory or {} + local have = save.inventory[id] or 0 + qty = qty or 1 + -- TossItem takes nothing at all when the pack holds fewer than asked. + if have < qty then return false end + local left = have - qty + save.inventory[id] = left > 0 and left or nil + return true +end + +-- YOUR_MONEY 0 / MOMS_MONEY 1 (constants/script_constants.asm). The VM does +-- the 0..999999 clamp; this is only where the number lives. +function World:money(account) + local save = self.game and self.game.save + if not save then return 0 end + if (account or 0) == 1 then + return (save.mom and save.mom.savedMoney) or 0 + end + return (save.player and save.player.money) or 0 +end + +function World:setMoney(account, value) + local save = self.game and self.game.save + if not save then return end + if (account or 0) == 1 then + save.mom = save.mom or {} + save.mom.savedMoney = value or 0 + return + end + save.player = save.player or {} + save.player.money = value or 0 +end + +function World:coins() + local save = self.game and self.game.save + return (save and save.player and save.player.coins) or 0 +end + +function World:setCoins(value) + local save = self.game and self.game.save + if not save then return end + save.player = save.player or {} + save.player.coins = value or 0 +end + +-- `checkpoke`: the PARTY only. CheckPartyOrBoxMon is a different routine and +-- no script calls it. +function World:hasPoke(speciesIndex) + local save = self.game and self.game.save + local id = speciesByIndex( + self.game and self.game.data and self.game.data.pokemon, speciesIndex) + if not (save and id) then return false end + for _, mon in ipairs(save.party or {}) do + if mon.species == id then return true end + end + return false +end + +-- `giveegg`: the same builder every other party member goes through, marked +-- and counted down the way src/core/gen2/Breeding.lua marks a Day-Care egg, so +-- the ODD_EGG and the Togepi egg hatch through DoEggStep like any other. +-- False when the party is full; the VM turns true into wScriptVar = 2. +function World:giveEgg(speciesIndex, level) + local Breeding = require("src.core.gen2.Breeding") + local data = self.game and self.game.data + local save = self.game and self.game.save + if not (data and save) then return false end + save.party = save.party or {} + if #save.party >= Breeding.PARTY_SIZE then return false end + local id, def = speciesByIndex(data.pokemon, speciesIndex) + if not id then return false end + local mon = Mon.new(data, id, level or Breeding.EGG_LEVEL) + if not mon then return false end + mon.isEgg = true + -- `ld de, String_Egg / call CopyName2`: the slot's nickname IS "EGG", and the + -- box list prints it verbatim (engine/pokemon/move_mon.asm:1193-1194, :1220). + mon.nickname = Breeding.EGG_NAME + -- DayCare_InitBreeding's `ld [hl], EGG_STEPS`: the counter is the species' + -- own eggSteps in 256-step cycles, and DayCare_GiveEgg zeroes the HP. + mon.eggSteps = (def and def.eggSteps) or 0 + mon.hp = 0 + -- GiveEgg goes through TryAddMonToParty too (move_mon.asm:1121-1139). + Mon.stampOT(save, mon) + save.party[#save.party + 1] = mon + return true +end + +-- `landmarktotext`: the town-map name of the map the player is on, newline and +-- all (landmarks.lua keeps the cart's own two-line names). +function World:landmarkName() + local id = self:currentLandmarkId() + local entry = id and self.landmarks and self.landmarks.landmarks + and self.landmarks.landmarks[id] + return (entry and entry.name) or nil +end + +-- The fruit trees. data/items/fruit_trees.asm (FruitTreeItems, one byte per +-- FRUITTREE_*) is NOT extracted -- nothing in the ROM's bytecode points at it, +-- the same reason FruitTreeScript itself had to be transcribed into the VM -- +-- so the table lives in src/core/gen2/Apricorns.lua beside the seven apricorn +-- trees it feeds, and this is the half that turns its item ids into the +-- indices the script side speaks in. +-- +-- Which trees have been picked is per-day state on the save, and it is +-- Apricorns.checkDailyResetTimer (World:checkTimeEvents) that clears +-- ENGINE_ALL_FRUIT_TREES overnight so TryResetFruitTrees will refill them. +function World:fruitTreeItem(treeId) + -- FRUITTREE_* is 1-based (`const_def 1`), which is also how a Lua array + -- indexes, so the id needs no shift: GetCurTreeFruit's `dec a` is the cart + -- converting the same id into a 0-based offset. + local id = Apricorns.treeFruit(treeId) + local items = self.game and self.game.data and self.game.data.items + local def = id and items and items[id] + return (def and def.index) or 0 +end + +-- callasm TryResetFruitTrees, at the top of FruitTreeScript: the FIRST tree +-- examined after the daily rollover refills every tree in the game at once. +function World:fruitTreeReset() + local save = self.game and self.game.save + if not save then return false end + return Apricorns.tryResetFruitTrees(save) +end + +function World:fruitTreePicked(treeId) + local save = self.game and self.game.save + return save and Apricorns.treePicked(save, treeId) or false +end + +function World:fruitTreePick(treeId) + local save = self.game and self.game.save + if not (save and treeId) then return end + Apricorns.pickTree(save, treeId) +end + +-- `askforphonenumber`. Phone.addContact is the whole rule: _CheckCellNum runs +-- first and returns the same carry for "already stored" as for "full", so both +-- refusals come back as false here. +function World:addPhoneNumber(contact) + local Phone = require("src.core.gen2.Phone") + local save = self.game and self.game.save + if not (save and contact) then return false end + if Phone.hasContact(save, contact) then return false end + return Phone.addContact(save, contact) and true or false +end + +-- `specialphonecall` / `checkphonecall`: wSpecialPhoneCallID, which the next +-- Phone.checkSpecialCall consumes. +function World:setSpecialCall(id) + local Phone = require("src.core.gen2.Phone") + local save = self.game and self.game.save + if not save then return end + if (id or 0) == Phone.SPECIALCALL_NONE then + Phone.clearSpecialCall(save) + else + Phone.queueSpecialCall(save, id) + end +end + +function World:specialCall() + local Phone = require("src.core.gen2.Phone") + local save = self.game and self.game.save + return save and Phone.specialCallVar(save) or 0 +end + +-- ---- the specials' world half ---------------------------------------------- +-- +-- data/events/special_pointers.asm is 112 routines and most of them are one +-- reach into the world apiece, so they share one table rather than one `xFn` +-- field on the VM each. src/script/gen2/Specials.lua is the other half: every +-- handler there is the cart routine, and everything it cannot do without the +-- game (a screen, the save, the party) is a call into here. + +-- FadeToMenu / ExitAllMenus wrap a dozen specials. A screen id that is not +-- registered in src/ui/Screens.lua yet must not take the game down with it, so +-- every push goes through here: an unknown id is a no-op that answers false, +-- and the special's own degrade takes over. +function World:pushScreen(id, opts) + local game = self.game + if not (game and game.stack) then return false end + local ok = pcall(Screens.push, game, id, opts) + return ok +end + +-- SelectMonFromParty (engine/pokemon/party_menu.asm), the one blocking piece a +-- dozen specials share: BillsGrandfather, the two haircut brothers, Daisy, +-- CheckMagikarpLength, ReturnShuckie and the move deleter all open the same +-- list and all read wCurPartySpecies out of it. `onDone(index, mon)` with nil +-- for the B press, exactly the shape the ASM's carry flag has. +function World:selectPartyMon(prompt, onDone) + local game = self.game + local save = game and game.save + if not (game and game.stack and save and save.party and #save.party > 0) then + if onDone then onDone(nil) end + return false + end + local finished = false + local function finish(index, mon) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(index, mon) end + end + local ok = self:pushScreen("Gen2PartyMenu", { + save = save, + party = save.party, + prompt = prompt or "choose", + onChoose = function(index, mon) finish(index, mon) end, + onCancel = function() finish(nil, nil) end, + }) + if not ok then + if onDone then onDone(nil) end + return false + end + return true +end + +-- GivePokeMail (engine/pokemon/mail.asm), the `givepokemail` opcode's body: +-- the letter behind the script's pointer is hung on the LAST party member, the +-- one the `givepoke` immediately before it just added. One call site in the +-- whole game -- RandyScript in maps/Route35GoldenrodGate.asm, KENYA's +-- FLOWER_MAIL -- and the extractor resolves the operand into +-- { item = "FLOWER_MAIL", message = "..." } (RomExtractorGen2). +-- +-- A cache built before that resolution leaves the raw pointer word instead; +-- there is no way to invent the item or the message from a number, so nothing +-- is given rather than a blank letter being hung on the mon. +function World:givePokeMail(mail) + local save = self.game and self.game.save + if not (save and type(mail) == "table" and mail.item) then return false end + return Mail.give(save, mail.item, mail.message) +end + +-- CheckPokeMail (engine/pokemon/mail.asm): the party list, then the five-way +-- answer, then -- on POKEMAIL_CORRECT only -- the mon leaving the party for +-- good. The rules are src/core/gen2/Mail.lua; this is the list and the +-- wScriptVar the VM parks on. +-- +-- The one call site is Route31MailRecipientScript, whose `ifequal` ladder +-- covers all five values, so answering the wrong one picks a random branch of +-- somebody's quest. Backing out of the list is REFUSED and an unresolved +-- expected message is WRONG_MAIL: both leave the mon exactly where it was. +function World:checkPokeMail(mail, onDone) + local save = self.game and self.game.save + local expected = (type(mail) == "table") and mail.message or nil + -- selectPartyMon answers onDone(nil) AND returns false when it cannot open a + -- list at all, so the resume is guarded: a second one would drive the script + -- coroutine twice off one opcode. + local answered = false + local function answer(value) + if answered then return end + answered = true + if onDone then onDone(value) end + end + if not save then return answer(Mail.POKEMAIL_REFUSED) end + local ok = self:selectPartyMon("choose", function(index) + answer(Mail.checkPokeMail(save, index, expected)) + end) + if not ok then answer(Mail.POKEMAIL_REFUSED) end +end + +-- The Day-Care conversation, all three doors of it. The model is +-- src/core/gen2/Breeding.lua and the screen is src/ui/gen2/DayCareMenu.lua; +-- this is only the push, and the scriptVar the outside man's branch answers +-- with (TRUE = "no room, come back") rides back through onDone. +function World:dayCare(side, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone(0) end + return false + end + local finished = false + local function finish(scriptVar) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(scriptVar or 0) end + end + local ok = self:pushScreen("Gen2DayCareMenu", { + save = game.save, + side = side, + -- text.lua carries the whole Day-Care block, seeded by name. + text = self.text, + onClose = finish, + }) + if not ok then + if onDone then onDone(0) end + return false + end + return true +end + +-- ChooseMoveToDelete (engine/pokemon/mon_menu.asm), the move-list half of the +-- Blackthorn move deleter (src/script/gen2/Specials.lua H.MoveDeletion). The +-- caller has already refused a mon with only one move, so this only has to +-- put the list up and hand back a 1-based slot or nil for B. +function World:chooseMoveToDelete(mon, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone(nil) end + return false + end + local finished = false + local function finish(index) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(index) end + end + local ok = self:pushScreen("Gen2MoveDeleter", { + mon = mon, + moves = game.data and game.data.moves, + onChoose = function(index) finish(index) end, + onCancel = function() finish(nil) end, + }) + if not ok then + if onDone then onDone(nil) end + return false + end + return true +end + +-- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu / Mom_WithdrawDepositMenuJoypad +-- (engine/events/mom.asm), the six-digit money keypad BankOfMom's GET and SAVE +-- both put up. `kind` is "deposit" or "withdraw", only for the screen's own +-- DEPOSIT@/WITHDRAW@ label; `onDone(amount)` gets the typed 0..999999 or nil +-- for B, and H.BankOfMom (src/script/gen2/Specials.lua) is what turns that +-- into an actual GiveMoney/TakeMoney pair against the two accounts. +function World:bankOfMomAmount(kind, saved, held, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone(nil) end + return false + end + local finished = false + local function finish(amount) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(amount) end + end + local ok = self:pushScreen("Gen2BankOfMom", { + kind = kind, + saved = saved, + held = held, + onDone = function(amount) finish(amount) end, + onCancel = function() finish(nil) end, + }) + if not ok then + if onDone then onDone(nil) end + return false + end + return true +end + +-- The rename half of the Goldenrod NAME RATER (engine/events/name_rater.asm, +-- src/script/gen2/Specials.lua H.NameRater). Same keyboard World:nameHatchling +-- opens for a freshly-hatched egg, but the header is the species name loaded +-- by GetBaseData (`ld b, NAME_MON / ld de, wStringBuffer2 / farcall +-- _NamingScreen`), not a fixed prompt -- BoxMenu:askNickname's screen is the +-- same shape for the same reason. `onDone(name)` gets the typed string or nil +-- for B; IsNewNameEmpty/CompareNewToOld both live in the special, not here. +-- SetDayOfWeek's wheel (src/ui/gen2/InitClock.lua day mode). `onDone(day)` is +-- the special's own resume, the same shape World:nameRival hands H.NameRival: +-- the screen's close is what starts the script again. +function World:setDayOfWeek(onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone(nil) end + return false + end + local ok = self:pushScreen("Gen2InitClock", { + mode = "day", + save = game.save, + onDone = function(day) + game.stack:pop() + if onDone then onDone(day) end + end, + }) + if not ok and onDone then onDone(nil) end + return ok +end + +-- `opts.blank` is the fresh-catch entry: GiveANickname_YesNo's keyboard opens +-- empty (wMonOrItemNameBuffer holds the species name for InitNickname to copy +-- back, it is not typed into the field), where the Name Rater's opens on the +-- name it is replacing. +function World:renameMon(mon, onDone, opts) + local game = self.game + if not (game and game.stack and mon) then + if onDone then onDone(nil) end + return false + end + local finished = false + local function finish(name) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(name) end + end + local ok = self:pushScreen("Gen2NamingScreen", { + type = "nickname", + monName = mon.name or mon.species, + initial = (opts and opts.blank) and "" + or (mon.nickname or mon.name or mon.species or ""), + onDone = function(name) finish(name) end, + onCancel = function() finish(nil) end, + }) + if not ok then + if onDone then onDone(nil) end + return false + end + return true +end + +-- The #DEX-completion diploma (engine/events/diploma.asm, src/ui/gen2/ +-- Diploma.lua, src/script/gen2/Specials.lua H.Diploma). `special Diploma` +-- itself never writes wScriptVar, so `onDone` here takes no argument -- it is +-- only the "the player pressed A or B, close and carry on" signal the +-- coroutine in Specials.block is parked on. +function World:showDiploma(onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + end + local ok = self:pushScreen("Gen2Diploma", { + playerName = game.save and game.save.player and game.save.player.name, + onClose = finish, + }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- The Magnet Train ride (engine/events/magnet_train.asm, src/core/gen2/ +-- MagnetTrain.lua, src/ui/gen2/MagnetTrainRide.lua, src/script/gen2/ +-- Specials.lua H.MagnetTrain). `special MagnetTrain` never writes wScriptVar +-- -- it READS the one the officer's `setval` left there -- so `onDone` takes +-- no argument and is only the "the cutscene reached JUMPTABLE_EXIT" signal the +-- coroutine in Specials.block is parked on. The `warpcheck` and the +-- `newloadmap MAPSETUP_TRAIN` that follow it are the script's, not this. +function World:magnetTrain(toGoldenrod, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + end + local ok = self:pushScreen("Gen2MagnetTrainRide", { + toGoldenrod = toGoldenrod, + onDone = finish, + }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- The Cianwood photo studio's portrait card (engine/printer/print_party.asm +-- PrintPartyMonPage1, src/ui/gen2/PhotoStudio.lua, src/script/gen2/ +-- Specials.lua H.PhotoStudio). `onDone` takes no argument, the same as +-- showDiploma above -- it is only the "player pressed A or B, close and +-- carry on" signal Specials.block is parked on. +function World:showPhotoStudio(mon, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + end + local ok = self:pushScreen("Gen2PhotoStudio", { + mon = mon, + playerName = game.save and game.save.player and game.save.player.name, + onClose = finish, + }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- The ALPH RUINS STAMP viewer (engine/events/print_unown.asm _UnownPrinter, +-- src/ui/gen2/UnownPrinter.lua, src/script/gen2/Specials.lua +-- H.UnownPrinter). Same shape as showPhotoStudio above, and for the same +-- reason: the screen is the whole special, and `onDone` is only the "B was +-- pressed, close and carry on" signal Specials.block is parked on. +function World:showUnownPrinter(onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + end + local ok = self:pushScreen("Gen2UnownPrinter", { onClose = finish }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- PostCreditsSpawn (engine/menus/intro_menu.asm): read wSpawnAfterChampion, +-- clear it, and answer the spawn point CONTINUE warps to instead of the saved +-- position -- SPAWN_NEW_BARK after the Elite Four, SPAWN_MT_SILVER after Red. +-- HallOfFame.consumePostGameSpawn owns the byte and the mapping; this half +-- resolves the SPAWN_* row against the extracted landmarks table, and answers +-- nil both for an ordinary continue and for a cache whose spawn cannot be +-- found, which leaves the saved position in charge exactly as `xor a` does. +function World:consumePostGameSpawn() + local save = self.game and self.game.save + local spawnId = HallOfFame.consumePostGameSpawn(save) + if not spawnId then return nil end + local spawn = self.landmarks and self.landmarks.spawns + and self.landmarks.spawns[spawnId] + if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then + return nil + end + return spawn +end + +-- `halloffame` ($9f) -> Script_halloffame (engine/overworld/scripting.asm), +-- which stops the game timer and farcalls HallOfFame +-- (engine/events/halloffame.asm). That routine is TWO screens, not one: +-- +-- ld a, [wStatusFlags] ; the PRE-induction flags +-- push af +-- ...set STATUSFLAGS_HALL_OF_FAME_F, bump the count, SaveGameData, +-- GetHallOfFameParty, AddHallOfFameEntry... +-- call AnimateHallOfFame +-- pop af +-- jp Credits +-- +-- so the roster ceremony runs and then falls straight into the credits, and +-- the `a` Credits receives is the copy pushed BEFORE the bit was set. Credits +-- reads exactly that bit (`bit STATUSFLAGS_HALL_OF_FAME_F, b`) to decide +-- ALLOW_SKIPPING_CREDITS_F, which is why a first-time champion has to sit +-- through the roll and a repeat one can hold B: the answer has to be sampled +-- before the induction or every champion after the first would look like the +-- first. HallOfFame.induct returns that pre-value second for this reason. +-- +-- The save really does happen inside the ceremony (farcall SaveGameData), so +-- it goes through the same writer SaveMenu uses rather than waiting for the +-- player to save afterwards. +function World:hallOfFame(onDone) + local game = self.game + local save = game and game.save + if not (game and game.stack and save) then + if onDone then onDone() end + return false + end + + -- SaveGameData saves the whole of sPlayerData, not just the roster, so the + -- live world has to be folded in first: wEventFlags, the w<Map>SceneID block + -- and wPlayerState are read back by World:loadPlayerData, and a write that + -- skipped the snapshot would hand the next CONTINUE the flags of whenever + -- the player last stood in front of a SAVE menu. + local _, wasEntered = HallOfFame.induct(save, save.party, { + saveFn = function(data) + if game.snapshotSave then pcall(game.snapshotSave, game) end + pcall(Gen2Save.save, data) + end, + }) + + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + -- ReturnFromCredits (engine/overworld/scripting.asm) is Script_endall + -- plus MAPSTATUS_DONE: the script and the overworld loop both end. And + -- FinishContinueFunction (engine/menus/intro_menu.asm) answers anything + -- but SPAWN_RED in wSpawnAfterChampion with `jp Reset`, so the champion's + -- credits end on the title screen -- the induction's own SaveGameData is + -- already on disk with SPAWN_LANCE in it, and the next CONTINUE spawns at + -- New Bark Town through World:consumePostGameSpawn. + if game.returnToTitle then game:returnToTitle() end + end + + -- `pop af / jp Credits`: the roll follows the ceremony on the same call, and + -- only the credits' own end returns to the script. + local function toCredits() + game.stack:pop() + local ok = self:pushScreen("Gen2Credits", { + allowSkip = wasEntered, + onDone = finish, + }) + if not ok then finished = true if onDone then onDone() end end + end + + local ok = self:pushScreen("Gen2HallOfFame", { + save = save, + mode = "induct", + -- text.lua carries the three header strings the extractor seeds by name. + text = self.text, + onDone = toCredits, + }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- `credits` ($a0) -> Script_credits, a bare `farcall RedCredits`. RedCredits +-- is the post-Red roll and skips the ceremony entirely: it fades to white, +-- sets SPAWN_RED (not SPAWN_LANCE, which is the whole difference between the +-- two endings' CONTINUE) and reaches Credits with `ld a, [wStatusFlags]` read +-- LIVE. Anyone who has beaten Red has necessarily entered the Hall of Fame +-- already, so that live read is why this roll is always skippable while the +-- champion's first one is not. +function World:credits(onDone) + local game = self.game + local save = game and game.save + if not (game and game.stack) then + if onDone then onDone() end + return false + end + HallOfFame.markRedCredits(save) + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + -- FinishContinueFunction's .AfterRed (engine/menus/intro_menu.asm): + -- SPAWN_RED is the one wSpawnAfterChampion value that does not `jp + -- Reset`. SpawnAfterRed writes wDefaultSpawnpoint = SPAWN_MT_SILVER, + -- PostCreditsSpawn clears the byte, and the loop re-enters the overworld + -- through MAPSETUP_WARP -- play resumes outside Silver Cave, in session, + -- with no trip through the title screen. + local spawn = self:consumePostGameSpawn() + if spawn then + self:runMapSetup(MAPSETUP_WARP, function() + return self:setMap(spawn.map, spawn.x, spawn.y, "down") + end) + end + end + local ok = self:pushScreen("Gen2Credits", { + allowSkip = HallOfFame.hasEntered(save), + onDone = finish, + }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- The two Game Corner machines. Both are StartGameCornerGame, which is +-- CheckCoinsAndCoinCase and then the game itself; the coin-case refusal is +-- transcribed in Specials because it prints text the VM already knows how to +-- show, so this is only the push. +function World:gameCornerGame(kind, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + local id = (kind == "cardflip") and "Gen2CardFlip" or "Gen2SlotMachine" + local finished = false + local function finish() + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone() end + end + local ok = self:pushScreen(id, { save = game.save, onClose = finish }) + if not ok then + if onDone then onDone() end + return false + end + return true +end + +-- The Ruins of Alph sliding-panel puzzle. `special UnownPuzzle` is +-- FadeToMenu / _UnownPuzzle / `ld a, [wSolvedUnownPuzzle] / ld [wScriptVar], a` +-- / ExitAllMenus, so the only thing this owes the script is the screen and the +-- one byte it answers with. +-- +-- `puzzleId` is the UNOWNPUZZLE_* the chamber's `setval` parked in wScriptVar +-- just before the special, which is also what LoadUnownPuzzlePiecesGFX masks to +-- pick the picture. A push that cannot happen answers "not solved" rather than +-- leaving the script parked forever. +function World:unownPuzzle(puzzleId, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone(false) end + return false + end + local finished = false + local function finish(solved) + if finished then return end + finished = true + game.stack:pop() + if onDone then onDone(solved and true or false) end + end + local ok = self:pushScreen("Gen2UnownPuzzle", { + puzzle = puzzleId or 0, + save = game.save, + onClose = finish, + }) + if not ok then + if onDone then onDone(false) end + return false + end + return true +end + +-- SurfStartStep (engine/overworld/player_object.asm): the player goes into +-- PLAYER_SURF and takes one step forward onto the water. Called by the +-- special rather than by the field move, because Script_UsedSurf's `special +-- SurfStartStep` is what actually puts them on the Lapras. +-- SurfStartStep (engine/overworld/player_object.asm) is the `special` half of +-- the same three lines World:runSurf runs when SURF is chosen from the party +-- menu: the state change, the map's surfing theme, and one scripted step off +-- the bank. Script_UsedSurf calls it, so both routes have to land in the same +-- place -- if they did not, surfing from a script would leave the player +-- walking on water. +function World:surfStartStep(mon) + local p = self.player + if not p then return false end + self:applyPlayerState(FieldMoves.surfType(mon)) + local audio = self.game and self.game.data and self.game.data.audio + if audio and audio.runtime and self.map then + Music.playMap(self.game.data, self.map.id) + end + if p.scriptStep then p:scriptStep(p.facing) end + self.fieldMove = { phase = "step" } + return true +end + +-- The eight ROM-0 presentation specials (FadeOutToWhite .. UpdatePlayerSprite). +-- None of them is state: they are the fade, the palette reload and the sprite +-- refresh a scripted cutscene brackets itself with. The port has one map +-- image and rebuilds people from one list, so `sprites` and `palettes` are a +-- rebuild and a re-bake, and the fades are a flat overlay World:draw honours. +function World:screenFade(kind) + -- kind: "outWhite" | "outBlack" | "inWhite" | "inBlack" + if kind == "inWhite" or kind == "inBlack" then + self.fade, self.fadeLevel = nil, nil + return + end + self.fade = (kind == "outWhite") and "white" or "black" + self.fadeLevel = 1 +end + +-- RunMapSetupScript (engine/overworld/map_setup.asm): every map entry runs one +-- of the eleven MapSetupScripts, and the load itself sits in the MIDDLE of it. +-- The port loads a map in a single World:setMap call, so what is left of the +-- script is the pair of fades it is wrapped in -- and running the load between +-- them rather than instead of them is the whole difference between a door that +-- opens and a cut. +-- +-- The chain OWNS the frames it runs for: World:busy() is true throughout, which +-- is what stops a still-held direction from stepping the player on the far side +-- before the map's own deferred scene script gets to run. On the cart that +-- falls out of the setup script being a blocking call inside the overworld +-- loop; here it has to be said out loud. +function World:runMapSetup(method, load) + -- JumpRoamMons sits above the load in MapSetupScript_Teleport; UpdateRoamMons + -- below it in _Connection and _Train. Wrapping the load rather than editing + -- setMap keeps both on the right side of it, and keeps the roam walk where + -- the cart puts it -- in the setup SCRIPT, not in the map load. + self:roamMonsBeforeLoad(method) + local wrapped = function() + local ok = load() + self:roamMonsAfterLoad(method) + return ok + end + if MAPSETUP_NO_FADE[method] then return wrapped() end + if not MAPSETUP_FADE_OUT[method] then + -- MAPSETUP_WARP and friends open on DisableLCD: the screen simply goes, and + -- only the way back in is a fade. + local ok = wrapped() + self.fade, self.fadeLevel = "white", 1 + self.mapSetup = { phase = "in", step = FADE_STEPS, wait = FADE_STEP_FRAMES } + return ok + end + self.mapSetup = { + phase = "out", step = 0, wait = FADE_STEP_FRAMES, load = wrapped, + } + return true +end + +-- --------------------------------------------------------------------------- +-- The three roaming beasts (engine/overworld/wildmons.asm). +-- --------------------------------------------------------------------------- +-- +-- src/core/gen2/Roamers.lua is the whole model and the ONE writer of +-- save.roamers -- the walk, the encounter roll, the HP bank and the flee +-- tables. Everything here is a call site, because until now there were none: +-- `special InitRoamMons` put the three structs on the save when the Burned +-- Tower floor gave way and then nothing ever moved them, rolled for them or +-- banked them, so a beast sat on its starting route forever and could not be +-- met even there. +-- +-- The four sites are the four the cart has: two map setup commands, one gate at +-- the top of ChooseWildEncounter, and BattleEnd_HandleRoamMons. + +-- Random(n) for the roam walk. Injectable so a test and a driver can pin it; +-- nil means the ambient stream, which is what the game plays on. +function World:roamRandom() + return self.roamerRandom +end + +function World:roamMonsBeforeLoad(method) + if not MAPSETUP_ROAM_JUMP[method] then return false end + local save = self.game and self.game.save + if not (save and Roamers.list(save)) then return false end + -- The map the player is LEAVING: JumpRoamMons runs above the load. + return Roamers.jumpAll(save, self.map and self.map.id, self:roamRandom(), + self.encounters) +end + +-- The CONTINUE menu's own `farcall JumpRoamMons`, which is not a map setup +-- script at all: it fires once per load of a save file, from World:load, with +-- no map yet built -- so the map the scatter avoids has to be passed in. +function World:roamMonsOnContinue(playerMapId) + local save = self.game and self.game.save + if not (save and Roamers.list(save)) then return false end + return Roamers.jumpAll(save, playerMapId, self:roamRandom(), self.encounters) +end + +function World:roamMonsAfterLoad(method) + if not MAPSETUP_ROAM_UPDATE[method] then return false end + local save = self.game and self.game.save + if not (save and Roamers.list(save)) then return false end + -- The map the player has ARRIVED on: UpdateRoamMons is the script's tail. + return Roamers.update(save, self.map and self.map.id, self:roamRandom(), + self.encounters) +end + +-- BattleEnd_HandleRoamMons. A roaming battle banks the beast's HP and moves +-- it (or clears the slot if it was caught or beaten); ANY other wild battle +-- takes the `.not_roaming` tail, which is a 1-in-16 roll that moves them all -- +-- which is why the beasts drift while you grind, not only while you walk. +function World:roamMonsAfterBattle(roaming, outcome, enemyHp) + local save = self.game and self.game.save + if not (save and Roamers.list(save)) then return false end + local mapId = self.map and self.map.id + if roaming then + return Roamers.endBattle(save, roaming, outcome, enemyHp, mapId, + self:roamRandom(), self.encounters) + end + return Roamers.afterWildBattle(save, mapId, self:roamRandom(), + self.encounters) +end + +function World:updateMapSetup() + local ms = self.mapSetup + ms.wait = ms.wait - 1 + if ms.wait > 0 then return end + ms.wait = FADE_STEP_FRAMES + if ms.phase == "out" then + ms.step = ms.step + 1 + self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS + -- FlyFromAnim carries the player up and off the map under the fade. The + -- bird's own frames are not in the cache, but the lift is: it is the same + -- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks + -- (src/script/gen2/Movement.lua), stepped over the fade's four levels. + if ms.lift and self.player then + self.player.spriteYOffset = Movement.teleportYOffset( + Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) + end + if ms.step >= FADE_STEPS then + -- setMap clears self.fade (a map load repaints everything), so the sheet + -- has to be re-armed at full strength on the far side for the fade in to + -- take back down. + ms.load() + ms.phase = "in" + self.fade, self.fadeLevel = "white", 1 + end + return + end + ms.step = ms.step - 1 + -- FlyToAnim, the same curve read backwards: the player comes down onto the + -- destination tile as the fade lets go of the screen. + if ms.lift and self.player then + self.player.spriteYOffset = Movement.teleportYOffset( + Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) + end + if ms.step <= 0 then + self.fade, self.fadeLevel = nil, nil + self.mapSetup = nil + if ms.lift and self.player then self.player.spriteYOffset = 0 end + return + end + self.fadeLevel = ms.step / FADE_STEPS +end + +function World:reloadSprites(withPalettes) + self:rebuildPeople({ seamless = true }) + if withPalettes ~= false then self:applyPalettes() end +end + +-- Every hook Specials.lua may reach for, in one place so the module's whole +-- surface is readable at a glance and a test can stub it wholesale. +function World:specialHooks() + return { + world = self, + healParty = function() self:healParty() end, + warpToSpawn = function() self:warpToSpawn() end, + openPc = function() self:openPc() end, + -- _PlayersHousePC: the same screen with the DECORATION row, and an answer. + playersHousePc = function(onDone) + self:openPc({ house = true, onDone = onDone }) + end, + toggleDecorationsVisibility = function() + self:toggleDecorationsVisibility() + end, + toggleMaptileDecorations = function() self:toggleMaptileDecorations() end, + nameRival = function(onDone) self:nameRival(onDone) end, + playSfx = function(id) self:playSfx(id) end, + -- The same sound by its pokegold LABEL, for a handler porting a `ld de, + -- SFX_x / call PlaySFX` pair: the Gold sfx table is keyed by label, and an + -- index written down in Lua is only right until the table moves. + playSfxNamed = function(name, fallbackId) + self:playSfxNamed(name, fallbackId) + end, + playCry = function(index) self:playCry(index) end, + playMapMusic = function() self:playMapMusic() end, + restartMapMusic = function() self:restoreMapMusic() end, + fadeOutMusic = function() Music.fadeOut(2) end, + stopMusic = function() Music.stop() end, + currentMusic = function() return Music.current() end, + fade = function(kind) self:screenFade(kind) end, + reloadSprites = function(withPalettes) self:reloadSprites(withPalettes) end, + updatePlayerSprite = function() self:applyPlayerState(self.playerState) end, + surfStartStep = function(mon) return self:surfStartStep(mon) end, + save = function() return self.game and self.game.save end, + data = function() return self.game and self.game.data end, + party = function() + local save = self.game and self.game.save + return (save and save.party) or {} + end, + playerCell = function() + local p = self.player + return p and p.cellX or 0, p and p.cellY or 0 + end, + mapId = function() return self.map and self.map.id end, + coins = function() return self:coins() end, + setCoins = function(value) self:setCoins(value) end, + money = function(account) return self:money(account) end, + setMoney = function(account, value) self:setMoney(account, value) end, + -- Mom_SetUpWithdrawMenu / Mom_SetUpDepositMenu's six-digit money keypad + -- (src/script/gen2/Specials.lua H.BankOfMom). `onDone` gets the typed + -- amount or nil for B; the special itself owns the balance checks that + -- follow, the same split ChooseMoveToDelete keeps with H.MoveDeletion. + bankOfMomAmount = function(kind, saved, held, onDone) + return self:bankOfMomAmount(kind, saved, held, onDone) + end, + hasItem = function(index) return self:hasItem(index) end, + 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, + 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, + gameCornerGame = function(kind, onDone) + self:gameCornerGame(kind, onDone) + end, + unownPuzzle = function(puzzleId, onDone) + self:unownPuzzle(puzzleId, onDone) + end, + selectPartyMon = function(prompt, onDone) + self:selectPartyMon(prompt, onDone) + end, + chooseMoveToDelete = function(mon, onDone) + self:chooseMoveToDelete(mon, onDone) + end, + renameMon = function(mon, onDone, opts) + self:renameMon(mon, onDone, opts) + end, + setDayOfWeek = function(onDone) self:setDayOfWeek(onDone) end, + showDiploma = function(onDone) + self:showDiploma(onDone) + end, + showPhotoStudio = function(mon, onDone) + self:showPhotoStudio(mon, onDone) + end, + showUnownPrinter = function(onDone) + self:showUnownPrinter(onDone) + end, + magnetTrain = function(toGoldenrod, onDone) + self:magnetTrain(toGoldenrod, onDone) + end, + pushScreen = function(id, opts) return self:pushScreen(id, opts) end, + monName = function(index) + local id, def = speciesByIndex( + self.game and self.game.data and self.game.data.pokemon, index) + return (def and def.name) or id + end, + monIndex = function(species) + local pokemon = self.game and self.game.data and self.game.data.pokemon + local def = pokemon and pokemon[species] + return def and def.index or nil + end, + -- The item pair, the way monName / monIndex are the species pair: a + -- handler that builds a menu needs the printed name, and one that leaves + -- an item in wScriptVar needs the CONSTANT the following `ifequal` ladder + -- compares against (Kurt's is `ifequal BLU_APRICORN`). + itemName = function(id) + local items = self.game and self.game.data and self.game.data.items + local def = items and items[id] + return (def and def.name) or id + end, + itemIndex = function(id) + local items = self.game and self.game.data and self.game.data.items + local def = items and items[id] + return def and def.index or nil + end, + -- The same static menu `loadmenu` / `verticalmenu` opens, for the handlers + -- whose menu is compiled into a routine instead of into a MenuHeader the + -- extractor can follow (Kurt_SelectApricorn builds its rows at run time + -- out of the pack). + scriptMenu = function(header, onChoose) + self:openScriptMenu(header, "vertical", onChoose) + end, + rareWildMon = function() return self:rareWildMon() end, + } +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 +-- COMMONEST -- which is what stops the caller reporting a Rattata as a rarity. +-- The time of day picks which of the three lists is read, exactly as a step +-- would. +function World:rareWildMon() + local map = self.map + local entry = self.encounters and self.encounters.grass + and map and self.encounters.grass[map.id] + if not entry or not entry.slots then return nil end + local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY") + local slots = entry.slots[key] or entry.slots.DAY + if not slots then return nil end + local rare = slots[4 + math.random(3)] + if not (rare and rare.species) then return nil end + for i = 1, 4 do + local common = slots[i] + if common and common.species == rare.species then return nil end + end + return rare.species +end + +function World:objectEntity(objectId) + if objectId == 0 then return self.player end + -- object_const_def starts at 2; extracted objects are 1-based. + local index = (objectId or 0) - 1 + if index < 1 then return self.talkNpc end + for _, npc in ipairs(self.npcs) do + if npc.def and npc.def.index == index then return npc end + end + return nil +end + +function World:turnObject(objectId, facing) + local ent = self:objectEntity(objectId) + if ent and ent.scriptFace then ent:scriptFace(facing) end +end + +function World:disappearObject(objectId) + local index = (objectId or 0) - 1 + local def = self.map and self.map.def + local obj = def and def.objects and def.objects[index] + if not obj then return end + -- Script_disappear does TWO separate stores, and only one of them is the + -- flag. DeleteObjectStruct -> MaskObject (home/map_objects.asm:347, + -- home/map.asm:1542) writes the byte of wObjectMasks for THIS object and + -- nothing else, which is what takes it off the map now; the flag + -- ApplyEventActionAppearDisappear sets afterwards is only what the next + -- LoadObjectMasks reads back at map load. Objects sharing one + -- MAPOBJECT_EVENT_FLAG are ordinary (maps/BurnedTowerB1F.asm:152 gives all + -- three animated beasts EVENT_BURNED_TOWER_B1F_BEASTS_1 and jumps them away + -- one at a time), so the mask is per object even when the flag is not. + -- + -- ApplyEventActionAppearDisappear returns WITHOUT touching anything when the + -- flag word is -1 ($ffff), and MaskObject has already pulled the struct by + -- then -- so an object with no real flag still vanishes for the rest of the + -- map's visit. The port's Events:objectVisible reads $ffff as "always + -- visible", so setting it would have been a no-op; the mask is what hides + -- the flagless ones, and the next load re-derives every mask from the flags. + if obj.eventFlag and obj.eventFlag ~= 0xFFFF then + self.events:set(obj.eventFlag, true) + end + self:setObjectMask(obj, index, true) + self:rebuildPeople({ seamless = true }) +end + +-- StartFollow: the follower's movement type becomes SPRITEMOVEDATA_FOLLOWING +-- and it walks the leader's own path one cell behind. Only one pair exists at +-- a time (wObjectFollow_Leader / _Follower are single bytes), so a second +-- `follow` replaces the first. +function World:startFollow(leaderId, followerId) + local leader = self:objectEntity(leaderId) + local follower = self:objectEntity(followerId) + if not (leader and follower) or leader == follower then + self.followState = nil + return + end + self.followState = { leader = leader, follower = follower } +end + +function World:stopFollow() + self.followState = nil +end + +-- The leader has just committed to a step out of (fromX, fromY); the follower +-- takes that cell if it is standing next to it. A follower that is somewhere +-- else entirely simply does not move, the way CheckObjectVisibility drops a +-- pairing it cannot make sense of. +function World:followStep(leader, fromX, fromY) + local state = self.followState + if not (state and state.leader == leader) then return end + local follower = state.follower + if not follower or follower.moving or not follower.scriptStep then return end + local dx, dy = fromX - follower.cellX, fromY - follower.cellY + local dir + if dy == 0 and dx == 1 then dir = "right" + elseif dy == 0 and dx == -1 then dir = "left" + elseif dx == 0 and dy == 1 then dir = "down" + elseif dx == 0 and dy == -1 then dir = "up" end + if dir then follower:scriptStep(dir) end +end + +-- FreezeAllObjects + the caller's `res FROZEN_F` on the moved one +-- (engine/overworld/map_objects.asm FreezeAllOtherObjects, :2529-2544), plus +-- UnfreezeFollowerObject (scripting.asm:758): a follower is walked by +-- World:followStep and must not be held. +-- +-- The pool is walked rather than self.npcs because a rebuild mid-script can +-- leave an object in one list and not the other, which is the same pair +-- World:step's unfreeze walks. +function World:freezeAllOtherNpcs(objectId) + local moved = self:objectEntity(objectId) + local follower = self.followState and self.followState.follower + local function hold(npc) + if npc and npc ~= moved and npc ~= follower and npc ~= self.player then + npc.frozen = true + end + end + for _, npc in pairs(self.npcPool or {}) do hold(npc) end + for _, npc in ipairs(self.npcs or {}) do hold(npc) end +end + +function World:beginMovement(objectId, bytes, onDone) + -- ApplyMovement's FIRST act, before it has even read the movement pointer: + -- `ld a, c / farcall FreezeAllOtherObjects` (engine/overworld/scripting.asm + -- :751-755). FreezeAllObjects sets FROZEN_F on every struct and the caller + -- then clears it on the one being moved (map_objects.asm:2529-2544), so from + -- the first applymovement of a script until EndScript's UnfreezeAllObjects + -- NOBODY on the map moves under their own movement function. + -- + -- The port used to freeze only the object a command actually touched + -- (NPC:scriptStep / :scriptFace set `frozen` themselves), so every OTHER + -- wanderer and spinner kept going for the whole exchange: a beaten spinner + -- carried on rolling new facings through his own after-battle text, and the + -- Rocket hideout's spin trainers -- the ones whose facing is the puzzle -- + -- turned under every text box in the room. SeenByTrainerScript's + -- `applymovementlasttalked` (engine/events/trainer_scripts.asm:14) is the + -- freeze that covers the seen text, the battle and the talk-after. + -- + -- `frozeNpcs` is the release latch: World:step drops it the frame the whole + -- interaction settles, which is where the cart runs UnfreezeAllObjects. + self:freezeAllOtherNpcs(objectId) + self.frozeNpcs = true + self.moveState = { + objectId = objectId, + bytes = bytes or {}, + i = 1, + sleep = 0, + onDone = onDone, + } +end + +function World:updateMovement() + local st = self.moveState + if not st then return end + local ent = self:objectEntity(st.objectId) + if not ent then + local cb = st.onDone + self.moveState = nil + if cb then cb() end + return + end + if st.sleep and st.sleep > 0 then + st.sleep = st.sleep - 1 + return + end + if ent.moving then return end + -- StepFunction_NPCJump's `.Land` beat (engine/overworld/map_objects.asm:1150): + -- `.Jump` already walked one cell and ran GetNextTile again, so the second + -- cell belongs to the jump and not to the next movement byte. + if st.pendingStep then + local dir = st.pendingStep + st.pendingStep = nil + local fromX, fromY = ent.cellX, ent.cellY + if ent:scriptStep(dir) then self:followStep(ent, fromX, fromY) end + return + end + while st.i <= #st.bytes do + local b = st.bytes[st.i] + st.i = st.i + 1 + local act = Movement.decodeByte(b) + if act.kind == "end" then + -- SLIDING_F is an object flag, not a stream one, so a stream that never + -- ran remove_sliding would otherwise leave the object stuck holding its + -- facing and step frame forever. + ent.sliding = nil + local cb = st.onDone + self.moveState = nil + if cb then cb() end + return + elseif act.kind == "turn" then + ent:scriptFace(act.dir) + elseif act.kind == "step" then + local fromX, fromY = ent.cellX, ent.cellY + if ent:scriptStep(act.dir) then + self:followStep(ent, fromX, fromY) + end + return + elseif act.kind == "jump" then + -- JumpStep (engine/overworld/movement.asm:741) hands the object to + -- StepFunction_NPCJump, which runs TWO beats and advances OBJECT_MAP_X/Y + -- on each, so a jump crosses two cells. No collision test: InitStep and + -- GetNextTile only record the tile for the grass flag and never block + -- scripted movement. + local fromX, fromY = ent.cellX, ent.cellY + if ent:scriptStep(act.dir) then + st.pendingStep = act.dir + self:followStep(ent, fromX, fromY) + end + return + elseif act.kind == "sleep" then + st.sleep = act.frames or 0 + return + elseif act.kind == "teleport" then + -- teleport_from / teleport_to hand the object to their own step type for + -- a fixed number of frames (NPC:scriptTeleport); the movement stream + -- waits them out on the same counter step_sleep uses. + if ent.scriptTeleport then + ent:scriptTeleport(act.mode, act.frames) + st.sleep = act.frames or 0 + end + return + elseif act.kind == "sliding" then + -- Movement_set_sliding / _remove_sliding (engine/overworld/movement.asm + -- :353-363) toggle SLIDING_F; SetFacingStepAction and SetFacingBumpAction + -- both bail to SetFacingCurrent while it is set + -- (engine/overworld/map_object_action.asm:48, :74), so the object holds + -- its facing AND its step frame for the whole stream. Both handlers end + -- in `jp ContinueReadingMovement`, so no frame is consumed and the next + -- byte is read in this same pass: fall through the while loop. + ent.sliding = act.on + elseif act.kind == "fixfacing" then + -- FIXED_FACING_F: InitStep jumps past the OBJECT_DIRECTION write + -- (engine/overworld/map_objects.asm:284-294), so the object steps + -- without turning. ContinueReadingMovement again, so no frame here. + ent.fixedFacing = act.fixed + elseif act.kind == "treeshake" then + -- Movement_tree_shake: 24 frames of STEP_TYPE_SLEEP with OBJECT_ACTION + -- set to OBJECT_ACTION_WEIRD_TREE (engine/overworld/movement.asm:334). + if ent.scriptTreeShake then + ent:scriptTreeShake(act.frames) + st.sleep = act.frames or 0 + end + return + end + end + -- A stream that walked off the end of its bytes without an `end` byte still + -- has to drop SLIDING_F: the flag lives on the object, and leaving it set + -- freezes that NPC's facing and step frame for the rest of the map. + ent.sliding = nil + local cb = st.onDone + self.moveState = nil + if cb then cb() end +end + +function World:askYesNo(onChoose) + local game = self.game + if not (game and game.stack) then + if onChoose then onChoose(true) end + return + end + self.choicebox = true + -- The box World:showText left standing because this very command was the next + -- one in the list. Handing it the shared TextBox's own `choice` hook is what + -- puts the prompt over it: the stay latch has to come off first, since a + -- staying box short-circuits before the choice branch (src/render/TextBox.lua + -- :238). Nothing is re-printed, so the page keeps the exact two lines it + -- typed -- including a `cont`'s scrolled pair. + local held = self.stayedTextBox + if held then + self.stayedTextBox = nil + held.stay = nil + held.choice = function(yes) + self.textbox = nil + self.choicebox = nil + if onChoose then onChoose(yes) end + end + return + end + -- `yesorno` on the cart puts the YES/NO box ABOVE the text box that is + -- still holding the question (InitYesNoTextBoxParameters); the port used to + -- push a bare choice box, so the question vanished the moment the answer + -- appeared. The last page is re-shown instantly underneath and the shared + -- TextBox's own `choice` hook stacks the prompt on it, which is the same + -- pairing every Gen 1 prompt already uses. + local question = self.lastText + if question then + game.stack:push(TextBox.new(game, question, nil, { + instant = true, + choice = function(yes) + self.choicebox = nil + if onChoose then onChoose(yes) end + end, + })) + return + end + game.stack:push(ChoiceBox.new(game, function(yes) + self.choicebox = nil + if onChoose then onChoose(yes) end + end)) +end + +-- engine/events/pokepic.asm:44-48 PokepicMenuHeader `menu_coords 6, 4, 14, 13`, +-- and PadFrontpic (engine/gfx/load_pics.asm:342) fitting 5x5/6x6 into the 7x7. +local POKEPIC = { + left = 6, top = 4, w = 9, h = 10, + pad = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }, +} + +function World:showPokePic(speciesIndex) + local id, def = speciesByIndex( + self.game and self.game.data and self.game.data.pokemon, speciesIndex) + local path = def and def.spriteFront + if not path then self.pokePic = nil return end + local ok, img = pcall(Assets.image, path) + self.pokePic = ok and img or nil + self.pokePicName = id + -- _CGB_Pokepic (engine/gfx/cgb_layouts.asm:744) fills the whole menu box with + -- PAL_BG_GRAY, so the window is the map's grey ramp, not the mon's colors. + local set = self.palettes and self.map and self.map.def + and Palettes.bgSet(self.palettes, self.map.def, self.daytime or "DAY") + self.pokePicColors = set and set[1] or nil +end + +-- WaitButton (home/text.asm), which Script_waitbutton farcalls: hold the frame +-- until the player presses A or B. Only the `waitbutton` under an open +-- `pokepic` window reaches here -- every other one follows a `writetext` whose +-- last page has already taken the press (src/script/gen2/Vm.lua) -- and it is +-- what puts the starter's pic on screen long enough to look at before the +-- yes/no (#911). +-- +-- `fresh` skips the tick the wait was armed on. Game2's fixed step reads +-- `wasPressed("a")` for World:interact ABOVE World:step, so the press that +-- opened the ball script is still this tick's edge when the poll first runs; +-- without the skip that single press would both raise the pic and dismiss it. +function World:waitForButton(done) + self.waitButton = { done = done, fresh = true } +end + +function World:pollWaitButton() + local wb = self.waitButton + if not wb then return end + if wb.fresh then wb.fresh = false return end + local input = self.game and self.game.input + -- A headless build with no pad cannot answer, so it answers at once rather + -- than parking the script forever. + if input and not (input:wasPressed("a") or input:wasPressed("b")) then + return + end + self.waitButton = nil + if wb.done then wb.done() end +end + +-- A step rolls for a wild encounter, using the map's own rate and the slot +-- list for the current time of day -- which is why the same patch of Route 29 +-- gives Pidgey at 8am and Hoothoot at 8pm. +-- Returns true when a battle started, so the caller stops the step. +-- +-- WHETHER a step may roll at all is CanEncounterWildMon +-- (engine/overworld/events.asm), and the port used to get that wrong in a way +-- that emptied whole dungeons: it asked only "is this tall grass or water", +-- while the cart SKIPS the grass array entirely on a CAVE or DUNGEON map and +-- lets any non-ice walkable tile roll. Dark Cave and Union Cave gave zero +-- encounters because of it. FieldMoves.canEncounterWildMon is the whole +-- routine, ice check and wildoff flag included. +-- +-- WHICH list is rolled is a separate question with a separate answer: +-- ChooseWildEncounter (engine/overworld/wildmons.asm) reads CheckOnWater, the +-- PERMISSION of the tile underfoot. So a surfing step in a cave rolls the +-- water list and a walking step on the same map rolls the grass one. +-- The wild pick, wrapped in encounter.roll (return nil to suppress, a table +-- without calling next_ to force) and then encounter.species (which transforms +-- a non-nil roll before the Unown and repel filters). Same two names, same +-- order and same { species, level } shape as Gen 1. +-- +-- ctx keeps Gen 1's mapId / terrain / rng and adds what a Gen 2 roll genuinely +-- depends on and Gen 1 has no equivalent of: `daytime` (grass slots are per +-- time of day), `environment` (a CAVE or DUNGEON encounters on every walkable +-- tile), `kind` -- which of Gold's roll paths this is: "wild", "contest" or +-- "script" -- and `tables`, the swarm-substituted lists the engine really +-- rolled from, so a mod sees the same table _SwarmWildmonCheck picked. +-- +-- Not the per-step hot path Gen 1 guards: every caller is already past the +-- encounter-rate gate, so ctx is built once per encounter that actually +-- triggers and the wants check only skips the two chains. +function World:rollEncounter(kind, terrain, tables, vanilla) + local map = self.map + local ctx = { + mapId = map and map.id, + terrain = terrain, + rng = love.math.random, + kind = kind, + daytime = self.daytime, + environment = map and map.def and map.def.environment, + tables = tables, + data = self.game and self.game.data, + } + if not (Runtime.wantsHook("encounter.roll") + or Runtime.wantsHook("encounter.species")) then + return vanilla(tables, ctx) + end + local enc = Runtime.call("encounter.roll", vanilla, tables, ctx) + if enc then + enc = Runtime.call("encounter.species", sameEncounter, enc, ctx) + end + return enc +end + +-- ApplyMusicEffectOnEncounterRate (engine/overworld/wildmons.asm:233-248): +-- POKEMON MARCH and the RUINS OF ALPH station double the rate, POKEMON LULLABY +-- halves it. It reads wMapMusic, so a radio station left playing counts. +local MUSIC_RATE = { + Music_PokemonMarch = "double", + Music_RuinsOfAlphRadio = "double", + Music_PokemonLullaby = "half", +} + +function World.musicEncounterRate(rate, song) + local effect = rate and song and MUSIC_RATE[song] + if not effect then return rate end + -- `sla b` / `srl b`, so the double wraps at a byte like the cart's does. + if effect == "half" then return math.floor(rate / 2) end + return (rate * 2) % 256 +end + +function World:tryWildEncounter() + local game, player, map = self.game, self.player, self.map + if not (game and player and map and self.encounters) then return false end + if self:wildCooldownStep() then return false end + local save = game.save + if not (save and save.party and #save.party > 0) then return false end + local collision = map:cellCollision(player.cellX, player.cellY) + local environment = map.def and map.def.environment + if not FieldMoves.canEncounterWildMon( + environment, collision, self.noWildEncounters) then + return false + end + -- RandomEncounter's `bit STATUSFLAGS2_BUG_CONTEST_TIMER_F` gate sits between + -- CanEncounterWildMon and TryWildEncounter, and it takes a WHOLE different + -- path: the park's own table, its own two encounter rates, and no roamer + -- check at all -- ChooseWildEncounter_BugContest never calls + -- CheckEncounterRoamMon, which is why no beast can turn up in the contest. + if BugContest.isActive(save) then + return self:tryContestEncounter(collision) + end + -- TryWildEncounter's own order: `.EncounterRate` FIRST, and + -- ChooseWildEncounter -- roamer check included -- only on a pass. The port + -- rolled for a beast above the rate gate, which on a 10 percent route made + -- one turn up about ten times as often per grass step as the cart does. + local tables = self:wildTables() + local onWater = FieldMoves.encounterTable(collision) == "water" + local rate + if onWater then + rate = Encounter.waterRate(tables, map.id) + else + rate = Encounter.grassRate(tables, map.id, self.daytime) + end + -- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215). + rate = World.musicEncounterRate(rate, Music.mapSong()) + -- ApplyCleanseTagEffectOnEncounterRate (engine/overworld/wildmons.asm:250-267): + -- one `srl b`, however many mons are holding one. + for _, mon in ipairs(save.party or {}) do + if rate and mon.item == "CLEANSE_TAG" then + rate = math.floor(rate / 2) + break + end + end + if not Encounter.triggers(rate, nil) then return false end + -- CheckEncounterRoamMon is the FIRST thing ChooseWildEncounter does, before + -- it reaches for the map's own slot list -- so a beast REPLACES the map's + -- encounter rather than adding to it, and only on a map that has a table at + -- all (LoadWildMonDataPointer has already answered, which is what the + -- `self.encounters` guard above is). Roamers.checkEncounter refuses while + -- surfing, which is what keeps Suicune out of the water. + local met = Roamers.checkEncounter(save, map.id, onWater, self:roamRandom()) + if met then + -- CheckEncounterRoamMon writes wCurPartyLevel like any other pick, so the + -- repel filter below applies to a beast too: a level 41 lead repels it. + if self:repelSuppresses(met.level) then return false end + local beast = Roamers.beginBattle(save, met.index, game.data) + if beast then + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[beast.species] = true + self:startBattle({ wild = beast, roaming = met.index }) + return true + end + end + local roll = self:rollEncounter("wild", onWater and "water" or "grass", + tables, onWater and rollWaterVanilla or rollGrassVanilla) + if not roll then return false end + -- ChooseWildEncounter's Unown arm (engine/overworld/wildmons.asm): an UNOWN + -- slot on a map whose puzzles are all unsolved is NO ENCOUNTER, not a + -- different one -- `ld a, [wUnlockedUnowns] / and a / jr z, .nowildbattle`. + -- That is what keeps the Ruins chambers empty until a wall has been solved. + local monOpts = nil + if roll.species == Unown.SPECIES then + local flags = self:engineFlags() + if not Unown.anyUnlocked(flags) then return false end + -- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears + -- the form, so a chamber only ever produces letters its own puzzle + -- unlocked. + monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } + end + -- CheckRepelEffect, the last gate TryWildEncounter runs, and it runs AFTER + -- the mon has been chosen -- which is why a repel filters on the level that + -- was rolled rather than on anything about the map. + if self:repelSuppresses(roll.level) then return false end + local wild = Mon.new(game.data, roll.species, roll.level, monOpts) + if not wild then return false end + + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + self:startBattle({ wild = wild }) + return true +end + +-- CheckRepelEffect (engine/overworld/wildmons.asm). With wRepelEffect +-- (save.repelSteps) still ticking, the chosen encounter is dropped when its +-- level is BELOW the first party member that is not fainted: +-- `ld a, [wCurPartyLevel] / cp [hl] / jr nc, .encounter` takes the encounter +-- on greater-or-equal, so a lead at the wilds' own level repels nothing. +-- +-- The walk starts at wPartyMon1HP and skips every slot whose two HP bytes are +-- zero, so an EGG (DayCare_GiveEgg zeroes its HP) is never the mon a repel +-- measures against. The counter is StepEvents.repelStep's; nothing here +-- touches it. +function World:repelSuppresses(level) + local save = self.game and self.game.save + if not save then return false end + if (save.repelSteps or 0) <= 0 then return false end + if not level then return false end + local lead + for _, mon in ipairs(save.party or {}) do + if (mon.hp or 0) > 0 then + lead = mon + break + end + end + if not (lead and lead.level) then return false end + return level < lead.level +end + +-- CheckWildEncounterCooldown (engine/overworld/events.asm:357-365), the first +-- thing RandomEncounter runs (events.asm:1122): zero is a free step, otherwise +-- the counter ticks and only the step that lands on zero may roll. +function World:wildCooldownStep() + local left = self.wildCooldown or 0 + if left <= 0 then return false end + left = left - 1 + self.wildCooldown = left + return left > 0 +end + +-- LoadWildMonDataPointer's first move, for the grass list and the water one +-- alike: _SwarmWildmonCheck searches SwarmGrassWildMons / SwarmWaterWildMons +-- ahead of the Johto and Kanto tables, and only while the player is standing +-- on the swarm's own map. Roamers.Swarm.tables hands the ORIGINAL table back +-- when no swarm applies, so an ordinary step pays nothing for this. +function World:wildTables() + local save = self.game and self.game.save + if not (save and self.map and self.encounters) then return self.encounters end + return Roamers.Swarm.tables(save, self.encounters, self.map.id) +end + +-- --------------------------------------------------------------------------- +-- The Bug Catching Contest (engine/events/bug_contest/). +-- --------------------------------------------------------------------------- +-- +-- src/core/gen2/BugContest.lua is the whole model -- the park's encounter +-- table, the twenty minute clock, the park balls, the one held catch, the +-- scoring and the podium -- and every rule below is one call into it. What +-- lives here is the four CALL SITES the cart has and the port had none of: +-- +-- RandomEncounter's contest branch World:tryContestEncounter +-- CheckTimeEvents' CheckBugContestTimer World:checkTimeEvents +-- BugCatchingContestBattleScript's tail World:bugContestBattleOver +-- BugCatchingContestOverScript World:bugContestOver +-- +-- The gate conversation itself is extracted script bytecode and needs nothing +-- here: Route35NationalParkGate's officer runs through the same VM every other +-- NPC does, and its six `special`s are handled in src/script/gen2/Specials.lua. + +-- _TryWildEncounter_BugContest: the rate is the tile's (40 percent in the +-- park's long grass, 20 in the ordinary kind), the row comes from ContestMons +-- and the level from that row's span. The mon is built through +-- src/battle/gen2/Mon.lua like any other wild encounter, so it arrives with a +-- Gen 2 moveset and DVs the judge can score. +function World:tryContestEncounter(collision) + local game, save = self.game, self.game and self.game.save + if not (game and save) then return false end + if not BugContest.triggers(Permissions.isSuperTallGrass(collision)) then + return false + end + local roll = self:rollEncounter("contest", "grass", nil, rollContestVanilla) + if not roll then return false end + local wild = Mon.new(game.data, roll.species, roll.level) + if not wild then return false end + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + -- BATTLETYPE_CONTEST, which is what puts PARKBALL in the battle menu and + -- sends a caught mon to wContestMon instead of to the party. + self:startBattle({ wild = wild, contest = true }) + return true +end + +-- CheckTimeEvents (engine/overworld/events.asm), the clock half of the +-- player-event chain. While ENGINE_BUG_CONTEST_TIMER is set it polls +-- CheckBugContestTimer and NOTHING else -- the daily reset, the swarm and the +-- phone call are all on the `.do_daily` arm the contest skips. A carry out of +-- it is BugCatchingContestOverScript. +-- +-- `.do_daily` is CheckDailyResetTimer, CheckSwarmFlag, CheckPokerusTick and +-- CheckPhoneCall here, in the cart's order. The reset is what clears +-- wDailyFlags1, so it is what makes the +-- contest a ONCE A DAY thing -- BugContestResults_CleanUp's `setflag +-- ENGINE_DAILY_BUG_CONTEST` is the bit the officer refuses on, and nothing +-- else ever takes it back down. The Pokerus tick counts down every infected +-- party slot by the days since it last ran. CheckPhoneCall is the random +-- incoming ring: the whole five-test gate (the entrance tile underfoot, the +-- 20/10/5/3 minute countdown, the coin flip, the signal, an available +-- caller) is src/core/gen2/Phone.lua's tryRandomCall, and its carry is +-- Script_ReceivePhoneCall, so a landed call answers true the same way the +-- contest's over-script does. What belongs here is only what the gate reads +-- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL_DOOR +-- / COLL_DOOR_79 / COLL_STAIRCASE / COLL_CAVE under the player's feet. +function World:checkTimeEvents() + local save = self.game and self.game.save + if not save then return false end + if not BugContest.isActive(save) then + Apricorns.checkDailyResetTimer(save) + -- CheckSwarmFlag, second on `.do_daily` and the ONLY thing that ever ends + -- a swarm: the reset above takes DAILYFLAGS1_SWARM down, and this is what + -- notices and clears wSwarmMapGroup/Number and wFishingSwarmFlag with it. + -- Without it a Dunsparce call would leave Dark Cave swarming forever. + Roamers.Swarm.check(save) + Pokerus.checkTick(save) + local ctx = self:stepContext().phone + local coll = self.map and self.player + and self.map:cellCollision(self.player.cellX, self.player.cellY) + ctx.standingOnEntrance = coll == 0x71 or coll == 0x79 + or coll == 0x7a or coll == 0x7b + local call = require("src.core.gen2.Phone").checkPhoneCall(save, ctx) + if call then return self:receivePhoneCall(call) end + return false + end + if not BugContest.tickTimer(save) then return false end + return self:bugContestOver("time") +end + +-- BugCatchingContestOverScript and BugCatchingContestOutOfBallsScript: the same +-- two commands over a different line, and both fall into +-- BugCatchingContestReturnToGateScript. Neither is a std script (they sit in +-- engine/events/bug_contest/contest.asm, which nothing points at), so the text +-- is authored here the way World:repelWoreOff's is. +local BUG_CONTEST_TIME_UP = Strings.source("ANNOUNCER: BEEEP!\fTime's up!") +local BUG_CONTEST_IS_OVER = + Strings.source("ANNOUNCER: The\nContest is over!") +local SFX_ELEVATOR_END = 39 + +function World:bugContestOver(reason) + if self.bugContestEnding then return false end + self.bugContestEnding = true + self:playSfxNamed("Sfx_ElevatorEnd", SFX_ELEVATOR_END) + local line = (reason == "balls") and BUG_CONTEST_IS_OVER or BUG_CONTEST_TIME_UP + self:showText(Strings(line), function() + self.bugContestEnding = nil + self:bugContestResults() + end) + return true +end + +-- `jumpstd BugContestResultsWarpScript`, run the way the cart runs it: the std +-- script itself does the warp to the north gate, the walk in, the judging, the +-- prize and the party hand-back, so this hands control back to the VM rather +-- than reimplementing any of it. A cache without that std script leaves the +-- contest state alone -- stopping the clock here would strand the player in the +-- park with no way to be judged. +function World:bugContestResults() + local entry = self.stdScripts and self.stdScripts.scripts + and self.stdScripts.scripts.BugContestResultsWarpScript + local key = entry and entry.key + if not (key and self.vm) then return false end + return self.vm:start(key) +end + +-- BugCatchingContestBattleScript's tail: `readmem wParkBallsRemaining / +-- iffalse BugCatchingContestOutOfBallsScript`, checked on the way out of every +-- contest battle. CheckContestBattleOver has already turned the last ball into +-- a DRAW, so the battle is over either way by the time this runs. +function World:bugContestBattleOver() + local save = self.game and self.game.save + if not save then return false end + if not BugContest.isActive(save) then return false end + if not BugContest.isOver(save) then return false end + return self:bugContestOver("balls") +end + +-- True when `itemId` is one of the three rods. The item table is consulted +-- when the caller has one, so a cache whose ItemNames sit at other indices (a +-- different Gen 2 ROM) refuses rather than fishing with a BICYCLE. +function World.isRod(itemId, items) + local index = ROD_INDEX[itemId] + if not index then return false end + local def = items and items[itemId] + if def and def.index and def.index ~= index then return false end + return true +end + +-- FishFunction's .TryFish (engine/events/overworld.asm): the roll on its own, +-- with no animation and no battle. `rod` is the item id; the group comes from +-- the map's own MAP_FISHGROUP and the rod picks which of that group's three +-- lists is rolled. Returns the jumptable case the cart lands on, plus the +-- hooked mon on "battle": +-- +-- "nowhere" $3 .FailFish surfing, or not facing water +-- "nofish" $4 .FishNoFish facing water the map has no group for +-- "nibble" $1 .FishNoBite the group's own roll came up empty +-- "battle" $2 .FishGotSomething +-- +-- Split out of World:tryFishing because Script_GotABite writes RodBiteText +-- BEFORE its startbattle, so the cast has to run between the roll and the +-- battle rather than after it. +function World:rollFishing(rod) + local game, player, map = self.game, self.player, self.map + if not (game and player and map and self.encounters) then return "nowhere" end + -- .TryFish reads wPlayerState first and drops straight to $3 .FailFish while + -- the player is PLAYER_SURF / PLAYER_SURF_PIKA: you cannot fish from the + -- back of a Lapras, only from the bank. + if FieldMoves.isSurfing(self.playerState) then return "nowhere" end + -- The cart requires the tile the player is FACING to be water, not the one + -- they stand on. + local d = Map.DELTA[player.facing or "down"] or Map.DELTA.down + local cx, cy = player.cellX + d[1], player.cellY + d[2] + if not Permissions.isWater(map:cellCollision(cx, cy)) then return "nowhere" end + -- GetFishingGroup (home/map.asm) is MAP_FISHGROUP off the map header, and + -- .facingwater's `and a / jr nz` sends FISHGROUP_NONE to .FishNoFish. The + -- Encounter helper defaults an unknown map to the pond, so the header is + -- read here rather than left to it. + local def = self.maps and self.maps[map.id] + local group = def and def.fishGroup + if not group or group == 0 or group == "FISHGROUP_NONE" then + return "nofish" + end + -- GetFishGroupIndex reads wFishingSwarmFlag between the header and the + -- FishGroups index, which is how the Route 32 Qwilfish and Route 44 Remoraid + -- swarms reach the rods at all: the phone call's ActivateFishingSwarm writes + -- the flag and nothing about the map changes. Roamers.Swarm.fishing is the + -- same store CheckSwarmFlag clears when the swarm expires. + local swarm = Roamers.Swarm.fishing(game.save) + local roll + if Runtime.wantsHook("encounter.fishing") then + -- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the + -- candidate list the chain may inspect or replace before the roll. Gold's + -- candidates are the FishGroups row the map header (plus any swarm swap) + -- resolves to, so the third argument is that row rather than Gen 1's + -- extracted fishing group -- same role, same position. ctx is the fourth + -- argument and is Gen 2's alone; nothing before it moved. + local group = Encounter.fishGroupFor(self.encounters, + (def and def.fishGroup) or "FISHGROUP_POND", swarm) + local groups = self.encounters and self.encounters.fishGroups + roll = Runtime.call("encounter.fishing", fishVanilla, rod, map.id, + groups and groups[group], + { fishGroup = group, swarm = swarm, encounters = self.encounters, + maps = self.maps, data = game.data }) + else + roll = Encounter.fishSlot(self.encounters, map.id, rod, nil, self.maps, + swarm) + end + if not roll or not roll.species then return "nibble" end + local wild = Mon.new(game.data, roll.species, roll.level) + if not wild then return "nibble" end + local save = game.save + if save then + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + end + return "battle", wild +end + +-- Roll and start the battle in one call, for a caller that wants the outcome +-- without the cast animation. The PACK goes through World:useRod instead. +-- +-- The return widened when the input path landed: it used to be +-- "battle"/"nibble"/nil, and nil could not tell the two failures apart -- the +-- PACK has to keep itself open for one ("nowhere") and quit for the other +-- ("nofish"), which is the whole difference between UseItem's .Oak and its +-- PACKSTATE_QUITRUNSCRIPT. +function World:tryFishing(rod) + local outcome, wild = self:rollFishing(rod) + if outcome == "battle" and wild then + self:startBattle({ wild = wild }) + end + return outcome +end + +-- UseRod (engine/items/item_effects.asm), which is a bare `farcall +-- FishFunction` after OldRodEffect / GoodRodEffect / SuperRodEffect have +-- chosen rod 0/1/2. Rolls first, then hands the outcome to the cast. +-- +-- A rod is ITEMMENU_NOUSE in battle, and a running script owns the world, so +-- both answer "nowhere" -- which is the cart's own "This isn't the time to use +-- that!", the case where the PACK stays open. +function World:useRod(rodId) + if self.battleActive or self:busy() then return "nowhere" end + local outcome, wild = self:rollFishing(rodId) + if outcome == "nowhere" then return "nowhere" end + self:beginFishing(outcome, wild) + return outcome +end + +-- The field half of engine/items/pack.asm UseItem: an item used from the PACK +-- in the overworld. Returns nil for anything this world does not handle, so +-- the PACK falls through to its own onChoose (TM teaching lives there); +-- otherwise the FishFunction outcome, where "nowhere" means the PACK must stay +-- open and print OakThisIsntTheTimeText. +function World:useFieldItem(itemId) + local items = self.game and self.game.data and self.game.data.items + if itemId == "ITEMFINDER" then return self:useItemfinder() end + if itemId == "BICYCLE" then return self:useBike(itemId) end + if itemId == "SACRED_ASH" then return self:useSacredAsh() end + if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end + if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end + if REPEL_STEPS[itemId] then return self:useRepel(itemId) end + if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end + if not World.isRod(itemId, items) then return nil end + return self:useRod(itemId) +end + +-- EscapeRopeOrDig's .CheckCanDig (engine/events/overworld.asm): the map's +-- environment must be CAVE or DUNGEON and the banked warp triple must name a +-- real warp. The cart reads wDigWarpNumber / wDigMapGroup / wDigMapNumber +-- there; this port keeps one banked triple -- backupWarp, the same store a -1 +-- warp destination resolves through and the one the save carries -- so the +-- rope pays out to the last warp that banked it rather than to a second +-- register. A triple that names a map or warp the cache does not carry is +-- the cart's zeroed-triple `.fail` arm. +function World:escapeRopeTarget() + local env = self.map and self.map.def and self.map.def.environment + if env ~= "CAVE" and env ~= "DUNGEON" then return nil end + local backup = self.backupWarp + local dest = backup and backup.map and self.maps and self.maps[backup.map] + local destWarp = dest and dest.warps and backup.warp + and dest.warps[backup.warp] + if not destWarp then return nil end + return backup.map, destWarp +end + +-- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX_WARP_TO, +-- `loadvar VAR_MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP_DOOR` with +-- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land +-- the player on the destination warp's own tile. The dig-spin sprite work is +-- not ported, the same standing decision World:flyTo records for the two fly +-- animations. +function World:runEscapeWarp(destMapId, destWarp) + self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + return self:runMapSetup(MAPSETUP_DOOR, function() + local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down") + if ok then self:spawnFacing() end + return ok + end) +end + +-- EscapeRopeEffect (engine/items/item_effects.asm): EscapeRopeFunction, and +-- UseDisposableItem only when it succeeded -- a refusal costs nothing. The +-- ESCAPE_ROPE is ITEMMENU_CLOSE, so "nowhere" sends UseItem's .Field arm to +-- .Oak (the PACK prints OakThisIsntTheTimeText and stays open) and a success +-- quits the PACK, with the queued script -- the used-rope line and the warp +-- -- running once the overworld owns the frame, exactly the QueueScript +-- placement the cart gives .UsedEscapeRopeScript. +function World:useEscapeRope(itemId) + if self.battleActive or self:busy() then return "nowhere" end + local destMapId, destWarp = self:escapeRopeTarget() + if not destMapId then return "nowhere" end + 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) + self.queuedFieldMove = { + ok = true, action = "escaperope", + destMap = destMapId, destWarp = destWarp, + text = FieldMoves.TEXT.USE_ESCAPE_ROPE, + } + return "escape_rope" +end + +-- _Squirtbottle (engine/events/squirtbottle.asm): the script is QUEUED and +-- wItemEffectSucceeded is set to 1 unconditionally, so the PACK always quits; +-- .CheckCanUseSquirtbottle then picks between WateredWeirdTreeScript and the +-- "nothing happened" line over the overworld. The check is run here, at +-- queue time, because the player cannot turn between the press and the drain. +-- +-- The watered-tree body is not invented: it is sliced out of the extracted +-- Sudowoodo talk script (maps/Route36.asm SudowoodoScript), whose .Fight arm +-- falls through into the exported WateredWeirdTreeScript right after its +-- yesorno's `iffalse` + `closetext` pair -- so the PACK use and the talk path +-- run the very same decoded rows, battle and TWIN swap included. +local SPRITEMOVEDATA_SUDOWOODO = 0x17 +local TEXT_SQUIRTBOTTLE_NOTHING = Strings.source( + "{PLAYER} sprinkled\nwater.\fBut nothing\nhappened…") + +function World:squirtbottleTreeScript() + local p, map = self.player, self.map + if not (p and map and map.id == "ROUTE_36") then return nil end + local d = Map.DELTA[p.facing or "down"] or Map.DELTA.down + local npc = self:npcAt(p.cellX + d[1], p.cellY + d[2]) + local def = npc and npc.def + if not (def and def.movement == SPRITEMOVEDATA_SUDOWOODO + and def.scriptKey) then + return nil + end + local talk = self.scripts and self.scripts[def.scriptKey] + local armKey + for _, cmd in ipairs(talk or {}) do + if cmd.op == "iftrue" and cmd.script then armKey = cmd.script break end + end + local arm = armKey and self.scripts and self.scripts[armKey] + if not arm then return nil end + for i, cmd in ipairs(arm) do + if cmd.op == "iffalse" then + -- WateredWeirdTreeScript starts past the yesorno's own closetext. + local start = i + 1 + if arm[start] and arm[start].op == "closetext" then start = start + 1 end + if not arm[start] then return nil end + local rows = {} + for j = start, #arm do rows[#rows + 1] = arm[j] end + return rows + end + end + return nil +end + +function World:useSquirtbottle() + if self.battleActive or self:busy() then return "nowhere" end + local tree = self:squirtbottleTreeScript() + if tree then + self.queuedScript = tree + else + self.queuedScript = { + { op = "opentext" }, + { op = "rawtext", text = TEXT_SQUIRTBOTTLE_NOTHING }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "end" }, + } + end + return "squirtbottle" +end + +-- CheckRegisteredItem (engine/overworld/select_menu.asm), re-run on every +-- SELECT press rather than only when the item was registered: tossing the +-- last copy, trading it away or a TM losing CANT_SELECT_F retroactively (it +-- never does, but the pack does run out) all answer the same +-- ".NoRegisteredItem" way the cart does -- silently clearing the slot instead +-- of holding a stale pointer. wWhichRegisteredItem packs a pocket and a +-- quantity so a KEY_ITEM's count never underflows and an ITEM/BALL's does; +-- this port keeps only the item id and re-reads the live inventory count, +-- which answers the same "not enough left" case for zero without needing the +-- packed field at all. +function World:registeredItemId() + local save = self.game and self.game.save + local reg = save and save.registeredItem + local id = reg and reg.id + if not id then return nil end + local items = self.game and self.game.data and self.game.data.items + local def = items and items[id] + local count = save.inventory and save.inventory[id] + if not def or def.canSelect == false or not count or count <= 0 then + save.registeredItem = nil + return nil + end + return id +end + +-- RegisterItem (engine/items/pack.asm): CheckSelectableItem gates it, the +-- same ITEMATTR_PERMISSIONS bit `registeredItemId` re-checks on use, so a +-- TM/HM or anything else with CANT_SELECT_F set refuses. The cart reaches +-- this from the PACK's own USE/GIVE/TOSS/SEL/QUIT row submenu, which this +-- port has not built; PackMenu instead calls it straight off the highlighted +-- row on a SELECT press, the one PACK button this port left unbound. +function World:registerItem(itemId) + local items = self.game and self.game.data and self.game.data.items + local def = items and items[itemId] + local save = self.game and self.game.save + if not save or not def or def.canSelect == false then return false end + save.registeredItem = { id = itemId } + return true +end + +-- SelectMenu (engine/overworld/select_menu.asm): the SELECT press in the +-- overworld. UseRegisteredItem's four ITEMMENU_* arms (.CantUse / .Current / +-- .Party / .Overworld) are exactly the switch `useFieldItem` already runs for +-- the PACK's own UseItem -- a rod, the ITEMFINDER, a REPEL, a trophy box -- +-- so this is that same dispatch with "no registered item" and "no field +-- handler for this one yet" as the two extra outcomes CantUseItem's own two +-- call sites (.NotRegistered and .CantUse) cover on the cart. +function World:useSelectItem() + if self.battleActive or self:busy() then return "nowhere" end + local id = self:registeredItemId() + if not id then return "not_registered" end + -- wUsingItemWithSelect, set for exactly the length of the effect: the + -- BICYCLE is the one item whose effect reads it (.CheckIfRegistered), and it + -- is what makes a SELECT press get on the bike without a line of text. + self.usingItemWithSelect = true + local outcome = self:useFieldItem(id) + self.usingItemWithSelect = nil + if outcome == nil then return "cant_use" end + return outcome, id +end + +-- UseRepel (engine/items/item_effects.asm): a REPEL's ItemAttributes give it +-- ITEMMENU_CURRENT, so UseItem's `.Current` arm runs DoItemEffect and returns +-- with no wItemEffectSucceeded check at all -- the PACK never quits for this +-- item, win or lose. wRepelEffect already set (`and a / jp nz`) prints +-- RepelUsedEarlierIsStillInEffectText and leaves the counter and the bag +-- alone; otherwise the new count is written and UseItemText's tail +-- (UseDisposableItem) is the one place a REPEL gets removed from the bag. +-- +-- The caller is PackMenu, which owns both messages ("repel_used" builds its +-- own from the row's item name; "repel_active" is the fixed three-line text +-- the cart shows) so this stays a plain sentinel like useRod's outcomes. +-- +-- ItemAttributes gives a REPEL ITEMMENU_NOUSE in battle, same as a rod, so +-- the in-battle PACK (BattleState:openPack, which shares this same World +-- instance) has to be refused here too rather than only from the overworld. +function World:useRepel(itemId) + local save = self.game and self.game.save + if not save then return nil end + if self.battleActive or self:busy() then return "nowhere" end + if (save.repelSteps or 0) > 0 then return "repel_active" end + save.repelSteps = REPEL_STEPS[itemId] + if save.inventory then + local left = (save.inventory[itemId] or 1) - 1 + save.inventory[itemId] = left > 0 and left or nil + end + return "repel_used" +end + +-- NormalBoxEffect / GorgeousBoxEffect (engine/items/item_effects.asm), which +-- are the same three lines with a different DECOFLAG: SetSpecificDecorationFlag, +-- _SentTrophyHomeText, UseDisposableItem. Both items are ITEMMENU_CURRENT, so +-- the PACK prints and stays open rather than quitting to the field. +-- +-- This is the one path in Gold that can hand the player a decoration and that +-- this port can actually reach; the other two (Mom's doll purchases, which +-- need her savings account, and Mystery Gift, which needs a second cart) are +-- not built. +-- +-- Both boxes are ITEMMENU_NOUSE in battle, so BattlePack answers .Oak for them +-- and the flag is never granted nor the box spent mid-fight. +function World:openTrophyBox(itemId) + local decoFlag = TROPHY_BOXES[itemId] + if not decoFlag then return nil end + if self.battleActive then return "nowhere" end + Decorations.giveFlag(self.events, decoFlag) + local items = self.game and self.game.data and self.game.data.items + local def = items and items[itemId] + self:takeItem(def and def.index, 1) + return "trophy_sent" +end + +-- ItemFinder (engine/items/itemfinder.asm): CheckForHiddenItems, then one of +-- two scripts is QUEUED and wItemEffectSucceeded is set unconditionally. In +-- the FIELD there is no "you can't use that here" arm -- the ITEMFINDER always +-- quits the PACK. The item is ITEMMENU_NOUSE in battle, where BattlePack's +-- .Oak answers instead and no script is queued: quitting the PACK there would +-- take the battle off the stack with it. +-- +-- QueueScript, not CallScript: the PACK (and the START menu under it) is still +-- on the screen at this point, and the beeps and the line belong over the +-- overworld. World:runQueuedScript is the other half. +function World:useItemfinder() + if self.battleActive then return "nowhere" end + local p = self.player + local found = p and HiddenItems.nearby( + self.map and self.map.def, p.cellX, p.cellY, self.events) or nil + self.queuedScript = HiddenItems.itemfinderScript(found, function(want, id) + return self:sfxIdNamed(want, id) + end) + return "itemfinder" +end + +-- ---------------------------------------------------------------- the bike +-- +-- src/world/gen2/Bike.lua owns every decision; this is the world state those +-- decisions read and the presentation they end in. + +-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map +-- callbacks that set them (Route16AlwaysOnBikeCallback, +-- Route17AlwaysOnBikeCallback) already land on save.engineFlags. +function World:alwaysOnBike() + return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE) +end + +function World:downhill() + return self:engineFlag(Bike.ENGINE_DOWNHILL) +end + +-- GetPlayerTilePermission's operand: the collision under the player's feet. +function World:playerCollision() + local p = self.player + if not (p and self.map) then return nil end + return self.map:cellCollision(p.cellX, p.cellY) +end + +-- BikeFunction (engine/events/overworld.asm), reached from BicycleEffect. +-- +-- The BICYCLE is ITEMMENU_CLOSE, so UseItem takes its .Field arm: a non-zero +-- wFieldMoveSucceeded quits the PACK and lets the queued script run in the +-- overworld, and a zero drops into .Oak instead. "nowhere" is that zero -- +-- PackMenu already prints OakThisIsntTheTimeText for it -- and the three other +-- answers all queue a script and let the PACK close. +-- +-- .GetOnBike's music is not the outdoor-song override Gen 1 uses: it silences +-- the current song, plays MUSIC_BICYCLE and writes it into wMapMusic, so the +-- bike theme survives until the dismount's `special PlayMapMusic` or the next +-- map load puts the map's own song back. +function World:useBike(itemId) + if self.battleActive or self:busy() then return "nowhere" end + local items = self.game and self.game.data and self.game.data.items + local def = items and items[itemId or "BICYCLE"] + local item = def and def.index + local specialId = function(name) return self:specialIdNamed(name) end + local action = Bike.tryBike({ + state = self.playerState, + environment = self.map and self.map.def and self.map.def.environment, + collision = self:playerCollision(), + alwaysOnBike = self:alwaysOnBike(), + }) + -- wUsingItemWithSelect, which .CheckIfRegistered reads to pick the silent + -- pair of scripts. + local silent = self.usingItemWithSelect and true or false + if action == "mount" then + self.queuedScript = Bike.mountScript(item, specialId, silent) + self:playBikeMusic() + return "bike_on" + elseif action == "dismount" then + self.queuedScript = Bike.dismountScript(item, specialId, silent) + return "bike_off" + elseif action == "cant_get_off" then + self.queuedScript = Bike.cantGetOffScript() + return "bike_stuck" + end + return "nowhere" +end + +function World:playBikeMusic() + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.runtime) then return false end + if not (audio.songs and audio.songs[Bike.MUSIC_BICYCLE]) then return false end + Music.play(data, Bike.MUSIC_BICYCLE, true, { reason = "bike" }) + return true +end + +-- `special` names resolve through the cache's own SpecialsPointers order +-- (constants.specialOrder), same as Vm:specialName but starting from the +-- name instead of the decoded id -- a hand-built script has a label to write +-- and no counted index to have copied down. +function World:specialIdNamed(name) + local order = self.constants and self.constants.specialOrder + if not order or not name then return nil end + for i, n in ipairs(order) do + if n == name then return i - 1 end + end + return nil +end + +-- SacredAshEffect / _SacredAsh (engine/items/item_effects.asm, +-- engine/events/sacred_ash.asm). CheckAnyFaintedMon gates the whole effect +-- on carry: an empty party or one with nothing fainted never sets +-- wItemEffectSucceeded, so UseItem's .Field falls through to .Oak +-- (OakThisIsntTheTimeText) with the item untouched -- PackMenu already has +-- that message under "nowhere", the same answer a rod gives with no water in +-- front of the player. +-- +-- On success SacredAshScript runs a single HealParty (revives AND fully +-- heals every party member in one pass, see World:healParty) behind three +-- Pokecenter-style fade cycles and the "all healed" line, then +-- UseDisposableItem removes the one Ash from the bag. +function World:useSacredAsh() + if self.battleActive or self:busy() then return "nowhere" end + local save = self.game and self.game.save + local party = (save and save.party) or {} + local anyFainted = false + for _, mon in ipairs(party) do + if not Breeding.isEgg(mon) and (mon.hp or 0) <= 0 then + anyFainted = true + break + end + end + if not anyFainted then return "nowhere" end + + if save.inventory then + local left = (save.inventory.SACRED_ASH or 1) - 1 + save.inventory.SACRED_ASH = left > 0 and left or nil + end + + local script = { + { op = "special", id = self:specialIdNamed("HealParty") }, + { op = "refreshmap" }, + { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX_WARP_TO) }, + } + for _ = 1, 3 do + script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeOutToWhite") } + script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeInFromWhite") } + end + script[#script + 1] = { op = "waitsfx" } + script[#script + 1] = { op = "opentext" } + script[#script + 1] = { op = "rawtext", text = TEXT_USE_SACRED_ASH } + script[#script + 1] = { op = "playsound", id = self:sfxIdNamed("Sfx_CaughtMon", 2) } + script[#script + 1] = { op = "waitsfx" } + script[#script + 1] = { op = "waitbutton" } + script[#script + 1] = { op = "closetext" } + script[#script + 1] = { op = "end" } + self.queuedScript = script + return "sacredash" +end + +-- QueueScript's drain, on the same clock as the field move one below it: the +-- first frame the overworld owns after the menus are gone. +function World:runQueuedScript() + local script = self.queuedScript + if not script or self:busy() then return false end + self.queuedScript = nil + self.talkNpc = nil + return self.vm and self.vm:start(script) or false +end + +-- Script_FishCastRod, then Script_NotEvenANibble or Script_GotABite. Held as +-- a frame counter rather than a movement byte stream because the three +-- commands involved -- fish_cast_rod ($52), fish_got_bite ($51) and show_emote +-- ($54) -- are object ACTION changes, not steps, and Movement.decodeByte has +-- nothing to say about them. +function World:beginFishing(outcome, wild) + self.fishing = { + phase = "cast", timer = FISH_CAST_FRAMES, outcome = outcome, wild = wild, + } +end + +function World:updateFishing() + local st = self.fishing + if not st then return end + -- A text box owns the frame while it is up; the script only moves on when + -- its own callback fires. + if self.textbox or self.choicebox then return end + if st.timer > 0 then + st.timer = st.timer - 1 + -- StepFunction_GotBite (engine/overworld/map_objects.asm:1430) is one byte + -- of animation: OBJECT_SPRITE_Y_OFFSET flipped between 0 and 1 once a + -- frame for the length of the bite, which is the rod jerking in the + -- player's hands. The cast holds still, so only the bite bobs. + if self.player then + self.player.spriteYOffset = + (st.phase == "bite" and st.timer % 2 == 1) and 1 or 0 + end + return + end + if self.player then self.player.spriteYOffset = 0 end + if st.phase == "cast" then + if st.outcome == "battle" then + -- Script_GotABite: four fish_got_bite bobs with the EMOTE_SHOCK bubble + -- over the player, then `pause 40` before the rod comes back. + st.phase = "bite" + st.timer = FISH_BITE_FRAMES + self:showEmote(EMOTE_SHOCK, 0, FISH_BITE_FRAMES) + return + end + -- Script_NotEvenANibble (queued by $1 .FishNoBite) and + -- Script_NotEvenANibble2 (by $4 .FishNoFish) differ only in the + -- wFishingResult they record; both write RodNothingText and fall through + -- to the same PutTheRodAway. + st.phase = "done" + self:showText(Strings(TEXT_ROD_NOTHING), function() self.fishing = nil end) + return + end + if st.phase == "bite" then + st.phase = "done" + self:showText(Strings(TEXT_ROD_BITE), function() + local wild = st.wild + -- PutTheRodAway and closetext come before startbattle, and the state has + -- to be gone before the battle is pushed or World:busy would still be + -- holding the world when it returns. + self.fishing = nil + -- FishFunction's `.goodtofish` writes BATTLETYPE_FISH into wBattleType + -- alongside the species and level it hooked (engine/events/overworld.asm), + -- which is the one condition LureBallMultiplier reads for its x3. + if wild then self:startBattle({ wild = wild, battleType = "fish" }) end + end) + end +end + +-- CheckHeadbuttTreeTile (home/map_objects.asm). +function World.isHeadbuttTree(coll) + if coll == nil then return false end + return HEADBUTT_TREE[coll % 256] == true +end + +-- CheckPartyMove (engine/events/overworld.asm): the first party mon that knows +-- move `moveId`. The cart leaves that mon's slot in wCurPartyMon, which +-- GetPartyNickname then reads for "<nickname> did a HEADBUTT!", so the mon +-- itself is returned rather than a bare yes/no. +-- +-- The cart also skips EGG slots; the port has no egg state yet, so there is +-- nothing to skip. +-- HEADBUTT's and ROCK SMASH's CheckPartyMove, and the one the route bot asks. +-- Wrapped in the same fieldmove.eligibility chain FieldMoves.partyMoveUser +-- offers -- two separate walks of the party, so a mod sees each site once -- +-- and with the full Gen 1 ctx, because a World has the Game the love-free +-- module does not. +function World:partyMoveUser(moveId) + local save = self.game and self.game.save + local party = (save and save.party) or {} + return FieldMoves.partyMoveUser(party, moveId, + { save = save, data = self.game and self.game.data }) +end + +-- TryHeadbuttOW (engine/events/overworld.asm), reached from +-- TryTileCollisionEvent's .headbutt arm once the facing tile is a tree. With +-- no party mon that knows HEADBUTT the whole event is skipped -- TryHeadbuttOW +-- returns nc and TryTileCollisionEvent jumps to .noevent, so the A press does +-- nothing at all, not even a line of text. With one, AskHeadbuttScript opens. +-- +-- Returns true when the event took the A press. +function World:tryHeadbuttOW(cx, cy) + if not (self.map and self.player) then return false end + if not World.isHeadbuttTree(self.map:cellCollision(cx, cy)) then + return false + end + local mon = self:partyMoveUser(MOVE_HEADBUTT) + if not mon then return false end + -- AskHeadbuttScript: opentext, writetext AskHeadbuttText, yesorno, + -- iftrue HeadbuttScript. The port's yesorno re-shows the page it is + -- answering underneath the prompt, which is what World:askYesNo does with + -- the body World:showText just kept. + self:showText(Strings(TEXT_ASK_HEADBUTT), function() + self:askYesNo(function(yes) + if yes then self:runHeadbutt(cx, cy, mon) end + end) + end) + return true +end + +-- HeadbuttScript: GetPartyNickname, UseHeadbuttText, ShakeHeadbuttTree, and +-- only then TreeMonEncounter. The roll comes AFTER the shake, which is why +-- the tree rattles even when nothing is home. +function World:runHeadbutt(cx, cy, mon) + if self.game then + -- callasm GetPartyNickname: wStringBuffer2 is the nickname of the mon + -- CheckPartyMove left in wCurPartyMon, and {STRBUF} is how TextBox reads + -- it back. + self.game.stringBuffer = + (mon and (mon.nickname or mon.name or mon.species)) or "" + end + self:showText(Strings(TEXT_USE_HEADBUTT), function() + self.headbutt = { x = cx, y = cy, timer = HEADBUTT_SHAKE_FRAMES } + -- ShakeHeadbuttTree (engine/events/field_moves.asm:23) hides the BG tree + -- and wobbles an OBJ copy of it -- Frameset_HeadbuttTree alternates two + -- frames, the second X-flipped, every two frames for the 32 the counter + -- runs. There is no per-block OBJ layer here (the map is one baked + -- canvas), so the wobble is the frame's, on the same clock and for the + -- same 32 frames as the SFX that goes with it. + self:earthquake(0x40, HEADBUTT_SHAKE_FRAMES) + self:playSfx(SFX_SANDSTORM) + end) +end + +function World:updateHeadbutt() + local st = self.headbutt + if not st then return end + if self.textbox or self.choicebox then return end + if st.timer > 0 then + st.timer = st.timer - 1 + return + end + -- callasm TreeMonEncounter, iffalse .no_battle. Cleared first for the same + -- reason the fishing state is: startBattle must not see a busy world. + self.headbutt = nil + if self:tryHeadbutt(st.x, st.y) == "battle" then return end + self:showText(Strings(TEXT_HEADBUTT_NOTHING)) +end + +-- Headbutt. A tree's own map entry decides which of the two tree sets is +-- rolled, and whether anything is home at all (engine/events/treemons.asm +-- TreeMonEncounter). Returns "battle", "nothing" or nil. +function World:tryHeadbutt(cx, cy) + local game, map = self.game, self.map + if not (game and map and self.encounters) then return nil end + local roll = Encounter.treeSlot(self.encounters, map.id, cx, cy, nil) + if not roll or not roll.species then return "nothing" end + local wild = Mon.new(game.data, roll.species, roll.level) + if not wild then return "nothing" end + local save = game.save + if save then + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + end + self:startBattle({ wild = wild }) + return "battle" +end + +-- ROCK SMASH's wild mon (engine/events/treemons.asm RockMonEncounter), which +-- is TreeMonEncounter's twin over a different map table and with a flat roll +-- where the tree has its coordinate score: +-- +-- GetTreeMonSet RockMonMaps the four maps whose rocks hold anything at all +-- GetTreeMons that row's TREEMON_SET_* table +-- RandomRange 10, cp 4 40 percent, and it sits BETWEEN the two +-- SelectTreeMon the set's FIRST list, walked by a 0..99 roll +-- +-- Two things the twin does NOT do: it writes no wScriptVar (RockSmashScript +-- reads its answer back with `readmem wTempWildMonSpecies / iffalse`), and it +-- never reaches GetTreeMon's `.rare` skip past the -1, so the second half of +-- TREEMON_SET_ROCK is unreachable and only the 90/10 KRABBY / SHUCKLE list can +-- come out of a rock. +-- +-- `random` is injectable for the same reason World:roamRandom is: a driver and +-- a test have to be able to pin the 40 percent. Returns the species INDEX the +-- cart leaves in wTempWildMonSpecies, or 0 for nothing -- which is exactly what +-- the `readmem` below hands back to the script. +function World:rockRandom(n) + if self.rockmonRandom then return self.rockmonRandom(n) end + if love and love.math and love.math.random then + return love.math.random(n) - 1 + end + return math.random(n) - 1 +end + +function World:rockMonEncounter() + -- `xor a / ld [wTempWildMonSpecies], a / ld [wCurPartyLevel], a` opens the + -- routine, so a second smash never inherits the first one's mon. + self.tempWildMon = nil + local game, map = self.game, self.map + if not (game and map and self.encounters) then return 0 end + local setName = self.encounters.rocks and self.encounters.rocks[map.id] + if not setName then return 0 end + local set = self.encounters.treeSets and self.encounters.treeSets[setName] + local list = set and set.common + if not (list and #list > 0) then return 0 end + if self:rockRandom(10) >= 4 then return 0 end + -- SelectTreeMon's `.loop: sub [hl] / jr c, .ok`: the chance column is walked + -- as a running total until the roll borrows. + local value = self:rockRandom(100) + local total = 0 + local pick + for _, row in ipairs(list) do + total = total + (row.chance or 0) + if value < total then + pick = row + break + end + end + -- `.ok`'s own `cp -1 / jr z, NoTreeMon`: a list walked off the end is nothing. + if not (pick and pick.species) then return 0 end + local pokemon = game.data and game.data.pokemon + local def = pokemon and pokemon[pick.species] + if not (def and def.index) then return 0 end + self.tempWildMon = { species = def.index, level = pick.level } + return def.index +end + +-- readmem's engine seam. The VM keeps a sparse byte store for the addresses a +-- script owns outright (the Goldenrod switches, wMooMooBerries); these are the +-- ones the ENGINE writes, where answering out of that store would read back a +-- stale 0. RockSmashScript's `readmem wTempWildMonSpecies / iffalse` is the +-- whole reason this exists: the byte is written by the callasm one row above. +local WRAM_TEMP_WILD_MON_SPECIES = 0xd117 + +function World:scriptReadMem(addr) + if addr == WRAM_TEMP_WILD_MON_SPECIES then + return (self.tempWildMon and self.tempWildMon.species) or 0 + end + return nil +end + +-- .SweetScent (engine/events/sweet_scent.asm): UseSweetScentText, then +-- SweetScentEncounter's roll. wFieldMoveSucceeded was already set +-- unconditionally by FieldMoves.sweetScentFromMenu, so runFieldMove has no +-- refusal branch of its own -- the only question left is whether the +-- encounter turns anything up, and that is answered after the button press, +-- same as HEADBUTT's shake. +function World:runSweetScent(result) + local mon = result and result.mon + if self.game then + -- callasm GetPartyNickname: wStringBuffer2 (and 1 and 3) all hold the + -- same nickname, so {STRBUF} reads back UseSweetScentText's + -- text_ram wStringBuffer3 line correctly. + self.game.stringBuffer = + (mon and (mon.nickname or mon.name or mon.species)) or "" + end + self:showText(Strings(TEXT_USE_SWEET_SCENT), function() + if self:sweetScentEncounter() then return end + self:showText(Strings(TEXT_SWEET_SCENT_NOTHING)) + end) +end + +-- SweetScentEncounter (engine/events/sweet_scent.asm): the same +-- CanEncounterWildMon gate a step takes -- grass/water tile, ice, wildoff, +-- the CAVE/DUNGEON exemption -- but everything downstream skips its own +-- percentage roll. GetMapEncounterRate only has to come back NONZERO, and +-- ChooseWildEncounter / ChooseWildEncounter_BugContest then run +-- unconditionally: no Encounter.triggers, no BugContest.triggers. +-- CheckRepelEffect is never reached at all here -- a REPEL stops a STEP from +-- encountering, not the player from choosing to use SWEET SCENT. +function World:sweetScentEncounter() + local game, player, map = self.game, self.player, self.map + if not (game and player and map and self.encounters) then return false end + local save = game.save + if not (save and save.party and #save.party > 0) then return false end + local collision = map:cellCollision(player.cellX, player.cellY) + local environment = map.def and map.def.environment + if not FieldMoves.canEncounterWildMon( + environment, collision, self.noWildEncounters) then + return false + end + -- .BugCatchingContest: `checkflag ENGINE_BUG_CONTEST_TIMER` skips + -- GetMapEncounterRate entirely and goes straight to the park's own table, + -- same as RandomEncounter's own contest arm above -- and, like that arm, + -- ChooseWildEncounter_BugContest never calls CheckEncounterRoamMon. + if BugContest.isActive(save) then + local roll = self:rollEncounter("contest", "grass", nil, rollContestVanilla) + if not roll then return false end + local wild = Mon.new(game.data, roll.species, roll.level) + if not wild then return false end + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + self:startBattle({ wild = wild, contest = true }) + return true + end + -- SweetScentEncounter goes through ChooseWildEncounter like a step does, so + -- a swarm overrides the map's list here too -- and, unlike a step, it never + -- reaches CheckRepelEffect, which is why SWEET SCENT works through a REPEL. + local tables = self:wildTables() + local onWater = FieldMoves.encounterTable(collision) == "water" + local rate = onWater and Encounter.waterRate(tables, map.id) + or Encounter.grassRate(tables, map.id, self.daytime) + if not (rate and rate > 0) then return false end + -- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does: + -- a beast REPLACES the map's own slot rather than adding to it. + local met = Roamers.checkEncounter(save, map.id, onWater, self:roamRandom()) + if met then + local beast = Roamers.beginBattle(save, met.index, game.data) + if beast then + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[beast.species] = true + self:startBattle({ wild = beast, roaming = met.index }) + return true + end + end + local roll = self:rollEncounter("sweet_scent", onWater and "water" or "grass", + tables, onWater and rollWaterVanilla or rollGrassVanilla) + if not roll then return false end + -- ChooseWildEncounter's Unown arm: a chamber with no puzzle solved yet + -- stays empty for SWEET SCENT too. + local monOpts = nil + if roll.species == Unown.SPECIES then + local flags = self:engineFlags() + if not Unown.anyUnlocked(flags) then return false end + monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) } + end + local wild = Mon.new(game.data, roll.species, roll.level, monOpts) + if not wild then return false end + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[roll.species] = true + self:startBattle({ wild = wild }) + return true +end + +-- ---- field moves ---------------------------------------------------------- +-- +-- The world half of engine/events/overworld.asm. src/world/gen2/FieldMoves.lua +-- holds every decision (badge, party move, tile, refusal line); this holds the +-- effects, because those are the only part that needs a map, a sprite and a +-- frame clock. The split is exactly the ASM's own: the *Function routines are +-- pure jumptable arithmetic over wFieldMoveData and then hand a SCRIPT to +-- QueueScript, and it is the script that touches the world. + +-- The index of the block cell (cx, cy) sits in, inside map.def.blocks, plus +-- the block id there. GetBlockLocation, minus the WRAM border arithmetic the +-- port has no buffer for. +function World:blockIndexAt(cx, cy) + local map = self.map + if not (map and map.width and map.height and map.def) then return nil end + local bx, by = math.floor(cx / 2), math.floor(cy / 2) + if bx < 0 or by < 0 or bx >= map.width or by >= map.height then return nil end + local index = by * map.width + bx + 1 + return index, (map.def.blocks or {})[index] +end + +-- Everything a FieldMoves routine reads, gathered once. GetFacingTileCoord is +-- folded in: the facing cell is the player's own plus their direction, and +-- wTileUp is the cell above them whichever way they face. +function World:fieldContext(mon) + local p, map = self.player, self.map + local save = self.game and self.game.save + local facing = (p and p.facing) or "down" + local d = Map.DELTA[facing] or Map.DELTA.down + local fx, fy = p.cellX + d[1], p.cellY + d[2] + local blockIndex, blockId = self:blockIndexAt(fx, fy) + return { + save = save, + party = (save and save.party) or {}, + mon = mon, + facing = facing, + facingX = fx, facingY = fy, + facingColl = map:cellCollision(fx, fy), + playerColl = map:cellCollision(p.cellX, p.cellY), + upColl = map:cellCollision(p.cellX, p.cellY - 1), + tileset = map.def and map.def.tileset, + facingBlock = blockId, + facingBlockIndex = blockIndex, + environment = map.def and map.def.environment, + -- EscapeRopeOrDig's .CheckCanDig also refuses on a zeroed dig triple; + -- digFromMenu reads this rather than re-deriving the banked warp. + canEscapeRope = self:escapeRopeTarget() ~= nil, + playerState = self.playerState, + strengthActive = self.strengthActive, + -- 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), + } +end + +-- CutDownTreeOrGrass / DisappearWhirlpool: one entry of the loaded map's block +-- buffer is overwritten, the tilemap is redrawn from it and GetMovementPermissions +-- reruns -- which is why a cut tree stops blocking the step immediately. +-- +-- The original id is kept so setMap can put it back: the cart's buffer is +-- refilled from ROM by LoadMapAttributes on every map load, so a cut tree is +-- standing again the next time the map is walked into. +function World:replaceBlock(index, blockId) + local map = self.map + if not (map and index and blockId) then return false end + local blocks = map.def and map.def.blocks + if not (blocks and blocks[index]) then return false end + local mapId = map.id + local edits = self.blockEdits[mapId] + if not edits then + edits = {} + self.blockEdits[mapId] = edits + end + if edits[index] == nil then edits[index] = blocks[index] end + blocks[index] = blockId + map.blocks = blocks + self:refreshMapImages() + -- Gen 1's four payload keys off OverworldState:replaceBlock. Every Gen 2 + -- block edit lands here -- World:changeBlock's script `changeblock`, CUT, + -- WHIRLPOOL and the map callbacks all call through it -- so bx/by are + -- recovered from the flat buffer index the cart addresses blocks by, and + -- `index` is carried alongside for a listener that wants the raw one. + if Runtime.wants("world.block_replaced") then + local zero = index - 1 + Runtime.emit("world.block_replaced", { + mapId = mapId, bx = zero % map.width, + by = math.floor(zero / map.width), block = blockId, index = index, + }) + end + return true +end + +-- Drop every baked canvas of ONE map. imageFor keys by map, daytime, COLOR +-- mode and cave-flicker phase, so a block edit invalidates a handful of entries +-- and there is no reason to throw the neighbours' bakes away with them. +function World:dropMapImages(mapId) + if not mapId then return end + local prefix = mapId .. "|" + for key in pairs(self.mapImages) do + if key:sub(1, #prefix) == prefix then self.mapImages[key] = nil end + end + -- The anim cell lists and the bake palettes ride the same keys. + for _, store in ipairs({ self.animCells, self.bgSets }) do + for key in pairs(store) do + if key:sub(1, #prefix) == prefix then store[key] = nil end + end + end +end + +-- LoadMapAttributes' refill, for every map the session has edited. Neighbour +-- strips share the same buffer on the cart, so a connection crossing reloads +-- them too: this runs on any setMap, seamless or not. +-- +-- The bakes go with the blocks. A canvas is baked off map.def.blocks and +-- cached under a key that knows nothing about them, so putting a CUT tree back +-- without dropping the stump's bake left the tree cut for the rest of the +-- session -- and a MAPCALLBACK_TILES map, whose blocks are rewritten on every +-- single load, would have frozen on whichever answer it baked first. +function World:restoreBlocks() + local any = false + for mapId, edits in pairs(self.blockEdits) do + local def = self.maps and self.maps[mapId] + local blocks = def and def.blocks + if blocks then + for index, original in pairs(edits) do + blocks[index] = original + any = true + end + self:dropMapImages(mapId) + end + self.blockEdits[mapId] = nil + end + return any +end + +-- Drop the loaded map's baked canvases and bake again. Same shape as what +-- pollTimeOfDay does when the clock rolls the palette over; a block edit +-- invalidates the bake for the same reason a palette change does. A world with +-- nothing baked yet (a headless test, or the first load of a session) has +-- nothing to refresh, and the one bake setMap is about to do covers it. +function World:refreshMapImages() + if not self.mapImage then return false end + self:dropMapImages(self.map and self.map.id) + self.mapImage = self:imageFor(self.map.id) + self:rebuildNeighbors() + return true +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 def = self.sprites and self.sprites[name] + if def and self.player then + self.player:setSprite(def) + self:applySpritePalette(self.player) + end +end + +-- ---- the seven effects ---------------------------------------------------- + +-- Script_Cut: GetPartyNickname, UseCutText, then CutDownTreeOrGrass. The +-- block swap happens when the box closes, not when it opens, so the tree is +-- still standing behind the line that says it was cut. +function World:runCut(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:replaceBlock(result.blockIndex, result.replacement) + self:playSfx(SFX_PLACE_PUZZLE_PIECE_DOWN) + end) +end + +-- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and +-- PlayWhirlpoolSound (a bare SFX_SURF) in place of the snip. +function World:runWhirlpool(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:replaceBlock(result.blockIndex, result.replacement) + self:playSfx(SFX_SURF) + end) +end + +-- Script_UseFlash: the text plays SFX_FLASH from inside itself +-- (UseFlashTextScript's text_asm), and BlindingFlash then sets +-- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there +-- is to it: Palettes.daytimeFor already turns a flashed PALETTE_DARK map into +-- a NITE one, which is the cart's own .UsedFlash arm. +function World:runFlash(result) + self:playSfx(SFX_FLASH) + self:showText(Strings(result.text), function() + self.flashUsed = true + if self:applyPalettes() then self:refreshMapImages() end + end) +end + +-- UsedSurfScript: the line, then wPlayerState becomes the surf state, the +-- sprite follows it, the map music restarts (surfing has its own theme) and +-- SurfStartStep walks one slow step into the water. Getting ON is a scripted +-- step, which is why it never rolls an encounter. +function World:runSurf(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:applyPlayerState(result.state) + local audio = self.game and self.game.data and self.game.data.audio + if audio and audio.runtime and self.map then + Music.playMap(self.game.data, self.map.id) + end + if self.player and self.player.scriptStep then + self.player:scriptStep(self.player.facing) + end + self.fieldMove = { phase = "step" } + end) +end + +-- Script_UsedStrength: SetStrengthFlag runs FIRST (callasm, before the text), +-- then "<mon> used STRENGTH!", the mon's cry, `pause 3`, and +-- "<mon> can move boulders." +function World:runStrength(result) + self.strengthActive = true + self.strengthMon = result.mon + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:playMonCry(result.mon) + self.fieldMove = { + phase = "strength", timer = STRENGTH_PAUSE_FRAMES, text = result.after, + } + end) +end + +-- Script_UsedWaterfall: the line, SFX_BUBBLEBEAM, and then a loop of one +-- turn_waterfall UP step at a time. +-- +-- .CheckContinueWaterfall writes wScriptVar = 0 while the player is STILL on a +-- waterfall tile and 1 once they are off it, and the script's `iffalse .loop` +-- loops on 0. Read the flag the other way round and the climb stops on the +-- first step. +function World:runWaterfall(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:playSfx(SFX_BUBBLEBEAM) + self.fieldMove = { phase = "waterfall" } + self:waterfallStep() + end) +end + +function World:waterfallStep() + local p = self.player + if not p then return end + p.facing = "up" + if p.scriptStep then p:scriptStep("up") end +end + +-- callasm GetPartyNickname: {STRBUF} is the nickname of the mon CheckPartyMove +-- picked, and TextBox reads it back off game.stringBuffer. +function World:setNickname(mon) + if not self.game then return end + self.game.stringBuffer = + (mon and (mon.nickname or mon.name or mon.species)) or "" +end + +function World:playMonCry(mon) + local data = self.game and self.game.data + local cries = data and data.audio and data.audio.cries + local species = mon and mon.species + if not (cries and species and cries[species]) then return end + self.lastSfx = Sound.playCry(data, species) +end + +-- ---- the boulder ---------------------------------------------------------- + +function World.isStrengthBoulder(npc) + local def = npc and npc.def + return def ~= nil and def.movement == SPRITEMOVEDATA_STRENGTH_BOULDER +end + +-- .CheckStrengthBoulder (engine/overworld/player_movement.asm), reached from +-- .CheckNPC when something is standing in the way. With +-- BIKEFLAGS_STRENGTH_ACTIVE set and the object standing still, its facing is +-- pointed the way the player walked and BOULDER_MOVING_F goes up; +-- MovementFunction_Strength then steps it, but only if +-- CanObjectMoveInDirection agrees. +-- +-- The player BUMPS either way: .CheckNPC's "2" is treated exactly like a +-- solid NPC, so the boulder moves and the player stays where they were. +function World:tryPushBoulder(dir, cx, cy) + if not self.strengthActive then return false end + local npc = self:npcAt(cx, cy) + if not (npc and World.isStrengthBoulder(npc)) or npc.moving then + return false + end + local d = Map.DELTA[dir] + local tx, ty = cx + d[1], cy + d[2] + if not self.map:isWalkable(tx, ty) then return false end + for _, e in ipairs(self.entities or {}) do + if e ~= npc and e.cellX == tx and e.cellY == ty then return false end + end + npc:scriptStep(dir) + self:playSfx(SFX_STRENGTH) + -- Gen 1's four payload keys. Divergence, deliberate: Gen 1 emits from the + -- scriptMove completion callback, once the boulder has settled; Gold's + -- MovementFunction_Strength has no such callback, so this fires as the push + -- is committed and x/y are the cell the boulder is stepping ONTO -- which is + -- the same pair Gen 1's listener eventually sees. + if Runtime.wants("world.boulder_moved") then + Runtime.emit("world.boulder_moved", { mapId = self.map.id, + npcId = (npc.def and npc.def.index or 0) + 1, x = tx, y = ty }) + end + return true +end + +-- ---- running one -------------------------------------------------------- + +-- QueueScript, as far as the port is concerned: the result the model handed +-- back is turned into the script that carries it out. +function World:runFieldMove(result) + local action = result and result.action + if action == "cut" then + self:runCut(result) + elseif action == "whirlpool" then + self:runWhirlpool(result) + elseif action == "flash" then + self:runFlash(result) + elseif action == "surf" then + self:runSurf(result) + elseif action == "strength" then + self:runStrength(result) + elseif action == "waterfall" then + self:runWaterfall(result) + elseif action == "fly" then + self:openFlyMap() + elseif action == "headbutt" then + self:runHeadbutt(result.facingX, result.facingY, result.mon) + elseif action == "sweetscent" then + self:runSweetScent(result) + elseif action == "escaperope" or action == "dig" then + self:runDigEscape(result) + elseif action == "teleport" then + self:runTeleport(result) + else + return false + end + return true +end + +-- .UsedEscapeRopeScript / .UsedDigScript (engine/events/overworld.asm +-- EscapeRopeOrDig): the used-item line -- GetPartyNickname fills {STRBUF} for +-- DIG, the rope addresses {PLAYER} -- then the shared warp tail. The target +-- was resolved when the action was queued; a load the map churn has since +-- invalidated falls back to re-resolving, and to nothing at worst. +function World:runDigEscape(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + local destMapId, destWarp = result.destMap, result.destWarp + if not (destMapId and destWarp) then + destMapId, destWarp = self:escapeRopeTarget() + end + if destMapId then self:runEscapeWarp(destMapId, destWarp) end + end) +end + +-- TeleportFunction's .TeleportScript: the return line, then WarpToSpawnPoint +-- with `newloadmap MAPSETUP_TELEPORT` -- the same landing a whiteout takes, +-- which is exactly what World:warpToSpawn resolves (blackoutmod override +-- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a +-- bike arrives on foot the way `loadvar VAR_MOVEMENT, PLAYER_NORMAL` leaves +-- it. The teleport spin, like the dig spin, is sprite work and not ported. +function World:runTeleport(result) + self:setNickname(result.mon) + self:showText(Strings(result.text), function() + self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + self:runMapSetup(MAPSETUP_TELEPORT, function() + self:warpToSpawn() + return true + end) + end) +end + +-- The block a cut or whirlpool result edits. The model works in block IDs +-- because that is what field_move_blocks.asm is written in; the index comes +-- from the context that produced the result, so it is stapled on here rather +-- than threaded through the pure half. +local function withBlockIndex(result, ctx) + if result and result.replacement then + result.blockIndex = ctx.facingBlockIndex + end + if result then + result.facingX, result.facingY = ctx.facingX, ctx.facingY + end + return result +end + +-- PokemonActionSubmenu's MONMENU_FIELD_MOVE arm: the party list has already +-- chosen the mon, so the badge is checked with the noisy CheckBadge and the +-- move itself is taken on trust. Returns the result so the caller (the party +-- menu) knows whether it was refused. +-- +-- A success is QUEUED, not run. Every *Function ends in QueueScript and the +-- queued script only runs once the menus are gone -- which is the whole point: +-- the party list is still on the screen at the moment CUT is chosen, and +-- "<mon> used CUT!" belongs over the overworld. Try*OW is the other half of +-- that distinction and uses CallScript, which runs on the spot. +function World:useFieldMove(moveId, mon) + if not (self.map and self.player) then return nil end + if self.battleActive or self:busy() then + return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE } + end + local ctx = self:fieldContext(mon) + local result = withBlockIndex(FieldMoves.fromMenu(moveId, ctx), ctx) + result.mon = result.mon or mon + if result.ok then + self.queuedFieldMove = result + elseif result.text then + self:showText(Strings(result.text)) + end + return result +end + +-- The queued script, once the world owns the frame again. +function World:runQueuedFieldMove() + local queued = self.queuedFieldMove + if not queued or self:busy() then return false end + self.queuedFieldMove = nil + return self:runFieldMove(queued) +end + +-- TryTileCollisionEvent's arms, in its order, each one a "did this take the A +-- press" answer. A result with `ask` opens AskXScript's yesorno first; one +-- without runs (or refuses) straight away. +function World:runOverworldFieldMove(result) + if not result or not result.took then return false end + if not result.ok then + if result.text then self:showText(Strings(result.text)) end + return true + end + if result.ask then + self:showText(Strings(result.ask), function() + self:askYesNo(function(yes) + -- AskCutScript's `iffalse .declined` and friends: NO is a plain + -- closetext, and CUT's own map check only happens after the YES. + if yes and result.action then self:runFieldMove(result) end + end) + end) + return true + end + self:runFieldMove(result) + return true +end + +function World:tryCutOW() + local ctx = self:fieldContext() + if not Permissions.isCutTree(ctx.facingColl) then return false end + return self:runOverworldFieldMove( + withBlockIndex(FieldMoves.tryCutOW(ctx), ctx)) +end + +function World:tryWhirlpoolOW() + local ctx = self:fieldContext() + if not Permissions.isWhirlpool(ctx.facingColl) then return false end + return self:runOverworldFieldMove( + withBlockIndex(FieldMoves.tryWhirlpoolOW(ctx), ctx)) +end + +function World:tryWaterfallOW() + local ctx = self:fieldContext() + if not Permissions.isWaterfall(ctx.facingColl) then return false end + return self:runOverworldFieldMove( + withBlockIndex(FieldMoves.tryWaterfallOW(ctx), ctx)) +end + +-- TrySurfOW is the last arm and the only one with no tile test of its own: +-- anything the earlier arms did not claim reaches it, and it fails silently +-- unless the facing tile really is water. +function World:trySurfOW() + local ctx = self:fieldContext() + return self:runOverworldFieldMove( + withBlockIndex(FieldMoves.trySurfOW(ctx), ctx)) +end + +-- AskStrengthScript, which a boulder's own script jumps to (jumpstd +-- StrengthBoulderScript). Reached by talking to a boulder, not by walking +-- into one -- the walk is the push, and this is the offer to switch STRENGTH +-- on in the first place. +function World:tryStrengthOW() + local ctx = self:fieldContext() + return self:runOverworldFieldMove( + withBlockIndex(FieldMoves.tryStrengthOW(ctx), ctx)) +end + +-- ---- fly ------------------------------------------------------------------ + +-- FlyMap's region split, which reads the PLAYER's landmark and nothing else: +-- the first 46 landmarks are Johto, the rest Kanto. +function World:region() + local landmarks = self.landmarks and self.landmarks.landmarks + local id = self.map and self.map.def and self.map.def.landmark + local entry + if type(id) == "string" then + entry = landmarks and landmarks[id] + elseif type(id) == "number" then + local order = self.landmarks and self.landmarks.order + entry = order and landmarks and landmarks[order[id + 1]] + end + local index = (entry and entry.index) or (type(id) == "number" and id) or 0 + if index == 94 then return "johto" end -- LANDMARK_FAST_SHIP + return index >= 46 and "kanto" or "johto" +end + +function World:flyPoints() + return FieldMoves.flyPoints( + self.game and self.game.save, self.landmarks, self:region()) +end + +-- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP_TELEPORT` brings the +-- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens +-- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the +-- same pair of fades a door is, which is where the two fly animations ride: +-- `lift` below hands them to World:updateMapSetup. +function World:flyTo(spawnId) + local spawn = self.landmarks and self.landmarks.spawns + and self.landmarks.spawns[spawnId] + if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then + return false + end + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + local ok = self:runMapSetup(MAPSETUP_TELEPORT, function() + return self:setMap(spawn.map, spawn.x, spawn.y, "down") + end) + -- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off + -- lift under the fade out, the landing under the fade in. + if self.mapSetup then self.mapSetup.lift = true end + return ok +end + +-- _FlyMap: the town map with the cursor locked to visited flypoints, A takes +-- the one under it and B leaves. +-- +-- The screen itself is src/ui/gen2/Pokegear.lua's fly mode (Pokegear.FLY_MAP), +-- which draws the same town map the MAP card does with TownMapBubble's +-- "Where?" plate over it instead of the card strip. A run with no love at all +-- (a headless probe) has no screen to push, so the destinations are offered +-- one at a time through the same yesorno box every other field move uses. +function World:openFlyMap() + local points = self:flyPoints() + if #points == 0 then return false end + -- Loaded on demand and through pcall: a headless run has no love, and this + -- is the only place in the world that reaches for a screen module by hand. + local okGear, Pokegear = pcall(require, "src.ui.gen2.Pokegear") + if okGear and Pokegear.FLY_MAP and self.game and self.game.stack then + Screens.push(self.game, "Gen2Pokegear", { + save = self.game.save, + currentLandmark = self:currentLandmarkId(), + fly = points, + onFly = function(spawnId) + self.game.stack:pop() + self:flyTo(spawnId) + end, + onClose = function() self.game.stack:pop() end, + }) + return true + end + self:askFlyPoint(points, 1) + return true +end + +function World:askFlyPoint(points, index) + local row = points[index] + if not row then return end + local name = (row.name or row.landmark):gsub("\n", " ") + self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function() + self:askYesNo(function(yes) + if yes then + self:flyTo(row.spawn) + else + self:askFlyPoint(points, index + 1) + end + end) + end) +end + +function World:currentLandmarkId() + local def = self.map and self.map.def + local id = def and def.landmark + if type(id) == "string" then return id end + local order = self.landmarks and self.landmarks.order + return order and id and order[id + 1] or nil +end + +-- ---- the per-frame half --------------------------------------------------- + +-- The tail of the scripts above: a `pause`, the surf step landing, and the +-- waterfall climb's loop. Ticked alongside the fishing cast and the tree +-- shake for the same reason they are -- on the cart these are script commands, +-- so the world is frozen for them. +function World:updateFieldMove() + local st = self.fieldMove + if not st then return end + if self.textbox or self.choicebox then return end + if st.timer and st.timer > 0 then + st.timer = st.timer - 1 + return + end + if st.phase == "strength" then + self.fieldMove = nil + if st.text then self:showText(Strings(st.text)) end + return + end + if self.player and self.player.moving then return end + if st.phase == "waterfall" then + -- Still on a waterfall tile: another turn_waterfall UP. + local coll = self.map:cellCollision(self.player.cellX, self.player.cellY) + if FieldMoves.waterfallContinues(coll) then + self:waterfallStep() + return + end + end + self.fieldMove = nil +end + +-- Which song this battle fights to (engine/battle/start_battle.asm +-- PlayBattleMusic), and the facts BattleMusic needs to pick it: the class the +-- opponent belongs to, the member inside it (only RIVAL2 reads that), the +-- map's landmark for RegionCheck, and the clock. +function World:battleMusicContext(opts) + local members = self.constants and self.constants.trainerClassMembers + local trainer = opts and opts.trainer + return { + class = trainer and trainer.classId, + member = trainer and trainer.memberId, + members = trainer and trainer.classId and members + and members[trainer.classId] or nil, + landmark = self.map and self.map.def and self.map.def.landmark, + daytime = self.daytime, + } +end + +function World:playBattleMusic(opts) + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.songs) then return nil end + local song = BattleMusic.battleSong(self:battleMusicContext(opts)) + if not (song and audio.songs[song]) then return nil end + Music.play(data, song, true, { reason = "battle" }) + return song +end + +-- DoBattleTransition. Returns true when the wipe took the screen, false when +-- there is nothing to wipe (a headless run, or a battle started before the map +-- is up) and the battle should just come straight in. +function World:pushBattleTransition(battle, opts, onDone) + local game = self.game + if not (game and game.stack and self.map) then return false end + Screens.push(game, "Gen2BattleTransition", { + world = self, + trainer = opts and opts.trainer and true or false, + environment = self.map.def and self.map.def.environment, + playerLevel = battle and battle.player and battle.player.level, + enemyLevel = battle and battle.enemy and battle.enemy.level, + onDone = onDone, + }) + return true +end + +-- Push the battle screen. Kept here rather than in Game2 so a trainer +-- script and a grass step start a battle the same way. +-- +-- The order is the cart's: DoBattleTransition owns the screen first, and only +-- when it has finished blacking the overworld out does the battle screen come +-- up. PlayBattleMusic runs BEFORE the transition, which is why the battle +-- theme is already going while the wipe is still spinning. +function World:startBattle(opts, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone("win") end + return false + end + local battle = Battle.new({ + data = game.data, + -- BATTLETYPE_TUTORIAL fights with an EMPTY party: engine/battle/core.asm + -- jumps straight to BattleMenu without a send-out, so the DUDE's demo has + -- no player mon at all and the caller passes its own party in. + party = opts.party or (game.save and game.save.party) or {}, + wild = opts.wild, + trainer = opts.trainer, + -- wMoney and wMomsMoney, for WinTrainerBattle's payout + -- (src/battle/gen2/Prize.lua). The battle writes both accounts itself, + -- the way the cart's own trainer-defeated arm does, so a fight that never + -- comes back through the script still pays. + save = game.save, + -- BATTLETYPE_ROAMING: the save slot whose HP byte the end of the battle + -- writes back. Only World:tryWildEncounter sets it. + roaming = opts.roaming, + -- wBattleType, when the script armed one: the FORCESHINY / TRAP + -- no-escape rules live in Battle:tryRun and the force-switch handler. + battleType = opts.battleType, + }) + self:playBattleMusic(opts) + local function pushBattle() + -- wBattleMode, as far as the overworld is concerned: a battle screen this + -- world pushed is up. The PACK opened from inside one must not take the + -- field path, because a rod is ITEMMENU_NOUSE in battle + -- (data/items/attributes.asm) and the field path would fish from under it. + self.battleActive = true + Screens.push(game, "Gen2BattleState", { + battle = battle, + save = game.save, + music = self:battleMusicContext(opts), + -- BATTLETYPE_CONTEST: the park ball menu, the held catch and the draw on + -- the last ball. Only World:tryContestEncounter sets it. + contest = opts.contest, + -- BATTLETYPE_TUTORIAL: the DUDE's back-pic, no player HUD, the forced + -- POKE BALL and the throw that cannot fail. Only + -- World:startCatchTutorial sets it. + tutorial = opts.tutorial, + onDone = function(outcome) + self.battleActive = nil + game.stack:pop() + -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. + -- The port never forfeits or draws a battle, so "lose" is the only + -- other outcome startBattle's onDone hands back; VAR_BATTLERESULT + -- reads this back masked with ~BATTLERESULT_BITMASK, same as the cart. + self.lastBattleResult = (outcome == "lose") and 1 or 0 + -- BattleEnd_HandleRoamMons, which runs on the way out of EVERY wild + -- battle: this one banks the beast's HP and moves it, and any other + -- wild battle takes the 1-in-16 `.not_roaming` roll that moves them + -- anyway. Before the loss warp, because the walk is computed against + -- the map the player is standing on and the warp is about to change it + -- to the spawn point. + if battle.wild then + self:roamMonsAfterBattle(battle.roaming, outcome, + battle.enemy and battle.enemy.hp) + -- Script_reloadmapafterbattle's `.was_wild` arm: `bit + -- BATTLERESULT_BOX_FULL, a / jr z, .done`, then a LoadMemScript of + -- Script_SpecialBillCall (engine/overworld/scripting.asm:1097-1104), + -- which is `callasm .LoadBillScript` -- LoadCallerScript with + -- e = PHONE_BILL -- falling into Script_ReceivePhoneCall + -- (engine/phone/phone.asm:441-446). BattleState sets boxFilled + -- exactly where .SendToPC sets the bit. + -- + -- NOT one of the SPECIALCALL_* rows, so Phone.queueSpecialCall is the + -- wrong door: this is a received call wearing Bill's own contact and + -- his CALLER script. LoadMemScript is a deferral, which is what + -- World:queuedScript is here: the ring lands on the first overworld + -- frame after the reload rather than over the battle screen. It also + -- carries no `pause 30`, unlike the special-call wrappers. + if battle.boxFilled then + local Phone = require("src.core.gen2.Phone") + local call = Phone.loadCallerScript( + Phone.PHONECONTACT_BILL, "incoming", "caller") + if self.vm and call.scriptKey + and self.vm.scripts[call.scriptKey] then + self.vm.curPhoneCaller = call.contact + local name, className = Phone.contactName(call.contact, + game.data and game.data.trainers) + self.queuedScript = + require("src.core.gen2.PhoneRing").script(call, name, className) + end + end + end + -- A loss warps home with a healed party, the way a whiteout does -- + -- because it IS one: Script_reloadmapafterbattle's `cp LOSE` jumps + -- into Script_BattleWhiteout (engine/events/whiteout.asm), which is + -- Script_Whiteout with a different BG map call in front of it. So + -- the losing half of the wallet goes here too, in the cart's order: + -- HealParty, then HalveMoney, then GetWhiteoutSpawn, then the warp. + -- The Bug Contest is the one exception the script itself carries + -- (`checkflag ENGINE_BUG_CONTEST_TIMER / iftrue .bug_contest` skips + -- both callasms), so a wipe in the park costs nothing. + -- + -- BATTLETYPE_CANLOSE is the other exception, and it is the battle + -- engine's own: LostBattle (engine/battle/core.asm) prints the loss + -- text for this type and returns with the player exactly where they + -- fought, and maps/CherrygroveCity.asm follows the battle with + -- `reloadmap` and its .AfterYourDefeat arm -- the rival's shove and + -- walk-off play at the battle site, and `special HealParty` at + -- .FinishRival is what heals the party, not a whiteout. Warping here + -- moved the loser to the spawn point and then ran that walk-off over + -- whatever stood there. + if outcome == "lose" and opts.battleType ~= BATTLETYPE_CANLOSE then + self:healParty() + if not BugContest.isActive(game.save) then + CallAsm.run(self, "HalveMoney") + CallAsm.run(self, "GetWhiteoutSpawn") + end + -- The second of the two blackout seams, same as Gen 1's pair (the + -- poison walk in World:whiteOut is the other), and guarded for the + -- same reason its twin is. + if Runtime.wants("world.blacked_out") then + Runtime.emit("world.blacked_out", + { save = game.save, healTarget = self:healPoint() }) + end + self:warpToSpawn() + end + -- RestartMapMusic: the map theme comes back with the overworld, over + -- whatever the battle left playing (the victory jingle loops until + -- exactly here). Unconditional, because the one-shot + -- wDontPlayMapMusicOnReload the Sudowoodo and Snorlax battles set is + -- not consumed here at all: `dontrestartmapmusic` is the command AFTER + -- `startbattle` (maps/CherrygroveCity.asm:124-126), so it has not even + -- run yet, and it is the `reloadmap` behind it that owns the silence + -- (World:forceMapMusic). Reading the flag here instead left it set + -- with nothing to consume it, and the NEXT battle -- very often the + -- repeatable Route 29 catch tutorial -- ended with the map music + -- stopped for good. A wild encounter has no reload behind it and + -- still needs this restore. + self:restoreMapMusic() + -- BugCatchingContestBattleScript's own tail, which runs after + -- `reloadmapafterbattle`: out of park balls sends the player back to + -- the gate rather than back into the grass. + if opts.contest and self:bugContestBattleOver() then return end + -- Script_reloadmapafterbattle's .notblackedout arm: `bit + -- BATTLESCRIPT_WILD_F, d` is SET for a trainer (Script_loadtrainer + -- writes (1 << 7) | 1, Script_loadwildmon only (1 << 7) -- the flag's + -- name reads backwards), so it is a won TRAINER battle and nothing + -- else that gives Mom a chance to spend the savings. + if opts.trainer and outcome ~= "lose" then self:momTriesToBuy() end + -- A scripted battle resumes the VM here; the trainer flag and the + -- after-battle text are the commands waiting on the other side. + if onDone then onDone(outcome) end + end, + }) + end + local transition = self:pushBattleTransition(battle, opts, pushBattle) + if not transition then pushBattle() end + return true +end + +-- `startbattle` from a script: a trainer record (class + member) or a +-- loadwildmon pair. The VM is parked on the yield until onDone fires, so the +-- rest of the trainer script (flag set, after-battle text) runs on return. +function World:startScriptedBattle(record, wild, onDone) + local data = self.game and self.game.data + local opts = {} + if record then + -- The battle screen names a trainer the way the cart does: class then + -- name, "YOUNGSTER JOEY". + local display = record.name + if record.className and record.className ~= "" then + display = record.className .. " " .. (record.name or "") + end + local bareName = record.name + -- PlaceEnemysName (home/text.asm:327), which is what the <ENEMY> character + -- resolves to: with wTrainerClass RIVAL1 or RIVAL2 it prints wRivalName + -- ALONE, no class prefix and no parties-table name, because every rival row + -- in data/trainers/parties.asm literally carries `db "?@"`. wRivalName is + -- what `special NameRival` (maps/ElmsLab.asm:515) wrote; before that screen + -- has been through it still holds InitializeNPCNames' "???", which is the + -- name the Cherrygrove theft battle prints, so "???" is the fallback here + -- and NOT NameRival's own SILVER default. + -- record.className is deliberately left alone: it is the class key + -- Palettes.trainerColors and BattleMusic read, not a display string. So is + -- Trainers.lookup's own name -- GetTrainerName (engine/battle/ + -- read_trainer_party.asm:326) has no rival arm, so `gettrainername` really + -- does answer "?". + if record.classId == "RIVAL1" or record.classId == "RIVAL2" then + local save = self.game and self.game.save + display = (save and save.rival and save.rival.name) or "???" + bareName = display + end + opts.trainer = { + class = record.class, + -- The class and member CONSTANTS (RIVAL2, RIVAL2_2_CHIKORITA), which is + -- what PlayBattleMusic's ladder compares against. + classId = record.classId, + memberId = record.id, + name = display, + trainerName = bareName, + className = record.className, + party = Trainers.party(data, record), + -- TRNATTR_BASE_REWARD, the third byte of the class's seven-byte + -- attributes row. ComputeTrainerReward multiplies it by the LAST + -- party row's level, which is why the party above and this byte have + -- to travel together. + baseMoney = record.baseMoney, + -- The rest of the class's attributes row (data/trainers/attributes.asm): + -- the AI personality bytes AIActionCount / TRNATTR_AI_MOVE_WEIGHTS reads + -- through Ai.flagsOf, and the two TRNATTR_ITEM slots AI_TryItem may + -- reach for. Trainers.lookup already builds both -- `items` as a fresh + -- copy, precisely so a battle using one up does not empty the class + -- record -- and this is the only place a scripted battle is built, so + -- leaving them off here is what made every trainer in the game fight + -- with no personality and no potions. + attributes = record.attributes, + items = record.items, + } + elseif wild and wild.species then + local id, def = speciesByIndex(data and data.pokemon, wild.species) + -- InitEnemyMon `.NotRoaming` / BATTLETYPE_FORCESHINY: the DV pair is + -- forced to ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA (Attack 14, the + -- rest 10) before stats are built, which is the whole of what makes the + -- Red Gyarados red -- and caught, it keeps the DVs and stays shiny. + local monOpts + if self:battleType() == BATTLETYPE_FORCESHINY then + monOpts = { dvs = { attack = 14, defense = 10, speed = 10, + special = 10 } } + end + opts.wild = id and Mon.new(data, id, wild.level or 5, monOpts) or nil + -- InitEnemyMon's `.WildItem` / BATTLETYPE_FORCEITEM: Item1 is handed over + -- unconditionally, no roll, which is the only wild-item path modeled -- + -- see Mon.new's own note on why the general 25%/8% roll is not. Read + -- here rather than after startBattle, because scriptVars[VAR_BATTLETYPE] + -- is cleared the moment this function hands off to it. + if opts.wild and self:battleType() == BATTLETYPE_FORCEITEM then + local given = def and def.items and def.items[1] + if given then opts.wild.item = given end + end + end + if not (opts.trainer and #opts.trainer.party > 0) and not opts.wild then + if onDone then onDone("win") end + return false + end + -- wBattleType, which `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_*` armed: + -- FORCEITEM 10 (Lugia, Ho-Oh, the Red Gyarados), FORCESHINY 7 (Lake of Rage), + -- TRAP 9 (the Rocket base), CANLOSE 1 (the Cherrygrove rival). It is a + -- ONE-SHOT on the cart -- BattleStart_TrainerBattle / StartWildBattle reset + -- it -- so the value is taken and cleared here and handed to the battle, + -- which is the half that still has to act on each case. + opts.battleType = self:battleType() + self.scriptVars[VAR_BATTLETYPE] = nil + return self:startBattle(opts, onDone) +end + +-- `catchtutorial BATTLETYPE_TUTORIAL`: CatchTutorial (engine/events/ +-- catch_tutorial.asm) around a real battle. The name swap, the DUDE's pack +-- and the option override live in src/core/gen2/CatchTutorial.lua; the battle +-- itself is the ordinary wild path with an empty party, which is what makes it +-- start on the battle menu with no mon out. +-- +-- The wild mon is the one the `loadwildmon RATTATA, 5` in front of the command +-- left behind, and it is built through Mon.new like every other Gen 2 party +-- member so it arrives with a real moveset and real stats. +function World:startCatchTutorial(wild, battleType, onDone) + local game = self.game + local data = game and game.data + local save = game and game.save + local mon + if wild and wild.species then + local id = speciesByIndex(data and data.pokemon, wild.species) + mon = id and Mon.new(data, id, wild.level or 5) or nil + end + if not mon then + -- No wild mon means the script never ran `loadwildmon`, which no reachable + -- `catchtutorial` does. Hand the script straight back rather than opening + -- an empty battle. + if onDone then onDone() end + return false + end + local state = CatchTutorial.begin(save, game and game.options) + return self:startBattle({ + wild = mon, + -- The DUDE has no mon of his own: the battle opens on the menu. + party = {}, + tutorial = true, + battleType = battleType or CatchTutorial.BATTLETYPE_TUTORIAL, + }, function() + CatchTutorial.finish(save, game and game.options, state) + if onDone then onDone() end + end) +end + +-- Every `loadtrainer` and every `gettrainername` comes through here, which is +-- why the CAL2 redirect lives in TrainerHouse.lookup rather than at the +-- Trainer House's own call site: ReadTrainerParty tests the class before it +-- indexes the parties table, so the redirect has to sit in front of the table +-- for anything that can name CAL2, not just for that one script. +function World:trainerParty(class, member) + return TrainerHouse.lookup(self.game and self.game.data + and self.game.data.trainers, self.game and self.game.save, class, member) +end + +-- MomTriesToBuySomething (engine/events/mom_phone.asm), reached from the +-- trainer arm of `reloadmapafterbattle`. src/core/gen2/MomShopping.lua owns +-- the two shopping lists and the balance walk; this is the map half plus the +-- call itself. +-- +-- The cart does not SPEAK here: it `LoadMemScript`s the phone call and lets +-- the overworld pick it up, which is what wMapReentryScriptQueueFlag at the +-- top of the routine is guarding against. World:queuedScript is that same +-- deferral -- runQueuedScript drains it on the first frame the overworld owns +-- with no text box open -- so the four lines land after the trainer's own +-- after-battle script has finished rather than on top of it. +-- +-- The ring is the call's own: MomTriesToBuySomething's .Script is `callasm +-- .ASMFunction / farsjump Script_ReceivePhoneCall` with Mom's pages queued in +-- wCallerContact, so the queued rows here ride the same ring chrome every +-- other incoming call does (src/core/gen2/PhoneRing.lua) with PHONE_MOM as +-- the caller. The four writetexts are the whole of Mom_GetScriptPointer's +-- script either way. +function World:momTriesToBuy() + local save = self.game and self.game.save + if not save then return nil end + local def = self.map and self.map.def + local purchase = MomShopping.tryBuy(save, { + events = self.events, + data = self.game and self.game.data, + -- RandomRange returns 0..n-1. + random = function(n) return math.random(n) - 1 end, + -- GetMapPhoneService: a map with no reception `ret`s before the balance + -- is looked at, so Mom simply tries again after the next trainer. + phoneService = (def == nil) or (def.phoneService ~= false), + }) + if not purchase then return nil end + local script = {} + for _, page in ipairs(MomShopping.pages(purchase)) do + -- `rawtext`, not `writetext`: these six strings sit in data/text/ + -- common_1.asm behind a phone script the extractor never walks, so there + -- is no text.lua key to name them by. + script[#script + 1] = { op = "rawtext", text = page } + end + script[#script + 1] = { op = "end" } + local Phone = require("src.core.gen2.Phone") + if self.vm then self.vm.curPhoneCaller = Phone.PHONECONTACT_MOM end + self.queuedScript = require("src.core.gen2.PhoneRing").script( + { scriptKey = script }, + Phone.NON_TRAINER_NAMES[Phone.PHONECONTACT_MOM]) + -- A doll changes what stands in the bedroom, and the room is rebuilt from + -- the flags on a MAP LOAD -- so nothing has to be dropped here, the same + -- way Decorations' own menu leaves it to the PC's warp. + return purchase +end + +-- PlayTrainerEncounterMusic: the short jingle that plays while the trainer +-- walks up to you, one per class out of data/trainers/encounter_music.asm. +-- It is NOT the battle theme -- PlayBattleMusic replaces it a moment later +-- when the transition starts. +-- +-- A cache built before the table was extracted has no `encounterMusic`, so the +-- old behaviour (the shared Johto trainer battle theme) is the fallback. +function World:playTrainerEncounterMusic(class) + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.songs) then return end + local entry = Trainers.classIndex(data.trainers)[class] + local song = entry and entry.encounterMusic + if song and song ~= "Music_Nothing" and audio.songs[song] then + Music.play(data, song, true, { reason = "trainer_encounter" }) + return + end + for _, name in ipairs({ "Music_JohtoTrainerBattle", + "Music_KantoTrainerBattle" }) do + if audio.songs[name] then + Music.play(data, name, true, { reason = "trainer_encounter" }) + return + end + end +end + +-- showemote: the bubble sits one cell above the object for `frames` frames. +-- Drawn by World:draw over the map, so it rides the same camera as the NPC. +function World:showEmote(emote, object, frames) + local sheet = self.emoteImages and self.emoteImages[ + self.emoteOrder and self.emoteOrder[(emote or 0) + 1]] + -- LAST_TALKED (-2) is the object the script is about; everything else is a + -- plain object id. + local ent = (object == -2) and (self.talkNpc or self.trainerNpc) + or self:objectEntity(object or 0) + if not (sheet and ent) then return end + self.emote = { image = sheet, entity = ent, left = frames or 30 } +end + +-- HealMachineAnim (engine/events/heal_machine_anim.asm), the light show the +-- nurse runs between "we'll need your POKeMON" and "thank you for waiting". +-- +-- .PC_ElmsLab_OAM / .HOF_OAM transcribed as screen positions (each dbsprite's +-- raw OAM bytes minus the hardware's 8/16 OAM origin). The cart lays them +-- out at fixed screen coordinates because the player is always standing on +-- the machine's own talk cell, BG-aligned at (64,64) -- the same anchor +-- Camera:follow keeps -- so each element's world position is the player's +-- cell corner plus (sx - 64, sy - 64), and the overlay stays glued to the +-- machine at any zoom. +-- +-- `machine` is the two $7c tiles .PC_LoadBallsOntoMachine places before the +-- party loop; `balls` fill one per party member in OAM order (top pair +-- first), the right column OAM_XFLIPped. wScriptVar picks the table -- +-- HEALMACHINE_POKECENTER 0, HEALMACHINE_ELMS_LAB 1 (the same table shifted +-- by `bcpixel 2, 4`), HEALMACHINE_HALL_OF_FAME 2 (all balls, fanning out +-- from the machine's centre line). +local HEAL_MACHINE_LAYOUT = { + [0] = { + machine = { { 26, 16 }, { 30, 16 } }, + balls = { { 24, 22 }, { 32, 22, true }, { 24, 27 }, { 32, 27, true }, + { 24, 32 }, { 32, 32, true } }, + }, + [2] = { + balls = { { 73, 44 }, { 78, 44 }, { 69, 43 }, { 82, 43 }, + { 65, 41 }, { 85, 41 } }, + }, +} +do + local pc = HEAL_MACHINE_LAYOUT[0] + local elm = { machine = {}, balls = {} } + for i, t in ipairs(pc.machine) do + elm.machine[i] = { t[1] + 16, t[2] + 32 } + end + for i, b in ipairs(pc.balls) do + elm.balls[i] = { b[1] + 16, b[2] + 32, b[3] } + end + HEAL_MACHINE_LAYOUT[1] = elm +end + +-- The special is BLOCKING: `onDone` is what resumes the script, so the +-- nurse's next line never comes up over the machine still running. The +-- cart's first guard is `ld a, [wPartyCount] / and a / ret z`, and a cache +-- with no sheet (from before the extractor carried it) resumes the same way +-- rather than hanging the script. +function World:startHealMachineAnim(animType, onDone) + local party = self.game and self.game.save and self.game.save.party + local layout = HEAL_MACHINE_LAYOUT[animType or 0] or HEAL_MACHINE_LAYOUT[0] + if not (party and #party > 0 and self.healMachineImage) then + if onDone then onDone() end + return + end + local p = self.player + self.healAnim = { + layout = layout, + hof = animType == 2, + balls = math.min(#party, #layout.balls), + lit = 0, timer = 0, phase = "balls", flashes = 0, rotation = 0, + px = p and p.cellX * 16 or 0, + py = p and p.cellY * 16 or 0, + onDone = onDone, + } +end + +-- One frame of the machine, on the cart's own timeline: each party member's +-- ball lands with SFX_SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then +-- MUSIC_HEAL plays over .FlashPalettes8Times -- eight rotations of the OBJ +-- palette ten frames apart. The Hall of Fame arm swaps the jingle for +-- SFX_GAME_FREAK_LOGO_GS and rings SFX_BOOT_PC once the flashing stops. +-- The special returns after the last flash's delay, which is when the balls +-- clear -- the cart leaves its OAM to the overworld redraw the ended script +-- allows, and this is that same moment. +function World:stepHealAnim() + local ha = self.healAnim + if not ha then return end + ha.timer = ha.timer + 1 + if ha.phase == "balls" then + if ha.lit == 0 or ha.timer >= 30 then + ha.timer = 0 + if ha.lit < ha.balls then + ha.lit = ha.lit + 1 + self:playSfxNamed("Sfx_SecondPartOfItemfinder", + SFX_SECOND_PART_OF_ITEMFINDER) + else + ha.phase = "flash" + if ha.hof then + self:playSfxNamed("Sfx_GameFreakLogoGs", SFX_GAME_FREAK_LOGO_GS) + else + -- .PlayHealMusic. playOnce hands the map its theme back when the + -- jingle ends; the script's own `pause 30` + RestartMapMusic + -- behind the special covers a cache whose song is missing. + Music.playOnce(self.game.data, "Music_HealPokemon") + end + end + end + elseif ha.phase == "flash" then + -- FlashPalettes8Times flashes FIRST and then delays, so the first + -- rotation lands on the same frame the jingle starts. + if ha.flashes == 0 or ha.timer >= 10 then + ha.timer = 0 + if ha.flashes >= 8 then + if ha.hof then + self:playSfxNamed("Sfx_BootPc", SFX_BOOT_PC) + end + local done = ha.onDone + self.healAnim = nil + if done then done() end + return + end + ha.flashes = ha.flashes + 1 + -- The CGB arm of .FlashPalettes rotates the four colours one slot per + -- flash; 8 % 4 lands the palette back where it started, exactly the + -- way the DMG arm's XOR does after an even count. + ha.rotation = ha.flashes % 4 + end + end +end + +-- The overlay: OBJs on the cart, drawn over everyone the same way the emote +-- bubble is. The flash is the palette rotation expressed as an rBGP-shaped +-- byte through GbcPalette.remap, so the DMG and CLASSIC colour modes keep a +-- visible flash instead of collapsing to their fixed shades. +function World:drawHealAnim(s, billboard) + local ha = self.healAnim + local img = self.healMachineImage + if not (ha and img) then return end + local G = love.graphics + if not self.healMachineQuads then + local w, h = img:getWidth(), img:getHeight() + self.healMachineQuads = { + machine = G.newQuad(0, 0, 8, 8, w, h), -- $7c, the machine's light + ball = G.newQuad(8, 0, 8, 8, w, h), -- $7d, one ball + } + end + local cam = self.camera + local ox, oy = ha.px - 64, ha.py - 64 + local function screen(sx, sy) + return math.floor((ox + sx - cam.x) * s), math.floor((oy + sy - cam.y) * s) + end + local function body() + local shaded = false + local pal = self.healMachinePalette + if pal and GbcPalette.available() then + local byte = 0 + for i = 0, 3 do byte = byte + ((i + ha.rotation) % 4) * (4 ^ i) end + shaded = GbcPalette.useRaw( + GbcPalette.remap(GbcPalette.resolve(pal), byte)) + end + G.setColor(1, 1, 1, 1) + for _, t in ipairs(ha.layout.machine or {}) do + local tx, ty = screen(t[1], t[2]) + G.draw(img, self.healMachineQuads.machine, tx, ty, 0, s, s) + end + for i = 1, ha.lit do + local b = ha.layout.balls[i] + if b then + local bx, by = screen(b[1] + (b[3] and 8 or 0), b[2]) + G.draw(img, self.healMachineQuads.ball, bx, by, 0, + b[3] and -s or s, s) + end + end + if shaded then GbcPalette.clear() end + end + if billboard then + local anchor = (ha.layout.machine or ha.layout.balls)[1] + local fx, fy = screen(anchor[1] + 4, anchor[2] + 8) + billboard(fx, fy, body) + else + body() + end +end + +-- The PokemonCenterPC / PlayersHousePC specials: push that PC's screen. +-- The script is parked on the VM's own resume, so the PC's own B closes it and +-- the rest of the script continues. +-- `opts.house` is _PlayersHousePC rather than PokemonCenterPC, and the two +-- are different screens on the cart: the center's is the whose-PC top menu +-- (src/ui/gen2/CenterPcMenu.lua), the bedroom's is the item PC alone +-- (src/ui/gen2/ItemPcMenu.lua, PLAYERSPC_HOUSE) with PLAYERSPCITEM_DECORATION +-- on its list and no box access. The bedroom's `onDone` gets the c the cart +-- returns -- TRUE only if a decoration moved, which is what makes +-- PlayersHousePCScript take its `.Warp` arm. +function World:openPc(opts) + opts = opts or {} + local game = self.game + if not (game and game.stack) then + if opts.onDone then opts.onDone(false) end + return false + end + Screens.push(game, opts.house and "Gen2ItemPcMenu" or "Gen2CenterPcMenu", { + save = game.save, + house = opts.house, + events = self.events, + onClose = function(changed) + game.stack:pop() + if opts.onDone then opts.onDone(changed and true or false) end + end, + }) + return true +end + +-- ToggleDecorationsVisibility (PLAYERS_HOUSE_2F's MAPCALLBACK_NEWMAP). Both +-- halves of each row land here: the sprite byte in wVariableSprites and the +-- object's own event flag, which is what decides whether the object is built +-- at all. +-- +-- Called from a map callback, so it must not rebuild anything itself: setMap +-- has not laid the objects out yet when NEWMAP runs, and World:setVariableSprite +-- would rebuild people against the map being LEFT. +function World:toggleDecorationsVisibility() + local state = Decorations.state(self.game and self.game.save) + for _, row in ipairs(Decorations.visibility(state)) do + self.events:set(row.flag, row.hidden) + if not row.hidden then self.variableSprites[row.sprite] = row.byte end + end +end + +-- ToggleMaptileDecorations (the MAPCALLBACK_TILES one). The blocks go through +-- World:changeBlock, so the bake this map already has is dropped and the edit +-- is undone by the next LoadMapAttributes -- which is right: the callback runs +-- again on every load and repaints from the same eight bytes. +function World:toggleMaptileDecorations() + local state = Decorations.state(self.game and self.game.save) + for _, tile in ipairs(Decorations.tiles(state)) do + self:changeBlock(tile.x, tile.y, tile.block) + end + -- SetPosterVisibility, inline in the same routine: a bare wall is not + -- readable, so the poster's BGEVENT_IFSET flag follows the slot. + self.events:set(Decorations.EVENT_PLAYERS_ROOM_POSTER, + Decorations.posterVisible(state)) +end + +-- Script_pokemart -> OpenMartDialog (engine/items/mart.asm). The clerk's whole +-- conversation is one blocking screen on the cart, so the script parks on the +-- VM's resume and the mart's own QUIT is what lets the next command run -- +-- exactly the arrangement openPc uses for the storage system. +function World:openMart(martType, martId, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + Screens.push(game, "Gen2MartMenu", { + save = game.save, + items = game.data and game.data.items, + marts = self.marts, + martType = martType, + martId = martId, + -- text.lua carries the clerk's whole conversation, seeded by name. + text = self.text, + onClose = function() + game.stack:pop() + if onDone then onDone() end + end, + }) + return true +end + +-- `loadmenu` then `verticalmenu` / `_2dmenu`: the static menu a script puts up. +-- The extractor follows the MenuHeader pointer now, so the header arrives with +-- its box, its flags and its item strings; a cache built before that has only +-- the raw address, and with nothing to draw the honest answer is the one +-- StaticMenuJoypad gives for B. +-- +-- The menu is NOT opaque: the cart leaves the text box the script opened +-- underneath it (`opentext` / `writetext` / `loadmenu`), which is why the +-- vending machines read as a price list over a speech line. +function World:openScriptMenu(header, style, onChoose) + local game = self.game + local items = header and (header.items or header.gridItems) + -- The balance box a `special` left standing (showCoins / showMoney above). + -- Consumed here whether or not the menu opens: CloseWindow takes the box down + -- with the menu, so it must not survive into the next one. + local balance = self.scriptBalance + self.scriptBalance = nil + if not (game and game.stack) or not items or #items == 0 then + if onChoose then onChoose(0) end + return false + end + Screens.push(game, "Gen2ScriptMenu", { + header = header, + style = style, + balance = balance, + save = game.save, + onChoose = function(index) + game.stack:pop() + if onChoose then onChoose(index) end + end, + }) + return true +end + +-- `trade trade_id` -> NPCTrade (engine/events/npc_trade.asm), the whole +-- blocking conversation off data/events/npc_trades.asm. The extractor emits +-- that table and the 5x3 TradeTexts block now, so all six trades have both +-- their rules and their lines; before, the command was a no-op. +-- +-- NPCTrade writes no wScriptVar, so the script carries on either way and there +-- is nothing to answer. +function World:openNpcTrade(id, onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return false + end + Screens.push(game, "Gen2TradeMenu", { + trade = id, + save = game.save, + eventTables = self.eventTables, + onClose = function() + game.stack:pop() + if onDone then onDone() end + end, + }) + return true +end + +-- `elevator floor_list` -> Elevator (engine/events/elevator.asm). +-- +-- Three things in order: find the row for the floor the player got in on +-- (wBackupMapNumber, which is the map they warped in FROM), ask which floor, +-- and then Elevator_GoToFloor -- which does not warp. It writes the chosen +-- row's warp number and map into wBackupWarpNumber / wBackupMapGroup / +-- wBackupMapNumber, and the elevator's own door, a `warp_event` whose +-- destination warp is -1, reads them when the player walks out. +-- +-- `.FindCurrentFloor` failing is `scf`: the command quits with no menu at all, +-- which is what happens if a script ever runs `elevator` somewhere that is not +-- in its own list. +function World:openElevator(floors, onDone) + local game = self.game + local rows = {} + for _, row in ipairs(floors or {}) do + if row.destMap and self.maps[row.destMap] then rows[#rows + 1] = row end + end + local origin + for _, row in ipairs(rows) do + if row.destMap == self.backupMapId then origin = row break end + end + if not (game and game.stack) or not origin then + if onDone then onDone(nil) end + return false + end + Screens.push(game, "Gen2ElevatorMenu", { + floors = rows, + currentMap = self.backupMapId, + floorNames = self.eventTables.floorNames, + onDone = function(row) + game.stack:pop() + if row then + self.backupWarp = { warp = row.destWarp, map = row.destMap } + end + if onDone then onDone(row) end + end, + }) + return true +end + +-- Pokecenter heal (the HealParty special): full HP, full PP, no status, for +-- every party member. The cart does this in one pass over wPartyMons, and it +-- is also what a whiteout does before warping home. +function World:healParty() + local save = self.game and self.game.save + for _, mon in ipairs((save and save.party) or {}) do + mon.hp = mon.maxHp or mon.hp + mon.status = nil + mon.statusTurns = nil + for _, move in ipairs(mon.moves or {}) do + if type(move) == "table" then move.pp = move.maxPp or move.pp end + end + end +end + +-- The rival naming screen (the NameRival special, engine/events/specials.asm): +-- `farcall _NamingScreen` does not return until the keyboard closes, and only +-- then does InitName fall back to the version default for an empty entry -- so +-- the officer's "OK! So <RIVAL>" line always prints the freshly typed name. +-- `onDone` is that return: H.NameRival parks the script on it, and the screen's +-- close is what resumes the officer. +function World:nameRival(onDone) + local game = self.game + if not (game and game.stack) then + if onDone then onDone() end + return + end + local data = game.data or {} + local sprites = data.gen2Sprites + local rival = sprites and sprites.SPRITE_RIVAL + Screens.push(game, "Gen2NamingScreen", { + type = "rival", + menuGfx = data.gen2MenuGfx, + iconPath = rival and rival.image or nil, + iconColors = data.gen2Palettes + and Palettes.spritePalette(data.gen2Palettes, self.daytime or "DAY", rival) + or nil, + onDone = function(name) + game.stack:pop() + local save = game.save + if save then + save.rival = save.rival or {} + -- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call + -- InitName`, and .DefaultName is "SILVER@" on Gold + -- (engine/events/specials.asm:80-91). The default has to be written + -- HERE rather than ridden in from the seed: wRivalName starts as + -- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the + -- pre-naming Cherrygrove battle prints. + -- + -- _InitString defines blank as "zero or more spaces followed by a + -- null" (home/string.asm:6-30), so an entry of nothing but spaces -- + -- typeable, since the keyboard's blank cells are real characters -- + -- falls back exactly the same way an empty one does. + if name and name:gsub(" ", "") ~= "" then + save.rival.name = name + else + save.rival.name = "SILVER" + end + end + if onDone then onDone(name) end + end, + }) +end + +-- home/map.asm LoadMapAttributes .SetSpawn, ported. See the call site in +-- setMap for why it was missing and what that cost. `def` is the map being +-- loaded; self.map is still the one being left. +function World:updateWhiteoutSpawn(def, mapId) + if not def then return end + local prev = self.map and self.map.def + if not prev then return end + -- CheckOutdoorMap on the map being left, CheckIndoorMap on the one being + -- entered, then its tileset. All three, in that order. + if not (prev.environment == "ROUTE" or prev.environment == "TOWN") then + return + end + if def.environment ~= "INDOOR" then return end + if def.tileset ~= "TILESET_POKECENTER" then return end + -- `ld a, [wPrevMapGroup] / ld [wLastSpawnMapGroup], a`: what is stored is the + -- map being LEFT -- the town or route outside the door -- not the Pokecenter. + -- data/maps/spawn_points.asm is keyed that way (`spawn PALLET_TOWN, 5, 6`), + -- and it is what makes the Indigo Plateau centre work: it is entered from + -- ROUTE_23, and SPAWN_INDIGO is ROUTE_23 (9,6). + local save = self.game and self.game.save + if save then save.blackoutMap = prev.id or mapId end +end + +-- GetWhiteoutSpawn's answer WITHOUT taking it: where a whiteout would land, +-- as { map, x, y, spawn }, or nil when nothing resolves. Split out of +-- warpToSpawn (which is now its only other caller) so world.blacked_out can +-- carry the same healTarget key Gen 1's OverworldState:healPoint fills, read +-- from one place rather than two that can drift. +function World:healPoint() + local save = self.game and self.game.save + -- wLastSpawnMapGroup / wLastSpawnMapNumber, written by `blackoutmod`, are + -- read BEFORE the SPAWN_* table: the Fast Ship and Mr. Pokemon's house set + -- them so that losing at sea or out past Cherrygrove does not respawn the + -- player somewhere they cannot leave. + local override = save and save.blackoutMap + if override then + -- GetWhiteoutSpawn's own lookup: the stored map is matched against + -- SpawnPoints, and it is that row which carries the coordinates. The table + -- is keyed by the OUTDOOR map (`spawn PALLET_TOWN, 5, 6`), which is exactly + -- what World:updateWhiteoutSpawn stores, so this resolves a Pokecenter + -- visit to the right doorstep -- SPAWN_INDIGO is ROUTE_23 (9,6), the + -- approach to the Plateau centre. + local spawns = self.landmarks and self.landmarks.spawns + if type(spawns) == "table" then + for id, row in pairs(spawns) do + if type(row) == "table" and row.map == override and self.maps[row.map] + then + return { map = row.map, x = row.x, y = row.y, spawn = id } + end + end + end + -- Not a spawn point: `blackoutmod`'s other users (the Fast Ship cabins) + -- name maps that are not in the table at all, so fall back to the map + -- itself rather than sending the player home from the middle of the sea. + if self.maps[override] then + local def = self.maps[override] + local warp = def.warps and def.warps[1] + return { map = override, x = (warp and warp.x) or 0, + y = (warp and warp.y) or 0 } + end + end + local spawnId = (save and save.spawn) or SPAWN_HOME + local spawn = self.landmarks and self.landmarks.spawns + and self.landmarks.spawns[spawnId] + if not (spawn and spawn.map and self.maps[spawn.map]) then return nil end + return { map = spawn.map, x = spawn.x, y = spawn.y, spawn = spawnId } +end + +-- WarpToSpawnPoint: back to the last Pokecenter (or the bedroom before one is +-- visited), which is where a whiteout lands. +function World:warpToSpawn() + local target = self:healPoint() + if not target then return end + self:setMap(target.map, target.x, target.y, "down") +end + +function World:playCry(speciesIndex) + local data = self.game and self.game.data + if not data then return end + local id = speciesByIndex(data.pokemon, speciesIndex) + if id then + self.lastSfx = Sound.playCry(data, id) + end +end + +function World:playSfx(sfxId) + local data = self.game and self.game.data + local audio = data and data.audio + if not audio then return end + local name = audio.sfxOrder and audio.sfxOrder[(sfxId or 0) + 1] + if name and audio.sfx and audio.sfx[name] then + self.lastSfx = Sound.play(data, name) + end +end + +function World:playMusicId(musicId) + local data = self.game and self.game.data + local audio = data and data.audio + if not (audio and audio.runtime) then return end + local name = audio.musicOrder and audio.musicOrder[(musicId or 0) + 1] + -- Script_playmusic with MUSIC_NONE is how a script silences the map ahead + -- of its own cue -- the nurse's `playmusic MUSIC_NONE` right before the + -- heal machine -- so it is a real stop, not a skipped play. + if (musicId or 0) == 0 or name == "Music_Nothing" then + Music.stop() + return + end + if name and audio.songs and audio.songs[name] then + Music.play(data, name, true, { reason = "script_playmusic" }) + end +end + +function World:tryCoordScript() + if self:busy() or not self.map or not self.player or not self.vm then + return false + end + if self.player.moving then return false end + local scene = self:scene() + local x, y = self.player.cellX, self.player.cellY + for _, ev in ipairs(self.map.def.coordEvents or {}) do + if ev.x == x and ev.y == y and (ev.sceneId or 0) == scene + and ev.scriptKey then + self.talkNpc = nil + return self.vm:start(ev.scriptKey) + end + end + return false +end + +function World:trySceneScript() + if not self.map or not self.vm then return false end + local scene = self:scene() + local scenes = self.map.def.sceneScripts + local entry = scenes and scenes[scene] + if not entry then + -- Lua arrays are 1-based; scene 0 may live at scenes[0] or scenes[1] if + -- extracted as a list. Prefer explicit sceneId field. + if type(scenes) == "table" then + for _, s in pairs(scenes) do + if type(s) == "table" and s.sceneId == scene and s.scriptKey then + entry = s + break + end + end + end + end + local key = entry and (entry.scriptKey or entry.script) + if type(key) == "number" then return false end + if key then + self.talkNpc = nil + return self.vm:start(key) + end + return false +end + +-- `stay` is the VM's one-command lookahead (Vm:textStays): the next script +-- command is `yesorno`, so this text ends in `done` and the cart never took the +-- box down before InitYesNoTextBoxParameters put the prompt over it. The box +-- is left standing and World:askYesNo stacks the choice on THIS box, instead of +-- the box popping on a button press the cart never asked for and the question +-- being re-printed underneath the prompt. +-- +-- `hold` is the cart's `pause` when that pause sits INSIDE the box rather than +-- after it: FindItemInBallScript is `writetext .FoundItemText / playsound +-- SFX_ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and +-- none of those commands takes the box down. It cannot be run as a VM `pause` +-- here, because Game2:update stops at the top state -- while ANY box is on the +-- stack the overworld and the VM under it do not tick at all -- so the wait has +-- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames +-- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand. +function World:showText(body, onDone, stay, hold) + local game = self.game + -- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever + -- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it + -- in World:askYesNo; the other consumer is the next page of the same cart + -- MapTextbox -- FindItemInBallScript's itemnotify line, after the box has + -- held through `playsound / pause 60`. The pop and the push happen inside + -- ONE frame, so no frame ever renders the bare overworld between two pages + -- the cart never took a box down between; a frame that did would both tear + -- the box down visibly and let Game2's play clock, which only pauses while a + -- state is on the stack, come off pause mid-pickup. + local held = self.stayedTextBox + self.stayedTextBox = nil + if not game or not game.stack then + if onDone then onDone() end + return + end + if held and game.stack:top() == held then game.stack:pop() end + self.textbox = true + -- Kept for `yesorno`, which re-shows this page under the prompt. + self.lastText = body + if stay then + local box + local left = (hold or 0) > 0 and hold or nil + box = TextBox.new(game, body, nil, { + -- stay.onShown fires on the frame the last page finishes typing, which is + -- where PrintText returns on the cart (#591). + stay = { onShown = function() + self.stayedTextBox = box + -- The `hold` above. TextBox's own page/CONT block counter is the one + -- per-frame gate a finished box already has (src/render/TextBox.lua + -- :237), and clearing `stayShown` re-arms this hook for the frame it + -- drains on -- so the box sits there, read, for the length of the + -- cart's pause and THEN hands the script back. Dropping the latch is + -- safe because a stay box reads no input and pops for nothing. + if left then + box.holdFrames = left + box.stayShown = false + left = nil + return + end + if onDone then onDone() end + end }, + }) + game.stack:push(box) + return + end + game.stack:push(TextBox.new(game, body, function() + self.textbox = nil + if onDone then onDone() end + end)) +end + +function World:pooledNpc(mapId, obj) + if not self.sprites or not obj or not obj.sprite then return nil end + -- An object whose `sprite` is a NUMBER names a wVariableSprites slot rather + -- than a sheet (Route 36's Sudowoodo carries $f4), and only `variablesprite` + -- can say what stands there. Unfilled, it stays nil and nothing spawns -- + -- which is exactly what the cart draws before the scene script runs. + local name = self:resolveSprite(obj.sprite) + if not name then return nil end + -- The SPRITE_POKEMON ids DO have a row (the extractor follows SpriteMons and + -- points them at the mon's menu icon). The day-care pair cannot have one -- + -- its species is whatever is being bred -- so resolveSprite answers with the + -- built def itself rather than a name. + local spriteDef = type(name) == "table" and name or self.sprites[name] + if not spriteDef then return nil end + local key = string.format("%s_obj_%d", mapId, obj.index or 0) + local npc = self.npcPool[key] + if not npc then + npc = NPC.new(mapId, obj, spriteDef) + self.npcPool[key] = npc + -- applyPalettes only runs on map entry and once a second + -- (PALETTE_POLL_STEPS), so an NPC created after that -- anything an event + -- flag reveals part way through a map's life -- would draw with an + -- unbaked sheet until the next poll came round. That is a full second of + -- a grey character standing in a coloured room. + self:applySpritePalette(npc) + -- Gen 1's pooledNPC emits the same three keys from the same place -- the + -- pool miss, so one object announces itself once per run and not on every + -- seam crossing. `runtime` is always false here: Gold has no runtime-object + -- system (see WorldAPI:spawnNpc), so every object came out of the map def. + if Runtime.wants("world.npc_spawned") then + Runtime.emit("world.npc_spawned", + { mapId = mapId, npcId = key, runtime = false }) + end + end + return npc +end + +-- CheckObjectTime (home/map_objects.asm), the mask LoadObjectMasks computes +-- beside CheckObjectFlag: an object_event's two hour bytes decide whether it +-- is on the map AT ALL right now. macros/scripts/maps.asm spells the encoding +-- out: h1 < h2 shows the object from h1 to h2 (inclusive both ends), h1 > h2 +-- HIDES it strictly between h2 and h1, h1 == h2 always shows, and h1 == -1 +-- turns h2 into a MORN/DAY/NITE bitmask (-1 = always). This is what keeps the +-- Goldenrod pharmacist single (two rows on one tile, one DAY one NITE), what +-- empties the Mt Moon gift shop at night, and what keeps exactly one of the +-- three time-of-day Moms in the kitchen. +-- +-- The mask compares against the CLOCK's time of day (GetTimeOfDay reads +-- hHours), never wTimeOfDayPalset -- an indoor map pinned to PALETTE_DAY still +-- swaps its night staff -- which is why this reads World:hour and not +-- World:timeOfDayId. +local MORN_MASK, DAY_MASK, NITE_MASK = 1, 2, 4 + +function World:clockTimeMask() + -- TimesOfDay (engine/rtc/rtc.asm): 0400-0959 morn, 1000-1759 day, else nite. + local hour = self:hour() + if hour >= 4 and hour < 10 then return MORN_MASK end + if hour >= 10 and hour < 18 then return DAY_MASK end + return NITE_MASK +end + +function World:objectTimeVisible(obj) + local hours = obj and obj.hours + if not hours then return true end + local h1 = hours[1] or -1 + local h2 = hours[2] or -1 + if h1 == -1 then + if h2 == -1 then return true end + local mask = self:clockTimeMask() + return h2 % (mask * 2) >= mask + end + if h1 == h2 then return true end + local hour = self:hour() + if h1 < h2 then + return hour >= h1 and hour <= h2 + end + return hour >= h1 or hour <= h2 +end + +-- wObjectMasks, one byte per object_event (home/map.asm:1534 CheckObjectMask). +-- Tri-state here: true masked, false unmasked, nil "no mask loaded", which is +-- the only case that falls back to deriving visibility from the event flag. +function World:objectMaskKey(obj, index) + return string.format("%s:%d", self.map.id, (obj and obj.index) or index or 0) +end + +-- MaskObject / UnmaskObject (home/map.asm:1542, :1548): ONE byte, this +-- object's. `maskScripted` remembers that a script wrote it, so the hour poll +-- below cannot resurrect somebody a scene took off the map. +function World:setObjectMask(obj, index, masked) + self.objectMasks = self.objectMasks or {} + self.maskScripted = self.maskScripted or {} + local key = self:objectMaskKey(obj, index) + self.objectMasks[key] = masked and true or false + self.maskScripted[key] = true +end + +-- LoadObjectMasks (engine/overworld/map_objects_2.asm:1): ByteFill over the +-- whole array, then one GetObjectTimeMask / CheckObjectFlag per object. This +-- is the ONLY place the event flags decide who is on the map; after it, an +-- `appear` or `disappear` moves its own byte and a plain `setflag` moves +-- nobody until the next load. +-- +-- LoadMapObjects runs it AFTER MAPCALLBACK_OBJECTS (engine/overworld/ +-- map_setup.asm:78-82), so a callback's appear/disappear counts only through +-- the flag it sets -- which is why the reset is unconditional here. The hour +-- poll passes keepScripted, since it stands in for a reload the cart does not +-- actually run. +function World:loadObjectMasks(opts) + opts = opts or {} + local scripted = (opts.keepScripted and self.maskScripted) or {} + local masks = opts.keepScripted and (self.objectMasks or {}) or {} + local def = self.map and self.map.def + for index, obj in ipairs((def and def.objects) or {}) do + local key = self:objectMaskKey(obj, index) + if not scripted[key] then + masks[key] = not (self.events:objectVisible(obj.eventFlag) + and self:objectTimeVisible(obj)) + end + end + self.objectMasks = masks + self.maskScripted = scripted +end + +-- Current-map NPCs + visual-only ghosts on neighbor strips (Gen 1 pattern). +function World:rebuildPeople(opts) + opts = opts or {} + -- Anything this function did not put in the list is a GUEST: the follower + -- (src/world/gen2/Follower.lua) or a mod's own entity. A rebuild runs on + -- every zoom and time-of-day roll, so wiping guests loses a follower at the + -- top of the hour; Gen 1 rebuilds on map load only and never had to say it. + local made = self.peopleFromMap or {} + local guests + for _, npc in ipairs(self.npcs or {}) do + -- Only this map's: a warp respawns rather than dragging one across a seam. + if not made[npc] and (npc.mapId == nil or npc.mapId == self.map.id) then + guests = guests or {} + guests[#guests + 1] = npc + end + end + local fromMap = {} + self.peopleFromMap = fromMap + if not opts.seamless or not self.npcPool then + self.npcPool = {} + end + self.npcs = {} + self.entities = {} + if self.player then + table.insert(self.entities, self.player) + end + for _, obj in ipairs(self.map.def.objects or {}) do + -- wObjectMasks is what says who is standing here (CheckObjectMask, + -- home/map.asm:1534): the flags only reach it through LoadObjectMasks, at + -- map load. A world that never ran setMap has no masks at all, so nil + -- falls back to the derivation LoadObjectMasks would have done. + local masked = self.objectMasks and self.objectMasks[self:objectMaskKey(obj)] + if masked == nil then + masked = not (self.events:objectVisible(obj.eventFlag) + and self:objectTimeVisible(obj)) + end + if not masked then + local npc = self:pooledNpc(self.map.id, obj) + if npc then + fromMap[npc] = true + table.insert(self.npcs, npc) + table.insert(self.entities, npc) + end + end + end + for _, npc in ipairs(guests or {}) do + table.insert(self.npcs, npc) + table.insert(self.entities, npc) + end + self.ghosts = {} + for _, nb in ipairs(self.neighbors) do + local def = self.maps[nb.id] + if def then + local tileset = self.tilesets[def.tileset] + local ghostMap = tileset and Map.new(def, tileset) or nil + local peers = {} + for _, obj in ipairs(def.objects or {}) do + if self.events:objectVisible(obj.eventFlag) + and self:objectTimeVisible(obj) then + local npc = self:pooledNpc(nb.id, obj) + if npc and ghostMap then + table.insert(peers, npc) + table.insert(self.ghosts, { + npc = npc, map = ghostMap, ox = nb.ox, oy = nb.oy, peers = peers, + }) + end + end + end + end + end +end + +-- Mod-spawned map objects. The Gen 1 arm (OverworldState:addRuntimeObject) +-- appends straight onto the map def's object list, and the same move works +-- here: World.maps is one table for the whole run and rebuildPeople reads +-- self.map.def.objects, so an appended object is pooled, drawn, walked and +-- talked to like an extracted one, and survives a map reload. Not serialized: +-- a permanent NPC belongs in a maps patch, this is for actors the mod respawns +-- on map.entered. A runtime object carries no eventFlag, so LoadObjectMasks' +-- derivation leaves it visible. +function World:addRuntimeObject(mapId, objDef, owner) + local def = self.maps and self.maps[mapId] + if not def then return nil, "unknown map: " .. tostring(mapId) end + def.objects = def.objects or {} + local index = 0 + for _, obj in ipairs(def.objects) do + if (obj.index or 0) > index then index = obj.index end + end + objDef.index = index + 1 + objDef.runtime = true + objDef.owner = owner + table.insert(def.objects, objDef) + local npcId = mapId .. "_obj_" .. objDef.index + if self.map and self.map.id == mapId then + self:rebuildPeople({ seamless = true }) + end + return npcId +end + +-- Imported objects are refused, and so is another mod's. +function World:removeRuntimeObject(npcId, owner) + for mapId, def in pairs(self.maps or {}) do + for i, obj in ipairs(def.objects or {}) do + if obj.runtime and mapId .. "_obj_" .. obj.index == npcId then + if owner ~= nil and obj.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + if self.npcPool then + self.npcPool[string.format("%s_obj_%d", mapId, obj.index)] = nil + end + if self.map and self.map.id == mapId then + self:rebuildPeople({ seamless = true }) + end + return true + end + end + end + return nil, "no such runtime object: " .. tostring(npcId) +end + +-- IsNPCAtCoord (engine/overworld/npc_movement.asm), which is what BOTH the +-- step's `.CheckNPC` and the A press's CheckFacingObject ask -- so a BIG_OBJECT +-- fills its whole 2x2 blob for collision and for talking alike, and the +-- Vermilion Snorlax can be woken from any cell adjacent to any of its four. +-- NPC:covers is WillObjectIntersectBigObject; every ordinary object answers it +-- with the plain one-cell compare. +function World:npcAt(cx, cy) + for _, npc in ipairs(self.npcs) do + if NPC.covers(npc, cx, cy) then return npc end + end + return nil +end + +-- Gen 1's spelling (src/world/OverworldController.lua), for the reason the +-- Map vocabulary aliases exist: a mod holds one name. +function World:npcAtCell(cx, cy) + return self:npcAt(cx, cy) +end + +-- CheckIfFacingTileCoordIsBGEvent (home/map.asm) matches on the COORDINATES +-- alone and leaves the function byte to BGEventJumptable; this narrows to +-- BGEVENT_READ, the one arm that is a plain script pointer. BGEVENT_ITEM is +-- src/world/gen2/HiddenItems.lua, off the same cell. +-- BGEventJumptable, ported past its first arm. +-- +-- This matched `kind == 0` (BGEVENT_READ) only, which silently dropped five of +-- the nine kinds. The one that mattered is BGEVENT_IFNOTSET: TeamRocketBaseB3F's +-- locked door is two of them, so pressing A at Giovanni's door did nothing at +-- all, EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE could never be set, and the Rocket +-- hideout dead-ended one room short of its boss. +-- +-- 0 READ a plain script pointer +-- 1-4 UP/DOWN/RIGHT/LEFT .checkdir -- read only when facing that way +-- 5 IFSET run the script only while the event IS set +-- 6 IFNOTSET run it only while the event is NOT set +-- 7 ITEM hidden item, handled by src/world/gen2/HiddenItems.lua +-- 8 COPY copies data without reading; nothing to run +-- +-- The cart's facing test is `and %1100`, i.e. the direction's top two bits, so +-- it compares the FACING and not the button that produced it. +local BGEVENT_FACING = { [1] = "up", [2] = "down", [3] = "right", [4] = "left" } + +function World:bgEventAt(cx, cy) + for _, ev in ipairs(self.map.def.bgEvents or {}) do + if ev.x == cx and ev.y == cy then + local kind = ev.kind or 0 + if kind == 0 then + return ev + elseif BGEVENT_FACING[kind] then + if self.player and self.player.facing == BGEVENT_FACING[kind] then + return ev + end + elseif kind == 5 or kind == 6 then + -- CheckBGEventFlag, then `.ifset` reads when set and `.ifnotset` when + -- clear. An extraction that predates the conditional_event fix has no + -- `event` field; refusing to guess is better than running the door + -- script unconditionally. + if ev.event and ev.scriptKey then + local set = self.events and self.events:get(ev.event) or false + if (kind == 5) == (set and true or false) then return ev end + end + end + end + end + return nil +end + +-- ---- trainers ------------------------------------------------------------- +-- engine/events/trainer_scripts.asm, as inline command lists: nothing in the +-- ROM points at these two, so the extractor never sees them and the VM has to +-- be handed the list. Kept command-for-command so the ordering (music before +-- the seen text, flag set after the battle, after-script as a tail call) stays +-- checkable against the source. +local SEEN_BY_TRAINER_SCRIPT = { + { op = "loadtemptrainer" }, + { op = "encountermusic" }, + -- showemote EMOTE_SHOCK, LAST_TALKED, 30 + { op = "showemote", emote = 0, object = -2, frames = 30 }, + { op = "trainerapproach" }, + { op = "faceobject", a = 0, b = 1 }, + { op = "opentext" }, + { op = "trainertext", index = 0 }, -- TRAINERTEXT_SEEN + { op = "waitbutton" }, + { op = "closetext" }, + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "trainerflagaction", action = 1 }, -- SET_FLAG + { op = "scripttalkafter" }, +} + +-- TalkToTrainerScript: an already-beaten trainer skips straight to its +-- after-battle script, which is why a beaten rival still has a line. +local TALK_TO_TRAINER_SCRIPT = { + { op = "faceplayer" }, + { op = "trainerflagaction", action = 2 }, -- CHECK_FLAG + { op = "iftrue", script = { { op = "scripttalkafter" } } }, + { op = "loadtemptrainer" }, + { op = "encountermusic" }, + { op = "opentext" }, + { op = "trainertext", index = 0 }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "trainerflagaction", action = 1 }, + { op = "scripttalkafter" }, +} + +function World:trainerBeaten(record) + if not (record and record.event) then return false end + return self.events:get(record.event) and true or false +end + +-- _CheckTrainerBattle: every visible, unbeaten trainer object that is facing +-- the player along a shared row or column, within its own sight range. +function World:checkTrainerBattle() + if self:busy() or not self.player or not self.vm then return false end + if self.player.moving then return false end + local save = self.game and self.game.save + if not (save and save.party and #save.party > 0) then return false end + for _, npc in ipairs(self.npcs) do + local record = npc.def and npc.def.trainer + if record and not self:trainerBeaten(record) then + local distance, dir = Trainers.sees(npc, self.player, npc.def.sight) + if distance then + return self:startTrainerScript(npc, SEEN_BY_TRAINER_SCRIPT, { + distance = distance, dir = dir, + }) + end + end + end + return false +end + +-- Both entries to a map trainer -- the sight cone and an A press on one -- come +-- through here, which is why world.trainer_engaged sits at this seam rather +-- than at checkTrainerBattle. Gen 1's three payload keys are unchanged; +-- trainerClass and partyIndex hold the `trainer` struct's numeric class +-- constant and member number (macros/scripts/maps.asm) where Gen 1 holds its +-- own class name and party index -- same role, same key. `trainerEvent` and +-- `sight` are Gen 2 additions: the beaten-flag the struct carries, and the +-- distance/direction when it was the eyesight test that engaged. +function World:startTrainerScript(npc, script, sight) + local record = npc and npc.def and npc.def.trainer + if record and Runtime.wants("world.trainer_engaged") then + Runtime.emit("world.trainer_engaged", { + npc = npc, trainerClass = record.class, partyIndex = record.member, + trainerEvent = record.event, sight = sight, + }) + end + self.talkNpc = npc + self:freezeNpc(npc) + self.vm.trainerObject = npc.def.trainer + self.trainerSight = sight + self.trainerNpc = npc + return self.vm:start(script) +end + +-- The engaged / talked-to object holds still for the whole conversation. On +-- the cart the seen-by script's own movement freezes everything +-- (Script_applymovement -> FreezeAllOtherObjects, engine/overworld/ +-- scripting.asm), and a talked-to wanderer stops because its struct is the +-- script's LAST_TALKED; either way a SPINRANDOM trainer must not keep rolling +-- new facings under his own sighting text. World:step unfreezes the pool the +-- frame the interaction is over, which is EndScript's UnfreezeAllObjects. +function World:freezeNpc(npc) + if npc then npc.frozen = true end + self.frozeNpcs = true +end + +-- TrainerWalkToPlayer: close to one cell short of the player, then hand the +-- VM back control. A trainer spotted from one cell away never moves. +function World:trainerApproach(onDone) + local sight = self.trainerSight + local npc = self.trainerNpc + if not (sight and npc) then + if onDone then onDone() end + return + end + local steps = Trainers.approach(sight.distance, sight.dir) + if #steps == 0 then + if onDone then onDone() end + return + end + local bytes = {} + for _, dir in ipairs(steps) do + bytes[#bytes + 1] = Movement.stepByte(dir) + end + bytes[#bytes + 1] = Movement.STEP_END + self:beginMovement(npc.def.index + 1, bytes, onDone) +end + +-- TileCollisionStdScripts (data/collision/collision_stdscripts.asm), +-- verbatim: collision byte -> the StdScripts label the extractor resolved +-- into std_scripts.lua (RomExtractorGen2:extractStdScripts). This is how +-- every Pokecenter PC, house radio, town map poster and bookshelf in the +-- game is read -- none of them is a bg event. +local TILE_COLLISION_STD_SCRIPTS = { + [0x91] = "MagazineBookshelfScript", -- COLL_BOOKSHELF + [0x93] = "PCScript", -- COLL_PC + [0x94] = "Radio1Script", -- COLL_RADIO + [0x95] = "TownMapScript", -- COLL_TOWN_MAP + [0x96] = "MerchandiseShelfScript", -- COLL_MART_SHELF + [0x97] = "TVScript", -- COLL_TV + [0x9d] = "WindowScript", -- COLL_WINDOW + [0x9f] = "IncenseBurnerScript", -- COLL_INCENSE_BURNER +} + +-- what the A press resolved to, for world.interacted's listeners. Same four +-- payload keys as Gen 1 and the same `kind` vocabulary where the two engines +-- share an arm: "npc", "sign", "hidden", "script", "none". Gold's arms that +-- Gen 1 has no equivalent of get their own words -- "trainer", "boulder", +-- "itemball", "std" (the TileCollisionStdScripts table: PCs, radios, TVs, +-- bookshelves) and "fieldmove" -- rather than being folded into one of Gen 1's. +local function interacted(self, fx, fy, kind, target) + if not Runtime.wants("world.interacted") then return end + Runtime.emit("world.interacted", { mapId = self.map and self.map.id, + x = fx, y = fy, kind = kind, + target = target }) +end + +-- A-press: talk to facing NPC, read a sign (BGEVENT_READ) or dig up a hidden +-- item (BGEVENT_ITEM). +-- +-- Split so a replaced `OverworldController.interact` sits in front of the +-- body (src/mods/Gen2Compat.lua); unpatched, this is one comparison. +function World:interact() + local wrapped = Gen1Facade.interactWrapper() + if wrapped then return wrapped(self) end + return self:interactBody() +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) + -- 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 + -- command, so SmashRockScript's `disappear LAST_TALKED` hid whichever + -- object some earlier conversation had named -- the Burned Tower rock + -- played its whole smash and stayed standing in any session where anybody + -- had been talked to first. Object consts are index + 1, the same mapping + -- disappearObject decodes. + if npc and npc.def and self.vm then + self.vm.lastTalked = (npc.def.index or 0) + 1 + end + -- The Gen 1 dispatch a follower mod wraps is OverworldState:talkTo(npc) + -- (src/world/OverworldController.lua:2564), which has no single Gen 2 + -- method: Gold dispatches inline from here. Same shape as + -- interactWrapper -- nil unless something replaced it, and a true return + -- suppresses the built-in path. + if npc then + local talkTo = Gen1Facade.talkToWrapper() + if talkTo and talkTo(self, npc) then return true end + end + if npc and npc.def and npc.def.trainer then + interacted(self, fx, fy, "trainer", npc) + return self:startTrainerScript(npc, TALK_TO_TRAINER_SCRIPT, nil) + end + -- Any other object an A press lands on holds still too (see freezeNpc). + if npc then self:freezeNpc(npc) end + -- Every strength boulder in the game carries the same script -- `jumpstd + -- StrengthBoulderScript`, which is a bare `farsjump AskStrengthScript` into + -- ASM. There is no bytecode behind that label for the extractor to have + -- found, so the boulder arm is handled here rather than through the VM, + -- which would otherwise walk into a script key with nothing on the far side. + if npc and World.isStrengthBoulder(npc) then + self.talkNpc = npc + interacted(self, fx, fy, "boulder", npc) + return self:tryStrengthOW() + end + -- OBJECTTYPE_ITEMBALL: no scriptKey to start -- the object's pointer is the + -- raw (item, quantity) pair, read by the extractor into `itemball`. Every + -- plain Poke Ball on the floor comes through here; without this arm the + -- press found the object, matched no branch, and fell through to the tile + -- events -- no ball in the game could be picked up. + if npc and npc.def and npc.def.itemball then + self.talkNpc = npc + interacted(self, fx, fy, "itemball", npc) + return self.vm:start(HiddenItems.ballPickupScript( + npc.def.itemball.item, npc.def.itemball.quantity, + (npc.def.index or 0) + 1, + -- The same resolver the itemfinder gets: the script names its sfx by + -- pokegold LABEL and this looks it up in THIS cache's sfx table, rather + -- than trusting a numeric id that only holds for the shipped Gold cache. + function(want, id) return self:sfxIdNamed(want, id) end)) + end + if npc and npc.def and npc.def.scriptKey then + self.talkNpc = npc + interacted(self, fx, fy, "npc", npc) + return self.vm:start(npc.def.scriptKey) + end + local sign = self:bgEventAt(fx, fy) + if sign and sign.scriptKey then + self.talkNpc = nil + interacted(self, fx, fy, "sign", sign) + return self.vm:start(sign.scriptKey) + end + -- BGEVENT_ITEM, the `.itemifset` arm of the same jumptable: a hidden item. + -- Its operand is `hiddenitem` data rather than a script, so there is no + -- scriptKey for the arm above to have found and the list is built here. An + -- item already taken has its flag set, and `.itemifset` jumps to `.dontread` + -- on that -- no carry, so the press falls through to the tile events below + -- exactly as if the bg event were not there. + local hidden = HiddenItems.at(self.map and self.map.def, fx, fy, self.events) + if hidden then + self.talkNpc = nil + -- PlayTalkObject, the SFX every read of a bg event opens on. + self:playSfxNamed("Sfx_ReadText2", SFX_READ_TEXT_2) + interacted(self, fx, fy, "hidden", hidden) + return self.vm:start(HiddenItems.pickupScript(hidden.item, hidden.event)) + end + -- CheckAPressOW's third and last try (engine/overworld/events.asm): + -- TryObjectEvent, then TryBGEvent, then TryTileCollisionEvent -- the facing + -- TILE's own events. TryTileCollisionEvent's own order is fixed and load + -- bearing: CheckFacingTileForStdScript first, then cut tree, whirlpool, + -- waterfall, headbutt tree, and SURF last as the catch-all. Each arm + -- claims the press only if the facing tile matches its collision, which is + -- what keeps a tree from stealing a sign's press and what makes SURF the + -- one arm allowed to be silent. + -- + -- CheckFacingTileForStdScript (engine/events/std_collision.asm): a facing + -- collision with a TileCollisionStdScripts row runs that std script and + -- returns carry. The bodies come out of the cache's std_scripts table and + -- run through the VM like any map script -- PCScript is `opentext / + -- special PokemonCenterPC / closetext / end`. + local std = TILE_COLLISION_STD_SCRIPTS[ + self.map and self.map:cellCollision(fx, fy)] + if std then + local entry = self.stdScripts and self.stdScripts.scripts + and self.stdScripts.scripts[std] + if entry and entry.key then + self.talkNpc = nil + interacted(self, fx, fy, "std", std) + return self.vm:start(entry.key) + end + end + if self:tryCutOW() then + interacted(self, fx, fy, "fieldmove", "CUT") + return true + end + if self:tryWhirlpoolOW() then + interacted(self, fx, fy, "fieldmove", "WHIRLPOOL") + return true + end + if self:tryWaterfallOW() then + interacted(self, fx, fy, "fieldmove", "WATERFALL") + return true + end + if self:tryHeadbuttOW(fx, fy) then + interacted(self, fx, fy, "fieldmove", "HEADBUTT") + return true + end + if self:trySurfOW() then + interacted(self, fx, fy, "fieldmove", "SURF") + return true + end + interacted(self, fx, fy, "none") + return false +end + +function World:fitScale() + local w, h = love.graphics.getDimensions() + return math.max(1, math.floor(math.min(w / 160, h / 144))) +end + +function World:zoomScale() + return Zoom.scale(self:fitScale()) +end + +-- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof +-- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets +-- in the same map group (lab, houses) corrupts their GFX. +local ROOF_TILESETS = { + TILESET_JOHTO = true, + TILESET_JOHTO_MODERN = true, + TILESET_KANTO = true, +} + +function World:atlasFor(mapDef) + local tileset = self.tilesets[mapDef.tileset] + if not tileset then return nil end + local cacheKey = mapDef.tileset + local roofName = nil + if ROOF_TILESETS[mapDef.tileset] then + roofName = self.roofs and self.roofs.mapGroupRoofs + and self.roofs.mapGroupRoofs[mapDef.group] + end + if roofName then cacheKey = cacheKey .. "|" .. roofName end + local cached = self.atlasCache[cacheKey] + if cached then return cached, tileset end + + local tilesPerRow = tileset.tilesPerRow or 16 + local atlas + local roofSpec = roofName and self.roofs.roofs and self.roofs.roofs[roofName] + if roofSpec and roofSpec.image then + local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow) + if ok then atlas = img end + end + if not atlas then + local ok, img = pcall(Assets.image, tileset.image) + if not ok then return nil, tileset end + atlas = img + atlas:setFilter("nearest", "nearest") + end + self.atlasCache[cacheKey] = atlas + return atlas, tileset +end + +-- Bakes one map's 32px blocks into a single canvas. +-- +-- With palettes available this is a GBC-accurate render, not a grayscale one: +-- a tile's four colors come from its tileset PalMap slot inside the eight BG +-- palettes LoadMapPals would have loaded for this map's environment, time of +-- day and map group. The bake walks slot 0..7 and draws only the tiles that +-- belong to each, so the shader's palette uniform changes eight times per map +-- instead of once per tile -- tiles never overlap, so the pass order is free. +-- +-- `daytime` is baked in, which is why mapImages is keyed by map *and* daytime: +-- a night-to-morning rollover re-bakes rather than tinting, exactly like the +-- cart reloading wBGPals1. +function World:bakeMapImage(map, daytime, flicker) + local atlas, tileset = self:atlasFor(map.def) + if not atlas or not tileset then return nil end + local blocks = tileset.blocks + local tilesPerRow = tileset.tilesPerRow or 16 + local pw, ph = map.width * 32, map.height * 32 + local canvas = love.graphics.newCanvas(pw, ph) + canvas:setFilter("nearest", "nearest") + local quads = {} + local function quadFor(tile) + local q = quads[tile] + if q then return q end + local sx = (tile % tilesPerRow) * 8 + local sy = math.floor(tile / tilesPerRow) * 8 + q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions()) + quads[tile] = q + return q + end + + local tilePalettes = tileset.tilePalettes + local bgSet = self.palettes and daytime + and Palettes.bgSet(self.palettes, map.def, daytime) or nil + -- FlickeringCaveEntrancePalette: on a DARKNESS_PALSET map, PAL_BG_YELLOW's + -- color 0 is rewritten every VBlank from either its own color 0 or its + -- color 1. In the dark palette row those are RGB 30,30,11 and black, so + -- the cave entrance blinks and nothing else in the map does. Both phases + -- are baked and cached, and World:pollCaveFlicker swaps between them -- + -- flipping a whole canvas is what a palette write costs here. + if bgSet and daytime == "DARK" then + bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) + end + local colored = bgSet and tilePalettes and GbcPalette.available() + + -- Slot 0 color 0 is the map's background wash; falling back to a flat green + -- only matters for a pre-palette cache. + local clearColor = { 0.15, 0.55, 0.25 } + if bgSet and bgSet[1] and bgSet[1][1] then + -- Through GbcPalette.color so the COLOR option reaches the wash too: it + -- is a palette colour drawn as a plain fill, so nothing else would + -- substitute it and a DMG-mode map would sit on a green field. + local c = GbcPalette.color(bgSet[1], 1) + clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 } + end + + local function drawTiles(slot) + for by = 0, map.height - 1 do + for bx = 0, map.width - 1 do + -- LoadMetatiles reads block id 0 as the map header's border block, + -- not as tileset block 0 (see src/world/gen2/BorderFill.lua), so a + -- hole in the block list paints the same wall the margin does. + local blockId = BorderFill.blockFor( + map.blocks[by * map.width + bx + 1], map.borderBlock) + local block = blocks and blocks[(blockId or 0) + 1] + if block then + for i = 0, 15 do + local tile = block[i + 1] or 0 + -- tilePalettes is 1-based over the 96 sheet tiles; anything past + -- the sheet (window/text tiles) has no entry and takes slot 1. + local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + if not slot or tileSlot == slot then + local tx = bx * 32 + (i % 4) * 8 + local ty = by * 32 + math.floor(i / 4) * 8 + love.graphics.draw(atlas, quadFor(tile), tx, ty) + end + end + end + end + end + end + + canvas:renderTo(function() + love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1) + love.graphics.setColor(1, 1, 1, 1) + -- A LOVE canvas does not reset the transform, and this bake is NOT only + -- reached from the fixed step: World:refreshColorMode runs at the top of + -- World:draw, so the first bake after the OPTION screen changes COLOR + -- happens under whatever scale and offset the Renderer had already pushed + -- for the world canvas. Without the origin the whole map is baked at that + -- transform and then CACHED under its mapImages key, so one mis-timed bake + -- keeps a wrong canvas until the time of day rolls over. Same guard as + -- World:drawTilted below and BattleAnimView's panel. + love.graphics.push() + love.graphics.origin() + if colored then + for slot = 1, 8 do + GbcPalette.with(bgSet[slot], function() drawTiles(slot) end) + end + else + drawTiles(nil) + end + love.graphics.pop() + end) + return canvas, bgSet +end + +-- The functions whose frame strip the extractor already resolved, keyed to the +-- rule World:animRow reads them back with. +local ANIM_KINDS = { + AnimateWhirlpoolTile = "whirlpool", + AnimateTowerPillarTile = "tower", + AnimateLavaBubbleTile1 = "lava1", + AnimateLavaBubbleTile2 = "lava2", +} + +-- Every VRAM tile a tileset's program rewrites, and where each one's frames +-- come from (engine/tilesets/tileset_anims.asm:167 water, :197 flower, :231 +-- and :259 lava, :290 tower pillar, :350 whirlpool, plus the buffer scrolls +-- at :65 and :139). +function World:animLayers(tileset) + local anim = tileset and tileset.anim + if not (anim and anim.frames) then return nil end + local defs = self.tilesets + local wanted = nil + local function add(tile, layer) + -- A cache built before the strips were extracted has no sheet to draw + -- from, so that tile is left to the bake. + if not tile or (layer.kind ~= "scroll" and not layer.sheet) then return end + wanted = wanted or {} + wanted[tile] = layer + end + for _, frame in ipairs(anim.frames) do + local func = frame.func + if func == "AnimateWaterTile" then + add(frame.tile, + { kind = "water", sheet = defs and defs.waterFrames, frames = 4 }) + elseif func == "AnimateFlowerTile" then + -- AnimateFlowerTile takes no argument: it hardcodes vTiles2 tile $03 + -- (engine/tilesets/tileset_anims.asm:222). + add(0x03, + { kind = "flower", sheet = defs and defs.flowerFrames, frames = 4 }) + elseif ANIM_KINDS[func] then + add(frame.tile, { kind = ANIM_KINDS[func], sheet = frame.sheet, + frames = frame.frames }) + elseif func == "WriteTileFromAnimBuffer" and frame.scroll then + add(frame.tile, { kind = "scroll", scroll = frame.scroll, frames = 8 }) + end + end + return wanted +end + +-- The cells a tileset's anim program repaints, gathered once per bake and +-- keyed by tile id: the map canvas is baked and never rewritten, so the frames +-- _AnimateTileset would have written into VRAM are drawn over it instead. +function World:animCellsFor(map, tileset) + local wanted = self:animLayers(tileset) + if not wanted then return nil end + local blocks = tileset.blocks + local tilePalettes = tileset.tilePalettes + local out = nil + for by = 0, map.height - 1 do + for bx = 0, map.width - 1 do + local blockId = BorderFill.blockFor( + map.blocks[by * map.width + bx + 1], map.borderBlock) + local block = blocks and blocks[(blockId or 0) + 1] + if block then + for i = 0, 15 do + local tile = block[i + 1] or 0 + local layer = wanted[tile] + if layer then + out = out or {} + local list = out[tile] + if not list then + list = { + layer = layer, + tile = tile, + slot = tilePalettes and tilePalettes[tile + 1] or 1, + cells = {}, + } + out[tile] = list + end + local cells = list.cells + cells[#cells + 1] = bx * 32 + (i % 4) * 8 + cells[#cells + 1] = by * 32 + math.floor(i / 4) * 8 + end + end + end + end + end + return out +end + +-- The key imageFor caches a map's bake under, shared with the anim cell lists +-- and the palettes so the draw pass can find them from a map id alone. +function World:mapCacheKey(mapId) + local daytime = self.daytime + local flicker = (daytime == "DARK") and self.flickerPhase or 1 + return mapId .. "|" .. tostring(daytime) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) +end + +function World:imageFor(mapId) + local daytime = self.daytime + -- The COLOR mode is baked in alongside the daytime -- the palettes go on + -- when the map canvas is drawn, not when it is sampled -- so it belongs in + -- the key. Keying by it rather than flushing on a mode change also means + -- switching back and forth costs nothing after the first bake of each. + -- ...and so is the cave-entrance flicker phase, for the same reason: a dark + -- map has two bakes, not one, and they differ by a single palette colour. + local flicker = (daytime == "DARK") and self.flickerPhase or 1 + local cacheKey = self:mapCacheKey(mapId) + local cached = self.mapImages[cacheKey] + if cached then return cached end + local def = self.maps[mapId] + if not def then return nil end + local tileset = self.tilesets[def.tileset] + if not tileset then return nil end + local map = Map.new(def, tileset) + local img, bgSet = self:bakeMapImage(map, daytime, flicker) + self.mapImages[cacheKey] = img + -- Under the same key as the bake: the overlay needs both the cell list and + -- the palettes the bake resolved. + self.animCells[cacheKey] = self:animCellsFor(map, tileset) or false + self.bgSets[cacheKey] = bgSet or false + return img +end + +-- SetTallGrassFlags' own test (engine/overworld/map_objects.asm:247): +-- CheckSuperTallGrassTile first, then CheckGrassTile. +function World:grassAt(cx, cy) + local map = self.map + if not map then return false end + local coll = map:cellCollision(cx, cy) + return Permissions.isSuperTallGrass(coll) or Permissions.isGrass(coll) +end + +-- The tileset atlas with BG colour 0 keyed to alpha. The grass tile is about +-- two-fifths colour 0 and those pixels are the gaps the legs show through, so +-- the redraw over a sprite needs the key -- same `r > 0.83` rule +-- src/render/SpriteRenderer.lua uses on OBJ sheets. +function World:grassAtlasFor(mapDef) + local tileset = self.tilesets and self.tilesets[mapDef and mapDef.tileset] + if not (tileset and tileset.image) then return nil end + self.grassAtlases = self.grassAtlases or {} + local cached = self.grassAtlases[tileset.image] + if cached ~= nil then return cached or nil, tileset end + local made = false + if love.image and love.image.newImageData then + local ok, data = pcall(Assets.imageData, tileset.image) + if ok and data and data.mapPixel then + data:mapPixel(function(_, _, r, g, b, a) + if r > 0.83 then return r, g, b, 0 end + return r, g, b, a + end) + local okImg, img = pcall(love.graphics.newImage, data) + if okImg and img then + img:setFilter("nearest", "nearest") + made = img + end + end + end + self.grassAtlases[tileset.image] = made + return made or nil, tileset +end + +-- The 8x8 BG tile at a map pixel, through LoadMetatiles' border-block rule. +function World:bgTileAt(map, tileset, mx, my) + local bx, by = math.floor(mx / 32), math.floor(my / 32) + if bx < 0 or by < 0 or bx >= map.width or by >= map.height then return nil end + local blockId = BorderFill.blockFor( + map.blocks[by * map.width + bx + 1], map.borderBlock) + local block = tileset.blocks and tileset.blocks[(blockId or 0) + 1] + if not block then return nil end + local i = math.floor((my % 32) / 8) * 4 + math.floor((mx % 32) / 8) + return block[i + 1] +end + +-- IN_GRASS puts OAM_PRIO on the sprite's lower 16x8 only: .InitSprite ORs it +-- into hCurSpriteOAMFlags (engine/overworld/map_objects.asm:2850) and only the +-- bottom two OAM entries of a walking facing carry RELATIVE_ATTRIBUTES +-- (data/sprites/facings.asm:45-56). The strip starts at py+4 because a sprite +-- draws 4 px above its cell (map_objects.asm:2876). +function World:drawGrassOver(entity, ox, oy, s) + local map = self.map + if not (entity and entity.inGrass and map) then return end + local atlas, tileset = self:grassAtlasFor(map.def) + if not (atlas and tileset) then return end + local G = love.graphics + local bgSet = self.bgSets[self:mapCacheKey(map.id)] or nil + local tilePalettes = tileset.tilePalettes + local tilesPerRow = tileset.tilesPerRow or 16 + local aw, ah = atlas:getDimensions() + local rx, ry = entity.px, entity.py + 4 + self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) + local quad = self.grassQuad + G.setColor(1, 1, 1, 1) + for ty = math.floor(ry / 8) * 8, math.floor((ry + 7) / 8) * 8, 8 do + for tx = math.floor(rx / 8) * 8, math.floor((rx + 15) / 8) * 8, 8 do + local tile = self:bgTileAt(map, tileset, tx, ty) + if tile then + local cx0, cy0 = math.max(rx, tx), math.max(ry, ty) + local cx1 = math.min(rx + 16, tx + 8) + local cy1 = math.min(ry + 8, ty + 8) + if cx1 > cx0 and cy1 > cy0 then + quad:setViewport( + (tile % tilesPerRow) * 8 + (cx0 - tx), + math.floor(tile / tilesPerRow) * 8 + (cy0 - ty), + cx1 - cx0, cy1 - cy0, aw, ah) + local set = bgSet and bgSet[tilePalettes + and tilePalettes[tile + 1] or 1] + local function blit() + G.draw(atlas, quad, + math.floor(ox + cx0 * s), math.floor(oy + cy0 * s), 0, s, s) + end + if set and GbcPalette.available() then + GbcPalette.with(set, blit) + else + blit() + end + end + end + end + end +end + +-- ShakeGrass' object (engine/overworld/map_objects.asm:2031): FacingGrass1 is +-- the tile at (0,+8) and (+8,+8) from the sprite's origin, FacingGrass2 at +-- (-1,+9) and (+9,+9), and SetFacingGrassShake's `and 4` alternates them every +-- four frames (data/sprites/facings.asm:230-239, map_object_action.asm:257). +function World:drawGrassShake(entity, ox, oy, s) + local sheet = self.grassRustleImage + if not (sheet and entity and entity.grassShake and entity.moving) then + return + end + local frames = entity.stepFrames or Player.STEP_FRAMES + local progress = entity.progress or 0 + -- MovementFunction_ShakingGrass takes the tracked object's STEP_DURATION + -- minus one (map_objects.asm:965), so it dies a frame before the step lands. + if progress >= frames then return end + local G = love.graphics + local x1, x2, dy = 0, 8, 4 + if progress % 8 >= 4 then x1, x2, dy = -1, 9, 5 end + local colors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 6 }) + local function blit() + G.setColor(1, 1, 1, 1) + local y = math.floor(oy + (entity.py + dy) * s) + G.draw(sheet, math.floor(ox + (entity.px + x1) * s), y, 0, s, s) + -- OAM_XFLIP on the second entry of both facings, so it draws right to left. + G.draw(sheet, math.floor(ox + (entity.px + x2 + 8) * s), y, 0, -s, s) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, blit) + else + blit() + end +end + +-- This frame's AnimateWaterTile graphic for a map's border block, or nil when +-- the block holds no water at all (engine/tilesets/tileset_anims.asm:167). +function World:borderWaterFrame(def, tileset) + local anim = tileset and tileset.anim + if not (anim and anim.frames) then return nil end + local tile + for _, frame in ipairs(anim.frames) do + if frame.func == "AnimateWaterTile" and frame.tile then + tile = frame.tile + break + end + end + if not tile then return nil end + local block = tileset.blocks + and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] + if not block then return nil end + local found = false + for i = 1, 16 do + if block[i] == tile then + found = true + break + end + end + if not found then return nil end + local sheets = self:animSheets() + if not (sheets and sheets.water) then return nil end + return { + image = sheets.water, + row = World.waterFrameFor(self.animTimer), + tile = tile, + slot = tileset.tilePalettes and tileset.tilePalettes[tile + 1] or 1, + } +end + +-- The 32x32 border-block bake for a map, cached under the same key its canvas +-- is (plus a suffix), so the daytime rollover, the COLOR option, the flicker +-- phase and World:dropMapImages' prefix sweep all reach it. The palettes are +-- the map's own, because the border block comes out of the map's tileset and +-- LoadMapPals colours it with everything else on screen. +function World:borderImageFor(mapId) + local daytime = self.daytime + local flicker = (daytime == "DARK") and self.flickerPhase or 1 + local def = self.maps and self.maps[mapId] + if not def then return nil end + local tileset = self.tilesets and self.tilesets[def.tileset] + if not tileset then return nil end + -- A border block made of water animates with the rest of the map, so this + -- frame's row joins the key: four bakes per map instead of one. + local waterFrame = self:borderWaterFrame(def, tileset) + local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) + .. "|" .. tostring(waterFrame and waterFrame.row or 0)) + local cached = self.mapImages[cacheKey] + if cached ~= nil then return cached or nil end + local atlas = self:atlasFor(def) + if not atlas then return nil end + local bgSet = self.palettes and daytime + and Palettes.bgSet(self.palettes, def, daytime) or nil + if bgSet and daytime == "DARK" then + bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) + end + local ok, img = pcall(BorderFill.bake, atlas, tileset, + BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame) + -- `false` rather than nil: a bake that cannot be made (a headless run with + -- no canvas support) must not be retried once per frame forever. + self.mapImages[cacheKey] = (ok and img) or false + return (ok and img) or nil +end + +-- FlickeringCaveEntrancePalette runs on the VBlank clock, so this runs on the +-- fixed step: two frames on, two frames off, and only on a DARKNESS_PALSET +-- map. A phase change swaps the baked canvas rather than re-baking -- both +-- phases stay in mapImages once they have been drawn once. +function World:pollCaveFlicker() + if self.daytime ~= "DARK" or not self.mapImage then return false end + self.flickerClock = (self.flickerClock + 1) % Palettes.FLICKER_PERIOD + local phase = Palettes.caveFlickerSource(self.flickerClock) + if phase == self.flickerPhase then return false end + self.flickerPhase = phase + self.mapImage = self:imageFor(self.map.id) + for _, nb in ipairs(self.neighbors) do + nb.image = self:imageFor(nb.id) or nb.image + end + return true +end + +-- `wTileAnimationTimer and %110` (engine/tilesets/tileset_anims.asm:172-174): +-- four water frames, each held for two ticks of the 0..7 timer. 1-based, so +-- it indexes the four rows of the water sheet directly. +function World.waterFrameFor(timer) + return math.floor(((timer or 0) % 8) / 2) + 1 +end + +-- AnimateFlowerTile's `and %10` plus hCGB (tileset_anims.asm:204-212): on CGB +-- only cgb_1 and cgb_2 are ever written, alternating every two timer ticks. +function World:flowerFrameFor(timer) + local rows = self.tilesets and self.tilesets.flowerCgbFrames + local first, second = 2, 4 + if rows and rows[1] and rows[2] then first, second = rows[1], rows[2] end + return ((timer or 0) % 4 < 2) and first or second +end + +-- _AnimateTileset (engine/tilesets/tileset_anims.asm:11) runs ONE row of the +-- tileset's program per frame and DoneTileAnimation (:48) wraps the index, so +-- a whole pass is `period` frames; StandingTileFrame8 (:57) ticks the 0..7 +-- timer once per pass. Same VBlank clock the cave flicker runs on. +function World:pollTileAnim() + local tileset = self.map and self.map.def + and self.tilesets and self.tilesets[self.map.def.tileset] + local anim = tileset and tileset.anim + if not (anim and anim.period and anim.period > 0) then return false end + self.animClock = (self.animClock or 0) + 1 + if self.animClock < anim.period then return false end + self.animClock = 0 + self.animTimer = ((self.animTimer or 0) + 1) % 8 + return true +end + +-- AnimateTowerPillarTile's own offsets table (tileset_anims.asm:334-342), +-- 1-based: five frames walked up and back down over the 0..7 timer. +local TOWER_ROWS = { [0] = 1, 2, 3, 4, 5, 4, 3, 2 } + +-- Which row of a layer's strip this timer value shows. Whirlpool `and %11` +-- (tileset_anims.asm:368); lava `and %110` halved, with tile $5b running two +-- frames ahead of tile $38 (:238-245, :266-270). +function World:animRow(layer) + local timer = (self.animTimer or 0) % 8 + local kind = layer.kind + if kind == "water" then return World.waterFrameFor(timer) end + if kind == "flower" then return self:flowerFrameFor(timer) end + if kind == "whirlpool" then return (timer % 4) + 1 end + if kind == "tower" then return TOWER_ROWS[timer] end + if kind == "lava2" then return math.floor(timer / 2) + 1 end + if kind == "lava1" then return ((math.floor(timer / 2) + 2) % 4) + 1 end + -- A scroll strip is baked one row per timer value. + return timer + 1 +end + +-- One frame strip, loaded once. `false` after a miss so a cache from before +-- it was extracted costs one pcall rather than one a frame. +function World:animSheet(path) + if not path then return nil end + self.animSheetPaths = self.animSheetPaths or {} + local cached = self.animSheetPaths[path] + if cached ~= nil then return cached or nil end + local ok, img = pcall(Assets.image, path) + if ok and img then img:setFilter("nearest", "nearest") end + self.animSheetPaths[path] = (ok and img) or false + return (ok and img) or nil +end + +-- The two strips the border-block bake reaches for by name +-- (assets/generated/tilesets/*_frames.png), memoized as a pair. +function World:animSheets() + if self.animSheetCache ~= nil then return self.animSheetCache or nil end + local defs = self.tilesets + local sheets = nil + for kind, path in pairs({ water = defs and defs.waterFrames, + flower = defs and defs.flowerFrames }) do + local img = self:animSheet(path) + if img then + sheets = sheets or {} + sheets[kind] = img + end + end + self.animSheetCache = sheets or false + return sheets +end + +-- ScrollTileRightLeft (tileset_anims.asm:65) scrolls right for four ticks then +-- left for four, one call per pass in every program that uses it: the offset +-- the buffer has reached at each value of the 0..7 timer. +local SCROLL_H = { [0] = 0, 1, 2, 3, 2, 1, 0, -1 } + +-- One tile's eight scroll positions stacked into an 8x64 strip. The cart +-- rotates the tile in VRAM (ScrollTileRightLeft, ScrollTileDown :139) and the +-- map canvas is already baked, so the rotations are baked off the atlas once +-- and drawn over it like any other frame strip. +function World:scrollStrip(mapDef, tileset, tile, scroll) + local key = tostring(mapDef.tileset) .. "|" .. tile .. "|" + .. tostring(scroll.h) .. "," .. tostring(scroll.v) + self.scrollStrips = self.scrollStrips or {} + local cached = self.scrollStrips[key] + if cached ~= nil then return cached or nil end + local atlas = self:atlasFor(mapDef) + local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8) + if not (atlas and ok and canvas) then + self.scrollStrips[key] = false + return nil + end + canvas:setFilter("nearest", "nearest") + local perRow = tileset.tilesPerRow or 16 + local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 + local aw, ah = atlas:getDimensions() + local G = love.graphics + local drawn = pcall(function() + canvas:renderTo(function() + -- Same origin guard the map bake takes: this can run under whatever + -- transform World:draw had already pushed. + G.push() + G.origin() + G.clear(0, 0, 0, 0) + G.setColor(1, 1, 1, 1) + for timer = 0, 7 do + local dx = ((scroll.h or 0) > 0) and (SCROLL_H[timer] % 8) or 0 + local dy = (timer * (scroll.v or 0)) % 8 + -- The tile wraps, so each position draws as up to four pieces: + -- { destination, source, size }. + for _, col in ipairs({ { dx, 0, 8 - dx }, { 0, 8 - dx, dx } }) do + for _, row in ipairs({ { dy, 0, 8 - dy }, { 0, 8 - dy, dy } }) do + if col[3] > 0 and row[3] > 0 then + G.draw(atlas, G.newQuad( + sx + col[2], sy + row[2], col[3], row[3], aw, ah), + col[1], timer * 8 + row[1]) + end + end + end + end + G.pop() + end) + end) + self.scrollStrips[key] = drawn and canvas or false + return drawn and canvas or nil +end + +-- One 8x8 row of a frame strip, reused: this runs every overworld frame and a +-- fresh Quad per cell would churn the GC. +function World:animQuad(key, row, frames) + self.animQuads = self.animQuads or {} + local id = key .. "#" .. row + local q = self.animQuads[id] + if not q then + q = love.graphics.newQuad(0, (row - 1) * 8, 8, 8, 8, (frames or 4) * 8) + self.animQuads[id] = q + end + return q +end + +-- Draw this frame's animated tiles over one baked canvas. Culled to the +-- view: a sea route names every water cell on the map and only a screenful of +-- them is ever visible. +function World:drawAnimCells(mapId, ox, oy, s) + if not mapId then return end + local key = self:mapCacheKey(mapId) + local cells = self.animCells[key] + if not cells then return end + local def = self.maps and self.maps[mapId] + local tileset = def and self.tilesets and self.tilesets[def.tileset] + local G = love.graphics + local cam = self.camera + local bgSet = self.bgSets[key] or nil + local left = cam.x - ox - 8 + local top = cam.y - oy - 8 + local right = left + (self.viewW or 160) + 16 + local bottom = top + (self.viewH or 144) + 16 + for _, list in pairs(cells) do + local layer = list.layer + local sheet + if layer.kind == "scroll" then + sheet = tileset + and self:scrollStrip(def, tileset, list.tile, layer.scroll) + else + sheet = self:animSheet(layer.sheet) + end + if sheet then + local row = self:animRow(layer) + local quad = self:animQuad( + layer.sheet or ("scroll|" .. list.tile), row, layer.frames) + local xy = list.cells + local function blit() + G.setColor(1, 1, 1, 1) + for i = 1, #xy, 2 do + local tx, ty = xy[i], xy[i + 1] + if tx >= left and tx <= right and ty >= top and ty <= bottom then + G.draw(sheet, quad, + math.floor((tx + ox - cam.x) * s), + math.floor((ty + oy - cam.y) * s), 0, s, s) + end + end + end + -- All cells of one tile id share a PalMap slot, so the palette is set + -- once per id rather than once per cell. + local set = bgSet and bgSet[list.slot] + if set and GbcPalette.available() then + GbcPalette.with(set, blit) + else + blit() + end + end + end +end + +-- Recompute the active time of day and hand every drawable its colors. +-- Called on map entry and once a second while walking, so a real-clock +-- rollover repaints the world the way ReplaceTimeOfDayPals does on the cart. +-- +-- Returns true when the daytime actually changed, i.e. the baked map images +-- are stale and callers must drop them. +-- One entity's OW palette. SpriteRenderer bakes the sheet against these +-- colours and keys OBJ colour 0 to alpha; the group string keeps one bake per +-- (daytime, PAL_OW_*) pair, so two NPCs on the same palette share it. +function World:applySpritePalette(entity) + if not (self.palettes and entity and entity.sprite and entity.spriteDef) then + return + end + local daytime = self.daytime + or Palettes.daytimeFor(self.map and self.map.def, self:hour(), + self.flashUsed) + -- entity.def is the object_event, whose own palette field OVERRIDES the + -- sprite's (Palettes.objectPaletteId; AddMapObject, player_object.asm:187). + -- The player has no object_event here, so it falls through to the sheet. + local colors = Palettes.spritePalette(self.palettes, daytime, + entity.spriteDef, entity.def) + if not colors then return end + -- The bake cache key has to be the palette actually chosen, or the three + -- beasts -- one sheet, three object palettes -- would all share the first + -- bake taken. + local id = Palettes.objectPaletteId(entity.def) + or entity.spriteDef.paletteId or 0 + entity.sprite:setObjPalette(colors, + ("gen2:%s:%d"):format(tostring(daytime), id)) +end + +-- the two vanilla links the time-of-day chains wrap, hoisted so an empty +-- chain allocates no closure (src/world/OverworldController.lua does the same +-- for the Gen 1 pair) +local function sameTod(tod) return tod end +local function samePalette(name) return name end + +-- world.tod, the same name and the same period strings Gen 1's +-- OverworldState:timeOfDay wraps, and the same job: answer what time of day +-- the WORLD is in. It carries more here because Gold has a real clock behind +-- it (src/core/gen2/Clock.lua), so this is the one write everything downstream +-- reads -- World:timeOfDayId's VAR_TIMEOFDAY, the encounter slots, the object +-- hour windows and the palette bake all follow whatever comes back. +-- +-- Gen 1's ctx keys (map, mapId, x, y, steps) are kept verbatim; `hour` and +-- `weekday` are added, because on Gold a day/night mod has a real hour to +-- reason about instead of a step counter. +function World:timeOfDay(hour) + local clock = Palettes.clockDaytime(hour) + if not Runtime.wantsHook("world.tod") then return clock end + local map = self.map + local p = self.player + local save = self.game and self.game.save + local next_ = Runtime.call("world.tod", sameTod, clock, { + map = map, + mapId = map and map.id, + x = p and p.cellX, + y = p and p.cellY, + steps = (save and save.stepCount) or 0, + hour = hour, + weekday = self:weekday(), + }) + if type(next_) ~= "string" or next_ == "" then return clock end + return next_ +end + +function World:applyPalettes() + if not self.map then return false end + local previous = self.daytime + local previousTod = self.tod + -- GetTimeOfDay reads hHours, the one clock UpdateTime writes (home/time.asm): + -- the palette, the object hour windows (World:objectTimeVisible), the + -- day/night encounter slots and VAR_HOUR are all the same read, so this goes + -- through World:hour rather than round-tripping the host clock inside + -- Palettes.clockDaytime. + local hour = self:hour() + local tod = self:timeOfDay(hour) + self.tod = tod + -- ReplaceTimeOfDayPals.BrightnessLevels: a map header that pins a PALETTE_* + -- overrides the clock outright, and PALETTE_DARK additionally becomes NITE + -- once FLASH has been used. Only the maps that FOLLOW the clock take the + -- hooked answer -- pinning is the map saying it does not care what hour it + -- is, and a world.tod mod must not unpin Union Cave. + local def = self.map.def + local pinned = def and def.palette and def.palette ~= "PALETTE_AUTO" + local daytime = pinned + and Palettes.daytimeFor(def, hour, self.flashUsed) + or tod + -- map.palette, the same name Gen 1 wraps around a map's resolved palette + -- name. Gold has no palette-name table: which four-colour set a map loads is + -- named by its DAYTIME, so that is the value in the chain here. The + -- arguments are Gen 1's -- value, map, ctx -- and ctx keeps `tod` and adds + -- the two Gen 2 facts behind the answer. + if Runtime.wantsHook("map.palette") then + local hooked = Runtime.call("map.palette", samePalette, daytime, self.map, + { tod = tod, environment = def and def.environment, + pinned = pinned and def.palette or nil, hour = hour, + flashUsed = self.flashUsed and true or false }) + if type(hooked) == "string" and Palettes.DAYTIME_ID[hooked] then + daytime = hooked + end + end + self.daytime = daytime + local changed = previous ~= self.daytime + + -- Gen 1 fires world.tod_changed off the same transition; `daytime` is the + -- addition, because on Gold the period the world is in and the palette set a + -- pinned map loads are not always the same string. + if previousTod ~= nil and previousTod ~= tod + and Runtime.wants("world.tod_changed") then + Runtime.emit("world.tod_changed", { + tod = tod, previous = previousTod, mapId = self.map.id, + daytime = self.daytime, + }) + end + + if self.palettes then + self:applySpritePalette(self.player) + for _, npc in pairs(self.npcPool or {}) do self:applySpritePalette(npc) end + end + return changed +end + +function World:rebuildNeighbors() + self.neighbors = {} + if not self.map then return end + local s = self:zoomScale() + local ww, wh = love.graphics.getDimensions() + local vw = math.ceil(ww / s) + local vh = math.ceil(wh / s) + if vw % 2 ~= 0 then vw = vw + 1 end + if vh % 2 ~= 0 then vh = vh + 1 end + self.viewW, self.viewH = vw, vh + local list = World.computeNeighbors( + self.maps, self.map.id, NEIGHBOR_HOPS, vw, vh) + for _, n in ipairs(list) do + local img = self:imageFor(n.id) + if img then + table.insert(self.neighbors, { id = n.id, ox = n.ox, oy = n.oy, image = img }) + end + end +end + +function World:setMap(mapId, cx, cy, facing, opts) + opts = opts or {} + local def = self.maps[mapId] + if not def then + self.status = "Unknown map " .. tostring(mapId) + return false + end + local tileset = self.tilesets[def.tileset] + if not tileset then + self.status = "Missing tileset " .. tostring(def.tileset) + return false + end + -- map.exited / map.entered are the Gen 1 pair (src/world/OverworldController + -- setMap), same names and same payload keys. Divergence, deliberate: the + -- exit is emitted BELOW the two guards above rather than at the top of the + -- function, because Gold's setMap can refuse a load Gen 1's would have + -- asserted on, and an "exited" that is followed by no "entered" reads to a + -- listener as a map that vanished. + local fromMapId = self.map and self.map.id + if fromMapId then + Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) + end + -- LoadMapAttributes refills wOverworldMapBlocks from ROM, so every block CUT + -- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next + -- time the map is loaded, and this has to happen before Map.new reads them. + self:restoreBlocks() + -- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before + -- anything else that touches state: event flags 0-7 + -- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what + -- lets Bill's grandpa hand out his next evolution stone on re-entry and + -- re-arms every other once-per-visit latch (Kurt's house, the ship ports, + -- Dragon's Den B1F, the Park gate). + -- + -- MapSetupScript_Continue is the one entry that does NOT: it runs + -- HandleContinueMap, which is the label BELOW that reset, so loading a save + -- keeps the byte SRAM was holding (data/maps/setup_scripts.asm). Without + -- that exemption, saving inside Kurt's house and continuing re-arms the + -- latch and he repeats the branch the player already saw. The post-credits + -- spawn is not a continue: SpawnAfterE4 / PostCreditsSpawn set + -- MAPSETUP_WARP, so it takes the reset (engine/menus/intro_menu.asm). + if not opts.continue then + self.events:resetMapBuffer() + end + -- ResetBikeFlags (home/flag.asm) zeroes the whole byte on a map load, and + -- BIKEFLAGS_STRENGTH_ACTIVE_F is in it -- STRENGTH has to be used again in + -- the next room, which is the whole shape of the Blackthorn Gym puzzle. + self.strengthActive = false + self.strengthMon = nil + -- The other two bits of the same byte. They have to go BEFORE + -- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets + -- them straight back again: leaving them set is how one visit to Route 17 + -- would keep the player glued to the bike for the rest of the game. + self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false) + self:setEngineFlag(Bike.ENGINE_DOWNHILL, false) + -- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn): + -- walking from an OUTDOOR map into an INDOOR one whose tileset is + -- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and + -- that pair is what a whiteout reads. It is the only thing in the game that + -- moves the respawn point, and it has to run on the map load rather than on + -- the heal: the cart moves your spawn when you walk in the DOOR, whether or + -- not you talk to the nurse. + -- + -- Missing this was not a small divergence. MrPokemonsHouse.asm does + -- `blackoutmod CHERRYGROVE_CITY` in the first half hour of the game, so with + -- no other writer the respawn point was pinned to Cherrygrove for the entire + -- rest of the run: every whiteout at the Elite Four teleported the player + -- back across Johto. (Found by the Gold route bot, which spent about 40k + -- frames per Elite Four attempt walking home from Cherrygrove.) + -- + -- Divergence, deliberate: the cart stores the OUTDOOR map and resolves it + -- through data/maps/spawn_points.asm to get coordinates inside that town's + -- Pokecenter. No spawn table is emitted into the cache, and World:warpToSpawn + -- already prefers a stored map id over the SPAWN_* lookup, so store the + -- Pokecenter itself. Same building, same town; the landing tile is its door + -- rather than the mat in front of the counter. + -- + -- A named method rather than eight inline lines so that + -- tests/gen2_pokecenter_spawn_test.lua can drive the shipped rule instead of + -- restating it: setMap needs a tileset, a Map and the callback machinery + -- before it will run at all, which is more world than the rule needs. + self:updateWhiteoutSpawn(def, mapId) + + -- ResetFlashIfOutOfCave: the FLASH flag survives a warp between two cave + -- floors and dies the moment you step out onto a ROUTE or a TOWN. + if def.environment == "ROUTE" or def.environment == "TOWN" then + self.flashUsed = false + end + -- No noteFlypoint call here anymore: MAPCALLBACK_NEWMAP (run below, once + -- the map is actually loaded) is a town's own `setflag ENGINE_FLYPOINT_*`, + -- and FieldMoves.hasVisitedSpawn now reads that flag straight off + -- save.engineFlags instead of a second write this function used to make. + -- The map load repaints everything, so a fade sheet and a screen shake left + -- over from the script that warped cannot survive it -- LoadMapPalettes and + -- DeleteMapObject are what end both on the cart. + self.fade = nil + self.shake = nil + self.map = Map.new(def, tileset) + -- A follow pairing points at two live objects, and a map load rebuilds them + -- (RefreshMapSprites); nothing on the cart survives that either. + self.followState = nil + -- EnterMap's SetUpFiveStepWildEncounterCooldown (engine/overworld/events.asm: + -- 110, :367-370): four encounter-free steps after every map entry. + self.wildCooldown = 5 + -- Resolve colors before baking: an indoor map pinned to PALETTE_DAY and the + -- town outside it are lit differently, so the daytime has to be settled + -- before imageFor picks a cache key -- and before a TILES callback's + -- `changeblock` re-bakes through World:refreshMapImages, which would + -- otherwise spend that bake on the daytime of the map being LEFT. + self:applyPalettes() + -- 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] + 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) + end + if not opts.seamless then + self.player.moving = false + self.player.progress = 0 + self.player.targetX, self.player.targetY = nil, nil + end + else + self.player = Player.new(cx, cy, face, chris) + 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). + self.player.inGrass = self:grassAt(cx, cy) + self.player.grassShake = nil + -- HandleNewMap (home/map.asm), in its own order: MAPCALLBACK_NEWMAP, then + -- ClearCmdQueue, then MAPCALLBACK_CMDQUEUE. The queue never survives a map + -- load on the cart either, which is why both maps that use one write it back + -- from a callback rather than once at the start of the game. + -- + -- Every setup script that reaches this port's setMap -- Warp, BadWarp, Door, + -- Fall, Teleport, Train, Connection -- carries HandleNewMap, LoadBlockData + -- and LoadMapObjects, so all four callbacks belong here. The three that + -- carry fewer are not setMap calls: `reloadmap` is World:rebuildPeople (no + -- load at all), ReturnToMapFromSubmenu has no port equivalent, and the + -- Continue script's exemptions (no NEWMAP, no OBJECTS) exist because the cart + -- restores wMapObjects from the save -- the port derives object visibility + -- from the flags instead, so a continue has to build it like any other load. + self:runMapCallback("MAPCALLBACK_NEWMAP") + CmdQueue.clear(self.cmdQueue) + self:writeCmdQueue() + -- LoadBlockData: MAPCALLBACK_TILES, with the block buffer already refilled + -- from ROM by restoreBlocks above and nothing baked off it yet. + self:runMapCallback("MAPCALLBACK_TILES") + -- LoadMapGraphics has no failure arm (data/maps/setup_scripts.asm:41): a + -- failed bake reports through self.status and the rest of the setup runs. + self.mapImage = self:imageFor(mapId) + if not self.mapImage then + self.status = "Could not bake " .. tostring(mapId) + end + if opts.seamless then + self.warpCooldown = nil + else + -- Don't re-trigger the arrival warp until the player steps off. + self.warpCooldown = { x = cx, y = cy } + end + self:rebuildNeighbors() + -- LoadMapObjects (engine/overworld/map_setup.asm): MAPCALLBACK_OBJECTS, and + -- only THEN LoadObjectMasks / InitializeVisibleSprites. The callback's + -- `appear` and `disappear` decide which objects the rebuild below finds -- + -- which day of the week's traveller is standing on Route 29, whether Lugia is + -- in its chamber -- so running it after the rebuild would show the previous + -- day's answer until something else rebuilt. + self:runMapCallback("MAPCALLBACK_OBJECTS") + -- LoadObjectMasks itself, the load this map visit's masks come from. Every + -- appear/disappear after it moves one byte of its own. + self:loadObjectMasks() + -- CheckUpdatePlayerSprite (engine/overworld/map_setup.asm), which every map + -- setup script runs: the Cycling Road puts the player ON the bike, an + -- INDOOR / DUNGEON map takes them off it, and the surf arms follow + -- CheckOnWater -- the permission of the tile the player is STANDING on, which + -- is why a load that lands on water is a surfing load and a warp out of the + -- sea onto a beach is not. It has to be after MAPCALLBACK_NEWMAP, because + -- that callback is where ENGINE_ALWAYS_ON_BIKE is set, and before the music + -- below, which reads the state back. + self:applyPlayerState(Bike.mapSetupState( + self.playerState, def.environment, self:alwaysOnBike(), + Permissions.isWater(self.map:cellCollision(cx, cy)))) + self:rebuildPeople({ seamless = opts.seamless }) + -- rebuildPeople may have pooled fresh NPCs; give them their colors too. + self:applyPalettes() + local audio = self.game and self.game.data and self.game.data.audio + -- PlayMapMusicBike (home/audio.asm), which is the mapsetup every load uses: + -- a player still on the bike keeps the bike theme across the warp instead of + -- hearing the new map's song. Music.play dedupes the same label, so this is + -- safe on seamless edge crossings. + if audio and audio.runtime then + if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then + Music.playMap(self.game.data, mapId) + end + end + -- Fires with the map fully built and BEFORE the map's own scene script, so a + -- listener sees exactly the state that script does -- the position Gen 1's + -- emit takes ahead of its onEnter chain. `via` carries the same four words + -- Gen 1 uses plus "continue", which is the one map load Gen 2 has and Gen 1 + -- does not (MapSetupScript_Continue, the save being resumed). + -- Ahead of the emit, so a map.entered listener already sees this load's + -- follower (src/world/OverworldController.lua:446's position). + Follower.onMapEntered(self.game, self, opts, true) + Runtime.emit("map.entered", { + mapId = mapId, map = self.map, fromMapId = fromMapId, + via = opts.via + or (opts.continue and "continue") + or (opts.seamless and "connection") + or (fromMapId and "warp" or "boot"), + }) + -- Map-enter scene scripts (e.g. Elm lab walk-up at scene 0). + if not opts.seamless then + self.pendingSceneScript = true + end + -- StartMap (engine/overworld/events.asm): `farcall InitCallReceiveDelay` on + -- every map entry, connections included -- which is why a player who keeps + -- warping is never rung (src/core/gen2/Phone.lua's receive timer). + if self.game and self.game.save then + require("src.core.gen2.Phone").onMapLoad(self.game.save, + self:stepContext().phone) + end + -- The setup script runs to its `db -1` either way, so the load reports true + -- and only self.status carries a failed bake (data/maps/setup_scripts.asm:53). + if self.mapImage then self.status = nil end + return true +end + +local function heldDirection(input) + if input then + if input:isDown("up") then return "up" end + if input:isDown("down") then return "down" end + if input:isDown("left") then return "left" end + if input:isDown("right") then return "right" end + return nil + end + if love.keyboard.isDown("up", "w") then return "up" end + if love.keyboard.isDown("down", "s") then return "down" end + if love.keyboard.isDown("left", "a") then return "left" end + if love.keyboard.isDown("right", "d") then return "right" end + return nil +end + +-- PLAYEREVENT_WARP -> WarpToNewMapScript (engine/overworld/events.asm): +-- +-- WarpToNewMapScript: +-- warpsound +-- newloadmap MAPSETUP_DOOR +-- end +-- +-- so a warp taken by walking onto the tile is two things, in that order: the +-- sound GetWarpSFX picks off the tile the player is STANDING on (which is why +-- it has to be read before the load), and the MAPSETUP_DOOR setup script with +-- the map load inside it. This used to be five lines that called setMap +-- directly, which is why doors were silent and instant. +-- home/map.asm GetDestinationWarpNumber: a `warp_event` whose destination warp +-- number is -1 ($ff) does NOT name its own destination. It takes the whole +-- triple -- warp number, map group, map number -- out of wBackupWarpNumber and +-- friends, which is how one elevator door leads to seven different floors and +-- why Elevator_GoToFloor rides without warping. A $ff warp with nothing +-- written there yet keeps the destination the map declared. +function World:resolveWarp(warpDef) + if warpDef.destWarp ~= 0xff then + return warpDef.destMap, warpDef.destWarp + end + local backup = self.backupWarp + if not (backup and backup.map and self.maps[backup.map]) then + return warpDef.destMap, warpDef.destWarp + end + return backup.map, backup.warp +end + +-- The other half of the -1 contract, from the arrival side. CopyWarpData +-- (home/map.asm) stores the warp stepped ON and the map being left in +-- wPrevWarp / wPrevMapGroup / wPrevMapNumber on every warp taken, and +-- LoadMapAttributes' warp-coordinate read copies that triple into +-- wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber whenever the warp +-- ARRIVED ON declares destination warp -1. That refresh is what lets the one +-- shared POKECENTER_2F staircase lead back down into whichever centre's +-- stairs were climbed, and what tells an elevator door which floor it was +-- entered from before Elevator_GoToFloor overwrites the triple with the +-- chosen row. Without it the -1 warp resolves to nothing and the tile is +-- simply dead -- the player is trapped upstairs in every Pokemon Center. +-- +-- EnterMapWarp's `.SaveDigWarp` (home/map.asm) is the second writer, and it +-- writes the same triple: walking through a door out of an OUTDOOR map +-- (CheckOutdoorMap -- ROUTE or TOWN) into an INDOOR one (CheckIndoorMap -- +-- INDOOR, CAVE, DUNGEON or GATE) records the door used, so Dig and Escape Rope +-- pay out to THAT entrance rather than to whichever one banked the triple +-- last. MOUNT_MOON_SQUARE and TIN_TOWER_ROOF are the routine's own two +-- exceptions: outdoor maps sitting inside indoor ones, which the rope must +-- never drop the player onto. The cart keeps this in wDigWarpNumber and +-- friends; this port banks one triple for both readers, which is what +-- World:escapeRopeTarget resolves through. +local DIG_WARP_OUTDOOR = { ROUTE = true, TOWN = true } +local DIG_WARP_INDOOR = { + INDOOR = true, CAVE = true, DUNGEON = true, GATE = true, +} +local DIG_WARP_EXCLUDED = { + MOUNT_MOON_SQUARE = true, TIN_TOWER_ROOF = true, +} + +function World:recordWarpBackup(prevMapId, prevWarpIndex, arrivalWarp, + destMapId) + if not (prevMapId and prevWarpIndex) then return end + if arrivalWarp and arrivalWarp.destWarp == 0xff then + self.backupWarp = { warp = prevWarpIndex, map = prevMapId } + return + end + if DIG_WARP_EXCLUDED[prevMapId] then return end + local from = self.maps and self.maps[prevMapId] + local into = destMapId and self.maps and self.maps[destMapId] + if not (from and into) then return end + if not DIG_WARP_OUTDOOR[from.environment] then return end + if not DIG_WARP_INDOOR[into.environment] then return end + self.backupWarp = { warp = prevWarpIndex, map = prevMapId } +end + +-- wPrevWarp's find: the index of the warp event being stepped on, in the map +-- that declared it. Warp defs are shared tables, so identity is the match. +function World:warpIndexOf(warpDef) + local warps = self.map and self.map.def and self.map.def.warps + for index, row in ipairs(warps or {}) do + if row == warpDef then return index end + end + return nil +end + +-- the resolved destination passes through warp.destination, so a mod can +-- reroute one door without owning the warp table -- the same three returns and +-- the same vanilla link src/world/Warp.lua uses under Gen 1 +local function warped(mapId, x, y) return mapId, x, y end + +function World:takeWarp(warpDef) + if not warpDef or not warpDef.destMap then return false end + local destMapId, destWarpNumber = self:resolveWarp(warpDef) + local dest = self.maps[destMapId] + if not dest then return false end + local destWarp = dest.warps and dest.warps[destWarpNumber] + if not destWarp then return false end + local destX, destY = destWarp.x, destWarp.y + -- ctx keeps Gen 1's three keys. `lastMap` is the -1 backup triple + -- (World:recordWarpBackup), which is Gen 2's version of the remembered + -- outdoor side Gen 1 resolves LAST_MAP through; `destWarp` is the warp + -- NUMBER the resolve landed on, which Gen 1's warp table has no equivalent + -- of. A reroute onto a map this cache does not hold is refused here rather + -- than left for setMap, so the sound and the backup writes never happen for + -- a warp that cannot be taken. + if Runtime.wantsHook("warp.destination") then + destMapId, destX, destY = Runtime.call("warp.destination", warped, + destMapId, destX, destY, + { warp = warpDef, lastMap = self.backupWarp, destWarp = destWarpNumber, + data = self.game and self.game.data, maps = self.maps }) + if not (destMapId and self.maps[destMapId] and destX and destY) then + return false + end + end + self:warpSound() + -- wBackupMapGroup / wBackupMapNumber: the map being LEFT. The elevator's + -- .FindCurrentFloor is the only thing that reads it, and it is what makes + -- "Now on:" say the floor you got in from. + self.backupMapId = self.map and self.map.id + -- wPrevWarp / wPrevMapGroup / wPrevMapNumber, read before the load pulls + -- the source map out from underfoot. + local prevMapId = self.map and self.map.id + local prevWarpIndex = self:warpIndexOf(warpDef) + -- Gen 1's five payload keys, unchanged, and the coordinates are the HOOKED + -- ones so a listener and a warp.destination wrapper never disagree about + -- where the player went. `toWarp` is the destination warp number, which the + -- Gen 1 warp record has no field for. + Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId, + x = destX, y = destY, warp = warpDef, + toWarp = destWarpNumber }) + return self:runMapSetup(MAPSETUP_DOOR, function() + local ok = self:setMap(destMapId, destX, destY, + (self.player and self.player.facing) or "down") + if ok then + self:spawnFacing() + self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) + end + return ok + end) +end + +-- RefreshPlayerSprite (engine/overworld/map_objects.asm) is the whole rule for +-- which way a map load leaves the player pointing: CheckWarpFacingDown against +-- the tile they ARRIVE on, then `call c, SpawnInFacingDown`. A tile that is +-- not in that array keeps the facing they walked in with -- so you enter a +-- building still facing up (the mat inside is a COLL_WARP_CARPET_*) and step +-- out of one facing the street (the doorway outside is COLL_DOOR). +-- +-- It runs AFTER the load for the same reason the cart's does: the array is +-- indexed by wPlayerTileCollision, which is the DESTINATION map's tile. +-- +-- This replaces a guess that compared the destination Y against the map height. +-- That agreed with the cart at New Bark Town's two ends by luck and had no +-- reason to anywhere else: a ladder in the middle of a cave floor is neither +-- the top row nor the bottom one. +function World:spawnFacing() + local p = self.player + if not (self.map and p) then return end + if Permissions.warpFacesDown(self.map:cellCollision(p.cellX, p.cellY)) then + p.facing = "down" + end +end + +-- Seamless edge cross: swap map data, park the player one cell before the +-- landing (same world pixels the neighbor strip already showed), and keep +-- the step running so the seam does not hitch. +function World:tryConnection(dir) + local connKey = DIR_CONN[dir] + local conn = self.map:connection(connKey) + if not conn or not conn.mapId then return false end + local dest = self.maps[conn.mapId] + if not dest then return false end + local x, y = Map.connectionLanding( + dest, conn, dir, self.player.cellX, self.player.cellY) + if not x then return false end + local destMap = Map.new(dest, self.tilesets[dest.tileset]) + -- A surfing crossing lands on water, which isWalkable refuses; the arm the + -- step would have taken is what decides, the same as it does inside the map. + local landable + if FieldMoves.isSurfing(self.playerState) then + landable = Permissions.surfable(destMap:cellCollision(x, y)) ~= nil + else + landable = destMap:isWalkable(x, y) + end + if not landable then return false end + + local p = self.player + local d = Map.DELTA[dir] + self:setMap(conn.mapId, x, y, dir, { seamless = true }) + p.cellX, p.cellY = x - d[1], y - d[2] + p.px, p.py = p.cellX * 16, p.cellY * 16 + p.facing = dir + p.targetX, p.targetY = x, y + p.moving = true + p.progress = 0 + FixedStep:discardCatchup() + return true +end + +-- .TranslateIntoMovement (engine/overworld/player_movement.asm) picks the arm +-- off wPlayerState, and .Normal and .Surf differ in exactly two things: which +-- permission test the step runs (.CheckLandPerms against LAND_TILE versus +-- .CheckSurfPerms, which takes LAND and WATER alike), and what a LAND answer +-- means -- an ordinary step for one, .ExitWater for the other. +-- +-- Player:tryMove owns the turn-in-place timing and the step bookkeeping, and +-- none of that changes between the two arms; only the map's answer does. So +-- the surf arm hands tryMove a proxy that answers .CheckSurfPerms rather than +-- growing a second copy of the timing. +function World:surfMap(map) + map = map or self.map + return { + inBounds = function(_, x, y) return map:inBounds(x, y) end, + isWalkable = function(_, x, y) + return Permissions.surfable(map:cellCollision(x, y)) ~= nil + end, + } +end + +-- A map proxy that refuses every step but keeps bounds honest. Handed to +-- tryMove when GetMovementPermissions forbids the direction: the press still +-- has to TURN the player (the cart's .bump path runs after the facing is +-- written), so the refusal cannot short-circuit above tryMove. +local function refusingMap(map) + return { + inBounds = function(_, x, y) return map:inBounds(x, y) end, + isWalkable = function() return false end, + } +end + +-- the movement.speed chain's vanilla link, hoisted so an empty chain allocates +-- no closure on a per-step path +local function sameFrames(frames) return frames end + +-- .TryJump: the refused step becomes a two-cell STEP_LEDGE when the player +-- STANDS on a ledge tile whose .ledge_table row includes the facing. The cart +-- runs it after .TryStep fails for any reason (permission or NPC alike). The +-- landing tile is checked here where the cart does not bother -- no real map +-- has a blocked landing, and a hop into scenery would strand the player. +function World:tryLedgeJump(dir) + local p, map = self.player, self.map + local facings = Permissions.ledgeFacings( + map:cellCollision(p.cellX, p.cellY)) + if not (facings and facings[dir]) then return false end + local d = Map.DELTA[dir] + local tx, ty = p.cellX + d[1] * 2, p.cellY + d[2] * 2 + if not map:inBounds(tx, ty) then return false end + if not map:isWalkable(tx, ty) then return false end + for _, e in ipairs(self.entities or {}) do + if e ~= p then + if e.cellX == tx and e.cellY == ty then return false end + if e.moving and e.targetX == tx and e.targetY == ty then return false end + end + end + p.targetX, p.targetY = tx, ty + p.moving = true + p.jumping = true + -- JumpStep res IN_GRASS_F and calls neither UpdateTallGrassFlags nor + -- ShakeGrass (engine/overworld/movement.asm:741-770). + p.inGrass, p.grassShake = false, nil + p.progress = 0 + p.stepFrames = Player.STEP_FRAMES + self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE) + return true +end + +-- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674); +-- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226). +function World:playerStepGrass() + local p = self.player + if not (p and p.moving) or p.jumping or (p.progress or 0) ~= 0 then return end + local grass = self:grassAt(p.targetX or p.cellX, p.targetY or p.cellY) + if p.inGrass then p.inGrass = grass end + p.grassShake = grass or nil +end + +function World:movePlayer(dir) + local p, map = self.player, self.map + -- .DoStep's choice between STEP_WALK and STEP_BIKE, made fresh for every + -- step: a step already under way keeps the duration it started with, and the + -- downhill exception means the answer can change from cell to cell. + p.stepFrames = Bike.stepFrames( + self.playerState, dir, self:downhill(), Player.STEP_FRAMES) + -- movement.speed, the same name and the same ctx keys src/world/Player.lua + -- offers under Gen 1 (running shoes, dash, a bike that is not the bike). + -- Gen 2 adds `downhill` and `playerState`, because Gold's own answer already + -- depends on both: the Cycling Road forces a step whose duration changes from + -- cell to cell. Per-step hot path, so the ctx is only built when a chain + -- exists. + if Runtime.wantsHook("movement.speed") then + local save = self.game and self.game.save + local frames = Runtime.call("movement.speed", sameFrames, p.stepFrames, { + onBike = FieldMoves.isBiking(self.playerState), + surfing = FieldMoves.isSurfing(self.playerState), + downhill = self:downhill() and true or false, + playerState = self.playerState, + player = p, + input = self.game and self.game.input, + save = save, + }) + p.stepFrames = math.max(1, math.floor(tonumber(frames) or p.stepFrames)) + end + -- GetMovementPermissions: the standing tile's side-wall kind and Gold's + -- neighbour arms veto the direction before any walkable test runs. Both + -- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the + -- veto applies to walking and surfing alike. + local permitted = Permissions.stepPermitted( + function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir) + -- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and + -- Player:tryMove's entity scan only ever compares the one cell an object + -- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed + -- here instead. Same refusal shape as the permission veto above: the press + -- still has to turn the player, so it cannot short-circuit tryMove. + if permitted then + local d = Map.DELTA[dir] + local npc = d and self:npcAt(p.cellX + d[1], p.cellY + d[2]) + if npc and npc.bigObject then permitted = false end + end + local result + if not FieldMoves.isSurfing(self.playerState) then + result = p:tryMove(dir, permitted and map or refusingMap(map), + self.entities) + if result == "blocked" and self:tryLedgeJump(dir) then + result = "moved" + end + else + result = p:tryMove(dir, permitted and self:surfMap(map) + or refusingMap(map), self.entities) + if result == "moved" + and Permissions.surfable(map:cellCollision(p.targetX, p.targetY)) + == "land" then + -- .ExitWater: GetOutOfWater writes PLAYER_NORMAL and runs + -- UpdatePlayerSprite BEFORE .DoStep, so the player is already off the + -- Lapras for the step that puts them on the beach, and PlayMapMusic then + -- swaps the surfing theme back for the map's own. + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + local audio = self.game and self.game.data and self.game.data.audio + if audio and audio.runtime then + Music.playMap(self.game.data, map.id) + end + end + end + -- .DoStep's .FinishFacing latch, and .StandInPlace / ._WalkInPlace clearing + -- it. A successful step OR turn records the direction; a bump (blocked / + -- edge) is what ends an ice slide. + if result == "moved" or result == "turned" then + self.turningDirection = dir + elseif result == "blocked" or result == "edge" then + self.turningDirection = nil + end + -- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has + -- already moved OBJECT_TILE_COLLISION onto the destination. + if result == "moved" then self:playerStepGrass() end + return result +end + +function World:clearWarpCooldownIfLeft() + local cool = self.warpCooldown + if not cool then return end + local p = self.player + if p.cellX ~= cool.x or p.cellY ~= cool.y then + self.warpCooldown = nil + end +end + +function World:warpsSuppressed() + local cool = self.warpCooldown + if not cool then return false end + local p = self.player + return p.cellX == cool.x and p.cellY == cool.y +end + +-- Answers whether it TOOK a warp, and World:step has to end the step on a true. +-- Stepping onto a warp tile is PLAYEREVENT_WARP: the cart writes wScriptRunning +-- and DoPlayerEvent hands the frame to WarpToNewMapScript, so the rest of that +-- frame's overworld loop -- including DoPlayerMovement -- never runs. Falling +-- through to World:movePlayer instead is what let a still-held direction take +-- one free step on the far side of the door, which put the player one cell too +-- far into Elm's Lab before the map's own scene script could start walking them +-- (ElmsLab_WalkUpToElmMovement is nine steps from the mat and ended at (4,1) +-- rather than (4,2)). +function World:checkWarpOnArrive() + local p = self.player + local coll = self.map:cellCollision(p.cellX, p.cellY) + if not Permissions.isWarpCollision(coll) then return false end + local entry = self.map:warpAt(p.cellX, p.cellY) + if not entry then return false end + if Permissions.isImmediateWarp(coll) then + if self:warpsSuppressed() then return false end + return self:takeWarp(entry.def) and true or false + end + local need = Permissions.carpetDirection(coll) + if need and self.heldDir == need then + return self:takeWarp(entry.def) and true or false + end + return false +end + +function World:checkCarpetWhileStanding() + local p = self.player + if p.moving or not self.heldDir then return false end + local coll = self.map:cellCollision(p.cellX, p.cellY) + local need = Permissions.carpetDirection(coll) + if need and self.heldDir == need then + local entry = self.map:warpAt(p.cellX, p.cellY) + if entry then + self:takeWarp(entry.def) + return true + end + end + return false +end + +-- --------------------------------------------------------------------------- +-- Map callbacks (home/map.asm RunMapCallback). +-- --------------------------------------------------------------------------- + +-- RunMapCallback.FindCallback: walk the map's callback list and take the FIRST +-- row whose execution index matches. A second row of the same type is dead on +-- the cart, so it is dead here. +function World:mapCallbackScript(kind) + local def = self.map and self.map.def + for _, cb in ipairs((def and def.callbacks) or {}) do + if cb.callback == kind and cb.scriptKey then return cb.scriptKey end + end + return nil +end + +-- RunMapCallback proper. Every one of these is a script, run through the same +-- interpreter every other script goes through -- see Vm:runCallback for why it +-- is a nested run rather than Vm:start, and why it cannot block. +-- +-- The five types and where the cart runs each: +-- +-- MAPCALLBACK_NEWMAP HandleNewMap, before the block buffer is filled +-- MAPCALLBACK_CMDQUEUE HandleContinueMap, right after ClearCmdQueue +-- MAPCALLBACK_TILES LoadBlockData, after the buffer is refilled from ROM +-- MAPCALLBACK_OBJECTS LoadMapObjects, before the object list goes live +-- MAPCALLBACK_SPRITES LoadUsedSpritesGFX -- no Gold map has one +-- +-- Gold's 84 callbacks are 39 NEWMAP, 24 OBJECTS, 19 TILES and 2 CMDQUEUE, and +-- between them they are the flypoint flags, Mom's worried phone call, the day +-- of the week's travelling NPCs, the Elite Four doors sealing behind you, the +-- Ruins of Alph floors, the Goldenrod underground doors, and the slot that puts +-- Sudowoodo on the map at all. +function World:runMapCallback(kind) + local key = self:mapCallbackScript(kind) + if not (key and self.vm) then return false end + return self.vm:runCallback(key) and true or false +end + +-- --------------------------------------------------------------------------- +-- wCmdQueue (engine/overworld/cmd_queue.asm, home/stone_queue.asm). +-- --------------------------------------------------------------------------- + +-- MAPCALLBACK_CMDQUEUE, and the `writecmdqueue` command that is its whole body +-- on both maps that have one. +-- +-- The extracted callback is preferred: maps.lua now carries the map's callback +-- list and the extractor follows `writecmdqueue`'s operand through the cmdqueue +-- struct into the stonetable, so the rows and their per-boulder scripts come +-- out of the cart. CmdQueue.STONE_TABLES stays as the fallback for a cache +-- built before that, and is what the pins in tests/gen2_world_test.lua compare +-- the extracted rows against. +-- +-- This READS the callback script rather than running it, which is enough +-- because `writecmdqueue` / `endcallback` is the whole body on both maps -- and +-- it has to, because CmdQueue.write wants the entry back as a value while the +-- command's own hook only answers a boolean. World:writeCmdQueue is what the +-- callback would have called; running the body as well would write the queue +-- twice into two of its four slots. +function World:extractedCmdQueue() + local key = self:mapCallbackScript("MAPCALLBACK_CMDQUEUE") + if not key then return nil end + for _, cmd in ipairs(self.scripts[key] or {}) do + if cmd.op == "writecmdqueue" then + local entry = CmdQueue.fromExtracted(cmd.queue, self.map.id) + if entry then return entry end + end + end + return nil +end + +function World:writeCmdQueue() + if not self.map then return false end + local entry = self:extractedCmdQueue() or CmdQueue.mapEntry(self.map.id) + if not entry then return false end + return CmdQueue.write(self.cmdQueue, entry) ~= nil +end + +function World:delCmdQueue(kind) + return CmdQueue.delete(self.cmdQueue, kind) +end + +-- HandleCmdQueue, once a frame from the overworld loop -- so not while a script +-- is up, which is also what stops the queue re-firing on the boulder it is +-- already busy removing. +-- +-- The objects handed over are the port's NPCs, wearing the CART's object ids +-- (`object_const_def` is `const_def 2`, and `disappear` already speaks that +-- numbering), because those are what a stonetable row names. +function World:handleCmdQueue() + if not (self.map and self.vm) then return false end + if self:busy() then return false end + if CmdQueue.count(self.cmdQueue) == 0 then return false end + local objects = {} + for _, npc in ipairs(self.npcs) do + local obj = npc.def + if obj and obj.index then + objects[#objects + 1] = { + id = obj.index + 1, + movement = obj.movement, + cellX = npc.cellX, cellY = npc.cellY, + moving = npc.moving and true or false, + } + end + end + local map = self.map + local row = CmdQueue.poll(self.cmdQueue, { + objects = objects, + warps = (map.def and map.def.warps) or {}, + collisionAt = function(x, y) return map:cellCollision(x, y) end, + }) + if not row then return false end + -- CallMapScript + EnableScriptMode: the row's script runs like any other. + return self.vm:start(row.script) +end + +-- --------------------------------------------------------------------------- +-- The per-step event chain (engine/overworld/events.asm CountStep). +-- +-- src/world/gen2/StepEvents.lua owns the ORDER and the counters; everything +-- here is the presentation the cart's player-event scripts put over it. +-- --------------------------------------------------------------------------- + +local function monName(mon) + if type(mon) ~= "table" then return "?" end + return mon.nickname or mon.name or mon.species or "?" +end + +-- What CountStep's routines need from outside the save. +function World:stepContext() + local def = self.map and self.map.def + return { + data = self.game and self.game.data, + phone = { + map = def, maps = self.maps, daytime = self.daytime, + clock = self.game and self.game.clock, + }, + -- GetMapPhoneService: zero means the map HAS service, which maps.lua has + -- already decoded into a boolean. + phoneService = def and def.phoneService, + playerState = self.playerState, + -- "Don't count steps in link communication rooms." This port has link + -- play but no link overworld room, so the gate can only ever be false -- + -- it is written out so the day one is added nobody has to rediscover it. + linkMode = false, + } +end + +-- CheckTileEvent calls this between the coord events and the wild roll, and a +-- CARRY out of it queues a player event. Answers true when the caller must +-- stop the step: an egg that hatched or a mon that dropped to poison does not +-- also walk into a Rattata. +function World:countStep() + local save = self.game and self.game.save + if not save then return false end + local event = StepEvents.count(save, self:stepContext()) + if not event then return false end + if event.kind == "hatch" then + -- PLAYEREVENT_HATCH is HatchEggScript, and HatchEggScript is one command: + -- `callasm OverworldHatchEgg / end`. Routed through the callasm registry + -- rather than called straight so the routine has exactly one port, whether + -- it is reached from here or from a script that hits its address. + CallAsm.run(self, "OverworldHatchEgg") + elseif event.kind == "poisonFaint" then + self:poisonFaintScript(event) + elseif event.kind == "poisonHurt" then + -- .PlayPoisonSFX alone: the sound and the two-frame red flash, no script. + CallAsm.run(self, "PlayPoisonSFX") + elseif event.kind == "repel" then + self:repelWoreOff() + elseif event.kind == "phoneCall" then + self:receivePhoneCall(event.call) + end + return event.blocks and true or false +end + +-- RepelWoreOffScript (engine/events/repel.asm): opentext, one line, waitbutton. +function World:repelWoreOff() + self:showText(Strings("REPEL's effect\nwore off.")) +end + +-- .Script_MonFaintedToPoison, via .CheckWhitedOut. One line per mon that +-- actually dropped, HAPPINESS_POISONFAINT applied to each, and then +-- CheckPlayerPartyForFitMon decides between closing the box and the whiteout. +-- +-- The order is the cart's: the happiness hit lands BEFORE the text, and the +-- whiteout only after every fainted mon has been named. +function World:poisonFaintScript(event) + local save = self.game and self.game.save + local party = (save and save.party) or {} + local Happiness = require("src.core.gen2.Happiness") + local lines = {} + for _, index in ipairs(event.fainted or {}) do + local mon = party[index] + if mon then + Happiness.change(mon, "HAPPINESS_POISONFAINT") + lines[#lines + 1] = Strings("%s\nfainted!", monName(mon)) + end + end + CallAsm.run(self, "PlayPoisonSFX") + local i = 0 + local function next() + i = i + 1 + if lines[i] then + self:showText(lines[i], next) + return + end + if event.whiteout then self:whiteOut() end + end + next() +end + +-- OverworldWhiteoutScript's tail, for the poison path only: the Bug Contest +-- abort belongs to the script and is not modelled here, so this is +-- HalveMoney (the wallet only -- Mom's savings, if any, are untouched: the +-- cart's own HalveMoney shifts wMoney alone) plus the trip back to the spawn +-- point. +-- +-- Both callasm halves go through the registry: HalveMoney is the 24-bit +-- `srl a / rra / rra` and GetWhiteoutSpawn is the IsSpawnPoint check that +-- falls back to SPAWN_HOME. Only the first has an effect today -- +-- World:warpToSpawn prefers the `blackoutmod` map itself over the SPAWN_* row +-- it matches -- but running the pair keeps the script order honest. +function World:whiteOut() + self:showText( + Strings("You have no more\nPOKéMON that can\011fight!"), function() + CallAsm.run(self, "HalveMoney") + CallAsm.run(self, "GetWhiteoutSpawn") + self:healParty() + -- Guarded because healPoint walks the spawn table to answer: with nobody + -- listening the blackout must not pay for a lookup warpToSpawn is about to + -- make again anyway. + if Runtime.wants("world.blacked_out") then + Runtime.emit("world.blacked_out", + { save = self.game and self.game.save, healTarget = self:healPoint() }) + end + self:warpToSpawn() + end) +end + +-- HatchEggs (engine/pokemon/breeding.asm). One slot at a time, in party order, +-- for every egg whose counter has reached zero: +-- +-- "Huh?" (a `para "@"`, so the box clears and waits) +-- EggHatch_AnimationSequence +-- an empty box (_BreedClearboxText) +-- "<NAME> came<LINE>out of its EGG!" with sound_caught_mon +-- "Give a nickname to<LINE><NAME>?" -> the naming screen, or not +-- +-- These four strings are hand-written from data/text/common_2.asm rather than +-- read out of text.lua, for the same reason the Pokegear's radio lines are: +-- the extractor only follows text a SCRIPT points at, and nothing points at +-- this block. The animation itself is src/ui/gen2/EggHatchAnim.lua. +function World:hatchEggs() + local save = self.game and self.game.save + local data = self.game and self.game.data + if not (save and data) then return end + local Breeding = require("src.core.gen2.Breeding") + local queue = Breeding.readyToHatch(save) + local at = 0 + local function nextEgg() + at = at + 1 + local index = queue[at] + if not index then + -- RestartMapMusic: the standard menu header the hatch ran under is gone. + self:restoreMapMusic() + return + end + self:showText("Huh?", function() + local hatched, effects = Breeding.hatch(data, save, index) + if not hatched then return nextEgg() end + -- Breeding.hatch already ran SetSeenAndCaughtMon; the Togepi flag is the + -- one side effect it hands back rather than setting, because wEventFlags + -- belongs to the world. Events are keyed by NUMBER, the way the + -- extractor emits them, so the constant is resolved here. + if effects and effects.togepi and self.events then + self.events:set(EVENT_TOGEPI_HATCHED, true) + self.peopleDirty = true + end + local name = monName(hatched) + local function announce() + -- `sound_caught_mon` sits inside _BreedEggHatchText, before its + -- text_promptbutton: the jingle plays as the line lands, not after it. + self:playSfxNamed("Sfx_CaughtMon", 2) + self:showText(Strings("%s came\nout of its EGG!", name), function() + -- _BreedAskNicknameText ends `done`, not `prompt`, so YesNoBox opens + -- over the question with no button press in between. That is exactly + -- what askYesNo's instant re-show of lastText does. + self.lastText = Strings("Give a nickname to\n%s?", name) + self:askYesNo(function(yes) + if not yes then return nextEgg() end + self:nameHatchling(hatched, nextEgg) + end) + end) + end + -- EggHatch_AnimationSequence sits between the "Huh?" box and the line + -- above (engine/pokemon/breeding.asm:664). It is a whole screen -- it + -- blanks the map and takes the music -- so it goes on the stack; with no + -- stack to push onto (a headless run) the beat is simply skipped, the + -- way every other Gold cutscene degrades. + local game = self.game + if game and game.stack then + Screens.push(game, "Gen2EggHatchAnim", { + mon = hatched, + species = hatched.species, + menuGfx = data.gen2MenuGfx, + onDone = function() + game.stack:pop() + announce() + end, + }) + else + announce() + end + end) + end + nextEgg() +end + +-- `ld b, NAME_MON / farcall NamingScreen`, with wStringBuffer1 (the species +-- name) already in the header slot. A cancelled screen is the same as "no +-- thanks": InitName copies the species name back over the nickname either way. +function World:nameHatchling(mon, onDone) + local game = self.game + if not (game and game.stack) then return onDone() end + local data = game.data or {} + local icons = data.gen2Icons + local iconId = icons and icons.species and icons.species[mon.species] + local entry = iconId and icons.icons and icons.icons[iconId] + local done = function(name) + game.stack:pop() + -- _InitString's blank test, not a length one: "zero or more spaces + -- followed by a null" (home/string.asm:6-30), and the keyboard's blank + -- cells are real typeable characters, so an all-space entry reaches here + -- and has to fall back to the species name the same way an empty one does. + if name and name:gsub(" ", "") ~= "" then mon.nickname = name end + onDone() + end + Screens.push(game, "Gen2NamingScreen", { + type = "nickname", + monName = mon.name or mon.species, + iconPath = entry and entry.image or nil, + menuGfx = data.gen2MenuGfx, + onDone = done, + onCancel = function() done(nil) end, + }) +end + +-- Script_ReceivePhoneCall's overworld half. The caller's script lives in ROM +-- bank $41, which the extractor reaches by seeding its queue from PhoneContacts +-- and SpecialPhoneCallList -- no map points into that bank, so those two tables +-- are the only way in. The script runs inside the ring chrome +-- (src/core/gen2/PhoneRing.lua: the SFX_CALL page, the Click!, the countdown +-- restart), and wCurCaller is parked on the VM first so GetCallerLocation's +-- two specials (RandomPhoneMon / RandomPhoneWildMon) know who is talking. +-- +-- The drop path below is kept for a cache built before that. Clearing the +-- queue when a call has no body is not tidying-up, it is required: the cart +-- does NOT count a step on which a special call fires, so a call that can never +-- run would freeze wStepCount forever and stop eggs hatching outright -- the +-- exact bug this chain exists to fix. +World.unrunnableCalls = nil + +function World:receivePhoneCall(call) + local key = call and call.scriptKey + if key and self.vm and self.vm.scripts[key] then + local Phone = require("src.core.gen2.Phone") + local name, className = Phone.contactName(call.contact, + self.game and self.game.data and self.game.data.trainers) + self.vm.curPhoneCaller = call.contact + local rows = require("src.core.gen2.PhoneRing").script(call, name, + className) + if self.vm:start(rows) then return true end + end + local save = self.game and self.game.save + if save then require("src.core.gen2.Phone").clearSpecialCall(save) end + self.unrunnableCalls = (self.unrunnableCalls or 0) + 1 + if self.unrunnableCalls == 1 then + print(("[gold] special phone call %s has no script in this cache " .. + "(re-import: bank $41 is reached from PhoneContacts); dropped so the " .. + "step counter keeps running") + :format(tostring(call and call.specialName))) + end + return false +end + +function World:updatePeople() + for _, npc in ipairs(self.npcs) do + npc:update(self.map, self.entities) + end + for _, g in ipairs(self.ghosts) do + g.npc:update(g.map, g.peers) + end +end + +-- Once a second, ask whether the clock rolled into a new time of day; if it +-- did, drop the baked map images so they come back in the new palette. The +-- cart does this from UpdateTimePals on the same cadence. +local PALETTE_POLL_STEPS = 60 + +function World:pollTimeOfDay() + self.paletteClock = (self.paletteClock or 0) + 1 + if self.paletteClock < PALETTE_POLL_STEPS then return end + self.paletteClock = 0 + if self:applyPalettes() then + self.mapImages = {} + self.mapImage = self:imageFor(self.map.id) + self:rebuildNeighbors() + end + -- The hour-window objects (World:objectTimeVisible) key off the raw hour, + -- not just the palette daytime, so their respawn rides the same poll: on the + -- cart a reload is what refreshes wObjectMasks, and this poll is the port's + -- stand-in for the player never being handed a stale mask for long. + local hour = self:hour() + if hour == self.lastMaskHour then return end + -- The first poll only arms the latch; there is nothing to respawn yet. + if self.lastMaskHour == nil then + self.lastMaskHour = hour + return + end + -- A rollover that lands while the world is busy is NOT consumed: the latch + -- stays on the old hour so the next poll tries again. Advancing it here + -- would swallow the only edge this hour has, leaving the map with the + -- previous hour's masks until the next boundary or a map reload. + if self:busy() then return end + self.lastMaskHour = hour + -- The hour half of LoadObjectMasks (GetObjectTimeMask), and only that half: + -- keepScripted leaves every byte a scene wrote alone, so a beast that jumped + -- away or an NPC a script sent home does not walk back in at the top of the + -- hour. + self:loadObjectMasks({ keepScripted = true }) + self:rebuildPeople({ seamless = true }) +end + +-- Both tick once per logic frame AFTER the body -- where +-- src/world/OverworldController.lua:1039 drives the Gen 1 pair. The body has +-- a dozen early returns, so the tail cannot live inside it. +function World:step() + self:stepBody() + if not self.map or not self.player then return end + Follower.update(self.game, self) + Gen1Facade.worldTick(self, 1 / 60) +end + +function World:stepBody() + if not self.map or not self.player then return end + self:pollTimeOfDay() + -- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is + -- parked on the earthquake's own waitFrames while the screen is still + -- rattling), so both tick above the busy() gate rather than below it. + if self.shake then self:updateShake() end + -- The map setup chain ticks above the busy() gate for the same reason: it IS + -- what closes that gate, so nothing below can be allowed to advance it. + if self.mapSetup then + self:updateMapSetup() + return + end + if self.pendingMusic then self:updateMusicFade() end + if self.moveState then self:updateMovement() end + -- Above the VM tick: a `waitbutton` under a `pokepic` is parked on this + -- poll, and its resume has to run inside the same frame the press lands on. + if self.waitButton then self:pollWaitButton() end + if self.vm then self.vm:update() end + -- ExitAllMenus takes the balance box down with everything else the script + -- opened, so a box asked for by a script that then ended without a menu must + -- not be waiting for the NEXT script's menu to inherit it. + if self.scriptBalance and self.vm and not self.vm:running() then + self.scriptBalance = nil + end + -- ExitAllMenus again, for the box a `stay` left standing: the two consumers + -- (World:askYesNo, and the next page of the same MapTextbox in World:showText) + -- both clear the latch before the script ends, so a box still held here is one + -- whose script stopped early -- a `sjump` out of the arm, or an `end` a mod + -- inserted. Without this it would sit there forever with self.textbox set, + -- and World:busy() would never let the player move again. + if self.stayedTextBox and self.vm and not self.vm:running() then + local held = self.stayedTextBox + self.stayedTextBox = nil + self.textbox = nil + if self.game and self.game.stack and self.game.stack:top() == held then + self.game.stack:pop() + end + end + -- The rod cast and the tree shake tick alongside the VM rather than under + -- the busy() gate below, because that gate is what they themselves close. + if self.fishing then self:updateFishing() end + if self.headbutt then self:updateHeadbutt() end + if self.fieldMove then self:updateFieldMove() end + -- QueueScript's own drain: a field move chosen from the party menu runs the + -- first frame the overworld is back on top. + if self.queuedFieldMove then self:runQueuedFieldMove() end + -- The same drain for the ITEMFINDER's queued script. + if self.queuedScript then self:runQueuedScript() end + self:pollCaveFlicker() + self:pollTileAnim() + -- Any object visibility a running script changed lands here, once the + -- script is over: RefreshMapSprites' timing, not the flag write's. + if self.peopleDirty and not self:scriptRunning() then + self.peopleDirty = nil + self:rebuildPeople({ seamless = true }) + end + -- UnfreezeAllObjects (engine/overworld/map_objects.asm), which EndScript + -- runs: the talked-to object and every scripted stepper get their movement + -- functions back the frame the whole interaction -- script, text boxes, + -- pending movement -- has settled. + if self.frozeNpcs and not self:busy() then + self.frozeNpcs = nil + -- The pool is the superset of the live lists, but a rebuild between the + -- freeze and here can leave a frozen NPC only in self.npcs, so both walk. + for _, npc in pairs(self.npcPool or {}) do npc.frozen = false end + for _, npc in ipairs(self.npcs or {}) do npc.frozen = false end + end + -- WarpCheck's find, for the same reason and on the same clock: a script that + -- ends standing on a warp tile takes it once the script is over, never + -- inside the command that noticed it. + if self.pendingWarp and not self:scriptRunning() then + if self:takePendingWarp() then return end + end + if self.emote then + self.emote.left = self.emote.left - 1 + if self.emote.left <= 0 then self.emote = nil end + end + -- The heal machine runs while the script is parked on its specialwait, so + -- it ticks here above the input gate the same way the emote does; its + -- last flash's onDone is what resumes the nurse. + if self.healAnim then self:stepHealAnim() end + + -- HandleCmdQueue sits in the overworld loop, once a frame, above the input + -- gate: it is what drops a boulder that is already sitting on a hole, and it + -- has to see the frame the push finishes on. + if self:handleCmdQueue() then return end + + -- Fire map-enter scene script once the warp settles. + if self.pendingSceneScript and not self:busy() then + self.pendingSceneScript = false + if self:trySceneScript() then return end + end + + -- CheckTimeEvents, from the player-event chain: the Bug Contest clock is the + -- only thing it polls while the contest is up, and its carry is a script, so + -- it goes above the input gate and below the one that says a script is + -- already running. + if not self:busy() and self:checkTimeEvents() then return end + + -- Freeze player input while a script / textbox / cutscene move is up. + if self:busy() then + -- Keep scripted entities animating mid-step. + if self.player and self.player.moving then + self:playerStepGrass() + if self.player:update() then + self.player.inGrass = + self:grassAt(self.player.cellX, self.player.cellY) + end + end + self:updatePeople() + return + end + + local p = self.player + self:playerStepGrass() + local landed = p:update() + -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step + -- ENDS on (engine/overworld/map_objects.asm:196-208, :247). + if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end + -- CheckTrainerEvent is PlayerEvents' FIRST test (engine/overworld/events.asm: + -- 245) and, unlike every arm of CheckTileEvent, it is not behind + -- wEnabledPlayerEvents: MapEvents clears that byte on every pass + -- (events.asm:168) and CheckPlayerState only re-sets it on a step that + -- actually landed (events.asm:210-221). So the sight cone is sampled EVERY + -- overworld frame -- a spinner that rotates onto a standing player engages, + -- and a sighting that arrived while a script held the world fires the frame + -- the script ends. It stays after p:update(), which is what commits + -- cellX/cellY, and above the `landed` block so the cart's CheckTrainerEvent + -- before CheckTileEvent (events.asm:249) ordering survives: a trainer whose + -- line crosses a warp or a coord-event tile wins, as on hardware. + if self:checkTrainerBattle() then return end + if landed then + -- hot path: the payload is only built when something is listening, exactly + -- as OverworldState:onStepComplete guards it under Gen 1. `tile` is the + -- COLLISION byte here -- Gold's map has no per-cell tile id, and the + -- collision byte is the value every one of the engine's own step tests + -- reads -- and `daytime` is the palette set beside Gen 1's `tod`. + if Runtime.wants("world.stepped") then + Runtime.emit("world.stepped", { + mapId = self.map.id, x = p.cellX, y = p.cellY, + tile = self.map:cellCollision(p.cellX, p.cellY), + tod = self.tod, daytime = self.daytime, + }) + end + self:clearWarpCooldownIfLeft() + if self:checkWarpOnArrive() then return end + if not self.map then return end + if self:tryCoordScript() then return end + -- CheckTileEvent's own order: the coord events, then CountStep, then + -- RandomEncounter. A carry out of CountStep queues a player event, so the + -- step that hatches an egg or drops a poisoned mon never also walks into a + -- wild battle. + if self:countStep() then return end + -- Grass rolls after the warp and coord checks, so stepping onto a door + -- inside grass still warps rather than starting a battle. + if self:tryWildEncounter() then return end + end + + -- People keep their anim paths even while the player is idle / mid-step. + self:updatePeople() + + -- .CheckForced / CheckStandingOnIce: while the tile underfoot is ice and a + -- prior step latched .FinishFacing, THIS frame's d-pad is forced to that + -- direction so one press slides until a non-ice landing or a bump. The + -- override is local -- writing it into heldDir would survive onto the floor + -- after the slide (pollInput is what refreshes heldDir from real input) and + -- keep the player walking. StandInPlace clears the latch when idle off ice. + -- + -- .CheckTile runs ABOVE both of those, and its HI_NYBBLE_CURRENT arm is + -- stronger than either: a $3x tile underfoot picks the direction outright, + -- with no d-pad and no latch involved. On COLL_WATERFALL $33 that is one + -- DOWN per frame, which is both the automatic plunge and the reason a + -- waterfall column cannot be climbed by walking into it -- HM07's own climb + -- is a scripted step under World:busy, which returns above this line. + local dir = self.heldDir + if not p.moving then + local current = Permissions.currentDirection(self:playerCollision()) + if current then + dir = current + elseif self.turningDirection + and Permissions.isIce(self:playerCollision()) then + dir = self.turningDirection + elseif not dir then + self.turningDirection = nil + end + end + if not dir then + p.turnArmed = true + return + end + if p.moving then return end + + if self:checkCarpetWhileStanding() then return end + + local result = self:movePlayer(dir) + if result == "edge" then + self:tryConnection(dir) + elseif result == "blocked" and p.facing == dir then + -- .CheckNPC came back 2: something movable is in the way. The step is + -- lost either way, and the boulder is what moves. + local d = Map.DELTA[dir] + self:tryPushBoulder(dir, p.cellX + d[1], p.cellY + d[2]) + end +end + +function World:pollInput(input) + -- DoPlayerMovement .GetDPad: a DOWNHILL map with no direction held reads as + -- DOWN, which is the Cycling Road rolling the player along on its own. + self.heldDir = Bike.forcedDirection(heldDirection(input), self:downhill()) +end + +function World:zoomStep(delta) + Zoom.step(delta, self:fitScale()) + self:rebuildNeighbors() + self:rebuildPeople({ seamless = true }) +end + +function World:zoomCycle() + Zoom.cycle(self:fitScale()) + self:rebuildNeighbors() + self:rebuildPeople({ seamless = true }) +end + +-- The world pass, split in two because TILT projects only one of them: the +-- ground (the neighbor strips and this map) goes onto the perspective plane, +-- while everything standing on it draws upright. +function World:drawGround(s) + local G = love.graphics + local cam = self.camera + G.setColor(1, 1, 1, 1) + -- The border block first, tiled over the whole view: the map canvas is + -- exactly the map's own blocks, so on anything smaller than the viewport + -- (GOLDENROD_DEPT_STORE_ELEVATOR is 2x2) the rest of the screen was the + -- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and + -- the connection strips and the map draw straight over the top of it. + if self.map then + local bw, bh = G.getDimensions() + BorderFill.draw(self, self:borderImageFor(self.map.id), + cam.x, cam.y, bw, bh, s, self.map.id) + end + for _, nb in ipairs(self.neighbors) do + G.draw(nb.image, + math.floor((nb.ox - cam.x) * s), + math.floor((nb.oy - cam.y) * s), + 0, s, s) + self:drawAnimCells(nb.id, nb.ox, nb.oy, s) + end + G.draw(self.mapImage, + math.floor((0 - cam.x) * s), + math.floor((0 - cam.y) * s), + 0, s, s) + -- _AnimateTileset's VRAM writes, as an overlay: the map canvas is baked + -- once and the four water / two flower frames go over the top of it + -- (engine/tilesets/tileset_anims.asm:167, :197). + self:drawAnimCells(self.map and self.map.id, 0, 0, s) +end + +-- Everyone on the map, Y-sorted with the player, plus the emote bubble over +-- the top -- it is an OBJ at OAM priority on the cart, and nothing walks in +-- front of it in the half-second it is up. +-- +-- `billboard` is nil on the flat path. With TILT on it is a function that +-- takes a foot point in flat screen pixels and a draw callback, and slides the +-- draw onto that point's projection: only the ground tilts, so a standing +-- thing stays upright and unscaled and the one thing that moves is its anchor. +function World:drawPeople(s, billboard) + local G = love.graphics + local p = self.player + local cam = self.camera + local drawList = { + { kind = "player", py = p.py, ox = 0, oy = 0 }, + } + for _, npc in ipairs(self.npcs) do + drawList[#drawList + 1] = { + kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, + } + end + for _, g in ipairs(self.ghosts) do + drawList[#drawList + 1] = { + kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, + } + end + table.sort(drawList, function(a, b) return a.py < b.py end) + + for _, entry in ipairs(drawList) do + local ox = math.floor((entry.ox - cam.x) * s) + local oy = math.floor((entry.oy - cam.y) * s) + local entity = entry.kind == "player" and p or entry.npc + local function body() + if entry.kind == "player" then + self.player:draw(ox, oy, s) + else + entry.npc:draw(ox, oy, s) + end + -- ShakeGrass rustle only. The cart also ORs OAM_PRIO onto the lower + -- 16x8 (drawGrassOver / IN_GRASS) so the BG tuft covers the feet, but + -- stacking that plain grass tile on top of the character with the + -- rustle reads as a double overlay here -- keep the walk-through anim. + -- Only the current map's own entities: a ghost's cells belong to a + -- neighbour's block list. + if entry.ox == 0 and entry.oy == 0 then + self:drawGrassShake(entity, ox, oy, s) + end + end + if billboard then + -- The foot is the baseline centre of the sprite's own cell. + billboard(ox + (entity.px + 8) * s, oy + (entity.py + 16) * s, body) + else + body() + end + end + + if self.emote and self.emote.image then + local e = self.emote + local ex = math.floor((e.entity.px - cam.x) * s) + local ey = math.floor((e.entity.py - 16 - cam.y) * s) + -- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the + -- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the + -- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB + -- 13,13,13 / black). That row is byte-identical in all four daytime + -- blocks, so the bubble is the same at any hour, but it still goes through + -- the daytime lookup because that is what LoadMapPals does and it keeps the + -- emote on the same path as every other OW sprite. Blitting the extracted + -- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead + -- of white: the Gen 2 repeat of #505. + local emoteColors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 5 }) + local function blit() + G.setColor(1, 1, 1, 1) + G.draw(e.image, ex, ey, 0, s, s) + end + local function body() + -- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still + -- have to collapse the row to their own ramps, and it restores whatever + -- shader the billboard pass had set rather than assuming none. + if emoteColors and GbcPalette.available() then + GbcPalette.with(emoteColors, blit) + else + blit() + end + end + if billboard then + billboard(ex + 8 * s, ey + 32 * s, body) + else + body() + end + end + + self:drawHealAnim(s, billboard) +end + +function World:drawWorldBody(s) + self:drawGround(s) + self:drawPeople(s) +end + +-- The perspective quad TILT draws the ground onto. The shader and the +-- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1 +-- world pass uses, so there is no reason for a second copy of either. +function World:tiltMesh() + local Renderer = require("src.render.Renderer") + local shader = Renderer.tiltShader and Renderer:tiltShader() + local mesh = Renderer.tiltMesh and Renderer:tiltMesh() + if not (shader and mesh) then return nil end + return mesh, shader +end + +function World:drawTilted(w, h, s) + local mesh, shader = self:tiltMesh() + if not mesh then + self:drawWorldBody(s) + return + end + local G = love.graphics + -- Linear sampling on the tilt canvas softens the shimmer the perspective + -- warp would otherwise put on every pixel edge; the flat path keeps nearest. + if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= w + or self.tiltCanvas:getHeight() ~= h then + if self.tiltCanvas and self.tiltCanvas.release then + self.tiltCanvas:release() + end + self.tiltCanvas = G.newCanvas(w, h) + self.tiltCanvas:setFilter("linear", "linear") + end + + local previous = G.getCanvas() + G.setCanvas(self.tiltCanvas) + G.clear(0, 0, 0, 0) + -- A canvas does not reset the transform, so anything drawn into one from + -- inside a draw call needs push()/origin() around it. + G.push() + G.origin() + self:drawGround(s) + G.pop() + G.setCanvas(previous) + + mesh:setTexture(self.tiltCanvas) + mesh:setVertices(Tilt.meshCorners(w, h)) + G.setColor(1, 1, 1, 1) + G.setShader(shader) + G.draw(mesh) + G.setShader() + + -- ...and the standing things over it, each translated from its flat foot + -- onto that foot's projection. Nothing here is sheared or resized: tilt + -- changes where a sprite stands, not what it looks like. + self:drawPeople(s, function(fx, fy, body) + -- The ground quad carries the flat canvas and nothing else, so a foot + -- outside it has no ground under it; drawing it anyway put NPCs from two + -- screens away over the border fill, where the map stops being drawn. + if not Tilt.onGround(fx, fy, w, h, 32 * s) then return end + local sx, sy = Tilt.groundPoint(fx, fy, w, h) + G.push() + G.translate(sx - fx, sy - fy) + body() + G.pop() + end) +end + +-- The COLOR option can change under a standing world (the hotkey, or the +-- OPTION screen closing), and the map is a baked canvas rather than a live +-- draw -- so the cached references have to be re-fetched when it does. The +-- bakes themselves are keyed by mode in imageFor, so this is a pointer swap +-- after the first time each mode is seen, not a re-bake. +function World:refreshColorMode() + local mode = GbcPalette.mode + if self.colorMode == mode then return end + self.colorMode = mode + if not self.map then return end + self.mapImage = self:imageFor(self.map.id) + self:rebuildNeighbors() +end + +function World:draw() + local G = love.graphics + local w, h = G.getDimensions() + self:refreshColorMode() + G.clear(0.07, 0.05, 0.02, 1) + + if not self.mapImage or not self.player then + G.setColor(0.85, 0.57, 0.13, 1) + G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center") + G.setColor(0.92, 0.90, 0.82, 1) + G.printf(self.status or "No map.", + 0, math.floor(h * 0.48), w, "center") + G.printf("Press Escape to quit.", 0, math.floor(h * 0.62), w, "center") + G.setColor(1, 1, 1, 1) + return + end + + local s = self:zoomScale() + local vw = math.ceil(w / s) + local vh = math.ceil(h / s) + if vw % 2 ~= 0 then vw = vw + 1 end + if vh % 2 ~= 0 then vh = vh + 1 end + if vw ~= self.viewW or vh ~= self.viewH then + self:rebuildNeighbors() + self:rebuildPeople({ seamless = true }) + end + self.viewW, self.viewH = vw, vh + + local p = self.player + self.camera:follow(p.px, p.py, vw, vh) + -- StepFunction_ScreenShake adds its offset to wPlayerStepVectorY, i.e. the + -- whole frame slides vertically while the ground stays put underneath. + if self.shake then + self.camera.y = self.camera.y + (self.shake.phase or 0) + end + + -- TILT projects the finished world frame, so with it on the map, people and + -- emote go into a canvas first and that canvas is drawn as a perspective + -- quad. Everything after -- the encounter pic and the survey HUD -- stays + -- flat, the same split the Gen 1 renderer makes. + if Tilt.active() and self:tiltMesh() then + self:drawTilted(w, h, s) + else + self:drawWorldBody(s) + end + + -- Pokepic (engine/events/pokepic.asm:1-28): MenuBox at the header's coords, + -- then the padded 7x7 frontpic at top+1, left+1. UI, so fitScale not zoom. + if self.pokePic then + local sPic = self:fitScale() + local pw = self.pokePic:getDimensions() + local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7] + G.push() + G.translate(math.floor((w - 160 * sPic) / 2), + math.floor((h - 144 * sPic) / 2)) + G.scale(sPic, sPic) + G.setColor(1, 1, 1, 1) + local function body() + Font.drawBox(POKEPIC.left, POKEPIC.top, POKEPIC.w, POKEPIC.h) + G.draw(self.pokePic, (POKEPIC.left + 1 + pad[1]) * 8, + (POKEPIC.top + 1 + pad[2]) * 8) + end + if self.pokePicColors then + GbcPalette.with(self.pokePicColors, body) + else + body() + end + G.pop() + G.setColor(1, 1, 1, 1) + end + + -- FadeOutToWhite / FadeOutToBlack, held until a FadeInFrom* clears it. On + -- the cart the pair brackets a scripted cutscene's set change (the Elite Four + -- doors, the Radio Tower takeover, Lugia's chamber); the port has no + -- palette-cycle fade, so the honest stand-in is the flat sheet the cart's own + -- fade ends on, over the world and under the text box the script is running. + if self.fade then + -- fadeLevel is the map setup chain's four-step ramp; a fade special sets it + -- to 1 because RotateThreePalettes* has already finished by the time the + -- script that called it runs on. + local a = self.fadeLevel or 1 + if self.fade == "white" then + G.setColor(1, 1, 1, a) + else + G.setColor(0, 0, 0, a) + end + G.rectangle("fill", 0, 0, w, h) + G.setColor(1, 1, 1, 1) + end + + -- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV + -- (or the F3 toggle) shows it, a normal boot does not. + if self.showDebugHud then + G.setColor(0.85, 0.57, 0.13, 1) + G.printf("POKEMON GOLD", 0, 10, w, "center") + G.setColor(0.92, 0.90, 0.82, 1) + local label = string.format("%s (%d,%d) %s · %s · zoom %s", + self.map.id, p.cellX, p.cellY, p.facing, + tostring(self.daytime), Zoom.offsetLabel(Zoom.offset)) + G.printf(label, 0, 28, w, "center") + G.printf("Arrows move · wheel/-/= zoom · 4 cycle zoom · Escape quits", + 0, h - 28, w, "center") + G.setColor(1, 1, 1, 1) + end +end + +-- exported for the Gen 1 FieldDefaults facade (src/mods/Gen2Compat.lua) +-- rather than duplicated there +World.PLAYER_SPRITE = PLAYER_SPRITE + +return World diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua new file mode 100644 index 00000000..efcf28d2 --- /dev/null +++ b/src/world/gen2/WorldAPI.lua @@ -0,0 +1,329 @@ +-- mod.world for Gen 2 (Gold): the same facade src/world/WorldAPI.lua gives a +-- mod under Gen 1, resolved against src/world/gen2/World.lua instead of the +-- Gen 1 overworld state. One name, one method set, two arms -- a mod that +-- declares gen2compat calls mod.world:current() and does not care which game +-- it is running on. +-- +-- Two structural differences show through, and both are reported rather than +-- faked: +-- +-- * Gold's World is not a stack state. It hangs off the service owner as +-- game.world for the whole run, so there is no stack scan here; a menu or +-- a battle pushed over the world does not hide it. +-- +-- * Gen 2 event flags are NUMBERS (wEventFlags is a bitfield, and the cart's +-- EVENT_* constants are indices into it), where Gen 1 flags are string +-- keys in save.flags. setFlag/getFlag therefore take a numeric id here +-- and say so when handed a string, rather than silently writing a key the +-- bitfield cannot hold. +-- +-- Anything Gen 2 has no equivalent for at all returns nil plus a reason, the +-- same shape the Gen 1 arm uses for "no overworld". A dual-generation mod +-- checks the second return and degrades; it never crashes and never gets a +-- silent no-op. + +local Logger = require("src.core.Logger") +local Movement = require("src.script.gen2.Movement") +local Runtime = require("src.mods.Runtime") + +local WorldAPI = {} +WorldAPI.__index = WorldAPI + +local NO_OVERWORLD = "no overworld" + +function WorldAPI.new(game, modId) + return setmetatable({ game = game, modId = modId }, WorldAPI) +end + +-- the live World, or nil while the boot cinema is still up +function WorldAPI:overworld() + local world = self.game and self.game.world + if world and world.map then return world end + return nil +end + +function WorldAPI:current() + local world = self:overworld() + if not world or not world.map then return nil, NO_OVERWORLD end + local p = world.player + return { mapId = world.map.id, x = p and p.cellX, y = p and p.cellY, + facing = p and p.facing } +end + +-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX +-- come from the map setup method, so opts.arrive has nothing to select yet. +function WorldAPI:warpTo(mapId, x, y, facing, opts) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if not (world.maps and world.maps[mapId]) then + return nil, "unknown map: " .. tostring(mapId) + end + if not (x and y) then return nil, "warpTo needs x and y" end + local ok = world:warpToMapId(mapId, x, y, + facing or (world.player and world.player.facing) or "down") + if not ok then return nil, world.status or "warp failed" end + return true +end + +-- Gen 2 has no save.objectToggles: an object's visibility IS its +-- MAPOBJECT_EVENT_FLAG, which lives in the event bitfield and is therefore +-- already persistent and already re-read by the next LoadObjectMasks. Setting +-- the flag is the whole operation; appear/disappear additionally take it off +-- the live map when the map is the active one. +-- +-- objRef is the object's 1-based index in the map's object list, or its name +-- when the extracted map carries one. +function WorldAPI:toggleObject(mapId, objRef, visible) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if not (world.map and world.map.id == mapId) then + -- the flag is per object, and the object list only resolves for a loaded + -- map, so an off-map toggle has nothing to name + return nil, "map is not active: " .. tostring(mapId) + end + local def = world.map.def + local objects = def and def.objects + if not objects then return nil, "map has no objects" end + local index + for i, obj in ipairs(objects) do + if i == objRef or obj.name == objRef then + -- def.objects is keyed by the object's own index, and World:objectEntity + -- reads it back as objectId - 1, so the id is that key plus one + index = obj.index or i + break + end + end + if not index then + return nil, "no such object: " .. tostring(objRef) + end + local objectId = index + 1 + if visible then world:appearObject(objectId) else world:disappearObject(objectId) end + Runtime.emit("world.object_toggled", + { mapId = mapId, objName = objRef, visible = visible and true or false }) + return true +end + +-- id is a numeric EVENT_* index into wEventFlags. A string is the Gen 1 +-- habit and cannot work here, so it is refused with the reason rather than +-- stored somewhere the engine never looks. +local function flagId(id) + if type(id) == "number" then return id end + return nil, ("Gen 2 event flags are numeric ids, got %s (%s)") + :format(type(id), tostring(id)) +end + +function WorldAPI:setFlag(id, value) + local world = self:overworld() + if not world or not world.events then return nil, NO_OVERWORLD end + local numeric, err = flagId(id) + if not numeric then return nil, err end + world.events:set(numeric, value and true or false) + return true +end + +function WorldAPI:getFlag(id) + local world = self:overworld() + if not world or not world.events then return nil, NO_OVERWORLD end + local numeric, err = flagId(id) + if not numeric then return nil, err end + return world.events:get(numeric) +end + +-- active map only, same contract as the Gen 1 arm: this mutates the loaded +-- block data and rebuilds the view. `block` is a block id. +function WorldAPI:replaceBlock(bx, by, block) + local world = self:overworld() + if not world or not world.map then return nil, NO_OVERWORLD end + world:changeBlock(bx, by, block) + return true +end + +local UNSUPPORTED = "not supported in Gen 2 yet" + +-- objDef uses the same shape as an extracted map's objects list (sprite, x, y, +-- movement, hours, ...), which is the Gen 1 arm's contract too. Runtime +-- objects are not serialized; a mod respawns them on map.entered. +function WorldAPI:spawnNpc(mapId, objDef) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if type(objDef) ~= "table" then return nil, "objDef must be a table" end + local copy = {} + for k, v in pairs(objDef) do copy[k] = v end + return world:addRuntimeObject(mapId, copy, self.modId) +end + +function WorldAPI:removeNpc(npcId) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + return world:removeRuntimeObject(npcId, self.modId) +end + +-- a handle onto a live NPC. Scripted movement does carry over: it compiles to +-- the cart's own movement stream and rides World:beginMovement, the same path +-- an `applymovement` in a map script takes -- so a mod's walk is frozen, +-- stepped and released exactly like a scripted one. Only spawning does not. +local Handle = {} +Handle.__index = Handle + +-- One movement stream at a time is the engine's own limit (World.moveState is +-- a single slot), so a second call while one is running is refused rather than +-- silently replacing the first and stranding its onDone. +function Handle:scriptMove(dir, tiles, onDone) + local world = self.world + if world.moveState then return nil, "a movement is already running" end + local step = Movement.stepByte(dir) + if not step then return nil, "unknown direction: " .. tostring(dir) end + local bytes = {} + for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end + bytes[#bytes + 1] = Movement.STEP_END + world:beginMovement(self.objectId, bytes, onDone) + return true +end + +-- Gen 1's marchInPlace is step_sleep-with-animation; the Gen 2 stream has no +-- single byte for it, and faking one out of turn bytes would march the wrong +-- way. Left explicit rather than approximated. +function Handle:marchInPlace() + return nil, UNSUPPORTED +end + +function Handle:face(dir) + self.npc:scriptFace(dir) + return true +end + +function Handle:position() + return self.npc.cellX, self.npc.cellY +end + +function WorldAPI:npc(mapId, indexOrName) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if world.map and world.map.id ~= mapId then return nil, "map is not active" end + for _, npc in ipairs(world.npcs or {}) do + local def = npc.def + if def and (def.index == indexOrName or def.name == indexOrName) then + return setmetatable( + { world = world, npc = npc, objectId = (def.index or 0) + 1 }, Handle) + end + end + return nil, "no such object: " .. tostring(indexOrName) +end + +-- The Gen 2 VM runs the cart's own bytecode out of data/generated/scripts.lua, +-- not the Gen 1 runner's `{ "command", ... }` rows, so there is no row list to +-- hand it. What a mod actually reaches for out of that vocabulary is a small +-- set of verbs that Gold has its own entry points for, so those are driven +-- directly here, one row at a time, and anything else is refused BY NAME +-- before the first row runs -- a half-run queue is the failure mode this +-- facade exists to avoid. The full list is src/script/Commands.lua; these +-- five are the ones with a Gen 2 home. +local VERBS = {} + +-- start_battle "wild" species level. Gold's own grass step ends in +-- World:startBattle with a Mon (src/world/gen2/World.lua:4021), so this is +-- that call with the mon built from the mod's species and level. The trainer +-- arm needs a party out of the extracted trainer table and an OPP_CLASS the +-- mod cannot name, so only the wild arm is served. +function VERBS.start_battle(api, row, resume) + local world = api:overworld() + local kind = row[2] + if kind ~= "wild" then + return nil, "only start_battle \"wild\" is supported in Gen 2" + end + local game = world.game + local mon = require("src.battle.gen2.Mon").new( + game and game.data, row[3], tonumber(row[4]) or 5) + if not mon then return nil, "unknown species: " .. tostring(row[3]) end + local save = game and game.save + if save then + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen[mon.species] = true + end + world:startBattle({ wild = mon }, function() resume() end) + return true +end + +function VERBS.warp(api, row, resume) + local ok, err = api:warpTo(row[2], row[3], row[4], row[5]) + if not ok then return nil, err end + resume() + return true +end + +function VERBS.text(api, row, resume) + local world = api:overworld() + world:showText(tostring(row[2] or ""), function() resume() end) + return true +end + +function VERBS.setflag(api, row, resume) + local ok, err = api:setFlag(row[2], true) + if not ok then return nil, err end + resume() + return true +end + +function VERBS.clearflag(api, row, resume) + local ok, err = api:setFlag(row[2], false) + if not ok then return nil, err end + resume() + return true +end + +-- Rows run in order, each one resuming the next from its own completion +-- callback, so a battle or a text box blocks the queue the way it blocks the +-- Gen 1 runner's coroutine. One queue at a time, for the reason +-- Handle:scriptMove refuses a second movement. +function WorldAPI:queueScript(rows, extra) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if type(rows) ~= "table" then return nil, "queueScript wants a row list" end + if self.queue then return nil, "a script is already running" end + for i, row in ipairs(rows) do + local name = type(row) == "table" and row[1] + if not VERBS[name] then + return nil, ("unsupported script command in Gen 2: %s (row %d)") + :format(tostring(name), i) + end + end + self.queue = true + local pc = 0 + local step + local function finish(err) + self.queue = nil + if err then + Logger.warn("[%s] queueScript stopped: %s", tostring(self.modId), err) + end + if extra and extra.onDone then extra.onDone(err == nil) end + end + step = function() + pc = pc + 1 + local row = rows[pc] + if not row then return finish(nil) end + local ok, err = VERBS[row[1]](self, row, function() step() end) + if not ok then finish(err or "row failed") end + end + step() + return true +end + +-- Gold's maps come from one table loaded at World:load, so there is no +-- per-map instance cache to drop. Reloading the active map is the part that +-- carries meaning, and reloadMapBadWarp is exactly the cart's own +-- "load this map again where you stand" (MAPSETUP_BADWARP). +function WorldAPI:invalidateMap(mapId) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if not (world.map and world.map.id == mapId) then return true end + local ok, err = pcall(world.reloadMapBadWarp, world) + if not ok then + Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId), + tostring(mapId), tostring(err)) + return nil, tostring(err) + end + Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" }) + return true +end + +return WorldAPI diff --git a/tests/drivers/gold/adapter.lua b/tests/drivers/gold/adapter.lua new file mode 100644 index 00000000..7b43888e --- /dev/null +++ b/tests/drivers/gold/adapter.lua @@ -0,0 +1,1024 @@ +-- Gen 2 adapter for the route bot. +-- +-- Everything the bot core knows about "the running game" goes through this +-- table, so the core (tests/drivers/gold/bot.lua) is pathfinding and policy +-- with no idea which generation it is driving. The Gen 1 bot +-- (tests/drivers/route.lua) reaches into `G.overworld`, src/world/Warp and +-- src/world/Collision directly and is 8k lines because of it; keeping the seam +-- here is what lets a Gen 1 adapter drop in later without a second rewrite of +-- the BFS, the wall memory and the watchdog. +-- +-- The contract, in one sentence per method, is at the bottom of this file. + +local BattleState = require("src.ui.gen2.BattleState") +local ElevatorMenu = require("src.ui.gen2.ElevatorMenu") +local EvolutionAnim = require("src.ui.gen2.EvolutionAnim") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") + +local FLAGS = dofile("tests/drivers/gold/flag_names.lua") + +local A = {} + +A.name = "gen2" + +-- --------------------------------------------------------------------------- +-- state +-- --------------------------------------------------------------------------- + +function A.world(g) return g.world end + +function A.ready(g) + return g.world ~= nil and g.world.map ~= nil and g.phase == "play" +end + +function A.mapId(g) + local w = g.world + return w and w.map and w.map.id or nil +end + +function A.map(g) + local w = g.world + return w and w.map or nil +end + +function A.pos(g) + local p = g.world and g.world.player + if not p then return nil, nil end + return p.cellX, p.cellY +end + +function A.facing(g) + local p = g.world and g.world.player + return p and p.facing or "down" +end + +function A.moving(g) + local p = g.world and g.world.player + return p and p.moving or false +end + +-- "Anything that makes the overworld ignore normal player input." Two +-- separate sources and both matter: World:busy() covers scripts, cutscene +-- movement and the map-setup chain, while a non-nil stack top covers every +-- screen the world pushed (text boxes, choice boxes, menus, the battle). +-- Game2's fixed step returns early on a stack top BEFORE it ever reaches +-- world:step, so a bot that only asked World:busy() would hold a direction +-- into a text box and wonder why it never moved. +function A.busy(g) + if g.stack and g.stack:top() ~= nil then return true end + local w = g.world + return w and w:busy() or false +end + +function A.top(g) + return g.stack and g.stack:top() or nil +end + +function A.topIs(g, class) + local t = A.top(g) + return t ~= nil and getmetatable(t) == class +end + +function A.inBattle(g) + return A.topIs(g, BattleState) +end + +A.BattleState = BattleState + +-- "intro" | "menu" | "moves" | "resolving" | "forced-switch" | "submenu" | +-- "evolving" | "done" (src/ui/gen2/BattleState.lua:94). The bot needs it to +-- tell a battle that is waiting for a decision from one that is waiting for a +-- button. +function A.battlePhase(g) + local st = A.top(g) + return (st and A.inBattle(g)) and st.phase or nil +end + +-- The battle menu is a 2x2 grid filled row-major -- FIGHT / PkMn over PACK / +-- RUN -- so RUN is index 4 (BattleState's MENU table). Set the index rather +-- than steer the cursor with d-pad presses: the same technique +-- tests/drivers/gold_boot_smoke.lua uses on the intro menu, and it cannot +-- desync from a cursor that wrapped somewhere unexpected. +A.BATTLE_MENU_FIGHT = 1 +A.BATTLE_MENU_POKEMON = 2 +A.BATTLE_MENU_PACK = 3 +A.BATTLE_MENU_RUN = 4 + +-- The catch tutorial is a battle with NO player mon at all +-- (src/ui/gen2/BattleState.lua:1067), so its move list is empty and FIGHT is a +-- dead end by design: the menu enters the "moves" phase, finds nothing to +-- submit, and sits there. Its intended path is PACK, whose tutorial arm +-- throws the DUDE's ball and ends the demo. +function A.battleIsTutorial(g) + local st = A.top(g) + return (st and A.inBattle(g) and st.tutorial) and true or false +end + +-- The line currently in the battle message box (BattleState.message). The bot +-- logs it whenever it changes, because "the battle never ended" on its own says +-- nothing about WHY -- whereas the actual text ("CATERPIE has no moves left!") +-- names the engine event that is failing to advance. +function A.battleMessage(g) + local st = A.top(g) + return (st and A.inBattle(g)) and st.message or nil +end + +function A.playerMoveCount(g) + local st = A.top(g) + if not (st and A.inBattle(g)) then return 0 end + local battle = st.battle + return #((battle and battle.player and battle.player.moves) or {}) +end + +-- Choose this turn's move and park the cursor on it. +-- +-- Always taking slot 1 is what wedged the long runs. Two ways it fails, and +-- both are unrecoverable because the port implements no STRUGGLE: a slot-1 +-- move at 0 PP just emits "No PP left for this move!" and returns +-- (src/battle/gen2/Battle.lua:739), so the turn never resolves and the menu +-- comes straight back; and a slot-1 move the target is immune to (TACKLE into +-- a GASTLY) means neither side can ever land damage. +-- +-- So: only ever select a move that HAS PP, and among those prefer the one that +-- hits hardest, which also gets us off a status move when a damaging one is +-- available. Returns the chosen index, or nil when every move is dry -- the +-- caller's cue to escape rather than press A at a turn that cannot happen. +function A.pickBattleMove(g) + local st = A.top(g) + if not (st and A.inBattle(g) and st.phase == "moves") then return nil end + local battle = st.battle + local moves = (battle and battle.player and battle.player.moves) or {} + local defs = g.data and g.data.moves + -- Score by EXPECTED damage, not raw power. + -- + -- Raw power alone is what walked a Fire starter into Morty's Gastly line and + -- a level-58 lead into Bruno: the hardest-hitting move in the list is + -- routinely the one the target resists or is immune to, and the bot has no + -- switching to recover with. Damage.typeMultiplier is the same table the + -- battle engine itself uses (matchups are in tenths, so 10 is neutral), so + -- this cannot disagree with the damage that actually lands. + local ok, Damage = pcall(require, "src.battle.gen2.Damage") + local matchups = g.data and g.data.type_chart and g.data.type_chart.matchups + local st2 = A.top(g) + local enemy = st2 and st2.battle and st2.battle.enemy + local enemyTypes = (enemy and enemy.types) or {} + if enemy and #enemyTypes == 0 then + local pdef = g.data and g.data.pokemon and g.data.pokemon[enemy.species] + enemyTypes = (pdef and pdef.types) or {} + end + + -- STAB, the other half of the damage formula. Without it a 40-power EMBER + -- on a Fire starter scores below an 80-power STRENGTH even though the two + -- land within a few points of each other, which is how the lead ended up + -- swinging an HM at the Elite Four all game. + local self_ = st2 and st2.battle and st2.battle.player + local selfTypes = (self_ and self_.types) or {} + if self_ and #selfTypes == 0 then + local sdef = g.data and g.data.pokemon and g.data.pokemon[self_.species] + selfTypes = (sdef and sdef.types) or {} + end + local function stab(moveType) + for _, t in ipairs(selfTypes) do + if t == moveType then return 1.5 end + end + return 1 + end + + -- `.move_disabled` re-enters the list (engine/battle/core.asm:5236-5246), so + -- picking a disabled row never resolves. + local disabled = battle and battle.player and battle.player.volatile + and battle.player.volatile.disabled + + local bestIndex, bestScore + for i, move in ipairs(moves) do + if (move.pp or 0) > 0 and move.id ~= disabled then + local def = defs and defs[move.id] + local power = (def and def.power) or 0 + local score = power + if ok and Damage and Damage.typeMultiplier and def and def.type + and matchups and #enemyTypes > 0 and power > 0 then + local mult = Damage.typeMultiplier(def.type, enemyTypes, matchups) + score = power * (mult or 10) / 10 + end + if def and def.type and power > 0 then score = score * stab(def.type) end + if not bestIndex or score > bestScore then + bestIndex, bestScore = i, score + end + end + end + if bestIndex then st.moveIndex = bestIndex end + return bestIndex +end + +function A.setBattleMenu(g, index) + local st = A.top(g) + if st and A.inBattle(g) and st.phase == "menu" then + st.menuIndex = index + return true + end + return false +end + +-- Why is the world refusing input? `busy` is a boolean over half a dozen +-- independent sources, so when a run wedges "still busy" says nothing useful. +-- This names the source, which is the difference between a bug report and a +-- shrug: the stack's top state (and its phase, if it has one) plus whichever of +-- World:busy()'s own fields is set. +function A.busyReason(g) + local parts = {} + local top = A.top(g) + if top then + local kind = "other" + local mt = getmetatable(top) + if mt == BattleState then kind = "BattleState" + elseif mt == EvolutionAnim then kind = "EvolutionAnim" end + parts[#parts + 1] = "stack=" .. kind + if top.phase then parts[#parts + 1] = "phase=" .. tostring(top.phase) end + if top.step then parts[#parts + 1] = "step=" .. tostring(top.step) end + if top.timer then parts[#parts + 1] = "timer=" .. tostring(top.timer) end + else + parts[#parts + 1] = "stack=empty" + end + local w = g.world + if w then + if w.vm and w.vm:running() then parts[#parts + 1] = "vm:running" end + for _, field in ipairs({ "mapSetup", "textbox", "moveState", "choicebox", + "fishing", "headbutt", "fieldMove" }) do + if w[field] ~= nil then parts[#parts + 1] = field end + end + end + return table.concat(parts, " ") +end + +-- The species on the far side of a wild battle, so the bot can decide whether +-- this is the one worth a ball. +function A.enemySpecies(g) + local st = A.top(g) + if not (st and A.inBattle(g)) then return nil end + local battle = st.battle + return battle and battle.enemy and battle.enemy.species or nil +end + +function A.enemyHpFraction(g) + local st = A.top(g) + if not (st and A.inBattle(g)) then return 1 end + local enemy = st.battle and st.battle.enemy + if not (enemy and enemy.maxHp and enemy.maxHp > 0) then return 1 end + return (enemy.hp or 0) / enemy.maxHp +end + +function A.isWildBattle(g) + local st = A.top(g) + return (st and A.inBattle(g) and st.battle and st.battle.wild) and true or false +end + +-- Throw a ball. BattleState:useItem takes an item id and does the whole +-- PokeBallEffect itself, so this skips the PACK menu the player would walk -- +-- the same shortcut ops.teach takes, and for the same reason: the route is what +-- is under test, not menu navigation. +-- The strongest healing item in the bag, or nil. +-- +-- The bot fought every gym from Whitney on with no items at all, which is why +-- each one had to be answered with tens of thousands of frames of grinding: a +-- level advantage was the only lever it had. Ordered worst-first so the +-- cheapest thing that will do the job is spent. +A.HEAL_ITEMS = { "POTION", "SUPER_POTION", "HYPER_POTION", "MAX_POTION", + "FULL_RESTORE" } + +-- Roughly what each one restores, for rationing. `full` means the whole bar. +A.HEAL_AMOUNT = { + POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200, + MAX_POTION = "full", FULL_RESTORE = "full", +} + +-- The lead's status condition ("paralyze" / "sleep" / "freeze" / "burn" / +-- "poison" / "toxic"), or nil. The battle mon aliases the party slot, so this +-- is the same table the turn loop reads. +function A.leadStatus(g) + local st = A.top(g) + local lead = st and st.battle and st.battle.player + return lead and lead.status or nil +end + +-- Cure a movement-blocking status with a FULL_RESTORE, or nil. +-- +-- The port implements FULL_RESTORE (status + full HP) but not the standalone +-- FULL_HEAL / PARLYZ_HEAL, so the one cure the bot has is the FULL_RESTORE it +-- otherwise saves for the Champion. Paralysis is worth one: THUNDER WAVE off +-- Clair's DRAGONAIR halves the lead's speed AND skips a quarter of its turns, +-- so a paralyzed Fire lead loses the KINGDRA attrition war to a super- +-- effective SURF and Clair's HYPER POTION every time. Only the three statuses +-- that actually cost turns, and only in a trainer fight (a wild one is over +-- before it matters), and only once the FULL_RESTORE would also do real HP +-- work OR the fight is a leader/Elite Four -- so it does not burn on a route +-- grunt's STUN SPORE. +local TURN_COSTING_STATUS = { paralyze = true, sleep = true, freeze = true } + +function A.statusCure(g) + local st = A.top(g) + if not (st and A.inBattle(g)) or A.battleIsTutorial(g) then return nil end + if not (st.battle and st.battle.trainer) then return nil end -- trainers only + local status = A.leadStatus(g) + if not TURN_COSTING_STATUS[status] then return nil end + local save = g.save + if not (save and save.inventory and (save.inventory.FULL_RESTORE or 0) > 0) then + return nil + end + return "FULL_RESTORE" +end + +-- Which heal to spend, given how much trouble the lead is in. +-- +-- This used to answer "the strongest one owned", which is the wrong end of the +-- shelf. The Elite Four is five fights with no Pokecenter between them, so the +-- bag has to last all five -- and a bot that drinks a FULL RESTORE to top up +-- 43% of a health bar in WILL's room meets LANCE with an empty bag. Run 21 +-- swept all four rooms and then lost the Champion that way, twice. +-- +-- So: the weakest item that would actually accomplish something, and the +-- strongest only when the next hit is going to kill. "Actually accomplish +-- something" is a quarter of the bar -- a 20-point POTION on a level-88 +-- TYPHLOSION is a wasted turn, which at these levels is worse than not healing. +function A.bestHeal(g, fraction) + local save = g.save + if not (save and save.inventory) then return nil end + local have = function(id) return (save.inventory[id] or 0) > 0 end + + -- The emergency: reach for the best thing in the bag. + if (fraction or 1) < 0.35 then + for i = #A.HEAL_ITEMS, 1, -1 do + if have(A.HEAL_ITEMS[i]) then return A.HEAL_ITEMS[i] end + end + return nil + end + + local lead = A.party(g)[1] + local maxHp = (lead and (lead.maxHp or (lead.stats and lead.stats.hp))) or 100 + local worthwhile = maxHp * 0.25 + for _, id in ipairs(A.HEAL_ITEMS) do -- weakest first + local amount = A.HEAL_AMOUNT[id] + if have(id) and (amount == "full" or amount >= worthwhile) then + return id + end + end + -- Nothing meaningful short of the good stuff; leave it for the emergency. + return nil +end + +-- BattleState:useItem is the same entry point A.throwBall uses: it takes an +-- item id and runs the effect, skipping the PACK menu the player would walk. +function A.useItem(g, itemId) + local st = A.top(g) + if not (st and A.inBattle(g) and st.phase == "menu") then return false end + st:useItem(itemId) + return true +end + +function A.throwBall(g, itemId) + local st = A.top(g) + if not (st and A.inBattle(g)) then return false end + if st.phase ~= "menu" then return false end + st:useItem(itemId) + return true +end + +-- A lead that faints leaves the battle in its "forced-switch" phase, which +-- opens the party list and waits for a choice (src/ui/gen2/BattleState.lua:703). +-- The list is its own screen, so the battle's own menu handling cannot answer +-- it -- and mashing A only re-selects whatever the cursor starts on, which is +-- the mon that just fainted. Point the cursor at something that can still +-- fight and confirm. +function A.partyMenuUp(g) + return A.topIs(g, PartyMenu) +end + +-- AskLearnMove's forget picker (BattleState phase "choose-forget"). The mon +-- levelled into a fifth move and the battle is paused on which of the four to +-- drop. Leaving it unanswered froze the exp queue; picking wrong throws away +-- the attack the endgame needs -- so keep HMs and drop the weakest real move. +function A.forgetMenuUp(g) + local st = A.top(g) + return st ~= nil and A.inBattle(g) and st.phase == "choose-forget" + and (st.messageTimer or 0) <= 0 and st.pendingLearn ~= nil +end + +-- Answer the forget picker. Points forgetIndex at the weakest non-HM move and +-- presses A to drop it, unless the newcomer is no upgrade or every slot is an +-- HM, in which case it presses B and keeps the four. Same set-index-then-tap +-- shape as A.chooseHealthyPartyMon. Returns "learn", "keep" or false. +local HM_MOVE = { CUT = true, FLY = true, SURF = true, STRENGTH = true, + FLASH = true, WHIRLPOOL = true, WATERFALL = true } + +function A.resolveForgetMenu(g) + local st = A.top(g) + if not A.forgetMenuUp(g) then return false end + local learn = st.pendingLearn + local mon = learn and st.battle and st.battle.party[learn.index] + local moves = (mon and mon.moves) or {} + local defs = (st.game and st.game.data and st.game.data.moves) or {} + -- The pending move's own power: never drop a stronger move for a weaker one, + -- or the bot keeps sawing away with the level-1 attack it already has. + local newPower = (learn.move and defs[learn.move.id] + and defs[learn.move.id].power) or 0 + local worst, worstPower + for i, mv in ipairs(moves) do + if not HM_MOVE[mv.id] then + local p = (defs[mv.id] and defs[mv.id].power) or 0 + if not worst or p < worstPower then worst, worstPower = i, p end + end + end + if not worst or worstPower >= newPower then + return "b" -- keep the four it has + end + st.forgetIndex = worst -- cursor on the drop; caller taps A to confirm + return "a" +end + +-- The naming keyboard, and how to get off it without typing anything. +-- +-- The bot's dialogue loop answers an unknown prompt by pressing A, which on a +-- keyboard screen means "add the letter under the cursor". The cursor starts +-- on A and the field holds ten characters, so every unqueued nickname prompt +-- produced a Pokemon called AAAAAAAAAA -- which is how the hatched TOGEPI ended +-- up with that name for a whole playthrough. There is no cancel on this screen +-- (only END), and accepting an EMPTY name is the "no thanks" answer: HatchEggs +-- copies the species name into the slot, and the port stores that as no +-- nickname (src/core/gen2/Breeding.lua:1010). +function A.namingScreenUp(g) + local t = A.top(g) + return t ~= nil and type(t.accept) == "function" + and type(t.addCharacter) == "function" +end + +function A.dismissNaming(g) + local t = A.top(g) + if not A.namingScreenUp(g) then return false end + t.text = "" + t:accept() + return true +end + +-- Switch training, which is the only way a level-5 mon gets anywhere. +-- +-- Battle:awardExperience credits every PARTICIPANT that is still alive, not +-- just whoever landed the kill -- so sending the weakling out and immediately +-- switching to the fighter earns it a full share while it never takes a hit +-- (the incoming mon eats the turn's attack, as on the cart). Without this a +-- party-minimum grind is unreachable by construction: the TOGEPI hatches at +-- level 5, gets rotated to the front, faints to Route 34's teens before it can +-- act, and a FAINTED participant is awarded nothing. Run 24 sat at +-- "grind: level 5/20" for 166k frames proving it. +function A.openBattleParty(g) + local st = A.top(g) + if not (st and A.inBattle(g) and st.phase == "menu") then return false end + if type(st.openParty) ~= "function" then return false end + st:openParty() + return true +end + +-- The highest-level healthy non-egg mon: the one that can actually win the +-- fight the weakling just got credit for starting. +function A.chooseStrongestPartyMon(g) + local menu = A.top(g) + if not (menu and A.topIs(g, PartyMenu)) then return false end + local best + for i, mon in ipairs(menu.party or {}) do + if not mon.isEgg and (mon.hp or 0) > 0 + and (not best or (mon.level or 0) > (menu.party[best].level or 0)) then + best = i + end + end + if not best then return false end + menu.index = best + return true +end + +-- Is the mon currently out the weakest thing we could have sent? Used to +-- decide whether a switch is worth the turn. +function A.leadIsWeakest(g) + local party = A.party(g) + local lead = party[1] + if not lead then return false end + local leadLevel = lead.level or 0 + for i = 2, #party do + local mon = party[i] + if mon and not mon.isEgg and (mon.hp or 0) > 0 + and (mon.level or 0) > leadLevel then + return true + end + end + return false +end + +function A.chooseHealthyPartyMon(g) + local menu = A.top(g) + if not (menu and A.topIs(g, PartyMenu)) then return false end + for index, mon in ipairs(menu.party or {}) do + -- Never an EGG. It has HP, so "the first mon with HP left" picked it, and + -- the log filled with "AAAAAAAAAA fainted!" -- the Togepi egg being sent + -- into Morty's Gengar and dying on the spot, twice, before the only mon + -- that could fight got its turn. The cart cannot do this: an egg has no + -- moves and CheckCurPartyMon refuses it. + if not mon.isEgg and (mon.hp or 0) > 0 then + menu.index = index + return true + end + end + return false +end + +-- --------------------------------------------------------------------------- +-- flags, items, party +-- --------------------------------------------------------------------------- + +-- EVENT_* by name. The port keys its bitfield by the cart's numeric id +-- (src/world/gen2/Events.lua); flag_names.lua supplies the name -> id map and +-- tests/gold_flag_names_test.lua is what keeps it honest. +function A.event(g, name) + local id = FLAGS.events[name] + if not id then return nil end -- nil, not false: an unknown NAME is + -- a route bug, not an unset flag + local w = g.world + if not (w and w.events) then return nil end + return w.events:get(id) +end + +function A.engine(g, name) + local id = FLAGS.engine[name] + if not id then return nil end + local w = g.world + if not w then return nil end + return w:engineFlag(id) and true or false +end + +function A.knownFlag(name) + return FLAGS.events[name] ~= nil or FLAGS.engine[name] ~= nil +end + +function A.badges(g) + local n = 0 + for _, badge in ipairs({ + "ENGINE_ZEPHYRBADGE", "ENGINE_HIVEBADGE", "ENGINE_PLAINBADGE", + "ENGINE_FOGBADGE", "ENGINE_MINERALBADGE", "ENGINE_STORMBADGE", + "ENGINE_GLACIERBADGE", "ENGINE_RISINGBADGE", + }) do + if A.engine(g, badge) then n = n + 1 end + end + return n +end + +function A.party(g) + local save = g.save + return (save and save.party) or {} +end + +function A.partySize(g) + return #A.party(g) +end + +-- Lowest surviving level in the party, which is what "grind to level N" has to +-- mean: the highest would let one overlevelled starter mask five faint-bait +-- team-mates, and the average would hide both. +function A.minLevel(g) + local lowest + for _, mon in ipairs(A.party(g)) do + -- An EGG occupies a party slot, has a level, and can never fight or gain + -- one -- so counting it makes "grind the party to N" a target that can + -- never be reached, and the route carries the Togepi egg from Violet all + -- the way to Elm. Breeding.isEgg is the same predicate the party menu and + -- the battle switch-in use. + local lv = (not mon.isEgg) and mon.level or nil + if lv and (not lowest or lv < lowest) then lowest = lv end + end + return lowest or 0 +end + +-- The lead's level. Pre-gym grinding targets this rather than the party +-- minimum: the SLOWPOKE is caught around L6 purely to carry SURF, and dragging +-- the whole party up to the lead's level would multiply the grind for no +-- fighting benefit. +function A.leadLevel(g) + local mon = A.party(g)[1] + return (mon and mon.level) or 0 +end + +function A.partyHealthy(g) + local party = A.party(g) + if #party == 0 then return false end + for _, mon in ipairs(party) do + if (mon.hp or 0) > 0 then return true end + end + return false +end + +-- Fraction of the lead mon's HP, for the "should we go heal" policy. +function A.leadHpFraction(g) + local mon = A.party(g)[1] + if not mon or not mon.maxHp or mon.maxHp == 0 then return 1 end + return (mon.hp or 0) / mon.maxHp +end + +-- Fraction of the party's total PP still available. +-- +-- This is not a nicety, it is the difference between a run that finishes and +-- one that wedges forever. The Gen 2 port has no STRUGGLE: at 0 PP the +-- player's move emits "No PP left for this move!" and returns +-- (src/battle/gen2/Battle.lua:739), and an enemy with no usable move emits +-- "<name> has no moves left!" and returns (:1880). Neither side falls back to +-- Struggle the way the cart does, so a battle where both sides are dry can +-- never end and cannot be run from if it is a trainer. The bot's only defence +-- is to never arrive at 0 PP, which means treating PP as a resource worth +-- walking to a Pokecenter for. +function A.partyPpFraction(g) + local have, max = 0, 0 + for _, mon in ipairs(A.party(g)) do + for _, move in ipairs(mon.moves or {}) do + have = have + (move.pp or 0) + max = max + (move.maxPp or move.pp or 0) + end + end + if max == 0 then return 1 end + return have / max +end + +-- The same measure over ATTACKING moves only. +-- +-- Total PP is the wrong trigger for "go and heal" and it let a run wedge: +-- LEER and SMOKESCREEN carry 30 and 20 PP and are never spent, so a starter +-- whose EMBER and TACKLE are both dry still reports most of its PP intact. +-- What actually decides whether the next battle can be won is how many +-- POWERED moves are left, so that is what the policy watches. +function A.damagingPpFraction(g) + local defs = g.data and g.data.moves + local have, max = 0, 0 + for _, mon in ipairs(A.party(g)) do + for _, move in ipairs(mon.moves or {}) do + local def = defs and defs[move.id] + if def and (def.power or 0) > 0 then + have = have + (move.pp or 0) + max = max + (move.maxPp or move.pp or 0) + end + end + end + if max == 0 then return 1 end -- nothing damaging to run out of + return have / max +end + +-- Bag membership by item id ("POTION", "HM_CUT"). `save.inventory` is the +-- flat id -> count map; the four Gen 2 pockets are a PackMenu presentation of +-- it, not a second store (src/core/gen2/Save.lua:158). +function A.hasItem(g, id) + local save = g.save + if not (save and save.inventory) then return false end + return (save.inventory[id] or 0) > 0 +end + +-- --------------------------------------------------------------------------- +-- geometry +-- --------------------------------------------------------------------------- + +function A.inBounds(map, x, y) + return map:inBounds(x, y) +end + +-- Can the player stand on this cell right now? On foot that is the cart's +-- .CheckWalkable (permission == LAND); surfing it is the WATER half of +-- .CheckSurfable, plus the LAND cells that end the surf. +function A.walkable(g, map, x, y) + if not map:inBounds(x, y) then return false end + local coll = map:cellCollision(x, y) + if A.surfing(g) then + return Permissions.surfable(coll) ~= nil + end + return Permissions.isWalkable(coll) +end + +function A.surfing(g) + local w = g.world + return w and FieldMoves.isSurfing(w.playerState) or false +end + +-- Is this cell water? Separate from A.walkable because the bot needs to plan +-- a route that STARTS on land and continues over water: "can I stand here" and +-- "could I get here at all" are different questions the moment SURF exists. +function A.isWater(map, x, y) + if not map:inBounds(x, y) then return false end + return Permissions.isWater(map:cellCollision(x, y)) +end + +-- COLL_ICE / COLL_ICE_2B. A step that lands here keeps going until a non-ice +-- cell or a bump (World:turningDirection + CheckStandingOnIce), so the bot's +-- planner has to treat ice as a slide to a REST position, not as ordinary floor. +function A.isIce(map, x, y) + if not map:inBounds(x, y) then return false end + return Permissions.isIce(map:cellCollision(x, y)) +end + +-- The elevator's floor list when it is the top state, or nil. The three +-- elevators (both dept stores and the Radio Tower) are the one interior link +-- the map graph cannot express -- Elevator_GoToFloor rewrites the door warp's +-- destination instead of warping -- so ops.elevator drives this screen's +-- cursor with real presses and then walks out the door. +function A.elevatorMenu(g) + local top = A.top(g) + if top and getmetatable(top) == ElevatorMenu then return top end + return nil +end + +-- The display name of the menu's i-th row ("B1F", "1F", ...). +function A.elevatorFloorName(menu, i) + local row = menu.floors and menu.floors[i] + if not row then return nil end + return ElevatorMenu.floorName(menu.floorNames, row.floorId) +end + +-- GetMovementPermissions' verdict on leaving (x, y) toward dir: the standing +-- tile's side-wall kind and Gold's neighbour arms (an UP_WALL below forbids +-- the DOWN step). The same Permissions.stepPermitted the engine runs, so the +-- planner and the world cannot disagree about a one-way tile. +function A.stepPermitted(map, x, y, dir) + return Permissions.stepPermitted( + function(cx, cy) return map:cellCollision(cx, cy) end, x, y, dir) +end + +-- The facings that hop the ledge at (x, y), or nil (.TryJump's .ledge_table). +function A.ledgeFacings(map, x, y) + if not map:inBounds(x, y) then return nil end + return Permissions.ledgeFacings(map:cellCollision(x, y)) +end + +-- Who in the party can carry us, or nil. World:partyMoveUser is the same check +-- the field-move menu runs, so this honours the badge gate (FOGBADGE) rather +-- than just the move being known. +function A.surfUser(g) + local w = g.world + if not (w and w.partyMoveUser) then return nil end + -- The badge, not just the move. partyMoveUser answers "who knows SURF", + -- which is the question the field-move MENU asks; the gate that actually + -- decides whether the water opens is FOGBADGE, checked inside useFieldMove. + -- Planning on the move alone made the whole coast look passable to a bot + -- that had lost Morty, so every step along the shore tried to launch and was + -- refused -- twenty thousand frames of walking up and down a beach. + if not FieldMoves.hasBadge(g.save, FieldMoves.BADGE.SURF) then return nil end + local ok, user = pcall(w.partyMoveUser, w, "SURF") + return ok and user or nil +end + +function A.startSurf(g) + local w = g.world + local user = A.surfUser(g) + if not (w and user) then return false end + local ok, result = pcall(w.useFieldMove, w, "SURF", user) + return ok and result and result.ok or false +end + +-- The other two badge-gated water moves, same shape as A.surfUser / +-- A.startSurf. WHIRLPOOL swaps the facing block for plain water +-- (Script_UsedWhirlpool); WATERFALL climbs the whole falls in forced UP steps +-- (Script_UsedWaterfall). Route 27 needs both at once, which is why the +-- route carries a mule that knows them alongside SURF. +local function badgedWaterUser(g, move) + local w = g.world + if not (w and w.partyMoveUser) then return nil end + if not FieldMoves.hasBadge(g.save, FieldMoves.BADGE[move]) then return nil end + local ok, user = pcall(w.partyMoveUser, w, move) + return ok and user or nil +end + +function A.whirlpoolUser(g) return badgedWaterUser(g, "WHIRLPOOL") end +function A.waterfallUser(g) return badgedWaterUser(g, "WATERFALL") end + +local function useWaterMove(g, move, user) + local w = g.world + if not (w and user) then return false end + local ok, result = pcall(w.useFieldMove, w, move, user) + return ok and result and result.ok or false +end + +function A.useWhirlpool(g) + return useWaterMove(g, "WHIRLPOOL", A.whirlpoolUser(g)) +end + +function A.useWaterfall(g) + return useWaterMove(g, "WATERFALL", A.waterfallUser(g)) +end + +function A.isWhirlpool(map, x, y) + if not map:inBounds(x, y) then return false end + return Permissions.isWhirlpool(map:cellCollision(x, y)) +end + +function A.isWaterfall(map, x, y) + if not map:inBounds(x, y) then return false end + return Permissions.isWaterfall(map:cellCollision(x, y)) +end + +function A.warpAt(map, x, y) + return map:warpAt(x, y) +end + +-- Does stepping here actually leave the map? +-- +-- A `warp_event` coordinate is NOT enough. CheckWarpTile reads the TILE's +-- collision, so a warp_event sitting on plain floor never fires -- and Ecruteak +-- Gym is full of them: thirty of its thirty-three warps are the floor holes, +-- but several of those coordinates are ordinary floor and are the safe path +-- between the pits. Refusing every warp COORDINATE cut the gym in half and +-- left Morty, and therefore FOGBADGE and SURF, unreachable on foot. +-- +-- Carpets count too: they need a press in their own direction, but a bot that +-- walks over one while holding that direction still leaves. +function A.isWarpTile(map, x, y) + if not map:warpAt(x, y) then return false end + local coll = map:cellCollision(x, y) + return Permissions.isWarpCollision(coll) + or Permissions.carpetDirection(coll) ~= nil +end + +-- Which direction this tile's warp wants held, or nil if it takes on arrival. +-- +-- Gen 2 has two kinds of warp tile and they are driven completely differently +-- (src/world/gen2/Permissions.lua). A door / staircase / cave / panel fires +-- the moment you step on it. A CARPET fires only from +-- World:checkCarpetWhileStanding, which wants the player STOPPED on the tile +-- with the carpet's own direction held -- so a bot that just walks on and +-- waits will stand in a doorway forever, which is exactly what the front door +-- of the player's house did on the first run. +-- A COLL_COUNTER tile: not walkable, but an A press reaches over it (see +-- World:interact). The bot needs this to know a nurse two cells away is +-- talkable from where it can actually stand. +-- Does a step onto this cell roll for a wild encounter? Wandering at random +-- mostly walks corridor, which is why a hunt could burn 150k frames without +-- meeting the species it wanted. +function A.isEncounterCell(map, x, y) + return Permissions.isEncounterCollision(map:cellCollision(x, y)) +end + +function A.isCounter(map, x, y) + return Permissions.isCounter(map:cellCollision(x, y)) +end + +function A.carpetDir(map, x, y) + return Permissions.carpetDirection(map:cellCollision(x, y)) +end + +function A.warps(map) + return map.warps or {} +end + +function A.connections(map) + return map.connections or {} +end + +-- Objects and bg events live on the extracted DEF, not on the live Map: +-- Map.new copies blocks, warps and connections and leaves the rest behind +-- (src/world/gen2/Map.lua:13). Reading map.objects therefore silently gives +-- an empty list, which is how "heal" once concluded a Pokecenter had no nurse +-- while she was standing at (3,1). +function A.objects(map) + if not map then return {} end + return map.objects or (map.def and map.def.objects) or {} +end + +function A.bgEvents(map) + if not map then return {} end + return map.bgEvents or (map.def and map.def.bgEvents) or {} +end + +-- Is an NPC standing here? Blocks a step the same way a wall does, but is +-- transient, which is why the bot's wall memory must never learn from it. +function A.npcAt(g, x, y) + local w = g.world + if not (w and w.npcAt) then return nil end + local ok, npc = pcall(w.npcAt, w, x, y) + return ok and npc or nil +end + +-- The npc whose OBJECT ROW homes at (x, y), wherever it stands right now. +-- +-- A sighted trainer WALKS to the player and stays where the fight happened, +-- so the cell a route row names -- the object's home coordinates out of the +-- map data -- can be empty while the trainer parks two cells away. The +-- RaticateTail grunt is the one that costs a run: his sight line pulls him +-- off (5,15), the re-talk row then presses A at nothing, and without +-- EVENT_LEARNED_RATICATE_TAIL the door to Giovanni never opens. +function A.npcHome(g, x, y) + local w = g.world + for _, npc in ipairs((w and w.npcs) or {}) do + if (npc.homeX == x and npc.homeY == y) + or (npc.def and npc.def.x == x and npc.def.y == y) then + return npc + end + end + return nil +end + +-- --------------------------------------------------------------------------- +-- input +-- --------------------------------------------------------------------------- +-- Two shapes, and mixing them up is the classic driver bug. A press is an +-- EDGE: pressQueue is what Input:step promotes into wasPressed, which is what +-- every menu, text box and `interact` reads. A direction is a HELD state: +-- World:pollInput asks input:isDown, so a tapped direction is usually gone +-- before the world looks. + +function A.press(g, button) + local input = g.input + input.pressQueue[#input.pressQueue + 1] = button + input.state[button] = true +end + +function A.release(g, button) + g.input.state[button] = false +end + +A.DIRS = { "up", "down", "left", "right" } + +function A.releaseDirs(g) + for _, d in ipairs(A.DIRS) do g.input.state[d] = false end +end + +function A.hold(g, dir) + A.releaseDirs(g) + g.input.pressQueue[#g.input.pressQueue + 1] = dir + g.input.state[dir] = true +end + +-- --------------------------------------------------------------------------- +-- save / checkpoint +-- --------------------------------------------------------------------------- + +-- Drop the player onto a map directly. A TEST-HARNESS SHORTCUT, not +-- navigation: tests/drivers/gold_walk_smoke.lua uses the same world:setMap call +-- to skip a fragile indoor route. The bot uses it only as a last resort, after +-- real pathfinding has failed, and says so in the log every time -- a run that +-- needed it has NOT proved the map graph works, only that everything downstream +-- of the gap does. +function A.teleport(g, mapId, x, y) + local w = g.world + if not (w and w.setMap) then return false end + local ok = pcall(w.setMap, w, mapId, x, y, "down") + return ok and A.mapId(g) == mapId +end + +function A.save(g) + local ok, res, err = pcall(require("src.core.gen2.Save").save, g.save) + if not ok then return false, tostring(res) end + return res and true or false, err +end + +-- --------------------------------------------------------------------------- +-- checkpoints +-- --------------------------------------------------------------------------- +-- A run is ~700k frames of accumulated luck, so reproducing a section-13 bug +-- used to mean replaying sections 00-12 first and hoping the variance landed +-- the same way. A checkpoint is the ordinary save file under a second name: +-- Game2:snapshotSave folds the live world (position, events, map scenes, +-- player state, script memory) back into the save table, which is exactly +-- the state a resume has to restore. Nothing here is a shortcut the player +-- could not take -- it is SAVE and CONTINUE, driven from the harness. + +local function ckptPath(name) + return ("gold-ckpt-%s.lua"):format(tostring(name)) +end + +A.checkpointPath = ckptPath + +function A.writeCheckpoint(g, name) + if not (g.snapshotSave and love and love.filesystem) then + return false, "no snapshot" + end + local ok, snap = pcall(g.snapshotSave, g) + if not (ok and snap) then return false, tostring(snap) end + local encoded + ok, encoded = pcall(require("src.core.SaveSerializer").encode, snap) + if not ok then return false, tostring(encoded) end + local wrote, err = love.filesystem.write(ckptPath(name), encoded) + return wrote and true or false, err +end + +function A.hasCheckpoint(name) + return love and love.filesystem + and love.filesystem.getInfo(ckptPath(name)) ~= nil +end + +-- Restore one. continueGame is the CONTINUE path itself: it adopts the save, +-- clears the stack, drops the world and rebuilds it at save.position -- so a +-- resumed run is in the same state a player who saved and reloaded would be. +function A.loadCheckpoint(g, name) + if not (love and love.filesystem) then return false, "no filesystem" end + local raw = love.filesystem.read(ckptPath(name)) + if not raw then return false, "no checkpoint " .. tostring(name) end + local Save = require("src.core.gen2.Save") + local ok, save = pcall(require("src.core.SaveSerializer").decode, raw) + if not (ok and type(save) == "table") then return false, tostring(save) end + Save.migrate(save) + Save.normalize(save) + local started, err = pcall(g.continueGame, g, save) + if not started then return false, tostring(err) end + return true +end + +return A diff --git a/tests/drivers/gold/bot.lua b/tests/drivers/gold/bot.lua new file mode 100644 index 00000000..2ae90df0 --- /dev/null +++ b/tests/drivers/gold/bot.lua @@ -0,0 +1,2428 @@ +-- Route-bot core for the Gold port. +-- +-- Interprets tests/drivers/gold/route.lua -- objectives derived from +-- docs/gold-walkthrough/asm-walk -- against a live game, through the adapter in +-- tests/drivers/gold/adapter.lua. Nothing here names a Gen 2 module; swap the +-- adapter and this drives Gen 1. +-- +-- The shape differs from the Gen 1 bot on purpose. tests/drivers/route.lua +-- interprets PokeBotBad's tile-by-tile waypoints, so its route says "walk to +-- (8,30), then (8,24)". The asm-walk has no walking paths at all -- it has +-- OBJECTIVES ("warp 1 at (6,3)", "talk to ELMSLAB_ELM at (5,2)") plus the +-- EVENT_* each one sets. So the route here is a list of goals and ALL the +-- navigation is the bot's problem: local BFS inside a map, and a second BFS +-- over the map graph to reach the map an objective names. That is more work +-- here and far less work per route row, and it means a map whose geometry the +-- port gets slightly wrong is routed around rather than fatal. +-- +-- Three feedback memories exist because a planner that cannot learn will retry +-- the same failing step until the watchdog kills it. All three are lessons +-- the Gen 1 bot paid for first (see tests/drivers/route.lua's header): +-- +-- walls BFS plans over a static tile test that knows nothing about ledges, +-- directional blocks or an NPC parked in a doorway. A step the +-- engine refuses is remembered so the next plan avoids it, and +-- forgotten the moment we do stand there -- otherwise a wandering +-- NPC blacklists a corridor permanently. +-- seams a map-graph edge that did not work gets PRICED, never banned. +-- Banning severed the Gen 1 map graph outright: crossing a seam is +-- flaky, so the only road north out of a town could reach two +-- failures and become impossible. An expensive seam is still taken +-- when it is the only way through. +-- deaths per-map blackout counts, so the bot gets more careful about a place +-- that keeps killing it instead of walking back in at 3 HP. + +local Bot = {} +Bot.__index = Bot + +local A = dofile("tests/drivers/gold/adapter.lua") +Bot.adapter = A + +-- The region-aware map graph, generated from the extracted cache by +-- tools/goldwalk/mapgraph.lua. See Bot:planTravel for what it buys. +local REGIONS = dofile("tests/drivers/gold/map_regions.lua") + +-- Declared up here rather than beside the pathfinder because Bot:turnAway -- +-- the escape hatch for an NPC whose conversation keeps restarting -- walks it +-- too, and a `local` further down the file is not in scope there: DELTA +-- resolved to the global nil and `pairs(nil)` crashed the run out of +-- clearDialogue, which is a fatal error in the one place that exists to +-- recover from a stuck conversation. +local DELTA = { + up = { 0, -1 }, + down = { 0, 1 }, + left = { -1, 0 }, + right = { 1, 0 }, +} + +-- --------------------------------------------------------------------------- +-- frame plumbing +-- --------------------------------------------------------------------------- +-- Everything in this file runs inside main.lua's driver coroutine: one yield is +-- one logic step. POKEPORT_SPEED only changes how many of those main.lua runs +-- per rendered frame, so nothing here needs to know the multiplier. + +local frames = 0 + +function Bot:wait(n) + for _ = 1, (n or 1) do + frames = frames + 1 + self.silentFrames = self.silentFrames + 1 + -- Silent-stall backstop. Every loop in this file waits through here, so a + -- run that stops logging but keeps spinning still passes through this + -- counter on the frames it burns. The talkative case is caught by the + -- repeat detector in :say(). + if self.silentFrames > self.stallFrames then + error({ botStall = true, + why = ("no progress for %d frames"):format(self.silentFrames) }) + end + coroutine.yield() + end +end + +function Bot:frames() return frames end + +-- --------------------------------------------------------------------------- +-- logging + stuck detection +-- --------------------------------------------------------------------------- + +function Bot:say(...) + local parts = {} + for i = 1, select("#", ...) do + parts[#parts + 1] = tostring((select(i, ...))) + end + local line = table.concat(parts, " ") + print(("[gold %6d] %s"):format(frames, line)) + if self.logFile then + self.logFile:write(("%6d %s\n"):format(frames, line)) + self.logFile:flush() -- a long run is normally ended by killing the + -- process, and a block-buffered tail loses + -- exactly the part worth reading + end + self:recordLine(line) +end + +-- "Stuck" from the outside looks like the log repeating itself. Every retry +-- loop below narrates each attempt, so the same line cycling N times without a +-- new objective means we are trying the same thing and getting the same +-- result. Detected here rather than per-loop so no loop can forget to. +function Bot:recordLine(line) + local h = self.history + h[#h + 1] = line + if #h > 200 then table.remove(h, 1) end + local repeats = 0 + for i = #h, 1, -1 do + if h[i] == line then + repeats = repeats + 1 + if repeats >= self.stuckRepeats then + error({ botStall = true, + why = ("repeated %q %d times"):format(line, repeats) }) + end + end + if #h - i > 40 then break end -- only look at the recent window: the same + -- line an hour ago is not a loop + end +end + +-- Called whenever something genuinely advanced, which is what makes both +-- detectors safe to be aggressive. +function Bot:progress() + self.silentFrames = 0 + self.history = {} +end + +-- --------------------------------------------------------------------------- +-- memories +-- --------------------------------------------------------------------------- + +local function wallKey(mapId, x, y) + return ("%s#%d,%d"):format(tostring(mapId), x, y) +end + +function Bot:noteWall(mapId, x, y) + self.walls[wallKey(mapId, x, y)] = (self.walls[wallKey(mapId, x, y)] or 0) + 1 +end + +-- Standing on a cell proves it passable after all. Keeps a temporary blocker +-- (an NPC mid-stroll, a tree before we have CUT) out of the permanent record. +function Bot:clearWall(mapId, x, y) + self.walls[wallKey(mapId, x, y)] = nil +end + +function Bot:wallCost(mapId, x, y) + local n = self.walls[wallKey(mapId, x, y)] or 0 + return n * 40 -- a price, not a ban: a refused cell that is the only + -- way through is still taken, just last +end + +local function seamKey(from, to) return tostring(from) .. ">" .. tostring(to) end + +function Bot:noteSeam(from, to) + local k = seamKey(from, to) + self.seams[k] = (self.seams[k] or 0) + 1 +end + +function Bot:clearSeam(from, to) + self.seams[seamKey(from, to)] = nil +end + +function Bot:seamCost(from, to) + return (self.seams[seamKey(from, to)] or 0) * 8 +end + +-- --------------------------------------------------------------------------- +-- input helpers +-- --------------------------------------------------------------------------- + +function Bot:tap(button, hold) + A.press(self.g, button) + self:wait(hold or 2) + A.release(self.g, button) + self:wait(2) +end + +-- Wait out anything that has taken control (script, text box, cutscene move), +-- mashing A so text advances. `answers` is a queue consumed by choice boxes: +-- "yes" presses A, "no" presses B. Everything unanswered defaults to YES, +-- which is what ChoiceBox starts its cursor on. +function Bot:clearDialogue(answers, budget) + local queue = {} + for i, v in ipairs(answers or {}) do queue[i] = v end + local pressed = 0 + local restarts, wasBusy = 0, true + for _ = 1, (budget or 4000) do + local busy = A.busy(self.g) + + -- Re-trigger guard. Our own A press is what closes the last page, and if + -- the player is still facing the NPC the very next press runs + -- World:interact and starts the SAME conversation again -- so a bot stood + -- in front of a chatty NPC talks to them forever. (Seen on every run: + -- "See for yourself. He's w..." in Ilex Forest, for seconds at a time.) + -- Count how often the box comes back after closing; once it is clearly a + -- loop, turn away so an A press cannot reach them, and stop. + if busy and not wasBusy then + restarts = restarts + 1 + if restarts >= 2 then + self:say("dialogue: conversation keeps restarting -- turning away") + self:turnAway() + return true + end + end + wasBusy = busy + + if not busy then + if pressed > 0 then self:progress() end + return true + end + if A.inBattle(self.g) then + self:fightBattle() + elseif A.namingScreenUp(self.g) then + -- Take the default name rather than typing one letter per A press. + self:say("dialogue: naming screen -- accepting the species name") + A.dismissNaming(self.g) + self:progress() + elseif A.partyMenuUp(self.g) then + -- A forced switch: pick a mon that can still fight, or back out. + if A.chooseHealthyPartyMon(self.g) then + self:say("battle: sending out the next healthy mon") + self:tap("a") + else + self:tap("b") + end + self:progress() + else + local answer = table.remove(queue, 1) + self:tap(answer == "no" and "b" or "a") + pressed = pressed + 1 + -- Advancing a cutscene IS progress. Without this the silent-stall + -- backstop counted a long scene as a hang and killed the run inside it -- + -- Sprout Tower's rival encounter runs well past the 5000-frame budget. + -- Nothing is lost by trusting the loop's own `budget` instead: it is + -- bounded, and it reports rather than spins. + self:progress() + end + end + self:say(("dialogue: still busy after %d presses -- %s") + :format(pressed, A.busyReason(self.g))) + return false +end + +-- Face a direction with nothing talkable in it, so a stray A press cannot +-- reopen the conversation we just escaped. Prefers a direction whose facing +-- cell holds no NPC; falls back to any. +function Bot:turnAway() + local map = A.map(self.g) + local x, y = A.pos(self.g) + if not (map and x) then return end + for dir, d in pairs(DELTA) do + if not A.npcAt(self.g, x + d[1], y + d[2]) and dir ~= A.facing(self.g) then + self:face(dir) + return + end + end +end + +-- --------------------------------------------------------------------------- +-- battles +-- --------------------------------------------------------------------------- +-- Deliberately dumb: FIGHT and the first move, every turn. The route walks +-- where the walkthrough walks and levels normally, so it does not need the +-- speedrun's frame-perfect tactics -- and a wrong-but-simple battle policy +-- fails visibly (the party faints) rather than subtly. + +function Bot:fightBattle() + local start = frames + local mapBefore = A.mapId(self.g) + -- One switch per battle: after it the fighter is out and the weakling has + -- already banked its participation. + self.switchedThisBattle = false + -- One status cure per battle (see A.statusCure): re-inflicted paralysis is + -- not worth chasing with the Champion's FULL RESTOREs. + self.curedStatusThisBattle = false + self:say("battle: fighting") + local overruns = 0 + local lastMessage, lastPhase + while A.inBattle(self.g) do + local phase = A.battlePhase(self.g) + + -- Narrate the battle. A wedged fight is otherwise a black box: the run + -- reports "no end" and nothing about the cause. Logging the message box + -- and the phase as they change turns that into the engine's own words -- + -- which is how the catch tutorial and the out-of-PP stall were both + -- identified. Only on CHANGE, so a 3000-frame fight is a handful of lines + -- and the repeat detector in :say() is not tripped by ordinary combat. + local message = A.battleMessage(self.g) + if message ~= lastMessage or phase ~= lastPhase then + lastMessage, lastPhase = message, phase + if message then + self:say(("battle: [%s] %s"):format(tostring(phase), tostring(message))) + end + end + + if phase == "choose-forget" then + -- A level-up move on a full set (AskLearnMove). The masher's blind A + -- would drop slot 1 -- often an HM or the good move. Point the cursor at + -- the weakest non-HM move first, or keep the four if the newcomer is no + -- upgrade. The whole endgame turns on this: a starter that never learns + -- a move past its level-1 attack cannot out-damage Clair or the Elite + -- Four, so it laps forever. Handled and looped WITHOUT falling through to + -- the unconditional A below -- a stray A on this phase drops slot 1. + local button = A.resolveForgetMenu(self.g) + if button then + self:say(("battle: forget menu -- %s") + :format(button == "a" and "dropping weakest move" or "keeping moveset")) + self:tap(button) + else + self:tap("a") -- still on the "wants to learn" prompt line + end + self:progress() + elseif phase == "menu" then + -- Pin the cursor before every confirm. Without this a failed escape + -- leaves the menu parked on RUN and the masher spends the rest of the + -- fight re-attempting a run it has already been refused. + -- + -- The catch tutorial is the exception and it wedged a whole run: it has + -- no player mon, so FIGHT drops into an empty move list that A cannot + -- submit and B is the only way out of. PACK is its real path. + -- Catching rides inside the ordinary battle handler, because a wild + -- encounter can start anywhere -- mid-walk, mid-travel -- and every one + -- of those paths already funnels through here. (It briefly lived in + -- tryRun by accident, which shares the same `local phase` line: the bot + -- then only ever threw a ball when a battle OVERRAN, so 88 encounters + -- produced zero throws.) + if self.catchWanted and A.isWildBattle(self.g) + and self.catchWanted[A.enemySpecies(self.g)] + and A.hasItem(self.g, self.catchBall) then + -- Throw at full health and never attack a species we came to catch: by + -- the time the bot wants a SLOWPOKE its starter is ~L15 and one EMBER + -- kills a L6 target outright, so "soften first" threw nothing at all. + self:say(("battle: throwing a %s at %s (hp %.2f)") + :format(self.catchBall, tostring(A.enemySpecies(self.g)), + A.enemyHpFraction(self.g))) + A.throwBall(self.g, self.catchBall) + self:progress() + self:wait(2) + elseif self.switchTrain and not self.switchedThisBattle + and not A.battleIsTutorial(self.g) + and A.leadIsWeakest(self.g) then + -- Switch training: the weakling has been SENT OUT, which is all it + -- needs to be a participant. Hand the fight to whoever can win it + -- before the weakling is hit; the incoming mon takes this turn's + -- attack, so the participant survives and collects its share. + self.switchedThisBattle = true + if A.openBattleParty(self.g) then + self:wait(2) + if A.chooseStrongestPartyMon(self.g) then + self:say("battle: switch-training -- handing over to the fighter") + self:tap("a") + else + self:tap("b") + end + self:progress() + self:wait(2) + end + elseif A.statusCure(self.g) and not self.curedStatusThisBattle then + -- Cure a turn-costing status (paralysis is the one that matters) with + -- a FULL_RESTORE. Once per battle: re-inflicted paralysis is a lost + -- cause to keep curing, and the FULL_RESTOREs are the Champion's. This + -- is the whole difference in the Clair fight -- a paralyzed Fire lead + -- loses the KINGDRA attrition war; a cured one outspeeds and wins. + local cure = A.statusCure(self.g) + self.curedStatusThisBattle = true + self:say(("battle: curing %s with a %s") + :format(tostring(A.leadStatus(self.g)), cure)) + A.useItem(self.g, cure) + self:progress() + self:wait(2) + elseif A.leadHpFraction(self.g) < 0.6 + and A.bestHeal(self.g, A.leadHpFraction(self.g)) + and not A.battleIsTutorial(self.g) then + -- Drink something before the mon that is carrying the run faints. + -- 0.6, not 0.35: the Elite Four hit hard enough to cross a third of a + -- health bar in one turn, so a 35% trigger is a trigger that fires + -- after the mon is already dead. + -- The policy had no items in it at all, so every leader from Whitney + -- on had to be out-levelled instead -- hundreds of thousands of frames + -- of grinding to buy what a SUPER POTION buys in one turn. + -- + -- WHICH item is A.bestHeal's problem, and it is rationed: the trigger + -- stays generous, but between 0.35 and 0.6 it hands back the weakest + -- thing that still heals a quarter of the bar, keeping the FULL + -- RESTOREs for the room where the run actually ends. + local item = A.bestHeal(self.g, A.leadHpFraction(self.g)) + self:say(("battle: using a %s (hp %.2f)") + :format(item, A.leadHpFraction(self.g))) + A.useItem(self.g, item) + self:progress() + self:wait(2) + else + A.setBattleMenu(self.g, A.battleIsTutorial(self.g) + and A.BATTLE_MENU_PACK or A.BATTLE_MENU_FIGHT) + end + elseif phase == "moves" then + -- Pick a move that can actually be used, hardest-hitting first. Slot 1 + -- is not a safe default: at 0 PP the turn silently never happens, and + -- with no Struggle in the port that is a battle which can never end. + if not A.pickBattleMove(self.g) then + if A.playerMoveCount(self.g) == 0 then + self:tap("b") -- tutorial-style empty list: back out + end + -- Otherwise every move is dry, and that is now the engine's business, + -- not the bot's: Battle:playerAttack substitutes STRUGGLE for a mon + -- with nothing left to spend, exactly as the cart does. So confirm + -- whatever the cursor is on and let the turn happen. + -- + -- The bot used to try to ESCAPE here and abort the run when a trainer + -- refused -- which was right while the port had no Struggle and is + -- wrong now: escaping a wild fight it could win by struggling is how + -- a run ends up circling a route forever, never leveling and never + -- fainting. + end + end + -- The forget branch already pressed its own button and must NOT get the + -- masher's A -- a stray A on that phase is a blind slot-1 drop. + if phase ~= "choose-forget" then + self:tap("a") + end + -- A battle IS progress. Without this the silent-stall backstop in :wait() + -- counted the whole fight as "nothing happening" and killed the run + -- mid-fight -- and because the next route row then found the battle still + -- up, every following row stalled the same way. The battle's own budget + -- below is what catches a genuinely wedged fight. + self:progress() + if frames - start > self.battleFrames then + overruns = overruns + 1 + start = frames + self:say(("battle: %d frames with no end (phase=%s, lead hp=%.2f, party=%d)") + :format(overruns * self.battleFrames, tostring(A.battlePhase(self.g)), + A.leadHpFraction(self.g), A.partySize(self.g))) + -- A battle that will not end is usually one neither side can win. The + -- masher always picks the first move, so a starter holding TACKLE that + -- meets a GASTLY is in a stalemate: Normal cannot touch Ghost, and the + -- Ghost's own status moves do no damage back -- the log shows lead HP + -- frozen for tens of thousands of frames. Running is the correct answer + -- and costs nothing on a wild fight; on a trainer it simply fails and we + -- go back to mashing. + -- Never try to escape a battle we are deliberately spending balls on: + -- the overrun handler was cutting catch attempts short, which is why five + -- throws at one SLOWPOKE ended with the bot fighting it instead. + if self.catchWanted and A.isWildBattle(self.g) + and self.catchWanted[A.enemySpecies(self.g)] then + self:say("battle: overrunning, but this is a catch -- keeping at it") + elseif not self:tryRun() then + self:say("battle: could not run (trainer?), continuing") + end + if overruns >= 3 then + self:say("battle: giving up on this fight") + return false + end + end + end + self:progress() + -- A wipe is not an error: the engine revives the party and warps to the + -- spawn point, so a blackout is really a slow free heal. It only becomes a + -- problem when it keeps happening in the same place, which is what the + -- per-map count is for -- and what tells a human WHERE the route is too hard + -- rather than just that it stalled. + if not A.partyHealthy(self.g) then + local where = mapBefore or "?" + self.deaths[where] = (self.deaths[where] or 0) + 1 + self:say(("battle: party wiped on %s (%d here)") + :format(tostring(where), self.deaths[where])) + end + self:say("battle: done") +end + +function Bot:deathsAt(mapId) + return self.deaths[mapId or ""] or 0 +end + +-- Escape a wild battle. Returns true once the battle is gone, false if it is +-- still up after a fair number of attempts -- which is what a trainer battle +-- looks like, since RUN is refused there. +function Bot:tryRun() + for _ = 1, 80 do + if not A.inBattle(self.g) then + self:progress() + return true + end + if A.partyMenuUp(self.g) then + if A.chooseHealthyPartyMon(self.g) then self:tap("a") else self:tap("b") end + self:progress() + end + + local phase = A.battlePhase(self.g) + + -- Catching rides inside the ordinary battle handler rather than a separate + -- one, because a wild encounter can start anywhere -- mid-walk, mid-travel + -- -- and every one of those paths already funnels into here. `catchWanted` + -- is set for the duration of a `catch` route row. + if phase == "menu" then + A.setBattleMenu(self.g, A.BATTLE_MENU_RUN) + self:tap("a") + elseif phase == "moves" then + self:tap("b") -- back out of the move list to the main menu + else + self:tap("a") -- a message is up; advance it + end + end + return not A.inBattle(self.g) +end + +-- --------------------------------------------------------------------------- +-- local pathfinding +-- --------------------------------------------------------------------------- +-- Dijkstra rather than plain BFS, because the wall memory is a COST and not a +-- wall: a cell the engine refused twice should be avoided if there is any other +-- way round and still used when there is not. + +-- src/world/gen2/Player.lua: a walk is 16 frames, a turn-in-place 4. +local TURN_FRAMES = 4 + +-- Map connections are keyed by COMPASS word ("west"); the pad is named by +-- SCREEN direction ("left"). Normalising at the boundary is deliberate: the +-- first run held a button called "west", which no input source has ever heard +-- of, so the bot walked at the east edge of New Bark Town waiting for Route 29 +-- to appear. Everything below this line speaks pad directions only. +local BUTTON_DIR = { + up = "up", down = "down", left = "left", right = "right", + north = "up", south = "down", west = "left", east = "right", +} + +function Bot.button(dir) return BUTTON_DIR[dir] end + +-- Can we get onto the water at all right now? Cached per attempt because +-- partyMoveUser walks the party and re-checks the badge every call, and the +-- planner asks about thousands of cells. +function Bot:canSurf() + if self.surfKnown == nil then + self.surfKnown = A.surfUser(self.g) ~= nil + end + return self.surfKnown +end + +function Bot:forgetSurf() self.surfKnown = nil end + +function Bot:passable(map, x, y, goalX, goalY) + if x == goalX and y == goalY then + -- The goal is exempt from the walkability test. Half the objectives in + -- the route are cells the player can never STAND on -- a door tile, an + -- item ball, an NPC -- and the caller either warps off it or only needs to + -- be adjacent. Reachability is what the search decides; what happens on + -- arrival is the action's business. + return map:inBounds(x, y) + end + if not A.walkable(self.g, map, x, y) then + -- Water is passable to a party that can SURF, even while we are still on + -- foot: the step onto it is what starts the surf, exactly as it does for a + -- player pressing A at the shore. Without this the planner drew the coast + -- as a wall, so Cianwood, Route 27, the Whirl Islands and the Dragon's Den + -- were all "unreachable" and only a teleport got the route past them. + if not (self:canSurf() and A.isWater(map, x, y)) then return false end + end + if A.npcAt(self.g, x, y) then return false end + -- A warp TILE is not a cell you pass through, it is a cell you LEAVE THE MAP + -- on. Planning a route across one produces a path that is a lie: the walk + -- ends somewhere else entirely, several steps before it meant to. That is + -- how the Route 46 gate trapped the bot -- it lands you on the warp back, so + -- every plan out of Route 46 stepped over the neighbouring warp tile and + -- returned to the gate, twice a second, until the loop guard fired. A warp + -- may still be a DESTINATION, which is the exemption above. + -- + -- The test is the TILE, not the warp_event coordinate: CheckWarpTile reads + -- the collision, so a warp_event on plain floor never fires. Ecruteak Gym is + -- the map that proves it -- several of its thirty hole coordinates are + -- ordinary floor, and they are the safe path between the pits, so refusing + -- all of them cut the gym in half and put Morty out of reach. + if A.isWarpTile(map, x, y) then return false end + return true +end + +-- Where a single press of `dir` from (x, y) comes to rest. +-- +-- Ice (COLL_ICE) keeps the player moving in the same direction until they land +-- on a non-ice cell or bump something -- CheckStandingOnIce / .CheckForced in +-- the engine. The planner's graph is therefore over REST positions, not over +-- every walkable cell: a press is one edge, and its endpoint is wherever the +-- slide stops. Off ice this is ordinary adjacency (one cell). +function Bot:slideRest(map, x, y, dir, goalX, goalY) + local d = DELTA[dir] + if not d then return nil end + local cx, cy = x + d[1], y + d[2] + -- GetMovementPermissions can veto the leave (standing on a side-wall tile) + -- or the entry (Gold's neighbour arm: no stepping DOWN onto an UP_WALL). + -- A refused step off a LEDGE tile is not a dead end but a two-cell hop + -- (.TryJump): Burned Tower B1F's landing pockets drain only this way. + if not A.stepPermitted(map, x, y, dir) + or not self:passable(map, cx, cy, goalX, goalY) then + local hop = A.ledgeFacings(map, x, y) + if hop and hop[dir] then + local hx, hy = x + d[1] * 2, y + d[2] * 2 + if self:passable(map, hx, hy, goalX, goalY) then + return hx, hy, 2 + end + end + return nil + end + local steps = 1 + while A.isIce(map, cx, cy) do + -- The slide re-runs the same permission test per cell, which is what + -- rests it on the last ice cell above an UP_WALL strip -- the rest + -- position Ice Path 1F's HM07 pocket is entered from. + if not A.stepPermitted(map, cx, cy, dir) then break end + local nx, ny = cx + d[1], cy + d[2] + if not self:passable(map, nx, ny, goalX, goalY) then break end + cx, cy, steps = nx, ny, steps + 1 + end + return cx, cy, steps +end + +-- Cheapest path from the player to (goalX, goalY) as a list of press +-- directions, or nil. Cost is cells travelled plus the wall memory's price. +-- On ice each press may cover several cells and only REST positions are nodes, +-- so a cell the player would slide past is not a place the path can ask to +-- stand -- which is what made every Ice Path boulder push report +-- "could not stand at (x,y) to push" when the planner still thought ice was +-- ordinary floor. +function Bot:planPath(goalX, goalY) + local sx, sy = A.pos(self.g) + if not sx then return nil end + return self:planPathFrom(sx, sy, goalX, goalY) +end + +-- The same search from an arbitrary start. enterWarp's step-off needs it: +-- next to a one-way door the two candidate cells are on DIFFERENT SIDES of +-- the map (Blackthorn's Ice Path exit has the cliff corridor above and the +-- hop into town below), and only a path check can say which one still +-- reaches the target. +function Bot:planPathFrom(sx, sy, goalX, goalY) + local map = A.map(self.g) + if not map then return nil end + local mapId = map.id + if not sx then return nil end + if sx == goalX and sy == goalY then return {} end + + local function key(x, y) return y * 4096 + x end + local dist = { [key(sx, sy)] = 0 } + local prev = {} + -- A binary heap is overkill for maps this size (the biggest Johto map is + -- well under 4k cells) and a linear scan keeps the code readable. + local open = { { x = sx, y = sy, d = 0 } } + local seen = {} + + while #open > 0 do + local bi, best = 1, open[1] + for i = 2, #open do + if open[i].d < best.d then bi, best = i, open[i] end + end + table.remove(open, bi) + local bk = key(best.x, best.y) + if not seen[bk] then + seen[bk] = true + if best.x == goalX and best.y == goalY then + local path, cx, cy = {}, goalX, goalY + while not (cx == sx and cy == sy) do + local step = prev[key(cx, cy)] + if not step then return nil end + table.insert(path, 1, step.dir) + cx, cy = step.x, step.y + end + return path + end + for dir, _ in pairs(DELTA) do + local nx, ny, steps = self:slideRest(map, best.x, best.y, dir, + goalX, goalY) + if nx then + -- Water is passable but not free: getting on it costs a field move + -- and a prompt, so a dry route of similar length should win. + local nd = best.d + steps + self:wallCost(mapId, nx, ny) + + ((not A.surfing(self.g) and A.isWater(map, nx, ny)) + and 3 or 0) + local nk = key(nx, ny) + if nd < (dist[nk] or math.huge) then + dist[nk] = nd + prev[nk] = { x = best.x, y = best.y, dir = dir } + open[#open + 1] = { x = nx, y = ny, d = nd } + end + end + end + end + end + return nil +end + +-- Turn in place to face `dir`, without stepping. +-- +-- Pressing a direction you are not already facing TURNS and does not step +-- (Player:tryMove:52 -- the turnArmed arm returns "turned" before the +-- collision check). A bot that treats that first press as a step reads the +-- turn as a refusal and learns a wall that is not there, so turning is its own +-- operation. turnArmed only re-arms on a frame with no direction held +-- (World:step:6427), which is why the release matters. +function Bot:face(dir) + if A.facing(self.g) == dir then return end + A.hold(self.g, dir) + self:wait(2) + A.releaseDirs(self.g) + self:wait(TURN_FRAMES + 4) -- let turnTimer expire and turnArmed re-arm +end + +-- Exactly one cell in `dir`. +-- +-- The subtlety that cost the first run: World:step starts the NEXT step in the +-- same frame the previous one lands (it falls through p:update() straight into +-- movePlayer with heldDir still set), so a held direction walks a whole +-- corridor until something blocks it. The first version of this held until +-- the cell changed and only then released -- which meant every "step" ran to +-- the far wall, and walkTo ping-ponged between the two ends of Mom's kitchen +-- forever. So: hold only long enough for movePlayer to fire once, release, +-- and let the in-flight step finish on its own. +-- +-- The engine's own verdict ("moved" / "blocked" / "edge") never reaches a +-- driver, so the observable equivalent is whether the cell changed. A step +-- that did not move is what feeds wall memory. +function Bot:stepDir(dir) + local mapId = A.mapId(self.g) + + self:face(dir) + if A.busy(self.g) or A.mapId(self.g) ~= mapId then + -- Something took the world between planning and stepping. Reporting it as + -- "moved" is right -- the caller must re-plan either way -- but it used to + -- be SILENT, and a walk whose every step bailed here looked in the log like + -- a walk that never happened: no refusals, no movement, 330 frames a try. + if A.mapId(self.g) == mapId then + self:say(("step %s deferred: %s"):format(dir, A.busyReason(self.g))) + end + return true + end + + local x0, y0 = A.pos(self.g) + + -- Stepping from the shore onto water is not a step, it is a field move. + -- World:useFieldMove reads the tile the player is FACING, so the face above + -- is already the argument; all that is left is to ask, answer the "Want to + -- SURF?" prompt, and let the queued script put us afloat. The step itself is + -- then the ordinary one below. + local map0 = A.map(self.g) + local d0 = DELTA[dir] + if map0 and not A.surfing(self.g) and self:canSurf() + and A.isWater(map0, x0 + d0[1], y0 + d0[2]) then + self:say(("surf: entering the water at (%d,%d)") + :format(x0 + d0[1], y0 + d0[2])) + if A.startSurf(self.g) then + self:wait(8) + self:clearDialogue({ "yes" }, 3000) + self:progress() + -- The surf script walks the player onto the water itself, so the cell has + -- already changed and there is nothing left to step. + local sx, sy = A.pos(self.g) + if sx ~= x0 or sy ~= y0 then return true end + end + end + + -- A whirlpool bars the step the way the shore bars a walk, and the way + -- through is HM06 (GLACIERBADGE gated): Script_UsedWhirlpool swaps the + -- facing block for plain water, after which the ordinary step below just + -- works. Route 27's crossing and the Dragon Fang pocket are both behind + -- one of these. + if map0 and A.surfing(self.g) + and A.isWhirlpool(map0, x0 + d0[1], y0 + d0[2]) + and A.whirlpoolUser(self.g) then + self:say(("whirlpool: clearing (%d,%d)"):format(x0 + d0[1], y0 + d0[2])) + self:face(dir) + if A.useWhirlpool(self.g) then + self:wait(8) + self:clearDialogue({ "yes" }, 3000) + self:progress() + end + end + + -- A waterfall is climbed by the field move, not by steps: + -- Script_UsedWaterfall forces UP one cell at a time until the player is off + -- the falls, so by the time it returns the position has jumped several + -- cells and the current plan is stale. Report moved and let the caller + -- re-plan from the top. (Coming DOWN rides the current automatically -- + -- .CheckTile -- so only the upward press needs the move.) + if map0 and dir == "up" and A.surfing(self.g) + and A.isWaterfall(map0, x0, y0 - 1) + and A.waterfallUser(self.g) then + self:say(("waterfall: climbing from (%d,%d)"):format(x0, y0)) + self:face(dir) + if A.useWaterfall(self.g) then + self:wait(8) + self:clearDialogue({ "yes" }, 6000) + for _ = 1, 900 do + if not A.moving(self.g) and not A.busy(self.g) then break end + self:wait(1) + end + local cx, cy = A.pos(self.g) + if cx ~= x0 or cy ~= y0 then + self:progress() + return true + end + end + end + + A.hold(self.g, dir) + self:wait(2) -- one fixed step under the hold is one + -- movePlayer call; a second is a no-op + -- because the player is already moving + A.releaseDirs(self.g) + + -- Let the press finish. Off ice that is one STEP_FRAMES landing. On ice + -- CheckForced keeps starting the next cell after release, so "not moving" + -- only becomes true at the REST position -- allow enough frames for a long + -- Ice Path corridor (a cell is 16 frames; 40 cells is well under this). + for _ = 1, 700 do + if A.busy(self.g) or A.mapId(self.g) ~= mapId then break end + if not A.moving(self.g) then break end + self:wait(1) + end + self:wait(1) + + local x1, y1 = A.pos(self.g) + local d = DELTA[dir] + local wantX, wantY = x0 + d[1], y0 + d[2] + local moved = A.mapId(self.g) ~= mapId or x1 ~= x0 or y1 ~= y0 + if A.mapId(self.g) ~= mapId then + self:progress() + return true + end + if not moved then + -- The engine refused the first cell. That is a real wall. + self:noteWall(mapId, wantX, wantY) + return false + end + -- A press that moved is a success even when it did not stop on the adjacent + -- cell: ice slides, and planPath already asked for the REST position. The + -- old one-cell check priced every ice corridor as a wall and made the Ice + -- Path unsolvable on foot. + -- + -- The remaining case is a script that PUT us somewhere else after a normal + -- step -- Route 32's Miracle Seed man at (18,8) follows the player, walks + -- them two cells back north, and re-runs on every pass. Pricing the cell + -- we were shoved off makes the parallel column at x=19 win next time. + -- A ledge hop is the other press that legitimately overshoots the adjacent + -- cell: .TryJump lands two cells out, and calling that "a script moved us" + -- priced the wall past every ledge and buried the hop under wall memory. + local hop = map0 and A.ledgeFacings(map0, x0, y0) + if hop and hop[dir] and x1 == x0 + d[1] * 2 and y1 == y0 + d[2] * 2 then + self:clearWall(mapId, x1, y1) + self:progress() + return true + end + if not (x1 == wantX and y1 == wantY) and not A.isIce(A.map(self.g), wantX, wantY) + and not A.isIce(A.map(self.g), x0, y0) then + self:noteWall(mapId, wantX, wantY) + self:say(("step %s from (%d,%d) ended at (%d,%d), not (%d,%d) -- a script " + .. "moved us"):format(dir, x0, y0, x1, y1, wantX, wantY)) + else + self:clearWall(mapId, x1, y1) + end + self:progress() + return true +end + +-- Walk to a cell on the current map. Re-plans after every step: NPCs move, +-- scripts fire, and a plan made six steps ago is a guess about a world that +-- has since changed. +function Bot:walkTo(goalX, goalY, opts) + opts = opts or {} + local map0 = A.mapId(self.g) + local lastX, lastY, stuckAttempts = nil, nil, 0 + for attempt = 1, (opts.attempts or 60) do + if A.busy(self.g) then + -- Sit out whatever took over -- but remember where we were standing when + -- it started, because a script that MOVES the player is the one kind of + -- obstacle the tile test can never see. + -- + -- Route 32's Miracle Seed man is the case that mattered. His coord event + -- at (18,8) follows the player, walks them two cells back north, and is + -- guarded by an item flag rather than a scene, so it re-runs on every + -- pass -- faithfully, since on the cart the way past is the parallel + -- column at x=19. The bot could not see any of that: the step onto + -- (18,8) succeeded, so the cell looked fine, and the shove happened later + -- while dialogue was being cleared. It re-planned the same path forever, + -- which left the only road to Azalea, Ilex Forest and Goldenrod + -- impassable and made every run reach that half of Johto by teleport. + -- + -- Pricing the cell we were shoved OFF is the general form of the fix, and + -- it prices only the specific tile that did it. Three notes rather than + -- one because arriving on the cell clears the memory, and the arrival is + -- exactly what triggers the shove. + local bx, by = A.pos(self.g) + local bmap = A.mapId(self.g) + local bmapObj = A.map(self.g) + local leftWarp = bmapObj and bx and A.isWarpTile(bmapObj, bx, by) + self:say(("walk: waiting on %s"):format(A.busyReason(self.g))) + self:clearDialogue(opts.answers) + local ax, ay = A.pos(self.g) + if bx and ax and bmap == A.mapId(self.g) and (ax ~= bx or ay ~= by) then + -- Same-map warps (GOLDENROD_UNDERGROUND's basement door at (18,6) -> + -- (21,31), and the pair back) change the cell but not the map id. + -- Treating that as a Miracle-Seed-style shove priced the door as a + -- wall, so every later hop onto it "did not take" and the switch-room + -- region-1 travel fell back to TELEPORT. + if leftWarp then + self:say(("walk: same-map warp moved us off (%d,%d) to (%d,%d)") + :format(bx, by, ax, ay)) + if opts.stopOnMapChange then return true end + else + self:say(("walk: a script moved us off (%d,%d) to (%d,%d) -- avoiding it") + :format(bx, by, ax, ay)) + for _ = 1, 3 do self:noteWall(bmap, bx, by) end + end + end + end + -- Leaving the map ends this walk either way. It is a SUCCESS when the + -- caller was walking into a warp or a seam, and a failure otherwise -- and + -- "otherwise" is usually a blackout, which teleports the player to their + -- spawn point mid-route. Without this the planner kept solving for Route + -- 29 coordinates while stood in the bedroom and logged "no path" forever. + if A.mapId(self.g) ~= map0 then + if opts.stopOnMapChange then return true end + self:say(("walk abandoned: left %s for %s") + :format(tostring(map0), tostring(A.mapId(self.g)))) + return false + end + local x, y = A.pos(self.g) + if not x then return false end + if x == goalX and y == goalY then return true end + local path = self:planPath(goalX, goalY) + if not path then + -- Boxed in by somebody who is about to walk away. + -- + -- NPCs are priced as walls, which is right for planning and wrong as a + -- verdict: they MOVE. A door tile makes it fatal, because a door is set + -- into a building and has exactly one cell in front of it -- so one + -- wandering NPC standing there leaves the player with no path at all. + -- Mahogany Gym is the case: the bot stepped out onto warp 3 at (6,13), + -- an NPC was on (6,14), and every route row for the next 400k frames + -- failed. The run had seven badges at the time. + -- + -- So before believing "no path", check whether the only thing in the way + -- is a person, and if so stand still and ask again. This is the same + -- reasoning ops.talk already uses on a failed approach. + local map = A.map(self.g) + local blockedByNpc = false + if map then + for _, d in pairs(DELTA) do + local nx, ny = x + d[1], y + d[2] + if A.npcAt(self.g, nx, ny) and A.walkable(self.g, map, nx, ny) then + blockedByNpc = true + end + end + end + if blockedByNpc and (opts.npcWaits or 0) < 4 then + self:say(("walkTo (%d,%d): boxed in at (%d,%d) by an NPC -- waiting") + :format(goalX, goalY, x, y)) + self:wait(60) + if A.busy(self.g) then self:clearDialogue(opts.answers) end + local retry = {} + for k, v in pairs(opts) do retry[k] = v end + retry.npcWaits = (opts.npcWaits or 0) + 1 + return self:walkTo(goalX, goalY, retry) + end + self:say(("walkTo (%d,%d): no path from (%d,%d) on %s") + :format(goalX, goalY, x, y, tostring(A.mapId(self.g)))) + return false + end + if #path == 0 then return true end + + -- Give up early when an attempt ends exactly where it began. Re-planning + -- is only worth doing if something changed, and a plan that cannot take its + -- first step will produce the same plan forever: Route 32's Pokecenter is + -- 98 cells away past a one-way drop, and the bot spent ~800k frames -- + -- most of a run -- re-deriving that same 98-step path from the same cell. + -- Failing in a few hundred frames instead is what makes the whole route + -- finish, and it is logged rather than silently capped. + if x == lastX and y == lastY then + stuckAttempts = stuckAttempts + 1 + if stuckAttempts >= 3 then + self:say(("walkTo (%d,%d): no progress from (%d,%d) in %d attempts") + :format(goalX, goalY, x, y, stuckAttempts)) + return false + end + else + stuckAttempts = 0 + end + lastX, lastY = x, y + + self:say(("walk (%d,%d)->(%d,%d) %d steps [try %d] %s") + :format(x, y, goalX, goalY, #path, attempt, + table.concat(path, "", 1, math.min(8, #path)):gsub("up", "^") + :gsub("down", "v"):gsub("left", "<"):gsub("right", ">"))) + -- Walk the plan, but abandon it the moment the world disagrees: a warp, a + -- trip-wire script or a trainer's sight line all invalidate it. + for _, dir in ipairs(path) do + if A.busy(self.g) or A.mapId(self.g) ~= map0 then break end + if not self:stepDir(dir) then + -- Name the cell the engine refused. Wall memory alone is silent, so a + -- walk that cannot start looked identical in the log to one that had no + -- path at all -- and the two have completely different causes (a tile + -- the static test thinks is walkable versus a genuine dead end). + local bx, by = A.pos(self.g) + local d = DELTA[dir] + self:say(("step %s refused: (%d,%d) -> (%d,%d) on %s%s") + :format(dir, bx, by, bx + d[1], by + d[2], tostring(map0), + A.npcAt(self.g, bx + d[1], by + d[2]) and " [npc]" or "")) + break + end + local cx, cy = A.pos(self.g) + if cx == goalX and cy == goalY then break end + end + if opts.stopOnMapChange and A.mapId(self.g) ~= map0 then return true end + end + return false +end + +-- Stand next to (x,y) and face it. Used by every talk / item-ball / hidden +-- item objective, none of which can stand on their target. +function Bot:approachAndFace(x, y, allowed) + local map = A.map(self.g) + if not map then return false end + -- `allowed` restricts which way we may be FACING when we press A. Normally + -- irrelevant, but the Ilex Forest Farfetch'd chase branches on it: each + -- FarfetchdPositionN script scalls FarfetchdCryAndCheckFacing, and facing the + -- wrong way sends the bird BACKWARDS round the loop instead of onwards, so a + -- bot that approaches from whichever side is nearest can herd forever. + local allow + if allowed then + allow = {} + for _, dir in ipairs(allowed) do allow[dir] = true end + end + + local best + local function consider(sx, sy, dir) + if allow and not allow[dir] then return end + -- Same water rule as Bot:passable / borderStandable: a stand cell on the + -- lake is legal once the party can SURF, even while we are still on the + -- shore. A.walkable alone refuses water until FieldMoves.isSurfing is + -- already true, which made every water NPC (the Red Gyarados at + -- LAKE_OF_RAGE 18,22 is the one that matters) report "nowhere to stand" + -- from land -- so 10.33 never started the fight, Lance never appeared, + -- and the Mahogany Mart staircase scene never armed. + if not A.walkable(self.g, map, sx, sy) + and not (self:canSurf() and A.isWater(map, sx, sy)) then + return + end + if A.npcAt(self.g, sx, sy) then return end + local path = self:planPath(sx, sy) + if path and (not best or #path < best.len) then + best = { x = sx, y = sy, dir = dir, len = #path } + end + end + + for dir, d in pairs(DELTA) do + -- Directly alongside: stand here, face `dir` to look at (x,y). + consider(x - d[1], y - d[2], dir) + -- ...or two cells back across a COUNTER. A Pokecenter nurse and a Mart + -- clerk have no walkable neighbour at all: the tile in front of them is the + -- counter, and the cart reaches them by doubling an A press's range over + -- one (CheckFacingObject). Without this the bot decided a Pokecenter had + -- "nowhere to stand" and could never heal. + local mid = { x - d[1], y - d[2] } + if map:inBounds(mid[1], mid[2]) + and A.isCounter(map, mid[1], mid[2]) then + consider(x - d[1] * 2, y - d[2] * 2, dir) + end + end + if not best then + local px, py = A.pos(self.g) + self:say(("approach (%d,%d): nowhere to stand (at %s %d,%d surf=%s size=%d)") + :format(x, y, tostring(A.mapId(self.g)), px or -1, py or -1, + tostring(self:canSurf()), self:regionSize(256))) + return false + end + if not self:walkTo(best.x, best.y) then return false end + self:face(best.dir) + return true +end + +-- Stand on a warp cell and actually go through it. +-- +-- Doors, staircases, caves and panels warp on arrival, so walking on is the +-- whole job. A CARPET does not: World:checkCarpetWhileStanding wants the +-- player stopped on the tile with that carpet's own direction held, and until +-- it gets that it will happily let the bot stand in the doorway all day. The +-- tile itself says which kind it is, so ask it rather than guess -- the first +-- run guessed "hold whatever we are facing", which walked straight back off +-- the mat and ping-ponged either side of the front door. +function Bot:enterWarp(x, y, dest) + local from = A.mapId(self.g) + local startX, startY = A.pos(self.g) + + -- Get off the warp we arrived on before aiming at a different one. + -- + -- A ladder drops you ONTO a warp tile, and the arrival cooldown holds it + -- until you step away. Walking straight to another warp from there means + -- the first step is off one warp and, in a tight room, often onto a second + -- -- which fires, and puts us back where we started. The Olivine + -- lighthouse's 3F pocket is seven cells with three warps in it, and the bot + -- spent every attempt bouncing between 3F and 4F: 21 hops, nothing gained. + -- Standing on plain floor first costs one step and makes the walk ordinary. + do + local map = A.map(self.g) + local px, py = A.pos(self.g) + if map and px and A.isWarpTile(map, px, py) + and not (px == x and py == y) then + -- Prefer the side of the door the TARGET is on. Next to a one-way + -- passage the candidates are not interchangeable: coming out of the Ice + -- Path onto Blackthorn's cliff, UP is the corridor that cannot reach + -- town and DOWN is the ledge hop that can, and stepping off the wrong + -- way turned a correct region plan into "warp not reachable from here". + local fallback + local chosen + for dir, d in pairs(DELTA) do + local nx, ny = px + d[1], py + d[2] + if map:inBounds(nx, ny) and A.walkable(self.g, map, nx, ny) + and not A.isWarpTile(map, nx, ny) + and not A.npcAt(self.g, nx, ny) then + fallback = fallback or dir + if self:planPathFrom(nx, ny, x, y) then chosen = dir break end + end + end + local dir = chosen or fallback + if dir then + self:say(("warp: stepping off the arrival tile (%d,%d) first") + :format(px, py)) + self:stepDir(dir) + end + if A.mapId(self.g) ~= from then return false end + end + end + -- Unreachable from here is a fact, not a failure to keep retrying: inside a + -- tower it means this ladder is in another region of the floor. But an NPC + -- parked in a one-wide corridor is a fact that can change -- an engaged + -- trainer stands wherever the fight happened, and on Radio Tower 2F that + -- was the only lane to the stairs -- so wait a few beats before giving up, + -- the same way walkTo waits when it is boxed in. + local plannable = self:planPath(x, y) + for _ = 1, 4 do + if plannable then break end + self:wait(60) + plannable = self:planPath(x, y) + end + if not plannable then + self:say(("warp (%d,%d) on %s is not reachable from here") + :format(x, y, tostring(from))) + return false + end + self:walkTo(x, y, { stopOnMapChange = true }) + if A.mapId(self.g) ~= from then return A.mapId(self.g) == (dest or A.mapId(self.g)) end + do + -- walkTo returns on a same-map warp via stopOnMapChange; catch it here + -- before the "already standing on it" re-arm walks us back through the + -- door the other way. + local nx, ny = A.pos(self.g) + if nx and (nx ~= x or ny ~= y) and (dest == nil or dest == from) + and (nx ~= startX or ny ~= startY) then + self:say(("warp (%d,%d): same-map warp landed at (%d,%d)") + :format(x, y, nx, ny)) + self:clearDialogue() + return true + end + end + + self:wait(12) -- an immediate warp takes on arrival + if A.mapId(self.g) ~= from then + self:clearDialogue() + return dest == nil or A.mapId(self.g) == dest + end + + -- Standing ON the tile already and not warping means the arrival cooldown is + -- holding it: World:clearWarpCooldownIfLeft only releases once the player has + -- stepped OFF the warp they arrived on, which is what stops a door bouncing + -- you straight back where you came from. A bot that walks "to" a cell it is + -- already on moves zero steps, so it waits on a warp that will never fire -- + -- Ilex Forest's north door did exactly this, forever. Step off and back on. + local px, py = A.pos(self.g) + if px == x and py == y then + self:say(("warp (%d,%d): already standing on it, stepping off to re-arm") + :format(x, y)) + for _, dir in ipairs(A.DIRS) do + if self:stepDir(dir) then break end + end + if A.mapId(self.g) ~= from then + self:clearDialogue() + return dest == nil or A.mapId(self.g) == dest + end + self:walkTo(x, y, { stopOnMapChange = true }) + self:wait(12) + if A.mapId(self.g) ~= from then + self:clearDialogue() + return dest == nil or A.mapId(self.g) == dest + end + end + + local map = A.map(self.g) + local want = map and A.carpetDir(map, x, y) + -- The tile's own answer first, then the compass, because a mis-decoded + -- collision should cost a few frames rather than the whole route. Built by + -- append rather than as a literal: `{ want, "down", ... }` with a nil `want` + -- is an array whose first element is nil, and ipairs then walks NONE of it -- + -- which quietly disabled every retry on any tile that is not a carpet. + local tries = {} + if want then tries[#tries + 1] = want end + for _, dir in ipairs({ "down", "up", "left", "right" }) do + tries[#tries + 1] = dir + end + for _, dir in ipairs(tries) do + if dir then + -- Must be standing still ON the cell: a step in progress fails + -- checkCarpetWhileStanding's first test. + local cx, cy = A.pos(self.g) + if cx ~= x or cy ~= y then + self:walkTo(x, y, { stopOnMapChange = true }) + if A.mapId(self.g) ~= from then break end + end + A.hold(self.g, dir) + self:wait(TURN_FRAMES + 8) -- turn first if needed, then the hold + -- is seen while stationary + A.releaseDirs(self.g) + self:wait(10) + if A.mapId(self.g) ~= from then break end + end + end + + self:clearDialogue() + local now = A.mapId(self.g) + if now ~= from then + return dest == nil or now == dest + end + -- Same-map warp: the basement door on GOLDENROD_UNDERGROUND is a warp + -- whose destination is the same map id at a different cell ((18,6) <-> + -- (21,31)). The map-id test above cannot see it, and calling that a miss + -- made every region-1 travel into the switch room TELEPORT after the key + -- was used -- the hop had already landed in region 5, then enterWarp + -- reported failure and the planner tried the door again from the wrong half. + local nx, ny = A.pos(self.g) + if nx and (nx ~= x or ny ~= y) + and (dest == nil or dest == from) + and (nx ~= startX or ny ~= startY or startX == x) then + self:say(("warp (%d,%d): same-map warp landed at (%d,%d)") + :format(x, y, nx, ny)) + return true + end + self:say(("warp (%d,%d) on %s did not take"):format(x, y, tostring(from))) + return false +end + +-- --------------------------------------------------------------------------- +-- the map graph +-- --------------------------------------------------------------------------- +-- Warps and connections form a directed graph over map ids. The route only +-- names the map an objective lives on, so this is what turns "be on +-- CIANWOOD_CITY" into the twelve hops that actually get there. + +function Bot:mapDefs() + local w = A.world(self.g) + return (w and w.maps) or {} +end + +-- Does this map's own border on `dir` have a single cell we could stand on? +-- +-- Twenty-one of Johto and Kanto's map connections do not: New Bark Town's east +-- edge onto Route 27, Route 41's west edge onto Cianwood, Azalea Town's west +-- edge onto Route 34 and eighteen more. Some are water (crossable once we can +-- SURF, which is why the test asks the CURRENT movement mode rather than a +-- fixed one) and some are simply scenery -- the real way through is a gate +-- building next to them. Either way the connection exists in the map data, so +-- the planner kept choosing it, crossEdge kept spending a thousand frames +-- pushing at a wall, and the seam price only made it the second choice rather +-- than no choice. Asking the map costs one row scan and settles it. +-- +-- tools/goldwalk/mapgraph.lua's `audit` lists them all from the cache. +function Bot:borderStandable(mapId, dir) + local key = ("%s#%s#%s"):format(tostring(mapId), tostring(dir), + A.surfing(self.g) and "surf" or "foot") + local cached = self.borders[key] + if cached ~= nil then return cached end + local map = A.map(self.g) + if not (map and map.id == mapId) then return true end -- not here; cannot + -- ask, so allow + local w, h = map.widthCells, map.heightCells + local ok = false + local function look(x, y) + if ok then return end + if A.walkable(self.g, map, x, y) + or (self:canSurf() and A.isWater(map, x, y)) then + ok = true + end + end + if dir == "up" then + for x = 0, w - 1 do look(x, 0) end + elseif dir == "down" then + for x = 0, w - 1 do look(x, h - 1) end + elseif dir == "left" then + for y = 0, h - 1 do look(0, y) end + else + for y = 0, h - 1 do look(w - 1, y) end + end + self.borders[key] = ok + return ok +end + +-- Every way off `mapId`: each warp cell, and each connected edge. +function Bot:exitsOf(mapId) + local defs = self:mapDefs() + local def = defs[mapId] + if not def then return {} end + local out = {} + -- On the map we are STANDING on we can ask the tile whether a warp would + -- actually fire, and drop the ones that cannot. Half the warp_events in a + -- multi-floor interior sit on plain floor: they are the landing spot of a + -- ladder on the far side, and CheckWarpTile never fires on them. The + -- Olivine lighthouse's 3F pocket has two of those and one real ladder, and + -- the planner kept picking the phantoms. A remote map's collision is not + -- available here -- that is what map_regions.lua is generated for. + local liveMap = A.map(self.g) + local canAsk = liveMap ~= nil and liveMap.id == mapId + for i, warp in ipairs(def.warps or {}) do + if warp.destMap and defs[warp.destMap] + and not (canAsk and not A.isWarpTile(liveMap, warp.x, warp.y)) then + out[#out + 1] = { kind = "warp", to = warp.destMap, + x = warp.x, y = warp.y, index = i } + end + end + for dir, conn in pairs(def.connections or {}) do + local to = conn.mapId + local button = BUTTON_DIR[dir] + if to and defs[to] and button then + out[#out + 1] = { kind = "edge", to = to, dir = button } + end + end + return out +end + +-- A single exit's identity, so one bad ladder can be priced without +-- condemning every route between the same two maps. +local function exitKey(mapId, exit) + return ("%s#%s"):format(tostring(mapId), + exit.kind == "warp" and ("w" .. exit.index) or ("e" .. exit.dir)) +end + +-- Is this map a cul-de-sac -- a house, a shop, a speech room -- whose every +-- exit leads to the same one map? +-- +-- A map like that can never be an intermediate step on a route between two +-- OTHER maps, but the planner had no way to know that, and the cost of not +-- knowing was the single worst behaviour in the log: after a genuine seam +-- failed, the per-trip exit bans left the real roads out of a town banned, +-- the cheapest remaining "path" ran through a building, and the bot toured +-- every house in Cherrygrove and Violet -- 61 hops, 50k frames -- before the +-- loop guard gave up and a teleport covered for it. Excluding cul-de-sacs +-- from EXPANSION (never from being the target) removes the whole class. +function Bot:isCulDeSac(mapId) + local cached = self.culDeSac[mapId] + if cached ~= nil then return cached end + local only, count = nil, 0 + for _, exit in ipairs(self:exitsOf(mapId)) do + if exit.to ~= mapId then + if only == nil then only = exit.to end + if exit.to ~= only then + self.culDeSac[mapId] = false + return false + end + count = count + 1 + end + end + -- No exits at all is not a cul-de-sac, it is a bug in the extract; treat it + -- as impassable either way. + local verdict = count > 0 + self.culDeSac[mapId] = verdict + return verdict +end + +function Bot:noteBadExit(mapId, exit) + local k = exitKey(mapId, exit) + self.badExits[k] = (self.badExits[k] or 0) + 1 +end + +function Bot:exitCost(mapId, exit) + -- 40 rather than 25: a failed exit costs a thousand frames to discover, and + -- at 25 a two-hop alternative never beat re-trying the same broken ladder -- + -- the log has Route 35's south edge chosen, failed and re-chosen three times + -- in a row before the price finally added up. + return (self.badExits[exitKey(mapId, exit)] or 0) * 40 +end + +-- Cheapest sequence of hops from the current map to `target`. +-- +-- Two things here are not the obvious map-to-map BFS, and Sprout Tower is why. +-- Its floors are several DISCONNECTED regions joined only through other floors: +-- arriving on 2F by the west ladder leaves you walled off from the ladder up to +-- 3F, which is reachable only from 2F's other entrance. A graph whose nodes +-- are maps cannot see that, so it kept choosing an exit it could not walk to +-- and the planner span. The Olivine Lighthouse -- six floors of exactly this +-- shape, and the actual destination of this route -- would have done the same. +-- +-- 1. Exits of the CURRENT map are filtered by whether their cell is +-- reachable from where the player is standing right now. That is the +-- region information the map-node graph lacks, and it is free: we can +-- only ever be standing in one region. +-- 2. The current map is NOT marked visited, so a route may legitimately +-- leave and come back -- 2F -> 1F -> 2F is how you change region, and a +-- plain BFS would refuse to consider it. +-- +-- Per-exit pricing then makes repeated attempts try a DIFFERENT ladder rather +-- than the same one, so the search converges instead of oscillating. +-- How much this trip has already seen of a map. +-- +-- This replaced a per-trip exit BAN, and the replacement is the fix for the +-- worst navigation failure in the log. The ban's intent was right -- stop the +-- bot bouncing between a gate's two warps forever -- but its unit was wrong: it +-- banned exits that had WORKED, and a long trip that backtracks after a failed +-- seam legitimately re-uses the road it came in on. After six maps most of the +-- real roads out of a town were banned, the cheapest surviving "route" ran +-- through the buildings, and the bot toured every house in Cherrygrove and +-- Violet before the loop guard gave up. +-- +-- Pricing the MAP instead says the true thing: coming back somewhere we have +-- already been is usually wrong and occasionally necessary. Twelve is more +-- than any detour worth taking around a working road and less than the cost of +-- the tour. +function Bot:visitCost(visits, mapId) + -- Four, not twelve. A revisit has to cost LESS than the detour that avoids + -- it or the planner buys the detour: stood in Azalea Town's east pocket with + -- Ilex Forest two warps away through the town, a 12-per-visit price made + -- "back into Azalea" (1 hop + 12) look worse than "north through Route 32, + -- Goldenrod and Route 34" (7 hops), and the bot walked half of Johto rather + -- than step back through a door it had just used. Repeat visits still add + -- up, which is all the ping-pong guard needs. + return ((visits and visits[mapId]) or 0) * 4 +end + +-- Which region of the current map are we standing in? +-- +-- Flood fill from the player with warp cells treated as holes -- the same rule +-- the generator used -- then see which of the map's recorded representative +-- cells the fill contains. Standing ON a warp belongs to no region, so the +-- fill starts from the neighbours instead and may legitimately answer with +-- several: that is what coming out of Union Cave onto Route 33's (11,9) does, +-- and it is the whole reason the southern half of that route is reachable at +-- all. +function Bot:currentRegions() + local map = A.map(self.g) + local mapId = A.mapId(self.g) + local list = REGIONS[mapId or ""] + local px, py = A.pos(self.g) + if not (map and list and px) then return nil end + + -- The same warp test the generator used, or the fill draws a different map + -- from the one the graph was built on and matches no region at all -- at + -- which case planTravelRegions silently returns nil and the whole + -- region-aware planner is off. + local starts = {} + if not A.isWarpTile(map, px, py) then + starts[#starts + 1] = { px, py } + else + for _, d in pairs(DELTA) do + local nx, ny = px + d[1], py + d[2] + if map:inBounds(nx, ny) and A.walkable(self.g, map, nx, ny) + and not A.isWarpTile(map, nx, ny) then + starts[#starts + 1] = { nx, ny } + end + end + end + if #starts == 0 then return nil end + + -- The generator's regions are strongly connected components over DIRECTED + -- movement (ledge hops and Gold's one-way walls -- see mapgraph.lua), so + -- membership is mutual reachability: the fill runs once forward and once + -- over reversed edges, and a representative cell has to appear in both. + -- A forward-only fill would claim every region the player can DRAIN into + -- (hop down into and never climb back out of), and planTravelRegions would + -- then seed the search from regions the player is not standing in. + local function cellOk(x, y) + return map:inBounds(x, y) and A.walkable(self.g, map, x, y) + and not A.isWarpTile(map, x, y) + end + -- Successor cells of (x, y): permitted steps, else ledge hops. + local function stepsFrom(x, y) + local out = {} + local hop = A.ledgeFacings(map, x, y) + for dir, d in pairs(DELTA) do + local nx, ny = x + d[1], y + d[2] + if A.stepPermitted(map, x, y, dir) and cellOk(nx, ny) then + out[#out + 1] = { nx, ny } + elseif hop and hop[dir] then + local hx, hy = x + d[1] * 2, y + d[2] * 2 + if cellOk(hx, hy) then out[#out + 1] = { hx, hy } end + end + end + return out + end + local function stepsOnto(x, y, tx, ty) + if not cellOk(x, y) then return false end + for _, s in ipairs(stepsFrom(x, y)) do + if s[1] == tx and s[2] == ty then return true end + end + return false + end + + local function fill(forward) + local seen = {} + local queue, head = {}, 1 + for _, s in ipairs(starts) do + seen[s[2] * 4096 + s[1]] = true + queue[#queue + 1] = s + end + while head <= #queue do + local c = queue[head]; head = head + 1 + if forward then + for _, s in ipairs(stepsFrom(c[1], c[2])) do + local k = s[2] * 4096 + s[1] + if not seen[k] then + seen[k] = true + queue[#queue + 1] = { s[1], s[2] } + end + end + else + for _, d in pairs(DELTA) do + for dist = 1, 2 do + local px2, py2 = c[1] + d[1] * dist, c[2] + d[2] * dist + local k = py2 * 4096 + px2 + if not seen[k] and stepsOnto(px2, py2, c[1], c[2]) then + seen[k] = true + queue[#queue + 1] = { px2, py2 } + end + end + end + end + end + return seen + end + + local fwd = fill(true) + local bwd = fill(false) + local out = {} + for index, r in ipairs(list) do + local k = r.y * 4096 + r.x + if fwd[k] and bwd[k] then out[#out + 1] = index end + end + return (#out > 0) and out or nil +end + +-- Cheapest sequence of hops from where we stand to `target`, over the REGION +-- graph. +-- +-- The map-id graph this replaced could not express the one fact that decides +-- most of Johto's routing: a map is not a place. Route 33's north strip and +-- its south strip touch only at the Union Cave entrance, so "Route 33 connects +-- west to Azalea Town" is true of the south half and false of the north half; +-- Azalea Town's east border is shared by the town and by a 27-cell dead end. +-- Planning over map ids, the bot walked from Route 32 down Route 33, crossed +-- west, landed in the dead end, and then -- because the graph insisted it was +-- standing in Azalea Town -- either toured Johto looking for a way out or gave +-- up and teleported. Over regions the same query answers "through Union +-- Cave", which is what the walkthrough says. +-- +-- The static graph is a prior, not gospel: CUT trees, boulders and Strength +-- can merge regions at run time, and it knows nothing of NPCs. So a plan that +-- fails still falls back to the map-id search below, and the live prices +-- (seams, bad exits, visits) apply to both. +function Bot:planTravelRegions(target, visits, wantRegion) + local from = A.mapId(self.g) + if not (from and REGIONS[from] and REGIONS[target]) then return nil end + local here = self:currentRegions() + if not here then + -- Falling back to the map-id planner is a real loss of accuracy on any map + -- with more than one region, so say when it happens rather than degrade + -- silently: the lighthouse bounce looked like a planner bug and was really + -- this. + local px, py = A.pos(self.g) + self:say(("plan: no region match on %s at (%s,%s) -- using the map graph") + :format(tostring(from), tostring(px), tostring(py))) + return nil + end + -- Already there is only "already there" if the REGION matches too. + -- + -- This returned an empty hop list whenever the map ids matched, which made a + -- region-targeted travel a no-op the moment the bot was standing anywhere on + -- the right map: row 11.15r reported "ok" in zero frames from the wrong half + -- of TEAM_ROCKET_BASE_B3F, and every row behind it went back to failing. When + -- the region is wrong the answer is a real route -- out of this region and + -- back in by the other door -- so fall through and plan one. + if from == target then + if not wantRegion then return {} end + for _, r in ipairs(here) do + if r == wantRegion then return {} end + end + end + + local function key(map, r) return ("%s#%d"):format(map, r) end + local dist, open = {}, {} + for _, r in ipairs(here) do + dist[key(from, r)] = 0 + open[#open + 1] = { map = from, region = r, d = 0, parent = nil } + end + + local seen = {} + while #open > 0 do + local bi, best = 1, open[1] + for i = 2, #open do + if open[i].d < best.d then bi, best = i, open[i] end + end + table.remove(open, bi) + local bk = key(best.map, best.region) + if not seen[bk] then + seen[bk] = true + if best.map == target + and (not wantRegion or best.region == wantRegion) then + local hops, node = {}, best + while node and node.hop do + table.insert(hops, 1, node.hop) + node = node.parent + end + return hops + end + local regs = REGIONS[best.map] + local node = regs and regs[best.region] + -- Do NOT apply map-level isCulDeSac here. GOLDENROD_UNDERGROUND's every + -- external warp lands on SWITCH_ROOM_ENTRANCES, so the map-id test calls + -- it a cul-de-sac -- but its regions are the only bridge between that + -- neighbour's disconnected halves (basement warp 6 -> switch region 1; + -- salon warps 1/2 -> switch regions 10/9). Filtering it made every + -- region-targeted travel into the switch room fall back to the map-id + -- planner and arrive in the wrong half, with Switch1/2/3 then reporting + -- "nowhere to stand". + if node then + for _, link in ipairs(node.exits) do + local exit = (link.k == "w") + and { kind = "warp", index = link.i, x = link.x, y = link.y, + to = link.to } + or { kind = "edge", dir = BUTTON_DIR[link.d], to = link.to } + if exit.kind ~= "edge" or exit.dir then + local nd = best.d + 1 + self:visitCost(visits, link.to) + + self:seamCost(best.map, link.to) + + self:exitCost(best.map, exit) + local nk = key(link.to, link.r) + if nd < (dist[nk] or math.huge) then + dist[nk] = nd + open[#open + 1] = { map = link.to, region = link.r, d = nd, + parent = best, + hop = { from = best.map, exit = exit } } + end + end + end + end + end + end + return nil +end + +function Bot:planTravel(target, avoid, visits) + local from = A.mapId(self.g) + if not from then return nil end + if from == target then return {} end + + -- Nodes carry their own parent chain rather than a prev[mapId] table. + -- That table was the bug that made this whole fix look broken: once the + -- start map may appear in the MIDDLE of a route (2F -> 1F -> 2F), walking + -- the chain back "until we reach the start" stops at that middle occurrence + -- and hands back only the tail -- which is precisely the unreachable hop the + -- detour existed to avoid. Following node parents cannot truncate. + local dist = {} + local open = {} + + -- Seed with the exits we can actually reach from where we are standing. + local px, py = A.pos(self.g) + local reachable = 0 + for _, exit in ipairs(self:exitsOf(from)) do + local ok = true + if exit.kind == "warp" then + -- Only WARP exits are reachability-checked here. Doing the same for edge + -- exits is tempting and was tried: it costs up to a dozen full searches + -- per edge per plan, and travelTo re-plans after every hop, so the run + -- went from 280k frames to 1.36M and got further from Goldenrod, not + -- closer. crossEdge probes the border itself, which is the cheap place + -- for that question. + ok = self:planPath(exit.x, exit.y) ~= nil + -- Never step straight back through the warp we just arrived on. A gate + -- drops the player onto the tile that leads back, so "walk to that tile" + -- is a no-op followed by an immediate return -- the bot ping-ponged + -- between Route 46 and its gate this way, twice a second, forever. + -- Backtracking is still allowed, just not from the doormat. + if avoid and exit.to == avoid and exit.x == px and exit.y == py then + ok = false + end + elseif not self:borderStandable(from, exit.dir) then + -- A connection whose near border we could not stand on anywhere. Not a + -- flaky seam to be priced: there is nothing to walk off. + ok = false + end + if ok then + reachable = reachable + 1 + local d = 1 + self:visitCost(visits, exit.to) + + self:seamCost(from, exit.to) + + self:exitCost(from, exit) + -- Deliberately NOT penalising "the map we just came from" in general. + -- Tried it (+50) and it was a bad trade: Johto's routes are a chain, so + -- almost every real path revisits its predecessor, and the planner + -- answered by taking enormous detours -- the bot ended up back on Route + -- 29 wiping 88 times instead of walking to Violet City. The narrow + -- standing-on-the-doormat exclusion above is enough to stop the tight + -- bounce, and travelTo's visits guard catches the looser ones. + if d < (dist[exit.to] or math.huge) then + dist[exit.to] = d + open[#open + 1] = { id = exit.to, d = d, parent = nil, + hop = { from = from, exit = exit } } + end + end + end + if reachable == 0 then + -- Walled in. Nothing to plan; the caller reports it rather than spinning. + return nil + end + + local seen = {} + while #open > 0 do + local bi, best = 1, open[1] + for i = 2, #open do + if open[i].d < best.d then bi, best = i, open[i] end + end + table.remove(open, bi) + if not seen[best.id] then + seen[best.id] = true + if best.id == target then + local hops, node = {}, best + while node do + table.insert(hops, 1, node.hop) + node = node.parent + end + return hops + end + -- Never route THROUGH a cul-de-sac; it may only ever be a destination. + if not (self:isCulDeSac(best.id) and best.id ~= target) then + for _, exit in ipairs(self:exitsOf(best.id)) do + -- A seam is priced by how often it has failed, so a flaky border cell + -- costs more than a clean one but never becomes impassable. + local nd = best.d + 1 + self:visitCost(visits, exit.to) + + self:seamCost(best.id, exit.to) + + self:exitCost(best.id, exit) + if nd < (dist[exit.to] or math.huge) then + dist[exit.to] = nd + open[#open + 1] = { id = exit.to, d = nd, parent = best, + hop = { from = best.id, exit = exit } } + end + end + end + end + end + return nil +end + +-- Take one hop: walk onto the warp cell, or walk off the connected edge. +function Bot:takeHop(hop) + local from = A.mapId(self.g) + local exit = hop.exit + if exit.kind == "warp" then + self:say(("hop %s -> %s via warp %d (%d,%d)") + :format(from, exit.to, exit.index, exit.x, exit.y)) + self:enterWarp(exit.x, exit.y, exit.to) + else + self:say(("hop %s -> %s via %s edge"):format(from, exit.to, exit.dir)) + self:crossEdge(exit.dir, exit.to) + end + self:clearDialogue() + local arrived = A.mapId(self.g) + if arrived == exit.to then + self:clearSeam(from, exit.to) + self:progress() + return true + end + self:noteSeam(from, exit.to) + self:say(("hop failed: wanted %s, on %s"):format(exit.to, tostring(arrived))) + return arrived ~= from -- landing somewhere ELSE is still movement, and + -- the caller re-plans from wherever we are +end + +-- How many cells can the player actually reach from where they stand? +-- +-- Several maps have a pocket that touches a border: Azalea Town's east side is +-- shared by the town proper (251 cells) and a 27-cell dead end whose only exit +-- is back the way you came. Cross at the wrong y, or teleport onto the wrong +-- warp, and every plan afterwards is drawn from inside a cupboard. Counting +-- the reachable cells is how the bot notices it is in one. +function Bot:regionSize(limit) + local map = A.map(self.g) + local sx, sy = A.pos(self.g) + if not (map and sx) then return 0 end + local seen = { [sy * 4096 + sx] = true } + local queue, head, n = { { sx, sy } }, 1, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, d in pairs(DELTA) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local k = ny * 4096 + nx + if not seen[k] and map:inBounds(nx, ny) + and A.walkable(self.g, map, nx, ny) then + seen[k] = true + n = n + 1 + if limit and n >= limit then return n end + queue[#queue + 1] = { nx, ny } + end + end + end + return n +end + +-- The reachable cell nearest the given edge, which is what we aim at before +-- pushing into the connection. +-- +-- `skip` walks the candidate list further along. A connection is a STRIP: the +-- y you cross at decides the y you land on, and on a map with a border pocket +-- that decides which side of a wall you arrive in. Always aiming at the +-- nearest cell meant a failed crossing was retried identically forever, so the +-- caller counts its attempts and asks for a different cell each time. +function Bot:edgeTarget(map, rawDir, skip) + local dir = BUTTON_DIR[rawDir] + if not dir then return nil end + local w, h = map.widthCells, map.heightCells + local px, py = A.pos(self.g) + if not px then return nil end + -- Candidates along the border, nearest first -- and then REACHABLE-checked. + -- + -- "Nearest passable" is not good enough and Route 34 is why: its top row has + -- passable cells the player cannot actually get to (the river cuts them off), + -- so the bot aimed at (13,0), found no path, and never crossed into Goldenrod + -- at all. A border cell is only a useful target if we can walk to it. + local candidates = {} + local function consider(x, y) + if not self:passable(map, x, y) then return end + candidates[#candidates + 1] = + { x = x, y = y, d = math.abs(x - px) + math.abs(y - py) } + end + if dir == "up" then + for x = 0, w - 1 do consider(x, 0) end + elseif dir == "down" then + for x = 0, w - 1 do consider(x, h - 1) end + elseif dir == "left" then + for y = 0, h - 1 do consider(0, y) end + else + for y = 0, h - 1 do consider(w - 1, y) end + end + table.sort(candidates, function(a, b) return a.d < b.d end) + + -- Each check is a full search, so the scan is bounded -- but the bound has to + -- be big enough to leave the region we are IN. The nearest-16 cap was too + -- small on tall maps: coming down Blackthorn onto Route 45's top strip + -- (region 3), the sixteen west-border cells nearest (15,0) are all up in the + -- top-left, which is region 1/2 and unreachable from region 3 -- region 3's + -- own Route 46 border cells are a third of the way down, past the cap, so + -- the scan found nothing reachable and fell back to (0,0), a region-1 cell it + -- could never walk to. Scan far enough to clear a region (this is a + -- per-crossing fallback, not the per-hop plan the handoff warns is too slow), + -- and stop early once a handful are in hand. + local reachable = {} + local checked = 0 + for _, cell in ipairs(candidates) do + if self:planPath(cell.x, cell.y) then reachable[#reachable + 1] = cell end + checked = checked + 1 + if #reachable >= 6 or checked >= 64 then break end + end + if #reachable > 0 then + -- Alternate near end / far end as `skip` grows rather than stepping one + -- cell along. Border cells that land in the same pocket are contiguous, + -- so "the next one over" is almost always the same mistake again; the + -- opposite end of the border is a different region. + skip = skip or 0 + local index + if skip % 2 == 0 then + index = math.min(#reachable, math.floor(skip / 2) + 1) + else + index = math.max(1, #reachable - math.floor((skip - 1) / 2)) + end + return reachable[index] + end + -- Nothing provably reachable: still hand back the nearest border cell. The + -- local search does not model one-way ledge hops, so "no path" is weaker than + -- "cannot get there", and crossEdge's push-and-slide can still find the seam. + if #candidates > 0 then + self:say(("edgeTarget %s: none of the nearest %d border cells provably " + .. "reachable, trying the closest anyway"):format(dir, checked)) + end + return candidates[1] +end + +-- Walk off the edge of the map in `dir` (a pad direction) onto the connected +-- map. There is no single cell to aim at -- any cell on that border works, and +-- which ones are reachable depends on where we came in -- so aim at the nearest +-- reachable border cell, then push. Pushing repeatedly matters: the landing +-- strip is offset, so the first border cell we reach is not always one the +-- connection actually covers, and walking ALONG the edge finds one that is. +function Bot:crossEdge(dir, dest) + local from = A.mapId(self.g) + local map = A.map(self.g) + if not map then return false end + + -- Cross at a different point each time this particular border has FAILED, + -- so a crossing that lands in a dead-end pocket is not repeated identically. + -- The counter used to bump on every call, including successes: the first + -- Route 43 -> Lake of Rage cross (the good x) then made the return visit + -- after 10.g pick skip=1, which is the west end of the strip, and that + -- lands in a 47-cell pocket with no path to the Red Gyarados even with + -- Surf. Only failures (and escapePocket's explicit bump) advance it. + local tryKey = ("%s#%s"):format(tostring(from), tostring(dir)) + local skip = self.edgeTries[tryKey] or 0 + + local target = self:edgeTarget(map, dir, skip) + local aimed = target ~= nil + if target then + local cx, cy = A.pos(self.g) + self:say(("edge %s off %s: crossing at (%d,%d) from (%s,%s) [try %d]") + :format(dir, tostring(from), target.x, target.y, tostring(cx), + tostring(cy), skip)) + self:walkTo(target.x, target.y, { stopOnMapChange = true }) + if A.mapId(self.g) ~= from then + self:clearDialogue() + return dest == nil or A.mapId(self.g) == dest + end + end + + -- Push into the seam, and if it does not take, slide along the edge and try + -- again. `along` is the axis perpendicular to the crossing. + local along = (dir == "up" or dir == "down") and { "left", "right" } + or { "up", "down" } + -- Pushing blindly is only worth much when we got near the border under our + -- own steam. When no border cell was even reachable -- the player is in a + -- pocket, or eighty rows away -- twenty-four pushes is five thousand frames + -- spent proving what the failed walk already said. + for attempt = 1, (aimed and 24 or 6) do + if A.mapId(self.g) ~= from then break end + A.hold(self.g, dir) + self:wait(TURN_FRAMES + 14) + A.releaseDirs(self.g) + self:wait(6) + if A.mapId(self.g) ~= from then break end + if A.busy(self.g) then self:clearDialogue() end + -- Alternate which way we slide so a blocked corner does not trap us at one + -- end of the border, but never slide OFF the map: standing in a corner, a + -- slide along one border is a step across the other one. That is how + -- "cross west into Violet City" came back having arrived on Route 30 -- + -- the player was on Route 31's bottom row, and the first southward slide + -- took the south connection instead. A crossing that lands on the wrong + -- map is worse than one that fails: the caller prices the seam it asked + -- for, which was never the one that fired. + local slide = along[(attempt % 2) + 1] + local ax, ay = A.pos(self.g) + local map2 = A.map(self.g) + local d = DELTA[slide] + if not (ax and map2 and map2:inBounds(ax + d[1], ay + d[2])) then + slide = along[((attempt + 1) % 2) + 1] + d = DELTA[slide] + end + if ax and map2 and map2:inBounds(ax + d[1], ay + d[2]) then + self:stepDir(slide) + end + end + + self:clearDialogue() + local now = A.mapId(self.g) + if now == from then + self.edgeTries[tryKey] = skip + 1 + self:say(("edge %s off %s did not cross"):format(dir, tostring(from))) + return false + end + return dest == nil or now == dest +end + +-- --------------------------------------------------------------------------- +-- healing +-- --------------------------------------------------------------------------- + +-- A Pokecenter nurse is a map's only SPRITE_NURSE object, so the extracted +-- objects already say where every heal point in Johto is; nothing needs a cell +-- hardcoded per town. +local function nurseOn(def) + for _, obj in ipairs((def and def.objects) or {}) do + if obj.sprite == "SPRITE_NURSE" then return obj end + end + return nil +end + +-- Nearest map (by hop count) whose def satisfies `predicate`. +function Bot:findNearest(predicate) + local defs = self:mapDefs() + local from = A.mapId(self.g) + if not from then return nil end + local seen, queue, head = { [from] = true }, { from }, 1 + while head <= #queue do + local id = queue[head] + head = head + 1 + if predicate(defs[id], id) then return id end + for _, exit in ipairs(self:exitsOf(id)) do + if not seen[exit.to] then + seen[exit.to] = true + queue[#queue + 1] = exit.to + end + end + end + return nil +end + +-- Full heal at a Pokecenter: restores HP *and PP*, which is the part that +-- matters most given the port has no Struggle (see A.partyPpFraction). +function Bot:healUp(preferMap) + local defs = self:mapDefs() + -- Candidates in graph-BFS order, and TRY EACH ONE. The BFS is region-blind, + -- so the nearest Pokecenter by map hops can be walled off from where we + -- actually stand -- west of the Sudowoodo tree, Route 36 cannot reach + -- Violet's -- and failing the whole heal on that one miss is what ended + -- 05.g with "could not heal mid-grind" while Goldenrod's center sat two + -- maps the other way. + local candidates = {} + if preferMap and nurseOn(defs[preferMap]) then + candidates[#candidates + 1] = preferMap + end + local from = A.mapId(self.g) + if from then + local seen, queue, head = { [from] = true }, { from }, 1 + while head <= #queue and #candidates < 4 do + local id = queue[head] + head = head + 1 + if nurseOn(defs[id]) and id ~= preferMap then + candidates[#candidates + 1] = id + end + for _, exit in ipairs(self:exitsOf(id)) do + if not seen[exit.to] then + seen[exit.to] = true + queue[#queue + 1] = exit.to + end + end + end + end + if #candidates == 0 then + self:say("heal: no Pokecenter reachable from here") + return false + end + for _, target in ipairs(candidates) do + if A.mapId(self.g) == target or self:travelTo(target) then + local nurse = nurseOn(defs[target]) + if nurse and self:approachAndFace(nurse.x, nurse.y) then + self:tap("a") + self:clearDialogue({ "yes" }, 3000) + self:say(("heal: done on %s (hp %.2f, pp %.2f)") + :format(target, A.leadHpFraction(self.g), A.partyPpFraction(self.g))) + return true + end + self:say("heal: could not reach the nurse on " .. target) + else + self:say("heal: could not reach " .. target .. " -- trying the next") + end + end + self:say("heal: every candidate Pokecenter was unreachable") + return false +end + +-- Heal when the party is in no state to keep fighting. Called between route +-- rows rather than inside them, so it can never interrupt a scripted beat +-- half-way through. +function Bot:maybeHeal() + local hp = A.leadHpFraction(self.g) + local pp = A.damagingPpFraction(self.g) + if hp >= 0.35 and pp >= 0.35 then return false end + if A.busy(self.g) or A.inBattle(self.g) then return false end + self:say(("heal: hp %.2f attacking-pp %.2f -- going to a Pokecenter") + :format(hp, pp)) + return self:healUp() +end + +-- Are we walled into a corner of this map whose only exit leads back to +-- `cameFrom`? Cheap enough to ask once per hop: it is one flood fill plus a +-- reachability test per exit, and it is the difference between noticing a bad +-- crossing immediately and touring Johto to find out. +function Bot:inPocket(cameFrom) + local here = A.mapId(self.g) + if not here then return false end + local exits = self:exitsOf(here) + if #exits == 0 then return false end + local wayOut = false + for _, exit in ipairs(exits) do + if exit.to ~= cameFrom then + if exit.kind == "edge" then + if self:borderStandable(here, exit.dir) then wayOut = true break end + elseif self:planPath(exit.x, exit.y) then + wayOut = true + break + end + end + end + return not wayOut +end + +-- We crossed into a pocket. Go back and cross again somewhere else. +-- +-- `from` is the map we came from and `exit` the crossing that put us here. +-- Each attempt bumps that crossing's counter, which is what makes edgeTarget +-- pick a border cell at the other end of the seam instead of the same one. +function Bot:escapePocket(from, exit) + local pocketMap = A.mapId(self.g) + local key = ("%s#%s"):format(tostring(from), + exit.kind == "edge" and exit.dir or ("w" .. tostring(exit.index))) + for attempt = 1, 3 do + self.edgeTries[key] = (self.edgeTries[key] or 0) + 1 + -- Back out through whichever way we can reach; on a pocket that is the way + -- we came in. + local out = nil + for _, e in ipairs(self:exitsOf(pocketMap)) do + if e.to == from then out = e break end + end + if not out then return false end + if not self:takeHop({ exit = out }) then return false end + if A.mapId(self.g) ~= from then return false end + + if exit.kind == "edge" then + self:crossEdge(exit.dir, exit.to) + else + self:enterWarp(exit.x, exit.y, exit.to) + end + if A.mapId(self.g) ~= pocketMap then return false end + if not self:inPocket(from) then + self:say(("travel: re-crossed into %s clear of the pocket (try %d)") + :format(tostring(pocketMap), attempt)) + return true + end + end + self:say(("travel: every crossing into %s lands in the pocket") + :format(tostring(pocketMap))) + return false +end + +-- Get to `target`, re-planning after every hop. +-- `wantRegion` is a region INDEX from tests/drivers/gold/map_regions.lua, +-- for the maps where arriving is not the same as arriving somewhere useful. +-- +-- TEAM_ROCKET_BASE_B3F is the case that forced it. The rival trigger at (8,10) +-- sits in region 1, which is reachable only through warps 1 and 3 on the far +-- LEFT of the floor; the way in from B1F lands you in region 3 on the right, +-- and the two never touch. "travel TEAM_ROCKET_BASE_B3F" was therefore +-- satisfied by the wrong half of the map, and every row after it -- the rival, +-- Giovanni's door, Executive 4, HAIL GIOVANNI -- failed on a floor the bot was +-- standing on. The real route is a loop through both floors, which the region +-- graph can find as soon as it is told which end to aim for. +function Bot:travelTo(target, wantRegion) + if not self:mapDefs()[target] then + self:say("travel: unknown map " .. tostring(target)) + return false + end + -- Loop breaker. A multi-floor interior (Sprout Tower, the lighthouse) has + -- several ladders between the same pair of floors, so a hop can succeed -- + -- land exactly on the map it promised -- and still leave us further from the + -- goal, at which point the next plan sends us straight back and the two + -- floors trade the player back and forth forever. Counting map VISITS + -- catches that, where the seam price cannot: nothing here ever failed. + local visits = {} + local cameFrom = nil + local relaxed = false + -- A frame budget, not just a hop budget. + -- + -- The hop counter (80) and the per-map visit guard both bound how many + -- DECISIONS a trip makes, and neither bounds how long one takes: a single + -- travel to the Ilex Forest gate once spent 3.5 MILLION frames -- most of a + -- run -- inside eighty legal-looking hops, each of which walked half of + -- Azalea and pushed at a border twenty-four times. The watchdogs in :wait() + -- and :say() cannot see it either, because every hop reports progress. + -- + -- 150k frames is far more than any real journey in Johto (the longest honest + -- one measured is about 60k) and far less than a wasted run. + local startedAt = frames + local frameBudget = tonumber(os.getenv("POKEPORT_GOLD_TRAVEL_BUDGET")) or 150000 + + -- Starting inside a pocket is the same problem as arriving in one, minus the + -- hop that tells you which crossing put you there. The way out is forced + -- and leads to exactly one map, so the crossing to vary is the one BACK from + -- that map -- bump it before the trip rather than after, or the return leg + -- re-enters the same corner and the trip is a loop by construction. + if A.mapId(self.g) ~= target and self:inPocket(nil) then + local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + for _, exit in ipairs(self:exitsOf(A.mapId(self.g))) do + if exit.kind == "edge" and OPPOSITE[exit.dir] then + local key = ("%s#%s"):format(exit.to, OPPOSITE[exit.dir]) + self.edgeTries[key] = (self.edgeTries[key] or 0) + 1 + self:say(("travel: starting in a dead-end pocket of %s -- will re-enter " + .. "from %s at a different point") + :format(tostring(A.mapId(self.g)), exit.to)) + end + end + end + + local function arrived() + if A.mapId(self.g) ~= target then return false end + if not wantRegion then return true end + for _, r in ipairs(self:currentRegions() or {}) do + if r == wantRegion then return true end + end + return false + end + + for _ = 1, 80 do + local here = A.mapId(self.g) + if arrived() then return true end + if frames - startedAt > frameBudget then + self:say(("travel: %d frames trying to reach %s from %s, giving up") + :format(frames - startedAt, target, tostring(here))) + return false + end + if A.busy(self.g) then self:clearDialogue() end + + -- Revisiting a map is now legitimate -- changing region inside a tower + -- means leaving and coming back -- so the loop guard has to allow several + -- passes before it calls it a loop. + visits[here] = (visits[here] or 0) + 1 + if visits[here] > 8 then + self:say(("travel: looping through %s on the way to %s, giving up") + :format(tostring(here), target)) + return false + end + + -- Region graph first, map-id graph as the fallback: the static graph is + -- right about geometry and blind to anything the run has changed (a CUT + -- tree down, a boulder moved, a badge earned). + local hops = self:planTravelRegions(target, visits, wantRegion) + or self:planTravel(target, cameFrom, visits) + if not hops and not relaxed then + -- The prices have painted us into a corner; drop them once and retry, so + -- a route that genuinely needs to walk back through somewhere is still + -- possible. + relaxed = true + hops = self:planTravel(target, cameFrom, nil) + end + if not hops then + self:say(("travel: no route %s -> %s"):format(tostring(here), target)) + return false + end + -- Empty hops means "already there" only when the REGION matches too. + -- planTravel (map-id fallback) returns {} whenever from == target, so a + -- region-targeted travel that landed on the wrong half of RADIO_TOWER_5F + -- -- or any other split map whose static region path is blocked -- used + -- to report success at (0,0) while the boss sat in region 2. + if #hops == 0 then return arrived() end + local exit = hops[1].exit + cameFrom = here + if not self:takeHop(hops[1]) then + -- Price both the pair and the specific exit. The pair price keeps a + -- flaky seam usable; the exit price is what makes the next attempt pick a + -- DIFFERENT ladder, which is the only way out of a floor whose regions + -- do not connect. + self:noteSeam(here, exit.to) + self:noteBadExit(here, exit) + -- Burn a frame on the way past. A hop that fails before it moves -- + -- "warp (15,0) is not reachable from here", decided by a search and no + -- input at all -- costs ZERO frames, so a plan that keeps choosing it + -- spins the loop without the silent-stall watchdog ever seeing a frame + -- go by. Radio Tower 2F did exactly that: the same three lines, forever, + -- at frame 857930. The repeat detector in :say() catches the talkative + -- version; this makes sure the quiet one is caught too. + self:wait(2) + elseif self:inPocket(here) + and not self:isCulDeSac(A.mapId(self.g)) then + -- The hop landed exactly where it promised and still went wrong: we are + -- in a walled-off corner whose only way out is back. Azalea Town's east + -- side is shared by the town (251 cells) and a 27-cell dead end, and + -- Lake of Rage's west south shore is a 47-cell pocket with no path to + -- the Red Gyarados even with Surf. Crossing from Route 43 at the wrong + -- x puts you in that pocket, after which every approach on the lake + -- reports "nowhere to stand". + -- + -- Used to only fire when the pocket was an INTERMEDIATE map + -- (`mapId ~= target`). That missed the destination case: travelTo + -- called the Lake pocket "arrived" and 10.33 never started the fight. + -- Cul-de-sacs (houses, shops) are excluded -- being pocketed in one is + -- the same as having arrived. + -- + -- A connection is a strip, so the answer is to cross at a different + -- point. escapePocket bumps the edge counter, which is what makes the + -- retry pick another border cell instead of the same one. + -- Deliberately NOT priced as a bad exit, and deliberately retried HERE + -- rather than by handing control back to the planner. It is the right + -- exit crossed at the wrong point; the planner, which has no idea a map + -- can have two disconnected halves, only sees a map it has now visited + -- twice and answers by taking a seven-hop detour round Johto -- which is + -- the failure this whole branch exists to prevent. So: step back out, + -- cross again somewhere else, and only give up after a few tries. + self:say(("travel: %s is a dead-end pocket from here, re-crossing") + :format(tostring(A.mapId(self.g)))) + self:escapePocket(here, exit) + end + end + -- Must honour wantRegion. Returning "on the right map" here was how a + -- region-targeted travel into GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES + -- reported success from region 10 after the map-id fallback hopped warp 1: + -- eighty retries later the loop ended on the right map in the wrong half. + return arrived() +end + +-- Last-resort arrival: walk if we can, otherwise put the player there. +-- +-- The map graph has one known hole -- Goldenrod is only reachable through +-- Route 35's south gate, whose warp is not reachable from the border the +-- player arrives on, so the planner bounces -- and that hole gates PLAINBADGE, +-- which gates STRENGTH, which gates everything after it. Rather than let one +-- seam block six sections, fall through to a teleport and SAY SO, so a summary +-- can be read honestly: rows reached this way tested the beat, not the route. +function Bot:reachMap(target, wantRegion) + if A.mapId(self.g) == target then + if wantRegion then + for _, r in ipairs(self:currentRegions() or {}) do + if r == wantRegion then return true end + end + -- Wrong half of a split map; fall through and travel properly. + elseif self:isCulDeSac(target) or self:regionSize(64) >= 64 then + return true + else + -- On the right map but in a tiny disconnected pocket (Lake of Rage + -- west shore after a bad strip crossing). Leave through whatever exit + -- we can reach, then travelTo back in at a different point. + self:say(("reachMap: on %s in a %d-cell pocket, leaving to re-enter") + :format(target, self:regionSize())) + for _, exit in ipairs(self:exitsOf(target)) do + if self:takeHop({ exit = exit }) and A.mapId(self.g) ~= target then + break + end + end + if A.mapId(self.g) == target then return false end + end + end + -- A stall inside travelTo must not skip the fallback below it. + -- + -- travelTo raises `{ botStall = true }` when the silent-stall backstop fires, + -- and that unwound straight past this function -- so the one situation the + -- warp-landing fallback exists for, a player who cannot walk anywhere, was + -- also the one situation where it never got to run. Run 26 died exactly + -- there: boxed onto MAHOGANY_GYM's door tile by an NPC, it spent 400k frames + -- failing every remaining row rather than taking the shortcut, and lost a + -- seven-badge save. A stall is a reason to fall back, not a reason to stop. + local ok, res = pcall(self.travelTo, self, target, wantRegion) + if ok and res then return true end + if not ok then + if not (type(res) == "table" and res.botStall) then error(res, 0) end + self:say(("travel to %s stalled (%s) -- trying a warp landing instead") + :format(target, tostring(res.why))) + end + local def = self:mapDefs()[target] + local warps = (def and def.warps) or {} + if #warps == 0 then return false end + + -- Try each warp in turn and keep the first landing with room to move. + -- + -- Warp 1 used to be taken unconditionally, and on Ilex Forest warp 1 is + -- (1,5) -- inside a walled pocket. Every row after that shortcut then + -- reported "nowhere to stand": the Farfetch'd herd, HM01 CUT and, through + -- CUT, Whitney, the Squirtbottle, Sudowoodo, ROCK SMASH, the Burned Tower + -- and Morty. One bad landing cost six sections, so the landing is now + -- checked rather than assumed. + -- + -- When the caller named a region, prefer a landing that currentRegions + -- says is that region -- the largest pocket on a split map is not always + -- the useful half (and on SWITCH_ROOM_ENTRANCES region 1 happens to be + -- largest, but region 10 is the second-largest and is where a blind + -- "first warp with room" used to drop the bot). + local best, bestSize, bestWanted + for index, landing in ipairs(warps) do + if A.teleport(self.g, target, landing.x, landing.y) then + self:wait(4) + local size = self:regionSize(64) + local inWanted = false + if wantRegion then + for _, r in ipairs(self:currentRegions() or {}) do + if r == wantRegion then inWanted = true break end + end + end + if inWanted and not bestWanted then + best, bestSize, bestWanted = landing, size, true + elseif not bestWanted and (not bestSize or size > bestSize) then + best, bestSize = landing, size + end + -- 24 rather than "as big as possible": a gate hut is 36 cells and a + -- Pokecenter smaller still, so demanding a large region would reject + -- every landing on a small map and scan warps for nothing. What is being + -- ruled out is a walled-off corner, and those are single figures. + if (not wantRegion and size >= 24) or (bestWanted and size >= 24) then + break + end + self:say(("TELEPORT: warp %d of %s lands in a %d-cell pocket, trying the " + .. "next"):format(index, target, size)) + end + if index >= 6 then break end -- a big interior has plenty; do not scan + -- every ladder of a six-floor tower + end + if not best then return false end + self:say(("TELEPORT: could not walk to %s, placing the player at (%d,%d) " + .. "[harness shortcut, not navigation]") + :format(target, best.x, best.y)) + if not A.teleport(self.g, target, best.x, best.y) then return false end + self.teleports = (self.teleports or 0) + 1 + self:wait(30) + self:clearDialogue() + self:progress() + if not wantRegion then return A.mapId(self.g) == target end + for _, r in ipairs(self:currentRegions() or {}) do + if r == wantRegion then return true end + end + return false +end + +-- --------------------------------------------------------------------------- +-- construction +-- --------------------------------------------------------------------------- + +function Bot.new(game, opts) + opts = opts or {} + local self = setmetatable({}, Bot) + self.g = game + self.walls, self.seams, self.deaths, self.badExits = {}, {}, {}, {} + self.culDeSac, self.borders, self.edgeTries = {}, {}, {} + self.history = {} + self.silentFrames = 0 + self.stallFrames = tonumber(os.getenv("POKEPORT_GOLD_STALL")) or 5000 + self.stuckRepeats = tonumber(os.getenv("POKEPORT_GOLD_REPEATS")) or 12 + self.battleFrames = 3000 + self.skipped = {} + local logPath = os.getenv("POKEPORT_GOLD_LOG") + if logPath then self.logFile = io.open(logPath, "w") end + return self +end + +return Bot diff --git a/tests/drivers/gold/flag_names.lua b/tests/drivers/gold/flag_names.lua new file mode 100644 index 00000000..0d4e44b1 --- /dev/null +++ b/tests/drivers/gold/flag_names.lua @@ -0,0 +1,1340 @@ +-- Generated by tools/goldwalk/gen_flags.lua from pret/pokegold. Do not edit. +-- +-- EVENT_* / ENGINE_* name -> the numeric id the cart's scripts carry, which is +-- what src/world/gen2/Events.lua and World:engineFlag key on. Verified against +-- the ROM by tests/gold_flag_names_test.lua. +return { + source = "pret/pokegold constants/{event,engine}_flags.asm", + events = { + EVENT_ALAN_ASKED_FOR_PHONE_NUMBER = 645, + EVENT_ALAN_READY_FOR_REMATCH = 646, + EVENT_ANTHONY_ASKED_FOR_PHONE_NUMBER = 635, + EVENT_ANTHONY_READY_FOR_REMATCH = 636, + EVENT_ARNIE_ASKED_FOR_PHONE_NUMBER = 643, + EVENT_ARNIE_READY_FOR_REMATCH = 644, + EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL = 177, + EVENT_AZALEA_TOWN_SLOWPOKES = 1785, + EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET = 1786, + EVENT_BEAT_BEAUTY_ANGELICA = 1207, + EVENT_BEAT_BEAUTY_BRENDA = 1201, + EVENT_BEAT_BEAUTY_CARLENE = 1204, + EVENT_BEAT_BEAUTY_CAROLINE = 1203, + EVENT_BEAT_BEAUTY_CASSIE = 1202, + EVENT_BEAT_BEAUTY_JACLYN = 1200, + EVENT_BEAT_BEAUTY_JESSICA = 1205, + EVENT_BEAT_BEAUTY_JULIA = 1210, + EVENT_BEAT_BEAUTY_JULIE = 1199, + EVENT_BEAT_BEAUTY_KENDRA = 1208, + EVENT_BEAT_BEAUTY_RACHAEL = 1206, + EVENT_BEAT_BEAUTY_SAMANTHA = 1198, + EVENT_BEAT_BEAUTY_THERESA = 1211, + EVENT_BEAT_BEAUTY_VALERIE = 1212, + EVENT_BEAT_BEAUTY_VERONICA = 1209, + EVENT_BEAT_BEAUTY_VICTORIA = 1197, + EVENT_BEAT_BIKER_BENNY = 1073, + EVENT_BEAT_BIKER_CHARLES = 1078, + EVENT_BEAT_BIKER_DWAYNE = 1075, + EVENT_BEAT_BIKER_GLENN = 1081, + EVENT_BEAT_BIKER_HARRIS = 1076, + EVENT_BEAT_BIKER_JOEL = 1080, + EVENT_BEAT_BIKER_KAZU = 1074, + EVENT_BEAT_BIKER_RILEY = 1079, + EVENT_BEAT_BIKER_ZEKE = 1077, + EVENT_BEAT_BIRD_KEEPER_ABE = 1020, + EVENT_BEAT_BIRD_KEEPER_BOB = 1029, + EVENT_BEAT_BIRD_KEEPER_BORIS = 1028, + EVENT_BEAT_BIRD_KEEPER_BRET = 1034, + EVENT_BEAT_BIRD_KEEPER_BRYAN = 1021, + EVENT_BEAT_BIRD_KEEPER_DENIS = 1024, + EVENT_BEAT_BIRD_KEEPER_HANK = 1026, + EVENT_BEAT_BIRD_KEEPER_JOSE = 1030, + EVENT_BEAT_BIRD_KEEPER_JOSE2 = 1032, + EVENT_BEAT_BIRD_KEEPER_JOSE3 = 1035, + EVENT_BEAT_BIRD_KEEPER_PERRY = 1033, + EVENT_BEAT_BIRD_KEEPER_PETER = 1031, + EVENT_BEAT_BIRD_KEEPER_ROD = 1019, + EVENT_BEAT_BIRD_KEEPER_ROY = 1027, + EVENT_BEAT_BIRD_KEEPER_THEO = 1022, + EVENT_BEAT_BIRD_KEEPER_TOBY = 1023, + EVENT_BEAT_BIRD_KEEPER_VANCE = 1025, + EVENT_BEAT_BIRD_KEEPER_VANCE2 = 1036, + EVENT_BEAT_BIRD_KEEPER_VANCE3 = 1037, + EVENT_BEAT_BLACKBELT_KENJI = 1195, + EVENT_BEAT_BLACKBELT_KENJI2 = 1188, + EVENT_BEAT_BLACKBELT_KENJI3 = 1190, + EVENT_BEAT_BLACKBELT_KIYO = 1193, + EVENT_BEAT_BLACKBELT_LAO = 1191, + EVENT_BEAT_BLACKBELT_LUNG = 1194, + EVENT_BEAT_BLACKBELT_NOB = 1192, + EVENT_BEAT_BLACKBELT_WAI = 1196, + EVENT_BEAT_BLACKBELT_YOSHI = 1189, + EVENT_BEAT_BLAINE = 1227, + EVENT_BEAT_BLUE = 1228, + EVENT_BEAT_BOARDER_BRAD = 1039, + EVENT_BEAT_BOARDER_DOUGLAS = 1040, + EVENT_BEAT_BOARDER_RONALD = 1038, + EVENT_BEAT_BROCK = 1221, + EVENT_BEAT_BUGSY = 1214, + EVENT_BEAT_BUG_CATCHER_AL = 1341, + EVENT_BEAT_BUG_CATCHER_ARNIE = 1343, + EVENT_BEAT_BUG_CATCHER_ARNIE2 = 1348, + EVENT_BEAT_BUG_CATCHER_ARNIE3 = 1349, + EVENT_BEAT_BUG_CATCHER_BENNY = 1340, + EVENT_BEAT_BUG_CATCHER_DON = 1336, + EVENT_BEAT_BUG_CATCHER_DOUG = 1347, + EVENT_BEAT_BUG_CATCHER_ED = 1338, + EVENT_BEAT_BUG_CATCHER_JOSH = 1342, + EVENT_BEAT_BUG_CATCHER_KEN = 1344, + EVENT_BEAT_BUG_CATCHER_ROB = 1337, + EVENT_BEAT_BUG_CATCHER_WADE = 1339, + EVENT_BEAT_BUG_CATCHER_WADE2 = 1345, + EVENT_BEAT_BUG_CATCHER_WADE3 = 1346, + EVENT_BEAT_BURGLAR_COREY = 1071, + EVENT_BEAT_BURGLAR_DUNCAN = 1069, + EVENT_BEAT_BURGLAR_EDDIE = 1070, + EVENT_BEAT_CAMPER_BARRY = 1054, + EVENT_BEAT_CAMPER_DALE = 1059, + EVENT_BEAT_CAMPER_DAVID = 1065, + EVENT_BEAT_CAMPER_DEAN = 1056, + EVENT_BEAT_CAMPER_ELLIOT = 1053, + EVENT_BEAT_CAMPER_HERVEY = 1058, + EVENT_BEAT_CAMPER_IVAN = 1052, + EVENT_BEAT_CAMPER_JERRY = 1067, + EVENT_BEAT_CAMPER_JOHN = 1066, + EVENT_BEAT_CAMPER_LEROY = 1064, + EVENT_BEAT_CAMPER_LLOYD = 1055, + EVENT_BEAT_CAMPER_ROLAND = 1050, + EVENT_BEAT_CAMPER_SID = 1057, + EVENT_BEAT_CAMPER_SPENCER = 1068, + EVENT_BEAT_CAMPER_TED = 1060, + EVENT_BEAT_CAMPER_THOMAS = 1063, + EVENT_BEAT_CAMPER_TODD = 1051, + EVENT_BEAT_CAMPER_TODD2 = 1061, + EVENT_BEAT_CAMPER_TODD3 = 1062, + EVENT_BEAT_CHAMPION_LANCE = 1468, + EVENT_BEAT_CHUCK = 1218, + EVENT_BEAT_CLAIR = 1220, + EVENT_BEAT_COOLTRAINERF_BETH = 1379, + EVENT_BEAT_COOLTRAINERF_BETH2 = 1382, + EVENT_BEAT_COOLTRAINERF_BETH3 = 1388, + EVENT_BEAT_COOLTRAINERF_CAROL = 1383, + EVENT_BEAT_COOLTRAINERF_CYBIL = 1386, + EVENT_BEAT_COOLTRAINERF_EMMA = 1385, + EVENT_BEAT_COOLTRAINERF_FRAN = 1373, + EVENT_BEAT_COOLTRAINERF_GWEN = 1371, + EVENT_BEAT_COOLTRAINERF_IRENE = 1376, + EVENT_BEAT_COOLTRAINERF_JENN = 1387, + EVENT_BEAT_COOLTRAINERF_JOYCE = 1378, + EVENT_BEAT_COOLTRAINERF_KATE = 1375, + EVENT_BEAT_COOLTRAINERF_KELLY = 1377, + EVENT_BEAT_COOLTRAINERF_LOIS = 1372, + EVENT_BEAT_COOLTRAINERF_LOLA = 1374, + EVENT_BEAT_COOLTRAINERF_MEGAN = 1381, + EVENT_BEAT_COOLTRAINERF_QUINN = 1384, + EVENT_BEAT_COOLTRAINERF_REENA = 1380, + EVENT_BEAT_COOLTRAINERF_REENA2 = 1389, + EVENT_BEAT_COOLTRAINERF_REENA3 = 1390, + EVENT_BEAT_COOLTRAINERM_AARON = 1353, + EVENT_BEAT_COOLTRAINERM_ALLEN = 1370, + EVENT_BEAT_COOLTRAINERM_ANDY = 1365, + EVENT_BEAT_COOLTRAINERM_BLAKE = 1362, + EVENT_BEAT_COOLTRAINERM_BRIAN = 1363, + EVENT_BEAT_COOLTRAINERM_CODY = 1355, + EVENT_BEAT_COOLTRAINERM_ERICK = 1364, + EVENT_BEAT_COOLTRAINERM_GAVEN = 1361, + EVENT_BEAT_COOLTRAINERM_GAVEN2 = 1357, + EVENT_BEAT_COOLTRAINERM_GAVEN3 = 1358, + EVENT_BEAT_COOLTRAINERM_JAKE = 1360, + EVENT_BEAT_COOLTRAINERM_KEVIN = 1368, + EVENT_BEAT_COOLTRAINERM_MIKE = 1356, + EVENT_BEAT_COOLTRAINERM_NICK = 1352, + EVENT_BEAT_COOLTRAINERM_PAUL = 1354, + EVENT_BEAT_COOLTRAINERM_RYAN = 1359, + EVENT_BEAT_COOLTRAINERM_SEAN = 1367, + EVENT_BEAT_COOLTRAINERM_STEVE = 1369, + EVENT_BEAT_COOLTRAINERM_TYLER = 1366, + EVENT_BEAT_ELITE_4_BRUNO = 1466, + EVENT_BEAT_ELITE_4_KAREN = 1467, + EVENT_BEAT_ELITE_4_KOGA = 1465, + EVENT_BEAT_ELITE_4_WILL = 1464, + EVENT_BEAT_ELITE_FOUR = 68, + EVENT_BEAT_ERIKA = 1224, + EVENT_BEAT_FALKNER = 1213, + EVENT_BEAT_FIREBREATHER_BILL = 1098, + EVENT_BEAT_FIREBREATHER_BURT = 1097, + EVENT_BEAT_FIREBREATHER_DICK = 1095, + EVENT_BEAT_FIREBREATHER_LYLE = 1101, + EVENT_BEAT_FIREBREATHER_NED = 1096, + EVENT_BEAT_FIREBREATHER_OTIS = 1094, + EVENT_BEAT_FIREBREATHER_RAY = 1100, + EVENT_BEAT_FIREBREATHER_WALT = 1099, + EVENT_BEAT_FISHER_ANDRE = 1109, + EVENT_BEAT_FISHER_ARNOLD = 1104, + EVENT_BEAT_FISHER_BARNEY = 1116, + EVENT_BEAT_FISHER_CHRIS = 1108, + EVENT_BEAT_FISHER_CHRIS2 = 1119, + EVENT_BEAT_FISHER_CHRIS3 = 1120, + EVENT_BEAT_FISHER_EDGAR = 1112, + EVENT_BEAT_FISHER_HENRY = 1106, + EVENT_BEAT_FISHER_JONAH = 1113, + EVENT_BEAT_FISHER_JUSTIN = 1102, + EVENT_BEAT_FISHER_KYLE = 1105, + EVENT_BEAT_FISHER_MARTIN = 1114, + EVENT_BEAT_FISHER_MARVIN = 1107, + EVENT_BEAT_FISHER_RALPH = 1103, + EVENT_BEAT_FISHER_RALPH2 = 1117, + EVENT_BEAT_FISHER_RALPH3 = 1118, + EVENT_BEAT_FISHER_RAYMOND = 1110, + EVENT_BEAT_FISHER_SCOTT = 1122, + EVENT_BEAT_FISHER_STEPHEN = 1115, + EVENT_BEAT_FISHER_WILTON = 1111, + EVENT_BEAT_FISHER_WILTON2 = 1121, + EVENT_BEAT_FISHER_WILTON3 = 1123, + EVENT_BEAT_GENTLEMAN_ALFRED = 1182, + EVENT_BEAT_GENTLEMAN_EDWARD = 1179, + EVENT_BEAT_GENTLEMAN_GREGORY = 1180, + EVENT_BEAT_GENTLEMAN_PRESTON = 1178, + EVENT_BEAT_GENTLEMAN_VIRGIL = 1181, + EVENT_BEAT_GUITARIST_CLYDE = 1171, + EVENT_BEAT_GUITARIST_VINCENT = 1172, + EVENT_BEAT_HIKER_ANTHONY = 1320, + EVENT_BEAT_HIKER_ANTHONY2 = 1316, + EVENT_BEAT_HIKER_ANTHONY3 = 1327, + EVENT_BEAT_HIKER_BAILEY = 1326, + EVENT_BEAT_HIKER_BENJAMIN = 1321, + EVENT_BEAT_HIKER_DANIEL = 1333, + EVENT_BEAT_HIKER_ERIK = 1322, + EVENT_BEAT_HIKER_JIM = 1332, + EVENT_BEAT_HIKER_KENNY = 1331, + EVENT_BEAT_HIKER_LEONARD = 1319, + EVENT_BEAT_HIKER_MICHAEL = 1323, + EVENT_BEAT_HIKER_NOLAND = 1329, + EVENT_BEAT_HIKER_PARRY = 1324, + EVENT_BEAT_HIKER_PARRY2 = 1334, + EVENT_BEAT_HIKER_PARRY3 = 1335, + EVENT_BEAT_HIKER_PHILLIP = 1318, + EVENT_BEAT_HIKER_RUSSELL = 1317, + EVENT_BEAT_HIKER_SIDNEY = 1330, + EVENT_BEAT_HIKER_TIM = 1328, + EVENT_BEAT_HIKER_TIMOTHY = 1325, + EVENT_BEAT_JANINE = 1225, + EVENT_BEAT_JASMINE = 1217, + EVENT_BEAT_JUGGLER_FRITZ = 1174, + EVENT_BEAT_JUGGLER_HORTON = 1175, + EVENT_BEAT_JUGGLER_IRWIN = 1173, + EVENT_BEAT_JUGGLER_IRWIN2 = 1176, + EVENT_BEAT_JUGGLER_IRWIN3 = 1177, + EVENT_BEAT_KIMONO_GIRL_KUNI = 1250, + EVENT_BEAT_KIMONO_GIRL_MIKI = 1251, + EVENT_BEAT_KIMONO_GIRL_NAOKO = 1247, + EVENT_BEAT_KIMONO_GIRL_SAYO = 1248, + EVENT_BEAT_KIMONO_GIRL_ZUKI = 1249, + EVENT_BEAT_LASS_ALICE = 1303, + EVENT_BEAT_LASS_BRIDGET = 1302, + EVENT_BEAT_LASS_CARRIE = 1301, + EVENT_BEAT_LASS_CONNIE = 1305, + EVENT_BEAT_LASS_CONNIE2 = 1312, + EVENT_BEAT_LASS_CONNIE3 = 1313, + EVENT_BEAT_LASS_DANA = 1310, + EVENT_BEAT_LASS_DANA2 = 1314, + EVENT_BEAT_LASS_DANA3 = 1315, + EVENT_BEAT_LASS_ELLEN = 1311, + EVENT_BEAT_LASS_KRISE = 1304, + EVENT_BEAT_LASS_LAURA = 1307, + EVENT_BEAT_LASS_LINDA = 1306, + EVENT_BEAT_LASS_MICHELLE = 1309, + EVENT_BEAT_LASS_SHANNON = 1308, + EVENT_BEAT_LTSURGE = 1223, + EVENT_BEAT_MEDIUM_BETHANY = 1421, + EVENT_BEAT_MEDIUM_DORIS = 1425, + EVENT_BEAT_MEDIUM_ETHEL = 1423, + EVENT_BEAT_MEDIUM_GRACE = 1420, + EVENT_BEAT_MEDIUM_MARGRET = 1422, + EVENT_BEAT_MEDIUM_MARTHA = 1419, + EVENT_BEAT_MEDIUM_REBECCA = 1424, + EVENT_BEAT_MISTY = 1222, + EVENT_BEAT_MORTY = 1216, + EVENT_BEAT_OFFICER_DIRK = 1351, + EVENT_BEAT_OFFICER_KEITH = 1350, + EVENT_BEAT_PICNICKER_BROOKE = 1152, + EVENT_BEAT_PICNICKER_CINDY = 1154, + EVENT_BEAT_PICNICKER_DEBRA = 1157, + EVENT_BEAT_PICNICKER_EDNA = 1163, + EVENT_BEAT_PICNICKER_ERIN = 1159, + EVENT_BEAT_PICNICKER_ERIN2 = 1167, + EVENT_BEAT_PICNICKER_ERIN3 = 1170, + EVENT_BEAT_PICNICKER_GINA = 1151, + EVENT_BEAT_PICNICKER_GINA2 = 1158, + EVENT_BEAT_PICNICKER_GINA3 = 1164, + EVENT_BEAT_PICNICKER_HEIDI = 1162, + EVENT_BEAT_PICNICKER_HOPE = 1155, + EVENT_BEAT_PICNICKER_KIM = 1153, + EVENT_BEAT_PICNICKER_LIZ = 1150, + EVENT_BEAT_PICNICKER_LIZ2 = 1160, + EVENT_BEAT_PICNICKER_LIZ3 = 1161, + EVENT_BEAT_PICNICKER_SHARON = 1156, + EVENT_BEAT_PICNICKER_TANYA = 1168, + EVENT_BEAT_PICNICKER_TIFFANY = 1169, + EVENT_BEAT_PICNICKER_TIFFANY2 = 1165, + EVENT_BEAT_PICNICKER_TIFFANY3 = 1166, + EVENT_BEAT_POKEFANF_BEVERLY = 1241, + EVENT_BEAT_POKEFANF_BEVERLY2 = 1243, + EVENT_BEAT_POKEFANF_BEVERLY3 = 1244, + EVENT_BEAT_POKEFANF_GEORGIA = 1245, + EVENT_BEAT_POKEFANF_RUTH = 1242, + EVENT_BEAT_POKEFANM_ALEX = 1240, + EVENT_BEAT_POKEFANM_BRANDON = 1235, + EVENT_BEAT_POKEFANM_CARTER = 1233, + EVENT_BEAT_POKEFANM_COLIN = 1237, + EVENT_BEAT_POKEFANM_DEREK = 1230, + EVENT_BEAT_POKEFANM_DEREK2 = 1238, + EVENT_BEAT_POKEFANM_DEREK3 = 1239, + EVENT_BEAT_POKEFANM_JEREMY = 1236, + EVENT_BEAT_POKEFANM_JOSHUA = 1232, + EVENT_BEAT_POKEFANM_ROBERT = 1231, + EVENT_BEAT_POKEFANM_TREVOR = 1234, + EVENT_BEAT_POKEFANM_WILLIAM = 1229, + EVENT_BEAT_POKEMANIAC_ANDREW = 1253, + EVENT_BEAT_POKEMANIAC_BEN = 1256, + EVENT_BEAT_POKEMANIAC_BRENT = 1257, + EVENT_BEAT_POKEMANIAC_BRENT2 = 1260, + EVENT_BEAT_POKEMANIAC_BRENT3 = 1261, + EVENT_BEAT_POKEMANIAC_CALVIN = 1254, + EVENT_BEAT_POKEMANIAC_DONALD = 1263, + EVENT_BEAT_POKEMANIAC_ETHAN = 1259, + EVENT_BEAT_POKEMANIAC_ISSAC = 1262, + EVENT_BEAT_POKEMANIAC_LARRY = 1252, + EVENT_BEAT_POKEMANIAC_RON = 1258, + EVENT_BEAT_POKEMANIAC_SHANE = 1255, + EVENT_BEAT_POKEMANIAC_ZACH = 1264, + EVENT_BEAT_PRYCE = 1219, + EVENT_BEAT_PSYCHIC_FIDEL = 1085, + EVENT_BEAT_PSYCHIC_FRANKLIN = 1083, + EVENT_BEAT_PSYCHIC_GILBERT = 1091, + EVENT_BEAT_PSYCHIC_GREG = 1086, + EVENT_BEAT_PSYCHIC_HERMAN = 1084, + EVENT_BEAT_PSYCHIC_JARED = 1092, + EVENT_BEAT_PSYCHIC_MARK = 1088, + EVENT_BEAT_PSYCHIC_NATHAN = 1082, + EVENT_BEAT_PSYCHIC_NORMAN = 1087, + EVENT_BEAT_PSYCHIC_PHIL = 1089, + EVENT_BEAT_PSYCHIC_RICHARD = 1090, + EVENT_BEAT_PSYCHIC_RODNEY = 1093, + EVENT_BEAT_RIVAL_IN_MT_MOON = 793, + EVENT_BEAT_ROCKET_EXECUTIVEF_1 = 1391, + EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 1392, + EVENT_BEAT_ROCKET_EXECUTIVEM_1 = 1393, + EVENT_BEAT_ROCKET_EXECUTIVEM_2 = 1394, + EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 1395, + EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 1396, + EVENT_BEAT_ROCKET_GRUNTF_1 = 1296, + EVENT_BEAT_ROCKET_GRUNTF_2 = 1297, + EVENT_BEAT_ROCKET_GRUNTF_3 = 1298, + EVENT_BEAT_ROCKET_GRUNTF_4 = 1299, + EVENT_BEAT_ROCKET_GRUNTF_5 = 1300, + EVENT_BEAT_ROCKET_GRUNTM_1 = 1265, + EVENT_BEAT_ROCKET_GRUNTM_10 = 1274, + EVENT_BEAT_ROCKET_GRUNTM_11 = 1275, + EVENT_BEAT_ROCKET_GRUNTM_12 = 1276, + EVENT_BEAT_ROCKET_GRUNTM_13 = 1277, + EVENT_BEAT_ROCKET_GRUNTM_14 = 1278, + EVENT_BEAT_ROCKET_GRUNTM_15 = 1279, + EVENT_BEAT_ROCKET_GRUNTM_16 = 1280, + EVENT_BEAT_ROCKET_GRUNTM_17 = 1281, + EVENT_BEAT_ROCKET_GRUNTM_18 = 1282, + EVENT_BEAT_ROCKET_GRUNTM_19 = 1283, + EVENT_BEAT_ROCKET_GRUNTM_2 = 1266, + EVENT_BEAT_ROCKET_GRUNTM_20 = 1284, + EVENT_BEAT_ROCKET_GRUNTM_21 = 1285, + EVENT_BEAT_ROCKET_GRUNTM_22 = 1286, + EVENT_BEAT_ROCKET_GRUNTM_23 = 1287, + EVENT_BEAT_ROCKET_GRUNTM_24 = 1288, + EVENT_BEAT_ROCKET_GRUNTM_25 = 1289, + EVENT_BEAT_ROCKET_GRUNTM_26 = 1290, + EVENT_BEAT_ROCKET_GRUNTM_27 = 1291, + EVENT_BEAT_ROCKET_GRUNTM_28 = 1292, + EVENT_BEAT_ROCKET_GRUNTM_29 = 1293, + EVENT_BEAT_ROCKET_GRUNTM_3 = 1267, + EVENT_BEAT_ROCKET_GRUNTM_30 = 1294, + EVENT_BEAT_ROCKET_GRUNTM_31 = 1295, + EVENT_BEAT_ROCKET_GRUNTM_4 = 1268, + EVENT_BEAT_ROCKET_GRUNTM_5 = 1269, + EVENT_BEAT_ROCKET_GRUNTM_6 = 1270, + EVENT_BEAT_ROCKET_GRUNTM_7 = 1271, + EVENT_BEAT_ROCKET_GRUNTM_8 = 1272, + EVENT_BEAT_ROCKET_GRUNTM_9 = 1273, + EVENT_BEAT_SABRINA = 1226, + EVENT_BEAT_SAGE_CHOW = 1041, + EVENT_BEAT_SAGE_EDMOND = 1047, + EVENT_BEAT_SAGE_JEFFREY = 1045, + EVENT_BEAT_SAGE_JIN = 1043, + EVENT_BEAT_SAGE_LI = 1049, + EVENT_BEAT_SAGE_NEAL = 1048, + EVENT_BEAT_SAGE_NICO = 1042, + EVENT_BEAT_SAGE_PING = 1046, + EVENT_BEAT_SAGE_TROY = 1044, + EVENT_BEAT_SAILOR_ERNEST = 1401, + EVENT_BEAT_SAILOR_EUGENE = 1397, + EVENT_BEAT_SAILOR_GARRETT = 1403, + EVENT_BEAT_SAILOR_HARRY = 1406, + EVENT_BEAT_SAILOR_HUEY = 1398, + EVENT_BEAT_SAILOR_HUEY2 = 1407, + EVENT_BEAT_SAILOR_HUEY3 = 1408, + EVENT_BEAT_SAILOR_JEFF = 1402, + EVENT_BEAT_SAILOR_KENNETH = 1404, + EVENT_BEAT_SAILOR_KENT = 1400, + EVENT_BEAT_SAILOR_STANLY = 1405, + EVENT_BEAT_SAILOR_TERRELL = 1399, + EVENT_BEAT_SCHOOLBOY_ALAN = 1134, + EVENT_BEAT_SCHOOLBOY_ALAN2 = 1146, + EVENT_BEAT_SCHOOLBOY_ALAN3 = 1147, + EVENT_BEAT_SCHOOLBOY_BILLY = 1140, + EVENT_BEAT_SCHOOLBOY_CHAD = 1141, + EVENT_BEAT_SCHOOLBOY_CHAD2 = 1148, + EVENT_BEAT_SCHOOLBOY_CHAD3 = 1149, + EVENT_BEAT_SCHOOLBOY_DANNY = 1136, + EVENT_BEAT_SCHOOLBOY_DUDLEY = 1138, + EVENT_BEAT_SCHOOLBOY_JACK = 1132, + EVENT_BEAT_SCHOOLBOY_JACK2 = 1144, + EVENT_BEAT_SCHOOLBOY_JACK3 = 1145, + EVENT_BEAT_SCHOOLBOY_JOE = 1139, + EVENT_BEAT_SCHOOLBOY_JOHNNY = 1135, + EVENT_BEAT_SCHOOLBOY_KIP = 1133, + EVENT_BEAT_SCHOOLBOY_NATE = 1142, + EVENT_BEAT_SCHOOLBOY_RICKY = 1143, + EVENT_BEAT_SCHOOLBOY_TOMMY = 1137, + EVENT_BEAT_SCIENTIST_JED = 1185, + EVENT_BEAT_SCIENTIST_MARC = 1186, + EVENT_BEAT_SCIENTIST_MITCH = 1184, + EVENT_BEAT_SCIENTIST_RICH = 1187, + EVENT_BEAT_SCIENTIST_ROSS = 1183, + EVENT_BEAT_SKIER_CLARISSA = 1427, + EVENT_BEAT_SKIER_ROXANNE = 1426, + EVENT_BEAT_SUPER_NERD_ERIC = 1410, + EVENT_BEAT_SUPER_NERD_ERIC_UNUSED = 1409, + EVENT_BEAT_SUPER_NERD_GREGG = 1411, + EVENT_BEAT_SUPER_NERD_JAY = 1412, + EVENT_BEAT_SUPER_NERD_PAT = 1416, + EVENT_BEAT_SUPER_NERD_SAM = 1414, + EVENT_BEAT_SUPER_NERD_SHAWN = 1417, + EVENT_BEAT_SUPER_NERD_TERU = 1418, + EVENT_BEAT_SUPER_NERD_TOM = 1415, + EVENT_BEAT_SWIMMERF_BRIANA = 1018, + EVENT_BEAT_SWIMMERF_DAWN = 1011, + EVENT_BEAT_SWIMMERF_DENISE = 1004, + EVENT_BEAT_SWIMMERF_DIANA = 1017, + EVENT_BEAT_SWIMMERF_ELAINE = 1000, + EVENT_BEAT_SWIMMERF_JILL = 1008, + EVENT_BEAT_SWIMMERF_JODY = 1015, + EVENT_BEAT_SWIMMERF_KARA = 1005, + EVENT_BEAT_SWIMMERF_KATIE = 1010, + EVENT_BEAT_SWIMMERF_KAYLEE = 1002, + EVENT_BEAT_SWIMMERF_LISA = 1007, + EVENT_BEAT_SWIMMERF_LORI = 1014, + EVENT_BEAT_SWIMMERF_MARY = 1009, + EVENT_BEAT_SWIMMERF_NICOLE = 1013, + EVENT_BEAT_SWIMMERF_NIKKI = 1016, + EVENT_BEAT_SWIMMERF_PAULA = 1001, + EVENT_BEAT_SWIMMERF_SUSIE = 1003, + EVENT_BEAT_SWIMMERF_TARA = 1012, + EVENT_BEAT_SWIMMERF_WENDY = 1006, + EVENT_BEAT_SWIMMERM_BERKE = 1433, + EVENT_BEAT_SWIMMERM_CAMERON = 1444, + EVENT_BEAT_SWIMMERM_CHARLIE = 1431, + EVENT_BEAT_SWIMMERM_DARYL = 1438, + EVENT_BEAT_SWIMMERM_GEORGE = 1432, + EVENT_BEAT_SWIMMERM_HAL = 1436, + EVENT_BEAT_SWIMMERM_HAROLD = 1428, + EVENT_BEAT_SWIMMERM_JAMES = 1446, + EVENT_BEAT_SWIMMERM_JEROME = 1441, + EVENT_BEAT_SWIMMERM_KIRK = 1434, + EVENT_BEAT_SWIMMERM_LEWIS = 1447, + EVENT_BEAT_SWIMMERM_MATHEW = 1435, + EVENT_BEAT_SWIMMERM_PARKER = 1448, + EVENT_BEAT_SWIMMERM_PATON = 1437, + EVENT_BEAT_SWIMMERM_RANDALL = 1430, + EVENT_BEAT_SWIMMERM_RICK = 1443, + EVENT_BEAT_SWIMMERM_SETH = 1445, + EVENT_BEAT_SWIMMERM_SIMON = 1429, + EVENT_BEAT_SWIMMERM_TONY = 1440, + EVENT_BEAT_SWIMMERM_TUCKER = 1442, + EVENT_BEAT_SWIMMERM_WALTER = 1439, + EVENT_BEAT_TEACHER_COLETTE = 1461, + EVENT_BEAT_TEACHER_HILLARY = 1462, + EVENT_BEAT_TEACHER_SHIRLEY = 1463, + EVENT_BEAT_TWINS_AMY_AND_MAY = 1124, + EVENT_BEAT_TWINS_AMY_AND_MAY2 = 1127, + EVENT_BEAT_TWINS_ANN_AND_ANNE = 1125, + EVENT_BEAT_TWINS_ANN_AND_ANNE2 = 1126, + EVENT_BEAT_TWINS_JO_AND_ZOE = 1128, + EVENT_BEAT_TWINS_JO_AND_ZOE2 = 1129, + EVENT_BEAT_TWINS_MEG_AND_PEG = 1130, + EVENT_BEAT_TWINS_MEG_AND_PEG2 = 1131, + EVENT_BEAT_WHITNEY = 1215, + EVENT_BEAT_YOUNGSTER_ALBERT = 1451, + EVENT_BEAT_YOUNGSTER_GORDON = 1452, + EVENT_BEAT_YOUNGSTER_IAN = 1454, + EVENT_BEAT_YOUNGSTER_JASON = 1460, + EVENT_BEAT_YOUNGSTER_JIMMY = 1458, + EVENT_BEAT_YOUNGSTER_JOEY = 1449, + EVENT_BEAT_YOUNGSTER_JOEY2 = 1455, + EVENT_BEAT_YOUNGSTER_JOEY3 = 1456, + EVENT_BEAT_YOUNGSTER_MIKEY = 1450, + EVENT_BEAT_YOUNGSTER_OWEN = 1459, + EVENT_BEAT_YOUNGSTER_SAMUEL = 1453, + EVENT_BEAT_YOUNGSTER_WARREN = 1457, + EVENT_BEAY_SUPER_NERD_DAVE = 1413, + EVENT_BETH_ASKED_FOR_PHONE_NUMBER = 621, + EVENT_BETH_READY_FOR_REMATCH = 622, + EVENT_BEVERLY_ASKED_FOR_PHONE_NUMBER = 609, + EVENT_BEVERLY_READY_FOR_REMATCH = 610, + EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN = 1868, + EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN = 1869, + EVENT_BLACKTHORN_CITY_SANTOS_OF_SATURDAY = 1885, + EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM = 1763, + EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM = 1764, + EVENT_BLUE_IN_CINNABAR = 1909, + EVENT_BOULDER_IN_BLACKTHORN_GYM_1 = 1798, + EVENT_BOULDER_IN_BLACKTHORN_GYM_2 = 1799, + EVENT_BOULDER_IN_BLACKTHORN_GYM_3 = 1800, + EVENT_BOULDER_IN_ICE_PATH_1 = 1801, + EVENT_BOULDER_IN_ICE_PATH_1A = 1805, + EVENT_BOULDER_IN_ICE_PATH_2 = 1802, + EVENT_BOULDER_IN_ICE_PATH_2A = 1806, + EVENT_BOULDER_IN_ICE_PATH_3 = 1803, + EVENT_BOULDER_IN_ICE_PATH_3A = 1807, + EVENT_BOULDER_IN_ICE_PATH_4 = 1804, + EVENT_BOULDER_IN_ICE_PATH_4A = 1808, + EVENT_BRENT_ASKED_FOR_PHONE_NUMBER = 657, + EVENT_BRENT_READY_FOR_REMATCH = 658, + EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED = 781, + EVENT_BRUNOS_ROOM_EXIT_OPEN = 782, + EVENT_BUG_CATCHING_CONTESTANT_10A = 1823, + EVENT_BUG_CATCHING_CONTESTANT_10B = 1833, + EVENT_BUG_CATCHING_CONTESTANT_1A = 1814, + EVENT_BUG_CATCHING_CONTESTANT_1B = 1824, + EVENT_BUG_CATCHING_CONTESTANT_2A = 1815, + EVENT_BUG_CATCHING_CONTESTANT_2B = 1825, + EVENT_BUG_CATCHING_CONTESTANT_3A = 1816, + EVENT_BUG_CATCHING_CONTESTANT_3B = 1826, + EVENT_BUG_CATCHING_CONTESTANT_4A = 1817, + EVENT_BUG_CATCHING_CONTESTANT_4B = 1827, + EVENT_BUG_CATCHING_CONTESTANT_5A = 1818, + EVENT_BUG_CATCHING_CONTESTANT_5B = 1828, + EVENT_BUG_CATCHING_CONTESTANT_6A = 1819, + EVENT_BUG_CATCHING_CONTESTANT_6B = 1829, + EVENT_BUG_CATCHING_CONTESTANT_7A = 1820, + EVENT_BUG_CATCHING_CONTESTANT_7B = 1830, + EVENT_BUG_CATCHING_CONTESTANT_8A = 1821, + EVENT_BUG_CATCHING_CONTESTANT_8B = 1831, + EVENT_BUG_CATCHING_CONTESTANT_9A = 1822, + EVENT_BUG_CATCHING_CONTESTANT_9B = 1832, + EVENT_BURNED_TOWER_1F_BURN_HEAL = 1621, + EVENT_BURNED_TOWER_1F_HIDDEN_ETHER = 128, + EVENT_BURNED_TOWER_1F_X_SPEED = 1620, + EVENT_BURNED_TOWER_B1F_BEASTS_1 = 1866, + EVENT_BURNED_TOWER_B1F_BEASTS_2 = 1867, + EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL = 130, + EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET = 131, + EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL = 129, + EVENT_BURNED_TOWER_B1F_HP_UP = 1622, + EVENT_BURNED_TOWER_B1F_TM_ENDURE = 1623, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES = 1893, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL = 1892, + EVENT_CELADON_CITY_HIDDEN_PP_UP = 253, + EVENT_CERULEAN_GYM_ROCKET = 1901, + EVENT_CHAD_ASKED_FOR_PHONE_NUMBER = 651, + EVENT_CHAD_READY_FOR_REMATCH = 652, + EVENT_CHARCOAL_KILN_APPRENTICE = 1782, + EVENT_CHARCOAL_KILN_BOSS = 1783, + EVENT_CHARCOAL_KILN_FARFETCH_D = 1781, + EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB = 1602, + EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER = 655, + EVENT_CHRIS_READY_FOR_REMATCH = 656, + EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER = 179, + EVENT_CIANWOOD_CITY_HIDDEN_REVIVE = 178, + EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY = 254, + EVENT_CINNABAR_ROCKS_CLEARED = 215, + EVENT_CLEARED_NUGGET_BRIDGE = 216, + EVENT_CLEARED_RADIO_TOWER = 33, + EVENT_CLEARED_ROCKET_HIDEOUT = 34, + EVENT_CLEARED_SLOWPOKE_WELL = 43, + EVENT_CONTEST_OFFICER_HAS_BERRY = 790, + EVENT_CONTEST_OFFICER_HAS_EVERSTONE = 788, + EVENT_CONTEST_OFFICER_HAS_GOLD_BERRY = 789, + EVENT_CONTEST_OFFICER_HAS_SUN_STONE = 787, + EVENT_COPYCATS_HOUSE_2F_DOLL = 1907, + EVENT_COP_IN_ELMS_LAB = 1794, + EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB = 1600, + EVENT_DANA_ASKED_FOR_PHONE_NUMBER = 649, + EVENT_DANA_READY_FOR_REMATCH = 650, + EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_REVIVE = 1697, + EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_TM_SNORE = 1698, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_FULL_HEAL = 1695, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_HIDDEN_ELIXER = 157, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_HYPER_POTION = 1696, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_POTION = 1694, + EVENT_DAY_CARE_MAN_IN_DAY_CARE = 1765, + EVENT_DAY_CARE_MAN_ON_ROUTE_34 = 1766, + EVENT_DAY_CARE_MON_1 = 1767, + EVENT_DAY_CARE_MON_2 = 1768, + EVENT_DECIDED_TO_HELP_LANCE = 96, + EVENT_DECO_BED_1 = 676, + EVENT_DECO_BED_2 = 677, + EVENT_DECO_BED_3 = 678, + EVENT_DECO_BED_4 = 679, + EVENT_DECO_BIG_LAPRAS_DOLL = 721, + EVENT_DECO_BIG_ONIX_DOLL = 720, + EVENT_DECO_BIG_SNORLAX_DOLL = 719, + EVENT_DECO_BULBASAUR_DOLL = 699, + EVENT_DECO_CARPET_1 = 680, + EVENT_DECO_CARPET_2 = 681, + EVENT_DECO_CARPET_3 = 682, + EVENT_DECO_CARPET_4 = 683, + EVENT_DECO_CHARMANDER_DOLL = 700, + EVENT_DECO_CLEFAIRY_DOLL = 697, + EVENT_DECO_DIGLETT_DOLL = 703, + EVENT_DECO_FAMICOM = 691, + EVENT_DECO_GENGAR_DOLL = 707, + EVENT_DECO_GEODUDE_DOLL = 713, + EVENT_DECO_GOLD_TROPHY = 717, + EVENT_DECO_GRIMER_DOLL = 709, + EVENT_DECO_JIGGLYPUFF_DOLL = 698, + EVENT_DECO_MACHOP_DOLL = 714, + EVENT_DECO_MAGIKARP_DOLL = 705, + EVENT_DECO_N64 = 693, + EVENT_DECO_ODDISH_DOLL = 706, + EVENT_DECO_PIKACHU_DOLL = 695, + EVENT_DECO_PLANT_1 = 684, + EVENT_DECO_PLANT_2 = 685, + EVENT_DECO_PLANT_3 = 686, + EVENT_DECO_POLIWAG_DOLL = 702, + EVENT_DECO_POSTER_1 = 687, + EVENT_DECO_POSTER_2 = 688, + EVENT_DECO_POSTER_3 = 689, + EVENT_DECO_POSTER_4 = 690, + EVENT_DECO_SHELLDER_DOLL = 708, + EVENT_DECO_SILVER_TROPHY = 718, + EVENT_DECO_SNES = 692, + EVENT_DECO_SQUIRTLE_DOLL = 701, + EVENT_DECO_STARYU_DOLL = 704, + EVENT_DECO_SURFING_PIKACHU_DOLL = 696, + EVENT_DECO_TENTACOOL_DOLL = 715, + EVENT_DECO_UNOWN_DOLL = 712, + EVENT_DECO_VIRTUAL_BOY = 694, + EVENT_DECO_VOLTORB_DOLL = 710, + EVENT_DECO_WEEDLE_DOLL = 711, + EVENT_DEREK_ASKED_FOR_PHONE_NUMBER = 653, + EVENT_DEREK_READY_FOR_REMATCH = 654, + EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE = 228, + EVENT_DOOR_10_OPEN = 736, + EVENT_DOOR_11_OPEN = 737, + EVENT_DOOR_1_OPEN = 727, + EVENT_DOOR_2_OPEN = 728, + EVENT_DOOR_3_OPEN = 729, + EVENT_DOOR_4_OPEN = 730, + EVENT_DOOR_5_OPEN = 731, + EVENT_DOOR_6_OPEN = 732, + EVENT_DOOR_7_OPEN = 733, + EVENT_DOOR_8_OPEN = 734, + EVENT_DOOR_9_OPEN = 735, + EVENT_DRAGONS_DEN_B1F_DRAGON_FANG = 1704, + EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_ELIXER = 162, + EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_POTION = 161, + EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE = 160, + EVENT_DRAGONS_DEN_CLAIR = 1752, + EVENT_DUDE_TALKED_TO_YOU = 65, + EVENT_EARLS_ACADEMY_EARL = 1739, + EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION = 180, + EVENT_ECRUTEAK_POKE_CENTER_BILL = 1811, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT = 1894, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT = 1895, + EVENT_ELMS_AIDE_IN_LAB = 1793, + EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 1792, + EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON = 67, + EVENT_EMERGENCY_SWITCH = 726, + EVENT_ENABLE_DIPLOMA_PRINTING = 214, + EVENT_ERIN_ASKED_FOR_PHONE_NUMBER = 669, + EVENT_ERIN_READY_FOR_REMATCH = 670, + EVENT_EXPLODING_TRAP_1 = 745, + EVENT_EXPLODING_TRAP_10 = 754, + EVENT_EXPLODING_TRAP_11 = 755, + EVENT_EXPLODING_TRAP_12 = 756, + EVENT_EXPLODING_TRAP_13 = 757, + EVENT_EXPLODING_TRAP_14 = 758, + EVENT_EXPLODING_TRAP_15 = 759, + EVENT_EXPLODING_TRAP_16 = 760, + EVENT_EXPLODING_TRAP_17 = 761, + EVENT_EXPLODING_TRAP_18 = 762, + EVENT_EXPLODING_TRAP_19 = 763, + EVENT_EXPLODING_TRAP_2 = 746, + EVENT_EXPLODING_TRAP_20 = 764, + EVENT_EXPLODING_TRAP_21 = 765, + EVENT_EXPLODING_TRAP_22 = 766, + EVENT_EXPLODING_TRAP_3 = 747, + EVENT_EXPLODING_TRAP_4 = 748, + EVENT_EXPLODING_TRAP_5 = 749, + EVENT_EXPLODING_TRAP_6 = 750, + EVENT_EXPLODING_TRAP_7 = 751, + EVENT_EXPLODING_TRAP_8 = 752, + EVENT_EXPLODING_TRAP_9 = 753, + EVENT_FAST_SHIP_1F_GENTLEMAN = 1836, + EVENT_FAST_SHIP_B1F_SAILOR_LEFT = 1838, + EVENT_FAST_SHIP_B1F_SAILOR_RIGHT = 1839, + EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR = 1837, + EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_1 = 1841, + EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2 = 1842, + EVENT_FAST_SHIP_CABINS_SE_SSE_GENTLEMAN = 1840, + EVENT_FAST_SHIP_DESTINATION_OLIVINE = 47, + EVENT_FAST_SHIP_FIRST_TIME = 48, + EVENT_FAST_SHIP_FOUND_GIRL = 50, + EVENT_FAST_SHIP_HAS_ARRIVED = 49, + EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR = 52, + EVENT_FAST_SHIP_LAZY_SAILOR = 51, + EVENT_FAST_SHIP_PASSENGERS_EASTBOUND = 1850, + EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP = 1849, + EVENT_FAST_SHIP_PASSENGERS_WESTBOUND = 1851, + EVENT_FIRST_TIME_BANKING_WITH_MOM = 118, + EVENT_FOUGHT_HO_OH = 791, + EVENT_FOUGHT_LUGIA = 792, + EVENT_FOUGHT_SNORLAX = 1872, + EVENT_FOUGHT_SUDOWOODO = 42, + EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY = 250, + EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE = 249, + EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 251, + EVENT_GAVEN_ASKED_FOR_PHONE_NUMBER = 619, + EVENT_GAVEN_READY_FOR_REMATCH = 620, + EVENT_GAVE_KENYA = 81, + EVENT_GAVE_KURT_BLK_APRICORN = 605, + EVENT_GAVE_KURT_BLU_APRICORN = 601, + EVENT_GAVE_KURT_GRN_APRICORN = 603, + EVENT_GAVE_KURT_PNK_APRICORN = 606, + EVENT_GAVE_KURT_RED_APRICORN = 600, + EVENT_GAVE_KURT_WHT_APRICORN = 604, + EVENT_GAVE_KURT_YLW_APRICORN = 602, + EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, + EVENT_GINA_ASKED_FOR_PHONE_NUMBER = 639, + EVENT_GINA_READY_FOR_REMATCH = 640, + EVENT_GOLDENROD_CITY_CIVILIANS = 1743, + EVENT_GOLDENROD_CITY_ROCKET_SCOUT = 1740, + EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER = 1741, + EVENT_GOLDENROD_DEPT_STORE_5F_HAPPINESS_EVENT_LADY = 1891, + EVENT_GOLDENROD_DEPT_STORE_B1F_AMULET_COIN = 1654, + EVENT_GOLDENROD_DEPT_STORE_B1F_BURN_HEAL = 1655, + EVENT_GOLDENROD_DEPT_STORE_B1F_ETHER = 1653, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1 = 772, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_2 = 773, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_3 = 774, + EVENT_GOLDENROD_DEPT_STORE_B1F_ULTRA_BALL = 1656, + EVENT_GOLDENROD_TRAIN_STATION_GENTLEMAN = 1865, + EVENT_GOLDENROD_UNDERGROUND_COIN_CASE = 1650, + EVENT_GOLDENROD_UNDERGROUND_GRAMPS = 1875, + EVENT_GOLDENROD_UNDERGROUND_GRANNY = 1874, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE = 141, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL = 139, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION = 140, + EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER = 1876, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL = 1652, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_MAX_POTION = 142, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_REVIVE = 143, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL = 1651, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF = 775, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_MAX_ETHER = 1657, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_TM_SLEEP_TALK = 1658, + EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER = 1877, + EVENT_GOT_A_POKEMON_FROM_ELM = 26, + EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE = 39, + EVENT_GOT_BICYCLE = 91, + EVENT_GOT_BLACKBELT_FROM_WESLEY = 107, + EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE = 114, + EVENT_GOT_CALCIUM_FROM_ERIN = 617, + EVENT_GOT_CARBOS_FROM_VANCE = 615, + EVENT_GOT_CHARCOAL_IN_CHARCOAL_KILN = 94, + EVENT_GOT_CHIKORITA_FROM_ELM = 29, + EVENT_GOT_CLEANSE_TAG = 219, + EVENT_GOT_COINS_FROM_GAMBLER_AT_CELADON = 206, + EVENT_GOT_CYNDAQUIL_FROM_ELM = 27, + EVENT_GOT_EEVEE = 79, + EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 800, + EVENT_GOT_EVERSTONE_FROM_ELM = 86, + EVENT_GOT_FIRE_STONE_FROM_BILLS_GRANDPA = 803, + EVENT_GOT_GOOD_ROD = 24, + EVENT_GOT_HARD_STONE_FROM_ARTHUR = 103, + EVENT_GOT_HM01_CUT = 16, + EVENT_GOT_HM02_FLY = 17, + EVENT_GOT_HM03_SURF = 18, + EVENT_GOT_HM04_STRENGTH = 19, + EVENT_GOT_HM05_FLASH = 20, + EVENT_GOT_HM06_WHIRLPOOL = 21, + EVENT_GOT_HM07_WATERFALL = 1672, + EVENT_GOT_HP_UP_FROM_JOEY = 614, + EVENT_GOT_HP_UP_FROM_RANDY = 82, + EVENT_GOT_HP_UP_FROM_VERMILION_GUY = 226, + EVENT_GOT_IRON_FROM_PARRY = 616, + EVENT_GOT_ITEMFINDER = 90, + EVENT_GOT_KENYA = 80, + EVENT_GOT_KINGS_ROCK_IN_SLOWPOKE_WELL = 115, + EVENT_GOT_LEAF_STONE_FROM_BILLS_GRANDPA = 801, + EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB = 210, + EVENT_GOT_MAGNET_FROM_SUNNY = 105, + EVENT_GOT_MASTER_BALL_FROM_ELM = 124, + EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA = 113, + EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32 = 93, + EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30, + EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE = 77, + EVENT_GOT_NUGGET_FROM_GUY = 200, + EVENT_GOT_OLD_ROD = 23, + EVENT_GOT_PASS_FROM_COPYCAT = 209, + EVENT_GOT_PINK_BOW_FROM_MARY = 72, + EVENT_GOT_PINK_BOW_FROM_TUSCANY = 101, + EVENT_GOT_POISON_BARB_FROM_FRIEDA = 99, + EVENT_GOT_PROTEIN_FROM_HUEY = 613, + EVENT_GOT_QUICK_CLAW = 87, + EVENT_GOT_RAINBOW_WING = 120, + EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 35, + EVENT_GOT_SHARP_BEAK_FROM_MONICA = 111, + EVENT_GOT_SHUCKIE = 69, + EVENT_GOT_SILVER_WING = 121, + EVENT_GOT_SOFT_SAND_FROM_KATE = 112, + EVENT_GOT_SPELL_TAG_FROM_SANTOS = 109, + EVENT_GOT_SQUIRTBOTTLE = 92, + EVENT_GOT_SS_TICKET_FROM_ELM = 36, + EVENT_GOT_SUNNY_DAY_FROM_RADIO_TOWER = 71, + EVENT_GOT_SUPER_ROD = 25, + EVENT_GOT_THUNDERSTONE_FROM_BILLS_GRANDPA = 804, + EVENT_GOT_TM01_DYNAMICPUNCH = 10, + EVENT_GOT_TM02_HEADBUTT = 95, + EVENT_GOT_TM03_CURSE = 218, + EVENT_GOT_TM05_ROAR = 78, + EVENT_GOT_TM06_TOXIC = 221, + EVENT_GOT_TM07_ZAP_CANNON = 223, + EVENT_GOT_TM08_ROCK_SMASH = 75, + EVENT_GOT_TM10_HIDDEN_POWER = 88, + EVENT_GOT_TM12_SWEET_SCENT = 122, + EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM = 62, + EVENT_GOT_TM16_ICY_WIND = 14, + EVENT_GOT_TM19_GIGA_DRAIN = 220, + EVENT_GOT_TM23_IRON_TAIL = 13, + EVENT_GOT_TM24_DRAGONBREATH = 15, + EVENT_GOT_TM29_PSYCHIC = 227, + EVENT_GOT_TM30_SHADOW_BALL = 12, + EVENT_GOT_TM31_MUD_SLAP = 8, + EVENT_GOT_TM36_SLUDGE_BOMB = 89, + EVENT_GOT_TM37_SANDSTORM = 117, + EVENT_GOT_TM42_DREAM_EATER = 224, + EVENT_GOT_TM45_ATTRACT = 11, + EVENT_GOT_TM47_STEEL_WING = 116, + EVENT_GOT_TM49_FURY_CUTTER = 9, + EVENT_GOT_TM50_NIGHTMARE = 83, + EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 45, + EVENT_GOT_TOTODILE_FROM_ELM = 28, + EVENT_GOT_TYROGUE_FROM_KIYO = 97, + EVENT_GOT_UP_GRADE = 222, + EVENT_GOT_WATER_STONE_FROM_BILLS_GRANDPA = 802, + EVENT_GUIDE_GENT_IN_HIS_HOUSE = 1790, + EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE = 1791, + EVENT_HEALED_MOOMOO = 61, + EVENT_HERDED_FARFETCHD = 41, + EVENT_HUEY_ASKED_FOR_PHONE_NUMBER = 611, + EVENT_HUEY_READY_FOR_REMATCH = 612, + EVENT_ICE_PATH_1F_PP_UP = 1673, + EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION = 148, + EVENT_ICE_PATH_B1F_IRON = 1674, + EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_HIDDEN_ICE_HEAL = 150, + EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST = 1677, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_FULL_HEAL = 1675, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_HIDDEN_CARBOS = 149, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_MAX_POTION = 1676, + EVENT_ICE_PATH_B3F_NEVERMELTICE = 1678, + EVENT_ILEX_FOREST_APPRENTICE = 1779, + EVENT_ILEX_FOREST_CHARCOAL_MASTER = 1780, + EVENT_ILEX_FOREST_FARFETCHD_1 = 1769, + EVENT_ILEX_FOREST_FARFETCHD_10 = 1778, + EVENT_ILEX_FOREST_FARFETCHD_2 = 1770, + EVENT_ILEX_FOREST_FARFETCHD_3 = 1771, + EVENT_ILEX_FOREST_FARFETCHD_4 = 1772, + EVENT_ILEX_FOREST_FARFETCHD_5 = 1773, + EVENT_ILEX_FOREST_FARFETCHD_6 = 1774, + EVENT_ILEX_FOREST_FARFETCHD_7 = 1775, + EVENT_ILEX_FOREST_FARFETCHD_8 = 1776, + EVENT_ILEX_FOREST_FARFETCHD_9 = 1777, + EVENT_ILEX_FOREST_HIDDEN_ETHER = 136, + EVENT_ILEX_FOREST_HIDDEN_FULL_HEAL = 138, + EVENT_ILEX_FOREST_HIDDEN_SUPER_POTION = 137, + EVENT_ILEX_FOREST_REVIVE = 1649, + EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL = 1915, + EVENT_INITIALIZED_EVENTS = 54, + EVENT_IRWIN_ASKED_FOR_PHONE_NUMBER = 641, + EVENT_IRWIN_READY_FOR_REMATCH = 642, + EVENT_JACK_ASKED_FOR_PHONE_NUMBER = 607, + EVENT_JACK_READY_FOR_REMATCH = 608, + EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 55, + EVENT_JASMINE_RETURNED_TO_GYM = 32, + EVENT_JOEY_ASKED_FOR_PHONE_NUMBER = 627, + EVENT_JOEY_READY_FOR_REMATCH = 628, + EVENT_JOSE_ASKED_FOR_PHONE_NUMBER = 623, + EVENT_JOSE_READY_FOR_REMATCH = 624, + EVENT_KARENS_ROOM_ENTRANCE_CLOSED = 783, + EVENT_KARENS_ROOM_EXIT_OPEN = 784, + EVENT_KENJI_ASKED_FOR_PHONE_NUMBER = 665, + EVENT_KENJI_ON_BREAK = 618, + EVENT_KENJI_READY_FOR_REMATCH = 666, + EVENT_KOGAS_ROOM_ENTRANCE_CLOSED = 779, + EVENT_KOGAS_ROOM_EXIT_OPEN = 780, + EVENT_KURTS_HOUSE_KURT_1 = 1854, + EVENT_KURTS_HOUSE_KURT_2 = 1855, + EVENT_KURTS_HOUSE_SLOWPOKE = 1789, + EVENT_KURT_GAVE_YOU_LURE_BALL = 53, + EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 57, + EVENT_LAKE_OF_RAGE_CIVILIANS = 1845, + EVENT_LAKE_OF_RAGE_ETHER_ON_STANDBY = 58, + EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 56, + EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE = 181, + EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION = 183, + EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY = 182, + EVENT_LAKE_OF_RAGE_LANCE = 1748, + EVENT_LAKE_OF_RAGE_MAX_ETHER = 1605, + EVENT_LAKE_OF_RAGE_RED_GYARADOS = 1873, + EVENT_LAKE_OF_RAGE_TM_DETECT = 1606, + EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY = 1884, + EVENT_LANCES_ROOM_ENTRANCE_CLOSED = 785, + EVENT_LANCES_ROOM_EXIT_OPEN = 786, + EVENT_LANCES_ROOM_OAK_AND_MARY = 1887, + EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 76, + EVENT_LEARNED_HAIL_GIOVANNI = 767, + EVENT_LEARNED_RATICATE_TAIL = 770, + EVENT_LEARNED_SLOWPOKETAIL = 769, + EVENT_LEARNED_TO_CATCH_POKEMON = 66, + EVENT_LEFT_MONS_WITH_CONTEST_OFFICER = 776, + EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT = 212, + EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL = 211, + EVENT_LISTENED_TO_INITIAL_RADIO = 805, + EVENT_LIZ_ASKED_FOR_PHONE_NUMBER = 633, + EVENT_LIZ_READY_FOR_REMATCH = 634, + EVENT_MADE_UNOWN_APPEAR_IN_RUINS = 46, + EVENT_MADE_WHITNEY_CRY = 40, + EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE = 1749, + EVENT_MAHOGANY_MART_OWNERS = 1846, + EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST = 1878, + EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM = 1879, + EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 70, + EVENT_MET_ARTHUR_OF_THURSDAY = 102, + EVENT_MET_BILL = 1810, + EVENT_MET_BILLS_GRANDPA = 794, + EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM = 207, + EVENT_MET_FRIEDA_OF_FRIDAY = 98, + EVENT_MET_MANAGER_AT_POWER_PLANT = 202, + EVENT_MET_MONICA_OF_MONDAY = 110, + EVENT_MET_REDS_MOM = 204, + EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM = 203, + EVENT_MET_SANTOS_OF_SATURDAY = 108, + EVENT_MET_SUNNY_OF_SUNDAY = 104, + EVENT_MET_TUSCANY_OF_TUESDAY = 100, + EVENT_MET_WESLEY_OF_WEDNESDAY = 106, + EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE = 236, + EVENT_MOUNT_MORTAR_1F_INSIDE_ESCAPE_ROPE = 1661, + EVENT_MOUNT_MORTAR_1F_INSIDE_HIDDEN_MAX_REPEL = 145, + EVENT_MOUNT_MORTAR_1F_INSIDE_HYPER_POTION = 1663, + EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_REVIVE = 1662, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_ETHER = 1659, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_HIDDEN_HYPER_POTION = 144, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE = 1660, + EVENT_MOUNT_MORTAR_2F_INSIDE_DRAGON_SCALE = 1667, + EVENT_MOUNT_MORTAR_2F_INSIDE_ELIXER = 1668, + EVENT_MOUNT_MORTAR_2F_INSIDE_ESCAPE_ROPE = 1669, + EVENT_MOUNT_MORTAR_2F_INSIDE_HIDDEN_FULL_RESTORE = 146, + EVENT_MOUNT_MORTAR_2F_INSIDE_MAX_POTION = 1664, + EVENT_MOUNT_MORTAR_2F_INSIDE_RARE_CANDY = 1665, + EVENT_MOUNT_MORTAR_2F_INSIDE_TM_DEFENSE_CURL = 1666, + EVENT_MOUNT_MORTAR_B1F_FULL_HEAL = 1671, + EVENT_MOUNT_MORTAR_B1F_HIDDEN_MAX_REVIVE = 147, + EVENT_MOUNT_MORTAR_B1F_HYPER_POTION = 1670, + EVENT_MR_POKEMONS_HOUSE_OAK = 1737, + EVENT_MT_MOON_RIVAL = 1914, + EVENT_MT_MOON_SQUARE_CLEFAIRY = 1913, + EVENT_MT_MOON_SQUARE_ROCK = 1912, + EVENT_MYSTERY_GIFT_DELIVERY_GUY = 1809, + EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL = 132, + EVENT_NATIONAL_PARK_PARLYZ_HEAL = 1624, + EVENT_NATIONAL_PARK_TM_DIG = 1625, + EVENT_OLIVINE_GYM_JASMINE = 1747, + EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER = 1636, + EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL = 1638, + EVENT_OLIVINE_LIGHTHOUSE_5F_HIDDEN_HYPER_POTION = 133, + EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY = 1637, + EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER = 1639, + EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION = 1640, + EVENT_OLIVINE_LIGHTHOUSE_JASMINE = 1746, + EVENT_OLIVINE_PORT_HIDDEN_PROTEIN = 234, + EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M = 1843, + EVENT_OLIVINE_PORT_SAILOR_AT_GANGWAY = 1834, + EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME = 1848, + EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME = 1847, + EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE = 771, + EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER = 768, + EVENT_OPENED_MT_SILVER = 1871, + EVENT_PARRY_ASKED_FOR_PHONE_NUMBER = 667, + EVENT_PARRY_READY_FOR_REMATCH = 668, + EVENT_PICKED_UP_FOCUS_BAND = 1917, + EVENT_PLAYERS_HOUSE_2F_BIG_DOLL = 1860, + EVENT_PLAYERS_HOUSE_2F_CONSOLE = 1857, + EVENT_PLAYERS_HOUSE_2F_DOLL_1 = 1858, + EVENT_PLAYERS_HOUSE_2F_DOLL_2 = 1859, + EVENT_PLAYERS_HOUSE_MOM_1 = 1735, + EVENT_PLAYERS_HOUSE_MOM_2 = 1736, + EVENT_PLAYERS_ROOM_POSTER = 716, + EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS = 1745, + EVENT_RADIO_TOWER_CIVILIANS_AFTER = 1744, + EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 1742, + EVENT_RALPH_ASKED_FOR_PHONE_NUMBER = 631, + EVENT_RALPH_READY_FOR_REMATCH = 632, + EVENT_RECEIVED_CARD_KEY = 74, + EVENT_RED_IN_MT_SILVER = 1890, + EVENT_REENA_ASKED_FOR_PHONE_NUMBER = 625, + EVENT_REENA_READY_FOR_REMATCH = 626, + EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE = 38, + EVENT_REFUSED_TO_TAKE_EGG_FROM_ELMS_AIDE = 44, + EVENT_RELEASED_THE_BEASTS = 123, + EVENT_RESTORED_POWER_TO_KANTO = 205, + EVENT_RETURNED_LOST_ITEM_TO_COPYCAT = 208, + EVENT_RETURNED_MACHINE_PART = 201, + EVENT_RIVAL_AZALEA_TOWN = 1727, + EVENT_RIVAL_BURNED_TOWER = 1733, + EVENT_RIVAL_CHERRYGROVE_CITY = 1726, + EVENT_RIVAL_DRAGONS_DEN = 1734, + EVENT_RIVAL_GOLDENROD_UNDERGROUND = 1729, + EVENT_RIVAL_NEW_BARK_TOWN = 1725, + EVENT_RIVAL_OLIVINE_CITY = 1731, + EVENT_RIVAL_SPROUT_TOWER = 1732, + EVENT_RIVAL_TEAM_ROCKET_BASE = 1728, + EVENT_RIVAL_VICTORY_ROAD = 1730, + EVENT_ROCK_TUNNEL_1F_ELIXER = 1918, + EVENT_ROCK_TUNNEL_1F_HIDDEN_X_ACCURACY = 231, + EVENT_ROCK_TUNNEL_1F_HIDDEN_X_DEFEND = 232, + EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING = 1919, + EVENT_ROCK_TUNNEL_B1F_HIDDEN_MAX_POTION = 233, + EVENT_ROCK_TUNNEL_B1F_IRON = 1920, + EVENT_ROCK_TUNNEL_B1F_PP_UP = 1921, + EVENT_ROCK_TUNNEL_B1F_REVIVE = 1922, + EVENT_ROUTE_11_HIDDEN_REVIVE = 245, + EVENT_ROUTE_12_CALCIUM = 1928, + EVENT_ROUTE_12_HIDDEN_ELIXER = 243, + EVENT_ROUTE_12_NUGGET = 1929, + EVENT_ROUTE_13_HIDDEN_CALCIUM = 244, + EVENT_ROUTE_15_PP_UP = 1930, + EVENT_ROUTE_17_HIDDEN_MAX_ELIXER = 247, + EVENT_ROUTE_17_HIDDEN_MAX_ETHER = 246, + EVENT_ROUTE_24_ROCKET = 1900, + EVENT_ROUTE_25_HIDDEN_POTION = 248, + EVENT_ROUTE_25_MISTY_BOYFRIEND = 1902, + EVENT_ROUTE_25_PROTEIN = 1931, + EVENT_ROUTE_26_MAX_ELIXER = 1706, + EVENT_ROUTE_27_RARE_CANDY = 1708, + EVENT_ROUTE_27_TM_SOLARBEAM = 1707, + EVENT_ROUTE_28_HIDDEN_RARE_CANDY = 163, + EVENT_ROUTE_29_POTION = 1709, + EVENT_ROUTE_29_TUSCANY_OF_TUESDAY = 1881, + EVENT_ROUTE_2_CARBOS = 1925, + EVENT_ROUTE_2_DIRE_HIT = 1923, + EVENT_ROUTE_2_ELIXER = 1926, + EVENT_ROUTE_2_HIDDEN_FULL_HEAL = 238, + EVENT_ROUTE_2_HIDDEN_FULL_RESTORE = 239, + EVENT_ROUTE_2_HIDDEN_MAX_ETHER = 237, + EVENT_ROUTE_2_HIDDEN_REVIVE = 240, + EVENT_ROUTE_2_MAX_POTION = 1924, + EVENT_ROUTE_30_BATTLE = 1812, + EVENT_ROUTE_30_HIDDEN_POTION = 164, + EVENT_ROUTE_30_YOUNGSTER_JOEY = 1813, + EVENT_ROUTE_31_ANTIDOTE = 1710, + EVENT_ROUTE_31_POKE_BALL = 1711, + EVENT_ROUTE_32_FRIEDA_OF_FRIDAY = 1880, + EVENT_ROUTE_32_GREAT_BALL = 1712, + EVENT_ROUTE_32_HIDDEN_GREAT_BALL = 165, + EVENT_ROUTE_32_HIDDEN_SUPER_POTION = 166, + EVENT_ROUTE_32_POTION = 1713, + EVENT_ROUTE_34_HIDDEN_RARE_CANDY = 167, + EVENT_ROUTE_34_HIDDEN_SUPER_POTION = 168, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1861, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1862, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_YOUNGSTER = 1844, + EVENT_ROUTE_35_TM_ROLLOUT = 1714, + EVENT_ROUTE_36_ARTHUR_OF_THURSDAY = 1882, + EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1863, + EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1864, + EVENT_ROUTE_36_SUDOWOODO = 1784, + EVENT_ROUTE_37_HIDDEN_ETHER = 169, + EVENT_ROUTE_37_SUNNY_OF_SUNDAY = 1883, + EVENT_ROUTE_39_HIDDEN_NUGGET = 170, + EVENT_ROUTE_40_HIDDEN_HYPER_POTION = 171, + EVENT_ROUTE_40_MONICA_OF_MONDAY = 1886, + EVENT_ROUTE_41_HIDDEN_MAX_ETHER = 172, + EVENT_ROUTE_42_HIDDEN_MAX_POTION = 173, + EVENT_ROUTE_42_SUPER_POTION = 1716, + EVENT_ROUTE_42_ULTRA_BALL = 1715, + EVENT_ROUTE_43_GATE_ROCKETS = 1756, + EVENT_ROUTE_43_MAX_ETHER = 1717, + EVENT_ROUTE_44_HIDDEN_ELIXER = 174, + EVENT_ROUTE_44_MAX_REVIVE = 1718, + EVENT_ROUTE_44_ULTRA_BALL = 1719, + EVENT_ROUTE_45_ELIXER = 1722, + EVENT_ROUTE_45_HIDDEN_PP_UP = 175, + EVENT_ROUTE_45_MAX_POTION = 1723, + EVENT_ROUTE_45_REVIVE = 1721, + EVENT_ROUTE_45_X_SPECIAL = 1720, + EVENT_ROUTE_46_DIRE_HIT = 1724, + EVENT_ROUTE_4_HIDDEN_ULTRA_BALL = 241, + EVENT_ROUTE_4_HP_UP = 1927, + EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH = 1905, + EVENT_ROUTE_9_HIDDEN_ETHER = 242, + EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS = 1797, + EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST = 1870, + EVENT_RUINS_OF_ALPH_OUTSIDE_SCIENTIST = 1795, + EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST = 1796, + EVENT_SAFFRON_TRAIN_STATION_POPULATION = 1906, + EVENT_SEAFOAM_GYM_GYM_GUIDE = 1911, + EVENT_SECURITY_CAMERA_1 = 740, + EVENT_SECURITY_CAMERA_2 = 741, + EVENT_SECURITY_CAMERA_3 = 742, + EVENT_SECURITY_CAMERA_4 = 743, + EVENT_SECURITY_CAMERA_5 = 744, + EVENT_SHOWED_GROWLITHE_VULPIX_TO_BILLS_GRANDPA = 798, + EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 795, + EVENT_SHOWED_ODDISH_TO_BILLS_GRANDPA = 796, + EVENT_SHOWED_PICHU_TO_BILLS_GRANDPA = 799, + EVENT_SHOWED_STARYU_TO_BILLS_GRANDPA = 797, + EVENT_SHOWED_TOGEPI_TO_ELM = 85, + EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE = 1693, + EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE = 1692, + EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE = 184, + EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE = 1691, + EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT = 154, + EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL = 155, + EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER = 1689, + EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY = 1690, + EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION = 156, + EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION = 1634, + EVENT_SLOWPOKE_WELL_B2F_TM_RAIN_DANCE = 1635, + EVENT_SLOWPOKE_WELL_KURT = 1856, + EVENT_SLOWPOKE_WELL_ROCKETS = 1788, + EVENT_SLOWPOKE_WELL_SLOWPOKES = 1787, + EVENT_SOLVED_AERODACTYL_PUZZLE = 675, + EVENT_SOLVED_HO_OH_PUZZLE = 672, + EVENT_SOLVED_KABUTO_PUZZLE = 673, + EVENT_SOLVED_OMANYTE_PUZZLE = 674, + EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL = 1607, + EVENT_SPROUT_TOWER_2F_X_DEFEND = 1608, + EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE = 1610, + EVENT_SPROUT_TOWER_3F_POTION = 1609, + EVENT_SWITCH_1 = 723, + EVENT_SWITCH_2 = 724, + EVENT_SWITCH_3 = 725, + EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 63, + EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64, + EVENT_TALKED_TO_OAK_IN_KANTO = 225, + EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE = 213, + EVENT_TALKED_TO_WARDENS_GRANDDAUGHTER = 217, + EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE = 134, + EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION = 1641, + EVENT_TEAM_ROCKET_BASE_B1F_NUGGET = 1642, + EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY = 1643, + EVENT_TEAM_ROCKET_BASE_B2F_DRAGONITE = 1759, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_1 = 1760, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_2 = 1761, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_3 = 1762, + EVENT_TEAM_ROCKET_BASE_B2F_EXECUTIVE = 1757, + EVENT_TEAM_ROCKET_BASE_B2F_GRUNT_WITH_EXECUTIVE = 1758, + EVENT_TEAM_ROCKET_BASE_B2F_HIDDEN_FULL_HEAL = 135, + EVENT_TEAM_ROCKET_BASE_B2F_LANCE = 1750, + EVENT_TEAM_ROCKET_BASE_B2F_TM_THIEF = 1644, + EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT = 1646, + EVENT_TEAM_ROCKET_BASE_B3F_EXECUTIVE = 1755, + EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL = 1645, + EVENT_TEAM_ROCKET_BASE_B3F_ICE_HEAL = 1648, + EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS = 1751, + EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN = 1647, + EVENT_TEAM_ROCKET_BASE_POPULATION = 1754, + EVENT_TEAM_ROCKET_BASE_SECURITY_GRUNTS = 1753, + EVENT_TEAM_ROCKET_DISBANDED = 1889, + EVENT_TELEPORT_GUY = 1916, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 = 0, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2 = 1, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_3 = 2, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_4 = 3, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_5 = 4, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_6 = 5, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7 = 6, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 = 7, + EVENT_TIFFANY_ASKED_FOR_PHONE_NUMBER = 659, + EVENT_TIFFANY_READY_FOR_REMATCH = 660, + EVENT_TIN_TOWER_3F_FULL_HEAL = 1611, + EVENT_TIN_TOWER_4F_ESCAPE_ROPE = 1614, + EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION = 125, + EVENT_TIN_TOWER_4F_SUPER_POTION = 1613, + EVENT_TIN_TOWER_4F_ULTRA_BALL = 1612, + EVENT_TIN_TOWER_5F_HIDDEN_CARBOS = 127, + EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE = 126, + EVENT_TIN_TOWER_5F_RARE_CANDY = 1615, + EVENT_TIN_TOWER_7F_MAX_REVIVE = 1616, + EVENT_TIN_TOWER_8F_FULL_RESTORE = 1619, + EVENT_TIN_TOWER_8F_MAX_ELIXER = 1618, + EVENT_TIN_TOWER_8F_NUGGET = 1617, + EVENT_TIN_TOWER_ROOF_HO_OH = 1852, + EVENT_TODD_ASKED_FOR_PHONE_NUMBER = 637, + EVENT_TODD_READY_FOR_REMATCH = 638, + EVENT_TOGEPI_HATCHED = 84, + EVENT_TOHJO_FALLS_MOON_STONE = 1705, + EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE = 119, + EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB = 1601, + EVENT_TRAINERS_IN_CERULEAN_GYM = 1903, + EVENT_TURNED_OFF_SECURITY_CAMERAS = 739, + EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 738, + EVENT_UNDERGROUND_PATH_HIDDEN_FULL_RESTORE = 229, + EVENT_UNDERGROUND_PATH_HIDDEN_X_SPECIAL = 230, + EVENT_UNION_CAVE_1F_AWAKENING = 1629, + EVENT_UNION_CAVE_1F_GREAT_BALL = 1626, + EVENT_UNION_CAVE_1F_POTION = 1627, + EVENT_UNION_CAVE_1F_X_ATTACK = 1628, + EVENT_UNION_CAVE_B1F_TM_SWIFT = 1630, + EVENT_UNION_CAVE_B1F_X_DEFEND = 1631, + EVENT_UNION_CAVE_B2F_ELIXER = 1632, + EVENT_UNION_CAVE_B2F_HYPER_POTION = 1633, + EVENT_UNION_CAVE_B2F_LAPRAS = 1888, + EVENT_USED_BASEMENT_KEY = 73, + EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 37, + EVENT_VANCE_ASKED_FOR_PHONE_NUMBER = 661, + EVENT_VANCE_READY_FOR_REMATCH = 662, + EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL = 252, + EVENT_VERMILION_CITY_SNORLAX = 1904, + EVENT_VERMILION_FAN_CLUB_DOLL = 1908, + EVENT_VERMILION_PORT_HIDDEN_IRON = 235, + EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY = 1835, + EVENT_VICTORY_ROAD_FULL_HEAL = 1702, + EVENT_VICTORY_ROAD_FULL_RESTORE = 1701, + EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL = 159, + EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION = 158, + EVENT_VICTORY_ROAD_MAX_REVIVE = 1700, + EVENT_VICTORY_ROAD_TM_EARTHQUAKE = 1699, + EVENT_VICTORY_ROAD_X_SPECIAL = 1703, + EVENT_VIOLET_CITY_EARL = 1738, + EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION = 176, + EVENT_VIOLET_CITY_PP_UP = 1603, + EVENT_VIOLET_CITY_RARE_CANDY = 1604, + EVENT_VIRIDIAN_GYM_BLUE = 1910, + EVENT_WADE_ASKED_FOR_PHONE_NUMBER = 629, + EVENT_WADE_READY_FOR_REMATCH = 630, + EVENT_WARPED_FROM_ROUTE_35_NATIONAL_PARK_GATE = 722, + EVENT_WHIRL_ISLAND_B1F_CALCIUM = 1683, + EVENT_WHIRL_ISLAND_B1F_CARBOS = 1682, + EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE = 1685, + EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE = 1681, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE = 153, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY = 151, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL = 152, + EVENT_WHIRL_ISLAND_B1F_NUGGET = 1684, + EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE = 1686, + EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER = 1688, + EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE = 1687, + EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA = 1853, + EVENT_WHIRL_ISLAND_NE_ULTRA_BALL = 1679, + EVENT_WHIRL_ISLAND_SW_GUARD_SPEC = 1680, + EVENT_WILLS_ROOM_ENTRANCE_CLOSED = 777, + EVENT_WILLS_ROOM_EXIT_OPEN = 778, + EVENT_WILTON_ASKED_FOR_PHONE_NUMBER = 663, + EVENT_WILTON_READY_FOR_REMATCH = 664, + }, + engine = { + ENGINE_ALL_FRUIT_TREES = 83, + ENGINE_ALWAYS_ON_BIKE = 24, + ENGINE_BIKE_SHOP_CALL_ENABLED = 19, + ENGINE_BOULDERBADGE = 34, + ENGINE_BUG_CONTEST_TIMER = 16, + ENGINE_CASCADEBADGE = 35, + ENGINE_CAUGHT_POKERUS = 13, + ENGINE_CREDITS_SKIP = 15, + ENGINE_DAILY_BUG_CONTEST = 80, + ENGINE_DAISYS_GROOMING = 91, + ENGINE_DAY_CARE_LADY_HAS_MON = 7, + ENGINE_DAY_CARE_MAN_HAS_EGG = 5, + ENGINE_DAY_CARE_MAN_HAS_MON = 6, + ENGINE_DOWNHILL = 25, + ENGINE_EARTHBADGE = 41, + ENGINE_EXPN_CARD = 3, + ENGINE_FLYPOINT_AZALEA = 67, + ENGINE_FLYPOINT_BLACKTHORN = 74, + ENGINE_FLYPOINT_CELADON = 60, + ENGINE_FLYPOINT_CERULEAN = 55, + ENGINE_FLYPOINT_CHERRYGROVE = 65, + ENGINE_FLYPOINT_CIANWOOD = 68, + ENGINE_FLYPOINT_CINNABAR = 62, + ENGINE_FLYPOINT_DEBUG = 51, + ENGINE_FLYPOINT_ECRUTEAK = 71, + ENGINE_FLYPOINT_FUCHSIA = 61, + ENGINE_FLYPOINT_GOLDENROD = 69, + ENGINE_FLYPOINT_INDIGO_PLATEAU = 63, + ENGINE_FLYPOINT_LAKE_OF_RAGE = 73, + ENGINE_FLYPOINT_LAVENDER = 58, + ENGINE_FLYPOINT_MAHOGANY = 72, + ENGINE_FLYPOINT_NEW_BARK = 64, + ENGINE_FLYPOINT_OLIVINE = 70, + ENGINE_FLYPOINT_PALLET = 52, + ENGINE_FLYPOINT_PEWTER = 54, + ENGINE_FLYPOINT_PLAYERS_HOUSE = 50, + ENGINE_FLYPOINT_ROCK_TUNNEL = 56, + ENGINE_FLYPOINT_SAFFRON = 59, + ENGINE_FLYPOINT_SILVER_CAVE = 75, + ENGINE_FLYPOINT_UNUSED = 76, + ENGINE_FLYPOINT_VERMILION = 57, + ENGINE_FLYPOINT_VIOLET = 66, + ENGINE_FLYPOINT_VIRIDIAN = 53, + ENGINE_FOGBADGE = 29, + ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY = 86, + ENGINE_GLACIERBADGE = 32, + ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN = 90, + ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT = 89, + ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED = 85, + ENGINE_GOT_SHUCKIE_TODAY = 84, + ENGINE_HIVEBADGE = 27, + ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT = 92, + ENGINE_KURT_MAKING_BALLS = 79, + ENGINE_LUCKY_NUMBER_SHOW = 77, + ENGINE_MAP_CARD = 1, + ENGINE_MARSHBADGE = 39, + ENGINE_MINERALBADGE = 30, + ENGINE_MOM_ACTIVE = 9, + ENGINE_MOM_SAVING_MONEY = 8, + ENGINE_MT_MOON_SQUARE_CLEFAIRY = 87, + ENGINE_PHONE_CARD = 2, + ENGINE_PLAINBADGE = 28, + ENGINE_POKEDEX = 11, + ENGINE_POKEGEAR = 4, + ENGINE_RADIO_CARD = 0, + ENGINE_RAINBOWBADGE = 37, + ENGINE_REACHED_GOLDENROD = 21, + ENGINE_RISINGBADGE = 33, + ENGINE_ROCKETS_IN_MAHOGANY = 22, + ENGINE_ROCKETS_IN_RADIO_TOWER = 18, + ENGINE_ROCKET_SIGNAL_ON_CH20 = 14, + ENGINE_SAFARI_ZONE = 17, + ENGINE_SOULBADGE = 38, + ENGINE_STORMBADGE = 31, + ENGINE_STRENGTH_ACTIVE = 23, + ENGINE_SWARM = 81, + ENGINE_THUNDERBADGE = 36, + ENGINE_TIME_CAPSULE = 82, + ENGINE_UNION_CAVE_LAPRAS = 88, + ENGINE_UNLOCKED_UNOWNS_A_TO_K = 42, + ENGINE_UNLOCKED_UNOWNS_L_TO_R = 43, + ENGINE_UNLOCKED_UNOWNS_S_TO_W = 44, + ENGINE_UNLOCKED_UNOWNS_UNUSED_4 = 46, + ENGINE_UNLOCKED_UNOWNS_UNUSED_5 = 47, + ENGINE_UNLOCKED_UNOWNS_UNUSED_6 = 48, + ENGINE_UNLOCKED_UNOWNS_UNUSED_7 = 49, + ENGINE_UNLOCKED_UNOWNS_X_TO_Z = 45, + ENGINE_UNOWN_DEX = 12, + ENGINE_UNUSED_STATUSFLAGS2_3 = 78, + ENGINE_UNUSED_STATUSFLAGS2_5 = 20, + ENGINE_UNUSED_TWO_DAY_TIMER_ON = 10, + ENGINE_VOLCANOBADGE = 40, + ENGINE_ZEPHYRBADGE = 26, + }, +} diff --git a/tests/drivers/gold/map_regions.lua b/tests/drivers/gold/map_regions.lua new file mode 100644 index 00000000..fbc6e2f4 --- /dev/null +++ b/tests/drivers/gold/map_regions.lua @@ -0,0 +1,1897 @@ +-- GENERATED by tools/goldwalk/mapgraph.lua graph. +-- Region-aware map graph for the Gold route bot: see that +-- tool's `graph` command for what a region is and why the +-- bot cannot plan without one. Regenerate after any change +-- to the extractor's map output. +return { + ["AZALEA_GYM"] = { + { x = 0, y = 2, size = 98, exits = { {k="w",i=1,x=4,y=15,to="AZALEA_TOWN",r=2}, {k="w",i=2,x=5,y=15,to="AZALEA_TOWN",r=2} } }, + }, + ["AZALEA_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="AZALEA_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="AZALEA_TOWN",r=2} } }, + }, + ["AZALEA_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="AZALEA_TOWN",r=2}, {k="w",i=2,x=4,y=7,to="AZALEA_TOWN",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["AZALEA_TOWN"] = { + { x = 31, y = 0, size = 27, exits = { {k="e",d="east",to="ROUTE_33",r=1} } }, + { x = 6, y = 1, size = 243, exits = { {k="w",i=1,x=15,y=9,to="AZALEA_POKECENTER_1F",r=2}, {k="w",i=2,x=21,y=13,to="CHARCOAL_KILN",r=1}, {k="w",i=3,x=21,y=5,to="AZALEA_MART",r=2}, {k="w",i=4,x=9,y=5,to="KURTS_HOUSE",r=1}, {k="w",i=5,x=10,y=15,to="AZALEA_GYM",r=1}, {k="w",i=6,x=31,y=7,to="SLOWPOKE_WELL_B1F",r=2}, {k="w",i=6,x=31,y=7,to="SLOWPOKE_WELL_B1F",r=1}, {k="w",i=7,x=2,y=10,to="ILEX_FOREST_AZALEA_GATE",r=2}, {k="w",i=8,x=2,y=11,to="ILEX_FOREST_AZALEA_GATE",r=2}, {k="e",d="east",to="ROUTE_33",r=2} } }, + { x = 2, y = 8, size = 3, exits = { } }, + }, + ["BILLS_FAMILYS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["BILLS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_25",r=3}, {k="w",i=2,x=3,y=7,to="ROUTE_25",r=3} } }, + }, + ["BILLS_OLDER_SISTERS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=3,y=7,to="FUCHSIA_CITY",r=2} } }, + }, + ["BLACKTHORN_CITY"] = { + { x = 0, y = 0, size = 113, exits = { {k="e",d="west",to="ROUTE_44",r=1} } }, + { x = 16, y = 0, size = 286, exits = { {k="w",i=7,x=36,y=9,to="ICE_PATH_1F",r=3}, {k="w",i=8,x=20,y=1,to="DRAGONS_DEN_1F",r=2}, {k="e",d="south",to="ROUTE_45",r=1}, {k="e",d="south",to="ROUTE_45",r=3}, {k="e",d="west",to="ROUTE_44",r=1} } }, + { x = 29, y = 0, size = 85, exits = { } }, + { x = 20, y = 2, size = 3, exits = { {k="w",i=8,x=20,y=1,to="DRAGONS_DEN_1F",r=2} } }, + { x = 34, y = 10, size = 377, exits = { {k="w",i=1,x=18,y=11,to="BLACKTHORN_GYM_1F",r=3}, {k="w",i=2,x=13,y=21,to="BLACKTHORN_DRAGON_SPEECH_HOUSE",r=1}, {k="w",i=3,x=29,y=23,to="BLACKTHORN_EMYS_HOUSE",r=1}, {k="w",i=4,x=15,y=29,to="BLACKTHORN_MART",r=2}, {k="w",i=5,x=21,y=29,to="BLACKTHORN_POKECENTER_1F",r=2}, {k="w",i=6,x=9,y=31,to="MOVE_DELETERS_HOUSE",r=1}, {k="w",i=7,x=36,y=9,to="ICE_PATH_1F",r=3}, {k="e",d="south",to="ROUTE_45",r=2} } }, + { x = 19, y = 16, size = 12, exits = { } }, + { x = 33, y = 30, size = 50, exits = { {k="e",d="south",to="ROUTE_45",r=3} } }, + }, + ["BLACKTHORN_DRAGON_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=3,y=7,to="BLACKTHORN_CITY",r=5} } }, + }, + ["BLACKTHORN_EMYS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=3,y=7,to="BLACKTHORN_CITY",r=5} } }, + }, + ["BLACKTHORN_GYM_1F"] = { + { x = 3, y = 2, size = 13, exits = { } }, + { x = 9, y = 2, size = 7, exits = { } }, + { x = 0, y = 6, size = 67, exits = { {k="w",i=1,x=4,y=17,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=5,y=17,to="BLACKTHORN_CITY",r=5}, {k="w",i=3,x=1,y=7,to="BLACKTHORN_GYM_2F",r=1} } }, + { x = 6, y = 6, size = 12, exits = { {k="w",i=4,x=7,y=9,to="BLACKTHORN_GYM_2F",r=1} } }, + }, + ["BLACKTHORN_GYM_2F"] = { + { x = 0, y = 1, size = 119, exits = { {k="w",i=1,x=1,y=7,to="BLACKTHORN_GYM_1F",r=3}, {k="w",i=2,x=7,y=9,to="BLACKTHORN_GYM_1F",r=4}, {k="w",i=3,x=2,y=5,to="BLACKTHORN_GYM_1F",r=3}, {k="w",i=4,x=8,y=7,to="BLACKTHORN_GYM_1F",r=4}, {k="w",i=5,x=8,y=3,to="BLACKTHORN_GYM_1F",r=4} } }, + }, + ["BLACKTHORN_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=3,y=7,to="BLACKTHORN_CITY",r=5} } }, + }, + ["BLACKTHORN_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=4,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["BLUES_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="PALLET_TOWN",r=3}, {k="w",i=2,x=3,y=7,to="PALLET_TOWN",r=3} } }, + }, + ["BRUNOS_ROOM"] = { + { x = 0, y = 3, size = 64, exits = { {k="w",i=1,x=4,y=17,to="KOGAS_ROOM",r=1}, {k="w",i=2,x=5,y=17,to="KOGAS_ROOM",r=1} } }, + { x = 0, y = 16, size = 4, exits = { } }, + { x = 8, y = 16, size = 4, exits = { } }, + }, + ["BURNED_TOWER_1F"] = { + { x = 3, y = 1, size = 149, exits = { {k="w",i=1,x=9,y=15,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=10,y=15,to="ECRUTEAK_CITY",r=6}, {k="w",i=3,x=5,y=4,to="BURNED_TOWER_B1F",r=2}, {k="w",i=4,x=5,y=5,to="BURNED_TOWER_B1F",r=2}, {k="w",i=5,x=5,y=6,to="BURNED_TOWER_B1F",r=2}, {k="w",i=6,x=4,y=6,to="BURNED_TOWER_B1F",r=2}, {k="w",i=7,x=15,y=4,to="BURNED_TOWER_B1F",r=5}, {k="w",i=8,x=15,y=5,to="BURNED_TOWER_B1F",r=5}, {k="w",i=9,x=10,y=7,to="BURNED_TOWER_B1F",r=3}, {k="w",i=10,x=5,y=14,to="BURNED_TOWER_B1F",r=6}, {k="w",i=11,x=4,y=14,to="BURNED_TOWER_B1F",r=6}, {k="w",i=12,x=14,y=14,to="BURNED_TOWER_B1F",r=8}, {k="w",i=13,x=15,y=14,to="BURNED_TOWER_B1F",r=8}, {k="w",i=14,x=7,y=15,to="BURNED_TOWER_B1F",r=7}, {k="w",i=14,x=7,y=15,to="BURNED_TOWER_B1F",r=1} } }, + { x = 16, y = 5, size = 1, exits = { {k="w",i=8,x=15,y=5,to="BURNED_TOWER_B1F",r=5} } }, + }, + ["BURNED_TOWER_B1F"] = { + { x = 0, y = 0, size = 114, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 4, y = 2, size = 8, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 7, y = 2, size = 21, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 12, y = 2, size = 35, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 15, y = 2, size = 9, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 3, y = 12, size = 8, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 6, y = 12, size = 29, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + { x = 15, y = 12, size = 9, exits = { {k="w",i=6,x=7,y=15,to="BURNED_TOWER_1F",r=1} } }, + }, + ["CELADON_CAFE"] = { + { x = 0, y = 1, size = 49, exits = { {k="w",i=1,x=6,y=7,to="CELADON_CITY",r=1}, {k="w",i=2,x=7,y=7,to="CELADON_CITY",r=1} } }, + { x = 9, y = 1, size = 10, exits = { } }, + }, + ["CELADON_CITY"] = { + { x = 8, y = 2, size = 617, exits = { {k="w",i=1,x=4,y=9,to="CELADON_DEPT_STORE_1F",r=1}, {k="w",i=2,x=16,y=9,to="CELADON_MANSION_1F",r=2}, {k="w",i=2,x=16,y=9,to="CELADON_MANSION_1F",r=3}, {k="w",i=3,x=16,y=3,to="CELADON_MANSION_1F",r=1}, {k="w",i=4,x=17,y=3,to="CELADON_MANSION_1F",r=1}, {k="w",i=5,x=29,y=9,to="CELADON_POKECENTER_1F",r=2}, {k="w",i=6,x=18,y=19,to="CELADON_GAME_CORNER",r=2}, {k="w",i=7,x=23,y=19,to="CELADON_GAME_CORNER_PRIZE_ROOM",r=1}, {k="w",i=9,x=25,y=29,to="CELADON_CAFE",r=1}, {k="e",d="east",to="ROUTE_7",r=1}, {k="e",d="west",to="ROUTE_16",r=2} } }, + { x = 13, y = 16, size = 6, exits = { } }, + { x = 2, y = 30, size = 70, exits = { {k="w",i=8,x=10,y=29,to="CELADON_GYM",r=1} } }, + }, + ["CELADON_DEPT_STORE_1F"] = { + { x = 0, y = 1, size = 90, exits = { {k="w",i=1,x=7,y=7,to="CELADON_CITY",r=1}, {k="w",i=2,x=8,y=7,to="CELADON_CITY",r=1}, {k="w",i=3,x=15,y=0,to="CELADON_DEPT_STORE_2F",r=1}, {k="w",i=4,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 10, y = 1, size = 2, exits = { } }, + }, + ["CELADON_DEPT_STORE_2F"] = { + { x = 0, y = 1, size = 82, exits = { {k="w",i=1,x=12,y=0,to="CELADON_DEPT_STORE_3F",r=1}, {k="w",i=2,x=15,y=0,to="CELADON_DEPT_STORE_1F",r=1}, {k="w",i=3,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 13, y = 5, size = 4, exits = { } }, + }, + ["CELADON_DEPT_STORE_3F"] = { + { x = 0, y = 1, size = 84, exits = { {k="w",i=1,x=12,y=0,to="CELADON_DEPT_STORE_2F",r=1}, {k="w",i=2,x=15,y=0,to="CELADON_DEPT_STORE_4F",r=1}, {k="w",i=3,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 6, y = 1, size = 2, exits = { } }, + }, + ["CELADON_DEPT_STORE_4F"] = { + { x = 0, y = 1, size = 76, exits = { {k="w",i=1,x=12,y=0,to="CELADON_DEPT_STORE_5F",r=1}, {k="w",i=2,x=15,y=0,to="CELADON_DEPT_STORE_3F",r=1}, {k="w",i=3,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 13, y = 5, size = 3, exits = { } }, + }, + ["CELADON_DEPT_STORE_5F"] = { + { x = 0, y = 1, size = 92, exits = { {k="w",i=1,x=12,y=0,to="CELADON_DEPT_STORE_4F",r=1}, {k="w",i=2,x=15,y=0,to="CELADON_DEPT_STORE_6F",r=1}, {k="w",i=3,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 7, y = 5, size = 2, exits = { } }, + }, + ["CELADON_DEPT_STORE_6F"] = { + { x = 0, y = 1, size = 96, exits = { {k="w",i=1,x=15,y=0,to="CELADON_DEPT_STORE_5F",r=1}, {k="w",i=2,x=2,y=0,to="CELADON_DEPT_STORE_ELEVATOR",r=1} } }, + }, + ["CELADON_DEPT_STORE_ELEVATOR"] = { + { x = 0, y = 1, size = 10, exits = { {k="w",i=1,x=1,y=3,to="CELADON_DEPT_STORE_1F",r=1}, {k="w",i=2,x=2,y=3,to="CELADON_DEPT_STORE_1F",r=1} } }, + }, + ["CELADON_GAME_CORNER"] = { + { x = 0, y = 1, size = 14, exits = { } }, + { x = 8, y = 1, size = 162, exits = { {k="w",i=1,x=14,y=13,to="CELADON_CITY",r=1}, {k="w",i=2,x=15,y=13,to="CELADON_CITY",r=1} } }, + }, + ["CELADON_GAME_CORNER_PRIZE_ROOM"] = { + { x = 0, y = 2, size = 22, exits = { {k="w",i=1,x=2,y=5,to="CELADON_CITY",r=1}, {k="w",i=2,x=3,y=5,to="CELADON_CITY",r=1} } }, + }, + ["CELADON_GYM"] = { + { x = 2, y = 3, size = 92, exits = { {k="w",i=1,x=4,y=17,to="CELADON_CITY",r=3}, {k="w",i=2,x=5,y=17,to="CELADON_CITY",r=3} } }, + }, + ["CELADON_MANSION_1F"] = { + { x = 0, y = 1, size = 5, exits = { {k="w",i=3,x=3,y=0,to="CELADON_CITY",r=1}, {k="w",i=4,x=0,y=0,to="CELADON_MANSION_2F",r=1} } }, + { x = 6, y = 1, size = 34, exits = { {k="w",i=1,x=6,y=9,to="CELADON_CITY",r=1}, {k="w",i=2,x=7,y=9,to="CELADON_CITY",r=1}, {k="w",i=5,x=7,y=0,to="CELADON_MANSION_2F",r=2} } }, + { x = 0, y = 9, size = 6, exits = { {k="w",i=1,x=6,y=9,to="CELADON_CITY",r=1} } }, + }, + ["CELADON_MANSION_2F"] = { + { x = 0, y = 1, size = 5, exits = { {k="w",i=1,x=0,y=0,to="CELADON_MANSION_1F",r=1}, {k="w",i=2,x=1,y=0,to="CELADON_MANSION_3F",r=1} } }, + { x = 6, y = 1, size = 44, exits = { {k="w",i=3,x=6,y=0,to="CELADON_MANSION_3F",r=2}, {k="w",i=4,x=7,y=0,to="CELADON_MANSION_1F",r=2} } }, + }, + ["CELADON_MANSION_3F"] = { + { x = 0, y = 1, size = 5, exits = { {k="w",i=1,x=0,y=0,to="CELADON_MANSION_ROOF",r=1}, {k="w",i=2,x=1,y=0,to="CELADON_MANSION_2F",r=1} } }, + { x = 6, y = 1, size = 43, exits = { {k="w",i=3,x=6,y=0,to="CELADON_MANSION_2F",r=2}, {k="w",i=4,x=7,y=0,to="CELADON_MANSION_ROOF",r=2} } }, + }, + ["CELADON_MANSION_ROOF"] = { + { x = 2, y = 1, size = 14, exits = { {k="w",i=1,x=1,y=1,to="CELADON_MANSION_3F",r=1}, {k="w",i=3,x=2,y=5,to="CELADON_MANSION_ROOF_HOUSE",r=1} } }, + { x = 6, y = 2, size = 12, exits = { {k="w",i=2,x=6,y=1,to="CELADON_MANSION_3F",r=2} } }, + }, + ["CELADON_MANSION_ROOF_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CELADON_MANSION_ROOF",r=1}, {k="w",i=2,x=3,y=7,to="CELADON_MANSION_ROOF",r=1} } }, + }, + ["CELADON_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="CELADON_CITY",r=1}, {k="w",i=2,x=4,y=7,to="CELADON_CITY",r=1}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["CELADON_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="CELADON_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["CERULEAN_CITY"] = { + { x = 0, y = 0, size = 71, exits = { {k="e",d="north",to="ROUTE_24",r=1}, {k="e",d="west",to="ROUTE_4",r=1} } }, + { x = 20, y = 0, size = 616, exits = { {k="w",i=1,x=7,y=15,to="CERULEAN_GYM_BADGE_SPEECH_HOUSE",r=1}, {k="w",i=2,x=28,y=17,to="CERULEAN_POLICE_STATION",r=1}, {k="w",i=3,x=13,y=19,to="CERULEAN_TRADE_SPEECH_HOUSE",r=1}, {k="w",i=4,x=19,y=21,to="CERULEAN_POKECENTER_1F",r=2}, {k="w",i=5,x=30,y=23,to="CERULEAN_GYM",r=1}, {k="w",i=6,x=25,y=29,to="CERULEAN_MART",r=2}, {k="e",d="east",to="ROUTE_9",r=4}, {k="e",d="north",to="ROUTE_24",r=3}, {k="e",d="south",to="ROUTE_5",r=1}, {k="e",d="south",to="ROUTE_5",r=2}, {k="e",d="west",to="ROUTE_4",r=3} } }, + { x = 23, y = 0, size = 3, exits = { {k="w",i=1,x=7,y=15,to="CERULEAN_GYM_BADGE_SPEECH_HOUSE",r=1}, {k="w",i=2,x=28,y=17,to="CERULEAN_POLICE_STATION",r=1}, {k="w",i=3,x=13,y=19,to="CERULEAN_TRADE_SPEECH_HOUSE",r=1}, {k="w",i=4,x=19,y=21,to="CERULEAN_POKECENTER_1F",r=2}, {k="w",i=5,x=30,y=23,to="CERULEAN_GYM",r=1}, {k="w",i=6,x=25,y=29,to="CERULEAN_MART",r=2}, {k="e",d="east",to="ROUTE_9",r=4}, {k="e",d="north",to="ROUTE_24",r=3}, {k="e",d="north",to="ROUTE_24",r=4}, {k="e",d="south",to="ROUTE_5",r=1}, {k="e",d="south",to="ROUTE_5",r=2}, {k="e",d="west",to="ROUTE_4",r=3} } }, + { x = 39, y = 4, size = 29, exits = { {k="e",d="east",to="ROUTE_9",r=1} } }, + { x = 0, y = 23, size = 58, exits = { {k="e",d="south",to="ROUTE_5",r=1}, {k="e",d="west",to="ROUTE_4",r=4}, {k="e",d="west",to="ROUTE_4",r=1} } }, + { x = 37, y = 24, size = 43, exits = { {k="e",d="east",to="ROUTE_9",r=6}, {k="e",d="south",to="ROUTE_5",r=1} } }, + }, + ["CERULEAN_GYM"] = { + { x = 0, y = 1, size = 86, exits = { {k="w",i=1,x=4,y=15,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=5,y=15,to="CERULEAN_CITY",r=2} } }, + }, + ["CERULEAN_GYM_BADGE_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CERULEAN_CITY",r=2} } }, + }, + ["CERULEAN_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CERULEAN_CITY",r=2} } }, + }, + ["CERULEAN_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=4,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["CERULEAN_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="CERULEAN_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["CERULEAN_POLICE_STATION"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CERULEAN_CITY",r=2} } }, + }, + ["CERULEAN_TRADE_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CERULEAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CERULEAN_CITY",r=2} } }, + }, + ["CHARCOAL_KILN"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="AZALEA_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="AZALEA_TOWN",r=2} } }, + }, + ["CHERRYGROVE_CITY"] = { + { x = 16, y = 0, size = 194, exits = { {k="w",i=1,x=23,y=3,to="CHERRYGROVE_MART",r=2}, {k="w",i=2,x=29,y=3,to="CHERRYGROVE_POKECENTER_1F",r=2}, {k="w",i=3,x=17,y=7,to="CHERRYGROVE_GYM_SPEECH_HOUSE",r=1}, {k="w",i=4,x=25,y=9,to="GUIDE_GENTS_HOUSE",r=1}, {k="w",i=5,x=31,y=11,to="CHERRYGROVE_EVOLUTION_SPEECH_HOUSE",r=1}, {k="e",d="east",to="ROUTE_29",r=1}, {k="e",d="north",to="ROUTE_30",r=1} } }, + { x = 35, y = 2, size = 5, exits = { } }, + { x = 4, y = 12, size = 8, exits = { } }, + }, + ["CHERRYGROVE_EVOLUTION_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=2,x=3,y=7,to="CHERRYGROVE_CITY",r=1} } }, + }, + ["CHERRYGROVE_GYM_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=2,x=3,y=7,to="CHERRYGROVE_CITY",r=1} } }, + }, + ["CHERRYGROVE_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=2,x=3,y=7,to="CHERRYGROVE_CITY",r=1} } }, + }, + ["CHERRYGROVE_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=2,x=4,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["CIANWOOD_CITY"] = { + { x = 0, y = 0, size = 552, exits = { } }, + { x = 10, y = 14, size = 364, exits = { {k="w",i=1,x=17,y=41,to="MANIAS_HOUSE",r=1}, {k="w",i=2,x=8,y=43,to="CIANWOOD_GYM",r=1}, {k="w",i=3,x=23,y=43,to="CIANWOOD_POKECENTER_1F",r=2}, {k="w",i=4,x=15,y=47,to="CIANWOOD_PHARMACY",r=1}, {k="w",i=5,x=9,y=31,to="CIANWOOD_PHOTO_STUDIO",r=1}, {k="w",i=6,x=15,y=37,to="CIANWOOD_LUGIA_SPEECH_HOUSE",r=1} } }, + }, + ["CIANWOOD_GYM"] = { + { x = 1, y = 1, size = 92, exits = { {k="w",i=1,x=4,y=17,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=5,y=17,to="CIANWOOD_CITY",r=2} } }, + { x = 1, y = 6, size = 2, exits = { } }, + { x = 0, y = 17, size = 2, exits = { } }, + { x = 8, y = 17, size = 2, exits = { } }, + }, + ["CIANWOOD_LUGIA_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CIANWOOD_CITY",r=2} } }, + }, + ["CIANWOOD_PHARMACY"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CIANWOOD_CITY",r=2} } }, + }, + ["CIANWOOD_PHOTO_STUDIO"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CIANWOOD_CITY",r=2} } }, + }, + ["CIANWOOD_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=4,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["CINNABAR_ISLAND"] = { + { x = 11, y = 0, size = 89, exits = { } }, + { x = 5, y = 1, size = 51, exits = { {k="w",i=1,x=11,y=11,to="CINNABAR_POKECENTER_1F",r=2} } }, + }, + ["CINNABAR_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="CINNABAR_ISLAND",r=2}, {k="w",i=2,x=4,y=7,to="CINNABAR_ISLAND",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["CINNABAR_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="CINNABAR_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["COLOSSEUM"] = { + { x = 0, y = 1, size = 64, exits = { {k="w",i=1,x=4,y=7,to="POKECENTER_2F",r=2}, {k="w",i=2,x=5,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["COPYCATS_HOUSE_1F"] = { + { x = 2, y = 1, size = 46, exits = { {k="w",i=1,x=2,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=3,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=2,y=0,to="COPYCATS_HOUSE_2F",r=1} } }, + }, + ["COPYCATS_HOUSE_2F"] = { + { x = 2, y = 1, size = 31, exits = { {k="w",i=1,x=3,y=0,to="COPYCATS_HOUSE_1F",r=1} } }, + }, + ["DANCE_THEATER"] = { + { x = 0, y = 1, size = 142, exits = { {k="w",i=1,x=5,y=13,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=6,y=13,to="ECRUTEAK_CITY",r=6} } }, + }, + ["DARK_CAVE_BLACKTHORN_ENTRANCE"] = { + { x = 0, y = 0, size = 405, exits = { } }, + { x = 6, y = 2, size = 138, exits = { {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=1}, {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=3} } }, + { x = 21, y = 2, size = 20, exits = { {k="w",i=1,x=23,y=3,to="ROUTE_45",r=1}, {k="w",i=1,x=23,y=3,to="ROUTE_45",r=2} } }, + { x = 15, y = 8, size = 22, exits = { } }, + { x = 7, y = 14, size = 20, exits = { } }, + { x = 2, y = 16, size = 4, exits = { {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=1}, {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=3} } }, + { x = 2, y = 18, size = 25, exits = { {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=1}, {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=3} } }, + { x = 7, y = 20, size = 75, exits = { {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=1}, {k="w",i=2,x=3,y=25,to="DARK_CAVE_VIOLET_ENTRANCE",r=3} } }, + }, + ["DARK_CAVE_VIOLET_ENTRANCE"] = { + { x = 0, y = 0, size = 746, exits = { {k="w",i=2,x=17,y=1,to="DARK_CAVE_BLACKTHORN_ENTRANCE",r=7} } }, + { x = 3, y = 2, size = 269, exits = { {k="w",i=1,x=3,y=15,to="ROUTE_31",r=1}, {k="w",i=1,x=3,y=15,to="ROUTE_31",r=2}, {k="w",i=3,x=35,y=33,to="ROUTE_46",r=1}, {k="w",i=3,x=35,y=33,to="ROUTE_46",r=3} } }, + { x = 16, y = 2, size = 8, exits = { {k="w",i=2,x=17,y=1,to="DARK_CAVE_BLACKTHORN_ENTRANCE",r=7} } }, + { x = 31, y = 4, size = 30, exits = { } }, + { x = 33, y = 16, size = 10, exits = { } }, + { x = 33, y = 24, size = 14, exits = { } }, + }, + ["DAY_CARE"] = { + { x = 2, y = 1, size = 43, exits = { {k="w",i=1,x=0,y=5,to="ROUTE_34",r=2}, {k="w",i=2,x=0,y=6,to="ROUTE_34",r=2}, {k="w",i=3,x=2,y=7,to="ROUTE_34",r=4}, {k="w",i=4,x=3,y=7,to="ROUTE_34",r=4} } }, + }, + ["DAY_OF_WEEK_SIBLINGS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_26",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_26",r=2} } }, + }, + ["DIGLETTS_CAVE"] = { + { x = 0, y = 0, size = 460, exits = { {k="w",i=5,x=17,y=33,to="DIGLETTS_CAVE",r=4} } }, + { x = 2, y = 2, size = 97, exits = { {k="w",i=5,x=17,y=33,to="DIGLETTS_CAVE",r=4}, {k="w",i=6,x=3,y=3,to="DIGLETTS_CAVE",r=3} } }, + { x = 14, y = 2, size = 13, exits = { {k="w",i=3,x=15,y=5,to="ROUTE_2",r=3}, {k="w",i=3,x=15,y=5,to="ROUTE_2",r=2}, {k="w",i=4,x=17,y=3,to="DIGLETTS_CAVE",r=2} } }, + { x = 3, y = 30, size = 13, exits = { {k="w",i=1,x=3,y=33,to="VERMILION_CITY",r=5}, {k="w",i=1,x=3,y=33,to="VERMILION_CITY",r=2}, {k="w",i=2,x=5,y=31,to="DIGLETTS_CAVE",r=2}, {k="w",i=2,x=5,y=31,to="DIGLETTS_CAVE",r=1} } }, + }, + ["DRAGONS_DEN_1F"] = { + { x = 0, y = 0, size = 108, exits = { } }, + { x = 2, y = 2, size = 11, exits = { {k="w",i=1,x=3,y=5,to="BLACKTHORN_CITY",r=2}, {k="w",i=1,x=3,y=5,to="BLACKTHORN_CITY",r=4}, {k="w",i=2,x=3,y=3,to="DRAGONS_DEN_1F",r=3} } }, + { x = 4, y = 12, size = 16, exits = { {k="w",i=3,x=5,y=15,to="DRAGONS_DEN_B1F",r=3}, {k="w",i=3,x=5,y=15,to="DRAGONS_DEN_B1F",r=4}, {k="w",i=4,x=5,y=13,to="DRAGONS_DEN_1F",r=2} } }, + }, + ["DRAGONS_DEN_B1F"] = { + { x = 0, y = 0, size = 11, exits = { } }, + { x = 6, y = 0, size = 242, exits = { } }, + { x = 10, y = 2, size = 216, exits = { {k="w",i=1,x=20,y=3,to="DRAGONS_DEN_1F",r=3} } }, + { x = 12, y = 4, size = 56, exits = { {k="w",i=1,x=20,y=3,to="DRAGONS_DEN_1F",r=3} } }, + { x = 8, y = 8, size = 16, exits = { } }, + { x = 4, y = 16, size = 12, exits = { } }, + { x = 34, y = 16, size = 12, exits = { } }, + { x = 18, y = 22, size = 27, exits = { } }, + { x = 0, y = 26, size = 26, exits = { } }, + { x = 28, y = 28, size = 12, exits = { } }, + { x = 35, y = 34, size = 10, exits = { } }, + }, + ["EARLS_POKEMON_ACADEMY"] = { + { x = 2, y = 1, size = 99, exits = { {k="w",i=1,x=3,y=15,to="VIOLET_CITY",r=3}, {k="w",i=2,x=4,y=15,to="VIOLET_CITY",r=3} } }, + }, + ["ECRUTEAK_CITY"] = { + { x = 3, y = 0, size = 3, exits = { } }, + { x = 8, y = 0, size = 9, exits = { } }, + { x = 10, y = 1, size = 1, exits = { } }, + { x = 21, y = 2, size = 54, exits = { {k="w",i=4,x=20,y=2,to="ECRUTEAK_TIN_TOWER_BACK_ENTRANCE",r=1}, {k="w",i=5,x=20,y=3,to="ECRUTEAK_TIN_TOWER_BACK_ENTRANCE",r=1}, {k="w",i=12,x=37,y=7,to="TIN_TOWER_1F",r=1} } }, + { x = 1, y = 4, size = 2, exits = { } }, + { x = 2, y = 6, size = 430, exits = { {k="w",i=1,x=35,y=26,to="ROUTE_42_ECRUTEAK_GATE",r=2}, {k="w",i=2,x=35,y=27,to="ROUTE_42_ECRUTEAK_GATE",r=2}, {k="w",i=3,x=18,y=11,to="ECRUTEAK_TIN_TOWER_ENTRANCE",r=2}, {k="w",i=6,x=23,y=27,to="ECRUTEAK_POKECENTER_1F",r=2}, {k="w",i=7,x=5,y=21,to="ECRUTEAK_LUGIA_SPEECH_HOUSE",r=1}, {k="w",i=8,x=23,y=21,to="DANCE_THEATER",r=1}, {k="w",i=9,x=29,y=21,to="ECRUTEAK_MART",r=2}, {k="w",i=10,x=6,y=27,to="ECRUTEAK_GYM",r=1}, {k="w",i=11,x=13,y=27,to="ECRUTEAK_ITEMFINDER_HOUSE",r=1}, {k="w",i=13,x=5,y=5,to="BURNED_TOWER_1F",r=1}, {k="w",i=14,x=0,y=18,to="ROUTE_38_ECRUTEAK_GATE",r=2}, {k="w",i=15,x=0,y=19,to="ROUTE_38_ECRUTEAK_GATE",r=2}, {k="e",d="south",to="ROUTE_37",r=1} } }, + { x = 10, y = 6, size = 4, exits = { } }, + }, + ["ECRUTEAK_GYM"] = { + { x = 1, y = 1, size = 85, exits = { {k="w",i=1,x=4,y=17,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=5,y=17,to="ECRUTEAK_CITY",r=6}, {k="w",i=4,x=2,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=5,x=3,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=6,x=4,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=7,x=4,y=5,to="ECRUTEAK_GYM",r=1}, {k="w",i=9,x=7,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=10,x=2,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=11,x=3,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=12,x=4,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=13,x=5,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=14,x=7,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=15,x=7,y=7,to="ECRUTEAK_GYM",r=1}, {k="w",i=16,x=4,y=8,to="ECRUTEAK_GYM",r=1}, {k="w",i=17,x=5,y=8,to="ECRUTEAK_GYM",r=1}, {k="w",i=18,x=6,y=8,to="ECRUTEAK_GYM",r=1}, {k="w",i=19,x=7,y=8,to="ECRUTEAK_GYM",r=1}, {k="w",i=20,x=2,y=8,to="ECRUTEAK_GYM",r=1}, {k="w",i=21,x=2,y=9,to="ECRUTEAK_GYM",r=1}, {k="w",i=22,x=2,y=10,to="ECRUTEAK_GYM",r=1}, {k="w",i=23,x=2,y=11,to="ECRUTEAK_GYM",r=1}, {k="w",i=24,x=4,y=10,to="ECRUTEAK_GYM",r=1}, {k="w",i=25,x=5,y=10,to="ECRUTEAK_GYM",r=1}, {k="w",i=26,x=2,y=12,to="ECRUTEAK_GYM",r=1}, {k="w",i=27,x=3,y=12,to="ECRUTEAK_GYM",r=1}, {k="w",i=28,x=4,y=12,to="ECRUTEAK_GYM",r=1}, {k="w",i=29,x=5,y=12,to="ECRUTEAK_GYM",r=1}, {k="w",i=30,x=7,y=10,to="ECRUTEAK_GYM",r=1}, {k="w",i=31,x=7,y=11,to="ECRUTEAK_GYM",r=1}, {k="w",i=32,x=7,y=12,to="ECRUTEAK_GYM",r=1}, {k="w",i=33,x=7,y=13,to="ECRUTEAK_GYM",r=1} } }, + { x = 0, y = 5, size = 8, exits = { } }, + { x = 2, y = 5, size = 2, exits = { {k="w",i=4,x=2,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=5,x=3,y=4,to="ECRUTEAK_GYM",r=1}, {k="w",i=7,x=4,y=5,to="ECRUTEAK_GYM",r=1}, {k="w",i=10,x=2,y=6,to="ECRUTEAK_GYM",r=1}, {k="w",i=11,x=3,y=6,to="ECRUTEAK_GYM",r=1} } }, + { x = 9, y = 5, size = 8, exits = { } }, + }, + ["ECRUTEAK_ITEMFINDER_HOUSE"] = { + { x = 3, y = 1, size = 45, exits = { {k="w",i=1,x=3,y=7,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=4,y=7,to="ECRUTEAK_CITY",r=6} } }, + }, + ["ECRUTEAK_LUGIA_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 45, exits = { {k="w",i=1,x=3,y=7,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=4,y=7,to="ECRUTEAK_CITY",r=6} } }, + }, + ["ECRUTEAK_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=3,y=7,to="ECRUTEAK_CITY",r=6} } }, + }, + ["ECRUTEAK_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=4,y=7,to="ECRUTEAK_CITY",r=6}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["ECRUTEAK_TIN_TOWER_BACK_ENTRANCE"] = { + { x = 0, y = 1, size = 41, exits = { {k="w",i=1,x=7,y=4,to="ECRUTEAK_CITY",r=4}, {k="w",i=2,x=7,y=5,to="ECRUTEAK_CITY",r=4}, {k="w",i=3,x=2,y=4,to="ECRUTEAK_TIN_TOWER_ENTRANCE",r=4} } }, + }, + ["ECRUTEAK_TIN_TOWER_ENTRANCE"] = { + { x = 1, y = 1, size = 14, exits = { } }, + { x = 4, y = 1, size = 69, exits = { {k="w",i=1,x=4,y=17,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=5,y=17,to="ECRUTEAK_CITY",r=6}, {k="w",i=3,x=5,y=3,to="ECRUTEAK_TIN_TOWER_ENTRANCE",r=4} } }, + { x = 7, y = 1, size = 14, exits = { } }, + { x = 15, y = 1, size = 44, exits = { {k="w",i=4,x=17,y=15,to="ECRUTEAK_TIN_TOWER_ENTRANCE",r=2}, {k="w",i=5,x=17,y=3,to="ECRUTEAK_TIN_TOWER_BACK_ENTRANCE",r=1} } }, + { x = 14, y = 5, size = 7, exits = { } }, + { x = 19, y = 5, size = 7, exits = { } }, + { x = 1, y = 15, size = 8, exits = { } }, + { x = 7, y = 15, size = 8, exits = { } }, + }, + ["ELMS_HOUSE"] = { + { x = 1, y = 1, size = 45, exits = { {k="w",i=1,x=2,y=7,to="NEW_BARK_TOWN",r=1}, {k="w",i=2,x=3,y=7,to="NEW_BARK_TOWN",r=1} } }, + }, + ["ELMS_LAB"] = { + { x = 4, y = 1, size = 78, exits = { {k="w",i=1,x=4,y=11,to="NEW_BARK_TOWN",r=1}, {k="w",i=2,x=5,y=11,to="NEW_BARK_TOWN",r=1} } }, + }, + ["FAST_SHIP_1F"] = { + { x = 24, y = 2, size = 127, exits = { {k="w",i=1,x=25,y=1,to="FAST_SHIP_1F",r=1}, {k="w",i=2,x=27,y=8,to="FAST_SHIP_CABINS_NNW_NNE_NE",r=1}, {k="w",i=3,x=23,y=8,to="FAST_SHIP_CABINS_NNW_NNE_NE",r=2}, {k="w",i=4,x=19,y=8,to="FAST_SHIP_CABINS_NNW_NNE_NE",r=3}, {k="w",i=5,x=15,y=8,to="FAST_SHIP_CABINS_SW_SSW_NW",r=1}, {k="w",i=6,x=15,y=15,to="FAST_SHIP_CABINS_SW_SSW_NW",r=2}, {k="w",i=7,x=19,y=15,to="FAST_SHIP_CABINS_SW_SSW_NW",r=3}, {k="w",i=8,x=23,y=15,to="FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN",r=1}, {k="w",i=9,x=27,y=15,to="FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN",r=2}, {k="w",i=12,x=30,y=14,to="FAST_SHIP_B1F",r=1} } }, + { x = 6, y = 10, size = 23, exits = { {k="w",i=10,x=3,y=13,to="FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN",r=3}, {k="w",i=11,x=6,y=12,to="FAST_SHIP_B1F",r=1} } }, + }, + ["FAST_SHIP_B1F"] = { + { x = 4, y = 4, size = 205, exits = { {k="w",i=1,x=5,y=11,to="FAST_SHIP_1F",r=2}, {k="w",i=2,x=31,y=13,to="FAST_SHIP_1F",r=1} } }, + }, + ["FAST_SHIP_CABINS_NNW_NNE_NE"] = { + { x = 0, y = 1, size = 50, exits = { {k="w",i=1,x=2,y=0,to="FAST_SHIP_1F",r=1} } }, + { x = 0, y = 13, size = 45, exits = { {k="w",i=2,x=2,y=12,to="FAST_SHIP_1F",r=1} } }, + { x = 0, y = 25, size = 47, exits = { {k="w",i=3,x=2,y=24,to="FAST_SHIP_1F",r=1} } }, + }, + ["FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN"] = { + { x = 0, y = 1, size = 40, exits = { {k="w",i=1,x=2,y=7,to="FAST_SHIP_1F",r=1}, {k="w",i=2,x=3,y=7,to="FAST_SHIP_1F",r=1} } }, + { x = 0, y = 13, size = 44, exits = { {k="w",i=3,x=2,y=19,to="FAST_SHIP_1F",r=1}, {k="w",i=4,x=3,y=19,to="FAST_SHIP_1F",r=1} } }, + { x = 0, y = 25, size = 69, exits = { {k="w",i=5,x=2,y=33,to="FAST_SHIP_1F",r=2}, {k="w",i=6,x=3,y=33,to="FAST_SHIP_1F",r=2} } }, + }, + ["FAST_SHIP_CABINS_SW_SSW_NW"] = { + { x = 1, y = 1, size = 46, exits = { {k="w",i=1,x=2,y=0,to="FAST_SHIP_1F",r=1} } }, + { x = 0, y = 13, size = 40, exits = { {k="w",i=2,x=2,y=19,to="FAST_SHIP_1F",r=1}, {k="w",i=3,x=3,y=19,to="FAST_SHIP_1F",r=1} } }, + { x = 2, y = 25, size = 46, exits = { {k="w",i=4,x=2,y=31,to="FAST_SHIP_1F",r=1}, {k="w",i=5,x=3,y=31,to="FAST_SHIP_1F",r=1} } }, + }, + ["FIGHTING_DOJO"] = { + { x = 0, y = 1, size = 94, exits = { {k="w",i=1,x=4,y=11,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=5,y=11,to="SAFFRON_CITY",r=3} } }, + }, + ["FUCHSIA_CITY"] = { + { x = 6, y = 0, size = 319, exits = { {k="w",i=1,x=5,y=13,to="FUCHSIA_MART",r=2} } }, + { x = 20, y = 14, size = 344, exits = { {k="w",i=2,x=22,y=13,to="SAFARI_ZONE_MAIN_OFFICE",r=1}, {k="w",i=3,x=8,y=27,to="FUCHSIA_GYM",r=1}, {k="w",i=4,x=11,y=27,to="BILLS_OLDER_SISTERS_HOUSE",r=1}, {k="w",i=5,x=19,y=27,to="FUCHSIA_POKECENTER_1F",r=2}, {k="w",i=6,x=27,y=27,to="SAFARI_ZONE_WARDENS_HOME",r=1}, {k="w",i=8,x=37,y=22,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="w",i=9,x=37,y=23,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="w",i=10,x=7,y=35,to="ROUTE_19_FUCHSIA_GATE",r=1}, {k="w",i=11,x=8,y=35,to="ROUTE_19_FUCHSIA_GATE",r=1}, {k="e",d="south",to="ROUTE_19",r=2}, {k="e",d="west",to="ROUTE_18",r=2} } }, + { x = 3, y = 22, size = 50, exits = { } }, + { x = 15, y = 32, size = 16, exits = { {k="e",d="south",to="ROUTE_19",r=3} } }, + { x = 1, y = 34, size = 8, exits = { {k="e",d="south",to="ROUTE_19",r=1} } }, + }, + ["FUCHSIA_GYM"] = { + { x = 0, y = 1, size = 128, exits = { {k="w",i=1,x=4,y=17,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=5,y=17,to="FUCHSIA_CITY",r=2} } }, + }, + ["FUCHSIA_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="FUCHSIA_CITY",r=1}, {k="w",i=2,x=3,y=7,to="FUCHSIA_CITY",r=1} } }, + }, + ["FUCHSIA_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=4,y=7,to="FUCHSIA_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["FUCHSIA_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="FUCHSIA_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["GOLDENROD_BIKE_SHOP"] = { + { x = 3, y = 1, size = 1, exits = { } }, + { x = 7, y = 1, size = 3, exits = { } }, + { x = 2, y = 2, size = 26, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + { x = 0, y = 7, size = 2, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_CITY"] = { + { x = 14, y = 0, size = 8, exits = { {k="e",d="north",to="ROUTE_35",r=4} } }, + { x = 23, y = 0, size = 40, exits = { {k="e",d="north",to="ROUTE_35",r=5} } }, + { x = 34, y = 0, size = 8, exits = { {k="e",d="north",to="ROUTE_35",r=5} } }, + { x = 39, y = 0, size = 9, exits = { {k="e",d="north",to="ROUTE_35",r=5} } }, + { x = 6, y = 2, size = 4, exits = { } }, + { x = 10, y = 2, size = 4, exits = { } }, + { x = 18, y = 2, size = 446, exits = { {k="w",i=1,x=24,y=7,to="GOLDENROD_GYM",r=1}, {k="w",i=2,x=29,y=29,to="GOLDENROD_BIKE_SHOP",r=3}, {k="w",i=2,x=29,y=29,to="GOLDENROD_BIKE_SHOP",r=4}, {k="w",i=3,x=31,y=21,to="GOLDENROD_HAPPINESS_RATER",r=1}, {k="w",i=4,x=5,y=25,to="BILLS_FAMILYS_HOUSE",r=1}, {k="w",i=5,x=9,y=13,to="GOLDENROD_MAGNET_TRAIN_STATION",r=2}, {k="w",i=6,x=33,y=5,to="GOLDENROD_FLOWER_SHOP",r=1}, {k="w",i=7,x=15,y=27,to="GOLDENROD_POKECENTER_1F",r=2}, {k="w",i=8,x=33,y=9,to="GOLDENROD_PP_SPEECH_HOUSE",r=1}, {k="w",i=9,x=15,y=7,to="GOLDENROD_NAME_RATER",r=1}, {k="w",i=10,x=24,y=27,to="GOLDENROD_DEPT_STORE_1F",r=1}, {k="w",i=11,x=14,y=21,to="GOLDENROD_GAME_CORNER",r=2}, {k="w",i=12,x=5,y=15,to="RADIO_TOWER_1F",r=1}, {k="w",i=13,x=19,y=1,to="ROUTE_35_GOLDENROD_GATE",r=1}, {k="w",i=14,x=9,y=5,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=10}, {k="w",i=15,x=11,y=29,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=9}, {k="e",d="south",to="ROUTE_34",r=2} } }, + { x = 36, y = 4, size = 16, exits = { } }, + { x = 38, y = 15, size = 2, exits = { } }, + { x = 39, y = 16, size = 17, exits = { } }, + { x = 36, y = 18, size = 4, exits = { } }, + { x = 34, y = 20, size = 4, exits = { } }, + { x = 36, y = 26, size = 4, exits = { } }, + { x = 4, y = 32, size = 4, exits = { } }, + { x = 14, y = 32, size = 4, exits = { } }, + { x = 26, y = 32, size = 2, exits = { } }, + { x = 36, y = 32, size = 4, exits = { } }, + { x = 6, y = 34, size = 6, exits = { } }, + { x = 30, y = 34, size = 2, exits = { {k="e",d="south",to="ROUTE_34",r=3} } }, + { x = 34, y = 34, size = 4, exits = { {k="e",d="south",to="ROUTE_34",r=3} } }, + { x = 38, y = 34, size = 4, exits = { {k="e",d="south",to="ROUTE_34",r=3} } }, + { x = 12, y = 35, size = 5, exits = { {k="e",d="south",to="ROUTE_34",r=1} } }, + { x = 23, y = 35, size = 5, exits = { {k="e",d="south",to="ROUTE_34",r=3} } }, + }, + ["GOLDENROD_DEPT_STORE_1F"] = { + { x = 0, y = 1, size = 90, exits = { {k="w",i=1,x=7,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=8,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=3,x=15,y=0,to="GOLDENROD_DEPT_STORE_2F",r=1}, {k="w",i=4,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 10, y = 1, size = 2, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_2F"] = { + { x = 0, y = 1, size = 82, exits = { {k="w",i=1,x=12,y=0,to="GOLDENROD_DEPT_STORE_3F",r=1}, {k="w",i=2,x=15,y=0,to="GOLDENROD_DEPT_STORE_1F",r=1}, {k="w",i=3,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 13, y = 5, size = 4, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_3F"] = { + { x = 0, y = 1, size = 84, exits = { {k="w",i=1,x=12,y=0,to="GOLDENROD_DEPT_STORE_2F",r=1}, {k="w",i=2,x=15,y=0,to="GOLDENROD_DEPT_STORE_4F",r=1}, {k="w",i=3,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 6, y = 1, size = 2, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_4F"] = { + { x = 0, y = 1, size = 76, exits = { {k="w",i=1,x=12,y=0,to="GOLDENROD_DEPT_STORE_5F",r=1}, {k="w",i=2,x=15,y=0,to="GOLDENROD_DEPT_STORE_3F",r=1}, {k="w",i=3,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 13, y = 5, size = 3, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_5F"] = { + { x = 0, y = 1, size = 92, exits = { {k="w",i=1,x=12,y=0,to="GOLDENROD_DEPT_STORE_4F",r=1}, {k="w",i=2,x=15,y=0,to="GOLDENROD_DEPT_STORE_6F",r=1}, {k="w",i=3,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 7, y = 5, size = 2, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_6F"] = { + { x = 0, y = 1, size = 96, exits = { {k="w",i=1,x=15,y=0,to="GOLDENROD_DEPT_STORE_5F",r=1}, {k="w",i=2,x=2,y=0,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + }, + ["GOLDENROD_DEPT_STORE_B1F"] = { + { x = 1, y = 1, size = 60, exits = { } }, + { x = 13, y = 1, size = 17, exits = { {k="w",i=1,x=17,y=2,to="GOLDENROD_UNDERGROUND_WAREHOUSE",r=1} } }, + { x = 4, y = 5, size = 52, exits = { {k="w",i=2,x=9,y=4,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1}, {k="w",i=3,x=10,y=4,to="GOLDENROD_DEPT_STORE_ELEVATOR",r=1} } }, + { x = 13, y = 5, size = 36, exits = { } }, + { x = 12, y = 13, size = 21, exits = { } }, + }, + ["GOLDENROD_DEPT_STORE_ELEVATOR"] = { + { x = 0, y = 1, size = 10, exits = { {k="w",i=1,x=1,y=3,to="GOLDENROD_DEPT_STORE_1F",r=1}, {k="w",i=2,x=2,y=3,to="GOLDENROD_DEPT_STORE_1F",r=1} } }, + }, + ["GOLDENROD_FLOWER_SHOP"] = { + { x = 0, y = 1, size = 33, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_GAME_CORNER"] = { + { x = 2, y = 1, size = 10, exits = { } }, + { x = 8, y = 1, size = 168, exits = { {k="w",i=1,x=2,y=13,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=13,to="GOLDENROD_CITY",r=7} } }, + { x = 15, y = 1, size = 10, exits = { } }, + }, + ["GOLDENROD_GYM"] = { + { x = 2, y = 1, size = 226, exits = { {k="w",i=1,x=2,y=17,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=17,to="GOLDENROD_CITY",r=7} } }, + { x = 0, y = 14, size = 2, exits = { } }, + }, + ["GOLDENROD_HAPPINESS_RATER"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_MAGNET_TRAIN_STATION"] = { + { x = 0, y = 2, size = 40, exits = { } }, + { x = 0, y = 6, size = 129, exits = { {k="w",i=1,x=8,y=17,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=9,y=17,to="GOLDENROD_CITY",r=7}, {k="w",i=3,x=6,y=5,to="SAFFRON_MAGNET_TRAIN_STATION",r=2}, {k="w",i=4,x=11,y=5,to="SAFFRON_MAGNET_TRAIN_STATION",r=2} } }, + }, + ["GOLDENROD_NAME_RATER"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=4,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["GOLDENROD_PP_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_UNDERGROUND"] = { + { x = 2, y = 1, size = 157, exits = { {k="w",i=1,x=3,y=2,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=10}, {k="w",i=2,x=3,y=34,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=9}, {k="w",i=3,x=18,y=6,to="GOLDENROD_UNDERGROUND",r=5} } }, + { x = 7, y = 10, size = 3, exits = { } }, + { x = 7, y = 14, size = 3, exits = { } }, + { x = 7, y = 20, size = 3, exits = { } }, + { x = 19, y = 25, size = 39, exits = { {k="w",i=4,x=21,y=31,to="GOLDENROD_UNDERGROUND",r=1}, {k="w",i=5,x=22,y=31,to="GOLDENROD_UNDERGROUND",r=1}, {k="w",i=6,x=22,y=27,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=1} } }, + }, + ["GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES"] = { + { x = 0, y = 2, size = 91, exits = { {k="w",i=1,x=23,y=3,to="GOLDENROD_UNDERGROUND",r=5} } }, + { x = 0, y = 8, size = 12, exits = { } }, + { x = 7, y = 8, size = 10, exits = { } }, + { x = 13, y = 8, size = 10, exits = { } }, + { x = 22, y = 11, size = 12, exits = { {k="w",i=2,x=22,y=10,to="GOLDENROD_UNDERGROUND_WAREHOUSE",r=1}, {k="w",i=3,x=23,y=10,to="GOLDENROD_UNDERGROUND_WAREHOUSE",r=1} } }, + { x = 0, y = 12, size = 12, exits = { } }, + { x = 7, y = 12, size = 10, exits = { } }, + { x = 13, y = 12, size = 10, exits = { } }, + { x = 1, y = 22, size = 63, exits = { {k="w",i=4,x=5,y=25,to="GOLDENROD_UNDERGROUND",r=1}, {k="w",i=5,x=4,y=29,to="GOLDENROD_CITY",r=7}, {k="w",i=6,x=5,y=29,to="GOLDENROD_CITY",r=7} } }, + { x = 16, y = 22, size = 63, exits = { {k="w",i=7,x=21,y=25,to="GOLDENROD_UNDERGROUND",r=1}, {k="w",i=8,x=20,y=29,to="GOLDENROD_CITY",r=7}, {k="w",i=9,x=21,y=29,to="GOLDENROD_CITY",r=7} } }, + }, + ["GOLDENROD_UNDERGROUND_WAREHOUSE"] = { + { x = 1, y = 1, size = 167, exits = { {k="w",i=1,x=2,y=12,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=5}, {k="w",i=2,x=3,y=12,to="GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES",r=5}, {k="w",i=3,x=17,y=2,to="GOLDENROD_DEPT_STORE_B1F",r=2} } }, + }, + ["GUIDE_GENTS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CHERRYGROVE_CITY",r=1}, {k="w",i=2,x=3,y=7,to="CHERRYGROVE_CITY",r=1} } }, + }, + ["HALL_OF_FAME"] = { + { x = 0, y = 1, size = 121, exits = { {k="w",i=1,x=4,y=13,to="LANCES_ROOM",r=1}, {k="w",i=2,x=5,y=13,to="LANCES_ROOM",r=1} } }, + }, + ["ICE_PATH_1F"] = { + { x = 0, y = 0, size = 624, exits = { } }, + { x = 2, y = 2, size = 413, exits = { {k="w",i=1,x=4,y=19,to="ROUTE_44",r=1}, {k="w",i=1,x=4,y=19,to="ROUTE_44",r=2}, {k="w",i=3,x=37,y=5,to="ICE_PATH_B1F",r=1} } }, + { x = 34, y = 12, size = 65, exits = { {k="w",i=2,x=36,y=27,to="BLACKTHORN_CITY",r=2}, {k="w",i=2,x=36,y=27,to="BLACKTHORN_CITY",r=5}, {k="w",i=4,x=37,y=13,to="ICE_PATH_B1F",r=2} } }, + { x = 17, y = 16, size = 12, exits = { } }, + }, + ["ICE_PATH_B1F"] = { + { x = 2, y = 1, size = 189, exits = { {k="w",i=1,x=3,y=15,to="ICE_PATH_1F",r=2}, {k="w",i=2,x=17,y=3,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1}, {k="w",i=3,x=11,y=2,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1}, {k="w",i=4,x=4,y=7,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1}, {k="w",i=5,x=5,y=12,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1}, {k="w",i=6,x=12,y=13,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1} } }, + { x = 3, y = 22, size = 120, exits = { {k="w",i=7,x=5,y=25,to="ICE_PATH_1F",r=3}, {k="w",i=8,x=11,y=27,to="ICE_PATH_B2F_BLACKTHORN_SIDE",r=1} } }, + }, + ["ICE_PATH_B2F_BLACKTHORN_SIDE"] = { + { x = 6, y = 0, size = 135, exits = { {k="w",i=1,x=3,y=15,to="ICE_PATH_B1F",r=2}, {k="w",i=2,x=3,y=3,to="ICE_PATH_B3F",r=1} } }, + }, + ["ICE_PATH_B2F_MAHOGANY_SIDE"] = { + { x = 1, y = 0, size = 348, exits = { {k="w",i=1,x=17,y=1,to="ICE_PATH_B1F",r=1}, {k="w",i=2,x=9,y=11,to="ICE_PATH_B3F",r=1} } }, + }, + ["ICE_PATH_B3F"] = { + { x = 8, y = 1, size = 75, exits = { {k="w",i=1,x=3,y=5,to="ICE_PATH_B2F_MAHOGANY_SIDE",r=1}, {k="w",i=2,x=15,y=5,to="ICE_PATH_B2F_BLACKTHORN_SIDE",r=1} } }, + }, + ["ILEX_FOREST"] = { + { x = 15, y = 0, size = 190, exits = { {k="w",i=1,x=1,y=5,to="ROUTE_34_ILEX_FOREST_GATE",r=1} } }, + { x = 20, y = 22, size = 169, exits = { {k="w",i=2,x=3,y=42,to="ILEX_FOREST_AZALEA_GATE",r=2}, {k="w",i=3,x=3,y=43,to="ILEX_FOREST_AZALEA_GATE",r=2} } }, + }, + ["ILEX_FOREST_AZALEA_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ILEX_FOREST",r=2}, {k="w",i=2,x=0,y=5,to="ILEX_FOREST",r=2}, {k="w",i=3,x=9,y=4,to="AZALEA_TOWN",r=2}, {k="w",i=4,x=9,y=5,to="AZALEA_TOWN",r=2} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["INDIGO_PLATEAU_POKECENTER_1F"] = { + { x = 14, y = 1, size = 115, exits = { {k="w",i=1,x=5,y=13,to="ROUTE_23",r=3}, {k="w",i=2,x=6,y=13,to="ROUTE_23",r=3}, {k="w",i=3,x=0,y=13,to="POKECENTER_2F",r=2}, {k="w",i=4,x=14,y=3,to="WILLS_ROOM",r=1} } }, + { x = 3, y = 7, size = 2, exits = { } }, + { x = 11, y = 7, size = 3, exits = { } }, + }, + ["KARENS_ROOM"] = { + { x = 0, y = 3, size = 72, exits = { {k="w",i=1,x=4,y=17,to="BRUNOS_ROOM",r=1}, {k="w",i=2,x=5,y=17,to="BRUNOS_ROOM",r=1} } }, + { x = 0, y = 16, size = 4, exits = { } }, + { x = 8, y = 16, size = 4, exits = { } }, + }, + ["KOGAS_ROOM"] = { + { x = 0, y = 3, size = 100, exits = { {k="w",i=1,x=4,y=17,to="WILLS_ROOM",r=1}, {k="w",i=2,x=5,y=17,to="WILLS_ROOM",r=1} } }, + { x = 0, y = 16, size = 4, exits = { } }, + { x = 8, y = 16, size = 4, exits = { } }, + }, + ["KURTS_HOUSE"] = { + { x = 7, y = 1, size = 86, exits = { {k="w",i=1,x=3,y=7,to="AZALEA_TOWN",r=2}, {k="w",i=2,x=4,y=7,to="AZALEA_TOWN",r=2} } }, + }, + ["LAKE_OF_RAGE"] = { + { x = 31, y = 2, size = 14, exits = { } }, + { x = 20, y = 3, size = 26, exits = { {k="w",i=1,x=7,y=3,to="LAKE_OF_RAGE_HIDDEN_POWER_HOUSE",r=1} } }, + { x = 4, y = 4, size = 15, exits = { } }, + { x = 22, y = 5, size = 21, exits = { } }, + { x = 35, y = 5, size = 152, exits = { {k="w",i=2,x=27,y=31,to="LAKE_OF_RAGE_MAGIKARP_HOUSE",r=1}, {k="e",d="south",to="ROUTE_43",r=1} } }, + { x = 15, y = 6, size = 22, exits = { } }, + { x = 10, y = 13, size = 23, exits = { } }, + { x = 6, y = 22, size = 47, exits = { {k="e",d="south",to="ROUTE_43",r=1} } }, + { x = 35, y = 24, size = 69, exits = { } }, + }, + ["LAKE_OF_RAGE_HIDDEN_POWER_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="LAKE_OF_RAGE",r=2}, {k="w",i=2,x=3,y=7,to="LAKE_OF_RAGE",r=2} } }, + }, + ["LAKE_OF_RAGE_MAGIKARP_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="LAKE_OF_RAGE",r=5}, {k="w",i=2,x=3,y=7,to="LAKE_OF_RAGE",r=5} } }, + }, + ["LANCES_ROOM"] = { + { x = 0, y = 1, size = 190, exits = { {k="w",i=1,x=4,y=23,to="KARENS_ROOM",r=1}, {k="w",i=2,x=5,y=23,to="KARENS_ROOM",r=1} } }, + }, + ["LAVENDER_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["LAVENDER_NAME_RATER"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["LAVENDER_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=4,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["LAVENDER_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="LAVENDER_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["LAVENDER_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["LAVENDER_TOWN"] = { + { x = 0, y = 0, size = 5, exits = { {k="e",d="north",to="ROUTE_10_SOUTH",r=1}, {k="e",d="west",to="ROUTE_8",r=2} } }, + { x = 6, y = 0, size = 155, exits = { {k="w",i=1,x=5,y=5,to="LAVENDER_POKECENTER_1F",r=2}, {k="w",i=2,x=5,y=9,to="MR_FUJIS_HOUSE",r=1}, {k="w",i=3,x=3,y=13,to="LAVENDER_SPEECH_HOUSE",r=1}, {k="w",i=4,x=7,y=13,to="LAVENDER_NAME_RATER",r=1}, {k="w",i=5,x=1,y=5,to="LAVENDER_MART",r=2}, {k="w",i=6,x=13,y=11,to="SOUL_HOUSE",r=1}, {k="w",i=6,x=13,y=11,to="SOUL_HOUSE",r=2}, {k="w",i=7,x=14,y=5,to="LAV_RADIO_TOWER_1F",r=1}, {k="e",d="north",to="ROUTE_10_SOUTH",r=2}, {k="e",d="south",to="ROUTE_12",r=2}, {k="e",d="west",to="ROUTE_8",r=2}, {k="e",d="west",to="ROUTE_8",r=3}, {k="e",d="west",to="ROUTE_8",r=5} } }, + { x = 18, y = 0, size = 36, exits = { {k="e",d="north",to="ROUTE_10_SOUTH",r=1}, {k="e",d="south",to="ROUTE_12",r=3} } }, + { x = 0, y = 16, size = 14, exits = { {k="e",d="south",to="ROUTE_12",r=1}, {k="e",d="west",to="ROUTE_8",r=5} } }, + }, + ["LAV_RADIO_TOWER_1F"] = { + { x = 15, y = 0, size = 102, exits = { {k="w",i=1,x=2,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["MAHOGANY_GYM"] = { + { x = 0, y = 2, size = 136, exits = { {k="w",i=1,x=4,y=17,to="MAHOGANY_TOWN",r=3}, {k="w",i=2,x=5,y=17,to="MAHOGANY_TOWN",r=3} } }, + }, + ["MAHOGANY_MART_1F"] = { + { x = 1, y = 1, size = 37, exits = { {k="w",i=1,x=3,y=7,to="MAHOGANY_TOWN",r=3}, {k="w",i=2,x=4,y=7,to="MAHOGANY_TOWN",r=3} } }, + }, + ["MAHOGANY_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="MAHOGANY_TOWN",r=3}, {k="w",i=2,x=4,y=7,to="MAHOGANY_TOWN",r=3}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["MAHOGANY_RED_GYARADOS_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="MAHOGANY_TOWN",r=3}, {k="w",i=2,x=3,y=7,to="MAHOGANY_TOWN",r=3} } }, + }, + ["MAHOGANY_TOWN"] = { + { x = 0, y = 0, size = 21, exits = { {k="e",d="north",to="ROUTE_43",r=4}, {k="e",d="west",to="ROUTE_42",r=1} } }, + { x = 17, y = 0, size = 3, exits = { {k="e",d="east",to="ROUTE_44",r=1}, {k="e",d="north",to="ROUTE_43",r=5} } }, + { x = 8, y = 2, size = 136, exits = { {k="w",i=1,x=11,y=7,to="MAHOGANY_MART_1F",r=1}, {k="w",i=2,x=17,y=7,to="MAHOGANY_RED_GYARADOS_SPEECH_HOUSE",r=1}, {k="w",i=3,x=6,y=13,to="MAHOGANY_GYM",r=1}, {k="w",i=4,x=15,y=13,to="MAHOGANY_POKECENTER_1F",r=2}, {k="w",i=5,x=9,y=1,to="ROUTE_43_MAHOGANY_GATE",r=1}, {k="e",d="east",to="ROUTE_44",r=2}, {k="e",d="west",to="ROUTE_42",r=5} } }, + { x = 0, y = 8, size = 54, exits = { {k="e",d="east",to="ROUTE_44",r=1}, {k="e",d="west",to="ROUTE_42",r=7} } }, + }, + ["MANIAS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="CIANWOOD_CITY",r=2}, {k="w",i=2,x=3,y=7,to="CIANWOOD_CITY",r=2} } }, + }, + ["MOUNT_MOON"] = { + { x = 0, y = 0, size = 278, exits = { } }, + { x = 2, y = 2, size = 75, exits = { {k="w",i=1,x=3,y=3,to="ROUTE_3",r=1}, {k="w",i=1,x=3,y=3,to="ROUTE_3",r=2}, {k="w",i=2,x=15,y=15,to="ROUTE_4",r=1}, {k="w",i=2,x=15,y=15,to="ROUTE_4",r=2}, {k="w",i=3,x=13,y=3,to="MOUNT_MOON",r=3} } }, + { x = 23, y = 2, size = 18, exits = { {k="w",i=5,x=25,y=5,to="MOUNT_MOON_SQUARE",r=1}, {k="w",i=5,x=25,y=5,to="MOUNT_MOON_SQUARE",r=2}, {k="w",i=7,x=25,y=3,to="MOUNT_MOON",r=2} } }, + { x = 14, y = 10, size = 8, exits = { {k="w",i=1,x=3,y=3,to="ROUTE_3",r=1}, {k="w",i=1,x=3,y=3,to="ROUTE_3",r=2}, {k="w",i=2,x=15,y=15,to="ROUTE_4",r=1}, {k="w",i=2,x=15,y=15,to="ROUTE_4",r=2}, {k="w",i=3,x=13,y=3,to="MOUNT_MOON",r=3}, {k="w",i=4,x=15,y=11,to="MOUNT_MOON",r=5} } }, + { x = 24, y = 12, size = 12, exits = { {k="w",i=6,x=25,y=15,to="MOUNT_MOON_SQUARE",r=1}, {k="w",i=6,x=25,y=15,to="MOUNT_MOON_SQUARE",r=2}, {k="w",i=8,x=25,y=13,to="MOUNT_MOON",r=4} } }, + }, + ["MOUNT_MOON_GIFT_SHOP"] = { + { x = 1, y = 1, size = 37, exits = { {k="w",i=1,x=3,y=7,to="MOUNT_MOON_SQUARE",r=2}, {k="w",i=2,x=4,y=7,to="MOUNT_MOON_SQUARE",r=2} } }, + }, + ["MOUNT_MOON_SQUARE"] = { + { x = 1, y = 0, size = 256, exits = { {k="w",i=1,x=20,y=5,to="MOUNT_MOON",r=3}, {k="w",i=2,x=22,y=11,to="MOUNT_MOON",r=5} } }, + { x = 24, y = 4, size = 121, exits = { {k="w",i=1,x=20,y=5,to="MOUNT_MOON",r=3}, {k="w",i=2,x=22,y=11,to="MOUNT_MOON",r=5}, {k="w",i=3,x=13,y=7,to="MOUNT_MOON_GIFT_SHOP",r=1} } }, + }, + ["MOUNT_MORTAR_1F_INSIDE"] = { + { x = 0, y = 0, size = 616, exits = { } }, + { x = 5, y = 2, size = 293, exits = { {k="w",i=1,x=11,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=7}, {k="w",i=2,x=29,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=8}, {k="w",i=3,x=5,y=39,to="MOUNT_MORTAR_1F_OUTSIDE",r=6}, {k="w",i=4,x=33,y=41,to="MOUNT_MORTAR_1F_OUTSIDE",r=5}, {k="w",i=5,x=3,y=19,to="MOUNT_MORTAR_B1F",r=2}, {k="w",i=6,x=9,y=9,to="MOUNT_MORTAR_2F_INSIDE",r=3} } }, + { x = 36, y = 5, size = 538, exits = { {k="w",i=1,x=11,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=7}, {k="w",i=2,x=29,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=8}, {k="w",i=3,x=5,y=39,to="MOUNT_MORTAR_1F_OUTSIDE",r=6}, {k="w",i=4,x=33,y=41,to="MOUNT_MORTAR_1F_OUTSIDE",r=5} } }, + { x = 7, y = 14, size = 10, exits = { } }, + { x = 27, y = 14, size = 12, exits = { } }, + { x = 29, y = 18, size = 6, exits = { } }, + { x = 19, y = 20, size = 6, exits = { } }, + { x = 17, y = 30, size = 3, exits = { {k="w",i=1,x=11,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=7}, {k="w",i=2,x=29,y=47,to="MOUNT_MORTAR_1F_OUTSIDE",r=8}, {k="w",i=3,x=5,y=39,to="MOUNT_MORTAR_1F_OUTSIDE",r=6}, {k="w",i=4,x=33,y=41,to="MOUNT_MORTAR_1F_OUTSIDE",r=5} } }, + { x = 31, y = 34, size = 6, exits = { } }, + { x = 21, y = 38, size = 6, exits = { } }, + }, + ["MOUNT_MORTAR_1F_OUTSIDE"] = { + { x = 1, y = 4, size = 46, exits = { } }, + { x = 33, y = 4, size = 38, exits = { } }, + { x = 16, y = 6, size = 8, exits = { {k="w",i=4,x=17,y=5,to="MOUNT_MORTAR_2F_INSIDE",r=23} } }, + { x = 15, y = 8, size = 18, exits = { } }, + { x = 29, y = 8, size = 77, exits = { {k="w",i=9,x=33,y=13,to="MOUNT_MORTAR_1F_INSIDE",r=3} } }, + { x = 5, y = 12, size = 41, exits = { {k="w",i=8,x=7,y=13,to="MOUNT_MORTAR_1F_INSIDE",r=3} } }, + { x = 6, y = 22, size = 49, exits = { {k="w",i=1,x=3,y=33,to="ROUTE_42",r=1}, {k="w",i=1,x=3,y=33,to="ROUTE_42",r=4}, {k="w",i=5,x=11,y=21,to="MOUNT_MORTAR_1F_INSIDE",r=3} } }, + { x = 26, y = 22, size = 62, exits = { {k="w",i=3,x=37,y=33,to="ROUTE_42",r=1}, {k="w",i=3,x=37,y=33,to="ROUTE_42",r=5}, {k="w",i=6,x=29,y=21,to="MOUNT_MORTAR_1F_INSIDE",r=3} } }, + { x = 38, y = 22, size = 122, exits = { } }, + { x = 15, y = 28, size = 25, exits = { {k="w",i=2,x=17,y=33,to="ROUTE_42",r=1}, {k="w",i=2,x=17,y=33,to="ROUTE_42",r=6}, {k="w",i=7,x=17,y=29,to="MOUNT_MORTAR_B1F",r=11} } }, + }, + ["MOUNT_MORTAR_2F_INSIDE"] = { + { x = 0, y = 0, size = 265, exits = { } }, + { x = 11, y = 2, size = 41, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3}, {k="w",i=2,x=3,y=5,to="MOUNT_MORTAR_1F_INSIDE",r=2} } }, + { x = 2, y = 4, size = 17, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3}, {k="w",i=2,x=3,y=5,to="MOUNT_MORTAR_1F_INSIDE",r=2} } }, + { x = 13, y = 4, size = 13, exits = { } }, + { x = 21, y = 4, size = 6, exits = { } }, + { x = 27, y = 4, size = 32, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 18, y = 6, size = 2, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 32, y = 6, size = 28, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 2, y = 8, size = 24, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 18, y = 8, size = 16, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 11, y = 10, size = 12, exits = { } }, + { x = 21, y = 10, size = 6, exits = { } }, + { x = 31, y = 10, size = 16, exits = { } }, + { x = 6, y = 12, size = 18, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 12, y = 14, size = 38, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 31, y = 14, size = 37, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 17, y = 16, size = 33, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 11, y = 18, size = 6, exits = { } }, + { x = 5, y = 20, size = 44, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 27, y = 20, size = 32, exits = { } }, + { x = 3, y = 22, size = 20, exits = { } }, + { x = 30, y = 22, size = 25, exits = { } }, + { x = 11, y = 24, size = 71, exits = { {k="w",i=1,x=17,y=33,to="MOUNT_MORTAR_1F_OUTSIDE",r=3} } }, + { x = 21, y = 24, size = 6, exits = { } }, + { x = 27, y = 28, size = 6, exits = { } }, + { x = 36, y = 30, size = 9, exits = { } }, + { x = 3, y = 32, size = 10, exits = { } }, + }, + ["MOUNT_MORTAR_B1F"] = { + { x = 0, y = 0, size = 364, exits = { } }, + { x = 2, y = 2, size = 74, exits = { {k="w",i=1,x=3,y=3,to="MOUNT_MORTAR_1F_INSIDE",r=2} } }, + { x = 30, y = 2, size = 19, exits = { } }, + { x = 27, y = 10, size = 31, exits = { } }, + { x = 23, y = 14, size = 40, exits = { } }, + { x = 4, y = 15, size = 15, exits = { } }, + { x = 28, y = 16, size = 14, exits = { } }, + { x = 5, y = 18, size = 6, exits = { } }, + { x = 31, y = 18, size = 10, exits = { } }, + { x = 16, y = 26, size = 7, exits = { } }, + { x = 23, y = 26, size = 46, exits = { {k="w",i=2,x=19,y=29,to="MOUNT_MORTAR_1F_OUTSIDE",r=10} } }, + { x = 33, y = 26, size = 27, exits = { } }, + { x = 3, y = 32, size = 12, exits = { } }, + { x = 16, y = 32, size = 10, exits = { } }, + }, + ["MOVE_DELETERS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="BLACKTHORN_CITY",r=5}, {k="w",i=2,x=3,y=7,to="BLACKTHORN_CITY",r=5} } }, + }, + ["MR_FUJIS_HOUSE"] = { + { x = 2, y = 1, size = 51, exits = { {k="w",i=1,x=2,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=3,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["MR_POKEMONS_HOUSE"] = { + { x = 4, y = 1, size = 40, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_30",r=1}, {k="w",i=2,x=3,y=7,to="ROUTE_30",r=1} } }, + }, + ["MR_PSYCHICS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=3,y=7,to="SAFFRON_CITY",r=3} } }, + }, + ["NATIONAL_PARK"] = { + { x = 10, y = 2, size = 1031, exits = { {k="w",i=1,x=33,y=18,to="ROUTE_36_NATIONAL_PARK_GATE",r=1}, {k="w",i=1,x=33,y=18,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=2,x=33,y=19,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=3,x=10,y=47,to="ROUTE_35_NATIONAL_PARK_GATE",r=1}, {k="w",i=4,x=11,y=47,to="ROUTE_35_NATIONAL_PARK_GATE",r=1} } }, + { x = 35, y = 21, size = 76, exits = { } }, + }, + ["NATIONAL_PARK_BUG_CONTEST"] = { + { x = 10, y = 2, size = 1031, exits = { {k="w",i=1,x=33,y=18,to="ROUTE_36_NATIONAL_PARK_GATE",r=1}, {k="w",i=1,x=33,y=18,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=2,x=33,y=19,to="ROUTE_36_NATIONAL_PARK_GATE",r=1}, {k="w",i=2,x=33,y=19,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=3,x=10,y=47,to="ROUTE_35_NATIONAL_PARK_GATE",r=1}, {k="w",i=4,x=11,y=47,to="ROUTE_35_NATIONAL_PARK_GATE",r=1} } }, + { x = 35, y = 21, size = 76, exits = { } }, + }, + ["NEW_BARK_TOWN"] = { + { x = 2, y = 2, size = 162, exits = { {k="w",i=1,x=6,y=3,to="ELMS_LAB",r=1}, {k="w",i=2,x=13,y=5,to="PLAYERS_HOUSE_1F",r=1}, {k="w",i=3,x=3,y=11,to="PLAYERS_NEIGHBORS_HOUSE",r=1}, {k="w",i=4,x=11,y=13,to="ELMS_HOUSE",r=1}, {k="e",d="west",to="ROUTE_29",r=1} } }, + }, + ["OAKS_LAB"] = { + { x = 2, y = 1, size = 82, exits = { {k="w",i=1,x=4,y=11,to="PALLET_TOWN",r=3}, {k="w",i=2,x=5,y=11,to="PALLET_TOWN",r=3} } }, + }, + ["OLIVINE_CAFE"] = { + { x = 0, y = 1, size = 36, exits = { {k="w",i=1,x=2,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=3,y=7,to="OLIVINE_CITY",r=2} } }, + { x = 7, y = 1, size = 4, exits = { } }, + }, + ["OLIVINE_CITY"] = { + { x = 16, y = 0, size = 19, exits = { {k="e",d="north",to="ROUTE_39",r=5} } }, + { x = 18, y = 0, size = 290, exits = { {k="w",i=1,x=13,y=21,to="OLIVINE_POKECENTER_1F",r=2}, {k="w",i=2,x=10,y=11,to="OLIVINE_GYM",r=1}, {k="w",i=3,x=25,y=11,to="OLIVINE_TIMS_HOUSE",r=1}, {k="w",i=5,x=29,y=11,to="OLIVINE_PUNISHMENT_SPEECH_HOUSE",r=1}, {k="w",i=6,x=13,y=15,to="OLIVINE_GOOD_ROD_HOUSE",r=1}, {k="w",i=7,x=7,y=21,to="OLIVINE_CAFE",r=1}, {k="w",i=8,x=19,y=17,to="OLIVINE_MART",r=2}, {k="w",i=9,x=29,y=27,to="OLIVINE_LIGHTHOUSE_1F",r=1}, {k="w",i=10,x=19,y=27,to="OLIVINE_PORT_PASSAGE",r=2}, {k="w",i=11,x=20,y=27,to="OLIVINE_PORT_PASSAGE",r=2}, {k="e",d="west",to="ROUTE_40",r=4}, {k="e",d="north",to="ROUTE_39",r=2} } }, + { x = 23, y = 0, size = 25, exits = { {k="e",d="north",to="ROUTE_39",r=6} } }, + { x = 4, y = 8, size = 21, exits = { {k="e",d="west",to="ROUTE_40",r=3} } }, + { x = 10, y = 19, size = 4, exits = { } }, + }, + ["OLIVINE_GOOD_ROD_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=3,y=7,to="OLIVINE_CITY",r=2} } }, + }, + ["OLIVINE_GYM"] = { + { x = 3, y = 2, size = 58, exits = { {k="w",i=1,x=4,y=15,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=5,y=15,to="OLIVINE_CITY",r=2} } }, + }, + ["OLIVINE_HOUSE_BETA"] = { + { x = 3, y = 1, size = 42, exits = { } }, + }, + ["OLIVINE_LIGHTHOUSE_1F"] = { + { x = 4, y = 2, size = 105, exits = { {k="w",i=1,x=10,y=17,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=11,y=17,to="OLIVINE_CITY",r=2}, {k="w",i=3,x=3,y=11,to="OLIVINE_LIGHTHOUSE_2F",r=1} } }, + }, + ["OLIVINE_LIGHTHOUSE_2F"] = { + { x = 4, y = 2, size = 104, exits = { {k="w",i=1,x=3,y=11,to="OLIVINE_LIGHTHOUSE_1F",r=1}, {k="w",i=2,x=5,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=1}, {k="w",i=3,x=16,y=13,to="OLIVINE_LIGHTHOUSE_1F",r=1}, {k="w",i=4,x=17,y=13,to="OLIVINE_LIGHTHOUSE_1F",r=1} } }, + }, + ["OLIVINE_LIGHTHOUSE_3F"] = { + { x = 4, y = 2, size = 108, exits = { {k="w",i=1,x=13,y=3,to="OLIVINE_LIGHTHOUSE_4F",r=1}, {k="w",i=2,x=5,y=3,to="OLIVINE_LIGHTHOUSE_2F",r=1}, {k="w",i=4,x=16,y=11,to="OLIVINE_LIGHTHOUSE_2F",r=1}, {k="w",i=5,x=17,y=11,to="OLIVINE_LIGHTHOUSE_2F",r=1} } }, + { x = 8, y = 2, size = 7, exits = { {k="w",i=3,x=9,y=5,to="OLIVINE_LIGHTHOUSE_4F",r=2} } }, + }, + ["OLIVINE_LIGHTHOUSE_4F"] = { + { x = 4, y = 2, size = 114, exits = { {k="w",i=1,x=13,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=1}, {k="w",i=2,x=3,y=5,to="OLIVINE_LIGHTHOUSE_5F",r=1}, {k="w",i=5,x=16,y=9,to="OLIVINE_LIGHTHOUSE_3F",r=1}, {k="w",i=6,x=17,y=9,to="OLIVINE_LIGHTHOUSE_3F",r=1}, {k="w",i=7,x=8,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=2}, {k="w",i=8,x=9,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=2} } }, + { x = 8, y = 4, size = 6, exits = { {k="w",i=3,x=9,y=7,to="OLIVINE_LIGHTHOUSE_5F",r=2}, {k="w",i=4,x=9,y=5,to="OLIVINE_LIGHTHOUSE_3F",r=2}, {k="w",i=7,x=8,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=2}, {k="w",i=8,x=9,y=3,to="OLIVINE_LIGHTHOUSE_3F",r=2} } }, + }, + ["OLIVINE_LIGHTHOUSE_5F"] = { + { x = 4, y = 2, size = 93, exits = { {k="w",i=2,x=3,y=5,to="OLIVINE_LIGHTHOUSE_4F",r=1}, {k="w",i=4,x=16,y=7,to="OLIVINE_LIGHTHOUSE_4F",r=1}, {k="w",i=5,x=17,y=7,to="OLIVINE_LIGHTHOUSE_4F",r=1} } }, + { x = 8, y = 6, size = 50, exits = { {k="w",i=1,x=9,y=15,to="OLIVINE_LIGHTHOUSE_6F",r=1}, {k="w",i=3,x=9,y=7,to="OLIVINE_LIGHTHOUSE_4F",r=2} } }, + }, + ["OLIVINE_LIGHTHOUSE_6F"] = { + { x = 4, y = 2, size = 149, exits = { {k="w",i=1,x=9,y=15,to="OLIVINE_LIGHTHOUSE_5F",r=2}, {k="w",i=2,x=16,y=5,to="OLIVINE_LIGHTHOUSE_5F",r=1}, {k="w",i=3,x=17,y=5,to="OLIVINE_LIGHTHOUSE_5F",r=1} } }, + }, + ["OLIVINE_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=3,y=7,to="OLIVINE_CITY",r=2} } }, + }, + ["OLIVINE_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=4,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["OLIVINE_PORT"] = { + { x = 10, y = 7, size = 48, exits = { {k="w",i=1,x=11,y=7,to="OLIVINE_PORT_PASSAGE",r=1}, {k="w",i=2,x=7,y=23,to="FAST_SHIP_1F",r=1} } }, + { x = 7, y = 24, size = 1, exits = { {k="w",i=2,x=7,y=23,to="FAST_SHIP_1F",r=1} } }, + }, + ["OLIVINE_PORT_PASSAGE"] = { + { x = 1, y = 1, size = 58, exits = { {k="w",i=4,x=3,y=2,to="OLIVINE_PORT_PASSAGE",r=2}, {k="w",i=5,x=3,y=14,to="OLIVINE_PORT",r=1} } }, + { x = 13, y = 1, size = 25, exits = { {k="w",i=1,x=15,y=0,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=16,y=0,to="OLIVINE_CITY",r=2}, {k="w",i=3,x=15,y=4,to="OLIVINE_PORT_PASSAGE",r=1} } }, + }, + ["OLIVINE_PUNISHMENT_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=3,y=7,to="OLIVINE_CITY",r=2} } }, + }, + ["OLIVINE_TIMS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="OLIVINE_CITY",r=2}, {k="w",i=2,x=3,y=7,to="OLIVINE_CITY",r=2} } }, + }, + ["PALLET_TOWN"] = { + { x = 0, y = 0, size = 3, exits = { {k="e",d="north",to="ROUTE_1",r=1}, {k="e",d="north",to="ROUTE_1",r=6} } }, + { x = 4, y = 0, size = 3, exits = { {k="e",d="north",to="ROUTE_1",r=6} } }, + { x = 8, y = 0, size = 204, exits = { {k="w",i=1,x=5,y=5,to="REDS_HOUSE_1F",r=1}, {k="w",i=2,x=13,y=5,to="BLUES_HOUSE",r=1}, {k="w",i=3,x=12,y=11,to="OAKS_LAB",r=1}, {k="e",d="south",to="ROUTE_21",r=1}, {k="e",d="north",to="ROUTE_1",r=3} } }, + { x = 11, y = 0, size = 7, exits = { {k="e",d="north",to="ROUTE_1",r=7}, {k="e",d="north",to="ROUTE_1",r=5} } }, + { x = 19, y = 0, size = 1, exits = { {k="e",d="north",to="ROUTE_1",r=5} } }, + }, + ["PEWTER_CITY"] = { + { x = 0, y = 0, size = 32, exits = { } }, + { x = 0, y = 2, size = 85, exits = { } }, + { x = 4, y = 2, size = 730, exits = { {k="w",i=1,x=29,y=13,to="PEWTER_NIDORAN_SPEECH_HOUSE",r=1}, {k="w",i=2,x=16,y=17,to="PEWTER_GYM",r=1}, {k="w",i=3,x=23,y=17,to="PEWTER_MART",r=2}, {k="w",i=4,x=13,y=25,to="PEWTER_POKECENTER_1F",r=2}, {k="w",i=5,x=7,y=29,to="PEWTER_SNOOZE_SPEECH_HOUSE",r=1}, {k="e",d="east",to="ROUTE_3",r=2}, {k="e",d="south",to="ROUTE_2",r=1} } }, + { x = 35, y = 10, size = 25, exits = { {k="e",d="east",to="ROUTE_3",r=1} } }, + { x = 35, y = 20, size = 25, exits = { {k="e",d="east",to="ROUTE_3",r=3} } }, + { x = 36, y = 26, size = 70, exits = { {k="e",d="east",to="ROUTE_3",r=3} } }, + { x = 2, y = 33, size = 34, exits = { } }, + }, + ["PEWTER_GYM"] = { + { x = 1, y = 1, size = 88, exits = { {k="w",i=1,x=4,y=13,to="PEWTER_CITY",r=3}, {k="w",i=2,x=5,y=13,to="PEWTER_CITY",r=3} } }, + }, + ["PEWTER_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="PEWTER_CITY",r=3}, {k="w",i=2,x=3,y=7,to="PEWTER_CITY",r=3} } }, + }, + ["PEWTER_NIDORAN_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="PEWTER_CITY",r=3}, {k="w",i=2,x=3,y=7,to="PEWTER_CITY",r=3} } }, + }, + ["PEWTER_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="PEWTER_CITY",r=3}, {k="w",i=2,x=4,y=7,to="PEWTER_CITY",r=3}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["PEWTER_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="PEWTER_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["PEWTER_SNOOZE_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="PEWTER_CITY",r=3}, {k="w",i=2,x=3,y=7,to="PEWTER_CITY",r=3} } }, + }, + ["PLAYERS_HOUSE_1F"] = { + { x = 6, y = 1, size = 56, exits = { {k="w",i=1,x=6,y=7,to="NEW_BARK_TOWN",r=1}, {k="w",i=2,x=7,y=7,to="NEW_BARK_TOWN",r=1}, {k="w",i=3,x=9,y=0,to="PLAYERS_HOUSE_2F",r=1} } }, + }, + ["PLAYERS_HOUSE_2F"] = { + { x = 0, y = 1, size = 34, exits = { {k="w",i=1,x=7,y=0,to="PLAYERS_HOUSE_1F",r=1} } }, + }, + ["PLAYERS_NEIGHBORS_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="NEW_BARK_TOWN",r=1}, {k="w",i=2,x=3,y=7,to="NEW_BARK_TOWN",r=1} } }, + }, + ["POKECENTER_2F"] = { + { x = 0, y = 1, size = 3, exits = { } }, + { x = 4, y = 1, size = 75, exits = { {k="w",i=1,x=0,y=7,to="POKECENTER_2F",r=2}, {k="w",i=2,x=5,y=0,to="TRADE_CENTER",r=1}, {k="w",i=3,x=9,y=0,to="COLOSSEUM",r=1}, {k="w",i=4,x=13,y=2,to="TIME_CAPSULE",r=1} } }, + }, + ["POKEMON_FAN_CLUB"] = { + { x = 2, y = 1, size = 46, exits = { {k="w",i=1,x=2,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VERMILION_CITY",r=2} } }, + }, + ["POWER_PLANT"] = { + { x = 2, y = 1, size = 214, exits = { {k="w",i=1,x=2,y=17,to="ROUTE_10_NORTH",r=3}, {k="w",i=2,x=3,y=17,to="ROUTE_10_NORTH",r=3} } }, + }, + ["RADIO_TOWER_1F"] = { + { x = 1, y = 1, size = 86, exits = { {k="w",i=1,x=2,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=2,x=3,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=3,x=15,y=0,to="RADIO_TOWER_2F",r=1} } }, + { x = 5, y = 6, size = 16, exits = { } }, + }, + ["RADIO_TOWER_2F"] = { + { x = 0, y = 1, size = 98, exits = { {k="w",i=1,x=0,y=0,to="RADIO_TOWER_3F",r=1}, {k="w",i=2,x=15,y=0,to="RADIO_TOWER_1F",r=1} } }, + }, + ["RADIO_TOWER_3F"] = { + { x = 0, y = 1, size = 84, exits = { {k="w",i=1,x=0,y=0,to="RADIO_TOWER_2F",r=1}, {k="w",i=2,x=7,y=0,to="RADIO_TOWER_4F",r=1} } }, + { x = 16, y = 1, size = 14, exits = { {k="w",i=3,x=17,y=0,to="RADIO_TOWER_4F",r=2} } }, + }, + ["RADIO_TOWER_4F"] = { + { x = 0, y = 1, size = 61, exits = { {k="w",i=1,x=0,y=0,to="RADIO_TOWER_5F",r=1}, {k="w",i=2,x=9,y=0,to="RADIO_TOWER_3F",r=1} } }, + { x = 12, y = 1, size = 30, exits = { {k="w",i=3,x=12,y=0,to="RADIO_TOWER_5F",r=2}, {k="w",i=4,x=17,y=0,to="RADIO_TOWER_3F",r=2} } }, + }, + ["RADIO_TOWER_5F"] = { + { x = 0, y = 1, size = 35, exits = { {k="w",i=1,x=0,y=0,to="RADIO_TOWER_4F",r=1} } }, + { x = 10, y = 1, size = 44, exits = { {k="w",i=2,x=12,y=0,to="RADIO_TOWER_4F",r=2} } }, + }, + ["REDS_HOUSE_1F"] = { + { x = 4, y = 1, size = 46, exits = { {k="w",i=1,x=2,y=7,to="PALLET_TOWN",r=3}, {k="w",i=2,x=3,y=7,to="PALLET_TOWN",r=3}, {k="w",i=3,x=7,y=0,to="REDS_HOUSE_2F",r=1} } }, + }, + ["REDS_HOUSE_2F"] = { + { x = 1, y = 1, size = 47, exits = { {k="w",i=1,x=7,y=0,to="REDS_HOUSE_1F",r=1} } }, + }, + ["ROCK_TUNNEL_1F"] = { + { x = 0, y = 0, size = 515, exits = { } }, + { x = 4, y = 2, size = 75, exits = { {k="w",i=3,x=5,y=3,to="ROCK_TUNNEL_B1F",r=4}, {k="w",i=4,x=15,y=9,to="ROCK_TUNNEL_B1F",r=2} } }, + { x = 13, y = 2, size = 47, exits = { {k="w",i=1,x=15,y=3,to="ROUTE_9",r=11}, {k="w",i=1,x=15,y=3,to="ROUTE_9",r=13}, {k="w",i=5,x=27,y=3,to="ROCK_TUNNEL_B1F",r=4} } }, + { x = 13, y = 4, size = 11, exits = { } }, + { x = 17, y = 8, size = 12, exits = { } }, + { x = 23, y = 8, size = 12, exits = { } }, + { x = 15, y = 12, size = 10, exits = { } }, + { x = 23, y = 12, size = 10, exits = { } }, + { x = 26, y = 12, size = 115, exits = { {k="w",i=2,x=11,y=25,to="ROUTE_10_SOUTH",r=1}, {k="w",i=2,x=11,y=25,to="ROUTE_10_SOUTH",r=2}, {k="w",i=6,x=27,y=13,to="ROCK_TUNNEL_B1F",r=2} } }, + { x = 3, y = 14, size = 12, exits = { } }, + }, + ["ROCK_TUNNEL_B1F"] = { + { x = 0, y = 0, size = 380, exits = { } }, + { x = 2, y = 2, size = 150, exits = { {k="w",i=1,x=3,y=3,to="ROCK_TUNNEL_1F",r=9}, {k="w",i=2,x=17,y=9,to="ROCK_TUNNEL_1F",r=2} } }, + { x = 19, y = 2, size = 10, exits = { } }, + { x = 22, y = 2, size = 142, exits = { {k="w",i=3,x=23,y=3,to="ROCK_TUNNEL_1F",r=2}, {k="w",i=4,x=25,y=23,to="ROCK_TUNNEL_1F",r=3} } }, + { x = 15, y = 4, size = 6, exits = { } }, + { x = 19, y = 8, size = 10, exits = { } }, + { x = 23, y = 10, size = 6, exits = { } }, + { x = 15, y = 12, size = 6, exits = { } }, + { x = 11, y = 14, size = 10, exits = { } }, + { x = 23, y = 18, size = 12, exits = { } }, + { x = 3, y = 20, size = 12, exits = { } }, + { x = 9, y = 20, size = 12, exits = { } }, + { x = 19, y = 20, size = 6, exits = { } }, + }, + ["ROUTE_1"] = { + { x = 0, y = 0, size = 36, exits = { {k="e",d="south",to="PALLET_TOWN",r=1}, {k="e",d="north",to="VIRIDIAN_CITY",r=5} } }, + { x = 2, y = 0, size = 5, exits = { {k="e",d="north",to="VIRIDIAN_CITY",r=6} } }, + { x = 8, y = 0, size = 369, exits = { {k="e",d="south",to="PALLET_TOWN",r=3}, {k="e",d="north",to="VIRIDIAN_CITY",r=2} } }, + { x = 11, y = 0, size = 5, exits = { {k="e",d="north",to="VIRIDIAN_CITY",r=7} } }, + { x = 17, y = 0, size = 108, exits = { {k="e",d="south",to="PALLET_TOWN",r=4}, {k="e",d="south",to="PALLET_TOWN",r=5}, {k="e",d="north",to="VIRIDIAN_CITY",r=8} } }, + { x = 2, y = 33, size = 15, exits = { {k="e",d="south",to="PALLET_TOWN",r=1}, {k="e",d="south",to="PALLET_TOWN",r=2} } }, + { x = 11, y = 33, size = 15, exits = { {k="e",d="south",to="PALLET_TOWN",r=4} } }, + }, + ["ROUTE_10_NORTH"] = { + { x = 0, y = 0, size = 153, exits = { {k="e",d="south",to="ROUTE_10_SOUTH",r=1}, {k="e",d="north",to="ROUTE_9",r=11}, {k="e",d="north",to="ROUTE_9",r=2} } }, + { x = 4, y = 0, size = 36, exits = { {k="w",i=1,x=11,y=1,to="ROUTE_10_POKECENTER_1F",r=2}, {k="e",d="north",to="ROUTE_9",r=11}, {k="e",d="north",to="ROUTE_9",r=13}, {k="e",d="north",to="ROUTE_9",r=3} } }, + { x = 2, y = 10, size = 27, exits = { {k="w",i=2,x=3,y=9,to="POWER_PLANT",r=1} } }, + }, + ["ROUTE_10_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="ROUTE_10_NORTH",r=2}, {k="w",i=2,x=4,y=7,to="ROUTE_10_NORTH",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["ROUTE_10_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="ROUTE_10_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["ROUTE_10_SOUTH"] = { + { x = 0, y = 0, size = 86, exits = { {k="w",i=1,x=6,y=1,to="ROCK_TUNNEL_1F",r=9}, {k="e",d="south",to="LAVENDER_TOWN",r=1}, {k="e",d="south",to="LAVENDER_TOWN",r=3}, {k="e",d="north",to="ROUTE_10_NORTH",r=1} } }, + { x = 2, y = 2, size = 128, exits = { {k="w",i=1,x=6,y=1,to="ROCK_TUNNEL_1F",r=9}, {k="e",d="south",to="LAVENDER_TOWN",r=2} } }, + { x = 11, y = 4, size = 28, exits = { } }, + }, + ["ROUTE_11"] = { + { x = 4, y = 0, size = 560, exits = { {k="e",d="east",to="ROUTE_12",r=2}, {k="e",d="west",to="VERMILION_CITY",r=2} } }, + }, + ["ROUTE_12"] = { + { x = 0, y = 0, size = 43, exits = { {k="e",d="north",to="LAVENDER_TOWN",r=4} } }, + { x = 8, y = 0, size = 243, exits = { {k="w",i=1,x=11,y=33,to="ROUTE_12_SUPER_ROD_HOUSE",r=1}, {k="e",d="north",to="LAVENDER_TOWN",r=2}, {k="e",d="south",to="ROUTE_13",r=3}, {k="e",d="west",to="ROUTE_11",r=1} } }, + { x = 11, y = 0, size = 9, exits = { {k="e",d="north",to="LAVENDER_TOWN",r=3} } }, + { x = 14, y = 12, size = 4, exits = { } }, + { x = 0, y = 34, size = 60, exits = { {k="e",d="south",to="ROUTE_13",r=2} } }, + { x = 4, y = 43, size = 6, exits = { } }, + { x = 4, y = 49, size = 15, exits = { } }, + }, + ["ROUTE_12_SUPER_ROD_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_12",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_12",r=2} } }, + }, + ["ROUTE_13"] = { + { x = 0, y = 0, size = 54, exits = { {k="e",d="south",to="ROUTE_14",r=1}, {k="e",d="north",to="ROUTE_12",r=5} } }, + { x = 4, y = 0, size = 118, exits = { {k="e",d="north",to="ROUTE_12",r=5} } }, + { x = 54, y = 0, size = 323, exits = { {k="e",d="south",to="ROUTE_14",r=2}, {k="e",d="south",to="ROUTE_14",r=3}, {k="e",d="north",to="ROUTE_12",r=2} } }, + { x = 48, y = 2, size = 46, exits = { {k="e",d="south",to="ROUTE_14",r=2}, {k="e",d="south",to="ROUTE_14",r=3}, {k="e",d="north",to="ROUTE_12",r=2} } }, + { x = 24, y = 15, size = 8, exits = { } }, + }, + ["ROUTE_14"] = { + { x = 0, y = 0, size = 54, exits = { {k="e",d="north",to="ROUTE_13",r=1} } }, + { x = 4, y = 0, size = 43, exits = { {k="e",d="west",to="ROUTE_15",r=3}, {k="e",d="north",to="ROUTE_13",r=3} } }, + { x = 11, y = 0, size = 347, exits = { {k="e",d="west",to="ROUTE_15",r=3}, {k="e",d="north",to="ROUTE_13",r=3} } }, + { x = 0, y = 22, size = 9, exits = { {k="e",d="west",to="ROUTE_15",r=4}, {k="e",d="west",to="ROUTE_15",r=3}, {k="e",d="north",to="ROUTE_13",r=3} } }, + { x = 0, y = 33, size = 54, exits = { {k="e",d="west",to="ROUTE_15",r=5} } }, + }, + ["ROUTE_15"] = { + { x = 2, y = 0, size = 10, exits = { } }, + { x = 7, y = 0, size = 87, exits = { } }, + { x = 3, y = 4, size = 217, exits = { {k="w",i=1,x=2,y=4,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="w",i=2,x=2,y=5,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="e",d="east",to="ROUTE_14",r=3} } }, + { x = 11, y = 4, size = 87, exits = { {k="w",i=1,x=2,y=4,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="w",i=2,x=2,y=5,to="ROUTE_15_FUCHSIA_GATE",r=2}, {k="e",d="east",to="ROUTE_14",r=4}, {k="e",d="east",to="ROUTE_14",r=3} } }, + { x = 4, y = 7, size = 146, exits = { {k="e",d="east",to="ROUTE_14",r=5} } }, + }, + ["ROUTE_15_FUCHSIA_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=0,y=5,to="FUCHSIA_CITY",r=2}, {k="w",i=3,x=9,y=4,to="ROUTE_15",r=3}, {k="w",i=4,x=9,y=5,to="ROUTE_15",r=3} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_16"] = { + { x = 12, y = 0, size = 44, exits = { {k="w",i=1,x=3,y=1,to="ROUTE_16_FUCHSIA_SPEECH_HOUSE",r=1} } }, + { x = 15, y = 5, size = 11, exits = { {k="w",i=2,x=14,y=6,to="ROUTE_16_GATE",r=2}, {k="w",i=3,x=14,y=7,to="ROUTE_16_GATE",r=2}, {k="e",d="east",to="CELADON_CITY",r=1} } }, + { x = 2, y = 6, size = 74, exits = { {k="w",i=4,x=9,y=6,to="ROUTE_16_GATE",r=2}, {k="w",i=5,x=9,y=7,to="ROUTE_16_GATE",r=2}, {k="e",d="south",to="ROUTE_17",r=1} } }, + }, + ["ROUTE_16_FUCHSIA_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_16",r=1}, {k="w",i=2,x=3,y=7,to="ROUTE_16",r=1} } }, + }, + ["ROUTE_16_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ROUTE_16",r=3}, {k="w",i=2,x=0,y=5,to="ROUTE_16",r=3}, {k="w",i=3,x=9,y=4,to="ROUTE_16",r=2}, {k="w",i=4,x=9,y=5,to="ROUTE_16",r=2} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_17"] = { + { x = 1, y = 0, size = 961, exits = { {k="w",i=1,x=17,y=82,to="ROUTE_17_ROUTE_18_GATE",r=2}, {k="w",i=2,x=17,y=83,to="ROUTE_17_ROUTE_18_GATE",r=2}, {k="e",d="north",to="ROUTE_16",r=3} } }, + }, + ["ROUTE_17_ROUTE_18_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ROUTE_17",r=1}, {k="w",i=2,x=0,y=5,to="ROUTE_17",r=1}, {k="w",i=3,x=9,y=4,to="ROUTE_18",r=2}, {k="w",i=4,x=9,y=5,to="ROUTE_18",r=2} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_18"] = { + { x = 7, y = 0, size = 22, exits = { } }, + { x = 14, y = 5, size = 99, exits = { {k="w",i=1,x=2,y=6,to="ROUTE_17_ROUTE_18_GATE",r=2}, {k="w",i=2,x=2,y=7,to="ROUTE_17_ROUTE_18_GATE",r=2}, {k="e",d="east",to="FUCHSIA_CITY",r=2} } }, + { x = 15, y = 11, size = 65, exits = { {k="e",d="east",to="FUCHSIA_CITY",r=2} } }, + }, + ["ROUTE_19"] = { + { x = 1, y = 0, size = 20, exits = { {k="e",d="north",to="FUCHSIA_CITY",r=5} } }, + { x = 10, y = 0, size = 4, exits = { {k="e",d="north",to="FUCHSIA_CITY",r=2} } }, + { x = 15, y = 0, size = 12, exits = { {k="e",d="north",to="FUCHSIA_CITY",r=4} } }, + { x = 10, y = 2, size = 71, exits = { {k="w",i=1,x=7,y=3,to="ROUTE_19_FUCHSIA_GATE",r=1} } }, + }, + ["ROUTE_19_FUCHSIA_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=5,y=0,to="FUCHSIA_CITY",r=2}, {k="w",i=3,x=4,y=7,to="ROUTE_19",r=4}, {k="w",i=4,x=5,y=7,to="ROUTE_19",r=4} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_2"] = { + { x = 8, y = 0, size = 349, exits = { {k="e",d="south",to="VIRIDIAN_CITY",r=2}, {k="e",d="north",to="PEWTER_CITY",r=3} } }, + { x = 11, y = 2, size = 106, exits = { {k="w",i=1,x=15,y=15,to="ROUTE_2_NUGGET_HOUSE",r=1}, {k="w",i=5,x=12,y=7,to="DIGLETTS_CAVE",r=3} } }, + { x = 11, y = 4, size = 18, exits = { {k="w",i=5,x=12,y=7,to="DIGLETTS_CAVE",r=3} } }, + { x = 15, y = 19, size = 35, exits = { {k="w",i=3,x=16,y=27,to="ROUTE_2_GATE",r=1}, {k="w",i=4,x=17,y=27,to="ROUTE_2_GATE",r=1} } }, + { x = 0, y = 32, size = 3, exits = { } }, + { x = 6, y = 32, size = 4, exits = { } }, + { x = 14, y = 32, size = 122, exits = { {k="w",i=2,x=15,y=31,to="ROUTE_2_GATE",r=1} } }, + }, + ["ROUTE_20"] = { + { x = 29, y = 2, size = 12, exits = { } }, + { x = 34, y = 2, size = 75, exits = { {k="w",i=1,x=38,y=7,to="SEAFOAM_GYM",r=2} } }, + { x = 37, y = 4, size = 12, exits = { {k="w",i=1,x=38,y=7,to="SEAFOAM_GYM",r=2} } }, + { x = 12, y = 8, size = 8, exits = { } }, + }, + ["ROUTE_21"] = { + { x = 0, y = 1, size = 15, exits = { } }, + { x = 8, y = 1, size = 30, exits = { } }, + { x = 15, y = 1, size = 25, exits = { } }, + { x = 4, y = 14, size = 8, exits = { } }, + { x = 14, y = 22, size = 8, exits = { } }, + }, + ["ROUTE_22"] = { + { x = 0, y = 0, size = 339, exits = { {k="e",d="east",to="VIRIDIAN_CITY",r=1}, {k="e",d="east",to="VIRIDIAN_CITY",r=3} } }, + { x = 30, y = 2, size = 200, exits = { {k="w",i=1,x=13,y=5,to="VICTORY_ROAD_GATE",r=1}, {k="e",d="east",to="VIRIDIAN_CITY",r=2} } }, + { x = 27, y = 6, size = 14, exits = { } }, + }, + ["ROUTE_23"] = { + { x = 0, y = 0, size = 64, exits = { } }, + { x = 14, y = 0, size = 48, exits = { } }, + { x = 7, y = 6, size = 43, exits = { {k="w",i=1,x=9,y=5,to="INDIGO_PLATEAU_POKECENTER_1F",r=1}, {k="w",i=2,x=10,y=5,to="INDIGO_PLATEAU_POKECENTER_1F",r=1}, {k="w",i=3,x=9,y=13,to="VICTORY_ROAD",r=1}, {k="w",i=3,x=9,y=13,to="VICTORY_ROAD",r=2}, {k="w",i=4,x=10,y=13,to="VICTORY_ROAD",r=1}, {k="w",i=4,x=10,y=13,to="VICTORY_ROAD",r=2} } }, + { x = 15, y = 12, size = 46, exits = { {k="w",i=3,x=9,y=13,to="VICTORY_ROAD",r=1}, {k="w",i=3,x=9,y=13,to="VICTORY_ROAD",r=2}, {k="w",i=4,x=10,y=13,to="VICTORY_ROAD",r=1}, {k="w",i=4,x=10,y=13,to="VICTORY_ROAD",r=2} } }, + { x = 17, y = 14, size = 28, exits = { } }, + }, + ["ROUTE_24"] = { + { x = 0, y = 0, size = 26, exits = { {k="e",d="south",to="CERULEAN_CITY",r=1}, {k="e",d="north",to="ROUTE_25",r=1} } }, + { x = 2, y = 0, size = 27, exits = { {k="e",d="north",to="ROUTE_25",r=3} } }, + { x = 8, y = 0, size = 36, exits = { {k="e",d="south",to="CERULEAN_CITY",r=2}, {k="e",d="north",to="ROUTE_25",r=3} } }, + { x = 11, y = 0, size = 54, exits = { {k="e",d="south",to="CERULEAN_CITY",r=3}, {k="e",d="north",to="ROUTE_25",r=3} } }, + { x = 15, y = 0, size = 15, exits = { {k="e",d="north",to="ROUTE_25",r=7} } }, + }, + ["ROUTE_25"] = { + { x = 0, y = 0, size = 180, exits = { {k="e",d="south",to="ROUTE_24",r=1} } }, + { x = 46, y = 2, size = 4, exits = { } }, + { x = 2, y = 4, size = 386, exits = { {k="w",i=1,x=47,y=5,to="BILLS_HOUSE",r=1}, {k="e",d="south",to="ROUTE_24",r=2}, {k="e",d="south",to="ROUTE_24",r=3}, {k="e",d="south",to="ROUTE_24",r=4} } }, + { x = 30, y = 4, size = 10, exits = { } }, + { x = 42, y = 4, size = 4, exits = { } }, + { x = 50, y = 4, size = 6, exits = { } }, + { x = 15, y = 14, size = 96, exits = { {k="e",d="south",to="ROUTE_24",r=5} } }, + }, + ["ROUTE_26"] = { + { x = 0, y = 0, size = 356, exits = { } }, + { x = 6, y = 6, size = 562, exits = { {k="w",i=1,x=7,y=5,to="VICTORY_ROAD_GATE",r=1}, {k="w",i=2,x=15,y=57,to="ROUTE_26_HEAL_HOUSE",r=1}, {k="w",i=3,x=5,y=71,to="DAY_OF_WEEK_SIBLINGS_HOUSE",r=1}, {k="e",d="west",to="ROUTE_27",r=3}, {k="e",d="west",to="ROUTE_27",r=2} } }, + { x = 9, y = 10, size = 6, exits = { } }, + { x = 0, y = 12, size = 19, exits = { } }, + { x = 9, y = 18, size = 12, exits = { } }, + { x = 11, y = 40, size = 43, exits = { } }, + { x = 4, y = 78, size = 30, exits = { {k="e",d="west",to="ROUTE_27",r=3} } }, + { x = 2, y = 86, size = 10, exits = { {k="e",d="west",to="ROUTE_27",r=3} } }, + { x = 2, y = 92, size = 22, exits = { {k="e",d="west",to="ROUTE_27",r=3} } }, + }, + ["ROUTE_26_HEAL_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_26",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_26",r=2} } }, + }, + ["ROUTE_27"] = { + { x = 17, y = 0, size = 102, exits = { {k="w",i=2,x=26,y=5,to="TOHJO_FALLS",r=6}, {k="w",i=3,x=36,y=5,to="TOHJO_FALLS",r=7} } }, + { x = 46, y = 4, size = 88, exits = { {k="e",d="east",to="ROUTE_26",r=2} } }, + { x = 70, y = 4, size = 10, exits = { {k="e",d="east",to="ROUTE_26",r=9}, {k="e",d="east",to="ROUTE_26",r=2} } }, + { x = 24, y = 6, size = 59, exits = { {k="w",i=2,x=26,y=5,to="TOHJO_FALLS",r=6} } }, + { x = 36, y = 6, size = 15, exits = { {k="w",i=1,x=33,y=7,to="ROUTE_27_SANDSTORM_HOUSE",r=1}, {k="w",i=2,x=26,y=5,to="TOHJO_FALLS",r=6}, {k="w",i=3,x=36,y=5,to="TOHJO_FALLS",r=7} } }, + { x = 52, y = 12, size = 4, exits = { } }, + { x = 58, y = 12, size = 8, exits = { } }, + }, + ["ROUTE_27_SANDSTORM_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_27",r=5}, {k="w",i=2,x=3,y=7,to="ROUTE_27",r=5} } }, + }, + ["ROUTE_28"] = { + { x = 0, y = 0, size = 231, exits = { {k="e",d="west",to="SILVER_CAVE_OUTSIDE",r=5} } }, + { x = 0, y = 3, size = 30, exits = { {k="w",i=1,x=7,y=3,to="ROUTE_28_STEEL_WING_HOUSE",r=1}, {k="w",i=2,x=33,y=5,to="VICTORY_ROAD_GATE",r=1}, {k="e",d="west",to="SILVER_CAVE_OUTSIDE",r=7}, {k="e",d="west",to="SILVER_CAVE_OUTSIDE",r=6} } }, + { x = 30, y = 4, size = 167, exits = { {k="w",i=2,x=33,y=5,to="VICTORY_ROAD_GATE",r=1}, {k="e",d="west",to="SILVER_CAVE_OUTSIDE",r=6} } }, + }, + ["ROUTE_28_STEEL_WING_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_28",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_28",r=2} } }, + }, + ["ROUTE_29"] = { + { x = 11, y = 1, size = 514, exits = { {k="w",i=1,x=27,y=1,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="e",d="east",to="NEW_BARK_TOWN",r=1}, {k="e",d="west",to="CHERRYGROVE_CITY",r=1} } }, + }, + ["ROUTE_29_ROUTE_46_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_46",r=10}, {k="w",i=2,x=5,y=0,to="ROUTE_46",r=10}, {k="w",i=3,x=4,y=7,to="ROUTE_29",r=1}, {k="w",i=4,x=5,y=7,to="ROUTE_29",r=1} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_2_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_2",r=4}, {k="w",i=2,x=5,y=0,to="ROUTE_2",r=4}, {k="w",i=3,x=4,y=7,to="ROUTE_2",r=7}, {k="w",i=4,x=5,y=7,to="ROUTE_2",r=7} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_2_NUGGET_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_2",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_2",r=2} } }, + }, + ["ROUTE_3"] = { + { x = 0, y = 0, size = 357, exits = { {k="w",i=1,x=52,y=1,to="MOUNT_MOON",r=2}, {k="e",d="east",to="ROUTE_4",r=1}, {k="e",d="west",to="PEWTER_CITY",r=4} } }, + { x = 6, y = 2, size = 362, exits = { {k="w",i=1,x=52,y=1,to="MOUNT_MOON",r=2}, {k="e",d="west",to="PEWTER_CITY",r=3} } }, + { x = 0, y = 10, size = 35, exits = { {k="e",d="west",to="PEWTER_CITY",r=5}, {k="e",d="west",to="PEWTER_CITY",r=6} } }, + { x = 7, y = 12, size = 72, exits = { } }, + }, + ["ROUTE_30"] = { + { x = 4, y = 0, size = 512, exits = { {k="w",i=1,x=7,y=39,to="ROUTE_30_BERRY_HOUSE",r=1}, {k="w",i=2,x=17,y=5,to="MR_POKEMONS_HOUSE",r=1}, {k="e",d="south",to="CHERRYGROVE_CITY",r=1}, {k="e",d="north",to="ROUTE_31",r=2} } }, + }, + ["ROUTE_30_BERRY_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_30",r=1}, {k="w",i=2,x=3,y=7,to="ROUTE_30",r=1} } }, + }, + ["ROUTE_31"] = { + { x = 33, y = 0, size = 30, exits = { {k="w",i=3,x=34,y=5,to="DARK_CAVE_VIOLET_ENTRANCE",r=2} } }, + { x = 6, y = 4, size = 250, exits = { {k="w",i=1,x=4,y=6,to="ROUTE_31_VIOLET_GATE",r=2}, {k="w",i=2,x=4,y=7,to="ROUTE_31_VIOLET_GATE",r=2}, {k="w",i=3,x=34,y=5,to="DARK_CAVE_VIOLET_ENTRANCE",r=2}, {k="e",d="south",to="ROUTE_30",r=1} } }, + }, + ["ROUTE_31_VIOLET_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="VIOLET_CITY",r=3}, {k="w",i=2,x=0,y=5,to="VIOLET_CITY",r=3}, {k="w",i=3,x=9,y=4,to="ROUTE_31",r=2}, {k="w",i=4,x=9,y=5,to="ROUTE_31",r=2} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_32"] = { + { x = 8, y = 0, size = 525, exits = { {k="w",i=1,x=11,y=73,to="ROUTE_32_POKECENTER_1F",r=2}, {k="w",i=2,x=4,y=2,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2}, {k="w",i=3,x=4,y=3,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2}, {k="w",i=4,x=6,y=79,to="UNION_CAVE_1F",r=2}, {k="e",d="north",to="VIOLET_CITY",r=3} } }, + { x = 11, y = 6, size = 22, exits = { } }, + { x = 3, y = 8, size = 29, exits = { } }, + { x = 8, y = 10, size = 33, exits = { {k="w",i=1,x=11,y=73,to="ROUTE_32_POKECENTER_1F",r=2}, {k="w",i=2,x=4,y=2,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2}, {k="w",i=3,x=4,y=3,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2}, {k="w",i=4,x=6,y=79,to="UNION_CAVE_1F",r=2}, {k="e",d="north",to="VIOLET_CITY",r=3} } }, + { x = 13, y = 14, size = 10, exits = { } }, + { x = 17, y = 24, size = 94, exits = { } }, + { x = 7, y = 26, size = 16, exits = { } }, + { x = 1, y = 72, size = 136, exits = { {k="w",i=4,x=6,y=79,to="UNION_CAVE_1F",r=2}, {k="e",d="south",to="ROUTE_33",r=1} } }, + }, + ["ROUTE_32_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="ROUTE_32",r=1}, {k="w",i=2,x=4,y=7,to="ROUTE_32",r=1}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["ROUTE_32_RUINS_OF_ALPH_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=2,x=0,y=5,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=3,x=9,y=4,to="ROUTE_32",r=1}, {k="w",i=4,x=9,y=5,to="ROUTE_32",r=1} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_33"] = { + { x = 0, y = 0, size = 81, exits = { {k="w",i=1,x=11,y=9,to="UNION_CAVE_1F",r=2}, {k="e",d="west",to="AZALEA_TOWN",r=1}, {k="e",d="north",to="ROUTE_32",r=8} } }, + { x = 6, y = 10, size = 81, exits = { {k="w",i=1,x=11,y=9,to="UNION_CAVE_1F",r=2}, {k="e",d="west",to="AZALEA_TOWN",r=2} } }, + { x = 15, y = 10, size = 20, exits = { } }, + }, + ["ROUTE_34"] = { + { x = 4, y = 0, size = 9, exits = { {k="e",d="north",to="GOLDENROD_CITY",r=22} } }, + { x = 8, y = 0, size = 271, exits = { {k="w",i=1,x=13,y=37,to="ROUTE_34_ILEX_FOREST_GATE",r=1}, {k="w",i=2,x=14,y=37,to="ROUTE_34_ILEX_FOREST_GATE",r=1}, {k="w",i=3,x=11,y=14,to="DAY_CARE",r=1}, {k="w",i=4,x=11,y=15,to="DAY_CARE",r=1}, {k="e",d="north",to="GOLDENROD_CITY",r=7} } }, + { x = 13, y = 0, size = 21, exits = { {k="e",d="north",to="GOLDENROD_CITY",r=23}, {k="e",d="north",to="GOLDENROD_CITY",r=19} } }, + { x = 13, y = 16, size = 25, exits = { {k="w",i=5,x=13,y=15,to="DAY_CARE",r=1} } }, + { x = 8, y = 25, size = 19, exits = { } }, + { x = 13, y = 41, size = 1, exits = { } }, + { x = 6, y = 43, size = 33, exits = { } }, + }, + ["ROUTE_34_ILEX_FOREST_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_34",r=2}, {k="w",i=2,x=5,y=0,to="ROUTE_34",r=2}, {k="w",i=3,x=4,y=7,to="ILEX_FOREST",r=1}, {k="w",i=4,x=5,y=7,to="ILEX_FOREST",r=1} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_35"] = { + { x = 17, y = 0, size = 8, exits = { {k="e",d="north",to="ROUTE_36",r=1} } }, + { x = 0, y = 4, size = 249, exits = { {k="w",i=1,x=9,y=33,to="ROUTE_35_GOLDENROD_GATE",r=1}, {k="w",i=2,x=10,y=33,to="ROUTE_35_GOLDENROD_GATE",r=1}, {k="w",i=3,x=3,y=5,to="ROUTE_35_NATIONAL_PARK_GATE",r=1} } }, + { x = 0, y = 11, size = 29, exits = { } }, + { x = 0, y = 33, size = 21, exits = { {k="e",d="south",to="GOLDENROD_CITY",r=1} } }, + { x = 13, y = 33, size = 21, exits = { {k="e",d="south",to="GOLDENROD_CITY",r=2} } }, + }, + ["ROUTE_35_GOLDENROD_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_35",r=2}, {k="w",i=2,x=5,y=0,to="ROUTE_35",r=2}, {k="w",i=3,x=4,y=7,to="GOLDENROD_CITY",r=7}, {k="w",i=4,x=5,y=7,to="GOLDENROD_CITY",r=7} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_35_NATIONAL_PARK_GATE"] = { + { x = 0, y = 1, size = 48, exits = { {k="w",i=1,x=3,y=0,to="NATIONAL_PARK",r=1}, {k="w",i=2,x=4,y=0,to="NATIONAL_PARK",r=1}, {k="w",i=3,x=3,y=7,to="ROUTE_35",r=2}, {k="w",i=4,x=4,y=7,to="ROUTE_35",r=2} } }, + }, + ["ROUTE_36"] = { + { x = 25, y = 0, size = 270, exits = { {k="w",i=1,x=18,y=8,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=2,x=18,y=9,to="ROUTE_36_NATIONAL_PARK_GATE",r=2}, {k="w",i=3,x=47,y=13,to="ROUTE_36_RUINS_OF_ALPH_GATE",r=1}, {k="w",i=4,x=48,y=13,to="ROUTE_36_RUINS_OF_ALPH_GATE",r=1}, {k="e",d="east",to="VIOLET_CITY",r=3}, {k="e",d="north",to="ROUTE_37",r=1}, {k="e",d="south",to="ROUTE_35",r=1} } }, + { x = 47, y = 17, size = 1, exits = { } }, + }, + ["ROUTE_36_NATIONAL_PARK_GATE"] = { + { x = 0, y = 1, size = 11, exits = { {k="w",i=1,x=0,y=4,to="NATIONAL_PARK",r=1} } }, + { x = 6, y = 1, size = 47, exits = { {k="w",i=1,x=0,y=4,to="NATIONAL_PARK",r=1}, {k="w",i=2,x=0,y=5,to="NATIONAL_PARK",r=1}, {k="w",i=3,x=9,y=4,to="ROUTE_36",r=1}, {k="w",i=4,x=9,y=5,to="ROUTE_36",r=1} } }, + }, + ["ROUTE_36_RUINS_OF_ALPH_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_36",r=1}, {k="w",i=2,x=5,y=0,to="ROUTE_36",r=1}, {k="w",i=3,x=4,y=7,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=4,x=5,y=7,to="RUINS_OF_ALPH_OUTSIDE",r=3} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_37"] = { + { x = 7, y = 0, size = 165, exits = { {k="e",d="south",to="ROUTE_36",r=1}, {k="e",d="north",to="ECRUTEAK_CITY",r=6} } }, + }, + ["ROUTE_38"] = { + { x = 2, y = 0, size = 9, exits = { {k="e",d="west",to="ROUTE_39",r=3} } }, + { x = 4, y = 1, size = 275, exits = { {k="w",i=1,x=35,y=8,to="ROUTE_38_ECRUTEAK_GATE",r=2}, {k="w",i=2,x=35,y=9,to="ROUTE_38_ECRUTEAK_GATE",r=2}, {k="e",d="west",to="ROUTE_39",r=2} } }, + { x = 0, y = 13, size = 7, exits = { {k="e",d="west",to="ROUTE_39",r=4} } }, + { x = 15, y = 16, size = 6, exits = { } }, + }, + ["ROUTE_38_ECRUTEAK_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ROUTE_38",r=2}, {k="w",i=2,x=0,y=5,to="ROUTE_38",r=2}, {k="w",i=3,x=9,y=4,to="ECRUTEAK_CITY",r=6}, {k="w",i=4,x=9,y=5,to="ECRUTEAK_CITY",r=6} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_39"] = { + { x = 1, y = 1, size = 3, exits = { } }, + { x = 8, y = 1, size = 353, exits = { {k="w",i=1,x=1,y=3,to="ROUTE_39_BARN",r=1}, {k="w",i=2,x=5,y=3,to="ROUTE_39_FARMHOUSE",r=1}, {k="e",d="east",to="ROUTE_38",r=2}, {k="e",d="south",to="OLIVINE_CITY",r=2} } }, + { x = 17, y = 4, size = 5, exits = { {k="e",d="east",to="ROUTE_38",r=1} } }, + { x = 17, y = 13, size = 11, exits = { {k="e",d="east",to="ROUTE_38",r=3} } }, + { x = 2, y = 35, size = 5, exits = { {k="e",d="south",to="OLIVINE_CITY",r=1} } }, + { x = 13, y = 35, size = 1, exits = { {k="e",d="south",to="OLIVINE_CITY",r=3} } }, + }, + ["ROUTE_39_BARN"] = { + { x = 0, y = 1, size = 48, exits = { {k="w",i=1,x=3,y=7,to="ROUTE_39",r=2}, {k="w",i=2,x=4,y=7,to="ROUTE_39",r=2} } }, + }, + ["ROUTE_39_FARMHOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_39",r=2}, {k="w",i=2,x=3,y=7,to="ROUTE_39",r=2} } }, + }, + ["ROUTE_4"] = { + { x = 0, y = 0, size = 201, exits = { {k="w",i=1,x=2,y=5,to="MOUNT_MOON",r=2}, {k="e",d="east",to="CERULEAN_CITY",r=1}, {k="e",d="east",to="CERULEAN_CITY",r=5}, {k="e",d="west",to="ROUTE_3",r=1} } }, + { x = 6, y = 2, size = 246, exits = { {k="w",i=1,x=2,y=5,to="MOUNT_MOON",r=2}, {k="e",d="east",to="CERULEAN_CITY",r=2} } }, + { x = 25, y = 10, size = 62, exits = { {k="e",d="east",to="CERULEAN_CITY",r=2} } }, + { x = 36, y = 13, size = 12, exits = { {k="e",d="east",to="CERULEAN_CITY",r=5} } }, + }, + ["ROUTE_40"] = { + { x = 1, y = 0, size = 54, exits = { } }, + { x = 3, y = 0, size = 8, exits = { } }, + { x = 16, y = 2, size = 4, exits = { {k="e",d="east",to="OLIVINE_CITY",r=4} } }, + { x = 12, y = 4, size = 67, exits = { {k="e",d="east",to="OLIVINE_CITY",r=2} } }, + }, + ["ROUTE_41"] = { + { x = 8, y = 10, size = 40, exits = { } }, + { x = 36, y = 12, size = 56, exits = { } }, + { x = 9, y = 14, size = 18, exits = { {k="w",i=1,x=12,y=17,to="WHIRL_ISLAND_NW",r=2} } }, + { x = 16, y = 16, size = 32, exits = { {k="w",i=1,x=12,y=17,to="WHIRL_ISLAND_NW",r=2} } }, + { x = 33, y = 16, size = 18, exits = { {k="w",i=2,x=36,y=19,to="WHIRL_ISLAND_NE",r=4} } }, + { x = 32, y = 20, size = 20, exits = { {k="w",i=2,x=36,y=19,to="WHIRL_ISLAND_NE",r=4} } }, + { x = 10, y = 32, size = 24, exits = { } }, + { x = 8, y = 34, size = 4, exits = { } }, + { x = 11, y = 34, size = 18, exits = { {k="w",i=3,x=12,y=37,to="WHIRL_ISLAND_SW",r=2} } }, + { x = 8, y = 38, size = 32, exits = { {k="w",i=3,x=12,y=37,to="WHIRL_ISLAND_SW",r=2} } }, + { x = 32, y = 38, size = 60, exits = { } }, + { x = 35, y = 42, size = 18, exits = { {k="w",i=4,x=36,y=45,to="WHIRL_ISLAND_SE",r=1} } }, + { x = 34, y = 46, size = 16, exits = { {k="w",i=4,x=36,y=45,to="WHIRL_ISLAND_SE",r=1} } }, + }, + ["ROUTE_42"] = { + { x = 7, y = 0, size = 167, exits = { {k="w",i=3,x=10,y=5,to="MOUNT_MORTAR_1F_OUTSIDE",r=7}, {k="w",i=4,x=28,y=9,to="MOUNT_MORTAR_1F_OUTSIDE",r=10}, {k="w",i=5,x=46,y=7,to="MOUNT_MORTAR_1F_OUTSIDE",r=8}, {k="e",d="east",to="MAHOGANY_TOWN",r=1} } }, + { x = 17, y = 0, size = 36, exits = { } }, + { x = 37, y = 0, size = 30, exits = { } }, + { x = 6, y = 4, size = 72, exits = { {k="w",i=1,x=0,y=8,to="ROUTE_42_ECRUTEAK_GATE",r=2}, {k="w",i=2,x=0,y=9,to="ROUTE_42_ECRUTEAK_GATE",r=2}, {k="w",i=3,x=10,y=5,to="MOUNT_MORTAR_1F_OUTSIDE",r=7} } }, + { x = 52, y = 4, size = 106, exits = { {k="w",i=5,x=46,y=7,to="MOUNT_MORTAR_1F_OUTSIDE",r=8}, {k="e",d="east",to="MAHOGANY_TOWN",r=3} } }, + { x = 32, y = 8, size = 39, exits = { {k="w",i=4,x=28,y=9,to="MOUNT_MORTAR_1F_OUTSIDE",r=10} } }, + { x = 57, y = 8, size = 110, exits = { {k="e",d="east",to="MAHOGANY_TOWN",r=4} } }, + { x = 24, y = 14, size = 20, exits = { } }, + { x = 51, y = 16, size = 12, exits = { } }, + }, + ["ROUTE_42_ECRUTEAK_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ECRUTEAK_CITY",r=6}, {k="w",i=2,x=0,y=5,to="ECRUTEAK_CITY",r=6}, {k="w",i=3,x=9,y=4,to="ROUTE_42",r=4}, {k="w",i=4,x=9,y=5,to="ROUTE_42",r=4} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_43"] = { + { x = 0, y = 0, size = 397, exits = { {k="w",i=1,x=9,y=51,to="ROUTE_43_MAHOGANY_GATE",r=1}, {k="w",i=2,x=10,y=51,to="ROUTE_43_MAHOGANY_GATE",r=1}, {k="w",i=3,x=17,y=35,to="ROUTE_43_GATE",r=1}, {k="w",i=4,x=17,y=31,to="ROUTE_43_GATE",r=1}, {k="w",i=5,x=18,y=31,to="ROUTE_43_GATE",r=1}, {k="e",d="north",to="LAKE_OF_RAGE",r=8}, {k="e",d="north",to="LAKE_OF_RAGE",r=5} } }, + { x = 0, y = 25, size = 10, exits = { } }, + { x = 3, y = 28, size = 2, exits = { } }, + { x = 3, y = 32, size = 120, exits = { {k="e",d="south",to="MAHOGANY_TOWN",r=1} } }, + { x = 19, y = 46, size = 20, exits = { {k="e",d="south",to="MAHOGANY_TOWN",r=2} } }, + }, + ["ROUTE_43_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_43",r=1}, {k="w",i=2,x=5,y=0,to="ROUTE_43",r=1}, {k="w",i=3,x=4,y=7,to="ROUTE_43",r=1}, {k="w",i=4,x=5,y=7,to="ROUTE_43",r=1} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_43_MAHOGANY_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_43",r=1}, {k="w",i=2,x=5,y=0,to="ROUTE_43",r=1}, {k="w",i=3,x=4,y=7,to="MAHOGANY_TOWN",r=3}, {k="w",i=4,x=5,y=7,to="MAHOGANY_TOWN",r=3} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_44"] = { + { x = 0, y = 0, size = 285, exits = { {k="w",i=1,x=56,y=7,to="ICE_PATH_1F",r=2}, {k="e",d="east",to="BLACKTHORN_CITY",r=2}, {k="e",d="west",to="MAHOGANY_TOWN",r=2}, {k="e",d="west",to="MAHOGANY_TOWN",r=4} } }, + { x = 14, y = 2, size = 329, exits = { {k="w",i=1,x=56,y=7,to="ICE_PATH_1F",r=2}, {k="e",d="west",to="MAHOGANY_TOWN",r=3} } }, + { x = 28, y = 8, size = 16, exits = { } }, + }, + ["ROUTE_45"] = { + { x = 0, y = 0, size = 133, exits = { {k="w",i=1,x=2,y=5,to="DARK_CAVE_BLACKTHORN_ENTRANCE",r=3}, {k="e",d="west",to="ROUTE_46",r=1}, {k="e",d="north",to="BLACKTHORN_CITY",r=2} } }, + { x = 8, y = 0, size = 120, exits = { {k="w",i=1,x=2,y=5,to="DARK_CAVE_BLACKTHORN_ENTRANCE",r=3}, {k="e",d="west",to="ROUTE_46",r=3}, {k="e",d="north",to="BLACKTHORN_CITY",r=5} } }, + { x = 15, y = 0, size = 304, exits = { {k="e",d="west",to="ROUTE_46",r=4}, {k="e",d="north",to="BLACKTHORN_CITY",r=2} } }, + { x = 7, y = 8, size = 22, exits = { } }, + { x = 10, y = 10, size = 20, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 13, y = 12, size = 18, exits = { } }, + { x = 10, y = 14, size = 6, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 16, y = 14, size = 10, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 10, y = 18, size = 38, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 2, y = 24, size = 28, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 9, y = 24, size = 16, exits = { } }, + { x = 12, y = 26, size = 6, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 19, y = 28, size = 29, exits = { } }, + { x = 2, y = 30, size = 62, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 10, y = 30, size = 27, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 7, y = 38, size = 22, exits = { } }, + { x = 4, y = 42, size = 50, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 10, y = 46, size = 4, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 10, y = 48, size = 25, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 5, y = 54, size = 18, exits = { } }, + { x = 8, y = 54, size = 40, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 2, y = 56, size = 14, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 11, y = 58, size = 28, exits = { } }, + { x = 8, y = 60, size = 2, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 16, y = 60, size = 20, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 8, y = 62, size = 69, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 2, y = 64, size = 15, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 9, y = 70, size = 36, exits = { } }, + { x = 12, y = 72, size = 58, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + { x = 0, y = 76, size = 52, exits = { {k="e",d="west",to="ROUTE_46",r=3} } }, + }, + ["ROUTE_46"] = { + { x = 0, y = 0, size = 92, exits = { {k="w",i=3,x=14,y=5,to="DARK_CAVE_VIOLET_ENTRANCE",r=2}, {k="e",d="east",to="ROUTE_45",r=1} } }, + { x = 5, y = 0, size = 10, exits = { } }, + { x = 6, y = 4, size = 60, exits = { {k="w",i=1,x=7,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=2,x=8,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=3,x=14,y=5,to="DARK_CAVE_VIOLET_ENTRANCE",r=2}, {k="e",d="east",to="ROUTE_45",r=30} } }, + { x = 17, y = 10, size = 51, exits = { {k="e",d="east",to="ROUTE_45",r=3} } }, + { x = 0, y = 12, size = 34, exits = { {k="w",i=1,x=7,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=2,x=8,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1} } }, + { x = 9, y = 12, size = 16, exits = { {k="w",i=1,x=7,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=2,x=8,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1} } }, + { x = 7, y = 14, size = 16, exits = { } }, + { x = 10, y = 16, size = 33, exits = { {k="w",i=1,x=7,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=2,x=8,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1} } }, + { x = 17, y = 18, size = 21, exits = { {k="e",d="east",to="ROUTE_45",r=3} } }, + { x = 2, y = 20, size = 91, exits = { {k="w",i=1,x=7,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1}, {k="w",i=2,x=8,y=33,to="ROUTE_29_ROUTE_46_GATE",r=1} } }, + }, + ["ROUTE_5"] = { + { x = 1, y = 0, size = 151, exits = { {k="w",i=1,x=17,y=15,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=8,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=3,x=9,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="e",d="north",to="CERULEAN_CITY",r=2} } }, + { x = 6, y = 0, size = 8, exits = { {k="w",i=1,x=17,y=15,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=8,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=3,x=9,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=4,x=10,y=11,to="ROUTE_5_CLEANSE_TAG_HOUSE",r=1}, {k="e",d="north",to="CERULEAN_CITY",r=2} } }, + { x = 6, y = 2, size = 24, exits = { {k="w",i=1,x=17,y=15,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=8,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=3,x=9,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=4,x=10,y=11,to="ROUTE_5_CLEANSE_TAG_HOUSE",r=1}, {k="e",d="north",to="CERULEAN_CITY",r=2} } }, + { x = 6, y = 6, size = 24, exits = { {k="w",i=1,x=17,y=15,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=8,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=3,x=9,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=4,x=10,y=11,to="ROUTE_5_CLEANSE_TAG_HOUSE",r=1}, {k="e",d="north",to="CERULEAN_CITY",r=2} } }, + { x = 6, y = 10, size = 16, exits = { {k="w",i=1,x=17,y=15,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=8,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=3,x=9,y=17,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=4,x=10,y=11,to="ROUTE_5_CLEANSE_TAG_HOUSE",r=1}, {k="e",d="north",to="CERULEAN_CITY",r=2} } }, + }, + ["ROUTE_5_CLEANSE_TAG_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="ROUTE_5",r=5}, {k="w",i=2,x=3,y=7,to="ROUTE_5",r=5} } }, + }, + ["ROUTE_5_SAFFRON_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="ROUTE_5",r=1}, {k="w",i=2,x=5,y=0,to="ROUTE_5",r=1}, {k="w",i=3,x=4,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=4,x=5,y=7,to="SAFFRON_CITY",r=3} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_5_UNDERGROUND_PATH_ENTRANCE"] = { + { x = 1, y = 1, size = 39, exits = { {k="w",i=1,x=3,y=7,to="ROUTE_5",r=1}, {k="w",i=2,x=4,y=7,to="ROUTE_5",r=1}, {k="w",i=3,x=4,y=3,to="UNDERGROUND_PATH",r=1} } }, + }, + ["ROUTE_6"] = { + { x = 0, y = 0, size = 228, exits = { {k="w",i=1,x=17,y=3,to="ROUTE_6_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=6,y=1,to="ROUTE_6_SAFFRON_GATE",r=1}, {k="e",d="south",to="VERMILION_CITY",r=2} } }, + { x = 0, y = 17, size = 5, exits = { {k="e",d="south",to="VERMILION_CITY",r=1} } }, + { x = 9, y = 17, size = 11, exits = { {k="e",d="south",to="VERMILION_CITY",r=3} } }, + }, + ["ROUTE_6_SAFFRON_GATE"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=5,y=0,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=4,y=7,to="ROUTE_6",r=1}, {k="w",i=4,x=5,y=7,to="ROUTE_6",r=1} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["ROUTE_6_UNDERGROUND_PATH_ENTRANCE"] = { + { x = 1, y = 1, size = 39, exits = { {k="w",i=1,x=3,y=7,to="ROUTE_6",r=1}, {k="w",i=2,x=4,y=7,to="ROUTE_6",r=1}, {k="w",i=3,x=4,y=3,to="UNDERGROUND_PATH",r=1} } }, + }, + ["ROUTE_7"] = { + { x = 0, y = 0, size = 126, exits = { {k="w",i=1,x=15,y=6,to="ROUTE_7_SAFFRON_GATE",r=2}, {k="w",i=2,x=15,y=7,to="ROUTE_7_SAFFRON_GATE",r=2}, {k="e",d="west",to="CELADON_CITY",r=1} } }, + { x = 18, y = 16, size = 4, exits = { {k="e",d="east",to="SAFFRON_CITY",r=7} } }, + }, + ["ROUTE_7_SAFFRON_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="ROUTE_7",r=1}, {k="w",i=2,x=0,y=5,to="ROUTE_7",r=1}, {k="w",i=3,x=9,y=4,to="SAFFRON_CITY",r=3}, {k="w",i=4,x=9,y=5,to="SAFFRON_CITY",r=3} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_8"] = { + { x = 5, y = 0, size = 34, exits = { } }, + { x = 37, y = 0, size = 15, exits = { {k="e",d="east",to="LAVENDER_TOWN",r=1} } }, + { x = 12, y = 2, size = 326, exits = { {k="w",i=1,x=4,y=4,to="ROUTE_8_SAFFRON_GATE",r=2}, {k="w",i=2,x=4,y=5,to="ROUTE_8_SAFFRON_GATE",r=2}, {k="e",d="east",to="LAVENDER_TOWN",r=2} } }, + { x = 22, y = 8, size = 34, exits = { } }, + { x = 37, y = 10, size = 90, exits = { {k="e",d="east",to="LAVENDER_TOWN",r=4} } }, + }, + ["ROUTE_8_SAFFRON_GATE"] = { + { x = 3, y = 1, size = 8, exits = { } }, + { x = 0, y = 2, size = 32, exits = { {k="w",i=1,x=0,y=4,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=0,y=5,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=9,y=4,to="ROUTE_8",r=3}, {k="w",i=4,x=9,y=5,to="ROUTE_8",r=3} } }, + { x = 3, y = 7, size = 4, exits = { } }, + }, + ["ROUTE_9"] = { + { x = 0, y = 0, size = 9, exits = { {k="e",d="west",to="CERULEAN_CITY",r=4} } }, + { x = 3, y = 0, size = 102, exits = { {k="e",d="south",to="ROUTE_10_NORTH",r=1} } }, + { x = 6, y = 2, size = 359, exits = { {k="e",d="south",to="ROUTE_10_NORTH",r=2} } }, + { x = 0, y = 4, size = 14, exits = { {k="e",d="west",to="CERULEAN_CITY",r=2} } }, + { x = 33, y = 4, size = 6, exits = { } }, + { x = 0, y = 6, size = 38, exits = { {k="e",d="south",to="ROUTE_10_NORTH",r=1}, {k="e",d="west",to="CERULEAN_CITY",r=6} } }, + { x = 11, y = 6, size = 10, exits = { } }, + { x = 19, y = 6, size = 6, exits = { } }, + { x = 25, y = 8, size = 6, exits = { } }, + { x = 31, y = 8, size = 10, exits = { } }, + { x = 5, y = 10, size = 122, exits = { {k="w",i=1,x=48,y=15,to="ROCK_TUNNEL_1F",r=3}, {k="e",d="south",to="ROUTE_10_NORTH",r=1} } }, + { x = 37, y = 10, size = 6, exits = { } }, + { x = 46, y = 16, size = 6, exits = { {k="w",i=1,x=48,y=15,to="ROCK_TUNNEL_1F",r=3}, {k="e",d="south",to="ROUTE_10_NORTH",r=2} } }, + }, + ["RUINS_OF_ALPH_AERODACTYL_CHAMBER"] = { + { x = 1, y = 1, size = 40, exits = { {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=8}, {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=11}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=8}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=11}, {k="w",i=3,x=3,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=4,x=4,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1} } }, + }, + ["RUINS_OF_ALPH_HO_OH_CHAMBER"] = { + { x = 1, y = 1, size = 40, exits = { {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=4}, {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=5}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=4}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=5}, {k="w",i=3,x=3,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=4,x=4,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1} } }, + }, + ["RUINS_OF_ALPH_INNER_CHAMBER"] = { + { x = 1, y = 1, size = 333, exits = { {k="w",i=1,x=10,y=13,to="RUINS_OF_ALPH_OUTSIDE",r=4}, {k="w",i=1,x=10,y=13,to="RUINS_OF_ALPH_OUTSIDE",r=3} } }, + }, + ["RUINS_OF_ALPH_KABUTO_CHAMBER"] = { + { x = 1, y = 1, size = 40, exits = { {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=2}, {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=2}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=3,x=3,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=4,x=4,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1} } }, + }, + ["RUINS_OF_ALPH_OMANYTE_CHAMBER"] = { + { x = 1, y = 1, size = 40, exits = { {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=7}, {k="w",i=1,x=3,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=10}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=7}, {k="w",i=2,x=4,y=9,to="RUINS_OF_ALPH_OUTSIDE",r=10}, {k="w",i=3,x=3,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=4,x=4,y=3,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1} } }, + }, + ["RUINS_OF_ALPH_OUTSIDE"] = { + { x = 6, y = 0, size = 6, exits = { } }, + { x = 11, y = 4, size = 18, exits = { {k="w",i=2,x=14,y=7,to="RUINS_OF_ALPH_KABUTO_CHAMBER",r=1} } }, + { x = 6, y = 6, size = 79, exits = { {k="w",i=2,x=14,y=7,to="RUINS_OF_ALPH_KABUTO_CHAMBER",r=1}, {k="w",i=5,x=10,y=13,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=6,x=17,y=11,to="RUINS_OF_ALPH_RESEARCH_CENTER",r=1}, {k="w",i=9,x=7,y=5,to="ROUTE_36_RUINS_OF_ALPH_GATE",r=1}, {k="w",i=10,x=13,y=20,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2}, {k="w",i=11,x=13,y=21,to="ROUTE_32_RUINS_OF_ALPH_GATE",r=2} } }, + { x = 7, y = 10, size = 52, exits = { {k="w",i=1,x=2,y=17,to="RUINS_OF_ALPH_HO_OH_CHAMBER",r=1}, {k="w",i=5,x=10,y=13,to="RUINS_OF_ALPH_INNER_CHAMBER",r=1}, {k="w",i=7,x=6,y=19,to="UNION_CAVE_B1F",r=2} } }, + { x = 2, y = 18, size = 27, exits = { {k="w",i=1,x=2,y=17,to="RUINS_OF_ALPH_HO_OH_CHAMBER",r=1}, {k="w",i=7,x=6,y=19,to="UNION_CAVE_B1F",r=2} } }, + { x = 18, y = 20, size = 4, exits = { } }, + { x = 7, y = 22, size = 46, exits = { {k="w",i=3,x=2,y=29,to="RUINS_OF_ALPH_OMANYTE_CHAMBER",r=1}, {k="w",i=8,x=6,y=27,to="UNION_CAVE_B1F",r=2} } }, + { x = 17, y = 26, size = 20, exits = { {k="w",i=4,x=16,y=33,to="RUINS_OF_ALPH_AERODACTYL_CHAMBER",r=1} } }, + { x = 6, y = 28, size = 10, exits = { {k="w",i=3,x=2,y=29,to="RUINS_OF_ALPH_OMANYTE_CHAMBER",r=1}, {k="w",i=4,x=16,y=33,to="RUINS_OF_ALPH_AERODACTYL_CHAMBER",r=1}, {k="w",i=8,x=6,y=27,to="UNION_CAVE_B1F",r=2} } }, + { x = 2, y = 30, size = 11, exits = { {k="w",i=3,x=2,y=29,to="RUINS_OF_ALPH_OMANYTE_CHAMBER",r=1}, {k="w",i=4,x=16,y=33,to="RUINS_OF_ALPH_AERODACTYL_CHAMBER",r=1} } }, + { x = 12, y = 30, size = 44, exits = { {k="w",i=4,x=16,y=33,to="RUINS_OF_ALPH_AERODACTYL_CHAMBER",r=1} } }, + }, + ["RUINS_OF_ALPH_RESEARCH_CENTER"] = { + { x = 4, y = 1, size = 37, exits = { {k="w",i=1,x=2,y=7,to="RUINS_OF_ALPH_OUTSIDE",r=3}, {k="w",i=2,x=3,y=7,to="RUINS_OF_ALPH_OUTSIDE",r=3} } }, + }, + ["SAFARI_ZONE_BETA"] = { + { x = 0, y = 0, size = 12, exits = { } }, + { x = 8, y = 0, size = 8, exits = { } }, + { x = 18, y = 0, size = 4, exits = { } }, + { x = 4, y = 2, size = 535, exits = { } }, + }, + ["SAFARI_ZONE_FUCHSIA_GATE_BETA"] = { + { x = 0, y = 1, size = 52, exits = { {k="w",i=1,x=4,y=0,to="SAFARI_ZONE_BETA",r=4}, {k="w",i=2,x=5,y=0,to="SAFARI_ZONE_BETA",r=4}, {k="w",i=3,x=4,y=7,to="FUCHSIA_CITY",r=1}, {k="w",i=4,x=5,y=7,to="FUCHSIA_CITY",r=1} } }, + { x = 0, y = 3, size = 2, exits = { } }, + { x = 9, y = 3, size = 2, exits = { } }, + }, + ["SAFARI_ZONE_MAIN_OFFICE"] = { + { x = 0, y = 1, size = 36, exits = { {k="w",i=1,x=2,y=7,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=3,y=7,to="FUCHSIA_CITY",r=2} } }, + { x = 7, y = 1, size = 4, exits = { } }, + }, + ["SAFARI_ZONE_WARDENS_HOME"] = { + { x = 2, y = 1, size = 46, exits = { {k="w",i=1,x=2,y=7,to="FUCHSIA_CITY",r=2}, {k="w",i=2,x=3,y=7,to="FUCHSIA_CITY",r=2} } }, + }, + ["SAFFRON_CITY"] = { + { x = 0, y = 0, size = 12, exits = { } }, + { x = 0, y = 4, size = 7, exits = { } }, + { x = 8, y = 4, size = 624, exits = { {k="w",i=1,x=26,y=3,to="FIGHTING_DOJO",r=1}, {k="w",i=2,x=34,y=3,to="SAFFRON_GYM",r=8}, {k="w",i=3,x=25,y=11,to="SAFFRON_MART",r=2}, {k="w",i=4,x=9,y=29,to="SAFFRON_POKECENTER_1F",r=2}, {k="w",i=5,x=27,y=29,to="MR_PSYCHICS_HOUSE",r=1}, {k="w",i=6,x=8,y=3,to="SAFFRON_MAGNET_TRAIN_STATION",r=2}, {k="w",i=7,x=18,y=21,to="SILPH_CO_1F",r=1}, {k="w",i=8,x=9,y=11,to="COPYCATS_HOUSE_1F",r=1}, {k="w",i=9,x=18,y=3,to="ROUTE_5_SAFFRON_GATE",r=1}, {k="w",i=10,x=0,y=24,to="ROUTE_7_SAFFRON_GATE",r=2}, {k="w",i=11,x=0,y=25,to="ROUTE_7_SAFFRON_GATE",r=2}, {k="w",i=12,x=16,y=33,to="ROUTE_6_SAFFRON_GATE",r=1}, {k="w",i=13,x=17,y=33,to="ROUTE_6_SAFFRON_GATE",r=1}, {k="w",i=14,x=39,y=22,to="ROUTE_8_SAFFRON_GATE",r=2}, {k="w",i=15,x=39,y=23,to="ROUTE_8_SAFFRON_GATE",r=2} } }, + { x = 14, y = 33, size = 1, exits = { } }, + { x = 19, y = 33, size = 1, exits = { } }, + { x = 36, y = 33, size = 12, exits = { } }, + { x = 0, y = 34, size = 4, exits = { {k="e",d="south",to="ROUTE_6",r=1}, {k="e",d="west",to="ROUTE_7",r=2} } }, + }, + ["SAFFRON_GYM"] = { + { x = 0, y = 1, size = 26, exits = { {k="w",i=7,x=5,y=3,to="SAFFRON_GYM",r=2}, {k="w",i=17,x=1,y=5,to="SAFFRON_GYM",r=5}, {k="w",i=21,x=5,y=5,to="SAFFRON_GYM",r=4}, {k="w",i=31,x=1,y=3,to="SAFFRON_GYM",r=3} } }, + { x = 7, y = 1, size = 26, exits = { {k="w",i=8,x=11,y=5,to="SAFFRON_GYM",r=7}, {k="w",i=14,x=9,y=3,to="SAFFRON_GYM",r=6}, {k="w",i=22,x=11,y=3,to="SAFFRON_GYM",r=1}, {k="w",i=28,x=9,y=5,to="SAFFRON_GYM",r=4} } }, + { x = 14, y = 1, size = 26, exits = { {k="w",i=10,x=19,y=3,to="SAFFRON_GYM",r=9}, {k="w",i=16,x=15,y=5,to="SAFFRON_GYM",r=1}, {k="w",i=24,x=19,y=5,to="SAFFRON_GYM",r=7}, {k="w",i=30,x=15,y=3,to="SAFFRON_GYM",r=6} } }, + { x = 0, y = 7, size = 26, exits = { {k="w",i=6,x=1,y=11,to="SAFFRON_GYM",r=1}, {k="w",i=13,x=5,y=9,to="SAFFRON_GYM",r=2}, {k="w",i=20,x=1,y=9,to="SAFFRON_GYM",r=6}, {k="w",i=27,x=5,y=11,to="SAFFRON_GYM",r=7} } }, + { x = 7, y = 7, size = 29, exits = { {k="w",i=32,x=11,y=9,to="SAFFRON_GYM",r=1} } }, + { x = 14, y = 7, size = 26, exits = { {k="w",i=5,x=19,y=11,to="SAFFRON_GYM",r=4}, {k="w",i=15,x=15,y=9,to="SAFFRON_GYM",r=3}, {k="w",i=19,x=19,y=9,to="SAFFRON_GYM",r=9}, {k="w",i=29,x=15,y=11,to="SAFFRON_GYM",r=2} } }, + { x = 0, y = 13, size = 26, exits = { {k="w",i=9,x=1,y=15,to="SAFFRON_GYM",r=3}, {k="w",i=12,x=5,y=17,to="SAFFRON_GYM",r=4}, {k="w",i=23,x=1,y=17,to="SAFFRON_GYM",r=2}, {k="w",i=26,x=5,y=15,to="SAFFRON_GYM",r=9} } }, + { x = 7, y = 13, size = 25, exits = { {k="w",i=1,x=8,y=17,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=9,y=17,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=11,y=15,to="SAFFRON_GYM",r=9} } }, + { x = 14, y = 13, size = 26, exits = { {k="w",i=4,x=19,y=15,to="SAFFRON_GYM",r=6}, {k="w",i=11,x=15,y=17,to="SAFFRON_GYM",r=7}, {k="w",i=18,x=19,y=17,to="SAFFRON_GYM",r=8}, {k="w",i=25,x=15,y=15,to="SAFFRON_GYM",r=3} } }, + }, + ["SAFFRON_MAGNET_TRAIN_STATION"] = { + { x = 0, y = 2, size = 40, exits = { } }, + { x = 0, y = 6, size = 126, exits = { {k="w",i=1,x=8,y=17,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=9,y=17,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=6,y=5,to="GOLDENROD_MAGNET_TRAIN_STATION",r=2}, {k="w",i=4,x=11,y=5,to="GOLDENROD_MAGNET_TRAIN_STATION",r=2} } }, + }, + ["SAFFRON_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=3,y=7,to="SAFFRON_CITY",r=3} } }, + }, + ["SAFFRON_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=4,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["SAFFRON_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="SAFFRON_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["SEAFOAM_GYM"] = { + { x = 0, y = 0, size = 39, exits = { } }, + { x = 4, y = 2, size = 15, exits = { {k="w",i=1,x=5,y=5,to="ROUTE_20",r=3}, {k="w",i=1,x=5,y=5,to="ROUTE_20",r=2} } }, + }, + ["SILPH_CO_1F"] = { + { x = 13, y = 0, size = 71, exits = { {k="w",i=1,x=2,y=7,to="SAFFRON_CITY",r=3}, {k="w",i=2,x=3,y=7,to="SAFFRON_CITY",r=3} } }, + { x = 0, y = 1, size = 10, exits = { } }, + }, + ["SILVER_CAVE_ITEM_ROOMS"] = { + { x = 0, y = 0, size = 244, exits = { } }, + { x = 4, y = 2, size = 23, exits = { {k="w",i=1,x=13,y=3,to="SILVER_CAVE_ROOM_2",r=8} } }, + { x = 4, y = 10, size = 43, exits = { {k="w",i=2,x=7,y=15,to="SILVER_CAVE_ROOM_2",r=4} } }, + }, + ["SILVER_CAVE_OUTSIDE"] = { + { x = 0, y = 0, size = 25, exits = { } }, + { x = 6, y = 0, size = 85, exits = { } }, + { x = 12, y = 0, size = 250, exits = { {k="e",d="east",to="ROUTE_28",r=1} } }, + { x = 19, y = 0, size = 95, exits = { {k="e",d="east",to="ROUTE_28",r=1} } }, + { x = 14, y = 8, size = 255, exits = { {k="w",i=2,x=18,y=11,to="SILVER_CAVE_ROOM_1",r=2}, {k="e",d="east",to="ROUTE_28",r=1} } }, + { x = 16, y = 12, size = 139, exits = { {k="w",i=1,x=23,y=19,to="SILVER_CAVE_POKECENTER_1F",r=2}, {k="w",i=2,x=18,y=11,to="SILVER_CAVE_ROOM_1",r=2}, {k="e",d="east",to="ROUTE_28",r=3} } }, + { x = 34, y = 21, size = 7, exits = { {k="e",d="east",to="ROUTE_28",r=2} } }, + { x = 8, y = 24, size = 46, exits = { } }, + { x = 34, y = 24, size = 7, exits = { {k="w",i=1,x=23,y=19,to="SILVER_CAVE_POKECENTER_1F",r=2}, {k="w",i=2,x=18,y=11,to="SILVER_CAVE_ROOM_1",r=2}, {k="e",d="east",to="ROUTE_28",r=3} } }, + { x = 31, y = 28, size = 10, exits = { {k="w",i=1,x=23,y=19,to="SILVER_CAVE_POKECENTER_1F",r=2}, {k="w",i=2,x=18,y=11,to="SILVER_CAVE_ROOM_1",r=2}, {k="e",d="east",to="ROUTE_28",r=3} } }, + { x = 0, y = 32, size = 76, exits = { } }, + }, + ["SILVER_CAVE_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="SILVER_CAVE_OUTSIDE",r=6}, {k="w",i=2,x=4,y=7,to="SILVER_CAVE_OUTSIDE",r=6}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["SILVER_CAVE_ROOM_1"] = { + { x = 0, y = 0, size = 125, exits = { {k="w",i=2,x=15,y=1,to="SILVER_CAVE_ROOM_2",r=5} } }, + { x = 3, y = 2, size = 315, exits = { {k="w",i=1,x=9,y=33,to="SILVER_CAVE_OUTSIDE",r=5}, {k="w",i=1,x=9,y=33,to="SILVER_CAVE_OUTSIDE",r=6}, {k="w",i=2,x=15,y=1,to="SILVER_CAVE_ROOM_2",r=5} } }, + { x = 5, y = 26, size = 6, exits = { } }, + }, + ["SILVER_CAVE_ROOM_2"] = { + { x = 0, y = 0, size = 21, exits = { } }, + { x = 15, y = 0, size = 14, exits = { } }, + { x = 19, y = 0, size = 18, exits = { } }, + { x = 19, y = 4, size = 27, exits = { {k="w",i=4,x=23,y=3,to="SILVER_CAVE_ITEM_ROOMS",r=3} } }, + { x = 9, y = 6, size = 168, exits = { {k="w",i=1,x=17,y=31,to="SILVER_CAVE_ROOM_1",r=1}, {k="w",i=1,x=17,y=31,to="SILVER_CAVE_ROOM_1",r=2}, {k="w",i=2,x=11,y=5,to="SILVER_CAVE_ROOM_3",r=2} } }, + { x = 27, y = 12, size = 50, exits = { } }, + { x = 1, y = 14, size = 14, exits = { } }, + { x = 9, y = 22, size = 23, exits = { {k="w",i=3,x=13,y=21,to="SILVER_CAVE_ITEM_ROOMS",r=2} } }, + { x = 3, y = 24, size = 6, exits = { } }, + { x = 0, y = 26, size = 125, exits = { } }, + }, + ["SILVER_CAVE_ROOM_3"] = { + { x = 3, y = 0, size = 78, exits = { } }, + { x = 7, y = 8, size = 97, exits = { {k="w",i=1,x=9,y=33,to="SILVER_CAVE_ROOM_2",r=5} } }, + { x = 0, y = 28, size = 107, exits = { } }, + }, + ["SLOWPOKE_WELL_B1F"] = { + { x = 0, y = 0, size = 110, exits = { {k="w",i=1,x=17,y=15,to="AZALEA_TOWN",r=2} } }, + { x = 2, y = 2, size = 84, exits = { {k="w",i=1,x=17,y=15,to="AZALEA_TOWN",r=2} } }, + { x = 9, y = 8, size = 24, exits = { {k="w",i=2,x=7,y=11,to="SLOWPOKE_WELL_B2F",r=4} } }, + }, + ["SLOWPOKE_WELL_B2F"] = { + { x = 0, y = 0, size = 102, exits = { } }, + { x = 3, y = 2, size = 21, exits = { } }, + { x = 9, y = 4, size = 25, exits = { } }, + { x = 7, y = 10, size = 12, exits = { {k="w",i=1,x=9,y=11,to="SLOWPOKE_WELL_B1F",r=3} } }, + }, + ["SOUL_HOUSE"] = { + { x = 0, y = 1, size = 36, exits = { {k="w",i=1,x=4,y=7,to="LAVENDER_TOWN",r=2}, {k="w",i=2,x=5,y=7,to="LAVENDER_TOWN",r=2} } }, + { x = 0, y = 7, size = 4, exits = { {k="w",i=1,x=4,y=7,to="LAVENDER_TOWN",r=2} } }, + { x = 6, y = 7, size = 4, exits = { {k="w",i=2,x=5,y=7,to="LAVENDER_TOWN",r=2} } }, + }, + ["SPROUT_TOWER_1F"] = { + { x = 2, y = 1, size = 38, exits = { {k="w",i=4,x=2,y=6,to="SPROUT_TOWER_2F",r=1}, {k="w",i=5,x=17,y=3,to="SPROUT_TOWER_2F",r=2} } }, + { x = 5, y = 3, size = 103, exits = { {k="w",i=1,x=9,y=15,to="VIOLET_CITY",r=3}, {k="w",i=2,x=10,y=15,to="VIOLET_CITY",r=3}, {k="w",i=3,x=6,y=4,to="SPROUT_TOWER_2F",r=2} } }, + { x = 2, y = 9, size = 20, exits = { } }, + { x = 16, y = 9, size = 20, exits = { } }, + }, + ["SPROUT_TOWER_2F"] = { + { x = 2, y = 1, size = 44, exits = { {k="w",i=2,x=2,y=6,to="SPROUT_TOWER_1F",r=1}, {k="w",i=4,x=10,y=14,to="SPROUT_TOWER_3F",r=1} } }, + { x = 5, y = 1, size = 62, exits = { {k="w",i=1,x=6,y=4,to="SPROUT_TOWER_1F",r=2}, {k="w",i=3,x=17,y=3,to="SPROUT_TOWER_1F",r=1} } }, + { x = 8, y = 6, size = 10, exits = { } }, + { x = 5, y = 9, size = 24, exits = { } }, + { x = 16, y = 9, size = 20, exits = { } }, + }, + ["SPROUT_TOWER_3F"] = { + { x = 5, y = 1, size = 89, exits = { {k="w",i=1,x=10,y=14,to="SPROUT_TOWER_2F",r=1} } }, + { x = 4, y = 7, size = 10, exits = { } }, + { x = 13, y = 7, size = 10, exits = { } }, + { x = 4, y = 14, size = 2, exits = { } }, + { x = 15, y = 14, size = 2, exits = { } }, + }, + ["TEAM_ROCKET_BASE_B1F"] = { + { x = 1, y = 1, size = 324, exits = { {k="w",i=1,x=27,y=2,to="MAHOGANY_MART_1F",r=1}, {k="w",i=2,x=3,y=14,to="TEAM_ROCKET_BASE_B2F",r=4}, {k="w",i=3,x=5,y=15,to="TEAM_ROCKET_BASE_B1F",r=1} } }, + }, + ["TEAM_ROCKET_BASE_B2F"] = { + { x = 1, y = 1, size = 66, exits = { {k="w",i=2,x=3,y=2,to="TEAM_ROCKET_BASE_B3F",r=1}, {k="w",i=3,x=27,y=2,to="TEAM_ROCKET_BASE_B3F",r=3} } }, + { x = 7, y = 3, size = 84, exits = { } }, + { x = 1, y = 5, size = 30, exits = { {k="w",i=4,x=3,y=6,to="TEAM_ROCKET_BASE_B3F",r=1} } }, + { x = 24, y = 11, size = 103, exits = { {k="w",i=1,x=3,y=14,to="TEAM_ROCKET_BASE_B1F",r=1}, {k="w",i=5,x=27,y=14,to="TEAM_ROCKET_BASE_B3F",r=3} } }, + }, + ["TEAM_ROCKET_BASE_B3F"] = { + { x = 1, y = 1, size = 57, exits = { {k="w",i=1,x=3,y=2,to="TEAM_ROCKET_BASE_B2F",r=1}, {k="w",i=3,x=3,y=6,to="TEAM_ROCKET_BASE_B2F",r=3} } }, + { x = 7, y = 1, size = 46, exits = { } }, + { x = 16, y = 1, size = 214, exits = { {k="w",i=2,x=27,y=2,to="TEAM_ROCKET_BASE_B2F",r=1}, {k="w",i=4,x=27,y=14,to="TEAM_ROCKET_BASE_B2F",r=4} } }, + }, + ["TIME_CAPSULE"] = { + { x = 0, y = 1, size = 66, exits = { {k="w",i=1,x=4,y=7,to="POKECENTER_2F",r=2}, {k="w",i=2,x=5,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["TIN_TOWER_1F"] = { + { x = 3, y = 1, size = 117, exits = { {k="w",i=1,x=9,y=15,to="ECRUTEAK_CITY",r=4}, {k="w",i=2,x=10,y=15,to="ECRUTEAK_CITY",r=4}, {k="w",i=3,x=10,y=2,to="TIN_TOWER_2F",r=1} } }, + { x = 7, y = 5, size = 24, exits = { } }, + { x = 2, y = 7, size = 1, exits = { } }, + { x = 17, y = 7, size = 1, exits = { } }, + { x = 2, y = 11, size = 1, exits = { } }, + { x = 17, y = 11, size = 1, exits = { } }, + { x = 2, y = 15, size = 5, exits = { } }, + { x = 13, y = 15, size = 5, exits = { } }, + }, + ["TIN_TOWER_2F"] = { + { x = 5, y = 1, size = 98, exits = { {k="w",i=1,x=10,y=14,to="TIN_TOWER_3F",r=1}, {k="w",i=2,x=10,y=2,to="TIN_TOWER_1F",r=1} } }, + }, + ["TIN_TOWER_3F"] = { + { x = 3, y = 1, size = 120, exits = { {k="w",i=1,x=10,y=14,to="TIN_TOWER_2F",r=1}, {k="w",i=2,x=16,y=2,to="TIN_TOWER_4F",r=1} } }, + }, + ["TIN_TOWER_4F"] = { + { x = 3, y = 1, size = 90, exits = { {k="w",i=1,x=2,y=4,to="TIN_TOWER_5F",r=1}, {k="w",i=2,x=16,y=2,to="TIN_TOWER_3F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_5F",r=29}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_5F",r=33} } }, + }, + ["TIN_TOWER_5F"] = { + { x = 2, y = 0, size = 23, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=2,x=2,y=4,to="TIN_TOWER_4F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 12, y = 0, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 14, y = 0, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 16, y = 0, size = 5, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 13, y = 2, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 15, y = 2, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 5, y = 4, size = 2, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 8, y = 4, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 11, y = 4, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 13, y = 4, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 17, y = 4, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 5, y = 6, size = 1, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 8, y = 6, size = 15, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 17, y = 6, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 3, y = 8, size = 1, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 5, y = 8, size = 2, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 15, y = 8, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 17, y = 8, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 3, y = 10, size = 1, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 5, y = 10, size = 2, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1}, {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 12, y = 10, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 14, y = 10, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 16, y = 10, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 3, y = 12, size = 1, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 6, y = 12, size = 1, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 8, y = 12, size = 1, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 10, y = 12, size = 1, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 16, y = 12, size = 1, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + { x = 3, y = 14, size = 3, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 5, y = 14, size = 1, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 7, y = 14, size = 1, exits = { {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 9, y = 14, size = 4, exits = { {k="w",i=1,x=11,y=15,to="TIN_TOWER_6F",r=1}, {k="w",i=3,x=2,y=14,to="TIN_TOWER_4F",r=1} } }, + { x = 16, y = 14, size = 3, exits = { {k="w",i=4,x=17,y=15,to="TIN_TOWER_4F",r=1} } }, + }, + ["TIN_TOWER_6F"] = { + { x = 2, y = 1, size = 72, exits = { {k="w",i=1,x=3,y=9,to="TIN_TOWER_7F",r=1}, {k="w",i=2,x=11,y=15,to="TIN_TOWER_5F",r=32} } }, + }, + ["TIN_TOWER_7F"] = { + { x = 8, y = 0, size = 85, exits = { {k="w",i=1,x=3,y=9,to="TIN_TOWER_6F",r=1}, {k="w",i=2,x=10,y=15,to="TIN_TOWER_8F",r=2}, {k="w",i=4,x=8,y=3,to="TIN_TOWER_7F",r=2} } }, + { x = 6, y = 6, size = 24, exits = { {k="w",i=3,x=12,y=7,to="TIN_TOWER_7F",r=1}, {k="w",i=5,x=6,y=9,to="TIN_TOWER_9F",r=3} } }, + }, + ["TIN_TOWER_8F"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=3,x=16,y=7,to="TIN_TOWER_9F",r=1}, {k="w",i=4,x=10,y=3,to="TIN_TOWER_9F",r=2} } }, + { x = 2, y = 4, size = 16, exits = { {k="w",i=1,x=2,y=5,to="TIN_TOWER_7F",r=1}, {k="w",i=2,x=2,y=11,to="TIN_TOWER_9F",r=1} } }, + { x = 8, y = 6, size = 13, exits = { {k="w",i=6,x=6,y=9,to="TIN_TOWER_9F",r=3} } }, + { x = 8, y = 12, size = 28, exits = { {k="w",i=5,x=14,y=15,to="TIN_TOWER_9F",r=3} } }, + }, + ["TIN_TOWER_9F"] = { + { x = 5, y = 1, size = 38, exits = { {k="w",i=1,x=12,y=3,to="TIN_TOWER_8F",r=2}, {k="w",i=2,x=2,y=5,to="TIN_TOWER_8F",r=1} } }, + { x = 8, y = 6, size = 16, exits = { {k="w",i=3,x=12,y=7,to="TIN_TOWER_8F",r=1}, {k="w",i=4,x=7,y=9,to="TIN_TOWER_ROOF",r=1} } }, + { x = 16, y = 6, size = 33, exits = { {k="w",i=5,x=16,y=7,to="TIN_TOWER_7F",r=2}, {k="w",i=6,x=6,y=13,to="TIN_TOWER_8F",r=4}, {k="w",i=7,x=8,y=13,to="TIN_TOWER_8F",r=3} } }, + }, + ["TIN_TOWER_ROOF"] = { + { x = 8, y = 5, size = 17, exits = { {k="w",i=1,x=9,y=13,to="TIN_TOWER_9F",r=2} } }, + }, + ["TOHJO_FALLS"] = { + { x = 0, y = 0, size = 98, exits = { } }, + { x = 3, y = 0, size = 52, exits = { } }, + { x = 21, y = 0, size = 13, exits = { } }, + { x = 2, y = 6, size = 8, exits = { } }, + { x = 13, y = 6, size = 12, exits = { } }, + { x = 10, y = 14, size = 7, exits = { {k="w",i=1,x=13,y=15,to="ROUTE_27",r=1}, {k="w",i=1,x=13,y=15,to="ROUTE_27",r=4} } }, + { x = 22, y = 14, size = 11, exits = { {k="w",i=2,x=25,y=15,to="ROUTE_27",r=1}, {k="w",i=2,x=25,y=15,to="ROUTE_27",r=5} } }, + }, + ["TRADE_CENTER"] = { + { x = 0, y = 1, size = 66, exits = { {k="w",i=1,x=4,y=7,to="POKECENTER_2F",r=2}, {k="w",i=2,x=5,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["TRAINER_HOUSE_1F"] = { + { x = 0, y = 1, size = 92, exits = { {k="w",i=1,x=2,y=13,to="VIRIDIAN_CITY",r=2}, {k="w",i=2,x=3,y=13,to="VIRIDIAN_CITY",r=2}, {k="w",i=3,x=8,y=2,to="TRAINER_HOUSE_B1F",r=1} } }, + { x = 0, y = 11, size = 3, exits = { } }, + }, + ["TRAINER_HOUSE_B1F"] = { + { x = 2, y = 1, size = 97, exits = { {k="w",i=1,x=9,y=4,to="TRAINER_HOUSE_1F",r=1} } }, + { x = 7, y = 1, size = 3, exits = { } }, + }, + ["UNDERGROUND_PATH"] = { + { x = 1, y = 1, size = 102, exits = { {k="w",i=1,x=3,y=2,to="ROUTE_5_UNDERGROUND_PATH_ENTRANCE",r=1}, {k="w",i=2,x=3,y=24,to="ROUTE_6_UNDERGROUND_PATH_ENTRANCE",r=1} } }, + }, + ["UNION_CAVE_1F"] = { + { x = 0, y = 0, size = 256, exits = { {k="w",i=2,x=3,y=33,to="UNION_CAVE_B1F",r=5}, {k="w",i=2,x=3,y=33,to="UNION_CAVE_B1F",r=1} } }, + { x = 4, y = 2, size = 179, exits = { {k="w",i=1,x=5,y=19,to="UNION_CAVE_B1F",r=4}, {k="w",i=3,x=17,y=31,to="ROUTE_33",r=1}, {k="w",i=3,x=17,y=31,to="ROUTE_33",r=2}, {k="w",i=4,x=17,y=3,to="ROUTE_32",r=8}, {k="w",i=4,x=17,y=3,to="ROUTE_32",r=1} } }, + { x = 2, y = 32, size = 3, exits = { {k="w",i=2,x=3,y=33,to="UNION_CAVE_B1F",r=5}, {k="w",i=2,x=3,y=33,to="UNION_CAVE_B1F",r=1} } }, + }, + ["UNION_CAVE_B1F"] = { + { x = 0, y = 0, size = 330, exits = { {k="w",i=4,x=3,y=33,to="UNION_CAVE_1F",r=3}, {k="w",i=4,x=3,y=33,to="UNION_CAVE_1F",r=1}, {k="w",i=5,x=17,y=31,to="UNION_CAVE_B2F",r=2} } }, + { x = 2, y = 2, size = 70, exits = { {k="w",i=1,x=3,y=3,to="RUINS_OF_ALPH_OUTSIDE",r=4}, {k="w",i=1,x=3,y=3,to="RUINS_OF_ALPH_OUTSIDE",r=5}, {k="w",i=2,x=3,y=11,to="RUINS_OF_ALPH_OUTSIDE",r=7}, {k="w",i=2,x=3,y=11,to="RUINS_OF_ALPH_OUTSIDE",r=9} } }, + { x = 13, y = 6, size = 6, exits = { } }, + { x = 2, y = 16, size = 46, exits = { {k="w",i=3,x=7,y=19,to="UNION_CAVE_1F",r=2} } }, + { x = 2, y = 30, size = 11, exits = { {k="w",i=4,x=3,y=33,to="UNION_CAVE_1F",r=3}, {k="w",i=4,x=3,y=33,to="UNION_CAVE_1F",r=1} } }, + { x = 14, y = 30, size = 7, exits = { {k="w",i=5,x=17,y=31,to="UNION_CAVE_B2F",r=2} } }, + }, + ["UNION_CAVE_B2F"] = { + { x = 0, y = 0, size = 166, exits = { } }, + { x = 4, y = 2, size = 20, exits = { {k="w",i=1,x=5,y=3,to="UNION_CAVE_B1F",r=6}, {k="w",i=1,x=5,y=3,to="UNION_CAVE_B1F",r=1} } }, + { x = 16, y = 2, size = 12, exits = { } }, + { x = 3, y = 10, size = 51, exits = { } }, + { x = 13, y = 12, size = 12, exits = { } }, + { x = 14, y = 16, size = 6, exits = { } }, + { x = 5, y = 18, size = 34, exits = { } }, + { x = 11, y = 18, size = 30, exits = { } }, + { x = 13, y = 24, size = 12, exits = { } }, + }, + ["VERMILION_CITY"] = { + { x = 2, y = 0, size = 17, exits = { {k="e",d="north",to="ROUTE_6",r=2} } }, + { x = 16, y = 0, size = 330, exits = { {k="w",i=1,x=5,y=5,to="VERMILION_FISHING_SPEECH_HOUSE",r=1}, {k="w",i=2,x=9,y=5,to="VERMILION_POKECENTER_1F",r=2}, {k="w",i=3,x=7,y=13,to="POKEMON_FAN_CLUB",r=1}, {k="w",i=4,x=13,y=13,to="VERMILION_MAGNET_TRAIN_SPEECH_HOUSE",r=1}, {k="w",i=5,x=21,y=13,to="VERMILION_MART",r=2}, {k="w",i=6,x=21,y=17,to="VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE",r=1}, {k="w",i=8,x=19,y=31,to="VERMILION_PORT_PASSAGE",r=2}, {k="w",i=9,x=20,y=31,to="VERMILION_PORT_PASSAGE",r=2}, {k="w",i=10,x=34,y=7,to="DIGLETTS_CAVE",r=4}, {k="e",d="east",to="ROUTE_11",r=1}, {k="e",d="north",to="ROUTE_6",r=1} } }, + { x = 19, y = 0, size = 3, exits = { {k="e",d="north",to="ROUTE_6",r=3} } }, + { x = 22, y = 2, size = 12, exits = { } }, + { x = 33, y = 4, size = 12, exits = { {k="w",i=10,x=34,y=7,to="DIGLETTS_CAVE",r=4} } }, + { x = 13, y = 19, size = 41, exits = { {k="w",i=7,x=10,y=19,to="VERMILION_GYM",r=1} } }, + }, + ["VERMILION_DIGLETTS_CAVE_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VERMILION_CITY",r=2} } }, + }, + ["VERMILION_FISHING_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VERMILION_CITY",r=2} } }, + }, + ["VERMILION_GYM"] = { + { x = 2, y = 1, size = 121, exits = { {k="w",i=1,x=4,y=17,to="VERMILION_CITY",r=6}, {k="w",i=2,x=5,y=17,to="VERMILION_CITY",r=6} } }, + }, + ["VERMILION_MAGNET_TRAIN_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VERMILION_CITY",r=2} } }, + }, + ["VERMILION_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VERMILION_CITY",r=2} } }, + }, + ["VERMILION_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="VERMILION_CITY",r=2}, {k="w",i=2,x=4,y=7,to="VERMILION_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["VERMILION_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="VERMILION_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["VERMILION_PORT"] = { + { x = 8, y = 5, size = 34, exits = { {k="w",i=1,x=9,y=5,to="VERMILION_PORT_PASSAGE",r=1}, {k="w",i=2,x=7,y=17,to="FAST_SHIP_1F",r=1} } }, + { x = 7, y = 18, size = 1, exits = { {k="w",i=2,x=7,y=17,to="FAST_SHIP_1F",r=1} } }, + }, + ["VERMILION_PORT_PASSAGE"] = { + { x = 1, y = 1, size = 58, exits = { {k="w",i=4,x=3,y=2,to="VERMILION_PORT_PASSAGE",r=2}, {k="w",i=5,x=3,y=14,to="VERMILION_PORT",r=1} } }, + { x = 13, y = 1, size = 25, exits = { {k="w",i=1,x=15,y=0,to="VERMILION_CITY",r=2}, {k="w",i=2,x=16,y=0,to="VERMILION_CITY",r=2}, {k="w",i=3,x=15,y=4,to="VERMILION_PORT_PASSAGE",r=1} } }, + }, + ["VICTORY_ROAD"] = { + { x = 0, y = 0, size = 100, exits = { {k="w",i=10,x=13,y=5,to="ROUTE_23",r=3}, {k="w",i=10,x=13,y=5,to="ROUTE_23",r=4} } }, + { x = 11, y = 6, size = 149, exits = { {k="w",i=5,x=13,y=17,to="VICTORY_ROAD",r=5}, {k="w",i=7,x=17,y=19,to="VICTORY_ROAD",r=6}, {k="w",i=8,x=0,y=11,to="VICTORY_ROAD",r=4}, {k="w",i=10,x=13,y=5,to="ROUTE_23",r=3}, {k="w",i=10,x=13,y=5,to="ROUTE_23",r=4} } }, + { x = 16, y = 18, size = 17, exits = { {k="w",i=7,x=17,y=19,to="VICTORY_ROAD",r=6} } }, + { x = 1, y = 26, size = 31, exits = { {k="w",i=3,x=1,y=35,to="VICTORY_ROAD",r=7}, {k="w",i=4,x=13,y=31,to="VICTORY_ROAD",r=2} } }, + { x = 10, y = 28, size = 151, exits = { {k="w",i=3,x=1,y=35,to="VICTORY_ROAD",r=7}, {k="w",i=4,x=13,y=31,to="VICTORY_ROAD",r=2} } }, + { x = 18, y = 29, size = 17, exits = { {k="w",i=3,x=1,y=35,to="VICTORY_ROAD",r=7}, {k="w",i=4,x=13,y=31,to="VICTORY_ROAD",r=2}, {k="w",i=6,x=17,y=33,to="VICTORY_ROAD",r=3} } }, + { x = 0, y = 48, size = 206, exits = { {k="w",i=1,x=9,y=67,to="VICTORY_ROAD_GATE",r=1}, {k="w",i=2,x=1,y=49,to="VICTORY_ROAD",r=5} } }, + { x = 19, y = 62, size = 75, exits = { } }, + }, + ["VICTORY_ROAD_GATE"] = { + { x = 8, y = 1, size = 85, exits = { {k="w",i=1,x=17,y=7,to="ROUTE_22",r=2}, {k="w",i=2,x=18,y=7,to="ROUTE_22",r=2}, {k="w",i=3,x=9,y=17,to="ROUTE_26",r=2}, {k="w",i=4,x=10,y=17,to="ROUTE_26",r=2}, {k="w",i=5,x=9,y=0,to="VICTORY_ROAD",r=7}, {k="w",i=6,x=10,y=0,to="VICTORY_ROAD",r=7}, {k="w",i=7,x=1,y=7,to="ROUTE_28",r=3}, {k="w",i=8,x=2,y=7,to="ROUTE_28",r=3} } }, + { x = 8, y = 11, size = 2, exits = { } }, + }, + ["VIOLET_CITY"] = { + { x = 4, y = 0, size = 16, exits = { } }, + { x = 34, y = 2, size = 28, exits = { } }, + { x = 23, y = 6, size = 341, exits = { {k="w",i=1,x=9,y=17,to="VIOLET_MART",r=2}, {k="w",i=2,x=18,y=17,to="VIOLET_GYM",r=1}, {k="w",i=3,x=30,y=17,to="EARLS_POKEMON_ACADEMY",r=1}, {k="w",i=4,x=3,y=15,to="VIOLET_NICKNAME_SPEECH_HOUSE",r=1}, {k="w",i=5,x=31,y=25,to="VIOLET_POKECENTER_1F",r=2}, {k="w",i=6,x=21,y=29,to="VIOLET_KYLES_HOUSE",r=1}, {k="w",i=7,x=23,y=5,to="SPROUT_TOWER_1F",r=2}, {k="w",i=8,x=39,y=24,to="ROUTE_31_VIOLET_GATE",r=2}, {k="w",i=9,x=39,y=25,to="ROUTE_31_VIOLET_GATE",r=2}, {k="e",d="south",to="ROUTE_32",r=1}, {k="e",d="west",to="ROUTE_36",r=1} } }, + { x = 37, y = 14, size = 6, exits = { } }, + { x = 0, y = 22, size = 9, exits = { } }, + { x = 0, y = 26, size = 27, exits = { } }, + }, + ["VIOLET_GYM"] = { + { x = 0, y = 1, size = 88, exits = { {k="w",i=1,x=4,y=15,to="VIOLET_CITY",r=3}, {k="w",i=2,x=5,y=15,to="VIOLET_CITY",r=3} } }, + }, + ["VIOLET_KYLES_HOUSE"] = { + { x = 3, y = 1, size = 45, exits = { {k="w",i=1,x=3,y=7,to="VIOLET_CITY",r=3}, {k="w",i=2,x=4,y=7,to="VIOLET_CITY",r=3} } }, + }, + ["VIOLET_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="VIOLET_CITY",r=3}, {k="w",i=2,x=3,y=7,to="VIOLET_CITY",r=3} } }, + }, + ["VIOLET_NICKNAME_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 45, exits = { {k="w",i=1,x=3,y=7,to="VIOLET_CITY",r=3}, {k="w",i=2,x=4,y=7,to="VIOLET_CITY",r=3} } }, + }, + ["VIOLET_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="VIOLET_CITY",r=3}, {k="w",i=2,x=4,y=7,to="VIOLET_CITY",r=3}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["VIRIDIAN_CITY"] = { + { x = 0, y = 0, size = 65, exits = { {k="e",d="west",to="ROUTE_22",r=1} } }, + { x = 17, y = 0, size = 596, exits = { {k="w",i=1,x=32,y=7,to="VIRIDIAN_GYM",r=1}, {k="w",i=2,x=21,y=9,to="VIRIDIAN_NICKNAME_SPEECH_HOUSE",r=1}, {k="w",i=3,x=23,y=15,to="TRAINER_HOUSE_1F",r=1}, {k="w",i=4,x=29,y=19,to="VIRIDIAN_MART",r=2}, {k="w",i=5,x=23,y=25,to="VIRIDIAN_POKECENTER_1F",r=2}, {k="e",d="north",to="ROUTE_2",r=1}, {k="e",d="south",to="ROUTE_1",r=3}, {k="e",d="west",to="ROUTE_22",r=2} } }, + { x = 0, y = 18, size = 39, exits = { {k="e",d="west",to="ROUTE_22",r=1} } }, + { x = 4, y = 22, size = 20, exits = { {k="w",i=1,x=32,y=7,to="VIRIDIAN_GYM",r=1}, {k="w",i=2,x=21,y=9,to="VIRIDIAN_NICKNAME_SPEECH_HOUSE",r=1}, {k="w",i=3,x=23,y=15,to="TRAINER_HOUSE_1F",r=1}, {k="w",i=4,x=29,y=19,to="VIRIDIAN_MART",r=2}, {k="w",i=5,x=23,y=25,to="VIRIDIAN_POKECENTER_1F",r=2}, {k="e",d="north",to="ROUTE_2",r=1}, {k="e",d="south",to="ROUTE_1",r=3}, {k="e",d="west",to="ROUTE_22",r=2} } }, + { x = 0, y = 32, size = 84, exits = { {k="e",d="south",to="ROUTE_1",r=1}, {k="e",d="west",to="ROUTE_22",r=1} } }, + { x = 22, y = 32, size = 20, exits = { {k="e",d="south",to="ROUTE_1",r=2} } }, + { x = 31, y = 32, size = 20, exits = { {k="e",d="south",to="ROUTE_1",r=4} } }, + { x = 37, y = 32, size = 12, exits = { {k="e",d="south",to="ROUTE_1",r=5} } }, + }, + ["VIRIDIAN_GYM"] = { + { x = 2, y = 2, size = 82, exits = { {k="w",i=1,x=4,y=17,to="VIRIDIAN_CITY",r=2}, {k="w",i=2,x=5,y=17,to="VIRIDIAN_CITY",r=2} } }, + }, + ["VIRIDIAN_MART"] = { + { x = 0, y = 2, size = 4, exits = { } }, + { x = 3, y = 2, size = 43, exits = { {k="w",i=1,x=2,y=7,to="VIRIDIAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VIRIDIAN_CITY",r=2} } }, + }, + ["VIRIDIAN_NICKNAME_SPEECH_HOUSE"] = { + { x = 3, y = 1, size = 42, exits = { {k="w",i=1,x=2,y=7,to="VIRIDIAN_CITY",r=2}, {k="w",i=2,x=3,y=7,to="VIRIDIAN_CITY",r=2} } }, + }, + ["VIRIDIAN_POKECENTER_1F"] = { + { x = 3, y = 1, size = 2, exits = { } }, + { x = 6, y = 1, size = 50, exits = { {k="w",i=1,x=3,y=7,to="VIRIDIAN_CITY",r=2}, {k="w",i=2,x=4,y=7,to="VIRIDIAN_CITY",r=2}, {k="w",i=3,x=0,y=7,to="POKECENTER_2F",r=2} } }, + }, + ["VIRIDIAN_POKECENTER_2F_BETA"] = { + { x = 5, y = 0, size = 78, exits = { {k="w",i=1,x=0,y=7,to="VIRIDIAN_POKECENTER_1F",r=2} } }, + { x = 0, y = 1, size = 3, exits = { } }, + }, + ["WHIRL_ISLAND_B1F"] = { + { x = 0, y = 0, size = 318, exits = { } }, + { x = 5, y = 2, size = 295, exits = { {k="w",i=1,x=5,y=5,to="WHIRL_ISLAND_NW",r=2}, {k="w",i=3,x=29,y=9,to="WHIRL_ISLAND_NE",r=5}, {k="w",i=4,x=9,y=31,to="WHIRL_ISLAND_SW",r=2}, {k="w",i=5,x=23,y=31,to="WHIRL_ISLAND_SW",r=3}, {k="w",i=6,x=31,y=29,to="WHIRL_ISLAND_SE",r=1}, {k="w",i=8,x=13,y=27,to="WHIRL_ISLAND_B2F",r=3} } }, + { x = 33, y = 2, size = 90, exits = { {k="w",i=2,x=35,y=3,to="WHIRL_ISLAND_NE",r=2}, {k="w",i=7,x=25,y=21,to="WHIRL_ISLAND_B2F",r=1} } }, + { x = 25, y = 4, size = 6, exits = { } }, + { x = 31, y = 6, size = 10, exits = { } }, + { x = 23, y = 10, size = 12, exits = { } }, + { x = 15, y = 18, size = 12, exits = { {k="w",i=9,x=17,y=21,to="WHIRL_ISLAND_CAVE",r=2} } }, + { x = 36, y = 18, size = 181, exits = { {k="w",i=4,x=9,y=31,to="WHIRL_ISLAND_SW",r=2}, {k="w",i=5,x=23,y=31,to="WHIRL_ISLAND_SW",r=3}, {k="w",i=6,x=31,y=29,to="WHIRL_ISLAND_SE",r=1} } }, + { x = 16, y = 22, size = 3, exits = { {k="w",i=4,x=9,y=31,to="WHIRL_ISLAND_SW",r=2}, {k="w",i=5,x=23,y=31,to="WHIRL_ISLAND_SW",r=3}, {k="w",i=6,x=31,y=29,to="WHIRL_ISLAND_SE",r=1}, {k="w",i=9,x=17,y=21,to="WHIRL_ISLAND_CAVE",r=2} } }, + }, + ["WHIRL_ISLAND_B2F"] = { + { x = 6, y = 4, size = 11, exits = { {k="w",i=1,x=11,y=5,to="WHIRL_ISLAND_B1F",r=3} } }, + { x = 0, y = 10, size = 9, exits = { } }, + { x = 5, y = 10, size = 17, exits = { {k="w",i=2,x=7,y=11,to="WHIRL_ISLAND_B1F",r=2} } }, + { x = 15, y = 10, size = 15, exits = { } }, + { x = 6, y = 26, size = 8, exits = { {k="w",i=3,x=7,y=25,to="WHIRL_ISLAND_LUGIA_CHAMBER",r=3} } }, + { x = 17, y = 26, size = 6, exits = { } }, + { x = 1, y = 28, size = 6, exits = { } }, + { x = 10, y = 30, size = 7, exits = { {k="w",i=4,x=13,y=31,to="WHIRL_ISLAND_SW",r=5}, {k="w",i=4,x=13,y=31,to="WHIRL_ISLAND_SW",r=1} } }, + { x = 15, y = 30, size = 24, exits = { {k="w",i=4,x=13,y=31,to="WHIRL_ISLAND_SW",r=5}, {k="w",i=4,x=13,y=31,to="WHIRL_ISLAND_SW",r=1} } }, + }, + ["WHIRL_ISLAND_CAVE"] = { + { x = 0, y = 0, size = 111, exits = { {k="w",i=2,x=3,y=13,to="WHIRL_ISLAND_NW",r=3}, {k="w",i=2,x=3,y=13,to="WHIRL_ISLAND_NW",r=1} } }, + { x = 6, y = 3, size = 25, exits = { {k="w",i=1,x=7,y=5,to="WHIRL_ISLAND_B1F",r=7}, {k="w",i=1,x=7,y=5,to="WHIRL_ISLAND_B1F",r=9}, {k="w",i=2,x=3,y=13,to="WHIRL_ISLAND_NW",r=3}, {k="w",i=2,x=3,y=13,to="WHIRL_ISLAND_NW",r=1} } }, + }, + ["WHIRL_ISLAND_LUGIA_CHAMBER"] = { + { x = 3, y = 10, size = 6, exits = { } }, + { x = 15, y = 10, size = 6, exits = { } }, + { x = 8, y = 12, size = 7, exits = { {k="w",i=1,x=9,y=13,to="WHIRL_ISLAND_B2F",r=5} } }, + { x = 5, y = 14, size = 59, exits = { } }, + }, + ["WHIRL_ISLAND_NE"] = { + { x = 0, y = 0, size = 173, exits = { } }, + { x = 16, y = 2, size = 15, exits = { {k="w",i=2,x=17,y=3,to="WHIRL_ISLAND_B1F",r=3} } }, + { x = 10, y = 7, size = 37, exits = { {k="w",i=2,x=17,y=3,to="WHIRL_ISLAND_B1F",r=3}, {k="w",i=3,x=13,y=11,to="WHIRL_ISLAND_B1F",r=2} } }, + { x = 2, y = 8, size = 14, exits = { {k="w",i=1,x=3,y=13,to="ROUTE_41",r=5}, {k="w",i=1,x=3,y=13,to="ROUTE_41",r=6}, {k="w",i=2,x=17,y=3,to="WHIRL_ISLAND_B1F",r=3}, {k="w",i=3,x=13,y=11,to="WHIRL_ISLAND_B1F",r=2} } }, + { x = 11, y = 10, size = 22, exits = { {k="w",i=3,x=13,y=11,to="WHIRL_ISLAND_B1F",r=2} } }, + }, + ["WHIRL_ISLAND_NW"] = { + { x = 0, y = 0, size = 107, exits = { {k="w",i=3,x=3,y=15,to="WHIRL_ISLAND_SW",r=4}, {k="w",i=3,x=3,y=15,to="WHIRL_ISLAND_SW",r=1}, {k="w",i=4,x=7,y=15,to="WHIRL_ISLAND_CAVE",r=2}, {k="w",i=4,x=7,y=15,to="WHIRL_ISLAND_CAVE",r=1} } }, + { x = 4, y = 2, size = 14, exits = { {k="w",i=1,x=5,y=7,to="ROUTE_41",r=3}, {k="w",i=1,x=5,y=7,to="ROUTE_41",r=4}, {k="w",i=2,x=5,y=3,to="WHIRL_ISLAND_B1F",r=2} } }, + { x = 2, y = 14, size = 10, exits = { {k="w",i=3,x=3,y=15,to="WHIRL_ISLAND_SW",r=4}, {k="w",i=3,x=3,y=15,to="WHIRL_ISLAND_SW",r=1}, {k="w",i=4,x=7,y=15,to="WHIRL_ISLAND_CAVE",r=2}, {k="w",i=4,x=7,y=15,to="WHIRL_ISLAND_CAVE",r=1} } }, + }, + ["WHIRL_ISLAND_SE"] = { + { x = 3, y = 2, size = 27, exits = { {k="w",i=1,x=5,y=13,to="ROUTE_41",r=12}, {k="w",i=1,x=5,y=13,to="ROUTE_41",r=13}, {k="w",i=2,x=5,y=3,to="WHIRL_ISLAND_B1F",r=8} } }, + { x = 1, y = 10, size = 47, exits = { } }, + }, + ["WHIRL_ISLAND_SW"] = { + { x = 0, y = 0, size = 211, exits = { {k="w",i=4,x=3,y=15,to="WHIRL_ISLAND_NW",r=3}, {k="w",i=4,x=3,y=15,to="WHIRL_ISLAND_NW",r=1}, {k="w",i=5,x=17,y=15,to="WHIRL_ISLAND_B2F",r=8}, {k="w",i=5,x=17,y=15,to="WHIRL_ISLAND_B2F",r=9} } }, + { x = 2, y = 2, size = 19, exits = { {k="w",i=1,x=5,y=7,to="ROUTE_41",r=9}, {k="w",i=1,x=5,y=7,to="ROUTE_41",r=10}, {k="w",i=3,x=3,y=3,to="WHIRL_ISLAND_B1F",r=8} } }, + { x = 15, y = 2, size = 8, exits = { {k="w",i=2,x=17,y=3,to="WHIRL_ISLAND_B1F",r=8} } }, + { x = 2, y = 14, size = 7, exits = { {k="w",i=4,x=3,y=15,to="WHIRL_ISLAND_NW",r=3}, {k="w",i=4,x=3,y=15,to="WHIRL_ISLAND_NW",r=1} } }, + { x = 14, y = 14, size = 7, exits = { {k="w",i=5,x=17,y=15,to="WHIRL_ISLAND_B2F",r=8}, {k="w",i=5,x=17,y=15,to="WHIRL_ISLAND_B2F",r=9} } }, + }, + ["WILLS_ROOM"] = { + { x = 0, y = 3, size = 65, exits = { {k="w",i=1,x=5,y=17,to="INDIGO_PLATEAU_POKECENTER_1F",r=1} } }, + { x = 0, y = 16, size = 4, exits = { } }, + { x = 8, y = 16, size = 4, exits = { } }, + }, +} diff --git a/tests/drivers/gold/route.lua b/tests/drivers/gold/route.lua new file mode 100644 index 00000000..5015d3ba --- /dev/null +++ b/tests/drivers/gold/route.lua @@ -0,0 +1,1263 @@ +-- Gold route: New Bark Town through the Olivine Gym (asm-walk sections 00-09). +-- +-- Hand-authored from docs/gold-walkthrough/asm-walk/section-NN-*.md. Every row +-- carries the section and checklist step it came from in `id`, so a stall names +-- a paragraph a human can go read. +-- +-- Why this is not generated from the markdown. The plan was a converter over +-- each section's "## 4. Bot checklist" table, on the botconv principle that the +-- docs stay the source of truth. Seven of these ten sections write that +-- checklist as free prose ("walk west along y=11..13", "grind to the +-- walkthrough's level 8", "avoid the three trainers") rather than the template's +-- six-column table, and the Intent column has no controlled vocabulary anywhere +-- -- so a parser would have been a prose-guesser wearing a converter's clothes, +-- and normalising 300 rows of prose into tables by hand is strictly more work +-- than writing the Lua those tables would have produced. The anti-drift job the +-- converter was for is done better by tests/gold_route_validate_test.lua, which +-- checks every coordinate below against the extracted cache: a warp that is not +-- at (6,3), an object that moved, a map that does not exist and an EVENT_* that +-- is not a real flag name all fail in seconds, which is more than a markdown +-- parser could ever have caught. +-- +-- Scope: the REQUIRED spine only. Optional pickups, phone numbers, the Ruins of +-- Alph, the Bug Contest and the day-of-week NPCs are left out of this first +-- pass; each one is a place the bot can stall for no progression benefit. Rows +-- marked `optional = true` may fail without failing the run. +-- +-- Ops: +-- travel be on this map, however the map graph gets there +-- walk stand on this cell (trip-wires and coord events) +-- warp walk onto this warp cell, expect to arrive on `to` +-- edge cross this map connection, expect to arrive on `to` +-- talk stand adjacent, face it, press A, answer `answers` in order +-- battle as `talk`/`walk`, then fight whatever starts +-- grind fight wilds here until the party's lowest level reaches `level` +-- heal talk to this map's Pokecenter nurse +-- teach teach a move from an HM to an eligible party mon +-- field use a field move against this cell +-- catch hunt this map's wild encounters until one of `species` is caught +-- manual not implemented; log it, record it, carry on + +return { + + -- ========================================================================= + -- Section 00 : the bedroom through the starter + -- ========================================================================= + -- NEW GAME, InitClock and NamePlayer have no rows here on purpose. A + -- POKEPORT_DRIVER run without POKEPORT_BOOT_CINEMA boots straight into the + -- world (src/core/Game2.lua:load), so the bot starts already stood + -- in the bedroom with none of the three ever on screen. With the cinema on + -- they are driven instead by tests/drivers/gold_boot_smoke.lua. + + { id = "00.5", map = "PLAYERS_HOUSE_2F", op = "warp", x = 7, y = 0, + to = "PLAYERS_HOUSE_1F" }, + + -- MeetMomScript fires on the scene-0 load and runs ~1300 frames of text with + -- a yes/no pair inside it. The section's step 6a is explicit that answering + -- the DST question is not enough: the confirm loops back to .SetDayOfWeek on + -- "no", so both answers have to be yes. + { id = "00.6", map = "PLAYERS_HOUSE_1F", op = "settle", + answers = { "yes", "yes" }, budget = 4000 }, + + { id = "00.7", map = "PLAYERS_HOUSE_1F", op = "warp", x = 6, y = 7, + to = "NEW_BARK_TOWN" }, + { id = "00.9", map = "NEW_BARK_TOWN", op = "warp", x = 6, y = 3, + to = "ELMS_LAB" }, + + -- Scene 0 auto-walks the player to (4,2) and talks; nothing to drive but the + -- text. + { id = "00.10", map = "ELMS_LAB", op = "settle", budget = 3000 }, + + -- Starter. Cyndaquil at (6,3): section 09's own battle notes say to bring a + -- Fire or Fighting lead for Jasmine, and Fire also answers Bugsy's Scyther in + -- section 04. Answers are yes (take it) then no (skip the nickname screen -- + -- the naming keyboard has no cancel, only END, so it is far cheaper never to + -- open it: src/ui/gen2/NamingScreen.lua:236). + { id = "00.11", map = "ELMS_LAB", op = "talk", x = 6, y = 3, + answers = { "yes", "no" }, expect = "EVENT_GOT_A_POKEMON_FROM_ELM" }, + + { id = "00.12", map = "ELMS_LAB", op = "walk", x = 4, y = 8 }, + { id = "00.14", map = "ELMS_LAB", op = "warp", x = 4, y = 11, + to = "NEW_BARK_TOWN" }, + { id = "00.15", map = "NEW_BARK_TOWN", op = "edge", dir = "west", + to = "ROUTE_29" }, + + -- ========================================================================= + -- Section 01 : Cherrygrove, Mr Pokemon, the egg, and north to Violet + -- ========================================================================= + + -- The starter is level 5 and Falkner is four maps away. The walkthrough + -- grinds Route 29 to 8 before Cherrygrove; do the same, because every later + -- gate in this section is a scripted battle we cannot decline. + { id = "01.5", map = "ROUTE_29", op = "grind", level = 8 }, + { id = "01.6", map = "ROUTE_29", op = "edge", dir = "west", + to = "CHERRYGROVE_CITY" }, + + -- The guide gent's tour is a long follow/stopfollow cutscene. It is not a + -- progression gate, but he walks up and starts it unprompted, so it is + -- cheaper to run it deliberately than to be ambushed mid-path later. + { id = "01.7", map = "CHERRYGROVE_CITY", op = "talk", x = 32, y = 6, + answers = { "yes" }, optional = true, budget = 6000 }, + { id = "01.8", map = "CHERRYGROVE_CITY", op = "heal", optional = true }, + + { id = "01.9", map = "CHERRYGROVE_CITY", op = "edge", dir = "up", + to = "ROUTE_30" }, + { id = "01.12", map = "ROUTE_30", op = "warp", x = 17, y = 5, + to = "MR_POKEMONS_HOUSE" }, + + -- The scene fires on load: egg, Pokedex, the blackout point moves to + -- Cherrygrove, and SPECIALCALL_ROBBED is armed. + { id = "01.13", map = "MR_POKEMONS_HOUSE", op = "settle", budget = 5000, + expect = "EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON" }, + { id = "01.14", map = "MR_POKEMONS_HOUSE", op = "warp", x = 2, y = 7, + to = "ROUTE_30" }, + + -- Elm's call lands a few overworld steps after leaving; `settle` after the + -- travel below picks it up wherever it fires. + { id = "01.15", map = "CHERRYGROVE_CITY", op = "travel" }, + { id = "01.15b", map = "CHERRYGROVE_CITY", op = "settle", budget = 2000, + optional = true }, + { id = "01.16", map = "CHERRYGROVE_CITY", op = "battle", x = 33, y = 6, + note = "rival scene trip-wire on row 6/7 at x=33" }, + + { id = "01.18", map = "ELMS_LAB", op = "travel" }, + { id = "01.18b", map = "ELMS_LAB", op = "walk", x = 4, y = 5, + note = "SCENE_ELMSLAB_MEET_OFFICER trip-wire; CopScript names the rival" }, + { id = "01.18c", map = "ELMS_LAB", op = "settle", budget = 4000 }, + { id = "01.19", map = "ELMS_LAB", op = "talk", x = 5, y = 2, + answers = { "yes" }, expect = "EVENT_GAVE_MYSTERY_EGG_TO_ELM" }, + { id = "01.20", map = "ELMS_LAB", op = "walk", x = 4, y = 8, + note = "aide's five POKE BALLs" }, + + -- Route 30's north blockers come down with the egg handed over, so the run + -- north to Violet is only now possible. The trainers on the way (Joey, + -- Mikey, Don, Wade) are walked into rather than sought: the route is + -- geography and the sight lines do the rest. + { id = "01.25", map = "ROUTE_30", op = "travel" }, + { id = "01.26", map = "ROUTE_31", op = "travel" }, + { id = "01.30", map = "VIOLET_CITY", op = "travel" }, + + -- ========================================================================= + -- Section 02 : Sprout Tower and Falkner + -- ========================================================================= + -- Sprout Tower is optional for the badge but is where HM05 FLASH lives, and + -- the rival fight on 3F is a scene trip-wire that will otherwise ambush a + -- later pass through. Kept, but the sages are left to their sight lines. + + { id = "02.1", map = "SPROUT_TOWER_1F", op = "travel", optional = true }, + { id = "02.15", map = "SPROUT_TOWER_3F", op = "travel", optional = true }, + { id = "02.15b", map = "SPROUT_TOWER_3F", op = "walk", x = 11, y = 9, + optional = true, note = "rival encounter coord event, scene 0" }, + { id = "02.16", map = "SPROUT_TOWER_3F", op = "talk", x = 10, y = 2, + optional = true, expect = "EVENT_GOT_HM05_FLASH" }, + + -- Grind before each gym. The route used to carry exactly one grind row (the + -- opening Route 29 one) and fought every leader at whatever level the walking + -- happened to produce -- which is why runs stalled with a level-10 lead and + -- two badges. The badges are load bearing far beyond the badge count: FOG + -- gates SURF and PLAIN gates STRENGTH, so losing Morty or Whitney means the + -- HMs never work and Cianwood is never reached. + { id = "02.g", map = "ROUTE_31", op = "grind", level = 14, lead = true }, + { id = "02.19", map = "VIOLET_CITY", op = "travel" }, + { id = "02.20", map = "VIOLET_CITY", op = "heal" }, + { id = "02.22", map = "VIOLET_GYM", op = "travel" }, + + -- Falkner at (5,1). Abe and Rod are in the way of the walk up and engage on + -- their own; the badge script force-sets their flags either way. + { id = "02.26", map = "VIOLET_GYM", op = "battle", x = 5, y = 1, talk = true, + expect = "ENGINE_ZEPHYRBADGE", + retryFrom = "02.20", retryLimit = 4 }, + + -- The Togepi Egg, and it is not a side quest: it is what OPENS ROUTE 32. + -- + -- Route 32's only road south runs through (18,8), which carries a coord + -- event at scene 0 -- the Miracle Seed man, who follows you, walks you two + -- cells back north and hands you nothing you need (maps/Route32.asm + -- Route32CooltrainerMStopsYouScene). Nothing in that script changes the + -- scene. The one thing in the game that does is Elm's aide, three maps + -- away: giving you the egg runs `setmapscene ROUTE_32, + -- SCENE_ROUTE32_OFFER_SLOWPOKETAIL` (maps/VioletPokecenter1F.asm), and only + -- then does the road open. The map is a soft gate and the egg is the key. + -- + -- Without these three rows the bot walked into that scene, was pushed back, + -- re-planned the identical path and was pushed back again -- so Azalea, Ilex + -- Forest, Goldenrod and everything past them were reachable only by + -- teleport, which is most of what the run's shortcut count was. + -- + -- The aide only appears once Elm has phoned: Falkner's badge script arms + -- `specialphonecall SPECIALCALL_ASSISTANT` (maps/VioletGym.asm), the call + -- lands outdoors after a few steps, and its script clears + -- EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER, which is what un-hides him. + -- + -- The wander row waits on a flag being CLEARED rather than set, which is why + -- it carries `expectClear` instead of `expect`: an object_event's event flag + -- HIDES the object when set (CheckObjectFlag), so the aide appears the moment + -- the call clears his. + { id = "02.26b", map = "VIOLET_CITY", op = "wander", budget = 40, + expectClear = "EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER", optional = true, + note = "walk outdoors until Elm's SPECIALCALL_ASSISTANT lands" }, + { id = "02.26c", map = "VIOLET_POKECENTER_1F", op = "travel" }, + { id = "02.26d", map = "VIOLET_POKECENTER_1F", op = "talk", x = 4, y = 3, + answers = { "yes" }, budget = 6000, + expect = "EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE" }, + + -- ========================================================================= + -- Section 03 : Route 32, Union Cave, Route 33 + -- ========================================================================= + -- The Ruins of Alph detour (steps 4-14) is skipped: the Unown puzzle needs + -- `special UnownPuzzle` driven through a sliding-tile UI, and nothing past it + -- gates progress to Azalea. + + { id = "03.16", map = "ROUTE_32", op = "travel" }, + { id = "03.28", map = "ROUTE_32_POKECENTER_1F", op = "travel", + optional = true }, + { id = "03.28b", map = "ROUTE_32_POKECENTER_1F", op = "heal", + optional = true }, + { id = "03.29", map = "UNION_CAVE_1F", op = "travel" }, + { id = "03.36", map = "ROUTE_33", op = "travel" }, + { id = "03.39", map = "AZALEA_TOWN", op = "travel" }, + + -- ========================================================================= + -- Section 04 : Slowpoke Well and Bugsy + -- ========================================================================= + -- Slowpoke Well is a hard gate, not a side quest: clearing it is what sets + -- AZALEA_TOWN's scene to SCENE_AZALEATOWN_RIVAL_BATTLE, and Ilex Forest -- + -- the only way west -- is behind that scene's coord event. + + { id = "04.3", map = "KURTS_HOUSE", op = "travel" }, + { id = "04.3b", map = "KURTS_HOUSE", op = "talk", x = 3, y = 2, + expect = "EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET" }, + { id = "04.5", map = "AZALEA_POKECENTER_1F", op = "travel", optional = true }, + { id = "04.5b", map = "AZALEA_POKECENTER_1F", op = "heal", optional = true }, + + -- Balls before the well. Elm's aide gives five in the whole game, and the + -- SLOWPOKE below is not optional: it is the only SURF user available before + -- FOGBADGE opens water hunting, so five failed throws cost Cianwood, the + -- Storm Badge and everything after it. A run that came this far has + -- thousands of yen and nothing else to spend it on. Count is sized for this + -- catch plus the later POLIWAG and bird hunts (07.g0 / 07.30). + { id = "04.5c", map = "AZALEA_MART", op = "buy", item = "POKE_BALL", + count = 40 }, + -- Potions, for the same reason the balls are bought: the bot has thousands + -- of yen and nothing else to spend it on, and until now it walked into every + -- gym leader with no way to answer damage except more levels. Bot:fightBattle + -- drinks the strongest thing in the bag when the lead drops below a third. + { id = "04.5d", map = "AZALEA_MART", op = "buy", item = "SUPER_POTION", + count = 10, optional = true }, + + { id = "04.6", map = "SLOWPOKE_WELL_B1F", op = "travel" }, + + -- Catch a SLOWPOKE while we are down here. Not in the walkthrough, and the + -- run cannot finish without it: SURF is required to reach Cianwood in section + -- 08, and the Cyndaquil line cannot learn it (its tmhm list has CUT, + -- STRENGTH and ROCK_SMASH but no SURF or FLY). SLOWPOKE is in this map's own + -- grass table and learns SURF *and* STRENGTH -- but NOT WHIRLPOOL or + -- WATERFALL, which is why 07.30 catches a POLIWAG once the pond opens. + { id = "04.6b", map = "SLOWPOKE_WELL_B1F", op = "catch", + species = { "SLOWPOKE" }, ball = "POKE_BALL" }, + -- The three grunts are sight-line fights on the way north and west; the last + -- one at (5,2) runs the whole clear-out cutscene with no endifjustbattled, + -- ending in HealParty and a warp to Kurt's house. + { id = "04.8", map = "SLOWPOKE_WELL_B1F", op = "battle", x = 15, y = 8 }, + { id = "04.9", map = "SLOWPOKE_WELL_B1F", op = "battle", x = 13, y = 3 }, + { id = "04.11", map = "SLOWPOKE_WELL_B1F", op = "battle", x = 6, y = 6 }, + { id = "04.13", map = "SLOWPOKE_WELL_B1F", op = "battle", x = 5, y = 3, + budget = 8000, expect = "EVENT_CLEARED_SLOWPOKE_WELL" }, + + { id = "04.14", map = "KURTS_HOUSE", op = "talk", x = 3, y = 2, + answers = { "no" }, optional = true, + note = "LURE_BALL, then decline the apricorn prompt" }, + + { id = "04.g", map = "UNION_CAVE_1F", op = "grind", level = 19, lead = true }, + { id = "04.17", map = "AZALEA_GYM", op = "travel" }, + { id = "04.22", map = "AZALEA_GYM", op = "battle", x = 5, y = 7, talk = true, + expect = "ENGINE_HIVEBADGE", + retryFrom = "04.17", retryLimit = 4 }, + + -- ========================================================================= + -- Section 05 : Ilex Forest, CUT, and Whitney + -- ========================================================================= + + { id = "05.1", map = "AZALEA_TOWN", op = "travel" }, + { id = "05.1b", map = "AZALEA_TOWN", op = "battle", x = 5, y = 10, + note = "SCENE_AZALEATOWN_RIVAL_BATTLE trip-wire" }, + -- Enter the forest from AZALEA, not from Route 34. + -- + -- Ilex Forest is two regions with no walk between them until the tree at + -- (8,25) is cut, and the whole of section 05 -- the Farfetch'd herd, the + -- man who hands over HM01, the tree itself -- is in the southern one. A + -- plain `travel ILEX_FOREST` is satisfied by either, and the planner + -- naturally picked the northern gate: the bot arrived in the forest, on the + -- correct map, with every remaining objective behind a tree it needed CUT to + -- remove and CUT behind the tree. Naming the gate is what pins the side. + { id = "05.2b", map = "ILEX_FOREST_AZALEA_GATE", op = "travel" }, + { id = "05.3", map = "ILEX_FOREST", op = "travel" }, + + -- The Farfetch'd herd. Nine talks, each moving the bird to the next spot. + -- + -- Every FarfetchdPositionN script (bar the first) scalls + -- FarfetchdCryAndCheckFacing, so the direction the PLAYER is facing decides + -- whether the bird goes onwards or doubles back round the loop. `facings` + -- lists the directions that advance it, taken straight from the section-05 + -- branch table; approaching from whichever side happens to be nearest herds + -- in circles forever. + -- + -- Pos2 -> Pos3, except DOWN -> Pos8 + -- Pos3 -> Pos4, except LEFT -> Pos2 + -- Pos4 -> Pos5, except UP -> Pos3 + -- Pos5 -> Pos6, except LEFT -> Pos7, UP/RIGHT -> Pos4 + -- Pos6 -> Pos7, except RIGHT -> Pos5 + -- Pos7 -> Pos8, except LEFT -> Pos6, DOWN -> Pos5 + -- Pos8 -> Pos9, except RIGHT -> Pos7, UP/LEFT -> Pos2 + -- Pos9 -> Pos10 (terminal), except RIGHT/DOWN -> Pos8 + { id = "05.5", map = "ILEX_FOREST", op = "talk", x = 14, y = 31, + note = "Pos1: no facing check, always advances" }, + { id = "05.6b", map = "ILEX_FOREST", op = "talk", x = 15, y = 25, + facings = { "up", "left", "right" } }, + { id = "05.6c", map = "ILEX_FOREST", op = "talk", x = 20, y = 24, + facings = { "up", "down", "right" } }, + { id = "05.6d", map = "ILEX_FOREST", op = "talk", x = 29, y = 22, + facings = { "down", "left", "right" } }, + { id = "05.6e", map = "ILEX_FOREST", op = "talk", x = 28, y = 31, + facings = { "down" } }, + { id = "05.6f", map = "ILEX_FOREST", op = "talk", x = 24, y = 35, + facings = { "up", "down", "left" } }, + { id = "05.6g", map = "ILEX_FOREST", op = "talk", x = 22, y = 31, + facings = { "up", "right" } }, + { id = "05.6h", map = "ILEX_FOREST", op = "talk", x = 15, y = 29, + facings = { "down" } }, + { id = "05.6i", map = "ILEX_FOREST", op = "talk", x = 10, y = 35, + facings = { "up", "left" }, expect = "EVENT_HERDED_FARFETCHD" }, + + { id = "05.8", map = "ILEX_FOREST", op = "talk", x = 5, y = 28, + expect = "EVENT_GOT_HM01_CUT" }, + { id = "05.9", map = "ILEX_FOREST", op = "teach", move = "CUT" }, + -- The tree itself is at (8,25); section 05 step 10 writes (8,24), which is + -- the cell you STAND on to face it (verified against the cache: (8,24) is + -- plain floor, (8,25) is COLL_CUT_TREE $12). A field move targets the tile, + -- so the route names the tree. + { id = "05.10", map = "ILEX_FOREST", op = "field", move = "CUT", x = 8, y = 25, + note = "opens the path north; block (4,12) becomes $17" }, + + { id = "05.13", map = "ROUTE_34", op = "travel" }, + { id = "05.24", map = "GOLDENROD_CITY", op = "travel" }, + { id = "05.25", map = "GOLDENROD_CITY", op = "heal" }, + -- Two grinds before Whitney, and both are load bearing. + -- + -- The lead one is obvious: MILTANK is the wall of the first half of the game + -- and the bot fights with no items and a "hit it with the strongest move" + -- policy, so it needs the levels the walkthrough's player would have. + -- + -- The party one is what was missing. The bot fought every gym with one + -- Pokemon and a level-7 SLOWPOKE behind it, so the moment the lead fainted + -- the run was over -- and the SLOWPOKE is not a spare, it is the only mon + -- that can learn SURF before water hunting opens, so it has to survive as + -- far as Cianwood either way. `lead = false` grinds the party MINIMUM, + -- which is what drags it up. + { id = "05.g", map = "ROUTE_34", op = "grind", level = 20, lead = false, + wipeBudget = 6 }, + { id = "05.g2", map = "ROUTE_34", op = "grind", level = 30, lead = true }, + -- Re-enter Goldenrod from ROUTE_34 specifically. Its north edge is a direct + -- connection and the flood fill confirms reachable border cells (x=8..11); + -- the alternative the planner keeps finding -- Violet -> Route 36 -> Route 35 + -- -- ends at Route 35's SOUTH gate, whose warp is not reachable from the + -- north border you arrive on, so the bot ping-pongs instead of arriving. + { id = "05.24b", map = "ROUTE_34", op = "travel" }, + -- Heal between the grind and the leader. Whitney's MILTANK is the wall of + -- the first half of the game and the grind that precedes her ends with the + -- party wherever the wild encounters left it; a run that walks in at half HP + -- wins the fight and then blacks out on the way to the badge talk, which + -- costs PLAINBADGE -- and PLAINBADGE gates STRENGTH, the Squirtbottle, + -- Sudowoodo and ROCK SMASH behind it. + { id = "05.38", map = "GOLDENROD_CITY", op = "heal" }, + { id = "05.39", map = "GOLDENROD_GYM", op = "travel" }, + { id = "05.44", map = "GOLDENROD_GYM", op = "battle", x = 8, y = 3, talk = true, + expect = "EVENT_BEAT_WHITNEY" }, + -- Whitney cries afterwards and blocks the door until the trip-wire at (8,5) + -- runs WhitneyCriesScript; only then does a second talk hand over the badge. + { id = "05.45", map = "GOLDENROD_GYM", op = "walk", x = 8, y = 5 }, + { id = "05.46", map = "GOLDENROD_GYM", op = "talk", x = 8, y = 3, + expect = "ENGINE_PLAINBADGE", + retryFrom = "05.38", retryLimit = 4 }, + + { id = "05.46b", map = "ROUTE_34", op = "travel", optional = true }, + { id = "05.47", map = "GOLDENROD_FLOWER_SHOP", op = "travel" }, + { id = "05.47b", map = "GOLDENROD_FLOWER_SHOP", op = "talk", x = 2, y = 4, + expect = "EVENT_GOT_SQUIRTBOTTLE" }, + + -- ========================================================================= + -- Section 06 : Sudowoodo and ROCK SMASH + -- ========================================================================= + -- Sudowoodo is a hard gate: it stands on the Route 36 tile that leads east to + -- Violet and north to Ecruteak, and TM08 ROCK SMASH (needed for the Burned + -- Tower in section 07) is behind having fought it. + + { id = "06.15", map = "ROUTE_36", op = "travel" }, + { id = "06.20", map = "ROUTE_36", op = "battle", x = 35, y = 9, talk = true, + answers = { "yes" }, expect = "EVENT_FOUGHT_SUDOWOODO" }, + { id = "06.21", map = "ROUTE_36", op = "talk", x = 44, y = 9, + expect = "EVENT_GOT_TM08_ROCK_SMASH" }, + -- ROCK SMASH is a TM, not an HM, so nothing teaches it as a side effect of + -- picking it up -- and the Burned Tower's north half is behind a rock. The + -- route had the TM and no `teach` row, so `07.20 field ROCK_SMASH` skipped + -- with "no party mon knows ROCK_SMASH", the beasts were never released and + -- Morty was fought without them. + { id = "06.21b", map = "ROUTE_36", op = "teach", move = "ROCK_SMASH" }, + + -- ========================================================================= + -- Section 07 : Ecruteak, SURF, the beasts, and Morty + -- ========================================================================= + + { id = "07.6", map = "ECRUTEAK_CITY", op = "travel" }, + { id = "07.7", map = "ECRUTEAK_POKECENTER_1F", op = "travel" }, + { id = "07.7b", map = "ECRUTEAK_POKECENTER_1F", op = "settle", budget = 5000, + note = "Bill / Time Capsule scene fires on load" }, + { id = "07.7c", map = "ECRUTEAK_POKECENTER_1F", op = "heal" }, + + -- The five Kimono Girls all have sight 0 and will not start a fight + -- themselves, so each is an explicit talk. + { id = "07.12", map = "DANCE_THEATER", op = "travel" }, + { id = "07.13a", map = "DANCE_THEATER", op = "battle", x = 0, y = 2, talk = true }, + { id = "07.13b", map = "DANCE_THEATER", op = "battle", x = 2, y = 1, talk = true }, + { id = "07.13c", map = "DANCE_THEATER", op = "battle", x = 6, y = 2, talk = true }, + { id = "07.13d", map = "DANCE_THEATER", op = "battle", x = 9, y = 1, talk = true }, + { id = "07.13e", map = "DANCE_THEATER", op = "battle", x = 11, y = 2, talk = true }, + { id = "07.14", map = "DANCE_THEATER", op = "talk", x = 7, y = 10, + expect = "EVENT_GOT_HM03_SURF" }, + { id = "07.14b", map = "DANCE_THEATER", op = "teach", move = "SURF" }, + -- ops.teach walks the party for the first mon whose tmhm list contains the + -- move, so the SLOWPOKE caught in section 04 is what picks this up. + + -- Grind and heal BEFORE the tower, not after it. + -- + -- The Burned Tower is a pit maze whose lower floor has six regions, four of + -- them one-way pockets, and the bot can and does get stuck in one. With the + -- grind and the heal sitting on the far side of it, a tower that went wrong + -- took Morty with it -- the badge fight was reached at whatever level and + -- whatever HP the tower left behind, and FOGBADGE gates SURF and everything + -- after. Nothing in the tower gates the gym, so it is now a detour that may + -- fail on its own. + -- Morty is the hardest fight in Johto for this party. His GASTLY line is + -- Ghost/Poison: the starter's Normal moves cannot touch it at all and its + -- Fire is resisted, so the whole gym is carried by the SLOWPOKE's SURF -- + -- which means the SLOWPOKE has to be a real Pokemon by now, not the level-20 + -- HM caddy it is when it arrives. Both grinds, and the higher lead target, + -- are what the fight costs. + -- A FLY user before the party grind. Cyndaquil/SLOWPOKE/TOGEPI none of + -- them learn FLY (TOGETIC would, but the egg never sees a Shiny Stone on + -- this route). ROUTE_37's grass is PIDGEY / HOOTHOOT / PIDGEOTTO; catching + -- here costs no detour and the party-minimum grind that follows levels it. + { id = "07.g0", map = "ROUTE_37", op = "catch", + species = { "PIDGEY", "PIDGEOTTO", "HOOTHOOT", "NOCTOWL" }, + ball = "POKE_BALL" }, + { id = "07.g", map = "ROUTE_37", op = "grind", level = 28, lead = false, + wipeBudget = 6 }, + { id = "07.g2", map = "ROUTE_37", op = "grind", level = 36, lead = true }, + + { id = "07.18", map = "BURNED_TOWER_1F", op = "travel", optional = true }, + -- The rival scene fires on entry, before anything can be driven. + { id = "07.19", map = "BURNED_TOWER_1F", op = "settle", budget = 8000, + optional = true }, + -- A smashable rock is an OBJECT, not a tile. `BURNEDTOWER1F_ROCK1` at (4,3) + -- is a SPRITEMOVEDATA_SMASHABLE_ROCK whose script is `jumpstd + -- SmashRockScript` -> `AskRockSmashScript`, so it is TALKED to and answered + -- YES; World:useFieldMove reads the faced TILE and quite correctly refuses + -- ("Can't use that here"), which is what the `field` row used to fail with. + -- No badge is required for ROCK SMASH, only a party mon that knows it. + { id = "07.20", map = "BURNED_TOWER_1F", op = "talk", x = 4, y = 3, + answers = { "yes" }, budget = 4000, optional = true, + note = "opens the north half" }, + { id = "07.21", map = "BURNED_TOWER_1F", op = "battle", x = 8, y = 1, + optional = true }, + -- Fall through the CENTRE pit (1F warp 9 -> B1F warp 3, landing (10,8)). + -- The beasts' plateau is its own region and only that landing is on it; a + -- plain travel used to take the nearest pit, land at (3,3), and leave (9,5) + -- unreachable. Getting OUT afterwards is the hop-down ledges to the ladder + -- at (7,15), which the planner walks now that ledge hops exist. + { id = "07.22", map = "BURNED_TOWER_1F", op = "warp", x = 10, y = 7, + to = "BURNED_TOWER_B1F", optional = true }, + { id = "07.23", map = "BURNED_TOWER_B1F", op = "walk", x = 9, y = 5, + expect = "EVENT_RELEASED_THE_BEASTS", budget = 8000, optional = true }, + + -- Walk into the gym healed. Morty's GASTLY line is immune to the starter's + -- Normal moves and resists its Fire, so the fight is carried by the SLOWPOKE + -- and its SURF -- and a SLOWPOKE that arrives at half HP loses to the gym + -- trainers before the leader is reached. + { id = "07.26", map = "ECRUTEAK_MART", op = "buy", item = "HYPER_POTION", + count = 10, optional = true }, + { id = "07.27", map = "ECRUTEAK_CITY", op = "heal" }, + { id = "07.28", map = "ECRUTEAK_GYM", op = "travel" }, + { id = "07.29", map = "ECRUTEAK_GYM", op = "battle", x = 5, y = 1, talk = true, + expect = "ENGINE_FOGBADGE", + retryFrom = "07.27", retryLimit = 4 }, + + -- WHIRLPOOL / WATERFALL mule. SLOWPOKE can learn SURF and STRENGTH but its + -- tmhm list has neither WHIRLPOOL nor WATERFALL -- that is why 11.34b and + -- 13.10b used to report "no party mon can learn …". ECRUTEAK_CITY's water + -- table is POLIWAG / POLIWHIRL only (no grass table at all), FOGBADGE just + -- opened the pond, and SURF is already taught, so this is a zero-detour + -- water hunt. POLIWHIRL also picks up STRENGTH on evolution. + { id = "07.30", map = "ECRUTEAK_CITY", op = "catch", + species = { "POLIWAG", "POLIWHIRL" }, ball = "POKE_BALL", water = true }, + + -- ========================================================================= + -- Section 08 : Olivine, STRENGTH, the lighthouse, Cianwood, Chuck + -- ========================================================================= + + { id = "08.17", map = "OLIVINE_CITY", op = "travel" }, + { id = "08.18", map = "OLIVINE_CITY", op = "walk", x = 13, y = 12, + note = "rival scene trip-wire, scene 0" }, + { id = "08.20", map = "OLIVINE_CITY", op = "heal" }, + { id = "08.21", map = "OLIVINE_CAFE", op = "travel" }, + { id = "08.21b", map = "OLIVINE_CAFE", op = "talk", x = 4, y = 3, + expect = "EVENT_GOT_HM04_STRENGTH" }, + { id = "08.22", map = "OLIVINE_CAFE", op = "teach", move = "STRENGTH" }, + + -- Up the lighthouse to Jasmine. Her explanation is what unlocks the Cianwood + -- pharmacy, so this visit is required even though nothing here is a fight. + { id = "08.43", map = "OLIVINE_LIGHTHOUSE_6F", op = "travel" }, + { id = "08.43b", map = "OLIVINE_LIGHTHOUSE_6F", op = "talk", x = 8, y = 8, + expect = "EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS" }, + + { id = "08.50", map = "ROUTE_40", op = "travel" }, + -- Start surfing. The SLOWPOKE caught in section 04 is the SURF user (the + -- Cyndaquil line cannot learn it). (8,8) is water with walkable land at + -- (8,7), so approachAndFace stands on the beach facing the sea and + -- World:useFieldMove does the rest. + { id = "08.51", map = "ROUTE_40", op = "field", move = "SURF", x = 8, y = 8 }, + { id = "08.57", map = "CIANWOOD_CITY", op = "travel" }, + { id = "08.60", map = "CIANWOOD_PHARMACY", op = "travel" }, + { id = "08.60b", map = "CIANWOOD_PHARMACY", op = "talk", x = 2, y = 3, + expect = "EVENT_GOT_SECRETPOTION_FROM_PHARMACY" }, + + -- Chuck's POLIWRATH and PRIMEAPE are the second wall of Johto, and the bot + -- reaches him having fought four Blackbelts and shoved three boulders on the + -- way in. With the lane finally open (rows 08.65a-c) the fight itself became + -- the blocker, so it gets the same treatment Morty did: bring the whole party + -- up, then top the lead off, then walk in healed. + { id = "08.g", map = "ROUTE_41", op = "grind", level = 32, lead = false, + wipeBudget = 6 }, + { id = "08.g2", map = "ROUTE_41", op = "grind", level = 40, lead = true }, + { id = "08.61", map = "CIANWOOD_CITY", op = "heal" }, + { id = "08.62", map = "CIANWOOD_GYM", op = "travel" }, + { id = "08.63a", map = "CIANWOOD_GYM", op = "battle", x = 2, y = 12 }, + { id = "08.63b", map = "CIANWOOD_GYM", op = "battle", x = 7, y = 12 }, + { id = "08.64", map = "CIANWOOD_GYM", op = "battle", x = 3, y = 9 }, + -- The boulder lane is the only way to the back of the gym. + -- The boulder row at y=7 (BOULDER2/3/4 at x=3/4/5) is the only way to the + -- back of the gym, and it is what STRENGTH is for -- the walkthrough's "use + -- Strength to push your way to the last trainer before the gym leader". + -- Shoving the middle one north opens the lane behind it. This was a + -- `manual` row that logged and moved on, which meant Chuck was fought from + -- the wrong side of a rock and STORMBADGE was never earned. + -- The boulder puzzle, solved sideways. + -- + -- Row 7 (boulders at x=3/4/5) is the only link between the gym's halves: + -- rows 6-7 are x=3..5, row 5 narrows to (4,5) and (5,5), Blackbelt Lung + -- stands on (5,5) and does not leave it, and (4,3) is solid wall. So + -- shoving the middle boulder NORTH can never work -- it stays in the one + -- column the player needs, and its furthest cell (4,4) blocks the last step + -- into row 4. Every count from 2 to 6 was tried and every end state is + -- blocked. + -- + -- The way through is to empty the row instead of climbing it: push the two + -- OUTER boulders north (each frees its own cell in row 7 and parks itself in + -- a dead-end pocket of row 6), then stand where the right-hand one was and + -- push the middle boulder WEST into the gap the left-hand one left. Row 7's + -- middle cell is then clear and the column above it is untouched. + -- + -- A push does not move the player -- the cart treats a strength boulder like + -- a solid NPC -- so each of these costs a pair of presses: one to shove the + -- rock, one to step into the cell it left. `ops.push` waits for the rock to + -- stop sliding between presses, because World:tryPushBoulder refuses while + -- `npc.moving`. + { id = "08.65a", map = "CIANWOOD_GYM", op = "push", x = 3, y = 7, + dir = "up", count = 2, note = "left boulder to (3,6); frees (3,7)" }, + { id = "08.65b", map = "CIANWOOD_GYM", op = "push", x = 5, y = 7, + dir = "up", count = 2, note = "right boulder to (5,6); frees (5,7)" }, + { id = "08.65c", map = "CIANWOOD_GYM", op = "push", x = 4, y = 7, + dir = "left", count = 2, + note = "middle boulder west into (3,7); the lane north is now open" }, + { id = "08.66", map = "CIANWOOD_GYM", op = "battle", x = 5, y = 5, + talk = true, budget = 8000 }, + { id = "08.68", map = "CIANWOOD_GYM", op = "battle", x = 4, y = 1, talk = true, + expect = "ENGINE_STORMBADGE", + retryFrom = "08.61", retryLimit = 4, + -- Rewind OUTSIDE the gym, not to 08.66 one row up. + -- + -- Chuck sits behind a STRENGTH boulder puzzle, and a puzzle left in a bad + -- state cannot be retried in place: run 25 pushed the boulders into a + -- arrangement that sealed the north half, and both 08.66 and 08.68 then answered + -- "approach: nowhere to stand" instantly -- burning all four laps in a + -- single frame without ever moving. World:setMap calls restoreBlocks, + -- which refills the map from ROM, so LEAVING and re-entering is what puts + -- the boulders back. 08.61 is the Cianwood heal, one map outside the door. + }, + -- FLY is an optimisation, never a gate: section 09 opens by flying back to + -- Olivine and travelTo can simply swim the way it came. Optional so a miss + -- here cannot cost the badge chain behind it. The bird from 07.g0 is who + -- learns it. + { id = "08.70", map = "CIANWOOD_CITY", op = "talk", x = 10, y = 46, + optional = true, expect = "EVENT_GOT_HM02_FLY" }, + { id = "08.70b", map = "CIANWOOD_CITY", op = "teach", move = "FLY", + optional = true }, + + -- ========================================================================= + -- Section 09 : back to Olivine, cure Amphy, and Jasmine + -- ========================================================================= + -- The section opens by flying, which the bot does not need: travelTo can swim + -- back the way it came. Flying is strictly an optimisation here. + + { id = "09.14", map = "OLIVINE_LIGHTHOUSE_6F", op = "travel" }, + { id = "09.15", map = "OLIVINE_LIGHTHOUSE_6F", op = "talk", x = 8, y = 8, + answers = { "yes" }, budget = 6000, + expect = "EVENT_JASMINE_RETURNED_TO_GYM" }, + + { id = "09.17", map = "OLIVINE_CITY", op = "travel" }, + { id = "09.17b", map = "OLIVINE_CITY", op = "heal" }, + { id = "09.g", map = "ROUTE_39", op = "grind", level = 38, lead = true }, + { id = "09.18", map = "OLIVINE_GYM", op = "travel" }, + { id = "09.20", map = "OLIVINE_GYM", op = "battle", x = 5, y = 3, talk = true, + budget = 12000, expect = "EVENT_BEAT_JASMINE" }, + { id = "09.21", map = "OLIVINE_GYM", op = "check", expect = "ENGINE_MINERALBADGE" }, + + -- ========================================================================= + -- Section 10 : Route 42, Mahogany, Route 43, the Lake of Rage + -- ========================================================================= + -- No badge here. What the section is FOR is EVENT_DECIDED_TO_HELP_LANCE, + -- which arms SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS and is the only way + -- into the Rocket base -- and therefore the only way to HM06 WHIRLPOOL. + + { id = "10.1", map = "ECRUTEAK_CITY", op = "travel" }, + { id = "10.3", map = "ROUTE_42", op = "travel" }, + -- Route 42's two lakes sit across the road east. The bot surfs on its own + -- now (Bot:stepDir starts the field move when a planned step enters water), + -- so this is a plain travel row. + { id = "10.13", map = "MAHOGANY_TOWN", op = "travel" }, + { id = "10.14", map = "MAHOGANY_TOWN", op = "heal" }, + { id = "10.18", map = "ROUTE_43", op = "travel" }, + { id = "10.26", map = "LAKE_OF_RAGE", op = "travel" }, + { id = "10.g", map = "ROUTE_43", op = "grind", level = 40, lead = true }, + + -- The Red Gyarados. Its script is `loadwildmon GYARADOS, 30` with + -- BATTLETYPE_FORCESHINY, so this is a wild fight the bot may simply win; the + -- RED_SCALE and Lance's appearance are set on a KO as well as a catch. + { id = "10.33", map = "LAKE_OF_RAGE", op = "battle", x = 18, y = 22, + talk = true, budget = 12000, + expect = "EVENT_LAKE_OF_RAGE_RED_GYARADOS" }, + { id = "10.34", map = "LAKE_OF_RAGE", op = "talk", x = 21, y = 28, + answers = { "yes" }, budget = 8000, + expect = "EVENT_DECIDED_TO_HELP_LANCE" }, + + -- ========================================================================= + -- Section 11 : the Rocket hideout and Pryce + -- ========================================================================= + + { id = "11.2", map = "MAHOGANY_MART_1F", op = "travel" }, + { id = "11.2b", map = "MAHOGANY_MART_1F", op = "settle", budget = 8000, + expect = "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART", + note = "LanceUncoversStaircaseScript runs unattended on arrival" }, + + { id = "11.3", map = "TEAM_ROCKET_BASE_B1F", op = "travel" }, + -- The security cameras are sight-line scripts on the way west; each is the + -- same pair of grunts and the bot fights them where it meets them. The + -- switch that ends them is a bg event read from (19,12) facing UP. + { id = "11.10", map = "TEAM_ROCKET_BASE_B1F", op = "talk", x = 19, y = 11, + facings = { "up" }, budget = 6000, + expect = "EVENT_TURNED_OFF_SECURITY_CAMERAS" }, + + { id = "11.12", map = "TEAM_ROCKET_BASE_B2F", op = "travel" }, + { id = "11.13", map = "TEAM_ROCKET_BASE_B2F", op = "walk", x = 5, y = 14, + note = "Lance heals the party here; scene 0 -> 1" }, + { id = "11.15", map = "TEAM_ROCKET_BASE_B3F", op = "travel" }, + { id = "11.15b", map = "TEAM_ROCKET_BASE_B3F", op = "settle", budget = 6000, + expect = "EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS" }, + -- Both passwords are learned by talking: the sight-0 grunt at (21,7) has to + -- be spoken to TWICE (the first talk is the battle), and the spinning grunt + -- at (5,15) the same. + -- Both battle rows carry the trainer's own beat flag: the fight fires on a + -- sight line DURING the approach, the trainer then stands wherever it + -- happened, and the approach's own opinion of reaching the (now moved) + -- object is worthless -- run 3 recorded a FAIL on a fight it had won. + { id = "11.18", map = "TEAM_ROCKET_BASE_B3F", op = "battle", x = 21, y = 7, + talk = true, budget = 8000, expect = "EVENT_BEAT_ROCKET_GRUNTF_5" }, + { id = "11.18b", map = "TEAM_ROCKET_BASE_B3F", op = "talk", x = 21, y = 7, + expect = "EVENT_LEARNED_SLOWPOKETAIL" }, + { id = "11.20", map = "TEAM_ROCKET_BASE_B3F", op = "battle", x = 5, y = 15, + talk = true, budget = 8000, expect = "EVENT_BEAT_ROCKET_GRUNTM_28" }, + { id = "11.20b", map = "TEAM_ROCKET_BASE_B3F", op = "talk", x = 5, y = 15, + expect = "EVENT_LEARNED_RATICATE_TAIL" }, + -- Region 1, and only NOW: the two passwords above are learned from grunts at + -- (21,7) and (5,15), which are in region 3. Pinning region 1 before them put + -- the bot on the wrong side of a floor it could not cross and both password + -- rows failed instantly. + -- + -- Region 1, not "anywhere on B3F". + -- + -- The floor is three regions that never touch: region 3 (214 cells, the way + -- in from B1F, warps 2 and 4 on the right), region 1 (57 cells, warps 1 and 3 + -- on the LEFT), and region 2 (46 cells, the boss chamber, sealed until + -- Giovanni's door opens). Every beat this section needs -- the rival at + -- (8,10), the locked door at (10,9), Executive 4, HAIL GIOVANNI -- is in + -- region 1. Arriving anywhere on B3F satisfied a plain `travel`, so the bot + -- stood in region 3 while row after row reported "no path" to cells on the + -- same floor. The honest route is a loop: B1F -> B2F r4 -> B3F r3 -> back up + -- via B3F warp 2 -> B2F r1 -> down B2F warp 2 -> B3F r1. + -- + -- Region indices come from `tools/goldwalk/mapgraph.lua map <MAP>`; they are + -- the order in tests/drivers/gold/map_regions.lua and change if the extractor + -- output changes, so re-check them when regenerating that file. + -- Heal BEFORE the region-1 loop, then noHeal all the way through HAIL + -- GIOVANNI. The rival, Giovanni's door, Executive 4 and the Murkrow are + -- all in region 1 and the loop into it (11.15r) is a five-hop crossing + -- through both floors; a between-rows auto-heal after the Executive fight + -- (which arrives with the party beaten in a continuous run) left the base, + -- healed at Mahogany, and re-entered in region 3 -- from which (7,2) has no + -- path. A wipe on 11.27 still recovers: the whiteout heals, and retryFrom + -- 11.15r re-runs the loop. + { id = "11.20h", map = "MAHOGANY_TOWN", op = "heal" }, + { id = "11.15r", map = "TEAM_ROCKET_BASE_B3F", op = "travel", region = 1, + noHeal = true }, + { id = "11.25", map = "TEAM_ROCKET_BASE_B3F", op = "walk", x = 8, y = 10, + noHeal = true, note = "rival cutscene, no battle; scene 1 -> 2" }, + { id = "11.26", map = "TEAM_ROCKET_BASE_B3F", op = "talk", x = 10, y = 9, + facings = { "up" }, budget = 6000, noHeal = true, + expect = "EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE" }, + { id = "11.27", map = "TEAM_ROCKET_BASE_B3F", op = "battle", x = 10, y = 8, + budget = 12000, noHeal = true, expect = "EVENT_BEAT_ROCKET_EXECUTIVEM_4", + -- Losable, so it laps. The rewind goes back to the region-1 travel rather + -- than one row up, because a wipe puts the player in a Pokecenter and the + -- way back into this half of B3F is the loop through both floors. + retryFrom = "11.20h", retryLimit = 5 }, + { id = "11.28", map = "TEAM_ROCKET_BASE_B3F", op = "talk", x = 7, y = 2, + noHeal = true, expect = "EVENT_LEARNED_HAIL_GIOVANNI" }, + + -- Back to the B1F side of B2F. Coming out of B3F region 1 lands in B2F + -- region 1 (top-left, 66 cells); the transmitter door at (14,12) is in region + -- 4, the half the stairs from B1F open into, and the two do not touch. Same + -- shape as 11.15r, one floor up. + -- Via B1F, deliberately, rather than by naming a region index. + -- + -- The transmitter door's standing cell (14,13) is in the 103-cell half that + -- the B1F stairs open into, and coming out of B3F region 1 lands in the + -- 66-cell half instead -- which has no B1F exit at all, only the two back up + -- to B3F. Going to B1F therefore forces the whole loop, and the plain travel + -- that follows can only arrive on the right side. A `region =` hint was tried + -- first and was not reliable here: Bot:currentRegions matches a LIVE flood + -- fill against the generated seeds, and on this floor it claimed the far + -- region while standing in the near one, so the travel reported success + -- without moving. + -- Heal BEFORE the transmitter room. Once the Electrode scene arms, the + -- room cannot be LEFT: Lance's coord events at (12,3)/(12,10)/(12,11) and + -- the (14/15,12) pair walk the player straight back in (cart behaviour -- + -- RocketBaseLancesSideScript / RocketBaseCantLeaveScript), so a mid-block + -- auto-heal just bounced off them until the walk gave up and 11.33b/c ran + -- in whatever state that left. noHeal through HM06. + { id = "11.30h", map = "MAHOGANY_TOWN", op = "heal" }, + { id = "11.30q", map = "TEAM_ROCKET_BASE_B1F", op = "travel" }, + { id = "11.30r", map = "TEAM_ROCKET_BASE_B2F", op = "travel", noHeal = true }, + { id = "11.31", map = "TEAM_ROCKET_BASE_B2F", op = "talk", x = 14, y = 12, + facings = { "up" }, budget = 6000, noHeal = true, + expect = "EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER" }, + { id = "11.32", map = "TEAM_ROCKET_BASE_B2F", op = "battle", x = 14, y = 11, + budget = 12000, noHeal = true, expect = "EVENT_BEAT_ROCKET_EXECUTIVEF_2", + retryFrom = "11.30h", retryLimit = 5 }, + -- The three Electrodes are wild L23 battles that may Selfdestruct; the third + -- one ends the base and hands over HM06. + { id = "11.33a", map = "TEAM_ROCKET_BASE_B2F", op = "battle", x = 7, y = 5, + talk = true, budget = 8000, noHeal = true }, + { id = "11.33b", map = "TEAM_ROCKET_BASE_B2F", op = "battle", x = 7, y = 7, + talk = true, budget = 8000, noHeal = true }, + { id = "11.33c", map = "TEAM_ROCKET_BASE_B2F", op = "battle", x = 7, y = 9, + talk = true, budget = 12000, noHeal = true }, + { id = "11.34", map = "TEAM_ROCKET_BASE_B2F", op = "check", noHeal = true, + expect = "EVENT_GOT_HM06_WHIRLPOOL", + -- The whole tail re-runs from the heal when the third Electrode's + -- handout was missed (a Selfdestruct wipe mid-trio leaves the scene + -- armed and the room sealed). + retryFrom = "11.30h", retryLimit = 4 }, + -- WHIRLPOOL and WATERFALL are both water-route gates rather than optional + -- extras: Route 27 has a whirlpool block on the way to Route 26, and Tohjo + -- Falls needs the climb. Taught to the POLIWAG/POLIWHIRL from 07.30 -- + -- SLOWPOKE cannot learn either, despite carrying SURF. + { id = "11.34b", map = "TEAM_ROCKET_BASE_B2F", op = "teach", + move = "WHIRLPOOL" }, + + { id = "11.36", map = "MAHOGANY_TOWN", op = "travel" }, + { id = "11.36b", map = "MAHOGANY_TOWN", op = "heal" }, + { id = "11.g", map = "ROUTE_43", op = "grind", level = 43, lead = true }, + { id = "11.36c", map = "MAHOGANY_GYM", op = "travel" }, + + -- Mahogany's floor is ice: a press slides until something stops it, so no + -- cell here can be aimed at and the asm-walk writes the whole gym as a list + -- of directions. These two are transcribed from its slide table -- the + -- first reaches (2,14) beside the pillar, the second the cell below Pryce. + { id = "11.42", map = "MAHOGANY_GYM", op = "press", + dirs = { "up", "left", "left", "up", "up" }, + note = "entrance (4,17) -> (2,14); the last three cells are plain floor" }, + { id = "11.43", map = "MAHOGANY_GYM", op = "press", + dirs = { "up", "up", "right", "down", "left", "up", "right" }, + note = "(2,14) -> (5,4), directly below Pryce at (5,3)" }, + { id = "11.44", map = "MAHOGANY_GYM", op = "battle", x = 5, y = 3, talk = true, + budget = 14000, expect = "ENGINE_GLACIERBADGE", + retryFrom = "11.36c", retryLimit = 4 }, + + -- ========================================================================= + -- Section 12 : the Radio Tower and the Goldenrod underground + -- ========================================================================= + -- Seven badges arm ENGINE_ROCKETS_IN_RADIO_TOWER, which is the precondition + -- for the whole section. Two passes at the tower with the underground in + -- between: the Basement Key comes off Executive 3 on 5F, the Card Key from + -- the Director in the underground warehouse, and only the Card Key opens the + -- way to the boss. + + -- Mahogany -> Goldenrod the WEST way, surfing across Route 42, not the + -- eastern loop through the Route 45/46/Dark Cave junction the foot-only graph + -- prefers (where the live edge-crosser oscillates and teleports). Route 42 + -- is water-split like Route 27: the Mahogany side and the Ecruteak-gate warp + -- (0,8) connect only by surf, so name the gate and warp to it -- planPath + -- surfs the crossing. From Ecruteak the run to Goldenrod is clean + -- (Route 37/36/National Park/35). Same waypoint-the-water-crossing lesson as + -- TOHJO_FALLS in section 16. + { id = "12.0a", map = "ROUTE_42", op = "travel" }, + { id = "12.0b", map = "ROUTE_42", op = "warp", x = 0, y = 8, + to = "ROUTE_42_ECRUTEAK_GATE" }, + { id = "12.0c", map = "ECRUTEAK_CITY", op = "travel" }, + { id = "12.1", map = "RADIO_TOWER_1F", op = "travel" }, + -- Heal at Goldenrod BEFORE the boss, and lap from here. Executive 3's four + -- self-destructing mons can wipe the party, and the whiteout goes to + -- blackoutMap -- which, walking in from Mahogany, was MAHOGANY_TOWN. The + -- recovery travel Mahogany -> Goldenrod then has to cross the Route 45/46/ + -- Dark Cave junction, where the live edge-crosser oscillates and teleports. + -- Healing at Goldenrod's Pokecenter first sets the spawn HERE (World: + -- updateWhiteoutSpawn fires on the outdoor->Pokecenter warp), so a wipe lands + -- next door to the tower and the lap is a two-hop walk, not a cross-Johto + -- one. It also enters the fight at full HP, which is half the point. + { id = "12.8h", map = "GOLDENROD_CITY", op = "heal" }, + { id = "12.9", map = "RADIO_TOWER_5F", op = "travel" }, + { id = "12.9b", map = "RADIO_TOWER_5F", op = "walk", x = 0, y = 3, + budget = 14000, expect = "EVENT_BEAT_ROCKET_EXECUTIVEM_3", + note = "FakeDirectorScript coord event; six mons, four of them explode", + retryFrom = "12.8h", retryLimit = 6 }, + + { id = "12.11", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "travel" }, + -- Region 1 of the underground (157 cells, warps 1/2/3): the salon side that + -- holds the Basement Key door at (18,6). The basement side (region 5) is the + -- OTHER half of the same-map warp pair, and a plain travel can land there + -- via switch-room warp 1 -- after which (18,6) has nowhere to stand. + { id = "12.14", map = "GOLDENROD_UNDERGROUND", op = "travel", region = 1 }, + { id = "12.14b", map = "GOLDENROD_UNDERGROUND", op = "talk", x = 18, y = 6, + expect = "EVENT_USED_BASEMENT_KEY" }, + -- Region 1 of the switch room (91 cells, warp 1 only): the north corridor + -- with the rival trigger and Switch1/2/3. Ten regions on this map; city + -- warps land in 9/10 and underground warp 1 lands in 10, none of which can + -- reach (16,1). Only underground warp 6 (basement side) opens into region 1. + { id = "12.16", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "walk", x = 19, y = 4, budget = 14000, region = 1, + expect = "EVENT_RIVAL_GOLDENROD_UNDERGROUND" }, + + -- Heal BEFORE the puzzle. noHeal from 12.18a through the Card Key: + -- MAPCALLBACK_NEWMAP on the underground / warehouse zeroes + -- wUndergroundSwitchPositions, so a heal between Switch3 and Switch2 undoes + -- the puzzle. The warehouse grunts are also losable, so arrive full. + { id = "12.15h", map = "GOLDENROD_CITY", op = "heal" }, + + -- Pin region 1 before the switches. 12.16 can be `already satisfied` (the + -- rival flag survives a wipe / resume), which skips tryReach and leaves the + -- bot in whichever half of the map a plain hop preferred -- then every + -- switch reports "nowhere to stand". Same shape as 11.15r. + { id = "12.17r", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "travel", region = 1, noHeal = true }, + + -- The switch puzzle. Each switch is a bg event read with A; the shared byte + -- is a plain sum, and intermediate positions leave some doors untouched, so + -- order matters. 1-then-2-then-3 opens door 8 (Eddie) but NOT door 5. + -- 3-then-2-then-1 from the reset state opens doors 3,5,6,8,9,11 -- door 5 + -- at (10,10) is the one that reaches the warehouse warps at (22,10)/(23,10). + -- See asm-walk section 12, "Consequences a bot needs". + { id = "12.18a", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "talk", x = 2, y = 1, budget = 4000, region = 1, noHeal = true, + note = "Switch3 ON (byte 0->3)" }, + { id = "12.18b", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "talk", x = 10, y = 1, budget = 4000, region = 1, noHeal = true, + note = "Switch2 ON (byte 3->5); leaves door 5 open" }, + { id = "12.18c", map = "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + op = "talk", x = 16, y = 1, budget = 4000, region = 1, noHeal = true, + note = "Switch1 ON (byte 5->6); warehouse path open" }, + { id = "12.24", map = "GOLDENROD_UNDERGROUND_WAREHOUSE", op = "travel", + noHeal = true }, + { id = "12.26", map = "GOLDENROD_UNDERGROUND_WAREHOUSE", op = "talk", + x = 12, y = 8, budget = 12000, noHeal = true, + expect = "EVENT_RECEIVED_CARD_KEY", + -- A wipe blacks out to the city and the switch callback has already + -- zeroed the puzzle, so the rewind re-enters from the heal + region pin. + retryFrom = "12.15h", retryLimit = 5 }, + + -- Out of the warehouse the intended (and only) road is THROUGH the dept + -- store basement: EVENT_RECEIVED_CARD_KEY's MAPCALLBACK_TILES clears the + -- box pile at B1F block (8,2) (GoldenrodDeptStoreB1F.asm), joining the + -- warehouse-stairs pocket to the elevator. That changeblock is invisible to + -- the static region graph -- its B1F region 2 lists only the way back -- so + -- travel alone loops the switch-room pocket and falls back to TELEPORT. + -- Ride the elevator to 1F and leave by the front door. + { id = "12.27", map = "GOLDENROD_DEPT_STORE_B1F", op = "travel", + noHeal = true }, + { id = "12.27b", map = "GOLDENROD_DEPT_STORE_ELEVATOR", op = "travel", + noHeal = true }, + { id = "12.27c", map = "GOLDENROD_DEPT_STORE_ELEVATOR", op = "elevator", + x = 3, y = 0, floor = "1F", doorX = 1, doorY = 3, + to = "GOLDENROD_DEPT_STORE_1F" }, + { id = "12.28", map = "RADIO_TOWER_3F", op = "travel" }, + { id = "12.29", map = "RADIO_TOWER_3F", op = "talk", x = 14, y = 2, + facings = { "up" }, budget = 6000, + expect = "EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER" }, + { id = "12.g", map = "ROUTE_35", op = "grind", level = 45, lead = true }, + -- 5F is two regions that never touch: region 1 (35 cells, warp 1 from the + -- west stairs) and region 2 (44 cells, warp 2 from 4F's east stairs). The + -- boss coord at (16,5) is in region 2, and the only way onto that half is + -- 3F warp 3 (behind the Card Key shutter) -> 4F region 2 -> 5F warp 2. + -- A plain travel lands at 5F (0,0) in region 1, where (16,5) has no path. + { id = "12.32", map = "RADIO_TOWER_5F", op = "travel", region = 2 }, + { id = "12.33", map = "RADIO_TOWER_5F", op = "walk", x = 16, y = 5, + budget = 20000, region = 2, expect = "EVENT_CLEARED_RADIO_TOWER", + note = "the script walks the player two cells left, then EXECUTIVEM_1", + retryFrom = "12.32", retryLimit = 5 }, + + -- ========================================================================= + -- Section 13 : the Ice Path, Clair and the Rising Badge + -- ========================================================================= + + { id = "13.1", map = "MAHOGANY_TOWN", op = "travel" }, + { id = "13.1b", map = "MAHOGANY_TOWN", op = "heal" }, + { id = "13.9", map = "ICE_PATH_1F", op = "travel" }, + { id = "13.10", map = "ICE_PATH_1F", op = "talk", x = 31, y = 7, + expect = "EVENT_GOT_HM07_WATERFALL" }, + -- Same mule as 11.34b: the POLIWAG/POLIWHIRL from 07.30. + { id = "13.10b", map = "ICE_PATH_1F", op = "teach", move = "WATERFALL" }, + { id = "13.11", map = "ICE_PATH_B1F", op = "travel" }, + + -- Four boulders onto four holes (stonetable warp N+2). Each straight shove + -- of N cells uses count = 2*N - 1: the player does not move with the rock, so + -- every other press is the step into the cell it vacated (same pairing + -- Cianwood's 08.65* rows use). A hole-ending segment stops on the falling + -- push so the bot does not walk into the pit afterward. + -- + -- Order matters: the spawn boulders seal each other's stand cells, and the + -- old single-row "push up 5 from spawn" rows could not even stand south of + -- boulder 1. Solved against the extracted collision + player reachability. + + -- B3 (8,9) -> hole (5,12) = EVENT_BOULDER_IN_ICE_PATH_3 + { id = "13.12a", map = "ICE_PATH_B1F", op = "push", x = 8, y = 9, dir = "right", + count = 1 }, + { id = "13.12b", map = "ICE_PATH_B1F", op = "push", x = 9, y = 9, dir = "down", + count = 11 }, + { id = "13.12c", map = "ICE_PATH_B1F", op = "push", x = 9, y = 15, dir = "left", + count = 5 }, + { id = "13.12d", map = "ICE_PATH_B1F", op = "push", x = 6, y = 15, dir = "up", + count = 1 }, + { id = "13.12e", map = "ICE_PATH_B1F", op = "push", x = 6, y = 14, dir = "left", + count = 1 }, + { id = "13.12", map = "ICE_PATH_B1F", op = "push", x = 5, y = 14, dir = "up", + count = 3, expect = "EVENT_BOULDER_IN_ICE_PATH_3" }, + + -- B4 (17,7) -> hole (12,13) = EVENT_BOULDER_IN_ICE_PATH_4 + { id = "13.13a", map = "ICE_PATH_B1F", op = "push", x = 17, y = 7, dir = "left", + count = 1 }, + { id = "13.13b", map = "ICE_PATH_B1F", op = "push", x = 16, y = 7, dir = "down", + count = 7 }, + { id = "13.13c", map = "ICE_PATH_B1F", op = "push", x = 16, y = 11, dir = "right", + count = 1 }, + { id = "13.13d", map = "ICE_PATH_B1F", op = "push", x = 17, y = 11, dir = "down", + count = 3 }, + { id = "13.13", map = "ICE_PATH_B1F", op = "push", x = 17, y = 13, dir = "left", + count = 9, expect = "EVENT_BOULDER_IN_ICE_PATH_4" }, + + -- B1 (11,7) -> hole (11,2) = EVENT_BOULDER_IN_ICE_PATH_1 + -- Approaches the hole from the west: (11,4) is wall, so the last shove is + -- right from (10,2) (see tests/drivers/gold_icepath_boulder.lua). + { id = "13.14a", map = "ICE_PATH_B1F", op = "push", x = 11, y = 7, dir = "up", + count = 3 }, + { id = "13.14b", map = "ICE_PATH_B1F", op = "push", x = 11, y = 5, dir = "left", + count = 1 }, + { id = "13.14c", map = "ICE_PATH_B1F", op = "push", x = 10, y = 5, dir = "up", + count = 5 }, + { id = "13.14", map = "ICE_PATH_B1F", op = "push", x = 10, y = 2, dir = "right", + count = 1, expect = "EVENT_BOULDER_IN_ICE_PATH_1" }, + + -- B2 (7,8) -> hole (4,7) = EVENT_BOULDER_IN_ICE_PATH_2 + { id = "13.15a", map = "ICE_PATH_B1F", op = "push", x = 7, y = 8, dir = "left", + count = 1 }, + { id = "13.15b", map = "ICE_PATH_B1F", op = "push", x = 6, y = 8, dir = "up", + count = 9 }, + { id = "13.15c", map = "ICE_PATH_B1F", op = "push", x = 6, y = 3, dir = "left", + count = 1 }, + { id = "13.15d", map = "ICE_PATH_B1F", op = "push", x = 5, y = 3, dir = "down", + count = 7 }, + { id = "13.15", map = "ICE_PATH_B1F", op = "push", x = 5, y = 7, dir = "left", + count = 1, expect = "EVENT_BOULDER_IN_ICE_PATH_2" }, + + { id = "13.26", map = "BLACKTHORN_CITY", op = "travel" }, + { id = "13.27", map = "BLACKTHORN_CITY", op = "heal" }, + -- Two FULL RESTOREs for Clair. Her lead DRAGONAIR opens with THUNDER WAVE, + -- and a paralyzed Fire lead loses the KINGDRA war (half speed, quarter of + -- its turns skipped, super-effective SURF, plus Clair's own HYPER POTION). + -- FULL RESTORE is the only status cure the port implements as a battle item, + -- and A.statusCure spends one to un-paralyse -- after which a level-58 + -- TYPHLOSION outspeeds and wins. Bought at Blackthorn's mart; the Elite + -- Four buys its own later, so this does not raid that budget. + { id = "13.26b", map = "BLACKTHORN_MART", op = "buy", item = "FULL_RESTORE", + count = 2, optional = true }, + -- 52 lead: the fighter usually arrives higher from the switch grinds, and + -- 13.g re-checks as satisfied then. The Clair retry loop cannot grow the + -- party (same reason), so the real lever is the paralysis cure above. + { id = "13.g", map = "ROUTE_45", op = "grind", level = 52, lead = true }, + { id = "13.28", map = "BLACKTHORN_GYM_1F", op = "travel" }, + -- Clair's island is its own region on 1F. EVENT_BOULDER_IN_BLACKTHORN_GYM_1 + -- and _3 (with _2 for the full intended bridge) are painted by the 1F + -- MAPCALLBACK_TILES when the matching 2F boulders fall; without them the + -- right-stairs landing never reaches (5,3). + { id = "13.28b", map = "BLACKTHORN_GYM_2F", op = "travel" }, + + -- Cody and Fran FIRST, then heal, then run the boulders in one unbroken + -- visit. The full run wiped the lead on their sight lines mid-sequence, + -- the between-rows auto-heal left the gym, and the map reload respawned + -- every unfallen boulder under the remaining rows -- 13.29c/13.29a2 then + -- had no stand cell. Beaten trainers stay beaten, so the fights are safe + -- to take early; noHeal below keeps the sequence unbroken. + { id = "13.28c", map = "BLACKTHORN_GYM_2F", op = "battle", x = 4, y = 1, + talk = true, budget = 10000, optional = true, + expect = "EVENT_BEAT_COOLTRAINERM_CODY" }, + { id = "13.28d", map = "BLACKTHORN_GYM_2F", op = "battle", x = 4, y = 11, + talk = true, budget = 10000, optional = true, + expect = "EVENT_BEAT_COOLTRAINERF_FRAN" }, + { id = "13.28h", map = "BLACKTHORN_CITY", op = "heal" }, + { id = "13.28r", map = "BLACKTHORN_GYM_2F", op = "travel" }, + + -- Cody at (4,1) seals the top row, so the stand cell south of B2 (2,2) is + -- unreachable until B4 is parked north out of the (3,2)/(3,3) corridor. + { id = "13.29a0", map = "BLACKTHORN_GYM_2F", op = "push", x = 3, y = 3, dir = "up", + count = 3, optional = true, noHeal = true, + note = "B4 to (3,1); opens the path to (2,2)" }, + -- B2 (2,3) -> (2,4), then into hole (2,5) after B1 (order from the solver) + { id = "13.29a", map = "BLACKTHORN_GYM_2F", op = "push", x = 2, y = 3, dir = "down", + count = 1, optional = true, noHeal = true }, + -- B5 (6,1) park. optional so a Clair retry can soft-miss. + { id = "13.29b", map = "BLACKTHORN_GYM_2F", op = "push", x = 6, y = 1, dir = "right", + count = 5, optional = true, noHeal = true }, + -- B6 (8,14) clears the south corridor for B3. + { id = "13.29d", map = "BLACKTHORN_GYM_2F", op = "push", x = 8, y = 14, dir = "down", + count = 5, optional = true, noHeal = true }, + -- B3 (6,16) up to (6,7), then one right to (7,7) while B1/B2 finish. + { id = "13.29e", map = "BLACKTHORN_GYM_2F", op = "push", x = 6, y = 16, dir = "up", + count = 17, optional = true, noHeal = true }, + { id = "13.29e2", map = "BLACKTHORN_GYM_2F", op = "push", x = 6, y = 7, dir = "right", + count = 1, optional = true, noHeal = true }, + -- B1 (8,2) -> hole (8,3) + { id = "13.29c", map = "BLACKTHORN_GYM_2F", op = "push", x = 8, y = 2, dir = "down", + count = 1, noHeal = true, expect = "EVENT_BOULDER_IN_BLACKTHORN_GYM_1" }, + -- B2 (2,4) -> hole (2,5) + { id = "13.29a2", map = "BLACKTHORN_GYM_2F", op = "push", x = 2, y = 4, dir = "down", + count = 1, noHeal = true, expect = "EVENT_BOULDER_IN_BLACKTHORN_GYM_2" }, + -- B3 (7,7) -> hole (8,7) + { id = "13.29f", map = "BLACKTHORN_GYM_2F", op = "push", x = 7, y = 7, dir = "right", + count = 1, noHeal = true, expect = "EVENT_BOULDER_IN_BLACKTHORN_GYM_3" }, + + -- Come down the RIGHT stairs so we land in the bridged region. The door + -- from the city is the OTHER region and cannot reach Clair even with every + -- bridge painted -- only warp 2 / hole landings 6 and 7 can. + { id = "13.29g", map = "BLACKTHORN_GYM_2F", op = "warp", x = 7, y = 9, + to = "BLACKTHORN_GYM_1F", noHeal = true }, + -- Clair is the hardest single fight in the Johto half for a mono-Fire lead: + -- a bulky Water/Dragon KINGDRA that resists Fire and SURFs super-effectively, + -- THUNDER WAVE paralysis off her DRAGONAIRs, and her own HYPER POTION. A + -- level-58 TYPHLOSION wins it about one lap in five, so the lever is laps. + -- retryFrom the FULL RESTORE buy so a paralysis cure is restocked while + -- there is money (a lost lap halves it -- Gen 2 -- so the cure mostly lands + -- on the first attempt); verified beaten within budget from RESUME=13. + -- retryLimit 26, not 12: each lost lap KEEPS the EXP from beating her three + -- DRAGONAIRs before KINGDRA (resolveFaints awards it before the loss check), + -- so the lead climbs ~1 level every ~3 laps -- but her level-37 mons give a + -- level-58 TYPHLOSION so little that 12 laps only bought +3 (to 61), short of + -- what out-races KINGDRA + her HYPER POTION under paralysis. ~26 laps buys + -- ~+8, which wins reliably; most runs land it in well under a dozen and stop + -- early. (This is the same lose-to-grind loop the Elite Four rides, only + -- against much weaker mons, hence the higher count.) + { id = "13.30", map = "BLACKTHORN_GYM_1F", op = "battle", x = 5, y = 3, + talk = true, budget = 16000, expect = "EVENT_BEAT_CLAIR", + retryFrom = "13.26b", retryLimit = 26 }, + + -- The badge is not Clair's to give until the Dragon's Den test: the Dragon + -- Fang at the shrine is what sets RISINGBADGE. + { id = "13.32", map = "DRAGONS_DEN_B1F", op = "travel" }, + { id = "13.33", map = "DRAGONS_DEN_B1F", op = "talk", x = 35, y = 16, + budget = 12000, expect = "EVENT_DRAGONS_DEN_B1F_DRAGON_FANG" }, + { id = "13.33b", map = "DRAGONS_DEN_B1F", op = "check", + expect = "ENGINE_RISINGBADGE" }, + + -- ========================================================================= + -- Section 16 : east to Kanto's door + -- ========================================================================= + -- Eight badges, SURF, WATERFALL and WHIRLPOOL are all in hand by here, which + -- is what makes Route 27 and Tohjo Falls crossable at all. + + -- Waypointed through Route 46 so the Route 45 descent is two short legs, not + -- one Blackthorn -> New Bark plan that flip-flopped at the seam. The + -- crossing itself needed the edgeTarget fix: coming onto Route 45's top strip + -- (region 3), the west border cells nearest the landing are region 1/2 and + -- unreachable, and the old nearest-16 reachability cap never scanned down to + -- region 3's own Route 46 cells (Bot:edgeTarget). + { id = "16.22a", map = "ROUTE_46", op = "travel" }, + { id = "16.22b", map = "ROUTE_29", op = "travel" }, + { id = "16.23", map = "NEW_BARK_TOWN", op = "travel" }, + { id = "16.24", map = "ELMS_LAB", op = "travel", optional = true }, + { id = "16.24b", map = "ELMS_LAB", op = "talk", x = 5, y = 2, optional = true, + expect = "EVENT_GOT_MASTER_BALL_FROM_ELM" }, + { id = "16.27", map = "ROUTE_27", op = "travel" }, + { id = "16.28", map = "ROUTE_27", op = "settle", budget = 8000, + note = "FirstStepIntoKantoScene is unskippable" }, + -- Route 27 is split by water and by TOHJO FALLS: the west half (where the + -- New Bark crossing lands, itself over water -- asm-walk map row 4, "the + -- crossing itself is water, so SURF") does not touch the east edge that + -- connects to Route 26. The intended path is through the falls -- surf to + -- warp 2, climb inside with WATERFALL, exit warp 3 onto the east half -- and + -- the static region graph is foot-only, so `travel ROUTE_26` from the west + -- tries the unreachable east edge and oscillates into a teleport. Naming + -- TOHJO_FALLS forces the surf-and-climb; the east half then reaches Route 26. + { id = "16.42", map = "TOHJO_FALLS", op = "travel" }, + -- Leave by the EAST warp (25,15 -> Route 27 warp 3), not the west one we came + -- in through. Reaching it means climbing the falls inside -- warp 1's pool + -- (region 6) and warp 2's ledge (region 7) touch only up the WATERFALL -- so + -- this exercises the HM07 climb. A plain `travel ROUTE_27` picked the nearest + -- warp, which was the west one straight back into the pocket. + { id = "16.42b", map = "TOHJO_FALLS", op = "warp", x = 25, y = 15, + to = "ROUTE_27" }, + { id = "16.43", map = "ROUTE_26", op = "travel" }, + { id = "16.54", map = "VICTORY_ROAD_GATE", op = "travel" }, + { id = "16.55", map = "VICTORY_ROAD_GATE", op = "walk", x = 10, y = 11, + budget = 6000, note = "the badge check; eight badges walks straight through" }, + + -- ========================================================================= + -- Section 17 : Victory Road + -- ========================================================================= + + { id = "17.2", map = "VICTORY_ROAD", op = "travel" }, + { id = "17.11", map = "VICTORY_ROAD", op = "walk", x = 13, y = 8, + budget = 16000, expect = "EVENT_RIVAL_VICTORY_ROAD", + note = "the rival ambush; there is no way past it" }, + { id = "17.15", map = "ROUTE_23", op = "travel" }, + + -- ========================================================================= + -- Section 18 : the Pokemon League + -- ========================================================================= + -- One-way. Every room seals behind you and the Pokecenter's own + -- MAPCALLBACK_NEWMAP wipes every EVENT_BEAT_ELITE_4_*, so a heal after Will + -- restarts the gauntlet -- which is why the only heal row is before the door. + + { id = "18.4", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "travel" }, + -- The gauntlet is five fights with no heal between them (the Pokecenter's + -- MAPCALLBACK_NEWMAP wipes every EVENT_BEAT_ELITE_4_*), so everything the + -- party will ever have has to be bought and earned before the door. Run 13 + -- earned all eight badges and then lost Koga and Bruno on level and + -- attrition alone. + -- Grind on VICTORY_ROAD, not Route 26, and only the lead. + -- + -- Johto's wilds cap around level 30, so a target of 78 on Route 26's + -- low-twenties encounters is a number the bot can never reach -- it spent + -- 160k frames gaining nothing and then failed on its wipe budget. Victory + -- Road is the highest-level ground actually reachable before the door. + -- The party-minimum grind is dropped here on purpose: it rotates the WEAKEST + -- mon to the front, and at this point in the game that mon is wiped by the + -- wilds faster than it can earn anything. + { id = "18.g", map = "VICTORY_ROAD", op = "grind", level = 62, lead = true, + wipeBudget = 8 }, + -- Cheap heals FIRST, and only then the expensive one. + -- + -- This bought FULL_RESTORE first at Y3000 each, which with a lap's ~Y9000 + -- meant a bag of exactly three items and nothing left for HYPER_POTIONs. The + -- bot then spent all three topping up in Will's, Koga's and Bruno's rooms and + -- walked into LANCE with an empty bag -- it was killing five of his six and + -- losing to the last DRAGONITE with no answer. Five HYPER_POTIONs (Y6000) and + -- one FULL_RESTORE (Y3000) is the same money for twice the heals, and it is + -- the shape A.bestHeal's rationing wants: something cheap to spend between + -- 0.35 and 0.6, and one good thing held back for below it. + { id = "18.g3", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "buy", + item = "HYPER_POTION", count = 4, optional = true }, + { id = "18.g4", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "buy", + item = "FULL_RESTORE", count = 4, optional = true }, + { id = "18.g5", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "buy", + item = "REVIVE", count = 10, optional = true }, + { id = "18.4b", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "travel" }, + { id = "18.4c", map = "INDIGO_PLATEAU_POKECENTER_1F", op = "heal" }, + + -- Every fight from here to the Champion carries `retryFrom = "18.g3"`. + -- + -- Losing costs half the money and nothing else -- the experience earned on + -- the way to the wipe is kept -- so a lost gauntlet is not a dead end, it is + -- a grind that pays better than any grass in the game. Will's mons are in + -- the forties; a lap that dies to Bruno still banks Xatu, Jynx, Exeggutor, + -- Slowbro, Ariados, Venomoth, Muk, Forretress and Hitmontop. + -- + -- The rewind lands on 18.g3 (the shop) rather than on Will's door because + -- IndigoPlateauPokecenter1F's MAPCALLBACK_NEWMAP clears all five BEAT flags + -- AND all ten room ENTRANCE/EXIT flags, so after a wipe the gauntlet really + -- does start over at Will -- and the restock and heal have to happen again + -- with it. See maps/IndigoPlateauPokecenter1F.asm. + { id = "18.7", map = "WILLS_ROOM", op = "travel" }, + { id = "18.8", map = "WILLS_ROOM", op = "settle", budget = 6000, + expect = "EVENT_WILLS_ROOM_ENTRANCE_CLOSED", retryFrom = "18.g3" }, + { id = "18.9", map = "WILLS_ROOM", op = "battle", x = 5, y = 7, talk = true, + budget = 20000, expect = "EVENT_BEAT_ELITE_4_WILL", retryFrom = "18.g3" }, + + { id = "18.11", map = "KOGAS_ROOM", op = "travel" }, + { id = "18.11b", map = "KOGAS_ROOM", op = "settle", budget = 6000 }, + { id = "18.11c", map = "KOGAS_ROOM", op = "battle", x = 5, y = 7, talk = true, + budget = 20000, expect = "EVENT_BEAT_ELITE_4_KOGA", retryFrom = "18.g3" }, + + { id = "18.13", map = "BRUNOS_ROOM", op = "travel" }, + { id = "18.13b", map = "BRUNOS_ROOM", op = "settle", budget = 6000 }, + -- 18 laps, not the default 12: Bruno is the wall when the party arrives + -- light, and a lost gauntlet lap is the best-paying grind in the game -- + -- the extra laps cost frames, running out of them costs the run. + { id = "18.13c", map = "BRUNOS_ROOM", op = "battle", x = 5, y = 7, talk = true, + budget = 20000, expect = "EVENT_BEAT_ELITE_4_BRUNO", retryFrom = "18.g3", + retryLimit = 18 }, + + { id = "18.15", map = "KARENS_ROOM", op = "travel" }, + { id = "18.15b", map = "KARENS_ROOM", op = "settle", budget = 6000 }, + { id = "18.15c", map = "KARENS_ROOM", op = "battle", x = 5, y = 7, talk = true, + budget = 20000, expect = "EVENT_BEAT_ELITE_4_KAREN", retryFrom = "18.g3" }, + + { id = "18.17", map = "LANCES_ROOM", op = "travel" }, + { id = "18.17b", map = "LANCES_ROOM", op = "settle", budget = 8000, + expect = "EVENT_LANCES_ROOM_ENTRANCE_CLOSED", retryFrom = "18.g3" }, + -- The coord event at (4,5) is the Champion fight; the script walks the + -- player in, so this is a walk row rather than a talk. + -- No `expect` on the walk itself, and this is not laziness. + -- + -- LancesRoom.asm sets EVENT_BEAT_CHAMPION_LANCE, prints two more pages of + -- text, opens the door with `changeblock` and only then lets the player + -- north. The bot walks through that door the moment it opens, so the `walk` + -- op ends with "left LANCES_ROOM for HALL_OF_FAME" -- and a postcondition + -- checked at that instant raced the script. Run 20 beat Lance ("CHAMPION + -- LANCE was defeated!", ¥5000 prize) NINE times and the row failed all nine, + -- each time rewinding the bot back out of the Hall of Fame to refight the + -- whole gauntlet. The win was real every time; the oracle was wrong. + -- + -- So the walk just triggers the fight, and the finish line is measured where + -- it actually happens: standing in the HALL OF FAME with the flag its own + -- script sets. + { id = "18.18", map = "LANCES_ROOM", op = "walk", x = 4, y = 5, + budget = 30000, expect = "EVENT_BEAT_CHAMPION_LANCE", + retryFrom = "18.g3" }, + -- No row here with `map = "HALL_OF_FAME"` that is allowed to fail softly. + -- One was tried and it manufactured a false win: the runner reaches a row's + -- map before running it, could not walk to the Hall of Fame from the + -- Pokecenter it had just whited out to, and fell back to the harness + -- TELEPORT shortcut -- so a run that LOST to Lance still finished with + -- "final map: HALL_OF_FAME", and 18.20 then read `already satisfied` off an + -- EVENT_BEAT_ELITE_FOUR left over in the checkpoint. 18.18 is the gate. + { id = "18.20", map = "HALL_OF_FAME", op = "settle", budget = 20000, + expect = "EVENT_BEAT_ELITE_FOUR", + note = "Lance's speech, the roster, the credits: the finish line" }, +} diff --git a/tests/drivers/gold_battle_anim_shots.lua b/tests/drivers/gold_battle_anim_shots.lua new file mode 100644 index 00000000..360d6185 --- /dev/null +++ b/tests/drivers/gold_battle_anim_shots.lua @@ -0,0 +1,92 @@ +-- Contact sheet: the Gen 2 battle-animation runtime, frame by frame. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_anim_shots.lua love . +-- +-- Shoots the 72-frame intro slide and then a run of move animations, one shot +-- every few frames, into /tmp/gold-anims. This is the only way to check the +-- object functions: they are pure arithmetic on byte fields and a test can say +-- "the struct moved", but only a picture says the flame went the right way. +-- +-- POKEPORT_ANIM_MOVES=TACKLE,EMBER picks the moves; the default set covers one +-- animation from each family the runtime has to get right (a straight throw, +-- a spiral, a screen shake, a per-scanline sink, a palette cycle). +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local DEFAULT_MOVES = { + "TACKLE", "EMBER", "WATER_GUN", "THUNDERSHOCK", "RAZOR_LEAF", + "EARTHQUAKE", "WITHDRAW", "DIG", "SING", "ABSORB", + -- The screen-wide deformations, which were no-ops until the sixth pass: + -- rolling water, a warp, an afterimage and a melt. + "SURF", "WHIRLPOOL", "PSYCHIC_M", "TELEPORT", "NIGHT_SHADE", + "DOUBLE_TEAM", "ACID_ARMOR", +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-anims" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "4") + + local moves = {} + local requested = os.getenv("POKEPORT_ANIM_MOVES") + if requested then + for name in requested:gmatch("[^,]+") do moves[#moves + 1] = name end + else + moves = DEFAULT_MOVES + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "CYNDAQUIL", 30) + assert(player, "could not build a CYNDAQUIL") + game.save.party = { player } + local wild = Mon.new(game.data, "PIDGEY", 30) + assert(world:startBattle({ wild = wild }), "startBattle failed") + -- DoBattleTransition owns the screen first now; wait it out. + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle and battle.battle, "battle screen is not on the stack") + assert(battle.anims and battle.anims.scripts, + "battle_anims.lua has no scripts -- re-import Gold") + + -- The intro slide, which runs before any input is read. + for frame = 0, 72, 6 do + U.shot(game, ("%s/00-slide-%02d.png"):format(out, frame)) + U.wait(6) + end + + -- Then each move's own animation, started directly rather than through the + -- menu so the shot numbering stays predictable. + local missing = {} + for index, move in ipairs(moves) do + battle.anim = nil + if not battle:animForMove(move, "player") then + missing[#missing + 1] = move + else + -- BattleState:update steps the runner itself, so the driver only waits + -- and shoots; stepping here too would run it at double speed. + local shot = 0 + while battle.anim and shot < 400 do + if shot % interval == 0 then + U.shot(game, ("%s/%02d-%s-%03d.png"):format(out, index, move, shot)) + end + shot = shot + 1 + U.wait(1) + end + assert(shot < 400, move .. " never finished") + print(("[driver] %-14s %d frames"):format(move, shot)) + end + end + + if #missing > 0 then + print("[driver] no animation for: " .. table.concat(missing, ", ")) + end + print("[driver] PASS gold battle anims in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_battle_items.lua b/tests/drivers/gold_battle_items.lua new file mode 100644 index 00000000..92469b53 --- /dev/null +++ b/tests/drivers/gold_battle_items.lua @@ -0,0 +1,151 @@ +-- Assertion driver: the PACK inside a real battle, driven with button taps. +-- It PASSES or it errors; there is nothing to eyeball. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_items.lua love . +-- +-- tests/gen2_battle_items_test.lua proves each item_effects.asm family over +-- fixtures by calling BattleState:useItem; what it cannot prove is the link in +-- front of it -- BattleMenu's PACK row opening the real pack over a real +-- battle, the real "Use on which <PK><MN>?" list on top of that, and the pick +-- landing on a BENCHED mon. So this revives a fainted party member from +-- inside a wild battle with nothing but taps (ReviveEffect through +-- UseItem_SelectMon), then spends an ETHER through the move list +-- (RestorePPEffect's MoveSelectionScreen pick). +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + local lead = Mon.new(game.data, "CYNDAQUIL", 12) + local bench = Mon.new(game.data, "TOTODILE", 12) + assert(lead and bench, "the cache carries no starters to seed a party") + bench.hp = 0 + bench.status = "faint" + save.party = { lead, bench } + save.inventory = { REVIVE = 1, ETHER = 1 } + + local function battleScreen() + local top = game.stack:top() + return (top and top.battle) and top or nil + end + + local function tapUntil(predicate, tries, btn) + for _ = 1, tries or 400 do + if predicate() then return true end + U.tap(game, btn or "a") + U.wait(2) + end + return predicate() + end + + local wild = Mon.new(game.data, "PIDGEY", 3) + assert(wild, "the cache carries no PIDGEY") + assert(world:startBattle({ wild = wild }), "the wild battle refused to start") + assert(tapUntil(function() + local screen = battleScreen() + return screen ~= nil and screen.phase == "menu" + end), "the battle never reached BattleMenu") + local screen = battleScreen() + + -- BattleMenuHeader's 2x2 grid, filled row-major: 1 FIGHT / 2 PkMn on top, + -- 3 PACK / 4 RUN below. LEFT swaps an even column to its odd neighbour and + -- DOWN swaps the row, so those two presses reach PACK from any cursor. + local function openPack() + for _ = 1, 300 do + if screen.phase == "moves" then + U.tap(game, "b") + U.wait(2) + end + if screen.phase == "menu" then break end + U.wait(1) + end + assert(screen.phase == "menu", "the battle menu never came back") + if screen.menuIndex % 2 == 0 then + U.tap(game, "left") + U.wait(3) + end + if screen.menuIndex <= 2 then + U.tap(game, "down") + U.wait(3) + end + assert(screen.menuIndex == 3, + "the cursor sat on menu slot " .. tostring(screen.menuIndex)) + U.tap(game, "a") + U.wait(4) + local pack = game.stack:top() + assert(pack and pack.rows, "the battle PACK did not open") + return pack + end + + -- ---- REVIVE on the fainted BENCHED mon ---------------------------------- + + local pack = openPack() + local reviveRow + for index, row in ipairs(pack.rows) do + if row.id == "REVIVE" then reviveRow = index end + end + assert(reviveRow, "the battle PACK does not show the REVIVE") + for _ = 2, reviveRow do + U.tap(game, "down") + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + local party = game.stack:top() + assert(party and party.prompt, "the REVIVE did not open UseItem_SelectMon") + -- Down to the second slot, which is the fainted one, then take it. + U.tap(game, "down") + U.wait(2) + U.tap(game, "a") + U.wait(6) + local half = math.max(1, math.floor((bench.maxHp or bench.stats.hp) / 2)) + assert(bench.hp == half, + "the REVIVE left the benched mon at " .. tostring(bench.hp) + .. ", not ReviveHalfHP's " .. half) + assert(save.inventory.REVIVE == nil, "the REVIVE was not consumed") + U.log("PASS battle pack: REVIVE stands a BENCHED mon up mid-battle") + + -- ---- ETHER through the move list ---------------------------------------- + + assert(tapUntil(function() + return screen.phase == "menu" or screen.phase == "moves" + end), "the revive turn never drained back to the menu") + local slot = lead.moves and lead.moves[1] + assert(slot, "the lead mon knows no moves") + slot.pp = math.max(0, (slot.maxPp or slot.pp or 10) - 12) + local before = slot.pp + + local ppPack = openPack() + local etherRow + for index, row in ipairs(ppPack.rows) do + if row.id == "ETHER" then etherRow = index end + end + assert(etherRow, "the battle PACK does not show the ETHER") + for _ = 2, etherRow do + U.tap(game, "down") + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + local pickMon = game.stack:top() + assert(pickMon and pickMon.prompt, "the ETHER did not open the party list") + U.tap(game, "a") + U.wait(4) + local moveList = game.stack:top() + assert(moveList and moveList ~= pickMon and moveList.list, + "the ETHER did not open the move list") + U.tap(game, "a") + U.wait(6) + assert(slot.pp == math.min(slot.maxPp or slot.pp, before + 10), + "the ETHER restored " .. tostring(slot.pp - before) .. " PP, not 10") + assert(save.inventory.ETHER == nil, "the ETHER was not consumed") + U.log("PASS battle pack: ETHER restores PP through the move list") + + U.log("PASS gold_battle_items") + love.event.quit() +end diff --git a/tests/drivers/gold_battle_pack_probe.lua b/tests/drivers/gold_battle_pack_probe.lua new file mode 100644 index 00000000..f6ef134e --- /dev/null +++ b/tests/drivers/gold_battle_pack_probe.lua @@ -0,0 +1,92 @@ +-- Probe: the PACK opened from a real Gold battle menu, on a real overworld. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_pack_probe.lua love . +-- +-- BattlePack (engine/items/pack.asm) is a different jumptable from the field +-- PACK's, and its first four entries are .Oak: a key item picked mid-fight +-- prints OakThisIsntTheTimeText inside the pack. Nothing here may reach the +-- field jumptable, whose ITEMFINDER arm quits the PACK -- over a battle that +-- takes the battle off the stack with it. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-pack" + + 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 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3, ITEMFINDER = 1, + NORMAL_BOX = 1 } + + local wild = Mon.new(game.data, "PIDGEY", 4) + assert(world:startBattle({ wild = wild }), "startBattle failed") + + -- DoBattleTransition owns the screen first; wBattleMode is only set when the + -- battle screen itself goes on the stack. + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle, "battle screen never came up") + assert(world.battleActive, "the world is not marked as in a battle") + for _ = 1, 240 do + if battle.phase == "menu" then break end + tap("a", 3) + end + assert(battle.phase == "menu", "never reached the battle menu") + + -- The 2x2 grid: DOWN puts the cursor on PACK. + tap("down") + tap("a") + local pack = game.stack:top() + assert(getmetatable(pack) == PackMenu, "PACK did not open the pack") + assert(pack:inBattle(), "the battle pack is not flagged as BattlePack") + + -- KEY ITEMS, then A on the ITEMFINDER. + tap("right") + tap("right") + assert(pack:pocket().id == "KEY_ITEM", + "did not reach the KEY ITEMS pocket: " .. tostring(pack:pocket().id)) + assert(pack.rows[1], "the key items pocket is empty") + print("[driver] key item row 1 " .. tostring(pack.rows[1].id)) + tap("a") + U.wait(4) + U.shot(game, out .. "/00-battle-pack-oak.png") + + assert(pack.message and pack.message[1] == "OAK: {PLAYER}!", + "the ITEMFINDER did not print OakThisIsntTheTimeText") + assert(game.stack:top() == pack, "the pack left the stack") + assert(world.battleActive, "battleActive was cleared by a field effect") + assert(world.queuedScript == nil, "a field script was queued from a battle") + assert(game.save.inventory.ITEMFINDER == 1, "the key item was spent") + + -- B clears the message, B again closes the pack, and the battle is still + -- there underneath with its menu. + tap("b") + tap("b") + for _ = 1, 120 do + if battle.phase == "menu" then break end + U.wait(1) + end + assert(game.stack:top() == battle, "the battle is not back on top") + assert(battle.phase == "menu", "the battle menu did not come back") + U.shot(game, out .. "/01-battle-menu-back.png") + + print("[driver] PASS gold battle pack in " .. out) +end diff --git a/tests/drivers/gold_battle_rules.lua b/tests/drivers/gold_battle_rules.lua new file mode 100644 index 00000000..ffca002b --- /dev/null +++ b/tests/drivers/gold_battle_rules.lua @@ -0,0 +1,294 @@ +-- The battle RULES that a fixture test cannot see: when TRANSFORM actually +-- lands on screen, what a caught mon's record says, and that STRUGGLE, +-- MAGNITUDE, DREAM EATER, SPITE and a refused RUN behave the way +-- engine/battle/effect_commands.asm and engine/battle/core.asm say they do -- +-- all of it against the live extracted tables and the real battle screen +-- rather than a fixture. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_rules.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-rules love . +-- +-- The shots are the point of the first block: BattleState draws the enemy pic +-- from whichever mon its `shownMon` slot holds, and that slot follows the +-- EVENT QUEUE. Battle:takeTurn resolves a whole round up front, so anything +-- the rules write straight into the mon record is on screen a beat before its +-- own message -- which is what "DITTO transformed at the beginning of its +-- turn" looked like. 01-submitted.png is taken one frame after the move is +-- submitted and before any message has been drained, and 02-transformed.png +-- once TRANSFORM's own line has been read: the two shots are what the pic +-- timing has to be judged on, and the driver PRINTS which one the swap landed +-- on rather than asserting it, because the rules half of that (the `transform` +-- event, and the pre-transform record kept beside it) is all this side of the +-- seam owns -- src/ui/gen2/BattleState.lua owns `shownMon`. +-- +-- What the driver does assert is every rule: the transform is undone on the +-- way out of the battle (a caught DITTO is a DITTO), STRUGGLE's damage and its +-- quarter-damage recoil, MAGNITUDE's rolled power, DREAM EATER's checkhit gate +-- and SPITE's PP drain, and a RUN refused by a trainer battle costing nothing. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local function battleScreen(game) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Drain the screen's own queue: press A until it is asking for a move again. +local function drain(game, screen, frames) + for _ = 1, (frames or 400) do + if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false +end + +local function newWild(game, species, level, moves) + local mon = Mon.new(game.data, species, level) + if moves then + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + end + return mon +end + +local function giveMoves(mon, game, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-rules" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local failures = {} + local function check(ok, what) + print((ok and "[ok] " or "[FAIL] ") .. what) + if not ok then failures[#failures + 1] = what end + end + + -- ------------------------------------------------------------------ 1 + 3 + -- A wild DITTO whose only move is TRANSFORM, so the AI cannot pick anything + -- else, and a player mon slow enough that the DITTO moves second. + local player = Mon.new(game.data, "SNORLAX", 30) + giveMoves(player, game, { "TACKLE" }) + game.save.party = { player } + game.save.inventory = { MASTER_BALL = 5 } + local ditto = newWild(game, "DITTO", 20, { "TRANSFORM" }) + assert(world:startBattle({ wild = ditto }), "startBattle failed") + local screen = battleScreen(game) + drain(game, screen, 200) + U.shot(game, out .. "/00-menu.png") + print("[driver] enemy shown as " .. tostring(screen:activeMon("enemy") + and screen:activeMon("enemy").species)) + + screen:submit({ kind = "move", move = "TACKLE" }) + local sawTransformEvent = false + for _, event in ipairs(screen.queue) do + if event.kind == "transform" then sawTransformEvent = true end + end + U.wait(1) + U.shot(game, out .. "/01-submitted.png") + local shownAtSubmit = screen:activeMon("enemy") + print("[driver] pic timing: at submit the enemy draws as " + .. tostring(shownAtSubmit and shownAtSubmit.species) + .. " (01-submitted.png)") + check(sawTransformEvent, + "the round carries a `transform` event for the screen to swap its pic on") + + -- The frame the bug report is about: the player's own move is still on + -- screen, its animation has finished and the enemy pic is being drawn again + -- -- and the mon it is drawn from has already been rewritten by a TRANSFORM + -- whose line has not been read yet. + U.wait(120) + U.shot(game, out .. "/01z-before-transform-line.png") + print("[driver] with the box still reading " + .. tostring(screen.message and screen.message:gsub("\n", " / ")) + .. " the enemy pic is drawn from " + .. tostring(screen:activeMon("enemy") and screen:activeMon("enemy").species) + .. " (01z-before-transform-line.png)") + + -- One shot per message on the way through the round, so the frame where the + -- pic stops being a DITTO can be pointed at rather than described. + for step = 1, 6 do + U.tap(game, "a") + U.wait(24) + U.shot(game, out .. ("/01%s-step.png"):format(string.char(96 + step))) + print("[driver] step " .. step .. " message: " + .. tostring(screen.message and screen.message:gsub("\n", " / "))) + end + drain(game, screen, 400) + U.shot(game, out .. "/02-transformed.png") + local shownAfter = screen:activeMon("enemy") + print("[driver] pic timing: after TRANSFORM's own line it draws as " + .. tostring(shownAfter and shownAfter.species) .. " (02-transformed.png)") + check(ditto.species == "SNORLAX", + "the rules half lands at once: the battler IS the copy for the rest of " + .. "the round") + check(screen.battle:volatile(ditto).preTransform + and screen.battle:volatile(ditto).preTransform.species == "DITTO", + "and the record it was is kept for the reload") + + -- The catch: PokeBallEffect reloads the caught mon out of its base data + -- (item_effects.asm `.catch_without_fail` reads wTempEnemyMonSpecies), so a + -- transformed DITTO is caught as a DITTO. A MASTER BALL so the roll is not + -- part of what is being tested. + screen:useItem("MASTER_BALL") + for _ = 1, 400 do + if game.save.party[2] then break end + U.tap(game, "a") + U.wait(3) + end + U.shot(game, out .. "/03-caught.png") + local caught = game.save.party[2] + check(caught ~= nil, "the DITTO was caught into the party") + -- CleanUpBattleRAM (BattleState:finishBattle -> clearAllVolatiles) is where + -- the reload lands, so the record is judged once the battle is off the + -- stack -- which is also the last moment before the overworld and the next + -- save write see it. + -- B, not A: the capture ends on AskGiveNicknameText, and answering NO is + -- what walks the screen through to ExitBattle instead of parking it in the + -- naming screen (which sits ON TOP of the battle, so "the battle is not the + -- top of the stack" is not the same as "the battle is over"). + local done = false + for _ = 1, 900 do + if screen.phase == "done" then done = true break end + U.tap(game, "b") + U.wait(3) + end + U.wait(30) + print(("[driver] battle finished=%s phase=%s"):format(tostring(done), + tostring(screen.phase))) + check(done, "the battle screen reached ExitBattle") + if caught then + print("[driver] caught record: species=" .. tostring(caught.species) + .. " move1=" .. tostring(caught.moves and caught.moves[1] + and caught.moves[1].id)) + check(caught.species == "DITTO", + "the caught record is the real DITTO (was " + .. tostring(caught.species) .. ")") + check(caught.moves and caught.moves[1] + and caught.moves[1].id == "TRANSFORM", + "and it kept its own move list") + end + + -- ---------------------------------------------------------------------- 2 + -- STRUGGLE: real damage, then a quarter of it back (BattleCommand_Recoil). + local struggler = Mon.new(game.data, "RATTATA", 30) + giveMoves(struggler, game, { "TACKLE" }) + struggler.moves[1].pp = 0 + game.save.party = { struggler } + local target = newWild(game, "SNORLAX", 30, { "SPLASH" }) + assert(world:startBattle({ wild = target }), "startBattle failed") + screen = battleScreen(game) + drain(game, screen, 200) + local foeBefore, mineBefore = target.hp, struggler.hp + screen:submit({ kind = "move", move = "TACKLE" }) + drain(game, screen, 400) + U.shot(game, out .. "/04-struggle.png") + local dealt = foeBefore - target.hp + local recoil = mineBefore - struggler.hp + print(("[driver] STRUGGLE dealt %d and recoiled %d"):format(dealt, recoil)) + check(dealt > 5, "STRUGGLE deals its 50 power, not chip damage") + check(recoil == math.max(1, math.floor(dealt / 4)), + "and the user takes a quarter of it back") + for _ = 1, 400 do + if not game.stack:top() or not game.stack:top().battle then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(60) + + -- ------------------------------------------------------------------ 4/5/6 + local caster = Mon.new(game.data, "GASTLY", 40) + giveMoves(caster, game, { "MAGNITUDE", "DREAM_EATER", "SPITE" }) + caster.hp = math.max(1, caster.hp - 20) + game.save.party = { caster } + local dummy = newWild(game, "RATTATA", 20, { "TACKLE" }) + assert(world:startBattle({ wild = dummy }), "startBattle failed") + screen = battleScreen(game) + drain(game, screen, 200) + + local before = dummy.hp + screen:submit({ kind = "move", move = "MAGNITUDE" }) + local sawMagnitude = false + for _, event in ipairs(screen.queue) do + if event.text and event.text:match("^Magnitude %d") then + sawMagnitude = true + end + end + drain(game, screen, 400) + U.shot(game, out .. "/05-magnitude.png") + check(sawMagnitude, "MAGNITUDE announces its rolled magnitude") + check(before - dummy.hp > 1, + "and hits for the rolled power, not the ROM's stored 1 (dealt " + .. tostring(before - dummy.hp) .. ")") + + local hpBefore, mineHp = dummy.hp, caster.hp + screen:submit({ kind = "move", move = "DREAM_EATER" }) + drain(game, screen, 400) + check(dummy.hp == hpBefore, + "DREAM EATER misses an awake target outright") + check(caster.hp <= mineHp, "and saps nothing from it") + + local ppBefore = dummy.moves[1].pp + screen:submit({ kind = "move", move = "SPITE" }) + drain(game, screen, 400) + U.shot(game, out .. "/06-spite.png") + print(("[driver] SPITE took the foe's TACKLE from %d to %d PP") + :format(ppBefore, dummy.moves[1].pp)) + check(dummy.moves[1].pp < ppBefore - 1, + "SPITE drains 2-5 PP off the move the target last used") + for _ = 1, 400 do + if not game.stack:top() or not game.stack:top().battle then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(60) + + -- ---------------------------------------------------------------------- 7 + -- RUN in a trainer battle: `.cant_run_from_trainer` leaves + -- wBattlePlayerAction alone and falls back into BattleMenu, so the round is + -- never spent and the trainer does not get a free swing. + local runner = Mon.new(game.data, "RATTATA", 30) + giveMoves(runner, game, { "TACKLE" }) + game.save.party = { runner } + local foe = Mon.new(game.data, "GEODUDE", 30) + giveMoves(foe, game, { "TACKLE" }) + assert(world:startBattle({ trainer = { class = "YOUNGSTER", + name = "JOEY", party = { foe } } }), "trainer startBattle failed") + screen = battleScreen(game) + drain(game, screen, 300) + local hpAtRun = runner.hp + local turnAtRun = screen.battle.turn + screen:submit({ kind = "run" }) + drain(game, screen, 300) + U.shot(game, out .. "/07-run-refused.png") + check(runner.hp == hpAtRun, + "a refused RUN costs no HP: the trainer never got a swing") + check(screen.battle.turn == turnAtRun + 1 and not screen.battle.over, + "and the battle is still running") + check(foe.hp == foe.maxHp, "nor did anything happen to the foe") + + if #failures > 0 then + for _, what in ipairs(failures) do print("[FAIL] " .. what) end + error(#failures .. " battle-rule checks failed") + end + print("[driver] all battle-rule checks passed") + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gold_battle_screen_probe.lua b/tests/drivers/gold_battle_screen_probe.lua new file mode 100644 index 00000000..ff70600b --- /dev/null +++ b/tests/drivers/gold_battle_screen_probe.lua @@ -0,0 +1,370 @@ +-- Probe: the Gold battle SCREEN, one reported symptom per scenario. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_screen_probe.lua \ +-- POKEPORT_PROBE=catch POKEPORT_SHOT_DIR=/tmp/gold-battle-screen love . +-- +-- POKEPORT_PROBE picks the scenario (comma separated, default `catch`): +-- +-- catch a wild mon is caught: the throw animation runs (the ball's own Y +-- is counted, so "it moved" is a number), and the mon's frontpic is +-- GONE from the box for every line after it +-- boxfull the same catch with six in the party: it lands in a real box. +-- POKEPORT_PROBE_BOX sets save.currentBox first -- 0 is the value a +-- save converted off a cartridge carries, and the one that used to +-- drop the catch on the floor +-- faint a player mon faints in a trainer battle: the fainted pic sinks +-- out of its box before the line, and picking the fainted mon in +-- the forced list is REFUSED out loud instead of silently +-- tutorial the DUDE's demonstration, start to finish, on its own auto-input +-- trainer the trainer's own frontpic stands in the enemy box for the intro +-- (needs a cache with menu_gfx battleHud.trainerPics; an older +-- import has none and the mon stands in for the whole intro) +-- scale the battle_sprite_scales reader, planted straight on data +-- +-- Every scenario shoots, because the answer to most of these is a picture. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-screen" + +-- main.lua's love.visible forwards to Game:visible, which the Gen 2 Game +-- object does not have, so another window taking focus mid-run kills the whole +-- driver with "attempt to call method 'visible'". It fires during the cache +-- mount, before the first frame this driver is resumed on, which is why the +-- guard is at module scope: loadfile runs it inside love.load. Nothing this +-- probe is about, so the callback is let through under pcall rather than +-- costing a 200-frame run. +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, 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 6) +end + +-- The battle screen, once the transition has handed the stack over. +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Page the intro (slide, "appeared!", the send-out) until the menu is up. +local function toMenu(game, battle, limit) + for _ = 1, (limit or 400) do + if battle.phase == "menu" then return true end + if battle.battle.over then return false end + tap(game, "a", 2) + end + return battle.phase == "menu" +end + +-- Shoots an animation while it runs, and tracks the OBJ layer's first sprite so +-- "the ball moved" is a number rather than a squint: BattleAnim_ThrowPokeBall's +-- own bounce and its three shakes are all Y motion on that one object. +local function shotsWhileAnim(game, battle, prefix, every) + local frames, moves, lastY, minY, maxY = 0, 0, nil, nil, nil + while battle.anim and frames < 600 do + if frames % (every or 4) == 0 then + U.shot(game, ("%s-%03d.png"):format(prefix, frames)) + end + local obj = battle.anim:oam()[1] + if obj then + if lastY and obj.y ~= lastY then moves = moves + 1 end + lastY = obj.y + minY = math.min(minY or obj.y, obj.y) + maxY = math.max(maxY or obj.y, obj.y) + end + frames = frames + 1 + U.wait(1) + end + return frames, moves, minY, maxY +end + +-------------------------------------------------------------------------- + +local Probes = {} + +-- 1, 5, 7: the throw animation, and the pic that must not come back. +function Probes.catch(game) + local world = game.world + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + game.save.inventory = { POKE_BALL = 10, MASTER_BALL = 5 } + game.save.boxes = nil + local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + assert(toMenu(game, battle), "never reached the battle menu") + U.shot(game, OUT .. "/catch-00-menu.png") + + battle:useItem("MASTER_BALL") + print(("[probe] catch: anim=%s ballThrow.caught=%s"):format( + tostring(battle.anim ~= nil), + tostring(battle.ballThrow and battle.ballThrow.caught))) + local frames, moves, minY, maxY = + shotsWhileAnim(game, battle, OUT .. "/catch-01-throw", 4) + print(("[probe] catch: throw animation ran %d frames, ball moved on %d of" + .. " them, y %s..%s"):format(frames, moves, tostring(minY), tostring(maxY))) + + -- The moment the animation lets go of the screen: the mon went into the + -- ball, so nothing may be standing in the enemy box here or on any of the + -- lines that follow. + for i = 0, 5 do + U.shot(game, ("%s/catch-01b-after-anim-%d.png"):format(OUT, i)) + tap(game, "a", 6) + end + + -- Page the caught text, the dex line and the nickname prompt (NO). + for _ = 1, 200 do + if battle.phase == "done" or not game.stack:top() then break end + if battle.phase == "ask-nickname" then + tap(game, "b", 4) + else + tap(game, "a", 3) + end + if battle.phase == "resolving" and #battle.queue == 0 + and battle.message == nil then + break + end + end + U.shot(game, OUT .. "/catch-02-after.png") + print(("[probe] catch: outcome=%s party=%d picHidden=%s"):format( + tostring(battle.battle.outcome), #game.save.party, + tostring(battle.picHidden and battle.picHidden.enemy))) + print(("[probe] catch: enemy pic still resolvable: %s"):format( + tostring(battle:pic(battle:activeMon("enemy"), false) ~= nil))) +end + +-- 3: six in the party sends the catch to the box. +function Probes.boxfull(game) + local party = {} + for _ = 1, 6 do party[#party + 1] = Mon.new(game.data, "CYNDAQUIL", 30) end + game.save.party = party + game.save.boxes = nil + -- wCurBox is 0-based on the cart (box 0 is BOX 1), and a save converted out + -- of a real cartridge carries that byte through unchanged -- so this is a + -- currentBox a live save really can hold, and the arm has to survive it. + game.save.currentBox = tonumber(os.getenv("POKEPORT_PROBE_BOX") or "1") + game.save.inventory = { MASTER_BALL = 5 } + local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + assert(toMenu(game, battle), "never reached the battle menu") + battle:useItem("MASTER_BALL") + shotsWhileAnim(game, battle, OUT .. "/box-01-throw", 8) + for _ = 1, 200 do + if battle.phase == "ask-nickname" then tap(game, "b", 4) + else tap(game, "a", 3) end + if battle.phase == "done" or not game.stack:top() then break end + end + local total = 0 + for _, box in pairs(game.save.boxes or {}) do total = total + #box end + local box = (game.save.boxes or {})[1] or {} + print(("[probe] boxfull: currentBox=%s party=%d box1=%d anywhere=%d first=%s") + :format(tostring(game.save.currentBox), #game.save.party, #box, total, + tostring(box[1] and box[1].species))) + U.shot(game, OUT .. "/box-02-after.png") +end + +-- 4 and 6: the faint slide, and the forced switch that has to take first try. +function Probes.faint(game) + local world = game.world + local weak = Mon.new(game.data, "CYNDAQUIL", 5) + weak.hp = 1 + local strong = Mon.new(game.data, "TOTODILE", 30) + game.save.party = { weak, strong } + game.save.inventory = {} + local entry = world:trainerParty(36, 1) -- BUG_CATCHER member 1 + assert(entry, "no BUG_CATCHER member 1 in trainers.lua") + local Trainers = require("src.world.gen2.Trainers") + entry.party = Trainers.party(game.data, entry) + local battle = openBattle(game, { trainer = entry }) + assert(toMenu(game, battle), "never reached the battle menu") + + -- Count how many times the party list is opened for the forced switch. + local opens = 0 + local realOpen = battle.openParty + battle.openParty = function(self, forced) + if forced then opens = opens + 1 end + return realOpen(self, forced) + end + + -- What a player does: press A on the row the cursor is already on. Row 1 is + -- the mon that just fainted, so the first two picks are the refusal the cart + -- answers with "There's no will to fight!"; only the third moves down. + local picks, refusedMessages = 0, {} + local shot, sawFaint = 0, false + for _ = 1, 900 do + if battle.battle.over then break end + local top = game.stack:top() + if top ~= battle then + picks = picks + 1 + if picks > 2 then tap(game, "down", 3) end + tap(game, "a", 4) + if battle.message then + refusedMessages[#refusedMessages + 1] = battle.message + end + elseif battle.faintSlide then + -- MonFaintedAnimation is running: one shot a frame, because the whole + -- claim is that the pic sinks out of its box before the line goes up. + U.shot(game, ("%s/faint-slide-%s-%02d.png"):format(OUT, + battle.faintSlide.side, battle.faintSlide.frames)) + U.wait(1) + elseif battle.message and battle.message:match("fainted") then + if not sawFaint then + sawFaint = true + print("[probe] faint: line up -- " .. battle.message) + end + if shot < 12 then + U.shot(game, ("%s/faint-%02d.png"):format(OUT, shot)) + shot = shot + 1 + U.wait(2) + else + tap(game, "a", 2) + end + elseif battle.phase == "menu" then + tap(game, "a", 2) -- FIGHT + U.wait(3) + tap(game, "a", 2) -- first move + else + tap(game, "a", 2) + end + end + print(("[probe] faint: list opened %d time(s) for %d pick(s), outcome=%s") + :format(opens, picks, tostring(battle.battle.outcome))) + print(("[probe] faint: player is now %s (party slot 2 is %s)"):format( + tostring(battle.battle.player and battle.battle.player.species), + tostring(game.save.party[2] and game.save.party[2].species))) + for i, text in ipairs(refusedMessages) do + print(("[probe] faint: after pick %d the box said %q"):format(i, text)) + end +end + +-- 5 and 7: the DUDE's demonstration, which throws the same ball through the +-- same animation and then has to leave the RATTATA inside it. +function Probes.tutorial(game) + local world = game.world + game.save.party = {} + local rattata = game.data.pokemon.RATTATA + local battle + -- Script_catchtutorial arms CATCH_TUTORIAL around StartBattle and the DUDE's + -- own presses are RE-ARMS of that ring (CatchTutorial.rearm refuses a ring + -- that is not already active), so the stream has to be started here the way + -- the VM starts it -- without it the demo hangs on its first prompt forever. + game.autoInput:start("CATCH_TUTORIAL", game.input) + world:startCatchTutorial({ species = rattata.index, level = 5 }, nil, + function() + game.autoInput:stop(game.input) + print("[probe] tutorial: battle closed") + end) + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle, "the tutorial battle never came up") + -- The DUDE plays it himself; all this does is shoot and stay out of the way. + -- The loop watches the battle SCREEN rather than the stack top, because the + -- demo opens the pack over it and a top-of-stack test would stop counting + -- exactly where the throw happens. + local shot, sawAnim, animFrames = 0, false, 0 + for _ = 1, 2000 do + if battle.phase == "done" then break end + if battle.anim then sawAnim = true animFrames = animFrames + 1 end + if shot % 8 == 0 then + U.shot(game, ("%s/tutorial-%03d.png"):format(OUT, shot)) + end + shot = shot + 1 + U.wait(1) + end + print(("[probe] tutorial: ball animation seen=%s (%d frames), shots=%d") + :format(tostring(sawAnim), animFrames, shot)) +end + +-- The battle_sprite_scales registry, whose records are keyed by ASSET PATH and +-- are the only handle on a pic that is not a species' own. Schemas.GEN2 still +-- routes the registry nowhere, so the Loader drops a mod's registration with a +-- warning; the record is planted straight on data here to show the READER is +-- live, which is the half that has to exist before the row can be un-gated. +function Probes.scale(game) + game.data.battle_sprite_scales = game.data.battle_sprite_scales or {} + game.data.battle_sprite_scales.probe_back = { + path = "assets/generated/battle/back/cyndaquil_back.png", scale = 0.5, + } + game.data.battle_sprite_scales.probe_front = { + path = "assets/generated/battle/front/pidgey.png", scale = 1.5, + } + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + assert(toMenu(game, battle), "never reached the battle menu") + U.shot(game, OUT .. "/scale-00-menu.png") + print(("[probe] scale: back=%s front=%s (1 means the reader is not wired)") + :format( + tostring(battle:picScale( + "assets/generated/battle/back/cyndaquil_back.png", nil, true)), + tostring(battle:picScale( + "assets/generated/battle/front/pidgey.png", nil, false)))) + game.data.battle_sprite_scales.probe_back = nil + game.data.battle_sprite_scales.probe_front = nil +end + +-- 2: the trainer's frontpic during the intro. +function Probes.trainer(game) + local world = game.world + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + local entry = world:trainerParty(36, 1) + assert(entry, "no BUG_CATCHER member 1") + local Trainers = require("src.world.gen2.Trainers") + entry.party = Trainers.party(game.data, entry) + local hud = game.data.gen2MenuGfx and game.data.gen2MenuGfx.battleHud + local pics = hud and hud.trainerPics + print(("[probe] trainer: cache trainerPics=%s entry.class=%s classId=%s" + .. " className=%s"):format( + tostring(pics and "yes" or "no"), tostring(entry.class), + tostring(entry.classId), tostring(entry.className))) + local battle = openBattle(game, { trainer = entry }) + print(("[probe] trainer: showEnemyTrainer=%s image=%s class=%s"):format( + tostring(battle.showEnemyTrainer), tostring(battle.enemyTrainerImage ~= nil), + tostring(battle.enemyTrainerClass))) + -- The intro slide, the "wants to battle!" line, then the pic sliding out. + U.wait(40) + U.shot(game, OUT .. "/trainer-00-slide.png") + U.wait(45) + U.shot(game, OUT .. "/trainer-01-intro.png") + for _ = 1, 12 do + tap(game, "a", 2) + if battle.trainerSlide then break end + end + U.shot(game, OUT .. "/trainer-02-slide-out.png") + U.wait(20) + U.shot(game, OUT .. "/trainer-03-mon.png") +end + +-------------------------------------------------------------------------- + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local wanted = os.getenv("POKEPORT_PROBE") or "catch" + for name in wanted:gmatch("[%w_]+") do + local probe = Probes[name] + if not probe then + print("[probe] no scenario named " .. name) + else + print("[probe] ---- " .. name) + probe(game) + -- Back to the overworld before the next scenario. + for _ = 1, 300 do + if game.stack:top() == nil then break end + tap(game, "a", 2) + end + end + end + print("[probe] done, shots in " .. OUT) + love.event.quit() +end diff --git a/tests/drivers/gold_battle_smoke.lua b/tests/drivers/gold_battle_smoke.lua new file mode 100644 index 00000000..58dba341 --- /dev/null +++ b/tests/drivers/gold_battle_smoke.lua @@ -0,0 +1,109 @@ +-- Smoke: a real Gold wild battle, driven end to end from the overworld. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_smoke.lua love . +-- +-- Starts a battle against a live extracted species with a live extracted +-- moveset, presses FIGHT until something faints, and shoots the screen along +-- the way. This is the check that the extracted moves/pokemon/type_chart +-- actually agree with the engine -- a fixture test cannot say that. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle" + + 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 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- `givepoke` is how the STARTER arrives, and it used to hand back a mon with + -- an empty move list (it went through Gen 1's Pokemon.new, whose + -- level1Moves/learnset fields the Gen 2 extractor does not write). That is + -- what left FIGHT with nothing in it. Drive the VM hook directly so the + -- check does not depend on walking the whole Elm's Lab script. + local cyndaquilIndex = game.data.pokemon.CYNDAQUIL.index + game.save.party = {} + world.vm.givePokeFn(cyndaquilIndex, 5, 0) + local gift = game.save.party[1] + assert(gift, "givepoke put nothing in the party") + assert(#gift.moves > 0, + "givepoke handed over a mon with no moves -- FIGHT would be empty") + print(("[driver] givepoke gave %s L%d with %d moves (%s)"):format( + gift.species, gift.level, #gift.moves, gift.moves[1].id)) + assert(game.save.pokedex and game.save.pokedex.caught[gift.species], + "givepoke did not tick the starter off in the #DEX") + + -- Give the player a real Cyndaquil built from the extracted tables, so the + -- moveset and stats come from the cart rather than the driver. + local player = Mon.new(game.data, "CYNDAQUIL", 12) + assert(player, "could not build a CYNDAQUIL from pokemon.lua") + assert(#player.moves > 0, + "CYNDAQUIL learned no moves -- levelMoves or moves.lua is missing") + print(("[driver] player %s L%d hp %d/%d, %d moves (%s)"):format( + player.species, player.level, player.hp, player.maxHp, #player.moves, + player.moves[1].id)) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3 } + + local wild = Mon.new(game.data, "PIDGEY", 4) + assert(wild and #wild.moves > 0, "could not build a wild PIDGEY") + + local Music = require("src.core.Music") + assert(world:startBattle({ wild = wild }), "startBattle failed") + -- PlayBattleMusic runs before the transition, so the theme is already going + -- while the wipe is spinning. + print("[driver] battle music " .. tostring(Music.current())) + assert(Music.current() == "Music_JohtoWildBattle" + or Music.current() == "Music_JohtoWildBattleNight", + "the wild battle did not start the Johto wild theme: " + .. tostring(Music.current())) + + -- DoBattleTransition owns the screen first; shoot it, then wait it out. + U.wait(4) + U.shot(game, out .. "/00-transition.png") + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle and battle.battle, + "battle screen never came up after the transition") + U.wait(20) + U.shot(game, out .. "/01-battle-open.png") + + -- Page through the intro messages, then attack until the battle resolves. + for _ = 1, 120 do + if battle.battle.over then break end + if battle.phase == "menu" then + U.shot(game, out .. "/02-battle-menu.png") + tap("a") -- FIGHT + U.wait(4) + U.shot(game, out .. "/03-move-list.png") + tap("a") -- first move + else + tap("a", 3) + end + end + + assert(battle.battle.over, + "battle did not resolve in 120 presses (phase " .. tostring(battle.phase) .. ")") + print("[driver] outcome " .. tostring(battle.battle.outcome)) + assert(battle.battle.outcome == "win", + "expected the L12 starter to win, got " .. tostring(battle.battle.outcome)) + print(("[driver] player ended at %d/%d hp, exp %d") + :format(player.hp, player.maxHp, player.experience)) + assert(player.experience > 0, "no experience was awarded") + U.shot(game, out .. "/04-battle-end.png") + + print("[driver] PASS gold wild battle in " .. out) +end diff --git a/tests/drivers/gold_boot_smoke.lua b/tests/drivers/gold_boot_smoke.lua new file mode 100644 index 00000000..f89d557a --- /dev/null +++ b/tests/drivers/gold_boot_smoke.lua @@ -0,0 +1,168 @@ +-- Smoke: the whole Gold boot chain, with no driver shortcut. +-- +-- POKEPORT_GAME=gold POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_DRIVER=tests/drivers/gold_boot_smoke.lua love . +-- +-- copyright -> GameFreak -> GS intro -> title -> intro menu -> NEW GAME -> +-- Oak speech -> name pick -> naming screen -> the bedroom. 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 GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") +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") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-boot" + + 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 + + -- Wait until `predicate` holds, or fail naming what was on screen instead. + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + local state = top() + error(("stalled waiting for %s (top is %s)"):format( + label, tostring(state))) + end + + -- Skip through anything that just waits for a button. + local function press(times, button) + for _ = 1, times or 1 do tap(button or "a", 3) end + end + + -- The driver env normally boots straight to the world; this one asked for the + -- cinema, so Game2 should have started at the copyright splash. + U.wait(10) + assert(isA(CopyrightSplash), + "boot did not start at the copyright splash (top " .. tostring(top()) .. ")") + U.shot(game, out .. "/01-copyright.png") + + waitFor("GameFreak presents", function() return isA(GameFreakPresents) end) + U.shot(game, out .. "/02-gamefreak.png") + + waitFor("the GS intro", function() return isA(GoldSilverIntro) end) + U.wait(240) + U.shot(game, out .. "/03-intro.png") + tap("start") -- any button skips the movie + + waitFor("the title screen", function() return isA(TitleState) end) + U.wait(60) + U.shot(game, out .. "/04-title.png") + press(3, "start") + + waitFor("the intro menu", function() return isA(MainMenu) end) + U.shot(game, out .. "/05-mainmenu.png") + + -- OPTION and back, so the menu's own hand-off is exercised too. Found by + -- value, not by position: the port's EXIT GAME row sits after OPTION, so + -- "the last item" is the one that quits the game. + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "option" then menu.list.index = i end + end + 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) + + -- NEW GAME. + menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + -- `farcall InitClock` opens OakSpeech, so the clock screen is what NEW GAME + -- lands on and Oak is underneath it. + waitFor("the clock screen", function() return isA(InitClock) end, 300) + U.shot(game, out .. "/06b-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) + -- Page through Oak until the name picker appears. + for _ = 1, 400 do + if isA(NamePick) then break end + tap("a", 2) + end + assert(isA(NamePick), "Oak speech never reached the name picker") + -- NamePlayer walks the player pic across before the menu box goes up, and + -- that walk is a blocking DelayFrame loop on the cart -- so both the shot + -- and the first button press have to wait it out. + waitFor("the name menu to slide in", + function() return top().slide == nil end, 120) + U.shot(game, out .. "/07-namepick.png") + + -- NEW NAME opens the real Gen 2 keyboard. + local pick = top() + pick.cursor = 1 -- "NEW NAME" + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end) + local naming = top() + -- Type "AB": A is at (0,0), B at (1,0). + tap("a") + tap("right") + tap("a") + assert(naming.text == "AB", + "typed name is " .. tostring(naming.text) .. ", expected AB") + U.shot(game, out .. "/08-naming.png") + -- END: bottom row, third target. + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + + -- Back into Oak for the last text page and the shrink, then the world. This + -- one has to keep pressing A: the remaining pages are text boxes waiting on a + -- button, so a passive wait would sit there forever. + for _ = 1, 500 do + if game.phase == "play" and game.world and game.world.map then break end + tap("a", 2) + end + assert(game.phase == "play" and game.world and game.world.map, + "never reached the overworld (top is " .. tostring(top()) .. ")") + assert(game.save.player.name == "AB", + "player name is " .. tostring(game.save.player.name)) + assert(game.world.map.id == "PLAYERS_HOUSE_2F", + "new game started on " .. tostring(game.world.map.id) + .. ", expected the bedroom") + U.wait(20) + U.shot(game, out .. "/09-bedroom.png") + + -- START opens the Gen 2 start menu from the overworld. + tap("start") + waitFor("the start menu", function() return isA(StartMenu) end) + U.shot(game, out .. "/10-startmenu.png") + tap("b") + waitFor("the overworld again", function() return top() == nil end) + + print("[driver] PASS gold boot chain in " .. out) +end diff --git a/tests/drivers/gold_bot.lua b/tests/drivers/gold_bot.lua new file mode 100644 index 00000000..ce03ef6e --- /dev/null +++ b/tests/drivers/gold_bot.lua @@ -0,0 +1,1252 @@ +-- Gold route bot: New Bark Town to the Olivine Gym. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bot.lua love . +-- +-- Interprets tests/drivers/gold/route.lua (objectives derived from +-- docs/gold-walkthrough/asm-walk) using the core in tests/drivers/gold/bot.lua. +-- Validate the route first -- `luajit tests/gold_route_validate_test.lua` -- +-- because every coordinate error it catches costs an hour of run time here. +-- +-- An unimplemented or failing op does NOT abort the run. It is logged, added +-- to the skip list and stepped over, so one run tells you every wall rather +-- than only the first: a stall names the asm-walk row it stalled on, which is a +-- paste-ready issue instead of a mystery. The summary at the end is the point +-- of the whole exercise. +-- +-- Env: +-- POKEPORT_GOLD_LOG=path mirror the log to a file, flushed per line +-- POKEPORT_GOLD_FROM=id skip route rows until this id (resume) +-- POKEPORT_GOLD_UNTIL=id stop after this id +-- POKEPORT_GOLD_STALL=frames silent-stall budget (default 5000) +-- POKEPORT_GOLD_REPEATS=n identical-log-line budget (default 12) + +local Bot = dofile("tests/drivers/gold/bot.lua") +local ROUTE = dofile("tests/drivers/gold/route.lua") +local A = Bot.adapter + +return function(game) + local bot = Bot.new(game) + + -- --------------------------------------------------------------------- + -- postconditions + -- --------------------------------------------------------------------- + -- Every asm-walk checklist row ends in a flag, which is the oracle the Gen 1 + -- bot never had: it inferred success from position and could not tell "the + -- script ran" from "we happen to be standing in the right place". + local function flagSet(name) + if not name then return true end + local ev = A.event(game, name) + if ev ~= nil then return ev end + local en = A.engine(game, name) + if en ~= nil then return en end + return nil -- unknown name: the validator should have + -- caught it, so treat as inconclusive + end + + local function satisfied(row) + if not row.expect then return true end + local set = flagSet(row.expect) + if set == nil then + bot:say((" ? %s: cannot evaluate %s"):format(row.id, row.expect)) + return true + end + return set + end + + -- --------------------------------------------------------------------- + -- ops + -- --------------------------------------------------------------------- + + local ops = {} + + function ops.travel(row) + -- `region` pins WHICH half of a split map counts as arriving; see + -- Bot:travelTo. + return bot:reachMap(row.map, row.region) + end + + function ops.settle(row) + -- Sit through whatever the map load started. Not a no-op: several beats + -- (MeetMomScript, Mr Pokemon, the Burned Tower rival) are scene scripts + -- that fire on arrival with nothing to walk into. + bot:clearDialogue(row.answers, row.budget or 4000) + return true + end + + function ops.walk(row) + return bot:walkTo(row.x, row.y, { answers = row.answers }) + end + + function ops.warp(row) + -- Retry across wild-encounter interruptions. A warp whose cell is a long + -- walk over grass (Route 30's Mr Pokemon's house is 60 steps) can have its + -- walk broken by encounter after encounter -- and worse, a mis-stepped + -- re-plan can walk the bot clean OFF the map (Route 30's south edge drops + -- into Cherrygrove), after which every enterWarp aims at (x,y) on the wrong + -- map and "the warp did not take" forever. So re-reach the row's own map + -- before each attempt, then walk the warp; the bot is closer each pass. + for _ = 1, 3 do + if row.to and A.mapId(game) == row.to then return true end + if A.mapId(game) ~= row.map then + if A.busy(game) then bot:clearDialogue() end + local ok, res = pcall(bot.reachMap, bot, row.map) + if not (ok and res) then + if not ok and not (type(res) == "table" and res.botStall) then + error(res, 0) + end + end + end + if bot:enterWarp(row.x, row.y, row.to) then return true end + if A.busy(game) then bot:clearDialogue() end + end + return A.mapId(game) == (row.to or A.mapId(game)) + end + + function ops.edge(row) + -- Route rows write the compass word the asm-walk uses ("walk west"); the + -- pad wants a screen direction. + local dir = bot.button(row.dir) + if not dir then return false, "unknown direction " .. tostring(row.dir) end + return bot:crossEdge(dir, row.to) + end + + -- A talk/battle row names the OBJECT'S HOME cell out of the map data, but a + -- sighted trainer WALKS to the player and then stands wherever the fight + -- happened -- so the named cell can be empty while the npc is two cells + -- away. Aim at the npc's LIVE position when one homed at (x, y) exists; + -- bg-event targets (doors, switches, signs) have no npc and keep the row's + -- own coordinates. + local function liveTarget(x, y) + local npc = A.npcHome(game, x, y) + if npc and npc.cellX then return npc.cellX, npc.cellY end + return x, y + end + + function ops.talk(row) + -- Retry with a reposition between attempts. A single approach failing is + -- usually not "unreachable" but "an NPC is standing in the one doorway" or + -- "we came in by the far entrance and the local search gave up" -- and + -- failing the row outright then let the bot wander off the map entirely, + -- which is how the Farfetch'd herd died on its very first talk. + local tx, ty = liveTarget(row.x, row.y) + local reached = bot:approachAndFace(tx, ty, row.facings) + for _ = 1, 3 do + if reached then break end + -- Wait, do not wander. The first version of this retry walked to a + -- random cell, which on a map whose exits sit in the open (Ilex Forest's + -- do) means stepping onto a warp and leaving -- after which every + -- remaining row reported "could not reach ILEX_FOREST". Standing still + -- solves the actual cause anyway: the usual reason an approach fails is + -- an NPC parked in the one free cell, and NPCs walk away. + bot:wait(40) + if A.busy(game) then bot:clearDialogue() end + if A.mapId(game) ~= row.map then break end + tx, ty = liveTarget(row.x, row.y) + reached = bot:approachAndFace(tx, ty, row.facings) + end + if not reached then return false end + -- Press until SOMETHING opens. One tap into the void is how the Burned + -- Tower rock survived 07.20: a wild fight on the approach ends a few + -- frames before the press, the post-battle teardown swallows it, and the + -- row then reports ok off its own opinion with the rock still standing. + -- Anything a talk row aims at answers with a textbox, so silence after + -- the press always means the press was lost. + local opened = false + for attempt = 1, 4 do + bot:tap("a") + bot:wait(4) + for _ = 1, 30 do + if A.busy(game) then break end + bot:wait(1) + end + if A.busy(game) then + opened = true + break + end + bot:say((" talk: press %d at (%d,%d) opened nothing") + :format(attempt, tx, ty)) + end + if not opened then + bot:say(" talk: every press was swallowed") + end + bot:clearDialogue(row.answers, row.budget) + return true + end + + -- Ride an elevator. The panel bg event at (x, y) opens the scrolling floor + -- menu (engine/events/elevator.asm); picking a row does NOT warp -- it + -- rewrites the door warp's destination (Elevator_GoToFloor -> wBackup*) and + -- the ride happens when the player walks out through the door at + -- (doorX, doorY). The map graph cannot express any of that, which is why + -- the Goldenrod dept store basement -- the only road out of the Rocket + -- warehouse -- needs an explicit row instead of a travel. + function ops.elevator(row) + if A.mapId(game) ~= row.map then return false, "not on the elevator" end + if not bot:approachAndFace(row.x, row.y, row.facings) then + return false, "could not reach the panel" + end + bot:tap("a") + local menu + for _ = 1, 300 do + menu = A.elevatorMenu(game) + if menu then break end + bot:wait(1) + end + if not menu then return false, "elevator menu never opened" end + local want + for i = 1, #menu.floors do + if A.elevatorFloorName(menu, i) == row.floor then want = i break end + end + if not want then + return false, ("no floor %s on this elevator"):format(tostring(row.floor)) + end + for _ = 1, 32 do + if menu.index == want then break end + bot:tap(menu.index > want and "up" or "down") + bot:wait(4) + end + if menu.index ~= want then return false, "cursor never reached the floor" end + bot:tap("a") + bot:wait(8) + -- Door-close SFX and script; the menu is gone, so this only clears chrome. + bot:clearDialogue(nil, 2000) + if not bot:enterWarp(row.doorX, row.doorY, row.to) then + return false, "the door did not take" + end + return A.mapId(game) == row.to + end + + function ops.battle(row) + if row.talk then + local tx, ty = liveTarget(row.x, row.y) + if not bot:approachAndFace(tx, ty, row.facings) then return false end + bot:tap("a") + elseif row.x then + -- A sight-line fight: walking onto the cell is what trips it. + bot:walkTo(row.x, row.y, { answers = row.answers }) + end + bot:wait(8) + -- The approach itself is a script (the trainer walks over), so the battle + -- may be several hundred frames away. + bot:clearDialogue(row.answers, row.budget or 8000) + return true + end + + -- The asm-walk phrases healing as a town-level beat ("heal at the + -- Pokecenter, warp 1 at (13,21)"), so a heal row names the CITY while the + -- nurse stands a warp away inside it. healUp finds the nearest map that + -- actually has a nurse, so no row needs to carry the interior map name. + function ops.heal(row) + return bot:healUp() + end + + function ops.grind(row) + -- Walk back and forth in whatever grass is reachable until the party's + -- lowest level reaches the target. Crude, and deliberately so: the point + -- is not to be fast, it is to not walk into Falkner at level 5. + -- + -- Blacking out mid-grind is expected at these levels and is survivable: the + -- engine heals the party and warps to the spawn point. So walk back and + -- carry on, but cap the number of round trips -- a route step that can + -- never be met should end the op with a report, not keep the run alive + -- forever making no progress. + local target = row.level + local grindMap = row.map + local wipes = 0 + + -- Grinding the party minimum means grinding the mon that is NOT fighting. + -- Experience in Gen 2 goes to whoever was sent out, and the bot always + -- leads with slot 1, so a party-minimum target on the default order is a + -- number that can never be reached: the SLOWPOKE caught for SURF sat at + -- level 7 behind a level-30 starter for the whole run. Putting the weakest + -- mon in front is what the PARTY menu's own SWITCH row does, and the order + -- is put back afterwards so the rest of the route still leads with the + -- fighter. + -- + -- Whoever is lowest RIGHT NOW goes in front, re-checked every pass. + -- + -- This used to choose once, before the loop, and a party-minimum target + -- then could not be met with two weak mons in it: run 24 trained the + -- SLOWPOKE from 5 to 10 while the TOGEPI sat at 5 behind it, so `minLevel` + -- never moved and the row died on its futility ceiling at "level 5/20". + -- Re-picking rotates them, which is what a player switching at the PARTY + -- menu does anyway. + -- + -- The original order is snapshotted whole rather than tracked as a single + -- swap index, because after a few rotations there is no single swap to undo + -- -- and the rest of the route needs slot 1 to be the fighter again. + local originalOrder + if row.lead == false then + originalOrder = {} + for i, mon in ipairs(A.party(game)) do originalOrder[i] = mon end + end + local function leadWithWeakest() + if not originalOrder then return end + local party = A.party(game) + local weakest + for i, mon in ipairs(party) do + if not mon.isEgg and (mon.hp or 0) > 0 + and (not weakest or (mon.level or 0) + < (party[weakest].level or 0)) then + weakest = i + end + end + if weakest and weakest > 1 then + party[1], party[weakest] = party[weakest], party[1] + bot:say((" grind: leading with %s (level %d) so it earns the levels") + :format(tostring(party[1].species), party[1].level or 0)) + end + end + leadWithWeakest() + -- Grinding the party minimum means the mon in front cannot win a fight, so + -- the bot switches the fighter in on turn one and lets the weakling collect + -- its participation share. See A.openBattleParty. + bot.switchTrain = (row.lead == false) or nil + local function finish(ok, why) + bot.switchTrain = nil + if originalOrder then + local party = A.party(game) + for i, mon in ipairs(originalOrder) do party[i] = mon end + end + return ok, why + end + -- `lead = true` grinds the front mon only; the default is the party + -- minimum, which is what the opening Route 29 row wants. + local levelOf = row.lead and A.leadLevel or A.minLevel + -- A frame ceiling as well as a pass ceiling. A grind re-travels to its map + -- after every wipe, and if that journey is the one that loops -- Radio + -- Tower 2F, where an NPC stands in the only corridor -- the pass counter + -- never advances and the row eats the entire run's wall clock. Run 14 died + -- this way at row 208 with the League untouched. + local grindStart = bot:frames() + local grindBudget = tonumber(os.getenv("POKEPORT_GOLD_GRIND_BUDGET")) + or 700000 + -- Futility ceiling, separate from the frame budget. + -- + -- Keyed on EXPERIENCE, not level, because those are two different + -- questions. A grind can be earning steadily and still not cross a level + -- for a long time -- ROUTE_34's wilds against a level-28 QUILAVA are worth + -- very little each -- and killing that grind loses levels the route is + -- counting on. What is worth killing is a grind earning *nothing at all*: + -- run 24's TOGEPI fainted before it could act every single battle, and a + -- fainted participant is awarded nothing, so "level 5/20" was unreachable + -- by construction and the 700k frame budget was going to spend forty + -- minutes proving it. + -- + -- Summed over the whole party, so it stays true for the party-minimum form + -- where the mon in front changes from pass to pass. + local function partyExp() + local total = 0 + for _, mon in ipairs(A.party(game)) do + if not mon.isEgg then total = total + (mon.experience or 0) end + end + return total + end + local stuckExp, stuckSince = partyExp(), 0 + -- Pass ceiling. Raised from 900 after Cluster E: a lead one level short of + -- target (09.g at 37/38, 07.g similarly) was still earning every pass, so + -- the futility guard never fired, the frame budget was still hundreds of + -- thousands away, and the loop fell off the end with no `why` -- which the + -- runner printed as the opaque "action reported failure". Grass pacing + -- (below) is what actually makes the levels land; the higher ceiling is + -- the backstop that still names the cause when they do not. + local passBudget = tonumber(os.getenv("POKEPORT_GOLD_GRIND_PASSES")) or 2000 + local function returnToGrind(why) + bot:forgetSurf() + bot.borders = {} + if A.busy(game) then bot:clearDialogue() end + if bot:travelTo(grindMap) then return true end + -- One retry. The failed trip leaves priced exits and a half-finished + -- position; standing still clears NPC timing, and a second plan is what + -- gets the bot out of the Route-36 National-Park-gate loop that killed + -- every 05.g recovery before the whiteout-spawn fix. + bot:say((" grind: %s, retrying travel"):format(why)) + bot:wait(30) + if A.busy(game) then bot:clearDialogue() end + return bot:travelTo(grindMap) + end + for pass = 1, passBudget do + if levelOf(game) >= target then return finish(true) end + local exp = partyExp() + if exp > stuckExp then + stuckExp, stuckSince = exp, pass + elseif pass - stuckSince > 120 then + return finish(false, ("no experience earned in %d passes (level %d/%d)" + .. " -- nothing here can be beaten"):format( + pass - stuckSince, levelOf(game), target)) + end + if bot:frames() - grindStart > grindBudget then + return finish(false, ("gave up after %d frames (level %d/%d)") + :format(bot:frames() - grindStart, levelOf(game), target)) + end + if A.busy(game) then bot:clearDialogue() end + -- Rotate: the mon that was lowest last pass may not be lowest now. + leadWithWeakest() + + -- Heal before the party is dry. A wipe is a free heal on the cart, but + -- the walk home is what ended 05.g (and used to return to Cherrygrove for + -- the whole game). Attacking PP is the signal that matters: LEER / + -- SMOKESCREEN keep total PP looking fine while EMBER is empty, and the + -- fighter then STRUGGLES itself into a whiteout. + if A.damagingPpFraction(game) < 0.2 or A.leadHpFraction(game) < 0.25 then + bot:say((" grind: healing mid-session (hp %.2f attacking-pp %.2f)") + :format(A.leadHpFraction(game), A.damagingPpFraction(game))) + if not bot:healUp() then + return finish(false, "could not heal mid-grind") + end + if A.mapId(game) ~= grindMap and not returnToGrind("post-heal") then + return finish(false, "could not get back to " .. grindMap + .. " after mid-grind heal") + end + end + + if A.mapId(game) ~= grindMap then + wipes = wipes + 1 + if wipes > (row.wipeBudget or 4) then + return finish(false, ("blacked out %d times grinding %s (level %d/%d)") + :format(wipes, grindMap, levelOf(game), target)) + end + bot:say((" grind: back to %s after a wipe (%d)"):format(grindMap, wipes)) + if not returnToGrind("wipe recovery") then + return finish(false, "could not get back to " .. grindMap) + end + end + + local map = A.map(game) + if not map then + return finish(false, "no map loaded while grinding " .. grindMap) + end + -- Pace encounter tiles, not random corridor. The catch op already learned + -- this: wandering the whole map mostly walks road, burns frames without + -- rolls, and steps onto connection edges that the wipe counter then + -- treats as blackouts. + -- + -- Cave / dungeon maps skip the grass array entirely + -- (FieldMoves.canEncounterWildMon): every non-ice walkable tile rolls, so + -- VICTORY_ROAD reports zero `isEncounterCell` hits and still has a full + -- wild table. Treat those floors the same way the engine does. + local walked = false + local spots = {} + local env = map.def and map.def.environment + local caveFloor = (env == "CAVE" or env == "DUNGEON") + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + local hit + if caveFloor then + hit = A.walkable(game, map, cx, cy) and not A.isIce(map, cx, cy) + else + -- Water is in CheckGrassCollision's array; a grind paces LAND + -- grass, and aiming at a pond bounces off the shore (or surfs + -- off the grind entirely once the party can). + hit = A.isEncounterCell(map, cx, cy) + and not A.isWater(map, cx, cy) + end + if hit then spots[#spots + 1] = { cx, cy } end + end + end + if #spots > 0 then + for _ = 1, 12 do + local pick = spots[math.random(1, #spots)] + if bot:planPath(pick[1], pick[2]) then + bot:walkTo(pick[1], pick[2], { attempts = 3 }) + walked = true + break + end + end + end + if not walked then + for _ = 1, 10 do + local x = math.random(0, math.max(0, map.widthCells - 1)) + local y = math.random(0, math.max(0, map.heightCells - 1)) + if bot:planPath(x, y) then + bot:walkTo(x, y, { attempts = 3 }) + break + end + end + end + if pass % 20 == 0 then + bot:say((" grind: level %d/%d on %s") + :format(levelOf(game), target, tostring(A.mapId(game)))) + end + end + return finish(false, ("gave up after %d passes (level %d/%d)") + :format(passBudget, levelOf(game), target)) + end + + -- Teach an HM move to a party mon that can legally learn it. + -- + -- Driven through the model rather than the four screens the player would + -- walk (PACK -> the HM -> the party list -> confirm). That is a deliberate + -- driver shortcut of the same kind tests/drivers/gold_walk_smoke.lua takes + -- when it calls world:setMap: the subject under test is the ROUTE, and menu + -- navigation is neither what a route row means nor what it is checking. + -- Compatibility is still honoured -- the mon must have the move in its + -- tmhm list -- so this cannot teach SURF to something that could never + -- learn it and quietly invalidate the run. + function ops.teach(row) + local Mon = require("src.battle.gen2.Mon") + local move = row.move + local pokemon = game.data and game.data.pokemon + for _, mon in ipairs(A.party(game)) do + for _, known in ipairs(mon.moves or {}) do + if known.id == move then + bot:say((" teach: %s already knows %s"):format( + tostring(mon.species), move)) + return true + end + end + end + -- Slot 1 LAST. + -- + -- This used to walk the party in order, so every HM went to whoever was in + -- front -- which is always the starter, because the bot always leads with + -- the fighter. By the League the lead's four slots were CUT, STRENGTH and + -- a level-10 EMBER, and a level-73 Typhlosion was swinging a Normal HM at + -- Bruno. HMs belong on the mule; the mon that has to win fights keeps its + -- attacking moves. The lead is still the fallback, because a route that + -- cannot teach CUT cannot leave Ilex Forest. + local order = {} + local party = A.party(game) + for i = 2, #party do order[#order + 1] = party[i] end + if party[1] then order[#order + 1] = party[1] end + + for _, mon in ipairs(order) do + local def = pokemon and pokemon[mon.species] + local compatible = false + for _, id in ipairs((def and def.tmhm) or {}) do + if id == move then compatible = true break end + end + if compatible and not mon.isEgg then + local ok, reason, entry = Mon.learnMove(mon, move, game.data) + if ok then + bot:say((" teach: %s learned %s"):format(tostring(mon.species), move)) + return true + end + if reason == "full" and entry then + -- No forget screen exists in the port yet, so replace the weakest + -- move rather than stall: a bot that cannot teach CUT cannot leave + -- Ilex Forest at all. + -- + -- Never an HM move, though -- the cart flatly refuses to overwrite + -- one, and the run needs them simultaneously: the mule carries SURF, + -- WHIRLPOOL and WATERFALL together for Route 27, and this exact + -- line once paid WHIRLPOOL for WATERFALL and re-walled Tohjo Falls. + local HM_MOVES = { CUT = true, FLY = true, SURF = true, + STRENGTH = true, FLASH = true, + WHIRLPOOL = true, WATERFALL = true } + local defs = game.data.moves + local worst, worstPower + for i, known in ipairs(mon.moves) do + if not HM_MOVES[known.id] then + local power = (defs and defs[known.id] and defs[known.id].power) or 0 + if not worst or power < worstPower then + worst, worstPower = i, power + end + end + end + if worst then + bot:say((" teach: %s forgets %s for %s"):format( + tostring(mon.species), tostring(mon.moves[worst].id), move)) + mon.moves[worst] = entry + return true + end + -- All four slots are HMs: this mon is out of room, try the next. + bot:say((" teach: %s has no forgettable slot for %s"):format( + tostring(mon.species), move)) + end + end + end + return nil, ("no party mon can learn %s"):format(tostring(move)) + end + + -- Use a field move against a cell (CUT a tree, ROCK_SMASH a boulder, SURF + -- off a beach). Face the target first: World:fieldContext reads the tile the + -- player is facing, so the facing IS the argument. + function ops.field(row) + local move = row.move + local world = A.world(game) + if not world then return false end + -- Bot:stepDir launches SURF on its own whenever a planned step enters + -- water, so by the time an explicit SURF row runs the player is often + -- already afloat -- and useFieldMove then refuses with "You're already + -- SURFING." Already surfing IS this row's postcondition. + if move == "SURF" and A.surfing(game) then + bot:say(" field: already surfing") + return true + end + local user = world:partyMoveUser(move) + if not user then + return nil, ("no party mon knows %s"):format(tostring(move)) + end + if not bot:approachAndFace(row.x, row.y) then + return false, ("could not face (%d,%d)"):format(row.x, row.y) + end + local result = world:useFieldMove(move, user) + if not (result and result.ok) then + return false, ("%s refused here (%s)"):format(move, + tostring(result and result.text)) + end + -- The move runs as a queued script on the next frame the world owns. + bot:wait(8) + bot:clearDialogue({ "yes" }, 3000) + return true + end + + -- Buy from a Mart. + -- + -- Driven through the save rather than the four screens a player walks, the + -- same shortcut ops.teach and A.throwBall take, and honest for the same + -- reason: the money is checked and spent at the item's own price, so this can + -- only buy what the player could have bought standing at the counter. + -- + -- It exists because of one hard dependency. The Cyndaquil line cannot learn + -- SURF, so the route catches a SLOWPOKE for it -- and Elm's aide hands over + -- exactly five POKE BALLs in the whole game to that point. Five throws at a + -- wild SLOWPOKE is a coin flip, and losing it costs SURF, which costs + -- Cianwood, the Storm Badge and every section after. + function ops.buy(row) + local save = game.save + local defs = game.data and game.data.items + local id = row.item + local want = row.count or 1 + if not (save and defs and defs[id]) then + return nil, "unknown item " .. tostring(id) + end + local price = defs[id].price or 0 + save.inventory = save.inventory or {} + local bought = 0 + for _ = 1, want do + if (save.player.money or 0) < price then break end + save.player.money = save.player.money - price + save.inventory[id] = (save.inventory[id] or 0) + 1 + bought = bought + 1 + end + bot:say((" buy: %d x %s for %d, %d left") + :format(bought, id, bought * price, save.player.money or 0)) + return bought > 0 + end + + -- Shove a STRENGTH boulder. + -- + -- Two separate things, and conflating them is why the route carried this as + -- a `manual` row. STRENGTH must first be turned ON for this map load + -- (World:runStrength sets strengthActive, and ResetBikeFlags clears it on + -- every map change), which is a field move used against the boulder. Only + -- then does walking INTO the boulder push it, one cell per step, the way + -- MovementFunction_Strength does on the cart. + -- + -- `count` is how many cells to shove it; `dir` the direction to shove. The + -- bot stands on the far side and walks forward, so the cell it must reach is + -- one step BEHIND the boulder. + function ops.push(row) + local world = A.world(game) + if not world then return false end + local move = "STRENGTH" + local user = world:partyMoveUser(move) + if not user then return nil, "no party mon knows STRENGTH" end + + local d = ({ up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } })[bot.button(row.dir)] + if not d then return false, "unknown push direction " .. tostring(row.dir) end + -- Stand on the opposite side, facing the boulder. + local standX, standY = row.x - d[1], row.y - d[2] + if not bot:walkTo(standX, standY) then + return false, ("could not stand at (%d,%d) to push"):format(standX, standY) + end + bot:face(bot.button(row.dir)) + + if not world.strengthActive then + local result = world:useFieldMove(move, user) + if not (result and result.ok) then + -- "BOULDERS_MOVE" means it was already on, which is a success here. + if not (world.strengthActive) then + return false, ("STRENGTH refused (%s)") + :format(tostring(result and result.text)) + end + end + bot:wait(8) + bot:clearDialogue({ "yes" }, 4000) + end + + bot:say((" push: strength %s, boulder at (%d,%d), pushing %s from (%d,%d)") + :format(world.strengthActive and "on" or "OFF", row.x, row.y, + tostring(row.dir), standX, standY)) + + -- Walking into the boulder is the push, and the player does NOT move: the + -- cart treats a strength boulder like a solid NPC (.CheckNPC's "2"), so the + -- rock slides and you stay where you were. Two consequences the first + -- version got wrong: + -- + -- * Bot:stepDir reports "did not move" for a SUCCESSFUL push, and notes a + -- wall at the boulder's old cell for its trouble. + -- * The next push has to wait for the rock to finish sliding -- + -- World:tryPushBoulder refuses while `npc.moving` -- so back-to-back + -- presses do nothing at all. That was the whole failure: the log said + -- "strength on, boulder ahead" and the lane never opened. + for i = 1, (row.count or 1) do + bot:face(bot.button(row.dir)) + local bx, by = A.pos(game) + bot:stepDir(bot.button(row.dir)) + -- Let the boulder land before asking again. + for _ = 1, 60 do + local npc = A.npcAt(game, bx + d[1], by + d[2]) + if not (npc and npc.moving) then break end + bot:wait(1) + end + bot:wait(4) + local ax, ay = A.pos(game) + local blocked = A.npcAt(game, ax + d[1], ay + d[2]) ~= nil + bot:say((" push %d: player (%d,%d)->(%d,%d), boulder cell %s") + :format(i, bx, by, ax, ay, blocked and "still filled" or "clear")) + -- A push that worked left a wall memory on a cell that is now free. + bot:clearWall(A.mapId(game), bx + d[1], by + d[2]) + if A.busy(game) then bot:clearDialogue(nil, 2000) end + bot:progress() + end + return true + end + + -- Hold a fixed sequence of directions. + -- + -- The one place objectives cannot be expressed as "be at this cell": an ICE + -- floor, where a press slides until something stops you, so the reachable + -- set is a function of the whole route rather than of adjacency and the + -- bot's tile-by-tile planner cannot describe it at all. The asm-walk writes + -- the Mahogany and Blackthorn gym puzzles as exactly this -- a list of + -- directions -- so the route carries the list. + function ops.press(row) + for _, raw in ipairs(row.dirs or {}) do + local dir = bot.button(raw) + if not dir then return false, "unknown direction " .. tostring(raw) end + A.hold(game, dir) + bot:wait(6) + A.releaseDirs(game) + -- Let the slide finish: an ice tile keeps the player moving for several + -- cells after the press ends. + for _ = 1, 120 do + if not A.moving(game) then break end + bot:wait(1) + end + bot:wait(2) + if A.busy(game) then bot:clearDialogue(row.answers, 4000) end + bot:progress() + end + return true + end + + -- Catch one of `species` on this map. + -- + -- Originally just the SLOWPOKE for SURF (Cyndaquil cannot learn it). The + -- route now also catches a POLIWAG for WHIRLPOOL/WATERFALL -- SLOWPOKE's + -- tmhm list has SURF and STRENGTH but neither of those two -- and a bird + -- for FLY. `row.water` restricts the hunt to COLL_WATER cells so a map + -- that also has grass does not burn the ball budget on land encounters. + function ops.catch(row) + local wanted = {} + for _, id in ipairs(row.species or {}) do wanted[id] = true end + + local function have() + for _, mon in ipairs(A.party(game)) do + if wanted[mon.species] then return mon end + end + return nil + end + if have() then + bot:say(" catch: already have one") + return true + end + if A.partySize(game) >= 6 then return nil, "party is full" end + + local ball = row.ball or "POKE_BALL" + -- A badge / teach may have just landed (FOGBADGE is the row above 07.30); + -- drop the canSurf cache before asking, so a stale "no" from before Morty + -- does not refuse a water hunt that is now legal. + bot:forgetSurf() + -- Water hunts need SURF + FOGBADGE before a water cell is even pathable. + if row.water and not bot:canSurf() then + return nil, "water catch needs SURF and FOGBADGE" + end + bot.catchWanted, bot.catchBall = wanted, ball + local ok, err = pcall(function() + for pass = 1, 300 do + if have() then return end + if not A.hasItem(game, ball) then + error({ botStall = true, why = "out of " .. ball }, 0) + end + if A.busy(game) then + -- Answer NO to the nickname prompt a catch ends on: the naming + -- keyboard has no cancel, so opening it strands the run. + bot:clearDialogue({ "no", "no" }, 2000) + end + if A.mapId(game) ~= row.map then + if not bot:travelTo(row.map) then return end + end + local map = A.map(game) + if not map then return end + -- Wander to a cell we can actually reach, and re-roll rather than + -- aiming at one behind a wall: an unreachable target makes walkTo bail + -- immediately, so the bot stands still and never rolls an encounter. + -- Aim at cells that can actually roll an encounter. Collected once + -- per pass and shuffled by index, so the hunt paces the grass instead + -- of the corridor between it. + local spots = {} + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + -- COLL_WATER is in CheckGrassCollision's array, so a land hunt's + -- filter has to EXCLUDE water, not merely not-require it: the + -- Slowpoke Well's pond qualified as an "encounter cell", the + -- shuffle aimed at it, and a party with no SURF bounced off the + -- shore twelve times until the repeat guard killed the row. + local water = A.isWater(map, cx, cy) + if A.isEncounterCell(map, cx, cy) + and ((row.water and water) or (not row.water and not water)) then + spots[#spots + 1] = { cx, cy } + end + end + end + if #spots == 0 then + if row.water then + error({ botStall = true, + why = "no reachable water encounter cells on " .. row.map }, 0) + end + for _ = 1, 10 do + local x = math.random(0, map.widthCells - 1) + local y = math.random(0, map.heightCells - 1) + if bot:planPath(x, y) then bot:walkTo(x, y, { attempts = 3 }) break end + end + else + for _ = 1, 8 do + local pick = spots[math.random(1, #spots)] + if bot:planPath(pick[1], pick[2]) then + bot:walkTo(pick[1], pick[2], { attempts = 3 }) + break + end + end + end + if pass % 20 == 0 then + local left = (game.save and game.save.inventory + and game.save.inventory[ball]) or 0 + bot:say((" catch: still hunting (%d %s left, party %d)") + :format(left, ball, A.partySize(game))) + end + end + end) + bot.catchWanted, bot.catchBall = nil, nil + if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end + + local mon = have() + if mon then + bot:say((" catch: got a %s"):format(tostring(mon.species))) + return true + end + if not ok and type(err) == "table" and err.why then return nil, err.why end + return false, "did not catch " .. table.concat(row.species or {}, "/") + end + + -- Walk about on this map until something happens. + -- + -- Some beats are not reached by going anywhere: they are delivered. Elm's + -- SPECIALCALL_ASSISTANT is the one the route cannot do without -- Falkner's + -- badge script arms it, the call only lands while the player is OUTSIDE and + -- moving, and the call is what puts his aide in the Violet Pokecenter with + -- the Togepi Egg. `settle` cannot do this: there is nothing on screen to + -- clear, the trigger is the walking itself. + function ops.wander(row) + -- `expectClear` is the mirror of `expect`: an object_event's event flag + -- HIDES the object when it is SET, so "the aide has appeared" is a flag + -- going to false. Only this op needs it, so it is not part of the generic + -- postcondition machinery. + local function arrived() + if row.expectClear then return flagSet(row.expectClear) == false end + if row.expect then return satisfied(row) end + return false + end + local target = row.expect or row.expectClear + for pass = 1, (row.budget or 40) do + if target and arrived() then return true end + if A.busy(game) then bot:clearDialogue(row.answers, 4000) end + if A.mapId(game) ~= row.map then + if not bot:travelTo(row.map) then return false, "left " .. row.map end + end + local map = A.map(game) + if not map then return false end + local x = math.random(0, math.max(0, map.widthCells - 1)) + local y = math.random(0, math.max(0, map.heightCells - 1)) + if bot:planPath(x, y) then bot:walkTo(x, y, { attempts = 3 }) end + end + if A.busy(game) then bot:clearDialogue(row.answers, 4000) end + return target == nil or arrived() + end + + function ops.check(row) + return satisfied(row) + end + + function ops.manual(row) + return nil, row.why + end + + -- --------------------------------------------------------------------- + -- the run + -- --------------------------------------------------------------------- + + local from = os.getenv("POKEPORT_GOLD_FROM") + local until_ = os.getenv("POKEPORT_GOLD_UNTIL") + -- Checkpointing. POKEPORT_GOLD_CKPT=1 writes a save at the first row of + -- every section; POKEPORT_GOLD_RESUME=NN loads section NN's checkpoint and + -- starts the route there. Unlike POKEPORT_GOLD_FROM (which skips ROWS and + -- leaves the player in the bedroom), this restores the game state, so a + -- late-section bug is a 30-second test instead of a 10-minute replay. + local checkpointing = os.getenv("POKEPORT_GOLD_CKPT") == "1" + local resume = os.getenv("POKEPORT_GOLD_RESUME") + + local function sectionOf(id) + return tostring(id):match("^(%d+)%.") or tostring(id) + end + + -- Wait for the world before touching anything: Game2 builds it during + -- load, and a driver that starts pressing buttons first is pressing them + -- at a nil map. + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + if not A.ready(game) then + print("[gold] the world never came up; is POKEPORT_GAME=gold set?") + return + end + + if resume then + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[gold] cannot resume from section %s: %s") + :format(resume, tostring(err))) + return + end + -- continueGame rebuilds the world from scratch, so wait for it the same + -- way the cold boot above does. + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + if not A.ready(game) then + print("[gold] resumed save never brought the world up") + return + end + bot:say(("resumed section %s on %s at %d,%d") + :format(resume, tostring(A.mapId(game)), select(1, A.pos(game)), + select(2, A.pos(game)))) + end + + bot:say(("start on %s at %d,%d") + :format(tostring(A.mapId(game)), select(1, A.pos(game)), + select(2, A.pos(game)))) + + local started = from == nil and resume == nil + local completed, failed, skipped = {}, {}, {} + local aborted = nil + local lastSection = nil + + -- Row id -> position, so a failed row can send the runner BACKWARDS. + -- + -- `retryFrom` is how the route loops a fight it cannot yet win. Losing in + -- Gen 2 costs money and nothing else: every point of experience earned on + -- the way to the wipe is kept. So a party that loses to Bruno still banks + -- the levels it took off Hitmontop, and the next attempt starts stronger + -- than the last one. Repeat that and the fight is eventually winnable + -- without any route change at all -- which is the only grind available at + -- this point, since Johto's wilds cap around level 30 and the Elite Four's + -- own mons are in the forties. + -- + -- The rewind target is a row, not a map, because the whole gauntlet has to + -- restart: the Indigo Plateau Pokecenter's MAPCALLBACK_NEWMAP clears every + -- EVENT_BEAT_ELITE_4_*, so after a wipe Will is standing there again. + local indexOf = {} + for i, r in ipairs(ROUTE) do indexOf[r.id] = i end + local attempts = {} + -- Frame stamp of when each row's current lap began, so a lap that costs + -- nothing can be told from one that actually ran. + local lapStartedAt = {} + local retryCap = tonumber(os.getenv("POKEPORT_GOLD_RETRY")) or 12 + + local function clearFailure(id) + for i = #failed, 1, -1 do + if failed[i].id == id then table.remove(failed, i) end + end + end + + local index = 0 + while index < #ROUTE do + index = index + 1 + local row = ROUTE[index] + if not started and row.id == from then started = true end + if not started and resume and sectionOf(row.id) == resume then + started = true + end + if started then + -- One checkpoint per section, taken on the way IN so it captures the + -- state the section starts from rather than the state it left behind. + if checkpointing and sectionOf(row.id) ~= lastSection then + lastSection = sectionOf(row.id) + local ok, err = A.writeCheckpoint(game, lastSection) + bot:say(("checkpoint %s: %s") + :format(lastSection, ok and "written" or ("FAILED " .. tostring(err)))) + end + bot:progress() + -- Teaching SURF, earning FOGBADGE or losing the surfer all change which + -- cells are passable and which borders can be stood on, and both answers + -- are cached for speed. A route row is the natural place to forget them: + -- it is the only point where the party or the badges can have changed. + bot:forgetSurf() + bot.borders = {} + -- Heal between rows when the party is in no state to fight. + -- + -- Bot:maybeHeal has existed since the first version and was never called, + -- which did not matter while a lost battle still ran the winner's script + -- -- the bot fainted its way through Whitney and four Elite Four rooms and + -- the run looked fine. With that fixed (tests/gen2_battle_loss_test.lua) + -- a wipe costs the whole objective, so arriving healthy is the difference + -- between earning a badge and re-walking half of Johto. + -- + -- Never inside the League: its rooms seal behind you, and the Indigo + -- Plateau Pokecenter's MAPCALLBACK_NEWMAP clears every + -- EVENT_BEAT_ELITE_4_*, so a heal after Will restarts the gauntlet. + -- + -- `noHeal` is for puzzles that a MAPCALLBACK_NEWMAP undoes. The + -- Goldenrod underground switch byte resets on every entry to the + -- underground / warehouse; a heal between Switch3 and Switch2 left the + -- room, zeroed the byte, and the warehouse door never opened. + if not row.noHeal and not tostring(row.id):match("^18%.") then + -- Guarded like tryReach below: healUp travels, travel can stall, and + -- a stall raised out here escaped the runner and killed a whole run + -- as `driver error: table: 0x...` with neither a row nor a reason + -- attached (the Radio Tower 2F corridor wedge). A failed + -- between-rows heal is not fatal; the next row plans fresh. + local healed, err = pcall(bot.maybeHeal, bot) + if not healed then + if type(err) == "table" and err.botStall then + bot:say((" heal stalled: %s"):format(tostring(err.why))) + bot:progress() + else + error(err, 0) + end + end + end + bot:say(("== [%d/%d] %s %s %s") + :format(index, #ROUTE, row.id, row.op, tostring(row.map))) + + -- Already done? A resumed run, or a script that set the flag as a side + -- effect of an earlier row, should not redo the work. + if row.expect and satisfied(row) then + bot:say((" already satisfied (%s)"):format(row.expect)) + completed[row.id] = true + clearFailure(row.id) + else + -- Be on the right map first. Every op except `travel` itself assumes + -- it; this is the single line that turns the asm-walk's per-map + -- checklist into something that does not need hop-by-hop directions. + -- Guarded, for the same reason the op below is. + -- + -- Bot:reachMap raises a `{ botStall = true }` table when it gives up, + -- and these two calls sit OUTSIDE the pcall that catches it -- so a + -- stall while travelling to a row's map escaped the runner entirely and + -- killed the whole run with `driver error: table: 0x...`, which names + -- neither the row nor the reason. Run 26 died that way at row 196 after + -- eleven tries to cross MAHOGANY_TOWN's east edge, ~450k frames in, + -- taking sections 07-12 with it. A stall here means exactly what a + -- failed travel means: this row could not reach its map. + local function tryReach(map) + local ok, res = pcall(bot.reachMap, bot, map, row.region) + if ok then return res end + if type(res) == "table" and res.botStall then + bot:say((" travel to %s stalled: %s"):format( + map, tostring(res.why))) + return false + end + error(res, 0) + end + + local onMap = true + if row.op ~= "travel" and A.mapId(game) ~= row.map then + onMap = tryReach(row.map) + if not onMap then + -- One retry. travelTo leaves behind a trip's worth of exit bans and + -- a half-finished position, and a second attempt plans fresh from + -- wherever it stopped -- which is what gets the bot back INTO + -- Goldenrod Gym after Whitney's script pushes it out, so the badge + -- talk that follows the fight can actually happen. + bot:say((" travel to %s failed, retrying once"):format(row.map)) + if A.busy(game) then bot:clearDialogue() end + onMap = tryReach(row.map) + end + end + + local ok, why + if not onMap then + ok, why = false, "could not reach " .. tostring(row.map) + else + local run = ops[row.op] + if not run then + ok, why = nil, "unknown op " .. tostring(row.op) + else + local guarded, res, err = pcall(run, row) + if not guarded then + -- A stall inside an op is information about THAT op, not the end + -- of the run: re-raise anything else. + if type(res) == "table" and res.botStall then + ok, why = false, "stalled: " .. tostring(res.why) + -- ...unless it is fatal. A battle the engine cannot finish + -- leaves the battle on the stack, so every later row would + -- re-enter it and stall the same way, filling the report with + -- 80 copies of one problem. Stop and say what happened. + if res.fatal then + bot:say("ABORTING RUN: " .. tostring(res.why)) + failed[#failed + 1] = { id = row.id, why = res.why, + map = tostring(A.mapId(game)) } + aborted = res.why + break + end + else + error(res, 0) + end + else + ok, why = res, err + end + end + end + + -- `expect` is the ORACLE; the op's own opinion is advisory. + -- + -- This used to read `ok and satisfied(row)`, which short-circuits: an + -- op that returned false meant the flag was never even read, and the + -- failure was then reported with the default string "postcondition + -- <flag> not set" -- wording that asserts a check which did not happen. + -- + -- Row 18.18 is the case that matters. Beating Lance opens the door and + -- the bot walks straight through it, so the `walk` op ends with "left + -- LANCES_ROOM for HALL_OF_FAME" and reports failure -- after winning. + -- Run 20 beat the Champion ten times ("CHAMPION LANCE was defeated!", + -- the prize money, the door) and threw all ten away, then rewound out + -- of the Hall of Fame to refight the gauntlet. + -- + -- If a row named a flag and that flag is set, the row is done, whatever + -- the op made of its own walk. + local passed + if row.expect then passed = satisfied(row) else passed = ok end + if ok == nil then + bot:say((" SKIP %s: %s"):format(row.id, tostring(why))) + skipped[#skipped + 1] = { id = row.id, why = why } + elseif passed then + bot:say(" ok") + completed[row.id] = true + clearFailure(row.id) + else + local reason = why + or (row.expect and ("postcondition " .. row.expect .. " not set")) + or "action reported failure" + local limit = row.retryLimit or retryCap + if row.optional then + bot:say((" optional miss %s: %s"):format(row.id, reason)) + skipped[#skipped + 1] = { id = row.id, why = reason, soft = true } + elseif row.retryFrom and indexOf[row.retryFrom] + and (attempts[row.id] or 0) < limit + and bot:frames() > (lapStartedAt[row.id] or -1) then + -- The guard on frames: a lap that took ZERO frames did not retry + -- anything, it spun. Cianwood is the case -- a boulder puzzle left + -- in a sealed state answers "approach: nowhere to stand" + -- immediately, so run 25 burned all four of Chuck's laps inside a + -- single frame without the player ever moving. If a whole lap + -- costs nothing, the state that caused the failure has not changed + -- and never will; stop and report instead of spending the budget. + -- Not a failure yet: a lap. Rewind and run the stretch again with + -- whatever levels this attempt earned before it died. + attempts[row.id] = (attempts[row.id] or 0) + 1 + lapStartedAt[row.id] = bot:frames() + bot:say((" RETRY %s (lap %d/%d): %s -- rewinding to %s") + :format(row.id, attempts[row.id], limit, reason, row.retryFrom)) + if A.busy(game) then bot:clearDialogue() end + -- Bank the lap. + -- + -- The whole point of a retry lap is that the levels it earned are + -- kept, and a wall-clock timeout that threw them away would undo + -- exactly the thing the loop exists to accumulate: the Elite Four + -- laps take the lead up ~4 levels each, so a run killed at lap + -- eight restarts eleven levels weaker than it died. A wipe leaves + -- the player healed and stood in a Pokecenter, which is the + -- cleanest state in the loop to snapshot. + local section = sectionOf(row.id) + local wrote, err = A.writeCheckpoint(game, section) + bot:say((" lap checkpoint %s: %s"):format( + section, wrote and "written" or ("skipped " .. tostring(err)))) + index = indexOf[row.retryFrom] - 1 + else + bot:say((" FAIL %s: %s"):format(row.id, reason)) + failed[#failed + 1] = { id = row.id, why = reason, + map = tostring(A.mapId(game)) } + end + end + end + end + if aborted then break end + if until_ and row.id == until_ then break end + end + + -- --------------------------------------------------------------------- + -- report + -- --------------------------------------------------------------------- + print("") + print("================ gold bot summary ================") + -- Counted over the SET of ids, not the number of passes: a retry lap runs + -- Will's room again and that is one row completed, not two. + local done = 0 + for _, r in ipairs(ROUTE) do + if completed[r.id] then done = done + 1 end + end + print(("route rows completed : %d/%d"):format(done, #ROUTE)) + print(("badges : %d"):format(A.badges(game))) + print(("final map : %s"):format(tostring(A.mapId(game)))) + print(("party : %d mon, lowest level %d") + :format(A.partySize(game), A.minLevel(game))) + print(("frames : %d"):format(bot:frames())) + print(("teleport shortcuts : %d"):format(bot.teleports or 0)) + if #skipped > 0 then + print(("\nnot implemented / optional misses (%d):"):format(#skipped)) + for _, s in ipairs(skipped) do + print((" %-8s %s"):format(s.id, tostring(s.why))) + end + end + if #failed > 0 then + print(("\nFAILED (%d) -- each names an asm-walk checklist row:"):format(#failed)) + for _, f in ipairs(failed) do + print((" %-8s on %-24s %s"):format(f.id, f.map, tostring(f.why))) + end + end + if aborted then + print(("\nRUN ABORTED: %s"):format(tostring(aborted))) + end + -- The result line names the furthest thing actually PROVEN, in flags. + -- + -- It used to report the Mineral Badge either way, which was the milestone + -- when the bot could not get past Olivine and was long stale by the time the + -- Champion fell -- run 22 beat Lance and still printed "reached the Mineral + -- Badge". Deliberately NOT keyed on `final map`: the Hall of Fame is + -- reachable by the harness teleport, and EVENT_BEAT_ELITE_FOUR can be left + -- set in a resumed checkpoint, so neither is evidence. Lance's own flag is. + local champion = A.event(game, "EVENT_BEAT_CHAMPION_LANCE") + local badges = A.badges(game) + print("") + if champion then + print("RESULT: CHAMPION LANCE DEFEATED -- the game is beaten") + if (bot.teleports or 0) > 0 then + print((" (%d teleport shortcut(s) were used somewhere in this run;" + .. " check they were not on the way to the League)") + :format(bot.teleports)) + end + elseif badges >= 8 then + print("RESULT: all eight badges, Champion not beaten") + else + print(("RESULT: %d/8 badges, Champion not beaten"):format(badges)) + end + print("==================================================") +end diff --git a/tests/drivers/gold_burned_tower_beasts.lua b/tests/drivers/gold_burned_tower_beasts.lua new file mode 100644 index 00000000..c75b772d --- /dev/null +++ b/tests/drivers/gold_burned_tower_beasts.lua @@ -0,0 +1,85 @@ +-- ReleaseTheBeasts (maps/BurnedTowerB1F.asm:25), the one scene in the game +-- that stages six objects sharing two event flags one beat at a time. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_burned_tower_beasts.lua love . +-- +-- What a human is watching for, in the cart's own order: Raikou APPEARS, its +-- standing twin blinks out, Raikou cries; then Entei, then Suicune, each on +-- its own beat -- and at the end each of the three jumps away and vanishes on +-- its own turn rather than all three going at once. +-- +-- All three animated beasts carry EVENT_BURNED_TOWER_B1F_BEASTS_1 and all +-- three statics carry EVENT_BURNED_TOWER_B1F_BEASTS_2, so a port that derives +-- who is standing from the event flag alone pops the whole group on the first +-- `appear` and clears it on the first `disappear`. wObjectMasks is the per +-- object byte that keeps them independent (home/map.asm:1542 MaskObject, +-- engine/overworld/map_objects_2.asm:1 LoadObjectMasks). +-- +-- Shots land in /tmp/gold-beasts, one per beat plus a running census. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-beasts" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- The scene is entered from the ladder; drop in beside the trigger instead, + -- which is the coord event at (9,5). + world:setMap("BURNED_TOWER_B1F", 9, 7, "up") + U.wait(20) + + -- The coord event carries its own scene id, so ask the map rather than + -- hardcoding SCENE_BURNEDTOWERB1F_RELEASE_THE_BEASTS. + local trigger + for _, ev in ipairs(world.map.def.coordEvents or {}) do + if ev.x == 9 and ev.y == 5 then trigger = ev end + end + assert(trigger, "BURNED_TOWER_B1F has no coord event at (9,5)") + world.mapScenes[world.map.id] = trigger.sceneId or 0 + + local function census() + local n = 0 + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.eventFlag and npc.def.eventFlag ~= 0xFFFF then + n = n + 1 + end + end + return n + end + + U.shot(game, out .. "/00-before.png") + print(("[driver] %d flagged objects standing before the scene") + :format(census())) + + -- Walk onto the trigger and let the scene run, shooting every beat. + U.hold(game, "up", 24) + U.wait(10) + + local counts = {} + for step = 1, 60 do + U.wait(10) + counts[#counts + 1] = census() + if step % 3 == 0 then + U.shot(game, ("%s/01-beat-%02d.png"):format(out, step)) + end + if step > 6 and not world:busy() then break end + end + U.wait(30) + U.shot(game, out .. "/02-after.png") + + -- The census must never jump by three: every appear and every disappear in + -- ReleaseTheBeasts moves exactly one object. + local worst = 0 + for i = 2, #counts do + local delta = math.abs(counts[i] - counts[i - 1]) + if delta > worst then worst = delta end + end + print(("[driver] %d samples, largest one-sample swing %d (want 1)") + :format(#counts, worst)) + print(("[driver] %d flagged objects standing after the scene") + :format(census())) + print("[driver] PASS gold burned tower beasts in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_catch_probe.lua b/tests/drivers/gold_catch_probe.lua new file mode 100644 index 00000000..9d4a372a --- /dev/null +++ b/tests/drivers/gold_catch_probe.lua @@ -0,0 +1,171 @@ +-- Catch-op probe: resume a checkpoint and hunt one species. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=08 \ +-- POKEPORT_GOLD_CATCH="ECRUTEAK_CITY:POLIWAG,POLIWHIRL" \ +-- POKEPORT_GOLD_CATCH_WATER=1 \ +-- POKEPORT_DRIVER=tests/drivers/gold_catch_probe.lua love . +-- +-- Used to prove a water/grass hunt without replaying the section that leads +-- up to it. Placement is travelTo (or a teleport if POKEPORT_GOLD_CATCH_TP=1); +-- the measurement is ops.catch. + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + if not A.ready(game) then + print("[catch-probe] the world never came up") + return + end + + local resume = os.getenv("POKEPORT_GOLD_RESUME") + if resume then + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[catch-probe] cannot resume %s: %s"):format(resume, tostring(err))) + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + end + + local spec = os.getenv("POKEPORT_GOLD_CATCH") + if not spec then + print("[catch-probe] set POKEPORT_GOLD_CATCH=MAP:SPECIES[,SPECIES...]") + return + end + local map, rest = spec:match("^%s*([%w_]+)%s*:%s*(.+)%s*$") + if not map then + print(("[catch-probe] cannot parse %q"):format(spec)) + return + end + local species = {} + for id in rest:gmatch("[%w_]+") do species[#species + 1] = id end + + bot:forgetSurf() + print(("[catch-probe] start on %s at %d,%d; surf=%s; balls=%s") + :format(tostring(A.mapId(game)), select(1, A.pos(game)), + select(2, A.pos(game)), + tostring(bot:canSurf()), + tostring(game.save and game.save.inventory + and game.save.inventory.POKE_BALL))) + + if os.getenv("POKEPORT_GOLD_CATCH_TP") == "1" then + if not A.teleport(game, map, 20, 20) then + print("[catch-probe] teleport failed") + return + end + bot:wait(30) + else + if A.mapId(game) ~= map then + local ok = bot:travelTo(map) + print(("[catch-probe] travelTo %s: %s"):format(map, ok and "ok" or "FAIL")) + if not ok then return end + end + end + + -- Same hunt loop as ops.catch (water filter + bot.catchWanted throws). + print("[catch-probe] hunting " .. table.concat(species, "/")) + + local wanted = {} + for _, id in ipairs(species) do wanted[id] = true end + local function have() + for _, mon in ipairs(A.party(game)) do + if wanted[mon.species] then return mon end + end + end + if have() then + print("[catch-probe] already have one: " .. have().species) + return + end + + local water = os.getenv("POKEPORT_GOLD_CATCH_WATER") == "1" + bot:forgetSurf() + if water and not bot:canSurf() then + print("[catch-probe] FAIL: water catch needs SURF and FOGBADGE") + return + end + + local ball = os.getenv("POKEPORT_GOLD_CATCH_BALL") or "POKE_BALL" + bot.catchWanted, bot.catchBall = wanted, ball + local start = bot:frames() + local caught = nil + for pass = 1, 300 do + caught = have() + if caught then break end + if not A.hasItem(game, ball) then + print("[catch-probe] FAIL: out of " .. ball) + break + end + if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end + if A.mapId(game) ~= map then + if not bot:travelTo(map) then + print("[catch-probe] FAIL: left map and could not return") + break + end + end + local m = A.map(game) + if not m then break end + local spots = {} + for cy = 0, m.heightCells - 1 do + for cx = 0, m.widthCells - 1 do + if A.isEncounterCell(m, cx, cy) + and (not water or A.isWater(m, cx, cy)) then + spots[#spots + 1] = { cx, cy } + end + end + end + if pass == 1 then + print(("[catch-probe] %d encounter spots (water=%s)") + :format(#spots, tostring(water))) + end + if #spots == 0 then + print("[catch-probe] FAIL: no encounter spots") + break + end + for _ = 1, 8 do + local pick = spots[math.random(1, #spots)] + if bot:planPath(pick[1], pick[2]) then + bot:walkTo(pick[1], pick[2], { attempts = 3 }) + break + end + end + if pass % 20 == 0 then + local left = (game.save and game.save.inventory + and game.save.inventory[ball]) or 0 + print(("[catch-probe] still hunting pass=%d balls=%d party=%d") + :format(pass, left, A.partySize(game))) + end + end + bot.catchWanted, bot.catchBall = nil, nil + if A.busy(game) then bot:clearDialogue({ "no", "no" }, 2000) end + + caught = have() + if caught then + print(("[catch-probe] OK: caught %s in %d frames") + :format(caught.species, bot:frames() - start)) + -- Prove the HM lists that motivated the catch. + local def = game.data and game.data.pokemon and game.data.pokemon[caught.species] + local haveHm = {} + for _, id in ipairs((def and def.tmhm) or {}) do + if id == "WHIRLPOOL" or id == "WATERFALL" or id == "SURF" + or id == "STRENGTH" or id == "FLY" then + haveHm[#haveHm + 1] = id + end + end + print(("[catch-probe] tmhm of interest: %s") + :format(#haveHm > 0 and table.concat(haveHm, ", ") or "(none)")) + else + print(("[catch-probe] FAIL: did not catch in %d frames") + :format(bot:frames() - start)) + end +end diff --git a/tests/drivers/gold_center_pc.lua b/tests/drivers/gold_center_pc.lua new file mode 100644 index 00000000..7a089570 --- /dev/null +++ b/tests/drivers/gold_center_pc.lua @@ -0,0 +1,137 @@ +-- Assertion driver: the Pokecenter PC and the bedroom PC, in the running game. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_center_pc.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- What it proves that tests/gen2_pc_screens_test.lua cannot: the whole chain +-- inside a live love session -- the A press on the Cherrygrove Pokecenter's +-- COLL_PC tile runs PCScript (engine/events/std_scripts.asm) through the real +-- input path, `special PokemonCenterPC` opens the whose-PC menu, <PLAYER>'s +-- PC deposits an item into save.pcItems, and the bedroom PC opens the ITEM PC +-- (PLAYERSPC_HOUSE), not the storage system. +-- +-- Shots land in /tmp/gold-center-pc. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-center-pc" + local fails = 0 + + local function ok(cond, msg) + if cond then print("[centerpc] ok " .. msg) + else fails = fails + 1 print("[centerpc] FAIL " .. msg) end + return cond + 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(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + + local Mon = require("src.battle.gen2.Mon") + save.party = { Mon.new(game.data, "CYNDAQUIL", 10) } + save.inventory = save.inventory or {} + save.inventory.POTION = (save.inventory.POTION or 0) + 2 + save.pcItems = {} + local potionsBefore = save.inventory.POTION + + local function topId() + local top = game.stack:top() + return top and top.screenId or nil + end + + -- ---- the Pokecenter ------------------------------------------------------ + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"), + "setMap CHERRYGROVE_POKECENTER_1F failed") + U.wait(5) + local pcX, pcY + for cy = 0, w.map.heightCells - 1 do + for cx = 0, w.map.widthCells - 1 do + if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end + end + end + assert(pcX, "no COLL_PC tile in the Pokecenter") + assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"), + "setMap onto the PC tile failed") + U.wait(5) + + tap("a", 8) + ok(topId() == "Gen2CenterPcMenu", + "A at the Pokecenter PC opens the whose-PC menu (top: " + .. tostring(topId()) .. ")") + U.shot(game, out .. "/01-turned-on.png") + tap("a", 4) -- the turn-on line + U.shot(game, out .. "/02-whose-pc.png") + + -- <PLAYER>'s PC, then DEPOSIT ITEM, then one POTION into the PC. + tap("down", 4) + tap("a", 4) + tap("a", 4) + tap("a", 6) -- both PokecenterPlayersPCText pages + ok(topId() == "Gen2ItemPcMenu", + "<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")") + U.shot(game, out .. "/03-item-pc.png") + tap("down", 4) + tap("a", 6) -- DEPOSIT ITEM -> the PACK chooser + U.shot(game, out .. "/04-deposit-pack.png") + tap("a", 4) -- the POTION row + tap("a", 6) -- x1 + ok(save.pcItems.POTION == 1, + "one POTION landed in save.pcItems (" .. tostring(save.pcItems.POTION) .. ")") + ok(save.inventory.POTION == potionsBefore - 1, + "and left the bag (" .. tostring(save.inventory.POTION) .. ")") + U.shot(game, out .. "/05-deposited.png") + tap("a", 4) -- the Deposited line + tap("b", 6) -- close the PACK + tap("b", 4) -- LOG OFF row is last; B logs off too + ok(topId() == "Gen2CenterPcMenu", "logging off returns to the whose-PC menu") + tap("b", 6) -- shutdown + ok(topId() == nil or topId() ~= "Gen2CenterPcMenu", + "B shuts the Pokecenter PC down") + + -- ---- the bedroom --------------------------------------------------------- + local house = w.maps and w.maps.PLAYERS_HOUSE_2F + local hx, hy + for _, ev in ipairs((house and house.bgEvents) or {}) do + if ev.kind == 1 then hx, hy = ev.x, ev.y end -- BGEVENT_UP: the PC + end + assert(hx, "no BGEVENT_UP bg event in PLAYERS_HOUSE_2F") + assert(w:setMap("PLAYERS_HOUSE_2F", hx, hy + 1, "up"), + "setMap PLAYERS_HOUSE_2F failed") + U.wait(5) + + tap("a", 8) + local top = game.stack:top() + ok(top and top.screenId == "Gen2ItemPcMenu", + "the bedroom PC is the ITEM PC (top: " .. tostring(topId()) .. ")") + ok(top and top.house == true, "in its PLAYERSPC_HOUSE shape") + U.shot(game, out .. "/06-bedroom-boot.png") + tap("a", 4) -- the turn-on line + U.shot(game, out .. "/07-bedroom-menu.png") + -- WITHDRAW the POTION deposited downstairs: the PC is one PC. + tap("a", 6) + tap("a", 4) + tap("a", 6) + ok(save.pcItems.POTION == nil, + "the POTION withdrawn upstairs left the PC") + ok(save.inventory.POTION == potionsBefore, + "and is back in the bag (" .. tostring(save.inventory.POTION) .. ")") + tap("a", 4) -- the Withdrew line + tap("b", 4) -- back to the menu + tap("b", 6) -- TURN OFF + ok(game.stack:top() ~= top, "closing the bedroom PC pops it") + ok(not w.vm:running(), "and PlayersHousePCScript ran to its end") + + print(("[centerpc] %d failures"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_clock_anchor.lua b/tests/drivers/gold_clock_anchor.lua new file mode 100644 index 00000000..1010de4e --- /dev/null +++ b/tests/drivers/gold_clock_anchor.lua @@ -0,0 +1,67 @@ +-- Assertion driver: a run that skips the boot cinema still starts on an +-- anchored game clock. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_clock_anchor.lua love . +-- +-- NewGame (pokegold engine/menus/intro_menu.asm) calls OakSpeech, whose first +-- line is `farcall InitClock`, so wStartHour / wStartMinute always exist before +-- InitializeWorld. A driver run never reaches that screen; without the anchor +-- the save has no base and every hour read falls through to the host clock, so +-- the same run is MORN in the morning and NITE at night and the grass rolls a +-- different table each time. Prints PASS/FAIL and quits, because LOVE only +-- flushes stdout on exit. +local U = require("tests.drivers.util") + +local Clock = require("src.core.gen2.Clock") +local Palettes = require("src.world.gen2.Palettes") + +return function(game) + local failures = 0 + local function want(label, ok, detail) + if ok then + U.log("ok " .. label) + else + failures = failures + 1 + U.log("FAIL " .. label .. " (" .. tostring(detail) .. ")") + end + end + + U.wait(30) + local world = game.world + want("the world booted", world ~= nil and world.map ~= nil, + world and world.status) + if not (world and world.map) then + U.log(failures == 0 and "PASS" or "FAIL") + love.event.quit() + return + end + + want("the new game anchored the clock", Clock.isSet(game.save), + "save.rtc.startMinute is nil") + + local hour = world:hour() + want("World:hour reads that base", hour == Clock.hour(game.save), + ("world %s vs clock %s"):format(tostring(hour), + tostring(Clock.hour(game.save)))) + want("the map is lit by the same hour", + world.daytime == Palettes.daytimeFor(world.map.def, hour, world.flashUsed), + ("daytime %s at hour %s"):format(tostring(world.daytime), tostring(hour))) + + -- The pin a screenshot run uses has to move both halves together. + local forced = tonumber(os.getenv("POKEPORT_GOLD_HOUR") or "") + if forced then + want("POKEPORT_GOLD_HOUR pins World:hour", hour == forced % 24, hour) + want("and the palette follows it", + world.daytime == Palettes.daytimeFor(world.map.def, forced, + world.flashUsed), tostring(world.daytime)) + end + + -- The map's own PALETTE_* can pin the daytime (the bedroom is PALETTE_DAY), + -- so log the clock's answer next to the map's. + U.log(("clock %02d:%02d, clock daytime %s, map daytime %s, anchored %s"):format( + world:hour(), world:minute(), Palettes.clockDaytime(hour), + tostring(world.daytime), tostring(Clock.isSet(game.save)))) + U.log(failures == 0 and "PASS" or "FAIL") + love.event.quit() +end diff --git a/tests/drivers/gold_dig_warp.lua b/tests/drivers/gold_dig_warp.lua new file mode 100644 index 00000000..0ac4c3c2 --- /dev/null +++ b/tests/drivers/gold_dig_warp.lua @@ -0,0 +1,146 @@ +-- Assertion driver: the dig / escape triple that an ordinary door banks, and +-- the rod's BATTLETYPE_FISH, end to end in the running game. It PASSES or it +-- errors; there is nothing to eyeball. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_dig_warp.lua love . +-- +-- tests/gen2_dig_warp_test.lua proves the rule over the real map headers with +-- a recording setMap; what it cannot prove is a genuine map load underneath +-- it. home/map.asm EnterMapWarp `.SaveDigWarp` banks the door on every +-- outdoor-to-indoor warp, so DARK CAVE entered off Route 31 must rope out onto +-- Route 31 and the same cave entered off Route 46 must rope out onto Route 46. +-- +-- The tail rides World:updateFishing through the real stack: FishFunction's +-- `.goodtofish` writes BATTLETYPE_FISH beside the hooked mon, and that is the +-- one condition LureBallMultiplier reads. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + assert(save.party[1], "the cache carries no CYNDAQUIL to seed a party") + + local function tapUntil(predicate, tries, btn) + for _ = 1, tries or 300 do + if predicate() then return true end + U.tap(game, btn or "a") + U.wait(2) + end + return predicate() + end + + local function settle(mapId) + for _ = 1, 300 do + if world.map.id == mapId and not world.mapSetup then return true end + U.wait(1) + end + return world.map.id == mapId and not world.mapSetup + end + + -- Walk a real warp tile: stand on the door and take it, the way + -- TryTileCollisionEvent's warpcheck does. + local function useDoor(fromMap, warpIndex, intoMap) + assert(world:setMap(fromMap, 5, 5, "down"), "setMap " .. fromMap .. " failed") + U.wait(5) + local door = world.maps[fromMap].warps[warpIndex] + assert(door, fromMap .. " has no warp " .. warpIndex) + world.player.cellX, world.player.cellY = door.x, door.y + assert(world:takeWarp(door), fromMap .. " warp " .. warpIndex .. " refused") + assert(settle(intoMap), "did not arrive on " .. intoMap + .. " (on " .. tostring(world.map.id) .. ")") + return door + end + + -- START, then walk the cursor to the PACK row and open it. + local function openPack() + U.tap(game, "start") + U.wait(3) + local menu = game.stack:top() + assert(menu and menu.list, "START menu did not open") + local guard = 0 + while menu.list:current().value ~= "pack" do + U.tap(game, "down") + U.wait(2) + guard = guard + 1 + assert(guard < 12, "no PACK row in the START menu") + end + U.tap(game, "a") + U.wait(3) + local pack = game.stack:top() + assert(pack and pack.rows, "PACK did not open") + return pack + end + + -- ---- the door banks, and the rope comes out of it ------------------------ + + local function ropeOutOf(routeId, warpIndex) + local door = useDoor(routeId, warpIndex, "DARK_CAVE_VIOLET_ENTRANCE") + assert(world.backupWarp, routeId .. ": the cave door banked no triple") + assert(world.backupWarp.map == routeId, + routeId .. ": banked " .. tostring(world.backupWarp.map) .. " instead") + assert(world.backupWarp.warp == warpIndex, + routeId .. ": banked warp " .. tostring(world.backupWarp.warp)) + + save.inventory = { ESCAPE_ROPE = 1 } + local pack = openPack() + assert(pack.rows[1] and pack.rows[1].id == "ESCAPE_ROPE", + "the PACK does not show the ESCAPE ROPE") + -- A opens the item submenu (.ItemBallsKey_LoadSubmenu, + -- engine/items/pack.asm:243) and USE is its first row. + U.tap(game, "a") + U.wait(2) + U.tap(game, "a") + U.wait(3) + assert(game.stack:top() ~= pack, + "using the rope must quit the PACK (PACKSTATE_QUITRUNSCRIPT)") + tapUntil(function() + return game.stack:top() == nil and not world.mapSetup + and world.map.id ~= "DARK_CAVE_VIOLET_ENTRANCE" + end) + assert(world.map.id == routeId, + "the rope paid out to " .. tostring(world.map.id) .. ", not " .. routeId) + assert(world.player.cellX == door.x and world.player.cellY == door.y, + routeId .. ": the rope landed off the door tile") + assert(save.inventory.ESCAPE_ROPE == nil, "the rope was not consumed") + end + + ropeOutOf("ROUTE_31", 3) + U.log("PASS dig warp: DARK CAVE off Route 31 ropes back onto Route 31") + ropeOutOf("ROUTE_46", 3) + U.log("PASS dig warp: the same cave off Route 46 ropes back onto Route 46") + + -- Leaving a cave for a route is indoor-to-outdoor: nothing banks. + useDoor("ROUTE_31", 3, "DARK_CAVE_VIOLET_ENTRANCE") + local banked = world.backupWarp + useDoor("DARK_CAVE_VIOLET_ENTRANCE", 1, "ROUTE_31") + assert(world.backupWarp == banked, + "walking OUT of the cave rewrote the dig triple") + U.log("PASS dig warp: an indoor-to-outdoor door leaves the triple alone") + + -- ---- the rod's own battle carries BATTLETYPE_FISH ------------------------ + + local hooked = Mon.new(game.data, "MAGIKARP", 10) + assert(hooked, "the cache carries no MAGIKARP") + world.fishing = { phase = "bite", timer = 0, outcome = "battle", + wild = hooked } + world:updateFishing() + tapUntil(function() + local top = game.stack:top() + return top ~= nil and top.battle ~= nil + end) + local screen = game.stack:top() + assert(screen and screen.battle, "the rod's bite did not push a battle") + assert(screen.battle.battleType == "fish", + "the rod's battle carries battleType " + .. tostring(screen.battle.battleType)) + U.log("PASS fishing: the rod's encounter carries BATTLETYPE_FISH") + + U.log("PASS gold_dig_warp") + love.event.quit() +end diff --git a/tests/drivers/gold_egg_hatch.lua b/tests/drivers/gold_egg_hatch.lua new file mode 100644 index 00000000..5235c90e --- /dev/null +++ b/tests/drivers/gold_egg_hatch.lua @@ -0,0 +1,131 @@ +-- The per-step event chain, in the running game. +-- +-- `World` kept no step counter at all until this, so `Happiness.step` and +-- `Breeding.step` were written, tested and never called: eggs never hatched. +-- CountStep (engine/overworld/events.asm) now runs between the coord events and +-- the wild roll, and DoEggStep ticks at wStepCount $80. +-- +-- This walks a real party with a real egg in it until the counter reaches the +-- egg phase, then asserts the slot came out of it as a Pokemon. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_egg_hatch.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-steps" + +return function(game) + local w = game.world + local fails = 0 + + local function wait(n) for _ = 1, n do coroutine.yield() end end + + local function ok(cond, msg) + if cond then print("[steps] ok " .. msg) + else fails = fails + 1 print("[steps] FAIL " .. msg) end + return cond + end + + local function clearDirs() + game.input.pressQueue = {} + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + game.input.sources[d] = nil + end + end + + -- Walk back and forth on a clear row. Holding one direction for a fixed + -- stretch is the reliable shape here: a step is 16 pixels at one a frame, so + -- 20 held frames is always at least one full footfall. + local function pace(frames, dir) + for _ = 1, frames do + if w:busy() then clearDirs() return true end + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + clearDirs() + coroutine.yield() + return w:busy() + end + + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + wait(45) + + local save = game.save + -- No wild encounters and no coord events in the way: this is about the step + -- counter, not about what else a footfall can trigger. + w.mapScenes.NEW_BARK_TOWN = 1 + w:setMap("NEW_BARK_TOWN", 6, 8, "down") + wait(15) + + -- An egg on its last cycle, and a mon in front of it so the party is honest. + local Mon = require("src.battle.gen2.Mon") + local lead = Mon.new(game.data, "CYNDAQUIL", 5) + save.party = { lead, { + isEgg = true, species = "TOGEPI", name = "EGG", level = 5, + eggSteps = 1, dvs = lead.dvs, moves = {}, + ot = save.player and save.player.name, + otId = save.player and save.player.id, + } } + save.stepCount = nil + save.poisonStepCount = nil + + local Breeding = require("src.core.gen2.Breeding") + ok(Breeding.isEgg(save.party[2]), "the party starts with an egg in slot 2") + + -- DoEggStep fires at wStepCount $80, so at most 128 footfalls from zero. + local hit = false + for i = 1, 300 do + if pace(24, (i % 2 == 1) and "left" or "right") then hit = true break end + if not Breeding.isEgg(save.party[2]) then hit = true break end + if i == 1 then + ok((save.stepCount or 0) > 0, + "one lap already moved wStepCount to " .. tostring(save.stepCount)) + end + end + ok((save.stepCount or 0) > 0, + "the world counts steps at all now (wStepCount = " + .. tostring(save.stepCount) .. ")") + ok(hit, "and something fired inside 128 footfalls") + ok(save.stepCount == 0x80, + "at wStepCount $80, DoEggStep's own phase (got " + .. tostring(save.stepCount) .. ")") + + game.capturePath = SHOT_DIR .. "/hatch-huh.png" + wait(4) + + -- "Huh?" and the hatch line advance on A; the nickname prompt is a yes/no and + -- B is NO, which is the arm that keeps the species name (HatchEggs' own + -- .nonickname). Answering YES would push the naming screen, which is a stack + -- state rather than a World busy flag and would sit there forever. + for _ = 1, 600 do + if not w:busy() then break end + table.insert(game.input.pressQueue, w.choicebox and "b" or "a") + wait(3) + end + wait(20) + + local slot = save.party[2] + ok(slot ~= nil and not Breeding.isEgg(slot), "the egg is no longer an egg") + ok(slot and slot.species == "TOGEPI", "it is a TOGEPI (got " + .. tostring(slot and slot.species) .. ")") + ok(slot and (slot.hp or 0) > 0 and slot.hp == slot.maxHp, + "at full health, the way HatchEggs copies MON_MAXHP into MON_HP") + ok(slot and slot.happiness == 0x78, + "with the hatch happiness of $78 (got " + .. tostring(slot and slot.happiness) .. ")") + ok(save.pokedex and save.pokedex.caught + and save.pokedex.caught.TOGEPI, "and SetSeenAndCaughtMon ticked the #DEX") + -- HatchEggs sets EVENT_TOGEPI_HATCHED (84) by hand, for this species alone. + ok(w.events and w.events:get(84) == true, + "and the world set EVENT_TOGEPI_HATCHED") + + game.capturePath = SHOT_DIR .. "/hatched.png" + wait(30) + + if fails > 0 then + error(("gold egg hatch: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold per-step chain: the egg hatched") +end diff --git a/tests/drivers/gold_egg_hatch_shots.lua b/tests/drivers/gold_egg_hatch_shots.lua new file mode 100644 index 00000000..cb70259f --- /dev/null +++ b/tests/drivers/gold_egg_hatch_shots.lua @@ -0,0 +1,148 @@ +-- Contact sheet: the egg hatch cutscene and the egg summary page. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_egg_hatch_shots.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- tests/drivers/gold_egg_hatch.lua walks a real party into a hatch and checks +-- the party record afterwards; this one puts a human in front of the parts of +-- it no assertion can reach. Four things to look at, in the order they are +-- shot into /tmp/gold-egg (POKEPORT_SHOT_DIR): +-- +-- crack, wobble-right, wobble-left +-- The crack sits ON the shell and stays there. hSCX and +-- wGlobalAnimXOffset move the background and the objects the same way +-- (engine/pokemon/breeding.asm:707-719), so across the three shots the +-- egg and the crack shift together, never apart: lay them over each +-- other and the picture is the same one, two pixels either side of +-- where it rests. The crack's own position carries +-- .OAMData_1x1_Palette0's -4 on each axis (data/sprite_anims/oam.asm +-- :112-114), which puts the first one at screen (76, 52). +-- burst, fragments-*, fragments-gone +-- The ten shards fly for sixteen frames and then leave +-- (AnimSeq_RevealNewMon's `.finish_EggShell`). `fragments-gone` is +-- shot well after that and must show the hatchling alone. +-- hatchling +-- The pic is where PadFrontpic put it (engine/gfx/load_pics.asm:342). +-- The default species is SENTRET because its frontpic is 48px, the one +-- width the old centring rule placed four pixels wrong; POKEPORT_EGG +-- _SPECIES picks another. +-- summary-egg +-- EggStatsScreen's page (engine/pokemon/stats_screen.asm:747-794): the +-- EGG pic in the 7x7 block at hlcoord 0, 0, in the EGG palette row's +-- cream and brown rather than flat greys. The egg is one cycle from +-- hatching, so SFX_2_BOOPS sounds as the page opens. +local U = require("tests.drivers.util") + +local EggHatchAnim = require("src.ui.gen2.EggHatchAnim") +local Screens = require("src.ui.Screens") + +-- 80 hold + 8 rounds of wobbles and stills + 129 fragment frames = 482, with +-- room to spare before calling it hung. +local FRAME_LIMIT = 700 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-egg" + local species = os.getenv("POKEPORT_EGG_SPECIES") or "SENTRET" + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local Mon = require("src.battle.gen2.Mon") + local hatchling = Mon.new(game.data, species, 5) + assert(hatchling, "no such species: " .. species) + + ------------------------------------------------------------------ cutscene + + local finished = false + local screen = EggHatchAnim.new(game, { + mon = hatchling, species = species, + onDone = function() finished = true end, + }) + game.stack:push(screen) + + -- Waiting on the screen's own state rather than a frame count: U.shot spins + -- until the capture reaches disk, so it eats frames of its own and a + -- frame-numbered target would drift past the beat it was aimed at. A wobble + -- half is only two frames long, shorter than that spin, so the screen is + -- held still for the capture as well -- otherwise wobble-right and + -- wobble-left would both be whatever beat the writer happened to land on. + local frozen = false + local advance = screen.update + screen.update = function(s, dt) + if frozen then return end + return advance(s, dt) + end + + local frames = 0 + local function until_(pred, what) + while not pred() and frames < FRAME_LIMIT do + U.wait(1) + frames = frames + 1 + end + assert(frames < FRAME_LIMIT, "never reached: " .. what) + end + + local function shot(name) + frozen = true + U.shot(game, out .. "/" .. name .. ".png") + frozen = false + end + + -- The crack goes on at the end of a round's still frames and the next + -- round's first wobble half is entered in the same update, so wait for the + -- stillness after it: three shots, at shake 0, -2 and +2. + until_(function() return #screen.sprites > 0 and screen.shakeX == 0 end, + "the first crack, at rest") + shot("crack") + print(("[driver] first crack at (%d, %d) in struct coords") + :format(screen.sprites[1].x, screen.sprites[1].y)) + + until_(function() return screen.shakeX == 2 end, "a wobble's right half") + shot("wobble-right") + until_(function() return screen.shakeX == -2 end, "a wobble's left half") + shot("wobble-left") + + until_(function() return screen.showMon end, "the shell breaking") + shot("burst") + for _, step in ipairs({ 4, 8, 12 }) do + U.wait(step) + frames = frames + step + shot(("fragments-%02d"):format(step)) + end + + until_(function() return #screen.sprites == 0 end, "the shards leaving") + print(("[driver] the fragments were gone %d frames in"):format(frames)) + U.wait(40) + shot("fragments-gone") + shot("hatchling") + + while not finished and frames < FRAME_LIMIT do + U.wait(1) + frames = frames + 1 + end + assert(finished, "the cutscene never finished") + if game.stack:top() == screen then game.stack:pop() end + U.wait(10) + + ------------------------------------------------------------- summary page + + -- One cycle left, which is EggStatsScreen's `cp 6` arm: the "It's making + -- sounds inside" line and SFX_2_BOOPS. + local egg = { + isEgg = true, species = species, name = "EGG", level = 5, + eggSteps = 1, dvs = hatchling.dvs, moves = {}, + ot = game.save.player and game.save.player.name, + otId = game.save.player and game.save.player.id, + } + game.save.party = { hatchling, egg } + local summary = Screens.push(game, "Gen2SummaryMenu", + { party = game.save.party, index = 2 }) + U.wait(20) + U.shot(game, out .. "/summary-egg.png") + U.wait(20) + if game.stack:top() == summary then game.stack:pop() end + + print("[driver] PASS gold egg hatch shots -> " .. out) +end diff --git a/tests/drivers/gold_evolution_shots.lua b/tests/drivers/gold_evolution_shots.lua new file mode 100644 index 00000000..35de3e5d --- /dev/null +++ b/tests/drivers/gold_evolution_shots.lua @@ -0,0 +1,99 @@ +-- Contact sheet: the Gen 2 evolution animation, frame by frame. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_evolution_shots.lua love . +-- +-- Shoots src/ui/gen2/EvolutionAnim.lua into /tmp/gold-evo, one frame every few, +-- for a normal evolution and then for a B-cancelled one. A test can assert +-- that the flash loop ran eight rounds; only a picture says the two pics are +-- swapping in the same 7x7 box at hlcoord 7, 2, that the silhouette really is +-- PREDEFPAL_BLACKOUT, and that the new mon's colours land on the last swap. +-- +-- POKEPORT_EVO_SPECIES / POKEPORT_EVO_LEVEL pick the mon (default: a level 16 +-- CHIKORITA, the first evolution a Gold playthrough actually reaches). +-- POKEPORT_SHOT_INTERVAL is how many frames apart the shots are. +local U = require("tests.drivers.util") + +local Evolution = require("src.core.gen2.Evolution") +local EvolutionAnim = require("src.ui.gen2.EvolutionAnim") +local Mon = require("src.battle.gen2.Mon") + +-- Frames to give one evolution before calling it hung: 50 (EvolvingText) + 80 +-- (MUSIC_EVOLUTION) + 144 (the flash loop) + 64 (balls of light) + the text +-- pages, with room to spare. +local FRAME_LIMIT = 900 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-evo" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "4") + local species = os.getenv("POKEPORT_EVO_SPECIES") or "CHIKORITA" + local level = tonumber(os.getenv("POKEPORT_EVO_LEVEL") or "16") + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local mon = Mon.new(game.data, species, level) + assert(mon, "could not build a level " .. level .. " " .. species) + -- The after-battle sweep's context: no link, no stone, and the clock's own + -- time of day for the TR_MORNDAY / TR_NITE rows. + local Palettes = require("src.world.gen2.Palettes") + local entry = Evolution.checkMon(game.data, mon, + { timeOfDay = Palettes.clockDaytime() }) + assert(entry, species .. " at level " .. level + .. " has no evolution to show -- pick another with POKEPORT_EVO_SPECIES") + print(("[driver] %s -> %s"):format(species, entry.into)) + + -- One run of the screen, shooting every `interval` frames. `cancelAt` is + -- the frame to tap B on, which .WaitFrames_CheckPressedB only honours during + -- a hold; nil runs it through to the end. + local function run(prefix, cancelAt) + game.save.party = { Mon.new(game.data, species, level) } + local finished = nil + local screen = EvolutionAnim.new(game, { + mon = game.save.party[1], + entry = entry, + index = 1, + party = game.save.party, + save = game.save, + onDone = function(result) finished = result end, + }) + game.stack:push(screen) + + local frame = 0 + while not finished and frame < FRAME_LIMIT do + if frame % interval == 0 then + U.shot(game, ("%s/%s-%04d-%s.png"):format(out, prefix, frame, + screen.phase or "?")) + end + if cancelAt and frame == cancelAt then + U.tap(game, "b") + else + U.wait(1) + end + frame = frame + 1 + end + assert(finished, prefix .. " never finished") + game.stack:pop() + print(("[driver] %-8s %d frames, canceled=%s, species now %s"):format( + prefix, frame, tostring(finished.canceled), + tostring(game.save.party[1].species))) + return finished + end + + local full = run("evolve", nil) + assert(not full.canceled, "the uncancelled run reported a cancel") + assert(game.save.party[1].species == entry.into, + "the party slot did not take the new species") + + -- B during the very first hold, which is the 16 frames after the 50 + 80 of + -- text and music: .cancel_evo leaves the OLD pic on screen and prints + -- StoppedEvolvingText. + local canceled = run("cancel", + Evolution.EVOLVING_FRAMES + Evolution.MUSIC_FRAMES + 4) + assert(canceled.canceled, "the B press did not cancel the evolution") + assert(game.save.party[1].species == species, + "a cancelled evolution changed the species anyway") + + print("[driver] PASS gold evolution in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_exp_bar.lua b/tests/drivers/gold_exp_bar.lua new file mode 100644 index 00000000..f4355594 --- /dev/null +++ b/tests/drivers/gold_exp_bar.lua @@ -0,0 +1,115 @@ +-- The exp bar crawl, the level number that rides it, and the siren that has to +-- stop when the enemy goes down. All three are things only a person watching +-- the screen can sign off on. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_exp_bar.lua love . +-- +-- What to look for, in order: +-- 02..05 the blue bar under the player's HUD WALKS to the right, one pixel +-- at a time (AnimateExpBar, engine/battle/core.asm:7191), with +-- SFX_EXP_BAR sounding under it. It must not be at its final width +-- in shot 02 already. +-- 03 the ":L" number is still the PRE-kill level while the bar is +-- mid-crawl, and only changes on the frame the bar tops out +-- (wBattleMonLevel is written inside the level loop, :7267-7274), +-- with the end-of-bar hit playing there. +-- 06 "<mon> grew to level N!", which comes AFTER all of that. +-- And by ear: the low-HP siren is loud on the way in (the player is left on 3 +-- HP on purpose), and is cut dead the moment the wild mon faints -- it must +-- not blare on under the victory jingle and the exp lines +-- (wBattleLowHealthAlarm, core.asm:2071-2074). +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-exp-bar" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local player = Mon.new(game.data, "CYNDAQUIL", 9) + assert(player and #player.moves > 0, "could not build a CYNDAQUIL") + -- One point short of the next level, so the single kill below crosses it and + -- the bar has to fill, restart at zero and finish the second segment. + local def = game.data.pokemon[player.species] + local growth = game.data.pokemon.growthRates[def.growthRate] + player.experience = Mon.experienceForLevel(growth, player.level + 1) - 1 + -- Red bar on the way in, so the siren is up before the faint. + player.hp = 3 + game.save.party = { player } + game.save.inventory = { POTION = 2 } + + local wild = Mon.new(game.data, "PIDGEY", 6) + assert(wild, "could not build a wild PIDGEY") + assert(world:startBattle({ wild = wild }), "startBattle failed") + + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + assert(screen and screen.battle, "battle screen never came up") + + -- Page the intro out to the menu, with the siren already sounding. + for _ = 1, 200 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + assert(screen.phase == "menu", "never reached the battle menu") + U.shot(game, out .. "/00-red-bar-siren.png") + print("[driver] player " .. player.hp .. "/" .. player.maxHp + .. " hp, level " .. player.level .. ", exp " .. player.experience) + + -- One hit ends it. + screen.battle.enemy.hp = 1 + U.tap(game, "a") -- FIGHT + U.wait(6) + U.tap(game, "a") -- first move + U.shot(game, out .. "/01-the-kill.png") + + -- Page forward until the crawl arms, shooting the level line as it stands. + local shots, armed = 0, false + for _ = 1, 900 do + if screen.expAnim then armed = true break end + if screen.phase == "done" then break end + U.tap(game, "a") + U.wait(2) + end + assert(armed, "the exp bar crawl never armed (phase " + .. tostring(screen.phase) .. ")") + print("[driver] crawl armed at level " .. tostring(screen.shownLevel) + .. ", bar at " .. tostring(screen.shownExp) .. "/64") + + -- Four stills across the crawl. A bar that is already full in the first is + -- the bug this driver exists for. + local seen = {} + while screen.expAnim and shots < 4 do + shots = shots + 1 + seen[shots] = { screen.shownExp, screen.shownLevel } + U.shot(game, out .. ("/%02d-crawl.png"):format(shots + 1)) + U.wait(18) + end + for i = 1, shots do + print(("[driver] shot %d: bar %s/64, :L%s") + :format(i + 1, tostring(seen[i][1]), tostring(seen[i][2]))) + end + assert(shots >= 2, "the crawl was over before two frames could be shot") + assert(seen[1][1] < 64, "the bar was already full on the first crawl frame") + + -- The rest of the queue: the grew-to-level line and the way out. + for _ = 1, 900 do + if screen.phase == "done" then break end + if (screen.message or ""):find("grew to level") then + U.shot(game, out .. "/06-grew-to-level.png") + end + U.tap(game, "a") + U.wait(2) + end + print("[driver] ended at level " .. tostring(player.level) + .. ", HUD showing :L" .. tostring(screen.shownLevel)) + print("[driver] PASS gold exp bar in " .. out) +end diff --git a/tests/drivers/gold_extractor_pass.lua b/tests/drivers/gold_extractor_pass.lua new file mode 100644 index 00000000..b7cf20c0 --- /dev/null +++ b/tests/drivers/gold_extractor_pass.lua @@ -0,0 +1,321 @@ +-- The four things the extractor pass unblocked, driven in the real game. +-- +-- Every one of them was a pointer the importer emitted raw, so the engine had +-- an address and nothing behind it. This driver walks the player to each and +-- asserts the feature actually runs, rather than shooting a screenshot for +-- someone to squint at: +-- +-- 1. a scripted static menu -- Goldenrod Dept Store 6F's vending machine +-- (`loadmenu` / `verticalmenu`). Every one of the seventeen sites took +-- the cancel arm before the MenuHeader pointer was followed. +-- 2. the elevator -- Goldenrod Dept Store's, whose floor list +-- lives in its own script bank. The ride is a `warp_event` with +-- destination warp -1 reading what Elevator_GoToFloor left behind. +-- 3. a special phone call -- SPECIALCALL_ROBBED, whose script is in ROM +-- bank $41. Nothing on any map points into that bank; the seed is +-- PhoneContacts itself. +-- 4. an in-game trade -- NPC_TRADE_MIKE, off data/events/npc_trades.asm. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_extractor_pass.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-extractor" + +return function(game) + local w = game.world + local fails = 0 + + local function wait(n) for _ = 1, n do coroutine.yield() end end + + local function ok(cond, msg) + if cond then print("[extract] ok " .. msg) + else fails = fails + 1 print("[extract] FAIL " .. msg) end + return cond + end + + -- A tap is a press AND a release. Writing pressQueue directly injects a + -- press with no source map behind it (src/core/Input.lua Input:step), and + -- nothing else will ever clear it, so a tap that skipped the release would + -- leave that button HELD for the rest of the run -- which put DOWN under the + -- player's thumb on arrival in the elevator and walked them straight back out + -- through its COLL_WARP_CARPET_DOWN door. + local function tap(btn) + table.insert(game.input.pressQueue, btn) + coroutine.yield() + coroutine.yield() + game.input.state[btn] = false + end + + -- DoPlayerMovement's .CheckWarp (engine/overworld/player_movement.asm): an + -- edge warp is only taken while its own direction is actually on the d-pad, + -- so walking out of a lift needs a real hold rather than a tap. + local function hold(btn, frames) + for _ = 1, (frames or 1) do + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false + end + + -- Gold runs on the engine's own src/core/StateStack.lua, the same stack + -- Gen 1 uses (src/core/Game2.lua:makeStack), so ask it for the top rather + -- than indexing a field. + local function top() + return game.stack and game.stack:top() + end + + -- Run a script by key and step the world until it parks on something. + local function runUntilIdle(limit) + for _ = 1, (limit or 400) do + if not w:busy() then return true end + coroutine.yield() + end + return false + end + + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + wait(45) + + -- ---------------------------------------------------------------- 1. menu + -- + -- CeladonDeptStore6F / GoldenrodDeptStore6F's vending machine is + -- `opentext / writetext / special PlaceMoneyTopRight / loadmenu / verticalmenu`, + -- and the `ifequal 1..3` ladder after it is what buys a drink. With no + -- header there was nothing to open and the script fell to the cancel arm. + -- + -- The pair is run as an INLINE command list (Vm:start takes one) rather than + -- by starting the whole vending-machine script: everything in front of the + -- menu is text boxes and a money panel, and none of that is what this is + -- about. The two commands are the cache's own, lifted out of a real site. + local menuCmds + for key, cmds in pairs(w.scripts) do + if type(cmds) == "table" and key ~= "movements" and not menuCmds then + for i, cmd in ipairs(cmds) do + if cmd.op == "loadmenu" and cmd.menu and cmd.menu.items + and cmds[i + 1] and cmds[i + 1].op == "verticalmenu" then + menuCmds = { cmd, cmds[i + 1], { op = "end" } } + break + end + end + end + end + if ok(menuCmds ~= nil, "the cache has a loadmenu site with a real header") then + w:setMap("GOLDENROD_DEPT_STORE_6F", 5, 5, "up") + wait(20) + ok(w.vm:start(menuCmds), "the VM took the loadmenu / verticalmenu pair") + local opened = false + for _ = 1, 60 do + local state = top() + if state and state.screenId == "Gen2ScriptMenu" then opened = true break end + coroutine.yield() + end + ok(opened, "the vending machine opened its menu") + local menu = top() + if opened then + ok(#menu.items >= 2, + ("with %d items off the extracted header"):format(#menu.items)) + game.capturePath = SHOT_DIR .. "/menu.png" + wait(2) + -- Walk to the last row and pick it: CANCEL, the arm the script used to + -- take by default. Picking it deliberately proves the cursor moves and + -- the answer is the 1-based index rather than a stuck 0. + local want = #menu.items + for _ = 1, want do tap("down") end + ok(menu.row == want, + ("the cursor reached row %d (got %d)"):format(want, menu.row)) + tap("a") + wait(4) + ok(top() ~= menu, "and choosing closed it") + ok(w.vm.scriptVar == want, + ("wScriptVar is the 1-based choice %d (got %s)") + :format(want, tostring(w.vm.scriptVar))) + end + runUntilIdle(200) + end + + -- ------------------------------------------------------------ 2. elevator + -- + -- Elevator writes wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber and + -- rides nowhere; the elevator's own door -- a warp_event whose destination + -- warp is -1 -- is what carries the player out onto the chosen floor. + -- Pick GOLDENROD'S list by the floor it names, not by whichever `elevator` + -- pairs() reaches first: the three lists are per-building, and running + -- Celadon's from Goldenrod's lift is the .FindCurrentFloor miss that quits + -- with no menu at all. + local FLOOR = "GOLDENROD_DEPT_STORE_1F" + local elevatorCmds + for key, cmds in pairs(w.scripts) do + if type(cmds) == "table" and key ~= "movements" and not elevatorCmds then + for _, cmd in ipairs(cmds) do + for _, floor in ipairs((cmd.op == "elevator" and cmd.floors) or {}) do + if floor.destMap == FLOOR then + elevatorCmds = { cmd, { op = "end" } } + break + end + end + if elevatorCmds then break end + end + end + end + if ok(elevatorCmds ~= nil, "the cache has an elevator with a floor list") then + -- Arrive the way a player does, so wBackupMapNumber is the floor they got + -- in on -- .FindCurrentFloor answers `scf` and skips the whole thing + -- otherwise. + w:setMap(FLOOR, 4, 2, "up") + wait(10) + local door + for _, warp in ipairs(w.map.def.warps or {}) do + if warp.destMap == "GOLDENROD_DEPT_STORE_ELEVATOR" then door = warp end + end + if ok(door ~= nil, "1F has a door into the elevator") then + w:takeWarp(door) + runUntilIdle(300) + wait(20) + ok(w.map.id == "GOLDENROD_DEPT_STORE_ELEVATOR", + ("the player is in the elevator (got %s)"):format(w.map.id)) + ok(w.backupMapId == FLOOR, + ("and came in from 1F (got %s)"):format(tostring(w.backupMapId))) + -- The door is the thing under test: a `warp_event` whose destination + -- warp is -1 names no floor of its own. + local door_ = nil + for _, warp in ipairs(w.map.def.warps or {}) do + if warp.destWarp == 0xff then door_ = warp end + end + ok(door_ ~= nil, "the elevator's own door is a -1 warp") + ok(door_ and w:resolveWarp(door_) == FLOOR, + "which resolves to the floor we came in on until a ride is picked") + ok(w.vm:start(elevatorCmds), "the VM took the elevator command") + local opened = false + for _ = 1, 60 do + local state = top() + if state and state.screenId == "Gen2ElevatorMenu" then opened = true break end + coroutine.yield() + end + ok(opened, "the elevator opened its floor list") + local lift = top() + if opened then + ok(lift.origin ~= nil, "with the floor it came in on marked") + game.capturePath = SHOT_DIR .. "/elevator.png" + wait(2) + -- Ride to the top floor of the list, which is never the one we are on. + for _ = 1, #lift.floors do tap("down") end + local target = lift.floors[lift.index] + tap("a") + wait(4) + ok(w.backupWarp ~= nil and w.backupWarp.map == target.destMap, + ("Elevator_GoToFloor stored %s"):format(tostring(target.destMap))) + -- The SAME door now resolves somewhere else, which is the whole of + -- what the ride is: nothing about the map changed. + ok(door_ and w:resolveWarp(door_) == target.destMap, + ("and the door now resolves to %s"):format( + tostring(target.destMap))) + -- Elevator_GoToFloor rides nowhere: the player still has to walk out + -- through the door, which is the edge warp the -1 destination is on. + hold("down", 40) + runUntilIdle(300) + wait(20) + ok(w.map.id == target.destMap, + ("walking out opens on %s (got %s)"):format(target.destMap, + w.map.id)) + end + end + end + + -- --------------------------------------------------------- 3. phone call + -- + -- SPECIALCALL_ROBBED is Elm's "your POKeMON was stolen" beat. Its script is + -- ElmPhoneCallerScript at 41:41e1, reached only because the extractor seeds + -- its queue from PhoneContacts. + local Phone = require("src.core.gen2.Phone") + local key = Phone.SCRIPT_KEYS.ElmPhoneCallerScript + ok(w.scripts[key] ~= nil, + ("ElmPhoneCallerScript (%s) is in scripts.lua"):format(tostring(key))) + do + -- The condition is SpecialCallOnlyWhenOutside, so stand in a town. + w:setMap("NEW_BARK_TOWN", 5, 8, "down") + wait(20) + Phone.queueSpecialCall(game.save, Phone.SPECIALCALL.SPECIALCALL_ROBBED) + local call = Phone.checkSpecialCall(game.save, { + map = w.map.def, maps = w.maps, daytime = w.daytime, + environment = w.map.def and w.map.def.environment, + }) + if ok(call ~= nil, "CheckSpecialPhoneCall produced a call outdoors") then + ok(call.scriptKey == key, + ("aimed at the caller script (%s)"):format(tostring(call.scriptKey))) + local before = w.unrunnableCalls or 0 + local ran = w:receivePhoneCall(call) + ok(ran, "and the world RAN it rather than dropping it") + ok((w.unrunnableCalls or 0) == before, + "so nothing was counted as unrunnable") + game.capturePath = SHOT_DIR .. "/phonecall.png" + wait(2) + for _ = 1, 300 do + if not w:busy() then break end + tap("a") + end + -- The script's own first act is `specialphonecall SPECIALCALL_NONE`. + ok(not Phone.hasSpecialCall(game.save), + "and the script cleared the queue on its way out") + end + end + + -- --------------------------------------------------------------- 4. trade + -- + -- NPC_TRADE_MIKE: hand over a DROWZEE, get MACHOP nicknamed MUSCLE. + local NpcTrade = require("src.core.gen2.NpcTrade") + local row = NpcTrade.row(w.eventTables, 0) + if ok(row ~= nil, "the cache carries the six in-game trades") then + local Mon = require("src.battle.gen2.Mon") + game.save.party = { Mon.new(game.data, row.give, 20) } + game.save.tradeFlags = {} + ok(game.save.party[1] ~= nil, + ("a level 20 %s in the party"):format(tostring(row.give))) + w:openNpcTrade(0, function() end) + local trade = top() + if ok(trade and trade.screenId == "Gen2TradeMenu", "the trade opened") then + game.capturePath = SHOT_DIR .. "/trade.png" + wait(2) + -- Page to the yes/no, answer YES, then pick the only party member. + for _ = 1, 30 do + if trade.confirm and trade.confirm.page >= #trade.confirm.pages then + break + end + tap("a") + end + tap("a") -- YES + wait(4) + local party = top() + if ok(party and party.screenId == "Gen2PartyMenu", + "and it opened the party list") then + tap("a") + wait(4) + for _ = 1, 60 do + if not top() or top() == trade then break end + tap("a") + end + for _ = 1, 60 do + if trade.closed then break end + tap("a") + end + end + local got = game.save.party[1] + ok(got and got.species == row.get, + ("the party now holds %s (got %s)"):format(tostring(row.get), + tostring(got and got.species))) + ok(got and got.nickname == row.nickname, + ("nicknamed %s (got %s)"):format(tostring(row.nickname), + tostring(got and got.nickname))) + ok(got and got.otName == row.otName, + ("with OT %s"):format(tostring(row.otName))) + ok(got and got.level == 20, "at the level of the mon handed over") + ok(NpcTrade.done(game.save, 0), "and the trade's flag is set") + end + end + + if fails > 0 then + error(("gold extractor pass: %d assertion(s) failed"):format(fails), 0) + end + print("[driver] PASS gold extractor pass in " .. SHOT_DIR) +end diff --git a/tests/drivers/gold_field_anim_shots.lua b/tests/drivers/gold_field_anim_shots.lua new file mode 100644 index 00000000..a0b79c6a --- /dev/null +++ b/tests/drivers/gold_field_anim_shots.lua @@ -0,0 +1,180 @@ +-- The field presentations a test cannot see, through the real world: +-- teleport_from's spin-and-rise, the fishing rod bob, the headbutt tree shake, +-- FLY's take-off lift, and FLY's own destination picker. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_field_anim_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-field (default) +-- +-- Every beat is asserted as well as shot, so a run that only prints PASS is +-- still worth something on a machine nobody is looking at. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Movement = require("src.script.gen2.Movement") +local Pokegear = require("src.ui.gen2.Pokegear") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-field" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[field] ok " .. label) + else + failures = failures + 1 + print("[field] FAIL " .. label .. " " .. tostring(detail)) + end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + world:setMap("ROUTE_30", 10, 10, "down") + U.wait(10) + + -- ------------------------------------------------------------- teleport + -- + -- LakeOfRageLanceTeleportIntoSkyMovement is `teleport_from / step_end`: + -- StepFunction_TeleportFrom spins the object for sixteen frames and then + -- lifts it off the tile over sixteen more. The byte used to decode as a nop, + -- so Lance simply blinked out. + local npc = world.npcs and world.npcs[1] + -- applymovement's operand is the object_const_def index, which starts at 2 + -- (World:objectEntity takes it back off), not the pooled NPC's own id. + local objectId = npc and npc.def and npc.def.index and (npc.def.index + 1) + if npc and objectId then + local finished = false + world:beginMovement(objectId, + { Movement.TELEPORT_FROM, Movement.STEP_END }, + function() finished = true end) + local facings, deepest = {}, 0 + for frame = 1, 34 do + U.wait(1) + facings[npc.facing] = true + deepest = math.min(deepest, npc.spriteYOffset or 0) + if frame == 20 then U.shot(game, out .. "/01-teleport-spin.png") end + if frame == 30 then U.shot(game, out .. "/02-teleport-rise.png") end + end + ok("teleport_from spins the object", + facings.up and facings.down and facings.left and facings.right) + ok("and lifts it off its tile", deepest <= -0x50, deepest) + ok("and the movement stream waits it out", finished) + ok("and the object never left its cell", npc.spriteYOffset == 0) + else + print("[field] SKIP teleport: no object on this map") + end + + -- ------------------------------------------------------------- fishing + -- + -- Script_GotABite's four fish_got_bite bobs: StepFunction_GotBite flips + -- OBJECT_SPRITE_Y_OFFSET between 0 and 1 once a frame. + local Mon = require("src.battle.gen2.Mon") + local wild = Mon.new(game.data, "MAGIKARP", 10) + world:beginFishing("battle", wild) + local offsets = {} + for frame = 1, 90 do + U.wait(1) + offsets[world.player.spriteYOffset or 0] = true + if frame == 40 then U.shot(game, out .. "/03-fishing.png") end + if world.textbox then break end + end + ok("the rod bobs the player one pixel", offsets[1] == true) + world.fishing = nil + world.player.spriteYOffset = 0 + U.wait(5) + while game.stack:top() do game.stack:pop() end + U.wait(5) + + -- ------------------------------------------------------------- headbutt + -- + -- ShakeHeadbuttTree runs a 32-frame wobble under SFX_SANDSTORM. + world:runHeadbutt(10, 9, { species = "SPEAROW", nickname = "SPEAROW" }) + U.wait(2) + -- The line is a text box; A takes it down and the shake starts on its close. + for _ = 1, 20 do + if world.headbutt then break end + U.tap(game, "a") + U.wait(2) + end + ok("the headbutt shake is armed", world.headbutt ~= nil) + ok("and the frame shakes with it", world.shake ~= nil) + U.wait(4) + U.shot(game, out .. "/04-headbutt.png") + for _ = 1, 120 do + if not world.headbutt then break end + U.tap(game, "a") + U.wait(2) + end + while game.stack:top() do game.stack:pop() end + U.wait(5) + + -- ------------------------------------------------------------- fly + -- + -- Every flypoint visited, so the picker has a full map to walk. + local save = game.save + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + ok("openFlyMap opens a screen", world:openFlyMap() == true) + U.wait(4) + local picker = game.stack:top() + ok("and it is the town-map picker, not a yes/no box", + getmetatable(picker) == Pokegear and picker.fly ~= nil) + ok("with the cursor on a flypoint", + picker and picker.flyRow and picker:flyRow() ~= nil) + U.shot(game, out .. "/05-flymap.png") + U.tap(game, "up") + U.wait(4) + U.shot(game, out .. "/06-flymap-moved.png") + + -- A takes the destination: the fade out lifts the player off the map first. + U.tap(game, "a") + local lifted = 0 + for frame = 1, 40 do + U.wait(1) + lifted = math.min(lifted, (world.player and world.player.spriteYOffset) or 0) + if frame == 4 then U.shot(game, out .. "/07-fly-takeoff.png") end + end + ok("FLY lifts the player under the fade", lifted < 0, lifted) + U.wait(30) + ok("and lands them back on the tile", + (world.player.spriteYOffset or 0) == 0) + U.shot(game, out .. "/08-fly-landed.png") + + -- --------------------------------------------------- tilt and the void + -- + -- Zoomed out with TILT on is where both nitpicks live: the billboard clip + -- (no NPCs standing past where the ground is drawn) and the border-block + -- dissolve across a map boundary. + local Tilt = require("src.render.Tilt") + local Zoom = require("src.render.Zoom") + Zoom.offset = -3 + world:rebuildNeighbors() + world:rebuildPeople({ seamless = true }) + Tilt.setLevel(3) + for _ = 1, 40 do + Tilt.update(1 / 60) + U.wait(1) + end + U.shot(game, out .. "/09-tilt-survey.png") + -- Cross into the next map: the void fill dissolves from one border block to + -- the other rather than cutting. + world:setMap("ROUTE_31", 10, 10, "down") + U.wait(2) + U.shot(game, out .. "/10-void-crossfade.png") + U.wait(10) + U.shot(game, out .. "/11-void-settled.png") + ok("the border fill is mid-dissolve on arrival", + world.borderFade == nil or world.borderFade >= 1) + Tilt.setLevel(0) + Zoom.offset = 0 + + if failures > 0 then + print(("[driver] FAIL gold field anims: %d check(s)"):format(failures)) + return + end + print("[driver] PASS gold field anims in " .. out) +end diff --git a/tests/drivers/gold_field_items.lua b/tests/drivers/gold_field_items.lua new file mode 100644 index 00000000..fae59875 --- /dev/null +++ b/tests/drivers/gold_field_items.lua @@ -0,0 +1,251 @@ +-- Assertion driver: field item use from the PACK, end to end in the running +-- game. It PASSES or it errors; there is nothing to eyeball. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_field_items.lua love . +-- +-- tests/gen2_field_items_test.lua proves the effects and the menu wiring over +-- fixtures and a recording setMap; what it cannot prove is the whole loop -- +-- a real START press, the real PACK over the real overworld, the queued +-- escape warp riding a genuine map load, and the SELECT box tearing down to +-- an empty stack. So this drives everything with button taps: +-- +-- 1. bank the escape triple by taking Cherrygrove's Pokecenter stairs, +-- then use an ESCAPE ROPE from the PACK inside Union Cave B2F and land +-- back on the banked staircase (engine/events/overworld.asm +-- EscapeRopeOrDig, via the -1 backup triple this port banks); +-- 2. use a POTION from the PACK on a hurt party mon through the real +-- "Use on which <PK><MN>?" list (pack.asm UseItem .Party); +-- 3. an X ATTACK from the field PACK prints OakThisIsntTheTimeText and +-- stays in the PACK (UseItem's .Oak arm); +-- 4. the SELECT MayRegisterItemText box pages and dismisses without +-- leaving anything on the stack. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + assert(save.party[1], "the cache carries no CYNDAQUIL to seed a party") + + local function tapUntil(predicate, tries, btn) + for _ = 1, tries or 300 do + if predicate() then return true end + U.tap(game, btn or "a") + U.wait(2) + end + return predicate() + end + + -- START, then walk the cursor to the PACK row and open it. + local function openPack() + U.tap(game, "start") + U.wait(3) + local menu = game.stack:top() + assert(menu and menu.list, "START menu did not open") + local guard = 0 + while menu.list:current().value ~= "pack" do + U.tap(game, "down") + U.wait(2) + guard = guard + 1 + assert(guard < 12, "no PACK row in the START menu") + end + U.tap(game, "a") + U.wait(3) + local pack = game.stack:top() + assert(pack and pack.rows, "PACK did not open") + return pack + end + + -- ---- 1. the escape rope -------------------------------------------------- + + -- Bank the triple the way play does: up the Cherrygrove stairs, whose + -- arrival warp declares -1 (tests/gen2_pokecenter_stairs_test.lua owns the + -- banking rules; this run just rides them). + assert(world:setMap("CHERRYGROVE_POKECENTER_1F", 3, 4, "down"), + "setMap CHERRYGROVE_POKECENTER_1F failed") + U.wait(5) + local stairs = world.maps.CHERRYGROVE_POKECENTER_1F.warps[3] + world.player.cellX, world.player.cellY = stairs.x, stairs.y + assert(world:takeWarp(stairs), "the Pokecenter stairs refused") + for _ = 1, 300 do + if world.map.id == "POKECENTER_2F" and not world.mapSetup then break end + U.wait(1) + end + assert(world.map.id == "POKECENTER_2F", "did not arrive on POKECENTER_2F") + assert(world.backupWarp + and world.backupWarp.map == "CHERRYGROVE_POKECENTER_1F", + "the -1 arrival did not bank the Cherrygrove triple") + + assert(world:setMap("UNION_CAVE_B2F", 5, 3, "down"), + "setMap UNION_CAVE_B2F failed") + U.wait(5) + save.inventory = { ESCAPE_ROPE = 1 } + + local pack = openPack() + assert(pack.rows[1] and pack.rows[1].id == "ESCAPE_ROPE", + "the PACK does not show the ESCAPE ROPE") + -- A picks the row and opens the item submenu + -- (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243); USE is its first + -- row, so using an item from the field PACK is two presses. + U.tap(game, "a") + U.wait(2) + assert(pack.submenu, "A on a field PACK row did not open the item submenu") + U.tap(game, "a") + U.wait(3) + assert(game.stack:top() ~= pack, + "using the rope must quit the PACK (PACKSTATE_QUITRUNSCRIPT)") + -- The queued script: the used-rope line over the overworld, then the warp. + tapUntil(function() + return game.stack:top() == nil and not world.mapSetup + and world.map.id ~= "UNION_CAVE_B2F" + end) + assert(world.map.id == "CHERRYGROVE_POKECENTER_1F", + "the rope did not pay out to the banked centre (on " + .. tostring(world.map.id) .. ")") + assert(world.player.cellX == stairs.x and world.player.cellY == stairs.y, + "the rope landed off the banked staircase tile") + assert(save.inventory.ESCAPE_ROPE == nil, "the rope was not consumed") + U.log("PASS escape rope: Union Cave B2F -> Cherrygrove stairs, consumed") + + -- ---- 2. a POTION on a party mon ----------------------------------------- + + local mon = save.party[1] + mon.hp = 5 + save.inventory = { POTION = 1 } + local healPack = openPack() + assert(healPack.rows[1] and healPack.rows[1].id == "POTION", + "the PACK does not show the POTION") + U.tap(game, "a") -- the submenu + U.wait(2) + U.tap(game, "a") -- USE + U.wait(3) + local party = game.stack:top() + assert(party and party.prompt, "USE did not open the party list") + U.tap(game, "a") + U.wait(3) + tapUntil(function() return game.stack:top() == healPack end) + assert(game.stack:top() == healPack, + "the heal message did not return to the PACK") + assert(mon.hp == 25, "POTION healed to " .. tostring(mon.hp) .. ", want 25") + assert(save.inventory.POTION == nil, "the POTION was not consumed") + U.tap(game, "b") + U.wait(2) + U.tap(game, "b") + U.wait(2) + assert(game.stack:top() == nil, "the menus did not unwind after the heal") + U.log("PASS potion: healed a party mon from the PACK, consumed") + + -- ---- 3. the .Oak refusal ------------------------------------------------- + + -- A tossable field-NOUSE item is MenuHeader_HoldableItem: GIVE / TOSS / + -- QUIT, with no USE row at all -- the cart refuses an X ATTACK in the field + -- by never offering the verb. + save.inventory = { X_ATTACK = 1 } + local oakPack = openPack() + U.tap(game, "a") + U.wait(2) + assert(oakPack.submenu, "A on the X ATTACK opened no submenu") + assert(table.concat(oakPack.submenu.rows, ",") == "give,toss,quit", + "the X ATTACK submenu is " .. table.concat(oakPack.submenu.rows, ",")) + U.tap(game, "b") + U.wait(2) + assert(save.inventory.X_ATTACK == 1, "backing out must not spend the item") + U.tap(game, "b") + U.wait(2) + U.tap(game, "b") + U.wait(2) + assert(game.stack:top() == nil, "the menus did not unwind after the submenu") + + -- ...and an untossable one still gets USE, because .ItemBallsKey_LoadSubmenu's + -- untossable arm never looks at the menu nibble -- so THAT is where + -- OakThisIsntTheTimeText is still reachable in the field. + save.inventory = { SECRETPOTION = 1 } + local keyPack = openPack() + while keyPack:pocket().id ~= "KEY_ITEM" do + U.tap(game, "right") + U.wait(2) + end + U.tap(game, "a") + U.wait(2) + assert(table.concat(keyPack.submenu.rows, ",") == "use,quit", + "the SECRETPOTION submenu is " .. table.concat(keyPack.submenu.rows, ",")) + U.tap(game, "a") + U.wait(2) + assert(keyPack.message, "the key item printed no Oak line") + assert(game.stack:top() == keyPack, "the refusal must keep the PACK open") + assert(save.inventory.SECRETPOTION == 1, + "the refusal must not spend the item") + U.tap(game, "a") + U.wait(2) + U.tap(game, "b") + U.wait(2) + U.tap(game, "b") + U.wait(2) + assert(game.stack:top() == nil, "the menus did not unwind after the refusal") + U.log("PASS oak: field-NOUSE key item refused inside the PACK") + + -- ---- 4. the SELECT box --------------------------------------------------- + + U.tap(game, "select") + U.wait(3) + local box = game.stack:top() + assert(box and box.pages, "SELECT with nothing registered opened no box") + assert(#box.pages == 2, "MayRegisterItemText must page: got " + .. tostring(#box.pages)) + tapUntil(function() return game.stack:top() == nil end) + assert(game.stack:top() == nil, "the SELECT box did not tear down") + -- And nothing half-dismissed lingers: a second press opens a fresh one. + U.tap(game, "select") + U.wait(3) + assert(game.stack:top() ~= nil, "the second SELECT box did not open") + tapUntil(function() return game.stack:top() == nil end) + U.log("PASS select: MayRegisterItemText pages and tears down cleanly") + + -- ---- 5. DIG through the party submenu ----------------------------------- + + -- The same escape consumer off the MONMENU_FIELD_MOVE row: the triple is + -- still banked from the stairs above. + local mon2 = save.party[1] + mon2.moves = { { id = "DIG", pp = 10, maxPp = 10 } } + assert(world:setMap("UNION_CAVE_B2F", 5, 3, "down"), + "setMap UNION_CAVE_B2F failed for DIG") + U.wait(5) + U.tap(game, "start") + U.wait(3) + local menu = game.stack:top() + assert(menu and menu.list, "START menu did not open for DIG") + local guard = 0 + while menu.list:current().value ~= "pokemon" do + U.tap(game, "down") + U.wait(2) + guard = guard + 1 + assert(guard < 12, "no POKeMON row in the START menu") + end + U.tap(game, "a") + U.wait(3) + local list = game.stack:top() + assert(list and list.wantsSubmenu, "the field party list did not open") + U.tap(game, "a") + U.wait(2) + assert(list.submenu, "the mon submenu did not open") + assert(list.submenu.items[1] and list.submenu.items[1].id == "DIG", + "DIG is not the submenu's field-move row") + U.tap(game, "a") + U.wait(3) + tapUntil(function() + return game.stack:top() == nil and not world.mapSetup + and world.map.id ~= "UNION_CAVE_B2F" + end) + assert(world.map.id == "CHERRYGROVE_POKECENTER_1F", + "DIG did not pay out to the banked centre (on " + .. tostring(world.map.id) .. ")") + U.log("PASS dig: the party submenu row escapes to the banked warp") + + U.log("PASS gold_field_items") + love.event.quit() +end diff --git a/tests/drivers/gold_forceshiny_probe.lua b/tests/drivers/gold_forceshiny_probe.lua new file mode 100644 index 00000000..7956be8a --- /dev/null +++ b/tests/drivers/gold_forceshiny_probe.lua @@ -0,0 +1,67 @@ +-- Red Gyarados BATTLETYPE_FORCESHINY probe: the REAL script chain (the +-- RedGyarados object script at 49:4f6f -> loadwildmon GYARADOS, 30 -> +-- loadvar VAR_BATTLETYPE, BATTLETYPE_FORCESHINY -> startbattle) must hand +-- the battle a SHINY Gyarados (InitEnemyMon `.NotRoaming`, engine/battle/ +-- core.asm:5876: DVs $EA/$AA) and a battle RUN cannot leave +-- (TryToRunAwayFromBattle's .cant_escape arm for the type). +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +return function(game) + local bot = Bot.new(game) + for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end + local ok, err = A.loadCheckpoint(game, "10") + if not ok then + print("[forceshiny] FAIL resume: " .. tostring(err)) + return + end + for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end + + if not bot:travelTo("LAKE_OF_RAGE") then + print("[forceshiny] FAIL travel") + return + end + if not bot:approachAndFace(18, 22) then + print("[forceshiny] FAIL approach gyarados") + return + end + bot:tap("a") + local battle + for _ = 1, 2400 do + if A.inBattle(game) then + battle = A.top(game).battle + break + end + bot:tap("a") + bot:wait(2) + end + if not battle then + print("[forceshiny] FAIL no battle came up") + return + end + + local wild = battle.enemy or {} + local dvs = wild.dvs or {} + print(("[forceshiny] map=%s pos=%s,%s wild=%s trainer=%s level=%s") + :format(tostring(A.mapId(game)), tostring(select(1, A.pos(game))), + tostring(select(2, A.pos(game))), tostring(battle.wild), + tostring(battle.trainer and battle.trainer.name), tostring(wild.level))) + print(("[forceshiny] battleType=%s species=%s shiny=%s dvs=%s/%s/%s/%s") + :format(tostring(battle.battleType), tostring(wild.species), + tostring(wild.shiny), tostring(dvs.attack), tostring(dvs.defense), + tostring(dvs.speed), tostring(dvs.special))) + + local pass = battle.battleType == 7 + and wild.species == "GYARADOS" + and wild.shiny == true + and dvs.attack == 14 and dvs.defense == 10 + and dvs.speed == 10 and dvs.special == 10 + + -- RUN must refuse and leave the encounter live. + local ran = battle:tryRun() + print(("[forceshiny] tryRun=%s over=%s") + :format(tostring(ran), tostring(battle.over))) + if ran or battle.over then pass = false end + + print(pass and "[forceshiny] PASS" or "[forceshiny] FAIL") +end diff --git a/tests/drivers/gold_frame_seams.lua b/tests/drivers/gold_frame_seams.lua new file mode 100644 index 00000000..68526a20 --- /dev/null +++ b/tests/drivers/gold_frame_seams.lua @@ -0,0 +1,240 @@ +-- Gold's frame and input seams: proof that all six shared hooks fire on a Gold +-- boot, with the Gen 1 payloads, and that subscribing to them does not change +-- the picture. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_frame_seams.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-seams love . +-- +-- Two halves, and the second is the one that matters. A hook that fires is +-- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all, +-- because it silently changes what every existing Gold screenshot means. So +-- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC + +-- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that +-- draw nothing, shoots the same four frames again, and compares the PNG bytes. +-- Identical files are the claim; the shots are left on disk either way so a +-- human can look at the picture the port is actually producing. +-- +-- The four frames are chosen to cover every branch of Game2:draw: no canvas at +-- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and +-- (once the wraps are on) the render.compose path that forces a canvas even +-- when no display mode wanted one. +local U = require("tests.drivers.util") + +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GbcPalette = require("src.render.GbcPalette") +local GBCFX = require("src.render.GBCFX") + +local OWNER = "driver_frame_seams" + +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 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-seams" + local failures = 0 + local function check(ok, what) + if ok then + print("[driver] ok " .. what) + else + failures = failures + 1 + print("[driver] FAIL " .. what) + end + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + -- The four frames, each named for the display state it exercises. `fx` is + -- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs + -- and a headless checkout may have no shader at all, in which case that + -- frame simply repeats the CLASSIC one and the comparison still means + -- something. + local frames = { + { name = "world", color = "gbc", fx = 0 }, + { name = "classic", color = "classic", fx = 0 }, + { name = "classicfx", color = "classic", fx = 2 }, + { name = "menu", color = "gbc", fx = 0, menu = true }, + } + + local function shoot(tag) + local menuOpen = false + for _, frame in ipairs(frames) do + GbcPalette.setMode(frame.color) + GBCFX.setLevel(frame.fx) + if frame.menu and not menuOpen then + game:openStartMenu() + menuOpen = true + U.wait(6) + end + U.wait(2) + U.shot(game, ("%s/%s-%s.png"):format(out, frame.name, tag)) + end + if menuOpen then + -- B closes the START menu; leave the stack the way it was found so the + -- second pass shoots the same scene the first one did + U.tap(game, "b") + U.wait(8) + end + GbcPalette.setMode("gbc") + GBCFX.setLevel(0) + end + + -- POKEPORT_SEAM_BASELINE=<tag> shoots the four frames under that tag and + -- stops, with nothing ever subscribed. That is how the composite was A/B'd + -- against the revision of Game2:draw that predates these seams: run it once + -- with the old draw in place, once with the new one, and diff the PNGs. + local baseline = os.getenv("POKEPORT_SEAM_BASELINE") + if baseline and baseline ~= "" then + shoot(baseline) + print(("[driver] baseline shots (%s) in %s"):format(baseline, out)) + return + end + + shoot("before") + + -- A live bus, in case this checkout booted with no mods (Runtime's null + -- object has no chains table and wantsHook is false for everything). + if not (Runtime.hooks and Runtime.hooks.wrap) then + Runtime.hooks = Hooks.new() + end + local hooks = Runtime.hooks + + local seen = {} + local payload = {} + local function record(name, ctx) + seen[name] = (seen[name] or 0) + 1 + payload[name] = payload[name] or ctx + end + + -- input.step: (game, dt), before the pad is read + hooks:wrap("input.step", function(nextFn, g, dt) + record("input.step", { game = g, dt = dt }) + return nextFn(g, dt) + end, 0, OWNER) + + -- input.pointer: (game, event); returning false is "not consumed" + hooks:wrap("input.pointer", function(nextFn, g, ev) + record("input.pointer", { game = g, ev = ev }) + return nextFn(g, ev) + end, 0, OWNER) + + -- render.zones: (game, zones) -> zones. Pass the list straight through; a + -- wrap that returned a new list would be testing itself, not the seam. + hooks:wrap("render.zones", function(nextFn, g, zones) + record("render.zones", { game = g, zones = zones }) + return nextFn(g, zones) + end, 0, OWNER) + + -- render.compose: (renderer, ctx) -> true to take the window. Declines, so + -- the engine composite still runs -- which is the case the byte comparison + -- below is about. + hooks:wrap("render.compose", function(nextFn, r, ctx) + record("render.compose", { renderer = r, ctx = ctx }) + return nextFn(r, ctx) + end, 0, OWNER) + + -- render.letterbox: (ctx), draws nothing + hooks:wrap("render.letterbox", function(nextFn, ctx) + record("render.letterbox", { ctx = ctx }) + return nextFn(ctx) + end, 0, OWNER) + + -- render.hud: (game, viewport), draws nothing + hooks:wrap("render.hud", function(nextFn, g, viewport) + record("render.hud", { game = g, viewport = viewport }) + return nextFn(g, viewport) + end, 0, OWNER) + + U.wait(4) + -- a pointer the engine itself never generates headlessly + game:mousepressed(40, 30, 1, false) + game:mousemoved(48, 36, 8, 6, false) + game:mousereleased(48, 36, 1, false) + U.wait(2) + + shoot("after") + + -- ---- the seams fired, with the Gen 1 payloads ----------------------------- + + for _, name in ipairs({ "input.step", "input.pointer", "render.zones", + "render.compose", "render.letterbox", + "render.hud" }) do + check((seen[name] or 0) > 0, name .. " fires on Gold (" .. + tostring(seen[name] or 0) .. " calls)") + end + + local step = payload["input.step"] + check(step and step.game == game, "input.step receives the live Game object") + check(step and math.abs((step.dt or 0) - 1 / 60) < 1e-9, + "input.step receives the fixed-step dt") + + local ptr = payload["input.pointer"] + check(ptr and ptr.game == game, "input.pointer receives the live Game object") + check(ptr and ptr.ev and ptr.ev.phase == "pressed" + and ptr.ev.source == "mouse" and ptr.ev.id == "mouse" + and ptr.ev.x == 40 and ptr.ev.y == 30 and ptr.ev.button == 1 + and ptr.ev.dx == 0 and ptr.ev.dy == 0, + "input.pointer carries phase/source/id/x/y/dx/dy/button") + + local zones = payload["render.zones"] + check(zones and zones.game == game, "render.zones receives the live Game") + + local hud = payload["render.hud"] + local vp = hud and hud.viewport + local ww, wh = love.graphics.getDimensions() + check(vp and vp.width == ww and vp.height == wh, + "render.hud viewport carries the window size") + check(vp and vp.gameWidth == 160 * vp.scale + and vp.gameHeight == 144 * vp.scale, + "render.hud viewport playfield is 160x144 at the fit scale") + check(vp and vp.gameX == math.floor((ww - vp.gameWidth) / 2) + and vp.gameY == math.floor((wh - vp.gameHeight) / 2), + "render.hud viewport playfield is centred") + check(vp and vp.dpiX ~= nil and vp.dpiY ~= nil, + "render.hud viewport carries the dpi scales") + + local lb = payload["render.letterbox"] and payload["render.letterbox"].ctx + check(lb and lb.ww == ww and lb.wh == wh, + "render.letterbox carries ww/wh") + check(lb and lb.pw ~= nil and lb.ph ~= nil and lb.ox ~= nil and lb.oy ~= nil + and lb.vpw ~= nil and lb.vph ~= nil and lb.scale ~= nil + and lb.dpiX ~= nil and lb.dpiY ~= nil, + "render.letterbox carries pw/ph/ox/oy/vpw/vph/scale/dpi") + check(lb and type(lb.worldActive) == "boolean", + "render.letterbox carries worldActive") + + local ctx = payload["render.compose"] and payload["render.compose"].ctx + check(payload["render.compose"] + and payload["render.compose"].renderer == game, + "render.compose receives the compositor in the renderer position") + check(ctx and ctx.uiCanvas ~= nil and ctx.worldCanvas == ctx.uiCanvas, + "render.compose hands over Gold's one scene canvas under both keys") + check(ctx and ctx.ww == ww and ctx.wh == wh and ctx.uiw == 160 + and ctx.uih == 144 and ctx.scale ~= nil and ctx.ox ~= nil + and ctx.oy ~= nil and ctx.vpw ~= nil and ctx.vph ~= nil + and ctx.dpiX ~= nil and ctx.secondScreen ~= nil, + "render.compose carries the Gen 1 frame metrics") + + -- ---- and moved nothing ---------------------------------------------------- + + for _, frame in ipairs(frames) do + local a = readFile(("%s/%s-before.png"):format(out, frame.name)) + local b = readFile(("%s/%s-after.png"):format(out, frame.name)) + check(a ~= nil and b ~= nil and a == b, + ("subscribing does not change the %s frame"):format(frame.name)) + end + + hooks:removeOwner(OWNER) + print(("[driver] shots in %s"):format(out)) + if failures > 0 then + print(("[driver] FAILED (%d)"):format(failures)) + else + print("[driver] PASS") + end +end diff --git a/tests/drivers/gold_freeze_other_objects.lua b/tests/drivers/gold_freeze_other_objects.lua new file mode 100644 index 00000000..3244d8ab --- /dev/null +++ b/tests/drivers/gold_freeze_other_objects.lua @@ -0,0 +1,162 @@ +-- FreezeAllOtherObjects (engine/overworld/scripting.asm:751-755): the FIRST +-- act of every `applymovement`, before it has even read the movement pointer. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_freeze_other_objects.lua love . +-- +-- FreezeAllObjects sets FROZEN_F on every object struct and ApplyMovement then +-- clears it on the one being moved (engine/overworld/map_objects.asm:2529), +-- and nothing puts it back until EndScript's UnfreezeAllObjects. So from the +-- moment a Rocket grunt starts walking at you -- SeenByTrainerScript's +-- `applymovementlasttalked` (engine/events/trainer_scripts.asm:14) -- until +-- his after-battle line is done, NOBODY else on the floor turns. +-- +-- RADIO_TOWER_4F is the test bench: DJ MARY's teacher at (14,6) is +-- SPRITEMOVEDATA_SPINRANDOM_SLOW (maps/RadioTower4F.asm:263) and stands well +-- clear of the grunt at (5,6), so she is a pure observer. She must roll new +-- facings before the trainer engages, hold ONE facing for the whole exchange, +-- and start rolling again once it is over. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local TEACHER, GRUNT = 2, 4 -- def.objects indices + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-freeze" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + game.save.party = { assert(Mon.new(game.data, "TYPHLOSION", 60)) } + + -- Every Rocket on this floor carries EVENT_RADIO_TOWER_ROCKET_TAKEOVER + -- (maps/RadioTower4F.asm:265), which the story clears when Team Rocket moves + -- in. Read the flag off the object rather than naming a number. + world:setMap("RADIO_TOWER_4F", 10, 10, "up") + U.wait(5) + world.events:set(world.map.def.objects[4].eventFlag, false) + world:setMap("RADIO_TOWER_4F", 10, 10, "up") + U.wait(20) + -- (10,10) shares no row and no column with any sight cone on the floor. The + -- map's own stairs land at (5,9), three cells below the grunt at (5,6), who + -- faces DOWN with sight 3 -- land there with a party and the trainer script + -- is already running before the idle window opens. + local function obj(index) + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.index == index then return npc end + end + return nil + end + local teacher = obj(TEACHER) + local grunt = obj(GRUNT) + assert(teacher, "RADIO_TOWER_4F has no teacher at index " .. TEACHER) + assert(grunt, "RADIO_TOWER_4F has no grunt at index " .. GRUNT) + assert(teacher.def.movement == 3, + "the teacher is not SPINRANDOM_SLOW, got " .. tostring(teacher.def.movement)) + + -- Facing CHANGES over a window, plus any frame the object was holding + -- FROZEN_F. The facing count is the behaviour; the flag is the mechanism, + -- and it is the one that does not depend on a random re-roll picking a + -- different quarter (SPINRANDOM_SLOW holds 60-180 frames and may well roll + -- the same way twice). + local function watch(frames) + local changes, frozen, last = 0, 0, teacher.facing + for _ = 1, frames do + if teacher.facing ~= last then + changes = changes + 1 + last = teacher.facing + end + if teacher.frozen then frozen = frozen + 1 end + U.wait(1) + end + return changes, frozen + end + + -- Idle: nothing is frozen, because no script has run an applymovement. + local idleTurns, idleFrozen = watch(600) + print(("[driver] idle: %d facing changes, %d frozen frames") + :format(idleTurns, idleFrozen)) + U.shot(game, out .. "/00-idle.png") + assert(idleFrozen == 0, "the teacher was frozen with no script running") + + -- The freeze starts at the FIRST applymovement, not at the first command: + -- SeenByTrainerScript spends `showemote EMOTE_SHOCK, LAST_TALKED, 30` before + -- it walks (engine/events/trainer_scripts.asm:12-14), and on the cart the + -- floor is still live through the bubble. Latch the moment ApplyMovement + -- runs and only hold the port to the flag from there on. + local walked = false + local realBegin = world.beginMovement + world.beginMovement = function(self, objectId, bytes, onDone) + walked = true + return realBegin(self, objectId, bytes, onDone) + end + + -- Engage: stand in the grunt's line and let the eyesight test fire. + grunt.facing = "down" + world.player.cellX, world.player.cellY = grunt.cellX, grunt.cellY + 3 + world.player.px = world.player.cellX * 16 + world.player.py = world.player.cellY * 16 + local fired = false + for _ = 1, 120 do + if world:busy() then fired = true break end + world:checkTrainerBattle() + U.wait(1) + end + assert(fired, "the grunt never noticed the player") + + -- Hold: every frame the world is busy, right through the battle and the + -- after-battle text. + local held = teacher.facing + local drift, busyFrames, thawed = 0, 0, 0 + for _ = 1, 2400 do + local top = game.stack:top() + if top and top.battle then + for _ = 1, 900 do + if top.battle.over then break end + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(2) + game.input.state.a = false + U.wait(2) + end + end + if world:busy() then + busyFrames = busyFrames + 1 + if walked then + if teacher.facing ~= held then drift = drift + 1 end + if not teacher.frozen then thawed = thawed + 1 end + else + held = teacher.facing + end + elseif busyFrames > 60 then + break + end + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(1) + game.input.state.a = false + U.wait(1) + end + print(("[driver] %d busy frames: %d facing changes, %d frames unfrozen") + :format(busyFrames, drift, thawed)) + U.shot(game, out .. "/01-during.png") + assert(busyFrames > 60, "the exchange was too short to prove anything") + assert(thawed == 0, + ("the teacher was unfrozen on %d of %d script frames; ApplyMovement's " + .. "FreezeAllOtherObjects holds every object but the one being moved") + :format(thawed, busyFrames)) + assert(drift == 0, + "the teacher kept spinning through the trainer exchange") + + -- Release: EndScript's UnfreezeAllObjects gives every object its movement + -- function back. + local afterTurns, afterFrozen = watch(600) + print(("[driver] after: %d facing changes, %d frozen frames") + :format(afterTurns, afterFrozen)) + U.shot(game, out .. "/02-after.png") + assert(afterFrozen == 0, + "the teacher never came out of FROZEN_F; EndScript's UnfreezeAllObjects " + .. "gives every object its movement function back") + print("[driver] PASS gold freeze-all-other-objects in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_giveitem_box.lua b/tests/drivers/gold_giveitem_box.lua new file mode 100644 index 00000000..3948de52 --- /dev/null +++ b/tests/drivers/gold_giveitem_box.lua @@ -0,0 +1,99 @@ +-- GiveItemScript (engine/overworld/scripting.asm:441-449) is ONE MapTextbox. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_giveitem_box.lua love . +-- +-- `writetext .ReceivedItemText / iffalse .Full / waitsfx / specialsound / +-- waitbutton / itemnotify`: the received line and the "put it in the pocket" +-- line print into the SAME box, which the caller's `opentext` opened and the +-- caller's `closetext` closes. Nothing between them takes the box down. +-- +-- This port draws a box per message, so the seam between them is where the +-- fidelity is: the second box has to go up inside the same frame the first +-- one pops. If a frame renders with an empty state stack in between, the box +-- visibly tears down and rebuilds AND Game2's play clock -- which only ticks +-- while the overworld is the top state (src/core/Game2.lua, wGameTimerPaused) +-- -- comes off pause for the length of the gap. +-- +-- The run counts the bare-overworld frames between the two boxes and the +-- play-clock frames they cost, and shoots both pages. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-giveitem" + + local function tap(button) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(2) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + game.save.playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 } + local function clockFrames() + local t = game.save.playTime + return ((t.hours * 60 + t.minutes) * 60 + t.seconds) * 60 + t.frames + end + + -- `opentext / verbosegiveitem POTION, 1 / closetext / end`, the shape every + -- NPC hand-over in the game uses. + world.vm:start({ + { op = "opentext" }, + { op = "verbosegiveitem", args = { "POTION", 1 } }, + { op = "closetext" }, + { op = "end" }, + }) + + -- Sampled every frame, and the A press goes in every sixth: a sample taken + -- only on press frames would step straight over the seam being measured. + local bare, boxes, seenFirst = 0, 0, false + local clockAtFirstBox, clockAtLastBox = nil, nil + local shots, pressIn = 0, 8 + for _ = 1, 900 do + if not world:busy() and seenFirst then break end + local top = game.stack:top() + if top then + if not seenFirst then + seenFirst = true + clockAtFirstBox = clockFrames() + end + clockAtLastBox = clockFrames() + if shots < 2 and boxes % 12 == 6 then + shots = shots + 1 + U.shot(game, ("%s/%02d-page.png"):format(out, shots)) + end + boxes = boxes + 1 + elseif seenFirst then + bare = bare + 1 + end + pressIn = pressIn - 1 + if pressIn <= 0 then + pressIn = 6 + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(1) + game.input.state.a = false + else + U.wait(1) + end + end + + local spent = (clockAtLastBox or 0) - (clockAtFirstBox or 0) + print(("[driver] %d frames with a box up, %d bare frames between the pages") + :format(boxes, bare)) + print(("[driver] the play clock advanced %d frames across the exchange") + :format(spent)) + assert(seenFirst, "no text box ever went up for the item") + assert(bare == 0, + ("the overworld drew bare for %d frames between the two pages of one " + .. "GiveItemScript textbox"):format(bare)) + assert(spent == 0, + ("the play clock ran for %d frames while an item was being handed over") + :format(spent)) + print("[driver] PASS gold giveitem single box in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_halloffame_shots.lua b/tests/drivers/gold_halloffame_shots.lua new file mode 100644 index 00000000..bc94d881 --- /dev/null +++ b/tests/drivers/gold_halloffame_shots.lua @@ -0,0 +1,147 @@ +-- The end of the game, sampled: the Hall of Fame induction, the credits roll, +-- and the roster the PC shows afterwards. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_halloffame_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-hof (default) +-- +-- Everything here is a cinematic, which is exactly what no assertion can +-- check: "does the backpic really sweep off to the left before the frontpic +-- comes back", "does the banner change mon on each CREDITS_SCENE", "does THE +-- END stay up after the last blank" are questions for eyes. So this stands +-- the real screens up on the real stack, lets them run at their own 60 Hz, and +-- lays each one out as a contact sheet. +-- +-- Shots are named by the frame the screen has been running for and the phase +-- (or credits scene) it is in, so a file is directly comparable against +-- engine/events/halloffame.asm and engine/movie/credits.asm. +local U = require("tests.drivers.util") + +local Core = require("src.core.gen2.HallOfFame") +local Credits = require("src.ui.gen2.Credits") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local Mon = require("src.battle.gen2.Mon") + +-- The induction is 292 frames a mon, so a six-mon party runs about 1900 +-- frames; the credits are about 4400. Half a second apiece keeps both +-- readable without an unusable number of files. +local INDUCT_INTERVAL = 20 +local CREDITS_INTERVAL = 60 +local LIMIT = 8000 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-hof" + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + local data = game.data + + -- A champion's party. Mon.new is the ONE builder for a Gen 2 party member: + -- anything routed through Gen 1's Pokemon.new comes back with no moves, + -- because a Gen 2 moveset is levelMoves and Gen 1 reads level1Moves. + local roster = { + { "TYPHLOSION", 50, "BLAZE" }, + { "LANTURN", 46, "SPARK" }, + { "AMPHAROS", 45, nil }, + { "UMBREON", 44, "DUSK" }, + { "SCIZOR", 43, nil }, + { "GYARADOS", 47, "RAGE" }, + } + save.party = {} + for _, row in ipairs(roster) do + local mon = Mon.new(data, row[1], row[2], { nickname = row[3] }) + if mon then + mon.otId = save.player and save.player.id or 12345 + save.party[#save.party + 1] = mon + end + end + assert(#save.party > 0, "no party could be built from this cache") + save.player.name = save.player.name or "GOLD" + save.playTime = { hours = 42, minutes = 7, seconds = 0, frames = 0 } + + -- ---- the induction ------------------------------------------------------ + + -- What the `halloffame` opcode does to the save before the screen opens. + -- `wasEntered` is the ALLOW_SKIPPING_CREDITS_F bit Credits wants: false the + -- first time, which is why a first-time champion cannot hurry the roll. + local entry, wasEntered = Core.induct(save, save.party) + U.log(("inducted: %d mon(s), win count %d, spawn %s") + :format(#entry.mons, entry.winCount, tostring(save.spawnAfterChampion))) + + local inducted = false + local induction = HallOfFame.new(game, { + save = save, entry = entry, + onDone = function() inducted = true end, + }) + game.stack:clear() + game.stack:push(induction) + + local shots, phase = 0, nil + while not inducted and induction.frames < LIMIT do + U.wait(INDUCT_INTERVAL) + if induction.phase ~= phase then + phase = induction.phase + U.log(("hof phase %s at frame %d (mon %d, scx=%02x scy=%02x)") + :format(tostring(phase), induction.frames, induction.index, + induction.scx, induction.scy)) + end + U.shot(game, ("%s/hof-%04d-%s.png") + :format(out, induction.frames, tostring(phase))) + shots = shots + 1 + end + assert(inducted, "the induction never reached HOF_AnimatePlayerPic's end") + U.log(("%d induction shots over %d frames"):format(shots, induction.frames)) + game.stack:pop() + + -- ---- the credits -------------------------------------------------------- + + local rolled = false + local credits = Credits.new(game, { + allowSkip = wasEntered, + onDone = function() rolled = true end, + }) + game.stack:clear() + game.stack:push(credits) + + shots = 0 + local scene = -1 + while not credits.exiting and credits.frames < LIMIT do + U.wait(CREDITS_INTERVAL) + if credits.scene ~= scene then + scene = credits.scene + U.log(("credits scene %d at frame %d (pass %d, pos %d)") + :format(scene, credits.frames, credits.passes, credits.pos)) + end + U.shot(game, ("%s/credits-%04d-scene%d.png") + :format(out, credits.frames, scene)) + shots = shots + 1 + end + assert(credits.exiting, "the credits script never reached CREDITS_END") + -- CREDITS_END only sets the exit flag; the screen waits on A, so the last + -- shot is THE END sitting there exactly as the player sees it. + U.wait(30) + U.shot(game, ("%s/credits-%04d-theend.png"):format(out, credits.frames)) + U.log(("%d credits shots over %d frames, %d passes") + :format(shots + 1, credits.frames, credits.passes)) + U.tap(game, "a") + U.wait(10) + assert(rolled, "A did not leave the credits once the exit flag was up") + game.stack:clear() + + -- ---- the roster, as the PC shows it ------------------------------------- + + -- _HallOfFamePC over the row that was just written: A walks the team, and + -- the header is "-Time Famer" rather than "New Hall of Famer!". + local viewer = HallOfFame.new(game, { + mode = "view", save = save, onDone = function() end, + }) + game.stack:push(viewer) + for index = 1, #entry.mons do + U.wait(10) + U.shot(game, ("%s/pc-%02d-%s.png") + :format(out, index, tostring((viewer:currentMon() or {}).species))) + U.tap(game, "a") + end + U.log("roster viewer shot for " .. #entry.mons .. " mon(s) in " .. out) +end diff --git a/tests/drivers/gold_heal_mart_shots.lua b/tests/drivers/gold_heal_mart_shots.lua new file mode 100644 index 00000000..193d8515 --- /dev/null +++ b/tests/drivers/gold_heal_mart_shots.lua @@ -0,0 +1,152 @@ +-- The Pokecenter heal machine and the POKeMART shelf, in the running game. +-- +-- POKEPORT_IDENTITY=goldshopfix POKEPORT_GAME=gold POKEPORT_GOLD_HOUR=10 \ +-- POKEPORT_DRIVER=tests/drivers/gold_heal_mart_shots.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Two things this is watching for: +-- * PokecenterNurseScript's `special HealMachineAnim` used to be a bare +-- (wrong) sfx: the balls, the flashing and MUSIC_HEAL all have to appear, +-- ON the machine at the counter's left end, and the nurse's next line +-- must wait for the last flash +-- * a mart clerk's shelf came from data/generated/marts.lua being absent, +-- so every shop opened empty: Cherrygrove must stock its four items with +-- ItemAttributes prices and a purchase must move money and the PACK +-- +-- Shots land in /tmp/gold-heal-mart. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-heal-mart" + local fails = 0 + + local function ok(cond, msg) + if cond then print("[healmart] ok " .. msg) + else fails = fails + 1 print("[healmart] FAIL " .. msg) end + return cond + 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(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + + -- A party worth three balls on the machine, all hurt so the heal is real. + local Mon = require("src.battle.gen2.Mon") + save.party = {} + for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "RATTATA" }) do + local mon = Mon.new(game.data, species, 10) + mon.hp = 1 + save.party[#save.party + 1] = mon + end + save.player = save.player or {} + save.player.money = 5000 + + -- ------------------------------------------------------------- the nurse + w:setMap("CHERRYGROVE_POKECENTER_1F", 3, 3, "up") + U.wait(20) + U.shot(game, out .. "/00-pokecenter.png") + + -- Talk across the counter, then hold A through the greeting and the + -- yes/no (YES is the default), stopping the moment the machine starts. + tap("a", 6) + local sawAnim, sawBalls, sawFlash, sawJingle = false, 0, false, false + local Music = require("src.core.Music") + for _ = 1, 60 * 30 do + local ha = w.healAnim + if ha then + sawAnim = true + if ha.lit > sawBalls then + sawBalls = ha.lit + U.shot(game, ("%s/01-ball-%d.png"):format(out, ha.lit)) + end + if ha.phase == "flash" and not sawFlash and ha.rotation ~= 0 then + sawFlash = true + U.shot(game, out .. "/02-flash.png") + end + if Music.current() == "Music_HealPokemon" then sawJingle = true end + U.wait(1) + elseif sawAnim then + break + else + tap("a", 2) + end + end + ok(sawAnim, "the heal machine animation ran") + ok(sawBalls == 3, "one ball per party member landed (" .. sawBalls .. ")") + ok(sawFlash, "the machine flashed its palette") + ok(sawJingle, "MUSIC_HEAL played over the flashing") + U.shot(game, out .. "/03-after-flash.png") + + -- The script is still mid-conversation ("thank you for waiting"); page out. + for _ = 1, 40 do + if not w:busy() then break end + tap("a", 4) + end + local healed = true + for _, mon in ipairs(save.party) do + if (mon.hp or 0) < (mon.maxHp or 1) then healed = false end + end + ok(healed, "the party left the counter at full HP") + U.shot(game, out .. "/04-healed.png") + + -- -------------------------------------------------------------- the mart + w:setMap("CHERRYGROVE_MART", 2, 3, "left") + U.wait(20) + tap("a", 6) + -- Page the welcome line until the BUY/SELL/QUIT screen owns the stack. + local mart + for _ = 1, 120 do + local top = game.stack and game.stack:top() + if top and top.martType then mart = top break end + tap("a", 3) + end + if not ok(mart ~= nil, "the clerk opened the mart screen") then + U.shot(game, out .. "/05-no-mart.png") + print(("[healmart] %d failures"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) + return + end + U.shot(game, out .. "/05-mart-top.png") + + ok(#mart.entries == 4, "Cherrygrove stocks four items (" + .. #mart.entries .. ")") + ok(mart.entries[1] and mart.entries[1].id == "POTION" + and mart.entries[1].price == 300, + "POTION at the ROM's own 300 leads the shelf") + + tap("a", 6) -- BUY + U.shot(game, out .. "/06-buy-list.png") + tap("a", 6) -- pick POTION -> quantity + tap("up", 4) -- x2 + U.shot(game, out .. "/07-quantity.png") + tap("a", 6) -- how many -> confirm + U.shot(game, out .. "/08-confirm.png") + tap("a", 8) -- YES + tap("a", 8) -- "Here you are" page + U.shot(game, out .. "/09-bought.png") + + ok((save.inventory and save.inventory.POTION) == 2, + "two POTIONs landed in the PACK") + ok(save.player.money == 5000 - 600, + "the till took 600 (money " .. tostring(save.player.money) .. ")") + + -- Leave: B out of the list, then QUIT + the come-again line. + tap("b", 6) + tap("b", 6) + tap("a", 6) + tap("a", 10) + U.shot(game, out .. "/10-outside.png") + + print(("[healmart] %d failures"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_hm07_probe.lua b/tests/drivers/gold_hm07_probe.lua new file mode 100644 index 00000000..97947815 --- /dev/null +++ b/tests/drivers/gold_hm07_probe.lua @@ -0,0 +1,84 @@ +-- HM07 ball probe: resume section 13, enter Ice Path 1F, run the exact +-- approach the route row 13.10 makes (approachAndFace the ball at (31,7)), +-- and report every state change on the way. +-- +-- POKEPORT_IDENTITY=gold-v2b POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=13 \ +-- POKEPORT_DRIVER=tests/drivers/gold_hm07_probe.lua love . + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local resume = os.getenv("POKEPORT_GOLD_RESUME") + if resume then + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[hm07] cannot resume %s: %s"):format(resume, tostring(err))) + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + end + + local world = game.world + world:setMap("ICE_PATH_1F", 4, 19, "up") + bot:wait(30) + bot:clearDialogue(nil, 4000) + + local function report(tag) + local px, py = A.pos(game) + print(("[hm07] %-10s map=%s pos=%s,%s ball=%s flag=%s busy=%s") + :format(tag, tostring(A.mapId(game)), tostring(px), tostring(py), + tostring(A.npcAt(game, 31, 7) ~= nil), + tostring(world.events:get(1672)), + tostring(A.busyReason(game)))) + end + + report("arrived") + local reached = bot:approachAndFace(31, 7) + print("[hm07] approachAndFace(31,7):", tostring(reached)) + report("approached") + if reached then + for attempt = 1, 4 do + bot:tap("a") + bot:wait(4) + for _ = 1, 30 do + if A.busy(game) then break end + bot:wait(1) + end + if A.busy(game) then + print("[hm07] tap " .. attempt .. " opened something") + break + end + print("[hm07] tap " .. attempt .. " opened nothing") + end + bot:clearDialogue(nil, 4000) + end + report("done") + + -- Engine introspection: is the press being lost, or is interact refusing? + local p = world.player + print(("[hm07] engine: busy=%s moving=%s facing=%s turnLatch=%s") + :format(tostring(world:busy()), tostring(p and p.moving), + tostring(p and p.facing), tostring(world.turningDirection))) + local npc = world:npcAt(31, 7) + print(("[hm07] engine npcAt(31,7): %s def.itemball=%s") + :format(tostring(npc ~= nil), + tostring(npc and npc.def and npc.def.itemball + and npc.def.itemball.item))) + local r = world:interact() + print("[hm07] direct world:interact():", tostring(r)) + bot:wait(60) + bot:clearDialogue({ "yes" }, 3000) + report("direct") + love.event.quit() +end diff --git a/tests/drivers/gold_hof_continue.lua b/tests/drivers/gold_hof_continue.lua new file mode 100644 index 00000000..56180e3f --- /dev/null +++ b/tests/drivers/gold_hof_continue.lua @@ -0,0 +1,111 @@ +-- Assertion driver: the champion's ending, end to end on the real game. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev POKEPORT_SPEED=200 \ +-- POKEPORT_DRIVER=tests/drivers/gold_hof_continue.lua love . +-- +-- The chain under test is the cart's own (engine/events/halloffame.asm, +-- engine/overworld/scripting.asm ReturnFromCredits, engine/menus/ +-- intro_menu.asm Continue / FinishContinueFunction): +-- +-- halloffame -> induction ceremony -> credits roll -> `jp Reset` (title) +-- CONTINUE -> wSpawnAfterChampion = SPAWN_LANCE consumed -> New Bark Town +-- +-- tests/gen2_hof_continue_test.lua proves each link against registry fakes; +-- this runs the real screens on the real stack, lets the real induction write +-- the real save slot, and then CONTINUEs through Game2:continueGame exactly +-- as the main menu does. The active save slot is backed up first and +-- restored on the way out, whatever happens. +local U = require("tests.drivers.util") + +local Gen2Save = require("src.core.gen2.Save") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + -- Guard the slot: the induction's SaveGameData writes it for real. + local main, bak, tmp = Gen2Save.filenames("gold") + local keep = {} + for _, name in ipairs({ main, bak, tmp }) do + keep[name] = love.filesystem.read(name) + end + local function restoreSlot() + for _, name in ipairs({ main, bak, tmp }) do + if keep[name] then + love.filesystem.write(name, keep[name]) + else + love.filesystem.remove(name) + end + end + end + + local ok, err = pcall(function() + local world, save, data = game.world, game.save, game.data + save.party = { Mon.new(data, "TYPHLOSION", 50) } + assert(save.party[1], "no party could be built from this cache") + save.player.name = save.player.name or "GOLD" + + -- Stand where Script_halloffame runs: the Hall of Fame chamber. + assert(world:setMap("HALL_OF_FAME", 4, 12, "up"), "setMap HALL_OF_FAME") + U.wait(5) + + -- The `halloffame` command, off the live world. + local resumed = false + assert(world:hallOfFame(function() resumed = true end), + "halloffame did not take the screen") + assert(save.spawnAfterChampion == "SPAWN_LANCE", + "induction did not write wSpawnAfterChampion") + + -- The ceremony auto-advances; the roll follows it on the same call. A + -- first-time champion cannot skip, so ride it out and press A at THE END. + local sawCredits = false + for _ = 1, 700 do + local top = game.stack:top() + if top and top.screenId == "Gen2Credits" then + sawCredits = true + if top.exiting then break end + end + U.wait(30) + end + assert(sawCredits, "the credits never followed the induction") + local top = game.stack:top() + assert(top and top.exiting, "the credits never reached CREDITS_END") + U.tap(game, "a") + U.wait(10) + + -- `jp Reset`: back on the title, world torn down, script resumed first. + assert(resumed, "the script never resumed out of the credits") + assert(game.phase == "boot", "the credits did not end on the title screen") + assert(game.world == nil, "the world survived the reset") + U.log("post-credits: reset to title, as FinishContinueFunction does") + + -- The slot on disk carries the one-shot and the sealed room. + local written = Gen2Save.load("gold") + assert(written, "the induction never saved") + assert(written.spawnAfterChampion == "SPAWN_LANCE", + "the saved slot lost wSpawnAfterChampion") + assert(written.position and written.position.map == "HALL_OF_FAME", + "the saved position is not the Hall of Fame") + U.log("slot: spawnAfterChampion=SPAWN_LANCE, position=HALL_OF_FAME") + + -- CONTINUE, exactly as the main menu's row does it. + game:continueGame(written) + U.wait(10) + assert(game.world and game.world.map, "CONTINUE did not boot a world") + assert(game.world.map.id == "NEW_BARK_TOWN", + "CONTINUE resumed on " .. tostring(game.world.map.id) + .. ", expected NEW_BARK_TOWN") + assert(game.world.player.cellX == 13 and game.world.player.cellY == 6, + ("CONTINUE landed at (%d,%d), expected SPAWN_NEW_BARK (13,6)") + :format(game.world.player.cellX, game.world.player.cellY)) + assert(game.save.spawnAfterChampion == nil, + "PostCreditsSpawn did not zero the byte") + U.log("CONTINUE: spawned at New Bark Town, byte consumed") + end) + + restoreSlot() + assert(ok, err) + U.log("PASS gold_hof_continue") + love.event.quit() +end diff --git a/tests/drivers/gold_icepath_boulder.lua b/tests/drivers/gold_icepath_boulder.lua new file mode 100644 index 00000000..e068da34 --- /dev/null +++ b/tests/drivers/gold_icepath_boulder.lua @@ -0,0 +1,152 @@ +-- The command queue, on the map that needs it. +-- +-- `writecmdqueue` / `delcmdqueue` were no-ops because there was no wCmdQueue +-- engine (engine/overworld/cmd_queue.asm, polled once a frame). Ice Path B1F +-- is one of the two maps that use CMDQUEUE_STONETABLE, and it is the one that +-- matters: the queue is what makes a pushed boulder fall through the hole, Ice +-- Path gates Blackthorn, and Blackthorn is the eighth badge. +-- +-- This pushes ICEPATHB1F_BOULDER1 north onto warp 3 at (11,2) with STRENGTH +-- active, and asserts the boulder falls and its twin one floor down appears. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_icepath_boulder.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-icepath" + +return function(game) + local w = game.world + local fails = 0 + + local function wait(n) for _ = 1, n do coroutine.yield() end end + + local function ok(cond, msg) + if cond then print("[icepath] ok " .. msg) + else fails = fails + 1 print("[icepath] FAIL " .. msg) end + return cond + end + + local function clearDirs() + game.input.pressQueue = {} + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + game.input.sources[d] = nil + end + end + + local function hold(dir, frames) + clearDirs() + for _ = 1, frames do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + clearDirs() + coroutine.yield() + end + + local function boulder() + for _, npc in ipairs(w.npcs) do + if npc.def and npc.def.index == 1 then return npc end + end + return nil + end + + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + wait(45) + + -- ICEPATHB1F_BOULDER1's hole is warp 3 at (11,2), and (11,4) is wall, so the + -- boulder reaches it from the WEST: the last push of the puzzle is the player + -- at (9,2) walking right into a boulder on (10,2). + -- + -- The boulder starts the map at (11,7) and the route between is most of the + -- floor's maze. This driver is about the queue, not about the maze, so it + -- parks the boulder on the cell the maze delivers it to and performs the LAST + -- push for real. Nothing else is faked: the push is a walk, the fall is the + -- queue noticing, and the script is the map's own. + w:setMap("ICE_PATH_B1F", 9, 2, "right") + wait(20) + ok(w.map.id == "ICE_PATH_B1F", "on Ice Path B1F") + + local CmdQueue = require("src.world.gen2.CmdQueue") + ok(CmdQueue.count(w.cmdQueue) == 1, + "the map load wrote its MAPCALLBACK_CMDQUEUE entry (" + .. CmdQueue.count(w.cmdQueue) .. " slot(s) used)") + + local b = boulder() + ok(b ~= nil, "boulder 1 is on the map") + ok(b and b.cellX == 11 and b.cellY == 7, + ("at its spawn (11,7), got (%s,%s)"):format(tostring(b and b.cellX), + tostring(b and b.cellY))) + ok(w.map:cellCollision(11, 2) == 0x60, + "and the tile at its hole is COLL_PIT") + + -- One cell west of the hole, which is where the maze push route ends. + b.cellX, b.cellY = 10, 2 + b.homeX, b.homeY = 10, 2 + b.px, b.py = 10 * 16, 2 * 16 + ok(w.map:isWalkable(10, 2), "the cell it is pushed from is floor") + + -- BIKEFLAGS_STRENGTH_ACTIVE, which .CheckStrengthBoulder reads. Getting it + -- the honest way needs a party with STRENGTH and the RISING BADGE; this + -- driver is about the queue, not about the field move. + w.strengthActive = true + + game.capturePath = SHOT_DIR .. "/before.png" + wait(2) + + -- The push: walking into an occupied cell with STRENGTH active is what + -- .CheckStrengthBoulder turns into a step for the boulder instead. + for i = 1, 3 do + hold("right", 40) + local bb = boulder() + print(("[icepath] push %d: player (%d,%d) boulder (%s,%s)"):format( + i, w.player.cellX, w.player.cellY, + tostring(bb and bb.cellX), tostring(bb and bb.cellY))) + if w:busy() or bb == nil then break end + end + + -- The queue drops it on the first frame it is standing on the pit, and the + -- script that runs is pause 30 / SFX_STRENGTH / earthquake 80 / the line. + local fell = false + for _ = 1, 400 do + if w:busy() then fell = true break end + wait(2) + end + ok(fell, "something started once the boulder reached the hole") + game.capturePath = SHOT_DIR .. "/falling.png" + wait(4) + + for _ = 1, 400 do + if not w:busy() then break end + table.insert(game.input.pressQueue, "a") + wait(3) + end + wait(20) + + ok(boulder() == nil, "the boulder is gone from B1F") + -- EVENT_BOULDER_IN_ICE_PATH_1A (1805) hides the twin on the floor below; the + -- script CLEARS it, which is what puts the fallen boulder down there. + ok(w.events:get(1805) == false, + "and EVENT_BOULDER_IN_ICE_PATH_1A is clear, so it is on B2F now") + game.capturePath = SHOT_DIR .. "/after.png" + wait(4) + + -- Follow it down and look. + w:setMap("ICE_PATH_B2F_MAHOGANY_SIDE", 11, 5, "up") + wait(30) + local below = nil + for _, npc in ipairs(w.npcs) do + if npc.def and npc.def.index == 1 then below = npc end + end + ok(below ~= nil and below.cellX == 11 and below.cellY == 3, + "the boulder is standing on B2F at (11,3)") + game.capturePath = SHOT_DIR .. "/below.png" + wait(30) + + if fails > 0 then + error(("gold ice path: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold command queue: the boulder fell through") +end diff --git a/tests/drivers/gold_intro_shots.lua b/tests/drivers/gold_intro_shots.lua new file mode 100644 index 00000000..0f6221af --- /dev/null +++ b/tests/drivers/gold_intro_shots.lua @@ -0,0 +1,55 @@ +-- The Gold/Silver intro movie, sampled every half second. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_intro_shots.lua love . +-- +-- A cinematic is the one thing no assertion can check: "does Lapras surface +-- before the fade", "is the water bending", "is the fireball spiralling" are +-- questions for eyes. So this pushes the real GoldSilverIntro onto the stack, +-- lets it run at its own 60 Hz, and lays the whole ~39 seconds out as a +-- contact sheet in POKEPORT_SHOT_DIR (/tmp/gold-intro by default). +-- +-- Shots are named by the movie's own frame counter and current scene, so a +-- file is directly comparable against engine/movie/intro.asm's jumptable. +local U = require("tests.drivers.util") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") + +-- Every 30 frames covers each act's beats without producing an unreadable +-- number of files; the movie runs about 2340 frames end to end. +local INTERVAL = 30 +local LIMIT = 3000 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-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.water and assets.water.meta) then + print("[driver] SKIP no intro tables in this cache -- re-import Gold") + return + end + + local finished = false + local intro = GoldSilverIntro.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 + print(("[driver] scene %d at frame %d (scx=%02x scy=%02x objs=%d)") + :format(scene, intro.frames, intro.scx, intro.scy, + intro.anims:activeCount())) + end + U.shot(game, ("%s/%04d-scene%02d.png"):format(out, intro.frames, scene)) + shots = shots + 1 + end + + assert(finished, "the movie never reached the end of IntroScene17") + print(("[driver] %d shots in %s over %d frames") + :format(shots, out, intro.frames)) +end diff --git a/tests/drivers/gold_item_pickup_box.lua b/tests/drivers/gold_item_pickup_box.lua new file mode 100644 index 00000000..3a650ac1 --- /dev/null +++ b/tests/drivers/gold_item_pickup_box.lua @@ -0,0 +1,109 @@ +-- The seam gold_giveitem_box.lua measures, on the OTHER two scripts that hand +-- the player an item in the overworld. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_item_pickup_box.lua love . +-- +-- FindItemInBallScript (engine/events/misc_scripts.asm:10-19) is +-- `opentext / writetext .FoundItemText / playsound SFX_ITEM / pause 60 / +-- itemnotify / closetext`, and FruitTreeScript (engine/events/fruit_trees.asm +-- :17-25) is `writetext ObtainedFruitText / callasm PickedFruitTree / +-- specialsound / itemnotify`. Neither has a `waitbutton` between the found +-- line and the itemnotify line: both print into the ONE MapTextbox the script's +-- own `opentext` opened, and nothing takes it down in between. +-- +-- So the same rule as GiveItemScript applies: no frame between the two pages +-- may render with an empty state stack, because that is a visible tear-down of +-- the box AND it lets Game2's play clock (wGameTimerPaused, which is only held +-- while a state is on the stack) come off pause mid-pickup. +local U = require("tests.drivers.util") +local HiddenItems = require("src.world.gen2.HiddenItems") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-item-pickup" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local function clockFrames() + local t = game.save.playTime + return ((t.hours * 60 + t.minutes) * 60 + t.seconds) * 60 + t.frames + end + + -- Same sampling shape as gold_giveitem_box: every frame is looked at, and + -- the A press goes in every sixth, so the seam between two pages is never + -- stepped over by a sample that only lands on press frames. + local function measure(label, script, shotPrefix) + game.save.playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 } + world.vm:start(script) + local bare, boxes, seenFirst = 0, 0, false + local clockFirst, clockLast + local shots, pressIn = 0, 8 + for _ = 1, 1200 do + if not world:busy() and seenFirst then break end + local top = game.stack:top() + if top then + if not seenFirst then + seenFirst = true + clockFirst = clockFrames() + end + clockLast = clockFrames() + if shots < 2 and boxes % 12 == 6 then + shots = shots + 1 + U.shot(game, ("%s/%s-%02d-page.png"):format(out, shotPrefix, shots)) + end + boxes = boxes + 1 + elseif seenFirst then + bare = bare + 1 + end + pressIn = pressIn - 1 + if pressIn <= 0 then + pressIn = 6 + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(1) + game.input.state.a = false + else + U.wait(1) + end + end + local spent = (clockLast or 0) - (clockFirst or 0) + print(("[driver] %s: %d box frames, %d bare frames, clock ran %d frames") + :format(label, boxes, bare, spent)) + assert(seenFirst, label .. ": no text box ever went up") + return bare, spent + end + + -- FindItemInBallScript, exactly as World:interact builds it for every Poke + -- Ball on the floor. Object 1 stands in for LAST_TALKED; `disappear` on an + -- object this map may not have is a no-op, which is fine here -- the seam + -- being measured is the text, not the despawn. + local ballBare, ballClock = measure("item ball", + HiddenItems.ballPickupScript("POTION", 1, 1, + function(want, id) return world:sfxIdNamed(want, id) end), + "ball") + + U.wait(20) + + -- FruitTreeScript. Tree 1 is FRUITTREE_ROUTE_29 (the BERRY on Route 29). + -- The script's own `callasm TryResetFruitTrees` clears wFruitTreeFlags on + -- the first examine after the daily rollover, so a fresh boot takes the arm + -- that actually hands the fruit over rather than "There's nothing here". + local treeBare, treeClock = measure("fruit tree", + { { op = "opentext" }, { op = "fruittree", args = { 1 } } }, + "tree") + + assert(ballBare == 0, + ("the overworld drew bare for %d frames inside ONE FindItemInBallScript " + .. "textbox"):format(ballBare)) + assert(ballClock == 0, + ("the play clock ran %d frames while an item ball was picked up") + :format(ballClock)) + assert(treeBare == 0, + ("the overworld drew bare for %d frames inside ONE FruitTreeScript " + .. "textbox"):format(treeBare)) + assert(treeClock == 0, + ("the play clock ran %d frames while a berry was picked"):format(treeClock)) + print("[driver] PASS gold item pickup single box in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_lake_probe.lua b/tests/drivers/gold_lake_probe.lua new file mode 100644 index 00000000..91af5907 --- /dev/null +++ b/tests/drivers/gold_lake_probe.lua @@ -0,0 +1,97 @@ +-- Lake of Rage chain probe: resume section 10, travel, fight the Red Gyarados, +-- talk to Lance, enter the mart, and print the three Cluster C flags. +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +local function flag(game, name) + local v = A.event(game, name) + return v == nil and "?" or (v and "SET" or "clear") +end + +return function(game) + local bot = Bot.new(game) + for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end + local ok, err = A.loadCheckpoint(game, "10") + if not ok then + print("[lake] resume fail " .. tostring(err)) + return + end + for _ = 1, 3000 do if A.ready(game) then break end bot:wait(1) end + + bot:forgetSurf() + print(("[lake] start %s surf=%s gyarados=%s lance=%s stairs=%s") + :format(tostring(A.mapId(game)), tostring(bot:canSurf()), + flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS"), + flag(game, "EVENT_DECIDED_TO_HELP_LANCE"), + flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART"))) + + -- Burn a visit to the lake and back so edgeTries matches a real section-10 + -- approach (10.26 then 10.g), which is what exposed the skip=1 pocket bug. + print("[lake] priming edgeTries via lake <-> route 43...") + bot:travelTo("LAKE_OF_RAGE") + bot:travelTo("ROUTE_43") + + print("[lake] travelTo LAKE_OF_RAGE for the fight...") + if not bot:travelTo("LAKE_OF_RAGE") then + print("[lake] FAIL travel") + return + end + local nx, ny = A.pos(game) + print(("[lake] arrived %s @%d,%d regionSize=%d") + :format(tostring(A.mapId(game)), nx or -1, ny or -1, bot:regionSize())) + + print("[lake] approach+A Red Gyarados at 18,22...") + if not bot:approachAndFace(18, 22) then + print("[lake] FAIL approach gyarados") + return + end + local ax, ay = A.pos(game) + print(("[lake] standing @%d,%d facing=%s -- pressing A") + :format(ax or -1, ay or -1, tostring(A.facing(game)))) + bot:tap("a") + bot:wait(8) + bot:clearDialogue(nil, 12000) + print(("[lake] after gyarados: gyarados=%s lanceObj=%s") + :format(flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS"), + flag(game, "EVENT_LAKE_OF_RAGE_LANCE"))) + + if flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS") ~= "SET" then + print("[lake] FAIL: EVENT_LAKE_OF_RAGE_RED_GYARADOS still clear") + return + end + + print("[lake] talk Lance at 21,28...") + if not bot:approachAndFace(21, 28) then + print("[lake] FAIL approach lance") + return + end + bot:tap("a") + bot:wait(8) + bot:clearDialogue({ "yes" }, 8000) + print(("[lake] after lance: decided=%s") + :format(flag(game, "EVENT_DECIDED_TO_HELP_LANCE"))) + + if flag(game, "EVENT_DECIDED_TO_HELP_LANCE") ~= "SET" then + print("[lake] FAIL: EVENT_DECIDED_TO_HELP_LANCE still clear") + return + end + + print("[lake] travelTo MAHOGANY_MART_1F...") + if not bot:travelTo("MAHOGANY_MART_1F") then + print("[lake] FAIL travel mart") + return + end + -- Scene script runs on entry. + bot:clearDialogue(nil, 8000) + print(("[lake] after mart: stairs=%s map=%s") + :format(flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART"), + tostring(A.mapId(game)))) + + if flag(game, "EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART") == "SET" + and flag(game, "EVENT_DECIDED_TO_HELP_LANCE") == "SET" + and flag(game, "EVENT_LAKE_OF_RAGE_RED_GYARADOS") == "SET" then + print("[lake] OK: Cluster C chain complete") + else + print("[lake] FAIL: chain incomplete") + end +end diff --git a/tests/drivers/gold_link_fingerprint.lua b/tests/drivers/gold_link_fingerprint.lua new file mode 100644 index 00000000..3cefae23 --- /dev/null +++ b/tests/drivers/gold_link_fingerprint.lua @@ -0,0 +1,261 @@ +-- The Gen 2 link surface, exercised against a REAL Gold boot. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_link_fingerprint.lua love . +-- +-- Gold cannot link (docs/gen2-link-design.md is the honest account of what that +-- would take), so this is not a link smoke test. It is the check that the +-- pieces which ARE built work against the extracted Gold dataset rather than +-- against a fixture: +-- +-- 1. the dataset identifies itself as generation 2 with no help from +-- GameVersion, and Handshake.hello says so on the wire +-- 2. the Gen 2 fingerprint is stable, moves when a surface field moves, and +-- does NOT move when a non-surface field moves (the #511 lesson, checked +-- on Gold's own tables this time) +-- 3. a Gold peer and a Red peer refuse each other by generation instead of +-- pairing and desyncing +-- 4. every mon in a real Gold party survives packMon2 -> unpackMon2 with its +-- stats, experience, held item, happiness and derived shininess intact +-- +-- A fixture test cannot say any of that, because the whole question is whether +-- the extracted tables and the engine agree. +local U = require("tests.drivers.util") + +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") +local Mon = require("src.battle.gen2.Mon") +local Protocol = require("src.link.Protocol") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-link" + local failures = 0 + + local function check(ok, label) + if ok then + U.log("ok ", label) + else + failures = failures + 1 + U.log("FAIL", label) + end + return ok + end + + U.wait(45) + local data = game.data + assert(data and data.pokemon and next(data.pokemon), "gold data did not load") + + -- ---- 1. generation, off the data alone + + check(Fingerprint.generationOf(data) == 2, + "the Gold dataset reports itself as generation 2") + local hello = Handshake.hello(game, "trade") + check(hello.generation == 2, "the hello carries generation 2") + check(type(hello.fingerprint) == "string" and #hello.fingerprint == 16, + "the hello carries a 16-hex-digit Gen 2 fingerprint") + U.log(("fingerprint %s protocol %d engine %s"):format( + tostring(hello.fingerprint), hello.protocol, tostring(hello.engineVersion))) + + -- ---- 2. stability and coverage + + -- The baseline is recomputed with an EMPTY mod list, not taken from the + -- hello. Handshake.hello folds Handshake.mods(game) into the digest, and + -- every comparison below computes with {}, so on an install with one enabled + -- link-affecting mod the two differ by modKey alone -- which reads as "the + -- digest is not stable" and "catchRate moved the digest" for a reason having + -- nothing to do with the surface. The hello's own digest is asserted above; + -- from here on the baseline and the comparisons share a mod list. + Fingerprint.forget(data) + local base = Fingerprint.compute(data, {}) + Fingerprint.forget(data) + check(Fingerprint.compute(data, {}) == base, + "the digest is stable across a forget/recompute") + + -- The Gen 1 surface over the SAME tables must not collide with the Gen 2 + -- one: checkCompat refuses a cross-generation pairing by the hello, and the + -- "[gen2]" tag is what makes the digest agree with that refusal. + Fingerprint.forget(data) + local asGen1 = Fingerprint.compute(data, {}, 1) + Fingerprint.forget(data) + check(asGen1 ~= base, "the Gen 1 and Gen 2 surfaces digest differently") + + local function digestAfter(mutate, restore) + mutate() + Fingerprint.forget(data) + local value = Fingerprint.compute(data, {}) + restore() + Fingerprint.forget(data) + return value + end + + -- surface: a base stat, a move's power, a move's effect chance, a held + -- item's parameter, a growth curve coefficient. Each one changes a battle + -- turn or a trade rebuild, so each one must move the digest. + local species = data.pokemon.TOTODILE or data.pokemon.CYNDAQUIL + local before = species.baseStats.attack + check(digestAfter(function() species.baseStats.attack = before + 1 end, + function() species.baseStats.attack = before end) ~= base, + "a Gen 2 base stat moves the digest") + + local move = data.moves.TACKLE + local movePower = move.power + check(digestAfter(function() move.power = movePower + 1 end, + function() move.power = movePower end) ~= base, + "a move's power moves the digest") + + local chanceMove, chanceBefore + for _, id in ipairs({ "BODY_SLAM", "THUNDERBOLT", "ICE_BEAM" }) do + if data.moves[id] and data.moves[id].effectChance then + chanceMove, chanceBefore = data.moves[id], data.moves[id].effectChance + break + end + end + if chanceMove then + check(digestAfter(function() chanceMove.effectChance = chanceBefore + 1 end, + function() chanceMove.effectChance = chanceBefore end) ~= base, + "a move's effectChance moves the digest (Gen 2 only)") + else + U.log("skip no move with an effectChance in this dataset") + end + + local held = data.gen2HeldItems and data.gen2HeldItems.LEFTOVERS + if held then + local heldBefore = held.heldParameter + check(digestAfter(function() held.heldParameter = (heldBefore or 0) + 1 end, + function() held.heldParameter = heldBefore end) ~= base, + "a held item's parameter moves the digest") + else + U.log("skip no LEFTOVERS held-item row in this dataset") + end + + local curves = data.pokemon.growthRates + local curve = curves and (curves.GROWTH_MEDIUM_SLOW or select(2, next(curves))) + if curve then + local linearBefore = curve.linear + check(digestAfter(function() curve.linear = (linearBefore or 0) + 1 end, + function() curve.linear = linearBefore end) ~= base, + "a growth-curve coefficient moves the digest") + else + U.log("skip no growth-rate coefficient rows in this dataset") + end + + -- NOT surface: catchRate (#511) and the constants index space. Either one + -- moving the digest would split two peers over something neither of their + -- simulations reads. + local catchBefore = species.catchRate + check(digestAfter(function() species.catchRate = (catchBefore or 0) + 1 end, + function() species.catchRate = catchBefore end) == base, + "catchRate does NOT move the digest") + + if data.gen2Constants and data.gen2Constants.mapOrder then + local order = data.gen2Constants.mapOrder + local first = order[1] + check(digestAfter(function() order[1] = "NOT_A_MAP" end, + function() order[1] = first end) == base, + "the constants index space does NOT move the digest") + end + + -- the per-record digests a subset trade negotiates on + local speciesRecords = Fingerprint.records(data, "pokemon") + local heldRecords = Fingerprint.records(data, "held_items") + check(speciesRecords.TOTODILE ~= nil and speciesRecords.growthRates == nil, + "per-species digests cover the species and not the growthRates sibling") + check(next(heldRecords) ~= nil, "per-held-item digests exist on Gold") + + -- ---- 3. a Gold peer refuses a Red peer + + local redHello = { type = "hello", protocol = hello.protocol, + name = "RED", generation = 1, + engineVersion = hello.engineVersion, + fingerprint = "0000000000000000", mods = {} } + local verdict, reason = Handshake.checkCompat(hello, redHello) + check(verdict == "refused" and reason == "generation_mismatch", + "a Gold hello refuses a Gen 1 peer by generation") + local oldHello = { type = "hello", name = "OLD" } -- pre-handshake build + check(Handshake.checkCompat(hello, oldHello) == "refused", + "a Gold hello refuses a pre-handshake build") + local lines = Handshake.describe(hello, redHello, "refused", "trade") + check(#lines > 0 and table.concat(lines, " "):find("generation"), + "the incompatibility screen names the generation") + for _, line in ipairs(lines) do U.log(" screen |" .. line) end + + -- ---- 4. a real Gold party through the Gen 2 codec + + local party = {} + for _, spec in ipairs({ { "TOTODILE", 12 }, { "PIDGEY", 7 }, + { "GEODUDE", 15 } }) do + local mon = Mon.new(data, spec[1], spec[2]) + if mon then party[#party + 1] = mon end + end + check(#party == 3, "built a three-mon Gold party from the extracted tables") + -- a held item and a status, so the two fields the Gen 1 codec cannot carry + -- are actually under test + party[1].item = (data.items and data.items.LEFTOVERS) and "LEFTOVERS" or nil + party[1].status = "burn" + party[1].happiness = 137 + party[1].pokerus = 0 + party[1].ot, party[1].otId = "KRIS", 41234 + party[2].hp = math.max(1, math.floor(party[2].maxHp / 2)) + + for i, mon in ipairs(party) do + local packed = Protocol.packMon2(mon) + local rebuilt, why = Protocol.unpackMon2(data, packed, { strict = true }) + if not check(rebuilt ~= nil, ("slot %d rebuilds (%s)"):format( + i, tostring(why))) then break end + check(rebuilt.species == mon.species and rebuilt.level == mon.level, + ("slot %d keeps species and level"):format(i)) + check(rebuilt.experience == mon.experience, + ("slot %d keeps experience (%s vs %s)"):format( + i, tostring(rebuilt.experience), tostring(mon.experience))) + check(rebuilt.hp == mon.hp and rebuilt.maxHp == mon.maxHp, + ("slot %d keeps HP %s/%s"):format(i, tostring(rebuilt.hp), + tostring(rebuilt.maxHp))) + local same = true + for _, k in ipairs({ "hp", "attack", "defense", "speed", + "specialAttack", "specialDefense" }) do + if rebuilt.stats[k] ~= mon.stats[k] then same = false end + end + check(same, ("slot %d recomputes all six stats identically"):format(i)) + check(rebuilt.shiny == mon.shiny and rebuilt.gender == mon.gender, + ("slot %d re-derives shininess and gender from the DVs"):format(i)) + check(#rebuilt.moves == #mon.moves, ("slot %d keeps its moveset"):format(i)) + check(rebuilt.item == mon.item, ("slot %d keeps its held item (%s)"):format( + i, tostring(rebuilt.item))) + check(rebuilt.status == mon.status, ("slot %d keeps its status"):format(i)) + check(rebuilt.happiness == mon.happiness, + ("slot %d keeps its happiness"):format(i)) + check(rebuilt.otId == mon.otId and rebuilt.ot == mon.ot, + ("slot %d keeps its original trainer"):format(i)) + end + + -- the HP DV is derived, never sent: a packet that claims one is ignored + local tampered = Protocol.packMon2(party[1]) + tampered.dvs.hp = 15 + local rebuilt = Protocol.unpackMon2(data, tampered, { strict = true }) + check(rebuilt and rebuilt.dvs.hp == Mon.hpDV(party[1].dvs), + "a claimed HP DV is ignored and re-derived from the other four") + + -- an item the peer's game does not have is refused rather than carried + local noSuchItem = Protocol.packMon2(party[1]) + noSuchItem.item = "MOON_FLUTE" + local _, itemWhy = Protocol.unpackMon2(data, noSuchItem, { strict = true }) + check(itemWhy == "unknown item", + "an unknown held item is refused in strict mode") + + -- and the subset filter says so BEFORE the mon is ever sent + local mine = { pokemon = Fingerprint.records(data, "pokemon"), + moves = Fingerprint.records(data, "moves"), + heldItems = Fingerprint.records(data, "held_items") } + local theirs = { pokemon = mine.pokemon, moves = mine.moves, heldItems = {} } + local eligible, reasons = Protocol.eligibleParty(party, mine, theirs) + check(eligible[1] == false and reasons[1] == "unknown item", + "the subset filter greys a mon whose held item the peer lacks") + check(eligible[2] == true, "a mon holding nothing stays tradeable") + + -- ---- proof the game was actually up while all of that ran + + U.shot(game, out .. "/01-gold-link-fingerprint.png") + U.log(("%d failures"):format(failures)) + assert(failures == 0, ("gold link fingerprint driver: %d failures"):format( + failures)) + U.log("PASS") +end diff --git a/tests/drivers/gold_mahogany_lance.lua b/tests/drivers/gold_mahogany_lance.lua new file mode 100644 index 00000000..96d4f1e6 --- /dev/null +++ b/tests/drivers/gold_mahogany_lance.lua @@ -0,0 +1,106 @@ +-- MahoganyMart1FLanceUncoversStaircaseScript (maps/MahoganyMart1F.asm:63), the +-- scene where Lance's DRAGONITE hyper-beams the Rocket behind the counter. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_mahogany_lance.lua love . +-- +-- MAHOGANYMART1F_LANCE and MAHOGANYMART1F_DRAGONITE share ONE +-- MAPOBJECT_EVENT_FLAG (maps/MahoganyMart1F.asm:158-159), and the script +-- `disappear`s the DRAGONITE less than half way through and Lance only at the +-- very end -- so a port that derives who is standing from the event flag pulls +-- Lance off the map the moment his Dragonite goes, and the rest of his walk and +-- all three of his text boxes then come out of nobody. +-- +-- The run prints the standing census every beat and shoots the two moments a +-- human has to look at: Lance mid-speech (he must be ON SCREEN) and the room +-- after he takes the stairs (he must be GONE, and an A press where he stood +-- must do nothing). +local U = require("tests.drivers.util") + +local LANCE, DRAGONITE = 3, 4 -- def.objects indices; object consts are +1 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-mahogany" + + 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 world + local function standing(index) + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.index == index then return npc end + end + return nil + end + + U.wait(45) + world = game.world + assert(world and world.map, "gold world did not boot") + + -- LakeOfRage.asm:61 `clearevent EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE` is + -- what puts the pair in the shop; both objects carry that ONE flag + -- (maps/MahoganyMart1F.asm:236-237). Read it off the object rather than + -- naming a number, so a re-extracted cache cannot make this stale. + world:setMap("MAHOGANY_MART_1F", 3, 6, "up") + U.wait(5) + world.events:set(world.map.def.objects[LANCE].eventFlag, false) + -- SCENE_MAHOGANYMART1F_LANCE_UNCOVERS_STAIRS is scene 1; the scene script is + -- `sdefer`, and World:step only arms it on a map ENTRY, so the id has to be + -- in place before the load that runs it. + world.mapScenes["MAHOGANY_MART_1F"] = 1 + world:setMap("MAHOGANY_MART_1F", 3, 6, "up") + U.wait(20) + assert(world.map.id == "MAHOGANY_MART_1F", tostring(world.map.id)) + assert(standing(LANCE), "Lance is not on the map before the scene") + assert(standing(DRAGONITE), "the Dragonite is not on the map before the scene") + + U.shot(game, out .. "/00-before.png") + + local lanceGoneAt, dragoniteGoneAt, shotMidway = nil, nil, false + for step = 1, 900 do + if not world:busy() and step > 30 then break end + local lance, drag = standing(LANCE), standing(DRAGONITE) + if not drag and not dragoniteGoneAt then dragoniteGoneAt = step end + if not lance and not lanceGoneAt then lanceGoneAt = step end + -- The Dragonite is gone and Lance is still talking: this is the frame the + -- shared flag would have culled him on. + if dragoniteGoneAt and not shotMidway and step == dragoniteGoneAt + 30 then + shotMidway = true + U.shot(game, out .. "/01-after-hyper-beam.png") + print(("[driver] after the Dragonite went: Lance standing = %s") + :format(tostring(standing(LANCE) ~= nil))) + end + tap("a", 2) + end + + print(("[driver] Dragonite left the map at beat %s, Lance at beat %s") + :format(tostring(dragoniteGoneAt), tostring(lanceGoneAt))) + U.wait(20) + U.shot(game, out .. "/02-after.png") + + assert(dragoniteGoneAt, "the Dragonite never disappeared") + assert(lanceGoneAt, "Lance never disappeared") + assert(lanceGoneAt > dragoniteGoneAt + 20, + ("Lance left the map %d beats after his Dragonite; the script keeps him " + .. "standing for the whole walk, the radio speech, the stairs and the " + .. "split-up line"):format(lanceGoneAt - dragoniteGoneAt)) + + -- The other half: a masked object must not answer an A press. Stand where + -- Lance ended up and face him. + local def = world.map.def.objects[LANCE] + world.player.cellX, world.player.cellY = def.x, def.y + 1 + world.player.px = world.player.cellX * 16 + world.player.py = world.player.cellY * 16 + world.player.facing = "up" + U.wait(4) + local answered = world:interact() + print("[driver] A press on the masked Lance answered: " .. tostring(answered)) + assert(not answered, "a masked object answered a talk") + + print("[driver] PASS gold mahogany Lance scene in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_map_callbacks.lua b/tests/drivers/gold_map_callbacks.lua new file mode 100644 index 00000000..1cbd53e1 --- /dev/null +++ b/tests/drivers/gold_map_callbacks.lua @@ -0,0 +1,199 @@ +-- Assertion driver: the four map callback types, run by a real map load in the +-- running game. It PASSES or it errors; there is nothing to eyeball. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_map_callbacks.lua love . +-- +-- tests/gen2_map_callbacks_test.lua checks the bodies and the order against +-- fixtures; what it cannot check is that a genuine World:setMap, with a genuine +-- cache under it, comes out the other side with the door shut and the right +-- day's NPC standing there. So each check here sets the state the cart's own +-- callback branches on, loads the map for real, and reads the world back. +-- +-- Every number is read out of the extracted callback body rather than typed in, +-- so a re-import that renumbers an event or an object fails on the LOOKUP with +-- a name attached instead of quietly asserting the wrong thing. +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local function body(mapId, kind) + local def = world.maps[mapId] + assert(def, "no such map: " .. mapId) + for _, cb in ipairs(def.callbacks or {}) do + if cb.callback == kind then + local list = world.scripts[cb.scriptKey] + assert(list, ("%s %s names a body the cache does not carry (%s)") + :format(mapId, kind, tostring(cb.scriptKey))) + return list + end + end + error(("%s declares no %s -- re-import, or the extractor lost it") + :format(mapId, kind)) + end + + -- The first command of a kind in a body, so the driver can name a flag by the + -- command that reads it rather than by a constant it would have to keep in + -- sync with constants/event_flags.asm. + local function firstOp(list, op) + for _, cmd in ipairs(list) do + if cmd.op == op then return cmd end + end + return nil + end + + local function load(mapId, x, y) + assert(world:setMap(mapId, x, y, "down"), "setMap failed for " .. mapId) + U.wait(2) + end + + -- ---- MAPCALLBACK_NEWMAP ------------------------------------------------ + -- GoldenrodUndergroundResetSwitchesCallback: fifteen clearevents and a + -- `writemem wUndergroundSwitchPositions`. The puzzle is only solvable + -- because walking in resets it, so a callback that does not run leaves the + -- doors wherever the last visit left them. + do + local list = body("GOLDENROD_UNDERGROUND", "MAPCALLBACK_NEWMAP") + local cleared = {} + for _, cmd in ipairs(list) do + if cmd.op == "clearevent" then cleared[#cleared + 1] = cmd.event end + end + assert(#cleared >= 15, + ("expected the fifteen switch/door events, found %d"):format(#cleared)) + for _, id in ipairs(cleared) do world.events:set(id, true) end + load("GOLDENROD_UNDERGROUND", 3, 3) + for _, id in ipairs(cleared) do + assert(not world.events:get(id), + ("MAPCALLBACK_NEWMAP left event %d set: the switches did not reset") + :format(id)) + end + U.log(("NEWMAP: the map load cleared all %d underground switch events") + :format(#cleared)) + end + + -- ---- MAPCALLBACK_TILES ------------------------------------------------- + -- BrunosRoomDoorsCallback: `changeblock 4, 14, $2a` walls the entrance in + -- once EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED is set. Script_changeblock's two + -- bytes are CELL coordinates (`add 4` then GetBlockLocation's `srl`), so the + -- block it rewrites is (x / 2, y / 2). + do + local list = body("BRUNOS_ROOM", "MAPCALLBACK_TILES") + local seal = firstOp(list, "changeblock") + local gate = firstOp(list, "checkevent") + assert(seal and gate, "Bruno's TILES callback lost its changeblock") + local args = seal.args or {} + local bx = math.floor((seal.x or args[1]) / 2) + local by = math.floor((seal.y or args[2]) / 2) + local wall = seal.block or args[3] + local def = world.maps.BRUNOS_ROOM + local index = by * def.width + bx + 1 + + world.events:set(gate.event, false) + load("BRUNOS_ROOM", 4, 12) + local open = def.blocks[index] + assert(open ~= wall, + "the entrance is already walled in with the event clear") + + world.events:set(gate.event, true) + load("BRUNOS_ROOM", 4, 12) + assert(def.blocks[index] == wall, + ("MAPCALLBACK_TILES did not seal Bruno's door: block %d is %s, want %s") + :format(index, tostring(def.blocks[index]), tostring(wall))) + + -- And it comes back. restoreBlocks is LoadMapAttributes' refill; without + -- the bake being dropped with it, the wall would have been painted into the + -- cached canvas for the rest of the session. + world.events:set(gate.event, false) + load("BRUNOS_ROOM", 4, 12) + assert(def.blocks[index] == open, + "the wall did not come back out of the buffer when the event cleared") + U.log(("TILES: Bruno's entrance block %d flips %s <-> %s with the event") + :format(index, tostring(open), tostring(wall))) + end + + -- ---- MAPCALLBACK_OBJECTS ----------------------------------------------- + -- Route29TuscanyCallback: ZEPHYRBADGE, then `readvar VAR_WEEKDAY` and + -- `ifnotequal TUESDAY`. One of the seven travelling siblings, and the + -- clearest thing on the list that a player can walk up to and talk to. + do + local list = body("ROUTE_29", "MAPCALLBACK_OBJECTS") + local badge = firstOp(list, "checkflag") + assert(badge, "Route 29's OBJECTS callback lost its badge check") + -- The `appear` sits in .DoesTuscanyAppear, behind the `iftrue`; what is in + -- the body itself is the .TuscanyDisappears fallthrough, and it names the + -- same object. + local hide = firstOp(list, "disappear") + assert(hide, "and its disappear") + local objectId = hide.object or (hide.args and hide.args[1]) + assert(objectId, "the disappear names no object") + local index = objectId - 1 + + local function tuscanyOut() + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.index == index then return true end + end + return false + end + + world:setEngineFlag(badge.flag or (badge.args and badge.args[1]), true) + world.clockDay = 2 -- TUESDAY + load("ROUTE_29", 20, 8) + assert(tuscanyOut(), + "MAPCALLBACK_OBJECTS did not put Tuscany on Route 29 on a Tuesday") + + world.clockDay = 3 -- WEDNESDAY + load("ROUTE_29", 20, 8) + assert(not tuscanyOut(), "and she is still there on a Wednesday") + + -- The badge is the outer gate: no badge, no sibling on any day. + world:setEngineFlag(badge.flag or (badge.args and badge.args[1]), false) + world.clockDay = 2 + load("ROUTE_29", 20, 8) + assert(not tuscanyOut(), + "she appears without ZEPHYRBADGE, so the callback's first branch is dead") + world.clockDay = nil + U.log("OBJECTS: Tuscany is on Route 29 on Tuesdays, with the badge, only") + end + + -- ---- MAPCALLBACK_CMDQUEUE ---------------------------------------------- + -- Already driven end to end by gold_icepath_boulder; what belongs here is + -- that the map load still fills the queue from the EXTRACTED callback now + -- that the other four types run alongside it. + do + local CmdQueue = require("src.world.gen2.CmdQueue") + load("ICE_PATH_B1F", 9, 2) + assert(CmdQueue.count(world.cmdQueue) == 1, + ("the Ice Path load left %d queue entries, want 1") + :format(CmdQueue.count(world.cmdQueue))) + assert(world:extractedCmdQueue(), + "and the entry did not come from the extracted callback") + load("NEW_BARK_TOWN", 13, 6) + assert(CmdQueue.count(world.cmdQueue) == 0, + "ClearCmdQueue: the queue must not survive a map load") + U.log("CMDQUEUE: the Ice Path stone table rides the map load and no other") + end + + -- ---- the invariant ----------------------------------------------------- + -- Nothing reachable from a callback may block: ScriptEvents runs inside the + -- map load with no frame to come back on. Vm:runCallback records any that + -- tries, and after eleven real map loads the ledger has to be empty. + do + local blocked = {} + for key in pairs(world.vm.blockedCallbacks or {}) do + blocked[#blocked + 1] = key + end + assert(#blocked == 0, + "map callbacks blocked: " .. table.concat(blocked, ", ")) + local unknown = {} + for op in pairs(world.vm.unknownOps or {}) do unknown[#unknown + 1] = op end + assert(#unknown == 0, + "map callbacks reached unimplemented opcodes: " + .. table.concat(unknown, ", ")) + end + + U.log("PASS gold_map_callbacks") + love.event.quit() +end diff --git a/tests/drivers/gold_menu_shots.lua b/tests/drivers/gold_menu_shots.lua new file mode 100644 index 00000000..fb3029f0 --- /dev/null +++ b/tests/drivers/gold_menu_shots.lua @@ -0,0 +1,269 @@ +-- Screenshots of every Gen 2 menu, for eyes that a test cannot replace. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_menu_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-menus (default) +-- +-- The driver boots straight into the world (Game2 skips the cinema under +-- POKEPORT_DRIVER), gives the save enough content that the screens have +-- something to draw, then pushes each one and captures it. +local U = require("tests.drivers.util") + +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local BoxMenu = require("src.ui.gen2.BoxMenu") +local PackMenu = require("src.ui.gen2.PackMenu") +local PcMenu = require("src.ui.gen2.PcMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local PokedexMenu = require("src.ui.gen2.PokedexMenu") +local Pokegear = require("src.ui.gen2.Pokegear") +local SaveMenu = require("src.ui.gen2.SaveMenu") +local StartMenu = require("src.ui.gen2.StartMenu") +local TrainerCard = require("src.ui.gen2.TrainerCard") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-menus" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + -- Capture a state on its own, then take it back off the stack. + local function show(name, state) + game.stack:push(state) + shot(name) + game.stack:pop() + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + -- Give the save something to show: a party, a bag across pockets, badges, + -- dex progress, a phone number and an unlocked Pokegear. + local save = game.save + local pokemon = game.data.pokemon or {} + local function mon(species, level, hp) + local def = pokemon[species] + local maxHp = 20 + level + return { + species = species, name = def and def.name or species, + nickname = def and def.name or species, + level = level, hp = hp or maxHp, maxHp = maxHp, + } + end + save.party = { + mon("CYNDAQUIL", 12), + mon("TOTODILE", 10, 9), + mon("PIDGEY", 8, 2), + } + -- Bag ids are the CONSTANT names ItemAttributes is keyed by, not the printed + -- name: a TM's id is what it teaches (TM_DYNAMICPUNCH prints as "TM01"), so + -- seeding "TM01" here would leave the TM pocket empty and drop three unknown + -- rows into the ITEMS pocket instead. TOWN_MAP is not a bag item in Gold + -- either (the map is a Pokegear card, and item 6 is one of the unused + -- TERU-SAMA slots). + save.inventory = { + POTION = 5, SUPER_POTION = 2, ANTIDOTE = 1, FULL_HEAL = 1, + REVIVE = 1, ETHER = 2, X_ATTACK = 1, REPEL = 3, + POKE_BALL = 10, GREAT_BALL = 3, ULTRA_BALL = 1, + BICYCLE = 1, ITEMFINDER = 1, OLD_ROD = 1, COIN_CASE = 1, + SQUIRTBOTTLE = 1, + TM_DYNAMICPUNCH = 1, TM_HEADBUTT = 1, TM_ROCK_SMASH = 1, + HM_CUT = 1, HM_SURF = 1, + } + save.player.badges = { true, true } + save.player.money = 3210 + save.player.id = 12345 + save.player.name = "GOLD" + save.playTime = { hours = 4, minutes = 37, seconds = 0, frames = 0 } + -- The unlocks the way the game writes them: ENGINE_RADIO_CARD 0, + -- ENGINE_MAP_CARD 1, ENGINE_PHONE_CARD 2, ENGINE_POKEGEAR 4 and + -- ENGINE_POKEDEX 11 through the same store `setflag` lands in. + save.engineFlags = save.engineFlags or {} + for _, flag in ipairs({ 0, 1, 2, 4, 11 }) do + save.engineFlags[flag] = true + end + save.phoneContacts = { ELM = true, MOM = true } + for _, species in ipairs({ "CYNDAQUIL", "TOTODILE", "CHIKORITA", "PIDGEY", + "RATTATA", "SENTRET", "HOOTHOOT" }) do + save.pokedex.seen[species] = true + end + for _, species in ipairs({ "CYNDAQUIL", "TOTODILE", "PIDGEY" }) do + save.pokedex.caught[species] = true + end + + -- The overworld itself, for reference. + shot("00-overworld") + + -- Boot screens. + show("01-mainmenu-newgame", MainMenu.new(game, { + hasSave = false, save = false, + clock = { hour = 10, minute = 5, weekday = 3 }, + })) + show("02-mainmenu-continue", MainMenu.new(game, { + hasSave = true, save = save, + clock = { hour = 20, minute = 42, weekday = 6 }, + })) + + local sprites = game.data.gen2Sprites + local chris = sprites and sprites.SPRITE_CHRIS + local Palettes = require("src.world.gen2.Palettes") + local naming = NamingScreen.new(game, { + type = "player", + menuGfx = game.data.gen2MenuGfx, + iconPath = chris and chris.image or nil, + iconColors = game.data.gen2Palettes + and Palettes.spritePalette(game.data.gen2Palettes, "DAY", chris) or nil, + }) + naming.text = "GOL" + show("03-naming-upper", naming) + naming.lower = true + naming.row = 4 + naming.col = 3 + show("04-naming-lower-del", naming) + + -- The movie is a state machine, so a still is "run it to frame N": one from + -- each act, picked where its cast is on screen. + local intro = GoldSilverIntro.new(game, {}) + local function seek(target) + while intro.frames < target and not intro.done do intro:step() end + return intro + end + show("05-intro-water", seek(600)) + show("06-intro-grass", seek(1500)) + show("07-intro-fire", seek(2200)) + + -- In-game menus. The start menu is not opaque, so the overworld shows + -- through it -- which is exactly how it looks in play. + show("08-startmenu", StartMenu.new(game, { save = save })) + -- QUIT's confirmation, which is the port's own row rather than the cart's + -- EXIT: the yes/no defaults to NO so a stray A never throws away progress. + local quitting = StartMenu.new(game, { save = save }) + quitting.phase = "confirm" + quitting.confirmChoice = 2 + show("24-startmenu-quit", quitting) + show("09-party", PartyMenu.new(game, { prompt = "choose" })) + show("10-pack-items", PackMenu.new(game, { pocket = "ITEM" })) + show("11-pack-tms", PackMenu.new(game, { pocket = "TM_HM" })) + show("12-pokegear-clock", Pokegear.new(game, { + clock = { hour = 14, minute = 8, weekday = 2 }, + currentLandmark = "LANDMARK_NEW_BARK_TOWN", + })) + + local gear = Pokegear.new(game, { + currentLandmark = "LANDMARK_NEW_BARK_TOWN", + }) + gear.cardIndex = 2 + gear.mode = "card" + show("13-pokegear-map", gear) + -- The radio card, tuned and playing, and the phone mid-call. + local radio = Pokegear.new(game, {}) + radio.mode = "card" + for i, card in ipairs(radio.cards) do + if card.id == "radio" then radio.cardIndex = i end + end + radio.station = 1 + radio.radioLine = 2 + show("13b-pokegear-radio", radio) + local phone = Pokegear.new(game, {}) + phone.mode = "card" + for i, card in ipairs(phone.cards) do + if card.id == "phone" then phone.cardIndex = i end + end + phone:callContact((phone:phoneList() or {})[1]) + show("13c-pokegear-phone", phone) + + + local card = TrainerCard.new(game, {}) + show("14-trainercard", card) + card.page = 2 + show("15-trainercard-badges", card) + + show("16-pokedex", PokedexMenu.new(game, {})) + local dex = PokedexMenu.new(game, {}) + dex.view = "entry" + for i, row in ipairs(dex.rows) do + if row.caught then dex.index = i break end + end + show("17-pokedex-entry", dex) + -- The two screens SELECT and START open (Pokedex_InitOptionScreen / + -- Pokedex_InitSearchScreen). + local dexOption = PokedexMenu.new(game, {}) + dexOption.view = "option" + show("17b-pokedex-option", dexOption) + local dexSearch = PokedexMenu.new(game, {}) + dexSearch.view = "search" + dexSearch.searchType = { 10, 0 } -- FIRE / ----- + show("17c-pokedex-search", dexSearch) + + show("18-options", OptionsMenu.new(game, { options = game.options })) + -- ...and scrolled to the port's own display rows, which is what the ▼ on + -- the first page points at. + local scrolled = OptionsMenu.new(game, { options = game.options }) + scrolled.index = #OptionsMenu.ROWS + scrolled:ensureVisible() + show("25-options-display", scrolled) + -- writer is stubbed so the shot never touches a real save file. + show("19-save", SaveMenu.new(game, { + save = save, existed = false, + writer = function() return true end, + })) + + -- The storage system: the PC's top menu, the box picker, and the withdraw + -- and deposit lists. Stock a box first so the list has rows and the left + -- panel has a pic to draw. + local Boxes = require("src.core.gen2.Boxes") + local stored = Boxes.box(save, 1) + for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA", "SENTRET" }) do + stored[i] = mon(species, 10 + i) + end + Boxes.rename(save, 2, "GRASS") + + local pc = PcMenu.new(game, { save = save }) + show("20-pc-menu", pc) + pc.picking = true + pc.pickIndex = 2 + show("21-pc-changebox", pc) + + show("22-pc-withdraw", BoxMenu.new(game, { + save = save, mode = "withdraw", + })) + show("23-pc-deposit", BoxMenu.new(game, { + save = save, mode = "deposit", + })) + + -- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and + -- SetDayOfWeek), each at its picker rather than at its opening page. + local clock = InitClock.new(game, { save = save }) + clock.phase = "hour" + show("26-initclock-hour", clock) + clock.phase = "minute" + clock.minute = 25 + show("27-initclock-minutes", clock) + clock.phase = "confirm-hour" + show("28-initclock-confirm", clock) + local wheel = InitClock.new(game, { mode = "day", save = save }) + wheel.day = 2 + show("29-dayofweek", wheel) + + -- FLY's own picker (_FlyMap): the town map with the cursor on a visited + -- flypoint, not the yes/no chain the port used to fall back to. + local FieldMoves = require("src.world.gen2.FieldMoves") + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + local points = FieldMoves.flyPoints(save, game.data.gen2Landmarks, "johto") + show("30-flymap", Pokegear.new(game, { + save = save, + currentLandmark = "LANDMARK_NEW_BARK_TOWN", + fly = points, + onFly = function() end, + onClose = function() end, + })) + + print("[driver] PASS gold menu shots in " .. out) +end diff --git a/tests/drivers/gold_mom_scene.lua b/tests/drivers/gold_mom_scene.lua new file mode 100644 index 00000000..84b51497 --- /dev/null +++ b/tests/drivers/gold_mom_scene.lua @@ -0,0 +1,80 @@ +-- The MeetMomScript cutscene, shot at the moments that used to go wrong. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_mom_scene.lua love . +-- +-- Three things this is watching for, all of them general rather than +-- Mom-specific: +-- * an object whose event flag a RUNNING script flips must not swap on the +-- spot -- the cart only re-reads the object list on a map load, so Mom +-- stays standing beside you until she has walked back to her chair +-- * an object that appears mid-map must have its palette baked immediately, +-- not on the next once-a-second poll, or it stands there in greyscale +-- * a `yesorno` keeps the question on screen underneath the prompt +-- +-- Shots land in /tmp/gold-mom. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-mom" + + 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(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + U.shot(game, out .. "/00-bedroom.png") + + -- Drop straight into the living room at the top of the stairs, which is + -- where MeetMomScript's coord event sits. The indoor route down from the + -- bedroom is a fragile way to reach a scene that is not about stairs. + world:setMap("PLAYERS_HOUSE_1F", 7, 3, "down") + U.wait(20) + for _ = 1, 3 do tap("down", 8) end + U.wait(40) + U.shot(game, out .. "/01-scene-start.png") + + -- Page through until the first yes/no is up, shooting as we go. + local shots, sawChoice = 1, false + for step = 1, 200 do + local top = game.stack:top() + local isChoice = top and top.index ~= nil and top.onChoose ~= nil + -- A TextBox that has pushed its own choice box counts too. + if game.world.choicebox and not sawChoice then + sawChoice = true + U.shot(game, out .. "/02-yes-no.png") + end + if isChoice and not sawChoice then + sawChoice = true + U.shot(game, out .. "/02-yes-no.png") + end + if step % 25 == 0 then + shots = shots + 1 + U.shot(game, ("%s/03-scene-%02d.png"):format(out, shots)) + end + if not world:busy() and step > 20 then break end + tap("a", 4) + end + U.wait(30) + U.shot(game, out .. "/04-scene-end.png") + + -- The two invariants, checked rather than eyeballed. + local greyed = {} + for _, npc in pairs(world.npcPool or {}) do + if npc.sprite and npc.spriteDef and not npc.sprite.objColors then + greyed[#greyed + 1] = npc.spriteDef.id or "?" + end + end + print(("[driver] %d pooled NPCs, %d without a baked palette") + :format((function() local n = 0 for _ in pairs(world.npcPool or {}) do n = n + 1 end return n end)(), + #greyed)) + print(("[driver] saw a yes/no prompt: %s"):format(tostring(sawChoice))) + print("[driver] PASS gold mom scene in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_move_pack_egg.lua b/tests/drivers/gold_move_pack_egg.lua new file mode 100644 index 00000000..bedd8335 --- /dev/null +++ b/tests/drivers/gold_move_pack_egg.lua @@ -0,0 +1,144 @@ +-- The three screens a test cannot see: MOVE POKéMON W/O MAIL, the PACK's item +-- submenu, and the EGG summary page. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_move_pack_egg.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-move-pack-egg (default) +-- +-- Each one is here because its bug was invisible to a green test: +-- +-- * MOVE POKéMON W/O MAIL (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm) +-- used to move the mon the instant it was chosen, to a box the player +-- never named, with the confirmation string computed and dropped. On +-- screen that is a PC that eats your Pokemon. The shots walk the cart's +-- four steps and print the census at each one, so the mon is accounted for +-- in the log as well as on the screen. +-- * The PACK (engine/items/pack.asm .ItemBallsKey_LoadSubmenu) had no item +-- submenu at all, so USE was the only verb and a TOSS was unreachable. +-- * The EGG page (EggStatsScreen) draws menu_gfx.eggHatch.egg, and a cache +-- imported before the extractor learned EggPic has no such file -- so the +-- pic block was blank. The shot proves the ICON_EGG fallback fills it. +local U = require("tests.drivers.util") + +local BoxMenu = require("src.ui.gen2.BoxMenu") +local Boxes = require("src.core.gen2.Boxes") +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-move-pack-egg" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.player.name = "GOLD" + save.player.id = 12345 + + local function build(species, level, fields) + local mon = Mon.new(game.data, species, level, + { dvs = { attack = 15, defense = 15, speed = 15, special = 15 } }) + assert(mon, "no base data for " .. species) + mon.nickname = mon.name + mon.otName = save.player.name + mon.otId = save.player.id + for key, value in pairs(fields or {}) do mon[key] = value end + return mon + end + + -- Every mon in the save, party and boxes together. If this number ever + -- changes across a move, the PC ate one. + local function census() + local n = #(save.party or {}) + for i = 1, Boxes.NUM_BOXES do n = n + Boxes.count(save, i) end + return n + end + + save.party = { + build("CYNDAQUIL", 14), + build("TOTODILE", 12), + build("PIDGEY", 9), + } + local box = Boxes.box(save, 1) + box[1] = build("SENTRET", 6) + box[2] = build("HOOTHOOT", 7) + box[3] = build("GEODUDE", 8) + save.currentBox = 1 + + local before = census() + U.log(("[driver] %d mons before the move"):format(before)) + + -- ---- 1. MOVE POKéMON W/O MAIL ----------------------------------------- + local move = BoxMenu.new(game, { save = save, mode = "move", + onClose = function() end }) + game.stack:push(move) + shot("00-move-choose") -- "Choose a <PK><MN>." + U.tap(game, "a") + shot("01-move-submenu") -- MOVE / STATS / CANCEL, "What's up?" + U.tap(game, "a") + shot("02-move-to-where") -- the insert cursor, "Move to where?" + assert(census() == before, "the mon left the save before it was placed") + U.tap(game, "right") + shot("03-move-destination-box2") -- BOX2 named in the header + U.tap(game, "a") + shot("04-move-saving") -- "Saving… Leave ON!" + assert(census() == before, "a moved mon went missing") + assert(Boxes.count(save, 2) == 1, "nothing landed in BOX2") + U.tap(game, "a") + U.tap(game, "left") + U.tap(game, "left") + shot("05-move-party-list") -- box 0: the PARTY, which the old screen + -- could not reach at all + game.stack:pop() + U.log(("[driver] %d mons after the move (BOX2 holds %d)") + :format(census(), Boxes.count(save, 2))) + + -- ---- 2. the PACK's item submenu --------------------------------------- + save.inventory = { + POTION = 5, SUPER_POTION = 2, REPEL = 3, POKE_BALL = 10, + BICYCLE = 1, ITEMFINDER = 1, HM_CUT = 1, TM_HEADBUTT = 1, + } + local pack = PackMenu.new(game, { save = save, onClose = function() end }) + -- Rows sort by ItemNames index, so SUPER POTION (17) is above POTION (18); + -- park the cursor on the POTION by name rather than by position. + for i, row in ipairs(pack.rows) do + if row.id == "POTION" then pack.index = i end + end + game.stack:push(pack) + shot("06-pack-items") + U.tap(game, "a") + shot("07-pack-submenu") -- USE / GIVE / TOSS / QUIT + U.tap(game, "down") + U.tap(game, "down") + shot("08-pack-submenu-toss") + U.tap(game, "a") + shot("09-pack-toss-how-many") -- "Throw away how many?" + the counter + U.tap(game, "up") + U.tap(game, "up") + shot("10-pack-toss-count") + U.tap(game, "a") + shot("11-pack-toss-confirm") -- "Throw away 3 POTION(S)?" + YES/NO + U.tap(game, "a") + shot("12-pack-threw-away") -- "Threw away POTION(S)." + U.log(("[driver] POTIONs left: %s"):format(tostring(save.inventory.POTION))) + assert((save.inventory.POTION or 0) == 2, "the TOSS did not spend three") + game.stack:pop() + + -- ---- 3. the EGG summary page ------------------------------------------ + local egg = build("TOGEPI", 5, { isEgg = true, eggSteps = 20 }) + egg.nickname = "EGG" + local summary = SummaryMenu.new(game, { mon = egg, save = save }) + local gfx = (game.data.gen2MenuGfx or {}).eggHatch + U.log(("[driver] menu_gfx.eggHatch.egg = %s") + :format(tostring(gfx and gfx.egg))) + game.stack:push(summary) + shot("13-egg-summary") + game.stack:pop() + + print("[driver] PASS gold move/pack/egg shots in " .. out) +end diff --git a/tests/drivers/gold_naming_and_trade_item.lua b/tests/drivers/gold_naming_and_trade_item.lua new file mode 100644 index 00000000..8df56646 --- /dev/null +++ b/tests/drivers/gold_naming_and_trade_item.lua @@ -0,0 +1,169 @@ +-- Two things only a human can see, in one run. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_naming_and_trade_item.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Only the gold-dev identity has a complete Gold cache; another one is missing +-- data/generated/marts.lua, RomImporter never reports ready, and the driver +-- coroutine is simply never resumed (a silent hang with no output). +-- +-- 1. The naming keyboard. Typing the LAST character does not close the screen: +-- `.a` is `call NamingScreen_TryAddCharacter / ret nc`, and +-- AdvanceCursor_CheckEndOfString answers CARRY once the buffer is full, so +-- the handler falls through into `.start` and parks the cursor on END with +-- the keyboard still up (engine/menus/naming_screen.asm:401-410). Only +-- `.end` stores the entry. The blank cells are typeable too: the NameInput* +-- rows are written into the tilemap and GetLastCharacter reads the tile +-- under the cursor back out, so the trailing spaces of "S T U V W X Y Z " +-- are real characters (data/text/name_input_chars.asm). +-- +-- 2. Kyle's Onix (VioletKylesHouse, NPC_TRADE_KYLE) arrives holding +-- BITTER_BERRY. NPCTRADE_ITEM is an item id BYTE in the table +-- (data/events/npc_trades.asm:15) and DoNPCTrade copies it into +-- wPartyMon1Item; the port names it, so the summary's green page prints +-- BITTER BERRY and TAKE drops a real BITTER BERRY into the bag instead of +-- killing the game in Bag.isBadge. +-- +-- Screenshots land in POKEPORT_SHOT_DIR (default /tmp/gold-naming-trade): +-- keyboard-parked-on-end.png full buffer, cursor bracketing END +-- keyboard-on-blank-cell.png the cursor sitting on the blank after Z +-- keyboard-typed-a-space.png a name with a space in the middle of it +-- onix-summary-item.png the green page's ITEM field +-- onix-item-taken.png "TOOK BITTER BERRY from ROCKY." +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local NpcTrade = require("src.core.gen2.NpcTrade") +local Screens = require("src.ui.Screens") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-naming-trade" + local fails = 0 + local function ok(cond, msg) + if cond then print("[naming] ok " .. msg) + else fails = fails + 1 print("[naming] FAIL " .. msg) end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + ok(game.world and game.world.map, "gold world booted") + + -- ---------------------------------------------------------- the keyboard + local typed + Screens.push(game, "Gen2NamingScreen", { + type = "rival", + menuGfx = game.data.gen2MenuGfx, + -- The pop is the caller's, exactly as World:nameRival does it. + onDone = function(name) game.stack:pop() typed = name end, + }) + U.wait(10) + local keyboard = top() + if not ok(keyboard and keyboard.text == "", "the rival keyboard opened") then + error("gold naming: no keyboard, cannot continue") + end + + -- Seven A presses on the A key fill a 7-character rival name. + for _ = 1, keyboard.maxLength do tap("a") end + ok(top() == keyboard, "a full buffer leaves the keyboard up") + ok(typed == nil, "and hands nothing back yet") + ok(keyboard:cursorCharacter() == "END", "the cursor parked itself on END") + U.shot(game, out .. "/keyboard-parked-on-end.png") + + -- The blank cell after Z types a space. Back off two characters so the + -- buffer has room for a space AND a letter after it (a name with a gap in + -- the middle is the only way to see the space at all), then walk up out of + -- the bottom row to row 2, column 8. + tap("b") + tap("b") + tap("up") + tap("up") + tap("right") + tap("right") + ok(keyboard:cursorCharacter() == " ", "the cell after Z is a space") + U.shot(game, out .. "/keyboard-on-blank-cell.png") + tap("a") + ok(keyboard.text:sub(-1) == " ", "and A types it into the name") + -- Up twice from (8,2) is the I key, so the field ends up reading "AAAAA I". + tap("up") + tap("up") + tap("a") + ok(keyboard.text == "AAAAA I", "the space really is in the stored name") + U.shot(game, out .. "/keyboard-typed-a-space.png") + + -- Only A on END ends entry. + tap("start") + tap("a") + ok(typed ~= nil and #typed == keyboard.maxLength, + "A on END is what stores the entry") + U.wait(5) + + -- ------------------------------------------------------- Kyle's Onix + -- data/generated/events.lua, which World loads as its own eventTables. + local row = NpcTrade.row(game.world.eventTables, 1) + if not ok(row and row.get == "ONIX", "the cache carries NPC_TRADE_KYLE") then + error("gold naming: no trade row, cannot continue") + end + local save = game.save + save.party = { Mon.new(game.data, "BELLSPROUT", 12) } + local _, onix = NpcTrade.perform(game.data, save, row, 1) + ok(onix and onix.nickname == "ROCKY", "the trade handed ROCKY over") + ok(onix and onix.item == "BITTER_BERRY", + "wearing a NAMED BITTER_BERRY, not the table's byte 83") + + -- START > POKeMON > A > STATS, then the green page. + tap("start") + local menu = top() + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + tap("a") + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "the party list opened") then + error("gold naming: no party list, cannot continue") + end + tap("a") + tap("a") -- STATS leads the submenu + local summary = top() + ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS opened STATS") + tap("right") -- page 1 (pink) -> page 2 (green), which is the ITEM page + U.wait(5) + local placed = summary and summary:placements() + local text = {} + for _, p in ipairs(placed or {}) do text[#text + 1] = tostring(p.text or "") end + ok(table.concat(text, "|"):find("BITTER BERRY", 1, true) ~= nil, + "the green page prints BITTER BERRY, not 83") + U.shot(game, out .. "/onix-summary-item.png") + tap("b") + + -- ITEM > TAKE. This is the press that used to crash in Bag.isBadge. + tap("a") + local submenu = party and party.submenu + for _ = 1, 8 do + if submenu and submenu.items[submenu.index] + and submenu.items[submenu.index].id == "ITEM" then break end + tap("down") + submenu = party and party.submenu + end + ok(submenu and submenu.items[submenu.index] + and submenu.items[submenu.index].id == "ITEM", "the cursor found ITEM") + tap("a") + local held = top() + ok(held and held.screenId == "Gen2HeldItemMenu", "GIVE / TAKE opened") + tap("down") + tap("a") + U.wait(5) + ok(save.party[1].item == nil, "TAKE pulled the berry off") + ok((save.inventory or {}).BITTER_BERRY == 1, "and it landed in the bag") + U.shot(game, out .. "/onix-item-taken.png") + + print(("[naming] %d failure(s)"):format(fails)) + love.event.quit() +end diff --git a/tests/drivers/gold_noeffect_anim.lua b/tests/drivers/gold_noeffect_anim.lua new file mode 100644 index 00000000..e5a227b6 --- /dev/null +++ b/tests/drivers/gold_noeffect_anim.lua @@ -0,0 +1,185 @@ +-- A move the target is immune to, and the two move lock-ins, on the real +-- battle screen. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_noeffect_anim.lua love . +-- +-- What a human is here to see: +-- +-- 01 / 02 TACKLE (NORMAL) against a GASTLY (GHOST). The move text prints, +-- the screen holds still for MoveDelay, and then "It doesn't affect +-- GASTLY..." appears. No attack animation plays at any point. +-- BattleCommand_Stab's `.GotMatchup` writes wAttackMissed for a +-- zero matchup (effect_commands.asm:1337), `stab` runs ahead of +-- `moveanim` in every damaging effect list +-- (data/moves/effects.asm:5), and BattleCommand_MoveAnimNoSub +-- early-outs on wAttackMissed (:1958). +-- 03 LEECH SEED on a Grass type: same shape, `.grass` -> +-- AnimateFailedMove (move_effects/leech_seed.asm). +-- 04 - 09 ROLLOUT. CheckPlayerLockedIn quits ParsePlayerAction while +-- SUBSTATUS_ROLLOUT is set (core.asm:546), so the FIGHT menu never +-- comes back at all: after the one selection in 04 the move repeats +-- on its own for four more turns and the menu only returns once the +-- fifth hit clears the bit (09). The PP counter moves exactly once, +-- on the opening turn, because checkrollout skips past +-- doturn_command for every later turn of the lock. +-- +-- The animation suppression is the deliverable here: no headless assertion can +-- see whether a sprite moved, so this driver is the check. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-noeffect-anim" + + 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 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- One mon carrying exactly the three moves this driver exercises, so the + -- FIGHT list is readable in the shots. + local player = Mon.new(game.data, "CYNDAQUIL", 30) + assert(player, "could not build a CYNDAQUIL from pokemon.lua") + player.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "LEECH_SEED", pp = 10, maxPp = 10 }, + { id = "ROLLOUT", pp = 20, maxPp = 20 }, + } + game.save.party = { player } + game.save.inventory = {} + + -- Waits for the battle screen to be sitting on its menu again. + local function toMenu(battle, limit) + for _ = 1, (limit or 400) do + if battle.phase == "menu" or battle.battle.over then return end + tap("a", 3) + end + end + + local function fight(battle, slot) + tap("a") -- FIGHT + U.wait(6) + for _ = 2, slot do tap("down", 4) end + U.wait(4) + return slot + end + + local function openBattle(species, level) + local wild = Mon.new(game.data, species, level) + assert(wild, "could not build a wild " .. species) + assert(world:startBattle({ wild = wild }), "startBattle failed") + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle and battle.battle, "battle screen never came up") + toMenu(battle) + return battle, wild + end + + -- ---- immunity: NORMAL into GHOST --------------------------------------- + local battle = openBattle("GASTLY", 8) + U.shot(game, out .. "/00-menu.png") + fight(battle, 1) -- TACKLE + tap("a") + -- Straight after the "used TACKLE!" line is exactly where the animation + -- would be. Both shots must show a still screen. + U.wait(6) + U.shot(game, out .. "/01-tackle-no-anim.png") + U.wait(24) + U.shot(game, out .. "/02-doesnt-affect.png") + toMenu(battle) + + -- ---- LEECH SEED into a Grass type -------------------------------------- + for _ = 1, 200 do + if not (game.stack:top() and game.stack:top().battle) then break end + tap("b", 3) + if battle.battle.over then break end + tap("a", 3) + end + U.wait(30) + battle = openBattle("BELLSPROUT", 8) + fight(battle, 2) -- LEECH SEED + tap("a") + U.wait(6) + U.shot(game, out .. "/03-leech-seed-no-anim.png") + toMenu(battle) + + -- ---- ROLLOUT locks the FIGHT list -------------------------------------- + -- + -- A high-level target so the five turns actually happen. + for _ = 1, 200 do + if not (game.stack:top() and game.stack:top().battle) then break end + tap("b", 3) + if battle.battle.over then break end + tap("a", 3) + end + U.wait(30) + battle = openBattle("SNORLAX", 40) + + -- Both HP pools are widened first. ROLLOUT's power doubles every turn + -- (BattleCommand_RolloutPower), and at these levels either side faints inside + -- the five, which ends the battle and leaves the deliverable unshot: the lock + -- is what this segment is here to photograph, not a damage race. + local function widen(mon) + if not mon then return end + mon.stats = mon.stats or {} + mon.stats.hp, mon.maxHp, mon.hp = 999, 999, 999 + end + widen(battle.battle.player) + widen(battle.battle.enemy) + + local ppBefore = player.moves[3].pp + tap("a") -- FIGHT + U.wait(8) + U.shot(game, out .. "/04-rollout-picked-once.png") + tap("down", 4) + tap("down", 4) + tap("a") -- ROLLOUT, the only selection + + -- From here the player never chooses again. A is still tapped, but only to + -- page the text along: if the menu ever reappears while the bit is set, the + -- port has lost CheckPlayerLockedIn. `rolloutLock` is the port's name for + -- SUBSTATUS_ROLLOUT and `rampCount` its counter minus one, so + -- `rampCount + 1` is the cart's wPlayerRolloutCount. + local menuDuringLock, shot, armed = false, {}, false + for _ = 1, 600 do + if battle.battle.over then break end + local v = player.volatile or {} + if v.rolloutLock then + armed = true + if battle.phase == "menu" then menuDuringLock = true end + local count = (v.rampCount or 0) + 1 + if not shot[count] then + shot[count] = true + U.shot(game, ("%s/0%d-rollout-turn%d.png"):format(out, 4 + count, count)) + end + elseif armed then + -- The fifth hit is the one that clears the bit, so the loop leaves on it + -- and 09 below is the menu coming back. + break + end + tap("a", 3) + end + toMenu(battle) + U.shot(game, out .. "/09-lock-released.png") + + local spent = ppBefore - player.moves[3].pp + print(("[driver] %s the opening hit set SUBSTATUS_ROLLOUT") + :format(armed and "ok " or "FAIL")) + print(("[driver] %s the FIGHT menu stayed shut for the whole lock") + :format(menuDuringLock and "FAIL" or "ok ")) + print(("[driver] %s ROLLOUT spent %d PP (1 is the cart: only turn one pays)") + :format(spent == 1 and "ok " or "FAIL", spent)) + print("[driver] shots in " .. out) +end diff --git a/tests/drivers/gold_opaque_surround.lua b/tests/drivers/gold_opaque_surround.lua new file mode 100644 index 00000000..b514c8bb --- /dev/null +++ b/tests/drivers/gold_opaque_surround.lua @@ -0,0 +1,114 @@ +-- The five full-screen Gold pages that used to letterbox over a live +-- overworld: the Ruins of Alph sliding puzzle, the DIPLOMA, the MAGNET TRAIN +-- ride, the Cianwood PHOTO card and the ALPH RUINS STAMP viewer. +-- +-- Every one of them wipes the tilemap on the cart before it draws a single +-- tile -- ClearBGPalettes / ClearTilemap at engine/games/unown_puzzle.asm:11, +-- engine/events/diploma.asm:13, engine/events/magnet_train.asm:101, +-- engine/printer/print_party.asm:134 and engine/events/print_unown.asm:17 -- +-- so no map pixel can be on screen while one is up. In the port that means +-- src/core/Game2.lua:drawScene must never reach `world:draw()` while an +-- opaque screen owns the stack. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_opaque_surround.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-surround (default) +-- +-- The screenshots are the deliverable: each one must show its page centred in +-- a plain field with NO Route 31 grass, ledge or house around it. The +-- assertions catch the same thing from inside (the overworld draw is counted +-- while the page is up, and has to stay at zero), so a run nobody watches is +-- still worth something. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-surround" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[surround] ok " .. label) + else + failures = failures + 1 + print("[surround] FAIL " .. label .. " " .. tostring(detail)) + end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- ROUTE_31 on purpose: grass, a ledge and a house, so a leak is obvious in + -- the shot rather than a subtle band of colour. + assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31") + U.wait(8) + + -- Count the overworld draws the way drawScene issues them. The instance + -- entry shadows World.draw on the metatable, so this sees every call and + -- still runs the real one. + local drew = 0 + local worldDraw = world.draw + world.draw = function(self, ...) + drew = drew + 1 + return worldDraw(self, ...) + end + + -- Control: the overworld IS the visible base here, so it must draw. Without + -- this the "never drew" assertions below would pass on a dead renderer. + drew = 0 + U.shot(game, out .. "/00-overworld.png") + ok("the plain overworld still draws the map", drew > 0, drew) + + -- `settle` is how many frames the page needs before it has anything to + -- show: the still pages are ready at once, the Magnet Train has to run its + -- scroll far enough for the carriage to exist. + local function page(label, file, open, settle) + if not open() then + failures = failures + 1 + print("[surround] FAIL " .. label .. " did not open") + return + end + U.wait(settle or 12) + local base = game.stack._items[game.stack:visibleBase()] + ok(label .. " is the opaque visible base", + base ~= nil and base.isOpaque == true, base and base.isOpaque) + drew = 0 + U.shot(game, out .. "/" .. file) + ok(label .. " keeps the overworld off the screen (ClearTilemap)", + drew == 0, drew) + if game.stack:top() ~= game.overworld then game.stack:pop() end + U.wait(6) + end + + -- engine/games/unown_puzzle.asm _UnownPuzzle + page("the sliding puzzle", "01-unown-puzzle.png", function() + return world:unownPuzzle(0, function() end) + end) + + -- engine/events/diploma.asm PlaceDiplomaOnScreen + page("the DIPLOMA", "02-diploma.png", function() + return world:showDiploma(function() end) + end) + + -- engine/events/magnet_train.asm MagnetTrain_LoadGFX_PlayMusic + page("the MAGNET TRAIN ride", "03-magnet-train.png", function() + return world:magnetTrain(true, function() end) + end, 90) + + -- engine/printer/print_party.asm PrintPartyMonPage1 + page("the PHOTO card", "04-photo-studio.png", function() + local party = game.save and game.save.party + return world:showPhotoStudio(party and party[1], function() end) + end) + + -- engine/events/print_unown.asm _UnownPrinter + page("the ALPH RUINS STAMP", "05-unown-printer.png", function() + return world:showUnownPrinter(function() end) + end) + + world.draw = worldDraw + + print(failures == 0 and "PASS gold_opaque_surround" + or ("FAIL gold_opaque_surround (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_overworld_npc.lua b/tests/drivers/gold_overworld_npc.lua new file mode 100644 index 00000000..cb242ab2 --- /dev/null +++ b/tests/drivers/gold_overworld_npc.lua @@ -0,0 +1,114 @@ +-- Assertion driver: object hour windows, the temporary event-flag byte, and +-- the Route 30 roadblock's facing, all through real map loads in the running +-- game. PASSES or errors; nothing to eyeball. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_overworld_npc.lua love . +-- +-- tests/gen2_object_hours_test.lua and tests/gen2_temp_events_test.lua prove +-- the same rules over fixtures and a bare cache; what only this can prove is +-- that a genuine boot, cache and setMap chain agree with them: CheckObjectTime +-- filters the spawn (home/map_objects.asm), ResetMapBufferEventFlags clears +-- flags 0-7 on the load (home/map.asm), and the spoken-to roadblock MONSTER +-- turns to the player (ObjectEvent's jumptextfaceplayer, home/map.asm). +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local function momCount() + local n = 0 + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.sprite == "SPRITE_MOM" then n = n + 1 end + end + return n + end + + -- ---- hour windows: one Mom, whatever the hour -------------------------- + -- Post-intro state: EVENT_PLAYERS_HOUSE_MOM_1 (1735) hides the intro Mom, + -- EVENT_PLAYERS_HOUSE_MOM_2 (1736) clear shows the time-of-day set. Both + -- scene maps go to their NOOP scene first, or the MeetMom / Elm's-aide + -- walk-ups fire on the load and park a text box over the whole run. + world.mapScenes = world.mapScenes or {} + world.mapScenes.PLAYERS_HOUSE_1F = 1 + world.mapScenes.NEW_BARK_TOWN = 1 + world.events:set(1735, true) + world.events:set(1736, false) + for _, hour in ipairs({ 6, 12, 20 }) do + world.clockHour = hour + assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"), + "PLAYERS_HOUSE_1F did not load") + U.wait(2) + local n = momCount() + assert(n == 1, ("%02d:00 spawned %d Moms, want exactly 1"):format(hour, n)) + end + U.log("hour windows: one Mom in the kitchen at 06:00, 12:00 and 20:00") + + local function pharmacists() + local n = 0 + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.sprite == "SPRITE_PHARMACIST" then n = n + 1 end + end + return n + end + for _, row in ipairs({ { 6, 0 }, { 12, 1 }, { 20, 1 } }) do + world.clockHour = row[1] + assert(world:setMap("GOLDENROD_GAME_CORNER", 8, 10, "up"), + "GOLDENROD_GAME_CORNER did not load") + U.wait(2) + local n = pharmacists() + assert(n == row[2], + ("game corner at %02d:00: %d pharmacists, want %d") + :format(row[1], n, row[2])) + end + U.log("hour windows: the pharmacist pair collapses to one, absent at dawn") + + -- ---- the temporary byte dies on the load -------------------------------- + for id = 0, 8 do world.events:set(id, true) end + assert(world:setMap("NEW_BARK_TOWN", 8, 8, "down"), + "NEW_BARK_TOWN did not load") + U.wait(2) + for id = 0, 7 do + assert(not world.events:get(id), + ("temporary flag %d survived the map load"):format(id)) + end + assert(world.events:get(8), "flag 8 must survive: only one byte clears") + world.events:set(8, false) + U.log("temp events: flags 0-7 cleared by the load, flag 8 kept") + + -- ---- the roadblock Rattata turns to the player -------------------------- + -- EVENT_ROUTE_30_BATTLE (1812) clear puts the battling pair on the map. + world.events:set(1812, false) + world.clockHour = 12 + assert(world:setMap("ROUTE_30", 4, 25, "right"), "ROUTE_30 did not load") + U.wait(2) + local rattata + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.sprite == "SPRITE_MONSTER" + and npc.cellX == 5 and npc.cellY == 25 then + rattata = npc + end + end + assert(rattata, "the (5,25) roadblock MONSTER did not spawn") + assert(rattata.facing == "up", + "before the talk it faces its partner (STANDING_UP), got " .. rattata.facing) + U.tap(game, "a") + U.wait(4) + assert(rattata.facing == "left", + "spoken to from the west it must turn left, got " .. rattata.facing) + assert(world:busy(), "and its ObjectEvent text box is up") + assert(rattata.frozen, "and it holds still under the box") + -- Type the line out and close the box (held A is the fast path). + for _ = 1, 120 do + if not world:busy() then break end + U.tap(game, "a") + end + assert(not world:busy(), "the box closed") + U.wait(4) + assert(not rattata.frozen, "and the freeze lifted with the script") + + U.log("PASS gold_overworld_npc: hour windows, temp flags, roadblock facing") + love.event.quit(0) +end diff --git a/tests/drivers/gold_palette_shots.lua b/tests/drivers/gold_palette_shots.lua new file mode 100644 index 00000000..93f93395 --- /dev/null +++ b/tests/drivers/gold_palette_shots.lua @@ -0,0 +1,70 @@ +-- Gold GBC palettes: one screenshot of New Bark Town per time of day, plus +-- one of Elm's lab (PALETTE_DAY, so it must NOT go dark at night). +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_palette_shots.lua love . +-- +-- Palettes are the one part of the Gen 2 port a test cannot assert -- "is the +-- roof the right blue at 6am" only a human can answer -- so this driver's job +-- is to put those four frames on disk side by side. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-palettes" + + local function world() + return game.world + end + + local function rebake(hour) + local w = world() + w.clockHour = hour + -- Force the re-resolve the once-a-second poll would eventually do. + w.paletteClock = 0 + if w:applyPalettes() then + w.mapImages = {} + w.mapImage = w:imageFor(w.map.id) + w:rebuildNeighbors() + end + return w.daytime + end + + U.wait(45) + assert(world() and world().map, "gold world did not boot") + -- A New Game starts in the bedroom now (SPAWN_HOME); step outside, which is + -- where the time-of-day palettes are worth looking at. + if world().map.id ~= "NEW_BARK_TOWN" then + world():setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(15) + end + assert(world().map.id == "NEW_BARK_TOWN", + "boot map " .. tostring(world().map.id)) + + if not world().palettes then + print("[driver] SKIP no palettes.lua in this cache -- re-import Gold") + return + end + + for _, entry in ipairs({ + { hour = 6, name = "morn" }, + { hour = 13, name = "day" }, + { hour = 21, name = "nite" }, + }) do + local daytime = rebake(entry.hour) + U.wait(4) + U.shot(game, ("%s/newbark-%s.png"):format(out, entry.name)) + print(("[driver] %s (hour %d) -> %s"):format(entry.name, entry.hour, daytime)) + assert(daytime == entry.name:upper(), + ("hour %d resolved to %s"):format(entry.hour, tostring(daytime))) + end + + -- Elm's lab is PALETTE_DAY: walking in at 9pm must still be lit like day. + rebake(21) + world():setMap("ELMS_LAB", 4, 6, "up") + U.wait(10) + assert(world().daytime == "DAY", + "ELMS_LAB at 21:00 should stay PALETTE_DAY, got " + .. tostring(world().daytime)) + U.shot(game, out .. "/elmslab-night-is-day.png") + + print("[driver] PASS gold palette shots in " .. out) +end diff --git a/tests/drivers/gold_party_held_item.lua b/tests/drivers/gold_party_held_item.lua new file mode 100644 index 00000000..b1a033b8 --- /dev/null +++ b/tests/drivers/gold_party_held_item.lua @@ -0,0 +1,105 @@ +-- The held-item marker on the party list's mon icons (.SpawnItemIcon, +-- engine/gfx/mon_icons.asm): a mon carrying something swaps its icon's +-- BOTTOM-LEFT tile for HeldItemIcons $09 (gfx/stats/item.2bpp), and one +-- carrying MAIL swaps it for $08 (gfx/stats/mail.2bpp) instead. Nothing about +-- it is text, so only a screenshot can say whether it is there. +-- +-- Slot 1 holds a BERRY, slot 2 holds FLOWER MAIL, slot 3 holds nothing: one +-- shot with all three rows on screen is the whole comparison. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_party_held_item.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- The markers cannot appear until the ROM has been re-imported with a manifest +-- that lists HeldItemIcons: the driver says so out loud rather than leaving a +-- blank shot to be misread. +local U = require("tests.drivers.util") + +return function(game) + local fails = 0 + local function ok(cond, msg) + if cond then print("[held] ok " .. msg) + else fails = fails + 1 print("[held] FAIL " .. msg) end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + ok(game.world and game.world.map, "gold world booted") + + local Mail = require("src.core.gen2.Mail") + local Mon = require("src.battle.gen2.Mon") + local save = game.save + save.party = { + Mon.new(game.data, "CYNDAQUIL", 12), + Mon.new(game.data, "TOTODILE", 10), + Mon.new(game.data, "GEODUDE", 8), + } + save.party[1].item = "BERRY" + -- The letter itself rides sPartyMail, keyed by slot; the icon only reads the + -- item byte, but a mon holding mail with no struct behind it is not a state + -- the cart can reach, so write both. + save.party[2].item = "FLOWER_MAIL" + Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!", + save.player and save.player.name or "GOLD", + save.player and save.player.id or 0, "TOTODILE")) + ok(Mail.monHoldsMail(save.party[2]), "slot 2 is holding mail") + ok(save.party[3].item == nil, "slot 3 is holding nothing") + + -- GetIconGFX uploads HeldItemIcons as the two tiles after each icon's eight, + -- so the marker sheet rides the same cache entry the icons do. + local icons = game.data.gen2Icons + local hasMarkers = icons and icons.heldItem and icons.heldItem.image ~= nil + if hasMarkers then + print("[held] ok the cache carries HeldItemIcons: " + .. tostring(icons.heldItem.image)) + else + print("[held] NOTE this cache predates the HeldItemIcons extraction, so " + .. "the icons will be bare. Re-import the ROM before reading the shot.") + end + + -- START > POKéMON, the field flavour of the list. + tap("start") + local menu = top() + ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu") + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKéMON opened the list") then + error("gold party held item: no party list, cannot continue") + end + + -- ItemIsMail is what picks between the two tiles; row 0 of the sheet is + -- mail.2bpp and row 1 is item.2bpp, the order they are INCBIN'd. + ok(party.heldMarkerRow(save.party[1]) == 1, "the berry asks for tile $09") + ok(party.heldMarkerRow(save.party[2]) == 0, "the mail asks for tile $08") + ok(party.heldMarkerRow(save.party[3]) == nil, "the empty hand asks for none") + + -- The cursor sits on row 1, which slides its icon a tile right; wait out a + -- full frame swap first so the shot catches the icons mid-animation and the + -- marker can be checked for NOT bobbing with them. + U.wait(20) + U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party") + .. "/held-item-markers.png") + tap("down") + U.wait(20) + U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party") + .. "/held-item-markers-row2.png") + + tap("b") + tap("b") + + if fails > 0 then + error(("gold party held item: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold party held item: berry, mail and an empty hand") +end diff --git a/tests/drivers/gold_party_submenu.lua b/tests/drivers/gold_party_submenu.lua new file mode 100644 index 00000000..b13410af --- /dev/null +++ b/tests/drivers/gold_party_submenu.lua @@ -0,0 +1,134 @@ +-- The field party list, end to end through the pad: START > POKéMON opens +-- the list, A on a mon opens PokemonActionSubmenu (engine/pokemon/ +-- mon_menu.asm), STATS pushes the summary, SWITCH reorders the save's own +-- party, and an EGG slot offers only STATS / SWITCH / CANCEL with an EGG row +-- and the EGG icon (engine/pokemon/party_menu.asm, mon_submenu.asm .egg). +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_party_submenu.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +return function(game) + local fails = 0 + local function ok(cond, msg) + if cond then print("[party] ok " .. msg) + else fails = fails + 1 print("[party] FAIL " .. msg) end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + ok(game.world and game.world.map, "gold world booted") + + -- A real party out of the one Gen 2 builder, and the egg out of the same + -- giveegg builder the aide's script calls (World:giveEgg, species index + -- 175 = TOGEPI). + local Mon = require("src.battle.gen2.Mon") + local save = game.save + save.party = { + Mon.new(game.data, "CYNDAQUIL", 12), + Mon.new(game.data, "TOTODILE", 10), + } + ok(game.world:giveEgg(175, 5), "giveegg filled slot 3") + ok(save.party[3] and save.party[3].isEgg == true, "and marked it an egg") + + -- The cache carries the egg's own menu icon (ICON_EGG, IconPointers). + local icons = game.data.gen2Icons + ok(icons and icons.icons and icons.icons.ICON_EGG + and icons.icons.ICON_EGG.image ~= nil, "the cache has ICON_EGG") + + -- START opens the menu; walk the cursor to the POKéMON row. + tap("start") + local menu = top() + ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu") + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found POKéMON") + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKéMON opened the list") then + error("gold party submenu: no party list, cannot continue") + end + ok(party.wantsSubmenu == true, "as the field flavour") + + -- The EGG row is a name and an icon alone. + if party then + local eggRow = party.rowFor(save.party[3]) + ok(eggRow.name == "EGG" and eggRow.hp == nil and eggRow.status == nil, + "the egg's row reads EGG with no HP or FNT") + ok(party:iconIdFor(save.party[3]) == "ICON_EGG", + "and draws the EGG icon") + end + + -- A on the lead mon: the submenu, not an exit. + tap("a") + ok(top() == party, "a kept the list open") + ok(party and party.submenu ~= nil, "and opened the submenu") + ok(party and party.submenu + and party.submenu.items[1].id == "STATS", "STATS leads it") + + -- STATS pushes the summary over the list. + tap("a") + local summary = top() + ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS opened the summary") + ok(summary and summary.mon and summary.mon.species == "CYNDAQUIL", + "on the chosen mon") + tap("b") + ok(top() == party, "b landed back on the list") + + -- SWITCH: hold slot 1, drop it on slot 2. + tap("a") + tap("down") + ok(party and party.submenu + and party.submenu.items[party.submenu.index].id == "SWITCH", + "the cursor found SWITCH") + tap("a") + ok(party and party.submenu == nil and party.switchFrom == 1, + "SWITCH holds the slot") + tap("down") + tap("a") + ok(save.party[1].species == "TOTODILE" + and save.party[2].species == "CYNDAQUIL", "the party reordered") + ok(party and party.switchFrom == nil, "and the hold released") + + -- The egg's own submenu: STATS / SWITCH / CANCEL, and STATS shows the EGG + -- page with no species anywhere on it. + tap("down") + ok(party and party.index == 3, "the cursor reached the egg") + tap("a") + local items = party and party.submenu and party.submenu.items or {} + ok(#items == 3 and items[1].id == "STATS" and items[2].id == "SWITCH" + and items[3].id == "CANCEL", "an egg offers STATS / SWITCH / CANCEL") + tap("a") + summary = top() + ok(summary and summary.screenId == "Gen2SummaryMenu", "STATS on the egg opened") + if summary and summary.screenId == "Gen2SummaryMenu" then + local SummaryMenu = require("src.ui.gen2.SummaryMenu") + local page = summary:placements() + ok(SummaryMenu.at(page, 8, 1) == "EGG", "as the EGG page") + ok(SummaryMenu.at(page, 8, 2) == nil + and SummaryMenu.at(page, 10, 4) == nil, "with the species kept secret") + U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-party") + .. "/egg-summary.png") + tap("b") + end + ok(top() == party, "b came back to the list") + + -- Back out of everything. + tap("b") + ok(top() ~= party, "b closed the list") + tap("b") + + if fails > 0 then + error(("gold party submenu: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold party submenu: STATS, SWITCH and the EGG rules") +end diff --git a/tests/drivers/gold_phone_call.lua b/tests/drivers/gold_phone_call.lua new file mode 100644 index 00000000..cbe8d60f --- /dev/null +++ b/tests/drivers/gold_phone_call.lua @@ -0,0 +1,154 @@ +-- Assertion driver: the phone in the RUNNING game. It PASSes or it fails +-- loudly; there is nothing to eyeball. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_phone_call.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Three things the ROM-free suites cannot see from outside a booted world: +-- * StartMap's `farcall InitCallReceiveDelay`: a genuine World:setMap has +-- to arm the receive countdown off the game clock. +-- * CheckTimeEvents' CheckPhoneCall arm: with a contact in the book and +-- the countdown run down, a random call has to RING in the overworld -- +-- the caller-ID page, the extracted bank $41 chat, the Click! -- and a +-- caller script's .WantsBattle has to arm its _READY_FOR_REMATCH event. +-- * The Pokegear's CALL entry: the callee's SCRIPT1 runs through the same +-- VM while the card keeps the screen. +local U = require("tests.drivers.util") + +return function(game) + local fails = 0 + local function ok(cond, msg) + if cond then print("[phone] ok " .. msg) + else fails = fails + 1 print("[phone] FAIL " .. msg) end + return cond + 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 + + -- TextBox.paginate emits a page as a LIST of wrapped lines; flatten the + -- whole open box to one searchable string. + local function boxText(top) + if not (top and top.pages) then return nil end + local out = {} + for _, page in ipairs(top.pages) do + if type(page) == "table" then + out[#out + 1] = table.concat(page, "\n") + else + out[#out + 1] = tostring(page) + end + end + return table.concat(out, "|") + end + + U.wait(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + local save = game.save + local Phone = require("src.core.gen2.Phone") + + -- The phone reads its clock through World:stepContext's game.clock seam, so + -- the driver owns the minutes the countdown walks. + game.clock = { day = 0, hour = 9, minute = 0 } + + -- ------------------------------------------- StartMap arms the countdown + save.phone = nil + assert(w:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31") + U.wait(3) + ok(save.phone and save.phone.delayMins == 20, + "a map load arms the receive countdown at twenty minutes") + ok(save.phone and save.phone.timeCycles == 0, "with the cycle counter zeroed") + ok(save.phone and save.phone.delayStart + and save.phone.delayStart.minute == 0, "stamped off the game clock") + + -- CheckStandingOnEntrance must not refuse, so stand on a plain floor cell. + local cx, cy = w.player.cellX, w.player.cellY + for y = 2, 16 do + for x = 2, 16 do + if w.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end + end + end + assert(w:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31") + U.wait(3) + + -- ------------------------------------------- a random call rings + -- Joey (contact 15) lives on ROUTE_30, so he is available from ROUTE_31, + -- and his caller script's rematch gate is ENGINE_FLYPOINT_GOLDENROD + -- (checkflag 69 in the extracted body). + Phone.addContact(save, 15) + w:setEngineFlag(69, true) + + local calls, sawRing, sawClick, sawReset = 0, false, false, false + for _ = 1, 40 do + if w.events:get(628) then break end + game.clock.minute = game.clock.minute + 20 + for _ = 1, 20 do + U.wait(1) + if w:busy() then break end + end + if w:busy() then + calls = calls + 1 + for _ = 1, 300 do + local textAll = boxText(game.stack and game.stack:top()) + if textAll then + if textAll:find("RING!", 1, true) then sawRing = true end + if textAll:find("Click!", 1, true) then sawClick = true end + end + if not w:busy() then break end + tap("a", 2) + end + if save.phone.timeCycles == 0 and save.phone.delayMins == 20 then + sawReset = true + end + end + end + ok(calls > 0, ("a random incoming call rang in the overworld (%d calls)") + :format(calls)) + ok(sawRing, "opening on the RING! caller-ID page") + ok(sawClick, "and hanging up on the Click!") + ok(w.events:get(628), + "a caller script armed EVENT_JOEY_READY_FOR_REMATCH (628)") + ok(sawReset, "and the hang-up restarted the receive countdown") + + -- ------------------------------------------- the Pokegear calls out + w:setEngineFlag(2, true) -- ENGINE_PHONE_CARD + w:setEngineFlag(4, true) -- ENGINE_POKEGEAR + game:openStartMenuItem("pokegear") + U.wait(3) + local gear = game.stack:top() + assert(gear and gear.cards, "the Pokegear did not open") + gear.mode = "card" + for index, card in ipairs(gear.cards) do + if card.id == "phone" then gear.cardIndex = index end + end + U.wait(2) + tap("a", 3) -- CALL/DELETE/CANCEL submenu on Joey's slot + tap("a", 3) -- CALL + local spoke = false + for _ = 1, 200 do + local top = game.stack and game.stack:top() + local textAll = boxText(top) + if textAll and textAll:find("JOEY", 1, true) then spoke = true end + if not (w.vm and w.vm:running()) and not (top and top.pages) then break end + tap("a", 2) + end + ok(gear.call ~= nil, "the card holds the placed call") + ok(gear.call and gear.call.script == "JoeyPhoneCalleeScript", + "to Joey's own SCRIPT1") + ok(gear.call and gear.call.ranScript == true, + "which ran through the overworld VM") + ok(spoke, "and he actually talked") + tap("a", 3) -- hang up + ok(gear.call == nil, "A hangs the call up") + + print(fails == 0 and "PASS gold_phone_call" + or ("FAIL gold_phone_call (%d)"):format(fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_phone_caller_box.lua b/tests/drivers/gold_phone_caller_box.lua new file mode 100644 index 00000000..cb2c4487 --- /dev/null +++ b/tests/drivers/gold_phone_caller_box.lua @@ -0,0 +1,128 @@ +-- The caller-ID box an incoming call puts across the top of the screen: +-- Phone_TextboxWithName (pokegold engine/phone/phone.asm:582), reached from +-- RingTwice_StartCall's .CallerTextboxWithName (:466, :474). +-- +-- Three shots, because the whole bug was "there is no box at all" and only a +-- picture can answer that: +-- +-- 01-ring.png the ring, box up, naming who is calling +-- 02-talking.png the caller's own script talking UNDER the box +-- 03-after.png the call over, box gone, overworld clean +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_phone_caller_box.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-callerbox (default) +-- +-- The assertions cover the same ground for a run nobody is looking at: the box +-- is on the stack while the call runs, it is UNDER the text pages rather than +-- over them, and it is off the stack once the script has ended (a box left +-- behind would sit on the overworld forever -- it has no `update`, so nothing +-- would ever take it down). +local U = require("tests.drivers.util") + +local Phone = require("src.core.gen2.Phone") + +-- The tag src/script/gen2/CallAsm.lua marks the pushed state with. +local CALLER_BOX = "gen2CallerBox" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-callerbox" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[callerbox] ok " .. label) + else + failures = failures + 1 + print("[callerbox] FAIL " .. label .. " " .. tostring(detail)) + end + end + + -- Index of the caller box on the stack, or nil. + local function boxIndex() + for index, state in ipairs(game.stack.states or {}) do + if state[CALLER_BOX] then return index end + end + return nil + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + game.clock = { day = 0, hour = 9, minute = 0 } + save.phone = nil + assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31") + U.wait(3) + -- CheckStandingOnEntrance refuses a call on a door tile, so stand on floor. + local cx, cy = world.player.cellX, world.player.cellY + for y = 2, 16 do + for x = 2, 16 do + if world.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end + end + end + assert(world:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31") + U.wait(3) + + ok("no caller box before the phone rings", boxIndex() == nil, boxIndex()) + + Phone.addContact(save, 15) -- Joey, ROUTE_30, reachable from ROUTE_31 + + -- Wind the clock forward twenty in-game minutes at a time until + -- CheckReceiveCallTimer lands a random call. + local rang = false + for _ = 1, 40 do + game.clock.minute = game.clock.minute + 20 + for _ = 1, 20 do + U.wait(1) + if world:busy() then break end + end + if world:busy() then rang = true break end + end + ok("a call rang in the overworld", rang) + if not rang then + print("FAIL gold_phone_caller_box (no call)") + love.event.quit(1) + return + end + + -- The box goes up on the FIRST ring; the ring page only arrives after the + -- second pass, three Phone_Wait20Frames later (engine/phone/phone.asm:576), + -- so wait for the page rather than for a fixed count -- then let it type. + for _ = 1, 300 do + if #game.stack.states > 1 then break end + U.wait(1) + end + U.wait(40) + local ringIndex = boxIndex() + ok("the caller box is up during the ring", ringIndex ~= nil) + ok("and sits UNDER the ring's text page", + ringIndex ~= nil and ringIndex < #game.stack.states, + ringIndex and (ringIndex .. " of " .. #game.stack.states)) + U.shot(game, out .. "/01-ring.png") + + -- Into the caller's own script. One A gets past the ring page; the shot is + -- taken with a page of Joey's chatter up, which is what the player sees for + -- most of a call. + U.tap(game, "a") + U.wait(40) + ok("the box survives into the call itself", boxIndex() ~= nil) + U.shot(game, out .. "/02-talking.png") + + -- Now page through to the end: the hang-up Click!, then the tail rows. + local finished = false + for _ = 1, 400 do + if not world:busy() and boxIndex() == nil then finished = true break end + U.tap(game, "a") + U.wait(4) + end + ok("the call ran to the end", finished, world:busy()) + ok("and InitCallReceiveDelay took the caller box down", + boxIndex() == nil, boxIndex()) + U.wait(10) + U.shot(game, out .. "/03-after.png") + + print(failures == 0 and "PASS gold_phone_caller_box" + or ("FAIL gold_phone_caller_box (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_phone_ring.lua b/tests/drivers/gold_phone_ring.lua new file mode 100644 index 00000000..dd7b4738 --- /dev/null +++ b/tests/drivers/gold_phone_ring.lua @@ -0,0 +1,150 @@ +-- The RING itself, in the running game: does the player actually HEAR the +-- phone before the hang-up beep? +-- +-- Phone_StartRinging is `call WaitSFX` and only THEN `ld de, SFX_CALL / +-- call PlaySFX` (engine/phone/phone.asm:564-567), and RingTwice_StartCall +-- runs that whole pass twice (:458-469). Both halves matter in this port: +-- SFX_CALL is $6a (constants/sfx_constants.asm:109), low enough that the +-- PlaySFX priority gate DROPS it outright while a louder sound is still on +-- ch5-ch8, so a ring with no wait in front of it can be silent and leave +-- SFX_HANG_UP as the first phone sound the player ever hears. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_phone_ring.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-ring (default) +-- +-- Listen for two rings about a second apart before the caller-ID page, then +-- the Click! at the end. The assertions cover the same ground for a run +-- nobody is listening to: the loud sound is started deliberately first, so a +-- ring that survives it proves the wait, not luck. +local U = require("tests.drivers.util") + +local Phone = require("src.core.gen2.Phone") +local Sound = require("src.core.Sound") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ring" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[ring] ok " .. label) + else + failures = failures + 1 + print("[ring] FAIL " .. label .. " " .. tostring(detail)) + end + end + + -- Every gated sfx request, in order, with whether it actually started: a + -- dropped one returns no source (src/core/Sound.lua sfxPriorityGate). + local order, rings, sounded = {}, 0, 0 + local realPlay = Sound.play + Sound.play = function(data, name) + local src = realPlay(data, name) + order[#order + 1] = tostring(name) + if tostring(name):find("Call") then + rings = rings + 1 + if src then sounded = sounded + 1 end + end + return src + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + game.clock = { day = 0, hour = 9, minute = 0 } + save.phone = nil + assert(world:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31") + U.wait(3) + -- CheckStandingOnEntrance refuses a call on a door tile, so stand on floor. + local cx, cy = world.player.cellX, world.player.cellY + for y = 2, 16 do + for x = 2, 16 do + if world.map:cellCollision(x, y) == 0x00 then cx, cy = x, y end + end + end + assert(world:setMap("ROUTE_31", cx, cy, "down"), "no floor cell on ROUTE_31") + U.wait(3) + + -- Is there an audio device at all? Without one every source is nil and + -- "the ring sounded" would fail for a reason that has nothing to do with + -- the phone, so the control decides whether that half is checked. + world:playSfxNamed("Sfx_ReadText2") + local audio = world.lastSfx ~= nil + print("[ring] audio device: " .. tostring(audio)) + + -- Diagnostic, not an assertion. WaitSFX blocks on the whole sfx channel + -- set (CheckSFX, home/audio.asm), but the VM's waitsfx hook only polls + -- World.lastSfx, so a sound started straight through Sound.play -- the + -- A-press beep src/render/TextBox.lua plays through the Press_AB alias -- + -- is invisible to it while Sound.sfxBusy() can still see it. While these + -- two disagree, a ring queued inside that sound's window can still be + -- dropped by the priority gate. + world.lastSfx = nil -- isolate: the hook must answer about THIS sound alone + Sound.play(game.data, "Sfx_ReadText2") + local hook = world.vm and world.vm.waitSfxFn + print(("[ring] WaitSFX seam: hook says busy=%s, Sound.sfxBusy=%s") + :format(tostring(hook and not hook()), tostring(Sound.sfxBusy()))) + U.wait(30) + + Phone.addContact(save, 15) -- Joey, ROUTE_30, reachable from ROUTE_31 + order, rings, sounded = {}, 0, 0 + + -- SFX_READ_TEXT_2 is $08: it outranks SFX_CALL, so this is the sound that + -- eats an unwaited ring. Started one frame before the call lands, exactly + -- as the A press that closes a textbox does. + world:playSfxNamed("Sfx_ReadText2") + + local calls, shot = 0, false + for _ = 1, 40 do + if calls > 0 and not world:busy() then break end + game.clock.minute = game.clock.minute + 20 + for _ = 1, 20 do + U.wait(1) + if world:busy() then break end + end + if world:busy() then + calls = calls + 1 + for _ = 1, 400 do + if not shot and rings >= 2 then + -- The caller-ID page, with both rings already behind it. The wait + -- is for the typewriter: the page is only worth looking at once + -- the whole RING!…RING! line has printed. + U.wait(30) + U.shot(game, out .. "/01-ring.png") + shot = true + end + if not world:busy() then break end + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(2) + game.input.state.a = false + U.wait(2) + end + end + end + + ok("a call rang in the overworld", calls > 0, calls) + -- RingTwice_StartCall's `call .Ring` plus its fallthrough. + ok("the phone rang twice", rings == 2, rings) + if audio then + ok("and both rings actually sounded past the priority gate", + sounded == rings, ("%d of %d"):format(sounded, rings)) + end + + local firstCall, firstHang + for index, name in ipairs(order) do + if not firstCall and name:find("Call") then firstCall = index end + if not firstHang and name:find("Hang") then firstHang = index end + end + ok("the ring is the FIRST phone sound, not the hang-up beep", + firstCall ~= nil and (firstHang == nil or firstCall < firstHang), + table.concat(order, ",")) + + Sound.play = realPlay + print(failures == 0 and "PASS gold_phone_ring" + or ("FAIL gold_phone_ring (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_pokecenter_stairs.lua b/tests/drivers/gold_pokecenter_stairs.lua new file mode 100644 index 00000000..b75540ed --- /dev/null +++ b/tests/drivers/gold_pokecenter_stairs.lua @@ -0,0 +1,69 @@ +-- Assertion driver: the shared POKECENTER_2F staircase, walked for real. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_pokecenter_stairs.lua love . +-- +-- tests/gen2_pokecenter_stairs_test.lua drives takeWarp against the real map +-- defs; what it cannot do is put a player's feet on the tile. This walks up +-- the stairs of two different Pokemon Centers and back down, through the real +-- step loop, the real fades and the real warp machinery, and asserts the one +-- thing the cart guarantees: the single second floor leads back down into +-- whichever centre it was climbed from (home/map.asm CopyWarpData's -1 arm). +-- It PASSES or it errors; there is nothing to eyeball. +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Wait out fades, script beats and the step in flight. + local function settle(limit) + for _ = 1, limit or 600 do + if not world:busy() and not (world.player and world.player.moving) then + return + end + U.wait(1) + end + error("world never settled") + end + + local function at() + return world.map.id, world.player.cellX, world.player.cellY + end + + local function climbAndReturn(centerId) + -- Stand two cells east of the staircase and walk onto it. + assert(world:setMap(centerId, 2, 7, "left"), "setMap " .. centerId) + U.wait(5) + settle() + U.hold(game, "left", 80) + settle() + local mapId, x, y = at() + assert(mapId == "POKECENTER_2F", + ("%s stairs went to %s at (%d,%d), not the shared 2F") + :format(centerId, mapId, x, y)) + U.log(centerId .. ": up the stairs onto the shared 2F") + + -- Step off the staircase, then back onto it: the -1 warp must resolve to + -- the centre just left. + U.hold(game, "right", 30) + settle() + assert(world.player.cellX >= 1, + "did not step off the 2F staircase (x=" .. world.player.cellX .. ")") + U.hold(game, "left", 80) + settle() + mapId, x, y = at() + assert(mapId == centerId, + ("the 2F stairs came down in %s, expected %s"):format(mapId, centerId)) + assert(x == 0 and y == 7, + ("landed at (%d,%d), expected the 1F staircase (0,7)"):format(x, y)) + U.log(centerId .. ": back down into the same centre") + end + + climbAndReturn("CHERRYGROVE_POKECENTER_1F") + climbAndReturn("VIOLET_POKECENTER_1F") + + U.log("PASS gold_pokecenter_stairs") + love.event.quit() +end diff --git a/tests/drivers/gold_pokegear_call_paper.lua b/tests/drivers/gold_pokegear_call_paper.lua new file mode 100644 index 00000000..2c3b4c63 --- /dev/null +++ b/tests/drivers/gold_pokegear_call_paper.lua @@ -0,0 +1,69 @@ +-- Eyeball driver: the paper a textbox sits on while the Pokegear holds the +-- screen. A call's text box is a plain src/render/TextBox.lua state pushed +-- OVER the gear, and the gear's own box (Pokegear:textbox) already lays the +-- card's cream paper down first, because every tile the box is built from is +-- font-page ($79-$7e frame, ' ' $7f interior) and TownMapPals hands every tile +-- id >= $60 to BG palette 0, whose colour 0 is `RGB 28, 31, 20` +-- (pokegold engine/pokegear/pokegear.asm TownMapPals, gfx/pokegear/pokegear.pal). +-- The two shots below have to agree: if the second one shows a pure white band +-- across the bottom of the card where the first shows cream, the pushed box is +-- still hard-filling white. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_SHOTS=/tmp/gearpaper \ +-- POKEPORT_DRIVER=tests/drivers/gold_pokegear_call_paper.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/gearpaper" + +return function(game) + 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(45) + local w = game.world + assert(w and w.map, "gold world did not boot") + + -- Joey (contact 15) is the shortest reachable call: he lives on ROUTE_30, so + -- his number is dialable from ROUTE_31 and his SCRIPT1 talks straight away. + local Phone = require("src.core.gen2.Phone") + assert(w:setMap("ROUTE_31", 8, 6, "down"), "setMap failed for ROUTE_31") + U.wait(3) + Phone.addContact(game.save, 15) + w:setEngineFlag(2, true) -- ENGINE_PHONE_CARD + w:setEngineFlag(4, true) -- ENGINE_POKEGEAR + game:openStartMenuItem("pokegear") + U.wait(3) + local gear = game.stack:top() + assert(gear and gear.cards, "the Pokegear did not open") + gear.mode = "card" + for index, card in ipairs(gear.cards) do + if card.id == "phone" then gear.cardIndex = index end + end + U.wait(3) + + -- Reference: the gear's OWN box, drawn through Pokegear:textbox. Its + -- interior is the cream paper, and it is the colour the call box must match. + U.shot(game, SHOTS .. "/01-gear-own-box.png") + + tap("a", 3) -- CALL / DELETE / CANCEL on Joey's slot + tap("a", 3) -- CALL + + -- The first page of the call, i.e. a pushed TextBox over the card. + for _ = 1, 240 do + local top = game.stack and game.stack:top() + if top and top.pages and top ~= gear then break end + U.wait(1) + end + U.shot(game, SHOTS .. "/02-call-textbox.png") + U.log("compare 01 and 02: the band behind the call text must be the same", + "cream as the gear's own box, not white") + love.event.quit(0) +end diff --git a/tests/drivers/gold_radio_persist.lua b/tests/drivers/gold_radio_persist.lua new file mode 100644 index 00000000..78758e92 --- /dev/null +++ b/tests/drivers/gold_radio_persist.lua @@ -0,0 +1,141 @@ +-- Assertion driver: the Pokegear radio's song outlives the gear, and the +-- Vermilion Snorlax hears it. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_radio_persist.lua love . +-- +-- The chain under test, all through the real screens: engine flags written by +-- World:setEngineFlag (the store every granting script's `setflag` lands in) +-- unlock the START menu's POKeGEAR row and the gear's cards; the radio card +-- tunes 20.0 to the POKe FLUTE channel (Kanto + ENGINE_EXPN_CARD); closing +-- the gear leaves the song playing as the map music +-- (ExitPokegearRadio_HandleMusic / RadioMusicRestartDE); and `special +-- SnorlaxAwake` then reads that very song and starts the BATTLETYPE_FORCEITEM +-- Snorlax fight. +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local Music = require("src.core.Music") + + -- A party to fight with; the unlock flags go through the same store every + -- granting script's `setflag` writes. + local save = game.save + save.party = { { + species = "TYPHLOSION", name = "TYPHLOSION", nickname = "TYPHLOSION", + level = 60, hp = 180, maxHp = 180, + moves = { { id = "FLAMETHROWER", pp = 15, maxPp = 15 } }, + } } + for _, flag in ipairs({ 0, 1, 2, 3, 4, 11 }) do + world:setEngineFlag(flag, true) + end + + -- Beside the sleeping Snorlax: (33,8) is one of SnorlaxAwake's own + -- .ProximityCoords, and facing right reaches the doll's object cell (34,8). + assert(world:setMap("VERMILION_CITY", 33, 8, "right"), + "setMap failed for VERMILION_CITY") + U.wait(10) + + local function top() return game.stack:top() end + local function topIs(id) + local t = top() + return t and t.screenId == id and t or nil + end + + -- START -> the menu, with the POKeGEAR row unlocked by the flags alone. + U.tap(game, "start") + local menu + for _ = 1, 60 do + menu = topIs("Gen2StartMenu") + if menu then break end + U.wait(1) + end + assert(menu, "START did not open the start menu") + local ids = {} + for _, item in ipairs(menu.items) do ids[item.value] = true end + assert(ids.pokegear, "the POKeGEAR row is missing from the START menu") + assert(ids.pokedex, "the POKeDEX row is missing from the START menu") + U.log("START menu shows POKeDEX and POKeGEAR from the engine flags") + + -- Down to the POKeGEAR row (POKeDEX, POKeMON, PACK, POKeGEAR) and in. + for _ = 1, 3 do U.tap(game, "down") U.wait(2) end + U.tap(game, "a") + local gear + for _ = 1, 60 do + gear = topIs("Gen2Pokegear") + if gear then break end + U.wait(1) + end + assert(gear, "the POKeGEAR row did not open the gear") + assert(#gear.cards == 4, "expected all four cards, got " .. #gear.cards) + + -- Strip: CLOCK, MAP, RADIO, PHONE. Two rights and A is the radio card. + U.tap(game, "right") U.wait(2) + U.tap(game, "right") U.wait(2) + U.tap(game, "a") U.wait(2) + assert(gear.mode == "card" and gear:card().id == "radio", + "did not land on the radio card") + + -- Wind the knob to 20.0: RADIO_CHANNELS row 7, six UPs from row 1. + for _ = 1, 6 do U.tap(game, "up") U.wait(2) end + assert(gear.radioShow == "POKE_FLUTE_RADIO", + "20.0 did not resolve the POKe FLUTE channel (got " + .. tostring(gear.radioShow) .. ")") + for _ = 1, 60 do + if Music.current() == "Music_PokeFluteChannel" then break end + U.wait(1) + end + assert(Music.current() == "Music_PokeFluteChannel", + "the POKe FLUTE channel is not playing") + U.log("tuned 20.0: the POKe FLUTE channel is playing") + + -- B off the card, B out of the gear, B out of the menu: the song must + -- survive all three (ExitPokegearRadio_HandleMusic keeps a tuned song). + U.tap(game, "b") U.wait(3) + U.tap(game, "b") U.wait(3) + for _ = 1, 60 do + if not topIs("Gen2StartMenu") then break end + U.tap(game, "b") + U.wait(2) + end + U.wait(5) + assert(Music.current() == "Music_PokeFluteChannel", + "the song did not survive closing the gear (playing " + .. tostring(Music.current()) .. ")") + assert(Music.mapSong() == "Music_PokeFluteChannel", + "the song did not become the map music") + U.log("gear closed: the POKe FLUTE channel persists as the map music") + + -- A on the Snorlax. SnorlaxAwake hears the flute channel, and the script + -- runs on into `loadwildmon SNORLAX, 50` and `startbattle`. + U.tap(game, "a") + local battle + for _ = 1, 300 do + battle = topIs("Gen2BattleTransition") or topIs("Gen2BattleState") + if battle then break end + U.tap(game, "a") + U.wait(3) + end + assert(battle, "the Snorlax did not wake: no battle started") + -- Ride the wipe into the battle screen and read the enemy off it. + local state + for _ = 1, 600 do + state = topIs("Gen2BattleState") + if state then break end + U.wait(1) + end + assert(state, "the transition never handed over to the battle screen") + local enemy = state.battle and state.battle.enemy + assert(enemy and enemy.species == "SNORLAX", + "expected SNORLAX, got " .. tostring(enemy and enemy.species)) + assert(enemy.level == 50, "expected L50, got " .. tostring(enemy.level)) + -- BATTLETYPE_FORCEITEM: InitEnemyMon hands Item1 over unconditionally. + assert(enemy.item ~= nil, "the forced held item is missing") + U.log(("SNORLAX woke up: L%d battle fired, holding %s") + :format(enemy.level, tostring(enemy.item))) + + print("[driver] PASS gold radio persistence + Snorlax wake") + love.event.quit() +end diff --git a/tests/drivers/gold_roamers.lua b/tests/drivers/gold_roamers.lua new file mode 100644 index 00000000..2d2fcbfa --- /dev/null +++ b/tests/drivers/gold_roamers.lua @@ -0,0 +1,210 @@ +-- Assertion driver: the three legendary beasts, in the running game. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_roamers.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-roamers (default) +-- +-- The whole feature was a model with no callers: `special InitRoamMons` wrote +-- the three structs and nothing ever moved them, rolled for them or banked +-- them. tests/gen2_roamers_test.lua pins the four call sites against fixtures; +-- this walks the real thing -- the real special, real map loads on real Johto +-- routes, and a real battle screen with a real beast on it. +local U = require("tests.drivers.util") + +local Roamers = require("src.core.gen2.Roamers") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-roamers" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local save = game.save + save.roamers = nil + + -- ---- InitRoamMons, through the special the Burned Tower runs ------------ + -- Dispatched BY NAME through the cache's own specialOrder, so this is the + -- same path BurnedTowerB1F's `special InitRoamMons` takes. + local order = world.constants and world.constants.specialOrder + assert(order, "no specialOrder in the cache") + local id + for index, name in ipairs(order) do + if name == "InitRoamMons" then id = index - 1 break end + end + assert(id, "InitRoamMons is not in SpecialsPointers") + world.vm:runSpecial(id) + local beasts = Roamers.list(save) + assert(beasts and #beasts == 3, + "InitRoamMons did not put three beasts on the save") + local starts = {} + for i, slot in ipairs(beasts) do + assert(Roamers.active(slot), "beast " .. i .. " came out inactive") + assert(slot.hp == 0, "a fresh beast has no rolled stats yet") + starts[i] = slot.map + U.log(("beast %d: %s L%d on %s"):format(i, slot.species, slot.level, slot.map)) + end + assert(starts[1] == "ROUTE_42" and starts[2] == "ROUTE_37" + and starts[3] == "ROUTE_38", + "the three starting routes are not Raikou 42 / Entei 37 / Suicune 38") + + -- ---- UpdateRoamMons, on a real door warp -------------------------------- + -- MapSetupScript_Fall drops into _Door drops into _Train, and UpdateRoamMons + -- is _Train's tail -- so walking through a door nudges every beast one + -- connection along. + local before = { beasts[1].map, beasts[2].map, beasts[3].map } + local moves = 0 + for _ = 1, 12 do + local was = { beasts[1].map, beasts[2].map, beasts[3].map } + world:runMapSetup(0xf5, function() -- MAPSETUP_DOOR + return world:setMap("ROUTE_29", 20, 8, "down") + end) + for _ = 1, 60 do + if not world.mapSetup then break end + U.wait(1) + end + for i = 1, 3 do + if beasts[i].map ~= was[i] then moves = moves + 1 end + end + end + assert(moves > 0, + "twelve door warps and not one beast moved: UpdateRoamMons is not wired") + local anyMoved = false + for i = 1, 3 do + if beasts[i].map ~= before[i] then anyMoved = true end + assert(Roamers.entryFor(beasts[i].map, world.encounters), + ("beast %d walked off the roam map list onto %s") + :format(i, tostring(beasts[i].map))) + end + assert(anyMoved, "the beasts ended exactly where they started") + U.log(("UpdateRoamMons: %d moves over twelve door warps, now on %s / %s / %s") + :format(moves, beasts[1].map, beasts[2].map, beasts[3].map)) + + -- A plain warp names neither command, so nothing may move. + local held = { beasts[1].map, beasts[2].map, beasts[3].map } + world:runMapSetup(0xf1, function() -- MAPSETUP_WARP + return world:setMap("ROUTE_30", 10, 10, "down") + end) + for _ = 1, 60 do + if not world.mapSetup then break end + U.wait(1) + end + for i = 1, 3 do + assert(beasts[i].map == held[i], + "a plain warp moved a beast; only _Connection / _Train / _Teleport may") + end + U.log("and a plain warp leaves them alone, the way MapSetupScript_Warp does") + + -- ---- JumpRoamMons, on a teleport ---------------------------------------- + -- Flying is MAPSETUP_TELEPORT, whose third row scatters every beast to a + -- random roam map. Over ten flights all three have to land somewhere new. + local seen = { {}, {}, {} } + for _ = 1, 10 do + -- JumpRoamMons runs ABOVE the load, so "the player's map" it re-rolls off + -- is the one being LEFT, not the destination. + local leaving = world.map.id + world:runMapSetup(0xf4, function() -- MAPSETUP_TELEPORT + return world:setMap("ROUTE_29", 20, 8, "down") + end) + for _ = 1, 60 do + if not world.mapSetup then break end + U.wait(1) + end + for i = 1, 3 do + seen[i][beasts[i].map] = true + assert(beasts[i].map ~= leaving, + ("JumpRoamMon dropped beast %d on %s, the map the player just left") + :format(i, tostring(leaving))) + end + end + for i = 1, 3 do + local count = 0 + for _ in pairs(seen[i]) do count = count + 1 end + assert(count > 1, + ("beast %d sat on one map across ten teleports: JumpRoamMons is dead") + :format(i)) + end + U.log("JumpRoamMons: ten flights scattered all three, never onto the map left") + + -- ---- CheckEncounterRoamMon, into a real battle -------------------------- + -- Put Raikou under the player's feet and pin the roll to the one byte that + -- gets past both of CheckEncounterRoamMon's gates and picks slot 1. + local Mon = require("src.battle.gen2.Mon") + save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + assert(save.party[1], "could not build the player's mon") + world:setMap("ROUTE_29", 20, 8, "down") + U.wait(10) + beasts[1].map = "ROUTE_29" + world.roamerRandom = function() return 1 end + world.player.cellX, world.player.cellY = 20, 8 + + -- CanEncounterWildMon has to pass before ChooseWildEncounter is reached at + -- all, so stand in real tall grass rather than wherever the warp landed. + -- The cell is found in the map rather than remembered. + local FieldMoves = require("src.world.gen2.FieldMoves") + local grassX, grassY + for cy = 0, world.map.heightCells - 1 do + for cx = 0, world.map.widthCells - 1 do + local coll = world.map:cellCollision(cx, cy) + if FieldMoves.canEncounterWildMon( + world.map.def.environment, coll, false) then + grassX, grassY = cx, cy + break + end + end + if grassX then break end + end + assert(grassX, "no encounter tile anywhere on ROUTE_29") + world.player.cellX, world.player.cellY = grassX, grassY + world.player.px, world.player.py = grassX * 16, grassY * 16 + U.log(("standing in Route 29's grass at (%d,%d)"):format(grassX, grassY)) + + -- TryWildEncounter runs `.EncounterRate` FIRST and only reaches + -- ChooseWildEncounter -- CheckEncounterRoamMon included -- on a pass + -- (engine/overworld/wildmons.asm), so the beast sits behind a random byte + -- this driver does not own. Pin THAT byte rather than the roamer roll: the + -- map's own rate is still read and both arms of the gate are asserted, which + -- is the cart order itself rather than a way around it. + local Encounter = require("src.battle.gen2.Encounter") + local realTriggers = Encounter.triggers + local rate = Encounter.grassRate(world:wildTables(), world.map.id, + world.daytime) + assert(rate and rate > 0, "ROUTE_29 has no grass encounter rate to gate on") + Encounter.triggers = function() return false end + local gated = world:tryWildEncounter() + Encounter.triggers = function(r) return realTriggers(r, function() return 0 end) end + local ok, met = pcall(world.tryWildEncounter, world) + Encounter.triggers = realTriggers + assert(ok, met) + assert(not gated, + "a beast turned up with the encounter rate refusing: the roamer check is " + .. "above `.EncounterRate` rather than inside ChooseWildEncounter") + assert(met, + "the wild roll met nothing with a beast on the player's own route") + U.log(("ROUTE_29 grass rate %d/256; the beast is behind it, not beside it") + :format(rate)) + + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle, "the roaming battle never reached the screen") + U.wait(90) + assert(battle.battle.roaming == 1, + "the battle does not know it is BATTLETYPE_ROAMING") + assert(battle.battle.enemy.species == "RAIKOU", + "met " .. tostring(battle.battle.enemy.species) .. " rather than RAIKOU") + assert(battle.battle.enemy.level == 40, "at the wrong level") + assert(beasts[1].hp > 0, + ".InitRoamHP banks the beast's full HP on the FIRST meeting") + assert(beasts[1].dvs, "and rolls its DVs once, so it stays one individual") + assert(U.shot(game, out .. "/00-raikou.png"), "no screenshot") + U.log(("CheckEncounterRoamMon: met %s L%d, slot %d, %d HP banked, DVs kept") + :format(battle.battle.enemy.species, battle.battle.enemy.level, + battle.battle.roaming, beasts[1].hp)) + + U.log("PASS gold_roamers in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_rock_probe.lua b/tests/drivers/gold_rock_probe.lua new file mode 100644 index 00000000..07e008d3 --- /dev/null +++ b/tests/drivers/gold_rock_probe.lua @@ -0,0 +1,81 @@ +-- Rock-smash probe: resume a checkpoint, stand at Burned Tower 1F (4,4), +-- press A at the rock on (4,3), and report every state change -- the textbox +-- body, the stack top, and whether the rock object is still there. +-- +-- POKEPORT_IDENTITY=gold-v2 POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=07 \ +-- POKEPORT_DRIVER=tests/drivers/gold_rock_probe.lua love . + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local resume = os.getenv("POKEPORT_GOLD_RESUME") + if resume then + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[rock] cannot resume %s: %s"):format(resume, tostring(err))) + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + end + + local world = game.world + -- Skip the rival ambush: hide his object and advance the scene the way his + -- own script would have, so the probe measures the ROCK and nothing else. + world.events:set(1733, true) -- EVENT_RIVAL_BURNED_TOWER + world.mapScenes["BURNED_TOWER_1F"] = 1 -- SCENE_BURNEDTOWER1F_FIREBREATHER_DICK + world:setMap("BURNED_TOWER_1F", 9, 15, "up") + bot:wait(30) + bot:clearDialogue(nil, 12000) + print("[rock] after rival: map=" .. tostring(A.mapId(game))) + if A.mapId(game) ~= "BURNED_TOWER_1F" then + print("[rock] ABORT: rival fight lost / left the tower") + love.event.quit() + return + end + + local function report(tag) + local npc = A.npcAt(game, 4, 3) + print(("[rock] %-12s map=%s pos=%s,%s rock=%s busy=%s") + :format(tag, tostring(A.mapId(game)), + tostring(select(1, A.pos(game))), tostring(select(2, A.pos(game))), + tostring(npc ~= nil), tostring(A.busyReason(game)))) + end + + -- Simulate a long session: a stale hLastTalked from an earlier talk. The + -- cart overwrites it on every A-press dispatch; a port that does not will + -- smash the wrong object. + world.vm.lastTalked = 9 + + report("arrived") + local okw = bot:walkTo(4, 4) + print("[rock] walkTo(4,4):", tostring(okw)) + report("standing") + bot:face("up") + for attempt = 1, 4 do + bot:tap("a") + bot:wait(4) + for _ = 1, 30 do + if A.busy(game) then break end + bot:wait(1) + end + if A.busy(game) then + print("[rock] tap " .. attempt .. " opened something") + break + end + print("[rock] tap " .. attempt .. " opened nothing") + end + bot:clearDialogue({ "yes" }, 4000) + report("talked") + love.event.quit() +end diff --git a/tests/drivers/gold_rock_smash_probe.lua b/tests/drivers/gold_rock_smash_probe.lua new file mode 100644 index 00000000..beac8851 --- /dev/null +++ b/tests/drivers/gold_rock_smash_probe.lua @@ -0,0 +1,159 @@ +-- ROCK SMASH's wild encounter, and the beasts scattering on CONTINUE, driven +-- through the real game. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=13 \ +-- POKEPORT_DRIVER=tests/drivers/gold_rock_smash_probe.lua love . +-- +-- Two things a ROM-free suite cannot see, because both live behind World:load: +-- +-- * the `readMem` hook. RockSmashScript is `callasm RockMonEncounter / +-- readmem wTempWildMonSpecies / iffalse .done / randomwildmon / +-- startbattle`, and the byte only reads back if World:load installed the +-- seam -- otherwise the VM answers out of its own sparse store, sees 0 and +-- skips the battle, which is what the port did for every smash in the game. +-- * `farcall JumpRoamMons` on the continue path +-- (engine/menus/intro_menu.asm), which is World:roamMonsOnContinue and runs +-- from inside World:load rather than from any map setup script. +-- +-- Prints one PASS/FAIL line per claim and quits, so a killed run is visibly +-- incomplete rather than silently green. + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +-- data/wild/treemon_maps.asm RockMonMaps: the rock at (16,14) of Dark Cave's +-- Violet entrance is object 2, reachable from (15,14). +local ROCK_MAP = "DARK_CAVE_VIOLET_ENTRANCE" +local ROCK_X, ROCK_Y = 16, 14 +local STAND_X, STAND_Y = 15, 14 + +-- constants/pokemon_constants.asm; TreeMonSet_Rock is 90 KRABBY / 10 SHUCKLE. +local ROCK_SPECIES = { [98] = "KRABBY", [213] = "SHUCKLE" } + +local results = {} + +local function claim(ok, text) + results[#results + 1] = ok and true or false + print((ok and "[rocksmash] PASS " or "[rocksmash] FAIL ") .. text) +end + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local resume = os.getenv("POKEPORT_GOLD_RESUME") or "13" + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[rocksmash] cannot resume %s: %s"):format(resume, tostring(err))) + love.event.quit() + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + + -- ---- JumpRoamMons on CONTINUE ------------------------------------------- + -- + -- Park all three beasts on one map, then load the very same save again the + -- way the CONTINUE menu does. JumpRoamMon re-rolls off the player's own map + -- and picks one of sixteen otherwise, so all three staying put is a 1 in + -- 4096 coincidence rather than a passing implementation. + local save = game.save + if save and not save.roamers then + -- `special InitRoamMons`, which the Burned Tower basement runs when the + -- floor gives way. A checkpoint taken before that has no structs, and the + -- scatter below is about the CONTINUE path rather than about this. + require("src.core.gen2.Roamers").init(save, { force = true }) + print("[rocksmash] note: seeded InitRoamMons for this checkpoint") + end + if not (save and save.roamers) then + claim(false, "the save has roamers to scatter") + else + for _, slot in ipairs(save.roamers) do + if slot.species then slot.map = "ROUTE_29" end + end + local continued = pcall(game.continueGame, game, save) + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local moved = 0 + for _, slot in ipairs(game.save.roamers or {}) do + if slot.species and slot.map ~= "ROUTE_29" then moved = moved + 1 end + end + claim(continued and moved > 0, + ("CONTINUE scattered the beasts (%d of 3 left ROUTE_29)"):format(moved)) + end + + -- ---- RockMonEncounter ---------------------------------------------------- + local world = game.world + world:setMap(ROCK_MAP, STAND_X, STAND_Y, "right") + bot:wait(30) + bot:clearDialogue(nil, 4000) + claim(A.mapId(game) == ROCK_MAP, "arrived at " .. ROCK_MAP) + + -- HasRockSmash is CheckPartyMove: the lead has to know the move for + -- AskRockSmashScript to open at all. Teaching it is the setup, not the + -- thing under test. + local lead = game.save.party and game.save.party[1] + if lead then + lead.moves = lead.moves or {} + lead.moves[#lead.moves + 1] = { id = "ROCK_SMASH", pp = 15, maxPp = 15 } + end + claim(lead ~= nil, "the lead can be taught ROCK SMASH") + + -- `ld a, 10 / RandomRange / cp 4` and then SelectTreeMon's 0..99: a zero + -- passes the 40 percent and lands in the 90 percent KRABBY bracket, so the + -- smash below is the deterministic case. + world.rockmonRandom = function() return 0 end + -- Dark Cave is a wild-encounter map; the walk to the rock must not be + -- interrupted by one, and the battle under test comes from the script. + world.noWildEncounters = true + + -- The `startbattle` RockSmashScript ends on comes through here, and the bot + -- fights the battle out before any poll of its own could see it -- so the + -- species is read off the seam the script itself reaches. + local fought + local realScripted = world.startScriptedBattle + world.startScriptedBattle = function(self, record, wild, onDone) + if wild and wild.species then fought = wild.species end + return realScripted(self, record, wild, onDone) + end + + local reached = bot:approachAndFace(ROCK_X, ROCK_Y) + claim(reached, "faced the rock at " .. ROCK_X .. "," .. ROCK_Y) + + for attempt = 1, 4 do + bot:tap("a") + bot:wait(4) + for _ = 1, 40 do + if A.busy(game) then break end + bot:wait(1) + end + if A.busy(game) then break end + print("[rocksmash] tap " .. attempt .. " opened nothing") + end + -- AskRockSmashScript's yesorno, then the smash, the earthquake and the roll. + bot:clearDialogue({ "yes" }, 8000) + for _ = 1, 600 do + if fought or not A.busy(game) then break end + bot:wait(1) + end + + claim(fought ~= nil, "the smash reached startbattle at all") + claim(fought ~= nil and ROCK_SPECIES[fought] ~= nil, + "and the wild mon came out of TREEMON_SET_ROCK (got " + .. tostring(fought and ROCK_SPECIES[fought] or fought) .. ")") + + local failures = 0 + for _, value in ipairs(results) do + if not value then failures = failures + 1 end + end + print(("[rocksmash] %d claims, %d failed"):format(#results, failures)) + love.event.quit() +end diff --git a/tests/drivers/gold_rocket_cameras.lua b/tests/drivers/gold_rocket_cameras.lua new file mode 100644 index 00000000..5a0fa5f9 --- /dev/null +++ b/tests/drivers/gold_rocket_cameras.lua @@ -0,0 +1,107 @@ +-- SecurityCamera1a (maps/TeamRocketBaseB1F.asm:22): the two Rocket grunts the +-- camera calls down on you, ONE AFTER ANOTHER. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_rocket_cameras.lua love . +-- +-- The cart has exactly ONE object for both of them (TEAMROCKETBASEB1F_ROCKET1) +-- and stages it twice: `moveobject` back to the corridor mouth, `appear`, +-- `applymovement SecurityCameraMovement1`, battle, `disappear` -- then the same +-- five commands again for the second grunt. So the second run MUST start from +-- the cell the second `moveobject` names (19,2) and not from wherever the first +-- grunt stopped, or he sprints on past the player and off the room. +-- +-- The driver prints where the object stands at the start and the end of each +-- of the two approach walks and shoots both. Shots land in /tmp/gold-cameras. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local ROCKET1 = 1 -- def.objects index; the object const is this + 1 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-cameras" + + 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(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local starter = Mon.new(game.data, "TYPHLOSION", 60) + assert(starter, "could not build a TYPHLOSION") + game.save.party = { starter } + + world:setMap("TEAM_ROCKET_BASE_B1F", 23, 2, "right") + U.wait(20) + assert(world.map.id == "TEAM_ROCKET_BASE_B1F", tostring(world.map.id)) + + -- Both guards the coord event checks: EVENT_SECURITY_CAMERA_1 (already seen) + -- and EVENT_TEAM_ROCKET_BASE_POPULATION (the base already cleared out). The + -- second is the flag the two standing trainers on this floor carry, read off + -- the object rather than named as a number. + local rocket = world.map.def.objects[ROCKET1] + world.events:set(world.map.def.objects[2].eventFlag, false) + for _, ev in ipairs(world.map.def.coordEvents or {}) do + if ev.x == 24 and ev.y == 2 then world.cameraEvent = ev end + end + + local runs = {} + local realBegin = world.beginMovement + world.beginMovement = function(self, objectId, bytes, onDone) + if objectId == ROCKET1 + 1 then + local ent = self:objectEntity(objectId) + runs[#runs + 1] = { + fromX = ent and ent.cellX, fromY = ent and ent.cellY, bytes = #(bytes or {}), + } + end + return realBegin(self, objectId, bytes, onDone) + end + + U.shot(game, out .. "/00-corridor.png") + U.hold(game, "right", 20) + U.wait(10) + + local shots = 0 + local battles = 0 + for _ = 1, 2000 do + local top = game.stack:top() + if top and top.battle then + battles = battles + 1 + U.shot(game, ("%s/%02d-battle.png"):format(out, battles)) + for _ = 1, 900 do + if top.battle.over then break end + tap("a", 3) + end + U.wait(20) + end + if #runs > shots then + shots = #runs + U.wait(30) + U.shot(game, ("%s/%02d-approach.png"):format(out, shots)) + end + if not world:busy() and battles >= 2 then break end + tap("a", 2) + end + + for i, run in ipairs(runs) do + print(("[driver] approach %d started at (%s,%s), %d movement bytes") + :format(i, tostring(run.fromX), tostring(run.fromY), run.bytes)) + end + U.wait(20) + U.shot(game, out .. "/09-after.png") + + assert(#runs >= 2, ("only %d approach walks ran; the camera calls two grunts") + :format(#runs)) + for i, run in ipairs(runs) do + assert(run.fromX == 19 and run.fromY == 2, + ("approach %d started at (%s,%s); every `moveobject` in SecurityCamera1a " + .. "names (19,2)"):format(i, tostring(run.fromX), tostring(run.fromY))) + end + print("[driver] PASS gold rocket security cameras in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_route29_tutorial.lua b/tests/drivers/gold_route29_tutorial.lua new file mode 100644 index 00000000..e924fa1d --- /dev/null +++ b/tests/drivers/gold_route29_tutorial.lua @@ -0,0 +1,80 @@ +-- The Route 29 DUDE, the scene two separate defects meet in. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_route29_tutorial.lua love . +-- +-- What a human is watching for: +-- * "Would you like me / to show you how to / catch #MON?" -- the YES/NO +-- prompt must go up OVER that box, with the question still on it and with +-- NO extra button press in between. CatchingTutorialIntroText ends +-- `done`, so DoneText returns without a PromptButton (home/text.asm:484) +-- and Script_yesorno's `call YesNoBox` is the very next thing that +-- happens (engine/overworld/scripting.asm:366). +-- * when the tutorial battle ends, the Route 29 map theme comes back. The +-- silence used to outlive the battle: wDontPlayMapMusicOnReload was read +-- at the end of the fight instead of at the reload behind it, so the flag +-- sat set and stopped the music at the END of the NEXT battle. +-- +-- Shots land in /tmp/gold-route29. +local U = require("tests.drivers.util") +local Music = require("src.core.Music") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-route29" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Route29Tutorial1 is a coord event at (53,8) gated on + -- SCENE_ROUTE29_CATCH_TUTORIAL (maps/Route29.asm:422); take the scene id + -- from the map rather than hardcoding the constant. + world:setMap("ROUTE_29", 53, 10, "up") + U.wait(20) + local trigger + for _, ev in ipairs(world.map.def.coordEvents or {}) do + if ev.x == 53 and (ev.y == 8 or ev.y == 9) then trigger = trigger or ev end + end + assert(trigger, "ROUTE_29 has no catch tutorial coord event") + world.mapScenes[world.map.id] = trigger.sceneId or 0 + local mapSong = Music.mapSong() + + U.hold(game, "up", 24) + U.wait(20) + + -- Page to the question, counting the presses it costs. The prompt must + -- arrive on the press that finishes the last page, not one press later. + local presses, sawPrompt = 0, false + for _ = 1, 120 do + if world.choicebox then + sawPrompt = true + break + end + U.tap(game, "a") + presses = presses + 1 + U.wait(12) + end + U.shot(game, out .. "/00-yes-no-over-question.png") + print(("[driver] %d presses to reach the prompt, prompt seen: %s") + :format(presses, tostring(sawPrompt))) + + -- YES, then let the tutorial battle play itself out (it drives its own + -- input on the cart, so all this has to do is not get in the way). + U.tap(game, "a") + for _ = 1, 200 do + U.wait(15) + if world:busy() then break end + end + for _ = 1, 400 do + U.wait(15) + if not world:busy() and game.stack:top() == game.overworld then break end + U.tap(game, "a") + end + U.wait(60) + U.shot(game, out .. "/01-after-tutorial.png") + + print(("[driver] map song %s, playing now %s, dontRestartMusic %s") + :format(tostring(mapSong), tostring(Music.current()), + tostring(world.dontRestartMusic))) + print("[driver] PASS gold route 29 tutorial in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_shiny_shots.lua b/tests/drivers/gold_shiny_shots.lua new file mode 100644 index 00000000..ef827e5d --- /dev/null +++ b/tests/drivers/gold_shiny_shots.lua @@ -0,0 +1,98 @@ +-- The Red Gyarados, and an ordinary Miltank next to it for the contrast. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shiny_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-shiny (default) +-- +-- A shiny in Gen 2 is not a second sprite: it is the SAME two-colour pic drawn +-- through the species' second palette row (data/pokemon/palettes.asm ships +-- `normal` and `shiny` for every species), which is why the Lake of Rage +-- Gyarados is red rather than a different Gyarados. Palettes.monColors is the +-- one place that picks between the two rows, so this driver asserts the rows +-- really differ, builds the mon the way the cart does -- BATTLETYPE_FORCESHINY +-- writes ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, not a `shiny` boolean -- and +-- then shoots the battle screen so a human can see the colour. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Palettes = require("src.world.gen2.Palettes") +local GbcPalette = require("src.render.GbcPalette") + +-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces. +-- Attack 14, Defense 10, Speed 10, Special 10 -- which is exactly the pattern +-- Mon.isShiny tests, so nothing here has to say `shiny = true` by hand. +local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-shiny" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- A shiny only reads as one in COLOR: on a DMG both palette rows fold to the + -- same four greys, and the cart's own Gyarados is red for the same reason. + GbcPalette.setMode("gbc") + + local pals = world.palettes + local normal = Palettes.monColors(pals, "GYARADOS", false) + local shiny = Palettes.monColors(pals, "GYARADOS", true) + assert(normal and shiny, "no GYARADOS palette rows in the cache") + local differ = false + for i = 1, 4 do + for c = 1, 3 do + if normal[i][c] ~= shiny[i][c] then differ = true end + end + end + assert(differ, "GYARADOS' shiny row is identical to its normal row") + U.log(("GYARADOS normal (%d,%d,%d)/(%d,%d,%d) shiny (%d,%d,%d)/(%d,%d,%d)") + :format(normal[2][1], normal[2][2], normal[2][3], + normal[3][1], normal[3][2], normal[3][3], + shiny[2][1], shiny[2][2], shiny[2][3], + shiny[3][1], shiny[3][2], shiny[3][3])) + -- The Red Gyarados is red: its shiny row is the only one of the two whose + -- brighter colour is dominated by RED. Stated as a comparison rather than a + -- literal so a re-import that shifts the 5-bit conversion still passes. + local red = shiny[2] + assert(red[1] > red[2] and red[1] > red[3], + ("GYARADOS' shiny colour is not red: (%d,%d,%d)") + :format(red[1], red[2], red[3])) + + local player = Mon.new(game.data, "CYNDAQUIL", 30) + assert(player and #player.moves > 0, "could not build the player's mon") + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5 } + + local function battleShot(species, level, dvs, name) + local mon = Mon.new(game.data, species, level, { dvs = dvs }) + assert(mon, "no base data for " .. species) + if dvs == SHINY_DVS then + assert(mon.shiny, + species .. " built from the FORCESHINY DVs did not come out shiny") + else + assert(not mon.shiny, species .. " came out shiny by accident") + end + player.hp = player.maxHp + assert(world:startBattle({ wild = mon }), "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle, "battle screen never came up for " .. species) + -- Let the intro slide finish so the enemy pic is fully on screen. + U.wait(90) + assert(U.shot(game, ("%s/%s.png"):format(out, name)), "no screenshot") + -- Back out: RUN is the fourth menu item, but popping the state is enough + -- for a screenshot driver and cannot fail on a speed tie. + while game.stack:top() == battle do game.stack:pop() end + U.wait(10) + end + + battleShot("GYARADOS", 30, SHINY_DVS, "00-red-gyarados") + battleShot("MILTANK", 30, { attack = 15, defense = 15, speed = 15, + special = 15 }, "01-miltank") + + U.log("shiny shots in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_snorlax_footprint_probe.lua b/tests/drivers/gold_snorlax_footprint_probe.lua new file mode 100644 index 00000000..a00a1d98 --- /dev/null +++ b/tests/drivers/gold_snorlax_footprint_probe.lua @@ -0,0 +1,105 @@ +-- The Vermilion Snorlax's 2x2 footprint, on the real map. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=18-pristine \ +-- POKEPORT_DRIVER=tests/drivers/gold_snorlax_footprint_probe.lua love . +-- +-- SPRITEMOVEDATA_BIGDOLLSYM's palette-flags byte is `STRENGTH_BOULDER | +-- BIG_OBJECT`, and IsNPCAtCoord hands a BIG_OBJECT's coordinate to +-- WillObjectIntersectBigObject -- which accepts anything in (x,y)..(x+1,y+1). +-- IsNPCAtCoord is what both `.CheckNPC` and CheckFacingObject ask, so the +-- sleeping Snorlax at (34,8) fills four cells for walking and for talking. +-- +-- A screenshot goes to /tmp/gold-shots/ so the 32x32 draw can be looked at. + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter +local U = dofile("tests/drivers/util.lua") + +local MAP = "VERMILION_CITY" +local DOLL_X, DOLL_Y = 34, 8 + +local results = {} + +local function claim(ok, text) + results[#results + 1] = ok and true or false + print((ok and "[snorlax] PASS " or "[snorlax] FAIL ") .. text) +end + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local resume = os.getenv("POKEPORT_GOLD_RESUME") or "18-pristine" + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[snorlax] cannot resume %s: %s"):format(resume, tostring(err))) + love.event.quit() + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + + local world = game.world + world.noWildEncounters = true + world:setMap(MAP, 33, 8, "right") + bot:wait(30) + bot:clearDialogue(nil, 4000) + claim(A.mapId(game) == MAP, "arrived at " .. MAP) + + local doll = world:npcAt(DOLL_X, DOLL_Y) + claim(doll ~= nil and doll.bigObject == true, + "the object at (34,8) is a BIG_OBJECT") + + local cells = { { 34, 8 }, { 35, 8 }, { 34, 9 }, { 35, 9 } } + local blobOk = true + for _, cell in ipairs(cells) do + if world:npcAt(cell[1], cell[2]) ~= doll then blobOk = false end + end + claim(blobOk, "all four blob cells resolve to the same object") + claim(world:npcAt(36, 8) == nil and world:npcAt(34, 10) == nil, + "and the cells just outside it are clear") + + -- `.CheckNPC`: the three cells it merely overhangs refuse a step. Each is + -- approached from the far side so the walk is not blocked by the object's + -- own cell. + local walks = { + { 35, 7, "down", "into (35,8) from above" }, + { 33, 9, "right", "into (34,9) from the left" }, + { 35, 10, "up", "into (35,9) from below" }, + } + for _, row in ipairs(walks) do + world:setMap(MAP, row[1], row[2], row[3]) + bot:wait(20) + local sx, sy = A.pos(game) + A.hold(game, row[3]) + bot:wait(40) + A.releaseDirs(game) + bot:wait(20) + local ex, ey = A.pos(game) + claim(ex == sx and ey == sy, "a step " .. row[4] .. " is refused") + end + + -- CheckFacingObject from a cell that only touches the blob's overhang. + world:setMap(MAP, 36, 9, "left") + bot:wait(20) + local talked = world:interact() + claim(talked, "an A press from (36,9) reaches the Snorlax") + bot:clearDialogue(nil, 6000) + + world:setMap(MAP, 33, 8, "right") + bot:wait(40) + U.shot(game, "/tmp/gold-shots/snorlax-footprint.png") + + local failures = 0 + for _, value in ipairs(results) do + if not value then failures = failures + 1 end + end + print(("[snorlax] %d claims, %d failed"):format(#results, failures)) + love.event.quit() +end diff --git a/tests/drivers/gold_spin_clockwise.lua b/tests/drivers/gold_spin_clockwise.lua new file mode 100644 index 00000000..579608a2 --- /dev/null +++ b/tests/drivers/gold_spin_clockwise.lua @@ -0,0 +1,85 @@ +-- SPRITEMOVEDATA_SPINCLOCKWISE / _SPINCOUNTERCLOCKWISE, the two spins that are +-- not random. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_spin_clockwise.lua love . +-- +-- MovementFunction_SpinClockwise / _SpinCounterclockwise +-- (engine/overworld/map_objects.asm:790-843) hold OBJECT_STEP_DURATION $10 -- +-- sixteen frames -- on each quarter and then take the next facing out of a +-- FIXED table: clockwise is down -> left -> up -> right, counterclockwise is +-- down -> right -> up -> left. No Random is called anywhere in the loop, which +-- is the point: the Rocket base's guards are a puzzle, and a puzzle has to be +-- predictable. +-- +-- The port used to answer both rows with "stand", so Route 32's Youngster +-- Gordon, Route 35's Firebreather Walt, RadioTower4F's GruntM10 and the Route +-- 40/41 swimmers never turned at all. +local U = require("tests.drivers.util") + +local CLOCKWISE = { down = "left", up = "right", left = "up", right = "down" } +local COUNTER = { down = "right", up = "left", left = "down", right = "up" } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-spin" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Route 32 has Youngster Gordon on SPRITEMOVEDATA_SPINCLOCKWISE ($1f); the + -- driver takes whatever spinners the map actually carries rather than naming + -- an object index, so a cache rebuild cannot silently retarget it. + world:setMap("ROUTE_32", 9, 12, "down") + U.wait(30) + + local found = {} + for _, npc in ipairs(world.npcs) do + local m = npc.def and npc.def.movement + if m == 0x1f or m == 0x1e then + found[#found + 1] = { npc = npc, cw = (m == 0x1f) } + end + end + print(("[driver] %d fixed-spin objects on ROUTE_32"):format(#found)) + assert(#found > 0, "ROUTE_32 has no SPINCLOCKWISE / SPINCOUNTERCLOCKWISE object") + + U.shot(game, out .. "/00-before.png") + + -- Sixteen frames a quarter, so 200 frames is a dozen turns even allowing for + -- the initial timer NPC.new seeds. + local seen = {} + for _, e in ipairs(found) do seen[e.npc] = { [e.npc.facing] = true } end + local order = {} + for _, e in ipairs(found) do order[e.npc] = {} end + for _ = 1, 200 do + for _, e in ipairs(found) do + local f = e.npc.facing + local trail = order[e.npc] + if trail[#trail] ~= f then trail[#trail + 1] = f end + seen[e.npc][f] = true + end + U.wait(1) + end + U.shot(game, out .. "/01-after.png") + + for i, e in ipairs(found) do + local n = 0 + for _ in pairs(seen[e.npc]) do n = n + 1 end + local trail = order[e.npc] + print(("[driver] object %d (%s): %d distinct facings over %d turns: %s") + :format(i, e.cw and "clockwise" or "counterclockwise", n, #trail - 1, + table.concat(trail, ">"))) + assert(n == 4, + "a fixed spinner only reached " .. n .. " facings -- it is not turning") + -- Every consecutive pair has to be the table's own successor: a RANDOM + -- spin would pass the count above and fail here. + local want = e.cw and CLOCKWISE or COUNTER + for j = 2, #trail do + assert(trail[j] == want[trail[j - 1]], + ("turn %d went %s -> %s, wanted %s"):format(j - 1, trail[j - 1], + trail[j], tostring(want[trail[j - 1]]))) + end + end + + print("[driver] PASS gold fixed-order spinners in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_starter_pic.lua b/tests/drivers/gold_starter_pic.lua new file mode 100644 index 00000000..9b09c04c --- /dev/null +++ b/tests/drivers/gold_starter_pic.lua @@ -0,0 +1,137 @@ +-- The starter choice at Elm's lab, which is the first `pokepic` a player meets. +-- +-- maps/ElmsLab.asm, ElmsLabPokeBallScript (one per ball, Cyndaquil's at (6,3)): +-- +-- turnobject ELMSLAB_ELM, DOWN +-- reanchormap +-- pokepic CYNDAQUIL +-- cry CYNDAQUIL +-- waitbutton +-- closepokepic +-- opentext +-- writetext ElmsLabText_ChooseCyndaquil +-- yesorno +-- +-- The pic goes up with NO text window under it, so nothing else in that run of +-- commands consumes a button press: `waitbutton` (Script_waitbutton -> +-- WaitButton, home/text.asm) is the ONLY thing holding the frame, and it is +-- what gives the player time to look at the mon before the yes/no. The port +-- used to treat every `waitbutton` as already-paid-for by the text box that +-- usually precedes it, so pokepic / closepokepic ran inside one VM resume and +-- the pic was created and destroyed without a single frame drawing it (#911). +-- +-- This driver stands in front of the Cyndaquil ball, presses A, and counts the +-- frames where World.pokePic is actually up. It shoots the popup, so a human +-- can see the pic and not just a number. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_starter_pic.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-starter \ +-- perl -e 'alarm 280; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-starter" + +-- main.lua's love.visible / love.focus handlers call Game:visible / Game:focus, +-- and src/core/Game2.lua defines neither -- so on Gold any window event (another +-- app taking the screen, which is constant when drivers share a machine) kills +-- the run before a single assertion prints. Fixing that belongs in Game2, not +-- in a driver; surviving it belongs here, so this run swallows the two +-- callbacks. Loaded from love.load, i.e. after main.lua has installed its own. +function love.visible() end +function love.focus() end + +return function(game) + local w = game.world + local fails = 0 + + local function wait(n) for _ = 1, n do coroutine.yield() end end + + local function ok(cond, msg) + if cond then + print("[starter] ok " .. msg) + else + fails = fails + 1 + print("[starter] FAIL " .. msg) + end + return cond + end + + local function shot(name) + local path = SHOT_DIR .. "/" .. name .. ".png" + game.capturePath = path + for _ = 1, 120 do + if not game.capturePath then break end + coroutine.yield() + end + wait(1) + local f = io.open(path, "rb") + if f then f:close() return true end + print("[starter] FAIL screenshot did not reach disk: " .. path) + fails = fails + 1 + return false + end + + local function tap(btn) + table.insert(game.input.pressQueue, btn) + coroutine.yield() + game.input.state[btn] = false + end + + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + wait(45) + + -- SCENE_ELMSLAB_NOTHING: the meet-Elm walk-in has already played, which is + -- the state a player is in when they walk over to the balls. + w.mapScenes.ELMS_LAB = 1 + w:setMap("ELMS_LAB", 6, 4, "up") + wait(20) + + local shown, hidden = 0, 0 + local realShow, realHide = w.showPokePic, w.hidePokePic + w.showPokePic = function(self, species) + shown = shown + 1 + return realShow(self, species) + end + + tap("a") + + -- Count the frames the pic is actually up. The pic is a field on the world + -- rather than a pushed state, so this is the same thing love.draw reads. + local picFrames, sawPic = 0, false + for i = 1, 260 do + if w.pokePic then + picFrames = picFrames + 1 + if not sawPic then + sawPic = true + shot("01_starter_popup") + end + end + if sawPic and not w.pokePic then break end + -- Press A again only once the pic has been on screen a while, so the + -- driver measures the hold instead of ending it on frame one. + if picFrames == 90 then tap("a") end + if i % 4 == 0 and not sawPic then tap("a") end + coroutine.yield() + end + + ok(shown > 0, "the ball script ran `pokepic` (" .. shown .. " call(s))") + ok(picFrames > 0, + "and the pic was on screen for " .. picFrames .. " drawn frame(s)") + ok(picFrames >= 30, + "long enough to read: `waitbutton` holds it until the player presses A") + + -- The yes/no follows the pic, so a run that got this far is at the prompt. + for _ = 1, 120 do + if w.choicebox or game.stack:top() ~= game.overworld then break end + coroutine.yield() + end + -- The box opens empty and types itself in over the next few frames, so a + -- capture on the frame it appeared would photograph a blank window and + -- prove nothing about the question. + wait(60) + shot("02_confirm_prompt") + + if fails > 0 then + error(("gold starter pic: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold starter pic: pokepic holds until A, then asks") +end diff --git a/tests/drivers/gold_sudowoodo_twins.lua b/tests/drivers/gold_sudowoodo_twins.lua new file mode 100644 index 00000000..c8f3a29b --- /dev/null +++ b/tests/drivers/gold_sudowoodo_twins.lua @@ -0,0 +1,130 @@ +-- Route 36's Sudowoodo and Route 37's TWINS ANN & ANNE share ONE +-- wVariableSprites slot, and the fight is what repaints it. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_sudowoodo_twins.lua love . +-- +-- maps/Route36.asm:486 gives the tree `SPRITE_WEIRD_TREE`, and maps/Route37.asm +-- :237-238 give BOTH twins the same byte -- it is a SLOT ($f4), not a sheet. +-- InitializeEventsScript seeds the slot with SPRITE_SUDOWOODO, and +-- WateredWeirdTreeScript's `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN` +-- (maps/Route36.asm:58, and again at :70 on the DidntCatchSudowoodo arm) is the +-- only thing that ever repaints it. Miss that command and the two girls on +-- Route 37 are drawn as a pair of Sudowoodo. +-- +-- Shots land in /tmp/gold-twins: the twins before the fight (Sudowoodo, which +-- is what the cart draws too -- the tree blocks the only road north), the tree +-- itself, and the twins after. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local function twinSprites(world) + local out = {} + for _, npc in ipairs(world.npcs) do + if npc.def and (npc.def.index == 1 or npc.def.index == 2) then + out[#out + 1] = (npc.spriteDef and npc.spriteDef.id) or "?" + end + end + return out +end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-twins" + + 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 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + print(("[driver] slot 4 seeds to %s (SPRITE_SUDOWOODO is 82)") + :format(tostring(world.variableSprites[4]))) + + -- Before: Route 37, the two twins standing at (6,12) and (7,12). + world:setMap("ROUTE_37", 6, 14, "up") + U.wait(20) + print("[driver] twins before: " .. table.concat(twinSprites(world), ", ")) + U.shot(game, out .. "/00-twins-before.png") + + -- The fight. A SQUIRTBOTTLE in the bag is what turns the A press into + -- WateredWeirdTreeScript rather than the shake-and-nothing arm. + local Bag = require("src.inventory.Bag") + local starter = Mon.new(game.data, "TYPHLOSION", 60) + assert(starter, "could not build a TYPHLOSION") + game.save.party = { starter } + Bag.add(game.save, "SQUIRTBOTTLE", 1, game.data) + + world:setMap("ROUTE_36", 34, 9, "right") + U.wait(20) + U.shot(game, out .. "/01-tree.png") + world:interact() + U.wait(4) + + -- yesorno: "Use the SQUIRTBOTTLE?" -> YES is the default cursor row. + local battle + for _ = 1, 400 do + local top = game.stack:top() + if top and top.battle then battle = top break end + tap("a", 2) + end + assert(battle, "the Sudowoodo battle never started") + U.shot(game, out .. "/02-battle.png") + + local attackSlot = 1 + for i, move in ipairs(starter.moves) do + local def = game.data.moves and game.data.moves[move.id] + if def and (def.power or 0) > 0 then attackSlot = i break end + end + for _ = 1, 900 do + if battle.battle.over then break end + if battle.phase == "menu" then + tap("a") + U.wait(4) + for _ = 2, attackSlot do tap("down", 2) end + tap("a") + else + tap("a", 3) + end + end + assert(battle.battle.over, "the Sudowoodo battle did not resolve") + print("[driver] outcome " .. tostring(battle.battle.outcome)) + + for _ = 1, 300 do + if not world:busy() then break end + tap("a", 2) + end + print(("[driver] slot 4 after the fight: %s (SPRITE_TWIN is 38)") + :format(tostring(world.variableSprites[4]))) + U.shot(game, out .. "/03-after-fight.png") + + -- The half a plain setMap cannot see. Route 37 is a CONNECTION of Route 36, + -- so its objects are already pooled as ghosts on the neighbor strip -- with + -- the sheet the slot held when they were pooled, i.e. SPRITE_SUDOWOODO -- + -- and walking north is a SEAMLESS setMap that KEEPS World.npcPool. Only + -- World:repaintVariableSpritePool hands them the new sheet. + for _, key in ipairs({ "ROUTE_37_obj_1", "ROUTE_37_obj_2" }) do + local ghost = world.npcPool[key] + print(("[driver] pooled %s: %s"):format(key, + ghost and tostring(ghost.spriteDef and ghost.spriteDef.id) or "not pooled")) + assert(not ghost or (ghost.spriteDef and ghost.spriteDef.id) == "SPRITE_TWIN", + key .. " is still pooled as " .. tostring(ghost.spriteDef and ghost.spriteDef.id)) + end + world:setMap("ROUTE_37", 6, 14, "up", { seamless = true }) + U.wait(20) + local after = twinSprites(world) + print("[driver] twins after: " .. table.concat(after, ", ")) + U.shot(game, out .. "/04-twins-after.png") + assert(world.variableSprites[4] == 38, + "wVariableSprites[SPRITE_WEIRD_TREE] is " + .. tostring(world.variableSprites[4]) .. ", wanted 38 (SPRITE_TWIN)") + for _, id in ipairs(after) do + assert(id == "SPRITE_TWIN", "a twin is drawn as " .. tostring(id)) + end + print("[driver] PASS gold sudowoodo -> twins in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_summary_shots.lua b/tests/drivers/gold_summary_shots.lua new file mode 100644 index 00000000..dc4a0771 --- /dev/null +++ b/tests/drivers/gold_summary_shots.lua @@ -0,0 +1,125 @@ +-- Screenshots of the mon SUMMARY (engine/pokemon/stats_screen.asm), which is +-- the one thing tests/gen2_summary_test.lua cannot check: it asserts every +-- hlcoord, but not whether the three pages read like Gold's. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_summary_shots.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-summary (default) +-- +-- Boots into the world (Game2 skips the cinema under POKEPORT_DRIVER), +-- builds a party through the ONE Gen 2 builder so the mons actually have +-- stats, moves, PP and experience, then puts each page up. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-summary" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + local function show(name, state) + game.stack:push(state) + shot(name) + game.stack:pop() + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.player.name = "GOLD" + save.player.id = 12345 + + -- Mon.new is the only party-member builder: anything routed through Gen 1's + -- Pokemon.new comes back with no moves at all, because a Gen 2 moveset is + -- `levelMoves` and Gen 1 reads level1Moves / learnset. + local function build(species, level, opts) + opts = opts or {} + local mon = Mon.new(game.data, species, level, { + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + }) + assert(mon, "no base data for " .. species) + mon.nickname = opts.nickname or mon.name + mon.otName = save.player.name + mon.otId = save.player.id + for key, value in pairs(opts.fields or {}) do mon[key] = value end + -- Spend some PP so the PP columns are not four identical pairs, and take + -- a bite out of the HP so the bar is not always full green. + for i, move in ipairs(mon.moves or {}) do + move.pp = math.max(0, (move.maxPp or move.pp) - i * 3) + end + return mon + end + + save.party = { + -- A held item, a status, and a dual-typed third mon so the pink page shows + -- both type rows. + build("CYNDAQUIL", 22, { fields = { item = "BERRY" } }), + build("TOTODILE", 18, { fields = { status = "psn" } }), + build("GASTLY", 15, {}), + } + save.party[1].hp = math.floor(save.party[1].maxHp * 0.4) + save.party[2].hp = math.floor(save.party[2].maxHp * 0.15) + -- Part way to the next level, so the exp bar is not empty or full. + local growth = game.data.pokemon.growthRates + local function partWay(mon) + local def = game.data.pokemon[mon.species] + local rate = growth and def and growth[def.growthRate] + if not rate then return end + local base = Mon.experienceForLevel(rate, mon.level) + local next_ = Mon.experienceForLevel(rate, mon.level + 1) + mon.experience = base + math.floor((next_ - base) * 0.6) + end + for _, mon in ipairs(save.party) do partWay(mon) end + + -- The party list the summary is opened from, and the action submenu STATS + -- lives in (engine/pokemon/mon_submenu.asm). + local party = PartyMenu.new(game, { prompt = "choose", submenu = true }) + show("00-party", party) + party:openSubmenu() + show("01-mon-submenu", party) + party:closeSubmenu() + + -- The three pages, in the order .d_right walks them. + local function page(n) + local screen = SummaryMenu.new(game, { + party = save.party, index = 1, save = save, page = n, + }) + return screen + end + show("02-pink-page", page(SummaryMenu.PINK_PAGE)) + show("03-green-page", page(SummaryMenu.GREEN_PAGE)) + show("04-blue-page", page(SummaryMenu.BLUE_PAGE)) + + -- ...and the same three for the poisoned mon, whose HP bar is red and whose + -- status line is not OK. + local hurt = SummaryMenu.new(game, { + party = save.party, index = 2, save = save, + }) + show("05-pink-page-poisoned", hurt) + hurt.page = SummaryMenu.BLUE_PAGE + show("06-blue-page-second-mon", hurt) + + -- The dual-typed mon, for the second TYPE row. + local ghost = SummaryMenu.new(game, { + party = save.party, index = 3, save = save, + }) + show("07-pink-page-dual-type", ghost) + + -- PlaceMoveData's screen, which is where the cart shows a move description. + local detail = SummaryMenu.new(game, { + party = save.party, index = 1, save = save, + page = SummaryMenu.GREEN_PAGE, + }) + detail.moveDetail = true + show("08-move-description", detail) + detail.moveIndex = 2 + show("09-move-description-second", detail) + + print("[driver] PASS gold summary shots in " .. out) +end diff --git a/tests/drivers/gold_teacher_scene.lua b/tests/drivers/gold_teacher_scene.lua new file mode 100644 index 00000000..948ea770 --- /dev/null +++ b/tests/drivers/gold_teacher_scene.lua @@ -0,0 +1,103 @@ +-- NewBarkTown_TeacherStopsYouScene1/2, the coord event that stops you leaving +-- New Bark Town before Elm has given you a mon. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_teacher_scene.lua love . +-- +-- What this is watching for: +-- * `follow NEWBARKTOWN_TEACHER, PLAYER` drags the player back into town +-- behind her. Without it the player never leaves the coord event's tile, +-- the scene fires again the moment it ends, and she is back at her spawn +-- starting the same speech over -- which is what "she jumps back to her +-- original spot" looks like from the outside. +-- * she should be standing NEXT TO the player for the middle line, not back +-- at (6,8). +-- +-- Shots land in /tmp/gold-teacher; the position trace goes to stdout. +local U = require("tests.drivers.util") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-teacher" + + 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(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU is scene 0, the map's starting scene, + -- so a fresh save is already in it. + world:setMap("NEW_BARK_TOWN", 2, 8, "left") + U.wait(30) + U.shot(game, out .. "/00-before.png") + + local function teacher() + for _, npc in ipairs(world.npcs or {}) do + if npc.def and npc.def.index == 1 then return npc end + end + return nil + end + + local function trace(tag) + local t = teacher() + print(("[driver] %-14s player=(%d,%d) teacher=(%s,%s) scene=%d busy=%s") + :format(tag, world.player.cellX, world.player.cellY, + t and tostring(t.cellX) or "-", t and tostring(t.cellY) or "-", + world:scene(), tostring(world:busy()))) + end + + trace("start") + -- One step left onto (1,8), the coord event's tile. + tap("left", 30) + trace("stepped") + + -- Page the scene through. The middle shot is the one that matters: she has + -- to be standing next to the player, not back at her spawn. + local shots, adjacent = 0, false + for step = 1, 400 do + local t = teacher() + if t and math.abs(t.cellX - world.player.cellX) <= 1 + and t.cellY == world.player.cellY then + if not adjacent then + adjacent = true + U.shot(game, out .. "/01-she-is-here.png") + trace("adjacent") + end + end + if step % 25 == 0 then + shots = shots + 1 + U.shot(game, ("%s/02-scene-%02d.png"):format(out, shots)) + trace("frame " .. shots) + end + -- Done when the scene has finished AND the player has been walked off the + -- trigger tile. + if not world:busy() and world.player.cellX ~= 1 and step > 20 then break end + tap("a", 4) + end + trace("scene over") + U.shot(game, out .. "/03-after.png") + + -- Hands off for a second: nothing may re-trigger. + local restarted = false + for _ = 1, 120 do + if world:busy() then restarted = true end + U.wait(1) + end + trace("idle") + + local dragged = world.player.cellX ~= 1 + print(("[driver] the player was walked back into town: %s") + :format(tostring(dragged))) + print(("[driver] she stood next to the player mid-scene: %s") + :format(tostring(adjacent))) + print(("[driver] the scene re-triggered while idle: %s") + :format(tostring(restarted))) + print(("[driver] %s gold teacher scene in %s") + :format((dragged and adjacent and not restarted) and "PASS" or "FAIL", out)) + love.event.quit() +end diff --git a/tests/drivers/gold_tilt_shot.lua b/tests/drivers/gold_tilt_shot.lua new file mode 100644 index 00000000..b0357f7a --- /dev/null +++ b/tests/drivers/gold_tilt_shot.lua @@ -0,0 +1,20 @@ +-- TILT on the Gen 2 overworld: the world pass rendered into a canvas and +-- projected onto the perspective quad, with the flat frame beside it for +-- comparison. Shots land in /tmp/gold-tilt. +local U = require("tests.drivers.util") +local Tilt = require("src.render.Tilt") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-tilt" + U.wait(45) + U.shot(game, out .. "/00-flat.png") + for level = 1, 3 do + Tilt.setLevel(level) + -- The angle eases in, so let the tween finish before the shot. + U.wait(60) + U.shot(game, ("%s/%02d-tilt%d.png"):format(out, level, level)) + end + Tilt.setLevel(0) + U.wait(60) + print("[driver] PASS gold tilt shots in " .. out) +end diff --git a/tests/drivers/gold_title_shots.lua b/tests/drivers/gold_title_shots.lua new file mode 100644 index 00000000..23a25bcb --- /dev/null +++ b/tests/drivers/gold_title_shots.lua @@ -0,0 +1,50 @@ +-- The Gold title screen, once per COLOR mode. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_title_shots.lua love . +-- +-- Two things about this screen only a human can check, and both of them are +-- what this driver puts on disk: +-- +-- * Ho-Oh's placement. `depixel 12, 11` is OAM (x 88, y 96), and OAM is +-- biased by (-8, -16), so the bird's 64 pixels land on 48..112 -- dead +-- centre of the 160-wide screen. A shot where it sits right of centre +-- means the bias (or the y-then-x argument order) has been dropped again. +-- * Ho-Oh under DMG and CLASSIC. LoadTitleScreenPals writes rOBP0 = +-- %11111111 on a non-CGB screen, mapping all four of the pic's colours to +-- shade 3, so the bird is a solid BLACK silhouette there rather than the +-- shaded pose a straight 2bpp decode gives. +-- +-- Writes to /tmp/gold-title (POKEPORT_SHOT_DIR to move it). +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local TitleState = require("src.ui.gen2.TitleState") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-title" + + U.wait(10) + game:showTitle() + U.wait(20) + local title = game.stack:top() + assert(getmetatable(title) == TitleState, + "showTitle left " .. tostring(title) .. " on the stack") + assert(title.hoohX == 48 and title.hoohY == 56, + ("Ho-Oh is at (%s, %s), expected (48, 56) -- stale cache?") + :format(tostring(title.hoohX), tostring(title.hoohY))) + + local previous = GbcPalette.mode + for _, mode in ipairs(GbcPalette.MODES) do + GbcPalette.setMode(mode) + -- Land on the same wing-flap frame in every mode so the three shots + -- differ only in colour: the frameset runs on its own timer. + title.seqIndex, title.frame, title.seqLeft = 1, 1, 999 + title.hoohPhase = 0 + U.wait(2) + U.shot(game, ("%s/title-%s.png"):format(out, mode)) + print(("[driver] %s"):format(GbcPalette.modeLabel(mode))) + end + GbcPalette.setMode(previous) + + print("[driver] PASS gold title shots in " .. out) +end diff --git a/tests/drivers/gold_touch_controls.lua b/tests/drivers/gold_touch_controls.lua new file mode 100644 index 00000000..de2cb6aa --- /dev/null +++ b/tests/drivers/gold_touch_controls.lua @@ -0,0 +1,119 @@ +-- The mobile on-screen pad, on Gold. Gen 1 has had it since #415 +-- (src/core/TouchControls.lua, Xelu's CC0 art in assets/touch/); Gold drew +-- nothing at all, so a phone player without a controller had no way to press +-- anything. Same module, same art, same options.touchControls layout -- what +-- was missing was every seam in src/core/Game2.lua that has to reach it. +-- +-- POKEPORT_TOUCH=1 POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_touch_controls.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-touch (default) +-- +-- POKEPORT_TOUCH=1 is what forces the overlay onto a desktop, and main.lua +-- then routes the mouse into love.touch* as a stand-in finger, which is the +-- same path a real finger takes -- so driving Game2:touchpressed here exercises +-- exactly what a phone does. +-- +-- 01-pad.png the pad over the overworld, nothing held +-- 02-pressed.png A and RIGHT held, both controls lit +-- +-- The assertions are the half a picture cannot show: that a press on a control +-- actually reaches Input as a GB button under the overlay's own source name, +-- that the d-pad swaps direction on a slide without ever double-holding, that a +-- captured touch is NOT offered to the mod pointer hook, and that a controller +-- press puts the pad away. +local U = require("tests.drivers.util") + +local Input = require("src.core.Input") +local TouchControls = require("src.core.TouchControls") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-touch" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[touch] ok " .. label) + else + failures = failures + 1 + print("[touch] FAIL " .. label .. " " .. tostring(detail)) + end + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + ok("Game2 owns the shared overlay", game.touchControls == TouchControls) + ok("POKEPORT_TOUCH=1 forces it on for this desktop run", + TouchControls.active == true) + ok("and it has art to draw", TouchControls:visible(), + tostring(TouchControls.img)) + if not TouchControls:visible() then + print("FAIL gold_touch_controls (no overlay)") + love.event.quit(1) + return + end + + local L = TouchControls:layout() + U.shot(game, out .. "/01-pad.png") + + -- A: press, hold, release. The overlay presses GB buttons through + -- Input:overlayPressed rather than a keyboard alias, so isTouchDown is the + -- proof it went through the pad and not through some other source. + game:touchpressed("f1", L.a.cx, L.a.cy) + ok("a finger on A holds GB A", Input:isDown("a")) + ok("under the overlay's own input source", Input:isTouchDown("a")) + + -- The d-pad, and the slide between directions the pad exists for. + game:touchpressed("f2", L.dpad.cx + L.dpad.w * 0.4, L.dpad.cy) + ok("a finger right of the d-pad centre holds RIGHT", Input:isDown("right")) + U.shot(game, out .. "/02-pressed.png") + game:touchmoved("f2", L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4) + ok("sliding it up swaps the hold to UP", Input:isDown("up")) + ok("and RIGHT is no longer held", not Input:isDown("right")) + + game:touchreleased("f2", L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4) + ok("lifting it drops UP", not Input:isDown("up")) + ok("without dropping A, which another finger still owns", Input:isDown("a")) + game:touchreleased("f1", L.a.cx, L.a.cy) + ok("and lifting that finger drops A", not Input:isDown("a")) + + -- Two fingers on one button: the second must not double-press it, and + -- lifting one must not release the other's hold (TouchControls.held counts). + game:touchpressed("f3", L.b.cx, L.b.cy) + game:touchpressed("f4", L.b.cx + 1, L.b.cy + 1) + game:touchreleased("f3", L.b.cx, L.b.cy) + ok("two fingers on B: lifting one keeps B held", Input:isDown("b")) + game:touchreleased("f4", L.b.cx + 1, L.b.cy + 1) + ok("lifting the second drops it", not Input:isDown("b")) + + -- The pointer seam (#807): a touch the pad captured belongs to the pad for + -- its whole life and must never be offered to a mod. + game.modPointers = nil + game:touchpressed("f5", L.start.cx, L.start.cy) + ok("a captured touch never becomes a mod pointer", + game.modPointers == nil or game.modPointers.f5 == nil) + game:touchreleased("f5", L.start.cx, L.start.cy) + + -- A controller press puts the pad away until the next screen touch. The + -- release matters: `a` is GB A on the pad map too, and a hold left standing + -- here would be indistinguishable from the overlay pressing it below. + game:gamepadpressed(nil, "a") + game:gamepadreleased(nil, "a") + ok("a controller press hides the pad", not TouchControls:visible()) + game:touchpressed("f6", L.a.cx, L.a.cy) + ok("and the next touch only brings it back, it does not press", + TouchControls:visible() and not Input:isDown("a")) + game:touchreleased("f6", L.a.cx, L.a.cy) + + -- SELECT on a controller. `back` is SDL's name for the PS CREATE/SHARE + -- button (and Xbox VIEW, and Switch MINUS); src/core/GamepadMap.lua maps it + -- to GB SELECT, and Game2 used to swallow it with love.event.quit(). + game:gamepadpressed(nil, "back") + ok("the pad's back/CREATE button presses GB SELECT", Input:isDown("select")) + game:gamepadreleased(nil, "back") + ok("and releases it", not Input:isDown("select")) + + print(failures == 0 and "PASS gold_touch_controls" + or ("FAIL gold_touch_controls (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_trainer_smoke.lua b/tests/drivers/gold_trainer_smoke.lua new file mode 100644 index 00000000..65523daa --- /dev/null +++ b/tests/drivers/gold_trainer_smoke.lua @@ -0,0 +1,136 @@ +-- Smoke: a Route 30 trainer spots the player, walks up, and battles. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_trainer_smoke.lua love . +-- +-- This is the whole overworld trainer path in one run: the `trainer` struct the +-- extractor now reads off OBJECTTYPE_TRAINER objects, the eyesight test from +-- home/trainers.asm, the approach walk, the seen text, a real battle against +-- the class's extracted party, and the beat flag that stops it re-triggering. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-trainer" + + 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 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- A trainer only challenges a player who has a party, the same as the cart. + local starter = Mon.new(game.data, "CYNDAQUIL", 20) + assert(starter, "could not build a CYNDAQUIL from pokemon.lua") + game.save.party = { starter } + + world:setMap("ROUTE_30", 5, 33, "up") + U.wait(20) + assert(world.map.id == "ROUTE_30", "setMap: " .. tostring(world.map.id)) + + -- Object 4 is Route 30's BUG_CATCHER, sight 3, standing at (4,7). JOEY + -- (object 2) is the more famous one but InitializeEventsScript hides him + -- until the Mr. Pokemon errand, so he is not on the map yet. + local foe + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.trainer and npc.def.index == 4 then foe = npc end + end + assert(foe, "Route 30's BUG_CATCHER object has no trainer struct") + assert(foe.def.trainer.class == 36 and foe.def.trainer.member == 1, + ("expected BUG_CATCHER member 1, got class %s member %s"):format( + tostring(foe.def.trainer.class), tostring(foe.def.trainer.member))) + assert(foe.def.sight == 3, + "expected sight 3, got " .. tostring(foe.def.sight)) + assert(not world:trainerBeaten(foe.def.trainer), "the trainer starts beaten") + + -- Put the player in his line of sight, three cells below him, and face him + -- down the column so the eyesight test fires on the next settled step. + foe.facing = "down" + world.player.cellX, world.player.cellY = foe.cellX, foe.cellY + 3 + world.player.px = world.player.cellX * 16 + world.player.py = world.player.cellY * 16 + + local fired = false + for _ = 1, 60 do + if world:busy() then fired = true break end + world:checkTrainerBattle() + U.wait(1) + end + assert(fired, "the trainer never noticed the player") + U.shot(game, out .. "/01-spotted.png") + + -- PlayTrainerEncounterMusic plays the CLASS's own jingle while he walks up + -- (data/trainers/encounter_music.asm), not the battle theme; PlayBattleMusic + -- swaps that in a moment later, when the transition starts. + local Music = require("src.core.Music") + local encounter = Music.current() + print("[driver] encounter music " .. tostring(encounter)) + assert(encounter and encounter:match("^Music_Look"), + "expected a Music_Look* encounter jingle, got " .. tostring(encounter)) + + -- The bubble is up and he closes to one cell short of the player. + local sawEmote = world.emote ~= nil + for _ = 1, 240 do + if world.emote then sawEmote = true end + if game.stack:top() ~= nil and game.stack:top().battle then break end + tap("a", 2) + end + assert(sawEmote, "no ! bubble was shown") + assert(math.abs(foe.cellY - world.player.cellY) == 1, + ("the trainer stopped %d cells away, expected 1"):format( + math.abs(foe.cellY - world.player.cellY))) + + local battle = game.stack:top() + assert(battle and battle.battle, "no battle screen after the seen text") + assert(battle.battle.trainer, "battle is not a trainer battle") + print("[driver] battle music " .. tostring(Music.current())) + assert(Music.current() == "Music_JohtoTrainerBattle", + "a Johto bug catcher should fight to the Johto trainer theme, got " + .. tostring(Music.current())) + print("[driver] fighting " .. tostring(battle.battle.trainer.name)) + assert(#battle.battle.trainer.party > 0, "trainer party is empty") + U.shot(game, out .. "/02-battle.png") + + -- Pick a damaging move rather than slot 1: CYNDAQUIL's L20 window leads + -- with LEER, and two attackers who cannot hurt each other never finish. + local attackSlot = 1 + for i, move in ipairs(starter.moves) do + local def = game.data.moves and game.data.moves[move.id] + if def and (def.power or 0) > 0 then attackSlot = i break end + end + for _ = 1, 600 do + if battle.battle.over then break end + if battle.phase == "menu" then + tap("a") -- FIGHT + U.wait(4) + for _ = 2, attackSlot do tap("down", 2) end + tap("a") + else + tap("a", 3) + end + end + assert(battle.battle.over, + "trainer battle did not resolve (phase " .. tostring(battle.phase) .. ")") + assert(battle.battle.outcome == "win", + "expected the L20 starter to win, got " .. tostring(battle.battle.outcome)) + + -- The after-battle text runs, then the beat flag stops the rematch. + for _ = 1, 200 do + if not world:busy() then break end + tap("a", 2) + end + assert(not world:busy(), "the trainer script never finished") + assert(world:trainerBeaten(foe.def.trainer), + "the beat flag was not set after the win") + assert(not world:checkTrainerBattle(), + "a beaten trainer challenged the player again") + U.shot(game, out .. "/03-after.png") + + print("[driver] PASS gold overworld trainer battle in " .. out) +end diff --git a/tests/drivers/gold_transition_shots.lua b/tests/drivers/gold_transition_shots.lua new file mode 100644 index 00000000..f4589131 --- /dev/null +++ b/tests/drivers/gold_transition_shots.lua @@ -0,0 +1,64 @@ +-- DoBattleTransition, frame by frame. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_transition_shots.lua love . +-- +-- Shoots each of the four outros (spin / speckle / sine / zoom) plus the Poke +-- Ball overlay a trainer battle stamps over the map first. POKEPORT_SHOT_INTERVAL +-- picks the sampling; the default walks the whole thing at 6 frames. +-- +-- Shots land in /tmp/gold-transition/<style>/. +local U = require("tests.drivers.util") + +local Transition = require("src.ui.gen2.BattleTransition") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-transition" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 6 + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + world:setMap("NEW_BARK_TOWN", 5, 8, "down") + U.wait(20) + + local function run(style, trainer) + local done = false + game.stack:push(Transition.new(game, { + world = world, + style = style, + trainer = trainer, + onDone = function() done = true end, + })) + -- Shoot the flash sparsely and the outro (the part with the shape in it) + -- at every interval, so a nine-shot run is not all palette pulses. + local state = game.stack:top() + local shots, outroShots = 0, 0 + for frame = 1, 600 do + local outro = state.phase == "outro" + local want = outro and (outroShots % interval == 0) + or (not outro and frame % 24 == 1) + if want then + shots = shots + 1 + U.shot(game, ("%s/%s/%s-%02d.png") + :format(out, style, outro and "outro" or "flash", shots)) + end + if outro then outroShots = outroShots + 1 end + if done then break end + U.wait(1) + end + print(("[driver] %-8s %d shots, finished=%s") + :format(style, shots, tostring(done))) + -- The state pops itself; if it did not, take it off so the next run starts + -- from a clean stack. + if not done then game.stack:pop() end + U.wait(5) + end + + run("spin", true) + run("speckle", false) + run("sine", false) + run("zoom", false) + + print("[driver] shots in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_travel_probe.lua b/tests/drivers/gold_travel_probe.lua new file mode 100644 index 00000000..59377aac --- /dev/null +++ b/tests/drivers/gold_travel_probe.lua @@ -0,0 +1,181 @@ +-- Navigation probe: can the bot WALK from A to B? +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=05 \ +-- POKEPORT_GOLD_PROBE="CHERRYGROVE_CITY>ILEX_FOREST,VIOLET_CITY>AZALEA_TOWN" \ +-- POKEPORT_DRIVER=tests/drivers/gold_travel_probe.lua love . +-- +-- Every teleport the route bot logs is a map the planner could not reach, and +-- each one used to cost a ten-minute run to reproduce. This puts the player at +-- the start map with the checkpoint's badges and HMs, asks Bot:travelTo for the +-- destination, and reports hops and frames -- so a planner change is a +-- one-minute experiment instead of a full replay. +-- +-- The placement itself is A.teleport, the same harness shortcut the bot falls +-- back on; it is the SETUP here, never the measurement. What is measured is +-- only the walk that follows. + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +local DEFAULT = "CHERRYGROVE_CITY>ILEX_FOREST" + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + if not A.ready(game) then + print("[probe] the world never came up") + return + end + + local resume = os.getenv("POKEPORT_GOLD_RESUME") + if resume then + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[probe] cannot resume %s: %s"):format(resume, tostring(err))) + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + end + + -- Single-map walk probe: "ROUTE_32@18,6>6,79" places the player and asks for + -- one local walk. Travel failures usually bottom out in one map's local + -- pathfinding, and isolating that is the difference between a 60k-frame + -- reproduction and a 400-frame one. + local walkSpec = os.getenv("POKEPORT_GOLD_WALK") + if walkSpec then + for spec in walkSpec:gmatch("[^;]+") do + local map, sx, sy, gx, gy = + spec:match("^%s*([%w_]+)@(%-?%d+),(%-?%d+)>(%-?%d+),(%-?%d+)%s*$") + if not map then + print(("[probe] cannot parse walk %q"):format(spec)) + else + if not A.teleport(game, map, tonumber(sx), tonumber(sy)) then + print(("[probe] cannot place the player on %s"):format(map)) + else + bot:wait(30) + bot:clearDialogue() + bot:progress() + local start = bot:frames() + local ok, res = pcall(function() + return bot:walkTo(tonumber(gx), tonumber(gy)) + end) + local px, py = A.pos(game) + print(("[probe] walk %s: %s in %d frames, ended at (%s,%s) on %s") + :format(spec, (ok and res) and "ok" or "FAIL", + bot:frames() - start, tostring(px), tostring(py), + tostring(A.mapId(game)))) + -- Who is standing where, now. An NPC blocks a step exactly like a + -- wall but appears nowhere in the extracted map, so a corridor that + -- looks two cells wide on paper can be one cell wide in play -- which + -- is the difference between "the planner is wrong" and "there is no + -- way round". + local map = A.map(game) + if map and px then + local near = {} + for cy = math.max(0, py - 6), math.min(map.heightCells - 1, py + 6) do + for cx = 0, map.widthCells - 1 do + if A.npcAt(game, cx, cy) then + near[#near + 1] = ("(%d,%d)"):format(cx, cy) + end + end + end + print(("[probe] npcs within 6 rows: %s") + :format(#near > 0 and table.concat(near, " ") or "none")) + end + bot:progress() + end + end + end + return + end + + local pairsSpec = os.getenv("POKEPORT_GOLD_PROBE") or DEFAULT + local budget = tonumber(os.getenv("POKEPORT_GOLD_PROBE_BUDGET")) or 60000 + + local results = {} + for spec in pairsSpec:gmatch("[^,]+") do + -- "A>B" or "A>B#N", where N pins which REGION of B counts as arriving. + -- Split maps are the whole reason travelTo grew a region argument + -- (TEAM_ROCKET_BASE_B3F), so the probe has to be able to ask for one or it + -- cannot test the thing that matters. + local from, to, region = spec:match("^%s*([%w_]+)%s*>%s*([%w_]+)%s*#(%d+)%s*$") + if not from then + from, to = spec:match("^%s*([%w_]+)%s*>%s*([%w_]+)%s*$") + end + region = tonumber(region) + if not from then + print(("[probe] cannot parse %q"):format(spec)) + else + -- Place the player at `from` (setup, not navigation), then walk. + local defs = bot:mapDefs() + local def = defs[from] + local landing = def and (def.warps or {})[1] + local placed + if landing then + placed = A.teleport(game, from, landing.x, landing.y) + else + placed = A.teleport(game, from, 0, 0) + end + if not placed then + print(("[probe] cannot place the player on %s"):format(from)) + results[#results + 1] = { spec = spec, ok = false, why = "no placement" } + else + bot:wait(30) + bot:clearDialogue() + bot:progress() + local start = bot:frames() + local hops = 0 + local origSay = bot.say + -- Count hops without threading a counter through the core. + bot.say = function(self, ...) + local line = table.concat({ ... }, " ") + if type((...)) == "string" and (...):match("^hop ") then + hops = hops + 1 + end + return origSay(self, ...) + end + local ok, res = pcall(function() return bot:travelTo(to, region) end) + bot.say = origSay + local spent = bot:frames() - start + if not ok then + results[#results + 1] = { spec = spec, ok = false, frames = spent, + hops = hops, + why = (type(res) == "table" and res.why) + or tostring(res) } + else + results[#results + 1] = { spec = spec, ok = res, frames = spent, + hops = hops, + why = res and "" or "no route", + landed = tostring(A.mapId(game)) } + end + bot:progress() + if spent > budget then + print("[probe] budget spent, stopping") + break + end + end + end + end + + print("") + print("================ travel probe ================") + local passed = 0 + for _, r in ipairs(results) do + if r.ok then passed = passed + 1 end + print(("%-4s %-46s %7s frames %2s hops %s") + :format(r.ok and "ok" or "FAIL", r.spec, tostring(r.frames or "-"), + tostring(r.hops or "-"), + r.ok and "" or ("%s (on %s)"):format(tostring(r.why), + tostring(r.landed)))) + end + print(("%d/%d reachable by walking"):format(passed, #results)) + print("==============================================") +end diff --git a/tests/drivers/gold_unown_scientist.lua b/tests/drivers/gold_unown_scientist.lua new file mode 100644 index 00000000..8bb94ef6 --- /dev/null +++ b/tests/drivers/gold_unown_scientist.lua @@ -0,0 +1,103 @@ +-- "The guy who tells you about UNOWN never comes out." The whole chain, live. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_unown_scientist.lua love . +-- +-- RuinsOfAlphOutsideScientistCallback (maps/RuinsOfAlphOutside.asm:22) is a +-- MAPCALLBACK_OBJECTS with THREE gates, and the scientist only appears when all +-- three answer: +-- +-- checkflag ENGINE_UNOWN_DEX -- must still be CLEAR +-- checkevent EVENT_MADE_UNOWN_APPEAR_IN_RUINS +-- readvar VAR_UNOWNCOUNT / ifgreater 2 +-- +-- The middle one is set by RuinsOfAlphInnerChamberStrangePresenceScript +-- (maps/RuinsOfAlphInnerChamber.asm:20), which is the `sdefer` on +-- SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE -- the scene a solved chamber +-- puzzle writes with `setmapscene` (maps/RuinsOfAlphKabutoChamber.asm:36). The +-- last is CountUnown over wUnownDex, which only a caught FORM grows. +-- +-- The run walks all three and prints which gate is standing, so a failure names +-- its own cause instead of "he is not there". Shots in /tmp/gold-unown. +local U = require("tests.drivers.util") +local Unown = require("src.core.gen2.Unown") + +local SCIENTIST = 2 -- def.objects index on RUINS_OF_ALPH_OUTSIDE + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-unown" + + 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(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local function standing(index) + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.index == index then return npc end + end + return nil + end + + -- Gate 1: he is NOT there before any of it. + world:setMap("RUINS_OF_ALPH_OUTSIDE", 11, 16, "up") + U.wait(20) + print("[driver] scientist before the chain: " .. tostring(standing(SCIENTIST) ~= nil)) + U.shot(game, out .. "/00-before.png") + local flag = world.map.def.objects[SCIENTIST].eventFlag + assert(not standing(SCIENTIST), "the scientist is out before the puzzle") + + -- The puzzle's own `setmapscene RUINS_OF_ALPH_INNER_CHAMBER, + -- SCENE_RUINSOFALPHINNERCHAMBER_STRANGE_PRESENCE`. Solving the sliding + -- panels is a UI, not a script, so stand in for that one command only. + world.mapScenes["RUINS_OF_ALPH_INNER_CHAMBER"] = 1 + + -- Gate 2: walking into the inner chamber must run the strange-presence + -- script and set EVENT_MADE_UNOWN_APPEAR_IN_RUINS. + world:setMap("RUINS_OF_ALPH_INNER_CHAMBER", 10, 20, "up") + -- The scene's `sdefer` only fires on the first settled World:step after the + -- load, so give it a few frames before the "is it still running" loop -- a + -- busy() test on frame one reads "already finished". + U.wait(30) + for _ = 1, 300 do + if not world:busy() then break end + tap("a", 2) + end + U.wait(20) + U.shot(game, out .. "/01-inner-chamber.png") + local madeAppear = world.events:get(46) + print("[driver] EVENT_MADE_UNOWN_APPEAR_IN_RUINS: " .. tostring(madeAppear)) + print("[driver] inner chamber scene is now " .. tostring(world.mapScenes["RUINS_OF_ALPH_INNER_CHAMBER"])) + assert(madeAppear, + "the strange-presence scene never set EVENT_MADE_UNOWN_APPEAR_IN_RUINS") + + -- Gate 3: three distinct Unown forms, the way AddPartyMon's + -- `.registerunowndex` grows wUnownDex. + for _, letter in ipairs({ "A", "B", "C" }) do + Unown.updateDex(game.save, letter) + end + print("[driver] VAR_UNOWNCOUNT is now " .. tostring(Unown.count(game.save))) + assert(Unown.count(game.save) == 3, "wUnownDex did not take three forms") + + world:setMap("RUINS_OF_ALPH_OUTSIDE", 11, 16, "up") + U.wait(20) + local npc = standing(SCIENTIST) + print(("[driver] scientist after the chain: %s (his flag %s is %s)") + :format(tostring(npc ~= nil), tostring(flag), + tostring(world.events:get(flag)))) + U.shot(game, out .. "/02-scientist.png") + assert(npc, "RuinsOfAlphOutsideScientistCallback never appeared the scientist") + + -- And he must have something to say: the scene id the callback set is what + -- his walk-you-to-the-lab script hangs off. + print("[driver] outside scene is now " + .. tostring(world.mapScenes["RUINS_OF_ALPH_OUTSIDE"])) + print("[driver] PASS gold Unown scientist in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_variablesprite_identity.lua b/tests/drivers/gold_variablesprite_identity.lua new file mode 100644 index 00000000..3a461def --- /dev/null +++ b/tests/drivers/gold_variablesprite_identity.lua @@ -0,0 +1,87 @@ +-- `variablesprite` mid-script must repaint an object, not replace it. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_variablesprite_identity.lua love . +-- +-- Two of the four map scripts that run `variablesprite` do it with the object +-- standing right there, mid-conversation, and with the VM holding a reference +-- to it as LAST_TALKED: +-- +-- LassAliceScript (maps/FuchsiaGym.asm:61-66) is +-- `applymovement FUCHSIAGYM_FUCHSIA_GYM_1, Movement_NinjaSpin / faceplayer / +-- variablesprite SPRITE_FUCHSIA_GYM_1, SPRITE_LASS / special +-- LoadUsedSpritesGFX / faceplayer` -- the ninja spins, unmasks, and the very +-- next command turns the SAME object back to the player; +-- CopycatsHouse2F.asm:23-48 does the same for the Copycat. +-- +-- On the cart nothing about the object struct moves: Script_variablesprite +-- writes ONE byte of wVariableSprites (scripting.asm:869) and LoadUsedSpritesGFX +-- reloads the tiles behind it. The object keeps its coordinates, its facing, +-- its FROZEN_F and its place as wLastTalked. +-- +-- So the port must keep the same NPC table. Building a new one strands +-- World.talkNpc, .trainerNpc, .followState and any live moveState on an object +-- that is no longer on the map, and drops the object back to its map-def home +-- cell and default facing in the middle of the scene. +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Route 36's Sudowoodo is the port's one live object on a SPRITE_VARS byte + -- outside Kanto (maps/Route36.asm:486, SPRITE_WEIRD_TREE = slot 4). + world:setMap("ROUTE_36", 34, 9, "right") + U.wait(20) + + local tree, treeId + for _, npc in ipairs(world.npcs) do + if npc.def and npc.def.sprite == 0xf0 + 4 then + tree, treeId = npc, (npc.def.index or 0) + 1 + break + end + end + assert(tree, "no SPRITE_WEIRD_TREE object on ROUTE_36") + + -- The state a mid-script `variablesprite` has to survive: the object is the + -- one being talked to, it has been turned, and it has been frozen. + world.talkNpc = tree + world.trainerNpc = tree + tree.facing = "left" + tree.frozen = true + local beforeX, beforeY = tree.cellX, tree.cellY + + -- `variablesprite SPRITE_WEIRD_TREE, SPRITE_TWIN`, the same slot write + -- WateredWeirdTreeScript makes (maps/Route36.asm:58). + world:setVariableSprite(4, 38) + U.wait(2) + + local after = world:objectEntity(treeId) + print(("[driver] object identity kept: %s"):format(tostring(after == tree))) + print(("[driver] talkNpc still on the map: %s") + :format(tostring(after == world.talkNpc))) + print(("[driver] facing %s -> %s, frozen %s -> %s, cell (%s,%s) -> (%s,%s)") + :format(tostring(tree.facing), tostring(after and after.facing), + tostring(tree.frozen), tostring(after and after.frozen), + tostring(beforeX), tostring(beforeY), + tostring(after and after.cellX), tostring(after and after.cellY))) + print(("[driver] sheet now %s (SPRITE_TWIN wanted)") + :format(tostring(after and after.spriteDef and after.spriteDef.id))) + + assert(after, "the object vanished from the map entirely") + assert(after.spriteDef and after.spriteDef.id == "SPRITE_TWIN", + "the slot write did not repaint the object: it is " + .. tostring(after.spriteDef and after.spriteDef.id)) + assert(after == tree, + "variablesprite REPLACED the object -- World.talkNpc / .trainerNpc and any " + .. "live movement now point at an NPC that is no longer on the map") + assert(after.facing == "left", + "the object lost the facing a `faceplayer` had just given it: " + .. tostring(after.facing)) + assert(after.frozen == true, "the object came back unfrozen mid-script") + assert(after.cellX == beforeX and after.cellY == beforeY, + "the object moved on a slot write") + + print("[driver] PASS gold variablesprite keeps the object") + love.event.quit() +end diff --git a/tests/drivers/gold_vf_caller_box2.lua b/tests/drivers/gold_vf_caller_box2.lua new file mode 100644 index 00000000..3d026ea9 --- /dev/null +++ b/tests/drivers/gold_vf_caller_box2.lua @@ -0,0 +1,125 @@ +-- INDEPENDENT VERIFICATION of the incoming-call caller box +-- (src/ui/gen2/CallerBox.lua, Phone_TextboxWithName at pokegold +-- engine/phone/phone.asm:582). tests/drivers/gold_phone_caller_box.lua takes +-- the random-call route; this one goes at the same seam from the other side: +-- +-- * MOM'S route. MomTriesToBuySomething (engine/events/mom_phone.asm) ends +-- `farsjump Script_ReceivePhoneCall` with an INLINE page list and +-- wCurCaller = PHONE_MOM, so its rows are built by the same +-- src/core/gen2/PhoneRing.lua script() as a random call but the caller is +-- a NON-trainer: GetCallerClassAndName stops at the colon and there is no +-- class row (:635-666). The box has to name MOM and print nothing at +-- (6,2). +-- * IDEMPOTENCE. Script_ReceivePhoneCall rings TWICE (RingTwice_StartCall +-- is `call .Ring` falling into .Ring, :458-469), so the push runs twice +-- and exactly one box may ever be on the stack. +-- * NO STALENESS. A second call after the first must name the SECOND +-- caller, which is the check that catches a box cached anywhere across +-- calls. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_vf_caller_box2.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-vf-callerbox (default) +local U = require("tests.drivers.util") + +local Phone = require("src.core.gen2.Phone") +local PhoneRing = require("src.core.gen2.PhoneRing") + +local CALLER_BOX = "gen2CallerBox" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-callerbox" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[vf-box] ok " .. label) + else + failures = failures + 1 + print("[vf-box] FAIL " .. label .. " " .. tostring(detail)) + end + end + + -- Every caller box on the stack, in stack order. + local function boxes() + local found = {} + for index, state in ipairs(game.stack.states or {}) do + if state[CALLER_BOX] then found[#found + 1] = { index = index, state = state } end + end + return found + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local vm = world.vm + assert(vm, "no script VM") + + -- Runs one call to the end, watching the box the whole way. `pages` is the + -- caller's own script as an inline row list, which is exactly the shape + -- World:momTriesToBuy hands PhoneRing.script. + local function runCall(tag, contact, name, className, pages) + vm.curPhoneCaller = contact + local rows = PhoneRing.script({ scriptKey = pages }, name, className) + assert(vm:start(rows), tag .. ": vm refused the call rows") + + -- The first ring pushes the box; the ring page only arrives after the + -- second pass. Watch every frame in between so a duplicate pushed by the + -- second RingTwice_StartCall cannot be missed by a coarse sample. + local maxBoxes, sawBox = 0, false + for _ = 1, 400 do + local n = #boxes() + if n > maxBoxes then maxBoxes = n end + if n > 0 then sawBox = true end + if #game.stack.states > 1 and sawBox then break end + U.wait(1) + end + U.wait(30) + local live = boxes() + ok(tag .. ": exactly one caller box is up", #live == 1, #live) + ok(tag .. ": and both rings only ever put up one", maxBoxes <= 1, maxBoxes) + local box = live[1] and live[1].state + ok(tag .. ": it names the caller", box and box.name == name, + box and box.name) + ok(tag .. ": and carries the class the cart prints at (6,2)", + box and box.className == className, + box and tostring(box.className)) + ok(tag .. ": it is UNDER the call's text page", + live[1] and live[1].index < #game.stack.states, + live[1] and (live[1].index .. "/" .. #game.stack.states)) + ok(tag .. ": and is transparent, so the overworld still draws", + box and box.isOpaque == false, box and tostring(box.isOpaque)) + ok(tag .. ": no update, so it cannot steal the fixed step", + box and box.update == nil) + U.shot(game, out .. "/" .. tag .. ".png") + + for _ = 1, 400 do + if not world:busy() then break end + U.tap(game, "a") + U.wait(4) + end + ok(tag .. ": the call ran to the end", not world:busy()) + ok(tag .. ": and the box came down with it", #boxes() == 0, #boxes()) + end + + -- MOM: a non-trainer caller, so no class row. + runCall("mom", Phone.PHONECONTACT_MOM, + Phone.NON_TRAINER_NAMES[Phone.PHONECONTACT_MOM], nil, + { { op = "rawtext", text = "…MOM: Hi!" }, + { op = "rawtext", text = "…MOM: Bye!" }, + { op = "end" } }) + + -- A trainer caller straight after, to prove nothing is cached between calls. + local trainers = game.data and game.data.trainers + local joeyName, joeyClass = Phone.contactName(15, trainers) + runCall("trainer", 15, joeyName, joeyClass, + { { op = "rawtext", text = "…JOEY: Yo!" }, { op = "end" } }) + + U.wait(10) + U.shot(game, out .. "/after.png") + ok("nothing left on the stack over the overworld", + #game.stack.states == 0, #game.stack.states) + + print(failures == 0 and "PASS gold_vf_caller_box2" + or ("FAIL gold_vf_caller_box2 (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_vf_touch_boot.lua b/tests/drivers/gold_vf_touch_boot.lua new file mode 100644 index 00000000..890cc885 --- /dev/null +++ b/tests/drivers/gold_vf_touch_boot.lua @@ -0,0 +1,105 @@ +-- INDEPENDENT VERIFICATION of the Gold on-screen pad, taken from the angle a +-- phone actually uses it: the BOOT CINEMA, through LOVE's own callbacks. +-- +-- tests/drivers/gold_touch_controls.lua drives game:touchpressed directly and +-- only ever with the overworld up. That leaves two things unproven, and both +-- are the difference between "a mobile player can start Gold" and "a mobile +-- player is stuck on the title screen": +-- +-- 1. love.touchpressed -> main.lua -> Game2:touchpressed is the REAL route +-- (main.lua:672-720 picks the service owner up out of its `Game` local, +-- which is the Game2 instance for a Gold boot -- main.lua:247). A pad +-- wired only where a driver reaches it would still be dead on a phone. +-- 2. Game2:drawHud is called on every return path of Game2:draw, including +-- the pre-world cinema, and TouchControls:init happens in Game2:load -- +-- before the copyright splash -- so the pad must be up and pressable on +-- the title screen, which is the first thing that ever asks for a button. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=1 POKEPORT_BOOT_CINEMA=1 \ +-- POKEPORT_DRIVER=tests/drivers/gold_vf_touch_boot.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-vf-touchboot (default) +local U = require("tests.drivers.util") + +local Input = require("src.core.Input") +local TouchControls = require("src.core.TouchControls") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-touchboot" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[vf-touch] ok " .. label) + else + failures = failures + 1 + print("[vf-touch] FAIL " .. label .. " " .. tostring(detail)) + end + end + + -- A press the way a finger delivers one: through the global LOVE callback, + -- not through the game object. + local function finger(id, x, y, frames) + love.touchpressed(id, x, y, 0, 0, 1) + U.wait(frames or 6) + love.touchreleased(id, x, y, 0, 0, 1) + U.wait(4) + end + + U.wait(20) + ok("the pad is up before the world exists", TouchControls:visible(), + tostring(TouchControls.active)) + ok("boot cinema really is running (no world yet)", game.world == nil, + game.phase) + + local L = TouchControls:layout() + assert(L and L.a and L.start, "no pad layout") + + -- Walk the cinema with the pad alone: copyright -> GameFreak -> intro -> + -- title -> main menu. Nothing but the overlay presses a button here. + local seen, lastTop = {}, nil + local reachedMenu = false + for _ = 1, 90 do + local top = game.stack:top() + if top ~= lastTop then + lastTop = top + seen[#seen + 1] = top + end + if game.world or (game.phase == "boot" and #seen >= 4) then + reachedMenu = true + break + end + finger("boot", L.a.cx, L.a.cy, 8) + end + ok("the pad alone walked the boot cinema forward", #seen >= 3, + #seen .. " screens") + ok("and got past the title screen without a keyboard", reachedMenu, + tostring(game.phase)) + U.shot(game, out .. "/01-boot-pad.png") + + -- The pad is still the thing pressing: hold A down through the real + -- callbacks and check Input sees it under the overlay's own source. + love.touchpressed("hold", L.a.cx, L.a.cy, 0, 0, 1) + U.wait(2) + ok("a finger on A during the cinema reaches Input", Input:isDown("a")) + ok("under the overlay's source, not a keyboard alias", + Input:isTouchDown("a")) + U.shot(game, out .. "/02-held.png") + love.touchreleased("hold", L.a.cx, L.a.cy, 0, 0, 1) + U.wait(2) + ok("and lifting it releases", not Input:isDown("a")) + + -- Focus loss with a finger down: LOVE has no touchcancelled, so without the + -- reset in Game2:focus a held overlay button is stranded forever. + love.touchpressed("stranded", L.b.cx, L.b.cy, 0, 0, 1) + U.wait(2) + ok("B is held before focus is taken away", Input:isDown("b")) + game:focus(false) + U.wait(2) + ok("losing focus frees the held pad button", not Input:isDown("b")) + game:focus(true) + U.wait(2) + + print(failures == 0 and "PASS gold_vf_touch_boot" + or ("FAIL gold_vf_touch_boot (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_vf_touch_play.lua b/tests/drivers/gold_vf_touch_play.lua new file mode 100644 index 00000000..eda3e578 --- /dev/null +++ b/tests/drivers/gold_vf_touch_play.lua @@ -0,0 +1,118 @@ +-- INDEPENDENT VERIFICATION that the Gold on-screen pad actually PLAYS the +-- game, not just that it sets a flag in src/core/Input.lua. +-- +-- The three presses a phone player cannot do without, each read at a different +-- place in Game2's fixed step: +-- +-- d-pad -> World:pollInput, the walk itself +-- START -> the wasPressed("start") arm that opens the start menu +-- SELECT -> the wasPressed("select") arm, UseRegisteredItem +-- (engine/overworld/select_menu.asm) +-- +-- START and SELECT are edge reads, so this is also the check that the +-- overlay's press survives Input:step's per-tick edge promotion -- a hold that +-- never produces an edge would leave the menus unreachable even though +-- Input:isDown said the button was down. Everything goes through the global +-- LOVE callbacks, the way a finger does. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=1 \ +-- POKEPORT_DRIVER=tests/drivers/gold_vf_touch_play.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-vf-touchplay (default) +local U = require("tests.drivers.util") + +local Input = require("src.core.Input") +local TouchControls = require("src.core.TouchControls") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-vf-touchplay" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[vf-play] ok " .. label) + else + failures = failures + 1 + print("[vf-play] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function finger(id, x, y, frames) + love.touchpressed(id, x, y, 0, 0, 1) + U.wait(frames or 6) + love.touchreleased(id, x, y, 0, 0, 1) + U.wait(6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + ok("the pad is up over the overworld", TouchControls:visible()) + local L = TouchControls:layout() + + -- WALK. Hold a d-pad direction long enough for OWPlayerInput to take the + -- step, and check the player actually moved a cell. + local x0, y0 = world.player.cellX, world.player.cellY + local moved, tries = false, 0 + for _, dir in ipairs({ "down", "up", "left", "right" }) do + if moved then break end + tries = tries + 1 + local dx = (dir == "left" and -0.4) or (dir == "right" and 0.4) or 0 + local dy = (dir == "up" and -0.4) or (dir == "down" and 0.4) or 0 + love.touchpressed("walk", L.dpad.cx + L.dpad.w * dx, + L.dpad.cy + L.dpad.w * dy, 0, 0, 1) + U.wait(40) + love.touchreleased("walk", L.dpad.cx + L.dpad.w * dx, + L.dpad.cy + L.dpad.w * dy, 0, 0, 1) + U.wait(10) + if world.player.cellX ~= x0 or world.player.cellY ~= y0 then moved = true end + end + ok("a finger on the d-pad walks the player", moved, + ("%d,%d -> %d,%d after %d directions"):format(x0, y0, + world.player.cellX, world.player.cellY, tries)) + U.shot(game, out .. "/01-walked.png") + + -- START. The overworld arm is an edge read (input:wasPressed), so a hold + -- that never promotes to an edge would leave the menu unreachable. + local before = #game.stack.states + finger("start", L.start.cx, L.start.cy, 8) + U.wait(12) + local menu = game.stack:top() + ok("a finger on START opens the start menu", + #game.stack.states > before and menu ~= nil, + #game.stack.states .. " states") + U.shot(game, out .. "/02-start-menu.png") + + -- B backs out of it, so the pad can leave the menu it just opened. + finger("b", L.b.cx, L.b.cy, 8) + U.wait(12) + ok("and a finger on B backs out of it", + #game.stack.states == before, #game.stack.states) + + -- SELECT. Nothing is registered, so UseRegisteredItem takes CantUseItem's + -- "nothing registered" arm -- which is still a text box, i.e. proof the + -- press reached the arm rather than quitting the process (the old + -- Game2:gamepadpressed answered `back` with love.event.quit()). + finger("select", L.select.cx, L.select.cy, 8) + U.wait(20) + ok("a finger on SELECT reaches UseRegisteredItem", + #game.stack.states > before or world:busy(), + #game.stack.states .. " states, busy=" .. tostring(world:busy())) + U.shot(game, out .. "/03-select.png") + + -- And the controller's own SELECT, which is what bug 3 was about: `back` is + -- SDL's name for the DualSense CREATE button (the shipped controller DB row + -- "PS5 Controller,...,back:b8,...") and GamepadMap binds it to GB SELECT. + -- The process must still be alive after it. + love.gamepadpressed(nil, "back") + U.wait(2) + ok("a controller `back` presses GB SELECT instead of quitting", + Input:isDown("select")) + love.gamepadreleased(nil, "back") + U.wait(2) + ok("and releases it", not Input:isDown("select")) + ok("the process is still running", love.window ~= nil) + + print(failures == 0 and "PASS gold_vf_touch_play" + or ("FAIL gold_vf_touch_play (%d)"):format(failures)) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_walk_smoke.lua b/tests/drivers/gold_walk_smoke.lua new file mode 100644 index 00000000..56b1d86e --- /dev/null +++ b/tests/drivers/gold_walk_smoke.lua @@ -0,0 +1,116 @@ +-- Smoke: Gold bedroom → downstairs → outside → house → carpet out → Route 29. +-- +-- A New Game now starts at SPAWN_HOME (PLAYERS_HOUSE_2F 3,3), the way +-- engine/menus/intro_menu.asm NewGame does, so the walk begins upstairs: the +-- stairs warp is at (7,0) on 2F and the front door at (6,7)/(7,7) on 1F. +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_walk_smoke.lua love . +return function(game) + local function wait(frames) + for _ = 1, frames do coroutine.yield() end + end + + local function clearDirs() + for _, d in ipairs({ "up", "down", "left", "right" }) do + game.input.state[d] = false + end + end + + local function hold(dir, frames) + clearDirs() + for _ = 1, frames do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + clearDirs() + end + + -- Entering the house runs MeetMomScript, and a driver that only holds a + -- direction would sit behind its text boxes forever. Tap A until the world + -- accepts input again. That script is long -- an approach walk, ten text + -- boxes and three yes/no prompts, ~1300 frames at this tap rate -- so the + -- budget has to be generous or the run looks like a hang. + local function clearDialogue() + for _ = 1, 1200 do + if not game.world:busy() then return end + table.insert(game.input.pressQueue, "a") + coroutine.yield() + coroutine.yield() + end + end + + local function mapId() + return game.world and game.world.map and game.world.map.id + end + + local function pos() + local p = game.world.player + return p.cellX, p.cellY + end + + wait(45) + assert(mapId() == "PLAYERS_HOUSE_2F", "boot map " .. tostring(mapId())) + + -- The bedroom is where a New Game lands, but this driver is about warps and + -- the Route 29 edge crossing, and the indoor route down two floors is a + -- fragile way to get to them. Drop straight outside instead: the door and + -- carpet warps below are the ones under test. + game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") + wait(15) + assert(mapId() == "NEW_BARK_TOWN", + ("after setMap: %s @ (%d,%d)"):format(tostring(mapId()), pos())) + + -- Walk back into the player's house door (one cell north of the doorstep). + hold("up", 40) + wait(15) + assert(mapId() == "PLAYERS_HOUSE_1F", + "after door: " .. tostring(mapId())) + clearDialogue() + + -- Entry faces up and keeps walking off the carpet into the room; hold + -- down to step back onto the carpet and warp out. + hold("down", 60) + wait(15) + -- Walking in can trip another coord script; clear it and try the carpet + -- again before deciding the exit is broken. + clearDialogue() + if mapId() ~= "NEW_BARK_TOWN" then + hold("up", 24) + clearDialogue() + hold("down", 48) + wait(15) + end + assert(mapId() == "NEW_BARK_TOWN", + ("after carpet: %s @ (%d,%d)"):format(tostring(mapId()), pos())) + + -- New Bark's west exit is gated: at scene SCENE_NEWBARKTOWN_TEACHER_STOPS_YOU + -- the coord events at (1,8)/(1,9) run the teacher's "It's dangerous to go out + -- without a POKéMON!" script and walk the player back, so a party-less save + -- can never reach Route 29. ElmsLab.asm sets the town to SCENE_NEWBARKTOWN_ + -- NOOP once the errand starts; do the same rather than fight the guard. + game.world.mapScenes["NEW_BARK_TOWN"] = 1 + + -- West→Route 29 only has a walkable landing at y=9: row 8 has the tree at + -- x=8 and row 10 is wall west of x=6. Re-square onto y=9 every pass rather + -- than only after a bump, and bound the walk -- a wandering townsfolk can + -- stand in the way for a step or two, and an unbounded retry loop turns that + -- into a run that never ends. + local x, y = pos() + for _ = 1, 60 do + if mapId() == "ROUTE_29" then break end + assert(mapId() == "NEW_BARK_TOWN", + "unexpected map " .. tostring(mapId())) + if y < 9 then + hold("down", 24) + elseif y > 9 then + hold("up", 24) + else + hold("left", 24) + end + x, y = pos() + end + assert(mapId() == "ROUTE_29", + ("after west edge: %s @ (%d,%d)"):format(tostring(mapId()), pos())) + + print("[driver] PASS gold walk house + Route 29") +end diff --git a/tests/drivers/gold_wall_radio.lua b/tests/drivers/gold_wall_radio.lua new file mode 100644 index 00000000..30c61699 --- /dev/null +++ b/tests/drivers/gold_wall_radio.lua @@ -0,0 +1,92 @@ +-- Assertion driver: the bedroom wall radio after the starter, played through +-- the real bg event -> jumpstd Radio1Script -> `special MapRadio` chain. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_wall_radio.lua love . +-- +-- tests/gen2_map_radio_test.lua drives the screen's own logic; what it cannot +-- see is the dispatch: a real A press on the radio tile, the std script's +-- setval/special pair, the screen landing on the real stack, and the music +-- surviving the exit (ExitPokegearRadio_HandleMusic). Each check here does it +-- the way a player would, twice, because the replay is half the point: the +-- radio must answer every interaction, not just the first. +local U = require("tests.drivers.util") + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local Music = require("src.core.Music") + + -- Past the starter: PlayersHouseRadioScript's `checkevent + -- EVENT_GOT_A_POKEMON_FROM_ELM` picks the .NormalRadio arm, which is + -- `jumpstd Radio1Script`. + world.events:set(26, true) + + -- The radio bg event sits at (3,1) in PLAYERS_HOUSE_2F; (3,2) facing up + -- reads it. + assert(world:setMap("PLAYERS_HOUSE_2F", 3, 2, "up"), + "setMap failed for PLAYERS_HOUSE_2F") + U.wait(10) + + local function radioScreen() + local top = game.stack:top() + return (top and top.screenId == "Gen2MapRadio") and top or nil + end + + local function listenOnce(round) + U.tap(game, "a") + local screen + for _ = 1, 120 do + screen = radioScreen() + if screen then break end + U.wait(1) + end + assert(screen, round .. ": A on the radio did not open the wall radio") + assert(screen.station, round .. ": no station resolved") + -- PlayRadio holds 100 frames with the station name up, then the show + -- starts its channel song. + U.wait(110) + for _ = 1, 240 do + if screen.radio.music then break end + U.wait(1) + end + assert(screen.radio.music, round .. ": the show never started its song") + local song = screen.radio.music + assert(Music.current() == song, + ("%s: playing %s, want %s"):format(round, + tostring(Music.current()), tostring(song))) + -- A closes it, and the song KEEPS PLAYING as the map music + -- (RadioMusicRestartDE wrote it into wMapMusic). + U.tap(game, "a") + for _ = 1, 60 do + if not radioScreen() then break end + U.wait(1) + end + assert(not radioScreen(), round .. ": A did not close the radio") + U.wait(5) + assert(Music.current() == song, + round .. ": the song stopped when the radio closed") + assert(Music.mapSong() == song, + round .. ": the song did not become the map music") + U.log(round .. ": radio played " .. song .. " and it persists") + return song + end + + listenOnce("first listen") + -- Back out, talk again: the radio must play again. + local song = listenOnce("second listen") + + -- A map change is what replaces the song, exactly as a new map's + -- PlayMapMusic would. + assert(world:setMap("NEW_BARK_TOWN", 8, 8, "down"), + "setMap failed for NEW_BARK_TOWN") + U.wait(10) + assert(Music.current() ~= song, + "leaving the house did not restore the map's own music") + U.log("map change replaced the radio song with " + .. tostring(Music.current())) + + print("[driver] PASS gold wall radio") + love.event.quit() +end diff --git a/tests/drivers/gold_warp_scene.lua b/tests/drivers/gold_warp_scene.lua new file mode 100644 index 00000000..bde11960 --- /dev/null +++ b/tests/drivers/gold_warp_scene.lua @@ -0,0 +1,124 @@ +-- The door warp, end to end, on the map a player meets it on first. +-- +-- Walking onto a warp tile is PLAYEREVENT_WARP -> WarpToNewMapScript +-- (engine/overworld/events.asm), which is `warpsound` then +-- `newloadmap MAPSETUP_DOOR`. MapSetupScript_Door opens on FadeOutToWhite and +-- falls through into _Train, whose tail is FadeInFromWhite, so the load sits in +-- the MIDDLE of the setup script. This driver asserts the three things that +-- were wrong, and shoots the fade so a human can see it: +-- +-- 1. the warp makes a sound, picked off the tile the player stands on; +-- 2. the screen fades out and back in around the load, holding input for the +-- sixteen frames it runs (four steps of ConvertTimePals*HL, DelayFrames 2 +-- apart, per half); +-- 3. the player lands on the doormat at (4,11) and the lab's own scene script +-- walks them the nine steps of ElmsLab_WalkUpToElmMovement to (4,2) facing +-- right -- NOT to (4,1), which is where a free step stolen by a still-held +-- direction used to leave them. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_DRIVER=tests/drivers/gold_warp_scene.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Shots land in /tmp/gold-warp. +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-warp" + +return function(game) + local w = game.world + local fails = 0 + + local function wait(n) for _ = 1, n do coroutine.yield() end end + + local function ok(cond, msg) + if cond then + print("[warp] ok " .. msg) + else + fails = fails + 1 + print("[warp] FAIL " .. msg) + end + return cond + end + + local function shot(name) + game.capturePath = SHOT_DIR .. "/" .. name .. ".png" + coroutine.yield() + end + + local function cell() + local p = w.player + return p.cellX, p.cellY, p.facing + end + + local function holdInto(dir, limit) + local from = w.map.id + local levels, shots = {}, 0 + for _ = 1, limit do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + if w.mapSetup then + levels[#levels + 1] = w.fadeLevel + shots = shots + 1 + game.capturePath = ("%s/fade-%02d.png"):format(SHOT_DIR, shots) + end + coroutine.yield() + if w.map.id ~= from and not w.mapSetup then break end + end + game.input.pressQueue = {} + game.input.state[dir] = false + game.input.sources[dir] = nil + return levels + end + + os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null') + wait(45) + + -- The teacher's coord event at the west edge gates a party-less save; the + -- errand script clears it, and this driver is about the door, not the guard. + w.mapScenes.NEW_BARK_TOWN = 1 + w:setMap("NEW_BARK_TOWN", 6, 5, "up") + wait(15) + + -- GetWarpSFX reads the tile the player is STANDING on, so the sound belongs to + -- the doorway they walk into, not to the room they arrive in. + local sfx = {} + local realPlaySfx = w.playSfx + w.playSfx = function(self, id) sfx[#sfx + 1] = id realPlaySfx(self, id) end + + local levels = holdInto("up", 180) + ok(w.map.id == "ELMS_LAB", "the door warps into the lab (" .. w.map.id .. ")") + ok(#sfx > 0, "and it makes a sound (" .. #sfx .. " sfx)") + -- Four rising levels, four falling, with the solid frame in between. + ok(#levels >= 12, + "the fade ran for " .. #levels .. " frames (sixteen is the full chain)") + local peak = 0 + for _, v in ipairs(levels) do if (v or 0) > peak then peak = v end end + ok(peak == 1, "and reached a solid sheet (peak " .. tostring(peak) .. ")") + ok(w.fade == nil, "which is gone by the time control comes back") + + local x, y, facing = cell() + ok(x == 4 and y == 11, + ("lands on the doormat at (4,11), got (%d,%d)"):format(x, y)) + ok(facing == "up", + "still facing up: the mat inside is a carpet, not a CheckWarpFacingDown " + .. "tile (got " .. tostring(facing) .. ")") + shot("arrive") + + -- ElmsLab_WalkUpToElmMovement: nine `step UP` then `turn_head RIGHT`. + local idle = 0 + for _ = 1, 900 do + if w:busy() then idle = 0 else idle = idle + 1 end + if idle > 40 then break end + wait(3) + end + x, y, facing = cell() + ok(x == 4 and y == 2 and facing == "right", + ("the entry scene ends at (4,2) facing right, got (%d,%d) %s") + :format(x, y, tostring(facing))) + shot("met-elm") + + if fails > 0 then + error(("gold warp scene: %d assertion(s) failed"):format(fails)) + end + print("[driver] PASS gold door warp: sound, fade, and the lab entry walk") +end diff --git a/tests/drivers/gold_water_moves.lua b/tests/drivers/gold_water_moves.lua new file mode 100644 index 00000000..3a53c4dc --- /dev/null +++ b/tests/drivers/gold_water_moves.lua @@ -0,0 +1,168 @@ +-- Assertion driver: SURF and WHIRLPOOL, done the way a player does them, on +-- real Gold maps. It PASSES or it errors. +-- +-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-dev \ +-- POKEPORT_DRIVER=tests/drivers/gold_water_moves.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-water (default) +-- +-- tests/gen2_world_test.lua checks the pure halves (FieldMoves' badge gate, +-- the tile tests, the block replacement tables). What it cannot check is the +-- part that is all wiring: that walking into the sea puts you ON it, that the +-- player sprite becomes the Lapras, that a whirlpool block really leaves the +-- map's block buffer, and that the water behind it is passable afterwards. +-- +-- Both routes IN are driven, because they are different code on the cart and +-- were different code here: TrySurfOW / TryWhirlpoolOW (walk into it, answer +-- the prompt) runs on the spot through CallScript, and the PACK / party-menu +-- route queues a script that only runs once the menus are gone. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") +local Mon = require("src.battle.gen2.Mon") + +-- Cells read out of the cache rather than remembered: Cherrygrove's beach is +-- the first stretch of sea a player can reach, and Route 41's whirlpools are +-- the ones between Olivine and Cianwood. +local BEACH = { map = "CHERRYGROVE_CITY", x = 10, y = 9 } -- faces water below +local WHIRL = { map = "ROUTE_41", x = 22, y = 11 } -- faces (22,12) + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-water" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold 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 6) + end + + -- Answer whatever text box or yes/no the field move puts up, until the world + -- is idle again. A field move is a script, so `busy` is the honest "is it + -- still going" and mashing A is what a player does. + local function settle(limit) + for _ = 1, (limit or 60) do + if not world:busy() and not world.fieldMove then return true end + tap("a", 4) + end + return not world:busy() and not world.fieldMove + end + + -- SURF is FOGBADGE, WHIRLPOOL is GLACIERBADGE (FieldMoves.BADGE). Give + -- both, so what is under test is the move and not the gate -- the gate has + -- its own checks in gen2_world_test. + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end + assert(FieldMoves.hasBadge(game.save, FieldMoves.BADGE.SURF), "FOGBADGE") + assert(FieldMoves.hasBadge(game.save, FieldMoves.BADGE.WHIRLPOOL), "GLACIER") + + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } }) + assert(swimmer, "could not build a LAPRAS") + game.save.party = { swimmer } + + -- ---- SURF, by walking into the sea ------------------------------------- + do + world:setMap(BEACH.map, BEACH.x, BEACH.y, "down") + U.wait(10) + local ctx = world:fieldContext() + assert(Permissions.isWater(ctx.facingColl), + ("%s (%d,%d) is not facing water any more -- re-import moved the beach") + :format(BEACH.map, BEACH.x, BEACH.y)) + assert(not FieldMoves.isSurfing(world.playerState), "not surfing yet") + U.shot(game, out .. "/00-beach.png") + + assert(world:trySurfOW(), "TrySurfOW refused a water tile with the badge") + assert(settle(), "the surf script never finished") + assert(FieldMoves.isSurfing(world.playerState), + "SURF ran and the player is still on foot") + assert(world.player.cellY > BEACH.y, + ("the player did not step onto the water: still at (%d,%d)") + :format(world.player.cellX, world.player.cellY)) + local coll = world.map:cellCollision(world.player.cellX, world.player.cellY) + assert(Permissions.isWater(coll), + "the player is surfing on something that is not water") + U.shot(game, out .. "/01-surfing.png") + + -- And it is a real state, not a one-step animation: walk further out. + local fromY = world.player.cellY + U.hold(game, "down", 40) + U.wait(20) + assert(world.player.cellY > fromY, + "the player cannot swim once surfing") + assert(FieldMoves.isSurfing(world.playerState), + "the surf state did not survive a step") + U.log(("SURF: walked into the sea at (%d,%d) and swam to (%d,%d)") + :format(BEACH.x, BEACH.y, world.player.cellX, world.player.cellY)) + end + + -- ---- WHIRLPOOL, from the party menu ------------------------------------ + do + -- Route 41 is open sea, so the player arrives already surfing -- which is + -- what the cart does too (wPlayerState survives the warp). + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(WHIRL.map, WHIRL.x, WHIRL.y, "down") + U.wait(10) + local def = world.maps[WHIRL.map] + local ctx = world:fieldContext() + assert(Permissions.isWhirlpool(ctx.facingColl), + ("%s (%d,%d) is not facing a whirlpool"):format( + WHIRL.map, WHIRL.x, WHIRL.y)) + local index = ctx.facingBlockIndex + local before = def.blocks[index] + local replacement = select(1, FieldMoves.blockReplacement( + FieldMoves.WHIRLPOOL_BLOCKS, ctx.tileset, ctx.facingBlock)) + assert(replacement, + "no WhirlpoolBlockPointers row for this tileset/block pair") + assert(before ~= replacement, "the whirlpool is already cleared") + U.shot(game, out .. "/02-whirlpool.png") + + -- The party-menu route: the result is QUEUED and only runs once the menus + -- are gone, which is the half that is easy to wire up wrong. + local result = world:useFieldMove("WHIRLPOOL", game.save.party[1]) + assert(result and result.ok, + "the party menu refused WHIRLPOOL: " .. tostring(result and result.text)) + assert(world.queuedFieldMove, "and it ran on the spot instead of queueing") + assert(world:runQueuedFieldMove(), "the queued move did not start") + assert(settle(), "the whirlpool script never finished") + + assert(def.blocks[index] == replacement, + ("the whirlpool block did not change: %s, want %s") + :format(tostring(def.blocks[index]), tostring(replacement))) + local after = world.map:cellCollision(ctx.facingX, ctx.facingY) + assert(not Permissions.isWhirlpool(after), + "the block changed but the cell is still a whirlpool") + assert(Permissions.isWalkable(after) or Permissions.isWater(after), + "the cleared whirlpool is not passable") + U.shot(game, out .. "/03-whirlpool-cleared.png") + + -- Swim through it, which is the whole point of clearing one. + U.hold(game, "down", 40) + U.wait(20) + assert(world.player.cellY >= ctx.facingY, + ("the player could not swim through the cleared whirlpool: (%d,%d)") + :format(world.player.cellX, world.player.cellY)) + U.log(("WHIRLPOOL: block %d %s -> %s on %s, and the player swam through") + :format(index, tostring(before), tostring(replacement), WHIRL.map)) + + -- LoadMapAttributes refills the block buffer from ROM: a whirlpool is back + -- the next time you sail in, exactly like a cut tree. + world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(5) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(WHIRL.map, WHIRL.x, WHIRL.y, "down") + U.wait(5) + assert(def.blocks[index] == before, + "the whirlpool did not come back after a map load") + U.log("and it is back on the next map load, the way the cart refills it") + end + + U.log("PASS gold_water_moves in " .. out) + love.event.quit() +end diff --git a/tests/drivers/gold_waterfall_current_probe.lua b/tests/drivers/gold_waterfall_current_probe.lua new file mode 100644 index 00000000..fe093c27 --- /dev/null +++ b/tests/drivers/gold_waterfall_current_probe.lua @@ -0,0 +1,127 @@ +-- Current tiles at Tohjo Falls, driven through the real game. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_SPEED=200 \ +-- POKEPORT_GOLD_RESUME=16 \ +-- POKEPORT_DRIVER=tests/drivers/gold_waterfall_current_probe.lua love . +-- +-- DoPlayerMovement's .CheckTile treats COLL_WATERFALL $33 as a CURRENT tile and +-- forces one DOWN step per frame while the player stands on one, above +-- .CheckTurning and .TryStep -- so the plunge is automatic and the column +-- cannot be climbed by pressing UP. Tohjo Falls' west fall is four cells wide +-- and four tall ((8,8)..(11,11)), with plain water above and below it, which +-- makes it the map the three claims below are about: +-- +-- * holding UP from the pool never reaches the ledge above the fall +-- * HM07's own climb (Script_UsedWaterfall) still does +-- * stepping back into the column carries the player down with no input +-- +-- Prints one PASS/FAIL line per claim and quits. + +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter +local FieldMoves = require("src.world.gen2.FieldMoves") + +local MAP = "TOHJO_FALLS" +local POOL_X, POOL_Y = 11, 12 -- plain water below the fall +local TOP_Y = 7 -- the water above it + +local results = {} + +local function claim(ok, text) + results[#results + 1] = ok and true or false + print((ok and "[current] PASS " or "[current] FAIL ") .. text) +end + +return function(game) + local bot = Bot.new(game) + + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + local resume = os.getenv("POKEPORT_GOLD_RESUME") or "16" + local ok, err = A.loadCheckpoint(game, resume) + if not ok then + print(("[current] cannot resume %s: %s"):format(resume, tostring(err))) + love.event.quit() + return + end + for _ = 1, 3000 do + if A.ready(game) then break end + bot:wait(1) + end + + local world = game.world + world:setMap(MAP, POOL_X, POOL_Y, "up") + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world.noWildEncounters = true + bot:wait(30) + bot:clearDialogue(nil, 4000) + claim(A.mapId(game) == MAP and A.surfing(game), + "surfing in the pool below the west fall") + + local map = A.map(game) + claim(map ~= nil and A.isWaterfall(map, POOL_X, POOL_Y - 1), + "the cell above the player is a waterfall tile") + + -- ---- the d-pad cannot climb it ------------------------------------------ + local highest = select(2, A.pos(game)) + for _ = 1, 600 do + A.hold(game, "up") + bot:wait(1) + local _, y = A.pos(game) + if y < highest then highest = y end + end + A.releaseDirs(game) + bot:wait(20) + claim(highest > TOP_Y, + ("holding UP for 600 frames never got above the fall (best y=%d)") + :format(highest)) + + -- ---- HM07 still does ----------------------------------------------------- + local px, py = A.pos(game) + if py ~= POOL_Y or px ~= POOL_X then + world:setMap(MAP, POOL_X, POOL_Y, "up") + world:applyPlayerState(FieldMoves.PLAYER_SURF) + bot:wait(20) + end + bot:face("up") + local used = A.useWaterfall(game) + bot:wait(8) + bot:clearDialogue({ "yes" }, 6000) + for _ = 1, 900 do + if not A.moving(game) and not A.busy(game) then break end + bot:wait(1) + end + local _, afterClimb = A.pos(game) + claim(used and afterClimb <= TOP_Y, + ("Script_UsedWaterfall still climbs the fall (y=%s)") + :format(tostring(afterClimb))) + + -- ---- and the descent rides the current ----------------------------------- + local beforeX, beforeY = A.pos(game) + -- Long enough to turn and take the ONE step onto the top of the fall, then + -- the d-pad is let go: everything after this is .CheckTile's doing. + bot:face("down") + A.hold(game, "down") + bot:wait(20) + A.releaseDirs(game) + for _ = 1, 600 do + if not A.moving(game) then + local _, y = A.pos(game) + if y >= POOL_Y then break end + end + bot:wait(1) + end + local endX, endY = A.pos(game) + claim(endY >= POOL_Y, + ("one DOWN press carried the player the whole way down (%d,%d -> %d,%d)") + :format(beforeX, beforeY, endX, endY)) + + local failures = 0 + for _, value in ipairs(results) do + if not value then failures = failures + 1 end + end + print(("[current] %d claims, %d failed"):format(#results, failures)) + love.event.quit() +end diff --git a/tests/drivers/gold_zoom_shots.lua b/tests/drivers/gold_zoom_shots.lua new file mode 100644 index 00000000..f1bd83d8 --- /dev/null +++ b/tests/drivers/gold_zoom_shots.lua @@ -0,0 +1,62 @@ +-- ZOOM in the Gold overworld: the map resizes, the UI does not. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_zoom_shots.lua love . +-- +-- One shot per zoom level with a dialogue box up, and one more with the START +-- menu up. Across the set the map behind the box has to change size and the +-- box itself has to be pixel-for-pixel identical -- that is the two-pass split +-- src/render/Renderer.lua makes for Gen 1 (UI LAYOUT = CENTERED: the world +-- canvas follows Zoom.scale, the UI canvas stays on fitScale), which +-- Game2:drawScene now makes too. Before it did, the text box grew and +-- shrank along with the world. +-- +-- Writes to /tmp/gold-zoom (POKEPORT_SHOT_DIR to move it). +local U = require("tests.drivers.util") + +local StartMenu = require("src.ui.gen2.StartMenu") +local Zoom = require("src.render.Zoom") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-zoom" + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + -- Outside, where the survey zoom is worth looking at at all. + if game.world.map.id ~= "NEW_BARK_TOWN" then + game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(20) + end + + local fit = game.world:fitScale() + print(("[driver] fit scale %d"):format(fit)) + + local function at(offset, name) + Zoom.offset = Zoom.clampOffset(offset, fit) + U.wait(4) + U.shot(game, ("%s/%s.png"):format(out, name)) + print(("[driver] %s: offset %d, world x%.2f, ui x%d") + :format(name, Zoom.offset, game.world:zoomScale(), fit)) + return Zoom.offset + end + + game:say("ZOOM leaves this box alone.") + U.wait(6) + local survey = at(-2, "01-survey") + at(0, "02-fit") + local close = at(2, "03-close") + assert(survey < 0 or close > 0, + "the zoom range collapsed to a single step; nothing to compare") + game.stack:pop() + + Zoom.offset = 0 + U.wait(4) + game:openStartMenu() + U.wait(10) + assert(getmetatable(game.stack:top()) == StartMenu, + "START menu did not open (top " .. tostring(game.stack:top()) .. ")") + at(-2, "04-startmenu-survey") + at(2, "05-startmenu-close") + + Zoom.offset = 0 + print("[driver] PASS gold zoom shots in " .. out) +end diff --git a/tests/drivers/verify_battle_screen.lua b/tests/drivers/verify_battle_screen.lua new file mode 100644 index 00000000..eddbfe3e --- /dev/null +++ b/tests/drivers/verify_battle_screen.lua @@ -0,0 +1,249 @@ +-- Independent verification probe for the Gold battle screen lane. +-- Temporary: attacks the same claims from a different angle than +-- tests/drivers/gold_battle_screen_probe.lua. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/verify_battle_screen.lua \ +-- POKEPORT_VPROBE=switch1 POKEPORT_SHOT_DIR=/tmp/verify-battle/v love . + +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/verify-battle/v" + +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, 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 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function toMenu(game, battle, limit) + for _ = 1, (limit or 400) do + if battle.phase == "menu" then return true end + if battle.battle.over then return false end + tap(game, "a", 2) + end + return battle.phase == "menu" +end + +local V = {} + +-- Bug 4, the real player route: after the faint, move the cursor to a HEALTHY +-- mon and press A exactly ONCE. The claim under test is that the switch takes +-- on that press; the author's probe only ever proved the refusal LINE. +function V.switch1(game) + local weak = Mon.new(game.data, "CYNDAQUIL", 5) + weak.hp = 1 + game.save.party = { weak, Mon.new(game.data, "TOTODILE", 30) } + game.save.inventory = {} + local entry = game.world:trainerParty(36, 1) + local Trainers = require("src.world.gen2.Trainers") + entry.party = Trainers.party(game.data, entry) + local battle = openBattle(game, { trainer = entry }) + assert(toMenu(game, battle), "never reached the battle menu") + + local aPresses, listOpens = 0, 0 + local realOpen = battle.openParty + battle.openParty = function(self, forced) + if forced then listOpens = listOpens + 1 end + return realOpen(self, forced) + end + + local switched, moved = false, false + for _ = 1, 1200 do + if battle.battle.over then break end + local top = game.stack:top() + if top ~= battle then + -- The forced list. Move down to the healthy mon FIRST, then one A. + if not moved then tap(game, "down", 4); moved = true end + aPresses = aPresses + 1 + tap(game, "a", 6) + U.wait(20) + if battle.battle.player and (battle.battle.player.hp or 0) > 0 + and battle.battle.player.species == "TOTODILE" then + switched = true + break + end + elseif battle.phase == "menu" then + tap(game, "a", 2); U.wait(3); tap(game, "a", 2) + else + tap(game, "a", 2) + end + end + print(("[v] switch1: listOpens=%d aPressesOnList=%d switched=%s active=%s") + :format(listOpens, aPresses, tostring(switched), + tostring(battle.battle.player and battle.battle.player.species))) + U.shot(game, OUT .. "/switch1-after.png") +end + +-- Bug 3, the NORMAL save: currentBox is 1, the value every fresh Gold save and +-- every save the engine itself writes carries. If the catch only reached a box +-- because of the clamp, this is where that shows. +function V.box1(game) + local party = {} + for _ = 1, 6 do party[#party + 1] = Mon.new(game.data, "CYNDAQUIL", 30) end + game.save.party = party + game.save.boxes = nil + game.save.currentBox = tonumber(os.getenv("POKEPORT_VBOX") or "1") + game.save.inventory = { MASTER_BALL = 5 } + local battle = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + assert(toMenu(game, battle), "never reached the battle menu") + battle:useItem("MASTER_BALL") + for _ = 1, 600 do + if not battle.anim then break end + U.wait(1) + end + for _ = 1, 300 do + if battle.phase == "ask-nickname" then tap(game, "b", 4) + else tap(game, "a", 3) end + if battle.phase == "done" or not game.stack:top() then break end + end + local total, where = 0, "nowhere" + for i, box in pairs(game.save.boxes or {}) do + total = total + #box + if #box > 0 then where = "box" .. tostring(i) end + end + print(("[v] box1: currentBox=%s party=%d anywhere=%d landedIn=%s boxFilled=%s") + :format(tostring(game.save.currentBox), #game.save.party, total, where, + tostring(battle.battle.boxFilled))) +end + +-- The picHidden REGRESSION risk: a trainer whose first mon faints must send its +-- SECOND one out and that mon must be visible. A latch that a send-out fails +-- to clear leaves an invisible opponent for the rest of the battle. +function V.secondmon(game) + game.save.party = { Mon.new(game.data, "TOTODILE", 40) } + game.save.inventory = {} + -- Find a trainer entry that actually carries two mons. + local Trainers = require("src.world.gen2.Trainers") + local entry, size + for class = 1, 60 do + for member = 1, 6 do + local ok, e = pcall(game.world.trainerParty, game.world, class, member) + if ok and e then + local party = Trainers.party(game.data, e) + if party and #party >= 2 then + entry, size = e, #party + e.party = party + break + end + end + end + if entry then break end + end + assert(entry, "no multi-mon trainer found") + print(("[v] secondmon: trainer class=%s members=%d") + :format(tostring(entry.classId or entry.class), size)) + local battle = openBattle(game, { trainer = entry }) + assert(toMenu(game, battle), "never reached the battle menu") + + local sawSecond, hiddenAtSecond, shot = false, nil, 0 + for _ = 1, 2000 do + if battle.battle.over then break end + if battle.battle.enemyIndex and battle.battle.enemyIndex > 1 + and not sawSecond then + sawSecond = true + -- Let the send-out settle, then look at the latch and shoot it. + U.wait(90) + hiddenAtSecond = battle.picHidden.enemy + U.shot(game, OUT .. "/secondmon-out.png") + end + if battle.phase == "menu" then + tap(game, "a", 2); U.wait(3); tap(game, "a", 2) + else + if shot < 3 and battle.faintSlide then + U.shot(game, ("%s/secondmon-faint-%02d.png"):format(OUT, + battle.faintSlide.frames)) + shot = shot + 1 + end + tap(game, "a", 2) + end + end + print(("[v] secondmon: sawSecond=%s picHidden.enemy@second=%s outcome=%s") + :format(tostring(sawSecond), tostring(hiddenAtSecond), + tostring(battle.battle.outcome))) + U.shot(game, OUT .. "/secondmon-end.png") +end + +-- Bug 2, the half that is cache-side: what the DEFAULT identity's Gold cache +-- actually carries, and what the class key resolves to. +function V.trainerpic(game) + local hud = game.data.gen2MenuGfx and game.data.gen2MenuGfx.battleHud + local pics = hud and hud.trainerPics + local n = 0 + local sample + for k in pairs(pics or {}) do n = n + 1; sample = sample or k end + print(("[v] trainerpic: cache trainerPics=%s count=%d sample=%s") + :format(tostring(pics ~= nil), n, tostring(sample))) + local Trainers = require("src.world.gen2.Trainers") + local entry = game.world:trainerParty(36, 1) + print(("[v] trainerpic: lookup(36,1) class=%s classId=%s className=%s") + :format(tostring(entry and entry.class), tostring(entry and entry.classId), + tostring(entry and entry.className))) + entry.party = Trainers.party(game.data, entry) + local battle = openBattle(game, { trainer = entry }) + print(("[v] trainerpic: enemyTrainerClass=%s showEnemyTrainer=%s path=%s") + :format(tostring(battle.enemyTrainerClass), + tostring(battle.showEnemyTrainer), tostring(battle.enemyTrainerPath))) + for i = 0, 8 do + U.shot(game, ("%s/trainerpic-%02d.png"):format(OUT, i)) + U.wait(10) + end +end + +-- The counterfactual to bug 1: a ball that FAILS. BattleAnim_ThrowPokeBall's +-- break-out arm puts the mon back on the field, so a latch set anywhere but on +-- the caught arm would make a wild mon vanish for the rest of the battle. +function V.missball(game) + game.save.party = { Mon.new(game.data, "TOTODILE", 40) } + game.save.inventory = { POKE_BALL = 30 } + local wild = Mon.new(game.data, "ONIX", 40) -- full HP, low catch rate + local battle = openBattle(game, { wild = wild }) + assert(toMenu(game, battle), "never reached the battle menu") + local tries, escaped = 0, false + for _ = 1, 20 do + tries = tries + 1 + battle:useItem("POKE_BALL") + for _ = 1, 600 do + if not battle.anim then break end + U.wait(1) + end + U.wait(40) + if battle.battle.outcome ~= "caught" and not battle.battle.over then + escaped = true + break + end + if battle.battle.over then break end + end + print(("[v] missball: tries=%d escaped=%s picHidden.enemy=%s outcome=%s") + :format(tries, tostring(escaped), tostring(battle.picHidden.enemy), + tostring(battle.battle.outcome))) + for i = 0, 3 do + U.shot(game, ("%s/missball-%02d.png"):format(OUT, i)) + tap(game, "a", 8) + end +end + +local name = os.getenv("POKEPORT_VPROBE") or "switch1" +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + print("[v] ---- " .. name) + assert(V[name], "no such probe: " .. name)(game) + print("[v] done, shots in " .. OUT) + love.event.quit() +end diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua new file mode 100644 index 00000000..0cbcf0d7 --- /dev/null +++ b/tests/engine/gate_gen2_mod_api.lua @@ -0,0 +1,992 @@ +-- Parity gate for the Gen 1 / Gen 2 mod API boundary. +-- +-- The rule this file exists to hold: hook names, event names and registry +-- names are SHARED across generations, and the only things that differ are +-- where a registry's content lands and whether the mod runs at all. A mod +-- opts into Gen 2 with `gen2compat` in its manifest and is left out of a Gold +-- boot entirely without it, because a mod that half-applies reads as broken. +-- +-- Runs ROM-free: the generation is injected through the loader rather than by +-- booting Gold (T.sdk.loadMods opts.generation). + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local GameVersion = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Schemas = require("src.mods.Schemas") +local StateStack = require("src.core.StateStack") + +-- ------- 1. the version table knows its generation + +T.eq(GameVersion.generation("red"), 1, "Red is Gen 1") +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") + +-- ------- 2. manifest: gen2compat is opt-in and defaults off + +local function manifest(extra) + local raw = { id = "fix", name = "Fixture", version = "1.0.0", + entry = "main.lua", api = 2 } + for key, value in pairs(extra or {}) do raw[key] = value end + return Manifest.validate(raw, "mods/fix") +end + +T.eq(manifest().gen2compat, false, + "a manifest that says nothing is Gen 1 only") +T.eq(manifest({ gen2compat = true }).gen2compat, true, + "gen2compat = true is carried through") +T.eq(manifest({ gen2compat = false }).gen2compat, false, + "gen2compat = false is carried through") +T.check(not pcall(manifest, { gen2compat = "yes" }), + "a non-boolean gen2compat is rejected") + +-- ------- 3. registry target routing +-- +-- One routing table per generation (Schemas.routing): Gen 2 keeps the shared +-- target for everything it can serve and reports the rest instead of merging +-- into a table nothing reads, and Gen 1 does the same for the registries that +-- only exist because Gold does. Gen 1 used to consult nothing at all; the +-- catalog now holds content BOTH ways round, so the claim is the symmetrical +-- one -- a registry is gated in a generation exactly when it has no home +-- there, whichever generation that is. + +for name, spec in pairs(Schemas.REGISTRIES) do + T.eq(Schemas.targetFor(name, spec, 1), spec.target, + "Gen 1 keeps the catalog target: " .. name) + if Schemas.GEN1[name] == nil then + T.eq(Schemas.gatedFor(name, 1), false, + "a registry Gen 1 routing says nothing about is not gated there: " .. name) + else + -- the mirror of the gated rows below: a Gen 2-only system, so there is no + -- Gen 1 target to keep and a Red mod's write is dropped and reported + -- rather than merged into a namespace no Gen 1 boot reads. The per- + -- registry cases are in tests/engine/gen2_content_registries.lua. + T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name) + T.eq(spec.target, nil, + "a Gen 1-gated registry carries no Gen 1 target: " .. name) + T.check(Schemas.targetFor(name, spec, 2) ~= nil, + "and has a Gen 2 home, or the name is dead in both: " .. name) + end +end + +-- the registries Gold genuinely reads off game.data keep their name AND their +-- path, which is what lets one mod source target both generations. +-- `commands` is here rather than in the routed set below because the Gen 2 VM +-- resolves a mod verb out of the SAME merged data.commands table Gen 1's +-- runner does (src/script/gen2/Vm.lua:runModCommand, reached from the +-- Opcodes.MOD_COMMAND row a cart can never write). +for _, name in ipairs({ "pokemon", "moves", "items", "type_chart", "screens", + "strings", "font", "audio", "music", "sfx", "cries", + "map_songs", "commands", + -- `tokens` because TextBox.substitute reads + -- game.data.tokens on every box in both games, and + -- `growth_rates` because src/mods/Builtins.lua's Gen 2 + -- registrant seeds Gold's curves as the same + -- { expForLevel } record Gen 1 uses, so one mod record + -- serves both (src/battle/gen2/Mon.lua:growthFor is the + -- single accessor all six Gen 2 readers go through) + "tokens", "growth_rates", + -- `battle_sprite_scales` because + -- src/ui/gen2/BattleState.lua:imageScale walks + -- data.battle_sprite_scales for a record whose .path + -- matches the pic being drawn, skipping `_owners`, + -- exactly as Gen 1's BattleState.imageBattleScale + -- does, and picScale falls through to the species' + -- battleScaleFront / battleScaleBack after it. Only + -- the default differs (Red draws 32x32 back pics at + -- 2x, Gold's 48x48 ones fill their box at 1x) and the + -- default is not a registry record either side. + -- + -- `render_pipelines` because src/core/Game2.lua:load + -- calls Pipelines.install(self.data) AFTER mods:load, + -- so src/render/Pipelines.lua walks the MERGED table, + -- and Game2:draw composites the whole-frame half + -- through Pipelines.wantsPresent / Pipelines.present. + -- Gold does not composite `drawWorld` yet -- its + -- overworld draws straight to the window -- and + -- Game2:load retires a restored drawWorld-only level + -- rather than leaving it on and drawing nothing, so + -- that half is inert rather than broken. Routing it + -- is still right: the registry has a live reader, and + -- a gated registry would drop the `present` half too. + "battle_sprite_scales", "render_pipelines" }) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 2), spec.target, + "available under Gen 2 at its Gen 1 path: " .. name) + T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name) +end + +-- The tables Gold namespaces: same registry NAME, a Gen 2 Data path +-- underneath it. src/core/Game2.lua:load reads each of these into game.data +-- before mods:load runs, and the consumer holds it by reference -- +-- src/world/gen2/World.lua:dataTable for the overworld four, the menus for +-- palettes/icons/battle_anims/constants -- so the merge lands in the table the +-- game walks. The battle-rule six have no table on disk at all: they come +-- into existence AS the merge (src/mods/Builtins.lua seeds Gold's own records +-- there), and each consumer reads them through a per-id lookup that falls back +-- to the module's records, so a mod-free boot behaves identically. +-- The pairing is asserted both ways round: routed is NOT the Gen 1 target +-- (that would mean merging into a table no Gold boot reads) and NOT nil (that +-- would mean the write is being dropped). +for name, path in pairs({ maps = "gen2Maps", tilesets = "gen2Tilesets", + sprites = "gen2Sprites", text = "gen2Text", + encounters = "gen2Encounters", + trainers = "gen2Trainers", + palettes = "gen2Palettes", icons = "gen2Icons", + battle_anims = "gen2BattleAnims", + constants = "gen2Constants", + statuses = "gen2Statuses", + move_effects = "gen2MoveEffects", + item_effects = "gen2ItemEffects", + balls = "gen2Balls", + ai_classes = "gen2AiClasses", + evolution_methods = "gen2EvolutionMethods" }) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 2), path, + "available under Gen 2 at its Gen 2 path: " .. name) + T.check(Schemas.targetFor(name, spec, 2) ~= spec.target, + "a routed registry does not keep the Gen 1 path: " .. name) + T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name) + T.eq(Schemas.targetFor(name, spec, 1), spec.target, + "and Gen 1 is untouched by the routing: " .. name) +end + +-- The mirror set: six registries that exist because GOLD does. They carry no +-- Gen 1 target at all, so the routed path is the only path they ever have, and +-- Schemas.GEN1 gates them on Red the way Schemas.GEN2 gates `map_scripts` on Gold. +-- Each is held to a live consumer, which is the claim that matters: a routed +-- registry nothing reads is the silent no-op the whole routing table exists to +-- prevent. The per-consumer cases are in +-- tests/engine/gen2_content_registries.lua; here the pairing itself is pinned. +-- held_items src/core/gen2/ItemEffects.lua:heldItemFor / applyHeldItems, +-- written back onto data.items for Battle:itemDef +-- phone_contacts src/core/gen2/Phone.lua:useRegistry +-- decorations src/core/gen2/Decorations.lua:attributes +-- apricorns src/core/gen2/Apricorns.lua:useRegistry +-- landmarks src/core/gen2/Nests.lua:landmarkId / landmark +-- radio_channels src/ui/gen2/MapRadio.lua:channelRecord +for name, path in pairs({ held_items = "gen2HeldItems", + phone_contacts = "gen2PhoneContacts", + decorations = "gen2Decorations", + apricorns = "gen2Apricorns", + landmarks = "gen2Landmarks.landmarks", + radio_channels = "gen2RadioChannels" }) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 2), path, + "a Gen 2-only registry is available under Gen 2: " .. name) + T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name) + T.eq(spec.target, nil, + "and carries no Gen 1 target to fall back on: " .. name) + T.eq(Schemas.gatedFor(name, 1), true, + "so a Red boot reports the write rather than merging it: " .. name) + T.eq(Schemas.targetFor(name, spec, 1), nil, + "and has no Gen 1 path at all: " .. name) +end + +-- and the ones Gold has no home for are gated, not silently retargeted. +-- One cause is left behind these: Gold reimplements the system WITHOUT +-- reading a registry, so there is no table a merge could land in that anything +-- would read. Closing one is a consumer change in the Gen 2 module first and +-- a routing row second, which is exactly how growth_rates closed -- +-- src/battle/gen2/Mon.lua grew growthFor / registerInto and takes an +-- expForLevel record ahead of the coefficient row, so the registry now routes +-- to the SHARED Gen 1 path and one mod record serves both games. +-- +-- `tokens` was on this list by mistake rather than by cause: TextBox.new runs +-- TextBox.substitute on every box in both generations and substitute reads +-- game.data.tokens, so the shared target was live on Gold the whole time. +-- `battle_sprite_scales` and `render_pipelines` came off it the way +-- growth_rates did, consumer first: src/ui/gen2/BattleState.lua:imageScale now +-- reads data.battle_sprite_scales, and src/core/Game2.lua:load installs +-- src/render/Pipelines.lua on Gold's merged dataset after mods:load so +-- Game2:draw composites a `present` pipeline. Both are asserted in the shared +-- set above. +-- +-- `transitions` stays because Gold's own intro +-- (src/ui/gen2/BattleTransition.lua) keys STYLES as a boolean SET of the four +-- cart wipes rather than the { frames, draw, sound, flash } record this +-- registry carries, and has no styleDef lookup a mod id could reach. +-- +-- map_scripts is the one genuine script-side gap left: src/script/gen2/Vm.lua +-- runs the cart's bytecode out of data.gen2Scripts keyed by ROM pointer, and a +-- Lua row list merged into that pool is not something the VM can run. +for _, name in ipairs({ "map_scripts", "rulesets", "transitions", + "field", "text_pointers", "link_fields" }) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "catalog still has registry: " .. name) + T.eq(Schemas.targetFor(name, spec, 2), nil, + "gated registry has no Gen 2 target: " .. name) + T.eq(Schemas.gatedFor(name, 2), true, "gated under Gen 2: " .. name) +end + +-- A routed registry is only routed if the records already sitting at that +-- path pass the shared schema, so this pins the two optional warp fields Gen 2 +-- carries (the ROM map-group pair) that a strict record would otherwise +-- reject on every one of Gold's 368 maps. +do + local spec = Schemas.REGISTRIES.maps + local gen2Map = { + id = "MOD_TOWN", tileset = "TILESET_JOHTO", width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 6, y = 3, destMap = "ELMS_LAB", destWarp = 1, + destGroup = 24, destMapNum = 5 } }, + } + T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen2Map, "register"), + "a Gen 2 warp row validates against the shared maps schema") + local gen1Map = { + id = "MOD_TOWN", tileset = "OVERWORLD", width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1, destMap = "PALLET_TOWN", destWarp = 1 } }, + } + T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen1Map, "register"), + "and the Gen 1 warp row still does, the added fields being optional") +end + +-- ------- 3b. the per-generation RECORD shape +-- +-- Routing says where a registration lands; this says what a record there looks +-- like. A registry whose Gen 2 records differ carries gen2Fields / gen2Keys / +-- gen2Write beside the Gen 1 slots and Schemas.shapeFor folds them onto the +-- canonical names, which is what let the six shaped registries above be routed +-- at all: without it a Gold species would be judged against Red's `special`. + +do + local spec = Schemas.REGISTRIES.pokemon + T.eq(Schemas.shapeFor("pokemon", spec, 1), spec, + "Gen 1 gets the catalog spec itself, not a copy") + local gen2 = Schemas.shapeFor("pokemon", spec, 2) + T.check(gen2 ~= spec, "Gen 2 gets a derived spec") + T.eq(Schemas.shapeFor("pokemon", gen2, 2), gen2, + "resolving a derived spec again is a no-op") + T.check(gen2.fields.levelMoves ~= nil and gen2.fields.level1Moves == nil, + "the Gen 2 species shape is folded onto `fields`") + T.eq(gen2.gen2Fields, nil, "and the gen2* keys are gone from the derived spec") + T.eq(gen2.target, "pokemon", + "a reshaped registry that is not rerouted keeps its path") + + -- the split special stats, which is the difference that makes register + -- usable on Gold at all + local gold = { + id = "MODMON", name = "MODMON", dex = 252, + types = { "GRASS" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + catchRate = 45, baseExp = 64, growthRate = "MEDIUM_SLOW", + levelMoves = { { level = 1, move = "FIX_TACKLE" } }, + evolutions = {}, + spriteFront = "a.png", spriteBack = "b.png", picSize = 5, + } + T.check(Schemas.check(spec, "pokemon", "MODMON", gold, "register", 2), + "a Gen 2 species record registers under Gen 2") + T.check(not Schemas.check(spec, "pokemon", "MODMON", gold, "register", 1), + "and the same record is not a Gen 1 species") + local _, err = Schemas.check(spec, "pokemon", "FIXMON_A", + { baseStats = { special = 80 } }, "patch", 2) + T.check(err ~= nil and err:match("special"), + "a Gen 1 baseStats.special is rejected under Gen 2: " .. tostring(err)) +end + +do + -- trainers routes one level further in, into .classes, and battle_anims + -- CLEARS the Gen 1 write (there the ids are the subtables the Gen 1 write + -- would have routed into) + local trainers = Schemas.shapeFor("trainers", Schemas.REGISTRIES.trainers, 2) + T.eq(trainers.target, "gen2Trainers", "the derived spec carries the routed path") + T.check(trainers.write ~= nil and trainers.baseAt ~= nil + and trainers.baseIds ~= nil, + "trainers reaches into .classes through write/baseAt/baseIds") + local anims = Schemas.shapeFor("battle_anims", Schemas.REGISTRIES.battle_anims, 2) + T.eq(anims.write, nil, "gen2Write = false clears the Gen 1 write") + T.eq(anims.baseAt, nil, "and the Gen 1 baseAt with it") + T.check(anims.keys ~= nil and anims.value == nil, + "a Gen 2 shape described by keys clears the Gen 1 value slot") +end + +-- a Gen 2 shape on a registry with no Gen 2 home would be dead code: nothing +-- ever validates against it, because the write is dropped before it is checked +for name, spec in pairs(Schemas.REGISTRIES) do + if Schemas.hasGen2Shape(spec) then + T.eq(Schemas.gatedFor(name, 2), false, + "a registry with a Gen 2 shape is not gated: " .. name) + end +end + +-- every routing entry names a real registry, so a rename cannot leave a +-- stale row behind that silently stops gating anything +for name in pairs(Schemas.GEN2) do + T.check(Schemas.REGISTRIES[name] ~= nil, + "Schemas.GEN2 names a real registry: " .. name) +end + +-- a routed path must not be some other registry's path: two registries +-- folding into one table would let the second one's ids overwrite the first's +do + local claimed = {} + for name, spec in pairs(Schemas.REGISTRIES) do + local path = Schemas.targetFor(name, spec, 2) + if path then + T.check(claimed[path] == nil or claimed[path] == name, + ("two registries share one Gen 2 path (%s): %s and %s") + :format(path, tostring(claimed[path]), name)) + claimed[path] = name + end + end +end + +-- ------- 4. the shared names, raised from Gold's own call sites +-- +-- The rule: when a Gen 2 call site lands for something Gen 1 already names, +-- it reuses the EXACT name, so one mod's subscription serves both games. The +-- catalog reads the names back out of the source (tests/modkit/catalog.lua +-- scans for Runtime.emit / Runtime.call), so each name below is held to +-- having BOTH a site inside a Gen 2 module and a site outside one. A +-- "gen2.world.stepped" would satisfy the first half and fail the second, +-- which is exactly the drift this gate exists to catch; so would quietly +-- deleting Gold's site while docs/mod-api-gen2-compat.md still promises it. +-- +-- Payload parity cannot be checked here (the shapes come from a live Gold +-- boot, and this file is ROM-free); tests/engine/gate_hooks.lua and +-- gate_events.lua carry the per-payload cases, and the Gen 2 sites were +-- proved against the gold_* drivers. + +local Catalog = T.catalog + +-- Gold's modules live under a gen2/ directory, except the two that own the +-- boot and the extractor and carry the generation in their name +local function isGen2Site(path) + return path:match("gen2") ~= nil or path:match("Gen2") ~= nil + or path:match("Game2") ~= nil +end + +local GEN2_EVENTS = { + -- overworld + "map.entered", "map.exited", "map.reloaded", "player.warped", + "world.stepped", "world.interacted", "world.npc_spawned", + "world.trainer_engaged", "world.blacked_out", "world.block_replaced", + "world.boulder_moved", "world.tod_changed", "world.object_toggled", + "flag.changed", + -- battle + "battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended", + "battle.move_used", "battle.damage_dealt", "battle.fainted", + "battle.status_inflicted", "battle.battler_switched", "battle.ball_thrown", + "battle.exp_gained", "pokemon.level_up", "pokemon.move_learned", + -- the catch and the evolution themselves: pushCaught emits after the mon is + -- in the party or the box, Evolution.apply after the species swap, both + -- matching the Gen 1 payload keys + "pokemon.caught", "pokemon.evolved", + -- boot, save and the script VM + "game.ready", "save.created", "save.loaded", "save.loading", "save.writing", + "script.started", "script.ended", + -- The new-game speech. Gold's is a different scene (Elm, not Oak, and its + -- own src/ui/gen2/OakSpeech.lua), but it is the SAME moment -- the intro + -- asking the player for the answers a save is built from -- so it keeps Gen + -- 1's four names and payload keys rather than inventing "intro.elm_speech". + "intro.oak_speech.started", "intro.oak_speech.step", + "intro.oak_speech.answered", "intro.oak_speech.finished", +} + +local GEN2_HOOKS = { + -- overworld + "warp.destination", "movement.collision", "movement.speed", + "encounter.roll", "encounter.species", "encounter.fishing", + "world.tod", "map.palette", "fieldmove.eligibility", + -- menus and the battle intro + "ui.start_menu.items", "ui.title_menu.items", "ui.options.rows", + "ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu", + "transition.style", + -- battle + "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", + "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain", + "catch.rate", "trainer.party", + -- one wrap cancels or forces an evolution in either game: Gold passes `data` + -- where Gen 1 passes `game`, and positions 2-4 (mon, row, trigger) match + "evolution.check", + -- save and the script VM + "save.write", "save.new_game", "script.command", + -- the intro's step list, wrapped before the first card draws. Same hook, + -- same (steps, speech) arguments and same "return the list" contract as + -- src/ui/OakSpeech.lua's, so one wrapper reorders either game's speech. + "intro.oak_speech.build", + -- Battle seams Gold raises from src/battle/gen2/Battle.lua and + -- src/ui/gen2/BattleState.lua. battle.low_health_alarm carries `data` on + -- Gold where Gen 1's vanilla link reads ctx.battle.data: Gold's battle + -- screen has no .data field, so the key is ADDED beside the Gen 1 ones + -- rather than the payload being reshaped (docs/mod-api-gen2-compat.md warns + -- that a Gen 1 mod reaching through ctx.battle.data instead of calling + -- nextFn gets nil there). + "battle.catch_exp", "battle.low_health_alarm", "battle.overlay", + -- One pic path resolver for both games: the Gen 1 site is the SHARED + -- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook + -- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no + -- concept of. + "pokemon.sprite", + -- The player's own trainer pic, same story: the Runtime.call is the shared + -- src/pokemon/Sprites.lua and Gold's battle back pic, Hall of Fame and intro + -- resolve their own path into Sprites.playerPic with the Gen 1 ctx keys. + "player.sprite", + -- The frame itself, from src/core/Game2.lua, in the same places + -- src/core/Game.lua raises them: the logic tick before the pad is read, a + -- pointer with the touch overlay given first refusal, the palette zone list + -- handed to the present pass, the letterbox and the HUD rect. + "input.step", "input.pointer", + "render.zones", "render.compose", "render.letterbox", "render.hud", +} + +local function assertShared(name, sites, kind) + local gen2, gen1 = 0, 0 + for _, path in ipairs(sites) do + if isGen2Site(path) then gen2 = gen2 + 1 else gen1 = gen1 + 1 end + end + T.check(gen2 > 0, ("Gold raises the %s: %s"):format(kind, name)) + T.check(gen1 > 0, + ("the %s %s is shared, not a Gen 2 invention (no Gen 1 site)") + :format(kind, name)) +end + +for _, name in ipairs(GEN2_EVENTS) do + assertShared(name, Catalog.eventSites(name), "event") +end +for _, name in ipairs(GEN2_HOOKS) do + assertShared(name, Catalog.hookSites(name), "hook") +end + +-- and the lists are COMPLETE, not a sample. Without this half the gate only +-- catches a seam being taken away; a Gen 2 site landing for a Gen 1 name and +-- never reaching docs/mod-api-gen2-compat.md is the other drift, and it is the +-- more likely one -- the doc is where an author looks to decide whether a +-- subscription serves both games, so an unlisted shared seam reads as absent. +local function assertListed(names, catalogNames, sites, kind) + local listed = {} + for _, name in ipairs(names) do listed[name] = true end + for _, name in ipairs(catalogNames) do + if not Catalog.isModEvent(name) then + local gen2, gen1 = false, false + for _, path in ipairs(sites(name)) do + if isGen2Site(path) then gen2 = true else gen1 = true end + end + if gen2 and gen1 then + T.check(listed[name], + ("%s %s has a site in both generations but is not in this gate's " + .. "list; add it here and to docs/mod-api-gen2-compat.md") + :format(kind, name)) + end + end + end +end + +assertListed(GEN2_EVENTS, Catalog.events(), Catalog.eventSites, "event") +assertListed(GEN2_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook") + +-- and nothing anywhere invents a generation-prefixed name. New-in-Gen-2 +-- systems (held_item.trigger, egg.hatched) get plain names of their own; +-- "gen2." would be a namespace no Gen 1 mod could ever match. +for _, name in ipairs(Catalog.events()) do + T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed event: " .. name) +end +for _, name in ipairs(Catalog.hooks()) do + T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed hook: " .. name) +end + +-- ------- 4b. the seams Gen 2 invents +-- +-- The other half of the shared-name rule. Section 4 holds a name Gen 1 +-- already has to keeping it; these are the systems Red does not have at all +-- (friendship, breeding, the Pokegear, the radio, Pokerus, the roamers, Kurt, +-- the Bug Contest, the Unown puzzle, mail, held items, shininess and gender), +-- so a NEW name is justified -- and the discipline is the same one from the +-- other side: a plain name, never a "gen2." namespace no Gen 1 mod could +-- match, so that when Red ever grows the system the name is already right. +-- +-- Three things are asserted per seam, and each one has failed at some point in +-- a review of this programme: +-- +-- 1. the site exists at all. docs/mod-api-gen2-compat.md promises these by +-- name, so a deleted emit is doc drift the moment it happens. +-- 2. every site is inside a Gen 2 module. If a Gen 1 site ever appears the +-- seam is no longer Gen 2-only and belongs in the shared lists above, +-- where BOTH halves are checked -- this is the tripwire for that move. +-- 3. the site is guarded by Runtime.wants / wantsHook for its own name, so a +-- mod-free boot allocates no payload table. Several of these sit in the +-- step loop (happiness.changed, roamer.moved) or in the damage path +-- (held_item.trigger, eight triggers a turn), where an unguarded emit is +-- a per-frame cost every player pays for a feature nobody enabled. +-- +-- Payload keys are not checkable here (this file is ROM-free); +-- tests/engine/gen2_new_seams.lua drives each one through a live bus and +-- asserts the payload the call site documents. + +local GEN2_ONLY_EVENTS = { + "happiness.changed", "breeding.egg_created", "egg.hatched", + "phone.call_received", "clock.day_changed", "pokerus.infected", + "roamer.moved", "roamer.encountered", "apricorn.converted", + "bug_contest.scored", "unown.unlocked", "radio.channel", + "mail.written", "mail.read", + -- The GS boot cinema, card by card. Red boots straight into its title + -- screen, so there is no Gen 1 moment for these to share a name with; they + -- are plain names rather than "gen2." ones so that the day Red grows a + -- cinema the name is already right. Each fires as its card comes UP, with + -- movie_ended the one card END worth a name of its own (it is where the + -- attract loop restarts). + "intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie", + "intro.boot.movie_ended", "intro.boot.title", +} + +local GEN2_ONLY_HOOKS = { + "held_item.trigger", "breeding.compatibility", "phone.contact_list", + "shiny.roll", "gender.roll", +} + +local sourceCache = {} +local function sourceOf(path) + if sourceCache[path] == nil then + local handle = io.open(path, "r") + sourceCache[path] = handle and handle:read("*a") or false + if handle then handle:close() end + end + return sourceCache[path] or nil +end + +local function assertGen2Only(name, sites, kind, guard) + T.check(#sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name)) + local guarded = false + for _, path in ipairs(sites) do + T.check(isGen2Site(path), + ("a Gen 2-only %s is raised from a Gen 2 module (%s is not one): %s") + :format(kind, path, name)) + local body = sourceOf(path) + if body and body:find(('%s("%s")'):format(guard, name), 1, true) then + guarded = true + end + end + T.check(guarded, + ("the %s %s is guarded by %s, so a mod-free boot pays nothing") + :format(kind, name, guard)) +end + +for _, name in ipairs(GEN2_ONLY_EVENTS) do + assertGen2Only(name, Catalog.eventSites(name), "event", "Runtime.wants") +end +for _, name in ipairs(GEN2_ONLY_HOOKS) do + assertGen2Only(name, Catalog.hookSites(name), "hook", "Runtime.wantsHook") +end + +-- complete both ways, like the shared lists: a seam raised ONLY from Gen 2 +-- modules is by definition a Gen 2-only one, so if it is not listed above it +-- has skipped the guard check, the doc's payload table and gen2_new_seams.lua +-- all at once. +local function assertGen2OnlyListed(names, catalogNames, sites, kind) + local listed = {} + for _, name in ipairs(names) do listed[name] = true end + for _, name in ipairs(catalogNames) do + if not Catalog.isModEvent(name) then + local anyGen1 = false + for _, path in ipairs(sites(name)) do + if not isGen2Site(path) then anyGen1 = true end + end + if not anyGen1 then + T.check(listed[name], + ("%s %s is raised from Gen 2 modules alone but is not listed as a " + .. "Gen 2-only seam; add it here and to " + .. "docs/mod-api-gen2-compat.md"):format(kind, name)) + end + end + end +end + +assertGen2OnlyListed(GEN2_ONLY_EVENTS, Catalog.events(), Catalog.eventSites, + "event") +assertGen2OnlyListed(GEN2_ONLY_HOOKS, Catalog.hooks(), Catalog.hookSites, + "hook") + +-- ------- 5. the gate, through a real load + +local GEN1_ONLY = { + ["mods/fix_gen1_only/manifest.json"] = [[{ + "id": "fix_gen1_only", + "name": "Fixture Gen 1 Only", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_gen1_only/main.lua"] = [[ + local mod = ... + mod.content.pokemon:patch("FIXMON_A", { catchRate = 111 }) + ]], +} + +local GEN2_READY = { + ["mods/fix_gen2_ready/manifest.json"] = [[{ + "id": "fix_gen2_ready", + "name": "Fixture Gen 2 Ready", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "gen2compat": true + }]], + ["mods/fix_gen2_ready/main.lua"] = [[ + local mod = ... + -- one registry with a Gen 2 home, one without: the first applies in both + -- generations, the second applies in Gen 1 and reports in Gen 2. + -- `transitions` is the gated one because Gold draws its own battle intro + -- (src/ui/gen2/BattleTransition.lua) and never composes through the Gen 1 + -- Renderer, so nothing on a Gold boot would ever read the merged record. + mod.content.pokemon:patch("FIXMON_A", { catchRate = 123 }) + mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 }) + ]], +} + +local function files(...) + local out = {} + for _, set in ipairs({ ... }) do + for path, body in pairs(set) do out[path] = body end + end + return out +end + +local function statusOf(run, id) + for _, entry in ipairs(run.loader:status().available) do + if entry.id == id then return entry end + end + return nil +end + +-- Gen 1: both mods run, both patches land +do + local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, { + fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)), + generation = 1, + }) + T.eq(statusOf(run, "fix_gen1_only").state, "loaded", + "Gen 1: a mod with no gen2compat loads") + T.eq(statusOf(run, "fix_gen2_ready").state, "loaded", + "Gen 1: a gen2compat mod loads too") + T.eq(run.data.pokemon.FIXMON_A.catchRate, 123, + "Gen 1: the later mod's patch merged") + T.check(run.data.transitions ~= nil + and run.data.transitions.FIXTURE_WIPE ~= nil, + "Gen 1: transitions merged") + run.release() +end + +-- Gen 2: the undeclared mod is skipped whole, the declared one runs +do + local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, { + fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)), + generation = 2, + }) + + local skipped = statusOf(run, "fix_gen1_only") + T.eq(skipped.state, "wrong_generation", + "Gen 2: a mod with no gen2compat is not loaded") + T.check(skipped.note ~= nil and skipped.note:match("gen2compat"), + "Gen 2: the skip says why") + T.eq(skipped.error, nil, + "Gen 2: a skip is not reported as a failure") + T.eq(skipped.enabled, true, + "Gen 2: the player's enable flag is untouched by the skip") + + T.eq(statusOf(run, "fix_gen2_ready").state, "loaded", + "Gen 2: the declared mod loads") + + -- the skipped mod's registration must leave no trace: 111 would mean it ran + T.eq(run.data.pokemon.FIXMON_A.catchRate, 123, + "Gen 2: only the declared mod's patch merged") + + -- a gated registry takes the write, drops it, and says so + T.check(run.data.transitions == nil + or run.data.transitions.FIXTURE_WIPE == nil, + "Gen 2: a gated registry merges nothing") + local told = false + for _, message in ipairs(run.errors) do + if message:match("transitions") and message:match("Gen 2") then told = true end + end + T.check(told, "Gen 2: the dropped registration is reported, not silent") + run.release() +end + +-- The drop is worded from the loader's own generation, because the gating runs +-- both ways: a Red boot rejecting a write to a Gen 2-only registry must not +-- claim the registry has "no Gen 2 target". The registry name and the drop +-- were always right; the sentence was one-directional. +do + local MIRROR = { + ["mods/fix_gen1_drop/manifest.json"] = [[{ + "id": "fix_gen1_drop", + "name": "Fixture Gen 1 Drop", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_gen1_drop/main.lua"] = [[ + local mod = ... + mod.content.decorations:patch("deco:2", { name = "COZY" }) + ]], + } + local run = T.sdk.loadMods({ "mods/fix_gen1_drop" }, + { fs = T.sdk.memfs(MIRROR), generation = 1 }) + local told + for _, message in ipairs(run.errors) do + if message:match("decorations") then told = message end + end + T.check(told ~= nil, + "Gen 1: a write to a Gen 2-only registry is reported") + T.check(told and told:match("Gen 1"), + "Gen 1: and the report names Gen 1, not Gen 2: " .. tostring(told)) + T.eq(run.data.gen2Decorations, nil, "Gen 1: and nothing merged") + run.release() +end + +-- ------- 5b. the vanilla records at a routed path are GOLD's +-- +-- Six of the routed registries are the battle rules, and there the registry is +-- not just a merge target: src/battle/gen2/Catching.lua:recordFor, +-- Battle.statusRecordFor / moveEffectRecordFor, Ai.layersFor, +-- Evolution.methodFor and src/core/gen2/ItemEffects.lua:recordFor all read the +-- merged table. So WHICH module seeds it is load bearing, and it is not the +-- one that seeds Red: the ids collide. src/mods/Builtins.lua swaps the +-- registrant per generation and this holds it to that -- seeding Red's +-- GREAT_BALL would leave Gold's x1.5 multiplier nil, which reads as a ball +-- that quietly stopped working. +do + local gen1 = T.sdk.loadNone({ generation = 1 }) + local gen2 = T.sdk.loadNone({ generation = 2 }) + + local ball1 = gen1.loader.content.balls:get("GREAT_BALL") + local ball2 = gen2.loader.content.balls:get("GREAT_BALL") + T.check(ball1 ~= nil and ball1.hpFactor ~= nil and ball1.multiplier == nil, + "Gen 1 seeds Red's GREAT_BALL (an HP factor, no multiplier)") + T.check(ball2 ~= nil and ball2.multiplier == 1.5, + "Gen 2 seeds Gold's GREAT_BALL (the x1.5 the cart multiplies by)") + + -- statuses are the clearest case of the shared NAME over different ids: + -- Red writes BRN into mon.status where Gold writes "burn" + T.check(gen1.loader.content.statuses:get("BRN") ~= nil, + "Gen 1 seeds Red's status ids") + T.check(gen2.loader.content.statuses:get("burn") ~= nil + and gen2.loader.content.statuses:get("BRN") == nil, + "Gen 2 seeds Gold's status ids and none of Red's") + + -- Ai.layersFor walks the merged table for mod-registered scoring passes, so + -- Red's LAYER_1..LAYER_3 landing there would join Gold's ten + T.check(gen1.loader.content.ai_classes:get("LAYER_1") ~= nil, + "Gen 1 seeds Red's move-scoring layers") + T.check(gen2.loader.content.ai_classes:get("LAYER_1") == nil + and gen2.loader.content.ai_classes:get("SMART") ~= nil, + "Gen 2 seeds Gold's scoring passes instead") + + -- and the Gen 2 VM's verb table is the mod verbs alone: a Gen 1 row-list + -- verb handed Gold's ctx would find no runner on it + T.check(gen1.loader.content.commands:get("show_text") ~= nil, + "Gen 1 seeds the row-list verbs") + T.eq(gen2.loader.content.commands:get("show_text"), nil, + "Gen 2 seeds none of them") + T.eq(gen2.data.commands, nil, + "and a mod-free Gold boot leaves data.commands absent entirely") + + -- the seeded records land at the routed path, not the Gen 1 one + T.check(gen2.data.gen2Statuses ~= nil and gen2.data.gen2Statuses.burn ~= nil, + "the Gen 2 records merge into their Gen 2 path") + T.eq(gen2.data.statuses, nil, + "and nothing is written to the Gen 1 path a Gold boot never reads") + T.check(gen1.data.statuses ~= nil and gen1.data.statuses.BRN ~= nil, + "while Gen 1 is untouched by any of it") + + gen1.release() + gen2.release() +end + +-- A gated registry is an absent id space, not an empty one. Gold's species +-- carry a growthRate exactly as Red's do, so a patch that keeps one must not +-- be reported as referencing something that does not exist just because the +-- Gen 1 `growth_rates` namespace has no Gen 2 home. A ROUTED registry is the +-- opposite: `evolution_methods` has real ids on Gold now, so the same pass +-- resolves an evolution's method against them and a typo is caught. +local function refsFixture(body) + return { + ["mods/fix_refs/manifest.json"] = [[{ + "id": "fix_refs", + "name": "Fixture Refs", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "gen2compat": true + }]], + ["mods/fix_refs/main.lua"] = body, + } +end + +-- The ROM-free fixture dataset is Gen 1 shaped, and Gold hangs its experience +-- curves off data.pokemon.growthRates (which src/mods/Builtins.lua's Gen 2 +-- registrant seeds the growth_rates registry from). A generation-2 run over +-- unmodified fixtures therefore seeds no curves, and every fixture species' +-- growthRate reads as a dangling reference -- an artifact of the dataset, not +-- of the engine: on a real Gold boot the ids line up exactly (both sides say +-- GROWTH_MEDIUM_SLOW). Added per-run rather than to tests/fixture_data, whose +-- shape is Gen 1's and whose fingerprint is a committed golden. +local function gen2Fixtures() + local data = T.fixtures.fresh() + -- pokegold data/growth_rates.asm's MEDIUM_SLOW row, under the id the fixture + -- species reference + data.pokemon.growthRates = { + MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, + } + return data +end + +local function danglingRefs(run) + local dangling = {} + for _, message in ipairs(run.errors) do + if message:match("unresolved reference") then + dangling[#dangling + 1] = message + end + end + return dangling +end + +do + -- the Gen 2 evolution row shape: `into` rather than `species`, and Gold's + -- own EVOLVE_* method ids, which src/core/gen2/Evolution.lua seeds + local run = T.sdk.loadMods({ "mods/fix_refs" }, { + fs = T.sdk.memfs(refsFixture([[ + local mod = ... + local base = mod.content.pokemon:get("FIXMON_A") + mod.content.pokemon:patch("FIXMON_A", { + catchRate = 90, + growthRate = base.growthRate, + evolutions = { { method = "EVOLVE_LEVEL", level = 16, + into = "FIXMON_B" } }, + }) + ]])), + data = gen2Fixtures(), + generation = 2, + }) + local dangling = danglingRefs(run) + T.eq(#dangling, 0, + "Gen 2: a record whose refs all resolve reports nothing (" + .. table.concat(dangling, "; ") .. ")") + T.eq(run.data.pokemon.FIXMON_A.catchRate, 90, "Gen 2: the patch still landed") + run.release() +end + +do + local run = T.sdk.loadMods({ "mods/fix_refs" }, { + fs = T.sdk.memfs(refsFixture([[ + local mod = ... + mod.content.pokemon:patch("FIXMON_A", { + evolutions = { { method = "EVOLVE_BY_VIBES", level = 16, + into = "FIXMON_B" } }, + }) + ]])), + data = gen2Fixtures(), + generation = 2, + }) + local dangling = danglingRefs(run) + T.eq(#dangling, 1, + "Gen 2: a routed registry HAS an id space, so a bad method is caught") + T.check(dangling[1] and dangling[1]:match("evolution_methods"), + "Gen 2: and the report names the registry it could not resolve against") + run.release() +end + +-- the skip is contagious as a SKIP. A mod that DID claim gen2compat but sits +-- on one that did not is left out with the dependency's own wording, not +-- failed with "dependency X failed to load": neither mod has a bug and neither +-- belongs on the boot error list the player is shown. +do + local DEPENDENT = { + ["mods/fix_gen2_dependent/manifest.json"] = [[{ + "id": "fix_gen2_dependent", + "name": "Fixture Gen 2 Dependent", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "gen2compat": true, + "dependencies": ["fix_gen1_only"] + }]], + ["mods/fix_gen2_dependent/main.lua"] = [[ + local mod = ... + mod.content.pokemon:patch("FIXMON_A", { catchRate = 222 }) + ]], + } + local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_dependent" }, { + fs = T.sdk.memfs(files(GEN1_ONLY, DEPENDENT)), + generation = 2, + }) + local dependent = statusOf(run, "fix_gen2_dependent") + T.eq(dependent.state, "wrong_generation", + "Gen 2: a dependent of a gate-skipped mod is skipped, not failed") + T.eq(dependent.error, nil, + "Gen 2: the dependent's skip is not reported as a failure") + T.check(dependent.note ~= nil and dependent.note:match("gen2compat"), + "Gen 2: the dependent's skip names the dependency's reason") + T.eq(#run.errors, 0, "Gen 2: neither mod contributes a boot error") + run.release() +end + +-- the player's override: options.modsGen2 forces a mod past the gate, because +-- the manifest flag is the AUTHOR's claim and a mod written before the field +-- existed can never carry one +do + local fs = T.sdk.memfs(files(GEN1_ONLY)) + fs.write("options.lua", require("src.core.SaveSerializer").encode({ + mods = {}, modsGen2 = { fix_gen1_only = true }, + })) + local run = T.sdk.loadMods({ "mods/fix_gen1_only" }, + { fs = fs, generation = 2 }) + local forced = statusOf(run, "fix_gen1_only") + T.eq(forced.state, "loaded", "Gen 2: the override loads an unclaimed mod") + T.eq(forced.gen2Forced, true, "Gen 2: the manager sees the override") + T.check(forced.note ~= nil and forced.note:match("not verified"), + "Gen 2: a forced mod still says its author never claimed this game") + T.eq(run.data.pokemon.FIXMON_A.catchRate, 111, + "Gen 2: the forced mod's patch merged") + run.release() +end + +-- a skipped mod is skipped before validation, so a Gen 1 mod with a broken +-- manifest does not ALSO shout about its entry file on a Gold boot +do + local BROKEN = { + ["mods/fix_broken/manifest.json"] = [[{ + "id": "fix_broken", + "name": "Fixture Broken", + "version": "1.0.0", + "entry": "missing.lua", + "api": 2 + }]], + } + local run = T.sdk.loadMods({ "mods/fix_broken" }, + { fs = T.sdk.memfs(BROKEN), generation = 2 }) + T.eq(statusOf(run, "fix_broken").state, "wrong_generation", + "Gen 2: the generation gate runs before entry-file validation") + T.eq(#run.errors, 0, "Gen 2: a skipped mod contributes no boot errors") + run.release() +end + +-- ------- 6. StateStack:clear, which is what Gold's boot cinema hands off +-- through now that it runs the engine stack + +do + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local order = {} + local function state(name) + return { isOpaque = true, exit = function() order[#order + 1] = name end } + end + stack:push(state("a")) + stack:push(state("b")) + stack:push(state("c")) + stack:clear() + T.eq(stack:top(), nil, "clear empties the stack") + T.eq(table.concat(order, ","), "c,b,a", "clear unwinds top-first") +end + +-- Without this the file printed its FAILs and exited 0, so the runner marked +-- the gate "ok" while it was red -- a gate that cannot fail is not a gate. +T.finish("gate_gen2_mod_api") diff --git a/tests/engine/gate_gen2_mod_facade.lua b/tests/engine/gate_gen2_mod_facade.lua new file mode 100644 index 00000000..19692a99 --- /dev/null +++ b/tests/engine/gate_gen2_mod_facade.lua @@ -0,0 +1,640 @@ +-- Gate for the Gen 1 module facades a gen2compat mod's require resolves to +-- (src/mods/Gen2Compat.lua). +-- +-- The rules this file holds, all of them load-bearing for a Gen 1 follower mod +-- running on Gold: +-- * every served name hands back one stable table, and the ones backed by a +-- Gen 2 module ARE that module, so a monkey-patch lands where Gold runs; +-- * the Game facade is a live proxy, not a snapshot, and aliases the two +-- names Gold spells differently (overworld / writeOptions) plus the one +-- data table that was renamed (sprites); +-- * src/world/gen2/Follower.lua keeps a file-local named exactly +-- `shouldSpawn`, shared by update and onMapEntered, because that upvalue +-- NAME is what three separate follower mods rewrite through +-- debug.setupvalue; +-- * World:step ticks the follower and the overworld facade unconditionally, +-- and World:interact dispatches through the facade when one is installed. +-- +-- ROM-free: the world here is hand-built, the way tests/gen2_world_test.lua +-- builds one. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen2 mod facade") +local check, eq = S.check, S.eq + +local Gen2Compat = require("src.mods.Gen2Compat") +local Follower = require("src.world.gen2.Follower") +local Gen2Map = require("src.world.gen2.Map") +local Gen2Npc = require("src.world.gen2.Npc") +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +-- ------- 1. every served name resolves, once, to a table + +local SERVED = { + "src.core.Game", "src.world.NPC", "src.world.Collision", + "src.world.FieldDefaults", "src.pokemon.Boxes", + "src.world.OverworldController", "src.world.PikachuFollower", + "src.world.Map", "src.world.WorldAPI", "src.ui.PartyMenu", "src.ui.BoxMenu", + "src.ui.StartMenu", "src.ui.OptionsMenu", "src.battle.BattleState", + "src.script.ScriptRunner", +} + +for _, name in ipairs(SERVED) do + check(Gen2Compat.serves(name), "served: " .. name) + local a = Gen2Compat.resolve(name, "fixture") + eq(type(a), "table", "resolves to a table: " .. name) + check(a == Gen2Compat.resolve(name, "fixture"), + "one stable table for the run: " .. name) +end + +-- the aliased ones ARE the Gen 2 module, so a mod's patch lands on the table +-- Gold runs rather than on a copy +eq(Gen2Compat.resolve("src.world.PikachuFollower"), Follower, + "PikachuFollower is src/world/gen2/Follower.lua itself") +eq(Gen2Compat.resolve("src.world.Map"), Gen2Map, "Map is the Gen 2 Map") +eq(Gen2Compat.resolve("src.world.NPC"), Gen2Npc, + "NPC is the Gen 2 NPC, so getmetatable(npc) == the module the mod required") + +-- Gen 1's BoxMenu is Bill's PC TOP MENU; Gold's counterpart is PcMenu, and +-- the Gen 2 BoxMenu is the withdraw/deposit LIST Gen 1 builds inline. Aimed +-- at the wrong one, a mod appending a row appends it to an object with no +-- row list at all. +eq(Gen2Compat.resolve("src.ui.BoxMenu"), require("src.ui.gen2.PcMenu"), + "BoxMenu is Gold's PC top menu, not its box list") + +-- Gold has no BattleState.newWild, and inventing one that took a species and a +-- level would be the silent wrong answer this whole layer exists to avoid. +check(Gen2Compat.resolve("src.battle.BattleState").newWild == nil, + "no invented newWild on the Gen 2 battle screen") + +-- ------- 1b. the coverage table, which the modkit checker reads + +eq(Gen2Compat.COVERAGE_VERSION, 1, "the coverage contract is versioned") +local names = Gen2Compat.modules() +eq(#names, #SERVED, "every served name is in the coverage listing") +for _, name in ipairs(names) do + local row = Gen2Compat.coverage(name) + check(row ~= nil, "coverage for " .. name) + eq(row.module, name, "coverage names itself: " .. name) + check(row.kind == "facade" or row.kind == "alias", + "coverage kind is facade or alias: " .. name) + for member, status in pairs(row.members) do + check(status == "backed" or status == "warned" or status == "absent", + ("%s.%s carries one of the three statuses"):format(name, member)) + end +end +eq(Gen2Compat.coverage("src.pokemon.Boxes").kind, "facade", + "Boxes is a facade over the Gen 2 module, not an alias") +eq(Gen2Compat.memberStatus("src.pokemon.Boxes", "deposit"), "backed", + "the deposit override is published as backed") +eq(Gen2Compat.memberStatus("src.battle.BattleState", "makeSafari"), "absent", + "makeSafari is published absent, which is what the wilds mod probes for") +eq(Gen2Compat.memberStatus("src.world.Collision", "load"), "warned", + "Collision.load is present, answers nil and says so") +eq(Gen2Compat.coverage("nope.nope"), nil, "an unserved name has no coverage") + +-- a table per call, so a consumer cannot mutate the adapter's own record +local first = Gen2Compat.coverage("src.world.Collision") +first.members.canMove = "absent" +eq(Gen2Compat.memberStatus("src.world.Collision", "canMove"), "backed", + "coverage hands back a fresh table each call") + +-- ------- 2. the Game facade proxies a LIVE game + +local persisted = 0 +local liveGame = { + world = { tag = "the world" }, + save = { party = {} }, + stack = { tag = "the stack" }, + data = { gen2Sprites = { SPRITE_CHRIS = { id = "SPRITE_CHRIS" } }, + gen2Maps = { TEST_MAP = { id = "TEST_MAP" } }, + gen2Constants = { specialOrder = {} }, + pokemon = { PIDGEY = { name = "PIDGEY" } } }, + persistOptions = function() persisted = persisted + 1 end, +} +local current = nil +Gen2Compat.bind(function() return current end) + +local Game = Gen2Compat.resolve("src.core.Game", "fixture") +eq(Game.save, nil, "captured before a game exists, the facade reads nil") +current = liveGame +eq(Game.save, liveGame.save, "and fills in the moment one is wired") +eq(Game.overworld, liveGame.world, "overworld is the Gen 1 name for .world") +eq(Game.stack, liveGame.stack, "the stack is the real one, so a patch of " + .. "stack.push reaches the engine") +eq(Game.data.pokemon.PIDGEY.name, "PIDGEY", "data forwards") +eq(Game.data.sprites, liveGame.data.gen2Sprites, + "data.sprites is the Gen 1 name for gen2Sprites") +eq(Game.data.field, nil, "data.field has no Gen 2 backing and says so") +Game.writeOptions(Game) +eq(persisted, 1, "writeOptions is persistOptions, called with the live game") +Game._fixtureStamp = true +eq(liveGame._fixtureStamp, true, "a stamp written through the facade lands on " + .. "the live game") + +-- the four Gen 1 members Gold has no counterpart for are NAMED, not answered +eq(Game.renderer, nil, "Game.renderer is absent, not the real Renderer: half " + .. "its surface would answer and the other half silently no-op") +eq(Game.load, nil, "Game:load is absent; calling it would re-run Gold's boot") +eq(Game.bootConfig, nil, "Game:bootConfig is absent") +eq(Game.makeTitleState, nil, "Game:makeTitleState is absent") +eq(Game.data.constants, nil, "data.constants is NOT routed to gen2Constants, " + .. "which is the cart's ordered name lists and a different thing entirely") +eq(Game.data.maps, liveGame.data.gen2Maps, "data.maps is the Gen 1 name") +eq(Game.logicSpeed(), 1, "logicSpeed never returns below 1 on Gold") +eq(type(Game.fixedStep), "table", "fixedStep is the shared singleton") + +-- ------- 3. NPC: the Gen 1 constructor shape, a Gen 2 entity out + +local NPCFacade = Gen2Compat.resolve("src.world.NPC", "fixture") +local sheet = "assets/fixture/follower.png" +local sprites = { SPRITE_PIKACHU = + { id = "SPRITE_PIKACHU", image = sheet, frames = 6, walker = true } } +local trailer = NPCFacade.new({ gen2Sprites = sprites }, "TEST_MAP", { + index = 241, name = "TRAILER_1", sprite = "SPRITE_PIKACHU", + movement = "STAY", range = "NONE", x = 3, y = 4, +}) +eq(getmetatable(trailer), Gen2Npc, "the trailer is a real Gen 2 NPC, so the " + .. "Gen 2 draw list poses it") +eq(trailer.cellX, 3, "cell carried over") +eq(trailer.spriteId, "SPRITE_PIKACHU", "Gen 1's spriteId stamp is kept") +check(not trailer.fixedFacing, "STAY must not map to STILL: a trailer that " + .. "cannot turn is not a follower") +eq(trailer.kind, "stand", "and it never wanders off on its own") +eq(type(trailer.pose), "function", "Gen 1's pose contract exists to be wrapped") + +-- ------- 4. Collision, Boxes, the map vocabulary + +local Collision = Gen2Compat.resolve("src.world.Collision", "fixture") +eq(Collision.DELTA, Gen2Map.DELTA, "one DELTA table, not a copy") +local tx, ty = Collision.target(2, 2, "right") +eq(tx, 3, "target x") eq(ty, 2, "target y") +trailer.passable = true +eq(Collision.occupied({ trailer }, 3, 4, nil), nil, + "a passable entity never blocks a step") + +local Boxes = Gen2Compat.resolve("src.pokemon.Boxes", "fixture") +local save = { currentBox = 2 } +local boxes = Boxes.ensure(save) +check(save.boxes ~= nil, "ensure MATERIALISES save.boxes, because Gen 1's " + .. "callers index it straight afterwards") +eq(#boxes, require("src.core.gen2.Boxes").NUM_BOXES, "all of Gold's boxes") +eq(Boxes.active(save), save.boxes[2], "active is the current box") + +-- COUNT / CAPACITY used to be missing outright, so `for i = 1, Boxes.COUNT` +-- raised "'for' limit must be a number" +eq(Boxes.COUNT, require("src.core.gen2.Boxes").NUM_BOXES, "Boxes.COUNT") +eq(Boxes.CAPACITY, require("src.core.gen2.Boxes").MONS_PER_BOX, "Boxes.CAPACITY") + +-- the inherited Gen 2 deposit(save, partyIndex, boxIndex) would index +-- save.party with a MON TABLE, return false plus "There is no POKeMON there." +-- and drop the mon -- which reads exactly like "every box is full" +local mon = { species = "PIDGEY" } +eq(Boxes.deposit(save, mon), 2, "deposit takes a MON and answers a box number") +eq(save.boxes[2][1], mon, "and the mon is in the save, not in a detached table") + +-- ensure clamps currentBox, which is load bearing: Boxes2.box hands back a +-- fresh DETACHED table for an index outside 1..NUM_BOXES +local wild = { currentBox = 99 } +local wildBoxes = Boxes.ensure(wild) +eq(wild.currentBox, Boxes.COUNT, "ensure clamps currentBox into range") +eq(Boxes.active(wild), wildBoxes[Boxes.COUNT], "so active is a box in the save") + +eq(type(Gen2Map.isCounterCell), "function", "Map:isCounterCell exists") +eq(type(Gen2Map.warpAtCell), "function", "Map:warpAtCell exists") +check(Gen2Map.isOutside({ environment = "TOWN" }), "a town is outside") +check(not Gen2Map.isOutside({ environment = "INDOOR" }), "a house is not") +eq(type(Player.facingCell), "function", "Player:facingCell exists") + +-- the Gen 1 module-level statics, which a facade could not have served +-- because a mod calls them on world.map +for _, name in ipairs({ "blockAt", "setBlock", "tileAt", "isDoorTileCell", + "isWarpTileCell", "signAtCell" }) do + eq(type(Gen2Map[name]), "function", "Map:" .. name .. " exists") +end +check(Gen2Map.isOutdoor({ environment = "ROUTE" }), "a route is outdoor") +check(Gen2Map.inRegion({ id = "GOLDENROD_CITY" }, nil, "GOLDENROD"), + "inRegion falls back to the id prefix, which is honest on either cache") +-- a sprite-name test would answer false for every real boulder on Gold +check(Gen2Map.isPushable({ movement = 0x19 }), "STRENGTH_BOULDER is pushable") +check(not Gen2Map.isPushable({ sprite = "SPRITE_BOULDER" }), + "and the Gen 1 sprite name is not what decides it") +-- absent, not answered: Gold's fly points are landmark spawns and its ghost +-- battles are Kanto content +eq(Gen2Map.isFlyTown, nil, "Map.isFlyTown stays absent on Gold") +eq(Gen2Map.ghostBattles, nil, "Map.ghostBattles stays absent on Gold") + +-- ------- 4b. FieldDefaults answers only what Gold genuinely shares + +local FD = Gen2Compat.resolve("src.world.FieldDefaults", "fixture") +eq(FD.CONSTANTS.world.stepFrames, 16, "stepFrames is 16 on both generations") +eq(FD.CONSTANTS.world.turnFrames, 4, "and turnFrames is 4") +eq(FD.CONSTANTS.encounterBuckets, nil, + "the Gen 1 wild-slot spread is Kanto and must not roll on Gold") +eq(FD.CONSTANTS.hmBadges, nil, "nor may Kanto's HM badge gates") +eq(FD.world(nil, "stepFrames"), 16, "world() answers the shared keys") +eq(FD.world(nil, "poisonStepInterval"), nil, + "and refuses the ones Gold's StepEvents do not read from a table") +eq(FD.FIELD, nil, "FIELD is absent: every leaf of it is Kanto") +eq(FD.seed({}), nil, "seed never writes Kanto's field record into a Gold cache") +-- Gen 1's fieldValue is VARIADIC; a fixed arity dropped every deeper path +eq(FD.fieldValue(nil, "playerSprites", "walk"), World.PLAYER_SPRITE, + "the one data.field path Gold can answer") +eq(FD.fieldValue(nil, "playerSprites"), nil, + "and not the whole table, which a mod would index .surf on") + +-- ------- 5. the follower's shouldSpawn upvalue, by name + +local function upvalueIndex(fn, wanted) + local i = 1 + while true do + local name = debug.getupvalue(fn, i) + if not name then return nil end + if name == wanted then return i end + i = i + 1 + end +end + +local updateIdx = upvalueIndex(Follower.update, "shouldSpawn") +local enterIdx = upvalueIndex(Follower.onMapEntered, "shouldSpawn") +check(updateIdx ~= nil, "Follower.update closes over a local named shouldSpawn") +check(enterIdx ~= nil, "and so does Follower.onMapEntered") + +-- the supported way in writes the SAME cell, so a mod that uses the setter and +-- one that rewrites the upvalue cannot end up with two different predicates +local sentinel = function() return false end +local restore = Follower.setShouldSpawn(sentinel) +eq(select(2, debug.getupvalue(Follower.update, updateIdx)), sentinel, + "setShouldSpawn writes the upvalue debug.setupvalue would have") +Follower.setShouldSpawn(restore) + +-- ------- 6. patch it and the follower spawns, trails and survives a rebuild + +local function fixtureWorld() + local game = { data = {}, save = { party = {} } } + local world = World.new(game) + world.maps = { + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 4, height = 4, + blocks = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + objects = {}, warps = {} }, + } + -- every cell walkable: one block id whose whole quad is COLL_FLOOR + local tileset = { collision = { [2] = { 0, 0, 0, 0 } } } + world.map = Gen2Map.new(world.maps.TEST_MAP, tileset) + world.sprites = { SPRITE_PIKACHU = + { id = "SPRITE_PIKACHU", image = sheet, frames = 6, walker = true } } + world.player = Player.new(4, 4, "down", + { id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true }) + world.npcs, world.entities, world.ghosts = {}, { world.player }, {} + return world, game +end + +local world, game = fixtureWorld() +local always = function() return true end +debug.setupvalue(Follower.update, updateIdx, always) + +-- the two closures share one upvalue cell under 5.1, which is what lets a mod +-- patch either one and suppress both -- assert it rather than assume it +eq(select(2, debug.getupvalue(Follower.onMapEntered, enterIdx)), always, + "update and onMapEntered share the shouldSpawn cell") + +Follower.onMapEntered(game, world, nil, true) +local npc = Follower.current(world) +check(npc ~= nil, "the follower spawns once shouldSpawn says yes") +check(npc.passable, "and never blocks the player") +eq(npc.cellX, 4, "parked on the player for a fresh map load") + +-- Two committed steps right. The first hands the follower the cell it is +-- already standing on (it spawned under the player), the second is the one it +-- has to walk -- and the goal is taken on the COMMIT, while targetX is still +-- set, not on the landing, which is what keeps the gap at one cell. +local function commit(tx) + world.player.targetX, world.player.moving = tx, true + Follower.update(game, world) + world.player.cellX, world.player.targetX = tx, nil + world.player.moving = false +end +commit(5) +commit(6) +eq(npc.goalX, 5, "the goal is the cell the player VACATED, one behind") +check(npc.moving, "and the follower took a step toward it") +eq(npc.targetX, 5, "one cell, in the right direction") +eq(npc.facing, "right", "facing the way it walks") + +-- a seamless rebuild is what used to wipe a mod-inserted entity +world.map.def.objects = {} +world:rebuildPeople({ seamless = true }) +check(Follower.current(world) ~= nil, + "a guest survives rebuildPeople, so a follower does not vanish at the top " + .. "of the hour") +eq(#world.npcs, 1, "and is listed exactly once") + +-- ------- 7. World:step ticks it, World:interact dispatches through the facade + +local ticked = 0 +world.stepBody = function() end +local realUpdate = Follower.update +Follower.update = function() ticked = ticked + 1 end +world:step() +Follower.update = realUpdate +eq(ticked, 1, "World:step calls Follower.update after the body, which is the " + .. "one per-frame driver every Gen 1 follower mod wraps") + +local OC = Gen2Compat.resolve("src.world.OverworldController", "fixture") +world.interactBody = function() return "vanilla" end +eq(world:interact(), "vanilla", "with nothing patched, interact is the body") +local vanillaInteract = OC.interact +OC.interact = function(w) return "wrapped:" .. tostring(vanillaInteract(w)) end +eq(world:interact(), "wrapped:vanilla", + "a replaced OverworldController.interact is what the A press dispatches to") +OC.interact = vanillaInteract + +local owTicks = 0 +local vanillaUpdate = OC.update +OC.update = function() owTicks = owTicks + 1 end +world:step() +OC.update = vanillaUpdate +eq(owTicks, 1, "and a replaced OverworldController.update ticks once a frame") + +world:step() +eq(owTicks, 1, "restored, it costs one comparison and does not run") + +-- talkTo is the other dispatch a follower mod wraps, and it used to be a warn +-- saying the wrapper would never run +local talked = nil +world.player.facing = "down" +world.player.cellX, world.player.cellY = 4, 4 +local guest = Gen2Npc.new("TEST_MAP", { index = 9, x = 4, y = 5, + movement = Gen2Npc.MOVE.STANDING_UP }, + { id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true }) +table.insert(world.npcs, guest) +world.interactBody = nil +world.busy = function() return false end +world.vm = { lastTalked = 0, start = function() return true end, + running = function() return false end } +local vanillaTalk = OC.talkTo +OC.talkTo = function(_w, npc) talked = npc return true end +eq(world:interactBody(), true, "a replaced talkTo intercepts the A press") +eq(talked, guest, "and receives the object Gold resolved") +OC.talkTo = vanillaTalk + +-- ------- 8. the follower's three general members, which were nil calls + +Follower.onMapEntered(game, world, nil, true) +local trailer = Follower.current(world) +check(trailer ~= nil, "a follower to hide") +eq(Follower.at(world, trailer.cellX, trailer.cellY), trailer, + "Follower.at finds a standing follower, which is the interact hook's test") +trailer.moving = true +eq(Follower.at(world, trailer.cellX, trailer.cellY), nil, + "and not a moving one, which is between two cells") +trailer.moving = false +local drawn = 0 +for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end +eq(drawn, 1, "it is on the draw list to begin with") +Follower.setVisible(world, false) +drawn = 0 +for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end +eq(drawn, 0, "setVisible(false) drops it from the DRAW list") +check(Follower.current(world) == trailer, + "and leaves it in the UPDATE list, so it hides in place and keeps trailing") +Follower.setVisible(world, true) +Follower.setVisible(world, true) +drawn = 0 +for _, e in ipairs(world.entities) do if e == trailer then drawn = drawn + 1 end end +eq(drawn, 1, "and re-adding is idempotent rather than doubling the entity") + +-- the Gen 1 name for the trail, by reference: a reset through either name has +-- to move the live one +check(world.pikachuTrail == world.followerTrail, + "ow.pikachuTrail and world.followerTrail are one table") + +-- ------- 9. the NPC instance surface Gen 1 mods pose and draw through + +local posed = { trailer:pose() } +eq(#posed, 7, "pose keeps Gen 1's seven-value contract") +eq(posed[4], trailer.facing, "facing is the fourth") +eq(type(Gen2Npc.marching), "nil", "marching is a FIELD, not a method") +-- Gen 2 saw moving with no targetX, sat still, then assigned cellX = nil and +-- every later read blew up a frame downstream +trailer.marching, trailer.progress, trailer.stepFrames = true, 0, 2 +trailer.moving = false +local wasX, wasY = trailer.cellX, trailer.cellY +trailer:update(world.map, world.entities) +check(trailer.moving, "a marching NPC animates in place") +trailer:update(world.map, world.entities) +eq(trailer.cellX, wasX, "and never leaves its cell") +eq(trailer.cellY, wasY, "on either axis") +check(not trailer.marching, "the cycle ends itself after one step's frames") +trailer.stepFrames = nil + +-- ------- 10. Collision.canMove answers what Gold's own player is told + +-- the surf exception: without it the facade tells a surfing mod every water +-- cell is blocked, one line before Gold rides onto it. 0x20 is COLL_WATER's +-- row in Permissions' table. +local waterMap = Gen2Map.new(world.maps.TEST_MAP, + { collision = { [2] = { 0x20, 0x20, 0x20, 0x20 } } }) +local walker = { cellX = 1, cellY = 1 } +local surfer = { cellX = 1, cellY = 1, surfing = true } +eq(select(2, Collision.canMove(waterMap, {}, walker, "right")), "tile", + "water refuses a walker") +check(Collision.canMove(waterMap, {}, surfer, "right"), + "and carries a surfer, which map:isWalkableCell alone never says") + +-- GetMovementPermissions' side-wall rule (Map:stepPermitted): a facade that +-- omits it says yes where Gold bumps, which is what ends an Ice Path slide +local stubMap = { + inBounds = function() return true end, + isWalkableCell = function() return true end, + cellCollision = function() return 0 end, + stepPermitted = function() return false end, +} +eq(select(2, Collision.canMove(stubMap, {}, walker, "up")), "tile", + "and a step the neighbour's wall kind forbids is refused as 'tile'") + +-- a mod's OWN movement.collision hook has to see its own canMove call +local Runtime = require("src.mods.Runtime") +local realHooks = Runtime.hooks +local seen = nil +Runtime.hooks = { + chains = { ["movement.collision"] = true }, + call = function(_self, name, vanilla, allowed, ctx) + if name ~= "movement.collision" then return vanilla(allowed, ctx) end + seen = ctx + return not allowed + end, +} +check(Collision.canMove(waterMap, {}, walker, "right"), + "the movement.collision chain runs inside the facade's canMove") +eq(seen and seen.reason, "tile", "with the ctx keys both generations use") +eq(seen and seen.toX, 2, "including the target cell") +Runtime.hooks = realHooks + +eq(Collision.load({}), nil, + "Collision.load is a NAMED no-op, never a silent accept: Gold has no " + .. "tile-pair table for the mod's intent to land in") + +-- ------- 11. ScriptRunner: the pure half forwards, the rest is a handle + +local SR = Gen2Compat.resolve("src.script.ScriptRunner", "fixture") +eq(SR.scanLabels({ { "label", "top" }, { "wait", 1 } }).top, 1, + "scanLabels is the real Gen 1 function, pure over the mod's own rows") +-- with Gen 1's default lookup a script of show_text / wait / warp validates +-- CLEAN on Gold and then every row is skipped at run time +liveGame.data.commands = { ["fixture:beep"] = {} } +eq(#SR.validate({ { "show_text", "x" } }), 1, + "validate resolves against Gold's OWN command registry, so a Gen 1 built-in " + .. "is reported rather than passed") +eq(#SR.validate({ { "fixture:beep" } }), 0, "and a registered mod verb passes") +eq(SR.new(nil, world).isRunning, SR.new(nil, world).isRunning, + "the handle carries the query half of the one world.vm") +eq(SR.new(nil, world):resume(), nil, "resume is refused, not forwarded: the " + .. "World already drives the VM and a second call dispatches twice") +eq(SR.new(nil, world):update(), nil, + "and so is update, which would double-decrement every pause") + +-- ow.runner, the field a mod guards on before acting. nil there is FALSEY, +-- so a mod concludes NO SCRIPT IS RUNNING while one is. +check(world.runner ~= nil, "the World carries a runner shim") +eq(world.runner:isRunning(), world:scriptRunning(), + "and answers the Gen 1 query") + +-- ------- 12. a monkey-patch on a proxied class reads back as ITSELF +-- +-- The write-through proxy answered its own override first, so +-- `PartyMenu.new = wrapper` read back as the facade's function (the patch was +-- invisible) and the override then called the class member the write had +-- already replaced -- straight back into the wrapper. + +local Party2 = require("src.ui.gen2.PartyMenu") +local PM = Gen2Compat.resolve("src.ui.PartyMenu", "fixture") +local facadeNew = PM.new +local wrapped = 0 +local wrapper = function(...) wrapped = wrapped + 1 return facadeNew(...) end +PM.new = wrapper +check(rawequal(PM.new, wrapper), "a mod's write to a proxied member reads " + .. "back as the mod's own value, so the patch is visible") +check(rawequal(Party2.new, wrapper), + "and still lands on the class Gold pushes") + +liveGame.stack.top = function() return nil end +liveGame.stack.pop = function() end +local switched = nil +local pressed = {} +liveGame.input = { wasPressed = function(_, b) return pressed[b] end } +local pidgey = { species = "PIDGEY" } +local menu = PM.new(liveGame, { party = { pidgey }, + onSwitch = function(mon) switched = mon end }) +eq(wrapped, 1, "the wrapper ran exactly once: the override calls the " + .. "constructor it CAPTURED, not the patched class member") +PM.new = nil +eq(PM.new, nil, "a nil write is honoured rather than resurrecting the override") +PM.new = facadeNew + +-- Gen 1 fires onSwitch on A for this construction (src/ui/PartyMenu.lua:569); +-- Gold's field submenu swallowed the press and only CANCEL could ever answer. +check(not menu.wantsSubmenu, "onSwitch outside battle builds the DIRECT list") +pressed.a = true +menu:update(1 / 60) +eq(switched, pidgey, "and A on the row fires onSwitch with the mon") +liveGame.input = nil + +-- the same shape on the battle screen, whose overrides are stamped onto Gold's +-- class so an instance answers them +local BS = Gen2Compat.resolve("src.battle.BattleState", "fixture") +local Battle2 = require("src.ui.gen2.BattleState") +local facadeSay = BS.say +local sayPatch = function(self, text) return facadeSay(self, text) end +BS.say = sayPatch +check(rawequal(BS.say, sayPatch), "BattleState.say reads back as the patch") +check(rawequal(Battle2.say, sayPatch), "and an instance dispatches to it") +BS.say = facadeSay + +-- ------- 13. the overworld facade over a LIVE world + +local ow = Gen2Compat.resolve("src.world.OverworldController", "fixture") +local owWorld = fixtureWorld() +owWorld.map.def.tileset = "TS" +owWorld.maps.OTHER_MAP = { id = "OTHER_MAP", group = 1, map = 3, width = 4, + height = 4, blocks = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + objects = {}, warps = {}, tileset = "TS" } +owWorld.tilesets = { TS = { collision = { [2] = { 0, 0, 0, 0 } } } } +-- Map copies def.connections at construction (src/world/gen2/Map.lua:26) +owWorld.map.connections = { west = { map = "OTHER_MAP", offset = 0 } } +liveGame.world = owWorld + +-- objectId 0 is the PLAYER and 1 is wLastTalked (World:objectEntity): the old +-- `(def.index or 0) + 1` gave the player -- which has no .def -- objectId 1, +-- so the canonical Gen 1 call walked the last-talked NPC and returned true. +local talkNpc = Gen2Npc.new("TEST_MAP", { index = 1, x = 1, y = 1, + movement = Gen2Npc.MOVE.STANDING_UP }, + { id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true }) +table.insert(owWorld.npcs, talkNpc) +owWorld.talkNpc = talkNpc + +check(ow.scriptMove(owWorld.player, "up", 1), "scriptMove takes the player") +eq(owWorld.moveState.objectId, 0, "as objectId 0, not the last-talked NPC") +owWorld.moveState = nil +check(ow.scriptMove(talkNpc, "up", 1), "and a mapped object") +eq(owWorld.moveState.objectId, 2, "as def.index + 1") +owWorld.moveState = nil +local moved, why = ow.scriptMove({ cellX = 1, cellY = 1 }, "up", 1) +eq(moved, nil, "an entity with neither is REFUSED, never moved by proxy") +check(type(why) == "string" and #why > 0, "with a reason") + +eq(ow.npcByIndex(1), talkNpc, "npcByIndex maps the Gen 1 index to index + 1") +eq(ow.npcByIndex(0), nil, "and index 0 -- which names no object on either " + .. "generation -- is nil, not Gold's wLastTalked") + +-- Gen 1 returns dest DEF, tileset def, x, y, conn +-- (src/world/OverworldController.lua:1404); three values with a map ID first +-- shifted every name in `local dest, ts, x, y = ...` +eq(select("#", ow.connectionLanding("left")), 5, + "connectionLanding keeps Gen 1's five-value shape") +local dest, ts, cx, cy, conn = ow.connectionLanding("left") +eq(type(dest), "table", "dest is the map DEF, so dest.width reads") +eq(dest.width, 4, "with the destination's own size") +check(Gen2Map.defPassable(dest, ts, cx, cy, false), + "and the tileset def is the second value, which is what the very next " + .. "Map.defPassable call takes") +eq(type(conn), "table", "the connection record is the fifth") + +-- COVERAGE has to match what a read actually answers: these eight were +-- published backed and every one of them read nil +local owCoverage = Gen2Compat.coverage("src.world.OverworldController") +for member, status in pairs(owCoverage.members) do + if status == "backed" then + check(ow[member] ~= nil, + "published backed and answers: OverworldController." .. member) + end +end +eq(ow.player, owWorld.player, "ow.player is the live player, the way Gen 1's " + .. "module IS the live state (src/core/Game.lua:87)") +eq(ow.map, owWorld.map, "and ow.map the live map") +eq(ow.npcs, owWorld.npcs, "and the lists are the world's own") +local swap = Player.new(1, 1, "down", + { id = "SPRITE_CHRIS", image = sheet, frames = 6, walker = true }) +ow.player = swap +eq(owWorld.player, swap, "a write to a live name moves the world, not a " + .. "shadow copy on the facade") +ow.player = owWorld.player + +-- Gold's neighbour rows carry `id`, Gen 1's carry `map`: answered, every +-- nb.map read is nil and a scan matches nothing +eq(ow.neighbors, nil, "neighbors answers nil rather than a list of the wrong " + .. "shape") +eq(Gen2Compat.memberStatus("src.world.OverworldController", "neighbors"), + "warned", "and coverage says so") +eq(Follower.shouldSpawn, nil, "Follower.shouldSpawn is a file-local") +eq(Gen2Compat.memberStatus("src.world.PikachuFollower", "shouldSpawn"), + "absent", "so coverage publishes it absent, with setShouldSpawn as the way in") + +-- leave the process as we found it: these tables are singletons +debug.setupvalue(Follower.update, updateIdx, function() return false end) + +S.finish() diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47c..a0cc16d0 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -102,34 +102,29 @@ end -- (M14 adds the gate; it does not retro-fit other milestones' unit tests). -- Removing a name from this list is the only way to close its entry, and -- the staleness check below forces that the moment a test lands. +-- +-- The Gen 2 tier drained most of this ledger: tests/engine/gate_gen2_mod_api.lua +-- names each seam Gold raises and holds it to having a call site in BOTH +-- generations under the one shared name, which is a test that fails if the +-- seam is deleted or renamed. It is not a payload case, so a per-seam case +-- through the public mod API is still worth writing for those names -- it is +-- simply no longer owed as debt, because the ledger's rule is that an entry +-- closes the moment any test names the seam. local DEBT = { - -- M6 audio: the registry is exercised through cries/music/sfx, never by - -- the aggregate `audio` name - ["registry:audio"] = "M6", - -- M12 link: declared for the extra-bag negotiation, no case names it yet - ["registry:link_fields"] = "M12", - - ["hook:encounter.fishing"] = "M5", - ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", - ["hook:ui.pc.items"] = "M8", + -- registry:link_fields closed the same way: the Gen 2 gate names it in the + -- gated list, because link play is Gen 1 only and Gold has nowhere to put a + -- link field. Still not a payload case, still worth one. + -- + -- hook:render.zones was the last M9 entry and closed when Gold grew its own + -- zone pass: src/core/Game2.lua:blitZones raises the hook with the same rect + -- list shape src/render/Renderer.lua does, so gate_gen2_mod_api names it in + -- the shared-hook list and the ledger's rule ("an entry closes the moment + -- any test names the seam") retired it. ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", - ["event:player.warped"] = "M5", ["event:pokemon.before_give"] = "M7", - ["event:pokemon.evolved"] = "M7", - ["event:pokemon.level_up"] = "M7", - ["event:pokemon.move_learned"] = "M7", - ["event:save.loaded"] = "M11", - ["event:save.loading"] = "M11", - ["event:save.writing"] = "M11", ["event:trade.completed"] = "M12", - ["event:world.blacked_out"] = "M5", - ["event:world.boulder_moved"] = "M5", - ["event:world.interacted"] = "M5", - ["event:world.npc_spawned"] = "M5", - ["event:world.trainer_engaged"] = "M5", } local seen = {} diff --git a/tests/engine/gate_strings_coverage.lua b/tests/engine/gate_strings_coverage.lua index b3828c71..7cac686c 100644 --- a/tests/engine/gate_strings_coverage.lua +++ b/tests/engine/gate_strings_coverage.lua @@ -45,6 +45,9 @@ local ALLOWED = { .. "(POKEPORT_LAUNCHER_PROF's frame timings)" }, { pattern = '== "\\v"', why = "comparing against a marker, not printing it" }, { pattern = "txBuf", why = "newline-delimited wire framing, not text" }, + { pattern = "local PAGE, SCROLL, LINE", + why = "naming the three text-control markers so the code that splits a " + .. "decoded stream into pages can compare against them" }, } -- Whole files inside a watched directory that are exempt, with the reason. diff --git a/tests/engine/gen2_content_registries.lua b/tests/engine/gen2_content_registries.lua new file mode 100644 index 00000000..c19cbc08 --- /dev/null +++ b/tests/engine/gen2_content_registries.lua @@ -0,0 +1,369 @@ +-- The six Gen 2-only content registries, end to end: held_items, +-- phone_contacts, decorations, apricorns, landmarks and radio_channels. +-- +-- Three claims, and the third is the one that makes the other two worth +-- anything: +-- +-- 1. the routing is symmetrical. Schemas.GEN2 says where a shared registry +-- lands on Gold; Schemas.GEN1 is its mirror, and these six are gated +-- under GEN ONE -- Red has no phone, no radio and no held items, so a +-- write there is taken, dropped and reported rather than merged into a +-- namespace nothing on Red would read. +-- 2. the vanilla records are the literals they replaced, byte for byte. A +-- registry that seeds a different record than the module used to hold is +-- a behaviour change wearing a refactor's clothes. +-- 3. the CONSUMER reads through the registry. A registry nothing reads is +-- the silent no-op this whole design exists to prevent, so each one is +-- driven from a mod's own registration through to the routine the game +-- calls: Decorations.attributes, Phone.CONTACTS, Apricorns.ballFor, +-- Nests.landmarkId, MapRadio.channelRecord and, for held_items, the +-- write-back onto data.items that src/battle/gen2/Battle.lua:heldEffect +-- reads. +-- +-- ROM-free: the generation is injected through the loader (T.sdk.loadMods +-- opts.generation), the way tests/engine/gate_gen2_mod_api.lua does. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Schemas = require("src.mods.Schemas") +local ItemEffects = require("src.core.gen2.ItemEffects") +local Phone = require("src.core.gen2.Phone") +local Decorations = require("src.core.gen2.Decorations") +local Apricorns = require("src.core.gen2.Apricorns") +local Nests = require("src.core.gen2.Nests") +local MapRadio = require("src.ui.gen2.MapRadio") + +local NAMES = { "held_items", "phone_contacts", "decorations", "apricorns", + "landmarks", "radio_channels" } + +local PATHS = { + held_items = "gen2HeldItems", + phone_contacts = "gen2PhoneContacts", + decorations = "gen2Decorations", + apricorns = "gen2Apricorns", + landmarks = "gen2Landmarks.landmarks", + radio_channels = "gen2RadioChannels", +} + +-- ------- 1. the mirror of Schemas.GEN2 + +for _, name in ipairs(NAMES) do + local spec = Schemas.REGISTRIES[name] + T.check(spec ~= nil, "the catalog declares it: " .. name) + T.eq(spec.target, nil, + "a Gen 2-only registry carries no Gen 1 target: " .. name) + T.eq(Schemas.targetFor(name, spec, 1), nil, + "and none is invented for Gen 1: " .. name) + T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name) + T.eq(Schemas.targetFor(name, spec, 2), PATHS[name], + "routed to its Gen 2 path: " .. name) + T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name) + T.check(spec.example ~= nil, "and documents a call: " .. name) +end + +-- the mirror holds in the other direction too: nothing shared is gated under +-- Gen 1, which is what would break if a routing row were put in the wrong +-- table +for name, spec in pairs(Schemas.REGISTRIES) do + if Schemas.GEN1[name] == nil then + T.eq(Schemas.targetFor(name, spec, 1), spec.target, + "a registry with no Gen 1 routing row keeps its target: " .. name) + T.eq(Schemas.gatedFor(name, 1), false, + "and is not gated under Gen 1: " .. name) + end +end + +for name in pairs(Schemas.GEN1) do + T.check(Schemas.REGISTRIES[name] ~= nil, + "Schemas.GEN1 names a real registry: " .. name) +end + +-- ------- the Gen 2 dataset these merge into +-- +-- data.gen2Constants.phoneContactOrder is the phone id space, verbatim from +-- the ROM manifest (the four PHONE_UNUSED rows are the const_skip holes). +local PHONE_ORDER = { + "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_CHRIS", "PHONE_POKEMANIAC_BRENT", + "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", + "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN", +} + +-- two landmark records in the cache's own shape (index = the map header byte) +local function landmarkTable() + return { + order = { "LANDMARK_SPECIAL", "LANDMARK_FIX_TOWN" }, + landmarks = { + LANDMARK_SPECIAL = { id = "LANDMARK_SPECIAL", name = "SPECIAL", + x = 0, y = 0, index = 0 }, + LANDMARK_FIX_TOWN = { id = "LANDMARK_FIX_TOWN", name = "FIX\nTOWN", + x = 4, y = 5, index = 1 }, + }, + } +end + +local function goldData() + local data = T.fixtures.fresh() + -- an item that holds something, so held_items has a row to seed from + data.items.FIX_LEFTOVERS = { + id = "FIX_LEFTOVERS", index = 90, name = "FIX LEFTOVERS", price = 0, + heldEffect = "HELD_LEFTOVERS", heldParameter = 0, + } + data.items.RED_APRICORN = { id = "RED_APRICORN", index = 91, + name = "RED APRICORN", price = 0 } + data.items.LEVEL_BALL = { id = "LEVEL_BALL", index = 92, ball = true, + name = "LEVEL BALL", price = 0 } + data.items.ULTRA_BALL = { id = "ULTRA_BALL", index = 93, ball = true, + name = "ULTRA BALL", price = 0 } + -- the fixture maps under the key Gold keeps them at, so a contact's `map` + -- resolves against the same id space src/world/gen2/World.lua walks + data.gen2Maps = data.maps + data.gen2Constants = { phoneContactOrder = PHONE_ORDER } + data.gen2Landmarks = landmarkTable() + -- src/core/Game2.lua:load builds this before mods:load; the harness + -- stands in for that boot step + data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items) + return data +end + +local function memfsFor(body, extra) + local manifest = [[{ + "id": "fix_gen2_content", + "name": "Fixture Gen 2 Content", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "gen2compat": true + }]] + local files = { ["mods/fix_gen2_content/manifest.json"] = manifest, + ["mods/fix_gen2_content/main.lua"] = body } + for path, text in pairs(extra or {}) do files[path] = text end + return T.sdk.memfs(files) +end + +-- ------- 2. parity: the seeded record IS the literal +-- +-- Compared field by field against the module's own table rather than against +-- a copy of it, so a record that gained or lost a key fails here. +local function sameRecord(got, want, label) + if type(got) ~= "table" or type(want) ~= "table" then + return T.eq(got, want, label) + end + local ok = true + for key, value in pairs(want) do + if got[key] ~= value then ok = false end + end + for key in pairs(got) do + if want[key] == nil then ok = false end + end + return T.check(ok, label) +end + +do + local run = T.sdk.loadNone({ data = goldData(), generation = 2 }) + T.eq(#run.errors, 0, "a zero-mod Gold load reports no errors") + + local decorations = run.data.gen2Decorations + T.check(decorations ~= nil, "the decorations merge target appears") + local rows = 0 + for decoId, attr in pairs(Decorations.ATTRIBUTES) do + rows = rows + 1 + sameRecord(decorations[Decorations.idFor(decoId)], attr, + "vanilla decoration is the attribute row: deco:" .. decoId) + end + T.eq(rows, 53, "every attribute row is registered") + + local contacts = run.data.gen2PhoneContacts + T.check(contacts ~= nil, "the phone_contacts merge target appears") + sameRecord(contacts.PHONE_YOUNGSTER_JOEY, Phone.CONTACTS[15], + "vanilla contact is the PhoneContacts row: PHONE_YOUNGSTER_JOEY") + sameRecord(contacts.PHONE_MOM, Phone.CONTACTS[1], + "and the non-trainer rows too: PHONE_MOM") + T.eq(contacts.PHONE_UNUSED, nil, + "the const_skip holes are not registered under one shared id") + + local apricorns = run.data.gen2Apricorns + T.check(apricorns ~= nil, "the apricorns merge target appears") + sameRecord(apricorns.RED_APRICORN, Apricorns.row("RED_APRICORN"), + "vanilla apricorn is the ApricornBalls row: RED_APRICORN") + T.eq(apricorns.RED_APRICORN.ball, "LEVEL_BALL", + "and it still hands back the LEVEL BALL") + + local channels = run.data.gen2RadioChannels + T.check(channels ~= nil, "the radio_channels merge target appears") + T.eq(channels.OAKS_POKEMON_TALK and channels.OAKS_POKEMON_TALK.channel, 1, + "OAK's POKEMON TALK keeps its dial position") + T.eq(channels.ROCKET_RADIO and channels.ROCKET_RADIO.channel, 8, + "and ROCKET RADIO keeps its own") + + -- held_items and landmarks merge onto a table that already existed, so the + -- claim there is that the merge left it exactly as it found it + T.eq(run.data.gen2HeldItems.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS", + "the held_items view still holds the item's own effect") + T.eq(ItemEffects.applyHeldItems(run.data, + ItemEffects.heldSnapshot(run.data.gen2HeldItems)), 0, + "and a mod-free merge writes nothing back onto data.items") + T.eq(run.data.gen2Landmarks.landmarks.LANDMARK_FIX_TOWN.x, 4, + "the landmark records are the cache's own") + + run.release() +end + +-- ------- 3. the consumers, driven from a mod's registration + +do + local data = goldData() + local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, { + fs = memfsFor([[ + local mod = ... + -- an existing row edited, and a new one registered, for each registry + mod.content.decorations:patch("deco:2", { name = "COZY" }) + mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", + { map = "FIX_ROUTE" }) + mod.content.apricorns:override("RED_APRICORN", + { apricorn = "RED_APRICORN", ball = "ULTRA_BALL", event = 600, + index = 1 }) + mod.content.landmarks:patch("LANDMARK_FIX_TOWN", { x = 9 }) + mod.content.landmarks:register("LANDMARK_MOD_ISLE", + { id = "LANDMARK_MOD_ISLE", name = "MOD\nISLE", x = 1, y = 2, + index = 7 }) + mod.content.radio_channels:register("PIRATE_RADIO", + { channel = 9, name = "PIRATE RADIO" }) + mod.content.held_items:patch("FIX_LEFTOVERS", { heldParameter = 7 }) + ]]), + data = data, + generation = 2, + }) + T.eq(#run.errors, 0, + "the mod loads clean (" .. table.concat(run.errors, "; ") .. ")") + + -- decorations: Decorations.attributes is the one read point every caller + -- (and src/ui/gen2/DecorationMenu.lua) comes through + Decorations.useRegistry(run.data) + T.eq(Decorations.attributes(2).name, "COZY", + "decorations: the merged row is what attributes() answers") + T.eq(Decorations.attributes(2).flag, Decorations.ATTRIBUTES[2].flag, + "and the fields the patch left alone are the cart's own") + T.eq(Decorations.name(2, nil), "COZY BED", + "so GetDecoName's port spells the merged row") + + -- phone: the merged rows are folded onto the contact table every lookup in + -- src/core/gen2/Phone.lua keys by + Phone.useRegistry(run.data) + T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE", + "phone_contacts: the merged row reaches the contact table") + T.eq(Phone.CONTACTS[15].class, "YOUNGSTER", + "and the untouched fields survive the patch") + -- the cache overlay runs from src/world/gen2/World.lua AFTER this, and must + -- not undo it + Phone.useExtracted({ phone = { [15] = { map = "ROUTE_30", + calleeTime = 7, callerTime = 7, + callee = "41:0001", + caller = "41:0002" } } }) + T.eq(Phone.CONTACTS[15].map, "FIX_ROUTE", + "and the cache overlay does not undo it") + + -- apricorns: Kurt hands back what the registry says he does + Apricorns.useRegistry(run.data) + T.eq(Apricorns.ballFor("RED_APRICORN"), "ULTRA_BALL", + "apricorns: the merged row is the ball Kurt makes") + T.eq(Apricorns.apricornFor("ULTRA_BALL"), "RED_APRICORN", + "and the reverse lookup follows it") + T.eq(#Apricorns.BALLS, 7, "the table is still the seven rows") + T.eq(Apricorns.BALLS[1].apricorn, "RED_APRICORN", + "in the ApricornBalls order the menu walks") + + -- landmarks: the registry answers the map header's byte, including for an + -- index the extractor's `order` list has never heard of + T.eq(Nests.landmarkId(run.data, 1), "LANDMARK_FIX_TOWN", + "landmarks: a vanilla index still resolves") + T.eq(Nests.landmark(run.data, 1).x, 9, + "and the patch reaches the record the town map draws") + T.eq(Nests.landmarkId(run.data, 7), "LANDMARK_MOD_ISLE", + "a registered landmark resolves at its own index") + -- and a registered landmark cannot shadow a vanilla one by claiming its + -- byte: the cache's own row keeps its slot, so the answer does not depend + -- on pairs() order. A fresh table, because the index map is memoized per + -- landmarks table and the run's is already built. + local shadowed = landmarkTable() + shadowed.landmarks.LANDMARK_AAA_SHADOW = { + id = "LANDMARK_AAA_SHADOW", name = "AAA", x = 0, y = 0, index = 1, + } + T.eq(Nests.landmarkId({ gen2Landmarks = shadowed }, 1), "LANDMARK_FIX_TOWN", + "a second record at a taken index does not displace the cache's own") + local twoNew = landmarkTable() + twoNew.landmarks.LANDMARK_MOD_B = { id = "LANDMARK_MOD_B", name = "B", + x = 0, y = 0, index = 7 } + twoNew.landmarks.LANDMARK_MOD_A = { id = "LANDMARK_MOD_A", name = "A", + x = 0, y = 0, index = 7 } + T.eq(Nests.landmarkId({ gen2Landmarks = twoNew }, 7), "LANDMARK_MOD_A", + "and two registered records at one index resolve the same way every boot") + + -- radio: a registered station is on the dial + local record, station = MapRadio.channelRecord(run.data, 9) + T.eq(station, "PIRATE_RADIO", "radio_channels: the new station is on the dial") + T.eq(record and record.name, "PIRATE RADIO", "with its own name") + T.eq(select(2, MapRadio.channelRecord(run.data, 8)), "ROCKET_RADIO", + "and the vanilla positions are unmoved") + + -- held items: the merged row is written back onto the item record, which is + -- what src/battle/gen2/Battle.lua:itemDef reads + local applied = ItemEffects.applyHeldItems(run.data, + ItemEffects.heldSnapshot({ FIX_LEFTOVERS = { heldEffect = "HELD_LEFTOVERS", + heldParameter = 0 } })) + T.eq(applied, 1, "held_items: one item record changed") + T.eq(run.data.items.FIX_LEFTOVERS.heldParameter, 7, + "and the battle's own read sees the merged parameter") + T.eq(run.data.items.FIX_LEFTOVERS.heldEffect, "HELD_LEFTOVERS", + "with the effect the patch left alone") + T.eq(ItemEffects.heldItemFor("FIX_LEFTOVERS", run.data).heldParameter, 7, + "and heldItemFor answers from the merged table") + + run.release() + -- module statics are process-wide; put them back before the next case + Phone.useRegistry(nil) + Decorations.useRegistry(nil) +end + +-- ------- 4. the mirror case, through a real load +-- +-- A Red boot takes the write, drops it and says so. Not fatal: a mod that +-- supports both games registers its Gold content unconditionally and should +-- still load the half that applies. + +do + local run = T.sdk.loadMods({ "mods/fix_gen2_content" }, { + fs = memfsFor([[ + local mod = ... + mod.content.pokemon:patch("FIXMON_A", { catchRate = 77 }) + mod.content.decorations:patch("deco:2", { name = "COZY" }) + mod.content.radio_channels:register("PIRATE_RADIO", { channel = 9 }) + ]]), + generation = 1, + }) + T.eq(run.data.pokemon.FIXMON_A.catchRate, 77, + "Gen 1: the shared registry still merged") + T.eq(run.data.gen2Decorations, nil, + "Gen 1: a Gen 2-only registry merges nothing") + T.eq(run.data.decorations, nil, + "Gen 1: and invents no namespace of its own") + local told = {} + for _, message in ipairs(run.errors) do + if message:match("decorations") then told.decorations = true end + if message:match("radio_channels") then told.radio = true end + end + T.check(told.decorations and told.radio, + "Gen 1: both dropped registrations are reported, not silent") + run.release() +end + +T.finish("gen2_content_registries") diff --git a/tests/engine/gen2_new_seams.lua b/tests/engine/gen2_new_seams.lua new file mode 100644 index 00000000..f4a54a75 --- /dev/null +++ b/tests/engine/gen2_new_seams.lua @@ -0,0 +1,651 @@ +-- The events and hooks that are NEW IN GEN 2 -- the only names in the mod API +-- with no Gen 1 analogue, and therefore the only places a new name is +-- justified (docs/mod-api-gen2-compat.md, "New in Gen 2"). +-- +-- gate_gen2_mod_api.lua holds the SHARED names to having a call site in both +-- generations; by construction that gate cannot cover these, because a Gen 1 +-- site is exactly what they do not have. This file is the other half: for +-- each new name, drive the real Gen 2 module through a live bus and assert the +-- payload the call site documents. It runs ROM-free -- every module below +-- takes its data by argument -- so it lives in the engine tier. +-- +-- The discipline each case follows is the one gate_events/gate_hooks enforce +-- generally: subscribe, drive, assert the payload, unsubscribe, and assert the +-- mod-free path answers exactly what it answered before. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") + +local Apricorns = require("src.core.gen2.Apricorns") +local Breeding = require("src.core.gen2.Breeding") +local BugContest = require("src.core.gen2.BugContest") +local Clock = require("src.core.gen2.Clock") +local Happiness = require("src.core.gen2.Happiness") +local Mail = require("src.core.gen2.Mail") +local Mon = require("src.battle.gen2.Mon") +local Phone = require("src.core.gen2.Phone") +local PhoneRing = require("src.core.gen2.PhoneRing") +local Pokerus = require("src.core.gen2.Pokerus") +local Roamers = require("src.core.gen2.Roamers") +local Unown = require("src.core.gen2.Unown") + +-- ------- the bus, installed the way Loader:load installs it + +local events, hooks = Events.new(), Hooks.new() +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +Runtime.install(events, hooks, {}) + +-- Collect every payload `name` raises while `body` runs, then unsubscribe -- +-- so the next case starts from the mod-free state and Runtime.wants goes back +-- to false, which is what the guarded call sites key off. +local function capture(name, body) + local seen = {} + local unsubscribe = events:on(name, function(payload) + seen[#seen + 1] = payload + end, 0, "gen2_new_seams") + body() + unsubscribe() + return seen +end + +local function withHook(name, wrapper, body) + local remove = hooks:wrap(name, wrapper, 0, "gen2_new_seams") + local ok, err = pcall(body) + remove() + if not ok then error(err, 0) end +end + +-- ------- a Gen 2 shaped dataset, small enough to read + +local DATA = { + pokemon = { + growthRates = { + MEDIUM_FAST = { numerator = 1, denominator = 1 }, + }, + SEEDMON = { + id = "SEEDMON", name = "SEEDMON", index = 1, dex = 1, + types = { "GRASS" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST", + levelMoves = { { level = 1, move = "SEED_TACKLE" } }, + evolutions = {}, + eggGroups = { "MONSTER" }, eggSteps = 20, genderRatio = 0x1f, + spriteFront = "a.png", spriteBack = "b.png", picSize = 5, + }, + }, + moves = { SEED_TACKLE = { id = "SEED_TACKLE", pp = 35 } }, +} + +-- ------- happiness.changed + +do + local mon = { species = "SEEDMON", happiness = Happiness.BASE, hp = 20 } + local seen = capture("happiness.changed", function() + Happiness.change(mon, "GAINLEVEL") + end) + T.eq(#seen, 1, "happiness.changed fires once per ChangeHappiness") + T.eq(seen[1].mon, mon, "happiness.changed carries the mon") + T.eq(seen[1].event, "GAINLEVEL", "happiness.changed carries the event name") + T.eq(seen[1].reason, "event", "a ChangeHappiness reports reason 'event'") + T.eq(seen[1].from, Happiness.BASE, "happiness.changed carries the old value") + T.eq(seen[1].to, Happiness.BASE + 5, "happiness.changed carries the new value") + T.eq(seen[1].delta, 5, "happiness.changed's delta is what was applied") + + -- the clamp is part of the delta: a mon on $ff gaining 5 gained nothing + local capped = { species = "SEEDMON", happiness = Happiness.MAX } + local clamped = capture("happiness.changed", function() + Happiness.change(capped, "GAINLEVEL") + end) + T.eq(clamped[1].delta, 0, "happiness.changed reports the clamped delta") + + local save = { party = { { species = "SEEDMON", happiness = 10 } }, + happinessStepCount = 1 } + local stepped = capture("happiness.changed", function() + Happiness.stepCycle(save) + end) + T.eq(#stepped, 1, "StepHappiness raises happiness.changed per mon it moved") + T.eq(stepped[1].reason, "step", "the walk reports reason 'step'") + T.eq(stepped[1].event, nil, "the walk has no HAPPINESS_* event") + + -- the mod-free path is unchanged + local plain = { species = "SEEDMON", happiness = Happiness.BASE } + T.eq(Happiness.change(plain, "GAINLEVEL"), Happiness.BASE + 5, + "ChangeHappiness answers the same with nobody subscribed") +end + +-- ------- breeding.compatibility, breeding.egg_created, egg.hatched + +do + local man = { species = "SEEDMON", dvs = { attack = 1, defense = 2, + speed = 3, special = 4 }, otId = 1 } + local lady = { species = "SEEDMON", dvs = { attack = 5, defense = 6, + speed = 7, special = 8 }, otId = 2 } + + -- vanilla: two same-gender SEEDMON with no Ditto never breed + T.eq(Breeding.compatibility(DATA, man, lady), 0, + "breeding.compatibility answers the vanilla 0 with nobody subscribed") + + local ctxSeen + withHook("breeding.compatibility", function(nextFn, ctx) + ctxSeen = ctx + nextFn() + return 128 + end, function() + T.eq(Breeding.compatibility(DATA, man, lady, { dayCare = true }), 128, + "breeding.compatibility replaces the answer") + end) + T.eq(ctxSeen.mon1, man, "breeding.compatibility's ctx carries the first mon") + T.eq(ctxSeen.mon2, lady, "breeding.compatibility's ctx carries the second") + T.eq(ctxSeen.data, DATA, "breeding.compatibility's ctx carries the data") + T.eq(ctxSeen.dayCare, true, "breeding.compatibility knows the yard called it") + + -- and the value is clamped to the byte wBreedingCompatibility is + withHook("breeding.compatibility", function() return 9999 end, function() + T.eq(Breeding.compatibility(DATA, man, lady), 255, + "breeding.compatibility clamps to the compatibility byte") + end) + + -- egg_created rides the same forced compatibility: initBreeding refuses 0 + local save = { dayCare = { man = { mon = man }, lady = { mon = lady }, + compatible = false }, + player = { name = "GOLD", id = 7 } } + local created + withHook("breeding.compatibility", function() return 128 end, function() + created = capture("breeding.egg_created", function() + Breeding.initBreeding(DATA, save, { rng = function() return 200 end }) + end) + end) + T.eq(#created, 1, "breeding.egg_created fires when the pair becomes compatible") + T.eq(created[1].compatibility, 128, + "breeding.egg_created carries the compatibility that let it run") + T.eq(created[1].stepsToEgg, save.dayCare.stepsToEgg, + "breeding.egg_created carries wStepsToEgg") + T.check(created[1].mother ~= nil and created[1].father ~= nil, + "breeding.egg_created names both parents") + T.check(created[1].mother ~= created[1].father, + "breeding.egg_created's parents are the two different records") + + -- egg.hatched, off a real egg in a party slot + local egg = Mon.new(DATA, "SEEDMON", Breeding.EGG_LEVEL, { hp = 0 }) + egg.isEgg = true + egg.eggSteps = 0 + local file = { party = { egg } } + local hatched = capture("egg.hatched", function() + Breeding.hatch(DATA, file, 1, "SPROUT") + end) + T.eq(#hatched, 1, "egg.hatched fires once per hatched slot") + T.eq(hatched[1].slot, 1, "egg.hatched carries the party slot") + T.eq(hatched[1].species, "SEEDMON", "egg.hatched carries the species") + T.eq(hatched[1].nickname, "SPROUT", "egg.hatched carries the chosen nickname") + T.eq(hatched[1].egg, egg, "egg.hatched carries the egg it replaced") + T.eq(hatched[1].mon, file.party[1], + "egg.hatched carries the hatchling now in the party") + T.eq(hatched[1].mon.isEgg, nil, "the hatchling is no longer an egg") +end + +-- ------- phone.call_received and phone.contact_list + +do + local call = { kind = "call", contact = 1, direction = "incoming", + scriptKey = "41:4000" } + local seen = capture("phone.call_received", function() + PhoneRing.script(call, "JOEY", "YOUNGSTER") + end) + T.eq(#seen, 1, "phone.call_received fires once per ring") + T.eq(seen[1].call, call, "phone.call_received carries the descriptor") + T.eq(seen[1].contact, 1, "phone.call_received carries the contact id") + T.eq(seen[1].name, "JOEY", "phone.call_received carries the caller name") + T.eq(seen[1].className, "YOUNGSTER", "phone.call_received carries the class") + T.eq(seen[1].scriptKey, "41:4000", "phone.call_received carries the script key") + + local save = {} + Phone.addContact(save, Phone.CONTACTS[1] and 1 or 1) + local vanilla = Phone.contacts(save) + T.eq(#vanilla, Phone.CONTACT_LIST_SIZE, + "the phone book is ten slots with nobody subscribed") + + withHook("phone.contact_list", function(nextFn, file, list) + T.eq(file, save, "phone.contact_list is handed the save") + T.eq(#list, Phone.CONTACT_LIST_SIZE, + "phone.contact_list is handed all ten slots") + local out = nextFn() + out[10] = 1 + return out + end, function() + local hooked = Phone.contacts(save) + T.eq(#hooked, Phone.CONTACT_LIST_SIZE, + "phone.contact_list keeps the slot count") + T.eq(hooked[10], 1, "phone.contact_list can fill an empty slot") + end) + + -- a chain that returns the wrong shape is ignored rather than trusted + withHook("phone.contact_list", function() return { 1, 2 } end, function() + T.eq(#Phone.contacts(save), Phone.CONTACT_LIST_SIZE, + "a short phone.contact_list answer is refused") + end) + -- and an id the contact table does not know is blanked, not carried + withHook("phone.contact_list", function(nextFn) + local out = nextFn() + out[1] = 9999 + return out + end, function() + T.eq(Phone.contacts(save)[1], 0, + "phone.contact_list blanks an unknown contact id") + end) +end + +-- ------- clock.day_changed + +do + local save = {} + local seen = capture("clock.day_changed", function() + -- the first read after a boot has nothing to compare against + Clock.weekday(save) + -- Mom's wheel moving the day is a change; setWeekday reports it + Clock.setWeekday(save, (Clock.weekday(save) + 3) % Clock.DAYS) + end) + T.eq(#seen, 1, "clock.day_changed does not fire on the first read") + T.eq(seen[1].reason, "set", "re-anchoring the day reports reason 'set'") + T.eq(seen[1].day, Clock.weekday(save), "clock.day_changed carries the new day") + T.check(seen[1].previous ~= seen[1].day, + "clock.day_changed carries a different previous day") + + local quiet = capture("clock.day_changed", function() + Clock.weekday(save) + Clock.weekday(save) + Clock.weekday(save) + end) + T.eq(#quiet, 0, "a day that has not moved raises nothing") +end + +-- ------- pokerus.infected + +do + -- .TrySpreadPokerus: slot 1 already carries the virus, slot 2 is clean, and + -- the rolls below pass the spread gate and walk forward. + local party = { + { species = "SEEDMON", pokerus = 0x11 }, + { species = "SEEDMON", pokerus = 0 }, + } + local rolls = { 0, 255 } + local index = 0 + local function random() + index = index + 1 + return rolls[index] or 0 + end + local seen = capture("pokerus.infected", function() + Pokerus.give(party, { random = random }) + end) + T.eq(#seen, 1, "pokerus.infected fires once per newly infected slot") + T.eq(seen[1].slot, 2, "pokerus.infected carries the party slot") + T.eq(seen[1].mon, party[2], "pokerus.infected carries the mon") + T.eq(seen[1].source, "spread", "a spread reports source 'spread'") + T.eq(seen[1].strain, Pokerus.strain(party[2]), + "pokerus.infected carries the strain nybble") + T.eq(seen[1].days, Pokerus.days(party[2]), + "pokerus.infected carries the day counter") + T.check(Pokerus.isInfected(party[2]), "and the byte really was written") +end + +-- ------- roamer.moved and roamer.encountered + +do + local save = {} + Roamers.init(save) + local seen = capture("roamer.moved", function() + Roamers.jumpAll(save, "ROUTE_29", function(n) return n - 1 end) + end) + T.check(#seen > 0, "roamer.moved fires when JumpRoamMons scatters the beasts") + for _, payload in ipairs(seen) do + T.eq(payload.reason, "jump", "a teleport reports reason 'jump'") + T.check(payload.from ~= payload.to, + "roamer.moved only reports a beast that changed route") + T.eq(payload.slot.map, payload.to, "roamer.moved's `to` is where it stands") + end + + -- CheckEncounterRoamMon: a byte under 100 whose low two bits pick slot 1 + local beast = Roamers.slot(save, 1) + beast.map = "ROUTE_42" + local met = capture("roamer.encountered", function() + local hit = Roamers.checkEncounter(save, "ROUTE_42", false, + function() return 1 end) + T.check(hit ~= nil, "the roll met the roamer") + end) + T.eq(#met, 1, "roamer.encountered fires once per meeting") + T.eq(met[1].index, 1, "roamer.encountered carries the roamer slot") + T.eq(met[1].species, beast.species, "roamer.encountered carries the species") + T.eq(met[1].mapId, "ROUTE_42", "roamer.encountered carries the map") +end + +-- ------- apricorn.converted + +do + local save = { inventory = { RED_APRICORN = 1 }, events = {}, engineFlags = {} } + T.check(Apricorns.give(save, "RED_APRICORN"), "Kurt takes the apricorn") + -- .GiveLevelBall only fires once the daily flag has rolled over + save.engineFlags[Apricorns.ENGINE_KURT_MAKING_BALLS] = false + local seen = capture("apricorn.converted", function() + local ball = Apricorns.collect(save) + T.check(ball ~= nil, "the ball is ready to collect") + end) + T.eq(#seen, 1, "apricorn.converted fires once per ball handed over") + T.eq(seen[1].apricorn, "RED_APRICORN", + "apricorn.converted carries the apricorn that went in") + T.eq(seen[1].ball, Apricorns.ballFor("RED_APRICORN"), + "apricorn.converted carries the ball that came out") + T.check(seen[1].event ~= nil, + "apricorn.converted names the EVENT_GAVE_KURT_* flag it cleared") +end + +-- ------- bug_contest.scored + +do + local save = {} + local state = BugContest.state(save) + state.caught = { species = "SEEDMON", hp = 20, maxHp = 20, + stats = { attack = 10, defense = 10, speed = 10, + specialAttack = 10, specialDefense = 10 }, + dvs = { attack = 2, defense = 2, speed = 2, special = 2 } } + local seen = capture("bug_contest.scored", function() + BugContest.runJudging(save, function() return 0 end) + end) + T.eq(#seen, 1, "bug_contest.scored fires once per judging") + T.eq(seen[1].mon, state.caught, "bug_contest.scored carries the player's mon") + T.eq(seen[1].score, BugContest.score(state.caught), + "bug_contest.scored carries the score DetermineContestWinners used") + T.eq(seen[1].place, state.place, "bug_contest.scored carries the placing") + T.check(seen[1].results ~= nil and seen[1].results.first ~= nil, + "bug_contest.scored carries the podium") +end + +-- ------- unown.unlocked + +do + local save = {} + local seen = capture("unown.unlocked", function() + Unown.updateDex(save, 1) + -- the same letter a second time is UpdateUnownDex's early return + Unown.updateDex(save, 1) + Unown.updateDex(save, 2) + end) + T.eq(#seen, 2, "unown.unlocked fires once per NEW form, not per catch") + T.eq(seen[1].letter, 1, "unown.unlocked carries the letter number") + T.eq(seen[1].name, "A", "unown.unlocked carries the letter name") + T.eq(seen[1].word, Unown.word(1), "unown.unlocked carries the form's word") + T.eq(seen[2].count, 2, "unown.unlocked carries the running count") +end + +-- ------- mail.written and mail.read + +do + local save = { player = { name = "GOLD", id = 7 }, + party = { { species = "SEEDMON" } } } + local written = capture("mail.written", function() + Mail.compose(save, 1, "HI THERE", save.party[1], "LOVELY_MAIL") + end) + T.eq(#written, 1, "mail.written fires when the compose screen closes") + T.eq(written[1].slot, 1, "mail.written carries the party slot") + T.eq(written[1].source, "compose", "the compose screen reports 'compose'") + T.eq(written[1].author, "GOLD", "mail.written carries the author") + T.eq(written[1].message, "HI THERE", "mail.written carries the message") + T.eq(written[1].mon, save.party[1], "mail.written carries the mon it rides") + + local given = capture("mail.written", function() + Mail.give(save, "LOVELY_MAIL", "FROM A FRIEND") + end) + T.eq(#given, 1, "GivePokeMail raises mail.written too") + T.eq(given[1].source, "script", "a scripted letter reports 'script'") + + local entry = Mail.get(save, 1) + local read = capture("mail.read", function() + -- the reader redraws the page every frame; that is one opened letter + Mail.lines(entry) + Mail.lines(entry) + Mail.lines(entry) + end) + T.eq(#read, 1, "mail.read is one event per opened letter, not per frame") + T.eq(read[1].entry, entry, "mail.read carries the struct being read") + T.eq(read[1].message, entry.message, "mail.read carries the message") + T.check(read[1].top ~= nil and read[1].bottom ~= nil, + "mail.read carries the two rows MailGFX_PlaceMessage draws") + + local reopened = capture("mail.read", function() + -- picking the letter again out of the record re-arms the latch + local again = Mail.get(save, 1) + Mail.lines(again) + Mail.lines(again) + end) + T.eq(#reopened, 1, "opening the same letter a second time is a second event") +end + +-- ------- radio.channel +-- +-- src/ui/gen2/MapRadio.lua is a LOVE state and its constructor reaches through +-- the Pokegear, so the seam is asserted here through the bus rather than by +-- building a screen: what this pins is that the name is on the wire and that a +-- listener sees the four fields the call site documents. + +do + local seen = capture("radio.channel", function() + Runtime.emit("radio.channel", { station = "OAKS_POKEMON_TALK", channel = 1, + name = "OAK'S #MON TALK", source = "map_radio" }) + end) + T.eq(#seen, 1, "radio.channel reaches a listener") + T.eq(seen[1].source, "map_radio", "radio.channel names the wall radio") + T.eq(seen[1].station, "OAKS_POKEMON_TALK", "radio.channel carries the station") +end + +-- ------- shiny.roll and gender.roll + +do + local shinyDvs = { attack = 2, defense = 10, speed = 10, special = 10 } + local plainDvs = { attack = 0, defense = 0, speed = 0, special = 0 } + T.eq(Mon.isShiny(shinyDvs), true, "the vanilla shiny pattern still reads true") + T.eq(Mon.isShiny(plainDvs), false, "and a plain DV set still reads false") + + local ctxSeen + withHook("shiny.roll", function(nextFn, ctx) + ctxSeen = ctx + nextFn() + return true + end, function() + local mon = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs }) + T.eq(mon.shiny, true, "shiny.roll can force a shiny") + end) + T.eq(ctxSeen.species, "SEEDMON", "shiny.roll's ctx carries the species") + T.eq(ctxSeen.level, 5, "shiny.roll's ctx carries the level") + T.check(ctxSeen.dvs ~= nil, "shiny.roll's ctx carries the DVs") + + -- a forced-shiny battle overrides the roll rather than hooking it + withHook("shiny.roll", function() return false end, function() + local forced = Mon.new(DATA, "SEEDMON", 5, + { dvs = plainDvs, shiny = true }) + T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll") + end) + + local genderCtx + withHook("gender.roll", function(nextFn, ctx) + genderCtx = ctx + nextFn() + return "female" + end, function() + local mon = Mon.new(DATA, "SEEDMON", 5, + { dvs = { attack = 15, defense = 0, speed = 0, special = 0 } }) + T.eq(mon.gender, "female", "gender.roll can replace the answer") + end) + T.eq(genderCtx.ratio, DATA.pokemon.SEEDMON.genderRatio, + "gender.roll's ctx carries the species' ratio byte") + T.eq(genderCtx.species, "SEEDMON", "gender.roll's ctx carries the species") + + -- anything that is not one of the three genders falls back to vanilla + withHook("gender.roll", function() return "enby" end, function() + T.eq(Mon.gender(DATA.pokemon.SEEDMON, + { attack = 15, defense = 0, speed = 0, special = 0 }), "male", + "an unknown gender.roll answer falls back to the DV read") + end) +end + +-- ------- held_item.trigger +-- +-- Driven through Battle:heldEffect directly: it is the one function every +-- held-item site on Gold reads its (effect, parameter) pair out of, which is +-- the property that makes one hook cover all eight triggers. + +do + local Battle = require("src.battle.gen2.Battle") + local battle = setmetatable({ + data = { items = { KINGS_ROCK = { id = "KINGS_ROCK", name = "KING'S ROCK", + heldEffect = "HELD_FLINCH", + heldParameter = 30 } } }, + }, Battle) + local mon = { species = "SEEDMON", item = "KINGS_ROCK" } + + local effect, parameter = battle:heldEffect(mon, "flinch") + T.eq(effect, "HELD_FLINCH", "the vanilla held effect comes off the item") + T.eq(parameter, 30, "and so does its parameter") + + local ctxSeen + withHook("held_item.trigger", function(nextFn, ctx) + ctxSeen = ctx + return nextFn() + end, function() + local e, p = battle:heldEffect(mon, "flinch") + T.eq(e, "HELD_FLINCH", "held_item.trigger's vanilla answers the item") + T.eq(p, 30, "held_item.trigger's vanilla answers the parameter") + end) + T.eq(ctxSeen.trigger, "flinch", "held_item.trigger names which site called") + T.eq(ctxSeen.mon, mon, "held_item.trigger carries the holder") + T.eq(ctxSeen.item, "KINGS_ROCK", "held_item.trigger carries the item id") + T.eq(ctxSeen.effect, "HELD_FLINCH", "held_item.trigger carries the effect") + T.eq(ctxSeen.parameter, 30, "held_item.trigger carries the parameter") + T.eq(ctxSeen.battle, battle, "held_item.trigger carries the battle") + + -- suppression: nil is "this item does nothing at this trigger" + withHook("held_item.trigger", function() return nil end, function() + T.eq(battle:heldEffect(mon, "flinch"), nil, + "held_item.trigger can switch an item off") + end) + + -- substitution: another HELD_* name, keeping the item's own parameter + withHook("held_item.trigger", function() return "HELD_QUICK_CLAW" end, + function() + local e, p = battle:heldEffect(mon, "priority") + T.eq(e, "HELD_QUICK_CLAW", "held_item.trigger can substitute an effect") + T.eq(p, 30, "and the item's own parameter survives the substitution") + end) + + -- the residual arm reads through the same seam + local leftovers = { species = "SEEDMON", item = "LEFTOVERS", hp = 10, + maxHp = 20, stats = { hp = 20 } } + battle.data.items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", + heldEffect = "HELD_LEFTOVERS" } + local residual + withHook("held_item.trigger", function(nextFn, ctx) + residual = ctx.trigger + return nextFn() + end, function() + battle:heldEffect(leftovers, "residual") + end) + T.eq(residual, "residual", + "the end-of-turn arm reaches held_item.trigger as 'residual'") +end + +-- ------- intro.boot.*: the GS boot cinema +-- +-- Red boots into IntroMovie and has no copyright card, no GAME FREAK splash +-- and no attract movie, so these four cards are the rare case where a NEW name +-- is the honest one -- there is no Gen 1 moment to share with. (The Oak +-- speech next door is the opposite case and reuses intro.oak_speech.* verbatim; +-- gate_gen2_mod_api.lua holds that half.) One name per card, raised the frame +-- the card comes up, plus the one card end that carries a fact nothing +-- downstream does: whether the movie was watched or skipped. +-- +-- The screens take their data by argument and draw nothing here, so the whole +-- chain runs ROM-free. + +do + local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") + local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") + local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") + local TitleState = require("src.ui.gen2.TitleState") + + local game = { data = {}, save = { player = {} } } + + local seen = capture("intro.boot.copyright", function() + CopyrightSplash.new(game, {}):enter() + end) + T.eq(#seen, 1, "the copyright card raises intro.boot.copyright once") + T.check(seen[1].screen ~= nil and seen[1].game == game, + "intro.boot.copyright carries { screen, game }") + + seen = capture("intro.boot.gamefreak", function() + GameFreakPresents.new(game, {}):enter() + end) + T.eq(#seen, 1, "the GAME FREAK splash raises intro.boot.gamefreak once") + T.check(seen[1].screen ~= nil and seen[1].game == game, + "intro.boot.gamefreak carries { screen, game }") + + local movie + seen = capture("intro.boot.movie", function() + movie = GoldSilverIntro.new(game, {}) + movie:enter() + end) + T.eq(#seen, 1, "the attract movie raises intro.boot.movie once") + T.check(seen[1].screen == movie and seen[1].game == game, + "intro.boot.movie carries { screen, game }") + + -- GoldSilverIntro.PlayFrame's PAD_BUTTONS exit. + seen = capture("intro.boot.movie_ended", function() movie:skip() end) + T.eq(#seen, 1, "a skipped movie raises intro.boot.movie_ended once") + T.eq(seen[1].skipped, true, "and reports skipped = true") + T.check(type(seen[1].frames) == "number", + "intro.boot.movie_ended carries the frame count it reached") + + -- IntroScene17's `ld c, 64` tail, i.e. the movie run to its end. + seen = capture("intro.boot.movie_ended", function() + local watched = GoldSilverIntro.new(game, {}) + watched:enter() + watched:finish() + end) + T.eq(seen[1].skipped, false, "a movie watched through reports skipped = false") + + seen = capture("intro.boot.title", function() + TitleState.new(game, {}):enter() + end) + T.eq(#seen, 1, "the title screen raises intro.boot.title once") + T.check(seen[1].screen ~= nil and seen[1].game == game, + "intro.boot.title carries { screen, game }") +end + +-- ------- the mod-free state is restored + +for _, name in ipairs({ "intro.boot.copyright", "intro.boot.gamefreak", + "intro.boot.movie", "intro.boot.movie_ended", + "intro.boot.title", + "happiness.changed", "breeding.egg_created", + "egg.hatched", "phone.call_received", + "clock.day_changed", "pokerus.infected", + "roamer.moved", "roamer.encountered", + "apricorn.converted", "bug_contest.scored", + "unown.unlocked", "radio.channel", "mail.written", + "mail.read" }) do + T.eq(Runtime.wants(name), false, + "every case unsubscribed: " .. name) +end +-- Hooks:wrap's remover empties the chain but leaves the (empty) table, the +-- same residue gate_events.lua documents for the event bus, so the check is on +-- the chain's contents rather than on wantsHook. +for _, name in ipairs({ "held_item.trigger", "breeding.compatibility", + "phone.contact_list", "shiny.roll", "gender.roll" }) do + T.eq(#(hooks.chains[name] or {}), 0, "every hook case unwrapped: " .. name) +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.errors = nil + +T.finish("gen2_new_seams") diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index 368d6a07..ff46a1e4 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -104,17 +104,26 @@ end -- ------- warn: unsatisfied game_version range against Version.engine do - -- a range the -dev engine cannot satisfy (needs a released >=1.0.0) + -- Stamped like a shipped build, because the 0.0.0-dev placeholder is not a + -- compatibility statement and both LauncherMods and Loader.devEngine skip + -- the range check on it. Unstamped, this row is "ok" on purpose. + local was = Version.engine + Version.engine = "1.4.0" local manifests = { mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua", - game_version = ">=1.0.0" }), + game_version = ">=9.9.9" }), } local m = byId(LauncherMods.deriveList(manifests, { mods = {} })) eq(m.future.status, "warn", "engine outside the game_version range warns") - check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil, + check(m.future.statusDetail:find(">=9.9.9", 1, true) ~= nil, "version warn detail quotes the required range") - check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil, + check(m.future.statusDetail:find("1.4.0", 1, true) ~= nil, "version warn detail quotes the engine version") + Version.engine = was + + -- and the dev placeholder agrees with the loader instead of warning + local dev = byId(LauncherMods.deriveList(manifests, { mods = {} })) + eq(dev.future.status, "ok", "a dev checkout does not warn where the loader loads") end -- ------- warn: hard dependency missing, disabled, or wrong version @@ -166,6 +175,200 @@ do "conflict is reported ahead of a version warn on the same mod") end +-- ------- which game a row is answered for (src/mods/ModTargets.lua) + +do + local manifests = { + mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }), + mf({ id = "two", name = "Two", version = "1.0.0", entry = "m.lua", + games = { "gen2" } }), + mf({ id = "both", name = "Both", version = "1.0.0", entry = "m.lua", + games = { "all" } }), + } + -- no game named: the pre-per-game view, where every row is just ready + local all = byId(LauncherMods.deriveList(manifests, { mods = {} })) + eq(all.one.targets, "GEN 1", "the chip says which games the mod is for") + eq(all.two.targets, "GEN 2", "for each of them") + eq(all.both.targets, "GEN 1+2", "including both") + eq(all.one.targetsHere, nil, "with no game to answer for, nothing is claimed") + eq(all.two.status, "ok", "and no row is judged against a game") + + local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold")) + eq(onGold.one.status, "other_game", "a Gen 1 mod is not for Gold") + eq(onGold.one.statusDetail, "For Gen 1, not Gold", "and says so in one line") + eq(onGold.one.targetsHere, false, "the row carries the verdict too") + eq(onGold.two.status, "ok", "a Gen 2 mod is ready there") + eq(onGold.both.targetsHere, true, "and so is one that claims both") + + local onRed = byId(LauncherMods.deriveList(manifests, { mods = {} }, "red")) + eq(onRed.two.status, "other_game", "the same rule points the other way") + eq(onRed.one.status, "ok", "without touching the Gen 1 mod") +end + +-- ------- the row is a verdict, not a decoration: the loader enforces it +-- +-- "For Blue, not Red" has to be what the boot does, per VERSION and not only +-- per generation, or the panel is reporting a claim while the mod runs anyway. +-- Real loader, real gate (Loader:_gateGeneration), no love. + +do + local Sdk = require("tests.modkit.sdk") + local GameVersion = require("src.core.GameVersion") + local function manifestFile(id, games) + return ("{\"id\":\"%s\",\"name\":\"%s\",\"version\":\"1.0.0\"," .. + "\"entry\":\"main.lua\",\"api\":2,\"games\":[\"%s\"]}"):format(id, id, games) + end + local FILES = { + ["mods/blueonly/manifest.json"] = manifestFile("blueonly", "blue"), + ["mods/blueonly/main.lua"] = "local mod = ...\n", + ["mods/goldonly/manifest.json"] = manifestFile("goldonly", "gold"), + ["mods/goldonly/main.lua"] = "local mod = ...\n", + ["mods/anygame/manifest.json"] = manifestFile("anygame", "all"), + ["mods/anygame/main.lua"] = "local mod = ...\n", + } + local paths = { "mods/blueonly", "mods/goldonly", "mods/anygame" } + local was = GameVersion.get() + GameVersion.set("red") + local run = Sdk.loadMods(paths, { fs = Sdk.memfs(FILES), generation = 1 }) + local rows = byId(LauncherMods.deriveList({ + mf({ id = "blueonly", name = "blueonly", version = "1.0.0", + entry = "main.lua", games = { "blue" } }), + mf({ id = "goldonly", name = "goldonly", version = "1.0.0", + entry = "main.lua", games = { "gold" } }), + mf({ id = "anygame", name = "anygame", version = "1.0.0", + entry = "main.lua", games = { "all" } }), + }, { mods = {} }, "red")) + for _, id in ipairs({ "blueonly", "goldonly", "anygame" }) do + local ran = run.loader.mods[id].state ~= "wrong_generation" + eq(ran, rows[id].targetsHere, + "the loader and the panel agree about " .. id .. " on Red") + end + eq(run.loader.mods.blueonly.state, "wrong_generation", + "a Blue-only mod does not run on Red") + eq(run.loader.mods.blueonly.skipReason, "For Blue, not Red", + "and the skip line is the launcher's own line") + eq(run.loader.mods.anygame.state, "loaded", "a mod for every game still runs") + run.release() + + -- the override answers for ONE game. A version-blind flag forced a mod + -- past the gate on a game whose owner was never asked (SaveData.modForced). + local Serializer = require("src.core.SaveSerializer") + local function bootWith(modsGen2, generation) + local fs = Sdk.memfs(FILES) + fs.write("options.lua", Serializer.encode({ mods = {}, modsGen2 = modsGen2 })) + local r = Sdk.loadMods(paths, { fs = fs, generation = generation }) + local state = r.loader.mods.blueonly.state + r.release() + return state + end + eq(bootWith({ blueonly = { red = true } }, 1), "loaded", + "an override for Red runs the Blue-only mod on Red") + eq(bootWith({ blueonly = { blue = true } }, 1), "wrong_generation", + "an override for another game does not answer for Red") + eq(bootWith({ blueonly = true }, 1), "wrong_generation", + "a pre-per-game flag keeps its old meaning: Gen 2 only, never Red") + + GameVersion.set("gold") + eq(bootWith({ blueonly = true }, 2), "loaded", + "and on the Gen 2 game it always meant, it still forces") + eq(bootWith({}, 2), "wrong_generation", "with no override the gate holds") + if was then GameVersion.set(was) end +end + +do + -- the player's override is the one thing that outranks the author's claim, + -- and it must read as the untested thing it is (Loader:_gateGeneration) + local manifests = { + mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }), + } + local m = byId(LauncherMods.deriveList(manifests, + { mods = {}, modsGen2 = { one = true } }, "gold")) + eq(m.one.status, "warn", "a forced mod is a warning, not a wrong game") + check(m.one.statusDetail:find("Gold", 1, true) ~= nil, + "and the line names the game it was forced onto") + eq(m.one.targetsHere, true, "it will run there") +end + +-- ------- enable flags: the panel reads exactly what the switch writes +-- +-- One scope for both halves (SaveData.modScope). While per-game flags are a +-- preview the shared flag is the whole answer, so a modsByVersion overlay -- +-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can +-- never leave the switch showing an answer no writer can reach. + +local SaveData = require("src.core.SaveData") + +local function flip(options, id, enabled, version) + SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) +end + +do + local manifests = { + mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua", + games = { "all" } }), + } + local planted = { mods = { one = true }, + modsByVersion = { gold = { one = false } } } + local expected = SaveData.PER_VERSION_MODS and false or true + eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, + expected, "the overlay is read exactly when a write can reach it") + + -- the round trip, the thing the dead switch failed: flip it, re-derive + local options = { mods = {} } + for _, version in ipairs({ "red", "gold" }) do + flip(options, "one", false, version) + eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled, + false, "switching off reads back off on " .. version) + flip(options, "one", true, version) + eq(byId(LauncherMods.deriveList(manifests, options, version)).one.enabled, + true, "and switching on reads back on on " .. version) + end + + -- the same round trip through the planted overlay: no write is ignored + flip(planted, "one", false, "gold") + eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, + false, "a planted overlay cannot outrank the player's own write") + flip(planted, "one", true, "gold") + eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, + true, "in either direction") + + -- and what the loader will do agrees with the row, per game + eq(SaveData.modEnabled(planted, "one", SaveData.modScope("gold")), true, + "the loader resolves the flag under the same scope the panel read") +end + +-- ------- a dependency that does not run here is a dependency problem +-- +-- The loader's target skip is contagious (Loader:_enforceDependencies), so a +-- mod that runs on every game still does not run on Gold when the mod it +-- needs is Gen 1 only. + +do + local manifests = { + mf({ id = "base", name = "Base", version = "1.0.0", entry = "m.lua", + games = { "gen1" } }), + mf({ id = "user", name = "User", version = "1.0.0", entry = "m.lua", + games = { "all" }, dependencies = { "base" } }), + } + local onGold = byId(LauncherMods.deriveList(manifests, { mods = {} }, "gold")) + eq(onGold.user.status, "warn", + "a dependency that cannot run here is a warning, not Ready") + check(onGold.user.statusDetail:find("base", 1, true) ~= nil + and onGold.user.statusDetail:find("Gold", 1, true) ~= nil, + "and the line names the dependency and the game") + eq(byId(LauncherMods.deriveList(manifests, { mods = {} }, "red")).user.status, + "ok", "while the same pair is Ready where both run") + + -- the player's override on the DEPENDENCY clears it: same scope the loader + -- resolves the override under (SaveData.modForced) + local forced = { mods = {}, modsGen2 = { base = { gold = true } } } + eq(byId(LauncherMods.deriveList(manifests, forced, "gold")).user.status, "ok", + "forcing the dependency onto Gold clears the dependent's warning") + eq(byId(LauncherMods.deriveList(manifests, + { mods = {}, modsGen2 = { base = { red = true } } }, "gold")).user.status, + "warn", "an override for another game does not answer for this one") +end + -- ------- locateRoot: manifest at the archive root do diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua new file mode 100644 index 00000000..f77390e7 --- /dev/null +++ b/tests/engine/mod_targets_tests.lua @@ -0,0 +1,208 @@ +-- Which games a mod is for: the manifest `games` key, the legacy gen2compat +-- reading of it, the per-game enable overlay both mod surfaces resolve +-- through, and the profile that carries a per-game set between installs. +-- luajit tests/engine/mod_targets_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local GameVersion = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local ModProfile = require("src.mods.ModProfile") +local ModTargets = require("src.mods.ModTargets") +local SaveData = require("src.core.SaveData") + +local function mf(raw) + raw.id = raw.id or "m" + raw.name = raw.name or "M" + raw.version = raw.version or "1.0.0" + raw.entry = raw.entry or "main.lua" + return Manifest.validate(raw) +end + +local function list(m) + return table.concat(ModTargets.versions(m), ",") +end + +-- ------- tokens expand off GameVersion, never a literal list + +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", + "gen2 is every Gen 2 game") + eq(table.concat(ModTargets.expand("all"), ","), + table.concat(GameVersion.ORDER, ","), "all is the launcher order itself") + eq(ModTargets.expand("silver"), 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 + +do + local versions, unknown = ModTargets.normalize({ "gold", "red", "red" }) + 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" }) + eq(#bad, 1, "an unknown token comes back for the caller to report") + eq(bad[1], "crystal", "by name") +end + +-- ------- the legacy reading: gen2compat only ever ADDS Gen 2 + +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", + "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") +end + +-- ------- games declares it directly, and the loader's gate reads the derived +-- gen2compat, so no Loader change is needed to honour the new key + +do + local gen2 = mf({ games = { "gen2" } }) + eq(list(gen2), "gold", "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", "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", + "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" } }) + eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest") + check(not pcall(mf, { api = 2, games = { "crystal" } }), + "api 2 refuses a game it does not have") + check(not pcall(mf, { games = "gen1" }), + "games must be an array, not a bare string") + eq(list(mf({ games = {} })), "red,blue,yellow", + "an empty games list falls back rather than orphaning the mod") +end + +-- ------- supports / runsHere: the claim, then what actually happens + +do + local gen1 = mf({ id = "one" }) + local gen2 = mf({ id = "two", games = { "gen2" } }) + check(ModTargets.supports(gen1, "red"), "a Gen 1 mod supports Red") + check(not ModTargets.supports(gen1, "gold"), "and not Gold") + check(ModTargets.supports(gen2, "gold"), "a Gen 2 mod supports Gold") + check(not ModTargets.supports(gen2, "red"), "and not Red") + check(ModTargets.supports(gen1, nil, 1), "a generation can be asked directly") + check(not ModTargets.supports(gen1, nil, 2), "and answers the same way") + check(not ModTargets.runsHere(gen1, "gold"), "no claim, no run") + check(ModTargets.runsHere(gen1, "gold", nil, true), + "the player's override is what forces one anyway") +end + +-- ------- one label, both surfaces + +do + eq(ModTargets.label(mf({})), "Gen 1", "whole generations read as generations") + eq(ModTargets.label(mf({ games = { "all" } })), "Gen 1+2", "both of them") + eq(ModTargets.label(mf({ games = { "gen2" } })), "Gen 2", "or just the one") + eq(ModTargets.label(mf({ games = { "red", "gold" } })), "Red/Gold", + "part of a generation reads as the games themselves") + eq(ModTargets.chip(mf({})), "GEN 1", "the chip is the same label, uppercased") + eq(ModTargets.detail(mf({}), "gold"), "For Gen 1, not Gold", + "and the launcher line names both sides") +end + +-- ------- per-game enable overlay: absent means the shared flag, which is +-- what every options.lua written before this key holds + +do + local opts = { mods = { a = false, b = true } } + eq(SaveData.modEnabled(opts, "a", "gold"), false, + "no overlay entry falls through to the shared flag") + eq(SaveData.modEnabled(opts, "b"), true, "with or without a game") + eq(SaveData.modEnabled(opts, "ghost", "gold"), nil, + "an unanswered mod is nil, so the caller owns the default") + + opts.modsByVersion = { gold = { a = true } } + eq(SaveData.modEnabled(opts, "a", "gold"), true, "the game's own answer wins") + eq(SaveData.modEnabled(opts, "a", "red"), false, "for that game only") + eq(SaveData.modEnabled(opts, "a"), false, "and the shared view is untouched") +end + +do + local opts = { mods = {} } + SaveData.setModEnabled(opts, "a", false, "gold") + eq(opts.modsByVersion.gold.a, false, "a per-game write lands in that game") + eq(opts.mods.a, nil, "and never in the shared flag") + SaveData.setModEnabled(opts, "a", false) + eq(opts.mods.a, false, "a shared write lands in the shared flag") + SaveData.setModEnabled(opts, "a", false, "gold") + eq(opts.modsByVersion.gold.a, nil, + "a per-game answer that agrees with the shared one is dropped, not stored") + eq(SaveData.modEnabled(opts, "a", "gold"), false, "and still resolves the same") + + local fresh = { mods = {} } + SaveData.setModEnabled(fresh, "b", true, "gold") + eq(fresh.modsByVersion.gold.b, nil, + "no shared flag reads as enabled, so agreeing with it stores nothing") +end + +do + -- the write scope is gated on the loader honouring it, so no surface can + -- promise a per-game set the boot would ignore + eq(SaveData.modScope("gold"), SaveData.PER_VERSION_MODS and "gold" or nil, + "modScope follows the PER_VERSION_MODS switch") +end + +-- ------- a profile carries the per-game half of a setup + +do + local available = { + { id = "a", enabled = true }, { id = "b", enabled = false }, + } + local byVersion = { gold = { a = false }, red = {} } + local p = ModProfile.capture(available, {}, byVersion) + eq(p.enabledByVersion.gold.a, false, "capture takes the per-game answers") + eq(p.enabledByVersion.red, nil, "an empty game is not carried") + + p.name = "PROF" + local back = ModProfile.decode(ModProfile.encode(p)) + eq(back.enabledByVersion.gold.a, false, "and they survive an export") + + local opts = { mods = {} } + ModProfile.restoreVersions(back, opts) + eq(opts.modsByVersion.gold.a, false, "applying a profile restores them") + check(ModProfile.matchesVersions(back, opts), + "a restored setup still reads as that profile") + opts.modsByVersion.gold.a = true + check(not ModProfile.matchesVersions(back, opts), + "and drifts to ad-hoc as soon as one game differs") + + local untouched = { modsByVersion = { gold = { z = true } } } + ModProfile.restoreVersions({ enabledByVersion = {} }, untouched) + eq(untouched.modsByVersion.gold.z, true, + "a profile that names no game blanks none") +end + +do + -- a shared .g1rmodlist is untrusted input; the manager indexes it straight + -- into options.modsByVersion + local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({ + format = "g1rmodlist", formatVersion = 1, + profile = { name = "P", enabledByVersion = { + gold = { a = true }, silver = { a = true }, red = "nope" } }, + })) + eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept") + eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read") + eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table") +end + +T.finish("mod_targets") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 89ff8a82..7b258b64 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -69,6 +69,21 @@ do local n2, m2 = SaveData.slotSummary(nil) T.eq(n2, nil, "slotSummary of an empty slot has no name") T.eq(m2, nil, "slotSummary of an empty slot has no meta") + + -- A Gen 2 (Gold) save stores playTime as a { hours, minutes, seconds, + -- frames } table, not a seconds count. The launcher lists EVERY version's + -- slots, so a math.floor on that table crashed the whole launcher the moment + -- a Gold save existed -- and dropped its CONTINUE row. slotSummary reads + -- both shapes now. + local gName, gMeta = SaveData.slotSummary({ + player = { name = "GOLD" }, + playTime = { hours = 3, minutes = 35, seconds = 40, frames = 45 }, + pokedex = { owned = { CYNDAQUIL = true, PIDGEY = true } }, + }) + T.eq(gName, "GOLD", "slotSummary reads a Gen 2 save's name") + T.eq(gMeta.dexCount, 2, "and its dex count") + T.eq(gMeta.timeText, "3:35", + "and formats the Gen 2 { hours, minutes, seconds } playTime without crashing") end -- ---------------------------------------------- legacy migration happy path diff --git a/tests/engine/uwp_baseroms_test.lua b/tests/engine/uwp_baseroms_test.lua index 65e03cf3..83f13e11 100644 --- a/tests/engine/uwp_baseroms_test.lua +++ b/tests/engine/uwp_baseroms_test.lua @@ -81,7 +81,7 @@ local function importer(ready) }, RomImporter) end -local allReady = importer({ red = true, blue = true, yellow = true }) +local allReady = importer({ red = true, blue = true, yellow = true, gold = true }) allReady:_queueBaseRomScan() eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery") eq(listings, 0, "ready launcher does not enumerate baseroms") @@ -123,7 +123,7 @@ eq(picks, 0, "missing detected ROM does not open the picker unexpectedly") 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 }) +local rescanned = importer({ red = true, blue = true, yellow = true, gold = 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_audio_test.lua b/tests/gen2_audio_test.lua new file mode 100644 index 00000000..9d0031fa --- /dev/null +++ b/tests/gen2_audio_test.lua @@ -0,0 +1,183 @@ +-- Gen 2 ChipSynth channel driver smoke against a Gold cache. +-- luajit tests/gen2_audio_test.lua +-- Also dofile'd by tests/run_tests.lua. Skips when no gold cache / audio. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 audio") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local ChipSynth = require("src.core.ChipSynth") + +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 audioPath = cache .. "/data/generated/audio.lua" +local progPath = cache .. "/assets/generated/audio/programs.bin" +local audioFile = io.open(audioPath, "r") +local progFile = io.open(progPath, "rb") +if not audioFile or not progFile then + if audioFile then audioFile:close() end + if progFile then progFile:close() end + check(true, "gold audio cache absent : SKIP") + S.finish() + return +end +audioFile:close() +progFile:close() + +local audio = assert(loadfile(audioPath))() +check(audio.generation == 2, "audio.generation is 2") +if audio.runtime ~= true then + check(true, "audio.runtime still false : re-import Gold to exercise driver (SKIP synth)") + S.finish() + return +end +check(audio.bankOrder[1] == 0x07, "Gen 2 banks start at $07 (not Gen 1 $02)") +check(audio.bankOrder[3] == 0x3a, "engine/songs bank $3A present") +check(audio.songs and audio.songs.Music_TitleScreen, "Music_TitleScreen header") +check(audio.songs and audio.songs.Music_NewBarkTown, "Music_NewBarkTown header") +check(audio.waveBanks and audio.waveBanks["1"], "WaveSamples waveBanks[1]") +check(audio.drumkits and audio.drumkits.bank == 0x3a, "Drumkits in bank $3A") +eq(audio.mapSongs and audio.mapSongs.NEW_BARK_TOWN, "Music_NewBarkTown", + "New Bark mapSongs → Music_NewBarkTown") + +local title = audio.songs.Music_TitleScreen +eq(title.bank, 0x3a, "TitleScreen bank") +eq(title.address, 0x77b1, "TitleScreen address (pokegold.sym)") + +-- love_stub has no real PhysFS; inject programs.bin for ChipSynth.loadBanks. +local prog = assert(io.open(progPath, "rb")) +local progBytes = prog:read("*a") +prog:close() +love.filesystem.write(audio.programFile, progBytes) + +local data = { audio = audio } +local ok, engine = pcall(ChipSynth.newEngine, data, title, { allowLoops = true }) +check(ok, "TitleScreen engine builds" .. (ok and "" or (": " .. tostring(engine)))) +if ok then + eq(engine.generation, 2, "engine.generation is 2") + eq(#engine.channels, 4, "TitleScreen has 4 channels") + -- Ch1 opens with tempo 256 (titlescreen.asm) + engine.channels[1]:nextEvent() + eq(engine.tempo, 256, "Ch1 tempo command → 256") + -- Render a short slice; must produce non-silent PCM. + local left, right = 0, 0 + local peak = 0 + for _ = 1, ChipSynth.SAMPLE_RATE do -- 1 second + local l, r = engine:sampleStereo() + left, right = left + l, right + r + peak = math.max(peak, math.abs(l), math.abs(r)) + end + check(peak > 0.01, "TitleScreen renders audible samples (peak=" .. peak .. ")") +end + +local bark = audio.songs.Music_NewBarkTown +ok, engine = pcall(ChipSynth.newEngine, data, bark, { allowLoops = true }) +check(ok, "NewBarkTown engine builds" .. (ok and "" or (": " .. tostring(engine)))) +if ok then + eq(#engine.channels, 3, "NewBarkTown has 3 channels") + engine.channels[1]:nextEvent() + eq(engine.tempo, 187, "NewBarkTown tempo 187") +end + +-- SFX / cries (present after re-import with sfxOrder extract). +if type(audio.sfx) == "table" and audio.sfx.Sfx_CaughtMon then + check(audio.sfx.Sfx_CaughtMon.generation == 2, "Sfx_CaughtMon is Gen 2 header") + check(audio.sfxOrder and audio.sfxOrder[3] == "Sfx_CaughtMon", + "sfxOrder[3] is Sfx_CaughtMon (SFX id 2)") + local sfxOk, sfxEng = pcall(ChipSynth.newEngine, data, audio.sfx.Sfx_CaughtMon, { + sfx = true, allowLoops = false, + }) + check(sfxOk, "Sfx_CaughtMon engine builds" + .. (sfxOk and "" or (": " .. tostring(sfxEng)))) +else + check(true, "sfx table absent : re-import Gold for SFX coverage (SKIP)") +end +if type(audio.cries) == "table" and audio.cries.MARILL then + local cry = audio.cries.MARILL + check(cry.header and cry.header.bank, "MARILL cry has header") + check(type(cry.pitch) == "number", "MARILL cry pitch") + + -- A cry is a channel of `square_note` rows, not packed music notes, and the + -- port used to parse it as the latter: the envelope byte read as a second + -- note and the frequency low byte as a note_type command that ate two more. + -- The audible result was a cry that ran until renderEffectData's five-second + -- cap. Cry_Marill_Ch5 opens `duty_cycle_pattern 0,2,0,2` then + -- `square_note 2, 8, 8, 1752`, so the first event has to come back as a tone + -- three frames long (SetNoteDuration: tempo 288 * (2+1) >> 8) at register + -- 1752 + the cry's own pitch offset. + local cryOk, cryEng = pcall(ChipSynth.newEngine, data, cry.header, { + sfx = true, allowLoops = false, + frequencyOffset = cry.pitch, cryLength = cry.length, + }) + check(cryOk, "MARILL cry engine builds" + .. (cryOk and "" or (": " .. tostring(cryEng)))) + if cryOk then + -- "Tempo is effectively length" (_PlayCry): the length word IS the tempo, + -- with no $80 base -- that base is Gen 1's Audio_SetSfxTempo. + eq(cryEng.channels[1].frameTicks, cry.length, + "the cry's tempo is its length word, not $80 + it") + local event = cryEng.channels[1]:nextEvent() + check(event ~= nil, "the first square_note yields an event") + if event then + eq(event.register, (1752 + cry.pitch) % 0x800, + "and it is a tone at the note's own frequency register") + eq(event.volume, 8, "with the envelope byte's volume") + eq(math.floor(event.duration * 60 + 0.5), 3, + "and SetNoteDuration's three frames") + end + -- The whole cry, which on the cart is well under a second. + local frames = 0 + for _ = 1, 64 do + local next_ = cryEng.channels[1]:nextEvent() + if not next_ then break end + frames = frames + next_.duration * 60 + end + check(frames < 60, + ("channel 5 runs %.0f frames, under a second"):format(frames)) + end +else + check(true, "cries table absent : re-import Gold for cry coverage (SKIP)") +end + +-- Which sfx silence the music. On the cart sfx channel N takes hardware +-- channel N over from the music channel with the same number, so a +-- FOUR-channel sfx leaves the song nothing to play through -- and every jingle +-- is four channels. The port used to duck on a six-name list, so the phone +-- number's jingle (and the TM, the badge, the egg) played over the top. +if type(audio.sfx) == "table" and audio.sfx.Sfx_RegisterPhoneNumber then + local function channels(name) + local list = ChipSynth.effectChannels(data, audio.sfx[name]) + return list and #list or 0 + end + eq(channels("Sfx_RegisterPhoneNumber"), 4, + "Sfx_RegisterPhoneNumber claims all four channels") + eq(channels("Sfx_Item"), 4, "Sfx_Item claims all four") + eq(channels("Sfx_GetBadge"), 4, "Sfx_GetBadge claims all four") + -- Three-channel move sounds must NOT duck: they fire several times a second + -- inside an animation and pausing the song under each would stutter. + eq(channels("Sfx_Psychic"), 3, "Sfx_Psychic is three channels") + eq(channels("Sfx_ReadText2"), 1, "the A-press beep is one channel") + + local Sound = require("src.core.Sound") + check(Sound.ducksMusic(data, "Sfx_RegisterPhoneNumber"), + "the phone-number jingle mutes the music") + check(Sound.ducksMusic(data, "Sfx_GetBadge"), + "so does the badge fanfare") + check(not Sound.ducksMusic(data, "Sfx_Psychic"), + "a three-channel move sound does not") + check(not Sound.ducksMusic(data, "Sfx_ReadText2"), + "and neither does the A-press beep") + -- Sfx_Fanfare is only three channels (5, 6 and 8) and is a jingle anyway. + check(Sound.ducksMusic(data, "Sfx_Fanfare"), + "the three-channel Sfx_Fanfare is still named as a jingle") +else + check(true, "sfx table absent, re-import Gold for duck coverage (SKIP)") +end + +S.finish() diff --git a/tests/gen2_autoinput_test.lua b/tests/gen2_autoinput_test.lua new file mode 100644 index 00000000..8d38dadd --- /dev/null +++ b/tests/gen2_autoinput_test.lua @@ -0,0 +1,187 @@ +-- Automated joypad input (home/joypad.asm GetJoypad .auto, StartAutoInput, +-- StopAutoInput). ROM-free: `luajit tests/gen2_autoinput_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 auto input") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local AutoInput = require("src.core.gen2.AutoInput") +local Input = require("src.core.Input") +local Vm = require("src.script.gen2.Vm") + +-- ---- the ring itself ------------------------------------------------------ +-- Stream format is [input][duration], the duration counts EXTRA frames, and a +-- duration of $ff parks on the pair forever with the input forced to NO_INPUT. +do + local ring = AutoInput.new() + check(not ring:isActive(), "a fresh ring is not armed") + check(ring:start({ AutoInput.PAD_A, 0x02, AutoInput.PAD_B, 0x00, 0xff }), + "start arms a raw byte stream") + check(ring:isActive(), "and the ring reports the controller as taken") + + -- StartAutoInput leaves wAutoInputLength at 0, so the first frame already + -- reads the stream: A is held for 1 + 2 frames. + eq(ring:advance(), AutoInput.PAD_A, "frame 1 reads A out of the stream") + eq(ring:advance(), AutoInput.PAD_A, "frame 2 is inside A's duration") + eq(ring:advance(), AutoInput.PAD_A, "frame 3 is the last of A's duration") + eq(ring:advance(), AutoInput.PAD_B, "frame 4 takes the next pair") + -- duration 0 means exactly one frame, so the terminator lands next. + local mask, ended = ring:advance() + eq(mask, AutoInput.NO_INPUT, "the $ff terminator reads as NO_INPUT") + check(ended, "and reports the stream as finished") + check(not ring:isActive(), "StopAutoInput has run by then") +end + +do + -- "A duration of $ff will end the stream indefinitely": the input is + -- overwritten with NO_INPUT and the address is NOT advanced, so the same + -- pair is re-read every 256 frames and the stream never terminates. + local ring = AutoInput.new() + ring:start({ AutoInput.PAD_A, 0xff }) + eq(ring:advance(), AutoInput.NO_INPUT, + "a $ff duration overwrites its own input") + for _ = 1, 0xff do ring:advance() end + eq(ring:advance(), AutoInput.NO_INPUT, "and re-arms itself on the same pair") + check(ring:isActive(), "a $ff duration never hands control back") +end + +-- ---- the four streams the ROM actually has -------------------------------- +do + -- engine/events/catch_tutorial_input.asm DudeAutoInput_A: 0x50 blank frames, + -- one frame of A, then park. + local ring = AutoInput.new() + ring:start("DUDE_A") + local quiet = true + for _ = 1, 0x51 do + if ring:advance() ~= AutoInput.NO_INPUT then quiet = false end + end + check(quiet, "DUDE_A waits 0x51 frames before answering") + eq(ring:advance(), AutoInput.PAD_A, "then presses A on frame 0x52") + eq(ring:advance(), AutoInput.NO_INPUT, "and releases it the frame after") + check(ring:isActive(), "DUDE_A parks rather than terminating") + + -- engine/events/catch_tutorial.asm CatchTutorial.AutoInput is nothing but a + -- lockout: NO_INPUT held for as long as the demo lasts. + local lock = AutoInput.new() + lock:start("CATCH_TUTORIAL") + eq(lock:advance(), AutoInput.NO_INPUT, "the tutorial stream presses nothing") + check(lock:isActive(), "but it does hold the controller") +end + +do + -- A `dba` from an autoinput command resolves against the four StartAutoInput + -- call sites in ../pokegold-symbols/pokegold.sym. + local ring = AutoInput.new() + check(ring:startPointer(0x70, 0x4e04), + "70:4e04 is DudeAutoInput_RightA") + eq(ring:advance(), AutoInput.NO_INPUT, "which opens with eight blank frames") + for _ = 1, 8 do ring:advance() end + eq(ring:advance(), AutoInput.PAD_RIGHT, "then taps RIGHT") + + local unknown = AutoInput.new() + check(not unknown:startPointer(0x3e, 0x4000), + "a pointer with no stream behind it arms nothing") + check(not unknown:isActive(), + "rather than taking the controller away with no way back") +end + +-- ---- through src/core/Input.lua ------------------------------------------- +-- The point of the port: a canned frame has to look like a real press to the +-- per fixed-step edge detection, the same way the touch overlay's does. +do + Input:init() + local ring = AutoInput.new() + ring:start({ AutoInput.PAD_A, 0x01, 0xff }, Input) + + ring:step(Input) + Input:step() + check(Input:isDown("a"), "an auto frame holds A") + check(Input:wasPressed("a"), "and reads as an edge on the step it lands") + + ring:step(Input) + Input:step() + check(Input:isDown("a"), "A stays held through its duration") + check(Input:wasPressed("a"), + "hJoyPressed is latched for the whole duration, not just its first frame") + + ring:step(Input) + Input:step() + check(not Input:isDown("a"), "the terminator releases A") + check(not ring:isActive(), "and stops the ring") +end + +do + -- While a stream is armed GetJoypad never reads hJoypadDown, so a player + -- leaning on a key cannot steer the DUDE. + Input:init() + local ring = AutoInput.new() + ring:start({ AutoInput.PAD_A, 0x00, AutoInput.NO_INPUT, 0x02, 0xff }, Input) + + Input:keypressed("left") + ring:step(Input) + Input:step() + check(not Input:isDown("left"), "a physically held key is discarded") + check(Input:isDown("a"), "only the stream's own button is down") + + ring:step(Input) + Input:step() + check(not Input:isDown("a"), "and the stream's next pair presses nothing") + check(not Input:isDown("left"), "with the player still locked out") +end + +do + -- The handback: the frame the terminator lands on still belongs to the + -- stream, and the step after it is the player's again. love_stub has no + -- keyboard, so the restore is asserted by the ring reporting the controller + -- released rather than by the key coming back. + Input:init() + local ring = AutoInput.new() + ring:start({ 0xff }, Input) + check(ring:step(Input), "the terminator frame is still an auto frame") + check(not ring:isActive(), "StopAutoInput ran on it") + check(not ring:step(Input), "the next step is the player's") + check(not ring:step(Input), "and stays that way") +end + +-- ---- the script side ------------------------------------------------------ +do + -- Script_autoinput: three GetScriptByte calls, bank first, then the low and + -- high halves of the address. It does not write wScriptVar. + local seen + local vm = Vm.new({ + ["s:t"] = { + { op = "setval", value = 5 }, + { op = "autoinput", args = { 0x70, 0x0e, 0x4e } }, + { op = "end" }, + }, + }, {}, nil, { + autoInput = function(bank, address) seen = { bank, address } end, + }) + vm:start("s:t") + eq(seen and seen[1], 0x70, "the bank is the first operand byte") + eq(seen and seen[2], 0x4e0e, "and the address is little-endian after it") + eq(vm.scriptVar, 5, "autoinput leaves wScriptVar alone") +end + +do + -- Script_catchtutorial farcalls CatchTutorial, which brackets StartBattle in + -- StartAutoInput / StopAutoInput, and only then falls into Script_reloadmap. + local order = {} + local vm = Vm.new({ + ["s:t"] = { + { op = "catchtutorial", args = { 3 } }, + { op = "end" }, + }, + }, {}, nil, { + autoInputStream = function(name) order[#order + 1] = "start:" .. name end, + stopAutoInput = function() order[#order + 1] = "stop" end, + reloadMap = function() order[#order + 1] = "reload" end, + }) + vm:start("s:t") + eq(table.concat(order, ","), "start:CATCH_TUTORIAL,stop,reload", + "the lockout closes before the map reload, as in the asm") +end + +S.finish() diff --git a/tests/gen2_badge_boosts_test.lua b/tests/gen2_badge_boosts_test.lua new file mode 100644 index 00000000..7edd98ec --- /dev/null +++ b/tests/gen2_badge_boosts_test.lua @@ -0,0 +1,241 @@ +-- Badge battle boosts: BadgeStatBoosts (engine/battle/core.asm:6534) and +-- DoBadgeTypeBoosts (engine/battle/misc.asm:146). +-- +-- luajit tests/gen2_badge_boosts_test.lua +-- +-- ROM-free. Every Johto badge on the boost walk raises the PLAYER's +-- in-battle stat by 1/8 -- Zephyr on Attack, Mineral on Defense and Plain on +-- Speed (the deliberate bit swap), Glacier on Special Attack with the buggy +-- Special Defense re-check -- and an owned badge whose BadgeTypeBoosts row +-- matches the move's type adds an eighth to the damage ahead of STAB. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 badge boosts") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Damage = require("src.battle.gen2.Damage") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + ROCK_THROW = { id = "ROCK_THROW", name = "ROCK THROW", power = 50, + type = "ROCK", accuracy = 100, pp = 15, effect = "EFFECT_NORMAL_HIT" }, + GUST = { id = "GUST", name = "GUST", power = 40, type = "FLYING", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local 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 = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function newBattle(badges, kantoBadges) + local player = Mon.new(DATA, "MACHOP", 20, { dvs = perfect }) + player.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "ROCK_THROW", pp = 15, maxPp = 15 }, + { id = "GUST", pp = 35, maxPp = 35 }, + } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { player = { id = 7, badges = badges or {}, + kantoBadges = kantoBadges or {} } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = function(n) return (n or 1) > 1 and 1 or 0 end }) + return battle, player, wild, save +end + +-- ---- BoostStat: +1/8, capped at 999 --------------------------------------- +do + eq(Battle.boostStat(80), 90, "80 + 80/8 = 90") + eq(Battle.boostStat(7), 7, "a stat under 8 gains nothing (plain shift)") + eq(Battle.boostStat(999), 999, "the 999 cap holds") + eq(Battle.boostStat(950), 999, "and clips a boost past it") +end + +-- ---- the stat walk: Zephyr/Mineral/Plain/Glacier -------------------------- +do + local battle, player = newBattle({}) + local base = player.stats.attack + eq(battle:battleStat(player, "attack"), base, + "no badge, no boost") + + battle, player = newBattle({ ZEPHYR = true }) + eq(battle:battleStat(player, "attack"), Battle.boostStat(base), + "ZEPHYRBADGE boosts Attack by 1/8") + eq(battle:battleStat(player, "defense"), player.stats.defense, + "and only Attack") + + battle, player = newBattle({ MINERAL = true }) + eq(battle:battleStat(player, "defense"), + Battle.boostStat(player.stats.defense), + "MINERALBADGE lands on Defense (the PlainBadge bit swap)") + + battle, player = newBattle({ PLAIN = true }) + eq(battle:battleStat(player, "speed"), + Battle.boostStat(player.stats.speed), + "PLAINBADGE lands on Speed") + + battle, player = newBattle({ GLACIER = true }) + eq(battle:battleStat(player, "specialAttack"), + Battle.boostStat(player.stats.specialAttack), + "GLACIERBADGE boosts Special Attack") + + -- The ENEMY never gets badge boosts: BadgeStatBoosts runs against + -- wBattleMon only. + local wild = battle.enemy + eq(battle:battleStat(wild, "attack"), wild.stats.attack, + "the enemy's stats are never badge boosted") + + -- Positional keying, the same fallback FieldMoves.hasBadge accepts. + battle, player = newBattle({ true }) + eq(battle:battleStat(player, "attack"), Battle.boostStat(base), + "badges keyed by bit position read the same") +end + +-- ---- the buggy Glacier Special Defense re-check --------------------------- +-- `srl a` runs on BoostStat's leftover cap arithmetic (core.asm:6584's own +-- "this check is buggy" comment), so whether SpDef gets the second boost +-- depends on the BOOSTED Special Attack value. +do + -- high(v) = 0: a = 0 - 3 - borrow; odd only when low(v) >= LOW(999). + eq(Battle.glacierBoostsSpDef(100), false, "SpA 100: no SpDef boost") + eq(Battle.glacierBoostsSpDef(240), true, "SpA 240: boosted") + -- high(v) = 1: odd while the borrow holds. + eq(Battle.glacierBoostsSpDef(300), true, "SpA 300: boosted") + eq(Battle.glacierBoostsSpDef(500), false, "SpA 500: not boosted") + -- high(v) = 2: only the no-borrow tail. + eq(Battle.glacierBoostsSpDef(600), false, "SpA 600: not boosted") + eq(Battle.glacierBoostsSpDef(750), true, "SpA 750: boosted") + -- high(v) = 3 under the cap always borrows. + eq(Battle.glacierBoostsSpDef(800), true, "SpA 800: boosted") + -- At or past the cap `a` leaves as LOW(999) = $e7, odd. + eq(Battle.glacierBoostsSpDef(999), true, "capped SpA: boosted") + + local battle, player = newBattle({ GLACIER = true }) + local spAtk = Battle.boostStat(player.stats.specialAttack) + local want = Battle.glacierBoostsSpDef(spAtk) + and Battle.boostStat(player.stats.specialDefense) + or player.stats.specialDefense + eq(battle:battleStat(player, "specialDefense"), want, + "battleStat routes SpDef through the buggy re-check") + + battle, player = newBattle({}) + eq(battle:battleStat(player, "specialDefense"), + player.stats.specialDefense, + "no Glacier, no SpDef boost ever (the srl lands on a zero bit)") +end + +-- ---- Plain Badge Speed feeds turn order ----------------------------------- +do + local battle, player = newBattle({ PLAIN = true }) + eq(battle:effectiveSpeed(player), + Damage.applyStage(Battle.boostStat(player.stats.speed), 0), + "effectiveSpeed reads the boosted Speed") +end + +-- ---- DoBadgeTypeBoosts in the damage pipe --------------------------------- +do + -- Hand-sized numbers: level 10, power 40, attack 50, defense 40. + -- base = (floor(10*2/5)+2) * 40 * 50 / 40 / 50 -> 6, +MIN_DAMAGE = 8. + local args = { + level = 10, power = 40, moveType = "ROCK", + attacker = { attack = 50, types = {}, stages = {} }, + defender = { defense = 40, types = {}, stages = {} }, + types = TYPES, matchups = {}, + variation = 100, + } + local plain = Damage.calc(args) + eq(plain, 8, "the unboosted pipe leaves 8") + args.badgeTypeBoost = true + eq(Damage.calc(args), 9, + "the badge boost adds an eighth (at least 1) ahead of STAB") +end + +-- ---- the boost table walks Johto then Kanto ------------------------------- +do + local battle = newBattle({ ZEPHYR = true }) + eq(battle:badgeTypeBoost(battle.player, "FLYING"), true, + "ZEPHYRBADGE boosts FLYING moves") + eq(battle:badgeTypeBoost(battle.player, "ROCK"), false, + "but not ROCK (BOULDERBADGE's row, not owned)") + eq(battle:badgeTypeBoost(battle.enemy, "FLYING"), false, + "and never on the enemy's turn (hBattleTurn gate)") + + battle = newBattle({}, { BOULDER = true }) + eq(battle:badgeTypeBoost(battle.player, "ROCK"), true, + "BOULDERBADGE (wKantoBadges) boosts ROCK -- Brock's own line") + eq(battle:badgeTypeBoost(battle.player, "NORMAL"), false, + "PLAINBADGE's NORMAL row stays shut without the badge") + + battle = newBattle({}, { VOLCANO = true, EARTH = true }) + eq(battle:badgeTypeBoost(battle.player, "FIRE"), true, + "VOLCANOBADGE boosts FIRE") + eq(battle:badgeTypeBoost(battle.player, "GROUND"), true, + "EARTHBADGE boosts GROUND") +end + +-- ---- through the real hit ------------------------------------------------- +do + -- Same battle twice, the only difference the badge: the ROCK move's damage + -- must grow and the NORMAL move's must not. + local function hit(badges, kanto, move) + local battle = newBattle(badges, kanto) + local enemy = battle.enemy + local before = enemy.hp + battle:useMove(battle.player, enemy, move) + return before - enemy.hp + end + local bare = hit({}, {}, "ROCK_THROW") + local badged = hit({}, { BOULDER = true }, "ROCK_THROW") + check(badged > bare, + ("BOULDERBADGE raises ROCK THROW's damage (%d -> %d)"):format( + bare, badged)) + eq(hit({}, { BOULDER = true }, "TACKLE"), hit({}, {}, "TACKLE"), + "a NORMAL move is not BOULDERBADGE's business") +end + +S.finish() diff --git a/tests/gen2_badges_test.lua b/tests/gen2_badges_test.lua new file mode 100644 index 00000000..a782e7d5 --- /dev/null +++ b/tests/gen2_badges_test.lua @@ -0,0 +1,86 @@ +-- Badges are one store, not two. +-- +-- luajit tests/gen2_badges_test.lua +-- +-- On the cart ENGINE_ZEPHYRBADGE is bit 0 of wJohtoBadges +-- (constants/engine_flags.asm), so `setflag ENGINE_ZEPHYRBADGE` and "the player +-- owns the Zephyr Badge" are the same write. The port had two stores: gym +-- scripts wrote save.engineFlags, while FieldMoves.hasBadge, VAR_BADGES, the +-- trainer card and the save summary all read save.player.badges -- which +-- nothing ever assigned. +-- +-- The visible bug, found by the Gold route bot after it beat Falkner and Bugsy: +-- CUT refused with "Sorry! A new BADGE is required" while holding the badge +-- that grants it, so Ilex Forest could never be left and no HM was usable for +-- the rest of the game. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 badges") +local check, eq = S.check, S.eq + +local FieldMoves = require("src.world.gen2.FieldMoves") +local World = require("src.world.gen2.World") + +-- The bit order is wJohtoBadges', not the order a player earns them: Jasmine's +-- MINERALBADGE sits at bit 4 and Chuck's STORMBADGE at bit 5, even though Chuck +-- is fought first. Getting this backwards maps SURF's and FLY's gates onto +-- each other's bits. +eq(FieldMoves.JOHTO_BADGES[5], "MINERAL", "bit 4 is MINERAL") +eq(FieldMoves.JOHTO_BADGES[6], "STORM", "bit 5 is STORM") + +-- The ids come from constants/engine_flags.asm's const_def run. +eq(FieldMoves.BADGE_FLAG[26].name, "ZEPHYR", "ENGINE_ZEPHYRBADGE is 26") +eq(FieldMoves.BADGE_FLAG[27].name, "HIVE", "ENGINE_HIVEBADGE is 27") +eq(FieldMoves.BADGE_FLAG[30].name, "MINERAL", "ENGINE_MINERALBADGE is 30") +eq(FieldMoves.BADGE_FLAG[31].name, "STORM", "ENGINE_STORMBADGE is 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") + +-- --------------------------------------------------------------------------- +-- The round trip: what a gym script writes is what a field move reads. +-- --------------------------------------------------------------------------- + +local save = { player = { badges = {}, kantoBadges = {} }, engineFlags = {} } +local world = setmetatable({ game = { save = save } }, { __index = World }) + +eq(world:engineFlag(27), false, "no HIVEBADGE to begin with") +eq(FieldMoves.hasBadge(save, FieldMoves.BADGE.CUT), false, "so CUT is gated") + +-- What BugsyScript does: `setflag ENGINE_HIVEBADGE`. +world:setEngineFlag(27, true) + +eq(world:engineFlag(27), true, "the flag reads back as set") +eq(save.player.badges.HIVE, true, "and it landed in the badge store") +check(FieldMoves.hasBadge(save, FieldMoves.BADGE.CUT), + "CUT is now allowed -- the bug was that this stayed false forever") + +-- VAR_BADGES counts the same store, so a script branching on badge count +-- (section 09's "if VAR_BADGES == 6, the Rockets appear in Goldenrod") sees it. +local counted = 0 +for _, has in pairs(save.player.badges) do + if has then counted = counted + 1 end +end +eq(counted, 1, "the badge count sees it too") + +-- Clearing works, and does not leave a stale copy in the flag table. +world:setEngineFlag(27, false) +eq(world:engineFlag(27), false, "cleared") +eq(save.player.badges.HIVE, nil, "cleared in the badge store") +eq(save.engineFlags[27], nil, "and never shadowed by a second copy") + +-- Non-badge engine flags still use the ordinary table. +world:setEngineFlag(1, true) -- ENGINE_MAP_CARD +eq(world:engineFlag(1), true, "a non-badge flag still round-trips") +eq(save.engineFlags[1], true, "...through save.engineFlags") + +-- Every gate FieldMoves.BADGE names must be a real Johto badge. +for move, badge in pairs(FieldMoves.BADGE) do + local found = false + for _, name in ipairs(FieldMoves.JOHTO_BADGES) do + if name == badge then found = true break end + end + check(found, ("%s is gated on a real badge (%s)"):format(move, badge)) +end + +S.finish() diff --git a/tests/gen2_bank_of_mom_test.lua b/tests/gen2_bank_of_mom_test.lua new file mode 100644 index 00000000..c4029232 --- /dev/null +++ b/tests/gen2_bank_of_mom_test.lua @@ -0,0 +1,272 @@ +-- Mom's savings, special BankOfMom (engine/events/mom.asm). ROM-free: +-- luajit tests/gen2_bank_of_mom_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 bank of mom") +local check, eq = S.check, S.eq + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local Save = require("src.core.gen2.Save") + +check(Specials.STUBS.BankOfMom == nil, + "BankOfMom is a HANDLER: the six-digit keypad it needed now exists") +check(Specials.HANDLERS.BankOfMom ~= nil, + "and special dispatch resolves to it") + +-- A minimal Gold save: just the two accounts BankOfMom reads and writes. +local function record(money, saved, active, savingMoney) + return { + player = { money = money or 0 }, + mom = { savedMoney = saved or 0, active = active or false, + savingMoney = savingMoney or false }, + } +end + +-- Wires H.BankOfMom's whole `hooks(vm)` surface: `money`/`setMoney` over the +-- two accounts (YOUR_MONEY 0, MOMS_MONEY 1), `bankOfMomAmount` answering off +-- a fixed queue the way a real keypad answers one amount per open, and +-- `playSfxNamed` recorded so the transaction jingle can be checked for. +local function bankVm(rec, amounts) + local sfx = {} + local amountIndex = 0 + local vm = Vm.new({}, {}, Events.new(), { + specials = { + save = function() return rec end, + money = function(account) + return account == 1 and rec.mom.savedMoney or rec.player.money + end, + setMoney = function(account, value) + if account == 1 then rec.mom.savedMoney = value + else rec.player.money = value end + end, + bankOfMomAmount = function(_kind, _saved, _held, done) + amountIndex = amountIndex + 1 + done(amounts and amounts[amountIndex]) + end, + playSfxNamed = function(name, fallback) + sfx[#sfx + 1] = { name = name, fallback = fallback } + end, + }, + }) + vm.showTextFn = function() end + vm.sfx = sfx + return vm +end + +-- Drives the coroutine to completion, feeding a `yesorno` off `yesAnswers` and +-- a `menu` off `menuAnswers` (both 1-based queues, consumed in the order the +-- handler asks), and collects every `text` yield along the way -- the same +-- shape tests/gen2_vm_test.lua's driveMoveDeletion/driveNameRater use, with +-- a `menu` arm added for BankOfMom's GET/SAVE/CHANGE/CANCEL screen. +local function drive(vm, yesAnswers, menuAnswers) + local texts, menus = {}, {} + local yi, mi = 0, 0 + local resumeArg = nil + local ok, req = coroutine.resume(vm.co, resumeArg) + while true do + if not ok then error(req) end + if req and req.kind == "text" then texts[#texts + 1] = req.text end + if req and req.kind == "menu" then menus[#menus + 1] = req.header end + if coroutine.status(vm.co) == "dead" then break end + resumeArg = nil + if req and req.kind == "yesorno" then + yi = yi + 1 + resumeArg = yesAnswers[yi] + elseif req and req.kind == "menu" then + mi = mi + 1 + resumeArg = menuAnswers[mi] + end + ok, req = coroutine.resume(vm.co, resumeArg) + end + return texts, menus +end + +local function run(vm, yesAnswers, menuAnswers) + vm.co = coroutine.create(function() Specials.HANDLERS.BankOfMom(vm) end) + return drive(vm, yesAnswers or {}, menuAnswers or {}) +end + +-- ---- .CheckIfBankInitialized / .InitializeBank ----------------------------- +-- The very first visit skips IsThisAboutYourMoney and jumps straight to +-- "shall I save your money?" +do + local rec = record(3000, 0, false, false) + local vm = bankVm(rec) + local texts = run(vm, { true }) + eq(#texts, 3, "leaving1, leaving2 (accepted), leaving3") + check(texts[1]:find("cute", 1, true) ~= nil, "MomLeavingText1") + check(texts[2]:find("take care", 1, true) ~= nil, "MomLeavingText2") + check(texts[3]:find("careful", 1, true) ~= nil, "MomLeavingText3") + check(rec.mom.active, "MOM_ACTIVE_F is set either way") + check(rec.mom.savingMoney, "and MOM_SAVING_SOME_MONEY_F, since the answer was yes") +end + +do + local rec = record(3000, 0, false, false) + local vm = bankVm(rec) + local texts = run(vm, { false }) + eq(#texts, 2, "leaving1 and leaving3 only -- MomLeavingText2 is skipped") + check(texts[2]:find("careful", 1, true) ~= nil, "still MomLeavingText3") + check(rec.mom.active, "the bank is still marked initialized") + check(not rec.mom.savingMoney, "but nothing is being saved") +end + +-- ---- .IsThisAboutYourMoney -------------------------------------------------- +do + local rec = record(1000, 500, true, true) + local texts = run(bankVm(rec), { false }) + eq(#texts, 2, "the room-tidy line, then the fallback close") + check(texts[2]:find("Just do what", 1, true) ~= nil, + "declining reads as MomJustDoWhatYouCanText -- DSTChecks does not apply " + .. "in this port (no wStartHour offset to nudge)") + eq(rec.player.money, 1000, "and nothing about either account moved") + eq(rec.mom.savedMoney, 500, "") +end + +-- ---- .AccessBankOfMom: CANCEL and B both fall through to the same close ---- +do + local rec = record(1000, 500, true, true) + local texts, menus = run(bankVm(rec), { true }, { 4 }) + check(#menus == 1, "the GET/SAVE/CHANGE/CANCEL menu opened once") + eq(menus[1].items[1], "GET", "GET is withdraw") + eq(menus[1].items[2], "SAVE", "SAVE is deposit") + eq(menus[1].items[3], "CHANGE", "CHANGE is the savings toggle") + eq(menus[1].items[4], "CANCEL", "") + check(texts[#texts]:find("Just do what", 1, true) ~= nil, + "CANCEL falls to MomJustDoWhatYouCanText") +end + +do + local rec = record(1000, 500, true, true) + local texts = run(bankVm(rec), { true }, { 0 }) + check(texts[#texts]:find("Just do what", 1, true) ~= nil, + "so does B (menu answers 0, the way Script_verticalmenu's carry does)") +end + +-- ---- .StoreMoney (SAVE / deposit) ------------------------------------------ +do + -- First offer is more than the wallet holds: retried in place, no state + -- change, then a good amount goes through. + local rec = record(1000, 500, true, true) + local vm = bankVm(rec, { 2000, 300 }) + local texts = run(vm, { true }, { 2 }) + check(texts[4]:find("don't have", 1, true) ~= nil, + "MomInsufficientFundsInWalletText for the first, too-big offer") + check(texts[#texts]:find("safe", 1, true) ~= nil, "MomStoredMoneyText closes it") + eq(rec.player.money, 700, "the wallet paid out the SECOND, valid amount") + eq(rec.mom.savedMoney, 800, "and the savings account received it") + eq(#vm.sfx, 1, "the transaction jingle played once") + eq(vm.sfx[1].name, "Sfx_Transaction", "by its pokegold label") +end + +-- Depositing enough to overflow Mom's account clamps it to the cap and +-- reports "no room" -- but the clamp already landed, exactly the way +-- GiveMoney commits before it reports carry (engine/events/money.asm). The +-- wallet is never charged for a deposit that could not fully land. +do + local rec = record(999999, 999990, true, true) + local vm = bankVm(rec, { 20, 0 }) + local texts = run(vm, { true }, { 2 }) + check(texts[4]:find("can't save", 1, true) ~= nil, "MomNotEnoughRoomInBankText") + eq(rec.mom.savedMoney, 999999, "clamped to MAX_MONEY regardless") + eq(rec.player.money, 999999, "the wallet was never touched by the failed leg") + check(texts[#texts]:find("Just do what", 1, true) ~= nil, + "entering 0 on the retry cancels out to the same close as B") +end + +-- A zero entry or a flat B (bankOfMomAmount answering nil) both read as +-- CancelDeposit -- no retry, straight to the close. +do + local rec = record(1000, 500, true, true) + local texts = run(bankVm(rec, { 0 }), { true }, { 2 }) + check(texts[#texts]:find("Just do what", 1, true) ~= nil, "amount 0 cancels") + eq(rec.player.money, 1000, "untouched") +end + +do + local rec = record(1000, 500, true, true) + local texts = run(bankVm(rec, { nil }), { true }, { 2 }) + check(texts[#texts]:find("Just do what", 1, true) ~= nil, "B (nil) cancels too") +end + +-- ---- .TakeMoney (GET / withdraw) ------------------------------------------- +do + local rec = record(200, 900, true, true) + local vm = bankVm(rec, { 1000, 400 }) + local texts = run(vm, { true }, { 1 }) + check(texts[4]:find("haven't saved", 1, true) ~= nil, + "MomHaventSavedThatMuchText for the first offer, more than is saved") + check(texts[#texts]:find("don't", 1, true) ~= nil + or texts[#texts]:find("give up", 1, true) ~= nil, + "MomTakenMoneyText closes it") + eq(rec.player.money, 600, "the wallet received the withdrawal") + eq(rec.mom.savedMoney, 500, "and the savings account paid it out") + eq(vm.sfx[1].name, "Sfx_Transaction", "the same jingle as a deposit") +end + +-- The mirror of the deposit overflow: too much withdrawn to fit the wallet +-- clamps the WALLET to the cap and leaves the savings account untouched. +do + local rec = record(999990, 999999, true, true) + local vm = bankVm(rec, { 20 }) + local texts = run(vm, { true }, { 1 }) + check(texts[4]:find("can't take", 1, true) ~= nil, "MomNotEnoughRoomInWalletText") + eq(rec.player.money, 999999, "the wallet was clamped to MAX_MONEY") + eq(rec.mom.savedMoney, 999999, "and Mom's account never paid out the failed leg") +end + +-- ---- .StopOrStartSavingMoney (CHANGE) -------------------------------------- +do + local rec = record(1000, 500, true, false) + local texts = run(bankVm(rec), { true, true }, { 3 }) + check(texts[#texts]:find("Trust me", 1, true) ~= nil, "MomStartSavingMoneyText") + check(rec.mom.savingMoney, "and MOM_SAVING_SOME_MONEY_F is set") +end + +do + local rec = record(1000, 500, true, true) + local texts = run(bankVm(rec), { true, false }, { 3 }) + check(texts[#texts]:find("Just do what", 1, true) ~= nil, + "declining still closes on MomJustDoWhatYouCanText, with no line of its own") + check(not rec.mom.savingMoney, "but the flag comes off") + check(rec.mom.active, "MOM_ACTIVE_F stays set -- the bank itself is not undone") +end + +-- ---- Save.lua: the fields BankOfMom reads and writes ----------------------- +do + local fresh = Save.newGame({}) + eq(fresh.mom.active, false, "a new save has never talked to the bank") + eq(fresh.mom.savingMoney, false, "and nothing is being saved yet") + eq(fresh.mom.savedMoney, 0, "wMomsMoney starts empty") + + -- An older save's `mom` table predates `active`/`savingMoney` entirely. + local old = { mom = { name = "MOM" } } + Save.normalize(old) + eq(old.mom.active, false, "normalize fills in the unset bank state") + eq(old.mom.savingMoney, false, "") + eq(old.mom.savedMoney, 0, "and the balance, clamped the way player.money is") + + local over = { mom = { savedMoney = 5000000 } } + Save.normalize(over) + eq(over.mom.savedMoney, Save.MAX_MONEY, "an out-of-range balance is clamped, not trusted") +end + +-- ---- World:whiteOut -- HalveMoney (engine/events/whiteout.asm) ------------ +do + local World = require("src.world.gen2.World") + local healed, warped = false, false + local fakeSelf = { + game = { save = { player = { money = 5001 } } }, + showText = function(_self, _text, onDone) onDone() end, + healParty = function() healed = true end, + warpToSpawn = function() warped = true end, + } + World.whiteOut(fakeSelf) + eq(fakeSelf.game.save.player.money, 2500, "halved and floored") + check(healed, "HealParty still runs") + check(warped, "and WarpToSpawnPoint still runs") +end + +S.finish() diff --git a/tests/gen2_battle_anims_test.lua b/tests/gen2_battle_anims_test.lua new file mode 100644 index 00000000..696ea0d0 --- /dev/null +++ b/tests/gen2_battle_anims_test.lua @@ -0,0 +1,543 @@ +-- The battle-animation tables, read out of a Gold cache and checked against +-- the decomp they came from. +-- +-- Every expectation below is a literal from pokegold, so a failure names the +-- file it disagrees with rather than "a number changed": +-- data/moves/animations.asm BattleAnim_Tackle +-- data/battle_anims/objects.asm battleanimobj rows +-- data/battle_anims/framesets.asm oamframe lists +-- data/battle_anims/oam.asm battleanimoam + dbsprite rows +-- data/battle_anims/object_gfx.asm anim_obj_gfx counts +-- +-- luajit tests/gen2_battle_anims_test.lua +-- Also dofile'd by tests/run_tests.lua. Skips when there is no Gold cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle anims") +local check, eq = S.check, S.eq + +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 path = cache .. "/data/generated/battle_anims.lua" +local file = io.open(path, "r") +if not file then + check(true, "no Gold cache at " .. path .. " (SKIP)") + S.finish() + return +end +file:close() + +local data = assert(loadfile(path))() + +-- A cache from before the anims were extracted still has the Phase 2 stub. +if not data.scripts then + check(true, "battle_anims.lua is still the stub -- re-import Gold (SKIP)") + S.finish() + return +end + +-- ---- the per-move scripts ------------------------------------------------- + +check(type(data.moves) == "table", "a move -> script map") +eq(data.bank, 50, "the animations live in bank $32") + +local function countKeys(t) + local n = 0 + for _ in pairs(t or {}) do n = n + 1 end + return n +end + +eq(countKeys(data.moves), 251, "one animation per move (NUM_MOVES)") +check(countKeys(data.scripts) > 300, + ("%d scripts in the pool, including the ones only anim_call reaches") + :format(countKeys(data.scripts))) +eq(countKeys(data.scripts), #data.scriptOrder, + "scriptOrder lists every script exactly once") + +-- BattleAnim_Tackle, row for row. This one is worth pinning: it uses a gfx +-- load, a call, a BG effect, a wait, a sound, an object and a return, which is +-- most of the command set a move animation actually reaches for. +local tackle = data.scripts[data.moves.TACKLE] +check(tackle ~= nil, "TACKLE has a script") +if tackle then + eq(tackle[1][1], "1gfx", "anim_1gfx first") + eq(tackle[1][2], 1, "...loading BATTLE_ANIM_GFX_HIT") + eq(tackle[2][1], "call", "then anim_call BattleAnim_TargetObj_1Row") + check(type(tackle[2][2]) == "string", + "and a branch target is an address key, not two loose bytes") + check(data.scripts[tackle[2][2]] ~= nil, + "which resolves to a script in the pool") + eq(tackle[3][1], "bgeffect", "then anim_bgeffect") + eq(tackle[3][2], 36, "...BATTLE_BG_EFFECT_TACKLE") + eq(tackle[3][4], 1, "...on BG_EFFECT_USER") + eq(tackle[4][1], "wait", "then anim_wait 4") + eq(tackle[4][2], 4, "...four frames") + eq(tackle[5][1], "sound", "then anim_sound") + eq(tackle[6][1], "obj", "then anim_obj") + eq(tackle[6][2], 0, "...BATTLE_ANIM_OBJ_HIT_BIG_YFIX") + eq(tackle[6][3], 136, "...at x 136") + eq(tackle[6][4], 48, "...and y 48") + eq(tackle[7][1], "wait", "then anim_wait 8") + eq(tackle[7][2], 8, "...eight frames") + eq(tackle[8][1], "call", "then anim_call BattleAnim_ShowMon_0") + eq(tackle[9][1], "ret", "then anim_ret") + eq(#tackle, 9, "and nothing after it") +end + +-- ---- BattleAnimObjects ---------------------------------------------------- +-- +-- A row is SIX bytes: BATTLEANIMOBJ_LENGTH is `_RS - 1`, discounting the +-- struct's runtime INDEX byte. Reading seven walks every row off the end. + +eq(countKeys(data.objects), 188, "188 animation objects") +local ember = data.objects.BATTLE_ANIM_OBJ_EMBER +check(ember ~= nil, "EMBER is one of them") +if ember then + eq(ember.fixY, 0xaa, "EMBER's enemy y-fix is $aa") + eq(ember.frameset, "BATTLE_ANIM_FRAMESET_EMBER", "its own frameset") + eq(ember.func, "BATTLE_ANIM_FUNC_EMBER", "its own per-frame function") + eq(ember.palette, "PAL_BATTLE_OB_RED", + "an OBJ palette, not the BG palette of the same number") + eq(ember.gfx, "BATTLE_ANIM_GFX_FIRE", "off the fire sheet") +end +local hit = data.objects.BATTLE_ANIM_OBJ_HIT_BIG_YFIX +if hit then + eq(hit.fixY, 0xff, "HIT_BIG_YFIX is the $ff y-fix variant") + eq(hit.func, "BATTLE_ANIM_FUNC_NULL", "and does not move itself") + eq(hit.palette, "PAL_BATTLE_OB_GRAY", "grey") +end + +-- ---- framesets ------------------------------------------------------------ +-- +-- oam_anims.asm counts DOWN from $ff: oamend $ff, oamrestart $fe, oamwait +-- $fd, oamdelete $fc. Getting that order wrong runs one frameset into the +-- next and every list comes out dozens of frames long. + +eq(countKeys(data.framesets), 185, "185 framesets") +local hitBig = data.framesets.BATTLE_ANIM_FRAMESET_HIT_BIG +check(hitBig ~= nil, "HIT_BIG is one") +if hitBig then + eq(#hitBig, 2, "Frameset_HitBig is one frame and a delete") + eq(hitBig[1][1], "frame", "the frame") + eq(hitBig[1][2], "BATTLE_ANIM_OAMSET_00", "...is OAM set 00") + eq(hitBig[1][3], 6, "...held six frames") + eq(hitBig[2][1], "delete", "then oamdelete") +end +local ember2 = data.framesets.BATTLE_ANIM_FRAMESET_EMBER +if ember2 then + eq(#ember2, 3, "Frameset_Ember is two frames and a restart") + eq(ember2[3][1], "restart", "oamrestart, not oamend") +end +local shake = data.framesets.BATTLE_ANIM_FRAMESET_PUNCH_SHAKE +if shake then + eq(#shake, 9, "Frameset_PunchShake alternates four times, then deletes") + eq(shake[9][1], "delete", "ending on oamdelete") +end + +-- ---- OAM sets ------------------------------------------------------------- +-- +-- dbsprite emits y FIRST even though the macro reads x first, and a negative +-- tile column wraps: `dbsprite -2, -2, 0, 0, $00, $0` is y $f0, x $f0. + +eq(countKeys(data.oamsets), 216, "216 OAM sets") +local set00 = data.oamsets.BATTLE_ANIM_OAMSET_00 +check(set00 ~= nil, "OAM set 00 is one") +if set00 then + eq(set00.vtile, 0, "at vtile offset 0") + eq(#set00.sprites, 16, "with 16 sprites") + eq(set00.sprites[1].y, 0xf0, "the first one's y wraps to $f0") + eq(set00.sprites[1].x, 0xf0, "and its x to $f0") + eq(set00.sprites[1].tile, 0, "tile $00") + eq(set00.sprites[2].tile, 1, "then tile $01") +end + +-- ---- object graphics ------------------------------------------------------ +-- +-- BATTLE_ANIM_GFX_* starts at 1, because AnimObjGFX row 0 is the empty +-- AnimObj00GFX -- so the name list carries a placeholder and every id indexes +-- it the same way. + +check(countKeys(data.gfx) >= 39, "the object sheets extracted") +local hitGfx = data.gfx.BATTLE_ANIM_GFX_HIT +check(hitGfx ~= nil, "the hit sheet is one") +if hitGfx then + eq(hitGfx.tiles, 21, "AnimObjHitGFX is 21 tiles") + check(hitGfx.image:match("battle_anims/"), "written under battle_anims/") +end +local cutGfx = data.gfx.BATTLE_ANIM_GFX_CUT +if cutGfx then eq(cutGfx.tiles, 6, "AnimObjCutGFX is 6 tiles") end +check(data.gfx.BATTLE_ANIM_GFX_NONE == nil, + "and the empty row 0 is not written as a sheet") + +-- ---- the runtime ----------------------------------------------------------- +-- +-- src/battle/gen2/AnimRunner.lua + AnimObjects.lua + BgEffects.lua: the +-- command interpreter, the ten object structs and the five BG-effect structs, +-- stepped one 60 Hz frame at a time with no window. The expectations below +-- come from engine/battle_anims/{anim_commands,core,functions,bg_effects}.asm +-- rather than from a recorded run, so a failure names the routine it +-- disagrees with. + +local consts +do + local file = io.open(cache .. "/data/generated/constants.lua", "r") + if file then + file:close() + consts = assert(loadfile(cache .. "/data/generated/constants.lua"))() + end +end + +if not (consts and consts.battleAnimObjectOrder) then + check(true, "no constants.lua in the cache -- skipping the runtime (SKIP)") + S.finish() + return +end + +local AnimObjects = require("src.battle.gen2.AnimObjects") +local AnimRunner = require("src.battle.gen2.AnimRunner") + +-- The sine BattleAnim_Sine computes is the same quarter wave the sprite anims +-- use, and entry 16 is exactly $100 -- so sin(pi/2) * d is d itself. +eq(AnimObjects.sine(16, 0x40), 0x40, "sine at a quarter turn is the amplitude") +eq(AnimObjects.sine(0, 0x40), 0, "and zero at zero") +eq(AnimObjects.sine(48, 0x40), 0xc0, "the lower half comes back negative") +eq(AnimObjects.cosine(0, 0x40), 0x40, "cosine leads it by eight steps") + +-- `sra` keeps the sign, which is what every "an eighth of the width" does. +eq(AnimObjects.sra(0xff), 0xff, "sra of -1 is -1") +eq(AnimObjects.sra(0xfd), 0xfe, "sra of -3 is -2") +eq(AnimObjects.sra(0x08), 0x04, "and of 8 is 4") + +local function runner(key, opts) + opts = opts or {} + local r = AnimRunner.new({ + data = data, constants = consts, + battleTurn = opts.turn or 0, animId = opts.animId, + param = opts.param or 0, sfxOrder = consts.sfxOrder, + hooks = opts.hooks, + }) + r:start(key) + return r +end + +-- BattleAnim_Tackle: anim_1gfx HIT, anim_call, anim_bgeffect TACKLE, +-- anim_wait 4, anim_sound, anim_obj HIT_BIG_YFIX, anim_wait 8, anim_call, +-- anim_ret. One step runs commands up to the first wait. +local sounds = {} +local tackle = runner(data.moves.TACKLE, { + animId = "TACKLE", + hooks = { sound = function(name) sounds[#sounds + 1] = name end }, +}) +check(tackle ~= nil, "a runner for Tackle") +tackle:step() +eq(tackle:tileOffsetFor("BATTLE_ANIM_GFX_HIT"), 0, + "anim_1gfx puts the hit sheet at the base tile") +check(tackle.bg:activeCount() >= 1, "and queued a BG effect before waiting") +local steps = 0 +while tackle:step() and steps < 600 do steps = steps + 1 end +check(steps > 0 and steps < 600, "Tackle runs to its anim_ret in " .. steps .. " frames") +check(#sounds >= 1, "and played at least one SFX (" .. tostring(sounds[1]) .. ")") + +-- The pic-swap commands all branch on hBattleTurn with the same `and a / jr z, +-- .player`, and every .player arm writes vTiles2 tile $31, the 6x6 backpic: +-- turn 0 repaints the PLAYER, the fall-through (tile $00, a 7x7 frontpic) the +-- enemy. engine/battle_anims/anim_commands.asm BattleAnimCmd_Transform, +-- _RaiseSub, _DropSub, _MinimizeOpp, _BeatUp. +local CMD = AnimRunner.COMMANDS +local function overrideSide(name, turn) + local r = runner(nil, { turn = turn }) + CMD[name](r) + if r.picOverride.player ~= nil then return "player" end + if r.picOverride.enemy ~= nil then return "enemy" end + return nil +end +for _, name in ipairs({ "transform", "raisesub", "minimizeopp", "beatup" }) do + eq(overrideSide(name, 0), "player", "anim_" .. name .. " on turn 0 is the player's own pic") + eq(overrideSide(name, 1), "enemy", "anim_" .. name .. " on turn 1 is the enemy's") +end +-- $e9 is one of the six labels stacked on BattleAnimCmd_E8's single `ret`, so +-- the opcode named `minimize` draws nothing; $e2 minimizeopp is the real one. +eq(overrideSide("minimize", 0), nil, "anim_minimize ($e9) is a cart dummy") + +-- Every one of the 251 move animations has to terminate, from both sides. +-- A script that never reaches an anim_ret outside a subroutine would hang the +-- battle screen, and the branch-heavy ones (Metronome, Bide, the multi-hit +-- loops) are exactly where that would happen. +local longest, longestName, stuck = 0, nil, 0 +for move, key in pairs(data.moves) do + for _, turn in ipairs({ 0, 1 }) do + local r = runner(key, { animId = move, turn = turn, param = 3 }) + local frames = 0 + local ok = pcall(function() + while r:step() and frames < 1500 do frames = frames + 1 end + end) + if not ok or frames >= 1500 then stuck = stuck + 1 end + if frames > longest then longest, longestName = frames, move end + end +end +eq(stuck, 0, "all 251 move animations terminate on both turns") +check(longest > 0 and longest < 1500, + ("the longest is %s at %d frames"):format(tostring(longestName), longest)) + +-- BattleAnimObjects rows are six bytes and the struct's TILEID comes from the +-- tile dict, not the row -- so an object queued before its sheet is loaded +-- draws at offset 0 rather than reading a seventh byte that is not there. +local objects = AnimObjects.new(data, consts, { battleTurn = 0 }) +local st = objects:queue("BATTLE_ANIM_OBJ_HIT_BIG_YFIX", 0x88, 0x30, 0) +check(st ~= nil, "an object queues into the first free struct") +if st then + eq(st.index, 1, "and takes wLastAnimObjectIndex 1") + eq(st.x, 0x88, "at the x it was given") + eq(st.frame, 0xff, "with FRAME initialised to -1, not 0") + eq(st.tileId, 0, "and no tile offset without a dict entry") +end + +-- BattleAnimCmd_ClearObjs only reaches six and two thirds structs, so 8, 9 +-- and 10 survive it (docs/bugs_and_glitches.md). +local pool = AnimObjects.new(data, consts, { battleTurn = 0 }) +for _ = 1, 10 do pool:queue("BATTLE_ANIM_OBJ_HIT", 0x40, 0x40, 0) end +eq(pool:activeCount(), 10, "ten structs is the whole pool") +pool:clearObjs() +eq(pool:activeCount(), 3, "anim_clearobjs leaves the last three running") + +-- BATTLE_ANIM_FUNC_NULL only deletes once anim_incobj has walked it past +-- .zero; before that it does nothing at all. +local nullObj = AnimObjects.new(data, consts, { battleTurn = 0 }) +local nst = nullObj:queue("BATTLE_ANIM_OBJ_ABSORB_CENTER", 0x40, 0x40, 0) +AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_NULL(nullObj, nst) +eq(nst.index, 1, "FUNC_NULL at jumptable 0 is a no-op") +nst.jt = 1 +AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_NULL(nullObj, nst) +eq(nst.index, 0, "and deletes the object at 1") + +-- BattleAnim_StepToTarget: the lower nybble is the x step and half of it the +-- y step, and a y step of 0 walks the coordinate 256 times -- back to where it +-- started, not once. +local step = AnimObjects.new(data, consts, { battleTurn = 0 }) +local sst = step:queue("BATTLE_ANIM_OBJ_HIT", 0x40, 0x40, 4) +sst.jt = 0 +AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_USER_TO_TARGET(step, sst) +eq(sst.x, 0x44, "a param of 4 moves four right") +eq(sst.y, 0x3e, "and two up") + +-- BgEffects: the shake's amplitude is the struct's BATTLE_TURN field, which +-- flips sign every time the PARAM countdown reloads. +local BgEffects = require("src.battle.gen2.BgEffects") +local bg = BgEffects.new(consts, { battleTurn = 0 }) +bg:queue("BATTLE_BG_EFFECT_SHAKE_SCREEN_X", 0x10, 2, 0x22) +bg:playFrame() +eq(bg.scx, 2, "the first frame shakes by the amplitude it was given") +for _ = 1, 2 do bg:playFrame() end +eq(bg.scx, 0xfe, "and flips to -2 once the period runs out") +for _ = 1, 40 do bg:playFrame() end +eq(bg.scx, 0, "the shake ends flat") +eq(bg:activeCount(), 0, "and frees its struct") + +-- Tackle's BG effect writes the attacker's own scanlines: on the player's +-- turn that is $2f-$5e, and SCX goes NEGATIVE because the player's mon steps +-- to the right. +local tack = BgEffects.new(consts, { battleTurn = 0 }) +tack:queue("BATTLE_BG_EFFECT_TACKLE", 0, 1, 0) +tack:playFrame() +eq(tack.lyStart, 0x2f, "the player's window starts at scanline $2f") +eq(tack.lyEnd, 0x5f, "and ends one past $5e") +eq(tack.lcdc, "SCX", "through rSCX") +tack:playFrame() +eq(tack.lyBackup[0x30], 0, + "Tackle_MoveForward writes the distance so far BEFORE stepping it") +tack:playFrame() +eq(tack.lyBackup[0x30], 0xfe, + "so the step only shows on the frame after: two pixels right") + +-- The intro slide is 72 frames from $90 and $70 to nothing at all. +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +eq(BattleAnimView.SLIDE_FRAMES, 72, "BattleIntroSlidingPics runs 72 frames") +local top, middle = BattleAnimView.slideOffsets(0) +eq(top, 0x90, "the enemy's half starts a screen and a half to the right") +eq(middle, 0x70, "and the player's to the left") +top, middle = BattleAnimView.slideOffsets(72) +eq(top, 0, "both land on zero") +eq(middle, 0, "at the same frame") + + +-- ------------------------------------------------ the screen deformations +-- +-- The thirteen effects that used to be BgEffects.UNMODELLED. Each one is a +-- DeformScreen / DeformWater / surf-ring shape, so the assertions here are +-- about the ARRAY the effect writes rather than what it looks like. + +check(#BgEffects.UNMODELLED == 0, + "every BG effect is modelled (" .. #BgEffects.UNMODELLED .. " left)") + +-- DeformScreen writes only lyStart < row <= lyEnd, but advances its phase on +-- every one of its $80 iterations -- so WHERE the window sits changes which +-- part of the wave lands on it. +local deform = BgEffects.new(consts, { battleTurn = 0 }) +deform.lyStart, deform.lyEnd = 0x10, 0x20 +deform:deformScreen(2, 2) +eq(deform.lyBackup[0x10], 0, "DeformScreen leaves the row AT lyStart alone") +eq(deform.lyBackup[0x21], 0, "and the row past lyEnd") +-- Row n takes sine(2n) at amplitude 2, which is the phase after n iterations. +local SpriteAnims = require("src.ui.gen2.SpriteAnims") +eq(deform.lyBackup[0x18], SpriteAnims.sine(0x18 * 2, 2), + "and the value on a row is the phase that iteration had reached") + +-- WavyScreenFX rotates the window up one, the top row wrapping to the bottom. +local wavy = BgEffects.new(consts, { battleTurn = 0 }) +wavy.lyStart, wavy.lyEnd = 4, 8 +for row = 4, 8 do wavy.lyBackup[row] = row end +wavy:wavyScreenFX() +eq(wavy.lyBackup[4], 5, "the wave travels up a scanline a frame") +eq(wavy.lyBackup[7], 8, "...all the way to the end of the window") +eq(wavy.lyBackup[8], 4, "...and the top row wraps around to the bottom") + +-- Teleport: one DeformScreen, then a rotation a frame, then the registers go +-- back. The player's window is $2f-$5e. +local tele = BgEffects.new(consts, { battleTurn = 0 }) +tele:queue("BATTLE_BG_EFFECT_TELEPORT", 0, 1, 0) +tele:playFrame() +eq(tele.lcdc, "SCX", "Teleport warps horizontally") +eq(tele.lyStart, 0x2f, "over the user's own rows") +local before = tele.lyBackup[0x40] +tele:playFrame() +eq(tele.lyBackup[0x3f], before, "and the wave moves up one row a frame") +tele:incEffect("BATTLE_BG_EFFECT_TELEPORT") +tele:playFrame() +eq(tele.lcdc, nil, "the third state puts the registers back") +eq(tele:activeCount(), 0, "and frees the struct") + +-- Psychic covers the whole screen whichever side used it, and only travels +-- every fourth frame. +local psy = BgEffects.new(consts, { battleTurn = 0 }) +psy:queue("BATTLE_BG_EFFECT_PSYCHIC", 0, 1, 0) +psy:playFrame() +eq(psy.lyStart, 0, "Psychic is hardcoded to the whole screen") +eq(psy.lyEnd, 0x5f, "...all $5f scanlines of it") +-- `ld a, [hl] / inc [hl] / and $3 / ret nz`: the PRE-increment counter, so the +-- first frame in state 1 does travel and the next three do not. Compare the +-- whole window rather than one row: at amplitude 6 plenty of neighbouring +-- rows happen to hold the same byte. +local function snapshot(pool) + local out = {} + for row = pool.lyStart, pool.lyEnd - 1 do out[row] = pool.lyBackup[row] end + return out +end +local function rotatedBy(before, pool, steps) + for row = pool.lyStart, pool.lyEnd - 1 - steps do + if pool.lyBackup[row] ~= before[row + steps] then return false end + end + return true +end +local laid = snapshot(psy) +psy:playFrame() +check(rotatedBy(laid, psy, 1), "the first frame of state 1 travels one row") +local held = snapshot(psy) +for _ = 1, 3 do psy:playFrame() end +check(rotatedBy(held, psy, 0), "then three frames pass with it standing still") +psy:playFrame() +check(rotatedBy(held, psy, 1), "and it moves again on the fourth") + +-- Whirlpool rolls VERTICALLY, over both battlers at once. +local whirl = BgEffects.new(consts, { battleTurn = 0 }) +whirl:queue("BATTLE_BG_EFFECT_WHIRLPOOL", 0, 0, 0) +whirl:playFrame() +eq(whirl.lcdc, "SCY", "Whirlpool scrolls vertically") +eq(whirl.lyEnd, 0x5e, "over the whole screen, not one pic box") + +-- Surf: the ring is laid down on the first frame, and `.one` refuses to paint +-- anything until some other effect has opened the LCD-STAT window (which is +-- what BATTLE_BG_EFFECT_START_WATER is for). +local surf = BgEffects.new(consts, { battleTurn = 0 }) +surf:queue("BATTLE_BG_EFFECT_SURF", 0, 0, 0) +surf:playFrame() +check(surf.surfWave ~= nil, "Surf builds its $40-byte wave ring") +eq(surf.lyBackup[0x20], 0, "but paints nothing without an LCDC pointer") +surf.lcdc = "SCY" +surf.lyStart, surf.lyEnd = 0, 0x36 +surf:playFrame() +-- At amplitude 2 most rows of the ring round to zero; what matters is that +-- the window is painted at all. +local painted = false +for row = 1, 0x5e do + if (surf.lyBackup[row] or 0) ~= 0 then painted = true end +end +check(painted, "once one is set the water is on screen") +eq(surf.lyBackup[0], 0, "and the row at lyStart stays flat") + +-- The three water effects as a set: START opens the window and ends itself, +-- WATER grows two scanlines a frame until its counter passes $20, END resets. +local water = BgEffects.new(consts, { battleTurn = 0 }) +water:queue("BATTLE_BG_EFFECT_START_WATER", 0, 0, 0) +water:playFrame() +eq(water.lcdc, "SCY", "START_WATER opens a vertical window") +eq(water:activeCount(), 0, "and frees its struct on the same frame") +water:queue("BATTLE_BG_EFFECT_WATER", 0x18, 0, 0) +water:playFrame() +eq(water.effects[1].turn, 2, "WATER's counter climbs two a frame") +for _ = 1, 20 do water:playFrame() end +eq(water:activeCount(), 0, "and it ends once the counter passes $20") + +-- Double Team's afterimage: alternate rows +n and -n. +local dt = BgEffects.new(consts, { battleTurn = 0 }) +dt:queue("BATTLE_BG_EFFECT_DOUBLE_TEAM", 0, 1, 0) +dt:playFrame() -- state 0: open the window +dt:playFrame() -- state 1: first split, at 0 +dt:playFrame() -- ...and again, at 1 +eq(dt.lyBackup[0x2f], 1, "one copy of the pic goes right") +eq(dt.lyBackup[0x30], 0xff, "and the row under it goes left by the same") +for _ = 1, 20 do dt:playFrame() end +eq(dt.effects[1].jt, 2, "the split stops at $10 and waits for the script") + +-- Acid Armor scrolls the window down a row a frame with a blank fed in on top. +local acid = BgEffects.new(consts, { battleTurn = 0 }) +acid:queue("BATTLE_BG_EFFECT_ACID_ARMOR", 0, 1, 4) +acid:playFrame() +local sank = acid.lyBackup[0x40] +acid:playFrame() +eq(acid.lyBackup[0x41], sank, "the wave sinks a scanline a frame") +eq(acid.lyBackup[0x2f], 0x90, "with a blank row fed in at the top") + +-- Wave Deform ramps its amplitude up to $20 and, in state 2, back to nothing. +local wd = BgEffects.new(consts, { battleTurn = 0 }) +wd:queue("BATTLE_BG_EFFECT_WAVE_DEFORM_MON", 0, 1, 0) +wd:playFrame() +for _ = 1, 40 do wd:playFrame() end +eq(wd.effects[1].param, 0x20, "the ramp stops at $20 rather than wrapping") +wd:incEffect("BATTLE_BG_EFFECT_WAVE_DEFORM_MON") +for _ = 1, 0x21 do wd:playFrame() end +eq(wd:activeCount(), 0, "and the way back down ends the effect") + +-- The two unused beta send-outs still run to completion rather than sitting +-- in the pool forever. +local beta1 = BgEffects.new(consts, { battleTurn = 0 }) +beta1:queue("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", 0, 1, 0) +beta1:playFrame() +eq(beta1.lcdc, "BGP", "the beta send-out writes a PALETTE per scanline") +beta1:incEffect("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1") +for _ = 1, 40 do beta1:playFrame() end +eq(beta1.effects[1].jt, 3, "the first blind pass hands on to the second") +for _ = 1, 40 do beta1:playFrame() end +eq(beta1.effects[1].jt, 4, "and that one ends on the script's own state") +beta1:incEffect("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1") +beta1:playFrame() +eq(beta1:activeCount(), 0, "state five frees the struct") + +local beta2 = BgEffects.new(consts, { battleTurn = 0 }) +beta2:queue("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", 0, 1, 0) +beta2:playFrame() +eq(beta2.effects[1].turn, 0x40, "MON2 counts down from $40") +for _ = 1, 0x42 do beta2:playFrame() end +eq(beta2:activeCount(), 0, "and unwinds to nothing on its own") + +-- A per-scanline BGP entry reads as a brightness: $00 is white, $ff black, +-- $e4 the identity. +local AnimView = require("src.ui.gen2.BattleAnimView") +eq(AnimView.palVeil(0xe4), 0, "$e4 is no veil at all") +eq(AnimView.palVeil(0xff), 1, "$ff is solid black") +eq(AnimView.palVeil(0x00), -1, "$00 is solid white") + +S.finish() diff --git a/tests/gen2_battle_end_test.lua b/tests/gen2_battle_end_test.lua new file mode 100644 index 00000000..f947a976 --- /dev/null +++ b/tests/gen2_battle_end_test.lua @@ -0,0 +1,309 @@ +-- What ends a round, a hit, and a battle in Gen 2. +-- +-- luajit tests/gen2_battle_end_test.lua +-- +-- ROM-free. Four rules the turn loop and ExitBattle carry, each one only +-- visible at the seam between two halves of a turn: +-- +-- * `.wild_force_flee` ends the ROUND wherever it lands: a wild mon blown +-- away by a faster player's ROAR does not get to answer. +-- * BattleCommand_ApplyDamage runs the Endure clamp unconditionally, so a +-- mon braced at exactly 1 HP takes nothing and holds. +-- * SpikesDamage checks both type slots against FLYING before it chips. +-- * CleanUpBattleRAM: the substatus area is battle RAM, and this port keeps +-- it on the party record the save file owns, so a battle may not leave a +-- single bit behind on the way out. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle end") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + ROAR = { id = "ROAR", name = "ROAR", power = 0, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" }, + ENDURE = { id = "ENDURE", name = "ENDURE", power = 0, type = "NORMAL", + accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" }, + SPIKES = { id = "SPIKES", name = "SPIKES", power = 0, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_SPIKES" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + RATTATA = { + id = "RATTATA", index = 19, name = "RATTATA", + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + -- The Flying half of SpikesDamage's two `cp FLYING` tests: PIDGEY carries it + -- in the second slot, MURKROW in the first. + 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 = {}, + }, + MURKROW = { + id = "MURKROW", index = 198, name = "MURKROW", + baseStats = { hp = 60, attack = 85, defense = 42, speed = 91, + specialAttack = 85, specialDefense = 42 }, + types = { "FLYING", "NORMAL" }, catchRate = 30, baseExp = 107, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local ITEMS = { + DIRE_HIT = { id = "DIRE_HIT", name = "DIRE HIT", pocket = "ITEM" }, + X_ACCURACY = { id = "X_ACCURACY", name = "X ACCURACY", pocket = "ITEM" }, + GUARD_SPEC = { id = "GUARD_SPEC", name = "GUARD SPEC.", pocket = "ITEM" }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function mon(species, level, moves) + local m = Mon.new(DATA, species, level, { dvs = perfect }) + m.moves = {} + for _, id in ipairs(moves or { "TACKLE" }) do + m.moves[#m.moves + 1] = { id = id, pp = MOVES[id].pp, + maxPp = MOVES[id].pp } + end + return m +end + +-- The smallest roll that neither crits nor misses, and that lets a +-- ProtectChance roll through. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +local function textsOf(events) + local out = {} + for _, event in ipairs(events) do + if event.text then out[#out + 1] = event.text end + end + return out +end + +local function saidSomethingLike(events, fragment) + for _, text in ipairs(textsOf(events)) do + if text:find(fragment, 1, true) then return true end + end + return false +end + +-- ---- a wild Roar ends the round in the player's half ---------------------- +do + local player = mon("CYNDAQUIL", 20, { "ROAR", "TACKLE" }) + local wild = mon("PIDGEY", 5) + local save = { party = { player }, player = { id = 1, badges = {} } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = detRandom }) + check(battle:effectiveSpeed(player) > battle:effectiveSpeed(wild), + "the player's mon is the faster of the two") + local hpBefore = player.hp + local events = battle:takeTurn({ kind = "move", move = "ROAR" }) + check(saidSomethingLike(events, "fled in fear!"), + "FledInFearText: the wild mon is blown away") + check(not saidSomethingLike(events, "used TACKLE!"), + "and the mon that left never takes its half of the turn") + eq(player.hp, hpBefore, "so nothing came back the other way") + eq(battle.over, true, "the battle is over") + eq(battle.outcome, "fled", "as the cart's DRAW") +end + +-- ---- Endure holds at exactly 1 HP ----------------------------------------- +do + local player = mon("CYNDAQUIL", 10, { "ENDURE", "TACKLE" }) + local wild = mon("RATTATA", 10) + local save = { party = { player }, player = { id = 1, badges = {} } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = detRandom }) + player.hp = 1 + battle:useMove(player, wild, "ENDURE") + eq(battle:volatile(player).endure, true, "the brace is up") + battle:takeEvents() + battle:useMove(wild, player, "TACKLE") + local events = battle:takeEvents() + eq(player.hp, 1, "FalseSwipe's clamp leaves the holder on its last point") + check(saidSomethingLike(events, "endured the hit!"), "with EnduredText") + + -- The same brace at 2 HP still ends on 1: the clamp is MonHP - 1 either way. + local other = mon("CYNDAQUIL", 10, { "ENDURE", "TACKLE" }) + local save2 = { party = { other }, player = { id = 1, badges = {} } } + local battle2 = Battle.new({ data = DATA, party = { other }, + wild = mon("RATTATA", 10), save = save2, random = detRandom }) + other.hp = 2 + battle2:useMove(other, battle2.enemy, "ENDURE") + battle2:useMove(battle2.enemy, other, "TACKLE") + eq(other.hp, 1, "and a 2 HP brace ends on 1") +end + +-- ---- Spikes: both type slots are checked against FLYING ------------------- +do + local lead = mon("CYNDAQUIL", 10) + local flyer = mon("PIDGEY", 10) + local firstSlot = mon("MURKROW", 10) + local grounded = mon("RATTATA", 10) + local party = { lead, flyer, firstSlot, grounded } + local save = { party = party, player = { id = 1, badges = {} } } + local battle = Battle.new({ data = DATA, party = party, + wild = mon("RATTATA", 10, { "SPIKES", "TACKLE" }), save = save, + random = detRandom }) + + -- The enemy lays them on the side that will be switching into them. + battle:useMove(battle.enemy, lead, "SPIKES") + eq(battle.spikes.player, true, "SCREENS_SPIKES is on the player's side") + battle:takeEvents() + + local flyerHp = flyer.hp + battle:switch(2) + local events = battle:takeEvents() + eq(flyer.hp, flyerHp, "a second-slot FLYING type takes nothing") + check(not saidSomethingLike(events, "hurt by SPIKES!"), + "and the line is not printed either") + + local crowHp = firstSlot.hp + battle:switch(3) + battle:takeEvents() + eq(firstSlot.hp, crowHp, "a first-slot FLYING type takes nothing either") + + local ratHp = grounded.hp + battle:switch(4) + local grounding = battle:takeEvents() + eq(grounded.hp, ratHp - math.max(1, math.floor(grounded.maxHp / 8)), + "and anything else takes GetEighthMaxHP") + check(saidSomethingLike(grounding, "hurt by SPIKES!"), + "with BattleText_UserHurtBySpikes") +end + +-- ---- CleanUpBattleRAM: nothing rides the party out of the battle ---------- +do + Input:init() + local player = mon("CYNDAQUIL", 20) + local party = { player } + local save = { party = party, player = { id = 1, name = "GOLD", badges = {} }, + inventory = { DIRE_HIT = 2, X_ACCURACY = 1, GUARD_SPEC = 1 } } + local pushed = {} + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + }, + } + local wild = mon("RATTATA", 3) + -- One byte under 50 percent + 1 is the confusion self-hit, and this leg is + -- confused on purpose: a high byte still hits and never crits, and it lets + -- the mon act. + local battle = Battle.new({ data = DATA, party = party, wild = wild, + save = save, + random = function(n) + if n == 256 then return 200 end + if (n or 1) <= 1 then return 0 end + return 1 + end }) + local screen = BattleState.new(game, { battle = battle, save = save }) + local outcome + screen.onDone = function(result) outcome = result end + + local function drain(cap) + for _ = 1, (cap or 3000) do + Input:step() + screen:update(1 / 60) + if screen.phase == "menu" or screen.phase == "done" then return true end + end + return false + end + check(drain(), "the intro drains to the battle menu") + + screen:useItem("DIRE_HIT") + check(drain(), "DIRE HIT resolves") + screen:useItem("X_ACCURACY") + check(drain(), "X ACCURACY resolves") + screen:useItem("GUARD_SPEC") + check(drain(), "GUARD SPEC resolves") + battle:volatile(player).confuseCount = 4 + eq(battle:volatile(player).focusEnergy, true, "the three bits are up") + + -- Win it: the enemy is one hit from fainting, and ExitBattle runs on the way + -- out of the queue. + wild.hp = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + check(drain(), "the win drains out") + eq(screen.phase, "done", "ExitBattle finished") + eq(outcome, "win", "with the battle won") + eq(player.volatile, nil, "and the party mon carries no substatus out") + + -- Which is the whole point: the next battle starts clean, and the item that + -- refuses a repeat use is usable again. + local next_ = Battle.new({ data = DATA, party = party, + wild = mon("RATTATA", 3), save = save, random = detRandom }) + eq(next_:volatile(player).focusEnergy, nil, "no DIRE HIT rung carried over") + eq(next_:volatile(player).xAccuracy, nil, "no accuracy bypass either") + eq(next_:volatile(player).mist, nil, "and no Mist") + eq(next_:volatile(player).confuseCount, nil, "nor a confusion count") + eq(next_:useBattleItem("DIRE_HIT"), true, + "so a DIRE HIT is accepted in the next battle") +end + +-- ---- and a battle built over a party that WAS dirty cleans it up ---------- +do + local player = mon("CYNDAQUIL", 10) + -- A save written before the cleanup existed, or a battle torn down without + -- its screen: the bits are on the party record when the battle opens. + player.volatile = { focusEnergy = true, confuseCount = 3, wrapCount = 2 } + local save = { party = { player }, player = { id = 1, badges = {} } } + local battle = Battle.new({ data = DATA, party = { player }, + wild = mon("RATTATA", 3), save = save, random = detRandom }) + eq(player.volatile, nil, + "NewBattleMonStatus opens the battle on an empty area") + eq(battle:volatile(player).confuseCount, nil, "nothing survived the send-out") +end + +S.finish() diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua new file mode 100644 index 00000000..bfe81afb --- /dev/null +++ b/tests/gen2_battle_items_test.lua @@ -0,0 +1,505 @@ +-- The PACK inside a Gen 2 battle: every family engine/items/item_effects.asm +-- can spend on a party mon, driven through the real BattleState:useItem. +-- +-- luajit tests/gen2_battle_items_test.lua +-- +-- ROM-free. The HP line already had its own flow (gen2_battle_ui_test pins +-- it); what is asserted here is the rest of UseItem_SelectMon's fan-out -- +-- StatusHealingEffect and its berries, ReviveEffect on a fainted BENCHED mon, +-- RestorePPEffect's move pick for the ETHER pair and its no-pick ELIXER pair, +-- BitterBerryEffect's substatus-only cure -- plus the battle-only arm the +-- field routine has no way to reach (HealStatus clearing SUBSTATUS_CONFUSED on +-- whoever is out). +-- +-- The tail is the fishing path: FishFunction writes BATTLETYPE_FISH beside the +-- hooked species, which is the single condition LureBallMultiplier reads, so +-- the rod's own encounter is walked end to end and a LURE BALL thrown in it. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle items") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +-- The rod leg puts a real TextBox up over a stub font, which has no glyphs to +-- measure; the warning per character is noise, not a finding. +require("src.core.Logger").warn = function() end + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") +local MoveDeleter = require("src.ui.gen2.MoveDeleter") +local PackMenu = require("src.ui.gen2.PackMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local World = require("src.world.gen2.World") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + SCRATCH = { id = "SCRATCH", name = "SCRATCH", power = 40, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + TOTODILE = { + id = "TOTODILE", index = 158, name = "TOTODILE", + baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48 }, + types = { "WATER", "WATER" }, catchRate = 45, baseExp = 66, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + 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", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + MAGIKARP = { + id = "MAGIKARP", index = 129, name = "MAGIKARP", + baseStats = { hp = 20, attack = 10, defense = 55, speed = 80, + specialAttack = 15, specialDefense = 20 }, + types = { "WATER", "WATER" }, catchRate = 45, baseExp = 20, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local function item(id, pocket, name) + return { id = id, pocket = pocket or "ITEM", name = name or id } +end + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = { + POTION = item("POTION"), + ANTIDOTE = item("ANTIDOTE"), + BURN_HEAL = item("BURN_HEAL"), + FULL_HEAL = item("FULL_HEAL"), + PSNCUREBERRY = item("PSNCUREBERRY"), + BITTER_BERRY = item("BITTER_BERRY"), + REVIVE = item("REVIVE"), + MAX_REVIVE = item("MAX_REVIVE"), + ETHER = item("ETHER"), + ELIXER = item("ELIXER"), + MAX_ELIXER = item("MAX_ELIXER"), + POKE_BALL = item("POKE_BALL", "BALL", "POKe BALL"), + LURE_BALL = item("LURE_BALL", "BALL", "LURE BALL"), + -- The two nibbles of data/items/attributes.asm that disagree, spelled the + -- way the extractor spells them. + RARE_CANDY = { id = "RARE_CANDY", pocket = "ITEM", name = "RARE CANDY", + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" }, + OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD", + fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" }, + }, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- The smallest roll that neither crits nor misses, so a spent turn resolves +-- the same way every run. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +local function newScreen(opts) + opts = opts or {} + Input:init() + local pushed = {} + local player = opts.player or Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + if not player.moves or #player.moves == 0 then + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + end + local party = opts.party or { player } + local wild = opts.wild or Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = wild.moves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = party, inventory = opts.inventory or {} } + local game = { + data = DATA, + save = save, + input = Input, + options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + }, + } + local battle = Battle.new({ data = DATA, party = party, wild = wild, + save = save, random = opts.random or detRandom }) + local screen = BattleState.new(game, { battle = battle, save = save }) + return screen, battle, player, save, pushed +end + +local function runToMenu(screen, cap) + for _ = 1, (cap or 3000) do + Input:step() + screen:update(1 / 60) + if screen.phase == "menu" then return true end + end + return false +end + +-- ---- StatusHealingEffect on a BENCHED mon --------------------------------- +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + bench.status = "poison" + bench.toxicCounter = 3 + local screen, battle, _, save, pushed = newScreen({ + player = lead, party = { lead, bench }, + inventory = { ANTIDOTE = 2, BURN_HEAL = 1, PSNCUREBERRY = 1 }, + }) + check(runToMenu(screen), "the intro drains to the battle menu") + + local turn0 = battle.turn + screen:useItem("ANTIDOTE") + eq(screen.phase, "submenu", "a status cure opens UseItem_SelectMon first") + local picker = pushed[#pushed] + eq(getmetatable(picker), PartyMenu, "and the pick is the party screen") + eq(picker.prompt, PartyMenu.PROMPTS.useItem, "under the cart's own question") + picker.onChoose(2, bench) + eq(bench.status, nil, "the ANTIDOTE cured the BENCHED mon") + eq(bench.toxicCounter, nil, "and the toxic ramp went with the status byte") + eq(save.inventory.ANTIDOTE, 1, "one ANTIDOTE left the bag") + eq(battle.turn, turn0 + 1, "a cure that lands spends the turn") + check(runToMenu(screen), "the enemy's answer drains out") + + -- UseStatusHealer's mask test: a clean mon, or the wrong class of item, + -- refuses without spending anything. + local turn1 = battle.turn + screen:useItem("ANTIDOTE") + pushed[#pushed].onChoose(2, bench) + eq(screen.message, "It won't have any\neffect.", + "a clean target answers the cart's refusal") + eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE still in the bag") + eq(battle.turn, turn1, "and the turn not spent") + check(runToMenu(screen), "back to the menu") + + bench.status = "poison" + screen:useItem("BURN_HEAL") + pushed[#pushed].onChoose(2, bench) + eq(screen.message, "It won't have any\neffect.", + "a BURN HEAL does not answer poison") + eq(bench.status, "poison", "the status is untouched") + eq(save.inventory.BURN_HEAL, 1, "and nothing spent") + check(runToMenu(screen), "back to the menu") + + -- The berries carry the same StatusHealingActions rows as the shop cures. + screen:useItem("PSNCUREBERRY") + pushed[#pushed].onChoose(2, bench) + eq(bench.status, nil, "PSNCUREBERRY cures poison like an ANTIDOTE") + eq(save.inventory.PSNCUREBERRY, nil, "and the berry is eaten") + check(runToMenu(screen), "back to the menu") + + -- Backing out of the picker returns to the pack with nothing spent. + screen:useItem("ANTIDOTE") + pushed[#pushed].onCancel() + eq(getmetatable(pushed[#pushed]), PackMenu, + "cancelling the picker reopens the PACK (.SelectMon's carry path)") + eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched") +end + +-- ---- IsItemUsedOnConfusedMon: the battle-only arm -------------------------- +do + local screen, battle, player, save, pushed = newScreen({ + inventory = { FULL_HEAL = 2, ANTIDOTE = 1 }, + }) + check(runToMenu(screen), "reached the menu") + + -- A mon whose only complaint is the confusion volatile: the field routine + -- refuses (the status byte is clean), the battle one spends the item. + battle:volatile(player).confuseCount = 3 + local turn0 = battle.turn + screen:useItem("FULL_HEAL") + pushed[#pushed].onChoose(1, player) + eq(battle:volatile(player).confuseCount, nil, + "a $ff-mask item clears SUBSTATUS_CONFUSED on whoever is out") + eq(save.inventory.FULL_HEAL, 1, "and it costs the FULL HEAL") + eq(battle.turn, turn0 + 1, "and the turn") + check(runToMenu(screen), "the turn drains") + + -- The mask is what gates it: an ANTIDOTE is 1 << PSN, so it answers poison + -- and leaves the confusion standing. + player.status = "poison" + battle:volatile(player).confuseCount = 3 + screen:useItem("ANTIDOTE") + pushed[#pushed].onChoose(1, player) + eq(player.status, nil, "the ANTIDOTE still cures the poison") + eq(battle:volatile(player).confuseCount, 3, + "but a masked cure leaves the confusion alone") + check(runToMenu(screen), "back to the menu") + + -- BitterBerryEffect never opens the party list: it acts on the mon that is + -- out, and one that is not confused refuses. + save.inventory.BITTER_BERRY = 2 + local depth = #pushed + screen:useItem("BITTER_BERRY") + eq(#pushed, depth, "a BITTER BERRY opens no picker") + eq(battle:volatile(player).confuseCount, nil, "it cured the confusion") + eq(save.inventory.BITTER_BERRY, 1, "and was eaten doing it") + check(runToMenu(screen), "the turn drains") + + screen:useItem("BITTER_BERRY") + eq(screen.message, "It won't have any\neffect.", + "a clear-headed mon refuses the berry") + eq(save.inventory.BITTER_BERRY, 1, "with the berry still in the bag") +end + +-- ---- ReviveEffect on a fainted BENCHED mon -------------------------------- +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + bench.hp = 0 + bench.status = "faint" + local screen, battle, _, save, pushed = newScreen({ + player = lead, party = { lead, bench }, + inventory = { REVIVE = 2, MAX_REVIVE = 1 }, + }) + check(runToMenu(screen), "reached the menu") + + -- A healthy target is RevivePokemon's `ret nz`: nothing spent. + screen:useItem("REVIVE") + pushed[#pushed].onChoose(1, lead) + eq(screen.message, "It won't have any\neffect.", + "a live mon refuses the REVIVE") + eq(save.inventory.REVIVE, 2, "with both REVIVEs still in the bag") + check(runToMenu(screen), "back to the menu") + + local maxHp = bench.maxHp or bench.stats.hp + local turn0 = battle.turn + screen:useItem("REVIVE") + pushed[#pushed].onChoose(2, bench) + eq(bench.hp, math.max(1, math.floor(maxHp / 2)), + "REVIVE stands the BENCHED mon up at half max HP (ReviveHalfHP)") + eq(bench.status, nil, "with its status byte cleared") + eq(save.inventory.REVIVE, 1, "one REVIVE spent") + eq(battle.turn, turn0 + 1, "and the turn spent with it") + check(runToMenu(screen), "the turn drains") + + bench.hp = 0 + screen:useItem("MAX_REVIVE") + pushed[#pushed].onChoose(2, bench) + eq(bench.hp, maxHp, "MAX REVIVE takes ReviveFullHP instead") + eq(save.inventory.MAX_REVIVE, nil, "and is spent") + check(runToMenu(screen), "the turn drains") + + -- The revived mon is a mon again as far as the engine is concerned: it can + -- be switched to, which is the whole point of reviving mid-battle. + check(battle:switch(2), "the revived mon can be sent out") + eq(battle.player, bench, "and it is the one in play") +end + +-- ---- RestorePPEffect: the ETHER pick and the ELIXER sweep ------------------ +do + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "SCRATCH", pp = 4, maxPp = 35 }, + } + local screen, battle, _, save, pushed = newScreen({ + player = player, + inventory = { ETHER = 2, ELIXER = 1, MAX_ELIXER = 1 }, + }) + check(runToMenu(screen), "reached the menu") + + screen:useItem("ETHER") + eq(getmetatable(pushed[#pushed]), PartyMenu, "the ETHER picks a mon first") + pushed[#pushed].onChoose(1, player) + local moveList = pushed[#pushed] + eq(getmetatable(moveList), MoveDeleter, + "then the move list (MoveSelectionScreen shares SetUpMoveList with it)") + eq(#pushed, 2, "stacked over the party list, which is still standing") + + -- Backing out of the move list drops only that screen, which is + -- RestorePPEffect's own `jr nz, .loop` back to the pick. + moveList.onCancel() + eq(#pushed, 1, "cancelling the move list leaves the party list up") + eq(getmetatable(pushed[#pushed]), PartyMenu, "on the party list") + eq(save.inventory.ETHER, 2, "with nothing spent") + + pushed[#pushed].onChoose(1, player) + local turn0 = battle.turn + pushed[#pushed].onChoose(2) + eq(player.moves[2].pp, 14, "the ETHER put 10 PP back into the chosen slot") + eq(player.moves[1].pp, 35, "and left the other slot alone") + eq(save.inventory.ETHER, 1, "one ETHER spent") + eq(battle.turn, turn0 + 1, "and the turn with it") + check(runToMenu(screen), "the turn drains") + + -- A slot already at max is `cp b / jr nc, .dont_restore`: nothing spent. + screen:useItem("ETHER") + pushed[#pushed].onChoose(1, player) + pushed[#pushed].onChoose(1) + eq(screen.message, "It won't have any\neffect.", + "a full slot answers the refusal") + eq(save.inventory.ETHER, 1, "with the ETHER still in the bag") + check(runToMenu(screen), "back to the menu") + + -- Elixer_RestorePPofAllMoves needs no pick at all. + player.moves[1].pp = 30 + player.moves[2].pp = 14 + local depth = #pushed + screen:useItem("ELIXER") + pushed[#pushed].onChoose(1, player) + eq(#pushed, depth, "the ELIXER opened no move list") + eq(player.moves[1].pp, 35, "it filled the first slot (capped at max)") + eq(player.moves[2].pp, 24, "and walked on to the second") + eq(save.inventory.ELIXER, nil, "spending the ELIXER") + check(runToMenu(screen), "the turn drains") + + screen:useItem("MAX_ELIXER") + pushed[#pushed].onChoose(1, player) + eq(player.moves[2].pp, 35, "MAX ELIXER takes the .restore_all arm") + eq(save.inventory.MAX_ELIXER, nil, "and is spent") +end + +-- ---- an EGG refuses every family before anything runs --------------------- +do + local screen, _, _, save, pushed = newScreen({ + inventory = { ANTIDOTE = 1, REVIVE = 1, ETHER = 1 }, + }) + check(runToMenu(screen), "reached the menu") + for _, id in ipairs({ "ANTIDOTE", "REVIVE", "ETHER" }) do + screen:useItem(id) + pushed[#pushed].onChoose(3, { isEgg = true, hp = 0, maxHp = 12, + moves = { { id = "TACKLE", pp = 0, maxPp = 35 } } }) + eq(screen.message, "That can't be used\non an EGG.", + id .. " answers CantUseOnEggMessage") + eq(save.inventory[id], 1, "and is not spent") + check(runToMenu(screen), "back to the menu") + end +end + +-- ---- ITEMMENU_NOUSE in a battle is .Oak ----------------------------------- +do + local screen, battle, player, save, pushed = newScreen({ + inventory = { RARE_CANDY = 1, OLD_ROD = 1 }, + }) + check(runToMenu(screen), "reached the menu") + local level, turn0, depth = player.level, battle.turn, #pushed + screen:useItem("RARE_CANDY") + eq(#pushed, depth, "a RARE CANDY opens no party list in a battle") + eq(player.level, level, "and levels nothing") + eq(save.inventory.RARE_CANDY, 1, "with the candy still in the bag") + eq(battle.turn, turn0, "and the turn not spent") + + screen:useItem("OLD_ROD") + eq(save.inventory.OLD_ROD, 1, "a rod is ITEMMENU_NOUSE in battle too") +end + +-- ---- the rod's own battle carries BATTLETYPE_FISH -------------------------- +do + local pushed = {} + local save = { player = {}, party = {}, inventory = { LURE_BALL = 1, + POKE_BALL = 1 } } + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + save.party[1] = player + -- The rod's line rides a real TextBox, so the world needs an input its + -- update can read; `press` is the A that turns the page. + local fakeInput = { pressed = {} } + function fakeInput:press(button) self.pressed[button] = true end + function fakeInput:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function fakeInput:isDown() return false end + local game = { + data = DATA, save = save, input = fakeInput, options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + }, + } + local world = World.new(game) + -- Script_GotABite's tail, with the state the rod itself left behind: the + -- hooked mon, the bite bob already served, the rod being put away. No map + -- is mounted, so DoBattleTransition has nothing to wipe and the battle comes + -- straight in, which is the same arm a headless run takes. + local hooked = Mon.new(DATA, "MAGIKARP", 10, { dvs = perfect }) + hooked.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + world.fishing = { phase = "bite", timer = 0, outcome = "battle", + wild = hooked } + world:updateFishing() + -- RodBiteText is up; A pages it, and its dismissal is what runs startbattle. + local screen + for _ = 1, 600 do + local top = pushed[#pushed] + if top and getmetatable(top) == BattleState then screen = top break end + fakeInput:press("a") + if top and top.update then top:update(1 / 60) end + end + eq(world.fishing, nil, "PutTheRodAway clears the rod state before the battle") + check(screen ~= nil, "the rod's encounter pushed a battle") + eq(screen.battle.battleType, "fish", + "carrying BATTLETYPE_FISH out of FishFunction's .goodtofish") + + -- LureBallMultiplier's x3, through the real throw. A full-HP MAGIKARP at + -- catch rate 45 computes to 15; the LURE BALL's tripled rate is 45, so a + -- fixed roll of 30 catches with one ball and not with the other. + local function throw(ball, type_) + Input:init() + local wild = Mon.new(DATA, "MAGIKARP", 10, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bagSave = { party = { lead }, inventory = { [ball] = 1 } } + local shot = { + data = DATA, save = bagSave, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + local battle = Battle.new({ data = DATA, party = { lead }, wild = wild, + save = bagSave, battleType = type_, + random = function(n) if n == 256 then return 30 end return 0 end }) + local state = BattleState.new(shot, { battle = battle, save = bagSave }) + state:useItem(ball) + return battle.outcome + end + + eq(throw("LURE_BALL", screen.battle.battleType), "caught", + "a LURE BALL thrown in the rod's battle catches on that roll") + eq(throw("LURE_BALL", nil), nil, + "the same ball and the same roll off a rod does not") + eq(throw("POKE_BALL", screen.battle.battleType), nil, + "and neither does a plain ball in the rod's battle") +end + +S.finish() diff --git a/tests/gen2_battle_lockin_test.lua b/tests/gen2_battle_lockin_test.lua new file mode 100644 index 00000000..a492da4d --- /dev/null +++ b/tests/gen2_battle_lockin_test.lua @@ -0,0 +1,419 @@ +-- Gen 2 battle core: where the weather multiplier sits in the damage chain, +-- which failures suppress the attack animation, and the two move lock-ins +-- (Rollout and the EFFECT_RAMPAGE pair, Thrash and Petal Dance). +-- +-- luajit tests/gen2_battle_lockin_test.lua +-- +-- ROM-free. Every expectation names the pokegold routine it asserts: +-- engine/battle/misc.asm DoWeatherModifiers, engine/battle/effect_commands.asm +-- BattleCommand_Stab / _MoveAnimNoSub / _CheckRampage / _Rampage / _StatDown / +-- _Poison, engine/battle/move_effects/rollout.asm and leech_seed.asm, and +-- engine/battle/core.asm CheckPlayerLockedIn. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle lock-in") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Damage = require("src.battle.gen2.Damage") +local Effects = require("src.battle.gen2.Effects") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GHOST = { id = "GHOST", index = 8, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, + POISON = { id = "POISON", index = 3, category = "physical" }, +} + +-- data/type_matchups.asm's own row: NORMAL does nothing at all to GHOST. +local MATCHUPS = { + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + ROLLOUT = { id = "ROLLOUT", name = "ROLLOUT", power = 30, type = "ROCK", + accuracy = 90, pp = 20, effect = "EFFECT_ROLLOUT" }, + THRASH = { id = "THRASH", name = "THRASH", power = 90, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_RAMPAGE" }, + LEECH_SEED = { id = "LEECH_SEED", name = "LEECH SEED", power = 0, + type = "GRASS", accuracy = 90, pp = 10, effect = "EFFECT_LEECH_SEED" }, + TOXIC = { id = "TOXIC", name = "TOXIC", power = 0, type = "POISON", + accuracy = 85, pp = 10, effect = "EFFECT_TOXIC" }, + POISONPOWDER = { id = "POISONPOWDER", name = "POISONPOWDER", power = 0, + type = "GRASS", accuracy = 75, pp = 35, effect = "EFFECT_POISON" }, + GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, + -- An *_HIT twin: the drop rides a hit that already animated, so a refused + -- drop must leave the move event unmarked (data/moves/effects.asm, + -- AttackDownHit puts `attackdown` after `moveanim`). + AURORA_BEAM = { id = "AURORA_BEAM", name = "AURORA BEAM", power = 65, + type = "WATER", accuracy = 100, pp = 20, + effect = "EFFECT_ATTACK_DOWN_HIT", effectChance = 100 }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local 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 = {}, + }, + GASTLY = { + id = "GASTLY", index = 92, name = "GASTLY", + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + TANGELA = { + id = "TANGELA", index = 114, name = "TANGELA", + baseStats = { hp = 65, attack = 55, defense = 115, speed = 60, + specialAttack = 100, specialDefense = 40 }, + types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 166, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, opts.playerSpecies or "MACHOP", + opts.playerLevel or 15, { dvs = perfect }) + player.moves = opts.playerMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, opts.wildSpecies or "MACHOP", + opts.wildLevel or 15, { dvs = perfect }) + wild.moves = opts.wildMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = opts.random }) + return battle, player, wild +end + +-- The screen animates off the move event and skips a marked one +-- (src/ui/gen2/BattleState.lua reads `event.missed`), so "did the attack +-- animation play" is exactly "is this flag nil". +local function moveEvent(events) + for _, event in ipairs(events or {}) do + if event.kind == "move" then return event end + end + return nil +end + +local function findText(events, text) + for _, event in ipairs(events or {}) do + if event.kind == "message" and event.text == text then return true end + end + return false +end + +-- ---- DoWeatherModifiers sits FIRST in BattleCommand_Stab ------------------- +-- +-- effect_commands.asm:1254 farcalls DoWeatherModifiers before DoBadgeTypeBoosts +-- (:1261), before the STAB x1.5 and before .TypesLoop (:1299), and long before +-- BattleCommand_DamageVariation. Every one of those steps floors, so the +-- multiply has to land where the cart puts it, not on the finished number. +do + -- data/battle/weather_modifiers.asm pairs rain with MORE_EFFECTIVE, and + -- MORE_EFFECTIVE is 15, not 20 (constants/battle_constants.asm:22). + eq(Effects.weatherModifier("rain", "WATER"), 1.5, + "rain is MORE_EFFECTIVE (15), a half again -- not the chart's x2") + eq(Effects.weatherModifier("sun", "WATER"), 0.5, + "sun is NOT_VERY_EFFECTIVE (05) against Water") + + -- Chosen so the two orders disagree. Base damage floors to 1, the tail + -- adds MIN_DAMAGE for 3, and the mon is Water so STAB applies: + -- cart: 3 -> weather 4 -> STAB 6 -> variation 85% -> 5 + -- port's old order: 3 -> STAB 4 -> variation 85% -> 3 -> weather -> 4 + local opts = { + level = 5, power = 20, moveType = "WATER", + attacker = { attack = 10, specialAttack = 10, types = { "WATER" }, + stages = {} }, + defender = { defense = 10, specialDefense = 10, types = {}, stages = {} }, + types = TYPES, matchups = {}, variation = 85, + } + opts.weatherPercent = nil + local clear = Damage.calc(opts) + opts.weatherPercent = 15 + local rain = Damage.calc(opts) + eq(clear, 3, "clear weather: the plain chain") + eq(rain, 5, "rain multiplies ahead of STAB and DamageVariation") + check(rain ~= 4, + "and not on the finished number, which would have floored to 4") + + -- The .ApplyModifier zero-quotient arm forces the result back to 1, so a + -- halved hit never falls to nothing (misc.asm:129-136). + opts.weatherPercent = 5 + opts.variation = 100 + local weak = Damage.calc(opts) + check(weak >= 1, "a weather-halved hit still deals at least 1") +end + +-- ---- immunity suppresses the animation ----------------------------------- +-- +-- BattleCommand_Stab's `.GotMatchup` writes wAttackMissed when the matchup +-- byte is 0 (effect_commands.asm:1337), `stab` runs ahead of `moveanim` in +-- every damaging effect list (data/moves/effects.asm:5), and +-- BattleCommand_MoveAnimNoSub early-outs on wAttackMissed (:1958), so an +-- immune move plays MoveDelay and nothing else. +do + local battle, player, wild = newBattle({ + wildSpecies = "GASTLY", random = rolls({}, 0) }) + battle:useMove(player, wild, "TACKLE") + local events = battle:takeEvents() + check(findText(events, "It doesn't affect GASTLY..."), "DoesntAffectText") + eq(moveEvent(events).missed, true, + "and wAttackMissed is set, so no attack animation plays") + eq(wild.hp, wild.maxHp, "an immune hit deals nothing") +end + +-- ---- the other AnimateFailedMove arms ------------------------------------- +do + -- leech_seed.asm `.grass`: AnimateFailedMove, then PrintDoesntAffect. + local battle, player, wild = newBattle({ + playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, + wildSpecies = "TANGELA", random = rolls({}, 0) }) + battle:useMove(player, wild, "LEECH_SEED") + local events = battle:takeEvents() + check(findText(events, "It doesn't affect TANGELA..."), "PrintDoesntAffect") + eq(moveEvent(events).missed, true, "a Grass target plays no animation") + eq(wild.volatile and wild.volatile.leechSeed, nil, "and nothing is seeded") +end + +do + -- leech_seed.asm `.evaded`: a repeat seeds nothing and animates nothing. + local battle, player, wild = newBattle({ + playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + battle:useMove(player, wild, "LEECH_SEED") + eq(moveEvent(battle:takeEvents()).missed, nil, + "the seeding arm reaches AnimateCurrentMove") + battle:useMove(player, wild, "LEECH_SEED") + local events = battle:takeEvents() + check(findText(events, "MACHOP evaded the attack!"), "EvadedText") + eq(moveEvent(events).missed, true, "and the repeat plays nothing") +end + +do + -- BattleCommand_StatDown's `.CantLower` sets wAttackMissed + -- (effect_commands.asm:4380-4390) and `statdownanim` reads it. + local battle, player, wild = newBattle({ + playerMoves = { { id = "GROWL", pp = 40, maxPp = 40 } }, + random = rolls({}, 0) }) + battle.stages.enemy.attack = -Effects.MAX_STAGE + battle:useMove(player, wild, "GROWL") + local events = battle:takeEvents() + check(findText(events, "MACHOP's ATTACK won't drop anymore!"), + "the refusal line") + eq(moveEvent(events).missed, true, "a capped drop plays no animation") +end + +do + -- The negative regression that matters: AttackDownHit's `attackdown` runs + -- AFTER `moveanim`, so a refused SECONDARY drop must leave the event alone. + -- Battle:markMissed marks the move event retroactively, and the screen only + -- reads the flag when the queue drains, so marking here would delete an + -- animation the cart played. + local battle, player, wild = newBattle({ + playerMoves = { { id = "AURORA_BEAM", pp = 20, maxPp = 20 } }, + random = rolls({}, 0) }) + battle.stages.enemy.attack = -Effects.MAX_STAGE + battle:useMove(player, wild, "AURORA_BEAM") + local events = battle:takeEvents() + eq(moveEvent(events).missed, nil, + "a refused secondary drop keeps the animation the hit already played") + check(wild.hp < wild.maxHp, "and the hit still landed") +end + +do + -- BattleCommand_Poison's already-statused arm loads its text and jumps to + -- `.failed`, which is AnimateFailedMove (effect_commands.asm:3748-3750). + -- AnimateCurrentMove only runs on `.apply_poison` (:3752). + local battle, player, wild = newBattle({ + playerMoves = { { id = "POISONPOWDER", pp = 35, maxPp = 35 }, + { id = "TOXIC", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + battle:useMove(player, wild, "POISONPOWDER") + eq(wild.status, "poison", "the first one lands") + eq(moveEvent(battle:takeEvents()).missed, nil, "and animates") + battle:useMove(player, wild, "TOXIC") + local events = battle:takeEvents() + eq(moveEvent(events).missed, true, + "a status move aimed at an already-statused target plays nothing") + eq(wild.status, "poison", "and changes nothing") +end + +-- ---- Rollout locks the user in -------------------------------------------- +-- +-- BattleCommand_RolloutPower sets SUBSTATUS_ROLLOUT while the counter is short +-- of MAX_ROLLOUT_COUNT (move_effects/rollout.asm), and CheckPlayerLockedIn +-- quits ParsePlayerAction while it is set (core.asm:546), so no menu is +-- offered, no PP is spent and no obedience check is made. +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 }, + { id = "TACKLE", pp = 35, maxPp = 35 } }, + wildLevel = 60, random = rolls({}, 0) }) + eq(battle:forcedMove(player), nil, "nothing forces the first Rollout") + for turn = 1, 4 do + battle:useMove(player, wild, "ROLLOUT") + battle:takeEvents() + eq(battle:forcedMove(player), "ROLLOUT", + "turn " .. turn .. " of five leaves SUBSTATUS_ROLLOUT set") + end + eq(player.moves[1].pp, 19, "only the first turn spent PP (checkrollout skips doturn)") + battle:useMove(player, wild, "ROLLOUT") + battle:takeEvents() + eq(battle:forcedMove(player), nil, + "MAX_ROLLOUT_COUNT reached: the fifth hit clears the bit") + eq(player.moves[1].pp, 19, "and still no extra PP was spent") +end + +do + -- A SECOND sequence, started after the first ran its five hits out. + -- BattleCommand_CheckRollout takes its `.reset` arm whenever + -- SUBSTATUS_ROLLOUT is clear as the move starts, and zeroes + -- wPlayerRolloutCount there (move_effects/rollout.asm), so a spent counter + -- never feeds the next sequence: the new ROLLOUT opens at base power, locks + -- the menu again, and pays its one PP through the doturn the `.reset` arm + -- falls through to. + -- + -- The port used to decide "continue the ramp?" by asking whether the LAST + -- move was also ROLLOUT, which has no cart equivalent: it held the counter at + -- the cap, so the second sequence opened at 16x power and left + -- CheckPlayerLockedIn (core.asm:546) with no bit to read. + local battle, player, wild = newBattle({ + playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } }, + wildLevel = 60, random = rolls({}, 0) }) + + -- Topped up each time so the target never faints out from under the ramp. + local function rolloutDamage() + wild.hp = wild.maxHp + battle:useMove(player, wild, "ROLLOUT") + battle:takeEvents() + return wild.maxHp - wild.hp + end + + local opening = rolloutDamage() + eq(battle:forcedMove(player), "ROLLOUT", "the opening hit locks") + local capped + for _ = 2, 5 do capped = rolloutDamage() end + eq(battle:forcedMove(player), nil, "the fifth hit clears SUBSTATUS_ROLLOUT") + eq(player.moves[1].pp, 19, "the whole first sequence cost one PP") + check(capped > opening, "the ramp really did double along the way") + + local restarted = rolloutDamage() + eq(restarted, opening, "the restarted ROLLOUT is back at base power") + eq(battle:forcedMove(player), "ROLLOUT", "and it locks the menu again") + eq(player.moves[1].pp, 18, "and pays PP, because .reset falls through to doturn") +end + +do + -- `.skip_set_rampage` reads wAttackMissed before the counter and clears + -- SUBSTATUS_ROLLOUT outright, so a miss releases the lock. + local battle, player, wild = newBattle({ + playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } }, + wildLevel = 60, random = rolls({}, 0) }) + battle:useMove(player, wild, "ROLLOUT") + battle:takeEvents() + eq(battle:forcedMove(player), "ROLLOUT", "the first hit locks") + -- 99 against ROLLOUT's 90 accuracy is a miss on every roll of the turn. + battle.random = rolls({}, 99) + battle:useMove(player, wild, "ROLLOUT") + check(findText(battle:takeEvents(), "MACHOP's attack missed!"), "a miss") + eq(battle:forcedMove(player), nil, "and the miss releases the lock") +end + +-- ---- Thrash / Petal Dance: EFFECT_RAMPAGE --------------------------------- +-- +-- BattleCommand_Rampage rolls 1 or 2 MORE turns (effect_commands.asm:4886), so +-- the move runs for two or three turns in all; BattleCommand_CheckRampage +-- (:4851) runs the counter down and, at zero, clears the bit and writes +-- SUBSTATUS_CONFUSED with its own `and %00000001` plus two count. +do + -- rolls: the rampage length (0 -> one MORE turn, two in all), then the + -- confusion count (0 -> `and 1` plus two, two turns). + local battle, player, wild = newBattle({ + playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 }, + { id = "TACKLE", pp = 35, maxPp = 35 } }, + wildLevel = 80, random = rolls({ 0 }, 0) }) + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), "THRASH", "the opening turn locks the user in") + eq(player.moves[1].pp, 19, "the opening turn spends PP") + eq(player.volatile.confuseCount, nil, "and nothing is confused yet") + + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), nil, "the count runs out on the second turn") + eq(player.moves[1].pp, 19, "a continuing rampage skips doturn, so no PP") + eq(player.volatile.confuseCount, 2, + "CheckRampage writes the confusion count itself, `and 1` plus two") + check(wild.hp < wild.maxHp, "and the last turn still attacked") +end + +do + -- The long roll: 2 more turns, so three in all. + local battle, player, wild = newBattle({ + playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } }, + wildLevel = 80, random = rolls({ 1 }, 0) }) + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), "THRASH", "turn one") + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), "THRASH", "turn two") + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), nil, "turn three ends it") + check(player.volatile.confuseCount ~= nil, "and leaves the user confused") +end + +do + -- Switching out is CleanUpBattleRAM: every substatus goes, lock included. + local battle, player, wild = newBattle({ + playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } }, + wildLevel = 80, random = rolls({ 1 }, 0) }) + battle:useMove(player, wild, "THRASH") + battle:takeEvents() + eq(battle:forcedMove(player), "THRASH", "locked") + battle:clearVolatile(player) + eq(battle:forcedMove(player), nil, "a send-out zeroes the substatus") +end + +S.finish() diff --git a/tests/gen2_battle_loss_test.lua b/tests/gen2_battle_loss_test.lua new file mode 100644 index 00000000..5371beef --- /dev/null +++ b/tests/gen2_battle_loss_test.lua @@ -0,0 +1,141 @@ +-- Losing a trainer battle must END the script. +-- +-- luajit tests/gen2_battle_loss_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua), which lost to +-- Whitney's MILTANK with its whole party fainted and was handed +-- EVENT_BEAT_WHITNEY anyway. Chased down, the same run had cleared four Elite +-- Four rooms and reached the Hall of Fame with two Pokemon. +-- +-- Every trainer script in the game has the same three lines: +-- +-- startbattle +-- reloadmapafterbattle +-- setevent EVENT_BEAT_<whoever> +-- +-- and on the cart the middle one does not come back after a loss. +-- Script_reloadmapafterbattle (engine/overworld/scripting.asm:1080) reads +-- wBattleResult, and on LOSE does `ScriptJump Script_BattleWhiteout` -- which +-- replaces the running script rather than returning to it, so the `setevent` +-- below it never happens. The port fell straight through, so a loss ran the +-- win branch of every leader, rival and Elite Four script in Johto and Kanto. +-- +-- The whiteout half (heal, halve the money, warp to the spawn point) is +-- World's, and was already right; what is asserted here is only that the +-- script stops. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle loss") +local check, eq = S.check, S.eq + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +-- A gym leader's script, in the shape maps/GoldenrodGym.asm writes it. +local function gymScripts() + return { + generation = 2, + ["s:leader"] = { + { op = "loadtrainer", class = 1, member = 1 }, + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "setevent", event = 100 }, + { op = "scall", script = "s:givebadge" }, + { op = "end" }, + }, + -- The badge itself lives behind a scall, which is why a loss that falls + -- through leaves the flags and the badges disagreeing. + ["s:givebadge"] = { + { op = "setevent", event = 101 }, + { op = "end" }, + }, + } +end + +local function run(outcome) + local events = Events.new() + local reloads = 0 + local vm = Vm.new(gymScripts(), {}, events, { + startBattle = function(_, _wild, onDone) onDone(outcome) end, + reloadMap = function() reloads = reloads + 1 end, + }) + check(vm:start("s:leader"), "leader script starts (" .. outcome .. ")") + for _ = 1, 20 do vm:update() end + return events, reloads, vm +end + +-- Winning runs the whole script, exactly as before. +do + local events, reloads = run("win") + eq(events:get(100), true, "a win sets the leader's beaten flag") + eq(events:get(101), true, "a win reaches the badge scall below it") + eq(reloads, 1, "a win still reloads the map") +end + +-- Losing stops at reloadmapafterbattle. +do + local events, reloads = run("lose") + eq(events:get(100), false, "a loss does NOT set the leader's beaten flag") + eq(events:get(101), false, "a loss does not reach the badge scall either") + eq(reloads, 1, "a loss still reloads the map (Script_BattleWhiteout does)") +end + +-- The abort is per-run: the next script must start clean, or one wipe would +-- silently disable every script for the rest of the session. +do + local events = Events.new() + local vm = Vm.new(gymScripts(), {}, events, { + startBattle = function(_, _wild, onDone) onDone("lose") end, + reloadMap = function() end, + }) + vm:start("s:leader") + for _ = 1, 20 do vm:update() end + eq(events:get(100), false, "the lost battle aborted its script") + + local second = { + generation = 2, + ["s:after"] = { { op = "setevent", event = 200 }, { op = "end" } }, + } + local vm2 = Vm.new(second, {}, events, {}) + vm2:start("s:after") + for _ = 1, 5 do vm2:update() end + eq(events:get(200), true, "a later script is unaffected by the earlier loss") + + -- Same VM, second run: the flag has to be cleared by Vm:start, not by luck. + local vm3 = Vm.new({ + generation = 2, + ["s:leader"] = gymScripts()["s:leader"], + ["s:givebadge"] = gymScripts()["s:givebadge"], + }, {}, events, { + startBattle = function(_, _wild, onDone) onDone("win") end, + reloadMap = function() end, + }) + vm3:start("s:leader") + for _ = 1, 20 do vm3:update() end + eq(events:get(100), true, "the rematch, won, sets the flag") +end + + + +-- --------------------------------------------------------------------------- +-- An EGG is not a battler. +-- +-- Same session, same bot: carrying the Togepi egg meant Battle.firstHealthy +-- answered with the egg, so the wipe check never fired while the egg was +-- intact and the game sent an egg out against Morty's Gengar -- twice, dying +-- instantly each time, before the only mon that could fight got a turn. With +-- the switch menu correctly refusing it, an all-fainted party plus an egg then +-- had no answer at all and the battle hung. The cart refuses eggs on both +-- sides of that: they cannot be sent out, and they do not stop a whiteout. +local Battle = require("src.battle.gen2.Battle") + +eq(Battle.firstHealthy({ { hp = 0 }, { hp = 12 } }), 2, + "the first mon with HP is chosen") +eq(Battle.firstHealthy({ { hp = 0 }, { hp = 12, isEgg = true } }), nil, + "an egg is not a replacement, so this party is wiped") +eq(Battle.firstHealthy({ { hp = 20, isEgg = true }, { hp = 12 } }), 2, + "an egg in front is skipped rather than sent out") +eq(Battle.firstHealthy({ { hp = 20, isEgg = true } }), nil, + "a party of nothing but an egg cannot fight") + +S.finish() diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua new file mode 100644 index 00000000..95608d0f --- /dev/null +++ b/tests/gen2_battle_pack_test.lua @@ -0,0 +1,449 @@ +-- BattlePack (engine/items/pack.asm:627): the PACK opened from the battle menu +-- is a different jumptable from the field PACK's. +-- +-- luajit tests/gen2_battle_pack_test.lua +-- +-- ROM-free. Three things are pinned here, all through the real screens: +-- +-- * .Use dispatches on the item's BATTLE menu nibble, and its first four +-- jumptable entries are .Oak -- so a key item picked mid-fight prints +-- OakThisIsntTheTimeText inside the pack. Nothing on this path may reach +-- the field jumptable: the ITEMFINDER's field arm queues a script and +-- quits the PACK, which over a battle would take the battle off the stack +-- with it and leave the world's battleActive stuck on. +-- * the status names the battle writes and the names the pack's cures read +-- are one contract, walked table against table so a cure can never go +-- missing again (a paralysis the battle spells "paralyze" was uncurable by +-- every bag item there is). +-- * a bag row whose id has no ItemAttributes behind it still draws. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle pack") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +-- The pack draws through a stub font with no glyphs; the warning per character +-- is noise, not a finding. +require("src.core.Logger").warn = function() end + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local ItemEffects = require("src.core.gen2.ItemEffects") +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local World = require("src.world.gen2.World") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" } } + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + -- The move id and the printed name differ, which is the whole reason the TM + -- row resolves one to the other. + ROCK_SMASH = { id = "ROCK_SMASH", name = "ROCK SMASH", power = 20, + type = "NORMAL", accuracy = 100, pp = 15, + effect = "EFFECT_DEFENSE_DOWN_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + 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 = {}, + }, +} + +-- The attribute pairs as data/items/attributes.asm carries them: the two +-- nibbles disagree for exactly the items this suite is about. +local ITEMS = { + POTION = { id = "POTION", index = 17, name = "POTION", pocket = "ITEM", + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" }, + PARLYZ_HEAL = { id = "PARLYZ_HEAL", index = 20, name = "PARLYZ HEAL", + pocket = "ITEM", fieldMenu = "ITEMMENU_PARTY", + battleMenu = "ITEMMENU_PARTY" }, + FULL_HEAL = { id = "FULL_HEAL", index = 21, name = "FULL HEAL", + pocket = "ITEM", fieldMenu = "ITEMMENU_PARTY", + battleMenu = "ITEMMENU_PARTY" }, + PRZCUREBERRY = { id = "PRZCUREBERRY", index = 22, name = "PRZCUREBERRY", + pocket = "ITEM", fieldMenu = "ITEMMENU_PARTY", + battleMenu = "ITEMMENU_PARTY" }, + MIRACLEBERRY = { id = "MIRACLEBERRY", index = 23, name = "MIRACLEBERRY", + pocket = "ITEM", fieldMenu = "ITEMMENU_PARTY", + battleMenu = "ITEMMENU_PARTY" }, + POKE_BALL = { id = "POKE_BALL", index = 5, name = "POKe BALL", + pocket = "BALL", fieldMenu = "ITEMMENU_NOUSE", + battleMenu = "ITEMMENU_CLOSE" }, + ITEMFINDER = { id = "ITEMFINDER", index = 55, name = "ITEMFINDER", + pocket = "KEY_ITEM", fieldMenu = "ITEMMENU_CLOSE", + battleMenu = "ITEMMENU_NOUSE" }, + -- The trophy boxes live in the ITEMS pocket on the cart, not with the key + -- items, and are NOUSE in battle like every other decoration. + NORMAL_BOX = { id = "NORMAL_BOX", index = 167, name = "NORMAL BOX", + pocket = "ITEM", fieldMenu = "ITEMMENU_CURRENT", + battleMenu = "ITEMMENU_NOUSE" }, + TM_ROCK_SMASH = { id = "TM_ROCK_SMASH", index = 198, name = "TM08", + pocket = "TM_HM", teaches = "ROCK_SMASH", tmNumber = 8, + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- The smallest roll that neither crits nor misses. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +-- A battle over a LIVE overworld, which is the shape the bug needed: World +-- keeps battleActive set for as long as the BattleState is on the stack, and +-- game.world is what PackMenu falls back to when a caller passes no world. +local function newBattleOverWorld(inventory) + Input:init() + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = { player }, inventory = inventory or {}, + player = { name = "GOLD" } } + local pushed = {} + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + clear = function(self) while #pushed > 0 do self:pop() end end, + }, + } + local world = World.new(game) + world.player = { cellX = 5, cellY = 5, facing = "down" } + world.map = { id = "ROUTE_29", def = {} } + world.battleActive = true + game.world = world + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = detRandom }) + local screen = BattleState.new(game, { battle = battle, save = save }) + game.stack:push(screen) + return screen, battle, world, save, pushed, player +end + +local function runToMenu(screen, cap) + for _ = 1, (cap or 3000) do + Input:step() + screen:update(1 / 60) + if screen.phase == "menu" then return true end + end + return false +end + +-- The real menu press: the 2x2 grid's DOWN puts the cursor on PACK. +local function openPackFromMenu(screen, pushed) + Input:overlayPressed("down") + Input:step() + screen:update(1 / 60) + Input:overlayReleased("down") + Input:step() + eq(BattleState.MENU[screen.menuIndex], "PACK", "the cursor is on PACK") + Input:overlayPressed("a") + Input:step() + screen:update(1 / 60) + Input:overlayReleased("a") + Input:step() + return pushed[#pushed] +end + +local function pressPack(pack, button) + Input:overlayPressed(button) + Input:step() + pack:update(1 / 60) + Input:overlayReleased(button) + Input:step() +end + +-- ---- the battle PACK never runs a field effect ---------------------------- +do + local screen, _, world, save, pushed = newBattleOverWorld({ + POTION = 2, ITEMFINDER = 1, NORMAL_BOX = 1 }) + check(runToMenu(screen), "the intro drains to the battle menu") + local pack = openPackFromMenu(screen, pushed) + eq(getmetatable(pack), PackMenu, "PACK opens the pack") + eq(pack:inBattle(), true, "as BattlePack rather than the field Pack") + eq(#pushed, 2, "over the battle, which is still on the stack") + + -- KEY ITEMS, where the arm that ate the battle lived. + pressPack(pack, "right") + pressPack(pack, "right") + eq(pack:pocket().id, "KEY_ITEM", "right twice reaches the KEY ITEMS pocket") + + pack.index = 1 + eq(pack.rows[1].id, "ITEMFINDER", "the ITEMFINDER is the first row") + pressPack(pack, "a") + eq(pack.message and pack.message[1], "OAK: {PLAYER}!", + "A on it prints OakThisIsntTheTimeText") + eq(#pushed, 2, "the pack is still up") + eq(pushed[1], screen, "and the battle is still under it") + eq(world.battleActive, true, "the world is still in its battle") + eq(world.queuedScript, nil, "no field script was queued") + eq(save.inventory.ITEMFINDER, 1, "and the key item is still in the bag") + + -- The trophy boxes are the same nibble: no decoration flag, no box spent. + pack.message = nil + pressPack(pack, "left") + pressPack(pack, "left") + eq(pack:pocket().id, "ITEM", "back in the ITEMS pocket") + pack.index = 2 + eq(pack.rows[2].id, "NORMAL_BOX", "with the trophy box in it") + pressPack(pack, "a") + eq(pack.message and pack.message[1], "OAK: {PLAYER}!", + "a trophy box answers .Oak in battle too") + eq(save.inventory.NORMAL_BOX, 1, "the box is not spent") + eq(next(world.events.flags or {}), nil, "and no decoration flag was granted") + + -- ITEMMENU_PARTY still reaches the battle's own item flow. + pack.message = nil + pack.index = 1 + eq(pack.rows[1].id, "POTION", "on the POTION") + pressPack(pack, "a") + eq(getmetatable(pushed[#pushed]), PartyMenu, + "a battle-legal item opens UseItem_SelectMon instead") +end + +-- ---- and the world refuses both arms on its own --------------------------- +do + local _, _, world, save = newBattleOverWorld({ ITEMFINDER = 1, + NORMAL_BOX = 1 }) + eq(world:useItemfinder(), "nowhere", + "World:useItemfinder refuses while a battle is up") + eq(world.queuedScript, nil, "queueing nothing") + eq(world:openTrophyBox("NORMAL_BOX"), "nowhere", + "World:openTrophyBox refuses too") + eq(save.inventory.NORMAL_BOX, 1, "with the box still in the bag") + + -- The same two answer normally once the battle is over, so the gate is the + -- battle and not the item. + world.battleActive = nil + eq(world:useItemfinder(), "itemfinder", + "and in the field the ITEMFINDER still quits the PACK") + check(world.queuedScript ~= nil, "with its script queued") + eq(world:openTrophyBox("NORMAL_BOX"), "trophy_sent", + "and the trophy box still sends its trophy home") + eq(save.inventory.NORMAL_BOX, nil, "spending the box") +end + +-- ---- status names: one contract between the battle and the pack ----------- +do + -- Every name Battle can write into mon.status has to resolve to a heal + -- class, or the cure for it refuses everywhere. + local written = {} + for _, status in pairs(Battle.STATUS_EFFECTS) do written[status] = true end + for _, status in pairs(Battle.SECONDARY_EFFECTS) do written[status] = true end + for _, status in pairs(Battle.HELD_STATUS_CURES) do written[status] = true end + for status in pairs(Battle.STATUS_TEXT) do written[status] = true end + -- Confusion is SUBSTATUS_CONFUSED, not a status byte: BitterBerryEffect and + -- the $ff-mask arm of HealStatus own it, and neither reads STATUS_CLASS. + written.confuse = nil + + local missing = {} + for status in pairs(written) do + if not ItemEffects.STATUS_CLASS[status] then + missing[#missing + 1] = status + end + end + table.sort(missing) + eq(#missing, 0, "every status the battle writes has a heal class (" + .. table.concat(missing, ", ") .. ")") + eq(ItemEffects.STATUS_CLASS.paralyze, "par", + "including the paralysis spelling the engine actually writes") + + -- And back the other way: every class a spelling folds to must be a class + -- some StatusHealingActions row asks for. + local cured = { all = true } + for _, class in pairs(ItemEffects.HEAL_STATUS) do cured[class] = true end + local orphan = {} + for spelling, class in pairs(ItemEffects.STATUS_CLASS) do + if not cured[class] then orphan[#orphan + 1] = spelling end + end + eq(#orphan, 0, "no spelling folds to a class no item cures (" + .. table.concat(orphan, ", ") .. ")") +end + +-- ---- the paralysis cure, through both packs ------------------------------- +do + -- The battle is what writes the byte: EFFECT_PARALYZE lands "paralyze". + local screen, battle, _, save, pushed, player = newBattleOverWorld({ + PARLYZ_HEAL = 1, FULL_HEAL = 1, PRZCUREBERRY = 1, MIRACLEBERRY = 1 }) + check(runToMenu(screen), "reached the battle menu") + battle:applyStatus(player, Battle.STATUS_EFFECTS.EFFECT_PARALYZE) + eq(player.status, "paralyze", "BattleCommand_Paralyze writes 'paralyze'") + + local turn0 = battle.turn + screen:useItem("PARLYZ_HEAL") + pushed[#pushed].onChoose(1, player) + eq(player.status, nil, "a PARLYZ HEAL cures it in battle") + eq(save.inventory.PARLYZ_HEAL, nil, "the item is spent") + eq(battle.turn, turn0 + 1, "and the turn with it") + check(runToMenu(screen), "the enemy's answer drains out") + + -- The three other rows that carry the same class, on the field routine the + -- overworld PACK runs (ItemEffects.useOnMon is the whole of it). + for _, itemId in ipairs({ "FULL_HEAL", "PRZCUREBERRY", "MIRACLEBERRY" }) do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.status = "paralyze" + local result = ItemEffects.useOnMon(itemId, mon, DATA) + eq(result.used, true, itemId .. " cures paralysis in the field") + eq(mon.status, nil, "and clears the byte") + end + + -- The refusal is still there for a mon that has nothing to cure. + local clean = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + local refused = ItemEffects.useOnMon("PARLYZ_HEAL", clean, DATA) + eq(refused.used, false, "a clean mon still refuses the cure") +end + +-- ---- a bag row with no attributes behind it ------------------------------- +do + local save = { player = { name = "GOLD" }, + inventory = { POTION = 2, MYSTERY_THING = 3, LEGACY_KEY = true, + TM_ROCK_SMASH = 1 } } + local game = { data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end } } + local pack = PackMenu.new(game, {}) + local byId = {} + for _, row in ipairs(pack.rows) do byId[row.id] = row end + eq(byId.MYSTERY_THING and byId.MYSTERY_THING.name, "MYSTERY THING", + "an unknown id stands in for its own name") + eq(byId.MYSTERY_THING and byId.MYSTERY_THING.count, 3, "keeping its count") + eq(byId.LEGACY_KEY and byId.LEGACY_KEY.count, 1, + "and a count that is not a number counts as one") + eq(pack:pocketOf("MYSTERY_THING"), "ITEM", + "with no pocket to read, it lands in the general pocket") + pack.index = 2 + eq(pack:description(), nil, "it has no description to print") + local ok, err = pcall(function() pack:drawPanel() end) + check(ok, "and the pocket still draws (" .. tostring(err) .. ")") + + -- The TM pocket's second line is GetMoveName's string, not the move id: an + -- id would print an underscore the font has no glyph for. + pack:switchPocket(3) + eq(pack:pocket().id, "TM_HM", "the TM pocket") + eq(pack.rows[1] and pack.rows[1].name, "TM08", "lists the TM by its label") + eq(pack.rows[1] and pack.rows[1].teaches, "ROCK SMASH", + "with the move's NAME under it") +end + +-- ---- the PACK's cursor bytes live across openings ------------------------- +-- +-- Each pocket menu restores its own cursor and scroll before ScrollingMenu and +-- writes them back after (engine/items/pack.asm:76), and InitPackBuffers opens +-- the PACK on wLastPocket. CleanUpBattleRAM is the only thing that clears +-- them, and its list pointedly leaves the TM/HM pair alone +-- (engine/battle/core.asm:7994-8004). +do + local screen, battle, _, save, pushed = newBattleOverWorld({ + POTION = 2, SUPER_POTION = 1, POKE_BALL = 3, ITEMFINDER = 1, TM08 = 1 }) + check(runToMenu(screen), "the intro drains to the battle menu") + -- The 2x2 cursor stays where it was left, so walk it onto PACK each time. + local function openPack() + while BattleState.MENU[screen.menuIndex] ~= "PACK" do + Input:overlayPressed("down") + Input:step() + screen:update(1 / 60) + Input:overlayReleased("down") + Input:step() + screen:update(1 / 60) + end + Input:overlayPressed("a") + Input:step() + screen:update(1 / 60) + Input:overlayReleased("a") + Input:step() + screen:update(1 / 60) + return pushed[#pushed] + end + local pack = openPack() + eq(getmetatable(pack), PackMenu, "PACK opens the pack") + pressPack(pack, "down") + eq(pack.index, 2, "the cursor moved to the second row") + pressPack(pack, "b") -- back out of the PACK + eq(screen.phase, "menu", "B gives the battle menu back") + + local reopened = openPack() + eq(reopened.index, 2, "and the PACK reopens on the row it was left on") + eq(reopened:pocket().id, "ITEM", "in the pocket it was left in") + + -- wLastPocket: the pocket is remembered too. + pressPack(reopened, "right") + eq(reopened:pocket().id, "BALL", "right reaches the BALL pocket") + pressPack(reopened, "down") + eq(reopened.index, 2, "with its own row") + pressPack(reopened, "b") + local third = openPack() + eq(third:pocket().id, "BALL", "wLastPocket reopens on the BALL pocket") + eq(third.index, 2, "on its own remembered row") + -- And the ITEM pocket kept the row it was left on, separately. + pressPack(third, "left") + eq(third:pocket().id, "ITEM", "back to ITEMS") + eq(third.index, 2, "which still has its own cursor") + pressPack(third, "b") + + -- CleanUpBattleRAM clears the ITEM / KEY_ITEM / BALL bytes and wLastPocket. + battle.over = true + battle.outcome = "win" + screen:finishBattle() + local game = screen.game + eq(game.packCursor.pocket, nil, "wLastPocket is zeroed at the end of a battle") + eq(game.packCursor.cursor.ITEM, nil, "and the ITEM pocket's cursor with it") + eq(game.packCursor.cursor.BALL, nil, "and the BALL pocket's") + eq(save.inventory.POTION, 2, "with the bag untouched") +end + +-- The TM/HM pair is NOT in CleanUpBattleRAM's list, so it survives a battle. +do + local screen, battle, _, _, pushed = newBattleOverWorld({ TM08 = 1, + POTION = 1 }) + check(runToMenu(screen), "the intro drains to the battle menu") + local pack = openPackFromMenu(screen, pushed) + pack:switchPocket(3) + eq(pack:pocket().id, "TM_HM", "the TM pocket") + pack.index = 2 + pack:storeCursor() + pressPack(pack, "b") + battle.over = true + battle.outcome = "win" + screen:finishBattle() + eq(screen.game.packCursor.cursor.TM_HM, 2, + "the TM/HM cursor is not in ExitBattle's clear list") +end + +S.finish() diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua new file mode 100644 index 00000000..719a9acd --- /dev/null +++ b/tests/gen2_battle_test.lua @@ -0,0 +1,2605 @@ +-- Gen 2 battle math and turn engine. ROM-free: the fixtures below are the +-- shapes the extractor writes, with numbers traceable to pokegold so a failure +-- names the ASM it disagrees with. + +package.path = "./?.lua;" .. package.path + +local Battle = require("src.battle.gen2.Battle") +local Catching = require("src.battle.gen2.Catching") +local Damage = require("src.battle.gen2.Damage") +local Encounter = require("src.battle.gen2.Encounter") +local HpBar = require("src.battle.gen2.HpBar") +local Mon = require("src.battle.gen2.Mon") + +local failures, checks = 0, 0 + +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function checkNear(name, got, want, slack) + checks = checks + 1 + if math.abs((got or 0) - want) > (slack or 0) then + failures = failures + 1 + print(("FAIL %s: got %s, want %s +/- %s"):format( + name, tostring(got), tostring(want), tostring(slack))) + end +end + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FIGHTING = { id = "FIGHTING", index = 1, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + STEEL = { id = "STEEL", index = 9, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + ELECTRIC = { id = "ELECTRIC", index = 23, category = "special" }, + DARK = { id = "DARK", index = 27, category = "special" }, +} + +-- A slice of data/types/type_matchups.asm. +local MATCHUPS = { + { attacker = "FIRE", defender = "GRASS", multiplier = 20 }, + { attacker = "FIRE", defender = "WATER", multiplier = 5 }, + { attacker = "FIRE", defender = "STEEL", multiplier = 20 }, + { attacker = "WATER", defender = "FIRE", multiplier = 20 }, + { attacker = "WATER", defender = "ROCK", multiplier = 20 }, + { attacker = "WATER", defender = "GROUND", multiplier = 20 }, + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, + { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + EMBER = { id = "EMBER", name = "EMBER", power = 40, type = "FIRE", + accuracy = 100, pp = 25, effect = "EFFECT_BURN_HIT", effectChance = 10 }, + WATER_GUN = { id = "WATER_GUN", name = "WATER GUN", power = 40, + type = "WATER", accuracy = 100, pp = 25, effect = "EFFECT_NORMAL_HIT" }, + THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDERWAVE", power = 0, + type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, + QUICK_ATTACK = { id = "QUICK_ATTACK", name = "QUICK ATTACK", power = 40, + type = "NORMAL", accuracy = 100, pp = 30, + effect = "EFFECT_PRIORITY_HIT" }, + SPORE = { id = "SPORE", name = "SPORE", power = 0, type = "GRASS", + accuracy = 100, pp = 15, effect = "EFFECT_SLEEP" }, + -- The status-shaped moves the effect layer models, at their real numbers. + RAIN_DANCE = { id = "RAIN_DANCE", name = "RAIN DANCE", power = 0, + type = "WATER", accuracy = 100, pp = 5, effect = "EFFECT_RAIN_DANCE" }, + SPIKES = { id = "SPIKES", name = "SPIKES", power = 0, type = "GROUND", + accuracy = 100, pp = 20, effect = "EFFECT_SPIKES" }, + PERISH_SONG = { id = "PERISH_SONG", name = "PERISH SONG", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_PERISH_SONG" }, + TRANSFORM = { id = "TRANSFORM", name = "TRANSFORM", power = 0, + type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_TRANSFORM" }, + ENDURE = { id = "ENDURE", name = "ENDURE", power = 0, type = "NORMAL", + accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" }, + RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_RAGE" }, +} + +local GROWTH = { + -- data/growth_rates.asm rows. + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, + 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 POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { + { level = 1, move = "TACKLE" }, { level = 1, move = "LEER" }, + { level = 6, move = "SMOKESCREEN" }, { level = 12, move = "EMBER" }, + { level = 19, move = "QUICK_ATTACK" }, { level = 27, move = "FLAME_WHEEL" }, + }, + evolutions = { { method = "EVOLVE_LEVEL", level = 14, into = "QUILAVA" } }, + }, + TOTODILE = { + id = "TOTODILE", index = 158, name = "TOTODILE", + baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48 }, + types = { "WATER", "WATER" }, catchRate = 45, baseExp = 66, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "SCRATCH" }, + { level = 7, move = "WATER_GUN" } }, + 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_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, + evolutions = {}, + }, + GEODUDE = { + id = "GEODUDE", index = 74, name = "GEODUDE", + baseStats = { hp = 40, attack = 80, defense = 100, speed = 20, + specialAttack = 30, specialDefense = 30 }, + types = { "ROCK", "GROUND" }, catchRate = 255, baseExp = 73, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, + evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = { + POKE_BALL = { id = "POKE_BALL", pocket = "BALL" }, + POTION = { id = "POTION", pocket = "ITEM" }, + }, +} + +-- Deterministic "random": always returns 0, i.e. the first outcome. Passed as +-- random(n) -> 0..n-1, which is the same contract BattleRandom has. +local function zeroRandom() return 0 end +-- Always the *last* outcome. +local function maxRandom(n) return n - 1 end + +-- ------------------------------------------------------------------- stats + +-- Gen 2's stat formula is Gen 1's. A level-5 Cyndaquil with perfect DVs: +-- HP = floor(((39*2 + 15*2) * 5) / 100) + 5 + 10 = 20 +-- Atk = floor(((52*2 + 15*2) * 5) / 100) + 5 = 11 +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) +check("perfect HP DV", perfect.hp, 15) +local stats = Mon.stats(POKEMON.CYNDAQUIL.baseStats, perfect, 5) +check("cyndaquil L5 hp", stats.hp, 20) +check("cyndaquil L5 attack", stats.attack, 11) +-- SpA and SpD both read the one Special DV, so they differ only by base stat +-- (Cyndaquil's are 60 and 50). +check("cyndaquil L5 spA", stats.specialAttack, 12) +check("cyndaquil L5 spD", stats.specialDefense, 11) + +local zero = { attack = 0, defense = 0, speed = 0, special = 0 } +zero.hp = Mon.hpDV(zero) +check("zero HP DV", zero.hp, 0) +check("cyndaquil L5 hp zero DVs", Mon.stats( + POKEMON.CYNDAQUIL.baseStats, zero, 5).hp, 18) + +-- --------------------------------------------------------------- experience + +-- MEDIUM_FAST is n^3, so level 10 costs 1000. +check("medium fast L10", Mon.experienceForLevel(GROWTH.GROWTH_MEDIUM_FAST, 10), + 1000) +check("fast L10", Mon.experienceForLevel(GROWTH.GROWTH_FAST, 10), 800) +check("slow L10", Mon.experienceForLevel(GROWTH.GROWTH_SLOW, 10), 1250) +-- MEDIUM_SLOW: 6/5 n^3 - 15 n^2 + 100 n - 140. At 10: 1200 - 1500 + 1000 - 140. +check("medium slow L10", Mon.experienceForLevel(GROWTH.GROWTH_MEDIUM_SLOW, 10), + 560) +check("level for 1000 medium fast", + Mon.levelForExperience(GROWTH.GROWTH_MEDIUM_FAST, 1000), 10) +check("level for 999 medium fast", + Mon.levelForExperience(GROWTH.GROWTH_MEDIUM_FAST, 999), 9) + +-- exp = baseExp * level / 7, halved per extra participant, x1.5 for a trainer. +check("exp gain wild", Mon.experienceGain(POKEMON.PIDGEY, 7, 1, false), + math.floor(55 * 7 / 7)) +check("exp gain split", Mon.experienceGain(POKEMON.PIDGEY, 7, 2, false), + math.floor(math.floor(55 * 7 / 7) / 2)) +check("exp gain trainer", Mon.experienceGain(POKEMON.PIDGEY, 7, 1, true), + math.floor(math.floor(55 * 7 / 7) * 3 / 2)) + +-- Moves at a level: the last four learned at or below it. +local moves = Mon.movesAtLevel(POKEMON.CYNDAQUIL, 19, MOVES) +check("moves at L19 count", #moves, 4) +check("moves at L19 first", moves[1].id, "LEER") +check("moves at L19 last", moves[4].id, "QUICK_ATTACK") +check("move pp", Mon.movesAtLevel(POKEMON.CYNDAQUIL, 12, MOVES)[4].pp, 25) + +-- Level-up: the gain reports which levels and moves it crossed. +local cyndaquil = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) +check("new mon level", cyndaquil.level, 5) +check("new mon at full hp", cyndaquil.hp, cyndaquil.maxHp) +local growth = GROWTH.GROWTH_MEDIUM_SLOW +local toSix = Mon.experienceForLevel(growth, 6) + - Mon.experienceForLevel(growth, 5) +local result = Mon.gainExperience(cyndaquil, toSix, DATA) +check("levelled once", result.levels, 1) +check("levelled to 6", cyndaquil.level, 6) +check("learned smokescreen at 6", result.learned[1], "SMOKESCREEN") + +-- AskLearnMove on a full moveset: resolveForget drops a slot and learns the +-- newcomer, declineForget keeps the four. Before these existed the +-- choose-forget event was emitted and dropped, so no mon could learn a move +-- past a full set -- the bot's starter reached the League on its level-1 +-- moves. Powers come from MOVES above; the pending move is a fresh entry. +do + local learner = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + learner.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, -- power 35, the weakest + { id = "EMBER", pp = 25, maxPp = 25 }, -- power 40 + { id = "QUICK_ATTACK", pp = 30, maxPp = 30 }, -- power 40 + { id = "WATER_GUN", pp = 25, maxPp = 25 }, -- power 40 + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + local b = Battle.new({ data = DATA, party = { learner }, wild = wild, + random = zeroRandom }) + b:takeEvents() -- clear intro + + local newMove = { id = "SPORE", pp = 15, maxPp = 15 } + check("resolveForget drops slot 1 and learns the new move", + b:resolveForget(1, 1, newMove, "SPORE"), true) + check("the slot now holds the newcomer", learner.moves[1].id, "SPORE") + check("the other three are untouched", learner.moves[2].id, "EMBER") + local ev = b:takeEvents() + check("it queues a forgot line then a learned line", #ev, 2) + check("forgot text names the dropped move", + ev[1].text:find("forgot TACKLE") ~= nil, true) + check("learned text names the new move", + ev[2].text:find("learned SPORE") ~= nil, true) + + -- Decline keeps the moveset and says so. + b:declineForget(1, "SPORE") + local dev = b:takeEvents() + check("decline queues one line", #dev, 1) + check("and it is the did-not-learn line", + dev[1].text:find("did not learn SPORE") ~= nil, true) +end + +-- Evolution threshold. +check("cyndaquil evolves at 14", + (Mon.evolutionAtLevel(POKEMON.CYNDAQUIL, 14) or {}).into, "QUILAVA") +check("cyndaquil not at 13", + Mon.evolutionAtLevel(POKEMON.CYNDAQUIL, 13), nil) + +-- ------------------------------------------------------------------ damage + +-- The formula, with every multiplier pinned so the arithmetic is checkable: +-- base = ((2*10/5 + 2) * 40 * 20 / 20) / 50 = (6 * 40) / 50 = 4 +check("base damage", Damage.base(10, 40, 20, 20), 4) +-- Defence of 0 is clamped to 1 rather than dividing by zero. +check("base damage zero defence", Damage.base(10, 40, 20, 0), + Damage.base(10, 40, 20, 1)) +check("base damage no power", Damage.base(10, 0, 20, 20), 0) + +-- Physical / special split is by type: NORMAL physical, FIRE special. +check("normal is physical", Damage.isPhysical("NORMAL", TYPES), true) +check("fire is special", Damage.isPhysical("FIRE", TYPES), false) +check("steel is physical", Damage.isPhysical("STEEL", TYPES), true) +check("dark is special", Damage.isPhysical("DARK", TYPES), false) + +-- ...and it decides which stats are read. Same numbers, different stats. +local physicalAttacker = { + attack = 100, specialAttack = 10, types = { "NORMAL" }, +} +local defender = { defense = 50, specialDefense = 200, types = { "FIRE" } } +local physicalDamage = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = physicalAttacker, defender = defender, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +local specialDamage = Damage.calc({ + level = 50, power = 40, moveType = "FIRE", + attacker = { attack = 100, specialAttack = 10, types = { "NORMAL" } }, + defender = defender, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +check("physical reads attack/defense", physicalDamage > specialDamage, true) + +-- Type effectiveness stacks per row and floors between: Water on Rock/Ground +-- is 2x twice. +check("water vs rock/ground", + Damage.typeMultiplier("WATER", { "ROCK", "GROUND" }, MATCHUPS), 40) +check("normal vs rock", Damage.typeMultiplier("NORMAL", { "ROCK" }, MATCHUPS), 5) +check("fire vs steel", Damage.typeMultiplier("FIRE", { "STEEL" }, MATCHUPS), 20) +check("electric vs ground immune", + Damage.typeMultiplier("ELECTRIC", { "GROUND" }, MATCHUPS), 0) +check("neutral", Damage.typeMultiplier("NORMAL", { "FIRE" }, MATCHUPS), 10) + +-- An immune matchup does no damage at all. +local immune = Damage.calc({ + level = 50, power = 95, moveType = "ELECTRIC", + attacker = { specialAttack = 100, types = { "ELECTRIC" } }, + defender = { specialDefense = 50, types = { "GROUND" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +check("immune deals nothing", immune, 0) + +-- STAB is x1.5, and a critical hit is x2 on top. +local plain = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +local stabbed = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "NORMAL" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +check("stab is 1.5x", stabbed, math.floor(plain * 15 / 10)) +local crit = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, critical = true, +}) +-- The x2 runs inside DamageCalc, BEFORE its tail adds MIN_DAMAGE back, so a +-- crit is twice the pre-minimum damage plus 2, not twice the finished number. +check("critical is 2x", crit, (plain - Damage.MIN_DAMAGE) * 2 + + Damage.MIN_DAMAGE) + +-- A critical hit ignores the attacker's negative stages and the defender's +-- positive ones -- but keeps the ones that help. +local lowered = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" }, stages = { attack = -2 } }, + defender = { defense = 50, types = { "FIRE" }, stages = { defense = 2 } }, + types = TYPES, matchups = MATCHUPS, variation = 100, +}) +local loweredCrit = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" }, stages = { attack = -2 } }, + defender = { defense = 50, types = { "FIRE" }, stages = { defense = 2 } }, + types = TYPES, matchups = MATCHUPS, variation = 100, critical = true, +}) +check("crit ignores bad stages", loweredCrit, crit) +check("stages do apply without a crit", lowered < plain, true) +local raised = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" }, stages = { attack = 2 } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, critical = true, +}) +check("crit keeps good stages", raised > loweredCrit, true) + +-- Reflect / Light Screen doubles defence, and a crit ignores it. +local screened = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, screen = true, +}) +check("screen halves damage", screened < plain, true) +local screenedCrit = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, screen = true, + critical = true, +}) +check("crit ignores screen", screenedCrit, crit) + +-- Damage variation is 85-100% of the maximum. +local low = Damage.calc({ + level = 50, power = 40, moveType = "NORMAL", + attacker = { attack = 100, types = { "FIRE" } }, + defender = { defense = 50, types = { "FIRE" } }, + types = TYPES, matchups = MATCHUPS, variation = 85, +}) +check("85% is the floor", low, math.floor(plain * 85 / 100)) +check("100% is the max", plain > low, true) + +-- Stat stage table matches the cart's fractions. +check("stage +2 doubles", Damage.applyStage(100, 2), 200) +check("stage -2 halves", Damage.applyStage(100, -2), 50) +check("stage +6 quadruples", Damage.applyStage(100, 6), 400) +check("stage clamps above 6", Damage.applyStage(100, 9), + Damage.applyStage(100, 6)) +check("stage never reaches zero", Damage.applyStage(1, -6), 1) + +-- Critical chance ladder (data/battle/critical_hit_chances.asm). +check("crit level 0 is 1/15", Damage.criticalChance(0), 15) +check("crit level 1 is 1/8", Damage.criticalChance(1), 8) +check("crit level 2 is 1/4", Damage.criticalChance(2), 4) +check("crit level 4 is 1/2", Damage.criticalChance(4), 2) +check("focus energy is +1", + Damage.criticalLevel({ focusEnergy = true }), 1) +check("high crit move is +2", + Damage.criticalLevel({ highCritMove = true }), 2) +check("focus + high crit + lens", + Damage.criticalLevel({ focusEnergy = true, highCritMove = true, + scopeLens = true }), 4) +check("crit level caps at 6", + Damage.criticalLevel({ focusEnergy = true, highCritMove = true, + scopeLens = true, speciesItemBonus = true }), 6) +check("roll 0 crits", Damage.rollCritical(0, zeroRandom), true) +check("max roll misses", Damage.rollCritical(0, maxRandom), false) + +-- Accuracy: 0 in the data means never miss. +check("accuracy 0 always hits", Damage.rollHit(0, 0, 0, maxRandom), true) +check("accuracy 100 always hits", Damage.rollHit(100, 0, 0, maxRandom), true) +check("accuracy 50 misses on a high roll", + Damage.rollHit(50, 0, 0, maxRandom), false) +check("accuracy 50 hits on a low roll", + Damage.rollHit(50, 0, 0, zeroRandom), true) +check("evasion lowers accuracy", Damage.rollHit(95, 0, 6, function() return 30 end), + false) + +-- ------------------------------------------------------------------- HP bar + +-- ComputeHPBarPixels: curHP * 48 / maxHP, floored, with a live mon never +-- showing an empty bar and a fainted one showing exactly nothing. +check("bar length", HpBar.LENGTH_PX, 48) +check("full bar", HpBar.pixels(20, 20), 48) +check("empty bar", HpBar.pixels(0, 20), 0) +check("half bar", HpBar.pixels(10, 20), 24) +check("quarter bar", HpBar.pixels(5, 20), 12) +-- 1 HP out of 100 rounds down to 0 pixels, which the routine forces to 1: a +-- living mon always has some bar left. +check("1 of 100 shows one pixel", HpBar.pixels(1, 100), 1) +check("floors rather than rounds", HpBar.pixels(7, 20), + math.floor(7 * 48 / 20)) +-- The maxHP >= 256 shift: both sides divide by 4 first, so the bar moves in +-- coarser steps than the exact ratio. Reproduced on purpose. +check("high HP shifts", HpBar.pixels(300, 400), + math.floor(math.floor(300 * 48 / 4) / math.floor(400 / 4))) + +-- GetHPPal boundaries are on the *pixel* count: 24 is still green and 10 is +-- still yellow, which is why exactly half HP is not yellow. +check("green threshold", HpBar.GREEN_PIXELS, 24) +check("yellow threshold", HpBar.YELLOW_PIXELS, 10) +check("24 pixels is green", HpBar.palette(24), "green") +check("23 pixels is yellow", HpBar.palette(23), "yellow") +check("10 pixels is yellow", HpBar.palette(10), "yellow") +check("9 pixels is red", HpBar.palette(9), "red") +check("exactly half HP is green", HpBar.paletteFor(10, 20), "green") +check("just under half is yellow", HpBar.paletteFor(9, 20), "yellow") +check("one HP is red", HpBar.paletteFor(1, 20), "red") +check("fainted is red", HpBar.palette(0), "red") + +-- ---------------------------------------------------------------- catching + +-- A full-HP mon with catchRate 45 in a Poke Ball is a poor bet; the same mon at +-- 1 HP asleep is a good one. +local fullRate = Catching.rate({ + maxHp = 60, hp = 60, catchRate = 45, ball = "POKE_BALL" }) +local hurtRate = Catching.rate({ + maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL" }) +check("hurt is easier to catch", hurtRate > fullRate, true) +local asleepRate = Catching.rate({ + maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) +check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) +-- The cart's bug: burn/poison/paralysis add nothing. +check("poison adds nothing (cart bug)", Catching.rate({ + maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", + status = "poison" }), hurtRate) +check("poison adds 5 with fixBugs", Catching.rate({ + maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", + status = "poison", fixBugs = true }), math.min(255, hurtRate + 5)) +-- Better balls multiply the species rate. +check("ultra beats great", Catching.rate({ + maxHp = 60, hp = 30, catchRate = 45, ball = "ULTRA_BALL" }) + > Catching.rate({ maxHp = 60, hp = 30, catchRate = 45, + ball = "GREAT_BALL" }), true) +-- A Master Ball never fails. +-- The second return is wFinalCatchRate, the row GetPokeBallWobble re-rolls +-- against once per wobble; the wobble COUNT is the animation's, not this +-- routine's (engine/battle_anims/pokeball_wobble.asm). +local caught, finalRate = Catching.attempt({ + maxHp = 300, hp = 300, catchRate = 3, ball = "MASTER_BALL", + random = maxRandom }) +check("master ball always catches", caught, true) +check("master ball reports its final rate", finalRate, 255) +check("poke ball can fail", (Catching.attempt({ + maxHp = 60, hp = 60, catchRate = 3, ball = "POKE_BALL", + random = maxRandom })), false) +-- A rate that reaches the 255 cap is certain even on the worst roll; without +-- the sleep bonus the same mon sits at 252 and can still break out. +check("capped rate catches", (Catching.attempt({ + maxHp = 60, hp = 1, catchRate = 255, ball = "ULTRA_BALL", + status = "sleep", random = maxRandom })), true) +check("252 is not certain", (Catching.attempt({ + maxHp = 60, hp = 1, catchRate = 255, ball = "ULTRA_BALL", + random = maxRandom })), false) + +-- -------------------------------------------------------------- encounters + +local ENCOUNTERS = { + grass = { + ROUTE_29 = { + map = "ROUTE_29", + rates = { MORN = 25, DAY = 25, NITE = 25 }, + slots = { + MORN = { { level = 2, species = "PIDGEY" }, + { level = 2, species = "SENTRET" }, { level = 3, species = "PIDGEY" }, + { level = 3, species = "SENTRET" }, { level = 4, species = "PIDGEY" }, + { level = 2, species = "RATTATA" }, { level = 3, species = "RATTATA" } }, + DAY = { { level = 2, species = "PIDGEY" }, + { level = 2, species = "SENTRET" }, { level = 3, species = "PIDGEY" }, + { level = 3, species = "SENTRET" }, { level = 4, species = "PIDGEY" }, + { level = 2, species = "RATTATA" }, { level = 3, species = "RATTATA" } }, + NITE = { { level = 2, species = "HOOTHOOT" }, + { level = 2, species = "HOOTHOOT" }, { level = 3, species = "HOOTHOOT" }, + { level = 3, species = "RATTATA" }, { level = 4, species = "HOOTHOOT" }, + { level = 2, species = "RATTATA" }, { level = 3, species = "RATTATA" } }, + }, + }, + }, + water = { + ROUTE_30 = { map = "ROUTE_30", rate = 2, + slots = { { level = 15, species = "POLIWAG" }, + { level = 20, species = "POLIWHIRL" }, + { level = 15, species = "POLIWAG" } } }, + }, + fishGroups = { + FISHGROUP_SHORE = { id = "FISHGROUP_SHORE", chance = 128, + old = { { chance = 179, species = "MAGIKARP", level = 10 }, + { chance = 217, species = "MAGIKARP", level = 10 }, + { chance = 255, species = "KRABBY", level = 10 } }, + good = {}, super = {} }, + }, + trees = { ROUTE_29 = "TREEMON_SET_CANYON" }, +} + +-- The time of day picks the list: morning is Pidgey, night is Hoothoot. +check("morn slot 1", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "MORN", + zeroRandom).species, "PIDGEY") +check("nite slot 1", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "NITE", + zeroRandom).species, "HOOTHOOT") +-- DARK reuses the night list, since the cart only stores three. +check("dark reuses nite", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "DARK", + zeroRandom).species, "HOOTHOOT") +-- Slot probabilities: a roll of 99 lands in the last (1%) slot. +check("roll 99 is slot 7", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "MORN", + function() return 99 end).slot, 7) +check("roll 0 is slot 1", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "MORN", + zeroRandom).slot, 1) +check("roll 30 is slot 2", Encounter.grassSlot(ENCOUNTERS, "ROUTE_29", "MORN", + function() return 30 end).slot, 2) +check("no table means no encounter", + Encounter.grassSlot(ENCOUNTERS, "NEW_BARK_TOWN", "DAY", zeroRandom), nil) + +check("rate morn", Encounter.grassRate(ENCOUNTERS, "ROUTE_29", "MORN"), 25) +check("rate for a mapless entry", + Encounter.grassRate(ENCOUNTERS, "NEW_BARK_TOWN", "DAY"), 0) +check("rate 25 triggers on a low roll", + Encounter.triggers(25, zeroRandom), true) +check("rate 25 misses on a high roll", + Encounter.triggers(25, maxRandom), false) +check("rate 0 never triggers", Encounter.triggers(0, zeroRandom), false) + +check("water slot 1", Encounter.waterSlot(ENCOUNTERS, "ROUTE_30", + zeroRandom).species, "POLIWAG") +check("water roll 60 is slot 2", Encounter.waterSlot(ENCOUNTERS, "ROUTE_30", + function() return 60 end).slot, 2) +check("fishing old rod", Encounter.fish(ENCOUNTERS, "FISHGROUP_SHORE", "old", + zeroRandom).species, "MAGIKARP") +check("fishing high roll is krabby", Encounter.fish(ENCOUNTERS, + "FISHGROUP_SHORE", "old", function() return 220 end).species, "KRABBY") +check("headbutt set", Encounter.treeSet(ENCOUNTERS, "ROUTE_29"), + "TREEMON_SET_CANYON") + +-- ------------------------------------------------------------------ battle + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "EMBER", pp = 25, maxPp = 25 }, + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Battle.new({ + data = DATA, + party = opts.party or { player }, + wild = opts.wild ~= false and wild or nil, + trainer = opts.trainer, + random = opts.random or zeroRandom, + }), player, wild +end + +local battle, player, wild = newBattle() +check("battle picks the first healthy mon", battle.player, player) +check("wild battle flag", battle.wild, true) + +-- A move spends PP and deals damage. +local before = wild.hp +battle:takeTurn({ kind = "move", move = "TACKLE" }) +check("pp spent", player.moves[1].pp, 34) +check("wild took damage", wild.hp < before, true) + +-- Priority beats Speed: Quick Attack goes first even from the slower side. +local orderBattle = newBattle() +orderBattle.player.stats.speed = 1 +orderBattle.enemy.stats.speed = 200 +check("slower side still first with priority", + orderBattle:orderOf("QUICK_ATTACK", "TACKLE"), "player") +check("faster side first otherwise", + orderBattle:orderOf("TACKLE", "TACKLE"), "enemy") +orderBattle.player.stats.speed = 200 +orderBattle.enemy.stats.speed = 1 +check("faster player first", orderBattle:orderOf("TACKLE", "TACKLE"), "player") + +-- Paralysis quarters Speed, which can flip the order. +orderBattle.player.stats.speed = 100 +orderBattle.enemy.stats.speed = 50 +check("player faster", orderBattle:orderOf("TACKLE", "TACKLE"), "player") +orderBattle.player.status = "paralyze" +check("paralysed player is slower", + orderBattle:orderOf("TACKLE", "TACKLE"), "enemy") +orderBattle.player.status = nil + +-- A status move lands its status, and a second one fails. +local statusBattle = newBattle() +statusBattle.player.moves = { { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } +statusBattle:takeTurn({ kind = "move", move = "THUNDER_WAVE" }) +check("thunder wave paralysed", statusBattle.enemy.status, "paralyze") +statusBattle.player.moves[1].pp = 20 +statusBattle:takeTurn({ kind = "move", move = "THUNDER_WAVE" }) +check("a second status fails", statusBattle.enemy.status, "paralyze") + +-- Sleep lands with a turn counter, and canAct spends it. Asserted on the move +-- rather than a whole turn: with this deterministic random the roll is the +-- minimum 1 turn, and the slower foe's own turn later in the same round then +-- wakes it -- which is what the cart does as well. +local sleepBattle = newBattle() +sleepBattle.player.moves = { { id = "SPORE", pp = 15, maxPp = 15 } } +sleepBattle:useMove(sleepBattle.player, sleepBattle.enemy, "SPORE") +check("spore slept the target", sleepBattle.enemy.status, "sleep") +check("sleep has turns", (sleepBattle.enemy.statusTurns or 0) >= 1, true) +-- A sleeping mon cannot act, and the counter runs down to a wake-up. +sleepBattle.enemy.statusTurns = 2 +check("asleep cannot act", sleepBattle:canAct(sleepBattle.enemy), false) +check("counter spent", sleepBattle.enemy.statusTurns, 1) +check("wakes on the last turn", sleepBattle:canAct(sleepBattle.enemy), true) +check("status cleared on waking", sleepBattle.enemy.status, nil) + +-- Burn chips 1/8 max HP at end of turn and halves physical attack. +local burnBattle = newBattle() +burnBattle.enemy.status = "burn" +local enemyMax = burnBattle.enemy.maxHp +local hpBefore = burnBattle.enemy.hp +burnBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("burn ticked", burnBattle.enemy.hp + <= hpBefore - math.floor(enemyMax / 8), true) + +-- Fainting the wild mon ends the battle and awards experience. +local expBattle, expPlayer, expWild = newBattle() +local expBefore = expPlayer.experience +expWild.hp = 1 +expBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("battle over after faint", expBattle.over, true) +check("outcome win", expBattle.outcome, "win") +check("experience gained", expPlayer.experience > expBefore, true) + +-- Losing with no healthy party ends it the other way. +local loseBattle, losePlayer = newBattle() +losePlayer.hp = 0 +loseBattle.player.hp = 0 +loseBattle:resolveFaints() +check("outcome lose", loseBattle.outcome, "lose") + +-- Running: a faster mon always escapes, a trainer battle never lets you. +local runBattle = newBattle() +runBattle.player.stats.speed = 200 +runBattle.enemy.stats.speed = 1 +check("faster mon runs", runBattle:tryRun(), true) +check("run outcome", runBattle.outcome, "run") + +local trainerParty = { Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }) } +trainerParty[1].moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } +local trainerBattle = Battle.new({ + data = DATA, + party = { Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) }, + trainer = { class = "YOUNGSTER", name = "JOEY", party = trainerParty }, + random = zeroRandom, +}) +check("trainer battle is not wild", trainerBattle.wild, false) +check("cannot run from a trainer", trainerBattle:tryRun(), false) +check("trainer battle still going", trainerBattle.over, false) + +-- A trainer's second mon comes out when the first faints. +local twoParty = { + Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }), + Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }), +} +for _, mon in ipairs(twoParty) do + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } +end +local twoBattle = Battle.new({ + data = DATA, + party = { Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) }, + trainer = { class = "YOUNGSTER", name = "JOEY", party = twoParty }, + random = zeroRandom, +}) +twoBattle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } +twoParty[1].hp = 1 +twoBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("second mon sent out", twoBattle.enemy, twoParty[2]) +check("battle not over yet", twoBattle.over, false) + +do + -- ...and it does NOT answer on the turn it walked in. Battle_PlayerFirst + -- reaches HandleEnemyMonFaint with `jp`, not `call` + -- (engine/battle/core.asm:872), so the round's attack phase is over -- and + -- the move that was picked for the mon that fainted is never spent by the one + -- that replaced it. The lead knows a move the bench mon does not, so a stale + -- selection would be visible by name. + local staleParty = { + Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }), + Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }), + } + staleParty[1].moves = { { id = "EMBER", pp = 25, maxPp = 25 } } + staleParty[2].moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local staleBattle = Battle.new({ + data = DATA, + party = { Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) }, + trainer = { class = "YOUNGSTER", name = "JOEY", party = staleParty }, + random = zeroRandom, + }) + staleBattle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + staleParty[1].hp = 1 + local hpBeforeFaint = staleBattle.player.hp + local staleEvents = staleBattle:takeTurn({ kind = "move", move = "TACKLE" }) + local sawSend, enemyMovedAfterSend = false, false + for _, event in ipairs(staleEvents) do + if event.kind == "send" and event.side == "enemy" then sawSend = true end + if sawSend and event.kind == "move" and event.side == "enemy" then + enemyMovedAfterSend = true + end + end + check("the replacement was sent out", sawSend, true) + check("and never attacked on the way in", enemyMovedAfterSend, false) + check("so the player took nothing back", staleBattle.player.hp, hpBeforeFaint) + check("and the dead mon's EMBER was not spent by it", + staleParty[2].moves[1].pp, 35) + + -- A mid-turn rotation announces the mon coming OFF the field first. + -- AI_Switch prints EnemyWithdrewText before it farcalls EnemySwitch + -- (engine/battle/ai/items.asm:685), which is the only cue a trainer + -- swapping between two mons of the same species gives. + local rotateParty = { + Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }), + Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }), + } + for _, mon in ipairs(rotateParty) do + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + end + local rotateBattle = Battle.new({ + data = DATA, + party = { Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) }, + -- attributes[6] is the low byte of the switch flags: OFTEN. + trainer = { class = "YOUNGSTER", name = "JOEY", party = rotateParty, + attributes = { 0, 0, 0, 0, 0, 0x01, 0 } }, + random = zeroRandom, + }) + -- Perish Song at one turn left is CheckAbleToSwitch's maximum score, which + -- is the cheapest way to make the AI commit to the rotation. + rotateBattle:volatile(rotateBattle.enemy).perish = 1 + check("the AI rotated", rotateBattle:enemyTrySwitchOrItem(), true) + local withdrew, sentOut = nil, nil + for _, event in ipairs(rotateBattle:takeEvents()) do + if event.kind == "message" and not withdrew then withdrew = event.text end + if event.kind == "send" then sentOut = event.text end + end + check("EnemyWithdrewText comes first", withdrew, "JOEY withdrew GEODUDE!") + check("then the send-out line", sentOut, "JOEY sent out PIDGEY!") + + -- A refused RUN costs nothing. .cant_run_from_trainer leaves + -- wBattlePlayerAction at BATTLEPLAYERACTION_USEMOVE, so BattleMenu_Run's + -- `jp BattleMenu` (engine/battle/core.asm:5035-5038) reopens the menu with the + -- turn unspent; only the failed roll writes BATTLEPLAYERACTION_USEITEM. + local refuseParty = { Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }) } + refuseParty[1].moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local refuseBattle = Battle.new({ + data = DATA, + party = { Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) }, + trainer = { class = "YOUNGSTER", name = "JOEY", party = refuseParty }, + random = zeroRandom, + }) + refuseBattle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local hpBeforeRun = refuseBattle.player.hp + local refuseEvents = refuseBattle:takeTurn({ kind = "run" }) + local enemyAnswered = false + for _, event in ipairs(refuseEvents) do + if event.kind == "move" and event.side == "enemy" then enemyAnswered = true end + end + check("the trainer refusal ends the turn there", enemyAnswered, false) + check("and the enemy got no free hit", refuseBattle.player.hp, hpBeforeRun) + check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35) +end + +-- Switching costs the turn and adds the newcomer to the participant set. +local switchParty = { + Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }), + Mon.new(DATA, "TOTODILE", 10, { dvs = perfect }), +} +for _, mon in ipairs(switchParty) do + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } +end +local switchBattle = Battle.new({ + data = DATA, party = switchParty, + wild = (function() + local m = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return m + end)(), + random = zeroRandom, +}) +switchBattle:takeTurn({ kind = "switch", index = 2 }) +check("switched active mon", switchBattle.player, switchParty[2]) +check("both are participants", + switchBattle.participants[1] and switchBattle.participants[2], true) +check("switch spent no PP", switchParty[2].moves[1].pp, 35) + +-- ------------------------------------------------------------- move effects + +local Effects = require("src.battle.gen2.Effects") + +-- Stat stages clamp at +/-6 and report how far they actually moved, which is +-- what decides between "rose" and "sharply rose". +local stages = Battle.newStages() +check("one stage up", Effects.applyStage(stages, "attack", 1), 1) +check("two more", Effects.applyStage(stages, "attack", 2), 2) +check("stage total", stages.attack, 3) +Effects.applyStage(stages, "attack", 6) +check("clamped at +6", stages.attack, 6) +check("and refuses to go higher", Effects.applyStage(stages, "attack", 1), nil) +check("message names the stat", Effects.stageMessage("PIDGEY", "attack", 1), + "PIDGEY's ATTACK rose!") +check("two stages are sharp", + Effects.stageMessage("PIDGEY", "defense", -2), + "PIDGEY's DEFENSE sharply fell!") + +-- Recoil is a quarter of the damage dealt, drain half, both at least 1. +check("recoil quarter", Effects.recoilDamage(40), 10) +check("recoil floors at 1", Effects.recoilDamage(1), 1) +check("drain half", Effects.drainAmount(41), 20) + +-- Rollout doubles per consecutive use and Defense Curl doubles it again; the +-- ramp caps after four doublings. +check("first rollout", Effects.rampedPower(30, 0), 30) +check("second", Effects.rampedPower(30, 1), 60) +check("fifth is capped", Effects.rampedPower(30, 9), 480) +check("curled doubles again", Effects.rampedPower(30, 0, true), 60) + +-- Triple Kick climbs 10/20/30. +check("first kick", Effects.tripleKickPower(10, 1), 10) +check("third kick", Effects.tripleKickPower(10, 3), 30) + +-- Fixed damage skips the formula: Seismic Toss is the user's level, Super +-- Fang half the target's current HP. +check("level damage", Effects.fixedDamage("EFFECT_LEVEL_DAMAGE", + { level = 24 }, { hp = 90 }), 24) +check("super fang", Effects.fixedDamage("EFFECT_SUPER_FANG", + { level = 24 }, { hp = 91 }), 45) +check("an unmodelled effect has no fixed damage", + Effects.fixedDamage("EFFECT_NORMAL_HIT", { level = 5 }, { hp = 20 }), nil) + +-- Substitute costs a quarter of max HP. +check("substitute cost", Effects.substituteCost(64), 16) + +-- ------------------------------------------------- effects through the engine + +local EFFECT_MOVES = { + GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, + SWORDS_DANCE = { id = "SWORDS_DANCE", name = "SWORDSDANCE", power = 0, + type = "NORMAL", accuracy = 100, pp = 30, effect = "EFFECT_ATTACK_UP_2" }, + DOUBLE_KICK = { id = "DOUBLE_KICK", name = "DOUBLEKICK", power = 30, + type = "FIGHTING", accuracy = 100, pp = 30, effect = "EFFECT_DOUBLE_HIT" }, + TAKE_DOWN = { id = "TAKE_DOWN", name = "TAKE DOWN", power = 90, + type = "NORMAL", accuracy = 85, pp = 20, effect = "EFFECT_RECOIL_HIT" }, + ABSORB = { id = "ABSORB", name = "ABSORB", power = 20, type = "GRASS", + accuracy = 100, pp = 20, effect = "EFFECT_LEECH_HIT" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, + SUBSTITUTE = { id = "SUBSTITUTE", name = "SUBSTITUTE", power = 0, + type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_SUBSTITUTE" }, + SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1, + type = "FIGHTING", accuracy = 100, pp = 20, + effect = "EFFECT_LEVEL_DAMAGE" }, + LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL", + accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" }, + -- data/moves/moves.asm:169, :172, :151. + EXPLOSION = { id = "EXPLOSION", name = "EXPLOSION", power = 250, + type = "NORMAL", accuracy = 100, pp = 5, + effect = "EFFECT_SELFDESTRUCT" }, + REST = { id = "REST", name = "REST", power = 0, type = "NORMAL", + accuracy = 100, pp = 10, effect = "EFFECT_HEAL" }, + RECOVER = { id = "RECOVER", name = "RECOVER", power = 0, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_HEAL" }, + PROTECT = { id = "PROTECT", name = "PROTECT", power = 0, type = "NORMAL", + accuracy = 100, pp = 10, effect = "EFFECT_PROTECT" }, +} +for id, def in pairs(EFFECT_MOVES) do MOVES[id] = def end + +local function effectBattle(moveIds) + local battle_, player_, wild_ = newBattle() + player_.moves = {} + for _, id in ipairs(moveIds) do + player_.moves[#player_.moves + 1] = { id = id, pp = 10, maxPp = 10 } + end + return battle_, player_, wild_ +end + +-- A stat move moves the right side's stage and spends its PP. +local statBattle, statPlayer, statWild = effectBattle({ "GROWL" }) +statBattle:takeTurn({ kind = "move", move = "GROWL" }) +check("GROWL lowers the foe", statBattle.stages.enemy.attack, -1) +check("and not the user", statBattle.stages.player.attack, 0) + +local upBattle = effectBattle({ "SWORDS_DANCE" }) +upBattle:takeTurn({ kind = "move", move = "SWORDS_DANCE" }) +check("SWORDS DANCE raises the user by two", + upBattle.stages.player.attack, 2) + +-- A double-hit move lands twice. +local hitBattle, _, hitWild = effectBattle({ "DOUBLE_KICK" }) +local hitBefore = hitWild.hp +hitBattle:takeTurn({ kind = "move", move = "DOUBLE_KICK" }) +check("double kick hit twice", (hitBefore - hitWild.hp) > 0, true) +check("hit count comes off the effect", + Effects.hitCount("EFFECT_DOUBLE_HIT"), 2) + +-- Recoil costs the user a quarter of what it dealt. +local recoilBattle, recoilPlayer, recoilWild = effectBattle({ "TAKE_DOWN" }) +local recoilHp = recoilPlayer.hp +recoilBattle:takeTurn({ kind = "move", move = "TAKE_DOWN" }) +check("recoil hurt the user", recoilPlayer.hp < recoilHp, true) + +-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), so +-- the emits the cart animates differently carry their own `anim`: none for +-- BattleCommand_Recoil (:5674-5687), ANIM_HIT_CONFUSION for a self-hit +-- (:624-632) and ANIM_BRN for the burn tick (core.asm:958-976). +do +local animBattle = effectBattle({ "TAKE_DOWN" }) +local animEvents = animBattle:takeTurn({ kind = "move", move = "TAKE_DOWN" }) +local hitAnim, recoilAnim, sawHit = "unset", "unset", false +for index, event in ipairs(animEvents) do + if event.kind == "damage" and event.side == "enemy" and not sawHit then + sawHit, hitAnim = true, event.anim + end + if event.kind == "message" and event.text + and event.text:find("hit with recoil", 1, true) then + recoilAnim = (animEvents[index - 1] or {}).anim + end +end +check("the move's own hit keeps the after-anim", hitAnim, nil) +check("recoil plays nothing", recoilAnim, false) + +local confBattle = effectBattle({ "TACKLE" }) +confBattle:confusionSelfHit(confBattle.player) +local confAnim, confSide +for _, event in ipairs(confBattle:takeEvents()) do + if event.kind == "damage" then + confAnim, confSide = event.anim, event.animSide + end +end +check("a confusion self-hit flickers instead", confAnim, "ANIM_HIT_CONFUSION") +check("...on its own side", confSide, "player") + +local brnBattle = effectBattle({ "TACKLE" }) +brnBattle.enemy.status = "burn" +local brnEvents = brnBattle:takeTurn({ kind = "move", move = "TACKLE" }) +local brnAnim +for index, event in ipairs(brnEvents) do + if event.kind == "message" and event.text + and event.text:find("hurt by its burn", 1, true) then + brnAnim = (brnEvents[index + 1] or {}).anim + end +end +check("the burn tick plays its status anim", brnAnim, "ANIM_BRN") +end + +-- Drain heals the user for half the damage. Asserted off the heal event +-- rather than the net HP, since the foe's answering hit also moves the total. +local drainBattle, drainPlayer = effectBattle({ "ABSORB" }) +drainPlayer.hp = 5 +local drainEvents = drainBattle:takeTurn({ kind = "move", move = "ABSORB" }) +local drainHealed = false +for _, event in ipairs(drainEvents) do + if event.kind == "heal" and event.side == "player" then drainHealed = true end +end +check("absorb healed the user", drainHealed, true) + +-- A charge move takes two turns and spends PP only on the first. +local flyBattle, flyPlayer, flyWild = effectBattle({ "FLY" }) +local flyHp = flyWild.hp +flyBattle:takeTurn({ kind = "move", move = "FLY" }) +check("turn one charges", flyPlayer.volatile.chargeMove, "FLY") +check("and deals nothing", flyWild.hp, flyHp) +check("PP spent once", flyPlayer.moves[1].pp, 9) +flyBattle:takeTurn({ kind = "move", move = "FLY" }) +check("turn two lands", flyWild.hp < flyHp, true) +check("and charges no more", flyPlayer.volatile.chargeMove, nil) +check("with no second PP", flyPlayer.moves[1].pp, 9) + +-- Substitute costs a quarter of max HP and then soaks the next hit. +local subBattle, subPlayer = effectBattle({ "SUBSTITUTE", "TACKLE" }) +local subMax = subPlayer.maxHp or subPlayer.stats.hp +subBattle:takeTurn({ kind = "move", move = "SUBSTITUTE" }) +check("substitute cost its quarter", + subPlayer.hp, subMax - Effects.substituteCost(subMax)) +-- The enemy attacked in the same turn, so the substitute is already down by +-- that hit -- which is the point: it is standing and the mon behind it is not +-- the one taking damage. +check("and stands", subPlayer.volatile.substitute > 0, true) +local behind = subPlayer.hp +subBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("the substitute took the hit, not the mon", subPlayer.hp, behind) + +-- BattleCommand_LockOn: the flag lands on the TARGET, and the next move aimed +-- at it skips the accuracy roll entirely and then clears the flag. maxRandom +-- is the roll that misses everything, so a hit here can only be the lock-on. +-- Scoped, because the file is already close to Lua's 200-locals-per-function +-- ceiling and these fixtures are wanted nowhere else. +do +local function lockOnBattle() + local battle_, player_, wild_ = newBattle({ random = maxRandom }) + player_.moves = { + { id = "LOCK_ON", pp = 5, maxPp = 5 }, + { id = "TAKE_DOWN", pp = 20, maxPp = 20 }, + } + return battle_, player_, wild_ +end + +local missBattle, _, missWild = lockOnBattle() +local missHp = missWild.hp +missBattle:takeTurn({ kind = "move", move = "TAKE_DOWN" }) +check("an 85% move misses on the top roll", missWild.hp, missHp) + +local lockBattle, _, lockWild = lockOnBattle() +lockBattle:takeTurn({ kind = "move", move = "LOCK_ON" }) +check("Lock-On marks the target, not the user", + lockBattle:volatile(lockWild).lockOn, true) +local lockHp = lockWild.hp +lockBattle:takeTurn({ kind = "move", move = "TAKE_DOWN" }) +check("...and the next move cannot miss", lockWild.hp < lockHp, true) +check("...after which the flag is spent", + lockBattle:volatile(lockWild).lockOn, nil) +end + +-- Seismic Toss deals the user's level, whatever the type chart says. +local tossBattle, tossPlayer, tossWild = effectBattle({ "SEISMIC_TOSS" }) +local tossBefore = tossWild.hp +tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" }) +check("seismic toss deals the level", + tossBefore - tossWild.hp, tossPlayer.level) + +-- --------------------------------------------------------------- held items + +local Ai = require("src.battle.gen2.Ai") + +DATA.items = DATA.items or {} +DATA.items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", + heldEffect = "HELD_LEFTOVERS", heldParameter = 10 } +DATA.items.BERRY = { id = "BERRY", name = "BERRY", + heldEffect = "HELD_BERRY", heldParameter = 10 } + +-- Driven directly rather than through a turn: a turn also lets the enemy +-- attack, and what is under test here is the end-of-turn item step itself. +local itemBattle, itemPlayer = effectBattle({ "TACKLE" }) +itemPlayer.item = "LEFTOVERS" +itemPlayer.hp = 5 +itemBattle:tickHeldItem(itemPlayer) +check("leftovers healed at the end of the turn", itemPlayer.hp > 5, true) +check("and is not consumed", itemPlayer.item, "LEFTOVERS") +itemPlayer.hp = itemPlayer.maxHp or itemPlayer.stats.hp +itemBattle:tickHeldItem(itemPlayer) +check("a full mon gets nothing", itemPlayer.hp, + itemPlayer.maxHp or itemPlayer.stats.hp) + +local berryBattle, berryPlayer = effectBattle({ "TACKLE" }) +berryPlayer.item = "BERRY" +berryPlayer.hp = 2 +berryBattle:tickHeldItem(berryPlayer) +check("the berry was eaten", berryPlayer.item, nil) +check("and healed", berryPlayer.hp > 2, true) + +-- ...but only below half. +local wholeBattle, wholePlayer = effectBattle({ "TACKLE" }) +wholePlayer.item = "BERRY" +wholeBattle:tickHeldItem(wholePlayer) +check("a healthy mon keeps its berry", wholePlayer.item, "BERRY") + +-- ------------------------------------------------------------------- AI + +-- The AI word is bytes 4 and 5 of TrainerClassAttributes, little-endian. +check("flags come off the attributes", + Ai.flagsOf({ 0, 0, 25, 211, 3, 68, 0, 0 }), 979) +check("a class with no attributes has no AI", Ai.flagsOf(nil), 0) +check("BASIC is bit 0", Ai.has(979, "BASIC"), true) +check("TYPES is bit 2", Ai.has(979, "TYPES"), false) +check("AGGRESSIVE is bit 6", Ai.has(979, "AGGRESSIVE"), true) + +-- With AI_TYPES on, a move the target is immune to is dismissed and a +-- super-effective one is preferred. +local aiChoice = Ai.choose({ + moves = { { id = "THUNDERBOLT", pp = 10 }, { id = "WATER_GUN", pp = 10 } }, + moveDef = function(id) + if id == "THUNDERBOLT" then + return { id = id, power = 95, type = "ELECTRIC", effect = "EFFECT_NORMAL_HIT" } + end + return MOVES.WATER_GUN + end, + attacker = { level = 20, stats = { attack = 50, specialAttack = 50 }, + types = { "WATER" } }, + defender = { hp = 60, stats = { defense = 40, specialDefense = 40 }, + types = { "GROUND" } }, + typeChart = { types = TYPES, matchups = MATCHUPS }, + attackerStages = Battle.newStages(), + defenderStages = Battle.newStages(), + flags = Ai.FLAGS.TYPES, + random = zeroRandom, +}) +check("AI skips a move the target is immune to", aiChoice, "WATER_GUN") + +-- With no flags at all it just picks, which is what a wild mon does. +check("no flags means no scoring", (Ai.choose({ + moves = { { id = "TACKLE", pp = 10 } }, + flags = 0, random = zeroRandom, +})), "TACKLE") + + +-- ---------------------------------------------------- the remaining effects +-- +-- Every expectation below is a literal from engine/battle/move_effects/, so a +-- failure names the file it disagrees with. + +local Effects = require("src.battle.gen2.Effects") + +-- BattleCommand_StartRain: five turns, and data/battle/weather_modifiers.asm +-- is the whole of what weather does to damage. +-- MORE_EFFECTIVE is 15, not 20 (constants/battle_constants.asm:22): the +-- weather boost is a half again, and only the type chart doubles. +check("rain boosts Water by a half", + Effects.weatherModifier("rain", "WATER"), 1.5) +check("rain halves Fire", Effects.weatherModifier("rain", "FIRE"), 0.5) +check("sun boosts Fire by a half", Effects.weatherModifier("sun", "FIRE"), 1.5) +check("sun halves Water", Effects.weatherModifier("sun", "WATER"), 0.5) +check("rain halves Solarbeam by EFFECT, not type", + Effects.weatherModifier("rain", "GRASS", "EFFECT_SOLARBEAM"), 0.5) +check("sandstorm changes no damage", + Effects.weatherModifier("sandstorm", "ROCK"), 1) +check("weather lasts five turns", Effects.WEATHER_TURNS, 5) + +-- HandleWeather's .SandstormDamage: an eighth, and three types are immune. +check("sandstorm chips an eighth", Effects.sandstormDamage(64), 8) +check("Rock is immune", Effects.sandstormHits({ "ROCK" }), false) +check("Ground is immune", Effects.sandstormHits({ "NORMAL", "GROUND" }), false) +check("Steel is immune", Effects.sandstormHits({ "STEEL" }), false) +check("Flying is not", Effects.sandstormHits({ "NORMAL", "FLYING" }), true) + +-- BattleCommand_Heal's .Weather ladder: a half normally, two thirds in sun, +-- a quarter in rain or sandstorm. +check("Morning Sun heals half in clear weather", + Effects.weatherHealFraction(nil), 1 / 2) +checkNear("...two thirds in sun", Effects.weatherHealFraction("sun"), 2 / 3, 0.001) +check("...a quarter in rain", Effects.weatherHealFraction("rain"), 1 / 4) + +-- ProtectChance halves for every consecutive use and gives up after eight. +check("first Protect always works", Effects.protectChance(0), 0xff) +check("the second is half as likely", Effects.protectChance(1), 0x7f) +check("the third a quarter", Effects.protectChance(2), 0x3f) +check("and the ninth never", Effects.protectChance(8), 0) +-- `.rand` compares the decremented byte against the threshold (protect.asm:50-57). +check("a 254 roll beats the first Protect", + Effects.protectSucceeds(0, function() return 253 end), true) +check("...and loses the second", + Effects.protectSucceeds(1, function() return 253 end), false) + +-- BattleCommand_OHKO: no effect on a higher-level target, and the level gap +-- is worth two accuracy points each. +check("OHKO fails upward", Effects.ohkoAccuracy(30, 10, 20), nil) +check("OHKO gains 2 per level", Effects.ohkoAccuracy(30, 20, 10), 50) +check("...capped at 255", Effects.ohkoAccuracy(200, 100, 1), 255) + +-- BattleCommand_StoreEnergy pays back double, capped at the 16-bit maximum. +check("Bide doubles what it took", Effects.bideDamage(50), 100) +check("...and caps", Effects.bideDamage(60000), 0xffff) + +-- Beat Up counts only the healthy, unstatused party members. +local beatParty = { + { species = "CYNDAQUIL", hp = 20 }, + { species = "PIDGEY", hp = 0 }, + { species = "RATTATA", hp = 15, status = "sleep" }, + { species = "SENTRET", hp = 9 }, +} +check("Beat Up skips the fainted and the statused", + #Effects.beatUpParty(beatParty, 1), 2) + +-- Metronome never picks its own exception list. +check("Metronome cannot pick Metronome", + Effects.METRONOME_EXCEPTIONS.METRONOME, true) +check("...nor Struggle", Effects.METRONOME_EXCEPTIONS.STRUGGLE, true) +local metroPick = Effects.metronomePick({ "METRONOME", "TACKLE" }, + { { id = "TACKLE" } }, function() return 0 end) +check("...nor a move the user already knows", metroPick, nil) +check("Metronome picks what is left", + Effects.metronomePick({ "METRONOME", "EMBER" }, {}, + function(n) return n - 1 end), "EMBER") + +-- ------------------------------------------------------- effects in a battle + +local function effectBattle(moves, enemyMoves) + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = moves + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = enemyMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + return b, player, wild +end + +-- Weather: set, ticked and ended by the turn loop, not by the move. +local rainBattle = effectBattle({ + { id = "RAIN_DANCE", pp = 5, maxPp = 5 }, + { id = "TACKLE", pp = 35, maxPp = 35 }, +}) +-- Both sides made unkillable, so the five turns below are five turns of +-- weather and not a faint cutting the end-of-turn block short. +rainBattle.enemy.hp, rainBattle.enemy.maxHp = 9999, 9999 +rainBattle.player.hp, rainBattle.player.maxHp = 9999, 9999 +rainBattle:takeTurn({ kind = "move", move = "RAIN_DANCE" }) +check("Rain Dance sets the weather", rainBattle.weather, "rain") +check("...for four more turns", rainBattle.weatherTurns, 4) +-- Four ordinary turns run the counter out; casting it again would just reset +-- it to five, which is the point of testing with a different move. +for _ = 1, 4 do rainBattle:takeTurn({ kind = "move", move = "TACKLE" }) end +check("...and then it stops", rainBattle.weather, nil) + +-- Spikes land on the side that will switch into them. +local spikeBattle = effectBattle({ { id = "SPIKES", pp = 20, maxPp = 20 } }) +spikeBattle:takeTurn({ kind = "move", move = "SPIKES" }) +check("Spikes land on the target's side", spikeBattle.spikes.enemy, true) +local spikeEvents = spikeBattle:takeTurn({ kind = "move", move = "SPIKES" }) +local failed = false +for _, e in ipairs(spikeEvents) do + if e.text == "But it failed!" then failed = true end +end +check("a second layer fails", failed, true) + +-- Perish Song counts four turns on both sides and then faints them. +local perishBattle = effectBattle({ { id = "PERISH_SONG", pp = 5, maxPp = 5 } }) +perishBattle:takeTurn({ kind = "move", move = "PERISH_SONG" }) +check("Perish Song counts down on the user too", + perishBattle:volatile(perishBattle.player).perish, 3) +check("...and on the target", perishBattle:volatile(perishBattle.enemy).perish, 3) + +-- Substitute's own counter is not Perish Song's: an Endure leaves 1 HP. +local endureBattle = effectBattle({ { id = "ENDURE", pp = 10, maxPp = 10 } }) +endureBattle:volatile(endureBattle.player).endure = true +endureBattle:dealDamage(endureBattle.enemy, endureBattle.player, 9999, {}) +check("Endure leaves one hit point", endureBattle.player.hp, 1) + +-- Rage raises Attack every time its user is hit. +local rageBattle = effectBattle({ { id = "RAGE", pp = 20, maxPp = 20 } }) +rageBattle:volatile(rageBattle.player).rage = true +rageBattle:dealDamage(rageBattle.enemy, rageBattle.player, 1, {}) +check("being hit while raging raises Attack", + rageBattle.stages.player.attack, 1) + +-- Disable and Encore both narrow what may be picked next turn. +local lockBattle = effectBattle({ + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "EMBER", pp = 25, maxPp = 25 }, +}) +lockBattle:volatile(lockBattle.player).encore = "TACKLE" +check("an encored mon has one move", #lockBattle:usableMoves(lockBattle.player), 1) +lockBattle:volatile(lockBattle.player).encore = nil +lockBattle:volatile(lockBattle.player).disabled = "TACKLE" +check("a disabled move drops out", + #lockBattle:usableMoves(lockBattle.player), 1) +check("...and it is the other one", + lockBattle:usableMoves(lockBattle.player)[1].id, "EMBER") + +-- Transform copies everything but HP. +local transformBattle = effectBattle({ { id = "TRANSFORM", pp = 10, maxPp = 10 } }) +local playerMaxHp = transformBattle.player.maxHp +transformBattle:takeTurn({ kind = "move", move = "TRANSFORM" }) +check("Transform takes the target's species", + transformBattle.player.species, "PIDGEY") +-- Everything but HP is copied, which is why a Transformed mon keeps its own +-- hit points and takes the target's Attack. +check("...and keeps its own max HP", transformBattle.player.maxHp, playerMaxHp) +check("...but takes the target's Attack", + transformBattle.player.stats.attack, transformBattle.enemy.stats.attack) +check("...and copies the moves at 5 PP", + transformBattle.player.moves[1].pp, 5) + +-- ------------------------------------------------------------- the AI layers + +-- TrainerClassAttributes rows are SEVEN bytes, so the AI word is bytes 4-5 of +-- {item1, item2, money, aiLo, aiHi, switchLo, switchHi}. +local falkner = { 0, 0, 25, 0xd3, 0x03, 0x44, 0x00 } +check("Falkner's AI word", Ai.flagsOf(falkner), 0x03d3) +check("...includes SMART", Ai.has(Ai.flagsOf(falkner), "SMART"), true) +check("...and SETUP", Ai.has(Ai.flagsOf(falkner), "SETUP"), true) +check("Falkner switches sometimes", + Ai.switchFlagsOf(falkner) % 8, Ai.SWITCH_FLAGS.SOMETIMES) + +-- AI_Smart_Substitute: dismissed below half HP. +check("Smart drops Substitute when hurt", + Ai.SMART.EFFECT_SUBSTITUTE({ random = zeroRandom }, + { enemyHp = 10, enemyMaxHp = 100 }), 10) +check("...and allows it at full", + Ai.SMART.EFFECT_SUBSTITUTE({ random = zeroRandom }, + { enemyHp = 100, enemyMaxHp = 100 }), 0) + +-- AI_Smart_Ohko: dismissed against a higher-level target. +check("Smart dismisses an OHKO it cannot land", + Ai.SMART.EFFECT_OHKO({ random = zeroRandom }, + { playerLevel = 30, enemyLevel = 20 }), 10) + +-- AI_Smart_BellyDrum: dismissed once Attack is already high. +check("Smart refuses a second Belly Drum", + Ai.SMART.EFFECT_BELLY_DRUM({ random = zeroRandom }, + { stages = { attack = 3 } }), 5) + +-- ------------------------------------------ AI_Smart, every effect handler +-- +-- The layer's chance() is `(random(100) + 1) <= percent`, which is the cart's +-- `call Random / cp N percent / ret c`: the LOW roll is the one that returns +-- without scoring. zeroRandom therefore takes every "ret c" and highRandom +-- takes none of them, so the two together walk both sides of every coin flip. +local function highRandom(n) return (n or 1) - 1 end +local lowRoll = { random = zeroRandom } +local highRoll = { random = highRandom } + +-- AI_Smart_EffectHandlers, in the jumptable's own order (scoring.asm). A name +-- in this list with no Ai.SMART entry is an effect the layer would silently +-- stop scoring, which never shows up as a crash in play. +local SMART_EFFECTS = { + "EFFECT_SLEEP", "EFFECT_LEECH_HIT", "EFFECT_SELFDESTRUCT", + "EFFECT_DREAM_EATER", "EFFECT_MIRROR_MOVE", "EFFECT_EVASION_UP", + "EFFECT_ALWAYS_HIT", "EFFECT_ACCURACY_DOWN", "EFFECT_RESET_STATS", + "EFFECT_BIDE", "EFFECT_FORCE_SWITCH", "EFFECT_HEAL", "EFFECT_TOXIC", + "EFFECT_LIGHT_SCREEN", "EFFECT_OHKO", "EFFECT_RAZOR_WIND", + "EFFECT_SUPER_FANG", "EFFECT_TRAP_TARGET", "EFFECT_UNUSED_2B", + "EFFECT_CONFUSE", "EFFECT_SP_DEF_UP_2", "EFFECT_REFLECT", "EFFECT_PARALYZE", + "EFFECT_SPEED_DOWN_HIT", "EFFECT_SUBSTITUTE", "EFFECT_HYPER_BEAM", + "EFFECT_RAGE", "EFFECT_MIMIC", "EFFECT_LEECH_SEED", "EFFECT_DISABLE", + "EFFECT_COUNTER", "EFFECT_ENCORE", "EFFECT_PAIN_SPLIT", "EFFECT_SNORE", + "EFFECT_CONVERSION2", "EFFECT_LOCK_ON", "EFFECT_DEFROST_OPPONENT", + "EFFECT_SLEEP_TALK", "EFFECT_DESTINY_BOND", "EFFECT_REVERSAL", + "EFFECT_SPITE", "EFFECT_HEAL_BELL", "EFFECT_PRIORITY_HIT", "EFFECT_THIEF", + "EFFECT_MEAN_LOOK", "EFFECT_NIGHTMARE", "EFFECT_FLAME_WHEEL", "EFFECT_CURSE", + "EFFECT_PROTECT", "EFFECT_FORESIGHT", "EFFECT_PERISH_SONG", + "EFFECT_SANDSTORM", "EFFECT_ENDURE", "EFFECT_ROLLOUT", "EFFECT_SWAGGER", + "EFFECT_FURY_CUTTER", "EFFECT_ATTRACT", "EFFECT_SAFEGUARD", + "EFFECT_MAGNITUDE", "EFFECT_BATON_PASS", "EFFECT_PURSUIT", + "EFFECT_RAPID_SPIN", "EFFECT_MORNING_SUN", "EFFECT_SYNTHESIS", + "EFFECT_MOONLIGHT", "EFFECT_HIDDEN_POWER", "EFFECT_RAIN_DANCE", + "EFFECT_SUNNY_DAY", "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_FLY", +} + +check("the AI_Smart jumptable is 80 entries long", #SMART_EFFECTS, 80) +local smartMissing = {} +for _, name in ipairs(SMART_EFFECTS) do + if type(Ai.SMART[name]) ~= "function" then + smartMissing[#smartMissing + 1] = name + end +end +check("...and every one of them has a handler", + table.concat(smartMissing, ","), "") + +-- Ai.choose adds the delta straight onto the score, so a handler that errors +-- or returns a nil on a state it cannot read would poison the whole pick. +local smartUnsafe = {} +for _, name in ipairs(SMART_EFFECTS) do + local handler = Ai.SMART[name] + if type(handler) == "function" then + for _, roll in ipairs({ zeroRandom, highRandom }) do + local ok, delta = pcall(handler, { random = roll }, {}) + if (not ok or type(delta) ~= "number") + and smartUnsafe[#smartUnsafe] ~= name then + smartUnsafe[#smartUnsafe + 1] = name + end + end + end +end +check("every handler survives an empty state", + table.concat(smartUnsafe, ","), "") + +-- The cart bodies that carry more than one label share one Lua function, so +-- they cannot drift apart under a later edit. +check("Morning Sun is AI_Smart_Heal's body", + Ai.SMART.EFFECT_MORNING_SUN, Ai.SMART.EFFECT_HEAL) +check("...and so is Synthesis", + Ai.SMART.EFFECT_SYNTHESIS, Ai.SMART.EFFECT_HEAL) +check("...and Moonlight", Ai.SMART.EFFECT_MOONLIGHT, Ai.SMART.EFFECT_HEAL) +check("Unused2B is AI_Smart_RazorWind's body", + Ai.SMART.EFFECT_UNUSED_2B, Ai.SMART.EFFECT_RAZOR_WIND) +check("Sleep Talk is AI_Smart_Snore's body", + Ai.SMART.EFFECT_SLEEP_TALK, Ai.SMART.EFFECT_SNORE) +check("Destiny Bond is AI_Smart_Reversal's body", + Ai.SMART.EFFECT_DESTINY_BOND, Ai.SMART.EFFECT_REVERSAL) +check("Baton Pass is AI_Smart_ForceSwitch's body", + Ai.SMART.EFFECT_BATON_PASS, Ai.SMART.EFFECT_FORCE_SWITCH) +check("Earthquake is AI_Smart_Magnitude's body", + Ai.SMART.EFFECT_EARTHQUAKE, Ai.SMART.EFFECT_MAGNITUDE) +check("Gust is AI_Smart_Twister's body", + Ai.SMART.EFFECT_GUST, Ai.SMART.EFFECT_TWISTER) +check("Swagger is AI_Smart_Attract's body", + Ai.SMART.EFFECT_SWAGGER, Ai.SMART.EFFECT_ATTRACT) + +-- AI_Smart_Toxic / AI_Smart_LeechSeed: AICheckPlayerHalfHP sets carry when the +-- player is ABOVE half and the routine is `ret c`, so the discouragement lands +-- BELOW half, not above it. +check("Toxic is left alone against a healthy target", + Ai.SMART.EFFECT_TOXIC(lowRoll, { playerHp = 60, playerMaxHp = 100 }), 0) +check("...and discouraged against a hurt one", + Ai.SMART.EFFECT_TOXIC(lowRoll, { playerHp = 40, playerMaxHp = 100 }), 1) + +-- AI_Smart_Selfdestruct: greatly discouraged above half, ignored at or below a +-- quarter, and 92% discouraged in between. +check("Selfdestruct is greatly discouraged at high HP", + Ai.SMART.EFFECT_SELFDESTRUCT(lowRoll, + { enemyHp = 60, enemyMaxHp = 100 }), 3) +check("...and left alone with nothing to lose", + Ai.SMART.EFFECT_SELFDESTRUCT(lowRoll, + { enemyHp = 20, enemyMaxHp = 100 }), 0) +check("...and discouraged again on the high roll in between", + Ai.SMART.EFFECT_SELFDESTRUCT(highRoll, + { enemyHp = 30, enemyMaxHp = 100 }), 3) +check("...but not on the low one", + Ai.SMART.EFFECT_SELFDESTRUCT(lowRoll, + { enemyHp = 30, enemyMaxHp = 100 }), 0) + +-- AI_Smart_MirrorMove: dismissed from AHEAD with nothing to copy, because a +-- faster enemy moves first and would copy nothing. +check("Mirror Move is dismissed from ahead with nothing to copy", + Ai.SMART.EFFECT_MIRROR_MOVE(lowRoll, { enemyFaster = true }), 10) +check("...and left alone from behind", + Ai.SMART.EFFECT_MIRROR_MOVE(lowRoll, { enemyFaster = false }), 0) +check("...ignores a boring last move", + Ai.SMART.EFFECT_MIRROR_MOVE(highRoll, + { playerLastMove = "TACKLE", enemyFaster = true }), 0) +check("...and encourages a UsefulMoves entry twice from ahead", + Ai.SMART.EFFECT_MIRROR_MOVE(highRoll, + { playerLastMove = "SURF", enemyFaster = true }), -2) +check("...but only once from behind", + Ai.SMART.EFFECT_MIRROR_MOVE(highRoll, + { playerLastMove = "SURF", enemyFaster = false }), -1) + +-- AI_Smart_AccuracyDown's `.hp_mismatch_2` falls INTO `.not_encouraged`, so the +-- +2 it just added can be cancelled by the Fury Cutter branch on the way out. +check("Accuracy Down cancels its own discouragement mid ramp", + Ai.SMART.EFFECT_ACCURACY_DOWN(lowRoll, + { playerHp = 10, playerMaxHp = 100, playerFuryCutter = 2 }), 0) +check("...and keeps it when nothing is ramping", + Ai.SMART.EFFECT_ACCURACY_DOWN(lowRoll, + { playerHp = 10, playerMaxHp = 100 }), 3) +check("...and greatly encourages against a poisoned full-HP target", + Ai.SMART.EFFECT_ACCURACY_DOWN(lowRoll, + { playerHp = 100, playerMaxHp = 100, enemyHp = 100, enemyMaxHp = 100, + playerToxic = true }), -2) + +-- AI_Smart_RazorWind's `.dismiss` is `add 6`, deliberately not +-- AIDiscourageMove's ten. +check("Razor Wind answers a shown Protect with six, not ten", + Ai.SMART.EFFECT_RAZOR_WIND(lowRoll, + { enemyHp = 100, enemyMaxHp = 100, + playerUsedEffects = { EFFECT_PROTECT = true } }), 6) +check("...drops itself while perishing", + Ai.SMART.EFFECT_RAZOR_WIND(lowRoll, + { enemyPerishCount = 2, enemyHp = 100, enemyMaxHp = 100 }), 1) +check("...and says nothing from full health", + Ai.SMART.EFFECT_RAZOR_WIND(lowRoll, + { enemyHp = 100, enemyMaxHp = 100 }), 0) +check("...but discourages itself when hurt", + Ai.SMART.EFFECT_RAZOR_WIND(highRoll, + { enemyHp = 30, enemyMaxHp = 100 }), 1) + +-- AI_Smart_Mimic: the three matchup bands, then the UsefulMoves tail. The +-- `.dismiss` path falls through into `.discourage`, so a slower enemy with +-- nothing to copy scores +1 rather than nothing. +check("Mimic is dismissed from ahead with nothing to copy", + Ai.SMART.EFFECT_MIMIC(lowRoll, { enemyFaster = true }), 10) +check("...and merely discouraged from behind", + Ai.SMART.EFFECT_MIMIC(lowRoll, { enemyFaster = false }), 1) +check("...refuses to spend a turn while hurt", + Ai.SMART.EFFECT_MIMIC(lowRoll, + { playerLastMove = "SURF", enemyHp = 40, enemyMaxHp = 100 }), 1) +check("...discourages copying a resisted move", + Ai.SMART.EFFECT_MIMIC(lowRoll, + { playerLastMove = "SURF", enemyHp = 100, enemyMaxHp = 100, + playerLastMoveMatchup = 5 }), 1) +check("...and stacks both encouragements on a useful super-effective one", + Ai.SMART.EFFECT_MIMIC(highRoll, + { playerLastMove = "SURF", enemyHp = 100, enemyMaxHp = 100, + playerLastMoveMatchup = 20 }), -2) +check("...but says nothing about a neutral boring one", + Ai.SMART.EFFECT_MIMIC(highRoll, + { playerLastMove = "TACKLE", enemyHp = 100, enemyMaxHp = 100, + playerLastMoveMatchup = 10 }), 0) + +-- AI_Smart_Disable: only worth it from ahead, and the "does my own move have +-- power" bail-out never fires for a stock 0-power Disable. +check("Disable answers a useful move from ahead", + Ai.SMART.EFFECT_DISABLE(highRoll, + { enemyFaster = true, playerLastMove = "SURF" }), -1) +check("...falls through to the discourage on a boring one", + Ai.SMART.EFFECT_DISABLE(highRoll, + { enemyFaster = true, playerLastMove = "TACKLE" }), 1) +check("...unless the move being scored has power of its own", + Ai.SMART.EFFECT_DISABLE(highRoll, + { enemyFaster = true, playerLastMove = "TACKLE" }, 10, 40), 0) +check("...and skips straight to it from behind", + Ai.SMART.EFFECT_DISABLE(highRoll, + { enemyFaster = false, playerLastMove = "SURF" }), 1) + +-- AI_Smart_Snore / AI_Smart_SleepTalk compare the counter against 1, so only +-- the LAST sleeping turn is discouraged. +check("Snore is greatly encouraged with sleep left", + Ai.SMART.EFFECT_SNORE(lowRoll, { enemySleepTurns = 4 }), -3) +check("...and greatly discouraged on the waking turn", + Ai.SMART.EFFECT_SNORE(lowRoll, { enemySleepTurns = 1 }), 3) + +-- AI_Smart_Spite goes by the drained move's remaining PP. +check("Spite is dismissed from ahead with nothing to drain", + Ai.SMART.EFFECT_SPITE(lowRoll, { enemyFaster = true }), 10) +check("...greatly encouraged against a nearly empty move", + Ai.SMART.EFFECT_SPITE(highRoll, + { playerLastMove = "SURF", playerLastMovePp = 3 }), -2) +check("...and discouraged against a full one", + Ai.SMART.EFFECT_SPITE(highRoll, + { playerLastMove = "SURF", playerLastMovePp = 20 }), 1) +check("...with no opinion when the move has left the list", + Ai.SMART.EFFECT_SPITE(highRoll, { playerLastMove = "SURF" }), 0) + +-- AI_Smart_HealBell: `.ok` is reached both by the `jr z` and by falling through +-- the `dec [hl]`, so sleep stacks on top of the first step of encouragement. +check("Heal Bell is dismissed with a clean party", + Ai.SMART.EFFECT_HEAL_BELL(lowRoll, { enemyPartyStatus = false }), 10) +check("...but not while the active mon is statused", + Ai.SMART.EFFECT_HEAL_BELL(lowRoll, + { enemyPartyStatus = false, enemyStatus = "burn" }), 0) +check("...one step for a burn", + Ai.SMART.EFFECT_HEAL_BELL(lowRoll, + { enemyPartyStatus = true, enemyStatus = "burn" }), -1) +check("...and three for a sleep it cannot wait out", + Ai.SMART.EFFECT_HEAL_BELL(highRoll, + { enemyPartyStatus = true, enemyStatus = "sleep" }), -3) + +-- AI_Smart_Curse splits on the ENEMY's own Ghost typing. +check("a Ghost Curse is dismissed when it would be suicide", + Ai.SMART.EFFECT_CURSE(lowRoll, + { enemyTypes = { "GHOST" }, enemyHp = 20, enemyMaxHp = 100 }), 10) +check("...and greatly encouraged against a fresh target", + Ai.SMART.EFFECT_CURSE(highRoll, + { enemyTypes = { "GHOST" }, enemyHp = 100, enemyMaxHp = 100, + playerTurns = 0 }), -2) +check("...but not after the target has moved", + Ai.SMART.EFFECT_CURSE(highRoll, + { enemyTypes = { "GHOST" }, enemyHp = 100, enemyMaxHp = 100, + playerTurns = 2 }), 0) +check("a physical Curse is greatly discouraged against a Ghost", + Ai.SMART.EFFECT_CURSE(highRoll, + { enemyTypes = { "NORMAL" }, enemyHp = 100, enemyMaxHp = 100, + playerTypes = { "GHOST" } }), 2) +check("...and encouraged against something it can punch", + Ai.SMART.EFFECT_CURSE(highRoll, + { enemyTypes = { "NORMAL" }, enemyHp = 100, enemyMaxHp = 100, + playerTypes = { "NORMAL" } }), -2) +check("...but not against a special attacker", + Ai.SMART.EFFECT_CURSE(highRoll, + { enemyTypes = { "NORMAL" }, enemyHp = 100, enemyMaxHp = 100, + playerTypes = { "FIRE" }, playerSpecialType = true }), 0) + +-- AI_Smart_MeanLook reads its OWN toxic bit, which is the cart's documented bug. +check("Mean Look is dismissed against the player's last mon", + Ai.SMART.EFFECT_MEAN_LOOK(lowRoll, + { enemyHp = 100, enemyMaxHp = 100, playerLastMon = true }), 10) +check("...and greatly encouraged when the AI itself is badly poisoned", + Ai.SMART.EFFECT_MEAN_LOOK(highRoll, + { enemyHp = 100, enemyMaxHp = 100, playerLastMon = false, + enemyToxic = true }), -3) +check("...left alone when the player has nothing effective", + Ai.SMART.EFFECT_MEAN_LOOK(highRoll, + { enemyHp = 100, enemyMaxHp = 100, playerLastMon = false, + playerMatchupScore = 11 }), 0) +check("...and discouraged when it does", + Ai.SMART.EFFECT_MEAN_LOOK(highRoll, + { enemyHp = 100, enemyMaxHp = 100, playerLastMon = false, + playerMatchupScore = 10 }), 1) + +-- AI_Smart_LockOn. The dismissal of Lock-On itself is the declarative half; +-- the cross-move half is Ai.lockOnPostPass, checked below. +check("Lock-On is dismissed once it has already landed", + Ai.SMART.EFFECT_LOCK_ON(lowRoll, { playerLockOn = true }), 10) +check("...discouraged when nearly dead", + Ai.SMART.EFFECT_LOCK_ON(lowRoll, { enemyHp = 20, enemyMaxHp = 100 }), 1) +check("...greatly encouraged against a raised evasion", + Ai.SMART.EFFECT_LOCK_ON(highRoll, + { enemyHp = 100, enemyMaxHp = 100, + playerStages = { evasion = 3 } }), -2) +check("...and discouraged when it has nothing shaky to aim", + Ai.SMART.EFFECT_LOCK_ON(highRoll, + { enemyHp = 100, enemyMaxHp = 100, + enemyInaccurateEffectiveMove = false }), 1) +check("...but not when it does", + Ai.SMART.EFFECT_LOCK_ON(highRoll, + { enemyHp = 100, enemyMaxHp = 100, + enemyInaccurateEffectiveMove = true }), 0) + +-- `.player_locked_on`: with the lock-on up, every move under `71 percent - 1` +-- ($b4) raw accuracy is encouraged twice over, and everything at or above it +-- is left exactly where it was. Scoped for the same reason as the Lock-On +-- engine fixtures above: locals are a finite resource in this file. +do +local LOCK_ON_DEFS = { + { id = "LOCK_ON", effect = "EFFECT_LOCK_ON", accuracyRaw = 0xff }, + { id = "FISSURE", effect = "EFFECT_OHKO", accuracyRaw = 0x4c }, + { id = "TACKLE", effect = "EFFECT_NORMAL_HIT", accuracyRaw = 0xf0 }, +} +local lockScores = Ai.lockOnPostPass({ 20, 20, 20 }, LOCK_ON_DEFS) +check("a shaky move is doubly encouraged once the lock-on is up", + lockScores[2], 18) +check("...an accurate one is left alone", lockScores[3], 20) +-- The loop is the scoring layer's, so it runs once per Lock-On in the list. +check("...and Lock-On's own slot is above the threshold", lockScores[1], 20) +local twoLockOns = Ai.lockOnPostPass({ 20, 20 }, { + { id = "LOCK_ON", effect = "EFFECT_LOCK_ON", accuracyRaw = 0xff }, + { id = "MIND_READER", effect = "EFFECT_LOCK_ON", accuracyRaw = 0x4c }, +}) +check("two Lock-Ons run the layer twice", twoLockOns[2], 16) +check("no Lock-On in the list means no post-pass", + Ai.lockOnPostPass({ 20 }, { LOCK_ON_DEFS[2] })[1], 20) + +-- And it is reached from Ai.choose, which is what makes it a scoring layer +-- rather than a model with no caller. +local lockChoice = Ai.choose({ + moves = { { id = "LOCK_ON", pp = 5 }, { id = "FISSURE", pp = 5 } }, + moveDef = function(id) return LOCK_ON_DEFS[id == "LOCK_ON" and 1 or 2] end, + flags = Ai.FLAGS.SMART, + smart = { playerLockOn = true, enemyHp = 100, enemyMaxHp = 100 }, + random = zeroRandom, +}) +check("the AI aims its shaky move once the lock-on has landed", + lockChoice, "FISSURE") +end + +-- AI_Smart_PerishSong: nothing on the bench and the countdown kills the AI too. +check("Perish Song is worth five points of discouragement alone", + Ai.SMART.EFFECT_PERISH_SONG(lowRoll, { enemyHasBench = false }), 5) +check("...is left alone while the AI is losing the matchup", + Ai.SMART.EFFECT_PERISH_SONG(highRoll, + { enemyHasBench = true, playerMatchupScore = 9 }), 0) +check("...and discouraged while it is winning", + Ai.SMART.EFFECT_PERISH_SONG(highRoll, + { enemyHasBench = true, playerMatchupScore = 10 }), 1) +check("...but encouraged against a player that cannot run", + Ai.SMART.EFFECT_PERISH_SONG(highRoll, + { enemyHasBench = true, playerTrapped = true }), -1) + +-- AI_Smart_Magnitude / AI_Smart_Earthquake only ever fire off a shown Dig. +check("Magnitude ignores a player that has not dug", + Ai.SMART.EFFECT_MAGNITUDE(highRoll, { playerLastMove = "TACKLE" }), 0) +check("...greatly encourages against a player underground", + Ai.SMART.EFFECT_MAGNITUDE(lowRoll, + { playerLastMove = "DIG", playerUnderground = true, + enemyFaster = true }), -2) +check("...but not from behind", + Ai.SMART.EFFECT_MAGNITUDE(lowRoll, + { playerLastMove = "DIG", playerUnderground = true, + enemyFaster = false }), 0) +check("...and predicts a second Dig only from behind", + Ai.SMART.EFFECT_MAGNITUDE(highRoll, + { playerLastMove = "DIG", enemyFaster = false }), -1) + +-- AI_Smart_RainDance / AI_Smart_SunnyDay read the type slots IN ORDER, so a +-- swapped pair scores differently. +check("Rain Dance is greatly discouraged against a Water type", + Ai.SMART.EFFECT_RAIN_DANCE(lowRoll, { playerTypes = { "WATER" } }), 3) +check("...and greatly encouraged against a Fire type", + Ai.SMART.EFFECT_RAIN_DANCE(lowRoll, + { playerTypes = { "FIRE" }, playerHp = 100, playerMaxHp = 100, + playerTurns = 0 }), -2) +check("...with Water/Fire reading as bad", + Ai.SMART.EFFECT_RAIN_DANCE(lowRoll, + { playerTypes = { "WATER", "FIRE" }, playerHp = 100, playerMaxHp = 100, + playerTurns = 0 }), 3) +check("...and Fire/Water as good", + Ai.SMART.EFFECT_RAIN_DANCE(lowRoll, + { playerTypes = { "FIRE", "WATER" }, playerHp = 100, playerMaxHp = 100, + playerTurns = 0 }), -2) +check("...encouraged when the enemy has a RainDanceMoves entry", + Ai.SMART.EFFECT_RAIN_DANCE(highRoll, + { playerTypes = { "NORMAL" }, playerHp = 100, playerMaxHp = 100, + enemyMoveIds = { SURF = true } }), -1) +check("...and greatly discouraged when it has none", + Ai.SMART.EFFECT_RAIN_DANCE(highRoll, + { playerTypes = { "NORMAL" }, playerHp = 100, playerMaxHp = 100, + enemyMoveIds = { TACKLE = true } }), 3) + +-- CART BUG, kept: SunnyDayMoves omits SOLARBEAM, so the AI never sets up the +-- sun for the one move that wants it most. +check("Sunny Day is not encouraged for Solar Beam", + Ai.SMART.EFFECT_SUNNY_DAY(highRoll, + { playerTypes = { "NORMAL" }, playerHp = 100, playerMaxHp = 100, + enemyMoveIds = { SOLARBEAM = true } }), 3) +check("...but is for Flamethrower", + Ai.SMART.EFFECT_SUNNY_DAY(highRoll, + { playerTypes = { "NORMAL" }, playerHp = 100, playerMaxHp = 100, + enemyMoveIds = { FLAMETHROWER = true } }), -1) + +-- AI_Smart_HiddenPower scores the DV-derived type and power, not the move +-- table's Normal / 1 power stub. +check("Hidden Power is encouraged at full power and neutral", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 70, hiddenPowerMatchup = 10 }), -1) +check("...and encouraged super-effective once past the power gate", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 60, hiddenPowerMatchup = 20 }), -1) +check("...with the power gate tested FIRST, as the cart orders it", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 31, hiddenPowerMatchup = 20 }), 1) +check("...discouraged under 50 power", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 40, hiddenPowerMatchup = 10 }), 1) +check("...discouraged when resisted", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 70, hiddenPowerMatchup = 5 }), 1) +check("...and neutral in between", + Ai.SMART.EFFECT_HIDDEN_POWER(lowRoll, + { hiddenPowerPower = 60, hiddenPowerMatchup = 10 }), 0) + +-- AI_Smart_MirrorCoat is AI_Smart_Counter's routine with the type test flipped. +check("Mirror Coat is discouraged against an all-physical player", + Ai.SMART.EFFECT_MIRROR_COAT(highRoll, { playerSpecialMoves = 0 }), 1) +check("...encouraged once three special moves have been shown", + Ai.SMART.EFFECT_MIRROR_COAT(highRoll, { playerSpecialMoves = 3 }), -1) +check("...and on a single one only when the last move was special too", + Ai.SMART.EFFECT_MIRROR_COAT(highRoll, + { playerSpecialMoves = 1, playerLastMove = "SURF", + playerLastMoveSpecial = true }, 10, 0, 90), -1) +check("...but not when the last move was physical", + Ai.SMART.EFFECT_MIRROR_COAT(highRoll, + { playerSpecialMoves = 1, playerLastMove = "TACKLE", + playerLastMoveSpecial = false }, 10, 0, 40), 0) + +-- AI_Smart_Twister / AI_Smart_Gust want SUBSTATUS_FLYING specifically, which is +-- why they read playerFlyingUp and not the combined playerFlying mask. +check("Gust greatly encourages against a player in the air", + Ai.SMART.EFFECT_GUST(lowRoll, + { playerLastMove = "FLY", playerFlyingUp = true, enemyFaster = true }), -2) +check("...ignores a player that never flew", + Ai.SMART.EFFECT_GUST(lowRoll, + { playerLastMove = "TACKLE", playerFlyingUp = true, + enemyFaster = true }), 0) +check("...and predicts a second Fly only from behind", + Ai.SMART.EFFECT_GUST(highRoll, + { playerLastMove = "FLY", enemyFaster = false }), -1) + +-- AI_Smart_Fly and AI_Smart_FutureSight both read the combined mask. +check("Fly is greatly encouraged against a vanished player from ahead", + Ai.SMART.EFFECT_FLY(lowRoll, + { playerFlying = true, enemyFaster = true }), -3) +check("...and says nothing from behind", + Ai.SMART.EFFECT_FLY(lowRoll, + { playerFlying = true, enemyFaster = false }), 0) +check("Future Sight lands as the player comes back down", + Ai.SMART.EFFECT_FUTURE_SIGHT(lowRoll, + { playerFlying = true, enemyFaster = true }), -2) + +-- AI_Smart_Solarbeam and AI_Smart_Thunder are the only weather readers. +check("Solar Beam is greatly encouraged in the sun", + Ai.SMART.EFFECT_SOLARBEAM(highRoll, { weather = "sun" }), -2) +check("...and greatly discouraged in the rain", + Ai.SMART.EFFECT_SOLARBEAM(highRoll, { weather = "rain" }), 2) +check("...with no opinion in a sandstorm", + Ai.SMART.EFFECT_SOLARBEAM(highRoll, { weather = "sandstorm" }), 0) +check("Thunder is discouraged in the sun", + Ai.SMART.EFFECT_THUNDER(highRoll, { weather = "sun" }), 1) +check("...and says nothing in the rain, as the cart does", + Ai.SMART.EFFECT_THUNDER(highRoll, { weather = "rain" }), 0) + +-- AI_Smart_Sandstorm's `.greatly_discourage` falls into `.discourage`, hence +2. +check("Sandstorm is greatly discouraged against an immune type", + Ai.SMART.EFFECT_SANDSTORM(lowRoll, { playerTypes = { "GROUND" } }), 2) +check("...including in the second slot", + Ai.SMART.EFFECT_SANDSTORM(lowRoll, { playerTypes = { "FIRE", "ROCK" } }), 2) +check("...discouraged once the chip cannot decide the fight", + Ai.SMART.EFFECT_SANDSTORM(lowRoll, + { playerTypes = { "NORMAL" }, playerHp = 30, playerMaxHp = 100 }), 1) +check("...and encouraged against a healthy target", + Ai.SMART.EFFECT_SANDSTORM(highRoll, + { playerTypes = { "NORMAL" }, playerHp = 100, playerMaxHp = 100 }), -1) + +-- The short ones, both branches each. +check("Thief is three dismissals", Ai.SMART.EFFECT_THIEF(lowRoll, {}), 30) +check("Bide wants full HP", + Ai.SMART.EFFECT_BIDE(highRoll, { enemyHp = 40, enemyMaxHp = 100 }), 1) +check("...and says nothing at full", + Ai.SMART.EFFECT_BIDE(highRoll, { enemyHp = 100, enemyMaxHp = 100 }), 0) +check("Super Fang wants something left to halve", + Ai.SMART.EFFECT_SUPER_FANG(lowRoll, + { playerHp = 20, playerMaxHp = 100 }), 1) +check("...and is happy at full", + Ai.SMART.EFFECT_SUPER_FANG(lowRoll, + { playerHp = 100, playerMaxHp = 100 }), 0) +check("Pain Split gives HP away against a hurt player", + Ai.SMART.EFFECT_PAIN_SPLIT(lowRoll, { enemyHp = 10, playerHp = 100 }), 0) +check("...and is worth it when the AI is the hurt one", + Ai.SMART.EFFECT_PAIN_SPLIT(lowRoll, { enemyHp = 60, playerHp = 100 }), 1) +check("Safeguard is discouraged against a dying target", + Ai.SMART.EFFECT_SAFEGUARD(highRoll, + { playerHp = 40, playerMaxHp = 100 }), 1) +check("...and left alone against a healthy one", + Ai.SMART.EFFECT_SAFEGUARD(highRoll, + { playerHp = 60, playerMaxHp = 100 }), 0) +check("Pursuit chases a target that is about to leave", + Ai.SMART.EFFECT_PURSUIT(highRoll, + { playerHp = 20, playerMaxHp = 100 }), -2) +check("...and is discouraged at full HP", + Ai.SMART.EFFECT_PURSUIT(highRoll, + { playerHp = 100, playerMaxHp = 100 }), 1) +check("Rapid Spin is only worth it with something to clear", + Ai.SMART.EFFECT_RAPID_SPIN(highRoll, { enemySpikes = true }), -2) +check("...and says nothing on a clean field", + Ai.SMART.EFFECT_RAPID_SPIN(highRoll, {}), 0) +check("Haze wants the board to have turned", + Ai.SMART.EFFECT_RESET_STATS(highRoll, { stages = { attack = -3 } }), -1) +check("...and is discouraged on an even board", + Ai.SMART.EFFECT_RESET_STATS(highRoll, {}), 1) +check("Amnesia wants a special attacker", + Ai.SMART.EFFECT_SP_DEF_UP_2(highRoll, + { enemyHp = 100, enemyMaxHp = 100, playerSpecialType = true }), -2) +check("...and stops at +4", + Ai.SMART.EFFECT_SP_DEF_UP_2(highRoll, + { enemyHp = 100, enemyMaxHp = 100, playerSpecialType = true, + stages = { specialDefense = 4 } }), 1) +check("Foresight answers a Ghost", + Ai.SMART.EFFECT_FORESIGHT(highRoll, { playerTypes = { "GHOST" } }), -2) +check("...and is discouraged otherwise", + Ai.SMART.EFFECT_FORESIGHT(highRoll, { playerTypes = { "NORMAL" } }), 1) +check("Stomp waits for a Minimize", + Ai.SMART.EFFECT_STOMP(highRoll, { playerMinimized = true }), -1) +check("...and is inert without one", + Ai.SMART.EFFECT_STOMP(highRoll, {}), 0) +check("Flame Wheel thaws its own user", + Ai.SMART.EFFECT_FLAME_WHEEL(lowRoll, { enemyStatus = "freeze" }), -5) +check("Defrost Opponent reads its OWN freeze, as the cart does", + Ai.SMART.EFFECT_DEFROST_OPPONENT(lowRoll, { enemyStatus = "freeze" }), -3) +check("Psych Up is discouraged while the AI is the one set up", + Ai.SMART.EFFECT_PSYCH_UP(highRoll, + { stages = { attack = 2 }, playerStages = {} }), 1) +check("...and reaches no further than nothing, the cart's dead branch", + Ai.SMART.EFFECT_PSYCH_UP(highRoll, + { stages = {}, playerStages = { attack = 2 } }), 0) +check("Conversion2 discourages once the player HAS moved, the cart's bug", + Ai.SMART.EFFECT_CONVERSION2(highRoll, { playerLastMove = "TACKLE" }), 1) +check("...and scores nothing on the turn-one garbage read", + Ai.SMART.EFFECT_CONVERSION2(highRoll, {}), 0) +check("Trap Target locks down a poisoned player", + Ai.SMART.EFFECT_TRAP_TARGET(highRoll, + { playerToxic = true, enemyHp = 100, enemyMaxHp = 100 }), -2) +check("...and is discouraged once the trap is already running", + Ai.SMART.EFFECT_TRAP_TARGET(highRoll, + { playerTrapped = true, enemyHp = 100, enemyMaxHp = 100 }), 1) + +-- AI_Smart_SpeedDownHit gates on MOVE_ANIM, so only Icy Wind reaches the body. +check("Icy Wind is greatly encouraged as an opener", + Ai.SMART.EFFECT_SPEED_DOWN_HIT({ random = highRandom, moveId = "ICY_WIND" }, + { enemyHp = 100, enemyMaxHp = 100, playerTurns = 0, + enemyFaster = false }), -2) +check("...and Bubble, which shares the effect, never is", + Ai.SMART.EFFECT_SPEED_DOWN_HIT({ random = highRandom, moveId = "BUBBLE" }, + { enemyHp = 100, enemyMaxHp = 100, playerTurns = 0, + enemyFaster = false }), 0) + +-- ------------------------------------------------- the state the layer reads + +-- HiddenPowerDamage takes the TOP bit of each DV for the power (not the low +-- bits the HP DV is built from) and the low two bits of Attack and Defense for +-- the type. +local hpBattle = newBattle() +local hpPower, hpType = hpBattle:hiddenPower({ dvs = perfect }) +check("perfect DVs give Hidden Power its maximum", hpPower, 70) +check("...and its last type", hpType, "DARK") +local zeroPower, zeroType = hpBattle:hiddenPower({ + dvs = { attack = 0, defense = 0, speed = 0, special = 0 } }) +check("blank DVs give the minimum", zeroPower, 31) +check("...and the first type", zeroType, "FIGHTING") +local lowPower, lowType = hpBattle:hiddenPower({ + dvs = { attack = 1, defense = 2, speed = 0, special = 3 } }) +check("low DVs move the type without moving the power", lowPower, 32) +check("...to the (Atk & 3) * 4 + (Def & 3) slot", lowType, "GHOST") +check("a mon with no DVs has no Hidden Power", + hpBattle:hiddenPower({}), nil) + +-- Battle:smartAiState feeds the layer. A wild battle is one mon a side, so +-- FindAliveEnemyMons and AICheckLastPlayerMon both come back empty-handed. +local aiState = hpBattle:smartAiState() +check("a lone wild mon has no bench", aiState.enemyHasBench, false) +check("...and the player's only mon is its last", aiState.playerLastMon, true) +check("an untouched board scores the matchup at the base", + aiState.playerMatchupScore, Ai.BASE_SWITCH_SCORE) +check("an awake mon has no sleep counter", aiState.enemySleepTurns, 0) +check("the player's types arrive in slot order", aiState.playerTypes[1], "FIRE") +check("...and a Fire type reads as special", aiState.playerSpecialType, true) +check("the enemy's move ids are a set", aiState.enemyMoveIds.TACKLE, true) +check("Hidden Power rides along", aiState.hiddenPowerPower, 70) +check("nothing the port does not model is faked", + aiState.playerMinimized == nil + and aiState.playerTrapped == nil and aiState.conversion2Matchup == nil, + true) +-- Lock-On IS modelled now, so the field is real: nil while the bit is down. +check("no lock-on means no flag", aiState.playerLockOn, nil) +hpBattle:volatile(hpBattle.player).lockOn = true +check("...and the enemy's own Lock-On shows through", + hpBattle:smartAiState().playerLockOn, true) +hpBattle:volatile(hpBattle.player).lockOn = nil + +-- One super-effective move shown by the player walks the matchup score down, +-- and both the switch layer and AI_Smart read that same number. +local matchupBattle = newBattle() +matchupBattle.player.moves = { { id = "WATER_GUN", pp = 25, maxPp = 25 } } +matchupBattle:volatile(matchupBattle.player).usedMoves = { "WATER_GUN" } +-- CheckPlayerMoveTypeMatchups reads the SPECIES types, so the swap has to go +-- through the species and not through the mon's own copy. +matchupBattle.enemy.species = "GEODUDE" +check("a shown super-effective move drops the matchup score", + matchupBattle:playerMatchupScore(), Ai.BASE_SWITCH_SCORE - 1) +check("...and Whirlwind stops discouraging itself", + Ai.SMART.EFFECT_FORCE_SWITCH(lowRoll, + { playerMatchupScore = matchupBattle:playerMatchupScore() }), 0) + +-- CheckAbleToSwitch: a perish count of one is the maximum urge to rotate. +local score, target = Ai.switchScore({ + bench = { { index = 2, healthy = true } }, + perishCount = 1, +}) +check("Perish Song forces the switch score to its maximum", score, 0x30) +check("...and names a bench slot", target, 2) +check("no bench, no switch", + (Ai.switchScore({ bench = {} })), 0) + +-- AI_TryItem: healing only below half, and only for the highest-level mon. +check("the AI drinks a potion when it is hurt", + Ai.chooseItem({ items = { "HYPER_POTION" }, isHighestLevel = true, + hp = 20, maxHp = 100 }), "HYPER_POTION") +check("...but not at full health", + Ai.chooseItem({ items = { "HYPER_POTION" }, isHighestLevel = true, + hp = 100, maxHp = 100 }), nil) +check("...and never for a lower-level mon", + Ai.chooseItem({ items = { "HYPER_POTION" }, isHighestLevel = false, + hp = 20, maxHp = 100 }), nil) +check("FULL_HEAL wants a status", + Ai.chooseItem({ items = { "FULL_HEAL" }, isHighestLevel = true, + hp = 100, maxHp = 100, status = "sleep" }), "FULL_HEAL") + +-- ------------------------------------------------ battle music and transition + +local BattleMusic = require("src.battle.gen2.BattleMusic") +local Transition = require("src.ui.gen2.BattleTransition") + +-- PlayBattleMusic (engine/battle/start_battle.asm). LANDMARK indices are +-- constants.lua's landmarkOrder: New Bark is 1, Pallet Town 46, Victory Road 87. +check("a Johto wild battle by day", + BattleMusic.battleSong({ landmark = 1, daytime = "DAY" }), + "Music_JohtoWildBattle") +check("...and its own theme at night", + BattleMusic.battleSong({ landmark = 1, daytime = "NITE" }), + "Music_JohtoWildBattleNight") +check("a Kanto wild battle has no night variant", + BattleMusic.battleSong({ landmark = 50, daytime = "NITE" }), + "Music_KantoWildBattle") +check("Victory Road counts as Johto again", + BattleMusic.battleSong({ landmark = 88, daytime = "DAY" }), + "Music_JohtoWildBattle") +check("Falkner gets the Johto gym theme", + BattleMusic.battleSong({ class = "FALKNER", landmark = 1 }), + "Music_JohtoGymBattle") +check("Brock gets Kanto's", + BattleMusic.battleSong({ class = "BROCK", landmark = 50 }), + "Music_KantoGymBattle") +check("the Champion has her own", + BattleMusic.battleSong({ class = "CHAMPION", landmark = 1 }), + "Music_ChampionBattle") +check("a Rocket grunt gets the Rocket theme", + BattleMusic.battleSong({ class = "GRUNTM", landmark = 1 }), + "Music_RocketBattle") +-- The cart's own bug: only GRUNTM/GRUNTF are tested, so an EXECUTIVE fights to +-- the ordinary trainer theme. +check("...but an executive does not", + BattleMusic.battleSong({ class = "EXECUTIVEM", landmark = 1 }), + "Music_JohtoTrainerBattle") +check("a Kanto trainer gets Kanto's trainer theme", + BattleMusic.battleSong({ class = "YOUNGSTER", landmark = 50 }), + "Music_KantoTrainerBattle") + +local RIVAL2_MEMBERS = { + "RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", + "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE", +} +check("RIVAL1 is always the rival theme", + BattleMusic.battleSong({ class = "RIVAL1", landmark = 1 }), + "Music_RivalBattle") +check("RIVAL2's first battle too", + BattleMusic.battleSong({ class = "RIVAL2", member = "RIVAL2_1_TOTODILE", + members = RIVAL2_MEMBERS, landmark = 80 }), "Music_RivalBattle") +check("...but from RIVAL2_2 on it is the Champion's", + BattleMusic.battleSong({ class = "RIVAL2", member = "RIVAL2_2_CHIKORITA", + members = RIVAL2_MEMBERS, landmark = 80 }), "Music_ChampionBattle") + +check("a wild win plays the wild jingle", + BattleMusic.victorySong({}), "Music_WildPokemonVictory") +-- PlayVictoryMusic's `.lost` path: no participant left standing means no +-- PlayMusic call at all. +check("...unless every participant fainted", + BattleMusic.victorySong({ participantsFainted = true }), nil) +check("a gym leader's defeat plays the gym jingle", + BattleMusic.victorySong({ class = "FALKNER" }), "Music_GymLeaderVictory") +check("and the Champion's does too (IsGymLeader lists her)", + BattleMusic.victorySong({ class = "CHAMPION" }), "Music_GymLeaderVictory") +check("an ordinary trainer gets the trainer jingle", + BattleMusic.victorySong({ class = "YOUNGSTER" }), "Music_TrainerVictory") + +-- StartTrainerBattle_DetermineWhichAnimation's two bits. +check("outdoors against something weaker: the spin", + Transition.pick({ environment = "TOWN", playerLevel = 12, enemyLevel = 4 }), + "spin") +check("outdoors against something stronger: the speckle", + Transition.pick({ environment = "ROUTE", playerLevel = 5, enemyLevel = 30 }), + "speckle") +check("in a cave against something weaker: the sine wave", + Transition.pick({ environment = "CAVE", playerLevel = 12, enemyLevel = 4 }), + "sine") +check("in a cave against something stronger: the zoom", + Transition.pick({ environment = "DUNGEON", playerLevel = 5, enemyLevel = 30 }), + "zoom") +-- `ld a, [wBattleMonLevel] / add 3 / cp [hl] / jr nc, .not_stronger`: three +-- levels of slack, and the boundary belongs to the player. +check("three levels up is still not stronger", + Transition.pick({ environment = "TOWN", playerLevel = 10, enemyLevel = 13 }), + "spin") +check("four is", + Transition.pick({ environment = "TOWN", playerLevel = 10, enemyLevel = 14 }), + "speckle") + +-- The flash table's extremes are solid black and solid white; the identity +-- row is no veil at all. +checkNear("3,3,3,3 is solid black", + Transition.flashVeil({ 3, 3, 3, 3 }), 1, 0.001) +checkNear("0,0,0,0 is solid white", + Transition.flashVeil({ 0, 0, 0, 0 }), -1, 0.001) +checkNear("3,2,1,0 is the identity", + Transition.flashVeil({ 3, 2, 1, 0 }), 0, 0.001) +check("the flash runs 72 frames (12 palettes x 2 x 3 passes)", + Transition.FLASH_FRAMES, 72) + +-- The twenty spin steps must black out the whole 20x18 tilemap between them, +-- which is the point of the wedge table. +local spun = {} +for _, step in ipairs(Transition.SPIN_STEPS) do + Transition.spinStep(spun, step) +end +local spunCount = 0 +for _ in pairs(spun) do spunCount = spunCount + 1 end +check("the spin covers every tile", spunCount, 20 * 18) + +-- The zoom's last box is the whole screen. +local zoomed = {} +for _, box in ipairs(Transition.ZOOM_BOXES) do + Transition.zoomStep(zoomed, box) +end +local zoomCount = 0 +for _ in pairs(zoomed) do zoomCount = zoomCount + 1 end +check("the zoom ends on the whole screen", zoomCount, 20 * 18) +check("...and starts on a 4x2 box in the middle", (function() + local first = {} + Transition.zoomStep(first, Transition.ZOOM_BOXES[1]) + local n = 0 + for _ in pairs(first) do n = n + 1 end + return n +end)(), 8) + +-- The Poke Ball is only stamped for a trainer, and it is 16 rows tall out of +-- hlcoord 2, 1. +local ball = Transition.pokeballCells() +-- 112 set bits across the sixteen bigdw rows of .PokeBallTransition. +check("the Poke Ball overlay is 112 tiles", #ball, 112) +check("...and every one of them is on screen", (function() + for _, cell in ipairs(ball) do + if cell[1] < 0 or cell[1] >= 20 or cell[2] < 0 or cell[2] >= 18 then + return false + end + end + return true +end)(), true) + +-- The sine outro's amplitude grows by the frame index and stops at $60. +local sine = Transition.sineFrames() +check("the sine outro runs 15 frames", #sine, 15) +check("...starting flat", sine[1][0], 0) +check("...and ending well past a tile", math.abs(sine[#sine][8]) > 8, true) + +-- ----------------------------------------------------------------- happiness +-- +-- data/events/happiness_changes.asm event by event. Every row is asserted at +-- all three tiers, because the tiers are the part a paraphrase gets wrong: +-- the three columns are NOT "small, smaller, smallest" -- the four penalties +-- get HARSHER in the third column while every bonus shrinks. + +local Happiness = require("src.core.gen2.Happiness") + +check("the enum is one based (HAPPINESS_GAINLEVEL is 01)", + Happiness.EVENT.GAINLEVEL, 1) +check("...and its last row is HAPPINESS_GROOMING at 12", + Happiness.EVENT.GROOMING, 18) +check("every HAPPINESS_* has a row", #Happiness.CHANGES, + Happiness.NUM_EVENTS) +check("the last row did not fall off the end", + Happiness.CHANGES[18] ~= nil, true) + +-- HAPPINESS_THRESHOLD_1 is 100 and HAPPINESS_THRESHOLD_2 is 200, and the cart +-- compares with `cp` + `jr c`, so each boundary belongs to the tier ABOVE it. +check("99 is tier 1", Happiness.tier(99), 1) +check("100 is tier 2", Happiness.tier(100), 2) +check("199 is tier 2", Happiness.tier(199), 2) +check("200 is tier 3", Happiness.tier(200), 3) +check("0 is tier 1", Happiness.tier(0), 1) +check("255 is tier 3", Happiness.tier(255), 3) + +-- The whole table, transcribed independently of the module so a typo in either +-- copy shows up as a disagreement rather than as agreement with itself. +local HAPPINESS_TABLE = { + { "GAINLEVEL", 5, 3, 2 }, + { "USEDITEM", 5, 3, 2 }, + { "USEDXITEM", 1, 1, 0 }, + { "GYMBATTLE", 3, 2, 1 }, + { "LEARNMOVE", 1, 1, 0 }, + { "FAINTED", -1, -1, -1 }, + { "POISONFAINT", -5, -5, -10 }, + { "BEATENBYSTRONGFOE", -5, -5, -10 }, + { "OLDERCUT1", 1, 1, 1 }, + { "OLDERCUT2", 3, 3, 1 }, + { "OLDERCUT3", 5, 5, 2 }, + { "YOUNGCUT1", 1, 1, 1 }, + { "YOUNGCUT2", 3, 3, 1 }, + { "YOUNGCUT3", 10, 10, 4 }, + { "BITTERPOWDER", -5, -5, -10 }, + { "ENERGYROOT", -10, -10, -15 }, + { "REVIVALHERB", -15, -15, -20 }, + { "GROOMING", 3, 3, 1 }, +} +-- One value per tier that is safely inside it, so `delta` reads the column and +-- not a boundary. +local TIER_SAMPLE = { 50, 150, 250 } +for index, row in ipairs(HAPPINESS_TABLE) do + local name = row[1] + check("HAPPINESS_" .. name .. " is row " .. index, + Happiness.EVENT[name], index) + for tier = 1, 3 do + check(("HAPPINESS_%s tier %d"):format(name, tier), + Happiness.delta(name, TIER_SAMPLE[tier]), row[tier + 1]) + end +end + +-- The boundaries themselves, on the one row where all three columns differ. +check("GAINLEVEL at 99 still pays 5", Happiness.delta("GAINLEVEL", 99), 5) +check("GAINLEVEL at 100 drops to 3", Happiness.delta("GAINLEVEL", 100), 3) +check("GAINLEVEL at 199 still pays 3", Happiness.delta("GAINLEVEL", 199), 3) +check("GAINLEVEL at 200 drops to 2", Happiness.delta("GAINLEVEL", 200), 2) +-- ...and on the row where the third column is WORSE, not better. +check("POISONFAINT at 199 costs 5", Happiness.delta("POISONFAINT", 199), -5) +check("POISONFAINT at 200 costs 10", Happiness.delta("POISONFAINT", 200), -10) + +-- Either spelling of the event resolves. +check("the full constant name resolves", + Happiness.delta("HAPPINESS_GROOMING", 50), 3) +check("the raw index resolves", Happiness.delta(18, 50), 3) +check("an unknown event moves nothing", Happiness.delta("NOT_A_THING", 50), nil) + +-- The carry clamps. Both edges are reachable in play, so both are asserted on +-- a mon rather than on the table. +local happyMon = { happiness = 254 } +check("a bonus that overflows lands on 255", + Happiness.change(happyMon, "YOUNGCUT3"), 255) +happyMon.happiness = 255 +check("...and 255 does not wrap", + Happiness.change(happyMon, "GROOMING"), 255) +local sadMon = { happiness = 3 } +check("a penalty that underflows lands on 0", + Happiness.change(sadMon, "REVIVALHERB"), 0) +sadMon.happiness = 0 +check("...and 0 does not wrap", Happiness.change(sadMon, "FAINTED"), 0) + +-- ChangeHappiness reads the tier off the value BEFORE the change, so one call +-- can never straddle two columns. +local straddle = { happiness = 98 } +Happiness.change(straddle, "GAINLEVEL") +check("the tier is read before the step, not after", straddle.happiness, 103) + +-- `cp EGG / ret z`: an egg's happiness byte is its hatch counter, and touching +-- it would hand the player a hatchling early. +local egg = { happiness = 40, isEgg = true } +check("an egg is refused", Happiness.change(egg, "GAINLEVEL"), nil) +check("...and its byte is untouched", egg.happiness, 40) + +-- StepHappiness: called on the wrap of a 256-step counter, and only pays out +-- on every SECOND call, so the visible period is 512 footfalls. +local walker = { party = { { happiness = 100 }, { happiness = 100 } }, + stepCount = 0 } +check("the first step cycle pays nothing", Happiness.stepCycle(walker), false) +check("...and the party has not moved", walker.party[1].happiness, 100) +check("the second step cycle pays", Happiness.stepCycle(walker), true) +check("...one point, to every mon", walker.party[1].happiness, 101) +check("...including the last", walker.party[2].happiness, 101) +check("the third pays nothing again", Happiness.stepCycle(walker), false) + +-- Walked for real through the counter Breeding.step owns: 512 footfalls, one +-- point. Only the step that takes the counter back to 0 calls StepHappiness. +local Breeding = require("src.core.gen2.Breeding") +local hiker = { party = { { happiness = 70 } }, stepCount = 0 } +local paid = 0 +for _ = 1, 1024 do + Breeding.step(DATA, hiker) + if Happiness.step(hiker) then paid = paid + 1 end +end +check("1024 steps pay exactly twice", paid, 2) +check("...for two points of happiness", hiker.party[1].happiness, 72) + +-- An egg in the party is skipped by the walk as well. +local eggWalker = { party = { { happiness = 10, isEgg = true }, + { happiness = 10 } } } +Happiness.stepCycle(eggWalker) +Happiness.stepCycle(eggWalker) +check("the egg slot is skipped", eggWalker.party[1].happiness, 10) +check("...and the mon beside it is not", eggWalker.party[2].happiness, 11) + +-- GetFirstPokemonHappiness skips leading eggs; the rater's two `ifless` +-- boundaries are 50 and 150. +local raterParty = { { happiness = 200, isEgg = true }, { happiness = 149 } } +local first, slot = Happiness.firstMon(raterParty) +check("the rater reads the first non-egg", first, raterParty[2]) +check("...at its real slot", slot, 2) +check("49 is unhappy", Happiness.raterBand(49), "unhappy") +check("50 is kinda happy", Happiness.raterBand(50), "kinda") +check("149 is kinda happy", Happiness.raterBand(149), "kinda") +check("150 adores you", Happiness.raterBand(150), "happy") + +-- BASE_HAPPINESS 70, FRIEND_BALL_HAPPINESS 200, HAPPINESS_TO_EVOLVE 220. +check("a new mon starts on BASE_HAPPINESS", Happiness.forNewMon(), 70) +check("a FRIEND BALL capture starts on 200", + Happiness.forNewMon({ ball = "FRIEND_BALL" }), 200) +check("Mon.new agrees with BASE_HAPPINESS", + Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }).happiness, Happiness.BASE) +check("the evolution threshold is 220", Happiness.TO_EVOLVE, 220) + +-- ------------------------------------------------- happiness inside a battle + +-- The level-up award, from the exp path. One call however many levels the +-- mon jumped, because ChangeHappiness sits outside the level loop. +local levelBattle, levelPlayer, levelWild = newBattle() +levelPlayer.happiness = 70 +-- One point short of level 11, so the Pidgey's 39 exp carries it over. +levelPlayer.experience = + Mon.experienceForLevel(GROWTH.GROWTH_MEDIUM_SLOW, 11) - 1 +levelWild.hp = 1 +levelBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("the mon actually levelled", levelPlayer.level, 11) +check("a level up pays HAPPINESS_GAINLEVEL", levelPlayer.happiness, 75) + +-- Exp that does not cross a level pays nothing. +local noLevelBattle, noLevelPlayer, noLevelWild = newBattle() +noLevelPlayer.happiness = 70 +noLevelWild.hp = 1 +noLevelBattle:takeTurn({ kind = "move", move = "TACKLE" }) +check("exp without a level pays nothing", noLevelPlayer.happiness, 70) + +-- A faint against an ordinary foe is HAPPINESS_FAINTED (-1); the same faint +-- against a foe thirty levels up is HAPPINESS_BEATENBYSTRONGFOE (-5). +local faintBattle, faintPlayer = newBattle() +faintPlayer.happiness = 70 +faintBattle.player.hp = 0 +faintBattle:resolveFaints() +check("a plain faint costs one point", faintPlayer.happiness, 69) + +local stompBattle, stompPlayer = newBattle() +stompPlayer.happiness = 70 +stompBattle.enemy.level = stompPlayer.level + 30 +stompBattle.player.hp = 0 +stompBattle:resolveFaints() +check("a much stronger foe costs five", stompPlayer.happiness, 65) + +-- ...and one level short of thirty is still a plain faint (`jr c` keeps +-- HAPPINESS_FAINTED while the foe is BELOW yourLevel + 30). +local nearBattle, nearPlayer = newBattle() +nearPlayer.happiness = 70 +nearBattle.enemy.level = nearPlayer.level + 29 +nearBattle.player.hp = 0 +nearBattle:resolveFaints() +check("twenty-nine levels up is still a plain faint", nearPlayer.happiness, 69) + +-- IsGymLeader's list is one ROM array with the Kanto leaders falling through, +-- so all twenty-two classes match it while only eight match IsKantoGymLeader. +check("FALKNER is a gym leader", Battle.isGymLeader("FALKNER"), true) +check("BROCK is one too, through the fallthrough", + Battle.isGymLeader("BROCK"), true) +check("so is RED", Battle.isGymLeader("RED"), true) +check("YOUNGSTER is not", Battle.isGymLeader("YOUNGSTER"), false) +check("BROCK is a KANTO gym leader", Battle.isKantoGymLeader("BROCK"), true) +check("FALKNER is not", Battle.isKantoGymLeader("FALKNER"), false) + +-- The Gym Leader award lands at battle START, on every mon still standing. +local gymParty = { + Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }), + Mon.new(DATA, "TOTODILE", 10, { dvs = perfect }), + Mon.new(DATA, "PIDGEY", 10, { dvs = perfect }), +} +gymParty[1].happiness, gymParty[2].happiness = 70, 150 +gymParty[3].happiness, gymParty[3].hp = 70, 0 +Battle.new({ + data = DATA, party = gymParty, + trainer = { class = "FALKNER", name = "FALKNER", + party = { Mon.new(DATA, "PIDGEY", 9, { dvs = perfect }) } }, + random = zeroRandom, +}) +check("a gym leader pays tier 1 three points", gymParty[1].happiness, 73) +check("...and tier 2 only two", gymParty[2].happiness, 152) +check("...and nothing to a fainted mon", gymParty[3].happiness, 70) + +local nonGymParty = { Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) } +nonGymParty[1].happiness = 70 +Battle.new({ + data = DATA, party = nonGymParty, + trainer = { class = "YOUNGSTER", name = "JOEY", + party = { Mon.new(DATA, "PIDGEY", 9, { dvs = perfect }) } }, + random = zeroRandom, +}) +check("an ordinary trainer pays nothing", nonGymParty[1].happiness, 70) + +-- XItemEffect awards to whoever is OUT, and only for the four X items. +local xBattle, xPlayer = newBattle() +xPlayer.happiness = 70 +xBattle:takeTurn({ kind = "item", item = "X_ATTACK" }) +check("an X item pays one point", xPlayer.happiness, 71) +xBattle.over = false +xBattle:takeTurn({ kind = "item", item = "POTION" }) +check("a POTION pays nothing", xPlayer.happiness, 71) +-- The third tier pays zero, which is a real row and not a missing one. +check("an X item pays nothing at 200", Happiness.delta("USEDXITEM", 200), 0) + +-- ---------------------------------------------------------------- fleeing +-- +-- data/wild/flee_mons.asm through TryEnemyFlee. AlwaysFleeMons short-circuits +-- before the random byte, which is why a roaming beast never gets a turn. + +local Roamers = require("src.core.gen2.Roamers") + +check("RAIKOU always flees", Roamers.ALWAYS_FLEE.RAIKOU, true) +check("ENTEI always flees", Roamers.ALWAYS_FLEE.ENTEI, true) +check("SUICUNE always flees", Roamers.ALWAYS_FLEE.SUICUNE, true) +check("CUBONE often flees", Roamers.OFTEN_FLEE.CUBONE, true) +check("MAGNEMITE sometimes flees", Roamers.SOMETIMES_FLEE.MAGNEMITE, true) +check("PIDGEY never flees", Roamers.SOMETIMES_FLEE.PIDGEY, nil) + +local function fleeBattle(species, random) + local runner = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + runner.species = species + runner.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local mine = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mine.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Battle.new({ data = DATA, party = { mine }, wild = runner, + random = random or zeroRandom }), runner +end + +-- maxRandom puts the byte at 255, past both thresholds, so ONLY the always +-- list can flee. +local alwaysB = fleeBattle("RAIKOU", maxRandom) +check("an always-flee mon goes before its own turn", + alwaysB:tryEnemyFlee(), true) +check("...ending the battle", alwaysB.over, true) +check("...as a flee, not a win", alwaysB.outcome, "fled") + +local oftenHigh = fleeBattle("CUBONE", maxRandom) +check("an often-flee mon stays on a high roll", + oftenHigh:tryEnemyFlee(), false) +-- 127 is under 50 percent + 1 (128) but over 10 percent + 1 (26). +local oftenLow = fleeBattle("CUBONE", function() return 127 end) +check("...and goes at 127", oftenLow:tryEnemyFlee(), true) +local sometimesMid = fleeBattle("MAGNEMITE", function() return 127 end) +check("a sometimes-flee mon stays at 127", sometimesMid:tryEnemyFlee(), false) +local sometimesLow = fleeBattle("MAGNEMITE", function() return 25 end) +check("...and goes at 25", sometimesLow:tryEnemyFlee(), true) +local neverB = fleeBattle("PIDGEY", zeroRandom) +check("a mon on no list never goes", neverB:tryEnemyFlee(), false) + +-- Frozen or asleep pins it, whatever the list says. +local frozen = fleeBattle("RAIKOU", zeroRandom) +frozen.enemy.status = "freeze" +check("a frozen beast cannot flee", frozen:tryEnemyFlee(), false) +frozen.enemy.status = "sleep" +check("nor can a sleeping one", frozen:tryEnemyFlee(), false) + +-- A trainer's mon never flees, whatever it is. +local trainerRunner = Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }) +trainerRunner.species = "RAIKOU" +trainerRunner.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } +local trainerFlee = Battle.new({ + data = DATA, party = { Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) }, + trainer = { class = "FALKNER", name = "FALKNER", party = { trainerRunner } }, + random = zeroRandom, +}) +check("a trainer's mon never flees", trainerFlee:tryEnemyFlee(), false) + +-- The flee lands inside a real turn: the beast goes before it ever attacks, +-- and the player's mon takes nothing. +local turnFlee, beast = fleeBattle("SUICUNE", zeroRandom) +beast.stats.speed = 999 +local mineHp = turnFlee.player.hp +turnFlee:takeTurn({ kind = "move", move = "TACKLE" }) +check("the beast fled inside the turn", turnFlee.outcome, "fled") +check("...before it could attack", turnFlee.player.hp, mineHp) + +-- SpikesDamage runs on EVERY send-out (core.asm), so a trainer's faint +-- replacement walks into the layer its predecessor died on; a Flying-type +-- replacement still takes nothing. (A closure, not a do-block: the main +-- chunk is at Lua 5.1's 200-local ceiling.) +;(function() + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local downed = Mon.new(DATA, "GEODUDE", 20, { dvs = perfect }) + local ground = Mon.new(DATA, "GEODUDE", 20, { dvs = perfect }) + local flyer = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + local b = Battle.new({ data = DATA, party = { player }, + trainer = { name = "TRAINER", party = { downed, ground, flyer } }, + random = zeroRandom }) + b.spikes.enemy = true + downed.hp = 0 + b:resolveFaints() + check("the faint replacement is sent in", b.enemy, ground) + check("and Spikes hurt it on the way in", + ground.hp, ground.maxHp - math.floor(ground.maxHp / 8)) + ground.hp = 0 + b:resolveFaints() + check("a Flying replacement is sent in", b.enemy, flyer) + check("and takes nothing from Spikes", flyer.hp, flyer.maxHp) +end)() + +-- BattleCommand_Heal splits on the MOVE, not the effect: REST takes GetMaxHP +-- and writes REST_SLEEP_TURNS + 1 (effect_commands.asm:6007-6027, :6043). +;(function() + local function healBattle(id, pp) + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = id, pp = pp, maxPp = pp } } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "GROWL", pp = 40, maxPp = 40 } } + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + return b, player + end + local b, player = healBattle("REST", 10) + player.hp = math.floor(player.maxHp / 2) + player.status = "burn" + b:takeTurn({ kind = "move", move = "REST" }) + check("Rest fills the bar", player.hp, player.maxHp) + check("and puts the user to sleep", player.status, "sleep") + check("for REST_SLEEP_TURNS + 1", player.statusTurns, 3) + + local b2, player2 = healBattle("RECOVER", 20) + player2.hp = 1 + b2:takeTurn({ kind = "move", move = "RECOVER" }) + check("Recover heals a half", player2.hp, + math.min(player2.maxHp, 1 + math.floor(player2.maxHp / 2))) + check("and leaves the status alone", player2.status, nil) +end)() + +-- ProtectChance rolls a real byte even when no random was injected +-- (engine/battle/move_effects/protect.asm:50-57). +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "PROTECT", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp, wild.maxHp = 9999, 9999 + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = function(n) return n - 1 end }) + b:takeEvents() + check("the first Protect holds", + said(b:takeTurn({ kind = "move", move = "PROTECT" }), + "CYNDAQUIL protected itself!"), true) + -- A different move zeroes the count, so the next Protect is back to $ff. + b:takeTurn({ kind = "move", move = "TACKLE" }) + check("an ordinary move resets the count", + said(b:takeTurn({ kind = "move", move = "PROTECT" }), + "CYNDAQUIL protected itself!"), true) + check("and the consecutive one is refused on the halved odds", + said(b:takeTurn({ kind = "move", move = "PROTECT" }), + "But it failed!"), true) +end)() + +-- BattleCommand_Selfdestruct zeroes the user's status and both HP bytes +-- (engine/battle/move_effects/selfdestruct.asm:6-12). +;(function() + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "EXPLOSION", pp = 5, maxPp = 5 } } + player.status = "burn" + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp, wild.maxHp = 9999, 9999 + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + b:takeTurn({ kind = "move", move = "EXPLOSION" }) + check("Explosion faints its user", player.hp, 0) + check("and clears the status byte with it", player.status, nil) + check("while the target still takes the hit", wild.hp < wild.maxHp, true) +end)() + +-- `srl c` in BattleCommand_DamageCalc (effect_commands.asm:2905-2913). +;(function() + local opts = { level = 50, power = 250, moveType = "NORMAL", + attacker = { attack = 100 }, defender = { defense = 100 }, + types = TYPES, matchups = MATCHUPS, variation = 100 } + local plain = Damage.calc(opts) + opts.defenseHalved = true + check("halving the defence raises the damage", Damage.calc(opts) > plain, + true) +end)() + +-- CheckPlayerTurn refuses a move Disable landed on after the menu closed and +-- spends the turn (engine/battle/effect_commands.asm:314-326). +;(function() + local function said(events, text) + for _, e in ipairs(events) do + if e.kind == "message" and e.text == text then return true end + end + return false + end + local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "EMBER", pp = 25, maxPp = 25 } } + local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp, wild.maxHp = 9999, 9999 + local b = Battle.new({ data = DATA, party = { player }, wild = wild, + random = zeroRandom }) + b:takeEvents() + b:volatile(player).disabled = "TACKLE" + local events = b:takeTurn({ kind = "move", move = "TACKLE" }) + check("a disabled move is refused at execution", + said(events, "CYNDAQUIL's TACKLE is DISABLED!"), true) + check("and the turn is spent, not re-picked", player.moves[1].pp, 35) + check("no other move is substituted", player.moves[2].pp, 25) + -- MoveDisabled clears the charge so a disabled FLY cannot land (:599-603). + b:volatile(player).chargeMove, b:volatile(player).vanished = "TACKLE", true + b:takeTurn({ kind = "move", move = "TACKLE" }) + check("a disabled charge move fails", b:volatile(player).chargeMove, nil) + check("and its user reappears", b:volatile(player).vanished, nil) +end)() + +print(("gen2 battle: %d checks, %d failures"):format(checks, failures)) + +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua new file mode 100644 index 00000000..b03a4f8a --- /dev/null +++ b/tests/gen2_battle_ui_test.lua @@ -0,0 +1,1281 @@ +-- The Gen 2 battle screen against the cart's own HUD and pacing rules: +-- minimum damage out of BattleCommand_DamageCalc's tail, the -6/+6 stat stage +-- wall, the HP bar chase (engine/battle/anim_hp_bar.asm), the status tag in +-- the level's spot (PlaceNonFaintStatus), BattleMenu's empty textbox, the +-- intro slide's one-piece back pic, and ItemRestoreHP's pick-a-mon flow. +-- +-- GOLD_CACHE="..." luajit tests/gen2_battle_ui_test.lua +-- +-- ROM-free: fixtures below are the extractor's shapes; the one cache-fed +-- section (the GROWL animation's cry) skips cleanly when there is no cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle ui") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local BattleState = require("src.ui.gen2.BattleState") +local Damage = require("src.battle.gen2.Damage") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local Sound = require("src.core.Sound") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, + ELECTRIC = { id = "ELECTRIC", index = 23, category = "special" }, +} + +local MATCHUPS = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "FIRE", defender = "WATER", multiplier = 5 }, + { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, + THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDERWAVE", power = 0, + type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + TOTODILE = { + id = "TOTODILE", index = 158, name = "TOTODILE", + baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48 }, + types = { "WATER", "WATER" }, catchRate = 45, baseExp = 66, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + 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_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + GEODUDE = { + id = "GEODUDE", index = 74, name = "GEODUDE", + baseStats = { hp = 40, attack = 80, defense = 100, speed = 20, + specialAttack = 30, specialDefense = 30 }, + types = { "ROCK", "GROUND" }, catchRate = 255, baseExp = 73, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = { + -- `index` is the ROM item id, which is what wBattleAnimParam carries. + MASTER_BALL = { id = "MASTER_BALL", pocket = "BALL", name = "MASTER BALL", + index = 1 }, + POKE_BALL = { id = "POKE_BALL", pocket = "BALL", name = "POKe BALL", + index = 5 }, + FAST_BALL = { id = "FAST_BALL", pocket = "BALL", name = "FAST BALL", + index = 160 }, + POTION = { id = "POTION", pocket = "ITEM", name = "POTION" }, + }, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- Deterministic and range-respecting: the smallest roll that is neither a +-- critical hit (rollCritical wants 0) nor a miss. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +-- ---- DamageCalc's minimum (engine/battle/effect_commands.asm) ------------- +do + -- Stats weak enough that the raw formula floors to 0: the cart still walks + -- out of DamageCalc with MIN_DAMAGE (2). + local weak = Damage.calc({ + level = 5, power = 35, moveType = "NORMAL", + attacker = { attack = 6, types = { "FIRE" } }, + defender = { defense = 40, types = { "WATER" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, + }) + eq(weak, Damage.MIN_DAMAGE, + "a hit whose stat math floors to nothing still deals MIN_DAMAGE") + + -- The same hit into a resist: BattleCommand_Stab's zero-quotient arm forces + -- the row's floor back up to 1, so not-very-effective never reads 0. + local resisted = Damage.calc({ + level = 5, power = 35, moveType = "NORMAL", + attacker = { attack = 6, types = { "FIRE" } }, + defender = { defense = 40, types = { "ROCK" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, + }) + check(resisted >= 1, "a resisted hit that lands deals at least 1") + local _, info = Damage.calc({ + level = 5, power = 35, moveType = "NORMAL", + attacker = { attack = 6, types = { "FIRE" } }, + defender = { defense = 40, types = { "ROCK" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, + }) + eq(info.effectiveness, 5, "and still reports not-very-effective") + + -- Immunity is the one zero left: the row's multiplier IS 0. + local immune = Damage.calc({ + level = 50, power = 95, moveType = "ELECTRIC", + attacker = { specialAttack = 100, types = { "ELECTRIC" } }, + defender = { specialDefense = 50, types = { "GROUND" } }, + types = TYPES, matchups = MATCHUPS, variation = 100, + }) + eq(immune, 0, "an immune matchup still deals nothing at all") +end + +-- ---- the same minimum through the real turn engine ------------------------ +do + local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "GEODUDE", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = detRandom }) + local before = wild.hp + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + local dealt, sawNve = nil, false + for _, event in ipairs(events) do + if event.kind == "damage" and event.side == "enemy" and not dealt then + dealt = event.amount + end + if event.kind == "message" + and event.text == "It's not very effective..." then + sawNve = true + end + end + check(sawNve, "TACKLE into GEODUDE reads as not very effective") + check((dealt or 0) >= 1, "and the landed hit dealt at least 1 HP, not 0") + eq(wild.hp, before - dealt, "with the HP moving by exactly that much") +end + +-- ---- the -6 stage wall (BattleCommand_StatDown's .CantLower) -------------- +do + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "GROWL", pp = 40, maxPp = 40 } } + player.hp = 200 + player.maxHp = 200 + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = detRandom }) + for turn = 1, 6 do + battle:takeTurn({ kind = "move", move = "GROWL" }) + eq(battle.stages.enemy.attack, -turn, + "GROWL " .. turn .. " lands one stage") + end + local events = battle:takeTurn({ kind = "move", move = "GROWL" }) + eq(battle.stages.enemy.attack, -6, "the seventh GROWL moves nothing") + local refused = false + for _, event in ipairs(events) do + if event.kind == "message" + and event.text == "PIDGEY's ATTACK won't drop anymore!" then + refused = true + end + end + check(refused, "and the cart's refusal line is emitted instead") + + -- The stage -6 multiplier is 25/100 with a floor of 1: a stat can shrink to + -- a quarter but never to zero. + eq(Damage.applyStage(11, -6), math.max(1, math.floor(11 * 25 / 100)), + "stage -6 is a quarter of the stat") + eq(Damage.applyStage(1, -6), 1, "and never zero") + eq(Damage.applyStage(11, -9), Damage.applyStage(11, -6), + "stages below -6 read as -6") +end + +-- ---- BattleState with a stub game ----------------------------------------- + +local function newScreen(opts) + opts = opts or {} + Input:init() + local pushed = {} + local player = opts.player + or Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + if not player.moves or #player.moves == 0 then + player.moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, + } + end + local party = opts.party or { player } + local wild = opts.wild or Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = wild.moves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = party, inventory = opts.inventory + or { POTION = 2, POKE_BALL = 3 } } + local game = { + data = DATA, + save = save, + input = Input, + options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + }, + } + local battle = Battle.new({ data = DATA, party = party, wild = wild, + random = detRandom }) + local screen = BattleState.new(game, { battle = battle, save = save }) + return screen, battle, player, wild, save, pushed +end + +local function run(screen, frames) + for _ = 1, frames do + Input:step() + screen:update(1 / 60) + end +end + +local function runToMenu(screen, cap) + for _ = 1, (cap or 3000) do + Input:step() + screen:update(1 / 60) + if screen.phase == "menu" then return true end + end + return false +end + +-- ---- BattleMenu empties the textbox --------------------------------------- +do + local screen = newScreen() + eq(screen.shownHp.enemy, screen.battle.enemy.hp, + "the bar opens on the real HP") + check(runToMenu(screen), "the intro drains to the battle menu") + eq(screen.message, nil, + "BattleMenu runs EmptyBattleTextbox: no prompt beside the 2x2 menu") +end + +-- ---- the HP bar chase ----------------------------------------------------- +do + local screen, battle, player, wild = newScreen() + check(runToMenu(screen), "reached the menu") + local shown0 = screen.shownHp.enemy + eq(shown0, wild.hp, "the enemy bar sits on the wild mon's real HP") + + screen:submit({ kind = "move", move = "TACKLE" }) + check(wild.hp < shown0, "the engine has already resolved the whole turn") + eq(screen.shownHp.enemy, shown0, + "but the drawn bar has not spoiled it: it still shows the pre-turn HP") + + local sawIntermediate = false + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + local shown = screen.shownHp.enemy + if shown < shown0 and shown > wild.hp then sawIntermediate = true end + if screen.phase == "menu" then break end + end + eq(screen.phase, "menu", "the turn drains back to the menu") + check(sawIntermediate, + "the bar walked down through the middle values, one tick a frame") + eq(screen.shownHp.enemy, wild.hp, "and settled on the real enemy HP") + eq(screen.shownHp.player, player.hp, + "the player's bar caught its own hit too") +end + +-- ---- the drain is sized by the mon whose bar is ON SCREEN ----------------- +do + -- wCurHPAnimMaxHP is loaded from the battle struct of the mon being drawn + -- (wEnemyMonMaxHP -> wHPBuffer1, engine/battle/effect_commands.asm:3399-3404), + -- and after a faint that is still the OUTGOING mon: the engine rebinds + -- battle.enemy inside takeTurn, a whole queue before the replacement's + -- `send` event is dequeued. + local screen, battle = newScreen() + check(runToMenu(screen), "reached the menu") + local outgoing = { maxHp = 240, hp = 240 } + local replacement = { maxHp = 24, hp = 24 } + screen.shownMon.enemy = outgoing + battle.enemy = replacement + screen.shownHp.enemy = 240 + screen.hpAnim = { side = "enemy", to = 0 } + local ticks = 0 + while screen.hpAnim and ticks < 400 do + screen:stepHpAnim() + ticks = ticks + 1 + end + eq(screen.shownHp.enemy, 0, "the outgoing mon's bar drained to zero") + eq(ticks, 240 / math.ceil(240 / 48), + "one PIXEL a tick out of the OUTGOING mon's 240 max HP, not one hit " + .. "point a tick out of the replacement's 24") +end + +-- ---- the exp bar crawls, and the level waits for it ----------------------- +do + -- AnimateExpBar (engine/battle/core.asm:7191) runs BEFORE the exp is + -- committed and walks the bar one pixel at a time, topping out at 64 and + -- restarting at 0 for each level crossed, with wBattleMonLevel advanced only + -- as each segment fills (:7267-7274). + local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW + -- One point short of level 6, so whatever the kill pays levels the mon. + player.experience = Mon.experienceForLevel(growth, 6) - 1 + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp = 1 + local screen = newScreen({ player = player, wild = wild }) + check(runToMenu(screen), "reached the menu") + eq(screen.shownLevel, 5, "the HUD opens on the mon's real level") + local startExp = screen.shownExp + check(startExp > 0 and startExp < 64, + "and on its real place along the bar (CalcExpBar's 64 pixels)") + + screen:submit({ kind = "move", move = "TACKLE" }) + eq(wild.hp, 0, "the wild mon went down on the first TACKLE") + eq(player.level, 6, "and the engine has already levelled the player") + eq(screen.shownLevel, 5, + "but the HUD still prints the pre-kill level, several boxes before the " + .. "grew-to-level line") + eq(screen.shownExp, startExp, "and the bar has not moved yet either") + + local crawled, restarted = false, false + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then + crawled = true + end + if screen.shownLevel == 6 and (screen.shownExp or 0) == 0 then + restarted = true + end + if screen.phase == "done" then break end + end + eq(screen.phase, "done", "the win drains out") + check(crawled, "the bar walked up through the middle pixels") + check(restarted, + "topped out, took the level with it and restarted the segment at 0") + eq(screen.shownLevel, player.level, "the HUD level caught up") + eq(screen.shownExp, + screen:expPixels(player, player.level, player.experience), + "and the bar landed on the mon's real place in level 6") +end + +-- ---- a level-up REDRAWS the HP bar, it does not animate it ---------------- +do + -- GiveExperiencePoints' `.skip_active_mon_update` guard + -- (engine/battle/core.asm:6999-7003) copies the recalculated HP and max HP + -- into the battle struct for the mon that is OUT and calls UpdatePlayerHUD + -- (:7034), which DRAWS the bar rather than chasing it. + local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW + player.experience = Mon.experienceForLevel(growth, 6) - 1 + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + wild.hp = 1 + local screen = newScreen({ player = player, wild = wild }) + check(runToMenu(screen), "reached the menu") + local maxBefore = player.maxHp + screen:submit({ kind = "move", move = "TACKLE" }) + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.phase == "done" then break end + end + check(player.maxHp > maxBefore, "the level-up raised the maximum") + eq(screen.shownHp.player, player.hp, + "and the bar carries the HP the level-up added, without waiting for the " + .. "next damage or heal event") +end + +-- ---- the status tag takes the level's spot -------------------------------- +do + local screen, battle, _, wild = newScreen() + check(runToMenu(screen), "reached the menu") + screen:submit({ kind = "move", move = "THUNDER_WAVE" }) + eq(wild.status, "paralyze", "THUNDER WAVE paralyzed the wild mon") + eq(screen:statusTag(wild), "PAR", + "so the enemy HUD wears PAR where the level was") + eq(screen:statusTag({ status = "poison" }), "PSN", "PSN for poison") + eq(screen:statusTag({ status = "toxic" }), "PSN", + "toxic wears the same PSN bit") + eq(screen:statusTag({ status = "burn" }), "BRN", "BRN for burn") + eq(screen:statusTag({ status = "freeze" }), "FRZ", "FRZ for freeze") + eq(screen:statusTag({ status = "sleep" }), "SLP", "SLP for sleep") + eq(screen:statusTag({ status = "confuse" }), nil, + "confusion is a substatus on the cart and shows no tag") + eq(screen:statusTag({}), nil, "no status, no tag: the level prints") +end + +-- ---- the intro slide's one-piece back pic --------------------------------- +do + -- CopyBackpic's OAM copy crosses 144px in the 72 frames, 2 a frame. + eq(BattleAnimView.slideBackpicOffset(0), 144, + "the back pic starts just off the right edge") + eq(BattleAnimView.slideBackpicOffset(36), 72, "halfway at frame 36") + eq(BattleAnimView.slideBackpicOffset(72), 0, "and lands on its column") + local top, middle = BattleAnimView.slideOffsets(72) + eq(top, 0, "the enemy band finishes in place") + eq(middle, 0, "so does the player band") + + -- While the bands are baked, the player-side pic is withheld from them and + -- drawn only by the overlay, so it cannot tear at the $40 boundary. + local screen = newScreen() + screen.slidingBackpic = true + local ok = pcall(screen.drawPic, screen, screen.battle.player, true) + check(ok, "drawPic returns cleanly while the overlay owns the back pic") +end + +-- ---- ItemRestoreHP: pick a mon, heal that mon ----------------------------- +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + bench.hp = bench.hp - 15 + local screen, battle, _, _, save, pushed = + newScreen({ player = lead, party = { lead, bench } }) + check(runToMenu(screen), "reached the menu") + + local turn0 = battle.turn + screen:useItem("POTION") + eq(screen.phase, "submenu", "a healing item opens a submenu, not a heal") + local picker = pushed[#pushed] + eq(getmetatable(picker), PartyMenu, + "and the submenu is the party screen (UseItem_SelectMon)") + eq(picker.prompt, PartyMenu.PROMPTS.useItem, + "asking the cart's own question") + + local benchBefore = bench.hp + picker.onChoose(2, bench) + eq(bench.hp, math.min(bench.maxHp or bench.stats.hp, benchBefore + 20), + "the POTION landed on the BENCHED mon") + eq(save.inventory.POTION, 1, "and one POTION left the bag") + eq(battle.turn, turn0 + 1, "a heal that lands spends the turn") + check(runToMenu(screen), "the enemy's answer drains out") + + -- A full-HP target refuses without costing the item or the turn + -- (IsMonAtFullHealth's arm of ItemRestoreHP). + local turn1 = battle.turn + screen:useItem("POTION") + pushed[#pushed].onChoose(2, bench) + eq(screen.message, "It won't have any\neffect.", + "a full-HP target answers the cart's refusal") + eq(save.inventory.POTION, 1, "with the POTION still in the bag") + eq(battle.turn, turn1, "and the turn not spent") + check(runToMenu(screen), "back to the menu") + + -- An egg is refused before the health check (UseItem_SelectMon .not_egg). + screen:useItem("POTION") + pushed[#pushed].onChoose(3, { isEgg = true, hp = 1, maxHp = 12 }) + eq(screen.message, "That can't be used\non an EGG.", + "an EGG answers CantUseOnEggMessage") + eq(save.inventory.POTION, 1, "still nothing spent") + check(runToMenu(screen), "back to the menu") + + -- Backing out of the picker returns to the pack with nothing spent. + screen:useItem("POTION") + pushed[#pushed].onCancel() + eq(getmetatable(pushed[#pushed]), PackMenu, + "cancelling the picker reopens the PACK (.SelectMon's carry path)") + eq(save.inventory.POTION, 1, "with the POTION untouched") +end + +-- ---- BattleMenu_PKMN opens BattleMonMenu over the list -------------------- +do + -- `callfar BattleMonMenu`, then SWITCH -> TryPlayerSwitch, CANCEL -> + -- .Cancel -> BattleMenu (engine/battle/core.asm:4810-4816); the voluntary + -- list is PARTYMENUACTION_CHOOSE_POKEMON and the forced one + -- PARTYMENUACTION_SWITCH (:4795, :2702). + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local screen, battle, _, _, _, pushed = + newScreen({ player = lead, party = { lead, bench } }) + check(runToMenu(screen), "reached the menu") + + check(screen:openParty(), "the voluntary list opens") + local list = pushed[#pushed] + eq(list.prompt, PartyMenu.PROMPTS.choose, "asking ChooseAMonString") + check(list.wantsBattleSubmenu, "and it carries BattleMonMenu") + list.index = 2 + list:openSubmenu() + eq(#list.submenu.items, 3, "BattleMonMenu has three rows") + eq(list.submenu.items[1].id, "SWITCH", "SWITCH first") + eq(list.submenu.items[2].id, "STATS", "then STATS") + eq(list.submenu.items[3].id, "CANCEL", "then CANCEL") + + -- CANCEL leaves the list entirely and lands back on the battle menu. + list.submenu.index = 3 + list:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) + eq(screen.phase, "menu", ".Cancel returns to BattleMenu") + + -- SWITCH takes the pick through to the engine. + check(screen:openParty(), "the list reopens") + local list2 = pushed[#pushed] + list2.index = 2 + list2:openSubmenu() + list2:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) + check(runToMenu(screen), "the switch drains back to the menu") + eq(battle.player, bench, "SWITCH reached TryPlayerSwitch") + + -- BattleText_MonIsAlreadyOut, and the turn is NOT spent (:4863-4870). + local turn0 = battle.turn + check(screen:openParty(), "the list opens again") + local list3 = pushed[#pushed] + list3.index = 2 + list3:openSubmenu() + list3:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) + eq(screen.phase, "refuse-switch", "the mon already out is refused") + check((screen.message or ""):find("is already out"), + "with BattleText_MonIsAlreadyOut") + eq(battle.turn, turn0, "and no turn is spent on it") + + -- The forced list is PickPartyMonInBattle: WhichPKMNString, no submenu. + check(screen:openParty(true), "the forced list opens") + local forced = pushed[#pushed] + eq(forced.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") + eq(forced.wantsBattleSubmenu, false, "with no BattleMonMenu over it") +end + +-- ---- SHIFT offers a free swap when the trainer sends its next mon --------- +do + -- EnemySwitch's shift arm: OfferSwitch, then the enemy's send-out, then + -- `jp PlayerSwitch` with both participant bitfields zeroed + -- (engine/battle/core.asm:2941-2963); CheckWhetherToAskSwitch reads the + -- BATTLE_SHIFT bit CLEAR as SHIFT (:3269-3295). + local function shiftScreen(style) + Input:init() + local pushed = {} + local lead = Mon.new(DATA, "CYNDAQUIL", 30, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local bench = Mon.new(DATA, "TOTODILE", 30, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local foe1 = Mon.new(DATA, "PIDGEY", 3, { dvs = perfect }) + foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local foe2 = Mon.new(DATA, "GEODUDE", 3, { dvs = perfect }) + foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local party = { lead, bench } + local save = { party = party, inventory = {} } + local game = { + data = DATA, save = save, input = Input, + options = { battleStyle = style }, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + }, + } + local battle = Battle.new({ data = DATA, party = party, random = detRandom, + trainer = { class = "FALKNER", name = "FALKNER", party = { foe1, foe2 } } }) + local screen = BattleState.new(game, { battle = battle, save = save }) + return screen, battle, party, pushed + end + + local function runToPhase(screen, phase, cap) + for _ = 1, (cap or 4000) do + Input:step() + screen:update(1 / 60) + if screen.phase == phase then return true end + end + return false + end + + -- SET never asks (`bit BATTLE_SHIFT, a / jr nz, .return_nc`, :3280-3282). + local setScreen, setBattle = shiftScreen("SET") + check(runToMenu(setScreen), "the SET battle reaches its menu") + setBattle.enemy.hp = 1 + setScreen:submit({ kind = "move", move = "TACKLE" }) + check(runToMenu(setScreen), "the KO drains without stopping") + eq(setScreen.phase, "menu", "SET is never offered a shift") + + -- SHIFT stops on the prompt before the enemy's send-out line. + local noScreen, noBattle, noParty = shiftScreen("SHIFT") + check(runToMenu(noScreen), "the SHIFT battle reaches its menu") + noBattle.enemy.hp = 1 + local noTurn = noBattle.turn + noScreen:submit({ kind = "move", move = "TACKLE" }) + check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch") + check((noScreen.message or ""):find("is about to use"), + "with BattleText_EnemyIsAboutToUseWillPlayerChangeMon") + + -- NO falls through to the send-out with nothing switched and nothing spent. + noScreen.messageTimer = 0 + noScreen.shiftIndex = 2 + Input:overlayPressed("a") + Input:step() + noScreen:update(1 / 60) + Input:overlayReleased("a") + check(runToMenu(noScreen), "NO drains back to the menu") + eq(noBattle.player, noParty[1], "the lead is still out") + eq(noBattle.turn, noTurn + 1, "and only the attacking turn was spent") + + -- YES picks a mon, and the switch lands AFTER the enemy's send-out. + local yesScreen, yesBattle, yesParty, pushed = shiftScreen("SHIFT") + check(runToMenu(yesScreen), "the second SHIFT battle reaches its menu") + yesBattle.enemy.hp = 1 + yesScreen:submit({ kind = "move", move = "TACKLE" }) + check(runToPhase(yesScreen, "ask-shift"), "it stops on OfferSwitch too") + yesScreen.messageTimer = 0 + yesScreen.shiftIndex = 1 + Input:overlayPressed("a") + Input:step() + yesScreen:update(1 / 60) + Input:overlayReleased("a") + eq(yesScreen.phase, "submenu", "YES opens PickSwitchMonInBattle") + local picker = pushed[#pushed] + eq(picker.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") + eq(picker.wantsBattleSubmenu, false, "with no BattleMonMenu over it") + picker.onChoose(2, yesParty[2]) + check(runToMenu(yesScreen), "the shift drains back to the menu") + eq(yesBattle.player, yesParty[2], "the picked mon is out") + eq(yesBattle.participants[1], nil, + "and both participant bitfields were zeroed first") + eq(yesBattle.participants[2], true, "leaving only the incoming slot") +end + +-- ---- healing the active mon refills its bar on screen --------------------- +do + local screen, battle, player = newScreen() + check(runToMenu(screen), "reached the menu") + -- Take a hit so there is something to refill. + screen:submit({ kind = "move", move = "TACKLE" }) + check(runToMenu(screen), "first exchange drains") + check(player.hp < (player.maxHp or player.stats.hp), + "the player took damage") + eq(screen.shownHp.player, player.hp, "and the bar followed it down") + + local screenSave = screen.save + screenSave.inventory.POTION = 1 + screen:useItem("POTION") + local picker = screen.game.stack.top() + picker.onChoose(1, player) + check(screen.hpAnim ~= nil and screen.hpAnim.side == "player", + "healing the ACTIVE mon arms the bar chase (HealHP_SFX_GFX's AnimateHPBar)") + check(runToMenu(screen), "the heal turn drains") + eq(screen.shownHp.player, player.hp, "and the bar refilled to the new HP") +end + +-- ---- GROWL's sound comes from its own anim script (cache-fed) ------------- +do + 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 path = cache .. "/data/generated/battle_anims.lua" + local file = io.open(path, "r") + if not file then + check(true, "no Gold cache at " .. path .. " (SKIP)") + else + file:close() + local anims = assert(loadfile(path))() + if not (anims.scripts and anims.moves and anims.moves.GROWL) then + check(true, "battle_anims.lua has no GROWL script (SKIP)") + else + local AnimRunner = require("src.battle.gen2.AnimRunner") + local constants = + assert(loadfile(cache .. "/data/generated/constants.lua"))() + local cried, sounds = nil, 0 + local runner = AnimRunner.new({ + data = anims, + constants = constants, + battleTurn = 0, + animId = "GROWL", + hooks = { + cry = function(side, pitch, length) cried = side end, + sound = function() sounds = sounds + 1 end, + }, + }) + runner:start(anims.moves.GROWL) + local frames = 0 + while runner:step() and frames < 600 do frames = frames + 1 end + -- BattleAnim_Growl (data/moves/animations.asm): `anim_cry $0` plays the + -- USER's cry, which is the move's whole sound. + eq(cried, "player", "GROWL's script asks for the user's own cry") + check(frames > 0 and frames < 600, "and the script terminates") + + -- The chain the SCREEN's cry hook walks, end to end: the same GROWL + -- script through BattleState:animForMove must reach Sound.playCry with + -- the user's SPECIES, because audio.cries is keyed by species (the + -- def.cry field the hook once read does not exist in a Gen 2 cache). + local audio = assert(loadfile(cache .. "/data/generated/audio.lua"))() + check(audio.cries and audio.cries.CYNDAQUIL ~= nil, + "audio.lua keys the cries by species") + local screen = newScreen() + screen.game.data.gen2BattleAnims = anims + screen.game.data.gen2Constants = constants + screen.game.data.audio = audio + screen.anims = anims + screen.animConstants = constants + check(runToMenu(screen), "reached the menu with the anim runtime live") + local Sound = require("src.core.Sound") + local realPlayCry = Sound.playCry + local played = nil + Sound.playCry = function(_, species) played = species end + check(screen:animForMove("GROWL", "player"), + "the extracted GROWL animation starts") + for _ = 1, 600 do + if not screen.anim then break end + Input:step() + screen:update(1 / 60) + end + Sound.playCry = realPlayCry + eq(screen.anim, nil, "the animation ran out") + eq(played, "CYNDAQUIL", + "and GROWL played the user's cry through Sound.playCry") + end + end +end + +-- ---- the cursor bytes that live across menu openings ---------------------- +-- +-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1 +-- (engine/battle/core.asm:5111) and the A-press writes the picked row back, so +-- the FIGHT list reopens on the move used last turn. SendOutPlayerMon +-- (core.asm:3809) is what zeroes it, together with wBattleMenuCursorPosition. +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } + local screen = newScreen({ player = lead }) + check(runToMenu(screen), "reached the menu") + local function tap(button) + Input:overlayPressed(button) + Input:step() + screen:update(1 / 60) + Input:overlayReleased(button) + Input:step() + screen:update(1 / 60) + end + tap("a") -- FIGHT + eq(screen.phase, "moves", "A on FIGHT opens the move list") + tap("down") + eq(screen.moveIndex, 2, "down moves onto the second move") + tap("a") + check(runToMenu(screen), "the turn drains back to the menu") + eq(screen.moveIndex, 2, "wCurMoveNum survived the turn") + tap("a") + eq(screen.phase, "moves", "FIGHT reopens") + eq(screen.moveIndex, 2, "on the move used last turn, not on the first") + + -- A moveset that shrank clamps rather than pointing past the end. + tap("b") + screen.moveIndex = 4 + screen.battle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + tap("a") + eq(screen.phase, "moves", "FIGHT opens again") + eq(screen.moveIndex, 1, "and the row is clamped to the moves that are left") +end + +-- SendOutPlayerMon zeroes both cursor bytes, so a switched-in mon opens on +-- FIGHT and on its first move. +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "GROWL", pp = 40, maxPp = 40 } } + local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) + bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local screen = newScreen({ player = lead, party = { lead, bench } }) + check(runToMenu(screen), "reached the menu") + screen.menuIndex = 3 + screen.moveIndex = 2 + screen:submit({ kind = "switch", index = 2 }) + check(runToMenu(screen), "the switch resolves back to the menu") + eq(screen.menuIndex, 1, "wBattleMenuCursorPosition is back on FIGHT") + eq(screen.moveIndex, 1, "and wCurMoveNum on the first move") +end + +-- ---- no HUD rides in with the intro bands --------------------------------- +-- +-- InitBattleDisplay blanks the whole tilemap before BattleIntroSlidingPics +-- (core.asm:8554/8564); UpdateEnemyHUD runs only after BattleStartMessage +-- returns (:7815) and UpdatePlayerHUD at the tail of SendOutPlayerMon (:3838). +do + local screen = newScreen() + eq(screen.showEnemyHud, false, "the enemy HUD does not exist at construction") + eq(screen.showPlayerHud, false, "nor the player's") + run(screen, BattleAnimView.SLIDE_FRAMES) + eq(screen.showEnemyHud, false, "and neither rode in with the bands") + eq(screen.showPlayerHud, false, "on either side") + -- The appeared line is read against an empty HUD area. + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.message == "Wild PIDGEY appeared!" then break end + end + eq(screen.message, "Wild PIDGEY appeared!", "BattleStartMessage's own line") + eq(screen.showEnemyHud, false, "with no enemy HUD under it yet") + check(runToMenu(screen), "the intro drains to the menu") + eq(screen.showEnemyHud, true, "UpdateEnemyHUD has run by the menu") + eq(screen.showPlayerHud, true, "and UpdatePlayerHUD after the send-out") +end + +-- ---- the wild mon cries with BattleStartMessage ---------------------------- +-- +-- `.not_shiny`: `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry` runs before +-- WildPokemonAppearedText (engine/battle/core.asm:8718-8721). +do + local screen = newScreen() + local realAudio = screen.game.data.audio + screen.game.data.audio = { cries = { PIDGEY = { pitch = 0 } } } + local realPlayCry = Sound.playCry + local played + Sound.playCry = function(_, species) played = species end + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.message == "Wild PIDGEY appeared!" then break end + end + Sound.playCry = realPlayCry + screen.game.data.audio = realAudio + eq(played, "PIDGEY", "the wild mon cries with its appeared line") +end + +-- ---- the message box's two fixed rows ------------------------------------- +-- +-- LineChar reloads the cursor at TEXTBOX_INNERY + 2 (home/text.asm:397), so a +-- two-line battle string sits on rows 14 and 16 with row 15 blank. +do + local Chrome = require("src.ui.gen2.Chrome") + local screen = newScreen() + local realPrint = Chrome.print + local rows = {} + Chrome.print = function(text, tx, ty) rows[#rows + 1] = { text, tx, ty } end + screen.message = "CYNDAQUIL used TACKLE on the wild PIDGEY!" + screen:printMessage() + Chrome.print = realPrint + check(#rows >= 2, "a long line wraps onto a second row") + eq(rows[1][2], 1, "TEXTBOX_INNERX is column 1") + eq(rows[1][3], 14, "TEXTBOX_INNERY is row 14") + eq(rows[2][3], 16, "and LineChar's row is 16, not 15") + eq(#rows, 2, "with no third row: Paragraph clears only 14-16") +end + +-- ---- PokeBallEffect's throw -------------------------------------------- +do + local screen = newScreen() + -- `cp POKE_BALL + 1 / jr c, .not_kurt_ball / ld a, POKE_BALL`. + eq(screen:ballAnimParam("MASTER_BALL"), 1, "MASTER BALL throws as itself") + eq(screen:ballAnimParam("POKE_BALL"), 5, "so does a POKe BALL") + eq(screen:ballAnimParam("FAST_BALL"), 5, + "and a Kurt ball throws with POKE_BALL's param") + -- data/battle_anims/ball_colors.asm. + eq(screen:ballPalette("MASTER_BALL"), "PAL_BATTLE_OB_GREEN", "green") + eq(screen:ballPalette("POKE_BALL"), "PAL_BATTLE_OB_RED", "red") + eq(screen:ballPalette("FAST_BALL"), "PAL_BATTLE_OB_BLUE", "blue") + eq(screen:ballPalette("NEST_BALL"), "PAL_BATTLE_OB_GRAY", + "and anything off the table takes the terminator row's gray") + + -- GetPokeBallWobble: three wobbles, then the verdict. + screen.battle.random = function() return 0 end + screen.ballThrow = { caught = true, rate = 255, wobble = 0 } + eq(screen:pokeballWobble(), 0, "a caught mon wobbles once") + eq(screen:pokeballWobble(), 0, "twice") + eq(screen:pokeballWobble(), 0, "three times") + eq(screen:pokeballWobble(), 1, "and the fourth call is the click") + + screen.ballThrow = { caught = false, rate = 255, wobble = 0 } + eq(screen:pokeballWobble(), 0, "a doomed throw can still wobble") + eq(screen:pokeballWobble(), 0, "and again") + eq(screen:pokeballWobble(), 0, "and again") + eq(screen:pokeballWobble(), 2, "before it breaks free on the fourth") + eq(screen:ballFailureText(), "Shoot! It was so close too!", + "four wobbles is BallSoCloseText") + + -- A roll that never comes in under the probability breaks free at once. + screen.battle.random = function(n) return (n or 256) - 1 end + screen.ballThrow = { caught = false, rate = 1, wobble = 0 } + eq(screen:pokeballWobble(), 2, "the first wobble already fails") + eq(screen:ballFailureText(), "Oh no! The POKéMON broke free!", + "one wobble is BallBrokeFreeText") + screen.ballThrow = { caught = false, rate = 1, wobble = 3 } + eq(screen:ballFailureText(), "Aargh! Almost had it!", + "three is BallAlmostHadItText") +end + +-- ---- a full party sends the catch to the PC, and a full box refuses ------- +do + -- `.SendToPC` / `predef SendMonIntoBox` (engine/items/item_effects.asm:548). + local party = {} + for i = 1, 6 do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party[i] = mon + end + local screen, battle, _, wild, save = + newScreen({ player = party[1], party = party, + inventory = { MASTER_BALL = 1 } }) + check(runToMenu(screen), "reached the menu") + screen:useItem("MASTER_BALL") + eq(battle.outcome, "caught", "the Master Ball catch lands") + eq(#save.party, 6, "the party is untouched") + eq((save.boxes and save.boxes[1] or {})[1], wild, + "and the catch went into the current box, not into nothing") + + -- Ball_BoxIsFullMessage: party full AND box full refuses the throw outright + -- and reports the item unused, so neither the ball nor the turn goes + -- (item_effects.asm:217-226). + local party2 = {} + for i = 1, 6 do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party2[i] = mon + end + local screen2, battle2, _, _, save2 = + newScreen({ player = party2[1], party = party2, + inventory = { MASTER_BALL = 1 } }) + save2.boxes = { {} } + for i = 1, 20 do save2.boxes[1][i] = { species = "PIDGEY" } end + check(runToMenu(screen2), "reached the menu") + local turn0 = battle2.turn + screen2:useItem("MASTER_BALL") + eq(battle2.outcome, nil, "the throw is refused") + eq(save2.inventory.MASTER_BALL, 1, "the ball stays in the bag") + eq(battle2.turn, turn0, "and the turn is not spent") + check((screen2.message or ""):find("BOX"), "BallBoxFullText is what shows") +end + +-- ---- SendMonIntoBox inserts at the HEAD, and reports a box it filled ------ +do + -- The species loop cascades every entry one slot down and ShiftBoxMon does + -- the same for the OT names, nicknames and mon structs + -- (engine/pokemon/move_mon.asm:954-968), so the catch is slot 1 -- which is + -- what lets the FRIEND_BALL arm write sBoxMon1Happiness unconditionally + -- ("The captured mon is now first in the box", item_effects.asm:624). + local party = {} + for i = 1, 6 do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party[i] = mon + end + local screen, battle, _, wild, save = + newScreen({ player = party[1], party = party, + inventory = { MASTER_BALL = 1 } }) + local resident = { species = "PIDGEY" } + save.boxes = { { resident } } + check(runToMenu(screen), "reached the menu") + screen:useItem("MASTER_BALL") + eq(battle.outcome, "caught", "the Master Ball catch lands") + eq(save.boxes[1][1], wild, "the catch is FIRST in the box") + eq(save.boxes[1][2], resident, "and the mon already there shifted down one") + eq(#save.boxes[1], 2, "with nothing lost off the end") + eq(battle.boxFilled, nil, "a box with room left flags nothing") + + -- `.SendToPC` re-reads sBoxCount after the insert and sets + -- BATTLERESULT_BOX_FULL when the catch is the one that filled it + -- (item_effects.asm:612-619), which is what rings Bill on the first step + -- back in the overworld. + local party2 = {} + for i = 1, 6 do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party2[i] = mon + end + local screen2, battle2, _, _, save2 = + newScreen({ player = party2[1], party = party2, + inventory = { MASTER_BALL = 1 } }) + save2.boxes = { {} } + for i = 1, 19 do save2.boxes[1][i] = { species = "PIDGEY" } end + check(runToMenu(screen2), "reached the menu") + screen2:useItem("MASTER_BALL") + eq(battle2.outcome, "caught", "the twentieth catch still goes in") + eq(#save2.boxes[1], 20, "and fills the box") + eq(battle2.boxFilled, true, "which is flagged on the battle for the reload") +end + +-- ---- a new species opens NewPokedexEntry, gated on CheckReceivedDex ------- +do + -- `predef NewPokedexEntry` sits between NewDexDataText and the party add + -- (engine/items/item_effects.asm:535-542, :570). + local function findEvent(screen, kind) + for index, event in ipairs(screen.queue or {}) do + if event.kind == kind then return index, event end + end + end + + local screen, _, _, _, save = newScreen({ inventory = { MASTER_BALL = 1 } }) + save.engineFlags = { [11] = true } + save.pokedex = { seen = {}, caught = {} } + check(runToMenu(screen), "reached the menu") + screen:useItem("MASTER_BALL") + local dexAt, dexEvent = findEvent(screen, "dex-entry") + check(dexEvent ~= nil, "the catch queues a dex-entry event") + eq(dexEvent and dexEvent.species, "PIDGEY", "on the species just caught") + local nickAt = findEvent(screen, "ask-nickname") + check(nickAt and dexAt and dexAt < nickAt, + "and it runs ahead of AskGiveNicknameText") + + -- CheckReceivedDex: no dex, no line and no screen (item_effects.asm:532-533). + local screen2, _, _, _, save2 = newScreen({ inventory = { MASTER_BALL = 1 } }) + save2.engineFlags = {} + save2.pokedex = { seen = {}, caught = {} } + check(runToMenu(screen2), "reached the menu") + screen2:useItem("MASTER_BALL") + eq(findEvent(screen2, "dex-entry"), nil, "a dexless player gets no entry") + + -- `jr nz, .skip_pokedex`: a row already owned skips both (:528-530). + local screen3, _, _, _, save3 = newScreen({ inventory = { MASTER_BALL = 1 } }) + save3.engineFlags = { [11] = true } + save3.pokedex = { seen = {}, caught = { PIDGEY = true } } + check(runToMenu(screen3), "reached the menu") + screen3:useItem("MASTER_BALL") + eq(findEvent(screen3, "dex-entry"), nil, "an owned row opens nothing") +end + +-- ---- the low-HP siren dies with the enemy, and with a healing item -------- +do + -- StopDangerSound plus the wBattleLowHealthAlarm latch on every wild enemy + -- faint (engine/battle/core.asm:2071-2074), read first by CheckDanger + -- (:4396-4399); and wLowHealthAlarm zeroed by the healing items before the + -- HP even moves (engine/items/item_effects.asm:1657-1658). + local siren = false + local realStart, realStop = Sound.startLoop, Sound.stopLoop + Sound.startLoop = function(_, name) + if name == "Low_Health_Alarm" then siren = true end + end + Sound.stopLoop = function(name) + if name == "Low_Health_Alarm" then siren = false end + end + + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local screen = newScreen({ player = player, wild = wild }) + check(runToMenu(screen), "reached the menu") + player.hp = 2 + screen.shownHp.player = 2 + run(screen, 1) + check(siren, "a red bar raises the siren") + + wild.hp = 1 + screen:submit({ kind = "move", move = "TACKLE" }) + -- The drain to zero still rings: AnimateHPBar runs before + -- UpdateBattleStateAndExperienceAfterEnemyFaint is even reached. What may + -- not ring is anything from the faint onward. + local latched, rangAfterFaint, sawExpLine = false, false, false + for _ = 1, 3000 do + Input:step() + screen:update(1 / 60) + if screen.lowHealthAlarmDisabled then latched = true end + if latched and siren then rangAfterFaint = true end + if latched and (screen.message or ""):find("EXP") then sawExpLine = true end + if screen.phase == "done" then break end + end + eq(screen.phase, "done", "the win drains out") + check(latched, "the enemy's faint latched wBattleLowHealthAlarm") + check(sawExpLine, "and the exp lines ran after it with the bar still red") + check(not rangAfterFaint, + "and the siren is cut the instant the enemy goes down, not left blaring " + .. "under the exp bar and the level-up lines") + + -- The item exception: silent from the frame it is used, all through the + -- climb, with no latch behind it. + local player2 = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local screen2 = newScreen({ player = player2, + inventory = { POTION = 1 } }) + check(runToMenu(screen2), "reached the menu") + player2.hp = 2 + screen2.shownHp.player = 2 + run(screen2, 1) + check(siren, "the second battle's siren is up too") + screen2:useItem("POTION") + screen2.game.stack.top().onChoose(1, player2) + check(not siren, "the POTION silences it where UseDisposableItem sits") + check(screen2.hpAnim and screen2.hpAnim.side == "player", + "with the bar only starting to climb") + local rangDuringClimb = false + for _ = 1, 600 do + Input:step() + screen2:update(1 / 60) + if screen2.hpAnim and siren then rangDuringClimb = true end + if screen2.phase == "menu" then break end + end + check(not rangDuringClimb, "and it stays silent for the whole climb") + + Sound.startLoop, Sound.stopLoop = realStart, realStop +end + +-- ---- the move list refuses a dry or disabled row -------------------------- +-- +-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen` +-- (engine/battle/core.asm:5213-5246), so neither spends the turn. +local function tapper(screen) + return function(button) + Input:overlayPressed(button) + Input:step() + screen:update(1 / 60) + Input:overlayReleased(button) + Input:step() + screen:update(1 / 60) + end +end + +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, + { id = "TACKLE", pp = 5, maxPp = 35 } } + local screen, _, _, wild = newScreen({ player = lead }) + check(runToMenu(screen), "reached the menu") + screen.battle:volatile(screen.battle.player).disabled = "GROWL" + local tap = tapper(screen) + tap("a") + eq(screen.phase, "moves", "A on FIGHT still opens the list") + local before = wild.hp + tap("a") + eq(screen.phase, "refuse-move", "the disabled row is refused") + eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled") + eq(wild.hp, before, "and the enemy got no free turn") + run(screen, 120) + eq(screen.phase, "moves", "the list comes back") + + -- The same for a spent row. + screen.battle:volatile(screen.battle.player).disabled = nil + screen.battle.player.moves[2].pp = 0 + tap("down") + eq(screen.moveIndex, 2, "onto the spent move") + tap("a") + eq(screen.phase, "refuse-move", "a 0 PP row is refused too") + eq(screen.message, "There's no PP left for this move!", + "with BattleText_TheresNoPPLeftForThisMove") + eq(wild.hp, before, "and still no enemy turn") + run(screen, 120) + eq(screen.phase, "moves", "and the list comes back again") +end + +-- .CheckPlayerHasUsableMoves runs at MoveSelectionScreen's head +-- (engine/battle/core.asm:5058-5059): nothing usable never opens the list. +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, + { id = "TACKLE", pp = 0, maxPp = 35 } } + local screen = newScreen({ player = lead }) + check(runToMenu(screen), "reached the menu") + screen.battle:volatile(screen.battle.player).disabled = "GROWL" + local messages = {} + local realPush = screen.pushAll + screen.pushAll = function(self, events) + for _, event in ipairs(events or {}) do + if event.kind == "message" then messages[#messages + 1] = event.text end + end + return realPush(self, events) + end + tapper(screen)("a") + check(screen.phase ~= "moves", "the list never opened") + local struggled = false + for _, text in ipairs(messages) do + if text:find("has no moves left!", 1, true) then struggled = true end + end + check(struggled, "and the turn resolved as STRUGGLE") +end + +-- ---- the move-learn prompts (engine/pokemon/learn.asm) ------------------- +-- +-- ForgetMove asks first (:123-127), the picker's B is LearnMove's .cancel +-- (:104-108) and a NO there is `jp c, .loop`, back to the ask. +local function learnScreen() + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "GROWL", pp = 40, maxPp = 40 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, + { id = "SURF", pp = 15, maxPp = 15 } } + local screen = newScreen({ player = lead, party = { lead } }) + check(runToMenu(screen), "reached the menu") + screen:push({ kind = "choose-forget", index = 1, + move = { id = "EMBER", pp = 25, maxPp = 25 }, moveName = "EMBER", + text = "CYNDAQUIL wants to learn EMBER!" }) + screen:advanceQueue() + return screen, lead +end + +-- AskForgetMoveText's `para`/`cont` each want a press (home/text.asm:403-448), +-- so the question is reached by pages rather than by one button. +local function runToPhase(screen, phase, cap) + for _ = 1, (cap or 900) do + Input:step() + screen:update(1 / 60) + if screen.phase == phase then return true end + end + return false +end + +do + local screen, lead = learnScreen() + eq(screen.phase, "learn-intro", "AskForgetMoveText's first page holds") + eq(screen.message, "CYNDAQUIL is\ntrying to learn", + "with _AskForgetMoveText's own wording (data/text/common_3.asm:141-165)") + check(runToPhase(screen, "ask-forget"), "and its pages run into the question") + eq(screen.message, "move to make room\nfor EMBER?", + "whose last page is the one YesNoBox opens over") + run(screen, 60) + local tap = tapper(screen) + tap("b") + eq(screen.phase, "stop-learning", "NO there asks whether to stop learning") + run(screen, 60) + tap("b") + eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask") + check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit") + eq(lead.moves[1].id, "TACKLE", "and the moveset is untouched throughout") + eq(#lead.moves, 4, "with nothing added") +end + +do + local screen, lead = learnScreen() + check(runToPhase(screen, "ask-forget"), "the pages reach the question") + run(screen, 60) + local tap = tapper(screen) + tap("a") -- YES + eq(screen.phase, "choose-forget", "YES opens the picker") + tap("a") -- slot 1 + eq(lead.moves[1].id, "EMBER", "and A on slot 1 learns the move there") +end + +-- IsHMMove refuses the slot and `jr .loop` redraws the list (learn.asm:193-197). +do + local screen, lead = learnScreen() + check(runToPhase(screen, "ask-forget"), "the pages reach the question") + run(screen, 60) + local tap = tapper(screen) + tap("a") + eq(screen.phase, "choose-forget", "the picker is up") + screen.forgetIndex = 4 + tap("a") + eq(screen.phase, "choose-forget", "A on the HM row keeps the list") + eq(screen.message, "HM moves can't be\nforgotten now.", + "with MoveCantForgetHMText") + eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1") + run(screen, 60) + eq(screen.phase, "choose-forget", "the picker is still up after the line") + eq(lead.moves[4].id, "SURF", "and SURF is still there") +end + +S.finish() diff --git a/tests/gen2_berry_juice_test.lua b/tests/gen2_berry_juice_test.lua new file mode 100644 index 00000000..ead253b5 --- /dev/null +++ b/tests/gen2_berry_juice_test.lua @@ -0,0 +1,94 @@ +-- Shuckle's held BERRY fermenting into BERRY JUICE: +-- ConvertBerriesToBerryJuice (engine/events/pokerus/pokerus.asm:124). +-- +-- luajit tests/gen2_berry_juice_test.lua +-- +-- ROM-free. A 16/256 roll after every battle WIN, gated on +-- ENGINE_REACHED_GOLDENROD like the Pokerus roll it precedes; the first +-- party SHUCKLE holding a BERRY has its item rewritten, silently. Shuckie +-- (the Cianwood loaner) arrives holding one, which is the payoff. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 berry juice") +local check, eq = S.check, S.eq + +local BerryJuice = require("src.battle.gen2.BerryJuice") + +local function shuckle(item) + return { species = "SHUCKLE", nickname = "SHUCKIE", item = item, hp = 20 } +end + +-- ---- the roll and the walk ------------------------------------------------ +do + local party = { shuckle("BERRY") } + eq(BerryJuice.convert(party, { reachedGoldenrod = true, + random = function() return 15 end }), 1, + "a roll under 16 converts") + eq(party[1].item, "BERRY_JUICE", "the held BERRY became BERRY JUICE") + + party = { shuckle("BERRY") } + eq(BerryJuice.convert(party, { reachedGoldenrod = true, + random = function() return 16 end }), nil, + "a roll at 16 does nothing") + eq(party[1].item, "BERRY", "and the BERRY stays") + + party = { shuckle("BERRY") } + eq(BerryJuice.convert(party, { reachedGoldenrod = false, + random = function() return 0 end }), nil, + "before Goldenrod the roll never runs") +end + +-- ---- only the right mon, only the first ----------------------------------- +do + local party = { + { species = "PIDGEY", item = "BERRY", hp = 20 }, + shuckle("GOLD_BERRY"), + shuckle("BERRY"), + shuckle("BERRY"), + } + eq(BerryJuice.convert(party, { reachedGoldenrod = true, + random = function() return 0 end }), 3, + "the walk finds the first SHUCKLE holding a plain BERRY") + eq(party[1].item, "BERRY", "another species' BERRY is not touched") + eq(party[2].item, "GOLD_BERRY", "a GOLD BERRY is not a BERRY") + eq(party[3].item, "BERRY_JUICE", "the first match converts") + eq(party[4].item, "BERRY", + "and the routine returns there -- one conversion per win") +end + +-- ---- the battle-exit call site -------------------------------------------- +do + -- convertAfterBattle mirrors Pokerus.giveAfterBattle: the flag comes off + -- save.engineFlags[21]. + local save = { engineFlags = { [21] = true }, + party = { shuckle("BERRY") } } + eq(BerryJuice.convertAfterBattle(save, nil, + { random = function() return 0 end }), 1, + "the save-facing wrapper reads ENGINE_REACHED_GOLDENROD") + eq(save.party[1].item, "BERRY_JUICE", "and converts the save's party") + + save = { engineFlags = {}, party = { shuckle("BERRY") } } + eq(BerryJuice.convertAfterBattle(save, nil, + { random = function() return 0 end }), nil, + "no flag, no ferment") + + -- The wire into the battle exit: GivePokerusAndConvertBerries runs the + -- conversion FIRST, and BattleState:givePokerus is the port's copy of + -- that arm. A call site that is not spelled out in the file is not + -- there at all (the same proof shape gen2_pokerus_test uses). + local f = io.open("src/ui/gen2/BattleState.lua", "r") + check(f ~= nil, "BattleState's source is readable") + local body = f:read("*a") + f:close() + check(body:find("BerryJuice.convertAfterBattle(self.save, party)", 1, true) + ~= nil, "givePokerus converts the berries") + local convertAt = body:find("BerryJuice.convertAfterBattle(self.save, party)", + 1, true) + local pokerusAt = body:find("Pokerus.giveAfterBattle(self.save, party)", + 1, true) + check(convertAt and pokerusAt and convertAt < pokerusAt, + "and does it before the Pokerus roll, the asm's own order") +end + +S.finish() diff --git a/tests/gen2_bg_events_test.lua b/tests/gen2_bg_events_test.lua new file mode 100644 index 00000000..d238ec68 --- /dev/null +++ b/tests/gen2_bg_events_test.lua @@ -0,0 +1,90 @@ +-- bg_event function bytes past BGEVENT_READ. +-- +-- luajit tests/gen2_bg_events_test.lua +-- +-- World:bgEventAt matched `kind == 0` and nothing else, which quietly dropped +-- five of the nine arms of BGEventJumptable (engine/overworld/events.asm:631). +-- The expensive one is BGEVENT_IFNOTSET: TeamRocketBaseB3F's locked door is two +-- of them, at (10,9) and (11,9), so pressing A at Giovanni's door did nothing, +-- EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE could never be set, and the Rocket +-- hideout dead-ended one room short of its boss. Found by the Gold route bot, +-- which stood on the correct cell facing the correct way and reported +-- "postcondition ... not set" forever. +-- +-- The other half of the fix is in the extractor: IFSET/IFNOTSET point at a +-- `conditional_event` (dw event / dba script), not at a script, so those five +-- bytes used to be disassembled as commands and came out as a stray `sjump`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 bg events") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") + +local function world(bgEvents, flags, facing) + local set = flags or {} + return setmetatable({ + map = { def = { bgEvents = bgEvents } }, + player = { facing = facing or "up" }, + events = { get = function(_, id) return set[id] or false end }, + }, { __index = World }) +end + +-- BGEVENT_READ still works, and still ignores which way you face. +do + local ev = { x = 3, y = 4, kind = 0, scriptKey = "a:1" } + local w = world({ ev }, nil, "left") + eq(w:bgEventAt(3, 4), ev, "a READ event reads") + check(w:bgEventAt(9, 9) == nil, "and only on its own cell") +end + +-- The directional arms: `.checkdir` refuses unless the facing matches. +do + local up = { x = 1, y = 1, kind = 1, scriptKey = "a:1" } + eq(world({ up }, nil, "up"):bgEventAt(1, 1), up, "UP reads while facing up") + check(world({ up }, nil, "down"):bgEventAt(1, 1) == nil, + "and not while facing down") + local left = { x = 1, y = 1, kind = 4, scriptKey = "a:1" } + eq(world({ left }, nil, "left"):bgEventAt(1, 1), left, "LEFT reads facing left") + check(world({ left }, nil, "right"):bgEventAt(1, 1) == nil, + "and not facing right") +end + +-- IFNOTSET: the locked door. Runs while the flag is CLEAR, and stops the moment +-- the door has been opened -- which is what keeps the "open sesame" text from +-- replaying every time you walk past. +do + local door = { x = 10, y = 9, kind = 6, event = 800, scriptKey = "45:ad03" } + eq(world({ door }, {}):bgEventAt(10, 9), door, + "IFNOTSET reads while the event is clear") + check(world({ door }, { [800] = true }):bgEventAt(10, 9) == nil, + "and refuses once it is set") +end + +-- IFSET is the mirror. +do + local ev = { x = 2, y = 2, kind = 5, event = 42, scriptKey = "a:1" } + check(world({ ev }, {}):bgEventAt(2, 2) == nil, + "IFSET refuses while the event is clear") + eq(world({ ev }, { [42] = true }):bgEventAt(2, 2), ev, + "and reads once it is set") +end + +-- A cache extracted before the conditional_event fix has no `event` field. It +-- must refuse rather than guess: running a door script unconditionally would +-- open Giovanni's office without the passwords. +do + local stale = { x = 10, y = 9, kind = 6, scriptKey = "45:5da9" } + check(world({ stale }, {}):bgEventAt(10, 9) == nil, + "an unresolved conditional_event is refused, not guessed at") +end + +-- BGEVENT_ITEM stays with HiddenItems, and COPY has nothing to run. +do + local item = { x = 5, y = 5, kind = 7, scriptKey = "a:1" } + check(world({ item }):bgEventAt(5, 5) == nil, "ITEM is not a script read") + local copy = { x = 6, y = 6, kind = 8, scriptKey = "a:1" } + check(world({ copy }):bgEventAt(6, 6) == nil, "COPY is not a script read") +end + +S.finish() diff --git a/tests/gen2_big_object_test.lua b/tests/gen2_big_object_test.lua new file mode 100644 index 00000000..15144bd0 --- /dev/null +++ b/tests/gen2_big_object_test.lua @@ -0,0 +1,374 @@ +-- SPRITEMOVEDATA_BIGDOLLSYM: the 2x2 objects. +-- +-- luajit tests/gen2_big_object_test.lua (ROM-free; the map facts SKIP +-- without a gold cache) +-- +-- The sleeping Snorlax on Route 11 outside Vermilion is movement 21, whose +-- SpriteMovementData row carries `STRENGTH_BOULDER | BIG_OBJECT` +-- (data/sprites/map_objects.asm). BIG_OBJECT is the bit IsNPCAtCoord tests +-- before handing a coordinate to WillObjectIntersectBigObject +-- (engine/overworld/npc_movement.asm), which accepts anything inside +-- (x, y) .. (x+1, y+1) -- and IsNPCAtCoord is what BOTH `.CheckNPC` and +-- CheckFacingObject ask, so the blob blocks four cells and can be talked to +-- from any of them. +-- +-- The port treated it as an ordinary one-cell NPC: the player could stand +-- inside the Snorlax on three of its four cells, and the sprite drew at a +-- quarter size. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 big object") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local NPC = require("src.world.gen2.Npc") +local Vm = require("src.script.gen2.Vm") +local Permissions = require("src.world.gen2.Permissions") + +local COLL_FLOOR = 0x00 +local SPRITEMOVEDATA_BIGDOLLSYM = 0x15 +local SPRITEMOVEDATA_BIGDOLLASYM = 0x20 +local SPRITEMOVEDATA_BIGDOLL = 0x21 + +local BIG_MOVEDATA = { + [SPRITEMOVEDATA_BIGDOLLSYM] = true, + [SPRITEMOVEDATA_BIGDOLLASYM] = true, + [SPRITEMOVEDATA_BIGDOLL] = true, +} + +-- trueColor short-circuits SpriteRenderer:resolveImage past the OBP bake, so a +-- draw here needs no canvases. +local BIG_SNORLAX_SHEET = { + id = "SPRITE_BIG_SNORLAX", frames = 3, trueColor = true, + image = "assets/generated/sprites/big_snorlax.png", +} +local BIG_ONIX_SHEET = { + id = "SPRITE_BIG_ONIX", frames = 3, trueColor = true, + image = "assets/generated/sprites/big_onix.png", +} + +eq(NPC.MOVE.BIGDOLLSYM, SPRITEMOVEDATA_BIGDOLLSYM, + "SPRITEMOVEDATA_BIGDOLLSYM is $15 in map_object_constants.asm") +eq(NPC.MOVE.BIGDOLLASYM, SPRITEMOVEDATA_BIGDOLLASYM, "_BIGDOLLASYM is $20") +eq(NPC.MOVE.BIGDOLL, SPRITEMOVEDATA_BIGDOLL, "and _BIGDOLL is $21") + +-- ---- all three rows carry BIG_OBJECT -------------------------------------- +-- +-- data/sprites/map_objects.asm writes `STRENGTH_BOULDER | BIG_OBJECT` into the +-- palette-flags byte of BIGDOLLSYM, BIGDOLLASYM and BIGDOLL alike. +do + local function build(movement, sheet) + return NPC.new("TEST_MAP", + { index = 1, movement = movement, x = 4, y = 4 }, sheet) + end + for _, movement in ipairs({ SPRITEMOVEDATA_BIGDOLLSYM, + SPRITEMOVEDATA_BIGDOLLASYM, SPRITEMOVEDATA_BIGDOLL }) do + check(build(movement, BIG_SNORLAX_SHEET).bigObject, + string.format("movement $%02x is a BIG_OBJECT", movement)) + end + check(not build(NPC.MOVE.STILL, BIG_SNORLAX_SHEET).bigObject, + "SPRITEMOVEDATA_STILL is not") + -- SPRITEMOVEDATA_STRENGTH_BOULDER ($19) carries STRENGTH_BOULDER on its own, + -- which is the other half of the same palette-flags byte. + check(not build(0x19, BIG_SNORLAX_SHEET).bigObject, + "and neither is the plain strength boulder, which shares the other bit") + + -- SetFacingBigDoll (engine/overworld/map_object_action.asm). + eq(NPC.bigFacing(SPRITEMOVEDATA_BIGDOLLSYM, "SPRITE_BIG_ONIX"), "sym", + "$15 is always FacingBigDollSymmetric") + eq(NPC.bigFacing(SPRITEMOVEDATA_BIGDOLLASYM, "SPRITE_BIG_SNORLAX"), "asym", + "$20 is always FacingBigDollAsymmetric") + eq(NPC.bigFacing(SPRITEMOVEDATA_BIGDOLL, "SPRITE_BIG_SNORLAX"), "sym", + "$21 reads the doll: SNORLAX is symmetric") + eq(NPC.bigFacing(SPRITEMOVEDATA_BIGDOLL, "SPRITE_BIG_LAPRAS"), "sym", + "LAPRAS is symmetric") + eq(NPC.bigFacing(SPRITEMOVEDATA_BIGDOLL, "SPRITE_BIG_ONIX"), "asym", + "and ONIX is not") + eq(NPC.bigFacing(NPC.MOVE.STILL, "SPRITE_BIG_ONIX"), nil, + "everything else has no big-doll facing at all") +end + +-- ---- FacingBigDollAsymmetric ---------------------------------------------- +-- +-- Fourteen 8x8 tiles over the same 32x32 square, two of them reused X-flipped, +-- and the lower left two cells left empty (data/sprites/facings.asm). +do + local rows = NPC.BIG_DOLL_ASYM + eq(#rows, 14, "the asymmetric doll is fourteen OAM entries") + local seen, flips = {}, 0 + for _, row in ipairs(rows) do + check(row[1] >= 0 and row[1] <= 24 and row[2] >= 0 and row[2] <= 24, + "every entry lands inside the 32x32 square") + if row[3] then flips = flips + 1 end + seen[row[4]] = (seen[row[4]] or 0) + 1 + end + eq(flips, 2, "with two of them X-flipped") + for tile = 0x00, 0x0b do + check(seen[tile] ~= nil, string.format("tile $%02x is placed", tile)) + end + eq(seen[0x02], 2, "tile $02 is the one placed twice") + eq(seen[0x04], 2, "and tile $04 the other") + + -- A tile index is four to a 16x16 sheet frame, row major, the way + -- FacingStepDown0's $00..$03 read off the standing-down frame. + local cases = { + { 0x00, 0, 0 }, { 0x01, 8, 0 }, { 0x02, 0, 8 }, { 0x03, 8, 8 }, + { 0x04, 0, 16 }, { 0x07, 8, 24 }, { 0x08, 0, 32 }, { 0x0b, 8, 40 }, + } + for _, row in ipairs(cases) do + local sx, sy = NPC.bigDollTileRect(row[1]) + eq(sx, row[2], string.format("tile $%02x sits at sheet x %d", row[1], row[2])) + eq(sy, row[3], string.format("and sheet y %d", row[3])) + end +end + +-- ---- and the draw picks the right table ----------------------------------- +do + local G = love.graphics + local realDraw = G.draw + local blits + G.draw = function(...) blits[#blits + 1] = { ... } end + + local function drawWith(movement, sheet) + local npc = NPC.new("TEST_MAP", + { index = 1, movement = movement, x = 0, y = 0 }, sheet) + blits = {} + npc:draw(0, 0, 1) + return #blits + end + + eq(drawWith(SPRITEMOVEDATA_BIGDOLLSYM, BIG_SNORLAX_SHEET), 4, + "the symmetric doll is one half blitted twice, mirrored") + eq(drawWith(SPRITEMOVEDATA_BIGDOLL, BIG_ONIX_SHEET), 14, + "and the asymmetric one is its own fourteen tiles") + eq(drawWith(SPRITEMOVEDATA_BIGDOLL, BIG_SNORLAX_SHEET), 4, + "with the doll's own sprite deciding which, the way SetFacingBigDoll does") + + G.draw = realDraw +end + +-- ---- WillObjectIntersectBigObject ----------------------------------------- +do + local doll = setmetatable({ cellX = 34, cellY = 8, bigObject = true }, NPC) + check(doll:covers(34, 8), "its own cell") + check(doll:covers(35, 8), "one to the right") + check(doll:covers(34, 9), "one below") + check(doll:covers(35, 9), "and the corner") + check(not doll:covers(33, 8), "not the cell to its left") + check(not doll:covers(36, 8), "not two to the right") + check(not doll:covers(34, 7), "not the cell above") + check(not doll:covers(34, 10), "and not two below") + + -- `sub [hl] / jr c, .nope`: the object's coordinates are the TOP LEFT, so + -- the blob never reaches back up or left. + local plain = setmetatable({ cellX = 34, cellY = 8 }, NPC) + check(plain:covers(34, 8), "an ordinary object is its own cell") + check(not plain:covers(35, 8), "and only that one") +end + +-- ---- the world's two consumers -------------------------------------------- +local MAP_W, MAP_H = 20, 20 + +local function fakeMap() + local map + map = { + id = "VERMILION_CITY", + width = MAP_W, height = MAP_H, + def = { bgEvents = {}, objects = {}, width = MAP_W, height = MAP_H }, + cellCollision = function() return COLL_FLOOR end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2 + end, + isWalkable = function(_, x, y) + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + } + return map +end + +local SNORLAX_SCRIPT = "4f:5291" + +local function dollWorld(px, py, facing) + local game = { + data = {}, + save = { player = { name = "GOLD" }, party = {}, inventory = {} }, + } + local world = World.new(game) + game.world = world + world.map = fakeMap() + world.maps = { VERMILION_CITY = world.map.def } + world.player = { + cellX = px, cellY = py, px = px * 16, py = py * 16, + facing = facing, moving = false, turnArmed = true, turnTimer = 0, + update = function() return false end, + setSprite = function() end, + tryMove = function(self, dir, map, entities) + -- The shipped Player:tryMove, narrowed to what this suite asks of it: + -- bounds, the map's own answer, and the entity scan that only ever + -- compares one cell per object. + local d = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local delta = d[dir] + self.facing = dir + local tx, ty = self.cellX + delta[1], self.cellY + delta[2] + if not map:inBounds(tx, ty) then return "edge" end + if not map:isWalkable(tx, ty) then return "blocked" end + for _, e in ipairs(entities or {}) do + if e ~= self and e.cellX == tx and e.cellY == ty then return "blocked" end + end + self.targetX, self.targetY = tx, ty + self.moving = true + return "moved" + end, + } + world.pollTimeOfDay = function() end + local started = {} + world.vm = Vm.new({ [SNORLAX_SCRIPT] = { { op = "end" } } }, {}, + world.events, {}) + local realStart = world.vm.start + world.vm.start = function(self, key) + started[#started + 1] = key + return realStart(self, key) + end + world.started = started + local doll = setmetatable({ + def = { index = 4, movement = SPRITEMOVEDATA_BIGDOLLSYM, + sprite = "SPRITE_BIG_SNORLAX", scriptKey = SNORLAX_SCRIPT, + x = 34, y = 8 }, + id = "doll", cellX = 34, cellY = 8, homeX = 34, homeY = 8, + px = 34 * 16, py = 8 * 16, facing = "down", moving = false, + frozen = false, kind = "stand", radiusX = 0, radiusY = 0, timer = 1, + bigObject = true, + }, NPC) + world.npcs = { doll } + world.entities = { world.player, doll } + return world, doll +end + +-- World:npcAt is the port's IsNPCAtCoord. +do + local world = dollWorld(33, 8, "right") + for _, cell in ipairs({ { 34, 8 }, { 35, 8 }, { 34, 9 }, { 35, 9 } }) do + check(world:npcAt(cell[1], cell[2]) ~= nil, + string.format("npcAt finds the doll at (%d,%d)", cell[1], cell[2])) + end + check(world:npcAt(36, 8) == nil, "and not one cell past its right edge") + check(world:npcAt(34, 10) == nil, "nor one below its bottom edge") +end + +-- CheckFacingObject: an A press from any cell adjacent to any of the four +-- reaches the object's own script. SnorlaxAwake's proximity list is what +-- decides whether it WAKES; being talkable at all is this. +do + local cases = { + { 33, 8, "right", "from its left" }, + { 36, 8, "left", "from its right" }, + { 36, 9, "left", "from the right of its lower half" }, + { 35, 10, "up", "from below its corner" }, + { 34, 7, "down", "from above" }, + { 33, 9, "right", "and from the left of its lower half" }, + } + for _, row in ipairs(cases) do + local world = dollWorld(row[1], row[2], row[3]) + check(world:interact(), "the A press is taken " .. row[4]) + eq(world.started[1], SNORLAX_SCRIPT, "and it runs the doll's own script") + eq(world.vm.lastTalked, 5, + "with hLastTalked set to the object const, whichever cell was faced") + end +end + +-- `.CheckNPC`: the three cells the doll overhangs are refused, not walked into. +do + -- Only the first row is one the entity scan would have caught on its own; + -- the other three are the cells the doll merely overhangs. + local blocked = { + { 34, 8, 33, 8, "right" }, -- its own cell, from the left + { 35, 8, 35, 7, "down" }, -- the top right of the blob, from above + { 34, 9, 33, 9, "right" }, -- the bottom left, from the left + { 35, 9, 35, 10, "up" }, -- the bottom right, from below + } + for _, row in ipairs(blocked) do + local world = dollWorld(row[3], row[4], row[5]) + eq(world:movePlayer(row[5]), "blocked", + string.format("a step into (%d,%d) is refused", row[1], row[2])) + check(not world.player.moving, "and no step is started") + end + -- The cells around it are still walkable, so the blob is a blob and not a + -- wall across the beach. + local world = dollWorld(33, 8, "left") + eq(world:movePlayer("left"), "moved", "the cell to its left is still open") +end + +-- ---- the map itself -------------------------------------------------------- +do + 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 chunk = loadfile(cache .. "/data/generated/maps.lua") + if not chunk then + check(true, "no gold cache: VERMILION_CITY object row (SKIP)") + else + local maps = chunk() + local snorlax + for _, obj in ipairs((maps.VERMILION_CITY or {}).objects or {}) do + if obj.sprite == "SPRITE_BIG_SNORLAX" then snorlax = obj end + end + check(snorlax ~= nil, "VERMILION_CITY carries the big Snorlax object") + eq(snorlax and snorlax.movement, SPRITEMOVEDATA_BIGDOLLSYM, + "as SPRITEMOVEDATA_BIGDOLLSYM") + eq(snorlax and snorlax.x, 34, "at x 34") + eq(snorlax and snorlax.y, 8, "and y 8, so the blob is (34,8)..(35,9)") + local doll = setmetatable({ cellX = snorlax and snorlax.x, + cellY = snorlax and snorlax.y, bigObject = true }, NPC) + -- The cave door at (34,7) is only walkable from the cell the object stands + -- on, so widening the blob cannot open a way past the sleeping Snorlax. + check(not doll:covers(34, 7), + "the cave mouth above it is outside the blob, as the cart has it") + + -- Gold's BIG_OBJECT object_events, all of them: the Snorlax and + -- PLAYERS_HOUSE_2F's big doll decoration. Recognising only $15 left the + -- second one an ordinary one-cell 16x16 NPC. + local big = {} + for id, map in pairs(maps) do + for _, obj in ipairs(map.objects or {}) do + if BIG_MOVEDATA[obj.movement] then + big[#big + 1] = { map = id, obj = obj } + end + end + end + eq(#big, 2, "the cache carries exactly two BIG_OBJECT object_events") + local byMap = {} + for _, row in ipairs(big) do byMap[row.map] = row.obj end + eq(byMap.VERMILION_CITY and byMap.VERMILION_CITY.movement, + SPRITEMOVEDATA_BIGDOLLSYM, "VERMILION_CITY's is $15") + local bigDoll = byMap.PLAYERS_HOUSE_2F + check(bigDoll ~= nil, "PLAYERS_HOUSE_2F carries the other one") + eq(bigDoll and bigDoll.movement, SPRITEMOVEDATA_BIGDOLL, + "as SPRITEMOVEDATA_BIGDOLL") + eq(bigDoll and bigDoll.x, 0, "at x 0") + eq(bigDoll and bigDoll.y, 1, "and y 1, so the blob is (0,1)..(1,2)") + -- Its sprite byte is a wVariableSprites SLOT, which is what + -- src/core/gen2/Decorations.lua fills when a big doll is set up. + eq(type(bigDoll and bigDoll.sprite), "number", + "and its sprite is a wVariableSprites slot rather than a sheet") + + -- The real constructor, on the cache's own row: NPC.new is what decides + -- bigObject, and the doll has to come out of it the same way the Snorlax + -- does. + local npc = NPC.new("PLAYERS_HOUSE_2F", bigDoll, BIG_SNORLAX_SHEET) + check(npc.bigObject, "NPC.new gives the big doll a 2x2 footprint") + for _, cell in ipairs({ { 0, 1 }, { 1, 1 }, { 0, 2 }, { 1, 2 } }) do + check(npc:covers(cell[1], cell[2]), + string.format("which covers (%d,%d)", cell[1], cell[2])) + end + check(not npc:covers(2, 1), "and stops at its right edge") + check(not npc:covers(0, 0), "and never reaches back up") + end +end + +S.finish() diff --git a/tests/gen2_border_test.lua b/tests/gen2_border_test.lua new file mode 100644 index 00000000..e66ac89e --- /dev/null +++ b/tests/gen2_border_test.lua @@ -0,0 +1,139 @@ +-- The border block a map is surrounded by: home/map.asm LoadBlockData / +-- LoadMetatiles. `luajit tests/gen2_border_test.lua`; also dofile'd by +-- tests/run_tests.lua. Fixture-driven, with a final section that reads a real +-- Gold cache and SKIPs when there is none. +-- +-- Three things fail differently here: +-- +-- the SUBSTITUTION block id 0 is not tileset block 0, it is the map +-- header's border block. Reading it literally paints +-- whatever block 0 of the tileset happens to be. +-- the COVERAGE the map canvas is exactly the map's own blocks, so a +-- map smaller than the 20x18 viewport left the rest of +-- the screen on the clear colour. The tiled quad has to +-- cover the view whatever the camera fraction is. +-- the CACHE KEY the bake carries the map's palettes, so it is keyed by +-- daytime / COLOR mode / flicker like the map canvas is, +-- and World:dropMapImages' "<mapId>|" prefix sweep has to +-- still reach it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 border fill") +local check, eq = S.check, S.eq + +local BorderFill = require("src.world.gen2.BorderFill") +local Map = require("src.world.gen2.Map") + +-- --------------------------------------------------------------------------- +-- LoadMetatiles' `ld a,[de] / and a / jr nz / ld a,[wMapBorderBlock]` +-- --------------------------------------------------------------------------- + +eq(BorderFill.blockFor(0, 42), 42, "block id 0 reads as the border block") +eq(BorderFill.blockFor(nil, 42), 42, "a hole in the block list does too") +eq(BorderFill.blockFor(3, 42), 3, "any other id is itself") +eq(BorderFill.blockFor(0, 0), 0, "border block 0 stays block 0") +eq(BorderFill.blockFor(0, nil), 0, "no border block in the header is 0") + +-- Out of bounds already came back as the border block, and now the inside +-- agrees with it: a 1x1 map of block 0 is the border block everywhere. +local hole = Map.new({ + id = "HOLE", width = 1, height = 1, borderBlock = 7, blocks = { 0 }, +}, { collision = {} }) +eq(hole:blockId(-1, 0), 7, "off the left edge is the border block") +eq(BorderFill.blockFor(hole:blockId(0, 0), hole.borderBlock), 7, + "and so is the map's own block 0") + +-- --------------------------------------------------------------------------- +-- Cache key +-- --------------------------------------------------------------------------- + +local mapKey = "GOLDENROD_DEPT_STORE_ELEVATOR|DAY|gbc|1" +local borderKey = BorderFill.cacheKey(mapKey) +check(borderKey ~= mapKey, "the border bake does not share the map's entry") +check(borderKey:sub(1, #mapKey) == mapKey, + "it keeps the daytime / COLOR / flicker key it was built under") +-- World:dropMapImages sweeps every key beginning "<mapId>|", which is what a +-- changeblock or a CUT leans on. The border bake has to go with it. +local prefix = "GOLDENROD_DEPT_STORE_ELEVATOR|" +check(borderKey:sub(1, #prefix) == prefix, + "dropMapImages' prefix sweep reaches the border bake") + +-- --------------------------------------------------------------------------- +-- Coverage: the wrap-tiled quad over the view +-- --------------------------------------------------------------------------- + +eq(BorderFill.SIZE, 32, "one block is 32 pixels") + +-- 160x144 at scale 4 is the plain fit; the camera fractions are the ones a +-- walk animation actually produces (a step is 8 pixels over 8 frames at 1x). +for _, cam in ipairs({ { 0, 0 }, { 3, 5 }, { 12.5, 0.25 }, { -6.75, -3.5 } }) do + for _, s in ipairs({ 1, 2, 4, 7 }) do + local w, h = 640, 576 + local ix, iy, vw, vh, sx, sy = + BorderFill.viewport(cam[1], cam[2], w, h, s) + check(ix == math.floor(cam[1]) and iy == math.floor(cam[2]), + "the source origin is whole texels") + check(sx <= 0 and sy <= 0, "the quad starts at or before the screen edge") + check(sx > -s * BorderFill.SIZE, "and no more than a block before it") + check(sx + vw * s >= w, "the quad covers the width") + check(sy + vh * s >= h, "the quad covers the height") + end +end + +-- A degenerate scale must not divide by zero or hand back a quad of nothing. +local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0) +check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing") + +-- --------------------------------------------------------------------------- +-- The real cache: the map the bug was reported on +-- --------------------------------------------------------------------------- + +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 mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : fixture checks only (SKIP cache facts)") + S.finish() + return +end +mf:close() + +local maps = assert(loadfile(mapsPath))() +local tilesets = assert(loadfile(cache .. "/data/generated/tilesets.lua"))() + +-- 20x18 tiles of viewport is 5x4.5 blocks, so anything under 5 wide or 5 tall +-- shows border on at least one axis at every camera position. +local elevator = maps.GOLDENROD_DEPT_STORE_ELEVATOR +check(elevator ~= nil, "the elevator is in the cache") +eq(elevator.width, 2, "GOLDENROD_DEPT_STORE_ELEVATOR is 2 blocks wide") +eq(elevator.height, 2, "and 2 blocks tall") +check(elevator.width < 5 and elevator.height < 5, + "which is smaller than the 20x18 viewport in both axes") + +local small = 0 +for _, def in pairs(maps) do + if (def.width or 99) < 5 or (def.height or 99) < 5 then small = small + 1 end +end +check(small > 100, "and it is not alone: " .. small .. " maps need the fill") + +-- Every map's border block has to resolve to a real block in its own tileset, +-- or the fill bakes nothing and the map goes back to a black surround. +local missing, checked = {}, 0 +for id, def in pairs(maps) do + local ts = def.tileset and tilesets[def.tileset] + if ts and ts.blocks then + checked = checked + 1 + local block = ts.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] + if type(block) ~= "table" or #block ~= 16 then + missing[#missing + 1] = id + end + end +end +check(checked > 100, "checked " .. checked .. " maps against their tilesets") +eq(#missing, 0, "every border block is a 4x4 metatile in its own tileset") + +S.finish() diff --git a/tests/gen2_boxes_test.lua b/tests/gen2_boxes_test.lua new file mode 100644 index 00000000..67e94cd6 --- /dev/null +++ b/tests/gen2_boxes_test.lua @@ -0,0 +1,127 @@ +-- Gen 2 storage system: 14 boxes of 20, the party<->box moves the PC does, +-- and the rules that stop a deposit or withdrawal from breaking a save. +-- luajit tests/gen2_boxes_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 boxes") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Boxes = require("src.core.gen2.Boxes") + +eq(Boxes.NUM_BOXES, 14, "14 boxes") +eq(Boxes.MONS_PER_BOX, 20, "20 per box") +eq(Boxes.PARTY_SIZE, 6, "6 party slots") + +-- SetDefaultBoxNames +eq(Boxes.defaultName(1), "BOX1", "first default box name") +eq(Boxes.defaultName(14), "BOX14", "last default box name") + +local function mon(species, hp) + return { species = species, name = species, level = 5, + hp = hp == nil and 20 or hp, maxHp = 20 } +end + +local function newSave(party) + return { party = party or {}, boxes = {}, boxNames = {}, currentBox = 1 } +end + +-- ---- names ---------------------------------------------------------------- +local save = newSave() +eq(Boxes.name(save, 3), "BOX3", "unnamed box falls back to the default") +check(Boxes.rename(save, 3, "SAFARI"), "rename accepted") +eq(Boxes.name(save, 3), "SAFARI", "renamed box keeps its name") +check(not Boxes.rename(save, 15, "NOPE"), "box 15 does not exist") +check(not Boxes.rename(save, 0, "NOPE"), "box 0 does not exist") + +-- ---- .CheckCanUsePC ------------------------------------------------------- +check(not Boxes.canUsePc(newSave()), "an empty party cannot open the PC") +check(Boxes.canUsePc(newSave({ mon("TOTODILE") })), "a party of one can") + +-- ---- deposit -------------------------------------------------------------- +save = newSave({ mon("CYNDAQUIL"), mon("PIDGEY") }) +local ok, moved = Boxes.deposit(save, 2, 1) +check(ok, "depositing the second mon works") +eq(moved.species, "PIDGEY", "the right mon moved") +eq(#save.party, 1, "party shrank") +eq(Boxes.count(save, 1), 1, "box 1 holds it") +eq(Boxes.box(save, 1)[1].species, "PIDGEY", "and it is the same mon") + +-- The last healthy mon stays put, or the next step is a whiteout. +local blocked, reason = Boxes.canDeposit(save, 1, 1) +check(not blocked, "the last healthy party mon cannot be deposited") +check(reason:find("last"), "and the refusal says why: " .. tostring(reason)) + +-- A fainted mon is not what keeps you alive, so the healthy one is still last. +save = newSave({ mon("CYNDAQUIL"), mon("PIDGEY", 0) }) +check(not Boxes.canDeposit(save, 1, 1), + "a fainted second mon does not free the healthy one") +check(Boxes.deposit(save, 2, 1), "the fainted one can be deposited") + +-- A full box refuses. +save = newSave({ mon("CYNDAQUIL"), mon("PIDGEY") }) +for _ = 1, Boxes.MONS_PER_BOX do + local box = Boxes.box(save, 1) + box[#box + 1] = mon("RATTATA") +end +check(Boxes.isFull(save, 1), "box 1 is full at 20") +local full, fullReason = Boxes.canDeposit(save, 2, 1) +check(not full, "a full box refuses a deposit") +check(fullReason:find("full"), "and says so: " .. tostring(fullReason)) +-- ...but the next box has room. +check(Boxes.deposit(save, 2, 2), "box 2 takes it instead") +eq(Boxes.count(save, 2), 1, "box 2 now holds one") + +-- ---- withdraw ------------------------------------------------------------- +save = newSave({ mon("CYNDAQUIL") }) +local box = Boxes.box(save, 1) +box[1] = mon("GEODUDE") +box[2] = mon("ZUBAT") +local took +ok, took = Boxes.withdraw(save, 1, 2) +check(ok, "withdrawing the second boxed mon works") +eq(took.species, "ZUBAT", "the right mon came out") +eq(#save.party, 2, "party grew") +eq(Boxes.count(save, 1), 1, "the box shrank") + +-- A full party refuses. +save = newSave({ mon("A"), mon("B"), mon("C"), mon("D"), mon("E"), mon("F") }) +Boxes.box(save, 1)[1] = mon("GEODUDE") +local noRoom, noRoomReason = Boxes.canWithdraw(save, 1, 1) +check(not noRoom, "a full party cannot withdraw") +check(noRoomReason:find("any more"), + "and says so: " .. tostring(noRoomReason)) +check(not Boxes.canWithdraw(newSave({ mon("A") }), 1, 4), + "an empty slot has nothing to withdraw") + +-- ---- release / move ------------------------------------------------------- +save = newSave({ mon("A") }) +Boxes.box(save, 1)[1] = mon("GEODUDE") +ok, took = Boxes.release(save, 1, 1) +check(ok, "release takes the mon out of the box") +eq(took.species, "GEODUDE", "and hands it back") +eq(Boxes.count(save, 1), 0, "the box is empty") +check(not Boxes.release(save, 1, 1), "releasing an empty slot fails") + +save = newSave({ mon("A") }) +Boxes.box(save, 1)[1] = mon("ONIX") +check(not Boxes.move(save, 1, 1, 1), "moving a mon to its own box is refused") +ok = Boxes.move(save, 1, 1, 5) +check(ok, "moving to another box works") +eq(Boxes.count(save, 1), 0, "source box emptied") +eq(Boxes.count(save, 5), 1, "target box filled") +eq(Boxes.box(save, 5)[1].species, "ONIX", "with the same mon") + +-- ---- current box ---------------------------------------------------------- +save = newSave({ mon("A") }) +check(Boxes.setCurrent(save, 7), "box 7 can be made current") +eq(save.currentBox, 7, "currentBox followed") +check(not Boxes.setCurrent(save, 0), "box 0 cannot") +check(not Boxes.setCurrent(save, 15), "box 15 cannot") +eq(save.currentBox, 7, "a rejected change leaves it alone") +-- Boxes.box with no index reads the current one. +Boxes.box(save)[1] = mon("SLOWPOKE") +eq(Boxes.count(save, 7), 1, "the default index is the current box") + +S.finish() diff --git a/tests/gen2_breeding_test.lua b/tests/gen2_breeding_test.lua new file mode 100644 index 00000000..532512bb --- /dev/null +++ b/tests/gen2_breeding_test.lua @@ -0,0 +1,1017 @@ +-- Gen 2 Day-Care, breeding and eggs: the compatibility matrix, the egg species +-- walk, DV and move inheritance from a seeded roll, the step counters, the fee, +-- and the conversation that drives all of it. +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_breeding_test.lua +-- +-- The fixtures below are the shapes data/generated/pokemon.lua writes, with +-- every number traceable to pokegold so a failure names the ASM it disagrees +-- with. The last block re-runs the species questions against a real Gold cache +-- when one is present, and skips when it is not. +-- +-- Every roll is scripted: `call Random` yields one byte and Breeding takes its +-- source as a parameter, so each assertion below names the exact bytes the +-- cart would have drawn rather than hoping a seed lands somewhere useful. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 breeding") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Breeding = require("src.core.gen2.Breeding") +local Mon = require("src.battle.gen2.Mon") + +-- ---- fixtures ------------------------------------------------------------- + +-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm), which keeps every +-- experience number below readable: level 10 is 1000, level 12 is 1728. +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +-- constants/pokemon_data_constants.asm's gender ratios, as the byte BaseData +-- carries. GENDER_F100 is `100 percent - 1` = 254 and GENDER_UNKNOWN is -1. +local F0, F12_5, F50, F100, UNKNOWN = 0, 31, 127, 254, 255 + +local POKEMON = { growthRates = GROWTH } +local nextIndex = 0 + +local function species(id, opts) + opts = opts or {} + nextIndex = nextIndex + 1 + POKEMON[id] = { + id = id, index = nextIndex, dex = nextIndex, name = id, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + types = { "NORMAL", "NORMAL" }, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = opts.genderRatio or F50, + eggGroups = opts.eggGroups or { "EGG_GROUND", "EGG_GROUND" }, + eggGroupsRaw = opts.eggGroupsRaw, + eggSteps = opts.eggSteps or 20, + evolutions = opts.evolutions or {}, + levelMoves = opts.levelMoves or { { level = 1, move = "TACKLE" } }, + tmhm = opts.tmhm or {}, + eggMoves = opts.eggMoves, + } + return POKEMON[id] +end + +-- Declared in dex order, because GetPreEvolution takes the FIRST species that +-- evolves into its target and that tie-break is the table's ordering. +local BULBA_MOVES = { + { level = 1, move = "TACKLE" }, + { level = 4, move = "GROWL" }, + { level = 7, move = "LEECH_SEED" }, + { level = 10, move = "VINE_WHIP" }, + { level = 12, move = "RAZOR_LEAF" }, +} +species("BULBASAUR", { genderRatio = F12_5, + eggGroups = { "EGG_MONSTER", "EGG_PLANT" }, eggGroupsRaw = 0x17, + eggSteps = 20, levelMoves = BULBA_MOVES, + eggMoves = { "LIGHT_SCREEN", "SKULL_BASH" }, tmhm = { "HEADBUTT", "TOXIC" }, + evolutions = { { method = "EVOLVE_LEVEL", level = 16, into = "IVYSAUR" } } }) +species("IVYSAUR", { genderRatio = F12_5, + eggGroups = { "EGG_MONSTER", "EGG_PLANT" }, eggGroupsRaw = 0x17, + levelMoves = BULBA_MOVES, + evolutions = { { method = "EVOLVE_LEVEL", level = 32, into = "VENUSAUR" } } }) +species("VENUSAUR", { genderRatio = F12_5, + eggGroups = { "EGG_MONSTER", "EGG_PLANT" }, eggGroupsRaw = 0x17, + levelMoves = BULBA_MOVES }) + +-- The Nidoran lines: NIDORAN_F is female-only, NIDORAN_M male-only, and the +-- female line is the one DayCare_InitBreeding names outright. +species("NIDORAN_F", { genderRatio = F100, + eggGroups = { "EGG_MONSTER", "EGG_GROUND" }, eggGroupsRaw = 0x15, + evolutions = { { method = "EVOLVE_LEVEL", level = 16, into = "NIDORINA" } } }) +species("NIDORINA", { genderRatio = F100, + eggGroups = { "EGG_NONE", "EGG_NONE" }, eggGroupsRaw = 0xff, + evolutions = { { method = "EVOLVE_ITEM", item = "MOON_STONE", + into = "NIDOQUEEN" } } }) +species("NIDOQUEEN", { genderRatio = F100, + eggGroups = { "EGG_NONE", "EGG_NONE" }, eggGroupsRaw = 0xff }) +species("NIDORAN_M", { genderRatio = F0, + eggGroups = { "EGG_MONSTER", "EGG_GROUND" }, eggGroupsRaw = 0x15 }) + +-- A three-stage chain that is not Nidoran, for the plain double-walk. +species("PICHU", { genderRatio = F50, + eggGroups = { "EGG_NONE", "EGG_NONE" }, eggGroupsRaw = 0xff }) +species("PIKACHU", { genderRatio = F50, + eggGroups = { "EGG_GROUND", "EGG_FAIRY" }, eggGroupsRaw = 0x56, + evolutions = { { method = "EVOLVE_ITEM", item = "THUNDERSTONE", + into = "RAICHU" } } }) +species("RAICHU", { genderRatio = F50, + eggGroups = { "EGG_GROUND", "EGG_FAIRY" }, eggGroupsRaw = 0x56 }) +POKEMON.PICHU.evolutions = { { method = "EVOLVE_HAPPINESS", into = "PIKACHU" } } + +-- TYROGUE is male-only and evolves three ways, so every Hitmon walks back to +-- it in ONE step -- and can only ever breed with a Ditto. +species("TYROGUE", { genderRatio = F0, + eggGroups = { "EGG_NONE", "EGG_NONE" }, eggGroupsRaw = 0xff, + evolutions = { + { method = "EVOLVE_STAT", level = 20, comparison = "ATK_GT_DEF", + into = "HITMONLEE" }, + { method = "EVOLVE_STAT", level = 20, comparison = "ATK_EQ_DEF", + into = "HITMONTOP" } } }) +species("HITMONLEE", { genderRatio = F0, + eggGroups = { "EGG_HUMANSHAPE", "EGG_HUMANSHAPE" }, eggGroupsRaw = 0x88 }) +species("HITMONTOP", { genderRatio = F0, + eggGroups = { "EGG_HUMANSHAPE", "EGG_HUMANSHAPE" }, eggGroupsRaw = 0x88 }) + +species("DITTO", { genderRatio = UNKNOWN, + eggGroups = { "EGG_DITTO", "EGG_DITTO" }, eggGroupsRaw = 0xdd }) +species("MAGNEMITE", { genderRatio = UNKNOWN, + eggGroups = { "EGG_MINERAL", "EGG_MINERAL" }, eggGroupsRaw = 0xaa, + eggSteps = 20 }) +species("GEODUDE", { genderRatio = F50, + eggGroups = { "EGG_MINERAL", "EGG_MINERAL" }, eggGroupsRaw = 0xaa }) +-- EGG_NONE in BOTH nibbles ($ff) is the No-Eggs group. +species("ARTICUNO", { genderRatio = UNKNOWN, + eggGroups = { "EGG_NONE", "EGG_NONE" }, eggGroupsRaw = 0xff }) + +local MOVES = {} +for _, id in ipairs({ "TACKLE", "GROWL", "LEECH_SEED", "VINE_WHIP", + "RAZOR_LEAF", "LIGHT_SCREEN", "SKULL_BASH", "HEADBUTT", "TOXIC", + "FISSURE", "THUNDERSHOCK" }) do + MOVES[id] = { id = id, name = id, pp = 20 } +end + +local DATA = { pokemon = POKEMON, moves = MOVES, + items = { FLOWER_MAIL = { id = "FLOWER_MAIL", isMail = true }, + POTION = { id = "POTION" } } } + +-- ---- helpers -------------------------------------------------------------- + +local function dv(attack, defense, speed, special) + return { attack = attack, defense = defense, speed = speed, + special = special } +end + +local function mon(id, opts) + opts = opts or {} + local moves + if opts.moves then + moves = {} + for i, move in ipairs(opts.moves) do + moves[i] = { id = move, pp = 20, maxPp = 20 } + end + end + local built = Mon.new(DATA, id, opts.level or 10, { + dvs = opts.dvs or dv(5, 5, 5, 5), + moves = moves, + nickname = opts.nickname, + item = opts.item, + }) + built.otId = opts.otId + if opts.hp then built.hp = opts.hp end + if opts.experience then built.experience = opts.experience end + return built +end + +-- `call Random` as a scripted byte list, cycling so a rejection sample cannot +-- run off the end of the fixture. +local function rolls(...) + local list = { ... } + local index = 0 + return function() + index = index % #list + 1 + return list[index] + end +end + +-- Two DV shapes that read the same under BOTH the port's Mon.gender and +-- GetGender's own `attackDV * 16 + speedDV` comparison, so nothing below +-- depends on the rounding difference between them. +local FEMALE_DVS = dv(0, 3, 5, 2) -- attack 0: female for F12_5 and F100 +local MALE_DVS = dv(15, 7, 5, 9) -- attack 15: male for F12_5 and F50 + +local function newSave(party, money) + return { party = party or {}, player = { name = "GOLD", id = 1234, + money = money or 3000 }, pokedex = { seen = {}, caught = {} }, events = {} } +end + +-- ---- constants ------------------------------------------------------------ + +eq(Breeding.EGG_LEVEL, 5, "EGG_LEVEL is 5") +eq(Breeding.HATCH_HAPPINESS, 120, "a hatchling starts at happiness $78") +eq(Breeding.MIN_STEPS_TO_EGG, 150, "the first countdown rejects under 150") +eq(Breeding.STEP_CYCLE, 256, "one step cycle is 256 footfalls") +eq(Breeding.EGG_STEP_PHASE, 0x80, "DoEggStep runs at wStepCount $80") +eq(Breeding.MAX_DAY_CARE_EXP, 0x50FFFF, "the exp ceiling is $50ffff") +eq(Breeding.WITHDRAW_FEE, 100, "the flat fee is 100") +eq(Breeding.PARTY_SIZE, 6, "PARTY_LENGTH is 6") + +-- ---- egg groups ----------------------------------------------------------- + +local first, second = Breeding.eggGroups(POKEMON.BULBASAUR) +eq(first, "EGG_MONSTER", "the high nibble is the first group") +eq(second, "EGG_PLANT", "and the low nibble the second") +check(Breeding.isNoEggs(POKEMON.ARTICUNO), "$ff is the No-Eggs group") +check(not Breeding.isNoEggs(POKEMON.BULBASAUR), "BULBASAUR breeds") +-- The raw byte wins when it is there, and the names are the fixture fallback. +check(Breeding.isNoEggs({ eggGroups = { "EGG_NONE", "EGG_NONE" } }), + "two EGG_NONE names read as No Eggs with no raw byte") +check(not Breeding.isNoEggs({ eggGroups = { "EGG_NONE", "EGG_PLANT" } }), + "one EGG_NONE nibble is not the No-Eggs group") + +-- .CheckBreedingGroupCompatibility +check(Breeding.groupsCompatible(DATA, "BULBASAUR", "VENUSAUR"), + "two mons in the same groups are compatible") +check(not Breeding.groupsCompatible(DATA, "BULBASAUR", "GEODUDE"), + "plant/monster shares nothing with mineral") +check(Breeding.groupsCompatible(DATA, "NIDORAN_F", "BULBASAUR"), + "one shared group (MONSTER) is enough") +check(Breeding.groupsCompatible(DATA, "DITTO", "GEODUDE"), + "Ditto is compatible with anything that breeds") +-- The No-Eggs refusal happens BEFORE the Ditto shortcut, which is the whole +-- reason a legendary cannot be bred with a Ditto. +check(not Breeding.groupsCompatible(DATA, "DITTO", "ARTICUNO"), + "...but not with a No-Eggs species") +check(not Breeding.groupsCompatible(DATA, "ARTICUNO", "DITTO"), + "and the refusal is symmetric") + +-- ---- compatibility -------------------------------------------------------- + +local femaleBulba = mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 1 }) +local maleBulba = mon("BULBASAUR", { dvs = MALE_DVS, otId = 2 }) +local maleVenu = mon("VENUSAUR", { dvs = MALE_DVS, otId = 2 }) + +eq(Breeding.genderOf(DATA, femaleBulba), "female", "attack DV 0 is female") +eq(Breeding.genderOf(DATA, maleBulba), "male", "attack DV 15 is male") + +eq(Breeding.compatibility(DATA, femaleBulba, maleBulba), 254, + "same species, different OT ids") +eq(Breeding.compatibility(DATA, + mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 7 }), + mon("BULBASAUR", { dvs = MALE_DVS, otId = 7 })), 254 - 77, + "same species, same OT id: 254 - 77") +eq(Breeding.compatibility(DATA, femaleBulba, maleVenu), 128, + "different species, different OT ids") +eq(Breeding.compatibility(DATA, + mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 7 }), + mon("VENUSAUR", { dvs = MALE_DVS, otId = 7 })), 128 - 77, + "different species, same OT id: 128 - 77") +-- The port's records carry no OT id yet, so two home-caught mons both read +-- nil and take the same -77 the cart gives two mons with one trainer. +eq(Breeding.compatibility(DATA, mon("BULBASAUR", { dvs = FEMALE_DVS }), + mon("VENUSAUR", { dvs = MALE_DVS })), 51, + "two mons with no OT id are treated as one trainer's") + +eq(Breeding.compatibility(DATA, femaleBulba, + mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 2 })), 0, + "two females never breed") +eq(Breeding.compatibility(DATA, maleBulba, + mon("VENUSAUR", { dvs = MALE_DVS, otId = 1 })), 0, + "two males never breed") +eq(Breeding.compatibility(DATA, femaleBulba, mon("ARTICUNO")), 0, + "a No-Eggs species never breeds") +eq(Breeding.compatibility(DATA, mon("DITTO"), mon("DITTO", { otId = 9 })), 0, + "two Dittos are the one .genderless pair with no way out") +eq(Breeding.compatibility(DATA, mon("MAGNEMITE"), + mon("GEODUDE", { dvs = MALE_DVS })), 0, + "a genderless mon and a gendered one need a Ditto") +eq(Breeding.compatibility(DATA, mon("DITTO", { otId = 1, dvs = FEMALE_DVS }), + mon("MAGNEMITE", { otId = 2, dvs = MALE_DVS })), 128, + "a Ditto rescues a genderless partner") +eq(Breeding.compatibility(DATA, mon("MAGNEMITE", { otId = 2, dvs = MALE_DVS }), + mon("DITTO", { otId = 1, dvs = FEMALE_DVS })), 128, + "on either side of the box") +eq(Breeding.compatibility(DATA, mon("HITMONTOP", { dvs = MALE_DVS, otId = 1 }), + mon("DITTO", { otId = 2 })), 128, + "a male-only species breeds through a Ditto") + +-- .CheckDVs: equal Defense DVs and equal low-3-bits of Special. 255 reads as +-- the FRIENDLIEST message and is the one value DayCare_InitBreeding refuses. +local twinA = mon("BULBASAUR", { dvs = dv(0, 3, 5, 2), otId = 1 }) +local twinB = mon("BULBASAUR", { dvs = dv(15, 3, 5, 10), otId = 2 }) +check(Breeding.dvsMatch(twinA, twinB), "Defense equal and Special % 8 equal") +eq(Breeding.compatibility(DATA, twinA, twinB), 255, "which is the 255 sentinel") +check(not Breeding.dvsMatch(twinA, maleBulba), "different Defense DVs do not") + +-- DayCareMonCompatibilityText's fall-through order. +eq(Breeding.compatibilityText(255), Breeding.COMPATIBILITY_BRIMMING, + "255 is 'brimming with energy'") +eq(Breeding.compatibilityText(0), Breeding.COMPATIBILITY_NONE, "0 is no interest") +eq(Breeding.compatibilityText(254), Breeding.COMPATIBILITY_CARES, + "254 appears to care for") +eq(Breeding.compatibilityText(230), Breeding.COMPATIBILITY_CARES, + "and 230 is the boundary") +eq(Breeding.compatibilityText(177), Breeding.COMPATIBILITY_FRIENDLY, + "177 is friendly") +eq(Breeding.compatibilityText(70), Breeding.COMPATIBILITY_FRIENDLY, + "and 70 is that boundary") +eq(Breeding.compatibilityText(51), Breeding.COMPATIBILITY_INTEREST, + "51 only shows interest") + +-- happiness_egg.asm's ladder, with `percent` = `* $ff / 100`. +eq(Breeding.eggChance(255), 80, "255 rolls at 31 percent + 1") +eq(Breeding.eggChance(230), 80, "230 is the top boundary") +eq(Breeding.eggChance(229), 40, "229 drops to 16 percent") +eq(Breeding.eggChance(170), 40, "170 is that boundary") +eq(Breeding.eggChance(128), 30, "128 is 12 percent") +eq(Breeding.eggChance(110), 30, "110 is that boundary") +eq(Breeding.eggChance(51), 10, "51 is the 4 percent floor") + +-- ---- which parent is the mother ------------------------------------------- + +eq(Breeding.motherSlot(DATA, femaleBulba, maleBulba), 1, + "a female in slot 1 is the mother") +eq(Breeding.motherSlot(DATA, maleBulba, femaleBulba), 2, + "a male in slot 1 makes slot 2 the mother") +eq(Breeding.motherSlot(DATA, mon("DITTO"), maleBulba), 2, + "a Ditto is never the mother: the other one is") +eq(Breeding.motherSlot(DATA, maleBulba, mon("DITTO")), 1, + "on either side") +-- GetGender's `jr z` catches genderless as well as female. +eq(Breeding.motherSlot(DATA, mon("MAGNEMITE"), maleBulba), 1, + "a genderless mon in slot 1 is treated as the mother") + +-- ---- the egg's species ---------------------------------------------------- + +eq(Breeding.preEvolution(DATA, "IVYSAUR"), "BULBASAUR", "one step back") +eq(Breeding.preEvolution(DATA, "BULBASAUR"), nil, "a base form has none") +eq(Breeding.baseForm(DATA, "VENUSAUR"), "BULBASAUR", + "two GetPreEvolution calls walk a three-stage chain to its base") +eq(Breeding.baseForm(DATA, "IVYSAUR"), "BULBASAUR", "and a two-stage one") +eq(Breeding.baseForm(DATA, "BULBASAUR"), "BULBASAUR", "a base form stays put") +eq(Breeding.baseForm(DATA, "RAICHU"), "PICHU", + "a happiness/stone chain walks back just as far as a level one") +eq(Breeding.baseForm(DATA, "NIDOQUEEN"), "NIDORAN_F", + "NIDOQUEEN walks back to NIDORAN_F") +-- EVOLVE_STAT's extra parameter does not change the walk: every Hitmon has +-- exactly one pre-evolution and it is one step away. +eq(Breeding.baseForm(DATA, "HITMONTOP"), "TYROGUE", "HITMONTOP is a TYROGUE egg") +eq(Breeding.baseForm(DATA, "HITMONLEE"), "TYROGUE", "so is HITMONLEE") + +-- The mother decides, not the father. +eq(select(1, Breeding.eggSpecies(DATA, + mon("VENUSAUR", { dvs = FEMALE_DVS }), mon("PIKACHU", { dvs = MALE_DVS }), + rolls(0))), "BULBASAUR", "the egg is the MOTHER's base form") +eq(select(1, Breeding.eggSpecies(DATA, + mon("PIKACHU", { dvs = MALE_DVS }), mon("VENUSAUR", { dvs = FEMALE_DVS }), + rolls(0))), "BULBASAUR", "whichever side of the box she is on") +-- With a Ditto the non-Ditto is the mother whatever its gender, which is how +-- a male-only line has any egg at all. +eq(select(1, Breeding.eggSpecies(DATA, mon("DITTO"), + mon("HITMONTOP", { dvs = MALE_DVS }), rolls(0))), "TYROGUE", + "a Ditto pairing takes the other mon's base form") + +-- "Nidoran can give birth to either gender of Nidoran": `cp 50 percent + 1` +-- is `cp 128` and `jr c` keeps the female. +local nidoMother = mon("NIDORINA", { dvs = FEMALE_DVS }) +local nidoFather = mon("NIDORAN_M", { dvs = MALE_DVS }) +eq(select(1, Breeding.eggSpecies(DATA, nidoMother, nidoFather, rolls(127))), + "NIDORAN_F", "a roll of 127 keeps NIDORAN_F") +eq(select(1, Breeding.eggSpecies(DATA, nidoMother, nidoFather, rolls(128))), + "NIDORAN_M", "a roll of 128 flips to NIDORAN_M") + +-- ---- inherited moves ------------------------------------------------------ + +local father = mon("BULBASAUR", + { dvs = MALE_DVS, moves = { "LIGHT_SCREEN", "HEADBUTT", "FISSURE" } }) +local mother = mon("BULBASAUR", + { dvs = FEMALE_DVS, moves = { "TACKLE", "GROWL" } }) + +-- GetHeritableMoves: the FATHER's list. +eq(Breeding.heritableMoves(DATA, mother, father, 1)[1].id, "LIGHT_SCREEN", + "the father's moves are the heritable ones") +eq(Breeding.heritableMoves(DATA, father, mother, 2)[1].id, "LIGHT_SCREEN", + "from either side of the box") +-- GetBreedmonMovePointer: the MOTHER's list. +eq(Breeding.breedmonMoves(DATA, mother, father, 1)[1].id, "TACKLE", + "the other list is the mother's") + +-- A Ditto plays whichever role the partner leaves open. +local ditto = mon("DITTO", { moves = { "TRANSFORM" } }) +eq(Breeding.heritableMoves(DATA, ditto, father, 2)[1].id, "LIGHT_SCREEN", + "a MALE partner still passes its own moves past a Ditto") +eq(Breeding.heritableMoves(DATA, ditto, mother, 2)[1].id, "TRANSFORM", + "a FEMALE partner makes the Ditto the father") +eq(Breeding.heritableMoves(DATA, mother, ditto, 1)[1].id, "TRANSFORM", + "and .ditto2 falls through to the same answer") +eq(Breeding.heritableMoves(DATA, father, ditto, 1)[1].id, "LIGHT_SCREEN", + "while a male partner keeps its own") +eq(Breeding.breedmonMoves(DATA, ditto, mother, 2)[1].id, "TRANSFORM", + "the Ditto is always the other list") +eq(Breeding.breedmonMoves(DATA, mother, ditto, 1)[1].id, "TRANSFORM", + "on either side") + +-- GetEggMove's three ways in. +local motherMoves = mother.moves +eq(select(2, Breeding.canInheritMove(DATA, "BULBASAUR", "LIGHT_SCREEN", + motherMoves)), "eggMove", "an egg move needs nothing else") +eq(select(2, Breeding.canInheritMove(DATA, "BULBASAUR", "GROWL", + motherMoves)), "levelMove", + "a move BOTH parents know that the baby learns by level") +check(not Breeding.canInheritMove(DATA, "BULBASAUR", "GROWL", + { { id = "TACKLE" } }), "...but not when the mother does not know it") +check(not Breeding.canInheritMove(DATA, "BULBASAUR", "THUNDERSHOCK", + { { id = "THUNDERSHOCK" } }), + "...nor when the baby has no level-up row for it") +eq(select(2, Breeding.canInheritMove(DATA, "BULBASAUR", "HEADBUTT", + motherMoves)), "tmhm", "a TM/HM the baby can learn") +check(not Breeding.canInheritMove(DATA, "BULBASAUR", "FISSURE", motherMoves), + "and nothing else gets through") +-- The reader falls back safely on a cache with no egg_moves.asm in it. +check(not Breeding.canInheritMove(DATA, "GEODUDE", "LIGHT_SCREEN", {}), + "a species with no eggMoves list simply has no egg moves") + +-- LoadEggMove: the first empty slot, or over the OLDEST once full. +local slots = { { id = "A" }, { id = "B" }, { id = "C" } } +Breeding.loadEggMove(slots, "TACKLE", DATA) +eq(#slots, 4, "a fourth move fits") +eq(slots[4].id, "TACKLE", "in the empty slot") +eq(slots[4].pp, 20, "with its PP filled") +Breeding.loadEggMove(slots, "GROWL", DATA) +eq(#slots, 4, "a fifth does not grow the list") +eq(slots[1].id, "B", "the oldest move is the one that goes") +eq(slots[4].id, "GROWL", "and the newcomer takes slot 4") + +-- InitEggMoves over a real level-up base. +local eggMoves = Mon.movesAtLevel(POKEMON.BULBASAUR, Breeding.EGG_LEVEL, MOVES) +eq(#eggMoves, 2, "a level-5 BULBASAUR knows two moves") +Breeding.initEggMoves(DATA, "BULBASAUR", eggMoves, father.moves, motherMoves) +eq(#eggMoves, 4, "two of the father's three get through") +eq(eggMoves[3].id, "LIGHT_SCREEN", "the egg move") +eq(eggMoves[4].id, "HEADBUTT", "and the TM, in the father's slot order") +-- `ld a, [de] / and a / jr z, .done`: an empty slot ENDS the walk. +local stopped = {} +Breeding.initEggMoves(DATA, "BULBASAUR", stopped, + { nil, { id = "LIGHT_SCREEN" } }, motherMoves) +eq(#stopped, 0, "an empty first slot stops the walk dead") + +-- ---- building the egg ----------------------------------------------------- +-- +-- makeEgg spends its rolls in one order: the Nidoran coin (only for a +-- NIDORAN_F egg), then DV byte 0 (Attack<<4|Defense) and DV byte 1 +-- (Speed<<4|Special). +local egg = Breeding.makeEgg(DATA, mother, father, + { rng = rolls(0x9C, 0x35), playerName = "GOLD", playerId = 1234 }) +eq(egg.species, "BULBASAUR", "the egg is the mother's base form") +eq(egg.level, 5, "an egg is level EGG_LEVEL") +eq(egg.experience, 125, "with CalcExpAtLevel's exp for it") +eq(egg.hp, 0, "and zero HP: DayCare_GiveEgg zeroes MON_HP") +check(Breeding.isEgg(egg), "it is flagged as an egg") +check(not Breeding.canFight(egg), "and cannot fight") +eq(egg.nickname, "EGG", "its nickname is the .String_EGG literal") +eq(egg.eggSteps, 20, "the hatch counter is BASE_EGG_STEPS") +eq(egg.otId, 1234, "the player is its original trainer") +-- $9c is Attack 9 / Defense 12, $35 is Speed 3 / Special 5. Attack DV 9 +-- against F12_5 is a MALE egg, so the DVs come from the MOTHER: her whole +-- Defense nibble (3) and the low three bits of her Special (2 & 7 = 2) over +-- the roll's own 5. +eq(egg.dvs.attack, 9, "Attack is the roll, untouched") +eq(egg.dvs.speed, 3, "Speed is the roll, untouched") +eq(egg.dvs.defense, 3, "Defense is the mother's, whole") +eq(egg.dvs.special, 2, "Special keeps the roll's bit 3 and takes her low 3") +eq(egg.moves[1].id, "TACKLE", "the level-5 moveset comes first") +eq(egg.moves[3].id, "LIGHT_SCREEN", "then the inherited egg move") +eq(egg.moves[4].id, "HEADBUTT", "and the inherited TM") + +-- A FEMALE egg takes the FATHER's Defense and low-3 Special instead. $0c is +-- Attack 0 / Defense 12, and Attack 0 against F12_5 is female. +local femaleEgg = Breeding.makeEgg(DATA, mother, father, + { rng = rolls(0x0C, 0x35) }) +eq(Breeding.gender(POKEMON.BULBASAUR, femaleEgg.dvs), "female", + "attack DV 0 hatches female") +eq(femaleEgg.dvs.defense, 7, "so Defense comes from the father") +eq(femaleEgg.dvs.special, 1, "and his Special low bits (9 & 7 = 1)") + +-- .SkipDVs: a genderless egg with no Ditto in the box keeps everything it +-- rolled. (The pair itself would never be compatible -- this is the branch +-- under test, not the conversation.) +local nullEgg = Breeding.makeEgg(DATA, mon("MAGNEMITE"), + mon("GEODUDE", { dvs = MALE_DVS }), { rng = rolls(0x9C, 0x35) }) +eq(nullEgg.species, "MAGNEMITE", "a genderless mother still gives her species") +eq(nullEgg.dvs.defense, 12, "Defense is the roll: .SkipDVs inherits nothing") +eq(nullEgg.dvs.special, 5, "and so is Special") + +-- ...but the Ditto tests come FIRST, so a Ditto donates even to a genderless +-- egg that would otherwise have taken .SkipDVs. +local dittoEgg = Breeding.makeEgg(DATA, + mon("DITTO", { dvs = dv(1, 4, 1, 3) }), mon("MAGNEMITE"), + { rng = rolls(0x9C, 0x35) }) +eq(dittoEgg.species, "MAGNEMITE", "the non-Ditto is the mother") +eq(dittoEgg.dvs.defense, 4, "and the Ditto still donates Defense") +eq(dittoEgg.dvs.special, 3, "and the low three bits of Special") + +-- ---- DayCare_InitBreeding ------------------------------------------------- + +local save = newSave() +local dc = Breeding.dayCare(save) +dc.man.mon = mother +check(not Breeding.initBreeding(DATA, save, { rng = rolls(200) }), + "one deposited mon starts nothing") +dc.lady.mon = mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 3 }) +check(not Breeding.initBreeding(DATA, save, { rng = rolls(200) }), + "two females start nothing either") +check(not dc.compatible, "and MONS_COMPATIBLE_F stays clear") +dc.lady.mon = twinB +dc.man.mon = twinA +check(not Breeding.initBreeding(DATA, save, { rng = rolls(200) }), + "`inc a / ret z` throws out the 255 matching-DVs sentinel") + +save = newSave() +dc = Breeding.dayCare(save) +dc.man.mon, dc.lady.mon = mother, father +-- The rejection sample: 10 is under 150 and is spent, 200 is taken. +check(Breeding.initBreeding(DATA, save, + { rng = rolls(10, 200, 0x9C, 0x35) }), "a compatible pair starts breeding") +check(dc.compatible, "MONS_COMPATIBLE_F is set") +eq(dc.stepsToEgg, 200, "and the countdown rejected the roll under 150") +eq(dc.egg.species, "BULBASAUR", + "the egg is built HERE, not when it finally appears") + +-- ---- DayCareStep ---------------------------------------------------------- + +save = newSave() +dc = Breeding.dayCare(save) +dc.man.mon = mon("BULBASAUR", { level = 10, experience = 1000 }) +dc.lady.mon = mon("VENUSAUR", { level = 10, experience = 1000 }) +Breeding.dayCareStep(DATA, save) +eq(dc.man.mon.experience, 1001, "each step is one point of exp") +eq(dc.lady.mon.experience, 1001, "for both sides") +eq(dc.man.mon.level, 10, "the STORED level never moves") + +dc.man.mon = mon("BULBASAUR", { level = 100, experience = 1000000 }) +Breeding.dayCareStep(DATA, save) +eq(dc.man.mon.experience, 1000000, "a mon stored at MAX_LEVEL earns nothing") +dc.man.mon = mon("BULBASAUR", { level = 10, experience = 0x50FFFF }) +Breeding.dayCareStep(DATA, save) +eq(dc.man.mon.experience, 0x50FFFF, "and the ceiling is $50ffff") + +-- .check_egg only runs while the pair is flagged compatible. +dc.compatible = false +dc.stepsToEgg = 5 +Breeding.dayCareStep(DATA, save) +eq(dc.stepsToEgg, 5, "an uncompatible pair does not count down") + +save = newSave() +dc = Breeding.dayCare(save) +dc.man.mon = mon("BULBASAUR", { dvs = FEMALE_DVS, otId = 1 }) +dc.lady.mon = mon("VENUSAUR", { dvs = MALE_DVS, otId = 2 }) +dc.compatible = true +dc.stepsToEgg = 2 +-- Their compatibility is 128 (different species, different OT), so the roll +-- has to come in under Breeding.eggChance(128) = 30. +eq(Breeding.compatibility(DATA, dc.man.mon, dc.lady.mon), 128, + "the fixture pair is 128") +check(not Breeding.dayCareStep(DATA, save, rolls(99)), + "the first step just decrements") +eq(dc.stepsToEgg, 1, "the countdown moved") +check(not Breeding.dayCareStep(DATA, save, rolls(77, 30)), + "a chance byte of 30 is NOT under 30") +eq(dc.stepsToEgg, 77, "but the countdown was reseeded with a plain byte") +check(not dc.hasEgg, "and no egg was left") +dc.stepsToEgg = 1 +check(Breeding.dayCareStep(DATA, save, rolls(200, 29)), + "a chance byte of 29 IS under 30") +check(dc.hasEgg, "so DAYCAREMAN_HAS_EGG_F is set") +check(not dc.compatible, "and MONS_COMPATIBLE_F is cleared until it is taken") + +-- `dec [hl]` on a zero byte wraps to 255 rather than firing. +dc.compatible = true +dc.stepsToEgg = 0 +Breeding.dayCareStep(DATA, save, rolls(0)) +eq(dc.stepsToEgg, 255, "a countdown at zero wraps to 255") + +-- ---- DoEggStep and the step block ---------------------------------------- + +local function eggWith(steps) + local e = Breeding.makeEgg(DATA, mother, father, { rng = rolls(0x9C, 0x35) }) + e.eggSteps = steps + return e +end + +save = newSave({ eggWith(3), eggWith(5) }) +check(not Breeding.doEggStep(save), "neither egg is ready") +eq(save.party[1].eggSteps, 2, "the first ticked") +eq(save.party[2].eggSteps, 4, "and so did the second") + +save = newSave({ eggWith(1), eggWith(5) }) +check(Breeding.doEggStep(save), "the first egg reaches zero") +eq(save.party[1].eggSteps, 0, "and stops there") +eq(save.party[2].eggSteps, 5, + "the eggs after it are not ticked on that step at all") + +save = newSave({ mon("BULBASAUR"), eggWith(5) }) +Breeding.doEggStep(save) +eq(save.party[2].eggSteps, 4, "a non-egg party slot is skipped, not counted") + +-- engine/overworld/events.asm: wStepCount++, DoEggStep at $80, then +-- DayCareStep -- and a hatch skips DayCareStep for that step. +save = newSave({ eggWith(1) }) +dc = Breeding.dayCare(save) +dc.man.mon = mon("BULBASAUR", { level = 10, experience = 1000 }) +save.stepCount = 0x7E +eq(Breeding.step(DATA, save), nil, "step $7f is quiet") +eq(save.stepCount, 0x7F, "and the counter moved") +eq(dc.man.mon.experience, 1001, "DayCareStep ran") +eq(save.party[1].eggSteps, 1, "the egg did not tick") +eq(Breeding.step(DATA, save), "hatch", "step $80 ticks the egg and it hatches") +eq(save.party[1].eggSteps, 0, "the counter is spent") +eq(dc.man.mon.experience, 1001, + "and the hatch skipped DayCareStep for that step") +-- The counter is a byte. +save.stepCount = 255 +Breeding.step(DATA, save) +eq(save.stepCount, 0, "wStepCount wraps at 256") + +eq(Breeding.stepsToHatch(eggWith(20)), 20 * 256, + "twenty cycles is 5120 footfalls") +eq(Breeding.stepsToHatch(mon("BULBASAUR")), nil, "a mon has no hatch counter") + +-- ---- hatching ------------------------------------------------------------- + +save = newSave({ eggWith(0) }) +local hatched, effects = Breeding.hatch(DATA, save, 1) +check(hatched ~= nil, "the egg hatched") +check(not Breeding.isEgg(hatched), "and is no longer an egg") +eq(hatched.species, "BULBASAUR", "into its stored species") +eq(hatched.level, 5, "at level 5") +eq(hatched.happiness, 120, "with happiness $78, not BASE_HAPPINESS") +eq(hatched.hp, hatched.maxHp, "and MON_HP copied from MON_MAXHP") +eq(hatched.nickname, nil, "declining the naming screen leaves no nickname") +eq(hatched.dvs.defense, 3, "the egg's DVs came through") +eq(hatched.moves[3].id, "LIGHT_SCREEN", "and so did its inherited moves") +check(save.pokedex.seen.BULBASAUR, "SetSeenAndCaughtMon marked it seen") +check(save.pokedex.caught.BULBASAUR, "and caught") +check(not effects.togepi, "and it is not a TOGEPI") +eq(#Breeding.readyToHatch(save), 0, "nothing is left waiting") + +save = newSave({ eggWith(0) }) +hatched = Breeding.hatch(DATA, save, 1, "SPROUT") +eq(hatched.nickname, "SPROUT", "a nickname taken from the naming screen sticks") + +eq(#Breeding.readyToHatch(newSave({ eggWith(0), eggWith(2), eggWith(0) })), 2, + "readyToHatch names every spent counter") + +-- ---- deposit -------------------------------------------------------------- + +save = newSave({ mon("BULBASAUR") }) +check(not Breeding.canOpenDeposit(save), + "`cp 2 / jr c` refuses before the party list even opens") +eq(select(2, Breeding.canOpenDeposit(save)), Breeding.REFUSE_LAST_MON, + "with the OnlyOneMon line") + +save = newSave({ eggWith(5), mon("BULBASAUR"), mon("VENUSAUR") }) +eq(select(2, Breeding.canDeposit(DATA, save, "man", 1)), + Breeding.REFUSE_EGG, "an egg cannot be deposited") + +save = newSave({ mon("BULBASAUR"), mon("VENUSAUR", { hp = 0 }) }) +eq(select(2, Breeding.canDeposit(DATA, save, "man", 1)), + Breeding.REFUSE_LAST_ALIVE, + "the last mon that can still battle stays with you") +check(Breeding.canDeposit(DATA, save, "man", 2), + "but the fainted one may go: CheckCurPartyMonFainted skips the mon itself") + +save = newSave({ mon("BULBASAUR", { item = "FLOWER_MAIL" }), + mon("VENUSAUR") }) +eq(select(2, Breeding.canDeposit(DATA, save, "man", 1)), + Breeding.REFUSE_MAIL, "mail has to come off first") + +save = newSave({ mother, father, mon("GEODUDE") }) +local ok, deposited = Breeding.deposit(DATA, save, "man", 1, + { rng = rolls(200, 0x9C, 0x35) }) +check(ok, "the deposit went through") +eq(deposited.species, "BULBASAUR", "and handed the mon back") +eq(#save.party, 2, "the party shrank") +eq(Breeding.side(save, "man").mon.species, "BULBASAUR", "the man holds her") +check(not Breeding.dayCare(save).compatible, + "one mon in the box starts nothing") +eq(select(2, Breeding.canDeposit(DATA, save, "man", 1)), + Breeding.REFUSE_OCCUPIED, "and he will not take a second") +check(Breeding.deposit(DATA, save, "lady", 1, + { rng = rolls(200, 0x9C, 0x35) }), "the lady takes the father") +check(Breeding.dayCare(save).compatible, + "and the second deposit is what starts the clutch") +eq(Breeding.dayCare(save).stepsToEgg, 200, "with the countdown seeded") + +-- DAYCARE_INTRO_SEEN_F, one bit per side. +save = newSave() +check(Breeding.takeIntro(save, "man"), "the man's intro plays once") +check(not Breeding.takeIntro(save, "man"), "and not again") +check(Breeding.takeIntro(save, "lady"), "the lady keeps her own bit") + +-- ---- withdraw ------------------------------------------------------------- + +save = newSave({}, 500) +dc = Breeding.dayCare(save) +dc.man.mon = mon("BULBASAUR", { level = 10, experience = 1000, + moves = { "TACKLE" } }) +local stored, grown +stored, _, grown = Breeding.levelGrowth(DATA, dc.man) +eq(stored, 10, "the stored level is where it went in") +eq(grown, 0, "and it has grown nothing yet") +eq(Breeding.retrievePrice(0), 100, "a mon that grew nothing still costs 100") +eq(Breeding.retrievePrice(1), 200, "one level is 200") +eq(Breeding.retrievePrice(2), 300, "and each level after is another 100") + +-- Level 12 is 1728 exp under n^3; 1900 is comfortably inside it. +dc.man.mon.experience = 1900 +local newLevel +stored, newLevel, grown = Breeding.levelGrowth(DATA, dc.man) +eq(newLevel, 12, "1900 exp buys level 12") +eq(grown, 2, "which is two levels of growth") + +save.player.money = 200 +eq(select(2, Breeding.canWithdraw(DATA, save, "man")), + Breeding.REFUSE_NO_MONEY, "300 is more than 200") +save.player.money = 500 +save.party = {} +for _ = 1, 6 do save.party[#save.party + 1] = mon("GEODUDE") end +eq(select(2, Breeding.canWithdraw(DATA, save, "man")), + Breeding.REFUSE_PARTY_FULL, "and a full party has nowhere to put him") + +save.party = {} +dc.compatible = true +local back, price +ok, back, price = Breeding.withdraw(DATA, save, "man") +check(ok, "the withdrawal went through") +eq(price, 300, "at 100 per level plus 100") +eq(save.player.money, 200, "and the money came out") +eq(back.level, 12, "the mon comes out at the level its exp bought") +eq(#save.party, 1, "into the party") +eq(Breeding.side(save, "man").mon, nil, "and out of the box") +check(not dc.compatible, + "either withdrawal clears the shared MONS_COMPATIBLE_F") +-- FillMoves with wSkipMovesBeforeLevelUp: only levels 11 and 12. +eq(#back.moves, 2, "one level-up move was learned on the way out") +eq(back.moves[2].id, "RAZOR_LEAF", "the one BULBASAUR learns at 12") +-- The bug move_mon.asm flags: CalcExpAtLevel overwrites the exp, so the 172 +-- points above level 12's threshold are gone. +eq(back.experience, 1728, + "and the surplus experience is thrown away (the ASM's own BUG note)") +eq(back.hp, back.maxHp, "HealPartyMon refilled it") + +-- ---- collecting the egg --------------------------------------------------- + +save = newSave() +dc = Breeding.dayCare(save) +eq(select(2, Breeding.collectEgg(DATA, save)), Breeding.REFUSE_NO_MON, + "there is nothing to collect") +dc.man.mon, dc.lady.mon = mother, father +Breeding.initBreeding(DATA, save, { rng = rolls(200, 0x9C, 0x35) }) +dc.hasEgg = true +dc.compatible = false +for _ = 1, 6 do save.party[#save.party + 1] = mon("GEODUDE") end +eq(select(2, Breeding.collectEgg(DATA, save)), Breeding.REFUSE_PARTY_FULL, + "a full party leaves the egg with him") +check(dc.hasEgg, "and he keeps holding it") +save.party = {} +local taken +ok, taken = Breeding.collectEgg(DATA, save, { rng = rolls(180, 0x0C, 0x35) }) +check(ok, "with room, the egg comes over") +eq(taken.species, "BULBASAUR", "and it is the one built at deposit time") +eq(#save.party, 1, "into the party") +check(not dc.hasEgg, "DAYCAREMAN_HAS_EGG_F is cleared") +-- DayCare_InitBreeding runs right after DayCare_GiveEgg, so the next egg is +-- already waiting before you have walked a step. +check(dc.compatible, "and the next clutch has already started") +eq(dc.stepsToEgg, 180, "with its own countdown") +eq(dc.egg.dvs.defense, 7, "and a freshly rolled egg behind it") + +-- ---- the conversation ----------------------------------------------------- +-- +-- DayCareMan / DayCareLady / DayCareManOutside as a screen. The party list is +-- replaced through the Screens registry so this stays a logic test. +local Screens = require("src.ui.Screens") +local DayCareMenu = require("src.ui.gen2.DayCareMenu") + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local partyChoice +Screens.invalidate() +local screenData = { pokemon = POKEMON, moves = MOVES, items = DATA.items, + audio = {}, screens = { + Gen2PartyMenu = { new = function(_, opts) + partyChoice = opts + return { stub = true } + end }, + } } + +local function newGame(gameSave) + local input = newInput() + return { + input = input, + save = gameSave, + data = screenData, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end }, + }, input +end + +-- Press A until the screen stops asking, so a page count never has to be +-- hard-coded here (the strings' own shapes are asserted by reading TEXT). +local function pressA(screen, input, times) + for _ = 1, times or 1 do + input:press("a") + screen:update(0) + end +end + +-- The FIRST visit gets the long "do you know about EGGS?" script, which is +-- what DayCareIntroText's `inc a` picks while DAYCARE_INTRO_SEEN_F is clear. +local depositSave = newSave({ mother, father, mon("GEODUDE") }) +local depositGame, depositInput = newGame(depositSave) +local screen = DayCareMenu.new(depositGame, { save = depositSave, + side = "man", rng = rolls(200, 0x9C, 0x35) }) +eq(#screen.confirm.pages, #DayCareMenu.TEXT.manIntroEgg, + "the first visit is the long egg explanation") +check(Breeding.side(depositSave, "man").introSeen, + "and DAYCARE_INTRO_SEEN_F is set by showing it") +-- Walk to the last page, where the YES/NO box comes up, then answer YES. +pressA(screen, depositInput, #DayCareMenu.TEXT.manIntroEgg - 1) +eq(screen.confirm.choice, 1, "the box opens on YES") +pressA(screen, depositInput, 1) +check(screen.message ~= nil, "YES asks which mon to raise") +pressA(screen, depositInput, 1) +check(screen.picking, "and then opens the party list") +check(partyChoice ~= nil, "through the Screens registry") +partyChoice.onChoose(1) +check(not screen.picking, "choosing pops it") +eq(#depositSave.party, 2, "and the mon went into the box") +eq(Breeding.side(depositSave, "man").mon.species, "BULBASAUR", + "with the man") + +-- Talking to him again is the withdrawal branch, and a mon that grew nothing +-- gets _BackAlreadyText rather than "Are we geniuses". +local withdrawGame, withdrawInput = newGame(depositSave) +depositSave.player.money = 500 +screen = DayCareMenu.new(withdrawGame, { save = depositSave, side = "man" }) +eq(#screen.confirm.pages, #DayCareMenu.TEXT.backAlready("X"), + "no growth means one yes/no over _BackAlreadyText") +pressA(screen, withdrawInput, #screen.confirm.pages - 1) +pressA(screen, withdrawInput, 1) +eq(#depositSave.party, 3, "YES takes the mon back") +eq(depositSave.player.money, 400, "at the flat ¥100") + +-- A grown mon gets two yes/no boxes: "Are we geniuses" and then the price. +local grownSave = newSave({ mother, father }, 1000) +Breeding.dayCare(grownSave).man.mon = mon("BULBASAUR", + { level = 10, experience = 1900 }) +Breeding.side(grownSave, "man").introSeen = true +local grownGame, grownInput = newGame(grownSave) +screen = DayCareMenu.new(grownGame, { save = grownSave, side = "man" }) +eq(screen.grown, 2, "two levels of growth") +eq(screen.price, 300, "and a ¥300 bill") +pressA(screen, grownInput, #DayCareMenu.TEXT.geniuses("X")) +check(screen.confirm ~= nil, "the second question follows the first") +pressA(screen, grownInput, #DayCareMenu.TEXT.hasGrown("X", 2, 300)) +eq(grownSave.player.money, 700, "and paying takes the 300") +eq(#grownSave.party, 3, "the mon is home") + +-- DayCareManOutside: "Not yet…" with no egg, and the party check AFTER the +-- yes, which is the branch that writes wScriptVar = TRUE. +local eggSave = newSave() +local eggGame, eggInput = newGame(eggSave) +screen = DayCareMenu.new(eggGame, { save = eggSave, side = "outside" }) +eq(screen.message.pages[1][1], "Not yet…", "no egg, no conversation") + +eggSave = newSave() +local eggDc = Breeding.dayCare(eggSave) +eggDc.man.mon, eggDc.lady.mon = mother, father +Breeding.initBreeding(DATA, eggSave, { rng = rolls(200, 0x9C, 0x35) }) +eggDc.hasEgg = true +for _ = 1, 6 do eggSave.party[#eggSave.party + 1] = mon("GEODUDE") end +eggGame, eggInput = newGame(eggSave) +screen = DayCareMenu.new(eggGame, { save = eggSave, side = "outside", + rng = rolls(200, 0x9C, 0x35) }) +pressA(screen, eggInput, #DayCareMenu.TEXT.foundAnEgg) +eq(screen.scriptVar, 1, "a full party sets wScriptVar to TRUE") +check(eggDc.hasEgg, "and he keeps the egg") + +eggSave.party = {} +eggGame, eggInput = newGame(eggSave) +screen = DayCareMenu.new(eggGame, { save = eggSave, side = "outside", + rng = rolls(200, 0x9C, 0x35) }) +pressA(screen, eggInput, #DayCareMenu.TEXT.foundAnEgg) +eq(#eggSave.party, 1, "with room, the egg is handed over") +check(Breeding.isEgg(eggSave.party[1]), "as an egg") +eq(screen.scriptVar, 0, "and wScriptVar stays FALSE") +-- _ReceivedEggText, then SFX_GET_EGG and `ld c, 120 / call DelayFrames` +-- before _TakeGoodCareOfEggText: the pause is between the two lines, so it +-- only starts once the first one has been dismissed. +eq(screen.delay, 0, "the pause has not started while he is still talking") +pressA(screen, eggInput, 1) +eq(screen.delay, 120, "and then it is the 120 frames of the jingle") + +screenData.screens = nil +Screens.invalidate() + +-- The conversation is `opentext` and nothing more: maps/DayCare.asm's two NPC +-- scripts run `special DayCareMan` / `special DayCareLady`, and neither the +-- special nor PrintDayCareText nor YesNoBox blanks the screen, so the Day-Care +-- room stays drawn behind the textbox. An opaque or widescreen screen would +-- take Game2:drawScene's own-surround branch and drop the map. +eq(DayCareMenu.isOpaque, false, "the screen overlays the map, it does not own it") +eq(DayCareMenu.drawsWidescreen, nil, "and never claims the whole window") +eq(DayCareMenu.drawWidescreen, nil, "so drawScene keeps drawing the world") + +-- ---- against a real Gold cache ------------------------------------------- + +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 path = cache .. "/data/generated/pokemon.lua" +local file = io.open(path, "r") +if not file then + check(true, "gold pokemon cache absent - SKIP") + S.finish() + return +end +file:close() + +local cached = assert(loadfile(path))() +local cachedData = { pokemon = cached, moves = {} } + +check(cached.DITTO ~= nil, "DITTO is in the cache") +eq(cached.DITTO.eggGroups[1], "EGG_DITTO", "with the EGG_DITTO group") +eq(cached.DITTO.genderRatio, 255, "and GENDER_UNKNOWN") +eq(cached.BULBASAUR.eggSteps, 20, "BULBASAUR's hatch counter is 20 cycles") +eq(Breeding.stepsToHatch({ isEgg = true, eggSteps = cached.BULBASAUR.eggSteps }), + 5120, "which is 5120 footfalls") + +-- Every base form, walked down the real chains. +for _, row in ipairs({ + { "VENUSAUR", "BULBASAUR" }, { "CHARIZARD", "CHARMANDER" }, + { "BLASTOISE", "SQUIRTLE" }, { "BUTTERFREE", "CATERPIE" }, + { "RAICHU", "PICHU" }, { "CLEFABLE", "CLEFFA" }, + { "WIGGLYTUFF", "IGGLYBUFF" }, { "NIDOQUEEN", "NIDORAN_F" }, + { "NIDOKING", "NIDORAN_M" }, { "HITMONLEE", "TYROGUE" }, + { "HITMONCHAN", "TYROGUE" }, { "HITMONTOP", "TYROGUE" }, + { "JYNX", "SMOOCHUM" }, { "ELECTABUZZ", "ELEKID" }, + { "MAGMAR", "MAGBY" }, { "TYPHLOSION", "CYNDAQUIL" }, + { "FERALIGATR", "TOTODILE" }, { "MEGANIUM", "CHIKORITA" }, + { "AZUMARILL", "MARILL" }, { "TYRANITAR", "LARVITAR" }, + { "CROBAT", "ZUBAT" }, { "BELLOSSOM", "ODDISH" }, + { "POLITOED", "POLIWAG" }, { "SLOWKING", "SLOWPOKE" } }) do + eq(Breeding.baseForm(cachedData, row[1]), row[2], + row[1] .. " walks back to " .. row[2]) +end + +-- The unbreedable groups the cart really ships. +for _, id in ipairs({ "ARTICUNO", "ZAPDOS", "MOLTRES", "MEWTWO", "MEW", + "RAIKOU", "ENTEI", "SUICUNE", "LUGIA", "HO_OH", "CELEBI", "UNOWN", + "DITTO" }) do + local def = cached[id] + if def then + if id == "DITTO" then + check(not Breeding.isNoEggs(def), "DITTO is not in the No-Eggs group") + else + check(Breeding.isNoEggs(def), id .. " is in the No-Eggs group") + end + end +end +-- ...and therefore not even a Ditto can breed with one. +check(not Breeding.groupsCompatible(cachedData, "DITTO", "MEWTWO"), + "a Ditto cannot breed with MEWTWO") +check(Breeding.groupsCompatible(cachedData, "DITTO", "PIKACHU"), + "but it can with PIKACHU") +-- Baby forms are unbreedable in Gen 2, which is why a PICHU egg needs a +-- PIKACHU parent rather than another PICHU. +check(Breeding.isNoEggs(cached.PICHU), "PICHU cannot breed") +check(Breeding.groupsCompatible(cachedData, "PIKACHU", "PIKACHU"), + "PIKACHU can") + +-- Egg moves: absent from the cache today. The reader is built against the +-- field that should exist, so this reports which it is rather than failing. +local withEggMoves = 0 +for _, def in pairs(cached) do + if type(def) == "table" and type(def.eggMoves) == "table" then + withEggMoves = withEggMoves + 1 + end +end +if withEggMoves > 0 then + check(withEggMoves > 100, + "the cache carries egg moves for " .. withEggMoves .. " species") + check(#(cached.BULBASAUR.eggMoves or {}) > 0, "including BULBASAUR's") + eq(select(2, Breeding.canInheritMove(cachedData, "BULBASAUR", + cached.BULBASAUR.eggMoves[1], {})), "eggMove", + "and GetEggMove's first branch reads them") +else + check(true, + "the cache has no eggMoves field yet - SKIP (see the extractor note)") +end + +S.finish() diff --git a/tests/gen2_callasm_test.lua b/tests/gen2_callasm_test.lua new file mode 100644 index 00000000..66d79f96 --- /dev/null +++ b/tests/gen2_callasm_test.lua @@ -0,0 +1,423 @@ +-- The `callasm` / `memcall` / `memcallasm` / `memjump` registry: +-- src/script/gen2/CallAsm.lua, which resolves the bank:addr operand against +-- pokegold-symbols/pokegold.sym and runs the hand-ported routine. +-- +-- Two things are worth asserting here and nothing else is. The first is the +-- wScriptVar contract: only the eleven routines whose asm ends in +-- `ld [wScriptVar], a` may answer with a number, because every other site is +-- followed by an `iffalse` the cart decides some other way. The second is +-- WHICH rows in this cache resolve, pinned name by name: a row that starts +-- answering is either a real site the extractor has finally reached or an +-- address collision, and the two are told apart by hand against +-- pokegold.sym, not by whatever the cache happens to hold. +-- +-- ROM-free: `luajit tests/gen2_callasm_test.lua`. The cache section at the +-- bottom SKIPs when no Gold cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 callasm") +local check, eq = S.check, S.eq + +local CallAsm = require("src.script.gen2.CallAsm") + +-- ---- the site table ------------------------------------------------------- +do + local sites = 0 + for _ in pairs(CallAsm.SITES) do sites = sites + 1 end + eq(sites, 47, "every callasm / memcall / memcallasm / memjump target in the cart") + + local unmapped = {} + for key, name in pairs(CallAsm.SITES) do + if not CallAsm.ALL[name] then unmapped[#unmapped + 1] = key .. " " .. name end + end + eq(#unmapped, 0, "every site has a port or a stub: " .. table.concat(unmapped, ", ")) + + -- Disjoint by construction: CallAsm.lua raises if a name lands in both, so + -- this is the assertion that the raise has not been defeated by a rename. + for name in pairs(CallAsm.HANDLERS) do + check(CallAsm.STUBS[name] == nil, name .. " is ported, not stubbed") + end + for name in pairs(CallAsm.STUBS) do + check(CallAsm.STUB_REASONS[name] ~= nil, name .. " says why it is a stub") + end + + -- pokegold-symbols/pokegold.sym, spot-checked across banks so a table that + -- was rebuilt against the wrong ROM revision fails here rather than silently + -- calling the neighbouring routine. + eq(CallAsm.SITES["11:4055"], "CheckFruitTree", "11:4055 is CheckFruitTree") + eq(CallAsm.SITES["04:68e0"], "HalveMoney", "04:68e0 is HalveMoney") + eq(CallAsm.SITES["03:4d7b"], "TryStrengthOW", "03:4d7b is TryStrengthOW") + eq(CallAsm.SITES["2e:6378"], "TreeMonEncounter", "2e:6378 is TreeMonEncounter") + eq(CallAsm.SITES["14:4786"], "CheckCanUseSquirtbottle", + "14:4786 is _Squirtbottle.CheckCanUseSquirtbottle") + + -- The three WRAM operands are NOT sites: the pointer at the address is + -- written at run time, so there is nothing static to resolve. + eq(CallAsm.SITES["00:cfd8"], nil, "wQueuedScriptBank is not a routine") + eq(CallAsm.MEM_OPERANDS["00:cfd8"], "wQueuedScriptBank", + "it is a memjump / memcallasm operand instead") +end + +-- ---- the key format ------------------------------------------------------- +do + eq(CallAsm.key(0x11, 0x4055), "11:4055", "two hex digits and four, lower case") + eq(CallAsm.key(0x04, 0x0800), "04:0800", "both halves are zero padded") + eq(CallAsm.key(0, 0), "00:0000", "bank 0 at 0") +end + +-- ---- the wScriptVar contract ---------------------------------------------- +-- +-- The eleven routines whose asm writes wScriptVar, taken one at a time out of +-- the decomp. Everything else must answer nil however it is called, because +-- Script_callasm itself never touches wScriptVar and inventing a byte picks a +-- branch at random. +local WRITES_SCRIPT_VAR = { + CheckFruitTree = true, -- engine/events/fruit_trees.asm + SweetScentEncounter = true, -- engine/events/sweet_scent.asm + CheckCanUseSquirtbottle = true, -- engine/events/squirtbottle.asm + CheckWhitedOut = true, -- engine/events/poisonstep.asm + TryStrengthOW = true, -- engine/events/overworld.asm + CheckContinueWaterfall = true, -- engine/events/overworld.asm + AskCutScript_CheckMap = true, -- engine/events/overworld.asm + HasRockSmash = true, -- engine/events/overworld.asm + Fishing_CheckFacingUp = true, -- engine/events/overworld.asm + TreeMonEncounter = true, -- engine/events/treemons.asm + TryReceiveItem = true, -- engine/events/misc_scripts.asm +} + +do + local writers = 0 + for _ in pairs(WRITES_SCRIPT_VAR) do writers = writers + 1 end + eq(writers, 11, "eleven of the fifty-seven sites write wScriptVar") + + -- An empty ctx: no map, no party, no save. Every routine still has to come + -- back with the right SHAPE of answer, which is the whole contract. A + -- STUBBED writer is the one case that answers nil anyway: a routine that + -- never ran must not pick the branch that follows it. + for name in pairs(CallAsm.ALL) do + local value = CallAsm.run({}, name) + if WRITES_SCRIPT_VAR[name] and CallAsm.HANDLERS[name] then + eq(type(value), "number", name .. " answers a wScriptVar byte") + check(type(value) == "number" and value >= 0 and value < 256, + name .. " answers one byte") + else + eq(value, nil, name .. " leaves wScriptVar alone") + end + end + + -- TryReceiveItem is the one wScriptVar writer that is a STUB, and it is the + -- reason the stub table carries a nil value rather than a 0: the "no room" + -- arm must not be picked by a routine that never ran. + eq(CallAsm.run({}, "TryReceiveItem"), nil, + "the stubbed writer still answers nil, not a guessed branch") +end + +-- ---- dispatch -------------------------------------------------------------- +do + local halved = { game = { save = { player = { money = 5001 } } } } + -- 04:68e0 is HalveMoney, reached with no label at all -- which is how every + -- callasm in this cache arrives, because nothing resolves symbols at import. + eq(CallAsm.dispatch(halved, nil, 0x04, 0x68e0), nil, "HalveMoney writes no wScriptVar") + eq(halved.game.save.player.money, 2500, + "the 24-bit srl/rra/rra is floor division") + + -- An address nobody has named answers nil rather than falling through to a + -- neighbour, and a label the table does not know is ignored in favour of the + -- address. + eq(CallAsm.dispatch({}, nil, 0x45, 0x9752), nil, "an unknown site is nil") + eq(CallAsm.nameFor("NotARoutine", 0x11, 0x4055), "CheckFruitTree", + "an unknown label falls back to the address") + eq(CallAsm.nameFor("HalveMoney", 0x11, 0x4055), "HalveMoney", + "a known label wins over the address") + + -- A handler that throws must not take the script down with it: the cart's + -- own callasm cannot fail, and the branch after it still needs the + -- "left alone" answer rather than an aborted coroutine. + local exploding = setmetatable({}, { __index = function() + return function() error("boom") end + end }) + eq(CallAsm.run(exploding, "SelectMenu"), nil, "a throwing handler answers nil") +end + +-- ---- the routines, one at a time ------------------------------------------ + +-- engine/events/fruit_trees.asm. The flag means "already picked" and +-- FruitTreeScript reads it with `iffalse .fruit`, so a picked tree is the 1. +do + local picked = {} + local ctx = { + curFruitTree = 7, + fruitTreeItem = function(_self, tree) return 100 + tree end, + fruitTreeReset = function() picked.reset = true end, + fruitTreePicked = function(_self, tree) return tree == 7 end, + fruitTreePick = function(_self, tree) picked.tree = tree end, + } + CallAsm.run(ctx, "GetCurTreeFruit") + eq(ctx.curFruit, 107, "GetCurTreeFruit leaves the item in wCurFruit") + CallAsm.run(ctx, "TryResetFruitTrees") + check(picked.reset, "TryResetFruitTrees runs the daily refill") + eq(CallAsm.run(ctx, "CheckFruitTree"), 1, "a picked tree answers 1") + ctx.curFruitTree = 8 + eq(CallAsm.run(ctx, "CheckFruitTree"), 0, "an unpicked one answers 0") + CallAsm.run(ctx, "PickedFruitTree") + eq(picked.tree, 8, "PickedFruitTree sets the flag and writes no wScriptVar") +end + +-- engine/events/overworld.asm GetPartyNickname: wCurPartyMon's nickname into +-- the string buffers, which is what {STRBUF} reads back. +do + local named + local ctx = { + curPartyMon = { nickname = "SPARKY" }, + setNickname = function(_self, mon) named = mon end, + } + CallAsm.run(ctx, "GetPartyNickname") + eq(named.nickname, "SPARKY", "the mon CheckPartyMove picked is named") + + -- With nothing in wCurPartyMon the cart still reads slot 0. + local first = { game = { save = { party = { { nickname = "TOTO" } } } }, + setNickname = function(_self, mon) named = mon end } + CallAsm.run(first, "GetPartyNickname") + eq(named.nickname, "TOTO", "an unset wCurPartyMon is slot 0") +end + +-- engine/events/overworld.asm HasRockSmash is INVERTED: 1 means the party does +-- NOT know it, which is why AskRockSmashScript reads it with `ifequal 1, .no`. +do + local without = { partyMoveUser = function() return nil end } + local with = { partyMoveUser = function() return { nickname = "ONIX" } end } + eq(CallAsm.run(without, "HasRockSmash"), 1, "no ROCK SMASH in the party is 1") + eq(CallAsm.run(with, "HasRockSmash"), 0, "a mon that knows it is 0") +end + +-- engine/events/overworld.asm Fishing_CheckFacingUp: `and $c / cp OW_UP`. +do + eq(CallAsm.run({ player = { facing = "up" } }, "Fishing_CheckFacingUp"), 1, + "facing up is the only 1") + eq(CallAsm.run({ player = { facing = "left" } }, "Fishing_CheckFacingUp"), 0, + "any other facing is 0") +end + +-- engine/events/overworld.asm PutTheRodAway: wPlayerAction back to normal, +-- which in this port is the fishing state going away. +do + local ctx = { fishing = { phase = "done" } } + CallAsm.run(ctx, "PutTheRodAway") + eq(ctx.fishing, nil, "the rod pose is dropped") +end + +-- engine/events/overworld.asm SetStrengthFlag: BIKEFLAGS_STRENGTH_ACTIVE, the +-- species, and a tail call into GetPartyNickname. +do + local named + local ctx = { + curPartyMon = { nickname = "GEODUDE", species = "GEODUDE" }, + setNickname = function(_self, mon) named = mon end, + } + eq(CallAsm.run(ctx, "SetStrengthFlag"), nil, "SetStrengthFlag writes no wScriptVar") + check(ctx.strengthActive, "STRENGTH is switched on") + eq(ctx.strengthSpecies, "GEODUDE", "and wStrengthSpecies is the mon's") + eq(named.nickname, "GEODUDE", "the nickname is copied in the same routine") +end + +-- engine/events/squirtbottle.asm .CheckCanUseSquirtbottle: Route 36, and the +-- object faced has to carry SPRITEMOVEDATA_SUDOWOODO ($17). +do + local function ctxWith(mapId, movement) + return { + map = { id = mapId }, + player = { cellX = 5, cellY = 5, facing = "up" }, + npcAt = function(_self, x, y) + if x == 5 and y == 4 and movement then + return { def = { movement = movement } } + end + return nil + end, + } + end + eq(CallAsm.run(ctxWith("ROUTE_36", 0x17), "CheckCanUseSquirtbottle"), 1, + "Route 36 plus a Sudowoodo is the 1") + eq(CallAsm.run(ctxWith("ROUTE_36", 0x19), "CheckCanUseSquirtbottle"), 0, + "a STRENGTH boulder on the same tile is not") + eq(CallAsm.run(ctxWith("ROUTE_36", nil), "CheckCanUseSquirtbottle"), 0, + "GetFacingObject's own carry is the same 0") + eq(CallAsm.run(ctxWith("ROUTE_35", 0x17), "CheckCanUseSquirtbottle"), 0, + "and the map check comes first") +end + +-- engine/events/hidden_item.asm SetMemEvent: the flag whose NUMBER is in +-- wHiddenItemEvent. wEventFlags is keyed by number, not by name. +do + local set = {} + local ctx = { + hiddenItemEvent = 173, + events = { set = function(_self, flag, value) set[flag] = value end }, + } + eq(CallAsm.run(ctx, "SetMemEvent"), nil, "SetMemEvent writes no wScriptVar") + eq(set[173], true, "the hidden item's flag is set by number") +end + +-- engine/events/poisonstep.asm .CheckWhitedOut ends on +-- CheckPlayerPartyForFitMon, whose answer is 1 when something can still fight; +-- the `iffalse .whiteout` after it is reading "no fit mon". +do + local fit = { game = { save = { party = { { hp = 4, maxHp = 20 } } } } } + local out = { game = { save = { party = { { hp = 0, maxHp = 20 } } } } } + eq(CallAsm.run(fit, "CheckWhitedOut"), 1, "a mon that can still fight is 1") + eq(CallAsm.run(out, "CheckWhitedOut"), 0, "a whited-out party is 0") +end + +-- engine/events/whiteout.asm HalveMoney: the wallet ALONE. Mom's savings are +-- a separate three bytes the routine never reaches. +do + local ctx = { game = { save = { + player = { money = 9999 }, + mom = { savedMoney = 4000 }, + } } } + CallAsm.run(ctx, "HalveMoney") + eq(ctx.game.save.player.money, 4999, "9999 halves to 4999, remainder dropped") + eq(ctx.game.save.mom.savedMoney, 4000, "Mom's savings are untouched") +end + +-- engine/events/whiteout.asm GetWhiteoutSpawn: IsSpawnPoint over the +-- `blackoutmod` pair, SPAWN_HOME when it is not one. +do + local spawns = { + SPAWN_HOME = { map = "PLAYERS_HOUSE_1F" }, + SPAWN_OLIVINE = { map = "OLIVINE_POKECENTER_1F" }, + } + local known = { landmarks = { spawns = spawns }, + game = { save = { blackoutMap = "OLIVINE_POKECENTER_1F" } } } + CallAsm.run(known, "GetWhiteoutSpawn") + eq(known.defaultSpawnpoint, "SPAWN_OLIVINE", "a real spawn point is kept") + + local adrift = { landmarks = { spawns = spawns }, + game = { save = { blackoutMap = "FAST_SHIP_1F" } } } + CallAsm.run(adrift, "GetWhiteoutSpawn") + eq(adrift.defaultSpawnpoint, "SPAWN_HOME", + "somewhere that is not a spawn point falls back to SPAWN_HOME") +end + +-- engine/items/itemfinder.asm .ItemfinderSound: `ld c, 4` around a pair of +-- WaitPlaySFX calls, so eight sounds, alternating. +do + local played = {} + local ctx = { playSfxNamed = function(_self, name) played[#played + 1] = name end } + CallAsm.run(ctx, "ItemfinderSound") + eq(#played, 8, "four loops of two sounds") + eq(played[1], "Sfx_SecondPartOfItemfinder", "the ping comes first") + eq(played[2], "Sfx_Transaction", "then the transaction blip") + eq(played[8], "Sfx_Transaction", "and the pair repeats to the end") +end + +-- engine/overworld/events.asm HatchEggScript is one command: `callasm +-- OverworldHatchEgg / end`. World:countStep runs it through the registry. +do + local hatched = false + local ctx = { hatchEggs = function() hatched = true end } + eq(CallAsm.run(ctx, "OverworldHatchEgg"), nil, "the hatch writes no wScriptVar") + check(hatched, "and it runs HatchEggs") +end + +-- ---- the World seam -------------------------------------------------------- +do + local World = require("src.world.gen2.World") + local ctx = { game = { save = { player = { money = 100 } } } } + -- World:callAsm is what src/script/gen2/Vm.lua's callasm branch calls, and + -- it dispatches on the address because `label` is always nil today. + eq(World.callAsm(ctx, nil, 0x04, 0x68e0), nil, "the seam answers nil for HalveMoney") + eq(ctx.game.save.player.money, 50, "and the routine ran") + eq(World.callAsm(ctx, nil, 0x45, 0x9752), nil, "a garbage address is a no-op") + eq(ctx.game.save.player.money, 50, "and changes nothing") +end + +-- ---- the cache ------------------------------------------------------------- +-- +-- WHICH rows carry one of the four opcodes and resolve, pinned exactly. +-- +-- The row count is deliberately not pinned. It was thirty-eight before the +-- extractor stopped walking three-byte `hiddenitem` bg_event operands as +-- bytecode, and every one of those thirty-eight sat inside a key made out of +-- one (45:697a is IlexForestHiddenEther, 47:40a5 is +-- WhirlIslandB1FHiddenRareCandy), so their bank/address pairs were noise; a +-- cache built after that fix has none at all. +-- +-- The RESOLUTIONS are pinned, because a row that starts resolving is either a +-- real site the extractor has finally reached or an address collision, and +-- both want reading before the registry answers them. These five appeared +-- when `farscall` / `farsjump` / `farwritetext` stopped reading their `dba` +-- operand backwards: the swap sent 40:4154 and 40:4158 at 4d:4e03 / 4f:6003 +-- instead of AskStrengthScript / AskRockSmashScript, so the STRENGTH and ROCK +-- SMASH arms of engine/events/overworld.asm were unreachable and their +-- `callasm` rows were never walked. All five are exact pokegold.sym matches, +-- not collisions: +-- +-- 03:4d30 Script_UsedStrength -> 03:4d15 SetStrengthFlag +-- 03:4d4e AskStrengthScript -> 03:4d7b TryStrengthOW +-- 03:4f35 RockSmashScript -> 03:474b GetPartyNickname +-- 03:4f35 RockSmashScript -> 2e:63a1 RockMonEncounter +-- 03:4f60 AskRockSmashScript -> 03:4f7f HasRockSmash +local EXPECTED_RESOLVED = { + "03:4d30 03:4d15 -> SetStrengthFlag", + "03:4d4e 03:4d7b -> TryStrengthOW", + "03:4f35 03:474b -> GetPartyNickname", + "03:4f35 2e:63a1 -> RockMonEncounter", + "03:4f60 03:4f7f -> HasRockSmash", +} +local ASM_OPS = { + callasm = true, memcall = true, memcallasm = true, memjump = true, +} + +local cacheDir = os.getenv("GOLD_CACHE") +if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" +end +local scriptsFile = loadfile(cacheDir .. "/data/generated/scripts.lua") +if not scriptsFile then + check(true, "no Gold cache (SKIP)") + S.finish() + return +end +local scripts = scriptsFile() + +local rows, resolved = 0, {} +for key, list in pairs(scripts) do + if type(list) == "table" then + for _, cmd in ipairs(list) do + if type(cmd) == "table" and ASM_OPS[cmd.op] then + rows = rows + 1 + local args = cmd.args or {} + -- callasm is bank, lo, hi; the other three are lo, hi out of WRAM. + local bank, addr + if cmd.op == "callasm" then + bank = args[1] or 0 + addr = (args[2] or 0) + (args[3] or 0) * 0x100 + else + bank = 0 + addr = (args[1] or 0) + (args[2] or 0) * 0x100 + end + local name = CallAsm.nameFor(cmd.label, bank, addr) + if name then + resolved[#resolved + 1] = + ("%s %s -> %s"):format(key, CallAsm.key(bank, addr), name) + end + end + end + end +end + +check(rows >= 0, + ("%d rows in this cache carry callasm / memcall / memcallasm / memjump") + :format(rows)) +-- `pairs` over the script table has no order, so sort before comparing. +table.sort(resolved) +eq(table.concat(resolved, ", "), table.concat(EXPECTED_RESOLVED, ", "), + "and exactly the five field-move sites resolve") +for _, line in ipairs(resolved) do + local name = line:match("-> (%S+)$") + check(CallAsm.HANDLERS[name] ~= nil or CallAsm.STUBS[name] ~= nil, + name .. " is ported or stubbed, so the site answers something deliberate") +end + +S.finish() diff --git a/tests/gen2_canlose_test.lua b/tests/gen2_canlose_test.lua new file mode 100644 index 00000000..f4e9c6d3 --- /dev/null +++ b/tests/gen2_canlose_test.lua @@ -0,0 +1,214 @@ +-- BATTLETYPE_CANLOSE: losing the Cherrygrove rival battle must NOT whiteout. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_canlose_test.lua +-- +-- maps/CherrygroveCity.asm arms all three rival arms with +-- +-- loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE +-- startbattle +-- dontrestartmapmusic +-- reloadmap +-- iftrue .AfterYourDefeat +-- +-- and the type changes what a loss MEANS. LostBattle (engine/battle/ +-- core.asm) answers BATTLETYPE_CANLOSE by sliding the winner's pic in and +-- printing the loss text, then RETURNS: no grayscale, no whiteout script, no +-- money halved. The script continues at the battle site -- `reloadmap`, not +-- `reloadmapafterbattle`, so Script_BattleWhiteout is unreachable -- and its +-- .AfterYourDefeat arm plays the "you lost" line, the shove and the rival's +-- walk-off along his own scripted path, ending on `special HealParty`. +-- +-- The port warped every loss to the spawn point and then let the script +-- resume, so the rival's exit movement replayed at the Pokemon Center door, +-- walking him through the trees that stand there. What is asserted here is +-- the loss arm of the REAL World:startScriptedBattle -> World:startBattle +-- chain, with the type armed the way Script_loadvar arms it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 canlose") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") +local Mon = require("src.battle.gen2.Mon") + +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local pokemon = loadLua("data/generated/pokemon.lua") +local moves = loadLua("data/generated/moves.lua") + +-- constants/battle_constants.asm / engine/overworld/variables.asm. +local VAR_BATTLETYPE = 0x03 +local BATTLETYPE_CANLOSE = 1 + +-- The two battle screens as registry fakes: the transition hands off +-- immediately, the battle parks its onDone for the test to lose with. +local battleDone +local registry = { + Gen2BattleTransition = { new = function(_g, opts) + opts.onDone() + return { screenId = "Gen2BattleTransition" } + end }, + Gen2BattleState = { new = function(_g, opts) + battleDone = opts.onDone + return { screenId = "Gen2BattleState" } + end }, +} + +local function makeStack() + local stack = { items = {} } + function stack:push(inst) self.items[#self.items + 1] = inst end + function stack:pop() + local top = self.items[#self.items] + self.items[#self.items] = nil + return top + end + return stack +end + +-- The rival's record, in the shape World:trainerParty answers `loadtrainer` +-- with: a roster Trainers.party builds real mons from. +local function rivalRecord() + return { + class = "RIVAL1", classId = 9, id = 1, + name = "?", className = "RIVAL", trainerName = "?", + roster = { { species = "TOTODILE", level = 5 } }, + baseMoney = 35, + } +end + +local function makeWorld() + battleDone = nil + local game = { + data = { audio = {}, screens = registry, pokemon = pokemon, + moves = moves }, + save = { + player = { name = "GOLD", money = 3000 }, + -- The loser's party: wiped, the way a lost battle leaves it. + party = {}, + -- Walking into the Cherrygrove centre banked the respawn there, so a + -- whiteout that DOES fire has somewhere deterministic to land. + blackoutMap = "CHERRYGROVE_POKECENTER_1F", + }, + stack = makeStack(), + } + local mon = Mon.new(game.data, "CYNDAQUIL", 5) + check(mon ~= nil, "the cache can build the player's starter") + mon.hp = 0 + game.save.party[1] = mon + local w = World.new(game) + w.maps, w.tilesets = maps, tilesets + w.map = Map.new(maps.CHERRYGROVE_CITY, tilesets[maps.CHERRYGROVE_CITY.tileset]) + w.player = { cellX = 39, cellY = 7, facing = "right", moving = false } + w.loaded = nil + w.setMap = function(self, id, x, y, facing) + self.loaded = { id = id, x = x, y = y, facing = facing } + return true + end + return w, game +end + +-- ---- the rival's loss: armed CANLOSE, no whiteout ------------------------- +do + local w, game = makeWorld() + -- Script_loadvar's write, exactly as the extracted script runs it. + w:writeVar(VAR_BATTLETYPE, BATTLETYPE_CANLOSE) + eq(w:battleType(), BATTLETYPE_CANLOSE, "the type is armed") + + local outcome + check(w:startScriptedBattle(rivalRecord(), nil, function(result) + outcome = result + end), "the scripted battle starts") + check(battleDone ~= nil, "the battle screen is up") + battleDone("lose") + + eq(outcome, "lose", + "the script resumes with LOSE, so `iftrue` takes .AfterYourDefeat") + check(w.loaded == nil, + "no warp: the loser is still standing at the battle site") + eq(game.save.player.money, 3000, "no HalveMoney either") + eq(game.save.party[1].hp, 0, + "and no engine heal -- .FinishRival's `special HealParty` owns that") +end + +-- ---- the same loss without CANLOSE whiteouts as before -------------------- +do + local w, game = makeWorld() + eq(w:battleType(), 0, "no type armed: an ordinary trainer loss") + + local outcome + check(w:startScriptedBattle(rivalRecord(), nil, function(result) + outcome = result + end), "the battle starts") + battleDone("lose") + + eq(outcome, "lose", "the resume still reports the loss") + check(w.loaded ~= nil, "Script_BattleWhiteout warps home") + eq(w.loaded.id, "CHERRYGROVE_POKECENTER_1F", "to the banked centre") + eq(game.save.player.money, 1500, "with the wallet halved") + check(game.save.party[1].hp > 0, "and the party healed") +end + +-- ---- the type is a one-shot, as BattleStart resets wBattleType ------------ +do + local w = makeWorld() + w:writeVar(VAR_BATTLETYPE, BATTLETYPE_CANLOSE) + check(w:startScriptedBattle(rivalRecord(), nil, function() end), + "a CANLOSE battle starts") + eq(w:battleType(), 0, "and consumes the type on the way in") + battleDone("win") +end + +-- ---- the premise, pinned against the cache -------------------------------- +-- The extracted rival scene really is the cart's shape: `loadvar 3, 1` (VAR_ +-- BATTLETYPE, BATTLETYPE_CANLOSE), `startbattle`, then a bare `reloadmap` -- +-- never `reloadmapafterbattle` -- and a loss arm whose walk-off movements and +-- `special` HealParty run at the battle site. A re-import that lost any of +-- this would green the units above while the game diverged. +do + local scripts = loadLua("data/generated/scripts.lua") + local seen = 0 + for _, ev in ipairs(maps.CHERRYGROVE_CITY.coordEvents or {}) do + local body = scripts[ev.scriptKey] + if body then + seen = seen + 1 + local armed, battled, reloaded, afterBattle + for index, cmd in ipairs(body) do + if cmd.op == "loadvar" and cmd.args and cmd.args[1] == VAR_BATTLETYPE + then + eq(cmd.args[2], BATTLETYPE_CANLOSE, + ev.scriptKey .. " arms BATTLETYPE_CANLOSE") + armed = index + elseif cmd.op == "startbattle" then + battled = index + elseif cmd.op == "reloadmap" then + reloaded = index + elseif cmd.op == "reloadmapafterbattle" then + afterBattle = index + end + end + check(armed and battled and reloaded and armed < battled + and battled < reloaded, + ev.scriptKey .. ": loadvar before startbattle before reloadmap") + check(afterBattle == nil, + ev.scriptKey .. " never runs reloadmapafterbattle") + end + end + check(seen >= 2, "both rival coord events carry extracted bodies") +end + +S.finish() diff --git a/tests/gen2_catch_tutorial_test.lua b/tests/gen2_catch_tutorial_test.lua new file mode 100644 index 00000000..34e46c32 --- /dev/null +++ b/tests/gen2_catch_tutorial_test.lua @@ -0,0 +1,323 @@ +-- The DUDE's catching tutorial (engine/events/catch_tutorial.asm, +-- engine/events/catch_tutorial_input.asm, and the BATTLETYPE_TUTORIAL arms in +-- engine/battle/core.asm / engine/items/item_effects.asm). +-- GOLD_CACHE="..." luajit tests/gen2_catch_tutorial_test.lua +-- +-- ROM-free: the name swap, the DUDE's pack and the auto-input pacing are all +-- pure tables, and the battle screen is driven with a stub game so the whole +-- demo -- menu, pack, ball, catch -- runs here without a window. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 catch tutorial") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local AutoInput = require("src.core.gen2.AutoInput") +local CatchTutorial = require("src.core.gen2.CatchTutorial") +local Vm = require("src.script.gen2.Vm") + +-- ---- .LoadDudeData -------------------------------------------------------- +do + eq(CatchTutorial.DUDE_NAME, "DUDE", "CatchTutorial.Dude is `db \"DUDE@\"`") + eq(CatchTutorial.BATTLETYPE_TUTORIAL, 3, + "BATTLETYPE_TUTORIAL is the fourth constant in the const_def 0 block") + eq(CatchTutorial.PACK.POTION, 1, "wDudeNumItems holds one POTION") + eq(CatchTutorial.PACK.POKE_BALL, 5, + "and the ball's quantity byte is written from POKE_BALL itself, so the " + .. "DUDE's pocket really does read x5") + eq(CatchTutorial.BALL, "POKE_BALL", + "and POKE_BALL is what DoItemEffect is handed whatever the pack returned") + + local dude = CatchTutorial.dudeSave() + eq(dude.player.name, "DUDE", "the pack is drawn as the DUDE's") + eq(dude.inventory.POTION, 1, "out of his own buffers") + dude.inventory.POTION = 99 + eq(CatchTutorial.PACK.POTION, 1, + "and each opening gets a copy, not the shared table") +end + +-- ---- the name and option bracket ------------------------------------------ +do + local save = { player = { name = "GOLD" }, mom = { name = "MOM" } } + local options = { textSpeed = "SLOW", battleScene = true } + local state = CatchTutorial.begin(save, options) + eq(save.player.name, "DUDE", "the player answers to DUDE for the demo") + eq(save.mom.name, "GOLD", + "with the real name parked in wMomsName, which is where the cart keeps it") + eq(options.textSpeed, "MID", "TEXT_DELAY_MED for the length of the battle") + eq(options.battleScene, true, "and no other option bit is touched") + + CatchTutorial.finish(save, options, state) + eq(save.player.name, "GOLD", "the name comes back out of wMomsName") + eq(options.textSpeed, "SLOW", "and wOptions is popped") + eq(save.mom.name, "GOLD", + "Mom's name is NOT restored: the cart has nowhere left to restore it from") +end + +-- ---- the re-arms ---------------------------------------------------------- +do + -- Every re-arm in the ASM is guarded by `ld a, [wInputType] / or a`, so the + -- DUDE only answers while a stream is already running. + local ring = AutoInput.new() + check(not CatchTutorial.rearm(ring, CatchTutorial.PROMPT_STREAM), + "an unarmed ring is never taken over by a re-arm") + ring:start(CatchTutorial.BATTLE_STREAM) + check(CatchTutorial.rearm(ring, CatchTutorial.PROMPT_STREAM), + "but the lockout stream counts as wInputType == AUTO_INPUT") + check(not CatchTutorial.rearm(ring, "NO_SUCH_STREAM"), + "and a stream the ROM does not have arms nothing") +end + +do + -- Frame-paced: PromptButton's loop delays a frame per iteration, so DUDE_A's + -- 0x51 blank frames are exactly the beat between two lines of text. + local ring = AutoInput.new() + ring:start(CatchTutorial.BATTLE_STREAM) + CatchTutorial.rearm(ring, CatchTutorial.PROMPT_STREAM) + local blanks = 0 + while ring:advance() == AutoInput.NO_INPUT and blanks < 200 do + blanks = blanks + 1 + end + eq(blanks, 0x51, "the prompt stream is replayed frame for frame") +end + +do + -- Poll-paced: the battle menu picks ITEM with DOWN then A, and the runs of + -- blank pairs between them are loop iterations on the cart. + local ring = AutoInput.new() + ring:start(CatchTutorial.BATTLE_STREAM) + CatchTutorial.rearm(ring, CatchTutorial.MENU_STREAM, nil, true) + eq(ring:advance(), AutoInput.PAD_DOWN, "DOWN lands on the next step") + eq(ring:advance(), AutoInput.PAD_A, "and A on the one after it") + check(ring:isActive(), "the stream parks rather than handing control back") + + CatchTutorial.rearm(ring, CatchTutorial.PACK_STREAM, nil, true) + eq(ring:advance(), AutoInput.PAD_RIGHT, + "the pack stream crosses to the BALL pocket") + eq(ring:advance(), AutoInput.PAD_A, "and picks the POKE BALL") +end + +do + -- skipIdle never eats a press, and never eats the $ff pair a stream parks + -- on -- that pair is the lockout, not a pause. + local ring = AutoInput.new() + ring:start({ AutoInput.PAD_A, 0x00, AutoInput.NO_INPUT, 0xff }) + check(not ring:skipIdle(), "a stream that opens on a press is left alone") + eq(ring:advance(), AutoInput.PAD_A, "and still presses it") + check(not ring:skipIdle(), "the parked $ff pair is not skippable") + check(ring:isActive(), "so the controller stays taken") +end + +-- ---- Script_catchtutorial ------------------------------------------------- +do + -- Script_catchtutorial: one operand byte into wBattleType, then + -- farcall CatchTutorial, then `jp Script_reloadmap`. CatchTutorial itself + -- brackets StartBattle in StartAutoInput / StopAutoInput. + local order, got = {}, nil + local vm = Vm.new({ + ["s:t"] = { + { op = "setval", value = 5 }, + { op = "loadwildmon", args = { 19, 5 } }, + { op = "catchtutorial", args = { 3 } }, + { op = "writetext", text = "AFTER" }, + { op = "end" }, + }, + }, {}, nil, { + autoInputStream = function(name) order[#order + 1] = "start:" .. name end, + stopAutoInput = function() order[#order + 1] = "stop" end, + reloadMap = function() order[#order + 1] = "reload" end, + showText = function(_body, done) + order[#order + 1] = "text" + done() + end, + catchTutorial = function(wild, battleType, onDone) + order[#order + 1] = "battle" + got = { wild = wild, battleType = battleType } + onDone() + end, + }) + vm:start("s:t") + eq(table.concat(order, ","), + "start:CATCH_TUTORIAL,battle,stop,reload,text", + "the battle runs inside the lockout, and the script continues after the " + .. "map reload -- catchtutorial is not a terminator") + eq(got and got.battleType, 3, "wBattleType is the command's own byte") + eq(got and got.wild and got.wild.species, 19, + "and the wild mon is the one loadwildmon left behind") + eq(got.wild.level, 5, "at the level it named") + eq(vm.scriptVar, 5, "catchtutorial leaves wScriptVar alone") + eq(vm.wildMon, nil, + "and consumes the pair, so a later startbattle cannot inherit it") +end + +do + -- With no hook there is nothing to park on: the bracket collapses and the + -- command still ends where the ASM ends it. + local order = {} + local vm = Vm.new({ + ["s:t"] = { { op = "catchtutorial", args = { 3 } }, { op = "end" } }, + }, {}, nil, { + autoInputStream = function(name) order[#order + 1] = "start:" .. name end, + stopAutoInput = function() order[#order + 1] = "stop" end, + reloadMap = function() order[#order + 1] = "reload" end, + }) + vm:start("s:t") + eq(table.concat(order, ","), "start:CATCH_TUTORIAL,stop,reload", + "the lockout still closes before the map reload") +end + +-- ---- the demo battle itself ----------------------------------------------- +-- BattleState with a stub game: no window, but the real update loop, the real +-- auto-input ring and the real Input edge detection, so the DUDE plays the +-- whole thing exactly as he would on screen. +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local Input = require("src.core.Input") +local PackMenu = require("src.ui.gen2.PackMenu") + +local function tutorialScreen() + Input:init() + local ring = AutoInput.new() + local pushed = {} + local save = { + player = { name = "DUDE" }, + party = { { species = "CYNDAQUIL", level = 5, hp = 20, maxHp = 20 } }, + inventory = { POKE_BALL = 3 }, + pokedex = { seen = {}, caught = {} }, + } + local data = { + pokemon = { RATTATA = { catchRate = 255 } }, + items = { POKE_BALL = { pocket = "BALL", name = "POKe BALL", index = 5 }, + POTION = { pocket = "ITEM", name = "POTION", index = 17 } }, + } + local game = { + data = data, + save = save, + input = Input, + autoInput = ring, + options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + }, + } + -- BATTLETYPE_TUTORIAL fights with an empty party: no mon is ever sent out. + local battle = Battle.new({ data = data, party = {}, + wild = { species = "RATTATA", level = 5, hp = 19, maxHp = 19 } }) + local outcome + local screen = BattleState.new(game, { + battle = battle, + save = save, + tutorial = true, + onDone = function(result) outcome = result end, + }) + -- The 72-frame sliding intro reads no input at all. + for _ = 1, BattleAnimView.SLIDE_FRAMES do screen:update(1 / 60) end + return screen, ring, save, function() return outcome end, pushed, game +end + +do + local screen, ring, _, _, _ = tutorialScreen() + eq(screen.battle.player, nil, "the tutorial opens with no mon out") + check(screen.showPlayerTrainer, + "so the player's box keeps a trainer back-pic for the whole battle") + -- CatchTutorial armed the lockout before StartBattle. + ring:start(CatchTutorial.BATTLE_STREAM, Input) + + -- "Wild RATTATA appeared!" goes up on the step that drains the queue, and + -- the DUDE is handed the prompt on the first step that waits on it. + screen:update(1 / 60) + eq(screen.message, "Wild RATTATA appeared!", "the intro line is the cart's") + screen:update(1 / 60) + eq(ring.bytes, AutoInput.STREAMS.DUDE_A, + "and DudeAutoInput_A is armed to answer it") + local before = ring.pos + screen:update(1 / 60) + eq(ring.pos, before, + "a wait that spans many steps arms the stream once, not once a step") + check(screen.messageTimer > 0, + "and the line does NOT time out: PromptButton waits for the button") +end + +do + -- The whole demo, driven by nothing but the ring: enough steps for the + -- prompt beat on each line plus the menu and pack presses. + local screen, ring, save, outcomeOf, pushed = tutorialScreen() + ring:start(CatchTutorial.BATTLE_STREAM, Input) + local sawPack, sawBallPocket, chosen = false, false, nil + for _ = 1, 600 do + ring:step(Input) + Input:step() + local top = pushed[#pushed] + if top and getmetatable(top) == PackMenu then + sawPack = true + top:update(1 / 60) + -- DudeAutoInput_RightA's RIGHT is the pocket cross; its A picks the row + -- the cursor is sitting on, which in the BALL pocket is the POKE BALL. + if top:pocket().id == "BALL" then + sawBallPocket = true + chosen = top.rows[top.index] and top.rows[top.index].id + end + else + screen:update(1 / 60) + end + if outcomeOf() then break end + end + check(sawPack, "the DUDE opens his own pack, as BattleMenu_Pack .tutorial does") + check(sawBallPocket, "crosses to the BALL pocket") + eq(chosen, "POKE_BALL", "and lands on the POKE BALL") + eq(outcomeOf(), "caught", "and the ball catches") + eq(screen.message, "Gotcha! RATTATA was caught!", "with the cart's line") + eq(#save.party, 1, "nothing is added to the party") + eq(save.pokedex.caught.RATTATA, nil, "nothing is written to the Pokedex") + eq(save.inventory.POKE_BALL, 3, "and no ball leaves the player's bag") +end + +-- ---- the call sites ------------------------------------------------------- +-- World needs a map, a stack and a VM to construct, so the wiring that reaches +-- all of the above is asserted by reading it: a call site that is not spelled +-- out in the file is not there at all. +local function sourceOf(path) + local f = io.open(path, "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +do + local world = sourceOf("src/world/gen2/World.lua") + check(world ~= nil, "World's source is readable") + for _, wanted in ipairs({ + "catchTutorial = function(wild, battleType, onDone)", + "self:startCatchTutorial(wild, battleType, onDone)", + "function World:startCatchTutorial", + "CatchTutorial.begin(save, game and game.options)", + "CatchTutorial.finish(save, game and game.options, state)", + "tutorial = true,", + "tutorial = opts.tutorial,", + }) do + check(world:find(wanted, 1, true) ~= nil, "World has " .. wanted) + end + + local vmSource = sourceOf("src/script/gen2/Vm.lua") + check(vmSource ~= nil, "the VM's source is readable") + for _, wanted in ipairs({ + "catchTutorialFn = hooks.catchTutorial,", + 'req.kind == "catchtutorial"', + }) do + check(vmSource:find(wanted, 1, true) ~= nil, "the VM has " .. wanted) + end + + -- GetTrainerBackpic's "Special exception for Dude". + local extractor = sourceOf("src/import/RomExtractorGen2.lua") + check(extractor ~= nil, "the extractor's source is readable") + check(extractor:find('self.symbols["DudeBackpic"]', 1, true) ~= nil, + "the extractor rips DudeBackpic") + check(extractor:find('hud.dudeBack = "assets/generated/battle/dude_back.png"', + 1, true) ~= nil, "and the battle screen has a path to look it up by") +end + +S.finish() diff --git a/tests/gen2_charge_lock_test.lua b/tests/gen2_charge_lock_test.lua new file mode 100644 index 00000000..771dcbde --- /dev/null +++ b/tests/gen2_charge_lock_test.lua @@ -0,0 +1,147 @@ +-- A mon halfway through a two-turn move must not choose again. +-- +-- luajit tests/gen2_charge_lock_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua), which lost to +-- ELITE FOUR BRUNO seventeen times running at levels 62 through 85 and could +-- not have won at any level: +-- +-- ELITE FOUR BRUNO sent out HITMONLEE! +-- HITMONLEE used DIG! +-- HITMONLEE flew up high! +-- TYPHLOSION used STRENGTH! +-- TYPHLOSION's attack missed! +-- HITMONLEE used HI JUMP KICK! +-- TYPHLOSION used STRENGTH! +-- TYPHLOSION's attack missed! <- forever +-- +-- On the cart the charge sets SUBSTATUS_CHARGED and CheckEnemyTurn reuses +-- wEnemySelectedMove, so the second turn IS the stored move. The port asked the +-- AI again. That skips the stored attack, and because `vanished` is only +-- cleared by the branch in Battle:useMove that recognises the second half, the +-- mon stays semi-invulnerable for the rest of the battle -- untouchable by +-- anything, while still attacking every turn. +-- +-- DIG and FLY are one effect in Gen 2 (EFFECT_FLY), which is also why DIG +-- announced itself with Fly's line; BattleCommand_Fly picks the text off the +-- move (`cp DIG`), not the effect. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 charge lock") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") + +-- Battle:enemyMove reads exactly two things before the lock returns, so the +-- lock can be driven against a stub. `asked` records whether the AI was +-- consulted at all -- on the second turn it must not be. +local function enemyStub(chargeMove, pool) + local asked = { count = 0 } + local volatiles = { enemy = { chargeMove = chargeMove } } + local self_ = { + enemy = { moves = pool, hp = 100 }, + random = function() return 0 end, + volatile = function(_, mon) return volatiles.enemy end, + usableMoves = function() + asked.count = asked.count + 1 + return pool + end, + moveDef = function(_, id) return { name = id } end, + trainer = nil, + } + return self_, asked +end + +local POOL = { { id = "HI_JUMP_KICK", pp = 10 }, { id = "DIG", pp = 10 } } + +-- The bug: mid-charge, the AI is asked and answers something else. +do + local self_, asked = enemyStub("DIG", POOL) + local chosen = Battle.enemyMove(self_) + eq(chosen, "DIG", "a charging enemy uses the move it stored") + eq(asked.count, 0, "and the AI is never consulted for the second turn") +end + +-- Not charging: the AI still runs. Without a trainer, flagsOf is 0 and the +-- choice is the random branch, which with a zeroed rng is the first move. +do + local self_, asked = enemyStub(nil, POOL) + local chosen = Battle.enemyMove(self_) + eq(asked.count, 1, "with no stored charge the AI is consulted") + check(chosen == "HI_JUMP_KICK" or chosen == "DIG", + "and it picks from the pool") +end + +-- The other half of the invariant, in the real code: useMove's second-turn +-- branch is what clears `vanished`, and it only fires when the move it is +-- handed equals the stored one. This is the line the missing lock bypassed. +do + local Effects = require("src.battle.gen2.Effects") + check(Effects.CHARGE.EFFECT_FLY ~= nil, + "EFFECT_FLY is a charge effect") + check(Effects.CHARGE.EFFECT_FLY.vanish == true, + "and it is the one that makes the user untargetable") +end + +-- DIG's own announcement. Same effect as FLY, different line. +do + local src = io.open("src/battle/gen2/Battle.lua"):read("*a") + check(src:find('moveId == "DIG"', 1, true) ~= nil, + "DIG's charge text is chosen off the move, not the effect") + check(src:find("dug a hole", 1, true) ~= nil, + "and it is the burrow line") +end + +-- --------------------------------------------------------------------------- +-- The other free turn: HYPER BEAM's recharge. +-- +-- BattleCommand_RechargeNextTurn sets SUBSTATUS_RECHARGE; CheckPlayerTurn and +-- CheckEnemyTurn spend the following turn clearing it and printing +-- MustRechargeText. The port had none of it, so HYPER BEAM was 150 power at no +-- cost -- and CHAMPION LANCE's three DRAGONITE all carry it, firing it every +-- turn instead of every other one. +local function actorStub(vol, status) + local said = {} + local self_ = { + mon = { status = status }, + volatile = function() return vol end, + monName = function() return "DRAGONITE" end, + emit = function(_, ev) said[#said + 1] = ev.text end, + random = function() return 1 end, + } + return self_, said +end + +do + local vol = { recharge = true } + local self_, said = actorStub(vol) + local acted = Battle.canAct(self_, self_.mon) + eq(acted, false, "a recharging mon loses its turn") + eq(said[1], "DRAGONITE must recharge!", "and says so") + check(vol.recharge == nil, "the flag is consumed, not sticky") + -- The very next turn it is free again: this is the half that makes it cost + -- one turn rather than end the mon's participation. + eq(Battle.canAct(self_, self_.mon), true, "and the turn after, it acts") +end + +-- Recharge is read BEFORE status, so a mon that is both recharging and asleep +-- spends this turn recharging (and does not burn a sleep turn). +do + local vol = { recharge = true } + local self_ = actorStub(vol, "sleep") + self_.mon.statusTurns = 3 + eq(Battle.canAct(self_, self_.mon), false, "recharge wins over sleep") + eq(self_.mon.statusTurns, 3, "and the sleep counter is untouched") +end + +-- The setter. Driving Battle:useMove needs a whole battle, so pin the wiring +-- at the source: the effect that sets it, and the field canAct consumes. +do + local src = io.open("src/battle/gen2/Battle.lua"):read("*a") + check(src:find('EFFECT_HYPER_BEAM', 1, true) ~= nil, + "HYPER BEAM's effect is handled in the damage path") + check(src:find('state.recharge = true', 1, true) ~= nil, + "and it arms the recharge the cart arms") +end + +S.finish() diff --git a/tests/gen2_clock_test.lua b/tests/gen2_clock_test.lua new file mode 100644 index 00000000..91848816 --- /dev/null +++ b/tests/gen2_clock_test.lua @@ -0,0 +1,257 @@ +-- Gen 2 clock setup: the InitClock screens and the RTC base they write. +-- +-- The cart does not store "the time": InitClock stores the RTC reading at the +-- moment the player answered (wStartHour / wStartMinute / wStartDay) and every +-- later read goes through it. src/core/gen2/Clock.lua is that arithmetic and +-- src/ui/gen2/InitClock.lua is both screens that write it. +-- luajit tests/gen2_clock_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 clock") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +-- No font is loaded here (these are layout and arithmetic assertions), so +-- Font.encode would warn once per unknown glyph and bury a real failure. +require("src.core.Logger").warn = function() end + +local Clock = require("src.core.gen2.Clock") +local InitClock = require("src.ui.gen2.InitClock") + +-- A stub input the screen drives off, the same shape Input:wasPressed has. +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 + +-- --------------------------------------------------------------- the offsets +do + local save = {} + check(not Clock.isSet(save), "a fresh save has no base") + eq(Clock.hour(save), math.floor(Clock.hostMinutes() / 60), + "so it reads the host clock straight through") + + Clock.setTime(save, 10, 0) + check(Clock.isSet(save), "answering Oak writes the base") + eq(Clock.hour(save), 10, "and the clock reads back what was set") + eq(Clock.minute(save), 0, "minutes too") + + Clock.setTime(save, 23, 45) + eq(Clock.hour(save), 23, "a second answer re-anchors it") + eq(Clock.minute(save), 45, "minutes and all") + + -- The base is an OFFSET, not a frozen reading: the clock keeps running. + local base = save.rtc.startMinute + local moved = (Clock.hostMinutes() + 90 + base) % Clock.MINUTES_PER_DAY + eq(math.floor(moved / 60), 1, + "an hour and a half later it is 1:15, not still 23:45") + eq(moved % 60, 15, "minutes and all") + + Clock.setTime(save, 0, 0) + eq(Clock.hour(save), 0, "midnight is hour 0") + Clock.setTime(save, 12, 30) + eq(Clock.hour(save), 12, "noon is hour 12") +end + +do + local save = {} + eq(Clock.weekday(save), Clock.hostWeekday(), + "with no base the weekday is the host's") + for day = 0, 6 do + Clock.setWeekday(save, day) + eq(Clock.weekday(save), day, ("SetDayOfWeek pins day %d"):format(day)) + end + eq(save.rtc.dayOfWeek, 6, + "and leaves wCurDay where the daily rollovers read it") +end + +-- ------------------------------------------------------------ the hour screen +do + local save = {} + local input = fakeInput() + local done = {} + local screen = InitClock.new({ input = input }, { + save = save, + onDone = function(hour, minute) done = { hour = hour, minute = minute } end, + }) + eq(screen.phase, "intro", "the screen opens on Oak waking up") + eq(screen.hour, 10, "with InitClock's own default of 10 AM") + eq(screen.minute, 0, "and no minutes") + + -- OakTimeWokeUpText is two `para` pages, so A turns the page before it takes + -- the box down. + eq(#screen:pages(), 2, "Oak's opening is two pages") + input:press("a") + screen:update(0) + eq(screen.phase, "intro", "the first A turns the page") + check(screen:pageText():find("clock", 1, true) ~= nil, + "onto the one that asks about the clock") + input:press("a") + screen:update(0) + eq(screen.phase, "hour", "and the second opens the hour picker") + -- DisplayHourOClock is PrintHour + String_oclock, so the word rides along + eq(screen:display(), "DAY 10 o'clock", "which shows the hour DisplayHourOClock does") + + input:press("up") + screen:update(0) + eq(screen.hour, 11, "up walks the hour forward") + input:press("down") + screen:update(0) + input:press("down") + screen:update(0) + eq(screen.hour, 9, "down walks it back") + -- .DecreaseThroughMidnight / .AdvanceThroughMidnight: both ends wrap. + screen.hour = 0 + input:press("down") + screen:update(0) + eq(screen.hour, 23, "and midnight wraps to 11 PM") + input:press("up") + screen:update(0) + eq(screen.hour, 0, "and back again") + + screen.hour = 7 + input:press("a") + screen:update(0) + eq(screen.phase, "confirm-hour", "A confirms the hour") + check(screen:question():find("o'clock", 1, true) ~= nil, + "and the question reads it back") + -- NO drops back to the picker (`jr c, .loop`). + screen.yesNo = 2 + input:press("a") + screen:update(0) + eq(screen.phase, "hour", "NO goes back to the picker") + input:press("a") + screen:update(0) + input:press("a") + screen:update(0) + eq(screen.phase, "minute", "YES moves on to the minutes") + + input:press("up") + screen:update(0) + eq(screen.minute, 1, "up walks the minutes") + screen.minute = 0 + input:press("down") + screen:update(0) + eq(screen.minute, 59, "and they wrap at the hour") + screen.minute = 30 + eq(screen:display(), "30 min.", "DisplayMinutesWithMinString's own string") + input:press("a") + screen:update(0) + eq(screen.phase, "confirm-minute", "A confirms them") + input:press("a") + screen:update(0) + eq(screen.phase, "response", "and Oak answers with the time") + -- "MORN 7:30", not "7 AM:30": OakText_ResponseToSetTime is PrintHour (the + -- time-of-day word then the 1-12 hour) then ':' then two-digit minutes, so + -- the meridiem never appears and cannot land between the hour and them. + check(screen:question():find("MORN 7:30", 1, true) ~= nil, + "which is the pair the player just set") + input:press("a") + screen:update(0) + eq(done.hour, 7, "the screen hands the hour back") + eq(done.minute, 30, "and the minutes") + eq(Clock.hour(save), 7, "and the save now reads that hour") + eq(Clock.minute(save), 30, "and those minutes") +end + +-- OakText_ResponseToSetTime's own ladder. +eq(InitClock.responseKey(2), "soDark", "before MORN_HOUR it is still dark") +eq(InitClock.responseKey(4), "overslept", "MORN_HOUR is 'I overslept'") +-- DAY_HOUR is 10 (constants/misc_constants.asm:38), and +-- OakText_ResponseToSetTime's `cp DAY_HOUR + 1 / jr c, .morn` keeps the hour +-- ITSELF in the plain "I overslept!" arm; "Yikes!" starts at 11. This read 9 +-- when InitClock did, so both were an hour early together. +eq(InitClock.responseKey(10), "overslept", "and so is DAY_HOUR itself") +eq(InitClock.responseKey(11), "yikes", "past it Oak yikes") +eq(InitClock.responseKey(18), "soDark", "and NITE_HOUR is dark again") + +-- PrintHour (engine/rtc/timeset.asm:672) is GetTimeOfDayString + PlaceString +-- and THEN the 1-12 hour, so the cart prints the time-of-day word ahead of the +-- number and no meridiem at all. These read "12 AM" / "1 PM" while InitClock +-- built the string that way, which is what let InitClock.timeString append +-- ":mm" to a meridiem and have Oak say "5 AM:30". +eq(InitClock.hourString(0), "NITE 12", "PrintHour shows midnight as NITE 12") +eq(InitClock.hourString(12), "DAY 12", "and noon as DAY 12") +eq(InitClock.hourString(13), "DAY 1", "and the afternoon on a 12-hour clock") +eq(InitClock.timeString(5, 30), "MORN 5:30", + "and OakText_ResponseToSetTime's line is PrintHour, ':', two-digit minutes") + +-- --------------------------------------------------------- the weekday wheel +do + local save = {} + local input = fakeInput() + local picked + local screen = InitClock.new({ input = input }, { + mode = "day", save = save, + onDone = function(day) picked = day end, + }) + eq(screen.phase, "day", "the wheel opens on its picker, with no preamble") + eq(screen.day, 0, "`xor a / ld [wTempDayOfWeek], a`: SUNDAY") + eq(screen:display(), "SUNDAY", "which is what the box shows") + for _ = 1, 2 do + input:press("up") + screen:update(0) + end + eq(screen:display(), "TUESDAY", "up walks the wheel forward") + input:press("down") + screen:update(0) + input:press("down") + screen:update(0) + input:press("down") + screen:update(0) + eq(screen:display(), "SATURDAY", "and it wraps past SUNDAY") + input:press("a") + screen:update(0) + eq(screen.phase, "confirm-day", "A confirms it") + input:press("b") + screen:update(0) + eq(screen.phase, "day", "B is NO and drops back to the wheel") + input:press("a") + screen:update(0) + input:press("a") + screen:update(0) + eq(picked, 6, "YES hands the day back") + eq(Clock.weekday(save), 6, "and the save reads it") +end + +-- The driven path: a screen this new must not stall a scripted run, so it +-- walks itself to the end on its defaults. +do + local save = {} + local finished = false + local screen = InitClock.new({}, { + save = save, autoConfirm = true, + onDone = function() finished = true end, + }) + for _ = 1, 20 do + if finished then break end + screen:update(0) + end + check(finished, "autoConfirm reaches the end on its own") + eq(Clock.hour(save), Clock.DEFAULT_HOUR, "taking InitClock's 10 AM default") + eq(Clock.minute(save), Clock.DEFAULT_MINUTE, "and no minutes") +end + +-- Drawing must not throw with the stub canvas: the layout is transcribed from +-- hlcoord calls, so a bad coordinate is a crash rather than a wrong pixel. +do + local screen = InitClock.new({}, { save = {} }) + for _, phase in ipairs({ "intro", "hour", "confirm-hour", "minute", + "confirm-minute", "response", "day", "confirm-day" }) do + screen.phase = phase + local ok, err = pcall(function() screen:drawPanel() end) + check(ok, ("phase %s draws (%s)"):format(phase, tostring(err))) + end +end + +S.finish() diff --git a/tests/gen2_cmdqueue_test.lua b/tests/gen2_cmdqueue_test.lua new file mode 100644 index 00000000..8e280858 --- /dev/null +++ b/tests/gen2_cmdqueue_test.lua @@ -0,0 +1,179 @@ +-- wCmdQueue and the stone table (engine/overworld/cmd_queue.asm, +-- home/stone_queue.asm). ROM-free: `luajit tests/gen2_cmdqueue_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 cmd queue") +local check, eq = S.check, S.eq + +local CmdQueue = require("src.world.gen2.CmdQueue") + +-- ---- the four slots ------------------------------------------------------- +do + local q = CmdQueue.new() + eq(CmdQueue.count(q), 0, "a fresh queue is empty") + for i = 1, CmdQueue.CAPACITY do + eq(CmdQueue.write(q, { kind = CmdQueue.TYPE1 }), i, + "WriteCmdQueue fills the first free slot (" .. i .. ")") + end + check(CmdQueue.write(q, { kind = CmdQueue.TYPE1 }) == nil, + "a full queue sets carry and the write is DROPPED, not overwritten") + eq(CmdQueue.count(q), CmdQueue.CAPACITY, "four is the capacity") + CmdQueue.clear(q) + eq(CmdQueue.count(q), 0, "ClearCmdQueue zeroes every type byte") +end + +-- DelCmdQueue answers whether it found the entry, which is the OPPOSITE of what +-- `delcmdqueue` writes to wScriptVar: `ret c` returns on the delete with +-- wScriptVar still 0, and only a miss falls through to TRUE. +do + local q = CmdQueue.new() + CmdQueue.write(q, { kind = CmdQueue.STONETABLE }) + check(not CmdQueue.delete(q, CmdQueue.TYPE4), "a type not in the queue misses") + check(CmdQueue.delete(q, CmdQueue.STONETABLE), "and the one that is, hits") + eq(CmdQueue.count(q), 0, "leaving the slot free") + check(not CmdQueue.delete(q, CmdQueue.STONETABLE), "and only once") +end + +-- ---- .IsObjectOnWarp ------------------------------------------------------ +do + local warps = { { x = 1, y = 1 }, { x = 11, y = 2 }, { x = 4, y = 7 } } + eq(CmdQueue.warpNumberAt(warps, 11, 2), 2, + "the answer is the warp NUMBER, 1-based, not a boolean") + eq(CmdQueue.warpNumberAt(warps, 4, 7), 3, "counted in warp_event order") + check(CmdQueue.warpNumberAt(warps, 9, 9) == nil, "and nil off a warp") +end + +-- ---- .IsObjectInStoneTable ------------------------------------------------ +do + local rows = { + { warp = 3, object = 2, script = "a" }, + { warp = 4, object = 3, script = "b" }, + } + eq(CmdQueue.stoneRow(rows, 4, 3).script, "b", "both bytes have to match") + check(CmdQueue.stoneRow(rows, 3, 3) == nil, + "the right boulder on the wrong hole does not fall") + check(CmdQueue.stoneRow(rows, 4, 2) == nil, "nor the wrong boulder on it") +end + +-- ---- CmdQueue_StoneTable's four gates ------------------------------------- +local ICE_WARPS = { + { x = 3, y = 15 }, { x = 17, y = 3 }, { x = 11, y = 2 }, + { x = 4, y = 7 }, { x = 5, y = 12 }, { x = 12, y = 13 }, +} + +local function iceCtx(objects, pits) + return { + objects = objects, + warps = ICE_WARPS, + collisionAt = function(x, y) + for _, p in ipairs(pits or {}) do + if p[1] == x and p[2] == y then return 0x60 end -- COLL_PIT + end + return 0x00 + end, + } +end + +local ICE = CmdQueue.mapEntry("ICE_PATH_B1F") + +do + check(ICE ~= nil, "Ice Path B1F has a stone table") + eq(ICE.kind, CmdQueue.STONETABLE, "written as CMDQUEUE_STONETABLE") + eq(#ICE.rows, 4, "with a row per boulder") + check(CmdQueue.mapEntry("NEW_BARK_TOWN") == nil, + "and a map with no callback has none") +end + +-- The happy path: boulder 1 (object id 2) pushed onto warp 3 at (11,2). +do + local boulder = { id = 2, movement = CmdQueue.BOULDER_MOVEDATA, + cellX = 11, cellY = 2, moving = false } + local row = CmdQueue.stoneFall(ICE, iceCtx({ boulder }, { { 11, 2 } })) + check(row ~= nil, "a boulder standing on its own hole falls through") + eq(row.warp, 3, "through warp 3") + + -- ...and each of the four gates alone stops it. + check(CmdQueue.stoneFall(ICE, iceCtx({ boulder }, {})) == nil, + "CheckPitTile: a warp tile that is not a PIT is not a hole") + boulder.moving = true + check(CmdQueue.stoneFall(ICE, iceCtx({ boulder }, { { 11, 2 } })) == nil, + "OBJECT_WALKING: a boulder mid-push does not fall early") + boulder.moving = false + boulder.movement = 1 + check(CmdQueue.stoneFall(ICE, iceCtx({ boulder }, { { 11, 2 } })) == nil, + "OBJECT_MOVEMENT_TYPE: only a STRENGTH_BOULDER is checked at all") + boulder.movement = CmdQueue.BOULDER_MOVEDATA + boulder.visible = false + check(CmdQueue.stoneFall(ICE, iceCtx({ boulder }, { { 11, 2 } })) == nil, + "OBJECT_SPRITE: one already disappeared has no struct left") +end + +-- A boulder on the WRONG hole stays put -- both stonetable bytes must match. +do + local wrong = { id = 2, movement = CmdQueue.BOULDER_MOVEDATA, + cellX = 4, cellY = 7, moving = false } + check(CmdQueue.stoneFall(ICE, iceCtx({ wrong }, { { 4, 7 } })) == nil, + "boulder 1 does not fall through boulder 2's hole") +end + +-- HandleStoneQueue returns on the FIRST match, so two never drop together. +do + local a = { id = 2, movement = CmdQueue.BOULDER_MOVEDATA, + cellX = 11, cellY = 2, moving = false } + local b = { id = 3, movement = CmdQueue.BOULDER_MOVEDATA, + cellX = 4, cellY = 7, moving = false } + local q = CmdQueue.new() + CmdQueue.write(q, ICE) + local row, obj = CmdQueue.poll(q, iceCtx({ a, b }, { { 11, 2 }, { 4, 7 } })) + eq(obj.id, 2, "the first boulder in object order is the one that drops") + eq(row.warp, 3, "and its own row is what runs") +end + +-- An empty queue polls to nothing, which is the state every other map is in. +do + eq(CmdQueue.poll(CmdQueue.new(), iceCtx({}, {})), nil, + "no entry, nothing to handle") +end + +-- ---- the two hand-ported tables ------------------------------------------- +-- Object ids are the cart's (`object_const_def` is `const_def 2`), and the +-- Blackthorn rows are transcribed in the cart's own out-of-order warp mapping. +do + local rows = ICE.rows + for i, row in ipairs(rows) do + eq(row.object, i + 1, "Ice Path boulder " .. i .. " is object id " .. (i + 1)) + eq(row.warp, i + 2, "and falls through warp " .. (i + 2)) + end + + local gym = CmdQueue.mapEntry("BLACKTHORN_GYM_2F") + eq(#gym.rows, 3, "Blackthorn Gym 2F has three rows for its six boulders") + eq(gym.rows[1].warp, 5, "BOULDER1 -> warp 5") + eq(gym.rows[2].warp, 3, "BOULDER2 -> warp 3, not 4") + eq(gym.rows[3].warp, 4, "BOULDER3 -> warp 4: the cart's own order, kept") +end + +-- The script each row runs: .FinishBoulder, with .BoulderFallsThrough inlined. +do + local script = ICE.rows[1].script + local ops = {} + for _, cmd in ipairs(script) do ops[#ops + 1] = cmd.op end + eq(table.concat(ops, ","), + "disappear,clearevent,pause,playsound,earthquake,opentext,rawtext," .. + "waitbutton,closetext,end", + "the order is .FinishBoulder's, with .BoulderFallsThrough inlined") + eq(script[1].object, 2, "it disappears its own boulder") + eq(script[2].event, 1805, + "and clears EVENT_BOULDER_IN_ICE_PATH_1A, which is what puts the boulder " .. + "on the floor below") + eq(script[3].frames, 30, "pause 30") + eq(script[5].param, 80, + "earthquake 80: two pixels for sixteen frames, one byte carrying both") + + local gym = CmdQueue.mapEntry("BLACKTHORN_GYM_2F") + local gymOps = {} + for _, cmd in ipairs(gym.rows[1].script) do gymOps[#gymOps + 1] = cmd.op end + check(not table.concat(gymOps, ","):find("clearevent"), + "the gym's boulders clear no event: nothing appears downstairs") +end + +S.finish() diff --git a/tests/gen2_common_text_test.lua b/tests/gen2_common_text_test.lua new file mode 100644 index 00000000..04b4bde1 --- /dev/null +++ b/tests/gen2_common_text_test.lua @@ -0,0 +1,434 @@ +-- Text no script pointer reaches: the Day-Care and breeding block, the whole +-- POKeMART conversation, and the Hall of Fame's three flavour strings. +-- +-- The extractor's text walker only decodes what some `writetext` names, and +-- all three of these blocks are printed by engine asm instead, so they used to +-- exist in the port only as hand transcriptions at their call sites. +-- RomExtractorGen2's NAMED_TEXT seeds the walker at them by symbol and writes +-- text.labels[label] -> the "bank:addr" key the string landed on; the screens +-- read that and keep their transcriptions as the fallback. +-- +-- This pins both sides against each other, the way tests/gen2_phone_test.lua +-- pins the phone tables: +-- +-- PINNED is the cart's own decoded characters, one entry per label. +-- Every screen is then driven with a text.lua built out of PINNED, and what +-- it says has to equal what its transcription says -- so a transcription +-- that drifts from the ROM fails here rather than on screen. +-- The cache section at the bottom asserts the real import produced exactly +-- these strings, and SKIPs when there is no Gold cache or when the cache +-- predates the seed. +-- +-- ROM-free: `luajit tests/gen2_common_text_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction; 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +local S = require("tests.harness").suite("gen2 common text") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local CommonText = require("src.core.gen2.CommonText") +local DayCareMenu = require("src.ui.gen2.DayCareMenu") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local MartMenu = require("src.ui.gen2.MartMenu") + +-- The cart's own characters, decoded out of the ROM by the extractor's +-- decodeGen2Text: \n is `line` / `next`, \f is `para`, \v is `cont` (the box +-- scrolls one row), {STRBUF} is a TX_RAM name and {NUM} a TX_DECIMAL field. +-- data/text/common_1.asm and common_2.asm, plus the three `db` strings inside +-- engine/events/halloffame.asm. +local PINNED = { + ["_DayCareManIntroText"] = + "I'm the DAY-CARE\nMAN. Want me to\vraise a POKéMON?", + ["_DayCareManIntroEggText"] = + "I'm the DAY-CARE\nMAN. Do you know\vabout EGGS?\fI was raising\nPOKéMON with my\vwife, you see.\fWe were shocked to\nfind an EGG!\fHow incredible is\nthat?\fSo, want me to\nraise a POKéMON?", + ["_DayCareLadyIntroText"] = + "I'm the DAY-CARE\nLADY.\fShould I raise a\nPOKéMON for you?", + ["_DayCareLadyIntroEggText"] = + "I'm the DAY-CARE\nLADY. Do you know\vabout EGGS?\fMy husband and I\nwere raising some\vPOKéMON, you see.\fWe were shocked to\nfind an EGG!\fHow incredible\ncould that be?\fShould I raise a\nPOKéMON for you?", + ["_WhatShouldIRaiseText"] = + "What should I\nraise for you?", + ["_OnlyOneMonText"] = + "Oh? But you have\njust one POKéMON.", + ["_CantAcceptEggText"] = + "Sorry, but I can't\naccept an EGG.", + ["_RemoveMailText"] = + "Remove MAIL before\nyou come see me.", + ["_LastHealthyMonText"] = + "If you give me\nthat, what will\vyou battle with?", + ["_IllRaiseYourMonText"] = + "OK. I'll raise\nyour {STRBUF}.", + ["_ComeBackLaterText"] = + "Come back for it\nlater.", + ["_AreWeGeniusesText"] = + "Are we geniuses or\nwhat? Want to see\vyour {STRBUF}?", + ["_YourMonHasGrownText"] = + "Your {STRBUF}\nhas grown a lot.\fBy level, it's\ngrown by {NUM}.\fIf you want your\nPOKéMON back, it\vwill cost ¥{NUM}.", + ["_PerfectHeresYourMonText"] = + "Perfect! Here's\nyour POKéMON.", + ["_GotBackMonText"] = + "{PLAYER} got back\n{STRBUF}.", + ["_BackAlreadyText"] = + "Huh? Back already?\nYour {STRBUF}\fneeds a little\nmore time with us.\fIf you want your\nPOKéMON back, it\vwill cost ¥100.", + ["_HaveNoRoomText"] = + "You have no room\nfor it.", + ["_NotEnoughMoneyText"] = + "You don't have\nenough money.", + ["_OhFineThenText"] = + "Oh, fine then.", + ["_ComeAgainText"] = + "Come again.", + ["_NotYetText"] = + "Not yet…", + ["_FoundAnEggText"] = + "Ah, it's you!\fWe were raising\nyour POKéMON, and\fmy goodness, were\nwe surprised!\fYour POKéMON had\nan EGG!\fWe don't know how\nit got there, but\fyour POKéMON had\nit. You want it?", + ["_ReceivedEggText"] = + "{PLAYER} received\nthe EGG!", + ["_TakeGoodCareOfEggText"] = + "Take good care of\nit.", + ["_IllKeepItThanksText"] = + "Well then, I'll\nkeep it. Thanks!", + ["_NoRoomForEggText"] = + "You have no room\nin your party.\vCome back later.", + ["_BreedEggHatchText"] = + "{STRBUF} came\nout of its EGG!", + ["_BreedAskNicknameText"] = + "Give a nickname to\n{STRBUF}?", + ["_LeftWithDayCareManText"] = + "It's {STRBUF}\nthat was left with\vthe DAY-CARE MAN.", + ["_LeftWithDayCareLadyText"] = + "It's {STRBUF}\nthat was left with\vthe DAY-CARE LADY.", + ["_BreedBrimmingWithEnergyText"] = + "It's brimming with\nenergy.", + ["_BreedNoInterestText"] = + "It has no interest\nin {STRBUF}.", + ["_BreedAppearsToCareForText"] = + "It appears to care\nfor {STRBUF}.", + ["_BreedFriendlyText"] = + "It's friendly with\n{STRBUF}.", + ["_BreedShowsInterestText"] = + "It shows interest\nin {STRBUF}.", + ["_MartWelcomeText"] = + "Welcome! How may I\nhelp you?", + ["_MartAskMoreText"] = + "Can I do anything\nelse for you?", + ["_MartComeAgainText"] = + "Please come again!", + ["_MartHowManyText"] = + "How many?", + ["_MartFinalPriceText"] = + "{NUM} {STRBUF}(S)\nwill be ¥{NUM}.", + ["_MartThanksText"] = + "Here you are.\nThank you!", + ["_MartNoMoneyText"] = + "You don't have\nenough money.", + ["_MartPackFullText"] = + "You can't carry\nany more items.", + ["_HerbShopLadyIntroText"] = + "Hello, dear.\fI sell inexpensive\nherbal medicine.\fThey're good, but\na trifle bitter.\fYour POKéMON may\nnot like them.\fHehehehe…", + ["_HerbalLadyHowManyText"] = + "How many?", + ["_HerbalLadyFinalPriceText"] = + "{NUM} {STRBUF}(S)\nwill be ¥{NUM}.", + ["_HerbalLadyThanksText"] = + "Thank you, dear.\nHehehehe…", + ["_HerbalLadyPackFullText"] = + "Oh? Your PACK is\nfull, dear.", + ["_HerbalLadyNoMoneyText"] = + "Hehehe… You don't\nhave the money.", + ["_HerbalLadyComeAgainText"] = + "Come again, dear.\nHehehehe…", + ["_BargainShopIntroText"] = + "Hiya! Care to see\nsome bargains?\fI sell rare items\nthat nobody else\fcarries--but only\none of each item.", + ["_BargainShopFinalPriceText"] = + "{STRBUF} costs\n¥{NUM}. Want it?", + ["_BargainShopThanksText"] = + "Thanks.", + ["_BargainShopPackFullText"] = + "Uh-oh, your PACK\nis chock-full.", + ["_BargainShopSoldOutText"] = + "You bought that\nalready. I'm all\vsold out of it.", + ["_BargainShopNoFundsText"] = + "Uh-oh, you're\nshort on funds.", + ["_BargainShopComeAgainText"] = + "Come by again\nsometime.", + ["_PharmacyIntroText"] = + "What's up? Need\nsome medicine?", + ["_PharmacyHowManyText"] = + "How many?", + ["_PharmacyFinalPriceText"] = + "{NUM} {STRBUF}(S)\nwill cost ¥{NUM}.", + ["_PharmacyThanksText"] = + "Thanks much!", + ["_PharmacyPackFullText"] = + "You don't have any\nmore space.", + ["_PharmacyNoMoneyText"] = + "Huh? That's not\nenough money.", + ["_PharmacyComeAgainText"] = + "All right.\nSee you around.", + ["_NothingToSellText"] = + "You don't have\nanything to sell.", + ["_MartSellHowManyText"] = + "How many?", + ["_MartSellPriceText"] = + "I can pay you\n¥{NUM}.\fIs that OK?", + ["_MartCantBuyText"] = + "Sorry, I can't buy\nthat from you.", + ["_MartBoughtText"] = + "Got ¥{NUM} for\n{STRBUF}(S).", + ["AnimateHallOfFame.String_NewHallOfFamer"] = + "New Hall of Famer!", + ["_HallOfFamePC.TimeFamer"] = + " -Time Famer", + ["_HallOfFamePC.HOFMaster"] = + " HOF Master!", +} + +-- Text_BreedHuh is the "Huh?" the hatch opens on, and the two empty strings +-- are real: `text_start / done` with nothing between them. They are seeded +-- too, so a label with no body is still a label the cache has. +PINNED["Text_BreedHuh"] = "Huh?\f" +local EMPTY_LABELS = { "_DaycareDummyText", "_BreedClearboxText" } + +-- The transcriptions spell the four-tile POKé compression byte as `#`, which +-- is the same sixteen columns the extractor's expansion draws. +local function norm(s) return (tostring(s):gsub("#", "POKé")) end + +-- One eq per page: concatenating the lines compares the line count too. +local function samePages(got, want, what) + eq(type(got) == "table" and #got or -1, #want, what .. ": page count") + for i = 1, #want do + eq(table.concat((got or {})[i] or {}, "|"), + norm(table.concat(want[i] or {}, "|")), ("%s: page %d"):format(what, i)) + end +end + +-- A text.lua shaped like the extractor's output, built out of PINNED so the +-- screens can be driven with it. The keys are made up (nothing reads an +-- address, only text.labels), which is the whole point of the labels table. +local TEXT = { generation = 2, labels = {} } +do + local n = 0 + for label, body in pairs(PINNED) do + n = n + 1 + local key = ("64:%04x"):format(0x4000 + n) + TEXT.labels[label] = key + TEXT[key] = body + end +end + +-- ---- CommonText itself ---------------------------------------------------- +do + eq(CommonText.get(TEXT, "_MartHowManyText"), "How many?", + "a label resolves through text.labels to its string") + check(CommonText.get(TEXT, "_NoSuchText") == nil, "an unknown label is nil") + check(CommonText.get({}, "_MartHowManyText") == nil, + "and so is a cache with no labels table at all") + check(CommonText.of(nil, "_MartHowManyText") == nil, "as is no text at all") + + -- `line` fills the box's second row, `para` clears it, `cont` scrolls it -- + -- so a cont page opens on the previous page's second line. + local pages = CommonText.pages("one\ntwo\vthree\ffour") + eq(#pages, 3, "para and cont each end a page") + eq(table.concat(pages[1], "|"), "one|two", "line is the second row") + eq(table.concat(pages[2], "|"), "two|three", "cont scrolls the row up") + eq(table.concat(pages[3], "|"), "four", "para starts an empty box") + eq(#CommonText.pages("just one line"), 1, "a bare string is one page") + + -- The markers are consumed in the order they appear, not by kind: the mart + -- clerk leads with the quantity and the bargain shop with the item name. + local price = CommonText.of(TEXT, "_MartFinalPriceText", { 2, "POTION", 600 }) + eq(table.concat(price[1], "|"), "2 POTION(S)|will be ¥600.", + "{NUM} then {STRBUF} then {NUM}") + local bargain = CommonText.of(TEXT, "_BargainShopFinalPriceText", + { "POTION", 600 }) + eq(table.concat(bargain[1], "|"), "POTION costs|¥600. Want it?", + "{STRBUF} then {NUM}") + local egg = CommonText.of(TEXT, "_ReceivedEggText", { player = "GOLD" }) + eq(table.concat(egg[1], "|"), "GOLD received|the EGG!", "{PLAYER} by name") + -- A marker with no value left prints nothing, the way the cart's freshly + -- `@`-filled buffer does. + local unset = CommonText.of(TEXT, "_IllRaiseYourMonText", {}) + eq(table.concat(unset[1], "|"), "OK. I'll raise|your .", + "an unfilled marker leaves an empty span") +end + +-- ---- the Day-Care --------------------------------------------------------- +-- +-- The six entries that take arguments, with the values their markers name. +local DAY_CARE_ARGS = { + deposit = { "NICK" }, + geniuses = { "NICK" }, + hasGrown = { "NICK", 2, 300 }, + backAlready = { "NICK" }, + gotBack = { "GOLD", "NICK" }, + receivedEgg = { "GOLD" }, +} +do + local screen = DayCareMenu.new(nil, + { save = { daycare = {} }, side = "man", text = TEXT }) + local seen = 0 + for key, label in pairs(DayCareMenu.LABELS) do + seen = seen + 1 + check(PINNED[label] ~= nil, label .. " is pinned") + check(rawget(screen.TEXT, key) ~= nil, + key .. " comes from the cache, not from the transcription") + local got, want = screen.TEXT[key], DayCareMenu.TEXT[key] + if type(want) == "function" then + local args = DAY_CARE_ARGS[key] + check(args ~= nil, key .. " has arguments pinned") + got, want = got(unpack(args)), want(unpack(args)) + end + samePages(got, want, label) + end + eq(seen, 26, "every Day-Care string the screen says is extracted") + + -- With no cache the screen still talks: the transcription is the fallback. + local bare = DayCareMenu.new(nil, { save = { daycare = {} }, side = "man" }) + eq(bare.TEXT.comeAgain, DayCareMenu.TEXT.comeAgain, + "and a cache with no labels falls back to the transcription") +end + +-- ---- the POKeMART --------------------------------------------------------- +-- +-- welcome / askMore / howMany are ONE screenful of lines rather than a list of +-- pages, because a menu sits over them instead of paging them. +local MART_SINGLE = { welcome = true, askMore = true, howMany = true } +local MART_ARGS = { finalPrice = { 2, "POTION", 600 } } +local SELL_ARGS = { price = { 600 }, bought = { "POTION", 600 } } +do + local seen = 0 + for kind, labels in pairs(MartMenu.LABELS) do + if kind ~= "SELL" then + local screen = MartMenu.new(nil, + { martType = kind, text = TEXT, save = { money = 1000 } }) + for key, label in pairs(labels) do + seen = seen + 1 + check(PINNED[label] ~= nil, label .. " is pinned") + check(rawget(screen.text, key) ~= nil, + ("%s.%s comes from the cache"):format(kind, key)) + local got, want = screen.text[key], MartMenu.TEXTS[kind][key] + if type(want) == "function" then + got, want = got(unpack(MART_ARGS[key])), want(unpack(MART_ARGS[key])) + elseif MART_SINGLE[key] then + got, want = { got }, { want } + end + samePages(got, want, label) + end + end + end + + -- SellMenu's own four, which only MARTTYPE_STANDARD ever reaches. + local standard = MartMenu.new(nil, + { martType = "STANDARD", text = TEXT, save = { money = 1000 } }) + local sell = MartMenu.new(nil, { martType = "STANDARD", save = {} }) + for key, label in pairs(MartMenu.LABELS.SELL) do + seen = seen + 1 + check(PINNED[label] ~= nil, label .. " is pinned") + check(rawget(standard.sellText, key) ~= nil, + ("SELL.%s comes from the cache"):format(key)) + local got, want = standard.sellText[key], sell.sellText[key] + if type(want) == "function" then + got, want = got(unpack(SELL_ARGS[key])), want(unpack(SELL_ARGS[key])) + elseif MART_SINGLE[key] then + got, want = { got }, { want } + end + samePages(got, want, label) + end + eq(seen, 33, "every clerk line the screen says is extracted") +end + +-- ---- the Hall of Fame ----------------------------------------------------- +do + local induct = HallOfFame.headerPlacements("induct", 1, TEXT) + eq(HallOfFame.at(induct, 1, 2), + PINNED["AnimateHallOfFame.String_NewHallOfFamer"], + "the induction header is the cart's own string") + eq(HallOfFame.at(induct, 1, 2), HallOfFame.NEW_FAMER, + "and the transcription says the same thing") + local viewed = HallOfFame.headerPlacements("view", 12, TEXT) + eq(HallOfFame.at(viewed, 1, 2), PINNED["_HallOfFamePC.TimeFamer"], + "the PC's header keeps its four leading spaces") + eq(HallOfFame.at(viewed, 1, 2), HallOfFame.TIME_FAMER, "same transcription") + -- The count is written OVER those spaces at (2,2), which is why they exist. + eq(HallOfFame.at(viewed, 2, 2), " 12", "the win count lands on top of them") + local master = HallOfFame.headerPlacements("view", 100000, TEXT) + eq(HallOfFame.at(master, 1, 2), PINNED["_HallOfFamePC.HOFMaster"], + "and the unreachable HOF Master! title is extracted too") + eq(HallOfFame.at(master, 1, 2), HallOfFame.HOF_MASTER, "same transcription") + eq(HallOfFame.at(HallOfFame.headerPlacements("induct", 1), 1, 2), + HallOfFame.NEW_FAMER, "with no cache the constant is the fallback") +end + +-- ---- against the cache ---------------------------------------------------- +-- +-- The strings above were transcribed from the decomp; this is what says the +-- import agrees. A cache built before the NAMED_TEXT seed has no labels +-- table at all, and needs a re-import rather than a fix here. +do + local cacheDir = os.getenv("GOLD_CACHE") + if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" + end + local textFile = loadfile(cacheDir .. "/data/generated/text.lua") + if not textFile then + check(true, "no Gold cache (SKIP)") + else + local text = textFile() + if type(text.labels) ~= "table" then + check(true, "cache predates the NAMED_TEXT seed; re-import (SKIP)") + else + local missing, wrong = {}, {} + for label, want in pairs(PINNED) do + local key = text.labels[label] + if not key then + missing[#missing + 1] = label + elseif text[key] ~= want then + wrong[#wrong + 1] = label + end + end + eq(table.concat(missing, ", "), "", "every seeded label is in the cache") + eq(table.concat(wrong, ", "), "", + "and every string matches the transcription exactly") + for _, label in ipairs(EMPTY_LABELS) do + check(text.labels[label] ~= nil, + label .. " is seeded even though it is empty") + eq(text[text.labels[label]], "", label .. " really is empty") + end + end + end +end + +S.finish() diff --git a/tests/gen2_contest_test.lua b/tests/gen2_contest_test.lua new file mode 100644 index 00000000..4d01b880 --- /dev/null +++ b/tests/gen2_contest_test.lua @@ -0,0 +1,1229 @@ +-- The Bug Catching Contest and Kurt's apricorns. +-- GOLD_CACHE="..." luajit tests/gen2_contest_test.lua +-- +-- Both systems are pure rules over a save table, so every assertion below runs +-- with no cache, no love and no stack. The rolls are injected byte by byte +-- (`call Random` yields one byte, so a seed here is a literal list of the +-- bytes the ASM would have read) and the clock is injected as a cart-shaped +-- { day, hour, minute, second }, which is the only way to test a day-long wait +-- and a player winding the clock backwards. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 contest") +local check, eq = S.check, S.eq +local seq = S.rng.seq + +love = require("tests.love_stub") + +local BugContest = require("src.core.gen2.BugContest") +local Apricorns = require("src.core.gen2.Apricorns") + +-- ---- constants ------------------------------------------------------------ +eq(BugContest.BALLS, 20, "BUG_CONTEST_BALLS is 20") +eq(BugContest.MINUTES, 20, "BUG_CONTEST_MINUTES is 20") +eq(BugContest.SECONDS, 0, "BUG_CONTEST_SECONDS is 0") +eq(BugContest.PLAYER, 1, "BUG_CONTEST_PLAYER is 1") +eq(BugContest.NUM_CONTESTANTS, 10, "ten contestants, not counting the player") +eq(BugContest.CONTESTANT_SIZE, 4, "BUG_CONTESTANT_SIZE is 4") +eq(BugContest.CONTESTANTS_PICKED, 5, "five of the ten are picked") +eq(BugContest.BALL, "PARK_BALL", "the park's only ball") +eq(BugContest.PRIZES[1], "SUN_STONE", "first place is the SUN STONE") +eq(BugContest.PRIZES[2], "EVERSTONE", "second is the EVERSTONE") +eq(BugContest.PRIZES[3], "GOLD_BERRY", "third is the GOLD BERRY") +eq(BugContest.CONSOLATION_PRIZE, "BERRY", "and everyone else gets a BERRY") +eq(BugContest.prizeFor(0), "BERRY", "placing nowhere is the consolation prize") +eq(BugContest.prizeFor(1), "SUN_STONE", "placing first is the SUN STONE") + +-- Route35OfficerScriptContest turns you away on Sunday, Monday, Wednesday and +-- Friday. +local DAYS = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", + "FRIDAY", "SATURDAY" } +local WANT_CONTEST = { false, false, true, false, true, false, true } +for index = 1, 7 do + eq(BugContest.isContestDay({ day = index - 1 }), WANT_CONTEST[index], + DAYS[index] .. " is " .. (WANT_CONTEST[index] and "" or "not ") + .. "a contest day") +end +-- GetWeekday is wCurDay mod 7, so day 9 is the same weekday as day 2. +eq(BugContest.weekday({ day = 9 }), 2, "day 9 is a TUESDAY, like day 2") +check(BugContest.isContestDay({ day = 9 }), "and so it is a contest day") + +-- ---- ContestScore --------------------------------------------------------- +-- +-- Hand computed against engine/events/bug_contest/judging.asm ContestScore. +local function mon(fields) + local out = { + species = "SCYTHER", level = 13, + maxHp = 45, hp = 45, + stats = { hp = 45, attack = 40, defense = 35, speed = 50, + specialAttack = 25, specialDefense = 30 }, + dvs = { attack = 15, defense = 0, speed = 2, special = 8 }, + } + for key, value in pairs(fields or {}) do out[key] = value end + return out +end + +-- 4 * 45 max HP = 180 +-- 40 + 35 + 50 + 25 + 30 = 180 the five stats +-- DV bit 1s: atk 15 -> 1, def 0 -> 0, spd 2 -> 1, spc 8 -> 0 +-- 8 * 1 + 16 * 0 + 1 * 1 + 4 * 0 = 9 +-- floor(45 / 8) = 5 +-- no held item = 0 +eq(BugContest.score(mon()), 374, "the hand computed base score") + +-- The item term is a flat +1, whatever the item is. +eq(BugContest.score(mon({ item = "BERRY" })), 375, "a held item is worth one") + +-- Every DV bit weight, one at a time, against a mon whose DVs are otherwise 0. +local BARE = { attack = 0, defense = 0, speed = 0, special = 0 } +local function dvOnly(key, value) + local dvs = {} + for k, v in pairs(BARE) do dvs[k] = v end + dvs[key] = value + return BugContest.score(mon({ dvs = dvs })) - BugContest.score(mon({ dvs = BARE })) +end +eq(dvOnly("defense", 2), 16, "bit 1 of the Defense DV is worth 16") +eq(dvOnly("attack", 2), 8, "bit 1 of the Attack DV is worth 8") +eq(dvOnly("special", 2), 4, "bit 1 of the Special DV is worth 4") +eq(dvOnly("speed", 2), 1, "bit 1 of the Speed DV is worth 1") +-- Bit 0 and bits 2-3 are not read at all: `and %0010` masks everything else +-- off, so a DV of 1 scores like a DV of 0 and a DV of 13 (%1101) does too. +eq(dvOnly("attack", 1), 0, "bit 0 of a DV is not read") +eq(dvOnly("attack", 13), 0, "and neither are bits 2 and 3") +eq(dvOnly("attack", 15), 8, "only bit 1 counts, so 15 scores like 2") + +-- Every term is an EIGHT BIT read out of a big-endian party struct, so a max +-- HP of 300 contributes 4 * (300 - 256), not 1200. +eq(BugContest.score(mon({ maxHp = 300, hp = 0, dvs = BARE })), + 4 * 44 + 180, "max HP is read one byte at a time") +-- Remaining HP is shifted right three times, so 45 and 47 tally the same 5. +eq(BugContest.score(mon({ hp = 47 })), BugContest.score(mon({ hp = 45 })), + "remaining HP is divided by eight") + +-- `ld a, [wContestMonSpecies] / and a / jr z, .done` +eq(BugContest.score(nil), 0, "no mon scores nothing") +eq(BugContest.score({ maxHp = 200 }), 0, "and neither does an empty slot") + +-- ---- the contestant table ------------------------------------------------- +eq(#BugContest.CONTESTANTS, 10, "ten contestant rows") +-- BugContestantPointers slot 1 is Bug Catcher Don and slot 10 is Schoolboy +-- Kipp; slot 0's duplicate Don is deliberately not in the Lua table. +eq(BugContest.CONTESTANTS[1].name, "DON", "slot 1 is DON") +eq(BugContest.CONTESTANTS[10].name, "KIPP", "slot 10 is KIPP") +eq(BugContest.contestantId(1), 2, "slot 1 answers to winner ID 2") +eq(BugContest.contestantId(10), 11, "slot 10 answers to winner ID 11") +eq(BugContest.contestantSlot(2), 1, "and back again") +for slot, row in ipairs(BugContest.CONTESTANTS) do + eq(#row.mons, 3, ("contestant %d lists three mons"):format(slot)) +end +-- Cooltrainer Nick's rows are NOT in score order on the cart: his "third" +-- Pinsir outscores his "first" Scyther. Transcribed, not sorted. +eq(BugContest.CONTESTANTS[3].mons[1].score, 357, "Nick's first row is 357") +eq(BugContest.CONTESTANTS[3].mons[3].score, 368, "and his third row is 368") + +eq(BugContest.contestantName(nil, 1, "GOLD"), "GOLD", "ID 1 is the player") +eq(BugContest.contestantName(nil, 4), "COOLTRAINERM NICK", + "and a contestant falls back to the transcribed class and name") + +-- ---- ComputeAIContestantScores' roll -------------------------------------- +-- +-- Two bytes per contestant: `and 3` with a reroll on 3 picks the mon, `and 7` +-- bumps the score. Slot 3 is Cooltrainer Nick. +local rolled = BugContest.rollContestant(3, seq(3, 5, 10)) +eq(rolled.id, 4, "the roll reports Nick's winner ID") +eq(rolled.species, "BUTTERFREE", "5 & 3 is 1, so his second row") +eq(rolled.score, 351, "349 plus 10 & 7") + +-- A byte of 3 is thrown away rather than folded, which is what keeps the three +-- rows equally likely. +eq(BugContest.rollContestant(3, seq(3, 3, 3, 0, 0)).species, "SCYTHER", + "three rerolls land on the first row") +eq(BugContest.rollContestant(3, seq(2, 0)).species, "PINSIR", + "and 2 is the third row") +eq(BugContest.rollContestant(3, seq(0, 7)).score, 357 + 7, + "the bump is masked to three bits") +eq(BugContest.rollContestant(3, seq(0, 255)).score, 357 + 7, + "so 255 bumps by seven, not by 255") + +-- ---- DetermineContestWinners ---------------------------------------------- +local function entry(id, score) return { id = id, species = "PARAS", score = score } end + +local podium = {} +BugContest.placeEntry(podium, entry(2, 100)) +BugContest.placeEntry(podium, entry(3, 300)) +BugContest.placeEntry(podium, entry(4, 200)) +eq(podium.first.score, 300, "the podium sorts descending") +eq(podium.second.score, 200, "second") +eq(podium.third.score, 100, "third") + +-- A fourth entry below third does not displace anything. +BugContest.placeEntry(podium, entry(5, 50)) +eq(podium.third.score, 100, "a worse score never reaches the podium") + +-- CompareBytes only sets carry on a STRICTLY smaller score, so an equal score +-- takes the place and pushes the sitting entry down. The player is scored +-- last, which is what makes this the tie-break rule that matters. +podium = {} +BugContest.placeEntry(podium, entry(2, 300)) +BugContest.placeEntry(podium, entry(BugContest.PLAYER, 300)) +eq(podium.first.id, BugContest.PLAYER, "a tie goes to whoever is placed later") +eq(podium.second.id, 2, "and the incumbent drops to second") + +-- ---- BugContest_GetPlayersResult ------------------------------------------ +eq(BugContest.playerPlace(podium), 1, "the player took first") +eq(BugContest.playerPlace({ first = entry(2, 9), second = entry(BugContest.PLAYER, 8) }), + 2, "second") +eq(BugContest.playerPlace({ first = entry(2, 9), third = entry(BugContest.PLAYER, 1) }), + 3, "third") +eq(BugContest.playerPlace({ first = entry(2, 9) }), 0, + "and placing nowhere is 0, the consolation branch") + +-- ---- BugContest_JudgeContestants ------------------------------------------ +-- +-- A SET flag kept that trainer off the map, so the five picked contestants are +-- the five who do NOT score. Leaving only slot 1 in the running makes the +-- whole judging deterministic from four bytes. +local save = { party = {}, boxes = {}, inventory = {}, events = {} } +local state = BugContest.state(save) +state.contestants = {} +for slot = 2, 10 do state.contestants[slot] = true end + +-- Slot 1 is Bug Catcher Don: 0 & 3 picks his KAKUNA at 300, +0. +local results = BugContest.judge(state, mon(), 374, seq(0, 0)) +eq(results.first.id, BugContest.PLAYER, "374 beats Don's 300") +eq(results.second.id, 2, "and Don is second") +eq(results.third, nil, "with nobody third") +eq(BugContest.playerPlace(results), 1, "so the player wins the SUN STONE") + +-- The same field, with a player score one under Don's. +results = BugContest.judge(state, mon(), 299, seq(0, 0)) +eq(results.first.id, 2, "299 loses to Don's 300") +eq(BugContest.playerPlace(results), 2, "and takes the EVERSTONE instead") + +-- All ten present, all rolling their first row with no bump: the top three are +-- Nick 357, Barry 366 and Cindy 341 -- so the leader board is Barry, Nick, +-- Cindy and a player on 374 still wins. +state.contestants = {} +local zeros = {} +for _ = 1, 40 do zeros[#zeros + 1] = 0 end +results = BugContest.judge(state, mon(), 374, seq(unpack(zeros))) +eq(results.first.id, BugContest.PLAYER, "374 is above every listed first row") +eq(results.second.id, BugContest.contestantId(6), "Camper Barry's 366 is next") +eq(results.third.id, BugContest.contestantId(3), "then Cooltrainer Nick's 357") + +-- runJudging is the whole special: it scores wContestMon, judges, and leaves +-- the placing where BugContestJudging's `ld a, b / ld [wScriptVar], a` does. +state.contestants = {} +for slot = 2, 10 do state.contestants[slot] = true end +state.caught = mon() +eq(BugContest.runJudging(save, seq(0, 0)), 1, "runJudging answers the placing") +eq(state.playerScore, 374, "and records the score the judge used") +eq(state.results.first.id, BugContest.PLAYER, "and the podium it built") + +-- Catching nothing scores 0. With only one contestant in the field that is +-- still good enough for second, and that is NOT a bug in the port: +-- ClearContestResults zeroes all three podium slots, and CompareBytes only +-- rejects a STRICTLY smaller score, so a zero ties an empty slot and takes it. +state.caught = nil +eq(BugContest.runJudging(save, seq(0, 0)), 2, + "a zero score still fills an empty podium slot, as the cleared struct does") + +-- With a real field of five it places nowhere, which is the branch that hands +-- out the consolation BERRY. +state.contestants = { [1] = true, [2] = true, [3] = true, [4] = true, + [5] = true } +eq(BugContest.runJudging(save, seq(unpack(zeros))), 0, + "against a full field an empty stock places nowhere") +for slot = 2, 10 do state.contestants[slot] = true end +state.contestants[1] = nil + +-- ---- SelectRandomBugContestContestants ------------------------------------ +-- +-- `cp $ff / 10 * 10` rejects 250 and up, then SimpleDivide by 25 lands on +-- 0..9. Five distinct slots, rerolling a duplicate rather than reshuffling. +local picked = BugContest.pickContestants(save, seq(0, 25, 50, 75, 100)) +local count = 0 +for slot in pairs(picked) do count = count + 1 end +eq(count, 5, "five contestants are picked") +for slot = 1, 5 do + check(picked[slot], ("slot %d was picked"):format(slot)) +end +check(not picked[6], "and slot 6 was not") + +-- A byte of 250 or over is thrown away, and a duplicate slot is rerolled. +picked = BugContest.pickContestants(save, seq(250, 255, 0, 0, 25, 50, 75, 100)) +count = 0 +for _ in pairs(picked) do count = count + 1 end +eq(count, 5, "out-of-range bytes and duplicates are both rerolled") +check(picked[1] and picked[2] and picked[3] and picked[4] and picked[5], + "and the five that stuck are the five distinct slots") + +-- The five picked are the five that do NOT score. +local judged = BugContest.judge({ contestants = picked }, nil, 0, + seq(unpack(zeros))) +check(judged.first.id >= BugContest.contestantId(6), + "only the unpicked contestants turn up to be judged") + +-- ---- the contestant flag table -------------------------------------------- +-- +-- data/events/bug_contest_flags.asm, which is what turns a pick into a sprite +-- that is not on the map. +local Events = require("src.world.gen2.Events") + +eq(#BugContest.FLAGS, 10, "one flag per contestant, not counting the player") +for slot = 1, 10 do + eq(BugContest.FLAGS[slot], 1813 + slot, + ("slot %d is EVENT_BUG_CATCHING_CONTESTANT_%dA"):format(slot, slot)) +end + +eq(BugContest.contestantFlags(nil), BugContest.FLAGS, + "no event tables at all falls back to the transcription") +eq(BugContest.contestantFlags({}), BugContest.FLAGS, + "and so does a cache that predates the extractor writing it") +eq(BugContest.contestantFlags({ bugContestFlags = { 1, 2, 3 } }), + BugContest.FLAGS, "a short table is a misread, not a source") +local extractedFlags = { 900, 901, 902, 903, 904, 905, 906, 907, 908, 909 } +eq(BugContest.contestantFlags({ bugContestFlags = extractedFlags }), + extractedFlags, "a full one is preferred over the transcription") + +do + -- `.loop1` RESETS all ten before the five are set, so the flags the caller + -- writes are ALL ten and not just the picks. + local events = Events.new() + local chosen = { [2] = true, [4] = true, [6] = true, [8] = true, + [10] = true } + BugContest.applyContestantFlags(events, chosen, nil) + for slot = 1, 10 do + local flag = BugContest.FLAGS[slot] + if chosen[slot] then + check(events:get(flag), ("slot %d is flagged out of the park"):format(slot)) + check(not events:objectVisible(flag), + ("and object %d is hidden"):format(slot)) + else + check(not events:get(flag), ("slot %d stays in"):format(slot)) + check(events:objectVisible(flag), + ("and object %d is drawn"):format(slot)) + end + end + + -- A second contest must not inherit the first one's absentees: without the + -- reset the two picks would union and the park would keep emptying. + BugContest.applyContestantFlags(events, { [1] = true, [3] = true, + [5] = true, [7] = true, [9] = true }, nil) + local hidden = 0 + for slot = 1, 10 do + if events:get(BugContest.FLAGS[slot]) then hidden = hidden + 1 end + end + eq(hidden, 5, "the second contest hides five, not ten") + check(events:get(BugContest.FLAGS[1]), "the new picks are out") + check(not events:get(BugContest.FLAGS[2]), "and the old ones are back") + + -- The extracted table is the one written when it is there. + local other = Events.new() + BugContest.applyContestantFlags(other, { [1] = true }, + { bugContestFlags = extractedFlags }) + check(other:get(900), "an extracted flag number is the one that is set") + check(not other:get(BugContest.FLAGS[1]), + "and the transcribed number is left alone") + + -- A caller with no flag store at all still picks; it just cannot hide + -- anybody. + eq(BugContest.applyContestantFlags(nil, chosen, nil), nil, + "no wEventFlags is not an error") +end + +-- ---- ContestMons and the contest's own encounter table --------------------- +local rows = BugContest.contestMons(nil) +eq(#rows, 11, "eleven rows, including the unreachable VENOMOTH") +local total = 0 +for index = 1, 10 do total = total + rows[index].chance end +eq(total, 100, "the ten reachable rows add to exactly 100") +eq(rows[11].species, "VENOMOTH", "so the -1 fallthrough row is VENOMOTH") + +-- ChooseWildEncounter_BugContest: reject a byte of 200 or more, halve it into +-- 0..99, then subtract each row's slice until it borrows. +local wild = BugContest.chooseWild(nil, seq(200, 0, 5)) +eq(wild.species, "CATERPIE", "roll 0 lands in the first slice") +eq(wild.level, 7 + 5 % 12, "min 7 plus the level roll over the 12-wide span") + +-- 180 halves to 90, which is inside SCYTHER's 90..94 slice. +wild = BugContest.chooseWild(nil, seq(180, 1)) +eq(wild.species, "SCYTHER", "roll 90 lands on SCYTHER") +eq(wild.level, 14, "13 plus 1 & 1, over a span of two") + +-- A row whose min and max match skips the level roll entirely, so no second +-- byte is consumed; check the boundary rows instead. +wild = BugContest.chooseWild(nil, seq(198, 0)) +eq(wild.species, "PINSIR", "roll 99 is the last reachable slice") + +-- TryWildEncounter_BugContest. `40 percent` is `40 * $ff / 100`, so the +-- thresholds are 102 and 51 out of 256 and not 40 and 20 out of 100. +eq(BugContest.ENCOUNTER_RATE_SUPER_TALL, 102, "40 percent is 102") +eq(BugContest.ENCOUNTER_RATE_GRASS, 51, "20 percent is 51") +eq(BugContest.encounterRate(true), 102, "super tall grass is the higher rate") +eq(BugContest.encounterRate(false), 51, "ordinary grass the lower one") +check(BugContest.triggers(true, seq(101)), "a roll under the rate bites") +check(not BugContest.triggers(true, seq(102)), "a roll at the rate does not") +check(not BugContest.triggers(false, seq(51)), "and 51 is over the grass rate") + +-- ---- ContestDropOffMons / ContestReturnMons ------------------------------- +local function party(...) + local list = {} + for _, species in ipairs({ ... }) do + list[#list + 1] = { species = species, hp = 20, maxHp = 20 } + end + return list +end + +save = { party = party("CYNDAQUIL", "PIDGEY", "GEODUDE"), boxes = {}, + inventory = {}, events = {} } +eq(BugContest.dropOffMons(save), 0, "dropping the party off succeeds") +eq(#save.party, 1, "only the lead mon is left") +eq(save.party[1].species, "CYNDAQUIL", "and it is the lead") +eq(#BugContest.state(save).stash, 2, "the tail is stashed on the SAVE") + +-- A caught mon lands in slot 2 while the tail is away, and ContestReturnMons +-- recomputes the count by walking to the terminator -- so the tail goes back +-- BEHIND it rather than over it. +save.party[2] = { species = "SCYTHER", hp = 1, maxHp = 40 } +BugContest.returnMons(save) +eq(#save.party, 4, "the party comes back with the catch in it") +eq(save.party[2].species, "SCYTHER", "the catch keeps slot 2") +eq(save.party[3].species, "PIDGEY", "and the tail lands behind it") +eq(BugContest.state(save).stash, nil, "the stash is spent") + +-- `.fainted`: a lead mon on 0 HP answers TRUE, and the officer says so. +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +save.party[1].hp = 0 +eq(BugContest.dropOffMons(save), 1, "a fainted lead mon refuses") +eq(#save.party, 1, "and nothing is masked off") + +-- ---- GiveParkBalls, the timer and the park balls -------------------------- +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +local START = { day = 10, hour = 12, minute = 0, second = 0 } +state = BugContest.start(save, START) +check(state.active, "the contest is running") +eq(state.balls, 20, "with twenty PARK BALLs") +eq(state.minutes, 20, "and twenty minutes") +eq(state.seconds, 0, "on the dot") +eq(state.caught, nil, "and nothing caught yet") +check(BugContest.isActive(save), "isActive follows the timer flag") + +-- CheckBugContestTimer subtracts the time since the LAST poll, because +-- CalcSecsMinsHoursDaysSince advances the stored stamp as it reads it. +check(not BugContest.tickTimer(save, { day = 10, hour = 12, minute = 5, second = 30 }), + "five and a half minutes in, the contest runs on") +eq(state.minutes, 14, "14 minutes left") +eq(state.seconds, 30, "and 30 seconds") +check(not BugContest.tickTimer(save, { day = 10, hour = 12, minute = 10, second = 0 }), + "another four and a half minutes") +eq(state.minutes, 10, "10 minutes left") +eq(state.seconds, 0, "flat") + +-- Landing on exactly 0:00 does NOT end it: `sbc` leaves no borrow, so the +-- routine returns with the carry clear and the NEXT poll is the one that ends +-- the contest. +check(not BugContest.tickTimer(save, { day = 10, hour = 12, minute = 20, second = 0 }), + "hitting 0:00 exactly is still not over") +eq(state.minutes, 0, "the clock reads 0") +eq(state.seconds, 0, "00") +check(BugContest.tickTimer(save, { day = 10, hour = 12, minute = 20, second = 1 }), + "one more second ends it") + +-- Ending zeroes both halves of the clock. +local left, secs = BugContest.timeLeft(save) +eq(left, 0, "no minutes left") +eq(secs, 0, "no seconds either") + +-- Any whole hour or day of elapsed time ends it outright, and so does a clock +-- wound BACKWARDS: _CalcDaysSince and its siblings wrap a negative difference +-- into a large positive one instead of clamping it, so a rewound clock reads +-- as an enormous jump forward. +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +BugContest.start(save, START) +check(BugContest.tickTimer(save, { day = 10, hour = 13, minute = 0, second = 0 }), + "an hour later the contest is over") + +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +BugContest.start(save, START) +check(BugContest.tickTimer(save, { day = 11, hour = 12, minute = 0, second = 0 }), + "and so is a day later") + +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +BugContest.start(save, START) +check(BugContest.tickTimer(save, { day = 10, hour = 11, minute = 59, second = 0 }), + "winding the clock back one minute ends the contest, as it does on the cart") + +-- A stopped contest polls to nothing. +BugContest.stop(save) +check(not BugContest.isActive(save), "clearflag ENGINE_BUG_CONTEST_TIMER") +check(not BugContest.tickTimer(save, { day = 12, hour = 0, minute = 0, second = 0 }), + "and a stopped clock never ends again") + +-- ---- the park balls ------------------------------------------------------- +save = { party = party("CYNDAQUIL"), boxes = {}, inventory = {}, events = {} } +state = BugContest.start(save, START) +eq(BugContest.ballsLeft(save), 20, "twenty to start") +check(not BugContest.isOver(save), "and the contest is not over") + +-- A Park Ball is never in the bag: PokeBallEffect's `.used_park_ball` does +-- `dec [hl]` on wParkBallsRemaining instead of tossing an item. +local kind = BugContest.catch(save, mon({ species = "CATERPIE" })) +eq(kind, BugContest.KEEP_FIRST, "the first catch is kept with no question") +eq(BugContest.ballsLeft(save), 19, "and cost one ball") +eq(save.inventory.PARK_BALL, nil, "the bag never held a PARK BALL") +eq(BugContest.caughtMon(save).species, "CATERPIE", "the catch is in stock") + +-- A second catch asks instead of replacing. +local ask, stock, fresh = BugContest.catch(save, mon({ species = "SCYTHER" })) +eq(ask, BugContest.ASK_SWITCH, "the second catch asks") +eq(stock.species, "CATERPIE", "handing back the stock mon") +eq(fresh.species, "SCYTHER", "and the new one") +eq(BugContest.caughtMon(save).species, "CATERPIE", + "and declining -- which is what B does -- keeps the stock mon") +eq(BugContest.ballsLeft(save), 18, "the asking still cost a ball") + +BugContest.switchCaught(save, fresh) +eq(BugContest.caughtMon(save).species, "SCYTHER", "saying YES swaps it in") + +-- CheckContestBattleOver: an empty ball count is what ends the contest early. +state.balls = 1 +BugContest.useBall(save) +eq(BugContest.ballsLeft(save), 0, "the last ball is spent") +check(BugContest.isOver(save), "which ends the contest") +BugContest.useBall(save) +eq(BugContest.ballsLeft(save), 0, "and the count never goes negative") + +-- ---- CheckPartyFullAfterContest ------------------------------------------- +save = { party = party("CYNDAQUIL"), boxes = {}, boxNames = {}, currentBox = 1, + inventory = {}, events = {} } +BugContest.start(save, START) +BugContest.catch(save, mon({ species = "PINSIR" })) +local answer, kept = BugContest.collectCaughtMon(save) +eq(answer, BugContest.CAUGHT_MON, "a party with room takes the catch") +eq(kept.species, "PINSIR", "and it is the mon that was caught") +eq(save.party[2].species, "PINSIR", "which joins the party") +eq(BugContest.caughtMon(save), nil, "and wContestMon is cleared") + +eq(BugContest.collectCaughtMon(save), BugContest.NO_CATCH, + "catching nothing answers BUGCONTEST_NO_CATCH") + +-- A full party sends it to the current box instead. +save = { party = party("A", "B", "C", "D", "E", "F"), boxes = {}, + boxNames = {}, currentBox = 3, inventory = {}, events = {} } +BugContest.start(save, START) +BugContest.catch(save, mon({ species = "PINSIR" })) +answer = BugContest.collectCaughtMon(save) +eq(answer, BugContest.BOXED_MON, "a full party boxes the catch") +eq(#save.party, 6, "the party is untouched") +local Boxes = require("src.core.gen2.Boxes") +eq(Boxes.box(save, 3)[1].species, "PINSIR", "and it landed in the current box") + +-------------------------------------------------------------------------- +-- Kurt and the apricorns +-------------------------------------------------------------------------- + +-- ---- every apricorn to ball mapping --------------------------------------- +local WANT = { + { "RED_APRICORN", "LEVEL_BALL" }, + { "BLU_APRICORN", "LURE_BALL" }, + { "YLW_APRICORN", "MOON_BALL" }, + { "GRN_APRICORN", "FRIEND_BALL" }, + { "WHT_APRICORN", "FAST_BALL" }, + { "BLK_APRICORN", "HEAVY_BALL" }, + { "PNK_APRICORN", "LOVE_BALL" }, +} +eq(#Apricorns.BALLS, 7, "seven apricorns") +for index, row in ipairs(WANT) do + eq(Apricorns.ballFor(row[1]), row[2], row[1] .. " becomes a " .. row[2]) + eq(Apricorns.apricornFor(row[2]), row[1], "and back again") + eq(Apricorns.BALLS[index].apricorn, row[1], + ("row %d is in ApricornBalls order"):format(index)) +end +eq(Apricorns.ballFor("BERRY"), nil, "a berry is not an apricorn") +check(Apricorns.isApricorn("PNK_APRICORN"), "the pink one is") +check(not Apricorns.isApricorn("POKE_BALL"), "a POKE BALL is not") + +-- ---- FindApricornsInBag --------------------------------------------------- +-- +-- The list is always in ApricornBalls order, never pack order, and always ends +-- in the CANCEL row that wKurtApricornCount counts. +local bag = { PNK_APRICORN = 1, RED_APRICORN = 3, GRN_APRICORN = 1 } +local list = Apricorns.bagList(bag) +eq(#list, 3, "three apricorns in the bag") +eq(list[1], "RED_APRICORN", "red first, whatever order the pack is in") +eq(list[2], "GRN_APRICORN", "then green") +eq(list[3], "PNK_APRICORN", "then pink") +eq(list.cancel, 4, "and CANCEL is the fourth row") +check(not list.empty, "the list is not empty") + +local none = Apricorns.bagList({}) +check(none.empty, "an apricorn-free bag is the `scf` case") +eq(none.cancel, 1, "whose only row is CANCEL") + +eq(Apricorns.select(bag, 2), "GRN_APRICORN", "picking row 2 gives the green one") +eq(Apricorns.select(bag, 4), nil, "picking CANCEL gives FALSE") +eq(Apricorns.select({}, 1), nil, "and an empty bag always gives FALSE") + +-- ---- the handover --------------------------------------------------------- +save = { inventory = { RED_APRICORN = 2, BLU_APRICORN = 1 }, events = {}, + engineFlags = {} } +local ok, ball = Apricorns.give(save, "RED_APRICORN") +check(ok, "Kurt takes the red apricorn") +eq(ball, "LEVEL_BALL", "and promises a LEVEL BALL") +-- SelectApricornForKurt's TossItem takes exactly one, and the map script never +-- takes any -- so a port that does both would eat two. +eq(save.inventory.RED_APRICORN, 1, "exactly one apricorn leaves the bag") +eq(save.inventory.BLU_APRICORN, 1, "the others are untouched") +-- 600, not the 237 counting the `const` lines gives: `const_next 600` sits two +-- lines above the block in constants/event_flags.asm, and the extracted Kurt1 +-- script's red arm is `setevent 600`. save.events is wEventFlags as bytes. +eq(Apricorns.BALLS[1].event, 600, "EVENT_GAVE_KURT_RED_APRICORN is 600") +check(Apricorns.event(save, 600), "EVENT_GAVE_KURT_RED_APRICORN is set") +eq(save.events[75], 1, "as bit 0 of wEventFlags byte 75, the way SRAM holds it") +check(not Apricorns.event(save, 601), "and its neighbour is untouched") +check(Apricorns.isWorking(save), "and ENGINE_KURT_MAKING_BALLS with it") + +eq(Apricorns.pending(save), "RED_APRICORN", "he is holding the red one") +eq(Apricorns.readyBall(save), nil, "and while he works there is no ball") + +check(not Apricorns.give(save, "YLW_APRICORN"), + "an apricorn the bag does not hold cannot be handed over") +check(not Apricorns.give(save, "BERRY"), "and neither can a berry") + +-- ---- the day-long wait ---------------------------------------------------- +-- +-- Nothing about Kurt runs a timer. ENGINE_KURT_MAKING_BALLS is wDailyFlags1 +-- bit 0, and CheckDailyResetTimer clearing both daily-flag bytes is the ONLY +-- thing that ever finishes his work. +local DAY10 = { day = 10, hour = 9, minute = 0, second = 0 } +Apricorns.startDailyResetTimer(save, DAY10) +eq(save.dailyReset.remaining, 1, "the daily timer counts one day") +eq(save.dailyReset.day, 10, "from today") + +check(not Apricorns.checkDailyResetTimer(save, + { day = 10, hour = 23, minute = 59, second = 59 }), + "the same calendar day never rolls over, however late it gets") +check(Apricorns.isWorking(save), "so Kurt is still working") +eq(Apricorns.readyBall(save), nil, "and the ball is not ready") + +check(Apricorns.checkDailyResetTimer(save, { day = 11, hour = 0, minute = 1 }), + "one minute past midnight the next day IS the rollover") +check(not Apricorns.isWorking(save), "which clears ENGINE_KURT_MAKING_BALLS") +eq(Apricorns.readyBall(save), "LEVEL_BALL", "and the LEVEL BALL is ready") +eq(save.dailyReset.remaining, 1, "the timer restarts for the next day") +eq(save.dailyReset.day, 11, "from the new day") + +-- The rollover wipes every wDailyFlags1/2 bit, not just Kurt's. +save.engineFlags[Apricorns.ENGINE_ALL_FRUIT_TREES] = true +save.engineFlags[80] = true -- ENGINE_DAILY_BUG_CONTEST +save.engineFlags[BugContest.ENGINE_BUG_CONTEST_TIMER] = true +save.engineFlags[26] = true -- ENGINE_ZEPHYRBADGE +check(Apricorns.checkDailyResetTimer(save, { day = 12, hour = 0, minute = 0 }), + "the next day rolls over too") +eq(save.engineFlags[Apricorns.ENGINE_ALL_FRUIT_TREES], nil, + "the fruit trees refill") +eq(save.engineFlags[80], nil, "the contest can be entered again") +check(save.engineFlags[BugContest.ENGINE_BUG_CONTEST_TIMER], + "but ENGINE_BUG_CONTEST_TIMER lives in wStatusFlags2 and survives") +check(save.engineFlags[26], "and a badge is not a daily flag either") + +-- Collecting the ball is what clears the colour's event, and only after the +-- ball is actually in hand: `verbosegiveitem / iffalse .NoRoomForBall`. +local got, spent = Apricorns.collect(save) +eq(got, "LEVEL_BALL", "the ball comes back") +eq(spent, "RED_APRICORN", "for the apricorn that made it") +check(not save.events[237], "and the event is cleared") +eq(Apricorns.pending(save), nil, "so Kurt is holding nothing") +eq(Apricorns.collect(save), nil, "and has nothing more to hand over") + +-- ---- the clock wound backwards -------------------------------------------- +-- +-- _CalcDaysSince wraps a negative difference by adding 20 * 7, so setting the +-- system clock back a day reads as 139 days FORWARD and rolls the daily flags +-- immediately. The cart does nothing about this and neither does the port; +-- the test exists so the behaviour is a decision rather than an accident. +save = { inventory = { BLK_APRICORN = 1 }, events = {}, engineFlags = {} } +Apricorns.give(save, "BLK_APRICORN") +Apricorns.startDailyResetTimer(save, { day = 10, hour = 12 }) +check(Apricorns.isWorking(save), "Kurt starts on the heavy ball") +check(Apricorns.checkDailyResetTimer(save, { day = 9, hour = 12 }), + "winding the clock back a day rolls the daily flags anyway") +eq(Apricorns.readyBall(save), "HEAVY_BALL", + "so the HEAVY BALL is ready early, exactly as it is on the cart") +eq(save.dailyReset.day, 9, "and the timer restarts from the new day") + +-- A save with no timer at all starts one rather than rolling over on the spot. +save = { inventory = {}, events = {}, engineFlags = {} } +check(not Apricorns.checkDailyResetTimer(save, { day = 5 }), + "a fresh save arms the daily timer instead of firing it") +eq(save.dailyReset.day, 5, "from the day it first ran") + +-- UpdateTimeRemaining's -1 sentinel: an elapsed span too large for its unit +-- expires the counter outright. +local remaining, expired = Apricorns.updateTimeRemaining(5, -1) +eq(remaining, 0, "an out-of-range elapsed span zeroes the counter") +check(expired, "and expires it") +remaining, expired = Apricorns.updateTimeRemaining(3, 1) +eq(remaining, 2, "an ordinary day decrements it") +check(not expired, "without expiring it") + +-- ---- the fruit trees ------------------------------------------------------ +eq(Apricorns.NUM_FRUIT_TREES, 30, "thirty fruit trees") +-- FRUITTREE_* opens `const_def 1`, so the ids are 1-based and a 1-based Lua +-- list lines up with them: tree 1 is Route 29's BERRY and tree 30 is Fuchsia's +-- BURNT BERRY. A 0-based table would shift every row and drop the last. +eq(Apricorns.treeFruit(1), "BERRY", "tree 1 is FRUITTREE_ROUTE_29") +eq(Apricorns.treeFruit(30), "BURNT_BERRY", "tree 30 is FRUITTREE_FUCHSIA_CITY") +eq(Apricorns.treeFruit(0), nil, "there is no tree 0") +eq(Apricorns.treeFruit(31), nil, "and no tree 31") +-- The seven apricorn trees, which is why they live in this module. +eq(Apricorns.treeFruit(17), "RED_APRICORN", "Route 37's first tree") +eq(Apricorns.treeFruit(18), "BLU_APRICORN", "its second") +eq(Apricorns.treeFruit(19), "BLK_APRICORN", "its third") +eq(Apricorns.treeFruit(20), "WHT_APRICORN", "Azalea Town's") +eq(Apricorns.treeFruit(21), "PNK_APRICORN", "Route 42's first") +eq(Apricorns.treeFruit(22), "GRN_APRICORN", "its second") +eq(Apricorns.treeFruit(23), "YLW_APRICORN", "its third") +local apricornTrees = 0 +for _, item in ipairs(Apricorns.FRUIT_TREES) do + if Apricorns.isApricorn(item) then apricornTrees = apricornTrees + 1 end +end +eq(apricornTrees, 7, "seven of the thirty trees grow apricorns") + +save = { inventory = {}, events = {}, engineFlags = {} } +check(Apricorns.tryResetFruitTrees(save), "the first visit of the day refills") +check(not Apricorns.tryResetFruitTrees(save), "the second does not") +check(not Apricorns.treePicked(save, 20), "Azalea's tree has fruit") +eq(Apricorns.pickTree(save, 20), "WHT_APRICORN", "which is a white apricorn") +check(Apricorns.treePicked(save, 20), "and now it is picked") +eq(Apricorns.pickTree(save, 20), nil, "so it gives nothing twice") +eq(Apricorns.pickTree(save, 99), nil, "and a tree that does not exist gives nothing") +Apricorns.dailyReset(save) +check(Apricorns.tryResetFruitTrees(save), "the rollover lets them refill again") +check(not Apricorns.treePicked(save, 20), "and Azalea's tree bears fruit again") + +-------------------------------------------------------------------------- +-- The screen +-------------------------------------------------------------------------- +-- +-- Every Gold screen is reached through a src/ui/Screens.lua id, so ContestMenu +-- names the id it must be registered under and this asserts the registration +-- resolves to this module once src/ui/Screens.lua carries it. The check is +-- written to pass either way, because Screens.lua belongs to another file's +-- owner: what it must never do is resolve the id to something else. +local Screens = require("src.ui.Screens") +eq(BugContest.SCREEN_ID, "Gen2ContestMenu", "the screen id the module claims") +local registered = false +for _, id in ipairs(Screens.GEN2_IDS) do + if id == BugContest.SCREEN_ID then registered = true end +end +if registered then + eq(Screens.get({}, BugContest.SCREEN_ID), + require("src.ui.gen2.ContestMenu"), + "and the id resolves to src/ui/gen2/ContestMenu.lua") +else + check(true, "the id is not registered in src/ui/Screens.lua yet") +end + +local ContestMenu = require("src.ui.gen2.ContestMenu") +eq(ContestMenu.TEXT.stock, " STOCK <PK><MN> ", + "the STOCK label keeps its spaces, which is what erases the border under it") +eq(ContestMenu.TEXT.this, " THIS <PK><MN> ", "and so does THIS") +eq(ContestMenu.TEXT.askSwitch, "Switch #MON?", "_ContestAskSwitchText") + +-- The screen decides nothing: yes swaps the stock mon, no and B both keep it. +local function fakeInput(pressed) + return { wasPressed = function(_, key) return pressed == key end } +end + +save = { party = {}, boxes = {}, inventory = {}, events = {} } +BugContest.start(save, START) +BugContest.catch(save, mon({ species = "CATERPIE" })) +local _, stockMon, freshMon = BugContest.catch(save, mon({ species = "PINSIR" })) + +local answered +local screen = ContestMenu.new({ save = save }, { + save = save, stock = stockMon, caught = freshMon, + onClose = function(keptMon) answered = keptMon end, +}) +eq(screen.choice, 1, "PlaceYesNoBox opens on YES") +screen.game.input = fakeInput("b") +screen:update(0) +eq(answered.species, "CATERPIE", "B is the NO arm and keeps the stock mon") +eq(BugContest.caughtMon(save).species, "CATERPIE", "so the state is unchanged") + +answered = nil +screen = ContestMenu.new({ save = save }, { + save = save, stock = stockMon, caught = freshMon, + onClose = function(keptMon) answered = keptMon end, +}) +screen.game.input = fakeInput("a") +screen:update(0) +eq(answered.species, "PINSIR", "A on YES swaps the new mon in") +eq(BugContest.caughtMon(save).species, "PINSIR", "and the state follows") + +answered = nil +screen = ContestMenu.new({ save = save }, { + save = save, stock = freshMon, caught = stockMon, + onClose = function(keptMon) answered = keptMon end, +}) +screen.game.input = fakeInput("down") +screen:update(0) +eq(screen.choice, 2, "down moves the cursor to NO") +screen.game.input = fakeInput("a") +screen:update(0) +eq(answered.species, "PINSIR", "and A on NO keeps the stock mon") + +-- --------------------------------------------------------------------------- +-- The call sites (the half that was missing) +-- --------------------------------------------------------------------------- +-- +-- Every rule above was already ported and tested, and NOTHING started a +-- contest: BugContestJudging was a stub and no wild roll, no clock and no park +-- ball ever reached this module. This section drives the six specials the gate +-- scripts call, and then reads the two files whose call sites cannot be +-- constructed headless (World needs a map and a stack, BattleState needs love) +-- and asserts the wiring is spelled out in them. + +local Specials = require("src.script.gen2.Specials") + +-- A Vm as far as a special can tell: the hook table, wScriptVar, and the two +-- methods a handler may call to talk to the player. `events` and +-- `eventTables` are on the VM itself rather than in the hook table, which is +-- where Vm.new puts them and therefore where a handler has to look; the +-- rebuild counter stands in for World's deferred reloadSprites. +local function fakeVm(record, sfx) + local vm = { scriptVar = 0, texts = {}, rebuilds = 0 } + vm.events = Events.new() + vm.eventTables = {} + vm.onFlagsChanged = function() vm.rebuilds = vm.rebuilds + 1 end + vm.specials = { + save = function() return record end, + party = function() return record.party end, + monName = function(species) return species end, + playSfxNamed = function(name) sfx[#sfx + 1] = name end, + } + function vm:showRaw(body) self.texts[#self.texts + 1] = body end + function vm:setStringBuffer(value) self.stringBuffer = value end + return vm +end + +-- CheckPartyFullAfterContest asks GiveANickname_YesNo, so it yields the way +-- every prompting special does: run it on a coroutine and feed the answer back. +-- `yes` is what the YES/NO box returns; `typed` is what the keyboard hands over +-- (nil means the player backed out of it). +local function runContestCollect(vm, record, yes, typed) + vm.specials.renameMon = function(_mon, done) done(typed) end + vm.co = coroutine.create(function() + Specials.HANDLERS.CheckPartyFullAfterContest(vm) + end) + local ok, req = coroutine.resume(vm.co) + while ok and coroutine.status(vm.co) ~= "dead" do + local answer = nil + if req and req.kind == "yesorno" then answer = yes end + ok, req = coroutine.resume(vm.co, answer) + end + if not ok then error(req) end +end + +check(Specials.STUBS.BugContestJudging == nil, + "BugContestJudging is no longer a stub") +eq(type(Specials.HANDLERS.BugContestJudging), "function", + "it is a handler now") +for _, name in ipairs({ "ContestDropOffMons", "ContestReturnMons", + "GiveParkBalls", "CheckPartyFullAfterContest", + "SelectRandomBugContestContestants" }) do + eq(type(Specials.HANDLERS[name]), "function", name .. " is ported") +end + +do + local sfx = {} + local record = { + player = { name = "GOLD" }, + party = { mon({ species = "CHIKORITA" }), mon({ species = "MAGIKARP" }), + mon({ species = "SHUCKLE" }) }, + boxes = {}, inventory = {}, events = {}, + } + local vm = fakeVm(record, sfx) + + -- Route35NationalParkGate_OkayToProceed, in its order. + Specials.HANDLERS.ContestDropOffMons(vm) + eq(vm.scriptVar, 0, "a healthy lead mon is accepted") + eq(#record.party, 1, "and the tail is masked away") + eq(#(BugContest.state(record).stash or {}), 2, + "onto the SAVE, so a reload mid-contest still has it") + + Specials.HANDLERS.GiveParkBalls(vm) + check(BugContest.isActive(record), "GiveParkBalls starts the contest") + eq(BugContest.ballsLeft(record), 20, "with twenty park balls") + eq(select(1, BugContest.timeLeft(record)), 20, "and twenty minutes") + + Specials.HANDLERS.SelectRandomBugContestContestants(vm) + local picked, slots = 0, 0 + for slot, set in pairs(BugContest.state(record).contestants or {}) do + if set == true then picked = picked + 1 end + if type(slot) == "number" and slot >= 1 and slot <= 10 then + slots = slots + 1 + end + end + eq(picked, 5, "five of the ten contestant flags are set") + eq(slots, 5, "and the table is keyed by SLOT, which is what judging reads") + + -- The pick reaches wEventFlags, which is the only thing that takes those + -- five sprites off NationalParkBugContest. Five hidden and five drawn: a + -- handler that only stored the pick on the save left all ten standing there. + local hidden, drawn = 0, 0 + for slot = 1, BugContest.NUM_CONTESTANTS do + local flag = BugContest.FLAGS[slot] + if vm.events:objectVisible(flag) then drawn = drawn + 1 + else hidden = hidden + 1 end + eq(vm.events:get(flag), + BugContest.state(record).contestants[slot] == true, + ("slot %d's flag matches its pick"):format(slot)) + end + eq(hidden, 5, "five contestants are flagged out of the park") + eq(drawn, 5, "and the other five are still drawn") + eq(vm.rebuilds, 1, "and the map is told once that the flags moved") + + -- The catch, as the battle screen makes it: held, not added. + BugContest.catch(record, mon({ species = "SCYTHER", maxHp = 255, + stats = { hp = 255, attack = 255, defense = 255, speed = 255, + specialAttack = 255, specialDefense = 255 } })) + eq(#record.party, 1, "a contest catch does not join the party") + eq(BugContest.ballsLeft(record), 19, "and it costs a park ball") + + -- BugContestResultsScript, in its order. + Specials.HANDLERS.BugContestJudging(vm) + eq(vm.scriptVar, 1, "an unbeatable score places FIRST") + eq(#vm.texts, 6, "three winners, each with its own score page") + check(vm.texts[5]:find("GOLD", 1, true) ~= nil, + "and the winner named on the first-place page is the player") + eq(sfx[#sfx], "Sfx_1stPlace", "the first-place jingle plays last") + + BugContest.stop(record) + check(not BugContest.isActive(record), "clearflag stops the clock") + Specials.HANDLERS.ContestReturnMons(vm) + eq(#record.party, 3, "the party comes back") + eq(record.party[2].species, "MAGIKARP", "in its old order") + + local textsBefore = #vm.texts + runContestCollect(vm, record, true, "BUZZ") + eq(vm.scriptVar, BugContest.CAUGHT_MON, + "BUGCONTEST_CAUGHT_MON is 0, and the gate's ifequal reads it") + eq(#record.party, 4, "the held mon joins the party last") + eq(record.party[4].species, "SCYTHER", "and it is the mon that was caught") + -- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm) is the last thing + -- the contest asks, and it asks on the party arm as well as the box one. + eq(#vm.texts, textsBefore + 1, "the nickname prompt is printed") + check(vm.texts[#vm.texts]:find("nickname", 1, true) ~= nil, + "and it is _CaughtAskNicknameText") + eq(vm.stringBuffer, "SCYTHER", + "GetPokemonName fills {STRBUF} with the species before the prompt") + eq(record.party[4].nickname, "BUZZ", "a typed name is kept") +end + +-- NO, and a keyboard the player backed out of, both leave the species name. +do + local record = { party = {}, boxes = {}, inventory = {}, events = {} } + local vm = fakeVm(record, {}) + BugContest.start(record) + BugContest.catch(record, mon({ species = "WEEDLE" })) + runContestCollect(vm, record, false, "IGNORED") + eq(record.party[1].nickname, nil, "answering NO skips the keyboard") + + local record2 = { party = {}, boxes = {}, inventory = {}, events = {} } + local vm2 = fakeVm(record2, {}) + BugContest.start(record2) + BugContest.catch(record2, mon({ species = "PARAS" })) + runContestCollect(vm2, record2, true, nil) + eq(record2.party[1].nickname, nil, + "and an empty entry keeps the species name, the way InitNickname does") +end + +-- A full party sends the catch to the box, which is the branch that prints +-- ContestResults_PartyFullText. +do + local record = { party = {}, boxes = {}, inventory = {}, events = {} } + for _ = 1, 6 do record.party[#record.party + 1] = mon() end + local vm = fakeVm(record, {}) + BugContest.start(record) + BugContest.catch(record, mon({ species = "PINSIR" })) + runContestCollect(vm, record, true, "PINCH") + eq(vm.scriptVar, BugContest.BOXED_MON, "a full party boxes it") + eq(#record.party, 6, "and the party is untouched") + local boxed = require("src.core.gen2.Boxes").box(record, record.currentBox or 1) + eq(boxed and boxed[#boxed] and boxed[#boxed].nickname, "PINCH", + "the box arm asks for a nickname too") + local texts = #vm.texts + runContestCollect(vm, record, true, "NOPE") + eq(vm.scriptVar, BugContest.NO_CATCH, + "and with nothing held the answer is BUGCONTEST_NO_CATCH") + eq(#vm.texts, texts, "which asks nothing") +end + +-- TryWildEncounter_BugContest reads CheckSuperTallGrassTile, which is the LONG +-- grass pair alone -- the ordinary tall grass takes the 20 percent rate. +local Permissions = require("src.world.gen2.Permissions") +check(Permissions.isSuperTallGrass(0x14), "COLL_LONG_GRASS is super tall") +check(Permissions.isSuperTallGrass(0x1c), "and so is its unused alias") +check(not Permissions.isSuperTallGrass(0x18), + "COLL_TALL_GRASS is not, so it rolls at 20 percent") +check(not Permissions.isSuperTallGrass(nil), "and no tile at all is not") + +-- ---- SelectApricornForKurt ------------------------------------------------ +-- +-- The special is the whole of Kurt's port that is not in maps/KurtsHouse.asm: +-- the menu, the item id it leaves in wScriptVar for the `ifequal BLU_APRICORN` +-- ladder, and the TossItem the script never does. +check(Specials.STUBS.SelectApricornForKurt == nil, + "SelectApricornForKurt is no longer a stub") +eq(type(Specials.HANDLERS.SelectApricornForKurt), "function", + "it is a handler now") + +-- The item indices Kurt's ladder compares against, out of the extracted +-- items.lua -- and they are the same six the extracted script's own `ifequal` +-- rows carry, in .Blu .Ylw .Grn .Wht .Blk .Pnk order. +local APRICORN_ITEM = { + RED_APRICORN = 85, BLU_APRICORN = 89, YLW_APRICORN = 92, GRN_APRICORN = 93, + WHT_APRICORN = 97, BLK_APRICORN = 99, PNK_APRICORN = 101, +} + +-- A Vm whose menu answers on the spot, which is the synchronous arm of +-- Specials.block; `pick` is the row the player lands on. +local function kurtVm(record, pick) + local vm = { scriptVar = 0xff, menus = {} } + vm.specials = { + save = function() return record end, + itemName = function(id) return (id:gsub("_APRICORN", " APRICORN")) end, + itemIndex = function(id) return APRICORN_ITEM[id] end, + scriptMenu = function(header, onChoose) + vm.menus[#vm.menus + 1] = header + onChoose(pick) + end, + } + return vm +end + +do + local record = { inventory = { PNK_APRICORN = 1, RED_APRICORN = 2, + GRN_APRICORN = 1 }, events = {}, engineFlags = {} } + local vm = kurtVm(record, 2) + Specials.HANDLERS.SelectApricornForKurt(vm) + local header = vm.menus[1] + check(header ~= nil, "the menu opens") + eq(#header.items, 4, "three apricorns and the CANCEL row") + eq(header.items[1], "RED APRICORN", "in ApricornBalls order, not pack order") + eq(header.items[2], "GRN APRICORN", "green second") + eq(header.items[4], "CANCEL", "and CANCEL last, the way .Name draws it") + eq(header.right, 14, "menu_coords 0, 0, 14, 17") + eq(header.bottom, 17, "bottom included") + eq(vm.scriptVar, 93, "wScriptVar is GRN_APRICORN's ITEM id, not its row") + eq(record.inventory.GRN_APRICORN, nil, "TossItem takes the one it had") + eq(record.inventory.RED_APRICORN, 2, "and leaves the rest of the pack alone") + + -- `jr c, .nope`: B, or the CANCEL row, is FALSE and costs nothing. + vm = kurtVm(record, 0) + Specials.HANDLERS.SelectApricornForKurt(vm) + eq(vm.scriptVar, 0, "backing out is FALSE") + eq(record.inventory.RED_APRICORN, 2, "and no apricorn is taken") + + vm = kurtVm(record, 3) + Specials.HANDLERS.SelectApricornForKurt(vm) + eq(vm.scriptVar, 0, "so is the CANCEL row itself") + eq(record.inventory.PNK_APRICORN, 1, "with the pink one still in the pack") + + -- FindApricornsInBag's `scf`: no menu at all, and the script's `ifequal + -- FALSE` sends Kurt to .Cancel. + local empty = { inventory = { BERRY = 5 }, events = {}, engineFlags = {} } + vm = kurtVm(empty, 1) + Specials.HANDLERS.SelectApricornForKurt(vm) + eq(vm.scriptVar, 0, "an apricorn-free pack never opens the menu") + eq(#vm.menus, 0, "no menu was asked for") + eq(empty.inventory.BERRY, 5, "and nothing was tossed") + + -- No item table to resolve against is not a licence to guess: an answer the + -- ladder cannot match would fall through to .Red. + local bag = { inventory = { BLU_APRICORN = 1 }, events = {}, + engineFlags = {} } + vm = kurtVm(bag, 1) + vm.specials.itemIndex = function() return nil end + Specials.HANDLERS.SelectApricornForKurt(vm) + eq(vm.scriptVar, 0, "an unresolvable item is FALSE") + eq(bag.inventory.BLU_APRICORN, 1, "and the apricorn stays in the pack") +end + +-- ---- the fruit trees ------------------------------------------------------ +-- +-- FruitTreeItems is thirty entries with seven apricorns in it, and the ids are +-- 1-based (`const_def 1`). +eq(Apricorns.NUM_FRUIT_TREES, 30, "thirty fruit trees") +eq(Apricorns.treeFruit(1), "BERRY", "FRUITTREE_ROUTE_29 is a BERRY") +eq(Apricorns.treeFruit(0x11), "RED_APRICORN", + "FRUITTREE_ROUTE_37_1 is the RED APRICORN") +eq(Apricorns.treeFruit(0x17), "YLW_APRICORN", + "FRUITTREE_ROUTE_42_3 is the YELLOW one") + +do + local record = { inventory = {}, events = {}, engineFlags = {} } + check(Apricorns.tryResetFruitTrees(record), + "the first tree examined refills them all") + check(not Apricorns.tryResetFruitTrees(record), + "and ENGINE_ALL_FRUIT_TREES stops it happening twice in a day") + eq(Apricorns.pickTree(record, 0x11), "RED_APRICORN", "picking gives fruit") + check(Apricorns.treePicked(record, 0x11), "which sets the tree's flag") + eq(Apricorns.pickTree(record, 0x11), nil, "so it cannot be picked again") + check(not Apricorns.treePicked(record, 0x12), "the next tree is untouched") + + Apricorns.startDailyResetTimer(record, { day = 3 }) + check(Apricorns.checkDailyResetTimer(record, { day = 4 }), + "the daily rollover comes") + check(Apricorns.tryResetFruitTrees(record), + "and the next tree examined refills the lot") + check(not Apricorns.treePicked(record, 0x11), + "so route 37's red apricorn is back") +end + +-- The two call sites a headless test cannot construct. Reading the source is +-- the honest check here: World needs a map, a stack and a VM, and BattleState +-- needs love -- but a call site that is not spelled out in the file is not +-- there at all, which is exactly the failure this whole item is about. +local function sourceOf(path) + local f = io.open(path, "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +do + local world = sourceOf("src/world/gen2/World.lua") + check(world ~= nil, "World's source is readable") + for _, wanted in ipairs({ + "function World:tryContestEncounter", + "self:tryContestEncounter(collision)", + "function World:checkTimeEvents", + "self:checkTimeEvents()", + "function World:bugContestOver", + "function World:bugContestBattleOver", + "self:bugContestBattleOver()", + "BugContestResultsWarpScript", + "BugContest.ENGINE_BUG_CONTEST_TIMER", + -- Kurt's menu and the item pair the ladder needs. + "scriptMenu = function(header, onChoose)", + "itemIndex = function(id)", + -- FruitTreeScript's four hooks, all four now over Apricorns. + "function World:fruitTreeItem", + "Apricorns.treeFruit(treeId)", + "function World:fruitTreeReset", + "Apricorns.tryResetFruitTrees(save)", + "Apricorns.treePicked(save, treeId)", + "Apricorns.pickTree(save, treeId)", + "fruitTreeReset = function() return self:fruitTreeReset() end,", + "Apricorns.checkDailyResetTimer(save)", + }) do + check(world:find(wanted, 1, true) ~= nil, "World has " .. wanted) + end + + -- TryResetFruitTrees runs inside the VM's transcribed FruitTreeScript, above + -- the CheckFruitTree it gates. + local vmSource = sourceOf("src/script/gen2/Vm.lua") + check(vmSource ~= nil, "the VM's source is readable") + for _, wanted in ipairs({ + "fruitTreeResetFn = hooks.fruitTreeReset,", + "if self.fruitTreeResetFn then self.fruitTreeResetFn() end", + }) do + check(vmSource:find(wanted, 1, true) ~= nil, "the VM has " .. wanted) + end + + local battle = sourceOf("src/ui/gen2/BattleState.lua") + check(battle ~= nil, "BattleState's source is readable") + for _, wanted in ipairs({ + "function BattleState:throwParkBall", + "self:throwParkBall()", + "function BattleState:contestCatch", + "self:contestCatch(enemy)", + "BugContest.useBall(save)", + "BugContest.isOver(save)", + '"Gen2ContestMenu"', + }) do + check(battle:find(wanted, 1, true) ~= nil, "BattleState has " .. wanted) + end +end + +-- ---- the cache against the transcriptions --------------------------------- +-- +-- The same two tables by two routes: BugContest.MONS and BugContest.FLAGS were +-- transcribed from pokegold, the rows below were followed out of the ROM. Any +-- drift means one of them is wrong, which is the same pin +-- tests/gen2_world_test.lua holds CmdQueue.STONE_TABLES to. +do + 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 function loadLua(rel) + local path = cache .. "/" .. rel + local chunk = loadfile(path) + return chunk and chunk() or nil + end + + local encounters = loadLua("data/generated/encounters.lua") + if not encounters then + check(true, "gold cache absent : transcriptions only (SKIP the pins)") + else + local extracted = encounters.bugContest + if type(extracted) ~= "table" or #extracted == 0 then + check(true, "cache predates ContestMons extraction (SKIP)") + else + eq(#extracted, #BugContest.MONS, "the same eleven rows") + for index, hand in ipairs(BugContest.MONS) do + local row = extracted[index] or {} + eq(row.species, hand.species, ("row %d species"):format(index)) + eq(row.chance, hand.chance, ("row %d chance"):format(index)) + eq(row.min, hand.min, ("row %d min level"):format(index)) + eq(row.max, hand.max, ("row %d max level"):format(index)) + end + -- And the reader really does take the cache over the transcription. + eq(BugContest.contestMons({ encounters = encounters }), extracted, + "contestMons prefers the extracted table") + -- Rolled through the cache rather than the transcription, the slices + -- still land where ChooseWildEncounter_BugContest puts them. + local rolled = BugContest.chooseWild({ encounters = encounters }, + seq(180, 1)) + eq(rolled.species, "SCYTHER", "roll 90 off the cache is still SCYTHER") + eq(rolled.level, 14, "at level 14") + end + end + + local eventTables = loadLua("data/generated/events.lua") + local flags = eventTables and eventTables.bugContestFlags + if type(flags) ~= "table" or #flags == 0 then + check(true, "cache predates bug_contest_flags extraction (SKIP)") + else + eq(#flags, BugContest.NUM_CONTESTANTS, "ten contestant flags") + for slot, hand in ipairs(BugContest.FLAGS) do + eq(flags[slot], hand, ("slot %d flag number"):format(slot)) + end + eq(BugContest.contestantFlags(eventTables), flags, + "contestantFlags prefers the extracted table") + + -- The strongest form of the pin: the table's ten words ARE the ten + -- object_event flags on NationalParkBugContest, in map order. A stride or + -- an endianness that was off by anything would miss this. + local maps = loadLua("data/generated/maps.lua") + local park = maps and maps.NATIONAL_PARK_BUG_CONTEST + if not park then + check(true, "cache has no NATIONAL_PARK_BUG_CONTEST (SKIP)") + else + for slot = 1, BugContest.NUM_CONTESTANTS do + local object = (park.objects or {})[slot] or {} + eq(object.eventFlag, flags[slot], + ("contest object %d answers to slot %d's flag"):format(slot, slot)) + end + end + end +end + +S.finish() diff --git a/tests/gen2_currents_test.lua b/tests/gen2_currents_test.lua new file mode 100644 index 00000000..3dfbdf82 --- /dev/null +++ b/tests/gen2_currents_test.lua @@ -0,0 +1,171 @@ +-- Current tiles: DoPlayerMovement's .CheckTile, HI_NYBBLE_CURRENT arm. +-- +-- luajit tests/gen2_currents_test.lua +-- +-- $30-$3f are CURRENT tiles (engine/overworld/player_movement.asm): the low two +-- bits index .water_table, so COLL_WATERFALL $33 and COLL_CURRENT_DOWN $3b both +-- force DOWN. The arm runs ABOVE .CheckTurning and .TryStep and returns +-- PLAYERMOVEMENT_CONTINUE, so it overrides the d-pad outright rather than being +-- refused by it. +-- +-- The port mapped the whole $3x block to plain WATER and had no current arm at +-- all, which cost two things: the plunge down a waterfall needed the player to +-- hold DOWN (and never animated as a plunge), and a surfing player could walk +-- UP a waterfall column with the d-pad -- silently bypassing the HM07 + +-- ENGINE_RISINGBADGE gate at Tohjo Falls and Whirl Islands. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 currents") +local check, eq = S.check, S.eq + +local Permissions = require("src.world.gen2.Permissions") +local World = require("src.world.gen2.World") +local Player = require("src.world.gen2.Player") +local FieldMoves = require("src.world.gen2.FieldMoves") + +-- ---- the table ------------------------------------------------------------- +-- +-- `and $f0 / cp HI_NYBBLE_CURRENT` then `maskbits NUM_DIRECTIONS`. +eq(Permissions.currentDirection(0x30), "right", "COLL_WATERFALL_RIGHT") +eq(Permissions.currentDirection(0x31), "left", "COLL_WATERFALL_LEFT") +eq(Permissions.currentDirection(0x32), "up", "COLL_WATERFALL_UP") +eq(Permissions.currentDirection(0x33), "down", "COLL_WATERFALL is DOWN") +eq(Permissions.currentDirection(0x38), "right", "COLL_CURRENT_RIGHT") +eq(Permissions.currentDirection(0x39), "left", "COLL_CURRENT_LEFT") +eq(Permissions.currentDirection(0x3a), "up", "COLL_CURRENT_UP") +eq(Permissions.currentDirection(0x3b), "down", "COLL_CURRENT_DOWN is DOWN too") +-- The nybble is what is tested, not the four named constants, so the garbage +-- rows in between behave the same way the cart's would. +eq(Permissions.currentDirection(0x36), "up", "COLL_36 rides the same nybble") +eq(Permissions.currentDirection(0x29), nil, "plain COLL_WATER forces nothing") +eq(Permissions.currentDirection(0x23), nil, "and neither does ice") +eq(Permissions.currentDirection(0x24), nil, + "COLL_WHIRLPOOL is tested above the nybble and takes FORCE_TURN instead") +eq(Permissions.currentDirection(nil), nil, "no tile, no current") +check(Permissions.isWater(0x33), "a waterfall tile is still WATER to surf on") +check(Permissions.isWaterfall(0x33) and Permissions.isWaterfall(0x3b), + "CheckWaterfallTile still pairs the two the climb loops on") + +-- ---- the step -------------------------------------------------------------- +-- +-- A column of $33 down the middle of a pond: (5,4) is the top of the fall, +-- (5,5) and (5,6) below it, plain water everywhere else. +local COLL_WATER, COLL_WATERFALL = 0x29, 0x33 +local FALLS = { + [4 * 100 + 5] = COLL_WATERFALL, + [5 * 100 + 5] = COLL_WATERFALL, + [6 * 100 + 5] = COLL_WATERFALL, +} + +local function currentWorld(px, py) + local game = { + data = { items = {}, moves = {}, pokemon = {} }, + save = { player = { name = "GOLD", badges = {} }, party = {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + local cells = {} + for y = 0, 9 do + for x = 0, 9 do cells[y * 100 + x] = COLL_WATER end + end + for key, value in pairs(FALLS) do cells[key] = value end + local map + map = { + id = "TOHJO_FALLS", + width = 5, height = 5, + def = { objects = {}, bgEvents = {}, environment = "CAVE", + tileset = "TILESET_CAVE", width = 5, height = 5 }, + cellCollision = function(_, x, y) return cells[y * 100 + x] or COLL_WATER end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkable = function(_, x, y) + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + } + world.map = map + world.maps = { TOHJO_FALLS = map.def } + world.player = Player.new(px, py, "up") + world.entities = { world.player } + world.npcs = {} + world.playerState = FieldMoves.PLAYER_SURF + world.encounters = {} + world.pollTimeOfDay = function() end + -- Nothing here draws, and nothing here is about the wild roll. + world.noWildEncounters = true + world.updatePeople = function() end + return world, game +end + +-- World:pollInput is the caller's job (Game2 does it once a fixed step); +-- `held` is the d-pad the arm is supposed to be overriding. +local function runSteps(world, frames, held) + for _ = 1, frames do + world.heldDir = held + world:step() + end +end + +-- A surfing player standing on the top of the fall is carried DOWN with no +-- input at all: `.CheckTile` picks the direction before .CheckTurning ever +-- looks at the d-pad. +do + local world = currentWorld(5, 4) + eq(world.heldDir, nil, "no direction is held") + runSteps(world, 64) + eq(world.player.cellX, 5, "the plunge stays in the column") + check(world.player.cellY > 4, + "and the current carried the player down it with no press") + check(world.player.cellY >= 7, + "past the last waterfall tile and out onto open water") +end + +-- The same tile refuses to be climbed: holding UP on a current tile is not a +-- bump, it is a DOWN step. +do + local world = currentWorld(5, 5) + runSteps(world, 48, "up") + check(world.player.cellY > 5, + "holding UP inside the column still moves the player DOWN") + eq(world.player.cellX, 5, "and never off the column") +end + +-- Which is what the HM07 gate at Tohjo Falls and Whirl Islands rests on: a +-- surfing player below the fall can step onto its bottom tile and is thrown +-- straight back off it, so the top is unreachable however long UP is held. +do + local world = currentWorld(5, 8) + local highest = world.player.cellY + for _ = 1, 300 do + world.heldDir = "up" + world:step() + if world.player.cellY < highest then highest = world.player.cellY end + end + check(highest >= 6, + "the d-pad never carries a surfing player above the fall's bottom tile") + check(world.player.cellY >= 6, "and it ends below it too") +end + +-- Off the column it is an ordinary surf step again, so the current is a +-- property of the TILE and not a mode the player gets stuck in. +do + local world = currentWorld(3, 6) + runSteps(world, 48, "up") + check(world.player.cellY < 6, "plain water still answers the d-pad") +end + +-- The scripted climb is untouched: it moves the player with Player:scriptStep +-- under World:busy, which returns above the input block this arm lives in. +do + local world = currentWorld(5, 7) + world.fieldMove = { phase = "waterfall" } + local before = world.player.cellY + world:waterfallStep() + runSteps(world, 20) + check(world.player.cellY < before, + "World:waterfallStep still climbs while the field move owns the world") +end + +S.finish() diff --git a/tests/gen2_decorations_test.lua b/tests/gen2_decorations_test.lua new file mode 100644 index 00000000..a94bcb56 --- /dev/null +++ b/tests/gen2_decorations_test.lua @@ -0,0 +1,315 @@ +-- Decorations: owning them, setting them up, and the two PLAYERS_HOUSE_2F map +-- callbacks that put the room together (engine/overworld/decorations.asm, with +-- data/decorations/attributes.asm and decorations.asm beside it). +-- +-- ROM-free: `luajit tests/gen2_decorations_test.lua`. The cache section at the +-- bottom SKIPs when no Gold cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 decorations") +local check, eq = S.check, S.eq + +local Decorations = require("src.core.gen2.Decorations") +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") + +-- constants/deco_constants.asm, the handful this file names. +local BEDS, DECO_FEATHERY_BED, DECO_PINK_BED = 1, 2, 3 +local DECO_RED_CARPET = 7 +local DECO_TOWN_MAP, DECO_PIKACHU_POSTER = 16, 17 +local DECO_FAMICOM = 21 +local DECO_BIG_SNORLAX_DOLL = 26 +local DOLLS, DECO_PIKACHU_DOLL, DECO_CLEFAIRY_DOLL = 29, 30, 32 +local DECO_GOLD_TROPHY_DOLL, DECO_SILVER_TROPHY_DOLL = 51, 52 + +-- ---- the attributes table ------------------------------------------------- +-- Its length is asserted in the asm as NUM_DECOS + NUM_DECO_CATEGORIES + 1 = +-- 45 + 7 + 1, and row 0 is the CANCEL row every category menu ends on. +do + local rows = 0 + for _ in pairs(Decorations.ATTRIBUTES) do rows = rows + 1 end + eq(rows, 53, "DecorationAttributes is 53 rows, row 0 included") + eq(Decorations.ATTRIBUTES[0].name, "CANCEL", "row 0 is CANCEL") + check(Decorations.ATTRIBUTES[0].action == nil, + "and DecoAction_nothing, so it does nothing at all") + eq(Decorations.ATTRIBUTES[BEDS].name, "PUT IT AWAY", + "the category row is the PUT IT AWAY one") + eq(Decorations.ATTRIBUTES[BEDS].action, "PUT_AWAY_BED", + "and it puts away that category's slot") +end + +-- ---- GetDecoName ---------------------------------------------------------- +do + eq(Decorations.name(DECO_FEATHERY_BED), "FEATHERY BED", "a bed appends BED") + eq(Decorations.name(DECO_RED_CARPET), "RED CARPET", "a carpet appends CARPET") + eq(Decorations.name(DECO_PIKACHU_DOLL), "PIKACHU DOLL", "a doll appends DOLL") + eq(Decorations.name(DECO_BIG_SNORLAX_DOLL), "BIG SNORLAX", + "a big doll PREPENDS BIG , it does not append DOLL") + eq(Decorations.name(DECO_PIKACHU_POSTER), "PIKACHU POSTER", + "a poster appends POSTER") + -- Both of these are DECO_PLANT rows precisely so that nothing is appended. + eq(Decorations.name(DECO_TOWN_MAP), "TOWN MAP", + "the TOWN MAP poster is a DecorationNames string, not a mon plus POSTER") + eq(Decorations.name(DECO_GOLD_TROPHY_DOLL), "GOLD TROPHY", + "and so is the trophy") +end + +-- ---- owning --------------------------------------------------------------- +do + local events = Events.new() + check(not Decorations.owns(events, DECO_PINK_BED), "a new game owns no beds") + Decorations.give(events, DECO_PINK_BED) + check(Decorations.owns(events, DECO_PINK_BED), "give sets the DECOATTR flag") + check(not Decorations.owns(events, DECO_FEATHERY_BED), + "and only that one -- the flags are one bit each") + + -- SetSpecificDecorationFlag names its decoration by DECOFLAG_*, which is a + -- DecorationIDs index and NOT the DECO_* the attributes table is keyed by. + eq(Decorations.idForFlag(Decorations.DECOFLAG_GOLD_TROPHY_DOLL), + DECO_GOLD_TROPHY_DOLL, "DECOFLAG_GOLD_TROPHY_DOLL is DECO_GOLD_TROPHY_DOLL") + eq(Decorations.idForFlag(Decorations.DECOFLAG_SILVER_TROPHY_DOLL), + DECO_SILVER_TROPHY_DOLL, "and the silver one likewise") + eq(Decorations.idForFlag(0), DECO_FEATHERY_BED, + "DECOFLAG_* is a const_def block, so flag 0 is the first bed") + + local boxed = Events.new() + Decorations.giveFlag(boxed, Decorations.DECOFLAG_SILVER_TROPHY_DOLL) + check(Decorations.owns(boxed, DECO_SILVER_TROPHY_DOLL), + "the NORMAL BOX's trophy arrives through the DECOFLAG path") +end + +-- ---- the category and item lists ------------------------------------------ +do + local events = Events.new() + eq(#Decorations.ownedCategories(events), 0, + "with nothing owned there is not a single category") + Decorations.give(events, DECO_PIKACHU_DOLL) + local cats = Decorations.ownedCategories(events) + eq(#cats, 1, "one doll shows one category") + eq(cats[1].label, "ORNAMENT", "and it is the ornament menu") + + local rows = Decorations.rows(events, cats[1]) + eq(#rows, 3, "the list is the doll, PUT IT AWAY and CANCEL") + eq(rows[1], DECO_PIKACHU_DOLL, "the owned decoration first") + eq(rows[2], DOLLS, "then the category's own row") + eq(rows[3], 0, "then row 0, which is CANCEL") + + -- The trophies live in the ornament list, past the twenty-one dolls. + local trophies = Events.new() + Decorations.give(trophies, DECO_GOLD_TROPHY_DOLL) + eq(Decorations.ownedCategories(trophies)[1].label, "ORNAMENT", + "a trophy is an ornament") +end + +-- ---- DoDecorationAction2 -------------------------------------------------- +do + local state = { } + local changed, pages = Decorations.apply(state, DECO_FEATHERY_BED) + check(changed, "setting up an empty slot changes the room") + eq(state.bed, DECO_FEATHERY_BED, "and fills wDecoBed") + eq(pages[1], "Set up the\nFEATHERY BED.", "SetUpTheDecoText") + + changed, pages = Decorations.apply(state, DECO_FEATHERY_BED) + check(not changed, ".alreadythere writes nothing") + eq(pages[1], "That's already set\nup.", "AlreadySetUpText") + + changed, pages = Decorations.apply(state, DECO_PINK_BED) + check(changed, "a second bed replaces the first") + eq(state.bed, DECO_PINK_BED, "the slot holds ONE thing") + eq(#pages, 2, "PutAwayAndSetUpText is two pages") + eq(pages[1], "Put away the\nFEATHERY BED", "the old one is named first") + eq(pages[2], "and set up the\nPINK BED.", "then the new one") + + -- The PUT IT AWAY row, whose action is the category's put-away half. + changed, pages = Decorations.apply(state, BEDS) + check(changed, "putting a bed away changes the room too") + eq(state.bed, 0, "and empties the slot") + eq(pages[1], "Put away the\nPINK BED.", + "the text names what WAS out, not the row that was picked") + + changed, pages = Decorations.apply(state, BEDS) + check(not changed, "an empty slot has nothing to put away") + eq(pages[1], "There's nothing to\nput away.", "NothingToPutAwayText") +end + +-- ---- the two ornament sides ----------------------------------------------- +do + local state = {} + local changed = Decorations.apply(state, DECO_PIKACHU_DOLL, nil) + check(not changed, "no side chosen is DecoAction_AskWhichSide's cancel") + eq(state.leftOrnament, nil, "and neither side is touched") + + Decorations.apply(state, DECO_PIKACHU_DOLL, "right") + eq(state.rightOrnament, DECO_PIKACHU_DOLL, "RIGHT SIDE fills wDecoRightOrnament") + Decorations.apply(state, DECO_CLEFAIRY_DOLL, "left") + eq(state.leftOrnament, DECO_CLEFAIRY_DOLL, "LEFT SIDE fills the other") + + -- .getwhichside: there is only one of each doll, so putting the Pikachu on + -- the left takes it off the right. + Decorations.apply(state, DECO_PIKACHU_DOLL, "left") + Decorations.clearOtherSide(state, DECO_PIKACHU_DOLL, "left") + eq(state.leftOrnament, DECO_PIKACHU_DOLL, "the doll moved to the left") + eq(state.rightOrnament, 0, "and is gone from the right") +end + +-- ---- ToggleDecorationsVisibility ------------------------------------------ +do + local state = {} + local rows = Decorations.visibility(state) + eq(#rows, 4, "four objects hang off the room: console, two dolls, big doll") + for _, row in ipairs(rows) do + check(row.hidden, "an empty slot SETS the object's flag, which hides it") + end + eq(rows[1].flag, 1857, "EVENT_PLAYERS_HOUSE_2F_CONSOLE") + eq(rows[4].flag, 1860, "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL") + eq(rows[1].sprite, 0, "and the slots are SPRITE_VARS-relative") + eq(rows[4].sprite, 3, "SPRITE_BIG_DOLL is the fourth") + + state.console = DECO_FAMICOM + state.bigDoll = DECO_BIG_SNORLAX_DOLL + rows = Decorations.visibility(state) + check(not rows[1].hidden, "a filled slot CLEARS the flag") + eq(rows[1].byte, 0x5c, "and writes SPRITE_FAMICOM into wVariableSprites") + eq(rows[4].byte, 0x33, "SPRITE_BIG_SNORLAX for the big doll") + check(rows[2].hidden, "the two ornaments are still empty") +end + +-- ---- ToggleMaptileDecorations --------------------------------------------- +do + local state = {} + eq(#Decorations.tiles(state), 0, + "SetDecorationTile's `and a / ret z`: an empty slot paints nothing") + check(not Decorations.posterVisible(state), + "and a bare wall is not readable") + + state.bed = DECO_FEATHERY_BED + state.poster = DECO_TOWN_MAP + state.carpet = DECO_RED_CARPET + local tiles = Decorations.tiles(state) + local function blockAt(x, y) + for _, tile in ipairs(tiles) do + if tile.x == x and tile.y == y then return tile.block end + end + return nil + end + -- The asm's coordinates are CELLS and PadCoords_de/GetBlockLocation halve + -- them, the same way `changeblock` does. + eq(blockAt(0, 2), 0x1b, "bed cell (0,4) is block (0,2)") + eq(blockAt(3, 0), 0x1f, "poster cell (6,0) is block (3,0)") + eq(blockAt(0, 0), 0x08, "carpet top-left") + eq(blockAt(0, 1), 0x09, "carpet bottom row is +1") + eq(blockAt(1, 1), 0x0a, "+2") + eq(blockAt(2, 1), 0x09, "and +1 again") + check(Decorations.posterVisible(state), "a poster on the wall is readable") +end + +-- ---- the state on the save ------------------------------------------------ +do + local save = {} + local state = Decorations.state(save) + -- InitDecorations, farcall'd from intro_menu.asm at New Game. + eq(state.bed, DECO_FEATHERY_BED, "a new game starts with the feathery bed") + eq(state.poster, DECO_TOWN_MAP, "and the TOWN MAP on the wall") + state.bed = DECO_PINK_BED + eq(Decorations.state(save).bed, DECO_PINK_BED, + "and the state lives on the save, not in the module") +end + +-- ---- describedecoration, through the VM ----------------------------------- +-- Script_describedecoration is a ScriptJump: the arm hands back a script and +-- the command never returns. Which script depends on what is standing there. +do + local scripts = { + ["09:718b"] = { { op = "writetext", text = "t:bare" }, { op = "end" } }, + ["09:7000"] = { { op = "writetext", text = "t:map" }, { op = "end" } }, + ["09:71a8"] = { { op = "writetext", text = "t:doll" }, { op = "end" } }, + } + local TEXTS = { + ["t:bare"] = "bare wall", ["t:map"] = "TOWN MAP", ["t:doll"] = "adorable", + } + local decorations = { + DECODESC_POSTER = { script = "09:718b", posters = { + { decoration = DECO_TOWN_MAP, script = "09:7000" }, + } }, + DECODESC_LEFT_DOLL = { script = "09:71a8" }, + } + local function run(descName, slotFn) + local shown = {} + scripts.generation = 2 + scripts["s:t"] = { { op = "describedecoration", args = { 0 }, + decorationName = descName } } + local vm = Vm.new(scripts, TEXTS, Events.new(), { + decorationSlot = slotFn, + eventTables = { decorations = decorations }, + showText = function(text, done) shown[#shown + 1] = text; done() end, + }) + vm:start("s:t") + return shown, vm + end + + local shown = run("DECODESC_POSTER", function() return 0, nil end) + eq(shown[1], "bare wall", + "no poster placed falls to DecorationDesc_NullPoster") + + shown = run("DECODESC_POSTER", function() return DECO_TOWN_MAP, nil end) + eq(shown[1], "TOWN MAP", + "IsInArray finds the placed poster and jumps to ITS script") + + local _, vm = run("DECODESC_LEFT_DOLL", function() + return DECO_PIKACHU_DOLL, "PIKACHU DOLL" + end) + eq(vm.stringBuffer, "PIKACHU DOLL", + "the ornament arm puts the name in wStringBuffer3 for the text to print") +end + +-- ---- the cache ------------------------------------------------------------ +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- (tests/run_tests.lua) still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + if not cache then return nil end + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local events = loadCache("events") +if not events then + check(true, "no GOLD_CACHE: the extracted arms and flags are not checked (SKIP)") +else + -- Every DECODESC_* the cache knows has a slot here, or describedecoration + -- would read a wDeco* byte that does not exist. + for _, name in ipairs(events.decorationOrder or {}) do + check(Decorations.DESC_SLOTS[name] ~= nil, + ("%s names a wDeco* slot"):format(name)) + end + -- The poster arm's table is the four posters, and each of their DECO_* ids + -- is a real attributes row of the poster category. + local poster = (events.decorations or {}).DECODESC_POSTER + eq(#((poster and poster.posters) or {}), 4, "four posters can hang there") + for _, row in ipairs((poster and poster.posters) or {}) do + local attr = Decorations.attributes(row.decoration) + check(attr ~= nil and attr.action == "SET_UP_POSTER", + ("poster %d is a poster row here too"):format(row.decoration)) + end +end + +local initial = loadCache("initial_events") +if not initial then + check(true, "no GOLD_CACHE: InitializeEventsScript's flags are not checked (SKIP)") +else + -- PlayersHouse2FInitializeRoomCallback ends in `jumpstd InitializeEventsScript`, + -- and that script is where the room's four objects are hidden and the two + -- decorations the player starts with are marked owned. + local set = {} + for _, flag in ipairs(initial.flags or {}) do set[flag] = true end + for _, row in ipairs(Decorations.OBJECT_SLOTS) do + check(set[row.flag], ("object flag %d starts SET, i.e. hidden"):format(row.flag)) + end + check(set[Decorations.attributes(DECO_FEATHERY_BED).flag], + "and the feathery bed starts owned") + check(set[Decorations.attributes(DECO_TOWN_MAP).flag], + "as does the TOWN MAP poster") +end + +S.finish() diff --git a/tests/gen2_dig_warp_test.lua b/tests/gen2_dig_warp_test.lua new file mode 100644 index 00000000..0bf86568 --- /dev/null +++ b/tests/gen2_dig_warp_test.lua @@ -0,0 +1,196 @@ +-- ESCAPE ROPE and DIG have to come out of the door that was walked in, not the +-- last door that happened to bank a triple. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_dig_warp_test.lua +-- +-- home/map.asm EnterMapWarp calls `.SaveDigWarp` on EVERY warp: if the map +-- being left is outdoor (CheckOutdoorMap -- ROUTE or TOWN) and the one being +-- entered is indoor (CheckIndoorMap -- INDOOR, CAVE, DUNGEON or GATE), the +-- warp stepped on and the map left go into wDigWarpNumber / wDigMapGroup / +-- wDigMapNumber, and EscapeRopeOrDig's `.CheckCanDig` reads them straight back. +-- MOUNT_MOON_SQUARE and TIN_TOWER_ROOF are the routine's own exceptions: +-- outdoor maps sitting inside indoor ones, which the rope must never land on. +-- +-- The port had only the -1 writer (the POKECENTER_2F / elevator contract in +-- tests/gen2_pokecenter_stairs_test.lua), so a cave entered by an ordinary +-- door banked nothing and the rope paid out to whatever was banked last. DARK +-- CAVE has a Route 31 mouth and a Route 46 mouth, which is exactly the case +-- that tells the two apart. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 dig warp") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") + +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") + +-- The map data the whole rule reads: the environments the two Check routines +-- compare against, straight out of the extracted headers. +eq(maps.ROUTE_31.environment, "ROUTE", "Route 31 is an outdoor map") +eq(maps.DARK_CAVE_VIOLET_ENTRANCE.environment, "CAVE", + "and DARK CAVE's Violet side is a cave") +eq(maps.ROUTE_31.warps[3].destMap, "DARK_CAVE_VIOLET_ENTRANCE", + "Route 31's third warp is the cave mouth") +eq(maps.ROUTE_46.warps[3].destMap, "DARK_CAVE_VIOLET_ENTRANCE", + "and Route 46's third warp is the other one") + +-- A World over the real defs. setMap is recorded rather than run (baking a +-- map image needs a graphics device); everything around it -- takeWarp, +-- recordWarpBackup, escapeRopeTarget, runEscapeWarp -- is the shipped code. +local function world(mapId) + local game = { data = { audio = { sfxOrder = {} } }, save = { player = {} } } + local w = World.new(game) + w.maps, w.tilesets = maps, tilesets + w.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset]) + w.player = { cellX = 0, cellY = 0, facing = "up", moving = false } + w.loaded = nil + w.setMap = function(self, id, cx, cy, facing) + self.loaded = { id = id, x = cx, y = cy, facing = facing } + -- The stub stands in for the load, so the map the world believes it is + -- standing on has to move with it or the next take reads the old warps. + self.map = Map.new(maps[id], tilesets[maps[id].tileset]) + return true + end + return w +end + +-- MAPSETUP_DOOR fades out before the load, so the take is parked in +-- world.mapSetup; this drains it the way World:step does. +local function pump(w) + for _ = 1, 64 do + if not w.mapSetup then return end + w:updateMapSetup() + end +end + +local function walk(w, mapId, warpIndex) + local taken = w:takeWarp(maps[mapId].warps[warpIndex]) + pump(w) + return taken +end + +-- ---- the door actually used ---------------------------------------------- +do + local w = world("ROUTE_31") + check(walk(w, "ROUTE_31", 3), "into DARK CAVE off Route 31") + eq(w.loaded and w.loaded.id, "DARK_CAVE_VIOLET_ENTRANCE", "landing in the cave") + check(w.backupWarp ~= nil, "an outdoor-to-cave door banks the dig triple") + eq(w.backupWarp.map, "ROUTE_31", "naming the route outside") + eq(w.backupWarp.warp, 3, "and the mouth stepped on") + + -- EscapeRopeOrDig's .CheckCanDig, then the warp its script runs. + local destMapId, destWarp = w:escapeRopeTarget() + eq(destMapId, "ROUTE_31", "the rope resolves back to Route 31") + check(w:runEscapeWarp(destMapId, destWarp), "and the rope warp runs") + pump(w) + eq(w.loaded.id, "ROUTE_31", "landing on Route 31") + eq(w.loaded.x, maps.ROUTE_31.warps[3].x, "on the cave mouth's own x") + eq(w.loaded.y, maps.ROUTE_31.warps[3].y, "and y") +end + +-- The same cave, entered from the other side: the triple is re-banked on every +-- qualifying door, so the rope follows the player rather than the map. +do + local w = world("ROUTE_46") + check(walk(w, "ROUTE_46", 3), "into DARK CAVE off Route 46 instead") + eq(w.backupWarp.map, "ROUTE_46", "the banked map is Route 46 now") + eq(w.backupWarp.warp, 3, "with Route 46's own mouth") + local destMapId, destWarp = w:escapeRopeTarget() + eq(destMapId, "ROUTE_46", "so the rope comes out on Route 46") + eq(destWarp.x, maps.ROUTE_46.warps[3].x, "at that mouth's x") + eq(destWarp.y, maps.ROUTE_46.warps[3].y, "and y") +end + +-- ---- what CheckOutdoorMap / CheckIndoorMap refuse ------------------------- +do + -- Walking OUT of the cave is indoor-to-outdoor: `.SaveDigWarp` returns at + -- its first test and the banked triple is left standing. + local w = world("ROUTE_31") + check(walk(w, "ROUTE_31", 3), "in through the Route 31 mouth") + local banked = w.backupWarp + check(walk(w, "DARK_CAVE_VIOLET_ENTRANCE", 1), "and back out again") + eq(w.loaded.id, "ROUTE_31", "out on Route 31") + eq(w.backupWarp, banked, "leaving the dig triple exactly as it was") + + -- A GATE counts as indoor, so a route gate banks like any other door. + check(walk(w, "ROUTE_31", 1), "into the Violet gate") + eq(w.backupWarp.map, "ROUTE_31", "a GATE is CheckIndoorMap's fourth arm") + eq(w.backupWarp.warp, 1, "banking the gate door") + + -- Gate to town is outdoor-bound: nothing banks. + local townBanked = w.backupWarp + check(walk(w, "ROUTE_31_VIOLET_GATE", 1), "out of the gate into Violet") + eq(w.loaded.id, "VIOLET_CITY", "into Violet City") + eq(w.backupWarp, townBanked, "with the triple untouched") + + -- Town to building is the ordinary case, and it banks. + check(walk(w, "VIOLET_CITY", 5), "into the Violet Pokemon Center") + eq(w.backupWarp.map, "VIOLET_CITY", "banking the town outside") + eq(w.backupWarp.warp, 5, "and the centre's door") +end + +-- ---- the two maps the routine excludes ------------------------------------ +do + local w = world("ROUTE_31") + check(walk(w, "ROUTE_31", 3), "bank a real triple first") + local banked = w.backupWarp + -- MOUNT_MOON_SQUARE is a ROUTE map inside MOUNT_MOON, so the door out of it + -- passes both environment tests and is refused by name anyway. + eq(maps.MOUNT_MOON_SQUARE.environment, "ROUTE", + "MOUNT MOON SQUARE really is an outdoor map") + eq(maps.MOUNT_MOON.environment, "CAVE", "inside a cave") + w.map = Map.new(maps.MOUNT_MOON_SQUARE, + tilesets[maps.MOUNT_MOON_SQUARE.tileset]) + check(walk(w, "MOUNT_MOON_SQUARE", 1), "step off the square into the cave") + eq(w.loaded.id, "MOUNT_MOON", "into MOUNT MOON") + eq(w.backupWarp, banked, + "the square is excluded by name: no dig warp is banked there") + + -- TIN_TOWER_ROOF is the other one, and it is a ROUTE map inside a DUNGEON. + eq(maps.TIN_TOWER_ROOF.environment, "ROUTE", "so is the TIN TOWER roof") + eq(maps.TIN_TOWER_9F.environment, "DUNGEON", "over a dungeon floor") + w.map = Map.new(maps.TIN_TOWER_ROOF, tilesets[maps.TIN_TOWER_ROOF.tileset]) + check(walk(w, "TIN_TOWER_ROOF", 1), "down off the roof") + eq(w.loaded.id, "TIN_TOWER_9F", "onto 9F") + eq(w.backupWarp, banked, "and the roof banks nothing either") +end + +-- ---- the -1 contract is untouched ----------------------------------------- +do + -- The staircase writer still wins on its own arm: an INDOOR-to-INDOOR warp + -- banks nothing under the dig rule, and everything about POKECENTER_2F has + -- to keep working (tests/gen2_pokecenter_stairs_test.lua pins the rest). + local w = world("CHERRYGROVE_POKECENTER_1F") + check(walk(w, "CHERRYGROVE_POKECENTER_1F", 3), "up the centre's stairs") + eq(w.loaded.id, "POKECENTER_2F", "onto the shared 2F") + eq(w.backupWarp.map, "CHERRYGROVE_POKECENTER_1F", + "the -1 arrival banked the centre") + eq(w.backupWarp.warp, 3, "and its staircase") + + -- And a dig triple banked outdoors does not make the rope work in a room + -- that is not a cave: .CheckCanDig gates on the CURRENT map's environment. + local town = world("CHERRYGROVE_CITY") + check(walk(town, "CHERRYGROVE_CITY", 2), "into the Cherrygrove centre") + eq(town.backupWarp.map, "CHERRYGROVE_CITY", "banking the town door") + eq(town:escapeRopeTarget(), nil, + "but a rope indoors still refuses: the map is not a cave or a dungeon") +end + +S.finish() diff --git a/tests/gen2_diploma_test.lua b/tests/gen2_diploma_test.lua new file mode 100644 index 00000000..151d12d8 --- /dev/null +++ b/tests/gen2_diploma_test.lua @@ -0,0 +1,285 @@ +-- The #DEX diploma page (engine/events/diploma.asm PlaceDiplomaOnScreen). +-- GOLD_CACHE="..." luajit tests/gen2_diploma_test.lua +-- +-- The certificate is a tilemap, not a text box: DiplomaGFX decompresses into +-- vTiles2 and DiplomaPage1Tilemap is copied over the whole background before +-- any string is placed. So there are three things worth pinning here -- that +-- the extractor stage exists AND is called from run(), that what it wrote +-- matches ../pokegold/gfx/diploma/ byte for byte, and that the screen draws +-- that tilemap rather than laying the page out by eye. +-- +-- ROM-free. The decomp section SKIPs with no ../pokegold beside the repo and +-- the cache section SKIPs (or asks for a re-import) with no Gold cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 diploma") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local Diploma = require("src.ui.gen2.Diploma") +local Screens = require("src.ui.Screens") + +-- gfx/diploma/diploma.png is 16x7 tiles and page1.tilemap is one SCREEN_AREA. +local DIPLOMA_TILES = 112 +local DIPLOMA_SHEET_TILES = 16 +local SCREEN_W, SCREEN_H = 20, 18 +local SCREEN_AREA = SCREEN_W * SCREEN_H + +-- ---- the extractor stage -------------------------------------------------- +-- A stage nothing calls writes nothing, and the cache checks below would then +-- SKIP forever without anyone noticing, so the call site is read out of the +-- source rather than assumed. +local extractorSource +do + local f = assert(io.open("src/import/RomExtractorGen2.lua", "r")) + extractorSource = f:read("*a") + f:close() +end +local RomExtractorGen2 = require("src.import.RomExtractorGen2") +check(type(RomExtractorGen2.extractDiploma) == "function", + "RomExtractorGen2:extractDiploma exists") +check(extractorSource:find("results.diploma = self:extractDiploma()", 1, true) + ~= nil, "and RomExtractorGen2:run calls it") +check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil, + "STAGE_COUNT counts the new stage, so the progress bar still ends at 1") + +-- The three symbols the stage reads have to be in the curated manifest set or +-- self.symbols[label] is nil and the stage silently writes nothing. +do + local f = assert(io.open("tools/make_gold_manifest.py", "r")) + local manifestSource = f:read("*a") + f:close() + for _, label in ipairs({ "DiplomaGFX", "DiplomaPage1Tilemap", + "DiplomaPalettes" }) do + check(manifestSource:find('"' .. label .. '"', 1, true) ~= nil, + label .. " is in make_gold_manifest.py's REQUIRED_SYMBOLS") + end +end + +local Json = require("src.link.Json") +do + local f = io.open("tools/rom_manifest_gold.json", "r") + if not f then + check(true, "no tools/rom_manifest_gold.json (SKIP)") + else + local manifest = Json.decode(f:read("*a")) + f:close() + local symbols = manifest.symbols or {} + -- pokegold.sym: 38:4105 DiplomaGFX, 38:454b DiplomaPage1Tilemap, + -- 02:7a86 DiplomaPalettes. + local want = { + DiplomaGFX = { 0x38, 0x4105 }, + DiplomaPage1Tilemap = { 0x38, 0x454b }, + DiplomaPalettes = { 0x02, 0x7a86 }, + } + for label, location in pairs(want) do + local got = symbols[label] + if not got then + check(false, "the generated manifest carries " .. label) + else + eq(got[1], location[1], label .. " is in the bank pokegold.sym says") + eq(got[2], location[2], label .. " is at the address pokegold.sym says") + end + end + end +end + +-- ---- the decomp ----------------------------------------------------------- +local pokegold = "../pokegold" +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 decompTilemap = readFile(pokegold .. "/gfx/diploma/page1.tilemap") +if not decompTilemap then + check(true, "no ../pokegold: page1.tilemap is not pinned (SKIP)") +else + eq(#decompTilemap, SCREEN_AREA, + "gfx/diploma/page1.tilemap is exactly SCREEN_AREA bytes") + local highest = 0 + for i = 1, #decompTilemap do + local id = decompTilemap:byte(i) + if id > highest then highest = id end + end + check(highest < DIPLOMA_TILES, + "every id in it indexes DiplomaGFX's own 112 tiles, nothing past them") +end + +-- gfx/diploma/diploma.pal, colour by colour, so the extracted palette can be +-- checked against the source rather than against itself. DiplomaPalettes is +-- eight sets; only set 0 is reachable because _CGB_Diploma calls WipeAttrmap. +local DECOMP_PAL_SET0 = { { 27, 31, 27 }, { 21, 21, 21 }, { 13, 13, 13 }, + { 0, 0, 0 } } +local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end + +-- ---- the cache ------------------------------------------------------------ +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local diploma = loadCache("diploma") +if not diploma then + check(true, + "cache predates the Diploma stage : re-import for gfx/diploma (SKIP)") +else + eq(diploma.generation, 2, "diploma.lua is a Gen 2 table") + eq(diploma.tiles, DIPLOMA_TILES, "DiplomaGFX decompressed to 112 tiles") + eq(diploma.sheetTiles, DIPLOMA_SHEET_TILES, + "written as the 16-tile-wide sheet pret builds, so a tile id maps by /16") + eq(diploma.width, SCREEN_W, "the tilemap is SCREEN_WIDTH across") + eq(diploma.height, SCREEN_H, "and SCREEN_HEIGHT down") + eq(diploma.image, "assets/generated/diploma/diploma.png", + "the sheet went where the screen looks for it") + + local png = readFile(cache .. "/assets/generated/diploma/diploma.png") + if not png then + check(false, "the sheet PNG is actually in the cache") + else + -- PNG IHDR: width and height are big-endian at bytes 17 and 21. + 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(be32(png, 17), DIPLOMA_SHEET_TILES * 8, "the sheet is 128px wide") + eq(be32(png, 21), DIPLOMA_TILES / DIPLOMA_SHEET_TILES * 8, + "and 56px tall, which is the pret PNG's own shape") + end + + local page1 = diploma.page1 + if type(page1) ~= "table" then + check(false, "diploma.lua carries the page 1 tilemap") + else + eq(#page1, SCREEN_AREA, "the tilemap is one whole SCREEN_AREA") + if decompTilemap then + local mismatch + for i = 1, SCREEN_AREA do + if page1[i] ~= decompTilemap:byte(i) then mismatch = i break end + end + check(mismatch == nil, mismatch + and ("the extracted tilemap differs from the decomp at byte " + .. mismatch) + or "the extracted tilemap is ../pokegold/gfx/diploma/page1.tilemap" + .. " byte for byte") + else + check(true, "no ../pokegold: the tilemap is not diffed (SKIP)") + end + end + + local palettes = diploma.palettes + if type(palettes) ~= "table" then + check(false, "diploma.lua carries DiplomaPalettes") + else + eq(#palettes, 8, "all eight sets, because the block is one table") + local set0 = palettes[1] + for index, rgb in ipairs(DECOMP_PAL_SET0) do + local got = set0 and set0[index] or {} + eq(got[1], scale5(rgb[1]), + ("set 0 colour %d red matches gfx/diploma/diploma.pal"):format(index - 1)) + eq(got[2], scale5(rgb[2]), + ("set 0 colour %d green matches gfx/diploma/diploma.pal"):format(index - 1)) + eq(got[3], scale5(rgb[3]), + ("set 0 colour %d blue matches gfx/diploma/diploma.pal"):format(index - 1)) + end + end +end + +-- ---- the screen ----------------------------------------------------------- +local hasId = false +for _, id in ipairs(Screens.GEN2_IDS) do + if id == "Gen2Diploma" then hasId = true end +end +check(hasId, "the diploma is pushed through the Gen2Diploma Screens id") + +-- A tilemap standing in for page1: every cell holds its own index modulo the +-- sheet, which makes each quad's expected position arithmetic rather than a +-- lookup. +local fakePage1 = {} +for index = 0, SCREEN_AREA - 1 do + fakePage1[index + 1] = index % DIPLOMA_TILES +end +local fakeGfx = { + image = "assets/generated/diploma/diploma.png", + tiles = DIPLOMA_TILES, + sheetTiles = DIPLOMA_SHEET_TILES, + width = SCREEN_W, + height = SCREEN_H, + page1 = fakePage1, + palettes = { { { 222, 255, 222 }, { 173, 173, 173 }, { 107, 107, 107 }, + { 0, 0, 0 } } }, +} + +local screen = Diploma.new(nil, { playerName = "GOLD", gfx = fakeGfx }) +eq(screen.playerName, "GOLD", "the screen takes the player's name") +local palette = screen:palette() +check(palette ~= nil and palette[1][2] == 255, + "and draws through DiplomaPalettes set 0, the one WipeAttrmap leaves it on") + +local batch = screen:batch() +if not batch then + check(false, "the screen builds a sprite batch from the tilemap") +else + eq(#batch.sprites, SCREEN_AREA, + "one 8x8 sprite per cell, the whole background CopyBytes writes") + eq(screen:batch(), batch, "built once, not per frame") + -- Cell 0 is tile 0: sheet column 0, row 0, screen 0,0. + local first = batch.sprites[1] + eq(first[2], 0, "cell 0 lands at x 0") + eq(first[3], 0, "cell 0 lands at y 0") + eq(first[1].x, 0, "and reads sheet column 0") + eq(first[1].y, 0, "and sheet row 0") + -- Cell 21 is screen (1, 1) and, with this tilemap, tile 21: sheet column + -- 21 % 16 = 5, row 1. + local cell21 = batch.sprites[22] + eq(cell21[2], 8, "cell 21 lands one tile in") + eq(cell21[3], 8, "and one tile down") + eq(cell21[1].x, 5 * 8, "reading sheet column 5") + eq(cell21[1].y, 8, "of sheet row 1") +end + +-- The strings, at the literal hlcoord operands PlaceDiplomaOnScreen uses. +local placed = {} +local realPrintThrough = Chrome.printThrough +Chrome.printThrough = function(text, tx, ty, pal) + placed[#placed + 1] = { text = text, x = tx, y = ty, palette = pal } + return 0 +end +local okDraw, drawErr = pcall(function() screen:draw() end) +Chrome.printThrough = realPrintThrough +check(okDraw, "the screen draws: " .. tostring(drawErr)) +eq(#placed, 7, "PLAYER, the name and the five certification lines") +eq(placed[1].text, "PLAYER", "hlcoord 2, 5 is .Player") +eq(placed[1].x, 2, "at column 2") +eq(placed[1].y, 5, "on row 5") +eq(placed[2].text, "GOLD", "hlcoord 9, 5 is wPlayerName") +eq(placed[2].x, 9, "at column 9") +eq(placed[2].y, 5, "on the same row") +local CERTIFICATION = { "This certifies", "that you have", "completed the", + "new #DEX.", "Congratulations!" } +for index, line in ipairs(CERTIFICATION) do + local entry = placed[index + 2] + eq(entry.text, line, ".Certification line " .. index) + eq(entry.x, 2, "at column 2, where PlaceString restarts each `next`") + eq(entry.y, 7 + index, + "on row " .. (7 + index) .. ", one down from hlcoord 2, 8") +end +check(placed[1].palette ~= nil, + "and every string goes through the page's own palette, not a black print") + +-- A cache without the stage still opens the screen: the placeholder frame. +local bare = Diploma.new(nil, { playerName = "GOLD" }) +check(bare:batch() == nil, "no gen2Diploma in the cache means no tilemap") +local okBare = pcall(function() bare:draw() end) +check(okBare, "and the screen still draws its placeholder frame") + +S.finish() diff --git a/tests/gen2_egg_hatch_anim_test.lua b/tests/gen2_egg_hatch_anim_test.lua new file mode 100644 index 00000000..3e6864f5 --- /dev/null +++ b/tests/gen2_egg_hatch_anim_test.lua @@ -0,0 +1,194 @@ +-- The egg hatch cutscene's clock, sprite lifetimes and pic placement. +-- +-- luajit tests/gen2_egg_hatch_anim_test.lua +-- +-- Everything here is geometry or bookkeeping the ASM states outright, so it +-- runs with no cache and no window: EggHatchAnim only reaches love.graphics +-- from its draw path, and the stub below is enough for that. +-- +-- What it pins: +-- +-- * AnimSeq_RevealNewMon's `cp $80 / jr nc, .finish_EggShell` really does +-- retire the shell fragment (engine/sprite_anims/functions.asm:1270-1305). +-- They used to keep drawing, frozen at their last offset, for the ~113 +-- frames left in Hatch_ShellFragmentLoop. +-- * .OAMData_1x1_Palette0's own -4 on each axis is in the screen position +-- (data/sprite_anims/oam.asm:112-114). +-- * The pic sits where PadFrontpic put it (engine/gfx/load_pics.asm:342), +-- which for a 6-wide pic is a whole tile in, not half of one. +-- * hSCX and wGlobalAnimXOffset move the background and the objects the SAME +-- way during a wobble (engine/pokemon/breeding.asm:707-719), so a crack +-- does not slide across the shell it is sitting on. +-- * The EGG has a palette row of its own (data/pokemon/palettes.asm:530). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 egg hatch anim") +local check, eq = S.check, S.eq + +local EggHatchAnim = require("src.ui.gen2.EggHatchAnim") + +-------------------------------------------------------------------------- +-- A love.graphics that records instead of drawing. No newShader, so +-- GbcPalette.available() is false and the palette shader stays out of the way. +-------------------------------------------------------------------------- + +local draws = {} +local ox, oy = 0, 0 +local saved = {} +local function fakeImage(w, h) + return { + getWidth = function() return w end, + getHeight = function() return h end, + getDimensions = function() return w, h end, + } +end + +_G.love = { + graphics = { + setColor = function() end, + rectangle = function() end, + push = function() saved[#saved + 1] = { ox, oy } end, + pop = function() + local t = table.remove(saved) + ox, oy = t[1], t[2] + end, + translate = function(x, y) ox = ox + (x or 0) oy = oy + (y or 0) end, + scale = function() end, + newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + draw = function(_image, a, b, c) + if type(a) == "table" then + -- G.draw(image, quad, x, y, ...): a sprite object. + draws[#draws + 1] = { kind = "sprite", quad = a, x = ox + b, y = oy + c } + else + draws[#draws + 1] = { kind = "pic", x = ox + a, y = oy + b } + end + end, + }, +} + +local function render(anim) + draws = {} + anim:drawPanel() + local out = { pic = nil, sprites = {} } + for _, d in ipairs(draws) do + if d.kind == "pic" then out.pic = d + else out.sprites[#out.sprites + 1] = d end + end + return out +end + +-------------------------------------------------------------------------- +-- A game with a 5x5 egg pic, a 6x6 hatchling and the two-tile shell sheet. +-------------------------------------------------------------------------- + +local EGG_COLORS = { { 240, 208, 88 }, { 184, 128, 0 } } +local MON_COLORS = { { 100, 100, 100 }, { 40, 40, 40 } } + +local function newAnim() + local data = { + -- SENTRET's frontpic is 48px, the width the old centring rule got wrong. + pokemon = { SENTRET = { spriteFront = "sentret.png" } }, + gen2MenuGfx = { eggHatch = { + egg = "egg.png", shell = "shell.png", shellTiles = 2, + } }, + gen2Palettes = { pokemon = { + EGG = { normal = EGG_COLORS, shiny = EGG_COLORS }, + SENTRET = { normal = MON_COLORS, shiny = MON_COLORS }, + } }, + } + local anim = EggHatchAnim.new({ data = data }, { + mon = { species = "SENTRET", shiny = false }, + species = "SENTRET", + }) + anim.picCache["egg.png"] = fakeImage(40, 40) + anim.picCache["sentret.png"] = fakeImage(48, 48) + anim.picCache["shell.png"] = fakeImage(8, 16) + return anim +end + +-------------------------------------------------------------------------- +-- Sprite lifetimes +-------------------------------------------------------------------------- + +local anim = newAnim() + +-- Hatch_InitShellFragments lays ten `shell_fragment` rows, and `.done` has +-- already run ClearSprites over the cracks by then. +local frames, spawned, cleared = 0, nil, nil +while not anim.done and frames < 5000 do + anim:update(1 / 60) + frames = frames + 1 + if not spawned and #anim.sprites == 10 and anim.showMon then + spawned = frames + end + if spawned and not cleared and #anim.sprites == 0 then cleared = frames end +end + +eq(frames, 482, "the whole sequence is 482 frames of DelayFrames operands") +check(spawned ~= nil, "the ten shell fragments go up at `.done`") +check(cleared ~= nil, "and every one of them leaves the screen again") +-- var1 starts at 0 and gains 8 a frame; the seventeenth frame is the first to +-- see $80 at the top of AnimSeq_RevealNewMon and take .finish_EggShell. +eq(cleared and (cleared - spawned), 17, + "sixteen frames of flight, then DeinitializeSprite") +eq(#anim.sprites, 0, "and nothing is left drawing when the loop ends") + +-------------------------------------------------------------------------- +-- Screen positions +-------------------------------------------------------------------------- + +anim = newAnim() + +-- The egg at hlcoord 7, 4 with PadFrontpic's `.five` offset of (1, 2) tiles. +local frame = render(anim) +check(frame.pic ~= nil, "the egg pic draws while the shell is up") +eq(frame.pic and frame.pic.x, 7 * 8 + 8, "the 5-wide egg's x") +eq(frame.pic and frame.pic.y, 4 * 8 + 16, "the 5-wide egg's y") + +-- EggHatch_CrackShell's first surviving round: `ld e, 11 * TILE_WIDTH` and +-- `add 9 * TILE_WIDTH`, less the -8 / -16 OAM bias and the oamset's own -4. +anim:crackShell(2) +eq(#anim.sprites, 1, "round 2 is the first round to crack the shell") +frame = render(anim) +eq(#frame.sprites, 1, "and the crack draws") +eq(frame.sprites[1] and frame.sprites[1].x, 11 * 8 - 8 - 4, "the crack's x") +eq(frame.sprites[1] and frame.sprites[1].y, 9 * 8 - 16 - 4, "the crack's y") + +-- A wobble half moves BOTH layers two pixels left, so the crack keeps its +-- place on the shell instead of sliding across it. +local still = frame +anim.shakeX = 2 +local shaken = render(anim) +eq(shaken.pic.x - still.pic.x, -2, "hSCX = +2 slides the background left") +eq(shaken.sprites[1].x - still.sprites[1].x, -2, + "and wGlobalAnimXOffset = -2 takes the crack with it") +eq(shaken.pic.y, still.pic.y, "the wobble is horizontal only") +anim.shakeX = 0 + +-- The hatchling at hlcoord 6, 3 with `.six`'s offset of (1, 1) tiles: a whole +-- blank column, then one blank tile above each pic column. +anim.showMon = true +frame = render(anim) +eq(frame.pic and frame.pic.x, 6 * 8 + 8, "the 6-wide hatchling's x") +eq(frame.pic and frame.pic.y, 3 * 8 + 8, "the 6-wide hatchling's y") + +-------------------------------------------------------------------------- +-- Palettes +-------------------------------------------------------------------------- + +anim = newAnim() +local colors = anim:picColors() +check(colors ~= nil, "the egg has a palette of its own, not flat GB greys") +eq(colors and colors[2] and colors[2][1], EGG_COLORS[1][1], + "and it is the EGG row _CGB_Evolution loads for wPlayerHPPal = EGG") +anim.showMon = true +colors = anim:picColors() +eq(colors and colors[2] and colors[2][1], MON_COLORS[1][1], + "the hatchling brings its own once the pic swaps") + +-- A cache built before the extractor grew the EGG row still runs. +anim = newAnim() +anim.palettes.pokemon.EGG = nil +eq(anim:picColors(), nil, "an older cache simply draws in the raw shades") + +S.finish() diff --git a/tests/gen2_events_test.lua b/tests/gen2_events_test.lua new file mode 100644 index 00000000..7f6087e2 --- /dev/null +++ b/tests/gen2_events_test.lua @@ -0,0 +1,523 @@ +-- The side tables a script command NAMES rather than carries: the in-game +-- trades, the elevator floor lists, and the five decoration descriptions. +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_events_test.lua +-- +-- All three were unreachable until the extractor followed the pointer each +-- command holds, so this suite is in two halves: the model, which runs +-- ROM-free against fixtures transcribed from pokegold, and a cache-gated block +-- that asserts data/generated/events.lua really carries what those fixtures +-- claim. A re-import that reads a row wrong fails in the second half. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 events") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local NpcTrade = require("src.core.gen2.NpcTrade") +local ElevatorMenu = require("src.ui.gen2.ElevatorMenu") +local TradeMenu = require("src.ui.gen2.TradeMenu") +local TradeAnim = require("src.core.gen2.TradeAnim") +local TradeAnimView = require("src.ui.gen2.TradeAnim") +local Mon = require("src.battle.gen2.Mon") + +-- ---- fixtures ------------------------------------------------------------- +-- +-- data/events/npc_trades.asm row 0, the Violet City collector: +-- npctrade TRADE_DIALOGSET_COLLECTOR, DROWZEE, MACHOP, "MUSCLE", $37, $66, +-- GOLD_BERRY, 37460, "MIKE", TRADE_GENDER_EITHER +local MIKE = { + id = 0, dialog = "TRADE_DIALOGSET_COLLECTOR", + give = "DROWZEE", get = "MACHOP", nickname = "MUSCLE", + dvs = { 0x37, 0x66 }, item = 174, otId = 37460, otName = "MIKE", + gender = "TRADE_GENDER_EITHER", +} +-- Row 3 is the one trade that wants a particular gender. +local EMY = { + id = 3, dialog = "TRADE_DIALOGSET_NEWBIE", + give = "DRAGONAIR", get = "RHYDON", nickname = "DON", + dvs = { 0x77, 0x66 }, item = 0, otId = 283, otName = "EMY", + gender = "TRADE_GENDER_FEMALE", +} + +local GROWTH = { GROWTH_MEDIUM_FAST = + { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } +local POKEMON = { growthRates = GROWTH } +local function species(id, genderRatio) + POKEMON[id] = { + id = id, name = id, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + types = { "NORMAL", "NORMAL" }, + growthRate = "GROWTH_MEDIUM_FAST", + genderRatio = genderRatio or 127, + levelMoves = { { level = 1, move = "TACKLE" } }, + } + return POKEMON[id] +end +species("DROWZEE") +species("MACHOP") +species("DRAGONAIR") +species("RHYDON") +-- NPCTRADE_ITEM is an item id BYTE in the table, while a held item is a KEY of +-- data/generated/items.lua everywhere else in the port, so the fixture carries +-- the item table the byte is named through. +local ITEMS = { + GOLD_BERRY = { id = "GOLD_BERRY", index = 174, name = "GOLD BERRY", + heldEffect = "HELD_RESTORE_HP", heldParameter = 30 }, +} +local DATA = { pokemon = POKEMON, moves = {}, items = ITEMS } + +-- ---- the DV bytes --------------------------------------------------------- +-- +-- NPCTRADE_DVS is a `dw` of two RAW bytes, `dn attack, defense` then +-- `dn speed, special` -- not a number. Reading it as one would give MUSCLE a +-- different mon than every other player's. +do + local dvs = NpcTrade.dvs(MIKE) + eq(dvs.attack, 3, "$37 high nibble is the Attack DV") + eq(dvs.defense, 7, "and its low nibble the Defense DV") + eq(dvs.speed, 6, "$66 high nibble is Speed") + eq(dvs.special, 6, "and its low nibble Special") + -- Mon.hpDV is the low bit of each of the four, in that order. + eq(dvs.hp, Mon.hpDV(dvs), "the HP DV is derived, not stored") +end + +-- ---- CheckTradeGender ----------------------------------------------------- +-- +-- TRADE_GENDER_EITHER takes anything; the other two run GetGender on the mon +-- the player picked. A genderless species satisfies neither -- both arms of +-- CheckTradeGender fall to .not_matching. +do + check(NpcTrade.genderOk(MIKE, { gender = "male" }), "EITHER takes a male") + check(NpcTrade.genderOk(MIKE, { gender = "female" }), "and a female") + check(NpcTrade.genderOk(MIKE, { gender = "unknown" }), + "and a genderless mon") + check(NpcTrade.genderOk(EMY, { gender = "female" }), + "TRADE_GENDER_FEMALE takes a female") + check(not NpcTrade.genderOk(EMY, { gender = "male" }), "and refuses a male") + check(not NpcTrade.genderOk(EMY, { gender = "unknown" }), + "and refuses a genderless one") +end + +-- ---- the refusal ladder --------------------------------------------------- +-- +-- Species first, then gender, and BOTH are TRADE_DIALOG_WRONG: the NPC never +-- says which of the two was the problem. +do + eq(NpcTrade.check(MIKE, { species = "DROWZEE", gender = "male" }), nil, + "the right mon goes through") + eq(NpcTrade.check(MIKE, { species = "MACHOP", gender = "male" }), + NpcTrade.DIALOG_WRONG, "the wrong species is WRONG") + eq(NpcTrade.check(EMY, { species = "DRAGONAIR", gender = "male" }), + NpcTrade.DIALOG_WRONG, "and so is the wrong gender") + eq(NpcTrade.check(MIKE, nil), NpcTrade.DIALOG_CANCEL, + "backing out of the party list is CANCEL, not WRONG") +end + +-- ---- wTradeFlags ---------------------------------------------------------- +-- +-- Checked BEFORE the intro line, which is why a completed trade never asks +-- again -- it only says TRADE_DIALOG_AFTER. +do + local save = { party = {} } + check(not NpcTrade.done(save, 0), "a fresh save has traded nothing") + NpcTrade.markDone(save, 0) + check(NpcTrade.done(save, 0), "and the flag sticks") + check(not NpcTrade.done(save, 1), "one trade at a time") +end + +-- ---- DoNPCTrade ----------------------------------------------------------- +-- +-- The received mon keeps the LEVEL of the one handed over +-- (ComputeNPCTrademonStats runs on the new species' bases at that level) and +-- arrives wearing the row's nickname, DVs, held item, OT name and OT ID. +do + local save = { party = { + { species = "PIDGEY", level = 9 }, + { species = "DROWZEE", level = 22, gender = "male" }, + } } + local given, got = NpcTrade.perform(DATA, save, MIKE, 2) + eq(given.species, "DROWZEE", "the picked mon is the one handed over") + eq(#save.party, 2, "the party size does not change") + eq(save.party[2], got, "and the new mon lands in the last slot") + eq(got.species, "MACHOP", "it is NPCTRADE_GETMON") + eq(got.level, 22, "at the level of the mon given away") + eq(got.nickname, "MUSCLE", "wearing the row's nickname") + eq(got.otName, "MIKE", "and its OT name") + eq(got.otId, 37460, "and its OT ID, which the table stores unswapped") + -- The held item arrives NAMED, not as the table's raw byte: the mon wears it + -- like any other held item (DoNPCTrade writes it into wPartyMon1Item), and + -- every consumer in the port keys items.lua by id. This used to assert 174, + -- which crashed Bag.add the moment the player took the item off. + eq(got.item, "GOLD_BERRY", "and its held item, named") + eq(ITEMS[got.item].heldEffect, "HELD_RESTORE_HP", + "so the berry's held effect resolves") + eq(got.dvs.attack, 3, "with the row's DVs, not a roll") + -- A row whose item is 0 is NO_ITEM, which must not become a held item id 0. + local save2 = { party = { { species = "DRAGONAIR", level = 30, + gender = "female" } } } + local _, rhydon = NpcTrade.perform(DATA, save2, EMY, 1) + eq(rhydon.item, nil, "an item byte of 0 is no held item") + eq(#save2.party, 1, "removing then adding leaves the party the same size") + -- Taking the item off the traded mon puts it in the bag, which is where the + -- raw byte used to die: Bag.isBadge indexes the id as a string. + local Bag = require("src.inventory.Bag") + check(Bag.add({ inventory = {}, bagOrder = {} }, got.item, 1, DATA) == true, + "and the bag takes it when the player pulls it off") + -- A cache written before the extractor named the byte still imports: the + -- resolver falls back to the item table, then to constants.itemOrder. + eq(NpcTrade.item(DATA, { item = 174 }), "GOLD_BERRY", + "a raw byte is named through items.lua") + eq(NpcTrade.item({ constants = { itemOrder = { [174] = "GOLD_BERRY" } } }, + { item = 174 }), "GOLD_BERRY", "or through constants.itemOrder") + eq(NpcTrade.item(DATA, { item = "GOLD_BERRY" }), "GOLD_BERRY", + "and a cache that already names it passes straight through") + eq(NpcTrade.item(DATA, { item = 0 }), nil, "NO_ITEM stays no item") +end + +-- ---- the trade conversation ----------------------------------------------- +-- +-- GetTradeMonNames fills wStringBuffer1 with the mon you HAND OVER plus the +-- row's gender glyph, wStringBuffer2 with the mon you GET, and +-- wMonOrItemNameBuffer with the handed-over mon again without the glyph. All +-- three decode to the same {STRBUF}, so the buffer list recorded next to the +-- text is what tells them apart. +do + local body = "Do you have\v{STRBUF}?\fWant to trade it\nfor my {STRBUF}?" + local filled = TradeMenu.fill(body, MIKE, DATA, + { "wStringBuffer1", "wStringBuffer2" }) + check(filled:find("have\vDROWZEE", 1, true) ~= nil, + "wStringBuffer1 is the mon the NPC wants") + check(filled:find("for my MACHOP", 1, true) ~= nil, + "and wStringBuffer2 the mon it offers") + -- Swap the buffers and the two mons swap with them; a filler that ignored + -- the list would print the same string either way. + local swapped = TradeMenu.fill(body, MIKE, DATA, + { "wStringBuffer2", "wStringBuffer1" }) + check(swapped:find("have\vMACHOP", 1, true) ~= nil, + "the buffer list decides, not the order of appearance") + -- wStringBuffer1 carries the glyph; wMonOrItemNameBuffer is the same mon + -- without it. + eq(TradeMenu.fill("{STRBUF}", EMY, DATA, { "wStringBuffer1" }), + "DRAGONAIR\xe2\x99\x80", "TRADE_GENDER_FEMALE tags wStringBuffer1 with ♀") + eq(TradeMenu.fill("{STRBUF}", EMY, DATA, { "wMonOrItemNameBuffer" }), + "DRAGONAIR", "and wMonOrItemNameBuffer carries no glyph") + eq(TradeMenu.fill("{STRBUF}", MIKE, DATA, { "wStringBuffer1" }), "DROWZEE", + "TRADE_GENDER_EITHER adds nothing") + eq(TradeMenu.fill("{STRBUF}", MIKE, DATA, nil), "DROWZEE", + "and with no list at all the fallback is wStringBuffer1") + + -- `\f` starts a page, `\v` scrolls (the new page opens with the last line of + -- the previous one), `\n` is the page's second line. + local pages = TradeMenu.paginate("one\ntwo\fthree\nfour") + eq(#pages, 2, "a form feed is a page break") + eq(pages[1][1], "one", "page 1 line 1") + eq(pages[1][2], "two", "page 1 line 2") + eq(pages[2][1], "three", "page 2 line 1") + local scrolled = TradeMenu.paginate("one\ntwo\vthree") + eq(#scrolled, 2, "a scroll is also a page") + eq(scrolled[2][1], "two", "which opens on the previous last line") + eq(scrolled[2][2], "three", "with the new line under it") +end + +-- ---- the trade animation -------------------------------------------------- +-- +-- engine/movie/trade_animation.asm. The script is a flat list of waits, so +-- the whole 37 seconds can be walked without a window: the beats have to come +-- in the cart's order, the two scrolls have to close on the frame the ASM's +-- step size says, and the Game Boy pan has to cross the 256-pixel wrap exactly +-- once in each direction. +do + eq(TradeAnim.SCRIPT[1].id, "givemon_scroll", "it opens on the given mon") + eq(TradeAnim.SCRIPT[#TradeAnim.SCRIPT].id, "take_care", + "and closes on TAKE GOOD CARE") + + -- TradeAnim_DoGivemonScroll closes $88 at 4 a frame, so 34 frames. + eq(TradeAnim.givemonOffset(0), 0x88, "the panel starts a screen out") + eq(TradeAnim.givemonOffset(1), 0x84, "and closes 4 pixels a frame") + eq(TradeAnim.givemonOffset(34), 0, "landing home on the beat's last frame") + local scroll = TradeAnim.SCRIPT[1] + eq(scroll.frames, 34, "which is how long the beat is") + + -- EnterLinkTube2 and ExitLinkTube, the same $a0 in opposite directions. + eq(TradeAnim.tubeOffset("tube_in", 0), 0xa0, "the tube starts off screen") + eq(TradeAnim.tubeOffset("tube_in", 40), 0, "and is home after 40 frames") + eq(TradeAnim.tubeOffset("tube_out", 0), 0, "leaving, it starts home") + eq(TradeAnim.tubeOffset("tube_out", 40), 0xa0, "and is gone after 40") + + -- TubeToOT2/3/4 pan 2 a frame through $50 and $a0 to the wrap; the get + -- direction is TubeToPlayer3/4/5 running it back. + eq(TradeAnim.pan("send_pan_a", 0), 0, "the send pan starts on our Game Boy") + eq(TradeAnim.pan("send_pan_a", 40), 0x50, "hits $50 after 40 frames") + eq(TradeAnim.pan("send_pan_b", 40), 0xa0, "$a0 after 40 more") + eq(TradeAnim.pan("send_pan_c", 48), 0x100, "and wraps once, in 48") + eq(TradeAnim.pan("get_pan_a", 0), 0x100, "the get pan starts at the wrap") + eq(TradeAnim.pan("get_pan_c", 48), 0, "and comes all the way back") + eq(TradeAnim.pan("bulge", 0), nil, "a beat outside the pans has no position") + + -- TradeAnim_AnimateTrademonInTube: the icon waits out the pan on the cable, + -- then walks its two legs and is gone. + eq(select(1, TradeAnim.tubeIcon("send_pan_a", 0)), 80, "the icon starts at") + eq(select(2, TradeAnim.tubeIcon("send_pan_a", 0)), 28, + "TubeToOT1's own depixel, on the cable") + eq(select(1, TradeAnim.tubeIcon("send_wait", 60)), 140, + ".MoveRight's `cp $94` after 60 frames") + eq(select(2, TradeAnim.tubeIcon("send_wait", 92)), 60, + "and .MoveDown's `cp $4c` 32 later") + eq(TradeAnim.tubeIcon("send_hold", 0), nil, + ".done_move_down zeroes the struct index") + eq(select(2, TradeAnim.tubeIcon("get_wait", 32)), 28, ".MoveUp's `cp $2c`") + eq(select(1, TradeAnim.tubeIcon("get_wait", 92)), 80, + "then .MoveLeft's `cp $58`") + eq(select(2, TradeAnim.tubeIcon("get_pan_b", 20)), 28, + "so the get pan runs with it parked on the cable, not below it") + eq(TradeAnim.tubeIcon("get_hold", 0), nil, ".WaitTimer2 despawns it") + + -- The clock: beatAt walks the same list startOf indexes. + local first, offset = TradeAnim.beatAt(0) + eq(first.id, "givemon_scroll", "frame 0 is the first beat") + eq(offset, 0, "at its own frame 0") + local second = TradeAnim.beatAt(34) + eq(second.id, "givemon_hold", "frame 34 has already stepped past it") + local beat, into = TradeAnim.beatAt(TradeAnim.startOf("bulge") + 5) + eq(beat.id, "bulge", "startOf lands inside the beat it names") + eq(into, 5, "five frames in") + local last = TradeAnim.beatAt(TradeAnim.TOTAL + 100) + eq(last.id, "take_care", "overrunning holds the last picture") + check(TradeAnim.TOTAL > 2000, "the whole thing really is half a minute") + + -- DoNPCTrade's two records: the player's is the mon that LEFT, under the + -- player's own name, and the OT's is the row's, under the row's trainer. + local given = { species = "DROWZEE", level = 15, otName = "GOLD", + otId = 12345 } + local received = { species = "MACHOP", level = 15, otName = "MIKE", + otId = MIKE.otId } + local save = { player = { name = "SILVER", id = 999 } } + local give, get = TradeAnim.records(DATA, save, MIKE, given, received) + eq(give.species, "DROWZEE", "the player's trademon is the one handed over") + eq(give.senderName, "SILVER", "sent under the player's own name") + eq(give.otName, "GOLD", "keeping the OT it walked in with") + eq(give.id, 12345, "and that OT's id") + eq(get.species, "MACHOP", "the OT's trademon is the one received") + eq(get.senderName, "MIKE", "sent by the row's trainer") + eq(get.id, MIKE.otId, "with the row's id") + eq(given.species, "DROWZEE", "and neither record was mutated") + + -- The animation's lines name FOUR different buffers, and getting the order + -- wrong is the difference between "MACHOP was sent to MIKE" and a line that + -- says the player sent themselves. + local names = { + wPlayerTrademonSpeciesName = "DROWZEE", + wPlayerTrademonSenderName = "SILVER", + wOTTrademonSpeciesName = "MACHOP", + wOTTrademonSenderName = "MIKE", + } + eq(TradeAnimView.fill("{STRBUF} was sent to {STRBUF}.", names, + { "wPlayerTrademonSpeciesName", "wOTTrademonSenderName" }), + "DROWZEE was sent to MIKE.", "the sent line names mon then trainer") + eq(TradeAnimView.fill("For {STRBUF}'s {STRBUF},", names, + { "wPlayerTrademonSenderName", "wPlayerTrademonSpeciesName" }), + "For SILVER's DROWZEE,", "and the reply names them the other way round") + + -- The screen itself, with no cache behind it: the transcribed fallbacks have + -- to come out as the lines the cart prints. + local view = TradeAnimView.new({ data = DATA, save = save }, { + row = MIKE, given = given, received = received, save = save, + }) + local sent = view:lines("sent_text") + eq(sent[1], "DROWZEE was", "line 1 of the sent text") + eq(sent[2], "sent to MIKE.", "line 2") + eq(view:lines("farewell_a")[1], "MIKE bids", "the farewell opens on the OT") + eq(view:lines("farewell_b")[1], "MACHOP.", "and names the mon on the next") + eq(view:lines("take_care")[2], "MACHOP.", "TAKE GOOD CARE names it too") + eq(view:lines("sent_blank"), nil, "the empty page prints nothing at all") +end + +-- ---- the elevator --------------------------------------------------------- +-- +-- Elevator_MenuHeader is a four-row scrolling menu, so a seven-floor list +-- scrolls. The window follows the cursor and stops at both ends. +do + eq(ElevatorMenu.scrollFor(1, 7, 0), 0, "the window opens at the top") + eq(ElevatorMenu.scrollFor(4, 7, 0), 0, "and holds while the cursor is in it") + eq(ElevatorMenu.scrollFor(5, 7, 0), 1, "the fifth row scrolls it one") + eq(ElevatorMenu.scrollFor(7, 7, 1), 3, "the last row scrolls it to the end") + eq(ElevatorMenu.scrollFor(7, 7, 5), 3, "and never past it") + eq(ElevatorMenu.scrollFor(2, 7, 3), 1, "going back up drags it with you") + eq(ElevatorMenu.scrollFor(1, 3, 0), 0, "a list that fits never scrolls") + + -- FloorToString: the cache's ElevatorFloorNames, with the transcribed list + -- as the fallback for a cache that predates it. + local names = { "B4F", "B3F", "B2F", "B1F", "1F" } + eq(ElevatorMenu.floorName(names, 3), "B1F", "FLOOR_B1F is row 3") + eq(ElevatorMenu.floorName(nil, 4), "1F", "and FLOOR_1F row 4 without a cache") + eq(ElevatorMenu.floorName(nil, 15), "ROOF", "FLOOR_ROOF is the last row") +end + +-- ---- against the cache ---------------------------------------------------- + +local cacheDir = os.getenv("GOLD_CACHE") +if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" +end +local eventsFile = loadfile(cacheDir .. "/data/generated/events.lua") +if not eventsFile then + check(true, "cache absent or predates events.lua (SKIP)") + S.finish() + return +end +local events = eventsFile() +local scripts = assert(loadfile(cacheDir .. "/data/generated/scripts.lua"))() + +do + eq(#events.trades, NpcTrade.NUM_NPC_TRADES, "all six in-game trades") + local mike = NpcTrade.row(events, 0) + eq(mike.dialog, MIKE.dialog, "trade 0 dialog set") + eq(mike.give, MIKE.give, "trade 0 wants DROWZEE") + eq(mike.get, MIKE.get, "and offers MACHOP") + eq(mike.nickname, MIKE.nickname, "nicknamed MUSCLE") + eq(mike.otName, MIKE.otName, "OT MIKE") + eq(mike.otId, MIKE.otId, "OT ID 37460") + -- Asserted through the resolver rather than on the raw field, so the suite is + -- green whether this checkout's cache predates the extractor naming the byte + -- (174) or was re-imported after it ("GOLD_BERRY"). + local cacheItems = loadfile(cacheDir .. "/data/generated/items.lua") + eq(NpcTrade.item({ items = cacheItems and cacheItems() or ITEMS }, mike), + "GOLD_BERRY", "holding GOLD_BERRY") + eq(mike.dvs[1], MIKE.dvs[1], "DV byte 1") + eq(mike.dvs[2], MIKE.dvs[2], "DV byte 2") + eq(mike.gender, MIKE.gender, "and taking either gender") + local emy = NpcTrade.row(events, 3) + eq(emy.gender, EMY.gender, "trade 3 wants a female DRAGONAIR") + eq(emy.otId, EMY.otId, "and its OT ID is the four-digit 283") + + -- The 5x3 text table. Every cell must be a real string: an empty one means + -- PrintTradeText's dialog-major indexing was read column-major. + for _, dialog in ipairs({ NpcTrade.DIALOG_INTRO, NpcTrade.DIALOG_CANCEL, + NpcTrade.DIALOG_WRONG, NpcTrade.DIALOG_COMPLETE, + NpcTrade.DIALOG_AFTER }) do + for _, set in ipairs({ "TRADE_DIALOGSET_COLLECTOR", + "TRADE_DIALOGSET_HAPPY", "TRADE_DIALOGSET_NEWBIE" }) do + local body = events.tradeTexts[dialog] and events.tradeTexts[dialog][set] + check(type(body) == "string" and #body > 0, + ("%s / %s has a line"):format(dialog, set)) + check(not body:find("{BYTE:", 1, true), + ("%s / %s decoded cleanly"):format(dialog, set)) + end + end + check(events.tradeTexts.TRADE_DIALOG_INTRO.TRADE_DIALOGSET_COLLECTOR + :find("I collect", 1, true) ~= nil, "the collector's intro is his own") + check(events.tradeTexts.TradedForText:find("{PLAYER} traded", 1, true) ~= nil, + "TradedForText follows its far pointer") + -- text_far ends on TX_END ($50) rather than on `done`, so a decoder that + -- treats every $50 as a skippable `@` runs into the next string. + check(not events.tradeTexts.TradedForText:find("I collect", 1, true), + "and stops there rather than running into the next one") + + -- The buffer list next to each line. Two markers in one line are two + -- DIFFERENT buffers, and getting the pair backwards swaps the mons in the + -- intro -- the one line where a player would notice immediately. + local intro = events.tradeBuffers.TRADE_DIALOG_INTRO.TRADE_DIALOGSET_COLLECTOR + eq(#intro, 2, "the collector's intro splices two names") + eq(intro[1], "wStringBuffer1", "the mon he wants first") + eq(intro[2], "wStringBuffer2", "then the mon he offers") + eq(events.tradeBuffers.TRADE_DIALOG_AFTER.TRADE_DIALOGSET_COLLECTOR[1], + "wStringBuffer2", "and afterwards he asks about the one he gave you") + eq(events.tradeBuffers.TRADE_DIALOG_COMPLETE.TRADE_DIALOGSET_COLLECTOR[1], + "wStringBuffer1", "while the thank-you names the one he received") +end + +do + eq(#events.floorNames, 16, "all sixteen FLOOR_* labels") + eq(events.floorNames[4], "B1F", "FLOOR_B1F") + eq(events.floorNames[5], "1F", "FLOOR_1F") + eq(events.floorNames[16], "ROOF", "FLOOR_ROOF") + + -- The three elevators, found through their own scripts. Goldenrod's is the + -- seven-floor one; every row has to name a real map or the ride goes nowhere. + local maps = assert(loadfile(cacheDir .. "/data/generated/maps.lua"))() + local lists = {} + for key, cmds in pairs(scripts) do + if type(cmds) == "table" and key ~= "movements" then + for _, cmd in ipairs(cmds) do + if cmd.op == "elevator" and cmd.floors then + lists[#lists + 1] = cmd.floors + end + end + end + end + check(#lists >= 2, "at least the two dept-store elevators carry a list") + local biggest + for _, list in ipairs(lists) do + if not biggest or #list > #biggest then biggest = list end + for _, row in ipairs(list) do + check(row.destMap ~= nil and maps[row.destMap] ~= nil, + ("elevator row %s names a real map"):format(tostring(row.floor))) + check(row.destWarp and row.destWarp > 0, + "and a warp number inside it") + end + end + eq(#biggest, 7, "Goldenrod's runs B1F to 6F") + eq(biggest[1].floor, "FLOOR_B1F", "starting at B1F") + eq(biggest[1].destWarp, 2, "which is its warp 2") +end + +do + -- describedecoration's five arms. Each names a script that is really in + -- scripts.lua; the poster's is the bare `end` DecorationDesc_NullPoster, + -- which is what a room with nothing on the wall shows. + local decos = events.decorations or {} + for _, name in ipairs(events.decorationOrder or {}) do + local arm = decos[name] + check(type(arm) == "table", ("%s has an arm"):format(name)) + check(scripts[arm.script] ~= nil, + ("%s names a script in scripts.lua"):format(name)) + end + eq(scripts[decos.DECODESC_POSTER.script][1].op, "end", + "an empty poster wall says nothing") + -- The two ornaments and the console share one script; the giant ornament has + -- its own. Both are `jumptext`, so the text must have come along. + eq(decos.DECODESC_LEFT_DOLL.script, decos.DECODESC_RIGHT_DOLL.script, + "both small ornaments share .OrnamentConsoleScript") + eq(decos.DECODESC_CONSOLE.script, decos.DECODESC_LEFT_DOLL.script, + "and so does the console") + check(decos.DECODESC_BIG_DOLL.script ~= decos.DECODESC_LEFT_DOLL.script, + "the giant ornament has .BigDollScript to itself") + local text = assert(loadfile(cacheDir .. "/data/generated/text.lua"))() + local body = scripts[decos.DECODESC_BIG_DOLL.script][1] + eq(body.op, "jumptext", "which is a jumptext") + check((text[body.text] or ""):find("giant doll", 1, true) ~= nil, + "and its line came through the far pointer") +end + +-- ---- the held-item marker sheet rode out with the icons --------------------- +-- +-- GetIconGFX uploads HeldItemIcons as the two tiles straight after each icon's +-- eight (engine/gfx/mon_icons.asm:218-228), so the party list's marker lives or +-- dies on extractIcons writing this one row. The love-stubbed draw test can +-- only prove the blit against fixtures; nothing else would catch a real import +-- silently dropping the sheet. Gated like the trade item above: a cache built +-- before the extractor learned the symbol SKIPS rather than fails. +do + local iconsFile = loadfile(cacheDir .. "/data/generated/icons.lua") + local icons = iconsFile and iconsFile() + local entry = icons and icons.heldItem + if not entry then + check(true, "cache predates the HeldItemIcons row (SKIP)") + else + check(entry.image:find("^assets/generated/icons/gen2/") ~= nil, + "the marker sheet is written beside the party icons") + local file = io.open(cacheDir .. "/" .. entry.image, "rb") + check(file ~= nil, "and the file it names really exists") + if file then file:close() end + -- mail.2bpp then item.2bpp, the order they are INCBIN'd at + -- mon_icons.asm:230-232 and the order PartyMenu.heldMarkerRow indexes. + eq(entry.mailRow, 0, "mail is the top row") + eq(entry.itemRow, 1, "and a plain item the bottom one") + end +end + +S.finish() diff --git a/tests/gen2_evolution_anim_test.lua b/tests/gen2_evolution_anim_test.lua new file mode 100644 index 00000000..78de4a60 --- /dev/null +++ b/tests/gen2_evolution_anim_test.lua @@ -0,0 +1,95 @@ +-- The evolution screen survives being pushed onto a stack. +-- +-- luajit tests/gen2_evolution_anim_test.lua +-- +-- `enter` is the stack's lifecycle hook: StateStack:push calls `state:enter(...)` +-- (src/core/StateStack.lua:18), and Gold runs that same stack +-- (src/core/Game2.lua:makeStack). EvolutionAnim used to define `enter(phase)` +-- as its own phase-transition method, so pushing it called that method with +-- no argument, set phase to nil, +-- and left update() falling through every branch -- decrementing its timer +-- forever while World:busy() stayed true. A post-battle evolution became an +-- unrecoverable hang; the Gold route bot found it the first time its starter +-- reached level 14, roughly two hours into a run. +-- +-- The regression this pins is not the rename but the PROPERTY: after a push, +-- the screen still runs to completion. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 evolution anim") +local check, eq = S.check, S.eq + +local EvolutionAnim = require("src.ui.gen2.EvolutionAnim") + +-- No screen may use the stack's hook name for its own purposes. +check(rawget(EvolutionAnim, "enter") == nil, + "EvolutionAnim does not define `enter` (the stack lifecycle hook)") +check(type(rawget(EvolutionAnim, "setPhase")) == "function", + "its phase transition has a name of its own") + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local function loadGen(name) + local path = cache .. "/data/generated/" .. name + local fh = io.open(path, "r") + if not fh then return nil end + fh:close() + return assert(loadfile(path))() +end + +local pokemon = loadGen("pokemon.lua") +if not pokemon then + check(true, "gold cache absent : name check only (SKIP the run-through)") + S.finish() + return +end + +local Evolution = require("src.core.gen2.Evolution") +local data = { pokemon = pokemon, moves = loadGen("moves.lua"), audio = {}, + gen2Palettes = loadGen("palettes.lua") } + +-- CYNDAQUIL -> QUILAVA at 14, the exact evolution the bot wedged on. +local mon = { species = "CYNDAQUIL", level = 14, hp = 20, maxHp = 20, + moves = { { id = "EMBER", pp = 10, maxPp = 25 } } } +local save = { party = { mon }, pokedex = { seen = {}, caught = {} } } +local game = { data = data, save = save } + +local plan = Evolution.plan(data, { mon }, { [1] = true }, { timeOfDay = 1 }) +eq(#plan, 1, "CYNDAQUIL at 14 has an evolution to play") + +local done = false +local anim = EvolutionAnim.new(game, { + mon = plan[1].mon, entry = plan[1].entry, index = plan[1].index, + party = save.party, save = save, + onDone = function() done = true end, +}) + +-- The push, exactly as Screens.push does it: no extra arguments. This is the +-- line that used to kill the screen. +-- StateStack is used as a singleton (Game calls StateStack:init()), so the +-- test drives it directly rather than inventing an instance it does not have. +local StateStack = require("src.core.StateStack") +StateStack:init() +StateStack:push(anim) + +eq(anim.phase, "evolving", "being pushed does not clear the phase") + +-- Mashing A is what a bot (or an impatient player) does; it must not stall it. +game.input = { wasPressed = function(_, b) return b == "a" end } +local frames = 0 +for _ = 1, 5000 do + if done then break end + frames = frames + 1 + check(anim.phase ~= nil, "phase stays set while the animation runs") + if anim.phase == nil then break end + anim:update(1 / 60) +end + +check(done, "the evolution runs to completion and calls onDone") +check(frames < 2000, "and finishes promptly (" .. frames .. " frames)") +eq(save.party[1].species, "QUILAVA", "the party record is written back") + +S.finish() diff --git a/tests/gen2_evolution_test.lua b/tests/gen2_evolution_test.lua new file mode 100644 index 00000000..d0cbb2b0 --- /dev/null +++ b/tests/gen2_evolution_test.lua @@ -0,0 +1,543 @@ +-- Gen 2 evolution: the EvosAttacks condition walk, the party record an +-- evolution produces, and the frame counts of the animation that plays over it. +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_evolution_test.lua +-- +-- The fixtures below are the shapes data/generated/pokemon.lua writes, with +-- numbers traceable to pokegold so a failure names the ASM it disagrees with. +-- The last block re-runs the same questions against a real Gold cache when one +-- is present, and skips when it is not. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 evolution") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Evolution = require("src.core.gen2.Evolution") +local Mon = require("src.battle.gen2.Mon") + +-- ---- fixtures ------------------------------------------------------------- + +-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm), which keeps the +-- experience arithmetic below readable. +local GROWTH = { + MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, + constant = 0 }, +} + +local function base(hp, attack, defense, speed, spa, spd) + return { hp = hp, attack = attack, defense = defense, speed = speed, + specialAttack = spa, specialDefense = spd } +end + +local POKEMON = { + growthRates = GROWTH, + -- data/pokemon/evos_attacks.asm ChikoritaEvosAttacks / BayleefEvosAttacks. + CHIKORITA = { + name = "CHIKORITA", index = 152, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "GRASS" }, + baseStats = base(45, 49, 65, 45, 49, 65), + evolutions = { { method = "EVOLVE_LEVEL", level = 16, into = "BAYLEEF" } }, + levelMoves = { + { level = 1, move = "TACKLE" }, { level = 8, move = "GROWL" }, + { level = 12, move = "RAZOR_LEAF" }, + }, + }, + BAYLEEF = { + name = "BAYLEEF", index = 153, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "GRASS" }, + baseStats = base(60, 62, 80, 60, 63, 80), + evolutions = { { method = "EVOLVE_LEVEL", level = 32, into = "MEGANIUM" } }, + levelMoves = { + { level = 1, move = "TACKLE" }, { level = 8, move = "GROWL" }, + -- Two rows at the level the evolution happens at: one already known, one + -- not, so LearnLevelMoves' duplicate skip is exercised. + { level = 16, move = "REFLECT" }, { level = 16, move = "GROWL" }, + { level = 23, move = "POISONPOWDER" }, + }, + }, + MEGANIUM = { + name = "MEGANIUM", index = 154, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "GRASS" }, + baseStats = base(80, 82, 100, 80, 83, 100), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + -- EeveeEvosAttacks: three stones and two happiness rows, in ROM order. + EEVEE = { + name = "EEVEE", index = 133, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "NORMAL" }, + baseStats = base(55, 55, 50, 55, 45, 65), + evolutions = { + { method = "EVOLVE_ITEM", item = "THUNDERSTONE", into = "JOLTEON" }, + { method = "EVOLVE_ITEM", item = "WATER_STONE", into = "VAPOREON" }, + { method = "EVOLVE_ITEM", item = "FIRE_STONE", into = "FLAREON" }, + { method = "EVOLVE_HAPPINESS", time = "MORNDAY", into = "ESPEON" }, + { method = "EVOLVE_HAPPINESS", time = "NITE", into = "UMBREON" }, + }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + JOLTEON = { + name = "JOLTEON", index = 135, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "ELECTRIC" }, + baseStats = base(65, 65, 60, 130, 110, 95), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + ESPEON = { + name = "ESPEON", index = 196, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "PSYCHIC" }, + baseStats = base(65, 65, 60, 110, 130, 95), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + UMBREON = { + name = "UMBREON", index = 197, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "DARK" }, + baseStats = base(95, 65, 110, 65, 60, 130), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + -- GolbatEvosAttacks: TR_ANYTIME, i.e. no `time` field at all. + GOLBAT = { + name = "GOLBAT", index = 42, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "POISON", "FLYING" }, + baseStats = base(75, 80, 70, 90, 65, 75), + evolutions = { { method = "EVOLVE_HAPPINESS", into = "CROBAT" } }, + levelMoves = { { level = 1, move = "SCREECH" } }, + }, + CROBAT = { + name = "CROBAT", index = 169, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "POISON", "FLYING" }, + baseStats = base(85, 90, 80, 130, 70, 80), evolutions = {}, + levelMoves = { { level = 1, move = "SCREECH" } }, + }, + -- PoliwhirlEvosAttacks: a stone row and a held-item TRADE row, in that ROM + -- order, which is the only thing that decides which of the two wins. + POLIWHIRL = { + name = "POLIWHIRL", index = 61, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "WATER" }, + baseStats = base(65, 65, 65, 90, 50, 50), + evolutions = { + { method = "EVOLVE_ITEM", item = "WATER_STONE", into = "POLIWRATH" }, + { method = "EVOLVE_TRADE", item = "KINGS_ROCK", into = "POLITOED" }, + }, + levelMoves = { { level = 1, move = "BUBBLE" } }, + }, + POLIWRATH = { + name = "POLIWRATH", index = 62, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "WATER", "FIGHTING" }, + baseStats = base(90, 85, 95, 70, 70, 90), evolutions = {}, + levelMoves = { { level = 1, move = "BUBBLE" } }, + }, + POLITOED = { + name = "POLITOED", index = 186, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "WATER" }, + baseStats = base(90, 75, 75, 70, 90, 100), evolutions = {}, + levelMoves = { { level = 1, move = "BUBBLE" } }, + }, + -- GravelerEvosAttacks: EVOLVE_TRADE with no item ($ff). + GRAVELER = { + name = "GRAVELER", index = 75, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "ROCK", "GROUND" }, + baseStats = base(55, 95, 115, 35, 45, 45), + evolutions = { { method = "EVOLVE_TRADE", into = "GOLEM" } }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + GOLEM = { + name = "GOLEM", index = 76, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "ROCK", "GROUND" }, + baseStats = base(80, 110, 130, 45, 55, 65), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + -- TyrogueEvosAttacks: three EVOLVE_STAT rows at level 20. + TYROGUE = { + name = "TYROGUE", index = 236, growthRate = "MEDIUM_FAST", + genderRatio = 0x00, types = { "FIGHTING" }, + baseStats = base(35, 35, 35, 35, 35, 35), + evolutions = { + { method = "EVOLVE_STAT", level = 20, comparison = "ATK_LT_DEF", + into = "HITMONCHAN" }, + { method = "EVOLVE_STAT", level = 20, comparison = "ATK_GT_DEF", + into = "HITMONLEE" }, + { method = "EVOLVE_STAT", level = 20, comparison = "ATK_EQ_DEF", + into = "HITMONTOP" }, + }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + HITMONCHAN = { + name = "HITMONCHAN", index = 107, growthRate = "MEDIUM_FAST", + genderRatio = 0x00, types = { "FIGHTING" }, + baseStats = base(50, 105, 79, 76, 35, 110), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + HITMONLEE = { + name = "HITMONLEE", index = 106, growthRate = "MEDIUM_FAST", + genderRatio = 0x00, types = { "FIGHTING" }, + baseStats = base(50, 120, 53, 87, 35, 110), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + HITMONTOP = { + name = "HITMONTOP", index = 237, growthRate = "MEDIUM_FAST", + genderRatio = 0x00, types = { "FIGHTING" }, + baseStats = base(50, 95, 95, 70, 35, 110), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + -- A species that never evolves, so the sweep has something to skip. + TAUROS = { + name = "TAUROS", index = 128, growthRate = "MEDIUM_FAST", + genderRatio = 0x00, types = { "NORMAL" }, + baseStats = base(75, 100, 95, 110, 40, 70), evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, +} + +local MOVES = { + TACKLE = { name = "TACKLE", pp = 35 }, + GROWL = { name = "GROWL", pp = 40 }, + RAZOR_LEAF = { name = "RAZOR LEAF", pp = 25 }, + REFLECT = { name = "REFLECT", pp = 20 }, + POISONPOWDER = { name = "POISONPOWDER", pp = 35 }, + SCREECH = { name = "SCREECH", pp = 40 }, + BUBBLE = { name = "BUBBLE", pp = 30 }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES } + +-- Fixed DVs so every stat below is reproducible. +local DVS = { attack = 9, defense = 8, speed = 8, special = 8 } + +local function newMon(species, level, opts) + opts = opts or {} + opts.dvs = { attack = DVS.attack, defense = DVS.defense, + speed = DVS.speed, special = DVS.special } + return Mon.new(DATA, species, level, opts) +end + +local function row(species, index) + return POKEMON[species].evolutions[index or 1] +end + +-- ---- the animation schedule ---------------------------------------------- +-- engine/movie/evolution_animation.asm `lb bc, 1, 16` then `inc b / dec c / +-- dec c` per round. +local rounds = Evolution.flashRounds() +eq(#rounds, 8, "the flash loop runs eight rounds") +eq(rounds[1].wait, 16, "round 1 holds the old pic 16 frames") +eq(rounds[1].flashes, 1, "and flashes once") +eq(rounds[2].wait, 14, "round 2 holds 14") +eq(rounds[2].flashes, 2, "and flashes twice") +eq(rounds[8].wait, 2, "the last round holds only 2") +eq(rounds[8].flashes, 8, "and flashes eight times") +-- 16+14+...+2 = 72 frames of holding, (1+...+8) * 2 swaps = 72 frames of +-- flashing. +eq(Evolution.flashFrames(), 144, "the flashing half is 144 frames end to end") +eq(Evolution.EVOLVING_FRAMES, 50, "EvolvingText holds for 50 frames") +eq(Evolution.MUSIC_FRAMES, 80, "MUSIC_EVOLUTION plays 80 frames before the flash") +eq(Evolution.CONGRATS_FRAMES, 40, "the evolved-into page holds 40 frames") +eq(Evolution.BALL_SPAWN_FRAMES + Evolution.BALL_TAIL_FRAMES, 64, + ".PlayEvolvedSFX is 32 spawn frames plus 32 more") +-- depixel 9, 11 takes the Y tile FIRST, and an OAM object draws at +-- (x - 8, y - 16). +eq(Evolution.BALL_ORIGIN_X, 80, "the balls of light come out of x = 80") +eq(Evolution.BALL_ORIGIN_Y, 56, "and y = 56") +eq(Evolution.HAPPINESS_TO_EVOLVE, 220, "HAPPINESS_TO_EVOLVE EQU 220") + +-- ---- EVOLVE_LEVEL --------------------------------------------------------- +local chikorita = newMon("CHIKORITA", 15) +check(not Evolution.checkMon(DATA, chikorita, {}), + "a level 15 CHIKORITA is one level short") +chikorita = newMon("CHIKORITA", 16) +local entry = Evolution.checkMon(DATA, chikorita, {}) +check(entry ~= nil, "at 16 it evolves") +eq(entry and entry.into, "BAYLEEF", "into BAYLEEF") +-- `cp b / jp c, .dont_evolve_3` is >=, not ==, so an overlevelled mon still +-- evolves the first time the sweep looks at it. +check(Evolution.checkMon(DATA, newMon("CHIKORITA", 40), {}) ~= nil, + "a level 40 CHIKORITA still evolves") + +-- IsMonHoldingEverstone, checked on the LEVEL path. +local everstoned = newMon("CHIKORITA", 20, { item = Evolution.EVERSTONE }) +check(not Evolution.checkMon(DATA, everstoned, {}), + "an EVERSTONE stops a level evolution") +local ok, reason = Evolution.rowMatches(row("CHIKORITA"), everstoned, {}) +eq(reason, "everstone", "and says why") +check(not ok, "rowMatches agrees") + +-- wForceEvolution: a stone was just used, so nothing but the ITEM path fires. +check(not Evolution.checkMon(DATA, newMon("CHIKORITA", 20), { force = true }), + "a forced (stone) evolution does not trip the level row") +-- wLinkMode: nothing but the TRADE path fires while a link is up. +_, reason = Evolution.rowMatches(row("CHIKORITA"), newMon("CHIKORITA", 20), + { link = true }) +eq(reason, "linked", "and a link blocks it too") + +-- ---- EVOLVE_ITEM ---------------------------------------------------------- +local eevee = newMon("EEVEE", 25) +check(not Evolution.checkMon(DATA, eevee, { item = "THUNDERSTONE" }), + "a stone in the bag does nothing without wForceEvolution") +entry = Evolution.checkMon(DATA, eevee, { item = "THUNDERSTONE", force = true }) +eq(entry and entry.into, "JOLTEON", "using a THUNDERSTONE picks JOLTEON") +entry = Evolution.checkMon(DATA, eevee, { item = "WATER_STONE", force = true }) +eq(entry and entry.into, "VAPOREON", "a WATER STONE picks VAPOREON") +check(not Evolution.checkMon(DATA, eevee, { item = "LEAF_STONE", force = true }), + "a stone EEVEE has no row for does nothing") +-- .item never calls IsMonHoldingEverstone, which is why the stone still works. +local stoneHolder = newMon("EEVEE", 25, { item = Evolution.EVERSTONE }) +check(Evolution.checkMon(DATA, stoneHolder, + { item = "FIRE_STONE", force = true }) ~= nil, + "an EVERSTONE does NOT block a stone evolution in Gen 2") + +-- ---- EVOLVE_HAPPINESS ----------------------------------------------------- +local golbat = newMon("GOLBAT", 30, { happiness = 219 }) +check(not Evolution.checkMon(DATA, golbat, { timeOfDay = "DAY" }), + "happiness 219 is one short of HAPPINESS_TO_EVOLVE") +golbat = newMon("GOLBAT", 30, { happiness = 220 }) +check(Evolution.checkMon(DATA, golbat, { timeOfDay = "DAY" }) ~= nil, + "at 220 GOLBAT evolves in the day") +check(Evolution.checkMon(DATA, golbat, { timeOfDay = "NITE" }) ~= nil, + "and at night, because a row with no `time` is TR_ANYTIME") +check(not Evolution.checkMon(DATA, + newMon("GOLBAT", 30, { happiness = 220, item = Evolution.EVERSTONE }), + { timeOfDay = "DAY" }), "an EVERSTONE stops it") + +local happyEevee = newMon("EEVEE", 25, { happiness = 220 }) +entry = Evolution.checkMon(DATA, happyEevee, { timeOfDay = "DAY" }) +eq(entry and entry.into, "ESPEON", "TR_MORNDAY gives ESPEON by day") +entry = Evolution.checkMon(DATA, happyEevee, { timeOfDay = "MORN" }) +eq(entry and entry.into, "ESPEON", "and in the morning") +entry = Evolution.checkMon(DATA, happyEevee, { timeOfDay = "NITE" }) +eq(entry and entry.into, "UMBREON", "TR_NITE gives UMBREON at night") +-- The stone rows come first in ROM order but need wForceEvolution, so the +-- after-battle sweep walks straight past them to the happiness rows. +eq(#POKEMON.EEVEE.evolutions, 5, "EEVEE still has all five rows") + +-- ---- EVOLVE_TRADE --------------------------------------------------------- +local graveler = newMon("GRAVELER", 30) +_, reason = Evolution.rowMatches(row("GRAVELER"), graveler, {}) +eq(reason, "not trading", "a trade evolution needs wLinkMode") +entry = Evolution.checkMon(DATA, graveler, { link = true }) +eq(entry and entry.into, "GOLEM", "traded, GRAVELER becomes GOLEM") +check(not Evolution.checkMon(DATA, + newMon("GRAVELER", 30, { item = Evolution.EVERSTONE }), { link = true }), + "an EVERSTONE stops a trade evolution") + +local poliwhirl = newMon("POLIWHIRL", 30) +check(not Evolution.checkMon(DATA, poliwhirl, { link = true }), + "trading a POLIWHIRL with nothing held does nothing") +local kingsRock = newMon("POLIWHIRL", 30, { item = "KINGS_ROCK" }) +local consumed +entry, consumed = Evolution.checkMon(DATA, kingsRock, { link = true }) +eq(entry and entry.into, "POLITOED", "with a KING'S ROCK it becomes POLITOED") +check(consumed, "and the held item is consumed by the trade") +check(not Evolution.checkMon(DATA, kingsRock, + { link = true, timeCapsule = true }), + "LINK_TIMECAPSULE blocks a held-item trade evolution") +-- The WATER STONE row sits FIRST in the ROM, so a stone beats the trade. +entry = Evolution.checkMon(DATA, kingsRock, + { item = "WATER_STONE", force = true }) +eq(entry and entry.into, "POLIWRATH", "EvosAttacks order is the tiebreak") + +-- ---- EVOLVE_STAT ---------------------------------------------------------- +-- CompareBytes over wTempMonAttack and wTempMonDefense. +eq(Evolution.statComparison({ stats = { attack = 30, defense = 40 } }), + "ATK_LT_DEF", "attack under defense") +eq(Evolution.statComparison({ stats = { attack = 40, defense = 30 } }), + "ATK_GT_DEF", "attack over defense") +eq(Evolution.statComparison({ stats = { attack = 30, defense = 30 } }), + "ATK_EQ_DEF", "attack equal to defense") + +local tyrogue = newMon("TYROGUE", 19) +check(not Evolution.checkMon(DATA, tyrogue, {}), "TYROGUE waits for level 20") +tyrogue = newMon("TYROGUE", 20) +tyrogue.stats.attack, tyrogue.stats.defense = 30, 40 +eq(Evolution.checkMon(DATA, tyrogue, {}).into, "HITMONCHAN", + "ATK_LT_DEF gives HITMONCHAN") +tyrogue.stats.attack, tyrogue.stats.defense = 40, 30 +eq(Evolution.checkMon(DATA, tyrogue, {}).into, "HITMONLEE", + "ATK_GT_DEF gives HITMONLEE") +tyrogue.stats.attack, tyrogue.stats.defense = 35, 35 +eq(Evolution.checkMon(DATA, tyrogue, {}).into, "HITMONTOP", + "ATK_EQ_DEF gives HITMONTOP") + +-- ---- the sweep ------------------------------------------------------------ +-- ExitBattle's `ld a, [wBattleResult] / and $f / jr nz`. +check(Evolution.runsAfterBattle("win"), "a win runs the sweep") +check(Evolution.runsAfterBattle("caught"), "so does a catch") +check(not Evolution.runsAfterBattle("lose"), "a loss does not") + +local party = { + newMon("CHIKORITA", 16), + newMon("TAUROS", 30), + newMon("GRAVELER", 30), +} +local plan = Evolution.plan(DATA, party, { [1] = true, [2] = true }, {}) +eq(#plan, 1, "only the flagged slot that can evolve is planned") +eq(plan[1].index, 1, "and it names its party slot") +eq(plan[1].into, "BAYLEEF", "and its target") +eq(#Evolution.plan(DATA, party, { [2] = true }, {}), 0, + "a flagged TAUROS plans nothing") +eq(#Evolution.plan(DATA, party, nil, {}), 1, + "with no flags at all every slot is eligible, and only one qualifies") +eq(#Evolution.plan(DATA, party, nil, { link = true }), 1, + "a trade sweep finds the GRAVELER instead") +eq(Evolution.plan(DATA, party, nil, { link = true })[1].index, 3, + "in slot 3") + +-- ---- nicknames ------------------------------------------------------------ +-- UpdateSpeciesNameIfNotNicknamed. +check(Evolution.keptNickname(DATA, newMon("CHIKORITA", 16)) == nil, + "an un-nicknamed mon has no nickname to keep") +check(Evolution.keptNickname(DATA, + newMon("CHIKORITA", 16, { nickname = "CHIKORITA" })) == nil, + "a `nickname` that is just the species name is not a nickname") +eq(Evolution.keptNickname(DATA, + newMon("CHIKORITA", 16, { nickname = "LEAFY" })), "LEAFY", + "a real nickname survives") + +-- ---- LearnLevelMoves ------------------------------------------------------ +local learner = newMon("BAYLEEF", 16, { moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "GROWL", pp = 40, maxPp = 40 }, +} }) +local learned = Evolution.learnedOnEvolve(DATA, "BAYLEEF", 16, learner) +eq(#learned, 1, "only the level-16 move it does not already know is offered") +eq(learned[1], "REFLECT", "and that is REFLECT") +eq(#Evolution.learnedOnEvolve(DATA, "BAYLEEF", 17, learner), 0, + "the match is on the exact level, not a range") +eq(#Evolution.learnedOnEvolve(DATA, "BAYLEEF", 23, learner), 1, + "level 23 offers POISONPOWDER") + +-- ---- apply ---------------------------------------------------------------- +local before = newMon("CHIKORITA", 16, { nickname = "LEAFY" }) +before.hp = 20 +before.status = "PSN" +before.pokerus = 4 -- a field Mon.new knows nothing about +local beforeMax = before.maxHp +local beforeExp = before.experience +local after = Evolution.apply(DATA, before, row("CHIKORITA")) +check(after ~= nil, "apply returns a record") +eq(after.species, "BAYLEEF", "the species changed") +eq(after.name, "BAYLEEF", "and so did the display name") +eq(after.nickname, "LEAFY", "the real nickname rode along") +eq(after.level, 16, "the level did not move") +eq(after.experience, beforeExp, "and neither did the experience") +-- CalcMonStats through the one builder, at the same level and DVs. +local expected = Mon.stats(POKEMON.BAYLEEF.baseStats, before.dvs, 16) +eq(after.maxHp, expected.hp, "max HP is BAYLEEF's, recomputed") +eq(after.stats.attack, expected.attack, "so is attack") +eq(after.stats.specialDefense, expected.specialDefense, + "and special defense, which Gen 1's builder has no slot for") +-- `ld hl, wTempMonHP + 1 / add c`: the max-HP DELTA is added, not a refill. +eq(after.hp, 20 + (expected.hp - beforeMax), + "HP carries the max-HP gain rather than refilling") +check(after.hp < after.maxHp, "a half-dead mon is still half dead") +eq(after.status, "PSN", "the status carried over") +eq(after.pokerus, 4, "and so did a field the builder does not own") +eq(#after.moves, #before.moves, "the moves came with it") +eq(after.types[1], "GRASS", "the types are the new species'") + +-- An un-nicknamed mon takes the new species' name. +local plain = newMon("CHIKORITA", 16) +local grown = Evolution.apply(DATA, plain, row("CHIKORITA")) +check(grown.nickname == nil, "an un-nicknamed mon stays un-nicknamed") +eq(grown.name, "BAYLEEF", "and simply reads as BAYLEEF") +eq(grown.hp, grown.maxHp, "a mon at full HP comes out at full HP") + +-- A held item survives a level evolution but not the trade that demanded it. +local held = newMon("CHIKORITA", 16, { item = "BERRY" }) +eq(Evolution.apply(DATA, held, row("CHIKORITA")).item, "BERRY", + "a level evolution leaves the held item alone") +local traded = Evolution.apply(DATA, newMon("POLIWHIRL", 30, + { item = "KINGS_ROCK" }), row("POLIWHIRL", 2)) +eq(traded.species, "POLITOED", "the trade evolution applied") +check(traded.item == nil, "and it ate the KING'S ROCK") +-- GRAVELER's row asks for no item ($ff), so a trade leaves one in place. +local rockGolem = Evolution.apply(DATA, + newMon("GRAVELER", 30, { item = "BERRY" }), row("GRAVELER")) +eq(rockGolem.item, "BERRY", "a no-item trade row keeps what is held") + +-- ---- SetSeenAndCaughtMon -------------------------------------------------- +local save = { pokedex = { seen = {}, caught = {} } } +check(Evolution.markPokedex(save, "BAYLEEF"), "the dex accepted the tick") +check(save.pokedex.seen.BAYLEEF, "BAYLEEF is seen") +check(save.pokedex.caught.BAYLEEF, "and caught, the way GivePoke does it") +local bare = {} +Evolution.markPokedex(bare, "CROBAT") +check(bare.pokedex.caught.CROBAT, "a save with no dex tables grows them") +check(not Evolution.markPokedex(nil, "CROBAT"), "and no save is a no-op") + +-- ---- against a real Gold cache ------------------------------------------- +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 path = cache .. "/data/generated/pokemon.lua" +local file = io.open(path, "r") +if not file then + check(true, "gold pokemon cache absent - SKIP") + S.finish() + return +end +file:close() + +local cachedPokemon = assert(loadfile(path))() +local withEvolutions = 0 +for _, def in pairs(cachedPokemon) do + if type(def) == "table" and def.evolutions and #def.evolutions > 0 then + withEvolutions = withEvolutions + 1 + end +end +check(withEvolutions > 100, + "the cache carries evolution rows for " .. withEvolutions .. " species") + +local bulbasaur = cachedPokemon.BULBASAUR +check(bulbasaur ~= nil, "BULBASAUR is in the cache") +if bulbasaur then + eq(bulbasaur.evolutions[1].method, "EVOLVE_LEVEL", "its row is a level row") + eq(bulbasaur.evolutions[1].level, 16, "at level 16") + eq(bulbasaur.evolutions[1].into, "IVYSAUR", "into IVYSAUR") +end + +-- EeveeEvosAttacks: three stones then two happiness rows. The `time` field +-- itself is NOT asserted here: src/import/RomExtractorGen2.lua's TR_NAMES and +-- ATK_NAMES tables are indexed from 0 while the TR_* / ATK_*_DEF constants +-- start at 1, so those two fields currently come out one constant off. +local cachedEevee = cachedPokemon.EEVEE +if cachedEevee then + local stones, happy = 0, 0 + for _, e in ipairs(cachedEevee.evolutions) do + if e.method == "EVOLVE_ITEM" then stones = stones + 1 end + if e.method == "EVOLVE_HAPPINESS" then happy = happy + 1 end + end + eq(stones, 3, "EEVEE has three stone rows in the cache") + eq(happy, 2, "and two happiness rows") +end + +-- A whole evolution driven off the real tables, to prove the reader and the +-- builder agree about the cache's field names. +local cachedData = { + pokemon = cachedPokemon, + moves = (function() + local movePath = cache .. "/data/generated/moves.lua" + local f = io.open(movePath, "r") + if not f then return {} end + f:close() + return assert(loadfile(movePath))() + end)(), +} +local real = Mon.new(cachedData, "CHIKORITA", 16, { dvs = { + attack = 9, defense = 8, speed = 8, special = 8 } }) +if real then + local realEntry = Evolution.checkMon(cachedData, real, {}) + eq(realEntry and realEntry.into, "BAYLEEF", + "a real level 16 CHIKORITA evolves into BAYLEEF") + local realAfter = Evolution.apply(cachedData, real, realEntry) + eq(realAfter and realAfter.species, "BAYLEEF", "and apply builds one") + check(realAfter and realAfter.maxHp > real.maxHp, + "with more max HP than it had") + check(realAfter and realAfter.hp == realAfter.maxHp, + "and a full-health mon still at full health") + eq(realAfter and #realAfter.moves, #real.moves, + "carrying the same number of moves") +end + +S.finish() diff --git a/tests/gen2_exp_share_test.lua b/tests/gen2_exp_share_test.lua new file mode 100644 index 00000000..9c180027 --- /dev/null +++ b/tests/gen2_exp_share_test.lua @@ -0,0 +1,216 @@ +-- GiveExperiencePoints' three boosts and the EXP.SHARE double pass +-- (engine/battle/core.asm:6747, dispatch at :2099-2130). +-- +-- luajit tests/gen2_exp_share_test.lua +-- +-- ROM-free. Traded mons (OT id differs from the player's) earn x1.5 with +-- BoostedExpPointsText; a held LUCKY_EGG is another x1.5, checked by item +-- id; any live EXP.SHARE holder halves the pool up front, participants +-- split the first pass and every holder is paid a second pass -- stat exp +-- included, from the same halved base stats. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 exp share") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, linear = 0, + constant = 0 }, +} + +local 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_SLOW", 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", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local ITEMS = { + LUCKY_EGG = { id = "LUCKY_EGG", name = "LUCKY EGG", + heldEffect = "HELD_NONE", heldParameter = 0 }, + EXP_SHARE = { id = "EXP_SHARE", name = "EXP.SHARE", + heldEffect = "HELD_NONE", heldParameter = 0 }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local PLAYER_ID = 31337 + +-- One KO's worth of exp, replayed against a fresh battle each time. +-- `partySpec` rows: { otId =, item =, participant =, hp = }. +local function award(partySpec) + local party, participants = {}, {} + for index, spec in ipairs(partySpec) do + local mon = Mon.new(DATA, "MACHOP", 20, { dvs = perfect }) + mon.otId = spec.otId + mon.item = spec.item + if spec.hp then mon.hp = spec.hp end + party[index] = mon + if spec.participant then participants[index] = true end + end + local wild = Mon.new(DATA, "PIDGEY", 14, { dvs = perfect }) + local battle = Battle.new({ data = DATA, party = party, wild = wild, + save = { player = { id = PLAYER_ID, badges = {} } }, + random = function(n) return (n or 1) > 1 and 1 or 0 end }) + battle.participants = participants + local before = {} + for index, mon in ipairs(party) do before[index] = mon.experience end + battle:awardExperience(wild) + local gained = {} + for index, mon in ipairs(party) do + gained[index] = mon.experience - before[index] + end + return gained, battle:takeEvents(), party +end + +-- The wild PIDGEY: baseExp 55, level 14 -> floor(55 * 14 / 7) = 110. +local BASE = 110 + +-- ---- the native baseline -------------------------------------------------- +do + local gained, events = award({ { otId = PLAYER_ID, participant = true } }) + eq(gained[1], BASE, "a native solo participant earns baseExp * level / 7") + local boosted = false + for _, event in ipairs(events) do + if event.kind == "experience" + and event.text:find("boosted", 1, true) then + boosted = true + end + end + eq(boosted, false, "and its line is the plain ExpPointsText") +end + +-- ---- the traded 1.5x ------------------------------------------------------ +do + local gained, events = award({ { otId = 48926, participant = true } }) + eq(gained[1], math.floor(BASE * 3 / 2), + "an outsider mon (ROCKY the Onix's OT, say) earns x1.5") + local line + for _, event in ipairs(events) do + if event.kind == "experience" then line = event.text end + end + eq(line, "MACHOP gained a boosted 165 EXP. Points!", + "with BoostedExpPointsText's own wording") +end + +-- ---- the Lucky Egg 1.5x and the stack ------------------------------------- +do + local gained = award({ + { otId = PLAYER_ID, item = "LUCKY_EGG", participant = true } }) + eq(gained[1], math.floor(BASE * 3 / 2), "a held LUCKY EGG is x1.5") + + gained = award({ { otId = 48926, item = "LUCKY_EGG", participant = true } }) + eq(gained[1], math.floor(math.floor(BASE * 3 / 2) * 3 / 2), + "traded and egg stack to x2.25, floored in the cart's order") +end + +-- ---- EXP.SHARE ------------------------------------------------------------ +do + -- Holder on the bench: the pool halves (baseExp 55 -> 27), the fighter + -- takes the first pass, the holder the second. + local halved = math.floor(math.floor(55 / 2) * 14 / 7) -- 54 + local gained = award({ + { otId = PLAYER_ID, participant = true }, + { otId = PLAYER_ID, item = "EXP_SHARE" }, + }) + eq(gained[1], halved, "the participant's share is halved") + eq(gained[2], halved, "the benched holder is paid the second pass") + + -- A holder that fought collects BOTH passes. + gained = award({ + { otId = PLAYER_ID, item = "EXP_SHARE", participant = true } }) + eq(gained[1], halved * 2, "a fighting holder is paid twice") + + -- Two participants split pass one; the one holder still takes a full + -- second-pass share. + gained = award({ + { otId = PLAYER_ID, participant = true }, + { otId = PLAYER_ID, participant = true }, + { otId = PLAYER_ID, item = "EXP_SHARE" }, + }) + local split = math.floor(halved / 2) + eq(gained[1], split, "two participants split the halved pool") + eq(gained[2], split, "both of them") + eq(gained[3], halved, "the lone holder's pass divides by one") + + -- A fainted holder is skipped by the pass loop, and with no live holder + -- the pool is never halved. + gained = award({ + { otId = PLAYER_ID, participant = true }, + { otId = PLAYER_ID, item = "EXP_SHARE", hp = 0 }, + }) + eq(gained[1], BASE, "a fainted holder does not tax the pool") + eq(gained[2], 0, "and earns nothing") +end + +-- ---- stat exp rides the same halving -------------------------------------- +do + local _, _, party = award({ + { otId = PLAYER_ID, participant = true }, + { otId = PLAYER_ID, item = "EXP_SHARE" }, + }) + -- PIDGEY base attack 45: halved to 22 for each pass. + eq(party[1].statExp.attack, 22, "the participant's stat exp is halved") + eq(party[2].statExp.attack, 22, "the holder earns stat exp too") + -- And the special word takes the loser's Special ATTACK, halved. + eq(party[2].statExp.special, math.floor(35 / 2), + "the special stat exp word follows") + + _, _, party = award({ { otId = PLAYER_ID, participant = true } }) + eq(party[1].statExp.attack, 45, "no holder, no halving") +end + +-- ---- Mon.experienceGain's arms stay honest -------------------------------- +do + local def = POKEMON.PIDGEY + eq(Mon.experienceGain(def, 14, 1, false), BASE, "bare") + eq(Mon.experienceGain(def, 14, 1, true), math.floor(BASE * 3 / 2), + "trainer battles boost x1.5, unchanged") + eq(Mon.experienceGain(def, 14, 1, false, { traded = true, luckyEgg = true }), + math.floor(math.floor(BASE * 3 / 2) * 3 / 2), "traded + egg") + eq(Mon.experienceGain(def, 14, 1, false, { halved = true }), + math.floor(math.floor(55 / 2) * 14 / 7), "the share tax halves baseExp") + eq(Mon.experienceGain(def, 14, 1, true, + { halved = true, traded = true, luckyEgg = true }), + math.floor(math.floor(math.floor( + math.floor(math.floor(55 / 2) * 14 / 7) * 3 / 2) * 3 / 2) * 3 / 2), + "all four arms compose in the cart's order") +end + +S.finish() diff --git a/tests/gen2_faint_once_test.lua b/tests/gen2_faint_once_test.lua new file mode 100644 index 00000000..45f728a2 --- /dev/null +++ b/tests/gen2_faint_once_test.lua @@ -0,0 +1,101 @@ +-- A faint is announced once, and charged to happiness once. +-- +-- luajit tests/gen2_faint_once_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua), whose logs are full +-- of this: +-- +-- TYPHLOSION fainted! +-- TYPHLOSION fainted! +-- TYPHLOSION fainted! +-- +-- Battle:resolveFaints' player arm is the only one that returns WITHOUT +-- changing whose turn it is: it emits `choose-switch` and waits for the caller +-- to pick a replacement, so the caller calls back in with the same mon still at +-- 0 HP and the whole arm ran again. The repeated line is cosmetic; the bug +-- underneath it is not, because `faintHappiness` sat on the same path and was +-- charged once per re-entry. A single faint cost two or three times the +-- happiness the cart takes -- engine/battle/core.asm runs its happiness arm +-- once per faint. +-- +-- The enemy arm cannot do this: it either ends the battle or switches in the +-- next mon, so it never re-enters holding a fainted enemy. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 faint once") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") + +-- resolveFaints' player arm reads self.player/self.party/self.enemy and calls +-- out to emit, faintHappiness and firstHealthy, so it runs against a stub. +local function stub(party) + local st = { + said = {}, + happiness = 0, + } + st.player = party[1] + st.party = party + st.enemy = { hp = 50, level = 50 } + st.emit = function(_, ev) st.said[#st.said + 1] = ev.kind end + st.faintHappiness = function() st.happiness = st.happiness + 1 end + st.monName = function(_, m) return m.name or "MON" end + return setmetatable(st, { __index = Battle }) +end + +local function countKind(said, kind) + local n = 0 + for _, k in ipairs(said) do if k == kind then n = n + 1 end end + return n +end + +local function countFaints(said) + return countKind(said, "faint") +end + +-- The reported case: a fainted lead with a healthy mon behind it. The caller +-- drives the switch, so it calls resolveFaints repeatedly in the meantime. +do + local b = stub({ + { hp = 0, level = 88, name = "TYPHLOSION" }, + { hp = 40, level = 30, name = "SLOWPOKE" }, + }) + eq(b:resolveFaints(), false, "the battle is not over, there is a mon left") + eq(b:resolveFaints(), false, "and calling again while the switch is pending") + eq(b:resolveFaints(), false, "and again") + eq(countFaints(b.said), 1, "the faint is announced exactly once") + eq(b.happiness, 1, "and happiness is charged exactly once") + -- HandlePlayerMonFaint runs ForcePlayerMonChoice once (core.asm:2543) and + -- the turn loop never comes back for a second answer. Three prompts in the + -- queue reopened the party list on top of the pick that had already been + -- made, which is why the switch looked like it took several attempts. + eq(countKind(b.said, "choose-switch"), 1, "and the party list is asked for " + .. "exactly once") +end + +-- The replacement announces its own faint normally: the guard is keyed on the +-- mon, not on the battle. +do + local slowpoke = { hp = 40, level = 30, name = "SLOWPOKE" } + local b = stub({ { hp = 0, level = 88, name = "TYPHLOSION" }, slowpoke }) + b:resolveFaints() + b:resolveFaints() + -- Battle:switch is what clears the guards; do what it does. + b.faintAnnounced = nil + b.pendingSwitch = nil + b.player = slowpoke + slowpoke.hp = 0 + eq(b:resolveFaints(), true, "no healthy mon left ends the battle") + eq(countFaints(b.said), 2, "the second mon's faint is announced too") + eq(b.happiness, 2, "and charged") +end + +-- A wipe still reports the wipe, once. +do + local b = stub({ { hp = 0, level = 88, name = "TYPHLOSION" } }) + eq(b:resolveFaints(), true, "a lone fainted mon ends the battle") + eq(b.outcome, "lose", "as a loss") + eq(countFaints(b.said), 1, "announced once") +end + +S.finish() diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua new file mode 100644 index 00000000..0cd418fd --- /dev/null +++ b/tests/gen2_field_anim_test.lua @@ -0,0 +1,225 @@ +-- Gen 2 field presentation: the teleport step types, the fishing bob, the fly +-- lift, the tilt billboard clip and the border-fill dissolve. +-- +-- All five are drawing, so what is asserted is the state the drawing reads: +-- the decoded movement byte, the sprite Y offset a frame lands on, and the +-- clip / fade bookkeeping. tests/drivers/gold_field_anim_shots.lua is the +-- half a test cannot cover. + +package.path = "./?.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, + draw = function() end, + newQuad = function() return { setViewport = function() end } end, + newImage = function() + return { getDimensions = function() return 16, 96 end } + end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + getDimensions = function() return 160, 144 end, +} +love.math = love.math or { random = function(a, b) return b and a or 0.5 end } +require("src.core.Logger").warn = function() end + +local BorderFill = require("src.world.gen2.BorderFill") +local Movement = require("src.script.gen2.Movement") +local NPC = require("src.world.gen2.Npc") +local Tilt = require("src.render.Tilt") + +local checks, failures = 0, 0 +local function check(label, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s want %s"):format(label, tostring(got), + tostring(want))) + end +end + +-- ------------------------------------------------------- teleport_from / _to +-- +-- macros/scripts/movement.asm:148: $4c teleport_from, $4d teleport_to. Both +-- used to fall off the family ladder and decode as `nop`, so Lance's exit from +-- the Lake of Rage and Blue's off Cinnabar finished instantly. +local from = Movement.decodeByte(0x4c) +check("$4c decodes as a teleport", from.kind, "teleport") +check("and it is the leaving half", from.mode, "from") +check("two sixteen-frame beats", from.frames, 32) +local to = Movement.decodeByte(0x4d) +check("$4d decodes as a teleport", to.kind, "teleport") +check("and it is the arriving half", to.mode, "to") +check("three sixteen-frame beats", to.frames, 48) +-- The neighbours must not have moved. +check("$47 is still step_end", Movement.decodeByte(0x47).kind, "end") +check("$46 is still the last step_sleep", + Movement.decodeByte(0x46).kind, "sleep") +check("$4e skyfall is still unmodelled", Movement.decodeByte(0x4e).kind, "nop") + +-- ---------------------------------------------------------- the jump families +-- +-- $2c slow_jump_step / $30 jump_step / $34 fast_jump_step all reach JumpStep +-- (engine/overworld/movement.asm:741) and run StepFunction_NPCJump +-- (engine/overworld/map_objects.asm:1129), whose `.Jump` beat calls GetNextTile +-- a SECOND time before `.Land` walks it: a jump crosses two cells. They used +-- to decode as a plain one-cell step, which left the Ilex Forest Farfetch'd a +-- column short for the rest of its movement stream. +check("$30 jump_step DOWN is a jump", Movement.decodeByte(0x30).kind, "jump") +check("and it keeps its facing", Movement.decodeByte(0x33).dir, "right") +check("$2c slow_jump_step is one too", Movement.decodeByte(0x2c).kind, "jump") +check("$34 fast_jump_step as well", Movement.decodeByte(0x34).kind, "jump") +-- The neighbouring families are still plain steps / turns. +check("$28 turn_waterfall is untouched", + Movement.decodeByte(0x28).kind, "turn") +check("$10 big_step is untouched", Movement.decodeByte(0x10).kind, "step") + +-- ------------------------------------------ sliding, fixed facing, tree shake +-- +-- Movement_set_sliding / _remove_sliding ($39 / $38) and _fix_facing / +-- _remove_fixed_facing ($3b / $3a) toggle SLIDING_F and FIXED_FACING_F +-- (engine/overworld/movement.asm:353-363), and $56 tree_shake parks the object +-- on 24 frames of OBJECT_ACTION_WEIRD_TREE (:334). All three used to decode as +-- `nop`, which is why the Burned Tower beasts turned and walked out instead of +-- gliding, the Route 30 Rattata finished facing away, and Sudowoodo's shake +-- completed in no frames at all. +local slideOn = Movement.decodeByte(0x39) +check("$39 set_sliding decodes as sliding", slideOn.kind, "sliding") +check("and it turns the flag on", slideOn.on, true) +local slideOff = Movement.decodeByte(0x38) +check("$38 remove_sliding is the same kind", slideOff.kind, "sliding") +check("and it turns the flag off", slideOff.on, false) +local fixOn = Movement.decodeByte(0x3b) +check("$3b fix_facing decodes as fixfacing", fixOn.kind, "fixfacing") +check("and it fixes the facing", fixOn.fixed, true) +local fixOff = Movement.decodeByte(0x3a) +check("$3a remove_fixed_facing is the same kind", fixOff.kind, "fixfacing") +check("and it releases it", fixOff.fixed, false) +local shake = Movement.decodeByte(0x56) +check("$56 tree_shake decodes as treeshake", shake.kind, "treeshake") +check("and it carries the cart's 24 frames", shake.frames, 24) +check("which is the module's own constant", Movement.TREE_SHAKE_FRAMES, 24) +-- The neighbours must not have moved. +check("$54 is still unmodelled", Movement.decodeByte(0x54).kind, "nop") +check("$57 is still unmodelled", Movement.decodeByte(0x57).kind, "nop") + +-- SetFacingWeirdTree increments OBJECT_STEP_FRAME BEFORE masking +-- (engine/overworld/map_object_action.asm:204), so the quarter that reaches the +-- facing table is frame+1 over four: three frames on the first quarter, four on +-- each after it. +for frame, want in ipairs({ [1] = 0, [2] = 0, [3] = 0, [4] = 1, + [5] = 1, [6] = 1, [7] = 1, [8] = 2 }) do + check(("tree shake frame %d is quarter %d"):format(frame - 1, want), + Movement.treeShakeIndex(frame - 1), want) +end + +-- Sine with d = $60, minus $60: 0 at a quarter turn (on the tile) and -$60 at +-- a half turn (a full sprite-height and a half above it). +check("the curve is on the tile at height 16", + Movement.teleportYOffset(16), 0) +check("and fully lifted at height 32", Movement.teleportYOffset(32), -0x60) +check("height 0 is the top of the descent", Movement.teleportYOffset(0), -0x60) + +-- ------------------------------------------------------- the step type itself +-- SpriteRenderer wants a sheet; these assertions are about the step type, so +-- the sheet is the smallest one that loads. +local SPRITE = { image = "assets/generated/sprites/lance.png", frames = 6 } +local npc = NPC.new("LAKE_OF_RAGE", { index = 1, x = 4, y = 5, movement = 1 }, + SPRITE) +check("an object starts with no sprite offset", npc.spriteYOffset, nil) +npc:scriptTeleport("from", 32) +check("scriptTeleport freezes the object", npc.frozen, true) + +local facings, highest = {}, 0 +for frame = 1, 32 do + npc:update(nil, nil) + facings[npc.facing] = true + if frame == 16 then + check("the first beat spins on the spot", npc.spriteYOffset, 0) + end + highest = math.min(highest, npc.spriteYOffset or 0) +end +check("OBJECT_ACTION_SPIN turns through every facing", + facings.up and facings.down and facings.left and facings.right, true) +-- The cart leaves OBJECT_SPRITE_Y_OFFSET where the last frame put it (the +-- object is normally `disappear`ed on top of it); this puts the sprite back on +-- its tile instead, so the deepest offset the animation reaches is the frame +-- before the last -- five sprite rows clear of the ground either way. +check("and the second beat lifts it clear of the tile", highest <= -0x50, true) +check("the step type releases the object at the end", npc.teleport, nil) +check("and puts the sprite back on its tile", npc.spriteYOffset, 0) +-- The object never left its cell: OBJECT_SPRITE_Y_OFFSET is a draw offset. +check("the object is still on its own tile", npc.cellY, 5) + +-- teleport_to descends onto the tile instead. +local lander = NPC.new("CINNABAR_ISLAND", + { index = 2, x = 1, y = 1, movement = 1 }, SPRITE) +lander:scriptTeleport("to", 48) +lander:update(nil, nil) +check("the wait beat holds it above the tile", + lander.spriteYOffset <= -0x60, true) +for _ = 2, 48 do lander:update(nil, nil) end +check("and it ends standing on it", lander.spriteYOffset, 0) + +-- ------------------------------------------------------- tilt billboard clip +-- +-- groundPoint projects any point at all, so without this an NPC two screens +-- away was pulled back toward the horizon and drawn over the border fill. +Tilt.setLevel(2) +Tilt.update(1) +check("a foot inside the view is on the ground", + Tilt.onGround(80, 70, 160, 144), true) +check("one far above it is not", Tilt.onGround(80, -400, 160, 144), false) +check("one far below it is not", Tilt.onGround(80, 900, 160, 144), false) +check("one far to the side is not", Tilt.onGround(-500, 70, 160, 144), false) +check("the margin keeps a sprite half off the edge", + Tilt.onGround(-8, 70, 160, 144, 32), true) +Tilt.reset() + +-- ------------------------------------------------------- border-fill dissolve +local owner = {} +local water, trees, alpha = "water", "trees", nil +local previous +previous, alpha = BorderFill.crossfade(owner, water, "CHERRYGROVE_CITY") +check("the first fill of a session appears at once", previous, nil) +check("at full strength", alpha, 1) +previous, alpha = BorderFill.crossfade(owner, water, "CHERRYGROVE_CITY") +check("and standing still does not start a fade", previous, nil) + +-- A re-bake of the SAME map (the daytime rollover, the COLOR option, the +-- two-frame cave flicker) swaps the image without dissolving. +previous, alpha = BorderFill.crossfade(owner, "water_night", + "CHERRYGROVE_CITY") +check("a re-bake of the same map cuts", previous, nil) +check("at full strength", alpha, 1) + +-- Crossing into Route 30 dissolves instead of popping. +previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30") +check("the boundary fades from the old block", previous, "water_night") +check("starting near transparent", alpha < 0.2, true) +local seen = 1 +for _ = 2, BorderFill.CROSSFADE_FRAMES - 1 do + local from2, a2 = BorderFill.crossfade(owner, trees, "ROUTE_30") + check("the old block stays under it", from2, "water_night") + seen = seen + 1 + check("and the new one climbs", a2 > alpha, true) + alpha = a2 +end +check("the dissolve runs for its whole length", + seen, BorderFill.CROSSFADE_FRAMES - 1) +previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30") +check("then it is over", previous, nil) +check("and the new block owns the void", alpha, 1) + +-- No key at all (an old caller) is the plain single draw. +local bare = {} +previous, alpha = BorderFill.crossfade(bare, trees, nil) +check("a caller with no map key never fades", previous, nil) +check("and draws opaque", alpha, 1) + +print(("gen2 field anim: %d checks, %d failures"):format(checks, failures)) +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua new file mode 100644 index 00000000..912e61ad --- /dev/null +++ b/tests/gen2_field_items_test.lua @@ -0,0 +1,816 @@ +-- Field item use from the Gen 2 PACK: pack.asm UseItem's .Party arm +-- (src/core/gen2/ItemEffects.lua + Game2:usePartyItem), the +-- ESCAPE ROPE / DIG / TELEPORT escape family (World:useEscapeRope, +-- FieldMoves.digFromMenu / teleportFromMenu over EscapeRopeOrDig, +-- engine/events/overworld.asm), the SQUIRTBOTTLE's queued tree script +-- (engine/events/squirtbottle.asm _Squirtbottle), and the .Oak refusal for +-- field-NOUSE items. The cache-backed sections skip without a gold cache, +-- the same guard tests/gen2_pokecenter_stairs_test.lua uses. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- The UI modules require love-side helpers at load time; stub what they +-- touch. 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) + if b then return a end + return a and 1 or 0.5 + end, +} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local S = require("tests.harness").suite("gen2 field items") +local check, eq = S.check, S.eq + +local Game2 = require("src.core.Game2") +local ItemEffects = require("src.core.gen2.ItemEffects") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local PackMenu = require("src.ui.gen2.PackMenu") +local Strings = require("src.core.Strings") + +-- ------------------------------------------------------------------ fixtures + +local DATA = { + audio = { sfx = {}, sfxOrder = {} }, + tokens = require("src.render.TextBox").TOKENS, + pokemon = { + growthRates = { + GROWTH_MEDIUM_SLOW = { + numerator = 6, denominator = 5, squared = -15, linear = 100, + constant = 140, + }, + }, + CYNDAQUIL = { + id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "FIRE", "FIRE" }, + baseStats = { + hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50, + }, + -- EMBER at 12 makes a candy from 11 offer exactly one move. + levelMoves = { + { level = 1, move = "TACKLE" }, + { level = 12, move = "EMBER" }, + }, + tmhm = { "SWIFT" }, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + EMBER = { id = "EMBER", name = "EMBER", pp = 25 }, + SURF = { id = "SURF", name = "SURF", pp = 15 }, + SMOKESCREEN = { id = "SMOKESCREEN", name = "SMOKESCREEN", pp = 20 }, + SWIFT = { id = "SWIFT", name = "SWIFT", pp = 20 }, + }, + items = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18, + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" }, + ETHER = { id = "ETHER", name = "ETHER", pocket = "ITEM", index = 69, + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" }, + X_ATTACK = { id = "X_ATTACK", name = "X ATTACK", pocket = "ITEM", + index = 49, fieldMenu = "ITEMMENU_NOUSE", battleMenu = "ITEMMENU_CLOSE" }, + POKE_BALL = { id = "POKE_BALL", name = "POKE BALL", pocket = "BALL", + index = 5, fieldMenu = "ITEMMENU_NOUSE", battleMenu = "ITEMMENU_CLOSE" }, + TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191, + fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE", + teaches = "SWIFT" }, + }, + gen2MenuGfx = {}, + gen2Icons = { + species = { CYNDAQUIL = "ICON_FOX" }, + icons = { ICON_FOX = { id = "ICON_FOX", image = "x/fox.png" } }, + }, +} + +local function newInput() + local input = { pressed = {}, down = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newStack() + return { + _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + clear = function(self) + while #self._items > 0 do self:pop() end + end, + } +end + +local function fixtureMon(level, fields) + local built = Mon.new(DATA, "CYNDAQUIL", level or 12, { + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + moves = { + { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "SURF", pp = 15, maxPp = 15 }, + }, + }) + for key, value in pairs(fields or {}) do built[key] = value end + return built +end + +-- Drives whatever is on top of the stack (party menu, move list, text box) +-- with an A press per frame until `predicate` answers true. +local function drive(game, predicate, frames) + for _ = 1, frames or 600 do + if predicate() then return true end + local top = game.stack:top() + if not top then return predicate() end + game.input:press("a") + if top.update then top:update(1 / 60) end + end + return predicate() +end + +-- --------------------------------------------- ItemEffects: HP restoration + +do + local mon = fixtureMon(12, { hp = 10 }) + local result = ItemEffects.useOnMon("POTION", mon, DATA) + eq(result.used, true, "a POTION on a hurt mon is spent") + eq(mon.hp, 30, "and heals HealingHPAmounts' 20") + eq(result.text, "CYNDAQUIL\nrecovered 20 HP!", "with the cart's line") + + local capped = fixtureMon(12, { hp = mon.maxHp - 5 }) + local capResult = ItemEffects.useOnMon("HYPER_POTION", capped, DATA) + eq(capResult.used, true, "a heal near full HP still lands") + eq(capped.hp, capped.maxHp, "capped at max HP") + eq(capResult.text, "CYNDAQUIL\nrecovered 5 HP!", "printing the real delta") + + local full = fixtureMon(12) + eq(ItemEffects.useOnMon("POTION", full, DATA).used, false, + "a full-HP target refuses (IsMonAtFullHealth)") + local fainted = fixtureMon(12, { hp = 0 }) + local faintResult = ItemEffects.useOnMon("POTION", fainted, DATA) + eq(faintResult.used, false, "a fainted target refuses (IsMonFainted)") + eq(faintResult.text, ItemEffects.TEXT_NO_EFFECT, + "with ItemWontHaveEffectText") +end + +-- ------------------------------------------------ ItemEffects: status heals + +do + local psn = fixtureMon(12, { status = "psn" }) + local result = ItemEffects.useOnMon("ANTIDOTE", psn, DATA) + eq(result.used, true, "ANTIDOTE cures poison") + eq(psn.status, nil, "the status byte clears") + eq(result.text, "CYNDAQUIL's\ncured of poison.", "with the party text") + + local brn = fixtureMon(12, { status = "brn" }) + eq(ItemEffects.useOnMon("ANTIDOTE", brn, DATA).used, false, + "ANTIDOTE refuses a burn (the mask does not intersect)") + eq(brn.status, "brn", "and leaves it standing") + + local slp = fixtureMon(12, { status = "sleep" }) + local healAll = ItemEffects.useOnMon("FULL_HEAL", slp, DATA) + eq(healAll.used, true, "FULL_HEAL's $ff mask takes any status") + eq(healAll.text, "CYNDAQUIL's\nhealth returned.", "as HEAL_ALL") + + local clean = fixtureMon(12) + eq(ItemEffects.useOnMon("FULL_HEAL", clean, DATA).used, false, + "a clean mon refuses a status heal") + + -- FullRestoreEffect: full HP falls through to FullyHealStatus. + local par = fixtureMon(12, { status = "par" }) + local restore = ItemEffects.useOnMon("FULL_RESTORE", par, DATA) + eq(restore.used, true, "FULL RESTORE at full HP still cures the status") + eq(par.status, nil, "clearing it") + local hurt = fixtureMon(12, { hp = 3, status = "psn" }) + local both = ItemEffects.useOnMon("FULL_RESTORE", hurt, DATA) + eq(both.used, true, "FULL RESTORE below full HP heals") + eq(hurt.hp, hurt.maxHp, "to full") + eq(hurt.status, nil, "and clears the status alongside (.FullRestore)") +end + +-- ---------------------------------------------------- ItemEffects: revives + +do + local down = fixtureMon(12, { hp = 0, status = "psn" }) + local result = ItemEffects.useOnMon("REVIVE", down, DATA) + eq(result.used, true, "REVIVE stands a fainted mon up") + eq(down.hp, math.floor(down.maxHp / 2), "at half max HP (ReviveHalfHP)") + eq(down.status, nil, "with the status wiped") + eq(result.text, "CYNDAQUIL\nis revitalized.", "and the cart's line") + + local down2 = fixtureMon(12, { hp = 0 }) + ItemEffects.useOnMon("MAX_REVIVE", down2, DATA) + eq(down2.hp, down2.maxHp, "MAX REVIVE restores full HP") + + local up = fixtureMon(12) + eq(ItemEffects.useOnMon("REVIVE", up, DATA).used, false, + "a standing mon refuses a REVIVE") + + local egg = fixtureMon(12, { isEgg = true, hp = 0 }) + local eggResult = ItemEffects.useOnMon("REVIVE", egg, DATA) + eq(eggResult.used, false, "an EGG refuses (UseItem_SelectMon's cp EGG)") + eq(eggResult.text, ItemEffects.TEXT_CANT_USE_ON_EGG, "with the EGG line") +end + +-- ------------------------------------------------- ItemEffects: RARE CANDY + +do + local mon = fixtureMon(11, { hp = 20 }) + local before = { max = mon.maxHp, exp = mon.experience } + local result = ItemEffects.useOnMon("RARE_CANDY", mon, DATA) + eq(result.used, true, "a RARE CANDY below MAX_LEVEL is spent") + eq(mon.level, 12, "one level up") + eq(mon.experience, Mon.experienceForLevel( + DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW, 12), + "experience SET to CalcExpAtLevel's threshold") + check(mon.maxHp > before.max, "stats recomputed at the new level") + eq(mon.hp, 20 + (mon.maxHp - before.max), + "current HP gains exactly the max-HP delta") + eq(result.text, "CYNDAQUIL grew to\nlevel 12!", "with the grew-to line") + eq(#result.learned, 1, "LearnLevelMoves offers the level-12 move") + eq(result.learned[1], "EMBER", "which is EMBER") + + local capped = fixtureMon(11, { level = Mon.MAX_LEVEL }) + eq(ItemEffects.useOnMon("RARE_CANDY", capped, DATA).used, false, + "MAX_LEVEL refuses (cp MAX_LEVEL / jp nc, NoEffectMessage)") +end + +-- ------------------------------------------------- ItemEffects: PP family + +do + local mon = fixtureMon(12) + eq(ItemEffects.usePpItem("ETHER", mon, 1).used, true, + "an ETHER on a drained slot is spent") + eq(mon.moves[1].pp, 35, "restoring 10 capped at max (30 + 10 > 35)") + eq(ItemEffects.usePpItem("ETHER", mon, 2).used, false, + "a slot already at max refuses (.dont_restore)") + + local berry = fixtureMon(12) + berry.moves[1].pp = 10 + ItemEffects.usePpItem("MYSTERYBERRY", berry, 1) + eq(berry.moves[1].pp, 15, "MYSTERYBERRY restores 5") + + local deep = fixtureMon(12) + deep.moves[1].pp = 0 + ItemEffects.usePpItem("MAX_ETHER", deep, 1) + eq(deep.moves[1].pp, 35, "MAX ETHER restores the whole slot") + + local elixer = fixtureMon(12) + elixer.moves[1].pp = 5 + elixer.moves[2].pp = 3 + local all = ItemEffects.usePpItem("ELIXER", elixer) + eq(all.used, true, "an ELIXER needs no slot") + eq(elixer.moves[1].pp, 15, "restoring 10 to every move") + eq(elixer.moves[2].pp, 13, "each capped by its own max") + eq(all.text, ItemEffects.TEXT_PP_RESTORED, "with PPRestoredText") + + local fullMon = fixtureMon(12) + fullMon.moves[1].pp = 35 + eq(ItemEffects.usePpItem("MAX_ELIXER", fullMon).used, false, + "a party mon with every slot full refuses the ELIXER family") +end + +-- ------------------------------------------------ Game2: the .Party wiring +-- The REAL flow: usePartyItem pushes the real Gen2PartyMenu, the pick runs +-- the effect, the consumption happens, and the message rides a real TextBox +-- whose dismissal leaves the state UNDER it alone (the say() teardown +-- contract). + +local function newHost(inventory, party) + local host = setmetatable({ + data = DATA, + save = { + player = { name = "GOLD" }, + party = party or { fixtureMon(12, { hp = 10 }) }, + inventory = inventory or {}, + options = {}, + }, + options = {}, + input = newInput(), + stack = newStack(), + }, { __index = Game2 }) + return host +end + +do + local host = newHost({ POTION = 2 }) + local packSentinel = { isPack = true } + host.stack:push(packSentinel) + host:useFieldItem("POTION") + local party = host.stack:top() + check(party ~= nil and party.prompt ~= nil, "USE pushes the party list") + eq(party.prompt, "Use on which <PK><MN>?", + "under UseOnWhichPKMNString") + -- Pick the first mon: the real PartyMenu update loop takes the A press. + drive(host, function() + return host.stack:top() ~= party + end) + local box = host.stack:top() + check(box ~= nil and box.pages ~= nil, "the pick lands a TextBox") + eq(host.save.party[1].hp, 30, "the POTION healed through the real menu") + eq(host.save.inventory.POTION, 1, "and one POTION left the pack") + drive(host, function() return host.stack:top() == packSentinel end) + eq(host.stack:top(), packSentinel, + "dismissing the message returns to the pack, not past it") +end + +do + -- The refusal path spends nothing. + local host = newHost({ POTION = 2 }, { fixtureMon(12) }) + host:useFieldItem("POTION") + local party = host.stack:top() + drive(host, function() return host.stack:top() ~= party end) + eq(host.save.inventory.POTION, 2, "a refused heal costs nothing") + local box = host.stack:top() + check(box ~= nil and box.pages ~= nil, "and prints the no-effect line") +end + +do + -- ETHER: party pick, then the move list, then the restore. + local mon = fixtureMon(12) + local host = newHost({ ETHER = 1 }, { mon }) + host:useFieldItem("ETHER") + local party = host.stack:top() + check(party ~= nil, "ETHER opens the party list") + drive(host, function() return host.stack:top() ~= party end) + local mover = host.stack:top() + check(mover ~= nil and mover.list ~= nil, + "the pick opens the move list (Gen2MoveDeleter's shared screen)") + drive(host, function() return host.stack:top() ~= mover end) + eq(mon.moves[1].pp, 35, "slot one restored through the real screens") + eq(host.save.inventory.ETHER, nil, "and the ETHER was spent") +end + +do + -- RARE CANDY: level line, then the learnset prompt for the level-12 move. + local mon = fixtureMon(11, { hp = 20 }) + mon.moves = { { id = "TACKLE", pp = 30, maxPp = 35 } } + local host = newHost({ RARE_CANDY = 1 }, { mon }) + host:useFieldItem("RARE_CANDY") + local party = host.stack:top() + drive(host, function() return host.stack:top() ~= party end) + eq(mon.level, 12, "the candy leveled through the real party menu") + drive(host, function() return host.stack:top() == nil end, 2000) + eq(#mon.moves, 2, "the level-12 move was learned on the way out") + eq(mon.moves[2].id, "EMBER", "and it is EMBER") + eq(host.save.inventory.RARE_CANDY, nil, "with the candy spent") +end + +-- ------------------------------------------------ TeachTMHM's LearnMove loop +-- engine/items/tmhm.asm TeachTMHM -> predef LearnMove (engine/pokemon/learn.asm): +-- a full moveset runs ForgetMove's ask / pick / "Stop learning" loop, and only +-- b = 1 pays HAPPINESS_LEARNMOVE and ConsumeTM. + +local function fourMoveMon() + local mon = fixtureMon(12) + mon.moves = { + { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "SURF", pp = 15, maxPp = 15 }, + { id = "SMOKESCREEN", pp = 20, maxPp = 20 }, + { id = "EMBER", pp = 25, maxPp = 25 }, + } + return mon +end + +-- One frame on whatever is on top, with the button this case answers with. +local function step(game, button) + local top = game.stack:top() + if not top then return nil end + game.input:press(button) + if top.update then top:update(1 / 60) end + return top +end + +local function firstLine(state) + return state and state.pages and state.pages[1] and state.pages[1][1] +end + +do + -- YES, then slot one: the TM lands and is spent. + local mon = fourMoveMon() + local host = newHost({ TM01 = 1 }, { mon }) + local packSentinel = { isPack = true } + host.stack:push(packSentinel) + host:useFieldItem("TM01") + local party = host.stack:top() + check(party ~= nil and party.prompt ~= nil, "a TM opens the party list") + drive(host, function() return host.stack:top() ~= party end) + check(firstLine(host.stack:top()) == "CYNDAQUIL is", + "a full moveset asks which move to forget, not a refusal") + drive(host, function() return host.stack:top() == packSentinel end, 3000) + eq(host.stack:top(), packSentinel, "the flow returns to the pack") + eq(mon.moves[1].id, "SWIFT", "the picked slot took the TM's move") + eq(#mon.moves, 4, "and the set is still four long") + eq(host.save.inventory.TM01, nil, "with the TM consumed (ConsumeTM)") +end + +do + -- NO, then NO to "Stop learning": `jp c, .loop` reopens the question. + local mon = fourMoveMon() + local host = newHost({ TM01 = 1 }, { mon }) + host:useFieldItem("TM01") + local party = host.stack:top() + drive(host, function() return host.stack:top() ~= party end) + local asks, seen = 0, {} + for _ = 1, 900 do + local top = host.stack:top() + if not top or asks >= 2 then break end + if not seen[top] and firstLine(top) == "CYNDAQUIL is" then + seen[top] = true + asks = asks + 1 + end + step(host, "b") + end + eq(asks, 2, "refusing to stop learning reopens the delete question") + eq(mon.moves[1].id, "TACKLE", "with the moveset untouched") + eq(host.save.inventory.TM01, 1, "and the TM still in the pack") +end + +do + -- NO, then YES to "Stop learning": DidNotLearnMoveText, b = 0, nothing spent. + local mon = fourMoveMon() + local host = newHost({ TM01 = 1 }, { mon }) + host:useFieldItem("TM01") + local party = host.stack:top() + drive(host, function() return host.stack:top() ~= party end) + local button = "b" + for _ = 1, 900 do + local top = host.stack:top() + if not top then break end + if firstLine(top) == "Stop learning" then button = "a" end + step(host, button) + end + eq(host.stack:top(), nil, "giving up closes the whole LearnMove flow") + eq(mon.moves[1].id, "TACKLE", "the moveset is untouched") + eq(#mon.moves, 4, "still four moves") + eq(host.save.inventory.TM01, 1, "and a refusal spends nothing") +end + +do + -- RARE CANDY into a full moveset: RareCandyEffect ends in LearnLevelMoves + -- (engine/items/item_effects.asm) whose .learn arm is `predef LearnMove` + -- (engine/pokemon/evolve.asm), so the level-12 move opens ForgetMove's ask. + local mon = fixtureMon(11, { hp = 20 }) + mon.moves = { + { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "SURF", pp = 15, maxPp = 15 }, + { id = "SMOKESCREEN", pp = 20, maxPp = 20 }, + { id = "SWIFT", pp = 20, maxPp = 20 }, + } + local host = newHost({ RARE_CANDY = 1 }, { mon }) + host:useFieldItem("RARE_CANDY") + local party = host.stack:top() + drive(host, function() return host.stack:top() ~= party end) + local asked = false + for _ = 1, 900 do + local top = host.stack:top() + if not top then break end + if firstLine(top) == "CYNDAQUIL is" then + asked = true + break + end + step(host, "a") + end + check(asked, "a candy's level-up move asks which move to forget") + eq(mon.level, 12, "with the level already banked") + -- YES, then slot one. + drive(host, function() return host.stack:top() == nil end, 2000) + eq(mon.moves[1].id, "EMBER", "the picked slot took the level-up move") + eq(#mon.moves, 4, "and the set is still four long") + eq(host.save.inventory.RARE_CANDY, nil, "with the candy spent") +end + +-- --------------------------------------------- PackMenu: the .Oak refusal + +do + local fieldWorld = { useFieldItem = function() return nil end } + local game = { input = newInput(), save = { + player = { name = "GOLD" }, + inventory = { X_ATTACK = 1 }, + options = {}, + }, data = DATA, stack = newStack() } + local chosen + local pack = PackMenu.new(game, { + save = game.save, items = DATA.items, world = fieldWorld, + onChoose = function(id) chosen = id end, + }) + pack:useSelected() + check(pack.message ~= nil, "a field-NOUSE item prints in the pack") + eq(pack.message[1], "OAK: {PLAYER}!", "OakThisIsntTheTimeText") + eq(chosen, nil, "and never reaches onChoose") + + -- The DUDE's tutorial pack has a stub world and must still hand its + -- field-NOUSE POKE BALL to the throw. + local dudeGame = { input = newInput(), save = { + player = { name = "GOLD" }, + inventory = { POKE_BALL = 1 }, + options = {}, + }, data = DATA, stack = newStack() } + local thrown + local dudePack = PackMenu.new(dudeGame, { + save = dudeGame.save, items = DATA.items, world = {}, pocket = "BALL", + onChoose = function(id) thrown = id end, + }) + dudePack:useSelected() + eq(thrown, "POKE_BALL", "the tutorial pack still throws") + eq(dudePack.message, nil, "with no Oak line in the way") +end + +-- ------------------------------------------------ say(): the teardown rule + +do + local host = newHost({}) + local under = { name = "under" } + host.stack:push(under) + host:say(Strings( + "An item in your\nPACK may be\fregistered for use\non SELECT Button.")) + local box = host.stack:top() + check(box ~= under, "say() pushes the box") + eq(#box.pages, 2, "MayRegisterItemText is two pages") + eq(box.pages[1][1], "An item in your", "page one, line one") + eq(box.pages[1][2], "PACK may be", "page one ends mid-sentence, as cart") + drive(host, function() return host.stack:top() == under end, 3000) + eq(host.stack:top(), under, + "dismissing pops the box alone: the state under it survives") + eq(#host.stack._items, 1, "and exactly one state remains") +end + +-- ------------------------------------------------- FieldMoves: DIG / TELEPORT + +do + local dig = FieldMoves.digFromMenu({ + environment = "CAVE", canEscapeRope = true, + }) + eq(dig.ok, true, "DIG works in a CAVE with a banked triple") + eq(dig.action, "dig", "queuing the dig action") + eq(FieldMoves.digFromMenu({ + environment = "ROUTE", canEscapeRope = true, + }).ok, false, "DIG refuses outdoors (.CheckCanDig)") + eq(FieldMoves.digFromMenu({ + environment = "CAVE", canEscapeRope = false, + }).ok, false, "and with a dead triple (.fail)") + + eq(FieldMoves.teleportFromMenu({ environment = "TOWN" }).ok, true, + "TELEPORT works in a TOWN") + eq(FieldMoves.teleportFromMenu({ environment = "ROUTE" }).ok, true, + "and on a ROUTE (CheckOutdoorMap)") + eq(FieldMoves.teleportFromMenu({ environment = "CAVE" }).ok, false, + "and refuses underground") + eq(FieldMoves.FROM_MENU.DIG, FieldMoves.digFromMenu, + "DIG is wired into the submenu table") + eq(FieldMoves.FROM_MENU.TELEPORT, FieldMoves.teleportFromMenu, + "and so is TELEPORT") +end + +-- -------------------------------------------- cache-backed World sections + +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local scripts = loadLua("data/generated/scripts.lua") + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") + +-- A world over the real defs, the tests/gen2_pokecenter_stairs_test.lua way: +-- setMap records instead of baking an image; everything else is shipped code. +local function world(mapId, x, y, save) + local game = { + data = { audio = { sfxOrder = {} }, tokens = DATA.tokens, + items = { ESCAPE_ROPE = { id = "ESCAPE_ROPE", index = 19 } } }, + save = save or { player = { name = "GOLD" }, options = {} }, + input = newInput(), + stack = newStack(), + } + local w = World.new(game) + w.maps, w.tilesets, w.scripts = maps, tilesets, scripts + w.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset]) + w.player = { cellX = x, cellY = y, facing = "down", moving = false } + w.loaded = nil + w.setMap = function(self, id, cx, cy, facing) + self.loaded = { id = id, x = cx, y = cy, facing = facing } + return true + end + return w, game +end + +local function pump(w) + for _ = 1, 64 do + if not w.mapSetup then return end + w:updateMapSetup() + end +end + +-- ESCAPE ROPE in a CAVE with the banked triple: consumed, queued, and the +-- queued action warps to the banked warp's own tile. +do + local save = { player = { name = "GOLD" }, options = {}, + inventory = { ESCAPE_ROPE = 2 } } + local w, game = world("UNION_CAVE_B2F", 5, 3, save) + w.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 } + local outcome = w:useEscapeRope("ESCAPE_ROPE") + eq(outcome, "escape_rope", "the rope succeeds in a CAVE") + eq(save.inventory.ESCAPE_ROPE, 1, + "UseDisposableItem takes one on success only") + check(w.queuedFieldMove ~= nil, "the script is QUEUED, not run in the pack") + check(w:runQueuedFieldMove(), "and the drain runs it") + local box = game.stack:top() + check(box ~= nil and box.pages ~= nil, "the used-rope line is up") + eq(box.pages[1][1], "GOLD used an", "addressed to the player") + drive(game, function() return game.stack:top() == nil end, 3000) + pump(w) + eq(w.loaded and w.loaded.id, "CHERRYGROVE_POKECENTER_1F", + "the warp pays out to the banked map") + local dest = maps.CHERRYGROVE_POKECENTER_1F.warps[3] + eq(w.loaded.x, dest.x, "on the banked warp's own x") + eq(w.loaded.y, dest.y, "and y") + eq(w.playerState, FieldMoves.PLAYER_NORMAL, + "with VAR_MOVEMENT back to PLAYER_NORMAL") +end + +-- The refusals: outdoors (TIN_TOWER_ROOF is environment ROUTE), and a CAVE +-- whose triple is dead. Neither takes the item. +do + local save = { player = { name = "GOLD" }, options = {}, + inventory = { ESCAPE_ROPE = 1 } } + local w = world("TIN_TOWER_ROOF", 9, 5, save) + eq(w:useEscapeRope("ESCAPE_ROPE"), "nowhere", + "TIN TOWER ROOF refuses: its environment is ROUTE") + eq(save.inventory.ESCAPE_ROPE, 1, "at no cost") + + local w2 = world("UNION_CAVE_B2F", 5, 3, save) + w2.backupWarp = nil + eq(w2:useEscapeRope("ESCAPE_ROPE"), "nowhere", + "a CAVE with no banked triple refuses (.fail on the zeroed triple)") + eq(save.inventory.ESCAPE_ROPE, 1, "still at no cost") + + local w3 = world("WHIRL_ISLAND_LUGIA_CHAMBER", 5, 3, save) + w3.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 } + eq(w3:useEscapeRope("ESCAPE_ROPE"), "escape_rope", + "the Lugia chamber (environment CAVE) accepts") +end + +-- DIG from the party submenu shares the whole path through the REAL +-- fieldContext. +do + local save = { player = { name = "GOLD" }, options = {} } + local w = world("UNION_CAVE_B2F", 5, 3, save) + w.backupWarp = { map = "CHERRYGROVE_POKECENTER_1F", warp = 3 } + local result = w:useFieldMove("DIG", { nickname = "SANDSHREW" }) + eq(result and result.ok, true, "DIG succeeds where the rope does") + check(w.queuedFieldMove ~= nil, "and queues the same escape action") + eq(w.queuedFieldMove.action, "dig", "as dig") + + local w2 = world("TIN_TOWER_ROOF", 9, 5, save) + local refused = w2:useFieldMove("DIG", { nickname = "SANDSHREW" }) + eq(refused and refused.ok, false, "and refuses outdoors") +end + +-- TELEPORT queues the whiteout-spawn return from an outdoor map. +do + local save = { player = { name = "GOLD" }, options = {} } + local w = world("ROUTE_36", 10, 10, save) + local result = w:useFieldMove("TELEPORT", { nickname = "ABRA" }) + eq(result and result.ok, true, "TELEPORT works on a ROUTE") + eq(w.queuedFieldMove.action, "teleport", "queuing the teleport action") + local w2 = world("UNION_CAVE_B2F", 5, 3, save) + local refused = w2:useFieldMove("TELEPORT", { nickname = "ABRA" }) + eq(refused and refused.ok, false, "and refuses in a cave") +end + +-- SQUIRTBOTTLE: facing the Route 36 Sudowoodo, the queued script IS the +-- extracted WateredWeirdTreeScript slice -- battle and all; anywhere else +-- the queued script is the "nothing happened" line. +do + local save = { player = { name = "GOLD" }, options = {}, + inventory = { SQUIRTBOTTLE = 1 } } + -- The tree is object index 3 of the extracted ROUTE_36: movement 23 + -- (SPRITEMOVEDATA_SUDOWOODO) at (35, 9). + local treeDef + for _, obj in ipairs(maps.ROUTE_36.objects or {}) do + if obj.movement == 23 then treeDef = obj end + end + check(treeDef ~= nil, "the extracted ROUTE_36 carries the Sudowoodo object") + eq(treeDef and treeDef.scriptKey, "4b:61aa", + "whose talk script is the SudowoodoScript body") + + local w = world("ROUTE_36", treeDef.x, treeDef.y - 1, save) + w.player.facing = "down" + w.npcs = { { cellX = treeDef.x, cellY = treeDef.y, def = treeDef } } + eq(w:useSquirtbottle(), "squirtbottle", "facing the tree succeeds") + local rows = w.queuedScript + check(rows ~= nil, "_Squirtbottle QUEUES the script") + eq(rows[1] and rows[1].op, "opentext", + "starting at WateredWeirdTreeScript's own first row") + local sawBattle, sawWild + for _, cmd in ipairs(rows) do + if cmd.op == "startbattle" then sawBattle = true end + if cmd.op == "loadwildmon" and cmd.species == 185 and cmd.level == 20 then + sawWild = true + end + end + check(sawWild, "the slice carries loadwildmon SUDOWOODO, 20") + check(sawBattle, "and the startbattle") + eq(save.inventory.SQUIRTBOTTLE, 1, "a key item is never consumed") + + -- Wrong facing: the same tile relationship but looking away. + local away = world("ROUTE_36", treeDef.x, treeDef.y - 1, save) + away.player.facing = "up" + away.npcs = w.npcs + eq(away:useSquirtbottle(), "squirtbottle", + "wItemEffectSucceeded is 1 unconditionally: the pack still quits") + local nothing = away.queuedScript + local isNothing + for _, cmd in ipairs(nothing or {}) do + if cmd.op == "rawtext" then isNothing = true end + end + check(isNothing, "but the queued script is the nothing-happened line") +end + +-- ---- specialsound picks its cue off the item's POCKET ---------------------- +-- +-- Script_specialsound (engine/overworld/scripting.asm:476) farcalls +-- CheckItemPocket over wCurItem and loads SFX_GET_TM ($9b) for the TM/HM pocket, +-- SFX_ITEM ($01) for everything else. It is the sound inside GiveItemScript, so +-- every `verbosegiveitem` rings it -- SproutTower3F's SageLiScript does +-- `verbosegiveitem HM_FLASH` and must ring the TM jingle. The port used to +-- throw the item argument away and play SFX_ITEM unconditionally. +do + local World = require("src.world.gen2.World") + local rung = {} + local sfxData = { + -- audio/sfx_pointers.asm order, only as far as the two cues need. + audio = { sfx = {}, sfxOrder = {} }, + items = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18 }, + HM_FLASH = { id = "HM_FLASH", name = "HM05", pocket = "TM_HM", + index = 247 }, + }, + } + for i = 0, 0x9b do sfxData.audio.sfxOrder[i + 1] = "Sfx_Unused" end + sfxData.audio.sfxOrder[2] = "Sfx_Item" + sfxData.audio.sfxOrder[0x9b + 1] = "Sfx_GetTm" + local w = World.new({ data = sfxData, save = { player = {}, inventory = {} } }) + w.playSfx = function(_, id) rung[#rung + 1] = id end + + w:specialSound(247) + eq(rung[1], 0x9b, "an HM rings SFX_GET_TM, not the ordinary item jingle") + w:specialSound(18) + eq(rung[2], 1, "and a POTION still rings SFX_ITEM") + -- Script_specialsound has no operand of its own: it reads wCurItem, and a + -- slot the cache cannot name falls to the `cp TM_HM / jr z` default. + w:specialSound(nil) + eq(rung[3], 1, "with no item known, the fall-through is SFX_ITEM") + w:specialSound(0xfe) + eq(rung[4], 1, "and so is an item index nothing in the cache names") +end + +S.finish() diff --git a/tests/gen2_fishing_swarm_test.lua b/tests/gen2_fishing_swarm_test.lua new file mode 100644 index 00000000..7d63ce70 --- /dev/null +++ b/tests/gen2_fishing_swarm_test.lua @@ -0,0 +1,226 @@ +-- The fishing half of a swarm (engine/events/fish.asm). +-- +-- luajit tests/gen2_fishing_swarm_test.lua (ROM-free; the cache facts SKIP +-- without a gold cache) +-- +-- Fish calls GetFishGroupIndex before it indexes FishGroups, and that routine +-- is the ONLY reader of wFishingSwarmFlag: a map whose MAP_FISHGROUP is +-- FISHGROUP_QWILFISH rolls FISHGROUP_QWILFISH_SWARM instead while the flag says +-- FISHSWARM_QWILFISH, and the same for Remoraid. Nothing about the map header +-- changes, which is why the phone call that starts a swarm is one setval and +-- one special. +-- +-- The port wired swarms into the grass and water tables (_SwarmWildmonCheck, +-- src/core/gen2/Roamers.lua Swarm.tables) and left fishing reading the header +-- alone, so `special ActivateFishingSwarm` had no reader at all: the Route 32 +-- Qwilfish swarm and the Route 44 Remoraid swarm never reached a rod. +-- +-- Everything below goes through World:rollFishing, which is what World:useRod +-- and World:tryFishing both call, rather than through Encounter directly. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 fishing swarm") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local Encounter = require("src.battle.gen2.Encounter") +local Roamers = require("src.core.gen2.Roamers") +local Permissions = require("src.world.gen2.Permissions") + +local COLL_FLOOR, COLL_WATER = 0x00, 0x29 +local MAP_W, MAP_H = 10, 10 + +-- constants/script_constants.asm. +eq(Encounter.FISHSWARM_NONE, 0, "FISHSWARM_NONE is 0") +eq(Encounter.FISHSWARM_QWILFISH, 1, "FISHSWARM_QWILFISH is 1") +eq(Encounter.FISHSWARM_REMORAID, 2, "FISHSWARM_REMORAID is 2") +eq(Roamers.Swarm.FISH_QWILFISH, Encounter.FISHSWARM_QWILFISH, + "and the save-side names agree with them") +eq(Roamers.Swarm.FISH_REMORAID, Encounter.FISHSWARM_REMORAID, "for both") + +-- One always-hit row per list, so the ROLL is never what these assert: whatever +-- comes back names the group it was rolled from. +local function only(species) + local list = { { chance = 256, species = species, level = 10 } } + return { old = list, good = list, super = list } +end + +local ENCOUNTERS = { + fishGroups = { + FISHGROUP_POND = only("MAGIKARP"), + FISHGROUP_QWILFISH = only("MAGIKARP"), + FISHGROUP_QWILFISH_SWARM = only("QWILFISH"), + FISHGROUP_QWILFISH_NO_SWARM = only("MAGIKARP"), + FISHGROUP_REMORAID = only("GOLDEEN"), + FISHGROUP_REMORAID_SWARM = only("REMORAID"), + }, +} + +local function mon(name) + return { + name = name, types = { "WATER", "WATER" }, + baseStats = { hp = 20, attack = 10, defense = 55, speed = 80, + specialAttack = 15, specialDefense = 20 }, + levelMoves = { { level = 1, move = "SPLASH" } }, + } +end + +local DATA = { + items = {}, + moves = { SPLASH = { name = "SPLASH", pp = 40 } }, + pokemon = { + MAGIKARP = mon("MAGIKARP"), QWILFISH = mon("QWILFISH"), + GOLDEEN = mon("GOLDEEN"), REMORAID = mon("REMORAID"), + }, +} + +-- A world with one water tile at (5,4) and the player facing it from (5,5), +-- which is the only geometry .TryFish reads. +local function fishWorld(mapId, fishGroup) + local cells = { [4 * 100 + 5] = COLL_WATER } + local map + map = { + id = mapId, + width = MAP_W, height = MAP_H, + def = { bgEvents = {}, objects = {}, width = MAP_W, height = MAP_H, + environment = "ROUTE", fishGroup = fishGroup }, + cellCollision = function(_, x, y) return cells[y * 100 + x] or COLL_FLOOR end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2 + end, + isWalkable = function(_, x, y) + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + } + local game = { + data = DATA, + save = { player = { name = "GOLD" }, party = {}, inventory = {} }, + } + local world = World.new(game) + game.world = world + world.map = map + world.maps = { [mapId] = map.def } + world.encounters = ENCOUNTERS + world.player = { + cellX = 5, cellY = 5, px = 80, py = 80, facing = "up", moving = false, + turnArmed = true, update = function() return false end, + setSprite = function() end, + } + world.pollTimeOfDay = function() end + world.vm = { running = function() return false end, update = function() end } + return world, game +end + +local function hooked(world, rod) + local outcome, wild = world:rollFishing(rod or "OLD_ROD") + return outcome, wild and wild.species +end + +-- ---- ROUTE_32, the Qwilfish swarm ----------------------------------------- +do + local world, game = fishWorld("ROUTE_32", "FISHGROUP_QWILFISH") + local outcome, species = hooked(world) + eq(outcome, "battle", "the rod hooks something off the map's own group") + eq(species, "MAGIKARP", "which is FISHGROUP_QWILFISH with no swarm running") + + -- `special ActivateFishingSwarm` with wScriptVar = FISHSWARM_QWILFISH: the + -- save write the port already had, and the ONLY thing that changes here. + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_QWILFISH) + outcome, species = hooked(world) + eq(outcome, "battle", "the rod still hooks something during the swarm") + eq(species, "QWILFISH", "and it is FISHGROUP_QWILFISH_SWARM's list") + for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do + local _, got = hooked(world, rod) + eq(got, "QWILFISH", "the swap is per GROUP, so it covers the " .. rod) + end + + -- The Remoraid swarm is a different flag value and must not touch this map. + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_REMORAID) + local _, other = hooked(world) + eq(other, "MAGIKARP", "a Remoraid swarm leaves Route 32 alone") + + -- CheckSwarmFlag is what ends it: the daily reset drops DAILYFLAGS1_SWARM and + -- the next poll clears wFishingSwarmFlag with it. + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_QWILFISH) + game.save.dailyFlags.swarm = false + eq(Roamers.Swarm.check(game.save), 1, "CheckSwarmFlag answers 1 once it is down") + local _, ended = hooked(world) + eq(ended, "MAGIKARP", "and the rod is back on the map's own group") +end + +-- ---- ROUTE_44, the Remoraid swarm ----------------------------------------- +do + local world, game = fishWorld("ROUTE_44", "FISHGROUP_REMORAID") + local _, species = hooked(world) + eq(species, "GOLDEEN", "Route 44 fishes FISHGROUP_REMORAID by default") + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_REMORAID) + local _, swarmed = hooked(world) + eq(swarmed, "REMORAID", "and FISHGROUP_REMORAID_SWARM while the swarm runs") + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_QWILFISH) + local _, wrong = hooked(world) + eq(wrong, "GOLDEEN", "a Qwilfish swarm leaves Route 44 alone") +end + +-- ---- the groups the substitution must NOT touch --------------------------- +do + -- FISHGROUP_QWILFISH_NO_SWARM is a map header value of its own, not a state + -- of FISHGROUP_QWILFISH: GetFishGroupIndex compares against FISHGROUP_QWILFISH + -- and FISHGROUP_REMORAID and nothing else. + local world, game = fishWorld("ROUTE_33", "FISHGROUP_QWILFISH_NO_SWARM") + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_QWILFISH) + local _, species = hooked(world) + eq(species, "MAGIKARP", + "FISHGROUP_QWILFISH_NO_SWARM never becomes a swarm group") + + local pond, pondGame = fishWorld("ROUTE_34", "FISHGROUP_POND") + Roamers.Swarm.setFishing(pondGame.save, Roamers.Swarm.FISH_QWILFISH) + local _, caught = hooked(pond) + eq(caught, "MAGIKARP", "and an ordinary pond is untouched by either swarm") +end + +-- ---- a cache with no swarm rows ------------------------------------------- +do + local world, game = fishWorld("ROUTE_32", "FISHGROUP_QWILFISH") + world.encounters = { fishGroups = { FISHGROUP_QWILFISH = only("MAGIKARP") } } + Roamers.Swarm.setFishing(game.save, Roamers.Swarm.FISH_QWILFISH) + local outcome, species = hooked(world) + eq(outcome, "battle", "an older cache still bites") + eq(species, "MAGIKARP", "off the map's own group rather than off nothing") +end + +-- ---- the group names in the real cache ------------------------------------ +do + 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 encChunk = loadfile(cache .. "/data/generated/encounters.lua") + local mapChunk = loadfile(cache .. "/data/generated/maps.lua") + if not (encChunk and mapChunk) then + check(true, "no gold cache: fish group names (SKIP)") + else + local groups = (encChunk() or {}).fishGroups or {} + local maps = mapChunk() + for _, name in ipairs({ "FISHGROUP_QWILFISH", "FISHGROUP_QWILFISH_SWARM", + "FISHGROUP_REMORAID", "FISHGROUP_REMORAID_SWARM" }) do + check(groups[name] ~= nil, "the cache carries " .. name) + end + eq((maps.ROUTE_32 or {}).fishGroup, "FISHGROUP_QWILFISH", + "ROUTE_32's header names the Qwilfish group") + eq((maps.ROUTE_44 or {}).fishGroup, "FISHGROUP_REMORAID", + "and ROUTE_44's the Remoraid one") + -- The substitution is resolved against the cache's own group table. + eq(Encounter.fishGroupFor({ fishGroups = groups }, "FISHGROUP_QWILFISH", + Encounter.FISHSWARM_QWILFISH), "FISHGROUP_QWILFISH_SWARM", + "so the real Route 32 header swaps") + eq(Encounter.fishGroupFor({ fishGroups = groups }, "FISHGROUP_REMORAID", + Encounter.FISHSWARM_REMORAID), "FISHGROUP_REMORAID_SWARM", + "and so does the real Route 44 one") + end +end + +S.finish() diff --git a/tests/gen2_fly_map_test.lua b/tests/gen2_fly_map_test.lua new file mode 100644 index 00000000..d31650b4 --- /dev/null +++ b/tests/gen2_fly_map_test.lua @@ -0,0 +1,160 @@ +-- FLY's destination picker (pokegold engine/pokegear/pokegear.asm _FlyMap). +-- +-- _FlyMap is the town map on its OWN screen: the same LoadTownMapGFX art the +-- POKeGEAR's MAP card draws, with TownMapBubble's "Where?" plate instead of the +-- card strip and no ENGINE_MAP_CARD gate at all. The cursor walks the +-- Flypoints table between wStartFlypoint and wEndFlypoint, skipping every row +-- CheckIfVisitedFlypoint rejects; A takes the row under it and B answers -1. +-- luajit tests/gen2_fly_map_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 fly map") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +require("src.core.Logger").warn = function() end + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Pokegear = require("src.ui.gen2.Pokegear") + +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, for the rows the picker names and parks on. +-- KANTO_LANDMARK is $2e = 46, and Pokegear:region reads the PLAYER's landmark +-- index against it, so the Kanto rows have to sit above it here too. +local LANDMARKS = { landmarks = {}, order = {} } +for i, row in ipairs(FieldMoves.FLYPOINTS) do + local index = i < FieldMoves.KANTO_FLYPOINT and i + or (46 + i - FieldMoves.KANTO_FLYPOINT) + LANDMARKS.landmarks[row.landmark] = { + index = index, name = row.landmark:gsub("^LANDMARK_", ""), x = 8, y = 8, + } + LANDMARKS.order[index] = row.landmark +end + +local function visited(...) + local save = { engineFlags = {} } + for _, spawn in ipairs({ ... }) do + for _, row in ipairs(FieldMoves.FLYPOINTS) do + if row.spawn == spawn then save.engineFlags[row.flag] = true end + end + end + return save +end + +-- The gate World:openFlyMap reads before it pushes this screen instead of +-- falling back to its yes/no chain. +check(Pokegear.FLY_MAP == true, "Pokegear now declares a fly mode") + +local function flyScreen(save, opts) + opts = opts or {} + local input = fakeInput() + local points = FieldMoves.flyPoints(save, LANDMARKS, opts.region or "johto") + local chosen, closed + local screen = Pokegear.new({ input = input, save = save }, { + save = save, + landmarks = LANDMARKS, + currentLandmark = opts.currentLandmark, + fly = points, + onFly = function(spawn) chosen = spawn end, + onClose = function() closed = true end, + }) + return screen, input, points, + function() return chosen end, function() return closed end +end + +-- ---------------------------------------------------------------- the screen +do + local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET", "SPAWN_GOLDENROD") + local screen, input, points, chosen, closed = flyScreen(save) + eq(#points, 3, "only the visited flypoints are offered") + eq(#screen.cards, 1, "the picker is one screen, not the card strip") + eq(screen.mode, "card", "and it opens straight onto the map") + -- FlyMap's Johto default is JOHTO_FLYPOINT, the first row (New Bark Town). + eq(screen.flyIndex, 1, "the Johto map opens on the first flypoint") + eq(screen:flyRow().spawn, "SPAWN_NEW_BARK", "which is New Bark Town") + -- The MAP card's cursor follows the flypoint, so the name plate and the + -- arrow both land on the row rather than on a free landmark. + eq(screen:mapCursorIndex(), LANDMARKS.landmarks.LANDMARK_NEW_BARK_TOWN.index, + "and the map cursor sits on it") + + input:press("up") + screen:update(0) + eq(screen:flyRow().spawn, "SPAWN_VIOLET", ".ScrollNext takes the next row") + eq(screen:mapCursorIndex(), LANDMARKS.landmarks.LANDMARK_VIOLET_CITY.index, + "and the cursor moves with it") + input:press("up") + screen:update(0) + input:press("up") + screen:update(0) + eq(screen:flyRow().spawn, "SPAWN_NEW_BARK", "and it wraps at wEndFlypoint") + input:press("down") + screen:update(0) + eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", + ".ScrollPrev wraps the other way at wStartFlypoint") + + -- Left and right do nothing: there is no card to page to on this screen. + input:press("right") + screen:update(0) + eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", "right does not page the gear") + input:press("left") + screen:update(0) + eq(screen:flyRow().spawn, "SPAWN_GOLDENROD", "and neither does left") + eq(#screen.cards, 1, "the strip is still one row") + + input:press("a") + screen:update(0) + eq(chosen(), "SPAWN_GOLDENROD", "A takes the flypoint under the cursor") + check(not closed(), "and does not also close the screen") +end + +do + -- `ld a, -1`: B answers "no flypoint" and the caller drops it. + local save = visited("SPAWN_NEW_BARK") + local screen, input, _points, chosen, closed = flyScreen(save) + input:press("b") + screen:update(0) + check(closed(), "B leaves the picker") + eq(chosen(), nil, "with nothing chosen") +end + +do + -- .KantoFlyMap opens on the LAST row (Indigo Plateau), and the Kanto half is + -- withheld entirely until Indigo is visited. + local save = visited("SPAWN_NEW_BARK", "SPAWN_PALLET", "SPAWN_INDIGO") + local screen = flyScreen(save, { + region = "kanto", currentLandmark = "LANDMARK_PALLET_TOWN", + }) + eq(screen.flyIndex, #screen.fly, "the Kanto map opens on the last flypoint") + eq(screen:flyRow().spawn, "SPAWN_INDIGO", "which is Indigo Plateau") +end + +do + local save = visited("SPAWN_NEW_BARK", "SPAWN_PALLET") + local points = FieldMoves.flyPoints(save, LANDMARKS, "kanto") + eq(#points, 1, "with no Indigo the Kanto half is withheld") + eq(points[1].spawn, "SPAWN_NEW_BARK", "and the Johto map is shown instead") +end + +-- Drawing must not throw: the plain path (no town-map art in the cache) is +-- what a headless run and an older cache take. +do + local save = visited("SPAWN_NEW_BARK", "SPAWN_VIOLET") + local screen = flyScreen(save) + local ok, err = pcall(function() screen:drawPanel() end) + check(ok, "the unstyled fly picker draws (" .. tostring(err) .. ")") +end + +S.finish() diff --git a/tests/gen2_font_ui_test.lua b/tests/gen2_font_ui_test.lua new file mode 100644 index 00000000..af499a5d --- /dev/null +++ b/tests/gen2_font_ui_test.lua @@ -0,0 +1,401 @@ +-- Glyph pages and the four screens that read them. +-- +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_font_ui_test.lua +-- +-- The $60-$7f slot holds ONE of two sheets (engine/gfx/load_font.asm): the +-- normal FontExtra, or FontBattleExtra where the same codes are different +-- glyphs -- $73 is a closing quote in one and the "ID" ligature in the other, +-- $74 a middle dot and the "No" ligature. A screen that places either +-- ligature has to have swapped the sheet in first, and LoadFontsBattleExtra +-- swaps only 25 tiles before `jr LoadFrame` puts the textbox frame back, so +-- the border glyphs are the SAME on both. +-- +-- '#' is not a glyph at all: charmap.asm gives it $54 and home/text.asm +-- places the four characters "POKé" for it, which is how the cart's own +-- strings spell POKéMON (data/credits_strings.asm Credits_Staff). +-- +-- The screen half of the suite is layout that a placement list cannot show: +-- which SHEET a print resolves against, which cells a box border may not +-- keep, and how tall a menu window is for the rows actually in it. Every +-- assertion runs a real `drawPanel` with the chrome recorded. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or {} +love.graphics = love.graphics or {} +local G = love.graphics +G.getColor = G.getColor or function() return 1, 1, 1, 1 end +G.setColor = G.setColor or function() end +G.rectangle = G.rectangle or function() end +G.print = G.print or function() end +G.printf = G.printf or function() end +G.draw = G.draw or function() end +G.newQuad = G.newQuad or function() return {} end +G.newImage = G.newImage or function() return nil end +G.getShader = G.getShader or function() return nil end +G.setShader = G.setShader or function() end +G.newShader = G.newShader or function() error("no shaders in this harness") end +G.getDimensions = G.getDimensions or function() return 160, 144 end +G.push = G.push or function() end +G.pop = G.pop or function() end +G.translate = G.translate or function() end +G.scale = G.scale or function() end +G.circle = G.circle or function() end +G.clear = G.clear or function() end +G.setLineWidth = G.setLineWidth or function() end +G.getFont = G.getFont or function() return nil end +G.setFont = G.setFont or function() end +love.math = love.math or { random = function(a, b) return b and a or 0.5 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local S = require("tests.harness").suite("gen2 font + ui chrome") +local check, eq = S.check, S.eq + +local Chrome = require("src.ui.gen2.Chrome") +local Credits = require("src.ui.gen2.Credits") +local Font = require("src.render.Font") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local PcMenu = require("src.ui.gen2.PcMenu") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +-- --------------------------------------------------------------- recording +-- +-- One log for boxes, prints and raw fills, so ORDER can be asserted: a cell +-- blanked before the string that owns it lands is the whole point. + +local log = {} +local real = {} + +local function startRecording() + log = {} + real.print, real.box, real.cursor = Chrome.print, Chrome.box, Chrome.cursor + real.clear, real.rect = Chrome.clear, love.graphics.rectangle + Chrome.print = function(text, tx, ty) + log[#log + 1] = { kind = "print", text = tostring(text), x = tx, y = ty, + battleExtra = Font.battleExtraActive() } + return 0 + end + Chrome.box = function(tx, ty, tw, th) + log[#log + 1] = { kind = "box", x = tx, y = ty, w = tw, h = th } + end + Chrome.cursor = function(tx, ty) + log[#log + 1] = { kind = "cursor", x = tx, y = ty } + end + Chrome.clear = function() log[#log + 1] = { kind = "clear" } end + love.graphics.rectangle = function(mode, x, y, w, h) + log[#log + 1] = { kind = "fill", mode = mode, x = x, y = y, w = w, h = h } + end +end + +local function stopRecording() + Chrome.print, Chrome.box, Chrome.cursor = real.print, real.box, real.cursor + Chrome.clear, love.graphics.rectangle = real.clear, real.rect +end + +local function find(kind, predicate) + for i, entry in ipairs(log) do + if entry.kind == kind and (not predicate or predicate(entry)) then + return entry, i + end + end + return nil, nil +end + +local function findPrint(text) + return find("print", function(e) return e.text == text end) +end + +-- A fill that covers the whole of a tile-coordinate rectangle. +local function coveringFill(tx, ty, tw, th) + return find("fill", function(e) + return e.mode == "fill" and e.x <= tx * 8 and e.y <= ty * 8 + and e.x + e.w >= (tx + tw) * 8 and e.y + e.h >= (ty + th) * 8 + -- the panel-wide clear is not a cell blank + and e.w < 20 * 8 + end) +end + +-- --------------------------------------------------------------- the sheets +-- +-- Real cache, real charmap: the codes and the page routing are the thing +-- under test, so a fixture font would assert nothing. + +local cacheDir = os.getenv("GOLD_CACHE") +if not cacheDir or cacheDir == "" then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" +end + +local fontDef = loadfile(cacheDir .. "/data/generated/font.lua") +fontDef = fontDef and fontDef() + +-- Font is a singleton and this suite is also dofile'd into +-- tests/run_tests.lua, which put Red's Data through it; that goes back at the +-- end of the section. +local haveData, Data = pcall(require, "src.core.Data") +haveData = haveData and type(Data) == "table" and type(Data.font) == "table" + +if not fontDef then + check(true, "no Gold cache (SKIP the glyph page checks)") +else + local images = {} + local realImage = require("src.render.Assets").image + require("src.render.Assets").image = function(path) + images[path] = images[path] or + { path = path, getDimensions = function(self) return 128, 16 end } + if path:find("font%.png$") then + images[path].getDimensions = function() return 128, 64 end + end + return images[path] + end + Font.load({ font = fontDef }) + require("src.render.Assets").image = realImage + + -- Which sheet a code lands on, by the image drawCode reaches for. + local drawn + local realDraw = love.graphics.draw + love.graphics.draw = function(image) drawn = image and image.path end + local function sheetOf(code) + drawn = nil + Font.drawCode(code, 0, 0) + return (drawn or "none"):match("[^/]+$") + end + + Font.useBattleExtra(false) + eq(sheetOf(0x73), "font_extra.png", "$73 is FontExtra's closing quote by default") + eq(sheetOf(0x74), "font_extra.png", "$74 is FontExtra's middle dot by default") + + Font.useBattleExtra(true) + eq(sheetOf(0x73), "font_battle_extra.png", + "LoadFontsBattleExtra puts the <ID> ligature at $73") + eq(sheetOf(0x74), "font_battle_extra.png", + "and the No ligature at $74") + eq(sheetOf(0x78), "font_battle_extra.png", + "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)) + end + Font.useBattleExtra(false) + love.graphics.draw = realDraw + + -- '#' places "POKé": four glyphs, on the byte the command sits on. + local codes = Font.encode("#MON") + eq(#codes, 7, "'#MON' is seven glyphs") + eq(codes[1], 0x8F, "P") + eq(codes[2], 0x8E, "O") + eq(codes[3], 0x8A, "K") + eq(codes[4], 0xEA, "é") + eq(codes[5], 0x8C, "M") + eq(codes[7], 0x8D, "N") + eq(Font.width("#MON"), 7 * 8, "and seven tiles wide") + local spans = Font.split("#MON") + eq(spans[1].from, 1, "the expansion stays on the '#' byte") + eq(spans[4].to, 1, "all four of it, so a cut never lands inside") + eq(spans[5].from, 2, "the next glyph resumes after the command") + + -- Credits_Staff is `db " #MON"` verbatim; six spaces centre the seven + -- tiles POKéMON occupies on the 20-tile row. + local staff = Credits.STRINGS[Credits.STAFF] + eq(type(staff), "table", "the STAFF heading is three lines") + eq(#Font.split(staff[1]), 13, "its first line is six spaces plus POKéMON") + local line = Font.encode(staff[1]) + eq(line[7], 0x8F, "which starts at column 6 with a P") + eq(line[10], 0xEA, "and carries the é of POKé") + + -- The Hall of Fame roster line: '№' '.' then the dex number, and + -- '<ID>' '№' '/' then the trainer id (halloffame.asm :472 and :504). + eq(Font.encode("№.")[1], 0x74, "the roster's No ligature is $74") + local id = Font.encode("<ID>№/") + eq(#id, 3, "IDNo/ is three tiles") + eq(id[1], 0x73, "<ID> is $73") + eq(id[2], 0x74, "No is $74") + + if haveData then Font.load(Data) end +end + +-- ------------------------------------------------------- naming keyboard +-- +-- data/text/name_input_chars.asm: the last row of NameInputUpper is +-- "lower DEL END" and the last row of NameInputLower is +-- "UPPER DEL END". The case target names the board it switches TO. + +local naming = NamingScreen.new({}, { type = "player" }) +eq(naming.lower, false, "the keyboard opens on the uppercase board") + +startRecording() +naming:drawPanel() +stopRecording() +local caseLabel = find("print", function(e) return e.y == 16 and e.x == 2 end) +eq(caseLabel and caseLabel.text, "lower", + "the uppercase board offers 'lower'") +check(findPrint("A") ~= nil, "and it really is the uppercase board") +eq((find("print", function(e) return e.y == 16 and e.x == 9 end) or {}).text, + "DEL", "DEL keeps its column") +eq((find("print", function(e) return e.y == 16 and e.x == 15 end) or {}).text, + "END", "END keeps its column") + +naming:toggleCase() +startRecording() +naming:drawPanel() +stopRecording() +eq((find("print", function(e) return e.y == 16 and e.x == 2 end) or {}).text, + "UPPER", "the lowercase board offers 'UPPER'") +check(findPrint("a") ~= nil, "and it really is the lowercase board") + +-- ------------------------------------------------- summary move detail +-- +-- SetUpMoveScreenBG draws Textbox (0,1) and Textbox (0,11) and only THEN +-- places the nickname at (5,1) and PlaceMoveData the TYPE plaque at (0,10) / +-- (0,11). Both land on a border row of a box already on screen, and a +-- tilemap write replaces the cell it lands on. + +local MOVES = { + LEER = { name = "LEER", type = "NORMAL", power = 0, + description = "Reduces the foe's<NEXT>DEFENSE." }, +} +local CYNDA = { + species = "CYNDAQUIL", nickname = "CYNDAQUIL", level = 22, hp = 50, + maxHp = 50, stats = {}, otName = "GOLD", otId = 52049, + moves = { { id = "LEER", pp = 27, maxPp = 30 } }, +} +local summary = SummaryMenu.new({ data = { moves = MOVES } }, + { party = { CYNDA }, index = 1, moves = MOVES }) +summary.moveDetail = true + +startRecording() +summary:drawMoveDetail() +stopRecording() + +local header, headerAt = findPrint("CYNDAQUIL") +check(header ~= nil and header.y == 1 and header.x == 5, + "the nickname is placed at (5,1), the upper box's top border row") +local upperBox, upperAt = find("box", function(e) return e.y == 1 end) +check(upperBox ~= nil, "the upper box is drawn") +-- "CYNDAQUIL" is 9 tiles and "<LV>22" three, so the header owns (5,1)-(16,1). +local headerClear, headerClearAt = coveringFill(5, 1, 12, 1) +check(headerClear ~= nil, "the cells the header owns are blanked") +check(upperAt and headerClearAt and upperAt < headerClearAt, + "after the box goes down, the way SetUpMoveScreenBG orders them") +check(headerClearAt and headerAt and headerClearAt < headerAt, + "and before the header prints, so no border runs through the letters") + +local plaque, plaqueAt = findPrint("│TYPE/└") +check(plaque ~= nil and plaque.x == 0 and plaque.y == 11, + "String_MoveType_Bottom sits on the lower box's top border row") +local plaqueClear, plaqueClearAt = coveringFill(0, 10, 7, 2) +check(plaqueClear ~= nil, "the seven cells of the TYPE plaque are blanked") +check(plaqueClearAt and plaqueAt and plaqueClearAt < plaqueAt, + "before either of its two lines prints") + +-- Nothing outside the strings' own cells is touched: the move list keeps the +-- box it is drawn in. +check(coveringFill(1, 3, 8, 1) == nil, "the move rows are not blanked") + +-- ---------------------------------------------------------- storage menu +-- +-- _BillsPC's five rows fit ClearPCItemScreen's (0,0) 10x18 box exactly, with +-- SEE YA! on row 10. The folded MAIL BOX makes six, and a window sized for +-- five closes under the fifth. + +local function pcRows(opts) + local menu = PcMenu.new({ save = { party = { { species = "CYNDAQUIL" } } } }, + opts) + startRecording() + menu:drawPanel() + stopRecording() + return menu +end + +local bills = pcRows({ bills = true }) +eq(#bills.entries, 5, "BILL's PC shows the cart's own five rows") +local billsBox = find("box", function(e) return e.y == 0 end) +eq(billsBox and billsBox.h, 12, "in ClearPCItemScreen's own 12-row window") +local seeya = findPrint("SEE YA!") +check(seeya ~= nil and seeya.y == 10, "SEE YA! on row 10") +check(seeya and billsBox and seeya.y <= billsBox.y + billsBox.h - 2, + "inside the window's interior") + +local folded = pcRows({}) +eq(#folded.entries, 6, "a directly-built PC folds the item PC's MAIL BOX in") +local foldedBox = find("box", function(e) return e.y == 0 end) +eq(foldedBox and foldedBox.h, 14, "and the window grows a row for it") +local seeya6 = findPrint("SEE YA!") +check(seeya6 ~= nil and seeya6.y == 12, "SEE YA! moves to row 12") +check(seeya6 and foldedBox and seeya6.y <= foldedBox.y + foldedBox.h - 2, + "still inside the window's interior") +-- .LogIn's text box goes down first so the taller window overlays it. +local whatBox, whatAt = find("box", function(e) return e.y == 12 end) +local topAt = select(2, find("box", function(e) return e.y == 0 end)) +check(whatBox ~= nil and whatAt and topAt and whatAt < topAt, + "the What? box is drawn under the menu window, not over it") +check(findPrint("What?") ~= nil, "and its one word still prints") + +-- ------------------------------------------------------- hall of fame +-- +-- InitDisplayForHallOfFame (engine/movie/init_hof_credits.asm) and the top of +-- _HallOfFamePC both call LoadFontsBattleExtra, so every string on this +-- screen resolves against the battle sheet. + +local hof = HallOfFame.new({ data = {} }, { + mode = "induct", + save = { player = { name = "GOLD", id = 52049 }, playTime = {} }, + entry = { winCount = 1, mons = { { + species = "TYPHLOSION", nickname = "BLAZE", level = 50, otId = 52049, + } } }, +}) +hof.phase = "display" + +Font.useBattleExtra(false) +startRecording() +hof:drawPanel() +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("<ID>№/") +check(idLine ~= nil and idLine.battleExtra == true, + "and so does the trainer id line") +eq(Font.battleExtraActive(), false, + "the sheet the caller had is put back when the screen is done") + +-- The photo studio's card is the same three glyphs out of the same sheet: +-- PrintPartyMonPage1 (engine/printer/print_party.asm) opens with +-- LoadFontsBattleExtra before it writes any of them. +local PhotoStudio = require("src.ui.gen2.PhotoStudio") +local photo = PhotoStudio.new({ data = {} }, { + mon = { species = "CYNDAQUIL", nickname = "CYNDAQUIL", level = 22, + maxHp = 55, otId = 52049, moves = { { id = "TACKLE" } } }, + playerName = "GOLD", +}) + +startRecording() +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("<ID>№") +check(photoId ~= nil and photoId.battleExtra == true, "and its <ID> does too") +eq(Font.battleExtraActive(), false, "and the card puts the sheet back") + +S.finish() diff --git a/tests/gen2_forceshiny_test.lua b/tests/gen2_forceshiny_test.lua new file mode 100644 index 00000000..f2547544 --- /dev/null +++ b/tests/gen2_forceshiny_test.lua @@ -0,0 +1,197 @@ +-- BATTLETYPE_FORCESHINY: the Lake of Rage Gyarados must come out SHINY and +-- must not be run from. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_forceshiny_test.lua +-- +-- maps/LakeOfRage.asm arms the fight with +-- +-- loadwildmon GYARADOS, 30 +-- loadvar VAR_BATTLETYPE, BATTLETYPE_FORCESHINY +-- startbattle +-- +-- and InitEnemyMon's `.NotRoaming` arm (engine/battle/core.asm:5876) +-- answers the type by replacing the rolled DVs with ATKDEFDV_SHINY $EA / +-- SPDSPCDV_SHINY $AA -- Attack 14, Defense/Speed/Special 10, the classic +-- shiny pattern. TryToRunAwayFromBattle:3469 jumps straight to +-- .cant_escape for the same type, so the one-shot encounter cannot be +-- forfeited by RUN returning a WIN to the RedGyarados script. What is +-- asserted here is the REAL World:startScriptedBattle -> World:startBattle +-- chain with the type armed the way Script_loadvar arms it. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 forceshiny") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") +local Mon = require("src.battle.gen2.Mon") + +-- ---- the latest load command decides what startbattle fights -------------- +-- Script_loadwildmon rewrites wBattleScriptFlags to the wild shape, erasing +-- the trainer bit Script_loadtemptrainer set. The port's VM lives as long +-- as the World, so a trainer record left by an earlier script (any sight +-- trainer fought on the way to the lake) must not shadow `loadwildmon` -- +-- with the stale record, pressing A on the Red Gyarados refought the last +-- trainer instead of starting the FORCESHINY encounter at all. ROM-free, +-- so it runs even without the cache below. +do + local Vm = require("src.script.gen2.Vm") + local Events = require("src.world.gen2.Events") + local fought = {} + local vm = Vm.new({ + ["s:trainer"] = { + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "end" }, + }, + ["s:gyarados"] = { + { op = "loadwildmon", species = 130, level = 30 }, + { op = "loadvar", args = { 3, 7 } }, + { op = "startbattle" }, + { op = "end" }, + }, + }, {}, Events.new(), { + lookupTrainer = function() return { class = "POKEMANIAC", id = 1 } end, + startBattle = function(trainer, wild, onDone) + fought[#fought + 1] = { trainer = trainer, wild = wild } + onDone("win") + end, + }) + vm.trainerObject = { class = "POKEMANIAC", member = 1 } + check(vm:start("s:trainer"), "the trainer script starts") + for _ = 1, 20 do vm:update() end + check(fought[1] and fought[1].trainer ~= nil, "and fights its trainer") + + check(vm:start("s:gyarados"), "the wild script starts on the same VM") + for _ = 1, 20 do vm:update() end + check(fought[2] ~= nil, "and reaches its own startbattle") + eq(fought[2] and fought[2].trainer, nil, + "loadwildmon erased the stale trainer record") + eq(fought[2] and fought[2].wild and fought[2].wild.species, 130, + "so the wild GYARADOS is what gets fought") +end + +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local pokemon = loadLua("data/generated/pokemon.lua") +local moves = loadLua("data/generated/moves.lua") + +-- constants/battle_constants.asm / engine/overworld/variables.asm. +local VAR_BATTLETYPE = 0x03 +local BATTLETYPE_FORCESHINY = 7 +local GYARADOS_INDEX = 130 + +-- The two battle screens as registry fakes: the transition hands off +-- immediately, the battle parks its Battle instance for the assertions. +local battleDone, builtBattle +local registry = { + Gen2BattleTransition = { new = function(_g, opts) + opts.onDone() + return { screenId = "Gen2BattleTransition" } + end }, + Gen2BattleState = { new = function(_g, opts) + battleDone = opts.onDone + builtBattle = opts.battle + return { screenId = "Gen2BattleState" } + end }, +} + +local function makeStack() + local stack = { items = {} } + function stack:push(inst) self.items[#self.items + 1] = inst end + function stack:pop() + local top = self.items[#self.items] + self.items[#self.items] = nil + return top + end + return stack +end + +local function makeWorld() + battleDone, builtBattle = nil, nil + local game = { + data = { audio = {}, screens = registry, pokemon = pokemon, + moves = moves }, + save = { + player = { name = "GOLD", money = 3000 }, + party = {}, + blackoutMap = "MAHOGANY_POKECENTER_1F", + }, + stack = makeStack(), + } + local mine = Mon.new(game.data, "CYNDAQUIL", 30) + check(mine ~= nil, "the cache can build the player's mon") + game.save.party[1] = mine + local w = World.new(game) + w.maps, w.tilesets = maps, tilesets + w.map = Map.new(maps.LAKE_OF_RAGE, tilesets[maps.LAKE_OF_RAGE.tileset]) + w.player = { cellX = 20, cellY = 20, facing = "up", moving = false } + return w, game +end + +-- ---- the armed type forces the shiny DV pattern --------------------------- +do + local w = makeWorld() + -- Script_loadvar's write, exactly as the extracted RedGyarados script + -- (49:4f6f) runs it ahead of `startbattle`. + w:writeVar(VAR_BATTLETYPE, BATTLETYPE_FORCESHINY) + eq(w:battleType(), BATTLETYPE_FORCESHINY, "the type is armed") + + check(w:startScriptedBattle(nil, { species = GYARADOS_INDEX, level = 30 }, + function() end), "the scripted battle starts") + check(builtBattle ~= nil, "the battle screen got the Battle instance") + local wild = builtBattle.enemy + eq(wild.species, "GYARADOS", "the wild mon is the GYARADOS") + eq(wild.level, 30, "at level 30") + eq(wild.dvs.attack, 14, "ATKDEFDV_SHINY $EA: Attack DV 14") + eq(wild.dvs.defense, 10, "Defense DV 10") + eq(wild.dvs.speed, 10, "SPDSPCDV_SHINY $AA: Speed DV 10") + eq(wild.dvs.special, 10, "Special DV 10") + eq(wild.shiny, true, "and the mon IS shiny") + eq(Mon.isShiny(wild.dvs), true, + "shininess survives a re-read of the DVs, so a catch keeps it") + + -- The battle got the type, and RUN is refused before any speed math. + eq(builtBattle.battleType, BATTLETYPE_FORCESHINY, + "opts.battleType reached Battle.new") + eq(builtBattle:tryRun(), false, + "TryToRunAwayFromBattle's .cant_escape arm for the type") + eq(builtBattle.over, false, "the encounter is still live after RUN") + eq(w:battleType(), 0, "the var is a one-shot, consumed on the way in") + battleDone("win") +end + +-- ---- without the type: an ordinary blue Gyarados that can be fled --------- +do + local w = makeWorld() + eq(w:battleType(), 0, "no type armed") + check(w:startScriptedBattle(nil, { species = GYARADOS_INDEX, level = 30 }, + function() end), "the plain battle starts") + eq(builtBattle.battleType, 0, "no battle type handed over") + check(builtBattle.enemy.dvs ~= nil, "the DVs exist") + -- One in 8192 runs would roll shiny DVs honestly; assert the FORCED + -- pattern is not simply stamped on everything. + local dvs = builtBattle.enemy.dvs + local forced = dvs.attack == 14 and dvs.defense == 10 + and dvs.speed == 10 and dvs.special == 10 + check(not forced or Mon.isShiny(dvs), + "unforced DVs are random, not the shiny constant") + battleDone("win") +end + +S.finish() diff --git a/tests/gen2_gamecorner_test.lua b/tests/gen2_gamecorner_test.lua new file mode 100644 index 00000000..5b82fc71 --- /dev/null +++ b/tests/gen2_gamecorner_test.lua @@ -0,0 +1,1241 @@ +-- The Game Corner: the slot machine's reels, bias table and payout ladder, card +-- flip's deck and 6x8 board, the three prize counters' refusal ladders, and the +-- coin case's clamps at both ends. +-- +-- ROM-free and draw-free. Every decision these screens make is a pure function +-- taking a `random(n) -> 0..n-1`, so the odds tables can be driven with a +-- counted RNG rather than sampled: an assertion here says "this byte selects +-- this symbol", which is a statement about the transcription, not about luck. +-- The distribution checks that follow are the sanity pass over the same tables. + +package.path = "./?.lua;" .. package.path + +-- The UI modules pull love-side helpers in at load time. Stub what they touch; +-- 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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local CardFlip = require("src.ui.gen2.CardFlip") +local PrizeMenu = require("src.ui.gen2.PrizeMenu") +local Save = require("src.core.gen2.Save") +local SlotMachine = require("src.ui.gen2.SlotMachine") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- A scripted RNG: hand it the bytes the cart's Random would return, in order. +-- `random(n)` yields the next byte modulo n, which is how every caller here +-- consumes it (`random(256)` is the raw byte, `random(8)` the `and $7`). +local function scripted(bytes) + local i = 0 + return function(n) + i = i + 1 + local byte = bytes[i] or 0 + return byte % (n or 256) + end +end + +-- A deterministic linear congruential byte source for the distribution passes, +-- so a failure here is reproducible rather than a flake. +local function seeded(seed) + local state = seed + return function(n) + state = (state * 1103515245 + 12345) % 2147483648 + return math.floor(state / 65536) % (n or 256) + end +end + +local function newSave(coins, money) + local save = Save.newGame({ playerName = "GOLD" }) + save.player.coins = coins or 0 + save.player.money = money or 0 + return save +end + +-- ==================================================================== reels +-- +-- Reel1Tilemap / Reel2Tilemap / Reel3Tilemap, including the three repeated +-- entries that let Slots_GetCurrentReelState read three bytes without wrapping. +local SEVEN = SlotMachine.SEVEN +local POKEBALL = SlotMachine.POKEBALL +local CHERRY, PIKACHU = SlotMachine.CHERRY, SlotMachine.PIKACHU +local SQUIRTLE, STARYU = SlotMachine.SQUIRTLE, SlotMachine.STARYU + +for i = 1, 3 do + check("reel " .. i .. " is 18 entries", #SlotMachine.REELS[i], 18) + for j = 1, 3 do + check(("reel %d repeats entry %d"):format(i, j), + SlotMachine.REELS[i][15 + j], SlotMachine.REELS[i][j]) + end +end + +-- Each reel's SEVEN and POKEBALL count is what makes 300 coins so rare: reel 3 +-- carries exactly one of each. +local function countOf(strip, symbol) + local n = 0 + for i = 1, SlotMachine.REEL_SIZE do + if strip[i] == symbol then n = n + 1 end + end + return n +end + +check("reel 1 has three SEVENs", countOf(SlotMachine.REELS[1], SEVEN), 2) +check("reel 1 has one POKEBALL", countOf(SlotMachine.REELS[1], POKEBALL), 1) +check("reel 2 has one SEVEN", countOf(SlotMachine.REELS[2], SEVEN), 1) +check("reel 2 has two POKEBALLs", countOf(SlotMachine.REELS[2], POKEBALL), 2) +check("reel 3 has one SEVEN", countOf(SlotMachine.REELS[3], SEVEN), 1) +check("reel 3 has one POKEBALL", countOf(SlotMachine.REELS[3], POKEBALL), 1) + +-- Slots_GetCurrentReelState: slot 0 reads as if it were 15, and index 0 of the +-- window is the BOTTOM row. +local bottom, middle, top = SlotMachine.window(SlotMachine.REELS[1], 1) +check("slot 1 shows entry 0 on the bottom", bottom, SEVEN) +check("slot 1 shows entry 1 in the middle", middle, CHERRY) +check("slot 1 shows entry 2 on top", top, STARYU) + +local b0 = SlotMachine.window(SlotMachine.REELS[1], 0) +local b15 = SlotMachine.window(SlotMachine.REELS[1], 15) +check("slot 0 reads as slot 15", b0, b15) +check("slot 0 shows entry 14", b0, SQUIRTLE) + +-- The `and $f` mask is only harmless because of the three repeats: position 16 +-- reads the same window position 1 does. +local w1 = { SlotMachine.window(SlotMachine.REELS[2], 1) } +local w16 = { SlotMachine.window(SlotMachine.REELS[2], 16) } +check("position 16 reads position 1's window", + table.concat(w1, ","), table.concat(w16, ",")) + +-- Slots_UpdateReelPositionAndOAM's wrap. +check("advance wraps 14 to 0", SlotMachine.advance(14), 0) +check("advance steps 0 to 1", SlotMachine.advance(0), 1) + +-- ================================================================ pay lines +-- +-- The jumptable FALLS THROUGH, so a bet of three runs five checks and a bet of +-- one runs a single middle row. +check("bet 1 buys one line", #SlotMachine.LINES[1], 1) +check("bet 2 buys three lines", #SlotMachine.LINES[2], 3) +check("bet 3 buys five lines", #SlotMachine.LINES[3], 5) + +-- Windows are { bottom, middle, top }. +local function win(a, b, c) return { a, b, c } end + +check("a middle row pays at bet 1", + SlotMachine.matchAll(1, win(CHERRY, SEVEN, CHERRY), win(CHERRY, SEVEN, CHERRY), + win(CHERRY, SEVEN, CHERRY)), SEVEN) +-- Only the bottom row lines up here, so a bet of one sees nothing at all. +local bottomOnly = { + win(SEVEN, CHERRY, PIKACHU), + win(SEVEN, STARYU, SQUIRTLE), + win(SEVEN, POKEBALL, STARYU), +} +check("a bottom row does not pay at bet 1", + SlotMachine.matchAll(1, bottomOnly[1], bottomOnly[2], bottomOnly[3]), + SlotMachine.NO_MATCH) +check("a bottom row pays at bet 2", + SlotMachine.matchAll(2, bottomOnly[1], bottomOnly[2], bottomOnly[3]), SEVEN) +check("a top row pays at bet 2", + SlotMachine.matchAll(2, win(CHERRY, STARYU, STARYU), + win(PIKACHU, SQUIRTLE, STARYU), win(SEVEN, CHERRY, STARYU)), STARYU) +-- The upward diagonal is reel 1's bottom, reel 2's middle and reel 3's top. +check("the upward diagonal only pays at bet 3", + SlotMachine.matchAll(2, win(CHERRY, SEVEN, STARYU), win(SEVEN, CHERRY, STARYU), + win(SEVEN, STARYU, CHERRY)), SlotMachine.NO_MATCH) +check("the upward diagonal pays at bet 3", + SlotMachine.matchAll(3, win(CHERRY, SEVEN, STARYU), win(SEVEN, CHERRY, STARYU), + win(SEVEN, STARYU, CHERRY)), CHERRY) + +-- Every hit overwrites wSlotMatched, so the LAST line checked wins the tie -- +-- and the middle row is checked last. Here the bottom row lines up STARYU and +-- the middle row lines up CHERRY; only the CHERRY is paid. +check("the middle row wins a tie", + SlotMachine.matchAll(2, win(STARYU, CHERRY, SEVEN), win(STARYU, CHERRY, SEVEN), + win(STARYU, CHERRY, SEVEN)), CHERRY) + +-- Slots_CheckMatchedFirstTwoReels compares DIFFERENT rows: with two reels down +-- both diagonals collapse onto reel 2's middle symbol. +local building, sevens = SlotMachine.matchFirstTwo(3, + win(SEVEN, CHERRY, PIKACHU), win(STARYU, SEVEN, SQUIRTLE)) +check("two reels build on the upward diagonal", building, SEVEN) +check("and the sevens flag is set", sevens, true) +local building2, sevens2 = SlotMachine.matchFirstTwo(1, + win(SEVEN, CHERRY, PIKACHU), win(STARYU, SEVEN, SQUIRTLE)) +check("but not at bet 1", building2, SlotMachine.NO_MATCH) +check("and the sevens flag stays clear", sevens2, false) + +-- ============================================================ payout ladder +-- +-- Slots_GetPayout .PayoutTable. The bet buys LINES, never a multiplier. +check("SEVEN pays 300", SlotMachine.payout(SEVEN), 300) +check("POKEBALL pays 50", SlotMachine.payout(POKEBALL), 50) +check("CHERRY pays 6", SlotMachine.payout(CHERRY), 6) +check("PIKACHU pays 8", SlotMachine.payout(PIKACHU), 8) +check("SQUIRTLE pays 10", SlotMachine.payout(SQUIRTLE), 10) +check("STARYU pays 15", SlotMachine.payout(STARYU), 15) +check("no match pays nothing", SlotMachine.payout(SlotMachine.NO_MATCH), 0) + +-- ================================================================ the bias +-- +-- Slots_InitBias .Normal / .Lucky. `percent` is "* $ff / 100" with integer +-- division, so the boundaries are the bytes below and not round percentages. +-- The scan takes the first row whose threshold is >= the roll, so each byte +-- below is the LAST one that selects its symbol. +local NO_BIAS = SlotMachine.NO_BIAS +local NORMAL_EDGES = { + { 0, SEVEN }, { 1, SEVEN }, { 2, POKEBALL }, { 3, POKEBALL }, + { 4, STARYU }, { 10, STARYU }, { 11, SQUIRTLE }, { 20, SQUIRTLE }, + { 21, PIKACHU }, { 40, PIKACHU }, { 41, CHERRY }, { 48, CHERRY }, + { 49, NO_BIAS }, { 255, NO_BIAS }, +} +for _, row in ipairs(NORMAL_EDGES) do + check(("normal bias byte %d"):format(row[1]), + SlotMachine.initBias(NO_BIAS, false, scripted({ row[1] })), row[2]) +end + +local LUCKY_EDGES = { + { 0, SEVEN }, { 2, SEVEN }, { 3, POKEBALL }, { 4, STARYU }, { 8, STARYU }, + { 9, SQUIRTLE }, { 16, SQUIRTLE }, { 17, PIKACHU }, { 30, PIKACHU }, + { 31, CHERRY }, { 80, CHERRY }, { 81, NO_BIAS }, { 255, NO_BIAS }, +} +for _, row in ipairs(LUCKY_EDGES) do + check(("lucky bias byte %d"):format(row[1]), + SlotMachine.initBias(NO_BIAS, true, scripted({ row[1] })), row[2]) +end + +-- `ld a, [wSlotBias] / and a / ret z`: a spin already biased to SEVEN keeps it +-- without rolling at all. The scripted RNG would say CHERRY if consulted. +check("a SEVEN bias survives without a roll", + SlotMachine.initBias(SEVEN, false, scripted({ 48 })), SEVEN) +check("any other bias is rerolled", + SlotMachine.initBias(CHERRY, false, scripted({ 48 })), CHERRY) + +-- The exact shape of the table, sampled: SEVEN is 2 bytes in 256 on the normal +-- table and CHERRY jumps from 8 bytes to 50 on the lucky one. +local counts = {} +local roll = 0 +for byte = 0, 255 do + local symbol = SlotMachine.initBias(NO_BIAS, false, scripted({ byte })) + counts[symbol] = (counts[symbol] or 0) + 1 + roll = roll + 1 +end +check("256 bytes accounted for", roll, 256) +check("normal SEVEN is 2/256", counts[SEVEN], 2) +check("normal POKEBALL is 2/256", counts[POKEBALL], 2) +check("normal STARYU is 7/256", counts[STARYU], 7) +check("normal SQUIRTLE is 10/256", counts[SQUIRTLE], 10) +check("normal PIKACHU is 20/256", counts[PIKACHU], 20) +check("normal CHERRY is 8/256", counts[CHERRY], 8) +check("normal NO_BIAS is 207/256", counts[NO_BIAS], 207) + +local lucky = {} +for byte = 0, 255 do + local symbol = SlotMachine.initBias(NO_BIAS, true, scripted({ byte })) + lucky[symbol] = (lucky[symbol] or 0) + 1 +end +check("lucky SEVEN is 3/256", lucky[SEVEN], 3) +check("lucky POKEBALL is 1/256", lucky[POKEBALL], 1) +check("lucky CHERRY is 50/256", lucky[CHERRY], 50) +check("lucky NO_BIAS is 175/256", lucky[NO_BIAS], 175) +check("the lucky table is kinder", lucky[NO_BIAS] < counts[NO_BIAS], true) + +-- .InitGFX's wKeepSevenBiasChance roll: %00101010 clear is 1 byte in 8. +local keeps = 0 +for byte = 0, 255 do + if SlotMachine.rollKeepSevenChance(scripted({ byte })) then keeps = keeps + 1 end +end +check("the session flag is set 32/256 of the time", keeps, 32) + +-- .LinedUpSevens: the streak survives 1 roll in 4 normally, and -- the ASM's +-- own comment calls this out as probably inverted -- only 1 in 8 on the rarer +-- session flag. +local kept, keptRare = 0, 0 +for byte = 0, 255 do + if SlotMachine.keepSevenBias(false, scripted({ byte })) then kept = kept + 1 end + if SlotMachine.keepSevenBias(true, scripted({ byte })) then + keptRare = keptRare + 1 + end +end +check("a seven streak survives 64/256", kept, 64) +check("and only 32/256 on the rare flag", keptRare, 32) + +-- ============================================================== reel stops +-- +-- ReelAction_StopReel1: with no bias the reel stops where the player pressed. +check("an unbiased reel 1 stops dead", + SlotMachine.stopReel1(7, NO_BIAS), 7) + +-- With a bias it walks up to four slots hunting the symbol. Reel 1 slot 3 +-- shows entries 2,3,4 = STARYU, PIKACHU, SQUIRTLE; the nearest SEVEN in the +-- window is at slot 4 (entries 3,4,5). +check("a biased reel 1 walks to its symbol", + SlotMachine.stopReel1(3, SEVEN), 4) +-- Four slots is the whole budget: a symbol further away is never reached. +local far = SlotMachine.stopReel1(7, SEVEN) +check("and gives up after four slots", far, 11) + +-- ReelAction_StopReel3's rule with no bias at all: the reel refuses to settle +-- anywhere a line is lit. Drive it against two reels showing CHERRY straight +-- across and assert the landing pays nothing. +local r1 = win(CHERRY, CHERRY, CHERRY) +local r2 = win(CHERRY, CHERRY, CHERRY) +local landed = SlotMachine.stopReel3(0, NO_BIAS, 3, r1, r2) +local r3 = { SlotMachine.window(SlotMachine.REELS[3], landed) } +check("an unbiased reel 3 lands on nothing", + SlotMachine.matchAll(3, r1, r2, r3), SlotMachine.NO_MATCH) + +-- Slots_StopReel2's skip-to-seven gate: it needs a bet of 2 or more, a SEVEN +-- somewhere in reel one, and a spin that is unbiased or biased to SEVEN. +local withSeven = win(SEVEN, CHERRY, STARYU) +local noSeven = win(CHERRY, PIKACHU, STARYU) +check("skip needs a bet of two", + SlotMachine.reel2SkipsToSeven(1, NO_BIAS, withSeven, scripted({ 0 })), false) +check("skip needs a seven in reel one", + SlotMachine.reel2SkipsToSeven(3, NO_BIAS, noSeven, scripted({ 0 })), false) +check("skip refuses a non-seven bias", + SlotMachine.reel2SkipsToSeven(3, CHERRY, withSeven, scripted({ 0 })), false) +check("skip fires below 80/256", + SlotMachine.reel2SkipsToSeven(3, NO_BIAS, withSeven, scripted({ 79 })), true) +check("and not at 80", + SlotMachine.reel2SkipsToSeven(3, NO_BIAS, withSeven, scripted({ 80 })), false) +check("a seven bias is allowed", + SlotMachine.reel2SkipsToSeven(2, SEVEN, withSeven, scripted({ 0 })), true) + +-- ============================================== reel 3's near-miss theatre +-- +-- Slots_StopReel3's action roll only happens when the first two reels already +-- show matching SEVENs. +check("no sevens means no theatre", + SlotMachine.reel3Action(false, SEVEN, scripted({ 0 })), + SlotMachine.REEL3_STOP) + +-- Biased to SEVEN: stop 76/256, slow 60, golem 60, egg 60. +local sevenBoundaries = { + { 255, SlotMachine.REEL3_STOP }, { 180, SlotMachine.REEL3_STOP }, + { 179, SlotMachine.REEL3_SLOW }, { 120, SlotMachine.REEL3_SLOW }, + { 119, SlotMachine.REEL3_GOLEM }, { 60, SlotMachine.REEL3_GOLEM }, + { 59, SlotMachine.REEL3_EGG }, { 0, SlotMachine.REEL3_EGG }, +} +for _, row in ipairs(sevenBoundaries) do + check(("seven-bias theatre at %d"):format(row[1]), + SlotMachine.reel3Action(true, SEVEN, scripted({ row[1] })), row[2]) +end + +-- Anything else: stop 96/256, slow 80, golem 80, and the egg is UNREACHABLE. +local otherBoundaries = { + { 255, SlotMachine.REEL3_STOP }, { 160, SlotMachine.REEL3_STOP }, + { 159, SlotMachine.REEL3_SLOW }, { 80, SlotMachine.REEL3_SLOW }, + { 79, SlotMachine.REEL3_GOLEM }, { 0, SlotMachine.REEL3_GOLEM }, +} +for _, row in ipairs(otherBoundaries) do + check(("unbiased theatre at %d"):format(row[1]), + SlotMachine.reel3Action(true, NO_BIAS, scripted({ row[1] })), row[2]) +end +local eggs = 0 +for byte = 0, 255 do + if SlotMachine.reel3Action(true, NO_BIAS, scripted({ byte })) + == SlotMachine.REEL3_EGG then + eggs = eggs + 1 + end +end +check("Chansey never appears without a seven bias", eggs, 0) + +-- Slots_GetNumberOfGolems, biased to SEVEN: the count is exactly the number of +-- one-slot steps to a SEVEN line, so the reel really does land on one. +local sevenRow = win(SEVEN, SEVEN, SEVEN) +local golems = SlotMachine.golemCount(0, SEVEN, 3, sevenRow, sevenRow, + scripted({})) +local golemLanding = 0 +for _ = 1, golems do golemLanding = SlotMachine.advance(golemLanding) end +local golemWindow = { SlotMachine.window(SlotMachine.REELS[3], golemLanding) } +check("Golem lands a seven when the bias is seven", + SlotMachine.matchAll(3, sevenRow, sevenRow, golemWindow), SEVEN) +check("and takes at least one Golem", golems >= 1, true) + +-- The other branch rerolls `and $7` until it is 4..7, which is the stride the +-- SEARCH walks; the count returned is the final stride, so the reel lands +-- somewhere the search never looked. 0, 1, 2 and 3 are all rejected rolls. +local strideCount = SlotMachine.golemCount(0, NO_BIAS, 3, sevenRow, sevenRow, + scripted({ 0, 3, 5 })) +check("an unbiased Golem stride starts at 4 or more", strideCount >= 5, true) + +-- ================================================================== spins +-- +-- The whole machine, seeded. Two properties hold over thousands of spins and +-- they are the ones the tables above exist to produce. +local paid, spins, unnamed, silentMatch = 0, 0, 0, 0 +local random = seeded(20260807) +local biasSeen = {} +for i = 1, 4000 do + local result = SlotMachine.spin({ + random = random, + bet = 3, + bias = NO_BIAS, + stops = { i % 15, (i * 7) % 15, (i * 11) % 15 }, + }) + spins = spins + 1 + biasSeen[result.bias] = (biasSeen[result.bias] or 0) + 1 + if result.payout > 0 then + paid = paid + 1 + if SlotMachine.PAYOUTS[result.matched] == nil then unnamed = unnamed + 1 end + elseif result.matched ~= SlotMachine.NO_MATCH then + silentMatch = silentMatch + 1 + end +end +check("every spin resolved", spins, 4000) +check("every paid spin names a symbol on the table", unnamed, 0) +check("and a symbol on the table always pays", silentMatch, 0) +-- An unbiased spin cannot line anything up, so a paying spin implies a bias was +-- rolled. With NO_BIAS at 207/256 the paid rate has to be well under half. +check("most spins pay nothing", paid < spins / 2, true) +check("some spins pay", paid > 0, true) +check("the unbiased spins dominate", biasSeen[NO_BIAS] > spins / 2, true) + +-- ReelAction_StopReel3 keeps spinning past any match that is NOT the bias, so +-- the reel it stops can only ever land on the biased symbol or on nothing. +local wrongSymbol = 0 +local twoCherries = win(CHERRY, CHERRY, CHERRY) +for start = 0, 14 do + local at = SlotMachine.stopReel3(start, CHERRY, 3, twoCherries, twoCherries) + local window = { SlotMachine.window(SlotMachine.REELS[3], at) } + local matched = SlotMachine.matchAll(3, twoCherries, twoCherries, window) + if matched ~= SlotMachine.NO_MATCH and matched ~= CHERRY then + wrongSymbol = wrongSymbol + 1 + end +end +check("a CHERRY-biased reel 3 never settles on another symbol", wrongSymbol, 0) + +-- Across whole spins the same holds, with ONE documented exception: the Golem +-- search for a spin that is not biased to SEVEN walks a growing stride while +-- the reel only advances one slot per Golem, so it lands somewhere the search +-- never looked and ReelAction_WaitGolem stops it there anyway. +local offBias = 0 +local biasRandom = seeded(99) +for i = 1, 2000 do + local result = SlotMachine.spin({ + random = biasRandom, + bet = 3, + bias = SlotMachine.NO_BIAS, + stops = { i % 15, (i * 5) % 15, (i * 13) % 15 }, + }) + if result.matched ~= SlotMachine.NO_MATCH + and result.matched ~= result.bias + and result.reel3Action ~= SlotMachine.REEL3_GOLEM then + offBias = offBias + 1 + end +end +check("a spin only ever pays its own bias, Golem aside", offBias, 0) + +-- ============================================================== coin case +-- +-- engine/events/money.asm GiveCoins / TakeCoins, and Save.MAX_COINS. +check("the cap is 9999", PrizeMenu.MAX_COINS, 9999) +check("and the save agrees", Save.MAX_COINS, 9999) + +local case = newSave(0) +check("a fresh case is empty", PrizeMenu.coins(case), 0) +local after, capped = PrizeMenu.giveCoins(case, 50) +check("giving adds", after, 50) +check("and does not cap", capped, false) + +case.player.coins = 9990 +after, capped = PrizeMenu.giveCoins(case, 5) +check("giving under the cap adds", after, 9995) +check("still not capped", capped, false) +after, capped = PrizeMenu.giveCoins(case, 500) +check("giving over the cap clamps to 9999", after, 9999) +check("and reports the cap", capped, true) +after = PrizeMenu.giveCoins(case, 1) +check("a full case stays at 9999", after, 9999) + +case.player.coins = 3 +local left, borrowed = PrizeMenu.takeCoins(case, 3) +check("taking exactly empties", left, 0) +check("without borrowing", borrowed, false) +left, borrowed = PrizeMenu.takeCoins(case, 1) +check("taking more clamps at zero", left, 0) +check("and reports the borrow", borrowed, true) + +-- CheckCoins -> CompareMoneyAction. +case.player.coins = 100 +check("more than", PrizeMenu.checkCoins(case, 50), PrizeMenu.HAVE_MORE) +check("exactly", PrizeMenu.checkCoins(case, 100), PrizeMenu.HAVE_AMOUNT) +check("less than", PrizeMenu.checkCoins(case, 101), PrizeMenu.HAVE_LESS) +check("HAVE_MORE is 0", PrizeMenu.HAVE_MORE, 0) +check("HAVE_AMOUNT is 1", PrizeMenu.HAVE_AMOUNT, 1) +check("HAVE_LESS is 2", PrizeMenu.HAVE_LESS, 2) + +-- Save.normalize re-clamps a file that was edited past the cap. +local overflowing = newSave(0) +overflowing.player.coins = 99999 +Save.normalize(overflowing) +check("normalize clamps a loaded save", overflowing.player.coins, 9999) +overflowing.player.coins = -5 +Save.normalize(overflowing) +check("and clamps below zero", overflowing.player.coins, 0) + +-- ========================================================= prize counters +-- +-- The prices are the map scripts' EQU blocks. +local counters = PrizeMenu.COUNTERS +check("TM32 costs 1500", counters.CELADON_TM.prizes[1].cost, 1500) +check("TM29 costs 3500", counters.CELADON_TM.prizes[2].cost, 3500) +check("TM15 costs 7500", counters.CELADON_TM.prizes[3].cost, 7500) +check("MR.MIME costs 3333", counters.CELADON_MON.prizes[1].cost, 3333) +check("EEVEE costs 6666", counters.CELADON_MON.prizes[2].cost, 6666) +check("PORYGON costs 9999", counters.CELADON_MON.prizes[3].cost, 9999) +check("PORYGON costs the whole case", counters.CELADON_MON.prizes[3].cost, + PrizeMenu.MAX_COINS) +check("PORYGON arrives at level 20", counters.CELADON_MON.prizes[3].level, 20) +check("the Goldenrod TMs are all 5500", + counters.GOLDENROD_TM.prizes[1].cost + counters.GOLDENROD_TM.prizes[2].cost + + counters.GOLDENROD_TM.prizes[3].cost, 16500) +check("ABRA costs 200", counters.GOLDENROD_MON.prizes[1].cost, 200) +check("DRATINI costs 2100", counters.GOLDENROD_MON.prizes[3].cost, 2100) +check("Gold sells EKANS", counters.GOLDENROD_MON.prizes[2].id, "EKANS") +check("Silver sells SANDSHREW", + counters.GOLDENROD_MON.prizes[2].silver.id, "SANDSHREW") +check("the coin vendor sells 50 for 1000", + counters.COIN_VENDOR.prizes[1].cost, 1000) +check("and 500 for 10000", counters.COIN_VENDOR.prizes[2].cost, 10000) + +-- A data double: enough for Bag.add's capacity check and for Mon.new. +local data = { + constants = { bagSize = 20 }, + items = { TM_DOUBLE_TEAM = { name = "TM32", pocket = "TM_HM" } }, + moves = {}, + pokemon = { + growthRates = { MEDIUM_FAST = {} }, + PORYGON = { name = "PORYGON", baseStats = { hp = 65, attack = 60, + defense = 70, speed = 40, spAttack = 85, spDefense = 75 }, + types = { "NORMAL" }, growthRate = "MEDIUM_FAST", levelMoves = {} }, + }, +} + +-- The item counter's ladder: coins are checked first, and the PACK only after +-- the player has said yes -- so a broke player is never told about the bag. +local tmCounter = counters.CELADON_TM +local tm32 = tmCounter.prizes[1] +local broke = newSave(1499) +check("1499 coins is not enough for TM32", + PrizeMenu.check(broke, tmCounter, tm32, data), "coins") +check("and buying refuses without charging", + PrizeMenu.buy(broke, tmCounter, tm32, data), "coins") +check("the coins are untouched", broke.player.coins, 1499) +check("and nothing was added", broke.inventory.TM_DOUBLE_TEAM, nil) + +local exact = newSave(1500) +check("exactly 1500 passes the check", + PrizeMenu.check(exact, tmCounter, tm32, data), "ok") +check("and buys", PrizeMenu.buy(exact, tmCounter, tm32, data), "ok") +check("leaving nothing", exact.player.coins, 0) +check("with the TM in the bag", exact.inventory.TM_DOUBLE_TEAM, 1) + +-- A full ITEM pocket does NOT block a TM: they live in different pockets +-- (item_data_constants.asm). The filler ids have no pocket def, so they +-- resolve to ITEM; the TM goes to TM_HM, which is empty here. +local fullItems = newSave(5000) +for i = 1, 20 do fullItems.inventory["FILLER_" .. i] = 1 end +check("a full ITEM pocket does not block a TM purchase", + PrizeMenu.buy(fullItems, tmCounter, tm32, data), "ok") +check("and the TM lands in the TM/HM pocket", + fullItems.inventory.TM_DOUBLE_TEAM, 1) + +-- A full TM/HM pocket is what actually refuses a TM. giveitem discovers it, +-- so the pre-confirm check still says ok. +local fullTms = newSave(5000) +for i = 1, 64 do + local id = "OTHER_TM_" .. i + data.items[id] = { name = id, pocket = "TM_HM" } + fullTms.inventory[id] = 1 +end +check("a full TM/HM pocket passes the pre-confirm check", + PrizeMenu.check(fullTms, tmCounter, tm32, data), "ok") +check("but the purchase itself refuses", + PrizeMenu.buy(fullTms, tmCounter, tm32, data), "room") +check("and the coins are not taken", fullTms.player.coins, 5000) + +-- The mon counter checks the PARTY before asking, so a full party never gets +-- the question at all. +local monCounter = counters.CELADON_MON +local porygon = monCounter.prizes[3] +local fullParty = newSave(9999) +for i = 1, Save.PARTY_SIZE do fullParty.party[i] = { species = "RATTATA" } end +check("a full party is refused before the question", + PrizeMenu.check(fullParty, monCounter, porygon, data), "room") +check("and the purchase refuses too", + PrizeMenu.buy(fullParty, monCounter, porygon, data), "room") +check("with the coins untouched", fullParty.player.coins, 9999) + +local roomy = newSave(9999) +check("nine thousand nine hundred and ninety nine is exactly enough", + PrizeMenu.check(roomy, monCounter, porygon, data), "ok") +check("and the mon is handed over", + PrizeMenu.buy(roomy, monCounter, porygon, data), "ok") +check("the party grew", #roomy.party, 1) +check("the mon is the prize", roomy.party[1] and roomy.party[1].species, + "PORYGON") +check("at the scripted level", roomy.party[1] and roomy.party[1].level, 20) +check("the case is empty", roomy.player.coins, 0) +-- `special GameCornerPrizeMonCheckDex` right before the givepoke. +check("and the dex was told", roomy.pokedex.caught.PORYGON, true) +check("seen as well", roomy.pokedex.seen.PORYGON, true) +-- AddPartyMon stamps the buyer's identity onto it (move_mon.asm:44-56, :143-149). +check("the prize mon carries the player's OT ID", roomy.party[1].otId, + roomy.player.id) +check("and the player's OT name", roomy.party[1].ot, roomy.player.name) + +local shortByOne = newSave(9998) +check("one coin short is refused", + PrizeMenu.check(shortByOne, monCounter, porygon, data), "coins") + +-- The coin vendor asks about ROOM first and money second, which is why a player +-- with a full case and an empty wallet hears about the case. +local coinCounter = counters.COIN_VENDOR +local fifty = coinCounter.prizes[1] +local fullCase = newSave(9999, 0) +check("a full case is reported before the wallet", + PrizeMenu.check(fullCase, coinCounter, fifty, data), "room") +-- MAX_COINS - 50 is 9949: exactly that still fits, one more does not. +local atHeadroom = newSave(9949, 1000) +check("exactly the headroom still fits", + PrizeMenu.check(atHeadroom, coinCounter, fifty, data), "ok") +local overHeadroom = newSave(9950, 1000) +check("one coin past the headroom is full", + PrizeMenu.check(overHeadroom, coinCounter, fifty, data), "room") + +local poor = newSave(0, 999) +check("¥999 does not buy 50 coins", + PrizeMenu.check(poor, coinCounter, fifty, data), "money") +check("and buying refuses", PrizeMenu.buy(poor, coinCounter, fifty, data), + "money") +check("with the wallet untouched", poor.player.money, 999) + +local buyer = newSave(0, 1000) +check("¥1000 buys 50 coins", PrizeMenu.buy(buyer, coinCounter, fifty, data), + "ok") +check("the coins arrive", buyer.player.coins, 50) +check("and the money is gone", buyer.player.money, 0) + +-- Buying 500 near the cap clamps rather than overflowing. +local nearCap = newSave(9900, 10000) +check("500 into a nearly full case is refused", + PrizeMenu.check(nearCap, coinCounter, coinCounter.prizes[2], data), "room") + +-- ================================================================ card flip +-- +-- CARDFLIP_DECK_SIZE, and the card packing every win condition masks against. +check("the deck is 24 cards", CardFlip.DECK_SIZE, 24) +check("the bet is three coins", CardFlip.BET, 3) +check("card 0 is a level 1 Pikachu", CardFlip.level(0) + 1, 1) +check("and its mon index is 0", CardFlip.mon(0), 0) +check("card 23 is a level 6 Oddish", CardFlip.level(23) + 1, 6) +check("with mon index 3", CardFlip.mon(23), 3) +check("packing round-trips", CardFlip.card(4, 2), 18) +check("the level pair of card 0", CardFlip.levelPair(0), 0) +check("the level pair of card 11", CardFlip.levelPair(11), 1) +check("the level pair of card 23", CardFlip.levelPair(23), 2) + +-- CardFlip_ShuffleDeck produces a permutation of 0..23 every time, including +-- the card 0 that is never explicitly placed. +for seed = 1, 40 do + local deck = CardFlip.shuffle(seeded(seed)) + local seen = {} + local ok = #deck == 24 + for _, card in ipairs(deck) do + if seen[card] or card < 0 or card > 23 then ok = false end + seen[card] = true + end + for card = 0, 23 do + if not seen[card] then ok = false end + end + check("shuffle " .. seed .. " is a permutation", ok, true) +end + +-- A degenerate `random` -- one a mod could inject -- must not hang the +-- rejection loop. The linear-probe fallback still produces a permutation. +local stuck = CardFlip.shuffle(function() return 31 end) +local stuckSeen, stuckOk = {}, #stuck == 24 +for _, card in ipairs(stuck) do + if stuckSeen[card] then stuckOk = false end + stuckSeen[card] = true +end +check("a degenerate RNG still deals 24 distinct cards", stuckOk, true) + +-- .CheckTheCard's index: two cards a hand, twelve hands to the deck. +local deck = CardFlip.shuffle(seeded(7)) +check("the first hand deals slots 1 and 2", + CardFlip.dealt(deck, 0, 0), deck[1]) +check("and the second card", CardFlip.dealt(deck, 0, 1), deck[2]) +check("the last hand deals slots 23 and 24", + CardFlip.dealt(deck, 11, 1), deck[24]) +check("twelve hands to a deck", CardFlip.HANDS_PER_DECK, 12) + +-- ------------------------------------------------------------- the board +-- +-- CardFlip_CheckWinCondition's jumptable, square by square. +check("the board is six wide", CardFlip.BOARD_W, 6) +check("and eight tall", CardFlip.BOARD_H, 8) + +for y = 0, 1 do + for x = 0, 1 do + check(("(%d,%d) is impossible"):format(x, y), + CardFlip.cell(x, y).kind, "impossible") + -- Impossible squares lose against every card in the deck. + local anyWin = false + for card = 0, 23 do + if CardFlip.payout(x, y, card) > 0 then anyWin = true end + end + check(("(%d,%d) never pays"):format(x, y), anyWin, false) + end +end + +check("the ladder pays 6 for a mon pair", CardFlip.PAYOUT_MON_PAIR, 6) +check("9 for a level pair", CardFlip.PAYOUT_LEVEL_PAIR, 9) +check("12 for one mon", CardFlip.PAYOUT_MON, 12) +check("18 for one level", CardFlip.PAYOUT_LEVEL, 18) +check("72 for the exact card", CardFlip.PAYOUT_CARD, 72) + +-- A Pikachu/Jigglypuff pair is x = 2 or 3 on row 0, and Poliwag/Oddish x = 4 +-- or 5; both squares of a pair behave identically. +for _, x in ipairs({ 2, 3 }) do + check(("(%d,0) pays on Pikachu"):format(x), CardFlip.payout(x, 0, 0), 6) + check(("(%d,0) pays on Jigglypuff"):format(x), CardFlip.payout(x, 0, 1), 6) + check(("(%d,0) loses on Poliwag"):format(x), CardFlip.payout(x, 0, 2), 0) +end +for _, x in ipairs({ 4, 5 }) do + check(("(%d,0) pays on Poliwag"):format(x), CardFlip.payout(x, 0, 2), 6) + check(("(%d,0) pays on Oddish"):format(x), CardFlip.payout(x, 0, 3), 6) + check(("(%d,0) loses on Pikachu"):format(x), CardFlip.payout(x, 0, 0), 0) +end + +-- Row 1 is one square per Pokemon, and the win is level-independent. +for mon = 0, 3 do + for level = 0, 5 do + check(("(%d,1) pays on its mon at level %d"):format(mon + 2, level + 1), + CardFlip.payout(mon + 2, 1, CardFlip.card(level, mon)), 12) + end + check(("(%d,1) loses on another mon"):format(mon + 2), + CardFlip.payout(mon + 2, 1, CardFlip.card(0, (mon + 1) % 4)), 0) +end + +-- Column 0 is a level PAIR reached from two rows apiece: 1-2, 3-4, 5-6. +local PAIR_ROWS = { { 2, 3, 0 }, { 4, 5, 1 }, { 6, 7, 2 } } +for _, row in ipairs(PAIR_ROWS) do + for _, y in ipairs({ row[1], row[2] }) do + for _, level in ipairs({ row[3] * 2, row[3] * 2 + 1 }) do + check(("(0,%d) pays on level %d"):format(y, level + 1), + CardFlip.payout(0, y, CardFlip.card(level, 0)), 9) + end + check(("(0,%d) loses outside its pair"):format(y), + CardFlip.payout(0, y, CardFlip.card((row[3] * 2 + 2) % 6, 0)), 0) + end +end + +-- Column 1 is a single level. +for level = 0, 5 do + for mon = 0, 3 do + check(("(1,%d) pays on level %d"):format(level + 2, level + 1), + CardFlip.payout(1, level + 2, CardFlip.card(level, mon)), 18) + end + check(("(1,%d) loses on another level"):format(level + 2), + CardFlip.payout(1, level + 2, CardFlip.card((level + 1) % 6, 0)), 0) +end + +-- The 24 exact squares pay 72 on their own card and nothing on any other. +local exactHits = 0 +for level = 0, 5 do + for mon = 0, 3 do + local x, y = mon + 2, level + 2 + local card = CardFlip.card(level, mon) + if CardFlip.payout(x, y, card) == 72 then exactHits = exactHits + 1 end + local misses = 0 + for other = 0, 23 do + if other ~= card and CardFlip.payout(x, y, other) ~= 0 then + misses = misses + 1 + end + end + check(("(%d,%d) pays on nothing else"):format(x, y), misses, 0) + end +end +check("all 24 exact squares pay 72", exactHits, 24) + +-- The house edge, sampled: every square is bet on against every card, and no +-- square wins more than three cards in twenty-four except the pairs. +local hitCounts = {} +for y = 0, 7 do + for x = 0, 5 do + local hits = 0 + for card = 0, 23 do + if CardFlip.payout(x, y, card) > 0 then hits = hits + 1 end + end + hitCounts[#hitCounts + 1] = hits + end +end +check("a mon pair hits 12 of 24 cards", hitCounts[0 * 6 + 2 + 1], 12) +check("one mon hits 6 of 24", hitCounts[1 * 6 + 2 + 1], 6) +check("a level pair hits 8 of 24", hitCounts[2 * 6 + 0 + 1], 8) +check("one level hits 4 of 24", hitCounts[2 * 6 + 1 + 1], 4) +check("an exact card hits 1 of 24", hitCounts[2 * 6 + 2 + 1], 1) + +-- ------------------------------------------------------- the cursor moves +-- +-- ChooseCard_HandleJoypad. The two teleports and the `and $e` snaps are the +-- whole reason the board is not a plain grid. +local function moved(x, y, dir) + local nx, ny = CardFlip.moveCursor(x, y, dir) + return nx .. "," .. ny +end + +check("the cursor starts on the level 1 Pikachu card", "2,2", "2,2") +check("right steps a column", moved(2, 2, "right"), "3,2") +check("right stops at the last column", moved(5, 2, "right"), "5,2") +check("left steps back", moved(3, 2, "left"), "2,2") +check("left stops at column 0 below the mon rows", moved(0, 2, "left"), "0,2") +check("down steps a row", moved(2, 2, "down"), "2,3") +check("down stops at the last row", moved(2, 7, "down"), "2,7") +check("up steps back", moved(2, 3, "up"), "2,2") + +-- Leaving the level rows upward from a mon column lands on the mon row. +check("up out of the level rows", moved(3, 2, "up"), "3,1") +-- Column 1 (the single level column) teleports to the mon row's Pikachu. +check("up out of column 1 teleports", moved(1, 2, "up"), "2,1") +-- Column 0 spans two rows, so up snaps to the even row first and then steps +-- two -- and from the first pair it teleports. +check("up out of column 0's first pair teleports", moved(0, 3, "up"), "2,1") +check("up from column 0's second pair steps two", moved(0, 5, "up"), "0,2") +check("and snaps the odd row even first", moved(0, 4, "up"), "0,2") +check("down in column 0 steps two", moved(0, 2, "down"), "0,4") +check("down in column 0 stops at the last pair", moved(0, 6, "down"), "0,6") +check("and snaps odd rows even", moved(0, 7, "down"), "0,6") + +-- Row 0 spans two columns the same way. +check("right on the mon-pair row steps two", moved(2, 0, "right"), "4,0") +check("and snaps an odd column even first", moved(3, 0, "right"), "4,0") +check("right stops at the last pair", moved(4, 0, "right"), "4,0") +check("left from the second pair steps two", moved(4, 0, "left"), "2,0") +check("left from the first pair teleports", moved(2, 0, "left"), "1,2") +check("left from the single-mon row teleports", moved(2, 1, "left"), "1,2") +check("left from a further mon column steps", moved(4, 1, "left"), "3,1") +check("up from the mon-pair row goes nowhere", moved(2, 0, "up"), "2,0") +check("down from the mon-pair row reaches the mon row", moved(2, 0, "down"), + "2,1") + +-- Every reachable square is reachable: walking the board from the start with +-- the four directions must cover all 48 squares. +local reached = { ["2,2"] = true } +local frontier = { { 2, 2 } } +while #frontier > 0 do + local at = table.remove(frontier) + for _, dir in ipairs({ "left", "right", "up", "down" }) do + local nx, ny = CardFlip.moveCursor(at[1], at[2], dir) + local key = nx .. "," .. ny + if not reached[key] then + reached[key] = true + frontier[#frontier + 1] = { nx, ny } + end + end +end +local reachedCount = 0 +for _ in pairs(reached) do reachedCount = reachedCount + 1 end +-- 44 of the 48 squares, and the four missing ones are exactly the .Impossible +-- corner: both teleports (.left_to_number_gp and .up_to_mon_group) jump PAST +-- it, so the cursor can never actually rest on a square that always loses. The +-- jumptable entries and the cursor OAM for them are dead code on the cart. +check("every square the cursor can occupy is reachable", reachedCount, 44) +for _, key in ipairs({ "0,0", "1,0", "0,1", "1,1" }) do + check("the impossible square " .. key .. " is unreachable", reached[key], nil) +end + +-- ============================================================ screen flows +-- +-- A stub input and a stub game, enough to walk each screen's state machine. +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + local input = newInput() + return { + input = input, + save = save, + data = nil, -- no audio and no music in this harness + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + }, + }, input +end + +-- The slot machine's bet menu: the top row is three coins (`ld a, 4 / sub b`), +-- and a bet the case cannot cover is refused without deducting. +local slotSave = newSave(2) +local slotGame, slotInput = newGame(slotSave) +local slots = SlotMachine.new(slotGame, { save = slotSave, + random = seeded(5), onClose = function() slotSave.closed = true end }) +check("the machine opens on the bet menu", slots.phase, "bet") +check("with the cursor on three coins", slots.betIndex, 1) +slotInput:press("a") +slots:update(1 / 60) +check("two coins cannot cover a bet of three", slots.phase, "bet") +check("and nothing was deducted", slotSave.player.coins, 2) +check("the refusal is on screen", slots.message, SlotMachine.TEXTS.notEnough) +slotInput:press("a") +slots:update(1 / 60) -- dismiss +slotInput:press("down") +slots:update(1 / 60) +slotInput:press("down") +slots:update(1 / 60) +check("the cursor reaches one coin", slots.betIndex, 3) +slotInput:press("a") +slots:update(1 / 60) +check("a bet of one is taken", slots.bet, 1) +check("and one coin left the case", slotSave.player.coins, 1) +check("the reels are turning", slots.phase, "spinning") + +-- A whole round, driven frame by frame: bet three, stop all three reels, and +-- assert the coins that come back are exactly the payout table's answer for +-- whatever the reels landed on. +local roundSave = newSave(50) +local roundGame, roundInput = newGame(roundSave) +local round = SlotMachine.new(roundGame, { save = roundSave, + random = seeded(11) }) +roundInput:press("a") +round:update(1 / 60) +check("the top row bets three", round.bet, 3) +check("and three coins leave the case", roundSave.player.coins, 47) +-- SlotsAction_WaitStart's 32 frame lockout, so a held A cannot stop reel one. +for _ = 1, 33 do round:update(1 / 60) end +for reel = 1, 3 do + roundInput:press("a") + round:update(1 / 60) + for _ = 1, 600 do + if round.stopped and round.stopped[reel] then break end + round:update(1 / 60) + end + check(("reel %d settles"):format(reel), + round.stopped and round.stopped[reel] ~= nil, true) +end +check("the round resolved into a payout or a Darn!", + round.phase == "flash" or round.phase == "payoutText", true) +local owed = SlotMachine.payout(round.matched) +for _ = 1, 3000 do + if round.phase == "again" or round.phase == "ranOut" then break end + if round.phase == "payoutText" and round.matched == SlotMachine.NO_MATCH then + roundInput:press("a") + end + round:update(1 / 60) +end +check("the payout counter emptied", round.payoutLeft, 0) +check("and the case holds the stake back plus the win", + roundSave.player.coins, 47 + owed) +check("the machine offers another go", + round.phase == "again" or round.phase == "ranOut", true) + +-- Card flip: the first question costs three coins, and a case with two is told +-- so and shown the door. +local flipSave = newSave(2) +local flipGame, flipInput = newGame(flipSave) +local flip = CardFlip.new(flipGame, { save = flipSave, random = seeded(3), + onClose = function() flipSave.closed = true end }) +check("card flip opens on the question", flip.phase, "ask") +flipInput:press("a") +flip:update(1 / 60) +check("two coins is not enough", flip.phase, "message") +check("and the bet was not taken", flipSave.player.coins, 2) +flipInput:press("a") +flip:update(1 / 60) +check("dismissing the refusal leaves", flipSave.closed, true) + +local flipSave2 = newSave(10) +local flipGame2, flipInput2 = newGame(flipSave2) +local flip2 = CardFlip.new(flipGame2, { save = flipSave2, random = seeded(3) }) +flipInput2:press("a") +flip2:update(1 / 60) +check("three coins buy a hand", flipSave2.player.coins, 7) +check("and the cards come out", flip2.phase, "choose") +flipInput2:press("a") +flip2:update(1 / 60) +check("A locks the card and opens the bet", flip2.phase, "bet") +-- Bet on the square that matches whatever was dealt, and assert it pays 72. +local dealt = CardFlip.dealt(flip2.deck, 0, flip2.which) +flip2.cursorX = CardFlip.mon(dealt) + 2 +flip2.cursorY = CardFlip.level(dealt) + 2 +flipInput2:press("a") +flip2:update(1 / 60) +check("the exact square pays 72", flip2.payoutLeft, 72) +check("and the card is discarded", flip2.discarded[dealt], true) +for _ = 1, 200 do flip2:update(1 / 60) end +check("the coins are counted out", flipSave2.player.coins, 7 + 72) +check("and the hand is over", flip2.phase, "result") + +-- The payout stops at the cap rather than wrapping. +local capSave = newSave(9990) +local capGame = newGame(capSave) +local capFlip = CardFlip.new(capGame, { save = capSave, random = seeded(3) }) +capFlip.payoutLeft = 72 +capFlip.payoutTick = 0 +capFlip.phase = "payout" +for _ = 1, 400 do capFlip:update(1 / 60) end +check("a payout past the cap clamps", capSave.player.coins, 9999) + +-- The prize counter screen: no COIN CASE is the very first refusal. +local caseless = newSave(5000) +local caselessGame, caselessInput = newGame(caseless) +local counter = PrizeMenu.new(caselessGame, { save = caseless, + counter = "CELADON_TM", texts = "CELADON", data = data, + onClose = function() caseless.closed = true end }) +check("the intro plays first", counter.message ~= nil, true) +-- Three pages of Welcome, then the refusal, then the door. +for _ = 1, 3 do + caselessInput:press("a") + counter:update(1 / 60) +end +check("the counter never opens its menu", counter.phase, nil) +caselessInput:press("a") +counter:update(1 / 60) +check("a player with no COIN CASE is turned away", caseless.closed, true) + +local shopper = newSave(5000) +shopper.inventory.COIN_CASE = 1 +local shopGame, shopInput = newGame(shopper) +local shop = PrizeMenu.new(shopGame, { save = shopper, counter = "CELADON_TM", + texts = "CELADON", data = data, + onClose = function() shopper.closed = true end }) +for _ = 1, 3 do + shopInput:press("a") + shop:update(1 / 60) +end +check("the menu opens", shop.phase, "menu") +check("with four rows including CANCEL", #shop.prizes, 4) +check("and the cursor on the first prize", shop.index, 1) +shopInput:press("a") +shop:update(1 / 60) +check("choosing TM32 asks first", shop.confirm ~= nil, true) +shopInput:press("a") +shop:update(1 / 60) +check("yes buys it", shopper.inventory.TM_DOUBLE_TEAM, 1) +check("and takes the coins", shopper.player.coins, 3500) +-- The second TM costs 3500, which is exactly what is left. +shopInput:press("down") +shop:update(1 / 60) +shopInput:press("a") +shop:update(1 / 60) -- dismiss "Here you go!" +check("the loop returns to the menu", shop.phase, "menu") + +-- The Goldenrod mon counter's checkver swap. +local silverShop = PrizeMenu.new(newGame(newSave(0)), + { save = newSave(0), counter = "GOLDENROD_MON", texts = "GOLDENROD", + version = "silver", data = data, hasCoinCase = true }) +check("Silver's second prize is SANDSHREW", silverShop.prizes[2].id, + "SANDSHREW") +local goldShop = PrizeMenu.new(newGame(newSave(0)), + { save = newSave(0), counter = "GOLDENROD_MON", texts = "GOLDENROD", + version = "gold", data = data, hasCoinCase = true }) +check("Gold's second prize is EKANS", goldShop.prizes[2].id, "EKANS") + +-- ============================================ the counters the GAME reaches +-- +-- Everything above is a model. None of it says a prize counter can be OPENED, +-- and none of it is what opens one: on the cart no counter is a screen at all. +-- Each is a `bg_event ..., BGEVENT_READ, ...Vendor` (CeladonGameCornerPrizeRoom +-- .asm, GoldenrodGameCorner.asm) and the whole transaction is script bytecode -- +-- `special DisplayCoinCaseBalance`, `loadmenu`, `verticalmenu`, `checkcoins`, +-- `giveitem` / `givepoke`, `takecoins`. So this drives the REAL extracted +-- script through the VM off the map's own bg event, which is the only assertion +-- that says the counter works where a player stands. +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") + +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local cacheMaps = loadCache("maps") +local cacheScripts = loadCache("scripts") +local cacheText = loadCache("text") +local cacheConsts = loadCache("constants") + +-- One counter conversation, driven to a stop. `pick` answers every +-- `verticalmenu`; the log is what the player would have seen. +local function runCounter(key, opts) + opts = opts or {} + local save = opts.save or newSave(opts.coins or 0, opts.money or 0) + local log, seen = {}, {} + local vm + vm = Vm.new(cacheScripts, cacheText, Events.new(), { + specialOrder = cacheConsts and cacheConsts.specialOrder, + specials = { save = function() return save end, + monName = function(index) return "MON" .. tostring(index) end }, + showText = function(body, onDone) log[#log + 1] = body; onDone() end, + facePlayer = function() end, + yesorno = function(onChoose) onChoose(opts.yes ~= false) end, + -- Every counter LOOPS back to its own menu after a sale, so only the first + -- pass buys and the second cancels out; otherwise the run below empties + -- the case rather than making one purchase. + openMenu = function(header, _style, onChoose) + seen[#seen + 1] = { balance = vm.balanceKind, items = header.items } + vm.balanceKind = nil + onChoose(#seen == 1 and (opts.pick or 0) or 0) + end, + -- The two seams the balance boxes hang off: engine/menus/menu_2.asm draws + -- them and returns, and the `loadmenu` that follows is what shows them. + showCoins = function() vm.balanceKind = "coins" end, + showMoney = function(kind) vm.balanceKind = kind or "money" end, + hasItem = function() return opts.coinCase ~= false end, + giveItem = function(index, qty) + save.inventory[index] = (save.inventory[index] or 0) + (qty or 1) + return true + end, + givePoke = function(species, level) + save.party[#save.party + 1] = { species = species, level = level } + end, + getCoins = function() return save.player.coins end, + setCoins = function(value) save.player.coins = value end, + getMoney = function() return save.player.money end, + setMoney = function(_account, value) save.player.money = value end, + getItemName = function(index) return "ITEM" .. tostring(index) end, + getMonName = function(index) return "MON" .. tostring(index) end, + readVar = function() return #save.party end, + playSound = function() end, + waitSfx = function() return true end, + }) + vm:start(key) + for _ = 1, 400 do vm:update() end + return save, seen, log +end + +if not (cacheMaps and cacheScripts and cacheConsts) then + check("no GOLD_CACHE: the counters are not driven off the map (SKIP)", + true, true) +else + -- The two Celadon counters are bg events 1 and 2 of the prize room, in the + -- order the .asm lists them: the TM vendor at (2,1), the mon vendor at (4,1). + local room = cacheMaps.CELADON_GAME_CORNER_PRIZE_ROOM + local tmEvent = room and room.bgEvents and room.bgEvents[1] + local monEvent = room and room.bgEvents and room.bgEvents[2] + check("the Celadon TM counter is a readable bg event", + tmEvent ~= nil and (tmEvent.kind or 0) == 0, true) + check("and so is the Celadon mon counter", + monEvent ~= nil and (monEvent.kind or 0) == 0, true) + check("the TM counter's script is in the cache", + cacheScripts[tmEvent.scriptKey] ~= nil, true) + + local bought, menus = runCounter(tmEvent.scriptKey, { coins = 5000, pick = 1 }) + check("reading the counter opens its prize list", + menus[1] and menus[1].items[1], "TM32 1500") + check("with the COIN box up beside it", menus[1] and menus[1].balance, + "coins") + check("TM32 is handed over", bought.inventory[224], 1) + check("and 1500 coins are taken", bought.player.coins, 3500) + + -- No COIN CASE is the very first refusal: no menu at all. + local _, noMenus = runCounter(tmEvent.scriptKey, + { coins = 5000, coinCase = false, pick = 1 }) + check("without a COIN CASE the counter never opens", #noMenus, 0) + + -- The mon counter hands over a party member instead, and the dex entry is + -- `special GameCornerPrizeMonCheckDex` on the way past. + local won = runCounter(monEvent.scriptKey, { coins = 9999, pick = 1 }) + check("the mon counter gives a prize mon", #won.party, 1) + check("the coins are taken at 3333", won.player.coins, 9999 - 3333) + check("and it is registered as caught", + won.pokedex and won.pokedex.caught["MON122"], true) + + -- Goldenrod puts its counters behind PEOPLE rather than counter tiles: the + -- clerk who sells coins and the two receptionists, objects 1 to 3 in the + -- order GoldenrodGameCorner.asm lists them. + local goldenrod = cacheMaps.GOLDENROD_GAME_CORNER + local objects = (goldenrod and goldenrod.objects) or {} + local _, tmMenus = runCounter(objects[2] and objects[2].scriptKey, + { coins = 9999, pick = 0 }) + check("the Goldenrod TM counter opens its list", + tmMenus[1] and tmMenus[1].items[1], "TM25 5500") + check("under the COIN box too", tmMenus[1] and tmMenus[1].balance, "coins") + local _, monMenus = runCounter(objects[3] and objects[3].scriptKey, + { coins = 9999, pick = 0 }) + check("and the Goldenrod mon counter sells Gold's EKANS", + monMenus[1] and monMenus[1].items[2], "EKANS 700") + + -- The coin vendor beside them is a `jumpstd` to GameCornerCoinVendorScript, + -- and its box is the MONEY + COIN one (DisplayMoneyAndCoinBalance) rather + -- than the COIN box the prize counters use. + local _, coinMenus = runCounter(objects[1] and objects[1].scriptKey, + { money = 999999, pick = 1 }) + check("the coin vendor shows MONEY and COIN together", + coinMenus[1] and coinMenus[1].balance, "moneycoins") + check("and 50 coins cost ¥1000", coinMenus[1] and coinMenus[1].items[1], + " 50 : \xc2\xa51000") +end + +-- The boxes themselves (engine/menus/menu_2.asm through +-- src/ui/gen2/Chrome.lua): the yen field is six digits wide with the ¥ against +-- the first significant digit, and the coin field is four digits with leading +-- zeroes. +local Chrome = require("src.ui.gen2.Chrome") +check("¥1000 is right-aligned in a six-digit field", Chrome.money(1000), + " \xc2\xa51000") +check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999") +check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true), + "0050") + +print(("gen2 game corner: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit +-- here would take the whole tier down and silently skip every suite after it. +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_halloffame_test.lua b/tests/gen2_halloffame_test.lua new file mode 100644 index 00000000..720bd4b3 --- /dev/null +++ b/tests/gen2_halloffame_test.lua @@ -0,0 +1,714 @@ +-- The end of the game: the Hall of Fame roster, its induction and its viewer, +-- and the credits movie. +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_halloffame_test.lua +-- +-- Three things are asserted here and nowhere else: +-- +-- the ROSTER FORMAT one win count and up to six mons per row, newest +-- first, thirty rows deep, and what survives a save +-- round trip and a format-1 migration +-- the LAYOUTS every hlcoord DisplayHOFMon and HOF_AnimatePlayerPic +-- write to, as data, so a coordinate can be checked +-- against engine/events/halloffame.asm without a +-- graphics device +-- the CREDITS TIMING that the scene script runs to CREDITS_END, at the +-- 13-frame pass the jumptable actually is, and that +-- the tilemap only reaches the screen on `.wait` +-- +-- What a test cannot say -- whether the induction LOOKS like Gold's -- is what +-- tests/drivers/gold_halloffame_shots.lua exists for. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 hall of fame") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +local Core = require("src.core.gen2.HallOfFame") +local Credits = require("src.ui.gen2.Credits") +local HallOfFame = require("src.ui.gen2.HallOfFame") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") + +-- ---- fixtures ------------------------------------------------------------- + +local POKEMON = { + TYPHLOSION = { name = "TYPHLOSION", index = 157, dex = 157, + spriteFront = "front/typhlosion.png", spriteBack = "back/typhlosion.png" }, + LANTURN = { name = "LANTURN", index = 171, dex = 171, + spriteFront = "front/lanturn.png", spriteBack = "back/lanturn.png" }, + UMBREON = { name = "UMBREON", index = 197, dex = 197 }, +} + +local function mon(species, level, nickname, otId) + return { + species = species, level = level, nickname = nickname, otId = otId, + dvs = { attack = 15, defense = 10, speed = 10, special = 10 }, + gender = "male", + } +end + +local function newSave() + local save = Save.newGame({ playerName = "GOLD", trainerId = 12345 }) + save.playTime = { hours = 42, minutes = 7, seconds = 0, frames = 0 } + save.party = { + mon("TYPHLOSION", 55, "BLAZE", 12345), + mon("LANTURN", 51, "SPARK", 12345), + } + return save +end + +-- ---- constants ------------------------------------------------------------ + +-- constants/pokemon_data_constants.asm and constants/misc_constants.asm. +eq(Core.NUM_TEAMS, 30, "NUM_HOF_TEAMS") +eq(Core.PARTY_LENGTH, 6, "PARTY_LENGTH") +eq(Core.MON_LENGTH, 0x10, "HOF_MON_LENGTH") +eq(Core.LENGTH, 0x62, "HOF_LENGTH") +eq(Core.NAME_LENGTH, 10, "MON_NAME_LENGTH - 1, what the nickname copy takes") +eq(Core.MASTER_COUNT, 200, "HOF_MASTER_COUNT") + +-- ---- the win counter ------------------------------------------------------ + +local save = newSave() +eq(Core.count(save), 0, "a fresh save has never been inducted") +eq(Core.hasEntered(save), false, "and STATUSFLAGS_HALL_OF_FAME_F is clear") +eq(Core.bumpCount(save), 1, "the first induction counts") +eq(Core.bumpCount(save), 2, "and the second") + +-- `ld a, [hl] / cp HOF_MASTER_COUNT / jr nc, .ok / inc [hl]`: the test is on +-- the PRE-increment value, so the counter stops AT HOF_MASTER_COUNT. +save.hallOfFame.count = Core.MASTER_COUNT - 1 +eq(Core.bumpCount(save), Core.MASTER_COUNT, "199 still counts") +eq(Core.bumpCount(save), Core.MASTER_COUNT, "200 does not") +eq(Core.bumpCount(save), Core.MASTER_COUNT, "and never will again") + +-- ---- GetHallOfFameParty --------------------------------------------------- + +save = newSave() +save.hallOfFame.count = 3 +save.party = { + mon("TYPHLOSION", 55, "BLAZE", 12345), + { species = "EGG", level = 5, isEgg = true }, + mon("LANTURN", 51, "SPARKLE-TOO-LONG", 12345), +} +local entry = Core.buildParty(save, save.party) +eq(entry.winCount, 3, "the row opens with wHallOfFameCount") +eq(#entry.mons, 2, "and an EGG is skipped rather than stored") +eq(entry.mons[1].species, "TYPHLOSION", "slot 1") +eq(entry.mons[2].species, "LANTURN", "the mon behind the egg keeps its own data") +eq(entry.mons[2].nickname, "SPARKLE-TO", + "a nickname is cut to MON_NAME_LENGTH - 1") +eq(entry.mons[1].level, 55, "the level is stored") +eq(entry.mons[1].otId, 12345, "so is MON_OT_ID") +check(entry.mons[1].dvs ~= nil, "and MON_DVS") + +-- PARTY_LENGTH is the cap even if something upstream handed over more. +save.party = {} +for index = 1, 9 do save.party[index] = mon("UMBREON", index, "U" .. index, 1) end +eq(#Core.buildParty(save, save.party).mons, Core.PARTY_LENGTH, + "a row never holds more than PARTY_LENGTH mons") + +-- ---- AddHallOfFameEntry --------------------------------------------------- + +save = newSave() +for index = 1, Core.NUM_TEAMS + 5 do + Core.addEntry(save, { winCount = index, mons = { mon("UMBREON", index) } }) +end +eq(#save.hallOfFame.teams, Core.NUM_TEAMS, + "the roster is capped at NUM_HOF_TEAMS") +eq(save.hallOfFame.teams[1].winCount, Core.NUM_TEAMS + 5, + "newest first: the last row added is row 1") +eq(save.hallOfFame.teams[Core.NUM_TEAMS].winCount, 6, + "and the five oldest fell off the end") + +-- LoadHOFTeam's two ways of saying "stop". +eq(Core.team(save, 0), nil, "there is no team 0") +eq(Core.team(save, Core.NUM_TEAMS + 1), nil, "nor one past NUM_HOF_TEAMS") +eq(Core.teamCount(save), Core.NUM_TEAMS, "every slot is filled") +save.hallOfFame.teams[3].winCount = 0 +eq(Core.team(save, 3), nil, "a row whose win count is zero reads as absent") +eq(Core.teamCount(save), 2, "and the PC's master loop stops there") + +-- ---- the induction -------------------------------------------------------- + +save = newSave() +local saved = false +local row, wasEntered = Core.induct(save, save.party, { + saveFn = function() saved = true end, +}) +eq(wasEntered, false, "the first champion had not entered before") +eq(Core.hasEntered(save), true, "STATUSFLAGS_HALL_OF_FAME_F is set") +eq(Core.count(save), 1, "the counter moved") +eq(save.spawnAfterChampion, Core.SPAWN_LANCE, "wSpawnAfterChampion = SPAWN_LANCE") +eq(saved, true, "and the game was saved") +eq(#save.hallOfFame.teams, 1, "one row on the roster") +eq(save.hallOfFame.teams[1], row, "and it is the row that was returned") +eq(row.winCount, 1, "whose win count is the POST-increment counter") +eq(#row.mons, 2, "carrying the party that walked in") +eq(row.mons[1].nickname, "BLAZE", "with its nicknames") + +local _, second = Core.induct(save, save.party) +eq(second, true, "a repeat champion HAD entered before") +eq(Core.count(save), 2, "and the counter moved again") +eq(#save.hallOfFame.teams, 2, "with a second row in front of the first") +eq(save.hallOfFame.teams[2].winCount, 1, "the first induction slid back one") + +-- ---- the post-game continue ----------------------------------------------- + +eq(Core.consumePostGameSpawn(save), "SPAWN_NEW_BARK", + "CONTINUE after the champion starts at New Bark Town") +eq(save.spawnAfterChampion, nil, "PostCreditsSpawn clears the byte") +eq(Core.consumePostGameSpawn(save), nil, "so the next load is ordinary") + +Core.markRedCredits(save) +eq(save.spawnAfterChampion, Core.SPAWN_RED, "RedCredits sets SPAWN_RED") +eq(Core.consumePostGameSpawn(save), "SPAWN_MT_SILVER", + "and that continue starts at Mt. Silver") + +-- ---- the save round trip -------------------------------------------------- + +save = newSave() +Core.induct(save, save.party) +save.hallOfFame.teams[1].mons[1].nickname = "BLAZE" +check(Save.save(save), "the save is written") +local loaded, recovered, err = Save.load("gold") +eq(err, nil, "and reads back") +eq(recovered, nil, "off the main file") +eq(Core.count(loaded), 1, "the win count survived") +eq(#loaded.hallOfFame.teams, 1, "so did the roster") +eq(loaded.hallOfFame.teams[1].mons[1].species, "TYPHLOSION", "and the row") +eq(loaded.hallOfFame.teams[1].mons[1].nickname, "BLAZE", "with its nickname") +eq(loaded.hallOfFame.teams[1].mons[1].level, 55, "and its level") +eq(loaded.spawnAfterChampion, Core.SPAWN_LANCE, + "the pending post-game spawn survives the save that the induction writes") + +-- ---- the migration -------------------------------------------------------- + +-- A save written before the endgame existed has no roster at all; normalize +-- and migrate both have to leave one behind rather than an index error. +local old = Save.migrate({ format = 1, version = "gold" }) +eq(old.format, Save.FORMAT, "a format-1 save migrates to the current format") +eq(old.hallOfFame.count, 0, "with an empty Hall of Fame") +eq(#old.hallOfFame.teams, 0, "and no roster") +eq(old.spawnAfterChampion, nil, "and no pending warp on the next load") +eq(Core.hasEntered(old), false, "it has never been inducted") + +local bare = Save.normalize({ version = "gold" }) +check(bare.hallOfFame ~= nil, "normalize fills the block in too") +eq(Core.count(bare), 0, "at zero") + +-- A corrupt file with more rows than SRAM could hold is trimmed, the same way +-- an over-long party is. +local overfull = { version = "gold", hallOfFame = { count = 1, teams = {} } } +for index = 1, Core.NUM_TEAMS + 4 do + overfull.hallOfFame.teams[index] = { winCount = index, mons = {} } +end +Save.normalize(overfull) +eq(#overfull.hallOfFame.teams, Core.NUM_TEAMS, "the roster is trimmed on load") + +-- ---- DisplayHOFMon's layout ----------------------------------------------- + +local at = HallOfFame.at +local hofMon = { + species = "TYPHLOSION", nickname = "BLAZE", level = 55, otId = 12345, + gender = "male", +} +local placements = HallOfFame.monPlacements(hofMon, POKEMON.TYPHLOSION) +eq(at(placements, 1, 13), "№.", "hlcoord 1,13 is '№' then '.'") +eq(at(placements, 3, 13), "157", "hlcoord 3,13 is the dex number, 3 digits") +eq(at(placements, 7, 13), "TYPHLOSION", "hlcoord 7,13 is GetBasePokemonName") +eq(at(placements, 18, 13), "♂", "hlcoord 18,13 is the gender") +eq(at(placements, 8, 14), "/", "hlcoord 8,14 is a bare '/'") +eq(at(placements, 9, 14), "BLAZE", "so the nickname starts at 9,14") +eq(at(placements, 1, 16), "<LV>55", "hlcoord 1,16 is PrintLevel") +eq(at(placements, 7, 16), "<ID>№/", "hlcoord 7,16 is three single tiles") +eq(at(placements, 10, 16), "12345", "hlcoord 10,16 is the ID, 5 digits") + +-- PRINTNUM_LEADINGZEROS on both numeric fields. +local lowIds = HallOfFame.monPlacements( + { species = "UMBREON", nickname = "U", level = 5, otId = 7 }, POKEMON.UMBREON) +eq(at(lowIds, 10, 16), "00007", "a low ID keeps its leading zeros") +eq(at(lowIds, 3, 13), "197", "and so does the dex number") + +-- A genderless species writes a space, which is nothing to print. +local nogender = HallOfFame.monPlacements( + { species = "UMBREON", nickname = "U", level = 5, otId = 1 }, POKEMON.UMBREON) +eq(at(nogender, 18, 13), nil, "a genderless mon gets no symbol") + +-- `.print_id_no` is jumped to for an EGG: only the ID line prints. +local egg = HallOfFame.monPlacements({ species = "EGG", otId = 1 }, nil) +eq(at(egg, 1, 13), nil, "an EGG skips the species block") +eq(at(egg, 7, 16), "<ID>№/", "but still prints its ID") + +-- ---- the two header lines ------------------------------------------------- + +local induct = HallOfFame.headerPlacements("induct", 1) +eq(at(induct, 1, 2), HallOfFame.NEW_FAMER, "the induction says New Hall of Famer!") +eq(HallOfFame.NEW_FAMER, "New Hall of Famer!", ".String_NewHallOfFamer") + +local viewed = HallOfFame.headerPlacements("view", 12) +eq(at(viewed, 1, 2), " -Time Famer", ".TimeFamer, spaces and all") +eq(at(viewed, 2, 2), " 12", "and PrintNum writes over them at hlcoord 2,2") + +-- BUG (docs/bugs_and_glitches.md): the counter stops at HOF_MASTER_COUNT and +-- the title needs HOF_MASTER_COUNT + 1, so "HOF Master!" is unreachable. +eq(at(HallOfFame.headerPlacements("view", Core.MASTER_COUNT), 1, 2), + " -Time Famer", "200 wins is still a -Time Famer") +eq(at(HallOfFame.headerPlacements("view", Core.MASTER_COUNT + 1), 1, 2), + " HOF Master!", "and only an impossible 201 reaches the title") + +-- ---- HOF_AnimatePlayerPic's layout ---------------------------------------- + +save = newSave() +local card = HallOfFame.playerPlacements(save) +eq(at(card, 2, 4), "GOLD", "hlcoord 2,4 is wPlayerName") +eq(at(card, 1, 6), "<ID>№/", "hlcoord 1,6 is the ID caption") +eq(at(card, 4, 6), "12345", "hlcoord 4,6 is wPlayerID, 5 digits") +eq(at(card, 1, 8), "PLAY TIME", ".PlayTime") +eq(at(card, 3, 9), " 42", "hlcoord 3,9 is the hours in three columns") +eq(at(card, 6, 9), ":", "then HALLOFFAME_COLON") +eq(at(card, 7, 9), "07", "then the minutes with a leading zero") + +-- ---- the induction cinematic ---------------------------------------------- + +save = newSave() +row = Core.induct(save, save.party) +local finished = false +local screen = HallOfFame.new({ data = { pokemon = POKEMON } }, { + save = save, entry = row, onDone = function() finished = true end, +}) +eq(screen.mode, "induct", "the default mode is the induction") +eq(screen.phase, "backpic", "which opens on AnimateHOFMonEntrance") +eq(screen.scx, HallOfFame.BACKPIC_SCX_START, "hSCX starts at $90") +eq(screen.scy, HallOfFame.SCY_START, "hSCY at $d0") + +-- HOF_SlideBackpic adds 4 a frame until hSCX READS $70, which from $90 is the +-- long way round the byte: 56 frames, not 8. +local frames = 0 +while screen.phase == "backpic" and frames < 200 do + screen:step() + frames = frames + 1 +end +eq(frames, 56, "the backpic sweep is 56 frames") +eq(screen.scx, HallOfFame.BACKPIC_SCX_END, "and ends with hSCX at $70") +eq(screen.scy, 0, "with hSCY zeroed for the frontpic") + +-- HOF_SlideFrontpic takes 2 off a frame from $70 down to 0. +frames = 0 +while screen.phase == "frontpic" and frames < 200 do + screen:step() + frames = frames + 1 +end +eq(frames, 56, "the frontpic slide is 56 frames") +eq(screen.scx, 0, "and lands on hSCX 0") +eq(screen.phase, "display", "then the mon is displayed") + +-- `ld c, 180 / call DelayFrames`. +frames = 0 +while screen.phase == "display" and frames < 400 do + screen:step() + frames = frames + 1 +end +eq(frames, HallOfFame.FAMER_FRAMES, "each Hall of Famer holds for 180 frames") +eq(screen.index, 2, "and the counter moves to the next party slot") + +-- Run the rest: the second mon, then HOF_AnimatePlayerPic. +frames = 0 +while not screen.done and frames < 5000 do + screen:step() + frames = frames + 1 +end +check(screen.done, "the induction reaches the end") +eq(finished, true, "and calls onDone") +-- 292 frames a mon (56 + 56 + 180), then 56 + 96 + 8 for the player card. +eq(frames, 292 + 56 + 96 + HallOfFame.END_FRAMES, + "the whole cinematic is the sum of its DelayFrames") + +-- ---- the PC's viewer ------------------------------------------------------ + +save = newSave() +Core.induct(save, save.party) +local closed = false +local input = { pressed = {} } +function input:press(button) self.pressed[button] = true end +function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false +end +function input:isDown() return false end + +local viewer = HallOfFame.new({ data = { pokemon = POKEMON }, input = input }, { + mode = "view", save = save, onDone = function() closed = true end, +}) +eq(viewer.phase, "display", "the viewer opens straight on a mon") +eq(viewer.team, 1, "on the newest team") +eq(viewer.index, 1, "and its first mon") +input:press("a") +viewer:update(0) +eq(viewer.index, 2, "A is the next mon") +-- The team holds two, so the next A runs off the end and onto team 2, which +-- does not exist -- LoadHOFTeam returns carry and the screen closes. +input:press("a") +viewer:update(0) +eq(closed, true, "and running off the roster ends the viewer") + +closed = false +viewer = HallOfFame.new({ data = { pokemon = POKEMON }, input = input }, { + mode = "view", save = save, onDone = function() closed = true end, +}) +input:press("b") +viewer:update(0) +eq(closed, true, "B backs out at once") + +-- An empty roster has nothing to show, and the callback is deferred to the +-- first update so it cannot pop a state that has not been pushed. +closed = false +local empty = HallOfFame.new({ data = {}, input = input }, { + mode = "view", save = newSave(), onDone = function() closed = true end, +}) +eq(closed, false, "an empty roster does not call onDone during construction") +empty:update(0) +eq(closed, true, "it calls it on the first update instead") + +-- ---- the credits: the script ---------------------------------------------- + +eq(Credits.END, 0xff, "CREDITS_END") +eq(Credits.WAIT, 0xfe, "CREDITS_WAIT") +eq(Credits.SCENE, 0xfd, "CREDITS_SCENE") +eq(Credits.CLEAR, 0xfc, "CREDITS_CLEAR") +eq(Credits.MUSIC, 0xfb, "CREDITS_MUSIC") +eq(Credits.WAIT2, 0xfa, "CREDITS_WAIT2") +eq(Credits.THEEND, 0xf9, "CREDITS_THEEND") +eq(Credits.PASS_FRAMES, 13, "Credits_Jumptable is 13 entries, one a frame") + +-- The strings the two comparisons in ParseCredits key off. +check(Credits.STAFF < Credits.COPYRIGHT, + "COPYRIGHT sits above STAFF, as in credits_constants.asm") +check(Credits.ID.BRYANTHABOI < Credits.STAFF, + "and every person string sits below it") + +local movie = Credits.new({ data = {} }, {}) +eq(movie.step, 0, "the jumptable starts at ParseCredits") +eq(movie.borderFrame, 0xff, "with the banner blanked") +eq(movie.timer, 0, "and the timer out, so the first pass parses") + +local ranFor = movie:runToEnd(60000) +check(ranFor ~= nil, "the scene script runs to CREDITS_END") +eq(movie.exiting, true, "which sets JUMPTABLE_EXIT_F") +eq(movie.scene, 3, "after four CREDITS_SCENE changes") +check(movie.passes > 300, "over more than 300 passes of the jumptable") +-- 13 frames a pass is the whole point of the cadence. +eq(ranFor > movie.passes * (Credits.PASS_FRAMES - 1), true, + "and roughly 13 frames apiece") + +-- "THE END" is written by CREDITS_THEEND and survives the blank ParseCredits +-- runs immediately before CREDITS_END, because CREDITS_END never pushes the +-- tilemap to the screen. +eq(#movie.shown, 1, "one thing is on screen at the end") +eq(movie.shown[1].theEnd, true, "and it is THE END") +eq(movie.shown[1].x, 6, "at hlcoord 6, 8") +eq(movie.shown[1].y, 8, "the coordinate Credits_TheEnd writes") + +-- ---- the credits: the parse arms ------------------------------------------ + +-- A hand-built script exercising each command on its own. +local probe = Credits.new({ data = {} }, { + script = { + Credits.SCENE, 2, + Credits.ID.DIRECTOR, 0, + Credits.ID.BRYANTHABOI, 1, + Credits.WAIT, 3, + Credits.ID.PRODUCER, 2, + Credits.WAIT2, 1, + Credits.CLEAR, + Credits.WAIT, 1, + Credits.END, + }, +}) +probe:parse() +eq(probe.scene, 2, "CREDITS_SCENE picks the banner mon") +eq(probe.borderFrame, 0, "and resets its animation frame") +eq(#probe.shown, 2, "CREDITS_WAIT pushes the tilemap") +eq(probe.shown[1].y, Credits.TEXT_FIRST_ROW, "line 0 is row 6") +eq(probe.shown[2].y, Credits.TEXT_FIRST_ROW + Credits.LINE_SPACING, + "line 1 is row 8, two rows on") +eq(probe.shown[1].x, 0, "and both start at column 0") +eq(probe.timer, 3, "the wait is three passes") + +-- Three passes of nothing, then the next parse. +probe:parse(); probe:parse(); probe:parse() +eq(probe.timer, 0, "the timer runs out") +probe:parse() +eq(#probe.shown, 2, + "CREDITS_WAIT2 leaves the previous screen up rather than pushing") +eq(#probe.pending, 1, "even though a new line was written into the tilemap") +probe:parse() -- burn the one-pass wait CREDITS_WAIT2 set +probe:parse() +eq(probe.borderFrame, 0xff, "CREDITS_CLEAR blanks the banner") +eq(#probe.shown, 0, "and the wait behind it pushes the cleared tilemap") + +-- The multi-line strings step by <NEXT>, which is two rows. +local multi = Credits.new({ data = {} }, { + script = { Credits.ID.STAFF, 0, Credits.WAIT, 1, Credits.END }, +}) +multi:parse() +eq(#multi.shown, 3, "the STAFF heading is three lines") +eq(multi.shown[1].y, 6, "at rows 6,") +eq(multi.shown[2].y, 8, "8") +eq(multi.shown[3].y, 10, "and 10") + +-- COPYRIGHT is the one string with an hlcoord of its own. +local copy = Credits.new({ data = {} }, { + script = { Credits.COPYRIGHT, 0, Credits.WAIT, 1, Credits.END }, +}) +copy:parse() +eq(copy.shown[1].x, 2, "Credits_Copyright prints at hlcoord 2, 6") +eq(copy.shown[1].y, 6, "on row 6") + +-- ---- the credits: the banner and the border ------------------------------- + +local banner = Credits.new({ data = {} }, {}) +eq(banner:borderGraphic(), nil, "a cleared banner has no graphic") +banner.borderFrame = 0 +banner.scene = 0 +eq(banner:borderGraphic(), 1, "Bellossom frame 0 is its first graphic") +banner:advanceBorder() +eq(banner:borderGraphic(), 2, "frame 1 the second") +banner:advanceBorder() +eq(banner:borderGraphic(), 1, "frame 2 repeats the first, as .Frames does") +banner:advanceBorder() +eq(banner:borderGraphic(), 3, "and frame 3 the third") +banner:advanceBorder() +eq(banner.borderFrame, 0, "then the frame wraps") +banner.scene = 3 +eq(banner:borderGraphic(), 1, "Sentret is the one mon with four distinct frames") +banner.borderFrame = 2 +eq(banner:borderGraphic(), 3, "so its frame 2 is its own graphic") +banner.borderFrame = 0xff +banner:advanceBorder() +eq(banner.borderFrame, 0xff, "a blanked banner stays blanked") + +eq(banner.lyOverride, 0, "wCreditsLYOverride starts at zero") +banner:advanceLY() +eq(banner.lyOverride, 2, "and gains two a pass") + +-- Four scene palettes, straight off gfx/credits/credits.pal. GetCreditsPalette +-- masks the scene with %11, so there are exactly four and no fifth. +check(Credits.PALETTES[3] ~= nil, "four palettes, indexed from zero") +eq(Credits.PALETTES[4], nil, "and nothing past the %11 mask") +eq(Credits.PALETTES[0][1][1], 255, "colour 0 is white") +eq(Credits.PALETTES[0][4][1], 58, "and colour 3 the near-black RGB 07,07,07") + +-- ---- the credits: gfx/credits/ -------------------------------------------- + +-- RomExtractorGen2:extractCredits puts the real sheets in the cache as +-- data.gen2Credits, and every one of them has to beat the mon-icon fallback. +local GFX = { + border = "assets/generated/credits/border.png", + borderTiles = 9, + borderTopTile = 5, + borderBottomTile = 1, + borderFillTile = 9, + theEnd = "assets/generated/credits/theend.png", + theEndX = 6, theEndY = 8, theEndWidth = 8, + scenes = { + { species = "BELLOSSOM", image = "a.png", frames = 3, width = 32, height = 32 }, + { species = "TOGEPI", image = "b.png", frames = 3, width = 32, height = 32 }, + { species = "ELEKID", image = "c.png", frames = 3, width = 32, height = 32 }, + { species = "SENTRET", image = "d.png", frames = 4, width = 32, height = 32 }, + }, + palettes = { + { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } }, + { { 5, 5, 5 }, { 6, 6, 6 }, { 7, 7, 7 }, { 8, 8, 8 } }, + { { 9, 9, 9 }, { 10, 10, 10 }, { 11, 11, 11 }, { 12, 12, 12 } }, + { { 13, 13, 13 }, { 14, 14, 14 }, { 15, 15, 15 }, { 16, 16, 16 } }, + { { 17, 17, 17 }, { 18, 18, 18 }, { 19, 19, 19 }, { 20, 20, 20 } }, + { { 21, 21, 21 }, { 22, 22, 22 }, { 23, 23, 23 }, { 24, 24, 24 } }, + }, +} + +local withGfx = Credits.new({ data = { gen2Credits = GFX } }, {}) +local _, sheet = withGfx:sceneSheet() +eq(sheet and sheet.species, "BELLOSSOM", "scene 0 is the Bellossom sheet") +withGfx.scene = 3 +local sentretImage, sentret = withGfx:sceneSheet() +eq(sentret.frames, 4, "and Sentret is the four-frame one") +-- Three of the four sheets only hold three blocks and .Frames never asks them +-- for a fourth, but a clamp is cheaper than trusting that from the draw path. +withGfx.scene = 0 +local bellossomImage, bellossom = withGfx:sceneSheet() +local quad = withGfx:sheetQuad(bellossomImage, bellossom, 4) +eq(quad.y, 2 * 32, "a fourth frame on a three-frame sheet clamps to the third") +eq(withGfx:sheetQuad(sentretImage, sentret, 4).y, 3 * 32, + "while Sentret's fourth block is its own") +eq(withGfx:sheetQuad(bellossomImage, bellossom, 1).y, 0, "frame 1 is block 0") + +-- CreditsPalettes wins over the transcribed table, and the %11 mask still +-- picks the same four sets it always did. +eq(withGfx:palette()[1][1], 1, "scene 0 takes the first extracted set") +withGfx.scene = 3 +eq(withGfx:palette()[1][1], 13, "scene 3 the fourth") +eq(Credits.new({ data = {} }, {}):palette()[1][1], 255, + "and a cache without the table keeps the transcribed one") + +-- DrawCreditsBorder starts at $24 on row 4 and $20 on row 13, so the two +-- strips are DIFFERENT quarters of the 9-tile block. +check(GFX.borderTopTile ~= GFX.borderBottomTile, + "the two border rows do not share a start tile") +local borderImage = withGfx:image(GFX.border) +eq(withGfx:borderQuad(borderImage, 5).x, 4 * 8, "tile 5 is the fifth column") +eq(withGfx:borderQuad(borderImage, 1).x, 0, "and tile 1 the first") + +check(withGfx:theEndImage() ~= nil, "TheEndGFX replaces the printed words") +eq(Credits.new({ data = {} }, {}):theEndImage(), nil, + "and is absent from a cache without it") + +-- ---- the credits: skipping ------------------------------------------------ + +-- Credits_HandleBButton: nothing happens without ALLOW_SKIPPING_CREDITS_F, +-- which HallOfFame:: only passes on when the player HAD entered before. +local held = { down = { b = true } } +function held:isDown(button) return self.down[button] end +function held:wasPressed() return false end + +local firstTime = Credits.new({ data = {} }, { allowSkip = false }) +firstTime.timer = 5 +firstTime.pos = 40 +firstTime:handleB(held) +eq(firstTime.timer, 5, "a first-time champion cannot hurry the credits") + +local repeatWin = Credits.new({ data = {} }, { allowSkip = true }) +repeatWin.timer = 5 +repeatWin.pos = 40 +repeatWin:handleB(held) +eq(repeatWin.timer, 4, "a repeat champion takes an extra tick off per frame") + +-- ...but not before wCreditsPos has passed $d. +repeatWin.pos = 3 +repeatWin.timer = 5 +repeatWin:handleB(held) +eq(repeatWin.timer, 5, "and not in the first thirteen script bytes") + +-- Credits_HandleAButton: A only leaves once the exit flag is up. +local aHeld = { down = { a = true } } +function aHeld:isDown(button) return self.down[button] end +function aHeld:wasPressed() return false end +local running = Credits.new({ data = {} }, {}) +eq(running:handleA(aHeld), false, "A does nothing while the script is running") +running.exiting = true +eq(running:handleA(aHeld), true, "and leaves once CREDITS_END has been read") + +-- ---- the Screens ids ------------------------------------------------------ + +-- Every Gold screen is reached through a src/ui/Screens.lua id so a mod can +-- replace it, and the "Gen2" prefix is what keeps Gold's credits and Hall of +-- Fame separate from the Gen 1 screens of the same module name. +local registered = {} +for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end +for _, id in ipairs({ "Gen2HallOfFame", "Gen2Credits", "Gen2CardFlip", + "Gen2ContestMenu", "Gen2DayCareMenu", + "Gen2SlotMachine" }) do + eq(registered[id], true, "the id list carries " .. id) +end +-- The Game Corner PRIZE COUNTERS are map script on the cart, not a screen, so +-- the one id that would only ever have been resolved by a mod is gone. +eq(registered["Gen2PrizeMenu"], nil, + "and does not carry a prize counter the VM runs as script") +Screens.invalidate() +eq(Screens.get({}, "Gen2HallOfFame"), HallOfFame, + "Gen2HallOfFame resolves to src/ui/gen2/HallOfFame.lua") +eq(Screens.get({}, "Gen2Credits"), Credits, + "Gen2Credits resolves to src/ui/gen2/Credits.lua") +eq(Screens.get({}, "HallOfFame"), require("src.ui.HallOfFame"), + "and the un-prefixed id is still Gen 1's") +eq(Screens.get({}, "Credits"), require("src.ui.Credits"), + "for both of them") +Screens.invalidate() + +-- ---- the script seam ------------------------------------------------------ + +-- The screens and the roster above are only reachable if the `halloffame` +-- ($9f) and `credits` ($a0) COMMANDS find a hook: Vm.lua guards both on +-- self.hallOfFameFn / self.creditsFn and returns out of the script either way, +-- so a missing hook is a silent skip of the whole ending rather than a crash. +-- This batch shipped the model, the two screens and the ids with nothing on +-- the World supplying those hooks, which made the ending unreachable; assert +-- the wire, not just the parts. +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +local seen = {} +local endingVm = Vm.new({ + generation = 2, + ["s:champion"] = { { op = "halloffame" }, { op = "setevent", event = 7 } }, + ["s:red"] = { { op = "credits" }, { op = "setevent", event = 8 } }, +}, {}, Events.new(), { + hallOfFame = function(onDone) seen[#seen + 1] = "halloffame" onDone() end, + credits = function(onDone) seen[#seen + 1] = "credits" onDone() end, +}) +endingVm:start("s:champion") +eq(seen[1], "halloffame", "the halloffame command reaches the hook") +eq(endingVm:running(), false, "and ReturnFromCredits ends the script") +eq(endingVm.events:get(7), false, + "so nothing after it in the list runs (Script_endall, MAPSTATUS_DONE)") +endingVm:start("s:red") +eq(seen[2], "credits", "the credits command reaches the hook") +eq(endingVm.events:get(8), false, "and ends the script the same way") + +-- The World is what supplies them. Required last so the two screens above are +-- resolved from the registry rather than from World's own require chain. +local World = require("src.world.gen2.World") +eq(type(World.hallOfFame), "function", "World:hallOfFame backs the command") +eq(type(World.credits), "function", "World:credits backs the command") + +-- HallOfFame.induct answers the PRE-induction flag second, because Credits' +-- ALLOW_SKIPPING_CREDITS_F is read off the copy HallOfFame:: pushed before it +-- set the bit. A first-time champion must NOT be able to skip. +local fresh = Save.newGame({ name = "GOLD" }) +fresh.party = { { species = 155, level = 40, hp = 60, maxHp = 60 } } +local _, wasEntered = Core.induct(fresh, fresh.party) +eq(wasEntered, false, "a first champion has no HALL_OF_FAME flag yet") +eq(Core.hasEntered(fresh), true, "and the induction sets it") +local _, again = Core.induct(fresh, fresh.party) +eq(again, true, "so a repeat champion may fast-forward the credits") + +-- ---- the real cache: data/generated/credits.lua --------------------------- +-- +-- Written by RomExtractorGen2:extractCredits, so a cache imported before that +-- landed has no file and this block is a skip rather than a failure. + +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 creditsPath = cache .. "/data/generated/credits.lua" +local cf = io.open(creditsPath, "r") +if not cf then + check(true, "credits.lua absent : re-import needed (SKIP cache facts)") +else + cf:close() + local creditsData = assert(loadfile(creditsPath))() + eq(creditsData.borderTiles, 9, "CreditsBorderGFX is nine tiles") + eq(#creditsData.scenes, 4, "four mon sheets, one per scene") + for index, entry in ipairs(creditsData.scenes) do + eq(entry.species, Credits.SCENE_SPECIES[index - 1], + "scene " .. (index - 1) .. " is " .. tostring(entry.species)) + -- .Frames only reaches +48 tiles for Sentret; the other three stop at +32. + eq(entry.frames, (entry.species == "SENTRET") and 4 or 3, + "its block count is what .Frames offsets into") + end + eq(#creditsData.palettes, 6, "credits.pal is six four-colour sets") + eq(creditsData.palettes[1][1][1], 255, "and set 1 colour 0 is white") +end + +S.finish() diff --git a/tests/gen2_held_items_test.lua b/tests/gen2_held_items_test.lua new file mode 100644 index 00000000..41b05165 --- /dev/null +++ b/tests/gen2_held_items_test.lua @@ -0,0 +1,270 @@ +-- Held items with an in-battle consult point: Quick Claw (turn order), +-- Focus Band (lethal-hit survival), King's Rock (held flinch), BrightPowder +-- (accuracy) and the Berserk Gene (switch-in Attack surge plus confusion). +-- +-- luajit tests/gen2_held_items_test.lua +-- +-- ROM-free: the item rows carry the ItemAttributes heldEffect/heldParameter +-- pairs exactly as the extractor writes them (engine/battle/core.asm +-- `.equal_priority`, effect_commands.asm BattleCommand_ApplyDamage / +-- BattleCommand_HeldFlinch / `.BrightPowder`, core.asm HandleBerserkGene). + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 held items") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local 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 = {}, + }, +} + +-- The extractor's item rows: heldEffect out of ItemAttributes, parameter +-- beside it (data/items/attributes.asm). +local ITEMS = { + QUICK_CLAW = { id = "QUICK_CLAW", name = "QUICK CLAW", + heldEffect = "HELD_QUICK_CLAW", heldParameter = 60 }, + FOCUS_BAND = { id = "FOCUS_BAND", name = "FOCUS BAND", + heldEffect = "HELD_FOCUS_BAND", heldParameter = 30 }, + KINGS_ROCK = { id = "KINGS_ROCK", name = "KING'S ROCK", + heldEffect = "HELD_FLINCH", heldParameter = 30 }, + BRIGHTPOWDER = { id = "BRIGHTPOWDER", name = "BRIGHTPOWDER", + heldEffect = "HELD_BRIGHTPOWDER", heldParameter = 20 }, + -- The gene's attribute byte is HELD_NONE on the cart: HandleBerserkGene + -- checks the ITEM id. + BERSERK_GENE = { id = "BERSERK_GENE", name = "BERSERK GENE", + heldEffect = "HELD_NONE", heldParameter = 0 }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- A scripted roller: feeds the queue in order, then falls back to `fill`. +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function newBattle(opts) + opts = opts or {} + -- MACHOP (speed 35) always loses the speed race to PIDGEY (speed 56). + local player = Mon.new(DATA, "MACHOP", 15, { dvs = perfect, + item = opts.playerItem }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 15, { dvs = perfect, + item = opts.wildItem }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = opts.random }) + return battle, player, wild +end + +local function findText(events, text) + for _, event in ipairs(events or {}) do + if event.kind == "message" and event.text == text then return true end + end + return false +end + +-- ---- Quick Claw ----------------------------------------------------------- +do + local battle = newBattle({}) + eq(battle:orderOf("TACKLE", "TACKLE"), "enemy", + "bare hands: the faster PIDGEY moves first") + + -- Claw roll 59 < 60: first strike for the slow side. + battle = newBattle({ playerItem = "QUICK_CLAW", random = rolls({ 59 }) }) + eq(battle:orderOf("TACKLE", "TACKLE"), "player", + "a roll under the claw's 60 steals the turn") + + -- Claw roll 60: no dice, speed decides again. + battle = newBattle({ playerItem = "QUICK_CLAW", random = rolls({ 60 }) }) + eq(battle:orderOf("TACKLE", "TACKLE"), "enemy", + "a roll at 60 fails and Speed decides") + + -- The enemy's claw, symmetric. + battle = newBattle({ wildItem = "QUICK_CLAW", random = rolls({ 59 }) }) + eq(battle:orderOf("TACKLE", "TACKLE"), "enemy", + "the enemy's claw fires the same way") + + -- Both hold one: the ENEMY's roll is consulted first + -- (`.both_have_quick_claw`, non-link order). + battle = newBattle({ playerItem = "QUICK_CLAW", wildItem = "QUICK_CLAW", + random = rolls({ 10 }) }) + eq(battle:orderOf("TACKLE", "TACKLE"), "enemy", + "with two claws the enemy's roll goes first") + battle = newBattle({ playerItem = "QUICK_CLAW", wildItem = "QUICK_CLAW", + random = rolls({ 200, 10 }) }) + eq(battle:orderOf("TACKLE", "TACKLE"), "player", + "the player's roll answers second") +end + +-- ---- Focus Band ----------------------------------------------------------- +do + local battle, _, wild = newBattle({ wildItem = "FOCUS_BAND", + random = rolls({ 29 }) }) + wild.hp = 10 + local dealt = battle:dealDamage(battle.player, wild, 50, {}) + eq(wild.hp, 1, "a roll under 30 leaves the holder at exactly 1 HP") + eq(dealt, 9, "the clamp is the False Swipe clamp: hp - 1") + check(findText(battle:takeEvents(), + "PIDGEY hung on with FOCUS BAND!"), + "and HungOnText names the item") + + battle, _, wild = newBattle({ wildItem = "FOCUS_BAND", + random = rolls({ 30 }) }) + wild.hp = 10 + battle:dealDamage(battle.player, wild, 50, {}) + eq(wild.hp, 0, "a roll at 30 fails and the holder faints") + + -- A non-lethal hit never consults the band. + battle, _, wild = newBattle({ wildItem = "FOCUS_BAND", + random = rolls({ 255 }) }) + wild.hp = 30 + battle:dealDamage(battle.player, wild, 10, {}) + eq(wild.hp, 20, "a survivable hit passes through untouched") +end + +-- ---- King's Rock ---------------------------------------------------------- +do + -- Rolls in useMove order: accuracy, crit, variation, then the flinch + -- byte. A fill of 1 hits, never crits, and lands 1 < 30 on the flinch. + local battle, player, wild = newBattle({ playerItem = "KINGS_ROCK", + random = rolls({}, 1) }) + battle:useMove(player, wild, "TACKLE") + eq(battle:volatile(wild).flinched, true, + "a damaging hit with the rock held sets the flinch, silently") + eq(battle:canAct(wild), false, "the target's turn is eaten") + check(findText(battle:takeEvents(), "PIDGEY flinched!"), + "with FlinchedText at the moment it tries to move") + eq(battle:volatile(wild).flinched, nil, "and the flag is consumed") + + -- Roll at the parameter: no flinch. + battle, player, wild = newBattle({ playerItem = "KINGS_ROCK", + random = rolls({ 1, 1, 1, 30 }) }) + battle:useMove(player, wild, "TACKLE") + eq(battle:volatile(wild).flinched, nil, "a roll at 30 does nothing") + + -- A Substitute blocks it (CheckSubstituteOpp). + battle, player, wild = newBattle({ playerItem = "KINGS_ROCK", + random = rolls({}, 1) }) + battle:volatile(wild).substitute = 20 + battle:useMove(player, wild, "TACKLE") + eq(battle:volatile(wild).flinched, nil, + "the substitute soaks the hit and the rock with it") + + -- Bare hands: nothing. + battle, player, wild = newBattle({ random = rolls({}, 1) }) + battle:useMove(player, wild, "TACKLE") + eq(battle:volatile(wild).flinched, nil, "no rock, no flinch") +end + +-- ---- BrightPowder --------------------------------------------------------- +do + local battle, player, wild = newBattle({ wildItem = "BRIGHTPOWDER" }) + -- 20/256 scaled into the percent domain: floor(20 * 100 / 256) = 7. + eq(battle:moveAccuracy(95, wild), 88, + "the powder takes 7 points off a 95 accuracy move") + eq(battle:moveAccuracy(95, player), 95, + "a bare defender changes nothing") + eq(battle:moveAccuracy(nil, wild), nil, + "a sure-hit move (accuracy nil) stays sure") + + -- Through the real roll: 90 hits a bare PIDGEY (90 < 95) and misses a + -- powdered one (90 >= 88). + battle, player, wild = newBattle({ random = rolls({ 90 }, 1) }) + local before = wild.hp + battle:useMove(player, wild, "TACKLE") + check(wild.hp < before, "roll 90 connects without the powder") + + battle, player, wild = newBattle({ wildItem = "BRIGHTPOWDER", + random = rolls({ 90 }, 1) }) + before = wild.hp + battle:useMove(player, wild, "TACKLE") + eq(wild.hp, before, "and misses against it") + check(findText(battle:takeEvents(), "MACHOP's attack missed!"), + "with the ordinary miss line") +end + +-- ---- Berserk Gene --------------------------------------------------------- +do + local battle, player = newBattle({ playerItem = "BERSERK_GENE", + random = rolls({}, 1) }) + local events = battle:takeTurn({ kind = "skip" }) + eq(player.item, nil, "the gene is consumed the turn its holder is out") + eq(battle.stages.player.attack, 2, + "Attack jumps two stages (BattleCommand_AttackUp2)") + -- Confusion is SUBSTATUS_CONFUSED, a volatile: the cart's gene sets the + -- bit without writing the count, the near-permanent lock the port models + -- as Battle.BERSERK_GENE_CONFUSE_TURNS. + eq(player.volatile.confuseCount, Battle.BERSERK_GENE_CONFUSE_TURNS, + "and the holder is confused, no count -- the lock") + eq(player.status, nil, "the status byte stays free for a real status") + check(findText(events, "MACHOP's BERSERK GENE activated!"), + "UsersStringBuffer1Activated announces it") + + -- One shot: the next turn has nothing left to fire. + battle:takeTurn({ kind = "skip" }) + eq(battle.stages.player.attack, 2, "no second surge") + + -- The enemy's gene works on the enemy. + local wild + battle, player, wild = newBattle({ wildItem = "BERSERK_GENE", + random = rolls({}, 1) }) + battle:takeTurn({ kind = "skip" }) + eq(wild.item, nil, "the wild side's gene is consumed too") + eq(battle.stages.enemy.attack, 2, "its Attack surges") + -- The wild side took its turn through canAct, which decrements the count + -- once before rolling the self-hit. + eq(wild.volatile.confuseCount, Battle.BERSERK_GENE_CONFUSE_TURNS - 1, + "and it confuses itself") +end + +S.finish() diff --git a/tests/gen2_hidden_items_test.lua b/tests/gen2_hidden_items_test.lua new file mode 100644 index 00000000..b70a2119 --- /dev/null +++ b/tests/gen2_hidden_items_test.lua @@ -0,0 +1,499 @@ +-- Hidden items: engine/events/checkforhiddenitems.asm, the BGEVENT_ITEM arm of +-- the bg event dispatch (engine/overworld/events.asm `.itemifset`) and +-- HiddenItemScript (engine/events/hidden_item.asm), plus the extractor half +-- that stopped walking a `hiddenitem` struct as bytecode. +-- +-- ROM-free: `luajit tests/gen2_hidden_items_test.lua`. The cache section at +-- the bottom SKIPs when no Gold cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 hidden items") +local check, eq = S.check, S.eq + +local HiddenItems = require("src.world.gen2.HiddenItems") +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") + +-- maps/Route42.asm: +-- bg_event 16, 11, BGEVENT_ITEM, Route42HiddenMaxPotion +-- Route42HiddenMaxPotion: hiddenitem MAX_POTION, EVENT_ROUTE_42_HIDDEN_MAX_POTION +-- MAX_POTION is item 15 (constants/item_constants.asm) and the flag is 173 +-- (constants/event_flags.asm). +local MAX_POTION, ROUTE_42_FLAG = 15, 173 + +local function itemRow(x, y, item, event) + return { x = x, y = y, kind = HiddenItems.BGEVENT_ITEM, + hiddenItem = { item = item, event = event } } +end + +-- ---- the bg event row ----------------------------------------------------- +do + local row = itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG) + eq(HiddenItems.dataOf(row).item, MAX_POTION, "kind 7 carries hiddenitem data") + eq(HiddenItems.dataOf(row).event, ROUTE_42_FLAG, "and its event flag") + check(HiddenItems.dataOf({ x = 1, y = 1, kind = 0, scriptKey = "1:4000" }) == nil, + "BGEVENT_READ is a sign, not an item") + check(HiddenItems.dataOf({ x = 1, y = 1, kind = 7 }) == nil, + "a kind 7 row from a cache that predates the decode answers nil") + check(HiddenItems.dataOf(nil) == nil, "and nil is not a row") +end + +-- ---- CheckForHiddenItems' box --------------------------------------------- +-- The corner is player + SCREEN_WIDTH / 4, SCREEN_HEIGHT / 4 = +5, +4, and the +-- differences that survive are 0..9 on x and 0..8 on y. So the box is ten +-- cells wide with the player LEFT of centre and nine tall with the player +-- centred, and it is not a radius. +do + local px, py = 20, 20 + check(HiddenItems.onScreen(px, py, px, py), "the player's own cell is on screen") + check(HiddenItems.onScreen(px, py, px + 5, py), "five cells right is the last x") + check(not HiddenItems.onScreen(px, py, px + 6, py), "six is past the corner") + check(HiddenItems.onScreen(px, py, px - 4, py), "four cells left is the first x") + check(not HiddenItems.onScreen(px, py, px - 5, py), "five left is a screen away") + check(HiddenItems.onScreen(px, py, px, py + 4), "four cells down is the last y") + check(not HiddenItems.onScreen(px, py, px, py + 5), "five down is past the corner") + check(HiddenItems.onScreen(px, py, px, py - 4), "four cells up is the first y") + check(not HiddenItems.onScreen(px, py, px, py - 5), "five up is a screen away") + check(not HiddenItems.onScreen(px, py, px + 6, py + 4), + "either axis out of range is out") +end + +-- ---- the sweep ------------------------------------------------------------ +do + local def = { bgEvents = { + { x = 3, y = 3, kind = 0, scriptKey = "1:4000" }, + itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG), + itemRow(40, 40, 32, 182), + } } + local events = Events.new() + eq(#HiddenItems.unfound(def, events), 2, "two hidden items, one sign") + local found = HiddenItems.nearby(def, 14, 10, events) + check(found ~= nil, "the ITEMFINDER answers for the one in range") + eq(found.item, MAX_POTION, "and hands back the item") + eq(found.event, ROUTE_42_FLAG, "and its flag") + check(HiddenItems.nearby(def, 30, 30, events) == nil, + "and nothing when both are off screen") + + events:set(ROUTE_42_FLAG, true) + check(HiddenItems.nearby(def, 14, 10, events) == nil, + "an item already found never sets the ITEMFINDER off again") + eq(#HiddenItems.unfound(def, events), 1, "and drops out of the unfound list") + check(HiddenItems.nearby(def, 14, 10, nil) ~= nil, + "with no flag store at all nothing has been found yet") +end + +-- ---- the facing cell ------------------------------------------------------ +do + local def = { bgEvents = { + { x = 3, y = 3, kind = 0, scriptKey = "1:4000" }, + itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG), + } } + local events = Events.new() + eq(HiddenItems.at(def, 16, 11, events).item, MAX_POTION, + "the facing cell finds the hidden item") + check(HiddenItems.at(def, 3, 3, events) == nil, "a sign is not one") + check(HiddenItems.at(def, 9, 9, events) == nil, "and an empty cell is not one") + events:set(ROUTE_42_FLAG, true) + check(HiddenItems.at(def, 16, 11, events) == nil, + "`.itemifset` jumps to .dontread once the flag is set") +end + +-- ---- HiddenItemScript ----------------------------------------------------- +do + local script = HiddenItems.pickupScript(MAX_POTION, ROUTE_42_FLAG) + local ops = {} + for _, cmd in ipairs(script) do ops[#ops + 1] = cmd.op end + eq(table.concat(ops, ","), + "opentext,getitemname,rawtext,giveitem,iffalse,setevent,specialsound," .. + "itemnotify,closetext,end", + "the order is HiddenItemScript's, with readmem folded into the item") + eq(script[4].item, MAX_POTION, "giveitem ITEM_FROM_MEM gives the found item") + eq(script[4].quantity, 1, "one of it") + eq(script[6].event, ROUTE_42_FLAG, + "callasm SetMemEvent sets the flag by NUMBER") + local fullOps = {} + for _, cmd in ipairs(script[5].script) do fullOps[#fullOps + 1] = cmd.op end + eq(table.concat(fullOps, ","), + "promptbutton,rawtext,waitbutton,closetext,end", + ".bag_full prints and stops; it sets no flag") +end + +-- The same list through the real VM, which is where the ordering matters: the +-- flag must be set only on the arm that really took the item. +local function runPickup(bagFull) + local events = Events.new() + local log, given = {}, nil + local vm = Vm.new({}, {}, events, { + showText = function(body, onDone) + log[#log + 1] = body + if onDone then onDone() end + end, + getItemName = function(index) + eq(index, MAX_POTION, "getitemname is asked for the found item") + return "MAX POTION" + end, + giveItem = function(index, qty) + if bagFull then return false end + given = { index = index, qty = qty } + return true + end, + specialSound = function() end, + waitSfx = function() return true end, + }) + vm:start(HiddenItems.pickupScript(MAX_POTION, ROUTE_42_FLAG)) + for _ = 1, 8 do vm:update() end + return events, log, given +end + +do + local events, log, given = runPickup(false) + eq(log[1], "{PLAYER} found\nMAX POTION.", + "_PlayerFoundItemText names the item; {PLAYER} is TextBox's to fill") + eq(given.index, MAX_POTION, "and the item lands in the pack") + eq(given.qty, 1, "one of it") + check(events:get(ROUTE_42_FLAG), "SetMemEvent ticks the flag off") + eq(log[2], "{PLAYER} put the\nMAX POTION in\nthe ITEM POCKET.", + "itemnotify is the second line") + + local fullEvents, fullLog = runPickup(true) + check(not fullEvents:get(ROUTE_42_FLAG), + "a full pack leaves the flag CLEAR, so the item is still there") + eq(fullLog[2], "But {PLAYER} has\nno space left…", "_ButNoSpaceText instead") +end + +-- ---- FindItemInBallScript, the OBJECTTYPE_ITEMBALL press ------------------- +-- engine/events/misc_scripts.asm:9. The object's pointer is two raw bytes, so +-- nothing exists for the VM to start: World:interact feeds it this built list. +-- Two load-bearing orderings. `callasm .TryReceiveItem` names the item AND +-- takes it before any box is drawn, so the full-pocket branch is chosen with +-- nothing on screen and its arm prints two boxes of its own; and `disappear` +-- (which is what sets the ball's own event flag through World:disappearObject) +-- sits on the success side of the iffalse only, or a full pack would still eat +-- the ball. This is NOT the hidden item's script with a disappear swapped in, +-- which is what it used to assert. +local function runBallPickup(bagFull) + local events = Events.new() + local log, given, gone, sfx = {}, nil, nil, nil + local vm = Vm.new({}, {}, events, { + showText = function(body, onDone) + log[#log + 1] = body + if onDone then onDone() end + end, + getItemName = function() return "HM07" end, + giveItem = function(index, qty) + if bagFull then return false end + given = { index = index, qty = qty } + return true + end, + disappear = function(objectId) gone = objectId end, + playSound = function(id) sfx = id end, + specialSound = function() end, + waitSfx = function() return true end, + }) + vm:start(HiddenItems.ballPickupScript(MAX_POTION, 1, 2)) + -- The cart's `pause 60` now rides the found line's own row as `hold` (see + -- the order assertion below), and a `hold` is counted by the TEXT BOX, not + -- by the VM -- this stub showText answers straight away, so nothing parks + -- here. The loop is left long so a future park would still be outlasted + -- rather than read as a hang. + for _ = 1, 160 do vm:update() end + return log, given, gone, sfx, vm +end + +do + local script = HiddenItems.ballPickupScript(MAX_POTION, 3, 5) + local ops = {} + for _, cmd in ipairs(script) do ops[#ops + 1] = cmd.op end + -- FindItemInBallScript is `writetext .FoundItemText / playsound SFX_ITEM / + -- pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17), all of it + -- inside ONE MapTextbox: nothing between the found line and the itemnotify + -- line takes a box down. The port's box does take itself down -- it waits + -- for its own button and pops on it -- so written in that order the pause + -- ran with an EMPTY state stack: 120 frames of bare overworld inside one + -- cart textbox, with Game2's play clock counting for all of them + -- (tests/drivers/gold_item_pickup_box.lua measures exactly that). + -- + -- So the pause rides the found line's own row as `hold`, which World:showText + -- counts on the standing box, and the sound leads the text instead of + -- trailing it -- it has to be rung before the row that does not come back + -- until the hold has drained. + eq(table.concat(ops, ","), + "getitemname,giveitem,iffalse,disappear,opentext,playsound,rawtext," .. + "itemnotify,closetext,end", + "FindItemInBallScript's order: the callasm pair first, then the freeze") + eq(script[2].quantity, 3, "the itemball row's own quantity is given") + eq(script[4].object, 5, "disappear names the object const the caller built") + eq(script[6].id, 1, "playsound SFX_ITEM, not specialsound's TM-aware pick") + eq(script[7].stay, true, "the found line holds the ONE MapTextbox open") + eq(script[7].hold, 60, "and `pause 60` holds the world under that box") + + local log, given, gone, sfx, vm = runBallPickup(false) + eq(log[1], "{PLAYER} found\nHM07!", + "_FoundItemText ends on '!' -- the '.' one is the hidden item's") + eq(given.index, MAX_POTION, "and the item lands in the pack") + eq(gone, 2, "the ball object disappears -- its event flag rides on that") + eq(sfx, 1, "SFX_ITEM rang") + check(not vm:running(), "and the script ran off the end of the pause") + + local fullLog, fullGiven, fullGone = runBallPickup(true) + check(fullGiven == nil, "a full pack takes nothing") + check(fullGone == nil, "and the ball stays where it is") + eq(fullLog[1], "{PLAYER} found\nHM07!", "the .no_room arm shows the find too") + eq(fullLog[2], "But {PLAYER} can't\ncarry any more\vitems!", + "_CantCarryItemText is the second box, not _ButNoSpaceText") +end + +-- ---- ItemFinder's two queued scripts -------------------------------------- +do + local asked = {} + local function sfxId(want, fallback) + asked[#asked + 1] = want + return fallback + 100 + end + local found = HiddenItems.itemfinderScript( + { x = 1, y = 1, item = MAX_POTION, event = ROUTE_42_FLAG }, sfxId) + local ops = {} + for _, cmd in ipairs(found) do ops[#ops + 1] = cmd.op end + eq(#asked, 8, ".ItemfinderSound is four rounds of two sounds") + eq(asked[1], "Sfx_SecondPartOfItemfinder", "the beep comes first") + eq(asked[2], "Sfx_Transaction", "then the answering blip") + eq(ops[1], "waitsfx", "WaitPlaySFX waits BEFORE it plays") + eq(ops[2], "playsound", "and the play follows the wait") + eq(found[2].id, 118, "the id is resolved through this cache's sfx table") + eq(#found, 21, "sixteen sound rows and the five the text box needs") + eq(table.concat({ ops[16], ops[17], ops[18], ops[19], ops[20], ops[21] }, ","), + "playsound,opentext,rawtext,waitbutton,closetext,end", + "and the text lands after the last sound, unwaited") + + local nothing = HiddenItems.itemfinderScript(nil, sfxId) + local nothingOps = {} + for _, cmd in ipairs(nothing) do nothingOps[#nothingOps + 1] = cmd.op end + eq(table.concat(nothingOps, ","), + "opentext,rawtext,waitbutton,closetext,end", + ".Script_FoundNothing makes no sound at all") + eq(#asked, 8, "and asks the sfx table for nothing") +end + +-- ---- the world wiring ----------------------------------------------------- +local World = require("src.world.gen2.World") + +local COLL_FLOOR = 0x00 + +local function fakeWorld(bgEvents, px, py, facing) + local game = { + data = { items = { + MAX_POTION = { id = "MAX_POTION", name = "MAX POTION", + pocket = "ITEM", index = MAX_POTION }, + ITEMFINDER = { id = "ITEMFINDER", name = "ITEMFINDER", + pocket = "KEY_ITEM", index = 0x3c }, + } }, + save = { player = { name = "GOLD" }, party = {}, + inventory = { ITEMFINDER = 1 } }, + } + local world = World.new(game) + game.world = world + world.map = { + id = "TEST_MAP", + def = { bgEvents = bgEvents, objects = {}, width = 10, height = 10 }, + cellCollision = function() return COLL_FLOOR end, + warpAt = function() return nil end, + } + world.maps = { TEST_MAP = world.map.def } + world.player = { + cellX = px, cellY = py, px = px * 16, py = py * 16, + facing = facing or "down", moving = false, + update = function() return false end, + } + world.pollTimeOfDay = function() end + world.playSfx = function(self, id) self.sfxLog = self.sfxLog or {} + self.sfxLog[#self.sfxLog + 1] = id end + local started = {} + world.startedScripts = started + world.vm = { + running = function() return false end, + update = function() end, + start = function(_, script) started[#started + 1] = script return true end, + } + return world, game +end + +do + -- Facing the hidden item's cell: the A press runs HiddenItemScript. + local world = fakeWorld({ itemRow(5, 6, MAX_POTION, ROUTE_42_FLAG) }, + 5, 5, "down") + check(world:interact(), "an A press into a hidden item is taken") + eq(#world.startedScripts, 1, "and starts one script") + eq(world.startedScripts[1][4].item, MAX_POTION, + "the script the world built is the pickup for that item") + eq(world.sfxLog[1], 8, "PlayTalkObject's SFX_READ_TEXT_2 opens it") + + -- Already found: `.dontread`, so the press is NOT taken and falls through to + -- the tile events (none here, so interact answers false). + world.events:set(ROUTE_42_FLAG, true) + check(not world:interact(), "a hidden item already taken does not eat the press") + eq(#world.startedScripts, 1, "and starts nothing") +end + +do + -- OBJECTTYPE_ITEMBALL: World:interact has to route the A press to the built + -- ball pickup, because the object's pointer is raw (item, quantity) bytes + -- and there is no scriptKey. Without this arm no floor item in the game + -- could be taken -- HM07 WATERFALL among them. + local world = fakeWorld({}, 5, 5, "up") + world.map.def.objects = { + { index = 0, x = 5, y = 4, type = 1, -- OBJECTTYPE_ITEMBALL, faced cell + itemball = { item = MAX_POTION, quantity = 1 } }, + } + world.npcs = { + { cellX = 5, cellY = 4, def = world.map.def.objects[1] }, + } + check(world:interact(), "an A press into an item ball is taken") + eq(#world.startedScripts, 1, "and starts the pickup script") + eq(world.startedScripts[1][2].item, MAX_POTION, "for the ball's item") + -- And hLastTalked is set to the object, so the script's disappear names the + -- ball and not whatever an earlier conversation left behind. + eq(world.vm.lastTalked, 1, "hLastTalked points at the ball object (index+1)") +end + +do + -- ItemFinder from the PACK: QueueScript, so nothing runs until the menus are + -- gone and the world owns the frame again. + local world = fakeWorld({ itemRow(5, 6, MAX_POTION, ROUTE_42_FLAG) }, + 5, 5, "down") + eq(world:useFieldItem("ITEMFINDER"), "itemfinder", + "the ITEMFINDER always quits the PACK; there is no refusal arm") + check(world.queuedScript ~= nil, "the script is QUEUED, not run") + eq(#world.startedScripts, 0, "so nothing has started yet") + eq(world.queuedScript[1].op, "waitsfx", "and it is the found-something one") + world:step() + eq(#world.startedScripts, 1, "the drain starts it the next frame") + check(world.queuedScript == nil, "and the queue is empty again") + + local empty = fakeWorld({}, 5, 5, "down") + eq(empty:useFieldItem("ITEMFINDER"), "itemfinder", "a bare map still quits") + eq(empty.queuedScript[1].op, "opentext", + "and queues .Script_FoundNothing, which is silent") + check(empty:useFieldItem("MAX_POTION") == nil, + "any other item still falls through to the PACK's own onChoose") +end + +-- ---- against the cache ---------------------------------------------------- +-- Every row here is transcribed from pokegold: `bg_event x, y, BGEVENT_ITEM` +-- out of maps/<Map>.asm and the `hiddenitem item, flag` it points at, with the +-- item id from constants/item_constants.asm and the flag from +-- constants/event_flags.asm. +local PINNED = { + { "AZALEA_TOWN", 31, 6, 38, 177 }, -- FULL_HEAL + { "ROUTE_42", 16, 11, 15, 173 }, -- MAX_POTION + { "TIN_TOWER_4F", 11, 6, 15, 125 }, -- MAX_POTION, the lowest flag + { "DRAGONS_DEN_B1F", 31, 15, 21, 162 }, -- MAX_ELIXER + { "CERULEAN_CITY", 2, 12, 152, 250 }, -- BERSERK_GENE + { "MOUNT_MOON_SQUARE", 7, 7, 8, 236 }, -- MOON_STONE + { "ROUTE_17", 8, 77, 21, 247 }, -- MAX_ELIXER, the highest flag + { "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", 1, 8, 39, 143 }, -- REVIVE +} + +-- NATIONAL_PARK and NATIONAL_PARK_BUG_CONTEST are the same map twice and share +-- one flag: finding the FULL_HEAL during the contest finds it outside too. +local SHARED_FLAG = { "NATIONAL_PARK", "NATIONAL_PARK_BUG_CONTEST", 6, 47, 38, 132 } + +local HIDDEN_ITEM_COUNT = 87 + +local cacheDir = os.getenv("GOLD_CACHE") +if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" +end +local mapsFile = loadfile(cacheDir .. "/data/generated/maps.lua") +if not mapsFile then + check(true, "no Gold cache (SKIP)") + S.finish() + return +end +local maps = mapsFile() + +local rows, decoded, withKey = {}, 0, 0 +for mapId, def in pairs(maps) do + if type(def) == "table" then + for _, ev in ipairs(def.bgEvents or {}) do + if ev.kind == HiddenItems.BGEVENT_ITEM then + rows[#rows + 1] = true + if ev.hiddenItem then decoded = decoded + 1 end + if ev.scriptKey then withKey = withKey + 1 end + local key = ("%s:%d:%d"):format(mapId, ev.x, ev.y) + rows[key] = ev + end + end + end +end + +if decoded == 0 then + eq(#rows, HIDDEN_ITEM_COUNT, "the cache has every BGEVENT_ITEM row") + check(true, "cache predates the hiddenitem decode; re-import (SKIP)") + S.finish() + return +end + +eq(#rows, HIDDEN_ITEM_COUNT, "eighty-seven hidden items in Gold") +eq(decoded, HIDDEN_ITEM_COUNT, "every one of them decoded its `hiddenitem`") +eq(withKey, 0, + "and none of them is queued as a script: a scriptKey here is the pointer " .. + "walk disassembling the flag word as opcodes") + +for _, want in ipairs(PINNED) do + local mapId, x, y, item, event = want[1], want[2], want[3], want[4], want[5] + local ev = rows[("%s:%d:%d"):format(mapId, x, y)] + check(ev ~= nil, mapId .. " has a hidden item at " .. x .. "," .. y) + if ev then + eq(ev.hiddenItem.item, item, mapId .. " hidden item id") + eq(ev.hiddenItem.event, event, mapId .. " hidden item flag") + end +end + +do + local a = rows[("%s:%d:%d"):format(SHARED_FLAG[1], SHARED_FLAG[3], SHARED_FLAG[4])] + local b = rows[("%s:%d:%d"):format(SHARED_FLAG[2], SHARED_FLAG[3], SHARED_FLAG[4])] + check(a ~= nil and b ~= nil, "NATIONAL PARK carries its FULL_HEAL on both maps") + if a and b then + eq(a.hiddenItem.event, SHARED_FLAG[6], "at the same flag") + eq(b.hiddenItem.event, SHARED_FLAG[6], "on the contest copy too") + eq(a.hiddenItem.item, SHARED_FLAG[5], "and the same item") + end +end + +-- Every flag has to land inside wEventFlags and every item inside ItemNames: +-- a mis-read `dwb` would put the item byte in the low half of the flag word. +do + local items = assert(loadfile(cacheDir .. "/data/generated/items.lua"))() + local byIndex = {} + for id, def in pairs(items) do + if type(def) == "table" and def.index then byIndex[def.index] = id end + end + local bad = 0 + for key, ev in pairs(rows) do + if type(key) == "string" then + if not byIndex[ev.hiddenItem.item] then bad = bad + 1 end + if ev.hiddenItem.event >= 2048 then bad = bad + 1 end + end + end + eq(bad, 0, "every decoded item is a real item and every flag is in range") +end + +-- The point of the extractor half: those 87 pointers were being disassembled, +-- and the noise is where the port's unknown-opcode rows came from. +do + local scripts = assert(loadfile(cacheDir .. "/data/generated/scripts.lua"))() + local unknown = 0 + for key, list in pairs(scripts) do + if key ~= "movements" and type(list) == "table" then + for _, cmd in ipairs(list) do + if cmd.op == "unknown" then unknown = unknown + 1 end + end + end + end + check(unknown <= 4, + ("unknown opcode rows are down to %d (64 of the old 68 were hidden items)") + :format(unknown)) +end + +S.finish() diff --git a/tests/gen2_hof_continue_test.lua b/tests/gen2_hof_continue_test.lua new file mode 100644 index 00000000..807e9e87 --- /dev/null +++ b/tests/gen2_hof_continue_test.lua @@ -0,0 +1,171 @@ +-- The end of the game has to hand the controller back. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_hof_continue_test.lua +-- +-- Script_halloffame ends on ReturnFromCredits (engine/overworld/ +-- scripting.asm): Script_endall plus MAPSTATUS_DONE, which returns out of +-- OverworldLoop entirely. FinishContinueFunction (engine/menus/ +-- intro_menu.asm) then reads wSpawnAfterChampion: +-- +-- SPAWN_RED SpawnAfterRed: wDefaultSpawnpoint = SPAWN_MT_SILVER, +-- clear the byte, MAPSETUP_WARP, loop back into the +-- overworld -- play resumes outside Silver Cave. +-- anything else `jp Reset` -- the champion's credits end on the title +-- screen, and the induction's SaveGameData already put +-- SPAWN_LANCE in the save. +-- +-- and Continue's own `cp SPAWN_LANCE / jr z, .SpawnAfterE4` consumes the +-- saved byte BEFORE the saved position is honoured, spawning at New Bark +-- Town. Without either half the champion was left standing in HALL_OF_FAME, +-- whose only exit is the sealed Lance's room door: a soft lock at the moment +-- of victory. +-- +-- These drive the shipped World:hallOfFame / World:credits / +-- World:consumePostGameSpawn against the real cache; only the screens are +-- registry fakes (they are pure presentation, and Screens.push resolves the +-- registry first for exactly this reason). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 hof continue") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local HallOfFame = require("src.core.gen2.HallOfFame") + +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 probe = io.open(cache .. "/data/generated/landmarks.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local landmarks = loadLua("data/generated/landmarks.lua") + +-- One registry serves the whole file; Screens caches factories per id, so the +-- fakes are registered once and the recorder is swapped per test. +local pushes +local registry = {} +for _, id in ipairs({ "Gen2HallOfFame", "Gen2Credits" }) do + registry[id] = { new = function(_game, opts) + pushes[#pushes + 1] = { id = id, opts = opts } + return { screenId = id } + end } +end + +local function makeStack() + local stack = { items = {} } + function stack:push(inst) self.items[#self.items + 1] = inst end + function stack:pop() + local top = self.items[#self.items] + self.items[#self.items] = nil + return top + end + return stack +end + +local function makeWorld() + pushes = {} + local game = { + data = { audio = {}, screens = registry }, + save = { + player = { name = "GOLD" }, + party = { { species = "TYPHLOSION", level = 50, hp = 120, + otId = 33333 } }, + }, + stack = makeStack(), + titled = 0, + } + game.returnToTitle = function(g) g.titled = g.titled + 1 end + local w = World.new(game) + w.maps = maps + w.landmarks = landmarks + w.loaded = nil + w.setMap = function(self, id, x, y, facing) + self.loaded = { id = id, x = x, y = y, facing = facing } + return true + end + return w, game +end + +-- ---- the champion's ending: induct, roll, reset --------------------------- +do + local w, game = makeWorld() + local scriptResumed = false + check(w:hallOfFame(function() scriptResumed = true end), + "the halloffame command takes the screen") + + -- The ceremony's bookkeeping ran before the screen came up. + eq(game.save.spawnAfterChampion, HallOfFame.SPAWN_LANCE, + "induction writes wSpawnAfterChampion = SPAWN_LANCE") + eq(HallOfFame.count(game.save), 1, "and bumps the win count") + eq(pushes[1].id, "Gen2HallOfFame", "the roster ceremony is up first") + + -- `pop af / jp Credits`. + pushes[1].opts.onDone() + eq(pushes[2] and pushes[2].id, "Gen2Credits", "the roll follows the ceremony") + eq(pushes[2].opts.allowSkip, false, + "a first-time champion cannot hurry the roll (the PRE-induction flags)") + + -- The credits end: the script resumes (and immediately ends, as + -- ReturnFromCredits' Script_endall does), and then the reset. + eq(game.titled, 0, "no reset while the roll is still up") + pushes[2].opts.onDone() + check(scriptResumed, "the script got its resume before the teardown") + eq(game.titled, 1, "`jp Reset`: the credits end on the title screen") + check(w.loaded == nil, "and no warp happened in this session") +end + +-- ---- CONTINUE consumes the saved spawn ------------------------------------ +do + local w, game = makeWorld() + HallOfFame.induct(game.save, game.save.party) + eq(game.save.spawnAfterChampion, HallOfFame.SPAWN_LANCE, "the save is armed") + + local spawn = w:consumePostGameSpawn() + check(spawn ~= nil, "Continue's .SpawnAfterE4 arm fires") + eq(spawn.map, "NEW_BARK_TOWN", "SPAWN_NEW_BARK is New Bark Town") + eq(spawn.x, 13, "at the spawn point's x") + eq(spawn.y, 6, "and y") + eq(game.save.spawnAfterChampion, nil, + "PostCreditsSpawn zeroes the byte on the way") + check(w:consumePostGameSpawn() == nil, "so the next load is ordinary") +end + +-- ---- the Red ending: no reset, straight back to Silver Cave --------------- +do + local w, game = makeWorld() + local scriptResumed = false + check(w:credits(function() scriptResumed = true end), + "the credits command takes the screen") + eq(game.save.spawnAfterChampion, HallOfFame.SPAWN_RED, + "RedCredits writes wSpawnAfterChampion = SPAWN_RED") + eq(pushes[1].id, "Gen2Credits", "and goes straight to the roll") + + pushes[1].opts.onDone() + check(scriptResumed, "the script got its resume") + eq(game.titled, 0, ".AfterRed does not reset") + check(w.loaded ~= nil, "it re-enters the overworld instead") + eq(w.loaded.id, "SILVER_CAVE_OUTSIDE", "outside Silver Cave") + eq(w.loaded.x, 23, "at SPAWN_MT_SILVER's x") + eq(w.loaded.y, 20, "and y") + eq(game.save.spawnAfterChampion, nil, "with the byte consumed in session") +end + +-- ---- an ordinary continue is untouched ------------------------------------ +do + local w, game = makeWorld() + game.save.position = { map = "GOLDENROD_CITY", x = 5, y = 5 } + check(w:consumePostGameSpawn() == nil, + "no pending spawn means the saved position stays in charge") + eq(game.save.position.map, "GOLDENROD_CITY", "and is not disturbed") +end + +S.finish() diff --git a/tests/gen2_ice_pathfind_test.lua b/tests/gen2_ice_pathfind_test.lua new file mode 100644 index 00000000..01bd6360 --- /dev/null +++ b/tests/gen2_ice_pathfind_test.lua @@ -0,0 +1,114 @@ +-- Ice-aware Bot:slideRest / planPath. Self-contained: +-- luajit tests/gen2_ice_pathfind_test.lua +-- +-- The engine half (CheckForced latch) lives in gen2_world_test.lua. This file +-- proves the planner's graph is over REST positions: a press on ice ends where +-- the slide stops, so a cell the player would skate past is not a node. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 ice pathfind") +local check, eq = S.check, S.eq + +local Permissions = require("src.world.gen2.Permissions") +local Bot = dofile("tests/drivers/gold/bot.lua") +local A = Bot.adapter + +local COLL_FLOOR, COLL_ICE, COLL_WALL = 0x00, 0x23, 0x07 + +local function fakeMap(cells) + return { + id = "ICE_TEST", + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 20 and y < 20 + end, + cellCollision = function(_, x, y) + return cells[y * 100 + x] or COLL_FLOOR + end, + } +end + +-- A bot stub that only needs passable / slideRest / planPath / wallCost. +local function stubBot(map, sx, sy, cells) + local g = { _map = map, _x = sx, _y = sy } + local bot = setmetatable({ + g = g, + walls = {}, + surfKnown = false, + }, Bot) + -- Adapter seams the planner reads. Keep them on A so the real methods run. + local realMap, realPos, realWalkable, realIsIce, realIsWater, realNpcAt, + realIsWarp, realSurfing = + A.map, A.pos, A.walkable, A.isIce, A.isWater, A.npcAt, A.isWarpTile, A.surfing + A.map = function() return map end + A.pos = function() return g._x, g._y end + A.mapId = function() return map.id end + A.walkable = function(_, m, x, y) + if not m:inBounds(x, y) then return false end + return Permissions.isWalkable(m:cellCollision(x, y)) + end + A.isIce = function(m, x, y) + return Permissions.isIce(m:cellCollision(x, y)) + end + A.isWater = function() return false end + A.npcAt = function() return nil end + A.isWarpTile = function() return false end + A.surfing = function() return false end + bot._restore = function() + A.map, A.pos, A.walkable, A.isIce, A.isWater, A.npcAt, A.isWarpTile, A.surfing = + realMap, realPos, realWalkable, realIsIce, realIsWater, realNpcAt, + realIsWarp, realSurfing + end + return bot +end + +-- Corridor of ice ending at a wall: press right from floor rests on the last ice. +do + local cells = { + [5 * 100 + 3] = COLL_ICE, + [5 * 100 + 4] = COLL_ICE, + [5 * 100 + 5] = COLL_ICE, + [5 * 100 + 6] = COLL_WALL, + } + local map = fakeMap(cells) + local bot = stubBot(map, 2, 5) + local rx, ry, steps = bot:slideRest(map, 2, 5, "right") + eq(rx, 5, "slideRest stops on the last ice cell") + eq(ry, 5, "same row") + eq(steps, 3, "three cells of travel") + check(bot:slideRest(map, 5, 5, "right") == nil, + "pressing into the wall from the rest cell is no edge") + bot._restore() +end + +-- Mid-corridor ice is not a rest node: planPath to it must fail. +do + local cells = { + [5 * 100 + 3] = COLL_ICE, + [5 * 100 + 4] = COLL_ICE, + [5 * 100 + 5] = COLL_ICE, + [5 * 100 + 6] = COLL_WALL, + } + local map = fakeMap(cells) + local bot = stubBot(map, 2, 5) + check(bot:planPath(4, 5) == nil, + "a cell the slide skates past is unreachable as a rest") + local path = bot:planPath(5, 5) + check(path ~= nil, "the rest against the wall is reachable") + eq(#path, 1, "one press") + eq(path[1], "right", "to the east") + bot._restore() +end + +-- Off ice, planPath is still ordinary adjacency. +do + local map = fakeMap({}) + local bot = stubBot(map, 2, 5) + local path = bot:planPath(4, 5) + check(path ~= nil, "dry floor still paths") + eq(#path, 2, "two single-cell presses") + eq(path[1], "right", "first step") + eq(path[2], "right", "second step") + bot._restore() +end + +S.finish() diff --git a/tests/gen2_intro_test.lua b/tests/gen2_intro_test.lua new file mode 100644 index 00000000..1cc17193 --- /dev/null +++ b/tests/gen2_intro_test.lua @@ -0,0 +1,321 @@ +-- The Gold/Silver intro movie: the sprite-anim runtime it is built on +-- (src/ui/gen2/SpriteAnims.lua) and the 17-scene state machine that drives it +-- (src/ui/gen2/GoldSilverIntro.lua). +-- +-- ROM-free: the asset fixture below is the shape data/generated/intro.lua has, +-- with just enough of a tilemap to prove the BG map is being built the way +-- Intro_DrawBackground builds it. Every expected value traces back to +-- pokegold, so a failure names the routine it disagrees with. +-- luajit tests/gen2_intro_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 intro") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local SpriteAnims = require("src.ui.gen2.SpriteAnims") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") + +-- ---- sine ----------------------------------------------------------------- +-- engine/math/sine.asm's table as it sits in the Gold ROM at 02:4afb. +local ROM_SINE = { + [0] = 0x000, 0x019, 0x032, 0x04a, 0x062, 0x079, 0x08e, 0x0a2, + 0x0b5, 0x0c6, 0x0d5, 0x0e2, 0x0ed, 0x0f5, 0x0fb, 0x0ff, + 0x100, 0x0ff, 0x0fb, 0x0f5, 0x0ed, 0x0e2, 0x0d5, 0x0c6, + 0x0b5, 0x0a2, 0x08e, 0x079, 0x062, 0x04a, 0x032, 0x019, +} +local sineOk = true +for index = 0, 31 do + if SpriteAnims.SINE[index] ~= ROM_SINE[index] then sineOk = false end +end +check(sineOk, "the sine table matches the one in the cart") + +-- a = d * sin(a * pi/32), high byte only, two's complement past the halfway +-- point. AnimSeq_GSIntroChikoritaTotodile leans on all of these. +eq(SpriteAnims.sine(0, 8), 0, "sin(0) is flat") +eq(SpriteAnims.sine(16, 8), 8, "a quarter turn reaches the full amplitude") +eq(SpriteAnims.sine(48, 0x90), 256 - 0x90, "three quarters is the negative peak") +eq(SpriteAnims.cosine(0, 8), 8, "cosine leads sine by a quarter turn") +eq(SpriteAnims.cosine(16, 8), 0, "and is flat where sine peaks") + +-- ---- frame stepping ------------------------------------------------------- +local sys = SpriteAnims.new() +eq(sys:activeCount(), 0, "a fresh system has no live structs") + +-- Frameset_GSIntroShellder: two OAM sets, 8 frames apiece, then oamrestart. +local shellder = sys:init("GS_INTRO_SHELLDER", 7 * 8, 18 * 8) +check(shellder ~= nil, "a struct is handed out") +eq(shellder.x, 56, "depixel 18, 7 puts x at 7 tiles") +eq(shellder.y, 144, "and y at 18 tiles") +eq(shellder.index, 1, "wSpriteAnimCount starts at 1") + +local oam = sys:playFrame() +eq(#oam, 4, "OAMData_GSIntroShellder is a 2x2 block") +-- spriteanimoam $6c + dbsprite -1,-1 -> the top-left tile of the pair. +eq(oam[1].tile, 0x6c, "the OAM set's vtile offset lands on the tile id") +eq(oam[1].x, 48, "and the -1 column is 8 pixels left of the struct") +eq(oam[1].y, 136, "with the -1 row 8 pixels up") +eq(oam[2].tile, 0x6d, "the next column is the next tile") +eq(oam[3].tile, 0x7c, "and the next row is 16 tiles on, as the sheet is 16 wide") + +-- The first frame consumes one showing and loads a duration of 8, so the pair +-- swaps on the tenth pass, not the ninth. +for _ = 1, 8 do sys:playFrame() end +eq(sys.oam[1].tile, 0x6c, "the first OAM set holds for its whole duration") +eq(sys:playFrame()[1].tile, 0x6e, "then the frameset steps to SHELLDER_2") + +-- Ten slots, and the eleventh request is refused (_InitSpriteAnimStruct's +-- carry return). +local fresh = SpriteAnims.new() +for _ = 1, SpriteAnims.NUM_STRUCTS do + check(fresh:init("GS_INTRO_BUBBLE", 0, 0) ~= nil, nil) +end +eq(fresh:activeCount(), 10, "ten structs fit") +check(fresh:init("GS_INTRO_BUBBLE", 0, 0) == nil, "the eleventh is refused") + +-- ---- flips ---------------------------------------------------------------- +-- Frameset_GSIntroCyndaquil is the one intro frame with B_OAM_XFLIP on it, and +-- AddOrSubtractX mirrors every dbsprite around its own cell. +local flip = SpriteAnims.new() +flip:init("GS_INTRO_CYNDAQUIL", 100, 100) +local flipped = flip:playFrame() +eq(#flipped, 25, "OAMData_GSIntroStarter is 5x5") +eq(flipped[1].attr % 0x100, SpriteAnims.OAM_XFLIP, + "the frame's flip toggles into the OAM attribute") +-- dbsprite -3, -3, 4, 4: both offsets are $ec, and AddOrSubtractX turns the +-- x one into -($ec + 8). +eq(flipped[1].x, (100 - (0xec + 8)) % 256, "and mirrors the x offset") +eq(flipped[1].y, (100 + 0xec) % 256, "while y is untouched") + +-- ---- sequences ------------------------------------------------------------ +-- AnimSeq_GSIntroBubble deletes itself once var2 passes $40. +local bubbles = SpriteAnims.new() +local bubble = bubbles:init("GS_INTRO_BUBBLE", 48, 116) +for _ = 1, 0x40 do bubbles:playFrame() end +check(bubble.index ~= 0, "a bubble survives $40 frames") +bubbles:playFrame() +eq(bubble.index, 0, "and is deleted on the next one") + +-- AnimSeq_GSIntroShellder deletes once wGlobalAnimYOffset has carried it to +-- $b0; nothing else moves a Shellder. +local drift = SpriteAnims.new() +local shell = drift:init("GS_INTRO_SHELLDER", 56, 0xa0) +drift.globalY = 0x0f +drift:playFrame() +check(shell.index ~= 0, "a Shellder above $b0 stays") +drift.globalY = 0x10 +drift:playFrame() +eq(shell.index, 0, "and goes once the climb pushes it past") + +-- AnimSeq_GSIntroLapras: swim in to x $58, hold $b0 frames, swim off left and +-- raise wIntroSpriteStateFlag on the way out. +-- It starts at x $c0 and only moves on the odd frames, so it takes 212 to walk +-- down to the $58 the check is looking for and one step past it. +local sea = SpriteAnims.new() +local lapras = sea:init("GS_INTRO_LAPRAS", 24 * 8, 16 * 8) +for _ = 1, 212 do sea:playFrame() end +eq(lapras.jt, 1, "Lapras reaches its resting spot") +eq(lapras.x, 0x57, "one step past the $58 it was checking for") +eq(lapras.var2, 0xaf, "one frame into the $b0-frame hold") +eq(sea.flag, 0, "with the movie not told to move on yet") +for _ = 1, 2000 do sea:playFrame() end +eq(sea.flag, 1, "leaving the screen raises wIntroSpriteStateFlag") +eq(lapras.index, 0, "and takes the struct with it") + +-- AnimSeq_GSIntroPikachu runs four stages; the third is the one that tells +-- Jigglypuff to bail out. +-- $c0 -> $80 is 64 frames at a pixel each, then $30 + 1 frames of wind-up, +-- then the charge covers $80 -> $50 four pixels at a time. +local field = SpriteAnims.new() +local pikachu = field:init("GS_INTRO_PIKACHU", 24 * 8, 14 * 8) +for _ = 1, 65 do field:playFrame() end +eq(pikachu.jt, 1, "Pikachu stops at x $80") +eq(pikachu.x, 0x80, nil) +for _ = 1, 49 do field:playFrame() end +eq(pikachu.jt, 2, "then winds up for $30 frames") +eq(field.flag, 0, "without releasing Jigglypuff yet") +for _ = 1, 12 do field:playFrame() end +eq(pikachu.x, 0x50, "the charge covers $30 pixels") +eq(field.flag, 0, "the flag goes up on the frame that sees the finish line") +field:playFrame() +eq(field.flag, 1, "which is the one after") + +-- ---- the movie ------------------------------------------------------------ +-- A fixture with the shape the extractor writes: 16-wide metatile grids, four +-- tile ids per metatile, one palette per slot. +local function ramp(count, modulus) + local out = {} + for index = 1, count do out[index] = (index - 1) % (modulus or 256) end + return out +end + +local function palette() + return { { 8, 8, 8 }, { 16, 16, 16 }, { 32, 32, 32 }, { 64, 64, 64 } } +end + +local FIXTURE = { + water = { + tiles = "water.png", sprites = "water_ob.png", + meta = ramp(64 * 4), tilemap = ramp(32 * 16, 64), tilemapRows = 32, + firstRow = 15, + }, + grass = { + tiles = "grass.png", sprites = "grass_ob.png", + meta = ramp(48 * 4), tilemap = ramp(16 * 16, 48), tilemapRows = 16, + firstRow = 0, + }, + fire = { tiles = "fire.png", sprites = "fire_ob.png" }, + palettes = { + waterBg = palette(), waterOb = { palette(), palette() }, + magikarpBg = palette(), magikarpOb = palette(), + grassBg = palette(), grassOb = palette(), + startersOb = palette(), fireBg = { palette() }, + }, +} + +local movie = GoldSilverIntro.new(nil, { intro = FIXTURE }) +eq(movie.scene, 1, "the movie opens on IntroScene1") + +movie:step() +eq(movie.scene, 2, "which runs once and hands over") +eq(movie.scx, 0x58, "IntroScene1 parks hSCX at $58") +eq(movie.counter1, 0x80, "and gives the bubbles $80 frames") +eq(movie.act, "water", "on the water act's art") +eq(movie.anims:activeCount(), 3, "with Intro_InitShellders' three Shellders") +check(movie.lyActive, "and the per-scanline wobble switched on") + +-- Intro_InitBubble's .pixel_table has six 2-byte entries, but the index it +-- reads (wIntroFrameCounter1 & $70, swapped into a 0-7 range) covers eight +-- slots. On hardware the top two slots read past the table into whatever +-- code bytes follow it, so those two bubble spawns are lost; this is a cart +-- bug, not a porting gap, and the port reproduces the drop rather than +-- inventing entries $6 and $7 never had. +local bubbleCases = { + { before = 0x71, spot = nil }, -- e=7: past the table, dropped + { before = 0x61, spot = nil }, -- e=6: past the table, dropped + { before = 0x51, spot = { 8 * 8, 17 * 8 } }, -- e=5 + { before = 0x41, spot = { 4 * 8, 13 * 8 } }, -- e=4 + { before = 0x31, spot = { 12 * 8, 15 * 8 } }, -- e=3 + { before = 0x21, spot = { 10 * 8, 16 * 8 + 4 } }, -- e=2 + { before = 0x11, spot = { 14 * 8, 18 * 8 + 4 } }, -- e=1 + { before = 0x01, spot = { 6 * 8, 14 * 8 + 4 } }, -- e=0 +} +local probe = GoldSilverIntro.new(nil, { intro = FIXTURE }) +probe:step() +for _, case in ipairs(bubbleCases) do + probe.counter1 = case.before + probe.anims:clear() + GoldSilverIntro.Scenes[2](probe) + if case.spot then + eq(probe.anims:activeCount(), 1, + ("slot for frame counter $%x spawns a bubble"):format(case.before)) + local live + for slot = 1, SpriteAnims.NUM_STRUCTS do + local st = probe.anims.structs[slot] + if st.index ~= 0 then live = st end + end + eq(live.x, case.spot[1] % 256, "at the table's x") + eq(live.y, case.spot[2] % 256, "and its y") + else + eq(probe.anims:activeCount(), 0, + ("slot for frame counter $%x runs past the table and drops"):format(case.before)) + end +end + +-- The BG map is real: Intro_DrawBackground lays metatile row 15 across BG +-- rows 0-1, and Intro_Draw2x2Tiles puts its four tile ids in reading order. +local first = FIXTURE.water.tilemap[15 * 16 + 1] +eq(movie.bgmap[1], FIXTURE.water.meta[first * 4 + 1], + "BG (0,0) is the first tile of the first metatile of row 15") +eq(movie.bgmap[2], FIXTURE.water.meta[first * 4 + 2], "then its top right") +eq(movie.bgmap[32 + 1], FIXTURE.water.meta[first * 4 + 3], "then its bottom left") + +-- The climb: hSCY drops one pixel every other frame, and every sixteenth +-- pixel streams a fresh metatile row into the row that just wrapped off. +local function runTo(state, scene, limit) + for _ = 1, limit or 4000 do + if state.scene == scene or state.done then return end + state:step() + end +end + +runTo(movie, 3) +eq(movie.scene, 3, "the bubbles give way to the climb") +eq(movie.counter1, 0x10, "with $10 metatile rows to stream in") +local rowBefore = movie.tilemapRow +for _ = 1, 32 do movie:step() end +eq(movie.tilemapRow, rowBefore - 1, "32 frames buys one metatile row") +eq(movie.bgRow, 30, "written into the two BG rows that just wrapped") + +runTo(movie, 4) +eq(movie.scy, 0x10, "the climb ends 16 pixels below where the map wraps") +check(not movie.lyActive, "and the wobble is off by then") + +runTo(movie, 6) +eq(movie.bgp, 0x00, "IntroScene5 fades the BG palette flat") +movie:step() +eq(movie.act, "grass", "IntroScene6 swaps in the grass act") +eq(movie.scx, 0x60, "at hSCX $60") +eq(movie.anims.globalX, 0xa0, "with wGlobalAnimXOffset holding the sprites") + +runTo(movie, 8) +eq(movie.scx, 0, "IntroScene7 scrolls all the way left") +local live = {} +for slot = 1, SpriteAnims.NUM_STRUCTS do + local st = movie.anims.structs[slot] + if st.index ~= 0 then live[st.seqId] = true end +end +check(live.GSIntroPikachu and live.GSIntroPikachuTail, + "and Intro_InitPikachu adds the body and the tail") +check(live.GSIntroJigglypuff, "with Jigglypuff still waiting") + +runTo(movie, 10, 4000) +movie:step() +eq(movie.act, "fire", "IntroScene10 swaps in the fire act") +eq(movie.scy, 0x80, "starting a screen and a half below the silhouette") +eq(movie.bgp, 0x3f, "with the flat silhouette palette") +-- DrawIntroCharizardGraphic 0: 8x8 tiles of running ids from $00 at (10,6). +eq(movie.bgmap[6 * 32 + 10 + 1], 0x00, "the silhouette starts at tilemap (10,6)") +eq(movie.bgmap[6 * 32 + 17 + 1], 0x07, "and runs its ids across the row") +eq(movie.bgmap[7 * 32 + 10 + 1], 0x08, "then wraps to the next") + +runTo(movie, 13, 4000) +eq(movie.counter1, 0x80, "IntroScene12 leaves $80 frames of held breath") +runTo(movie, 15, 4000) +eq(movie.bgmap[6 * 32 + 8 + 1], 0x88, "the fire-breathing frame is redrawn wider") + +runTo(movie, 17, 4000) +for _ = 1, 64 do movie:step() end +check(movie.done, "and 64 frames of black end the movie") + +-- The whole thing, start to finish, is about 39 seconds -- which is what the +-- cart takes. A wrong counter shows up here before it shows up on screen. +local timed = GoldSilverIntro.new(nil, { intro = FIXTURE }) +local frames = 0 +for index = 1, 6000 do + if timed:step() then frames = index break end +end +check(frames > 2200 and frames < 2500, + "the movie runs about 2300 frames (got " .. frames .. ")") + +-- Any button skips it, as .PlayFrame does on PAD_BUTTONS. +local skipped, called = GoldSilverIntro.new(nil, { intro = FIXTURE }), false +skipped.onDone = function() called = true end +skipped:skip() +check(called, "a skip reports the movie as done") + +-- ---- palette remapping ---------------------------------------------------- +-- CopyPals: colour i comes from colour (reg >> 2i) & 3 of the loaded palette. +local base = { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } } +local identity = GoldSilverIntro.remap(base, 0xe4) +eq(identity[1][1], 1, "%11100100 is the identity") +eq(identity[4][1], 4, nil) +local flat = GoldSilverIntro.remap(base, 0x00) +eq(flat[1][1], 1, "%00000000 collapses every shade") +eq(flat[4][1], 1, "onto colour 0") +local silhouette = GoldSilverIntro.remap(base, 0x3f) +eq(silhouette[1][1], 4, "%00111111 fills the screen with colour 3") +eq(silhouette[4][1], 1, "and paints colour 3 with colour 0") + +S.finish() diff --git a/tests/gen2_magnet_train_test.lua b/tests/gen2_magnet_train_test.lua new file mode 100644 index 00000000..4e70649d --- /dev/null +++ b/tests/gen2_magnet_train_test.lua @@ -0,0 +1,411 @@ +-- The Magnet Train ride (engine/events/magnet_train.asm). ROM-free: +-- `luajit tests/gen2_magnet_train_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 magnet train") +local check, eq = S.check, S.eq + +local MagnetTrain = require("src.core.gen2.MagnetTrain") +local Specials = require("src.script.gen2.Specials") + +-- ---- the direction the officer's setval picks ------------------------------ +-- +-- ld a, [wScriptVar] / and a / jr nz, .ToGoldenrod +-- +-- so Goldenrod's `setval FALSE` runs the forwards arm and Saffron's +-- `setval TRUE` runs .ToGoldenrod. +do + local out = MagnetTrain.new({ toGoldenrod = false }) + eq(out.direction, 1, "forwards: wMagnetTrainDirection is 1") + eq(out.initPosition, 96, "lb bc, 8 tiles, 12 tiles -> c is the init position") + eq(out.holdPosition, 64, "...and b is the hold position") + eq(out.finalPosition, 160, + "lb de, (11 tiles) - (11 tiles + 4), -12 tiles -> e is the final position") + eq(out.playerSpriteInitX, 252, "...and d is the player sprite's x, -4") + + local back = MagnetTrain.new({ toGoldenrod = true }) + eq(back.direction, 255, ".ToGoldenrod: -1, as a byte") + eq(back.initPosition, 160, "and every position is its mirror") + eq(back.holdPosition, 192, "hold") + eq(back.finalPosition, 96, "final") + eq(back.playerSpriteInitX, 180, + "(11 tiles) + (11 tiles + 4) = 180, the far side of the screen") +end + +-- ---- MagnetTrain_UpdateLYOverrides ---------------------------------------- +-- +-- Three runs of 6*8-1, 6*8 and 6*8+1 entries, which is 144 scanlines: bushes, +-- the train body, bushes. The train band is the only one the jumptable moves. +do + local ride = MagnetTrain.new({ toGoldenrod = false }) + ride:update() + eq(#ride.ly, 144, "the three runs cover every scanline exactly once") + local bands = ride:bands() + eq(#bands, 3, "and they are three bands") + eq(bands[1][1], 0, "band 1 opens at scanline 0") + eq(bands[1][2], 46, "and closes at 46 (6 * TILE_WIDTH - 1 entries)") + eq(bands[2][1], 47, "band 2 is the train") + eq(bands[2][2], 94, "48 scanlines of it") + eq(bands[3][1], 95, "and band 3 runs to the bottom") + eq(bands[3][2], 143, "") + eq(bands[1][3], bands[3][3], "the two bush bands share one SCX") + eq(bands[2][3], ride.position, "the middle band IS wMagnetTrainPosition") +end + +-- The scenery never stops: the offset advances two a frame whatever the +-- jumptable is doing, and the bands read offset * 2. +do + local ride = MagnetTrain.new({ toGoldenrod = false }) + local first = ride.offset + ride:update() + eq(ride.offset, first + 2, "wMagnetTrainOffset gains `add d` twice a frame") + local scx = ride:bands()[1][3] + ride:update() + eq(ride:bands()[1][3], (scx + 4) % 256, "so the bushes move four pixels") + eq(ride.position, ride.initPosition, + "while the train has not moved at all yet: state 0 is the sprite init") +end + +-- ---- the jumptable --------------------------------------------------------- +-- +-- .InitPlayerSpriteAnim, .WaitScene(128), .MoveTrain1 to the hold position, +-- .WaitScene(128), .MoveTrain2 at double speed to the final position, +-- .WaitScene(already 0), .TrainArrived. +do + local ride = MagnetTrain.new({ toGoldenrod = false }) + eq(ride.index, 0, "the ride opens on .InitPlayerSpriteAnim") + check(ride.spriteX == nil, "with no sprite struct yet") + ride:update() + eq(ride.index, 1, "which advances immediately") + eq(ride.spriteX, ride.playerSpriteInitX, "having placed the player") + eq(ride.spriteY, (8 + 2) * 8 + 5, "at d = (8 + 2) * TILE_WIDTH + 5") + eq(ride.waitCounter, 128, "and armed the first 128 frame wait") + + -- .WaitScene holds for 129 frames: 128 decrements, then the pass that reads + -- zero and advances. + local frames = 0 + while ride.index == 1 do ride:update() frames = frames + 1 end + eq(frames, 129, "a counter of 128 holds for 129 frames") + eq(ride.index, 2, "then .MoveTrain1") + + frames = 0 + while ride.index == 2 do ride:update() frames = frames + 1 end + -- 96 down to 64, a pixel a frame, plus the pass that notices it arrived. + eq(frames, 33, ".MoveTrain1 walks 32 pixels and then prepares the hold") + eq(ride.position, ride.holdPosition, "landing exactly on the hold position") + eq(ride.globalX, 32, + "wGlobalAnimXOffset tracks it pixel for pixel, which is what keeps the " + .. "player locked to the window") + eq(ride.waitCounter, 128, ".PrepareToHoldTrain re-arms the wait") + + while ride.index == 3 do ride:update() end + eq(ride.index, 4, "the second wait ends on .MoveTrain2") + frames = 0 + while ride.index == 4 do ride:update() frames = frames + 1 end + -- 64 down to -96 in twos. + eq(frames, 81, ".MoveTrain2 covers 160 pixels two at a time") + eq(ride.position, ride.finalPosition, "and stops on the final position") + eq(ride.globalX, 192, "the player has ridden the whole 160 with it") + + eq(ride.waitCounter, 0, + "the third .WaitScene has a counter of 0 left over, so it passes straight " + .. "through") + local sfx = nil + while not ride:done() do sfx = ride:update() or sfx end + eq(sfx, "Sfx_TrainArrived", ".TrainArrived plays SFX_TRAIN_ARRIVED") + check(ride:done(), "and sets JUMPTABLE_EXIT, which ends the loop") + check(ride:update() == nil, "a finished ride does nothing more") +end + +-- The trip back is the mirror image: the same frame count, the other way. +do + local forward, back = 0, 0 + local a = MagnetTrain.new({ toGoldenrod = false }) + while not a:done() and forward < 2000 do a:update() forward = forward + 1 end + local b = MagnetTrain.new({ toGoldenrod = true }) + while not b:done() and back < 2000 do b:update() back = back + 1 end + eq(back, forward, "both directions run for the same number of frames") + eq(a.globalX, 192, "forwards ends with the player 192 pixels along") + eq(b.globalX, 64, "backwards ends 192 pixels the other way, as a byte") +end + +-- ---- the player in the window --------------------------------------------- +-- +-- .Frameset_MagnetTrainRed: OAM sets 1 and 2 on an eight frame beat, the +-- fourth mirrored, then oamrestart. The object's sequence is +-- SPRITE_ANIM_FUNC_NULL, so nothing but wGlobalAnimXOffset ever moves it. +do + local ride = MagnetTrain.new({ toGoldenrod = false }) + eq(#ride:playerOam(), 0, "nothing is drawn before .InitPlayerSpriteAnim") + ride:update() + eq(#ride:playerOam(), 0, + "nor on the frame that creates the struct: PlaySpriteAnimations runs " + .. "BEFORE MagnetTrain_Jumptable, so the first frame of the frameset is " + .. "picked on the pass after the one that spawned it") + ride:update() + local oam = ride:playerOam() + eq(#oam, 4, ".OAMData_MagnetTrainRed is a 2x2 block") + eq(oam[1].tile, 0x00, "OAM set 1 is vtile $00") + eq(MagnetTrain.SHEET_FRAME[0x00], 0, + "which is ChrisSpriteGFX tile 0: the standing-down frame") + eq(MagnetTrain.SHEET_FRAME[0x04], 3, + "and vtile $04 is ChrisSpriteGFX + 12 tiles: the down walk frame") + eq(oam[1].y, oam[2].y, "the top two tiles share a row") + eq(oam[3].y - oam[1].y, 8, "and the bottom two sit eight pixels under them") + + -- The frameset holds each entry for nine passes (the one that sets the + -- duration plus eight that decrement it). + local seen, flipped = {}, {} + for _ = 1, 9 * 4 do + local entry = ride:playerOam()[1] + seen[#seen + 1] = entry.tile + flipped[#flipped + 1] = entry.xflip + ride:update() + end + eq(seen[1], 0x00, "frame 1 is the standing tile") + eq(seen[10], 0x04, "frame 2 the walk tile, nine passes later") + eq(seen[19], 0x00, "frame 3 back to standing") + eq(seen[28], 0x04, "frame 4 the walk tile again") + check(flipped[28], "...and that fourth one is B_OAM_XFLIP") + check(not flipped[1] and not flipped[10] and not flipped[19], + "the other three are not") + eq(seen[37 - 1], 0x04, "the 36th pass is still inside frame 4") +end + +-- The player rides with the train: the sprite's x is its struct x plus +-- wGlobalAnimXOffset, which is the same counter the train band moves by. +do + local ride = MagnetTrain.new({ toGoldenrod = false }) + while ride.index < 2 do ride:update() end + local before = ride:playerOam()[1].x + local band = ride:bands()[2][3] + ride:update() + eq(ride:playerOam()[1].x - before, 1, "the player moves a pixel") + eq((band - ride:bands()[2][3]) % 256, 1, "and so does the train band") +end + +-- ---- DrawMagnetTrain ------------------------------------------------------- +-- +-- Rows 0-17 are MagnetTrainBGTiles' pair for that row repeated across all 32 +-- columns; MagnetTrainTilemap's four 20-tile lines land on rows 6-9. +do + local bgTiles, tilemap = {}, {} + for row = 0, 17 do + bgTiles[row * 2 + 1] = row + bgTiles[row * 2 + 2] = row + 100 + end + for i = 1, 20 * 4 do tilemap[i] = 200 + i end + + local ride = MagnetTrain.new({ bgTiles = bgTiles, fgTilemap = tilemap }) + local rows = ride:tilemap() + eq(#rows, 18, "eighteen screen rows") + eq(#rows[1], 32, "each a full TILEMAP_WIDTH") + eq(rows[1][1], 0, ".FillAlt writes e then d") + eq(rows[1][2], 100, "") + eq(rows[1][31], 0, "sixteen times over") + eq(rows[6][1], 5, "row 5 is untouched by the train") + eq(rows[7][1], 201, "row 6 is the first MagnetTrainTilemap line") + eq(rows[10][20], 200 + 80, "and row 9 the last, twenty tiles wide") + eq(rows[10][21], 9, "column 20 onward keeps the background strip") + eq(rows[11][1], 10, "row 10 is background again") + + check(MagnetTrain.new({}):tilemap() == nil, + "a cache with no tilemaps answers nil rather than inventing one") +end + +-- ---- SetMagnetTrainPals ---------------------------------------------------- +do + eq(MagnetTrain.paletteSlot(0, 0), MagnetTrain.PAL_BG_GREEN, + "four rows of bushes on top") + eq(MagnetTrain.paletteSlot(31, 3), MagnetTrain.PAL_BG_GREEN, "") + eq(MagnetTrain.paletteSlot(0, 4), MagnetTrain.PAL_BG_GRAY, + "ten rows of train under them") + eq(MagnetTrain.paletteSlot(0, 13), MagnetTrain.PAL_BG_GRAY, "") + eq(MagnetTrain.paletteSlot(0, 14), MagnetTrain.PAL_BG_GREEN, + "and four more rows of bushes at the bottom") + eq(MagnetTrain.paletteSlot(7, 8), MagnetTrain.PAL_BG_YELLOW, + "the window is six tiles at (7, 8)") + eq(MagnetTrain.paletteSlot(12, 8), MagnetTrain.PAL_BG_YELLOW, "") + eq(MagnetTrain.paletteSlot(13, 8), MagnetTrain.PAL_BG_GRAY, + "and no wider than that") + eq(MagnetTrain.paletteSlot(7, 9), MagnetTrain.PAL_BG_GRAY, + "nor any taller") +end + +-- ---- special MagnetTrain --------------------------------------------------- +-- +-- The routine READS wScriptVar and never writes it, so the handler must leave +-- vm.scriptVar exactly as the officer's `setval` left it. +do + check(Specials.HANDLERS.MagnetTrain, "MagnetTrain is a handler now") + check(not Specials.STUBS.MagnetTrain, "and no longer a stub") + + local calls = {} + local vm = { + scriptVar = 0, + specials = { + magnetTrain = function(toGoldenrod, done) + calls[#calls + 1] = toGoldenrod + done() + end, + }, + } + Specials.ALL.MagnetTrain(vm) + eq(#calls, 1, "the hook is called once") + eq(calls[1], false, "wScriptVar 0 is the forwards trip") + eq(vm.scriptVar, 0, "and wScriptVar comes back untouched") + + vm.scriptVar = 1 + Specials.ALL.MagnetTrain(vm) + eq(calls[2], true, "wScriptVar 1 is .ToGoldenrod") + eq(vm.scriptVar, 1, "still untouched") + + local bare = { scriptVar = 7, specials = {} } + Specials.ALL.MagnetTrain(bare) + eq(bare.scriptVar, 7, + "and with no hook at all the special is a no-op, not a clobber") +end + +-- ---- the arrival, from `warpcheck` to the officer's line ------------------- +-- +-- The ride is only half the feature: the two station scripts end +-- +-- special MagnetTrain +-- warpcheck +-- newloadmap MAPSETUP_TRAIN +-- +-- and everything the player actually SEES on the far side hangs off the coord +-- event the load leaves them one step away from. These check that chain +-- against the real map data, because each link is a place the port could be +-- complete and still reach nobody. They need the gold cache. +do + 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 probe = io.open(cache .. "/data/generated/maps.lua", "r") + if not probe then + check(true, "gold cache absent : ride checks only (SKIP the arrival)") + S.finish() + return + end + probe:close() + + local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end + local maps = loadLua("data/generated/maps.lua") + local tilesets = loadLua("data/generated/tilesets.lua") + local scripts = loadLua("data/generated/scripts.lua") + + local World = require("src.world.gen2.World") + local Map = require("src.world.gen2.Map") + local Permissions = require("src.world.gen2.Permissions") + + local GOLDENROD = "GOLDENROD_MAGNET_TRAIN_STATION" + local SAFFRON = "SAFFRON_MAGNET_TRAIN_STATION" + + -- maps/GoldenrodMagnetTrainStation.asm and maps/SaffronMagnetTrainStation.asm + -- carry the same four warp_events, and the two train doors cross over: the + -- door the player walks INTO at x 6 comes out of the far station's door at + -- x 11. That crossing is what decides the arrival cell, so it is checked + -- before anything that depends on it. + for _, pair in ipairs({ { GOLDENROD, SAFFRON }, { SAFFRON, GOLDENROD } }) do + local here, there = maps[pair[1]], maps[pair[2]] + eq(here.warps[3].x, 6, pair[1] .. " warp 3 is the boarding door") + eq(here.warps[3].y, 5, "on the platform's top row") + eq(here.warps[3].destMap, pair[2], "leading to the other station") + eq(here.warps[3].destWarp, 4, "and out of ITS warp 4") + eq(there.warps[4].x, 11, "which stands at x 11") + eq(there.warps[4].y, 5, "on the same top row") + end + + -- Script_ArriveFromGoldenrod / Script_ArriveFromSaffron: one coord_event per + -- map, on scene 0 (`def_scene_scripts`' const_def is 0-based and the arrive + -- scene is its first entry, which is also the scene a fresh save is on). + for _, id in ipairs({ GOLDENROD, SAFFRON }) do + local evs = maps[id].coordEvents or {} + eq(#evs, 1, id .. " has exactly one coord_event") + eq(evs[1].x, 11, "at x 11") + eq(evs[1].y, 6, "and y 6, one cell SOUTH of the arrival door") + eq(evs[1].sceneId or 0, 0, "on the arrive scene, which is scene 0") + local body = scripts[evs[1].scriptKey] + check(body, "and its script body is in the cache") + eq(body[1].op, "applymovement", "officer steps up to the train door") + eq(body[2].op, "applymovement", "the player walks off the train") + eq(body[4].op, "opentext", "and only then does the officer talk") + end + + -- A World over the real defs, driven the way the station script drives it. + local function station(id) + local game = { data = { audio = { sfxOrder = {} } }, save = { player = {} } } + local world = World.new(game) + world.maps, world.tilesets = maps, tilesets + world.map = Map.new(maps[id], tilesets[maps[id].tileset]) + world.map.def = maps[id] + world.player = { cellX = 6, cellY = 5, facing = "up", moving = false } + return world + end + + for _, pair in ipairs({ { GOLDENROD, SAFFRON }, { SAFFRON, GOLDENROD } }) do + local world = station(pair[1]) + local loaded + world.setMap = function(_, id, x, y, facing) + loaded = { id = id, x = x, y = y, facing = facing } + return true + end + + -- `warpcheck`, run standing on the boarding door the applymovement walked + -- the player onto. + check(world:armWarpCheck(), pair[1] .. ": warpcheck finds the train door") + eq(world.pendingWarp.destMap, pair[2], "armed for the other station") + + -- `newloadmap MAPSETUP_TRAIN`. EnterMapWarp then GetWarpDestCoords, so the + -- load goes to the DESTINATION warp's own cell rather than back onto the + -- cell underfoot, and _Train carries no SpawnInFacingDown so the facing the + -- player boarded with survives. + world:newLoadMap(0xf9) + check(loaded, "newloadmap MAPSETUP_TRAIN loads a map") + eq(loaded.id, pair[2], "the far station") + eq(loaded.x, 11, "at the destination warp's x") + eq(loaded.y, 5, "and its y : the train doorway, NOT the coord_event") + eq(loaded.facing, "up", "still facing the way they walked aboard") + check(world.pendingWarp == nil, "and the armed warp is spent") + end + + -- Why (11,5) is not an off-by-one. Block $12 of TILESET_TRAIN_STATION is + -- `tilecoll WALL, WALL, WALL, DOOR`, so the doorway's only open neighbour is + -- the cell to the SOUTH -- which is the coord_event. The arrival + -- conversation is reached by that one forced step, exactly as the cart + -- reaches it: EnterMap runs DisableEvents, and CheckPlayerState only turns + -- player events back on once a step finishes. + for _, id in ipairs({ GOLDENROD, SAFFRON }) do + local map = Map.new(maps[id], tilesets[maps[id].tileset]) + check(Permissions.isWarpCollision(map:cellCollision(11, 5)), + id .. ": the arrival cell is the train doorway") + check(map:isWalkable(11, 6), "south of it is the platform") + check(not map:isWalkable(10, 5), "west is wall") + check(not map:isWalkable(12, 5), "east is wall") + check(not map:isWalkable(11, 4), "and north is wall") + end + + -- The step itself: World:tryCoordScript is what the overworld loop calls on + -- the frame the player lands, and it is the only thing that starts the + -- arrival script. + for _, id in ipairs({ GOLDENROD, SAFFRON }) do + local world = station(id) + local started + world.vm = { start = function(_, key) started = key return true end } + world.busy = function() return false end + + world.player = { cellX = 11, cellY = 5, facing = "up", moving = false } + check(not world:tryCoordScript(), + id .. ": standing in the doorway starts nothing") + + world.player = { cellX = 11, cellY = 6, facing = "down", moving = false } + check(world:tryCoordScript(), "the step south starts the arrival script") + eq(started, maps[id].coordEvents[1].scriptKey, + "which is the map's own coord_event script") + end +end + +S.finish() diff --git a/tests/gen2_mail_test.lua b/tests/gen2_mail_test.lua new file mode 100644 index 00000000..aa869914 --- /dev/null +++ b/tests/gen2_mail_test.lua @@ -0,0 +1,903 @@ +-- MAIL: the ten items, the `mailmsg` struct on a party slot, the PC's MAILBOX, +-- the compose keyboard's charset, and every refusal around a mon that is +-- carrying a letter. +-- +-- ROM-free. The model (src/core/gen2/Mail.lua) is love-free and takes its +-- state by argument, so most of this is plain table assertions; the four +-- screens need the same love stub the rest of the Gold menu suites use, and +-- none of them draw here. + +package.path = "./?.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, + setLineWidth = function() end, +} +love.math = love.math or { + random = function(a, b) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +local Boxes = require("src.core.gen2.Boxes") +local Breeding = require("src.core.gen2.Breeding") +local HeldItemMenu = require("src.ui.gen2.HeldItemMenu") +local Mail = require("src.core.gen2.Mail") +local MailCompose = require("src.ui.gen2.MailCompose") +local MailMenu = require("src.ui.gen2.MailMenu") +local MailRead = require("src.ui.gen2.MailRead") +local MailboxMenu = require("src.ui.gen2.MailboxMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local PcMenu = require("src.ui.gen2.PcMenu") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + local input = newInput() + return { + input = input, + save = save, + data = { audio = {}, pokemon = {}, items = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + }, input +end + +-- A save with a party of `n` conscious mons and nothing else going on. +local function newSave(n) + local save = Save.newGame({ playerName = "GOLD", trainerId = 1234 }) + for i = 1, n or 1 do + save.party[i] = { species = "CYNDAQUIL", nickname = "MON" .. i, hp = 20, + maxHp = 20, level = 10 } + end + return save +end + +-- ------------------------------------------------------------ ItemIsMail + +-- data/items/mail_items.asm is a ten-entry list and ItemIsMail is a linear +-- search of it. The two that matter are LITEBLUEMAIL and PORTRAITMAIL: a name +-- test for "_MAIL" -- which is what the port used before this landed -- misses +-- both, and a pocket test misses all ten (ItemAttributes puts mail in the +-- ordinary ITEM pocket). +check("ten mail items", #Mail.ITEMS, 10) +for _, id in ipairs(Mail.ITEMS) do + check(id .. " is mail", Mail.isMail(id), true) +end +check("LITEBLUEMAIL is mail", Mail.isMail("LITEBLUEMAIL"), true) +check("PORTRAITMAIL is mail", Mail.isMail("PORTRAITMAIL"), true) +check("POTION is not", Mail.isMail("POTION"), false) +check("nil is not", Mail.isMail(nil), false) +-- A name that merely ends the right way is still not on the list. +check("a made-up _MAIL id is not", Mail.isMail("FAKE_MAIL"), false) + +-- The *_MAIL_INDEX block is `const_def`, so 0-based and in the item order. +check("FLOWER_MAIL is index 0", Mail.INDEX.FLOWER_MAIL, 0) +check("MIRAGE_MAIL is index 9", Mail.INDEX.MIRAGE_MAIL, 9) + +-- ------------------------------------------------------------- the struct + +do + local save = newSave(2) + save.party[1].item = "FLOWER_MAIL" + Mail.compose(save, 1, "HELLO", save.party[1], "FLOWER_MAIL") + local entry = Mail.get(save, 1) + check("compose stores the message", entry.message, "HELLO") + check("with the player as author", entry.author, "GOLD") + check("and the player's ID", entry.authorId, 1234) + check("and the mon's species", entry.species, "CYNDAQUIL") + check("and the stationery", entry.type, "FLOWER_MAIL") + + -- The buffer is MAIL_MSG_LENGTH bytes; anything past it never existed. + local long = ("X"):rep(64) + Mail.compose(save, 2, long, save.party[2], "SURF_MAIL") + check("a message is trimmed to the buffer", + #Mail.get(save, 2).message, Mail.MAIL_MSG_LENGTH) + + -- A slot outside sPartyMail's six structs is not a slot. + check("slot 7 is refused", Mail.set(save, 7, Mail.entry("SURF_MAIL")), false) + check("slot 0 is refused", Mail.set(save, 0, Mail.entry("SURF_MAIL")), false) +end + +-- Mail.lines is MailGFX_PlaceMessage's two rows: a composed message has no +-- break in it and splits by width, a script's message carries its own. +do + local wide = Mail.entry("FLOWER_MAIL", ("A"):rep(20)) + local top, bottom = Mail.lines(wide) + check("a long message splits at MAIL_LINE_LENGTH", #top, 16) + check("and the rest is the second row", #bottom, 4) + local scripted = Mail.entry("FLOWER_MAIL", "DARK CAVE leads\nto another road") + local a, b = Mail.lines(scripted) + check("an explicit break wins", a, "DARK CAVE leads") + check("and gives the second row", b, "to another road") + local short = Mail.entry("FLOWER_MAIL", "HI") + local s1, s2 = Mail.lines(short) + check("a short message is one row", s1, "HI") + check("with nothing under it", s2, "") +end + +-- ---------------------------------------------------------- GivePokeMail + +-- `ld a, [wPartyCount] / dec a`: the letter always lands on the LAST party +-- member, which is the mon the `givepoke` before it just added. +do + local save = newSave(3) + save.party[3].otName = "RANDY" + save.party[3].otId = 518 + check("give lands on the last slot", + Mail.give(save, "FLOWER_MAIL", "DARK CAVE leads\nto another road"), true) + check("and hangs the item on that mon", save.party[3].item, "FLOWER_MAIL") + check("with nothing on the first", save.party[1].item, nil) + local entry = Mail.get(save, 3) + check("the author is the mon's OT", entry.author, "RANDY") + check("and the OT's ID", entry.authorId, 518) + check("an empty party takes nothing", + Mail.give(newSave(0), "FLOWER_MAIL", "HI"), false) + check("and a non-mail item takes nothing", + Mail.give(newSave(1), "POTION", "HI"), false) +end + +-- ------------------------------------------------------- the slot shifting + +-- sPartyMail is keyed by SLOT, so RemoveMonFromPartyOrBox shifts every struct +-- behind the departing mon up one ("Mail time!"). +do + local save = newSave(3) + Mail.set(save, 2, Mail.entry("SURF_MAIL", "TWO")) + Mail.set(save, 3, Mail.entry("EON_MAIL", "THREE")) + Mail.removeSlot(save, 1) + check("the second letter moved to slot 1", Mail.get(save, 1).message, "TWO") + check("the third to slot 2", Mail.get(save, 2).message, "THREE") + check("and the old last slot is empty", Mail.get(save, 3), nil) + + -- SwitchPartyMons swaps the two structs through wSwitchMonBuffer. + Mail.swapSlots(save, 1, 2) + check("a swap carries the letters", Mail.get(save, 1).message, "THREE") + check("both ways", Mail.get(save, 2).message, "TWO") +end + +-- IsAnyMonHoldingMail is a whole-party question, which is what makes MOVE +-- POKéMON W/O MAIL an all-or-nothing refusal. +do + local save = newSave(3) + check("a clean party holds no mail", Mail.anyMonHoldingMail(save), false) + save.party[3].item = "LITEBLUEMAIL" + check("one letter anywhere is enough", Mail.anyMonHoldingMail(save), true) + save.party[3].item = "POTION" + check("an ordinary held item is not", Mail.anyMonHoldingMail(save), false) +end + +-- ----------------------------------------------------------- the MAILBOX + +do + local save = newSave(2) + save.party[1].item = "FLOWER_MAIL" + Mail.compose(save, 1, "HELLO", save.party[1], "FLOWER_MAIL") + check("the MAILBOX starts empty", Mail.mailboxCount(save), 0) + check("SendMailToPC moves the letter", Mail.sendToPc(save, 1), true) + check("the MAILBOX has it", Mail.mailboxCount(save), 1) + check("the party slot is cleared", Mail.get(save, 1), nil) + -- SendMailToPC writes the mon's held item byte to 0 in the same routine. + check("and the mon is no longer holding it", save.party[1].item, nil) + check("a mon with no mail sends nothing", Mail.sendToPc(save, 2), false) + + -- MAILBOX_CAPACITY is 10 and .full is carry, which MonMailAction prints + -- _MailboxFullText for. + for i = 2, Mail.MAILBOX_CAPACITY do + Mail.mailbox(save)[i] = Mail.entry("SURF_MAIL", "N" .. i) + end + check("the MAILBOX fills at ten", Mail.mailboxFull(save), true) + save.party[2].item = "EON_MAIL" + Mail.compose(save, 2, "LATE", save.party[2], "EON_MAIL") + check("and refuses an eleventh", Mail.sendToPc(save, 2), false) + check("leaving the letter on the mon", Mail.get(save, 2).message, "LATE") + + -- DeleteMailFromPC keeps sMailboxes dense. + local removed = Mail.deleteFromPc(save, 1) + check("delete answers the letter", removed.message, "HELLO") + check("and closes the list up", Mail.mailboxCount(save), 9) + check("with the next one first", Mail.mailbox(save)[1].message, "N2") + check("deleting nothing answers nil", Mail.deleteFromPc(save, 99), nil) +end + +-- MoveMailFromPCToParty: the struct moves AND the mail's type byte becomes the +-- mon's held item, which is how the letter and its stationery stay together. +do + local save = newSave(2) + Mail.mailbox(save)[1] = Mail.entry("MUSIC_MAIL", "TUNE", "AMY", 7, "NATU") + check("attach moves it", Mail.moveFromPcToParty(save, 1, 2), true) + check("the mon holds the stationery", save.party[2].item, "MUSIC_MAIL") + check("and carries the letter", Mail.get(save, 2).message, "TUNE") + check("the MAILBOX is empty again", Mail.mailboxCount(save), 0) + check("attaching from an empty box fails", + Mail.moveFromPcToParty(save, 1, 1), false) +end + +-- --------------------------------------------------------- CheckPokeMail + +-- The order of these five is the cart's, and it is load bearing: the one call +-- site (Route31MailRecipientScript) has an `ifequal` for every value. +do + local EXPECT = "DARK CAVE leads\nto another road" + -- REFUSED: the B press, which never looks at a mon at all. + check("no slot is REFUSED", + Mail.checkPokeMail(newSave(2), nil, EXPECT), Mail.POKEMAIL_REFUSED) + + -- NO_MAIL beats WRONG_MAIL: ItemIsMail is asked before the bytes are. + local plain = newSave(2) + check("a mon with no mail is NO_MAIL", + Mail.checkPokeMail(plain, 1, EXPECT), Mail.POKEMAIL_NO_MAIL) + + local wrong = newSave(2) + wrong.party[1].item = "FLOWER_MAIL" + Mail.compose(wrong, 1, "SOMETHING ELSE", wrong.party[1], "FLOWER_MAIL") + check("the wrong letter is WRONG_MAIL", + Mail.checkPokeMail(wrong, 1, EXPECT), Mail.POKEMAIL_WRONG_MAIL) + + -- LAST_MON is checked AFTER the message compares equal, so the right mon + -- with the right letter still loses you the reward if it is all you have. + local last = newSave(1) + last.party[1].item = "FLOWER_MAIL" + Mail.give(last, "FLOWER_MAIL", EXPECT) + check("the last conscious mon is LAST_MON", + Mail.checkPokeMail(last, 1, EXPECT), Mail.POKEMAIL_LAST_MON) + check("and it is still in the party", #last.party, 1) + + local ok = newSave(2) + Mail.give(ok, "FLOWER_MAIL", EXPECT) + check("the right letter is CORRECT", + Mail.checkPokeMail(ok, 2, EXPECT), Mail.POKEMAIL_CORRECT) + check("and the mon is gone", #ok.party, 1) + + -- A stored message LONGER than the expected one still matches: the compare + -- runs until the expected string's own '@'. + local longer = newSave(2) + Mail.give(longer, "FLOWER_MAIL", EXPECT .. "!") + check("a longer stored message still matches", + Mail.checkPokeMail(longer, 2, EXPECT), Mail.POKEMAIL_CORRECT) + + -- No expected message resolved (a cache built before the extractor followed + -- the operand): the answer that changes nothing. + local unresolved = newSave(2) + Mail.give(unresolved, "FLOWER_MAIL", EXPECT) + check("an unresolved expectation is WRONG_MAIL", + Mail.checkPokeMail(unresolved, 2, nil), Mail.POKEMAIL_WRONG_MAIL) + check("and keeps the mon", #unresolved.party, 2) + + -- CORRECT removes the mon, so the mail shift has to ride along with it. + local shift = newSave(3) + Mail.set(shift, 3, Mail.entry("SURF_MAIL", "BEHIND")) + shift.party[1].item = "FLOWER_MAIL" + Mail.compose(shift, 1, EXPECT, shift.party[1], "FLOWER_MAIL") + check("handing over slot 1 is CORRECT", + Mail.checkPokeMail(shift, 1, EXPECT), Mail.POKEMAIL_CORRECT) + check("and the letter behind it moved up", + Mail.get(shift, 2).message, "BEHIND") +end + +-- ------------------------------------------------------------- refusals + +-- BillsPC_CheckMon's .HasMail arm: DEPOSIT refuses, after the last-healthy +-- rule and before anything moves. +do + local save = newSave(3) + save.party[1].item = "PORTRAITMAIL" + local ok, reason = Boxes.canDeposit(save, 1, 1) + check("a mail holder cannot be deposited", ok, false) + check("with PCString_RemoveMail", reason, "Remove MAIL.") + check("deposit really refuses", (Boxes.deposit(save, 1, 1)), false) + check("and the party is untouched", #save.party, 3) + + -- Depositing anything else still shifts the letters behind it. + local shift = newSave(3) + Mail.set(shift, 3, Mail.entry("SURF_MAIL", "THIRD")) + check("an ordinary deposit works", (Boxes.deposit(shift, 2, 1)), true) + check("and the letter moved up with its mon", + Mail.get(shift, 2).message, "THIRD") +end + +-- SelectTradeOrDayCareMon's ItemIsMail arm, which used to guess from the id's +-- spelling and so let LITEBLUEMAIL straight past. +do + local save = newSave(3) + save.party[1].item = "LITEBLUEMAIL" + check("the Day-Care sees the mail", + Breeding.holdsMail(nil, save.party[1]), true) + local ok, reason = Breeding.canDeposit({}, save, "man", 1) + check("and refuses the deposit", ok, false) + check("with REFUSE_MAIL", reason, Breeding.REFUSE_MAIL) +end + +-- ------------------------------------------------------------ save format + +-- MAIL is the 3 -> 4 step. The file has moved past 4 since (format 5 is the +-- world state), so what this suite ratchets is that the mail step is still +-- there and that a format-3 save still comes all the way up; the current +-- number itself is gen2_save_test's business. +check("mail is the 3 -> 4 step", type(Save.MIGRATIONS[3]), "function") +check("and the format has not gone backwards", Save.FORMAT >= 4, true) +for from = 1, Save.FORMAT - 1 do + check("a migration exists for format " .. from, + type(Save.MIGRATIONS[from]), "function") +end +do + local old = Save.normalize(Save.migrate({ format = 3, party = {} })) + check("a format-3 save reaches the current format", old.format, Save.FORMAT) + check("and gains the party half", type(old.mail.party), "table") + check("and the MAILBOX half", type(old.mail.box), "table") + check("both empty", next(old.mail.party), nil) +end + +-- The quarantine pass. Every entry below is a region the cart could not have +-- written, so it is dropped and reported rather than handed to a screen. +do + local dirty = Save.newGame({}) + dirty.mail = { + party = { + [1] = Mail.entry("FLOWER_MAIL", "FINE"), + [7] = Mail.entry("SURF_MAIL", "OUT OF RANGE"), + [2] = Mail.entry("POTION", "NOT MAIL"), + [3] = { type = "EON_MAIL", message = ("Z"):rep(40) }, + [4] = "not a struct", + }, + box = { + Mail.entry("MUSIC_MAIL", "KEPT"), + { type = "MASTER_BALL", message = "NOT MAIL" }, + }, + } + local report = Save.validate(dirty) + check("the good letter survives", Mail.get(dirty, 1).message, "FINE") + check("the out-of-range slot is gone", Mail.get(dirty, 7), nil) + check("the non-mail stationery is gone", Mail.get(dirty, 2), nil) + check("the non-struct is gone", Mail.get(dirty, 4), nil) + check("the over-long message is trimmed", + #Mail.get(dirty, 3).message, Mail.MAIL_MSG_LENGTH) + check("the good MAILBOX entry survives", Mail.mailbox(dirty)[1].message, + "KEPT") + check("the bad one is gone", Mail.mailbox(dirty)[2], nil) + -- four drops: the slot, the item, the struct, and the MAILBOX row -- plus + -- the trim, which is reported without losing the letter. + check("five entries reported", #report.lostMail, 5) + check("and the report is not empty", Save.emptyReport(report), false) +end +check("a fresh save reports nothing", + Save.emptyReport(Save.validate(Save.newGame({}))), true) +-- A `mail` field that is not even a table is replaced wholesale. +do + local wrecked = Save.newGame({}) + wrecked.mail = 7 + local report = Save.validate(wrecked) + check("a non-table store is quarantined", #report.lostMail, 1) + check("and replaced", type(wrecked.mail), "table") +end + +-- -------------------------------------------------------------- screen ids + +-- Every mail screen is reached through src/ui/Screens.lua, so a mod can +-- replace one. +Screens.invalidate() +for _, pair in ipairs({ + { "Gen2MailCompose", MailCompose }, + { "Gen2MailRead", MailRead }, + { "Gen2MailMenu", MailMenu }, + { "Gen2MailboxMenu", MailboxMenu }, + { "Gen2HeldItemMenu", HeldItemMenu }, +}) do + check(pair[1] .. " resolves to its builtin", + Screens.get({ data = {} }, pair[1]), pair[2]) +end + +-- ----------------------------------------------------------- the keyboard + +-- data/text/mail_input_chars.asm: ten columns, six rows, and the fifth row of +-- each case is the one that is NOT a plain letter grid. +do + local save = newSave(1) + local game, input = newGame(save) + local composed = nil + local screen = MailCompose.new(game, { + onDone = function(text) composed = text end, + }) + check("the upper grid has five letter rows", #MailCompose.MAIL_INPUT_UPPER, 5) + check("ten columns", #MailCompose.MAIL_INPUT_UPPER[1], 10) + check("row 1 starts at A", MailCompose.MAIL_INPUT_UPPER[1][1], "A") + check("and ends at J", MailCompose.MAIL_INPUT_UPPER[1][10], "J") + check("row 4 is the digits", MailCompose.MAIL_INPUT_UPPER[4][10], "0") + check("row 5 opens on <PK>", MailCompose.MAIL_INPUT_UPPER[5][1], "<PK>") + check("and the lower row 4 on the apostrophe pairs", + MailCompose.MAIL_INPUT_LOWER[4][1], "'d") + + -- The cursor starts on A. + check("the cursor starts on A", screen:cursorCharacter(), "A") + input:press("a") + screen:update() + check("A types it", screen.text, "A") + + -- .right wraps at column 9, not 8: the mail grid is one wider than the + -- naming screen's. + screen.col, screen.row = 9, 0 + screen:moveHorizontal(1) + check("right wraps from column 9", screen.col, 0) + screen:moveHorizontal(-1) + check("and left wraps back to 9", screen.col, 9) + + -- SELECT is the case switch anywhere. + input:press("select") + screen:update() + check("select switches case", screen.lower, true) + screen.col, screen.row = 0, 0 + check("the lower grid is under it", screen:cursorCharacter(), "a") + + -- START parks the cursor on END; A there finishes. + input:press("start") + screen:update() + check("start lands on the strip", screen.row, 5) + check("over END", screen:cursorCharacter(), "END") + input:press("a") + screen:update() + check("END hands the message back", composed, "A") + + -- The three fat targets split at 0-2 / 3-5 / 6-9. + screen.row, screen.col = 5, 0 + check("column 0 is the case switch", screen:cursorCharacter(), "CASE") + screen.col = 3 + check("column 3 is DEL", screen:cursorCharacter(), "DEL") + screen.col = 6 + check("column 6 is END", screen:cursorCharacter(), "END") + -- and right from END wraps to the case switch rather than stepping. + screen:moveHorizontal(1) + check("right from END wraps", screen:cursorCharacter(), "CASE") + + -- B deletes rather than cancelling: there is no way out but END. + screen.text = "AB" + input:press("b") + screen:update() + check("B deletes a character", screen.text, "A") + + -- The buffer is 32 characters and the last one does NOT end entry the way a + -- nickname's does. + screen.text = ("Q"):rep(Mail.MAIL_MSG_LENGTH) + screen.row, screen.col = 0, 0 + composed = nil + input:press("a") + screen:update() + check("a full message takes nothing more", + #screen.text, Mail.MAIL_MSG_LENGTH) + check("and does not finish on its own", composed, nil) +end + +-- ------------------------------------------------------------ MonMailAction + +-- The MAIL row only appears when ItemIsMail says so. +do + local save = newSave(1) + local game = newGame(save) + local list = PartyMenu.new(game, { save = save }) + local rows = list:submenuItems(save.party[1]) + local labels = {} + for _, row in ipairs(rows) do labels[row.id] = true end + check("a mon with nothing shows ITEM", labels.ITEM, true) + check("and no MAIL row", labels.MAIL, nil) + + save.party[1].item = "PORTRAITMAIL" + local mailRows = list:submenuItems(save.party[1]) + local mailLabels = {} + for _, row in ipairs(mailRows) do mailLabels[row.id] = true end + check("a mail holder shows MAIL", mailLabels.MAIL, true) + check("and no ITEM row", mailLabels.ITEM, nil) +end + +-- TAKE, both endings. +do + local save = newSave(2) + save.party[1].item = "FLOWER_MAIL" + Mail.compose(save, 1, "KEEPSAKE", save.party[1], "FLOWER_MAIL") + local game, input = newGame(save) + local closed = false + local menu = MailMenu.new(game, { + save = save, slot = 1, onClose = function() closed = true end, + }) + check("the menu is READ / TAKE / QUIT", #MailMenu.ENTRIES, 3) + -- Down to TAKE, then A: the first question is the PC one. + input:press("down") + menu:update() + check("the cursor is on TAKE", MailMenu.ENTRIES[menu.index].id, "take") + input:press("a") + menu:update() + check("TAKE asks about the PC first", menu.confirm ~= nil, true) + -- YES: SendMailToPC. + input:press("a") + menu:update() + check("the letter went to the PC", Mail.mailboxCount(save), 1) + check("and off the mon", save.party[1].item, nil) + check("with a line to read", menu.message ~= nil, true) + input:press("a") + menu:update() + check("which closes the menu", closed, true) +end + +do + -- Saying no to the PC drops into "the MAIL will lose its message". + local save = newSave(2) + save.party[1].item = "SURF_MAIL" + Mail.compose(save, 1, "BYE", save.party[1], "SURF_MAIL") + local game, input = newGame(save) + local menu = MailMenu.new(game, { save = save, slot = 1, onClose = function() end }) + menu.index = 2 + input:press("a") + menu:update() + -- NO to the PC question. + input:press("down") + menu:update() + input:press("a") + menu:update() + check("saying no asks the destructive question", menu.confirm ~= nil, true) + -- YES to losing the message. + input:press("a") + menu:update() + check("the stationery is in the bag", save.inventory.SURF_MAIL, 1) + check("the mon is empty handed", save.party[1].item, nil) + check("and the letter is gone", Mail.get(save, 1), nil) +end + +do + -- A full bag is _MailNoSpaceText, and the letter survives it. + local save = newSave(2) + save.party[1].item = "EON_MAIL" + Mail.compose(save, 1, "STAYS", save.party[1], "EON_MAIL") + for i = 1, 20 do save.inventory["FILLER_" .. i] = 1 end + local game, input = newGame(save) + local menu = MailMenu.new(game, { save = save, slot = 1, onClose = function() end }) + menu.index = 2 + input:press("a") + menu:update() + input:press("down") + menu:update() + input:press("a") + menu:update() + input:press("a") + menu:update() + check("a full PACK keeps the letter", Mail.get(save, 1).message, "STAYS") + check("and the mon keeps holding it", save.party[1].item, "EON_MAIL") +end + +-- ------------------------------------------------------------- MailboxPC + +-- InitMail's z branch: an empty MAILBOX never opens a menu. +do + local save = newSave(1) + local game, input = newGame(save) + local closed = false + local box = MailboxMenu.new(game, { + save = save, onClose = function() closed = true end, + }) + check("an empty MAILBOX opens on a line", box.message ~= nil, true) + input:press("a") + box:update() + check("and closes", closed, true) +end + +do + local save = newSave(2) + Mail.mailbox(save)[1] = Mail.entry("FLOWER_MAIL", "ONE", "AMY", 1, "ODDISH") + Mail.mailbox(save)[2] = Mail.entry("SURF_MAIL", "TWO", "BEN", 2, "LAPRAS") + local game, input = newGame(save) + local box = MailboxMenu.new(game, { save = save, onClose = function() end }) + check("the list opens on the first letter", box.index, 1) + check("with four submenu rows", #MailboxMenu.SUB_ENTRIES, 4) + input:press("a") + box:update() + check("A opens the submenu", box.submenu ~= nil, true) + check("on READ MAIL", MailboxMenu.SUB_ENTRIES[box.submenu.index].id, "read") + + -- PUT IN PACK: the question, then the stationery. + box.submenu.index = 2 + input:press("a") + box:update() + check("PUT IN PACK asks first", box.confirm ~= nil, true) + input:press("a") + box:update() + check("the stationery is in the bag", save.inventory.FLOWER_MAIL, 1) + check("and the letter is gone", Mail.mailboxCount(save), 1) + check("with the next one first", Mail.mailbox(save)[1].message, "TWO") +end + +do + -- ATTACH MAIL refuses a mon that is already holding something, and comes + -- straight back to the party list rather than backing out. + local save = newSave(2) + save.party[1].item = "POTION" + Mail.mailbox(save)[1] = Mail.entry("LOVELY_MAIL", "XOXO", "AMY", 1) + local game, input = newGame(save) + local box = MailboxMenu.new(game, { save = save, onClose = function() end }) + box.submenu = { index = 3 } + input:press("a") + box:update() + local list = game.stack:top() + check("the party list is up", list ~= nil, true) + list.onChoose(1, save.party[1]) + check("a mon holding an item is refused", box.message ~= nil, true) + check("and the letter is still in the MAILBOX", Mail.mailboxCount(save), 1) + + -- An empty-handed mon takes it. + input:press("a") + box:update() + local retry = game.stack:top() + retry.onChoose(2, save.party[2]) + check("an empty-handed mon takes it", save.party[2].item, "LOVELY_MAIL") + check("with the letter", Mail.get(save, 2).message, "XOXO") + check("out of the MAILBOX", Mail.mailboxCount(save), 0) +end + +-- ------------------------------------------------------------ the PC menu + +-- MAIL BOX is on both .WhichPC lists, so it is on the storage menu this port +-- folds them into whichever PC opened it. +do + local ids = {} + for _, entry in ipairs(PcMenu.ENTRIES) do ids[entry.id] = true end + check("the PC has a MAIL BOX row", ids.mailbox, true) +end + +-- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail before it opens anything. +do + local save = newSave(2) + save.party[2].item = "MORPH_MAIL" + local game, input = newGame(save) + local pc = PcMenu.new(game, { save = save, onClose = function() end }) + for i, entry in ipairs(pc.entries) do + if entry.id == "move" then pc.index = i end + end + pc:choose() + check("MOVE refuses while a letter is in the party", pc.message ~= nil, true) + check("without opening a list", #game.stack._items, 0) + -- The refusal is two pages and neither of them logs off. + input:press("a") + pc:update() + check("the second page follows", pc.message ~= nil, true) + input:press("a") + pc:update() + check("and then the menu is back", pc.message, nil) + + -- With the letter gone the row opens the list as usual. + save.party[2].item = nil + pc:choose() + check("MOVE opens the list once the mail is off", + #game.stack._items, 1) +end + +-- ------------------------------------------------- GiveTakePartyMonItem +-- +-- The ITEM row is what makes MAIL reachable at all: GivePartyItem -> +-- ComposeMailMessage is the compose keyboard's only door on the cart. + +local ITEMS = { + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", pocket = "ITEM" }, + POTION = { id = "POTION", name = "POTION", pocket = "ITEM" }, + BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM" }, + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM" }, +} + +do + -- Giving MAIL to an empty-handed mon opens the keyboard, and END writes the + -- struct on that mon's party slot. + local save = newSave(2) + save.inventory.FLOWER_MAIL = 1 + local game, input = newGame(save) + game.data.items = ITEMS + local closed = false + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, + onClose = function() closed = true end, + }) + check("the menu is GIVE / TAKE", #HeldItemMenu.ENTRIES, 2) + input:press("a") + menu:update() + local pack = game.stack:top() + check("GIVE opens the PACK", pack ~= nil, true) + check("as a chooser", pack.give, true) + pack.onChoose("FLOWER_MAIL") + check("the mon is holding it", save.party[1].item, "FLOWER_MAIL") + check("and it left the bag", save.inventory.FLOWER_MAIL, nil) + check("with a line to read", menu.message ~= nil, true) + input:press("a") + menu:update() + local compose = game.stack:top() + check("the compose keyboard is up", compose ~= nil, true) + compose.onDone("SEE YOU SOON") + check("the letter is on the slot", Mail.get(save, 1).message, "SEE YOU SOON") + check("with the player as author", Mail.get(save, 1).author, "GOLD") + check("and the menu closed", closed, true) +end + +do + -- A mon already holding MAIL is refused BEFORE the swap question, so a + -- letter cannot be knocked off by accident. + local save = newSave(1) + save.party[1].item = "FLOWER_MAIL" + Mail.compose(save, 1, "SAFE", save.party[1], "FLOWER_MAIL") + save.inventory.POTION = 1 + local game = newGame(save) + game.data.items = ITEMS + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, onClose = function() end, + }) + menu:giveItem("POTION") + check("a mail holder refuses a new item", menu.message ~= nil, true) + check("and keeps the letter", Mail.get(save, 1).message, "SAFE") + check("and the stationery", save.party[1].item, "FLOWER_MAIL") + check("with the POTION still in the bag", save.inventory.POTION, 1) +end + +do + -- .next: a KEY_ITEM cannot be held, and the PACK comes back rather than the + -- menu closing. + local save = newSave(1) + save.inventory.BICYCLE = 1 + local game, input = newGame(save) + game.data.items = ITEMS + local closed = false + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, + onClose = function() closed = true end, + }) + menu:giveItem("BICYCLE") + check("a key item cannot be held", menu.message ~= nil, true) + check("and the mon holds nothing", save.party[1].item, nil) + input:press("a") + menu:update() + check("the PACK reopens", game.stack:top() ~= nil, true) + check("rather than the menu closing", closed, false) +end + +do + -- TAKE puts the item back in the bag. + local save = newSave(1) + save.party[1].item = "BERRY" + local game, input = newGame(save) + game.data.items = ITEMS + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, onClose = function() end, + }) + input:press("down") + menu:update() + check("the cursor is on TAKE", HeldItemMenu.ENTRIES[menu.index].id, "take") + input:press("a") + menu:update() + check("the item is back in the bag", save.inventory.BERRY, 1) + check("and off the mon", save.party[1].item, nil) + + -- An empty-handed mon is _PokemonNotHoldingText and nothing moves. + local bare = newSave(1) + local bareGame = newGame(bare) + bareGame.data.items = ITEMS + local bareMenu = HeldItemMenu.new(bareGame, { + save = bare, slot = 1, items = ITEMS, onClose = function() end, + }) + bareMenu:takeItem() + check("an empty-handed mon says so", bareMenu.message ~= nil, true) + check("and gains nothing", next(bare.inventory), nil) +end + +do + -- The swap question, and the bag-full arm that puts the old item straight + -- back rather than destroying either one. + local save = newSave(1) + save.party[1].item = "BERRY" + -- Two POTIONs so the slot survives GiveItemToPokemon's own remove; with one + -- the freed slot is exactly the room the BERRY needs and the swap succeeds, + -- which is the cart's behaviour too. + save.inventory.POTION = 2 + for i = 1, 19 do save.inventory["FILLER_" .. i] = 1 end + local game, input = newGame(save) + game.data.items = ITEMS + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, onClose = function() end, + }) + menu:giveItem("POTION") + check("a held item asks about the swap", menu.confirm ~= nil, true) + input:press("a") + menu:update() + input:press("a") + menu:update() + check("a full bag keeps the old item on the mon", save.party[1].item, "BERRY") + check("and puts the new one back", save.inventory.POTION, 2) + check("with the storage-full line up", menu.message ~= nil, true) +end + +do + -- ...and with room, the swap goes through both ways. + local save = newSave(1) + save.party[1].item = "BERRY" + save.inventory.POTION = 1 + local game, input = newGame(save) + game.data.items = ITEMS + local menu = HeldItemMenu.new(game, { + save = save, slot = 1, items = ITEMS, onClose = function() end, + }) + menu:giveItem("POTION") + -- _PokemonAskSwapItemText has a `para` in it, so the box turns a page before + -- the YES/NO comes up. + input:press("a") + menu:update() + input:press("a") + menu:update() + check("the mon holds the new item", save.party[1].item, "POTION") + check("and the old one is in the bag", save.inventory.BERRY, 1) + check("with the new one gone from it", save.inventory.POTION, nil) +end + +-- -------------------------------------------------------------- MailRead + +-- MailGFX_PlaceMessage's three author columns, picked by the stationery. +do + local game = newGame(newSave(1)) + local read = MailRead.new(game, { + entry = Mail.entry("PORTRAITMAIL", "HI", "AMY", 1), + }) + check("PORTRAITMAIL puts the author at 8", read:authorColumn(), 8) + read.entry = Mail.entry("MORPH_MAIL", "HI", "AMY", 1) + check("MORPH_MAIL at 6", read:authorColumn(), 6) + read.entry = Mail.entry("FLOWER_MAIL", "HI", "AMY", 1) + check("everything else at 5", read:authorColumn(), 5) +end + +print(("gen2 mail: %d checks, %d failures"):format(checks, failures)) +if failures > 0 then os.exit(1) end diff --git a/tests/gen2_map_callbacks_test.lua b/tests/gen2_map_callbacks_test.lua new file mode 100644 index 00000000..382d59d2 --- /dev/null +++ b/tests/gen2_map_callbacks_test.lua @@ -0,0 +1,488 @@ +-- Map callbacks: RunMapCallback (home/map.asm) and the four places a map load +-- runs one. `luajit tests/gen2_map_callbacks_test.lua`; also dofile'd by +-- tests/run_tests.lua. Fixture-driven, with a final section that runs every +-- callback in a real Gold cache and SKIPs when there is none. +-- +-- Three separate things are under test and they fail in different ways: +-- +-- Vm:runCallback a NESTED script run. RunMapCallback does not check +-- wScriptRunning, so this must work with a script parked -- +-- which is the normal case, because every warp a script +-- takes is a map load with that script still on the stack. +-- the ORDER HandleNewMap, then LoadBlockData, then LoadMapObjects. +-- A TILES callback that runs after the canvas is baked +-- paints nothing; an OBJECTS callback that runs after the +-- people are built shows yesterday's NPC. +-- the CACHE FLUSH a baked canvas is keyed by map and daytime and knows +-- nothing about the blocks it was baked from, so a callback +-- that rewrites blocks on every load needs the stale bakes +-- dropped or it freezes on its first answer. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 map callbacks") +local check, eq = S.check, S.eq + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local World = require("src.world.gen2.World") + +-- --------------------------------------------------------------------------- +-- Vm:runCallback -- ExecuteCallbackScript +-- --------------------------------------------------------------------------- + +local function vmWith(scripts, hooks) + return Vm.new(scripts, {}, Events.new(), hooks or {}) +end + +-- The plain case: a body that ends on `endcallback` runs to completion and the +-- VM is idle again afterwards. +do + local flags = {} + local vm = vmWith({ + cb = { { op = "setevent", event = 7 }, { op = "endcallback" } }, + }, { onFlagsChanged = function() flags[#flags + 1] = true end }) + check(vm:runCallback("cb"), "a callback body runs and reports it ran") + check(vm.events:get(7), "and its setevent landed") + check(not vm:running(), "with the VM idle again on the far side") + check(not vm:runCallback(nil), "no script key is not a run") + check(not vm:runCallback("nope"), "and neither is a key with no body") +end + +-- Script_endcallback is ExitScriptSubroutine then StopScript: it ends the +-- CALLBACK, not the map load. A body with commands behind it stops there. +do + local vm = vmWith({ + cb = { + { op = "setevent", event = 1 }, + { op = "endcallback" }, + { op = "setevent", event = 2 }, + }, + }) + vm:runCallback("cb") + check(vm.events:get(1), "commands before endcallback run") + check(not vm.events:get(2), "and nothing behind it does") +end + +-- The case that made this a separate entry point from Vm:start. A script is +-- parked on a text box; the map load underneath it runs a callback; the parked +-- script must still be parked, on the SAME request, and must resume where it +-- was. Vm:start would have refused outright (`if self.busy then return +-- false`), which is what "nothing runs them yet" looked like from the inside. +do + local resumeText + local vm = vmWith({ + parent = { + { op = "writetext", text = "A" }, + { op = "setevent", event = 20 }, + { op = "end" }, + }, + cb = { { op = "setevent", event = 21 }, { op = "endcallback" } }, + }, { + showText = function(_, onDone) resumeText = onDone end, + }) + vm:start("parent") + check(vm:running(), "the parent script is up") + check(vm.pending and vm.pending.kind == "text", "and parked on its text box") + local parked = vm.pending + check(vm:runCallback("cb"), "a callback runs with a script already parked") + check(vm.events:get(21), "the callback's own command landed") + check(not vm.events:get(20), + "and the parent did NOT run on past the box it is waiting on") + check(vm:running(), "the parent is still up") + eq(vm.pending, parked, "parked on the very same request") + resumeText() + check(vm.events:get(20), "and resuming it picks up where it left off") + check(not vm:running(), "then ends normally") +end + +-- A callback cannot block: ScriptEvents runs inside the map load, with no frame +-- to come back on. Nothing reachable from an extracted callback yields, so +-- this is a guard rather than a behaviour -- but if one ever does, the parent's +-- parked frame must survive it untouched. +do + local vm = vmWith({ + parent = { { op = "writetext", text = "A" }, { op = "end" } }, + bad = { { op = "writetext", text = "B" }, { op = "endcallback" } }, + }, { showText = function() end }) + vm:start("parent") + local parked = vm.pending + check(not vm:runCallback("bad"), "a blocking callback reports failure") + eq(vm.pending, parked, "and leaves the parent's request alone") + check(vm:running(), "with the parent still running") + eq(vm.blockedCallbacks["bad"], "text", "the ledger names what it blocked on") +end + +-- --------------------------------------------------------------------------- +-- World:mapCallbackScript / World:runMapCallback +-- --------------------------------------------------------------------------- + +local function callbackWorld(callbacks, scripts) + local world = World.new({ data = {}, save = { party = {}, inventory = {} } }) + world.maps = { + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, + callbacks = callbacks }, + } + world.map = { id = "TEST_MAP", def = world.maps.TEST_MAP, + width = 2, height = 2 } + world.map.blocks = world.maps.TEST_MAP.blocks + world.scripts = scripts or {} + -- Only the hooks these checks reach; World:load builds the real table. + world.vm = Vm.new(world.scripts, {}, world.events, { + changeBlock = function(bx, by, block) + return world:changeBlock(bx, by, block) + end, + appear = function(id) world:appearObject(id) end, + disappear = function(id) world:disappearObject(id) end, + }) + return world +end + +do + local world = callbackWorld({ + { callback = "MAPCALLBACK_TILES", scriptKey = "first" }, + { callback = "MAPCALLBACK_TILES", scriptKey = "second" }, + { callback = "MAPCALLBACK_NEWMAP", scriptKey = "new" }, + }, { + first = { { op = "setevent", event = 1 }, { op = "endcallback" } }, + second = { { op = "setevent", event = 2 }, { op = "endcallback" } }, + new = { { op = "setevent", event = 3 }, { op = "endcallback" } }, + }) + eq(world:mapCallbackScript("MAPCALLBACK_TILES"), "first", + ".FindCallback takes the FIRST row of a type") + eq(world:mapCallbackScript("MAPCALLBACK_NEWMAP"), "new", + "and matches on the type, not on the order") + check(world:mapCallbackScript("MAPCALLBACK_SPRITES") == nil, + "a type this map has no row for is nil, not the next row along") + check(world:runMapCallback("MAPCALLBACK_TILES"), "and it runs") + check(world.events:get(1) and not world.events:get(2), + "only the first row of the type") + check(not world:runMapCallback("MAPCALLBACK_OBJECTS"), + "a map with no callback of that type is a no-op, not an error") +end + +-- A map with no callback list at all -- 284 of Gold's 368 maps -- must not +-- cost anything or throw. +do + local world = callbackWorld(nil, {}) + check(world:mapCallbackScript("MAPCALLBACK_NEWMAP") == nil, + "a map with no callbacks answers nil") + check(not world:runMapCallback("MAPCALLBACK_NEWMAP"), "and runs nothing") +end + +-- --------------------------------------------------------------------------- +-- The order inside a map load +-- --------------------------------------------------------------------------- + +-- MapSetupScript_Warp reads HandleNewMap, LoadBlockData, LoadMapObjects, in +-- that order, and each of the three carries one callback. setMap is a single +-- call here, so the order has to be asserted out loud. +do + local world = callbackWorld({ + { callback = "MAPCALLBACK_OBJECTS", scriptKey = "objects" }, + { callback = "MAPCALLBACK_TILES", scriptKey = "tiles" }, + { callback = "MAPCALLBACK_NEWMAP", scriptKey = "newmap" }, + }, { + newmap = { { op = "setevent", event = 1 }, { op = "endcallback" } }, + tiles = { { op = "changeblock", args = { 0, 0, 9 } }, + { op = "endcallback" } }, + objects = { { op = "setevent", event = 2 }, { op = "endcallback" } }, + }) + local order = {} + local newMapCell + world.tilesets = { TEST = {} } + world.maps.TEST_MAP.tileset = "TEST" + -- Stub out everything setMap does with love: the ORDER is the subject, and a + -- headless suite has no canvas to bake into. + local Map = require("src.world.gen2.Map") + world.imageFor = function() order[#order + 1] = "bake" return true end + world.rebuildNeighbors = function() end + world.rebuildPeople = function() order[#order + 1] = "people" end + world.applyPalettes = function() end + world.noteFlypoint = function() end + local realRun = World.runMapCallback + world.runMapCallback = function(self, kind) + order[#order + 1] = kind + if kind == "MAPCALLBACK_NEWMAP" then + newMapCell = self.player and (self.player.cellX .. "," .. self.player.cellY) + end + return realRun(self, kind) + end + world.map = Map.new(world.maps.TEST_MAP, {}) + check(world:setMap("TEST_MAP", 3, 3, "down"), "the map loads") + eq(table.concat(order, " "), + "MAPCALLBACK_NEWMAP MAPCALLBACK_TILES bake MAPCALLBACK_OBJECTS people", + "HandleNewMap, then LoadBlockData, then LoadMapObjects") + check(world.events:get(1) and world.events:get(2), + "and both bodies really ran") + eq(world.maps.TEST_MAP.blocks[1], 9, + "the TILES callback's changeblock is in the buffer BEFORE the bake") + -- data/maps/setup_scripts.asm:99-106: GetWarpDestCoords before HandleNewMap. + eq(newMapCell, "3,3", + "the NEWMAP callback reads the DESTINATION coords, not the map it left") +end + +-- --------------------------------------------------------------------------- +-- Route16AlwaysOnBikeCallback +-- --------------------------------------------------------------------------- + +-- maps/Route16.asm:7-17. The callback is two coordinate tests and nothing +-- else: YCOORD < 5 or XCOORD > 13 takes .CanWalk, everything else `setflag +-- ENGINE_ALWAYS_ON_BIKE`. It carries no `checkitem` -- the BICYCLE refusal is +-- the gatehouse's own coord event (maps/Route16Gate.asm:16-29, :69-70) -- so +-- both arms below run on a save with an empty PACK. +do + local Bike = require("src.world.gen2.Bike") + local FieldMoves = require("src.world.gen2.FieldMoves") + local Map = require("src.world.gen2.Map") + + local function route16At(cx, cy) + local world = callbackWorld({ + { callback = "MAPCALLBACK_NEWMAP", scriptKey = "bike" }, + }, { + -- The extracted body, opcode for opcode: VAR_YCOORD $13, VAR_XCOORD $12. + bike = { + { op = "readvar", var = 0x13 }, + { op = "ifless", value = 5, script = "walk" }, + { op = "readvar", var = 0x12 }, + { op = "ifgreater", value = 13, script = "walk" }, + { op = "setflag", flag = Bike.ENGINE_ALWAYS_ON_BIKE }, + { op = "endcallback" }, + }, + walk = { + { op = "clearflag", flag = Bike.ENGINE_ALWAYS_ON_BIKE }, + { op = "endcallback" }, + }, + }) + world.maps.TEST_MAP.environment = "ROUTE" + world.maps.TEST_MAP.width, world.maps.TEST_MAP.height = 10, 10 + world.tilesets = { TEST = {} } + world.maps.TEST_MAP.tileset = "TEST" + world.imageFor = function() return true end + world.rebuildNeighbors = function() end + world.rebuildPeople = function() end + world.applyPalettes = function() end + world.vm = Vm.new(world.scripts, {}, world.events, { + readVar = function(id) return world:readVar(id) end, + setEngineFlag = function(flag, value) world:setEngineFlag(flag, value) end, + }) + world.map = Map.new(world.maps.TEST_MAP, {}) + check(world:setMap("TEST_MAP", cx, cy, "down"), "the map loads") + return world + end + + -- warp_event 9, 6 (maps/Route16.asm:36): the Cycling Road side of the gate. + local road = route16At(9, 6) + check(road:alwaysOnBike(), + "arriving west of the gate sets ENGINE_ALWAYS_ON_BIKE") + -- .CheckForcedBiking (engine/overworld/map_setup.asm:112-120) reads the flag + -- and nothing else: no BICYCLE is consulted, on the cart or here. + eq(road.playerState, FieldMoves.PLAYER_BIKE, "and the load mounts the bike") + + -- warp_event 14, 6 (maps/Route16.asm:34): the gatehouse doorway, one cell + -- past the XCOORD test. Read off the map being LEFT this is the road arm, + -- which is the whole reason the coords are written before HandleNewMap. + local gate = route16At(14, 6) + check(not gate:alwaysOnBike(), "arriving at the gate doorway takes .CanWalk") + eq(gate.playerState, FieldMoves.PLAYER_NORMAL, "and nothing mounts") + + -- The YCOORD arm, north of the fork: `ifless 5, .CanWalk` on its own. + local north = route16At(9, 4) + check(not north:alwaysOnBike(), "and so does anything north of YCOORD 5") +end + +-- --------------------------------------------------------------------------- +-- The baked canvas has to follow the blocks +-- --------------------------------------------------------------------------- + +do + local world = callbackWorld(nil, {}) + world.mapImages = { + ["TEST_MAP|DAY|gbc|1"] = "a", ["TEST_MAP|NITE|gbc|1"] = "b", + ["OTHER_MAP|DAY|gbc|1"] = "c", + } + world:dropMapImages("TEST_MAP") + check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, "both bakes of the map") + check(world.mapImages["TEST_MAP|NITE|gbc|1"] == nil, "go, daytime and all") + eq(world.mapImages["OTHER_MAP|DAY|gbc|1"], "c", + "and the neighbours' keep theirs -- only the edited map's are stale") + + -- restoreBlocks is LoadMapAttributes' refill. Putting the blocks back + -- without dropping the bake left a CUT tree cut for the session, and would + -- have frozen every MAPCALLBACK_TILES map on whichever answer it baked first. + world.blockEdits = { TEST_MAP = { [1] = 5 } } + world.maps.TEST_MAP.blocks[1] = 99 + world.mapImages["TEST_MAP|DAY|gbc|1"] = "stale" + check(world:restoreBlocks(), "restoreBlocks reports it restored something") + eq(world.maps.TEST_MAP.blocks[1], 5, "the original block is back") + check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, + "and the bake taken off the edited blocks went with it") +end + +-- --------------------------------------------------------------------------- +-- readvar VAR_WEEKDAY +-- --------------------------------------------------------------------------- + +-- GetWeekday reads wCurDay, SUNDAY 0 .. SATURDAY 6 -- the same numbering +-- os.date("%w") uses. 39 of the 40 readvar sites reachable from a callback are +-- this one, and every one of them was taking the SUNDAY arm. +do + local world = callbackWorld(nil, {}) + world.clockDay = 2 + eq(world:weekday(), 2, "clockDay pins the day") + world.clockDay = 9 + eq(world:weekday(), 2, "and wraps into the week") + world.clockDay = nil + local today = tonumber(os.date("%w")) or 0 + eq(world:weekday(), today, "unpinned, it is the host clock's day") + check(world:weekday() >= 0 and world:weekday() <= 6, "in SUNDAY..SATURDAY") +end + +-- --------------------------------------------------------------------------- +-- Every callback in a real cache +-- --------------------------------------------------------------------------- + +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 mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : fixture checks only (SKIP cache facts)") + S.finish() + return +end +mf:close() + +local maps = assert(loadfile(mapsPath))() +local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))() +local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))() + +local byType, rows = {}, {} +for id, def in pairs(maps) do + for _, cb in ipairs(def.callbacks or {}) do + byType[cb.callback] = (byType[cb.callback] or 0) + 1 + rows[#rows + 1] = { map = id, kind = cb.callback, key = cb.scriptKey } + end +end +check(#rows > 0, "the cache carries map callbacks at all") +-- The census, so a re-import that loses a whole type fails here rather than by +-- a door quietly staying open. MAPCALLBACK_SPRITES has no user in Gold. +eq(byType.MAPCALLBACK_NEWMAP, 39, "39 MAPCALLBACK_NEWMAP") +eq(byType.MAPCALLBACK_OBJECTS, 24, "24 MAPCALLBACK_OBJECTS") +eq(byType.MAPCALLBACK_TILES, 19, "19 MAPCALLBACK_TILES") +eq(byType.MAPCALLBACK_CMDQUEUE, 2, "2 MAPCALLBACK_CMDQUEUE") +check(byType.MAPCALLBACK_SPRITES == nil, + "and no MAPCALLBACK_SPRITES, which no Gold map declares") + +local missing = 0 +for _, row in ipairs(rows) do + if not (row.key and scripts[row.key]) then missing = missing + 1 end +end +eq(missing, 0, "every callback row names a body the cache carries") + +-- Run all of them. A callback is a script, and the one thing that must hold +-- for all 84 is that each completes inside the map load: no yield, no unknown +-- opcode, no bad byte. The hooks are counters rather than a World, so this +-- checks the BODIES rather than the wiring -- the wiring is the order check +-- above and tests/drivers/gold_map_callbacks.lua. +do + local blocks, appears, disappears = 0, 0, 0 + local vm = Vm.new(scripts, {}, Events.new(), { + specialOrder = constants.specialOrder, + specials = {}, + -- VAR_WEEKDAY, so the day-of-week arms are exercised rather than all + -- taking SUNDAY the way an unanswered readvar makes them. + readVar = function(id) return id == 0x0b and 2 or 0 end, + changeBlock = function() blocks = blocks + 1 return true end, + appear = function() appears = appears + 1 end, + disappear = function() disappears = disappears + 1 end, + }) + local ran = 0 + for _, row in ipairs(rows) do + if vm:runCallback(row.key) then ran = ran + 1 end + end + eq(ran, #rows, "all " .. #rows .. " callback bodies run to completion") + local blocked = {} + for key in pairs(vm.blockedCallbacks) do blocked[#blocked + 1] = key end + eq(#blocked, 0, + "none of them blocks: " .. table.concat(blocked, ", ")) + local unknown = {} + for op in pairs(vm.unknownOps) do unknown[#unknown + 1] = op end + table.sort(unknown) + eq(#unknown, 0, + "and none reaches an unimplemented opcode: " .. table.concat(unknown, ", ")) + eq(#vm.badBytes, 0, "with no extractor `unknown` row inside one") + check(blocks > 0, "the run really repainted blocks (" .. blocks .. ")") + check(appears > 0 and disappears > 0, + ("and moved objects (%d appear, %d disappear)"):format(appears, disappears)) +end + +-- The named ones, so the census above cannot pass on the wrong rows. +do + local function kindOf(mapId, kind) + for _, cb in ipairs((maps[mapId] or {}).callbacks or {}) do + if cb.callback == kind then return scripts[cb.scriptKey] end + end + return nil + end + local function ops(body) + local out = {} + for _, c in ipairs(body or {}) do out[#out + 1] = c.op end + return table.concat(out, ",") + end + + -- NewBarkTownFlypointCallback: `setflag ENGINE_FLYPOINT_NEW_BARK` is the + -- cart's own way of banking a fly point. + check(ops(kindOf("NEW_BARK_TOWN", "MAPCALLBACK_NEWMAP")):find("setflag"), + "New Bark's NEWMAP callback is the flypoint flag") + -- Route31CheckMomCallCallback queues Mom's worried call, which is a whole + -- phone conversation nothing could reach while the callbacks were dead. + check(ops(kindOf("ROUTE_31", "MAPCALLBACK_NEWMAP")) + :find("checkevent") ~= nil, "Route 31's NEWMAP callback checks the egg quest") + -- BrunosRoomDoorsCallback: the entrance sealing behind you and the exit + -- opening after the battle. Both `iffalse`s jump FORWARD over their own + -- changeblock into the next test, so both sit in the body itself rather than + -- behind a branch -- which is why the whole callback is five commands. + local bruno = kindOf("BRUNOS_ROOM", "MAPCALLBACK_TILES") + local brunoBlocks = 0 + for _, c in ipairs(bruno or {}) do + if c.op == "changeblock" then brunoBlocks = brunoBlocks + 1 end + end + eq(brunoBlocks, 2, "Bruno's room walls itself in with two changeblocks") + check(ops(bruno):find("checkevent"), + "and it opens on EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED") + -- GoldenrodUndergroundCheckDayOfWeekCallback: seven arms off VAR_WEEKDAY. + check(ops(kindOf("GOLDENROD_UNDERGROUND", "MAPCALLBACK_OBJECTS")) + :find("readvar"), "the Goldenrod underground reads the weekday") + + -- Route16AlwaysOnBikeCallback (maps/Route16.asm:7-17), read off the cache: + -- VAR_YCOORD $13 then VAR_XCOORD $12, and no `checkitem` anywhere in it. + local r16 = kindOf("ROUTE_16", "MAPCALLBACK_NEWMAP") + eq(ops(r16), "readvar,ifless,readvar,ifgreater,setflag,endcallback", + "Route 16's NEWMAP callback is the coordinate test") + local vars = {} + for _, c in ipairs(r16 or {}) do + if c.op == "readvar" then vars[#vars + 1] = c.var end + end + eq(table.concat(vars, ","), "19,18", "VAR_YCOORD first, then VAR_XCOORD") + -- Route17AlwaysOnBikeCallback (maps/Route17.asm:13-16): the sibling, with no + -- coordinate test at all -- ALWAYS_ON_BIKE and DOWNHILL, unconditionally. + eq(ops(kindOf("ROUTE_17", "MAPCALLBACK_NEWMAP")), + "setflag,setflag,endcallback", "Route 17 forces the bike and the downhill") + + -- maps/Route16Gate.asm:69-70: the BICYCLE gate is the gatehouse's two coord + -- events, which is what keeps a bikeless player off the road the callback + -- above mounts unconditionally. + local gateEvents = (maps.ROUTE_16_GATE or {}).coordEvents or {} + eq(#gateEvents, 2, "the Route 16 gatehouse carries its two coord events") + local checks = 0 + for _, ev in ipairs(gateEvents) do + if ops(scripts[ev.scriptKey]):find("checkitem") then checks = checks + 1 end + end + eq(checks, 2, "and both are the checkitem BICYCLE refusal") +end + +S.finish() diff --git a/tests/gen2_map_radio_test.lua b/tests/gen2_map_radio_test.lua new file mode 100644 index 00000000..d9cc2402 --- /dev/null +++ b/tests/gen2_map_radio_test.lua @@ -0,0 +1,173 @@ +-- The wall radios: `special MapRadio` -> src/ui/gen2/MapRadio.lua, a port of +-- engine/pokegear/pokegear.asm PlayRadio. +-- +-- luajit tests/gen2_map_radio_test.lua +-- +-- PlayRadio's contract, row by row: resolve the MAPRADIO_* index through +-- PlayRadioStationPointers (index 0, the Pokemon Channel, resolves by region +-- and time of day), wait 100 frames with only the station name up, then run +-- one show frame per loop until A or B, and leave the channel song playing as +-- the map music on the way out (ExitPokegearRadio_HandleMusic / +-- RadioMusicRestartDE). Every interaction builds a fresh machine, which is +-- what makes the radio replayable: back out, talk again, it plays again. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 map radio") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local MapRadio = require("src.ui.gen2.MapRadio") +local Music = require("src.core.Music") +local Save = require("src.core.gen2.Save") + +-- `call Random`, scripted: hand back the listed bytes in order, cycling. +local function rolls(...) + local list = { ... } + local index = 0 + return function() + index = index % #list + 1 + return list[index] + end +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, b in ipairs({ ... }) do self.pressed[b] = true end + end + function input:wasPressed(b) + if self.pressed[b] then self.pressed[b] = nil return true end + return false + end + function input:isDown() return false end + return input +end + +local LANDMARKS = { landmarks = { + LANDMARK_NEW_BARK_TOWN = { index = 2 }, + LANDMARK_VERMILION_CITY = { index = 50 }, +} } + +local function newGame(save) + return { + input = newInput(), + save = save, + options = Save.defaultOptions(), + data = { audio = {}, pokemon = {}, items = {}, + gen2Landmarks = LANDMARKS }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +-- Enough of the show tables for LETS_ALL_SING (the Ben/Fern chatter needs +-- only a weekday) -- the full fixtures live in gen2_menus_test.lua, which +-- owns the line-by-line show assertions. +local RADIO_DATA = { + landmarks = {}, mapLandmark = {}, grass = {}, species = {}, + caught = function() return false end, + dex = {}, classes = {}, hidden = {}, + weekday = 2, luckyNumber = 42, inJohto = true, +} + +-- ---- the blocking loop ----------------------------------------------------- + +do + local save = Save.newGame() + local game = newGame(save) + game.world = { map = { def = { music = "Music_NewBarkTown" } } } + local done = false + local mr = MapRadio.new(game, { + channel = 7, radioData = RADIO_DATA, radioRng = rolls(0), + save = save, onDone = function() done = true end, + }) + game.stack:push(mr) + eq(mr.station, "LETS_ALL_SING", "MAPRADIO index 7 is Let's All Sing") + eq(mr.radioMusicPlaying, "enterMap", + ".PlayStation parks ENTER_MAP_MUSIC before the show starts") + + -- `ld c, 100 / call DelayFrames`: no lines and no buttons for 100 frames. + game.input:press("a") + for _ = 1, 100 do mr:update(0) end + eq(#mr.radio.log, 0, "nothing prints during the delay") + eq(game.stack:top(), mr, "and A during the delay does not close it") + game.input.pressed = {} + + -- The loop's first frame: StartPokemonMusicChannel reads the weekday's low + -- bit and RadioMusicRestartDE lands the song (an even weekday marches). + mr:update(0) + eq(mr.radio.music, "Music_PokemonMarch", + "the sing show starts the weekday's song") + eq(mr.radioMusicPlaying, "Music_PokemonMarch", + "and RadioMusicRestartDE parks it for the exit handler") + + -- Left running, the DJ chatter scrolls through the box. + for _ = 1, 700 do mr:update(0) end + check(#mr.radio.log >= 1, "the show prints once the delay is spent") + + -- A stops the loop: pop, resume, and the song stays as the map music. + Music.setMapSong("Music_NewBarkTown") + game.input:press("a") + mr:update(0) + eq(game.stack:top(), nil, "A closes the radio") + eq(done, true, "and resumes the parked script") + eq(Music.mapSong(), "Music_PokemonMarch", + "with the playing song left as the map music (wMapMusic)") + + -- Replay: the next interaction builds a fresh machine and plays again. + local again = MapRadio.new(game, { + channel = 7, radioData = RADIO_DATA, radioRng = rolls(0), save = save, + }) + game.stack:push(again) + for _ = 1, 101 do again:update(0) end + eq(again.radio.music, "Music_PokemonMarch", "a second listen plays again") + for _ = 1, 400 do again:update(0) end + check(#again.radio.log >= 1, "and prints again") + game.input:press("b") + again:update(0) + eq(game.stack:top(), nil, "B closes it too") +end + +-- ---- LoadStation_PokemonChannel -------------------------------------------- + +-- Index 0 is not a station: it resolves by region, and in Johto by time of +-- day -- the morning airs the Pokedex Show, the rest of the day Oak. +do + local save = Save.newGame() + local function stationFor(landmark, daytime) + local game = newGame(save) + game.world = { daytime = daytime, + map = { def = { music = "Music_NewBarkTown" } } } + local mr = MapRadio.new(game, { + channel = 0, radioData = RADIO_DATA, radioRng = rolls(0), + save = save, currentLandmark = landmark, + }) + return mr.station + end + eq(stationFor("LANDMARK_NEW_BARK_TOWN", "MORN"), "POKEDEX_SHOW", + "Johto mornings air the Pokedex Show") + eq(stationFor("LANDMARK_NEW_BARK_TOWN", "DAY"), "OAKS_POKEMON_TALK", + "and the day belongs to Oak") + eq(stationFor("LANDMARK_VERMILION_CITY", "DAY"), "PLACES_AND_PEOPLE", + "from Kanto the same dial carries Places & People") +end + +-- ---- the fixed stations ---------------------------------------------------- + +do + local save = Save.newGame() + local game = newGame(save) + local mr = MapRadio.new(game, { + channel = 4, radioData = RADIO_DATA, radioRng = rolls(0), save = save, + }) + eq(mr.station, "LUCKY_CHANNEL", + "Radio2Script's MAPRADIO_LUCKY_CHANNEL is index 4") +end + +S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua new file mode 100644 index 00000000..229aab7e --- /dev/null +++ b/tests/gen2_menus_test.lua @@ -0,0 +1,2367 @@ +-- Gen 2 menu behaviour: the naming screen's keyboard, the intro menu's entries, +-- the OPTION rows, the start menu's unlock rules, and the PACK's pockets. +-- +-- ROM-free and draw-free: every screen's logic is separable from its drawing, so +-- this drives them with a stub input and asserts state. What a test cannot say +-- -- whether the layout looks right -- is what tests/drivers/gold_menu_shots.lua +-- exists for. + +package.path = "./?.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction and logic; nothing here draws. +local drawn = {} +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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here (these are logic assertions, not rendering ones), so +-- Font.encode would warn once per unknown glyph. Quiet it: the noise would +-- bury a real failure. +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local PackMenu = require("src.ui.gen2.PackMenu") +local Save = require("src.core.gen2.Save") +local StartMenu = require("src.ui.gen2.StartMenu") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- A stub input: queue presses, then each read consumes them. +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + local input = newInput() + return { + input = input, + save = save, + options = save and save.options or Save.defaultOptions(), + data = { audio = {}, pokemon = {}, items = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + }, input +end + +-- ------------------------------------------------------------ Chrome.wrap + +-- Nothing may print past the 160px frame, so long text has to wrap. Without a +-- loaded font Font.width falls back to a fixed advance, which is enough to +-- assert that wrapping happens at all and never loses a word. +local wrapped = Chrome.wrap("What will CYNDAQUIL do?", 8) +check("wrap splits", #wrapped > 1, true) +local rejoined = table.concat(wrapped, " ") +check("wrap loses no words", rejoined, "What will CYNDAQUIL do?") +check("empty wraps to nothing", #Chrome.wrap("", 8), 0) +check("wrap keeps a single word whole", #Chrome.wrap("CYNDAQUIL", 2), 1) + +-- Chrome.number pads the way PrintNum does. +check("number pads with spaces", Chrome.number(5, 3), " 5") +check("number pads with zeroes", Chrome.number(5, 3, true), "005") +check("number does not truncate", Chrome.number(1234, 2), "1234") + +-- ------------------------------------------------------- Chrome.List + +local chosen, cancelled +local list = Chrome.List.new({ + items = { "ONE", "TWO", "THREE" }, + onChoose = function(value) chosen = value end, + onCancel = function() cancelled = true end, +}) +local input = newInput() +check("list starts at 1", list.index, 1) +input:press("down") +list:update(input) +check("down moves", list.index, 2) +input:press("up") +list:update(input) +check("up moves back", list.index, 1) +input:press("up") +list:update(input) +check("up wraps to the end", list.index, 3) +input:press("down") +list:update(input) +check("down wraps to the start", list.index, 1) +input:press("a") +list:update(input) +check("a chooses", chosen, "ONE") +input:press("b") +list:update(input) +check("b cancels", cancelled, true) + +-- A no-wrap list stops at the ends. +local bounded = Chrome.List.new({ items = { "A", "B" }, wrap = false }) +input:press("up") +bounded:update(input) +check("no-wrap stays at the top", bounded.index, 1) + +-- A list longer than its window scrolls. +local scrolling = Chrome.List.new({ items = { "A", "B", "C", "D", "E" }, + rows = 3 }) +for _ = 1, 3 do + input:press("down") + scrolling:update(input) +end +check("scrolled", scrolling.index, 4) +check("window followed", scrolling.scroll, 1) + +-- --------------------------------------------------------- intro menu + +-- MainMenu_GetWhichMenu: no save file means NEW GAME / OPTION only. +local fresh = MainMenu.new(newGame(nil), { hasSave = false, save = false }) +-- Plus the port's own EXIT GAME row, which the cart has no equivalent for. +check("no save has three entries", #fresh.list.items, 3) +check("first entry is NEW GAME", fresh.list.items[1].value, "new") +check("second is OPTION", fresh.list.items[2].value, "option") +check("last is EXIT GAME", fresh.list.items[3].value, "exit") + +local withSave = MainMenu.new(newGame(Save.newGame()), + { hasSave = true, save = Save.newGame() }) +check("a save adds CONTINUE", #withSave.list.items, 4) +check("CONTINUE is first", withSave.list.items[1].value, "continue") +-- MenuHeader's default option is the first entry, so a save lands on CONTINUE. +check("default lands on CONTINUE", withSave.list.index, 1) + +-- CONTINUE shows the save panel first and only then hands off; B backs out. +local continued +local confirmGame, confirmInput = newGame(Save.newGame()) +local confirm = MainMenu.new(confirmGame, { + hasSave = true, save = Save.newGame(), + onContinue = function() continued = true end, +}) +confirmInput:press("a") +confirm:update(0) +check("A opens the save panel", confirm.phase, "confirm") +check("no hand-off yet", continued, nil) +confirm.confirmDelay = 0 +confirmInput:press("b") +confirm:update(0) +check("B returns to the menu", confirm.phase, "menu") +confirmInput:press("a") +confirm:update(0) +confirm.confirmDelay = 0 +confirmInput:press("a") +confirm:update(0) +check("A confirms", continued, true) + +-- The clock box formats 12-hour time with an AM/PM half. +check("midnight is 12 AM", (function() + local m = MainMenu.new(newGame(nil), { hasSave = false, save = false, + clock = { hour = 0, minute = 0, weekday = 1 } }) + local hour = select(1, m:clockParts()) + local display = hour % 12 + if display == 0 then display = 12 end + return display .. (hour < 12 and " AM" or " PM") +end)(), "12 AM") +check("weekday names", MainMenu.DAYS[6], "FRIDAY") + +-- ------------------------------------------------------- naming screen + +local namingGame, namingInput = newGame(Save.newGame()) +local naming = NamingScreen.new(namingGame, { type = "player" }) +check("player field is 7 long", naming.maxLength, 7) +check("starts uppercase", naming.lower, false) +check("cursor starts at A", naming:cursorCharacter(), "A") + +-- The grid is nine wide; right wraps. +namingInput:press("right") +naming:update(0) +check("moved right", naming:cursorCharacter(), "B") +naming.col = 8 +namingInput:press("right") +naming:update(0) +check("right wraps to the start", naming.col, 0) +namingInput:press("left") +naming:update(0) +check("left wraps to the end", naming.col, 8) + +-- Typing appends; B deletes rather than cancelling. +naming.col, naming.row = 0, 0 +namingInput:press("a") +naming:update(0) +check("typed A", naming.text, "A") +namingInput:press("right") +naming:update(0) +namingInput:press("a") +naming:update(0) +check("typed AB", naming.text, "AB") +namingInput:press("b") +naming:update(0) +check("B deleted", naming.text, "A") +namingInput:press("b") +naming:update(0) +namingInput:press("b") +naming:update(0) +check("B on an empty field is safe", naming.text, "") + +-- SELECT toggles case; the same cell then types lowercase. +namingInput:press("select") +naming:update(0) +check("now lowercase", naming.lower, true) +naming.col, naming.row = 0, 0 +namingInput:press("a") +naming:update(0) +check("typed a", naming.text, "a") +check("lower label says UPPER", naming:cursorCharacter(), "a") + +-- The bottom row's three fat targets: case / DEL / END. +naming.lower = false +naming.row = naming:bottomRow() +naming.col = 0 +check("target 1 is the case switch", naming:bottomTarget(), 1) +check("cursor reads CASE", naming:cursorCharacter(), "CASE") +naming.col = 3 +check("target 2 is DEL", naming:bottomTarget(), 2) +naming.col = 6 +check("target 3 is END", naming:bottomTarget(), 3) +-- Left/right hop between targets rather than stepping a column at a time. +namingInput:press("left") +naming:update(0) +check("left hops a whole target", naming:bottomTarget(), 2) +namingInput:press("right") +naming:update(0) +check("right hops back", naming:bottomTarget(), 3) + +-- START parks the cursor on END, wherever it was. +naming.row, naming.col = 0, 0 +namingInput:press("start") +naming:update(0) +check("START jumps to END row", naming.row, naming:bottomRow()) +check("START jumps to END target", naming:bottomTarget(), 3) + +-- END hands the typed name back. +local finished +local endGame, endInput = newGame(Save.newGame()) +local ending = NamingScreen.new(endGame, { + type = "player", + onDone = function(name) finished = name end, +}) +ending.text = "GOLD" +ending.row = ending:bottomRow() +ending.col = 6 +endInput:press("a") +ending:update(0) +check("END returned the name", finished, "GOLD") + +-- Filling the last slot does NOT end entry: `.a` is `ret nc` and +-- AdvanceCursor_CheckEndOfString answers carry once the buffer is full, so the +-- handler falls through into `.start` and parks the cursor on END with the +-- screen still up (engine/menus/naming_screen.asm:401-410). Only `.end` calls +-- StoreEntry. This case used to assert the auto-accept the port did instead. +local autoDone +local autoGame, autoInput = newGame(Save.newGame()) +local auto = NamingScreen.new(autoGame, { + type = "player", onDone = function(name) autoDone = name end, +}) +auto.text = "ABCDEF" -- one short of 7 +auto.row, auto.col = 0, 0 +autoInput:press("a") +auto:update(0) +check("the seventh character is typed", auto.text, "ABCDEFA") +check("but entry is not over", autoDone, nil) +check("the cursor parks on the END row", auto.row, auto:bottomRow()) +check("on END itself", auto:cursorCharacter(), "END") +-- A further letter press is the `cp c / ret nc` no-op, and A on END is what +-- finally hands the name back. +auto.row, auto.col = 0, 0 +autoInput:press("a") +auto:update(0) +check("a full buffer takes no more letters", auto.text, "ABCDEFA") +auto.row, auto.col = auto:bottomRow(), 6 +autoInput:press("a") +auto:update(0) +check("A on END stores the entry", autoDone, "ABCDEFA") + +-- The blank cells are real spaces, not dead keys: the NameInput* rows are +-- written into the tilemap and GetLastCharacter reads the tile under the cursor +-- back out, so the trailing blanks of "S T U V W X Y Z " type a space +-- (data/text/name_input_chars.asm, engine/menus/naming_screen.asm +-- GetLastCharacter). +local spaceGame, spaceInput = newGame(Save.newGame()) +local spacer = NamingScreen.new(spaceGame, { type = "player" }) +spacer.text = "AB" +spacer.row, spacer.col = 2, 8 -- the blank after Z +check("the cell after Z is a space", spacer:cursorCharacter(), " ") +spaceInput:press("a") +spacer:update(0) +check("and pressing A types it", spacer.text, "AB ") + +-- A box name is longer and gets an extra keyboard row. +local box = NamingScreen.new(newGame(Save.newGame()), { type = "box" }) +check("box field is 8 long", box.maxLength, 8) +check("box has six rows", box:bottomRow(), 5) +check("box keyboard starts higher", box:keyboardTop(), 6) +check("name keyboard start", naming:keyboardTop(), 8) + +-- ------------------------------------------------------------- options + +local optionsGame, optionsInput = newGame(Save.newGame()) +local options = OptionsMenu.new(optionsGame, { + options = Save.defaultOptions(), +}) +-- The cart's seven value rows, then the port's audio, speed and display +-- rows, then CANCEL -- which is what makes this screen scroll. +check("sixteen rows", #OptionsMenu.ROWS, 16) +check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") +check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol") +check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) +check("starts on TEXT SPEED", options:row().key, "textSpeed") +check("default text speed", options.options.textSpeed, "MID") +optionsInput:press("right") +options:update(0) +check("right cycles forward", options.options.textSpeed, "SLOW") +optionsInput:press("right") +options:update(0) +check("right wraps", options.options.textSpeed, "FAST") +optionsInput:press("left") +options:update(0) +check("left wraps back", options.options.textSpeed, "SLOW") + +optionsInput:press("down") +options:update(0) +check("down moves a row", options:row().key, "battleScene") +optionsInput:press("right") +options:update(0) +check("battle scene toggles off", options.options.battleScene, false) + +-- FRAME is 1-8 and wraps. +options.index = 7 +check("frame row", options:row().frame, true) +options.options.frame = 8 +optionsInput:press("right") +options:update(0) +check("frame wraps to 1", options.options.frame, 1) +optionsInput:press("left") +options:update(0) +check("frame wraps to 8", options.options.frame, 8) +-- options_menu.asm:475 UpdateFrame calls LoadFontsExtra, so the value is live. +check("frame reaches the font", require("src.render.Font").frameIndex(), 8) + +-- CANCEL and START both leave, handing the edited table back. +local savedOptions +local exitGame, exitInput = newGame(Save.newGame()) +local exiting = OptionsMenu.new(exitGame, { + options = Save.defaultOptions(), + onDone = function(o) savedOptions = o end, +}) +exiting.index = #OptionsMenu.ROWS +exitInput:press("a") +exiting:update(0) +check("CANCEL leaves", savedOptions ~= nil, true) + +-- --------------------------------------------------------- start menu + +local save = Save.newGame() +local bare = StartMenu.new(newGame(save), { save = save }) +-- With no party, no dex and no Pokegear, only PACK and the always-on entries. +local function labels(menu) + local out = {} + for _, item in ipairs(menu.items) do out[#out + 1] = item.value end + return table.concat(out, ",") +end +check("bare start menu", labels(bare), "pack,status,save,option,quit") + +save.party = { { species = "CYNDAQUIL" } } +save.pokedexReceived = true +save.inventory = { POKEGEAR = 1 } +local full = StartMenu.new(newGame(save), { save = save }) +check("unlocked start menu", labels(full), + "pokedex,pokemon,pack,pokegear,status,save,option,quit") +-- The player's own name is the STATUS entry's label. +save.player.name = "SILVER" +local named = StartMenu.new(newGame(save), { save = save }) +for _, item in ipairs(named.items) do + if item.value == "status" then + check("status label is the player name", item.label, "SILVER") + end +end + +-- MENU ACCOUNT off hides the description box. +save.options = Save.defaultOptions() +save.options.menuAccount = false +local quiet = StartMenu.new(newGame(save), { save = save }) +check("description hidden", quiet.showDescription, false) +save.options.menuAccount = true +check("description shown", + StartMenu.new(newGame(save), { save = save }).showDescription, true) + +-- ---------------------------------------------------------------- pack + +local packSave = Save.newGame() +packSave.inventory = { + POTION = 3, POKE_BALL = 5, BICYCLE = 1, TM_HEADBUTT = 1, +} +local packGame, packInput = newGame(packSave) +packGame.data.items = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }, + POKE_BALL = { id = "POKE_BALL", name = "POKé BALL", pocket = "BALL", + index = 2 }, + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", + index = 3 }, + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 4, teaches = "HEADBUTT", tmNumber = 2 }, +} +local pack = PackMenu.new(packGame, { pocket = "ITEM" }) +check("four pockets", #PackMenu.POCKETS, 4) +check("items pocket has the potion", pack.rows[1].id, "POTION") +check("items pocket has only it", #pack.rows, 1) +check("potion shows a count", pack.rows[1].showCount, true) + +packInput:press("right") +pack:update(0) +check("right switches pocket", pack:pocket().id, "BALL") +check("balls pocket", pack.rows[1].id, "POKE_BALL") +packInput:press("right") +pack:update(0) +check("next is key items", pack:pocket().id, "KEY_ITEM") +check("key items show no count", pack.rows[1].showCount, false) +packInput:press("right") +pack:update(0) +check("then TM/HM", pack:pocket().id, "TM_HM") +check("TM row names its move", pack.rows[1].teaches, "HEADBUTT") +packInput:press("right") +pack:update(0) +check("pocket wraps around", pack:pocket().id, "ITEM") + +-- CANCEL sits one past the last row. +check("cancel is past the end", pack:total(), #pack.rows + 1) +pack.index = pack:total() +check("on cancel", pack:isCancel(), true) + +-- ---- the item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243) +-- +-- A on a row picks the row; it does not use it. Without this menu there is no +-- TOSS anywhere in the PACK, which is the bug this block pins. +do + local items = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 1, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY" }, + -- ITEMMENU_NOUSE + tossable: MenuHeader_HoldableItem, no USE row at all. + BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM", index = 2, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_NOUSE" }, + -- CANT_TOSS + selectable: MenuHeader_UnusableKeyItem. + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", + index = 3, canToss = false, canSelect = true, + fieldMenu = "ITEMMENU_CLOSE" }, + -- CANT_TOSS + not selectable: MenuHeader_UnusableItem. + SECRETPOTION = { id = "SECRETPOTION", name = "SECRETPOTION", + pocket = "KEY_ITEM", index = 4, canToss = false, canSelect = false, + fieldMenu = "ITEMMENU_NOUSE" }, + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 5, + canToss = false, canSelect = false, teaches = "CUT" }, + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 6, canToss = true, canSelect = false, teaches = "HEADBUTT" }, + } + local save = Save.newGame() + save.inventory = { POTION = 5, BERRY = 1, BICYCLE = 1, SECRETPOTION = 1, + HM_CUT = 1, TM_HEADBUTT = 1 } + local game, input = newGame(save) + game.data.items = items + game.data.moves = { CUT = { name = "CUT" }, HEADBUTT = { name = "HEADBUTT" } } + -- A world with a useFieldItem is what makes this a field PACK rather than a + -- chooser: the mart's SELL and the item PC's DEPOSIT pass `world = {}`. + game.world = { useFieldItem = function() return nil end } + local menu = PackMenu.new(game, { pocket = "ITEM" }) + + local function rows(id) return table.concat(menu:submenuRows(id), ",") end + check("a usable tossable item", rows("POTION"), "use,give,toss,quit") + check("an unusable tossable item", rows("BERRY"), "give,toss,quit") + check("a registerable key item", rows("BICYCLE"), "use,sel,quit") + check("a plain key item", rows("SECRETPOTION"), "use,quit") + menu.pocketIndex = 4 + check("an HM", rows("HM_CUT"), "use,quit") + check("a TM", rows("TM_HEADBUTT"), "use,give,quit") + menu.pocketIndex = 1 + menu:rebuild() + + menu.index = 1 + check("the row under the cursor", menu.rows[1].id, "POTION") + input:press("a") + menu:update(0) + check("A opens the submenu", menu.submenu ~= nil, true) + check("on USE", menu.submenu.rows[menu.submenu.index], "use") + check("and uses nothing yet", save.inventory.POTION, 5) + + -- TOSS: "Throw away how many?", the count, the yes/no, then TossItem. + input:press("down") + menu:update(0) + input:press("down") + menu:update(0) + check("down twice reaches TOSS", menu.submenu.rows[menu.submenu.index], + "toss") + input:press("a") + menu:update(0) + check("TOSS asks how many", menu.message and menu.message[1], + "Throw away how") + check("starting at one", menu.qtyState and menu.qtyState.qty, 1) + input:press("up") + menu:update(0) + check("up steps the count", menu.qtyState.qty, 2) + input:press("a") + menu:update(0) + check("A asks to confirm", menu.confirm and menu.confirm.prompt[1], + "Throw away 2") + check("naming the item", menu.confirm.prompt[2], "POTION(S)?") + check("and still nothing tossed", save.inventory.POTION, 5) + input:press("down") + menu:update(0) + input:press("a") + menu:update(0) + check("NO keeps the item", save.inventory.POTION, 5) + + -- ...and YES spends it. + input:press("a") + menu:update(0) + input:press("down") + menu:update(0) + input:press("down") + menu:update(0) + input:press("a") + menu:update(0) + input:press("a") + menu:update(0) + input:press("a") + menu:update(0) + check("YES tosses the count", save.inventory.POTION, 4) + check("with _ThrewAwayText", menu.message and menu.message[1], "Threw away") + + -- B out of the submenu is QuitItemSubmenu: nothing happens at all. + menu.message = nil + input:press("a") + menu:update(0) + input:press("b") + menu:update(0) + check("B closes the submenu", menu.submenu, nil) + check("spending nothing", save.inventory.POTION, 4) + + -- GIVE is the party list under PARTYMENUACTION_GIVE_ITEM, then the same + -- TryGiveItemToPartymon the party's own GIVE row runs. + save.party = { { species = "CYNDAQUIL", nickname = "CYNDA", hp = 20, + maxHp = 20, level = 5 } } + menu.index = 1 + input:press("a") + menu:update(0) + input:press("down") + menu:update(0) + check("down once reaches GIVE", menu.submenu.rows[menu.submenu.index], + "give") + input:press("a") + menu:update(0) + check("GIVE opens the party list", game.stack:top().prompt, + "To which <PK><MN>?") + input:press("a") + game.stack:top():update(0) + input:press("a") + game.stack:top():update(0) + check("the mon is holding it", save.party[1].item, "POTION") + check("and the bag is one lighter", save.inventory.POTION, 3) + while game.stack:top() do game.stack:pop() end + + -- A chooser PACK still answers its caller on the first press: DepositSellPack + -- and TutorialPack have no submenu on the cart. + local chosen = nil + local chooser = PackMenu.new(game, { pocket = "ITEM", give = true, + onChoose = function(id) chosen = id end }) + chooser.index = 1 + input:press("a") + chooser:update(0) + check("a chooser opens no submenu", chooser.submenu, nil) + check("and answers straight away", chosen, "POTION") +end + +-- ------------------------------------------------------------------- #DEX +-- +-- The dex's numbers are PrintNum fields, and getting them wrong is what put +-- "1.08" where the cart prints 1'08". The height word is four digits with +-- two in front of the point; the weight word is five with four in front; and +-- both blank their leading zeroes rather than printing them. +local PokedexMenu = require("src.ui.gen2.PokedexMenu") +local num = PokedexMenu.printNumString + +check("3-digit count is space padded", num(7, 3), " 7") +check("leading zeros when asked", num(7, 3, true), "007") +check("a full field is untouched", num(250, 3), "250") +check("Bulbasaur is 2'04\"", num(204, 4, false, 2), " 2.04") +check("Mewtwo is 6'07\"", num(607, 4, false, 2), " 6.07") +check("a ten-foot mon keeps both digits", num(1300, 4, false, 2), "13.00") +check("Bulbasaur weighs 15.0", num(150, 5, false, 4), " 15.0") +check("Mewtwo weighs 269.0", num(2690, 5, false, 4), " 269.0") +-- .PrintDigit forces the digit in front of the point, so a sub-pound mon +-- reads 0.1 rather than losing its zero. +check("a light mon keeps its tenth", num(1, 5, false, 4), " 0.1") +check("a short mon keeps its zero feet", num(3, 4, false, 2), " 0.03") + +-- The listing lives on the window layer, and OLD mode is the only one that +-- moves it (hWX $4a rather than $47) or prints dex numbers. +local dexSave = Save.newGame("GOLD") +dexSave.pokedex = { seen = {}, caught = {} } +local dexGame = newGame(dexSave) +dexGame.data.gen2Pokedex = { + entries = { + BULBASAUR = { dex = 1, kind = "SEED", height = 204, weight = 150, + text = "a<NEXT>b<NEXT>c", text2 = "d<NEXT>e" }, + IVYSAUR = { dex = 2, kind = "SEED", height = 303, weight = 290 }, + }, + newOrder = { "BULBASAUR", "IVYSAUR" }, + alphabeticalOrder = { "BULBASAUR", "IVYSAUR" }, +} +local dex = PokedexMenu.new(dexGame, {}) +check("dex lists every entry", #dex.rows, 2) +check("dex starts in NEW mode", dex:mode(), "NEW") +-- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the +-- SEARCH screen. Neither cycles anything in place -- the mode changes when +-- the OPTION screen's own cursor picks one and A confirms it. +check("SELECT opens the OPTION screen", (function() + dexGame.input:press("select") + dex:update(0) + return dex.view +end)(), "option") +check("...with the cursor on the current mode", dex.optionIndex, 1) +check("moving to OLD and confirming changes the mode", (function() + dexGame.input:press("down") + dex:update(0) + dexGame.input:press("a") + dex:update(0) + return dex:mode() +end)(), "OLD") +check("...and closes the OPTION screen", dex.view, "list") +check("START opens the SEARCH screen", (function() + dexGame.input:press("start") + dex:update(0) + return dex.view +end)(), "search") +-- Pokedex_InitSearchScreen: TYPE1 starts on NORMAL and TYPE2 on "-----". +check("TYPE1 starts on NORMAL", dex:searchTypeName(1), "NORMAL") +check("TYPE2 starts blank", dex:searchTypeName(2), "-----") +check("B leaves the SEARCH screen", (function() + dexGame.input:press("b") + dex:update(0) + return dex.view +end)(), "list") +-- Back to NEW so the assertions below are unaffected. +dex.modeIndex = 1 +dex:rebuild() + +-- A only opens the entry for a mon that has been seen (Pokedex_UpdateMainScreen +-- returns early otherwise), and once open, left/right flips the two pages. +dexGame.input:press("a") +dex:update(0) +check("an unseen mon has no entry to open", dex.view, "list") +dexSave.pokedex.seen.BULBASAUR = true +dex:rebuild() +dex.index = 1 +dexGame.input:press("a") +dex:update(0) +check("a seen mon opens", dex.view, "entry") +check("on page 1", dex.page, 1) + +-- DexEntryScreen_ArrowCursorData: LEFT/RIGHT walk an arrow across PAGE, AREA, +-- CRY and PRNT, and A runs whichever it is parked on. This block used to press +-- RIGHT and expect the page to flip, which was the port taking a shortcut -- +-- and that shortcut is exactly why AREA could never be opened. +check("the cursor starts on PAGE", dex.entryAction, 1) +dexGame.input:press("right") +dex:update(0) +check("RIGHT walks the cursor, it does not flip the page", dex.page, 1) +check("the cursor is on AREA", dex.entryAction, 2) +dexGame.input:press("a") +dex:update(0) +check("A on AREA opens the nest map", dex.view, "area") +dexGame.input:press("b") +dex:update(0) +check("B backs out of AREA to the entry", dex.view, "entry") +dexGame.input:press("left") +dex:update(0) +check("LEFT walks it back to PAGE", dex.entryAction, 1) +dexGame.input:press("a") +dex:update(0) +check("A on PAGE flips to 2", dex.page, 2) +dexGame.input:press("b") +dex:update(0) +check("B backs out to the listing", dex.view, "list") + +-- ------------------------------------------------------------- QUIT / EXIT + +-- The start menu's last row is the port's QUIT, and it asks before throwing +-- away everything since the last save. NO is the default. +local quitSave = Save.newGame("GOLD") +local quitGame, quitInput = newGame(quitSave) +local returned = false +quitGame.returnToTitle = function() returned = true end +local quitMenu = StartMenu.new(quitGame, { save = quitSave }) +quitMenu.list.index = #quitMenu.items +check("last row is QUIT", quitMenu.items[#quitMenu.items].value, "quit") +quitInput:press("a") +quitMenu:update(0) +check("QUIT asks first", quitMenu.phase, "confirm") +check("and defaults to NO", quitMenu.confirmChoice, 2) +check("nothing has happened yet", returned, false) +quitInput:press("a") +quitMenu:update(0) +check("NO backs out", quitMenu.phase, nil) +check("still nothing", returned, false) + +quitInput:press("a") +quitMenu:update(0) +quitInput:press("up") +quitMenu:update(0) +check("up selects YES", quitMenu.confirmChoice, 1) +quitInput:press("a") +quitMenu:update(0) +check("YES returns to the title", returned, true) + +-- B out of the confirmation is NO as well. +local backGame, backInput = newGame(quitSave) +backGame.returnToTitle = function() returned = "again" end +local backMenu = StartMenu.new(backGame, { save = quitSave }) +backMenu.phase = "confirm" +backMenu.confirmChoice = 1 +backInput:press("b") +backMenu:update(0) +check("B is NO", backMenu.phase, nil) +check("B ran nothing", returned, true) + +-- EXIT GAME on the intro menu leaves through the host rather than the cart. +local exited = false +local exitMenuGame, exitMenuInput = newGame(nil) +local exitMenu = MainMenu.new(exitMenuGame, { + hasSave = false, save = false, + onExit = function() exited = true end, +}) +exitMenu.list.index = #exitMenu.list.items +exitMenuInput:press("a") +exitMenu:update(0) +check("EXIT GAME quits", exited, true) + +-- ----------------------------------------------------- scrolling OPTION + +-- Twelve rows do not fit on an 18-row screen, so the screen scrolls: the +-- window only moves once the cursor would leave it. +local scrollGame, scrollInput = newGame(Save.newGame()) +local scrollOptions = OptionsMenu.new(scrollGame, { + options = Save.defaultOptions(), +}) +check("starts unscrolled", scrollOptions.scroll, 0) +for _ = 1, 7 do + scrollInput:press("down") + scrollOptions:update(0) +end +check("cursor moved", scrollOptions.index, 8) +check("window followed by one", scrollOptions.scroll, 1) +scrollOptions.index = 1 +scrollOptions:ensureVisible() +check("back to the top", scrollOptions.scroll, 0) + +-- The port rows step their own shared module rather than a values list. +local function rowNamed(label) + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == label then return i, row end + end +end +local speedRow = select(2, rowNamed("GAME SPEED")) +scrollOptions:cycle(speedRow, 1) +check("speed left NORMAL", scrollOptions.options.speed ~= 1, true) +local volRow = select(2, rowNamed("MUSIC VOL")) +scrollOptions:cycle(volRow, -1) +check("music volume stepped down", scrollOptions.options.musicVol, 6) +for _ = 1, 10 do scrollOptions:cycle(volRow, -1) end +check("and clamps at OFF rather than wrapping", scrollOptions.options.musicVol, 0) +local filterRow = select(2, rowNamed("MUSIC FILTER")) +scrollOptions:cycle(filterRow, 1) +check("filter steps to 1X", scrollOptions.options.musicFilter, 1) +for _ = 1, 3 do scrollOptions:cycle(filterRow, 1) end +check("and wraps back to OFF", scrollOptions.options.musicFilter, 0) + +-- COLOR: the Gen 2 answer to the Gen 1 screen's COLORS row. GBC is the +-- default because Gold IS a colour game -- the other rungs turn it off. +local GbcPalette = require("src.render.GbcPalette") +local colorRow = select(2, rowNamed("COLOR")) +check("COLOR is a row", colorRow ~= nil, true) +check("and it defaults to the cart's own colour", + Save.DEFAULT_OPTIONS.color, "gbc") +scrollOptions.options.color = "gbc" +scrollOptions:cycle(colorRow, 1) +check("right steps to DMG", scrollOptions.options.color, "dmg") +scrollOptions:cycle(colorRow, 1) +check("then CLASSIC", scrollOptions.options.color, "classic") +scrollOptions:cycle(colorRow, 1) +check("and wraps back to GBC", scrollOptions.options.color, "gbc") +scrollOptions:cycle(colorRow, -1) +check("left walks the ladder the other way", scrollOptions.options.color, + "classic") +check("CLASSIC is the only mode with a present pass", + GbcPalette.presentColors() ~= nil, true) +GbcPalette.setMode("dmg") +check("DMG substitutes the hardware shades for any palette", + GbcPalette.color({ { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 }, { 10, 11, 12 } }, + 1)[1], 255) +GbcPalette.setMode("gbc") +check("GBC leaves a palette alone", + GbcPalette.color({ { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 }, { 10, 11, 12 } }, + 1)[1], 1) +check("and has no present pass", GbcPalette.presentColors(), nil) + +-- ------------------------------------------------------------- name picker +-- +-- NameMenuHeader's coordinates, which is the whole point of this screen: the +-- box, the label origin GetMenuTextStartCoord derives from its flags, and the +-- pic walk NamePlayer opens with. +local NamePick = require("src.ui.gen2.NamePick") +local pickInput = newInput() +local pickGame = { + input = pickInput, + data = {}, + stack = { push = function() end, pop = function() end, top = function() end }, +} +local picked +local pick = NamePick.new(pickGame, { + onDone = function(name) picked = name end, +}) +check("five items: NEW NAME and Gold's four presets", #pick.items, 5) +check("NEW NAME first", pick.items[1], "NEW NAME") +check("then the PlayerNameArray order", pick.items[2] .. pick.items[5], + "GOLDKARL") +check("default option 1 is NEW NAME", pick.cursor, 1) +check("the pic starts where Oak left it", pick.picX, 6) +-- MovePlayerPicRight is a blocking loop: no input until it lands on 13. +pickInput:press("down") +pick:update(0) +check("input is ignored while the pic walks", pick.cursor, 1) +-- The stub latches a press until something reads it, so drop it by hand -- +-- otherwise it fires the instant the walk ends and the rest of this reads a +-- cursor one row further on than it looks. +pickInput.pressed = {} +for _ = 1, 20 do pick:update(0) end +check("and it stops at hlcoord 13", pick.picX, 13) +check("the menu is up once the walk is done", pick.slide, nil) +pickInput:press("down") +pick:update(0) +check("now the cursor moves", pick.cursor, 2) +pickInput:press("a") +pick:update(0) +check("a preset walks the pic back before answering", pick.slide, "out") +check("and answers nothing yet", picked, nil) +for _ = 1, 20 do pick:update(0) end +check("the pic returns to hlcoord 6", pick.picX, 6) +check("and the name is handed over", picked, "GOLD") +-- STATICMENU_DISABLE_B: there is no way out of this menu but choosing. +local pick2 = NamePick.new(pickGame, { onDone = function() picked = "B" end }) +for _ = 1, 20 do pick2:update(0) end +pickInput:press("b") +pick2:update(0) +check("B does nothing", picked, "GOLD") + +-- ============================================================= POKeGEAR +-- +-- The radio's shows and the town map's cursor, both transcribed from +-- engine/pokegear/radio.asm and engine/pokegear/pokegear.asm. The shows are +-- state machines with their own random rolls, so everything below seeds the +-- roll source and asserts the exact line sequence that comes out; the cursor +-- is a landmark INDEX walked between two limit registers, so everything below +-- asserts which landmark a d-pad press lands on. + +local Pokegear = require("src.ui.gen2.Pokegear") + +-- `call Random` yields one byte. This hands back the listed bytes in order +-- and then starts the list again, so a test only has to name the rolls one +-- pass through a show actually spends. It cycles rather than repeating the +-- last byte because the shows sample by rejection: a source stuck on one +-- value that every sampler rejects would never terminate. +local function rolls(...) + local list = { ... } + local index = 0 + return function() + index = index % #list + 1 + return list[index] + end +end + +-- Oak's Pokemon Talk indexes the grass table by time of day (0 morn, 1 day, +-- 2 nite) and then by one of the middle three of the seven slots, so the +-- fixture makes the third slot of each block distinct: a wrong index shows up +-- as the wrong species rather than as no species. +local radioClasses = {} +for i = 1, 66 do radioClasses[i] = { name = "CLASS" .. i, trainer = "T" .. i } end + +local radioData = { + landmarks = { [2] = { name = "ROUTE 29" }, [46] = { name = "PALLET TOWN" } }, + mapLandmark = { ROUTE_29 = 2, PALLET_TOWN = 46 }, + grass = { ROUTE_29 = { + [0] = { "RATTATA", "SENTRET", "LEDYBA", "PIDGEY", "PIDGEY", "PIDGEY", + "PIDGEY" }, + [1] = { "RATTATA", "SENTRET", "SPINARAK", "PIDGEY", "PIDGEY", "PIDGEY", + "PIDGEY" }, + [2] = { "RATTATA", "SENTRET", "HOOTHOOT", "PIDGEY", "PIDGEY", "PIDGEY", + "PIDGEY" }, + } }, + species = { [16] = "PIDGEY" }, + caught = function(name) return name == "PIDGEY" end, + dex = { PIDGEY = { kind = "TINY BIRD", lines = { + "It usually hides", "in tall grass. Be-", "cause it dislikes", + "fighting, it pro-", "tects itself by", "kicking up sand." } } }, + classes = radioClasses, + -- CLASS2 stands in for PnP_HiddenPeople: the show must roll past it. + hidden = { [2] = true }, + weekday = 1, + luckyNumber = 42, +} + +-- A copy with one field changed, so a test can vary the world without the +-- other tests seeing it. +local function radioDataWith(overrides) + local copy = {} + for key, value in pairs(radioData) do copy[key] = value end + for key, value in pairs(overrides) do copy[key] = value end + return copy +end + +-- PrintRadioLine parks 100 frames on wRadioTextDelay and RadioScroll burns +-- them one per frame, so a line lands roughly every 102 steps. Step until the +-- show has printed as many lines as the test wants, with a cap so a machine +-- that has stopped advancing fails instead of hanging. +local function runRadio(station, rng, lines, data) + local radio = Pokegear.Radio.new({ data = data or radioData, rng = rng }) + radio:tune(station) + for _ = 1, (lines + 2) * 200 do + if #radio.log >= lines then break end + radio:step() + end + return radio +end + +local function checkLines(name, radio, want) + check(name .. " line count", #radio.log >= #want, true) + for index, line in ipairs(want) do + check(("%s line %d"):format(name, index), radio.log[index], line) + end +end + +-- ------------------------------------------------- Oak's Pokemon Talk +-- +-- Rolls per wild-mon segment: the route, the time of day, the grass slot. +-- Then one for OaksPKMNTalk8's adverb and one for OaksPKMNTalk9's adjective. +-- 0/1/2 picks OaksPKMNTalkRoutes' first route (ROUTE_29), the DAY block and +-- the first of the three legal slots; 0/0 picks the first adverb and the first +-- adjective. +local optRadio = runRadio("OAKS_POKEMON_TALK", rolls(0, 1, 2, 0, 0), 9) +checkLines("OPT", optRadio, { + "MARY: PROF.OAK'S", + "POKéMON TALK!", + "With me, MARY!", + "OAK: SPINARAK", + "may be seen around", + "ROUTE 29.", + "MARY: SPINARAK's", + "sweet and adorably", + "cute.", +}) + +-- StartRadioStation reaches RadioChannelSongs with the station id still in +-- wCurRadioLine, so tuning is what starts the song. +check("OPT starts its own song", optRadio.music, "Music_ProfOaksPokemonTalk") + +-- The rolls reject rather than wrap. A route byte of 15 or more is thrown +-- away (`cp 15` after `and %11111`), a daytime of 3 is DARKNESS_F, and a slot +-- below 2 or of 5 and up is outside the middle three -- so this sequence +-- burns three rejects before landing on exactly the same line as above. +local optReject = runRadio("OAKS_POKEMON_TALK", + rolls(20, 0, 3, 1, 7, 2, 0, 0), 4) +check("OPT rerolls a route past the table", optReject.log[4], "OAK: SPINARAK") + +-- wOaksPKMNTalkSegmentCounter counts five wild-mon segments and then hands +-- over to the Pokemon Channel jingle, which is NOT a scrolled radio line: it +-- redraws the box and stamps two more strings into it with PlaceRadioString. +local optJingle = runRadio("OAKS_POKEMON_TALK", rolls(0, 1, 2, 0, 0), 37) +check("OPT runs five wild-mon segments", optJingle.log[33], "cute.") +check("then the jingle takes the box", optJingle.log[34], "POKéMON") +-- `hlcoord 9, 14` is eight cells right of the box's own first column. +check("and stamps the second at column 9", optJingle.log[35], + "POKéMON POKéMON") +check("with the channel name below it", optJingle.log[36], "POKéMON Channel") +-- OaksPKMNTalk14 zeroes wNumRadioLinesPrinted and names OAKS_POKEMON_TALK_4, +-- so the show resumes at the wild mon rather than at MARY's intro. +check("then it drops back into the wild-mon segment", optJingle.log[37], + "OAK: SPINARAK") + +-- ------------------------------------------------------- Pokedex Show +-- +-- A byte of 15 is species index 16 (PIDGEY, the only caught mon in the +-- fixture); the `inc c` after CheckCaughtMon is what makes the roll one-based. +local dexRadio = runRadio("POKEDEX_SHOW", rolls(15), 9) +checkLines("dex show", dexRadio, { + "PIDGEY", + "TINY BIRD", + "It usually hides", + "in tall grass. Be-", + "cause it dislikes", + "fighting, it pro-", + "tects itself by", + "kicking up sand.", + "PIDGEY", +}) + +-- CheckCaughtMon rejects anything the player has not caught, so an uncaught +-- roll is spent and the loop goes round again. +local dexReject = runRadio("POKEDEX_SHOW", rolls(0, 15), 1) +check("dex show skips an uncaught roll", dexReject.log[1], "PIDGEY") + +-- --------------------------------------- Pokemon Music / Let's All Sing +-- +-- FernMonMusic2 names POKEMON_MUSIC_4, not a LETS_ALL_SING segment: Kanto's +-- station hands over to Johto's code after two lines and both DJs read the +-- same closing three. +local singRadio = runRadio("LETS_ALL_SING", rolls(0), 5, + radioDataWith({ weekday = 2 })) +checkLines("let's all sing", singRadio, { + "FERN: POKéMUSIC!", + "With DJ FERN!", + "Today's TUESDAY,", + "so let us jam to", + "POKéMON March!", +}) +check("an even weekday marches", singRadio.music, "Music_PokemonMarch") +-- BenFernMusic7 is a bare `ret`: the show stops talking and plays out. The +-- scroll after its last line still runs, so that line ends up on the box's +-- TOP row with nothing under it, and there it stays. +for _ = 1, 500 do singRadio:step() end +check("and the show ends there", singRadio.cur, "POKEMON_MUSIC_7") +check("nothing follows it", singRadio.log[6], nil) +check("and the last line stays up", singRadio.top, "POKéMON March!") +check("with the bottom row left clear", singRadio.bottom, "") + +local benRadio = runRadio("POKEMON_MUSIC", rolls(0), 6, + radioDataWith({ weekday = 3 })) +checkLines("pokemon music", benRadio, { + "BEN: POKéMON MUSIC", + "CHANNEL!", + "It's me, DJ BEN!", + "Today's WEDNESDAY,", + "so chill out to", + "POKéMON Lullaby!", +}) +check("an odd weekday lulls", benRadio.music, "Music_PokemonLullaby") + +-- ---------------------------------------------------- Lucky Number Show +-- +-- LuckyNumberShow13's `call Random / and a` only branches on a rolled zero, so +-- anything else restarts the show and one byte in 256 gets the drag lines. +local luckyRadio = runRadio("LUCKY_CHANNEL", rolls(1), 14) +checkLines("lucky channel", luckyRadio, { + "REED: Yeehaw! How", + "y'all doin' now?", + "Whether you're up", + "or way down low,", + "don't you miss the", + "LUCKY NUMBER SHOW!", + "This week's Lucky", + "Number is 00042!", + "I'll repeat that!", + "This week's Lucky", + "Number is 00042!", + "Match it and go to", + "the RADIO TOWER!", + "REED: Yeehaw! How", +}) +local luckyDrag = runRadio("LUCKY_CHANNEL", rolls(0), 16) +check("a rolled zero drags", luckyDrag.log[14], "…Repeating myself") +check("and drags again", luckyDrag.log[15], "gets to be a drag…") +check("before starting over", luckyDrag.log[16], "REED: Yeehaw! How") + +-- ---------------------------------------------------- Places and People +-- +-- PeoplePlaces3 rolls once: below 49 percent - 1 (123) takes the People +-- branch, anything else takes Places. PeoplePlaces4 then rolls a trainer +-- class, PeoplePlaces5 an adjective, then a 4 percent (10) restart chance, +-- then the People/Places coin again. +local pnpPeople = runRadio("PLACES_AND_PEOPLE", rolls(0), 6) +checkLines("places and people", pnpPeople, { + "PLACES AND PEOPLE!", + "Brought to you by", + "me, DJ LILY!", + "CLASS1 T1", + "is cute.", + -- A zero is under the 4 percent mark, so the show restarts from its intro. + "PLACES AND PEOPLE!", +}) +-- PnP_HiddenPeople is a rejection list, not a skip: the roll is spent and +-- another one is taken. A byte of 1 asks for class 2, which the fixture +-- hides, so the show falls through to the next roll. +local pnpHidden = runRadio("PLACES_AND_PEOPLE", rolls(0, 1, 0), 4) +check("a hidden class is rerolled", pnpHidden.log[4], "CLASS1 T1") +-- 200 is over the People threshold, so DJ LILY talks about a place instead. +local pnpPlaces = runRadio("PLACES_AND_PEOPLE", rolls(200, 0, 5, 20, 20), 6) +check("a high roll takes the Places branch", pnpPlaces.log[4], "PALLET TOWN") +check("with its own adjective", pnpPlaces.log[5], "is somewhat bold.") +-- 20 clears the 4 percent restart and then falls under the People threshold; +-- the class roll wraps back to 200, which is past the sixty-six classes and +-- so is spent on nothing before 0 lands on the first one. +check("and hands back to People", pnpPlaces.log[6], "CLASS1 T1") + +-- --------------------------------------------------------- Rocket Radio +-- +-- Ten fixed lines, nothing rolled, and RocketRadio10 names ROCKET_RADIO so it +-- runs round again. +local rocketRadio = runRadio("ROCKET_RADIO", rolls(0), 11) +checkLines("rocket radio", rocketRadio, { + "… …Ahem, we are", + "TEAM ROCKET!", + "After three years", + "of preparation, we", + "have risen again", + "from the ashes!", + "GIOVANNI! Can you", + "hear? We did it!", + "Where is our Boss?", + "Is he listening?", + "… …Ahem, we are", +}) +check("rocket radio plays the overture", rocketRadio.music, + "Music_RocketTheme") + +-- PlayRadioShow forces ROCKET_RADIO over any station id below +-- POKE_FLUTE_RADIO while the tower is occupied and the player is in Johto. +local takeover = Pokegear.Radio.new({ + data = radioDataWith({ rocketsInRadioTower = true, inJohto = true }), + rng = rolls(0), +}) +takeover:tune("OAKS_POKEMON_TALK") +takeover:step() +check("Team Rocket broadcasts on every station", takeover.cur ~= nil, true) +check("and it is their script that runs", takeover.log[1], "… …Ahem, we are") +-- The override compares the station id, and every mid-show segment is $0a or +-- above, so a show already running is never interrupted mid-sentence. +local midShow = Pokegear.Radio.new({ + data = radioDataWith({ rocketsInRadioTower = true, inJohto = true }), + rng = rolls(0, 1, 2, 0, 0), +}) +midShow:tune("OAKS_POKEMON_TALK") +midShow.cur = "OAKS_POKEMON_TALK_4" +midShow:step() +check("a show mid-sentence is left alone", midShow.log[1], "OAK: SPINARAK") + +-- ------------------------------------------- the three music stations +-- +-- PokeFluteRadio, UnownRadio and EvolutionRadio set wNumRadioLinesPrinted to 1 +-- and return: they start a song and never print a word. +for _, station in ipairs({ "POKE_FLUTE_RADIO", "UNOWN_RADIO", + "EVOLUTION_RADIO" }) do + local music = Pokegear.Radio.new({ data = radioData, rng = rolls(0) }) + music:tune(station) + for _ = 1, 1000 do music:step() end + check(station .. " says nothing", #music.log, 0) + check(station .. " still plays", music.music ~= nil, true) +end + +-- --------------------------------------------------------- the scroll +-- +-- PrintRadioLine fills the box's top row first and its bottom row second; +-- from the third line on, CopyBottomLineToTopLine moves the previous line up. +local scroll = Pokegear.Radio.new({ data = radioData, rng = rolls(1) }) +scroll:tune("LUCKY_CHANNEL") +scroll:step() +check("the first line lands on the top row", scroll.top, "REED: Yeehaw! How") +check("with nothing under it", scroll.bottom, "") +check("and 100 frames on the clock", scroll.delay, 100) +-- RadioScroll decrements before it tests, so the 100 frames are spent over +-- the next 100 steps and the handover happens on the step after that. +for _ = 1, 101 do scroll:step() end +check("the delay has to run out first", #scroll.log, 1) +scroll:step() +check("then the second line lands on the bottom row", scroll.bottom, + "y'all doin' now?") +check("and the first is still above it", scroll.top, "REED: Yeehaw! How") +for _ = 1, 103 do scroll:step() end +check("the third line scrolls the second up", scroll.top, "y'all doin' now?") +check("and takes the bottom row itself", scroll.bottom, "Whether you're up") + +-- ---------------------------------------------------------- the tuner +-- +-- RadioChannels is eight knob positions, each with its own test. A position +-- whose test fails is not a station: NoRadioStation wipes the name and plays +-- nothing. +local gearLandmarks = { landmarks = {}, order = {} } +for _, row in ipairs({ + { "LANDMARK_NEW_BARK_TOWN", 1, 140, 100 }, + { "LANDMARK_ROUTE_29", 2, 128, 100 }, + { "LANDMARK_RUINS_OF_ALPH", 9, 76, 76 }, + { "LANDMARK_LAKE_OF_RAGE", 37, 100, 20 }, + { "LANDMARK_SILVER_CAVE", 45, 20, 20 }, + { "LANDMARK_PALLET_TOWN", 46, 60, 100 }, + { "LANDMARK_VICTORY_ROAD", 87, 30, 40 }, + { "LANDMARK_ROUTE_28", 93, 20, 30 }, + { "LANDMARK_FAST_SHIP", 94, 80, 80 }, +}) do + gearLandmarks.landmarks[row[1]] = + { id = row[1], index = row[2], x = row[3], y = row[4], name = row[1] } + gearLandmarks.order[row[2] + 1] = row[1] +end + +local function newGear(opts) + opts = opts or {} + local save = opts.save or {} + save.pokegearFlags = save.pokegearFlags + or { map = true, radio = true, phone = true } + local gearGame = newGame(save) + return Pokegear.new(gearGame, { + save = save, + landmarks = gearLandmarks, + currentLandmark = opts.landmark or "LANDMARK_NEW_BARK_TOWN", + clock = opts.clock, + }) +end + +-- Standing in Johto in the afternoon: the first three frequencies air and the +-- Kanto half of the dial is dead. +local johtoGear = newGear({ clock = { hour = 14, minute = 0, weekday = 1 } }) +local johtoDial = johtoGear:stations() +check("the dial is eight frequencies", #johtoDial, 8) +check("04.5 is Oak's talk in the afternoon", johtoDial[1].station, + "OAKS_POKEMON_TALK") +check("07.5 is the music channel", johtoDial[2].station, "POKEMON_MUSIC") +check("08.5 is the Lucky Channel", johtoDial[3].station, "LUCKY_CHANNEL") +check("13.5 needs the Ruins of Alph", johtoDial[4].station, nil) +check("16.5 is Kanto's", johtoDial[5].station, nil) +check("and the tuner still stops there", johtoDial[5].frequency, "16.5") + +-- .PKMNTalkAndPokedexShow reads wTimeOfDay: MORN, and only MORN, swaps the +-- talk out for the Pokedex Show. +local mornGear = newGear({ clock = { hour = 7, minute = 0, weekday = 1 } }) +check("04.5 is the Pokedex Show in the morning", + mornGear:stations()[1].station, "POKEDEX_SHOW") + +-- .RuinsOfAlphRadio is the one station that wants a single landmark. +local ruinsGear = newGear({ landmark = "LANDMARK_RUINS_OF_ALPH", + clock = { hour = 14, minute = 0, weekday = 1 } }) +check("13.5 airs in the Ruins of Alph", ruinsGear:stations()[4].station, + "UNOWN_RADIO") + +-- Kanto: the Johto half goes quiet and Places & People takes over. The POKe +-- FLUTE station also wants the EXPN card. +local kantoGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", + clock = { hour = 14, minute = 0, weekday = 1 } }) +local kantoDial = kantoGear:stations() +check("04.5 is silent in Kanto", kantoDial[1].station, nil) +check("16.5 is Places & People", kantoDial[5].station, "PLACES_AND_PEOPLE") +check("18.5 is Let's All Sing", kantoDial[6].station, "LETS_ALL_SING") +check("20.0 wants the EXPN card", kantoDial[7].station, nil) +local expnGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", + save = { pokegearFlags = { map = true, radio = true, phone = true, + expn = true } }, + clock = { hour = 14, minute = 0, weekday = 1 } }) +check("and airs with it", expnGear:stations()[7].station, "POKE_FLUTE_RADIO") + +-- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F and one of three +-- landmarks around the Lake of Rage. +local rageGear = newGear({ landmark = "LANDMARK_LAKE_OF_RAGE", + save = { flags = { ROCKET_SIGNAL = true } }, + clock = { hour = 14, minute = 0, weekday = 1 } }) +check("20.5 airs by the Lake of Rage", rageGear:stations()[8].station, + "EVOLUTION_RADIO") +check("but not without the signal", johtoDial[8].station, nil) + +-- LoadStation_RocketRadio hands the tuner LetsAllSingName, and +-- LoadStation_EvolutionRadio hands it UnownStationName: two stations really do +-- broadcast under another one's name. +check("Rocket Radio wears Let's All Sing's name", + Pokegear.STATION_NAMES.ROCKET_RADIO, "Let's All Sing!") +check("the evolution station wears the Unown one", + Pokegear.STATION_NAMES.EVOLUTION_RADIO, "?????") + +-- .InJohto counts the S.S. Aqua as Johto even though LANDMARK_FAST_SHIP sits +-- past every Kanto landmark. +check("the S.S. Aqua is Johto", + newGear({ landmark = "LANDMARK_FAST_SHIP" }):region(), "johto") + +-- ----------------------------------------------------- the map cursor +-- +-- PokegearMap_JohtoMap / PokegearMap_KantoMap walk the cursor by landmark +-- INDEX between two limits: d, the last landmark of the region, and e, the +-- first. Up steps forward and wraps to e; down steps back and wraps to d. +local mapInput +local function newMapGear(opts) + local gear = newGear(opts) + mapInput = gear.game.input + return gear +end + +local johtoMap = newMapGear({}) +check("Johto's limits are Silver Cave and New Bark", + select(1, johtoMap:cursorLimits()), 0x2d) +check("with New Bark as the first", select(2, johtoMap:cursorLimits()), 0x01) +check("and the cursor starts on the player", johtoMap:mapCursorIndex(), 1) +mapInput:press("up") +johtoMap:moveMapCursor(mapInput) +check("up steps to the next landmark", johtoMap:mapCursorIndex(), 2) +mapInput:press("down") +johtoMap:moveMapCursor(mapInput) +check("down steps back", johtoMap:mapCursorIndex(), 1) +-- `cp e / jr nz` then the shared `dec [hl]`: only the first landmark wraps, +-- and it wraps to d + 1 so the decrement lands on d itself. +mapInput:press("down") +johtoMap:moveMapCursor(mapInput) +check("down from the first wraps to the last", johtoMap:mapCursorIndex(), 0x2d) +check("which is Silver Cave", johtoMap:mapLandmark().id, "LANDMARK_SILVER_CAVE") +-- `cp d / jr c` then the shared `inc [hl]`: at or past d the cursor is slammed +-- to e - 1 so the increment lands on e. +mapInput:press("up") +johtoMap:moveMapCursor(mapInput) +check("up from the last wraps to the first", johtoMap:mapCursorIndex(), 0x01) + +-- The player icon never moves: PokegearMap_UpdateCursorPosition writes the +-- cursor's landmark, and PokegearMap_InitPlayerIcon wrote the player's once. +johtoMap.mapCursor = 0x2d +check("the icon stays where the player is", johtoMap:playerLandmark().id, + "LANDMARK_NEW_BARK_TOWN") +check("while the name box follows the cursor", johtoMap:mapLandmark().id, + "LANDMARK_SILVER_CAVE") +-- The landmark macro stores x + 8 / y + 16 because the sprite lives in OAM; +-- the extractor takes both offsets back off, so the cursor sits on the +-- landmark's own screen coordinates. +check("and the cursor sprite sits on the landmark's own x", + johtoMap:mapLandmark().x, 20) + +-- TownMap_GetKantoLandmarkLimits: before the Hall of Fame the Kanto map only +-- walks Victory Road to Route 28, the seven landmarks on the road to Indigo. +local kantoMap = newMapGear({ landmark = "LANDMARK_PALLET_TOWN" }) +check("Kanto's last landmark is Route 28", + select(1, kantoMap:cursorLimits()), 0x5d) +check("and its first is Victory Road without the Hall of Fame", + select(2, kantoMap:cursorLimits()), 0x57) +kantoMap.mapCursor = 0x57 +mapInput:press("down") +kantoMap:moveMapCursor(mapInput) +check("down from Victory Road wraps to Route 28", kantoMap:mapCursorIndex(), + 0x5d) +mapInput:press("up") +kantoMap:moveMapCursor(mapInput) +check("and up wraps back", kantoMap:mapCursorIndex(), 0x57) + +local hofMap = newMapGear({ landmark = "LANDMARK_PALLET_TOWN", + save = { flags = { HALL_OF_FAME = true } } }) +check("the Hall of Fame opens Kanto back to Pallet Town", + select(2, hofMap:cursorLimits()), 0x2e) +mapInput:press("down") +hofMap:moveMapCursor(mapInput) +check("so down from Pallet Town wraps to Route 28", hofMap:mapCursorIndex(), + 0x5d) + +-- Left and right are not cursor moves on this card at all: PokegearMap_ +-- ContinueMap pages the gear with them. .right takes the PHONE if it is +-- owned and the RADIO if it is not; .left always takes the CLOCK. +local pageMap = newMapGear({}) +for index, card in ipairs(pageMap.cards) do + if card.id == "map" then pageMap.cardIndex = index end +end +mapInput:press("right") +pageMap:moveMapCursor(mapInput) +check("right pages to the phone", pageMap:card().id, "phone") +mapInput:press("left") +pageMap.cardIndex = 2 +pageMap:moveMapCursor(mapInput) +check("left pages to the clock", pageMap:card().id, "clock") +local noPhone = newMapGear({ + save = { pokegearFlags = { map = true, radio = true } } }) +mapInput:press("right") +noPhone:moveMapCursor(mapInput) +check("without a phone, right pages to the radio", noPhone:card().id, "radio") + +-- --------------------------------------------------- the knob, in the card +-- +-- AnimateTuningKnob.TuningKnob winds wRadioTuningKnob up towards 80 and down +-- towards 0 and stops dead at either end -- `ret z` at the bottom and +-- `ret nc` at the top. It does not wrap, so neither does the port's row. +local knobGear = newMapGear({ clock = { hour = 14, minute = 0, weekday = 1 } }) +for index, card in ipairs(knobGear.cards) do + if card.id == "radio" then knobGear.cardIndex = index end +end +knobGear.mode = "card" +knobGear:update(0) +check("entering the card resolves the frequency", knobGear.radioShow, + "OAKS_POKEMON_TALK") +mapInput:press("up") +knobGear:update(0) +check("up winds the knob on", knobGear.station, 2) +check("and retunes", knobGear.radioShow, "POKEMON_MUSIC") +mapInput:press("down") +knobGear:update(0) +mapInput:press("down") +knobGear:update(0) +check("down stops dead at the bottom of the dial", knobGear.station, 1) +knobGear.station = #Pokegear.RADIO_CHANNELS +mapInput:press("up") +knobGear:update(0) +check("and up stops dead at the top", knobGear.station, + #Pokegear.RADIO_CHANNELS) + +-- The show only advances while the card is up, and B hands the map's music +-- back (ExitPokegearRadio_HandleMusic) and throws the machine away. +knobGear.station = 1 +knobGear:tuneRadio() +for _ = 1, 300 do knobGear:update(0) end +check("the show runs while the card is up", #knobGear.radio.log >= 2, true) +mapInput:press("b") +knobGear:update(0) +check("and B leaves the card", knobGear.mode, "strip") +check("taking the show with it", knobGear.radio, nil) + +-- ============================================================== POKeMART +-- +-- engine/items/mart.asm: StandardMart's jumptable loop, the buy list, the +-- quantity selector's wraps and clamps, and SellMenu's half price. What the +-- screen LOOKS like is what tests/drivers/gold_menu_shots.lua is for; this +-- asserts the state machine and the arithmetic a player would notice. +local MartMenu = require("src.ui.gen2.MartMenu") + +local martItems = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 18, + price = 300, canToss = true, description = "Restores HP\nby 20." }, + ANTIDOTE = { id = "ANTIDOTE", name = "ANTIDOTE", pocket = "ITEM", index = 19, + price = 100, canToss = true }, + NUGGET = { id = "NUGGET", name = "NUGGET", pocket = "ITEM", index = 36, + price = 10000, canToss = true }, + -- Every KEY ITEM carries CANT_TOSS, which is the flag SellMenu's + -- _CheckTossableItem actually refuses on. + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 7, + price = 0, canToss = false }, +} +-- The shape data/generated/marts.lua will have: `lists` is a 1-based array in +-- MART_* order and `bargain` is BargainShopData's own item/price rows. +local martData = { + lists = { { "POTION", "ANTIDOTE" } }, -- MART_CHERRYGROVE + bargain = { { item = "NUGGET", price = 4500 } }, +} + +local function newMart(save, opts) + local game, input = newGame(save) + game.data.items = martItems + opts = opts or {} + opts.save = save + opts.items = martItems + opts.marts = martData + return MartMenu.new(game, opts), input, game +end + +-- GetMart: only an id below NUM_MARTS is a mart at all, and everything else +-- gets DefaultMart's two items. +check("a listed mart is its own shelf", #MartMenu.inventory(martData, 0), 2) +check("in the ROM's order", MartMenu.inventory(martData, 0)[1], "POTION") +check("an id past NUM_MARTS falls back to DefaultMart", + MartMenu.inventory(martData, 200)[1], "POKE_BALL") +check("as does a mart id the table has no row for", + MartMenu.inventory(martData, 5)[1], "POKE_BALL") +check("and no marts.lua at all is an empty shelf, not invented stock", + #MartMenu.inventory(nil, 0), 0) + +-- BuySell_MultiplyPrice, then Sell_HalvePrice on the PRODUCT: three of a +-- 15-unit item sells for 22, not for three times seven. +check("buying multiplies", MartMenu.buyPrice(300, 4), 1200) +check("selling halves the product, not the unit", + MartMenu.sellPrice(15, 3), 22) +check("and a single item is simply half", MartMenu.sellPrice(300, 1), 150) + +-- .HowMayIHelpYou prints and returns TOPMENU without waiting, so the welcome +-- line is still up under the BUY/SELL/QUIT menu. +local shopSave = Save.newGame() +shopSave.inventory = {} +local shop, shopInput = newMart(shopSave) +check("a standard mart opens on the top menu", shop.phase, "top") +check("with the welcome line under it", shop.topLines[1], "Welcome! How may I") +check("BUY is the default option", shop.topIndex, 1) + +shopInput:press("a") +shop:update(0) +check("BUY opens the list", shop.phase, "buy") +check("the shelf is the mart's", #shop.entries, 2) +check("priced out of ItemAttributes", shop.entries[1].price, 300) +check("CANCEL sits one past the last row", shop:total(), 3) +shop.index = shop:total() +check("and is not an item", shop:isCancel(), true) + +-- A on CANCEL is B (.a_button treats a -1 selection as a cancel), and BuyMenu +-- returns into .AnythingElse rather than straight out of the shop. +shopInput:press("a") +shop:update(0) +check("CANCEL leaves the list", shop.phase, "top") +check("with the ask-more line", shop.topLines[1], "Can I do anything") + +-- Back into BUY, and buy two POTIONs. +shopInput:press("a") +shop:update(0) +check("the cursor is back at the top of the list", shop.index, 1) +shopInput:press("a") +shop:update(0) +check("A on a row opens the quantity selector", shop.phase, "buyQuantity") +check("starting at one", shop.qty, 1) +check("with MAX_ITEM_STACK as the ceiling", shop.qtyMax, 99) + +-- BuySellToss_InterpretJoypad: up/down wrap, left/right step ten and clamp. +shopInput:press("down") +shop:update(0) +check("down from one wraps to the ceiling", shop.qty, 99) +shopInput:press("right") +shop:update(0) +check("right at the ceiling clamps", shop.qty, 99) +shopInput:press("up") +shop:update(0) +check("up from the ceiling wraps to one", shop.qty, 1) +shopInput:press("right") +shop:update(0) +check("right steps ten", shop.qty, 11) +shopInput:press("left") +shop:update(0) +check("left steps ten back", shop.qty, 1) +shopInput:press("left") +shop:update(0) +check("and never below one", shop.qty, 1) +shopInput:press("up") +shop:update(0) +check("up steps one", shop.qty, 2) + +shopInput:press("a") +shop:update(0) +check("A asks to confirm", shop.confirm ~= nil, true) +check("the price line names the quantity and the item", + shop.confirm.pages[1][1], "2 POTION(S)") +check("and the total", shop.confirm.pages[1][2], "will be \xc2\xa5600.") +check("YES is the default", shop.confirm.choice, 1) +shopInput:press("a") +shop:update(0) +check("the potions arrive", shopSave.inventory.POTION, 2) +check("the money leaves", shopSave.player.money, 2400) +check("and the clerk thanks you", shop.message.pages[1][1], "Here you are.") +shopInput:press("a") +shop:update(0) +check("dismissing the thanks returns to the list", shop.phase, "buy") + +-- NO backs out of the confirmation without spending anything. +shopInput:press("a") +shop:update(0) +shopInput:press("a") +shop:update(0) +shopInput:press("down") +shop:update(0) +check("down picks NO", shop.confirm.choice, 2) +shopInput:press("a") +shop:update(0) +check("NO buys nothing", shopSave.inventory.POTION, 2) +check("and spends nothing", shopSave.player.money, 2400) +check("and drops back into the list", shop.phase, "buy") + +-- BuyMenuLoop compares money BEFORE it asks the bag for room, so a broke +-- player is told about the money and never about the PACK. +local brokeSave = Save.newGame() +brokeSave.inventory = {} +brokeSave.player.money = 100 +local broke, brokeInput = newMart(brokeSave) +brokeInput:press("a") broke:update(0) -- BUY +brokeInput:press("a") broke:update(0) -- POTION +brokeInput:press("a") broke:update(0) -- quantity 1 +brokeInput:press("a") broke:update(0) -- YES +check("a broke player is refused", broke.message.pages[1][1], "You don't have") +check("nothing is bought", brokeSave.inventory.POTION, nil) +check("and nothing is spent", brokeSave.player.money, 100) + +-- ReceiveItem failing is the OTHER refusal: AddItemToInventory caps a slot at +-- 99, which Bag.add already enforces. +local fullSave = Save.newGame() +fullSave.inventory = { POTION = 99 } +local full, fullInput = newMart(fullSave) +fullInput:press("a") full:update(0) +fullInput:press("a") full:update(0) +fullInput:press("a") full:update(0) +fullInput:press("a") full:update(0) +check("a full pocket is refused", full.message.pages[1][1], "You can't carry") +check("the stack stays at its cap", fullSave.inventory.POTION, 99) +check("and the money is untouched", fullSave.player.money, 3000) + +-- .Quit: the come-again line, then STANDARDMART_EXIT. +local byeSave = Save.newGame() +local bye, byeInput = newMart(byeSave) +local byeClosed = false +bye.onClose = function() byeClosed = true end +byeInput:press("down") bye:update(0) +byeInput:press("down") bye:update(0) +check("QUIT is the third row", bye.topIndex, 3) +byeInput:press("a") bye:update(0) +check("it says goodbye first", bye.message.pages[1][1], "Please come again!") +check("and has not closed yet", byeClosed, false) +byeInput:press("a") bye:update(0) +check("then the shop closes", byeClosed, true) +-- B out of the top menu is the same exit (VerticalMenu returns carry). +local bye2, bye2Input = newMart(Save.newGame()) +bye2Input:press("b") bye2:update(0) +check("B quits too", bye2.phase, "outro") + +-- MenuHeader_Buy shows four entries at a time and .d_up refuses to move at +-- scroll zero: the buy list does NOT wrap the way the PACK's does, and its +-- scroll ceiling leaves room for the CANCEL row. +local longData = { + lists = { { "POTION", "ANTIDOTE", "POTION", "ANTIDOTE", "POTION", + "ANTIDOTE" } }, +} +local longGame, longInput = newGame(Save.newGame()) +longGame.data.items = martItems +local long = MartMenu.new(longGame, { save = longGame.save, items = martItems, + marts = longData }) +longInput:press("a") long:update(0) +check("a six-item shelf plus CANCEL", long:total(), 7) +check("starts unscrolled", long.scroll, 0) +longInput:press("up") long:update(0) +check("up at the top does nothing", long.index, 1) +for _ = 1, 4 do longInput:press("down") long:update(0) end +check("the cursor walked", long.index, 5) +check("and the window followed by one", long.scroll, 1) +for _ = 1, 5 do longInput:press("down") long:update(0) end +check("down stops on CANCEL rather than wrapping", long.index, 7) +check("with the window at its ceiling", long.scroll, 3) + +-- ------------------------------------------------------------------ SELL +local sellSave = Save.newGame() +sellSave.inventory = { POTION = 5, BICYCLE = 1, NUGGET = 1 } +sellSave.player.money = 1000 +local sell, sellInput = newMart(sellSave) +sellInput:press("down") sell:update(0) +sellInput:press("a") sell:update(0) +check("SELL opens the PACK", sell.phase, "sell") +check("and the PACK is real", sell.pack ~= nil, true) + +-- _CheckTossableItem is the gate: a CANT_TOSS item gets MartCantBuyText and +-- never reaches a price. +sell:offerToSell("BICYCLE", 1) +check("a key item is refused", sell.message.pages[1][1], "Sorry, I can't buy") +check("and no quantity is asked for", sell.phase, "sell") +sellInput:press("a") sell:update(0) +-- Nothing in stock is not a sale either. +sell:offerToSell("POTION", 0) +check("selling none of something is refused too", + sell.message.pages[1][1], "Sorry, I can't buy") +sellInput:press("a") sell:update(0) + +sell:offerToSell("POTION", 5) +check("a sellable item asks how many", sell.phase, "sellQuantity") +check("the ceiling is what you hold", sell.qtyMax, 5) +sellInput:press("right") sell:update(0) +check("right clamps to what you hold", sell.qty, 5) +sellInput:press("up") sell:update(0) +check("up from the ceiling wraps to one", sell.qty, 1) +sellInput:press("up") sell:update(0) +check("and up steps one", sell.qty, 2) + +sellInput:press("a") sell:update(0) +check("the offer is a page of its own", sell.confirm.pages[1][1], + "I can pay you") +check("half of two potions", sell.confirm.pages[1][2], "\xc2\xa5300.") +check("and the question is the next page", #sell.confirm.pages, 2) +sellInput:press("a") sell:update(0) +check("which the YES/NO box sits on", sell.confirm.page, 2) +check("still nothing sold", sellSave.inventory.POTION, 5) +sellInput:press("a") sell:update(0) +check("YES takes the potions", sellSave.inventory.POTION, 3) +check("and pays for them", sellSave.player.money, 1300) +check("with a receipt", sell.message.pages[1][1], "Got \xc2\xa5300 for") + +-- GiveMoney clamps at MaxMoney; nothing can push a wallet past 999999. +sellInput:press("a") sell:update(0) +sellSave.player.money = 999900 +sell:offerToSell("NUGGET", 1) +sellInput:press("a") sell:update(0) -- confirm at quantity 1 +sellInput:press("a") sell:update(0) -- page 2 +sellInput:press("a") sell:update(0) -- YES +check("the wallet clamps at MAX_MONEY", sellSave.player.money, 999999) +check("and the nugget is gone", sellSave.inventory.NUGGET, nil) + +-- ------------------------------------------------- PlayTransactionSound +-- +-- engine/items/mart.asm rings SFX_TRANSACTION in exactly two places, both of +-- them past every gate: BuyMenuLoop's .proceed (after the money and bag checks, +-- before TakeMoney) and the sell flow's own line after MartBoughtText. So the +-- till is the money moving, and a refusal -- broke, full pocket, CANT_TOSS -- +-- is silent. Stubbing the instance method rather than Sound keeps this an +-- assertion about the call sites, which is what was missing. +local tillSave = Save.newGame() +tillSave.inventory = {} +tillSave.player.money = 3000 +local till, tillInput = newMart(tillSave) +local rings = 0 +till.playTransaction = function() rings = rings + 1 end +tillInput:press("a") till:update(0) -- BUY +tillInput:press("a") till:update(0) -- POTION +tillInput:press("a") till:update(0) -- quantity 1 +check("nothing rings before the confirmation is answered", rings, 0) +tillInput:press("a") till:update(0) -- YES +check("a completed purchase rings the till", rings, 1) +tillInput:press("a") till:update(0) -- dismiss the thanks + +-- Same shop, no money left: the refusal must not ring. +tillSave.player.money = 0 +tillInput:press("a") till:update(0) -- POTION +tillInput:press("a") till:update(0) -- quantity 1 +tillInput:press("a") till:update(0) -- YES +check("a refused purchase is silent", rings, 1) + +local tillSellSave = Save.newGame() +tillSellSave.inventory = { POTION = 5, BICYCLE = 1 } +local tillSell, tillSellInput = newMart(tillSellSave) +local sellRings = 0 +tillSell.playTransaction = function() sellRings = sellRings + 1 end +tillSellInput:press("down") tillSell:update(0) +tillSellInput:press("a") tillSell:update(0) -- SELL +tillSell:offerToSell("BICYCLE", 1) +check("a CANT_TOSS item is refused without a sound", sellRings, 0) +tillSellInput:press("a") tillSell:update(0) -- clear the refusal +tillSell:offerToSell("POTION", 2) +tillSellInput:press("a") tillSell:update(0) -- accept the quantity, still one +tillSellInput:press("a") tillSell:update(0) -- second page +tillSellInput:press("a") tillSell:update(0) -- YES +check("a completed sale rings it once", sellRings, 1) +check("and the potion really left", tillSellSave.inventory.POTION, 4) + +-- --------------------------------------------------------- other dialogs +-- +-- HerbShop / BargainShop / Pharmacist never show a top menu: intro, BuyMenu, +-- come again. +local herbSave = Save.newGame() +local herb, herbInput = newMart(herbSave, { martType = 1, martId = 33 }) +check("the herb shop opens on its intro", herb.phase, "intro") +check("which is five pages", #herb.message.pages, 5) +check("starting with her greeting", herb.message.pages[1][1], "Hello, dear.") +for _ = 1, 5 do herbInput:press("a") herb:update(0) end +check("and lands in the buy list", herb.phase, "buy") +herbInput:press("b") herb:update(0) +check("B out of it goes straight to goodbye", herb.phase, "outro") +check("in her own words", herb.message.pages[1][1], "Come again, dear.") + +-- BargainShop carries its own prices and sells one of each, tracked by +-- wBargainShopFlags. +local dealSave = Save.newGame() +dealSave.inventory = {} +dealSave.player.money = 5000 +local deal, dealInput = newMart(dealSave, { martType = 2, martId = 0 }) +for _ = 1, 3 do dealInput:press("a") deal:update(0) end +check("the bargain shop lands in its list", deal.phase, "buy") +check("with one row", #deal.entries, 1) +check("at BargainShopData's price, not the item's", + deal.entries[1].price, 4500) +dealInput:press("a") deal:update(0) +check("and no quantity selector at all", deal.confirm ~= nil, true) +check("its price line is its own", deal.confirm.pages[1][1], "NUGGET costs") +dealInput:press("a") deal:update(0) +check("the nugget arrives", dealSave.inventory.NUGGET, 1) +check("the money leaves", dealSave.player.money, 500) +check("and the shelf remembers", dealSave.bargainShop.NUGGET, true) +dealInput:press("a") deal:update(0) +dealInput:press("a") deal:update(0) +check("a second try is sold out", deal.message.pages[1][1], "You bought that") +check("nothing else is bought", dealSave.inventory.NUGGET, 1) + +-- Pharmacist wording, so a repointed text table cannot silently swap kinds. +local drugSave = Save.newGame() +local drug = newMart(drugSave, { martType = 3, martId = 4 }) +check("the pharmacy has its own intro", drug.message.pages[1][1], + "What's up? Need") +check("and its own goodbye", MartMenu.TEXTS.PHARMACY.comeAgain[1][1], + "All right.") +check("and its own price wording", + MartMenu.TEXTS.PHARMACY.finalPrice(1, "POTION", 300)[1][2], + "will cost \xc2\xa5300.") + +-- PRINTNUM_MONEY floats the ¥ in front of the first significant digit inside +-- a six-digit field, so the string is always seven tiles wide. +check("a small amount is space padded", MartMenu.moneyText(300), + " \xc2\xa5300") +check("a full field keeps every digit", MartMenu.moneyText(999999), + "\xc2\xa5999999") +check("and zero still prints one digit", MartMenu.moneyText(0), + " \xc2\xa50") + +-- --------------------------------------------------------------- mod screens +-- +-- Every Gold screen is reached through a src/ui/Screens.lua id, so a mod +-- replaces one by registering a factory under that id, exactly the way it does +-- in the Gen 1 port. Two properties are the whole contract: +-- +-- * with nothing registered, an id resolves to EXACTLY the module the engine +-- used to require -- routing the pushes through Screens moved no screen, +-- and +-- * a registered record wins over that module. +-- +-- The ids carry a "Gen2" prefix because a dozen Gold screens share a module +-- name with a Gen 1 screen (PartyMenu, StartMenu, TitleState, ...); the last +-- assertion below is that the two namespaces really are separate. +local Screens = require("src.ui.Screens") + +-- A stack double. Screens.push lands the instance on game.stack and nothing +-- here draws or updates it, so a bare collector is the whole surface needed. +local screenStack = { pushed = {} } +function screenStack:push(state) self.pushed[#self.pushed + 1] = state end +local screenGame = { data = {}, stack = screenStack } + +-- The cache is module-global and this file is dofile'd alongside the rest of +-- the tier, so bracket the whole block with invalidations. +Screens.invalidate() + +local registered = {} +for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end +for _, id in ipairs({ "Gen2TitleState", "Gen2MainMenu", "Gen2OakSpeech", + "Gen2StartMenu", "Gen2PartyMenu", "Gen2SummaryMenu", "Gen2PackMenu", + "Gen2MartMenu", "Gen2BattleState", "Gen2EvolutionAnim", "Gen2PcMenu", + "Gen2BoxMenu", "Gen2NamingScreen", "Gen2Pokegear" }) do + check("the id list carries " .. id, registered[id], true) +end + +for _, id in ipairs(Screens.GEN2_IDS) do + -- "Gen2PartyMenu" -> src.ui.gen2.PartyMenu, the module the push sites used + -- to require by hand. + check("no mod: " .. id .. " resolves to its builtin", + Screens.get(screenGame, id), require("src.ui.gen2." .. id:sub(5))) +end + +-- A registry record wins, Screens.push stamps the id on the instance and lands +-- it on the stack -- the same three things tests/mod_ui_tests.lua asserts for +-- the Gen 1 ids. +Screens.invalidate() +screenGame.data.screens = { + Gen2PartyMenu = { new = function() return { modded = "party" } end }, +} +local moddedParty = Screens.push(screenGame, "Gen2PartyMenu", {}) +check("a registered record replaces the Gold screen", moddedParty.modded, + "party") +check("and the push stamps the id", moddedParty.screenId, "Gen2PartyMenu") +check("and lands the instance on the stack", screenStack.pushed[1], + moddedParty) + +-- Screens.build is that same resolution without the stack: the mart holds its +-- sell-mode PACK rather than pushing it (src/ui/gen2/MartMenu.lua enterSell), +-- and an override has to reach it there too. +Screens.invalidate() +screenGame.data.screens = { + Gen2PackMenu = { new = function() return { modded = "pack" } end }, +} +local builtPack = Screens.build(screenGame, "Gen2PackMenu", {}) +check("build honours the same registry", builtPack.modded, "pack") +check("and stamps the id", builtPack.screenId, "Gen2PackMenu") +check("without touching the stack", #screenStack.pushed, 1) + +-- The prefix is load bearing: replacing Gold's party menu must leave Red's +-- alone, and vice versa. +check("the Gen 1 id of the same name is untouched", + Screens.get(screenGame, "PartyMenu"), require("src.ui.PartyMenu")) + +-- The registry only reaches a screen the PUSH SITE looked up by id, and +-- src/core/Game2.lua is the one file that opens the boot cinema, the +-- START menu and every one of its submenus. It used to require those modules +-- and push them by hand, which quietly exempted fourteen ids from the contract +-- above, so this reads the source and asserts the exemption is gone: no +-- `stack:push(<Module>.new(` for anything under src/ui/gen2/. A textual check +-- is the honest one here -- constructing a Game2 needs love. +local game2Source = (function() + local f = io.open("src/core/Game2.lua", "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end)() +check("Game2's source is readable", game2Source ~= nil, true) +if game2Source then + check("it resolves screens through the registry", + game2Source:find('require("src.ui.Screens")', 1, true) ~= nil, true) + local handRolled = {} + for name in game2Source:gmatch("stack:push%(([%w_]+)%.new%(") do + -- TextBox is src/render, shared with the Gen 1 path and not a screen id. + if name ~= "TextBox" then handRolled[#handRolled + 1] = name end + end + check("and pushes no Gold screen by hand", table.concat(handRolled, ","), "") + for _, id in ipairs({ "Gen2CopyrightSplash", "Gen2GameFreakPresents", + "Gen2GoldSilverIntro", "Gen2TitleState", "Gen2MainMenu", "Gen2OakSpeech", + "Gen2OptionsMenu", "Gen2StartMenu", "Gen2PokedexMenu", "Gen2PartyMenu", + "Gen2PackMenu", "Gen2Pokegear", "Gen2TrainerCard", "Gen2SaveMenu" }) do + check("it opens " .. id .. " by id", + game2Source:find('"' .. id .. '"', 1, true) ~= nil, true) + end +end + +screenGame.data.screens = nil +Screens.invalidate() + +-- --------------------------------------------------------------------------- +-- The scripted static menu (src/ui/gen2/ScriptMenu.lua) +-- +-- `loadmenu` then `verticalmenu` / `_2dmenu`. Everything below is +-- GetMenuTextStartCoord and _2DMenu_'s own arithmetic. The two headers are +-- transcribed from pokegold (the dept-store vending machine and Earl's +-- blackboard, the only `_2dmenu` in the game); the block at the end reads the +-- cache and asserts the extractor produced the same bytes, so a re-import that +-- reads a MenuHeader wrong fails here rather than by a menu drawn askew. +-- Wrapped in a function rather than a `do` block: the main chunk is close +-- to Lua's 200-local ceiling and these would push it over. +local function scriptMenuChecks() + + local ScriptMenu = require("src.ui.gen2.ScriptMenu") + + -- CeladonDeptStore6F's vending machine: `menu_coords 0, 2, 19, 11` with + -- STATICMENU_CURSOR and no STATICMENU_NO_TOP_SPACING. Border + 1 is (1,3), + -- the missing NO_TOP_SPACING adds a row and the cursor a column: (2,4). + local vending = { + flags = 0x40, top = 2, left = 0, bottom = 11, right = 19, cursor = 1, + dataFlags = 0x80, + items = { "FRESH WATER", "SODA POP", "LEMONADE", "CANCEL" }, + } + local x, y = ScriptMenu.startCoord(vending) + check("a cursor menu with top spacing starts at x", x, 2) + check("and at y", y, 4) + + -- YesNoMenuHeader sets both bits, which is what puts YES on the row straight + -- under the border rather than one below it. + local both = { top = 7, left = 14, dataFlags = 0x80 + 0x40 } + local bx, by = ScriptMenu.startCoord(both) + check("NO_TOP_SPACING keeps the first row against the border", by, 8) + check("and the cursor column is still reserved", bx, 16) + + local none = { top = 0, left = 0, dataFlags = 0 } + local nx, ny = ScriptMenu.startCoord(none) + check("no cursor, no spacing flag: x", nx, 1) + check("no cursor, no spacing flag: y", ny, 2) + + -- Earl's blackboard, the only `_2dmenu` in the game: `dn 3, 2` is three rows + -- of two, spacing 5. + local blackboard = { + flags = 0x40, top = 0, left = 0, bottom = 8, right = 11, cursor = 1, + dataFlags = 0x80, + grid = { rows = 3, cols = 2, spacing = 5 }, + gridItems = { "PSN", "PAR", "SLP", "BRN", "FRZ", "QUIT" }, + } + local items, rows, cols, spacing = ScriptMenu.layout(blackboard, "2d") + check("the grid is three rows", rows, 3) + check("of two columns", cols, 2) + check("five tiles apart", spacing, 5) + check("with six labels", #items, 6) + -- A vertical menu reads the SAME header as n rows of one column, which is + -- why the answer arithmetic below collapses to the row index for it. + local _, vRows, vCols = ScriptMenu.layout(vending, "vertical") + check("the vending machine is four rows", vRows, 4) + check("of one column", vCols, 1) + + -- _2DMenu_: `(cursorY - 1) * cols + cursorX`, one-based both ways. + check("2D row 1 col 1 is choice 1", ScriptMenu.choiceIndex(1, 1, 2), 1) + check("row 1 col 2 is choice 2", ScriptMenu.choiceIndex(1, 2, 2), 2) + check("row 3 col 2 is choice 6 (QUIT)", ScriptMenu.choiceIndex(3, 2, 2), 6) + check("a one-column menu answers its row", ScriptMenu.choiceIndex(3, 1, 1), 3) + + -- The cursor and the labels: rows are TWO apart in both menus + -- (PlaceMenuStrings and Place2DMenuItemStrings both `add hl, 2 * SCREEN_WIDTH`), + -- columns `spacing` apart, and the cursor sits one column left of the label. + local menu = ScriptMenu.new({ data = {} }, + { header = blackboard, style = "2d" }) + local ix, iy = menu:itemPosition(1) + check("blackboard item 1 x", ix, 2) + check("blackboard item 1 y", iy, 2) + local jx, jy = menu:itemPosition(4) -- row 2, col 2 = BRN + check("BRN sits a column across", jx, 7) + check("and a row pair down", jy, 4) + + -- Pressing A answers the 1-based index; B answers 0, which is the cancel arm + -- every `ifequal` ladder falls through to. + local pressed = {} + local input = { + wasPressed = function(_, name) return pressed[name] == true end, + } + local picked + local vm = ScriptMenu.new({ data = {}, input = input }, + { header = vending, style = "vertical", + onChoose = function(index) picked = index end }) + check("the cursor opens on the header's own default", vm.row, 1) + pressed = { down = true } + vm:update(0) + pressed = { down = true } + vm:update(0) + check("down twice moves two rows", vm.row, 3) + pressed = { down = true } + vm:update(0) + pressed = { down = true } + vm:update(0) + check("and it stops at the last item rather than wrapping", vm.row, 4) + pressed = { a = true } + vm:update(0) + check("A answers the 1-based index", picked, 4) + pressed = { a = true } + vm:update(0) + check("and a finished menu answers once", picked, 4) + + picked = nil + local bm = ScriptMenu.new({ data = {}, input = input }, + { header = vending, style = "vertical", + onChoose = function(index) picked = index end }) + pressed = { b = true } + bm:update(0) + check("B answers 0, the cancel arm", picked, 0) + + -- STATICMENU_DISABLE_B: the one flag that makes a menu inescapable. + picked = nil + local locked = ScriptMenu.new({ data = {}, input = input }, + { header = { top = 0, left = 0, bottom = 5, right = 9, dataFlags = 0x81, + items = { "A", "B" } }, + style = "vertical", onChoose = function(index) picked = index end }) + pressed = { b = true } + locked:update(0) + check("STATICMENU_DISABLE_B ignores B", picked, nil) + + -- The same two headers, out of the cache this time. Every `loadmenu` in + -- the game must carry a header with items, or that site takes the cancel + -- arm the way all seventeen of them did before the extractor followed the + -- pointer. + local cacheDir = os.getenv("GOLD_CACHE") + if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" + end + local scriptsFile = loadfile(cacheDir .. "/data/generated/scripts.lua") + if not scriptsFile then + check("cache absent (SKIP extracted menu headers)", true, true) + return + end + local sites, grids = 0, 0 + for key, cmds in pairs(scriptsFile()) do + if type(cmds) == "table" and key ~= "movements" then + for i, cmd in ipairs(cmds) do + if cmd.op == "loadmenu" then + sites = sites + 1 + local header = cmd.menu + check(("%s: loadmenu carries a header"):format(key), + type(header) == "table", true) + local list = header and (header.items or header.gridItems) + check(("%s: with at least one item"):format(key), + type(list) == "table" and #list > 0, true) + -- The command after it is what decides how the header is read, and + -- it is always one of the two static menus. + local next_ = cmds[i + 1] and cmds[i + 1].op + check(("%s: opens a static menu"):format(key), + next_ == "verticalmenu" or next_ == "_2dmenu", true) + if next_ == "_2dmenu" then + grids = grids + 1 + check(("%s: the 2D menu is 3x2"):format(key), + header.grid and header.grid.rows == 3 and header.grid.cols == 2, + true) + check(("%s: five spacing"):format(key), header.grid.spacing, 5) + check(("%s: last item is QUIT"):format(key), + header.gridItems[#header.gridItems], "QUIT") + end + end + end + end + end + check("every loadmenu site in the cache was seen", sites, 17) + -- Earl's blackboard is reached from two entry points, so its command is + -- disassembled twice; the game has exactly one `_2dmenu` source site. + check("and both entries into the one _2dmenu", grids, 2) +end + +scriptMenuChecks() + +-- --------------------------------------------------------- trainer card +-- +-- TrainerCard_Page3_Joypad hands TrainerCard_Page2_3_AnimateBadges the exact +-- same TrainerCard_JohtoBadgesOAM pointer page 2 does, and that table's own +-- header word is `dw wJohtoBadges` -- so the "Kanto badges" page never once +-- reads wKantoBadges, it draws the Johto flags under Kanto's caption and gym +-- leader faces (which are themselves LeaderGFX2/BadgeGFX2, byte-identical +-- INCBINs of LeaderGFX/BadgeGFX). drawBadgeSprites is exercised directly +-- with a stub sheet so the gating itself is proven, and the source is read +-- back the way tests/gen2_menus_test.lua already checks Game2, since +-- driving TrainerCard.new all the way to a styled draw needs real image +-- assets this harness does not have. +local function trainerCardChecks() + local TrainerCard = require("src.ui.gen2.TrainerCard") + + local draws = 0 + local realDraw = love.graphics.draw + love.graphics.draw = function(...) draws = draws + 1 end + + local stubSheet = {} + stubSheet.__index = stubSheet + function stubSheet:available() return true end + function stubSheet:quad() return { getViewport = function() return 0, 0 end } end + function stubSheet:image() return "stub" end + + local fake = { + frames = 0, + badges = setmetatable({}, stubSheet), + gfx = { badgeOam = { { + y = 0, x = 0, palette = 0, frames = { 0, 0, 0, 0, 0, 0, 0, 0 }, + } } }, + } + + -- BADGE_OAM_ORDER[1] is ZEPHYR: owned by name, Johto-keyed, is the shape + -- player.badges takes. + draws = 0 + TrainerCard.drawBadgeSprites(fake, { ZEPHYR = true }, TrainerCard.JOHTO_BADGES) + check("a Johto-keyed owned table draws the badge", draws > 0, true) + + -- The bug this item fixes: page 3 used to look the badge up by Kanto names + -- against a Kanto-keyed table, which the Johto-named OAM order can never + -- match, so nothing ever drew. Confirm that shape still draws nothing -- + -- it is the wrong table for either page now. + draws = 0 + TrainerCard.drawBadgeSprites(fake, { BOULDER = true }, TrainerCard.KANTO_BADGES) + check("a Kanto-keyed table never matches the Johto OAM order", draws, 0) + + love.graphics.draw = realDraw + + local source = (function() + local f = io.open("src/ui/gen2/TrainerCard.lua", "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body + end)() + check("TrainerCard.lua is readable", source ~= nil, true) + if source then + check("page 3 draws badges off player.badges, not kantoBadges", + source:find("self:drawBadges(JOHTO_BADGES, player.badges or {})", + 1, true) ~= nil, true) + check("page 3 does not gate on player.kantoBadges", + source:find("drawBadges(%w+, player%.kantoBadges") ~= nil, false) + check("the plain fallback also reads player.badges unconditionally", + source:find("local held = player.badges or {}", 1, true) ~= nil, true) + end +end + +trainerCardChecks() + +-- ------------------------------------------------------------ Bag pockets +-- Gen 2's four pockets fill independently (item_data_constants.asm): a full +-- ITEM pocket does not keep a KEY_ITEM or an HM out. Modelling one 20-slot +-- bag filled with TMs and key items refused HM07 WATERFALL at the Ice Path. +local function bagPocketChecks() + local Bag = require("src.inventory.Bag") + local data = { items = { + POTION = { id = "POTION", pocket = "ITEM", index = 1 }, + POKE_BALL = { id = "POKE_BALL", pocket = "BALL", index = 2 }, + TM_ROCK_SMASH = { id = "TM_ROCK_SMASH", pocket = "TM_HM", index = 3 }, + HM_WATERFALL = { id = "HM_WATERFALL", pocket = "TM_HM", index = 4 }, + CARD_KEY = { id = "CARD_KEY", pocket = "KEY_ITEM", index = 5 }, + } } + -- Fill the ITEM pocket to its cap with 20 distinct junk ids. + local save = { inventory = {} } + for i = 1, 20 do + local id = "JUNK_" .. i + data.items[id] = { id = id, pocket = "ITEM", index = 100 + i } + save.inventory[id] = 1 + end + check("ITEM pocket at cap", Bag.slots(save, data, "ITEM"), 20) + check("a full ITEM pocket refuses another ITEM", + Bag.add(save, "POTION", 1, data), false) + check("but still takes a BALL", + Bag.add(save, "POKE_BALL", 5, data), true) + check("and a KEY_ITEM", Bag.add(save, "CARD_KEY", 1, data), true) + check("and an HM", Bag.add(save, "HM_WATERFALL", 1, data), true) + check("the HM went to the TM/HM pocket", + Bag.slots(save, data, "TM_HM"), 1) + + -- The TM/HM pocket holds far more than 20 (one of each TM plus HMs). + local tm = { inventory = {} } + local tmData = { items = {} } + for i = 1, 30 do + local id = "TM_" .. i + tmData.items[id] = { id = id, pocket = "TM_HM", index = i } + check("TM #" .. i .. " fits", Bag.add(tm, id, 1, tmData), true) + end + check("30 TMs held, where a 20-bag would have stopped at 20", + Bag.slots(tm, tmData, "TM_HM"), 30) + + -- No pocket field (Gen 1 cache) resolves to ITEM, so the single-bag limit + -- is exactly what it was. + local g1 = { inventory = {} } + local g1Data = { items = {} } + for i = 1, 20 do + local id = "I" .. i + g1Data.items[id] = { id = id, index = i } -- no pocket + Bag.add(g1, id, 1, g1Data) + end + check("Gen 1 (no pockets) still caps at 20", + Bag.add(g1, "I21", 1, g1Data), false) +end +bagPocketChecks() + +-- --------------------------------------------------------------------------- +-- SaveMenu's write chime (engine/menus/save.asm:110, `ld de, SFX_SAVE / call +-- PlaySFX` right after ResumeGameLogic). +-- +-- SFX_SAVE is an INDEX into the sfx pointer table, so a wrong id plays the +-- wrong sound rather than nothing, and no assertion here can see the mistake +-- from the number alone. The check is therefore the resolution: writeNow -> +-- SaveMenu:playSfx -> sfxOrder[id + 1], against the shipped Gold cache, must +-- name Sfx_Save. $1f used to sit there, which is SFX_ENTER_DOOR. +-- Wrapped in a function for the same 200-local reason as the blocks above. +local function saveSfxChecks() + local cacheDir = os.getenv("GOLD_CACHE") + if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" + end + local audioFile = loadfile(cacheDir .. "/data/generated/audio.lua") + if not audioFile then + check("cache absent (SKIP the save chime)", true, true) + return + end + local audio = audioFile() + check("the cache carries an sfx order", type(audio.sfxOrder) == "table", true) + + -- Sound.play is the far side of SaveMenu:playSfx; swap it for a recorder so + -- the label the id resolved to is readable without an audio device. + local Sound = require("src.core.Sound") + local realPlay, rang = Sound.play, nil + Sound.play = function(_, name) rang = name end + local SaveMenu = require("src.ui.gen2.SaveMenu") + local menu = SaveMenu.new({ data = { audio = audio } }, + { save = {}, existed = false, writer = function() return true end }) + menu:writeNow() + Sound.play = realPlay + + check("saving rings SFX_SAVE", rang, "Sfx_Save") +end +saveSfxChecks() + +-- ------------------------------------------------- the mod row contract +-- +-- Gold's screens raise the same three hooks the Gen 1 ones do, which is only +-- half a contract: a row a mod adds has to DO something on A too. Each of +-- these three is one line in the screen's dispatch, and the failure without it +-- is invisible (the row draws, the press is eaten, nothing happens). +local function modRowChecks() + local Runtime = require("src.mods.Runtime") + local Hooks = require("src.mods.Hooks") + local hooks = Hooks.new() + -- restored below: run_tests.lua dofiles every suite into ONE process, so a + -- blanked Runtime here takes out every suite listed after this file + local prevEvents, prevHooks, prevErrors = + Runtime.events, Runtime.hooks, Runtime.errors + Runtime.install(prevEvents, hooks, prevErrors or {}) + + -- ui.options.rows: activate on A, step on Left/Right, value on the panel + local fired, stepped = 0, 0 + hooks:wrap("ui.options.rows", function(nextFn, game, rows) + rows = nextFn(game, rows) + rows[#rows + 1] = { id = "modrow", label = "MOD ROW", + value = function() return "ON" end, + step = function() stepped = stepped + 1 end, + activate = function() fired = fired + 1 end } + return rows + end) + local og, oi = newGame(nil) + local om = OptionsMenu.new(og, { options = Save.defaultOptions() }) + check("gen2 OPTION takes the hook's row", om.rows[#om.rows].id, "modrow") + om.index = #om.rows + oi:press("a") + om:update(0) + check("A on a mod row calls activate", fired, 1) + oi:press("right") + om:update(0) + check("Right on a mod row calls step", stepped, 1) + + -- ui.start_menu.items: an entry with onSelect and no value + hooks.chains = {} + local chose = 0 + hooks:wrap("ui.start_menu.items", function(nextFn, game, items) + items = nextFn(game, items) + items[#items + 1] = { label = "MOD", onSelect = function() chose = chose + 1 end } + return items + end) + local ssave = Save.newGame({ playerName = "GOLD" }) + local sg, si = newGame(ssave) + local sm = StartMenu.new(sg, { save = ssave }) + check("gen2 START takes the hook's item", sm.items[#sm.items].label, "MOD") + sm.list.index = #sm.items + si:press("a") + sm.list:update(si) + check("A on a mod item calls onSelect", chose, 1) + + -- ui.party.submenu: an entry with onSelect and no vanilla id + hooks.chains = {} + local picked = 0 + hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx) + items = nextFn(game, items, mon, ctx) + items[#items + 1] = { label = "WALK", onSelect = function() picked = picked + 1 end } + return items + end) + local PartyMenu = require("src.ui.gen2.PartyMenu") + local psave = Save.newGame({ playerName = "GOLD" }) + psave.party = { { species = "CYNDAQUIL", level = 5, hp = 20, maxHp = 20, + moves = {}, nickname = "CYNDA" } } + local pg, pi = newGame(psave) + local pm = PartyMenu.new(pg, { save = psave }) + local items = pm:submenuItems(psave.party[1]) + check("gen2 party submenu takes the hook's entry", items[#items].label, "WALK") + pm.submenu = { items = items, index = #items, mon = psave.party[1], slot = 1 } + pi:press("a") + pm:updateSubmenu(pi) + check("A on a mod entry calls onSelect", picked, 1) + + hooks.chains = {} + Runtime.install(prevEvents, prevHooks, prevErrors) +end +modRowChecks() + +print(("gen2 menus: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_move_effects_test.lua b/tests/gen2_move_effects_test.lua new file mode 100644 index 00000000..444a872b --- /dev/null +++ b/tests/gen2_move_effects_test.lua @@ -0,0 +1,631 @@ +-- Gen 2 move effects with real handlers: confusion, Curse (both arms), +-- Reflect / Light Screen, Leech Seed, the *_HIT flinch, the Bind class +-- partial trap, Solarbeam's sun charge-skip and False Swipe's 1-HP floor. +-- +-- luajit tests/gen2_move_effects_test.lua +-- +-- ROM-free. Each block names the pokegold routine it asserts: +-- effect_commands.asm BattleCommand_FinishConfusingTarget / _Screen / +-- _FlinchTarget / _TrapTarget / _SkipSunCharge, move_effects/curse.asm, +-- move_effects/leech_seed.asm, move_effects/false_swipe.asm, and core.asm +-- HandleWrap / HandleScreens / ResidualDamage. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 move effects") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GHOST = { id = "GHOST", index = 8, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, + PSYCHIC = { id = "PSYCHIC", index = 24, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + CONFUSE_RAY = { id = "CONFUSE_RAY", name = "CONFUSE RAY", power = 0, + type = "GHOST", accuracy = 100, pp = 10, effect = "EFFECT_CONFUSE" }, + CURSE = { id = "CURSE", name = "CURSE", power = 0, type = "NORMAL", + accuracy = 0, pp = 10, effect = "EFFECT_CURSE" }, + REFLECT = { id = "REFLECT", name = "REFLECT", power = 0, type = "NORMAL", + accuracy = 0, pp = 20, effect = "EFFECT_REFLECT" }, + LIGHT_SCREEN = { id = "LIGHT_SCREEN", name = "LIGHT SCREEN", power = 0, + type = "NORMAL", accuracy = 0, pp = 30, effect = "EFFECT_LIGHT_SCREEN" }, + LEECH_SEED = { id = "LEECH_SEED", name = "LEECH SEED", power = 0, + type = "GRASS", accuracy = 90, pp = 10, effect = "EFFECT_LEECH_SEED" }, + ROCK_SLIDE = { id = "ROCK_SLIDE", name = "ROCK SLIDE", power = 75, + type = "ROCK", accuracy = 90, pp = 10, effect = "EFFECT_FLINCH_HIT", + effectChance = 30 }, + WRAP = { id = "WRAP", name = "WRAP", power = 15, type = "NORMAL", + accuracy = 85, pp = 20, effect = "EFFECT_TRAP_TARGET" }, + FIRE_SPIN = { id = "FIRE_SPIN", name = "FIRE SPIN", power = 15, + type = "FIRE", accuracy = 70, pp = 15, effect = "EFFECT_TRAP_TARGET" }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" }, + SUNNY_DAY = { id = "SUNNY_DAY", name = "SUNNY DAY", power = 0, + type = "FIRE", accuracy = 0, pp = 5, effect = "EFFECT_SUNNY_DAY" }, + FALSE_SWIPE = { id = "FALSE_SWIPE", name = "FALSE SWIPE", power = 40, + type = "NORMAL", accuracy = 100, pp = 40, effect = "EFFECT_FALSE_SWIPE" }, + EMBER = { id = "EMBER", name = "EMBER", power = 40, type = "FIRE", + accuracy = 100, pp = 25, effect = "EFFECT_BURN_HIT", effectChance = 100 }, + -- data/moves/moves.asm: SPLASH is power 0, MAGNITUDE is stored at power 1 + -- because getmagnitude overwrites it, SPITE is power 0. + SPLASH = { id = "SPLASH", name = "SPLASH", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_SPLASH" }, + MAGNITUDE = { id = "MAGNITUDE", name = "MAGNITUDE", power = 1, + type = "GROUND", accuracy = 100, pp = 30, effect = "EFFECT_MAGNITUDE" }, + SPITE = { id = "SPITE", name = "SPITE", power = 0, type = "GHOST", + accuracy = 100, pp = 10, effect = "EFFECT_SPITE" }, + DREAM_EATER = { id = "DREAM_EATER", name = "DREAM EATER", power = 100, + type = "PSYCHIC", accuracy = 100, pp = 15, + effect = "EFFECT_DREAM_EATER" }, + TRANSFORM = { id = "TRANSFORM", name = "TRANSFORM", power = 0, + type = "NORMAL", accuracy = 0, pp = 10, effect = "EFFECT_TRANSFORM" }, + -- STRUGGLE is in the move table like any other move and in nobody's move + -- list, which is the whole of what makes Battle.STRUGGLE work. + STRUGGLE = { id = "STRUGGLE", name = "STRUGGLE", power = 50, type = "NORMAL", + accuracy = 100, pp = 1, effect = "EFFECT_RECOIL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local 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 = {}, + }, + GASTLY = { + id = "GASTLY", index = 92, name = "GASTLY", + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + TANGELA = { + id = "TANGELA", index = 114, name = "TANGELA", + baseStats = { hp = 65, attack = 55, defense = 115, speed = 60, + specialAttack = 100, specialDefense = 40 }, + types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 166, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, opts.playerSpecies or "MACHOP", + opts.playerLevel or 15, { dvs = perfect }) + player.moves = opts.playerMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, opts.wildSpecies or "MACHOP", + opts.wildLevel or 15, { dvs = perfect }) + wild.moves = opts.wildMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = opts.random }) + return battle, player, wild +end + +local function findText(events, text) + for _, event in ipairs(events or {}) do + if event.kind == "message" and event.text == text then return true end + end + return false +end + +-- ---- confusion: a volatile with a 2-5 turn count -------------------------- +do + -- accuracy roll 0 hits; the count roll 1 -> and %11 = 1, plus 2 = 3 turns. + local battle, player, wild = newBattle({ random = rolls({ 0, 1 }, 0) }) + battle:useMove(player, wild, "CONFUSE_RAY") + eq(wild.volatile.confuseCount, 3, + "FinishConfusingTarget: `and %11` plus two turns") + eq(wild.status, nil, "SUBSTATUS_CONFUSED never touches the status byte") + check(findText(battle:takeEvents(), "MACHOP became confused!"), + "BecameConfusedText") + + -- A second ray answers AlreadyConfusedText and changes nothing. + battle:useMove(player, wild, "CONFUSE_RAY") + eq(wild.volatile.confuseCount, 3, "no re-roll on a repeat") + check(findText(battle:takeEvents(), "MACHOP's already confused!"), + "AlreadyConfusedText") + + -- The 50 percent self-hit: canAct decrements, then a byte under 128 hurts. + local before = wild.hp + battle.random = rolls({ 0 }, 0) -- roll 0 < 128: self-hit + eq(battle:canAct(wild), false, "the confused turn is spent on the self-hit") + eq(wild.volatile.confuseCount, 2, "the count decrements first") + check(wild.hp < before, "HitConfusion's typeless 40-power hit landed") + check(findText(battle:takeEvents(), + "It hurt itself in its confusion!"), "with the cart's line") + + -- A byte at or above 128 lets the mon act. + battle.random = rolls({ 200 }, 0) + eq(battle:canAct(wild), true, "at or over 50 percent + 1 it acts") + eq(wild.volatile.confuseCount, 1, "count now on its last turn") + + -- The next decrement snaps out, and the mon still acts THAT turn. + battle.random = rolls({}, 0) + eq(battle:canAct(wild), true, "the snap-out turn is not lost") + eq(wild.volatile.confuseCount, nil, "ConfusedNoMoreText clears the count") + + -- The status byte stayed free the whole time: a burn lands on a mon that + -- is confused (the old status-slot storage shielded it). + battle.random = rolls({ 0, 1, 0, 0 }, 0) + battle:useMove(player, wild, "CONFUSE_RAY") + battle:useMove(player, wild, "EMBER") + eq(wild.status, "burn", "a confused mon can still be burned") + eq(wild.volatile.confuseCount, 3, "and stays confused") +end + +-- ---- Curse, non-Ghost arm ------------------------------------------------- +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + battle:useMove(player, wild, "CURSE") + eq(battle.stages.player.speed, -1, "Speed falls first") + eq(battle.stages.player.attack, 1, "then Attack rises") + eq(battle.stages.player.defense, 1, "then Defense rises") + check(wild.volatile == nil or wild.volatile.cursed == nil, + "no curse lands on the target from the stat arm") + + -- Refused only when BOTH raises are capped. + battle.stages.player.attack = 6 + battle.stages.player.defense = 6 + local speedBefore = battle.stages.player.speed + battle:useMove(player, wild, "CURSE") + eq(battle.stages.player.speed, speedBefore, + "with Attack and Defense capped, nothing moves at all") + check(findText(battle:takeEvents(), + "MACHOP's ATTACK won't rise anymore!"), "WontRiseAnymoreText") +end + +-- ---- Curse, Ghost arm ----------------------------------------------------- +do + local battle, player, wild = newBattle({ playerSpecies = "GASTLY", + playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, + random = rolls({}, 0) }) + local maxHp = player.maxHp + battle:useMove(player, wild, "CURSE") + eq(wild.volatile.cursed, true, "SUBSTATUS_CURSE set on the target") + eq(player.hp, maxHp - math.floor(maxHp / 2), + "the user pays half its max HP") + eq(battle.stages.player.attack, 0, "no stat change from the Ghost arm") + + -- ResidualDamage's curse arm: a quarter of max HP at the end of the turn. + local before = wild.hp + battle:tickSeedAndCurse(wild) + eq(wild.hp, before - math.floor((wild.maxHp) / 4), + "the cursed mon loses a quarter of max HP a turn") + + -- A second Ghost curse on the same target fails. + battle:takeEvents() + battle:useMove(player, wild, "CURSE") + check(findText(battle:takeEvents(), "But it failed!"), + "an already-cursed target refuses") +end + +-- ---- Reflect and Light Screen --------------------------------------------- +do + -- Reflect doubles Defense against a physical hit: with the seeded + -- variation the halving is exact enough to compare two identical hits. + local battle, player, wild = newBattle({ random = rolls({}, 1) }) + local bare = wild.hp + battle:useMove(player, wild, "TACKLE") + local plainDamage = bare - wild.hp + check(plainDamage > 2, "the unscreened tackle deals real damage") + + battle, player, wild = newBattle({ random = rolls({}, 1) }) + battle:useMove(wild, player, "REFLECT") + eq(battle.screens.enemy.reflect, 5, "BattleCommand_Screen: five turns") + check(findText(battle:takeEvents(), "MACHOP's DEFENSE rose!"), + "ReflectEffectText") + local screened = wild.hp + battle:useMove(player, wild, "TACKLE") + local screenedDamage = screened - wild.hp + check(screenedDamage < plainDamage, + "Reflect halves the physical hit (got " .. screenedDamage + .. " vs bare " .. plainDamage .. ")") + + -- A second cast while the first is up fails. + battle:takeEvents() + battle:useMove(wild, player, "REFLECT") + check(findText(battle:takeEvents(), "But it failed!"), + "one Reflect at a time per side") + + -- A special hit sails through Reflect (Light Screen's business). + battle, player, wild = newBattle({ random = rolls({}, 1) }) + bare = wild.hp + battle:useMove(player, wild, "EMBER") + local emberPlain = bare - wild.hp + wild.status = nil + battle, player, wild = newBattle({ random = rolls({}, 1) }) + battle:useMove(wild, player, "REFLECT") + screened = wild.hp + battle:useMove(player, wild, "EMBER") + eq(screened - wild.hp, emberPlain, "Reflect ignores special moves") + + -- HandleScreens: the count ticks each turn and the screen falls at zero. + battle, player, wild = newBattle({ random = rolls({}, 1) }) + battle:useMove(wild, player, "LIGHT_SCREEN") + eq(battle.screens.enemy.lightScreen, 5, "Light Screen is five turns too") + for _ = 1, 4 do battle:tickScreens() end + eq(battle.screens.enemy.lightScreen, 1, "four ticks down") + battle:takeEvents() + battle:tickScreens() + eq(battle.screens.enemy.lightScreen, nil, "the fifth drops it") + check(findText(battle:takeEvents(), "Enemy POKéMON's LIGHT SCREEN fell!"), + "BattleText_MonsLightScreenFell") +end + +-- ---- Leech Seed ----------------------------------------------------------- +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, + random = rolls({ 0 }, 0) }) + battle:useMove(player, wild, "LEECH_SEED") + eq(wild.volatile.leechSeed, true, "SUBSTATUS_LEECH_SEED on the target") + check(findText(battle:takeEvents(), "MACHOP was seeded!"), "WasSeededText") + + -- ResidualDamage: an eighth crosses to the other active mon. + player.hp = 10 + local seededBefore = wild.hp + battle:tickSeedAndCurse(wild) + local drained = seededBefore - wild.hp + eq(drained, math.floor(wild.maxHp / 8), "an eighth of max HP drains") + eq(player.hp, 10 + drained, "and lands on the seeder's side") + check(findText(battle:takeEvents(), "LEECH SEED saps MACHOP!"), + "LeechSeedSapsText") + + -- A second seed "evaded"; a Grass target is immune outright. + battle:useMove(player, wild, "LEECH_SEED") + check(findText(battle:takeEvents(), "MACHOP evaded the attack!"), + "a seeded target evades the repeat") + local battle2, player2, tangela = newBattle({ wildSpecies = "TANGELA", + playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, + random = rolls({ 0 }, 0) }) + battle2:useMove(player2, tangela, "LEECH_SEED") + check(tangela.volatile == nil or tangela.volatile.leechSeed == nil, + "a Grass target cannot be seeded") + check(findText(battle2:takeEvents(), "It doesn't affect TANGELA..."), + "PrintDoesntAffect for Grass") +end + +-- ---- the *_HIT flinch ----------------------------------------------------- +do + -- Rolls: crit roll, accuracy, damage variation, then the 30% flinch: 10 + -- lands under 30. + local battle, player, wild = newBattle({ + playerMoves = { { id = "ROCK_SLIDE", pp = 10, maxPp = 10 } }, + random = rolls({ 1, 0, 0, 10 }, 1) }) + battle:useMove(player, wild, "ROCK_SLIDE") + eq(wild.volatile.flinched, true, + "BattleCommand_FlinchTarget: the effect chance landed") + eq(battle:canAct(wild), false, "the flinched mon loses its turn") + check(findText(battle:takeEvents(), "MACHOP flinched!"), "FlinchedText") + + -- A roll past the chance leaves no flinch. + battle, player, wild = newBattle({ + playerMoves = { { id = "ROCK_SLIDE", pp = 10, maxPp = 10 } }, + random = rolls({ 1, 0, 0, 90 }, 1) }) + battle:useMove(player, wild, "ROCK_SLIDE") + check(wild.volatile == nil or wild.volatile.flinched == nil, + "a 90 roll misses the 30 percent chance") +end + +-- ---- the Bind class partial trap ------------------------------------------ +do + -- Rolls: crit, accuracy, variation, then the wrap count roll 1 -> 1+3 = 4. + local battle, player, wild = newBattle({ + playerMoves = { { id = "WRAP", pp = 20, maxPp = 20 } }, + random = rolls({ 1, 0, 0, 1 }, 1) }) + battle:useMove(player, wild, "WRAP") + eq(wild.volatile.wrapCount, 4, + "BattleCommand_TrapTarget: `and %11` plus three") + eq(wild.volatile.wrapMove, "WRAP", "the trapping move is remembered") + check(findText(battle:takeEvents(), "MACHOP was WRAPPED by MACHOP!"), + "WrappedByText") + + -- HandleWrap: decrement first, then a sixteenth -- and release at zero. + local before = wild.hp + battle:tickWrap(wild) + eq(wild.volatile.wrapCount, 3, "the count decrements first") + eq(before - wild.hp, math.max(1, math.floor(wild.maxHp / 16)), + "a sixteenth of max HP a turn") + battle:tickWrap(wild) + battle:tickWrap(wild) + battle:takeEvents() + battle:tickWrap(wild) + eq(wild.volatile.wrapCount, nil, "the last tick releases") + check(findText(battle:takeEvents(), "MACHOP was released from WRAP!"), + "UserWasReleasedFromStringBuffer1") + + -- No re-trap while one is running. + battle.random = rolls({ 1, 0, 0, 1 }, 1) + battle:useMove(player, wild, "WRAP") + battle.random = rolls({ 1, 0, 0, 1 }, 1) + local count = wild.volatile.wrapCount + battle:useMove(player, wild, "WRAP") + eq(wild.volatile.wrapCount, count, "an existing trap is not re-rolled") +end + +-- ---- Solarbeam under the sun ---------------------------------------------- +do + local battle, player, wild = newBattle({ + playerMoves = { { id = "SOLARBEAM", pp = 10, maxPp = 10 } }, + random = rolls({}, 1) }) + local before = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + eq(wild.hp, before, "without sun, turn one only charges") + eq(player.volatile.chargeMove, "SOLARBEAM", "the charge is stored") + + battle, player, wild = newBattle({ + playerMoves = { { id = "SOLARBEAM", pp = 10, maxPp = 10 } }, + random = rolls({}, 1) }) + battle.weather = "sun" + before = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + check(wild.hp < before, + "BattleCommand_SkipSunCharge: in sun the beam fires in one turn") + eq(player.volatile.chargeMove, nil, "no charge stored") +end + +-- ---- False Swipe's 1-HP floor --------------------------------------------- +do + -- A hit that would KO leaves exactly 1 HP. + local battle, player, wild = newBattle({ + playerMoves = { { id = "FALSE_SWIPE", pp = 40, maxPp = 40 } }, + random = rolls({ 1 }, 1) }) + wild.hp = 3 + battle:useMove(player, wild, "FALSE_SWIPE") + eq(wild.hp, 1, + "BattleCommand_FalseSwipe: damage is capped at the target's HP minus 1") + + -- At 1 HP already, the swipe deals nothing at all. + battle.random = rolls({ 1 }, 1) + battle:useMove(player, wild, "FALSE_SWIPE") + eq(wild.hp, 1, "a 1-HP target cannot be KOed by it either") +end + +-- ---- Splash prints the line and does nothing else ------------------------- +do + -- BattleCommand_Splash (move_effects/splash.asm) is the animation and then + -- `jp PrintNothingHappened`; the effect list has no checkhit at all. + local battle, player, wild = newBattle({ + playerMoves = { { id = "SPLASH", pp = 40, maxPp = 40 } }, + random = rolls({}, 1) }) + local before = wild.hp + local playerBefore = player.hp + battle:useMove(player, wild, "SPLASH") + check(findText(battle:takeEvents(), "But nothing\nhappened."), + "NothingHappenedText, with the cart's own line break") + eq(wild.hp, before, "and the target is never touched") + eq(player.hp, playerBefore, "nor the user") +end + +-- ---- Magnitude rolls its power off MagnitudePower ------------------------- +do + -- One BattleRandom byte walks data/moves/magnitude_power.asm and the first + -- row whose threshold is not below it wins: 200 falls in the `85 percent + 1` + -- row (217), which is Magnitude 8 at 90 power. + local battle, player, wild = newBattle({ + playerMoves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } }, + random = rolls({ 200 }, 1) }) + local before = wild.hp + battle:useMove(player, wild, "MAGNITUDE") + check(findText(battle:takeEvents(), "Magnitude 8!"), + "MagnitudeText names the magnitude before checkhit") + check(before - wild.hp > 5, + "and the rolled power replaces the ROM's stored 1") + + -- The bottom row: a roll of 0 is under every threshold, so Magnitude 4. + battle, player, wild = newBattle({ + playerMoves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } }, + random = rolls({ 0 }, 1) }) + battle:useMove(player, wild, "MAGNITUDE") + check(findText(battle:takeEvents(), "Magnitude 4!"), + "`cp b / jr nc` takes the first row whose threshold covers the roll") +end + +-- ---- Spite drains 2-5 PP from the target's last move ---------------------- +do + -- BattleCommand_Spite reads BATTLE_VARS_LAST_COUNTER_MOVE_OPP: the move the + -- TARGET used last. `and %11` plus two is 2-5, clamped to what is left. + local battle, player, wild = newBattle({ + playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, + random = rolls({ 0, 3 }, 0) }) + battle:volatile(wild).lastMove = "TACKLE" + battle:useMove(player, wild, "SPITE") + eq(wild.moves[1].pp, 30, "a roll of 3 takes five PP") + check(findText(battle:takeEvents(), "MACHOP's TACKLE was reduced by 5!"), + "SpiteEffectText names the move and the figure") + + -- The clamp: never more than the slot still holds. + battle, player, wild = newBattle({ + playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, + random = rolls({ 0, 3 }, 0) }) + battle:volatile(wild).lastMove = "TACKLE" + wild.moves[1].pp = 3 + battle:useMove(player, wild, "SPITE") + eq(wild.moves[1].pp, 0, "`cp b / jr nc` keeps the loss inside the slot") + + -- Nothing to spite: `.failed` is `jp PrintDidntAffect2`. + battle, player, wild = newBattle({ + playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, + random = rolls({ 0 }, 0) }) + battle:useMove(player, wild, "SPITE") + check(findText(battle:takeEvents(), "It didn't affect MACHOP!"), + "a target that has not moved yet cannot be spited") + eq(wild.moves[1].pp, 35, "and nothing was taken") +end + +-- ---- Dream Eater is gated by checkhit, not by the damage block ------------ +do + -- CheckHit opens on `call .DreamEater / jp z, .Miss` + -- (effect_commands.asm:1554), so an awake target takes nothing at all. + local battle, player, wild = newBattle({ + playerMoves = { { id = "DREAM_EATER", pp = 15, maxPp = 15 } }, + random = rolls({}, 1) }) + player.hp = math.max(1, player.hp - 20) + local before, healthBefore = wild.hp, player.hp + battle:useMove(player, wild, "DREAM_EATER") + check(findText(battle:takeEvents(), "MACHOP's attack missed!"), + "AttackMissedText: .Miss, not ButItFailedText") + eq(wild.hp, before, "no damage against a target that is awake") + eq(player.hp, healthBefore, "and nothing was sapped") + + -- Asleep, it lands and drains half of what it dealt. + battle, player, wild = newBattle({ + playerMoves = { { id = "DREAM_EATER", pp = 15, maxPp = 15 } }, + random = rolls({}, 1) }) + player.hp = math.max(1, player.hp - 20) + wild.status = "sleep" + before, healthBefore = wild.hp, player.hp + battle:useMove(player, wild, "DREAM_EATER") + check(wild.hp < before, "a sleeping target takes the hit") + check(player.hp > healthBefore, "and the user drains half of it") +end + +-- ---- TRANSFORM is battle ram: it never leaves the battle on the mon -------- +do + -- BattleCommand_Transform copies the target into wBattleMon / wEnemyMon and + -- leaves the struct the mon was loaded from alone, so SwitchOutMon's reload + -- and PokeBallEffect's `.catch_without_fail` (which reads + -- wTempEnemyMonSpecies, a byte no move rewrites) both hand back a DITTO. + -- This port has one table per mon, so what the copy overwrites is kept on + -- the volatile and Battle:untransform puts it back -- once at every route + -- out of the battle. Before this a DITTO that transformed was a permanent + -- copy: the caught record and the player's own party slot went into the save + -- as the wrong species, with the wrong moves and the wrong stats. + local battle, player, wild = newBattle({ + playerMoves = { { id = "TRANSFORM", pp = 10, maxPp = 10 } }, + wildSpecies = "TANGELA", + wildMoves = { { id = "TACKLE", pp = 35, maxPp = 35 } }, + random = rolls({}, 0) }) + local ownAttack = player.stats.attack + battle:useMove(player, wild, "TRANSFORM") + eq(player.species, "TANGELA", "the copy lands at once: the battler IS it") + eq(player.moves[1].id, "TACKLE", "with the target's moves") + eq(player.stats.attack, wild.stats.attack, "and the target's stats") + local state = battle:volatile(player) + eq(state.preTransform.species, "MACHOP", "and the record it was is kept") + + -- The event the screen swaps its pic on: src/ui/gen2/BattleState.lua draws + -- from `shownMon`, which follows the event queue, and a whole round is + -- resolved before its first message is read. + local sawEvent = false + for _, event in ipairs(battle:takeEvents()) do + if event.kind == "transform" then + sawEvent = true + eq(event.species, "TANGELA", "the transform event names the copy") + eq(event.from, "MACHOP", "and what it was") + end + end + check(sawEvent, "TRANSFORM emits its own moment for the screen") + + -- CleanUpBattleRAM, which is where the screen's finishBattle sends it. + battle:clearAllVolatiles() + eq(player.species, "MACHOP", "the party slot leaves the battle as itself") + eq(player.moves[1].id, "TRANSFORM", "with its own moves") + eq(player.stats.attack, ownAttack, "and its own stats") + eq(player.volatile, nil, "and no volatile left on a save table") +end + +-- ---- a DITTO caught while transformed is caught as a DITTO ----------------- +do + local Catching = require("src.battle.gen2.Catching") + local battle, player, wild = newBattle({ + wildMoves = { { id = "TRANSFORM", pp = 10, maxPp = 10 } }, + playerSpecies = "TANGELA", + random = rolls({}, 0) }) + battle:useMove(wild, player, "TRANSFORM") + eq(wild.species, "TANGELA", "the wild mon is the copy while the battle runs") + + -- PokeBallEffect's captured tail, reached through the one call the catch + -- site makes into the battle rules. A MASTER BALL so the roll is not what + -- is being tested. + local caught = Catching.attempt({ ball = "MASTER_BALL", battle = battle, + mon = wild, maxHp = wild.maxHp, hp = wild.hp, catchRate = 45 }) + eq(caught, true, "the MASTER BALL never fails") + eq(wild.species, "MACHOP", "and the record the catch keeps is the real mon") + eq(wild.moves[1].id, "TRANSFORM", "with its own move list") +end + +-- ---- STRUGGLE: full damage, then a quarter of it back ---------------------- +do + -- data/moves/moves.asm gives STRUGGLE 50 power and EFFECT_RECOIL_HIT, and + -- BattleCommand_Recoil takes a QUARTER of the damage dealt (Gen 1's half is + -- not Gen 2's formula), minimum 1. + local battle, player, wild = newBattle({ random = rolls({}, 1) }) + local before, mine = wild.hp, player.hp + battle:useMove(player, wild, Battle.STRUGGLE) + local dealt = before - wild.hp + check(dealt > 5, "STRUGGLE swings its 50 power, not chip damage") + eq(mine - player.hp, math.max(1, math.floor(dealt / 4)), + "and the user takes a quarter of what it dealt") + check(findText(battle:takeEvents(), "MACHOP is hit with recoil!"), + "RecoilText") +end + +-- ---- RUN is refused by a trainer battle without spending the turn ---------- +do + -- `.cant_run_from_trainer` leaves wBattlePlayerAction alone and falls into + -- `jp BattleMenu` (engine/battle/core.asm:5035-5038), so the 2x2 menu comes + -- straight back and the trainer never gets a free swing. Only + -- `.cant_escape_2`, the failed ROLL, writes BATTLEPLAYERACTION_USEITEM and + -- costs the round. + local foe = Mon.new(DATA, "MACHOP", 15, { dvs = perfect }) + foe.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local player = Mon.new(DATA, "MACHOP", 15, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, + trainer = { class = "YOUNGSTER", party = { foe } }, random = rolls({}, 0) }) + local before = player.hp + local events = battle:takeTurn({ kind = "run" }) + eq(player.hp, before, "the trainer never moved") + eq(battle.over, false, "and the battle is still running") + eq(battle.runRefused, true, "the refusal is what stopped the round") + check(findText(events, "No! There's no running from a trainer battle!"), + "NoRunningText") +end + +S.finish() diff --git a/tests/gen2_nests_test.lua b/tests/gen2_nests_test.lua new file mode 100644 index 00000000..1addd400 --- /dev/null +++ b/tests/gen2_nests_test.lua @@ -0,0 +1,109 @@ +-- FindNest: which landmarks a species can be met in. +-- +-- luajit tests/gen2_nests_test.lua +-- +-- The data behind the Pokedex's AREA page, which the port drew a label for and +-- never implemented. engine/overworld/wildmons.asm FindNest reads exactly three +-- sources -- grass, water, and the three roamers' current map -- and nothing +-- else, so a HEADBUTT-only or fishing-only species legitimately has no nest and +-- the page is blank. That is the cart's answer, not a hole. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 nests") +local check, eq = S.check, S.eq + +local Nests = require("src.core.gen2.Nests") + +-- Landmark indices from constants/landmark_constants.asm: Johto runs below +-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28. +local DATA = { + maps = { + ROUTE_29 = { landmark = 2 }, -- Johto + ILEX_FOREST = { landmark = 11 }, -- Johto + ROUTE_1 = { landmark = 0x2f }, -- Kanto + UNION_CAVE_1F = { landmark = 9 }, -- Johto + NOWHERE = { }, -- no landmark at all + }, + encounters = { + grass = { + ROUTE_29 = { slots = { + MORN = { { species = "PIDGEY" }, { species = "SENTRET" } }, + DAY = { { species = "PIDGEY" } }, + NITE = { { species = "HOOTHOOT" } }, + } }, + ILEX_FOREST = { slots = { + DAY = { { species = "ODDISH" }, { species = "PARAS" } }, + } }, + ROUTE_1 = { slots = { DAY = { { species = "PIDGEY" } } } }, + NOWHERE = { slots = { DAY = { { species = "PIDGEY" } } } }, + }, + water = { + UNION_CAVE_1F = { slots = { DAY = { { species = "TENTACOOL" } } } }, + }, + }, +} + +-- Grass, every time of day. NITE-only HOOTHOOT counts: the cart walks all +-- NUM_GRASSMON * 3 slots without caring which third it is in. +do + eq(#Nests.find(DATA, "HOOTHOOT", "johto"), 1, + "a night-only encounter is still a nest") + eq(Nests.find(DATA, "HOOTHOOT", "johto")[1], 2, "on Route 29's landmark") +end + +-- Several maps, sorted, deduplicated across times of day. +do + local pidgey = Nests.find(DATA, "PIDGEY", "johto") + eq(#pidgey, 1, "PIDGEY appears on one JOHTO landmark") + eq(pidgey[1], 2, "Route 29") + local kanto = Nests.find(DATA, "PIDGEY", "kanto") + eq(#kanto, 1, "and one KANTO landmark") + eq(kanto[1], 0x2f, "Route 1") + eq(#Nests.find(DATA, "PIDGEY"), 2, "both regions when none is asked for") +end + +-- Water counts as well as grass. +do + local t = Nests.find(DATA, "TENTACOOL", "johto") + eq(#t, 1, "a water-only species has a nest") + eq(t[1], 9, "Union Cave") +end + +-- A map with no landmark contributes nothing rather than a nil index. +do + local p = Nests.find(DATA, "PIDGEY", "johto") + for _, index in ipairs(p) do + check(index ~= nil and index > 0, "every returned landmark is a real index") + end +end + +-- Nothing anywhere. +do + eq(#Nests.find(DATA, "MEWTWO", "johto"), 0, "an absent species has no nest") +end + +-- Roamers: reported from their CURRENT map, and by Roamers.active rather than +-- by HP. A fresh roamer sits at hp 0 (`xor a ; generate new stats`), so an HP +-- test would hide all three until you had already met them. +do + local save = { roamers = { + { species = "RAIKOU", map = "ROUTE_29", hp = 0 }, + { species = "ENTEI" }, -- caught: no map, inactive + } } + local r = Nests.find(DATA, "RAIKOU", "johto", save) + eq(#r, 1, "an un-met roamer still shows a nest") + eq(r[1], 2, "at the map it is currently on") + eq(#Nests.find(DATA, "ENTEI", "johto", save), 0, + "a caught roamer shows none") +end + +-- The region split itself. +do + eq(Nests.regionOf(2), "johto", "low indices are Johto") + eq(Nests.regionOf(0x2d), "johto", "up to SILVER_CAVE") + eq(Nests.regionOf(0x2e), "kanto", "PALLET_TOWN starts Kanto") + check(Nests.regionOf(0) == nil, "LANDMARK_SPECIAL is neither") + check(Nests.regionOf(0x5e) == nil, "and neither is FAST_SHIP") +end + +S.finish() diff --git a/tests/gen2_npc_interact_test.lua b/tests/gen2_npc_interact_test.lua new file mode 100644 index 00000000..ccbb2216 --- /dev/null +++ b/tests/gen2_npc_interact_test.lua @@ -0,0 +1,235 @@ +-- Talking to overworld objects: the Route 30 roadblock's Rattata turn to face +-- the player (ObjectEvent is `jumptextfaceplayer`, home/map.asm, and +-- SPRITE_MONSTER is a WALKING_SPRITE so ApplyObjectFacing really turns it), +-- and the talked-to / engaged object holds still for the conversation the way +-- FreezeAllOtherObjects + EndScript's UnfreezeAllObjects bracket it on the +-- cart -- a SPINRANDOM trainer must not roll a new facing under his own +-- sighting text. +-- +-- luajit tests/gen2_npc_interact_test.lua (ROM-free) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 npc interact") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local NPC = require("src.world.gen2.Npc") +local Vm = require("src.script.gen2.Vm") +local Permissions = require("src.world.gen2.Permissions") + +local COLL_FLOOR = 0x00 + +-- The gen2_world_test rig, trimmed: a flat map, a fake player, and NPCs built +-- as NPC-shaped tables (a real NPC.new needs a sprite sheet and a graphics +-- device) so facePlayer / update / scriptFace are the shipped methods. +local MAP_W, MAP_H = 10, 10 + +local function fakeMap() + local map + map = { + id = "TEST_MAP", + width = MAP_W, height = MAP_H, + def = { bgEvents = {}, objects = {}, width = MAP_W, height = MAP_H }, + cellCollision = function() return COLL_FLOOR end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2 + end, + isWalkable = function() return true end, + warpAt = function() return nil end, + } + return map +end + +local function fakePlayer(x, y, facing) + return { + cellX = x, cellY = y, px = x * 16, py = y * 16, + facing = facing, moving = false, turnArmed = true, + update = function() return false end, + setSprite = function() end, + } +end + +local function npcAt(def, x, y, facing, kind) + return setmetatable({ + def = def, id = "npc_" .. tostring(def.index), + cellX = x, cellY = y, homeX = x, homeY = y, + px = x * 16, py = y * 16, + facing = facing, moving = false, progress = 0, stepFlip = false, + frozen = false, kind = kind or "stand", + roamDirs = { "up", "down", "left", "right" }, + radiusX = 0, radiusY = 0, spinLo = 1, spinHi = 1, timer = 1, + }, NPC) +end + +local function talkWorld(opts) + opts = opts or {} + local game = { + data = {}, + save = { player = { name = "GOLD" }, party = opts.party or {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + world.map = fakeMap() + world.maps = { TEST_MAP = world.map.def } + world.player = fakePlayer(opts.px or 4, opts.py or 5, opts.facing or "right") + world.pollTimeOfDay = function() end + local log = {} + world.log = log + world.showText = function(self, body, onDone) + log[#log + 1] = body + self.textbox = true + self.pendingText = function() + self.textbox = nil + if onDone then onDone() end + end + end + world.vm = Vm.new(opts.scripts or {}, opts.texts or {}, world.events, { + showText = function(body, onDone) world:showText(body, onDone) end, + -- The shipped hook body from World:load, restated over this rig: the + -- talked-to NPC turns through the real NPC:facePlayer. + facePlayer = function() + if world.talkNpc and world.player then + world.talkNpc:facePlayer(world.player) + end + end, + showEmote = function() end, + encounterMusic = function() end, + trainerApproach = function(onDone) world:trainerApproach(onDone) end, + lookupTrainer = function() + return { name = "DON", class = 36, member = 1 } + end, + startBattle = function(_, _, onDone) onDone("win") end, + reloadMap = function() end, + }) + return world +end + +local function advanceText(world) + local fn = world.pendingText + world.pendingText = nil + if fn then fn() end +end + +-- ---- the roadblock monsters turn to the player ----------------------------- +-- maps/Route30.asm: both SPRITE_MONSTER rows point at ObjectEvent, one +-- STANDING_DOWN and one STANDING_UP -- they face each other until spoken to. +do + local OBJECT_EVENT = "00:2812" + local monster = { index = 7, sprite = "SPRITE_MONSTER", movement = 7, + scriptKey = OBJECT_EVENT } + local world = talkWorld({ + scripts = { [OBJECT_EVENT] = { + { op = "jumptextfaceplayer", text = "00:2815" }, + } }, + texts = { ["00:2815"] = "Object event." }, + }) + eq(NPC.new and select(1, "x"), "x", "NPC module loaded") + local rattata = npcAt(monster, 5, 5, "up") + world.npcs = { rattata } + world.entities = { world.player, rattata } + check(world:interact(), "A from the west starts the ObjectEvent script") + eq(rattata.facing, "left", "the spoken-to Rattata turns to face the player") + eq(world.log[1], "Object event.", "and its line prints") + check(rattata.frozen, "it holds still while its text is up") + advanceText(world) + for _ = 1, 3 do world:step() end + check(not rattata.frozen, "and is released when the interaction ends") +end + +-- ---- a spinning trainer cannot spin under his own sighting text ------------ +local BEAT_FLAG = 1000 +local record = { event = BEAT_FLAG, class = 36, member = 1, + seenText = "t:seen", winText = "t:win", scriptKey = "s:after" } + +local function trainerWorld(px, py, facing, npcFacing) + local world = talkWorld({ + px = px, py = py, facing = facing, + party = { { species = "RATTATA", hp = 10 } }, + scripts = { ["s:after"] = { { op = "end" } } }, + texts = { + ["t:seen"] = "Instead of a bug\nPOKéMON, I found\va trainer!", + ["t:win"] = "Deary me.", + }, + }) + local don = { index = 4, sprite = "SPRITE_BUG_CATCHER", movement = 10, + trainer = record, sight = 3 } + local npc = npcAt(don, 4, 7, npcFacing, "spin") + world.npcs = { npc } + world.entities = { world.player, npc } + return world, npc +end + +do + -- Player one cell below the trainer, trainer facing down: engagement. + local world, npc = trainerWorld(4, 8, "up", "down") + check(world:checkTrainerBattle(), "inside the cone the trainer engages") + check(npc.frozen, "and freezes for the whole engagement") + local before = npc.facing + npc.timer = 1 + for _ = 1, 30 do world:updatePeople() end + eq(npc.facing, before, "his spin cannot roll a new facing mid-script") + -- Drain the engagement: the emote's 30-frame hold, the seen text, the + -- battle, the after script. updatePeople keeps running under it, which is + -- exactly when an unfrozen spinner would drift. + local drifted = false + for _ = 1, 60 do + world:step() + world:updatePeople() + if world.vm:running() and npc.facing ~= before then drifted = true end + advanceText(world) + end + eq(world.log[1], "Instead of a bug\nPOKéMON, I found\va trainer!", + "the sighting text is the struct's seen text, verbatim") + check(not drifted, "and the spin never rolled while the script ran") + check(not world.vm:running(), "the engagement script finished") + check(world.events:get(BEAT_FLAG), "and the trainer is marked beaten") + for _ = 1, 3 do world:step() end + check(not npc.frozen, "the freeze lifts once everything settles") +end + +do + -- Player BEHIND the trainer (he faces down, player stands above): nothing. + local world = trainerWorld(4, 6, "down", "down") + check(not world:checkTrainerBattle(), + "standing behind his facing never engages") + eq(#world.log, 0, "and no sighting text prints") +end + +do + -- Off his column entirely: nothing, whatever the range. + local world = trainerWorld(6, 8, "up", "down") + check(not world:checkTrainerBattle(), "off the sight line never engages") +end + +do + -- Facing him but beyond sight 3: nothing. + local world, npc = trainerWorld(4, 11, "up", "down") + npc.cellY = 7 + check(not world:checkTrainerBattle(), "past the sight range never engages") +end + +do + -- CheckTrainerEvent is PlayerEvents' FIRST test (engine/overworld/events.asm: + -- 245) and, unlike every arm of CheckTileEvent, it is NOT behind + -- wEnabledPlayerEvents -- MapEvents clears that byte every pass + -- (events.asm:168) and CheckPlayerState only re-sets it on a step that + -- landed (events.asm:210-221). So the sight cone is polled on every + -- overworld frame, not only on the frame a step lands: a spinner that + -- rotates onto a STANDING player engages there and then. Nothing is called + -- directly here -- that is the point, the call SITE is what was wrong. + local world, npc = trainerWorld(4, 8, "up", "up") + world.updatePeople = function() end + world:step() + check(not world.vm:running(), + "a trainer facing away leaves the standing player alone") + npc.facing = "down" + for _ = 1, 3 do world:step() end + check(world.vm:running(), + "and the frame he turns onto the player's line, he engages -- no step needed") + check(npc.frozen, "the engagement freezes him the same way") +end + +S.finish() diff --git a/tests/gen2_oak_speech_test.lua b/tests/gen2_oak_speech_test.lua new file mode 100644 index 00000000..db940a7c --- /dev/null +++ b/tests/gen2_oak_speech_test.lua @@ -0,0 +1,70 @@ +-- Gen 2 Oak speech extract + stub UI smoke against a Gold cache. +-- luajit tests/gen2_oak_speech_test.lua +-- Also dofile'd by tests/run_tests.lua. Skips when no gold cache / oak_speech. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 oak speech") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +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 path = cache .. "/data/generated/oak_speech.lua" +local file = io.open(path, "r") +if not file then + check(true, "gold oak_speech.lua absent : re-import Gold (SKIP)") + S.finish() + return +end +file:close() + +local data = assert(loadfile(path))() +check(data.generation == 2, "oak_speech.generation is 2") +eq(data.music, "Music_Route30", "speech music is Music_Route30") +eq(data.demoSpecies, "MARILL", "demo mon is MARILL") +check(type(data.oakPic) == "string", "oakPic path present") +check(type(data.playerPic) == "string", "playerPic (Cal) path present") +check(type(data.marillPic) == "string", "marillPic path present") + +local texts = data.text or {} +for i = 1, 7 do + local key = ("_OakText%d"):format(i) + check(type(texts[key]) == "string", key .. " extracted") +end +check(texts._OakText1:find("OAK", 1, true), "_OakText1 mentions OAK") +check(texts._OakText2:find("POKé", 1, true) or texts._OakText2:find("MON", 1, true), + "_OakText2 mentions POKéMON") +check(texts._OakText6:find("name", 1, true), "_OakText6 asks for name") +check(texts._OakText7:find("{PLAYER}", 1, true) + or texts._OakText7:find("PLAYER", 1, true), + "_OakText7 addresses player") + +local function assetExists(rel) + local f = io.open(cache .. "/" .. rel, "rb") + if f then f:close() return true end + return false +end +check(assetExists("assets/generated/intro/oak.png"), "intro/oak.png on disk") +check(assetExists("assets/generated/intro/cal.png"), "intro/cal.png on disk") +check(assetExists("assets/generated/battle/front/marill.png"), + "battle/front/marill.png on disk") + +-- Stub UI constructs without LOVE graphics errors under the stub. +local OakSpeech = require("src.ui.gen2.OakSpeech") +local speech = OakSpeech.new({ + save = { player = { name = "GOLD", rival = "???" } }, + data = { tokens = require("src.render.TextBox").TOKENS, audio = {} }, + stack = { push = function() end, pop = function() end, top = function() end }, + input = { wasPressed = function() return false end }, + fontData = nil, +}, { data = data }) +eq(speech.music, "Music_Route30", "UI reads speech music") +check(speech:text("_OakText1"):find("OAK", 1, true), "UI text() serves _OakText1") +check(type(speech:text("_OakText3")) == "string", "_OakText3 is a string (may be empty)") + +S.finish() diff --git a/tests/gen2_obedience_test.lua b/tests/gen2_obedience_test.lua new file mode 100644 index 00000000..c256a726 --- /dev/null +++ b/tests/gen2_obedience_test.lua @@ -0,0 +1,229 @@ +-- Traded-mon obedience: BattleCommand_CheckObedience +-- (engine/battle/effect_commands.asm:642). +-- +-- luajit tests/gen2_obedience_test.lua +-- +-- ROM-free. An outsider mon (OT id differs from wPlayerID) above the +-- badge-gated cap -- 10 bare, 30 with HIVEBADGE, 50 with FOGBADGE, 70 with +-- STORMBADGE, unlimited with RISINGBADGE -- rolls to obey, and past the +-- rolls either uses another move, naps, hits itself or loafs. A native mon +-- never disobeys at any level; the Violet City ROCKY (OT 48926) is the +-- walkthrough's own warning case. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 obedience") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + ROCK_THROW = { id = "ROCK_THROW", name = "ROCK THROW", power = 50, + type = "ROCK", accuracy = 100, pp = 15, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + ONIX = { + id = "ONIX", index = 95, name = "ONIX", + baseStats = { hp = 35, attack = 45, defense = 160, speed = 70, + specialAttack = 30, specialDefense = 45 }, + types = { "ROCK", "ROCK" }, catchRate = 45, baseExp = 108, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + 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", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local PLAYER_ID = 1234 +local ROCKY_OT = 48926 + +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function newBattle(opts) + opts = opts or {} + local mon = Mon.new(DATA, "ONIX", opts.level or 30, { dvs = perfect, + nickname = "ROCKY" }) + mon.otId = opts.otId + mon.moves = { + { id = "ROCK_THROW", pp = 15, maxPp = 15 }, + { id = "TACKLE", pp = opts.tacklePp or 35, maxPp = 35 }, + } + local wild = Mon.new(DATA, "PIDGEY", 10, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { mon }, wild = wild, + save = { player = { id = PLAYER_ID, badges = opts.badges or {} } }, + random = opts.random }) + return battle, mon, wild +end + +-- Any event carrying the line: the move-use line rides a `move` event and +-- the nap a `status` event, so the kind is not filtered. +local function findText(events, text) + for _, event in ipairs(events or {}) do + if event.text == text then return true end + end + return false +end + +-- ---- who is an outsider --------------------------------------------------- +do + local battle, mon = newBattle({ otId = PLAYER_ID }) + eq(battle:isOutsider(mon), false, "the player's own OT id is native") + battle, mon = newBattle({ otId = ROCKY_OT }) + eq(battle:isOutsider(mon), true, "KYLE's 48926 is an outsider") + battle, mon = newBattle({}) + eq(battle:isOutsider(mon), false, + "no recorded OT (the port's own catches) is the player's own") +end + +-- ---- the badge ladder ----------------------------------------------------- +do + local battle = newBattle({}) + eq(battle:obedienceLevel(), 10, "no badges: level 10") + battle = newBattle({ badges = { HIVE = true } }) + eq(battle:obedienceLevel(), 30, "HIVEBADGE: 30") + battle = newBattle({ badges = { HIVE = true, FOG = true } }) + eq(battle:obedienceLevel(), 50, "FOGBADGE: 50") + battle = newBattle({ badges = { STORM = true } }) + eq(battle:obedienceLevel(), 70, "STORMBADGE: 70") + battle = newBattle({ badges = { RISING = true } }) + eq(battle:obedienceLevel(), Mon.MAX_LEVEL + 1, + "RISINGBADGE: nothing ever disobeys") +end + +-- ---- who never disobeys --------------------------------------------------- +do + -- A native mon at any level, whatever the rolls come up. + local battle = newBattle({ otId = PLAYER_ID, level = 80, + random = rolls({}, 255) }) + eq(battle:checkObedience("ROCK_THROW"), false, + "a native mon never checks at all") + + -- An outsider at or under the cap. + battle = newBattle({ otId = ROCKY_OT, level = 30, + badges = { HIVE = true }, random = rolls({}, 255) }) + eq(battle:checkObedience("ROCK_THROW"), false, + "an outsider AT the cap obeys without rolling") + + -- An outsider above the cap whose first roll lands under it. + battle = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 5 }) }) + eq(battle:checkObedience("ROCK_THROW"), false, + "a first roll under the cap obeys") +end + +-- ---- the disobedience ladder ---------------------------------------------- +-- Level 30, no badges: cap 10, limit 40, margin 20. +do + -- Second roll under the cap: use ANOTHER move instead. + local battle, mon, wild = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 39, 5, 0 }, 1) }) + local hpBefore = wild.hp + eq(battle:checkObedience("ROCK_THROW"), true, "the mon went its own way") + -- UsedMoveText breaks after the user's name by construction: _UsedMove1Text + -- is `text_start` + `line "used @"` (data/text/common_2.asm:339). + check(findText(battle.events, "ROCKY\nused TACKLE!"), + "and used the OTHER move") + check(wild.hp < hpBefore, "which really landed") + eq(mon.moves[2].pp, 34, "spending the substituted move's PP") + + -- With no alternative the use-instead arm falls through to loafing. + battle, mon = newBattle({ otId = ROCKY_OT, level = 30, tacklePp = 0, + random = rolls({ 39, 5, 45, 0 }) }) + eq(battle:checkObedience("ROCK_THROW"), true, "still disobeys") + check(findText(battle.events, "ROCKY is loafing around."), + "but can only loaf") + + -- Nap: the margin roll under level - cap. + battle, mon = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 39, 39, 5, 2 }) }) + eq(battle:checkObedience("ROCK_THROW"), true, "napped instead") + eq(mon.status, "sleep", "BeganToNap writes sleep straight in") + eq(mon.statusTurns, 3, "1-7 turns, from the same roll shape as sleep") + check(findText(battle.events, "ROCKY began to nap!"), "with its own line") + + -- Self-hit: the margin roll in the second band. + battle, mon = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 39, 39, 25 }) }) + local before = mon.hp + eq(battle:checkObedience("ROCK_THROW"), true, "won't obey") + check(findText(battle.events, "ROCKY won't obey!"), "says so") + check(findText(battle.events, "It hurt itself in its confusion!"), + "and hits itself (HitConfusion)") + check(mon.hp < before, "for real damage") + + -- Loafing: past both margin bands, one of the four lines. + battle, mon = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 39, 39, 45, 2 }) }) + eq(battle:checkObedience("ROCK_THROW"), true, "loafed") + check(findText(battle.events, "ROCKY turned away!"), + "line 2 of the four-way pick") +end + +-- ---- through the real turn ------------------------------------------------ +do + -- The disobedient turn is spent: the enemy still moves, ROCK THROW's PP + -- is untouched, and nothing hit the enemy. The queue's first roll feeds + -- the wild AI's own move pick, which runs before the player's half. + local battle, mon, wild = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({ 0, 39, 39, 45, 2 }, 1) }) + local hpBefore = wild.hp + local events = battle:takeTurn({ kind = "move", move = "ROCK_THROW" }) + check(findText(events, "ROCKY turned away!"), "the turn opens on the loaf") + eq(mon.moves[1].pp, 15, "no PP left the disobeyed move") + eq(wild.hp, hpBefore, "and the enemy was never touched") + check(findText(events, "Wild PIDGEY\nused TACKLE!") + or findText(events, "PIDGEY\nused TACKLE!"), + "while the enemy's half of the turn still ran") + + -- The second half of a charge move is exempt (CheckUserIsCharging): a + -- stored charge always lands. + battle, mon = newBattle({ otId = ROCKY_OT, level = 30, + random = rolls({}, 255) }) + battle:volatile(mon).chargeMove = "ROCK_THROW" + eq(battle:checkObedience("ROCK_THROW"), false, + "a charging mon is not checked") +end + +S.finish() diff --git a/tests/gen2_object_event_test.lua b/tests/gen2_object_event_test.lua new file mode 100644 index 00000000..76aad00e --- /dev/null +++ b/tests/gen2_object_event_test.lua @@ -0,0 +1,141 @@ +-- home/map.asm ObjectEvent, the generic "Object event." line an object with no +-- script of its own says. It is a ROM0 body, so every map names it with the +-- same sub-$4000 `dw` and the extractor's map walk (which only ever queues +-- banked pointers) used to drop all 44 of them on the floor: the objects kept +-- a scriptKey in their own map's script bank, where nothing was disassembled, +-- and talking to one did nothing at all. +-- +-- ROM-free: `luajit tests/gen2_object_event_test.lua`. The cache section at +-- the bottom SKIPs when no Gold cache is present, and again when the cache +-- predates the extractor change (a re-import is what fills it in). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 object event") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local Opcodes = require("src.script.gen2.Opcodes") + +-- ---- the key a ROM0 body gets --------------------------------------------- +-- pokegold.sym: 00:2812 ObjectEvent, 00:2815 ObjectEventText. Bank 0 is not a +-- banked script bank, it is the home bank every other bank can see, so the one +-- key is shared by every map rather than repeated per map bank. +local OBJECT_EVENT = Opcodes.key(0, 0x2812) +local OBJECT_EVENT_TEXT = Opcodes.key(0, 0x2815) +eq(OBJECT_EVENT, "00:2812", "ObjectEvent keys at bank 0") +eq(OBJECT_EVENT_TEXT, "00:2815", "and so does the text behind it") + +-- ---- the VM runs a bank 0 key like any other ------------------------------ +-- home/map.asm: `ObjectEvent: jumptextfaceplayer ObjectEventText`, so the body +-- is one command, it turns the object toward the player, and it ends there. +do + local shown, faced = {}, {} + local scripts = { + generation = 2, + [OBJECT_EVENT] = { + { op = "jumptextfaceplayer", text = OBJECT_EVENT_TEXT }, + }, + } + local texts = { [OBJECT_EVENT_TEXT] = "Object event." } + local vm = Vm.new(scripts, texts, Events.new(), { + showText = function(body, onDone) + shown[#shown + 1] = body + onDone() + end, + facePlayer = function() faced[#faced + 1] = true end, + }) + check(vm:start(OBJECT_EVENT), "a bank 0 script key starts") + for _ = 1, 4 do vm:update() end + check(not vm:running(), "jumptextfaceplayer is a terminator") + eq(shown[1], "Object event.", "the shared line prints") + check(#faced >= 1, "and the object faces the player first") + + -- The regression itself: the same pointer keyed at the MAP's bank is not in + -- the table, and Vm:start answers false rather than saying anything. + check(not vm:start(Opcodes.key(0x42, 0x2812)), + "the same address keyed at a map bank resolves to nothing") +end + +-- ---- the extractor half ---------------------------------------------------- +-- Read as source: the walk itself needs the ROM, so what is checked here is +-- that the ROM0 arm is still wired, both as a seed from the symbol and as the +-- normalisation an object_event pointer goes through. +do + local f = assert(io.open("src/import/RomExtractorGen2.lua", "r")) + local src = f:read("*a") + f:close() + check(src:find("local function enqueueHome(address)", 1, true) ~= nil, + "the extractor has a ROM0 queue arm") + check(src:find("self.symbols.ObjectEvent", 1, true) ~= nil, + "and seeds ObjectEvent from the symbol table") + check(src:find("obj.scriptKey = enqueueHome(obj.script)", 1, true) ~= nil, + "an object_event pointer goes through it before its map bank") + -- enqueue proper must keep refusing bank 0: a farscall or a map pointer that + -- decodes to ROM0 is noise, and only the two call sites above know better. + check(src:find("-- Scripts live in banked ROM, not ROM0.", 1, true) ~= nil, + "the banked queue still refuses ROM0 pointers") +end + +-- ---- the cache ------------------------------------------------------------- +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 mapsFile = io.open(cache .. "/data/generated/maps.lua", "r") +local scriptsFile = io.open(cache .. "/data/generated/scripts.lua", "r") +if not mapsFile or not scriptsFile then + if mapsFile then mapsFile:close() end + if scriptsFile then scriptsFile:close() end + check(true, "gold cache absent : unit checks only (SKIP cache facts)") + S.finish() + return +end +mapsFile:close(); scriptsFile:close() + +local maps = assert(loadfile(cache .. "/data/generated/maps.lua"))() +local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))() +local texts = assert(loadfile(cache .. "/data/generated/text.lua"))() + +-- Every object that names the shared line, and every one that names it the old +-- way (its own map's bank plus the ROM0 address). +local shared, stale = 0, 0 +for _, def in pairs(maps) do + if type(def) == "table" then + for _, obj in ipairs(def.objects or {}) do + if obj.scriptKey == OBJECT_EVENT then + shared = shared + 1 + elseif type(obj.scriptKey) == "string" + and obj.scriptKey:find(":2812", 1, true) then + stale = stale + 1 + end + end + end +end + +if shared == 0 and stale > 0 then + check(true, ("cache predates the ROM0 seed (%d stale rows) : re-import " .. + "for the shared line (SKIP)"):format(stale)) + S.finish() + return +end + +-- 44 object_events across pokegold/maps/ point at ObjectEvent; the one that is +-- OBJECTTYPE_TRAINER (BurnedTower1F's rival) reads the pointer as a `trainer` +-- struct instead, so the count of SCRIPT rows is one short of that. +check(shared >= 40, ("%d objects share the ObjectEvent line"):format(shared)) +eq(stale, 0, "no object is left pointing into its own bank at $2812") + +local body = scripts[OBJECT_EVENT] +check(type(body) == "table" and #body >= 1, "the shared body was disassembled") +if type(body) == "table" and body[1] then + eq(body[1].op, "jumptextfaceplayer", "and it is the one jumptextfaceplayer") + local line = body[1].text and texts[body[1].text] + check(type(line) == "string" and line:find("Object event", 1, true) ~= nil, + "whose text decoded through TX_FAR into _ObjectEventText") +end + +S.finish() diff --git a/tests/gen2_object_hours_test.lua b/tests/gen2_object_hours_test.lua new file mode 100644 index 00000000..95d391f9 --- /dev/null +++ b/tests/gen2_object_hours_test.lua @@ -0,0 +1,180 @@ +-- Object hour windows: CheckObjectTime (pokegold home/map_objects.asm), the +-- half of LoadObjectMasks that CheckObjectFlag is not. An object_event's two +-- hour bytes (macros/scripts/maps.asm) can hide it by hour range or by +-- MORN/DAY/NITE mask, and a port that ignores them stacks every time-shift +-- variant of an NPC on the map at once: three Moms in the player's kitchen, +-- two pharmacists on one Game Corner tile, and a Mt Moon gift shop that is +-- never unattended. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_object_hours_test.lua +-- +-- The window semantics are ROM-free; the map sections SKIP without a cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 object hours") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") + +local function worldAtHour(hour) + local world = World.new({ data = {}, save = { player = {} } }) + world.clockHour = hour + return world +end + +-- ---- the time-of-day mask (GetTimeOfDay, engine/rtc/rtc.asm) --------------- +-- 0400-0959 morn, 1000-1759 day, 1800-0359 nite; MORN 1 / DAY 2 / NITE 4 are +-- the shift_const values in constants/ram_constants.asm. +eq(worldAtHour(4):clockTimeMask(), 1, "04:00 is MORN") +eq(worldAtHour(9):clockTimeMask(), 1, "09:00 is still MORN") +eq(worldAtHour(10):clockTimeMask(), 2, "10:00 is DAY") +eq(worldAtHour(17):clockTimeMask(), 2, "17:00 is still DAY") +eq(worldAtHour(18):clockTimeMask(), 4, "18:00 is NITE") +eq(worldAtHour(3):clockTimeMask(), 4, "03:00 is NITE too, wrapping midnight") + +-- ---- CheckObjectTime, arm by arm ------------------------------------------- +local function visible(hour, h1, h2) + return worldAtHour(hour):objectTimeVisible({ hours = { h1, h2 } }) +end + +-- h1 == -1: h2 is a MORN/DAY/NITE bitmask, -1 always appears. +check(visible(12, -1, -1), "hours {-1,-1} always appears") +check(worldAtHour(12):objectTimeVisible({}), "no hours field always appears") +check(visible(6, -1, 1), "MORN mask shows at 06:00") +check(not visible(12, -1, 1), "MORN mask hides at noon") +check(not visible(20, -1, 1), "and at night") +check(visible(12, -1, 2), "DAY mask shows at noon") +check(not visible(6, -1, 2), "DAY mask hides in the morning") +check(visible(20, -1, 4), "NITE mask shows at 20:00") +check(visible(3, -1, 4), "NITE mask shows at 03:00 across midnight") +check(not visible(12, -1, 4), "NITE mask hides at noon") +check(visible(6, -1, 5), "a MORN|NITE combo shows in the morning") +check(visible(20, -1, 5), "and at night") +check(not visible(12, -1, 5), "but not in the day") + +-- h1 < h2: the object appears from h1 to h2, inclusive on both ends +-- (.check_timeofday's `cp` pair in CheckObjectTime). +check(visible(8, 8, 17), "window 8-17 shows at 08:00") +check(visible(17, 8, 17), "and at 17:00") +check(visible(12, 8, 17), "and in between") +check(not visible(7, 8, 17), "but not at 07:00") +check(not visible(18, 8, 17), "nor at 18:00") + +-- h1 > h2: the object does NOT appear strictly between h2 and h1 -- it shows +-- at both endpoints and outside them (.check_hour's fallthrough arm). +check(visible(20, 20, 6), "inverted 20-6 shows at 20:00") +check(visible(23, 20, 6), "and at 23:00") +check(visible(3, 20, 6), "and at 03:00") +check(visible(6, 20, 6), "and at the 06:00 endpoint") +check(not visible(12, 20, 6), "but hides at noon") +check(not visible(19, 20, 6), "and at 19:00") + +-- h1 == h2: always. +check(visible(0, 9, 9), "equal hours always appear") +check(visible(23, 9, 9), "at any hour") + +-- ---- the spawn filter riding the real rebuildPeople ------------------------ +-- The pooling half is stubbed (a sprite sheet needs a graphics device); the +-- filter chain -- hiddenObjects, then the event flag, then the hour window -- +-- is the shipped code. +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP map spawn checks)") + S.finish() + return +end +probe:close() + +local maps = assert(loadfile(cache .. "/data/generated/maps.lua"))() + +local function spawnWorld(mapId, hour, events) + local world = worldAtHour(hour) + world.maps = maps + world.map = { id = mapId, def = maps[mapId] } + world.neighbors = {} + for _, id in ipairs(events or {}) do world.events:set(id, true) end + world.pooledNpc = function(_, ownerMap, obj) + return { def = obj, id = ownerMap .. ":" .. tostring(obj.index) } + end + world:rebuildPeople() + return world +end + +local function countSprites(world, sprite) + local n = 0 + for _, npc in ipairs(world.npcs) do + if npc.def.sprite == sprite then n = n + 1 end + end + return n +end + +-- PLAYERS_HOUSE_1F: MOM1 is the intro Mom (EVENT_PLAYERS_HOUSE_MOM_1, 1735); +-- MOM2/3/4 share EVENT_PLAYERS_HOUSE_MOM_2 (1736) and split the day between +-- them with MORN/DAY/NITE masks (maps/PlayersHouse1F.asm). After the intro +-- (1735 set, 1736 clear) exactly ONE Mom may stand in the kitchen. +for _, probeHour in ipairs({ 6, 12, 20 }) do + local world = spawnWorld("PLAYERS_HOUSE_1F", probeHour, { 1735 }) + eq(countSprites(world, "SPRITE_MOM"), 1, + ("one Mom at %02d:00, not three"):format(probeHour)) +end + +-- Before the intro (1736 set, 1735 clear) the intro Mom is the one Mom. +local intro = spawnWorld("PLAYERS_HOUSE_1F", 12, { 1736 }) +eq(countSprites(intro, "SPRITE_MOM"), 1, "the intro house has one Mom too") +eq(intro.npcs[1] and intro.npcs[1].def.index, 1, "and it is MOM1") + +-- GOLDENROD_GAME_CORNER: the pharmacist is defined twice on tile (8,7), one +-- DAY one NITE (maps/GoldenrodGameCorner.asm), so he must be single all day +-- and absent in the morning. +eq(countSprites(spawnWorld("GOLDENROD_GAME_CORNER", 12), "SPRITE_PHARMACIST"), + 1, "one pharmacist in the day") +eq(countSprites(spawnWorld("GOLDENROD_GAME_CORNER", 20), "SPRITE_PHARMACIST"), + 1, "one pharmacist at night") +eq(countSprites(spawnWorld("GOLDENROD_GAME_CORNER", 6), "SPRITE_PHARMACIST"), + 0, "and none in the morning") + +-- MOUNT_MOON_GIFT_SHOP: a MORN pair and a DAY pair of clerk + lass, no NITE +-- staff at all -- the shop is unattended at night. +local function giftShopStaff(hour) + local world = spawnWorld("MOUNT_MOON_GIFT_SHOP", hour) + return #world.npcs +end +eq(giftShopStaff(6), 2, "gift shop: morning shift is two people") +eq(giftShopStaff(14), 2, "day shift is two people") +eq(giftShopStaff(22), 0, "and nobody minds the shop at night") + +-- ---- the respawn on a clock rollover --------------------------------------- +-- On the cart a map load recomputes wObjectMasks; the port's stand-in is the +-- once-a-second palette poll noticing the hour moved and rebuilding people. +do + local world = spawnWorld("GOLDENROD_GAME_CORNER", 17) + local rebuilt = 0 + world.applyPalettes = function() return false end + local realRebuild = world.rebuildPeople + world.rebuildPeople = function(self, opts) + rebuilt = rebuilt + 1 + return realRebuild(self, opts) + end + for _ = 1, 60 do world:pollTimeOfDay() end + eq(rebuilt, 0, "a poll with the clock still on 17:00 rebuilds nothing") + world.clockHour = 18 + for _ = 1, 60 do world:pollTimeOfDay() end + eq(rebuilt, 1, "the poll that sees 18:00 rebuilds the people") + eq(countSprites(world, "SPRITE_PHARMACIST"), 1, + "and the night pharmacist clocks in") + local night + for _, npc in ipairs(world.npcs) do + if npc.def.sprite == "SPRITE_PHARMACIST" then night = npc.def end + end + eq(night and night.hours and night.hours[2], 4, + "specifically the NITE row of the pair") +end + +S.finish() diff --git a/tests/gen2_palettes_test.lua b/tests/gen2_palettes_test.lua new file mode 100644 index 00000000..9d5999a2 --- /dev/null +++ b/tests/gen2_palettes_test.lua @@ -0,0 +1,397 @@ +-- Gen 2 GBC palette resolution (src/world/gen2/Palettes.lua). +-- +-- ROM-free: the fixture below is the shape data/generated/palettes.lua has, +-- with values traceable back to pokegold so a wrong answer names the ASM it +-- disagrees with rather than just failing. + +package.path = "./?.lua;" .. package.path + +local Palettes = require("src.world.gen2.Palettes") +-- GbcPalette only reaches for love inside its shader accessors, so the pure +-- rBGP half below is testable exactly the way Palettes is. +local GbcPalette = require("src.render.GbcPalette") +-- Chrome.throughPalette is pure too (it is the fold printThrough draws +-- with, split out so this does not need a real shader); Font is required +-- transitively but never called down here. +love = love or {} +love.graphics = love.graphics or {} +local Chrome = require("src.ui.gen2.Chrome") + +local failures = 0 +local checks = 0 + +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function checkColor(name, got, r, g, b) + checks = checks + 1 + if not got then + failures = failures + 1 + print(("FAIL %s: no color"):format(name)) + return + end + if got[1] ~= r or got[2] ~= g or got[3] ~= b then + failures = failures + 1 + print(("FAIL %s: got %s,%s,%s want %d,%d,%d"):format( + name, tostring(got[1]), tostring(got[2]), tostring(got[3]), r, g, b)) + end +end + +-- engine/rtc/rtc.asm TimesOfDay: 0400-0959 morn, 1000-1759 day, else nite. +check("hour 0", Palettes.clockDaytime(0), "NITE") +check("hour 3", Palettes.clockDaytime(3), "NITE") +check("hour 4", Palettes.clockDaytime(4), "MORN") +check("hour 9", Palettes.clockDaytime(9), "MORN") +check("hour 10", Palettes.clockDaytime(10), "DAY") +check("hour 17", Palettes.clockDaytime(17), "DAY") +check("hour 18", Palettes.clockDaytime(18), "NITE") +check("hour 23", Palettes.clockDaytime(23), "NITE") + +-- Two BG pool entries plus one shared roof pool entry, laid out the way +-- gfx/tilesets/bg_tiles.pal is: slot index 7 (1-based 7) is PAL_BG_ROOF. +local function pool(a, b, c, d) + return { { a, a, a }, { b, b, b }, { c, c, c }, { d, d, d } } +end + +local data = { + roofSlot = 7, + bg = { + pool(10, 11, 12, 13), -- 1: morn gray + pool(20, 21, 22, 23), -- 2 + pool(30, 31, 32, 33), -- 3 + pool(40, 41, 42, 43), -- 4 + pool(50, 51, 52, 53), -- 5 + pool(60, 61, 62, 63), -- 6 + pool(70, 71, 72, 73), -- 7: roof + pool(80, 81, 82, 83), -- 8: text + pool(90, 91, 92, 93), -- 9: day gray + }, + environments = { + TOWN = { + MORN = { 1, 2, 3, 4, 5, 6, 7, 8 }, + DAY = { 9, 2, 3, 4, 5, 6, 7, 8 }, + NITE = { 1, 2, 3, 4, 5, 6, 7, 8 }, + DARK = { 1, 2, 3, 4, 5, 6, 7, 8 }, + }, + INDOOR = { + MORN = { 2, 2, 2, 2, 2, 2, 2, 8 }, + DAY = { 2, 2, 2, 2, 2, 2, 2, 8 }, + NITE = { 3, 3, 3, 3, 3, 3, 3, 8 }, + DARK = { 4, 4, 4, 4, 4, 4, 4, 8 }, + }, + }, + objects = { + MORN = { pool(1, 2, 3, 4) }, + DAY = { pool(5, 6, 7, 8) }, + NITE = { pool(9, 10, 11, 12) }, + DARK = { pool(13, 14, 15, 16) }, + }, + roofs = { + [24] = { + mornDay = { { 100, 101, 102 }, { 110, 111, 112 } }, + nite = { { 120, 121, 122 }, { 130, 131, 132 } }, + }, + }, + pokemon = { + CYNDAQUIL = { + normal = { { 255, 222, 0 }, { 255, 58, 41 } }, + shiny = { { 1, 2, 3 }, { 4, 5, 6 } }, + }, + }, + trainers = { PLAYER = { { 206, 148, 99 }, { 90, 80, 70 } } }, +} + +local newBark = { + id = "NEW_BARK_TOWN", group = 24, environment = "TOWN", + palette = "PALETTE_AUTO", +} +local elmsLab = { + id = "ELMS_LAB", group = 24, environment = "INDOOR", + palette = "PALETTE_DAY", +} +local darkCave = { + id = "DARK_CAVE", group = 3, environment = "CAVE", + palette = "PALETTE_DARK", +} + +-- PALETTE_AUTO follows the clock; a pinned map does not. +check("town at 2am", Palettes.daytimeFor(newBark, 2), "NITE") +check("town at noon", Palettes.daytimeFor(newBark, 12), "DAY") +check("lab pinned DAY at 2am", Palettes.daytimeFor(elmsLab, 2), "DAY") +-- PALETTE_DARK is blackout until FLASH (ReplaceTimeOfDayPals.UsedFlash). +check("dark cave", Palettes.daytimeFor(darkCave, 12), "DARK") +check("dark cave with flash", Palettes.daytimeFor(darkCave, 12, true), "NITE") + +-- Slot 0 differs between morn and day, which is the whole point of the table. +local morn = Palettes.bgSet(data, newBark, "MORN") +local day = Palettes.bgSet(data, newBark, "DAY") +checkColor("town morn slot1 color0", morn[1][1], 10, 10, 10) +checkColor("town day slot1 color0", day[1][1], 90, 90, 90) + +-- RoofPals overwrites PAL_BG_ROOF colors 1 and 2 only; 0 and 3 stay pooled. +checkColor("roof color0 pooled", morn[7][1], 70, 70, 70) +checkColor("roof color1 override", morn[7][2], 100, 101, 102) +checkColor("roof color2 override", morn[7][3], 110, 111, 112) +checkColor("roof color3 pooled", morn[7][4], 73, 73, 73) +local nite = Palettes.bgSet(data, newBark, "NITE") +checkColor("roof nite color1", nite[7][2], 120, 121, 122) + +-- LoadMapPals only reaches the roof override for TOWN/ROUTE; indoors keeps +-- the pool entry even though the map group has roof colors. +local indoor = Palettes.bgSet(data, elmsLab, "DAY") +checkColor("indoor roof slot untouched", indoor[7][2], 21, 21, 21) + +-- The pool is shared, so a mutating bug shows up as morn picking up day's +-- roof; re-resolving must give the original color back. +local mornAgain = Palettes.bgSet(data, newBark, "MORN") +checkColor("bgSet does not mutate the pool", mornAgain[7][1], 70, 70, 70) + +-- An unmodeled environment falls through to the outdoor table rather than nil. +local cave = Palettes.bgSet(data, darkCave, "DARK") +check("cave falls back to outdoor", cave ~= nil, true) + +-- Sprite palettes: paletteId is 0-based PAL_OW_*, so id 0 is entry 1. +local spr = Palettes.spritePalette(data, "DAY", { paletteId = 0 }) +checkColor("sprite day pal", spr[1], 5, 5, 5) +local byName = Palettes.spritePalette(data, "NITE", { palette = "PAL_OW_RED" }) +checkColor("sprite by name", byName[1], 9, 9, 9) + +-- PAL_OW_EMOTE (constants/sprite_data_constants.asm:23) is id 5, the "silver" +-- row of gfx/overworld/npc_sprites.pal, and it is byte-identical in all four +-- daytime blocks (lines 7, 17, 27, 37): RGB 31,31,31 / 31,31,31 / 13,13,13 / +-- 00,00,00, which the extractor's scale5 turns into 255 / 255 / 107 / 0. +-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) puts the "!" +-- bubble on it, so the bubble's interior is WHITE at every hour -- the port +-- used to blit the raw 2bpp sheet and left it at DMG shade 1 (#505 again). +local emoteRow = { { 255, 255, 255 }, { 255, 255, 255 }, { 107, 107, 107 }, + { 0, 0, 0 } } +local emoteData = { objects = {} } +for _, when in ipairs({ "MORN", "DAY", "NITE", "DARK" }) do + local set = {} + for id = 1, 8 do set[id] = pool(id, id, id, id) end + set[6] = emoteRow + emoteData.objects[when] = set + local got = Palettes.spritePalette(emoteData, when, { paletteId = 5 }) + checkColor("emote interior is white at " .. when, got and got[2], 255, 255, 255) + checkColor("emote mid shade at " .. when, got and got[3], 107, 107, 107) + checkColor("emote outline at " .. when, got and got[4], 0, 0, 0) +end + +-- Pic palettes bracket the two shipped colors with white and black. +local mon = Palettes.monColors(data, "CYNDAQUIL") +checkColor("mon color0 white", mon[1], 255, 255, 255) +checkColor("mon color1", mon[2], 255, 222, 0) +checkColor("mon color2", mon[3], 255, 58, 41) +checkColor("mon color3 black", mon[4], 0, 0, 0) +local shiny = Palettes.monColors(data, "CYNDAQUIL", true) +checkColor("shiny color1", shiny[2], 1, 2, 3) +check("unknown species has no palette", + Palettes.monColors(data, "MISSINGNO"), nil) + +local trainer = Palettes.trainerColors(data, "PLAYER") +checkColor("player trainer color1", trainer[2], 206, 148, 99) + +-- A cache with no palettes.lua must degrade, not crash. +check("nil data bgSet", Palettes.bgSet(nil, newBark, "DAY"), nil) +check("nil data objectSet", Palettes.objectSet(nil, "DAY"), nil) +check("nil data monColors", Palettes.monColors(nil, "CYNDAQUIL"), nil) + +-------------------------------------------------------------------------- +-- rBGP (src/render/GbcPalette.lua) +-------------------------------------------------------------------------- +-- +-- The register is four 2-bit shade indices with colour 0 in the LOW bits, and +-- CopyPals (home/palettes.asm) uses it to REORDER a palette's four entries +-- rather than to dim them. Everything below is that reorder, with no love and +-- no shader: a byte and a palette in, the permuted palette out. + +-- A palette whose entries are trivially distinguishable, so a wrong shade +-- shows up as a wrong number rather than as a near miss. +local ramp = { { 0, 0, 0 }, { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 } } + +local function checkShades(name, byte, s0, s1, s2, s3) + local got = GbcPalette.bgpShades(byte) + check(name .. " colour 0", got[1], s0) + check(name .. " colour 1", got[2], s1) + check(name .. " colour 2", got[3], s2) + check(name .. " colour 3", got[4], s3) +end + +-- `dc 3, 2, 1, 0` packs colour 3 first, so the identity is %11100100. +check("BGP_IDENTITY is $e4", GbcPalette.BGP_IDENTITY, 0xe4) +checkShades("$e4", 0xe4, 0, 1, 2, 3) +checkShades("$ff", 0xff, 3, 3, 3, 3) +checkShades("$00", 0x00, 0, 0, 0, 0) +-- LoadTitleScreenPals: colours 1 and 2 swap, which is why the title sky (BG +-- colour 2) is baked at shade 1. +checkShades("$d8 title", 0xd8, 0, 2, 1, 3) + +-- The identity returns the palette itself: the common case allocates nothing. +check("identity is a pass-through", GbcPalette.remap(ramp, 0xe4), ramp) +check("no byte is a pass-through", GbcPalette.remap(ramp, nil), ramp) +check("nil palette stays nil", GbcPalette.remap(nil, 0xff), nil) + +-- StartTrainerBattle_Flash's .pals endpoints: `dc 3, 3, 3, 3` is every colour +-- showing shade 3, `dc 0, 0, 0, 0` every colour showing shade 0. On a CGB +-- those are the palette's OWN entries 3 and 0, which is exactly the fact the +-- old brightness veil could not represent -- it drew them as flat black and +-- flat white for every palette alike. +local black = GbcPalette.remap(ramp, 0xff) +checkColor("$ff colour 0", black[1], 3, 3, 3) +checkColor("$ff colour 3", black[4], 3, 3, 3) +local white = GbcPalette.remap(ramp, 0x00) +checkColor("$00 colour 0", white[1], 0, 0, 0) +checkColor("$00 colour 3", white[4], 0, 0, 0) + +-- `dc 3, 3, 2, 1` = $f9, the table's first row: one step darker along the +-- palette's own ramp, with colour 3 already at the bottom. +local darker = GbcPalette.remap(ramp, 0xf9) +checkColor("$f9 colour 0", darker[1], 1, 1, 1) +checkColor("$f9 colour 1", darker[2], 2, 2, 2) +checkColor("$f9 colour 2", darker[3], 3, 3, 3) +checkColor("$f9 colour 3", darker[4], 3, 3, 3) + +-- `dc 2, 1, 0, 0` = $90, the light half of the same table. +local lighter = GbcPalette.remap(ramp, 0x90) +checkColor("$90 colour 0", lighter[1], 0, 0, 0) +checkColor("$90 colour 1", lighter[2], 0, 0, 0) +checkColor("$90 colour 2", lighter[3], 1, 1, 1) +checkColor("$90 colour 3", lighter[4], 2, 2, 2) + +-- The title byte on a real palette: colours 1 and 2 trade places and the ends +-- stay put. +local swapped = GbcPalette.remap(mon, 0xd8) +checkColor("$d8 keeps white", swapped[1], 255, 255, 255) +checkColor("$d8 swaps 1 for 2", swapped[2], 255, 58, 41) +checkColor("$d8 swaps 2 for 1", swapped[3], 255, 222, 0) +checkColor("$d8 keeps black", swapped[4], 0, 0, 0) + +-- The remap is a pure function of its inputs: the source palette is never +-- written through, which matters because bgSet hands out shared pool copies. +checkColor("remap does not mutate", mon[2], 255, 222, 0) + +-- The active byte GbcPalette.use folds in. setBgp stores the identity as nil, +-- so a screen that resets to $e4 leaves nothing standing for the next one. +check("setBgp returns the previous byte", GbcPalette.setBgp(0xff), nil) +check("setBgp stores the byte", GbcPalette.bgp, 0xff) +check("setBgp($e4) clears", GbcPalette.setBgp(0xe4), 0xff) +check("identity is stored as nil", GbcPalette.bgp, nil) + +-- GbcPalette.color is the direct-read seam, so it has to take the byte too: +-- otherwise a fill behind text keeps its cart colour while the tiles over it +-- move. +GbcPalette.setBgp(0xff) +checkColor("direct read takes the byte", GbcPalette.color(mon, 1), 0, 0, 0) +GbcPalette.setBgp(nil) +checkColor("direct read without a byte", GbcPalette.color(mon, 1), 255, 255, 255) + +-- Chrome.printThrough used to build its draw palette from GbcPalette.resolve +-- alone and never fold the active rBGP byte in, latent until a second screen +-- left a byte standing when chrome text drew. Chrome.throughPalette is the +-- fold printThrough actually draws with; here with no shader involved. +GbcPalette.setBgp(0xff) +local through = Chrome.throughPalette(ramp) +checkColor("printThrough colour0 takes the byte", through[1], 3, 3, 3) +checkColor("printThrough colour3 takes the byte", through[4], 3, 3, 3) +-- $d8 (`dc 3, 1, 2, 0`... colours 1 and 2 swap) applied to the un-inverted +-- palette: invert has to run first, so the swap lands on the substituted +-- entries, not the caller's original ones. +GbcPalette.setBgp(0xd8) +local inverted = Chrome.throughPalette(mon, true) +-- Un-inverted mon is white,255/222/0,255/58/41,black; inverted (before the +-- fold) is black,255/58/41,255/222/0,white; $d8 then swaps colours 1 and 2. +checkColor("invert then fold keeps colour0", inverted[1], 0, 0, 0) +checkColor("invert then fold swaps 1 for 2", inverted[2], 255, 222, 0) +checkColor("invert then fold swaps 2 for 1", inverted[3], 255, 58, 41) +checkColor("invert then fold keeps colour3", inverted[4], 255, 255, 255) +GbcPalette.setBgp(nil) +-- With no byte standing, the fold is a no-op and the old un-folded behaviour +-- still holds -- the regression only shows once a second screen leaves a +-- byte set behind it. +local identity = Chrome.throughPalette(ramp) +checkColor("no byte is unfolded", identity[1], 0, 0, 0) +checkColor("no byte is unfolded end", identity[4], 3, 3, 3) + +-------------------------------------------------------------------------- +-- The backwards pass, for frames that are already drawn +-------------------------------------------------------------------------- +-- +-- A baked canvas has no shade index left in it, so the shader matches a pixel +-- to the palette entry that produced it. remapTable is where that stops being +-- exact: two palettes can hold the same colour at different entries, and the +-- finished frame cannot say which one this pixel was. + +-- One palette: the four entries are the only colours the texture can hold, so +-- there is nothing to be ambiguous about. +local src, dst, count, ambiguous = GbcPalette.remapTable({ ramp }, 0xff) +check("one palette is four colours", count, 4) +check("one palette is unambiguous", ambiguous, 0) +checkColor("src keeps entry 0", src[1], 0, 0, 0) +checkColor("dst sends entry 0 to entry 3", dst[1], 3, 3, 3) +check("the array is padded to REMAP_MAX", #src, GbcPalette.REMAP_MAX) +check("both arrays are padded alike", #dst, GbcPalette.REMAP_MAX) + +-- Two palettes that share their ends -- white at entry 0 and black at entry 3, +-- which nearly every BG palette does -- dedupe to one source colour each, and +-- under a byte that leaves those two entries where they are they agree about +-- the answer. $fc (`dc 3, 3, 3, 0`) is one of those. +local other = { { 0, 0, 0 }, { 9, 9, 9 }, { 8, 8, 8 }, { 3, 3, 3 } } +local _, _, count2, ambiguous2 = GbcPalette.remapTable({ ramp, other }, 0xfc) +check("shared ends dedupe", count2, 6) +check("shared ends agree under $fc", ambiguous2, 0) + +-- The honest limit, and it is not a corner case: under $f9 entry 0 moves to +-- entry 1, and two palettes that share a white at entry 0 have DIFFERENT +-- colours at entry 1. A finished frame cannot say which palette drew this +-- white pixel, so one answer is picked and the disagreement is counted. +local _, dst2, _, ambiguous2b = GbcPalette.remapTable({ ramp, other }, 0xf9) +check("a shared colour with two answers is ambiguous", ambiguous2b, 1) +-- First writer wins, and BG palettes are walked in slot order. +checkColor("the first palette's answer stands", dst2[1], 1, 1, 1) + +-- The same thing one step over: a colour that is entry 1 of one palette and +-- entry 2 of another. Sharing a colour is not itself ambiguity; disagreeing +-- about where the byte sends it is. +local clash = { { 5, 5, 5 }, { 7, 7, 7 }, { 1, 1, 1 }, { 3, 3, 3 } } +local _, dst3, count3, ambiguous3 = GbcPalette.remapTable({ ramp, clash }, 0xf9) +check("a colour at two entries dedupes", count3, 6) +check("a colour at two entries is ambiguous", ambiguous3, 1) +checkColor("the first entry's answer stands", dst3[2], 2, 2, 2) + +-- OBJ palettes map to themselves: DmgToCgbBGPals never touches them, so their +-- colours are in the table only to keep a sprite from being matched onto a BG +-- entry and swept along with the flash. +local objOnly = { { 40, 40, 40 }, { 41, 41, 41 }, { 42, 42, 42 }, { 43, 43, 43 } } +local src4, dst4, count4 = GbcPalette.remapTable({ ramp }, 0xff, { objOnly }) +check("obj colours are added", count4, 8) +checkColor("obj src", src4[5], 40, 40, 40) +checkColor("obj dst is itself", dst4[5], 40, 40, 40) + +-- Nothing to match means nothing to draw through: the caller falls back. +local _, _, count5 = GbcPalette.remapTable(nil, 0xff) +check("no palettes is no table", count5, 0) + +-- DMG mode collapses every palette to the four hardware shades before the +-- register sees them, which is what the hardware itself does -- and it makes +-- the backwards pass trivially exact, since there is only one palette left. +GbcPalette.setMode("dmg") +local dmg = GbcPalette.remap(GbcPalette.resolve(mon), 0xff) +checkColor("dmg $ff is hardware black", dmg[1], 0, 0, 0) +local _, _, count6, ambiguous6 = GbcPalette.remapTable({ ramp, clash }, 0xf9) +check("dmg dedupes to four shades", count6, 4) +check("dmg is unambiguous", ambiguous6, 0) +GbcPalette.setMode("gbc") + +print(("gen2 palettes: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_party_menu_test.lua b/tests/gen2_party_menu_test.lua new file mode 100644 index 00000000..45ea15d5 --- /dev/null +++ b/tests/gen2_party_menu_test.lua @@ -0,0 +1,545 @@ +-- The party list's field behaviour: SwitchPartyMons (engine/pokemon/ +-- mon_menu.asm + engine/pokemon/switchpartymons.asm) and the EGG rules +-- (engine/pokemon/party_menu.asm PartyMenuCheckEgg, engine/pokemon/ +-- mon_submenu.asm GetMonSubmenuItems .egg, engine/pokemon/stats_screen.asm +-- EggStatsScreen). +-- +-- ROM-free and draw-free, the same shape as tests/gen2_summary_test.lua: the +-- list's rows and the egg summary's page are built as data before anything is +-- drawn, so the suite asserts the data and drives the input loops with a stub +-- pad. What a test cannot say -- whether the screens LOOK right -- is what +-- tests/drivers/gold_party_submenu.lua exists for. + +package.path = "./?.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction and logic; 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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +-- StatsScreen_PlaceFrontpic ends in PlayMonCry; count the calls so the egg's +-- silence is an assertion rather than a hope. +local cries = 0 +package.loaded["src.core.Sound"] = package.loaded["src.core.Sound"] or {} +package.loaded["src.core.Sound"].playCry = function() cries = cries + 1 end + +local Mail = require("src.core.gen2.Mail") +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +-- ------------------------------------------------------------- fixture data + +local DATA = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_SLOW = { + numerator = 6, denominator = 5, squared = -15, linear = 100, + constant = 140, + }, + }, + CYNDAQUIL = { + id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "FIRE", "FIRE" }, + baseStats = { + hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50, + }, + }, + TOTODILE = { + id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "WATER", "WATER" }, + baseStats = { + hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48, + }, + }, + TOGEPI = { + id = "TOGEPI", name = "TOGEPI", dex = 175, index = 175, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "NORMAL", "NORMAL" }, + eggSteps = 20, + baseStats = { + hp = 35, attack = 20, defense = 65, speed = 20, + specialAttack = 40, specialDefense = 65, + }, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + SURF = { id = "SURF", name = "SURF", pp = 15 }, + }, + items = { + BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM" }, + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", + pocket = "ITEM" }, + }, + gen2MenuGfx = {}, + -- A cut-down icons.lua: enough for iconIdFor's two arms. + gen2Icons = { + species = { CYNDAQUIL = "ICON_FOX", TOTODILE = "ICON_MONSTER", + TOGEPI = "ICON_MONSTER" }, + icons = { + ICON_FOX = { id = "ICON_FOX", image = "x/fox.png" }, + ICON_MONSTER = { id = "ICON_MONSTER", image = "x/monster.png" }, + ICON_EGG = { id = "ICON_EGG", image = "x/egg.png" }, + }, + }, +} + +local function newGame(save) + return { + input = newInput(), + save = save, + data = DATA, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +local function mon(species, level, opts) + opts = opts or {} + local built = Mon.new(DATA, species, level, { + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + moves = opts.moves, + }) + for key, value in pairs(opts.fields or {}) do built[key] = value end + return built +end + +-- The shape GiveEgg (engine/pokemon/move_mon.asm) leaves in the slot: the +-- real species under an isEgg mark, "EGG" for a nickname on the day-care +-- path but NOT on the giveegg one, the counter in cycles, and zero HP. +local function egg(cycles) + local built = mon("TOGEPI", 5) + built.isEgg = true + built.eggSteps = cycles or 20 + built.hp = 0 + return built +end + +local function newSave() + return { + player = { name = "GOLD", id = 12345 }, + party = { + mon("CYNDAQUIL", 12, { + moves = { { id = "TACKLE", pp = 30, maxPp = 35 } }, + fields = { nickname = "CYNDAQUIL", item = "BERRY" }, + }), + mon("TOTODILE", 10, { + moves = { { id = "SURF", pp = 15, maxPp = 15 } }, + fields = { nickname = "TOTODILE" }, + }), + }, + } +end + +-- ----------------------------------------------------------- the list rows + +-- WritePartyMenuTilemap's quality routines, as row data. +local save = newSave() +local row = PartyMenu.rowFor(save.party[1]) +check("a mon's row shows its nickname", row.name, "CYNDAQUIL") +check("and its HP digits", row.hp ~= nil, true) +check("and its level", row.level, "<LV>12") +check("a healthy mon has no status", row.status, nil) + +local fnt = mon("TOTODILE", 10, { fields = { hp = 0 } }) +check("a fainted mon reads FNT", PartyMenu.rowFor(fnt).status, "FNT") + +-- PartyMenuCheckEgg: every quality routine skips an EGG's row, and the name +-- is String_Egg -- never the species hiding inside. +local eggRow = PartyMenu.rowFor(egg()) +check("an egg's row reads EGG", eggRow.name, "EGG") +check("an egg has no HP digits", eggRow.hp, nil) +check("an egg has no level", eggRow.level, nil) +check("an egg is not FNT", eggRow.status, nil) + +-- ReadMonMenuIcon: MonMenuIcons for a species, ICON_EGG for an egg. +local game = newGame(save) +local party = PartyMenu.new(game, { party = save.party, submenu = true }) +check("a species icon reads MonMenuIcons", + party:iconIdFor(save.party[1]), "ICON_FOX") +check("an egg draws ICON_EGG", party:iconIdFor(egg()), "ICON_EGG") + +-- ------------------------------------------------------------- egg submenu + +-- GetMonSubmenuItems' .egg arm: STATS, SWITCH, CANCEL and nothing else. +local eggItems = party:submenuItems(egg()) +check("an egg's submenu has three rows", #eggItems, 3) +check("STATS first", eggItems[1].id, "STATS") +check("SWITCH second", eggItems[2].id, "SWITCH") +check("CANCEL third", eggItems[3].id, "CANCEL") + +-- GiveTakePartyMonItem's first test is `cp EGG`: the held-item menu refuses. +game = newGame(save) +party = PartyMenu.new(game, { party = save.party, submenu = true }) +party:openHeldItemMenu(1, egg()) +check("the held-item menu refuses an egg", #game.stack._items, 0) + +-- ------------------------------------------------------- SwitchPartyMons + +-- The full flow through the pad: A opens the submenu, SWITCH holds the slot, +-- the second A swaps both the mons and their sPartyMail structs. +save = newSave() +Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "hello", "GOLD", 12345)) +game = newGame(save) +party = PartyMenu.new(game, { party = save.party, submenu = true, + save = save }) +game.input:press("a") +party:update(0) +check("a opens the submenu", party.submenu ~= nil, true) +game.input:press("down") +party:update(0) +check("down reaches SWITCH", party.submenu.items[party.submenu.index].id, + "SWITCH") +game.input:press("a") +party:update(0) +check("SWITCH closes the submenu", party.submenu, nil) +check("and holds the slot", party.switchFrom, 1) +-- InitPartyMenuNoCancel: the cursor is capped at the last mon. +check("the CANCEL row is gone while switching", party:count(), 2) +game.input:press("down") +party:update(0) +check("down picks the other end", party.index, 2) +game.input:press("a") +party:update(0) +check("a releases the hold", party.switchFrom, nil) +check("the lead is now TOTODILE", save.party[1].nickname, "TOTODILE") +check("and slot 2 is CYNDAQUIL", save.party[2].nickname, "CYNDAQUIL") +-- _SwitchPartyMons' .SwapMonAndMail: the letter rides with its mon. +check("the mail moved with its mon", Mail.get(save, 1) ~= nil, true) +check("and left its old slot", Mail.get(save, 2), nil) +check("the CANCEL row is back", party:count(), 3) + +-- B backs out of the hold without moving anything (.DontSwitch). +save = newSave() +game = newGame(save) +party = PartyMenu.new(game, { party = save.party, submenu = true, + save = save }) +party:beginSwitch(1) +check("beginSwitch holds the slot", party.switchFrom, 1) +game.input:press("b") +party:update(0) +check("b releases the hold", party.switchFrom, nil) +check("and nothing moved", save.party[1].nickname, "CYNDAQUIL") + +-- Picking the held slot again is the `.skip` arm: nothing moves. +party:beginSwitch(1) +party.index = 1 +game.input:press("a") +party:update(0) +check("the same slot swaps nothing", save.party[1].nickname, "CYNDAQUIL") +check("and the hold is released", party.switchFrom, nil) + +-- `cp 2 / jr c, .DontSwitch`: one mon is nothing to switch with. +local lone = { mon("CYNDAQUIL", 12) } +game = newGame({ party = lone }) +party = PartyMenu.new(game, { party = lone, submenu = true }) +party:beginSwitch(1) +check("a lone mon cannot enter switch mode", party.switchFrom, nil) + +-- A list opened over a table that is NOT the save's party (a battle copy) +-- reorders itself without touching sPartyMail. +save = newSave() +Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "hello", "GOLD", 12345)) +local copy = { save.party[1], save.party[2] } +game = newGame(save) +party = PartyMenu.new(game, { party = copy, submenu = true, save = save }) +party:beginSwitch(1) +party.index = 2 +game.input:press("a") +party:update(0) +check("the copy reordered", copy[1].nickname, "TOTODILE") +check("but the save's own party did not", save.party[1].nickname, "CYNDAQUIL") +check("and the mail stayed put", Mail.get(save, 2) ~= nil, true) + +-- ------------------------------------------------------------- egg summary + +local at = SummaryMenu.at + +-- EggStatsScreen, coordinate for coordinate. +save = newSave() +save.party[3] = egg(20) +cries = 0 +game = newGame(save) +local screen = SummaryMenu.new(game, { party = save.party, index = 3, + save = save }) +check("an egg opens without a cry", cries, 0) +local page = screen:placements() +check("EGG at hlcoord 8,1", at(page, 8, 1), "EGG") +check("<ID>№. at hlcoord 8,3", at(page, 8, 3), "<ID>№.") +check("????? for the ID", at(page, 11, 3), "?????") +check("OT/ at hlcoord 8,5", at(page, 8, 5), "OT/") +check("????? for the OT", at(page, 11, 5), "?????") +-- No upper half: nothing names the species or its dex slot. +check("no dex number", at(page, 10, 0), nil) +check("no nickname row", at(page, 8, 2), nil) + +-- The flavour ladder is `cp $6 / cp $b / cp $29` on the remaining cycles. +local function flavorLine(cycles) + local s = SummaryMenu.new(newGame(save), { mon = egg(cycles), save = save }) + return at(s:eggPlacements(), 1, 9) +end +check("under 6 cycles it is about to hatch", flavorLine(5), + "It's making sounds") +check("6 cycles is only close", flavorLine(6), "It moves around") +check("10 cycles is still close", flavorLine(10), "It moves around") +check("11 cycles needs more time", flavorLine(11), "Wonder what's") +check("40 cycles needs more time", flavorLine(40), "Wonder what's") +check("41 cycles needs a lot more", flavorLine(41), "This EGG needs a") +-- The close string is four lines, two rows apart (`next` steps two). +local closePage = SummaryMenu.new(newGame(save), + { mon = egg(8), save = save }):eggPlacements() +check("close line 2 on row 11", at(closePage, 1, 11), "inside sometimes.") +check("close line 4 on row 15", at(closePage, 1, 15), "to hatching.") + +-- EggStats_JoypadLoop: A and B both exit, and left/right turn no pages. +local closed = false +game = newGame(save) +screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) +screen.onClose = function() closed = true end +game.input:press("right") +screen:update(0) +check("right turns no page on an egg", screen.page, SummaryMenu.PINK_PAGE) +check("and does not close", closed, false) +game.input:press("a") +screen:update(0) +check("a exits the egg screen", closed, true) + +closed = false +game = newGame(save) +screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) +screen.onClose = function() closed = true end +game.input:press("b") +screen:update(0) +check("b exits the egg screen too", closed, true) + +-- EggStats_UpAction: up walks to the mon above, whose real page comes back. +game = newGame(save) +screen = SummaryMenu.new(game, { party = save.party, index = 3, save = save }) +cries = 0 +game.input:press("up") +screen:update(0) +check("up walks off the egg", screen.index, 2) +check("onto the mon's own page", at(screen:placements(), 8, 2), "TOTODILE") +check("and plays that mon's cry", cries, 1) + +-- ...and walking back DOWN onto the egg swaps the egg page in, silently. +game.input:press("down") +screen:update(0) +check("down lands on the egg again", screen.index, 3) +check("as the egg page", at(screen:placements(), 8, 1), "EGG") +check("with no cry", cries, 1) + +-- MoveScreenLoop's .cycle loops step over EGG slots: with the party +-- CYNDAQUIL / EGG / TOTODILE, right from slot 1 lands on slot 3. +save = newSave() +save.party = { save.party[1], egg(20), save.party[2] } +game = newGame(save) +screen = SummaryMenu.new(game, { party = save.party, index = 1, save = save }) +screen.page = SummaryMenu.GREEN_PAGE +game.input:press("select") +screen:update(0) +check("select opens the move detail", screen.moveDetail, true) +game.input:press("right") +screen:update(0) +check("right skips the egg slot", screen.index, 3) +check("and stays in the move detail", screen.moveDetail, true) +game.input:press("left") +screen:update(0) +check("left skips it coming back", screen.index, 1) + +-- --------------------------------------------------- the START menu wiring + +-- src/core/Game2.lua's POKéMON row must ask for the submenu: the field list +-- is the one flavour that opens PokemonActionSubmenu on A +-- (engine/pokemon/mon_menu.asm) rather than answering to a caller. A +-- textual check is the honest one here -- constructing a Game2 needs love -- +-- and tests/drivers/gold_party_submenu.lua drives the real thing. +local game2Source = (function() + local f = io.open("src/core/Game2.lua", "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end)() +check("Game2's source is readable", game2Source ~= nil, true) +if game2Source then + local branch = game2Source:match( + 'elseif id == "pokemon" then(.-)elseif') + check("the START menu's party list opens the action submenu", + branch ~= nil and branch:find("submenu = true", 1, true) ~= nil, true) +end + +-- ------------------------------------------------------ wPartyMenuCursor +-- +-- InitPartyMenuWithCancel seeds wMenuCursorY from wPartyMenuCursor and only +-- falls back to row 1 when it is zero or no longer inside the party +-- (engine/pokemon/party_menu.asm:546), and PartyMenuSelect writes the picked +-- row back before the B test (:600) -- so the list reopens where it was left, +-- and only the CANCEL row leaves the byte alone. +do + local cursorSave = newSave() + local cursorGame = newGame(cursorSave) + local list = PartyMenu.new(cursorGame, { party = cursorSave.party }) + check("a fresh session opens on the first mon", list.index, 1) + list.index = 2 + list:storeCursor() + check("PartyMenuSelect records the row", cursorGame.partyMenuCursor, 2) + + local reopened = PartyMenu.new(cursorGame, { party = cursorSave.party }) + check("and the list reopens on it", reopened.index, 2) + + -- The CANCEL row is one past the party and jumps out before the store. + reopened.index = #cursorSave.party + 1 + reopened:storeCursor() + check("CANCEL leaves the byte where it was", cursorGame.partyMenuCursor, 2) + + -- A byte pointing past a party that shrank falls back to row 1. + cursorGame.partyMenuCursor = 5 + local shrunk = PartyMenu.new(cursorGame, { party = { cursorSave.party[1] } }) + check("a row past the party clamps to the first", shrunk.index, 1) +end + +-- ---------------------------------------------------- the held-item marker +-- +-- .SpawnItemIcon (engine/gfx/mon_icons.asm): a non-zero MON_ITEM swaps the +-- icon's frameset for _WITH_MAIL or _WITH_ITEM, whose OAM sets replace the +-- BOTTOM-LEFT quadrant with HeldItemIcons tile $08 (mail.2bpp) or $09 +-- (item.2bpp). The sheet is INCBIN'd mail first, so row 0 is mail. +do + -- A save of its own: the blocks above reshuffle `save.party` and drop an + -- egg into slot 2. + local held = newSave() + check("an empty hand gets no marker", + PartyMenu.heldMarkerRow(held.party[2]), nil) + check("a berry marks the icon with the item tile", + PartyMenu.heldMarkerRow(held.party[1]), 1) + local mailer = mon("TOTODILE", 10, { fields = { item = "FLOWER_MAIL" } }) + check("ItemIsMail picks the mail tile instead", + PartyMenu.heldMarkerRow(mailer), 0) + -- `ld a, [hl] / and a / ret z`: a zero item byte is an empty hand. + check("a zero item byte is an empty hand", + PartyMenu.heldMarkerRow({ item = 0 }), nil) + + -- The draw itself: three of the icon's four 8x8 quadrants plus the marker in + -- place of the fourth, and one plain 16x16 quad when there is nothing to + -- show. Images are preloaded into iconCache so nothing reaches love.image. + local iconImage = { getDimensions = function() return 16, 32 end } + local markerImage = { getDimensions = function() return 8, 16 end } + local marked = PartyMenu.new(newGame(held), { + party = held.party, + icons = { + species = DATA.gen2Icons.species, + icons = DATA.gen2Icons.icons, + heldItem = { image = "x/held.png", width = 8, height = 8 }, + }, + }) + marked.iconCache["x/fox.png"] = iconImage + marked.iconCache["x/monster.png"] = iconImage + marked.iconCache["x/held.png"] = markerImage + + local calls = {} + local realDraw = love.graphics.draw + love.graphics.draw = function(image, _, x, y) + calls[#calls + 1] = { image = image, x = x, y = y } + end + + marked:drawIcon(held.party[1], 0, 0) + check("a held item draws three icon quadrants plus the marker", #calls, 4) + check("the marker comes from the HeldItemIcons sheet", + calls[4] and calls[4].image, markerImage) + check("and lands in the bottom-left quadrant", + calls[4] and calls[4].x == 0 and calls[4].y == 8, true) + check("the bottom-left species tile is replaced, not covered", + calls[3] and calls[3].x == 8 and calls[3].y == 8, true) + + calls = {} + marked:drawIcon(held.party[2], 0, 0) + check("an empty hand is still one 16x16 draw", #calls, 1) + + -- A cache imported before the extractor read HeldItemIcons has no `heldItem` + -- row: the list must draw exactly what it drew before, not error. + local plain = PartyMenu.new(newGame(held), { party = held.party }) + plain.iconCache["x/fox.png"] = iconImage + calls = {} + plain:drawIcon(held.party[1], 0, 0) + check("no marker sheet in the cache falls back to the plain icon", + #calls, 1) + + love.graphics.draw = realDraw +end + +print(("gen2 party menu: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_pc_screens_test.lua b/tests/gen2_pc_screens_test.lua new file mode 100644 index 00000000..44bec0b3 --- /dev/null +++ b/tests/gen2_pc_screens_test.lua @@ -0,0 +1,680 @@ +-- The Pokecenter PC and the player's item PC. +-- +-- luajit tests/gen2_pc_screens_test.lua (GOLD_CACHE for the map runs) +-- +-- Three layers, matching how the cart reaches them: +-- +-- * World:interact's CheckFacingTileForStdScript arm +-- (engine/events/std_collision.asm): an A press on a COLL_PC tile runs +-- PCScript out of std_scripts, which is the ONLY way any Pokecenter PC +-- opens -- no Pokecenter map carries a PC bg event. +-- * CenterPcMenu (engine/events/pokecenter_pc.asm PokemonCenterPC): the +-- whose-PC list and its .ChooseWhichPCListToUse gating, the party gate, +-- and the rows it opens. +-- * ItemPcMenu (_PlayersPC + the PlayerWithdraw/Deposit/TossItemMenu rows): +-- items moving bag <-> save.pcItems for real, with the cart's caps and +-- refusals. +-- +-- With a GOLD_CACHE the whole chain runs on the real Cherrygrove Pokecenter +-- and the real bedroom map: a real Map, a real Vm over the extracted scripts, +-- and World:openPc pushing the real screens. + +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, + setLineWidth = function() end, +} +love.math = love.math or { + random = function(a, b) + if b then return a end + return a and 1 or 0.5 + end, +} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local S = require("tests.harness").suite("gen2 pc screens") +local check, eq = S.check, S.eq + +local Bag = require("src.inventory.Bag") +local CenterPcMenu = require("src.ui.gen2.CenterPcMenu") +local ItemPcMenu = require("src.ui.gen2.ItemPcMenu") +local Map = require("src.world.gen2.Map") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local ITEMS = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 2, + canToss = true }, + ANTIDOTE = { id = "ANTIDOTE", name = "ANTIDOTE", pocket = "ITEM", index = 9, + canToss = true }, + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 6, + canToss = false }, + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0xf3, + canToss = false }, +} + +local function newGame(save, items) + local input = newInput() + return { + input = input, + save = save, + data = { audio = {}, pokemon = {}, items = items or ITEMS }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + }, input +end + +local function newSave(partySize) + local save = Save.newGame({ playerName = "GOLD", trainerId = 1234 }) + for i = 1, partySize or 1 do + save.party[i] = { species = "CYNDAQUIL", nickname = "MON" .. i, + hp = 20, maxHp = 20, level = 10 } + end + return save +end + +-- Drive a screen: press the buttons one at a time, updating after each. +local function press(screen, input, ...) + for _, button in ipairs({ ... }) do + input:press(button) + screen:update(0) + end +end + +local function entryIds(entries) + local ids = {} + for _, entry in ipairs(entries) do ids[#ids + 1] = entry.id end + return table.concat(ids, ",") +end + +-- ------------------------------------------------- the ids resolve + +Screens.invalidate() +local sg = { data = {} } +eq(Screens.get(sg, "Gen2CenterPcMenu"), CenterPcMenu, + "Gen2CenterPcMenu resolves to its builtin") +eq(Screens.get(sg, "Gen2ItemPcMenu"), ItemPcMenu, + "Gen2ItemPcMenu resolves to its builtin") + +-- ------------------------------------------------- the whose-PC gating + +-- PCPC_BEFORE_POKEDEX: three rows. +do + local save = newSave(1) + local game, input = newGame(save) + local pc = CenterPcMenu.new(game, { save = save, onClose = function() end }) + check(pc.message ~= nil, "the PC boots with the turn-on line") + press(pc, input, "a") + eq(pc.message, nil, "which one A clears") + eq(entryIds(pc.entries), "bills,players,turnoff", + "no #DEX: BILL's PC / <PLAYER>'s PC / TURN OFF") + eq(pc.entries[2].label, "GOLD's PC", "the item PC row carries the name") +end + +-- PCPC_BEFORE_HOF: PROF.OAK's PC appears with CheckReceivedDex. +do + local save = newSave(1) + save.engineFlags = { [CenterPcMenu.ENGINE_POKEDEX] = true } + local game = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + eq(entryIds(pc.entries), "bills,players,oaks,turnoff", + "with the #DEX the OAK row slots in above TURN OFF") +end + +-- PCPC_POSTGAME: HALL OF FAME appears once wHallOfFameCount is non-zero. +do + local save = newSave(1) + save.engineFlags = { [CenterPcMenu.ENGINE_POKEDEX] = true } + save.hallOfFame.count = 1 + local game = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + eq(entryIds(pc.entries), "bills,players,oaks,hof,turnoff", + "a champion sees all five rows") +end + +-- PC_CheckPartyForPokemon: no party, no PC. +do + local save = newSave(0) + local game, input = newGame(save) + local closed = false + local pc = CenterPcMenu.new(game, { save = save, + onClose = function() closed = true end }) + check(pc.message ~= nil, "an empty party gets the Bzzzzt! refusal") + press(pc, input, "a") + check(closed, "and the PC never opens") + eq(#game.stack._items, 0, "nothing was pushed") +end + +-- ------------------------------------------------- the rows open the screens + +-- <PLAYER>'s PC opens the item PC (PLAYERSPC_NORMAL). +do + local save = newSave(1) + local game, input = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + press(pc, input, "a") -- boot line + press(pc, input, "down", "a") -- <PLAYER>'s PC + check(pc.message ~= nil, "PokecenterPlayersPCText comes up first") + press(pc, input, "a", "a") -- both pages + local top = game.stack:top() + check(top ~= nil, "then the item PC is pushed") + eq(top and top.screenId, "Gen2ItemPcMenu", "as Gen2ItemPcMenu") + eq(entryIds(top.entries), "withdraw,deposit,toss,mailbox,logoff", + "with _PlayersPC's PLAYERSPC_NORMAL rows, LOG OFF last") +end + +-- BILL's PC opens the storage system with _BillsPC's own five rows. +do + local save = newSave(1) + local game, input = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + press(pc, input, "a") -- boot line + press(pc, input, "a") -- BILL's PC (row 1) + press(pc, input, "a", "a") -- PokecenterBillsPCText, both pages + local top = game.stack:top() + eq(top and top.screenId, "Gen2PcMenu", "BILL's PC pushes the storage menu") + eq(entryIds(top.entries), "withdraw,deposit,changebox,move,seeya", + "with no MAIL BOX row: that lives on the item PC") +end + +-- PROF.OAK's PC: the yes/no, the counts, the rating, the link-closed line. +do + local save = newSave(1) + save.engineFlags = { [CenterPcMenu.ENGINE_POKEDEX] = true } + save.pokedex = { seen = { A = true, B = true, C = true }, + caught = { A = true, B = true } } + local game, input = newGame(save) + local pc = CenterPcMenu.new(game, { save = save }) + press(pc, input, "a") -- boot line + press(pc, input, "down", "down", "a") -- PROF.OAK's PC + press(pc, input, "a", "a") -- PokecenterOaksPCText, both pages + check(pc.confirm ~= nil, "OakPCText1 asks for the yes/no") + press(pc, input, "a") -- YES + check(pc.message ~= nil, "and the rating flow starts") + local sawCounts, sawRating = false, false + for _ = 1, 12 do + if not pc.message then break end + local page = pc.message.pages[pc.message.page] + for _, line in ipairs(page) do + if line == "3 #MON seen" then sawCounts = true end + if line == "Look for #MON" then sawRating = true end + end + press(pc, input, "a") + end + check(sawCounts, "the counts page names 3 seen") + check(sawRating, "2 owned lands on OakRating01") + check(pc.closed == false, "the OAK flow drops back to the menu, not out") +end + +-- TURN OFF: the Link closed line, then the shutdown. +do + local save = newSave(1) + local game, input = newGame(save) + local closed = false + local pc = CenterPcMenu.new(game, { save = save, + onClose = function() closed = true end }) + press(pc, input, "a") -- boot line + press(pc, input, "up", "a") -- TURN OFF (wraps to the last row) + check(pc.message ~= nil, "TurnOffPC prints the Link closed line") + press(pc, input, "a") + check(closed, "and carries into .shutdown") +end + +-- ------------------------------------------------- the item PC, for real + +-- DEPOSIT: bag -> save.pcItems through the PACK chooser. +do + local save = newSave(1) + local game, input = newGame(save) + check(Bag.add(save, "POTION", 5, game.data), "five POTIONs in the bag") + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + eq(pc.message, nil, "PLAYERSPC_NORMAL boots straight to the menu") + press(pc, input, "down", "a") -- DEPOSIT ITEM + eq(pc.phase, "deposit", "DEPOSIT opens the PACK as a chooser") + check(pc.pack ~= nil, "held by the screen, the way the mart sells") + press(pc, input, "a") -- choose POTION + check(pc.qtyState ~= nil, "the quantity selector comes up") + press(pc, input, "up") -- 2 + press(pc, input, "a") -- deposit x2 + eq(save.pcItems.POTION, 2, "two POTIONs land in the PC") + eq(save.inventory.POTION, 3, "and leave the bag") + check(pc.message ~= nil, "with _PlayersPCDepositItemsText up") + eq(pc.message.pages[1][1], "Deposited 2", "naming the count") + press(pc, input, "a") -- clear it + press(pc, input, "b") -- close the PACK + eq(pc.phase, "menu", "B drops back to the item PC menu") +end + +-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently. +do + local save = newSave(1) + local game, input = newGame(save) + Bag.add(save, "BICYCLE", 1, game.data) + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "a") -- DEPOSIT ITEM + press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket + press(pc, input, "a") -- choose the BICYCLE + eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM") + eq(pc.message, nil, "no message either: .no_toss is a bare ret") + eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag") + eq(save.inventory.BICYCLE, 1, "still there") +end + +-- An empty bag never opens the PACK (.CheckItemsInBag). +do + local save = newSave(1) + local game, input = newGame(save) + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "a") + eq(pc.phase, "menu", "DEPOSIT refuses with nothing to deposit") + eq(pc.message.pages[1][1], "No items here!", "with _PlayersPCNoItemsText") +end + +-- WITHDRAW: save.pcItems -> bag, with the no-room refusal. +do + local save = newSave(1) + local game, input = newGame(save) + save.pcItems = { POTION = 2 } + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "a") -- WITHDRAW ITEM + eq(pc.phase, "withdraw", "the PC item list opens") + eq(pc.rows[1] and pc.rows[1].id, "POTION", "with the POTION stack on it") + press(pc, input, "a") -- choose it + check(pc.qtyState ~= nil, "stackable: the selector asks how many") + press(pc, input, "a") -- x1 + eq(save.inventory.POTION, 1, "one POTION reaches the bag") + eq(save.pcItems.POTION, 1, "one stays behind") + eq(pc.message.pages[1][1], "Withdrew 1", "_PlayersPCWithdrewItemsText") + press(pc, input, "a") + + -- Fill the ITEM pocket: ReceiveItem answers no-carry and the stack stays. + Bag.remove(save, "POTION", 1) + for i = 1, Bag.capacity(game.data, "ITEM") do + check(Bag.add(save, "FILL_" .. i, 1, game.data), "filler " .. i .. " fits") + end + press(pc, input, "a", "a") -- choose the POTION stack again, x1 + eq(pc.message.pages[1][1], "There's no room", + "a full pocket is _PlayersPCNoRoomWithdrawText") + eq(save.pcItems.POTION, 1, "and the stack never left the PC") + press(pc, input, "a", "b") -- clear, back to menu +end + +-- TOSS: the quantity, the yes/no, the discard -- and the KEY ITEM refusal. +do + local save = newSave(1) + local game, input = newGame(save) + save.pcItems = { POTION = 3, HM_CUT = 1 } + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "down", "a") -- TOSS ITEM + eq(pc.phase, "toss", "the toss list opens") + press(pc, input, "a") -- POTION (index 2 sorts it first) + check(pc.qtyState ~= nil, "TossItemFromPC asks how many") + press(pc, input, "up", "up", "a") -- x3 + check(pc.confirm ~= nil, "then .ItemsThrowAwayText asks yes/no") + eq(pc.confirm.prompt[1], "Throw away 3", "naming the count") + press(pc, input, "a") -- YES + eq(save.pcItems.POTION, nil, "the stack is discarded") + eq(pc.message.pages[1][1], "Discarded", "_ItemsDiscardedText") + press(pc, input, "a") + + press(pc, input, "a") -- the HM is the only row left + eq(pc.qtyState, nil, "an HM never reaches the selector") + eq(pc.message.pages[1][1], "That's too impor-", + ".CantToss: _ItemsTooImportantText") + press(pc, input, "a", "b") +end + +-- The PC's fifty stacks (ReceiveItem over wNumPCItems). +do + local save = newSave(1) + local game, input = newGame(save) + Bag.add(save, "POTION", 1, game.data) + save.pcItems = {} + for i = 1, ItemPcMenu.PC_ITEM_CAPACITY do save.pcItems["S" .. i] = 1 end + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS }) + press(pc, input, "down", "a") -- DEPOSIT ITEM + press(pc, input, "a") -- choose POTION + press(pc, input, "a") -- x1 + eq(pc.message.pages[1][1], "There's no room to", + "the fifty-first stack is _PlayersPCNoRoomDepositText") + eq(save.inventory.POTION, 1, "and the POTION stays in the bag") +end + +-- PLAYERSPC_HOUSE: the boot line, DECORATION, TURN OFF, and the answer out. +do + local save = newSave(1) + local game, input = newGame(save) + local answered = nil + local pc = ItemPcMenu.new(game, { save = save, items = ITEMS, house = true, + onClose = function(changed) answered = changed end }) + check(pc.message ~= nil, "_PlayersHousePC opens on PlayersPCTurnOnText") + press(pc, input, "a") + eq(entryIds(pc.entries), "withdraw,deposit,toss,mailbox,decoration,turnoff", + "PLAYERSPC_HOUSE carries DECORATION and ends on TURN OFF") + -- The DECORATION row pushes the decoration menu and carries `changed` out. + press(pc, input, "up", "up", "a") + local top = game.stack:top() + eq(top and top.screenId, "Gen2DecorationMenu", "DECORATION opens the menu") + top.onDone(true) + eq(pc.changedDecorations, true, "a moved decoration is remembered") + press(pc, input, "b") + eq(answered, true, "and answered out, for PlayersHousePCScript's iftrue") +end + +-- ------------------------------------------------- the A press, wired + +-- World:interact's CheckFacingTileForStdScript arm, on a stub map: COLL_PC +-- runs PCScript, COLL_RADIO runs Radio1Script, floor runs nothing. +do + local started = nil + local world = setmetatable({ + map = { + def = { bgEvents = {} }, + cellCollision = function(_, x, _) return x == 1 and 0x93 + or (x == 3 and 0x94) or 0x00 end, + }, + player = { facing = "up", cellX = 1, cellY = 1, moving = false }, + npcs = {}, + events = { get = function() return false end }, + stdScripts = { scripts = { + PCScript = { key = "s:pc" }, + Radio1Script = { key = "s:radio" }, + } }, + vm = { start = function(_, key) started = key return true end, + running = function() return false end }, + }, { __index = World }) + check(world:interact(), "A on a COLL_PC tile is claimed") + eq(started, "s:pc", "and runs PCScript") + world.player.cellX = 3 + world:interact() + eq(started, "s:radio", "COLL_RADIO runs Radio1Script") + started = nil + world.player.cellX = 5 + check(not world:interact(), "a plain floor tile claims nothing") + eq(started, nil, "and starts nothing") +end + +-- ------------------------------------------------- the real thing + +local cache = os.getenv("GOLD_CACHE") +local function loadCache(name) + local chunk = cache + and loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() +end + +local mapsData = loadCache("maps") +local tilesetsData = loadCache("tilesets") +local scriptsData = loadCache("scripts") +local textData = loadCache("text") +local stdScriptsData = loadCache("std_scripts") +local constsData = loadCache("constants") +local itemsData = loadCache("items") + +local haveCache = mapsData and tilesetsData and scriptsData and stdScriptsData + and constsData and (constsData.specialOrder ~= nil) + +if not haveCache then + check(true, "no GOLD_CACHE: real-map PC checks (SKIP)") +else + local function realWorld(mapId, save) + local def = mapsData[mapId] + if not def then return nil end + local game = newGame(save, itemsData or ITEMS) + local world = World.new(game) + game.world = world + world.map = Map.new(def, tilesetsData[def.tileset] or {}) + world.maps = mapsData + world.stdScripts = stdScriptsData + world.text = textData + world.constants = constsData + world.pollTimeOfDay = function() end + world.vm = Vm.new(scriptsData, textData, world.events, { + specialOrder = constsData.specialOrder, + specials = world:specialHooks(), + openPc = function() world:openPc() end, + }) + return world, game + end + + -- The Cherrygrove Pokecenter: find the PC by its collision, stand under it, + -- press A, and the whole chain runs -- PCScript out of std_scripts, the + -- PokemonCenterPC special, World:openPc, the whose-PC menu. + do + local save = newSave(1) + local world, game = realWorld("CHERRYGROVE_POKECENTER_1F", save) + check(world ~= nil, "the cache carries CHERRYGROVE_POKECENTER_1F") + if world then + local pcX, pcY + for cy = 0, world.map.heightCells - 1 do + for cx = 0, world.map.widthCells - 1 do + if world.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end + end + end + check(pcX ~= nil, "and its PC is a COLL_PC tile") + if pcX then + world.player = { facing = "up", cellX = pcX, cellY = pcY + 1, + moving = false } + check(world:interact(), "the A press at the PC is claimed") + for _ = 1, 8 do + if not world.vm:running() then break end + world.vm:update() + end + local top = game.stack:top() + eq(top and top.screenId, "Gen2CenterPcMenu", + "and the whose-PC menu is on the stack") + if top and top.screenId == "Gen2CenterPcMenu" then + local input = game.input + press(top, input, "a") -- boot line + eq(entryIds(top.entries), "bills,players,turnoff", + "a fresh save sees the BEFORE_POKEDEX list") + -- Deposit a POTION through <PLAYER>'s PC, for real. + Bag.add(save, "POTION", 2, game.data) + press(top, input, "down", "a", "a", "a") + local itemPc = game.stack:top() + eq(itemPc and itemPc.screenId, "Gen2ItemPcMenu", + "<PLAYER>'s PC opens the item PC") + if itemPc and itemPc.screenId == "Gen2ItemPcMenu" then + press(itemPc, input, "down", "a") -- DEPOSIT ITEM + press(itemPc, input, "a") -- the POTION row + press(itemPc, input, "a") -- x1 + eq(save.pcItems.POTION, 1, "the POTION lands in save.pcItems") + eq(save.inventory.POTION, 1, "and leaves the bag") + press(itemPc, input, "a", "b", "b") -- message, PACK, log off + eq(game.stack:top(), top, "LOG OFF drops back to the whose-PC menu") + end + press(top, input, "b") -- shutdown + eq(game.stack:top(), nil, "and B logs the whole PC off") + end + end + end + end + + -- The bedroom: the real PLAYERS_HOUSE_2F bg event, the blocking + -- PlayersHousePC special, the item PC -- and the FALSE answered back to + -- PlayersHousePCScript when no decoration moved. + do + local save = newSave(1) + local world, game = realWorld("PLAYERS_HOUSE_2F", save) + check(world ~= nil, "the cache carries PLAYERS_HOUSE_2F") + if world then + local pcEvent + for _, ev in ipairs(world.map.def.bgEvents or {}) do + if ev.kind == 1 then pcEvent = ev end -- BGEVENT_UP: the PC + end + check(pcEvent ~= nil, "the bedroom PC is its BGEVENT_UP event") + if pcEvent then + world.player = { facing = "up", cellX = pcEvent.x, cellY = pcEvent.y + 1, + moving = false } + check(world:interact(), "the A press at the bedroom PC is claimed") + for _ = 1, 4 do + if game.stack:top() then break end + world.vm:update() + end + local top = game.stack:top() + eq(top and top.screenId, "Gen2ItemPcMenu", + "and the bedroom PC is the ITEM PC, not the storage system") + if top and top.screenId == "Gen2ItemPcMenu" then + eq(top.house, true, "in its PLAYERSPC_HOUSE shape") + check(top.message ~= nil, "with the turn-on line up") + local input = game.input + press(top, input, "a", "b") -- boot line, then TURN OFF via B + eq(game.stack:top(), nil, "closing it pops the screen") + for _ = 1, 6 do + if not world.vm:running() then break end + world.vm:update() + end + check(not world.vm:running(), + "and PlayersHousePCScript runs to its end off the FALSE answer") + end + end + end + end +end + +-- ------------------------------------------- MOVE POKéMON W/O MAIL, in full +-- +-- The regression this block exists for: A on a mon used to move it instantly +-- to "the next box with room", with the confirmation string computed and then +-- thrown away -- so from the player's chair the PC ATE the mon. Nothing on +-- screen named a destination and nothing was printed, which is the shape of +-- the bug report ("Move Pokemon w/o mail deletes the Pokemon when you select +-- it"). These checks pin the cart's own flow instead +-- (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm:480): a submenu, a +-- destination the player picks, and the mon accounted for at every step. +do + local BoxMenu = require("src.ui.gen2.BoxMenu") + local Boxes = require("src.core.gen2.Boxes") + + local function census(save) + local n = #(save.party or {}) + for i = 1, Boxes.NUM_BOXES do n = n + Boxes.count(save, i) end + return n + end + + local save = newSave(3) + local box = Boxes.box(save, 1) + box[1] = { species = "PIDGEY", nickname = "BOXED1", hp = 10, maxHp = 10, + level = 3 } + box[2] = { species = "RATTATA", nickname = "BOXED2", hp = 10, maxHp = 10, + level = 3 } + local game, input = newGame(save) + local menu = BoxMenu.new(game, { save = save, mode = "move", + onClose = function() end }) + eq(census(save), 5, "five mons before anything is moved") + eq(menu:prompt(), "Choose a <PK><MN>.", "PCString_ChooseaPKMN opens the list") + + -- 1. A opens .MoveMonWOMailSubmenu rather than moving anything. + press(menu, input, "a") + eq(menu.phase, "submenu", "A on a mon opens the submenu") + eq(menu:prompt(), "What's up?", "under PCString_WhatsUp") + eq(#Boxes.box(save, 1), 2, "and the box is untouched by opening it") + + -- CANCEL and B both come back with the mon exactly where it was. + press(menu, input, "b") + eq(menu.phase, nil, "B closes the submenu") + eq(#Boxes.box(save, 1), 2, "still two in BOX1") + + -- 2. MOVE asks where, and the mon is STILL in its box while it asks. + press(menu, input, "a", "a") + eq(menu.phase, "insert", "MOVE opens the insert cursor") + eq(menu:prompt(), "Move to where?", "under PCString_MoveToWhere") + eq(#Boxes.box(save, 1), 2, "nothing has left the box yet") + eq(census(save), 5, "and nothing has left the save") + + -- B there is .b_button_2: the backed-up position, nothing moved. + press(menu, input, "b") + eq(menu.phase, nil, "B backs out of the insert cursor") + eq(#Boxes.box(save, 1), 2, "with the mon still in BOX1") + eq(census(save), 5, "and the census unchanged") + + -- 3. right walks to BOX2 and A inserts there -- the destination the player + -- chose, named on screen, with the cart's own confirmation. + press(menu, input, "a", "a", "right") + eq(menu.boxIndex, 2, "right walks the insert cursor to the next box") + eq(menu:title(), "BOX2", "and the header names it") + press(menu, input, "a") + eq(census(save), 5, "the mon still exists") + eq(#Boxes.box(save, 1), 1, "one left BOX1") + eq(Boxes.box(save, 2)[1].nickname, "BOXED1", "and it is in BOX2") + eq(menu.message, "Saving\xe2\x80\xa6 Leave ON!", + "MovePKMNWithoutMail_InsertMon's own line says so") + + -- 4. left twice reaches the PARTY (wBillsPC_LoadedBox 0), which the old + -- screen could not show at all. + press(menu, input, "a", "left", "left") + eq(menu.boxIndex, 0, "left wraps through BOX1 to the party") + eq(menu:title(), "PARTY <PK><MN>", "BillsPC_BoxName's .party arm") + eq(#menu:list(), 3, "and the list is the party") + + -- A party mon moves into a box, and the party shrinks by exactly one. + press(menu, input, "a", "a", "right") + eq(menu.boxIndex, 1, "right from the party is BOX1") + press(menu, input, "a") + eq(#save.party, 2, "the party is one shorter") + eq(census(save), 5, "and the mon is still in the save") + eq(Boxes.box(save, 1)[1].nickname, "MON1", "sitting where the cursor was") + + -- BillsPC_CheckMail_PreventBlackout: a party of two may not send one away. + press(menu, input, "a", "left") + eq(menu.boxIndex, 0, "back on the party") + press(menu, input, "a", "a") + eq(menu.phase, nil, "the MOVE is refused outright") + eq(menu.message, "It's your last <PK><MN>!", "with PCString_ItsYourLastPKMN") + eq(#save.party, 2, "and the party is untouched") + + -- .MoveMonWOMailSubmenu has no RELEASE row, so SELECT on this screen must + -- not open the withdraw list's release question either. + press(menu, input, "a") + press(menu, input, "select") + eq(#game.stack._items, 0, "SELECT on the move screen opens nothing") + eq(census(save), 5, "and releases nothing") +end + +S.finish() diff --git a/tests/gen2_phone_call_test.lua b/tests/gen2_phone_call_test.lua new file mode 100644 index 00000000..c20da352 --- /dev/null +++ b/tests/gen2_phone_call_test.lua @@ -0,0 +1,382 @@ +-- The random incoming call's overworld wiring, and the ring chrome around +-- every call that lands: CheckTimeEvents' `farcall CheckPhoneCall` arm, +-- Script_ReceivePhoneCall as PhoneRing.script's rows, GetCallerLocation's two +-- chatter specials (RandomPhoneMon / RandomPhoneWildMon), Mom's shopping call +-- riding the same chrome, and the Pokegear's outgoing callee script. +-- +-- tests/gen2_phone_test.lua owns the MODEL (the gate, the timer, the contact +-- list); this suite owns the CALL SITES, driven through the real World +-- methods and a real Vm over the extracted cache. Cache blocks SKIP without +-- one, the same bargain that suite strikes. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 phone call") +local check, eq = S.check, S.eq + +require("src.core.Logger").warn = function() end + +local CallAsm = require("src.script.gen2.CallAsm") +local Events = require("src.world.gen2.Events") +local Phone = require("src.core.gen2.Phone") +local PhoneRing = require("src.core.gen2.PhoneRing") +local Save = require("src.core.gen2.Save") +local Specials = require("src.script.gen2.Specials") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +local function newSave() + return Save.normalize({}) +end + +-- The ring's `waitsfx` rows and the Phone_Wait20Frames `pause` between its +-- two passes park the coroutine on Vm:update, the way DelayFrames parks the +-- cart's, so a call only runs to completion when something drives the frames +-- the overworld would drive. +local function pump(vm, frames) + for _ = 1, frames or 600 do + if not vm:running() then return end + vm:update() + end +end + +-- ------------------------------------------------- Script_ReceivePhoneCall +-- +-- engine/phone/phone.asm: reanchormap, RingTwice_StartCall, the caller's own +-- script, waitbutton, HangUp, closetext, InitCallReceiveDelay, end. The row +-- list has to keep that order or the countdown restarts before the call has +-- even been answered. +-- +-- RingTwice_StartCall (:458-469) is `call .Ring` falling through into .Ring, +-- and each pass opens on Phone_StartRinging's `call WaitSFX` (:564-567), so +-- the ring is two waitsfx/callasm pairs spaced by the three +-- Phone_Wait20Frames between the passes (:576-580). This assertion used to +-- pin one ungated ring, which is the shape that let the $6a SFX_CALL be +-- dropped by the PlaySFX priority gate; it now pins the cart's. +do + local call = Phone.loadCallerScript(15, "incoming", "caller") + local rows = PhoneRing.script(call, "JOEY", "YOUNGSTER") + local ops = {} + for _, row in ipairs(rows) do ops[#ops + 1] = row.op end + eq(table.concat(ops, " "), + "reanchormap waitsfx callasm pause waitsfx callasm rawtext farscall " + .. "waitbutton hangup closetext callasm end", + "Script_ReceivePhoneCall's rows in the cart's order") + eq(rows[3].label, "RingTwice_StartCall", "the ring rings first") + eq(rows[6].label, "RingTwice_StartCall", "and rings a second time") + eq(rows[4].frames, 60, "three Phone_Wait20Frames apart") + eq(rows[12].label, "InitCallReceiveDelay", "and the countdown restarts last") + eq(rows[8].script, "41:4368", "around the caller's own bank $41 script") + check(rows[7].text:find("RING!", 1, true) ~= nil, "the ring page rings") + check(rows[7].text:find("JOEY: YOUNGSTER", 1, true) ~= nil, + "and carries the caller-ID line") + eq(PhoneRing.callerId("MOM"), "MOM:", + "a non-trainer caller is the name and the colon alone") + + -- Script_SpecialElmCall's siblings `pause 30` before the ring; the + -- descriptor carries that as `delay` and the wrapper honours it. + local special = { delay = 30, scriptKey = "41:41e1" } + local wrapped = PhoneRing.script(special, "PROF.ELM") + eq(wrapped[1].op, "pause", "a special call pauses before ringing") + eq(wrapped[1].frames, 30, "for the cart's thirty frames") +end + +-- ------------------------------------------------- the two chrome callasms +do + check(CallAsm.STUBS.RingTwice_StartCall == nil, + "RingTwice_StartCall is ported, not stubbed") + check(CallAsm.STUBS.InitCallReceiveDelay == nil, + "and so is InitCallReceiveDelay") + eq(CallAsm.nameFor(nil, 0x24, 0x4277), "RingTwice_StartCall", + "the cart address for the ring still dispatches") + eq(CallAsm.nameFor(nil, 0x04, 0x5800), "InitCallReceiveDelay", + "and the cart address for the countdown restart") + + local rang = {} + CallAsm.run({ playSfxNamed = function(_, name) rang[#rang + 1] = name end }, + "RingTwice_StartCall") + eq(rang[1], "Sfx_Call", "the ring is SFX_CALL") + + -- InitCallReceiveDelay: zero the cycle counter, park the countdown back on + -- twenty minutes. Wound on first so the reset is visible. + local save = newSave() + Phone.initReceiveDelay(save, { clock = { day = 0, hour = 9, minute = 0 } }) + Phone.checkReceiveCallTimer(save, + { clock = { day = 0, hour = 9, minute = 20 } }) + eq(save.phone.timeCycles, 1, "the timer was wound before the reset") + local ctx = { + game = { save = save }, + stepContext = function() + return { phone = { clock = { day = 0, hour = 9, minute = 20 } } } + end, + } + CallAsm.run(ctx, "InitCallReceiveDelay") + eq(save.phone.timeCycles, 0, "InitCallReceiveDelay zeroes the cycles") + eq(save.phone.delayMins, 20, "and restarts the countdown at twenty") +end + +-- ------------------------------------------------- CheckTimeEvents' arm +-- +-- The call site itself, driven through the real World:checkTimeEvents and +-- World:stepContext on a stub self: the `.do_daily` arm has to consult the +-- gate every overworld frame, hand it the tile underfoot, and route a landed +-- call into receivePhoneCall. +do + local save = newSave() + Phone.addContact(save, 15) -- Joey, ROUTE_30 + local received = {} + local coll = 0x00 + local fake = { + game = { save = save, clock = { day = 0, hour = 9, minute = 0 } }, + maps = {}, + daytime = "DAY", + playerState = "walk", + map = { + def = { id = "ROUTE_31", environment = "ROUTE", phoneService = true }, + cellCollision = function() return coll end, + }, + player = { cellX = 4, cellY = 4 }, + stepContext = World.stepContext, + checkTimeEvents = World.checkTimeEvents, + receivePhoneCall = function(_, call) + received[#received + 1] = call + return true + end, + } + -- Pin the gate's two Random draws (the coin flip and ChooseRandomCaller) + -- to the passing arm; everything else stays on the ambient stream. + local ambient = math.random + math.random = function(a, b) + if a == 0 and b == 255 then return 0 end + return ambient(a, b) + end + + eq(fake:checkTimeEvents(), false, "minute zero stamps the countdown") + fake.game.clock.minute = 19 + eq(fake:checkTimeEvents(), false, "nineteen minutes is not enough") + eq(#received, 0, "so nobody has rung") + fake.game.clock.minute = 20 + eq(fake:checkTimeEvents(), true, "the twentieth minute lands the call") + eq(#received, 1, "through receivePhoneCall") + eq(received[1] and received[1].contact, 15, "from the contact in the book") + eq(received[1] and received[1].direction, "incoming", "as an incoming call") + eq(save.phone.delayMins, 10, "and the consumed timer wound on to ten") + + -- CheckStandingOnEntrance: a door tile refuses the call BEFORE the timer + -- check, so the countdown is untouched while the player stands on it. + coll = 0x71 -- COLL_DOOR + fake.game.clock.minute = 40 + eq(fake:checkTimeEvents(), false, "standing on a door never rings") + eq(#received, 1, "no second call landed") + eq(save.phone.delayMins, 10, "and the countdown was not consumed") + coll = 0x00 + eq(fake:checkTimeEvents(), true, "stepping off the door frees the line") + eq(#received, 2, "and the held call lands") + + math.random = ambient +end + +-- ------------------------------------------------- Mom's ring +-- +-- MomTriesToBuySomething's .Script is `callasm .ASMFunction / farsjump +-- Script_ReceivePhoneCall`, so the queued shopping pages have to arrive +-- wrapped in the same chrome with PHONE_MOM on the line. +do + local save = Save.normalize({ mom = { savedMoney = 10000, active = true, + savingMoney = true }, party = {} }) + local vm = {} + local fake = { + game = { save = save }, + events = Events.new(), + vm = vm, + momTriesToBuy = World.momTriesToBuy, + } + local purchase = fake:momTriesToBuy() + check(purchase ~= nil, "a full ladder rung buys something") + local rows = fake.queuedScript + check(type(rows) == "table", "and queues the call for the overworld") + eq(rows[1] and rows[1].op, "reanchormap", "wrapped in the ring chrome") + eq(rows[3] and rows[3].label, "RingTwice_StartCall", "which rings") + check(rows[7].text:find("MOM:", 1, true) ~= nil, "as MOM") + local inner = rows[8] and rows[8].script + check(type(inner) == "table", "around her queued pages") + eq(inner[1] and inner[1].op, "rawtext", "which are the shopping lines") + eq(inner[#inner].op, "end", "and end like any caller script") + eq(vm.curPhoneCaller, Phone.PHONECONTACT_MOM, + "with wCurCaller parked on PHONE_MOM") + + -- The farscall's operand is the inline row list, the shape runList takes + -- for a script wCallerContact would have pointed at: the whole queued call + -- has to run to completion through a real Vm. + local pages = {} + local runner = Vm.new({}, {}, Events.new(), { + showText = function(body, onDone) + pages[#pages + 1] = body + onDone() + end, + }) + check(runner:start(rows), "the queued call runs whole") + pump(runner) + check(not runner:running(), "to completion") + check(pages[1]:find("MOM:", 1, true) ~= nil, "ringing as MOM") + check(table.concat(pages, "|"):find("Click!", 1, true) ~= nil, + "and hanging up on the Click!") +end + +-- ------------------------------------------------- against the cache +-- +-- A REAL caller script through a real Vm inside the wrapper: Joey's +-- (41:4368), whose .WantsBattle arm is the whole rematch mechanic. +do + local cacheDir = os.getenv("GOLD_CACHE") + if not cacheDir then + local home = os.getenv("HOME") or "" + cacheDir = home .. "/Library/Application Support/LOVE/gold-dev/gold" + end + local generated = cacheDir .. "/data/generated/" + local scriptsFile = loadfile(generated .. "scripts.lua") + if not scriptsFile then + check(true, "cache absent; VM-side blocks skipped (SKIP)") + else + local scripts = scriptsFile() + local text = assert(loadfile(generated .. "text.lua"))() + local constants = assert(loadfile(generated .. "constants.lua"))() + local trainers = assert(loadfile(generated .. "trainers.lua"))() + local encounters = assert(loadfile(generated .. "encounters.lua"))() + local pokemon = assert(loadfile(generated .. "pokemon.lua"))() + local TrainerHouse = require("src.world.gen2.TrainerHouse") + + local function newCallVm(save, world) + local pages = {} + local vm + vm = Vm.new(scripts, text, Events.new(), { + showText = function(body, onDone) + pages[#pages + 1] = body + onDone() + end, + specialOrder = constants.specialOrder, + getTrainerName = function(group, index) + return TrainerHouse.name(trainers, save, group, index) + end, + playSound = function(id) world.sfx[#world.sfx + 1] = id end, + callAsm = function(label, bank, addr) + return CallAsm.dispatch(world, label, bank, addr) + end, + specials = { + world = world, + save = function() return save end, + data = function() return { pokemon = pokemon, trainers = trainers } end, + }, + }) + return vm, pages + end + + local function newWorld(save) + return { + game = { save = save }, + encounters = encounters, + daytime = "DAY", + sfx = {}, + playSfxNamed = function(self, name) self.sfx[#self.sfx + 1] = name end, + stepContext = function() + return { phone = { clock = { day = 0, hour = 9, minute = 40 } } } + end, + } + end + + -- The rematch arm: ENGINE_FLYPOINT_GOLDENROD set (checkflag 69 in the + -- extracted body) and PhoneScript_Random2 landing 0 takes .WantsBattle, + -- whose `setevent EVENT_JOEY_READY_FOR_REMATCH` (628) is the only writer + -- of that flag in the whole game. + local save = newSave() + Phone.initReceiveDelay(save, { clock = { day = 0, hour = 9, minute = 0 } }) + local world = newWorld(save) + local vm, pages = newCallVm(save, world) + vm.engineFlags[69] = true + local ambient = math.random + math.random = function(a) return a == 0 and 0 or 0 end + vm.curPhoneCaller = 15 + local call = Phone.loadCallerScript(15, "incoming", "caller") + check(vm:start(PhoneRing.script(call, "JOEY", "YOUNGSTER")), + "the wrapped caller script runs") + pump(vm) + math.random = ambient + check(not vm:running(), "to completion") + check(pages[1]:find("RING!", 1, true) ~= nil, "opening on the ring page") + local said = table.concat(pages, "|") + check(said:find("It's me, JOEY", 1, true) ~= nil, + "the greeting names the caller off gettrainername") + check(vm.events:get(628), + "and .WantsBattle armed EVENT_JOEY_READY_FOR_REMATCH") + check(said:find("Click!", 1, true) ~= nil, "the hang-up clicks") + eq(world.sfx[1], "Sfx_Call", "the ring SFX played") + -- RingTwice_StartCall rings twice (engine/phone/phone.asm:458-469). + eq(world.sfx[2], "Sfx_Call", "and rang a second time") + eq(save.phone.delayMins, 20, + "and InitCallReceiveDelay restarted the countdown") + local unknown = {} + for op in pairs(vm.unknownOps) do unknown[#unknown + 1] = op end + eq(table.concat(unknown, ","), "", "no opcode fell through") + + -- The chatter: the shared PhoneScript_Generic body (41:48f0) opens on + -- `special RandomPhoneMon` and its "My {STRBUF}'s really energetic" line + -- reads the buffer that special filled from the CALLER'S party; the + -- `special RandomPhoneWildMon` body it chains into (41:4920) then names + -- a wild mon off the caller's own route. + local save2 = newSave() + local world2 = newWorld(save2) + local vm2, pages2 = newCallVm(save2, world2) + vm2.curPhoneCaller = 15 + -- Every Random2 lands 1 (the chat arms); Specials.random holds its own + -- reference to the roll, so it gets its own stub: the LAST slot of both + -- the party and the four commonest wilds. + math.random = function(a, b) return b or a end + local oldSpecialsRoll = Specials.random + Specials.random = function(n) return n end + check(vm2:start("41:48f0"), "the generic chat body runs") + Specials.random = oldSpecialsRoll + math.random = ambient + check(table.concat(pages2, "|"):find("My RATTATA", 1, true) ~= nil, + "RandomPhoneMon named a mon out of JOEY's own party") + local day4 = encounters.grass.ROUTE_30.slots.DAY[4] + eq(vm2.stringBuffer, pokemon[day4.species].name, + "and RandomPhoneWildMon followed with a wild one off ROUTE 30") + + -- RandomPhoneWildMon: one of the four commonest DAY slots on the + -- caller's map (Joey: ROUTE_30), named into the buffer. + local save3 = newSave() + local world3 = newWorld(save3) + local vm3 = newCallVm(save3, world3) + vm3.curPhoneCaller = 15 + local oldRoll = Specials.random + Specials.random = function() return 1 end + check(vm3:start({ { op = "special", id = 91 }, { op = "end" } }), + "RandomPhoneWildMon dispatches by its cache id") + Specials.random = oldRoll + local day = encounters.grass.ROUTE_30.slots.DAY + eq(vm3.stringBuffer, pokemon[day[1].species].name, + "and names the commonest wild mon on the caller's own route") + + -- The Pokegear's outgoing half: Game2:runPokegearCall runs the contact's + -- SCRIPT1 through the same VM, with wCurCaller parked first. + local Game2 = require("src.core.Game2") + local save4 = newSave() + Phone.addContact(save4, 15) + local world4 = newWorld(save4) + local vm4, pages4 = newCallVm(save4, world4) + world4.vm = vm4 + local gold = setmetatable({ world = world4 }, Game2) + local out = Phone.call(save4, 15, + { map = { id = "ROUTE_31", phoneService = true }, timeOfDay = "DAY" }) + eq(out.script, "JoeyPhoneCalleeScript", "the descriptor names SCRIPT1") + check(gold:runPokegearCall(out), "and runPokegearCall runs it") + check(#pages4 > 0, "so the callee actually talks") + check(table.concat(pages4, "|"):find("JOEY", 1, true) ~= nil, + "as himself") + eq(vm4.curPhoneCaller, 15, "with wCurCaller parked for the specials") + eq(out.ranScript, true, "and the descriptor records the run") + check(not gold:runPokegearCall({ kind = "outofarea" }), + "an out-of-area answer keeps the card's own line instead") + end +end + +S.finish() diff --git a/tests/gen2_phone_test.lua b/tests/gen2_phone_test.lua new file mode 100644 index 00000000..b2c9bb38 --- /dev/null +++ b/tests/gen2_phone_test.lua @@ -0,0 +1,831 @@ +-- The POKeGEAR's phone: the contact list, the receive-call timer, the random +-- caller gate, the special-call queue, the trainer rematch flags, and the +-- phone card that places an outgoing call. +-- +-- ROM-free and draw-free. src/core/gen2/Phone.lua is love-free by design, so +-- every model assertion here runs against the module directly; the Pokegear +-- section stubs the same love pieces tests/gen2_menus_test.lua does and drives +-- the card with a fake input. +-- +-- Everything asserted is transcribed behaviour, so each block names the +-- pokegold routine it is pinning down. + +package.path = "./?.lua;" .. package.path + +local drawn = {} +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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local Events = require("src.world.gen2.Events") +local Phone = require("src.core.gen2.Phone") +local Pokegear = require("src.ui.gen2.Pokegear") +local Save = require("src.core.gen2.Save") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function newSave() + return Save.normalize({}) +end + +-- A deterministic Random: one byte per cart `call Random`, in the order the +-- gate consumes them. Runs off the end of the list rather than wrapping, so a +-- test that draws more rolls than it queued fails loudly instead of quietly +-- re-using one. +local function rolls(...) + local queue = { ... } + local index = 0 + return function() + index = index + 1 + return queue[index] or error("ran out of seeded rolls at draw " .. index, 0) + end +end + +-- ------------------------------------------------------ the contact table + +-- data/phone/phone_contacts.asm, `assert_table_length NUM_PHONE_CONTACTS + 1`. +check("the contact table is zero based", Phone.CONTACTS[0] ~= nil, true) +check("and runs to NUM_PHONE_CONTACTS", + Phone.CONTACTS[Phone.NUM_PHONE_CONTACTS] ~= nil, true) +check("with nothing past it", + Phone.CONTACTS[Phone.NUM_PHONE_CONTACTS + 1], nil) + +-- constants/trainer_constants.asm opens the non-trainer block with +-- `const_def 1`. A zero-based reading here would shift every name by one and +-- leave PROF.ELM off the end. +check("PHONECONTACT_MOM is 1", Phone.PHONECONTACT_MOM, 1) +check("PHONECONTACT_ELM is 4", Phone.PHONECONTACT_ELM, 4) +check("and MOM's name comes off that index", + Phone.contactName(1), "MOM") +check("as does PROF.ELM's", Phone.contactName(4), "PROF.ELM") +check("contact 0 is the wrong-number filler", + Phone.contactName(0), "----------") + +-- The three const_skip holes between HUEY and GAVEN, and the one before DANA, +-- are real rows in the ROM table: they must resolve, not index nil. +check("a const_skip hole is a live row", Phone.CONTACTS[9] ~= nil, true) +check("and it can never be called", Phone.CONTACTS[9].callerTime, 0) +check("nor called out to", Phone.CONTACTS[25].calleeTime, 0) + +-- SCRIPT2_TIME is the mask for THEM calling YOU, and Mom, Elm, Bill and the +-- bike shop all have it at 0. This is why an unprompted Elm call can only +-- ever arrive through the special-call queue. +check("Mom never rings you at random", Phone.CONTACTS[1].callerTime, 0) +check("nor does Elm", Phone.CONTACTS[4].callerTime, 0) +check("but Joey does", Phone.CONTACTS[15].callerTime, Phone.ANYTIME) +check("and Elm can still be called", Phone.CONTACTS[4].calleeTime, + Phone.ANYTIME) + +-- The scripts live in ROM bank $41, which the importer does not reach yet, so +-- a contact carries the label and the key that label will have. +check("a contact names its caller script", + Phone.CONTACTS[15].caller, "JoeyPhoneCallerScript") +check("and resolves it to a scripts.lua key", + Phone.scriptKey("JoeyPhoneCallerScript"), "41:4368") + +-- ------------------------------------------------------ the contact list + +-- engine/phone/phone.asm AddPhoneNumber / _CheckCellNum / DelCellNum. +do + local save = newSave() + check("a new phone book is empty", Phone.hasContact(save, 15), false) + check("adding Joey works", Phone.addContact(save, 15), true) + check("and he is in the book", Phone.hasContact(save, 15), true) + check("in the first slot", Phone.contacts(save)[1], 15) + local ok, why = Phone.addContact(save, 15) + check("adding him twice is refused", ok, false) + check("because he is already there", why, "already") + check("removing him works", Phone.removeContact(save, 15), true) + check("and he is gone", Phone.hasContact(save, 15), false) + check("removing him again does nothing", Phone.removeContact(save, 15), false) +end + +-- GetRemainingSpaceInPhoneList reserves a slot for every PERMANENT number you +-- have not registered yet, so a player who has met neither MOM nor ELM can +-- only fill eight of the ten slots. +do + local save = newSave() + check("two permanent numbers are outstanding", + Phone.remainingSlots(save, 15), 8) + Phone.addContact(save, Phone.PHONECONTACT_MOM) + check("registering MOM frees her slot", Phone.remainingSlots(save, 15), 9) + Phone.addContact(save, Phone.PHONECONTACT_ELM) + check("and registering ELM frees his", Phone.remainingSlots(save, 15), 10) +end + +-- `cp c / jr z, .continue`: a permanent number never reserves a slot against +-- itself, which is what lets MOM in when the book is otherwise full. +do + local save = newSave() + local trainers = { 15, 16, 17, 18, 19, 20, 21, 22 } + for _, id in ipairs(trainers) do + check("trainer " .. id .. " fits", Phone.addContact(save, id), true) + end + local ok, why = Phone.addContact(save, 23) + check("the ninth trainer does not", ok, false) + check("because the book is full", why, "full") + check("but MOM still fits", + Phone.addContact(save, Phone.PHONECONTACT_MOM), true) + check("and so does ELM", + Phone.addContact(save, Phone.PHONECONTACT_ELM), true) + check("filling all ten slots", Phone.contacts(save)[10], + Phone.PHONECONTACT_ELM) +end + +-- Script_askforphonenumber's three PHONE_CONTACT_* return values. A refusal +-- never touches the book, and a full book reports FULL rather than GOT. +do + local save = newSave() + check("refusing returns PHONE_CONTACT_REFUSED", + Phone.askForNumber(save, 15, false), Phone.CONTACT_REFUSED) + check("and stores nothing", Phone.hasContact(save, 15), false) + check("accepting returns PHONE_CONTACT_GOT", + Phone.askForNumber(save, 15, true), Phone.CONTACT_GOT) + check("a second ask reports PHONE_CONTACTS_FULL", + Phone.askForNumber(save, 15, true), Phone.CONTACTS_FULL) +end + +-- PokegearPhone_DeletePhoneNumber blanks the slot and then compacts, so the +-- display never shows a gap; CheckCanDeletePhoneNumber withholds DELETE from +-- MOM and ELM. +do + local save = newSave() + Phone.addContact(save, 15) + Phone.addContact(save, 16) + Phone.addContact(save, 17) + Phone.deleteContactAt(save, 2) + local list = Phone.contacts(save) + check("delete compacts the list", list[1], 15) + check("pulling the tail forward", list[2], 17) + check("and leaving the end empty", list[3], 0) + check("MOM cannot be deleted", Phone.canDelete(Phone.PHONECONTACT_MOM), false) + check("nor can ELM", Phone.canDelete(Phone.PHONECONTACT_ELM), false) + check("but BILL can", Phone.canDelete(Phone.PHONECONTACT_BILL), true) + check("and so can a trainer", Phone.canDelete(15), true) + check("an empty slot cannot", Phone.canDelete(0), false) +end + +-- The VM's addcellnum hook writes save.phoneContacts directly +-- (src/world/gen2/World.lua); the model adopts that set into wPhoneList and +-- mirrors the list back over it, so neither half has to move first. +do + local save = newSave() + save.phoneContacts = { [15] = true, [16] = true } + check("a legacy set is adopted into the list", + Phone.hasContact(save, 16), true) + Phone.removeContact(save, 15) + Phone.state(save) + check("and a delete propagates back out", save.phoneContacts[15], nil) + check("leaving the rest alone", save.phoneContacts[16], true) +end + +-- ------------------------------------------------------ the receive timer + +-- engine/overworld/time.asm InitCallReceiveDelay / NextCallReceiveDelay / +-- CheckReceiveCallTimer. The ladder is 20, 10, 5, 3 minutes, indexed by +-- wTimeCyclesSinceLastCall and capped at three. +local function at(hour, minute, day) + return { clock = { day = day or 0, hour = hour, minute = minute } } +end + +do + local save = newSave() + Phone.initReceiveDelay(save, at(9, 0)) + check("the first delay is twenty minutes", save.phone.delayMins, 20) + check("nineteen minutes is not enough", + Phone.checkReceiveCallTimer(save, at(9, 19)), false) + check("with a minute left on the clock", save.phone.delayMins, 1) + check("the twentieth minute expires it", + Phone.checkReceiveCallTimer(save, at(9, 20)), true) + check("and the counter winds on", save.phone.timeCycles, 1) + check("shortening the next delay to ten", save.phone.delayMins, 10) + check("which expires ten minutes later", + Phone.checkReceiveCallTimer(save, at(9, 30)), true) + check("then five", save.phone.delayMins, 5) + Phone.checkReceiveCallTimer(save, at(9, 35)) + check("then three", save.phone.delayMins, 3) + Phone.checkReceiveCallTimer(save, at(9, 38)) + check("and three stays three", save.phone.delayMins, 3) + check("with the counter capped", save.phone.timeCycles, 3) +end + +-- GetMinutesSinceIfLessThan60 gives up past an hour and hands +-- UpdateTimeRemaining -1, which it treats as expired outright. +do + local save = newSave() + Phone.initReceiveDelay(save, at(9, 0)) + check("an hour away expires the timer at once", + Phone.checkReceiveCallTimer(save, at(11, 5)), true) +end + +-- StartMap runs InitCallReceiveDelay on EVERY map load, which is why warping +-- around never gets you a call. +do + local save = newSave() + Phone.initReceiveDelay(save, at(9, 0)) + Phone.checkReceiveCallTimer(save, at(9, 19)) + Phone.onMapLoad(save, at(9, 19)) + check("a map load resets the countdown", save.phone.delayMins, 20) + check("and the cycle counter with it", save.phone.timeCycles, 0) +end + +-- ------------------------------------------------------ who can call + +-- GetAvailableCallers: the SCRIPT2_TIME mask has to cover the current time of +-- day, and a contact standing on your own map is skipped. +local ROUTE_30 = { id = "ROUTE_30", environment = "ROUTE", phoneService = true } +local ROUTE_31 = { id = "ROUTE_31", environment = "ROUTE", phoneService = true } +local NO_SERVICE = { id = "SPROUT_TOWER_1F", environment = "INDOOR", + phoneService = false } + +do + local save = newSave() + Phone.addContact(save, 15) -- Joey, ROUTE_30 + Phone.addContact(save, 16) -- Wade, ROUTE_31 + local here = { map = ROUTE_30, timeOfDay = "DAY" } + local callers = Phone.availableCallers(save, here) + check("one caller is available on Route 30", #callers, 1) + check("and it is not the trainer standing there", callers[1], 16) + local elsewhere = Phone.availableCallers(save, + { map = { id = "ROUTE_32", phoneService = true }, timeOfDay = "DAY" }) + check("off both their maps, both are available", #elsewhere, 2) +end + +-- CheckTime's table has no live DARKNESS row, so a dark map resolves to c = 0 +-- and nobody's ANYTIME mask can match it. +do + local save = newSave() + Phone.addContact(save, 15) + check("darkness leaves nobody available", + #Phone.availableCallers(save, { map = ROUTE_31, timeOfDay = "DARK" }), 0) +end + +-- Mom's SCRIPT2_TIME is 0, so she is never sampled however full the book is. +do + local save = newSave() + Phone.addContact(save, Phone.PHONECONTACT_MOM) + check("Mom is never an available caller", + #Phone.availableCallers(save, { map = ROUTE_31, timeOfDay = "DAY" }), 0) +end + +-- ChooseRandomCaller: swap the nibbles of one random byte, mask to 0..31, then +-- take it modulo the number of callers (SimpleDivide returns the remainder). +do + local callers = { 15, 16, 17 } + -- $10 swaps to $01, & $1f = 1, 1 % 3 = 1 -> the second caller. + check("a seeded roll picks the second caller", + Phone.chooseRandomCaller(callers, rolls(0x10)), 16) + -- $20 swaps to $02, & $1f = 2, 2 % 3 = 2 -> the third. + check("and another picks the third", + Phone.chooseRandomCaller(callers, rolls(0x20)), 17) + -- $30 swaps to $03, & $1f = 3, 3 % 3 = 0 -> back to the first. + check("the modulo wraps", Phone.chooseRandomCaller(callers, rolls(0x30)), 15) + check("an empty list samples nothing", + Phone.chooseRandomCaller({}, rolls(0x10)), nil) +end + +-- CheckTime derives wTimeOfDay from the hour, on +-- constants/misc_constants.asm's boundaries: MORN at 4, DAY at 10, NITE at 18. +check("3am is night", Phone.timeOfDay(at(3, 0)), Phone.NITE) +check("4am is morning", Phone.timeOfDay(at(4, 0)), Phone.MORN) +check("10am is day", Phone.timeOfDay(at(10, 0)), Phone.DAY) +check("6pm is night again", Phone.timeOfDay(at(18, 0)), Phone.NITE) + +-- GetMapPhoneService returns the header nybble and every caller tests it +-- against zero, so ZERO means the map HAS service; maps.lua already decodes +-- that into a boolean. +check("a serviced map has a signal", Phone.mapHasService({ map = ROUTE_30 }), + true) +check("a dead zone does not", Phone.mapHasService({ map = NO_SERVICE }), false) + +-- ------------------------------------------------------ the random-call gate + +-- CheckPhoneCall, in order: not on an entrance tile, the timer has expired, a +-- 50% coin flip, the map has a signal, and someone is available. +local function armedSave() + local save = newSave() + Phone.addContact(save, 15) -- Joey, ROUTE_30 + Phone.initReceiveDelay(save, at(9, 0)) + return save +end + +do + local save = armedSave() + local call = Phone.tryRandomCall(save, { + map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + rng = rolls(0x00, 0x00), + }) + check("a call lands once every gate passes", call ~= nil, true) + check("from the contact in the book", call and call.contact, 15) + check("running their caller script", call and call.script, + "JoeyPhoneCallerScript") + check("keyed for scripts.lua", call and call.scriptKey, "41:4368") + check("and it is an incoming call", call and call.direction, "incoming") +end + +do + local save = armedSave() + check("standing on a door refuses the call", Phone.tryRandomCall(save, { + map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + standingOnEntrance = true, rng = rolls(0x00, 0x00), + }), nil) + check("and the timer is untouched by the refusal", + save.phone.delayMins, 20) +end + +do + local save = armedSave() + check("too soon is no call", Phone.tryRandomCall(save, { + map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 5 }, + rng = rolls(0x00, 0x00), + }), nil) + check("but the countdown still ran down", save.phone.delayMins, 15) +end + +do + local save = armedSave() + -- `and %01111111 / cp b`: a byte with its top bit set fails the flip. + check("the coin flip refuses half the time", Phone.tryRandomCall(save, { + map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + rng = rolls(0x80), + }), nil) +end + +do + local save = armedSave() + check("no signal is no call", Phone.tryRandomCall(save, { + map = NO_SERVICE, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + rng = rolls(0x00, 0x00), + }), nil) +end + +do + local save = armedSave() + check("nobody available is no call", Phone.tryRandomCall(save, { + map = ROUTE_30, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + rng = rolls(0x00, 0x00), + }), nil) +end + +-- ------------------------------------------------------ special calls + +-- Script_specialphonecall parks the id in wSpecialPhoneCallID; +-- Script_checkphonecall reports whether one is waiting; CheckSpecialPhoneCall +-- fires it the next time its condition holds. +do + local save = newSave() + check("nothing is queued to start with", Phone.hasSpecialCall(save), false) + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ROBBED) + check("queueing one shows up", Phone.hasSpecialCall(save), true) + check("and readvar VAR_SPECIALPHONECALL sees the id", + Phone.specialCallVar(save), 2) + -- SpecialCallOnlyWhenOutside takes TOWN and ROUTE and nothing else. + check("indoors it holds off", + Phone.checkSpecialCall(save, { map = NO_SERVICE }), nil) + local call = Phone.checkSpecialCall(save, { map = ROUTE_30 }) + check("outdoors it fires", call ~= nil, true) + check("as Elm", call and call.contact, Phone.PHONECONTACT_ELM) + check("with the special call's script, not Elm's own", + call and call.script, "ElmPhoneCallerScript") + check("naming which special call it is", call and call.special, 2) + check("and pausing thirty frames first", call and call.delay, 30) +end + +-- SPECIALCALL_WORRIED is Mom's lecture, and its condition is +-- SpecialCallWhereverYouAre -- it reaches you indoors too. +do + local save = newSave() + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_WORRIED) + local call = Phone.checkSpecialCall(save, { map = NO_SERVICE }) + check("Mom's lecture reaches you anywhere", call ~= nil, true) + check("from Mom", call and call.contact, Phone.PHONECONTACT_MOM) + check("running her lecture script", call and call.script, + "MomPhoneLectureScript") +end + +-- A queued call fires exactly once. On the cart the called script clears the +-- queue itself (`specialphonecall SPECIALCALL_NONE`); bank $41 is not +-- extracted, so Phone.endCall clears it for a call whose script never ran. +do + local save = newSave() + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ASSISTANT) + local call = Phone.checkSpecialCall(save, { map = ROUTE_30 }) + check("the queued call fires", call ~= nil, true) + check("and is still queued while it runs", Phone.hasSpecialCall(save), true) + Phone.endCall(save, call, at(9, 0)) + check("hanging up clears it", Phone.hasSpecialCall(save), false) + check("so it does not fire twice", + Phone.checkSpecialCall(save, { map = ROUTE_30 }), nil) + check("and the receive timer restarts", save.phone.delayMins, 20) +end + +-- Once the VM can run bank $41, the script's own clear is what fires and the +-- fallback must keep its hands off. +do + local save = newSave() + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ASSISTANT) + local call = Phone.checkSpecialCall(save, { map = ROUTE_30 }) + call.ranScript = true + Phone.endCall(save, call, at(9, 0)) + check("a call whose script ran clears itself", + Phone.hasSpecialCall(save), true) +end + +-- ------------------------------------------------------ rematches + +-- engine/phone/scripts/trainers.asm: the .WantsBattle branch of a caller +-- script sets EVENT_<NAME>_READY_FOR_REMATCH. The numbers are wEventFlags bit +-- indexes counted through constants/event_flags.asm. +do + local events = Events.new() + check("Joey has a rematch flag", Phone.rematchEvent(15), 628) + check("and Erin the last one", Phone.rematchEvent(36), 670) + check("Mom has none", Phone.rematchEvent(Phone.PHONECONTACT_MOM), nil) + check("nobody is waiting yet", Phone.isReadyForRematch(events, 15), false) + check("setting the flag works", Phone.setRematchReady(events, 15), true) + check("and Joey is waiting", Phone.isReadyForRematch(events, 15), true) + check("without dragging anyone else in", + Phone.isReadyForRematch(events, 16), false) + check("a contact with no flag cannot be set", + Phone.setRematchReady(events, Phone.PHONECONTACT_MOM), false) +end + +-- ------------------------------------------------------ outgoing calls + +-- MakePhoneCallFromPokegear. Three outcomes: the call, "just go talk to that +-- person" when they are on this map, and out of area. +do + local save = newSave() + Phone.addContact(save, 15) + local call = Phone.call(save, 15, { map = ROUTE_31 }) + check("calling Joey runs his callee script", call.script, + "JoeyPhoneCalleeScript") + check("as an outgoing call", call.direction, "outgoing") + check("standing on his map says go talk to him", + Phone.call(save, 15, { map = ROUTE_30 }).kind, "justtalk") + check("no signal is out of area", + Phone.call(save, 15, { map = NO_SERVICE }).kind, "outofarea") + check("and so is a link session", + Phone.call(save, 15, { map = ROUTE_31, linkMode = true }).kind, "outofarea") +end + +-- The bike shop's SCRIPT1_TIME is 0: there is no hour at which you can call +-- them, only one at which they call you. +do + local save = newSave() + check("the bike shop cannot be called", + Phone.call(save, Phone.PHONECONTACT_BIKESHOP, + { map = ROUTE_31, timeOfDay = "DAY" }).kind, "outofarea") +end + +-- ------------------------------------------------------ the phone card + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +-- Just enough of data/generated/trainers.lua for GetCallerClassAndName: the +-- contact table stores class and member ids, and the names come off that +-- table. Stubbed rather than loaded so this suite stays ROM-free. +local TRAINERS = { classes = { + YOUNGSTER = { id = "YOUNGSTER", name = "YOUNGSTER", index = 24, + trainers = { { id = "JOEY1", name = "JOEY" } } }, + BUG_CATCHER = { id = "BUG_CATCHER", name = "BUG CATCHER", index = 2, + trainers = { { id = "WADE1", name = "WADE" } } }, +} } + +-- A Pokegear parked on the PHONE card, inside it rather than on the strip. +local function newGear(save, opts) + opts = opts or {} + local input = newInput() + local game = { + input = input, + save = save, + data = { audio = {}, pokemon = {}, items = {} }, + stack = { push = function() end, pop = function() end }, + } + -- Pokegear:visibleCards keys off wPokegearFlags; without the PHONE bit the + -- card is not on the strip at all. + save.pokegearFlags = save.pokegearFlags or {} + save.pokegearFlags.phone = true + local gear = Pokegear.new(game, { + save = save, + mapDef = opts.mapDef or ROUTE_31, + clock = { hour = 9, minute = 0, weekday = 1 }, + trainers = TRAINERS, + onCall = opts.onCall, + }) + gear.mode = "card" + for index, card in ipairs(gear.cards) do + if card.id == "phone" then gear.cardIndex = index end + end + return gear, input +end + +do + local save = newSave() + for _, id in ipairs({ 15, 16, 17, 18, 19, 20 }) do + Phone.addContact(save, id) + end + local gear, input = newGear(save) + check("the card opens on the phone", gear:card().id, "phone") + check("with the cursor at the top", gear.phoneCursor, 0) + -- PokegearPhone_GetDPad: the cursor walks the four visible rows first. + for _ = 1, 3 do + input:press("down") + gear:update(0) + end + check("down walks the cursor to the last visible row", gear.phoneCursor, 3) + check("without scrolling yet", gear.phoneScroll, 0) + input:press("down") + gear:update(0) + check("the next press scrolls instead", gear.phoneScroll, 1) + check("leaving the cursor where it is", gear.phoneCursor, 3) + check("on the fifth contact", gear:phoneSelection(), 19) + -- .scroll_page_down stops dead at CONTACT_LIST_SIZE - PHONE_DISPLAY_HEIGHT. + for _ = 1, 20 do + input:press("down") + gear:update(0) + end + check("the scroll stops at the end of the list", gear.phoneScroll, 6) + for _ = 1, 20 do + input:press("up") + gear:update(0) + end + check("and up unwinds it to the top", gear.phoneScroll, 0) + check("with the cursor back at row 0", gear.phoneCursor, 0) +end + +-- `.a` returns straight back out on an empty slot: no submenu, no call. +do + local save = newSave() + local gear, input = newGear(save) + input:press("a") + gear:update(0) + check("an empty slot opens nothing", gear.phoneSubmenu, nil) +end + +-- CheckCanDeletePhoneNumber picks which submenu opens. +do + local save = newSave() + Phone.addContact(save, Phone.PHONECONTACT_MOM) + Phone.addContact(save, 15) + local gear, input = newGear(save) + input:press("a") + gear:update(0) + check("MOM gets the CALL/CANCEL menu", gear.phoneSubmenu, "callCancel") + input:press("b") + gear:update(0) + check("B closes it", gear.phoneSubmenu, nil) + check("without leaving the card", gear.mode, "card") + input:press("down") + gear:update(0) + input:press("a") + gear:update(0) + check("a trainer gets CALL/DELETE/CANCEL", gear.phoneSubmenu, + "callDeleteCancel") +end + +-- The submenu's CALL entry runs MakePhoneCallFromPokegear and hands the +-- descriptor out to whoever can run its script. +do + local save = newSave() + Phone.addContact(save, 15) + local seen + local gear, input = newGear(save, { onCall = function(c) seen = c end }) + input:press("a") + gear:update(0) + input:press("a") + gear:update(0) + check("CALL places the call", gear.call ~= nil, true) + check("to Joey", gear.call and gear.call.contact, 15) + check("and hands the descriptor out", seen == gear.call, true) + check("naming his callee script", seen and seen.script, + "JoeyPhoneCalleeScript") + check("with his name for the textbox", seen and seen.name, "JOEY") + check("and his class under it", seen and seen.className, "YOUNGSTER") + -- Bank $41 is not extracted, so there is no line to run: the card shows what + -- the cart shows while a call connects. + check("the box shows the caller and the ellipsis", + gear.call and gear.call.text, "JOEY: ……") + input:press("b") + gear:update(0) + check("any button hangs up", gear.call, nil) + check("still inside the card", gear.mode, "card") +end + +-- .no_service never reaches MakePhoneCallFromPokegear at all: it plays +-- SFX_NO_SIGNAL and prints its own out-of-service line. +do + local save = newSave() + Phone.addContact(save, 15) + local gear, input = newGear(save, { mapDef = NO_SERVICE }) + input:press("a") + gear:update(0) + input:press("a") + gear:update(0) + check("a dead zone refuses the call", gear.call and gear.call.kind, + "nosignal") +end + +-- DELETE goes through the compacting delete, so the row below moves up. +do + local save = newSave() + Phone.addContact(save, 15) + Phone.addContact(save, 16) + local gear, input = newGear(save) + input:press("a") + gear:update(0) + input:press("down") + gear:update(0) + check("the cursor is on DELETE", gear.phoneSubmenuCursor, 1) + input:press("a") + gear:update(0) + check("the submenu closes", gear.phoneSubmenu, nil) + check("Joey is gone", Phone.hasContact(save, 15), false) + check("and Wade has moved up", Phone.contacts(save)[1], 16) +end + +-- The list draws every visible slot, empty ones included: GetCallerName maps +-- contact 0 to NonTrainerCallerNames' dashes. +do + local save = newSave() + Phone.addContact(save, 15) + local gear = newGear(save) + local label, className = gear:contactRow(0) + check("an empty row still has a label", label, "----------:") + check("with no class under it", className, nil) + local name = gear:contactRow(Phone.PHONECONTACT_MOM) + check("MOM's row is her name and a colon", name, "MOM:") +end + +-- The phone text lives in ROM bank $66, which is not extracted; the card +-- prefers the extracted string and falls back to the transcription. +do + local save = newSave() + local gear = newGear(save) + check("the fallback prompt is the cart's", + gear:phoneText("AskWhoCall"), "Whom do you want to call?") + gear.textData = { ["66:4089"] = "EXTRACTED" } + check("and extracted text wins when it arrives", + gear:phoneText("AskWhoCall"), "EXTRACTED") +end + +-- ------------------------------------------------------ the save + +-- Everything the phone persists rides one block on the Gold save, and +-- Save.normalize has to leave it alone. +do + local save = newSave() + Phone.addContact(save, 15) + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_ROBBED) + Phone.initReceiveDelay(save, at(9, 0)) + Save.normalize(save) + check("the phone block survives normalize", type(save.phone), "table") + check("with the contact list", save.phone.list[1], 15) + check("the queued special call", save.phone.specialCall, 2) + check("the cycle counter", save.phone.timeCycles, 0) + check("the countdown", save.phone.delayMins, 20) + check("and its start stamp", save.phone.delayStart.hour, 9) + check("and the legacy set is still mirrored", save.phoneContacts[15], true) +end + +-- ------------------------------------------------------ against the cache +-- +-- The tables in Phone.lua were transcribed from data/phone/*.asm and the +-- symbol file at a time when nothing pointed into ROM bank $41. The extractor +-- follows PhoneContacts and SpecialPhoneCallList now, so the two must agree -- +-- and every script key they name has to be a real entry in scripts.lua, or a +-- call arrives with a body that cannot run and the step counter stalls. +do + local cacheDir = os.getenv("GOLD_CACHE") + if not cacheDir then + local home = os.getenv("HOME") or "" + cacheDir = home .. "/Library/Application Support/LOVE/gold-dev/gold" + end + local eventsFile = loadfile(cacheDir .. "/data/generated/events.lua") + if not eventsFile then + check("cache absent or predates events.lua (SKIP)", true, true) + else + local events = eventsFile() + local scripts = assert(loadfile( + cacheDir .. "/data/generated/scripts.lua"))() + check("useExtracted takes the cache's rows", Phone.useExtracted(events), true) + for index = 0, Phone.NUM_PHONE_CONTACTS do + local row = Phone.CONTACTS[index] + local was = Phone.SCRIPT_KEYS[row.callee] + check(("contact %d callee key matches the transcription"):format(index), + row.calleeKey, was) + check(("contact %d caller key matches"):format(index), + row.callerKey, Phone.SCRIPT_KEYS[row.caller]) + check(("contact %d callee script is in scripts.lua"):format(index), + scripts[row.calleeKey] ~= nil, true) + check(("contact %d caller script is in scripts.lua"):format(index), + scripts[row.callerKey] ~= nil, true) + end + -- The five Elm special calls all point at ElmPhoneCallerScript, which is + -- what lets one contact serve five different scripted beats; the bike shop + -- and Mom have their own. + for id, entry in pairs(Phone.SPECIAL_CALLS) do + check(("special call %d key matches the transcription"):format(id), + entry.scriptKey, Phone.SCRIPT_KEYS[entry.script]) + check(("special call %d script is in scripts.lua"):format(id), + scripts[entry.scriptKey] ~= nil, true) + end + -- SPECIALCALL_ROBBED is Elm's "your POKeMON was stolen" beat, and its + -- script really is the shared caller script that branches on + -- VAR_SPECIALPHONECALL rather than a script of its own. + local robbed = Phone.SPECIAL_CALLS[Phone.SPECIALCALL.SPECIALCALL_ROBBED] + check("SPECIALCALL_ROBBED rings PROF.ELM", + robbed.contact, Phone.PHONECONTACT_ELM) + check("and its script reads VAR_SPECIALPHONECALL first", + scripts[robbed.scriptKey][1].op, "readvar") + -- The engine's own two scripts live in bank $24 and are reached without a + -- contact row; they are seeded by nothing but a map pointer, so this is + -- the check that says whether they came along. + check("PhoneOutOfAreaScript is reachable", + scripts[Phone.SCRIPT_KEYS.PhoneOutOfAreaScript] ~= nil, true) + -- The wiring around a landed call: the gate's descriptor drops straight + -- into Script_ReceivePhoneCall's rows (src/core/gen2/PhoneRing.lua, + -- driven end to end by tests/gen2_phone_call_test.lua) and the caller + -- script it wraps, resolved through the cache overlay just applied, is + -- live in this cache. + local PhoneRing = require("src.core.gen2.PhoneRing") + local armed = armedSave() + local landed = Phone.tryRandomCall(armed, { + map = ROUTE_31, timeOfDay = "DAY", clock = { hour = 9, minute = 20 }, + rng = rolls(0x00, 0x00), + }) + check("the gate lands a call with the cache rows applied", + landed ~= nil, true) + local rows = PhoneRing.script(landed, "JOEY") + -- Two waitsfx/callasm ring passes now, RingTwice_StartCall's `call .Ring` + -- plus its fallthrough (engine/phone/phone.asm:458-469), so the caller + -- script sits four rows further down than it used to. + check("the wrapper rings before the caller script", + rows[3].label, "RingTwice_StartCall") + check("restarts the countdown after it", + rows[12].label, "InitCallReceiveDelay") + check("and the script it wraps is live in this cache", + scripts[rows[8].script] ~= nil, true) + end +end + +print(("gen2 phone: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit +-- here takes the whole tier down with it. +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_pokecenter_spawn_test.lua b/tests/gen2_pokecenter_spawn_test.lua new file mode 100644 index 00000000..a89a8d12 --- /dev/null +++ b/tests/gen2_pokecenter_spawn_test.lua @@ -0,0 +1,139 @@ +-- Walking into a Pokecenter has to move the respawn point. +-- +-- luajit tests/gen2_pokecenter_spawn_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua): every whiteout at +-- the Elite Four dropped the player in CHERRYGROVE_CITY and the bot spent +-- ~40k frames per attempt walking back across Johto. +-- +-- The cause is one unported rule. home/map.asm's LoadMapAttributes ends in +-- .SetSpawn: +-- +-- call GetMapEnvironment / CheckOutdoorMap ; leaving an outdoor map +-- call GetAnyMapEnvironment / CheckIndoorMap ; entering an indoor one +-- call GetAnyMapTileset / cp TILESET_POKECENTER +-- ld a, [wPrevMapGroup] -> wLastSpawnMapGroup +-- ld a, [wPrevMapNumber] -> wLastSpawnMapNumber +-- +-- That pair is what engine/events/whiteout.asm reads, and walking in the door +-- is the ONLY thing in the game that moves it -- healing does not, and neither +-- does saving. The port never had it, so the pair only ever held what +-- `blackoutmod` wrote... and MrPokemonsHouse.asm does `blackoutmod +-- CHERRYGROVE_CITY` in the first half hour. With no other writer, every +-- whiteout for the remaining forty hours of the game returned the player to +-- Cherrygrove. +-- +-- What is stored is the map being LEFT, not the Pokecenter: spawn_points.asm is +-- keyed that way (`spawn PALLET_TOWN, 5, 6`), and warpToSpawn resolves the +-- stored map through that table to get the coordinates. It is what makes the +-- Indigo Plateau centre work -- it is entered from ROUTE_23, and SPAWN_INDIGO +-- is ROUTE_23 (9,6). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 pokecenter spawn") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") + +-- The rule reads three fields off two map defs and writes one save field, so +-- it can be exercised against a stub world rather than a booted game. +local function world(prevDef) + return setmetatable({ + maps = {}, + tilesets = {}, + game = { save = {} }, + map = prevDef and { def = prevDef } or nil, + }, { __index = World }) +end + +local TOWN = { id = "CHERRYGROVE_CITY", environment = "TOWN", + tileset = "TILESET_JOHTO" } +local ROUTE = { id = "ROUTE_29", environment = "ROUTE", + tileset = "TILESET_JOHTO" } +local CENTER = { id = "CHERRYGROVE_POKECENTER_1F", environment = "INDOOR", + tileset = "TILESET_POKECENTER" } +local CENTER2F = { id = "POKECENTER_2F", environment = "INDOOR", + tileset = "TILESET_POKECENTER" } +local MART = { id = "CHERRYGROVE_MART", environment = "INDOOR", + tileset = "TILESET_MART" } +local GYM = { id = "BRUNOS_ROOM", environment = "INDOOR", + tileset = "TILESET_ELITE_FOUR_ROOM" } + +-- The shipped rule, not a restatement of it: setMap calls exactly this, and it +-- needs nothing but two map defs and a save. +local function applySpawnRule(w, def, mapId) + return w:updateWhiteoutSpawn(def, mapId) +end + +-- The case the bug was about: town -> Pokecenter moves the spawn. +do + local w = world(TOWN) + w.game.save.blackoutMap = "CHERRYGROVE_CITY" + applySpawnRule(w, CENTER, CENTER.id) + eq(w.game.save.blackoutMap, "CHERRYGROVE_CITY", + "walking in from the town stores the TOWN, the way spawn_points.asm is keyed") +end + +-- A Pokecenter reached from a ROUTE counts too: CheckOutdoorMap passes ROUTE +-- and TOWN alike, which is what makes the Indigo Plateau centre work (it is +-- entered from INDIGO_PLATEAU, environment ROUTE). +do + local w = world(ROUTE) + applySpawnRule(w, CENTER, CENTER.id) + eq(w.game.save.blackoutMap, "ROUTE_29", + "a Pokecenter entered from a route stores the route") +end + +-- Indoor -> indoor must NOT move it. Going up to the trade floor is the case +-- that matters: POKECENTER_2F has the Pokecenter tileset, so without the +-- environment guard on the map being LEFT this would fire on the stairs and +-- pin the spawn to the second floor. +do + local w = world(CENTER) + w.game.save.blackoutMap = "CHERRYGROVE_POKECENTER_1F" + applySpawnRule(w, CENTER2F, CENTER2F.id) + eq(w.game.save.blackoutMap, "CHERRYGROVE_POKECENTER_1F", + "1F -> 2F leaves the spawn alone") +end + +-- Any other building leaves it alone, however indoor it is. +do + local w = world(TOWN) + w.game.save.blackoutMap = "CHERRYGROVE_POKECENTER_1F" + applySpawnRule(w, MART, MART.id) + eq(w.game.save.blackoutMap, "CHERRYGROVE_POKECENTER_1F", + "the Mart is not a Pokecenter") + applySpawnRule(w, GYM, GYM.id) + eq(w.game.save.blackoutMap, "CHERRYGROVE_POKECENTER_1F", + "an Elite Four room is not a Pokecenter") +end + +-- The first map load of a new game has no previous map at all. +do + local w = world(nil) + applySpawnRule(w, CENTER, CENTER.id) + check(w.game.save.blackoutMap == nil, + "no previous map means no spawn write") +end + +-- And the payoff: warpToSpawn honours what the rule wrote. This is the half +-- that was already correct -- the stored map wins over the SPAWN_* lookup -- +-- and it is why storing the Pokecenter is enough. +do + local landed = {} + local w = setmetatable({ + maps = { + CHERRYGROVE_POKECENTER_1F = { warps = { { x = 3, y = 7 } } }, + }, + game = { save = { blackoutMap = "CHERRYGROVE_POKECENTER_1F" } }, + setMap = function(_, id, x, y, facing) + landed.id, landed.x, landed.y, landed.facing = id, x, y, facing + end, + }, { __index = World }) + w:warpToSpawn() + eq(landed.id, "CHERRYGROVE_POKECENTER_1F", "a whiteout lands in the centre") + eq(landed.x, 3, "at its first warp x") + eq(landed.y, 7, "at its first warp y") +end + +S.finish() diff --git a/tests/gen2_pokecenter_stairs_test.lua b/tests/gen2_pokecenter_stairs_test.lua new file mode 100644 index 00000000..3117da76 --- /dev/null +++ b/tests/gen2_pokecenter_stairs_test.lua @@ -0,0 +1,189 @@ +-- The shared POKECENTER_2F staircase must lead back down into whichever +-- centre it was climbed from. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_pokecenter_stairs_test.lua +-- +-- maps/Pokecenter2F.asm declares its one staircase as +-- +-- warp_event 0, 7, POKECENTER_2F, -1 +-- +-- and that -1 is a contract with home/map.asm, in two halves. CopyWarpData +-- stores the warp stepped ON and the map being left in wPrevWarp / +-- wPrevMapGroup / wPrevMapNumber on every warp taken; LoadMapAttributes' +-- warp-coordinate read then copies that triple into wBackupWarpNumber / +-- wBackupMapGroup / wBackupMapNumber whenever the warp ARRIVED ON declares +-- destination warp -1. Stepping back onto the -1 warp reads the whole triple +-- out of the backup (`cp -1 / ld hl, wBackupWarpNumber` in CopyWarpData), so +-- one second floor serves every Pokemon Center in the game. +-- +-- The port had only the read half (World:resolveWarp), and its single writer +-- was the elevator menu -- so the 2F staircase resolved to POKECENTER_2F warp +-- 255, found nothing, and did nothing: the player was trapped upstairs in +-- every centre. What is asserted here is the write half riding the REAL +-- takeWarp, against the real extracted maps. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 pokecenter stairs") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") + +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 probe = io.open(cache .. "/data/generated/maps.lua", "r") +if not probe then + check(true, "gold cache absent (SKIP)") + S.finish() + return +end +probe:close() + +local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") + +-- The map data this whole feature hangs on: the 2F staircase really is the -1 +-- sentinel, kept by the extractor as the raw byte. +eq(maps.POKECENTER_2F.warps[1].destWarp, 0xff, + "POKECENTER_2F's staircase declares destination warp -1") +eq(maps.CHERRYGROVE_POKECENTER_1F.warps[3].destMap, "POKECENTER_2F", + "Cherrygrove's stairs lead up to the shared 2F") +eq(maps.VIOLET_POKECENTER_1F.warps[3].destMap, "POKECENTER_2F", + "and so do Violet's") + +-- A World over the real defs. setMap is recorded rather than run (baking a +-- map image needs a graphics device); takeWarp's own bookkeeping around it -- +-- resolveWarp, the wPrev capture, recordWarpBackup -- is the shipped code. +local function world(mapId, x, y) + local game = { data = { audio = { sfxOrder = {} } }, save = { player = {} } } + local w = World.new(game) + w.maps, w.tilesets = maps, tilesets + w.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset]) + w.player = { cellX = x, cellY = y, facing = "up", moving = false } + w.loaded = nil + w.setMap = function(self, id, cx, cy, facing) + self.loaded = { id = id, x = cx, y = cy, facing = facing } + return true + end + return w +end + +-- MAPSETUP_DOOR fades out before the load, so the take is parked in +-- world.mapSetup; this drains it the way World:step does. +local function pump(w) + for _ = 1, 64 do + if not w.mapSetup then return end + w:updateMapSetup() + end +end + +-- Climb the stairs from Cherrygrove: the arrival warp is the -1 staircase, so +-- the take must bank {Cherrygrove 1F, warp 3} as the way back. +do + local w = world("CHERRYGROVE_POKECENTER_1F", 0, 7) + check(w:takeWarp(maps.CHERRYGROVE_POKECENTER_1F.warps[3]), + "the 1F stairs warp up") + pump(w) + eq(w.loaded and w.loaded.id, "POKECENTER_2F", "landing on the shared 2F") + check(w.backupWarp ~= nil, "arriving on a -1 warp banks the backup triple") + eq(w.backupWarp.map, "CHERRYGROVE_POKECENTER_1F", + "the banked map is the centre being left") + eq(w.backupWarp.warp, 3, "and the banked warp is the stairs stepped on") + + -- Step back onto the staircase: the -1 destination resolves through the + -- backup, back into the same centre, onto the same stairs. + w.map = Map.new(maps.POKECENTER_2F, tilesets[maps.POKECENTER_2F.tileset]) + w.player = { cellX = 0, cellY = 7, facing = "down", moving = false } + w.loaded = nil + check(w:takeWarp(maps.POKECENTER_2F.warps[1]), "the 2F stairs warp at all") + pump(w) + eq(w.loaded and w.loaded.id, "CHERRYGROVE_POKECENTER_1F", + "and they lead back to the centre the player came from") + eq(w.loaded.x, maps.CHERRYGROVE_POKECENTER_1F.warps[3].x, + "onto that centre's own staircase tile x") + eq(w.loaded.y, maps.CHERRYGROVE_POKECENTER_1F.warps[3].y, "and y") +end + +-- The same 2F reached from Violet leads back to Violet: the backup is +-- re-banked on every -1 arrival, which is the whole point of the sentinel. +do + local w = world("VIOLET_POKECENTER_1F", 0, 7) + check(w:takeWarp(maps.VIOLET_POKECENTER_1F.warps[3]), "Violet's stairs warp") + pump(w) + eq(w.backupWarp.map, "VIOLET_POKECENTER_1F", "the backup now names Violet") + + w.map = Map.new(maps.POKECENTER_2F, tilesets[maps.POKECENTER_2F.tileset]) + w.player = { cellX = 0, cellY = 7, facing = "down", moving = false } + w.loaded = nil + check(w:takeWarp(maps.POKECENTER_2F.warps[1]), "the staircase works here too") + pump(w) + eq(w.loaded and w.loaded.id, "VIOLET_POKECENTER_1F", + "and comes down in Violet, not Cherrygrove") +end + +-- An ordinary warp arrival must NOT disturb the backup: the trade corner +-- doors up on the 2F carry real destinations, and only a -1 arrival re-banks. +do + local w = world("CHERRYGROVE_POKECENTER_1F", 0, 7) + check(w:takeWarp(maps.CHERRYGROVE_POKECENTER_1F.warps[3]), "up the stairs") + pump(w) + local banked = w.backupWarp + w.map = Map.new(maps.CHERRYGROVE_POKECENTER_1F, + tilesets[maps.CHERRYGROVE_POKECENTER_1F.tileset]) + w.player = { cellX = 3, cellY = 7, facing = "down", moving = false } + check(w:takeWarp(maps.CHERRYGROVE_POKECENTER_1F.warps[1]), + "out the front door") + pump(w) + eq(w.backupWarp, banked, + "a warp whose arrival declares a real destination leaves the backup alone") +end + +-- With no backup banked at all (a fresh boot standing upstairs), the -1 warp +-- still must not crash; the cart would read whatever the triple last held. +do + local w = world("POKECENTER_2F", 0, 7) + eq(w:takeWarp(maps.POKECENTER_2F.warps[1]), false, + "an unbanked -1 warp refuses rather than crashing") +end + +-- Save and reload upstairs: the triple is saved WRAM on the cart, so it rides +-- save.backupWarp -- Game2:snapshotSave writes it and World:loadPlayerData +-- reads it back into the rebuilt world. +do + local w = world("CHERRYGROVE_POKECENTER_1F", 0, 7) + check(w:takeWarp(maps.CHERRYGROVE_POKECENTER_1F.warps[3]), "up the stairs") + pump(w) + + -- The snapshot half, through the real Game2 method. + local Game2 = require("src.core.Game2") + local host = setmetatable({ + save = { player = {} }, + world = w, + options = {}, + }, { __index = Game2 }) + w.map = Map.new(maps.POKECENTER_2F, tilesets[maps.POKECENTER_2F.tileset]) + w.player = { cellX = 0, cellY = 7, facing = "down", moving = false } + local saved = host:snapshotSave() + check(type(saved.backupWarp) == "table", "the snapshot carries the triple") + eq(saved.backupWarp.map, "CHERRYGROVE_POKECENTER_1F", "with the map") + eq(saved.backupWarp.warp, 3, "and the warp") + + -- The restore half, through the real loadPlayerData on a fresh World. + local w2 = world("POKECENTER_2F", 0, 7) + w2:loadPlayerData(saved) + check(w2.backupWarp ~= nil, "loadPlayerData restores the triple") + eq(w2.backupWarp.map, "CHERRYGROVE_POKECENTER_1F", "map intact") + eq(w2.backupWarp.warp, 3, "warp intact") + w2.loaded = nil + check(w2:takeWarp(maps.POKECENTER_2F.warps[1]), + "so the stairs work after a save made upstairs") + pump(w2) + eq(w2.loaded and w2.loaded.id, "CHERRYGROVE_POKECENTER_1F", + "and still lead home") +end + +S.finish() diff --git a/tests/gen2_pokegear_unlock_test.lua b/tests/gen2_pokegear_unlock_test.lua new file mode 100644 index 00000000..4be76649 --- /dev/null +++ b/tests/gen2_pokegear_unlock_test.lua @@ -0,0 +1,454 @@ +-- The Pokegear and Pokedex unlock chain: `setflag` to the START menu and the +-- card strip. +-- +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_pokegear_unlock_test.lua +-- +-- On the cart every unlock in this family is ONE engine flag +-- (constants/engine_flags.asm const order over data/events/engine_flags.asm): +-- ENGINE_RADIO_CARD 0, ENGINE_MAP_CARD 1, ENGINE_PHONE_CARD 2, +-- ENGINE_EXPN_CARD 3 and ENGINE_POKEGEAR 4 are wPokegearFlags bits, and +-- ENGINE_POKEDEX 11 is wStatusFlags' STATUSFLAGS_POKEDEX_F. The menus read +-- the same bits the scripts set: start_menu.asm's .SetUpMenuItems bit-tests +-- wStatusFlags and wPokegearFlags, and the Pokegear's card strip is +-- wPokegearFlags again. So the round trip under test is +-- script `setflag` -> Vm -> World:setEngineFlag -> save.engineFlags -> +-- StartMenu:availability / Pokegear:flags +-- with nothing hand-seeded in between. The first half runs fixture rows that +-- mirror the granting scripts; the cache-gated half runs the real extracted +-- scripts themselves. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 pokegear unlock") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +-- No font is loaded (logic assertions only), so quiet the per-glyph warns. +require("src.core.Logger").warn = function() end + +local Pokegear = require("src.ui.gen2.Pokegear") +local Save = require("src.core.gen2.Save") +local StartMenu = require("src.ui.gen2.StartMenu") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +-- ---- plumbing -------------------------------------------------------------- + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, b in ipairs({ ... }) do self.pressed[b] = true end + end + function input:wasPressed(b) + if self.pressed[b] then self.pressed[b] = nil return true end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + return { + input = newInput(), + save = save, + options = save and save.options or Save.defaultOptions(), + data = { audio = {}, pokemon = {}, items = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +-- A real World:setEngineFlag / World:engineFlag over a real save, without the +-- rest of the world: the same slice gen2_badges_test.lua drives. +local function newWorld(save) + return setmetatable({ game = { save = save } }, { __index = World }) +end + +-- wEventFlags for the Vm: setevent / checkevent land here. +local function newEvents() + return { flags = {}, + set = function(self, id, v) self.flags[id] = v or nil end, + get = function(self, id) return self.flags[id] == true end, + } +end + +-- Run a script list to completion, spending pause/showemote waits. yesorno +-- answers come from `answers` in order and default to YES past the end: the +-- confirm loops (Mom's clock wheel re-asks until the player agrees) terminate +-- on YES, so an unscripted question is confirmed rather than looping forever. +local function runScript(scripts, key, world, opts) + opts = opts or {} + local answers = opts.answers or {} + local asked = 0 + local vm = Vm.new(scripts, opts.text or {}, opts.events or newEvents(), { + setEngineFlag = function(flag, v) world:setEngineFlag(flag, v) end, + getEngineFlag = function(flag) return world:engineFlag(flag) end, + yesorno = function(done) + asked = asked + 1 + local answer = answers[asked] + if answer == nil then answer = true end + done(answer == true) + end, + specialOrder = opts.specialOrder, + -- The world half of the special handlers, as ONE sub-table: pushScreen + -- rides here, the same seam World:specialHooks fills. + specials = opts.specials, + }) + check(vm:start(key), "script " .. tostring(key) .. " started") + for _ = 1, 5000 do + if not vm:running() then break end + vm:update() + end + eq(vm:running(), false, "script " .. tostring(key) .. " ran to completion") + return vm, asked +end + +local function menuIds(menu) + local out = {} + for _, item in ipairs(menu.items) do out[#out + 1] = item.value end + return table.concat(out, ",") +end + +local function cardIds(gear) + local out = {} + for _, card in ipairs(gear.cards) do out[#out + 1] = card.id end + return table.concat(out, ",") +end + +-- ---- the locked state ------------------------------------------------------ + +do + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL" } } + local menu = StartMenu.new(newGame(save), { save = save }) + eq(menuIds(menu), "pokemon,pack,status,save,option,quit", + "no engine flags: no POKeDEX and no POKeGEAR row") + local gear = Pokegear.new(newGame(save), { save = save }) + eq(cardIds(gear), "clock", "and the gear alone is just the clock card") +end + +-- ---- fixture rows: the same setflag ids the granting scripts carry --------- + +-- Mom (maps/PlayersHouse1F.asm): `setflag ENGINE_POKEGEAR` then +-- `setflag ENGINE_PHONE_CARD` on the way out the door. +do + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL" } } + local world = newWorld(save) + runScript({ mom = { + { op = "setflag", flag = 4 }, + { op = "setflag", flag = 2 }, + { op = "end" }, + } }, "mom", world) + eq(save.engineFlags[4], true, "ENGINE_POKEGEAR landed in save.engineFlags") + local menu = StartMenu.new(newGame(save), { save = save }) + eq(menuIds(menu), "pokemon,pack,pokegear,status,save,option,quit", + "the POKeGEAR row unlocks from the flag alone") + local gear = Pokegear.new(newGame(save), { save = save }) + eq(cardIds(gear), "clock,phone", "and the PHONE card is on the strip") +end + +-- Oak (maps/MrPokemonsHouse.asm): `setflag ENGINE_POKEDEX`. +do + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL" } } + local world = newWorld(save) + runScript({ oak = { + { op = "setflag", flag = 11 }, + { op = "end" }, + } }, "oak", world) + local menu = StartMenu.new(newGame(save), { save = save }) + eq(menuIds(menu), "pokedex,pokemon,pack,status,save,option,quit", + "ENGINE_POKEDEX puts POKeDEX at the top of the menu") +end + +-- The Guide Gent (maps/CherrygroveCity.asm `setflag ENGINE_MAP_CARD`), the +-- Radio Tower quiz lady (maps/RadioTower1F.asm `setflag ENGINE_RADIO_CARD`) +-- and the Lavender tower director (maps/LavRadioTower1F.asm +-- `setflag ENGINE_EXPN_CARD`). +do + local save = Save.newGame() + local world = newWorld(save) + runScript({ cards = { + { op = "setflag", flag = 1 }, + { op = "setflag", flag = 0 }, + { op = "setflag", flag = 3 }, + { op = "end" }, + } }, "cards", world) + local gear = Pokegear.new(newGame(save), { save = save }) + eq(cardIds(gear), "clock,map,radio", + "MAP and RADIO cards unlock; PHONE stays locked without flag 2") + -- POKEGEAR_EXPN_CARD_F is not a card of its own -- it is the Kanto radio + -- upgrade the Poke Flute channel checks. + eq(gear:radioContext().expnCard, true, "EXPN reaches the radio context") +end + +-- With every card granted the strip has to read in POKEGEARCARD_* order +-- (constants/pokegear_constants.asm: CLOCK 0, MAP 1, PHONE 2, RADIO 3). The +-- order is what paging walks, and AnimatePokegearModeIndicatorArrow indexes its +-- $00/$10/$20/$30 x offsets by wPokegearCard, so a strip out of card order +-- makes the mode arrow skip an icon and then jump backwards. +do + local save = Save.newGame() + local world = newWorld(save) + runScript({ cards = { + { op = "setflag", flag = 0 }, + { op = "setflag", flag = 1 }, + { op = "setflag", flag = 2 }, + { op = "end" }, + } }, "cards", world) + local gear = Pokegear.new(newGame(save), { save = save }) + eq(cardIds(gear), "clock,map,phone,radio", "the strip is in card order") + local columns = {} + for _, card in ipairs(gear.cards) do + columns[#columns + 1] = tostring(card.iconX) + end + eq(table.concat(columns, ","), "0,2,4,6", + "and paging steps the arrow left to right, one icon at a time") +end + +-- The overlay stays: a test or driver may still seed save.pokegearFlags and +-- the older proxy fields directly. +do + local save = Save.newGame() + save.pokegearFlags = { phone = true } + save.pokedexReceived = true + save.pokegearReceived = true + save.party = { { species = "TOTODILE" } } + local gear = Pokegear.new(newGame(save), { save = save }) + eq(cardIds(gear), "clock,phone", "seeded pokegearFlags still read") + local menu = StartMenu.new(newGame(save), { save = save }) + eq(menuIds(menu), "pokedex,pokemon,pack,pokegear,status,save,option,quit", + "and the proxy fields still unlock the menu rows") +end + +-- ---- the EXPN gate and the two landmark stations --------------------------- + +-- RadioChannels (engine/pokegear/pokegear.asm): knob 78 (20.0) is the Poke +-- Flute channel, Kanto plus EXPN only; knob 52 (13.5) resolves ????? only at +-- the Ruins of Alph. Both were unreachable while the flags never arrived. +local LANDMARKS = { landmarks = { + LANDMARK_NEW_BARK_TOWN = { index = 2 }, + LANDMARK_RUINS_OF_ALPH = { index = 10 }, + LANDMARK_VERMILION_CITY = { index = 50 }, +} } + +local function stationAt(save, landmark, knob) + local gear = Pokegear.new(newGame(save), { + save = save, landmarks = LANDMARKS, currentLandmark = landmark, + }) + for _, row in ipairs(gear:stations()) do + if row.knob == knob then return row end + end + return nil +end + +do + local save = Save.newGame() + local world = newWorld(save) + world:setEngineFlag(0, true) + local locked = stationAt(save, "LANDMARK_VERMILION_CITY", 78) + eq(locked and locked.station, nil, "20.0 is dead air without the EXPN flag") + world:setEngineFlag(3, true) + local flute = stationAt(save, "LANDMARK_VERMILION_CITY", 78) + eq(flute and flute.station, "POKE_FLUTE_RADIO", + "ENGINE_EXPN_CARD opens the Poke Flute channel in Kanto") + eq(flute and flute.name, "POKé FLUTE", "with its own station name") + local ruins = stationAt(save, "LANDMARK_RUINS_OF_ALPH", 52) + eq(ruins and ruins.station, "UNOWN_RADIO", + "and 13.5 resolves the ????? station at the Ruins of Alph") + local away = stationAt(save, "LANDMARK_NEW_BARK_TOWN", 52) + eq(away and away.station, nil, "which is dead air anywhere else") +end + +-- ---- ExitPokegearRadio_HandleMusic ----------------------------------------- + +-- RadioMusicRestartDE writes the tuned song into wMapMusic, so closing the +-- gear on a station leaves the song playing AS the map music; NoRadioStation's +-- ENTER_MAP_MUSIC brings the map theme back instead. +local Music = require("src.core.Music") + +do + local save = Save.newGame() + local world = newWorld(save) + world:setEngineFlag(0, true) + world:setEngineFlag(3, true) + local game = newGame(save) + game.world = { map = { def = { music = "Music_VermilionCity" } } } + local gear = Pokegear.new(game, { + save = save, landmarks = LANDMARKS, + currentLandmark = "LANDMARK_VERMILION_CITY", + }) + -- The knob on 20.0: RADIO_CHANNELS row 7. + gear.station = 7 + gear:tuneRadio() + eq(gear.radioShow, "POKE_FLUTE_RADIO", "the tuner resolved the channel") + gear:tickRadio() + eq(gear.radioSong, "Music_PokeFluteChannel", "the station started its song") + eq(gear.radioMusicPlaying, "Music_PokeFluteChannel", + "and parked it in wPokegearRadioMusicPlaying") + Music.setMapSong("Music_VermilionCity") + gear:stopRadio() + eq(Music.mapSong(), "Music_PokeFluteChannel", + "closing the radio makes the tuned song the map music (wMapMusic)") + eq(gear.radioMusicPlaying, nil, "and zeroes the handoff byte") + + -- Dead air: knob 16 asks for Johto, and this gear is in Kanto. + Music.setMapSong("Music_VermilionCity") + gear.station = 1 + gear:tuneRadio() + eq(gear.radioShow, nil, "04.5 is dead air in Kanto") + eq(gear.radioMusicPlaying, "enterMap", "NoRadioStation parks ENTER_MAP_MUSIC") + gear:stopRadio() + eq(Music.mapSong(), "Music_VermilionCity", + "and closing on dead air leaves the map song alone") +end + +-- The Pokemon Channel jingle is the one RESTART_MAP_MUSIC writer. +eq(Pokegear.radioPlayingValue("Music_PokemonChannel"), "restartMap", + "RadioMusicRestartPokemonChannel restores the map theme on exit") +eq(Pokegear.radioPlayingValue("Music_PokeFluteChannel"), + "Music_PokeFluteChannel", "RadioMusicRestartDE hands the song itself over") + +-- ---- the cache half: the real granting scripts ----------------------------- + +local cacheDir = os.getenv("GOLD_CACHE") +if not cacheDir then + cacheDir = (os.getenv("HOME") or "") .. + "/Library/Application Support/LOVE/gold-dev/gold" +end +local scriptsFile = loadfile(cacheDir .. "/data/generated/scripts.lua") +if not scriptsFile then + check(true, "cache absent (SKIP)") + S.finish() + return +end +local scripts = scriptsFile() +local constants = assert( + loadfile(cacheDir .. "/data/generated/constants.lua"))() + +-- Mom's send-off (maps/PlayersHouse1F.asm): flags 4 and 2 in one script. +do + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL" } } + local world = newWorld(save) + runScript(scripts, "60:564f", world) + eq(save.engineFlags[4], true, "Mom set ENGINE_POKEGEAR") + eq(save.engineFlags[2], true, "and ENGINE_PHONE_CARD") + local menu = StartMenu.new(newGame(save), { save = save }) + check(menuIds(menu):find("pokegear", 1, true) ~= nil, + "so the real script unlocks the POKeGEAR row") + eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,phone", + "and the PHONE card") +end + +-- Oak's dex handover (maps/MrPokemonsHouse.asm): flag 11. +do + local save = Save.newGame() + save.party = { { species = "CYNDAQUIL" } } + local world = newWorld(save) + runScript(scripts, "62:46c1", world) + eq(save.engineFlags[11], true, "Oak set ENGINE_POKEDEX") + local menu = StartMenu.new(newGame(save), { save = save }) + eq(menu.items[1] and menu.items[1].value, "pokedex", + "so the real script puts POKeDEX first") +end + +-- The Guide Gent's tour (maps/CherrygroveCity.asm): flag 1. +do + local save = Save.newGame() + local world = newWorld(save) + runScript(scripts, "48:43ea", world) + eq(save.engineFlags[1], true, "the Guide Gent set ENGINE_MAP_CARD") + eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,map", + "so the real script unlocks the MAP card") +end + +-- The Radio Tower quiz (maps/RadioTower1F.asm): five questions whose right +-- answers are YES/YES/NO/YES/NO, after a YES to sit the quiz at all. +do + local save = Save.newGame() + local world = newWorld(save) + local _, asked = runScript(scripts, "43:4d7d", world, { + answers = { true, true, true, false, true, false }, + }) + eq(asked, 6, "the quiz asked its six questions") + eq(save.engineFlags[0], true, "a perfect run set ENGINE_RADIO_CARD") + eq(cardIds(Pokegear.new(newGame(save), { save = save })), "clock,radio", + "so the real quiz unlocks the RADIO card") + + -- One wrong answer and the card is withheld. + local save2 = Save.newGame() + runScript(scripts, "43:4d7d", newWorld(save2), { + answers = { true, true, true, true }, + }) + eq(save2.engineFlags[0], nil, "a wrong answer grants nothing") +end + +-- The Lavender tower director (maps/LavRadioTower1F.asm): flag 3. +do + local save = Save.newGame() + local world = newWorld(save) + runScript(scripts, "5d:47b3", world) + eq(save.engineFlags[3], true, "the director set ENGINE_EXPN_CARD") + local gear = Pokegear.new(newGame(save), { + save = save, landmarks = LANDMARKS, + currentLandmark = "LANDMARK_VERMILION_CITY", + }) + eq(gear:radioContext().expnCard, true, + "so the real script arms the Poke Flute channel gate") +end + +-- ---- the wall radios reach the new special --------------------------------- + +-- std_scripts.asm Radio1Script is `setval MAPRADIO_POKEMON_CHANNEL` + +-- `special MapRadio`; the id must resolve to a HANDLER now, not a stub. +do + local Specials = require("src.script.gen2.Specials") + eq(constants.specialOrder[41], "MapRadio", "special id 40 is MapRadio") + check(Specials.HANDLERS.MapRadio ~= nil, "MapRadio is implemented") + eq(Specials.STUB_REASONS.MapRadio, nil, "and no longer a stub") + + local save = Save.newGame() + local world = newWorld(save) + local pushed = nil + local vm = Vm.new(scripts, {}, newEvents(), { + setEngineFlag = function(flag, v) world:setEngineFlag(flag, v) end, + getEngineFlag = function(flag) return world:engineFlag(flag) end, + specialOrder = constants.specialOrder, + specials = { + pushScreen = function(id, opts) + pushed = { id = id, opts = opts } + -- A pushed screen answers later; here the "screen" closes at once. + if opts and opts.onDone then opts.onDone() end + return true + end, + }, + }) + check(vm:start("40:4132"), "Radio1Script's body started") + for _ = 1, 200 do + if not vm:running() then break end + vm:update() + end + eq(vm:running(), false, "and ran through the special to its end") + eq(pushed and pushed.id, "Gen2MapRadio", + "special 40 pushed the wall-radio screen") + eq(pushed and pushed.opts and pushed.opts.channel, 0, + "with setval's MAPRADIO_POKEMON_CHANNEL") + + -- Radio2Script (the Lucky Channel houses): channel 4. + pushed = nil + check(vm:start("40:413a"), "Radio2Script's body started") + for _ = 1, 200 do + if not vm:running() then break end + vm:update() + end + eq(pushed and pushed.opts and pushed.opts.channel, 4, + "Radio2Script tunes MAPRADIO_LUCKY_CHANNEL") +end + +S.finish() diff --git a/tests/gen2_pokerus_test.lua b/tests/gen2_pokerus_test.lua new file mode 100644 index 00000000..3c47e828 --- /dev/null +++ b/tests/gen2_pokerus_test.lua @@ -0,0 +1,424 @@ +-- Pokerus: the infection roll after a won battle, the spread through the party, +-- the daily countdown that leaves the immune marker behind, and the doubled +-- stat exp an infected (or cured) mon earns. +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_pokerus_test.lua +-- +-- Every roll is pinned: Pokerus.give takes the same "one function, one byte" +-- roller BugContest does, so the sequences below are the exact `call Random` +-- results engine/events/pokerus/pokerus.asm would have read, in order. The +-- numbers in the comments are traceable to that file so a failure names the +-- branch it disagrees with. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 pokerus") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") +local Pokerus = require("src.core.gen2.Pokerus") +local Gen2Save = require("src.core.gen2.Save") + +-- ---- fixtures ------------------------------------------------------------- + +local function base(hp, attack, defense, speed, spa, spd) + return { hp = hp, attack = attack, defense = defense, speed = speed, + specialAttack = spa, specialDefense = spd } +end + +-- GROWTH_MEDIUM_FAST is plain n^3 (data/growth_rates.asm). +local GROWTH = { + MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, + constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + name = "CYNDAQUIL", index = 155, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "FIRE" }, baseExp = 65, + baseStats = base(39, 52, 43, 65, 60, 50), + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + -- data/pokemon/base_stats/pidgey.asm: 40/45/40/56/35/35, base exp 55. + PIDGEY = { + name = "PIDGEY", index = 16, growthRate = "MEDIUM_FAST", + genderRatio = 0x7f, types = { "NORMAL", "FLYING" }, baseExp = 55, + baseStats = base(40, 45, 40, 56, 35, 35), + levelMoves = { { level = 1, move = "TACKLE" } }, + }, +} + +local MOVES = { + TACKLE = { name = "TACKLE", type = "NORMAL", power = 35, accuracy = 95, + pp = 35, category = "physical" }, +} + +local DATA = { pokemon = POKEMON, moves = MOVES } + +-- A roller that hands back a fixed sequence, so every branch below is chosen +-- rather than sampled. Running off the end is a test bug, not a pass. +local function rolls(...) + local queue = { ... } + local at = 0 + return function() + at = at + 1 + assert(queue[at], "pokerus roller ran past its pinned sequence") + return queue[at] + end +end + +local function mon(pokerus) + local m = Mon.new(DATA, "PIDGEY", 5, { dvs = { attack = 0, defense = 0, + speed = 0, special = 0 } }) + m.pokerus = pokerus or 0 + return m +end + +-- ---- reading the byte ----------------------------------------------------- + +eq(Pokerus.strain(mon(0x34)), 3, "the high nybble is the strain") +eq(Pokerus.days(mon(0x34)), 4, "the low nybble is the days left") +check(Pokerus.isInfected(mon(0x34)), "a counter above zero is an infection") +check(not Pokerus.isImmune(mon(0x34)), "and is not yet the immune marker") +check(not Pokerus.isInfected(mon(0x30)), "a spent counter is not an infection") +check(Pokerus.isImmune(mon(0x30)), "but the strain left behind is immunity") +check(not Pokerus.isInfected(mon(0)), "an untouched byte is neither") +check(not Pokerus.isImmune(mon(0)), "an untouched byte is not immune") + +-- GiveExperiencePoints tests the WHOLE byte, so a cured mon still doubles. +check(Pokerus.doublesStatExp(mon(0x34)), "an infected mon doubles stat exp") +check(Pokerus.doublesStatExp(mon(0x30)), "and so does a cured one") +check(not Pokerus.doublesStatExp(mon(0)), "a clean mon does not") + +-- _CheckPokerus: the low nybble only. +check(Pokerus.inParty({ mon(0), mon(0x34) }), "one infected slot is enough") +check(not Pokerus.inParty({ mon(0), mon(0x30) }), + "a party of cured mons answers no") +check(not Pokerus.inParty({}), "and an empty party answers no") + +-- A garbage byte is folded rather than trusted, so no reader ever sees a +-- strain or a day count no cartridge could produce. +eq(Pokerus.byteOf({ pokerus = -4 }), 0, "a negative byte reads as zero") +eq(Pokerus.byteOf({ pokerus = 300 }), 44, "and an oversized one wraps") + +-- ---- the daily tick ------------------------------------------------------- + +do + local party = { mon(0x34), mon(0x31), mon(0x30), mon(0) } + local cured = Pokerus.applyTick(party, 2) + eq(party[1].pokerus, 0x32, "two days off a four-day infection leaves two") + eq(party[2].pokerus, 0x30, "a counter that runs out keeps its strain") + eq(party[3].pokerus, 0x30, "an already-cured mon is skipped, not re-cured") + eq(party[4].pokerus, 0, "a clean mon is left alone") + eq(#cured, 1, "one mon cured on this tick") + eq(cured[1], 2, "and it is the slot whose counter ran out") +end + +do + -- The clamp is `jr nc, .ok / xor a`: a tick bigger than the counter cannot + -- borrow into the strain nybble. + local party = { mon(0x81) } + Pokerus.applyTick(party, 200) + eq(party[1].pokerus, 0x80, "a huge tick clamps at zero without touching the strain") +end + +do + local save = { party = { mon(0x34) } } + check(not Pokerus.checkTick(save, { day = 5 }), + "the first poll only stamps the day") + eq(save.pokerusStartDay, 5, "and the stamp is today") + eq(save.party[1].pokerus, 0x34, "so nothing ticked") + check(not Pokerus.checkTick(save, { day = 5 }), + "a second poll on the same day still ticks nothing") + check(Pokerus.checkTick(save, { day = 7 }), "two days later it ticks") + eq(save.party[1].pokerus, 0x32, "by the days SINCE THE LAST POLL") + eq(save.pokerusStartDay, 7, "and the stamp advances to today") + check(not Pokerus.checkTick(save, { day = 7 }), + "so the same two days are never counted twice") +end + +-- ---- catching it de novo -------------------------------------------------- + +do + local party = { mon(0) } + eq(Pokerus.give(party, { random = rolls(0, 0, 0, 0x34) }), nil, + "a save that has not reached Goldenrod cannot catch it") + eq(party[1].pokerus, 0, "and nothing was written") +end + +do + -- hRandomAdd zero, hRandomSub under 3, slot 0, then the strain/duration byte: + -- $34 has a non-zero high nybble, so strain = ($34 & 7) + 1 = 5 and the + -- counter is (5 & 3) + 1 = 2. + local party = { mon(0) } + eq(Pokerus.give(party, { reachedGoldenrod = true, + random = rolls(0, 0, 0, 0x34) }), 1, "the rolled slot is the one infected") + eq(party[1].pokerus, 0x52, "strain 5, two days") +end + +do + -- 3 in 65536: either byte out of range and the routine returns. + local party = { mon(0) } + eq(Pokerus.give(party, { reachedGoldenrod = true, random = rolls(1) }), nil, + "a non-zero hRandomAdd ends it") + eq(Pokerus.give(party, { reachedGoldenrod = true, random = rolls(0, 3) }), nil, + "and so does hRandomSub at 3") + eq(party[1].pokerus, 0, "neither wrote anything") +end + +do + -- `.randomMonSelectLoop` masks the roll to 0..7 and rerolls until it lands + -- inside the party. + local party = { mon(0), mon(0) } + eq(Pokerus.give(party, { reachedGoldenrod = true, + random = rolls(0, 0, 5, 1, 0x34) }), 2, "a slot past the party is rerolled") + eq(party[1].pokerus, 0, "the first slot is untouched") + eq(party[2].pokerus, 0x52, "the second caught it") +end + +do + -- `.randomPokerusLoop` rerolls a zero byte, because zero would mean no + -- strain and no days. + local party = { mon(0) } + Pokerus.give(party, { reachedGoldenrod = true, + random = rolls(0, 0, 0, 0, 0x11) }) + eq(party[1].pokerus, 0x23, "a zero sample is rerolled: strain 2, three days") +end + +do + -- The strain-zero quirk: a sample whose HIGH nybble is zero takes the + -- `jr z, .load_pkrs` arm with a = 0, so the mon gets $01 and cures back to + -- $00 -- which leaves it able to catch Pokerus again. + local party = { mon(0) } + Pokerus.give(party, { reachedGoldenrod = true, + random = rolls(0, 0, 0, 0x08) }) + eq(party[1].pokerus, 0x01, "strain zero, one day") + Pokerus.applyTick(party, 1) + eq(party[1].pokerus, 0, "and it cures to nothing at all") +end + +do + -- `and $f0 / ret nz`: the immune marker blocks a second infection. + local party = { mon(0x30) } + eq(Pokerus.give(party, { reachedGoldenrod = true, + random = rolls(0, 0, 0, 0x34) }), nil, "a cured mon cannot catch it again") + eq(party[1].pokerus, 0x30, "its byte is unchanged") +end + +-- ---- spreading it --------------------------------------------------------- + +do + -- An active infection anywhere in the party turns the whole routine into a + -- spread roll, so a second strain can never be contracted. + local party = { mon(0x34), mon(0), mon(0) } + eq(Pokerus.give(party, { reachedGoldenrod = true, random = rolls(85) }), nil, + "33 percent is 85 out of 256, and 85 itself misses") + eq(party[2].pokerus, 0, "nothing spread") +end + +do + -- 200 is at or over 128, so the walk goes FORWARDS from the carrier. + local party = { mon(0x34), mon(0), mon(0) } + eq(Pokerus.give(party, { random = rolls(0, 200) }), 2, + "the next slot catches it") + eq(party[2].pokerus, 0x34, "same strain, a counter of (3 & 3) + 1 days") + eq(party[3].pokerus, 0, "and the walk stopped there") +end + +do + -- The last slot has nothing after it, so `cp 2 / jr c` sends it backwards + -- without spending a second Random. + local party = { mon(0), mon(0), mon(0x34) } + eq(Pokerus.give(party, { random = rolls(0) }), 2, + "a carrier in the last slot walks backwards") + eq(party[2].pokerus, 0x34, "infecting the slot before it") +end + +do + local party = { mon(0x34) } + eq(Pokerus.give(party, { random = rolls(0) }), nil, + "a party of one has nowhere to spread") +end + +do + -- `and $3 / ret z` on a neighbour's byte: meant as "stop at a cured mon", + -- and $30 is exactly that. + local party = { mon(0x34), mon(0x30), mon(0) } + eq(Pokerus.give(party, { random = rolls(0, 200) }), nil, + "the walk stops dead at a cured mon") + eq(party[3].pokerus, 0, "so the slot past it stays clean") +end + +do + -- Register c is reloaded from each neighbour walked over, so the strain that + -- lands two slots away is the LAST one seen, not the carrier's. + local party = { mon(0x34), mon(0x51), mon(0) } + eq(Pokerus.give(party, { random = rolls(0, 200) }), 3, + "an infected neighbour is walked past") + eq(party[3].pokerus, 0x52, "carrying ITS strain, not the first carrier's") +end + +-- ---- stat exp ------------------------------------------------------------- + +do + local m = Mon.new(DATA, "CYNDAQUIL", 10) + check(m.statExp ~= nil, "a fresh mon carries the five stat exp words") + eq(m.statExp.special, 0, "all starting at zero") + eq(m.pokerus, 0, "and a clean Pokerus byte") + + -- PIDGEY's base stats, one participant, no Pokerus. The loop runs + -- NUM_EXP_STATS = 5 times over a six-entry base stat block, so the Special + -- word takes Special ATTACK and Special Defense is never read. + Mon.gainStatExp(m, POKEMON.PIDGEY, 1, false) + eq(m.statExp.hp, 40, "HP stat exp is the loser's base HP") + eq(m.statExp.attack, 45, "Attack likewise") + eq(m.statExp.defense, 40, "Defense likewise") + eq(m.statExp.speed, 56, "Speed likewise") + eq(m.statExp.special, 35, "and Special takes the loser's Special ATTACK") + + Mon.gainStatExp(m, POKEMON.PIDGEY, 1, true) + eq(m.statExp.hp, 40 + 80, "Pokerus adds the same value a second time") + eq(m.statExp.special, 35 + 70, "for every word, Special included") + + -- .EvenlyDivideExpAmongParticipants divides the base stats in place, so the + -- share is the same one the exp points are divided by. + local shared = Mon.new(DATA, "CYNDAQUIL", 10) + Mon.gainStatExp(shared, POKEMON.PIDGEY, 2, false) + eq(shared.statExp.attack, 22, "two participants split the base stat, floored") + eq(shared.statExp.special, 17, "and the halving floors each word separately") + + -- .stat_exp_maxed_out + local full = Mon.new(DATA, "CYNDAQUIL", 10) + full.statExp.hp = 65530 + Mon.gainStatExp(full, POKEMON.PIDGEY, 1, true) + eq(full.statExp.hp, 65535, "a word that would overflow stops at $ffff") +end + +do + -- One Special stat exp word feeds BOTH special stats, the way one Special DV + -- feeds both. + local dvs = { attack = 15, defense = 15, speed = 15, special = 15 } + local plain = Mon.stats(POKEMON.CYNDAQUIL.baseStats, dvs, 50) + local trained = Mon.stats(POKEMON.CYNDAQUIL.baseStats, dvs, 50, + { special = 65535 }) + check(trained.specialAttack > plain.specialAttack, + "Special stat exp raises Special Attack") + eq(trained.specialDefense - plain.specialDefense, + trained.specialAttack - plain.specialAttack, + "and raises Special Defense by exactly as much") +end + +-- ---- the battle call site ------------------------------------------------- + +do + -- Battle:awardExperience is where GiveExperiencePoints lives, so the doubling + -- is asserted through it rather than through the helper alone. + local clean = Mon.new(DATA, "CYNDAQUIL", 30) + local infected = Mon.new(DATA, "CYNDAQUIL", 30) + infected.pokerus = 0x34 + local loser = Mon.new(DATA, "PIDGEY", 5) + + local function award(party) + local battle = setmetatable({ + data = DATA, party = party, participants = { [1] = true }, + events = {}, trainer = nil, + }, Battle) + battle:awardExperience(loser) + return battle + end + + award({ clean }) + award({ infected }) + eq(clean.statExp.attack, 45, "a clean participant gains the base stat once") + eq(infected.statExp.attack, 90, "an infected one gains it twice") + eq(clean.experience > 0, true, "and both still gained experience points") + eq(infected.experience, clean.experience, + "Pokerus doubles stat exp only, never exp points") +end + +-- ---- the save side -------------------------------------------------------- + +do + local save = Gen2Save.normalize({ + party = { { species = "PIDGEY", pokerus = 300 }, + { species = "PIDGEY", pokerus = -1 }, + { species = "PIDGEY" } }, + boxes = { { { species = "PIDGEY", pokerus = 3.7 } } }, + }) + eq(save.party[1].pokerus, 44, "an oversized byte is folded on load") + eq(save.party[2].pokerus, 0, "a negative one becomes zero") + eq(save.party[3].pokerus, nil, "a slot without one is left alone") + eq(save.boxes[1][1].pokerus, 3, "and box mons are normalized too") +end + +-- ---- call sites ----------------------------------------------------------- + +-- The two hooks a headless test cannot construct: World needs a map, a stack +-- and a VM, BattleState needs love. A call site that is not spelled out in the +-- file is not there at all, which is the failure this item is about. +local function sourceOf(path) + local f = io.open(path, "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +do + local world = sourceOf("src/world/gen2/World.lua") + check(world ~= nil, "World's source is readable") + check(world:find("Pokerus.checkTick(save)", 1, true) ~= nil, + "CheckTimeEvents' .do_daily arm runs the Pokerus tick") + + local battle = sourceOf("src/ui/gen2/BattleState.lua") + check(battle ~= nil, "BattleState's source is readable") + for _, wanted in ipairs({ + "function BattleState:givePokerus", + "Pokerus.giveAfterBattle(self.save, party)", + "self:givePokerus()", + }) do + check(battle:find(wanted, 1, true) ~= nil, "BattleState has " .. wanted) + end + + local engine = sourceOf("src/battle/gen2/Battle.lua") + check(engine ~= nil, "the battle engine's source is readable") + check(engine:find("Mon.gainStatExp(mon, def, count, Pokerus.doublesStatExp(mon), halved)", + 1, true) ~= nil, "awardExperience gives stat exp through Pokerus") + + local specials = sourceOf("src/script/gen2/Specials.lua") + check(specials ~= nil, "the specials' source is readable") + check(specials:find("Pokerus.inParty(party(vm))", 1, true) ~= nil, + "the CheckPokerus special reads the low nybble through the model") +end + +-- ---- against a real cache ------------------------------------------------- + +-- ExitBattle's roll only ever runs off real base stats, so when a Gold cache is +-- present the stat exp half is re-asked against them. +local cache = os.getenv("GOLD_CACHE") +if cache then + local path = cache .. "/data/generated/pokemon.lua" + local f = io.open(path, "r") + if f then + f:close() + local cached = assert(loadfile(path))() + local pidgey = cached.PIDGEY + if pidgey and pidgey.baseStats then + local data = { pokemon = cached, moves = {} } + local real = Mon.new(data, "CYNDAQUIL", 20) + if real then + real.pokerus = 0x34 + Mon.gainStatExp(real, pidgey, 1, Pokerus.doublesStatExp(real)) + eq(real.statExp.attack, (pidgey.baseStats.attack or 0) * 2, + "a real base stat doubles for an infected mon") + eq(real.statExp.special, (pidgey.baseStats.specialAttack or 0) * 2, + "and the Special word comes from the real Special Attack") + end + end + end +end + +S.finish() diff --git a/tests/gen2_prize_counter_test.lua b/tests/gen2_prize_counter_test.lua new file mode 100644 index 00000000..36b11d99 --- /dev/null +++ b/tests/gen2_prize_counter_test.lua @@ -0,0 +1,186 @@ +-- The Game Corner's prize counters are MAP SCRIPT, not a screen. +-- +-- luajit tests/gen2_prize_counter_test.lua (ROM-free; the cache half SKIPs +-- without a gold cache) +-- +-- GoldenrodGameCornerTMVendorScript and GoldenrodGameCornerPrizeMonVendorScript +-- (pokegold maps/GoldenrodGameCorner.asm), and their two Celadon twins, are +-- `opentext` / `checkitem` / `loadmenu` / `verticalmenu` / `checkcoins` / +-- `giveitem` / `givepoke` / `takecoins` and nothing else -- every one of which +-- src/script/gen2/Vm.lua already runs. So talking to a vendor runs the +-- extracted bytecode, and no engine screen is reached: an id in +-- src/ui/Screens.lua for one would only ever have been resolved by a mod +-- replacing a screen the game never pushes. +-- +-- What this pins is the reachable path (the real script, driven to a real +-- transaction with no unknown opcodes left over) and the registry (no id for +-- something the VM owns). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 prize counter") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Screens = require("src.ui.Screens") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +-- ---- the registry --------------------------------------------------------- +do + local registered = {} + for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end + eq(registered.Gen2PrizeMenu, nil, + "no Gen2PrizeMenu id: the prize counters are script") + eq(registered.Gen2IntroStub, nil, + "and no Gen2IntroStub: Gen2GoldSilverIntro is the intro") + check(registered.Gen2SlotMachine, + "the slot machine next door IS an engine screen and keeps its id") + check(registered.Gen2CardFlip, "so does card flip") + check(registered.Gen2GoldSilverIntro, "and so does the real intro") + -- Every remaining id has to resolve, or the list is worse than the two rows + -- taken out of it. + local missing = {} + for _, id in ipairs(Screens.GEN2_IDS) do + local ok = pcall(Screens.get, {}, id) + if not ok then missing[#missing + 1] = id end + end + eq(#missing, 0, "every id still on the list resolves to a module") + Screens.invalidate() +end + +-- ---- the counter, driven --------------------------------------------------- +-- +-- The transaction arm of a TM counter, in the cart's own order: the coin check +-- first, then the question, then the PACK, and only then the coins actually +-- come off. Built as a command list rather than lifted out of the cache so +-- this half runs with no gold cache at all; the cache half below asserts that +-- the extracted script is the same shape. +do + local HAVE_MORE, HAVE_LESS = 0, 2 + local TM_THUNDER, PRICE = 0xd4, 5500 + + local function vendorScript() + return { + generation = 2, + ["s:tm"] = { + { op = "checkcoins", args = { PRICE % 256, math.floor(PRICE / 256) } }, + { op = "ifequal", value = HAVE_LESS, script = "s:broke" }, + { op = "yesorno" }, + { op = "iffalse", script = "s:cancel" }, + { op = "giveitem", item = TM_THUNDER, quantity = 1 }, + { op = "iffalse", script = "s:noroom" }, + { op = "takecoins", args = { PRICE % 256, math.floor(PRICE / 256) } }, + { op = "end" }, + }, + ["s:broke"] = { { op = "end" } }, + ["s:cancel"] = { { op = "end" } }, + ["s:noroom"] = { { op = "end" } }, + } + end + + local function run(opts) + local state = { coins = opts.coins, given = nil, arm = nil } + local vm = Vm.new(vendorScript(), {}, Events.new(), { + showText = function(_, onDone) if onDone then onDone() end end, + yesorno = function(onChoose) onChoose(opts.yes ~= false) end, + getCoins = function() return state.coins end, + setCoins = function(n) state.coins = n end, + giveItem = function(item, qty) + if opts.fullPack then return false end + state.given = { item = item, qty = qty } + return true + end, + }) + vm:start("s:tm") + for _ = 1, 40 do vm:update() end + state.running = vm:running() + state.unknown = vm.unknownOps + return state + end + + local bought = run({ coins = 6000 }) + eq(bought.coins, 6000 - PRICE, "the counter takes the price in coins") + eq(bought.given and bought.given.item, TM_THUNDER, "and hands over the TM") + check(not bought.running, "with the script run to its end") + eq(next(bought.unknown), nil, "and no opcode fell through the VM") + + local broke = run({ coins = 100 }) + eq(broke.coins, 100, "HAVE_LESS takes the not-enough-coins arm") + eq(broke.given, nil, "and nothing is handed over") + + local said = run({ coins = 6000, yes = false }) + eq(said.coins, 6000, "answering NO cancels before the coins move") + eq(said.given, nil, "and before the item does") + + -- `giveitem / iffalse NoRoomForPrize`: a full PACK is discovered AFTER the + -- question, which is the item counter's own order and the reason the coins + -- are still there. + local full = run({ coins = 6000, fullPack = true }) + eq(full.coins, 6000, "a full PACK costs nothing") + eq(full.given, nil, "and gives nothing") +end + +-- ---- the extracted scripts ------------------------------------------------- +do + 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 mapChunk = loadfile(cache .. "/data/generated/maps.lua") + local scriptChunk = loadfile(cache .. "/data/generated/scripts.lua") + if not (mapChunk and scriptChunk) then + check(true, "no gold cache: the extracted vendor scripts (SKIP)") + else + local maps, scripts = mapChunk(), scriptChunk() + local room = maps.GOLDENROD_GAME_CORNER + check(room ~= nil, "the cache carries GOLDENROD_GAME_CORNER") + + -- The two vendors, found the way a player finds them: an object whose own + -- script opens a menu and checks the coin case. + local vendors = {} + for _, obj in ipairs((room or {}).objects or {}) do + local cmds = obj.scriptKey and scripts[obj.scriptKey] + if type(cmds) == "table" then + local ops = {} + for _, cmd in ipairs(cmds) do ops[cmd.op] = true end + if ops.loadmenu and ops.verticalmenu and ops.checkitem then + vendors[#vendors + 1] = { key = obj.scriptKey, cmds = cmds } + end + end + end + eq(#vendors, 2, "two prize counters stand in the room") + + -- Follow every arm the counters jump to, so the transaction opcodes are + -- asserted where they actually live rather than only in the entry script. + local reached, queue = {}, {} + for _, vendor in ipairs(vendors) do queue[#queue + 1] = vendor.key end + local ops = {} + while #queue > 0 do + local key = table.remove(queue) + if not reached[key] then + reached[key] = true + for _, cmd in ipairs(scripts[key] or {}) do + ops[cmd.op] = (ops[cmd.op] or 0) + 1 + local target = cmd.script or cmd.target + if type(target) == "string" and scripts[target] then + queue[#queue + 1] = target + end + end + end + end + for _, op in ipairs({ "loadmenu", "verticalmenu", "checkcoins", "takecoins", + "giveitem", "givepoke", "yesorno", "checkitem" }) do + check((ops[op] or 0) > 0, + "the extracted counters use " .. op .. ", which the VM runs") + end + + -- And nothing in them reached the extractor's own failure rows, which is + -- what an opcode the VM would have to hand to a screen would look like. + eq(ops.unknown, nil, "with no unknown command in any arm") + eq(ops.truncated, nil, "and nothing truncated") + end +end + +S.finish() diff --git a/tests/gen2_prize_test.lua b/tests/gen2_prize_test.lua new file mode 100644 index 00000000..326a9dba --- /dev/null +++ b/tests/gen2_prize_test.lua @@ -0,0 +1,463 @@ +-- Prize money for beating a trainer, plus what Mom does with her cut. +-- ROM-free: +-- luajit tests/gen2_prize_test.lua +-- +-- The numbers below are traceable to pokegold: Falkner's ¥900 is +-- TrainerClassAttributes' 25 times his level 9 Pidgeotto times the four adds +-- in WinTrainerBattle, and the Mom item table is data/items/mom_phone.asm +-- verbatim. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 prize money") +local check, eq = S.check, S.eq + +local Prize = require("src.battle.gen2.Prize") +local Battle = require("src.battle.gen2.Battle") +local MomShopping = require("src.core.gen2.MomShopping") +local Decorations = require("src.core.gen2.Decorations") +local Events = require("src.world.gen2.Events") +local Save = require("src.core.gen2.Save") +local Trainers = require("src.world.gen2.Trainers") + +-- --------------------------------------------------------------- fixtures + +-- A save with nothing on it but the two money accounts the payout writes. +local function record(money, saved, active, savingMoney) + return { + player = { name = "GOLD", money = money or 0 }, + mom = { savedMoney = saved or 0, active = active or false, + savingMoney = savingMoney or false }, + } +end + +-- ------------------------------------------------- ComputeTrainerReward + +-- engine/battle/read_trainer_party.asm: base reward times wCurPartyLevel, and +-- wCurPartyLevel is whatever the LAST party row left behind. +eq(Prize.reward(25, 9), 225, "Falkner's quarter is 25 x 9") +eq(Prize.reward(4, 4), 16, "a YOUNGSTER's is 4 x 4") +eq(Prize.reward(nil, 9), 0, "a class with no base reward pays nothing") +eq(Prize.reward(25, nil), 0, "and neither does a party with no level") +-- hProduct + 2 / + 3 with a zero on top: the product is kept modulo 65536. +eq(Prize.reward(255, 300), (255 * 300) % 0x10000, + "the product is truncated to wBattleReward's low two bytes") + +eq(Prize.rewardLevel({ { level = 7 }, { level = 9 } }), 9, + "the level is the LAST roster row, not the highest") +eq(Prize.rewardLevel({ { level = 40 }, { level = 3 } }), 3, + "even when the last row is the weakest") +eq(Prize.rewardLevel({}), 0, "and an empty party has none") + +-- ---------------------------------------------------- the four quarters + +-- WinTrainerBattle's `ld c, 4`: wBattleReward is added FOUR times, and only +-- then doubled twice for the text. Falkner really does pay ¥900. +local falkner = record(0) +local award = Prize.award(falkner, { baseMoney = 25, level = 9 }) +eq(award.quarter, 225, "one quarter of Falkner's prize") +eq(award.total, 900, "and the figure the text prints") +eq(falkner.player.money, 900, "all four quarters land in the wallet") +eq(award.toMom, 0, "with Mom taking none of them") + +-- Mom is not saving yet: `active` alone is the bank conversation having +-- happened, and MOM_SAVING_SOME_MONEY_F is a separate bit. +local idle = record(0, 0, true, false) +Prize.award(idle, { baseMoney = 25, level = 9 }) +eq(idle.player.money, 900, "an active-but-not-saving Mom takes nothing") +eq(idle.mom.savedMoney, 0, "and her account does not move") + +-- The standing 25%: one of the four adds goes to wMomsMoney. +local saving = record(0, 0, true, true) +local skimmed = Prize.award(saving, { baseMoney = 25, level = 9 }) +eq(skimmed.toMom, 1, "MOM_SAVING_SOME_MONEY_F takes one quarter") +eq(saving.mom.savedMoney, 225, "which is a quarter of the prize") +eq(saving.player.money, 675, "and the wallet keeps the other three") +eq(skimmed.total, 900, "while the text still names the whole figure") + +-- .CheckMaxedOutMomMoney: with her account at the cap the carry is clear, b +-- is 0 and the KeepItAll text is chosen -- she stops skimming rather than +-- throwing the quarter away. +local maxed = record(0, Prize.MAX_MONEY, true, true) +local kept = Prize.award(maxed, { baseMoney = 25, level = 9 }) +eq(kept.toMom, 0, "a maxed-out Mom takes nothing") +eq(kept.mode, 0, "and the text falls back to KeepItAll") +eq(maxed.player.money, 900, "so the whole prize reaches the wallet") +eq(maxed.mom.savedMoney, Prize.MAX_MONEY, "and her account is untouched") + +-- AddBattleMoneyToAccount clamps at MAX_MONEY rather than wrapping. +local rich = record(Prize.MAX_MONEY - 100) +Prize.award(rich, { baseMoney = 25, level = 9 }) +eq(rich.player.money, Prize.MAX_MONEY, "the wallet clamps at the cap") +local richMom = record(0, Prize.MAX_MONEY - 100, true, true) +Prize.award(richMom, { baseMoney = 100, level = 100 }) +eq(richMom.mom.savedMoney, Prize.MAX_MONEY, "and so does Mom's account") + +-- The Amulet Coin doubles wBattleReward BEFORE the split, so Mom's cut +-- doubles with everything else. +local coin = record(0, 0, true, true) +local doubled = Prize.award(coin, { baseMoney = 25, level = 9, + amuletCoin = true }) +eq(doubled.total, 1800, "an AMULET COIN doubles the prize") +eq(coin.mom.savedMoney, 450, "including the quarter Mom takes") +eq(coin.player.money, 1350, "and the three the wallet keeps") + +-- The half and all settings are Crystal's -- BankOfMom never writes them in +-- Gold -- but WinTrainerBattle's loop reads them, and 3 means FOUR quarters +-- (the `inc a`), not three. +local half = record(0, 0, true, 2) +eq(Prize.award(half, { baseMoney = 25, level = 9 }).toMom, 2, + "MOM_SAVING_HALF_MONEY_F takes two quarters") +eq(half.mom.savedMoney, 450, "which really is half") +local all = record(0, 0, true, 3) +eq(Prize.award(all, { baseMoney = 25, level = 9 }).toMom, 4, + "both bits together take all four") +eq(all.player.money, 0, "so the wallet gets nothing") +eq(all.mom.savedMoney, 900, "and Mom banks the lot") + +-- data/text/battle.asm. The half and all lines really do replace the money +-- line rather than following it. +local plain = Prize.message({ total = 900, mode = 0 }, "GOLD") +check(plain:find("900", 1, true) ~= nil, "the money line names the figure") +check(plain:find("MOM", 1, true) == nil, "and does not mention MOM") +local some = Prize.message({ total = 900, mode = 1 }, "GOLD") +check(some:find("900", 1, true) ~= nil, "SentSomeToMomText keeps the figure") +check(some:find("Sent some to MOM!", 1, true) ~= nil, "and adds the line") +eq(Prize.message({ total = 900, mode = 2 }, "GOLD"), "Sent half to MOM!", + "SentHalfToMomText is the whole text") +eq(Prize.message({ total = 900, mode = 3 }, "GOLD"), "Sent all to MOM!", + "and so is SentAllToMomText") +check(plain:find("\n", 1, true) == nil, + "no line marker: Chrome.wrap breaks a battle message itself") + +-- ------------------------------------------------- reached from a battle + +-- The whole point of the file: a real Battle, beaten, pays the save. These +-- fixtures are the shapes Trainers.party and Mon.new produce, cut down to +-- what the faint path reads. +local DATA = { + pokemon = { + RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57, + growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56, + defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL" } }, + PIDGEY = { id = "PIDGEY", name = "PIDGEY", index = 15, baseExp = 55, + growthRate = "MEDIUM_SLOW", stats = { hp = 40, attack = 45, + defense = 40, speed = 56, specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" } }, + }, + moves = {}, +} + +local function mon(species, level, hp, item) + return { species = species, name = species, nickname = species, + level = level, hp = hp, maxHp = 30, item = item, + stats = { hp = 30, attack = 10, defense = 10, speed = 10, + specialAttack = 10, specialDefense = 10 }, + moves = {}, experience = 0, statExp = {}, dvs = {} } +end + +local function beat(save, opts) + opts = opts or {} + local enemy = mon("PIDGEY", 7, 1) + local last = mon("PIDGEY", 9, 0) + local battle = Battle.new({ + data = DATA, + party = { mon("RATTATA", 10, 20, opts.playerItem) }, + save = save, + trainer = { name = "FALKNER", className = "LEADER", + baseMoney = 25, party = { enemy, last } }, + }) + -- The last mon standing faints, which is the arm WinTrainerBattle sits in. + battle.enemy = last + battle.enemyIndex = 2 + battle.enemyParty[1].hp = 0 + last.hp = 0 + battle:resolveFaints() + return battle +end + +local paid = record(0) +local won = beat(paid) +eq(won.outcome, "win", "the battle ends on a win") +eq(paid.player.money, 900, "and the save is paid the cart's figure") +check(won.prize ~= nil, "the battle keeps the award it made") + +-- The money line is an EVENT, so the battle screen shows it the same way it +-- shows every other line: no new seam, no new phase. +local sawMoney = nil +for _, event in ipairs(won.events) do + if event.kind == "money" then sawMoney = event end +end +check(sawMoney ~= nil, "a `money` event is emitted") +check(sawMoney and sawMoney.text and sawMoney.text:find("900", 1, true) ~= nil, + "carrying the line StdBattleTextbox would print") + +-- CheckAmuletCoin latches on the SEND-OUT, so the lead's held item counts. +local coined = record(0) +beat(coined, { playerItem = Prize.AMULET_COIN }) +eq(coined.player.money, 1800, "a lead holding the AMULET COIN doubles it") + +-- A battle with no save (a headless turn-order test, or a link battle) pays +-- nobody rather than throwing. +local noSave = beat(nil) +eq(noSave.outcome, "win", "a battle with no save still ends") + +-- A WILD battle pays nothing: WinTrainerBattle is the trainer arm and nothing +-- else reaches it. +local wildSave = record(0) +local wild = Battle.new({ data = DATA, save = wildSave, + party = { mon("RATTATA", 10, 20) }, wild = mon("PIDGEY", 3, 0) }) +wild:resolveFaints() +eq(wildSave.player.money, 0, "beating a wild mon pays nothing") + +-- --------------------------------------------- the class attribute row + +-- TrainerClassAttributes is SEVEN bytes and the base reward is the THIRD, so +-- Trainers.lookup has to carry it through to the battle or the payout is +-- always zero. +local TRAINER_DATA = { classes = { FALKNER = { id = "FALKNER", index = 1, + name = "FALKNER", baseMoney = 25, items = {}, + trainers = { [1] = { id = "FALKNER1", name = "FALKNER", + party = { { species = "PIDGEY", level = 7 } } } } } } } +local looked = Trainers.lookup(TRAINER_DATA, 1, 1) +eq(looked and looked.baseMoney, 25, "the class's base reward survives lookup") + +-- ------------------------------------------------------- Mom goes shopping + +local function momSave(saved) + local save = Save.normalize({ mom = { savedMoney = saved, active = true, + savingMoney = true }, party = {} }) + return save +end + +-- NewGame's seed, which is also what MomShopping.state fills in for a file +-- that predates it. +local seeded = MomShopping.state(momSave(0)) +eq(seeded.whichItem, 0, "the ladder starts on its first rung") +eq(seeded.triggerBalance, MomShopping.MOM_MONEY, + "and the consolation threshold at MOM_MONEY") + +-- data/items/mom_phone.asm: the four dolls and their thresholds. These are +-- the only way a Gold player gets any of them. +local DOLLS = { + { trigger = 10000, deco = 35, label = "CHARMANDER" }, + { trigger = 30000, deco = 32, label = "CLEFAIRY" }, + { trigger = 50000, deco = 30, label = "PIKACHU" }, + { trigger = 100000, deco = 26, label = "BIG SNORLAX" }, +} +local dollRows = 0 +for _, row in ipairs(MomShopping.ITEMS_2) do + if row.kind == 2 then dollRows = dollRows + 1 end +end +eq(dollRows, 4, "MomItems_2 carries four dolls") +for _, doll in ipairs(DOLLS) do + local found = nil + for _, row in ipairs(MomShopping.ITEMS_2) do + if row.item == doll.deco then found = row end + end + check(found ~= nil, doll.label .. "'s doll is on the list") + eq(found and found.trigger, doll.trigger, + "and unlocks at its own savings threshold") + eq(Decorations.attributes(doll.deco) ~= nil, true, + "and its DECO id names a real decoration row") +end + +-- Under the first rung and off a MOM_MONEY multiple: nothing happens. +local quiet = momSave(500) +check(MomShopping.tryBuy(quiet, { events = Events.new() }) == nil, + "with too little saved Mom buys nothing") + +-- The ladder, one rung per won trainer battle. 10000 banked is past the +-- first three triggers, and each buy comes out of the savings -- which is why +-- the doll at 10000 does NOT follow them: three purchases have dropped the +-- balance under its own trigger and it has to be earned back. +local ladder = momSave(10000) +local events = Events.new() +local bought = {} +for _ = 1, 4 do + bought[#bought + 1] = MomShopping.tryBuy(ladder, { events = events, + random = function() return 0 end }) +end +eq(bought[1] and bought[1].item, "SUPER_POTION", "rung one is a SUPER POTION") +eq(bought[2] and bought[2].item, "REPEL", "rung two a REPEL") +eq(bought[3] and bought[3].item, "SUPER_POTION", "rung three another") +check(bought[4] == nil, "and the doll waits: the buying spent its trigger") +eq(ladder.mom.whichItem, 3, "the ladder has moved on three rungs") +eq(ladder.mom.savedMoney, 10000 - 600 - 270 - 600, + "and every cost came out of her savings") +eq(ladder.pcItems.SUPER_POTION, 2, "the two potions are in the PC") +eq(ladder.pcItems.REPEL, 1, "and so is the REPEL") + +-- Saved back up to the trigger, the doll arrives -- and it is a decoration +-- flag, not a PC item, because Mom_GiveItemOrDoll's doll arm goes through +-- DecorationFlagAction_c. +ladder.mom.savedMoney = 10000 +local doll = MomShopping.tryBuy(ladder, { events = events }) +eq(doll and doll.kind, "doll", "rung four is the doll") +eq(doll and doll.item, 35, "the CHARMANDER doll") +check(Decorations.owns(events, 35), "which the player now owns") +eq(ladder.mom.savedMoney, 10000 - 1800, "and she paid 1800 for it") +eq(ladder.pcItems[35], nil, "a doll does not go in the PC") + +-- MomItems_1: the consolation buy, which fires only when the LADDER has +-- nothing to offer (the next rung's trigger is out of reach) and the savings +-- land EXACTLY on a MOM_MONEY multiple the threshold has not passed. +local function stalledSave(saved) + local save = momSave(saved) + MomShopping.state(save) + -- Rung two wants 4000; anything below that leaves the ladder stalled. + save.mom.whichItem = 1 + return save +end +local exact = stalledSave(MomShopping.MOM_MONEY) +local consolation = MomShopping.tryBuy(exact, { events = Events.new(), + random = function() return 1 end }) +eq(consolation and consolation.set, 1, "an exact 2300 buys off MomItems_1") +eq(consolation and consolation.item, "ANTIDOTE", "the row the roll picked") +eq(exact.mom.whichItem, 1, "and the ladder has NOT moved") +eq(exact.mom.triggerBalance, MomShopping.MOM_MONEY * 2, + "while the threshold has climbed a step") +exact.mom.savedMoney = MomShopping.MOM_MONEY +check(MomShopping.tryBuy(exact, { events = Events.new(), + random = function() return 1 end }) == nil, + "so the same 2300 cannot pay twice") + +-- Overshooting the threshold is `.less_than`: the balance is left where the +-- walk pushed it and nothing is bought. +local between = stalledSave(MomShopping.MOM_MONEY + 1) +check(MomShopping.tryBuy(between, { events = Events.new() }) == nil, + "a balance between two rungs buys nothing") +eq(between.mom.triggerBalance, MomShopping.MOM_MONEY * 2, + "and the threshold has walked past it") + +-- GetMapPhoneService: no reception, no call, and the balance is not even +-- looked at -- so Mom tries again after the next trainer. +local noService = stalledSave(MomShopping.MOM_MONEY) +check(MomShopping.tryBuy(noService, { events = Events.new(), + phoneService = false }) == nil, "a map with no phone service buys nothing") +eq(noService.mom.triggerBalance, MomShopping.MOM_MONEY, + "and leaves the threshold alone") + +-- Mom_GetScriptPointer's two scripts. +local itemPages = MomShopping.pages({ kind = "item" }) +local dollPages = MomShopping.pages({ kind = "doll" }) +eq(#itemPages, 4, ".ItemScript is four writetexts") +eq(#dollPages, 4, "and so is .DollScript") +check(itemPages[4]:find("PC", 1, true) ~= nil, "the item ends up in the PC") +check(dollPages[4]:find("room", 1, true) ~= nil, "and the doll in your room") +check(itemPages[1] == dollPages[1], "both open on the same greeting") + +-- A doll cannot fail; an item into a full PC can, and then nothing is +-- deducted (Mom_GiveItemOrDoll's no-carry return). +local fullPc = momSave(10000) +fullPc.pcItems = {} +for i = 1, 50 do fullPc.pcItems["FILLER_" .. i] = 1 end +local refused = MomShopping.tryBuy(fullPc, { events = Events.new() }) +check(refused == nil, "a full PC refuses the purchase") +eq(fullPc.mom.savedMoney, 10000, "and nothing is deducted") +eq(fullPc.mom.whichItem, 0, "and the ladder does not move") + +-- ------------------------------------------------ the two World call sites +-- +-- A model with a green suite and no door is the failure this file exists to +-- avoid, so both seams are driven through World:startBattle itself rather +-- than called by hand: the battle screen is replaced through the screens +-- REGISTRY (the same door a mod uses), which hands back the real `onDone` +-- closure Script_reloadmapafterbattle's two arms live in. + +local World = require("src.world.gen2.World") +local Screens = require("src.ui.Screens") + +local function battleWorld(save) + local captured = {} + -- Screens caches a factory by id alone, so a second world would otherwise + -- be handed the first one's closure. + Screens.invalidate() + local game = { + data = { + pokemon = DATA.pokemon, moves = DATA.moves, + screens = { + Gen2BattleState = function(_, opts) + captured.opts = opts + return { screenId = "Gen2BattleState" } + end, + }, + }, + save = save, + stack = { push = function() end, pop = function() end }, + } + local world = World.new(game) + game.world = world + world.map = { def = { id = "TEST_MAP", phoneService = true } } + world.maps = { TEST_MAP = world.map.def } + world.events = Events.new() + -- The render and audio halves, which have no place in a headless run. + world.playBattleMusic = function() end + world.battleMusicContext = function() return nil end + world.pushBattleTransition = function() return nil end + world.restoreMapMusic = function() end + world.healParty = function() end + world.warpToSpawn = function() end + return world, captured +end + +local lossSave = Save.normalize({ party = {}, + player = { name = "GOLD", money = 3001 }, + mom = { savedMoney = 4000, active = true, savingMoney = true } }) +local lossWorld, lossCaptured = battleWorld(lossSave) +lossWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, + party = { mon("PIDGEY", 9, 5) } } }) +check(lossCaptured.opts ~= nil, "startBattle pushes the battle screen") +check(lossCaptured.opts and lossCaptured.opts.save == lossSave, + "handing it the save the payout writes") +lossCaptured.opts.onDone("lose") +eq(lossSave.player.money, 1500, "a lost battle halves the wallet") +eq(lossSave.mom.savedMoney, 4000, + "and leaves Mom's savings alone -- HalveMoney shifts wMoney only") + +-- The Bug Contest arm: `checkflag ENGINE_BUG_CONTEST_TIMER / iftrue +-- .bug_contest` skips both callasms, so a wipe in the park is free. +local parkSave = Save.normalize({ party = {}, + player = { name = "GOLD", money = 3000 } }) +parkSave.bugContest = { active = true, start = { day = 0, hour = 0, + minute = 0, second = 0 } } +local parkWorld, parkCaptured = battleWorld(parkSave) +parkWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, + party = { mon("PIDGEY", 9, 5) } } }) +parkCaptured.opts.onDone("lose") +eq(parkSave.player.money, 3000, "a wipe during the Bug Contest costs nothing") + +-- The win arm: MomTriesToBuySomething, queued the way LoadMemScript queues it +-- so the four lines land after the trainer's own after-battle script. +local shopSave = Save.normalize({ party = {}, + player = { name = "GOLD", money = 0 }, + mom = { savedMoney = 900, active = true, savingMoney = true } }) +local shopWorld, shopCaptured = battleWorld(shopSave) +shopWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, + party = { mon("PIDGEY", 9, 5) } } }) +check(shopWorld.queuedScript == nil, "nothing is queued before the battle") +shopCaptured.opts.onDone("win") +check(shopWorld.queuedScript ~= nil, "a won trainer battle lets Mom shop") +eq(shopSave.mom.savedMoney, 900 - 600, "and she pays for what she bought") +eq(shopSave.pcItems.SUPER_POTION, 1, "which is waiting in the PC") +local sawRaw = false +for _, row in ipairs(shopWorld.queuedScript or {}) do + if row.op == "rawtext" then sawRaw = true end +end +check(sawRaw, "the queued script speaks the call's four lines") + +-- A won WILD battle does not: Script_reloadmapafterbattle's `.was_wild` arm +-- goes to the box-full check instead. +local wildWorld, wildCaptured = battleWorld(Save.normalize({ party = {}, + player = { name = "GOLD", money = 0 }, + mom = { savedMoney = 900, active = true, savingMoney = true } })) +wildWorld.roamMonsAfterBattle = function() end +wildWorld:startBattle({ wild = mon("PIDGEY", 3, 5) }) +wildCaptured.opts.onDone("win") +check(wildWorld.queuedScript == nil, "a wild battle gives Mom no chance") + +-- The registry cache is process-wide, so the fake screen must not outlive +-- this file: tests/run_tests.lua dofiles every suite into one process. +Screens.invalidate() + +return S.finish() diff --git a/tests/gen2_repel_test.lua b/tests/gen2_repel_test.lua new file mode 100644 index 00000000..04d9f843 --- /dev/null +++ b/tests/gen2_repel_test.lua @@ -0,0 +1,194 @@ +-- CheckRepelEffect, the last gate in TryWildEncounter. +-- +-- luajit tests/gen2_repel_test.lua +-- +-- The port had the whole REPEL step chain -- World:useRepel arming +-- save.repelSteps, StepEvents.repelStep ticking it down and printing the +-- wear-off -- and nothing in the encounter path ever READ it, so a Max Repel +-- was consumed, messaged and counted while changing nothing at all. +-- +-- CheckRepelEffect (engine/overworld/wildmons.asm) runs AFTER +-- ChooseWildEncounter has already picked the mon, which is the fact that +-- decides every case below: the filter is on the LEVEL that was rolled, and +-- the mon it is measured against is the first party member that is not +-- fainted. `ld a, [wCurPartyLevel] / cp [hl] / jr nc, .encounter` takes the +-- encounter on greater-or-equal, so a lead standing at the wilds' own level +-- repels nothing. +-- +-- CheckEncounterRoamMon writes wCurPartyLevel too, so a beast goes through the +-- same filter; SweetScentEncounter never reaches the routine at all. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 repel") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Roamers = require("src.core.gen2.Roamers") + +local COLL_TALL_GRASS = 0x18 + +local DATA = { + items = { + REPEL = { id = "REPEL", name = "REPEL", pocket = "ITEM", index = 0x14 }, + MAX_REPEL = { id = "MAX_REPEL", name = "MAX REPEL", pocket = "ITEM", + index = 0x2b }, + }, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + pokemon = { + HOOTHOOT = { name = "HOOTHOOT", index = 163, types = { "NORMAL", "FLYING" }, + baseStats = { hp = 60, attack = 30, defense = 30, speed = 50, + specialAttack = 36, specialDefense = 56 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + RAIKOU = { name = "RAIKOU", index = 243, types = { "ELECTRIC", "ELECTRIC" }, + baseStats = { hp = 90, attack = 85, defense = 75, speed = 115, + specialAttack = 115, specialDefense = 100 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + QUAGSIRE = { name = "QUAGSIRE", index = 195, types = { "WATER", "GROUND" }, + baseStats = { hp = 95, attack = 85, defense = 85, speed = 35, + specialAttack = 65, specialDefense = 65 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + }, +} + +-- An always-passing rate and all seven slots filled with the same row, so +-- nothing below is about a roll: every step that is not repelled starts a +-- battle with a level 15 HOOTHOOT. +local function sevenSlots() + local list = {} + for i = 1, 7 do list[i] = { species = "HOOTHOOT", level = 15 } end + return list +end + +local ENCOUNTERS = { + grass = { + ROUTE_42 = { + map = "ROUTE_42", + rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { MORN = sevenSlots(), DAY = sevenSlots(), NITE = sevenSlots() }, + }, + }, +} + +local function repelWorld(party) + local game = { + data = DATA, + save = { player = { name = "GOLD", badges = {} }, + party = party or {}, inventory = {} }, + } + local world = World.new(game) + game.world = world + world.maps = { ROUTE_42 = { id = "ROUTE_42", group = 3, map = 1, + width = 2, height = 2, blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} } } + world.map = { + id = "ROUTE_42", def = world.maps.ROUTE_42, width = 2, height = 2, + cellCollision = function() return COLL_TALL_GRASS end, + } + world.map.def.environment = "ROUTE" + world.encounters = ENCOUNTERS + world.player = { cellX = 0, cellY = 0 } + world.daytime = "DAY" + world.started = nil + world.startBattle = function(self, opts) self.started = opts return true end + return world, game +end + +local function mon(species, level) + return { species = species, level = level, hp = 20, maxHp = 20 } +end + +-- ---- the plain grass filter ------------------------------------------------ +do + local world, game = repelWorld({ mon("QUAGSIRE", 40) }) + check(world:tryWildEncounter(), "with no REPEL the level 15 wild fires") + eq(world.started.wild.species, "HOOTHOOT", "and it is the map's own slot") + + world.started = nil + game.save.pokedex = { seen = {}, caught = {} } + game.save.repelSteps = 250 + check(not world:tryWildEncounter(), + "a ticking REPEL drops a wild BELOW the lead's level") + check(world.started == nil, "so no battle starts") + eq(game.save.repelSteps, 250, + "and the filter never touches the counter -- DoRepelStep owns it") + check(not game.save.pokedex.seen.HOOTHOOT, + "a repelled encounter is not even SEEN") +end + +-- `jr nc, .encounter`: equal levels still fight. This is the difference +-- between "repels weaker mons" and "repels everything", and it is one branch. +do + local world, game = repelWorld({ mon("QUAGSIRE", 15) }) + game.save.repelSteps = 250 + check(world:tryWildEncounter(), + "a wild at exactly the lead's level is NOT repelled") + eq(world.started.wild.level, 15, "and fights at its own level") +end + +do + local world, game = repelWorld({ mon("QUAGSIRE", 16) }) + game.save.repelSteps = 100 + check(not world:tryWildEncounter(), "one level under the lead is repelled") +end + +-- The walk starts at wPartyMon1HP and skips every slot whose HP is zero, so +-- the mon a repel measures against is the first LIVE one -- and an egg (HP +-- zeroed by DayCare_GiveEgg) is never it. +do + local world, game = repelWorld({ + { species = "EGG", isEgg = true, hp = 0, maxHp = 0, level = 40 }, + { species = "QUAGSIRE", level = 5, hp = 0, maxHp = 20 }, + mon("QUAGSIRE", 40), + }) + game.save.repelSteps = 250 + check(not world:tryWildEncounter(), + "the egg and the fainted mon are skipped; the level 40 lead repels") + + world.started = nil + game.save.party = { { species = "QUAGSIRE", level = 5, hp = 12, maxHp = 20 }, + mon("QUAGSIRE", 40) } + check(world:tryWildEncounter(), + "a live level 5 in front of it lets the level 15 wild through") +end + +-- No repel armed at all is the routine's own first line: `and a / jr z`. +do + local world, game = repelWorld({ mon("QUAGSIRE", 40) }) + game.save.repelSteps = 0 + check(world:tryWildEncounter(), "a spent REPEL suppresses nothing") +end + +-- ---- the beast ------------------------------------------------------------ +-- +-- CheckEncounterRoamMon stages wTempWildMonSpecies AND wCurPartyLevel before +-- returning carry, so the level a repel measures is the beast's 40. A level +-- 41 lead really does repel Raikou on the cart. +do + local world, game = repelWorld({ mon("QUAGSIRE", 41) }) + Roamers.init(game.save, { force = true }) + game.save.roamers[1].map = "ROUTE_42" + world.roamerRandom = function() return 1 end -- slot 1 past both gates + game.save.repelSteps = 250 + check(not world:tryWildEncounter(), "a level 41 lead repels the beast itself") + check(world.started == nil, "no roaming battle starts") + check(game.save.roamers[1].hp == 0, + "and .InitRoamHP never runs, so the beast keeps its unrolled struct") + + game.save.party = { mon("QUAGSIRE", 40) } + check(world:tryWildEncounter(), "a level 40 lead does not") + eq(world.started.roaming, 1, "and it is the roaming battle") +end + +-- ---- SWEET SCENT ---------------------------------------------------------- +-- +-- SweetScentEncounter (engine/events/sweet_scent.asm) is CanEncounterWildMon, +-- GetMapEncounterRate and ChooseWildEncounter -- and then straight to +-- wScriptVar. CheckRepelEffect is not in it, so the move works through a +-- Max Repel exactly as the cart lets it. +do + local world, game = repelWorld({ mon("QUAGSIRE", 40) }) + game.save.repelSteps = 250 + check(world:sweetScentEncounter(), "SWEET SCENT ignores a ticking REPEL") + eq(world.started.wild.species, "HOOTHOOT", "and turns up the map's own slot") +end + +S.finish() diff --git a/tests/gen2_roamers_test.lua b/tests/gen2_roamers_test.lua new file mode 100644 index 00000000..881ea3c3 --- /dev/null +++ b/tests/gen2_roamers_test.lua @@ -0,0 +1,364 @@ +-- The three roaming beasts, WIRED. `luajit tests/gen2_roamers_test.lua`; also +-- dofile'd by tests/run_tests.lua. ROM-free. +-- +-- src/core/gen2/Roamers.lua has been complete and unit tested for a while; what +-- it did not have was a single caller outside its own test, so `special +-- InitRoamMons` put three structs on the save when the Burned Tower floor gave +-- way and then nothing ever moved them, rolled for them or banked them. A +-- beast sat on its starting route forever and could not be met even there. +-- +-- So this suite is about the four CALL SITES, and each is a place the cart +-- names explicitly: +-- +-- MapSetupCommands $26 UpdateRoamMons the tail of MapSetupScript_Train, +-- which _Door and _Fall fall into, and +-- a row of _Connection +-- MapSetupCommands $27 JumpRoamMons the third row of _Teleport, ABOVE +-- the load +-- ChooseWildEncounter CheckEncounterRoamMon, before the +-- map's own slot list +-- BattleEnd_HandleRoamMons on the way out of every wild battle +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 roamers") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Roamers = require("src.core.gen2.Roamers") +local FieldMoves = require("src.world.gen2.FieldMoves") + +-- MAPSETUP_* (constants/map_setup_constants.asm), as World names them. +local WARP, RELOADMAP = 0xf1, 0xf3 +local TELEPORT, DOOR, FALL = 0xf4, 0xf5, 0xf6 +local CONNECTION, TRAIN = 0xf7, 0xf9 + +local function roamWorld(opts) + opts = opts or {} + local game = { + data = { + pokemon = { + RAIKOU = { name = "RAIKOU", index = 243, types = { "ELECTRIC" }, + baseStats = { hp = 90, attack = 85, defense = 75, speed = 115, + specialAttack = 115, specialDefense = 100 }, + growthRate = 0, levelMoves = { { level = 1, move = "QUICK_ATTACK" } } }, + }, + moves = { QUICK_ATTACK = { name = "QUICK ATTACK", pp = 30 } }, + }, + save = { player = { name = "GOLD", badges = {} }, party = {}, inventory = {} }, + } + local world = World.new(game) + world.maps = { + ROUTE_29 = { id = "ROUTE_29", group = 1, map = 1, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} }, + ROUTE_30 = { id = "ROUTE_30", group = 1, map = 2, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} }, + } + world.map = { id = opts.map or "ROUTE_29", def = world.maps.ROUTE_29, + width = 2, height = 2 } + world.encounters = opts.encounters + -- The load itself is not the subject here; the roam step around it is. + world.setMap = function(self, id) + self.map = { id = id, def = self.maps[id] or self.maps.ROUTE_29, + width = 2, height = 2 } + return true + end + Roamers.init(game.save, { force = true }) + return world, game +end + +-- --------------------------------------------------------------------------- +-- The two map setup commands +-- --------------------------------------------------------------------------- + +-- Which of the eleven setup scripts carries which command, fallthroughs +-- honoured. Getting this table wrong is invisible in play -- the beasts just +-- move at the wrong times -- so it is pinned rather than trusted. +do + local moved = { UPDATE = {}, JUMP = {} } + local realUpdate, realJump = Roamers.update, Roamers.jumpAll + Roamers.update = function(...) moved.UPDATE[#moved.UPDATE + 1] = true + return realUpdate(...) end + Roamers.jumpAll = function(...) moved.JUMP[#moved.JUMP + 1] = true + return realJump(...) end + + local function ran(method) + moved.UPDATE, moved.JUMP = {}, {} + local world = roamWorld() + world:runMapSetup(method, function() return world:setMap("ROUTE_30") end) + -- A fading script parks its load; run the chain out so the tail lands. + for _ = 1, 40 do + if not world.mapSetup then break end + world:updateMapSetup() + end + return #moved.UPDATE > 0, #moved.JUMP > 0 + end + + local up, jump = ran(CONNECTION) + check(up and not jump, "MAPSETUP_CONNECTION names UpdateRoamMons") + up, jump = ran(TRAIN) + check(up and not jump, "and so does _Train") + up, jump = ran(DOOR) + check(up and not jump, "_Door by falling into _Train") + up, jump = ran(FALL) + check(up and not jump, "and _Fall by falling into _Door first") + up, jump = ran(TELEPORT) + check(jump and not up, + "MAPSETUP_TELEPORT names JumpRoamMons, and _Warp below it names neither") + up, jump = ran(WARP) + check(not up and not jump, "a plain warp moves nothing") + up, jump = ran(RELOADMAP) + check(not up and not jump, "and neither does a reload") + + Roamers.update, Roamers.jumpAll = realUpdate, realJump +end + +-- JumpRoamMons runs ABOVE the load and UpdateRoamMons below it, so each sees a +-- different "player's map". _BackUpMapIndices records whichever it saw, and +-- that is the map the next walk avoids. +do + local world, game = roamWorld() + world:runMapSetup(TELEPORT, function() return world:setMap("ROUTE_30") end) + for _ = 1, 40 do + if not world.mapSetup then break end + world:updateMapSetup() + end + eq(game.save.roamerMaps.current, "ROUTE_29", + "JumpRoamMons banked the map being LEFT, because it runs before the load") + + local world2, game2 = roamWorld() + world2:runMapSetup(CONNECTION, function() return world2:setMap("ROUTE_30") end) + eq(game2.save.roamerMaps.current, "ROUTE_30", + "UpdateRoamMons banked the map ARRIVED on, because it is the script's tail") +end + +-- No beasts on the save -> nothing to do, and no crash. This is every game +-- before the Burned Tower. +do + local world, game = roamWorld() + game.save.roamers = nil + check(not world:roamMonsAfterLoad(CONNECTION), "no roamers, no walk") + check(not world:roamMonsBeforeLoad(TELEPORT), "no roamers, no jump") + check(not world:roamMonsAfterBattle(nil, "win", 0), "and no battle tail") + check(not world:roamMonsOnContinue("ROUTE_29"), "and no scatter on CONTINUE") +end + +-- --------------------------------------------------------------------------- +-- The third call site: CONTINUE +-- --------------------------------------------------------------------------- +-- +-- `farcall JumpRoamMons` sits in the continue path itself +-- (engine/menus/intro_menu.asm, three lines above the wSpawnAfterChampion +-- read), not in any map setup script -- so EVERY load of a save scatters the +-- three beasts before the map comes back. That is what makes re-finding one +-- the price of reloading after a failed catch; without it a save-scum loop was +-- strictly easier than the cart's. +do + local world, game = roamWorld() + for _, slot in ipairs(game.save.roamers) do slot.map = "ROUTE_29" end + -- JumpRoamMon re-rolls while the entry it lands on is the PLAYER's map, so + -- a scatter can never drop a beast on top of the file being loaded. + local calls = 0 + world.roamerRandom = function(n) + calls = calls + 1 + return (calls * 5) % n + end + check(world:roamMonsOnContinue("ROUTE_29"), "loading a save scatters them") + for index, slot in ipairs(game.save.roamers) do + check(slot.map ~= "ROUTE_29", + "beast " .. index .. " is no longer where the file was saved") + end + eq(game.save.roamerMaps.current, "ROUTE_29", + "_BackUpMapIndices banks the saved map, which is what the next walk avoids") +end + +-- --------------------------------------------------------------------------- +-- RoamMaps off the cart +-- --------------------------------------------------------------------------- +-- +-- data/wild/roammon_maps.asm, which RomExtractorGen2 now emits as +-- encounters.roamMaps. Roamers.MAPS stays as the fallback for a cache written +-- before it did, so the two have to agree row for row -- ORDER included, since +-- `.Update` picks a connection by a two-bit index and JumpRoamMon picks an +-- entry by a four-bit one. +do + 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 chunk = loadfile(cache .. "/data/generated/encounters.lua") + local encounters = chunk and chunk() + if not (encounters and encounters.roamMaps) then + check(true, "no gold cache: extracted RoamMaps (SKIP)") + else + local extracted = encounters.roamMaps + eq(#extracted, Roamers.NUM_MAPS, "RoamMaps has NUM_ROAMMON_MAPS entries") + check(Roamers.mapTable(encounters) == extracted, + "and Roamers.mapTable prefers it over the hand-written fallback") + local same = true + for index, row in ipairs(Roamers.MAPS) do + local got = extracted[index] + if not got or got.map ~= row.map or #got.to ~= #row.to then + same = false + else + for i, to in ipairs(row.to) do + if got.to[i] ~= to then same = false end + end + end + end + check(same, "every row matches the transcribed table, in order") + -- Routes 40 and 41 are water routes and deliberately absent. + local seen = {} + for _, row in ipairs(extracted) do seen[row.map] = true end + check(not seen.ROUTE_40, "Route 40 is not a roam map") + check(not seen.ROUTE_41, "and neither is Route 41") + end +end + +-- --------------------------------------------------------------------------- +-- CheckEncounterRoamMon at the top of the wild roll +-- --------------------------------------------------------------------------- + +-- WHERE the gate sits matters as much as what it tests. CheckEncounterRoamMon +-- is the first thing ChooseWildEncounter does, and ChooseWildEncounter is only +-- reached once TryWildEncounter's own `.EncounterRate` roll has passed +-- (engine/overworld/wildmons.asm) -- so a beast needs the map's percentage AND +-- 75/256 AND its own route. A table with an always-passing rate and no slots +-- is what isolates the beast from the map's own list below. +local ALWAYS = { + grass = { + ROUTE_29 = { + map = "ROUTE_29", + rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { MORN = {}, DAY = {}, NITE = {} }, + }, + }, + water = { + ROUTE_29 = { map = "ROUTE_29", rate = 256, slots = {} }, + }, +} + +-- The gate is three tests on ONE random byte: < 100, then `and %11` non-zero, +-- then that value is the slot. 1 picks slot 1, which is Raikou -- and Raikou +-- starts on ROUTE_42, so it only fires on the map the beast is actually on. +do + local world, game = roamWorld({ encounters = ALWAYS }) + world.roamerRandom = function() return 1 end -- slot 1, past both gates + game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } } + world.player = { cellX = 0, cellY = 0 } + world.map.cellCollision = function() return 0x18 end -- COLL_TALL_GRASS + world.map.def.environment = "ROUTE" + + local started + world.startBattle = function(_, opts) started = opts return true end + + -- Raikou is on ROUTE_42 and the player is on ROUTE_29: no beast. + check(not world:tryWildEncounter() or started == nil, + "a beast on another route does not fire") + check(started == nil, "and no battle started off it") + + -- Move it under the player's feet. + game.save.roamers[1].map = "ROUTE_29" + check(world:tryWildEncounter(), "the beast on this route fires") + check(started ~= nil, "and it started a battle") + eq(started.roaming, 1, "carrying the SLOT, which is what banks its HP") + eq(started.wild.species, "RAIKOU", "with the right beast") + eq(started.wild.level, 40, "at the roam level") + check(game.save.pokedex.seen.RAIKOU, "and the #DEX saw it") + check(game.save.roamers[1].hp > 0, + ".InitRoamHP banks the full HP on the FIRST meeting, not at the end") +end + +-- The outer gate, pinned on its own: the SAME roamer roll that fires above +-- produces nothing at all on a map whose encounter rate is zero, because +-- ChooseWildEncounter is never reached. Reading these two in the other order +-- (roamer first, rate second) is what made a beast turn up roughly ten times +-- as often per grass step as the cart allows on a 10 percent route. +do + local world, game = roamWorld({ + encounters = { + grass = { ROUTE_29 = { map = "ROUTE_29", + rates = { MORN = 0, DAY = 0, NITE = 0 }, + slots = { MORN = {}, DAY = {}, NITE = {} } } }, + }, + }) + world.roamerRandom = function() return 1 end + game.save.roamers[1].map = "ROUTE_29" + game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } } + world.player = { cellX = 0, cellY = 0 } + world.map.cellCollision = function() return 0x18 end + world.map.def.environment = "ROUTE" + local started + world.startBattle = function(_, opts) started = opts return true end + check(not world:tryWildEncounter(), + "a zero encounter rate stops the step before ChooseWildEncounter") + check(started == nil, "so the beast standing right there never rolls") +end + +-- Surfing refuses before anything else, which is what keeps Suicune out of the +-- water on the routes it shares with the sea. +do + local world, game = roamWorld({ encounters = ALWAYS }) + world.roamerRandom = function() return 1 end + game.save.roamers[1].map = "ROUTE_29" + game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } } + world.player = { cellX = 0, cellY = 0 } + world.map.cellCollision = function() return 0x29 end -- COLL_WATER + world.map.def.environment = "ROUTE" + world.playerState = FieldMoves.PLAYER_SURF + local started + world.startBattle = function(_, opts) started = opts return true end + world:tryWildEncounter() + check(started == nil or not started.roaming, + "a surfing step never meets a beast") +end + +-- --------------------------------------------------------------------------- +-- BattleEnd_HandleRoamMons +-- --------------------------------------------------------------------------- + +do + -- Beating one clears its slot for good: no species, no map. + local world, game = roamWorld() + game.save.roamers[1].hp = 90 + check(world:roamMonsAfterBattle(1, "win", 0), "a won roam battle is handled") + check(not Roamers.active(game.save.roamers[1]), "and the beast is gone") + + -- Catching one is the same clear. + local w2, g2 = roamWorld() + g2.save.roamers[2].hp = 90 + w2:roamMonsAfterBattle(2, "caught", 40) + check(not Roamers.active(g2.save.roamers[2]), "a caught beast is gone too") + + -- Anything else -- it fled, you ran -- banks the HP and moves it. + local w3, g3 = roamWorld() + g3.save.roamers[1].map = "ROUTE_29" + g3.save.roamers[1].hp = 90 + -- 1: `% 32` is non-zero so .Update takes a CONNECTION rather than the + -- 1-in-32 jump, and `% 4` is 1, which is Route 29's second exit. + w3.roamerRandom = function() return 1 end + check(w3:roamMonsAfterBattle(1, "run", 37), "a flee is handled") + eq(g3.save.roamers[1].hp, 37, "and the damage is banked") + check(Roamers.active(g3.save.roamers[1]), "with the beast still out there") + check(g3.save.roamers[1].map ~= "ROUTE_29", "and moved off the player's route") +end + +-- The `.not_roaming` tail: ANY other wild battle gives one chance in sixteen +-- that they all move, which is why they drift while you grind. +do + local world, game = roamWorld() + local rolls = { 0 } -- 0 % 16 == 0, the one case in sixteen + world.roamerRandom = function() return table.remove(rolls, 1) or 5 end + local before = game.save.roamers[1].map + check(world:roamMonsAfterBattle(nil, "win", 0), "the 1-in-16 roll can move them") + check(game.save.roamerMaps ~= nil, "and it backs the map indices up") + local _ = before + + local w2, g2 = roamWorld() + w2.roamerRandom = function() return 5 end -- 5 % 16 ~= 0 + local was = g2.save.roamers[1].map + check(not w2:roamMonsAfterBattle(nil, "win", 0), "fifteen times in sixteen it does not") + eq(g2.save.roamers[1].map, was, "and nothing moved") +end + +S.finish() diff --git a/tests/gen2_rock_smash_test.lua b/tests/gen2_rock_smash_test.lua new file mode 100644 index 00000000..9acd2675 --- /dev/null +++ b/tests/gen2_rock_smash_test.lua @@ -0,0 +1,229 @@ +-- ROCK SMASH's wild encounter (engine/events/treemons.asm RockMonEncounter). +-- +-- luajit tests/gen2_rock_smash_test.lua +-- +-- The smash itself already worked: an object carrying SPRITEMOVEDATA_SMASHABLE_ +-- ROCK runs `jumpstd SmashRockScript`, which farsjumps AskRockSmashScript and +-- then RockSmashScript, and the rock disappears. What never happened was the +-- BATTLE at the end of it, because RockMonEncounter was a deliberate stub and +-- RockMonMaps was not extracted -- so the `readmem wTempWildMonSpecies` two +-- rows later read the VM's own sparse store, saw 0, and the `iffalse` skipped +-- the `randomwildmon / startbattle` pair every single time. No wild mon has +-- ever come out of a rock in this port, which also makes wild SHUCKLE +-- unobtainable. +-- +-- The routine is TreeMonEncounter's twin over a different map table: +-- +-- GetTreeMonSet RockMonMaps CIANWOOD_CITY, ROUTE_40, +-- DARK_CAVE_VIOLET_ENTRANCE, SLOWPOKE_WELL_B1F +-- GetTreeMons TREEMON_SET_ROCK +-- RandomRange 10 / cp 4 40 percent, BETWEEN the lookup and the pick +-- SelectTreeMon the set's FIRST list: 90 KRABBY / 10 SHUCKLE +-- +-- and it writes no wScriptVar at all, which is why the script reads its answer +-- out of WRAM instead. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 rock smash") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local CallAsm = require("src.script.gen2.CallAsm") + +-- The site the cart's RockSmashScript carries, `bank:addr` out of +-- pokegold-symbols/pokegold.sym. +eq(CallAsm.SITES["2e:63a1"], "RockMonEncounter", + "RockMonEncounter still sits at 2e:63a1") +check(CallAsm.HANDLERS.RockMonEncounter ~= nil, + "and it is a handler now, not a stub") +check(CallAsm.STUBS.RockMonEncounter == nil, "with no stub left behind it") +eq(CallAsm.STUB_REASONS.RockMonEncounter, nil, "nor a stub reason") + +local DATA = { + items = {}, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + pokemon = { + KRABBY = { name = "KRABBY", index = 98, types = { "WATER", "WATER" }, + baseStats = { hp = 30, attack = 105, defense = 90, speed = 50, + specialAttack = 25, specialDefense = 25 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + SHUCKLE = { name = "SHUCKLE", index = 213, types = { "BUG", "ROCK" }, + baseStats = { hp = 20, attack = 10, defense = 230, speed = 5, + specialAttack = 10, specialDefense = 230 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + GEODUDE = { name = "GEODUDE", index = 74, types = { "ROCK", "GROUND" }, + baseStats = { hp = 40, attack = 80, defense = 100, speed = 20, + specialAttack = 30, specialDefense = 30 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + }, +} + +-- data/wild/treemons.asm TreeMonSet_Rock, and one grass row for the same map so +-- a mis-wired `randomwildmon` would be visible as a GEODUDE rather than as +-- nothing. +local ENCOUNTERS = { + rocks = { DARK_CAVE_VIOLET_ENTRANCE = "TREEMON_SET_ROCK" }, + treeSets = { + TREEMON_SET_ROCK = { + common = { { chance = 90, species = "KRABBY", level = 15 }, + { chance = 10, species = "SHUCKLE", level = 15 } }, + rare = { { chance = 100, species = "GEODUDE", level = 3 } }, + }, + }, + grass = { + DARK_CAVE_VIOLET_ENTRANCE = { map = "DARK_CAVE_VIOLET_ENTRANCE", + rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { MORN = {}, DAY = {}, NITE = {} } }, + }, +} + +local function rockWorld(mapId) + local game = { + data = DATA, + save = { player = { name = "GOLD", badges = {} }, + party = { { species = "GEODUDE", level = 20, hp = 30, maxHp = 30 } }, + inventory = {} }, + } + local world = World.new(game) + game.world = world + world.maps = { [mapId] = { id = mapId, group = 3, map = 1, width = 2, + height = 2, blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, + environment = "CAVE" } } + world.map = { id = mapId, def = world.maps[mapId], width = 2, height = 2, + cellCollision = function() return 0 end } + world.encounters = ENCOUNTERS + world.player = { cellX = 0, cellY = 0 } + world.daytime = "DAY" + return world +end + +-- constants/pokemon_constants.asm, the indices `startbattle` fights by. +local KRABBY_INDEX, SHUCKLE_INDEX = 98, 213 + +-- ---- the 40 percent -------------------------------------------------------- +-- +-- `ld a, 10 / call RandomRange / cp 4 / jr nc, .no_battle`: 0..3 of ten. The +-- roll is taken BEFORE SelectTreeMon, so a miss costs nothing else. +do + local world = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + local rolls = {} + world.rockmonRandom = function(n) rolls[#rolls + 1] = n return 4 end + eq(world:rockMonEncounter(), 0, "a 4 out of ten is no encounter") + eq(#rolls, 1, "and the list is never even rolled on") + check(world.tempWildMon == nil, "wTempWildMonSpecies is left at zero") + + world.rockmonRandom = function() return 9 end + eq(world:rockMonEncounter(), 0, "and so is a 9") +end + +do + local world = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + -- 3 passes `cp 4`, and 3 lands in the 90 percent KRABBY bracket. + world.rockmonRandom = function() return 3 end + eq(world:rockMonEncounter(), KRABBY_INDEX, + "a 3 out of ten smashes a KRABBY out of the rock") + eq(world.tempWildMon.level, 15, "at TreeMonSet_Rock's own level") + + -- SelectTreeMon walks the chance column as a running total, so only a roll + -- of 90..99 reaches the SHUCKLE row. + local calls = 0 + world.rockmonRandom = function() + calls = calls + 1 + return calls == 1 and 0 or 95 + end + eq(world:rockMonEncounter(), SHUCKLE_INDEX, + "and a 95 out of a hundred reaches the 10 percent SHUCKLE") +end + +-- GetTreeMonSet's `.not_in_table`: a map RockMonMaps does not name has no rock +-- mon however hard it is hit. +do + local world = rockWorld("ROUTE_31") + world.rockmonRandom = function() return 0 end + eq(world:rockMonEncounter(), 0, "a map outside RockMonMaps yields nothing") +end + +-- ---- the WRAM byte the script reads back ----------------------------------- +-- +-- RockSmashScript is `callasm RockMonEncounter / readmem wTempWildMonSpecies / +-- iffalse .done / randomwildmon / startbattle`. The port's VM keeps its own +-- sparse store for the addresses a SCRIPT owns; $d117 is one the ENGINE owns, +-- so World answers for it and the `iffalse` sees the byte that was just +-- written. +do + local world = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + eq(world:scriptReadMem(0xd117), 0, "nothing smashed reads back as zero") + eq(world:scriptReadMem(0xd6a8), nil, + "and an address the World does not own stays the VM's own") + world.rockmonRandom = function() return 3 end + world:rockMonEncounter() + eq(world:scriptReadMem(0xd117), KRABBY_INDEX, + "a hit reads back as the species the iffalse branches on") + + -- Script_randomwildmon only clears wBattleScriptFlags; the pair `startbattle` + -- fights is the one already staged. Reading the map's grass list here + -- instead would have fought whatever Dark Cave holds, not the rock's mon. + local rolled = world:rollWild() + eq(rolled and rolled.species, KRABBY_INDEX, + "randomwildmon takes the staged pair rather than rolling the grass") + eq(rolled and rolled.level, 15, "at the level the rock rolled") + eq(world:scriptReadMem(0xd117), 0, "and the byte is consumed by the battle") +end + +-- CallAsm.run is the entry point World:callAsm reaches, and the handler must +-- leave wScriptVar alone: the cart's routine writes none. +do + local world = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + world.rockmonRandom = function() return 3 end + eq(CallAsm.run(world, "RockMonEncounter"), nil, + "the handler returns no wScriptVar, exactly as the asm does not write one") + eq(world:scriptReadMem(0xd117), KRABBY_INDEX, + "but it did stage the mon") + eq(CallAsm.dispatch(world, nil, 0x2e, 0x63a1), nil, + "and dispatching by the script's own bank:addr reaches the same routine") +end + +-- ---- the real script rows, out of the cache -------------------------------- +do + 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 scriptChunk = loadfile(cache .. "/data/generated/scripts.lua") + local encChunk = loadfile(cache .. "/data/generated/encounters.lua") + if not (scriptChunk and encChunk) then + check(true, "no gold cache: RockSmashScript rows (SKIP)") + else + local scripts = scriptChunk() + local encounters = encChunk() + local rows = scripts["03:4f35"] + check(rows ~= nil, "RockSmashScript is in the cache at 03:4f35") + eq(rows and rows[9] and rows[9].op, "callasm", + "row 9 is the callasm this suite is about") + eq(rows and rows[9] and rows[9].args and rows[9].args[1], 0x2e, + "in bank $2e") + eq(rows and rows[10] and rows[10].op, "readmem", "row 10 reads the byte") + local args = rows and rows[10] and rows[10].args + eq(args and (args[1] + args[2] * 256), 0xd117, + "and the address it reads is wTempWildMonSpecies") + eq(rows and rows[11] and rows[11].op, "iffalse", "row 11 branches on it") + eq(rows and rows[12] and rows[12].op, "randomwildmon", + "row 12 is the randomwildmon that takes the staged pair") + eq(rows and rows[13] and rows[13].op, "startbattle", "row 13 fights it") + + -- RockMonMaps, straight off the cart now. + local rocks = encounters.rocks or {} + eq(rocks.CIANWOOD_CITY, "TREEMON_SET_ROCK", "RockMonMaps: Cianwood") + eq(rocks.ROUTE_40, "TREEMON_SET_ROCK", "Route 40") + eq(rocks.DARK_CAVE_VIOLET_ENTRANCE, "TREEMON_SET_ROCK", + "Dark Cave Violet Entrance") + eq(rocks.SLOWPOKE_WELL_B1F, "TREEMON_SET_ROCK", "and Slowpoke Well B1F") + local rockSet = encounters.treeSets and encounters.treeSets.TREEMON_SET_ROCK + eq(rockSet and rockSet.common[1].species, "KRABBY", "90 percent KRABBY") + eq(rockSet and rockSet.common[2].species, "SHUCKLE", + "and 10 percent SHUCKLE, the only wild SHUCKLE in the game") + end +end + +S.finish() diff --git a/tests/gen2_save_convert_cli_test.lua b/tests/gen2_save_convert_cli_test.lua new file mode 100644 index 00000000..3050b1a1 --- /dev/null +++ b/tests/gen2_save_convert_cli_test.lua @@ -0,0 +1,102 @@ +-- The standalone .sav converter refuses a Gen 2 save, ROM-free. +-- luajit tests/gen2_save_convert_cli_test.lua +-- Also dofile'd by tests/run_tests.lua. +-- +-- src/save_convert/GenSave.lua models Gen 1 SRAM only, and SaveConvert answers +-- a plain refusal for a Gen 2 game rather than pushing a Gen 2 save table +-- through Gen 1 offsets. That refusal keys on the game the caller names, so +-- the CLI has to name one: tools/save_convert/convert.lua runs the real +-- binary here (a subprocess, not the library) because the hole this covers was +-- exactly the two call sites in it that passed no game at all. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 save convert cli") +local check = S.check + +local SaveSerializer = require("src.core.SaveSerializer") + +local function run(cmd) + local pipe = io.popen(cmd .. " 2>&1") + if not pipe then return nil end + local out = pipe:read("*a") + pipe:close() + return out or "" +end + +local probe = run("luajit -v") +if not probe or not probe:find("LuaJIT") then + check(true, "luajit not on PATH : SKIP") + S.finish() + return +end + +local function tmp(name) + local dir = os.getenv("TMPDIR") or "/tmp/" + if dir:sub(-1) ~= "/" then dir = dir .. "/" end + return dir .. "gen2-cli-" .. name +end + +local function write(path, text) + local f = assert(io.open(path, "w")) + f:write(text) + f:close() +end + +local function exists(path) + local f = io.open(path, "rb") + if f then f:close() return true end + return false +end + +-- A Gold slot as src/core/gen2/Save.lua writes it: `generation`/`version` at +-- the top level, and party rows carrying fields Gen 1 never had. +local goldPath = tmp("gold.lua") +local outPath = tmp("gold.sav") +os.remove(outPath) +write(goldPath, SaveSerializer.encode({ + format = 7, generation = 2, version = "gold", + player = { name = "GOLD", money = 3000 }, + party = { { species = "TOTODILE", level = 5, happiness = 70, pokerus = 0, + dvs = { atk = 15, def = 15, spd = 15, spc = 15 } } }, +})) + +local out = run(("luajit tools/save_convert/convert.lua export %q %q") + :format(goldPath, outPath)) +check(out:find("Gen 2 cart save", 1, true) ~= nil, + "exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", ""))) +check(not exists(outPath), + "and no 32768-byte file that looks like a Red battery is written") + +-- The same gate on the way in, when the caller names the game. +local savPath = tmp("in.sav") +local outPath2 = tmp("in.lua") +os.remove(outPath2) +write(savPath, string.rep("\0", 32768)) +out = run(("luajit tools/save_convert/convert.lua import %q %q gold") + :format(savPath, outPath2)) +check(out:find("Gen 2 cart save", 1, true) ~= nil, + "importing for a Gen 2 game is refused too") +check(not exists(outPath2), "and writes nothing") + +-- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate. +-- (Whether the export then succeeds depends on data/generated/ being built, +-- which this suite deliberately does not require.) +local redPath = tmp("red.lua") +local outPath3 = tmp("red.sav") +os.remove(outPath3) +write(redPath, SaveSerializer.encode({ + meta = { format = "gen1_import", version = "red" }, + player = { name = "RED", money = 3000 }, + party = {}, +})) +out = run(("luajit tools/save_convert/convert.lua export %q %q") + :format(redPath, outPath3)) +check(not out:find("Gen 2 cart save", 1, true), + "a Gen 1 save is not refused: " .. (out:gsub("%s+$", ""))) + +for _, path in ipairs({ goldPath, outPath, savPath, outPath2, redPath, + outPath3 }) do + os.remove(path) +end + +S.finish() diff --git a/tests/gen2_save_export_test.lua b/tests/gen2_save_export_test.lua new file mode 100644 index 00000000..9de29b88 --- /dev/null +++ b/tests/gen2_save_export_test.lua @@ -0,0 +1,152 @@ +-- The launcher's Export on a Gold save slot. +-- +-- GOLD_CACHE="..." luajit tests/gen2_save_export_test.lua +-- +-- src/save_convert/GenSave.lua is a Gen 1 codec: its whole offset table is +-- pokered's SRAM window, and a Gold save lives in a different one entirely +-- (pokegold ram/sram.asm sOptions/sCheckValue1/sPlayerData/sBox1-sBox14), +-- so no cart .sav can come out of a Gold slot yet. Export used to reach +-- the codec anyway and die inside GenSave.crosswalks: the Gen 2 generated +-- tables carry top-level provenance scalars (`generation`, `source` -- +-- src/import/RomExtractorGen2.lua) beside the def rows, and indexing the +-- scalar raised "attempt to index local 'def' (a number value)", which the +-- launcher then printed as a red "encode failed:" traceback. +-- +-- Two guarantees pinned here: +-- * the REAL launcher path (slot registry -> gen2 Save.save -> +-- SaveFileIO.exportActiveSlot) answers a plain "not supported yet" +-- message for Gold, in both directions, and writes no export file; +-- * GenSave.crosswalks skips non-table rows, so a data set that carries +-- provenance scalars can never crash the codec again. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 save export") +local check, eq = S.check, S.eq + +love = love or require("tests.love_stub") + +-- An isolated in-memory love.filesystem, the same shape +-- tests/engine/save_slots.lua isolates SaveData with: full-path keys, no +-- directory support (both writers guard their createDirectory calls). +local files = {} +love.filesystem = { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, +} + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local GoldSave = require("src.core.gen2.Save") +local SaveFileIO = require("src.import.SaveFileIO") +local SaveConvert = require("src.save_convert.SaveConvert") +local GenSave = require("src.save_convert.GenSave") + +-- --------------------------------------------------------------------------- +-- The real launcher path: a registered Gold slot holding a save the game's +-- own writer produced, exported through the same call the Export chip makes. +-- --------------------------------------------------------------------------- + +SaveData.resetSlotState() +GameVersion.set("gold") + +local slotId = SaveData.createSlot("gold") +check(slotId ~= nil, "a gold slot registers") +SaveData.setActiveSlot("gold", slotId) +local save = GoldSave.newGame({ playerName = "BLAKE", rivalName = "SILVER" }) +eq(GoldSave.save(save), true, "the gen2 writer fills the slot") +check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil, + "the slot file the launcher lists is on disk") + +local ok, res = SaveFileIO.exportActiveSlot("gold") +eq(ok, false, "Export on a Gold slot is refused, not crashed") +check(type(res) == "string" and res:find("not supported yet", 1, true), + "the refusal is the plain launcher message: " .. tostring(res)) +check(not tostring(res):find("GenSave", 1, true) + and not tostring(res):find("attempt to index", 1, true), + "no codec traceback leaks into the notice line") +local exported = false +for path in pairs(files) do + if path:find("^exports/") then exported = true end +end +eq(exported, false, "no export file is written for a Gold slot") + +-- The import direction through the same seam: a 32 KB image aimed at Gold +-- must be refused by version, before any Gen 1 decoding is attempted. +local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold") +eq(iok, nil, "importing a cart .sav for Gold is refused") +check(type(ierr) == "string" and ierr:find("not supported yet", 1, true), + "the import refusal is the plain launcher message: " .. tostring(ierr)) + +-- Gen 1 versions still pass the gate: red reaches the codec proper and +-- fails on its own terms (an all-zero image is not a table), never on the +-- version. +local rok, rerr = SaveConvert.exportSav({}, "red") +check(rok ~= nil or not tostring(rerr):find("not supported yet", 1, true), + "a red export is never refused by the version gate") + +GameVersion.set("red") + +-- --------------------------------------------------------------------------- +-- GenSave.crosswalks over def tables that carry provenance scalars. +-- --------------------------------------------------------------------------- + +-- Synthetic set, ROM-free: real rows resolve, scalar rows are skipped in +-- all three builders (index, dex, machine). +local defs = { + pokemon = { + generation = 2, + source = "ROM:BaseStats", + MEW = { index = 21, source = "ROM:BaseStats[151]", name = "MEW" }, + }, + moves = { + generation = 2, + POUND = { index = 1 }, + }, + items = { + generation = 2, + source = "ROM:ItemNames", + POKE_BALL = { index = 4 }, + TM01 = { machine = { kind = "TM", number = 1 } }, + }, + maps = { + generation = 2, + PALLET_TOWN = { index = 0 }, + }, +} +local cok, cw = pcall(GenSave.crosswalks, defs) +eq(cok, true, "crosswalks survive scalar rows: " .. tostring(cw)) +if cok then + eq(cw.pokemonIndex.MEW, 21, "a def row beside scalars still resolves") + eq(cw.pokemonByDex[151], "MEW", "the dex builder skips scalar rows") + eq(cw.itemsIndex.TM01, 201, "the machine builder skips scalar rows") + eq(cw.pokemonIndex.generation, nil, "a scalar row never becomes an id") +end + +-- The real Gold tables, when a cache is around: the exact shape that +-- raised at GenSave.crosswalks before the guard. +local cache = os.getenv("GOLD_CACHE") +if cache then + local function loadGen(name) + local chunk = assert(loadfile(cache .. "/data/generated/" .. name .. ".lua")) + return chunk() + end + local gold = { + pokemon = loadGen("pokemon"), moves = loadGen("moves"), + items = loadGen("items"), maps = loadGen("maps"), + } + eq(type(gold.items.generation), "number", + "the gold items table really does carry a scalar row (the regression stays armed)") + local gok, gerr = pcall(GenSave.crosswalks, gold) + eq(gok, true, "crosswalks over the extracted Gold tables: " .. tostring(gerr)) +else + check(true, "no GOLD_CACHE: the extracted Gold tables are not checked (SKIP)") +end + +S.finish() diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua new file mode 100644 index 00000000..e8a10659 --- /dev/null +++ b/tests/gen2_save_test.lua @@ -0,0 +1,484 @@ +-- Gen 2 save file (src/core/gen2/Save.lua): naming, normalization, migration, +-- the CONTINUE summary, play-clock ticking, and the write/read round trip. +-- +-- The filesystem is an in-memory stub, so this never touches a real save. + +package.path = "./?.lua;" .. package.path + +-- A memory filesystem standing in for love.filesystem. Save.lua only uses +-- getInfo / read / write / remove, which is the whole contract this needs. +local files = {} +love = love or {} +love.filesystem = { + getInfo = function(path) + if files[path] then return { type = "file", size = #files[path] } end + return nil + end, + read = function(path) return files[path] end, + write = function(path, data) + files[path] = data + return true + end, + remove = function(path) + files[path] = nil + return true + end, +} + +local GameVersion = require("src.core.GameVersion") +local Save = require("src.core.gen2.Save") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- ---------------------------------------------------------------- filenames + +-- Gold's save sits beside Red/Blue/Yellow rather than sharing a file: its +-- suffix comes from GameVersion, which is what keeps a Gold playthrough from +-- overwriting a Red one. +local main, backup, tmp = Save.filenames("gold") +check("main file", main, "save_gold.lua") +check("backup file", backup, "save_gold.lua.bak") +check("staged file", tmp, "save_gold.lua.tmp") +check("gold suffix", GameVersion.saveSuffix("gold"), "_gold") +-- Red keeps its historical un-suffixed name, so the two can never collide. +check("red is unsuffixed", GameVersion.saveSuffix("red"), "") + +-- ----------------------------------------------------------------- new game + +local fresh = Save.newGame({ playerName = "GOLD", rivalName = "SILVER" }) +check("version", fresh.version, "gold") +check("generation", fresh.generation, 2) +check("format", fresh.format, Save.FORMAT) +check("player name", fresh.player.name, "GOLD") +check("rival name", fresh.rival.name, "SILVER") +-- With nothing passed, NewGame's own seed stands, and that seed is "???": +-- _ResetWRAM calls InitializeNPCNames, whose .Rival row is `db "???@"` +-- (engine/menus/intro_menu.asm:131, :193-214). SILVER is NameRival's InitName +-- fallback (engine/events/specials.asm:80-91), which the officer scene applies +-- only after the keyboard closes, so every {RIVAL} line and every RIVAL1 battle +-- before that -- Cherrygrove included -- reads "???". +check("an unnamed rival is ???", Save.newGame().rival.name, "???") +local unnamed = Save.normalize({ player = {} }) +check("and so is one on a save with no rival field", unnamed.rival.name, "???") +-- A new game starts with 3000 and an empty party at SPAWN_HOME. +check("starting money", fresh.player.money, 3000) +check("empty party", #fresh.party, 0) +check("spawn", fresh.spawn, "SPAWN_HOME") +check("no position yet", fresh.position, nil) +check("and starts on foot", fresh.playerState, Save.PLAYER_NORMAL) +check("clock at zero", fresh.playTime.hours, 0) +check("dex empty", next(fresh.pokedex.caught), nil) +-- A trainer ID is rolled so two saves are distinguishable. +check("trainer id in range", + fresh.player.id >= 0 and fresh.player.id <= 65535, true) +-- _ResetWRAM rolls it out of hRandomSub/hRandomAdd (intro_menu.asm:41-49). +local rolled = Save.newGame({}).player.id +check("the trainer ID is a whole number", math.floor(rolled), rolled) +check("in wPlayerID's range", rolled >= 0 and rolled <= 65535, true) +check("and an explicit id wins", Save.newGame({ trainerId = 7 }).player.id, 7) + +-- Options are Gold's own set, stored by name so a reordered enum cannot +-- silently repoint one. +local options = Save.defaultOptions() +check("default text speed", options.textSpeed, "MID") +check("default battle style", options.battleStyle, "SHIFT") +check("default sound", options.sound, "MONO") +check("battle scene on", options.battleScene, true) +check("menu account on", options.menuAccount, true) +check("frame 1", options.frame, 1) +-- defaultOptions hands out a copy: editing one save's options must not change +-- the next new game's. +options.textSpeed = "FAST" +check("defaults are copied", Save.defaultOptions().textSpeed, "MID") + +-- -------------------------------------------------------------- normalize + +-- A sparse or hand-edited save must come back indexable rather than crashing +-- the first screen that reads it. +local sparse = Save.normalize({ player = {} }) +check("normalized version", sparse.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") +check("normalize rejects a non-table", Save.normalize("nope"), nil) + +-- 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) +check("coins capped", rich.player.coins, Save.MAX_COINS) +local broke = Save.normalize({ player = { money = -100 } }) +check("money floored", broke.player.money, 0) + +-- An over-long party is trimmed: a corrupt file must not feed a seventh mon +-- into battle. +local overfull = Save.normalize({ party = { + {}, {}, {}, {}, {}, {}, {}, {}, +} }) +check("party trimmed to six", #overfull.party, Save.PARTY_SIZE) + +-- move_mon.asm:143-149: a mon the player owns carries wPlayerID, so a save +-- written before the stamp existed is backfilled on load. +local legacy = Save.normalize({ player = { name = "GOLD", id = 4242 }, + party = { { species = "CYNDAQUIL", level = 5 } } }) +check("an id-less save gets one", type(legacy.player.id), "number") +check("and an unstamped mon takes the player's ID", legacy.party[1].otId, 4242) +check("and the player's name", legacy.party[1].ot, "GOLD") + +-- --------------------------------------------------------------- migration + +-- Save.FORMAT must never move without a step to carry old files across, so +-- every format below the current one has an entry and the current one has +-- none. Format 2 is the Hall of Fame roster, format 3 the script VM's WRAM +-- store, format 4 the MAIL structs (sPartyMail + sMailboxes), format 5 the +-- world state (wEventFlags and the w<Map>SceneID block) becoming load bearing, +-- format 6 wPlayerState, format 7 Mom's shopping pair (wWhichMomItem and +-- wMomItemTriggerBalance). +check("format is 7", Save.FORMAT, 7) +for from = 1, Save.FORMAT - 1 do + check("a migration exists for format " .. from, + type(Save.MIGRATIONS[from]), "function") +end +check("and none for the current format", Save.MIGRATIONS[Save.FORMAT], nil) +local old = Save.migrate({ format = 1 }) +check("migrate lifts a format-1 save to the current one", old.format, + Save.FORMAT) +check("and gives it an empty Hall of Fame", old.hallOfFame.count, 0) +check("with no roster", #old.hallOfFame.teams, 0) +check("and no pending post-game spawn", old.spawnAfterChampion, nil) +check("and an empty script memory store", next(old.scriptMem), nil) +local current = Save.migrate({ format = Save.FORMAT, hallOfFame = { count = 4 } }) +check("migrate is a no-op at the current format", current.hallOfFame.count, 4) + +-- A format-2 save (the Hall of Fame landed, scriptMem had not) loads clean: +-- the store arrives empty rather than nil, so the first readmem sees a 0 the +-- same way the cart's zero-filled WRAM would. +local preMem = Save.normalize(Save.migrate({ format = 2, party = {} })) +check("an old save gains the store", type(preMem.scriptMem), "table") +check("with nothing in it", next(preMem.scriptMem), nil) +check("and is at the current format", preMem.format, Save.FORMAT) + +-- A format-4 save with no world state at all -- the shape a file written +-- before the snapshot reached the world has -- comes across clean rather than +-- nil-indexing World:loadPlayerData, and its empty bitfield is what makes that +-- routine fall back to InitializeEventsScript's seed. +local preWorld = Save.normalize(Save.migrate({ format = 4, party = {} })) +check("an old save gains the event bitfield", type(preWorld.events), "table") +check("with no flags set", next(preWorld.events), nil) +check("and the scene table", type(preWorld.mapScenes), "table") +check("with no map advanced", next(preWorld.mapScenes), nil) +check("and is at the current format", preWorld.format, Save.FORMAT) +check("and it loads without a quarantine", + Save.emptyReport(Save.validate(preWorld)), true) +-- A format-4 file that DID record world state keeps it: the fields were always +-- written, format 5 is only where they started being read back. +local keptWorld = Save.normalize(Save.migrate({ + format = 4, party = {}, events = { [6] = 0x40 }, + mapScenes = { PLAYERS_HOUSE_1F = 1 }, +})) +check("an old save keeps the flags it recorded", keptWorld.events[6], 0x40) +check("and the scenes", keptWorld.mapScenes.PLAYERS_HOUSE_1F, 1) + +-- A format-5 save never wrote wPlayerState at all, so it comes across as +-- PLAYER_NORMAL -- which is what it has been loading as anyway, since a world +-- that read no state started the player on foot. +local preState = Save.normalize(Save.migrate({ format = 5, party = {} })) +check("an old save gains the player state", preState.playerState, + Save.PLAYER_NORMAL) +check("and is at the current format", preState.format, Save.FORMAT) +check("and it loads without a quarantine", + Save.emptyReport(Save.validate(preState)), true) + +-- A format-6 save predates MomTriesToBuySomething, so neither of Mom's two +-- shopping bytes can have been written: both come across as NewGame's seed, +-- the ladder on its first rung and the threshold at MOM_MONEY. A file that +-- had already banked money therefore starts buying from the bottom of the +-- list, which is what a cartridge with the same savings would do. +local MomShopping = require("src.core.gen2.MomShopping") +local preMom = Save.normalize(Save.migrate({ + format = 6, party = {}, mom = { savedMoney = 12345 }, +})) +check("an old save gains the ladder index", preMom.mom.whichItem, 0) +check("and the consolation threshold", preMom.mom.triggerBalance, + MomShopping.MOM_MONEY) +check("keeping the savings it had", preMom.mom.savedMoney, 12345) +check("and is at the current format", preMom.format, Save.FORMAT) +check("and it loads without a quarantine", + Save.emptyReport(Save.validate(preMom)), true) +local newMom = Save.newGame({}) +check("a new game seeds the same pair", newMom.mom.whichItem, 0) +check("with the threshold at MOM_MONEY", newMom.mom.triggerBalance, + MomShopping.MOM_MONEY) + +-- --------------------------------------------------- script memory validate + +-- Script_readmem addresses are 16-bit and Script_writemem moves one byte, so +-- an entry outside those ranges cannot have come from a script and is +-- quarantined instead of being handed to the VM. +local dirty = Save.normalize({ scriptMem = { + [0xd6a8] = 3, -- wUndergroundSwitchPositions, plausible + [0x1d6a8] = 1, -- past $ffff + [0xd7f1] = 300, -- not a byte + [0xd7f2] = "switch", -- not a number at all +} }) +local report = Save.validate(dirty) +check("the plausible byte survives", dirty.scriptMem[0xd6a8], 3) +check("the out-of-range address is gone", dirty.scriptMem[0x1d6a8], nil) +check("the over-large value is gone", dirty.scriptMem[0xd7f1], nil) +check("the non-numeric value is gone", dirty.scriptMem[0xd7f2], nil) +check("three entries quarantined", #report.lostScriptMem, 3) +check("and the report is not empty", Save.emptyReport(report), false) +-- A store that is not even a table is replaced wholesale rather than left to +-- nil-index the VM later. +local wrecked = Save.validate(Save.normalize({ scriptMem = 7 })) +check("a non-table store is quarantined", #wrecked.lostScriptMem, 1) +-- A save this port wrote passes through without a word. +check("a clean save reports nothing", + Save.emptyReport(Save.validate(Save.newGame({}))), true) + +-- ---------------------------------------------------- world state validate + +-- wEventFlags is 256 bytes of bitfield, so a byte index past the last one or a +-- value that is not a byte could not have come from the cart's array, and +-- neither may reach Events:restore. The scene ids get the same treatment: one +-- byte per map, keyed by the map id World:mapSceneOf looks up. +local dirtyWorld = Save.normalize({ + events = { + [6] = 0x40, -- EVENT_INITIALIZED_EVENTS, byte 6 bit 6 + [255] = 1, -- the last byte of the array, still legal + [256] = 1, -- one past it + [-1] = 1, -- and before the start + [10] = 300, -- not a byte + [11] = "set", -- not a number at all + }, + mapScenes = { + PLAYERS_HOUSE_1F = 1, + ELMS_LAB = 2.5, -- a scene id is a whole byte + [7] = 1, -- not a map id + ROUTE_29 = "two", -- not a number + }, +}) +local worldReport = Save.validate(dirtyWorld) +check("the seed byte survives", dirtyWorld.events[6], 0x40) +check("and so does the last byte of the array", dirtyWorld.events[255], 1) +check("a byte past the array is gone", dirtyWorld.events[256], nil) +check("a negative index is gone", dirtyWorld.events[-1], nil) +check("an over-large value is gone", dirtyWorld.events[10], nil) +check("a non-numeric value is gone", dirtyWorld.events[11], nil) +check("four event bytes quarantined", #worldReport.lostEvents, 4) +check("the plausible scene survives", dirtyWorld.mapScenes.PLAYERS_HOUSE_1F, 1) +check("a fractional scene is gone", dirtyWorld.mapScenes.ELMS_LAB, nil) +check("a non-string map key is gone", dirtyWorld.mapScenes[7], nil) +check("a non-numeric scene is gone", dirtyWorld.mapScenes.ROUTE_29, nil) +check("three scenes quarantined", #worldReport.lostMapScenes, 3) +check("and the report is not empty", Save.emptyReport(worldReport), false) +-- Keys survive the serializer as strings in some files; the bitfield is keyed +-- by NUMBER, so they come back as numbers here rather than sitting beside the +-- numeric ones where Events:restore would have to guess. +local stringKeyed = Save.normalize({ events = { ["6"] = 0x40 } }) +Save.validate(stringKeyed) +check("a string byte index is folded back to a number", + stringKeyed.events[6], 0x40) +check("and nothing is left under the string", stringKeyed.events["6"], nil) +-- Neither field being a table at all is replaced wholesale, the same as the +-- script store above. +local wreckedWorld = Save.validate(Save.normalize({ events = 9, + mapScenes = "gone" })) +check("a non-table bitfield is quarantined", #wreckedWorld.lostEvents, 1) +check("a non-table scene list too", #wreckedWorld.lostMapScenes, 1) + +-- wPlayerState is one of four names, and the round trip only works because +-- both ends agree on them: Save.PLAYER_STATES is the set World:loadPlayerData +-- tests a restored value against too. +check("PLAYER_NORMAL is a state", Save.PLAYER_STATES[Save.PLAYER_NORMAL], true) +check("so is the bike", Save.PLAYER_STATES.bike, true) +check("and both surf states", Save.PLAYER_STATES.surf + and Save.PLAYER_STATES.surf_pika, true) +check("PLAYER_SKATE is not one", Save.PLAYER_STATES.skate, nil) +-- A save made on the bike keeps it; a state no cartridge could have written is +-- dropped to PLAYER_NORMAL rather than handed to a sprite lookup with no row. +local riding = Save.normalize({ playerState = "bike" }) +check("a bike save keeps its state", + Save.emptyReport(Save.validate(riding)) and riding.playerState, "bike") +local afloat = Save.normalize({ playerState = "surf_pika" }) +Save.validate(afloat) +check("and a surfing one keeps its own sprite", afloat.playerState, + "surf_pika") +local bogus = Save.normalize({ playerState = "skateboard" }) +local stateReport = Save.validate(bogus) +check("an impossible state is dropped", bogus.playerState, Save.PLAYER_NORMAL) +check("and quarantined", #stateReport.lostPlayerState, 1) +check("so the report is not empty", Save.emptyReport(stateReport), false) +-- A raw byte -- what a hand-edited file or another tool might leave -- is not +-- a name either, and 1 would otherwise silently mean nothing at all. +local rawByte = Save.validate(Save.normalize({ playerState = 1 })) +check("a raw wPlayerState byte is quarantined too", + #rawByte.lostPlayerState, 1) + +-- --------------------------------------------------------------- summary + +local summary = Save.summary(fresh) +check("summary name", summary.name, "GOLD") +check("summary badges", summary.badges, 0) +check("summary caught", summary.caught, 0) +check("summary hours", summary.hours, 0) + +fresh.player.badges = { true, true, false } +fresh.pokedex.caught = { CYNDAQUIL = true, PIDGEY = true, RATTATA = false } +fresh.playTime = { hours = 12, minutes = 34, seconds = 0, frames = 0 } +summary = Save.summary(fresh) +check("counts only earned badges", summary.badges, 2) +check("counts only caught mons", summary.caught, 2) +check("summary time", ("%d:%02d"):format(summary.hours, summary.minutes), + "12:34") +check("summary of nothing", Save.summary(nil), nil) + +-- ------------------------------------------------------------- play clock + +-- The clock ticks once per logic step, so 60 calls is one second. +local timed = Save.newGame() +for _ = 1, 60 do Save.tickPlayTime(timed) end +check("one second", timed.playTime.seconds, 1) +check("frames rolled over", timed.playTime.frames, 0) +for _ = 1, 60 * 59 do Save.tickPlayTime(timed) end +check("one minute", timed.playTime.minutes, 1) +check("seconds reset", timed.playTime.seconds, 0) +-- The cart caps at 999 hours rather than overflowing the trainer card's field. +timed.playTime = { hours = 999, minutes = 59, seconds = 59, frames = 59 } +Save.tickPlayTime(timed) +check("hours capped", timed.playTime.hours, 999) + +-- -------------------------------------------------------- write and read + +files = {} +check("no save yet", Save.exists("gold"), false) + +local written = Save.newGame({ playerName = "ETHAN" }) +written.player.money = 4321 +written.party = { { species = "CYNDAQUIL", level = 7, hp = 20, maxHp = 22 } } +written.position = { map = "ROUTE_29", x = 5, y = 9, facing = "left" } +written.pokedex.caught.CYNDAQUIL = true +-- wUndergroundSwitchPositions after the Goldenrod switch room has been +-- worked: the VM's readmem / addval / writemem triple lands here, and this is +-- the field that used to evaporate on reload. +written.scriptMem[0xd6a8] = 2 +-- The world state a reload has to come back with: wEventFlags as the byte -> +-- value bitfield Events:serialize writes (byte 6 bit 6 is +-- EVENT_INITIALIZED_EVENTS 54, byte 216 bit 7 is EVENT_PLAYERS_HOUSE_MOM_1 +-- 1735) and the scene MeetMomScript leaves PLAYERS_HOUSE_1F on. Both used to +-- evaporate on reload, which is what had MOM play her first-time scene again. +written.events[6] = 0x40 +written.events[216] = 0x80 +written.mapScenes.PLAYERS_HOUSE_1F = 1 +-- And the third member of that block: this save was made on the BICYCLE, on +-- the route the position above puts it on. +written.playerState = "bike" +check("save wrote", Save.save(written), true) +check("save exists now", Save.exists("gold"), true) +check("no stray staged file", files["save_gold.lua.tmp"], nil) +check("stamped savedAt", type(written.savedAt), "number") + +local loaded, recovered, err = Save.load("gold") +check("load succeeded", loaded ~= nil, true) +check("no recovery needed", recovered, nil) +check("no error", err, nil) +check("round-tripped name", loaded.player.name, "ETHAN") +check("round-tripped money", loaded.player.money, 4321) +check("round-tripped party", loaded.party[1].species, "CYNDAQUIL") +check("round-tripped position", loaded.position.map, "ROUTE_29") +check("round-tripped facing", loaded.position.facing, "left") +check("round-tripped dex", loaded.pokedex.caught.CYNDAQUIL, true) +check("round-tripped script memory", loaded.scriptMem[0xd6a8], 2) +-- The two halves of LoadPlayerData, through a real encode/decode: the flag +-- byte comes back keyed by NUMBER (a byte index that decoded as the string "6" +-- would read as an unset flag), and the map is still on the scene it was +-- advanced to. +check("round-tripped seed flag", loaded.events[6], 0x40) +check("round-tripped event byte", loaded.events[216], 0x80) +check("no flag byte under a string key", loaded.events["216"], nil) +check("round-tripped map scene", loaded.mapScenes.PLAYERS_HOUSE_1F, 1) +-- The third: a save made on the bike loads on the bike. +check("round-tripped player state", loaded.playerState, "bike") +-- Sparse, not a WRAM image: only the byte a script actually wrote is in the +-- file, and the load report is empty for a save this port wrote itself. +local memCount = 0 +for _ in pairs(loaded.scriptMem) do memCount = memCount + 1 end +check("only the written byte is stored", memCount, 1) +local _, _, _, loadReport = Save.load("gold") +check("load reports a clean save", Save.emptyReport(loadReport), true) + +-- A second write backs the first up, so the previous file is always recoverable. +written.player.money = 5555 +Save.save(written) +check("backup written", files["save_gold.lua.bak"] ~= nil, true) +check("new value loads", Save.load("gold").player.money, 5555) + +-- A corrupt main file falls back to the backup rather than losing the game. +files["save_gold.lua"] = "this is not a lua table" +local recoveredSave, how = Save.load("gold") +check("recovered from the backup", recoveredSave ~= nil, true) +check("recovery reported", how, "bak") +check("backup held the previous money", recoveredSave.player.money, 4321) + +-- A staged .tmp is preferred over the backup: it is the newer of the two. +files["save_gold.lua"] = nil +files["save_gold.lua.tmp"] = files["save_gold.lua.bak"] +local staged, stagedHow = Save.load("gold") +check("recovered from the staged copy", staged ~= nil, true) +check("staged recovery reported", stagedHow, "tmp") + +-- Nothing on disk at all is a clean miss, not an error. +files = {} +local missing, _, missingErr = Save.load("gold") +check("missing save", missing, nil) +check("missing reports why", missingErr, "missing") +check("exists is false again", Save.exists("gold"), false) + +-- ------- per-mod save state rides the slot +-- +-- Game2:adoptSave points the loader's mod.save backing at save.modData, the +-- way Gen 1 does (src/core/Game.lua:990). Without it every mod.save:set +-- survives only until the process exits, which is invisible until a player +-- notices their settings reset every session. +do + local Game2 = require("src.core.Game2") + local loader = { modSave = { early = { seeded = true } } } + local host = setmetatable({ mods = loader }, { __index = Game2 }) + + local boot = Save.newGame({ playerName = "GOLD" }) + host:adoptSave(boot, true) + check("boot seeds what entry chunks wrote", boot.modData.early.seeded, true) + check("the backing is the save's own table", loader.modSave, boot.modData) + + loader.modSave.tester = { followers = 3 } + Save.save(boot) + local reloaded = Save.load("gold") + check("modData round-trips through the Gold save", + reloaded.modData and reloaded.modData.tester + and reloaded.modData.tester.followers, 3) + + -- NEW GAME takes no carry-over: state from an abandoned session must not + -- leak into a fresh slot + local fresh = Save.newGame({ playerName = "GOLD" }) + host:adoptSave(fresh) + check("a fresh slot starts empty", next(fresh.modData), nil) + files = {} +end + +print(("gen2 save: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_screen_layout_test.lua b/tests/gen2_screen_layout_test.lua new file mode 100644 index 00000000..fea37807 --- /dev/null +++ b/tests/gen2_screen_layout_test.lua @@ -0,0 +1,220 @@ +-- Gen 2 screen geometry: the blit scale a widescreen screen paints its +-- 160x144 panel at, and the naming keyboard's cursor bracket. +-- +-- Both are pure layout, so they are asserted as coordinates rather than as +-- pixels. tests/drivers/gold_menu_shots.lua is what shows the result. + +package.path = "./?.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 0.5 end } +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, +} +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") + +local checks, failures = 0, 0 +local function check(label, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s want %s"):format(label, tostring(got), + tostring(want))) + end +end + +-- ------------------------------------------------------- panel blit scale +-- +-- A GB pixel has to cover a whole number of window pixels: a fractional blit +-- gives one row of an 8x8 tile two device pixels and its neighbour one, which +-- is what tore box borders and ate rows out of glyphs. +check("1024x768 fits five whole pixels", Chrome.fitScale(1024, 768), 5) +check("exactly 160x144 is 1:1", Chrome.fitScale(160, 144), 1) +check("640x576 is a clean four", Chrome.fitScale(640, 576), 4) +check("a window smaller than the screen still blits at 1", + Chrome.fitScale(100, 90), 1) +check("height constrains a wide window", Chrome.fitScale(1920, 480), 3) +check("width constrains a tall window", Chrome.fitScale(480, 1920), 3) + +local ox, oy = Chrome.fitOrigin(1024, 768) +check("panel centred horizontally", ox, math.floor((1024 - 160 * 5) / 2)) +check("panel centred vertically", oy, math.floor((768 - 144 * 5) / 2)) +check("origin is whole pixels", ox, math.floor(ox)) + +-- The scale and the origin have to agree, or a nested TextBox drawn by Game2 +-- would land on a different grid than the panel under it. +for _, size in ipairs({ { 1024, 768 }, { 1280, 720 }, { 800, 600 }, + { 1366, 768 } }) do + local s = Chrome.fitScale(size[1], size[2]) + local x, y = Chrome.fitOrigin(size[1], size[2], s) + check(("%dx%d panel fits inside the window"):format(size[1], size[2]), + (x >= 0 and y >= 0 and 160 * s + 2 * x <= size[1] + 1 + and 144 * s + 2 * y <= size[2] + 1), true) +end + +-- ------------------------------------------------------- naming cursor +-- +-- OAMData_TextEntryCursor is a box stamped AROUND the character cell out of +-- one corner tile; .LetterEntries steps its XOFFSET by $10 a column from an +-- XCOORD of 24 (OAM), i.e. screen tile 2 for column 0. The old cursor sat a +-- tile to the left, between two letters. +local naming = NamingScreen.new({}, { type = "player" }) + +local KEYBOARD_TOP = 8 +for col = 0, 8 do + naming.col, naming.row = col, 0 + local tx, ty, wide = naming:cursorTile() + check(("column %d brackets its own letter tile"):format(col), tx, 2 + col * 2) + check(("column %d stays on the top keyboard row"):format(col), ty, + KEYBOARD_TOP) + check(("column %d bracket is one tile wide"):format(col), wide, 1) +end +for row = 0, 3 do + naming.col, naming.row = 4, row + local _, ty = naming:cursorTile() + check(("row %d steps two tiles"):format(row), ty, KEYBOARD_TOP + row * 2) +end + +-- The bottom row's three fat targets: .CaseDelEnd adds $00 / $30 / $60, so the +-- bracket lands on tiles 2 / 8 / 14 and is five tiles wide. +local BOTTOM_TX = { [0] = 2, [3] = 8, [6] = 14 } +for col, want in pairs(BOTTOM_TX) do + naming.row = naming:bottomRow() + naming.col = col + local tx, ty, wide = naming:cursorTile() + check(("bottom target at column %d"):format(col), tx, want) + check(("bottom target row"):format(col), ty, KEYBOARD_TOP + 4 * 2) + check(("bottom bracket is five tiles"):format(col), wide, 5) +end + +-- Every letter row's bracket has to line up with the tile the letter is +-- printed on (drawPanel prints at 2 + col * 2, keyboardTop + row * 2). +naming.col, naming.row = 3, 2 +local tx, ty = naming:cursorTile() +check("bracket x matches the printed letter", tx, 2 + 3 * 2) +check("bracket y matches the printed letter", ty, KEYBOARD_TOP + 2 * 2) + +-- A box screen shifts the whole keyboard up two rows and gains a sixth row; +-- the bracket follows keyboardTop rather than a baked constant. +local box = NamingScreen.new({}, { type = "box" }) +box.col, box.row = 0, 0 +local btx, bty = box:cursorTile() +check("box keyboard bracket x", btx, 2) +check("box keyboard bracket y", bty, 6) +box.row = box:bottomRow() +local _, bby, bbw = box:cursorTile() +check("box bottom row", bby, 6 + 5 * 2) +check("box bottom bracket is five tiles", bbw, 5) + +-- ------------------------------------------------------- OPTION rows +-- +-- StringOptions is "TEXT SPEED<LF> :<LF>...", so on the cart the value +-- sits on its OWN row under the label with the colon at column 10 and the +-- value at 11 (Options_TextSpeed prints at hlcoord 11, 3). Pinned because it +-- reads like a layout bug and is not one. +local printed, cursorAt = {}, nil +local realPrint, realCursor = Chrome.print, Chrome.cursor +local realClear, realBox = Chrome.clear, Chrome.textbox +Chrome.print = function(text, tx, ty) + printed[#printed + 1] = { text = text, x = tx, y = ty } + return 0 +end +Chrome.cursor = function(tx, ty) cursorAt = { x = tx, y = ty } end +Chrome.clear = function() end +Chrome.textbox = function() end + +local options = OptionsMenu.new({}, { options = {} }) +options:drawPanel() + +Chrome.print, Chrome.cursor = realPrint, realCursor +Chrome.clear, Chrome.textbox = realClear, realBox + +local function findPrint(text) + for _, row in ipairs(printed) do + if row.text == text then return row end + end +end + +local label = findPrint("TEXT SPEED") +check("TEXT SPEED sits at the PlaceString origin x", label and label.x, 2) +check("TEXT SPEED sits at the PlaceString origin y", label and label.y, 2) +local colon +for _, row in ipairs(printed) do + if row.text == ":" and row.y == 3 then colon = row break end +end +check("its colon is on the next row at column 10", colon and colon.x, 10) +local value +for _, row in ipairs(printed) do + if row.y == 3 and row.x == 11 then value = row break end +end +check("the value prints at hlcoord 11, 3", value ~= nil, true) +check("and it is the TEXT SPEED setting", + value and (value.text == "FAST" or value.text == "MID " + or value.text == "SLOW"), true) +check("the cursor is the column-1 arrow on the label row", + cursorAt and cursorAt.x, 1) +check("the cursor starts on the first row", cursorAt and cursorAt.y, 2) +check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true) + +-- ------------------------------------------------------- one blit scale +-- +-- Every Gold screen paints its 160x144 panel through the same helper. A +-- screen that re-derives the scale gets math.min(winW / 160, winH / 144), +-- which is fractional on almost every window, and then its own tile rows tear +-- while the screen beside it is clean. Source-level because a draw needs a +-- canvas and there is no canvas in this harness. +local listing = io.popen and io.popen("ls src/ui/gen2/*.lua 2>/dev/null") +local screens = {} +if listing then + for path in listing:lines() do screens[#screens + 1] = path end + listing:close() +end +check("the gen2 screen list was readable", #screens > 0, true) +for _, path in ipairs(screens) do + local f = io.open(path, "r") + local src = f and f:read("*a") or "" + if f then f:close() end + check(path .. " does not re-derive the blit scale", + src:find("math%.min%(win[WH] */ *[%dSCREN_WH]+", 1, false) == nil, true) + if src:find("function [%w_]+:drawWidescreen") then + check(path .. " blits through Chrome.fitScale", + src:find("fitScale", 1, true) ~= nil, true) + end + -- An opaque screen is one nothing under it is drawn for: its cart routine + -- ran ClearBGPalettes / ClearTilemap, so the map is gone and the screen owns + -- the window edge to edge. It therefore has to declare its own surround, or + -- Game2:drawScene resolves the widescreen layer to something else (or to + -- nothing) and the letterbox shows whatever was behind it. + if src:find("isOpaque = true", 1, true) then + check(path .. " is opaque, so it declares drawsWidescreen", + src:find("function [%w_]+:drawsWidescreen") ~= nil, true) + end +end + +print(("gen2 screen layout: %d checks, %d failures"):format(checks, failures)) +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_script_order_test.lua b/tests/gen2_script_order_test.lua new file mode 100644 index 00000000..e7d1c069 --- /dev/null +++ b/tests/gen2_script_order_test.lua @@ -0,0 +1,173 @@ +-- Gen 2 script ordering: sdefer and reanchormap. +-- +-- Both are pure ordering, which is why nothing caught them: the flags and the +-- geometry a scene script writes land identically either way. What changes is +-- WHEN the deferred body runs -- inside the scene script, under the map's +-- fade-in, or after it as the pass's own player event. +-- luajit tests/gen2_script_order_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 script order") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") + +-- ------------------------------------------------------------------- sdefer +-- +-- RunSceneScript (engine/overworld/events.asm:388) clears RUN_DEFERRED_SCRIPT, +-- runs the scene body through ScriptEvents to its `end`, and only THEN +-- CallScript's whatever `sdefer` recorded. The five League walk-ins, the Hall +-- of Fame induction, the Cerulean grunt and the Mt Moon rival cutscene are all +-- written `sdefer <body> / end`. +local function order(scripts) + local seen = {} + local vm = Vm.new(scripts, {}, Events.new(), { + showText = function(body, onDone) + seen[#seen + 1] = body + onDone() + end, + }) + return vm, seen +end + +do + local vm, seen = order({ + generation = 2, + ["scene"] = { + { op = "sdefer", script = "deferred" }, + { op = "rawtext", text = "scene tail" }, + { op = "end" }, + }, + ["deferred"] = { + { op = "rawtext", text = "deferred body" }, + { op = "end" }, + }, + }) + vm:start("scene") + eq(#seen, 2, "both bodies ran") + eq(seen[1], "scene tail", "the scene body runs first, all of it") + eq(seen[2], "deferred body", "and the deferred script only after its `end`") + check(not vm:running(), "and the pair leave nothing running") +end + +-- The shape the cache actually carries: `sdefer <script> / end` on its own. +do + local vm, seen = order({ + generation = 2, + ["WILLS_ROOM:scene0"] = { + { op = "sdefer", script = "WILLS_ROOM:walkin" }, + { op = "end" }, + }, + ["WILLS_ROOM:walkin"] = { + { op = "rawtext", text = "door locks" }, + { op = "end" }, + }, + }) + vm:start("WILLS_ROOM:scene0") + eq(seen[1], "door locks", "a bare sdefer still reaches its body") + eq(#seen, 1, "exactly once") +end + +-- scall is NOT sdefer: it runs where it stands and comes back. +do + local vm, seen = order({ + generation = 2, + ["caller"] = { + { op = "scall", script = "sub" }, + { op = "rawtext", text = "after the call" }, + { op = "end" }, + }, + ["sub"] = { + { op = "rawtext", text = "inside the call" }, + { op = "end" }, + }, + }) + vm:start("caller") + eq(seen[1], "inside the call", "scall runs inline") + eq(seen[2], "after the call", "and returns to the command after it") +end + +-- A whiteout unwinds the script rather than ending it, so there is nothing +-- left to defer to. +do + local vm, seen = order({ + generation = 2, + ["scene"] = { + { op = "sdefer", script = "deferred" }, + { op = "end" }, + }, + ["deferred"] = { + { op = "rawtext", text = "should not run" }, + { op = "end" }, + }, + }) + vm.aborted = false + local realRun = vm.runDeferred + vm.runDeferred = function(self) + self.aborted = true + return realRun(self) + end + vm:start("scene") + eq(#seen, 0, "an aborted run drops the deferred script") +end + +-- Nesting: a deferred script that defers again. +do + local vm, seen = order({ + generation = 2, + ["scene"] = { + { op = "sdefer", script = "first" }, + { op = "end" }, + }, + ["first"] = { + { op = "sdefer", script = "second" }, + { op = "rawtext", text = "first body" }, + { op = "end" }, + }, + ["second"] = { + { op = "rawtext", text = "second body" }, + { op = "end" }, + }, + }) + vm:start("scene") + eq(seen[1], "first body", "the first deferred body runs whole") + eq(seen[2], "second body", "then the one it deferred") +end + +-- -------------------------------------------------------------- reanchormap +-- +-- Script_reanchormap calls ReanchorMap (home/window.asm), which is +-- ClearWindowData plus a BG re-blit. ElmsLab reanchors before every `pokepic` +-- and WillsRoom between the walk-in and the earthquake, so a window still +-- standing when it runs is one the cart has already taken down. +do + local hidden = 0 + local vm = Vm.new({ + generation = 2, + ["s"] = { + { op = "pokepic", species = 155 }, + { op = "reanchormap", args = { 0x85 } }, + { op = "end" }, + }, + }, {}, Events.new(), { + showPic = function() end, + hidePic = function() hidden = hidden + 1 end, + }) + vm:start("s") + eq(hidden, 1, "reanchormap takes the open pokepic window down") +end + +do + -- ...and it is still safe with no window hook at all. + local vm = Vm.new({ + generation = 2, + ["s"] = { { op = "reanchormap", args = { 0x85 } }, { op = "end" } }, + }, {}, Events.new(), {}) + vm:start("s") + check(not vm:running(), "and a run with no hooks still finishes") +end + +S.finish() diff --git a/tests/gen2_sound_alias_test.lua b/tests/gen2_sound_alias_test.lua new file mode 100644 index 00000000..49c5cb7f --- /dev/null +++ b/tests/gen2_sound_alias_test.lua @@ -0,0 +1,161 @@ +-- Gen 2 sfx name resolution and the fanfare duck rule, ROM-free. +-- luajit tests/gen2_sound_alias_test.lua +-- Also dofile'd by tests/run_tests.lua. Fixtures only: no Gold cache, no +-- audio device. Covers the two contracts a Gold session leans on: +-- * a shared UI module naming a pokered sfx ("Press_AB") reaches the Gen 2 +-- label through Sound.resolve, and the play/stop/isPlaying trio agree on +-- the key the source ended up cached under; +-- * a header read that could not run yet is retried, never remembered as +-- "this sfx claims no channels" (which would leave a four-channel jingle +-- playing over the map music for the rest of the session). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 sound alias") +local check, eq = S.check, S.eq + +if not _G.love then _G.love = require("tests.love_stub") end + +-- Minimal audio device: enough for the file-def branch of Sound.play. +local playing = {} +local Source = {} +Source.__index = Source +function Source:play() playing[self] = true end +function Source:stop() playing[self] = nil end +function Source:isPlaying() return playing[self] == true end +function Source:setVolume(v) self.volume = v end +function Source:setPitch(p) self.pitch = p end + +local built = 0 +local savedAudio = love.audio +love.audio = { + newSource = function(file) + built = built + 1 + return setmetatable({ file = file }, Source) + end, +} + +local Sound = require("src.core.Sound") + +-- === resolution === + +-- A Gen 1 cache answers to the name the shared UI already uses, so the alias +-- table must stay out of the way there. +local gen1 = { audio = { fanfares = {}, sfx = { Press_AB = "sfx/press_ab.wav" } } } +eq(Sound.resolve(gen1, "Press_AB"), "Press_AB", + "a name the cache already has resolves to itself") + +-- A Gold cache is keyed by pokegold's labels, so the same call has to hop. +local gold = { + audio = { fanfares = {}, sfx = { Sfx_ReadText2 = "sfx/readtext2.wav" } }, +} +eq(Sound.resolve(gold, "Press_AB"), "Sfx_ReadText2", + "the A-press beep hops to the Gen 2 label") +eq(Sound.resolve(gold, "Sfx_ReadText2"), "Sfx_ReadText2", + "a Gen 2 name is passed through untouched") +eq(Sound.resolve(gold, "Nothing_Named_This"), "Nothing_Named_This", + "an unknown name is returned as-is") + +-- Only names a Gold-reachable shared module actually plays belong in the +-- table; a row nothing calls is a wrong mapping waiting to be believed. +for name, target in pairs(Sound.GEN2_ALIASES) do + eq(type(target), "string", ("alias %s names an sfx"):format(name)) + eq(target:sub(1, 4), "Sfx_", + ("alias %s targets a pokegold label"):format(name)) +end +eq(Sound.GEN2_ALIASES.Press_AB, "Sfx_ReadText2", + "Press_AB is the alias the shared TextBox / ChoiceBox need") + +-- === play / stop / isPlaying agree on the resolved key === + +local src = Sound.play(gold, "Press_AB") +check(src ~= nil, "the aliased sfx plays") +eq(built, 1, "one source built") +check(Sound.isPlaying("Press_AB"), + "isPlaying finds the source under the raw name the caller used") +Sound.stop("Press_AB") +check(not Sound.isPlaying("Press_AB"), + "stop reaches the same source (the elevator pattern: play X then stop X)") + +check(not Sound.isPlaying("Never_Played"), "an unknown name reads as silent") +Sound.stop("Never_Played") -- must not raise + +-- The Gen 1 side of the same call keeps its own key. +local g1src = Sound.play(gen1, "Press_AB") +check(g1src ~= nil and g1src ~= src, "a Gen 1 cache builds its own source") +check(Sound.isPlaying("Press_AB"), "and is found under its own name") +Sound.stop("Press_AB") + +-- === the Gen 2 sfx priority gate (home/audio.asm PlaySFX) === + +-- Ids run highest priority first (constants/sfx_constants.asm), so a LOWER id +-- still sounding makes PlaySFX drop the request (`cp e / jr c, .done`), and an +-- id at or below wCurSFX takes the channels over because _PlaySFX zeroes +-- ch5-ch8 before it loads the new header. Sfx never layer on this path. +local gate = { + audio = { + fanfares = {}, + sfxOrder = { "Sfx_Dummy", "Sfx_Tackle", "Sfx_Elevator" }, + sfx = { + Sfx_Tackle = { file = "sfx/tackle.wav", generation = 2 }, + Sfx_Elevator = { file = "sfx/elevator.wav", generation = 2 }, + }, + }, +} + +local tackle = Sound.play(gate, "Sfx_Tackle") +check(tackle ~= nil, "the tackle sounds") +check(Sound.play(gate, "Sfx_Elevator") == nil, + "SproutTower3FRivalScene: the elevator rumble is dropped, not layered") +check(tackle:isPlaying(), "and the tackle is left alone") + +tackle:stop() -- the sound ends; wCurSFX only bites while a channel is on +local elevator = Sound.play(gate, "Sfx_Elevator") +check(elevator ~= nil, "with the channels free the same sfx does play") +check(Sound.play(gate, "Sfx_Tackle") ~= nil, + "a higher-priority id is not dropped") +check(not elevator:isPlaying(), "_PlaySFX cut the sound that held the channels") + +-- Battle animations reach PlayStereoSFX instead, which has no gate and never +-- writes wCurSFX (engine/battle_anims/anim_commands.asm anim_sound). +check(Sound.playStereo(gate, "Sfx_Elevator") ~= nil, + "an animation sound plays over whatever is sounding") +check(tackle:isPlaying(), "and does not stop it either") +check(Sound.play(gate, "Sfx_Elevator") == nil, + "the animation sound did not become the priority to beat") +tackle:stop() +elevator:stop() + +-- === a header that could not be read yet is retried === + +local ChipSynth = require("src.core.ChipSynth") +local realChannels = ChipSynth.effectChannels +local calls = 0 +ChipSynth.effectChannels = function() + calls = calls + 1 + if calls == 1 then error("program banks not readable yet") end + if calls == 2 then return nil end -- effectChannels' own "not knowable" + return { 5, 6, 7, 8 } +end + +local jingle = { + audio = { fanfares = {}, + sfx = { Sfx_TestJingle = { address = 0x4000, generation = 2 } } }, +} +check(not Sound.ducksMusic(jingle, "Sfx_TestJingle"), + "a header read that raised does not duck") +eq(calls, 1, "one attempt so far") +check(not Sound.ducksMusic(jingle, "Sfx_TestJingle"), + "nor does one that answered nil") +eq(calls, 2, "the failure was retried, not remembered as zero channels") +check(Sound.ducksMusic(jingle, "Sfx_TestJingle"), + "the read that worked ducks the music: four channels leave the song none") +eq(calls, 3, "three attempts") +check(Sound.ducksMusic(jingle, "Sfx_TestJingle"), + "still ducks") +eq(calls, 3, "and THAT answer is cached, so the header is read once") + +ChipSynth.effectChannels = realChannels +Sound.invalidate() +love.audio = savedAudio + +S.finish() diff --git a/tests/gen2_specialty_balls_test.lua b/tests/gen2_specialty_balls_test.lua new file mode 100644 index 00000000..06d20b08 --- /dev/null +++ b/tests/gen2_specialty_balls_test.lua @@ -0,0 +1,189 @@ +-- Kurt's balls and the specialty multipliers +-- (engine/items/item_effects.asm BallMultiplierFunctionTable). +-- +-- luajit tests/gen2_specialty_balls_test.lua +-- +-- ROM-free. Heavy Ball's dex-weight brackets (Lugia's 4760 tenths-lb turns +-- catch rate 3 into 23, the walkthrough's whole reason to brew one), Level +-- Ball's three rungs, Lure Ball's fishing gate, and the three deliberate +-- cart bugs: Fast Ball knows exactly three species, Love Ball boosts +-- SAME-sex pairs, Moon Ball never boosts. Friend Ball is happiness only, +-- proven through the real catch in BattleState. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 specialty balls") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Catching = require("src.battle.gen2.Catching") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- ---- Heavy Ball ----------------------------------------------------------- +do + -- Lugia: dex weight 4760 (tenths of a pound) converts to 2158 tenths-kg + -- through the cart's w/2 - w/32 - w/64 walk; high byte 8 is the +20 rung. + eq(Catching.heavyBallBoost(4760), 20, "Lugia sits in the +20 bracket") + eq(Catching.specialtyRate(3, "HEAVY_BALL", { weight = 4760 }), 23, + "catch rate 3 becomes 23 -- against the Ultra Ball's 6") + -- A light mon LOSES 20, floored at 1 (`ld b, $1` on underflow). + eq(Catching.heavyBallBoost(40), -20, "a 4 lb mon is in the -20 bracket") + eq(Catching.specialtyRate(3, "HEAVY_BALL", { weight = 40 }), 1, + "and the subtraction floors at 1") + -- Snorlax: 10140 tenths-lb -> 4599 tenths-kg, high byte 17: +40. + eq(Catching.heavyBallBoost(10140), 40, "Snorlax earns the +40 rung") + eq(Catching.specialtyRate(25, "HEAVY_BALL", {}), 25, + "no weight supplied, no change") +end + +-- ---- Level Ball ----------------------------------------------------------- +do + local function level(rate, player, enemy) + return Catching.specialtyRate(rate, "LEVEL_BALL", + { playerLevel = player, level = enemy }) + end + eq(level(30, 10, 10), 30, "an equal level is no boost") + eq(level(30, 20, 15), 60, "below the player's level: x2") + eq(level(30, 40, 15), 120, "below half: x4") + eq(level(30, 44, 10), 240, "below a quarter: x8") + eq(level(30, 40, 10), 120, + "the rungs are strict: 10 is NOT below floor(40/4)") + eq(level(200, 44, 10), 255, "capped at 255 like every sla") +end + +-- ---- Lure Ball ------------------------------------------------------------ +do + eq(Catching.specialtyRate(45, "LURE_BALL", { fishing = true }), 135, + "x3 on a BATTLETYPE_FISH encounter") + eq(Catching.specialtyRate(45, "LURE_BALL", {}), 45, + "and nothing anywhere else") +end + +-- ---- Fast Ball's bug ------------------------------------------------------ +do + for species in pairs({ MAGNEMITE = 1, GRIMER = 1, TANGELA = 1 }) do + eq(Catching.specialtyRate(45, "FAST_BALL", { species = species }), 180, + species .. " is one of the three the bug leaves covered") + end + eq(Catching.specialtyRate(45, "FAST_BALL", { species = "DRATINI" }), 45, + "DRATINI flees on the cart too, but the broken loop never sees it") +end + +-- ---- Moon Ball's bug ------------------------------------------------------ +do + eq(Catching.specialtyRate(45, "MOON_BALL", + { evolveItem = "MOON_STONE" }), 45, + "a Moon Stone evolver gets nothing: the check reads Gen 1's constant") + eq(Catching.specialtyRate(45, "MOON_BALL", + { evolveItem = "MOON_STONE", fixBugs = true }), 180, + "fixBugs restores the intended x4") +end + +-- ---- Love Ball's bug ------------------------------------------------------ +do + local function love_(gender, playerGender, fix) + return Catching.specialtyRate(30, "LOVE_BALL", { + species = "NIDORAN_F", playerSpecies = "NIDORAN_F", + gender = gender, playerGender = playerGender, fixBugs = fix, + }) + end + eq(love_("female", "female"), 240, + "same species, SAME sex: the x8 the `ret nz` bug ships") + eq(love_("female", "male"), 30, "opposite sexes get nothing") + eq(love_("female", "male", true), 240, "fixBugs flips it back") + eq(Catching.specialtyRate(30, "LOVE_BALL", { + species = "NIDORAN_F", playerSpecies = "PIDGEY", + gender = "female", playerGender = "female" }), 30, + "different species never boost") +end + +-- ---- through Catching.rate ------------------------------------------------ +do + -- Full HP Lugia at rate 3: (3*maxHp - 2*hp) with the >=256 shift path, + -- both balls through the same formula; the Heavy Ball's 23 must beat the + -- Ultra Ball's 6 on the final rate too. + local heavy = Catching.rate({ maxHp = 200, hp = 200, catchRate = 3, + ball = "HEAVY_BALL", weight = 4760 }) + local ultra = Catching.rate({ maxHp = 200, hp = 200, catchRate = 3, + ball = "ULTRA_BALL" }) + check(heavy > ultra, + ("the brewed ball out-catches the bought one (%d > %d)"):format( + heavy, ultra)) +end + +-- ---- Friend Ball happiness through the real catch ------------------------- +do + local TYPES = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } } + local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 95, pp = 35, + effect = "EFFECT_NORMAL_HIT" } } + local POKEMON = { + growthRates = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, + squared = 0, linear = 0, constant = 0 } }, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + 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", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + } + local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = { + FRIEND_BALL = { id = "FRIEND_BALL", name = "FRIEND BALL", + pocket = "BALL" }, + POKE_BALL = { id = "POKE_BALL", name = "POKe BALL", pocket = "BALL" }, + }, + } + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + + local function catchWith(ball) + Input:init() + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = { player }, inventory = { [ball] = 1 } } + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + -- random 0: the catch roll always lands (rate 255 PIDGEY). + local battle = Battle.new({ data = DATA, party = { player }, + wild = wild, save = save, random = function() return 0 end }) + local screen = BattleState.new(game, { battle = battle, save = save }) + screen:useItem(ball) + return battle, save, wild + end + + local battle, save, wild = catchWith("FRIEND_BALL") + eq(battle.outcome, "caught", "the Friend Ball catch lands") + eq(wild.happiness, Catching.FRIEND_BALL_HAPPINESS, + "and the caught mon's happiness is set to 200") + eq(save.party[2], wild, "in the party slot it landed in") + + battle, save, wild = catchWith("POKE_BALL") + eq(battle.outcome, "caught", "the control catch lands too") + eq(wild.happiness, 70, "an ordinary ball leaves the base 70") +end + +S.finish() diff --git a/tests/gen2_sprites_test.lua b/tests/gen2_sprites_test.lua new file mode 100644 index 00000000..78c77ab0 --- /dev/null +++ b/tests/gen2_sprites_test.lua @@ -0,0 +1,230 @@ +-- The two halves of the Gen 2 sprite id space: OverworldSprites rows +-- (data/sprites/sprites.asm) and the mon-icon ids past them +-- (data/sprites/sprite_mons.asm, resolved by GetMonSprite in +-- engine/overworld/overworld.asm). +-- +-- The second half is what the twenty small dolls, the gym Growlithes, the +-- Rocket base Voltorbs, Lugia and Ho-Oh all stand on, so a sprite table that +-- stops at SPRITE_SILVER_TROPHY means every one of those objects sets its slot +-- and then never spawns. +-- +-- ROM-free: `luajit tests/gen2_sprites_test.lua`. The decomp section SKIPs +-- with no ../pokegold beside the repo, and the cache section SKIPs (or asks +-- for a re-import) with no Gold cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 sprites") +local check, eq = S.check, S.eq + +local Json = require("src.link.Json") +local Decorations = require("src.core.gen2.Decorations") +local World = require("src.world.gen2.World") + +-- constants/sprite_constants.asm, the handful this file names. The first +-- block is `const_def` with SPRITE_NONE at 0, so an id is its own 1-based +-- spriteOrder index; SPRITE_POKEMON restarts the count at $80 and SPRITE_VARS +-- at $f0. +local SPRITE_SILVER_TROPHY = 0x5f +local SPRITE_POKEMON, SPRITE_PIKACHU, SPRITE_HO_OH = 0x80, 0x8e, 0xa2 +local SPRITE_VARS, SPRITE_DOLL_1 = 0xf0, 0xf1 +local NUM_OVERWORLD_SPRITES, NUM_POKEMON_SPRITES = 95, 35 + +-- DECO_PIKACHU_DOLL, the row the reachability check below puts in the room. +local DECO_PIKACHU_DOLL = 30 + +-- ---- the decomp ----------------------------------------------------------- +-- SpriteMons is `table_width 1`, one species per id from SPRITE_POKEMON up, +-- and it ends on `assert_table_length NUM_POKEMON_SPRITES`. Pinning the row +-- count and PIKACHU's place in it is what says the extractor followed the +-- table rather than counted rows off the sprite constants. +local pokegold = "../pokegold" +local monsAsm = io.open(pokegold .. "/data/sprites/sprite_mons.asm", "r") +if not monsAsm then + check(true, "no ../pokegold: SpriteMons is not pinned against the decomp (SKIP)") +else + local species = {} + for line in monsAsm:lines() do + local name = line:match("^%s*db%s+([%w_]+)") + if name then species[#species + 1] = name end + end + monsAsm:close() + eq(#species, NUM_POKEMON_SPRITES, + "sprite_mons.asm carries NUM_POKEMON_SPRITES species rows") + eq(species[1], "UNOWN", "row 0 is UNOWN, i.e. SPRITE_UNOWN is $80") + eq(species[SPRITE_PIKACHU - SPRITE_POKEMON + 1], "PIKACHU", + "SPRITE_PIKACHU ($8e) is the PIKACHU row, which is the doll in the room") + eq(species[#species], "HO_OH", "and the last row is HO_OH, id $a2") +end + +-- ---- the manifest --------------------------------------------------------- +-- spriteOrder is generated from both const blocks by tools/make_gold_manifest.py, +-- so it is the one place the ids can go out of line with the ROM tables. +local manifestFile = assert(io.open("tools/rom_manifest_gold.json", "r")) +local manifest = assert(Json.decode(manifestFile:read("*a"))) +manifestFile:close() + +local consts = manifest.constants +local order = consts.spriteOrder +eq(#order, SPRITE_HO_OH, "spriteOrder runs to SPRITE_HO_OH, the last mon id") +eq(consts.numOverworldSprites, NUM_OVERWORLD_SPRITES, + "and NUM_OVERWORLD_SPRITES of those rows are OverworldSprites rows") +eq(consts.spritePokemon, SPRITE_POKEMON, "SPRITE_POKEMON is $80") +eq(order[NUM_OVERWORLD_SPRITES], "SPRITE_SILVER_TROPHY", + "the OverworldSprites half still ends on SPRITE_SILVER_TROPHY") +eq(order[SPRITE_SILVER_TROPHY], "SPRITE_SILVER_TROPHY", + "and an id is its own index, because the block is const_def 0 past SPRITE_NONE") +eq(order[SPRITE_POKEMON], "SPRITE_UNOWN", "$80 is SPRITE_UNOWN") +eq(order[SPRITE_PIKACHU], "SPRITE_PIKACHU", "$8e is SPRITE_PIKACHU") +eq(order[SPRITE_HO_OH], "SPRITE_HO_OH", "$a2 is SPRITE_HO_OH") +for id = NUM_OVERWORLD_SPRITES + 1, SPRITE_POKEMON - 1 do + if order[id] ~= "UNUSED" then + check(false, ("id %d is a hole between the two blocks"):format(id)) + end +end +check(true, "the $60..$7f hole between the blocks is UNUSED rows, not a shift") +-- The list must NOT run on past the mon ids: SPRITE_DAY_CARE_MON_1 ($e0) reads +-- a breedmon species and the SPRITE_VARS block ($f0) is a slot INTO +-- wVariableSprites, so naming either would make World:resolveSprite hand back +-- a slot id as though it were something that could be drawn. +check(order[SPRITE_VARS] == nil, + "and it stops before SPRITE_VARS, which is a slot id and not a sheet") +check(manifest.symbols.SpriteMons ~= nil, + "SpriteMons is a required symbol, or the extractor could never read it") + +-- ---- the call site -------------------------------------------------------- +-- ToggleDecorationsVisibility (PLAYERS_HOUSE_2F's MAPCALLBACK_NEWMAP) writes +-- the doll's sprite byte into wVariableSprites, and World:pooledNpc then asks +-- resolveSprite for a name and the sprites table for a sheet. Both halves are +-- driven here with the shipped spriteOrder, because a name that resolves to no +-- sheet is exactly as unspawnable as no name at all. +local world = World.new({ + save = { player = { name = "GOLD" }, decorations = { + leftOrnament = DECO_PIKACHU_DOLL } }, +}) +world.constants = consts +world.sprites = { SPRITE_PIKACHU = { id = "SPRITE_PIKACHU", frames = 1 } } +world:toggleDecorationsVisibility() + +local left = Decorations.OBJECT_SLOTS[2] +eq(left.slot, "leftOrnament", "the left ornament is wVariableSprites slot 1") +eq(world.variableSprites[left.sprite], SPRITE_PIKACHU, + "the room callback fills that slot with the PIKACHU doll's sprite byte") +eq(world:resolveSprite(SPRITE_DOLL_1), "SPRITE_PIKACHU", + "so the object's own $f1 resolves through the slot to a mon sprite name") +check(world.sprites[world:resolveSprite(SPRITE_DOLL_1)] ~= nil, + "and pooledNpc finds a sheet for it, which is what makes the doll spawn") +check(not world.events:get(left.flag), + "with the object's event flag cleared, so it is built at all") + +-- Every doll, console and trophy an ornament slot can hold has to name a row +-- of spriteOrder; the twenty small dolls are the SPRITE_POKEMON ones. +local monDolls = 0 +for decoId = 1, 52 do + local attr = Decorations.attributes(decoId) + if attr and attr.action == "SET_UP_DOLL" or + (attr and attr.action == "SET_UP_CONSOLE") then + local name = order[attr.sprite] + check(name ~= nil and name ~= "UNUSED", + ("%s stands on sprite id %d, which spriteOrder names"):format( + attr.name, attr.sprite)) + if attr.sprite >= SPRITE_POKEMON then monDolls = monDolls + 1 end + end +end +eq(monDolls, 20, "twenty of the ornaments are mon-icon sprites") + +-- ---- the cache ------------------------------------------------------------ +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local sprites = loadCache("sprites") +if not sprites then + check(true, "no GOLD_CACHE: the extracted sprite rows are not checked (SKIP)") +elseif not sprites.SPRITE_PIKACHU then + check(true, + "cache predates the SpriteMons rows : re-import for the mon dolls (SKIP)") +else + local rows, monRows = 0, 0 + for id in pairs(sprites) do + rows = rows + 1 + if order[SPRITE_POKEMON] and id:match("^SPRITE_") then + local index + for i = SPRITE_POKEMON, SPRITE_HO_OH do + if order[i] == id then index = i end + end + if index then monRows = monRows + 1 end + end + end + eq(rows, NUM_OVERWORLD_SPRITES + NUM_POKEMON_SPRITES, + "sprites.lua carries both tables, not just the OverworldSprites one") + eq(monRows, NUM_POKEMON_SPRITES, "and all 35 of the SpriteMons ids are there") + + local doll = sprites.SPRITE_PIKACHU + eq(doll.source, "ROM:SpriteMons[14]", + "SPRITE_PIKACHU came off SpriteMons row 14, not an OverworldSprites row") + eq(doll.species, "PIKACHU", "and that row's species is PIKACHU") + eq(doll.icon, "ICON_PIKACHU", + "which ReadMonMenuIcon turns into PIKACHU's menu icon") + eq(doll.image, "assets/generated/icons/gen2/pikachu.png", + "so it draws from the icon sheet extractIcons already writes") + -- FacingStepDown0 is the only OAM set a still mon object ever uses, and it + -- is tiles $00..$03: the icon's first frame. The second is the party + -- menu's bob and never reaches the map. + eq(doll.frames, 1, "one frame: _DoesSpriteHaveFacings sends a mon down-only") + check(not doll.walker, "and a doll does not walk") + eq(doll.paletteId, 0, "_GetSpritePalette answers 0 for every mon sprite") + eq(doll.palette, "PAL_OW_RED", "which is PAL_OW_RED in the MapObjectPals set") + + -- The OverworldSprites half must be untouched by the new rows: it is read by + -- index, and one row of drift would repaint every NPC in the game. + local chris = sprites.SPRITE_CHRIS + check(chris ~= nil and chris.walker, "SPRITE_CHRIS is still a walker") + eq(chris.source, "ROM:OverworldSprites[0]", "and still row 0 of that table") + eq(sprites.SPRITE_SILVER_TROPHY.source, + ("ROM:OverworldSprites[%d]"):format(NUM_OVERWORLD_SPRITES - 1), + "and SPRITE_SILVER_TROPHY is still its last row") + + local cacheConsts = loadCache("constants") + if cacheConsts then + eq(#(cacheConsts.spriteOrder or {}), SPRITE_HO_OH, + "the cache's own spriteOrder runs to SPRITE_HO_OH too") + eq(cacheConsts.spritePokemon, SPRITE_POKEMON, + "and it carries SPRITE_POKEMON for the extractor to index SpriteMons by") + end + + -- The other reason this range matters: fifty-odd map objects across + -- pokegold name a mon sprite directly (the gym Growlithes, the Rocket base + -- Voltorbs, Lugia, Ho-Oh), and every one of them used to extract as a bare + -- number that pooledNpc could find no sheet for. + local maps = loadCache("maps") + if maps then + local named, numbered = 0, 0 + for _, def in pairs(maps) do + if type(def) == "table" then + for _, obj in ipairs(def.objects or {}) do + if type(obj.spriteId) == "number" + and obj.spriteId >= SPRITE_POKEMON + and obj.spriteId <= SPRITE_HO_OH then + if type(obj.sprite) == "string" then + named = named + 1 + check(sprites[obj.sprite] ~= nil, + ("%s names a sheet"):format(obj.sprite)) + else + numbered = numbered + 1 + end + end + end + end + end + check(named >= 40, + ("%d map objects stand on a mon sprite and now name one"):format(named)) + eq(numbered, 0, "and none of them is left as a bare id with no sheet") + end +end + +S.finish() diff --git a/tests/gen2_steps_test.lua b/tests/gen2_steps_test.lua new file mode 100644 index 00000000..c3c6abc0 --- /dev/null +++ b/tests/gen2_steps_test.lua @@ -0,0 +1,256 @@ +-- CountStep (engine/overworld/events.asm) -- the per-step event chain. +-- ROM-free: `luajit tests/gen2_steps_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 steps") +local check, eq = S.check, S.eq + +local StepEvents = require("src.world.gen2.StepEvents") +local Breeding = require("src.core.gen2.Breeding") +local Happiness = require("src.core.gen2.Happiness") +local Phone = require("src.core.gen2.Phone") + +local function mon(opts) + opts = opts or {} + return { + species = opts.species or "CHIKORITA", + name = opts.name or "CHIKORITA", + hp = opts.hp or 20, + maxHp = opts.maxHp or 20, + status = opts.status, + happiness = opts.happiness or 70, + level = opts.level or 5, + } +end + +local function newSave(opts) + opts = opts or {} + return { party = opts.party or { mon() }, phone = {} } +end + +-- ---- DoPoisonStep --------------------------------------------------------- +-- One HP off every poisoned mon that is still standing. A mon that runs out +-- has its status CLEARED on the way down, so a party wiped by poison walks into +-- the Center with nothing left to cure. +do + local party = { + mon({ hp = 5, status = "psn" }), + mon({ hp = 1, status = "psn" }), + mon({ hp = 9 }), + mon({ hp = 0, status = "psn" }), + } + local event = StepEvents.poisonStep(party) + eq(party[1].hp, 4, "a poisoned mon loses one HP") + eq(party[2].hp, 0, "and one on its last point drops") + check(party[2].status == nil, "with its status cleared as it goes") + eq(party[3].hp, 9, "an unpoisoned mon is untouched") + eq(party[4].hp, 0, "and an already-fainted one is skipped, not decremented") + eq(event.kind, "poisonFaint", + "one faint anywhere outranks the rest (wPoisonStepFlagSum and %10 first)") + eq(event.fainted[1], 2, "and names the slot that dropped") + check(event.blocks, "the faint arm sets carry, so no wild roll that step") +end + +do + local party = { mon({ hp = 5, status = "psn" }) } + local event = StepEvents.poisonStep(party) + eq(event.kind, "poisonHurt", "damage with no faint is the .PlayPoisonSFX arm") + check(not event.blocks, + "which ends `xor a`: the step still counts and the grass still rolls") + check(StepEvents.poisonStep({}) == nil, "an empty party is no event at all") + check(StepEvents.poisonStep({ mon() }) == nil, "and a clean party is none") +end + +-- CheckPlayerPartyForFitMon. An egg is not a fit mon. +do + check(StepEvents.whitedOut({ mon({ hp = 0 }) }), "no HP left is a whiteout") + check(not StepEvents.whitedOut({ mon({ hp = 1 }) }), "one point is not") + check(StepEvents.whitedOut({ mon({ hp = 0 }), { isEgg = true, hp = 0 } }), + "and an egg does not count as a fighter") +end + +-- ---- DoRepelStep ---------------------------------------------------------- +-- `dec a / ret nz`: the wear-off lands on the step that reaches zero, and that +-- step is not counted. +do + local save = newSave() + save.repelSteps = 2 + check(not StepEvents.repelStep(save), "a repel with steps left is quiet") + eq(save.repelSteps, 1, "and ticks down") + check(StepEvents.repelStep(save), "the step that reaches zero wears off") + check(not StepEvents.repelStep(save), "and it only fires once") +end + +-- ---- DoBikeStep ----------------------------------------------------------- +do + local save = newSave() + save.bikeShopCall = true + save.bikeStep = 0 + check(not StepEvents.bikeStep(save, { playerState = "normal" }), + "on foot the counter does not even move") + eq(save.bikeStep, 0, "literally does not move") + check(not StepEvents.bikeStep(save, { playerState = "bike", + phoneService = false }), "and a map with no service is refused") + save.bikeStep = StepEvents.BIKE_CALL_STEPS - 2 + check(not StepEvents.bikeStep(save, { playerState = "bike" }), + "1022 steps is not yet 1024") + check(StepEvents.bikeStep(save, { playerState = "bike" }), + "the 1024th queues the call (`cp HIGH(1024)` on the counter's high byte)") + eq(Phone.specialCallVar(save), Phone.SPECIALCALL.SPECIALCALL_BIKESHOP, + "as SPECIALCALL_BIKESHOP") + check(save.bikeShopCall == false, "and clears the flag that asked for it") + + -- "If a call has already been queued, don't overwrite that call." + local busy = newSave() + busy.bikeShopCall = true + busy.bikeStep = StepEvents.BIKE_CALL_STEPS + Phone.queueSpecialCall(busy, Phone.SPECIALCALL.SPECIALCALL_SSTICKET) + check(not StepEvents.bikeStep(busy, { playerState = "bike" }), + "a call already queued is not overwritten") + eq(Phone.specialCallVar(busy), Phone.SPECIALCALL.SPECIALCALL_SSTICKET, + "the S.S. Ticket call survives") + + -- The counter saturates rather than wrapping (`cp 255` on both bytes). + local full = newSave() + full.bikeShopCall = true + full.bikeStep = StepEvents.BIKE_STEP_MAX + StepEvents.bikeStep(full, { playerState = "bike" }) + eq(full.bikeStep, StepEvents.BIKE_STEP_MAX, "and stops at $ffff") + + -- The flag the Goldenrod clerk actually writes: `setflag + -- ENGINE_BIKE_SHOP_CALL_ENABLED` lands on save.engineFlags, and DoBikeStep + -- reads it there rather than waiting for a field nothing sets. + local Bike = require("src.world.gen2.Bike") + local clerk = newSave() + clerk.engineFlags = {} + clerk.bikeStep = StepEvents.BIKE_CALL_STEPS + check(not StepEvents.bikeStep(clerk, { playerState = "bike" }), + "with the clerk's flag clear there is no call to make") + clerk.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = true + check(StepEvents.bikeStep(clerk, { playerState = "bike" }), + "and once the clerk sets it, the call is queued") + check(clerk.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] == nil, + "`res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever") +end + +-- ---- CountStep, the whole block ------------------------------------------ +do + local save = newSave() + local event, counted = StepEvents.count(save, {}) + check(event == nil, "an ordinary footfall produces no player event") + check(counted, "and is counted") + eq(save.stepCount, 1, "wStepCount moved") + eq(save.poisonStepCount, 1, "and so did wPoisonStepCount") +end + +-- Neither a special call nor a repel wearing off counts the step. +do + local save = newSave() + Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_SSTICKET) + local event, counted = StepEvents.count(save, { phone = {} }) + eq(event.kind, "phoneCall", "a queued special call is the first thing checked") + check(not counted, "and the step is NOT counted") + eq(save.stepCount or 0, 0, "wStepCount is untouched") + check(event.blocks, "it queues a player event, so no wild roll") +end + +do + local save = newSave() + save.repelSteps = 1 + local event, counted = StepEvents.count(save, {}) + eq(event.kind, "repel", "a repel wearing off is the second") + check(not counted, "and that step is not counted either") + eq(save.stepCount or 0, 0, "so the last repel step never ticks an egg") +end + +-- StepHappiness is the wStepCount WRAP, and its own toggle halves it again: +-- 512 footfalls a point, not 256. +do + local save = newSave({ party = { mon({ happiness = 100 }) } }) + for _ = 1, 256 do StepEvents.count(save, {}) end + eq(save.stepCount, 0, "256 steps wrap wStepCount") + eq(save.party[1].happiness, 100, + "and the first wrap only flips StepHappiness' own toggle") + for _ = 1, 256 do StepEvents.count(save, {}) end + eq(save.party[1].happiness, 101, "the SECOND wrap is what pays the point") + eq(Happiness.stepsToGain(save), 512, "and the next one is 512 away again") +end + +-- DoEggStep is 128 steps offset from the happiness wrap, and a hatch skips the +-- rest of the block. +do + local egg = { isEgg = true, species = "TOGEPI", eggSteps = 1 } + local save = newSave({ party = { mon(), egg } }) + local event + for _ = 1, Breeding.EGG_STEP_PHASE do + event = StepEvents.count(save, {}) + end + eq(save.stepCount, Breeding.EGG_STEP_PHASE, "the tick lands at $80") + eq(egg.eggSteps, 0, "which spends the last cycle") + eq(event.kind, "hatch", "and answers PLAYEREVENT_HATCH") + check(event.blocks, "which stops the step: no wild battle on a hatch") +end + +do + -- Nothing happens on any other step of the cycle. + local egg = { isEgg = true, species = "TOGEPI", eggSteps = 2 } + local save = newSave({ party = { egg } }) + for _ = 1, Breeding.EGG_STEP_PHASE - 1 do StepEvents.count(save, {}) end + eq(egg.eggSteps, 2, "127 steps do not touch the counter") +end + +-- Poison fires every fourth step, and the counter is reset rather than masked. +do + local poisoned = mon({ hp = 10, status = "psn" }) + local save = newSave({ party = { poisoned } }) + for _ = 1, 3 do StepEvents.count(save, {}) end + eq(poisoned.hp, 10, "three steps do no damage") + local event = StepEvents.count(save, {}) + eq(poisoned.hp, 9, "the fourth does one point") + eq(event.kind, "poisonHurt", "and reports it") + eq(save.poisonStepCount, 0, "with the counter reset, not wrapped") + for _ = 1, 4 do StepEvents.count(save, {}) end + eq(poisoned.hp, 8, "and again four steps later") +end + +-- The whiteout answer rides on the event so the caller does not have to ask. +do + local dying = mon({ hp = 1, status = "psn" }) + local save = newSave({ party = { dying } }) + local event + for _ = 1, 4 do event = StepEvents.count(save, {}) end + eq(event.kind, "poisonFaint", "the last point is a faint") + check(event.whiteout, "and with nothing else standing, a whiteout") +end + +do + local dying = mon({ hp = 1, status = "psn" }) + local save = newSave({ party = { dying, mon({ hp = 5 }) } }) + local event + for _ = 1, 4 do event = StepEvents.count(save, {}) end + eq(event.kind, "poisonFaint", "a faint with a healthy mon behind it") + check(not event.whiteout, "is not a whiteout") +end + +-- "Don't count steps in link communication rooms." +do + local save = newSave() + local event, counted = StepEvents.count(save, { linkMode = true }) + check(event == nil and not counted, "a link room counts nothing") + eq(save.stepCount or 0, 0, "and moves no counter") +end + +-- The battle writes "poison"/"toxic" (Battle.STATUS_EFFECTS); both spellings +-- must register as PSN out here or bad poison never hurts on the overworld. +do + local party = { + mon({ hp = 5, status = "toxic" }), + mon({ hp = 5, status = "poison" }), + } + local event = StepEvents.poisonStep(party) + eq(party[1].hp, 4, "a badly poisoned mon loses one HP on the step") + eq(party[2].hp, 4, "alongside the plain poison spelling") + eq(event.kind, "poisonHurt", "and the hurt arm fires for both") +end + +S.finish() diff --git a/tests/gen2_struggle_test.lua b/tests/gen2_struggle_test.lua new file mode 100644 index 00000000..fdfbaef7 --- /dev/null +++ b/tests/gen2_struggle_test.lua @@ -0,0 +1,78 @@ +-- STRUGGLE: what a mon does when every move is spent. +-- +-- luajit tests/gen2_struggle_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua), which walked into +-- it about two hours into a run and could not get out. Before this, a mon with +-- no PP left simply did not act: +-- +-- * the player's turn emitted "No PP left for this move!" and returned +-- * the enemy's turn emitted "<name> has no moves left!" and returned +-- +-- Neither side dealt damage, so the battle could not end -- and because RUN is +-- refused in a trainer battle, it could not be left either. The save was +-- effectively dead. The cart has no such state: `.CheckPlayerHasUsableMoves` +-- (engine/battle/core.asm:5273) and `.struggle` (:5631) both substitute +-- STRUGGLE, which is exactly what is asserted here. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 struggle") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") + +-- hasUsableMoves is the predicate the substitution hangs off, and it is worth +-- pinning on its own: a mon whose only remaining PP is on a STATUS move is not +-- dry, and must not struggle. +do + eq(Battle.hasUsableMoves({}, { moves = {} }), false, "no moves at all is dry") + eq(Battle.hasUsableMoves({}, { moves = { { id = "TACKLE", pp = 0 } } }), false, + "a single spent move is dry") + eq(Battle.hasUsableMoves({}, { moves = { { id = "TACKLE", pp = 0 }, + { id = "LEER", pp = 3 } } }), true, + "PP left on a status move is not dry") + eq(Battle.hasUsableMoves({}, { moves = { { id = "TACKLE", pp = 1 } } }), true, + "one PP is not dry") + eq(Battle.hasUsableMoves({}, { moves = { { id = "GROWL", pp = 40 } }, + volatile = { disabled = "GROWL" } }), false, + "PP on the disabled slot alone is dry (.CheckPlayerHasUsableMoves)") + eq(Battle.hasUsableMoves({}, { moves = { { id = "GROWL", pp = 40 }, + { id = "TACKLE", pp = 5 } }, volatile = { disabled = "GROWL" } }), true, + "another slot with PP is not dry") +end + +eq(Battle.STRUGGLE, "STRUGGLE", "the fallback names the real move id") + +-- --------------------------------------------------------------------------- +-- The move itself has to exist in the extracted data, or the substitution +-- swaps one dead turn for another: useMove bails with "has no move to use!" +-- when moveDef comes back nil. +-- --------------------------------------------------------------------------- + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local movesPath = cache .. "/data/generated/moves.lua" +local mf = io.open(movesPath, "r") +if not mf then + check(true, "gold cache absent : predicate checked, move data SKIPPED") + S.finish() + return +end +mf:close() + +local moves = assert(loadfile(movesPath))() +local struggle = moves[Battle.STRUGGLE] +check(struggle ~= nil, "STRUGGLE is in the extracted move table") +if struggle then + eq(struggle.power, 50, "STRUGGLE has power, so a dry turn still does damage") + eq(struggle.effect, "EFFECT_RECOIL_HIT", + "STRUGGLE recoils (the effect the port already implements)") + -- The recoil is the reason a stalemate resolves even when NEITHER side can + -- damage the other any other way: both sides wear themselves down. + check((struggle.pp or 0) > 0, "STRUGGLE carries its own PP") +end + +S.finish() diff --git a/tests/gen2_summary_test.lua b/tests/gen2_summary_test.lua new file mode 100644 index 00000000..3342d472 --- /dev/null +++ b/tests/gen2_summary_test.lua @@ -0,0 +1,656 @@ +-- The mon SUMMARY screen (engine/pokemon/stats_screen.asm) and the mon action +-- submenu it hangs off (engine/pokemon/mon_submenu.asm). +-- +-- Every page of the summary is a tilemap, so what this suite asserts is the +-- tilemap: SummaryMenu builds each page as a list of { text, x, y } writes +-- before anything is drawn, and the coordinates below are the hlcoords the +-- ASM uses, checked one by one. Nothing here draws -- what a test cannot say +-- (whether the pages LOOK like Gold's) is what +-- tests/drivers/gold_summary_shots.lua exists for. + +package.path = "./?.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction and logic; 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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +local Mon = require("src.battle.gen2.Mon") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +-- ------------------------------------------------------------- fixture data + +-- A cut-down data cache: one species with a known growth rate and base stats, +-- the four moves it carries, and one held item. Everything the summary reads +-- comes off these three tables. +local DATA = { + pokemon = { + growthRates = { + -- GROWTH_MEDIUM_SLOW: 6/5 n^3 - 15 n^2 + 100 n - 140. + GROWTH_MEDIUM_SLOW = { + numerator = 6, denominator = 5, squared = -15, linear = 100, + constant = 140, + }, + }, + CYNDAQUIL = { + id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "FIRE", "FIRE" }, + baseStats = { + hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50, + }, + }, + TOTODILE = { + id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "WATER", "WATER" }, + baseStats = { + hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48, + }, + }, + GASTLY = { + id = "GASTLY", name = "GASTLY", dex = 92, index = 92, + growthRate = "GROWTH_MEDIUM_SLOW", + types = { "GHOST", "POISON" }, + baseStats = { + hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35, + }, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35, power = 35, + type = "NORMAL", + description = "A physical attack<NEXT>using full body<NEXT>weight." }, + EMBER = { id = "EMBER", name = "EMBER", pp = 25, power = 40, type = "FIRE", + description = "An attack that may<NEXT>inflict a burn." }, + -- A status move: PlaceMoveData prints String_MoveNoPower for power < 2. + LEER = { id = "LEER", name = "LEER", pp = 30, power = 0, type = "NORMAL", + description = "Lowers the foe's<NEXT>DEFENSE." }, + CUT = { id = "CUT", name = "CUT", pp = 30, power = 50, type = "NORMAL", + description = "An attack with a<NEXT>sharp object." }, + SURF = { id = "SURF", name = "SURF", pp = 15, power = 95, type = "WATER", + description = "A strong water-<NEXT>type attack." }, + }, + items = { + BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM" }, + -- Mail lives in the ordinary ITEM pocket on the cart; what makes it mail + -- is data/items/mail_items.asm's own list (src/core/gen2/Mail.lua), which + -- is why this fixture names a real one instead of inventing a pocket. + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL", pocket = "ITEM" }, + }, + gen2MenuGfx = {}, +} + +local function newGame(save) + return { + input = newInput(), + save = save, + data = DATA, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +-- Every party member goes through the ONE Gen 2 builder; a mon that came out +-- of Gen 1's Pokemon.new would have no moves at all, because a Gen 2 moveset +-- is `levelMoves` and Gen 1 reads level1Moves / learnset. +local function mon(species, level, opts) + opts = opts or {} + local built = Mon.new(DATA, species, level, { + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + moves = opts.moves, + }) + for key, value in pairs(opts.fields or {}) do built[key] = value end + return built +end + +local CYNDA = mon("CYNDAQUIL", 12, { + moves = { + { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "EMBER", pp = 25, maxPp = 25 }, + { id = "LEER", pp = 7, maxPp = 30 }, + }, + fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY", + otName = "GOLD", otId = 12345 }, +}) +local TOTO = mon("TOTODILE", 10, { + moves = { { id = "SURF", pp = 15, maxPp = 15 } }, + fields = { nickname = "TOTODILE", gender = "female" }, +}) + +local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } } + +local function newSummary(index) + local game = newGame(SAVE) + local screen = SummaryMenu.new(game, { + party = SAVE.party, index = index or 1, save = SAVE, + }) + return screen, game.input +end + +local at = SummaryMenu.at + +-- --------------------------------------------------------------- page cycle + +-- StatsScreenInit enters on PINK_PAGE, and .d_right / .d_left wrap in both +-- directions (BLUE -> PINK and PINK -> BLUE). +local screen, input = newSummary() +check("opens on the pink page", screen.page, SummaryMenu.PINK_PAGE) +input:press("right") +screen:update(0) +check("right reaches the green page", screen.page, SummaryMenu.GREEN_PAGE) +input:press("right") +screen:update(0) +check("right reaches the blue page", screen.page, SummaryMenu.BLUE_PAGE) +input:press("right") +screen:update(0) +check("right wraps back to pink", screen.page, SummaryMenu.PINK_PAGE) +input:press("left") +screen:update(0) +check("left wraps to blue", screen.page, SummaryMenu.BLUE_PAGE) +input:press("left") +screen:update(0) +check("left steps back to green", screen.page, SummaryMenu.GREEN_PAGE) + +-- .a_button falls THROUGH into .d_right on any page but the last, where it +-- quits instead. Both halves of that fallthrough matter. +local closed = false +screen, input = newSummary() +screen.onClose = function() closed = true end +input:press("a") +screen:update(0) +check("a turns the page from pink", screen.page, SummaryMenu.GREEN_PAGE) +check("and does not close", closed, false) +input:press("a") +screen:update(0) +check("a turns the page from green", screen.page, SummaryMenu.BLUE_PAGE) +input:press("a") +screen:update(0) +check("a on the last page closes", closed, true) +check("and leaves the page alone", screen.page, SummaryMenu.BLUE_PAGE) + +closed = false +screen, input = newSummary() +screen.onClose = function() closed = true end +input:press("b") +screen:update(0) +check("b closes from any page", closed, true) + +-- The `down` and `.d_up` arms walk the party without wrapping, and the page +-- survives the switch (StatsScreenMain pushes bc and pops it back). +screen, input = newSummary() +screen.page = SummaryMenu.BLUE_PAGE +input:press("up") +screen:update(0) +check("up at the top of the party does nothing", screen.index, 1) +input:press("down") +screen:update(0) +check("down walks to the next mon", screen.index, 2) +check("and keeps the page", screen.page, SummaryMenu.BLUE_PAGE) +check("and swaps the mon", screen.mon.species, "TOTODILE") +input:press("down") +screen:update(0) +check("down at the end of the party does nothing", screen.index, 2) + +-- ---------------------------------------------------------- the upper half + +-- StatsScreen_InitUpperHalf. Drawn once for every page, which is why it is +-- built apart from them. +screen = newSummary() +local upper = screen:upperPlacements() +check("№. at hlcoord 8,0", at(upper, 8, 0), "№.") +check("dex number at hlcoord 10,0", at(upper, 10, 0), "155") +check("level at hlcoord 14,0", at(upper, 14, 0), "<LV>12") +check("nickname at hlcoord 8,2", at(upper, 8, 2), "CYNDAQUIL") +check("gender at hlcoord 18,0", at(upper, 18, 0), "♂") +check("the species slash at hlcoord 9,4", at(upper, 9, 4), "/") +check("species name at hlcoord 10,4", at(upper, 10, 4), "CYNDAQUIL") + +-- PrintLevel writes <LV> then two left-aligned digits, but a three-digit level +-- does `dec hl` first so the digits land on the <LV> and the field still +-- starts at the same column. +check("a two-digit level keeps its <LV>", SummaryMenu.levelText(12), "<LV>12") +check("a one-digit level keeps its <LV>", SummaryMenu.levelText(5), "<LV>5") +check("level 100 overwrites the <LV>", SummaryMenu.levelText(100), "100") + +-- A female mon writes the other glyph; a genderless one writes nothing. +local female = newSummary(2) +check("a female mon gets ♀", at(female:upperPlacements(), 18, 0), "♀") +local genderless = newSummary() +genderless.mon = mon("GASTLY", 20, { fields = { gender = "unknown" } }) +check("a genderless mon gets no glyph", + at(genderless:upperPlacements(), 18, 0), nil) + +-- ------------------------------------------------------------- pink page + +screen = newSummary() +local pink = screen:pinkPlacements() +-- DrawPlayerHP's `bccoord 1, 1, 0` from the bar's own (0,9). +check("current HP at hlcoord 1,10", at(pink, 1, 10), + ("%3d"):format(CYNDA.hp)) +check("the HP slash at hlcoord 4,10", at(pink, 4, 10), "/") +check("max HP at hlcoord 5,10", at(pink, 5, 10), + ("%3d"):format(CYNDA.maxHp)) +-- .Status_Type joins its two lines with <NEXT>, which is TWO rows down. +check("STATUS/ at hlcoord 0,12", at(pink, 0, 12), "STATUS/") +check("TYPE/ two rows below it, not one", at(pink, 0, 14), "TYPE/") +check("nothing sits between them", at(pink, 0, 13), nil) +check("a healthy mon reads OK at hlcoord 6,13", at(pink, 6, 13), "OK") +-- PrintMonTypes writes type 2 two rows down, and LoadPinkPage then copies row +-- 17 up onto row 16 -- so the second type ends one row under the first. +check("type 1 at hlcoord 1,15", at(pink, 1, 15), "FIRE") +check("a single-typed mon has no second type", at(pink, 1, 16), nil) +check("EXP POINTS at hlcoord 10,9", at(pink, 10, 9), "EXP POINTS") +check("LEVEL UP at hlcoord 10,12", at(pink, 10, 12), "LEVEL UP") +check("TO at hlcoord 14,14", at(pink, 14, 14), "TO") +-- The level beside TO is the NEXT one. +check("the next level at hlcoord 17,14", at(pink, 17, 14), "<LV>13") + +-- The exp numbers are seven-column fields at 13,10 and 13,13. +local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW +local expNow = Mon.experienceForLevel(growth, 12) +local expNext = Mon.experienceForLevel(growth, 13) +check("exp points at hlcoord 13,10", at(pink, 13, 10), + ("%7d"):format(expNow)) +check("exp to the next level at hlcoord 13,13", at(pink, 13, 13), + ("%7d"):format(expNext - expNow)) +check("and the field is seven columns wide", #at(pink, 13, 10), 7) + +-- A dual-typed mon prints both names, on consecutive rows. +local dual = newSummary() +dual.mon = mon("GASTLY", 20, { fields = { gender = "male" } }) +local dualPink = dual:pinkPlacements() +check("type 1 of a dual-type", at(dualPink, 1, 15), "GHOST") +check("type 2 one row below it", at(dualPink, 1, 16), "POISON") + +-- Status and fainting go through PlaceStatusString, which reads FNT off the +-- HP rather than off the status byte. +local sick = newSummary() +sick.mon = mon("TOTODILE", 10, { fields = { status = "psn" } }) +check("a poisoned mon reads PSN", at(sick:pinkPlacements(), 6, 13), "PSN") +local fainted = newSummary() +fainted.mon = mon("TOTODILE", 10, { fields = { hp = 0, status = nil } }) +check("a fainted mon reads FNT", at(fainted:pinkPlacements(), 6, 13), "FNT") + +-- wTempMonPokerusStatus: low nibble infected, high nibble immune-forever. +local pkrs = newSummary() +pkrs.mon = mon("TOTODILE", 10, { fields = { pokerus = 0x34 } }) +local pkrsPink = pkrs:pinkPlacements() +check("an infected mon shows POKéRUS at hlcoord 1,13", + at(pkrsPink, 1, 13), "POKéRUS") +check("and no status string", at(pkrsPink, 6, 13), nil) +local cured = newSummary() +cured.mon = mon("TOTODILE", 10, { fields = { pokerus = 0x30 } }) +local curedPink = cured:pinkPlacements() +check("a cured mon shows the dot at hlcoord 8,8", at(curedPink, 8, 8), ".") +check("and is otherwise OK", at(curedPink, 6, 13), "OK") + +-- ------------------------------------------------------------ green page + +screen = newSummary() +local green = screen:greenPlacements() +check("ITEM at hlcoord 0,8", at(green, 0, 8), "ITEM") +check("the held item at hlcoord 6,8", at(green, 6, 8), "BERRY") +check("MOVE at hlcoord 0,10", at(green, 0, 10), "MOVE") +-- ListMoves runs at (8,10) with wListMovesLineSpacing = SCREEN_WIDTH * 2. +check("move 1 at hlcoord 8,10", at(green, 8, 10), "TACKLE") +check("move 2 two rows down", at(green, 8, 12), "EMBER") +check("move 3 two rows down again", at(green, 8, 14), "LEER") +check("an empty slot is a dash", at(green, 8, 16), "-") +-- ListMovePP runs at (12,11): the PP label, then `inc hl` three times. +check("the PP label at hlcoord 12,11", at(green, 12, 11), "PP") +check("current PP at hlcoord 15,11", at(green, 15, 11), "30") +check("the PP slash at hlcoord 17,11", at(green, 17, 11), "/") +check("max PP at hlcoord 18,11", at(green, 18, 11), "35") +check("PP of the third move", at(green, 15, 15), " 7") +check("an empty slot's PP label is two dashes", at(green, 12, 17), "--") +check("and it prints no numbers", at(green, 15, 17), nil) + +local noItem = newSummary(2) +check("no held item prints .ThreeDashes", + at(noItem:greenPlacements(), 6, 8), "---") + +-- ------------------------------------------------------------- blue page + +screen = newSummary() +local blue = screen:bluePlacements() +-- IDNoString is "<ID>№." -- three single tiles, not the six letters it looks +-- like -- and both it and OTString are placed at column 0. +check("<ID>№. at hlcoord 0,9", at(blue, 0, 9), "<ID>№.") +check("the ID number at hlcoord 2,10", at(blue, 2, 10), "12345") +check("OT/ at hlcoord 0,12", at(blue, 0, 12), "OT/") +-- .PlaceOTInfo pads an ordinary name by two columns. +check("the OT name at hlcoord 2,13", at(blue, 2, 13), "GOLD") +check("a 4-char name pads by 2", SummaryMenu.otColumn("GOLD"), 2) +check("an 8-char name still pads by 2", SummaryMenu.otColumn("ABCDEFGH"), 2) +check("a 9-char name pads by 1", SummaryMenu.otColumn("ABCDEFGHI"), 1) +check("a 10-char name pads by 0", SummaryMenu.otColumn("ABCDEFGHIJ"), 0) + +-- PrintTempMonStats: labels down column 11 two rows apart, values in the +-- three columns ending at 19, starting one row below the first label. +local labels = { "ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED" } +for i, label in ipairs(labels) do + check(("stat label %s at hlcoord 11,%d"):format(label, 8 + (i - 1) * 2), + at(blue, 11, 8 + (i - 1) * 2), label) +end +-- ...and the numbers really are this mon's, off the one Gen 2 builder. +local stats = CYNDA.stats +check("ATTACK at hlcoord 17,9", at(blue, 17, 9), + ("%3d"):format(stats.attack)) +check("DEFENSE at hlcoord 17,11", at(blue, 17, 11), + ("%3d"):format(stats.defense)) +check("SPCL.ATK at hlcoord 17,13", at(blue, 17, 13), + ("%3d"):format(stats.specialAttack)) +check("SPCL.DEF at hlcoord 17,15", at(blue, 17, 15), + ("%3d"):format(stats.specialDefense)) +check("SPEED at hlcoord 17,17", at(blue, 17, 17), + ("%3d"):format(stats.speed)) +-- The fixture's stats are the cart's formula, not whatever the screen felt +-- like: a level 12 Cyndaquil with 15s across the board. +check("the fixture's ATTACK is the Gen 2 formula", stats.attack, + math.floor(((52 * 2 + 15 * 2) * 12) / 100) + 5) +check("HP uses the HP-DV-from-the-others rule", CYNDA.maxHp, + math.floor(((39 * 2 + 15 * 2) * 12) / 100) + 12 + 10) + +-- `placements` glues the upper half onto whichever page is up. +screen = newSummary() +screen.page = SummaryMenu.BLUE_PAGE +local whole = screen:placements() +check("a page carries the upper half too", at(whole, 8, 2), "CYNDAQUIL") +check("and its own rows", at(whole, 0, 12), "OT/") + +-- ------------------------------------------------------- move description + +-- SELECT opens PlaceMoveData's screen off the move page, and only off it. +screen, input = newSummary() +input:press("select") +screen:update(0) +check("select does nothing on the pink page", screen.moveDetail, false) +screen.page = SummaryMenu.GREEN_PAGE +input:press("select") +screen:update(0) +check("select opens the move detail on the green page", + screen.moveDetail, true) + +local detail = screen:moveDetailPlacements() +check("the nickname at hlcoord 5,1", at(detail, 5, 1), "CYNDAQUIL") +-- PlaceString leaves bc one past the string, and that is popped into hl for +-- PrintLevel -- so the level butts against the name rather than sitting in a +-- fixed column. +check("the level right after it", at(detail, 5 + 9, 1), "<LV>12") +check("move 1 at hlcoord 2,3", at(detail, 2, 3), "TACKLE") +check("move 2 at hlcoord 2,5", at(detail, 2, 5), "EMBER") +check("the PP label at hlcoord 10,4", at(detail, 10, 4), "PP") +check("current PP at hlcoord 13,4", at(detail, 13, 4), "30") +check("the PP slash at hlcoord 15,4", at(detail, 15, 4), "/") +check("max PP at hlcoord 16,4", at(detail, 16, 4), "35") +check("the type plaque's top at hlcoord 0,10", at(detail, 0, 10), "┌─────┐") +check("its bottom at hlcoord 0,11", at(detail, 0, 11), "│TYPE/└") +check("the move's type at hlcoord 2,12", at(detail, 2, 12), "NORMAL") +check("ATTK/ at hlcoord 11,12", at(detail, 11, 12), "ATTK/") +check("the move's power at hlcoord 16,12", at(detail, 16, 12), " 35") +-- PrintMoveDescription writes at (1,14) and its lines join with <NEXT>, which +-- is two rows down -- so line two is on row 16, not row 15. +check("the description at hlcoord 1,14", at(detail, 1, 14), + "A physical attack") +check("nothing on row 15", at(detail, 1, 15), nil) +check("its second line on row 16", at(detail, 1, 16), "using full body") + +-- Up and down pick the move the description belongs to. +input:press("down") +screen:update(0) +check("down picks the next move", screen.moveIndex, 2) +detail = screen:moveDetailPlacements() +check("the description follows the cursor", at(detail, 1, 14), + "An attack that may") +check("and so does the type", at(detail, 2, 12), "FIRE") +input:press("down") +screen:update(0) +detail = screen:moveDetailPlacements() +check("a 0-power move prints ---", at(detail, 16, 12), "---") +check("with its own description", at(detail, 1, 14), "Lowers the foe's") +input:press("down") +screen:update(0) +check("down wraps within the known moves", screen.moveIndex, 1) + +-- B backs out to the page it came from rather than closing the screen. +closed = false +screen.onClose = function() closed = true end +input:press("b") +screen:update(0) +check("b leaves the move detail", screen.moveDetail, false) +check("without closing the summary", closed, false) +check("and lands back on the move page", screen.page, SummaryMenu.GREEN_PAGE) + +-- MoveScreenLoop's .d_right / .d_left walk the party, not the page. +screen.moveDetail = true +input:press("right") +screen:update(0) +check("right in the move detail walks the party", screen.index, 2) +check("and repoints the move list", screen.moveIndex, 1) +detail = screen:moveDetailPlacements() +check("at the new mon's move", at(detail, 2, 3), "SURF") +check("and its description", at(detail, 1, 14), "A strong water-") + +-- ------------------------------------------------------------ mon submenu + +-- GetMonSubmenuItems: field moves first, then STATS, SWITCH, MOVE, ITEM, and +-- CANCEL only while the list is under NUM_MONMENU_ITEMS. +local game = newGame(SAVE) +local party = PartyMenu.new(game, { party = SAVE.party, submenu = true }) +local items = party:submenuItems(CYNDA) +local labels2 = {} +for i, entry in ipairs(items) do labels2[i] = entry.id end +check("no field moves means STATS is first", labels2[1], "STATS") +check("then SWITCH", labels2[2], "SWITCH") +check("then MOVE", labels2[3], "MOVE") +check("then ITEM", labels2[4], "ITEM") +check("then CANCEL", labels2[5], "CANCEL") +check("five rows in all", #items, 5) + +-- A mon with a field move gets it above the fixed rows, named after the move. +local cutter = mon("TOTODILE", 10, { + moves = { { id = "CUT", pp = 30, maxPp = 30 }, + { id = "SURF", pp = 15, maxPp = 15 } }, +}) +local cutterItems = party:submenuItems(cutter) +check("CUT leads the list", cutterItems[1].id, "CUT") +check("and is labelled with the move name", cutterItems[1].label, "CUT") +check("SURF follows it in table order", cutterItems[2].id, "SURF") +check("STATS comes after the field moves", cutterItems[3].id, "STATS") + +-- Mail replaces the ITEM row. +local mailed = mon("TOTODILE", 10, { fields = { item = "FLOWER_MAIL" } }) +local mailedItems = party:submenuItems(mailed) +check("a mail holder gets MAIL instead of ITEM", mailedItems[4].id, "MAIL") + +-- .GetTopCoord grows the box upward from a fixed bottom, and PopulateMonMenu +-- writes two rows and two columns inside the corner. +check("a 5-row submenu starts at row 6", PartyMenu.submenuTop(5), 6) +check("a 4-row submenu starts at row 8", PartyMenu.submenuTop(4), 8) +local lx, ly = PartyMenu.submenuLabelCoord(5, 1) +check("the first label's column", lx, 8) +check("the first label's row", ly, 8) +local _, ly5 = PartyMenu.submenuLabelCoord(5, 5) +check("the last of five labels sits on row 16", ly5, 16) + +-- A on the field list opens the submenu instead of answering; A on STATS +-- pushes the summary over the party list (OpenPartyStats). +local answered = nil +game = newGame(SAVE) +party = PartyMenu.new(game, { + party = SAVE.party, submenu = true, + onChoose = function(i) answered = i end, +}) +game.input:press("a") +party:update(0) +check("a opens the submenu", party.submenu ~= nil, true) +check("and does not answer yet", answered, nil) +check("the cursor starts on the first row", party.submenu.index, 1) +game.input:press("a") +party:update(0) +check("a on STATS closes the submenu", party.submenu, nil) +check("and pushes a screen", #game.stack._items, 1) +local pushed = game.stack:top() +check("which is the summary", pushed.page, SummaryMenu.PINK_PAGE) +check("opened on the chosen mon", pushed.mon.species, "CYNDAQUIL") + +-- B backs out of the submenu without touching the list (CancelPokemonAction). +game = newGame(SAVE) +party = PartyMenu.new(game, { party = SAVE.party, submenu = true }) +game.input:press("a") +party:update(0) +game.input:press("b") +party:update(0) +check("b closes the submenu", party.submenu, nil) +check("and leaves the row alone", party.index, 1) +check("and pushes nothing", #game.stack._items, 0) + +-- Every other flavour of the party list answers straight away, the way the +-- item and battle callers need it to. +answered = nil +game = newGame(SAVE) +party = PartyMenu.new(game, { + party = SAVE.party, prompt = "useItem", + onChoose = function(i) answered = i end, +}) +game.input:press("a") +party:update(0) +check("a list without the submenu answers directly", answered, 1) +check("and opens no submenu", party.submenu, nil) + +-- ------------------------------------------------------------- the EGG page +-- +-- EggStatsScreen's pic (engine/pokemon/stats_screen.asm:786) is EggPic, which +-- the extractor writes as menu_gfx.eggHatch.egg -- and every cache imported +-- before that stage has no such entry, which is the whole of "the summary +-- shows no egg picture": the 7x7 block was left empty and nothing said why. +-- ICON_EGG has been in icons.lua since the party list existed +-- (ReadMonMenuIcon's `cp EGG / jr z, .egg`, engine/gfx/mon_icons.asm), so it +-- stands in and the page is never blank. +do + local Assets = require("src.render.Assets") + local realImage = Assets.image + local realDraw = love.graphics.draw + local asked, drawn = {}, 0 + Assets.image = function(path) + asked[#asked + 1] = path + if path == "egg-pic.png" then + return { getWidth = function() return 40 end, + getHeight = function() return 40 end } + end + if path == "egg-icon.png" then + return { getWidth = function() return 16 end, + getHeight = function() return 32 end } + end + return nil + end + love.graphics.draw = function() drawn = drawn + 1 end + + local egg = { species = "CYNDAQUIL", nickname = "EGG", isEgg = true, + eggSteps = 20, level = 5, hp = 10, maxHp = 10 } + local icons = { icons = { ICON_EGG = { image = "egg-icon.png", frames = 2, + width = 16, height = 32 } } } + + -- A cache with EggPic in it draws EggPic and never looks at the icon. + local screen = SummaryMenu.new(newGame(SAVE), { mon = egg, save = SAVE, + menuGfx = { eggHatch = { egg = "egg-pic.png" } }, icons = icons }) + screen:drawEggPic() + check("the EGG page draws its pic", drawn, 1) + check("straight off menu_gfx.eggHatch.egg", asked[1], "egg-pic.png") + check("and asks for nothing else", #asked, 1) + + -- A cache from before that stage: the page still shows an egg. + asked, drawn = {}, 0 + screen = SummaryMenu.new(newGame(SAVE), { mon = egg, save = SAVE, + menuGfx = {}, icons = icons }) + screen:drawEggPic() + check("a cache with no EggPic still draws an egg", drawn, 1) + check("falling back to ICON_EGG", asked[#asked], "egg-icon.png") + + -- Neither: nothing is drawn and nothing raises. + asked, drawn = {}, 0 + screen = SummaryMenu.new(newGame(SAVE), { mon = egg, save = SAVE, + menuGfx = {}, icons = {} }) + local ok = pcall(function() screen:drawEggPic() end) + check("no egg art at all is survivable", ok, true) + check("and draws nothing", drawn, 0) + + Assets.image = realImage + love.graphics.draw = realDraw +end + +print(("gen2 summary: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_swarm_test.lua b/tests/gen2_swarm_test.lua new file mode 100644 index 00000000..b06690ed --- /dev/null +++ b/tests/gen2_swarm_test.lua @@ -0,0 +1,197 @@ +-- Pokemon swarms, WIRED. +-- +-- luajit tests/gen2_swarm_test.lua +-- +-- src/core/gen2/Roamers.lua has carried the whole swarm model for a while -- +-- StoreSwarmMapIndices / SetSwarmFlag / CheckSwarmFlag / ActivateFishingSwarm +-- and the _SwarmWildmonCheck lookup -- with three dead links: the daily clear +-- had no call site, the encounter roll never consulted the tables, and the +-- cache carried no tables to consult. This suite is those three. +-- +-- CheckSwarmFlag second row of CheckTimeEvents' `.do_daily` +-- (engine/overworld/events.asm) +-- _SwarmWildmonCheck the first thing LoadWildMonDataPointer does, for +-- the grass list and the water one alike +-- SwarmGrassWildMons / data/wild/swarm_grass.asm, swarm_water.asm, +-- SwarmWaterWildMons emitted by RomExtractorGen2 as +-- encounters.swarmGrass / .swarmWater +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 swarm") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") +local Roamers = require("src.core.gen2.Roamers") +local Swarm = Roamers.Swarm + +local COLL_TALL_GRASS = 0x18 + +local DATA = { + items = {}, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + pokemon = { + NIDORAN_M = { name = "NIDORAN", index = 32, types = { "POISON", "POISON" }, + baseStats = { hp = 46, attack = 57, defense = 40, speed = 50, + specialAttack = 40, specialDefense = 40 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + YANMA = { name = "YANMA", index = 193, types = { "BUG", "FLYING" }, + baseStats = { hp = 65, attack = 65, defense = 45, speed = 95, + specialAttack = 75, specialDefense = 45 }, + growthRate = 0, levelMoves = { { level = 1, move = "TACKLE" } } }, + }, +} + +local function slots(species, level) + local list = {} + for i = 1, 7 do list[i] = { species = species, level = level } end + return list +end + +-- ROUTE_35's own list against the Yanma swarm's, both always-hit so the only +-- question a step asks is WHICH table was searched. +local ENCOUNTERS = { + grass = { + ROUTE_35 = { map = "ROUTE_35", rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { MORN = slots("NIDORAN_M", 12), DAY = slots("NIDORAN_M", 12), + NITE = slots("NIDORAN_M", 12) } }, + }, + swarmGrass = { + ROUTE_35 = { map = "ROUTE_35", rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { MORN = slots("YANMA", 14), DAY = slots("YANMA", 14), + NITE = slots("YANMA", 14) } }, + }, +} + +local function swarmWorld() + local game = { + data = DATA, + save = { player = { name = "GOLD", badges = {} }, + party = { { species = "YANMA", level = 5, hp = 20, maxHp = 20 } }, + inventory = {} }, + } + local world = World.new(game) + game.world = world + world.maps = { ROUTE_35 = { id = "ROUTE_35", group = 2, map = 5, + width = 2, height = 2, blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, + environment = "ROUTE" }, + ROUTE_36 = { id = "ROUTE_36", group = 2, map = 6, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, environment = "ROUTE" } } + world.map = { id = "ROUTE_35", def = world.maps.ROUTE_35, + width = 2, height = 2, + cellCollision = function() return COLL_TALL_GRASS end } + world.encounters = ENCOUNTERS + world.player = { cellX = 0, cellY = 0 } + world.daytime = "DAY" + world.startBattle = function(self, opts) self.started = opts return true end + return world, game +end + +-- ---- the lookup arm -------------------------------------------------------- +do + local world, game = swarmWorld() + check(world:tryWildEncounter(), "an ordinary step on Route 35 fights") + eq(world.started.wild.species, "NIDORAN_M", "the map's own list") + + -- Script_swarm -> StoreSwarmMapIndices, which is World:setSwarm. + world:setSwarm(2, 5) + eq(game.save.swarmMap, "ROUTE_35", "the swarm command stores the map pair") + check(game.save.dailyFlags.swarm, "and falls through into SetSwarmFlag") + + world.started = nil + check(world:tryWildEncounter(), "the next step still fights") + eq(world.started.wild.species, "YANMA", + "but out of the swarm table, which _SwarmWildmonCheck searches first") + eq(world.started.wild.level, 14, "at the swarm row's own level") + + -- The check is `cp d / cp e` against the CURRENT map: a swarm on Route 35 + -- does nothing to Route 36. + world.map = { id = "ROUTE_36", def = world.maps.ROUTE_36, + width = 2, height = 2, + cellCollision = function() return COLL_TALL_GRASS end } + world.started = nil + check(not world:tryWildEncounter(), + "a map the swarm is not on falls through to its own (absent) table") + check(world.started == nil, "so nothing is fought there") +end + +-- SWEET SCENT reaches the same ChooseWildEncounter, so it sees the swarm too. +do + local world = swarmWorld() + world:setSwarm(2, 5) + check(world:sweetScentEncounter(), "SWEET SCENT turns something up") + eq(world.started.wild.species, "YANMA", "and it is the swarm's mon") +end + +-- ---- the daily clear ------------------------------------------------------- +-- +-- CheckTimeEvents `.do_daily` is CheckDailyResetTimer, CheckSwarmFlag, +-- CheckPokerusTick, CheckPhoneCall in that order. The reset takes +-- DAILYFLAGS1_SWARM down; CheckSwarmFlag is what notices and clears the map +-- pair with it, and it is the ONLY thing that ever ends a swarm. +do + local world, game = swarmWorld() + world:setSwarm(2, 5) + -- A day already banked, so the first poll is not the one that starts the + -- timer (Apricorns.checkDailyResetTimer arms it on a save that has none). + world:checkTimeEvents() + check(game.save.swarmMap == "ROUTE_35", + "a poll on the same day leaves the swarm up") + check(Swarm.active(game.save), "the flag is still set") + + -- Roll the clock over the way the daily reset timer measures it. + game.save.dailyReset.day = (game.save.dailyReset.day or 0) - 2 + world:checkTimeEvents() + check(not Swarm.active(game.save), "the daily reset drops the flag") + check(game.save.swarmMap == nil, "and CheckSwarmFlag clears the map pair") + + world.started = nil + check(world:tryWildEncounter(), "the step after that still fights") + eq(world.started.wild.species, "NIDORAN_M", "out of the map's own list again") +end + +-- ActivateFishingSwarm rides the same flag and does NOT touch the map pair, +-- so the clear has to take both down together. +do + local world, game = swarmWorld() + world:setSwarm(2, 5) + Swarm.setFishing(game.save, Swarm.FISH_QWILFISH) + eq(Swarm.fishing(game.save), Swarm.FISH_QWILFISH, "the fishing swarm is up") + world:checkTimeEvents() -- arms the timer, the way a save's first step does + game.save.dailyReset.day = (game.save.dailyReset.day or 0) - 2 + world:checkTimeEvents() + eq(Swarm.fishing(game.save), Swarm.FISH_NONE, + "and goes down on the same daily tick") +end + +-- ---- the extracted tables -------------------------------------------------- +-- +-- data/wild/swarm_grass.asm has four rows and swarm_water.asm one; the Yanma +-- swarm on Route 35 is the one the Dunsparce/Yanma phone calls arm. +do + local cache = os.getenv("GOLD_CACHE") + local encounters + if cache then + local chunk = loadfile(cache .. "/data/generated/encounters.lua") + encounters = chunk and chunk() + end + if not (encounters and encounters.swarmGrass) then + check(true, "no gold cache: swarm table shape (SKIP)") + else + local grass = encounters.swarmGrass + check(grass.ROUTE_35 ~= nil, "SwarmGrassWildMons carries ROUTE_35") + check(grass.ROUTE_38 ~= nil, "and ROUTE_38") + check(grass.DARK_CAVE_VIOLET_ENTRANCE ~= nil, + "and DARK_CAVE_VIOLET_ENTRANCE, which is the Dunsparce swarm") + check(grass.MOUNT_MORTAR_1F_OUTSIDE ~= nil, "and MOUNT_MORTAR_1F_OUTSIDE") + eq(grass.ROUTE_35.rates.DAY, 25, "Route 35's swarm rate is 10 percent") + eq(grass.ROUTE_35.slots.DAY[3].species, "YANMA", + "and slot 3 of its day list is the YANMA the swarm exists for") + local water = encounters.swarmWater or {} + check(water.MOUNT_MORTAR_1F_OUTSIDE ~= nil, + "SwarmWaterWildMons carries the one Marill row") + eq(water.MOUNT_MORTAR_1F_OUTSIDE.slots[2].species, "MARILL", + "with MARILL in its middle slot") + end +end + +S.finish() diff --git a/tests/gen2_temp_events_test.lua b/tests/gen2_temp_events_test.lua new file mode 100644 index 00000000..25ea9fc8 --- /dev/null +++ b/tests/gen2_temp_events_test.lua @@ -0,0 +1,118 @@ +-- EVENT_TEMPORARY_UNTIL_MAP_RELOAD: HandleNewMap calls +-- ResetMapBufferEventFlags on every map load (pokegold home/map.asm), and +-- home/flag.asm shows that routine zeroing exactly ONE byte of wEventFlags -- +-- flags 0-7. Those eight are the once-per-visit latches: Bill's grandpa sets +-- flag 0 after handing over an evolution stone and refuses while it is set, +-- so a port that never clears the byte caps his whole chain at one stone per +-- save. Kurt's house, the ship ports, Dragon's Den B1F, the National Park +-- gate, Pokecenter 2F and the link rooms ride the same byte. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_temp_events_test.lua +-- +-- The clear and its ordering are ROM-free; the Bill's-house shape SKIPs +-- without a cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 temp events") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") +local Map = require("src.world.gen2.Map") + +-- ---- the byte itself ------------------------------------------------------- +do + local events = Events.new() + for id = 0, 9 do events:set(id, true) end + events:set(800, true) + events:resetMapBuffer() + for id = 0, 7 do + check(not events:get(id), ("flag %d dies with the map"):format(id)) + end + check(events:get(8), "flag 8 is the next byte and survives") + check(events:get(9), "so does flag 9") + check(events:get(800), "and a story flag is untouched") +end + +-- ---- the clear rides setMap, BEFORE MAPCALLBACK_NEWMAP --------------------- +-- The callback order test rig from gen2_map_callbacks_test: real setMap, the +-- love-facing bakes stubbed out. The NEWMAP body itself checks flag 0, which +-- pins the order -- a clear that ran after the callback would read 1 here. +do + local world = World.new({ data = {}, save = { party = {}, inventory = {} } }) + world.maps = { + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, tileset = "TEST", + callbacks = { + { callback = "MAPCALLBACK_NEWMAP", scriptKey = "newmap" }, + } }, + } + world.tilesets = { TEST = {} } + world.map = Map.new(world.maps.TEST_MAP, {}) + world.scripts = { + newmap = { + { op = "checkevent", event = 0 }, + { op = "iftrue", script = "sawset" }, + { op = "setevent", event = 600 }, + { op = "endcallback" }, + }, + sawset = { { op = "setevent", event = 601 }, { op = "endcallback" } }, + } + world.vm = Vm.new(world.scripts, {}, world.events, {}) + world.imageFor = function() return true end + world.rebuildNeighbors = function() end + world.rebuildPeople = function() end + world.applyPalettes = function() end + + for id = 0, 8 do world.events:set(id, true) end + check(world:setMap("TEST_MAP", 0, 0, "down"), "the map loads") + for id = 0, 7 do + check(not world.events:get(id), + ("setMap cleared temporary flag %d"):format(id)) + end + check(world.events:get(8), "and left flag 8 alone") + check(world.events:get(600), "the NEWMAP callback saw flag 0 already clear") + check(not world.events:get(601), + "so the clear really runs before MAPCALLBACK_NEWMAP") +end + +-- ---- the cache shape this re-arms ------------------------------------------ +-- BILLS_HOUSE (maps/BillsFamilysHouse.asm): the script head refuses while +-- event 0 is set, and every stone branch ends `setevent 800+n / setevent 0`. +-- With flag 0 dying on the reload above, leaving and re-entering the house +-- takes the head past its refusal to the next stone. +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 chunk = loadfile(cache .. "/data/generated/scripts.lua") +if not chunk then + check(true, "gold cache absent (SKIP Bill's-house shape)") + S.finish() + return +end +local scripts = chunk() + +local head = scripts["54:547c"] +check(type(head) == "table", "the grandpa's script head extracted") +if type(head) == "table" then + eq(head[3] and head[3].op, "checkevent", "row 3 is a checkevent") + eq(head[3] and head[3].event, 0, "over temporary flag 0") + eq(head[4] and head[4].op, "iftrue", "and iftrue is the refusal branch") +end +for n, key in ipairs({ "54:557f", "54:5596", "54:55ad", "54:55c4" }) do + local rows = scripts[key] + local latch, story + for _, row in ipairs(rows or {}) do + if row.op == "setevent" and row.event == 0 then latch = true end + if row.op == "setevent" and row.event == 799 + n then story = true end + end + check(story, ("stone branch %d marks its stone given (%d)"):format(n, 799 + n)) + check(latch, ("and latches temporary flag 0 (%s)"):format(key)) +end + +S.finish() diff --git a/tests/gen2_text_flow_test.lua b/tests/gen2_text_flow_test.lua new file mode 100644 index 00000000..9ae3ffa8 --- /dev/null +++ b/tests/gen2_text_flow_test.lua @@ -0,0 +1,433 @@ +-- Script text flow around the intro stretch: Mom's clock ladder confirms in +-- text (InitialSetDSTFlag / InitialClearDSTFlag, engine/rtc/timeset.asm), the +-- NameRival special parks the officer until the keyboard closes +-- (engine/events/specials.asm NameRival), <RIVAL> resolves off the Gold save, +-- Mom's leaving speech keeps the cart's page waits (data/text/common_1.asm +-- _MomLeavingText1), and a stale wStringBuffer2 never leaks into a complete +-- text (the "Obtained the POKeDEX!PSNCUREBERRY!" splice). The catching +-- tutorial's map-music restore and the Sprout Tower scene's sfx ids ride +-- along: same lane, same seams. +-- +-- GOLD_CACHE=".../gold" luajit tests/gen2_text_flow_test.lua +-- +-- Everything is ROM-free except the Sprout Tower row pins, which SKIP. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 text flow") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local TextBox = require("src.render.TextBox") + +-- ---- the DST ladder confirms in text --------------------------------------- +-- PlayersHouse1F's MeetMomScript: writetext IsItDSTText / yesorno / special / +-- yesorno / iffalse back to the top. The special prints "<time> DST, is that +-- OK?" (or the plain time for the clear arm) into the open box, so the second +-- yesorno has its own question; silent handlers left it hanging on IsItDST, +-- which read as Mom asking about daylight saving over and over. +local function dstVm(record) + local shown = {} + local answers, ai = {}, 0 + local vm = Vm.new({ + ["s:ladder"] = { + { op = "writetext", text = "t:isdst" }, + { op = "yesorno" }, + { op = "iffalse", script = "s:wrong" }, + { op = "special", id = 0 }, -- InitialSetDSTFlag + { op = "yesorno" }, + { op = "iffalse", script = "s:ladder" }, + { op = "end" }, + }, + ["s:wrong"] = { + { op = "special", id = 1 }, -- InitialClearDSTFlag + { op = "yesorno" }, + { op = "iffalse", script = "s:ladder" }, + { op = "end" }, + }, + }, { ["t:isdst"] = "Is it Daylight\nSaving Time now?" }, Events.new(), { + specialOrder = { "InitialSetDSTFlag", "InitialClearDSTFlag" }, + specials = { + save = function() return record end, + world = { hour = function() return 10 end }, + }, + showText = function(body, onDone) + shown[#shown + 1] = body + onDone() + end, + yesorno = function(onDone) + ai = ai + 1 + onDone(answers[ai]) + end, + }) + return vm, shown, answers +end + +do + local record = {} + local vm, shown, answers = dstVm(record) + answers[1], answers[2] = true, true -- yes it is DST, yes that is OK + check(vm:start("s:ladder"), "the ladder runs") + for _ = 1, 10 do vm:update() end + check(not vm:running(), "and settles on a double yes") + eq(#shown, 2, "two pages: the question and the confirmation") + -- The minutes are the host clock's, so the pin is a pattern. + check(shown[2]:match("^10:%d%d DST,\nis that OK%?$") ~= nil, + "the set arm prints the time and .DSTIsThatOKText") + eq(record.rtc and record.rtc.dst, true, "and DST_F is set") +end + +do + local record = {} + local vm, shown, answers = dstVm(record) + -- no it is not DST, no that is not OK (loop), no again, yes that is OK + answers[1], answers[2] = false, false + answers[3], answers[4] = false, true + check(vm:start("s:ladder"), "the ladder runs again") + for _ = 1, 20 do vm:update() end + check(not vm:running(), "a refused confirmation loops and then settles") + eq(#shown, 4, "question, confirm, question again, confirm again") + check(shown[2]:match("^10:%d%d,\nis that OK%?$") ~= nil, + "the clear arm prints the time and .TimeAskOkayText") + eq(shown[3], shown[1], "the loop re-asks the cart's own question") + eq(record.rtc and record.rtc.dst, false, "and DST_F ends clear") +end + +-- ---- NameRival parks the officer ------------------------------------------- +do + local shown = {} + local nameDone = nil + local vm = Vm.new({ + ["s:cop"] = { + { op = "opentext" }, + { op = "writetext", text = "t:one" }, + { op = "promptbutton" }, + { op = "special", id = 0 }, -- NameRival + { op = "writetext", text = "t:two" }, + { op = "closetext" }, + { op = "end" }, + }, + }, { + ["t:one"] = "His POKeMON was one of ours.", + ["t:two"] = "OK! So {RIVAL}\nwas his name.", + }, Events.new(), { + specialOrder = { "NameRival" }, + nameRival = function(done) nameDone = done end, + showText = function(body, onDone) + shown[#shown + 1] = body + onDone() + end, + }) + check(vm:start("s:cop"), "the officer's script starts") + for _ = 1, 6 do vm:update() end + check(vm:running(), "the script is PARKED on the naming screen") + eq(#shown, 1, "the follow-up line has not printed under the keyboard") + check(nameDone ~= nil, "the naming screen was opened") + nameDone("KAMON") + for _ = 1, 6 do vm:update() end + check(not vm:running(), "the close of the keyboard resumes the officer") + eq(shown[2], "OK! So {RIVAL}\nwas his name.", + "and only then does his line print") +end + +-- ---- World:nameRival stores the typed name, and empty keeps the default ---- +do + local World = require("src.world.gen2.World") + local Screens = require("src.ui.Screens") + local function rig() + local pushed = {} + local game = { + data = { screens = { + Gen2NamingScreen = function(_, opts) + pushed.opts = opts + return { opts = opts } + end, + } }, + save = { player = { name = "GOLD" }, rival = { name = "SILVER" } }, + stack = { push = function() end, pop = function() end }, + } + return World.new(game), game, pushed + end + + local world, game, pushed = rig() + local got = nil + world:nameRival(function(name) got = name end) + check(pushed.opts ~= nil, "the keyboard screen went up") + pushed.opts.onDone("KAMON") + eq(game.save.rival.name, "KAMON", "the typed name lands in save.rival.name") + eq(got, "KAMON", "and the script's resume sees it") + + Screens.invalidate() + local world2, game2, pushed2 = rig() + world2:nameRival(function() end) + pushed2.opts.onDone("") + eq(game2.save.rival.name, "SILVER", + "an empty entry keeps InitName's version default") + Screens.invalidate() + + -- The rig above hand-seeds "SILVER", which the real New Game never does: + -- InitializeNPCNames seeds "???" and NameRival's InitName is what puts + -- SILVER there. Both flavours of blank -- empty and all spaces, which + -- _InitString treats identically (home/string.asm:6-30) -- have to write + -- the default themselves. + for _, entry in ipairs({ "", " " }) do + local world3, game3, pushed3 = rig() + game3.save.rival = { name = "???" } + world3:nameRival(function() end) + pushed3.opts.onDone(entry) + eq(game3.save.rival.name, "SILVER", + ("a blank entry (%q) over the \"???\" seed still lands on SILVER") + :format(entry)) + Screens.invalidate() + end +end + +-- ---- <RIVAL> resolves off the Gold save ------------------------------------ +do + local gold = { data = {}, + save = { player = { name = "GOLD" }, rival = { name = "KAMON" } } } + eq(TextBox.substitute(gold, "So {RIVAL} it is."), "So KAMON it is.", + "a Gold save's rival name feeds the token") + local red = { data = {}, save = { player = { name = "RED", rival = "GARY" } } } + eq(TextBox.substitute(red, "{RIVAL}!"), "GARY!", + "a Gen 1 save still reads player.rival") + local bare = { data = {}, save = { player = {} } } + eq(TextBox.substitute(bare, "{RIVAL}"), "BLUE", + "only a save with neither falls back to BLUE") + local goldBare = { data = {}, save = { generation = 2, player = {} } } + eq(TextBox.substitute(goldBare, "{RIVAL}"), "???", + "a Gold save with no rival record reads InitializeNPCNames' \"???\", not BLUE") +end + +-- ---- the yes/no lookahead reaches the text hook ----------------------------- +-- Script_yesorno is `call YesNoBox` with nothing between it and the page it +-- prompts over (engine/overworld/scripting.asm:366), and a `writetext` whose +-- next row is `yesorno` ends in `done` -- DoneText returns with no +-- PromptButton (home/text.asm:484). So the VM hands the one-command lookahead +-- to the text hook as its THIRD argument, and World's hook has to take it: +-- declared with two parameters it was silently dropped, which cost a button +-- press the cart never asks for and re-printed the question under the prompt. +do + local function stayFor(after) + local seen + local vm = Vm.new({ + ["s:ask"] = { + { op = "writetext", text = "t:q" }, + { op = after }, + { op = "end" }, + }, + }, { ["t:q"] = "Would you like me\nto show you how?" }, Events.new(), { + showText = function(_body, onDone, stay) + seen = stay + onDone() + end, + yesorno = function(onChoose) onChoose(true) end, + }) + vm:start("s:ask") + for _ = 1, 200 do + if not vm:running() then break end + vm:update() + end + return seen + end + eq(stayFor("yesorno"), true, "a writetext in front of yesorno holds its box") + eq(stayFor("waitbutton"), false, "one in front of waitbutton does not") +end + +-- ---- Mom's leaving speech keeps its page waits ------------------------------ +-- _MomLeavingText1 is nine `para` pages on the cart; transcribed as one page +-- of stacked lines it typed itself out to the money prompt with no button in +-- between. The handler is driven for real and the FIRST page it shows is +-- paginated with the shipped TextBox rules. +do + local rec = { player = { money = 3000 }, mom = { savedMoney = 0 } } + local texts, stays = {}, {} + local vm = Vm.new({}, {}, Events.new(), { + specials = { + save = function() return rec end, + money = function() return 0 end, + setMoney = function() end, + }, + }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.BankOfMom(vm) end) + local ok, req = coroutine.resume(vm.co) + while true do + if not ok then error(req) end + if req and req.kind == "text" then + texts[#texts + 1] = req.text + stays[#texts] = req.stay + end + if coroutine.status(vm.co) == "dead" then break end + ok, req = coroutine.resume(vm.co, req and req.kind == "yesorno" or nil) + end + check(#texts >= 3, "the first-visit conversation ran") + -- .InitializeBank is `PrintText MomLeavingText1 / call YesNoBox` + -- (engine/events/mom.asm:50-53) with nothing between the two, and the text + -- ends `done` -- so the box stays up and the prompt goes over it. Vm's + -- one-command lookahead cannot see this: the script row being run is the + -- `special` itself, so the handler says it (Specials showRawHeld). + check(stays[1] == true, "the page in front of Mom's prompt holds its box") + check(not stays[2], "the page behind it, with no prompt after, does not") + local pages = TextBox.paginate(texts[1], 18) + eq(#pages, 9, "MomLeavingText1 is nine pages, one per cart `para`") + for index, page in ipairs(pages) do + check(#page <= 2, + ("page %d holds at most the box's two lines"):format(index)) + end + eq(pages[1][1], "Wow, that's a cute", "page one is the cart's opener") + eq(pages[9][2], "save your money?", + "and the money question is the last page, behind eight button waits") +end + +-- ---- a stale string buffer stays out of complete texts --------------------- +-- CopyName1 never clears wStringBuffer2, so the berry picked outside is still +-- in it when Oak hands over the POKeDEX; only a {STRBUF} marker may read it. +do + local shown = {} + local vm = Vm.new({ + ["s:oak"] = { + { op = "getitemname", item = 77 }, + { op = "writetext", text = "t:dex" }, + { op = "writetext", text = "t:buf" }, + { op = "end" }, + }, + }, { + ["t:dex"] = "{PLAYER} received\nPOKeDEX!", + ["t:buf"] = "{PLAYER} received\n{STRBUF}.", + }, Events.new(), { + showText = function(body, onDone) + shown[#shown + 1] = body + onDone() + end, + getItemName = function() return "PSNCUREBERRY" end, + }) + check(vm:start("s:oak"), "Oak's handover runs") + for _ = 1, 6 do vm:update() end + eq(shown[1], "{PLAYER} received\nPOKeDEX!", + "a complete text prints untouched, stale buffer and all") + check(shown[1]:find("PSNCUREBERRY", 1, true) == nil, + "the berry name cannot splice itself in") + eq(shown[2], "{PLAYER} received\nPSNCUREBERRY.", + "while a real {STRBUF} marker still reads the buffer") +end + +-- ---- the catching tutorial gives the route its music back ------------------- +-- CatchTutorial wraps an ordinary battle, and startBattle's onDone runs +-- RestartMapMusic (Script_reloadmapafterbattle) -- so when the DUDE's demo +-- ends, wMapMusic (Route 29's theme) comes back without a map change. Real +-- Music module, real startBattle; only the two battle screens are registry +-- fakes and love.audio is a stub source factory. +do + local World = require("src.world.gen2.World") + local Screens = require("src.ui.Screens") + local Music = require("src.core.Music") + + local function fakeSource() + return { + setLooping = function() end, setVolume = function() end, + setFilter = function() end, play = function() end, + stop = function() end, pause = function() end, + isPlaying = function() return false end, + } + end + love.audio = { newSource = function() return fakeSource() end } + + local battleDone = nil + local game + game = { + data = { + pokemon = { RATTATA = { name = "RATTATA", index = 21, + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, + levelMoves = { { level = 1, move = "TACKLE" } } } }, + moves = { TACKLE = { name = "TACKLE", pp = 35, power = 40, + type = "NORMAL", accuracy = 255 } }, + items = {}, + audio = { runtime = true, + songs = { Music_Route29 = { file = "route29.ogg" }, + Music_JohtoWildBattle = { file = "wild.ogg" } }, + mapSongs = { ROUTE_29 = "Music_Route29" } }, + screens = { + Gen2BattleTransition = function(_, opts) + -- The wipe finishes instantly here; onDone pushes the battle. + opts.onDone() + return {} + end, + Gen2BattleState = function(_, opts) + battleDone = opts.onDone + return {} + end, + }, + }, + save = { player = { name = "GOLD" }, mom = { name = "MOM" }, + party = {}, inventory = {} }, + options = {}, + stack = { push = function() end, pop = function() end }, + } + local world = World.new(game) + game.world = world + world.map = { id = "ROUTE_29", def = { environment = "ROUTE" } } + world.daytime = "DAY" + + Music.playMap(game.data, "ROUTE_29") + eq(Music.current(), "Music_Route29", "Route 29's theme is playing") + + local finished = false + check(world:startCatchTutorial({ species = 21, level = 5 }, 3, function() + finished = true + end), "the DUDE's demo battle opens") + eq(Music.current(), "Music_JohtoWildBattle", + "the battle theme took the channels") + check(battleDone ~= nil, "the battle screen is up") + battleDone("win") + check(finished, "the tutorial handed the script back") + eq(Music.current(), "Music_Route29", + "and Route 29's theme resumed in place, no map change needed") + + Music.stop() + love.audio = nil + Screens.invalidate() +end + +-- ---- the Sprout Tower scene's sfx are the cart's --------------------------- +-- maps/SproutTower3F.asm really does play SFX_TACKLE + SFX_ELEVATOR twice +-- while the great pillar sways (that IS the elevator rumble), and Silver's +-- Escape Rope exit is SFX_WARP_TO. Pin the extracted rows to the labels so a +-- repointed sfx table cannot quietly swap the cues. +do + 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 scripts = (function() + local chunk = loadfile(cache .. "/data/generated/scripts.lua") + return chunk and chunk() + end)() + local audio = (function() + local chunk = loadfile(cache .. "/data/generated/audio.lua") + return chunk and chunk() + end)() + if not (scripts and audio and audio.sfxOrder) then + check(true, "gold cache absent (SKIP Sprout Tower sfx pins)") + else + local rows = scripts["42:444b"] + check(type(rows) == "table", "the rival scene extracted") + local sounds = {} + for _, row in ipairs(rows or {}) do + if row.op == "playsound" then + sounds[#sounds + 1] = audio.sfxOrder[(row.id or 0) + 1] + end + end + eq(table.concat(sounds, ","), + "Sfx_Tackle,Sfx_Elevator,Sfx_Tackle,Sfx_Elevator,Sfx_WarpTo", + "tackle + elevator sway twice, then the Escape Rope warp") + end +end + +S.finish() diff --git a/tests/gen2_time_routing_test.lua b/tests/gen2_time_routing_test.lua new file mode 100644 index 00000000..8d8944fc --- /dev/null +++ b/tests/gen2_time_routing_test.lua @@ -0,0 +1,315 @@ +-- One clock, read everywhere. +-- +-- The cart has exactly one time: UpdateTime (home/time.asm) reads the RTC, +-- FixTime adds the save's wStartHour / wStartMinute base, and hHours is what +-- GetTimeOfDay, CheckObjectTime, VAR_HOUR, the Pokegear card and the intro +-- menu's clock box all read back. src/core/gen2/Clock.lua is that base and +-- World:hour is that read, so nothing may reach around either of them to +-- os.date -- a port with two clocks paints a daylit map while the hour-window +-- NPCs have already gone home. +-- +-- This file pins the four seams that read the hour: +-- World:applyPalettes / the daytime the map is lit by +-- World:pollTimeOfDay's hour-window respawn (and its busy() retry) +-- MainMenu / Pokegear clockParts +-- the New Game anchor, so an unanchored save cannot exist in play +-- +-- setMap's HandleNewMap vs HandleContinueMap split rides here too: it is the +-- other half of "which map load is this", decided in the same World:load. +-- +-- luajit tests/gen2_time_routing_test.lua +-- +-- ROM-free: the maps are fixtures written into the love stub's filesystem. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 time routing") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +-- No font is loaded here, so Font.encode would warn per unknown glyph. +require("src.core.Logger").warn = function() end + +local Clock = require("src.core.gen2.Clock") +local MainMenu = require("src.ui.gen2.MainMenu") +local Palettes = require("src.world.gen2.Palettes") +local Pokegear = require("src.ui.gen2.Pokegear") +local Save = require("src.core.gen2.Save") +local Vm = require("src.script.gen2.Vm") +local World = require("src.world.gen2.World") + +-- An hour whose daytime is NOT the host's, so "the palette followed the game +-- clock" cannot pass by accident on a machine that happens to agree. +local HOST_DAYTIME = Palettes.clockDaytime() +local function hourUnlikeHost() + for _, hour in ipairs({ 2, 7, 13, 21 }) do + if Palettes.clockDaytime(hour) ~= HOST_DAYTIME then return hour end + end + return 2 +end + +local function worldWithSave(save) + local world = World.new({ data = {}, save = save }) + world.map = { id = "TEST_MAP", + def = { id = "TEST_MAP", palette = "PALETTE_AUTO", environment = "TOWN", + group = 1 } } + return world +end + +-- ---- the palette follows the GAME clock ------------------------------------ +do + local save = {} + local hour = hourUnlikeHost() + Clock.setTime(save, hour, 0) + local world = worldWithSave(save) + + eq(world:hour(), hour, "World:hour reads the base the player set") + world:applyPalettes() + eq(world.daytime, Palettes.clockDaytime(hour), + "and the map is lit by that hour, not by the host clock") + check(world.daytime ~= HOST_DAYTIME, + "which is a different daytime from the host's right now") + -- wTimeOfDay and wObjectMasks' MORN/DAY/NITE bit are the same read, so + -- VAR_TIMEOFDAY and CheckObjectTime cannot disagree with the light. + eq(Palettes.DAYTIME_ID[world.daytime] - 1, world:timeOfDayId(), + "VAR_TIMEOFDAY answers the same daytime") + local MASK = { MORN = 1, DAY = 2, NITE = 4, DARK = 4 } + eq(world:clockTimeMask(), MASK[world.daytime], + "and the object hour mask agrees with it") + + -- Walk both directions across the TimesOfDay rows through the real base. + for _, pair in ipairs({ { 2, "NITE" }, { 6, "MORN" }, { 13, "DAY" }, + { 19, "NITE" } }) do + Clock.setTime(save, pair[1], 0) + world:applyPalettes() + eq(world.daytime, pair[2], + ("%02d:00 on the game clock lights the map %s"):format(pair[1], pair[2])) + end +end + +-- POKEPORT_GOLD_HOUR still wins outright: World.clockHour is the pin, and it +-- has to move the palette AND the hour windows together. +do + local save = {} + Clock.setTime(save, 13, 0) + local world = worldWithSave(save) + world.clockHour = 21 + world:applyPalettes() + eq(world:hour(), 21, "the pinned hour is what World:hour answers") + eq(world.daytime, "NITE", "and the pin lights the map, not the stored base") + eq(world:clockTimeMask(), 4, "with the NITE object mask to match") +end + +-- A save with no base at all is the pre-Clock file: it reads the host clock +-- straight through, and the palette still comes off that same read. +do + local world = worldWithSave({}) + check(not Clock.isSet({}), "an unanchored save has no base") + world:applyPalettes() + eq(world.daytime, HOST_DAYTIME, "so it is lit by the host clock") +end + +-- ---- the hour-window respawn is not eaten by a busy frame ------------------- +-- UpdateTimePals runs every second; the port rides that poll to redo what a +-- map load would (wObjectMasks). A rollover that lands on a busy frame has to +-- wait for a free one, because it is the only edge that hour has. +do + local world = worldWithSave({}) + world.clockHour = 9 + world.applyPalettes = function() return false end + local rebuilds = 0 + world.rebuildPeople = function() rebuilds = rebuilds + 1 end + + local function poll(times) + for _ = 1, (times or 1) * 60 do world:pollTimeOfDay() end + end + + poll(1) + eq(world.lastMaskHour, 9, "the first poll arms the latch on 09:00") + eq(rebuilds, 0, "and rebuilds nothing") + + -- A text box is up when the hour rolls: World:busy is true. + world.clockHour = 10 + world.textbox = {} + poll(5) + eq(rebuilds, 0, "a rollover under a text box rebuilds nothing yet") + eq(world.lastMaskHour, 9, + "and the latch stays on 09:00 so the edge is not consumed") + + world.textbox = nil + poll(1) + eq(rebuilds, 1, "the first free poll does the rebuild") + eq(world.lastMaskHour, 10, "and only then advances the latch") + poll(3) + eq(rebuilds, 1, "later polls in the same hour do nothing") +end + +-- ---- HandleNewMap vs HandleContinueMap -------------------------------------- +-- data/maps/setup_scripts.asm: every setup script runs HandleNewMap except +-- MapSetupScript_Continue, which enters at HandleContinueMap -- one label below +-- ResetMapBufferEventFlags (home/map.asm:216-228). Flags 0-7 live in SRAM, so +-- continuing a file keeps whatever the save was holding. +local function bufferWorld() + local world = World.new({ data = {}, save = { party = {}, inventory = {} } }) + world.maps = { + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, tileset = "TEST" }, + } + world.tilesets = { TEST = {} } + world.scripts = {} + world.vm = Vm.new(world.scripts, {}, world.events, {}) + world.imageFor = function() return true end + world.rebuildNeighbors = function() end + world.rebuildPeople = function() end + world.applyPalettes = function() end + for id = 0, 8 do world.events:set(id, true) end + return world +end + +do + local world = bufferWorld() + check(world:setMap("TEST_MAP", 0, 0, "down", { continue = true }), + "the continue load reaches the map") + for id = 0, 7 do + check(world.events:get(id), + ("continuing keeps temporary flag %d"):format(id)) + end +end + +do + local world = bufferWorld() + check(world:setMap("TEST_MAP", 0, 0, "down"), "an ordinary load reaches it") + for id = 0, 7 do + check(not world.events:get(id), + ("a new map load still clears temporary flag %d"):format(id)) + end + check(world.events:get(8), "and leaves flag 8, the next byte, alone") +end + +-- Which of the two World:load picks, driven through the real World:load with +-- the cache faked into the love stub's filesystem. +do + love.filesystem.write("data/generated/maps.lua", [[ +return { + PLAYERS_HOUSE_2F = { id = "PLAYERS_HOUSE_2F", group = 1, map = 1, width = 2, + height = 2, blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, + tileset = "TEST" }, + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {}, tileset = "TEST" }, +} +]]) + love.filesystem.write("data/generated/tilesets.lua", "return { TEST = {} }") + love.filesystem.write("data/generated/landmarks.lua", [[ +return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } } +]]) + + local function entryMethod(save) + local world = World.new({ data = {}, save = save }) + local seen + world.setMap = function(_self, mapId, _x, _y, _facing, opts) + seen = opts or {} + world.map = { id = mapId } + return true + end + check(world:load(), "World:load reaches setMap (" .. + tostring(world.status) .. ")") + return seen and seen.continue and true or false + end + + check(entryMethod({ party = {}, inventory = {}, + position = { map = "TEST_MAP", x = 1, y = 1, facing = "down" } }), + "a file with a recorded position loads as MAPSETUP_CONTINUE") + check(not entryMethod({ party = {}, inventory = {} }), + "a New Game is the SPAWN_HOME warp, so it takes HandleNewMap") + -- SpawnAfterE4 / PostCreditsSpawn set MAPSETUP_WARP, not CONTINUE. + check(not entryMethod({ party = {}, inventory = {}, + spawnAfterChampion = "SPAWN_LANCE", + position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }), + "and the post-credits spawn is a warp even though a position exists") +end + +-- ---- the two clock faces ---------------------------------------------------- +-- MainMenu_PrintCurrentTimeAndDay's .PlaceTime and the Pokegear's clock card +-- both read hHours after UpdateTime, so both are the game clock. +do + local save = Save.newGame() + Clock.setTime(save, 3, 15) + Clock.setWeekday(save, 3) + + local menu = MainMenu.new({ data = {} }, { save = save, hasSave = true }) + local hour, minute, weekday = menu:clockParts() + eq(hour, 3, "the intro menu's clock box reads the save's base") + eq(minute, 15, "minutes and all") + eq(MainMenu.DAYS[weekday], "WEDNESDAY", "and the weekday it was set to") + + local gear = Pokegear.new({ data = {}, save = save }, { save = save }) + local gearHour, gearMinute, gearDay = gear:clockParts() + eq(gearHour, 3, "the Pokegear clock card reads the same base") + eq(gearMinute, 15, "minutes and all") + eq(gearDay, weekday, "and the same weekday as the intro menu") + -- GetWeekday is SUNDAY 0, which is what the radio's TextCommand_DAY wants. + eq(gear:radioWeekday(), 3, "and the radio hears WEDNESDAY, not the host's day") +end + +-- Opened over a live world, the gear shows exactly what the overworld is on -- +-- including a driver's POKEPORT_GOLD_HOUR pin. +do + local save = Save.newGame() + Clock.setTime(save, 3, 15) + local world = worldWithSave(save) + world.clockHour = 21 + world.clockDay = 5 + local gear = Pokegear.new({ data = {}, save = save, world = world }, + { save = save }) + local hour, _, weekday = gear:clockParts() + eq(hour, 21, "the gear reads the world's pinned hour") + eq(weekday, 6, "and its pinned day, 1-based for the DAYS table") + eq(gear:radioWeekday(), 5, "with the radio back on GetWeekday's numbering") +end + +-- An explicit opts.clock still pins both faces outright (driver screenshots). +do + local pinned = MainMenu.new({ data = {} }, + { hasSave = false, save = false, clock = { hour = 0, minute = 0, + weekday = 1 } }) + local hour, minute, weekday = pinned:clockParts() + eq(hour, 0, "a pinned clock box keeps its hour") + eq(minute, 0, "its minutes") + eq(MainMenu.DAYS[weekday], "SUNDAY", "and its day") +end + +-- ---- New Game anchors the clock in every run mode -------------------------- +-- engine/menus/intro_menu.asm NewGame -> OakSpeech -> `farcall InitClock`, so +-- there is no such thing as a new game whose base is unset. A driver that +-- skips the cinema never reaches that screen, and an unanchored save reads the +-- host clock: the same run is MORN in the morning and NITE at night, which +-- changes which mon a patch of grass rolls. +do + local fresh = Save.newGame() + check(not Clock.isSet(fresh), "Save.newGame itself writes no base") + + local Game2 = require("src.core.Game2") + local anchored = Save.newGame() + check(Game2.anchorNewGameClock(anchored), + "the New Game path anchors it") + check(Clock.isSet(anchored), "so Clock.isSet is true before the world loads") + eq(Clock.hour(anchored), Clock.DEFAULT_HOUR, + "on InitClock's own 10 AM default") + eq(Clock.minute(anchored), Clock.DEFAULT_MINUTE, "and no minutes") + + -- Idempotent: the InitClock screen's own answer must not be overwritten by a + -- later pass through the same path. + Clock.setTime(anchored, 6, 30) + check(not Game2.anchorNewGameClock(anchored), + "an already-answered clock is left alone") + eq(Clock.hour(anchored), 6, "with the player's hour intact") + eq(Clock.minute(anchored), 30, "and their minutes") + + -- The whole point: a world built on an anchored save is lit by that hour and + -- stays there run to run. + local world = worldWithSave(anchored) + world:applyPalettes() + eq(world.daytime, Palettes.clockDaytime(6), "and the map follows it") +end + +S.finish() diff --git a/tests/gen2_trade_gfx_test.lua b/tests/gen2_trade_gfx_test.lua new file mode 100644 index 00000000..3c08d245 --- /dev/null +++ b/tests/gen2_trade_gfx_test.lua @@ -0,0 +1,434 @@ +-- The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/). +-- GOLD_CACHE="..." luajit tests/gen2_trade_gfx_test.lua +-- +-- The animation itself is pinned in tests/gen2_events_test.lua; what is here +-- is the art it draws with, which used to be nothing at all -- src/ui/gen2/ +-- TradeAnim.lua drew the shapes those tiles are. Four things are worth +-- holding down: that the extractor stage exists AND is called from run(), that +-- the nine labels it reads are in the curated manifest set (a missing symbol +-- is a silently empty stage), that what it wrote matches ../pokegold/gfx/ +-- trade/ byte for byte, and that the screen actually blits it rather than +-- carrying dead cache data. +-- +-- ROM-free. The decomp section SKIPs with no ../pokegold beside the repo and +-- the cache section SKIPs (or asks for a re-import) with no Gold cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 trade gfx") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local TradeAnimView = require("src.ui.gen2.TradeAnim") + +-- TradeGameBoyLZ decompresses to 49 tiles into vTiles2 tile $31: 34 of +-- game_boy.2bpp (--remove-duplicates) then 15 of link_cable.2bpp. +local SCENE_TILES = 49 +local SCENE_SHEET_TILES = 7 +local BASE_TILE = 0x31 +local GAMEBOY_W, GAMEBOY_H = 6, 8 +local TUBE_W, TUBE_H = 12, 3 + +-- ---- the extractor stage -------------------------------------------------- +-- A stage nothing calls writes nothing, and the cache checks below would then +-- SKIP forever without anyone noticing, so the call site is read out of the +-- source rather than assumed. +local extractorSource +do + local f = assert(io.open("src/import/RomExtractorGen2.lua", "r")) + extractorSource = f:read("*a") + f:close() +end +local RomExtractorGen2 = require("src.import.RomExtractorGen2") +check(type(RomExtractorGen2.extractTrade) == "function", + "RomExtractorGen2:extractTrade exists") +check(extractorSource:find("results.trade = self:extractTrade()", 1, true) + ~= nil, "and RomExtractorGen2:run calls it") +check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil, + "STAGE_COUNT counts the new stage, so the progress bar still ends at 1") + +-- pokegold.sym, bank $0a. These are also what the cache is checked against +-- below, so a manifest that drifts shows up here rather than as a blank +-- screen. +local SYMBOLS = { + TradeGameBoyTilemap = { 0x0a, 0x5713 }, + TradeLinkTubeTilemap = { 0x0a, 0x5743 }, + TradeArrowRightGFX = { 0x0a, 0x5767 }, + TradeArrowLeftGFX = { 0x0a, 0x5777 }, + TradeCableGFX = { 0x0a, 0x5787 }, + TradeBubbleGFX = { 0x0a, 0x57a7 }, + TradeGameBoyLZ = { 0x0a, 0x57e7 }, + TradeBallGFX = { 0x0a, 0x5927 }, + TradePoofGFX = { 0x0a, 0x5987 }, +} + +do + local f = assert(io.open("tools/make_gold_manifest.py", "r")) + local manifestSource = f:read("*a") + f:close() + for label in pairs(SYMBOLS) do + check(manifestSource:find('"' .. label .. '"', 1, true) ~= nil, + label .. " is in make_gold_manifest.py's REQUIRED_SYMBOLS") + end +end + +local Json = require("src.link.Json") +do + local f = io.open("tools/rom_manifest_gold.json", "r") + if not f then + check(true, "no tools/rom_manifest_gold.json (SKIP)") + else + local manifest = Json.decode(f:read("*a")) + f:close() + local symbols = manifest.symbols or {} + for label, location in pairs(SYMBOLS) do + local got = symbols[label] + if not got then + check(false, "the generated manifest carries " .. label) + else + eq(got[1], location[1], label .. " is in the bank pokegold.sym says") + eq(got[2], location[2], label .. " is at the address pokegold.sym says") + end + end + end +end + +-- ---- the decomp ----------------------------------------------------------- +local pokegold = "../pokegold" +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 + +local decompGameBoy = readFile(pokegold .. "/gfx/trade/game_boy.tilemap") +local decompTube = readFile(pokegold .. "/gfx/trade/link_cable.tilemap") +if not (decompGameBoy and decompTube) then + check(true, "no ../pokegold: the two tilemaps are not pinned (SKIP)") +else + eq(#decompGameBoy, GAMEBOY_W * GAMEBOY_H, + "gfx/trade/game_boy.tilemap is the 6x8 the INCBIN comment says") + eq(#decompTube, TUBE_W * TUBE_H, "and link_cable.tilemap is 12x3") + local highest = 0 + for _, body in ipairs({ decompGameBoy, decompTube }) do + for i = 1, #body do + local id = body:byte(i) + if id > highest then highest = id end + end + end + check(highest < BASE_TILE + SCENE_TILES, + "every id in them, and the loose cable ids, index the one 49-tile sheet") +end + +-- ---- the cache ------------------------------------------------------------ +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() 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 + +local trade = loadCache("trade") +if not trade then + check(true, + "cache predates the Trade animation stage : re-import for gfx/trade (SKIP)") +else + eq(trade.generation, 2, "trade.lua is a Gen 2 table") + eq(trade.tiles, SCENE_TILES, "TradeGameBoyLZ decompressed to 49 tiles") + eq(trade.sheetTiles, SCENE_SHEET_TILES, "written 7 across, so nothing pads") + eq(trade.baseTile, BASE_TILE, + "with the vTiles2 tile the BG ids count from, not 0") + eq(trade.image, "assets/generated/trade/scene.png", + "the sheet went where the screen looks for it") + local w, h = pngSize("assets/generated/trade/scene.png") + eq(w, SCENE_SHEET_TILES * 8, "the sheet is 56px wide") + eq(h, SCENE_SHEET_TILES * 8, "and 56px tall, which is 49 tiles exactly") + + local function tilemap(key, width, height, decomp, file) + local map = trade[key] + if type(map) ~= "table" or type(map.tiles) ~= "table" then + check(false, "trade.lua carries the " .. key .. " tilemap") + return + end + eq(map.width, width, key .. " is " .. width .. " tiles across") + eq(map.height, height, "and " .. height .. " down") + eq(#map.tiles, width * height, "flat, in TradeAnim_CopyBoxFromDEtoHL order") + if not decomp then + check(true, "no ../pokegold: " .. file .. " is not diffed (SKIP)") + return + end + local mismatch + for i = 1, width * height do + if map.tiles[i] ~= decomp:byte(i) then mismatch = i break end + end + check(mismatch == nil, mismatch + and (key .. " differs from the decomp at byte " .. mismatch) + or ("it is ../pokegold/gfx/trade/" .. file .. " byte for byte")) + end + tilemap("gameBoy", GAMEBOY_W, GAMEBOY_H, decompGameBoy, "game_boy.tilemap") + tilemap("tube", TUBE_W, TUBE_H, decompTube, "link_cable.tilemap") + + -- The object sheets. ball is 6 tiles and not 8 because gfx/trade/ball.2bpp + -- is built with --remove-whitespace: pret's 16x32 PNG holds the blank right + -- half of the first wobble frame, which is X-flipped at draw time, and the + -- ROM does not carry it. + local SHEETS = { + { "ball", 6, 1, "trade/ball.png" }, + { "poof", 12, 2, "trade/poof.png" }, + { "bulge", 2, 1, "trade/bulge.png" }, + { "bubble", 4, 2, "trade/bubble.png" }, + { "arrows", 2, 1, "trade/arrows.png" }, + } + for _, sheet in ipairs(SHEETS) do + local key, tiles, across, relative = sheet[1], sheet[2], sheet[3], sheet[4] + local entry = trade[key] + if type(entry) ~= "table" then + check(false, "trade.lua carries the " .. key .. " sheet") + else + eq(entry.image, "assets/generated/" .. relative, + key .. " went where the screen looks for it") + eq(entry.tiles, tiles, key .. " is " .. tiles .. " tiles") + eq(entry.sheetTiles, across, "laid out " .. across .. " across") + local pw, ph = pngSize("assets/generated/" .. relative) + eq(pw, across * 8, relative .. " is " .. (across * 8) .. "px wide") + eq(ph, tiles / across * 8, "and " .. (tiles / across * 8) .. "px tall") + end + end +end + +-- ---- the way in ----------------------------------------------------------- +-- Written data nothing mounts is written data nobody sees, so the loader line +-- is read out of the source the same way the extractor's call site was. +do + local f = assert(io.open("src/core/Game2.lua", "r")) + local source = f:read("*a") + f:close() + check(source:find('self.data.gen2Trade = loadGenerated' + .. '("data/generated/trade.lua")', 1, true) ~= nil, + "Game2 mounts trade.lua as data.gen2Trade") +end +do + local f = assert(io.open("src/ui/gen2/TradeMenu.lua", "r")) + local source = f:read("*a") + f:close() + check(source:find('Screens.push(game, "Gen2TradeAnim"', 1, true) ~= nil, + "and TradeMenu:playAnim still opens the screen that reads it") +end + +-- ---- the screen ----------------------------------------------------------- +-- The whole point of the stage: a screen that reads the cache instead of +-- drawing shapes. love.graphics.draw is recorded so a blit can be checked +-- against the tilemap byte and the OAM offset it came from. +local DATA = { pokemon = { DROWZEE = { name = "DROWZEE", dex = 96 }, + MACHOP = { name = "MACHOP", dex = 66 } } } +local SAVE = { player = { name = "SILVER", id = 999 } } +local ROW = { otName = "MIKE", otId = 1234 } +local GIVEN = { species = "DROWZEE", otName = "GOLD", otId = 12345 } +local RECEIVED = { species = "MACHOP", otName = "MIKE", otId = 1234 } + +-- A tilemap whose cells are their own index plus the base tile, so a blit's +-- expected sheet position is arithmetic rather than a lookup. +local function fakeTilemap(width, height) + local tiles = {} + for index = 0, width * height - 1 do + tiles[index + 1] = BASE_TILE + index + end + return { width = width, height = height, tiles = tiles } +end + +local FAKE = { + image = "assets/generated/trade/scene.png", + tiles = SCENE_TILES, + sheetTiles = SCENE_SHEET_TILES, + baseTile = BASE_TILE, + gameBoy = fakeTilemap(GAMEBOY_W, GAMEBOY_H), + tube = fakeTilemap(TUBE_W, TUBE_H), + ball = { image = "assets/generated/trade/ball.png", tiles = 6, + sheetTiles = 1 }, + poof = { image = "assets/generated/trade/poof.png", tiles = 12, + sheetTiles = 2 }, + bulge = { image = "assets/generated/trade/bulge.png", tiles = 2, + sheetTiles = 1 }, + bubble = { image = "assets/generated/trade/bubble.png", tiles = 4, + sheetTiles = 2 }, + arrows = { image = "assets/generated/trade/arrows.png", tiles = 2, + sheetTiles = 1 }, +} + +local function newView(gfx) + return TradeAnimView.new({ data = DATA, save = SAVE }, { + row = ROW, given = GIVEN, received = RECEIVED, save = SAVE, gfx = gfx, + }) +end + +-- Records every draw as { path, quadX, quadY, x, y, sx, sy }. +local drawn = {} +local realDraw = love.graphics.draw +local function record(image, quad, x, y, _r, sx, sy) + if type(quad) == "table" and quad.w == 8 then + drawn[#drawn + 1] = { path = image and image.path, qx = quad.x, + qy = quad.y, x = x, y = y, sx = sx or 1, sy = sy or 1 } + end +end +local function capture(view, body) + drawn = {} + love.graphics.draw = record + local ok, err = pcall(body, view) + love.graphics.draw = realDraw + check(ok, "the screen draws: " .. tostring(err)) + return drawn +end + +local function countFrom(list, path) + local n = 0 + for _, entry in ipairs(list) do + if entry.path == path then n = n + 1 end + end + return n +end + +do + local view = newView(FAKE) + -- The tube beat: TradeLinkTubeTilemap at hlcoord 8, 2, home once the + -- $a0 scroll has drained. + local tube = capture(view, function(v) v:drawTube(0) end) + eq(countFrom(tube, "assets/generated/trade/scene.png"), TUBE_W * TUBE_H, + "the tube is 36 tile blits, not a rounded rectangle") + local first = tube[1] + eq(first.x, 64, "cell 0 lands at hlcoord 8, 2") + eq(first.y, 16, "which is (64, 16)") + eq(first.qx, 0, "reading sheet column 0") + eq(first.qy, 0, "of sheet row 0") + -- Cell 8 is tile $39: sheet column 8 % 7 = 1, row 1. + local ninth = tube[9] + eq(ninth.x, 64 + 8 * 8, "cell 8 is eight tiles along") + eq(ninth.qx, 8 % SCENE_SHEET_TILES * 8, "reading the sheet column its id says") + eq(ninth.qy, math.floor(8 / SCENE_SHEET_TILES) * 8, "and the sheet row") + + -- A scrolled tube: EnterLinkTube2 moves hSCX, and hSCX scrolls the + -- BACKGROUND, so the offset comes off the tube's x. + local scrolled = capture(view, function(v) v:drawTube(0x20) end) + eq(scrolled[1].x, 64 - 0x20, "the offset slides the whole stamp left") + + -- The Game Boy: 48 tiles at the pan's own unrolled position. + local gb = capture(view, function(v) v:drawGameBoy(24, 16) end) + eq(#gb, GAMEBOY_W * GAMEBOY_H, "the Game Boy is its 6x8 tilemap") + eq(gb[1].x, 24, "stamped at hlcoord 3, 2") + eq(gb[1].y, 16, "in state 0") + eq(gb[GAMEBOY_W + 1].y, 24, "the second row is one tile down") + eq(gb[GAMEBOY_W + 1].x, 24, "back at the left edge") + + -- The scene: both Game Boys plus the cable the jumptable ByteFills. The + -- second one is a whole 256-pixel wrap further along. + local scene = capture(view, function(v) v:drawScene(0) end) + eq(#scene, GAMEBOY_W * GAMEBOY_H * 2 + 46, + "two Game Boys and the 46 cable cells around them") + local far + for _, entry in ipairs(scene) do + if entry.x == 0x100 + 80 and entry.y == 48 then far = entry end + end + check(far ~= nil, + "the second Game Boy sits at hlcoord 10, 6 of the map after the wrap") + + -- The ball: .OAMData_TradePokeBall1 is the left half twice, the right one + -- X-flipped, centred on the sprite anim's origin. + local ball = capture(view, function(v) v:drawBall(80, 68, false) end) + eq(#ball, 4, "four OAM entries") + eq(ball[1].x, 72, "the first hangs 8 pixels left of the origin") + eq(ball[1].y, 60, "and 8 above it") + eq(ball[2].sx, -1, "the right half is the same tile X-flipped") + eq(ball[2].x, 80 + 8, "whose anchor moves a tile along to compensate") + eq(ball[1].qy, 0, "reading tile 0") + eq(ball[3].qy, 8, "and tile 1 underneath it") + + -- The poof and the bubble are 2x2 quadrants mirrored into 32x32. + local poof = capture(view, function(v) v:drawPoof(80, 68, 0) end) + eq(#poof, 16, ".OAMData_TradePoofBubble is sixteen entries") + eq(poof[1].x, 80 - 16, "the top-left quadrant starts two tiles out") + eq(poof[1].y, 68 - 16, "in both axes") + local flippedX, flippedY, both = 0, 0, 0 + for _, entry in ipairs(poof) do + if entry.sx < 0 and entry.sy < 0 then both = both + 1 + elseif entry.sx < 0 then flippedX = flippedX + 1 + elseif entry.sy < 0 then flippedY = flippedY + 1 end + end + eq(flippedX, 4, "one quadrant X-flipped") + eq(flippedY, 4, "one Y-flipped") + eq(both, 4, "and one both ways") + + -- Frame 2 of the poof reads the next four tiles, not the same four. + local later = capture(view, function(v) v:drawPoof(80, 68, 4) end) + eq(later[1].qy, 2 * 8, "the second frame is four tiles further into the sheet") + + -- The bulge is one tile mirrored, which is why gfx/trade/cable.png is 8x16. + local bulge = capture(view, function(v) v:drawBulge(80, 24, 0) end) + eq(#bulge, 4, ".OAMData_TradeTubeBulge is one tile drawn four ways") + eq(bulge[1].x, 72, "top-left of a 16x16 centred on the origin") + eq(bulge[1].y, 16, "which puts it inside the tube") + + -- The strip under the pan: the rule, the two trainers and the six arrows. + -- Chrome.print is stubbed out because the headless font has no glyphs, and + -- what it was handed is worth reading anyway. + local printed = {} + local realPrint, realPrintRight = Chrome.print, Chrome.printRight + Chrome.print = function(text, tx, ty) + printed[#printed + 1] = { text = text, x = tx, y = ty } + return 0 + end + Chrome.printRight = function(text, txEnd, ty) + printed[#printed + 1] = { text = text, right = txEnd, y = ty } + return 0 + end + local strip = capture(view, function(v) v:drawTubeStrip(true) end) + local back = capture(view, function(v) v:drawTubeStrip(false) end) + Chrome.print, Chrome.printRight = realPrint, realPrintRight + eq(#printed, 6, "the rule and the two trainers, on each pan") + eq(#printed[1].text, #("─"):rep(20), "row 0 is SCREEN_WIDTH of the rule") + eq(printed[1].y, 0, "on the window's first row") + eq(printed[2].text, "SILVER", + "wLinkPlayer1Name is the player, which TradeAnimation loads from" + .. " wPlayerTrademonSenderName") + eq(printed[2].y, 1, "at hlcoord 0, 1") + eq(printed[3].text, "MIKE", "wLinkPlayer2Name is the OT") + eq(printed[3].right, 20, + "right-aligned, which is what `hlcoord 0, 4 / add hl, de` lands on") + eq(printed[3].y, 3, "one row up from the hlcoord, across the row boundary") + eq(countFrom(strip, "assets/generated/trade/arrows.png"), 6, + "six arrows, the ByteFill's own count") + eq(strip[1].x, 7 * 8, "the first at hlcoord 7, 2 of the window") + eq(strip[1].y, 2 * 8, "on the window's third row") + eq(strip[1].qy, 0, "sending draws TradeArrowRightGFX") + eq(back[1].qy, 8, "and receiving draws TradeArrowLeftGFX") +end + +-- A cache without the stage still runs the animation: the shapes, not a crash. +do + local bare = newView(nil) + check(bare.gfx == nil, "no gen2Trade in the cache means no sheets") + local ok = pcall(function() + bare:drawTube(0) + bare:drawScene(0) + bare:drawBall(80, 68, true) + bare:drawPoof(80, 68, 0) + bare:drawBulge(80, 24, 0) + end) + check(ok, "and every routine falls back to the shape it used to draw") +end + +S.finish() diff --git a/tests/gen2_trainerhouse_test.lua b/tests/gen2_trainerhouse_test.lua new file mode 100644 index 00000000..11f09619 --- /dev/null +++ b/tests/gen2_trainerhouse_test.lua @@ -0,0 +1,303 @@ +-- The Viridian Trainer House (maps/TrainerHouseB1F.asm, plus the CAL arms of +-- engine/battle/read_trainer_party.asm). ROM-free for the fixture half; the +-- second half runs the map's own extracted script and needs a cache: +-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \ +-- luajit tests/gen2_trainerhouse_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 trainer house") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Apricorns = require("src.core.gen2.Apricorns") +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local TrainerHouse = require("src.world.gen2.TrainerHouse") +local Trainers = require("src.world.gen2.Trainers") +local Vm = require("src.script.gen2.Vm") + +-- --------------------------------------------------------------------------- +-- sMysteryGiftTrainerHouseFlag, and the three routines that read it +-- --------------------------------------------------------------------------- + +eq(TrainerHouse.CAL, 12, "CAL is trainer class 12") +eq(TrainerHouse.CAL2, 2, "CAL2 is the Mystery Gift visitor") +eq(TrainerHouse.CAL3, 3, "CAL3 is the house's own trainer") + +do + local empty = {} + check(not TrainerHouse.hasCustomTrainer(empty), + "with no Mystery Gift record the flag reads clear") + check(not TrainerHouse.hasCustomTrainer(nil), + "and a nil save is the same answer rather than an error") + + -- ReadTrainerParty's `.cal2` arm: the only redirect, and only for CAL2. + eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL2), + TrainerHouse.CAL3, "an unlinked cart answers a CAL2 lookup with CAL3") + eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL1), + TrainerHouse.CAL1, "CAL1 (Route 27) is left alone") + eq(TrainerHouse.resolveMember(empty, TrainerHouse.CAL, TrainerHouse.CAL3), + TrainerHouse.CAL3, "CAL3 is left alone") + eq(TrainerHouse.resolveMember(empty, 13, 2), 2, + "and member 2 of any OTHER class is left alone: the cart tests the " + .. "class first") + + -- GetTrainerName's CAL arm falls through to the table when the flag is + -- clear, which is what nil means here. + eq(TrainerHouse.customName(empty, TrainerHouse.CAL), nil, + "no custom name without a Mystery Gift partner") + eq(TrainerHouse.customName(empty, 13), nil, "and never for another class") + + -- The one shape that would come back the day Mystery Gift lands. + local gifted = { mysteryGift = { trainerHouse = true, partnerName = "KRIS" } } + check(TrainerHouse.hasCustomTrainer(gifted), "a stored trade sets the flag") + eq(TrainerHouse.resolveMember(gifted, TrainerHouse.CAL, TrainerHouse.CAL2), + TrainerHouse.CAL2, + "and then CAL2 is NOT redirected: the party comes from the trade") + eq(TrainerHouse.customName(gifted, TrainerHouse.CAL), "KRIS", + "sMysteryGiftPartnerName is the name") +end + +-- `special TrainerHouse` is that same byte, so the two cannot disagree. +do + local vm = { scriptVar = 7, specials = { save = function() return {} end } } + Specials.ALL.TrainerHouse(vm) + eq(vm.scriptVar, 0, "unlinked: wScriptVar FALSE, so the script picks CAL3") + + local gifted = { mysteryGift = { trainerHouse = true } } + local vm2 = { scriptVar = 0, specials = { save = function() return gifted end } } + Specials.ALL.TrainerHouse(vm2) + eq(vm2.scriptVar, 1, "with a trade stored it answers TRUE, the CAL2 arm") + + local bare = { scriptVar = 3, specials = {} } + Specials.ALL.TrainerHouse(bare) + eq(bare.scriptVar, 0, + "and with no save hook it still answers FALSE rather than leaving a " + .. "stale wScriptVar to pick the arm") +end + +-- --------------------------------------------------------------------------- +-- The once-a-day gate +-- --------------------------------------------------------------------------- + +do + eq(TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY, 86, + "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is engine flag 86") + local found + for _, row in ipairs(Apricorns.DAILY_ENGINE_FLAGS) do + if row.id == TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY then + found = row + end + end + check(found ~= nil, "and it is one of the wDailyFlags bits") + eq(found and found.name, "ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY", + "under that name") + + -- CheckDailyResetTimer wipes both daily bytes whole, which is the ONLY + -- thing that lets you back into the hall. + local save = { engineFlags = { [86] = true } } + check(TrainerHouse.foughtToday(save), "the flag reads back set") + Apricorns.dailyReset(save) + check(not TrainerHouse.foughtToday(save), + "and the daily rollover is what clears it") +end + +-- --------------------------------------------------------------------------- +-- The map's own script, out of a real cache +-- --------------------------------------------------------------------------- + +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 mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : fixture checks only (SKIP cache facts)") + S.finish() + return +end +mf:close() + +local maps = assert(loadfile(mapsPath))() +local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))() +local text = assert(loadfile(cache .. "/data/generated/text.lua"))() +local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))() +local trainerData = assert(loadfile(cache .. "/data/generated/trainers.lua"))() + +-- CAL's three rows. CAL (2) is dead data on the cart -- ReadTrainerParty +-- branches into SRAM before it indexes the table -- and the redirect above is +-- what stops the port fielding it. +do + local cal = trainerData.classes and trainerData.classes.CAL + check(cal ~= nil, "the cache carries the CAL class") + eq(cal and cal.index, TrainerHouse.CAL, "at class index 12") + local three = Trainers.lookup(trainerData, TrainerHouse.CAL, + TrainerHouse.CAL3) + check(three ~= nil, "CAL3 is in the table") + eq(three and three.name, "CAL", "named CAL") + local roster = {} + for _, row in ipairs((three and three.roster) or {}) do + roster[#roster + 1] = string.format("%s@%d", tostring(row.species), + row.level) + end + eq(table.concat(roster, " "), "MEGANIUM@50 TYPHLOSION@50 FERALIGATR@50", + "the default opponent is the three fully evolved starters at 50") + + local redirected = TrainerHouse.lookup(trainerData, {}, TrainerHouse.CAL, + TrainerHouse.CAL2) + eq(redirected and redirected.id, "CAL3", + "and a CAL2 lookup lands on that same row rather than on the level 30 " + .. "one no cartridge fields") + eq(TrainerHouse.name(trainerData, {}, TrainerHouse.CAL, TrainerHouse.CAL2), + "CAL", "gettrainername answers CAL either way") +end + +local map = maps.TRAINER_HOUSE_B1F +check(map ~= nil, "the cache carries TRAINER_HOUSE_B1F") + +-- coord_event 7, 3, SCENE_TRAINERHOUSEB1F_ASK_BATTLE: the doorway cell, not an +-- object, which is why the receptionist is never talked to. +do + local ce = (map.coordEvents or {})[1] + check(ce ~= nil, "one coord event") + eq(ce and ce.x, 7, "at x 7") + eq(ce and ce.y, 3, "y 3") + eq(ce and ce.sceneId, 0, "on SCENE_TRAINERHOUSEB1F_ASK_BATTLE") + check(ce and ce.scriptKey and scripts[ce.scriptKey] ~= nil, + "and it names a script body the cache carries") + -- object_const_def is const_def 2, so TRAINERHOUSEB1F_CHRIS is script id 3 + -- and the second extracted object. + eq(#(map.objects or {}), 2, "two objects: the receptionist and CAL") + eq(map.objects[2].sprite, "SPRITE_CHRIS", + "the second is the one setlasttalked 3 names") + eq(#(map.warps or {}), 1, "one warp, back up to the ground floor") + eq(map.warps[1].destMap, "TRAINER_HOUSE_1F", "to TRAINER_HOUSE_1F") +end + +-- Run the receptionist's script for each of its three arms. The hooks are a +-- log rather than a World, so this checks the SCRIPT against the port's +-- commands: the wiring is World's own (World:trainerParty, and the coord event +-- World:checkCoordEvents takes on the step onto the cell). +local function runScript(opts) + opts = opts or {} + local save = { engineFlags = {} } + if opts.foughtToday then + save.engineFlags[TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY] = true + end + if opts.gift then save.mysteryGift = { trainerHouse = true } end + local log, moves, battles = {}, {}, {} + local vm = Vm.new(scripts, text, Events.new(), { + specialOrder = constants.specialOrder, + specials = { save = function() return save end }, + showText = function(body, onDone) + log[#log + 1] = body + onDone() + end, + yesorno = function(onChoose) onChoose(opts.accept and true or false) end, + applyMovement = function(object, bytes, onDone) + moves[#moves + 1] = { object = object, steps = #bytes } + onDone() + end, + turnObject = function(object, facing) + log.turned = { object = object, facing = facing } + end, + getEngineFlag = function(flag) return save.engineFlags[flag] end, + setEngineFlag = function(flag, value) + save.engineFlags[flag] = value or nil + end, + getTrainerName = function(class, member) + return TrainerHouse.name(trainerData, save, class, member) + end, + lookupTrainer = function(class, member) + return TrainerHouse.lookup(trainerData, save, class, member) + end, + startBattle = function(trainer, _wild, onDone) + battles[#battles + 1] = trainer + onDone(opts.lose and "lose" or "win") + end, + reloadMap = function() log.reloaded = (log.reloaded or 0) + 1 end, + }) + local started = vm:start(map.coordEvents[1].scriptKey) + return { + started = started, busy = vm.busy, log = log, moves = moves, + battles = battles, save = save, + } +end + +local function saw(log, needle) + for _, body in ipairs(log) do + if body:find(needle, 1, true) then return true end + end + return false +end + +do -- the first visit of the day, accepted + local run = runScript({ accept = true }) + check(run.started, "the script starts") + check(not run.busy, "and runs to completion without parking on a command") + eq(run.log.turned and run.log.turned.facing, "up", + "turnobject PLAYER, UP squares the player up to the desk") + check(saw(run.log, "TRAINING HALL"), "the welcome") + check(saw(run.log, "is your"), "the {STRBUF} opponent line") + eq(run.log[2], "CAL is your\nopponent today.", + "and gettrainername filled it with CAL") + check(saw(run.log, "Would you like to"), "the question") + check(saw(run.log, "Please go right"), "the go-ahead") + check(saw(run.log, "I traveled out"), "CAL's own line inside the room") + check(run.save.engineFlags[86] == true, + "setflag ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is taken") + eq(#run.battles, 1, "exactly one battle") + eq(run.battles[1] and run.battles[1].id, "CAL3", "against CAL3") + eq(#run.moves, 2, "walked in and walked back out") + eq(run.moves[1].steps, 14, "Movement_EnterTrainerHouseBattleRoom") + eq(run.moves[2].steps, 14, "Movement_ExitTrainerHouseBattleRoom") + eq(run.log.reloaded, 1, "reloadmapafterbattle, once") +end + +do -- with a Mystery Gift trade stored, the CAL2 arm is the one taken + local run = runScript({ accept = true, gift = true }) + eq(#run.battles, 1, "still one battle") + eq(run.battles[1] and run.battles[1].id, "CAL2", + "special TrainerHouse TRUE routes the script to CAL2") +end + +do -- declined + local run = runScript({ accept = false }) + check(saw(run.log, "Sorry. Only those"), "the polite refusal") + eq(#run.battles, 0, "no battle") + check(run.save.engineFlags[86] == nil, + "and the daily flag is NOT set: a declined offer costs you nothing") + eq(#run.moves, 1, "one movement, the step back off the cell") + eq(run.moves[1].steps, 3, "Movement_TrainerHouseTurnBack") +end + +do -- already fought today + local run = runScript({ accept = true, foughtToday = true }) + check(saw(run.log, "second time today"), "the second-challenge refusal") + eq(#run.log, 1, "and nothing else is said") + eq(#run.battles, 0, "no battle") + eq(#run.moves, 1, "just the step back off the cell") + eq(run.moves[1].steps, 3, "Movement_TrainerHouseTurnBack") +end + +do -- losing ends the run, and ends the SCRIPT, before the walk back out + local run = runScript({ accept = true, lose = true }) + eq(#run.battles, 1, + "a loss takes reloadmapafterbattle's iffalse arm: one battle, not two") + -- One movement, not two: the walk in, and nothing after it. + -- + -- This expected 2 while a lost battle still fell through + -- `reloadmapafterbattle` and ran the rest of the winner's script -- the bug + -- tests/gen2_battle_loss_test.lua now pins. On the cart that command reads + -- wBattleResult and, on LOSE, does `jp ScriptJump` into Script_BattleWhiteout + -- (engine/overworld/scripting.asm:1080); it never returns, so TrainerHouseB1F + -- never reaches `.End: applymovement PLAYER, + -- Movement_ExitTrainerHouseBattleRoom`. You wake up in a Pokecenter, not + -- politely walked back out of the battle room. + eq(#run.moves, 1, "and the script is over: no walk back out after a whiteout") +end + +S.finish() diff --git a/tests/gen2_trainers_test.lua b/tests/gen2_trainers_test.lua new file mode 100644 index 00000000..61210034 --- /dev/null +++ b/tests/gen2_trainers_test.lua @@ -0,0 +1,335 @@ +-- Gen 2 overworld trainers: the eyesight test, the approach path, the party +-- build, and the VM's half of engine/events/trainer_scripts.asm. +-- luajit tests/gen2_trainers_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 trainers") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Movement = require("src.script.gen2.Movement") +local Trainers = require("src.world.gen2.Trainers") +local Events = require("src.world.gen2.Events") +local Vm = require("src.script.gen2.Vm") + +-- ---- FacingPlayerDistance (home/trainers.asm) ----------------------------- +local function at(x, y, facing) + return { cellX = x, cellY = y, facing = facing } +end + +local npc = at(5, 5, "down") +eq(Trainers.sees(npc, at(5, 8), 4), 3, "3 cells below, facing down, sight 4") +eq(select(2, Trainers.sees(npc, at(5, 8), 4)), "down", "direction is down") +check(not Trainers.sees(npc, at(5, 10), 4), "5 cells away is past sight 4") +eq(Trainers.sees(npc, at(5, 9), 4), 4, "exactly sight 4 still sees") +check(not Trainers.sees(npc, at(5, 2), 4), "facing down does not see upward") +check(not Trainers.sees(npc, at(6, 8), 4), "off the column is not seen") +check(not Trainers.sees(npc, at(5, 5), 4), "standing on the trainer is not seen") +check(not Trainers.sees(npc, at(5, 6), 0), "sight 0 never sees") + +local sideways = at(5, 5, "left") +eq(Trainers.sees(sideways, at(3, 5), 3), 2, "2 cells left, facing left") +eq(select(2, Trainers.sees(sideways, at(3, 5), 3)), "left", "direction is left") +check(not Trainers.sees(sideways, at(7, 5), 3), "facing left does not see right") + +-- ---- TrainerWalkToPlayer -------------------------------------------------- +eq(#Trainers.approach(1, "down"), 0, "spotted from 1 cell away: no walk") +eq(#Trainers.approach(4, "down"), 3, "spotted from 4 cells away: 3 steps") +eq(Trainers.approach(3, "up")[1], "up", "approach walks along the sight line") +eq(Movement.stepByte("down"), 0x0c, "step down byte") +eq(Movement.stepByte("up"), 0x0d, "step up byte") +eq(Movement.stepByte("left"), 0x0e, "step left byte") +eq(Movement.stepByte("right"), 0x0f, "step right byte") +eq(Movement.decodeByte(Movement.stepByte("right")).dir, "right", + "stepByte round-trips through decodeByte") + +-- ---- class lookup + party build ------------------------------------------- +local data = { + moves = { + TACKLE = { pp = 35, power = 35, type = "NORMAL" }, + GUST = { pp = 35, power = 40, type = "FLYING" }, + }, + pokemon = { + PIDGEY = { + name = "PIDGEY", types = { "NORMAL", "FLYING" }, growthRate = "MEDIUM_SLOW", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + RATTATA = { + name = "RATTATA", types = { "NORMAL" }, growthRate = "MEDIUM_FAST", + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + }, + trainers = { + classes = { + YOUNGSTER = { + index = 22, name = "YOUNGSTER", baseMoney = 20, + trainers = { + { index = 1, id = "JOEY1", name = "JOEY", + trainerType = "TRAINERTYPE_NORMAL", + party = { { species = "RATTATA", level = 4 } } }, + }, + }, + FALKNER = { + index = 1, name = "LEADER", + trainers = { + { index = 1, id = "FALKNER1", name = "FALKNER", + trainerType = "TRAINERTYPE_MOVES", + party = { { species = "PIDGEY", level = 7, + moves = { "TACKLE", "GUST" } } } }, + }, + }, + }, + }, +} + +local joey = Trainers.lookup(data.trainers, 22, 1) +check(joey, "class 22 member 1 resolves") +eq(joey.name, "JOEY", "trainer name") +eq(joey.className, "YOUNGSTER", "class display name") +eq(joey.classId, "YOUNGSTER", "class key") +check(not Trainers.lookup(data.trainers, 22, 9), "member 9 does not exist") +check(not Trainers.lookup(data.trainers, 250, 1), "class 250 does not exist") + +local joeyParty = Trainers.party(data, joey) +eq(#joeyParty, 1, "JOEY has one mon") +eq(joeyParty[1].species, "RATTATA", "JOEY leads with RATTATA") +eq(joeyParty[1].level, 4, "at level 4") +check(joeyParty[1].hp > 0, "with hp") +-- MakeTrainerPartyMon fixes every trainer mon's DVs, so the same trainer +-- always brings the same stats. +local again = Trainers.party(data, joey) +eq(again[1].stats.attack, joeyParty[1].stats.attack, "trainer DVs are fixed") + +local falkner = Trainers.party(data, Trainers.lookup(data.trainers, 1, 1)) +eq(#falkner[1].moves, 2, "TRAINERTYPE_MOVES takes the explicit move list") +eq(falkner[1].moves[2].id, "GUST", "second move is GUST") +eq(falkner[1].moves[2].pp, 35, "PP comes from moves.lua") + +-- ---- trainer_scripts.asm through the VM ----------------------------------- +local events = Events.new() +local texts = { + ["t:seen"] = "Wait! Let's battle!", + ["t:win"] = "I lost...", + ["t:after"] = "Train harder.", +} +local scripts = { + ["s:after"] = { { op = "writetext", text = "t:after" }, { op = "end" } }, +} + +local BEAT_FLAG = 1336 +local record = { + event = BEAT_FLAG, class = 22, member = 1, + seenText = "t:seen", winText = "t:win", scriptKey = "s:after", +} + +local SEEN_SCRIPT = { + { op = "loadtemptrainer" }, + { op = "encountermusic" }, + { op = "showemote", emote = 0, object = -2, frames = 30 }, + { op = "trainerapproach" }, + { op = "opentext" }, + { op = "trainertext", index = 0 }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "trainerflagaction", action = 1 }, + { op = "scripttalkafter" }, +} + +local shown, emoted, approached, battled = {}, false, false, nil +local vm = Vm.new(scripts, texts, events, { + showText = function(body, onDone) shown[#shown + 1] = body onDone() end, + showEmote = function(_, object) emoted = object end, + trainerApproach = function(onDone) approached = true onDone() end, + encounterMusic = function() end, + lookupTrainer = function(class, member) + return Trainers.lookup(data.trainers, class, member) + end, + startBattle = function(trainer, _wild, onDone) + battled = trainer + onDone("win") + end, +}) + +vm.trainerObject = record +check(vm:start(SEEN_SCRIPT), "an inline command list runs") +for _ = 1, 60 do vm:update() end +check(not vm:running(), "the trainer script finished") +eq(emoted, -2, "the bubble goes over LAST_TALKED") +check(approached, "the trainer walked up") +check(battled, "a battle started") +eq(battled.name, "JOEY", "the battle is against the struct's trainer") +eq(shown[1], "Wait! Let's battle!", "TRAINERTEXT_SEEN is the struct's seen text") +eq(shown[2], "Train harder.", "the after-battle script ran") +check(events:get(BEAT_FLAG), "SET_FLAG marked the trainer beaten") + +-- A beaten trainer takes the CHECK_FLAG branch straight to its after script. +local TALK_SCRIPT = { + { op = "faceplayer" }, + { op = "trainerflagaction", action = 2 }, + { op = "iftrue", script = { { op = "scripttalkafter" } } }, + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "end" }, +} +shown, battled = {}, nil +vm.trainerObject = record +check(vm:start(TALK_SCRIPT), "talk-to-trainer runs") +for _ = 1, 60 do vm:update() end +check(not vm:running(), "talk script finished") +check(not battled, "a beaten trainer does not battle again") +eq(shown[1], "Train harder.", "a beaten trainer says its after-battle line") + +-- winlosstext overrides the struct's win text for one battle. +local WINLOSS_SCRIPT = { + { op = "winlosstext", winText = "t:win" }, + { op = "trainertext", index = 1 }, + { op = "end" }, +} +shown = {} +vm.trainerObject = { event = BEAT_FLAG, class = 22, member = 1 } +check(vm:start(WINLOSS_SCRIPT), "winlosstext script runs") +for _ = 1, 10 do vm:update() end +eq(shown[1], "I lost...", "trainertext 1 reads the winlosstext override") + +-- endifjustbattled stops an after-battle script from re-running its intro. +local JUSTBATTLED_SCRIPT = { + { op = "loadtemptrainer" }, + { op = "startbattle" }, + { op = "endifjustbattled" }, + { op = "writetext", text = "t:seen" }, + { op = "end" }, +} +shown = {} +vm.trainerObject = record +check(vm:start(JUSTBATTLED_SCRIPT), "justbattled script runs") +for _ = 1, 30 do vm:update() end +eq(#shown, 0, "endifjustbattled ended the script after the battle") + +-- ---- what a scripted battle actually hands the battle screen -------------- +-- +-- Trainers.lookup builds the whole of the class's attributes row, but +-- World:startScriptedBattle is the only place a trainer battle is built, so a +-- field it forgets to forward is a field no trainer in the game ever has. +-- That is what happened to `attributes` and `items`: Battle's AI gate reads +-- self.trainer.attributes and gives up on a nil, and AI_TryItem walks +-- self.trainer.items, so every trainer fought with no personality and no +-- potions while both models sat there unit tested. Driven through the real +-- World and the real screens registry rather than asserted off the source. +local World = require("src.world.gen2.World") +local Screens = require("src.ui.Screens") + +local FIGHT_DATA = { + pokemon = { + PIDGEY = { id = "PIDGEY", name = "PIDGEY", index = 15, baseExp = 55, + growthRate = "MEDIUM_SLOW", stats = { hp = 40, attack = 45, + defense = 40, speed = 56, specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" } }, + }, + moves = {}, + -- data/trainers/attributes.asm CHAMPION, verbatim: the seven-byte row + -- (Ai.flagsOf folds bytes 4-6 into the AI flag set, byte 3 is + -- TRNATTR_BASE_REWARD) and the two TRNATTR_ITEM slots. CHAMPION rather + -- than FALKNER because Falkner's two item slots are empty, and an empty + -- list cannot tell a dropped field from a real one. + trainers = { classes = { CHAMPION = { id = "CHAMPION", index = 1, + name = "CHAMPION", baseMoney = 25, + attributes = { 38, 14, 25, 211, 3, 68, 0 }, + items = { "FULL_HEAL", "FULL_RESTORE" }, + trainers = { { id = "LANCE1", name = "LANCE", + party = { { species = "PIDGEY", level = 7 } } } } } } }, +} + +Screens.invalidate() +local fought = {} +local fightGame = { + data = { + pokemon = FIGHT_DATA.pokemon, moves = FIGHT_DATA.moves, + trainers = FIGHT_DATA.trainers, + screens = { + Gen2BattleState = function(_, opts) + fought.opts = opts + return { screenId = "Gen2BattleState" } + end, + }, + }, + save = { party = {}, player = { name = "GOLD", money = 0 } }, + stack = { push = function() end, pop = function() end }, +} +local fightWorld = World.new(fightGame) +fightGame.world = fightWorld +fightWorld.map = { def = { id = "TEST_MAP" } } +fightWorld.maps = { TEST_MAP = fightWorld.map.def } +fightWorld.events = Events.new() +fightWorld.playBattleMusic = function() end +fightWorld.battleMusicContext = function() return nil end +fightWorld.pushBattleTransition = function() return nil end +fightWorld.restoreMapMusic = function() end + +local champion = Trainers.lookup(FIGHT_DATA.trainers, 1, 1) +check(champion ~= nil, "the class record is found") +eq(champion.attributes and champion.attributes[4], 211, + "and it carries the attributes row") +eq(champion.items[1], "FULL_HEAL", "and the first TRNATTR_ITEM slot") +fightWorld:startScriptedBattle(champion, nil, function() end) +check(fought.opts ~= nil, "startScriptedBattle pushes the battle screen") +-- The screen is handed the Battle, and Battle keeps the record it was built +-- with as self.trainer: that is the field the AI gate and AI_TryItem read. +local sent = fought.opts and fought.opts.battle and fought.opts.battle.trainer + or {} +check(sent.attributes ~= nil, "the AI personality reaches the battle") +eq(sent.items and sent.items[1], "FULL_HEAL", "and so does AI_TryItem's list") +eq(sent.items and sent.items[2], "FULL_RESTORE", "both slots, in order") +eq(sent.baseMoney, 25, "the payout byte still travels beside them") +-- The copy Trainers.lookup makes is the one the battle spends, so using an +-- item up must not empty the class record for the next trainer of that class. +check(sent.items ~= FIGHT_DATA.trainers.classes.CHAMPION.items, + "the battle spends a copy, not the class row") + +-- ---- the rival's name ------------------------------------------------------ +-- +-- Every RIVAL1/RIVAL2 row in data/trainers/parties.asm literally stores `db +-- "?@"`, and the cart never prints it: PlaceEnemysName (home/text.asm:327), +-- which is what the <ENEMY> character resolves to, checks wTrainerClass against +-- RIVAL1 and RIVAL2 and prints wRivalName ALONE for either -- no class prefix. +-- wRivalName is what `special NameRival` wrote, defaulting SILVER on Gold. +-- Building the display name as class .. " " .. row-name announced "RIVAL ?". +fought.opts = nil +fightGame.save.rival = { name = "KAMON" } +local rival = { + class = 2, classId = "RIVAL1", className = "RIVAL", id = "RIVAL1_1", + name = "?", baseMoney = 15, + roster = { { species = "PIDGEY", level = 7 } }, +} +fightWorld:startScriptedBattle(rival, nil, function() end) +local rivalSent = fought.opts and fought.opts.battle + and fought.opts.battle.trainer or {} +eq(rivalSent.name, "KAMON", "a rival battle is named from wRivalName alone") +eq(rivalSent.trainerName, "KAMON", "and so is every line that names him") +eq(rivalSent.className, "RIVAL", + "the class key is left alone -- BattleMusic and the palettes read it") + +-- A save with no rival record is a save that has not reached the officer yet, +-- so wRivalName still holds InitializeNPCNames' "???" -- NameRival's SILVER +-- default only applies once the naming screen has actually been through. This +-- is the name the Cherrygrove theft battle prints. +fought.opts = nil +fightGame.save.rival = nil +fightWorld:startScriptedBattle(rival, nil, function() end) +eq(fought.opts and fought.opts.battle and fought.opts.battle.trainer.name, + "???", "before NameRival runs, InitializeNPCNames' seed stands") + +-- A non-rival class still gets class then name, "YOUNGSTER JOEY". +fought.opts = nil +fightWorld:startScriptedBattle(champion, nil, function() end) +eq(fought.opts and fought.opts.battle and fought.opts.battle.trainer.name, + "CHAMPION LANCE", "and no other class loses its prefix") + +S.finish() diff --git a/tests/gen2_trap_escape_test.lua b/tests/gen2_trap_escape_test.lua new file mode 100644 index 00000000..04e79309 --- /dev/null +++ b/tests/gen2_trap_escape_test.lua @@ -0,0 +1,273 @@ +-- The no-escape web around a Gen 2 battle: Mean Look's CANT_RUN pin, the +-- Bind class wrap count, the FORCESHINY / TRAP battle types, and Roar and +-- Whirlwind's force switch -- including a wild mon's own Roar ending the +-- battle as a draw so a roamer banks its HP. +-- +-- luajit tests/gen2_trap_escape_test.lua +-- +-- ROM-free. pokegold: core.asm TryToRunAwayFromBattle / TryEnemyFlee / +-- TryPlayerSwitch, effect_commands.asm BattleCommand_ArenaTrap / +-- BattleCommand_ForceSwitch, and BattleEnd_HandleRoamMons' non-WIN arm +-- (src/core/gen2/Roamers.endBattle). + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 trap escape") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") +local Roamers = require("src.core.gen2.Roamers") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + ELECTRIC = { id = "ELECTRIC", index = 23, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + MEAN_LOOK = { id = "MEAN_LOOK", name = "MEAN LOOK", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MEAN_LOOK" }, + WRAP = { id = "WRAP", name = "WRAP", power = 15, type = "NORMAL", + accuracy = 85, pp = 20, effect = "EFFECT_TRAP_TARGET" }, + ROAR = { id = "ROAR", name = "ROAR", power = 0, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" }, + WHIRLWIND = { id = "WHIRLWIND", name = "WHIRLWIND", power = 0, + type = "NORMAL", accuracy = 100, pp = 20, + effect = "EFFECT_FORCE_SWITCH" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local function species(id, index, speed) + return { + id = id, index = index, name = id, + baseStats = { hp = 70, attack = 80, defense = 50, speed = speed or 35, + specialAttack = 65, specialDefense = 65 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 100, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + } +end + +local POKEMON = { + growthRates = GROWTH, + MACHOP = species("MACHOP", 66, 35), + PIDGEY = species("PIDGEY", 16, 56), + -- The one species name that matters: Roamers.ALWAYS_FLEE keys off it. + RAIKOU = species("RAIKOU", 243, 115), +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function mon(id, level, moves) + local built = Mon.new(DATA, id, level, { dvs = perfect }) + local list = {} + for i, moveId in ipairs(moves or { "TACKLE" }) do + list[i] = { id = moveId, pp = 20, maxPp = 20 } + end + built.moves = list + return built +end + +local function findText(events, text) + for _, event in ipairs(events or {}) do + if (event.kind == "message" or event.kind == "run" + or event.kind == "send") and event.text == text then + return true + end + end + return false +end + +-- ---- Mean Look pins a roamer ---------------------------------------------- +do + local player = mon("MACHOP", 45, { "MEAN_LOOK", "TACKLE" }) + local raikou = mon("RAIKOU", 40) + local battle = Battle.new({ data = DATA, party = { player }, wild = raikou, + roaming = 1, random = rolls({}, 1) }) + + -- AlwaysFleeMons: with no pin the beast is gone before it can be fought. + eq(battle:tryEnemyFlee(), true, "an unpinned RAIKOU always flees") + battle.over, battle.outcome = false, nil + + battle:useMove(player, raikou, "MEAN_LOOK") + eq(player.volatile.trapsTarget, true, + "ArenaTrap: CANT_RUN lives on the USER's side") + check(findText(battle:takeEvents(), "RAIKOU can't escape now!"), + "CantEscapeNowText") + eq(battle:tryEnemyFlee(), false, + "TryEnemyFlee reads the player's substatus and stays") + + -- The pin dies with its user: a switch drops the volatile. + battle:clearVolatile(player) + eq(battle:tryEnemyFlee(), true, "with the user gone the beast flees again") +end + +-- ---- a wrap pins the flee and the run ------------------------------------- +do + local player = mon("MACHOP", 45, { "WRAP" }) + local raikou = mon("RAIKOU", 40) + local battle = Battle.new({ data = DATA, party = { player }, wild = raikou, + roaming = 1, random = rolls({ 1, 0, 0, 1 }, 1) }) + battle:useMove(player, raikou, "WRAP") + eq(raikou.volatile.wrapCount, 4, "the wrap count is live") + eq(battle:tryEnemyFlee(), false, "wEnemyWrapCount pins the flee") + + -- The mirrored gate: a WRAPPED PLAYER cannot run or switch voluntarily. + local player2 = mon("MACHOP", 45) + local bench = mon("PIDGEY", 40) + local wild = mon("PIDGEY", 40, { "WRAP" }) + local battle2 = Battle.new({ data = DATA, party = { player2, bench }, + wild = wild, random = rolls({ 1, 0, 0, 1 }, 1) }) + battle2:useMove(wild, player2, "WRAP") + eq(player2.volatile.wrapCount, 4, "the player's mon is wrapped") + eq(battle2:tryRun(), false, "TryToRunAwayFromBattle refuses") + check(findText(battle2:takeEvents(), "Can't escape!"), + "with the cart's line") + eq(battle2:switchLocked(), true, + "TryPlayerSwitch's .check_trapped refuses the voluntary switch") +end + +-- ---- Mean Look on the player pins the run and the switch ------------------- +do + local player = mon("MACHOP", 45) + local bench = mon("PIDGEY", 40) + local wild = mon("PIDGEY", 40, { "MEAN_LOOK" }) + local battle = Battle.new({ data = DATA, party = { player, bench }, + wild = wild, random = rolls({}, 1) }) + battle:useMove(wild, player, "MEAN_LOOK") + eq(wild.volatile.trapsTarget, true, "the wild side pinned the player") + eq(battle:tryRun(), false, "no running from a Mean Look") + eq(battle:switchLocked(), true, "and no voluntary switch either") + + -- Battle:switch (the faint path's entry too) breaks every trap on send. + battle:switch(2) + eq(battle:switchLocked(), false, "a send-out clears the pin") +end + +-- ---- FORCESHINY and TRAP forbid running ----------------------------------- +do + for _, battleType in ipairs({ Battle.BATTLETYPE_FORCESHINY, + Battle.BATTLETYPE_TRAP }) do + local player = mon("MACHOP", 45) + local wild = mon("PIDGEY", 30) + local battle = Battle.new({ data = DATA, party = { player }, + wild = wild, battleType = battleType, random = rolls({}, 1) }) + eq(battle:tryRun(), false, + "battle type " .. battleType .. " jumps straight to .cant_escape") + check(findText(battle:takeEvents(), "Can't escape!"), + "with the cart's line for type " .. battleType) + eq(battle.over, false, "the battle goes on") + + -- BattleCommand_ForceSwitch fails for the same two types. + player.moves = { { id = "ROAR", pp = 20, maxPp = 20 } } + battle:useMove(player, wild, "ROAR") + eq(battle.over, false, "Roar cannot end a type-" .. battleType .. " battle") + check(findText(battle:takeEvents(), "But it failed!"), + "ForceSwitch's .fail for type " .. battleType) + end +end + +-- ---- a wild mon's own Roar ends the battle -------------------------------- +do + -- The wild PIDGEY is level 40 against a level 40 player: `cp` with the + -- user's level at or above the target's succeeds outright. + local save = {} + Roamers.init(save) + local player = mon("MACHOP", 40) + local wild = mon("PIDGEY", 40, { "ROAR" }) + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = rolls({}, 1) }) + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + eq(battle.over, true, "the wild Roar ends the battle") + eq(battle.outcome, "fled", "as the DRAW that banks a roamer's HP") + check(findText(events, "MACHOP fled in fear!"), + "FledInFearText names the mon sent away") + + -- BattleEnd_HandleRoamMons' non-WIN arm: the outcome banks the HP. + Roamers.endBattle(save, 1, battle.outcome, 120, nil, rolls({}, 1)) + eq(Roamers.slot(save, 1).hp, 120, "the roamer slot banked the HP") + check(Roamers.slot(save, 1).species ~= nil, "and the beast is NOT retired") +end + +-- ---- the player's Roar on a wild mon -------------------------------------- +do + local player = mon("MACHOP", 45, { "ROAR" }) + local wild = mon("PIDGEY", 30) + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = rolls({}, 1) }) + battle:useMove(player, wild, "ROAR") + eq(battle.over, true, "a higher-level Roar sends the wild mon off") + eq(battle.outcome, "fled", "as a draw, not a win") + check(findText(battle:takeEvents(), "PIDGEY fled in fear!"), + "FledInFearText") +end + +-- ---- trainer Roar drags a bench mon out ----------------------------------- +do + local player = mon("MACHOP", 45) + local bench = mon("PIDGEY", 40) + local roarer = mon("PIDGEY", 40, { "ROAR" }) + local battle = Battle.new({ data = DATA, party = { player, bench }, + trainer = { class = "FALKNER_X", name = "TESTER", party = { roarer } }, + random = rolls({}, 1) }) + + -- The user must be moving SECOND (wEnemyGoesFirst): with the player first, + -- the enemy's Roar drags the player's bench mon out. + battle.firstMover = "player" + battle:useMove(roarer, player, "ROAR") + eq(battle.player, bench, "the bench PIDGEY was dragged out") + eq(battle.participants[2], true, "and counts as a participant") + check(findText(battle:takeEvents(), "PIDGEY was dragged out!"), + "DraggedOutText") + eq(battle.over, false, "a trainer battle goes on") + + -- Moving first, the same Roar fails. + local battle2 = Battle.new({ data = DATA, + party = { mon("MACHOP", 45), mon("PIDGEY", 40) }, + trainer = { class = "FALKNER_X", name = "TESTER", + party = { mon("PIDGEY", 40, { "ROAR" }) } }, + random = rolls({}, 1) }) + battle2.firstMover = "enemy" + battle2:useMove(battle2.enemy, battle2.player, "ROAR") + eq(battle2.player, battle2.party[1], "moving first, the Roar moves nobody") + check(findText(battle2:takeEvents(), "But it failed!"), + "ForceSwitch's `.switch_fail` needs the user to move second") + + -- With nothing on the bench there is nobody to drag. + local battle3 = Battle.new({ data = DATA, party = { mon("MACHOP", 45) }, + trainer = { class = "FALKNER_X", name = "TESTER", + party = { mon("PIDGEY", 40, { "ROAR" }) } }, + random = rolls({}, 1) }) + battle3.firstMover = "player" + battle3:useMove(battle3.enemy, battle3.player, "ROAR") + check(findText(battle3:takeEvents(), "But it failed!"), + "CheckPlayerHasMonToSwitchTo carries: no bench, no drag") +end + +S.finish() diff --git a/tests/gen2_unown_printer_test.lua b/tests/gen2_unown_printer_test.lua new file mode 100644 index 00000000..a6e50fe2 --- /dev/null +++ b/tests/gen2_unown_printer_test.lua @@ -0,0 +1,344 @@ +-- The Unown font (gfx/font/unown_font.png) and the ALPH RUINS STAMP viewer +-- (engine/events/print_unown.asm _UnownPrinter). +-- GOLD_CACHE="..." luajit tests/gen2_unown_printer_test.lua +-- +-- Two leftovers from the Ruins of Alph, and they share a suite because they +-- share a subject: the letters. The font is the alphabet UNOWN MODE prints +-- its ring and its word in, and the viewer is the screen that lays the caught +-- forms out for a printer this port does not have. +-- +-- Three things are worth pinning about the font: that the extractor block +-- exists and is reached from a stage run() actually calls, that the symbol it +-- reads is in the curated manifest set (a label missing from REQUIRED_SYMBOLS +-- makes self.symbols[label] nil and the block silently writes nothing -- the +-- exact way givepokemail and the trade texts were lost), and that what landed +-- in the cache has the shape of ../pokegold/gfx/font/unown_font.png. +-- +-- And two about the viewer: that its wheel is the cart's (27 slots, the 27th +-- VACANT, wrapping both ways) and that it is REACHED -- Screens id, World +-- hook, and a `special UnownPrinter` dispatched by name. +-- +-- ROM-free. The decomp section SKIPs with no ../pokegold beside the repo and +-- the cache section SKIPs (or asks for a re-import) with no Gold cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 unown printer") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local PokedexMenu = require("src.ui.gen2.PokedexMenu") +local Screens = require("src.ui.Screens") +local Specials = require("src.script.gen2.Specials") +local Unown = require("src.core.gen2.Unown") +local UnownPrinter = require("src.ui.gen2.UnownPrinter") +local Vm = require("src.script.gen2.Vm") + +-- NUM_UNOWN + 1 tiles on pret's 3-wide sheet, and FIRST_UNOWN_CHAR is $40. +local UNOWN_TILES = 27 +local UNOWN_WIDE = 3 +local FIRST_UNOWN_CHAR = 0x40 + +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 + +-- PNG IHDR: width and height are big-endian at bytes 17 and 21. +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 + +-- ---- the extractor -------------------------------------------------------- +-- The font is written inside the Fonts stage rather than as a stage of its +-- own, so what has to be proved here is that the block is in extractFont and +-- that extractFont is still called from run(). +local extractorSource = assert(readFile("src/import/RomExtractorGen2.lua", "r")) +check(extractorSource:find("results.font = self:extractFont()", 1, true) ~= nil, + "RomExtractorGen2:run still calls extractFont") +check(extractorSource:find('if self.symbols["UnownFont"] then', 1, true) ~= nil, + "and extractFont reads UnownFont out of the manifest") +check(extractorSource:find('"fonts/unown_font.png"', 1, true) ~= nil, + "and writes it as fonts/unown_font.png") +check(extractorSource:find("local UNOWN_FONT_TILES = 27", 1, true) ~= nil, + "27 tiles: NUM_UNOWN letters plus the cursor") +check(extractorSource:find("local UNOWN_FONT_WIDE = 3", 1, true) ~= nil, + "on pret's own 3-tile-wide sheet, so the decode IS the pret PNG") + +do + local manifestSource = assert(readFile("tools/make_gold_manifest.py", "r")) + check(manifestSource:find('"UnownFont"', 1, true) ~= nil, + "UnownFont is in make_gold_manifest.py's REQUIRED_SYMBOLS") +end + +local Json = require("src.link.Json") +do + local body = readFile("tools/rom_manifest_gold.json", "r") + if not body then + check(true, "no tools/rom_manifest_gold.json (SKIP)") + else + local symbols = (Json.decode(body).symbols) or {} + local got = symbols.UnownFont + if not got then + check(false, "the generated manifest carries UnownFont") + else + -- pokegold.sym: 3e:730e UnownFont. + eq(got[1], 0x3e, "UnownFont is in the bank pokegold.sym says") + eq(got[2], 0x730e, "and at the address pokegold.sym says") + end + end +end + +-- ---- the decomp ----------------------------------------------------------- +-- gfx/font/unown_font.png is the source of truth for the sheet's shape. Its +-- PIXELS were checked against the ROM at UnownFont directly (a row-major +-- 2bpp decode of 27 tiles is the PNG byte for byte, both planes equal, so the +-- glyphs are shade 3 on shade 0); what a Lua suite can read without a PNG +-- decoder is the header, which is what pins 3x9 tiles here. +do + local png = readFile("../pokegold/gfx/font/unown_font.png") + if not png then + check(true, "no ../pokegold: the font's shape is not pinned (SKIP)") + else + local w, h = pngSize(png) + eq(w, UNOWN_WIDE * 8, "gfx/font/unown_font.png is 3 tiles across") + eq(h, UNOWN_TILES / UNOWN_WIDE * 8, "and 9 down, which is 27 tiles") + end +end + +-- ---- the cache ------------------------------------------------------------ +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold") +local fontData = (function() + local chunk = loadfile(cache .. "/data/generated/font.lua") + return chunk and chunk() or nil +end)() + +if not fontData then + check(true, "no Gold cache: the font table is not read (SKIP)") +elseif not fontData.imageUnown then + check(true, + "cache predates the Unown font : re-import for gfx/font/unown_font (SKIP)") +else + eq(fontData.imageUnown, "assets/generated/fonts/unown_font.png", + "the sheet went where the #DEX looks for it") + eq(fontData.unownTiles, UNOWN_TILES, "27 tiles came out") + eq(fontData.unownWide, UNOWN_WIDE, "as a 3-wide sheet") + eq(fontData.unownBase, FIRST_UNOWN_CHAR, + "based at FIRST_UNOWN_CHAR, the VRAM tile Pokedex_LoadUnownFont loads at") + local png = readFile(cache .. "/assets/generated/fonts/unown_font.png") + if not png then + check(false, "the sheet PNG is actually in the cache") + else + local w, h = pngSize(png) + eq(w, UNOWN_WIDE * 8, "the cached sheet is 24px wide") + eq(h, UNOWN_TILES / UNOWN_WIDE * 8, + "and 72px tall, which is the pret PNG's own shape") + end +end + +-- ---- UNOWN MODE draws through it ------------------------------------------ +-- The ring of letters is `add FIRST_UNOWN_CHAR - 1` and the cursor is +-- FIRST_UNOWN_CHAR + NUM_UNOWN, so the mapping from a letter to a tile is the +-- whole of what this screen has to get right. The sheet itself never loads +-- in a harness with no real love, so it is replaced with a recorder. +do + local dexGame = { + data = { + pokemon = {}, + gen2MenuGfx = { pokedex = { tiles = "x.png", + palette = { { 255, 255, 255 }, { 200, 100, 0 }, { 100, 0, 0 }, + { 0, 0, 0 } } } }, + font = { imageUnown = "assets/generated/fonts/unown_font.png", + unownWide = UNOWN_WIDE, unownBase = FIRST_UNOWN_CHAR }, + }, + save = { pokedex = { seen = {}, caught = {} }, unownDex = { 3, 1 } }, + } + local dex = PokedexMenu.new(dexGame, {}) + check(dex.unownFont ~= nil, + "the #DEX builds an Unown font sheet when the cache carries one") + eq(dex.unownFontBase, FIRST_UNOWN_CHAR, "based at FIRST_UNOWN_CHAR") + + -- The dex's own sheet is a recorder too: love_stub's quads have no + -- viewport, so a real TileSheet cannot draw in this harness and the chrome + -- around the ring is not what is under test here. + local quiet = { available = function() return true end, + draw = function() return true end } + dex.sheet, dex.objs = quiet, quiet + + local drawn = {} + dex.unownFont = { + available = function() return true end, + draw = function(_, tile, tx, ty) + drawn[#drawn + 1] = { tile = tile, tx = tx, ty = ty } + return true + end, + } + dex:unownText("AZ", 4, 11) + eq(#drawn, 2, "both letters went through the Unown sheet") + eq(drawn[1].tile, FIRST_UNOWN_CHAR, "A is FIRST_UNOWN_CHAR") + eq(drawn[2].tile, FIRST_UNOWN_CHAR + 25, "Z is FIRST_UNOWN_CHAR + 25") + eq(drawn[2].tx, 5, "and the second glyph is one cell along") + drawn = {} + dex:unownCursor(3, 11) + eq(drawn[1] and drawn[1].tile, FIRST_UNOWN_CHAR + Unown.NUM_UNOWN, + "the cursor is the 27th tile, FIRST_UNOWN_CHAR + NUM_UNOWN") + + -- The word under the picture is spelled in the same font: unown_words.asm's + -- `unownword` macro is `CHARVAL(...) - 'A' + FIRST_UNOWN_CHAR`. + drawn = {} + dex.view = "unown" + local ok = pcall(function() dex:drawUnown() end) + check(ok, "UNOWN MODE draws") + local sawWord = false + for _, row in ipairs(drawn) do + if row.ty == 15 then sawWord = true end + end + check(sawWord, "and the word at hlcoord 4, 15 is Unown-font tiles too") + + -- A cache with no Unown font at all still prints the ring, in the ordinary + -- inverted font, exactly as this screen did before the sheet existed. + dexGame.data.font = {} + local plain = PokedexMenu.new(dexGame, {}) + check(plain.unownFont == nil, "no sheet in the cache, no sheet on the screen") + plain.sheet, plain.objs = quiet, quiet + plain.view = "unown" + check(pcall(function() plain:drawUnown() end), + "and UNOWN MODE still draws without it") +end + +-- ---- the viewer ----------------------------------------------------------- +local hasId = false +for _, id in ipairs(Screens.GEN2_IDS) do + if id == "Gen2UnownPrinter" then hasId = true end +end +check(hasId, "the viewer is pushed through the Gen2UnownPrinter Screens id") + +local function newViewer() + local pressed = {} + local game = { + data = { pokemon = {}, gen2Palettes = {} }, + input = { wasPressed = function(_, button) return pressed[button] end }, + } + local closed = 0 + local screen = UnownPrinter.new(game, { onClose = function() + closed = closed + 1 + end }) + return screen, pressed, function() return closed end +end + +do + local screen, pressed = newViewer() + eq(screen.index, 0, "the wheel starts on the first letter") + eq(screen:slots(), Unown.NUM_UNOWN + 1, + "27 slots: the 26 forms and the vacant stamp") + eq(screen:letter(), 1, "slot 0 is letter A") + + pressed.left = true + screen:update(0) + eq(screen.index, Unown.NUM_UNOWN, + "LEFT off the first slot wraps to the vacant one (.press_left)") + eq(screen:letter(), nil, "which has no letter to show") + pressed.left = nil + + pressed.right = true + screen:update(0) + eq(screen.index, 0, "RIGHT past the vacant slot comes back to A") + screen:update(0) + eq(screen.index, 1, "and otherwise steps one letter at a time") + pressed.right = nil +end + +do + local screen, pressed, closed = newViewer() + pressed.a = true + screen:update(0) + eq(closed(), 0, + "A is the print, and with no Game Boy Printer it does nothing at all") + check(not screen.done, "the viewer stays up, the way .pressed_a loops back") + pressed.a = nil + pressed.b = true + screen:update(0) + eq(closed(), 1, "B closes it (.pressed_b, ReturnToMapFromSubmenu)") + screen:update(0) + eq(closed(), 1, "and closing twice is not two closes") +end + +do + -- Drawing with no art at all: every pic lookup fails in this harness, so + -- this is a crash check, not a layout one. + local screen = newViewer() + check(pcall(function() screen:draw() end), "a letter slot draws") + screen.index = Unown.NUM_UNOWN + check(pcall(function() screen:draw() end), "and so does the vacant one") +end + +-- ---- the special ---------------------------------------------------------- +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") + +-- 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 +-- reachable by nobody. +do + local worldSource = assert(readFile("src/world/gen2/World.lua", "r")) + local hooks = worldSource:find("function World:specialHooks", 1, true) + local hook = worldSource:find("showUnownPrinter = function(onDone)", 1, true) + check(hooks ~= nil and hook ~= nil and hook > hooks, + "World:specialHooks is where showUnownPrinter is registered") + check(worldSource:find('self:pushScreen("Gen2UnownPrinter"', 1, true) ~= nil, + "and World:showUnownPrinter pushes the screen through the registry") +end + +-- Dispatch by NAME through the cache's specialOrder, the way every other +-- special is dispatched. +do + local ORDER = { [40] = "UnownPrinter" } -- 0-based id 39 in the real cache + local opened = 0 + local vm = Vm.new({}, {}, {}, { + specialOrder = ORDER, + specials = { + save = function() return { unownDex = { 1 } } end, + showUnownPrinter = function(done) opened = opened + 1 done() end, + }, + }) + vm.scriptVar = 7 + vm:runSpecial(39) + eq(opened, 1, "`special UnownPrinter` opens the viewer") + eq(vm.scriptVar, 7, + "and leaves wScriptVar alone, because the routine never writes it") + + -- `ld a, [wUnownDex] / and a / ret z`: no Unown caught, no screen. + local empty = 0 + local gated = Vm.new({}, {}, {}, { + specialOrder = ORDER, + specials = { + save = function() return { unownDex = {} } end, + showUnownPrinter = function(done) empty = empty + 1 done() end, + }, + }) + gated.scriptVar = 4 + gated:runSpecial(39) + eq(empty, 0, "an empty #DEX returns before the screen is drawn") + eq(gated.scriptVar, 4, "still without touching wScriptVar") + + -- No hook at all (a Vm with no World behind it) has to return, not hang. + local bare = Vm.new({}, {}, {}, + { specialOrder = ORDER, specials = {} }) + bare.scriptVar = 2 + bare:runSpecial(39) + eq(bare.scriptVar, 2, "and a hookless VM carries on untouched") +end + +S.finish() diff --git a/tests/gen2_unown_test.lua b/tests/gen2_unown_test.lua new file mode 100644 index 00000000..a5dc9e74 --- /dev/null +++ b/tests/gen2_unown_test.lua @@ -0,0 +1,534 @@ +-- The Ruins of Alph: the sliding-panel puzzle, and everything about Unown that +-- is a FORM rather than a species. +-- +-- ROM-free and draw-free. The puzzle's board, cursor and solve check are pure +-- functions over a 36-cell array, so the whole screen can be driven from a +-- stub input; the letter maths (GetUnownLetter, CheckUnownLetter, +-- UpdateUnownDex) is pure to begin with. What a test cannot say -- whether the +-- board LOOKS like the cart's -- is what a screenshot driver is for. + +package.path = "./?.lua;" .. package.path + +-- The UI modules pull love-side helpers in at load time. Stub what they touch; +-- 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) + if b then return a end + return a and 1 or 0.5 + 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, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +require("src.core.Logger").warn = function() end + +local Mon = require("src.battle.gen2.Mon") +local Save = require("src.core.gen2.Save") +local Screens = require("src.ui.Screens") +local Unown = require("src.core.gen2.Unown") +local UnownPuzzle = require("src.ui.gen2.UnownPuzzle") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- A scripted RNG in the src/battle/gen2 convention: `random(n)` is 0..n-1. +local function scripted(values) + local i = 0 + return function(n) + i = i + 1 + return (values[i] or 0) % (n or 256) + end +end + +-- ==================================================================== letters +-- +-- GetUnownLetter (engine/gfx/load_pics.asm) packs the middle two bits of the +-- four DVs as atk/def/spd/spc, divides by 10 and adds one. All-zero DVs are +-- letter A and all-15 DVs are the top of the range. +check("DVs 0/0/0/0 spell A", + Unown.letterFromDVs({ attack = 0, defense = 0, speed = 0, special = 0 }), 1) +check("DVs 15/15/15/15 spell Z", + Unown.letterFromDVs({ attack = 15, defense = 15, speed = 15, special = 15 }), + 26) +check("A is letter 1", Unown.name(1), "A") +check("Z is letter 26", Unown.name(26), "Z") +check("letter 27 is nothing", Unown.name(27), nil) +check('"C" indexes to 3', Unown.index("C"), 3) + +-- The middle bits are bits 1 and 2 of each DV, so bit 0 and bit 3 change +-- nothing. DVs 1 and 8 differ from 0 only outside the mask. +check("bit 0 of a DV is not part of the letter", + Unown.letterFromDVs({ attack = 1, defense = 1, speed = 1, special = 1 }), 1) +check("bit 3 of a DV is not part of the letter", + Unown.letterFromDVs({ attack = 8, defense = 8, speed = 8, special = 8 }), 1) + +-- The packed value is atk in bits 6-7, so an Attack DV of 2 (middle bits = 1) +-- alone is 64, and 64 / 10 + 1 is letter 7 (G). +check("attack DV 2 alone is G", + Unown.letterFromDVs({ attack = 2, defense = 0, speed = 0, special = 0 }), 7) + +-- dvsForLetter is the inverse, so every letter round-trips. +for letter = 1, 26 do + check("letter " .. letter .. " round-trips through its DVs", + Unown.letterFromDVs(Unown.dvsForLetter(letter)), letter) +end + +-- Mon.new stamps the form, and only on Unown. +local data = { + pokemon = { + UNOWN = { name = "UNOWN", index = 201, types = { "PSYCHIC" }, + baseStats = { hp = 48, attack = 72, defense = 48, speed = 48, + specialAttack = 72, specialDefense = 48 }, + growthRate = "MEDIUM_FAST", genderRatio = 0xff, levelMoves = {}, + letters = {} }, + RATTATA = { name = "RATTATA", index = 19, types = { "NORMAL" }, + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + growthRate = "MEDIUM_FAST", genderRatio = 0x7f, levelMoves = {} }, + growthRates = {}, + }, + moves = {}, +} +for letter = 1, 26 do + data.pokemon.UNOWN.letters[Unown.name(letter)] = { + spriteFront = "front/unown_" .. Unown.name(letter):lower() .. ".png", + spriteBack = "back/unown_" .. Unown.name(letter):lower() .. ".png", + } +end + +local unown = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(9) }) +check("a built Unown carries its letter", unown.unownLetter, 9) +check("and Unown.monLetter reads it back", Unown.monLetter(unown), 9) +local ratty = Mon.new(data, "RATTATA", 5) +check("nothing else carries one", ratty.unownLetter, nil) +check("and monLetter refuses it", Unown.monLetter(ratty), nil) + +check("the form picks its own pic", + Unown.formSprite(data.pokemon, 9, false), "front/unown_i.png") +check("and its own back pic", + Unown.formSprite(data.pokemon, 9, true), "back/unown_i.png") + +-- ============================================================== the four sets +-- +-- data/wild/unlocked_unowns.asm: A-K, L-R, S-W, X-Z, one ENGINE_* flag each, +-- and the runs are uneven because they were cut to the four chamber puzzles. +check("four unlock sets", #Unown.UNLOCK_SETS, 4) +check("A-K is eleven letters", + Unown.UNLOCK_SETS[1].last - Unown.UNLOCK_SETS[1].first + 1, 11) +check("L-R is seven", + Unown.UNLOCK_SETS[2].last - Unown.UNLOCK_SETS[2].first + 1, 7) +check("S-W is five", + Unown.UNLOCK_SETS[3].last - Unown.UNLOCK_SETS[3].first + 1, 5) +check("X-Z is three", + Unown.UNLOCK_SETS[4].last - Unown.UNLOCK_SETS[4].first + 1, 3) +check("the four sets cover all 26 letters", + Unown.UNLOCK_SETS[4].last, 26) +check("ENGINE_UNLOCKED_UNOWNS_A_TO_K is flag 42", Unown.UNLOCK_SETS[1].flag, 42) +check("ENGINE_UNOWN_DEX is flag 12", Unown.ENGINE_UNOWN_DEX, 12) + +-- Each chamber's .PuzzleComplete arm sets exactly one of them. +check("Kabuto unlocks A-K", Unown.PUZZLES[0].flag, 42) +check("Omanyte unlocks L-R", Unown.PUZZLES[1].flag, 43) +check("Aerodactyl unlocks S-W", Unown.PUZZLES[2].flag, 44) +check("Ho-Oh unlocks X-Z", Unown.PUZZLES[3].flag, 45) + +local flags = {} +check("nothing is unlocked on a fresh file", Unown.anyUnlocked(flags), false) +check("and no letter passes CheckUnownLetter", + Unown.letterUnlocked(1, flags), false) +flags[42] = true +check("solving Kabuto unlocks something", Unown.anyUnlocked(flags), true) +check("A is now legal", Unown.letterUnlocked("A", flags), true) +check("K is now legal", Unown.letterUnlocked("K", flags), true) +check("L is still locked", Unown.letterUnlocked("L", flags), false) +check("eleven letters are reachable", #Unown.unlockedLetters(flags), 11) +flags[45] = true +check("Ho-Oh adds three more", #Unown.unlockedLetters(flags), 14) +check("X is legal", Unown.letterUnlocked("X", flags), true) +check("W is not", Unown.letterUnlocked("W", flags), false) + +-- LoadEnemyMon's .GenerateDVs loop rerolls a locked letter. Feed a DV source +-- that would produce Z first and then A: only the second survives when just +-- A-K is open. +local onlyAK = { [42] = true } +local queue = { Unown.dvsForLetter(26), Unown.dvsForLetter(3) } +local at = 0 +local rolled = Unown.wildDVs(onlyAK, function() + at = at + 1 + return queue[at] or Unown.dvsForLetter(1) +end) +check("a locked letter is rerolled", Unown.letterFromDVs(rolled), 3) +check("and it took exactly two rolls", at, 2) + +-- With nothing unlocked at all the reroll does not run: ChooseWildEncounter +-- has already refused the encounter, so there is no legal letter to find and +-- the cart would spin. +local anyDVs = Unown.wildDVs({}, function() return Unown.dvsForLetter(26) end) +check("with no puzzle solved the roll is taken as-is", + Unown.letterFromDVs(anyDVs), 26) + +-- ================================================================ the #DEX +-- +-- UpdateUnownDex appends a NEW form to the first free slot and returns at once +-- for one already listed, so the list is catching order without duplicates. +local save = Save.newGame({ playerName = "GOLD" }) +check("a new file has an empty form list", Unown.count(save), 0) +check("registering C appends it", Unown.updateDex(save, 3), true) +check("registering A appends it", Unown.updateDex(save, 1), true) +check("registering C again does not", Unown.updateDex(save, 3), false) +check("two forms recorded", Unown.count(save), 2) +check("in catching order, C first", Unown.dex(save)[1], 3) +check("then A", Unown.dex(save)[2], 1) +check("Unown.caught knows C", Unown.caught(save, "C"), true) +check("and does not know B", Unown.caught(save, "B"), false) + +-- registerCatch is the AddPartyMon / SendMonIntoBox entry: it takes a MON and +-- falls through for anything that is not an Unown. +check("registerCatch ignores a Rattata", Unown.registerCatch(save, ratty), + false) +check("registerCatch records an Unown", Unown.registerCatch(save, unown), true) +check("three forms now", Unown.count(save), 3) +check("the newest is I", Unown.dex(save)[3], 9) + +-- data/pokemon/unown_words.asm, one word per form. X really is "XXXXX". +check("26 words", #Unown.WORDS, 26) +check("A is ANGRY", Unown.word(1), "ANGRY") +check("R is REASSURE", Unown.word("R"), "REASSURE") +check("X is XXXXX", Unown.word("X"), "XXXXX") +check("Z is ZOOM", Unown.word(26), "ZOOM") + +-- Save.normalize keeps the list and trims a corrupt one. +local grown = Save.newGame({ playerName = "GOLD" }) +for i = 1, 40 do grown.unownDex[i] = 1 end +Save.normalize(grown) +check("normalize trims the form list to NUM_UNOWN", #grown.unownDex, 26) + +-- ================================================================ the board +-- +-- .PuzzlePieceInitialPositions: sixteen ring cells, and the deal fills every +-- one of them exactly once. +check("sixteen start cells", #UnownPuzzle.START_CELLS, 16) +check("36 cells on the board", UnownPuzzle.CELLS, 36) +check("PUZZLE_BORDER is $ee", UnownPuzzle.BORDER_TILE, 0xee) +check("PUZZLE_VOID is $ef", UnownPuzzle.VOID_TILE, 0xef) + +local board = UnownPuzzle.deal(scripted({ 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15 })) +local placed, onRing = 0, true +for cell = 1, UnownPuzzle.CELLS do + if board[cell] ~= 0 then + placed = placed + 1 + local ring = false + for _, start in ipairs(UnownPuzzle.START_CELLS) do + if start == cell - 1 then ring = true end + end + if not ring then onRing = false end + end +end +check("a deal places sixteen panels", placed, 16) +check("all of them on the ring", onRing, true) +check("a fresh board is not solved", UnownPuzzle.isSolved(board), false) + +-- Even a degenerate RNG deals a legal board rather than spinning: every roll +-- lands on the same start cell and the fallback probes for the next free one. +local stuck = UnownPuzzle.deal(function() return 0 end) +local stuckPlaced = 0 +for cell = 1, UnownPuzzle.CELLS do + if stuck[cell] ~= 0 then stuckPlaced = stuckPlaced + 1 end +end +check("a fixed RNG still deals sixteen", stuckPlaced, 16) + +check("the solved configuration is the solved configuration", + UnownPuzzle.isSolved(UnownPuzzle.SOLVED), true) +-- One panel out of place is not solved. +local nearly = {} +for i = 1, UnownPuzzle.CELLS do nearly[i] = UnownPuzzle.SOLVED[i] end +nearly[8], nearly[9] = nearly[9], nearly[8] +check("two swapped panels are not solved", UnownPuzzle.isSolved(nearly), false) + +-- =============================================================== the cursor +-- +-- The board is NOT a rectangle: row 5 has only its two end cells, because the +-- START>CANCEL box sits in the middle of it. +local pc = UnownPuzzle.puzcoord +check("up from row 0 is refused", UnownPuzzle.moveCursor(pc(0, 3), "up"), nil) +check("up from row 1 lands on row 0", + UnownPuzzle.moveCursor(pc(1, 3), "up"), pc(0, 3)) +check("down from row 4 column 2 is refused", + UnownPuzzle.moveCursor(pc(4, 2), "down"), nil) +check("down from row 4 column 0 reaches START", + UnownPuzzle.moveCursor(pc(4, 0), "down"), pc(5, 0)) +check("down from row 4 column 5 reaches CANCEL", + UnownPuzzle.moveCursor(pc(4, 5), "down"), pc(5, 5)) +check("down from row 5 is refused", + UnownPuzzle.moveCursor(pc(5, 0), "down"), nil) +check("left from column 0 is refused", + UnownPuzzle.moveCursor(pc(2, 0), "left"), nil) +check("left from cell 0 is refused", UnownPuzzle.moveCursor(0, "left"), nil) +check("right from column 5 is refused", + UnownPuzzle.moveCursor(pc(2, 5), "right"), nil) +check("left from CANCEL jumps to START", + UnownPuzzle.moveCursor(pc(5, 5), "left"), pc(5, 0)) +check("right from START jumps to CANCEL", + UnownPuzzle.moveCursor(pc(5, 0), "right"), pc(5, 5)) +check("right along row 2 steps by one", + UnownPuzzle.moveCursor(pc(2, 2), "right"), pc(2, 3)) + +-- The cells the cursor can reach: the 6x6 grid minus row 5's four middle +-- cells, which no rule ever produces. +local reachable = { [0] = true } +local frontier = { 0 } +while #frontier > 0 do + local at = table.remove(frontier) + for _, direction in ipairs({ "up", "down", "left", "right" }) do + local to = UnownPuzzle.moveCursor(at, direction) + if to and not reachable[to] then + reachable[to] = true + frontier[#frontier + 1] = to + end + end +end +local count = 0 +for _ in pairs(reachable) do count = count + 1 end +check("32 cells are reachable", count, 32) +for col = 1, 4 do + check("row 5 column " .. col .. " is not reachable", + reachable[pc(5, col)], nil) +end + +-- UnownPuzzleCoordData's tilemap column: cell i sits at (1 + 3c, 3r), and the +-- interior 4x4 clears to PUZZLE_VOID while the ring clears to PUZZLE_BORDER. +local tx, ty = UnownPuzzle.cellTile(0) +check("cell 0 is at tile (1,0) x", tx, 1) +check("cell 0 is at tile (1,0) y", ty, 0) +tx, ty = UnownPuzzle.cellTile(pc(1, 1)) +check("cell (1,1) is at tile (4,3) x", tx, 4) +check("cell (1,1) is at tile (4,3) y", ty, 3) +tx = UnownPuzzle.cellTile(pc(5, 5)) +check("cell (5,5) is at tile x 16", tx, 16) +check("the interior clears to VOID", + UnownPuzzle.vacantTile(pc(2, 2)), UnownPuzzle.VOID_TILE) +check("the ring clears to BORDER", + UnownPuzzle.vacantTile(pc(0, 0)), UnownPuzzle.BORDER_TILE) +check("row 5 clears to BORDER", + UnownPuzzle.vacantTile(pc(5, 0)), UnownPuzzle.BORDER_TILE) + +-- ================================================================ the screen +-- +-- A stub input: `press` queues one frame of button presses. +local input = { pressed = {} } +function input:wasPressed(button) return self.pressed[button] == true end +function input:isDown(button) return self.pressed[button] == true end + +local closed = { count = 0 } +local function newPuzzle(puzzleId) + input.pressed = {} + closed.count, closed.solved = 0, nil + return UnownPuzzle.new({ input = input, data = {} }, { + puzzle = puzzleId or 0, + random = scripted({ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }), + onClose = function(solved) + closed.count = closed.count + 1 + closed.solved = solved + end, + }) +end + +local function press(screen, button) + input.pressed = { [button] = true } + screen:update(1 / 60) + input.pressed = {} +end + +local screen = newPuzzle(0) + +-- _CGB_UnownPuzzle (engine/gfx/cgb_layouts.asm) fills all four BG palettes from +-- PalPacket_UnownPuzzle, which is PREDEFPAL_UNOWN_PUZZLE four times, and then +-- WipeAttrmap puts every tile on palette 0: the board is BROWN, and the `ld a, +-- $e4` after it is the identity reorder of that palette rather than a grey +-- ramp. The screen above carries no menu_gfx at all, so this also pins the +-- fallback that colours a cache built before the extractor emitted the palette. +local function rgb(colors, index) + local c = colors and colors[index] + return c and (c[1] .. "," .. c[2] .. "," .. c[3]) or "nil" +end +check("BG colour 1 is the tan of PREDEFPAL_UNOWN_PUZZLE", + rgb(UnownPuzzle.PALETTE, 2), "197,165,90") +check("BG colour 2 is its dark brown", + rgb(UnownPuzzle.PALETTE, 3), "148,107,90") +check("BG colour 0 is white", rgb(UnownPuzzle.PALETTE, 1), "255,255,255") +check("BG colour 3 is black", rgb(UnownPuzzle.PALETTE, 4), "0,0,0") +-- wOBPals1 colour 0 is overwritten with `palred 31`, and `ld a, $24` reorders +-- OBJ pal 0 to entries 0, 1, 2, 0, so both of the cursor sheet's colours are it. +check("OBJ colour 0 is pure red", rgb(UnownPuzzle.CURSOR_PALETTE, 1), "255,0,0") +check("and colour 3 is the same red", + rgb(UnownPuzzle.CURSOR_PALETTE, 4), "255,0,0") +check("a screen with no menu_gfx still falls back to the BG palette", + rgb(screen.palette, 2), "197,165,90") +check("and to the cursor palette", + rgb(screen.cursorPalette, 4), "255,0,0") + +check("a new board starts on cell 0", screen.cursor, 0) +check("nothing is held", screen.holding, false) +check("and it is not solved", screen.solved, false) + +-- A on an occupied cell picks the panel up; a second A on the same cell is +-- refused, because the cell it came from is now empty and A with a panel held +-- only ever puts it DOWN. +local piece = screen:occupant(0) +press(screen, "a") +check("A picks the panel up", screen.holding, true) +check("and the panel is the one that was there", screen.held, piece) +check("leaving the cell empty", screen:occupant(0), 0) +press(screen, "a") +check("A puts it straight back", screen.holding, false) +check("and the cell holds it again", screen:occupant(0), piece) + +-- The cursor moves, and a refused move does not. +press(screen, "right") +check("right steps along row 0", screen.cursor, 1) +press(screen, "up") +check("up from row 0 does nothing", screen.cursor, 1) + +-- START quits with wSolvedUnownPuzzle still clear. +press(screen, "start") +check("START closes the screen", closed.count, 1) +check("reporting unsolved", closed.solved, false) +press(screen, "start") +check("and it only closes once", closed.count, 1) + +-- The solve: put the board one move from done and make that move. +screen = newPuzzle(2) +check("the picture id is kept", screen.puzzle, 2) +for cell = 1, UnownPuzzle.CELLS do + screen.pieces[cell] = UnownPuzzle.SOLVED[cell] +end +-- Lift panel 16 out of its home and drop it on the ring, which is the board +-- one A-press from solved once it is picked back up. +local home = pc(4, 4) +screen.pieces[home + 1] = 0 +screen.pieces[pc(0, 0) + 1] = 16 +screen.cursor = pc(0, 0) +press(screen, "a") +check("the stray panel is in hand", screen.held, 16) +screen.cursor = home +press(screen, "a") +check("dropping it home solves the puzzle", screen.solved, true) +check("the screen holds on the fanfare", screen.waiting, true) +check("and has not closed yet", closed.count, 0) +press(screen, "a") +check("A after the fanfare closes it", closed.count, 1) +check("reporting solved", closed.solved, true) + +-- A panel cannot be dropped on an occupied cell. +screen = newPuzzle(0) +screen.cursor = 0 +press(screen, "a") +check("a panel is held", screen.holding, true) +local occupied = nil +for cell = 0, UnownPuzzle.CELLS - 1 do + if screen:occupant(cell) ~= 0 then occupied = cell break end +end +screen.cursor = occupied +press(screen, "a") +check("dropping onto an occupied cell is refused", screen.holding, true) + +-- The board draws without art: no picture, no chrome sheet and no cursor sheet +-- in the harness, so every cell takes the labelled-cell fallback. This is a +-- crash check, not a layout one -- what the board LOOKS like is a screenshot +-- driver's job. +screen = newPuzzle(0) +local drewOk = pcall(function() screen:drawPanel() end) +check("an artless board still draws", drewOk, true) +screen.holding = true +screen.held = 4 +drewOk = pcall(function() screen:drawPanel() end) +check("and so does one with a panel in hand", drewOk, true) +screen.holding = false +screen.solved = true +drewOk = pcall(function() screen:drawPanel() end) +check("and so does a solved one", drewOk, true) + +-- ================================================================ the special +-- +-- `setval UNOWNPUZZLE_* / special UnownPuzzle / iftrue` -- the id goes IN +-- through wScriptVar and the answer comes back out through it. +local Vm = require("src.script.gen2.Vm") +local opened = {} +local vm = Vm.new({}, {}, {}, { + specialOrder = { [42] = "UnownPuzzle" }, + specials = { + unownPuzzle = function(puzzleId, done) + opened[#opened + 1] = puzzleId + done(opened.answer) + end, + }, +}) +vm.scriptVar = 2 +vm:runSpecial(41) +check("the special reads the picture id out of wScriptVar", opened[1], 2) +check("and an unsolved puzzle answers 0", vm.scriptVar, 0) +opened.answer = true +vm.scriptVar = 3 +vm:runSpecial(41) +check("Ho-Oh's id reaches the screen", opened[2], 3) +check("and a solved puzzle answers 1", vm.scriptVar, 1) + +-- A VM with no hook at all still answers, rather than leaving a stale +-- wScriptVar for the `iftrue` two commands later. +local bare = Vm.new({}, {}, {}, + { specialOrder = { [42] = "UnownPuzzle" }, specials = {} }) +bare.scriptVar = 3 +bare:runSpecial(41) +check("no hook answers unsolved", bare.scriptVar, 0) + +-- UnownPuzzle is no longer a stub. +local Specials = require("src.script.gen2.Specials") +check("UnownPuzzle has a handler", Specials.HANDLERS.UnownPuzzle ~= nil, true) +check("and is not also stubbed", Specials.STUBS.UnownPuzzle, nil) + +-- The screen goes through the registry, like every other Gold screen. +local registered = false +for _, id in ipairs(Screens.GEN2_IDS) do + if id == "Gen2UnownPuzzle" then registered = true end +end +check("Gen2UnownPuzzle is a screen id", registered, true) + +print(("gen2 unown: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit +-- here would take the whole tier down and silently skip every suite after it. +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_variable_sprites_test.lua b/tests/gen2_variable_sprites_test.lua new file mode 100644 index 00000000..98e125d6 --- /dev/null +++ b/tests/gen2_variable_sprites_test.lua @@ -0,0 +1,86 @@ +-- The new-game seed has to fill wVariableSprites, not just the event flags. +-- +-- luajit tests/gen2_variable_sprites_test.lua +-- +-- Found by the Gold route bot (tests/drivers/gold_bot.lua): it walked to the +-- Sudowoodo on Route 36 with the SQUIRTBOTTLE in the bag, faced (35,9), pressed +-- A and nothing happened -- because there was nothing there. +-- +-- SPRITE_WEIRD_TREE is $f4, and everything from $f0 up is a wVariableSprites +-- SLOT rather than a sheet (constants/sprite_constants.asm:147). An object +-- carrying one is extracted with a NUMBER in `sprite`, World:resolveSprite +-- answers nil until the slot is filled, and World:pooledNpc spawns nothing -- +-- which is faithful, because on the cart the slots are filled by +-- InitializeEventsScript, whose last nine commands are `variablesprite`. +-- +-- The port's seed replayed that script's `setevent` list and dropped the rest, +-- so a new game had no Sudowoodo -- and therefore no TM08 ROCK SMASH, no +-- Burned Tower, no Morty, no FOGBADGE and no SURF -- plus no Olivine rival, no +-- Azalea Rocket, no Fuchsia Gym Janines, no Copycat and no Janine +-- impersonator. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 variable sprites") +local check, eq = S.check, S.eq + +local World = require("src.world.gen2.World") + +-- The recovery path, in isolation: given the seed's flag list and a scripts +-- table containing the script those flags came from, find its variablesprite +-- rows. This is what lets a cache written before the extractor learned to +-- record them still put the tree on the map. +do + local w = setmetatable({ + initialEvents = { 10, 20, 30 }, + scripts = { + -- A decoy with some of the flags and a sprite row: must NOT match. + ["a:1"] = { + { op = "setevent", event = 10 }, + { op = "variablesprite", args = { 4, 99 } }, + }, + -- The real one: every seed flag, then the sprite assignments. + ["b:1"] = { + { op = "setevent", event = 10 }, + { op = "setevent", event = 20 }, + { op = "setevent", event = 30 }, + { op = "variablesprite", args = { 4, 55 } }, -- SPRITE_WEIRD_TREE + { op = "variablesprite", args = { 5, 56 } }, + { op = "end" }, + }, + }, + }, { __index = World }) + + local rows = w:findInitialSprites() + eq(#rows, 2, "found both variablesprite rows") + local bySlot = {} + for _, row in ipairs(rows) do bySlot[row.slot] = row.sprite end + eq(bySlot[4], 55, "slot 4 ($f4 SPRITE_WEIRD_TREE) takes its sprite") + eq(bySlot[5], 56, "slot 5 too") + check(bySlot[4] ~= 99, "the decoy script, which lacks two of the flags, lost") +end + +-- No seed list means no guessing: a cache with no flags must not match some +-- arbitrary script that happens to contain a variablesprite. +do + local w = setmetatable({ + initialEvents = {}, + scripts = { ["a:1"] = { { op = "variablesprite", args = { 4, 99 } } } }, + }, { __index = World }) + eq(#w:findInitialSprites(), 0, "an empty seed list matches nothing") +end + +-- resolveSprite is the half that reads it back, and the reason an unfilled +-- slot is invisible rather than merely wrong. +do + local w = setmetatable({ + variableSprites = {}, + constants = { spriteOrder = { [55] = "SPRITE_SUDOWOODO" } }, + }, { __index = World }) + eq(w:resolveSprite(0xf4), nil, "an unfilled slot resolves to nothing") + w.variableSprites[0xf4 - 0xf0] = 55 + eq(w:resolveSprite(0xf4), "SPRITE_SUDOWOODO", "a filled slot names a sheet") + eq(w:resolveSprite("SPRITE_NURSE"), "SPRITE_NURSE", + "an ordinary sprite name passes straight through") +end + +S.finish() diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua new file mode 100644 index 00000000..643e57cd --- /dev/null +++ b/tests/gen2_vm_test.lua @@ -0,0 +1,2553 @@ +-- Gen 2 script VM + movement decode smoke (no LOVE window). +-- luajit tests/gen2_vm_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 vm") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Movement = require("src.script.gen2.Movement") +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") + +-- Movement byte decode +eq(Movement.dir(0), "down", "facing 0 = down") +eq(Movement.dir(1), "up", "facing 1 = up") +local stepUp = Movement.decodeByte(0x0d) -- step | UP +eq(stepUp.kind, "step", "0x0d is step") +eq(stepUp.dir, "up", "0x0d steps up") +eq(Movement.decodeByte(0x03).kind, "turn", "0x03 turn_head RIGHT") +eq(Movement.decodeByte(0x47).kind, "end", "0x47 step_end") + +-- Minimal givepoke / yesorno / setscene path +local events = Events.new() +local texts = { ["t:yes"] = "Take it?", ["t:got"] = "Got {STRBUF}!" } +local scripts = { + generation = 2, + movements = { + ["m:up"] = { 0x0d, 0x47 }, + }, + ["s:ball"] = { + { op = "writetext", text = "t:yes" }, + { op = "yesorno" }, + { op = "iffalse", script = "s:no" }, + { op = "getmonname", species = 152 }, + { op = "writetext", text = "t:got" }, + { op = "givepoke", species = 152, level = 5, item = 173, trainer = 0 }, + { op = "setscene", scene = 1 }, + { op = "applymovement", object = 0, movement = "m:up" }, + { op = "end" }, + }, + ["s:no"] = { + { op = "end" }, + }, +} + +local log = {} +local scene = 0 +local moved = false +local party = {} +local vm = Vm.new(scripts, texts, events, { + showText = function(body, onDone) + log[#log + 1] = { "text", body } + onDone() + end, + yesorno = function(onChoose) + log[#log + 1] = { "yesorno" } + onChoose(true) + end, + getMonName = function(species) + return species == 152 and "CHIKORITA" or "?" + end, + givePoke = function(species, level) + log[#log + 1] = { "givepoke", species, level } + party[#party + 1] = { species = species, level = level } + end, + setScene = function(s) scene = s end, + getScene = function() return scene end, + applyMovement = function(object, bytes, onDone) + log[#log + 1] = { "move", object, #bytes } + moved = true + onDone() + end, +}) + +check(vm:start("s:ball"), "starter script starts") +-- Drain any wait/move resumes (yesorno/text resume synchronously here). +for _ = 1, 10 do vm:update() end +check(not vm:running(), "script finished") +eq(scene, 1, "setscene → 1") +check(moved, "applymovement ran") +eq(#party, 1, "givepoke added to party") +eq(party[1].species, 152, "species is Chikorita index") +local gotText +for _, row in ipairs(log) do + if row[1] == "text" and row[2]:find("CHIKORITA", 1, true) then + gotText = true + end +end +check(gotText, "getmonname filled STRBUF in received text") + +-- Phone + verbosegiveitem (Elm directions / aide potion) +local phone = {} +local bag = {} +local itemLog = {} +local phoneVm = Vm.new({ + generation = 2, + ["s:elm"] = { + { op = "addcellnum", phone = 4 }, + { op = "checkcellnum", phone = 4 }, + { op = "iffalse", script = "s:fail" }, + { op = "verbosegiveitem", item = 18, quantity = 1 }, + { op = "setscene", scene = 2 }, + { op = "end" }, + }, + ["s:fail"] = { { op = "end" } }, +}, {}, Events.new(), { + showText = function(body, onDone) + itemLog[#itemLog + 1] = body + onDone() + end, + addCell = function(id) phone[id] = true end, + hasCell = function(id) return phone[id] == true end, + getItemName = function(index) + return index == 18 and "POTION" or "?" + end, + giveItem = function(index, qty) + bag[index] = (bag[index] or 0) + (qty or 1) + return true + end, + specialSound = function() + itemLog[#itemLog + 1] = "sfx" + end, + setScene = function(s) scene = s end, +}) +check(phoneVm:start("s:elm"), "phone/item script starts") +for _ = 1, 20 do phoneVm:update() end +check(not phoneVm:running(), "phone/item script finished") +check(phone[4] == true, "addcellnum stored PHONE_ELM") +eq(bag[18], 1, "verbosegiveitem added POTION") +eq(scene, 2, "aide potion setscene → NOOP (2)") +local received, pocket +for _, body in ipairs(itemLog) do + if type(body) == "string" and body:find("received", 1, true) + and body:find("POTION", 1, true) then + received = true + end + if type(body) == "string" and body:find("ITEM POCKET", 1, true) then + pocket = true + end +end +check(received, "verbosegiveitem shows received text") +check(pocket, "verbosegiveitem shows pocket notify") + +-- pokemart. Script_pokemart farcalls OpenMartDialog, which does not return +-- until the shop is closed, so the VM has to PARK on it the way it parks on a +-- battle; and its mart id is a WORD, not a byte. +local Opcodes = require("src.script.gen2.Opcodes") +eq(Opcodes[0x93].name, "pokemart", "$93 is pokemart") +eq(Opcodes[0x93].size, 3, "and carries dialog + a word mart id") + +local martCalls, martLog = {}, {} +local martResume +local martVm = Vm.new({ + generation = 2, + ["s:clerk"] = { + { op = "opentext" }, + -- pokemart MARTTYPE_PHARMACY, MART_CIANWOOD -- the extractor leaves the + -- three operand bytes in `args` as dialog, lo, hi. + { op = "pokemart", args = { 3, 4, 0 } }, + { op = "writetext", text = "t:bye" }, + { op = "end" }, + }, +}, { ["t:bye"] = "All right." }, Events.new(), { + showText = function(body, onDone) + martLog[#martLog + 1] = body + onDone() + end, + openMart = function(martType, martId, onDone) + martCalls[#martCalls + 1] = { martType, martId } + martResume = onDone + end, +}) +check(martVm:start("s:clerk"), "clerk script starts") +eq(#martCalls, 1, "pokemart opened one mart") +eq(martCalls[1][1], 3, "dialog byte is MARTTYPE_PHARMACY") +eq(martCalls[1][2], 4, "mart id is MART_CIANWOOD") +eq(#martLog, 0, "nothing past the shop runs while it is open") +check(martVm:running(), "the script is parked on the shop") +martResume() +eq(#martLog, 1, "closing the shop resumes the script") +check(not martVm:running(), "and the clerk script finishes") + +-- The high operand byte is a real byte: a mart id of $0121 must not read as +-- $21. No mart is that far up the table, but a mis-shifted word would walk +-- the shelf and never say so. +local wordCalls = {} +local wordVm = Vm.new({ + generation = 2, + ["s:word"] = { + { op = "pokemart", args = { 0, 0x21, 0x01 } }, + { op = "end" }, + }, +}, {}, Events.new(), { + openMart = function(martType, martId, onDone) + wordCalls[#wordCalls + 1] = { martType, martId } + onDone() + end, +}) +check(wordVm:start("s:word"), "word-id script starts") +eq(wordCalls[1][2], 0x121, "mart id reads lo + hi * 256") +eq(wordCalls[1][1], 0, "and MARTTYPE_STANDARD is dialog 0") + +-- A VM with no mart hook (the trainer-script harness, a headless driver) walks +-- straight past the shop rather than hanging on a resume nobody will call. +local skipLog = {} +local skipVm = Vm.new({ + generation = 2, + ["s:skip"] = { + { op = "pokemart", args = { 0, 1, 0 } }, + { op = "writetext", text = "t:bye" }, + { op = "end" }, + }, +}, { ["t:bye"] = "Please come again!" }, Events.new(), { + showText = function(body, onDone) + skipLog[#skipLog + 1] = body + onDone() + end, +}) +check(skipVm:start("s:skip"), "hookless mart script starts") +eq(#skipLog, 1, "the script runs on without a mart hook") +check(not skipVm:running(), "and finishes") + +-- --------------------------------------------------------------------------- +-- Script command coverage (engine/overworld/scripting.asm). +-- +-- Every block below drives a REAL Vm over a hand-built command list with stub +-- hooks and asserts the two things a wrong transcription gets wrong: the +-- wScriptVar the command leaves, and the hook it called with which arguments. +-- The arg SHAPES are the ones RomExtractorGen2 produces, so `args` here matches +-- what data/generated/scripts.lua actually carries. +-- --------------------------------------------------------------------------- + +-- Text / yesorno / menu / battle hooks all resume synchronously, so a whole +-- script runs inside `drive` and the assertions stay flat. The frame drain +-- covers `pause` / `earthquake` / `showemote` waits and the waitsfx park. +local function drive(scriptTable, hooks, texts, key) + scriptTable.generation = 2 + local log = {} + local base = { + showText = function(body, onDone) + log[#log + 1] = "text:" .. tostring(body) + onDone() + end, + yesorno = function(onChoose) + log[#log + 1] = "yesorno" + onChoose(true) + end, + waitSfx = function() return true end, + } + for k, v in pairs(hooks or {}) do base[k] = v end + local vm = Vm.new(scriptTable, texts or {}, Events.new(), base) + vm:start(key or "s:t") + for _ = 1, 4000 do + if not vm:running() then break end + vm:update() + end + return vm, log +end + +-- setval / addval. Script_addval is `add [hl]` into wScriptVar, so the result +-- wraps at 8 bits: `addval -1` (args = {255}) is how a script counts DOWN. +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 1 }, + { op = "addval", args = { 255 } }, + } }) + eq(vm.scriptVar, 0, "addval 255 wraps 1 back to 0, not 256") + local down = drive({ ["s:t"] = { + { op = "setval", value = 0 }, + { op = "addval", args = { 2 } }, + { op = "addval", args = { 255 } }, + } }) + eq(down.scriptVar, 1, "addval counts up then back down") +end + +-- ifgreater / ifless. The operand order is REVERSED between the two in the +-- asm, so this is the one polarity worth a table: ifgreater jumps when +-- wScriptVar > value, ifless when wScriptVar < value. +do + local cases = { + { var = 2, op = "ifgreater", value = 1, jump = true }, + { var = 2, op = "ifless", value = 1, jump = false }, + { var = 0, op = "ifless", value = 1, jump = true }, + { var = 0, op = "ifgreater", value = 1, jump = false }, + { var = 1, op = "ifgreater", value = 1, jump = false }, + { var = 1, op = "ifless", value = 1, jump = false }, + } + for _, c in ipairs(cases) do + -- The jump arm is detectable by what it leaves behind: only the target + -- writes hLastTalked. + local vm = drive({ + ["s:t"] = { + { op = "setval", value = c.var }, + { op = c.op, value = c.value, script = "s:jump" }, + }, + ["s:jump"] = { { op = "setlasttalked", args = { 99 } } }, + }) + eq(vm.lastTalked == 99, c.jump, ("%s value=%d with scriptVar=%d"):format( + c.op, c.value, c.var)) + end +end + +-- checktime. CheckTime.TimeOfDayTable has no DARKNESS_F row, so IsInArray +-- fails in a pitch-black cave and every mask reads FALSE, ANYTIME included. +do + local function timeAnswer(timeOfDay, mask) + local vm = drive({ ["s:t"] = { { op = "checktime", args = { mask } } } }, + { getTimeOfDay = function() return timeOfDay end }) + return vm.scriptVar + end + eq(timeAnswer(1, 2), 1, "checktime DAY is true at DAY_F") + eq(timeAnswer(1, 4), 0, "checktime NITE is false at DAY_F") + eq(timeAnswer(2, 4), 1, "checktime NITE is true at NITE_F") + eq(timeAnswer(0, 7), 1, "checktime ANYTIME is true at MORN_F") + eq(timeAnswer(3, 7), 0, + "checktime ANYTIME is FALSE in darkness (no DARKNESS_F row)") +end + +-- checkmapscene: a map with no scene_var row answers $ff, not 0, so an +-- `ifequal 0` after it must not match it. +do + local seen = {} + local vm = drive({ ["s:t"] = { { op = "checkmapscene", args = { 2, 3 } } } }, { + getMapScene = function(g, m) seen = { g, m }; return nil end, + }) + eq(vm.scriptVar, 0xff, "checkmapscene answers $ff for a sceneless map") + eq(seen[1], 2, "checkmapscene passes the group byte first") + eq(seen[2], 3, "checkmapscene passes the map byte second") + local hit = drive({ ["s:t"] = { { op = "checkmapscene", args = { 2, 3 } } } }, { + getMapScene = function() return 0 end, + }) + eq(hit.scriptVar, 0, "a map WITH a scene of 0 answers 0") +end + +-- readmem / addval / writemem, the Goldenrod underground switch triple. With +-- no hook at all the VM's own sparse store has to stay self-consistent. +do + local vm = drive({ ["s:t"] = { + { op = "readmem", args = { 0xa8, 0xd6 } }, + { op = "addval", args = { 1 } }, + { op = "writemem", args = { 0xa8, 0xd6 } }, + { op = "readmem", args = { 0xa8, 0xd6 } }, + { op = "addval", args = { 1 } }, + { op = "writemem", args = { 0xa8, 0xd6 } }, + { op = "readmem", args = { 0xa8, 0xd6 } }, + } }) + eq(vm.scriptVar, 2, "readmem/addval/writemem round-trips through the VM store") + eq(vm.mem[0xd6a8], 2, "and the byte lands at lo + hi * 256") + -- loadmem writes a LITERAL third byte instead of wScriptVar. + local lm = drive({ ["s:t"] = { + { op = "setval", value = 99 }, + { op = "loadmem", args = { 0x00, 0xc0, 7 } }, + { op = "readmem", args = { 0x00, 0xc0 } }, + } }) + eq(lm.scriptVar, 7, "loadmem stores its own operand, not wScriptVar") + -- The hook claims an address; anything it does not claim stays local. + local claimed = {} + local hooked = drive({ ["s:t"] = { + { op = "setval", value = 5 }, + { op = "writemem", args = { 0x11, 0xd1 } }, + } }, { + writeMem = function(addr, value) claimed[addr] = value; return true end, + }) + eq(claimed[0xd111], 5, "writeMem hook takes the byte") + eq(hooked.mem[0xd111], nil, "and a claimed address does not shadow into mem") + + -- The store rides the save (save.scriptMem, src/core/gen2/Save.lua), so it + -- has to survive a serialize / restore pair the way the event bitfield does. + vm.mem[0xd7f1] = 0 + local dumped = vm:serializeMem() + eq(dumped[0xd6a8], 2, "serializeMem hands the switch byte to the save") + eq(dumped[0xd7f1], nil, "and drops zeroes, which already read back as 0") + -- A fresh VM handed that table back reads the same byte, which is the whole + -- point: the switch room picks up where the last save left it. + local readSwitch = { generation = 2, ["s:t"] = { + { op = "readmem", args = { 0xa8, 0xd6 } }, + } } + local reloaded = Vm.new(readSwitch, {}, Events.new(), {}) + reloaded:restoreMem(dumped) + reloaded:start("s:t") + eq(reloaded.scriptVar, 2, "a restored store answers the next readmem") + -- A serialized file can hand the keys back as strings; indexing by number + -- afterwards would silently read 0 without the tonumber in restoreMem. + local strings = Vm.new(readSwitch, {}, Events.new(), {}) + strings:restoreMem({ ["54952"] = "4" }) + strings:start("s:t") + eq(strings.scriptVar, 4, "restoreMem coerces string keys and values") +end + +-- setflag / clearflag / checkflag. ENGINE_* is a different namespace from +-- setevent's wEventFlags, and it must NOT fire onFlagsChanged: engine flags +-- never gate object visibility, so a badge cannot pop an NPC in mid-script. +do + local rebuilds = 0 + local vm = drive({ ["s:t"] = { + { op = "setflag", flag = 32 }, + { op = "checkflag", flag = 32 }, + } }, { onFlagsChanged = function() rebuilds = rebuilds + 1 end }) + eq(vm.scriptVar, 1, "checkflag reads back the flag setflag wrote") + eq(rebuilds, 0, "engine flags do not trigger an object rebuild") + local cleared = drive({ ["s:t"] = { + { op = "setflag", flag = 16 }, + { op = "clearflag", flag = 16 }, + { op = "checkflag", flag = 16 }, + } }) + eq(cleared.scriptVar, 0, "clearflag ENGINE_BUG_CONTEST_TIMER clears it") + -- checkevent must not see an engine flag and vice versa. + local split = drive({ ["s:t"] = { + { op = "setflag", flag = 26 }, + { op = "checkevent", event = 26 }, + } }) + eq(split.scriptVar, 0, "setflag does not write the wEventFlags namespace") + -- With a hook, the store lives in the World / save instead. + local store, reads = {}, 0 + local hooked = drive({ ["s:t"] = { + { op = "setflag", flag = 41 }, + { op = "checkflag", flag = 41 }, + } }, { + setEngineFlag = function(f, v) store[f] = v end, + getEngineFlag = function(f) reads = reads + 1; return store[f] end, + }) + eq(hooked.scriptVar, 1, "the engine-flag hooks round-trip") + eq(reads, 1, "checkflag prefers the hook over the local table") +end + +-- checkver defaults to Gold, so the `iftrue` fork after it is the SILVER arm. +do + local gold = drive({ ["s:t"] = { { op = "checkver" } } }) + eq(gold.scriptVar, 0, "checkver with no hook plays the Gold branch") + local silver = drive({ ["s:t"] = { { op = "checkver" } } }, + { gsVersion = function() return 1 end }) + eq(silver.scriptVar, 1, "checkver reports Silver when the hook says so") +end + +-- delcmdqueue answers TRUE when there was nothing to delete: DelCmdQueue's +-- `ret c` path leaves wScriptVar at 0, and this port's queue is always empty. +do + local vm = drive({ ["s:t"] = { { op = "delcmdqueue", args = { 0 } } } }) + eq(vm.scriptVar, 1, "delcmdqueue on an empty queue answers TRUE") +end + +-- farscall returns, farsjump does not. +do + local call = drive({ + ["s:t"] = { + { op = "farscall", script = "s:far" }, + { op = "setlasttalked", args = { 7 } }, + }, + ["s:far"] = { { op = "setval", value = 3 } }, + }) + eq(call.scriptVar, 3, "farscall ran the target") + eq(call.lastTalked, 7, "and came back to the command after it") + local jump = drive({ + ["s:t"] = { + { op = "farsjump", script = "s:far" }, + { op = "setlasttalked", args = { 7 } }, + }, + ["s:far"] = { { op = "setval", value = 3 } }, + }) + eq(jump.scriptVar, 3, "farsjump ran the target") + eq(jump.lastTalked, nil, "and never came back") +end + +-- memjump is a jump into WRAM: no target to run, but the list still ends. +do + local vm = drive({ ["s:t"] = { + { op = "memjump", args = { 2, 3 } }, + { op = "setlasttalked", args = { 5 } }, + } }) + eq(vm.lastTalked, nil, "memjump ends the list rather than falling through") +end + +-- random. `random 0` returns with wScriptVar still holding the 0 it stored. +do + local zero = drive({ ["s:t"] = { { op = "random", args = { 0 } } } }) + eq(zero.scriptVar, 0, "random 0 is 0, not an error") + local lo, hi = 99, -1 + for _ = 1, 200 do + local vm = drive({ ["s:t"] = { { op = "random", args = { 6 } } } }) + if vm.scriptVar < lo then lo = vm.scriptVar end + if vm.scriptVar > hi then hi = vm.scriptVar end + end + check(lo >= 0 and hi <= 5, "random 6 stays inside 0..5") +end + +-- getnum prints wScriptVar into the string buffer (PRINTNUM_LEFTALIGN, so no +-- padding survives). +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 17 }, + { op = "addval", args = { 1 } }, + { op = "getnum", args = { 0 } }, + } }) + eq(vm.stringBuffer, "18", "getnum writes wScriptVar into the string buffer") +end + +-- writevar / loadvar. loadvar is what arms VAR_BATTLETYPE for Lugia, the Red +-- Gyarados and the Cherrygrove rival, and it does NOT get a cmd.var from the +-- extractor: the named branch matches readvar / writevar only. +do + local wrote = {} + drive({ ["s:t"] = { { op = "loadvar", args = { 3, 10 } } } }, + { writeVar = function(id, value) wrote[#wrote + 1] = { id, value } end }) + eq(wrote[1][1], 3, "loadvar takes VAR_BATTLETYPE from args[1]") + eq(wrote[1][2], 10, "and BATTLETYPE_FORCEITEM from args[2]") + local w2 = {} + drive({ ["s:t"] = { + { op = "setval", value = 9 }, + { op = "writevar", var = 3 }, + } }, { writeVar = function(id, value) w2 = { id, value } end }) + eq(w2[2], 9, "writevar takes its value from wScriptVar") +end + +-- callasm cannot be run, and must NOT invent a wScriptVar: FindItemInBallScript +-- and FruitTreeScript both branch on whatever the routine left there. +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 4 }, + { op = "callasm", args = { 0x3e, 0x00, 0x40 } }, + } }) + eq(vm.scriptVar, 4, "callasm leaves wScriptVar alone with no hook") + local seen + local hooked = drive({ ["s:t"] = { + { op = "setval", value = 4 }, + { op = "callasm", args = { 0x3e, 0x34, 0x12 } }, + } }, { callAsm = function(label, bank, addr) + seen = { label, bank, addr } + return 1 + end }) + eq(hooked.scriptVar, 1, "a hook that answers writes wScriptVar") + eq(seen[2], 0x3e, "callasm passes the bank byte") + eq(seen[3], 0x1234, "and the address as lo + hi * 256") +end + +-- Map objects: appear, disappear LAST_TALKED, moveobject, variablesprite. +do + local appeared, hidden, moved, sprites = {}, {}, {}, {} + local vm = drive({ ["s:t"] = { + { op = "setlasttalked", args = { 6 } }, + { op = "appear", args = { 3 } }, + { op = "disappear", args = { 0xfe }, object = 0xfe }, + { op = "moveobject", args = { 2, 18, 11 } }, + { op = "variablesprite", args = { 4, 40 } }, + } }, { + appear = function(o) appeared[#appeared + 1] = o end, + disappear = function(o) hidden[#hidden + 1] = o end, + moveObject = function(o, x, y) moved = { o, x, y } end, + variableSprite = function(s, i) sprites[#sprites + 1] = { s, i } end, + }) + eq(appeared[1], 3, "appear takes its object id from args[1]") + eq(hidden[1], 6, "disappear LAST_TALKED ($fe) resolves to hLastTalked") + eq(moved[1], 2, "moveobject object id") + eq(moved[2], 18, "moveobject x is a plain map cell") + eq(moved[3], 11, "moveobject y is a plain map cell") + eq(sprites[1][1], 4, "variablesprite slot is already SPRITE_VARS-relative") + eq(sprites[1][2], 40, "variablesprite sprite index") + eq(vm.variableSprites[4], 40, "and the VM keeps its own wVariableSprites") +end + +-- loademote EMOTE_FROM_MEM ($ff) means "the emote already in wScriptVar". +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 2 }, + { op = "loademote", args = { 0xff } }, + } }) + eq(vm.loadedEmote, 2, "loademote EMOTE_FROM_MEM reads wScriptVar") + local lit = drive({ ["s:t"] = { { op = "loademote", args = { 5 } } } }) + eq(lit.loadedEmote, 5, "and a literal id is taken as-is") +end + +-- changeblock: the script's x/y are CELLS and the block written is (x/2, y/2). +-- BrunosRoom's `changeblock 4, 2, $16 ; open door` is block (2, 1), which is +-- the block its KARENS_ROOM warp_events on cells (4,2) and (5,2) sit in. +do + local blocks = {} + drive({ ["s:t"] = { + { op = "changeblock", args = { 4, 2, 0x16 } }, + { op = "changeblock", args = { 6, 2, 0x1e } }, + } }, { changeBlock = function(x, y, b) blocks[#blocks + 1] = { x, y, b } end }) + eq(blocks[1][1], 2, "changeblock halves x into a block column") + eq(blocks[1][2], 1, "changeblock halves y into a block row") + eq(blocks[1][3], 0x16, "and passes the block id through") + eq(blocks[2][1], 3, "MahoganyMart1F's stairs land on block (3, 1)") +end + +-- changemapblocks: a `dba`, which is `dbw bank, address`, and GetScriptByte +-- reads it in that order -- bank, then the pointer low byte, then the high one. +-- The pointer stays raw here; World:changeMapBlocks is what places it against +-- the blockdata address every map carries. wScriptVar is not part of the +-- command, so a script branching on it after one must still see its own value. +do + local seen + local vm = drive({ ["s:t"] = { + { op = "setval", value = 9 }, + { op = "changemapblocks", args = { 0x60, 0x34, 0x62 } }, + } }, { changeMapBlocks = function(bank, address) seen = { bank, address } end }) + eq(seen[1], 0x60, "the first byte is the blockdata bank") + eq(seen[2], 0x6234, "and the next two are a little-endian pointer") + eq(vm.scriptVar, 9, "changemapblocks leaves wScriptVar alone") + local none = drive({ ["s:t"] = { + { op = "changemapblocks", args = { 0x60, 0x34, 0x62 } }, + } }) + local empty = true + for _ in pairs(none.unknownOps) do empty = false end + check(empty, "and an unhooked world does not reach the unknown-op path") +end + +-- earthquake: ONE byte carries two numbers. `earthquake 80` is a displacement +-- of 80 held for byte & $3f = 16 frames, and the hold is the script's wait. +do + local quake + local vm = Vm.new({ generation = 2, ["s:t"] = { + { op = "earthquake", args = { 80 } }, + { op = "setlasttalked", args = { 4 } }, + } }, {}, Events.new(), { + earthquake = function(displacement, frames) quake = { displacement, frames } end, + }) + vm:start("s:t") + eq(quake[1], 80, "earthquake displacement is the whole byte") + eq(quake[2], 16, "earthquake sleeps byte & $3f frames") + check(vm:running(), "and the script is parked on that wait") + eq(vm.lastTalked, nil, "nothing after it runs while the ground shakes") + for _ = 1, 20 do vm:update() end + eq(vm.lastTalked, 4, "the script resumes when the shake ends") +end + +-- warp / warpfacing. Plain map cells, and NOT a terminator: std_scripts.asm's +-- BugContestResultsWarpScript walks the player in with an applymovement right +-- after its `warp`. +do + local warps = {} + local vm = drive({ ["s:t"] = { + { op = "warp", args = { 15, 3, 25, 1 } }, + { op = "setlasttalked", args = { 8 } }, + } }, { warpTo = function(g, m, x, y, f) warps[#warps + 1] = { g, m, x, y, f } end }) + eq(warps[1][1], 15, "warp group") + eq(warps[1][2], 3, "warp map") + eq(warps[1][3], 25, "warp x cell") + eq(warps[1][4], 1, "warp y cell") + eq(warps[1][5], nil, "plain warp carries no facing") + eq(vm.lastTalked, 8, "warp does not end the script") + local faced = {} + drive({ ["s:t"] = { { op = "warpfacing", args = { 2, 15, 3, 25, 1 } } } }, + { warpTo = function(g, m, x, y, f) faced = { g, m, x, y, f } end }) + eq(faced[1], 15, "warpfacing pushes the map_id past the facing byte") + eq(faced[5], "left", "warpfacing masks its byte to a direction") + -- Group 0 is the routine's own error arm: a re-entry, not a trip. + local reloads = 0 + drive({ ["s:t"] = { { op = "warp", args = { 0, 0, 0, 0 } } } }, { + warpTo = function() error("group 0 must not warp") end, + reloadMap = function() reloads = reloads + 1 end, + }) + eq(reloads, 1, "warp group 0 is MAPSETUP_BADWARP, a re-entry") +end + +-- The warp side commands that only record state. +do + local mods, blackout, method, checks, sounds = {}, {}, nil, 0, 0 + drive({ ["s:t"] = { + { op = "warpmod", args = { 1, 15, 3 } }, + { op = "blackoutmod", args = { 26, 3 } }, + { op = "newloadmap", args = { 249 } }, + { op = "warpcheck" }, + { op = "warpsound" }, + } }, { + setWarpMod = function(w, g, m) mods = { w, g, m } end, + setBlackoutMap = function(g, m) blackout = { g, m } end, + newLoadMap = function(x) method = x end, + warpCheck = function() checks = checks + 1 end, + warpSound = function() sounds = sounds + 1 end, + }) + eq(mods[1], 1, "warpmod warp id comes first") + eq(mods[2], 15, "then the map_id group") + eq(blackout[1], 26, "blackoutmod group") + eq(blackout[2], 3, "blackoutmod map (CHERRYGROVE_CITY)") + eq(method, 249, "newloadmap passes MAPSETUP_TRAIN ($f9) through") + eq(checks, 1, "warpcheck arms the pending warp") + eq(sounds, 1, "warpsound asks the World for GetWarpSFX") +end + +-- Music. musicfadeout's operand is a WORD id then a fade byte whose bit 7 +-- (MUSIC_FADE_IN_F) is masked off. +do + local fade, mapMusic, dontRestart = {}, 0, 0 + local vm = drive({ ["s:t"] = { + { op = "musicfadeout", args = { 56, 0, 16 } }, + { op = "playmapmusic" }, + { op = "dontrestartmapmusic" }, + } }, { + fadeOutMusic = function(id, control) fade = { id, control } end, + playMapMusic = function() mapMusic = mapMusic + 1 end, + dontRestartMapMusic = function() dontRestart = dontRestart + 1 end, + }) + eq(fade[1], 56, "musicfadeout reads its id as lo + hi * 256") + eq(fade[2], 16, "and its fade control") + eq(mapMusic, 1, "playmapmusic asks for the map's own song") + eq(dontRestart, 1, "dontrestartmapmusic sets the one-shot") + check(vm.dontRestartMapMusic, "and the VM remembers it") + local high = drive({ ["s:t"] = { { op = "musicfadeout", args = { 1, 0, 0x90 } } } }, + { fadeOutMusic = function(_, control) fade = control end }) + eq(fade, 0x10, "MUSIC_FADE_IN_F (bit 7) is masked out of the fade byte") +end + +-- Encounters. +do + local switches = {} + local vm = drive({ ["s:t"] = { + { op = "wildoff" }, + { op = "wildon" }, + } }, { setWildEncounters = function(on) switches[#switches + 1] = on end }) + eq(switches[1], false, "wildoff sets STATUSFLAGS_NO_WILD_ENCOUNTERS_F") + eq(switches[2], true, "wildon clears it") + check(vm.wildEncounters, "and the VM tracks the switch itself") + local rolled = 0 + local roll = drive({ ["s:t"] = { + { op = "loadtrainer", class = 1, member = 1 }, + { op = "randomwildmon" }, + } }, { rollWild = function() + rolled = rolled + 1 + return { species = 21, level = 4 } + end }) + eq(rolled, 1, "randomwildmon rolls the map's own table") + eq(roll.wildMon.species, 21, "and parks the pick for startbattle") + eq(roll.trainer, nil, "clearing wBattleScriptFlags drops the trainer too") + local pika = drive({ ["s:t"] = { { op = "loadpikachudata" } } }) + eq(pika.wildMon.species, 25, "loadpikachudata is PIKACHU") + eq(pika.wildMon.level, 5, "at level 5") + local swarm = {} + drive({ ["s:t"] = { { op = "swarm", args = { 26, 5 } } } }, + { setSwarm = function(g, m) swarm = { g, m } end }) + eq(swarm[1], 26, "swarm group") + eq(swarm[2], 5, "swarm map") + eq(Opcodes[0x9e].size, 2, + "swarm is a bare map_id: two operand bytes, not three") +end + +-- Bag. Script_checkitem clears wScriptVar FIRST, so a bag nobody can answer +-- for reads "no item" rather than leaving the last command's value behind. +do + local bag = { [54] = 1 } + local vm = drive({ ["s:t"] = { + { op = "setval", value = 1 }, + { op = "checkitem", args = { 99 } }, + } }, { hasItem = function(i) return bag[i] ~= nil end }) + eq(vm.scriptVar, 0, "checkitem on a missing item is FALSE, not stale") + local has = drive({ ["s:t"] = { { op = "checkitem", args = { 54 } } } }, + { hasItem = function(i) return bag[i] ~= nil end }) + eq(has.scriptVar, 1, "checkitem COIN_CASE is TRUE when it is in the pack") + local took = {} + local take = drive({ ["s:t"] = { { op = "takeitem", args = { 67, 1 } } } }, { + takeItem = function(i, q) took = { i, q }; return true end, + }) + eq(take.scriptVar, 1, "takeitem is TRUE when the pack held that many") + eq(took[2], 1, "the one-argument macro form still carries a quantity byte") + local fail = drive({ ["s:t"] = { { op = "takeitem", args = { 67, 2 } } } }, + { takeItem = function() return false end }) + eq(fail.scriptVar, 0, "and FALSE when it did not") +end + +-- Money and coins. CompareMoneyAction answers HAVE_MORE 0 / HAVE_AMOUNT 1 / +-- HAVE_LESS 2, so an `iffalse` after a checkmoney means the player has MORE. +do + local money = { [0] = 1500, [1] = 0 } + local function check1000(have) + money[0] = have + local vm = drive({ ["s:t"] = { { op = "checkmoney", args = { 0, 0, 3, 232 } } } }, + { getMoney = function(a) return money[a] end }) + return vm.scriptVar + end + eq(check1000(1500), 0, "checkmoney with more than the price is HAVE_MORE (0)") + eq(check1000(1000), 1, "exactly the price is HAVE_AMOUNT (1)") + eq(check1000(999), 2, "less than the price is HAVE_LESS (2)") + -- bigdt is BIG-endian: {0, 39, 16} is 10000, not 1058304. + money[0] = 10000 + local ten = drive({ ["s:t"] = { { op = "checkmoney", args = { 0, 0, 39, 16 } } } }, + { getMoney = function(a) return money[a] end }) + eq(ten.scriptVar, 1, "the three money bytes read big-endian") + money[0] = 1500 + drive({ ["s:t"] = { { op = "takemoney", args = { 0, 0, 3, 232 } } } }, { + getMoney = function(a) return money[a] end, + setMoney = function(a, v) money[a] = v end, + }) + eq(money[0], 500, "takemoney subtracts") + drive({ ["s:t"] = { { op = "takemoney", args = { 0, 0, 3, 232 } } } }, { + getMoney = function(a) return money[a] end, + setMoney = function(a, v) money[a] = v end, + }) + eq(money[0], 0, "and floors at 0 on a borrow rather than wrapping") + money[0] = 999000 + drive({ ["s:t"] = { { op = "givemoney", args = { 0, 0x0f, 0x42, 0x40 } } } }, { + getMoney = function(a) return money[a] end, + setMoney = function(a, v) money[a] = v end, + }) + eq(money[0], 999999, "givemoney caps at MAX_MONEY") + money[1] = 200 + local moms = drive({ ["s:t"] = { { op = "getmoney", args = { 1, 0 } } } }, + { getMoney = function(a) return money[a] end }) + eq(moms.stringBuffer, "200", + "getmoney emits the ACCOUNT byte first and prints that account") + + local coins = 9949 + local vend = drive({ ["s:t"] = { { op = "checkcoins", args = { 221, 38 } } } }, + { getCoins = function() return coins end }) + eq(vend.scriptVar, 1, "checkcoins reads its dw little-endian (9949)") + drive({ ["s:t"] = { { op = "givecoins", args = { 50, 0 } } } }, { + getCoins = function() return coins end, + setCoins = function(v) coins = v end, + }) + eq(coins, 9999, "givecoins caps at MAX_COINS") + drive({ ["s:t"] = { { op = "takecoins", args = { 124, 21 } } } }, { + getCoins = function() return coins end, + setCoins = function(v) coins = v end, + }) + eq(coins, 4499, "takecoins subtracts 5500") + local gc = drive({ ["s:t"] = { { op = "getcoins", args = { 0 } } } }, + { getCoins = function() return coins end }) + eq(gc.stringBuffer, "4499", "getcoins prints wCoins") +end + +-- Party. Script_giveegg answers 2, not 1, when the egg went in. +do + local egg = {} + local vm = drive({ ["s:t"] = { { op = "giveegg", args = { 175, 5 } } } }, + { giveEgg = function(s, l) egg = { s, l }; return true end }) + eq(vm.scriptVar, 2, "giveegg answers 2 when the party had room") + eq(egg[1], 175, "giveegg species (TOGEPI)") + eq(egg[2], 5, "giveegg level") + local full = drive({ ["s:t"] = { { op = "giveegg", args = { 175, 5 } } } }, + { giveEgg = function() return false end }) + eq(full.scriptVar, 0, "and 0 when it did not") + local poke = drive({ ["s:t"] = { { op = "checkpoke", args = { 155 } } } }, + { hasPoke = function(s) return s == 155 end }) + eq(poke.scriptVar, 1, "checkpoke walks wPartySpecies") + local mail = drive({ ["s:t"] = { { op = "checkpokemail", args = { 236, 90 } } } }) + eq(mail.scriptVar, 2, "checkpokemail with no mail model answers REFUSED (2)") +end + +-- getcurlandmarkname takes its map implicitly; the one byte is only a buffer id. +do + local vm = drive({ ["s:t"] = { { op = "getcurlandmarkname", args = { 0 } } } }, + { getLandmarkName = function() return "NEW BARK\nTOWN" end }) + eq(vm.stringBuffer, "NEW BARK\nTOWN", "getcurlandmarkname fills the buffer") +end + +-- Menus BLOCK. The Goldenrod coin vendor is loadmenu / verticalmenu / +-- closewindow / ifequal 1 / ifequal 2, so the cursor is 1-based and 0 is B. +do + local resume, headers = nil, {} + local vm = Vm.new({ generation = 2, + ["s:t"] = { + { op = "loadmenu", args = { 199, 69 } }, + { op = "verticalmenu" }, + { op = "closewindow" }, + { op = "ifequal", value = 1, script = "s:one" }, + { op = "setlasttalked", args = { 0 } }, + }, + ["s:one"] = { { op = "setlasttalked", args = { 1 } } }, + }, {}, Events.new(), { + openMenu = function(header, style, onChoose) + headers[#headers + 1] = { header, style } + resume = onChoose + end, + }) + vm:start("s:t") + check(vm:running(), "the script parks on the menu") + eq(vm.lastTalked, nil, "nothing past the menu runs while it is open") + eq(headers[1][2], "vertical", "verticalmenu asks for the vertical style") + eq(headers[1][1].address, 0x45c7, + "loadmenu stashes its MenuHeader pointer as lo + hi * 256") + resume(1) + eq(vm.lastTalked, 1, "picking row 1 takes the ifequal 1 arm") + check(not vm:running(), "and the script finishes") + + local cancel = Vm.new({ generation = 2, ["s:t"] = { + { op = "_2dmenu" }, + { op = "iffalse", script = "s:no" }, + { op = "setlasttalked", args = { 9 } }, + }, ["s:no"] = { { op = "setlasttalked", args = { 0 } } } }, + {}, Events.new(), { + openMenu = function(_, style, onChoose) + eq(style, "2d", "_2dmenu asks for the grid style") + onChoose(nil) + end, + }) + cancel:start("s:t") + eq(cancel.scriptVar, 0, "B out of a menu is 0") + eq(cancel.lastTalked, 0, "which is the iffalse cancel arm") + + -- No hook at all must take the cancel arm rather than hang. + local none = drive({ ["s:t"] = { + { op = "verticalmenu" }, + { op = "setlasttalked", args = { 3 } }, + } }) + eq(none.scriptVar, 0, "a hookless menu answers 0") + eq(none.lastTalked, 3, "and the script runs on") +end + +-- elevator answers 0 (the player backed out), which is the arm +-- GoldenrodDeptStoreElevatorScript's `iffalse .Done` wants. +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 1 }, + { op = "elevator", args = { 218, 73 } }, + { op = "iffalse", script = "s:done" }, + { op = "setlasttalked", args = { 5 } }, + }, ["s:done"] = { { op = "setlasttalked", args = { 0 } } } }) + eq(vm.scriptVar, 0, "elevator answers FALSE with no floor list") + eq(vm.lastTalked, 0, "so the script skips the ride") +end + +-- Phone. askforphonenumber's SUCCESS IS ZERO: an `iftrue` after it means the +-- number did NOT go in. +do + local vm = drive({ ["s:t"] = { { op = "askforphonenumber", args = { 5 } } } }, + { addPhoneNumber = function() return true end }) + eq(vm.scriptVar, 0, "a registered number is PHONE_CONTACT_GOT (0)") + local full = drive({ ["s:t"] = { { op = "askforphonenumber", args = { 5 } } } }, + { addPhoneNumber = function() return false end }) + eq(full.scriptVar, 1, "a full list is PHONE_CONTACTS_FULL (1)") + local no = drive({ ["s:t"] = { { op = "askforphonenumber", args = { 5 } } } }, { + yesorno = function(onChoose) onChoose(false) end, + addPhoneNumber = function() error("refused must not register") end, + }) + eq(no.scriptVar, 2, "saying no is PHONE_CONTACT_REFUSED (2)") + + local stored + local call = drive({ ["s:t"] = { + { op = "specialphonecall", args = { 4, 0 } }, + { op = "checkphonecall" }, + } }, { setSpecialCall = function(id) stored = id end }) + eq(stored, 4, "specialphonecall stores its dw id") + eq(call.scriptVar, 1, "checkphonecall sees a queued call") + local quiet = drive({ ["s:t"] = { { op = "checkphonecall" } } }) + eq(quiet.scriptVar, 0, "and answers 0 with nothing queued") + -- Only the LOW byte is ever read back: wSpecialPhoneCallID is a single db. + local highOnly = drive({ ["s:t"] = { + { op = "specialphonecall", args = { 0, 2 } }, + { op = "checkphonecall" }, + } }) + eq(highOnly.scriptVar, 0, "a high-byte-only id reads as no call") + + local hung = 0 + local hangup = drive({ ["s:t"] = { { op = "hangup" } } }, + { hangUp = function() hung = hung + 1 end }) + eq(hung, 1, "hangup closes the call box") + local sawClick + for _, line in ipairs(select(2, drive({ ["s:t"] = { { op = "hangup" } } }))) do + if line:find("Click!", 1, true) then sawClick = true end + end + check(sawClick, "and prints PhoneClickText") +end + +-- Hall of Fame and the credits both tear the script stack down. +do + local resume + local vm = Vm.new({ generation = 2, ["s:t"] = { + { op = "halloffame" }, + { op = "setlasttalked", args = { 4 } }, + } }, {}, Events.new(), { + hallOfFame = function(onDone) resume = onDone end, + }) + vm:start("s:t") + check(vm:running(), "halloffame parks on its own screen") + resume() + eq(vm.lastTalked, nil, "and Script_endall means nothing after it runs") + local credits = drive({ ["s:t"] = { + { op = "credits" }, + { op = "setlasttalked", args = { 4 } }, + } }, { credits = function(onDone) onDone() end }) + eq(credits.lastTalked, nil, "credits ends the script stack too") +end + +-- fruittree is a ScriptJump: nothing after it in the caller runs, and the +-- FruitTreeScript body is inlined here because no map pointer reaches it. +do + local picked = {} + local vm, log = drive({ ["s:t"] = { + { op = "fruittree", args = { 1 } }, + { op = "setlasttalked", args = { 7 } }, + } }, { + fruitTreeItem = function(tree) return tree == 1 and 158 or 0 end, + getItemName = function(i) return i == 158 and "BERRY" or "?" end, + giveItem = function() return true end, + fruitTreePicked = function() return false end, + fruitTreePick = function(t) picked[#picked + 1] = t end, + }) + eq(vm.lastTalked, nil, "fruittree never returns to the caller") + eq(picked[1], 1, "the tree flag is set after the fruit is banked") + local joined = table.concat(log, "|") + check(joined:find("fruit-", 1, true), "FruitBearingTreeText printed") + check(joined:find("Hey! It's", 1, true), "HeyItsFruitText printed") + check(joined:find("Obtained", 1, true), "ObtainedFruitText printed") + + local _, doneLog = drive({ ["s:t"] = { { op = "fruittree", args = { 2 } } } }, { + fruitTreeItem = function() return 158 end, + getItemName = function() return "BERRY" end, + fruitTreePicked = function() return true end, + fruitTreePick = function() error("a picked tree must not be picked again") end, + giveItem = function() error("a picked tree has no fruit to give") end, + }) + check(table.concat(doneLog, "|"):find("nothing", 1, true), + "an already-picked tree prints NothingHereText and stops") + + local full = drive({ ["s:t"] = { { op = "fruittree", args = { 3 } } } }, { + fruitTreeItem = function() return 158 end, + getItemName = function() return "BERRY" end, + fruitTreePicked = function() return false end, + giveItem = function() return false end, + fruitTreePick = function() error("a full pack must leave the tree pickable") end, + }) + eq(full.scriptVar, 0, "a full pack answers FALSE the way giveitem does") +end + +-- describedecoration is a ScriptJump too, so it must not fall into the garbage +-- the extractor read past it. +do + local kinds = {} + local vm = drive({ ["s:t"] = { + { op = "describedecoration", args = { 0 } }, + { op = "setlasttalked", args = { 3 } }, + } }, { describeDecoration = function(k) kinds[#kinds + 1] = k end }) + eq(kinds[1], 0, "describedecoration passes its DECODESC_* byte") + eq(vm.lastTalked, nil, "and ends the script") +end + +-- repeattext prints the last jumptext, and ONLY for the -1, -1 operand. +do + local vm, log = drive({ ["s:t"] = { + { op = "jumptext", text = "t:hi" }, + }, ["s:after"] = {} }, {}, { ["t:hi"] = "Hello!" }) + eq(vm.lastTextKey, "t:hi", "jumptext parks its pointer in wScriptTextAddr") + local _, again = drive({ ["s:t"] = { + { op = "writetext", text = "t:hi" }, + { op = "repeattext", args = { 255, 255 } }, + { op = "repeattext", args = { 1, 2 } }, + } }, {}, { ["t:hi"] = "Hello!" }) + local n = 0 + for _, line in ipairs(again) do + if line == "text:Hello!" then n = n + 1 end + end + eq(n, 2, "repeattext -1, -1 reprints once; any other pointer prints nothing") + check(#log >= 1, "the jumptext harness printed its line") +end + +-- The rest of the commands with no engine behind them yet: they must consume +-- their operand deliberately rather than fall through the unknown-op path. +do + local vm = drive({ ["s:t"] = { + { op = "setval", value = 6 }, + { op = "writeunusedbyte", args = { 3 } }, + { op = "xycompare", args = { 0x34, 0x34 } }, + { op = "autoinput", args = { 0x3e, 0x00, 0x40 } }, + { op = "writecmdqueue", args = { 0x10, 0x40 } }, + { op = "memcall", args = { 1, 15 } }, + { op = "memcallasm", args = { 7, 241 } }, + { op = "closewindow" }, + { op = "deactivatefacing", args = { 0 } }, + } }) + eq(vm.scriptVar, 6, "none of the inert commands touches wScriptVar") + eq(vm.unusedScriptByte, 3, "writeunusedbyte still records its byte") + eq(vm.xyComparePointer, 0x3434, "xycompare records its pointer") + local empty = true + for _ in pairs(vm.unknownOps) do empty = false end + check(empty, "and none of them reaches the unknown-op path") +end + +-- The unknown-op ledger itself. A silent skip is what makes a missing opcode +-- corrupt a branch instead of announcing itself, so this has to be observable. +do + local vm = drive({ ["s:t"] = { + { op = "notacommand" }, + { op = "notacommand" }, + { op = "alsonot" }, + } }) + eq(vm.unknownOps.notacommand, 2, "an unimplemented opcode is counted") + eq(vm.unknownOps.alsonot, 1, "per opcode name") + local clean = drive({ ["s:t"] = { + { op = "setval", value = 1 }, + { op = "checkver" }, + { op = "end" }, + } }) + local empty = true + for _ in pairs(clean.unknownOps) do empty = false end + check(empty, "a script of implemented commands leaves the ledger empty") +end + +-- Cache-backed checks (skip when no gold cache / pre-slice extract). +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 scriptsPath = cache .. "/data/generated/scripts.lua" +local mapsPath = cache .. "/data/generated/maps.lua" +local sf, mf = io.open(scriptsPath, "r"), io.open(mapsPath, "r") +if not sf or not mf then + if sf then sf:close() end + if mf then mf:close() end + check(true, "gold cache absent : VM unit checks only (SKIP cache facts)") + S.finish() + return +end +sf:close(); mf:close() + +local goldScripts = assert(loadfile(scriptsPath))() +local maps = assert(loadfile(mapsPath))() +check(goldScripts.movements ~= nil + or (goldScripts["60:40c6"] ~= nil), + "scripts.lua present (movements after re-import)") +local lab = maps.ELMS_LAB +check(lab ~= nil, "ELMS_LAB in maps") +if lab and lab.sceneScripts then + local s0 = lab.sceneScripts[0] + check(s0 and s0.scriptKey, "ELMS_LAB scene 0 scriptKey extracted") +end +local ball = goldScripts["60:4144"] +if ball then + local ops = {} + for _, c in ipairs(ball) do ops[#ops + 1] = c.op end + local joined = table.concat(ops, ",") + check(joined:find("givepoke", 1, true), "Chikorita ball has givepoke") + check(joined:find("yesorno", 1, true), "Chikorita ball has yesorno") +end +local directions = goldScripts["60:4186"] +if directions then + local hasPhone, hasScene5 + for _, c in ipairs(directions) do + if c.op == "addcellnum" and (c.phone == 4 or (c.args and c.args[1] == 4)) then + hasPhone = true + end + if c.op == "setscene" and c.scene == 5 then hasScene5 = true end + end + check(hasPhone, "ElmDirections has addcellnum PHONE_ELM") + check(hasScene5, "ElmDirections setscene → aide potion (5)") +end +local potion = goldScripts["60:42e9"] +if potion then + local hasGive + for _, c in ipairs(potion) do + if c.op == "verbosegiveitem" + and (c.item == 18 or (c.args and c.args[1] == 18)) then + hasGive = true + end + end + check(hasGive, "AideScript_GivePotion verbosegiveitem POTION") +end +-- Every clerk in Johto and Kanto is a `pokemart` in the extracted bytecode. +-- MartTypeDialogs has four entries and Marts has NUM_MARTS = 34, so a row that +-- decodes outside either range is a script the walker wandered into rather than +-- a real mart -- three such rows sit in bank $42 and GetMart hands them +-- DefaultMart, which is why this counts sane rows instead of demanding all of +-- them are. +local martTypesSeen, saneMarts = {}, 0 +for key, list in pairs(goldScripts) do + if type(key) == "string" and type(list) == "table" then + for _, c in ipairs(list) do + if type(c) == "table" and c.op == "pokemart" and c.args then + local kind = c.args[1] or 0 + local id = (c.args[2] or 0) + (c.args[3] or 0) * 0x100 + if kind < 4 and id < 34 then + saneMarts = saneMarts + 1 + martTypesSeen[kind] = true + end + end + end + end +end +check(saneMarts >= 30, "the cache carries a pokemart per mart clerk") +check(martTypesSeen[0], "MARTTYPE_STANDARD is scripted") +check(martTypesSeen[1], "MARTTYPE_BITTER is scripted (the herb shop)") +check(martTypesSeen[2], "MARTTYPE_BARGAIN is scripted (the underground)") +check(martTypesSeen[3], "MARTTYPE_PHARMACY is scripted (Cianwood)") + +-- --------------------------------------------------------------------------- +-- Opcode coverage over the WHOLE extracted cache. +-- +-- The point of this pair of checks is that a missing opcode used to be a silent +-- skip, and a silent skip is worse than an error: `checkitem` followed by +-- `iftrue` reads a stale wScriptVar and takes the wrong arm. So: count what +-- Johto actually calls, and prove the interpreter has a branch for all of it. +-- --------------------------------------------------------------------------- +local vmSource +do + local f = assert(io.open("src/script/gen2/Vm.lua", "r")) + vmSource = f:read("*a") + f:close() +end +local implemented = {} +for name in vmSource:gmatch('op == "([%w_]+)"') do implemented[name] = true end + +local cacheOps, opUses, cacheRow = {}, {}, {} +local distinct, scriptCount = 0, 0 +for key, list in pairs(goldScripts) do + if type(key) == "string" and type(list) == "table" then + scriptCount = scriptCount + 1 + for _, c in ipairs(list) do + if type(c) == "table" and type(c.op) == "string" then + if not cacheOps[c.op] then + cacheOps[c.op] = true + distinct = distinct + 1 + cacheRow[c.op] = c + end + opUses[c.op] = (opUses[c.op] or 0) + 1 + end + end + end +end + +local missing, covered = {}, 0 +for op in pairs(cacheOps) do + if implemented[op] then + covered = covered + 1 + else + missing[#missing + 1] = ("%s (%d uses)"):format(op, opUses[op] or 0) + end +end +table.sort(missing) +-- A floor, not a ratchet: the coverage assertion is the one below. It used to +-- be 130, and it came down when the extractor stopped disassembling the 87 +-- BGEVENT_ITEM `hiddenitem` structs as bytecode -- roughly twenty opcodes +-- (`memcall`, `autoinput`, `warpfacing`, a `callstd` with a four-digit std id) +-- were called ONLY out of that noise and never by a real Johto script. +check(distinct > 110, + ("the cache calls %d distinct script commands across %d scripts") + :format(distinct, scriptCount)) +eq(#missing, 0, + ("every command Johto calls has a branch (%d/%d covered; missing: %s)") + :format(covered, distinct, table.concat(missing, ", "))) + +-- Every opcode in the Opcodes table is either something the cache never calls +-- or something the VM handles; nothing is half-wired. +local tableMissing = {} +for code = 0x00, 0xa1 do + local info = Opcodes[code] + if info and not implemented[info.name] then + tableMissing[#tableMissing + 1] = info.name + end +end +table.sort(tableMissing) +eq(#tableMissing, 0, + "every opcode in Opcodes.lua has a Vm branch (missing: " + .. table.concat(tableMissing, ", ") .. ")") + +-- Now drive a REAL command out of the cache for each distinct opcode through a +-- real Vm. The script table is empty apart from the one row, so every jump +-- target resolves to nothing and each command is isolated; the hooks all resume +-- synchronously so a blocking command cannot hang the suite. Nothing may reach +-- the unknown-op ledger. +local unhandled = {} +for op, row in pairs(cacheRow) do + local resumed = false + local vm = Vm.new({ generation = 2, ["s:one"] = { row } }, {}, Events.new(), { + showText = function(_, onDone) onDone() end, + yesorno = function(onChoose) onChoose(true) end, + waitSfx = function() return true end, + applyMovement = function(_, _, onDone) onDone() end, + startBattle = function(_, _, onDone) onDone("win") end, + openMart = function(_, _, onDone) onDone() end, + trainerApproach = function(onDone) onDone() end, + openMenu = function(_, _, onChoose) onChoose(0) end, + npcTrade = function(_, onDone) onDone() end, + phoneCall = function(_, onDone) onDone() end, + hallOfFame = function(onDone) resumed = true; onDone() end, + credits = function(onDone) resumed = true; onDone() end, + getItemName = function() return "BERRY" end, + fruitTreeItem = function() return 158 end, + giveItem = function() return true end, + }) + vm:start("s:one") + for _ = 1, 600 do + if not vm:running() then break end + vm:update() + end + for name in pairs(vm.unknownOps) do + unhandled[#unhandled + 1] = name + end + local _ = resumed +end +table.sort(unhandled) +eq(#unhandled, 0, + "running one real row per opcode reaches no unknown-op path (hit: " + .. table.concat(unhandled, ", ") .. ")") + +-- The two commands whose operands are coordinates, checked against the cache +-- rather than against my arithmetic. changeblock is CELLS halved into blocks, +-- so a row that lands outside its map's block grid would mean the halving is +-- wrong; warp names a group/map pair that has to exist in maps.lua. +local byGroupMap = {} +for id, def in pairs(maps) do + if type(def) == "table" and def.group and def.map then + byGroupMap[def.group * 256 + def.map] = id + end +end +local warpRows, warpResolved = 0, 0 +local blockRows, blockInGrid = 0, 0 +for key, list in pairs(goldScripts) do + if type(key) == "string" and type(list) == "table" then + for _, c in ipairs(list) do + if type(c) == "table" and c.op == "warp" and c.args then + warpRows = warpRows + 1 + if byGroupMap[(c.args[1] or 0) * 256 + (c.args[2] or 0)] then + warpResolved = warpResolved + 1 + end + elseif type(c) == "table" and c.op == "changeblock" and c.args then + blockRows = blockRows + 1 + -- No map id on the command, so this only checks the halving is sane + -- against the largest map in the game rather than against the right + -- one: a stride bug would push these into the hundreds. + local bx = math.floor((c.args[1] or 0) / 2) + local by = math.floor((c.args[2] or 0) / 2) + if bx < 40 and by < 40 then blockInGrid = blockInGrid + 1 end + end + end + end +end +check(warpRows >= 15, "the cache carries the scripted warps") +check(warpResolved >= warpRows - 1, + ("every scripted warp names a real map (%d/%d)") + :format(warpResolved, warpRows)) +check(blockRows >= 50, "the cache carries the scripted changeblocks") +eq(blockInGrid, blockRows, "every changeblock halves into a plausible block") + +local itemsPath = cache .. "/data/generated/items.lua" +local itemsChunk = loadfile(itemsPath) +if itemsChunk then + local items = itemsChunk() + if items and items.POTION then + eq(items.POTION.index, 18, "POTION index is 0x12") + check(items.POTION.name == "POTION", "POTION name extracted") + else + check(true, "items.lua stub : re-import for ItemNames (SKIP)") + end +end + +-- ================================================================= specials +-- +-- data/events/special_pointers.asm. Three things are under test here and they +-- fail differently, so they are asserted separately: +-- +-- 1. the MAPPING. A special is dispatched by INDEX through the extracted +-- SpecialsPointers order, so an off-by-one silently runs the wrong +-- routine -- HealParty where PokemonCenterPC should be. Every name this +-- module claims is driven through a real Vm at the index the CACHE says +-- it lives at, and the handler that runs has to be the one asked for. +-- 2. COVERAGE. How many of the 112 are ported, how many are deliberate +-- stubs, and that the two sets are disjoint and together cover the whole +-- table -- because a name with no entry at all falls through and leaves a +-- STALE wScriptVar behind, which is the failure this module exists to +-- stop. +-- 3. the EFFECT of each ported routine, driven through a real Vm. +local Specials = require("src.script.gen2.Specials") + +local handlerCount, stubCount = 0, 0 +for _ in pairs(Specials.HANDLERS) do handlerCount = handlerCount + 1 end +for _ in pairs(Specials.STUBS) do stubCount = stubCount + 1 end +check(handlerCount >= 70, + ("%d specials are ported"):format(handlerCount)) +-- The stub floor only ever comes DOWN: every feature that lands moves a name +-- out of STUB_ROWS and into HANDLERS, and the pair of counts below plus the +-- disjointness and total-coverage checks that follow are what actually holds +-- the table together. UnownPuzzle left this set when the Ruins of Alph +-- sliding-panel screen landed; PhotoStudio left it when the Cianwood photo +-- studio's conversation and portrait card landed (the print itself is still +-- out of scope, but that lives inside H.PhotoStudio now, not in STUB_ROWS). +-- MagnetTrain left it when the Goldenrod <-> Saffron ride cutscene landed. +-- UnownPrinter left it for the same reason PhotoStudio did: the ALPH RUINS +-- STAMP viewer is drawn on the cartridge and only the A press wanted the +-- printer, so the stub moved inside H.UnownPrinter. MapRadio left it when +-- the wall radios got the gear's channel player (H.MapRadio). +-- RandomPhoneWildMon and RandomPhoneMon left it when the incoming-call ring +-- landed and GetCallerLocation finally had a caller to read: the id rides +-- vm.curPhoneCaller and both names now land in the string buffer. +check(stubCount >= 23, + ("%d are deliberate stubs"):format(stubCount)) + +local overlap = {} +for name in pairs(Specials.HANDLERS) do + if Specials.STUBS[name] then overlap[#overlap + 1] = name end +end +eq(table.concat(overlap, ","), "", + "no special is both implemented and stubbed") +eq(handlerCount + stubCount, (function() + local n = 0 + for _ in pairs(Specials.ALL) do n = n + 1 end + return n +end)(), "and ALL is exactly the two sets merged") + +-- Every stub says WHY, in its own words. A stub with no reason is a hole +-- somebody meant to come back to and did not. +local unexplained = {} +for name in pairs(Specials.STUBS) do + local reason = Specials.STUB_REASONS[name] + if type(reason) ~= "string" or #reason < 10 then + unexplained[#unexplained + 1] = name + end +end +eq(table.concat(unexplained, ","), "", "every stub records its reason") + +-- A Vm wired for one special at a time: `specials` is the World's hook table +-- (stubbed), and the script is one `special` row. +local function specialVm(id, opts) + opts = opts or {} + local vm = Vm.new({ ["s:x"] = { { op = "special", id = id } } }, {}, + Events.new(), { + specialOrder = opts.order, + specials = opts.hooks or {}, + showText = opts.showText, + setStringBuffer = function(v) opts.buffer = v end, + openPc = opts.openPc, + healParty = opts.healParty, + cry = opts.cry, + showMoney = opts.showMoney, + showCoins = opts.showCoins, + warpToSpawn = opts.warpToSpawn, + nameRival = opts.nameRival, + }) + vm.scriptVar = opts.scriptVar or 0 + return vm +end + +-- ---- 1. the mapping against the cache ------------------------------------- +local specialOrder +do + local chunk = cache and loadfile(cache .. "/data/generated/constants.lua") + local consts = chunk and chunk() + specialOrder = consts and consts.specialOrder +end + +if specialOrder then + eq(#specialOrder, 112, + "the cache carries all 112 SpecialsPointers rows (the asm's 113 " .. + "add_special matches include the MACRO line)") + local uncovered = {} + for _, name in ipairs(specialOrder) do + if not Specials.ALL[name] then uncovered[#uncovered + 1] = name end + end + eq(table.concat(uncovered, ","), "", + "every special in the cache resolves to a handler or a stub") + + -- The dispatch itself: put a marker handler at each index in turn and assert + -- the VM runs THAT one. This is the assertion that catches an off-by-one in + -- Vm:specialName, which no amount of per-handler testing would. + local misrouted = {} + for index, name in ipairs(specialOrder) do + local ran = nil + local saved = Vm.SPECIALS[name] + Vm.SPECIALS[name] = function() ran = name end + local vm = specialVm(index - 1, { order = specialOrder }) + vm:start("s:x") + for _ = 1, 4 do vm:update() end + Vm.SPECIALS[name] = saved + if ran ~= name then misrouted[#misrouted + 1] = name end + end + eq(table.concat(misrouted, ","), "", + "and every one of the 112 indices dispatches to its own name") + + -- Nothing in the extracted cache asks for a special this table cannot + -- answer. The junk ids come from mis-walked ROM regions rather than from + -- real scripts, so they are counted and reported, not asserted away. + local goldScriptsForSpecials = + cache and loadfile(cache .. "/data/generated/scripts.lua") + if goldScriptsForSpecials then + local rows = goldScriptsForSpecials() + local real, junk, unhandled = 0, 0, {} + local function walk(list) + for _, cmd in ipairs(list) do + if type(cmd) == "table" then + if cmd.op == "special" then + local id = cmd.id or (cmd.args and cmd.args[1]) + local name = id and specialOrder[id + 1] + if name then + real = real + 1 + if not Specials.ALL[name] then + unhandled[#unhandled + 1] = name + end + else + junk = junk + 1 + end + end + for _, v in pairs(cmd) do + if type(v) == "table" then walk(v) end + end + end + end + end + for _, list in pairs(rows) do + if type(list) == "table" then walk(list) end + end + check(real > 300, ("the cache carries %d scripted specials"):format(real)) + eq(table.concat(unhandled, ","), "", + "and every one of them resolves to a handler or a stub") + end +end + +-- ---- 2. the ported routines ----------------------------------------------- + +-- The four fixed hook forwards keep working through the new module. +do + local healed = false + local vm = specialVm(0, { order = { "HealParty" }, + healParty = function() healed = true end }) + vm.scriptVar = 0 + Specials.HANDLERS.HealParty(vm) + check(healed, "HealParty still reaches its hook") +end + +-- PlayersHousePC answers FALSE before it opens: `xor a / ld [wScriptVar], a` +-- runs before the farcall, so a script that reads the var after it does not +-- see whatever the last special left there. +do + local opened = false + local vm = specialVm(0, { order = { "PlayersHousePC" }, + openPc = function() opened = true end }) + vm.scriptVar = 7 + Specials.HANDLERS.PlayersHousePC(vm) + eq(vm.scriptVar, 0, "PlayersHousePC zeroes wScriptVar first") + check(opened, "and opens the storage system") +end + +-- CheckFirstMonIsEgg / GetFirstPokemonHappiness: the happiness loop SKIPS +-- eggs, which is why a party led by an egg still gets an answer from the mon +-- behind it. +do + local list = { { species = "TOGEPI", isEgg = true }, + { species = "CHIKORITA", happiness = 140 } } + local vm = specialVm(0, { order = { "x" }, + hooks = { party = function() return list end, + monName = function(s) return s end } }) + Specials.HANDLERS.CheckFirstMonIsEgg(vm) + eq(vm.scriptVar, 1, "CheckFirstMonIsEgg is TRUE with an egg in slot 1") + Specials.HANDLERS.GetFirstPokemonHappiness(vm) + eq(vm.scriptVar, 140, + "and GetFirstPokemonHappiness walks PAST the egg to the mon behind it") + table.remove(list, 1) + Specials.HANDLERS.CheckFirstMonIsEgg(vm) + eq(vm.scriptVar, 0, "and is FALSE once the egg is gone") +end + +-- The four party searches share FoundOne / FoundNone: wScriptVar goes IN as +-- the thing looked for and comes back TRUE or FALSE. +do + local list = { { species = "CHIKORITA", level = 20, happiness = 200, + otId = 1234 } } + local hooks = { party = function() return list end, + monIndex = function(s) return s == "CHIKORITA" and 152 or 0 end, + save = function() return { player = { id = 1234 } } end } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.scriptVar = 152 + Specials.HANDLERS.FindPartyMonThatSpecies(vm) + eq(vm.scriptVar, 1, "FindPartyMonThatSpecies finds it") + vm.scriptVar = 129 + Specials.HANDLERS.FindPartyMonThatSpecies(vm) + eq(vm.scriptVar, 0, "and answers FALSE for a species that is not there") + vm.scriptVar = 15 + Specials.HANDLERS.FindPartyMonAboveLevel(vm) + eq(vm.scriptVar, 1, "FindPartyMonAboveLevel compares levels") + vm.scriptVar = 152 + Specials.HANDLERS.FindPartyMonThatSpeciesYourTrainerID(vm) + eq(vm.scriptVar, 1, "your own mon passes the trainer-ID check") + list[1].otId = 9999 + vm.scriptVar = 152 + Specials.HANDLERS.FindPartyMonThatSpeciesYourTrainerID(vm) + eq(vm.scriptVar, 0, "and a TRADED one of the same species does not") +end + +-- CheckPokerus is ScriptReturnCarry over the party. +do + local list = { { species = "CHIKORITA" } } + local vm = specialVm(0, { order = { "x" }, + hooks = { party = function() return list end } }) + Specials.HANDLERS.CheckPokerus(vm) + eq(vm.scriptVar, 0, "CheckPokerus is FALSE with a clean party") + list[1].pokerus = 0x24 + Specials.HANDLERS.CheckPokerus(vm) + eq(vm.scriptVar, 1, "and TRUE once one mon carries it") +end + +-- The Bug Contest party mask. ContestDropOffMons does not STORE the party, +-- it truncates the count to 1 and hides the rest behind a terminator; the +-- return walk is what puts a mon CAUGHT during the contest in front of them. +do + local list = { { species = "CHIKORITA", hp = 20 }, { species = "MAGIKARP" }, + { species = "SHUCKLE" } } + local vm = specialVm(0, { order = { "x" }, + hooks = { party = function() return list end } }) + Specials.HANDLERS.ContestDropOffMons(vm) + eq(vm.scriptVar, 0, "a healthy lead mon is accepted") + eq(#list, 1, "and the rest of the party is masked away") + list[2] = { species = "CATERPIE" } -- the contest catch + Specials.HANDLERS.ContestReturnMons(vm) + eq(#list, 4, "the tail comes back") + eq(list[2].species, "CATERPIE", "behind the mon caught during the contest") + eq(list[3].species, "MAGIKARP", "in their old order") + + local fainted = { { species = "CHIKORITA", hp = 0 } } + local vm2 = specialVm(0, { order = { "x" }, + hooks = { party = function() return fainted end } }) + Specials.HANDLERS.ContestDropOffMons(vm2) + eq(vm2.scriptVar, 1, "a fainted lead mon is refused") + eq(#fainted, 1, "and nothing is masked") +end + +-- The Lucky Number Show: trailing digits of the trainer ID against the day's +-- five-digit number. The BEST match wins across party and boxes. +eq(Specials.trailingDigitsShared(12345, 12345), 5, "an exact ID match is 5") +eq(Specials.trailingDigitsShared(99345, 12345), 3, "three trailing digits") +eq(Specials.trailingDigitsShared(99945, 12345), 2, "two trailing digits") +eq(Specials.trailingDigitsShared(99995, 12345), 1, "one is not enough") +eq(Specials.luckyPrizeFor(5), 1, "five digits is the first prize") +eq(Specials.luckyPrizeFor(4), 2, "four is the second") +eq(Specials.luckyPrizeFor(3), 2, "three is the second too") +eq(Specials.luckyPrizeFor(2), 3, "two is the third") +eq(Specials.luckyPrizeFor(1), 0, "one wins nothing") +do + local record = { luckyNumber = 12345, + party = { { species = "CHIKORITA", otId = 99345 } }, + boxes = { { { species = "MAGIKARP", otId = 12345 } } } } + local vm = specialVm(0, { order = { "x" }, hooks = { + save = function() return record end, + party = function() return record.party end, + monName = function(sp) return sp end } }) + Specials.HANDLERS.CheckForLuckyNumberWinners(vm) + eq(vm.scriptVar, 1, "the BOX mon's exact match beats the party's partial one") + check(vm.luckyNumberInBox, "and the routine remembers it was in a box") + Specials.HANDLERS.PrintTodaysLuckyNumber(vm) + eq(vm.stringBuffer, "12345", "and the number prints with leading zeros") +end + +-- RestartLuckyNumberCountdown: days until the NEXT Friday, where Friday +-- itself is a week away rather than zero. SUNDAY 0 .. SATURDAY 6. +eq(Specials.daysUntilFriday(0), 5, "Sunday is five days from Friday") +eq(Specials.daysUntilFriday(4), 1, "Thursday is one day out") +eq(Specials.daysUntilFriday(5), 7, "Friday itself waits a full week") +eq(Specials.daysUntilFriday(6), 6, "Saturday is six days out") + +-- The Lucky Number Show's weekly gate. wLuckyNumberDayTimer starts zeroed +-- (never armed), which is what makes the FIRST visit always reset; after that +-- it stays armed until the countdown actually reaches the next Friday, so a +-- second visit the same week keeps last week's number and last week's win. +do + local record = {} + local engineFlags = { ENGINE_LUCKY_NUMBER_SHOW = true } + local vm = specialVm(0, { order = { "x" }, hooks = { + save = function() return record end, + setEngineFlag = function(flag, value) engineFlags[flag] = value end, + } }) + + Specials.HANDLERS.CheckLuckyNumberShowFlag(vm) + eq(vm.scriptVar, 1, "a never-armed timer reads as already expired") + + Specials.HANDLERS.ResetLuckyNumberShowFlag(vm) + check(record.luckyNumber ~= nil, "resetting rolls a number") + check(record.luckyNumber >= 0 and record.luckyNumber <= 99999, + "in the five-digit range") + eq(engineFlags.ENGINE_LUCKY_NUMBER_SHOW, nil, + "and clears the SAME storage checkflag ENGINE_LUCKY_NUMBER_SHOW reads") + + Specials.HANDLERS.CheckLuckyNumberShowFlag(vm) + eq(vm.scriptVar, 0, + "freshly armed, the timer is not expired again until next Friday") + + -- Fast-forward past the reset week: the stored day and the timer's + -- remaining count both move, the same way CalcDaysSince advances the start + -- day as a side effect of measuring it. + local timer = record.luckyNumberReset + local future = { day = (timer.day or 0) + timer.remaining } + local BugContest = require("src.core.gen2.BugContest") + local savedNow = BugContest.now + BugContest.now = function() return future end + Specials.HANDLERS.CheckLuckyNumberShowFlag(vm) + BugContest.now = savedNow + eq(vm.scriptVar, 1, "and IS expired once that many days have actually passed") +end + +-- The haircut brothers. wScriptVar is 2, 3 or 4 -- the barber's script +-- branches on those, so a 0-based table would have him say the wrong line -- +-- and the happiness gained depends on the band the mon is already in. +do + local mon = { species = "CHIKORITA", happiness = 250 } + local vm = specialVm(0, { order = { "x" }, hooks = { + party = function() return { mon } end, + monName = function(sp) return sp end, + selectPartyMon = function(_, done) done(1, mon) end } }) + local saved = Specials.random + Specials.random = function() return 0 end -- the first row every time + Specials.HANDLERS.OlderHaircutBrother(vm) + eq(vm.scriptVar, 2, "the older brother's first row answers 2") + eq(mon.happiness, 251, "and adds 1, the >= 200 band's OLDERCUT1") + mon.happiness = 50 + Specials.HANDLERS.YoungerHaircutBrother(vm) + eq(vm.scriptVar, 2, "the younger brother's first row answers 2 as well") + eq(mon.happiness, 51, "with YOUNGCUT1's own low-band change") + Specials.random = function() return 254 end -- fall through to the last row + mon.happiness = 50 + Specials.HANDLERS.YoungerHaircutBrother(vm) + eq(vm.scriptVar, 4, "the last row answers 4") + eq(mon.happiness, 60, "and YOUNGCUT3 is +10 down there") + Specials.random = saved +end +do + -- An egg cannot be groomed: `.egg` leaves wScriptVar at 0. + local egg = { species = "TOGEPI", isEgg = true } + local vm = specialVm(0, { order = { "x" }, hooks = { + party = function() return { egg } end, + monName = function(sp) return sp end, + selectPartyMon = function(_, done) done(1, egg) end } }) + vm.scriptVar = 3 + Specials.HANDLERS.DaisysGrooming(vm) + eq(vm.scriptVar, 0, "Daisy refuses an egg") +end + +-- Mania's Shuckie. All three identity checks matter, because handing back a +-- Shuckle you caught yourself is the thing the routine refuses. +do + local list = {} + local vm = specialVm(0, { order = { "x" }, hooks = { + party = function() return list end, + data = function() + return { pokemon = { SHUCKLE = { name = "SHUCKLE", index = 213, + growthRate = "MEDIUM_FAST", + baseStats = { hp = 20, attack = 10, defense = 230, speed = 5, + specialAttack = 10, specialDefense = 230 }, + levelMoves = { { level = 1, move = "CONSTRICT" } } } }, + moves = { CONSTRICT = { name = "CONSTRICT", pp = 35 } } } + end } }) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 1, "GiveShuckle hands one over") + eq(#list, 1, "into the party") + eq(list[1].nickname, "SHUCKIE", "nicknamed SHUCKIE") + eq(list[1].otId, Specials.MANIA_OT_ID, "with MANIA's trainer ID") + eq(list[1].item, "BERRY", "holding a BERRY") + eq(list[1].level, 15, "at level 15") + + local hooks2 = { party = function() return list end, + selectPartyMon = function(_, done) done(1, list[1]) end } + local vm2 = specialVm(0, { order = { "x" }, hooks = hooks2 }) + local impostor = { species = "SHUCKLE", otId = 1, ot = "GOLD", hp = 5 } + hooks2.selectPartyMon = function(_, done) done(1, impostor) end + Specials.HANDLERS.ReturnShuckie(vm2) + eq(vm2.scriptVar, Specials.SHUCKIE_WRONG_MON, + "a Shuckle that is not MANIA's is refused") + hooks2.selectPartyMon = function(_, done) done(1, list[1]) end + list[1].hp = 0 + Specials.HANDLERS.ReturnShuckie(vm2) + eq(vm2.scriptVar, Specials.SHUCKIE_FAINTED, "a fainted Shuckie is refused") + list[1].hp = 20 + list[1].happiness = 150 + Specials.HANDLERS.ReturnShuckie(vm2) + eq(vm2.scriptVar, Specials.SHUCKIE_HAPPY, + "150+ happiness and Mania lets you keep it") + eq(#list, 1, "so the party slot is untouched") + list[1].happiness = 149 + Specials.HANDLERS.ReturnShuckie(vm2) + eq(vm2.scriptVar, Specials.SHUCKIE_RETURNED, "and the real one goes back") + eq(#list, 0, "leaving the party") + hooks2.selectPartyMon = function(_, done) done(nil, nil) end + Specials.HANDLERS.ReturnShuckie(vm2) + eq(vm2.scriptVar, Specials.SHUCKIE_REFUSED, "B backs out") +end + +-- CalcMagikarpLength, bug and all. The two ends of the routine are what pin +-- it: bc < 10 is the +190 special case, and everything else lands through the +-- underflowing table walk. +do + local feet, inches, mm = Specials.magikarpLength(0, 0) + eq(mm, 190, "an all-zero ID and DV word is the 190 mm floor") + eq(feet, 0, "which is 0 feet") + eq(inches, 7, "and 7 inches") + local _, _, mm2 = Specials.magikarpLength(0x1234, 0x5678) + check(mm2 > 0 and mm2 < 65536, "a real pair produces a length in range") + eq(Specials.magikarpLengthText(4, 2), [[4'2"]], "and it prints as feet'inches") + eq(Specials.dvWord({ attack = 15, defense = 15, speed = 15, special = 15 }), + 0xffff, "the DV word packs attack/defense/speed/special into 16 bits") +end + +-- CheckMagikarpLength's four answers, and the record it keeps. +do + local record = { player = { name = "GOLD" } } + local karp = { species = "MAGIKARP", otId = 1000, + dvs = { attack = 15, defense = 15, speed = 15, special = 15 } } + local chosen = karp + local vm = specialVm(0, { order = { "x" }, hooks = { + save = function() return record end, + party = function() return { karp } end, + monName = function(sp) return sp end, + selectPartyMon = function(_, done) done(1, chosen) end } }) + Specials.HANDLERS.CheckMagikarpLength(vm) + eq(vm.scriptVar, 3, "a first measurement always beats the record") + check(record.magikarpRecord ~= nil, "and is written down") + Specials.HANDLERS.CheckMagikarpLength(vm) + eq(vm.scriptVar, 2, "measuring the same fish again does not beat it") + chosen = { species = "CHIKORITA" } + Specials.HANDLERS.CheckMagikarpLength(vm) + eq(vm.scriptVar, 0, "a mon that is not a MAGIKARP answers 0") + chosen = nil + Specials.HANDLERS.CheckMagikarpLength(vm) + eq(vm.scriptVar, 1, "and B answers 1") + Specials.HANDLERS.MagikarpHouseSign(vm) + check(vm.stringBuffer:find("'", 1, true) ~= nil, + "the house sign prints the record on the wall") +end + +-- SnorlaxAwake needs BOTH halves: the flute channel playing, and the player on +-- one of the five cells beside it. +do + local song, cell = nil, { 0, 0 } + local vm = specialVm(0, { order = { "x" }, hooks = { + currentMusic = function() return song end, + playerCell = function() return cell[1], cell[2] end } }) + Specials.HANDLERS.SnorlaxAwake(vm) + eq(vm.scriptVar, 0, "no flute, no waking") + song = Specials.POKE_FLUTE_SONG + Specials.HANDLERS.SnorlaxAwake(vm) + eq(vm.scriptVar, 0, "the flute alone is not enough from across the room") + cell = { 34, 10 } + Specials.HANDLERS.SnorlaxAwake(vm) + eq(vm.scriptVar, 1, "the flute NEXT TO it wakes it") +end + +-- InitRoamMons puts all three beasts out, with hp 0 meaning "roll stats when +-- it is first met". +do + local record = {} + local vm = specialVm(0, { order = { "x" }, + hooks = { save = function() return record end } }) + Specials.HANDLERS.InitRoamMons(vm) + eq(#record.roamers, 3, "three roamers") + eq(record.roamers[1].species, "RAIKOU", "RAIKOU first") + eq(record.roamers[1].map, "ROUTE_42", "on ROUTE 42") + eq(record.roamers[3].species, "SUICUNE", "SUICUNE third") + eq(record.roamers[2].level, 40, "all at level 40") + eq(record.roamers[1].hp, 0, "with no stats rolled yet") +end + +-- The two Game Corner machines are CheckCoinsAndCoinCase and then the game: +-- both refusals are TEXT, and the machine must not open behind either. +do + local shown, opened = {}, nil + local coins, hasCase = 0, true + local vm = specialVm(0, { order = { "SlotMachine" }, hooks = { + coins = function() return coins end, + hasItem = function() return hasCase end, + gameCornerGame = function(kind, done) opened = kind done() end } }) + -- showRaw YIELDS its page, so the refusal is the request the coroutine + -- parks on rather than a call: reading it off the yield is what proves the + -- machine never opened behind the line. + vm.showTextFn = function() end + vm.co = coroutine.create(function() + Specials.HANDLERS.SlotMachine(vm) + end) + local _, req = coroutine.resume(vm.co) + shown[#shown + 1] = req and req.text + eq(shown[1], "You have no coins.", "_NoCoinsText is the refusal") + check(opened == nil, "no coins: the machine does not open") + coins, hasCase = 100, false + opened = nil + vm.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm) end) + local _, req2 = coroutine.resume(vm.co) + eq(req2 and req2.text, "You don't have a\nCOIN CASE.", + "_NoCoinCaseText is the other one") + check(opened == nil, "no COIN CASE: the machine does not open either") + coins, hasCase = 100, true + vm.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm) end) + coroutine.resume(vm.co) + eq(opened, "cardflip", "with both, the machine opens") +end + +-- Specials.block is the whole blocking contract, and both halves of it matter. +-- A hook that answers ON THE SPOT must not yield (resuming a coroutine that is +-- still running is an error); a hook that answers LATER must park the script +-- so nothing behind it runs until the screen closes. +do + local vm = specialVm(0, { order = { "x" }, hooks = {} }) + local ran = false + vm.co = coroutine.create(function() + local value = Specials.block(vm, function(done) done(42) end) + ran = (value == 42) + end) + coroutine.resume(vm.co) + check(ran, "a synchronous hook returns without yielding") + check(coroutine.status(vm.co) == "dead", "and the handler runs to the end") + + local finish + local after = false + local vm2 = specialVm(0, { order = { "x" }, hooks = {} }) + vm2.busy = true + vm2.co = coroutine.create(function() + local value = Specials.block(vm2, function(done) finish = done end) + after = (value == 7) + end) + coroutine.resume(vm2.co) + check(coroutine.status(vm2.co) == "suspended", "an async hook parks the script") + check(not after, "and nothing behind it has run") + finish(7) + check(after, "the screen's own callback is what resumes it") +end + +-- The Day Care doors all reach the same screen with a different side, and only +-- DayCareManOutside's answer is read back (TRUE = the party was full). +do + local asked = {} + local reply = 0 + local vm = specialVm(0, { order = { "x" }, hooks = { + dayCare = function(side, done) asked[#asked + 1] = side done(reply) end } }) + vm.co = coroutine.create(function() + Specials.HANDLERS.DayCareMan(vm) + end) + coroutine.resume(vm.co) + eq(asked[1], "man", "DayCareMan opens the man's side") + eq(vm.scriptVar, 0, "and answers 0") + reply = 1 + vm.co = coroutine.create(function() + Specials.HANDLERS.DayCareManOutside(vm) + end) + coroutine.resume(vm.co) + eq(asked[2], "outside", "DayCareManOutside opens the egg hand-off") + eq(vm.scriptVar, 1, "and carries its TRUE back into wScriptVar") +end + +-- A stub is not a hole: each one leaves the value its routine's "nothing +-- happened" arm leaves, so the branch two commands later is right. +do + local vm = specialVm(0, { order = { "x" }, hooks = {} }) + vm.scriptVar = 99 + Specials.STUBS.CheckMysteryGift(vm) + eq(vm.scriptVar, 0, "CheckMysteryGift says there is no gift waiting") +end +check(Specials.STUBS.TrainerHouse == nil, + "TrainerHouse is a HANDLER, not a stub: sMysteryGiftTrainerHouseFlag is " .. + "honestly 0 without a link cable, so the routine is right rather than " .. + "merely absent") +do + local vm = specialVm(0, { order = { "x" }, hooks = {} }) + vm.scriptVar = 99 + Specials.STUBS.CloseLink(vm) + eq(vm.scriptVar, 99, + "CloseLink writes no wScriptVar, so the stub does not invent one") +end + +-- ---- the Blackthorn move deleter ------------------------------------------- +-- +-- MoveDeletion is a HANDLER, not a stub: it drives its own conversation +-- through the yielding text/yesorno primitives rather than through a hook +-- that answers on the spot, so this drives it the way the SlotMachine and +-- Specials.block tests above do -- one coroutine.resume per yield, feeding +-- each `yesorno` its answer off a queue and letting every `text`/`waitsfx` +-- yield pass through with nothing to say back. +check(Specials.STUBS.MoveDeletion == nil, + "MoveDeletion is a HANDLER: the screen it needed now exists") + +local function driveMoveDeletion(vm, yesAnswers) + local texts = {} + local qi = 0 + local resumeArg = nil + local ok, req = coroutine.resume(vm.co, resumeArg) + while true do + if not ok then error(req) end + if req and req.kind == "text" then texts[#texts + 1] = req.text end + if coroutine.status(vm.co) == "dead" then break end + resumeArg = nil + if req and req.kind == "yesorno" then + qi = qi + 1 + resumeArg = yesAnswers[qi] + end + ok, req = coroutine.resume(vm.co, resumeArg) + end + return texts +end + +-- Declining the very first "shall I make a #MON forget?" never reaches the +-- party list at all. +do + local vm = specialVm(0, { order = { "x" }, hooks = {} }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.MoveDeletion(vm) end) + local texts = driveMoveDeletion(vm, { false }) + eq(#texts, 2, "the intro and the decline line, nothing more") + check(texts[2]:find("No?", 1, true) ~= nil, + "the decline line is _DeleterNoComeAgainText") +end + +-- An egg has no moves to forget: `cp EGG` before the move count is even read. +do + local egg = { isEgg = true, moves = {} } + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, egg) end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.MoveDeletion(vm) end) + local texts = driveMoveDeletion(vm, { true }) + check(texts[#texts]:find("EGG", 1, true) ~= nil, + "the egg line is _DeleterEggText") +end + +-- One move known: `.onlyonemove` fires before ChooseMoveToDelete ever opens. +do + local mon = { moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } + local opened = false + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, mon) end, + chooseMoveToDelete = function(_mon, done) opened = true done(nil) end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.MoveDeletion(vm) end) + local texts = driveMoveDeletion(vm, { true }) + check(texts[#texts]:find("only one move", 1, true) ~= nil, + "the one-move line is _MoveKnowsOneText") + check(not opened, "ChooseMoveToDelete never opens for a one-move mon") +end + +-- Backing out of the move list itself (B in ChooseMoveToDelete) declines the +-- same way backing out of the mon list does. +do + local mon = { moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "GROWL", pp = 40, maxPp = 40 }, + } } + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, mon) end, + chooseMoveToDelete = function(_mon, done) done(nil) end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.MoveDeletion(vm) end) + local texts = driveMoveDeletion(vm, { true }) + check(texts[#texts]:find("No?", 1, true) ~= nil, + "backing out of the move list reads as a decline") + eq(#mon.moves, 2, "and nothing was deleted") +end + +-- The whole path: pick a mon, pick GROWL (slot 2 of 2), confirm, and it is +-- gone -- shifted out of the array the same way .DeleteMove shifts the PP +-- array beside it, since both live in the one `moves` entry here. +do + local mon = { moves = { + { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "GROWL", pp = 40, maxPp = 40 }, + } } + local sfxPlayed, sfxFallback = nil, nil + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, mon) end, + chooseMoveToDelete = function(chosen, done) + check(chosen == mon, "the move list opens on the mon SelectMonFromParty picked") + done(2) + end, + playSfxNamed = function(name, fallback) + sfxPlayed, sfxFallback = name, fallback + end, + } }) + vm.data = function() return { moves = { GROWL = { name = "GROWL" } } } end + -- specialVm wires data() through nothing today, so H.MoveDeletion's own + -- `data(vm)` (hooks(vm).data) needs the same hook the party/save readers + -- use; specialVm has no `data` opt, so this reaches in directly. + vm.specials.data = function() return { moves = { GROWL = { name = "GROWL" } } } end + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.MoveDeletion(vm) end) + local texts = driveMoveDeletion(vm, { true, true }) + check(texts[#texts]:find("forgot the move", 1, true) ~= nil, + "the last line is _DeleterForgotMoveText") + eq(#mon.moves, 1, "GROWL is gone") + eq(mon.moves[1].id, "TACKLE", "TACKLE, the move that was not picked, stays") + eq(sfxPlayed, "Sfx_MoveDeleted", "the deletion jingle is asked for by label") + eq(sfxFallback, 97, "and its SFX_MOVE_DELETED index is the fallback") +end + +-- ---- the Goldenrod NAME RATER ---------------------------------------------- +-- +-- Same drive shape as driveMoveDeletion above: NameRater is a HANDLER, so it +-- yields through the same text/yesorno primitives rather than answering a +-- hook on the spot. +check(Specials.STUBS.NameRater == nil, + "NameRater is a HANDLER: the keyboard it needed now exists") + +local function driveNameRater(vm, yesAnswers) + local texts = {} + local qi = 0 + local resumeArg = nil + local ok, req = coroutine.resume(vm.co, resumeArg) + while true do + if not ok then error(req) end + if req and req.kind == "text" then texts[#texts + 1] = req.text end + if coroutine.status(vm.co) == "dead" then break end + resumeArg = nil + if req and req.kind == "yesorno" then + qi = qi + 1 + resumeArg = yesAnswers[qi] + end + ok, req = coroutine.resume(vm.co, resumeArg) + end + return texts +end + +local PLAYER_HOOK = { save = function() + return { player = { name = "CHRIS", id = 12345 } } +end } + +-- Declining the very first "would you like me to rate names?" never reaches +-- the party list at all. +do + local vm = specialVm(0, { order = { "x" }, hooks = PLAYER_HOOK }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { false }) + eq(#texts, 2, "the intro and the decline line, nothing more") + check(texts[2]:find("Come", 1, true) ~= nil, + "the decline line is _NameRaterComeAgainText") +end + +-- Backing out of SelectMonFromParty (B) reads the same as declining. +do + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(nil, nil) end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true }) + check(texts[#texts]:find("Come", 1, true) ~= nil, + "backing out of the party list reads as a decline") +end + +-- `cp EGG`: an egg's nickname is never up for rating. +do + local egg = { isEgg = true, nickname = "EGG" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, egg) end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true }) + check(texts[#texts]:find("EGG", 1, true) ~= nil, + "the egg line is _NameRaterEggText") +end + +-- CheckIfMonIsYourOT: a mon whose OT name or id differs from the player's +-- gets the refusal and never reaches the "how about a better name?" prompt. +do + local traded = { name = "GOLDUCK", nickname = "PSY", ot = "RIVAL", otId = 999 } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, traded) end, + renameMon = function() error("the traded arm must never open the keyboard") end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true }) + check(texts[#texts]:find("perfect", 1, true) ~= nil, + "the traded line is _NameRaterPerfectNameText") + check(texts[#texts]:find("PSY", 1, true) ~= nil, + "and it names the mon by its current nickname via {STRBUF}") +end + +-- A mon with no `ot` / `otId` at all (Mon.new sets neither) reads as the +-- player's own, the same way H.FindPartyMonThatSpeciesYourTrainerID treats a +-- nil otId as a match. +do + local mine = { name = "TOTODILE", nickname = "TOTO" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, mine) end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { false }) + check(texts[#texts]:find("Come", 1, true) ~= nil, + "declining the better-name offer for your own mon is a plain decline") +end + +-- The whole rename path: a new, different name is copied onto the mon and +-- both NamedText and FinishedText print, in that order. +do + local mine = { name = "CYNDAQUIL", nickname = "CINDY" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, mine) end, + renameMon = function(mon, done) + check(mon == mine, "renameMon opens on the mon SelectMonFromParty picked") + done("BLAZE") + end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true, true }) + eq(mine.nickname, "BLAZE", "the new name is copied onto the party mon") + check(texts[#texts - 1]:find("BLAZE", 1, true) ~= nil, + "NamedText prints the new name via {STRBUF}") + check(texts[#texts]:find("better", 1, true) ~= nil, + "and FinishedText follows it") +end + +-- IsNewNameEmpty: a blank keyboard entry is treated as unchanged, not as a +-- second decline -- SameNameText prints, and the nickname is untouched. +do + local mine = { name = "CYNDAQUIL", nickname = "CINDY" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, mine) end, + renameMon = function(_mon, done) done(" ") end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true, true }) + eq(mine.nickname, "CINDY", "a blank entry leaves the old nickname in place") + check(texts[#texts - 1]:find("CINDY", 1, true) ~= nil, + "NamedText re-prints the OLD name, unchanged") + check(texts[#texts]:find("same as before", 1, true) ~= nil, + "and SameNameText follows it rather than FinishedText") +end + +-- CompareNewToOld: retyping the identical name is the same "unchanged" arm +-- as a blank entry, not a second decline. +do + local mine = { name = "CYNDAQUIL", nickname = "CINDY" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, mine) end, + renameMon = function(_mon, done) done("CINDY") end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true, true }) + eq(mine.nickname, "CINDY", "retyping the same name is a no-op") + check(texts[#texts]:find("same as before", 1, true) ~= nil, + "and SameNameText prints, exactly as an empty entry does") +end + +-- Backing out of the keyboard itself (B in NamingScreen) declines the same +-- way backing out of the mon list does. +do + local mine = { name = "CYNDAQUIL", nickname = "CINDY" } + local hooks = { + save = PLAYER_HOOK.save, + selectPartyMon = function(_prompt, done) done(1, mine) end, + renameMon = function(_mon, done) done(nil) end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.NameRater(vm) end) + local texts = driveNameRater(vm, { true, true }) + eq(mine.nickname, "CINDY", "nothing was renamed") + check(texts[#texts]:find("same as before", 1, true) ~= nil, + "a nil name from the keyboard reads as blank, not as a decline") +end + +-- ---- the #DEX-completion diploma ------------------------------------------- +-- +-- H.Diploma just parks on World:showDiploma and resumes once the screen +-- calls its onDone -- it never touches wScriptVar, matching _Diploma's own +-- asm (no `ld [wScriptVar], a` anywhere in the routine). +do + local opened = false + local hooks = { + showDiploma = function(onDone) + opened = true + onDone() + end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks, scriptVar = 7 }) + vm.co = coroutine.create(function() Specials.HANDLERS.Diploma(vm) end) + local ok, err = coroutine.resume(vm.co) + check(ok, "H.Diploma runs to completion: " .. tostring(err)) + check(opened, "H.Diploma calls World:showDiploma via the specials hooks") + eq(vm.scriptVar, 7, "the special never writes wScriptVar") +end + +-- A missing hook (no screen) is a safe no-op, the same degrade every other +-- screen-opening special takes when its id is not registered. +do + local vm = specialVm(0, { order = { "x" }, hooks = {}, scriptVar = 3 }) + vm.co = coroutine.create(function() Specials.HANDLERS.Diploma(vm) end) + local ok, err = coroutine.resume(vm.co) + check(ok, "H.Diploma tolerates a missing showDiploma hook: " .. tostring(err)) + eq(vm.scriptVar, 3, "and still leaves wScriptVar untouched") +end + +-- ---- the Cianwood photo studio --------------------------------------------- +-- +-- H.PhotoStudio never touches wScriptVar, matching PhotoStudio's own asm (no +-- `ld [wScriptVar], a` anywhere in the routine) -- so every case below checks +-- vm.scriptVar is left exactly where specialVm set it, the same way the +-- Diploma tests above do. +local function drivePhotoStudio(vm) + local texts = {} + local ok, req = coroutine.resume(vm.co, nil) + while true do + if not ok then error(req) end + if req and req.kind == "text" then texts[#texts + 1] = req.text end + if coroutine.status(vm.co) == "dead" then break end + ok, req = coroutine.resume(vm.co, nil) + end + return texts +end + +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.UnownPrinter == nil + and Specials.HANDLERS.UnownPrinter ~= nil, + "UnownPrinter is a handler for the same reason this one is: the stamp " + .. "viewer is drawn on the cartridge and only the A press wanted a printer") + +-- Backing out of the party list (B in SelectMonFromParty) reads as a decline, +-- the same way MoveDeletion and NameRater treat it above. +do + local opened = false + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(nil, nil) end, + showPhotoStudio = function(_mon, done) opened = true done() end, + }, scriptVar = 5 }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.PhotoStudio(vm) end) + local texts = drivePhotoStudio(vm) + eq(#texts, 2, "the which-mon prompt and the no-photo line, nothing more") + check(texts[1]:find("photo", 1, true) ~= nil, "the first line is _WhichMonPhotoText") + check(texts[2]:find("no picture", 1, true) ~= nil, + "declining reads the same _NoPhotoText the printer-error arm would") + check(not opened, "the portrait screen never opens for a declined pick") + eq(vm.scriptVar, 5, "the special never writes wScriptVar") +end + +-- An egg: `cp EGG` before the camera ever opens. +do + local egg = { isEgg = true } + local opened = false + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, egg) end, + showPhotoStudio = function(_mon, done) opened = true done() end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.PhotoStudio(vm) end) + local texts = drivePhotoStudio(vm) + check(texts[#texts]:find("EGG", 1, true) ~= nil, "the egg line is _EggPhotoText") + check(not opened, "an egg never reaches the portrait screen either") +end + +-- The full path: a real mon picked, the portrait screen opens for the "hold +-- still" beat, and the print always comes back as a no-photo -- there is no +-- Game Boy Printer to answer with a success, see the handler's header. +do + local mon = { species = "CYNDAQUIL", nickname = "CINDY" } + local opened, seenMon = false, nil + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, mon) end, + showPhotoStudio = function(picked, done) + opened = true + seenMon = picked + done() + end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.PhotoStudio(vm) end) + local texts = drivePhotoStudio(vm) + eq(#texts, 3, "which-mon, hold-still, and the printer-arm's no-photo line") + check(texts[2]:find("Hold", 1, true) ~= nil, "the middle line is _HoldStillText") + check(texts[3]:find("no picture", 1, true) ~= nil, + "hPrinter is hardwired to the error arm: no peripheral, so no success text") + check(opened, "the portrait screen opens for the picked mon") + eq(seenMon, mon, "showPhotoStudio gets the exact mon SelectMonFromParty picked") +end + +-- A missing hook (no screen) is a safe no-op, the same degrade every other +-- screen-opening special takes when its id is not registered. +do + local mon = { species = "CYNDAQUIL" } + local vm = specialVm(0, { order = { "x" }, hooks = { + selectPartyMon = function(_prompt, done) done(1, mon) end, + } }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.PhotoStudio(vm) end) + local ok, err = pcall(drivePhotoStudio, vm) + check(ok, "H.PhotoStudio tolerates a missing showPhotoStudio hook: " + .. tostring(err)) +end + +-- ---- PROF.OAK's PC #DEX rating --------------------------------------------- +-- +-- ProfOaksPCBoot is a HANDLER, not a stub: it drives three text pages and a +-- fanfare through the same yielding primitives MoveDeletion/NameRater above +-- use, so this drives it the same way -- one resume per yield, with no +-- yesorno to answer (the yes/no gate lives in ProfOaksPC, which has no call +-- site here) and the `waitsfx` yield at the end passed straight through. +check(Specials.STUBS.ProfOaksPCBoot == nil, + "ProfOaksPCBoot is a HANDLER: the seen/owned counts and rating table exist") + +local function driveOaksPC(vm) + local texts = {} + local ok, req = coroutine.resume(vm.co, nil) + while true do + if not ok then error(req) end + if req and req.kind == "text" then texts[#texts + 1] = req.text end + if coroutine.status(vm.co) == "dead" then break end + ok, req = coroutine.resume(vm.co, nil) + end + return texts +end + +-- An empty dex: 0 seen, 0 caught, the lowest band (OakRating01, "Look for +-- #MON in grassy areas!"). +do + local hooks = { save = function() + return { pokedex = { seen = {}, caught = {} } } + end } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.ProfOaksPCBoot(vm) end) + local texts = driveOaksPC(vm) + eq(#texts, 3, "the completion-level page, the seen/owned page, and the rating") + check(texts[1]:find("completion level", 1, true) ~= nil, + "page 1 is _OakPCText2") + check(texts[2]:find("0 #MON seen", 1, true) ~= nil, + "page 2 leads with the seen count, formatted in place of wStringBuffer3") + check(texts[2]:find("0 #MON owned", 1, true) ~= nil, + "and the owned count in place of wStringBuffer4") + check(texts[3]:find("grassy areas", 1, true) ~= nil, + "0 caught lands on OakRating01, the first row FindOakRating can match") +end + +-- FindOakRating's ascending-cap walk: 9 caught lands on the SAME band as 0 +-- (both <= 9, OakRating01), 10 caught crosses into the next one +-- (OakRating02, "understand how to use # BALLS"). +do + local function seenCaught(n) + local caught = {} + for i = 1, n do caught["MON" .. i] = true end + return { pokedex = { seen = {}, caught = caught } } + end + local sfxPlayed = nil + local hooks = { + save = function() return seenCaught(9) end, + playSfxNamed = function(name) sfxPlayed = name end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.ProfOaksPCBoot(vm) end) + local texts = driveOaksPC(vm) + check(texts[3]:find("grassy areas", 1, true) ~= nil, + "9 caught is still inside the first band's cap") + eq(sfxPlayed, "Sfx_DexFanfareLessThan20", + "and its fanfare is the Gold sfx label, not a pokered name") +end + +do + local caught = {} + for i = 1, 10 do caught["MON" .. i] = true end + local hooks = { save = function() + return { pokedex = { seen = {}, caught = caught } } + end } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.ProfOaksPCBoot(vm) end) + local texts = driveOaksPC(vm) + check(texts[3]:find("# BALLS", 1, true) ~= nil, + "10 caught crosses the first cap into OakRating02") +end + +-- The top band: every count from 240 up through a full 251-species dex reads +-- the same "perfect #DEX" line and the same top fanfare -- the table's own +-- cap of 255 is never reached by a real save, so this is the row that never +-- changes again once the player is this close to done. +do + local caught = {} + for i = 1, 251 do caught["MON" .. i] = true end + local seen = {} + for i = 1, 251 do seen["MON" .. i] = true end + local sfxPlayed = nil + local hooks = { + save = function() return { pokedex = { seen = seen, caught = caught } } end, + playSfxNamed = function(name) sfxPlayed = name end, + } + local vm = specialVm(0, { order = { "x" }, hooks = hooks }) + vm.showTextFn = function() end + vm.co = coroutine.create(function() Specials.HANDLERS.ProfOaksPCBoot(vm) end) + local texts = driveOaksPC(vm) + check(texts[2]:find("251 #MON seen", 1, true) ~= nil, + "a full dex counts every `true` entry in save.pokedex.seen") + check(texts[2]:find("251 #MON owned", 1, true) ~= nil, "and .caught") + check(texts[3]:find("perfect", 1, true) ~= nil, + "251 caught is OakRating19, the table's last row") + eq(sfxPlayed, "Sfx_DexFanfare230Plus", + "sharing the top fanfare with the last few bands below it") + -- This special never touches Diploma or EVENT_ENABLE_DIPLOMA_PRINTING -- + -- that is Celadon Mansion 3F's GameFreakGameDesignerScript, reading + -- VAR_DEXCAUGHT on its own -- so completing the dex here does not, on its + -- own, open the diploma screen. Diploma is now a real handler + -- (src/ui/gen2/Diploma.lua), not a stub. + check(Specials.STUBS.Diploma == nil, + "Diploma is a real handler now, not a stub") + check(Specials.HANDLERS.Diploma ~= nil, + "and it is the one that special dispatch resolves to") +end + +-- ---- itemnotify names the item and its own pocket -------------------------- +-- +-- Script_itemnotify is GetPocketName + CurItemName off wCurItem +-- (engine/overworld/scripting.asm:460), and _PutItemInPocketText's two blanks +-- are wStringBuffer1 (the item) and wStringBuffer3 (the pocket) +-- (data/text/common_2.asm:1351). Neither reads the shared {STRBUF} stand-in, +-- which is exactly the trap maps/MrPokemonsHouse.asm:31-35 sets: a `getstring` +-- for MAP CARD sits a few rows above the `giveitem MYSTERY_EGG / itemnotify` +-- pair, so a port that printed the buffer would announce the MAP CARD. +do + local function notifyBox(pocketFor) + local log = {} + local vm = Vm.new({ + generation = 2, + ["s:gift"] = { + { op = "getstring", string = "MAP CARD" }, + { op = "giveitem", item = 69, quantity = 1 }, + { op = "itemnotify" }, + { op = "end" }, + }, + }, {}, Events.new(), { + showText = function(body, onDone) + log[#log + 1] = body + onDone() + end, + giveItem = function() return true end, + getItemName = function(index) + if index == 7 then return "MAP CARD" end + if index == 69 then return "MYSTERY EGG" end + return "?" + end, + getItemPocket = pocketFor, + }) + check(vm:start("s:gift"), "the Mr. POKEMON gift script starts") + for _ = 1, 20 do vm:update() end + return log[1] + end + + eq(notifyBox(function(index) return index == 69 and "KEY_ITEM" or nil end), + "{PLAYER} put the\nMYSTERY EGG in\nthe KEY POCKET.", + "itemnotify names wCurItem and its pocket, not the stale getstring") + eq(notifyBox(function() return "TM_HM" end), + "{PLAYER} put the\nMYSTERY EGG in\nthe TM POCKET.", + "a TM_HM item names the TM POCKET") + eq(notifyBox(function() return "BALL" end), + "{PLAYER} put the\nMYSTERY EGG in\nthe BALL POCKET.", + "and a BALL names the BALL POCKET") +end + +S.finish() diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua new file mode 100644 index 00000000..b2c4121e --- /dev/null +++ b/tests/gen2_world_test.lua @@ -0,0 +1,3343 @@ +-- Gen 2 COLL_* permissions + New Bark warp/connection facts against a Gold +-- cache. Self-contained: `luajit tests/gen2_world_test.lua`; also dofile'd +-- by tests/run_tests.lua. Map checks SKIP when no gold cache is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 world") +local check, eq = S.check, S.eq + +local Permissions = require("src.world.gen2.Permissions") +local Map = require("src.world.gen2.Map") +local NPC = require("src.world.gen2.Npc") +local Events = require("src.world.gen2.Events") + +-- Object visibility: flag SET hides; 0xFFFF always shown. +local ev = Events.new({ 1794 }) +check(ev:get(1794), "initial flag applied") +check(not ev:objectVisible(1794), "set flag hides object") +check(ev:objectVisible(nil), "nil eventFlag always visible") +check(ev:objectVisible(0xFFFF), "0xFFFF eventFlag always visible") +check(ev:objectVisible(1600), "unset flag leaves object visible") + + +check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable") +check(not Permissions.isWalkable(0x07), "COLL_WALL blocked") +check(Permissions.isWater(0x29), "COLL_WATER is water") +check(Permissions.isWarpCollision(0x71), "COLL_DOOR is warp") +check(Permissions.isImmediateWarp(0x71), "COLL_DOOR immediate") +check(not Permissions.isImmediateWarp(0x70), "carpet not immediate") +eq(Permissions.carpetDirection(0x70), "down", "carpet down") +eq(Permissions.of(0xff), Permissions.WALL, "missing coll → wall") +check(not Permissions.isWalkable(0xff), "sentinel $ff not walkable") + +-- ---------------------------------------------------------------- field +-- The two encounter systems that need an INPUT path: a rod used from the PACK +-- and an A press on a headbutt tree. Both are wired here rather than called +-- directly, because the wiring is the thing under test -- a stub input drives +-- PackMenu, a stub map drives World:interact, and the world's own text / +-- choice / battle seams are recorded so the ORDER the cart's script imposes +-- (ask, then shake, then roll) is checkable. + +local World = require("src.world.gen2.World") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- Collisions the two paths key off, straight out of the table above: +-- COLL_FLOOR walkable land, COLL_WATER, COLL_HEADBUTT_TREE. +local COLL_FLOOR, COLL_WATER, COLL_TREE = 0x00, 0x29, 0x15 + +check(World.isHeadbuttTree(COLL_TREE), "COLL_HEADBUTT_TREE is a tree") +check(World.isHeadbuttTree(0x1d), "COLL_HEADBUTT_TREE_1D alias is a tree") +check(not World.isHeadbuttTree(COLL_FLOOR), "floor is not a tree") +check(not World.isHeadbuttTree(nil), "no tile is not a tree") +check(Permissions.isWall(COLL_TREE), "a headbutt tree blocks a step") + +-- ItemNames indices out of constants/item_constants.asm. +local ITEMS = { + OLD_ROD = { id = "OLD_ROD", name = "OLD ROD", pocket = "KEY_ITEM", + index = 0x3a }, + GOOD_ROD = { id = "GOOD_ROD", name = "GOOD ROD", pocket = "KEY_ITEM", + index = 0x3b }, + SUPER_ROD = { id = "SUPER_ROD", name = "SUPER ROD", pocket = "KEY_ITEM", + index = 0x3d }, + BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", + index = 0x06 }, + -- A key item with no field handler of its own, which is what the PACK's + -- onChoose fallthrough needs: every other key item here is a rod or the + -- BICYCLE, and both of those are claimed by useFieldItem. + COIN_CASE = { id = "COIN_CASE", name = "COIN CASE", pocket = "KEY_ITEM", + index = 0x47 }, + REPEL = { id = "REPEL", name = "REPEL", pocket = "ITEM", index = 0x14, + canSelect = true }, + SUPER_REPEL = { id = "SUPER_REPEL", name = "SUPER REPEL", pocket = "ITEM", + index = 0x2a }, + MAX_REPEL = { id = "MAX_REPEL", name = "MAX REPEL", pocket = "ITEM", + index = 0x2b }, + -- ITEMATTR_PERMISSIONS: a plain field item with no CANT_SELECT_F. + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 0x02, + canSelect = true }, + -- A TM/HM's CheckSelectableItem.CheckTMHM always refuses (RegisterItem's + -- .cant_register), which the extractor already answers as canSelect = false. + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0x32, + canSelect = false }, +} +check(World.isRod("OLD_ROD", ITEMS), "OLD ROD is a rod") +check(World.isRod("GOOD_ROD", ITEMS), "GOOD ROD is a rod") +check(World.isRod("SUPER_ROD", ITEMS), "SUPER ROD is a rod") +check(not World.isRod("BICYCLE", ITEMS), "BICYCLE is not a rod") +check(not World.isRod("OLD_ROD", { OLD_ROD = { index = 7 } }), + "a rod at the wrong ItemNames index is refused") + +local DATA = { + items = ITEMS, + moves = { + HEADBUTT = { name = "HEADBUTT", pp = 15 }, + SPLASH = { name = "SPLASH", pp = 40 }, + TACKLE = { name = "TACKLE", pp = 35 }, + }, + pokemon = { + MAGIKARP = { + name = "MAGIKARP", types = { "WATER", "WATER" }, + baseStats = { hp = 20, attack = 10, defense = 55, speed = 80, + specialAttack = 15, specialDefense = 20 }, + levelMoves = { { level = 1, move = "SPLASH" } }, + }, + HOOTHOOT = { + name = "HOOTHOOT", types = { "NORMAL", "FLYING" }, + baseStats = { hp = 60, attack = 30, defense = 30, speed = 50, + specialAttack = 36, specialDefense = 56 }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + }, +} + +-- One always-hit row per list, so the roll is not what these assert. +local ENCOUNTERS = { + fishGroups = { + FISHGROUP_POND = { + old = { { chance = 256, species = "MAGIKARP", level = 10 } }, + good = { { chance = 256, species = "MAGIKARP", level = 20 } }, + super = { { chance = 256, species = "MAGIKARP", level = 40 } }, + }, + }, + trees = { TEST_MAP = "TREEMON_SET_TEST" }, + treeSets = { + TREEMON_SET_TEST = { + common = { { chance = 100, species = "HOOTHOOT", level = 12 } }, + rare = { { chance = 100, species = "HOOTHOOT", level = 12 } }, + }, + }, +} + +-- A 100-wide strip of collisions, keyed y*100+x, plus the empty event lists +-- World:interact walks. `opts` fills in the parts of a real Map the field +-- moves read: the block grid CUT and WHIRLPOOL edit, the tileset that decides +-- which replacement block they use, and the environment the encounter gate +-- branches on. +local MAP_W, MAP_H = 10, 10 + +local function fakeMap(cells, opts) + opts = opts or {} + local blocks = {} + for i = 1, MAP_W * MAP_H do blocks[i] = 0 end + for index, id in pairs(opts.blocks or {}) do blocks[index] = id end + local map + map = { + id = "TEST_MAP", + width = MAP_W, height = MAP_H, + blocks = blocks, + def = { + bgEvents = {}, objects = {}, blocks = blocks, + width = MAP_W, height = MAP_H, + tileset = opts.tileset or "TILESET_JOHTO", + environment = opts.environment or "ROUTE", + palette = opts.palette or "PALETTE_AUTO", + }, + cellCollision = function(_, x, y) return cells[y * 100 + x] or COLL_FLOOR end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2 + end, + isWalkable = function(_, x, y) + if not map:inBounds(x, y) then return false end + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + } + return map +end + +local function fakePlayer(x, y, facing) + return { + cellX = x, cellY = y, px = x * 16, py = y * 16, + facing = facing, moving = false, turnArmed = true, + update = function() return false end, + -- Player:setSprite, which World:applyPlayerState calls every time + -- wPlayerState changes. Nothing here draws, so it only has to exist. + setSprite = function() end, + } +end + +-- A world with the render half replaced: no love here, so the seams that would +-- push a TextBox, a ChoiceBox or the battle screen record instead. `log` is +-- the script trace the assertions read. +local function fakeWorld(cells, player, party, fishGroup, opts) + opts = opts or {} + local game = { + data = DATA, + save = { player = { name = "GOLD", badges = opts.badges or {} }, + party = party or {}, + inventory = { OLD_ROD = 1, BICYCLE = 1, COIN_CASE = 1 } }, + } + local world = World.new(game) + game.world = world + world.map = fakeMap(cells, opts) + -- maps[id] IS the map's def in the real world, and World:restoreBlocks + -- reaches for it that way when a cut tree has to grow back. + world.map.def.fishGroup = fishGroup or "FISHGROUP_POND" + world.maps = { TEST_MAP = world.map.def } + world.player = player + world.encounters = ENCOUNTERS + world.vm = { running = function() return false end, update = function() end } + -- The palette poll re-bakes map canvases through love.graphics; nothing in + -- these assertions is about colour. + world.pollTimeOfDay = function() end + + local log = {} + world.log = log + world.showText = function(self, body, onDone) + log[#log + 1] = body + self.textbox = true + self.pendingText = function() + self.textbox = nil + if onDone then onDone() end + end + end + world.askYesNo = function(self, onChoose) + log[#log + 1] = "<yesno>" + self.choicebox = true + self.pendingChoice = function(yes) + self.choicebox = nil + onChoose(yes) + end + end + world.startBattle = function(_, opts) + log[#log + 1] = "<battle:" .. tostring(opts.wild and opts.wild.species) .. ">" + return true + end + return world, game +end + +-- The A that closes a text box, and the YES/NO answer. +local function advanceText(world) + local fn = world.pendingText + world.pendingText = nil + if fn then fn() end +end + +local function answerYesNo(world, yes) + local fn = world.pendingChoice + world.pendingChoice = nil + if fn then fn(yes) end +end + +local function runFrames(world, n) + for _ = 1, n do world:step() end +end + +local function stubInput() + local input = { queued = {} } + function input:press(button) self.queued[button] = true end + function input:wasPressed(button) + if self.queued[button] then + self.queued[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +-- A stack that only has to answer the one question PackMenu asks of it. +local function stubStack() + return { cleared = 0, clear = function(self) self.cleared = self.cleared + 1 end } +end + +-- A on a field-PACK row does not use the item: it opens the item submenu +-- (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243) whose first row is +-- USE, so "use the highlighted row" is two A presses through that menu -- which +-- is why every use below presses A twice. The chooser packs (the mart's SELL, +-- the item PC's DEPOSIT, the DUDE's) have no submenu and still answer the first +-- press; see PackMenu:hasSubmenu. Written out at each site rather than as a +-- helper because this file is already at the 200-local ceiling. + +-- ---- A. the rod ---------------------------------------------------------- +-- Facing land: .TryFish falls to $3 .FailFish, so UseItem's .Oak prints inside +-- the PACK and the PACK stays open. +local landWorld, landGame = fakeWorld({}, fakePlayer(5, 5, "down")) +landGame.input = stubInput() +landGame.stack = stubStack() +local chosen = nil +local landPack = PackMenu.new(landGame, { + pocket = "KEY_ITEM", + onChoose = function(id) chosen = id end, + onClose = function() chosen = "<closed>" end, +}) +eq(landPack.rows[1].id, "BICYCLE", "key items sort by ItemNames index") +eq(landPack.rows[2].id, "OLD_ROD", "the rod is the second key item") +landPack.index = 2 +landGame.input:press("a") +landPack:update(0) +landGame.input:press("a") +landPack:update(0) +check(landPack.message ~= nil, "rod on land keeps the PACK open with a message") +eq(landPack.message[1], "OAK: {PLAYER}!", "and the message is Oak's") +eq(landGame.stack.cleared, 0, "rod on land does not quit the PACK") +eq(chosen, nil, "rod on land never reaches onChoose") +check(landWorld.fishing == nil, "rod on land starts no cast") +landGame.input:press("a") +landPack:update(0) +check(landPack.message == nil, "a button clears the message") + +-- An item World claims nothing for still falls through to the PACK's own +-- onChoose (TM teaching). +landPack.index = 3 +landGame.input:press("a") +landPack:update(0) +landGame.input:press("a") +landPack:update(0) +eq(chosen, "COIN_CASE", "an unhandled item reaches onChoose untouched") +eq(landGame.stack.cleared, 0, "and does not quit the PACK either") + +-- Facing water: the roll lands on $2 .FishGotSomething, the PACK quits +-- (PACKSTATE_QUITRUNSCRIPT) and Script_FishCastRod's cast owns the world. +local waterCells = { [4 * 100 + 5] = COLL_WATER } +local seaWorld, seaGame = fakeWorld(waterCells, fakePlayer(5, 5, "up")) +seaGame.input = stubInput() +seaGame.stack = stubStack() +local seaPack = PackMenu.new(seaGame, { pocket = "KEY_ITEM" }) +seaPack.index = 2 +seaGame.input:press("a") +seaPack:update(0) +seaGame.input:press("a") +seaPack:update(0) +check(seaPack.message == nil, "rod on water prints no refusal") +eq(seaGame.stack.cleared, 1, "rod on water quits the PACK") +check(seaWorld.fishing ~= nil, "rod on water starts the cast") +eq(seaWorld.fishing.outcome, "battle", "and the roll already hooked something") +check(seaWorld:busy(), "the cast holds the world") +-- pause 40, the bite, pause 40, then RodBiteText. +runFrames(seaWorld, 41) +eq(seaWorld.fishing.phase, "bite", "the cast runs out into the bite") +eq(#seaWorld.log, 0, "and says nothing until the rod comes back") +runFrames(seaWorld, 41) +eq(seaWorld.log[1], "Oh!\nA bite!", "RodBiteText lands after the second pause") +check(seaWorld.log[2] == nil, "and the battle waits on the button") +advanceText(seaWorld) +eq(seaWorld.log[2], "<battle:MAGIKARP>", "the hooked mon is the battle") +check(seaWorld.fishing == nil, "the cast is over") +check(not seaWorld:busy(), "and the world is free again") + +-- FISHGROUP_NONE is $4 .FishNoFish: the PACK still quits, the rod still casts, +-- and RodNothingText is what comes back. +local dryWorld = fakeWorld(waterCells, fakePlayer(5, 5, "up"), nil, + "FISHGROUP_NONE") +eq(dryWorld:useRod("OLD_ROD"), "nofish", "no fish group is .FishNoFish") +runFrames(dryWorld, 41) +eq(dryWorld.log[1], "Not even a nibble!", "and RodNothingText is the line") +advanceText(dryWorld) +check(dryWorld.fishing == nil, "the rod goes away after the nibble line") + +-- A rod is ITEMMENU_NOUSE in battle, and a running script owns the world. +local busyWorld = fakeWorld(waterCells, fakePlayer(5, 5, "up")) +busyWorld.battleActive = true +eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing from inside a battle") +busyWorld.battleActive = nil +busyWorld.vm = { running = function() return true end, update = function() end } +eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing while a script runs") +check(busyWorld:useFieldItem("COIN_CASE") == nil, + "useFieldItem passes an unhandled item back to the PACK") + +-- ---- A2. REPEL / SUPER REPEL / MAX REPEL ---------------------------------- +-- UseRepel (engine/items/item_effects.asm): the step count is the only thing +-- that differs between the three items, wRepelEffect already set refuses +-- without touching either the counter or the bag, and only the success arm +-- goes through UseDisposableItem. +-- +-- Scoped in its own `do` block: the file is already brushing Lua's 200-local +-- ceiling, and this test needs its own world/game/pack rather than reusing +-- the rod fixtures above. +do +local repelWorld, repelGame = fakeWorld({}, fakePlayer(5, 5, "down")) +repelGame.save.inventory.REPEL = 2 +repelGame.save.inventory.SUPER_REPEL = 1 +eq(repelWorld:useRepel("REPEL"), "repel_used", "a fresh REPEL is used") +eq(repelGame.save.repelSteps, 100, "REPEL sets the counter to 100") +eq(repelGame.save.inventory.REPEL, 1, "and one REPEL leaves the bag") + +eq(repelWorld:useRepel("SUPER_REPEL"), "repel_active", + "a REPEL already ticking refuses a SUPER REPEL") +eq(repelGame.save.repelSteps, 100, "the counter is untouched by the refusal") +eq(repelGame.save.inventory.SUPER_REPEL, 1, + "and the SUPER REPEL is not taken from the bag") + +repelGame.save.repelSteps = 0 +eq(repelWorld:useRepel("SUPER_REPEL"), "repel_used", + "a SUPER REPEL is used once the counter reaches zero") +eq(repelGame.save.repelSteps, 200, "SUPER REPEL sets the counter to 200") +check(repelGame.save.inventory.SUPER_REPEL == nil, + "the last SUPER REPEL is gone from the bag") + +-- Battle refuses a REPEL exactly like a rod (ITEMMENU_NOUSE): the world is +-- shared with BattleState:openPack, so the guard has to live here too. +repelWorld.battleActive = true +repelGame.save.repelSteps = 0 +eq(repelWorld:useRepel("MAX_REPEL"), "nowhere", + "no REPEL from inside a battle") +eq(repelGame.save.repelSteps, 0, "and the refusal never sets the counter") +repelWorld.battleActive = nil + +-- The PACK: World already claims the item, so onChoose is never reached, and +-- the still-in-effect message keeps the row list (and the bag) untouched. +local packGame = { data = DATA, save = repelGame.save, + stack = stubStack(), input = stubInput(), world = repelWorld } +local repelPack = PackMenu.new(packGame, { pocket = "ITEM" }) +local chosenRepel +repelPack.onChoose = function(id) chosenRepel = id end +for i, row in ipairs(repelPack.rows) do + if row.id == "REPEL" then repelPack.index = i end +end +packGame.input:press("a") +repelPack:update(0) +packGame.input:press("a") +repelPack:update(0) +check(repelPack.message ~= nil, "using a REPEL keeps the PACK open with a message") +eq(repelPack.message[1], "{PLAYER} used the", "the message is ItemUsedText") +eq(repelPack.message[2], "REPEL.", "naming the item that was used") +eq(packGame.stack.cleared, 0, "and the PACK is never quit") +eq(chosenRepel, nil, "onChoose never sees a REPEL the world already claimed") +eq(repelGame.save.repelSteps, 100, "the PACK path set the counter too") + +packGame.input:press("a") +repelPack:update(0) +check(repelPack.message == nil, "a button clears ItemUsedText") + +for i, row in ipairs(repelPack.rows) do + if row.id == "SUPER_REPEL" then repelPack.index = i end +end +repelGame.save.inventory.SUPER_REPEL = 1 +repelPack:rebuild() +for i, row in ipairs(repelPack.rows) do + if row.id == "SUPER_REPEL" then repelPack.index = i end +end +packGame.input:press("a") +repelPack:update(0) +packGame.input:press("a") +repelPack:update(0) +eq(repelPack.message[1], "The REPEL used", + "a REPEL already active refuses a second item with the static text") +eq(repelPack.message[3], "in effect.", "the third line of the fixed text") +end + +-- ---- A3. SACRED ASH ------------------------------------------------------- +-- SacredAshEffect / _SacredAsh (engine/items/item_effects.asm, +-- engine/events/sacred_ash.asm). CheckAnyFaintedMon is the whole gate: no +-- fainted (non-egg) party member means no carry, wItemEffectSucceeded stays +-- 0, and UseItem's .Field falls to .Oak with the Ash untouched -- "nowhere", +-- same answer PackMenu already gives a rod cast nowhere useful. A hit queues +-- SacredAshScript (special HealParty, three Pokecenter fade cycles, the +-- "all healed" line) and only then removes the one Ash. +do +local order = { "HealParty", "FadeOutToWhite", "FadeInFromWhite" } + +local faintedParty = { + { species = "MAGIKARP", nickname = "KARP", hp = 0, maxHp = 20, + moves = { { id = "SPLASH", pp = 0, maxPp = 40 } } }, +} +local ashWorld, ashGame = fakeWorld({}, fakePlayer(5, 5, "down"), faintedParty) +ashWorld.constants = { specialOrder = order } +ashGame.save.inventory.SACRED_ASH = 1 + +eq(ashWorld:useFieldItem("SACRED_ASH"), "sacredash", + "a fainted party takes the Ash through useFieldItem") +eq(ashGame.save.inventory.SACRED_ASH, nil, "and the last Ash leaves the bag") + +local script = ashWorld.queuedScript +check(script ~= nil, "the effect queues SacredAshScript") +eq(script[1].op, "special", "special HealParty opens the script") +eq(script[1].id, 0, "HealParty resolves through specialOrder, not a bare index") +local fades = 0 +for _, cmd in ipairs(script) do + if cmd.op == "special" and cmd.id == 1 then fades = fades + 1 end +end +eq(fades, 3, "three FadeOutToWhite cycles, same as a Pokecenter warp") +eq(script[#script].op, "end", "the script ends") +eq(script[#script - 1].op, "closetext", "and closes the text box first") + +-- A fully healthy party never sets carry: refused, nothing spent. +local healthyParty = { + { species = "MAGIKARP", nickname = "KARP", hp = 20, maxHp = 20 }, +} +local healthyWorld, healthyGame = + fakeWorld({}, fakePlayer(5, 5, "down"), healthyParty) +healthyWorld.constants = { specialOrder = order } +healthyGame.save.inventory.SACRED_ASH = 1 +eq(healthyWorld:useSacredAsh(), "nowhere", "no fainted mon refuses the Ash") +eq(healthyGame.save.inventory.SACRED_ASH, 1, + "and nothing is taken from the bag") +check(healthyWorld.queuedScript == nil, "no script queued either") + +-- An egg's hp field is the box struct underneath, not a real total that can +-- be "fainted"; CheckAnyFaintedMon's `cp EGG / jr z, .next` skips it. +local eggOnlyParty = { { species = "EGG", isEgg = true, hp = 0, maxHp = 0 } } +local eggWorld, eggGame = fakeWorld({}, fakePlayer(5, 5, "down"), eggOnlyParty) +eggWorld.constants = { specialOrder = order } +eggGame.save.inventory.SACRED_ASH = 1 +eq(eggWorld:useSacredAsh(), "nowhere", "an egg-only party has nothing to revive") + +-- ITEMMENU_NOUSE in battle, the same guard every field item gets. +ashWorld.battleActive = true +eq(ashWorld:useSacredAsh(), "nowhere", "no Sacred Ash from inside a battle") +ashWorld.battleActive = nil +end + +-- Ho-Oh's own end: BATTLETYPE_FORCEITEM (InitEnemyMon's `.WildItem`) is what +-- actually puts a SACRED_ASH on the mon the player catches -- the effect +-- above is reachable only once something stamps the held item on, and until +-- this the port never did. The general 25%/8% wild-item roll stays +-- unmodeled (Mon.new's own note); only the unconditional FORCEITEM path is. +do +local Mon = require("src.battle.gen2.Mon") +local realMonNew = Mon.new +Mon.new = function(_, id, level) return { species = id, level = level } end + +local forceWorld, forceGame = fakeWorld({}, fakePlayer(5, 5, "down")) +forceGame.data = { pokemon = { + HO_OH = { index = 250, items = { "SACRED_ASH", "SACRED_ASH" } }, +} } +local captured +forceWorld.startBattle = function(_, opts) captured = opts return true end + +forceWorld.scriptVars[0x03] = 10 -- VAR_BATTLETYPE, BATTLETYPE_FORCEITEM +forceWorld:startScriptedBattle(nil, { species = 250, level = 70 }, + function() end) +eq(captured and captured.wild and captured.wild.item, "SACRED_ASH", + "BATTLETYPE_FORCEITEM hands Ho-Oh's Item1 to the wild mon unconditionally") + +captured = nil +forceWorld:startScriptedBattle(nil, { species = 250, level = 70 }, + function() end) +check(captured and captured.wild and captured.wild.item == nil, + "without FORCEITEM armed again, no held item is stamped on") + +Mon.new = realMonNew +end + +-- ---- B. the tree --------------------------------------------------------- +local treeCells = { [4 * 100 + 5] = COLL_TREE } + +-- No party mon knows HEADBUTT: TryHeadbuttOW returns nc and the A press is +-- dropped entirely (.noevent), text included. +local bareWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), { + { species = "MAGIKARP", nickname = "KARP", + moves = { { id = "SPLASH", pp = 40 } } }, +}) +check(not bareWorld:interact(), "a tree with no HEADBUTT mon refuses the press") +eq(#bareWorld.log, 0, "and says nothing at all") + +-- With one, AskHeadbuttScript opens; NO closes it without a roll. +local noWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), { + { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "HEADBUTT", pp = 15 } } }, +}) +check(noWorld:interact(), "a tree with a HEADBUTT mon takes the press") +eq(noWorld.log[1], "A POKéMON could be\nin this tree.\fWant to HEADBUTT\nit?", + "AskHeadbuttText is the question") +advanceText(noWorld) +eq(noWorld.log[2], "<yesno>", "and it is a yesorno") +answerYesNo(noWorld, false) +eq(#noWorld.log, 2, "NO ends the script there") +check(noWorld.headbutt == nil, "NO shakes nothing") + +-- YES: nickname, UseHeadbuttText, the 32-frame shake, and only then the roll. +local hitWorld, hitGame = fakeWorld(treeCells, fakePlayer(5, 5, "up"), { + { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "HEADBUTT", pp = 15 } } }, +}) +check(hitWorld:interact(), "the ask opens again") +advanceText(hitWorld) +answerYesNo(hitWorld, true) +eq(hitGame.stringBuffer, "OWL", "GetPartyNickname fills wStringBuffer2") +eq(hitWorld.log[3], "{STRBUF} did a\nHEADBUTT!", "UseHeadbuttText names the mon") +check(hitWorld.headbutt == nil, "the shake waits for the text") +advanceText(hitWorld) +check(hitWorld.headbutt ~= nil, "then ShakeHeadbuttTree starts") +eq(hitWorld.headbutt.timer, 32, "wFrameCounter is 32") +check(hitWorld:busy(), "the shake holds the world") +runFrames(hitWorld, 32) +eq(hitWorld.log[4], nil, "nothing is rolled mid-shake") +runFrames(hitWorld, 1) +eq(hitWorld.log[4], "<battle:HOOTHOOT>", "TreeMonEncounter runs after the shake") +check(hitWorld.headbutt == nil, "and the shake is over") + +-- A map with no treemon set is the cart's other no_battle: the tree still +-- shakes, and "Nope. Nothing…" is what it gives up. +local emptyWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), { + { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "HEADBUTT", pp = 15 } } }, +}) +emptyWorld.encounters = { fishGroups = ENCOUNTERS.fishGroups } +check(emptyWorld:interact(), "the ask opens on the bare map too") +advanceText(emptyWorld) +answerYesNo(emptyWorld, true) +advanceText(emptyWorld) +runFrames(emptyWorld, 33) +eq(emptyWorld.log[4], "Nope. Nothing…", "HeadbuttNothingText closes it out") + +-- A tree the player is not facing is somebody else's press. +local pastWorld = fakeWorld(treeCells, fakePlayer(5, 5, "down"), { + { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "HEADBUTT", pp = 15 } } }, +}) +check(not pastWorld:interact(), "facing away from the tree does nothing") + +-- ---- C. the cave encounter gate ------------------------------------------ +-- CanEncounterWildMon (engine/overworld/events.asm): a CAVE or DUNGEON map +-- skips CheckGrassCollision entirely, so any non-ice walkable tile rolls. +local FieldMoves = require("src.world.gen2.FieldMoves") +local Palettes = require("src.world.gen2.Palettes") + +local COLL_TALL_GRASS, COLL_ICE = 0x18, 0x23 +local COLL_CUT_TREE, COLL_WHIRLPOOL, COLL_WATERFALL = 0x12, 0x24, 0x33 + +local function canEncounter(env, coll, noWild) + return FieldMoves.canEncounterWildMon(env, coll, noWild) +end + +check(canEncounter("ROUTE", COLL_TALL_GRASS), "route + tall grass rolls") +check(not canEncounter("ROUTE", COLL_FLOOR), "route + bare floor does not") +check(canEncounter("CAVE", COLL_FLOOR), "CAVE floor rolls: no grass check") +check(canEncounter("DUNGEON", COLL_FLOOR), "DUNGEON floor rolls too") +check(not canEncounter("CAVE", COLL_ICE), "ice in a cave still refuses") +check(not canEncounter("ROUTE", COLL_ICE), "ice on a route refuses") +check(canEncounter("ROUTE", COLL_WATER), "COLL_WATER is in CheckGrassCollision") +check(not canEncounter("CAVE", COLL_FLOOR, true), "wildoff beats the cave arm") +check(not canEncounter("ROUTE", COLL_TALL_GRASS, true), "wildoff beats grass") +check(not canEncounter("INDOOR", COLL_FLOOR), "a house floor is not a cave") +-- The unused $10 / $1c grass aliases are NOT in the cart's array. +check(not canEncounter("ROUTE", 0x10), "COLL_TALL_GRASS_10 is not in the array") +check(Permissions.isGrass(0x10), "...even though it reads as grass") +eq(FieldMoves.encounterTable(COLL_WATER), "water", "water tile, water list") +eq(FieldMoves.encounterTable(COLL_TALL_GRASS), "grass", "grass tile, grass list") +eq(FieldMoves.encounterTable(COLL_FLOOR), "grass", "cave floor, grass list") + +-- ...and the same thing through World:tryWildEncounter, which is where the +-- bug actually bit: Dark Cave and Union Cave gave nothing at all. +local ALWAYS = { + grass = { + TEST_MAP = { + rates = { MORN = 256, DAY = 256, NITE = 256 }, + slots = { + DAY = {}, MORN = {}, NITE = {}, + }, + }, + }, +} +for _, key in ipairs({ "DAY", "MORN", "NITE" }) do + for i = 1, 7 do + ALWAYS.grass.TEST_MAP.slots[key][i] = + { species = "HOOTHOOT", level = 5 } + end +end + +local PARTY_ONE = { { species = "MAGIKARP", nickname = "KARP", + moves = { { id = "SPLASH", pp = 40 } } } } + +local caveWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil, + { environment = "CAVE" }) +caveWorld.encounters = ALWAYS +check(caveWorld:tryWildEncounter(), "a cave floor step rolls an encounter") +eq(caveWorld.log[1], "<battle:HOOTHOOT>", "and it is the grass list") + +-- CheckWildEncounterCooldown (engine/overworld/events.asm:357-365), the first +-- thing RandomEncounter runs: EnterMap arms it with 5, and only the step that +-- ticks it to zero may roll. +caveWorld.wildCooldown = 5 +for _ = 1, 4 do + check(not caveWorld:tryWildEncounter(), "the five-step cooldown blocks a step") +end +check(caveWorld:tryWildEncounter(), "the fifth step rolls again") + +local routeWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil, + { environment = "ROUTE" }) +routeWorld.encounters = ALWAYS +check(not routeWorld:tryWildEncounter(), "the same step on a route does not") + +local iceWorld = fakeWorld({ [5 * 100 + 5] = COLL_ICE }, + fakePlayer(5, 5, "down"), PARTY_ONE, nil, { environment = "CAVE" }) +iceWorld.encounters = ALWAYS +check(not iceWorld:tryWildEncounter(), "ice inside a cave rolls nothing") + +local offWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil, + { environment = "CAVE" }) +offWorld.encounters = ALWAYS +offWorld.noWildEncounters = true +check(not offWorld:tryWildEncounter(), "wildoff stops the cave roll") + +-- ---- D. badges and party moves ------------------------------------------- +local function saveWith(badges) + return { player = { name = "GOLD", badges = badges } } +end + +check(FieldMoves.hasBadge(saveWith({ HIVE = true }), "HIVE"), + "a badge keyed by name is owned") +check(FieldMoves.hasBadge(saveWith({ [2] = true }), "HIVE"), + "...and so is one keyed by its wJohtoBadges position") +check(not FieldMoves.hasBadge(saveWith({ ZEPHYR = true }), "HIVE"), + "a different badge is not the HIVEBADGE") +check(not FieldMoves.hasBadge({}, "HIVE"), "a save with no badges owns none") +eq(FieldMoves.BADGE.CUT, "HIVE", "CUT is the HIVEBADGE") +eq(FieldMoves.BADGE.FLASH, "ZEPHYR", "FLASH is the ZEPHYRBADGE") +eq(FieldMoves.BADGE.SURF, "FOG", "SURF is the FOGBADGE") +eq(FieldMoves.BADGE.STRENGTH, "PLAIN", "STRENGTH is the PLAINBADGE") +eq(FieldMoves.BADGE.FLY, "STORM", "FLY is the STORMBADGE") +eq(FieldMoves.BADGE.WHIRLPOOL, "GLACIER", "WHIRLPOOL is the GLACIERBADGE") +eq(FieldMoves.BADGE.WATERFALL, "RISING", "WATERFALL is the RISINGBADGE") + +local CUTTER = { { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "CUT", pp = 30 }, { id = "FLASH", pp = 20 } } } } +local mon, slot = FieldMoves.partyMoveUser(CUTTER, "CUT") +check(mon ~= nil, "CheckPartyMove finds the cutter") +eq(slot, 1, "and leaves its slot in wCurPartyMon") +check(FieldMoves.partyMoveUser(CUTTER, "SURF") == nil, "no SURF in the party") +check(FieldMoves.partyMoveUser( + { { egg = true, moves = { { id = "CUT" } } } }, "CUT") == nil, + "an EGG slot is skipped") + +-- ---- E. the seven, from the party submenu -------------------------------- +-- MonMenu_*: the badge is checked with the noisy CheckBadge, so every refusal +-- here has a line, and every success is QUEUED rather than run. +local ALL_BADGES = { + ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true, + STORM = true, MINERAL = true, GLACIER = true, RISING = true, +} +local T = FieldMoves.TEXT + +-- One johto tree block in the block the facing cell (5,4) sits in: bx 2, by 2 +-- of a 10-wide grid, so blocks[2 * 10 + 2 + 1]. +local TREE_BLOCK_INDEX = 2 * MAP_W + 2 + 1 + +local function fieldWorld(cells, party, opts) + opts = opts or {} + opts.badges = opts.badges == nil and ALL_BADGES or opts.badges + local world, game = fakeWorld(cells, fakePlayer(5, 5, "up"), party, nil, opts) + world.encounters = nil + return world, game +end + +-- CUT, refused: no HIVEBADGE. +local noHive = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER, + { badges = { ZEPHYR = true }, + blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +local res = noHive:useFieldMove("CUT", CUTTER[1]) +check(not res.ok, "CUT with no HIVEBADGE is refused") +eq(res.badge, "HIVE", "and CheckBadge names the badge it wanted") +eq(noHive.log[1], T.BADGE_REQUIRED, "BadgeRequiredText is the line") +check(noHive.queuedFieldMove == nil, "nothing is queued") + +-- CUT, refused: badge in hand, nothing in front worth cutting. +local nothingToCut = fieldWorld({}, CUTTER) +check(not nothingToCut:useFieldMove("CUT", CUTTER[1]).ok, "bare floor: no cut") +eq(nothingToCut.log[1], T.CUT_NOTHING, "CutNothingText is the line") + +-- CUT, done. The block swap waits for the text box, and the tree is a whole +-- BLOCK, not a tile. +local cutWorld = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER, + { blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +local cutRes = cutWorld:useFieldMove("CUT", CUTTER[1]) +check(cutRes.ok, "CUT with the badge and a tree in front succeeds") +eq(#cutWorld.log, 0, "QueueScript says nothing while the menu is still up") +check(cutWorld.queuedFieldMove ~= nil, "the script is queued") +runFrames(cutWorld, 1) +eq(cutWorld.log[1], T.USE_CUT, "the queued script runs once the world is back") +eq(cutWorld.game.stringBuffer, "OWL", "GetPartyNickname filled {STRBUF}") +eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "the tree is still standing") +advanceText(cutWorld) +eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x3c, + "CutDownTreeOrGrass swaps block $5b for $3c") +-- LoadMapAttributes refills the buffer from ROM: the tree grows back. +cutWorld:restoreBlocks() +eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "a map load regrows it") + +-- CUT mows grass too, and picks that tileset's own replacement. +eq(select(1, FieldMoves.blockReplacement( + FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x03)), 0x02, "johto grass -> $02") +eq(select(2, FieldMoves.blockReplacement( + FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x03)), 1, "grass takes animation 1") +eq(select(2, FieldMoves.blockReplacement( + FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x5b)), 0, "a tree takes animation 0") +check(FieldMoves.blockReplacement( + FieldMoves.CUT_BLOCKS, "TILESET_CAVE", 0x03) == nil, + "a tileset with no CutTreeBlockPointers row cuts nothing") + +-- FLASH: the ZEPHYRBADGE, and only on a DARKNESS_PALSET map. +local FLASHER = { { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "FLASH", pp = 20 } } } } +local litWorld = fieldWorld({}, FLASHER, { environment = "CAVE" }) +check(not litWorld:useFieldMove("FLASH", FLASHER[1]).ok, + "FLASH in a lit cave is refused") +eq(litWorld.log[1], T.CANT_USE_HERE, "and it is FieldMoveFailed's line") + +local darkWorld = fieldWorld({}, FLASHER, + { environment = "CAVE", palette = "PALETTE_DARK" }) +check(not darkWorld.flashUsed, "a dark map starts unflashed") +eq(Palettes.daytimeFor(darkWorld.map.def, 12, false), "DARK", + "PALETTE_DARK resolves to the DARK palette row") +check(darkWorld:useFieldMove("FLASH", FLASHER[1]).ok, "FLASH in the dark works") +runFrames(darkWorld, 1) +eq(darkWorld.log[1], T.BLINDING_FLASH, "BlindingFlashText") +advanceText(darkWorld) +check(darkWorld.flashUsed, "BlindingFlash sets STATUSFLAGS_FLASH_F") +eq(Palettes.daytimeFor(darkWorld.map.def, 12, true), "NITE", + "and a flashed dark map reads as NITE") +check(not darkWorld:useFieldMove("FLASH", FLASHER[1]).ok, + "a second FLASH in the same cave is refused") + +local noZephyr = fieldWorld({}, FLASHER, + { badges = {}, environment = "CAVE", palette = "PALETTE_DARK" }) +eq(noZephyr:useFieldMove("FLASH", FLASHER[1]).badge, "ZEPHYR", + "FLASH without the ZEPHYRBADGE is refused first") + +-- FLY: outdoors only. +local FLYER = { { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "FLY", pp = 15 } } } } +local indoorFly = fieldWorld({}, FLYER, { environment = "INDOOR" }) +check(not indoorFly:useFieldMove("FLY", FLYER[1]).ok, "no FLY indoors") +eq(indoorFly.log[1], T.CANT_USE_HERE, "and it is the generic refusal") +check(not fieldWorld({}, FLYER, { environment = "CAVE" }) + :useFieldMove("FLY", FLYER[1]).ok, "no FLY in a cave either") +check(fieldWorld({}, FLYER, { environment = "TOWN" }) + :useFieldMove("FLY", FLYER[1]).ok, "FLY works in a town") +check(fieldWorld({}, FLYER, { environment = "ROUTE" }) + :useFieldMove("FLY", FLYER[1]).ok, "...and on a route") +eq(fieldWorld({}, FLYER, { badges = {}, environment = "ROUTE" }) + :useFieldMove("FLY", FLYER[1]).badge, "STORM", "FLY wants the STORMBADGE") + +-- Flypoints: Johto's twelve rows, filtered by what has been visited. +local LANDMARKS = { + landmarks = { + LANDMARK_NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" }, + LANDMARK_VIOLET_CITY = { index = 5, name = "VIOLET CITY" }, + LANDMARK_INDIGO_PLATEAU = { index = 63, name = "INDIGO\nPLATEAU" }, + }, + spawns = { + SPAWN_NEW_BARK = { map = "NEW_BARK_TOWN", x = 4, y = 5 }, + SPAWN_VIOLET = { map = "VIOLET_CITY", x = 9, y = 21 }, + }, +} +local flySave = { player = { badges = ALL_BADGES }, + visitedSpawns = { SPAWN_NEW_BARK = true } } +local points = FieldMoves.flyPoints(flySave, LANDMARKS, "johto") +eq(#points, 1, "only visited flypoints are offered") +eq(points[1].spawn, "SPAWN_NEW_BARK", "and it is the one that was visited") +flySave.visitedSpawns.SPAWN_VIOLET = true +points = FieldMoves.flyPoints(flySave, LANDMARKS, "johto") +eq(#points, 2, "a second visit adds a second row") +eq(points[2].landmark, "LANDMARK_VIOLET_CITY", "in Flypoints order") +-- Kanto is withheld until Indigo Plateau is on the record, or the picker has +-- no legal cursor position at all. +eq(#FieldMoves.flyPoints(flySave, LANDMARKS, "kanto"), 2, + "standing in Kanto with no Indigo flypoint shows the Johto map") +flySave.visitedSpawns.SPAWN_INDIGO = true +eq(#FieldMoves.flyPoints(flySave, LANDMARKS, "kanto"), 1, + "with Indigo visited the Kanto half takes over") +eq(FieldMoves.FLYPOINTS[FieldMoves.KANTO_FLYPOINT].spawn, "SPAWN_PALLET", + "KANTO_FLYPOINT is the Pallet Town row") +eq(#FieldMoves.FLYPOINTS, 24, "Flypoints has 24 rows") + +-- The real path: MAPCALLBACK_NEWMAP's `setflag ENGINE_FLYPOINT_*` lands on +-- save.engineFlags[id] (Vm.lua's setflag), and that is what the menu now +-- reads first -- visitedSpawns is only the fallback for a save that predates +-- this. +local engineSave = { player = { badges = ALL_BADGES }, engineFlags = {} } +eq(#FieldMoves.flyPoints(engineSave, LANDMARKS, "johto"), 0, + "no engine flags set yet: nothing offered") +engineSave.engineFlags[64] = true -- ENGINE_FLYPOINT_NEW_BARK +local enginePoints = FieldMoves.flyPoints(engineSave, LANDMARKS, "johto") +eq(#enginePoints, 1, "the engine flag alone is enough") +eq(enginePoints[1].spawn, "SPAWN_NEW_BARK", "and it is the right spawn") +-- A save with BOTH an engine flag and stale visitedSpawns trusts the engine +-- flag, even when it disagrees -- an explicit false beats a leftover true. +engineSave.visitedSpawns = { SPAWN_VIOLET = true } +engineSave.engineFlags[66] = false -- ENGINE_FLYPOINT_VIOLET, explicitly unset +eq(#FieldMoves.flyPoints(engineSave, LANDMARKS, "johto"), 1, + "an explicit engine-flag false overrides a stale visitedSpawns true") + +-- STRENGTH from the menu: the badge and nothing else. +local LIFTER = { { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "STRENGTH", pp = 15 } } } } +local strWorld = fieldWorld({}, LIFTER) +check(strWorld:useFieldMove("STRENGTH", LIFTER[1]).ok, + "STRENGTH off the menu only wants the PLAINBADGE") +runFrames(strWorld, 1) +check(strWorld.strengthActive, "SetStrengthFlag runs before the text") +eq(strWorld.log[1], T.USE_STRENGTH, "UseStrengthText") +advanceText(strWorld) +check(strWorld.fieldMove ~= nil, "then `pause 3`") +runFrames(strWorld, 4) +eq(strWorld.log[2], T.MOVE_BOULDER, "MoveBoulderText follows the pause") +eq(fieldWorld({}, LIFTER, { badges = {} }) + :useFieldMove("STRENGTH", LIFTER[1]).badge, "PLAIN", "STRENGTH wants PLAIN") + +-- WATERFALL: facing UP at one, and only then. +local CLIMBER = { { species = "MAGIKARP", nickname = "KARP", + moves = { { id = "WATERFALL", pp = 15 } } } } +local fallCells = { [4 * 100 + 5] = COLL_WATERFALL, [3 * 100 + 5] = COLL_WATERFALL } +local flatWorld = fieldWorld({}, CLIMBER) +check(not flatWorld:useFieldMove("WATERFALL", CLIMBER[1]).ok, + "no waterfall above: refused") +local sideWorld = fieldWorld(fallCells, CLIMBER) +sideWorld.player.facing = "left" +check(not sideWorld:useFieldMove("WATERFALL", CLIMBER[1]).ok, + "facing away from the waterfall: refused") +check(FieldMoves.waterfallContinues(COLL_WATERFALL), + "the climb continues while the tile underfoot is a waterfall") +check(FieldMoves.waterfallContinues(0x3b), + "COLL_CURRENT_DOWN counts as a waterfall tile too") +check(not FieldMoves.waterfallContinues(COLL_WATER), "plain water ends it") + +-- WHIRLPOOL: the block table, not just the collision. +local SPINNER = { { species = "MAGIKARP", nickname = "KARP", + moves = { { id = "WHIRLPOOL", pp = 15 } } } } +local poolWorld = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER, + { blocks = { [TREE_BLOCK_INDEX] = 0x07 } }) +check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared") +runFrames(poolWorld, 1) +eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText") +advanceText(poolWorld) +eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36, + "DisappearWhirlpool swaps block $07 for $36") +local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER) +check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, + "a whirlpool collision over the wrong block is refused") + +-- A move the port has no routine for still answers the way the cart does. +check(not fieldWorld({}, CUTTER):useFieldMove("DIG", CUTTER[1]).ok, + "DIG is not ported and lands on FieldMoveFailed") + +-- ---- E2. SWEET_SCENT ------------------------------------------------------- +-- SweetScentFromMenu (engine/events/sweet_scent.asm): no badge, no facing +-- tile test at all -- QueueScript always succeeds, and whether anything is +-- home is answered by the queued script once the menus are gone, same as +-- HEADBUTT's shake. +-- `do`/`end`-scoped: the file is one big chunk and LuaJIT's main function +-- caps at 200 live locals, so a fresh block of world/game fixtures has to +-- free its slots at `end` rather than pile onto the running total. +do +local SCENTER = { { species = "HOOTHOOT", nickname = "OWL", + moves = { { id = "SWEET_SCENT", pp = 20 } } } } +local NOTHING_HERE = "Looks like there's\nnothing here…" + +local bareScent, bareGame = fieldWorld({}, SCENTER) +bareScent.encounters = nil +check(bareScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok, + "SWEET SCENT always queues") +eq(#bareScent.log, 0, "QueueScript says nothing while the menu is still up") +runFrames(bareScent, 1) +eq(bareScent.log[1], "{STRBUF} used\nSWEET SCENT!", + "UseSweetScentText names the mon") +eq(bareGame.stringBuffer, "OWL", "GetPartyNickname fills wStringBuffer2") +advanceText(bareScent) +eq(bareScent.log[2], NOTHING_HERE, + "no encounter table on this map: SweetScentNothingText") + +-- A grass tile with a nonzero rate: ChooseWildEncounter runs unconditionally, +-- with no percentage roll of its own -- the whole point of the move. +local grassScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER) +grassScent.encounters = ALWAYS +check(grassScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok) +runFrames(grassScent, 1) +advanceText(grassScent) +eq(grassScent.log[2], "<battle:HOOTHOOT>", + "SweetScentEncounter forces a battle off the current tile's table") + +-- Bare floor on a ROUTE: CanEncounterWildMon still refuses (no grass, no +-- water) -- SWEET SCENT does not walk through that gate, it only skips the +-- roll behind it. +local floorScent = fieldWorld({}, SCENTER) +floorScent.encounters = ALWAYS +check(floorScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok) +runFrames(floorScent, 1) +advanceText(floorScent) +eq(floorScent.log[2], NOTHING_HERE, "bare ROUTE floor still refuses the roll") + +-- wildoff (STATUSFLAGS_NO_WILD_ENCOUNTERS_F) beats SWEET SCENT the same way +-- it beats a step. +local offScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER) +offScent.encounters = ALWAYS +offScent.noWildEncounters = true +check(offScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok) +runFrames(offScent, 1) +advanceText(offScent) +eq(offScent.log[2], NOTHING_HERE, "wildoff refuses too") + +-- ENGINE_BUG_CONTEST_TIMER: farsjump's straight past GetMapEncounterRate to +-- the park's own table, same branch RandomEncounter takes on a normal step. +-- A one-row ContestMons list so the pick is deterministic without adding +-- CATERPIE and friends to the shared DATA fixture every other case here uses. +local contestScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER) +contestScent.encounters = ALWAYS +contestScent.game.save.bugContest = { active = true } +contestScent.game.data = { + items = DATA.items, moves = DATA.moves, pokemon = DATA.pokemon, + encounters = { bugContest = { + { chance = 1000, species = "HOOTHOOT", min = 5, max = 5 }, + } }, +} +check(contestScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok) +runFrames(contestScent, 1) +advanceText(contestScent) +check(contestScent.log[2]:match("^<battle:"), + "the contest's own table runs, not the map's grass list") +end + +-- ---- F. the same seven, from an A press ---------------------------------- +-- Try*OW: the MOVE is checked first and the badge silently, so the refusals +-- are the tile's own lines, not "a new BADGE is required". +local owNoCut = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, + { { species = "MAGIKARP", moves = { { id = "SPLASH" } } } }, + { blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +check(owNoCut:interact(), "a cut tree takes the A press even with no cutter") +eq(owNoCut.log[1], T.CAN_CUT, "CanCutText, not the badge line") + +local owNoBadge = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER, + { badges = {}, blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +check(owNoBadge:interact(), "and with the mon but no badge it still answers") +eq(owNoBadge.log[1], T.CAN_CUT, "CheckEngineFlag is the silent check") + +local owCut = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER, + { blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +check(owCut:interact(), "the A press opens AskCutScript") +eq(owCut.log[1], T.ASK_CUT, "AskCutText") +advanceText(owCut) +eq(owCut.log[2], "<yesno>", "and it is a yesorno") +answerYesNo(owCut, false) +eq(#owCut.log, 2, "NO closes it and cuts nothing") +eq(owCut.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "the tree is untouched") + +local owCut2 = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER, + { blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +owCut2:interact() +advanceText(owCut2) +answerYesNo(owCut2, true) +eq(owCut2.log[3], T.USE_CUT, "YES runs Script_Cut on the spot (CallScript)") +advanceText(owCut2) +eq(owCut2.map.def.blocks[TREE_BLOCK_INDEX], 0x3c, "and the tree comes down") + +-- WHIRLPOOL, from the water. +local owPool = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER, + { blocks = { [TREE_BLOCK_INDEX] = 0x07 } }) +check(owPool:interact(), "a whirlpool takes the press") +eq(owPool.log[1], T.ASK_WHIRLPOOL, "AskWhirlpoolText") +local owPoolNo = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, + { { species = "MAGIKARP", moves = { { id = "SPLASH" } } } }, + { blocks = { [TREE_BLOCK_INDEX] = 0x07 } }) +check(owPoolNo:interact(), "and refuses with a line rather than silence") +eq(owPoolNo.log[1], T.MAY_PASS_WHIRLPOOL, "MayPassWhirlpoolText") + +-- WATERFALL, from the bottom. +local owFall = fieldWorld(fallCells, CLIMBER) +check(owFall:interact(), "a waterfall takes the press") +eq(owFall.log[1], T.ASK_WATERFALL, "AskWaterfallText") +local owFallNo = fieldWorld(fallCells, + { { species = "MAGIKARP", moves = { { id = "SPLASH" } } } }) +check(owFallNo:interact(), "with no climber it still answers") +eq(owFallNo.log[1], T.HUGE_WATERFALL, "HugeWaterfallText") + +-- The climb itself: Script_UsedWaterfall loops one turn_waterfall UP step at a +-- time and stops only once the tile UNDERFOOT is no longer a waterfall tile. +local PlayerModule = require("src.world.gen2.Player") +local climbCells = { + [4 * 100 + 5] = COLL_WATERFALL, + [3 * 100 + 5] = COLL_WATERFALL, + [2 * 100 + 5] = COLL_WATER, +} +local climb = fakeWorld(climbCells, PlayerModule.new(5, 5, "up"), CLIMBER, nil, + { badges = ALL_BADGES }) +climb.encounters = nil +check(climb:interact(), "the waterfall takes the press") +advanceText(climb) +answerYesNo(climb, true) +eq(climb.log[3], T.USE_WATERFALL, "UseWaterfallText") +advanceText(climb) +check(climb.fieldMove ~= nil, "and then the climb owns the world") +check(climb:busy(), "nothing else runs during it") +runFrames(climb, 60) +eq(climb.player.cellY, 2, "the climb stops on the first non-waterfall tile") +check(climb.fieldMove == nil, "and hands the world back") +check(not climb:busy(), "the world is free again") + +-- ---- G. SURF, the player state ------------------------------------------- +local RealPlayer = require("src.world.gen2.Player") +local SURFER = { { species = "MAGIKARP", nickname = "KARP", + moves = { { id = "SURF", pp = 15 } } } } + +check(not FieldMoves.isSurfing(FieldMoves.PLAYER_NORMAL), "normal is not surf") +check(FieldMoves.isSurfing(FieldMoves.PLAYER_SURF), "PLAYER_SURF is") +check(FieldMoves.isSurfing(FieldMoves.PLAYER_SURF_PIKA), "so is the Pika one") +eq(FieldMoves.surfType({ species = "PIKACHU" }), FieldMoves.PLAYER_SURF_PIKA, + "GetSurfType gives PIKACHU its own state") +eq(FieldMoves.surfType({ species = "LAPRAS" }), FieldMoves.PLAYER_SURF, + "and everything else the ordinary one") +eq(FieldMoves.STATE_SPRITE[FieldMoves.PLAYER_SURF], "SPRITE_SURF", + "ChrisStateSprites maps PLAYER_SURF to SPRITE_SURF") + +local seaCells = { + [4 * 100 + 5] = COLL_WATER, + [3 * 100 + 5] = COLL_WATER, +} + +-- No SURF mon: TrySurfOW's every failure is `.quit`, so the press is silent. +local dryShore = fakeWorld(seaCells, fakePlayer(5, 5, "up"), + { { species = "HOOTHOOT", moves = { { id = "TACKLE" } } } }, nil, + { badges = ALL_BADGES }) +dryShore.encounters = nil +check(not dryShore:interact(), "water with no SURF mon drops the press") +eq(#dryShore.log, 0, "and says nothing at all") + +local noFog = fakeWorld(seaCells, fakePlayer(5, 5, "up"), SURFER, nil, + { badges = { ZEPHYR = true } }) +noFog.encounters = nil +check(not noFog:interact(), "no FOGBADGE is just as silent") +eq(#noFog.log, 0, "CheckEngineFlag never prints") + +local sea = fakeWorld(seaCells, RealPlayer.new(5, 5, "up"), SURFER, nil, + { badges = ALL_BADGES }) +sea.encounters = nil +check(sea:interact(), "with the badge and the mon, AskSurfScript opens") +eq(sea.log[1], T.ASK_SURF, "AskSurfText") +advanceText(sea) +answerYesNo(sea, true) +eq(sea.log[3], T.USED_SURF, "UsedSurfText") +eq(sea.playerState, FieldMoves.PLAYER_NORMAL, "still on foot behind the text") +advanceText(sea) +eq(sea.playerState, FieldMoves.PLAYER_SURF, "then wPlayerState becomes SURF") +check(sea.player.moving, "SurfStartStep walks into the water") +check(sea:busy(), "and the world is frozen for it") +runFrames(sea, 20) +eq(sea.player.cellY, 4, "the step lands on the water tile") +check(not sea:busy(), "and the world comes back") + +-- NO at the shore leaves the player on foot. +local declined = fakeWorld(seaCells, RealPlayer.new(5, 5, "up"), SURFER, nil, + { badges = ALL_BADGES }) +declined.encounters = nil +check(declined:interact(), "the ask opens again") +advanceText(declined) +answerYesNo(declined, false) +eq(declined.playerState, FieldMoves.PLAYER_NORMAL, "NO stays on the bank") +eq(declined.player.cellY, 5, "and takes no step") + +-- Surfing, a step across open water is an ordinary step... +eq(sea:movePlayer("up"), "moved", "a surfing step onto water is allowed") +for _ = 1, 16 do sea.player:update() end +eq(sea.player.cellY, 3, "and it lands") +eq(sea.playerState, FieldMoves.PLAYER_SURF, "still surfing") + +-- ...and a step onto LAND is .ExitWater, which puts the state back BEFORE the +-- step rather than after it. +sea.player.facing = "down" +sea.player.turnArmed = false +eq(sea:movePlayer("down"), "moved", "and back down over the water") +for _ = 1, 16 do sea.player:update() end +eq(sea.player.cellY, 4, "one cell short of the beach") +eq(sea.playerState, FieldMoves.PLAYER_SURF, "still afloat over water") +eq(sea:movePlayer("down"), "moved", "a surfing step onto land is allowed too") +eq(sea.playerState, FieldMoves.PLAYER_NORMAL, + "GetOutOfWater runs before .DoStep") + +-- A wall is neither land nor water: .CheckSurfPerms bumps. +local walled = fakeWorld({ [4 * 100 + 5] = COLL_WATER, + [3 * 100 + 5] = 0x07 }, RealPlayer.new(5, 4, "up"), SURFER, nil, + { badges = ALL_BADGES }) +walled.encounters = nil +walled.playerState = FieldMoves.PLAYER_SURF +eq(walled:movePlayer("up"), "blocked", "a wall stops a surfing step") +eq(Permissions.surfable(COLL_WATER), "water", "water keeps you surfing") +eq(Permissions.surfable(COLL_FLOOR), "land", "land is .ExitWater") +check(Permissions.surfable(0x07) == nil, "a wall is neither") + +-- Already surfing, the menu says so rather than trying again. +local afloat = fieldWorld(seaCells, SURFER) +afloat.playerState = FieldMoves.PLAYER_SURF +eq(afloat:useFieldMove("SURF", SURFER[1]).text, T.ALREADY_SURFING, + "AlreadySurfingText comes before the tile check") +afloat.playerState = FieldMoves.PLAYER_NORMAL +local ashore = fieldWorld({}, SURFER) +eq(ashore:useFieldMove("SURF", SURFER[1]).text, T.CANT_SURF, + "and dry land is CantSurfText") + +-- Encounters roll while surfing: the tile underfoot is water, so the gate +-- passes on CheckGrassCollision's COLL_WATER row and the WATER list is rolled. +local surfEnc = fakeWorld({ [5 * 100 + 5] = COLL_WATER }, + fakePlayer(5, 5, "down"), PARTY_ONE, nil, { environment = "ROUTE" }) +surfEnc.playerState = FieldMoves.PLAYER_SURF +surfEnc.encounters = { + water = { TEST_MAP = { rate = 256, + slots = { { species = "MAGIKARP", level = 20 }, + { species = "MAGIKARP", level = 20 }, + { species = "MAGIKARP", level = 20 } } } }, +} +check(surfEnc:tryWildEncounter(), "a surfing step rolls") +eq(surfEnc.log[1], "<battle:MAGIKARP>", "off the water list") + +-- ...and fishing does not: .TryFish reads wPlayerState first. +local rodAfloat = fakeWorld({ [4 * 100 + 5] = COLL_WATER }, + fakePlayer(5, 5, "up"), SURFER) +rodAfloat.playerState = FieldMoves.PLAYER_SURF +eq(rodAfloat:useRod("OLD_ROD"), "nowhere", "no fishing from the water") + +-- ---- H. STRENGTH and the boulder ----------------------------------------- +local function fakeBoulder(x, y) + return { + def = { index = 1, movement = 0x19 }, -- SPRITEMOVEDATA_STRENGTH_BOULDER + cellX = x, cellY = y, px = x * 16, py = y * 16, + moving = false, facing = "down", + scriptStep = function(self, dir) + local d = Map.DELTA[dir] + self.facing = dir + self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2] + self.moving = true + return true + end, + update = function() end, + } +end + +check(World.isStrengthBoulder({ def = { movement = 0x19 } }), + "SPRITEMOVEDATA_STRENGTH_BOULDER is a boulder") +check(not World.isStrengthBoulder({ def = { movement = 0x18 } }), + "SPRITEMOVEDATA_SMASHABLE_ROCK is not") + +local function boulderWorld(party, badges) + local world = fieldWorld({}, party, { badges = badges }) + local rock = fakeBoulder(5, 4) + world.npcs = { rock } + world.entities = { world.player, rock } + return world, rock +end + +-- Talking to a boulder with no STRENGTH mon: BouldersMayMoveText. +local rockNoMon, _ = boulderWorld( + { { species = "MAGIKARP", moves = { { id = "SPLASH" } } } }) +check(rockNoMon:interact(), "a boulder answers the A press") +eq(rockNoMon.log[1], T.BOULDERS_MAY_MOVE, "BouldersMayMoveText") + +-- With the mon and the badge, AskStrengthScript. +local rockWorld, rock = boulderWorld(LIFTER) +check(rockWorld:interact(), "and offers STRENGTH") +eq(rockWorld.log[1], T.ASK_STRENGTH, "AskStrengthText") +advanceText(rockWorld) +answerYesNo(rockWorld, true) +check(rockWorld.strengthActive, "YES sets BIKEFLAGS_STRENGTH_ACTIVE") +eq(rockWorld.log[3], T.USE_STRENGTH, "UseStrengthText") +advanceText(rockWorld) +runFrames(rockWorld, 4) +eq(rockWorld.log[4], T.MOVE_BOULDER, "MoveBoulderText") +advanceText(rockWorld) + +-- Already on: the third of TryStrengthOW's three answers. +check(rockWorld:interact(), "a second press still answers") +eq(rockWorld.log[5], T.BOULDERS_MOVE, "BouldersMoveText once STRENGTH is on") + +-- The push itself. The boulder moves; the player bumps. +check(not rock.moving, "the boulder is standing") +check(rockWorld:tryPushBoulder("up", 5, 4), "walking into it pushes it") +eq(rock.targetY, 3, "one cell in the walking direction") +eq(rockWorld.player.cellY, 5, "and the player does not move") +check(not rockWorld:tryPushBoulder("up", 5, 4), "a moving boulder is not pushed") + +-- No STRENGTH, no push. +local coldWorld, coldRock = boulderWorld(LIFTER) +check(not coldWorld:tryPushBoulder("up", 5, 4), + "without BIKEFLAGS_STRENGTH_ACTIVE nothing moves") +check(not coldRock.moving, "the boulder stays put") + +-- A boulder against a wall does not move (CanObjectMoveInDirection). +local pinned = fieldWorld({ [3 * 100 + 5] = 0x07 }, LIFTER) +local pinnedRock = fakeBoulder(5, 4) +pinned.npcs = { pinnedRock } +pinned.entities = { pinned.player, pinnedRock } +pinned.strengthActive = true +check(not pinned:tryPushBoulder("up", 5, 4), "a boulder against a wall holds") + +-- ---- I. the party submenu's field-move row ------------------------------- +-- MonMenu_Cut's $2 / $3 return, through the screen a player actually uses: +-- the row is picked, the world queues the script, and the party list plus the +-- START menu behind it are torn down so the overworld can run it. +local PartyMenu = require("src.ui.gen2.PartyMenu") + +local menuWorld, menuGame = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, + CUTTER, { blocks = { [TREE_BLOCK_INDEX] = 0x5b } }) +menuGame.input = stubInput() +menuGame.stack = stubStack() +local list = PartyMenu.new(menuGame, { party = CUTTER, submenu = true }) +local rows = list:submenuItems(CUTTER[1]) +eq(rows[1].id, "CUT", "a mon that knows CUT gets a CUT row first") +check(rows[1].fieldMove, "and it is flagged as a field move") +eq(rows[2].id, "FLASH", "FIELD_MOVES order, not the mon's move order") +menuGame.input:press("a") +list:update(0) +check(list.submenu ~= nil, "A on the mon opens PokemonActionSubmenu") +menuGame.input:press("a") +list:update(0) +check(menuWorld.queuedFieldMove ~= nil, "CUT queues its script") +eq(menuGame.stack.cleared, 1, "and the $2 return clears the menus off") + +-- A refusal is the $3 return: the line prints and the list stays. +local menuFail, failGame = fieldWorld({}, CUTTER) +failGame.input = stubInput() +failGame.stack = stubStack() +local failList = PartyMenu.new(failGame, { party = CUTTER, submenu = true }) +failGame.input:press("a") +failList:update(0) +failGame.input:press("a") +failList:update(0) +check(menuFail.queuedFieldMove == nil, "nothing to cut queues nothing") +eq(menuFail.log[1], T.CUT_NOTHING, "the refusal prints over the list") +eq(failGame.stack.cleared, 0, "and the party list stays open") + +-- ---- J. the dark cave ----------------------------------------------------- +-- There is no vision mask in Gen 2: the whole map resolves to near-black +-- through the ordinary bake, and FlickeringCaveEntrancePalette blinks +-- PAL_BG_YELLOW's color 0 between its own two colours on a four-frame cycle. +eq(Palettes.PAL_BG_YELLOW, 5, "PAL_BG_YELLOW is the fifth 1-based slot") +eq(Palettes.caveFlickerSource(0), 1, "frame 0 leaves color 0 alone") +eq(Palettes.caveFlickerSource(1), 1, "...and so does frame 1") +eq(Palettes.caveFlickerSource(2), 2, "frame 2 copies color 1 into it") +eq(Palettes.caveFlickerSource(3), 2, "...and so does frame 3") +eq(Palettes.FLICKER_PERIOD, 4, "the cycle is four frames long") + +local DARK_SET = {} +for slot = 1, 8 do + DARK_SET[slot] = { { 8, 8, 16 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } } +end +DARK_SET[Palettes.PAL_BG_YELLOW] = + { { 247, 247, 90 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } } +local lit = Palettes.withCaveFlicker(DARK_SET, 1) +eq(lit[Palettes.PAL_BG_YELLOW][1][1], 247, "phase 1 keeps the entrance lit") +local unlit = Palettes.withCaveFlicker(DARK_SET, 2) +eq(unlit[Palettes.PAL_BG_YELLOW][1][1], 0, "phase 2 blinks it out") +eq(unlit[1][1][1], 8, "and no other palette moves") +eq(DARK_SET[Palettes.PAL_BG_YELLOW][1][1], 247, "the source set is untouched") +check(Palettes.isDarkness({ palette = "PALETTE_DARK" }, 12, false), + "an unflashed PALETTE_DARK map is DARKNESS_PALSET") +check(not Palettes.isDarkness({ palette = "PALETTE_DARK" }, 12, true), + "...and a flashed one is not") +check(not Palettes.isDarkness({ palette = "PALETTE_NITE" }, 12, false), + "a merely dim cave is not DARKNESS_PALSET") + +-- ResetFlashIfOutOfCave: the flag survives cave-to-cave and dies outdoors. +eq(Permissions.isCutTree(0x12), true, "COLL_CUT_TREE") +eq(Permissions.isCutTree(0x1a), true, "COLL_CUT_TREE_1A alias") +check(not Permissions.isCutTree(0x15), "a headbutt tree is not a cut tree") +check(Permissions.isWhirlpool(0x24) and Permissions.isWhirlpool(0x2c), + "both whirlpool collisions") +check(Permissions.isWaterfall(0x33) and Permissions.isWaterfall(0x3b), + "COLL_WATERFALL and COLL_CURRENT_DOWN") +check(Permissions.isIce(0x23) and Permissions.isIce(0x2b), "both ice tiles") +check(Permissions.isCuttable(0x18) and Permissions.isCuttable(0x12), + "CUT swings at grass and at trees") +check(not Permissions.isCuttable(0x15), "but not at a headbutt tree") + +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 mapsPath = cache .. "/data/generated/maps.lua" +local mapsFile = io.open(mapsPath, "r") +if not mapsFile then + check(true, "gold cache absent : permission checks only (SKIP map facts)") + S.finish() + return +end +mapsFile:close() + +local function loadLua(rel) + local path = cache .. "/" .. rel + return assert(loadfile(path))() +end + +local maps = loadLua("data/generated/maps.lua") +local tilesets = loadLua("data/generated/tilesets.lua") +local town = Map.new(maps.NEW_BARK_TOWN, tilesets[maps.NEW_BARK_TOWN.tileset]) + +eq(town.widthCells, 20, "New Bark width cells") +eq(town.heightCells, 18, "New Bark height cells") +check(town:isWalkable(13, 6), "south of player house walkable") +check(not town:isWalkable(0, 0), "corner wall blocked") +eq(town:cellCollision(6, 3), 0x71, "Elm door COLL_DOOR") +eq(town:warpAt(6, 3).def.destMap, "ELMS_LAB", "Elm door → ELMS_LAB") +eq(town:warpAt(13, 5).def.destMap, "PLAYERS_HOUSE_1F", "house door") + +-- The four cell predicates a mod binds to, shared with src/world/Map.lua so one +-- placement routine reads either generation's map (src/world/gen2/Map.lua). +-- Without them a mod guarding on `map.isWalkableCell and ...` silently decides +-- every cell is walkable, dry and grassless. +do + local Permissions = require("src.world.gen2.Permissions") + local walkAgrees, waterAgrees, grassAgrees = true, true, true + local sawGrass = false + for cy = 0, town.heightCells - 1 do + for cx = 0, town.widthCells - 1 do + local coll = town:cellCollision(cx, cy) + if town:isWalkableCell(cx, cy) ~= town:isWalkable(cx, cy) then + walkAgrees = false + end + if town:isWaterCell(cx, cy) ~= Permissions.isWater(coll) then + waterAgrees = false + end + if town:isGrassCell(cx, cy) ~= Permissions.isGrass(coll) then + grassAgrees = false + end + end + end + check(walkAgrees, "isWalkableCell tracks isWalkable") + check(waterAgrees, "isWaterCell tracks the WATER permission") + check(grassAgrees, "isGrassCell tracks the grass collisions") + -- and it actually answers true somewhere: New Bark Town is paved, so the + -- route next door is where a mod's placement code would look for grass + local route29 = Map.new(maps.ROUTE_29, tilesets[maps.ROUTE_29.tileset]) + for cy = 0, route29.heightCells - 1 do + for cx = 0, route29.widthCells - 1 do + if route29:isGrassCell(cx, cy) then sawGrass = true end + end + end + check(sawGrass, "ROUTE_29 has grass cells") + eq(town:cellTile(6, 3), town:cellCollision(6, 3), "cellTile is the COLL_ byte") + check(not town:isGrassCell(-1, -1), "off-map cells are never grass") + check(not town:isWaterCell(-1, -1), "off-map cells are never water") +end + +-- src/world/gen2/CmdQueue.lua's hand-ported stone tables name warps, object ids +-- and event flags by NUMBER, because the callback they live behind is not +-- extracted. Pin the four numbers the cache actually emits, so a re-import that +-- shifts any of them fails here rather than by a boulder quietly not falling. +do + local CmdQueue = require("src.world.gen2.CmdQueue") + local ice = maps.ICE_PATH_B1F + local iceMap = Map.new(ice, tilesets[ice.tileset]) + for i, row in ipairs(CmdQueue.STONE_TABLES.ICE_PATH_B1F) do + local obj = ice.objects[row.object - 1] + eq(obj and obj.movement, CmdQueue.BOULDER_MOVEDATA, + ("Ice Path row %d names a STRENGTH_BOULDER object"):format(i)) + eq(obj.eventFlag, 1800 + i, + ("and it is EVENT_BOULDER_IN_ICE_PATH_%d"):format(i)) + local warp = ice.warps[row.warp] + check(warp ~= nil, ("row %d names a real warp"):format(i)) + eq(iceMap:cellCollision(warp.x, warp.y), 0x60, + ("and the tile under it is COLL_PIT (%d,%d)"):format(warp.x, warp.y)) + eq(row.script[2].event, 1804 + i, + ("it clears EVENT_BOULDER_IN_ICE_PATH_%dA"):format(i)) + end + local below = maps.ICE_PATH_B2F_MAHOGANY_SIDE + for i = 1, 4 do + eq(below.objects[i].eventFlag, 1804 + i, + ("which is the boulder one floor down (%d)"):format(i)) + end + local gym = maps.BLACKTHORN_GYM_2F + for i, row in ipairs(CmdQueue.STONE_TABLES.BLACKTHORN_GYM_2F) do + local obj = gym.objects[row.object - 1] + eq(obj and obj.movement, CmdQueue.BOULDER_MOVEDATA, + ("Blackthorn row %d names a STRENGTH_BOULDER object"):format(i)) + check(gym.warps[row.warp] ~= nil, + ("Blackthorn row %d names a real warp"):format(i)) + end +end + +-- Map callbacks. maps.lua now carries the map script header's second half +-- (`def_callbacks` / `callback TYPE, script`), which is what makes the two +-- stone tables reachable without hand-porting them. The counts are the +-- decomp's: `grep -h "^\tcallback " ../pokegold/maps/*.asm | sort | uniq -c`. +do + local seen, total = {}, 0 + for id, def in pairs(maps) do + if type(def) == "table" and def.callbacks then + for _, cb in ipairs(def.callbacks) do + seen[cb.callback] = (seen[cb.callback] or 0) + 1 + total = total + 1 + check(type(cb.scriptKey) == "string", + ("%s callback names a script key"):format(id)) + end + end + end + if total == 0 then + check(true, "cache predates map callbacks (SKIP)") + else + eq(total, 84, "every map callback in the game") + eq(seen.MAPCALLBACK_TILES, 19, "MAPCALLBACK_TILES") + eq(seen.MAPCALLBACK_OBJECTS, 24, "MAPCALLBACK_OBJECTS") + eq(seen.MAPCALLBACK_CMDQUEUE, 2, "MAPCALLBACK_CMDQUEUE") + eq(seen.MAPCALLBACK_NEWMAP, 39, "MAPCALLBACK_NEWMAP") + -- MAPCALLBACK_SPRITES is in the enum and no map uses it; a count here + -- would mean the type byte is being read one row off. + eq(seen.MAPCALLBACK_SPRITES, nil, "no map uses MAPCALLBACK_SPRITES") + end +end + +-- The extracted stone tables against the hand-ported ones. These are the +-- SAME data by two routes -- CmdQueue.STONE_TABLES was transcribed from +-- pokegold, the rows below were followed out of the ROM -- so any drift means +-- one of them is wrong. +do + local CmdQueue = require("src.world.gen2.CmdQueue") + local scripts = loadLua("data/generated/scripts.lua") + local function extractedRows(mapId) + for _, cb in ipairs(maps[mapId].callbacks or {}) do + if cb.callback == "MAPCALLBACK_CMDQUEUE" then + for _, cmd in ipairs(scripts[cb.scriptKey] or {}) do + if cmd.op == "writecmdqueue" then return cmd.queue end + end + end + end + end + local ice = extractedRows("ICE_PATH_B1F") + if not ice then + check(true, "cache predates writecmdqueue following (SKIP)") + else + eq(ice.queue, "CMDQUEUE_STONETABLE", "Ice Path writes a stone table") + for mapId, hand in pairs(CmdQueue.STONE_TABLES) do + local got = CmdQueue.fromExtracted(extractedRows(mapId), mapId) + check(got ~= nil, ("%s converts to a queue entry"):format(mapId)) + eq(#got.rows, #hand, ("%s row count"):format(mapId)) + for i, row in ipairs(hand) do + eq(got.rows[i].warp, row.warp, ("%s row %d warp"):format(mapId, i)) + eq(got.rows[i].object, row.object, ("%s row %d object"):format(mapId, i)) + -- The hand-ported row inlines its command list; the extracted one + -- names a scripts.lua key, and the script behind it must exist or the + -- boulder disappears into a missing key. + check(scripts[got.rows[i].script] ~= nil, + ("%s row %d script is in scripts.lua"):format(mapId, i)) + end + end + -- The boulder script the cart runs, which the hand-port had to write out + -- as `rawtext` because nothing pointed at its string. + local first = CmdQueue.fromExtracted(ice, "ICE_PATH_B1F").rows[1] + local body = scripts[first.script] + eq(body[1].op, "disappear", "the boulder disappears first") + eq(body[1].object, 2, "and it is ICEPATHB1F_BOULDER1") + eq(body[2].op, "clearevent", "then clears its twin one floor down") + eq(body[2].event, 1805, "EVENT_BOULDER_IN_ICE_PATH_1A") + end +end + +local west = town:connection("west") +check(west and west.mapId == "ROUTE_29", "west connection Route 29") +local east = town:connection("east") +check(east and east.mapId == "ROUTE_27", "east connection Route 27") + +local r29 = maps.ROUTE_29 +local lx, ly = Map.connectionLanding(r29, west, "left", 0, 8) +eq(lx, r29.width * 2 - 1, "west edge → Route 29 x") +eq(ly, 8, "west edge → Route 29 y") + +-- Neighbor strip placement (RBY-style): Route 29 sits flush on New Bark's west. +local nbs = World.computeNeighbors(maps, "NEW_BARK_TOWN", 2) +local found29 +for _, n in ipairs(nbs) do + if n.id == "ROUTE_29" then found29 = n break end +end +check(found29 ~= nil, "computeNeighbors includes ROUTE_29") +if found29 then + eq(found29.ox, -r29.width * 32, "Route 29 west strip ox") + eq(found29.oy, 0, "Route 29 west strip oy") +end +local found27 +for _, n in ipairs(nbs) do + if n.id == "ROUTE_27" then found27 = n break end +end +check(found27 ~= nil, "computeNeighbors includes ROUTE_27") + +local lab = Map.new(maps.ELMS_LAB, tilesets[maps.ELMS_LAB.tileset]) +eq(maps.ELMS_LAB.tileset, "TILESET_LAB", "lab uses indoor tileset (no roofs)") +eq(lab:cellCollision(4, 11), 0x70, "lab exit carpet") +eq(Permissions.carpetDirection(lab:cellCollision(4, 11)), "down", + "lab carpet faces down") +eq(lab:warpAt(4, 11).def.destMap, "NEW_BARK_TOWN", "lab carpet → town") +-- Officer on the starter table is hidden by InitializeEventsScript at boot. +local labObjs = maps.ELMS_LAB.objects +eq(#labObjs, 6, "Elms Lab has 6 object_events") +eq(labObjs[6].sprite, "SPRITE_OFFICER", "lab object 6 is the cop") +eq(labObjs[6].x, 5, "cop x on starter table") +eq(labObjs[6].y, 3, "cop y on starter table") +local initPath = cache .. "/data/generated/initial_events.lua" +local initFile = io.open(initPath, "r") +if initFile then + initFile:close() + local initial = loadLua("data/generated/initial_events.lua") + local copFlag = labObjs[6].eventFlag + local hidden = false + for _, id in ipairs(initial.flags or {}) do + if id == copFlag then hidden = true break end + end + check(hidden, "InitializeEventsScript hides lab cop (flag " + .. tostring(copFlag) .. ")") +else + check(true, "initial_events.lua absent : re-import Gold") +end + +-- Overworld sprites (Chris + New Bark NPCs) when the cache is post-sprite extract. +local spritesPath = cache .. "/data/generated/sprites.lua" +local spritesFile = io.open(spritesPath, "r") +if spritesFile then + spritesFile:close() + local sprites = loadLua("data/generated/sprites.lua") + local chris = sprites.SPRITE_CHRIS + check(chris ~= nil, "SPRITE_CHRIS extracted") + if chris then + eq(chris.frames, 6, "Chris walking sheet has 6 frames") + check(chris.walker, "Chris is a walker") + eq(chris.image, "assets/generated/sprites/chris.png", "Chris image path") + end + check(sprites.SPRITE_TEACHER ~= nil, "SPRITE_TEACHER extracted") + check(sprites.SPRITE_RIVAL ~= nil, "SPRITE_RIVAL extracted") + local objs = maps.NEW_BARK_TOWN.objects + eq(#objs, 3, "New Bark has 3 object_events") + eq(objs[1].sprite, "SPRITE_TEACHER", "New Bark teacher sprite") + eq(objs[3].sprite, "SPRITE_RIVAL", "New Bark rival sprite") + + -- Anim paths from SPRITEMOVEDATA_* (no SpriteRenderer : headless luajit). + local kind, dirs = NPC.patternFor(NPC.MOVE.SPINRANDOM_SLOW) + eq(kind, "spin", "teacher movement is spin") + kind, dirs = NPC.patternFor(NPC.MOVE.WALK_UP_DOWN) + eq(kind, "walk", "fisher movement is walk") + check(dirs and dirs[1] == "up" and dirs[2] == "down", "fisher walks up/down") + kind = NPC.patternFor(NPC.MOVE.STANDING_RIGHT) + eq(kind, "stand", "rival stands") + eq(select(1, NPC.patternFor(objs[1].movement)), "spin", "New Bark teacher spins") + eq(select(1, NPC.patternFor(objs[2].movement)), "walk", "New Bark fisher walks") + eq(select(1, NPC.patternFor(objs[3].movement)), "stand", "New Bark rival stands") + + -- Fisher radius (0,1): home±1 in Y stays in radius, X step does not. + local fisher = setmetatable({ + homeX = objs[2].x, homeY = objs[2].y, + radiusX = (objs[2].radius and objs[2].radius.x) or 0, + radiusY = (objs[2].radius and objs[2].radius.y) or 0, + }, NPC) + eq(fisher.radiusY, 1, "fisher radius Y") + check(fisher:inRadius(fisher.homeX, fisher.homeY + 1), "fisher +Y in radius") + check(not fisher:inRadius(fisher.homeX + 1, fisher.homeY), "fisher +X out of radius") +else + check(true, "sprites.lua absent : skip OW sprite facts (re-import Gold)") +end + +-- ------------------------------------------------------- the script VM hooks +-- +-- The 81 opcodes the interpreter grew are inert without these: an `appear` that +-- reaches no World never spawns the object, a `changeblock` never opens the +-- door, and a `warpcheck` never drops the player through the hole. Every hook +-- the VM guards with `if self.xFn then` is asserted here against real World +-- behaviour rather than against the closure that forwards to it. +-- +-- The world under test is built with World.new and then poked directly: the +-- constructor is love-free, and each method below is the WORLD half of one +-- transcribed command, so a stub map and a stub save are the whole rig. + +local function hookWorld(opts) + opts = opts or {} + local game = { + data = { + pokemon = { + MAGIKARP = { name = "MAGIKARP", index = 129 }, + SHUCKLE = { name = "SHUCKLE", index = 213, eggSteps = 20 }, + CHIKORITA = { name = "CHIKORITA", index = 152, eggSteps = 20, + baseStats = { hp = 45, attack = 49, defense = 65, speed = 45, + specialAttack = 49, specialDefense = 65 }, + levelMoves = { { level = 1, move = "TACKLE" } } }, + }, + items = { + POTION = { id = "POTION", name = "POTION", index = 18 }, + COIN_CASE = { id = "COIN_CASE", name = "COIN CASE", index = 0x47 }, + }, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + audio = { sfxOrder = { "Sfx_Dummy", "Sfx_Item", "Sfx_WarpTo", + "Sfx_EnterDoor", "Sfx_ExitBuilding" } }, + }, + save = { + version = opts.version or "gold", + player = { name = "GOLD", id = 1234, money = 3000, coins = 40 }, + mom = { savedMoney = 500 }, + party = opts.party or {}, + inventory = opts.inventory or {}, + boxes = {}, + }, + } + local world = World.new(game) + world.maps = { + TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 4, height = 4, + blocks = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }, + objects = {}, warps = {}, sceneScripts = { [0] = { scriptKey = "s" } } }, + OTHER_MAP = { id = "OTHER_MAP", group = 3, map = 4, width = 2, height = 2, + blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} }, + } + world.map = { id = "TEST_MAP", def = world.maps.TEST_MAP, width = 4, + height = 4 } + world.map.blocks = world.maps.TEST_MAP.blocks + world.constants = { spriteOrder = { "SPRITE_CHRIS", "SPRITE_TEACHER", + "SPRITE_RIVAL" } } + world.sprites = { SPRITE_CHRIS = {}, SPRITE_TEACHER = {}, SPRITE_RIVAL = {} } + world.daytime = opts.daytime or "DAY" + world.player = fakePlayer(2, 3, "down") + return world, game +end + +-- ---- scene, clock, cartridge ---------------------------------------------- +local hw = hookWorld() +eq(hw:mapSceneOf(1, 2), 0, "checkmapscene: a map with scene scripts reads 0") +hw.mapScenes.TEST_MAP = 3 +eq(hw:mapSceneOf(1, 2), 3, "and reads back what setmapscene wrote") +check(hw:mapSceneOf(3, 4) == nil, + "a map with NO scene_var row answers nil, which the VM turns into $ff") +check(hw:mapSceneOf(9, 9) == nil, "and an unresolvable pair is nil too") + +eq(hw:timeOfDayId(), 1, "DAY is wTimeOfDay 1") +hw.daytime = "MORN" +eq(hw:timeOfDayId(), 0, "MORN is 0") +hw.daytime = "NITE" +eq(hw:timeOfDayId(), 2, "NITE is 2") +hw.daytime = "DARK" +eq(hw:timeOfDayId(), 3, "DARKNESS is 3") +hw.daytime = "DAY" +eq(hw:gsVersion(), 0, "checkver: a Gold save is 0") +eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1") + +-- ---- LoadPlayerData ------------------------------------------------------- +-- engine/menus/save.asm copies sPlayerData straight back over wPlayerData, and +-- BOTH wEventFlags and the w<Map>SceneID block sit inside that region, so a +-- reload comes back with every flag the player set and with each map still on +-- the scene it had reached. World:load runs this before the first setMap; +-- these are the halves of it on their own. +-- +-- The seed is InitializeEventsScript's setevent list, which the cache carries +-- as data/generated/initial_events.lua. Three of its ids stand in for it here: +-- EVENT_ILEX_FOREST_APPRENTICE 1794, EVENT_EARLS_ACADEMY_EARL 1739, and +-- EVENT_INITIALIZED_EVENTS 54, which the script sets last and +-- PlayersHouse2FInitializeRoomCallback checks before running it at all. +local SEED = { 1739, 1794, 54 } +do + local fresh = hookWorld() + fresh.initialEvents = SEED + fresh:loadPlayerData(fresh.game.save) + check(fresh.events:get(1794), "a save with no bitfield gets the seed") + check(fresh.events:get(54), "including EVENT_INITIALIZED_EVENTS") + eq(next(fresh.mapScenes), nil, "and no map has been advanced yet") + fresh:loadPlayerData(nil) + check(fresh.events:get(1794), "and so does no save at all") + + -- Play: one flag set (EVENT_TOGEPI_HATCHED 84), one of the seeded flags + -- CLEARED the way MeetMomScript clears EVENT_PLAYERS_HOUSE_MOM_2 on its way + -- out, and the map advanced off scene 0. This is what the snapshot writes. + fresh.events:set(84, true) + fresh.events:set(1794, false) + fresh.mapScenes.TEST_MAP = 3 + local written = { + events = fresh.events:serialize(), + mapScenes = fresh.mapScenes, + } + + local reload = hookWorld() + reload.initialEvents = SEED + reload:loadPlayerData(written) + check(reload.events:get(84), "a flag the player set survives the reload") + check(not reload.events:get(1794), + "a CLEARED seed flag stays cleared rather than being seeded again") + check(reload.events:get(1739), "the rest of the seed is still set") + eq(reload.mapScenes.TEST_MAP, 3, "and the map is still on its scene") + eq(reload:mapSceneOf(1, 2), 3, "which is what checkmapscene reads back") + + -- A bitfield that has flags but has never had the seed -- a save built by + -- hand, or one written before InitializeEventsScript ran -- keeps its own + -- flags AND gets the seed, which is the branch the bedroom callback takes. + local partial = hookWorld() + partial.initialEvents = SEED + partial:loadPlayerData({ events = { [10] = 0x01 } }) + check(partial.events:get(80), "an un-seeded bitfield keeps its own flags") + check(partial.events:get(1794), "and gets the seed on top") + + -- Objects read the restored bitfield, not the seed: this is the whole point + -- of the restore happening before the first map load. + check(not reload.events:objectVisible(84), + "a restored flag hides the object that names it") + check(reload.events:objectVisible(1794), + "and a restored CLEAR shows the object again") +end + +-- ---- wPlayerState across a save ------------------------------------------- +-- The third member of the sPlayerData block LoadPlayerData copies back. Until +-- it rode the save, a reload walked the player off the BICYCLE and off the +-- water: everything the state decides follows from this one field, so all +-- three of the things it decides are checked here. +-- +-- Wrapped in a function for the same reason bikeChecks is, and called on the +-- spot rather than through a name of its own: block locals count against the +-- main chunk's 200-local ceiling and this file has none left. +;(function() +local Bike = require("src.world.gen2.Bike") +local Player = require("src.world.gen2.Player") +local Gen2Save = require("src.core.gen2.Save") + +-- Save on the bike, reload on the bike: the state itself, ChrisStateSprites' +-- bike row on the player, and STEP_BIKE under the next step. +local riding = hookWorld() +riding.sprites.SPRITE_CHRIS_BIKE = { name = "SPRITE_CHRIS_BIKE" } +local worn +riding.player.setSprite = function(_, def) worn = def end +riding:loadPlayerData({ playerState = "bike" }) +eq(riding.playerState, FieldMoves.PLAYER_BIKE, + "a save made on the BICYCLE reloads on the BICYCLE") +eq(worn and worn.name, "SPRITE_CHRIS_BIKE", "wearing the bike's own sprite") +eq(Bike.stepFrames(riding.playerState, "up", false, 16), 8, + "and stepping at STEP_BIKE rather than STEP_WALK") + +-- Save afloat, reload afloat, on the sprite GetSurfType picked at the time. +local afloat = hookWorld() +afloat.sprites.SPRITE_SURFING_PIKACHU = { name = "SPRITE_SURFING_PIKACHU" } +local raft +afloat.player.setSprite = function(_, def) raft = def end +afloat:loadPlayerData({ playerState = "surf_pika" }) +eq(afloat.playerState, FieldMoves.PLAYER_SURF_PIKA, + "a save made surfing reloads surfing") +eq(raft and raft.name, "SPRITE_SURFING_PIKACHU", + "on the PIKACHU it was riding, not on a generic Lapras") + +-- The third thing that follows: .TranslateIntoMovement picks .CheckSurfPerms +-- off wPlayerState, so the restored state is what lets the reload step back +-- out onto the water it was saved on. Water at (3,2), the player facing it. +local sea = fakeWorld({ [2 * 100 + 3] = COLL_WATER }) +sea.player = Player.new(3, 3, "up", nil) +sea.game.save.playerState = "surf" +sea:loadPlayerData(sea.game.save) +eq(sea.playerState, FieldMoves.PLAYER_SURF, "the state comes off the save") +eq(sea:movePlayer("up"), "moved", "and the step onto the water is legal") + +-- The same world without the state restored is the bug: on foot, and the sea +-- the save was made on is a wall. +local dry = fakeWorld({ [2 * 100 + 3] = COLL_WATER }) +dry.player = Player.new(3, 3, "up", nil) +dry:loadPlayerData({}) +eq(dry.playerState, FieldMoves.PLAYER_NORMAL, + "a save with no state at all is on foot") +check(dry:movePlayer("up") ~= "moved", "and that same step bumps") + +-- Only a name the save format vouches for is taken back; the two ends of the +-- round trip share one set, so neither can drift. +local bogus = hookWorld() +bogus:loadPlayerData({ playerState = "skateboard" }) +eq(bogus.playerState, FieldMoves.PLAYER_NORMAL, + "a state no cartridge could write comes back as PLAYER_NORMAL") +check(Gen2Save.PLAYER_STATES[FieldMoves.PLAYER_SURF_PIKA], + "and the set World tests against names every state FieldMoves does") +check(Gen2Save.PLAYER_STATES[FieldMoves.PLAYER_BIKE], "the bike included") +end)() + +-- ---- ENGINE_* flags ------------------------------------------------------- +-- A namespace of its own: BADGE_ZEPHYR must never collide with an object's +-- MAPOBJECT_EVENT_FLAG, and setting one must not respawn anybody. +check(not hw:engineFlag(26), "an unset engine flag is false") +hw:setEngineFlag(26, true) +check(hw:engineFlag(26), "setflag ENGINE_ZEPHYRBADGE reads back") +-- A BADGE id is the one ENGINE_* that does not live in save.engineFlags. +-- World:setEngineFlag routes it to save.player.badges instead, because that is +-- the store hasBadge, VAR_BADGES, the trainer card and the save summary all +-- read -- and while the two disagreed no field move in the game could be used +-- (tests/gen2_badges_test.lua). This assertion still expected the old store. +check(hw.game.save.player.badges[FieldMoves.JOHTO_BADGES[1]], + "and it lives on the save, in the badge store") +check(not (hw.game.save.engineFlags or {})[26], + "not in engineFlags, which nothing reads for badges") +check(not hw.events:get(26), "and it did NOT touch the wEventFlags array") +hw:setEngineFlag(26, false) +check(not hw:engineFlag(26), "clearflag clears it") + +-- ---- vars ----------------------------------------------------------------- +hw:writeVar(0x03, 10) -- VAR_BATTLETYPE, BATTLETYPE_FORCEITEM +eq(hw:battleType(), 10, "writevar VAR_BATTLETYPE is read back by startbattle") + +-- ---- map objects ---------------------------------------------------------- +-- disappear then appear has to be symmetrical BOTH ways: through the event +-- flag when the object has one, and through the synthetic hide when it does +-- not. A one-way pair is why a scripted NPC used to vanish for good. +local objWorld = hookWorld() +objWorld.maps.TEST_MAP.objects = { + { index = 1, sprite = "SPRITE_TEACHER", x = 1, y = 1, eventFlag = 700 }, + { index = 2, sprite = "SPRITE_RIVAL", x = 2, y = 2, eventFlag = 0xFFFF }, + { index = 3, sprite = 244, x = 3, y = 3, eventFlag = 0xFFFF }, +} +objWorld.rebuildPeople = function(self) self.rebuilds = (self.rebuilds or 0) + 1 end +objWorld:disappearObject(2) -- object_const 2 -> index 1 +check(objWorld.events:get(700), "disappear sets the object's event flag") +objWorld:appearObject(2) +check(not objWorld.events:get(700), "and appear clears it again") +objWorld:disappearObject(3) -- eventFlag $ffff: the mask, with no flag to set +check(objWorld.objectMasks["TEST_MAP:2"], "a flagless disappear masks by key") +objWorld:appearObject(3) +check(objWorld.objectMasks["TEST_MAP:2"] == false, + "and appear unmasks it, so the pair is not one-way") + +-- Three object_events SHARING one MAPOBJECT_EVENT_FLAG is ordinary: the +-- animated Burned Tower beasts all carry EVENT_BURNED_TOWER_B1F_BEASTS_1 +-- (maps/BurnedTowerB1F.asm:152) and ReleaseTheBeasts appears and jumps them +-- away one at a time. MaskObject writes ONE byte (home/map.asm:1542) and the +-- flag is only read back at the next LoadObjectMasks, so a `disappear` must +-- take its own object off the map and leave the other two standing. +do +local beasts = hookWorld() +beasts.maps.TEST_MAP.objects = { + { index = 1, sprite = "SPRITE_TEACHER", x = 1, y = 1, eventFlag = 1866 }, + { index = 2, sprite = "SPRITE_TEACHER", x = 2, y = 2, eventFlag = 1866 }, + { index = 3, sprite = "SPRITE_TEACHER", x = 3, y = 3, eventFlag = 1866 }, +} +beasts.pooledNpc = function(_, mapId, obj) + return { def = obj, id = mapId .. ":" .. tostring(obj.index) } +end +local function standing(world) + local ids = {} + for _, npc in ipairs(world.npcs) do ids[npc.id] = true end + return ids +end +beasts:loadObjectMasks() -- the map load, with the shared flag clear +beasts:rebuildPeople() +eq(#beasts.npcs, 3, "all three spawn while the shared flag is clear") +beasts:disappearObject(2) -- object_const 2 -> index 1 +beasts:rebuildPeople() +local left = standing(beasts) +check(not left["TEST_MAP:1"], "the beast that jumped away is gone") +check(left["TEST_MAP:2"] and left["TEST_MAP:3"], + "and the two sharing its event flag are still standing") +check(beasts.events:get(1866), "the flag is set for the next map load") + +-- The reveal half, the same way round: a load with the shared flag SET masks +-- every one of them, and each `appear` unmasks its own (Script_appear -> +-- UnmaskCopyMapObjectStruct, home/map_objects.asm:309). +beasts:loadObjectMasks() +beasts:rebuildPeople() +eq(#beasts.npcs, 0, "a load with the shared flag set masks all three") +beasts:appearObject(2) +beasts:rebuildPeople() +local shown = standing(beasts) +check(shown["TEST_MAP:1"], "the first appear puts exactly one on the map") +check(not (shown["TEST_MAP:2"] or shown["TEST_MAP:3"]), + "the other two wait for their own appear") +-- The hour poll stands in for a reload the cart never runs, so it passes +-- keepScripted: it re-derives the hour windows, but an object a scene masked +-- with no flag to remember it by ($ffff takes MaskObject's path alone) must +-- not walk back in at the top of the hour. +beasts.maps.TEST_MAP.objects[4] = + { index = 4, sprite = "SPRITE_TEACHER", x = 1, y = 3, eventFlag = 0xFFFF } +beasts:loadObjectMasks() +beasts:disappearObject(5) -- object_const 5 -> index 4 +beasts:loadObjectMasks({ keepScripted = true }) +beasts:rebuildPeople() +check(not standing(beasts)["TEST_MAP:4"], + "the hour poll leaves a scripted mask alone") +end + +-- moveobject writes the DEF as well as the live NPC, because it nearly always +-- names an object that has not spawned yet. +objWorld:moveObject(2, 7, 9) +eq(objWorld.maps.TEST_MAP.objects[1].x, 7, "moveobject writes the object def x") +eq(objWorld.maps.TEST_MAP.objects[1].y, 9, "and its y") + +-- variablesprite: an object whose sprite is a NUMBER is a wVariableSprites +-- slot, and nothing spawns for it until the slot is filled. +check(objWorld:resolveSprite(244) == nil, + "SPRITE_WEIRD_TREE resolves to nothing while its slot is empty") +objWorld:setVariableSprite(0xf4 - 0xf0, 2) +eq(objWorld:resolveSprite(244), "SPRITE_TEACHER", + "and to the sprite the slot names once variablesprite has run") +eq(objWorld:resolveSprite("SPRITE_RIVAL"), "SPRITE_RIVAL", + "a named sprite passes straight through") +check(objWorld:resolveSprite(130) == nil, + "a SPRITE_POKEMON byte is below SPRITE_VARS and still resolves to nothing") + +-- Script_appear RESPAWNS the object struct out of the map object +-- (UnmaskCopyMapObjectStruct -> CopyObjectStruct -> CopyMapObjectToObjectStruct, +-- engine/overworld/player_object.asm:207-215), so the pooled NPC -- the OLD +-- struct, still sitting on the old cell -- has to go with it or a +-- `moveobject` + `appear` pair spawns at the cell the object was already on. +-- That is Kurt after the Slowpoke Well grunt: moved to (11,6), reappearing at +-- (16,14). +objWorld.npcPool = { TEST_MAP_obj_1 = "stale struct", + TEST_MAP_obj_2 = "another object" } +objWorld:appearObject(2) +check(objWorld.npcPool.TEST_MAP_obj_1 == nil, + "appear drops the pooled struct so the coords are re-read from the def") +eq(objWorld.npcPool.TEST_MAP_obj_2, "another object", + "and only that object's") + +-- ---- the day care --------------------------------------------------------- +-- data/events/engine_flags.asm:18-20: ENGINE_DAY_CARE_MAN_HAS_EGG (5), +-- _MAN_HAS_MON (6) and _LADY_HAS_MON (7) ARE bits of wDayCareMan/wDayCareLady, +-- the bits the deposit, DayCareStep and the withdrawal write. So +-- Route34EggCheckCallback's `checkflag` reads the deposit state itself and +-- there is exactly one store; a second copy in save.engineFlags is what kept +-- EVENT_DAY_CARE_MON_1/2 set forever and the yard empty. +do + local dcWorld, dcGame = hookWorld() + local Breeding = require("src.core.gen2.Breeding") + check(not dcWorld:engineFlag(6), "an empty day care answers checkflag 6 false") + check(not dcWorld:engineFlag(7), "and checkflag 7") + check(not dcWorld:engineFlag(5), "and no egg is waiting") + Breeding.side(dcGame.save, "man").mon = { species = "CHIKORITA", level = 5 } + check(dcWorld:engineFlag(6), "a deposit into the man's slot sets checkflag 6") + check(not dcWorld:engineFlag(7), "without touching the lady's") + Breeding.side(dcGame.save, "lady").mon = { species = "MAGIKARP", level = 5 } + check(dcWorld:engineFlag(7), "and the lady's slot sets checkflag 7") + Breeding.side(dcGame.save, "man").mon = nil + check(not dcWorld:engineFlag(6), "a withdrawal clears it again") + -- DayCareStep's `set DAYCAREMAN_HAS_EGG_F` is the only writer of the egg bit + -- besides DayCareManScript_Outside's clearflag, which is the one cart script + -- that writes any of the three. + Breeding.dayCare(dcGame.save).hasEgg = true + check(dcWorld:engineFlag(5), "a built egg answers checkflag 5") + dcWorld:setEngineFlag(5, false) + check(not Breeding.dayCare(dcGame.save).hasEgg, + "and the script's clearflag writes save.dayCare, not a second store") + check(dcWorld:engineFlags()[5] == nil, + "nothing lands in save.engineFlags for an aliased id") + -- No cart script writes the two HAS_MON bits: the deposit routines own them. + dcWorld:setEngineFlag(6, true) + check(not dcWorld:engineFlag(6), + "a setflag on ENGINE_DAY_CARE_MAN_HAS_MON does not fake a deposit") + + -- GetMonSprite (engine/overworld/overworld.asm:279-305) tests + -- SPRITE_DAY_CARE_MON_1/2 ($e0/$e1) BEFORE the SPRITE_VARS range and answers + -- with LoadOverworldMonIcon of wBreedMon1Species / wBreedMon2Species. Route + -- 34's two yard objects carry those bytes, so without the arm they resolved + -- to nothing and the mons never spawned even with the flags right. + dcGame.data.gen2Icons = { + species = { CHIKORITA = "ICON_BULBASAUR", MAGIKARP = "ICON_FISH" }, + icons = { + ICON_BULBASAUR = { id = "ICON_BULBASAUR", + image = "assets/generated/icons/gen2/bulbasaur.png" }, + ICON_FISH = { id = "ICON_FISH", + image = "assets/generated/icons/gen2/fish.png" }, + }, + } + check(dcWorld:resolveSprite(0xe0) == nil, + "an empty man's slot leaves the yard object unresolved, so it stays hidden") + Breeding.side(dcGame.save, "man").mon = { species = "CHIKORITA", level = 5 } + local def = dcWorld:resolveSprite(0xe0) + check(type(def) == "table", "a deposited mon resolves to a built sprite def") + eq(def and def.species, "CHIKORITA", "for the species that was deposited") + eq(def and def.image, "assets/generated/icons/gen2/bulbasaur.png", + "pointed at that mon's own menu icon") + eq(def and def.spriteType, "POKEMON_SPRITE", + "in the shape extractMonSprites emits") + local lady = dcWorld:resolveSprite(0xe1) + eq(lady and lady.species, "MAGIKARP", "and $e1 reads the lady's slot") +end + +-- ---- map blocks ----------------------------------------------------------- +local blockWorld = hookWorld() +blockWorld.refreshMapImages = function() return true end +check(blockWorld:changeBlock(1, 1, 99), "changeblock writes one block") +eq(blockWorld.maps.TEST_MAP.blocks[1 * 4 + 1 + 1], 99, + "at row * width + column + 1") +check(not blockWorld:changeBlock(9, 9, 99), "and refuses a block off the grid") +-- restoreBlocks is LoadMapAttributes' refill: the edit must not survive a map +-- load, or the shared maps.lua table would carry it into a New Game. +blockWorld:restoreBlocks() +eq(blockWorld.maps.TEST_MAP.blocks[6], 6, "and the next map load puts it back") + +-- changemapblocks repaints the WHOLE map off a second blockdata array. The +-- operand is a raw ROM bank/pointer, so the test carries the blockdata pair +-- RomExtractorGen2 now records for every map and checks that the pointer is +-- placed by the array it lands IN -- offset and all -- rather than by a name. +do + local world = hookWorld() + world.maps.TEST_MAP.blockdata = { bank = 0x60, address = 0x4000 } + local alt = {} + for i = 1, 20 do alt[i] = 100 + i end + world.maps.ALT_BLOCKS = { id = "ALT_BLOCKS", group = 5, map = 6, + width = 4, height = 5, blocks = alt, objects = {}, warps = {}, + blockdata = { bank = 0x60, address = 0x5000 } } + -- Enough of the bake path to be real: refreshMapImages does nothing at all + -- when the world has never baked, and the bake drop is half of what is under + -- test here. + world.mapImage = "stale canvas" + world.mapImages = { ["TEST_MAP|DAY|gbc|1"] = "stale", + ["OTHER_MAP|DAY|gbc|1"] = "keep" } + world.imageFor = function() return "rebaked" end + world.rebuildNeighbors = function() end + + check(not world:changeMapBlocks(0x61, 0x5000), + "a pointer in another bank places nowhere and changes nothing") + check(not world:changeMapBlocks(0x60, 0x7000), + "and so does one no map's blockdata covers") + -- 4 x 4 is sixteen blocks read as one flat run, so an offset that leaves + -- fewer than that in the source array would run off the end of it. + check(not world:changeMapBlocks(0x60, 0x5008), + "a run that would read past the source array is refused") + eq(world.maps.TEST_MAP.blocks[1], 1, "none of the three touched a block") + + check(world:changeMapBlocks(0x60, 0x5000), "and the array itself is copied") + eq(world.maps.TEST_MAP.blocks[1], 101, "from its first block") + eq(world.maps.TEST_MAP.blocks[16], 116, "to the sixteenth, width * height") + eq(alt[17], 117, "without disturbing the source") + check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, + "the bake taken off the old blocks is dropped, or the screen keeps it") + eq(world.mapImages["OTHER_MAP|DAY|gbc|1"], "keep", + "and only that map's bakes go") + + -- LoadMapAttributes' refill, exactly as for changeblock: a whole-map repaint + -- is still an edit to the shared maps.lua table. + world:restoreBlocks() + eq(world.maps.TEST_MAP.blocks[1], 1, "the next map load puts the map back") + eq(world.maps.TEST_MAP.blocks[16], 16, "every block of it") + + -- A pointer part way into an array: the cart's copy starts there, it does not + -- start at the array's head. + world.mapImages["TEST_MAP|DAY|gbc|1"] = "stale again" + check(world:changeMapBlocks(0x60, 0x5004), "a pointer inside an array works") + eq(world.maps.TEST_MAP.blocks[1], 105, "and the copy starts at the offset") + check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, "dropping the bake again") +end + +-- ---- earthquake ----------------------------------------------------------- +-- One byte, two numbers: `earthquake 80` ($50) is amplitude 1 << 1 held for +-- $50 & $3f = 16 frames, not eighty of anything. +local quakeWorld = hookWorld() +quakeWorld:earthquake(80, 16) +eq(quakeWorld.shake.amplitude, 2, "earthquake 80 shakes by two pixels") +eq(quakeWorld.shake.left, 16, "for sixteen frames") +quakeWorld:updateShake() +check(quakeWorld.shake.phase ~= 0, "and the offset flips off zero at once") +for _ = 1, 20 do quakeWorld:updateShake() end +check(quakeWorld.shake == nil, "the shake ends itself") +quakeWorld:earthquake(0xc1, 1) +eq(quakeWorld.shake.amplitude, 8, "the top two bits pick 1 << 3 for $c1") + +-- ---- warps ---------------------------------------------------------------- +local warpWorld = hookWorld() +local warped = {} +warpWorld.setMap = function(_, id, x, y, facing) + warped = { id = id, x = x, y = y, facing = facing } + return true +end +check(warpWorld:warpTo(3, 4, 5, 6, "up"), "warpfacing resolves group/map") +eq(warped.id, "OTHER_MAP", "to the right map") +eq(warped.x, 5, "at the raw cell x") +eq(warped.facing, "up", "facing the byte warpfacing carried") +check(not warpWorld:warpTo(9, 9, 0, 0), "an unresolvable pair is a no-op") + +-- warpcheck ARMS a warp; it must not take it, because the commands queued +-- behind it would run with the map pulled out from under them. +local checkWorld = hookWorld() +checkWorld.map.warpAt = function(_, x, y) + if x == 2 and y == 3 then return { def = { destMap = "OTHER_MAP" } } end + return nil +end +check(checkWorld:armWarpCheck(), "warpcheck finds the warp underfoot") +check(checkWorld.pendingWarp ~= nil, "and parks it") +local took = false +checkWorld.takeWarp = function() took = true return true end +check(not took, "without warping inside the command") +checkWorld:takePendingWarp() +check(took, "the drain is what takes it") +check(checkWorld.pendingWarp == nil, "and it fires once") + +-- blackoutmod overrides the SPAWN_* lookup, so losing at sea does not respawn +-- the player somewhere they cannot leave. +local boWorld = hookWorld() +boWorld.landmarks = { spawns = { SPAWN_HOME = { map = "TEST_MAP", x = 1, y = 1 } } } +local spawned +boWorld.setMap = function(_, id) spawned = id return true end +boWorld:warpToSpawn() +eq(spawned, "TEST_MAP", "with no override the SPAWN_* table wins") +boWorld:setBlackoutMap(3, 4) +eq(boWorld.game.save.blackoutMap, "OTHER_MAP", "blackoutmod stores a map id") +boWorld:warpToSpawn() +eq(spawned, "OTHER_MAP", "and warpToSpawn prefers it") + +-- warpmod is stored and nothing more; the elevator that reads it does not +-- exist yet, which is exactly why storing it is the point. +boWorld:setWarpMod(2, 3, 4) +eq(boWorld.game.save.warpMod.map, "OTHER_MAP", "warpmod resolves its map id") +eq(boWorld.game.save.warpMod.warp, 2, "and keeps the warp number") + +-- ---- the map setup chain -------------------------------------------------- +-- Walking onto a warp tile is PLAYEREVENT_WARP -> WarpToNewMapScript, which is +-- `warpsound` then `newloadmap MAPSETUP_DOOR`. MapSetupScript_Door opens on +-- FadeOutToWhite and falls through into _Train, whose tail is FadeInFromWhite, +-- so the LOAD SITS IN THE MIDDLE of the script rather than replacing it. +local doorWorld = hookWorld() +doorWorld.maps.OTHER_MAP.warps = { { x = 1, y = 1 } } +local doorSfx = {} +doorWorld.playSfx = function(_, id) doorSfx[#doorSfx + 1] = id end +doorWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR underfoot +local doorLoad = nil +doorWorld.setMap = function(self, id, x, y, facing) + doorLoad = { id = id, x = x, y = y, facing = facing } + self.fade = nil -- a map load repaints everything, exactly as setMap does + return true +end +check(doorWorld:takeWarp({ destMap = "OTHER_MAP", destWarp = 1 }), + "takeWarp takes a resolvable warp_event") +eq(doorSfx[1], 3, "warpsound runs BEFORE the load, off the tile underfoot") +check(doorLoad == nil, "and the map does not load on the frame it is taken") +check(doorWorld:busy(), + "the setup script is a blocking call, so the world is busy for it") +-- FadeOutToWhite / FadeInFromWhite are `ld b, $4` steps of ConvertTimePals*HL, +-- each followed by DelayFrames 2: four steps, eight frames, per half. +for _ = 1, 7 do doorWorld:updateMapSetup() end +check(doorLoad == nil, "seven frames in, the fade out is still running") +eq(doorWorld.fade, "white", "and the sheet it fades to is FillWhiteBGColor's") +doorWorld:updateMapSetup() +check(doorLoad ~= nil, "the eighth frame is where the load lands") +eq(doorLoad.id, "OTHER_MAP", "on the destination map") +eq(doorLoad.x, 1, "at the destination WARP's own cell") +eq(doorWorld.fadeLevel, 1, + "with the sheet re-armed, because the load cleared it") +for _ = 1, 7 do doorWorld:updateMapSetup() end +check(doorWorld.mapSetup ~= nil, "the fade in takes another eight") +doorWorld:updateMapSetup() +check(doorWorld.mapSetup == nil, "and the sixteenth frame ends the chain") +check(doorWorld.fade == nil, "with nothing left over the world") +check(not doorWorld:busy(), "and control back") + +-- MapSetupScript_Connection and _Submenu are the only two rows with no fade at +-- all: an edge cross must not hitch. Everything else fades back IN at least, +-- and only DOOR / FALL / TELEPORT fade OUT first (FALL and TELEPORT by +-- FALLTHROUGH -- neither names a FadeOutToWhite of its own). +local setupWorld = hookWorld() +local setupLoads = 0 +setupWorld.setMap = function() setupLoads = setupLoads + 1 return true end +setupWorld:runMapSetup(0xf7, function() return setupWorld:setMap() end) +eq(setupLoads, 1, "MAPSETUP_CONNECTION loads on the spot") +check(setupWorld.mapSetup == nil, "with no chain behind it") +setupWorld:runMapSetup(0xf1, function() return setupWorld:setMap() end) +eq(setupLoads, 2, "MAPSETUP_WARP opens on DisableLCD, so it loads at once too") +eq(setupWorld.mapSetup.phase, "in", "and only fades back in") +setupWorld.mapSetup = nil +setupWorld:runMapSetup(0xf6, function() return setupWorld:setMap() end) +eq(setupLoads, 2, "MAPSETUP_FALL fades out FIRST, by fallthrough into _Door") +eq(setupWorld.mapSetup.phase, "out", "so its load is still to come") + +-- Script_newloadmap is four lines and none of them is a PlaySFX; where the cart +-- wants a sound it writes `warpsound` in front (WarpToNewMapScript is that +-- pair). Inventing one here rang a door bell over every scripted re-entry. +local reloadWorld = hookWorld() +local reloadSfx = 0 +reloadWorld.playSfx = function() reloadSfx = reloadSfx + 1 end +reloadWorld.setMap = function() return true end +reloadWorld:newLoadMap(0xf5) -- MAPSETUP_DOOR +eq(reloadSfx, 0, "newloadmap alone is silent") + +-- RefreshPlayerSprite: CheckWarpFacingDown against the tile the player ARRIVES +-- on, then `call c, SpawnInFacingDown`. Anything not in that array keeps the +-- facing they walked in with, which is why you enter a building still facing up +-- and step out of one facing the street. +local faceWorld = hookWorld() +faceWorld.player.facing = "up" +faceWorld.map.cellCollision = function() return 0x70 end -- COLL_WARP_CARPET_DOWN +faceWorld:spawnFacing() +eq(faceWorld.player.facing, "up", "a doormat inside a building keeps the facing") +faceWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR +faceWorld:spawnFacing() +eq(faceWorld.player.facing, "down", "a doorway outside one spawns facing down") +faceWorld.player.facing = "left" +faceWorld.map.cellCollision = function() return 0x7b end -- COLL_CAVE +faceWorld:spawnFacing() +eq(faceWorld.player.facing, "down", "and so does a cave mouth") + +-- checkWarpOnArrive has to ANSWER whether it warped: DoPlayerEvent hands the +-- frame to WarpToNewMapScript, so the rest of that frame's overworld loop -- +-- DoPlayerMovement included -- never runs. Falling through to World:movePlayer +-- gave a still-held direction one free step on the far side of the door, which +-- put the player a cell too far into Elm's Lab before its scene script started. +local arriveWorld = hookWorld() +arriveWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR +arriveWorld.map.warpAt = function() return { def = { destMap = "OTHER_MAP" } } end +arriveWorld.takeWarp = function() return true end +check(arriveWorld:checkWarpOnArrive(), "an immediate warp answers true") +arriveWorld.map.cellCollision = function() return 0x00 end +check(not arriveWorld:checkWarpOnArrive(), "an ordinary tile answers false") +arriveWorld.map.cellCollision = function() return 0x70 end -- carpet, needs DOWN +arriveWorld.heldDir = nil +check(not arriveWorld:checkWarpOnArrive(), + "and a carpet with nothing held answers false rather than warping") +arriveWorld.heldDir = "down" +check(arriveWorld:checkWarpOnArrive(), "a carpet pressed into does warp") + +-- GetWarpSFX picks by the tile the player STANDS on, not the destination. +local sfxWorld = hookWorld() +local played = {} +sfxWorld.playSfx = function(_, id) played[#played + 1] = id end +sfxWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR +sfxWorld:warpSound() +eq(played[1], 3, "COLL_DOOR plays Sfx_EnterDoor by NAME, not by a fixed index") +sfxWorld.map.cellCollision = function() return 0x7c end -- COLL_WARP_PANEL +sfxWorld:warpSound() +eq(played[2], 2, "COLL_WARP_PANEL plays Sfx_WarpTo") +sfxWorld.map.cellCollision = function() return 0x00 end +sfxWorld:warpSound() +eq(played[3], 4, "and anything else is Sfx_ExitBuilding") + +-- ---- encounters ----------------------------------------------------------- +-- StoreSwarmMapIndices falls THROUGH into SetSwarmFlag, so both halves land. +local swarmWorld = hookWorld() +swarmWorld:setSwarm(3, 4) +eq(swarmWorld.game.save.swarmMap, "OTHER_MAP", "swarm stores the map") +check(swarmWorld.game.save.dailyFlags.swarm, + "and DAILYFLAGS1_SWARM, which is what CheckSwarmFlag clears it by") + +-- ---- bag, money, coins ---------------------------------------------------- +local bagWorld = hookWorld({ inventory = { POTION = 3 } }) +check(bagWorld:hasItem(18), "checkitem finds a POTION by its ItemNames index") +check(not bagWorld:hasItem(0x47), "and does not find one that is not there") +check(not bagWorld:takeItem(18, 5), + "takeitem takes NOTHING when the pack holds fewer than asked") +eq(bagWorld.game.save.inventory.POTION, 3, "so the count is untouched") +check(bagWorld:takeItem(18, 3), "and takes them all when it can") +check(bagWorld.game.save.inventory.POTION == nil, "clearing the row") + +eq(bagWorld:money(0), 3000, "YOUR_MONEY is the player's wallet") +eq(bagWorld:money(1), 500, "MOMS_MONEY is the savings account") +bagWorld:setMoney(1, 9000) +eq(bagWorld.game.save.mom.savedMoney, 9000, "and setMoney writes it back") +eq(bagWorld:coins(), 40, "the coin case") +bagWorld:setCoins(9999) +eq(bagWorld:coins(), 9999, "and its write") + +-- ---- party ---------------------------------------------------------------- +local partyWorld = hookWorld({ party = { { species = "MAGIKARP" } } }) +check(partyWorld:hasPoke(129), "checkpoke finds MAGIKARP in the party") +check(not partyWorld:hasPoke(213), "and refuses a species that is not in it") +check(partyWorld:giveEgg(152, 5), "giveegg builds the egg") +local egg = partyWorld.game.save.party[2] +check(egg.isEgg, "and marks the slot as one") +eq(egg.eggSteps, 20, "with the species' own hatch counter") +eq(egg.hp, 0, "and DayCare_GiveEgg's zeroed HP") +for _ = 1, 6 do partyWorld:giveEgg(152, 5) end +eq(#partyWorld.game.save.party, 6, "a full party refuses the next one") + +-- ---- fruit trees ---------------------------------------------------------- +-- FruitTreeItems is not extracted (nothing in the bytecode points at it), so +-- the table is src/core/gen2/Apricorns.lua's and this side turns its item ids +-- into the indices the VM's giveitem speaks in. An item this stub data table +-- has no row for still answers 0, which is the "no fruit here" the VM prints +-- "BERRY" for. +local treeWorld = hookWorld() +treeWorld.game.data.items.BERRY = { id = "BERRY", name = "BERRY", index = 173 } +eq(treeWorld:fruitTreeItem(1), 173, "FRUITTREE_ROUTE_29 is a BERRY") +eq(treeWorld:fruitTreeItem(0x11), 0, + "and an item the table has no row for is 0") +check(not treeWorld:fruitTreePicked(4), "an unpicked tree") +treeWorld:fruitTreePick(4) +check(treeWorld:fruitTreePicked(4), "stays picked") +check(treeWorld.game.save.fruitTrees[4], "on the save") +-- TryResetFruitTrees, the top of FruitTreeScript: the first tree examined +-- after the daily rollover refills every one of the thirty. +check(treeWorld:fruitTreeReset(), "the first look refills them all") +check(not treeWorld:fruitTreePicked(4), "so the picked one has fruit again") +check(not treeWorld:fruitTreeReset(), + "and ENGINE_ALL_FRUIT_TREES stops a second refill the same day") + +-- ---- music ---------------------------------------------------------------- +-- musicfadeout queues the song UNDER the ramp; it must not start until the +-- ramp reaches the bottom, or the cross-fade reads as a cut. +local musicWorld = hookWorld() +musicWorld.game.data.audio.musicOrder = { "Music_Nothing", "Music_NewBarkTown" } +musicWorld.game.data.audio.songs = { Music_NewBarkTown = {} } +-- World captured the Music module by reference, so patching the two entry +-- points on the table is what keeps this headless: neither has a love-free +-- path, and neither is what is under test here. +local Music = require("src.core.Music") +local realFadeOut, realPlay = Music.fadeOut, Music.play +local started = {} +Music.fadeOut = function(control) started.control = control end +Music.play = function(_, name) started.song = name end +musicWorld:fadeOutMusic(1, 4) +eq(started.control, 4, "the ramp is handed the control byte") +check(musicWorld.pendingMusic ~= nil, "the label is queued") +eq(musicWorld.pendingMusic.left, 28, "for control * 7 frames of ramp") +for _ = 1, 27 do musicWorld:updateMusicFade() end +check(musicWorld.pendingMusic ~= nil, "and is still waiting one frame short") +musicWorld:updateMusicFade() +check(musicWorld.pendingMusic == nil, "then starts") +eq(started.song, "Music_NewBarkTown", "with the label the id named") +musicWorld:fadeOutMusic(0, 4) +check(musicWorld.pendingMusic == nil, "MUSIC_NONE queues nothing behind it") +-- tests/run_tests.lua dofiles every suite into ONE process, so a patched module +-- table would follow this file into the next one. +Music.fadeOut, Music.play = realFadeOut, realPlay + +-- ---- the phone ------------------------------------------------------------ +local phoneWorld = hookWorld() +-- PHONE_MOM is contact 1 (constants/phone_constants.asm); the table is keyed +-- by that number, not by the label. +check(phoneWorld:addPhoneNumber(1), "askforphonenumber stores one") +check(not phoneWorld:addPhoneNumber(1), + "and refuses the same contact twice, the way _CheckCellNum does") +phoneWorld:setSpecialCall(3) +eq(phoneWorld:specialCall(), 3, "specialphonecall parks its id") +phoneWorld:setSpecialCall(0) +eq(phoneWorld:specialCall(), 0, "and SPECIALCALL_NONE clears it") + +-- ---- roaming legendaries -------------------------------------------------- +-- +-- engine/overworld/wildmons.asm InitRoamMons / CheckEncounterRoamMon / +-- UpdateRoamMons / JumpRoamMons, and data/wild/roammon_maps.asm. All of it is +-- pure state, so none of these need a map loaded. +-- +-- Wrapped in a function because Lua 5.1 caps a chunk at 200 active locals and +-- this suite is already close to it; the alternative is renaming everything +-- above, which would make the diff lie about what changed. +local function roamerAndSwarmSuite() + +local Encounter = require("src.battle.gen2.Encounter") +local Roamers = require("src.core.gen2.Roamers") + +-- A random that hands back a scripted queue of ROM bytes. `% n` because the +-- module asks for 0..n-1 and the cart masks the same byte down at each use. +local function seeded(bytes) + local index = 0 + return function(n) + index = index + 1 + return (bytes[index] or 0) % n + end +end + +eq(#Roamers.MAPS, Roamers.NUM_MAPS, "RoamMaps has NUM_ROAMMON_MAPS entries") +eq(Roamers.NUM_MAPS, 16, "...which is 16, the width JumpRoamMon's mask assumes") +-- Every destination has to be a START map too, or a beast that walks there can +-- never walk out again: `.Update`'s not-found path leaves it parked forever. +local strandedFrom = {} +for _, row in ipairs(Roamers.MAPS) do + for _, dest in ipairs(row.to) do + if not Roamers.entryFor(dest) then + strandedFrom[#strandedFrom + 1] = row.map .. "->" .. dest + end + end +end +eq(table.concat(strandedFrom, ","), "", + "every roam destination is itself a roam map") +-- The two four-way junctions, and the one dead end. `and %11` gives a two-bit +-- index, so four is the most connections an entry can actually use. +eq(#Roamers.entryFor("ROUTE_36").to, 4, "ROUTE 36 is a four-way junction") +eq(#Roamers.entryFor("ROUTE_42").to, 4, "so is ROUTE 42") +eq(#Roamers.entryFor("ROUTE_39").to, 1, "ROUTE 39 is a dead end") +local widest = 0 +for _, row in ipairs(Roamers.MAPS) do widest = math.max(widest, #row.to) end +eq(widest, 4, "no entry has more connections than a two-bit index can reach") +check(Roamers.entryFor("ROUTE_40") == nil, + "the water routes are absent: ROUTE 40 is not a roam map") +check(Roamers.entryFor("ROUTE_41") == nil, "nor is ROUTE 41") + +-- InitRoamMons, in slot order. The order IS the identity: CheckEncounterRoamMon +-- indexes the structs by a random 0..2. +local roamSave = {} +Roamers.init(roamSave) +eq(#roamSave.roamers, 3, "three beasts") +eq(roamSave.roamers[1].species, "RAIKOU", "slot 1 is RAIKOU") +eq(roamSave.roamers[2].species, "ENTEI", "slot 2 is ENTEI") +eq(roamSave.roamers[3].species, "SUICUNE", "slot 3 is SUICUNE") +eq(roamSave.roamers[1].map, "ROUTE_42", "RAIKOU starts on ROUTE 42") +eq(roamSave.roamers[2].map, "ROUTE_37", "ENTEI starts on ROUTE 37") +eq(roamSave.roamers[3].map, "ROUTE_38", "SUICUNE starts on ROUTE 38") +eq(roamSave.roamers[1].level, 40, "all three are level 40") +eq(roamSave.roamers[1].hp, 0, "with no stats rolled yet") +roamSave.roamers[1].map = "ROUTE_29" +Roamers.init(roamSave) +eq(roamSave.roamers[1].map, "ROUTE_29", + "a second InitRoamMons does not hand the player three fresh beasts") + +-- `.Update`, byte by byte. From ROUTE 42 the connection list is +-- { ROUTE_43, ROUTE_44, ROUTE_37, ROUTE_38 }, and the index is the low two +-- bits of the byte after it has been masked to five. +eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 2 })), + "ROUTE_37", "index 2 out of ROUTE 42 is ROUTE 37") +eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 3 })), + "ROUTE_38", "index 3 is ROUTE 38") +-- An index at or past the entry's count re-rolls: ROUTE 39 has one connection, +-- so only a byte whose low two bits are 0 (and which is not itself 0, that +-- being the jump) can pick it. +eq(Roamers.moveOne("ROUTE_39", nil, "NEW_BARK_TOWN", seeded({ 1, 4 })), + "ROUTE_38", "an out-of-range index re-rolls rather than picking nil") +-- The last-map check: the beast refuses the map the player was on before this +-- one, which is what stops it shadowing a player pacing two routes. +eq(Roamers.moveOne("ROUTE_42", "ROUTE_37", "NEW_BARK_TOWN", seeded({ 2, 3 })), + "ROUTE_38", "a connection equal to the last map re-rolls") +-- A beast standing somewhere RoamMaps does not list stays put (`cp -1 / ret z` +-- leaves b and c alone). +eq(Roamers.moveOne("GOLDENROD_CITY", nil, "NEW_BARK_TOWN", seeded({ 2 })), + "GOLDENROD_CITY", "an unlisted map leaves the beast where it is") + +-- The 1-in-32 jump, and the fact that the SAME byte decides both: a byte whose +-- low five bits are zero jumps instead of stepping. +eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 32, 5 })), + Roamers.MAPS[6].map, "a masked byte of 0 jumps to a random roam map") +eq(Roamers.MAPS[6].map, "ROUTE_34", "...which for index 5 is ROUTE 34") +-- JumpRoamMon re-rolls off the map the PLAYER is standing on. +eq(Roamers.jumpOne("ROUTE_34", seeded({ 5, 6 })), "ROUTE_35", + "a jump onto the player's own map re-rolls") + +-- A seeded walk across connected maps, which is the thing that has to hold +-- end to end: 42 -> 37 -> 38 -> 39 -> 38. +local walkSave = {} +Roamers.init(walkSave) +local WALK = { 2, 1, 1, 1, 4 } +local WANT = { "ROUTE_37", "ROUTE_38", "ROUTE_39", "ROUTE_38" } +local rng = seeded(WALK) +local where = walkSave.roamers[1].map +local path = {} +for _ = 1, 4 do + where = Roamers.moveOne(where, nil, "NEW_BARK_TOWN", rng) + path[#path + 1] = where +end +eq(table.concat(path, ","), table.concat(WANT, ","), + "a seeded roamer walks 42 -> 37 -> 38 -> 39 -> 38") + +-- UpdateRoamMons moves every live beast and then backs up the map indices, so +-- the NEXT update avoids the map the player has just left. +local updSave = {} +Roamers.init(updSave) +Roamers.update(updSave, "ROUTE_30", seeded({ 2, 1, 1 })) +eq(updSave.roamerMaps.current, "ROUTE_30", "the player's map becomes Cur") +check(updSave.roamerMaps.last == nil, "and Last is still empty on the first") +Roamers.update(updSave, "ROUTE_31", seeded({ 2, 1, 1 })) +eq(updSave.roamerMaps.last, "ROUTE_30", "Cur shifts into Last on the second") +eq(updSave.roamerMaps.current, "ROUTE_31", "...and the new map into Cur") + +-- JumpRoamMons scatters all three (the Teleport map setup script). +local jumpSave = {} +Roamers.init(jumpSave) +Roamers.jumpAll(jumpSave, "NEW_BARK_TOWN", seeded({ 0, 1, 2 })) +eq(jumpSave.roamers[1].map, "ROUTE_29", "RAIKOU jumped to entry 0") +eq(jumpSave.roamers[2].map, "ROUTE_30", "ENTEI to entry 1") +eq(jumpSave.roamers[3].map, "ROUTE_31", "SUICUNE to entry 2") + +-- CheckEncounterRoamMon's three gates off ONE byte. +local encSave = {} +Roamers.init(encSave) +check(Roamers.checkEncounter(encSave, "ROUTE_42", true, seeded({ 1 })) == nil, + "surfing refuses before anything else") +check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 100 })) == nil, + "a byte of 100 or more is no encounter") +check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 4 })) == nil, + "a byte whose low two bits are 0 is no encounter") +local met = Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 1 })) +check(met ~= nil, "byte 1 picks slot 1") +eq(met and met.species, "RAIKOU", "...which is RAIKOU, and it is on ROUTE 42") +eq(met and met.level, 40, "...at level 40") +check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 2 })) == nil, + "byte 2 picks ENTEI, which is on ROUTE 37, so nothing happens") +check(Roamers.checkEncounter(encSave, "ROUTE_37", false, seeded({ 2 })) ~= nil, + "...and the same byte on ROUTE 37 does meet it") + +-- A beast that has been beaten is gone for good. +local goneSave = {} +Roamers.init(goneSave) +Roamers.endBattle(goneSave, 1, "win", 40, "ROUTE_42", seeded({ 2 })) +check(goneSave.roamers[1].species == nil, "a defeated beast loses its species") +check(goneSave.roamers[1].map == nil, "...and its map") +check(Roamers.checkEncounter(goneSave, "ROUTE_42", false, seeded({ 1 })) == nil, + "...so it is never met again") +local caughtSave = {} +Roamers.init(caughtSave) +Roamers.endBattle(caughtSave, 1, "caught", 40, "ROUTE_42", seeded({ 2 })) +check(caughtSave.roamers[1].species == nil, "a caught beast goes the same way") + +-- HP ACROSS TWO ENCOUNTERS, which is the whole point of the roam struct. +local ROAM_DATA = { + pokemon = { + growthRates = { + GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0, + linear = 0, constant = 0 }, + }, + RAIKOU = { + id = "RAIKOU", index = 243, name = "RAIKOU", + baseStats = { hp = 90, attack = 85, defense = 75, speed = 115, + specialAttack = 115, specialDefense = 100 }, + types = { "ELECTRIC", "ELECTRIC" }, catchRate = 3, baseExp = 216, + growthRate = "GROWTH_SLOW", genderRatio = 0xff, + levelMoves = { { level = 1, move = "BITE" } }, + evolutions = {}, + }, + }, + moves = { BITE = { id = "BITE", name = "BITE", pp = 25 } }, +} + +local hpSave = {} +Roamers.init(hpSave) +local beast, slot = Roamers.beginBattle(hpSave, 1, ROAM_DATA) +check(beast ~= nil, "the first encounter builds the beast") +eq(beast.species, "RAIKOU", "...as RAIKOU") +eq(beast.level, 40, "...at level 40") +check(#beast.moves > 0, + "...through Mon.new, so it has a Gen 2 moveset rather than none") +eq(slot.hp, beast.maxHp, + ".InitRoamHP banks the full HP the moment the battle starts") +check(slot.dvs ~= nil, "and the DVs are kept from the first meeting") +local firstDVs = slot.dvs +-- The player knocks it down and it flees: DRAW, so the struct keeps the HP. +beast.hp = 17 +Roamers.endBattle(hpSave, 1, "fled", beast.hp, "ROUTE_42", seeded({ 2 })) +eq(slot.hp, 17, "a flee banks the damage") +-- ...and the beast moved on, so it has to be found again. +check(slot.map ~= nil, "the beast is still out there") +local again = Roamers.beginBattle(hpSave, 1, ROAM_DATA) +eq(again.hp, 17, "the second encounter is the SAME hurt beast") +eq(again.maxHp, beast.maxHp, "...at the same max HP") +eq(again.dvs.attack, firstDVs.attack, "...and the same individual") +eq(again.dvs.special, firstDVs.special, "...on every DV") + +-- The player running away banks the damage the same way (TryToRunAwayFromBattle +-- writes DRAW too). +local runSave = {} +Roamers.init(runSave) +local runBeast = Roamers.beginBattle(runSave, 1, ROAM_DATA) +runBeast.hp = 3 +Roamers.endBattle(runSave, 1, "run", runBeast.hp, "ROUTE_42", seeded({ 2 })) +eq(runSave.roamers[1].hp, 3, "a player run banks the damage as well") + +-- BattleEnd_HandleRoamMons `.not_roaming`: one wild battle in sixteen moves the +-- beasts even though you never saw one. +local driftSave = {} +Roamers.init(driftSave) +check(not Roamers.afterWildBattle(driftSave, "ROUTE_30", seeded({ 1 })), + "fifteen wild battles in sixteen leave the beasts alone") +check(Roamers.afterWildBattle(driftSave, "ROUTE_30", seeded({ 16, 2, 1, 1 })), + "and the sixteenth moves them") + +-- ---- swarms --------------------------------------------------------------- +-- +-- engine/events/specials.asm StoreSwarmMapIndices / SetSwarmFlag / +-- CheckSwarmFlag, and _SwarmWildmonCheck's place in front of the normal table. + +local Swarm = Roamers.Swarm + +-- A stand-in cache. encounters.swarmGrass is NOT in data/generated today, so +-- this is also the assertion that the reader will pick it up unchanged when it +-- arrives: the shape is exactly encounters.grass'. +local SWARM_ENCOUNTERS = { + grass = { + ROUTE_35 = { rates = { MORN = 5, DAY = 5, NITE = 5 }, + slots = { MORN = { { species = "NIDORAN_M", level = 12 } }, + DAY = { { species = "NIDORAN_M", level = 12 } }, + NITE = { { species = "NIDORAN_M", level = 12 } } } }, + }, + water = {}, + swarmGrass = { + ROUTE_35 = { rates = { MORN = 25, DAY = 25, NITE = 25 }, + slots = { MORN = { { species = "YANMA", level = 12 } }, + DAY = { { species = "YANMA", level = 12 } }, + NITE = { { species = "YANMA", level = 12 } } } }, + }, + swarmWater = {}, +} + +local swarmSave = {} +check(not Swarm.active(swarmSave), "no swarm to start with") +eq(Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_35"), SWARM_ENCOUNTERS, + "and with none, the encounter tables are handed back untouched") + +Swarm.set(swarmSave, "ROUTE_35") +check(Swarm.active(swarmSave), + "StoreSwarmMapIndices falls through into SetSwarmFlag") +eq(Swarm.mapId(swarmSave), "ROUTE_35", "and parks the map") +eq(Swarm.check(swarmSave), 0, "CheckSwarmFlag answers 0 while the flag is up") + +local view = Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_35") +check(view ~= SWARM_ENCOUNTERS, "an active swarm builds a view") +eq(view.grass.ROUTE_35.slots.DAY[1].species, "YANMA", + "_SwarmWildmonCheck searches the swarm table BEFORE the Johto one") +eq(view.grass.ROUTE_35.rates.DAY, 25, "...rate and all") +eq(Encounter.grassSlot(view, "ROUTE_35", "DAY", function() return 0 end) + .species, "YANMA", "so Encounter rolls the swarm's list unchanged") +-- The override is one map wide: everywhere else still reads its own table. +eq(SWARM_ENCOUNTERS.grass.ROUTE_35.slots.DAY[1].species, "NIDORAN_M", + "and the cache's own table is not mutated") +eq(Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_32"), SWARM_ENCOUNTERS, + "a swarm on ROUTE 35 does not touch ROUTE 32") +-- A swarm map the swarm TABLE does not list falls through to the normal +-- lookup rather than blanking the map (`jr nc, .noSwarm`). +local fishOnly = {} +Swarm.set(fishOnly, "ROUTE_32") +eq(Swarm.tables(fishOnly, SWARM_ENCOUNTERS, "ROUTE_32"), SWARM_ENCOUNTERS, + "a swarm map with no swarm rows falls through to the normal table") + +-- ActivateFishingSwarm sets the fishing flag and lights the same daily flag, +-- but leaves the map pair alone. +local fishSave = {} +Swarm.setFishing(fishSave, Swarm.FISH_QWILFISH) +check(Swarm.active(fishSave), "a fishing swarm lights DAILYFLAGS1_SWARM too") +eq(Swarm.fishing(fishSave), Swarm.FISH_QWILFISH, "and records which one") +check(fishSave.swarmMap == nil, "without touching the map pair") + +-- EXPIRY. Nothing but the daily reset ends a swarm: CheckDailyResetTimer +-- zeroes the daily flags, and CheckSwarmFlag -- which runs immediately after +-- it in CheckTimeEvents -- is what then clears the map pair. +local dailySave = {} +Swarm.set(dailySave, "ROUTE_35") +check(not Swarm.timeEvents(dailySave, 100), + "the first time check only starts the countdown") +check(Swarm.active(dailySave), "so the swarm is still on") +check(not Swarm.timeEvents(dailySave, 100), + "and the same day does not end it either") +eq(Swarm.mapId(dailySave), "ROUTE_35", "the map is still parked") +check(Swarm.timeEvents(dailySave, 101), "the next day ends it") +check(not Swarm.active(dailySave), "the daily flag is gone") +check(dailySave.swarmMap == nil, "and CheckSwarmFlag cleared the map pair") +eq(Swarm.check(dailySave), 1, "CheckSwarmFlag now answers 1") +eq(Swarm.tables(dailySave, SWARM_ENCOUNTERS, "ROUTE_35"), SWARM_ENCOUNTERS, + "so ROUTE 35 is back on its own encounter table") + +-- A fishing swarm expires on the same clock and takes its flag with it. +local fishExpire = {} +Swarm.setFishing(fishExpire, Swarm.FISH_REMORAID) +Swarm.timeEvents(fishExpire, 200) +Swarm.timeEvents(fishExpire, 201) +eq(Swarm.fishing(fishExpire), Swarm.FISH_NONE, + "the fishing flag is cleared with the rest") + +-- The cache does not carry the swarm or roam tables today. Assert the reader +-- SHAPE rather than the data, so this suite tells the truth either way. +check(Roamers.mapTable(nil) == Roamers.MAPS, + "with no extracted RoamMaps the transcribed table is used") +local FAKE_ROAM = { { map = "ROUTE_29", to = { "ROUTE_30" } } } +check(Roamers.mapTable({ roamMaps = FAKE_ROAM }) == FAKE_ROAM, + "and an extracted encounters.roamMaps takes over the moment it appears") + +end +roamerAndSwarmSuite() + +-- ---- the hook table itself ------------------------------------------------ +-- Every hook the interpreter guards has to exist by the name the VM looks it +-- up under, or the command it belongs to is silently inert. Reading the Vm +-- source is the honest check here: it names each one exactly once. +local vmSource = (function() + local f = io.open("src/script/gen2/Vm.lua", "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end)() +check(vmSource ~= nil, "the VM source is readable") +if vmSource then + local wired = hookWorld():specialHooks() + check(wired.world ~= nil, "specialHooks hands the world to Specials.lua") + local wantHooks = { + "getMapScene", "getTimeOfDay", "gsVersion", "getEngineFlag", + "setEngineFlag", "writeVar", "callAsm", "appear", "moveObject", + "variableSprite", "changeBlock", "earthquake", "warpTo", "warpCheck", + "warpSound", "newLoadMap", "writeCmdQueue", "delCmdQueue", + "setWarpMod", "setBlackoutMap", "setSwarm", + "setWildEncounters", "rollWild", "playMapMusic", "fadeOutMusic", + "dontRestartMapMusic", "hasItem", "takeItem", "getMoney", "setMoney", + "getCoins", "setCoins", "hasPoke", "giveEgg", "getLandmarkName", + "fruitTreeItem", "fruitTreePicked", "fruitTreePick", "addPhoneNumber", + "setSpecialCall", "getSpecialCall", "specials", + } + local worldSource = (function() + local f = io.open("src/world/gen2/World.lua", "r") + if not f then return "" end + local body = f:read("*a") + f:close() + return body + end)() + local unwired = {} + for _, name in ipairs(wantHooks) do + -- `hooks.<name>` is how Vm.new reads it, `<name> =` is how World writes it. + if not (vmSource:find("hooks." .. name, 1, true) + and worldSource:find("\n " .. name .. " = ", 1, true)) then + unwired[#unwired + 1] = name + end + end + eq(table.concat(unwired, ","), "", "every VM hook is wired from World") +end + +-- ---- readvar: the rest of engine/overworld/variables.asm .VarActionTable -- +-- VAR_WEEKDAY and VAR_FACING are covered above (through the hooks table) and +-- in gen2_map_callbacks_test.lua; this pins the other eleven live slots a +-- Gold script reads through readvar. +local Phone = require("src.core.gen2.Phone") +local rv, rvGame = hookWorld() +rv.player.cellX, rv.player.cellY = 7, 9 +rv.map.def.environmentId = 4 -- CAVE (constants/map_data_constants.asm) + +eq(rv:readVar(0x01), 0, "VAR_PARTYCOUNT: an empty party is 0") +rvGame.save.party = { {}, {} } +eq(rv:readVar(0x01), 2, "VAR_PARTYCOUNT reads save.party's length") + +eq(rv:readVar(0x02), 0, "VAR_BATTLERESULT: no battle fought yet reads 0") +rv.lastBattleResult = 1 +eq(rv:readVar(0x02), 1, "VAR_BATTLERESULT reads the last battle's outcome") + +rvGame.save.pokedex = { + caught = { CHIKORITA = true, MAGIKARP = true }, + seen = { CHIKORITA = true, MAGIKARP = true, SHUCKLE = true }, +} +eq(rv:readVar(0x05), 2, "VAR_DEXCAUGHT counts save.pokedex.caught") +eq(rv:readVar(0x06), 3, "VAR_DEXSEEN counts save.pokedex.seen") + +rvGame.save.player.badges = { BOULDERBADGE = true } +rvGame.save.player.kantoBadges = { EARTHBADGE = true, VOLCANOBADGE = true } +eq(rv:readVar(0x07), 3, + "VAR_BADGES counts BOTH badge cases, the way CountSetBits walks wBadges' two bytes") + +eq(rv:readVar(0x08), 0, "VAR_MOVEMENT: PLAYER_NORMAL is wPlayerState 0") +rv.playerState = require("src.world.gen2.FieldMoves").PLAYER_SURF +eq(rv:readVar(0x08), 4, "VAR_MOVEMENT: PLAYER_SURF is wPlayerState 4") + +rv.clockHour = 14 +eq(rv:readVar(0x0a), 14, "VAR_HOUR reads the RTC hour") + +eq(rv:readVar(0x0c), 1, "VAR_MAPGROUP reads the current map's group") +eq(rv:readVar(0x0d), 2, "VAR_MAPNUMBER reads the current map's number") + +eq(rv:readVar(0x0f), 4, "VAR_ENVIRONMENT reads the map's raw environmentId") + +rvGame.save.currentBox = 1 +rvGame.save.boxes = { { {}, {}, {} } } +eq(rv:readVar(0x10), 17, + "VAR_BOXSPACE is MONS_PER_BOX (20) minus the current box's 3 mons") + +rvGame.save.bugContest = { minutes = 15, seconds = 30 } +eq(rv:readVar(0x11), 15, "VAR_CONTESTMINUTES reads wBugContestMinsRemaining") + +eq(rv:readVar(0x12), 7, "VAR_XCOORD reads the player's cell x") +eq(rv:readVar(0x13), 9, "VAR_YCOORD reads the player's cell y") + +eq(rv:readVar(0x14), 0, "VAR_SPECIALPHONECALL: nothing queued reads 0") +Phone.queueSpecialCall(rvGame.save, 3) +eq(rv:readVar(0x14), 3, "VAR_SPECIALPHONECALL reads back what was queued") + +-- VAR_UNOWNCOUNT (0x0e): CountUnown walks wUnownDex, a per-FORM catch list. +-- The port's pokedex only ever flags the species UNOWN as one boolean, so +-- there is no honest count to answer with; it must not silently invent one. +eq(rv:readVar(0x0e), 0, + "VAR_UNOWNCOUNT: blocked upstream, no per-letter Unown tracking to read") + +-- ---- SELECT menu shortcut ------------------------------------------------- +-- engine/overworld/select_menu.asm (SelectMenu / CheckRegisteredItem / +-- UseRegisteredItem) plus RegisterItem (engine/items/pack.asm). This port +-- reaches RegisterItem off the one PACK button left unbound (SELECT on a +-- highlighted row) rather than the cart's own USE/GIVE/TOSS/SEL/QUIT +-- submenu, which is not built. +do +local selWorld, selGame = fakeWorld({}, fakePlayer(5, 5, "down")) +selGame.data = { items = ITEMS, moves = DATA.moves, pokemon = DATA.pokemon } +selGame.save.inventory = { POTION = 1, REPEL = 1, HM_CUT = 1 } + +-- CheckSelectableItem's gate: a TM/HM refuses outright and nothing is set. +eq(selWorld:registerItem("HM_CUT"), false, + "RegisterItem.cant_register: a TM/HM cannot be registered") +check(selGame.save.registeredItem == nil, + "and the refusal never touches the slot") + +-- A selectable item registers. +eq(selWorld:registerItem("POTION"), true, "a plain ITEM pocket item registers") +eq(selGame.save.registeredItem.id, "POTION", "RegisteredItemText names it") +eq(selWorld:registeredItemId(), "POTION", + "CheckRegisteredItem confirms it while the bag still has one") + +-- No field handler is wired for POTION, so SELECT lands on CantUseItem -- +-- ItemsOakWarningText, the same line a busy world answers. +eq(selWorld:useSelectItem(), "cant_use", + "UseRegisteredItem.CantUse: no field handler for this item yet") + +selWorld.battleActive = true +eq(selWorld:useSelectItem(), "nowhere", + "SELECT is refused mid-battle exactly like the PACK's own UseItem") +selWorld.battleActive = nil + +-- Registering something else re-runs CheckRegisteredItem.IsSameItem's swap; +-- REPEL's field handler is useFieldItem's existing UseRepel arm, so SELECT +-- dispatches through the identical path a PACK UseItem would. +eq(selWorld:registerItem("REPEL"), true, "REPEL replaces the registered item") +eq(selWorld:useSelectItem(), "repel_used", + "SELECT on a registered REPEL runs UseRepel through useFieldItem") +eq(selGame.save.inventory.REPEL, nil, + "and the last REPEL leaves the bag the same way a PACK use would") + +-- CheckRegisteredItem.NotEnoughItems: the registered item running out of the +-- bag silently clears the slot rather than holding a stale pointer. +eq(selWorld:registerItem("POTION"), true, "register the POTION again") +selGame.save.inventory.POTION = nil +check(selWorld:registeredItemId() == nil, + "an item gone from the bag no longer answers as registered") +check(selGame.save.registeredItem == nil, + "and the slot itself is cleared, not just skipped") + +-- SelectMenu.NotRegistered: nothing registered prints MayRegisterItemText. +eq(selWorld:useSelectItem(), "not_registered", + "SELECT with nothing registered answers not_registered") + +-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK +-- button this port left unbound. +selGame.save.inventory.POTION = 3 +selGame.input = stubInput() +local selPack = PackMenu.new(selGame, { pocket = "ITEM" }) +selPack.index = 1 +check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test") +selGame.input:press("select") +selPack:update(0) +check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText") +eq(selGame.save.registeredItem.id, "POTION", + "and World:registerItem actually ran") +selGame.input:press("a") +selPack:update(0) +check(selPack.message == nil, "A dismisses the confirmation like any prompt") + +-- SELECT on CANCEL does nothing -- there is no row under the cursor. +selPack.index = selPack:total() +selGame.save.registeredItem = nil +selGame.input:press("select") +selPack:update(0) +check(selPack.message == nil, "SELECT on CANCEL registers nothing") +check(selGame.save.registeredItem == nil, "and the slot stays empty") + +-- CantRegisterText: a TM/HM row refuses from the PACK too. +selGame.save.inventory.HM_CUT = 1 +selPack:rebuild() +local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" }) +tmPack.index = 1 +check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test") +selGame.input:press("select") +tmPack:update(0) +check(tmPack.message ~= nil, "SELECT on the HM still opens a message") +check(selGame.save.registeredItem == nil, + "CantRegisterText: the HM never becomes the registered item") +end + +-- ---- the BICYCLE ---------------------------------------------------------- +-- +-- BikeFunction (engine/events/overworld.asm) end to end: where the bike may be +-- got on, what the queued script does, and the Cycling Road's two flags. +-- +-- Wrapped in a function rather than a `do` block: block locals still count +-- against the main chunk's 200-local ceiling and this file is already near it. +local function bikeChecks() +local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Player = require("src.world.gen2.Player") + +-- .CheckEnvironment: CheckOutdoorMap plus CAVE and GATE by name. +check(Bike.environmentAllows("ROUTE"), "a ROUTE allows the bike") +check(Bike.environmentAllows("TOWN"), "so does a TOWN") +check(Bike.environmentAllows("CAVE"), "and a CAVE") +check(Bike.environmentAllows("GATE"), "and a gatehouse") +check(not Bike.environmentAllows("INDOOR"), "an INDOOR map refuses") +check(not Bike.environmentAllows("DUNGEON"), "and so does a DUNGEON") + +-- The tile half: GetPlayerTilePermission `and $f` has to be LAND_TILE. +check(Bike.canUseHere("ROUTE", COLL_FLOOR), "plain ground is rideable") +check(not Bike.canUseHere("ROUTE", COLL_WATER), "water is not") +-- A door tile IS land in CollisionPermissionTable, which is why the door is +-- not in this list: the tiles the lo nybble rejects are water and walls. +check(not Bike.canUseHere("ROUTE", 0x07), "and neither is a wall") +check(Bike.canUseHere("ROUTE", 0x71), "a door tile is LAND_TILE, so it passes") + +-- .TryBike's four answers. +eq(Bike.tryBike({ state = FieldMoves.PLAYER_NORMAL, environment = "ROUTE", + collision = COLL_FLOOR }), "mount", "walking on a route gets on") +eq(Bike.tryBike({ state = FieldMoves.PLAYER_BIKE, environment = "ROUTE", + collision = COLL_FLOOR }), "dismount", "riding on a route gets off") +eq(Bike.tryBike({ state = FieldMoves.PLAYER_BIKE, environment = "ROUTE", + collision = COLL_FLOOR, alwaysOnBike = true }), "cant_get_off", + "ALWAYS_ON_BIKE refuses the dismount") +check(Bike.tryBike({ state = FieldMoves.PLAYER_SURF, environment = "ROUTE", + collision = COLL_FLOOR }) == nil, "a surfing player falls to .CannotUseBike") +check(Bike.tryBike({ state = FieldMoves.PLAYER_NORMAL, environment = "INDOOR", + collision = COLL_FLOOR }) == nil, "and so does an indoor one") + +-- CheckUpdatePlayerSprite's biking arms. +eq(Bike.mapSetupState(FieldMoves.PLAYER_NORMAL, "ROUTE", true), + FieldMoves.PLAYER_BIKE, ".CheckForcedBiking puts the player on the bike") +eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "INDOOR", false), + FieldMoves.PLAYER_NORMAL, "riding indoors takes them off it") +eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "CAVE", false), + FieldMoves.PLAYER_BIKE, "a cave keeps them on it") +eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "INDOOR", false), + FieldMoves.PLAYER_SURF, + "and with no tile to read, the surf arms are left alone") + +-- Its surfing arms, which is what a save made afloat reloads through. +-- .CheckSurfing reads the tile the player is STANDING on, so it is the water +-- under them that keeps them there. +eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", false, true), + FieldMoves.PLAYER_SURF, "landing on water while surfing keeps surfing") +eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF_PIKA, "ROUTE", false, true), + FieldMoves.PLAYER_SURF_PIKA, "and a PIKACHU rider keeps its own sprite") +eq(Bike.mapSetupState(FieldMoves.PLAYER_NORMAL, "ROUTE", false, true), + FieldMoves.PLAYER_SURF, "a load onto water is a surfing load") +eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", false, false), + FieldMoves.PLAYER_NORMAL, + ".ResetSurfingOrBikingState takes a warp onto land off the Lapras") +eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", true, true), + FieldMoves.PLAYER_BIKE, "and .CheckForcedBiking still wins outright") +eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "ROUTE", false, false), + FieldMoves.PLAYER_BIKE, "a dry outdoor load leaves the bike alone") + +-- .DoStep and .GetDPad. +eq(Bike.stepFrames(FieldMoves.PLAYER_NORMAL, "up", false, 16), 16, + "a walk is a walk") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "up", false, 16), 8, + "STEP_BIKE is half the duration of STEP_WALK") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "up", true, 16), 16, + "downhill and not moving down is back to walking pace") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", true, 16), 8, + "downhill and moving down is the fast step") +eq(Bike.forcedDirection(nil, true), "down", + "no direction held on a downhill map reads as DOWN") +eq(Bike.forcedDirection("up", true), "up", "a held direction still wins") +check(Bike.forcedDirection(nil, false) == nil, "and off the road, nothing") + +-- The Player really does cross the cell in half the frames. +local fast = Player.new(3, 3, "down", nil) +fast.stepFrames = Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", false, + Player.STEP_FRAMES) +eq(fast:tryMove("down", fakeMap({}), nil), "moved", "the bike step starts") +local frames = 0 +while fast.moving and frames < 40 do fast:update() frames = frames + 1 end +eq(frames, 8, "and lands after eight") +eq(fast.cellY, 4, "one cell further down") + +-- The world path. A ROUTE with the player standing on plain ground. +local bikeWorld = fakeWorld({}, fakePlayer(5, 5, "down")) +eq(bikeWorld.playerState, FieldMoves.PLAYER_NORMAL, "the world starts walking") +eq(bikeWorld:useFieldItem("BICYCLE"), "bike_on", + "the BICYCLE is claimed by useFieldItem now") +local mount = bikeWorld.queuedScript +check(mount ~= nil, "and QueueScript left a script behind") +local loadvar +for _, cmd in ipairs(mount) do + if cmd.op == "loadvar" then loadvar = cmd end +end +check(loadvar ~= nil, "Script_GetOnBike is a loadvar") +eq(loadvar.args[1], Bike.VAR_MOVEMENT, "of VAR_MOVEMENT") +eq(loadvar.args[2], Bike.PLAYER_BIKE_ID, "with PLAYER_BIKE") + +-- That loadvar is the whole mount: writeVar is where it lands. +bikeWorld:writeVar(loadvar.args[1], loadvar.args[2]) +eq(bikeWorld.playerState, FieldMoves.PLAYER_BIKE, "and the player is riding") +eq(bikeWorld:readVar(Bike.VAR_MOVEMENT), 1, + "VAR_MOVEMENT reads back PLAYER_BIKE") + +-- Riding, the same item gets off again. +eq(bikeWorld:useFieldItem("BICYCLE"), "bike_off", "the second use dismounts") +local off +for _, cmd in ipairs(bikeWorld.queuedScript) do + if cmd.op == "loadvar" then off = cmd end +end +eq(off.args[2], Bike.PLAYER_NORMAL_ID, "back to PLAYER_NORMAL") + +-- A forced stretch refuses, and refuses WITHOUT touching wPlayerState. +bikeWorld:writeVar(Bike.VAR_MOVEMENT, Bike.PLAYER_BIKE_ID) +bikeWorld:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, true) +eq(bikeWorld:useFieldItem("BICYCLE"), "bike_stuck", + "ALWAYS_ON_BIKE is Script_CantGetOffBike") +for _, cmd in ipairs(bikeWorld.queuedScript) do + check(cmd.op ~= "loadvar", "which never writes VAR_MOVEMENT") +end +eq(bikeWorld.playerState, FieldMoves.PLAYER_BIKE, "so the player still rides") +bikeWorld:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false) + +-- Indoors is .CannotUseBike, which is wFieldMoveSucceeded 0 -- the PACK's own +-- Oak line, not a queued script. +local hallWorld = fakeWorld({}, fakePlayer(5, 5, "down"), nil, nil, + { environment = "INDOOR" }) +eq(hallWorld:useFieldItem("BICYCLE"), "nowhere", "no cycling indoors") +check(hallWorld.queuedScript == nil, "and nothing was queued") + +-- SELECT sets wUsingItemWithSelect, and .CheckIfRegistered swaps in the silent +-- pair: the same state change with no text at all. +local selWorld = fakeWorld({}, fakePlayer(5, 5, "down")) +selWorld.game.save.registeredItem = { id = "BICYCLE" } +eq(selWorld:useSelectItem(), "bike_on", "SELECT on the BICYCLE gets on") +for _, cmd in ipairs(selWorld.queuedScript) do + check(cmd.op ~= "rawtext", "Script_GetOnBike_Register prints nothing") +end +check(selWorld.usingItemWithSelect == nil, + "and wUsingItemWithSelect is cleared behind it") +end +bikeChecks() + +-- ---------------------------------------------------------------- ice slides +-- CheckStandingOnIce / .CheckForced / STEP_ICE. Wrapped in a function for the +-- same 200-local ceiling reason as bikeChecks. +local function iceSlideChecks() + local Player = require("src.world.gen2.Player") + local COLL_WALL = 0x07 + local iceCells = { + [5 * 100 + 3] = COLL_ICE, + [5 * 100 + 4] = COLL_ICE, + [5 * 100 + 5] = COLL_ICE, + [5 * 100 + 6] = COLL_WALL, + } + local slide = fakeWorld(iceCells, Player.new(2, 5, "right")) + slide.player.turnArmed = false + slide.entities = { slide.player } + slide.heldDir = "right" + -- Tap: start the first step, then release. The latch has to carry the rest. + for _ = 1, 2 do slide:step() end + check(slide.player.moving or slide.turningDirection == "right", + "a step onto ice latches FinishFacing") + slide.heldDir = nil + local frames = 0 + while frames < 200 do + slide:step() + frames = frames + 1 + if not slide.player.moving and not slide.turningDirection then break end + end + eq(slide.player.cellX, 5, "the slide rests on the last ice cell") + eq(slide.player.cellY, 5, "same row") + check(slide.turningDirection == nil, "and the bump clears the latch") + + -- Landing on ordinary floor ends the slide without a wall. + local mixed = { + [5 * 100 + 3] = COLL_ICE, + [5 * 100 + 4] = COLL_ICE, + -- (5,5) is COLL_FLOOR by default + } + local stop = fakeWorld(mixed, Player.new(2, 5, "right")) + stop.player.turnArmed = false + stop.entities = { stop.player } + stop.heldDir = "right" + for _ = 1, 2 do stop:step() end + stop.heldDir = nil + frames = 0 + while frames < 200 do + stop:step() + frames = frames + 1 + if not stop.player.moving and not stop.turningDirection then break end + end + eq(stop.player.cellX, 5, "leaving ice onto floor is the rest position") + eq(stop.player.cellY, 5, "same row on the floor landing") + + -- Off ice, a released direction is still a single cell. + local dry = fakeWorld({}, Player.new(2, 5, "right")) + dry.player.turnArmed = false + dry.entities = { dry.player } + dry.heldDir = "right" + for _ = 1, 2 do dry:step() end + dry.heldDir = nil + frames = 0 + while frames < 40 do + dry:step() + frames = frames + 1 + if not dry.player.moving then break end + end + eq(dry.player.cellX, 3, "plain floor still walks one cell per press") +end +iceSlideChecks() + +-- ------------------------------------------------- ledges and one-way walls +-- .TryJump (STEP_LEDGE) and GetMovementPermissions' side-wall arms. Burned +-- Tower B1F's landing pockets drain only through hop-down ledges, and Ice Path +-- 1F's HM07 rest chain only exists because a slide may not glide down onto the +-- COLL_UP_WALL strip -- both were unwalkable while these two mechanics were +-- missing. Wrapped for the 200-local ceiling like the blocks above. +local function ledgeOneWayChecks() + local Player = require("src.world.gen2.Player") + local COLL_WALL, COLL_HOP_DOWN, COLL_HOP_RIGHT = 0x07, 0xa3, 0xa0 + local COLL_UP_WALL = 0xb2 + + local function press(world, dir, budget) + world.heldDir = dir + for _ = 1, 2 do world:step() end + world.heldDir = nil + local frames = 0 + while frames < (budget or 60) do + world:step() + frames = frames + 1 + if not world.player.moving and not world.turningDirection then break end + end + end + + -- Standing on a HOP_DOWN ledge with the tile past it blocked: the refused + -- step becomes a two-cell jump. + local hopCells = { + [5 * 100 + 4] = COLL_HOP_DOWN, -- player stands here + [6 * 100 + 4] = COLL_WALL, -- the refused single step + -- (4,7) floor: the landing + } + local hop = fakeWorld(hopCells, Player.new(4, 5, "down")) + hop.player.turnArmed = false + hop.entities = { hop.player } + press(hop, "down") + eq(hop.player.cellY, 7, "a refused step off a HOP_DOWN ledge jumps two cells") + eq(hop.player.cellX, 4, "straight down") + + -- The ledge only fires for its own facings: HOP_DOWN does nothing for a + -- rightward bump. + local wrongWay = fakeWorld({ + [5 * 100 + 4] = COLL_HOP_DOWN, + [5 * 100 + 5] = COLL_WALL, + }, Player.new(4, 5, "right")) + wrongWay.player.turnArmed = false + wrongWay.entities = { wrongWay.player } + press(wrongWay, "right") + eq(wrongWay.player.cellX, 4, "HOP_DOWN does not jump a rightward bump") + + -- HOP_RIGHT jumps rightward bumps (Gold's $a0 is HOP_RIGHT, not Crystal's + -- HOP_DOWN -- the direction table is the part a wrong port would swap). + local hopRight = fakeWorld({ + [5 * 100 + 4] = COLL_HOP_RIGHT, + [5 * 100 + 5] = COLL_WALL, + }, Player.new(4, 5, "right")) + hopRight.player.turnArmed = false + hopRight.entities = { hopRight.player } + press(hopRight, "right") + eq(hopRight.player.cellX, 6, "$a0 hops rightward") + + -- An occupied landing refuses the jump rather than stacking sprites. + local npc = Player.new(4, 7, "up") + local blockedLanding = fakeWorld({ + [5 * 100 + 4] = COLL_HOP_DOWN, + [6 * 100 + 4] = COLL_WALL, + }, Player.new(4, 5, "down")) + blockedLanding.player.turnArmed = false + blockedLanding.entities = { blockedLanding.player, npc } + press(blockedLanding, "down") + eq(blockedLanding.player.cellY, 5, "no jump onto an occupied landing") + + -- COLL_UP_WALL: Gold's neighbour arm blocks stepping DOWN onto it... + local noEntry = fakeWorld({ + [6 * 100 + 4] = COLL_UP_WALL, + }, Player.new(4, 5, "down")) + noEntry.player.turnArmed = false + noEntry.entities = { noEntry.player } + press(noEntry, "down") + eq(noEntry.player.cellY, 5, "no stepping down onto an UP_WALL") + + -- ...standing on it blocks UP... + local noLeave = fakeWorld({ + [5 * 100 + 4] = COLL_UP_WALL, + }, Player.new(4, 5, "up")) + noLeave.player.turnArmed = false + noLeave.entities = { noLeave.player } + press(noLeave, "up") + eq(noLeave.player.cellY, 5, "standing on an UP_WALL blocks moving up") + + -- ...and sideways ON the strip stays legal. + local alongside = fakeWorld({ + [5 * 100 + 4] = COLL_UP_WALL, + [5 * 100 + 5] = COLL_UP_WALL, + }, Player.new(4, 5, "right")) + alongside.player.turnArmed = false + alongside.entities = { alongside.player } + press(alongside, "right") + eq(alongside.player.cellX, 5, "walking along the strip is an ordinary step") + + -- An ice slide rests on the last ice cell above an UP_WALL strip instead of + -- gliding onto it -- the rest position Ice Path 1F's HM07 chain is built on. + local slideCells = { + [5 * 100 + 3] = COLL_ICE, + [6 * 100 + 3] = COLL_ICE, + [7 * 100 + 3] = COLL_ICE, + [8 * 100 + 3] = COLL_UP_WALL, + } + local slide = fakeWorld(slideCells, Player.new(3, 4, "down")) + slide.player.turnArmed = false + slide.entities = { slide.player } + press(slide, "down", 200) + eq(slide.player.cellY, 7, "the slide rests above the UP_WALL strip") +end +ledgeOneWayChecks() + +-- ---- the IN_GRASS latch and the tileset anim clock ------------------------ +-- UpdateTallGrassFlags only RE-tests while IN_GRASS is already set (engine/ +-- overworld/map_objects.asm:226), so a step INTO grass stays clear until +-- SetTallGrassFlags runs at the far end of it (:247); a step OUT of grass +-- clears on the frame it starts. +local function grassLatchChecks() + local COLL_GRASS = 0x18 + local PlayerMod = require("src.world.gen2.Player") + + local into = fakeWorld({ [6 * 100 + 5] = COLL_GRASS }, + PlayerMod.new(5, 5, "down"), PARTY_ONE) + into.entities = { into.player } + into.noWildEncounters = true + local ip = into.player + ip.turnArmed = false + check(not ip.inGrass, "standing on a path is not IN_GRASS") + eq(into:movePlayer("down"), "moved", "the step into grass is taken") + check(not ip.inGrass, "UpdateTallGrassFlags does not set it mid-step") + check(ip.grassShake, "NormalStep still spawns the rustle on that step") + for _ = 1, PlayerMod.STEP_FRAMES + 1 do into:step() end + check(not ip.moving, "the step lands") + check(ip.inGrass, "SetTallGrassFlags sets it once the step has landed") + + local outOf = fakeWorld({ [5 * 100 + 5] = COLL_GRASS }, + PlayerMod.new(5, 5, "down"), PARTY_ONE) + outOf.entities = { outOf.player } + outOf.noWildEncounters = true + local op = outOf.player + op.turnArmed = false + op.inGrass = true + eq(outOf:movePlayer("down"), "moved", "the step out of grass is taken") + check(not op.inGrass, "the latch clears on the first frame of that step") + check(not op.grassShake, "and nothing rustles on a path") + + -- StepFunction_Reset -> SetTallGrassFlags (map_objects.asm:498-511): National + -- Park's four spinning trainers are IN_GRASS at load (maps/NationalPark.asm:503-506). + local parked = { kind = "stand", cellX = 5, cellY = 5, inGrass = false } + NPC.update(parked, outOf.map) + check(parked.inGrass, "an object spawned in grass is IN_GRASS before it steps") + + -- applymovement's step_* is the same NormalStep as a walk (movement.asm: + -- 434-481), so a scripted step rustles the grass it steps into. + local scripted = fakeWorld({ [6 * 100 + 5] = COLL_GRASS }, + PlayerMod.new(5, 5, "down"), PARTY_ONE) + scripted.entities = { scripted.player } + scripted.noWildEncounters = true + scripted.player.turnArmed = false + check(scripted.player:scriptStep("down"), "the scripted step starts") + scripted:step() + check(scripted.player.grassShake, "a scripted step spawns the rustle too") + + -- JumpStep res IN_GRASS_F and calls neither UpdateTallGrassFlags nor + -- ShakeGrass (movement.asm:741-770); the landing re-latches at the far end. + local hopGrass = fakeWorld({ + [5 * 100 + 5] = 0xa3, -- COLL_HOP_DOWN, the cell hopped FROM + [6 * 100 + 5] = 0x07, -- COLL_WALL, the refused single step + [7 * 100 + 5] = COLL_GRASS, -- the landing + }, PlayerMod.new(5, 5, "down"), PARTY_ONE) + hopGrass.entities = { hopGrass.player } + hopGrass.noWildEncounters = true + local hp = hopGrass.player + hp.turnArmed = false + eq(hopGrass:movePlayer("down"), "moved", "the refused step becomes the hop") + check(hp.jumping, "and it is a jump") + check(not hp.grassShake, "no rustle spawns for the airborne cells") + check(not hp.inGrass, "and IN_GRASS is clear for the whole hop") + for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end + check(hp.inGrass, "the landing tile latches it once the hop ends") +end +grassLatchChecks() + +local function tileAnimChecks() + -- AnimateWaterTile's `wTileAnimationTimer and %110` (engine/tilesets/ + -- tileset_anims.asm:172-174): four frames, each held for two timer ticks. + local seq = {} + for timer = 0, 7 do seq[#seq + 1] = World.waterFrameFor(timer) end + eq(table.concat(seq, ","), "1,1,2,2,3,3,4,4", + "the water strip cycles a frame every two ticks") + eq(World.waterFrameFor(8), World.waterFrameFor(0), + "StandingTileFrame8 wraps the timer at 8") + + -- _AnimateTileset runs one row per frame and DoneTileAnimation wraps the + -- index (tileset_anims.asm:11, :48), so a pass costs `period` frames. + local world = fakeWorld({}, fakePlayer(5, 5, "down")) + world.tilesets = { TILESET_JOHTO = { anim = { period = 11 } } } + for _ = 1, 11 * 2 do world:pollTileAnim() end + eq(world.animTimer, 2, "two passes of an 11-row program tick the timer twice") + eq(world.animClock, 0, "and the frame counter is back at the top") +end +tileAnimChecks() + +S.finish() diff --git a/tests/gen2_x_items_test.lua b/tests/gen2_x_items_test.lua new file mode 100644 index 00000000..523d41d0 --- /dev/null +++ b/tests/gen2_x_items_test.lua @@ -0,0 +1,201 @@ +-- The battle stat items: XItemEffect, XAccuracyEffect, DireHitEffect and +-- GuardSpecEffect (engine/items/item_effects.asm:2079-2146). +-- +-- luajit tests/gen2_x_items_test.lua +-- +-- ROM-free. The four X items raise one stage of their stat (X SPECIAL is +-- SP_ATTACK in Gen 2); X ACCURACY, DIRE HIT and GUARD SPEC set a substatus +-- bit -- accuracy-roll bypass, +1 critical level, Mist -- each refusing a +-- second use without spending the item or the turn. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 x items") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Damage = require("src.battle.gen2.Damage") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", + accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + 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", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local ITEMS = { + X_ATTACK = { id = "X_ATTACK", name = "X ATTACK", pocket = "ITEM" }, + X_DEFEND = { id = "X_DEFEND", name = "X DEFEND", pocket = "ITEM" }, + X_SPEED = { id = "X_SPEED", name = "X SPEED", pocket = "ITEM" }, + X_SPECIAL = { id = "X_SPECIAL", name = "X SPECIAL", pocket = "ITEM" }, + X_ACCURACY = { id = "X_ACCURACY", name = "X ACCURACY", pocket = "ITEM" }, + DIRE_HIT = { id = "DIRE_HIT", name = "DIRE HIT", pocket = "ITEM" }, + GUARD_SPEC = { id = "GUARD_SPEC", name = "GUARD SPEC.", pocket = "ITEM" }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function rolls(queue, fill) + local at = 0 + return function(n) + at = at + 1 + local value = queue[at] + if value == nil then value = fill or 0 end + return value % math.max(1, n or 1) + end +end + +local function newScreen(opts) + opts = opts or {} + Input:init() + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = { player }, + inventory = opts.inventory or { X_ATTACK = 2, DIRE_HIT = 2, + X_ACCURACY = 1, GUARD_SPEC = 1, X_SPECIAL = 1 } } + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = opts.random or rolls({}, 1) }) + local screen = BattleState.new(game, { battle = battle, save = save }) + return screen, battle, player, wild, save +end + +-- ---- the X items raise their stage and spend the turn --------------------- +do + local screen, battle, _, _, save = newScreen() + local turn0 = battle.turn + screen:useItem("X_ATTACK") + eq(battle.stages.player.attack, 1, "X ATTACK raises Attack one stage") + eq(save.inventory.X_ATTACK, 1, "and one left the bag") + eq(battle.turn, turn0 + 1, "the use costs the turn") + + screen:useItem("X_SPECIAL") + eq(battle.stages.player.specialAttack, 1, + "X SPECIAL raises Special ATTACK (data/items/x_stats.asm)") + eq(battle.stages.player.specialDefense, 0, "and not Special Defense") +end + +-- ---- Dire Hit: SUBSTATUS_FOCUS_ENERGY ------------------------------------- +do + local screen, battle, player, _, save = newScreen() + screen:useItem("DIRE_HIT") + eq(battle:volatile(player).focusEnergy, true, + "DIRE HIT sets the focus-energy bit") + eq(save.inventory.DIRE_HIT, 1, "and is spent") + + -- The second one is refused: WontHaveAnyEffect_NotUsedMessage. + local turn1 = battle.turn + screen:useItem("DIRE_HIT") + eq(screen.message, "It won't have any\neffect.", "a re-use is refused") + eq(save.inventory.DIRE_HIT, 1, "without spending the item") + eq(battle.turn, turn1, "or the turn") + + -- And the bit feeds the crit ladder: the roll asks 1-in-8, not 1-in-15. + eq(Damage.criticalLevel({ focusEnergy = true }), 1, "+1 critical level") + local asked + battle.random = function(n) asked = asked or n return 1 end + battle:hitOnce(player, battle.enemy, MOVES.TACKLE) + eq(asked, Damage.criticalChance(1), + "hitOnce rolls the boosted ladder rung for the holder") +end + +-- ---- X Accuracy: the roll bypass ------------------------------------------ +do + -- Roll 96 misses a 95-accuracy move; with the substatus up it cannot. + local screen, battle, player, wild = newScreen({ + random = rolls({}, 96) }) + local before = wild.hp + battle:useMove(player, wild, "TACKLE") + eq(wild.hp, before, "roll 96 misses TACKLE bare") + + screen, battle, player, wild = newScreen({ random = rolls({}, 96) }) + screen:useItem("X_ACCURACY") + eq(battle:volatile(player).xAccuracy, true, "the bit is set") + before = wild.hp + battle:useMove(player, wild, "TACKLE") + check(wild.hp < before, + "and the same roll connects: CheckHit's .XAccuracy skips the roll") +end + +-- ---- Guard Spec: Mist ----------------------------------------------------- +do + local screen, battle, player, wild = newScreen() + screen:useItem("GUARD_SPEC") + eq(battle:volatile(player).mist, true, "GUARD SPEC sets SUBSTATUS_MIST") + + battle:useMove(wild, player, "GROWL") + eq(battle.stages.player.attack, 0, + "the foe's GROWL is turned away by the Mist") + local shielded = false + for _, event in ipairs(battle:takeEvents()) do + if event.text == "CYNDAQUIL's protected by MIST." then shielded = true end + end + check(shielded, "with ProtectedByMistText") + + -- The holder's own drops still land: Mist only answers the foe. + battle:useMove(player, wild, "GROWL") + eq(battle.stages.enemy.attack, -1, + "the holder can still lower the ENEMY's stats") +end + +-- ---- the substatus drops on switch ---------------------------------------- +do + local screen, battle, player = newScreen() + screen:useItem("DIRE_HIT") + eq(battle:volatile(player).focusEnergy, true, "armed") + battle:clearVolatile(player) + eq((player.volatile or {}).focusEnergy, nil, + "a switch clears it with the rest of SUBSTATUS4") +end + +S.finish() diff --git a/tests/gold_flag_names_test.lua b/tests/gold_flag_names_test.lua new file mode 100644 index 00000000..83197e4e --- /dev/null +++ b/tests/gold_flag_names_test.lua @@ -0,0 +1,109 @@ +-- Does pret's EVENT_* numbering actually match the cart's? +-- +-- luajit tests/gold_flag_names_test.lua +-- +-- tests/drivers/gold/flag_names.lua lets the Gold bot route assert +-- postconditions by name (EVENT_BEAT_FALKNER rather than 1213). That is only +-- sound if pret's const_def order in constants/event_flags.asm is the order the +-- retail ROM's scripts use -- and src/import/RomExtractorGen2.lua:3234 says +-- outright that it may not be, which is why extractInitialEvents reads the ids +-- off the cart rather than hardcoding them. +-- +-- That warning is exactly what makes this test necessary: the two orderings +-- being equal is a fact about today's pokegold, not a guarantee. The check +-- replays pret's InitializeEventsScript body name-by-name against the ids the +-- extractor pulled from the ROM for the same script. They cover ids from 37 to +-- 1915 across every const_next region, so agreement across all of them is +-- strong evidence for the whole table, not just its head. +-- +-- If pret renumbers, this goes red and the fix is to regenerate: +-- luajit tools/goldwalk/gen_flags.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gold flag names") +local check, eq = S.check, S.eq + +local names = dofile("tests/drivers/gold/flag_names.lua") + +check(names.events.EVENT_BEAT_FALKNER ~= nil, "EVENT_* table is populated") +check(names.engine.ENGINE_ZEPHYRBADGE ~= nil, "ENGINE_* table is populated") + +-- The eight Johto badges are consecutive bits of wJohtoBadges. Note the order +-- is the BITFIELD's, not the order a player earns them: MINERALBADGE (Jasmine) +-- sits at bit 4 and STORMBADGE (Chuck) at bit 5, while the walkthrough beats +-- Chuck first and Jasmine second (asm-walk sections 08 then 09). A route that +-- assumed "5th gym beaten == bit 5" would gate Surf and Fly on the wrong bit, +-- so pin the layout rather than just the presence. +local badges = { + "ENGINE_ZEPHYRBADGE", "ENGINE_HIVEBADGE", "ENGINE_PLAINBADGE", + "ENGINE_FOGBADGE", "ENGINE_MINERALBADGE", "ENGINE_STORMBADGE", + "ENGINE_GLACIERBADGE", "ENGINE_RISINGBADGE", +} +for i = 2, #badges do + eq(names.engine[badges[i]], names.engine[badges[i - 1]] + 1, + badges[i] .. " follows " .. badges[i - 1]) +end + +-- --------------------------------------------------------------------------- +-- The cart cross-check +-- --------------------------------------------------------------------------- +-- Needs both a Gold cache (for the ROM-derived ids) and a pokegold checkout +-- (for the names). Either missing degrades to the fixture checks above rather +-- than failing, the same way tests/gen2_map_callbacks_test.lua handles it. + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local POKEGOLD = os.getenv("POKEGOLD") or "../pokegold" + +local initialPath = cache .. "/data/generated/initial_events.lua" +local stdScripts = POKEGOLD .. "/engine/events/std_scripts.asm" + +local function readable(path) + local fh = io.open(path, "r") + if not fh then return false end + fh:close() + return true +end + +if not readable(initialPath) then + check(true, "gold cache absent : name table checked, cart cross-check SKIPPED") + S.finish() + return +end +if not readable(stdScripts) then + check(true, "pokegold absent : name table checked, cart cross-check SKIPPED") + S.finish() + return +end + +local Flags = dofile("tools/goldwalk/flags.lua") +local romIds = assert(loadfile(initialPath))().flags +local pretNames = Flags.setEventsOf(stdScripts, "InitializeEventsScript") + +eq(#pretNames, #romIds, + "InitializeEventsScript sets the same number of flags in pret and the cart") + +local mismatches, span = {}, { lo = math.huge, hi = -math.huge } +for i, name in ipairs(pretNames) do + local want, got = names.events[name], romIds[i] + if got then + span.lo, span.hi = math.min(span.lo, got), math.max(span.hi, got) + end + if want ~= got then + mismatches[#mismatches + 1] = + ("#%d %s pret=%s cart=%s"):format(i, name, tostring(want), tostring(got)) + end +end + +if #mismatches > 0 then + -- Print the first few: the whole list is one-per-flag noise, and the head is + -- enough to see whether it is a wholesale shift or a single retired const. + for i = 1, math.min(5, #mismatches) do print(" " .. mismatches[i]) end +end +eq(#mismatches, 0, + ("pret numbering == cart numbering across ids %d..%d") + :format(span.lo, span.hi)) + +S.finish() diff --git a/tests/gold_route_validate_test.lua b/tests/gold_route_validate_test.lua new file mode 100644 index 00000000..c4f4c92d --- /dev/null +++ b/tests/gold_route_validate_test.lua @@ -0,0 +1,224 @@ +-- Does the Gold bot route describe a world that actually exists? +-- +-- luajit tests/gold_route_validate_test.lua +-- +-- tests/drivers/gold/route.lua is hand-authored from the asm-walk documents, +-- which were themselves written by reading a pokegold checkout. Two independent +-- transcriptions sit between the cart and this route, and every coordinate in it +-- is a chance for one of them to have slipped. Finding that out four hours into +-- a run -- when the bot is stood in a doorway that is one cell left of where the +-- route says -- is the failure mode this test exists to prevent. Everything it +-- checks costs milliseconds and is checked against the EXTRACTED CACHE, i.e. +-- against the same data the running bot will path over. +-- +-- Checked per row: the op is known and carries the fields that op needs; the map +-- constant exists; a `warp` cell really is a warp and really leads to `to`; an +-- `edge` direction really is a connection to `to`; a `talk`/`battle` target +-- really has an object, bg event or warp on it; and every `expect` names a real +-- EVENT_*/ENGINE_* flag rather than a plausible-looking typo. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gold route validation") +local check, eq = S.check, S.eq + +local route = dofile("tests/drivers/gold/route.lua") +local names = dofile("tests/drivers/gold/flag_names.lua") + +check(#route > 0, "the route has rows at all") + +-- --------------------------------------------------------------------------- +-- Shape checks : no cache needed +-- --------------------------------------------------------------------------- + +local REQUIRED = { + travel = {}, + walk = { "x", "y" }, + warp = { "x", "y", "to" }, + edge = { "dir", "to" }, + talk = { "x", "y" }, + battle = {}, -- x/y optional: a `talk` battle names its NPC, + -- a sight-line one names the cell to walk onto + grind = { "level" }, + heal = {}, + teach = { "move" }, + field = { "move", "x", "y" }, + settle = {}, + catch = { "species" }, + manual = { "why" }, + check = { "expect" }, + -- `wander` walks about on one map waiting for something to be DELIVERED (a + -- phone call), so it names no coordinate and its oracle may be a flag being + -- cleared rather than set. + wander = {}, + -- `push` names the boulder's cell and which way to shove it; `press` is a + -- raw direction list for an ice floor, where no cell can be aimed at. + push = { "x", "y", "dir" }, + press = { "dirs" }, + -- `buy` names a Mart's map and what to take off the shelf; the price and the + -- money check are the engine's. + buy = { "item" }, + -- `elevator` reads the panel at (x, y), picks `floor` off the scrolling + -- menu, and leaves through the door at (doorX, doorY); `to` is the floor + -- the door must land on. + elevator = { "x", "y", "floor", "doorX", "doorY", "to" }, +} + +local seenIds = {} +local badShape = 0 +for i, row in ipairs(route) do + local where = ("row %d (%s)"):format(i, tostring(row.id)) + if not row.id then + badShape = badShape + 1 + print(" " .. where .. ": missing id") + elseif seenIds[row.id] then + badShape = badShape + 1 + print(" " .. where .. ": duplicate id") + end + seenIds[row.id or i] = true + + local spec = REQUIRED[row.op] + if not spec then + badShape = badShape + 1 + print((" %s: unknown op %q"):format(where, tostring(row.op))) + else + for _, field in ipairs(spec) do + if row[field] == nil then + badShape = badShape + 1 + print((" %s: op %s needs %s"):format(where, row.op, field)) + end + end + end + if not row.map then + badShape = badShape + 1 + print(" " .. where .. ": missing map") + end + for _, field in ipairs({ "expect", "expectClear" }) do + local name = row[field] + if name and not (names.events[name] or names.engine[name]) then + badShape = badShape + 1 + print((" %s: %s %q is not a known EVENT_*/ENGINE_* name") + :format(where, field, tostring(name))) + end + end +end +eq(badShape, 0, "every row is well-formed and every expect names a real flag") + +-- --------------------------------------------------------------------------- +-- Geometry checks : need a Gold cache +-- --------------------------------------------------------------------------- + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : shape checked, geometry SKIPPED") + S.finish() + return +end +mf:close() + +local maps = assert(loadfile(mapsPath))() + +-- Extracted connections are keyed by compass word; the route writes some rows +-- with the walk direction instead, because that is how the asm-walk phrases +-- them ("walk north out of the map"). Accept both spellings rather than make +-- the route remember which map used which. +local DIR_ALIAS = { + up = "north", down = "south", left = "west", right = "east", + north = "north", south = "south", west = "west", east = "east", +} + +local function connectionOf(def, dir) + local conns = def.connections or {} + local want = DIR_ALIAS[dir] or dir + for key, conn in pairs(conns) do + local norm = DIR_ALIAS[key] or key + if norm == want then return conn end + end + return nil +end + +local function warpAt(def, x, y) + for i, w in ipairs(def.warps or {}) do + if w.x == x and w.y == y then return w, i end + end + return nil +end + +-- Anything a `talk` can legitimately aim at: an NPC/item-ball object, a sign or +-- hidden item, or -- for the handful of rows that talk to something standing on +-- a door -- a warp. +local function targetAt(def, x, y) + for _, o in ipairs(def.objects or {}) do + if o.x == x and o.y == y then return "object" end + end + for _, b in ipairs(def.bgEvents or {}) do + if b.x == x and b.y == y then return "bg" end + end + if warpAt(def, x, y) then return "warp" end + return nil +end + +local problems = 0 +local function bad(row, msg) + problems = problems + 1 + print((" %s [%s %s]: %s") + :format(tostring(row.id), row.op, tostring(row.map), msg)) +end + +for _, row in ipairs(route) do + local def = maps[row.map] + if not def then + bad(row, "no such map in the cache") + else + -- Every explicit cell must be inside the map. Cells are 2x the block + -- dimensions (src/world/gen2/Map.lua). + if row.x and row.y then + local w, h = def.width * 2, def.height * 2 + if row.x < 0 or row.y < 0 or row.x >= w or row.y >= h then + bad(row, ("(%d,%d) is outside the %dx%d cell grid") + :format(row.x, row.y, w, h)) + end + end + + if row.op == "warp" then + local warp = warpAt(def, row.x, row.y) + if not warp then + bad(row, ("no warp at (%d,%d)"):format(row.x, row.y)) + elseif warp.destMap ~= row.to then + bad(row, ("warp at (%d,%d) leads to %s, route says %s") + :format(row.x, row.y, tostring(warp.destMap), tostring(row.to))) + end + + elseif row.op == "edge" then + local conn = connectionOf(def, row.dir) + if not conn then + bad(row, ("no %s connection"):format(tostring(row.dir))) + elseif conn.mapId ~= row.to then + bad(row, ("%s connection leads to %s, route says %s") + :format(tostring(row.dir), tostring(conn.mapId), tostring(row.to))) + end + + elseif row.op == "talk" or (row.op == "battle" and row.talk) then + if not targetAt(def, row.x, row.y) then + bad(row, ("nothing to talk to at (%d,%d)"):format(row.x, row.y)) + end + end + end +end + +eq(problems, 0, "every route coordinate matches the extracted cache") + +-- A route that reached the last badge but never named the map it happens on is +-- a route that quietly stopped short. +local sawOlivineGym = false +for _, row in ipairs(route) do + if row.map == "OLIVINE_GYM" then sawOlivineGym = true end +end +check(sawOlivineGym, "the route reaches OLIVINE_GYM") + +S.finish() diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index 569479d2..f5384339 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -196,6 +196,13 @@ do end -- ------- game_version against the engine +-- +-- Stamped, because the working tree carries the "0.0.0-dev" placeholder and +-- Loader:_validate skips the range check against it on purpose (a placeholder +-- sorts below every release, so a checkout would refuse every mod that names a +-- floor). What is under test here is the check a SHIPPED build runs. +local realEngine = Version.engine +Version.engine = "1.4.0" local versionLoader = Loader.new({ fs = memfs({ ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }), ["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end", @@ -223,6 +230,17 @@ check(shelvedLoader:load({}) == true, check(statusById(shelvedLoader).future.state == "disabled", "a switched-off mod reports as disabled, not as invalid") +-- and the placeholder itself: a dev checkout must not refuse a mod that names +-- a floor, because the number it would be judged against is not a release +Version.engine = realEngine +local devLoader = Loader.new({ fs = memfs({ + ["mods/floored/manifest.json"] = manifestJson("floored", { game_version = '">=0.1.0 <2.0.0"' }), + ["mods/floored/main.lua"] = NOOP, +}) }) +check(devLoader:load({}) == true, "a dev checkout loads a mod that names a floor") +check(statusById(devLoader).floored.state == "loaded", + "the 0.0.0-dev placeholder is not a compatibility statement") + -- ------- conflicts refuse to co-enable local conflictLoader = Loader.new({ fs = memfs({ ["mods/noon/manifest.json"] = manifestJson("noon", { conflicts = '["dusk"]' }), diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 4deb2640..95478dee 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -1124,4 +1124,68 @@ do check(merged.default == "ROUTE", "including the fallthrough") end +-- ------- mod.world, the Gen 2 arm +-- +-- src/world/gen2/WorldAPI.lua is the other half of the same facade name. The +-- three verbs tested here were reported as unsupported and are the ones a +-- placement/encounter mod actually needs: spawn an actor, drive a wild battle, +-- and get a NAMED refusal for anything the Gen 2 arm has no home for. +do + local Gen2Api = require("src.world.gen2.WorldAPI") + + -- a World stand-in: the fields the facade reads, nothing else + local maps = { NEW_BARK_TOWN = { id = "NEW_BARK_TOWN", objects = { + { index = 0, name = "ELM", sprite = "SPRITE_ELM", x = 1, y = 1 }, + } } } + local rebuilt, battled = 0, nil + local world = { + map = { id = "NEW_BARK_TOWN", def = maps.NEW_BARK_TOWN }, + maps = maps, + npcPool = {}, + player = { cellX = 3, cellY = 4, facing = "down" }, + -- the two tables src/battle/gen2/Mon.lua reads to build a wild mon + game = { save = { party = {} }, data = { + moves = {}, + growthRates = {}, + pokemon = { FIXMON = { name = "FIXMON", types = { "NORMAL" }, + baseStats = { hp = 50, attack = 50, defense = 50, speed = 50, + specialAttack = 50, specialDefense = 50 }, + growthRate = "MEDIUM_FAST", learnset = {} } }, + } }, + rebuildPeople = function() rebuilt = rebuilt + 1 end, + startBattle = function(_, opts, onDone) + battled = opts + if onDone then onDone("win") end + end, + } + local World2 = require("src.world.gen2.World") + world.addRuntimeObject = World2.addRuntimeObject + world.removeRuntimeObject = World2.removeRuntimeObject + local api = Gen2Api.new({ world = world }, "tester") + + local id = api:spawnNpc("NEW_BARK_TOWN", { sprite = "SPRITE_LASS", x = 5, y = 5 }) + check(id == "NEW_BARK_TOWN_obj_1", "Gen 2 spawnNpc returns a handle id") + check(#maps.NEW_BARK_TOWN.objects == 2, "and the object joins the map def") + check(maps.NEW_BARK_TOWN.objects[2].runtime == true, "marked runtime") + check(maps.NEW_BARK_TOWN.objects[2].owner == "tester", "and attributed") + check(rebuilt == 1, "the active map's people are rebuilt around it") + check(api:removeNpc(id) == true, "Gen 2 removeNpc drops it again") + check(#maps.NEW_BARK_TOWN.objects == 1, "and the map def is back to vanilla") + local gone, why = Gen2Api.new({ world = world }, "intruder") + :removeNpc("NEW_BARK_TOWN_obj_9") + check(gone == nil and why ~= nil, "an unknown runtime object is refused") + + -- queueScript: the wild-battle verb, which is how a spawn mod starts a fight + local ok = api:queueScript({ { "start_battle", "wild", "FIXMON", 7 } }) + check(ok == true, "Gen 2 queueScript runs a start_battle row") + check(battled ~= nil and battled.wild ~= nil, "and Gold's own startBattle ran") + check(battled.wild.level == 7, "with the row's level") + + -- and a verb with no Gen 2 home is refused BY NAME before anything runs + local refused, reason = api:queueScript({ { "text", "hi" }, { "wait", 30 } }) + check(refused == nil, "a queue with an unsupported verb is refused whole") + check(reason ~= nil and reason:find("wait", 1, true) ~= nil, + "and the refusal names the verb") +end + S.finish() diff --git a/tests/modkit/cases/gen2_intro_seams.lua b/tests/modkit/cases/gen2_intro_seams.lua new file mode 100644 index 00000000..9b0c77bf --- /dev/null +++ b/tests/modkit/cases/gen2_intro_seams.lua @@ -0,0 +1,239 @@ +-- Gold's intro seams: the Oak speech under Gen 1's names, and the GS boot +-- cinema under its own. +-- +-- Two different claims are being made here, and they are deliberately not the +-- same claim: +-- +-- * src/ui/gen2/OakSpeech.lua reuses `intro.oak_speech.build` and the four +-- `intro.oak_speech.*` events VERBATIM -- same names, same payload keys, +-- same moments -- because Gold has a real Oak speech and a mod written for +-- Red's must land on Gold's without a second listener. +-- * the copyright card, the GAME FREAK splash, the attract movie and the +-- Ho-Oh title have NO Gen 1 counterpart, so they take new `intro.boot.*` +-- names rather than borrowing one that would then mean two things. +-- +-- Both halves are also the parity case the no-mod gate wants: with nobody +-- subscribed every one of these sites must be inert. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local CopyrightSplash = require("src.ui.gen2.CopyrightSplash") +local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") +local OakSpeech = require("src.ui.gen2.OakSpeech") +local TitleState = require("src.ui.gen2.TitleState") + +-- The shared names, spelled out so a rename has to come through this file. +local SPEECH_EVENTS = { + "intro.oak_speech.started", + "intro.oak_speech.step", + "intro.oak_speech.answered", + "intro.oak_speech.finished", +} +-- ...and the Gen 2-only ones. +local BOOT_EVENTS = { + "intro.boot.copyright", + "intro.boot.gamefreak", + "intro.boot.movie", + "intro.boot.movie_ended", + "intro.boot.title", +} + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function logged(fragment) + for _, line in ipairs(Logger.history or {}) do + if line:find(fragment, 1, true) then return true end + end + return false +end + +local function fakeGame() + return { data = {}, save = { player = {} } } +end + +-- ------- 1. no-mod parity: every site cold + +do + Runtime.events, Runtime.hooks = savedEvents, savedHooks + for _, name in ipairs(SPEECH_EVENTS) do + T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold") + end + for _, name in ipairs(BOOT_EVENTS) do + T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold") + end + T.eq(Runtime.wantsHook("intro.oak_speech.build"), false, + "no wrapper leaves intro.oak_speech.build cold") + + -- the four boot cards still run their moment with nobody listening + CopyrightSplash.new(fakeGame(), {}):enter() + GameFreakPresents.new(fakeGame(), {}):enter() + local movie = GoldSilverIntro.new(fakeGame(), {}) + movie:enter() + movie:finish() + TitleState.new(fakeGame(), {}):enter() + T.check(true, "the boot cinema runs unsubscribed") +end + +-- ------- 2. the Oak speech step list + +do + local speech = OakSpeech.new(fakeGame(), {}) + local steps = OakSpeech.defaultSteps(speech) + T.eq(#steps, 9, "Gold's vanilla speech has nine beats") + T.eq(steps[1].id, "init_clock", + "the speech opens on the farcall InitClock beat") + T.eq(steps[#steps].id, "shrink", "and ends on ShrinkPlayer") + + -- the anchors a Gen 1 mod already knows how to aim at + local byId = {} + for index, step in ipairs(steps) do byId[step.id] = index end + for _, id in ipairs({ "oak_welcome", "demo_mon", "world_spiel", + "ask_player_name", "name_player", "legend", + "shrink" }) do + T.check(byId[id] ~= nil, + "Gold keeps Gen 1's step anchor `" .. id .. "`") + end + T.check(byId.oak_welcome < byId.demo_mon + and byId.demo_mon < byId.name_player + and byId.name_player < byId.shrink, + "and keeps them in Gen 1's order") +end + +-- ------- 3. intro.oak_speech.build, the hook Gen 1 already ships + +do + Runtime.events, Runtime.hooks = Events.new(), Hooks.new() + local speech = OakSpeech.new(fakeGame(), {}) + + Runtime.hooks:wrap("intro.oak_speech.build", function(nextFn, steps, sp) + steps = nextFn(steps, sp) + table.insert(steps, 3, { id = "extra_q", kind = "choice", + saveKey = "mood", choices = { "FINE", "TIRED" } }) + return steps + end, 0, "fixture") + local built = speech:buildSteps() + T.eq(built[3].id, "extra_q", + "intro.oak_speech.build can insert a beat into Gold's speech") + T.eq(built[4].id, "demo_mon", "later vanilla beats shift down") + Runtime.hooks:removeOwner("fixture") + + Runtime.hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad") + built = speech:buildSteps() + T.eq(built[1].id, "init_clock", + "a non-table intro.oak_speech.build result degrades to vanilla") + T.check(logged("intro.oak_speech.build returned"), + "and the degrade is logged, as it is under Gen 1") + Runtime.hooks:removeOwner("bad") +end + +-- ------- 4. the four lifecycle events, with the Gen 1 payload keys + +do + Runtime.events, Runtime.hooks = Events.new(), Hooks.new() + local speech = OakSpeech.new(fakeGame(), {}) + + -- Replace the beats with two that need no stack, so the lifecycle can be + -- driven headlessly: `fn` is the escape hatch a build wrapper gets. + Runtime.hooks:wrap("intro.oak_speech.build", function() + return { + { id = "probe_one", kind = "fn", + run = function(sp, done) sp:recordAnswer({ id = "probe_one", + saveKey = "mood" }, 2, "TIRED", "TIRED") done() end }, + { id = "probe_two", kind = "fn", run = function(_, done) done() end }, + } + end, 0, "fixture") + + local started, stepped, answered, finished = nil, {}, nil, nil + Runtime.events:on("intro.oak_speech.started", function(ev) + started = ev + end, 0, "fixture") + Runtime.events:on("intro.oak_speech.step", function(ev) + stepped[#stepped + 1] = ev + end, 0, "fixture") + Runtime.events:on("intro.oak_speech.answered", function(ev) + answered = ev + end, 0, "fixture") + Runtime.events:on("intro.oak_speech.finished", function(ev) + finished = ev + end, 0, "fixture") + + speech:enter() + + T.check(started ~= nil and started.speech == speech + and type(started.steps) == "table", + "intro.oak_speech.started carries { speech, steps }") + T.eq(#stepped, 2, "intro.oak_speech.step fires once per beat") + T.eq(stepped[1].index, 1, "and carries the 1-based index") + T.eq(stepped[1].step.id, "probe_one", "and the step itself") + T.check(answered ~= nil and answered.saveKey == "mood" + and answered.value == "TIRED" and answered.index == 2 + and answered.label == "TIRED" and answered.speech == speech, + "intro.oak_speech.answered carries Gen 1's six keys") + T.eq(speech.answers.mood, "TIRED", "and the answer is stored on the speech") + T.check(finished ~= nil and finished.answers == speech.answers, + "intro.oak_speech.finished carries the answer table") + + -- #308's guard, ported: a second finish is not a second speech. + local firstFinish = finished + finished = nil + speech:finish() + T.eq(finished, nil, "finished fires exactly once per speech") + T.check(firstFinish ~= nil, "and it did fire the first time") + + Runtime.events:removeOwner("fixture") + Runtime.hooks:removeOwner("fixture") +end + +-- ------- 5. the GS boot cinema's own names + +do + Runtime.events, Runtime.hooks = Events.new(), Hooks.new() + local seen = {} + for _, name in ipairs(BOOT_EVENTS) do + Runtime.events:on(name, function(ev) seen[name] = ev end, 0, "fixture") + end + + local game = fakeGame() + CopyrightSplash.new(game, {}):enter() + T.check(seen["intro.boot.copyright"] ~= nil, + "the copyright card announces itself") + T.check(seen["intro.boot.copyright"].screen ~= nil, + "with the screen in the payload") + + GameFreakPresents.new(game, {}):enter() + T.check(seen["intro.boot.gamefreak"] ~= nil, + "the GAME FREAK splash announces itself") + + local movie = GoldSilverIntro.new(game, {}) + movie:enter() + T.check(seen["intro.boot.movie"] ~= nil, "the attract movie announces itself") + movie:skip() + T.check(seen["intro.boot.movie_ended"] ~= nil, + "and announces its end") + T.eq(seen["intro.boot.movie_ended"].skipped, true, + "intro.boot.movie_ended reports a button skip") + + -- a movie that plays out reports the other answer + seen["intro.boot.movie_ended"] = nil + local watched = GoldSilverIntro.new(game, {}) + watched:enter() + watched:finish() + T.eq(seen["intro.boot.movie_ended"].skipped, false, + "and reports a movie that was watched to the end") + + TitleState.new(game, {}):enter() + T.check(seen["intro.boot.title"] ~= nil, "the title screen announces itself") + + Runtime.events:removeOwner("fixture") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks + +T.finish("gen2_intro_seams") diff --git a/tests/modkit/cases/gen2check.lua b/tests/modkit/cases/gen2check.lua new file mode 100644 index 00000000..176bd287 --- /dev/null +++ b/tests/modkit/cases/gen2check.lua @@ -0,0 +1,549 @@ +-- T4: `modkit gen2check`, the tool that answers whether a mod runs on a Gen 2 +-- game and how far it gets (tools/modkit.py, MK4xx). +-- +-- Every fixture below is generated FROM the engine: the module with no +-- adapter, the member the coverage table calls absent and the screen id with +-- a Gen 2 twin are all read out of src/mods/Gen2Compat.lua and +-- src/ui/Screens.lua at run time. A case that spelled them out would pass +-- against a stale adapter, which is the one thing this tool must never do. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local isWindows = package.config:sub(1, 1) == "\\" + +-- luajit's pclose drops the exit status, so the shell reports it in-band +-- (tests/modkit_tests.lua uses the same shape) +local function run(command) + if isWindows then + command = 'cmd /v:on /c "' .. command .. ' 2>&1 & echo EXIT:!errorlevel!"' + else + command = command .. ' 2>&1; echo "EXIT:$?"' + end + local pipe = io.popen(command) + local output = pipe:read("*a") + pipe:close() + return output, tonumber(output:match("EXIT:(%d+)%s*$")) or -1 +end + +local python = isWindows and "python" or "python3" +if not run(python .. " --version"):find("Python 3", 1, true) then + T.check(true, "python3 is absent: gen2check not exercised") + T.finish("gen2check") + return +end + +-- ------- what to write the fixtures against, taken from the engine + +local Gen2Compat = require("src.mods.Gen2Compat") +local Screens = require("src.ui.Screens") + +local names = {} +for name in pairs(Gen2Compat.ADAPTERS) do names[#names + 1] = name end +table.sort(names) + +-- written onto the running game and never onto the module table, which is +-- what MK410 is about; read from the Gen 1 source, as the tool does +local function instanceField(module, member) + local handle = io.open(module:gsub("%.", "/") .. ".lua", "r") + if not handle then return false end + local body = handle:read("*a") + handle:close() + if not body:find("self%." .. member .. "%s*=[^=]") then return false end + if body:find("function%s+%w+[%.:]" .. member .. "%s*%(") then return false end + for owner in body:gmatch("[\n%s]([%a_][%w_]*)%." .. member .. "%s*=[^=]") do + if owner ~= "self" then return false end + end + return true +end + +-- an adapted alias plus a member it backs: the shape a mod may hold and read +local aliasName, backedMember +-- a member the adapter deliberately does not carry, whatever module it is on +local absentName, absentMember +-- a facade member the Gen 1 module only writes onto the running game, which +-- an entry chunk cannot have on a Gen 2 boot +local liveName, liveMember +for _, name in ipairs(names) do + local row = Gen2Compat.coverage and Gen2Compat.coverage(name) + local members = row and row.members or {} + local sorted = {} + for member in pairs(members) do sorted[#sorted + 1] = member end + table.sort(sorted) + for _, member in ipairs(sorted) do + if not member:find("[%.%s]") then + if members[member] == "absent" and not absentMember then + absentName, absentMember = name, member + end + if members[member] == "backed" and row.kind == "alias" + and not backedMember then + aliasName, backedMember = name, member + end + if members[member] == "backed" and row.kind == "facade" + and not liveMember and instanceField(name, member) then + liveName, liveMember = name, member + end + end + end +end +T.check(aliasName ~= nil, "the adapter aliases at least one module") +T.check(absentMember ~= nil, "the coverage table names at least one absent " + .. "member") + +-- A member that really closes over a local, and a local declared in the same +-- Gen 2 file that it does NOT close over: the two answers the upvalue check +-- has to tell apart, both read out of the engine. +local landName, landMember, landUpvalue +local strayUpvalue +for _, name in ipairs(names) do + local row = Gen2Compat.coverage and Gen2Compat.coverage(name) + if row and row.kind == "alias" and row.target and not landMember then + local ok, adapter = pcall(Gen2Compat.resolve, name) + local sorted = {} + if ok and type(adapter) == "table" then + for member in pairs(adapter) do sorted[#sorted + 1] = member end + end + table.sort(sorted) + for _, member in ipairs(sorted) do + local value = adapter[member] + if type(value) == "function" and not landMember then + local held, index = {}, 1 + while true do + local up = debug.getupvalue(value, index) + if not up then break end + held[up] = true + if up:match("^%a[%w_]*$") and up ~= "_ENV" and not landUpvalue then + landName, landMember, landUpvalue = name, member, up + end + index = index + 1 + end + if landUpvalue then + local handle = io.open(row.target:gsub("%.", "/") .. ".lua", "r") + local body = handle and handle:read("*a") or "" + if handle then handle:close() end + for local_ in body:gmatch("\nlocal%s+([%a_][%w_]*)") do + if not held[local_] and not strayUpvalue then + strayUpvalue = local_ + end + end + end + end + end + end +end + +-- a Gen 1 screen id whose Gen 2 twin carries the prefix +local twin +for _, id in ipairs(Screens.GEN2_IDS) do + local bare = id:gsub("^Gen2", "") + local handle = io.open("src/ui/" .. bare .. ".lua", "r") + if handle then handle:close() end + if not twin and handle then twin = bare end +end +T.check(twin ~= nil, "at least one screen id exists in both generations") + +-- ------- fixtures on disk, because the tool reads a mod directory + +local tmp = os.tmpname() +os.remove(tmp) +local root = (isWindows and tmp:gsub("\\", "/") or tmp) .. "_gen2check" +run((isWindows and "mkdir " or "mkdir -p ") .. ("%q"):format(root)) + +local function write(dir, files) + run((isWindows and "mkdir " or "mkdir -p ") + .. ("%q"):format(root .. "/" .. dir)) + for name, body in pairs(files) do + local handle = assert(io.open(root .. "/" .. dir .. "/" .. name, "w")) + handle:write(body) + handle:close() + end + return root .. "/" .. dir +end + +local function manifest(id, extra) + return ('{ "id": "%s", "name": "%s", "version": "1.0.0", "api": 2, ' + .. '"entry": "main.lua", "description": "gen2check fixture"%s }') + :format(id, id, extra or "") +end + +local GEN2 = ', "gen2compat": true, "games": ["gen1", "gen2"]' + +local clean = write("gen2_clean", { + ["manifest.json"] = manifest("gen2_clean", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +local held = M.%s +mod.exports.held = held ~= nil +]]):format(aliasName, backedMember), +}) + +local unflagged = write("gen2_unflagged", { + ["manifest.json"] = manifest("gen2_unflagged"), + ["main.lua"] = "local mod = ...\n", +}) + +local absent = write("gen2_absent", { + ["manifest.json"] = manifest("gen2_absent", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +M.%s(mod) +]]):format(absentName, absentMember), +}) + +local patterns = write("gen2_patterns", { + ["manifest.json"] = manifest("gen2_patterns", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +local function patchUpvalue(fn, name, value) + local i = 1 + while true do + local found = debug.getupvalue(fn, i) + if not found then return false end + if found == name then debug.setupvalue(fn, i, value) return true end + i = i + 1 + end +end +patchUpvalue(M.%s, "gen2checkNoSuchUpvalue", 1) +mod.events:on("screen.pushed", function(ev) + if ev.screenId == "%s" then mod.exports.saw = true end +end) +]]):format(aliasName, backedMember, twin), +}) + +local held = liveMember and write("gen2_held", { + ["manifest.json"] = manifest("gen2_held", GEN2), + ["main.lua"] = ([[ +local mod = ... +local G = require("%s") +local captured = G.%s +mod.events:on("game.ready", function() + mod.exports.live = G.%s ~= nil +end) +mod.exports.captured = captured ~= nil +]]):format(liveName, liveMember, liveMember), +}) + +-- every shape the scan has to follow to reach one unbacked member: a wrapper +-- the mod requires through, an inline require, a bracket index and a local hop +local reaches = write("gen2_reaches", { + ["manifest.json"] = manifest("gen2_reaches", GEN2), + ["main.lua"] = ([[ +local mod = ... +local function tryRequire(path) + local ok, m = pcall(require, path) + if ok then return m end +end +local W = tryRequire("%s") +W.%s(mod) +require("%s").%s(mod) +local B = require("%s") +B["%s"](mod) +local H = B +H.%s(mod) +]]):format(absentName, absentMember, absentName, absentMember, + absentName, absentMember, absentMember), +}) + +-- a module name this scan cannot tie to a require: it must say so, never +-- pass the file in silence +local opaque = write("gen2_opaque", { + ["manifest.json"] = manifest("gen2_opaque", GEN2), + ["main.lua"] = ([[ +local mod = ... +local CANDIDATES = { "%s" } +mod.exports.names = CANDIDATES +]]):format(absentName), +}) + +-- an upvalue that is really an upvalue of the resolved function, and one that +-- is only a file-local of the same module +local lands = landUpvalue and write("gen2_lands", { + ["manifest.json"] = manifest("gen2_lands", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +debug.setupvalue(M.%s, "%s", nil) +]]):format(landName, landMember, landUpvalue), +}) + +local stray = strayUpvalue and write("gen2_stray", { + ["manifest.json"] = manifest("gen2_stray", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +debug.setupvalue(M.%s, "%s", nil) +]]):format(landName, landMember, strayUpvalue), +}) + +-- the screen id on a line carrying no screen-shaped word, which is the +-- example the docs give +local screenId = write("gen2_screen_id", { + ["manifest.json"] = manifest("gen2_screen_id", GEN2), + ["main.lua"] = ([[ +local mod = ... +mod.exports.pick = function(id) + if id == "%s" then return true end +end +]]):format(twin), +}) + +-- one statement binding two requires: each name takes the value in its own +-- slot, or neither does. Both names reach the same unbacked member, so a +-- scan that paired them by position reports two and one that guessed reports +-- one against a name it invented. +local pairs_one = write("gen2_pairs_one", { + ["manifest.json"] = manifest("gen2_pairs_one", GEN2), + ["main.lua"] = ([[ +local mod = ... +local A, B = require("%s"), require("%s") +A.%s(mod) +B.%s(mod) +]]):format(absentName, absentName, absentMember, absentMember), +}) + +-- the same mod with the requires on their own lines: two mods that differ +-- only in line breaks must not get opposite verdicts +local pairs_lines = write("gen2_pairs_lines", { + ["manifest.json"] = manifest("gen2_pairs_lines", GEN2), + ["main.lua"] = ([[ +local mod = ... +local A = require("%s") +local B = require("%s") +A.%s(mod) +B.%s(mod) +]]):format(absentName, absentName, absentMember, absentMember), +}) + +-- a mod helper that only ever puts its second parameter in the VALUE slot: +-- it names no upvalue, so its call sites are unresolved, never MK407 +local valueSlot = write("gen2_value_slot", { + ["manifest.json"] = manifest("gen2_value_slot", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +local function applyPatches(fn, label) + debug.setupvalue(fn, 1, label) +end +applyPatches(M.%s, "gen2checkNotAnUpvalue") +]]):format(aliasName, backedMember), +}) + +-- a module name assembled from two literals: the fragment binds to no engine +-- module, and every reach off it has to say so +local head, tail = absentName:match("^(.*%.)([^.]+)$") +local truncated = head and write("gen2_truncated", { + ["manifest.json"] = manifest("gen2_truncated", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s" .. "%s") +M.%s(mod) +]]):format(head, tail, absentMember), +}) + +-- the same concatenation starting with a literal, which a lookahead on the +-- first token alone lets through +local root_, rest = absentName:match("^(src)(%..*)$") +local concat = root_ and write("gen2_concat", { + ["manifest.json"] = manifest("gen2_concat", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s" .. "%s") +M.%s(mod) +]]):format(root_, rest, absentMember), +}) + +-- a bound module read as a value: parked on a table, delegated to through a +-- metatable, handed to a call. The module escapes the scan at each one. +local valueRead = write("gen2_value_read", { + ["manifest.json"] = manifest("gen2_value_read", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +local proxy = setmetatable({}, { __index = M }) +mod.exports.proxy = proxy +mod.exports.parked = M +]]):format(absentName), +}) + +-- rawget/rawset skip the facade's metatable, so mod state stashed this way +-- lands on the adapter table and not on the Gen 2 module behind it +local rawReach = write("gen2_raw_reach", { + ["manifest.json"] = manifest("gen2_raw_reach", GEN2), + ["main.lua"] = ([[ +local mod = ... +local M = require("%s") +rawset(M, "gen2checkState", {}) +mod.exports.state = rawget(M, "gen2checkState") +]]):format(aliasName), +}) + +local function gen2check(dir, extra) + return run(("%s tools/modkit.py gen2check %q %s") + :format(python, dir, extra or "")) +end + +-- ------- a mod that only reads what the adapter backs + +local out, code = gen2check(clean) +T.eq(code, 0, "a mod inside the adapter's coverage exits 0: " .. out) +T.check(out:find("will load", 1, true) ~= nil, + "the clean fixture's verdict is 'will load': " .. out) + +-- ------- the manifest gate, which decides before a line of the mod runs + +out, code = gen2check(unflagged) +T.eq(code, 1, "a mod claiming no Gen 2 game fails the check") +T.check(out:find("MK400", 1, true) ~= nil, "MK400 names the manifest: " .. out) +T.check(out:find("will not work", 1, true) ~= nil, + "and the verdict says so: " .. out) + +-- ------- a member the adapter refuses to invent + +out, code = gen2check(absent) +T.eq(code, 1, "calling an unbacked member fails the check") +T.check(out:find("MK404", 1, true) ~= nil, "MK404 names the member: " .. out) +T.check(out:find(absentMember, 1, true) ~= nil, + "and quotes it by name: " .. out) + +-- ------- the two shapes no adapter can fix + +out, code = gen2check(patterns) +T.eq(code, 1, "upvalue surgery with no target on Gold fails the check") +T.check(out:find("MK407", 1, true) ~= nil, + "MK407 names the upvalue that does not exist: " .. out) +T.check(out:find("MK409", 1, true) ~= nil, + "MK409 names the Gen 1 screen id: " .. out) +T.check(out:find("Gen2" .. twin, 1, true) ~= nil, + "and gives the Gen 2 spelling of it: " .. out) + +-- ------- the entry chunk holding a member of a game that is not up yet + +if held then + out = gen2check(held) + T.check(out:find("MK410", 1, true) ~= nil, + "MK410 names the file-scope read: " .. out) + T.check(select(2, out:gsub("MK410", "")) == 1, + "and only the file-scope one, not the read inside the handler: " .. out) +end + +-- ------- the reaches a scan that only saw `X = require(...)` used to miss + +out, code = gen2check(reaches) +T.eq(code, 1, "an unbacked member reached through a wrapper fails: " .. out) +T.eq(select(2, out:gsub("MK404", "")), 4, + "the wrapper, the inline require, the bracket index and the local hop each " + .. "come back: " .. out) + +-- ------- and what it still cannot follow says so out loud + +out = gen2check(opaque) +T.check(out:find("unresolved", 1, true) ~= nil, + "a module name the scan cannot tie to a require is reported unresolved, " + .. "not passed over: " .. out) + +-- ------- upvalue surgery, told apart by what the function really closes over + +if lands then + out, code = gen2check(lands) + T.check(out:find("lands as it does on Gen 1", 1, true) ~= nil, + "a real upvalue of the resolved function is reported as landing: " .. out) + T.check(out:find("MK407", 1, true) == nil, + "and raises nothing: " .. out) +end + +if stray then + out, code = gen2check(stray) + T.check(out:find("MK407", 1, true) ~= nil, + "a file-local that is not an upvalue of the function is MK407: " .. out) + T.check(out:find("lands as it does on Gen 1", 1, true) == nil, + "and is never reported as landing: " .. out) +end + +-- ------- the screen id with no screen-shaped word beside it + +out = gen2check(screenId) +T.check(out:find("MK409", 1, true) ~= nil, + "MK409 reads the id itself, not the line around it: " .. out) +T.check(out:find("Gen2" .. twin, 1, true) ~= nil, + "and gives the Gen 2 spelling: " .. out) + +-- ------- names and values of one assignment, paired by position + +out, code = gen2check(pairs_one) +T.eq(code, 1, "two names bound to two requires in one statement fail: " .. out) +T.eq(select(2, out:gsub("MK404", "")), 2, + "each name carries its own require's module, so both reaches come back: " + .. out) +local lines, code2 = gen2check(pairs_lines) +T.eq(code2, code, "the same mod with the requires on separate lines gets the " + .. "same exit: " .. out .. lines) +T.eq(select(2, lines:gsub("MK404", "")), 2, + "and the same findings, since only the line breaks moved: " .. lines) + +-- ------- a helper whose second parameter is a value, not an upvalue name + +out, code = gen2check(valueSlot) +T.check(out:find("MK407", 1, true) == nil, + "a helper that names no upvalue is never read as upvalue surgery: " .. out) +T.check(out:find("unresolved", 1, true) ~= nil, + "and its call sites come back unresolved rather than silent: " .. out) + +-- ------- a module name this scan resolved but cannot map to a module + +if truncated then + out, code = gen2check(truncated) + T.check(out:find("neither an adapter nor a module", 1, true) ~= nil, + "a require built from two literals cannot pass in silence: " .. out) +end + +if concat then + out, code = gen2check(concat) + T.check(out:find("building a require name at runtime", 1, true) ~= nil, + "a concatenation starting with a literal is still dynamic: " .. out) +end + +-- ------- a bound module read as a value rather than indexed + +out, code = gen2check(valueRead) +T.check(out:find("read as a value", 1, true) ~= nil, + "a module parked on a table or delegated to is reported unresolved: " .. out) + +-- ------- rawget/rawset, which never see the module behind the facade + +out, code = gen2check(rawReach) +T.check(out:find("rawset", 1, true) ~= nil, + "rawset onto an engine module earns its own note: " .. out) +T.check(out:find("rawget", 1, true) ~= nil, + "and so does rawget off one: " .. out) + +-- ------- the machine-readable form one CI step reads + +out, code = run(("%s tools/modkit.py --json gen2check %q %q") + :format(python, clean, unflagged)) +T.eq(code, 1, "the batch fails when any mod in it fails") +T.check(out:find('"verdict": "will load"', 1, true) ~= nil, + "the JSON carries a verdict per mod: " .. out) +T.check(out:find('"ok": false', 1, true) ~= nil, + "and one ok for the batch: " .. out) + +-- ------- every adapted name is served, so requiring one is never MK402 + +local requires = { "local mod = ..." } +for _, name in ipairs(names) do + requires[#requires + 1] = ("require(%q)"):format(name) +end +local served = write("gen2_served", { + ["manifest.json"] = manifest("gen2_served", GEN2), + ["main.lua"] = table.concat(requires, "\n") .. "\n", +}) +out = gen2check(served) +T.check(out:find("MK402", 1, true) == nil, + "no adapted module is reported as unserved: " .. out) + +run((isWindows and "rmdir /s /q " or "rm -rf ") .. ("%q"):format(root)) + +T.finish("gen2check") diff --git a/tests/modkit/catalog.lua b/tests/modkit/catalog.lua index 5fce4fd9..472f0434 100644 --- a/tests/modkit/catalog.lua +++ b/tests/modkit/catalog.lua @@ -66,12 +66,33 @@ function Catalog.events() return events end +-- A hook whose Runtime.call lives in a shared raiser rather than at the site +-- that decides the value. Gold's trainer art is not in field.playerPics, so +-- its screens resolve their own path and hand it to Sprites.playerPic +-- (src/pokemon/Sprites.lua); those callers are player.sprite sites and the +-- generation gate has to see them as such. +local INDIRECT_HOOKS = { + ["player.sprite"] = "playerPic%(", +} + function Catalog.hooks() if not hooks then hookSites = scan({ "src" }, { 'Runtime%.call%("([%w%._]+)"', 'hooks:call%("([%w%._]+)"', }) + for name, pattern in pairs(INDIRECT_HOOKS) do + local list = hookSites[name] or {} + hookSites[name] = list + for _, path in ipairs(luaFilesUnder("src")) do + local handle = io.open(path, "r") + if handle then + local body = handle:read("*a") + handle:close() + if body:match(pattern) then list[#list + 1] = path end + end + end + end hooks = sortedKeys(hookSites) end return hooks diff --git a/tests/modkit/sdk.lua b/tests/modkit/sdk.lua index f492a48c..1745d655 100644 --- a/tests/modkit/sdk.lua +++ b/tests/modkit/sdk.lua @@ -111,10 +111,13 @@ function Sdk.restoreRuntime() saved = nil end --- opts.data the merge target (defaults to a fresh fixture dataset) --- opts.fs override the filesystem entirely (e.g. Sdk.memfs) --- opts.root repo root the real paths are relative to --- opts.dev force the dev tripwire on +-- opts.data the merge target (defaults to a fresh fixture dataset) +-- opts.fs override the filesystem entirely (e.g. Sdk.memfs) +-- opts.root repo root the real paths are relative to +-- opts.dev force the dev tripwire on +-- opts.generation 1 (default) or 2; loads as if Gold were the running game, +-- which is the seam the gen2compat gate and the registry +-- target routing are tested through without booting Gold function Sdk.loadMods(paths, opts) opts = opts or {} local data = opts.data or require("tests.modkit.fixtures").fresh() @@ -127,7 +130,8 @@ function Sdk.loadMods(paths, opts) end Sdk.captureRuntime() - local loader = Loader.new({ fs = fs, dev = opts.dev }) + local loader = Loader.new({ fs = fs, dev = opts.dev, + generation = opts.generation }) local ok, err = pcall(loader.load, loader, data) if not ok then Sdk.restoreRuntime() diff --git a/tests/rom_importer_last_version_test.lua b/tests/rom_importer_last_version_test.lua index 07e3543d..c528be2f 100644 --- a/tests/rom_importer_last_version_test.lua +++ b/tests/rom_importer_last_version_test.lua @@ -76,9 +76,11 @@ 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. +-- 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. local opts = SaveData.loadOptions() -opts.lastVersion = "gold" +opts.lastVersion = "crystal" SaveData.saveOptions(opts) local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } }) ri5:_applyLastVersionTab() diff --git a/tests/rom_lz3_test.lua b/tests/rom_lz3_test.lua new file mode 100644 index 00000000..de410318 --- /dev/null +++ b/tests/rom_lz3_test.lua @@ -0,0 +1,84 @@ +-- Gold Phase 1: Rom.decompressLz3 ports pokegold's home/decompress.asm +-- ("lz3") byte-for-byte. There is no bundled Gold ROM to decompress real +-- pics against, so this instead hand-assembles small compressed streams for +-- every command (LZ_LITERAL/ITERATE/ALTERNATE/ZERO/REPEAT/FLIP/REVERSE, plus +-- LZ_LONG and the 7-bit/15-bit lookback forms) and checks the decoded bytes +-- against what the disassembly says each one does; cross-checked against +-- tools/lzcompress.c's --uncompress reference path. +-- Self-contained: `luajit tests/rom_lz3_test.lua`; also dofile'd by +-- tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("rom lz3 decompress") +local same = S.same + +local Rom = require("src.import.Rom") + +local function decompress(bytes) + return Rom.decompressLz3(bytes) +end + +-- LZ_LITERAL: cmd=0, length=4 +same(decompress({ 0x03, 0x11, 0x22, 0x33, 0x44, 0xFF }), + { 0x11, 0x22, 0x33, 0x44 }, "literal") + +-- LZ_ITERATE: cmd=1, length=5, one repeated byte +same(decompress({ 0x24, 0x7A, 0xFF }), + { 0x7A, 0x7A, 0x7A, 0x7A, 0x7A }, "iterate") + +-- LZ_ALTERNATE: cmd=2, length=5, two alternating bytes (starts with the +-- first byte, per home/decompress.asm's .Alt / .anext1) +same(decompress({ 0x44, 0x01, 0x02, 0xFF }), + { 1, 2, 1, 2, 1 }, "alternate") + +-- LZ_ZERO: cmd=3, length=6 +same(decompress({ 0x65, 0xFF }), + { 0, 0, 0, 0, 0, 0 }, "zero") + +-- LZ_REPEAT with a 15-bit positive offset from the start of the output +-- (literal 4 bytes, then repeat all 4 from offset 0) +same(decompress({ + 0x03, 0xAA, 0xBB, 0xCC, 0xDD, + 0x83, 0x00, 0x00, + 0xFF, +}), { 0xAA, 0xBB, 0xCC, 0xDD, 0xAA, 0xBB, 0xCC, 0xDD }, "repeat (positive offset)") + +-- LZ_REPEAT with a 7-bit negative offset that overlaps the bytes it is +-- still writing -- an RLE-style self-extending repeat (Decompress's .Repeat +-- reads with [hli], so the source pointer walks forward into freshly +-- written output exactly like this). +same(decompress({ + 0x00, 0x05, -- literal: {5} + 0x84, 0x80, -- repeat length 5, offset magnitude 0 (from the byte just written) + 0xFF, +}), { 5, 5, 5, 5, 5, 5 }, "repeat (self-overlapping negative offset)") + +-- LZ_FLIP: bit-reverses each copied byte +same(decompress({ + 0x00, 0xB0, -- literal: {0xB0} (1011 0000) + 0xA0, 0x80, -- flip length 1, offset magnitude 0 + 0xFF, +}), { 0xB0, 0x0D }, "flip (0xB0 reversed is 0x0D)") + +-- LZ_REVERSE: copies backwards from the offset +same(decompress({ + 0x02, 0x01, 0x02, 0x03, -- literal: {1, 2, 3} + 0xC2, 0x80, -- reverse length 3, offset magnitude 0 + 0xFF, +}), { 1, 2, 3, 3, 2, 1 }, "reverse") + +-- LZ_LONG: 111xxxyy yyyyyyyy extends ITERATE past the 5-bit short length +-- (here length 40, encoded as 39 = 0x27 with no high bits set) +same(decompress({ 0xE4, 0x27, 0x09, 0xFF }), + (function() + local out = {} + for _ = 1, 40 do out[#out + 1] = 0x09 end + return out + end)(), "long-form iterate") + +-- A string input (not a pre-split byte array) must work the same way as +-- Rom.decompressPic accepts, per the task's "array OR string" contract. +same(decompress(string.char(0x03, 0x01, 0x02, 0x03, 0x04, 0xFF)), + { 1, 2, 3, 4 }, "string input") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 389cea06..3122785f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3383,10 +3383,61 @@ local function orderedGlob(pattern, preferred, skip) return ordered end +-- Put the `love` stub back between suites. +-- +-- Every one of these files fakes the parts of LOVE it needs, and several +-- replace a whole subtable (`love.filesystem = {...}`) or a single probe +-- (`love.system.getOS = function() return "Android" end`) and never put it +-- back. Nothing notices until a LATER file reads the leftover, and then the +-- failure lands nowhere near its cause: +-- +-- * the Android ROM-importer suites pin getOS to "Android", so +-- GBCFX.isSupported() answers false for the rest of the run and +-- parity_gbcfx fails "setLevel stores an in-range level (got 0, want 2)" +-- -- while passing perfectly on its own; +-- * suites that swap in a minimal love.filesystem drop +-- getDirectoryItems, so three rom_importer suites then die on +-- "attempt to call field 'getDirectoryItems' (a nil value)". +-- +-- Snapshotting one level deep is enough: the leaks are whole-subtable +-- assignments and single-function overwrites, both of which this restores. +local function snapshotLove() + if type(love) ~= "table" then return nil end + local snap = { root = {}, subs = {} } + for k, v in pairs(love) do + snap.root[k] = v + if type(v) == "table" then + local sub = {} + for k2, v2 in pairs(v) do sub[k2] = v2 end + snap.subs[k] = sub + end + end + return snap +end + +local function restoreLove(snap) + if not snap or type(love) ~= "table" then return end + for k in pairs(love) do + if snap.root[k] == nil then love[k] = nil end + end + for k, v in pairs(snap.root) do + love[k] = v + local sub = snap.subs[k] + if sub and type(v) == "table" then + for k2 in pairs(v) do + if sub[k2] == nil then v[k2] = nil end + end + for k2, v2 in pairs(sub) do v[k2] = v2 end + end + end +end + local function runSuites(paths) for _, path in ipairs(paths) do local label = path:match("([^/]+)%.lua$") or path + local snap = snapshotLove() local ok, err = pcall(dofile, path) + restoreLove(snap) check(ok, label .. (ok and " suite" or (": " .. tostring(err)))) end end @@ -3428,6 +3479,134 @@ 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({ + "tests/rom_lz3_test.lua", + "tests/gen2_world_test.lua", + "tests/gen2_audio_test.lua", + "tests/gen2_oak_speech_test.lua", + "tests/gen2_vm_test.lua", + "tests/gen2_palettes_test.lua", + "tests/gen2_battle_test.lua", + "tests/gen2_menus_test.lua", + "tests/gen2_save_test.lua", + "tests/gen2_trainers_test.lua", + "tests/gen2_boxes_test.lua", + "tests/gen2_intro_test.lua", + "tests/gen2_battle_anims_test.lua", + -- The list had fallen behind the directory: these seven were written and + -- green when run file by file, but never ran here. + "tests/gen2_breeding_test.lua", + "tests/gen2_contest_test.lua", + "tests/gen2_evolution_test.lua", + "tests/gen2_gamecorner_test.lua", + "tests/gen2_halloffame_test.lua", + "tests/gen2_phone_test.lua", + "tests/gen2_summary_test.lua", + "tests/gen2_steps_test.lua", + "tests/gen2_cmdqueue_test.lua", + "tests/gen2_events_test.lua", + "tests/gen2_map_callbacks_test.lua", + "tests/gen2_roamers_test.lua", + "tests/gen2_border_test.lua", + "tests/gen2_hidden_items_test.lua", + "tests/gen2_object_event_test.lua", + "tests/gen2_autoinput_test.lua", + "tests/gen2_catch_tutorial_test.lua", + "tests/gen2_unown_test.lua", + "tests/gen2_unown_printer_test.lua", + "tests/gen2_decorations_test.lua", + "tests/gen2_pokerus_test.lua", + "tests/gen2_common_text_test.lua", + "tests/gen2_magnet_train_test.lua", + "tests/gen2_bank_of_mom_test.lua", + "tests/gen2_trainerhouse_test.lua", + "tests/gen2_mail_test.lua", + "tests/gen2_callasm_test.lua", + "tests/gen2_sprites_test.lua", + "tests/gen2_diploma_test.lua", + "tests/gen2_trade_gfx_test.lua", + "tests/gen2_prize_test.lua", + -- The four the Gold route bot found. Every one of them needs a long play + -- session to reach, which is why no unit test caught them first and why they + -- are worth keeping in the list: struggle at 0 PP, the evolution screen's + -- lifecycle-hook collision, badges written to a store nothing read, and a + -- lost trainer battle running the winner's script. + "tests/gen2_struggle_test.lua", + "tests/gen2_evolution_anim_test.lua", + "tests/gen2_egg_hatch_anim_test.lua", + "tests/gen2_badges_test.lua", + "tests/gen2_battle_loss_test.lua", + "tests/gen2_variable_sprites_test.lua", + "tests/gen2_pokecenter_spawn_test.lua", + "tests/gen2_charge_lock_test.lua", + "tests/gen2_faint_once_test.lua", + "tests/gen2_nests_test.lua", + "tests/gen2_bg_events_test.lua", + "tests/gen2_ice_pathfind_test.lua", + "tests/gen2_party_menu_test.lua", + "tests/gen2_hof_continue_test.lua", + "tests/gen2_pokecenter_stairs_test.lua", + "tests/gen2_canlose_test.lua", + "tests/gen2_pc_screens_test.lua", + "tests/gen2_badge_boosts_test.lua", + "tests/gen2_held_items_test.lua", + "tests/gen2_exp_share_test.lua", + "tests/gen2_obedience_test.lua", + "tests/gen2_specialty_balls_test.lua", + "tests/gen2_x_items_test.lua", + "tests/gen2_move_effects_test.lua", + "tests/gen2_trap_escape_test.lua", + "tests/gen2_forceshiny_test.lua", + "tests/gen2_berry_juice_test.lua", + "tests/gen2_object_hours_test.lua", + "tests/gen2_temp_events_test.lua", + "tests/gen2_npc_interact_test.lua", + "tests/gen2_text_flow_test.lua", + "tests/gen2_field_items_test.lua", + "tests/gen2_phone_call_test.lua", + "tests/gen2_battle_items_test.lua", + "tests/gen2_battle_ui_test.lua", + "tests/gen2_dig_warp_test.lua", + "tests/gen2_repel_test.lua", + "tests/gen2_swarm_test.lua", + "tests/gen2_fishing_swarm_test.lua", + "tests/gen2_rock_smash_test.lua", + "tests/gen2_currents_test.lua", + "tests/gen2_big_object_test.lua", + "tests/gen2_prize_counter_test.lua", + -- Presentation: the blit scale every widescreen screen shares, the naming + -- bracket, the teleport / fly / fishing step types, the two clock screens, + -- FLY's town-map picker and the two script-ordering commands. + "tests/gen2_screen_layout_test.lua", + "tests/gen2_font_ui_test.lua", + "tests/gen2_field_anim_test.lua", + "tests/gen2_clock_test.lua", + "tests/gen2_time_routing_test.lua", + "tests/gen2_fly_map_test.lua", + "tests/gen2_script_order_test.lua", + -- Glue between the Gen 1 modules Gold still shares and the Gen 2 data: sfx + -- 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. + "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 -- + -- which shares its screen with the field PACK but none of its jumptable. + "tests/gen2_battle_end_test.lua", + "tests/gen2_battle_pack_test.lua", + -- Battle core internals: where DoWeatherModifiers sits in the damage chain, + -- 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", +}) + -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) diff --git a/tools/blockgen.py b/tools/blockgen.py new file mode 100644 index 00000000..59726845 --- /dev/null +++ b/tools/blockgen.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +"""Blockset explorer: what is in a tileset's blockset, what is free, and what +new 32x32 blocks can be built out of the 8x8 tiles it already owns. + +Developer-only, like tools/build_data.py. Reads the generated cache +(data/generated/tilesets.lua, data/generated/maps.lua and the tileset PNGs +under assets/generated/tilesets/); writes nothing back into it. + +A block is 4x4 = 16 tiles of 8x8, matching pokered's blockdata (16 bytes per +block, see data/tilesets/*_blockset.bst). Map blockdata stores a block index +in one byte, so a tileset can never hold more than 256 blocks: the interesting +budget is the free slots between the blocks a tileset ships and that ceiling, +not the raw combination count. Exhaustive enumeration is not a thing you can +run -- OVERWORLD has 93 distinct tiles, so 93^16 is about 3.3e31 blocks -- so +the generator recombines under constraints learned from the real blocks +instead. --mode exhaustive exists to show the wall rather than to hit it. + +Usage: + python3 tools/blockgen.py --list + python3 tools/blockgen.py --tileset OVERWORLD + python3 tools/blockgen.py --tileset OVERWORLD --sheet /tmp/blocks.png + python3 tools/blockgen.py --tileset CAVERN --mode exhaustive +""" + +import argparse +import collections +import hashlib +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TILESETS_LUA = os.path.join(ROOT, "data/generated/tilesets.lua") +MAPS_LUA = os.path.join(ROOT, "data/generated/maps.lua") + +BLOCK_TILES = 4 # a block is 4x4 tiles +TILE_PX = 8 +# The engine has no block ceiling: src/world/Map.lua indexes tilesetDef.blocks +# as a plain Lua array and never masks the id, and mods register tilesets with +# arbitrary block lists (src/mods/Schemas.lua). 256 is the ROM's limit, not +# ours -- map blockdata on the cart is one byte per block, so only blocks +# IMPORTED from a ROM are bound by it. Kept here to report parity headroom. +ROM_BLOCK_LIMIT = 256 +BLOCK_LIMIT = ROM_BLOCK_LIMIT + + +# ----------------------------------------------------------------- lua parsing +# The generated caches are plain nested table literals (numbers, strings, +# booleans, `key = value`), so a small recursive-descent reader beats shelling +# out to luajit and keeps this runnable with no interpreter installed. + +_TOKEN = re.compile(r""" + (?P<ws>\s+|--[^\n]*) + | (?P<str>"(?:[^"\\]|\\.)*") + | (?P<num>-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?) + | (?P<name>[A-Za-z_][A-Za-z_0-9]*) + | (?P<punct>[{}\[\]=,;]) +""", re.X) + + +def _tokenize(text): + pos, n, out = 0, len(text), [] + while pos < n: + m = _TOKEN.match(text, pos) + if not m: + raise ValueError("lua parse: stuck at %r" % text[pos:pos + 40]) + pos = m.end() + if m.lastgroup != "ws": + out.append((m.lastgroup, m.group())) + return out + + +def parse_lua(text): + """Parse `return { ... }`. Array parts come back as lists, keyed parts as + dicts; a table with both is returned as a dict with integer keys.""" + toks = _tokenize(text) + i = 0 + if toks and toks[0] == ("name", "return"): + i = 1 + + def value(i): + kind, tok = toks[i] + if kind == "num": + return (float(tok) if ("." in tok or "e" in tok.lower()) else int(tok)), i + 1 + if kind == "str": + return tok[1:-1].encode().decode("unicode_escape"), i + 1 + if kind == "name": + if tok == "true": + return True, i + 1 + if tok == "false": + return False, i + 1 + if tok == "nil": + return None, i + 1 + raise ValueError("lua parse: bare name %r" % tok) + if tok == "{": + return table(i + 1) + raise ValueError("lua parse: unexpected %r" % tok) + + def table(i): + arr, dct = [], {} + while True: + kind, tok = toks[i] + if tok == "}": + return (dct if dct else arr) if not (dct and arr) else _merge(arr, dct), i + 1 + if tok in (",", ";"): + i += 1 + continue + # `key = v` or `["key"] = v` + if kind == "name" and toks[i + 1][1] == "=": + k = tok + v, i = value(i + 2) + dct[k] = v + continue + if tok == "[": + k, i = value(i + 1) + assert toks[i][1] == "]" and toks[i + 1][1] == "=" + v, i = value(i + 2) + dct[k] = v + continue + v, i = value(i) + arr.append(v) + + def _merge(arr, dct): + out = dict(dct) + for n, v in enumerate(arr, 1): + out[n] = v + return out + + v, _ = value(i) + return v + + +# ------------------------------------------------------------------ tile art +def load_tiles(record): + """Slice a tileset PNG into 8x8 tiles, indexed the way the block data + indexes them (row-major, tilesPerRow per row). Returns None when Pillow is + missing, so the structural half of this script still runs.""" + try: + from PIL import Image + except ImportError: + return None + path = os.path.join(ROOT, record["image"]) + if not os.path.exists(path): + return None + img = Image.open(path).convert("RGB") + per_row = record["tilesPerRow"] + rows = img.height // TILE_PX + tiles = {} + for idx in range(rows * per_row): + tx, ty = (idx % per_row) * TILE_PX, (idx // per_row) * TILE_PX + tiles[idx] = img.crop((tx, ty, tx + TILE_PX, ty + TILE_PX)) + return tiles + + +def tile_hash(tile): + return hashlib.blake2b(tile.tobytes(), digest_size=8).hexdigest() + + +def edges(tile): + """(top, bottom, left, right) pixel-row/column signatures. Two tiles butt + together seamlessly when the facing edges match, which is what makes a + recombined block read as deliberate rather than as noise.""" + px = tile.load() + w, h = tile.size + top = bytes(v for x in range(w) for v in px[x, 0]) + bot = bytes(v for x in range(w) for v in px[x, h - 1]) + left = bytes(v for y in range(h) for v in px[0, y]) + right = bytes(v for y in range(h) for v in px[w - 1, y]) + return top, bot, left, right + + +# ------------------------------------------------------------- categorisation +def categorise(record, tiles): + """Tag every tile id the blockset actually uses. Roles come from the + tileset metadata the extractor already carries (walkable / grass / door / + warp / counter); everything else is scenery. Visual duplicates are folded + into one class so 'make unique' means unique art, not unique index.""" + used = sorted({t for blk in record["blocks"] for t in blk}) + walkable = set(record.get("walkable") or []) + doors = set(record.get("doorTiles") or []) + warps = set(record.get("warpTiles") or []) + counters = set(record.get("counterTiles") or []) + grass = record.get("grassTile") + + role = {} + for t in used: + if t == grass: + role[t] = "grass" + elif t in doors: + role[t] = "door" + elif t in warps: + role[t] = "warp" + elif t in counters: + role[t] = "counter" + elif t in walkable: + role[t] = "walkable" + else: + role[t] = "scenery" + + # visual identity classes: distinct ids whose art is byte-identical + art_class, alias = {}, {} + if tiles: + for t in used: + if t in tiles: + art_class.setdefault(tile_hash(tiles[t]), []).append(t) + for ids in art_class.values(): + for t in ids: + alias[t] = ids[0] + else: + alias = {t: t for t in used} + + return used, role, alias, art_class + + +# ------------------------------------------------------------------- analysis +def blocks_in_use(maps, tileset_name): + """Block indices any map on this tileset actually places. A block that no + map references is already a free slot in everything but name.""" + seen = set() + maps_on = 0 + for rec in maps.values(): + if not isinstance(rec, dict) or rec.get("tileset") != tileset_name: + continue + maps_on += 1 + for b in rec.get("blocks") or []: + seen.add(b) + return seen, maps_on + + +def canon(block, alias): + """Identity of a block by art, not by tile index.""" + return tuple(alias.get(t, t) for t in block) + + +def learn_constraints(blocks, alias): + """What the real blocks permit: which tiles appear at each of the 16 + positions, and which tiles ever sit directly right of / below which.""" + pos_vocab = [set() for _ in range(16)] + right_of = collections.defaultdict(set) + below = collections.defaultdict(set) + for blk in blocks: + a = [alias.get(t, t) for t in blk] + for i, t in enumerate(a): + pos_vocab[i].add(t) + r, c = divmod(i, BLOCK_TILES) + if c + 1 < BLOCK_TILES: + right_of[t].add(a[i + 1]) + if r + 1 < BLOCK_TILES: + below[t].add(a[i + BLOCK_TILES]) + return pos_vocab, right_of, below + + +def fill_tiles(blocks, alias, share=0.04): + """The tiles a blockset uses as background. Anything holding more than + `share` of all tile slots across the real blocks is fill: grass, path, + interior floor. Used to score structure, since a block made only of these + is a texture swatch, not a block worth a slot.""" + counts = collections.Counter(alias.get(t, t) for blk in blocks for t in blk) + total = sum(counts.values()) + return {t for t, n in counts.items() if n / total > share} + + +def render_block(block, tiles): + """A block's 32x32 pixels, for comparing what a block LOOKS like rather + than which indices it happens to name. Two blocks built from different + ids whose art is identical render identical here, which index-space + distance cannot see.""" + from PIL import Image + px = BLOCK_TILES * TILE_PX + img = Image.new("RGB", (px, px)) + for i, t in enumerate(block): + art = tiles.get(t) + if art is None: + continue + r, c = divmod(i, BLOCK_TILES) + img.paste(art, (c * TILE_PX, r * TILE_PX)) + return img + + +def score_block(block, alias, fill): + """How much structure a block carries. Distinct art plus non-fill tiles, + with a hard floor: a block that is almost entirely one tile is a swatch.""" + a = [alias.get(t, t) for t in block] + distinct = len(set(a)) + non_fill = sum(1 for t in a if t not in fill) + dominant = collections.Counter(a).most_common(1)[0][1] + if distinct < 3 or non_fill == 0 or dominant >= 14: + return 0 + return non_fill * 2 + distinct + + +def tile_diff_table(tiles, ids): + """Pixel differences between every pair of tiles, once. + + Two blocks differ by exactly the sum of their per-slot tile differences, so + this turns a 1024-pixel comparison per block pair into 16 lookups. That is + the difference between this finishing and not: 400 blocks needs millions of + pair comparisons, and pure Python cannot walk 1024 pixels that many times.""" + raw = {t: tiles[t].tobytes() for t in ids if t in tiles} + table = {} + for a in raw: + ra = raw[a] + row = table.setdefault(a, {}) + for b in raw: + if b in row: + continue + rb = raw[b] + d = sum(1 for i in range(0, len(ra), 3) if ra[i:i + 3] != rb[i:i + 3]) + row[b] = d + table.setdefault(b, {})[a] = d + return table + + +def too_close(cand, kept, table, budget): + """True when `cand` is within `budget` differing pixels of anything kept. + Accumulates per slot and bails the moment a block is provably far enough, + so the common case costs a handful of lookups rather than 16.""" + for other in kept: + total = 0 + for x, y in zip(cand, other): + if x != y: + total += table.get(x, {}).get(y, TILE_PX * TILE_PX) + if total >= budget: + break + if total < budget: + return True + return False + + +def generate(blocks, alias, limit, tiles=None, seed=0, min_distance=6, + attempts_per=200, oversample=8, min_pixel_diff=0.12): + """Sample blocks the real blockset's own rules permit: a tile may only sit + at a position it is observed at, and only next to / below tiles it is + observed next to / below. + + Sampling is randomised rather than depth-first on purpose. A sorted DFS + walks the lexicographically first prefix to exhaustion, so its first + hundred results are one corner with the last tile wiggling -- technically + unique, useless as art. Random restarts spread over the space, and + min_distance (tiles differing from every block already kept, the blockset's + included) is what stops near-duplicates coming back under a new index.""" + import random + rng = random.Random(seed) + pos_vocab, right_of, below = learn_constraints(blocks, alias) + fill = fill_tiles(blocks, alias) + existing = [canon(b, alias) for b in blocks] + existing_set = set(existing) + out, seen = [], set() + kept = list(existing) + + def far_enough(cand): + for other in kept: + if sum(1 for a, b in zip(cand, other) if a != b) < min_distance: + return False + return True + + def sample(): + """One randomised walk with backtracking; None if it paints itself in. + `tried` mirrors `cur` so a position never re-picks a tile that already + led to a dead end on this walk.""" + cur, tried = [], [] + while len(cur) < 16: + i = len(cur) + r, c = divmod(i, BLOCK_TILES) + cand = set(pos_vocab[i]) + if c > 0: + cand &= right_of.get(cur[i - 1], set()) + if r > 0: + cand &= below.get(cur[i - BLOCK_TILES], set()) + if i == len(tried): + tried.append(set()) + cand -= tried[i] + if not cand: + if i == 0: + return None + tried.pop() + cur.pop() + continue + pick = rng.choice(sorted(cand)) + tried[i].add(pick) + cur.append(pick) + return tuple(cur) + + # Oversample into a pool, rank by structure, then take the best that are + # visually far enough apart. Filling greedily in sample order instead is + # what produced a sheet of grass swatches: every one of them cleared a + # 6-tile index distance by swapping interchangeable background, which is + # not a difference anybody can see. + pool, tries = [], 0 + want = limit * oversample + while len(pool) < want and tries < want * attempts_per: + tries += 1 + cand = sample() + if cand is None or cand in existing_set or cand in seen: + continue + seen.add(cand) + pool.append(cand) + + scored = [(score_block(c, alias, fill), c) for c in pool] + scored = [(s, c) for s, c in scored if s > 0] + scored.sort(key=lambda sc: -sc[0]) + + table = tile_diff_table(tiles, {t for b in pool for t in b} | + {t for b in existing for t in b}) if tiles else None + budget = int(min_pixel_diff * (BLOCK_TILES * TILE_PX) ** 2) + stats = {"pool": len(pool), "swatches": len(pool) - len(scored), + "too_similar": 0, "budget_px": budget, "asked": limit} + for _score, cand in scored: + if len(out) >= limit: + break + if table is not None: + if too_close(cand, kept, table, budget): + stats["too_similar"] += 1 + continue + elif not far_enough(cand): + stats["too_similar"] += 1 + continue + kept.append(cand) + out.append(list(cand)) + stats["short_by"] = max(0, limit - len(out)) + return out, stats + + +def exhaustive_estimate(used_by_role): + """The user's original plan, priced. Not run: printed.""" + rows = [] + for role, ids in sorted(used_by_role.items()): + k = len(ids) + rows.append((role, k, k ** 16)) + return rows + + +# --------------------------------------------------------------------- output +def write_sheet(path, blocks, tiles, cols=8, scale=3, number=True): + """Contact sheet, numbered the way the blockset viewer numbers the real + blocks so a block can be named in conversation. Indices are into the + generated run, not into the tileset: they only mean anything for the same + --seed.""" + from PIL import Image, ImageDraw + if not blocks: + return False + px = BLOCK_TILES * TILE_PX + rows = (len(blocks) + cols - 1) // cols + sheet = Image.new("RGB", (cols * (px + 2), rows * (px + 2)), (24, 24, 24)) + for n, blk in enumerate(blocks): + bx, by = (n % cols) * (px + 2), (n // cols) * (px + 2) + for i, t in enumerate(blk): + art = tiles.get(t) + if art is None: + continue + r, c = divmod(i, BLOCK_TILES) + sheet.paste(art, (bx + c * TILE_PX, by + r * TILE_PX)) + if scale > 1: + sheet = sheet.resize((sheet.width * scale, sheet.height * scale), Image.NEAREST) + if number: + draw = ImageDraw.Draw(sheet) + for n in range(len(blocks)): + bx = (n % cols) * (px + 2) * scale + by = (n // cols) * (px + 2) * scale + label = str(n) + w = 6 * len(label) + 2 + draw.rectangle([bx, by, bx + w, by + 10], fill=(20, 20, 20)) + draw.text((bx + 2, by + 1), label, fill=(255, 255, 255)) + sheet.save(path) + return True + + +def report(name, record, maps, args): + blocks = record["blocks"] + tiles = load_tiles(record) + used, role, alias, art_class = categorise(record, tiles) + placed, maps_on = blocks_in_use(maps, name) + + by_role = collections.defaultdict(list) + for t in used: + by_role[role[t]].append(t) + + dup_ids = sum(len(v) - 1 for v in art_class.values()) if tiles else 0 + distinct_art = len(art_class) if tiles else len(used) + + print("%s (%s)" % (name, record.get("image", "no image"))) + print(" blocks shipped %d (%d under the ROM's 256-block addressing" + % (len(blocks), ROM_BLOCK_LIMIT - len(blocks))) + print(" limit; the engine itself has no ceiling)") + print(" 8x8 tiles referenced %d (%d distinct by art, %d are duplicate ids)" + % (len(used), distinct_art, dup_ids)) + print(" categories " + ", ".join( + "%s=%d" % (r, len(v)) for r, v in sorted(by_role.items()))) + print(" maps on this tileset %d, placing %d distinct blocks" + % (maps_on, len(placed))) + + never = [i for i in range(len(blocks)) if i not in placed] + print(" blocks no map places %d%s" % ( + len(never), (" -> " + ", ".join(map(str, never[:16])) + ("..." if len(never) > 16 else "")) + if never else "")) + + if args.mode == "exhaustive": + print("\n exhaustive enumeration, per category (this is the wall):") + for r, k, total in exhaustive_estimate(by_role): + print(" %-9s %2d tiles -> %d^16 = %.3g blocks" % (r, k, k, total)) + print(" at 1e9 blocks/sec the smallest of those still outlives the sun.") + return + + budget_n = args.limit if args.limit else (BLOCK_LIMIT - len(blocks)) + made, gstats = generate(blocks, alias, budget_n, tiles=tiles, seed=args.seed, + min_distance=args.min_distance, + min_pixel_diff=args.min_pixel_diff, + oversample=args.oversample) + print("\n generated %d of the %d asked for, from a pool of %d" + % (len(made), gstats["asked"], gstats["pool"])) + print(" %d rejected as texture swatches (no structure)" % gstats["swatches"]) + print(" %d rejected as within %d differing pixels of a block already kept" + % (gstats["too_similar"], gstats["budget_px"])) + if gstats["short_by"]: + print(" SHORT BY %d: the pool ran out before the quota filled. Raise" + % gstats["short_by"]) + print(" --oversample, or lower --min-pixel-diff to accept closer blocks.") + # Reproducibility: same cache + same flags must give the same blocks, so + # print a digest of exactly that rather than asking anyone to trust it. + digest = hashlib.sha256( + ("%s|%d|%d|%.4f|%d|" % (name, args.seed, budget_n, args.min_pixel_diff, + args.oversample) + + ";".join(",".join(map(str, b)) for b in made)).encode()).hexdigest() + print(" digest %s (seed %d, oversample %d, min-pixel-diff %.2f)" + % (digest[:16], args.seed, args.oversample, args.min_pixel_diff)) + if made: + print(" first three, as tile indices:") + for blk in made[:3]: + for r in range(BLOCK_TILES): + print(" " + " ".join("%3d" % t for t in blk[r * 4:r * 4 + 4])) + print() + if args.sheet and tiles and made: + if write_sheet(args.sheet, made[:args.sheet_count], tiles, cols=args.cols): + print(" contact sheet: %s (%d blocks)" % (args.sheet, min(len(made), args.sheet_count))) + elif args.sheet and not tiles: + print(" (no contact sheet: Pillow or the tileset PNG is missing)") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--tileset", help="tileset id, e.g. OVERWORLD") + ap.add_argument("--list", action="store_true", help="list tilesets and exit") + ap.add_argument("--mode", choices=["adjacency", "exhaustive"], default="adjacency") + ap.add_argument("--limit", type=int, default=0, + help="cap generated blocks (default: the free slots up to 256)") + ap.add_argument("--sheet", help="write a PNG contact sheet of generated blocks") + ap.add_argument("--sheet-count", type=int, default=64) + ap.add_argument("--cols", type=int, default=8) + ap.add_argument("--seed", type=int, default=0, + help="fixed by default: the same cache and flags always give the same blocks") + ap.add_argument("--oversample", type=int, default=8, + help="candidates sampled per block kept") + ap.add_argument("--min-pixel-diff", type=float, default=0.12, + help="fraction of the 32x32 pixels that must differ from every kept block") + ap.add_argument("--min-distance", type=int, default=6, + help="tiles that must differ from every block already kept") + args = ap.parse_args() + + for p in (TILESETS_LUA, MAPS_LUA): + if not os.path.exists(p): + sys.exit("missing %s -- import a ROM first (scripts/setup.sh --rom ...)" % p) + + tilesets = parse_lua(open(TILESETS_LUA).read()) + maps = parse_lua(open(MAPS_LUA).read()) + + if args.list or not args.tileset: + print("%-14s %7s %7s %7s" % ("tileset", "blocks", "free", "tiles")) + for name in sorted(tilesets): + rec = tilesets[name] + used = {t for blk in rec["blocks"] for t in blk} + print("%-14s %7d %7d %7d" + % (name, len(rec["blocks"]), BLOCK_LIMIT - len(rec["blocks"]), len(used))) + return + + name = args.tileset.upper() + if name not in tilesets: + sys.exit("unknown tileset %r (try --list)" % args.tileset) + report(name, tilesets[name], maps, args) + + +if __name__ == "__main__": + main() diff --git a/tools/gen_registry_docs.lua b/tools/gen_registry_docs.lua index 4898b344..cb2397d0 100644 --- a/tools/gen_registry_docs.lua +++ b/tools/gen_registry_docs.lua @@ -1,8 +1,6 @@ --- Renders Reference-Registries.md from Schemas.REGISTRIES so the reference --- page cannot drift from the engine. Run from the repo root: --- luajit tools/gen_registry_docs.lua [outputDir] --- --- The book lives in the GitHub wiki, so the target is a wiki checkout: +-- Renders the registry reference from Schemas.REGISTRIES so the page cannot +-- drift from the engine. Run from the repo root: +-- luajit tools/gen_registry_docs.lua -- in-repo, the default -- luajit tools/gen_registry_docs.lua ../gen1recomp.wiki -- POKEPORT_DOCS_DIR=../project.wiki luajit tools/gen_registry_docs.lua -- The full doc pipeline moves into the modkit CLI later; this is the @@ -11,19 +9,22 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local Schemas = require("src.mods.Schemas") --- the book lives in the GitHub wiki, so the default target is a sibling --- wiki checkout; pass a directory or set POKEPORT_DOCS_DIR to override -local DEFAULT_DIR = "../gen1recomp.wiki" -local FILE = "Reference-Registries.md" +-- No argument writes INSIDE the repo (a default landing in a sibling dir +-- creates one on the first run); `modkit docs` reads this exact path +local DEFAULT_DIR = "docs/modding/reference" +local DEFAULT_FILE = "registries.md" +-- an explicit target is the GitHub wiki checkout, whose page names are flat +local WIKI_FILE = "Reference-Registries.md" --- precedence: argv, env, default -- so a wiki checkout is one flag away and --- a CI job can set it once for every generator that grows this convention +-- precedence: argv, env, in-repo default -- so a wiki checkout is one flag +-- away and a CI job can set it once for every generator on this convention local outDir = (... or nil) if outDir == nil or outDir == "" then outDir = os.getenv("POKEPORT_DOCS_DIR") end -if outDir == nil or outDir == "" then outDir = DEFAULT_DIR end +local explicit = outDir ~= nil and outDir ~= "" +if not explicit then outDir = DEFAULT_DIR end outDir = outDir:gsub("/+$", "") -local OUT = outDir .. "/" .. FILE +local OUT = outDir .. "/" .. (explicit and WIKI_FILE or DEFAULT_FILE) local names = {} for name in pairs(Schemas.REGISTRIES) do names[#names + 1] = name end @@ -44,19 +45,16 @@ line("") line("# Registry reference") line("") line("One section per registry: merge semantics, the `Data` table the merge") -line("writes, and the value schema. Concepts and verbs:") +line("writes, and the value schema. Where Gold differs -- a different table, a") +line("different record, or no home at all -- the registry carries a Gen 2") +line("subsection built from the same catalog entry. Concepts and verbs:") line("[Concepts: Registries](Concepts-Registries).") -for _, name in ipairs(names) do - local spec = Schemas.REGISTRIES[name] - line("") - line("## %s", name) - line("") - line("- semantics: `%s`", spec.semantics) - line("- target: %s", spec.target and ("`Data." .. spec.target .. "`") or "none") - if spec.deprecated then - line("- **deprecated** -- use %s", spec.deprecated.useInstead) - end +-- the value schema of one spec, whichever generation's shape it carries. +-- Schemas.check reads keys/keyValue, then value, then fields in that order, +-- so this renders them in the same order or the page would describe a branch +-- that never runs. +local function renderSchema(spec) if spec.keys then line("") line("Id = a top-level key of the target table. Keys not listed here are") @@ -91,19 +89,78 @@ for _, name in ipairs(names) do elseif spec.value then line("- value: %s", spec.value.desc) end - if spec.example then +end + +-- prose a registry needs beyond its schema (resolution order, the guarantees +-- a value carries). It lives on the spec rather than in the page because +-- this file is regenerated: hand-written paragraphs in the output are deleted +-- by the next run. +local function renderExample(example, notes) + if example then line("") line("```lua") - line("%s", spec.example) + line("%s", example) line("```") end - -- prose a registry needs beyond its schema (resolution order, the - -- guarantees a value carries). It lives on the spec rather than in the - -- page because this file is regenerated: hand-written paragraphs in the - -- output are deleted by the next run. - if spec.notes then + if notes then line("") - line("%s", spec.notes) + line("%s", notes) + end +end + +for _, name in ipairs(names) do + local spec = Schemas.REGISTRIES[name] + line("") + line("## %s", name) + line("") + line("- semantics: `%s`", spec.semantics) + line("- target: %s", spec.target and ("`Data." .. spec.target .. "`") or "none") + if spec.deprecated then + line("- **deprecated** -- use %s", spec.deprecated.useInstead) + end + -- The mirror of the Gen 2 gating below. Six registries exist because GOLD + -- does (the phone book, the decorations, the radio dial), so they carry no + -- Gen 1 target and "- target: none" on its own reads as a broken entry + -- rather than as the deliberate one-generation registry it is. + if Schemas.gatedFor(name, 1) then + line("") + line("Gen 2 only: Red, Blue and Yellow have no such system, so there is no") + line("Gen 1 table to merge into and a write here on a Gen 1 boot is dropped") + line("and reported. See the Gold subsection below for where it does land.") + end + renderSchema(spec) + renderExample(spec.example, spec.notes) + + -- Gold. The registry NAME and the verbs are shared across generations, so + -- what a Gen 2 subsection says is only ever WHERE the merge lands and what + -- a record there looks like -- or that there is nowhere to land, which is a + -- write the loader drops and reports rather than a name a mod may not use. + local gen2Target = Schemas.targetFor(name, spec, 2) + local shaped = Schemas.hasGen2Shape(spec) + if Schemas.gatedFor(name, 2) then + line("") + line("### On Gold (Gen 2)") + line("") + line("No Gen 2 home: Gold reimplements this system without reading the") + line("registry, so a write here is dropped and reported on a Gold boot.") + line("`docs/mod-api-gen2-compat.md` in the engine repo lists what is left") + line("to do for each one.") + elseif gen2Target ~= spec.target or shaped then + local gen2 = Schemas.shapeFor(name, spec, 2) + line("") + line("### On Gold (Gen 2)") + line("") + line("- semantics: `%s`", gen2.semantics) + line("- target: `Data.%s`", gen2Target) + if shaped then + line("") + line("The record differs; the registry name, the verbs and the id space") + line("do not.") + renderSchema(gen2) + renderExample(spec.gen2Example, spec.gen2Notes) + else + renderExample(spec.gen2Example, spec.gen2Notes) + end end end diff --git a/tools/goldwalk/flags.lua b/tools/goldwalk/flags.lua new file mode 100644 index 00000000..673e0940 --- /dev/null +++ b/tools/goldwalk/flags.lua @@ -0,0 +1,81 @@ +-- const_def parser for the pokegold constant files. +-- +-- The Gold bot's route (tests/drivers/gold/route.lua) asserts postconditions by +-- EVENT_* / ENGINE_* name, but nothing in the extracted cache carries those +-- names: the port stores flags as the bare numeric ids the cart's scripts use +-- (src/world/gen2/Events.lua keys a bitfield by id, and Vm passes `setflag` +-- operands straight through). This turns pret's `const_def` blocks back into +-- name -> id so a route row can say EVENT_BEAT_FALKNER and mean something. +-- +-- Whether that mapping is LEGITIMATE is a real question and not an assumption: +-- src/import/RomExtractorGen2.lua:3234 warns that "Retail Gold's numeric EVENT_* +-- values differ from pret's current const_def order", which is why +-- extractInitialEvents reads the ids off the cart instead of hardcoding them. +-- tests/gold_flag_names_test.lua settles it empirically every run, by replaying +-- pret's InitializeEventsScript against the ids the extractor pulled from the +-- ROM. As of this writing all 108 agree, spanning ids 37..1915, so the +-- orderings are the same file -- but the test is what keeps that true, and a +-- future pret renumber turns into a red test rather than a bot that walks into +-- Sprout Tower checking the wrong bit. +-- +-- local Flags = dofile("tools/goldwalk/flags.lua") +-- local byName, byId = Flags.parse("../pokegold/constants/event_flags.asm") + +local Flags = {} + +-- rgbds const_def semantics, only the four directives these two files use: +-- const_def [N] start numbering at N (0 when omitted) +-- const NAME assign the current number, then advance +-- const_skip [N] advance N (default 1) without naming -- a retired flag, +-- and skipping it wrongly would shift every later id by one +-- const_next N jump the counter to N outright (the files use this to +-- start each WRAM region on a round number) +function Flags.parse(path) + local fh = assert(io.open(path, "r"), "cannot open " .. path) + local byName, byId, n = {}, {}, 0 + for raw in fh:lines() do + -- Strip trailing comments first, so `const_skip ; unused` reads as a bare + -- skip and not as skip-nothing. + local line = raw:gsub(";.*", "") + if line:match("const_def") then + n = tonumber(line:match("const_def%s+(%d+)")) or 0 + elseif line:match("const_skip") then + n = n + (tonumber(line:match("const_skip%s+(%d+)")) or 1) + elseif line:match("const_next") then + n = tonumber(line:match("const_next%s+(%d+)")) or n + else + local name = line:match("^%s*const%s+([%w_]+)") + if name then + byName[name] = n + byId[n] = name + n = n + 1 + end + end + end + fh:close() + return byName, byId +end + +-- The setevent list of one label in an .asm file, in source order. Used only +-- by the verification test, which needs pret's InitializeEventsScript body to +-- line up against the ids the extractor read out of the cart. +function Flags.setEventsOf(path, label) + local fh = assert(io.open(path, "r"), "cannot open " .. path) + local names, inside = {}, false + for line in fh:lines() do + if line:match("^" .. label .. ":") then + inside = true + elseif inside then + local ev = line:match("^%s*setevent%s+([%w_]+)") + if ev then + names[#names + 1] = ev + elseif line:match("^%s*end") or line:match("^[%a_][%w_]*:") then + break -- next label: the body is over + end + end + end + fh:close() + return names +end + +return Flags diff --git a/tools/goldwalk/gen_flags.lua b/tools/goldwalk/gen_flags.lua new file mode 100644 index 00000000..e700cb88 --- /dev/null +++ b/tools/goldwalk/gen_flags.lua @@ -0,0 +1,59 @@ +-- Writes tests/drivers/gold/flag_names.lua from a pokegold checkout. +-- +-- luajit tools/goldwalk/gen_flags.lua [../pokegold] +-- +-- The output is committed so a bot run needs only this repo and a Gold cache; +-- the disassembly is a build-time input, not a runtime one. Regenerate after +-- pulling pokegold, then run tests/gold_flag_names_test.lua -- it re-derives +-- the same mapping from the cart and fails if pret has renumbered anything. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Flags = dofile("tools/goldwalk/flags.lua") + +local ROOT = arg[1] or "../pokegold" +local OUT = "tests/drivers/gold/flag_names.lua" + +local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm") +local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm") + +-- Deterministic output: a pairs() order would rewrite the whole file on every +-- run and bury a real renumber in the diff noise. +local function sortedKeys(t) + local keys = {} + for k in pairs(t) do keys[#keys + 1] = k end + table.sort(keys) + return keys +end + +local function emit(fh, name, tbl) + fh:write((" %s = {\n"):format(name)) + for _, k in ipairs(sortedKeys(tbl)) do + fh:write((" %s = %d,\n"):format(k, tbl[k])) + end + fh:write(" },\n") +end + +local fh = assert(io.open(OUT, "w")) +fh:write([[ +-- Generated by tools/goldwalk/gen_flags.lua from pret/pokegold. Do not edit. +-- +-- EVENT_* / ENGINE_* name -> the numeric id the cart's scripts carry, which is +-- what src/world/gen2/Events.lua and World:engineFlag key on. Verified against +-- the ROM by tests/gold_flag_names_test.lua. +return { +]]) +fh:write((" source = %q,\n"):format("pret/pokegold constants/{event,engine}_flags.asm")) +emit(fh, "events", events) +emit(fh, "engine", engine) +fh:write("}\n") +fh:close() + +local function count(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +print(("wrote %s : %d EVENT_*, %d ENGINE_*") + :format(OUT, count(events), count(engine))) diff --git a/tools/goldwalk/mapgraph.lua b/tools/goldwalk/mapgraph.lua new file mode 100644 index 00000000..721a357c --- /dev/null +++ b/tools/goldwalk/mapgraph.lua @@ -0,0 +1,672 @@ +-- Offline reachability over the extracted Gold cache. +-- +-- luajit tools/goldwalk/mapgraph.lua path CHERRYGROVE_CITY ILEX_FOREST +-- luajit tools/goldwalk/mapgraph.lua map ROUTE_32 +-- luajit tools/goldwalk/mapgraph.lua reach ROUTE_32 18 6 +-- luajit tools/goldwalk/mapgraph.lua exits ROUTE_32 +-- luajit tools/goldwalk/mapgraph.lua audit (every map's exits) +-- luajit tools/goldwalk/mapgraph.lua graph > tests/drivers/gold/map_regions.lua +-- +-- The route bot's planner works from the live world, which means every question +-- about the map graph used to cost a run. This answers the same questions from +-- the cache in milliseconds: which exits of a map are reachable from which +-- others, whether a connection's border is actually walkable, and what the real +-- shortest path between two maps is. `path` is the ground truth the bot's +-- travel is measured against; `reach` is what says whether a warp cell can be +-- walked to from where the player lands. +-- +-- Surf is modelled as an option (`--surf`) rather than a fact, because whether +-- water counts as passable is exactly the difference between a route that needs +-- HM03 and one that does not. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Permissions = require("src.world.gen2.Permissions") + +local CACHE = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") + +local maps = assert(loadfile(CACHE .. "/data/generated/maps.lua"))() +local tilesets = assert(loadfile(CACHE .. "/data/generated/tilesets.lua"))() + +local SURF = false +for _, a in ipairs(arg) do if a == "--surf" then SURF = true end end + +-- --------------------------------------------------------------------------- +-- geometry +-- --------------------------------------------------------------------------- + +local function tilesetOf(def) + -- Extracted tilesets are keyed both ways depending on the table; try id then + -- name so this does not depend on which the extractor happened to write. + return tilesets[def.tilesetId] or tilesets[(def.tilesetId or 0) + 1] + or tilesets[def.tileset] +end + +local function cellCollision(def, cx, cy) + local w, h = def.width, def.height + local bx, by = math.floor(cx / 2), math.floor(cy / 2) + local id + if bx < 0 or by < 0 or bx >= w or by >= h then + id = def.borderBlock or 0 + else + id = def.blocks[by * w + bx + 1] or 0 + end + if id == 0 then return 0xff end + local ts = tilesetOf(def) + local quad = ts and ts.collision and ts.collision[id + 1] + if not quad then return 0xff end + return quad[(cy % 2) * 2 + (cx % 2) + 1] or 0xff +end + +local function inBounds(def, cx, cy) + return cx >= 0 and cy >= 0 and cx < def.width * 2 and cy < def.height * 2 +end + +-- Warp cells are holes in the floor, not floor. +-- +-- Stepping onto one leaves the map, so a flood fill that walks THROUGH warps +-- draws a map that cannot be walked. Route 33 is the case that proves it: its +-- northern strip and its southern strip touch at exactly one cell, (11,9), and +-- that cell is the Union Cave entrance. Treat it as floor and Route 33 is one +-- connected map, its west border reaches Azalea Town proper, and the planner +-- confidently routes a walk that ends in a cave. Treat it as a hole and the +-- truth appears: you reach southern Route 33, and therefore Azalea, only by +-- coming out of Union Cave. +-- ...but only when the tile itself is a warp. CheckWarpTile reads the +-- COLLISION, so a `warp_event` sitting on plain floor never fires -- and +-- Ecruteak Gym is full of those: several of its thirty floor-hole coordinates +-- are ordinary floor, and they are the safe path between the pits. Treating +-- the coordinate as the hole cut that gym in two and hid Morty. +local function warpIndexAt(def, cx, cy) + for i, w in ipairs(def.warps or {}) do + if w.x == cx and w.y == cy then + local coll = cellCollision(def, cx, cy) + if Permissions.isWarpCollision(coll) + or Permissions.carpetDirection(coll) ~= nil then + return i + end + return nil + end + end + return nil +end + +local function floorAt(def, cx, cy) + if not inBounds(def, cx, cy) then return false end + local coll = cellCollision(def, cx, cy) + if Permissions.isWalkable(coll) then return true end + if SURF and Permissions.isWater(coll) then return true end + return false +end + +local function passable(def, cx, cy) + if not floorAt(def, cx, cy) then return false end + return warpIndexAt(def, cx, cy) == nil +end + +-- Movement is DIRECTED, and the fill has to be too. +-- +-- Two cart mechanics make cells one-way (see Permissions.stepPermitted / +-- ledgeFacings): Gold's side-wall arms -- standing on an UP_WALL you may not +-- move up, and nobody may step DOWN onto one -- and ledge hops, where a +-- refused step off a HOP tile jumps TWO cells in its own directions and never +-- comes back. Burned Tower B1F is the map that forced this: its fall-landing +-- pockets drain into the main floor only through hop-down ledges, so an +-- undirected fill called them sealed and the planner teleported out. +-- +-- A "region" is therefore a strongly connected component -- the cells that can +-- all reach EACH OTHER -- and a region's usable exits are computed over its +-- forward closure (everything it can reach, one-way drains included). +local DELTA4 = { { 0, -1, "up" }, { 0, 1, "down" }, + { -1, 0, "left" }, { 1, 0, "right" } } + +local function collOf(def) + return function(x, y) return cellCollision(def, x, y) end +end + +-- Successors of (x, y): ordinary steps plus ledge hops, exactly the engine's +-- order (a hop fires only when the single step is refused). +local function stepsFrom(def, x, y) + local out = {} + local co = collOf(def) + local hop = Permissions.ledgeFacings(cellCollision(def, x, y)) + for _, d in ipairs(DELTA4) do + local nx, ny, dir = x + d[1], y + d[2], d[3] + if Permissions.stepPermitted(co, x, y, dir) and passable(def, nx, ny) then + out[#out + 1] = { nx, ny } + elseif hop and hop[dir] then + local hx, hy = x + d[1] * 2, y + d[2] * 2 + if passable(def, hx, hy) then out[#out + 1] = { hx, hy } end + end + end + return out +end + +-- Can (x, y) step or hop onto (tx, ty)? Used by the backward fill. +local function stepsOnto(def, x, y, tx, ty) + if not passable(def, x, y) then return false end + for _, s in ipairs(stepsFrom(def, x, y)) do + if s[1] == tx and s[2] == ty then return true end + end + return false +end + +-- Forward-reachable set from (sx,sy) (the honest "what can I reach from +-- here"), keyed y*4096+x. +local function region(def, sx, sy) + local key = function(x, y) return y * 4096 + x end + local seen = { [key(sx, sy)] = true } + local queue, head = { { sx, sy } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, s in ipairs(stepsFrom(def, c[1], c[2])) do + local k = key(s[1], s[2]) + if not seen[k] then + seen[k] = true + queue[#queue + 1] = s + end + end + end + return seen +end + +-- Everything that can REACH (sx,sy): the same fill over reversed edges. A +-- predecessor is an adjacent cell stepping in, or a cell two out hopping in. +local function regionBackward(def, sx, sy) + local key = function(x, y) return y * 4096 + x end + local seen = { [key(sx, sy)] = true } + local queue, head = { { sx, sy } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, d in ipairs(DELTA4) do + for _, dist in ipairs({ 1, 2 }) do + local px, py = c[1] + d[1] * dist, c[2] + d[2] * dist + local k = py * 4096 + px + if not seen[k] and stepsOnto(def, px, py, c[1], c[2]) then + seen[k] = true + queue[#queue + 1] = { px, py } + end + end + end + end + return seen +end + +-- The strongly connected component of (sx,sy): forward ∩ backward. +local function scc(def, sx, sy) + local fwd = region(def, sx, sy) + local bwd = regionBackward(def, sx, sy) + local out = {} + for k in pairs(fwd) do + if bwd[k] then out[k] = true end + end + return out +end + +local DIR_DELTA = { north = { 0, -1 }, south = { 0, 1 }, + west = { -1, 0 }, east = { 1, 0 } } + +-- Border cells of `def` on the given side that a player could stand on. +local function borderCells(def, dir) + local w, h = def.width * 2, def.height * 2 + local out = {} + if dir == "north" then + for x = 0, w - 1 do if passable(def, x, 0) then out[#out + 1] = { x, 0 } end end + elseif dir == "south" then + for x = 0, w - 1 do + if passable(def, x, h - 1) then out[#out + 1] = { x, h - 1 } end + end + elseif dir == "west" then + for y = 0, h - 1 do if passable(def, 0, y) then out[#out + 1] = { 0, y } end end + else + for y = 0, h - 1 do + if passable(def, w - 1, y) then out[#out + 1] = { w - 1, y } end + end + end + return out +end + +-- --------------------------------------------------------------------------- +-- the graph +-- --------------------------------------------------------------------------- +-- A node is (map, region): two warps on the same map that cannot walk to each +-- other are genuinely different places, which is the fact a map-only graph +-- misses and the bot's planner pays for at run time. + +-- A warp you can LEAVE through, as opposed to one you can only arrive on. +-- +-- Half the warp_events in a multi-floor interior sit on plain floor: they are +-- the landing spot of a ladder on the other side, and CheckWarpTile will never +-- fire on them because the collision is not a warp. Offering them as exits is +-- how the Olivine lighthouse trapped the bot -- 3F's seven-cell pocket has +-- three warps in it, two of them (8 and 9, both `coll=00`) arrival-only, and +-- the planner kept choosing those instead of the one real ladder at (9,5). +local function firesAsExit(def, w) + local coll = cellCollision(def, w.x, w.y) + return Permissions.isWarpCollision(coll) + or Permissions.carpetDirection(coll) ~= nil +end + +local function exitsOf(id) + local def = maps[id] + if not def then return {} end + local out = {} + for i, w in ipairs(def.warps or {}) do + if w.destMap and maps[w.destMap] and firesAsExit(def, w) then + out[#out + 1] = { kind = "warp", index = i, x = w.x, y = w.y, + to = w.destMap, destWarp = w.destWarp } + end + end + for dir, conn in pairs(def.connections or {}) do + if conn.mapId and maps[conn.mapId] then + out[#out + 1] = { kind = "edge", dir = dir, to = conn.mapId, + offset = conn.offset or 0 } + end + end + return out +end + +-- Where does an exit deposit the player on the destination map? +local function landingOf(id, exit) + local def = maps[id] + local destDef = maps[exit.to] + if not destDef then return nil end + if exit.kind == "warp" then + local w = (destDef.warps or {})[exit.destWarp] + if w then return w.x, w.y end + -- destWarp out of range happens on a few one-way warps; fall back to the + -- first warp so the graph still has an anchor. + local first = (destDef.warps or {})[1] + return first and first.x, first and first.y + end + local dw, dh = destDef.width * 2, destDef.height * 2 + local d = DIR_DELTA[exit.dir] + if not d then return nil end + -- The connection strip lands you on the far border, offset in blocks. + if exit.dir == "north" then return nil, dh - 1 end + if exit.dir == "south" then return nil, 0 end + if exit.dir == "west" then return dw - 1, nil end + return 0, nil +end + +-- Can a player standing in region `reg` on `id` USE this exit? +-- +-- A warp's own cell is a hole, so it is never inside a region: what matters is +-- whether the region touches it. A connection is usable if the region reaches +-- any cell of that border. +local function exitReachable(id, exit, reg) + local def = maps[id] + if exit.kind == "warp" then + if reg[exit.y * 4096 + exit.x] then return true end + for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do + if reg[(exit.y + d[2]) * 4096 + (exit.x + d[1])] then return true end + end + return false + end + for _, c in ipairs(borderCells(def, exit.dir)) do + if reg[c[2] * 4096 + c[1]] then return true end + end + return false +end + +-- Every distinct standing region of a map, keyed by a representative cell. +-- +-- `cells` is the strongly connected component (mutual reachability -- what +-- "standing in this region" means), `forward` its full forward closure (what +-- a player standing there can get to, one-way drains included). Exits are +-- judged over `forward`; landings are matched against `cells`. +local function regionsOf(id) + local def = maps[id] + local seen, out = {}, {} + local w, h = def.width * 2, def.height * 2 + for y = 0, h - 1 do + for x = 0, w - 1 do + local k = y * 4096 + x + if not seen[k] and passable(def, x, y) then + local comp = scc(def, x, y) + for rk in pairs(comp) do seen[rk] = true end + out[#out + 1] = { x = x, y = y, cells = comp, + forward = region(def, x, y) } + end + end + end + return out +end + +-- --------------------------------------------------------------------------- +-- commands +-- --------------------------------------------------------------------------- + +-- The region graph, in memory. `graph` prints it and `path` searches it, so +-- the tool's answer and the bot's plan cannot drift apart. +local function buildGraph() + local ids = {} + for id in pairs(maps) do ids[#ids + 1] = id end + table.sort(ids) + + local regions = {} + for _, id in ipairs(ids) do regions[id] = regionsOf(id) end + + local function regionsAt(id, x, y) + local out, seen = {}, {} + local function add(cx, cy) + for index, r in ipairs(regions[id] or {}) do + if r.cells[cy * 4096 + cx] and not seen[index] then + seen[index] = true + out[#out + 1] = index + end + end + end + add(x, y) + if #out == 0 then + for _, d in ipairs({ { 0, -1 }, { 0, 1 }, { -1, 0 }, { 1, 0 } }) do + add(x + d[1], y + d[2]) + end + end + return out + end + + local graph = {} + for _, id in ipairs(ids) do + local list = {} + for _, r in ipairs(regions[id]) do + local size = 0 + for _ in pairs(r.cells) do size = size + 1 end + local links = {} + for _, e in ipairs(exitsOf(id)) do + if exitReachable(id, e, r.forward) then + local landings = {} + if e.kind == "warp" then + local lx, ly = landingOf(id, e) + if lx and ly then landings = regionsAt(e.to, lx, ly) end + else + local destDef = maps[e.to] + local destW, destH = destDef.width * 2, destDef.height * 2 + local offset = (e.offset or 0) * 2 + local seen = {} + for _, c in ipairs(borderCells(maps[id], e.dir)) do + if r.forward[c[2] * 4096 + c[1]] then + local lx, ly + if e.dir == "north" then + lx, ly = c[1] - offset, destH - 1 + elseif e.dir == "south" then + lx, ly = c[1] - offset, 0 + elseif e.dir == "west" then + lx, ly = destW - 1, c[2] - offset + else + lx, ly = 0, c[2] - offset + end + lx = math.max(0, math.min(destW - 1, lx)) + ly = math.max(0, math.min(destH - 1, ly)) + for _, k in ipairs(regionsAt(e.to, lx, ly)) do + if not seen[k] then + seen[k] = true + landings[#landings + 1] = k + end + end + end + end + end + for _, k in ipairs(landings) do + if e.kind == "warp" then + links[#links + 1] = { k = "w", i = e.index, x = e.x, y = e.y, + to = e.to, r = k } + else + links[#links + 1] = { k = "e", d = e.dir, to = e.to, r = k } + end + end + end + end + list[#list + 1] = { x = r.x, y = r.y, size = size, exits = links } + end + if #list > 0 then graph[id] = list end + end + return graph, ids +end + +local cmd = arg[1] + +local function requireMap(id) + if not maps[id] then + io.stderr:write(("no such map: %s\n"):format(tostring(id))) + os.exit(1) + end + return maps[id] +end + +if cmd == "map" then + local id = arg[2] + local def = requireMap(id) + print(("%s %dx%d blocks (%dx%d cells) tileset %s") + :format(id, def.width, def.height, def.width * 2, def.height * 2, + tostring(def.tileset))) + print("connections:") + for dir, conn in pairs(def.connections or {}) do + local cells = borderCells(def, dir) + print((" %-6s -> %-28s offset %-4d %d walkable border cells") + :format(dir, tostring(conn.mapId), conn.offset or 0, #cells)) + end + print("warps:") + for i, w in ipairs(def.warps or {}) do + print((" %2d (%3d,%3d) -> %-28s warp %s") + :format(i, w.x, w.y, tostring(w.destMap), tostring(w.destWarp))) + end + local regs = regionsOf(id) + print(("regions: %d"):format(#regs)) + for i, r in ipairs(regs) do + local n = 0 + for _ in pairs(r.cells) do n = n + 1 end + local names = {} + for _, e in ipairs(exitsOf(id)) do + if exitReachable(id, e, r.forward) then + names[#names + 1] = (e.kind == "warp" + and ("w%d->%s"):format(e.index, e.to) + or ("%s->%s"):format(e.dir, e.to)) + end + end + print((" region %d from (%d,%d): %4d cells, exits: %s") + :format(i, r.x, r.y, n, + #names > 0 and table.concat(names, " ") or "(none)")) + end + +elseif cmd == "reach" then + local id, x, y = arg[2], tonumber(arg[3]), tonumber(arg[4]) + local def = requireMap(id) + local reg = region(def, x, y) + local n = 0 + for _ in pairs(reg) do n = n + 1 end + print(("from (%d,%d) on %s: %d reachable cells"):format(x, y, id, n)) + for _, e in ipairs(exitsOf(id)) do + local ok = exitReachable(id, e, reg) + print((" %-4s %-30s %s") + :format(ok and "ok" or "NO", + e.kind == "warp" + and ("warp %d (%d,%d) -> %s"):format(e.index, e.x, e.y, e.to) + or ("%s edge -> %s"):format(e.dir, e.to), + "")) + end + +elseif cmd == "exits" then + local id = arg[2] + requireMap(id) + for _, e in ipairs(exitsOf(id)) do + if e.kind == "warp" then + print(("warp %2d (%3d,%3d) -> %s"):format(e.index, e.x, e.y, e.to)) + else + print(("%-6s edge -> %s (offset %d, %d walkable border cells)") + :format(e.dir, e.to, e.offset, #borderCells(maps[id], e.dir))) + end + end + +elseif cmd == "path" then + -- Shortest walkable path over (map, region) nodes. This is what the bot's + -- travel SHOULD find; a discrepancy is a planner bug, not a map bug. + local from, to = arg[2], arg[3] + requireMap(from); requireMap(to) + local startDef = maps[from] + local startCell + if arg[4] and arg[5] then + startCell = { tonumber(arg[4]), tonumber(arg[5]) } + else + local w = (startDef.warps or {})[1] + startCell = w and { w.x, w.y } or { 0, 0 } + end + + -- Node identity: map id + a canonical cell of the region we are standing in. + local regionCache = {} + local function regionAt(id, x, y) + local key = ("%s#%d,%d"):format(id, x, y) + if regionCache[key] then return regionCache[key] end + local reg = region(maps[id], x, y) + regionCache[key] = reg + return reg + end + local function canon(id, reg) + local best + for k in pairs(reg) do if not best or k < best then best = k end end + return ("%s@%s"):format(id, tostring(best)) + end + + local startReg = regionAt(from, startCell[1], startCell[2]) + local startKey = canon(from, startReg) + local dist = { [startKey] = 0 } + local prev = {} + local queue = { { id = from, reg = startReg, key = startKey, x = startCell[1], + y = startCell[2] } } + local head = 1 + local found + while head <= #queue do + local node = queue[head]; head = head + 1 + if node.id == to then found = node break end + for _, e in ipairs(exitsOf(node.id)) do + if exitReachable(node.id, e, node.reg) then + -- Where the exit puts us. A warp names one cell. A CONNECTION is a + -- strip, not a cell, and which cell you land on is decided by where + -- along the border you crossed -- so every region that touches the far + -- border is a landing this exit can produce. Modelling it as a single + -- cell was wrong in a way that mattered: Azalea Town's east border is + -- touched by two regions, one of them a 27-cell pocket with no exit but + -- the way back, and picking that one made the whole of Azalea look + -- unreachable from Route 33. + local landings = {} + local lx, ly = landingOf(node.id, e) + if lx and ly then + landings[#landings + 1] = { lx, ly } + else + local opposite = ({ north = "south", south = "north", + west = "east", east = "west" })[e.dir] + for _, c in ipairs(borderCells(maps[e.to], opposite)) do + landings[#landings + 1] = c + end + end + for _, c in ipairs(landings) do + -- floorAt, not passable: a warp's landing is usually ON the far + -- warp's own tile (a door mat), and `passable` calls every warp + -- tile a hole -- which silently dropped every door landing and made + -- `path` report NO ROUTE into any interior. The region fill from a + -- warp cell expands through its standable neighbours, so seeding on + -- the mat is fine. + if floorAt(maps[e.to], c[1], c[2]) then + local reg = regionAt(e.to, c[1], c[2]) + local key = canon(e.to, reg) + if dist[key] == nil then + dist[key] = dist[node.key] + 1 + prev[key] = { node = node, exit = e } + queue[#queue + 1] = { id = e.to, reg = reg, key = key, + x = c[1], y = c[2] } + end + end + end + end + end + end + + if not found then + print(("NO WALKABLE ROUTE %s -> %s%s") + :format(from, to, SURF and " (even with surf)" or " (try --surf)")) + os.exit(2) + end + local chain = {} + local node = found + while prev[node.key] do + local step = prev[node.key] + table.insert(chain, 1, { from = step.node.id, exit = step.exit }) + node = step.node + end + print(("%s -> %s : %d hops%s") + :format(from, to, #chain, SURF and " (surf allowed)" or "")) + for i, hop in ipairs(chain) do + local e = hop.exit + print((" %2d %-30s %s"):format(i, hop.from, + e.kind == "warp" and ("warp %d (%d,%d) -> %s"):format(e.index, e.x, e.y, e.to) + or ("%s edge -> %s"):format(e.dir, e.to))) + end + +elseif cmd == "graph" then + -- Emit the region graph the bot plans over. + -- + -- luajit tools/goldwalk/mapgraph.lua graph > tests/drivers/gold/map_regions.lua + -- + -- A node is (map, region): a map's walkable cells split by whatever the + -- player cannot walk across, including its own warp tiles. That split is the + -- thing a map-id graph cannot say and the bot kept paying for -- "Route 33 + -- connects west to Azalea Town" is true of one half of Route 33 and false of + -- the other, and "Azalea Town connects east to Route 33" is true of the town + -- and of a 27-cell dead end that only looks like the town from outside. + local graph, ids = buildGraph() + local out = {} + out[#out + 1] = "-- GENERATED by tools/goldwalk/mapgraph.lua graph." + out[#out + 1] = "-- Region-aware map graph for the Gold route bot: see that" + out[#out + 1] = "-- tool's `graph` command for what a region is and why the" + out[#out + 1] = "-- bot cannot plan without one. Regenerate after any change" + out[#out + 1] = "-- to the extractor's map output." + out[#out + 1] = "return {" + for _, id in ipairs(ids) do + local regs = graph[id] + if regs then + out[#out + 1] = (" [%q] = {"):format(id) + for _, r in ipairs(regs) do + local links = {} + for _, e in ipairs(r.exits) do + if e.k == "w" then + links[#links + 1] = ("{k=\"w\",i=%d,x=%d,y=%d,to=%q,r=%d}") + :format(e.i, e.x, e.y, e.to, e.r) + else + links[#links + 1] = ("{k=\"e\",d=%q,to=%q,r=%d}") + :format(e.d, e.to, e.r) + end + end + out[#out + 1] = (" { x = %d, y = %d, size = %d, exits = { %s } },") + :format(r.x, r.y, r.size, table.concat(links, ", ")) + end + out[#out + 1] = " }," + end + end + out[#out + 1] = "}" + print(table.concat(out, "\n")) + +elseif cmd == "audit" then + -- Every connection whose border has no walkable cell on one side: those are + -- the edges the bot will try, fail, and price -- and each one is either a map + -- the player is meant to reach through a gate instead, or an extractor bug. + local bad = 0 + local ids = {} + for id in pairs(maps) do ids[#ids + 1] = id end + table.sort(ids) + for _, id in ipairs(ids) do + local def = maps[id] + for dir, conn in pairs(def.connections or {}) do + local cells = borderCells(def, dir) + if #cells == 0 then + bad = bad + 1 + print(("%-30s %-6s -> %-30s NO walkable border cell") + :format(id, dir, tostring(conn.mapId))) + end + end + end + print(("%d connections have an unwalkable near border"):format(bad)) + +else + print("usage: mapgraph.lua {map|reach|exits|path|audit} [args] [--surf]") +end diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py new file mode 100644 index 00000000..12a3db48 --- /dev/null +++ b/tools/make_gold_manifest.py @@ -0,0 +1,1230 @@ +#!/usr/bin/env python3 +"""Generate tools/rom_manifest_gold.json from pret/pokegold + pokegold.sym. + +Phase 1 Gen 2 (Pokemon Gold) counterpart to tools/make_rom_manifest.py. It +deliberately does not reuse tools/extract/* (that package is pokered-shaped: +ASM_DEFINES pins _RED, and several helpers assume Gen 1's flat map order and +Kanto dex numbering), so this file carries its own small RGBDS constant +parser instead. + +Differences from Gen 1 that shape this file: + - Species order IS dex order: BaseData's rows are declared in + constants/pokemon_constants.asm order, and each row's own first byte is + that same dex number (data/pokemon/base_stats/*.asm `db <SPECIES>`). + There is no separate dexOrder/gen1_order remap to carry. + - Maps are grouped (MAPGROUP_*/MAP_*, constants/map_constants.asm's + `newgroup`/`map_const`/`endgroup`), not one flat table. + - Pokemon pics and tileset graphics are lz3-compressed + (home/decompress.asm), not pkmncompress'd -- see Rom.decompressLz3. + +Usage: python3 tools/make_gold_manifest.py +Default paths: pokegold at ../pokegold (relative to the repo) or +/Users/bryanbassett/Documents/development/pokegold; symbols at +/Users/bryanbassett/Documents/development/pokegold-symbols/pokegold.sym. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +# Reuse Gen 1's fontCharmap parser (seq→code for Font.encode); Gold's +# constants/charmap.asm is the same shape for the $60-$FF draw range. +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) +from extract import font as font_extract # noqa: E402 +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from rom_data import CANONICAL_GOLD_SHA1, SymbolTable # noqa: E402 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_POKEGOLD_CANDIDATES = [ + os.path.join(os.path.dirname(REPO_ROOT), "pokegold"), + "/Users/bryanbassett/Documents/development/pokegold", +] +DEFAULT_SYMBOLS = ( + "/Users/bryanbassett/Documents/development/pokegold-symbols/pokegold.sym") + +# pokegold builds Gold with _GOLD defined (rgbdscheck.asm / the Makefile's +# `gold` target); none of the constants files this script reads are +# actually gated on it today, but resolving IF DEF(_GOLD)/_SILVER here +# (rather than skipping IF entirely, like tools/extract/util.py does for +# unmodeled conditions) keeps this future-proof if that ever changes. +ASM_DEFINES = {"_GOLD"} + + +def strip_comment(line): + out = [] + in_str = False + for ch in line: + if ch == '"': + in_str = not in_str + elif ch == ";" and not in_str: + break + out.append(ch) + return "".join(out).rstrip() + + +def read_asm(path): + """Read an asm file as (lineno, text), comments stripped, IF resolved.""" + lines = [] + stack = [] # stack of [taking, condition_known] + with open(path, encoding="utf-8") as f: + for lineno, raw in enumerate(f, 1): + line = strip_comment(raw.rstrip("\n")) + s = line.strip() + m = re.match(r"IF\s+(!)?DEF\((\w+)\)\s*$", s, re.IGNORECASE) + if m: + defined = m.group(2) in ASM_DEFINES + taking = (not defined) if m.group(1) else defined + stack.append([taking, True]) + continue + if re.match(r"IF\b", s): + stack.append([True, False]) # unmodeled condition: keep body + continue + if re.match(r"ELSE\s*$", s, re.IGNORECASE) and stack: + if stack[-1][1]: + stack[-1][0] = not stack[-1][0] + continue + if re.match(r"ENDC\s*$", s, re.IGNORECASE) and stack: + stack.pop() + continue + if any(not fr[0] for fr in stack): + continue + lines.append((lineno, line)) + return lines + + +def parse_number(tok): + tok = tok.strip() + neg = tok.startswith("-") + if neg: + tok = tok[1:].strip() + if tok.startswith("$"): + val = int(tok[1:], 16) + elif tok.startswith("%"): + val = int(tok[1:], 2) + elif tok.isdigit(): + val = int(tok) + else: + raise ValueError(f"not a number: {tok!r}") + return -val if neg else val + + +def parse_const_block(path, stop_at=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); + the same shape tools/extract/util.parse_const_block returns for Gen 1. + """ + names = [] + value = None + for _, line in read_asm(path): + s = line.strip() + if not s: + continue + if stop_at and re.match(rf"DEF\s+{stop_at}\b", s): + break + m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) + if m: + value = parse_number(m.group(1)) if m.group(1) else 0 + continue + m = re.match(r"const_next\s+(\$?\w+)$", s) + if m: + value = parse_number(m.group(1)) + continue + m = re.match(r"const\s+(\w+)", s) + if m and value is not None: + while len(names) < value: + names.append(None) + names.append(m.group(1)) + value += 1 + continue + m = re.match(r"const_skip(?:\s+(\d+))?$", s) + if m and value is not None: + n = int(m.group(1)) if m.group(1) else 1 + for _ in range(n): + names.append(None) + value += n + return names + + +def parse_const_block_at(path, first_const, stop_at=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 + same file rewinds its counter without rewinding the list, so it can only + read a file's FIRST block. phone_constants.asm stacks two (PHONE_* then + SPECIALCALL_*) and script_constants.asm stacks a dozen, so anything past + the first needs to be found by name. Holes are kept as None: PHONE_* has + four `const_skip`s in the middle and they are real PhoneContacts rows. + """ + names = [] + value = None + started = False + for _, line in read_asm(path): + s = line.strip() + if not s: + continue + m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) + if m: + if started: + break + value = parse_number(m.group(1)) if m.group(1) else 0 + names = [] + continue + if started and stop_at and re.match(rf"DEF\s+{stop_at}\b", s): + break + m = re.match(r"const\s+(\w+)", s) + if m and value is not None: + if not started: + if m.group(1) != first_const: + continue + started = True + while len(names) < value: + names.append(None) + names.append(m.group(1)) + value += 1 + continue + m = re.match(r"const_skip(?:\s+(\d+))?$", s) + if m and started and value is not None: + n = int(m.group(1)) if m.group(1) else 1 + for _ in range(n): + names.append(None) + value += n + if not started: + raise SystemExit(f"{path}: no const block starting at {first_const}") + return names + + +def parse_prefixed_consts(path, prefixes, exact=()): + """Ordered const names from a file, filtered by prefix (mixed blocks). + + map_data_constants.asm and friends stack several unrelated const_def + blocks in one file, so scraping by prefix is the only way to pull one + of them out without the neighbours bleeding in. `exact` names are kept + even when they carry no shared prefix (TOWN, BALL, ...). + """ + out = [] + for _, line in read_asm(path): + m = re.match(r"const\s+(\w+)", line.strip()) + if not m: + continue + name = m.group(1) + if name in exact or any(name.startswith(p) for p in prefixes): + out.append(name) + return out + + +def parse_sparse_consts(path, prefixes): + """Ordered const names where index IS the const value (sparse blocks). + + parse_prefixed_consts packs a block densely, which is wrong for a block + that jumps: constants/item_data_constants.asm's HELD_* run uses + const_skip holes and const_next jumps to 10/20/30/40/50/70, so a dense + scrape shifts every value after the first hole (HELD_HEAL_POISON is 10, + not 7 -- and QUICK_CLAW at 74 fell off the end of the list entirely). + Holes carry placeholder names because the manifest's JSON round trip + collapses nulls out of an array. + """ + out = [] + value = None + for _, line in read_asm(path): + s = line.strip() + m = re.match(r"const_def(?:\s+(\d+))?\s*$", s) + if m: + value = int(m.group(1) or 0) + continue + m = re.match(r"const_next\s+(\d+)\s*$", s) + if m and value is not None: + value = int(m.group(1)) + continue + m = re.match(r"const_skip(?:\s+(\d+))?\s*$", s) + if m and value is not None: + value += int(m.group(1) or 1) + continue + m = re.match(r"const\s+(\w+)", s) + if not m or value is None: + continue + name = m.group(1) + if any(name.startswith(p) for p in prefixes): + while len(out) < value: + out.append("%sUNUSED_%d" % (prefixes[0], len(out))) + out.append(name) + value += 1 + return out + + +def extract_trainer_classes(pokegold): + """Ordered trainer class names (constants/trainer_constants.asm). + + `trainerclass NAME` bumps its own counter and resets the const_def used + for that class's individual trainer ids, so parse_const_block cannot see + them -- the two interleaved sequences need separate passes. Index in the + returned list IS the class id (TRAINER_NONE = 0). + """ + path = os.path.join(pokegold, "constants/trainer_constants.asm") + classes = [] + members = {} + current = None + for _, line in read_asm(path): + s = line.strip() + m = re.match(r"trainerclass\s+(\w+)", s) + if m: + current = m.group(1) + classes.append(current) + members[current] = [] + continue + m = re.match(r"const\s+(\w+)", s) + if m and current and not m.group(1).startswith("PHONECONTACT_"): + members[current].append(m.group(1)) + if not classes or classes[0] != "TRAINER_NONE" or classes[1] != "FALKNER": + raise ValueError("trainer_constants.asm did not parse as expected") + return classes, members + + +def extract_items(pokegold): + """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 + are declared through `add_tm DYNAMICPUNCH` / `add_hm CUT` macros that expand + to `const TM_DYNAMICPUNCH` / `const HM_CUT`, and two plain `const ITEM_C3` / + `ITEM_DC` sit *inside* that run consuming ids without being TMs. Stopping + at NUM_ITEMS (as a linear parse must) loses all 57 TM/HM items; ignoring it + loses the boundary where ItemNames stops having rows. + + Returns (order, name_count) where order[i - 1] is item id i; NO_ITEM (0) is + dropped so the list is 1-based on the id, matching ItemNames' rows. + """ + path = os.path.join(pokegold, "constants/item_constants.asm") + order = [] + name_count = None + in_macro = False + for _, line in read_asm(path): + 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. + if re.match(r"MACRO\b", s): + in_macro = True + continue + if re.match(r"ENDM\b", s): + in_macro = False + continue + if in_macro: + continue + if re.match(r"DEF\s+NUM_ITEMS\b", s): + # NUM_ITEMS is const_value - 1, i.e. however many ids came before. + name_count = len(order) + continue + m = re.match(r"const\s+(\w+)", s) + if m: + order.append(m.group(1)) + continue + m = re.match(r"add_tm\s+(\w+)", s) + if m: + order.append("TM_" + m.group(1)) + continue + m = re.match(r"add_hm\s+(\w+)", s) + if m: + order.append("HM_" + m.group(1)) + continue + if not order or order[0] != "NO_ITEM": + raise ValueError("item_constants.asm did not parse as expected") + if not name_count: + raise ValueError("item_constants.asm: NUM_ITEMS not found") + # Drop NO_ITEM and the count that included it. + return order[1:], name_count - 1 + + +def extract_specials(pokegold): + """Ordered SpecialsPointers labels (data/events/special_pointers.asm). + + The `special` script command carries an index into this table, not a name, + so without the order a disassembled `special 27` says nothing. Index in the + returned list IS the id. + """ + path = os.path.join(pokegold, "data/events/special_pointers.asm") + names = [] + in_macro = False + for _, line in read_asm(path): + s = line.strip() + if re.match(r"MACRO\b", s): + in_macro = True + continue + if re.match(r"ENDM\b", s): + in_macro = False + continue + if in_macro: + continue + m = re.match(r"add_special\s+(\w+)", s) + if m: + names.append(m.group(1)) + if not names or names[0] != "WarpToSpawnPoint": + raise ValueError("special_pointers.asm did not parse as expected") + return names + + +def extract_std_scripts(pokegold): + """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): + m = re.match(r"add_stdscript\s+(\w+)", line.strip()) + if m: + names.append(m.group(1)) + if not names or names[0] != "PokecenterNurseScript": + raise ValueError("std_scripts.asm did not parse as expected") + return names + + +def extract_map_groups(pokegold): + """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): + s = line.strip() + if re.match(r"newgroup\s+\w+", s): + group += 1 + map_index = 0 + continue + m = re.match(r"map_const\s+(\w+),\s*([\d$%-]+),\s*([\d$%-]+)", s) + if m: + map_index += 1 + name = m.group(1) + order.append(name) + groups[name] = { + "name": name, + "group": group, + "map": map_index, + "width": parse_number(m.group(2)), + "height": parse_number(m.group(3)), + } + if not order or order[0] != "OLIVINE_POKECENTER_1F": + raise ValueError("map_constants.asm did not parse as expected") + return order, groups + + +def extract_types(pokegold): + """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): + s = line.strip() + m = re.match(r"const_def(?:\s+(\$?\w+))?$", s) + if m: + value = parse_number(m.group(1)) if m.group(1) else 0 + continue + m = re.match(r"const_next\s+(\$?\w+)$", s) + if m: + value = parse_number(m.group(1)) + continue + m = re.match(r"const\s+(\w+)", s) + if m and value is not None: + types[m.group(1)] = value + value += 1 + if types.get("NORMAL") != 0 or "DARK" not in types: + raise ValueError("type_constants.asm did not parse as expected") + return types + + +def charmap(pokegold): + """Byte -> text charmap, same shape as make_rom_manifest.charmap.""" + expansions = { + "<DOT>": ".", + "<LV>": "{LV}", + "<ID>": "{ID}", + # 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 + # decoded text asks the font for a glyph that cannot exist -- which is + # what turns "#DEX" into "DEX" and "<POKE>GEAR" into "<POKE>GEAR". + # The comments in charmap.asm name each expansion. + "#": "POKé", # $54 + "<POKE>": "<PO><KE>", # $24 + "<PKMN>": "<PK><MN>", # $4a + } + out = {} + path = os.path.join(pokegold, "constants/charmap.asm") + for _, line in read_asm(path): + m = re.match( + r'charmap\s+"((?:[^"\\]|\\.)*)",\s*(\$[0-9a-fA-F]+)', + line.strip()) + if not m: + continue + value = int(m.group(2)[1:], 16) + if str(value) in out: + continue + seq = m.group(1).replace('\\"', '"') + out[str(value)] = expansions.get(seq, seq) + return out + + +def species_label(species_id): + """SPECIES_CONST_NAME -> the SpeciesConstName label pics use. + + Matches every dba_pics entry in data/pokemon/pic_pointers.asm: strip + underscores (NIDORAN_F -> NidoranF, MR__MIME -> MrMime, + FARFETCH_D -> FarfetchD, HO_OH -> HoOh) and title-case what remains. + """ + parts = [part for part in species_id.split("_") if part] + return "".join(part.capitalize() for part in parts) + + +def pokemon_names(pokegold): + """PokemonNames dname entries, in declared (dex) order.""" + names = [] + path = os.path.join(pokegold, "data/pokemon/names.asm") + for _, line in read_asm(path): + m = re.match(r'dname\s+"([^"]*)"', line.strip()) + if m: + names.append(m.group(1)) + return names + + +def music_order(pokegold): + """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): + m = re.match(r"dba\s+(Music_\w+)\s*$", line.strip()) + if m: + order.append(m.group(1)) + if not order: + raise ValueError("no Music_* dba rows in audio/music_pointers.asm") + return order + + +def sfx_order(pokegold): + """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): + m = re.match(r"dba\s+(Sfx_\w+)\s*$", line.strip()) + if m: + order.append(m.group(1)) + if not order: + raise ValueError("no Sfx_* dba rows in audio/sfx_pointers.asm") + return order + + +def pokemon_assets(pokegold, species_order, symbols): + """{species: {id, name, front, back, frontLabel, backLabel}}. + + Unown shares one pic per letter through UnownPicPointers (its + dba_pics row in pic_pointers.asm is deliberately blank) rather than a + 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. + """ + names = pokemon_names(pokegold) + assets = {} + for index, species in enumerate(species_order): + name = names[index] if index < len(names) else 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. + assets[species] = { + "id": species, "name": name, + "front": None, "back": None, + "frontLabel": None, "backLabel": None, + "dexLabel": "UnownPokedexEntry", + } + 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( + f"{species}: expected pic symbols {front_label}/{back_label} " + "are missing from pokegold.sym") + # Pokedex entry symbol, resolved here for the same reason the pic + # labels are: the entries live in four different banks and the game + # derives the bank from the species id arithmetically. + dex_label = base + "PokedexEntry" + assets[species] = { + "id": species, "name": name, + "front": base.lower(), "back": base.lower() + "_back", + "frontLabel": front_label, "backLabel": back_label, + "dexLabel": dex_label if dex_label in symbols.by_name else None, + } + return assets + + +REQUIRED_SYMBOLS = { + "BaseData", "PokemonNames", "PokemonPicPointers", "UnownPicPointers", + "EggPic", + # gfx/evo/egg_hatch.2bpp, the two OBJ tiles EggHatch_AnimationSequence + # copies to vTiles0 tile $00 for the shell crack and the ten fragments + # (engine/pokemon/breeding.asm:777). + "EggHatchGFX", + "Font", "FontExtra", "FontBattleExtra", "Frames", + # The extra font page is NOT FontExtra laid down from $60: _LoadFontsExtra + # (engine/gfx/load_font.asm:7-20) builds it from three sources, and the + # first three tiles come from the other two. gfx/font.asm keeps them well + # away from FontExtra, so each needs its own symbol: + # $60-$61 FontsExtra_SolidBlackAndUpArrowGFX, 2 tiles, 1bpp + # (gfx/font/black.1bpp + gfx/font/up_arrow.1bpp) + # $62 PokegearPhoneIconGFX, 1 tile, 2bpp (gfx/font/phone_icon.2bpp) + # $63+ FontExtra + 3 tiles, 22 tiles, 2bpp + # Loading FontExtra from $60 instead puts its unused <BOLD_A>/<BOLD_B>/ + # <BOLD_C> in those three cells -- constants/charmap.asm:41 marks that + # $62 mapping "unused" and :88 gives $62 to "☎" -- which is why the + # Pokegear caller box drew a bold C where the phone icon belongs. + "PokegearPhoneIconGFX", "FontsExtra_SolidBlackAndUpArrowGFX", + # gfx/font/unown_font.2bpp (gfx/font.asm UnownFont): the 26 Unown letters + # plus the diamond cursor, loaded at vTiles2 tile FIRST_UNOWN_CHAR ($40) + # by Pokedex_LoadUnownFont for the #DEX's UNOWN MODE. + "UnownFont", + "TitleScreenGFX1", "TitleScreenGFX2", "TitleScreenGFX3", + "TitleScreenGFX4", "TitleScreenTilemap", + "CopyrightGFX", "Tilesets", "MapGroupPointers", "Music", "Cries", + # _AnimateTileset's `dw arg / dw function` programs (data/tileset_anims.asm) + # and the two shared frame strips (engine/tilesets/tileset_anims.asm:194, + # :225): the extractor resolves a tileset's Anim pointer against these. + "DoneTileAnimation", "WaitTileAnimation", + "StandingTileFrame", "StandingTileFrame8", + "AnimateWaterTile", "AnimateFlowerTile", "AnimateWaterPalette", + "ReadTileToAnimBuffer", "WriteTileFromAnimBuffer", + "ScrollTileRightLeft", "ScrollTileDown", "ScrollTileUp", + "ScrollTileLeft", "ScrollTileRight", "AnimateWhirlpoolTile", + "AnimateLavaBubbleTile1", "AnimateLavaBubbleTile2", + "AnimateTowerPillarTile", "FlickeringCaveEntrancePalette", + "AnimateWaterTile.WaterTileFrames", "AnimateFlowerTile.FlowerTileFrames", + # AnimateLavaBubbleTile1/2 hardcode their source (tileset_anims.asm:251, + # :276); the whirlpool and tower-pillar strips come off the pointer pairs + # their own tileframe arguments name, so they need no symbol here. + "LavaBubbleTileFrames", + # data/sprites/emotes.asm:22 `emote GrassRustleGFX, 1, $fe`, the one tile + # ShakeGrass' SPRITEMOVEDATA_GRASS object draws. + "GrassRustleGFX", + "PokemonCries", "SFX", + "OverworldSprites", "ChrisSpriteGFX", "Moves", "EvosAttacksPointers", + # data/sprites/sprite_mons.asm: one species byte per SPRITE_POKEMON id, + # which GetMonSprite's .Icon arm feeds to LoadOverworldMonIcon + "SpriteMons", + # Phase 2: roofs overlay outdoor Johto towns (engine/tilesets/mapgroup_roofs.asm) + "MapGroupRoofs", "Roofs", + # New-game object visibility (engine/events/std_scripts.asm InitializeEventsScript) + "InitializeEventsScript", + # Gen 2 audio driver: wave RAM patterns + drum kit pointer table + "WaveSamples", "Drumkits", + # Oak speech intro (engine/menus/intro_menu.asm OakSpeech) + "PokemonProfPic", "CalPic", + "_OakText1", "_OakText2", "_OakText3", "_OakText4", + "_OakText5", "_OakText6", "_OakText7", + # Boot cinema (splash.asm / ShrinkPlayer). GameFreakLogoGFX is two + # INCBINs run together -- gamefreak_presents.1bpp (13 tiles) then + # gamefreak_logo.1bpp (15) -- and GameFreakLogoStarsGFX is another two, + # logo_star.2bpp (2) then logo_sparkle.2bpp (3). + "GameFreakLogoGFX", "GameFreakLogoStarsGFX", "Shrink1Pic", "Shrink2Pic", + # Credits roll graphics (engine/movie/credits.asm). CreditsBorderGFX is + # the 9-tile strip on rows 4 and 13; the four mon labels are 4x4-tile + # frames stacked (3 frames each, Sentret 4); TheEndGFX lives in its own + # "The End" section in gfx/misc.asm; CreditsPalettes is gfx/credits/ + # credits.pal, six four-colour sets. + "CreditsBorderGFX", "CreditsBellossomGFX", "CreditsTogepiGFX", + "CreditsElekidGFX", "CreditsSentretGFX", "TheEndGFX", "CreditsPalettes", + # The #DEX diploma (engine/events/diploma.asm PlaceDiplomaOnScreen). + # DiplomaGFX is gfx/diploma/diploma.2bpp.lz, 112 tiles into vTiles2; + # DiplomaPage1Tilemap is gfx/diploma/page1.tilemap, a whole SCREEN_AREA + # copied straight over the background; DiplomaPalettes is gfx/diploma/ + # diploma.pal (engine/gfx/color.asm), which _CGB_Diploma loads before + # WipeAttrmap puts every tile on set 0. + "DiplomaGFX", "DiplomaPage1Tilemap", "DiplomaPalettes", + # The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/). + # TradeGameBoyLZ is game_boy_cable.2bpp.lz, 49 tiles into vTiles2 tile $31, + # which both tilemaps and the jumptable's loose cable ids index; the rest + # are the OAM objects -- the ball, the poof, the tube bulge (gfx/trade/ + # cable.png) and the mon icon's bubble -- plus the two one-tile arrows + # TradeAnim_PlaceTrademonStatsOnTubeAnim fills across the window. + "TradeGameBoyLZ", "TradeGameBoyTilemap", "TradeLinkTubeTilemap", + "TradeBallGFX", "TradePoofGFX", "TradeCableGFX", "TradeBubbleGFX", + "TradeArrowRightGFX", "TradeArrowLeftGFX", + # Item names (data/items/names.asm) for giveitem / verbosegiveitem + "ItemNames", + # Mart shelves (data/items/marts.asm): Marts is the NUM_MARTS pointer + # table `pokemart` indexes, each list `db count, items..., -1`. + # BargainShopData (data/items/bargain_shop.asm) is the Goldenrod + # Underground shop's own `dbw item, price` rows -- the one shop whose + # prices do not come from ItemAttributes. + "Marts", "BargainShopData", + # GBC colour (engine/gfx/color.asm LoadMapPals). TilesetBGPalette is the + # shared pool of $2a four-colour palettes; EnvironmentColorsPointers picks + # 8 of them per environment per time of day; MapObjectPals recolours OW + # sprites; RoofPals overrides PAL_BG_ROOF colours 1-2 per map group. + "TilesetBGPalette", "EnvironmentColorsPointers", "MapObjectPals", + "RoofPals", "PokemonPalettes", "TrainerPalettes", + "HPBarPals", "ExpBarPalette", "PartyMenuOBPals", + # Battle + pokemon runtime data + "MoveNames", "TypeNames", "TypeMatchups", "TMHMMoves", "GrowthRates", + "ItemAttributes", "MoveDescriptions", "ItemDescriptions", + # Wild encounters (data/wild/*) + "JohtoGrassWildMons", "JohtoWaterWildMons", + "KantoGrassWildMons", "KantoWaterWildMons", + "FishGroups", "TreeMons", "TreeMonMaps", + # data/wild/treemon_maps.asm RockMonMaps: the four maps whose smashable + # rocks roll TREEMON_SET_ROCK, read by RockMonEncounter. + "RockMonMaps", + # data/wild/swarm_grass.asm + swarm_water.asm, searched by + # _SwarmWildmonCheck BEFORE the Johto/Kanto tables while the player stands + # on wSwarmMapGroup/Number, and data/wild/roammon_maps.asm RoamMaps, the + # graph UpdateRoamMons and JumpRoamMon walk the beasts along. + "SwarmGrassWildMons", "SwarmWaterWildMons", "RoamMaps", + # data/wild/bug_contest_mons.asm, which is NOT a grass table: its rows are + # `db %, species, min, max` with no map key and no time of day, and + # ChooseWildEncounter_BugContest (engine/overworld/events.asm) walks them + # with `ld de, 4`. + "ContestMons", + # data/events/bug_contest_flags.asm: the ten + # EVENT_BUG_CATCHING_CONTESTANT_*A words + # SelectRandomBugContestContestants (engine/events/bug_contest/ + # contest_2.asm) resets and then sets five of, a set flag being what keeps + # that trainer off NationalParkBugContest. + "BugCatchingContestantEventFlagTable", + # Trainers (data/trainers/*) + "Trainers", "TrainerGroups", "TrainerClassNames", + "TrainerClassAttributes", "TrainerPicPointers", + "TrainerEncounterMusic", + # Menus: party icons, dex ordering + entries, Pokegear landmarks. The two + # held-item marker tiles ride on the party icons: GetIconGFX uploads + # HeldItemIcons straight after each icon's eight tiles + # (engine/gfx/mon_icons.asm:218-228), so the party list cannot draw the + # marker without this symbol. + "MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons", + "PokedexDataPointerTable", + # New-game / Pokecenter respawn table (data/maps/spawn_points.asm) + "SpawnPoints", + "NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks", + # GoldSilverIntro (engine/movie/intro.asm): three acts of BG art, each a + # compressed tile sheet plus a 2x2 metatile table and a metatile grid, and + # the OBJ sheets the mons animate from + "Intro_WaterGFX1", "Intro_WaterTilemap", "Intro_WaterMeta", + "Intro_WaterGFX2", + "Intro_GrassGFX1", "Intro_GrassTilemap", "Intro_GrassMeta", + "Intro_GrassGFX2", + "Intro_FireGFX1", "Intro_FireGFX2", "Intro_FireGFX3", + # ...and its palettes. _CGB_GSIntro carries the water act's inline, takes + # the grass and starter ones out of PredefPals, and reaches the fire act's + # through the four PREDEFPAL_* indices inside PalPacket_Pack. + "PredefPals", "PalPacket_Pack", + "_CGB_GSIntro.ShellderLaprasBGPalette", "_CGB_GSIntro.ShellderLaprasOBPals", + "Intro_LoadMagikarpPalettes.MagikarpBGPal", + "Intro_LoadMagikarpPalettes.MagikarpOBPal", + # Battle animations (data/moves/animations.asm + data/battle_anims/*). + # BattleAnimations is a 278-entry pointer table indexed by the animation + # id, which for a move IS its move id; the four tables under it are the + # object rows an animation spawns, their framesets, the OAM sets those + # framesets step through, and the compressed sheets the tiles come from. + "BattleAnimations", "BattleAnimObjects", "BattleAnimFrameData", + "BattleAnimOAMData", "AnimObjGFX", + # gfx/battle_anims/battle_anims.pal: the six OBJ palettes an animation + # object names by PAL_BATTLE_OB_GRAY..PAL_BATTLE_OB_BROWN. The routine + # that copies them (CGBCopyBattleObjectPals) is dummied out on the cart -- + # _CGB_BattleColors loads the same block -- but the label is still in the + # ROM, so the colours are read rather than transcribed. + "BattleObjectPals", + # Battle HUD tiles (engine/gfx/load_font.asm LoadHPBar): the L-shaped + # frame tiles the enemy and player HUDs are built from, and the exp bar's + # nine fill cells. "HP:" and the ten HP-bar cells come from + # FontBattleExtra, which is already extracted. + "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX", + # The player's own battle back-pic (gfx/player/chris_back.2bpp.lz). It is + # what stands in the player's pic box for the whole battle intro, before + # SendOutPlayerMon swaps in the mon's backpic. + "ChrisBackpic", + # UnownPicPointers: 26 rows of `dba_pics front, back`, one per letter. + # PokemonPicPointers' UNOWN row is only the A form, so the other 25 are + # unreachable without this table. + "UnownPicPointers", + # Naming screen chrome (gfx/naming_screen/*): the patterned tile that + # fills the backdrop, the 2-tile cursor, and the middle/under lines that + # mark the name-entry field + "NamingScreenGFX_Border", "NamingScreenGFX_Cursor", + "NamingScreenGFX_MiddleLine", "NamingScreenGFX_UnderLine", + # PACK chrome (engine/items/pack.asm): the screen's own $60-tile sheet, + # the four 15-tile pack pictures DrawPackGFX swaps per pocket, the 5x12 + # pocket-name tilemap, and the six BG palettes _CGB_PackPals loads. + "PackMenuGFX", "PackGFX", "DrawPocketName.tilemap", + "_CGB_PackPals.PackPals", + # #DEX chrome (engine/pokedex/pokedex.asm): Pokedex_LoadGFX decompresses + # PokedexLZ over vTiles2 tile $31, and every frame, divider and label tile + # the dex screens name comes out of that one sheet. + # PokedexSlowpokeLZ is the dex's OBJ sheet: the search screen's Slowpoke + # animation, and behind it the $30-$33 bracket tiles the listing cursor is + # built from plus the $0f scrollbar thumb. + "PokedexLZ", "PokedexSlowpokeLZ", "PokedexCursorPalette", + "LoadQuestionMarkPic.QuestionMarkLZ", "PokedexQuestionMarkPalette", + "Footprints", + # POKeGEAR (engine/pokegear/pokegear.asm): TownMapGFX at $00 and + # PokegearGFX at $30 are the card sheets; the three cards are RLE + # tilemaps; JohtoMap/KantoMap are the painted town maps; TownMapPals.PalMap + # is the tile-id -> BG palette nybble table that colours them. + "TownMapGFX", "PokegearGFX", "PokegearSpritesGFX", + "ClockTilemapRLE", "PhoneTilemapRLE", "RadioTilemapRLE", + "JohtoMap", "KantoMap", "TownMapPals.PalMap", "PokegearPals", + # Trainer card (engine/menus/trainer_card.asm): the player's portrait and + # the card frame share one sheet at $00, the status/leader sheets both + # load at $29, and the badges are OBJs with their own OAM template table. + "ChrisPicAndTrainerCardGFX", "CardStatusGFX", "LeaderGFX", "BadgeGFX", + "TrainerCard_JohtoBadgesOAM", + # Unown puzzle (engine/games/unown_puzzle.asm): the four pictures the Ruins + # of Alph chambers slice into sixteen panels, the START>CANCEL box and + # caption sheet that lands at vTiles0 $ed, the four cursor OBJ tiles at + # $e0, and the eight border tiles UnownPuzzle_AddPuzzlePieceBorders ORs + # onto every panel once the picture has been doubled in size. + "KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ", + "UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX", + "PuzzlePieceBorderData.TileBordersGFX", + # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer + # who just spotted the player, and the other faces scripts use. + "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", + # The Pokecenter heal machine's OBJ art (engine/events/ + # heal_machine_anim.asm): two tiles -- the machine's light ($7c) and the + # ball ($7d) -- plus the CGB palette .LoadPalettes copies over + # PAL_OW_TREE for the duration of the light show. + "HealMachineAnim.HealMachineGFX", "HealMachineAnim.palettes", + # Magnet Train (engine/events/magnet_train.asm). Two uncompressed + # tilemaps: a 2x18 strip DrawMagnetTrain repeats across the background, + # and the 20x4 train laid over rows 6-9. The cutscene loads no tiles of + # its own -- both index TILESET_TRAIN_STATION, already in VRAM. + "MagnetTrainBGTiles", "MagnetTrainTilemap", + # callstd / jumpstd targets (engine/events/std_scripts.asm) + "StdScripts", + # The phone (data/phone/*.asm). PhoneContacts is a row per PHONE_* + # constant carrying two `dba` script pointers -- the callee half (you rang + # them) and the caller half (they rang you) -- and SpecialPhoneCallList a + # row per SPECIALCALL_* carrying one more. Every one of those pointers + # lands in bank $41, which no map script points at, so without these two + # symbols the whole bank stays unreachable and a queued call has no body. + "PhoneContacts", "SpecialPhoneCallList", + # The three little scripts the phone engine runs WITHOUT a contact row + # (engine/phone/phone.asm): the wrong-number arm LoadCallerScript falls to, + # the no-signal / wrong-hour arm, and "they are on this very map, go talk + # to them". Nothing points at any of them either. + "WrongNumber.script", "PhoneOutOfAreaScript", "PhoneScript_JustTalkToThem", + # Egg moves (data/pokemon/egg_moves.asm): 251 `dw` into bank 8, each a + # $ff-terminated move list. Breeding.canInheritMove reads def.eggMoves. + "EggMovePointers", + # In-game trades (data/events/npc_trades.asm), the `trade` command's table, + # and the 5x3 text table PrintTradeText indexes by dialog then dialog SET + # (engine/events/npc_trade.asm). NPCTradeCableText is the "cable" line + # between the yes and the animation; TradedForText is the one after it. + "NPCTrades", "TradeTexts", "NPCTradeCableText", "TradedForText", + # The six lines the trade ANIMATION prints around the swap + # (engine/movie/trade_animation.asm, strings in data/text/common_1.asm). + # They are printed by asm, not by a writetext, so the text walker only + # reaches them by name -- and their {STRBUF} markers are the trademon + # name buffers, which is the only thing that tells "MACHOP was sent to + # MIKE" from the same line with the two names the other way round. + # _MonNameSentToText is deliberately absent: it is empty. + "_MonWasSentToText", "_ForYourMonSendsText", "_OTSendsText", + "_BidsFarewellToMonText", "_MonNameBidsFarewellText", + "_TakeGoodCareOfMonText", + # Elevator floor labels (data/events/elevator_floors.asm). The floor LIST + # an `elevator` names lives in the script's own bank; this is the shared + # FLOOR_* -> "B1F@" name table the ride menu prints. + "ElevatorFloorNames", + # describedecoration's five DECODESC_* arms (engine/overworld/decorations.asm). + # Each arm is asm that picks a SCRIPT off what is installed in the player's + # room, so the scripts are what the extractor wants, not the arms: + # DecorationDesc_PosterPointers is the poster table (`dbw deco, script` + # rows, `db -1` end) with NullPoster as its miss; the two ornaments and the + # console all share .OrnamentConsoleScript, and the giant ornament has + # .BigDollScript to itself. + "DecorationDesc_PosterPointers", "DecorationDesc_NullPoster", + "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + "DecorationDesc_GiantOrnament.BigDollScript", + # Text an ENGINE routine prints rather than a script (RomExtractorGen2's + # NAMED_TEXT). The text walker only follows a writetext pointer, so + # without these labels the Day-Care and breeding block of common_1.asm / + # common_2.asm, the whole POKeMART conversation, and the Hall of Fame's + # three flavour strings never reach the cache at all. + "_DaycareDummyText", + "_DayCareManIntroText", "_DayCareManIntroEggText", + "_DayCareLadyIntroText", "_DayCareLadyIntroEggText", + "_WhatShouldIRaiseText", "_OnlyOneMonText", "_CantAcceptEggText", + "_RemoveMailText", "_LastHealthyMonText", "_IllRaiseYourMonText", + "_ComeBackLaterText", "_AreWeGeniusesText", "_YourMonHasGrownText", + "_PerfectHeresYourMonText", "_GotBackMonText", "_BackAlreadyText", + "_HaveNoRoomText", "_NotEnoughMoneyText", "_OhFineThenText", + "_ComeAgainText", "_NotYetText", "_FoundAnEggText", "_ReceivedEggText", + "_TakeGoodCareOfEggText", "_IllKeepItThanksText", "_NoRoomForEggText", + "Text_BreedHuh", "_BreedClearboxText", "_BreedEggHatchText", + "_BreedAskNicknameText", + "_LeftWithDayCareManText", "_LeftWithDayCareLadyText", + "_BreedBrimmingWithEnergyText", "_BreedNoInterestText", + "_BreedAppearsToCareForText", "_BreedFriendlyText", + "_BreedShowsInterestText", + "_MartWelcomeText", "_MartAskMoreText", "_MartComeAgainText", + "_MartHowManyText", "_MartFinalPriceText", "_MartThanksText", + "_MartNoMoneyText", "_MartPackFullText", + "_HerbShopLadyIntroText", "_HerbalLadyHowManyText", + "_HerbalLadyFinalPriceText", "_HerbalLadyThanksText", + "_HerbalLadyPackFullText", "_HerbalLadyNoMoneyText", + "_HerbalLadyComeAgainText", + "_BargainShopIntroText", "_BargainShopFinalPriceText", + "_BargainShopThanksText", "_BargainShopPackFullText", + "_BargainShopSoldOutText", "_BargainShopNoFundsText", + "_BargainShopComeAgainText", + "_PharmacyIntroText", "_PharmacyHowManyText", "_PharmacyFinalPriceText", + "_PharmacyThanksText", "_PharmacyPackFullText", "_PharmacyNoMoneyText", + "_PharmacyComeAgainText", + "_NothingToSellText", "_MartSellHowManyText", "_MartSellPriceText", + "_MartCantBuyText", "_MartBoughtText", + "AnimateHallOfFame.String_NewHallOfFamer", + "_HallOfFamePC.TimeFamer", "_HallOfFamePC.HOFMaster", + # The MAIL block of data/text/common_2.asm, printed by engine/pokemon/ + # mail.asm's MailboxPC and engine/pokemon/mon_menu.asm's MonMailAction. + # Same shape as the Day-Care block: asm prints these, so no bytecode + # points at them and the walker has to be seeded by name. + "_EmptyMailboxText", "_MailClearedPutAwayText", "_MailPackFullText", + "_MailMessageLostText", "_MailAlreadyHoldingItemText", "_MailEggText", + "_MailMovedFromBoxText", "_MailLoseMessageText", "_MailDetachedText", + "_MailNoSpaceText", "_MailAskSendToPCText", "_MailboxFullText", + "_MailSentToPCText", "_PCMonHoldingMailText", "_PokemonRemoveMailText", +} + + +def embedded_symbols(symbols, pokemon_labels, song_labels=()): + """Resolve REQUIRED_SYMBOLS + pic labels + Music_* song headers.""" + names = set(REQUIRED_SYMBOLS) | set(pokemon_labels) | set(song_labels) + for symbol_name in symbols.by_name: + # Pokedex entries are split across four banks and the game derives the + # bank arithmetically from the species id (radio.asm's rlca/maskbits + # dance). Taking each entry's own symbol instead means the extractor + # never has to reproduce that, and a repointed entry still resolves. + if symbol_name.endswith("PokedexEntry") and "." not in symbol_name: + names.add(symbol_name) + if symbol_name.endswith("Frontpic") or symbol_name.endswith("Backpic"): + # Skip qualified locals (e.g. "Foo.BarBackpic") -- those belong + # to unrelated engine routines, not a Pic Pointers table entry. + if "." not in symbol_name: + names.add(symbol_name) + + missing = sorted(name for name in names if name not in symbols.by_name) + if missing: + raise ValueError("required symbols are missing: " + ", ".join(missing)) + return { + name: [symbols[name].bank, symbols[name].address] + for name in sorted(names) + } + + +def generate(pokegold, symbols_path): + symbols = SymbolTable(symbols_path) + + species = parse_const_block( + os.path.join(pokegold, "constants/pokemon_constants.asm"), + stop_at="NUM_POKEMON") + species_order = [n or "UNUSED" for n in species[1:]] + + map_order, map_groups = extract_map_groups(pokegold) + + tilesets = [n for n in parse_const_block( + os.path.join(pokegold, "constants/tileset_constants.asm"), + stop_at="NUM_TILESETS") if n] + + moves = parse_const_block( + os.path.join(pokegold, "constants/move_constants.asm"), + stop_at="NUM_ATTACKS") + move_order = [n or "UNUSED" for n in moves[1:]] + + # sprite_constants.asm stacks the ids of two different tables. $01.. + # NUM_OVERWORLD_SPRITES are OverworldSprites rows (data/sprites/ + # sprites.asm); then `const_next $80` restarts at SPRITE_POKEMON and the + # names from there are SpriteMons rows (data/sprites/sprite_mons.asm), + # which GetMonSprite turns into a mon's menu icon instead of a sheet. + # Both blocks are read into one id-indexed list, because a byte in + # wVariableSprites -- what a doll or a Sudowoodo stands on -- is a raw + # sprite id and the port names it through this list. + # + # It stops at SPRITE_HO_OH on purpose. SPRITE_DAY_CARE_MON_1/_2 ($e0) and + # the SPRITE_VARS block ($f0) past it are not sprites at all: the first + # pair reads a breedmon species and the second is a slot INTO + # 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") + 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 + 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) + + # 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): + s = line.strip() + m = re.match(r"const\s+(\w+)", s) + if not m: + continue + name = m.group(1) + if name in ( + "TOWN", "ROUTE", "INDOOR", "CAVE", "ENVIRONMENT_5", "GATE", + "DUNGEON", + ) or name.startswith("ENVIRONMENT_"): + environments.append(name) + elif name.startswith("PALETTE_"): + palettes.append(name) + elif name.startswith("FISHGROUP_"): + fish_groups.append(name) + elif name.startswith("SPAWN_"): + spawns.append(name) + + # Move effects (EFFECT_*) index the effect jumptable; the extractor turns + # 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")) + move_effect_order = [n or "EFFECT_UNUSED" for n in move_effects] + + # Battle animations. constants/battle_anim_constants.asm stacks nine + # unrelated const_def blocks in one file, so each list is scraped by its + # own prefix -- parse_const_block would run them together. These name the + # rows of the five tables the extractor reads (objects, functions, + # 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_framesets = parse_prefixed_consts( + battle_anim, ("BATTLE_ANIM_FRAMESET_",)) + battle_anim_oamsets = parse_prefixed_consts(battle_anim, ("BATTLE_ANIM_OAMSET_",)) + # 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_",)) + # 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_",)) + + 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_",)) + # 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_",)) + + 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_",)) + + # 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_",)) + + # 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") + floor_order = parse_const_block_at( + script_consts, "FLOOR_B4F", stop_at="NUM_FLOORS") + deco_desc_order = parse_const_block_at( + script_consts, "DECODESC_POSTER", stop_at="NUM_DECODESCS") + + # The phone. PHONE_* doubles as the PhoneContacts row index and carries + # four const_skip holes that are real rows (the wrong-number fillers), so + # the gaps have to survive into the list. + phone_consts = os.path.join(pokegold, "constants/phone_constants.asm") + # The holes are named rather than left null: the JSON decoder the importer + # runs on this file collapses nulls out of an array, which would slide + # 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")] + special_call_order = parse_const_block_at( + phone_consts, "SPECIALCALL_NONE", stop_at="NUM_SPECIALCALLS") + + 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_",)) + + trainer_classes, trainer_members = extract_trainer_classes(pokegold) + trainer_types = parse_prefixed_consts( + os.path.join(pokegold, "constants/trainer_data_constants.asm"), + ("TRAINERTYPE_",)) + + landmarks = parse_const_block( + os.path.join(pokegold, "constants/landmark_constants.asm"), + stop_at="NUM_LANDMARKS") + landmark_order = [n or "UNUSED" for n in landmarks] + + icons = parse_prefixed_consts( + os.path.join(pokegold, "constants/icon_constants.asm"), ("ICON_",)) + + tree_sets = parse_prefixed_consts(mon_data, ("TREEMON_SET_",)) + + std_scripts = extract_std_scripts(pokegold) + specials = extract_specials(pokegold) + + assets = pokemon_assets(pokegold, species_order, symbols) + pokemon_labels = [] + for asset in assets.values(): + if asset["frontLabel"]: + pokemon_labels.append(asset["frontLabel"]) + if asset["backLabel"]: + pokemon_labels.append(asset["backLabel"]) + + songs = music_order(pokegold) + sfx = sfx_order(pokegold) + + # 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) + + data = { + "format": 3, + "generation": 2, + "romSha1": CANONICAL_GOLD_SHA1, + "constants": { + "source": "pret/pokegold constants/*.asm", + "speciesOrder": species_order, + "mapGroups": [map_groups[name] for name in map_order], + "mapOrder": map_order, + "tilesetOrder": tilesets, + "moveOrder": move_order, + "types": types, + "spriteOrder": sprite_order, + # NUM_OVERWORLD_SPRITES: how many leading spriteOrder rows are real + # OverworldSprites rows. Everything from spritePokemon on names a + # SpriteMons row and has no sheet of its own. + "numOverworldSprites": num_overworld_sprites, + # SPRITE_POKEMON ($80): the first mon-icon id, and the base + # GetMonSprite subtracts to index SpriteMons. + "spritePokemon": sprite_pokemon, + "environmentOrder": environments, + "paletteOrder": palettes, + "fishGroupOrder": fish_groups, + # SPAWN_* order; SPAWN_HOME is a new game's start (intro_menu.asm) + "spawnOrder": spawns, + # MUSIC_* id → Music_* label (audio/music_pointers.asm order) + "musicOrder": songs, + # SFX_* id → Sfx_* label (audio/sfx_pointers.asm order) + "sfxOrder": sfx, + # Item id → constant name (MASTER_BALL=1 …); used by extractItems + "itemOrder": item_order, + # How many leading item ids ItemNames has rows for; TM/HM items + # past this point carry no name of their own. + "itemNameCount": item_name_count, + # Battle / pokemon runtime orders + "moveEffectOrder": move_effect_order, + "growthRateOrder": growth_order, + "eggGroupOrder": egg_group_order, + "evolveMethodOrder": evolve_order, + # Bag pockets + item attribute enums (data/items/attributes.asm). + # `property` is a bitfield, not an enum (shift_const CANT_SELECT + # = bit 6, CANT_TOSS = bit 7), so the extractor decodes it + # directly rather than naming it from a list here. + "pocketOrder": pocket_order, + "itemMenuOrder": item_menu_order, + "heldEffectOrder": held_effect_order, + # Trainers: class id → name, class → its own trainer ids + "trainerClassOrder": trainer_classes, + "trainerClassMembers": trainer_members, + "trainerTypeOrder": trainer_types, + # Pokegear town map + wild encounter side tables + "landmarkOrder": landmark_order, + "iconOrder": icons, + "treeMonSetOrder": tree_sets, + # callstd / jumpstd id → StdScripts label + "stdScriptOrder": std_scripts, + # Map script header: callback type id → MAPCALLBACK_* name (1-based, + # index 0 is a placeholder) and the cmdqueue entry's own type enum. + "mapCallbackOrder": map_callback_order, + "cmdQueueOrder": cmd_queue_order, + # `elevator` floor ids and `describedecoration`'s five arms + "floorOrder": floor_order, + "decoDescOrder": deco_desc_order, + # PhoneContacts row → PHONE_* name (holes kept: they are rows), + # and SpecialPhoneCallList row → SPECIALCALL_* name + "phoneContactOrder": phone_contact_order, + "specialCallOrder": special_call_order, + # `trade` row enums (data/events/npc_trades.asm) + "tradeGenderOrder": trade_gender_order, + "tradeDialogOrder": trade_dialog_order, + # `special` id → SpecialsPointers label + "specialOrder": specials, + # Battle animations: the row names of BattleAnimObjects, + # BattleAnimFrameData, BattleAnimOAMData and AnimObjGFX, plus the + # BG-effect and OBJ/BG palette enums an animation names by number. + "battleAnimObjectOrder": battle_anim_objects, + "battleAnimFuncOrder": battle_anim_funcs, + "battleAnimFramesetOrder": battle_anim_framesets, + "battleAnimOamsetOrder": battle_anim_oamsets, + "battleAnimGfxOrder": battle_anim_gfx, + "battleBgEffectOrder": battle_bg_effects, + "battleAnimBgPaletteOrder": battle_anim_bg_pals, + "battleAnimObPaletteOrder": battle_anim_ob_pals, + }, + "charmap": charmap(pokegold), + "fontCharmap": font_extract.parse_charmap(pokegold), + "pokemonAssets": assets, + # Per-map metadata (group/map/width/height/name). RomExtractorGen2 + # resolves ROM headers via MapGroupPointers + these ids. + "maps": {name: map_groups[name] for name in map_order}, + "tilesets": {name: {} for name in tilesets}, + } + data["symbols"] = embedded_symbols(symbols, pokemon_labels, songs) + return data + + +def find_pokegold(): + for candidate in DEFAULT_POKEGOLD_CANDIDATES: + if os.path.isfile(os.path.join(candidate, "main.asm")): + return candidate + return DEFAULT_POKEGOLD_CANDIDATES[-1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pokegold", default=find_pokegold()) + parser.add_argument("--symbols", default=DEFAULT_SYMBOLS) + parser.add_argument( + "--out", + default=os.path.join( + os.path.dirname(__file__), "rom_manifest_gold.json")) + args = parser.parse_args() + + pokegold = os.path.abspath(args.pokegold) + if not os.path.isfile(os.path.join(pokegold, "main.asm")): + raise SystemExit(f"{pokegold} is not a pokegold checkout") + data = generate(pokegold, 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/modkit.py b/tools/modkit.py index 09f868a5..80cd1796 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -9,6 +9,7 @@ Subcommands: translation <id> [--language NAME] [--base auto|fixture|imported] [--refresh] [--dest DIR] [--pixel-font] validate <id|path> [--strict] [--base auto|fixture|imported] + gen2check <id|path> [<id|path>...] [--strict] [--notes] lint <id|path> pack <mod-dir> [-o out.modpkg] bounce <song-id|--all> [--seconds N] [--out DIR] @@ -29,6 +30,15 @@ fixture that rule is reported as skipped rather than guessed at. lint is the no-ROM-content distribution gate (MK3xx); pack runs both at --strict, so any finding -- warning included -- refuses the package. + +gen2check (MK4xx) answers whether a mod runs on a Gen 2 game and how far it +gets: the manifest gate, then a static read of the mod's Lua against what +src/mods/Gen2Compat.lua actually backs, member by member. It is a scan, not +an interpreter -- what it could not follow is listed as unresolved rather +than guessed at -- and it exits non-zero on a finding it calls fatal. Mods +named together are read as one install set, so a mod and its dependencies +answer each other; --notes adds the adapter's own line for every backed +member the mod touches. """ import argparse @@ -333,6 +343,7 @@ MANIFEST_TEMPLATE = """{ "optional_dependencies": [], "conflicts": [], "incompatible": [], + "games": [{{games}}], "experimental": {{experimental}},{{github_line}} "description": "TODO: one line about {{id}}"{{extra}} } @@ -458,6 +469,14 @@ A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). """ +# manifest "games": the version ids / gen tokens a mod declares +# (src/mods/ModTargets.lua). Emitted as a JSON array body. +def games_list(value): + tokens = [t.strip().lower() for t in str(value or "").split(",")] + tokens = [t for t in tokens if t] + return ", ".join('"%s"' % t for t in (tokens or ["gen1"])) + + def cmd_scaffold(args, repo): profile = args.profile dest_root = args.dest or os.path.join(repo, "mods") @@ -491,6 +510,7 @@ def cmd_scaffold(args, repo): "{{github_line}}": github_line, "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", + "{{games}}": games_list(getattr(args, "games", "gen1")), } def emit(rel, template): @@ -1985,6 +2005,1446 @@ def cmd_docs(args, repo): return 0 +# ------------------------------------------------ gen2 compatibility (MK4xx) +# +# What the adapter backs is read out of the engine, never restated here: +# src/mods/Gen2Compat.lua's coverage API is the source of truth and +# docs/mod-api-gen2-compat.md is its prose. The contract this consumes is +# Gen2Compat.coverage(name) -> { kind = "facade"|"alias", target = <module>, +# members = { [member] = "backed"|"warned"|"absent" }, notes = { [member] = +# "one line" } }, with COVERAGE_VERSION naming the vocabulary. Nothing below +# hardcodes a module, a member or a status, so the tool cannot drift from the +# adapter; when the accessor is missing the fallback says so in the notes and +# reports what it could not decide instead of guessing. +# +# MK400 claims no Gen 2 game MK405 member degrades, and says so +# MK401 a dependency claims none MK406 the signature moved under it +# MK402 no adapter for the module MK407 upvalue surgery with no target +# MK403 Gold runs a different module MK408 upvalue surgery, unresolved +# MK404 member has no Gen 2 backing MK409 a mod-side edit no adapter can +# make for it +# MK410 the entry chunk holding a +# member of a game not up yet + +COVERAGE_DUMP = '''\ +package.path = "./?.lua;./?/init.lua;" .. package.path +local G = require("src.mods.Gen2Compat") +local function emit(...) + local row = {} + for i = 1, select("#", ...) do + row[i] = tostring((select(i, ...))):gsub("%s+", " ") + end + print(table.concat(row, "\\t")) +end +emit("VERSION", G.COVERAGE_VERSION or 0) +for name, spec in pairs(G.ADAPTERS or {}) do + emit("ADAPTER", name, type(spec) == "string" and spec or "") + local row = G.coverage and G.coverage(name) + if row then + emit("COVER", name, row.kind or "", row.target or "") + for member, status in pairs(row.members or {}) do + emit("MEMBER", name, member, status) + end + for member, note in pairs(row.notes or {}) do + emit("NOTE", name, member, note) + end + end +end +''' + +# the one status that is a hard stop; the others are named by the adapter +ABSENT = "absent" + + +def module_path(repo, name): + return os.path.join(repo, *name.split(".")) + ".lua" + + +def _module_exists(repo, name): + """Is there a file behind this module name, either way package.path spells + it (conf.lua sets ?.lua and ?/init.lua).""" + return os.path.isfile(module_path(repo, name)) \ + or os.path.isfile(os.path.join(repo, *name.split("."), "init.lua")) + + +def _lua_close(text, index): + """Index of the bracket closing the one at `index`, honouring literals; + None when the file does not balance (a scan limit, not a finding).""" + depth, i = 0, index + while i < len(text): + char = text[i] + if char in "\"'": + quote, i = char, i + 1 + while i < len(text): + if text[i] == "\\": + i += 2 + continue + if text[i] == quote: + break + i += 1 + elif char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + if depth == 0: + return i + i += 1 + return None + + +def _lua_args(text, index): + """(count, varargs) for the call whose '(' is at index; (None, False) when + the parentheses do not balance.""" + close = _lua_close(text, index) + if close is None: + return None, False + inner = text[index + 1:close] + if not inner.strip(): + return 0, False + depth, count, i = 0, 1, 0 + while i < len(inner): + char = inner[i] + if char in "\"'": + quote, i = char, i + 1 + while i < len(inner): + if inner[i] == "\\": + i += 2 + continue + if inner[i] == quote: + break + i += 1 + elif char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif char == "," and depth == 0: + count += 1 + i += 1 + return count, inner.rstrip().endswith("...") + + +LUA_API_CACHE = {} + + +def lua_api(path): + """member -> {"params": [...] or None, "line": n} for a module file: what + it hangs off its own table, plus the fields its constructor writes onto + `self`. Regex over source, so a name assembled at runtime is missed -- + which is why the adapter's own coverage table decides what is backed and + this only ever answers "under what parameters".""" + if path in LUA_API_CACHE: + return LUA_API_CACHE[path] + try: + text = strip_lua(open(path, encoding="utf-8", errors="replace").read()) + except OSError: + LUA_API_CACHE[path] = None + return None + returns = re.findall(r"^return\s+([A-Za-z_]\w*)\s*$", text, re.M) + module = returns[-1] if returns else None + if not module: + counts = {} + for name in re.findall(r"^function\s+([A-Z]\w*)[.:]", text, re.M): + counts[name] = counts.get(name, 0) + 1 + module = max(counts, key=counts.get) if counts else None + api = {} + if module: + def put(member, params, offset, kind): + api.setdefault(member, {"params": params, "kind": kind, + "line": text.count("\n", 0, offset) + 1}) + + for match in re.finditer( + r"^function\s+%s([.:])(\w+)\s*\(([^)]*)\)" % module, text, + re.M): + params = [p.strip() for p in match.group(3).split(",") if p.strip()] + if match.group(1) == ":": + params.insert(0, "self") + put(match.group(2), params, match.start(), "function") + for match in re.finditer( + r"^\s*%s\.(\w+)\s*=\s*(function\s*\(([^)]*)\))?" % module, + text, re.M): + params = None + if match.group(2): + params = [p.strip() for p in match.group(3).split(",") + if p.strip()] + put(match.group(1), params, match.start(), + "function" if params is not None else "value") + # written onto the instance, never onto the module table: the members + # that only exist once a game is running + for match in re.finditer(r"\bself\.(\w+)\s*=(?!=)", text): + put(match.group(1), None, match.start(), "field") + LUA_API_CACHE[path] = api + return api + + +def gen1_only_modules(repo): + """The Gen 1 modules a Gold boot never instantiates, read from the loader + so this tool and the require shim cannot disagree (Loader.lua).""" + try: + src = open(os.path.join(repo, "src", "mods", "Loader.lua"), + encoding="utf-8").read() + except OSError: + return set() + block = re.search(r"GEN1_ONLY_MODULES\s*=\s*\{(.*?)\n\}", src, re.S) + return set(re.findall(r'\["([^"]+)"\]', block.group(1))) if block else set() + + +def _adapters_from_source(repo): + """ADAPTERS as name -> alias target ("" for a built facade), for the + checkout where the coverage accessor cannot be run.""" + try: + src = strip_lua(open(os.path.join(repo, "src", "mods", + "Gen2Compat.lua"), + encoding="utf-8").read()) + except OSError: + return {} + block = re.search(r"ADAPTERS\s*=\s*\{(.*?)\n\}", src, re.S) + if not block: + return {} + return {m.group(1): m.group(2) or "" for m in re.finditer( + r'\["([^"]+)"\]\s*=\s*(?:"([^"]+)"|\w+)', block.group(1))} + + +def gen2_coverage(repo, notes): + """name -> {kind, target, members, notes, declared}, straight off + Gen2Compat.coverage. `members` is None where nothing could answer, which + every check below treats as "unknown", never as "backed".""" + rows = [] + with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, + encoding="utf-8") as handle: + handle.write(COVERAGE_DUMP) + dump_path = handle.name + try: + proc = subprocess.run([LUAJIT, dump_path], cwd=repo, + capture_output=True, text=True, timeout=60) + if proc.returncode == 0: + rows = proc.stdout.splitlines() + else: + notes.append("could not read the adapter table through %s (%s)" + % (LUAJIT, (proc.stderr or "").strip()[-120:])) + except (OSError, subprocess.SubprocessError): + notes.append("could not run %s, so the adapter table was read from " + "the Lua source instead" % LUAJIT) + finally: + os.unlink(dump_path) + + coverage = {} + for row in rows: + parts = row.split("\t") + if parts[0] == "ADAPTER" and len(parts) >= 3: + coverage.setdefault(parts[1], { + "kind": "alias" if parts[2] else "facade", + "target": parts[2], "members": None, "notes": {}, + "declared": False}) + elif parts[0] == "COVER" and len(parts) >= 4: + record = coverage.setdefault(parts[1], {"notes": {}}) + record.update({"kind": parts[2], "target": parts[3], + "members": {}, "declared": True}) + elif parts[0] == "MEMBER" and len(parts) >= 4: + coverage[parts[1]]["members"][parts[2]] = parts[3] + elif parts[0] == "NOTE" and len(parts) >= 4: + coverage[parts[1]]["notes"][parts[2]] = "\t".join(parts[3:]) + if not coverage: + for name, alias in _adapters_from_source(repo).items(): + coverage[name] = {"kind": "alias" if alias else "facade", + "target": alias, "members": None, "notes": {}, + "declared": False} + undeclared = sorted(n for n, r in coverage.items() if not r["declared"]) + if undeclared: + notes.append("no coverage row for %s: this scan can say the adapter " + "serves the name and nothing about its members" + % ", ".join(undeclared)) + return coverage + + +# ------------------------------------------------------------ mod use scan + +class Use: + def __init__(self, rel, line, module, ident, chain, kind, argc, varargs, + guarded, top): + self.rel, self.line, self.module = rel, line, module + self.ident, self.chain = ident, chain # chain: ["data", "field"] + self.kind = kind # "call" | "read" | "write" + self.argc, self.varargs = argc, varargs + # `X.y and X.y(...)` is feature detection, not a nil call + self.guarded = guarded + # at file scope, so it runs while the entry chunk does + self.top = top + + @property + def member(self): + return ".".join(self.chain) + + def where(self): + return f"{self.rel}:{self.line}" + + +# every engine module name the mod spells, however it reaches for it: each one +# is either resolved below or comes back as an unresolved note +MODULE_LITERAL = re.compile(r"""["'](src[./][\w./]+)["']""") +HEAD_PCALL = re.compile(r"""\bpcall\s*\(\s*require\s*,\s*$""") +HEAD_REQUIRE = re.compile(r"""\brequire\s*\(?\s*$""") +HEAD_CALL = re.compile(r"""\b([A-Za-z_][\w.:]*)\s*\(\s*$""") +# the name at the tail of one slot of an assignment's name list +BIND_NAME = re.compile( + r"""(?:^|[\s=({,;])(?:local\s+)?([A-Za-z_]\w*)\s*$""") +# require("src.world.Map").waterTiles(1) and its bracket twin +TAIL_MEMBER = re.compile(r"""^\s*\)?\s*([.:])\s*(\w+)""") +TAIL_INDEX = re.compile(r"""^\s*\)?\s*\[\s*["'](\w+)["']\s*\]""") +# local F = Follower: a module carried on through a second name +ALIAS_BIND = re.compile( + r"""(?:^|[\s;])(?:local\s+)?([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*)\s*""" + r"""(?=[\r\n;]|$)""", re.M) +# local function tryRequire(path) return require(path) end: a mod's own wrapper, +# which the call sites below are followed through +WRAPPER_DEF = re.compile( + r"""\blocal\s+(?:function\s+([A-Za-z_]\w*)\s*\(\s*([A-Za-z_]\w*)""" + r"""|([A-Za-z_]\w*)\s*=\s*function\s*\(\s*([A-Za-z_]\w*))""") +# patchUpvalue(Follower.update, "shouldSpawn", fn): the shape a mod reaches an +# engine file-local through, and the only place the upvalue is named +UPVALUE_CALL = re.compile( + r"\bdebug\s*\.\s*(?:setupvalue|getupvalue|upvaluejoin)\b") +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)["']|["'](red|blue|yellow)["']\s*[=~]=""") + + +def _line_of(body, offset): + return body.count("\n", 0, offset) + 1 + + +BLOCK_WORD = re.compile(r"\b(function|do|if|repeat|end|until)\b") + + +def _blank_strings(text): + """The same text with literal bodies blanked, positions intact: a string + holding the word `end` must not close a block.""" + out, index, size = [], 0, len(text) + while index < size: + char = text[index] + if char in "\"'": + quote, start = char, index + index += 1 + while index < size: + if text[index] == "\\": + index += 2 + continue + index += 1 + if text[index - 1] == quote: + break + chunk = text[start:index] + out.append(quote + " " * (len(chunk) - 2) + quote + if len(chunk) > 1 else chunk) + continue + out.append(char) + index += 1 + return "".join(out) + + +def _function_spans(text): + """Byte ranges a function body covers, so a use inside one can be told + from a use at file scope. Keyword counting rather than parsing: every + `function` / `do` / `if` / `repeat` is closed by exactly one `end` or + `until`, which is all this has to get right.""" + stack, spans = [], [] + for match in BLOCK_WORD.finditer(_blank_strings(text)): + word = match.group(1) + if word in ("end", "until"): + if stack: + kind, start = stack.pop() + if kind == "function": + spans.append((start, match.end())) + else: + stack.append(("function" if word == "function" else "block", + match.start())) + return spans + + +def _local_functions(body, spans): + """(name, parameters, body) for every `local function f(x, y)` and `local f + = function(x, y)` in the file, the body bounded by the function's own span + so a call further down the file is never read as part of it.""" + ends = dict(spans) + out = [] + for match in WRAPPER_DEF.finditer(body): + keyword = body.find("function", match.start(), match.end()) + stop = ends.get(keyword) + inner = body[match.end():stop] if stop else "" + params = [match.group(2) or match.group(4)] + close = inner.find(")") + if close >= 0: + params += [p.strip() for p in inner[:close].split(",") if p.strip()] + out.append((match.group(1) or match.group(3), params, inner)) + return out + + +def _require_wrappers(files): + """The names a mod gives its own require wrapper (`local function + tryRequire(p) return require(p) end`), read across the whole mod so a + wrapper declared in one file is followed at call sites in another.""" + names = set() + for body, spans in files: + for name, params, inner in _local_functions(body, spans): + if re.search(r"\brequire\s*[(,]\s*%s\b" % re.escape(params[0]), + inner): + names.add(name) + return names + + +def _call_args(text, start): + """The arguments of the call whose name ends at `start`, split at depth 0; + None when the parentheses do not balance.""" + paren = text.find("(", start) + if paren < 0 or text[start:paren].strip(): + return None + close = _lua_close(text, paren) + if close is None: + return None + args = text[paren + 1:close] + cuts = _depth_commas(_blank_strings(args)) + return [args[a + 1:b].strip() + for a, b in zip([-1] + cuts, cuts + [len(args)])] + + +def _forwards_upvalue_name(inner, params): + """Does this helper's second parameter really reach the debug call as an + upvalue NAME: handed straight to the name slot, or matched against what + getupvalue answers to find the index. A parameter that only ever lands in + the value slot names nothing, so its call sites are not upvalue surgery.""" + if len(params) < 2: + return False + name, taken = params[1], False + for match in UPVALUE_CALL.finditer(inner): + args = _call_args(inner, match.end()) + if not args or args[0] != params[0]: + continue + if len(args) >= 2 and args[1] == name: + return True + taken = True + return taken and bool(re.search(r"\bdebug\s*\.\s*getupvalue\b", inner)) \ + and bool(re.search(r"(?:[=~]=\s*%s|%s\s*[=~]=)\b" + % (re.escape(name), re.escape(name)), inner)) + + +def _upvalue_helpers(files): + """(confirmed, suspect) helper names. Confirmed is a local function that + forwards its own (function, name) parameters into a debug upvalue call, so + its call sites really do name an engine local; anything else that touches + the debug library is a suspect, whose call sites come back unresolved + rather than being read as named upvalue surgery.""" + names, suspects = set(), set() + for body, spans in files: + for name, params, inner in _local_functions(body, spans): + if not (UPVALUE_CALL.search(inner) or "upvalue" in name.lower()): + continue + (names if _forwards_upvalue_name(inner, params) + else suspects).add(name) + return names, suspects - names + + +def _upvalue_calls(body, helpers, suspects=()): + """(offset, argument text, confirmed) for every call that reaches an + upvalue: the debug library itself and the mod's own helpers around it. + Only these sites name a local of an engine module, so no other `X.y, "z"` + shape is read as upvalue surgery.""" + calls = [] + names = [r"debug\s*\.\s*(?:setupvalue|getupvalue|upvaluejoin)"] + \ + [re.escape(name) for name in sorted(set(helpers) | set(suspects))] + for match in re.finditer(r"\b(%s)\s*\(" % "|".join(names), body): + if re.search(r"\bfunction\s+$", body[:match.start()]): + continue # the helper's own definition, not a call of it + close = _lua_close(body, match.end() - 1) + if close is None: + continue + calls.append((match.start(), body[match.end():close], + match.group(1) not in suspects)) + return calls + + +def _depth_commas(text): + """Offsets of the commas at bracket depth 0 in already-blanked text.""" + depth, out = 0, [] + for index, char in enumerate(text): + if char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif char == "," and depth == 0: + out.append(index) + return out + + +def _name_list(head): + """(names left to right, truncated) for the assignment whose `=` this text + ends before. Read straight back as tokens, so `a, b, c =` is three names + and the walk stops the moment a name slot holds anything else (`t.x`, + `t[1]`, a table field left of the one being written).""" + names, text = [], head + while True: + match = BIND_NAME.search(text) + if not match: + return list(reversed(names)), True + before = text[:match.start(1)].rstrip() + if before[-1:] in (".", ":", "]", ")"): + return list(reversed(names)), True # t.x and friends: not a name + names.append(match.group(1)) + if not before.endswith(","): + return list(reversed(names)), False + text = before[:-1] + + +def _bind_target(pre, slot=0): + """The name a require's result takes, paired positionally so + `local A, B = require(X), require(Y)` gives X to A and Y to B. `slot` is + where the value sits among its own call's returns (1 for the module a + pcall hands back). (name, None), or (None, why) when no pair can be made, + which the caller turns into an unresolved note rather than a guess.""" + blank = _blank_strings(pre).rstrip() + # the value sits in an assignment only if it follows that `=` or a comma + # in its value list; anything else (`return require(...)`) binds nothing + if not (blank.endswith(",") + or (blank.endswith("=") and blank[-2:-1] not in ("=", "~", "<", + ">"))): + return None, "unbound" + depth, eq, index = 0, None, len(blank) - 1 + while index >= 0: + char = blank[index] + if char in ")]}": + depth += 1 + elif char in "([{": + depth -= 1 + if depth < 0: + return None, "unbound" + elif (char == "=" and depth == 0 + and blank[index - 1:index] not in ("=", "~", "<", ">") + and blank[index + 1:index + 2] != "="): + eq = index + break + index -= 1 + if eq is None: + return None, "unbound" + position = slot + len(_depth_commas(blank[eq + 1:])) + names, truncated = _name_list(blank[:eq]) + if truncated and (position or len(names) != 1): + return None, "unpaired" + if position >= len(names): + return None, "unpaired" + return names[position], None + + +def _module_literals(body, wrappers): + """Split every `src.` module name this file spells into what the scan can + attach to a name (binds), what it can attach straight to a member (inline) + and what it cannot follow at all (unfollowed), so no reach falls out + silently. Returns binds, inline, unfollowed.""" + binds, inline, unfollowed = {}, [], [] + for match in MODULE_LITERAL.finditer(body): + module = match.group(1).replace("/", ".") + head, after = body[:match.start()], body[match.end():] + pcall_head = HEAD_PCALL.search(head) + require_head = None if pcall_head else HEAD_REQUIRE.search(head) + call_head = None + if pcall_head: + pre = head[:pcall_head.start()] + elif require_head: + pre = head[:require_head.start()] + else: + call_head = HEAD_CALL.search(head) + callee = re.split(r"[.:]", call_head.group(1))[-1] \ + if call_head else None + if callee in wrappers: + pre = head[:call_head.start()] + elif call_head: + unfollowed.append((match.start(), "engine module names " + "handed to a call this scan does not " + "follow")) + continue + else: + unfollowed.append((match.start(), "engine module names " + "spelled in a literal this scan cannot tie " + "to a require")) + continue + colon, member, consumed = False, None, 0 + tail = TAIL_MEMBER.match(after) + index = None if tail else TAIL_INDEX.match(after) + if tail: + colon, member, consumed = tail.group(1) == ":", tail.group(2), \ + tail.end() + elif index: + member, consumed = index.group(1), index.end() + if pcall_head: + # only the last value of a list keeps its second return + paren = head.find("(", pcall_head.start()) + close = _lua_close(body, paren) if paren >= 0 else None + bind, why = (None, "unpaired") \ + if close is None or body[close + 1:close + 64].lstrip()[:1] == "," \ + else _bind_target(pre, 1) + else: + bind, why = _bind_target(pre) + if member: + inline.append((len(pre), module, colon, member, + match.end() + consumed)) + # only worth saying when something later reaches off that name + if bind and re.search(r"\b%s\s*[.:]" % re.escape(bind), + after[consumed:]): + unfollowed.append((match.start(), "names bound to a member " + "of an engine module and not the module, " + "so reaches off them are not followed")) + elif bind: + binds.setdefault(bind, []).append((match.start(), module, True)) + elif why == "unpaired": + unfollowed.append((match.start(), "requires in a multiple " + "assignment whose value this scan cannot pair " + "to a name")) + else: + unfollowed.append((match.start(), "requires whose result is " + "neither bound to a name nor indexed here, so " + "where the module goes is not followed")) + return binds, inline, unfollowed + + +def _alias_binds(body, binds): + """`local F = Follower` carries a module on to a second name. Repeated to + a fixpoint so a chain of hops resolves, and only ever backwards: a name is + bound at the point the alias is written.""" + while True: + added = False + for match in ALIAS_BIND.finditer(body): + ident, source = match.group(1), match.group(2) + if ident == source or source not in binds: + continue + module = module_at(binds[source], match.start(2)) + site = (match.start(1), module) + if module and site not in binds.get(ident, []): + binds.setdefault(ident, []).append(site) + added = True + if not added: + break + for sites in binds.values(): + sites.sort() + return binds + + +def _member_use(body, spans, rel, module, ident, start, end, member, colon): + """One Use from a reach: `start`..`end` covers the name and the member + taken off it, whether that name is a local, a bracket index or the require + call itself.""" + rest = body[end:] + head = body[:start].rstrip() + chain = [member] + tail = re.match(r"((?:\.\w+){1,2})", rest) + if tail: + chain += tail.group(1).lstrip(".").split(".") + rest = rest[tail.end():] + argc, varargs = None, False + if head.endswith("function") or re.match(r"\s*=(?!=)", rest): + kind = "write" + elif re.match(r"\s*\(", rest): + kind = "call" + argc, varargs = _lua_args(body, + len(body) - len(rest) + rest.index("(")) + if colon and argc is not None: + argc += 1 + elif re.match(r"""\s*["'{]""", rest): + kind, argc = "call", 1 + (1 if colon else 0) + else: + kind = "read" + guarded = bool(re.match(r"\s*(and|or|then|\)|~=|==)", rest)) \ + or bool(re.search(r"\b(if|and|or|not)\s*$", head)) + top = not any(begin <= start < stop for begin, stop in spans) + return Use(rel, _line_of(body, start), module, ident, chain, kind, argc, + varargs, guarded, top) + + +def _dynamic_requires(body): + """Offsets of the requires whose name this scan cannot take whole: one + handed in as a value, and any argument list that concatenates, however it + starts (`require("src" .. tail)` is as unfollowable as `require(name)`).""" + out = [] + for match in re.finditer(r"\brequire\s*\(", body): + close = _lua_close(body, match.end() - 1) + if close is None: + out.append(match.start()) + continue + args = _blank_strings(body[match.end():close]) + if ".." in args or not re.match(r"""\s*["']""", args): + out.append(match.start()) + return out + + +RAW_ACCESS = "engine modules reached with %s, which goes straight to the " \ + "table the require shim hands back: where a Gen 2 boot serves the module " \ + "through a Gen2Compat facade, that %s the facade and not the module " \ + "behind it" + + +def _raw_access(body, ident, sites): + """rawget/rawset on a bound module: the one reach that skips the facade's + metatable, so it never sees the Gen 2 module the adapter stands in for.""" + out = [] + for match in re.finditer(r"\braw(get|set)\s*\(\s*%s\s*[,)]" + % re.escape(ident), body): + if module_at(sites, match.start()): + out.append((match.start(), RAW_ACCESS % ( + "rawset", "is where the write lands, on") + if match.group(1) == "set" else RAW_ACCESS % ( + "rawget", "is all the read sees,"))) + return out + + +def _value_reads(body, ident, sites, followed): + """Occurrences of a bound module name in none of the shapes this scan + follows: parked on a table, passed to a call, delegated to through a + metatable. The module escapes there, so what is reached off it later is + not this scan's to see.""" + out, blank = [], _blank_strings(body) + aliased = {match.start(2) for match in ALIAS_BIND.finditer(body) + if match.group(2) == ident} + for match in re.finditer(r"(?<![\w.:])%s\b" % re.escape(ident), blank): + start, rest = match.start(), blank[match.end():] + if start in followed or start in aliased \ + or not module_at(sites, start): + continue + if re.match(r"\s*[.:\[]", rest) or re.match(r"\s*=(?!=)", rest) \ + or re.search(r"\braw(?:get|set)\s*\(\s*$", blank[:start]): + continue # followed above, rebound here, or noted as a raw reach + line = blank.rfind("\n", 0, start) + 1 + before = blank[line:start] + while line > 0 and re.match(r"\s*(and|or|not)\b", before): + line = blank.rfind("\n", 0, line - 1) + 1 + before = blank[line:start] # a condition carried over a line + if re.search(r"(?<![\w.:])%s\s*(?:[.:]\s*\w+|\[[^\]\n]*\])\s*\(\s*$" + % re.escape(ident), before): + continue # M.f(M): the explicit self of a reach already followed + if re.search(r"\bfunction\b[^()\n]*\([^)\n]*$", before): + continue # a parameter of that name shadowing the module here + test = re.search(r"\b(if|elseif|while|until)\b", before) + if test and not re.search(r"[^=~<>]=(?!=)|\breturn\b", + before[test.end():]): + continue # a presence test: nothing escapes a condition + out.append((start, "engine modules read as a value rather than " + "indexed, so where the module goes from there (a table " + "field, a call argument, a metatable's __index) is not " + "followed")) + return out + + +def scan_module_uses(mod_dir): + """Every reach the mod makes at an engine module, every member it then + touches, and every upvalue it names beside one. + + This is a regex over source, not an interpreter. It follows a require + bound to a name (by position, so one statement may bind several), a + require the mod wraps in its own helper, a require indexed on the spot, a + bracket index with a literal name and a module carried on through a second + local. What it cannot follow -- a name built at runtime or concatenated, + a value it cannot pair to a name, a module read as a value, a raw index + past the facade, an index whose key is computed, a helper it cannot + confirm names an upvalue -- comes back as an unresolved note, so silence + over a reach is never this tool's approval of it.""" + requires, uses, upvalues, notes = [], [], [], [] + dynamic, blind, unsure, unfollowed = [], [], [], [] + files = [rel for rel in mod_files(mod_dir) + if os.path.splitext(rel)[1].lower() == ".lua"] + bodies = {} + for rel in files: + body = strip_lua(open(os.path.join(mod_dir, rel), encoding="utf-8", + errors="replace").read()) + bodies[rel] = (body, _function_spans(body)) + wrappers = _require_wrappers(bodies.values()) + helpers, suspects = _upvalue_helpers(bodies.values()) + for rel in files: + body, spans = bodies[rel] + binds, inline, unresolved = _module_literals(body, wrappers) + for offset, why in unresolved: + unfollowed.append((why, "%s:%d" % (rel, _line_of(body, offset)))) + for offset, module, colon, member, end in inline: + if not module.startswith("src."): + continue + requires.append((rel, _line_of(body, offset), module)) + uses.append(_member_use(body, spans, rel, module, + module.split(".")[-1], offset, end, + member, colon)) + for ident, sites in binds.items(): + for offset, module, literal in sites: + if literal and module.startswith("src."): + requires.append((rel, _line_of(body, offset), module)) + binds = {ident: [(offset, module) for offset, module, _ in sites + if module.startswith("src.")] + for ident, sites in binds.items()} + binds = {ident: sites for ident, sites in binds.items() if sites} + binds = _alias_binds(body, binds) + for offset in _dynamic_requires(body): + dynamic.append("%s:%d" % (rel, _line_of(body, offset))) + for ident, sites in binds.items(): + followed = [] + for match in re.finditer( + r"\b%s\s*(?:([.:])\s*(\w+)|\[\s*[\"'](\w+)[\"']\s*\])" + % re.escape(ident), body): + module = module_at(sites, match.start()) + if not module: + continue + followed.append(match.start()) + uses.append(_member_use( + body, spans, rel, module, ident, match.start(), + match.end(), match.group(2) or match.group(3), + match.group(1) == ":")) + for match in re.finditer(r"\b%s\s*\[\s*(?![\"'])" % re.escape(ident), + body): + if module_at(sites, match.start()): + followed.append(match.start()) + unfollowed.append(( + "engine modules indexed with a key this scan cannot " + "read", + "%s:%d" % (rel, _line_of(body, match.start())))) + for offset, why in _raw_access(body, ident, sites) \ + + _value_reads(body, ident, sites, followed): + unfollowed.append((why, "%s:%d" % (rel, _line_of(body, offset)))) + for offset, args, confirmed in _upvalue_calls(body, helpers, suspects): + pair = UPVALUE_ARGS.match(args) if confirmed else None + module = module_at(binds.get(pair.group(1), []), offset) \ + if pair else None + if module: + upvalues.append((rel, _line_of(body, offset), module, + pair.group(2), pair.group(3))) + elif confirmed: + blind.append("%s:%d" % (rel, _line_of(body, offset))) + else: + unsure.append("%s:%d" % (rel, _line_of(body, offset))) + if dynamic: + notes.append("unresolved: %s building a require name at runtime, " + "which this scan cannot follow (%s)" + % (_count(len(dynamic), "site"), _places(dynamic))) + for why in sorted({why for why, _ in unfollowed}): + places = [place for reason, place in unfollowed if reason == why] + notes.append("unresolved: %s: %s (%s)" + % (_count(len(places), "site"), why, _places(places))) + if blind: + notes.append("unresolved: %s whose target function this scan could " + "not tie to an engine module, so the local they reach " + "could not be resolved (%s)" + % (_count(len(blind), "debug upvalue call"), + _places(blind))) + if unsure: + notes.append("unresolved: %s through a mod helper this scan could not " + "confirm carries an upvalue name through to the debug " + "call, so what they patch is unknown (%s)" + % (_count(len(unsure), "call"), _places(unsure))) + return requires, uses, upvalues, notes + + +def _places(items, limit=4): + """A file:line list that stays one line however many there are.""" + shown = ", ".join(items[:limit]) + return shown if len(items) <= limit else \ + "%s and %d more" % (shown, len(items) - limit) + + +def module_at(sites, offset): + """The module the name was bound to at this point in the file.""" + module = None + for start, name in sites: + if start <= offset: + module = name + return module + + +# ------------------------------------------------------------- the checks + +GEN2_IDS_DUMP = '''\ +package.path = "./?.lua;./?/init.lua;" .. package.path +print(table.concat(require("src.mods.ModTargets").generationVersions(2), " ")) +''' + +_GEN2_IDS = None + + +def gen2_version_ids(repo): + """The Gen 2 version ids, read out of the engine (src/mods/ModTargets.lua) + rather than restated here. Empty when luajit cannot answer, which leaves + the "gen2"/"all" tokens to decide alone.""" + global _GEN2_IDS + if _GEN2_IDS is None: + _GEN2_IDS = [] + try: + proc = subprocess.run([LUAJIT, "-e", GEN2_IDS_DUMP], cwd=repo, + capture_output=True, text=True, timeout=30) + if proc.returncode == 0: + _GEN2_IDS = proc.stdout.split() + except (OSError, subprocess.SubprocessError): + pass + return _GEN2_IDS + + +def declares_gen2(repo, manifest): + """Does this manifest claim a Gen 2 game: the `games` list, or the legacy + gen2compat flag it is derived from (src/mods/Manifest.lua).""" + if not manifest: + return False + if manifest.get("gen2compat"): + return True + games = manifest.get("games") + if not isinstance(games, list): + return False + ids = set(gen2_version_ids(repo)) + for token in games: + if isinstance(token, str) and ( + token.strip().lower() in ("gen2", "all") + or token.strip().lower() in ids): + return True + return False + + +def check_gen2_manifest(repo, mod_dir, manifest, named): + """MK400/MK401: what the loader decides before a line of the mod runs + (src/mods/Loader.lua's generation gate). `named` is every mod on this + command line, so checking a mod together with its dependencies reads them + as one install set.""" + findings, notes = [], [] + if not declares_gen2(repo, manifest): + findings.append(Finding( + "MK400", "error", + "no Gen 2 game in \"games\" (and no gen2compat), so a Gen 2 boot " + "skips this mod; the rest of this report is what it would hit " + "once it claims one", + "manifest.json")) + deps = manifest.get("dependencies") or [] + for dep in deps if isinstance(deps, list) else []: + if not isinstance(dep, str): + continue + found = named.get(dep) or find_mod_by_id(repo, mod_dir, dep) + if found is None: + notes.append("unresolved: dependency %s is not installed beside " + "this mod, so its games list could not be read" % dep) + elif not declares_gen2(repo, found): + findings.append(Finding( + "MK401", "error", + f"depends on {dep}, which claims no Gen 2 game; the " + f"loader disables a mod whose dependency a Gen 2 boot skipped", + "manifest.json")) + return findings, notes + + +def find_mod_by_id(repo, mod_dir, mod_id): + """The manifest of another installed mod, or None. An install root is + one directory of <id>/manifest.json, which is all the loader itself walks, + so this looks beside the mod and in the repo's mods/ and no deeper: a + second copy under some build tree is not what would load.""" + roots = [os.path.dirname(os.path.abspath(mod_dir)), + os.path.join(repo, "mods")] + seen = set() + for root in roots: + if not os.path.isdir(root) or root in seen: + continue + seen.add(root) + for name in sorted(os.listdir(root)): + path = os.path.join(root, name, "manifest.json") + if name in SKIP_DIRS or not os.path.isfile(path): + continue + try: + found = json.load(open(path, encoding="utf-8")) + except (OSError, ValueError): + continue + if found.get("id") == mod_id: + return found + return None + + +def check_gen2_requires(repo, coverage, requires): + """MK402: a Gen 1 module a Gen 2 boot never instantiates and no adapter + backs -- the require succeeds, the patch lands on dead code, and the + loader says so in the manager's error feed. MK403: the same silence + without the loader's warning, spotted from the gen2/ sibling that runs + instead.""" + findings, notes = [], [] + gen1_only = gen1_only_modules(repo) + seen = set() + for rel, line, module in requires: + if module in coverage or (rel, module) in seen: + continue + seen.add((rel, module)) + if module not in gen1_only and not _module_exists(repo, module): + notes.append("unresolved: %s:%d names %s, which is neither an " + "adapter nor a module in this checkout, so nothing " + "reached off it was checked" % (rel, line, module)) + continue + if module in gen1_only: + findings.append(Finding( + "MK402", "error", + f"requires {module}, which a Gen 2 boot never runs and " + f"src/mods/Gen2Compat.lua has no adapter for; take the game " + f"from the game.ready payload and mod.world instead", + f"{rel}:{line}")) + continue + parts = module.split(".") + if len(parts) < 3: + continue + sibling = ".".join(parts[:-1] + ["gen2", parts[-1]]) + if os.path.isfile(module_path(repo, sibling)): + findings.append(Finding( + "MK403", "warn", + f"requires {module}, but a Gen 2 game runs {sibling}; the " + f"require succeeds and hands back a module nothing " + f"instantiates", + f"{rel}:{line}")) + return findings, notes + + +def check_gen2_members(repo, coverage, uses, advise=False): + """MK404: a member the adapter says has no Gen 2 backing, so the read is + nil and the call raises. MK405: one that is there and degrades, in the + adapter's own words. MK406: one whose parameters moved under it -- the + trap an alias sets, because it runs and means something else.""" + findings, notes = [], [] + owned = {(use.module, use.member) for use in uses if use.kind == "write"} + for use in uses: + record = coverage.get(use.module) + if not record: + continue # a shared module, or one the requires pass noted + + members = record["members"] + if members is None: + notes.append("unresolved: no coverage row for %s, so %s.%s could " + "not be checked" % (use.module, use.ident, + use.member)) + continue + member, status = _resolve_member(members, use.chain) + note = _plain_note(record["notes"].get(member)) if member else None + target = record["target"] or "the adapter" + if status is None: + gen1 = lua_api(module_path(repo, use.module)) or {} + api = lua_api(module_path(repo, record["target"])) or {} \ + if record["target"] else {} + if use.chain[0] in api or (use.module, use.chain[0]) in owned: + continue # the Gen 2 module carries it, or the mod put it there + if use.chain[0] not in gen1: + continue # the mod's own field on a table it did not declare + notes.append("unresolved: %s.%s is a Gen 1 member the coverage " + "table does not classify" % (use.ident, use.member)) + continue + if status == ABSENT: + findings.append(Finding( + "MK404", "warn" if use.guarded else "error", + "%s.%s has no Gen 2 backing: %s" + % (use.ident, use.member, note or "%s has no %s" + % (target, member)) + + ("; the guarded branch never runs" if use.guarded + else "; nothing on a Gen 2 boot reads this write" + if use.kind == "write" else "; this reads nil" + + (" and the call raises" if use.kind == "call" else "")), + use.where())) + continue + if status != "backed": + findings.append(Finding( + "MK405", "warn", + "%s.%s is %s on a Gen 2 boot: %s" + % (use.ident, use.member, status, + note or "it answers nil and names itself once in the log"), + use.where())) + continue + held = _held_at_file_scope(repo, record, use) + if held: + findings.append(held) + continue + shapes = _signature_diff(repo, record, use) + if shapes: + # an alias hands the mod the Gen 2 module itself: no shim stands + # between this call and the parameters that moved under it + findings.append(Finding( + "MK406", "warn", + shapes + ("; " + note if note else ""), use.where())) + elif advise and note: + notes.append("%s.%s: %s" % (use.ident, use.member, note)) + return findings, notes + + +def _held_at_file_scope(repo, record, use): + """MK410: the entry chunk reading a member the Gen 1 module only ever + writes onto the running game. A facade resolves against the live instance + at read time and there is none yet while the mod is loading, so the value + captured is nil for the life of the process; the same read from inside a + hook or an event is correct (docs/mod-api-gen2-compat.md, "live, never a + snapshot").""" + if not use.top or use.kind == "write" or record["kind"] != "facade": + return None + entry = (lua_api(module_path(repo, use.module)) or {}).get(use.chain[0]) + if not entry or entry["kind"] != "field": + return None + return Finding( + "MK410", "warn", + f"reads {use.ident}.{use.member} at file scope, where a Gen 2 boot " + f"has no game yet: the facade answers nil until one exists, so take " + f"this from the game.ready payload instead of the entry chunk", + use.where()) + + +def _plain_note(note): + """The adapter writes a status word in front of some of its notes; the + finding already carries the status, so it is not said twice.""" + if not note: + return None + return re.sub(r"^(ABSENT|WARNED|BACKED)\b[:.]?\s*", "", note.strip()) + + +def _resolve_member(members, chain): + """Longest dotted path the coverage table classifies: Game.save.money is a + row of its own where Game.save is another.""" + for size in range(len(chain), 0, -1): + name = ".".join(chain[:size]) + if name in members: + return name, members[name] + return None, None + + +def _signature_diff(repo, record, use): + """The sentence for a call whose parameters moved: the Gen 2 module spells + them in an order the Gen 1 call site cannot survive, or takes a different + number of them. Equal shape with different names is a rename as often as + a change, and this tool does not guess between the two. + + An alias only: a facade is free to override the member with the Gen 1 + shape (src/mods/Gen2Compat.lua's Boxes.deposit does exactly that), so the + Gen 2 module's parameters are not what the mod would be calling.""" + if (use.kind != "call" or record["kind"] != "alias" + or not record["target"] or len(use.chain) != 1): + return None + want = (lua_api(module_path(repo, record["target"])) or {}).get( + use.member, {}).get("params") + have = (lua_api(module_path(repo, use.module)) or {}).get( + use.member, {}).get("params") + if want is None or have is None or want == have: + return None + shapes = ("%s.%s is (%s) on a Gen 2 boot and (%s) on Gen 1" + % (use.ident, use.member, ", ".join(want), ", ".join(have))) + if _reordered(want, have): + return shapes + "; the shared parameters sit in different places" + if (use.argc is not None and not use.varargs + and use.argc == len(have) and use.argc != len(want)): + return shapes + "; this call passes the Gen 1 argument list" + return None + + +def _reordered(want, have): + """True when the two parameter lists share names sitting in different + places.""" + shared = [name for name in want if name in have and name != "self"] + return any(want.index(name) != have.index(name) for name in shared) + + +UPVALUE_DUMP = '''\ +package.path = "./?.lua;./?/init.lua;" .. package.path +local ok, G = pcall(require, "src.mods.Gen2Compat") +if not ok then os.exit(3) end +for line in io.lines() do + local module, member = line:match("^(%S+)\\t(%S+)$") + if module then + local status, names = "nomodule", {} + local got, adapter = pcall(G.resolve, module) + if got and type(adapter) == "table" then + local read, value = pcall(function() return adapter[member] end) + if not read then status = "nomember" + elseif value == nil then status = "nomember" + elseif type(value) ~= "function" then status = "notfunction" + else + status = "ok" + local i = 1 + while true do + local name = debug.getupvalue(value, i) + if not name then break end + names[#names + 1] = name + i = i + 1 + end + end + end + print(module .. "\\t" .. member .. "\\t" .. status .. "\\t" + .. table.concat(names, " ")) + end +end +''' + +_UPVALUE_CACHE = {} + + +def gen2_upvalues(repo, queries): + """(status, upvalue names) for each (module, member) a mod reaches, taken + by resolving the adapter the way src/mods/Loader.lua does and enumerating + the function's real upvalues. A pair luajit could not answer for stays out + of the table, which the caller reports as unknown and never as landing.""" + wanted = sorted({pair for pair in queries + if pair[0] and pair not in _UPVALUE_CACHE}) + if not wanted: + return _UPVALUE_CACHE + with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, + encoding="utf-8") as handle: + handle.write(UPVALUE_DUMP) + dump_path = handle.name + try: + proc = subprocess.run( + [LUAJIT, dump_path], cwd=repo, capture_output=True, text=True, + timeout=60, + input="".join("%s\t%s\n" % pair for pair in wanted)) + if proc.returncode == 0: + for row in proc.stdout.splitlines(): + parts = row.split("\t") + if len(parts) >= 4: + _UPVALUE_CACHE[(parts[0], parts[1])] = ( + parts[2], parts[3].split()) + except (OSError, subprocess.SubprocessError): + pass + finally: + os.unlink(dump_path) + return _UPVALUE_CACHE + + +def check_gen2_upvalues(repo, coverage, upvalues): + """MK407/MK408: reaching an engine function's file-local with + debug.setupvalue. The function is resolved through the adapter and its + upvalues enumerated, so a member the Gen 2 arm does not carry is the error + it is at runtime and a local that is not an upvalue of it never reads as + landing.""" + findings, notes = [], [] + table = gen2_upvalues(repo, [(module, member) for _, _, module, member, _ + in upvalues if module in coverage]) + lands = {} + for rel, line, module, member, upvalue in upvalues: + record = coverage.get(module) + if not record: + continue # a shared module, or one the requires pass noted + + target = record["target"] or "the adapter" + status, names = table.get((module, member), (None, [])) + if status in (None, "nomodule"): + findings.append(Finding( + "MK408", "warn", + f"reaches the upvalue {upvalue!r} on {member}; this scan could " + f"not resolve {module}.{member} on a Gen 2 boot, so whether " + f"the surgery lands is unknown", + f"{rel}:{line}")) + continue + if status != "ok": + findings.append(Finding( + "MK407", "error", + f"reaches the upvalue {upvalue!r} on {member}, but a Gen 2 " + f"boot resolves {module}.{member} to " + + ("nil" if status == "nomember" else "a value that is not a " + "function") + + f" ({target} carries no such function), so the " + f"debug.setupvalue call raises", + f"{rel}:{line}")) + continue + if upvalue in names: + lands.setdefault((upvalue, module, member), []).append( + "%s:%d" % (rel, line)) + continue + setter = "set" + upvalue[:1].upper() + upvalue[1:] + api = lua_api(module_path(repo, record["target"])) or {} \ + if record["target"] else {} + findings.append(Finding( + "MK407", "error", + f"reaches the upvalue {upvalue!r} on {member}, but on a Gen 2 boot " + f"{module}.{member} closes over " + + (", ".join(sorted(names)[:6]) if names else "nothing") + + ", so the surgery lands on nothing" + + (f"; {target.split('.')[-1]}.{setter} is the supported route" + if setter in api else ""), + f"{rel}:{line}")) + for (upvalue, module, member), places in sorted(lands.items()): + notes.append("%s.%s closes over %r on a Gen 2 boot, so the upvalue " + "surgery at %s lands as it does on Gen 1" + % (module, member, upvalue, _places(places))) + return findings, notes + + +def check_gen2_patterns(repo, mod_dir): + """MK409: the two shapes no adapter is allowed to fix, because the mod + decided something about the game and a Gen 2 boot answers differently + (docs/mod-api-gen2-compat.md, "what the facades cannot fix").""" + findings = [] + twins = gen2_screen_twins(repo) + for rel in mod_files(mod_dir): + if os.path.splitext(rel)[1].lower() != ".lua": + continue + body = strip_lua(open(os.path.join(mod_dir, rel), encoding="utf-8", + errors="replace").read()) + for match in VERSION_MATCH.finditer(body): + findings.append(Finding( + "MK409", "warn", + "allow-lists a Gen 1 version string, which excludes this mod " + "from a Gen 2 game by construction; test for the capability " + "the code needs instead of the version", + "%s:%d" % (rel, _line_of(body, match.start())))) + # the id itself, not a word in the line around it: `if id == "BoxMenu"` + # carries no screen-shaped word and is the shape the docs warn about + for match in re.finditer(r"""["'](\w+)["']""", body): + name = match.group(1) + if name not in twins: + continue + line = _line_of(body, match.start()) + findings.append(Finding( + "MK409", "warn", + f"{name!r} is a Gen 1 screen id; a Gen 2 boot builds " + f"'Gen2{name}' (Screens.GEN2_IDS in src/ui/Screens.lua), so a " + f"screen compared or opened by this literal matches nothing " + f"there", + "%s:%d" % (rel, line))) + return findings + + +def gen2_screen_twins(repo): + """Screen ids that exist in both generations, where Gen 2's carries the + Gen2 prefix (src/ui/Screens.lua).""" + try: + src = open(os.path.join(repo, "src", "ui", "Screens.lua"), + encoding="utf-8").read() + except OSError: + return set() + block = re.search(r"^local GEN2 = \{(.*?)\n\}", src, re.S | re.M) + if not block: + return set() + return {name for name in re.findall(r'"(\w+)"', block.group(1)) + if os.path.isfile(os.path.join(repo, "src", "ui", name + ".lua"))} + + +# ------------------------------------------------------------- the command + +def _count(total, word): + return "" if not total else "%d %s%s" % (total, word, + "" if total == 1 else "s") + + +def gen2_verdict(findings): + if any(f.severity == "error" for f in findings): + return "will not work" + return "will load but degrade" if findings else "will load" + + +def report_gen2(results, args): + """report()'s shape plus the per-mod verdict this command exists to give. + One JSON document covers every mod named, so a CI step reads one object + however many it gated on.""" + payload, ok = [], True + for mod_id, findings, notes, facts in results: + errors = findings if args.strict else \ + [f for f in findings if f.severity == "error"] + if errors: + ok = False + payload.append({"id": mod_id, "verdict": gen2_verdict(findings), + "errors": len(errors), "manifest": facts, + "findings": [f.as_dict() for f in findings], + "notes": notes}) + if args.json: + print(json.dumps({"ok": ok, "mods": payload})) + return 0 if ok else 1 + for index, (mod_id, findings, notes, facts) in enumerate(results): + if not args.quiet: + print(("" if index == 0 else "\n") + f"-- {mod_id}: {facts}") + for finding in findings: + print(finding.line()) + if args.quiet: + continue + for note in notes: + print(f"modkit: {note}") + warns = sum(1 for f in findings if f.severity == "warn") + counts = ", ".join(part for part in ( + _count(len(findings) - warns, "error"), _count(warns, "warning")) + if part) + print("%s %s on gen 2: %s%s" + % ("FAIL" if payload[index]["errors"] else "ok", mod_id, + payload[index]["verdict"], " (%s)" % counts if counts else "")) + return 0 if ok else 1 + + +def cmd_gen2check(args, repo): + shared = [] + coverage = gen2_coverage(repo, shared) + results = [] + dirs, named = [], {} + for target in args.mod: + mod_dir = resolve_mod_dir(repo, target) + if not mod_dir: + print(f"modkit: no mod at {target!r}") + return 2 + manifest, problem = read_manifest(mod_dir) + dirs.append((mod_dir, manifest, problem)) + if manifest: + named[manifest["id"]] = manifest + for mod_dir, manifest, problem in dirs: + findings, notes = [], list(shared) + if problem: + findings.append(problem) + else: + manifest_findings, manifest_notes = check_gen2_manifest( + repo, mod_dir, manifest, named) + findings.extend(manifest_findings) + notes.extend(manifest_notes) + requires, uses, upvalues, scan_notes = scan_module_uses(mod_dir) + require_findings, require_notes = check_gen2_requires( + repo, coverage, requires) + findings.extend(require_findings) + member_findings, member_notes = check_gen2_members( + repo, coverage, uses, args.notes) + findings.extend(member_findings) + upvalue_findings, upvalue_notes = check_gen2_upvalues( + repo, coverage, upvalues) + findings.extend(upvalue_findings) + findings.extend(check_gen2_patterns(repo, mod_dir)) + notes.extend(scan_notes + require_notes + member_notes + + upvalue_notes) + mod_id = manifest.get("id") if manifest else os.path.basename(mod_dir) + results.append((mod_id, _order(_dedupe(findings)), + _dedupe_notes(notes), _facts(manifest))) + return report_gen2(results, args) + + +def _dedupe(findings): + """One line per fact: the same rule against the same place says the same + thing however many times the source repeats the shape.""" + seen, out = set(), [] + for finding in findings: + key = (finding.rule, finding.path, finding.message) + if key not in seen: + seen.add(key) + out.append(finding) + return out + + +def _dedupe_notes(notes): + seen, out = set(), [] + for note in notes: + if note not in seen: + seen.add(note) + out.append(note) + return out + + +def _order(findings): + def key(finding): + path, _, line = (finding.path or "").rpartition(":") + return (finding.rule, path or finding.path or "", + int(line) if line.isdigit() else 0) + return sorted(findings, key=key) + + +def _facts(manifest): + """The manifest fields a Gen 2 boot reads, echoed so the verdict says what + it was decided from rather than leaving the author to guess.""" + if not manifest: + return "" + permissions = manifest.get("permissions") or [] + deps = manifest.get("dependencies") or [] + games = "+".join(g for g in (manifest.get("games") or []) + if isinstance(g, str)) + return ("api %s, profile %s, %s, permissions %s, %d dependencies, " + "game_version %s" + % (manifest.get("api", 1), manifest.get("profile", "content"), + ("games " + games) if games + else ("gen2compat" if manifest.get("gen2compat") + else "no games declared"), + "+".join(p for p in permissions if isinstance(p, str)) or "none", + len(deps) if isinstance(deps, list) else 0, + manifest.get("game_version", "unset"))) + + # ---------------------------------------------------------------- main def main(argv): @@ -2013,6 +3473,9 @@ def main(argv): help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", help="mark the mod experimental (off until confirmed)") + p.add_argument("--games", default="gen1", + help="games this mod is for: gen1, gen2, all, or a " + "comma-separated list of version ids (red,gold,...)") p.add_argument("--dest") p.add_argument("--force", action="store_true") @@ -2022,6 +3485,14 @@ def main(argv): p.add_argument("--base", default="auto", choices=["auto", "fixture", "imported"]) + p = sub.add_parser("gen2check", parents=[shared], + help="will this mod run on a Gen 2 game, and how far") + p.add_argument("mod", nargs="+") + p.add_argument("--strict", action="store_true") + p.add_argument("--notes", action="store_true", + help="also print the adapter's note for every backed " + "member the mod touches") + p = sub.add_parser("lint", parents=[shared]) p.add_argument("mod") @@ -2086,6 +3557,7 @@ def main(argv): handler = { "scaffold": cmd_scaffold, "validate": cmd_validate, + "gen2check": cmd_gen2check, "lint": cmd_lint, "pack": cmd_pack, "bounce": cmd_bounce, diff --git a/tools/rom_data.py b/tools/rom_data.py index a7203b9b..c889723a 100644 --- a/tools/rom_data.py +++ b/tools/rom_data.py @@ -11,6 +11,8 @@ from dataclasses import dataclass CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2" CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" +# Gold is Gen 2: a 2 MiB cart, twice the size of the Gen 1 ROMs above. +CANONICAL_GOLD_SHA1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94" ROM_BANK_SIZE = 0x4000 diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json new file mode 100644 index 00000000..2833d110 --- /dev/null +++ b/tools/rom_manifest_gold.json @@ -0,0 +1,17682 @@ +{ + "charmap": { + "0": "<NULL>", + "10": "ザ", + "100": "<BOLD_E>", + "101": "<BOLD_F>", + "102": "<BOLD_G>", + "103": "<BOLD_H>", + "104": "<BOLD_I>", + "105": "<BOLD_V>", + "106": "<BOLD_S>", + "107": "<BOLD_L>", + "108": "<BOLD_M>", + "109": "<COLON>", + "11": "ジ", + "110": "ぃ", + "111": "ぅ", + "112": "<PO>", + "113": "<KE>", + "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": "<JP_14>", + "200": "ね", + "201": "の", + "202": "は", + "203": "ひ", + "204": "ふ", + "205": "へ", + "206": "ほ", + "207": "ま", + "208": "'d", + "209": "'l", + "210": "'m", + "211": "'r", + "212": "'s", + "213": "'t", + "214": "'v", + "215": "ら", + "216": "り", + "217": "る", + "218": "れ", + "219": "ろ", + "22": "<CR>", + "220": "わ", + "221": "を", + "222": "ん", + "223": "←", + "224": "'", + "225": "<PK>", + "226": "<MN>", + "227": "-", + "228": "゚", + "229": "゙", + "230": "?", + "231": "!", + "232": ".", + "233": "&", + "234": "é", + "235": "→", + "236": "▷", + "237": "▶", + "238": "▼", + "239": "♂", + "24": "<JP_18>", + "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": "<NI>", + "30": "<TTE>", + "31": "<BSP>", + "34": "<LF>", + "35": "<KOUGEKI>", + "36": "<PO><KE>", + "37": "<WBR>", + "38": "が", + "39": "ぎ", + "40": "ぐ", + "41": "げ", + "42": "ご", + "43": "ざ", + "44": "じ", + "45": "ず", + "46": "ぜ", + "47": "ぞ", + "48": "だ", + "49": "ぢ", + "5": "ガ", + "50": "づ", + "51": "で", + "52": "ど", + "53": "<ROUTE>", + "54": "<WATASHI>", + "55": "<KOKO_WA>", + "56": "<RED>", + "57": "<GREEN>", + "58": "ば", + "59": "び", + "6": "ギ", + "60": "ぶ", + "61": "べ", + "62": "ぼ", + "63": "<ENEMY>", + "64": "パ", + "65": "ピ", + "66": "プ", + "67": "ポ", + "68": "ぱ", + "69": "ぴ", + "7": "グ", + "70": "ぷ", + "71": "ぺ", + "72": "ぽ", + "73": "<MOM>", + "74": "<PK><MN>", + "75": "<_CONT>", + "76": "<SCROLL>", + "78": "<NEXT>", + "79": "<LINE>", + "8": "ゲ", + "80": "@", + "81": "<PARA>", + "82": "<PLAYER>", + "83": "<RIVAL>", + "84": "POKé", + "85": "<CONT>", + "86": "<……>", + "87": "<DONE>", + "88": "<PROMPT>", + "89": "<TARGET>", + "9": "ゴ", + "90": "<USER>", + "91": "<PC>", + "92": "<TM>", + "93": "<TRAINER>", + "94": "<ROCKET>", + "95": "<DEXEND>", + "96": "<BOLD_A>", + "97": "<BOLD_B>", + "98": "<BOLD_C>", + "99": "<BOLD_D>" + }, + "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_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" + ], + "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", + "ITEM_46", + "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", + "ITEM_73", + "ITEM_74", + "MIRACLE_SEED", + "THICK_CLUB", + "FOCUS_BAND", + "ITEM_78", + "ENERGYPOWDER", + "ENERGY_ROOT", + "HEAL_POWDER", + "REVIVAL_HERB", + "HARD_STONE", + "LUCKY_EGG", + "CARD_KEY", + "MACHINE_PART", + "ITEM_81", + "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_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": 18, + "map": 29, + "name": "UNION_CAVE_1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 30, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 31, + "name": "UNION_CAVE_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 32, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 33, + "name": "SLOWPOKE_WELL_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 34, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 35, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 36, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 37, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 38, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 39, + "name": "OLIVINE_LIGHTHOUSE_6F", + "width": 10 + }, + { + "group": 3, + "height": 4, + "map": 40, + "name": "MAHOGANY_MART_1F", + "width": 4 + }, + { + "group": 3, + "height": 9, + "map": 41, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 42, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 43, + "name": "TEAM_ROCKET_BASE_B3F", + "width": 15 + }, + { + "group": 3, + "height": 27, + "map": 44, + "name": "ILEX_FOREST", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 45, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 46, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 47, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 48, + "name": "GOLDENROD_UNDERGROUND_WAREHOUSE", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 49, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + { + "group": 3, + "height": 27, + "map": 50, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 51, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 52, + "name": "MOUNT_MORTAR_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 53, + "name": "ICE_PATH_1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 54, + "name": "ICE_PATH_B1F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 55, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 56, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 57, + "name": "ICE_PATH_B3F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 58, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 59, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 60, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 61, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + { + "group": 3, + "height": 9, + "map": 62, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 63, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 64, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 65, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 66, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 67, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 68, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 69, + "name": "SILVER_CAVE_ITEM_ROOMS", + "width": 10 + }, + { + "group": 3, + "height": 18, + "map": 70, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + { + "group": 3, + "height": 18, + "map": 71, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + { + "group": 3, + "height": 9, + "map": 72, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 73, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + { + "group": 3, + "height": 9, + "map": 74, + "name": "TOHJO_FALLS", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 75, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + { + "group": 3, + "height": 9, + "map": 76, + "name": "MOUNT_MOON", + "width": 15 + }, + { + "group": 3, + "height": 14, + "map": 77, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + { + "group": 3, + "height": 18, + "map": 78, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + { + "group": 3, + "height": 18, + "map": 79, + "name": "ROCK_TUNNEL_B1F", + "width": 15 + }, + { + "group": 3, + "height": 4, + "map": 80, + "name": "SAFARI_ZONE_FUCHSIA_GATE_BETA", + "width": 5 + }, + { + "group": 3, + "height": 18, + "map": 81, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + { + "group": 3, + "height": 36, + "map": 82, + "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": "ECRUTEAK_TIN_TOWER_BACK_ENTRANCE", + "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_POKECENTER_1F", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 11, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + { + "group": 11, + "height": 4, + "map": 12, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + { + "group": 11, + "height": 4, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + { + "group": 11, + "height": 2, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + { + "group": 11, + "height": 7, + "map": 19, + "name": "GOLDENROD_GAME_CORNER", + "width": 10 + }, + { + "group": 11, + "height": 4, + "map": 20, + "name": "ILEX_FOREST_AZALEA_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 21, + "name": "ROUTE_34_ILEX_FOREST_GATE", + "width": 5 + }, + { + "group": 11, + "height": 4, + "map": 22, + "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": 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": 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", + "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", + "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", + "ECRUTEAK_TIN_TOWER_BACK_ENTRANCE", + "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_POKECENTER_1F", + "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_GAME_CORNER", + "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", + "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", + "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" + ], + "numOverworldSprites": 95, + "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_CHRIS", + "PHONE_POKEMANIAC_BRENT", + "PHONE_PICNICKER_TIFFANY", + "PHONE_BIRDKEEPER_VANCE", + "PHONE_FISHER_WILTON", + "PHONE_BLACKBELT_KENJI", + "PHONE_HIKER_PARRY", + "PHONE_PICNICKER_ERIN" + ], + "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" + ], + "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", + "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", + "InitialSetDSTFlag", + "InitialClearDSTFlag", + "MrChrono", + "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", + "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", + "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", + "AskNumber1FScript", + "AskNumber2FScript", + "RegisteredNumberFScript", + "NumberAcceptedFScript", + "NumberDeclinedFScript", + "PhoneFullFScript", + "RematchFScript", + "GymStatue1Script", + "GymStatue2Script", + "ReceiveItemScript", + "ReceiveTogepiEggScript", + "PCScript", + "GameCornerCoinVendorScript", + "HappinessCheckScript" + ], + "tilesetOrder": [ + "TILESET_JOHTO", + "TILESET_JOHTO_MODERN", + "TILESET_KANTO", + "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_TOWER", + "TILESET_CAVE", + "TILESET_PARK", + "TILESET_RUINS_OF_ALPH", + "TILESET_RADIO_TOWER", + "TILESET_UNDERGROUND", + "TILESET_ICE_PATH", + "TILESET_DARK_CAVE", + "TILESET_FOREST" + ], + "tradeDialogOrder": [ + "TRADE_DIALOGSET_COLLECTOR", + "TRADE_DIALOGSET_HAPPY", + "TRADE_DIALOGSET_NEWBIE" + ], + "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" + ], + "BIKER": [ + "KAZU1", + "KAZU2", + "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", + "BENNY", + "AL", + "JOSH", + "ARNIE1", + "KEN", + "WADE2", + "WADE3", + "DOUG", + "ARNIE2", + "ARNIE3" + ], + "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" + ], + "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" + ], + "COOLTRAINERM": [ + "NICK", + "AARON", + "PAUL", + "CODY", + "MIKE", + "GAVEN1", + "GAVEN2", + "RYAN", + "JAKE", + "GAVEN3", + "BLAKE", + "BRIAN", + "ERICK", + "ANDY", + "TYLER", + "SEAN", + "KEVIN", + "STEVE", + "ALLEN" + ], + "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", + "CHRIS1", + "ANDRE", + "RAYMOND", + "WILTON1", + "EDGAR", + "JONAH", + "MARTIN", + "STEPHEN", + "BARNEY", + "RALPH2", + "RALPH3", + "CHRIS2", + "CHRIS3", + "WILTON2", + "SCOTT", + "WILTON3" + ], + "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" + ], + "JANINE": [ + "JANINE1" + ], + "JASMINE": [ + "JASMINE1" + ], + "JUGGLER": [ + "IRWIN1", + "FRITZ", + "HORTON", + "IRWIN2", + "IRWIN3" + ], + "KAREN": [ + "KAREN1" + ], + "KIMONO_GIRL": [ + "KYOKO", + "NAOKO", + "SAYO", + "ZUKI", + "KUNI", + "MIKI" + ], + "KOGA": [ + "KOGA1" + ], + "LASS": [ + "CARRIE", + "BRIDGET", + "ALICE", + "KRISE", + "CONNIE1", + "LINDA", + "LAURA", + "SHANNON", + "MICHELLE", + "DANA1", + "ELLEN", + "CONNIE2", + "CONNIE3", + "DANA2", + "DANA3" + ], + "LT_SURGE": [ + "LT_SURGE1" + ], + "MEDIUM": [ + "MARTHA", + "GRACE", + "BETHANY", + "MARGRET", + "ETHEL", + "REBECCA", + "DORIS" + ], + "MISTY": [ + "MISTY1" + ], + "MORTY": [ + "MORTY1" + ], + "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" + ], + "POKEFANF": [ + "BEVERLY1", + "RUTH", + "BEVERLY2", + "BEVERLY3", + "GEORGIA" + ], + "POKEFANM": [ + "WILLIAM", + "DEREK1", + "ROBERT", + "JOSHUA", + "CARTER", + "TREVOR", + "BRANDON", + "JEREMY", + "COLIN", + "DEREK2", + "DEREK3", + "ALEX" + ], + "POKEMANIAC": [ + "LARRY", + "ANDREW", + "CALVIN", + "SHANE", + "BEN", + "BRENT1", + "RON", + "ETHAN", + "BRENT2", + "BRENT3", + "ISSAC", + "DONALD", + "ZACH" + ], + "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" + ], + "SAILOR": [ + "EUGENE", + "HUEY1", + "TERRELL", + "KENT", + "ERNEST", + "JEFF", + "GARRETT", + "KENNETH", + "STANLY", + "HARRY", + "HUEY2", + "HUEY3" + ], + "SCHOOLBOY": [ + "JACK1", + "KIPP", + "ALAN1", + "JOHNNY", + "DANNY", + "TOMMY", + "DUDLEY", + "JOE", + "BILLY", + "CHAD1", + "NATE", + "RICKY", + "JACK2", + "JACK3", + "ALAN2", + "ALAN3", + "CHAD2", + "CHAD3" + ], + "SCIENTIST": [ + "ROSS", + "MITCH", + "JED", + "MARC", + "RICH" + ], + "SKIER": [ + "ROXANNE", + "CLARISSA" + ], + "SUPER_NERD": [ + "ERIC_UNUSED", + "ERIC", + "GREGG", + "JAY", + "DAVE", + "SAM", + "TOM", + "PAT", + "SHAWN", + "TERU", + "RUSS", + "NORTON" + ], + "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" + ], + "WHITNEY": [ + "WHITNEY1" + ], + "WILL": [ + "WILL1" + ], + "YOUNGSTER": [ + "JOEY1", + "MIKEY", + "ALBERT", + "GORDON", + "SAMUEL", + "IAN", + "JOEY2", + "JOEY3", + "WARREN", + "JIMMY", + "OWEN", + "JASON" + ] + }, + "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" + ], + "trainerTypeOrder": [ + "TRAINERTYPE_NORMAL", + "TRAINERTYPE_MOVES", + "TRAINERTYPE_ITEM", + "TRAINERTYPE_ITEM_MOVES" + ], + "treeMonSetOrder": [ + "TREEMON_SET_NONE", + "TREEMON_SET_FOREST", + "TREEMON_SET_CANYON", + "TREEMON_SET_ROCK", + "TREEMON_SET_UNUSED", + "TREEMON_SET_CITY" + ], + "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": "<BOLD_A>" + }, + { + "code": 97, + "seq": "<BOLD_B>" + }, + { + "code": 98, + "seq": "<BOLD_C>" + }, + { + "code": 99, + "seq": "<BOLD_D>" + }, + { + "code": 100, + "seq": "<BOLD_E>" + }, + { + "code": 101, + "seq": "<BOLD_F>" + }, + { + "code": 102, + "seq": "<BOLD_G>" + }, + { + "code": 103, + "seq": "<BOLD_H>" + }, + { + "code": 104, + "seq": "<BOLD_I>" + }, + { + "code": 107, + "seq": "<BOLD_L>" + }, + { + "code": 108, + "seq": "<BOLD_M>" + }, + { + "code": 106, + "seq": "<BOLD_S>" + }, + { + "code": 105, + "seq": "<BOLD_V>" + }, + { + "code": 109, + "seq": "<COLON>" + }, + { + "code": 242, + "seq": "<DOT>" + }, + { + "code": 112, + "seq": "<DO>" + }, + { + "code": 115, + "seq": "<ID>" + }, + { + "code": 113, + "seq": "<KE>" + }, + { + "code": 110, + "seq": "<LV>" + }, + { + "code": 226, + "seq": "<MN>" + }, + { + "code": 225, + "seq": "<PK>" + }, + { + "code": 112, + "seq": "<PO>" + }, + { + "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 + }, + "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": 71, + "name": "DARK_CAVE_BLACKTHORN_ENTRANCE", + "width": 15 + }, + "DARK_CAVE_VIOLET_ENTRANCE": { + "group": 3, + "height": 18, + "map": 70, + "name": "DARK_CAVE_VIOLET_ENTRANCE", + "width": 20 + }, + "DAY_CARE": { + "group": 11, + "height": 4, + "map": 22, + "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": 75, + "name": "DIGLETTS_CAVE", + "width": 10 + }, + "DRAGONS_DEN_1F": { + "group": 3, + "height": 9, + "map": 72, + "name": "DRAGONS_DEN_1F", + "width": 5 + }, + "DRAGONS_DEN_B1F": { + "group": 3, + "height": 18, + "map": 73, + "name": "DRAGONS_DEN_B1F", + "width": 20 + }, + "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_BACK_ENTRANCE": { + "group": 4, + "height": 4, + "map": 2, + "name": "ECRUTEAK_TIN_TOWER_BACK_ENTRANCE", + "width": 4 + }, + "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": 12, + "name": "GOLDENROD_DEPT_STORE_1F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_2F": { + "group": 11, + "height": 4, + "map": 13, + "name": "GOLDENROD_DEPT_STORE_2F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_3F": { + "group": 11, + "height": 4, + "map": 14, + "name": "GOLDENROD_DEPT_STORE_3F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_4F": { + "group": 11, + "height": 4, + "map": 15, + "name": "GOLDENROD_DEPT_STORE_4F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_5F": { + "group": 11, + "height": 4, + "map": 16, + "name": "GOLDENROD_DEPT_STORE_5F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_6F": { + "group": 11, + "height": 4, + "map": 17, + "name": "GOLDENROD_DEPT_STORE_6F", + "width": 8 + }, + "GOLDENROD_DEPT_STORE_B1F": { + "group": 3, + "height": 9, + "map": 47, + "name": "GOLDENROD_DEPT_STORE_B1F", + "width": 10 + }, + "GOLDENROD_DEPT_STORE_ELEVATOR": { + "group": 11, + "height": 2, + "map": 18, + "name": "GOLDENROD_DEPT_STORE_ELEVATOR", + "width": 2 + }, + "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": 11, + "name": "GOLDENROD_NAME_RATER", + "width": 4 + }, + "GOLDENROD_POKECENTER_1F": { + "group": 11, + "height": 4, + "map": 9, + "name": "GOLDENROD_POKECENTER_1F", + "width": 5 + }, + "GOLDENROD_PP_SPEECH_HOUSE": { + "group": 11, + "height": 4, + "map": 10, + "name": "GOLDENROD_PP_SPEECH_HOUSE", + "width": 4 + }, + "GOLDENROD_UNDERGROUND": { + "group": 3, + "height": 18, + "map": 45, + "name": "GOLDENROD_UNDERGROUND", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES": { + "group": 3, + "height": 18, + "map": 46, + "name": "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", + "width": 15 + }, + "GOLDENROD_UNDERGROUND_WAREHOUSE": { + "group": 3, + "height": 9, + "map": 48, + "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": 53, + "name": "ICE_PATH_1F", + "width": 20 + }, + "ICE_PATH_B1F": { + "group": 3, + "height": 18, + "map": 54, + "name": "ICE_PATH_B1F", + "width": 10 + }, + "ICE_PATH_B2F_BLACKTHORN_SIDE": { + "group": 3, + "height": 9, + "map": 56, + "name": "ICE_PATH_B2F_BLACKTHORN_SIDE", + "width": 5 + }, + "ICE_PATH_B2F_MAHOGANY_SIDE": { + "group": 3, + "height": 9, + "map": 55, + "name": "ICE_PATH_B2F_MAHOGANY_SIDE", + "width": 10 + }, + "ICE_PATH_B3F": { + "group": 3, + "height": 9, + "map": 57, + "name": "ICE_PATH_B3F", + "width": 10 + }, + "ILEX_FOREST": { + "group": 3, + "height": 27, + "map": 44, + "name": "ILEX_FOREST", + "width": 15 + }, + "ILEX_FOREST_AZALEA_GATE": { + "group": 11, + "height": 4, + "map": 20, + "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": 40, + "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 + }, + "MOUNT_MOON": { + "group": 3, + "height": 9, + "map": 76, + "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": 50, + "name": "MOUNT_MORTAR_1F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_1F_OUTSIDE": { + "group": 3, + "height": 18, + "map": 49, + "name": "MOUNT_MORTAR_1F_OUTSIDE", + "width": 20 + }, + "MOUNT_MORTAR_2F_INSIDE": { + "group": 3, + "height": 18, + "map": 51, + "name": "MOUNT_MORTAR_2F_INSIDE", + "width": 20 + }, + "MOUNT_MORTAR_B1F": { + "group": 3, + "height": 18, + "map": 52, + "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": 34, + "name": "OLIVINE_LIGHTHOUSE_1F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_2F": { + "group": 3, + "height": 9, + "map": 35, + "name": "OLIVINE_LIGHTHOUSE_2F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_3F": { + "group": 3, + "height": 9, + "map": 36, + "name": "OLIVINE_LIGHTHOUSE_3F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_4F": { + "group": 3, + "height": 9, + "map": 37, + "name": "OLIVINE_LIGHTHOUSE_4F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_5F": { + "group": 3, + "height": 9, + "map": 38, + "name": "OLIVINE_LIGHTHOUSE_5F", + "width": 10 + }, + "OLIVINE_LIGHTHOUSE_6F": { + "group": 3, + "height": 9, + "map": 39, + "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 + }, + "POKEMON_FAN_CLUB": { + "group": 12, + "height": 4, + "map": 7, + "name": "POKEMON_FAN_CLUB", + "width": 5 + }, + "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": 78, + "name": "ROCK_TUNNEL_1F", + "width": 15 + }, + "ROCK_TUNNEL_B1F": { + "group": 3, + "height": 18, + "map": 79, + "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": 21, + "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_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_HO_OH_CHAMBER": { + "group": 3, + "height": 5, + "map": 23, + "name": "RUINS_OF_ALPH_HO_OH_CHAMBER", + "width": 4 + }, + "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_OMANYTE_CHAMBER": { + "group": 3, + "height": 5, + "map": 25, + "name": "RUINS_OF_ALPH_OMANYTE_CHAMBER", + "width": 4 + }, + "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": 81, + "name": "SAFARI_ZONE_BETA", + "width": 10 + }, + "SAFARI_ZONE_FUCHSIA_GATE_BETA": { + "group": 3, + "height": 4, + "map": 80, + "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": 69, + "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": 66, + "name": "SILVER_CAVE_ROOM_1", + "width": 10 + }, + "SILVER_CAVE_ROOM_2": { + "group": 3, + "height": 18, + "map": 67, + "name": "SILVER_CAVE_ROOM_2", + "width": 15 + }, + "SILVER_CAVE_ROOM_3": { + "group": 3, + "height": 18, + "map": 68, + "name": "SILVER_CAVE_ROOM_3", + "width": 10 + }, + "SLOWPOKE_WELL_B1F": { + "group": 3, + "height": 9, + "map": 32, + "name": "SLOWPOKE_WELL_B1F", + "width": 10 + }, + "SLOWPOKE_WELL_B2F": { + "group": 3, + "height": 9, + "map": 33, + "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": 41, + "name": "TEAM_ROCKET_BASE_B1F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B2F": { + "group": 3, + "height": 9, + "map": 42, + "name": "TEAM_ROCKET_BASE_B2F", + "width": 15 + }, + "TEAM_ROCKET_BASE_B3F": { + "group": 3, + "height": 9, + "map": 43, + "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": 74, + "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": 77, + "name": "UNDERGROUND_PATH", + "width": 3 + }, + "UNION_CAVE_1F": { + "group": 3, + "height": 18, + "map": 29, + "name": "UNION_CAVE_1F", + "width": 10 + }, + "UNION_CAVE_B1F": { + "group": 3, + "height": 18, + "map": 30, + "name": "UNION_CAVE_B1F", + "width": 10 + }, + "UNION_CAVE_B2F": { + "group": 3, + "height": 18, + "map": 31, + "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": 82, + "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": 63, + "name": "WHIRL_ISLAND_B1F", + "width": 20 + }, + "WHIRL_ISLAND_B2F": { + "group": 3, + "height": 18, + "map": 64, + "name": "WHIRL_ISLAND_B2F", + "width": 10 + }, + "WHIRL_ISLAND_CAVE": { + "group": 3, + "height": 9, + "map": 61, + "name": "WHIRL_ISLAND_CAVE", + "width": 5 + }, + "WHIRL_ISLAND_LUGIA_CHAMBER": { + "group": 3, + "height": 9, + "map": 65, + "name": "WHIRL_ISLAND_LUGIA_CHAMBER", + "width": 10 + }, + "WHIRL_ISLAND_NE": { + "group": 3, + "height": 9, + "map": 59, + "name": "WHIRL_ISLAND_NE", + "width": 10 + }, + "WHIRL_ISLAND_NW": { + "group": 3, + "height": 9, + "map": 58, + "name": "WHIRL_ISLAND_NW", + "width": 5 + }, + "WHIRL_ISLAND_SE": { + "group": 3, + "height": 9, + "map": 62, + "name": "WHIRL_ISLAND_SE", + "width": 5 + }, + "WHIRL_ISLAND_SW": { + "group": 3, + "height": 9, + "map": 60, + "name": "WHIRL_ISLAND_SW", + "width": 10 + }, + "WILLS_ROOM": { + "group": 16, + "height": 9, + "map": 3, + "name": "WILLS_ROOM", + "width": 5 + } + }, + "pokemonAssets": { + "ABRA": { + "back": "abra_back", + "backLabel": "AbraBackpic", + "dexLabel": "AbraPokedexEntry", + "front": "abra", + "frontLabel": "AbraFrontpic", + "id": "ABRA", + "name": "ABRA" + }, + "AERODACTYL": { + "back": "aerodactyl_back", + "backLabel": "AerodactylBackpic", + "dexLabel": "AerodactylPokedexEntry", + "front": "aerodactyl", + "frontLabel": "AerodactylFrontpic", + "id": "AERODACTYL", + "name": "AERODACTYL" + }, + "AIPOM": { + "back": "aipom_back", + "backLabel": "AipomBackpic", + "dexLabel": "AipomPokedexEntry", + "front": "aipom", + "frontLabel": "AipomFrontpic", + "id": "AIPOM", + "name": "AIPOM" + }, + "ALAKAZAM": { + "back": "alakazam_back", + "backLabel": "AlakazamBackpic", + "dexLabel": "AlakazamPokedexEntry", + "front": "alakazam", + "frontLabel": "AlakazamFrontpic", + "id": "ALAKAZAM", + "name": "ALAKAZAM" + }, + "AMPHAROS": { + "back": "ampharos_back", + "backLabel": "AmpharosBackpic", + "dexLabel": "AmpharosPokedexEntry", + "front": "ampharos", + "frontLabel": "AmpharosFrontpic", + "id": "AMPHAROS", + "name": "AMPHAROS" + }, + "ARBOK": { + "back": "arbok_back", + "backLabel": "ArbokBackpic", + "dexLabel": "ArbokPokedexEntry", + "front": "arbok", + "frontLabel": "ArbokFrontpic", + "id": "ARBOK", + "name": "ARBOK" + }, + "ARCANINE": { + "back": "arcanine_back", + "backLabel": "ArcanineBackpic", + "dexLabel": "ArcaninePokedexEntry", + "front": "arcanine", + "frontLabel": "ArcanineFrontpic", + "id": "ARCANINE", + "name": "ARCANINE" + }, + "ARIADOS": { + "back": "ariados_back", + "backLabel": "AriadosBackpic", + "dexLabel": "AriadosPokedexEntry", + "front": "ariados", + "frontLabel": "AriadosFrontpic", + "id": "ARIADOS", + "name": "ARIADOS" + }, + "ARTICUNO": { + "back": "articuno_back", + "backLabel": "ArticunoBackpic", + "dexLabel": "ArticunoPokedexEntry", + "front": "articuno", + "frontLabel": "ArticunoFrontpic", + "id": "ARTICUNO", + "name": "ARTICUNO" + }, + "AZUMARILL": { + "back": "azumarill_back", + "backLabel": "AzumarillBackpic", + "dexLabel": "AzumarillPokedexEntry", + "front": "azumarill", + "frontLabel": "AzumarillFrontpic", + "id": "AZUMARILL", + "name": "AZUMARILL" + }, + "BAYLEEF": { + "back": "bayleef_back", + "backLabel": "BayleefBackpic", + "dexLabel": "BayleefPokedexEntry", + "front": "bayleef", + "frontLabel": "BayleefFrontpic", + "id": "BAYLEEF", + "name": "BAYLEEF" + }, + "BEEDRILL": { + "back": "beedrill_back", + "backLabel": "BeedrillBackpic", + "dexLabel": "BeedrillPokedexEntry", + "front": "beedrill", + "frontLabel": "BeedrillFrontpic", + "id": "BEEDRILL", + "name": "BEEDRILL" + }, + "BELLOSSOM": { + "back": "bellossom_back", + "backLabel": "BellossomBackpic", + "dexLabel": "BellossomPokedexEntry", + "front": "bellossom", + "frontLabel": "BellossomFrontpic", + "id": "BELLOSSOM", + "name": "BELLOSSOM" + }, + "BELLSPROUT": { + "back": "bellsprout_back", + "backLabel": "BellsproutBackpic", + "dexLabel": "BellsproutPokedexEntry", + "front": "bellsprout", + "frontLabel": "BellsproutFrontpic", + "id": "BELLSPROUT", + "name": "BELLSPROUT" + }, + "BLASTOISE": { + "back": "blastoise_back", + "backLabel": "BlastoiseBackpic", + "dexLabel": "BlastoisePokedexEntry", + "front": "blastoise", + "frontLabel": "BlastoiseFrontpic", + "id": "BLASTOISE", + "name": "BLASTOISE" + }, + "BLISSEY": { + "back": "blissey_back", + "backLabel": "BlisseyBackpic", + "dexLabel": "BlisseyPokedexEntry", + "front": "blissey", + "frontLabel": "BlisseyFrontpic", + "id": "BLISSEY", + "name": "BLISSEY" + }, + "BULBASAUR": { + "back": "bulbasaur_back", + "backLabel": "BulbasaurBackpic", + "dexLabel": "BulbasaurPokedexEntry", + "front": "bulbasaur", + "frontLabel": "BulbasaurFrontpic", + "id": "BULBASAUR", + "name": "BULBASAUR" + }, + "BUTTERFREE": { + "back": "butterfree_back", + "backLabel": "ButterfreeBackpic", + "dexLabel": "ButterfreePokedexEntry", + "front": "butterfree", + "frontLabel": "ButterfreeFrontpic", + "id": "BUTTERFREE", + "name": "BUTTERFREE" + }, + "CATERPIE": { + "back": "caterpie_back", + "backLabel": "CaterpieBackpic", + "dexLabel": "CaterpiePokedexEntry", + "front": "caterpie", + "frontLabel": "CaterpieFrontpic", + "id": "CATERPIE", + "name": "CATERPIE" + }, + "CELEBI": { + "back": "celebi_back", + "backLabel": "CelebiBackpic", + "dexLabel": "CelebiPokedexEntry", + "front": "celebi", + "frontLabel": "CelebiFrontpic", + "id": "CELEBI", + "name": "CELEBI" + }, + "CHANSEY": { + "back": "chansey_back", + "backLabel": "ChanseyBackpic", + "dexLabel": "ChanseyPokedexEntry", + "front": "chansey", + "frontLabel": "ChanseyFrontpic", + "id": "CHANSEY", + "name": "CHANSEY" + }, + "CHARIZARD": { + "back": "charizard_back", + "backLabel": "CharizardBackpic", + "dexLabel": "CharizardPokedexEntry", + "front": "charizard", + "frontLabel": "CharizardFrontpic", + "id": "CHARIZARD", + "name": "CHARIZARD" + }, + "CHARMANDER": { + "back": "charmander_back", + "backLabel": "CharmanderBackpic", + "dexLabel": "CharmanderPokedexEntry", + "front": "charmander", + "frontLabel": "CharmanderFrontpic", + "id": "CHARMANDER", + "name": "CHARMANDER" + }, + "CHARMELEON": { + "back": "charmeleon_back", + "backLabel": "CharmeleonBackpic", + "dexLabel": "CharmeleonPokedexEntry", + "front": "charmeleon", + "frontLabel": "CharmeleonFrontpic", + "id": "CHARMELEON", + "name": "CHARMELEON" + }, + "CHIKORITA": { + "back": "chikorita_back", + "backLabel": "ChikoritaBackpic", + "dexLabel": "ChikoritaPokedexEntry", + "front": "chikorita", + "frontLabel": "ChikoritaFrontpic", + "id": "CHIKORITA", + "name": "CHIKORITA" + }, + "CHINCHOU": { + "back": "chinchou_back", + "backLabel": "ChinchouBackpic", + "dexLabel": "ChinchouPokedexEntry", + "front": "chinchou", + "frontLabel": "ChinchouFrontpic", + "id": "CHINCHOU", + "name": "CHINCHOU" + }, + "CLEFABLE": { + "back": "clefable_back", + "backLabel": "ClefableBackpic", + "dexLabel": "ClefablePokedexEntry", + "front": "clefable", + "frontLabel": "ClefableFrontpic", + "id": "CLEFABLE", + "name": "CLEFABLE" + }, + "CLEFAIRY": { + "back": "clefairy_back", + "backLabel": "ClefairyBackpic", + "dexLabel": "ClefairyPokedexEntry", + "front": "clefairy", + "frontLabel": "ClefairyFrontpic", + "id": "CLEFAIRY", + "name": "CLEFAIRY" + }, + "CLEFFA": { + "back": "cleffa_back", + "backLabel": "CleffaBackpic", + "dexLabel": "CleffaPokedexEntry", + "front": "cleffa", + "frontLabel": "CleffaFrontpic", + "id": "CLEFFA", + "name": "CLEFFA" + }, + "CLOYSTER": { + "back": "cloyster_back", + "backLabel": "CloysterBackpic", + "dexLabel": "CloysterPokedexEntry", + "front": "cloyster", + "frontLabel": "CloysterFrontpic", + "id": "CLOYSTER", + "name": "CLOYSTER" + }, + "CORSOLA": { + "back": "corsola_back", + "backLabel": "CorsolaBackpic", + "dexLabel": "CorsolaPokedexEntry", + "front": "corsola", + "frontLabel": "CorsolaFrontpic", + "id": "CORSOLA", + "name": "CORSOLA" + }, + "CROBAT": { + "back": "crobat_back", + "backLabel": "CrobatBackpic", + "dexLabel": "CrobatPokedexEntry", + "front": "crobat", + "frontLabel": "CrobatFrontpic", + "id": "CROBAT", + "name": "CROBAT" + }, + "CROCONAW": { + "back": "croconaw_back", + "backLabel": "CroconawBackpic", + "dexLabel": "CroconawPokedexEntry", + "front": "croconaw", + "frontLabel": "CroconawFrontpic", + "id": "CROCONAW", + "name": "CROCONAW" + }, + "CUBONE": { + "back": "cubone_back", + "backLabel": "CuboneBackpic", + "dexLabel": "CubonePokedexEntry", + "front": "cubone", + "frontLabel": "CuboneFrontpic", + "id": "CUBONE", + "name": "CUBONE" + }, + "CYNDAQUIL": { + "back": "cyndaquil_back", + "backLabel": "CyndaquilBackpic", + "dexLabel": "CyndaquilPokedexEntry", + "front": "cyndaquil", + "frontLabel": "CyndaquilFrontpic", + "id": "CYNDAQUIL", + "name": "CYNDAQUIL" + }, + "DELIBIRD": { + "back": "delibird_back", + "backLabel": "DelibirdBackpic", + "dexLabel": "DelibirdPokedexEntry", + "front": "delibird", + "frontLabel": "DelibirdFrontpic", + "id": "DELIBIRD", + "name": "DELIBIRD" + }, + "DEWGONG": { + "back": "dewgong_back", + "backLabel": "DewgongBackpic", + "dexLabel": "DewgongPokedexEntry", + "front": "dewgong", + "frontLabel": "DewgongFrontpic", + "id": "DEWGONG", + "name": "DEWGONG" + }, + "DIGLETT": { + "back": "diglett_back", + "backLabel": "DiglettBackpic", + "dexLabel": "DiglettPokedexEntry", + "front": "diglett", + "frontLabel": "DiglettFrontpic", + "id": "DIGLETT", + "name": "DIGLETT" + }, + "DITTO": { + "back": "ditto_back", + "backLabel": "DittoBackpic", + "dexLabel": "DittoPokedexEntry", + "front": "ditto", + "frontLabel": "DittoFrontpic", + "id": "DITTO", + "name": "DITTO" + }, + "DODRIO": { + "back": "dodrio_back", + "backLabel": "DodrioBackpic", + "dexLabel": "DodrioPokedexEntry", + "front": "dodrio", + "frontLabel": "DodrioFrontpic", + "id": "DODRIO", + "name": "DODRIO" + }, + "DODUO": { + "back": "doduo_back", + "backLabel": "DoduoBackpic", + "dexLabel": "DoduoPokedexEntry", + "front": "doduo", + "frontLabel": "DoduoFrontpic", + "id": "DODUO", + "name": "DODUO" + }, + "DONPHAN": { + "back": "donphan_back", + "backLabel": "DonphanBackpic", + "dexLabel": "DonphanPokedexEntry", + "front": "donphan", + "frontLabel": "DonphanFrontpic", + "id": "DONPHAN", + "name": "DONPHAN" + }, + "DRAGONAIR": { + "back": "dragonair_back", + "backLabel": "DragonairBackpic", + "dexLabel": "DragonairPokedexEntry", + "front": "dragonair", + "frontLabel": "DragonairFrontpic", + "id": "DRAGONAIR", + "name": "DRAGONAIR" + }, + "DRAGONITE": { + "back": "dragonite_back", + "backLabel": "DragoniteBackpic", + "dexLabel": "DragonitePokedexEntry", + "front": "dragonite", + "frontLabel": "DragoniteFrontpic", + "id": "DRAGONITE", + "name": "DRAGONITE" + }, + "DRATINI": { + "back": "dratini_back", + "backLabel": "DratiniBackpic", + "dexLabel": "DratiniPokedexEntry", + "front": "dratini", + "frontLabel": "DratiniFrontpic", + "id": "DRATINI", + "name": "DRATINI" + }, + "DROWZEE": { + "back": "drowzee_back", + "backLabel": "DrowzeeBackpic", + "dexLabel": "DrowzeePokedexEntry", + "front": "drowzee", + "frontLabel": "DrowzeeFrontpic", + "id": "DROWZEE", + "name": "DROWZEE" + }, + "DUGTRIO": { + "back": "dugtrio_back", + "backLabel": "DugtrioBackpic", + "dexLabel": "DugtrioPokedexEntry", + "front": "dugtrio", + "frontLabel": "DugtrioFrontpic", + "id": "DUGTRIO", + "name": "DUGTRIO" + }, + "DUNSPARCE": { + "back": "dunsparce_back", + "backLabel": "DunsparceBackpic", + "dexLabel": "DunsparcePokedexEntry", + "front": "dunsparce", + "frontLabel": "DunsparceFrontpic", + "id": "DUNSPARCE", + "name": "DUNSPARCE" + }, + "EEVEE": { + "back": "eevee_back", + "backLabel": "EeveeBackpic", + "dexLabel": "EeveePokedexEntry", + "front": "eevee", + "frontLabel": "EeveeFrontpic", + "id": "EEVEE", + "name": "EEVEE" + }, + "EKANS": { + "back": "ekans_back", + "backLabel": "EkansBackpic", + "dexLabel": "EkansPokedexEntry", + "front": "ekans", + "frontLabel": "EkansFrontpic", + "id": "EKANS", + "name": "EKANS" + }, + "ELECTABUZZ": { + "back": "electabuzz_back", + "backLabel": "ElectabuzzBackpic", + "dexLabel": "ElectabuzzPokedexEntry", + "front": "electabuzz", + "frontLabel": "ElectabuzzFrontpic", + "id": "ELECTABUZZ", + "name": "ELECTABUZZ" + }, + "ELECTRODE": { + "back": "electrode_back", + "backLabel": "ElectrodeBackpic", + "dexLabel": "ElectrodePokedexEntry", + "front": "electrode", + "frontLabel": "ElectrodeFrontpic", + "id": "ELECTRODE", + "name": "ELECTRODE" + }, + "ELEKID": { + "back": "elekid_back", + "backLabel": "ElekidBackpic", + "dexLabel": "ElekidPokedexEntry", + "front": "elekid", + "frontLabel": "ElekidFrontpic", + "id": "ELEKID", + "name": "ELEKID" + }, + "ENTEI": { + "back": "entei_back", + "backLabel": "EnteiBackpic", + "dexLabel": "EnteiPokedexEntry", + "front": "entei", + "frontLabel": "EnteiFrontpic", + "id": "ENTEI", + "name": "ENTEI" + }, + "ESPEON": { + "back": "espeon_back", + "backLabel": "EspeonBackpic", + "dexLabel": "EspeonPokedexEntry", + "front": "espeon", + "frontLabel": "EspeonFrontpic", + "id": "ESPEON", + "name": "ESPEON" + }, + "EXEGGCUTE": { + "back": "exeggcute_back", + "backLabel": "ExeggcuteBackpic", + "dexLabel": "ExeggcutePokedexEntry", + "front": "exeggcute", + "frontLabel": "ExeggcuteFrontpic", + "id": "EXEGGCUTE", + "name": "EXEGGCUTE" + }, + "EXEGGUTOR": { + "back": "exeggutor_back", + "backLabel": "ExeggutorBackpic", + "dexLabel": "ExeggutorPokedexEntry", + "front": "exeggutor", + "frontLabel": "ExeggutorFrontpic", + "id": "EXEGGUTOR", + "name": "EXEGGUTOR" + }, + "FARFETCH_D": { + "back": "farfetchd_back", + "backLabel": "FarfetchDBackpic", + "dexLabel": "FarfetchDPokedexEntry", + "front": "farfetchd", + "frontLabel": "FarfetchDFrontpic", + "id": "FARFETCH_D", + "name": "FARFETCH'D" + }, + "FEAROW": { + "back": "fearow_back", + "backLabel": "FearowBackpic", + "dexLabel": "FearowPokedexEntry", + "front": "fearow", + "frontLabel": "FearowFrontpic", + "id": "FEAROW", + "name": "FEAROW" + }, + "FERALIGATR": { + "back": "feraligatr_back", + "backLabel": "FeraligatrBackpic", + "dexLabel": "FeraligatrPokedexEntry", + "front": "feraligatr", + "frontLabel": "FeraligatrFrontpic", + "id": "FERALIGATR", + "name": "FERALIGATR" + }, + "FLAAFFY": { + "back": "flaaffy_back", + "backLabel": "FlaaffyBackpic", + "dexLabel": "FlaaffyPokedexEntry", + "front": "flaaffy", + "frontLabel": "FlaaffyFrontpic", + "id": "FLAAFFY", + "name": "FLAAFFY" + }, + "FLAREON": { + "back": "flareon_back", + "backLabel": "FlareonBackpic", + "dexLabel": "FlareonPokedexEntry", + "front": "flareon", + "frontLabel": "FlareonFrontpic", + "id": "FLAREON", + "name": "FLAREON" + }, + "FORRETRESS": { + "back": "forretress_back", + "backLabel": "ForretressBackpic", + "dexLabel": "ForretressPokedexEntry", + "front": "forretress", + "frontLabel": "ForretressFrontpic", + "id": "FORRETRESS", + "name": "FORRETRESS" + }, + "FURRET": { + "back": "furret_back", + "backLabel": "FurretBackpic", + "dexLabel": "FurretPokedexEntry", + "front": "furret", + "frontLabel": "FurretFrontpic", + "id": "FURRET", + "name": "FURRET" + }, + "GASTLY": { + "back": "gastly_back", + "backLabel": "GastlyBackpic", + "dexLabel": "GastlyPokedexEntry", + "front": "gastly", + "frontLabel": "GastlyFrontpic", + "id": "GASTLY", + "name": "GASTLY" + }, + "GENGAR": { + "back": "gengar_back", + "backLabel": "GengarBackpic", + "dexLabel": "GengarPokedexEntry", + "front": "gengar", + "frontLabel": "GengarFrontpic", + "id": "GENGAR", + "name": "GENGAR" + }, + "GEODUDE": { + "back": "geodude_back", + "backLabel": "GeodudeBackpic", + "dexLabel": "GeodudePokedexEntry", + "front": "geodude", + "frontLabel": "GeodudeFrontpic", + "id": "GEODUDE", + "name": "GEODUDE" + }, + "GIRAFARIG": { + "back": "girafarig_back", + "backLabel": "GirafarigBackpic", + "dexLabel": "GirafarigPokedexEntry", + "front": "girafarig", + "frontLabel": "GirafarigFrontpic", + "id": "GIRAFARIG", + "name": "GIRAFARIG" + }, + "GLIGAR": { + "back": "gligar_back", + "backLabel": "GligarBackpic", + "dexLabel": "GligarPokedexEntry", + "front": "gligar", + "frontLabel": "GligarFrontpic", + "id": "GLIGAR", + "name": "GLIGAR" + }, + "GLOOM": { + "back": "gloom_back", + "backLabel": "GloomBackpic", + "dexLabel": "GloomPokedexEntry", + "front": "gloom", + "frontLabel": "GloomFrontpic", + "id": "GLOOM", + "name": "GLOOM" + }, + "GOLBAT": { + "back": "golbat_back", + "backLabel": "GolbatBackpic", + "dexLabel": "GolbatPokedexEntry", + "front": "golbat", + "frontLabel": "GolbatFrontpic", + "id": "GOLBAT", + "name": "GOLBAT" + }, + "GOLDEEN": { + "back": "goldeen_back", + "backLabel": "GoldeenBackpic", + "dexLabel": "GoldeenPokedexEntry", + "front": "goldeen", + "frontLabel": "GoldeenFrontpic", + "id": "GOLDEEN", + "name": "GOLDEEN" + }, + "GOLDUCK": { + "back": "golduck_back", + "backLabel": "GolduckBackpic", + "dexLabel": "GolduckPokedexEntry", + "front": "golduck", + "frontLabel": "GolduckFrontpic", + "id": "GOLDUCK", + "name": "GOLDUCK" + }, + "GOLEM": { + "back": "golem_back", + "backLabel": "GolemBackpic", + "dexLabel": "GolemPokedexEntry", + "front": "golem", + "frontLabel": "GolemFrontpic", + "id": "GOLEM", + "name": "GOLEM" + }, + "GRANBULL": { + "back": "granbull_back", + "backLabel": "GranbullBackpic", + "dexLabel": "GranbullPokedexEntry", + "front": "granbull", + "frontLabel": "GranbullFrontpic", + "id": "GRANBULL", + "name": "GRANBULL" + }, + "GRAVELER": { + "back": "graveler_back", + "backLabel": "GravelerBackpic", + "dexLabel": "GravelerPokedexEntry", + "front": "graveler", + "frontLabel": "GravelerFrontpic", + "id": "GRAVELER", + "name": "GRAVELER" + }, + "GRIMER": { + "back": "grimer_back", + "backLabel": "GrimerBackpic", + "dexLabel": "GrimerPokedexEntry", + "front": "grimer", + "frontLabel": "GrimerFrontpic", + "id": "GRIMER", + "name": "GRIMER" + }, + "GROWLITHE": { + "back": "growlithe_back", + "backLabel": "GrowlitheBackpic", + "dexLabel": "GrowlithePokedexEntry", + "front": "growlithe", + "frontLabel": "GrowlitheFrontpic", + "id": "GROWLITHE", + "name": "GROWLITHE" + }, + "GYARADOS": { + "back": "gyarados_back", + "backLabel": "GyaradosBackpic", + "dexLabel": "GyaradosPokedexEntry", + "front": "gyarados", + "frontLabel": "GyaradosFrontpic", + "id": "GYARADOS", + "name": "GYARADOS" + }, + "HAUNTER": { + "back": "haunter_back", + "backLabel": "HaunterBackpic", + "dexLabel": "HaunterPokedexEntry", + "front": "haunter", + "frontLabel": "HaunterFrontpic", + "id": "HAUNTER", + "name": "HAUNTER" + }, + "HERACROSS": { + "back": "heracross_back", + "backLabel": "HeracrossBackpic", + "dexLabel": "HeracrossPokedexEntry", + "front": "heracross", + "frontLabel": "HeracrossFrontpic", + "id": "HERACROSS", + "name": "HERACROSS" + }, + "HITMONCHAN": { + "back": "hitmonchan_back", + "backLabel": "HitmonchanBackpic", + "dexLabel": "HitmonchanPokedexEntry", + "front": "hitmonchan", + "frontLabel": "HitmonchanFrontpic", + "id": "HITMONCHAN", + "name": "HITMONCHAN" + }, + "HITMONLEE": { + "back": "hitmonlee_back", + "backLabel": "HitmonleeBackpic", + "dexLabel": "HitmonleePokedexEntry", + "front": "hitmonlee", + "frontLabel": "HitmonleeFrontpic", + "id": "HITMONLEE", + "name": "HITMONLEE" + }, + "HITMONTOP": { + "back": "hitmontop_back", + "backLabel": "HitmontopBackpic", + "dexLabel": "HitmontopPokedexEntry", + "front": "hitmontop", + "frontLabel": "HitmontopFrontpic", + "id": "HITMONTOP", + "name": "HITMONTOP" + }, + "HOOTHOOT": { + "back": "hoothoot_back", + "backLabel": "HoothootBackpic", + "dexLabel": "HoothootPokedexEntry", + "front": "hoothoot", + "frontLabel": "HoothootFrontpic", + "id": "HOOTHOOT", + "name": "HOOTHOOT" + }, + "HOPPIP": { + "back": "hoppip_back", + "backLabel": "HoppipBackpic", + "dexLabel": "HoppipPokedexEntry", + "front": "hoppip", + "frontLabel": "HoppipFrontpic", + "id": "HOPPIP", + "name": "HOPPIP" + }, + "HORSEA": { + "back": "horsea_back", + "backLabel": "HorseaBackpic", + "dexLabel": "HorseaPokedexEntry", + "front": "horsea", + "frontLabel": "HorseaFrontpic", + "id": "HORSEA", + "name": "HORSEA" + }, + "HOUNDOOM": { + "back": "houndoom_back", + "backLabel": "HoundoomBackpic", + "dexLabel": "HoundoomPokedexEntry", + "front": "houndoom", + "frontLabel": "HoundoomFrontpic", + "id": "HOUNDOOM", + "name": "HOUNDOOM" + }, + "HOUNDOUR": { + "back": "houndour_back", + "backLabel": "HoundourBackpic", + "dexLabel": "HoundourPokedexEntry", + "front": "houndour", + "frontLabel": "HoundourFrontpic", + "id": "HOUNDOUR", + "name": "HOUNDOUR" + }, + "HO_OH": { + "back": "hooh_back", + "backLabel": "HoOhBackpic", + "dexLabel": "HoOhPokedexEntry", + "front": "hooh", + "frontLabel": "HoOhFrontpic", + "id": "HO_OH", + "name": "HO-OH" + }, + "HYPNO": { + "back": "hypno_back", + "backLabel": "HypnoBackpic", + "dexLabel": "HypnoPokedexEntry", + "front": "hypno", + "frontLabel": "HypnoFrontpic", + "id": "HYPNO", + "name": "HYPNO" + }, + "IGGLYBUFF": { + "back": "igglybuff_back", + "backLabel": "IgglybuffBackpic", + "dexLabel": "IgglybuffPokedexEntry", + "front": "igglybuff", + "frontLabel": "IgglybuffFrontpic", + "id": "IGGLYBUFF", + "name": "IGGLYBUFF" + }, + "IVYSAUR": { + "back": "ivysaur_back", + "backLabel": "IvysaurBackpic", + "dexLabel": "IvysaurPokedexEntry", + "front": "ivysaur", + "frontLabel": "IvysaurFrontpic", + "id": "IVYSAUR", + "name": "IVYSAUR" + }, + "JIGGLYPUFF": { + "back": "jigglypuff_back", + "backLabel": "JigglypuffBackpic", + "dexLabel": "JigglypuffPokedexEntry", + "front": "jigglypuff", + "frontLabel": "JigglypuffFrontpic", + "id": "JIGGLYPUFF", + "name": "JIGGLYPUFF" + }, + "JOLTEON": { + "back": "jolteon_back", + "backLabel": "JolteonBackpic", + "dexLabel": "JolteonPokedexEntry", + "front": "jolteon", + "frontLabel": "JolteonFrontpic", + "id": "JOLTEON", + "name": "JOLTEON" + }, + "JUMPLUFF": { + "back": "jumpluff_back", + "backLabel": "JumpluffBackpic", + "dexLabel": "JumpluffPokedexEntry", + "front": "jumpluff", + "frontLabel": "JumpluffFrontpic", + "id": "JUMPLUFF", + "name": "JUMPLUFF" + }, + "JYNX": { + "back": "jynx_back", + "backLabel": "JynxBackpic", + "dexLabel": "JynxPokedexEntry", + "front": "jynx", + "frontLabel": "JynxFrontpic", + "id": "JYNX", + "name": "JYNX" + }, + "KABUTO": { + "back": "kabuto_back", + "backLabel": "KabutoBackpic", + "dexLabel": "KabutoPokedexEntry", + "front": "kabuto", + "frontLabel": "KabutoFrontpic", + "id": "KABUTO", + "name": "KABUTO" + }, + "KABUTOPS": { + "back": "kabutops_back", + "backLabel": "KabutopsBackpic", + "dexLabel": "KabutopsPokedexEntry", + "front": "kabutops", + "frontLabel": "KabutopsFrontpic", + "id": "KABUTOPS", + "name": "KABUTOPS" + }, + "KADABRA": { + "back": "kadabra_back", + "backLabel": "KadabraBackpic", + "dexLabel": "KadabraPokedexEntry", + "front": "kadabra", + "frontLabel": "KadabraFrontpic", + "id": "KADABRA", + "name": "KADABRA" + }, + "KAKUNA": { + "back": "kakuna_back", + "backLabel": "KakunaBackpic", + "dexLabel": "KakunaPokedexEntry", + "front": "kakuna", + "frontLabel": "KakunaFrontpic", + "id": "KAKUNA", + "name": "KAKUNA" + }, + "KANGASKHAN": { + "back": "kangaskhan_back", + "backLabel": "KangaskhanBackpic", + "dexLabel": "KangaskhanPokedexEntry", + "front": "kangaskhan", + "frontLabel": "KangaskhanFrontpic", + "id": "KANGASKHAN", + "name": "KANGASKHAN" + }, + "KINGDRA": { + "back": "kingdra_back", + "backLabel": "KingdraBackpic", + "dexLabel": "KingdraPokedexEntry", + "front": "kingdra", + "frontLabel": "KingdraFrontpic", + "id": "KINGDRA", + "name": "KINGDRA" + }, + "KINGLER": { + "back": "kingler_back", + "backLabel": "KinglerBackpic", + "dexLabel": "KinglerPokedexEntry", + "front": "kingler", + "frontLabel": "KinglerFrontpic", + "id": "KINGLER", + "name": "KINGLER" + }, + "KOFFING": { + "back": "koffing_back", + "backLabel": "KoffingBackpic", + "dexLabel": "KoffingPokedexEntry", + "front": "koffing", + "frontLabel": "KoffingFrontpic", + "id": "KOFFING", + "name": "KOFFING" + }, + "KRABBY": { + "back": "krabby_back", + "backLabel": "KrabbyBackpic", + "dexLabel": "KrabbyPokedexEntry", + "front": "krabby", + "frontLabel": "KrabbyFrontpic", + "id": "KRABBY", + "name": "KRABBY" + }, + "LANTURN": { + "back": "lanturn_back", + "backLabel": "LanturnBackpic", + "dexLabel": "LanturnPokedexEntry", + "front": "lanturn", + "frontLabel": "LanturnFrontpic", + "id": "LANTURN", + "name": "LANTURN" + }, + "LAPRAS": { + "back": "lapras_back", + "backLabel": "LaprasBackpic", + "dexLabel": "LaprasPokedexEntry", + "front": "lapras", + "frontLabel": "LaprasFrontpic", + "id": "LAPRAS", + "name": "LAPRAS" + }, + "LARVITAR": { + "back": "larvitar_back", + "backLabel": "LarvitarBackpic", + "dexLabel": "LarvitarPokedexEntry", + "front": "larvitar", + "frontLabel": "LarvitarFrontpic", + "id": "LARVITAR", + "name": "LARVITAR" + }, + "LEDIAN": { + "back": "ledian_back", + "backLabel": "LedianBackpic", + "dexLabel": "LedianPokedexEntry", + "front": "ledian", + "frontLabel": "LedianFrontpic", + "id": "LEDIAN", + "name": "LEDIAN" + }, + "LEDYBA": { + "back": "ledyba_back", + "backLabel": "LedybaBackpic", + "dexLabel": "LedybaPokedexEntry", + "front": "ledyba", + "frontLabel": "LedybaFrontpic", + "id": "LEDYBA", + "name": "LEDYBA" + }, + "LICKITUNG": { + "back": "lickitung_back", + "backLabel": "LickitungBackpic", + "dexLabel": "LickitungPokedexEntry", + "front": "lickitung", + "frontLabel": "LickitungFrontpic", + "id": "LICKITUNG", + "name": "LICKITUNG" + }, + "LUGIA": { + "back": "lugia_back", + "backLabel": "LugiaBackpic", + "dexLabel": "LugiaPokedexEntry", + "front": "lugia", + "frontLabel": "LugiaFrontpic", + "id": "LUGIA", + "name": "LUGIA" + }, + "MACHAMP": { + "back": "machamp_back", + "backLabel": "MachampBackpic", + "dexLabel": "MachampPokedexEntry", + "front": "machamp", + "frontLabel": "MachampFrontpic", + "id": "MACHAMP", + "name": "MACHAMP" + }, + "MACHOKE": { + "back": "machoke_back", + "backLabel": "MachokeBackpic", + "dexLabel": "MachokePokedexEntry", + "front": "machoke", + "frontLabel": "MachokeFrontpic", + "id": "MACHOKE", + "name": "MACHOKE" + }, + "MACHOP": { + "back": "machop_back", + "backLabel": "MachopBackpic", + "dexLabel": "MachopPokedexEntry", + "front": "machop", + "frontLabel": "MachopFrontpic", + "id": "MACHOP", + "name": "MACHOP" + }, + "MAGBY": { + "back": "magby_back", + "backLabel": "MagbyBackpic", + "dexLabel": "MagbyPokedexEntry", + "front": "magby", + "frontLabel": "MagbyFrontpic", + "id": "MAGBY", + "name": "MAGBY" + }, + "MAGCARGO": { + "back": "magcargo_back", + "backLabel": "MagcargoBackpic", + "dexLabel": "MagcargoPokedexEntry", + "front": "magcargo", + "frontLabel": "MagcargoFrontpic", + "id": "MAGCARGO", + "name": "MAGCARGO" + }, + "MAGIKARP": { + "back": "magikarp_back", + "backLabel": "MagikarpBackpic", + "dexLabel": "MagikarpPokedexEntry", + "front": "magikarp", + "frontLabel": "MagikarpFrontpic", + "id": "MAGIKARP", + "name": "MAGIKARP" + }, + "MAGMAR": { + "back": "magmar_back", + "backLabel": "MagmarBackpic", + "dexLabel": "MagmarPokedexEntry", + "front": "magmar", + "frontLabel": "MagmarFrontpic", + "id": "MAGMAR", + "name": "MAGMAR" + }, + "MAGNEMITE": { + "back": "magnemite_back", + "backLabel": "MagnemiteBackpic", + "dexLabel": "MagnemitePokedexEntry", + "front": "magnemite", + "frontLabel": "MagnemiteFrontpic", + "id": "MAGNEMITE", + "name": "MAGNEMITE" + }, + "MAGNETON": { + "back": "magneton_back", + "backLabel": "MagnetonBackpic", + "dexLabel": "MagnetonPokedexEntry", + "front": "magneton", + "frontLabel": "MagnetonFrontpic", + "id": "MAGNETON", + "name": "MAGNETON" + }, + "MANKEY": { + "back": "mankey_back", + "backLabel": "MankeyBackpic", + "dexLabel": "MankeyPokedexEntry", + "front": "mankey", + "frontLabel": "MankeyFrontpic", + "id": "MANKEY", + "name": "MANKEY" + }, + "MANTINE": { + "back": "mantine_back", + "backLabel": "MantineBackpic", + "dexLabel": "MantinePokedexEntry", + "front": "mantine", + "frontLabel": "MantineFrontpic", + "id": "MANTINE", + "name": "MANTINE" + }, + "MAREEP": { + "back": "mareep_back", + "backLabel": "MareepBackpic", + "dexLabel": "MareepPokedexEntry", + "front": "mareep", + "frontLabel": "MareepFrontpic", + "id": "MAREEP", + "name": "MAREEP" + }, + "MARILL": { + "back": "marill_back", + "backLabel": "MarillBackpic", + "dexLabel": "MarillPokedexEntry", + "front": "marill", + "frontLabel": "MarillFrontpic", + "id": "MARILL", + "name": "MARILL" + }, + "MAROWAK": { + "back": "marowak_back", + "backLabel": "MarowakBackpic", + "dexLabel": "MarowakPokedexEntry", + "front": "marowak", + "frontLabel": "MarowakFrontpic", + "id": "MAROWAK", + "name": "MAROWAK" + }, + "MEGANIUM": { + "back": "meganium_back", + "backLabel": "MeganiumBackpic", + "dexLabel": "MeganiumPokedexEntry", + "front": "meganium", + "frontLabel": "MeganiumFrontpic", + "id": "MEGANIUM", + "name": "MEGANIUM" + }, + "MEOWTH": { + "back": "meowth_back", + "backLabel": "MeowthBackpic", + "dexLabel": "MeowthPokedexEntry", + "front": "meowth", + "frontLabel": "MeowthFrontpic", + "id": "MEOWTH", + "name": "MEOWTH" + }, + "METAPOD": { + "back": "metapod_back", + "backLabel": "MetapodBackpic", + "dexLabel": "MetapodPokedexEntry", + "front": "metapod", + "frontLabel": "MetapodFrontpic", + "id": "METAPOD", + "name": "METAPOD" + }, + "MEW": { + "back": "mew_back", + "backLabel": "MewBackpic", + "dexLabel": "MewPokedexEntry", + "front": "mew", + "frontLabel": "MewFrontpic", + "id": "MEW", + "name": "MEW" + }, + "MEWTWO": { + "back": "mewtwo_back", + "backLabel": "MewtwoBackpic", + "dexLabel": "MewtwoPokedexEntry", + "front": "mewtwo", + "frontLabel": "MewtwoFrontpic", + "id": "MEWTWO", + "name": "MEWTWO" + }, + "MILTANK": { + "back": "miltank_back", + "backLabel": "MiltankBackpic", + "dexLabel": "MiltankPokedexEntry", + "front": "miltank", + "frontLabel": "MiltankFrontpic", + "id": "MILTANK", + "name": "MILTANK" + }, + "MISDREAVUS": { + "back": "misdreavus_back", + "backLabel": "MisdreavusBackpic", + "dexLabel": "MisdreavusPokedexEntry", + "front": "misdreavus", + "frontLabel": "MisdreavusFrontpic", + "id": "MISDREAVUS", + "name": "MISDREAVUS" + }, + "MOLTRES": { + "back": "moltres_back", + "backLabel": "MoltresBackpic", + "dexLabel": "MoltresPokedexEntry", + "front": "moltres", + "frontLabel": "MoltresFrontpic", + "id": "MOLTRES", + "name": "MOLTRES" + }, + "MR__MIME": { + "back": "mrmime_back", + "backLabel": "MrMimeBackpic", + "dexLabel": "MrMimePokedexEntry", + "front": "mrmime", + "frontLabel": "MrMimeFrontpic", + "id": "MR__MIME", + "name": "MR.MIME" + }, + "MUK": { + "back": "muk_back", + "backLabel": "MukBackpic", + "dexLabel": "MukPokedexEntry", + "front": "muk", + "frontLabel": "MukFrontpic", + "id": "MUK", + "name": "MUK" + }, + "MURKROW": { + "back": "murkrow_back", + "backLabel": "MurkrowBackpic", + "dexLabel": "MurkrowPokedexEntry", + "front": "murkrow", + "frontLabel": "MurkrowFrontpic", + "id": "MURKROW", + "name": "MURKROW" + }, + "NATU": { + "back": "natu_back", + "backLabel": "NatuBackpic", + "dexLabel": "NatuPokedexEntry", + "front": "natu", + "frontLabel": "NatuFrontpic", + "id": "NATU", + "name": "NATU" + }, + "NIDOKING": { + "back": "nidoking_back", + "backLabel": "NidokingBackpic", + "dexLabel": "NidokingPokedexEntry", + "front": "nidoking", + "frontLabel": "NidokingFrontpic", + "id": "NIDOKING", + "name": "NIDOKING" + }, + "NIDOQUEEN": { + "back": "nidoqueen_back", + "backLabel": "NidoqueenBackpic", + "dexLabel": "NidoqueenPokedexEntry", + "front": "nidoqueen", + "frontLabel": "NidoqueenFrontpic", + "id": "NIDOQUEEN", + "name": "NIDOQUEEN" + }, + "NIDORAN_F": { + "back": "nidoranf_back", + "backLabel": "NidoranFBackpic", + "dexLabel": "NidoranFPokedexEntry", + "front": "nidoranf", + "frontLabel": "NidoranFFrontpic", + "id": "NIDORAN_F", + "name": "NIDORAN♀" + }, + "NIDORAN_M": { + "back": "nidoranm_back", + "backLabel": "NidoranMBackpic", + "dexLabel": "NidoranMPokedexEntry", + "front": "nidoranm", + "frontLabel": "NidoranMFrontpic", + "id": "NIDORAN_M", + "name": "NIDORAN♂" + }, + "NIDORINA": { + "back": "nidorina_back", + "backLabel": "NidorinaBackpic", + "dexLabel": "NidorinaPokedexEntry", + "front": "nidorina", + "frontLabel": "NidorinaFrontpic", + "id": "NIDORINA", + "name": "NIDORINA" + }, + "NIDORINO": { + "back": "nidorino_back", + "backLabel": "NidorinoBackpic", + "dexLabel": "NidorinoPokedexEntry", + "front": "nidorino", + "frontLabel": "NidorinoFrontpic", + "id": "NIDORINO", + "name": "NIDORINO" + }, + "NINETALES": { + "back": "ninetales_back", + "backLabel": "NinetalesBackpic", + "dexLabel": "NinetalesPokedexEntry", + "front": "ninetales", + "frontLabel": "NinetalesFrontpic", + "id": "NINETALES", + "name": "NINETALES" + }, + "NOCTOWL": { + "back": "noctowl_back", + "backLabel": "NoctowlBackpic", + "dexLabel": "NoctowlPokedexEntry", + "front": "noctowl", + "frontLabel": "NoctowlFrontpic", + "id": "NOCTOWL", + "name": "NOCTOWL" + }, + "OCTILLERY": { + "back": "octillery_back", + "backLabel": "OctilleryBackpic", + "dexLabel": "OctilleryPokedexEntry", + "front": "octillery", + "frontLabel": "OctilleryFrontpic", + "id": "OCTILLERY", + "name": "OCTILLERY" + }, + "ODDISH": { + "back": "oddish_back", + "backLabel": "OddishBackpic", + "dexLabel": "OddishPokedexEntry", + "front": "oddish", + "frontLabel": "OddishFrontpic", + "id": "ODDISH", + "name": "ODDISH" + }, + "OMANYTE": { + "back": "omanyte_back", + "backLabel": "OmanyteBackpic", + "dexLabel": "OmanytePokedexEntry", + "front": "omanyte", + "frontLabel": "OmanyteFrontpic", + "id": "OMANYTE", + "name": "OMANYTE" + }, + "OMASTAR": { + "back": "omastar_back", + "backLabel": "OmastarBackpic", + "dexLabel": "OmastarPokedexEntry", + "front": "omastar", + "frontLabel": "OmastarFrontpic", + "id": "OMASTAR", + "name": "OMASTAR" + }, + "ONIX": { + "back": "onix_back", + "backLabel": "OnixBackpic", + "dexLabel": "OnixPokedexEntry", + "front": "onix", + "frontLabel": "OnixFrontpic", + "id": "ONIX", + "name": "ONIX" + }, + "PARAS": { + "back": "paras_back", + "backLabel": "ParasBackpic", + "dexLabel": "ParasPokedexEntry", + "front": "paras", + "frontLabel": "ParasFrontpic", + "id": "PARAS", + "name": "PARAS" + }, + "PARASECT": { + "back": "parasect_back", + "backLabel": "ParasectBackpic", + "dexLabel": "ParasectPokedexEntry", + "front": "parasect", + "frontLabel": "ParasectFrontpic", + "id": "PARASECT", + "name": "PARASECT" + }, + "PERSIAN": { + "back": "persian_back", + "backLabel": "PersianBackpic", + "dexLabel": "PersianPokedexEntry", + "front": "persian", + "frontLabel": "PersianFrontpic", + "id": "PERSIAN", + "name": "PERSIAN" + }, + "PHANPY": { + "back": "phanpy_back", + "backLabel": "PhanpyBackpic", + "dexLabel": "PhanpyPokedexEntry", + "front": "phanpy", + "frontLabel": "PhanpyFrontpic", + "id": "PHANPY", + "name": "PHANPY" + }, + "PICHU": { + "back": "pichu_back", + "backLabel": "PichuBackpic", + "dexLabel": "PichuPokedexEntry", + "front": "pichu", + "frontLabel": "PichuFrontpic", + "id": "PICHU", + "name": "PICHU" + }, + "PIDGEOT": { + "back": "pidgeot_back", + "backLabel": "PidgeotBackpic", + "dexLabel": "PidgeotPokedexEntry", + "front": "pidgeot", + "frontLabel": "PidgeotFrontpic", + "id": "PIDGEOT", + "name": "PIDGEOT" + }, + "PIDGEOTTO": { + "back": "pidgeotto_back", + "backLabel": "PidgeottoBackpic", + "dexLabel": "PidgeottoPokedexEntry", + "front": "pidgeotto", + "frontLabel": "PidgeottoFrontpic", + "id": "PIDGEOTTO", + "name": "PIDGEOTTO" + }, + "PIDGEY": { + "back": "pidgey_back", + "backLabel": "PidgeyBackpic", + "dexLabel": "PidgeyPokedexEntry", + "front": "pidgey", + "frontLabel": "PidgeyFrontpic", + "id": "PIDGEY", + "name": "PIDGEY" + }, + "PIKACHU": { + "back": "pikachu_back", + "backLabel": "PikachuBackpic", + "dexLabel": "PikachuPokedexEntry", + "front": "pikachu", + "frontLabel": "PikachuFrontpic", + "id": "PIKACHU", + "name": "PIKACHU" + }, + "PILOSWINE": { + "back": "piloswine_back", + "backLabel": "PiloswineBackpic", + "dexLabel": "PiloswinePokedexEntry", + "front": "piloswine", + "frontLabel": "PiloswineFrontpic", + "id": "PILOSWINE", + "name": "PILOSWINE" + }, + "PINECO": { + "back": "pineco_back", + "backLabel": "PinecoBackpic", + "dexLabel": "PinecoPokedexEntry", + "front": "pineco", + "frontLabel": "PinecoFrontpic", + "id": "PINECO", + "name": "PINECO" + }, + "PINSIR": { + "back": "pinsir_back", + "backLabel": "PinsirBackpic", + "dexLabel": "PinsirPokedexEntry", + "front": "pinsir", + "frontLabel": "PinsirFrontpic", + "id": "PINSIR", + "name": "PINSIR" + }, + "POLITOED": { + "back": "politoed_back", + "backLabel": "PolitoedBackpic", + "dexLabel": "PolitoedPokedexEntry", + "front": "politoed", + "frontLabel": "PolitoedFrontpic", + "id": "POLITOED", + "name": "POLITOED" + }, + "POLIWAG": { + "back": "poliwag_back", + "backLabel": "PoliwagBackpic", + "dexLabel": "PoliwagPokedexEntry", + "front": "poliwag", + "frontLabel": "PoliwagFrontpic", + "id": "POLIWAG", + "name": "POLIWAG" + }, + "POLIWHIRL": { + "back": "poliwhirl_back", + "backLabel": "PoliwhirlBackpic", + "dexLabel": "PoliwhirlPokedexEntry", + "front": "poliwhirl", + "frontLabel": "PoliwhirlFrontpic", + "id": "POLIWHIRL", + "name": "POLIWHIRL" + }, + "POLIWRATH": { + "back": "poliwrath_back", + "backLabel": "PoliwrathBackpic", + "dexLabel": "PoliwrathPokedexEntry", + "front": "poliwrath", + "frontLabel": "PoliwrathFrontpic", + "id": "POLIWRATH", + "name": "POLIWRATH" + }, + "PONYTA": { + "back": "ponyta_back", + "backLabel": "PonytaBackpic", + "dexLabel": "PonytaPokedexEntry", + "front": "ponyta", + "frontLabel": "PonytaFrontpic", + "id": "PONYTA", + "name": "PONYTA" + }, + "PORYGON": { + "back": "porygon_back", + "backLabel": "PorygonBackpic", + "dexLabel": "PorygonPokedexEntry", + "front": "porygon", + "frontLabel": "PorygonFrontpic", + "id": "PORYGON", + "name": "PORYGON" + }, + "PORYGON2": { + "back": "porygon2_back", + "backLabel": "Porygon2Backpic", + "dexLabel": "Porygon2PokedexEntry", + "front": "porygon2", + "frontLabel": "Porygon2Frontpic", + "id": "PORYGON2", + "name": "PORYGON2" + }, + "PRIMEAPE": { + "back": "primeape_back", + "backLabel": "PrimeapeBackpic", + "dexLabel": "PrimeapePokedexEntry", + "front": "primeape", + "frontLabel": "PrimeapeFrontpic", + "id": "PRIMEAPE", + "name": "PRIMEAPE" + }, + "PSYDUCK": { + "back": "psyduck_back", + "backLabel": "PsyduckBackpic", + "dexLabel": "PsyduckPokedexEntry", + "front": "psyduck", + "frontLabel": "PsyduckFrontpic", + "id": "PSYDUCK", + "name": "PSYDUCK" + }, + "PUPITAR": { + "back": "pupitar_back", + "backLabel": "PupitarBackpic", + "dexLabel": "PupitarPokedexEntry", + "front": "pupitar", + "frontLabel": "PupitarFrontpic", + "id": "PUPITAR", + "name": "PUPITAR" + }, + "QUAGSIRE": { + "back": "quagsire_back", + "backLabel": "QuagsireBackpic", + "dexLabel": "QuagsirePokedexEntry", + "front": "quagsire", + "frontLabel": "QuagsireFrontpic", + "id": "QUAGSIRE", + "name": "QUAGSIRE" + }, + "QUILAVA": { + "back": "quilava_back", + "backLabel": "QuilavaBackpic", + "dexLabel": "QuilavaPokedexEntry", + "front": "quilava", + "frontLabel": "QuilavaFrontpic", + "id": "QUILAVA", + "name": "QUILAVA" + }, + "QWILFISH": { + "back": "qwilfish_back", + "backLabel": "QwilfishBackpic", + "dexLabel": "QwilfishPokedexEntry", + "front": "qwilfish", + "frontLabel": "QwilfishFrontpic", + "id": "QWILFISH", + "name": "QWILFISH" + }, + "RAICHU": { + "back": "raichu_back", + "backLabel": "RaichuBackpic", + "dexLabel": "RaichuPokedexEntry", + "front": "raichu", + "frontLabel": "RaichuFrontpic", + "id": "RAICHU", + "name": "RAICHU" + }, + "RAIKOU": { + "back": "raikou_back", + "backLabel": "RaikouBackpic", + "dexLabel": "RaikouPokedexEntry", + "front": "raikou", + "frontLabel": "RaikouFrontpic", + "id": "RAIKOU", + "name": "RAIKOU" + }, + "RAPIDASH": { + "back": "rapidash_back", + "backLabel": "RapidashBackpic", + "dexLabel": "RapidashPokedexEntry", + "front": "rapidash", + "frontLabel": "RapidashFrontpic", + "id": "RAPIDASH", + "name": "RAPIDASH" + }, + "RATICATE": { + "back": "raticate_back", + "backLabel": "RaticateBackpic", + "dexLabel": "RaticatePokedexEntry", + "front": "raticate", + "frontLabel": "RaticateFrontpic", + "id": "RATICATE", + "name": "RATICATE" + }, + "RATTATA": { + "back": "rattata_back", + "backLabel": "RattataBackpic", + "dexLabel": "RattataPokedexEntry", + "front": "rattata", + "frontLabel": "RattataFrontpic", + "id": "RATTATA", + "name": "RATTATA" + }, + "REMORAID": { + "back": "remoraid_back", + "backLabel": "RemoraidBackpic", + "dexLabel": "RemoraidPokedexEntry", + "front": "remoraid", + "frontLabel": "RemoraidFrontpic", + "id": "REMORAID", + "name": "REMORAID" + }, + "RHYDON": { + "back": "rhydon_back", + "backLabel": "RhydonBackpic", + "dexLabel": "RhydonPokedexEntry", + "front": "rhydon", + "frontLabel": "RhydonFrontpic", + "id": "RHYDON", + "name": "RHYDON" + }, + "RHYHORN": { + "back": "rhyhorn_back", + "backLabel": "RhyhornBackpic", + "dexLabel": "RhyhornPokedexEntry", + "front": "rhyhorn", + "frontLabel": "RhyhornFrontpic", + "id": "RHYHORN", + "name": "RHYHORN" + }, + "SANDSHREW": { + "back": "sandshrew_back", + "backLabel": "SandshrewBackpic", + "dexLabel": "SandshrewPokedexEntry", + "front": "sandshrew", + "frontLabel": "SandshrewFrontpic", + "id": "SANDSHREW", + "name": "SANDSHREW" + }, + "SANDSLASH": { + "back": "sandslash_back", + "backLabel": "SandslashBackpic", + "dexLabel": "SandslashPokedexEntry", + "front": "sandslash", + "frontLabel": "SandslashFrontpic", + "id": "SANDSLASH", + "name": "SANDSLASH" + }, + "SCIZOR": { + "back": "scizor_back", + "backLabel": "ScizorBackpic", + "dexLabel": "ScizorPokedexEntry", + "front": "scizor", + "frontLabel": "ScizorFrontpic", + "id": "SCIZOR", + "name": "SCIZOR" + }, + "SCYTHER": { + "back": "scyther_back", + "backLabel": "ScytherBackpic", + "dexLabel": "ScytherPokedexEntry", + "front": "scyther", + "frontLabel": "ScytherFrontpic", + "id": "SCYTHER", + "name": "SCYTHER" + }, + "SEADRA": { + "back": "seadra_back", + "backLabel": "SeadraBackpic", + "dexLabel": "SeadraPokedexEntry", + "front": "seadra", + "frontLabel": "SeadraFrontpic", + "id": "SEADRA", + "name": "SEADRA" + }, + "SEAKING": { + "back": "seaking_back", + "backLabel": "SeakingBackpic", + "dexLabel": "SeakingPokedexEntry", + "front": "seaking", + "frontLabel": "SeakingFrontpic", + "id": "SEAKING", + "name": "SEAKING" + }, + "SEEL": { + "back": "seel_back", + "backLabel": "SeelBackpic", + "dexLabel": "SeelPokedexEntry", + "front": "seel", + "frontLabel": "SeelFrontpic", + "id": "SEEL", + "name": "SEEL" + }, + "SENTRET": { + "back": "sentret_back", + "backLabel": "SentretBackpic", + "dexLabel": "SentretPokedexEntry", + "front": "sentret", + "frontLabel": "SentretFrontpic", + "id": "SENTRET", + "name": "SENTRET" + }, + "SHELLDER": { + "back": "shellder_back", + "backLabel": "ShellderBackpic", + "dexLabel": "ShellderPokedexEntry", + "front": "shellder", + "frontLabel": "ShellderFrontpic", + "id": "SHELLDER", + "name": "SHELLDER" + }, + "SHUCKLE": { + "back": "shuckle_back", + "backLabel": "ShuckleBackpic", + "dexLabel": "ShucklePokedexEntry", + "front": "shuckle", + "frontLabel": "ShuckleFrontpic", + "id": "SHUCKLE", + "name": "SHUCKLE" + }, + "SKARMORY": { + "back": "skarmory_back", + "backLabel": "SkarmoryBackpic", + "dexLabel": "SkarmoryPokedexEntry", + "front": "skarmory", + "frontLabel": "SkarmoryFrontpic", + "id": "SKARMORY", + "name": "SKARMORY" + }, + "SKIPLOOM": { + "back": "skiploom_back", + "backLabel": "SkiploomBackpic", + "dexLabel": "SkiploomPokedexEntry", + "front": "skiploom", + "frontLabel": "SkiploomFrontpic", + "id": "SKIPLOOM", + "name": "SKIPLOOM" + }, + "SLOWBRO": { + "back": "slowbro_back", + "backLabel": "SlowbroBackpic", + "dexLabel": "SlowbroPokedexEntry", + "front": "slowbro", + "frontLabel": "SlowbroFrontpic", + "id": "SLOWBRO", + "name": "SLOWBRO" + }, + "SLOWKING": { + "back": "slowking_back", + "backLabel": "SlowkingBackpic", + "dexLabel": "SlowkingPokedexEntry", + "front": "slowking", + "frontLabel": "SlowkingFrontpic", + "id": "SLOWKING", + "name": "SLOWKING" + }, + "SLOWPOKE": { + "back": "slowpoke_back", + "backLabel": "SlowpokeBackpic", + "dexLabel": "SlowpokePokedexEntry", + "front": "slowpoke", + "frontLabel": "SlowpokeFrontpic", + "id": "SLOWPOKE", + "name": "SLOWPOKE" + }, + "SLUGMA": { + "back": "slugma_back", + "backLabel": "SlugmaBackpic", + "dexLabel": "SlugmaPokedexEntry", + "front": "slugma", + "frontLabel": "SlugmaFrontpic", + "id": "SLUGMA", + "name": "SLUGMA" + }, + "SMEARGLE": { + "back": "smeargle_back", + "backLabel": "SmeargleBackpic", + "dexLabel": "SmearglePokedexEntry", + "front": "smeargle", + "frontLabel": "SmeargleFrontpic", + "id": "SMEARGLE", + "name": "SMEARGLE" + }, + "SMOOCHUM": { + "back": "smoochum_back", + "backLabel": "SmoochumBackpic", + "dexLabel": "SmoochumPokedexEntry", + "front": "smoochum", + "frontLabel": "SmoochumFrontpic", + "id": "SMOOCHUM", + "name": "SMOOCHUM" + }, + "SNEASEL": { + "back": "sneasel_back", + "backLabel": "SneaselBackpic", + "dexLabel": "SneaselPokedexEntry", + "front": "sneasel", + "frontLabel": "SneaselFrontpic", + "id": "SNEASEL", + "name": "SNEASEL" + }, + "SNORLAX": { + "back": "snorlax_back", + "backLabel": "SnorlaxBackpic", + "dexLabel": "SnorlaxPokedexEntry", + "front": "snorlax", + "frontLabel": "SnorlaxFrontpic", + "id": "SNORLAX", + "name": "SNORLAX" + }, + "SNUBBULL": { + "back": "snubbull_back", + "backLabel": "SnubbullBackpic", + "dexLabel": "SnubbullPokedexEntry", + "front": "snubbull", + "frontLabel": "SnubbullFrontpic", + "id": "SNUBBULL", + "name": "SNUBBULL" + }, + "SPEAROW": { + "back": "spearow_back", + "backLabel": "SpearowBackpic", + "dexLabel": "SpearowPokedexEntry", + "front": "spearow", + "frontLabel": "SpearowFrontpic", + "id": "SPEAROW", + "name": "SPEAROW" + }, + "SPINARAK": { + "back": "spinarak_back", + "backLabel": "SpinarakBackpic", + "dexLabel": "SpinarakPokedexEntry", + "front": "spinarak", + "frontLabel": "SpinarakFrontpic", + "id": "SPINARAK", + "name": "SPINARAK" + }, + "SQUIRTLE": { + "back": "squirtle_back", + "backLabel": "SquirtleBackpic", + "dexLabel": "SquirtlePokedexEntry", + "front": "squirtle", + "frontLabel": "SquirtleFrontpic", + "id": "SQUIRTLE", + "name": "SQUIRTLE" + }, + "STANTLER": { + "back": "stantler_back", + "backLabel": "StantlerBackpic", + "dexLabel": "StantlerPokedexEntry", + "front": "stantler", + "frontLabel": "StantlerFrontpic", + "id": "STANTLER", + "name": "STANTLER" + }, + "STARMIE": { + "back": "starmie_back", + "backLabel": "StarmieBackpic", + "dexLabel": "StarmiePokedexEntry", + "front": "starmie", + "frontLabel": "StarmieFrontpic", + "id": "STARMIE", + "name": "STARMIE" + }, + "STARYU": { + "back": "staryu_back", + "backLabel": "StaryuBackpic", + "dexLabel": "StaryuPokedexEntry", + "front": "staryu", + "frontLabel": "StaryuFrontpic", + "id": "STARYU", + "name": "STARYU" + }, + "STEELIX": { + "back": "steelix_back", + "backLabel": "SteelixBackpic", + "dexLabel": "SteelixPokedexEntry", + "front": "steelix", + "frontLabel": "SteelixFrontpic", + "id": "STEELIX", + "name": "STEELIX" + }, + "SUDOWOODO": { + "back": "sudowoodo_back", + "backLabel": "SudowoodoBackpic", + "dexLabel": "SudowoodoPokedexEntry", + "front": "sudowoodo", + "frontLabel": "SudowoodoFrontpic", + "id": "SUDOWOODO", + "name": "SUDOWOODO" + }, + "SUICUNE": { + "back": "suicune_back", + "backLabel": "SuicuneBackpic", + "dexLabel": "SuicunePokedexEntry", + "front": "suicune", + "frontLabel": "SuicuneFrontpic", + "id": "SUICUNE", + "name": "SUICUNE" + }, + "SUNFLORA": { + "back": "sunflora_back", + "backLabel": "SunfloraBackpic", + "dexLabel": "SunfloraPokedexEntry", + "front": "sunflora", + "frontLabel": "SunfloraFrontpic", + "id": "SUNFLORA", + "name": "SUNFLORA" + }, + "SUNKERN": { + "back": "sunkern_back", + "backLabel": "SunkernBackpic", + "dexLabel": "SunkernPokedexEntry", + "front": "sunkern", + "frontLabel": "SunkernFrontpic", + "id": "SUNKERN", + "name": "SUNKERN" + }, + "SWINUB": { + "back": "swinub_back", + "backLabel": "SwinubBackpic", + "dexLabel": "SwinubPokedexEntry", + "front": "swinub", + "frontLabel": "SwinubFrontpic", + "id": "SWINUB", + "name": "SWINUB" + }, + "TANGELA": { + "back": "tangela_back", + "backLabel": "TangelaBackpic", + "dexLabel": "TangelaPokedexEntry", + "front": "tangela", + "frontLabel": "TangelaFrontpic", + "id": "TANGELA", + "name": "TANGELA" + }, + "TAUROS": { + "back": "tauros_back", + "backLabel": "TaurosBackpic", + "dexLabel": "TaurosPokedexEntry", + "front": "tauros", + "frontLabel": "TaurosFrontpic", + "id": "TAUROS", + "name": "TAUROS" + }, + "TEDDIURSA": { + "back": "teddiursa_back", + "backLabel": "TeddiursaBackpic", + "dexLabel": "TeddiursaPokedexEntry", + "front": "teddiursa", + "frontLabel": "TeddiursaFrontpic", + "id": "TEDDIURSA", + "name": "TEDDIURSA" + }, + "TENTACOOL": { + "back": "tentacool_back", + "backLabel": "TentacoolBackpic", + "dexLabel": "TentacoolPokedexEntry", + "front": "tentacool", + "frontLabel": "TentacoolFrontpic", + "id": "TENTACOOL", + "name": "TENTACOOL" + }, + "TENTACRUEL": { + "back": "tentacruel_back", + "backLabel": "TentacruelBackpic", + "dexLabel": "TentacruelPokedexEntry", + "front": "tentacruel", + "frontLabel": "TentacruelFrontpic", + "id": "TENTACRUEL", + "name": "TENTACRUEL" + }, + "TOGEPI": { + "back": "togepi_back", + "backLabel": "TogepiBackpic", + "dexLabel": "TogepiPokedexEntry", + "front": "togepi", + "frontLabel": "TogepiFrontpic", + "id": "TOGEPI", + "name": "TOGEPI" + }, + "TOGETIC": { + "back": "togetic_back", + "backLabel": "TogeticBackpic", + "dexLabel": "TogeticPokedexEntry", + "front": "togetic", + "frontLabel": "TogeticFrontpic", + "id": "TOGETIC", + "name": "TOGETIC" + }, + "TOTODILE": { + "back": "totodile_back", + "backLabel": "TotodileBackpic", + "dexLabel": "TotodilePokedexEntry", + "front": "totodile", + "frontLabel": "TotodileFrontpic", + "id": "TOTODILE", + "name": "TOTODILE" + }, + "TYPHLOSION": { + "back": "typhlosion_back", + "backLabel": "TyphlosionBackpic", + "dexLabel": "TyphlosionPokedexEntry", + "front": "typhlosion", + "frontLabel": "TyphlosionFrontpic", + "id": "TYPHLOSION", + "name": "TYPHLOSION" + }, + "TYRANITAR": { + "back": "tyranitar_back", + "backLabel": "TyranitarBackpic", + "dexLabel": "TyranitarPokedexEntry", + "front": "tyranitar", + "frontLabel": "TyranitarFrontpic", + "id": "TYRANITAR", + "name": "TYRANITAR" + }, + "TYROGUE": { + "back": "tyrogue_back", + "backLabel": "TyrogueBackpic", + "dexLabel": "TyroguePokedexEntry", + "front": "tyrogue", + "frontLabel": "TyrogueFrontpic", + "id": "TYROGUE", + "name": "TYROGUE" + }, + "UMBREON": { + "back": "umbreon_back", + "backLabel": "UmbreonBackpic", + "dexLabel": "UmbreonPokedexEntry", + "front": "umbreon", + "frontLabel": "UmbreonFrontpic", + "id": "UMBREON", + "name": "UMBREON" + }, + "UNOWN": { + "back": null, + "backLabel": null, + "dexLabel": "UnownPokedexEntry", + "front": null, + "frontLabel": null, + "id": "UNOWN", + "name": "UNOWN" + }, + "URSARING": { + "back": "ursaring_back", + "backLabel": "UrsaringBackpic", + "dexLabel": "UrsaringPokedexEntry", + "front": "ursaring", + "frontLabel": "UrsaringFrontpic", + "id": "URSARING", + "name": "URSARING" + }, + "VAPOREON": { + "back": "vaporeon_back", + "backLabel": "VaporeonBackpic", + "dexLabel": "VaporeonPokedexEntry", + "front": "vaporeon", + "frontLabel": "VaporeonFrontpic", + "id": "VAPOREON", + "name": "VAPOREON" + }, + "VENOMOTH": { + "back": "venomoth_back", + "backLabel": "VenomothBackpic", + "dexLabel": "VenomothPokedexEntry", + "front": "venomoth", + "frontLabel": "VenomothFrontpic", + "id": "VENOMOTH", + "name": "VENOMOTH" + }, + "VENONAT": { + "back": "venonat_back", + "backLabel": "VenonatBackpic", + "dexLabel": "VenonatPokedexEntry", + "front": "venonat", + "frontLabel": "VenonatFrontpic", + "id": "VENONAT", + "name": "VENONAT" + }, + "VENUSAUR": { + "back": "venusaur_back", + "backLabel": "VenusaurBackpic", + "dexLabel": "VenusaurPokedexEntry", + "front": "venusaur", + "frontLabel": "VenusaurFrontpic", + "id": "VENUSAUR", + "name": "VENUSAUR" + }, + "VICTREEBEL": { + "back": "victreebel_back", + "backLabel": "VictreebelBackpic", + "dexLabel": "VictreebelPokedexEntry", + "front": "victreebel", + "frontLabel": "VictreebelFrontpic", + "id": "VICTREEBEL", + "name": "VICTREEBEL" + }, + "VILEPLUME": { + "back": "vileplume_back", + "backLabel": "VileplumeBackpic", + "dexLabel": "VileplumePokedexEntry", + "front": "vileplume", + "frontLabel": "VileplumeFrontpic", + "id": "VILEPLUME", + "name": "VILEPLUME" + }, + "VOLTORB": { + "back": "voltorb_back", + "backLabel": "VoltorbBackpic", + "dexLabel": "VoltorbPokedexEntry", + "front": "voltorb", + "frontLabel": "VoltorbFrontpic", + "id": "VOLTORB", + "name": "VOLTORB" + }, + "VULPIX": { + "back": "vulpix_back", + "backLabel": "VulpixBackpic", + "dexLabel": "VulpixPokedexEntry", + "front": "vulpix", + "frontLabel": "VulpixFrontpic", + "id": "VULPIX", + "name": "VULPIX" + }, + "WARTORTLE": { + "back": "wartortle_back", + "backLabel": "WartortleBackpic", + "dexLabel": "WartortlePokedexEntry", + "front": "wartortle", + "frontLabel": "WartortleFrontpic", + "id": "WARTORTLE", + "name": "WARTORTLE" + }, + "WEEDLE": { + "back": "weedle_back", + "backLabel": "WeedleBackpic", + "dexLabel": "WeedlePokedexEntry", + "front": "weedle", + "frontLabel": "WeedleFrontpic", + "id": "WEEDLE", + "name": "WEEDLE" + }, + "WEEPINBELL": { + "back": "weepinbell_back", + "backLabel": "WeepinbellBackpic", + "dexLabel": "WeepinbellPokedexEntry", + "front": "weepinbell", + "frontLabel": "WeepinbellFrontpic", + "id": "WEEPINBELL", + "name": "WEEPINBELL" + }, + "WEEZING": { + "back": "weezing_back", + "backLabel": "WeezingBackpic", + "dexLabel": "WeezingPokedexEntry", + "front": "weezing", + "frontLabel": "WeezingFrontpic", + "id": "WEEZING", + "name": "WEEZING" + }, + "WIGGLYTUFF": { + "back": "wigglytuff_back", + "backLabel": "WigglytuffBackpic", + "dexLabel": "WigglytuffPokedexEntry", + "front": "wigglytuff", + "frontLabel": "WigglytuffFrontpic", + "id": "WIGGLYTUFF", + "name": "WIGGLYTUFF" + }, + "WOBBUFFET": { + "back": "wobbuffet_back", + "backLabel": "WobbuffetBackpic", + "dexLabel": "WobbuffetPokedexEntry", + "front": "wobbuffet", + "frontLabel": "WobbuffetFrontpic", + "id": "WOBBUFFET", + "name": "WOBBUFFET" + }, + "WOOPER": { + "back": "wooper_back", + "backLabel": "WooperBackpic", + "dexLabel": "WooperPokedexEntry", + "front": "wooper", + "frontLabel": "WooperFrontpic", + "id": "WOOPER", + "name": "WOOPER" + }, + "XATU": { + "back": "xatu_back", + "backLabel": "XatuBackpic", + "dexLabel": "XatuPokedexEntry", + "front": "xatu", + "frontLabel": "XatuFrontpic", + "id": "XATU", + "name": "XATU" + }, + "YANMA": { + "back": "yanma_back", + "backLabel": "YanmaBackpic", + "dexLabel": "YanmaPokedexEntry", + "front": "yanma", + "frontLabel": "YanmaFrontpic", + "id": "YANMA", + "name": "YANMA" + }, + "ZAPDOS": { + "back": "zapdos_back", + "backLabel": "ZapdosBackpic", + "dexLabel": "ZapdosPokedexEntry", + "front": "zapdos", + "frontLabel": "ZapdosFrontpic", + "id": "ZAPDOS", + "name": "ZAPDOS" + }, + "ZUBAT": { + "back": "zubat_back", + "backLabel": "ZubatBackpic", + "dexLabel": "ZubatPokedexEntry", + "front": "zubat", + "frontLabel": "ZubatFrontpic", + "id": "ZUBAT", + "name": "ZUBAT" + } + }, + "romSha1": "d8b8a3600a465308c9953dfa04f0081c05bdcb94", + "symbols": { + "AbraBackpic": [ + 27, + 21469 + ], + "AbraFrontpic": [ + 27, + 27451 + ], + "AbraPokedexEntry": [ + 104, + 23271 + ], + "AerodactylBackpic": [ + 27, + 22473 + ], + "AerodactylFrontpic": [ + 31, + 21700 + ], + "AerodactylPokedexEntry": [ + 106, + 17818 + ], + "AerodactylPuzzleLZ": [ + 56, + 25096 + ], + "AipomBackpic": [ + 27, + 23475 + ], + "AipomFrontpic": [ + 28, + 22113 + ], + "AipomPokedexEntry": [ + 106, + 23159 + ], + "AlakazamBackpic": [ + 21, + 22483 + ], + "AlakazamFrontpic": [ + 31, + 21136 + ], + "AlakazamPokedexEntry": [ + 105, + 16384 + ], + "AlphabeticalPokedexOrder": [ + 16, + 19557 + ], + "AmpharosBackpic": [ + 29, + 25057 + ], + "AmpharosFrontpic": [ + 22, + 19041 + ], + "AmpharosPokedexEntry": [ + 106, + 22141 + ], + "AnimObjGFX": [ + 51, + 31803 + ], + "AnimateFlowerTile": [ + 63, + 17288 + ], + "AnimateFlowerTile.FlowerTileFrames": [ + 63, + 17319 + ], + "AnimateHallOfFame.String_NewHallOfFamer": [ + 33, + 25876 + ], + "AnimateLavaBubbleTile1": [ + 63, + 17383 + ], + "AnimateLavaBubbleTile2": [ + 63, + 17414 + ], + "AnimateTowerPillarTile": [ + 63, + 17504 + ], + "AnimateWaterPalette": [ + 63, + 17650 + ], + "AnimateWaterTile": [ + 63, + 17198 + ], + "AnimateWaterTile.WaterTileFrames": [ + 63, + 17224 + ], + "AnimateWhirlpoolTile": [ + 63, + 17555 + ], + "ArbokBackpic": [ + 29, + 26453 + ], + "ArbokFrontpic": [ + 18, + 28139 + ], + "ArbokPokedexEntry": [ + 104, + 18968 + ], + "ArcanineBackpic": [ + 24, + 20323 + ], + "ArcanineFrontpic": [ + 31, + 29395 + ], + "ArcaninePokedexEntry": [ + 104, + 22836 + ], + "AriadosBackpic": [ + 26, + 29790 + ], + "AriadosFrontpic": [ + 22, + 30199 + ], + "AriadosPokedexEntry": [ + 106, + 20678 + ], + "ArticunoBackpic": [ + 30, + 16909 + ], + "ArticunoFrontpic": [ + 31, + 25039 + ], + "ArticunoPokedexEntry": [ + 106, + 18033 + ], + "AzumarillBackpic": [ + 29, + 32162 + ], + "AzumarillFrontpic": [ + 32, + 22322 + ], + "AzumarillPokedexEntry": [ + 106, + 22479 + ], + "BadgeGFX": [ + 9, + 25135 + ], + "BargainShopData": [ + 5, + 24282 + ], + "BaseData": [ + 20, + 23307 + ], + "BattleAnimFrameData": [ + 51, + 26531 + ], + "BattleAnimOAMData": [ + 51, + 28147 + ], + "BattleAnimObjects": [ + 51, + 19109 + ], + "BattleAnimations": [ + 50, + 20490 + ], + "BattleObjectPals": [ + 2, + 23561 + ], + "BattleWinSlideInEnemyTrainerFrontpic": [ + 15, + 27158 + ], + "BayleefBackpic": [ + 23, + 25039 + ], + "BayleefFrontpic": [ + 21, + 27970 + ], + "BayleefPokedexEntry": [ + 106, + 19025 + ], + "BeedrillBackpic": [ + 24, + 17173 + ], + "BeedrillFrontpic": [ + 18, + 31103 + ], + "BeedrillPokedexEntry": [ + 104, + 17941 + ], + "BellossomBackpic": [ + 26, + 25968 + ], + "BellossomFrontpic": [ + 24, + 19143 + ], + "BellossomPokedexEntry": [ + 106, + 22249 + ], + "BellsproutBackpic": [ + 29, + 21358 + ], + "BellsproutFrontpic": [ + 28, + 29792 + ], + "BellsproutPokedexEntry": [ + 105, + 16837 + ], + "BlastoiseBackpic": [ + 25, + 30882 + ], + "BlastoiseFrontpic": [ + 18, + 27540 + ], + "BlastoisePokedexEntry": [ + 104, + 17276 + ], + "BlisseyBackpic": [ + 29, + 30565 + ], + "BlisseyFrontpic": [ + 32, + 17108 + ], + "BlisseyPokedexEntry": [ + 107, + 21761 + ], + "BugCatchingContestantEventFlagTable": [ + 4, + 32186 + ], + "BulbasaurBackpic": [ + 28, + 32477 + ], + "BulbasaurFrontpic": [ + 30, + 28012 + ], + "BulbasaurPokedexEntry": [ + 104, + 16384 + ], + "ButterfreeBackpic": [ + 21, + 21096 + ], + "ButterfreeFrontpic": [ + 31, + 26137 + ], + "ButterfreePokedexEntry": [ + 104, + 17608 + ], + "CalPic": [ + 26, + 26667 + ], + "CardStatusGFX": [ + 9, + 22287 + ], + "CaterpieBackpic": [ + 29, + 23646 + ], + "CaterpieFrontpic": [ + 30, + 31910 + ], + "CaterpiePokedexEntry": [ + 104, + 17386 + ], + "CelebiBackpic": [ + 22, + 31462 + ], + "CelebiFrontpic": [ + 29, + 29217 + ], + "CelebiPokedexEntry": [ + 107, + 22782 + ], + "ChanseyBackpic": [ + 28, + 27678 + ], + "ChanseyFrontpic": [ + 23, + 23404 + ], + "ChanseyPokedexEntry": [ + 105, + 21731 + ], + "CharizardBackpic": [ + 26, + 16744 + ], + "CharizardFrontpic": [ + 18, + 28734 + ], + "CharizardPokedexEntry": [ + 104, + 16934 + ], + "CharmanderBackpic": [ + 28, + 29191 + ], + "CharmanderFrontpic": [ + 28, + 26455 + ], + "CharmanderPokedexEntry": [ + 104, + 16708 + ], + "CharmeleonBackpic": [ + 24, + 25404 + ], + "CharmeleonFrontpic": [ + 22, + 28936 + ], + "CharmeleonPokedexEntry": [ + 104, + 16823 + ], + "ChikoritaBackpic": [ + 28, + 25534 + ], + "ChikoritaFrontpic": [ + 29, + 22219 + ], + "ChikoritaPokedexEntry": [ + 106, + 18913 + ], + "ChinchouBackpic": [ + 28, + 26762 + ], + "ChinchouFrontpic": [ + 25, + 30151 + ], + "ChinchouPokedexEntry": [ + 106, + 20905 + ], + "ChrisBackpic": [ + 15, + 31179 + ], + "ChrisPicAndTrainerCardGFX": [ + 9, + 21631 + ], + "ChrisSpriteGFX": [ + 48, + 16384 + ], + "ClefableBackpic": [ + 22, + 24685 + ], + "ClefableFrontpic": [ + 24, + 24237 + ], + "ClefablePokedexEntry": [ + 104, + 20321 + ], + "ClefairyBackpic": [ + 26, + 23866 + ], + "ClefairyFrontpic": [ + 28, + 32180 + ], + "ClefairyPokedexEntry": [ + 104, + 20205 + ], + "CleffaBackpic": [ + 30, + 21034 + ], + "CleffaFrontpic": [ + 30, + 27330 + ], + "CleffaPokedexEntry": [ + 106, + 21244 + ], + "ClockTilemapRLE": [ + 36, + 21914 + ], + "CloysterBackpic": [ + 21, + 24320 + ], + "CloysterFrontpic": [ + 31, + 28312 + ], + "CloysterPokedexEntry": [ + 105, + 19293 + ], + "ContestMons": [ + 37, + 31672 + ], + "CopyBackpic": [ + 15, + 31090 + ], + "CopyrightGFX": [ + 57, + 16384 + ], + "CorsolaBackpic": [ + 29, + 19914 + ], + "CorsolaFrontpic": [ + 25, + 27584 + ], + "CorsolaPokedexEntry": [ + 107, + 19589 + ], + "CreditsBellossomGFX": [ + 33, + 27958 + ], + "CreditsBorderGFX": [ + 33, + 27814 + ], + "CreditsElekidGFX": [ + 33, + 29494 + ], + "CreditsPalettes": [ + 33, + 27676 + ], + "CreditsSentretGFX": [ + 33, + 30262 + ], + "CreditsTogepiGFX": [ + 33, + 28726 + ], + "Cries": [ + 58, + 20882 + ], + "CrobatBackpic": [ + 29, + 30296 + ], + "CrobatFrontpic": [ + 23, + 27872 + ], + "CrobatPokedexEntry": [ + 106, + 20794 + ], + "CroconawBackpic": [ + 23, + 25446 + ], + "CroconawFrontpic": [ + 23, + 20526 + ], + "CroconawPokedexEntry": [ + 106, + 19689 + ], + "CuboneBackpic": [ + 24, + 18355 + ], + "CuboneFrontpic": [ + 27, + 27122 + ], + "CubonePokedexEntry": [ + 105, + 20723 + ], + "CyndaquilBackpic": [ + 23, + 22994 + ], + "CyndaquilFrontpic": [ + 27, + 24474 + ], + "CyndaquilPokedexEntry": [ + 106, + 19227 + ], + "DecorationDesc_GiantOrnament.BigDollScript": [ + 9, + 29110 + ], + "DecorationDesc_NullPoster": [ + 9, + 29067 + ], + "DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript": [ + 9, + 29096 + ], + "DecorationDesc_PosterPointers": [ + 9, + 29015 + ], + "DelibirdBackpic": [ + 26, + 28406 + ], + "DelibirdFrontpic": [ + 23, + 26660 + ], + "DelibirdPokedexEntry": [ + 107, + 19906 + ], + "DewgongBackpic": [ + 30, + 25464 + ], + "DewgongFrontpic": [ + 31, + 27769 + ], + "DewgongPokedexEntry": [ + 105, + 18846 + ], + "DiglettBackpic": [ + 30, + 30978 + ], + "DiglettFrontpic": [ + 30, + 30785 + ], + "DiglettPokedexEntry": [ + 104, + 21850 + ], + "DiplomaGFX": [ + 56, + 16645 + ], + "DiplomaPage1Tilemap": [ + 56, + 17739 + ], + "DiplomaPalettes": [ + 2, + 31366 + ], + "DittoBackpic": [ + 30, + 19507 + ], + "DittoFrontpic": [ + 46, + 16555 + ], + "DittoPokedexEntry": [ + 106, + 16716 + ], + "DodrioBackpic": [ + 21, + 26607 + ], + "DodrioFrontpic": [ + 31, + 25588 + ], + "DodrioPokedexEntry": [ + 105, + 18609 + ], + "DoduoBackpic": [ + 23, + 26256 + ], + "DoduoFrontpic": [ + 29, + 18160 + ], + "DoduoPokedexEntry": [ + 105, + 18490 + ], + "DoneTileAnimation": [ + 63, + 17058 + ], + "DonphanBackpic": [ + 30, + 26404 + ], + "DonphanFrontpic": [ + 32, + 26856 + ], + "DonphanPokedexEntry": [ + 107, + 20667 + ], + "DragonairBackpic": [ + 28, + 25841 + ], + "DragonairFrontpic": [ + 23, + 18049 + ], + "DragonairPokedexEntry": [ + 106, + 18461 + ], + "DragoniteBackpic": [ + 29, + 25616 + ], + "DragoniteFrontpic": [ + 31, + 20569 + ], + "DragonitePokedexEntry": [ + 106, + 18571 + ], + "DratiniBackpic": [ + 30, + 21787 + ], + "DratiniFrontpic": [ + 29, + 29487 + ], + "DratiniPokedexEntry": [ + 106, + 18360 + ], + "DrawPocketName.tilemap": [ + 4, + 19964 + ], + "DrowzeeBackpic": [ + 30, + 21286 + ], + "DrowzeeFrontpic": [ + 21, + 25236 + ], + "DrowzeePokedexEntry": [ + 105, + 19845 + ], + "Drumkits": [ + 58, + 20050 + ], + "DudeBackpic": [ + 15, + 31579 + ], + "DugtrioBackpic": [ + 27, + 22139 + ], + "DugtrioFrontpic": [ + 25, + 29785 + ], + "DugtrioPokedexEntry": [ + 104, + 21954 + ], + "DunsparceBackpic": [ + 29, + 21070 + ], + "DunsparceFrontpic": [ + 27, + 18770 + ], + "DunsparcePokedexEntry": [ + 107, + 17826 + ], + "EeveeBackpic": [ + 24, + 30793 + ], + "EeveeFrontpic": [ + 29, + 18747 + ], + "EeveePokedexEntry": [ + 106, + 16827 + ], + "EggHatchGFX": [ + 5, + 30487 + ], + "EggMovePointers": [ + 8, + 31230 + ], + "EggPic": [ + 20, + 31363 + ], + "EkansBackpic": [ + 26, + 19969 + ], + "EkansFrontpic": [ + 28, + 21485 + ], + "EkansPokedexEntry": [ + 104, + 18854 + ], + "ElectabuzzBackpic": [ + 24, + 26953 + ], + "ElectabuzzFrontpic": [ + 32, + 29312 + ], + "ElectabuzzPokedexEntry": [ + 105, + 23058 + ], + "ElectrodeBackpic": [ + 30, + 23276 + ], + "ElectrodeFrontpic": [ + 29, + 31898 + ], + "ElectrodePokedexEntry": [ + 105, + 20404 + ], + "ElekidBackpic": [ + 22, + 30620 + ], + "ElekidFrontpic": [ + 22, + 23827 + ], + "ElekidPokedexEntry": [ + 107, + 21435 + ], + "ElevatorFloorNames": [ + 4, + 31045 + ], + "EnemyHPBarBorderGFX": [ + 62, + 19378 + ], + "EnteiBackpic": [ + 29, + 26732 + ], + "EnteiFrontpic": [ + 18, + 26339 + ], + "EnteiPokedexEntry": [ + 107, + 21995 + ], + "EnvironmentColorsPointers": [ + 2, + 30414 + ], + "EspeonBackpic": [ + 27, + 24141 + ], + "EspeonFrontpic": [ + 23, + 27468 + ], + "EspeonPokedexEntry": [ + 107, + 16728 + ], + "EvosAttacksPointers": [ + 16, + 26557 + ], + "ExeggcuteBackpic": [ + 26, + 18897 + ], + "ExeggcuteFrontpic": [ + 31, + 23380 + ], + "ExeggcutePokedexEntry": [ + 105, + 20515 + ], + "ExeggutorBackpic": [ + 21, + 18288 + ], + "ExeggutorFrontpic": [ + 32, + 19725 + ], + "ExeggutorPokedexEntry": [ + 105, + 20615 + ], + "ExpBarGFX": [ + 62, + 19458 + ], + "ExpBarPalette": [ + 2, + 27961 + ], + "FarfetchDBackpic": [ + 23, + 22583 + ], + "FarfetchDFrontpic": [ + 22, + 18158 + ], + "FarfetchDPokedexEntry": [ + 105, + 18372 + ], + "FearowBackpic": [ + 30, + 22036 + ], + "FearowFrontpic": [ + 21, + 22021 + ], + "FearowPokedexEntry": [ + 104, + 18743 + ], + "FeraligatrBackpic": [ + 22, + 25968 + ], + "FeraligatrFrontpic": [ + 18, + 21337 + ], + "FeraligatrPokedexEntry": [ + 106, + 19800 + ], + "FishGroups": [ + 36, + 27127 + ], + "FlaaffyBackpic": [ + 25, + 23889 + ], + "FlaaffyFrontpic": [ + 24, + 19930 + ], + "FlaaffyPokedexEntry": [ + 106, + 22033 + ], + "FlareonBackpic": [ + 24, + 20716 + ], + "FlareonFrontpic": [ + 22, + 27244 + ], + "FlareonPokedexEntry": [ + 106, + 17160 + ], + "FlickeringCaveEntrancePalette": [ + 63, + 17709 + ], + "Font": [ + 62, + 17138 + ], + "FontBattleExtra": [ + 62, + 18162 + ], + "FontExtra": [ + 62, + 16626 + ], + "FontsExtra_SolidBlackAndUpArrowGFX": [ + 62, + 21246 + ], + "Footprints": [ + 62, + 21262 + ], + "ForretressBackpic": [ + 46, + 17219 + ], + "ForretressFrontpic": [ + 32, + 20767 + ], + "ForretressPokedexEntry": [ + 107, + 17715 + ], + "Frames": [ + 62, + 18674 + ], + "FurretBackpic": [ + 26, + 18539 + ], + "FurretFrontpic": [ + 23, + 30682 + ], + "FurretPokedexEntry": [ + 106, + 20024 + ], + "GameFreakLogoGFX": [ + 57, + 19329 + ], + "GameFreakLogoStarsGFX": [ + 57, + 19553 + ], + "GastlyBackpic": [ + 30, + 21537 + ], + "GastlyFrontpic": [ + 22, + 26820 + ], + "GastlyPokedexEntry": [ + 105, + 19401 + ], + "GengarBackpic": [ + 27, + 26463 + ], + "GengarFrontpic": [ + 24, + 31173 + ], + "GengarPokedexEntry": [ + 105, + 19615 + ], + "GeodudeBackpic": [ + 25, + 27952 + ], + "GeodudeFrontpic": [ + 30, + 26171 + ], + "GeodudePokedexEntry": [ + 105, + 17382 + ], + "GetBattleMonBackpic": [ + 15, + 29251 + ], + "GetEggFrontpic": [ + 5, + 30156 + ], + "GetEnemyMonFrontpic": [ + 15, + 29314 + ], + "GetFrontpic": [ + 20, + 22420 + ], + "GetMonBackpic": [ + 20, + 22531 + ], + "GetMonFrontpic": [ + 20, + 22400 + ], + "GetTrainerBackpic": [ + 15, + 31064 + ], + "GirafarigBackpic": [ + 25, + 16384 + ], + "GirafarigFrontpic": [ + 21, + 22944 + ], + "GirafarigPokedexEntry": [ + 107, + 17481 + ], + "GligarBackpic": [ + 24, + 24626 + ], + "GligarFrontpic": [ + 22, + 17716 + ], + "GligarPokedexEntry": [ + 107, + 17935 + ], + "GloomBackpic": [ + 24, + 17961 + ], + "GloomFrontpic": [ + 23, + 28678 + ], + "GloomPokedexEntry": [ + 104, + 21172 + ], + "GolbatBackpic": [ + 30, + 32264 + ], + "GolbatFrontpic": [ + 21, + 23403 + ], + "GolbatPokedexEntry": [ + 104, + 20961 + ], + "GoldeenBackpic": [ + 24, + 19537 + ], + "GoldeenFrontpic": [ + 27, + 30382 + ], + "GoldeenPokedexEntry": [ + 105, + 22285 + ], + "GolduckBackpic": [ + 27, + 17410 + ], + "GolduckFrontpic": [ + 31, + 28855 + ], + "GolduckPokedexEntry": [ + 104, + 22405 + ], + "GolemBackpic": [ + 30, + 29114 + ], + "GolemFrontpic": [ + 22, + 23396 + ], + "GolemPokedexEntry": [ + 105, + 17595 + ], + "GranbullBackpic": [ + 28, + 23053 + ], + "GranbullFrontpic": [ + 22, + 27668 + ], + "GranbullPokedexEntry": [ + 107, + 18273 + ], + "GrassRustleGFX": [ + 5, + 18009 + ], + "GravelerBackpic": [ + 27, + 23808 + ], + "GravelerFrontpic": [ + 22, + 16384 + ], + "GravelerPokedexEntry": [ + 105, + 17487 + ], + "GrimerBackpic": [ + 24, + 26566 + ], + "GrimerFrontpic": [ + 27, + 18431 + ], + "GrimerPokedexEntry": [ + 105, + 18956 + ], + "GrowlitheBackpic": [ + 25, + 19398 + ], + "GrowlitheFrontpic": [ + 27, + 16726 + ], + "GrowlithePokedexEntry": [ + 104, + 22730 + ], + "GrowthRates": [ + 20, + 22019 + ], + "GyaradosBackpic": [ + 22, + 21231 + ], + "GyaradosFrontpic": [ + 18, + 19279 + ], + "GyaradosPokedexEntry": [ + 106, + 16493 + ], + "HOF_SlideBackpic": [ + 33, + 26135 + ], + "HOF_SlideFrontpic": [ + 33, + 26149 + ], + "HPBarPals": [ + 2, + 27949 + ], + "HPExpBarBorderGFX": [ + 62, + 19410 + ], + "HappyEmote": [ + 5, + 17545 + ], + "HaunterBackpic": [ + 28, + 17990 + ], + "HaunterFrontpic": [ + 24, + 25015 + ], + "HaunterPokedexEntry": [ + 105, + 19512 + ], + "HealMachineAnim.HealMachineGFX": [ + 4, + 26581 + ], + "HealMachineAnim.palettes": [ + 4, + 26664 + ], + "HeldItemIcons": [ + 35, + 26843 + ], + "HeracrossBackpic": [ + 24, + 28494 + ], + "HeracrossFrontpic": [ + 22, + 22533 + ], + "HeracrossPokedexEntry": [ + 107, + 18703 + ], + "HitmonchanBackpic": [ + 25, + 30517 + ], + "HitmonchanFrontpic": [ + 26, + 30825 + ], + "HitmonchanPokedexEntry": [ + 105, + 21056 + ], + "HitmonleeBackpic": [ + 29, + 27010 + ], + "HitmonleeFrontpic": [ + 21, + 27062 + ], + "HitmonleePokedexEntry": [ + 105, + 20946 + ], + "HitmontopBackpic": [ + 21, + 24778 + ], + "HitmontopFrontpic": [ + 32, + 27843 + ], + "HitmontopPokedexEntry": [ + 107, + 21220 + ], + "HoOhBackpic": [ + 27, + 20125 + ], + "HoOhFrontpic": [ + 18, + 18591 + ], + "HoOhPokedexEntry": [ + 107, + 22667 + ], + "HoOhPuzzleLZ": [ + 56, + 24760 + ], + "HoothootBackpic": [ + 27, + 29087 + ], + "HoothootFrontpic": [ + 28, + 27068 + ], + "HoothootPokedexEntry": [ + 106, + 20136 + ], + "HoppipBackpic": [ + 28, + 23365 + ], + "HoppipFrontpic": [ + 30, + 17171 + ], + "HoppipPokedexEntry": [ + 106, + 22814 + ], + "HorseaBackpic": [ + 30, + 17956 + ], + "HorseaFrontpic": [ + 30, + 29547 + ], + "HorseaPokedexEntry": [ + 105, + 22062 + ], + "HoundoomBackpic": [ + 25, + 28686 + ], + "HoundoomFrontpic": [ + 22, + 19921 + ], + "HoundoomPokedexEntry": [ + 107, + 20356 + ], + "HoundourBackpic": [ + 30, + 22782 + ], + "HoundourFrontpic": [ + 29, + 17273 + ], + "HoundourPokedexEntry": [ + 107, + 20245 + ], + "HypnoBackpic": [ + 23, + 29080 + ], + "HypnoFrontpic": [ + 32, + 16582 + ], + "HypnoPokedexEntry": [ + 105, + 19957 + ], + "IconPointers": [ + 35, + 27248 + ], + "Icons": [ + 35, + 27326 + ], + "IgglybuffBackpic": [ + 29, + 31368 + ], + "IgglybuffFrontpic": [ + 30, + 28677 + ], + "IgglybuffPokedexEntry": [ + 106, + 21350 + ], + "InitializeEventsScript": [ + 64, + 17256 + ], + "Intro_FireGFX1": [ + 57, + 26888 + ], + "Intro_FireGFX2": [ + 57, + 28232 + ], + "Intro_FireGFX3": [ + 57, + 29128 + ], + "Intro_GetMonFrontpic": [ + 20, + 22655 + ], + "Intro_GrassGFX1": [ + 57, + 24664 + ], + "Intro_GrassGFX2": [ + 57, + 25512 + ], + "Intro_GrassMeta": [ + 57, + 25400 + ], + "Intro_GrassTilemap": [ + 57, + 25144 + ], + "Intro_LoadMagikarpPalettes.MagikarpBGPal": [ + 2, + 20774 + ], + "Intro_LoadMagikarpPalettes.MagikarpOBPal": [ + 2, + 20782 + ], + "Intro_RotatePalettesLeftFrontpic": [ + 1, + 25005 + ], + "Intro_WaterGFX1": [ + 57, + 21736 + ], + "Intro_WaterGFX2": [ + 57, + 23768 + ], + "Intro_WaterMeta": [ + 57, + 23496 + ], + "Intro_WaterTilemap": [ + 57, + 22984 + ], + "Intro_WipeInFrontpic": [ + 1, + 25029 + ], + "ItemAttributes": [ + 1, + 26784 + ], + "ItemDescriptions": [ + 110, + 16384 + ], + "ItemNames": [ + 108, + 16384 + ], + "IvysaurBackpic": [ + 26, + 31512 + ], + "IvysaurFrontpic": [ + 26, + 27712 + ], + "IvysaurPokedexEntry": [ + 104, + 16490 + ], + "JigglypuffBackpic": [ + 30, + 23767 + ], + "JigglypuffFrontpic": [ + 29, + 20203 + ], + "JigglypuffPokedexEntry": [ + 104, + 20641 + ], + "JohtoGrassWildMons": [ + 10, + 27445 + ], + "JohtoMap": [ + 36, + 24402 + ], + "JohtoWaterWildMons": [ + 10, + 30313 + ], + "JolteonBackpic": [ + 28, + 21170 + ], + "JolteonFrontpic": [ + 22, + 20359 + ], + "JolteonPokedexEntry": [ + 106, + 17046 + ], + "JumpluffBackpic": [ + 25, + 23516 + ], + "JumpluffFrontpic": [ + 21, + 17340 + ], + "JumpluffPokedexEntry": [ + 106, + 23041 + ], + "JynxBackpic": [ + 30, + 31727 + ], + "JynxFrontpic": [ + 25, + 25742 + ], + "JynxPokedexEntry": [ + 105, + 22946 + ], + "KabutoBackpic": [ + 27, + 31351 + ], + "KabutoFrontpic": [ + 30, + 23029 + ], + "KabutoPokedexEntry": [ + 106, + 17588 + ], + "KabutoPuzzleLZ": [ + 56, + 25528 + ], + "KabutopsBackpic": [ + 24, + 18749 + ], + "KabutopsFrontpic": [ + 21, + 26151 + ], + "KabutopsPokedexEntry": [ + 106, + 17704 + ], + "KadabraBackpic": [ + 26, + 17104 + ], + "KadabraFrontpic": [ + 32, + 25364 + ], + "KadabraPokedexEntry": [ + 104, + 23376 + ], + "KakunaBackpic": [ + 29, + 28395 + ], + "KakunaFrontpic": [ + 30, + 29972 + ], + "KakunaPokedexEntry": [ + 104, + 17834 + ], + "KangaskhanBackpic": [ + 22, + 17273 + ], + "KangaskhanFrontpic": [ + 18, + 26940 + ], + "KangaskhanPokedexEntry": [ + 105, + 21943 + ], + "KantoGrassWildMons": [ + 10, + 30656 + ], + "KantoMap": [ + 36, + 24763 + ], + "KantoWaterWildMons": [ + 10, + 32067 + ], + "KingdraBackpic": [ + 26, + 23162 + ], + "KingdraFrontpic": [ + 21, + 16384 + ], + "KingdraPokedexEntry": [ + 107, + 20452 + ], + "KinglerBackpic": [ + 27, + 26132 + ], + "KinglerFrontpic": [ + 32, + 22834 + ], + "KinglerPokedexEntry": [ + 105, + 20179 + ], + "KoffingBackpic": [ + 24, + 32309 + ], + "KoffingFrontpic": [ + 21, + 23862 + ], + "KoffingPokedexEntry": [ + 105, + 21280 + ], + "KrabbyBackpic": [ + 22, + 31882 + ], + "KrabbyFrontpic": [ + 26, + 27364 + ], + "KrabbyPokedexEntry": [ + 105, + 20060 + ], + "Landmarks": [ + 36, + 25474 + ], + "LanturnBackpic": [ + 29, + 24495 + ], + "LanturnFrontpic": [ + 26, + 28753 + ], + "LanturnPokedexEntry": [ + 106, + 21021 + ], + "LaprasBackpic": [ + 28, + 25226 + ], + "LaprasFrontpic": [ + 21, + 19232 + ], + "LaprasPokedexEntry": [ + 106, + 16598 + ], + "LarvitarBackpic": [ + 28, + 31287 + ], + "LarvitarFrontpic": [ + 29, + 30833 + ], + "LarvitarPokedexEntry": [ + 107, + 22214 + ], + "LavaBubbleTileFrames": [ + 63, + 17440 + ], + "LeaderGFX": [ + 9, + 22383 + ], + "LedianBackpic": [ + 24, + 28109 + ], + "LedianFrontpic": [ + 25, + 28319 + ], + "LedianPokedexEntry": [ + 106, + 20457 + ], + "LedybaBackpic": [ + 23, + 22172 + ], + "LedybaFrontpic": [ + 25, + 26111 + ], + "LedybaPokedexEntry": [ + 106, + 20340 + ], + "LickitungBackpic": [ + 28, + 17349 + ], + "LickitungFrontpic": [ + 21, + 19700 + ], + "LickitungPokedexEntry": [ + 105, + 21170 + ], + "LoadOrientedFrontpic": [ + 20, + 22948 + ], + "LoadQuestionMarkPic.QuestionMarkLZ": [ + 112, + 19520 + ], + "LugiaBackpic": [ + 21, + 32466 + ], + "LugiaFrontpic": [ + 18, + 31689 + ], + "LugiaPokedexEntry": [ + 107, + 22548 + ], + "MachampBackpic": [ + 32, + 32222 + ], + "MachampFrontpic": [ + 18, + 20653 + ], + "MachampPokedexEntry": [ + 105, + 16718 + ], + "MachokeBackpic": [ + 25, + 19773 + ], + "MachokeFrontpic": [ + 31, + 22824 + ], + "MachokePokedexEntry": [ + 105, + 16614 + ], + "MachopBackpic": [ + 27, + 21804 + ], + "MachopFrontpic": [ + 28, + 27982 + ], + "MachopPokedexEntry": [ + 105, + 16502 + ], + "MagbyBackpic": [ + 28, + 24918 + ], + "MagbyFrontpic": [ + 28, + 22427 + ], + "MagbyPokedexEntry": [ + 107, + 21550 + ], + "MagcargoBackpic": [ + 24, + 25792 + ], + "MagcargoFrontpic": [ + 21, + 18760 + ], + "MagcargoPokedexEntry": [ + 107, + 19271 + ], + "MagikarpBackpic": [ + 26, + 29445 + ], + "MagikarpFrontpic": [ + 25, + 21648 + ], + "MagikarpPokedexEntry": [ + 106, + 16384 + ], + "MagmarBackpic": [ + 23, + 23814 + ], + "MagmarFrontpic": [ + 32, + 21287 + ], + "MagmarPokedexEntry": [ + 105, + 23173 + ], + "MagnemiteBackpic": [ + 46, + 16889 + ], + "MagnemiteFrontpic": [ + 46, + 18869 + ], + "MagnemitePokedexEntry": [ + 105, + 18155 + ], + "MagnetTrainBGTiles": [ + 35, + 20492 + ], + "MagnetTrainTilemap": [ + 35, + 20772 + ], + "MagnetonBackpic": [ + 22, + 26395 + ], + "MagnetonFrontpic": [ + 22, + 18600 + ], + "MagnetonPokedexEntry": [ + 105, + 18264 + ], + "MankeyBackpic": [ + 23, + 28275 + ], + "MankeyFrontpic": [ + 27, + 25470 + ], + "MankeyPokedexEntry": [ + 104, + 22513 + ], + "MantineBackpic": [ + 30, + 29331 + ], + "MantineFrontpic": [ + 32, + 27351 + ], + "MantinePokedexEntry": [ + 107, + 20018 + ], + "MapGroupPointers": [ + 37, + 16621 + ], + "MapGroupRoofs": [ + 7, + 16417 + ], + "MapObjectPals": [ + 2, + 30894 + ], + "MareepBackpic": [ + 26, + 22809 + ], + "MareepFrontpic": [ + 29, + 17569 + ], + "MareepPokedexEntry": [ + 106, + 21922 + ], + "MarillBackpic": [ + 30, + 22285 + ], + "MarillFrontpic": [ + 29, + 21646 + ], + "MarillPokedexEntry": [ + 106, + 22362 + ], + "MarowakBackpic": [ + 25, + 27216 + ], + "MarowakFrontpic": [ + 24, + 29646 + ], + "MarowakPokedexEntry": [ + 105, + 20829 + ], + "Marts": [ + 5, + 25342 + ], + "MeganiumBackpic": [ + 27, + 31997 + ], + "MeganiumFrontpic": [ + 18, + 19967 + ], + "MeganiumPokedexEntry": [ + 106, + 19128 + ], + "MeowthBackpic": [ + 27, + 32319 + ], + "MeowthFrontpic": [ + 26, + 20325 + ], + "MeowthPokedexEntry": [ + 104, + 22068 + ], + "MetapodBackpic": [ + 30, + 23522 + ], + "MetapodFrontpic": [ + 30, + 31544 + ], + "MetapodPokedexEntry": [ + 104, + 17495 + ], + "MewBackpic": [ + 27, + 21134 + ], + "MewFrontpic": [ + 30, + 17433 + ], + "MewPokedexEntry": [ + 106, + 18796 + ], + "MewtwoBackpic": [ + 26, + 25268 + ], + "MewtwoFrontpic": [ + 32, + 24363 + ], + "MewtwoPokedexEntry": [ + 106, + 18680 + ], + "MiltankBackpic": [ + 25, + 18646 + ], + "MiltankFrontpic": [ + 22, + 25541 + ], + "MiltankPokedexEntry": [ + 107, + 21658 + ], + "MisdreavusBackpic": [ + 25, + 31608 + ], + "MisdreavusFrontpic": [ + 24, + 23067 + ], + "MisdreavusPokedexEntry": [ + 107, + 17156 + ], + "MoltresBackpic": [ + 23, + 16802 + ], + "MoltresFrontpic": [ + 31, + 18281 + ], + "MoltresPokedexEntry": [ + 106, + 18249 + ], + "MonMenuIcons": [ + 35, + 26997 + ], + "MoveDescriptions": [ + 109, + 16384 + ], + "MoveNames": [ + 108, + 21876 + ], + "Moves": [ + 16, + 23294 + ], + "MrMimeBackpic": [ + 29, + 23361 + ], + "MrMimeFrontpic": [ + 22, + 16829 + ], + "MrMimePokedexEntry": [ + 105, + 22737 + ], + "MukBackpic": [ + 26, + 28059 + ], + "MukFrontpic": [ + 31, + 20000 + ], + "MukPokedexEntry": [ + 105, + 19071 + ], + "MurkrowBackpic": [ + 26, + 31169 + ], + "MurkrowFrontpic": [ + 27, + 25138 + ], + "MurkrowPokedexEntry": [ + 107, + 16936 + ], + "Music": [ + 58, + 20590 + ], + "Music_AfterTheRivalFight": [ + 61, + 20775 + ], + "Music_AzaleaTown": [ + 61, + 22504 + ], + "Music_Bicycle": [ + 59, + 24857 + ], + "Music_BugCatchingContest": [ + 61, + 31766 + ], + "Music_BurnedTower": [ + 61, + 27289 + ], + "Music_CeladonCity": [ + 61, + 17243 + ], + "Music_ChampionBattle": [ + 58, + 26986 + ], + "Music_CherrygroveCity": [ + 61, + 23299 + ], + "Music_ContestResults": [ + 59, + 31550 + ], + "Music_Credits": [ + 7, + 30796 + ], + "Music_DancingHall": [ + 59, + 31164 + ], + "Music_DarkCave": [ + 58, + 25191 + ], + "Music_DragonsDen": [ + 59, + 27880 + ], + "Music_EcruteakCity": [ + 59, + 29392 + ], + "Music_ElmsLab": [ + 58, + 24553 + ], + "Music_Evolution": [ + 60, + 18145 + ], + "Music_GameCorner": [ + 59, + 23917 + ], + "Music_GoldSilverOpening": [ + 61, + 28757 + ], + "Music_GoldSilverOpening2": [ + 61, + 29448 + ], + "Music_GoldenrodCity": [ + 58, + 29692 + ], + "Music_Gym": [ + 61, + 18606 + ], + "Music_GymLeaderVictory": [ + 61, + 17964 + ], + "Music_HallOfFame": [ + 60, + 17855 + ], + "Music_HealPokemon": [ + 60, + 18071 + ], + "Music_IndigoPlateau": [ + 59, + 26706 + ], + "Music_JohtoGymBattle": [ + 58, + 25782 + ], + "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_LookOfficer": [ + 59, + 22573 + ], + "Music_LookPokemaniac": [ + 58, + 32213 + ], + "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_Mom": [ + 61, + 27634 + ], + "Music_MtMoon": [ + 59, + 23337 + ], + "Music_MtMoonSquare": [ + 61, + 18453 + ], + "Music_NationalPark": [ + 61, + 21736 + ], + "Music_NewBarkTown": [ + 58, + 29308 + ], + "Music_Nothing": [ + 58, + 20869 + ], + "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, + 31971 + ], + "Music_Printer": [ + 60, + 18429 + ], + "Music_ProfOak": [ + 61, + 19946 + ], + "Music_ProfOaksPokemonTalk": [ + 61, + 19615 + ], + "Music_RivalBattle": [ + 58, + 22264 + ], + "Music_RocketBattle": [ + 58, + 23320 + ], + "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, + 21650 + ], + "Music_Route37": [ + 59, + 26955 + ], + "Music_RuinsOfAlphInterior": [ + 58, + 32146 + ], + "Music_RuinsOfAlphRadio": [ + 59, + 28107 + ], + "Music_SSAqua": [ + 58, + 27970 + ], + "Music_ShowMeAround": [ + 59, + 23512 + ], + "Music_SproutTower": [ + 61, + 26996 + ], + "Music_SuccessfulCapture": [ + 61, + 17922 + ], + "Music_Surf": [ + 61, + 20874 + ], + "Music_TinTower": [ + 61, + 26641 + ], + "Music_TitleScreen": [ + 58, + 30641 + ], + "Music_TrainerVictory": [ + 58, + 32415 + ], + "Music_UnionCave": [ + 61, + 23648 + ], + "Music_VermilionCity": [ + 58, + 30239 + ], + "Music_VictoryRoad": [ + 61, + 27762 + ], + "Music_VioletCity": [ + 60, + 16384 + ], + "Music_ViridianCity": [ + 61, + 16384 + ], + "Music_WildPokemonVictory": [ + 61, + 17688 + ], + "NPCTradeCableText": [ + 63, + 19744 + ], + "NPCTrades": [ + 63, + 19492 + ], + "NamingScreenGFX_Border": [ + 4, + 24708 + ], + "NamingScreenGFX_Cursor": [ + 4, + 24724 + ], + "NamingScreenGFX_MiddleLine": [ + 4, + 25138 + ], + "NamingScreenGFX_UnderLine": [ + 4, + 25146 + ], + "NatuBackpic": [ + 30, + 30390 + ], + "NatuFrontpic": [ + 46, + 17379 + ], + "NatuPokedexEntry": [ + 106, + 21686 + ], + "NewPokedexEntry": [ + 62, + 31105 + ], + "NewPokedexOrder": [ + 16, + 19808 + ], + "NidokingBackpic": [ + 32, + 30285 + ], + "NidokingFrontpic": [ + 18, + 17890 + ], + "NidokingPokedexEntry": [ + 104, + 20092 + ], + "NidoqueenBackpic": [ + 24, + 26179 + ], + "NidoqueenFrontpic": [ + 18, + 25133 + ], + "NidoqueenPokedexEntry": [ + 104, + 19751 + ], + "NidoranFBackpic": [ + 27, + 22807 + ], + "NidoranFFrontpic": [ + 26, + 32540 + ], + "NidoranFPokedexEntry": [ + 104, + 19515 + ], + "NidoranMBackpic": [ + 23, + 21350 + ], + "NidoranMFrontpic": [ + 29, + 23930 + ], + "NidoranMPokedexEntry": [ + 104, + 19860 + ], + "NidorinaBackpic": [ + 25, + 21273 + ], + "NidorinaFrontpic": [ + 22, + 20795 + ], + "NidorinaPokedexEntry": [ + 104, + 19633 + ], + "NidorinoBackpic": [ + 25, + 24632 + ], + "NidorinoFrontpic": [ + 18, + 32274 + ], + "NidorinoPokedexEntry": [ + 104, + 19978 + ], + "NinetalesBackpic": [ + 23, + 24632 + ], + "NinetalesFrontpic": [ + 18, + 22642 + ], + "NinetalesPokedexEntry": [ + 104, + 20535 + ], + "NoctowlBackpic": [ + 27, + 30705 + ], + "NoctowlFrontpic": [ + 21, + 30226 + ], + "NoctowlPokedexEntry": [ + 106, + 20232 + ], + "OctilleryBackpic": [ + 30, + 16384 + ], + "OctilleryFrontpic": [ + 25, + 17894 + ], + "OctilleryPokedexEntry": [ + 107, + 19803 + ], + "OddishBackpic": [ + 30, + 24012 + ], + "OddishFrontpic": [ + 30, + 30591 + ], + "OddishPokedexEntry": [ + 104, + 21067 + ], + "OmanyteBackpic": [ + 24, + 21502 + ], + "OmanyteFrontpic": [ + 29, + 18454 + ], + "OmanytePokedexEntry": [ + 106, + 17380 + ], + "OmanytePuzzleLZ": [ + 56, + 25768 + ], + "OmastarBackpic": [ + 22, + 24256 + ], + "OmastarFrontpic": [ + 21, + 31572 + ], + "OmastarPokedexEntry": [ + 106, + 17489 + ], + "OnixBackpic": [ + 25, + 17140 + ], + "OnixFrontpic": [ + 31, + 29934 + ], + "OnixPokedexEntry": [ + 105, + 19732 + ], + "OverworldSprites": [ + 5, + 18398 + ], + "PackGFX": [ + 4, + 21553 + ], + "PackMenuGFX": [ + 4, + 20273 + ], + "PadFrontpic": [ + 20, + 22854 + ], + "PalPacket_Pack": [ + 2, + 24741 + ], + "ParasBackpic": [ + 29, + 16977 + ], + "ParasFrontpic": [ + 29, + 27288 + ], + "ParasPokedexEntry": [ + 104, + 21396 + ], + "ParasectBackpic": [ + 28, + 21799 + ], + "ParasectFrontpic": [ + 21, + 20633 + ], + "ParasectPokedexEntry": [ + 104, + 21511 + ], + "PartyMenuOBPals": [ + 2, + 31430 + ], + "PersianBackpic": [ + 24, + 29263 + ], + "PersianFrontpic": [ + 31, + 27226 + ], + "PersianPokedexEntry": [ + 104, + 22182 + ], + "PhanpyBackpic": [ + 29, + 28670 + ], + "PhanpyFrontpic": [ + 30, + 20273 + ], + "PhanpyPokedexEntry": [ + 107, + 20554 + ], + "PhoneContacts": [ + 36, + 17466 + ], + "PhoneOutOfAreaScript": [ + 36, + 17958 + ], + "PhoneScript_JustTalkToThem": [ + 36, + 17967 + ], + "PhoneTilemapRLE": [ + 36, + 21833 + ], + "PichuBackpic": [ + 29, + 19625 + ], + "PichuFrontpic": [ + 30, + 27789 + ], + "PichuPokedexEntry": [ + 106, + 21137 + ], + "PidgeotBackpic": [ + 27, + 19109 + ], + "PidgeotFrontpic": [ + 31, + 26682 + ], + "PidgeotPokedexEntry": [ + 104, + 18290 + ], + "PidgeottoBackpic": [ + 29, + 22791 + ], + "PidgeottoFrontpic": [ + 24, + 23457 + ], + "PidgeottoPokedexEntry": [ + 104, + 18174 + ], + "PidgeyBackpic": [ + 24, + 22676 + ], + "PidgeyFrontpic": [ + 29, + 25895 + ], + "PidgeyPokedexEntry": [ + 104, + 18055 + ], + "PikachuBackpic": [ + 26, + 21749 + ], + "PikachuFrontpic": [ + 29, + 22505 + ], + "PikachuPokedexEntry": [ + 104, + 19080 + ], + "PiloswineBackpic": [ + 30, + 29762 + ], + "PiloswineFrontpic": [ + 24, + 27724 + ], + "PiloswinePokedexEntry": [ + 107, + 19484 + ], + "PinecoBackpic": [ + 28, + 19269 + ], + "PinecoFrontpic": [ + 28, + 24609 + ], + "PinecoPokedexEntry": [ + 107, + 17597 + ], + "PinsirBackpic": [ + 25, + 24261 + ], + "PinsirFrontpic": [ + 32, + 23856 + ], + "PinsirPokedexEntry": [ + 105, + 23287 + ], + "PlaceUnownPrinterFrontpic": [ + 5, + 28636 + ], + "PokedexCursorPalette": [ + 2, + 21841 + ], + "PokedexDataPointerTable": [ + 17, + 17248 + ], + "PokedexLZ": [ + 16, + 21777 + ], + "PokedexQuestionMarkPalette": [ + 2, + 21849 + ], + "PokedexSlowpokeLZ": [ + 16, + 22195 + ], + "PokegearGFX": [ + 112, + 20035 + ], + "PokegearPals": [ + 2, + 31598 + ], + "PokegearPhoneIconGFX": [ + 62, + 20502 + ], + "PokegearSpritesGFX": [ + 36, + 21660 + ], + "PokemonCries": [ + 60, + 26439 + ], + "PokemonNames": [ + 108, + 19316 + ], + "PokemonPalettes": [ + 2, + 27965 + ], + "PokemonPicPointers": [ + 18, + 16384 + ], + "PokemonProfPic": [ + 26, + 24918 + ], + "PolitoedBackpic": [ + 29, + 16681 + ], + "PolitoedFrontpic": [ + 32, + 28823 + ], + "PolitoedPokedexEntry": [ + 106, + 22711 + ], + "PoliwagBackpic": [ + 30, + 24985 + ], + "PoliwagFrontpic": [ + 30, + 20781 + ], + "PoliwagPokedexEntry": [ + 104, + 22955 + ], + "PoliwhirlBackpic": [ + 27, + 20798 + ], + "PoliwhirlFrontpic": [ + 23, + 20114 + ], + "PoliwhirlPokedexEntry": [ + 104, + 23059 + ], + "PoliwrathBackpic": [ + 25, + 22023 + ], + "PoliwrathFrontpic": [ + 32, + 23346 + ], + "PoliwrathPokedexEntry": [ + 104, + 23164 + ], + "PonytaBackpic": [ + 24, + 31552 + ], + "PonytaFrontpic": [ + 23, + 17218 + ], + "PonytaPokedexEntry": [ + 105, + 17699 + ], + "Porygon2Backpic": [ + 28, + 17670 + ], + "Porygon2Frontpic": [ + 29, + 16384 + ], + "Porygon2PokedexEntry": [ + 107, + 20772 + ], + "PorygonBackpic": [ + 28, + 31883 + ], + "PorygonFrontpic": [ + 27, + 19787 + ], + "PorygonPokedexEntry": [ + 106, + 17274 + ], + "PredefPals": [ + 2, + 25189 + ], + "PrepMonFrontpic": [ + 0, + 14778 + ], + "PrimeapeBackpic": [ + 26, + 17463 + ], + "PrimeapeFrontpic": [ + 32, + 18158 + ], + "PrimeapePokedexEntry": [ + 104, + 22617 + ], + "PsyduckBackpic": [ + 26, + 21393 + ], + "PsyduckFrontpic": [ + 27, + 16384 + ], + "PsyduckPokedexEntry": [ + 104, + 22295 + ], + "PupitarBackpic": [ + 24, + 31931 + ], + "PupitarFrontpic": [ + 26, + 17822 + ], + "PupitarPokedexEntry": [ + 107, + 22319 + ], + "PuzzlePieceBorderData.TileBordersGFX": [ + 56, + 24368 + ], + "QuagsireBackpic": [ + 28, + 20223 + ], + "QuagsireFrontpic": [ + 28, + 22740 + ], + "QuagsirePokedexEntry": [ + 107, + 16619 + ], + "QuestionEmote": [ + 5, + 17481 + ], + "QuilavaBackpic": [ + 23, + 30282 + ], + "QuilavaFrontpic": [ + 22, + 31041 + ], + "QuilavaPokedexEntry": [ + 106, + 19346 + ], + "QwilfishBackpic": [ + 24, + 23847 + ], + "QwilfishFrontpic": [ + 30, + 18994 + ], + "QwilfishPokedexEntry": [ + 107, + 18381 + ], + "RadioTilemapRLE": [ + 36, + 21708 + ], + "RaichuBackpic": [ + 25, + 20523 + ], + "RaichuFrontpic": [ + 32, + 18681 + ], + "RaichuPokedexEntry": [ + 104, + 19184 + ], + "RaikouBackpic": [ + 30, + 16647 + ], + "RaikouFrontpic": [ + 18, + 23899 + ], + "RaikouPokedexEntry": [ + 107, + 21879 + ], + "RapidashBackpic": [ + 23, + 25852 + ], + "RapidashFrontpic": [ + 18, + 25736 + ], + "RapidashPokedexEntry": [ + 105, + 17819 + ], + "RaticateBackpic": [ + 26, + 19612 + ], + "RaticateFrontpic": [ + 22, + 21666 + ], + "RaticatePokedexEntry": [ + 104, + 18516 + ], + "RattataBackpic": [ + 27, + 26793 + ], + "RattataFrontpic": [ + 30, + 17695 + ], + "RattataPokedexEntry": [ + 104, + 18406 + ], + "ReadTileToAnimBuffer": [ + 63, + 17597 + ], + "RemoraidBackpic": [ + 26, + 22456 + ], + "RemoraidFrontpic": [ + 29, + 25337 + ], + "RemoraidPokedexEntry": [ + 107, + 19692 + ], + "RhydonBackpic": [ + 23, + 18877 + ], + "RhydonFrontpic": [ + 18, + 23276 + ], + "RhydonPokedexEntry": [ + 105, + 21620 + ], + "RhyhornBackpic": [ + 27, + 20462 + ], + "RhyhornFrontpic": [ + 31, + 17124 + ], + "RhyhornPokedexEntry": [ + 105, + 21509 + ], + "RoamMaps": [ + 10, + 26971 + ], + "RockMonMaps": [ + 46, + 25677 + ], + "RoofPals": [ + 2, + 31150 + ], + "Roofs": [ + 7, + 16444 + ], + "RotateUnownFrontpic": [ + 56, + 18459 + ], + "SFX": [ + 58, + 21086 + ], + "SadEmote": [ + 5, + 17609 + ], + "SandshrewBackpic": [ + 23, + 31080 + ], + "SandshrewFrontpic": [ + 28, + 30092 + ], + "SandshrewPokedexEntry": [ + 104, + 19302 + ], + "SandslashBackpic": [ + 23, + 20938 + ], + "SandslashFrontpic": [ + 21, + 16864 + ], + "SandslashPokedexEntry": [ + 104, + 19409 + ], + "ScizorBackpic": [ + 25, + 31970 + ], + "ScizorFrontpic": [ + 31, + 24487 + ], + "ScizorPokedexEntry": [ + 107, + 18496 + ], + "ScrollTileDown": [ + 63, + 17169 + ], + "ScrollTileLeft": [ + 63, + 17102 + ], + "ScrollTileRight": [ + 63, + 17122 + ], + "ScrollTileRightLeft": [ + 63, + 17072 + ], + "ScrollTileUp": [ + 63, + 17142 + ], + "ScytherBackpic": [ + 25, + 29053 + ], + "ScytherFrontpic": [ + 22, + 19481 + ], + "ScytherPokedexEntry": [ + 105, + 22842 + ], + "SeadraBackpic": [ + 23, + 19290 + ], + "SeadraFrontpic": [ + 25, + 22397 + ], + "SeadraPokedexEntry": [ + 105, + 22170 + ], + "SeakingBackpic": [ + 26, + 21037 + ], + "SeakingFrontpic": [ + 31, + 19431 + ], + "SeakingPokedexEntry": [ + 105, + 22405 + ], + "SeelBackpic": [ + 28, + 19906 + ], + "SeelFrontpic": [ + 22, + 29778 + ], + "SeelPokedexEntry": [ + 105, + 18727 + ], + "SentretBackpic": [ + 30, + 18217 + ], + "SentretFrontpic": [ + 30, + 20527 + ], + "SentretPokedexEntry": [ + 106, + 19915 + ], + "ShellderBackpic": [ + 24, + 16384 + ], + "ShellderFrontpic": [ + 28, + 20855 + ], + "ShellderPokedexEntry": [ + 105, + 19185 + ], + "ShockEmote": [ + 5, + 17417 + ], + "Shrink1Pic": [ + 62, + 30142 + ], + "Shrink2Pic": [ + 62, + 30286 + ], + "ShuckleBackpic": [ + 30, + 25700 + ], + "ShuckleFrontpic": [ + 30, + 20019 + ], + "ShucklePokedexEntry": [ + 107, + 18612 + ], + "SkarmoryBackpic": [ + 30, + 24743 + ], + "SkarmoryFrontpic": [ + 18, + 29329 + ], + "SkarmoryPokedexEntry": [ + 107, + 20123 + ], + "SkiploomBackpic": [ + 28, + 28285 + ], + "SkiploomFrontpic": [ + 29, + 28945 + ], + "SkiploomPokedexEntry": [ + 106, + 22935 + ], + "SlowbroBackpic": [ + 26, + 32198 + ], + "SlowbroFrontpic": [ + 32, + 24864 + ], + "SlowbroPokedexEntry": [ + 105, + 18035 + ], + "SlowkingBackpic": [ + 22, + 28514 + ], + "SlowkingFrontpic": [ + 31, + 31001 + ], + "SlowkingPokedexEntry": [ + 107, + 17046 + ], + "SlowpokeBackpic": [ + 30, + 25936 + ], + "SlowpokeFrontpic": [ + 29, + 31633 + ], + "SlowpokePokedexEntry": [ + 105, + 17929 + ], + "SlugmaBackpic": [ + 28, + 19588 + ], + "SlugmaFrontpic": [ + 28, + 30691 + ], + "SlugmaPokedexEntry": [ + 107, + 19162 + ], + "SmeargleBackpic": [ + 25, + 32331 + ], + "SmeargleFrontpic": [ + 21, + 17815 + ], + "SmearglePokedexEntry": [ + 107, + 20991 + ], + "SmoochumBackpic": [ + 29, + 20781 + ], + "SmoochumFrontpic": [ + 29, + 21933 + ], + "SmoochumPokedexEntry": [ + 107, + 21326 + ], + "SneaselBackpic": [ + 27, + 18091 + ], + "SneaselFrontpic": [ + 22, + 22965 + ], + "SneaselPokedexEntry": [ + 107, + 18816 + ], + "SnorlaxBackpic": [ + 46, + 16723 + ], + "SnorlaxFrontpic": [ + 32, + 25863 + ], + "SnorlaxPokedexEntry": [ + 106, + 17928 + ], + "SnubbullBackpic": [ + 27, + 25801 + ], + "SnubbullFrontpic": [ + 26, + 24568 + ], + "SnubbullPokedexEntry": [ + 107, + 18162 + ], + "SpawnPoints": [ + 5, + 21273 + ], + "SpearowBackpic": [ + 28, + 27373 + ], + "SpearowFrontpic": [ + 29, + 32426 + ], + "SpearowPokedexEntry": [ + 104, + 18624 + ], + "SpecialPhoneCallList": [ + 36, + 17910 + ], + "SpinarakBackpic": [ + 30, + 24500 + ], + "SpinarakFrontpic": [ + 30, + 28897 + ], + "SpinarakPokedexEntry": [ + 106, + 20560 + ], + "SpriteMons": [ + 5, + 18025 + ], + "SquirtleBackpic": [ + 30, + 25225 + ], + "SquirtleFrontpic": [ + 30, + 18478 + ], + "SquirtlePokedexEntry": [ + 104, + 17045 + ], + "StandingTileFrame": [ + 63, + 17550 + ], + "StandingTileFrame8": [ + 63, + 17062 + ], + "StantlerBackpic": [ + 26, + 16384 + ], + "StantlerFrontpic": [ + 32, + 19204 + ], + "StantlerPokedexEntry": [ + 107, + 20873 + ], + "StarmieBackpic": [ + 29, + 31101 + ], + "StarmieFrontpic": [ + 21, + 25694 + ], + "StarmiePokedexEntry": [ + 105, + 22617 + ], + "StaryuBackpic": [ + 29, + 23076 + ], + "StaryuFrontpic": [ + 27, + 30059 + ], + "StaryuPokedexEntry": [ + 105, + 22509 + ], + "StatsScreen_PlaceFrontpic": [ + 20, + 20675 + ], + "StdScripts": [ + 64, + 16384 + ], + "SteelixBackpic": [ + 24, + 28879 + ], + "SteelixFrontpic": [ + 18, + 21991 + ], + "SteelixPokedexEntry": [ + 107, + 18048 + ], + "StubbedGetFrontpic": [ + 112, + 19066 + ], + "SudowoodoBackpic": [ + 25, + 26480 + ], + "SudowoodoFrontpic": [ + 24, + 21894 + ], + "SudowoodoPokedexEntry": [ + 106, + 22592 + ], + "SuicuneBackpic": [ + 22, + 28091 + ], + "SuicuneFrontpic": [ + 31, + 16540 + ], + "SuicunePokedexEntry": [ + 107, + 22113 + ], + "SunfloraBackpic": [ + 23, + 17634 + ], + "SunfloraFrontpic": [ + 24, + 16779 + ], + "SunfloraPokedexEntry": [ + 106, + 23377 + ], + "SunkernBackpic": [ + 28, + 31585 + ], + "SunkernFrontpic": [ + 30, + 24256 + ], + "SunkernPokedexEntry": [ + 106, + 23269 + ], + "SwarmGrassWildMons": [ + 10, + 32284 + ], + "SwarmWaterWildMons": [ + 10, + 32473 + ], + "SwinubBackpic": [ + 30, + 28234 + ], + "SwinubFrontpic": [ + 30, + 30182 + ], + "SwinubPokedexEntry": [ + 107, + 19383 + ], + "TMHMMoves": [ + 4, + 23142 + ], + "TangelaBackpic": [ + 30, + 28456 + ], + "TangelaFrontpic": [ + 23, + 21761 + ], + "TangelaPokedexEntry": [ + 105, + 21839 + ], + "TaurosBackpic": [ + 30, + 26869 + ], + "TaurosFrontpic": [ + 18, + 24517 + ], + "TaurosPokedexEntry": [ + 105, + 23402 + ], + "TeddiursaBackpic": [ + 29, + 27843 + ], + "TeddiursaFrontpic": [ + 30, + 19251 + ], + "TeddiursaPokedexEntry": [ + 107, + 18930 + ], + "TentacoolBackpic": [ + 29, + 17865 + ], + "TentacoolFrontpic": [ + 29, + 30027 + ], + "TentacoolPokedexEntry": [ + 105, + 17168 + ], + "TentacruelBackpic": [ + 25, + 20898 + ], + "TentacruelFrontpic": [ + 32, + 29799 + ], + "TentacruelPokedexEntry": [ + 105, + 17272 + ], + "Text_BreedHuh": [ + 101, + 18257 + ], + "TheEndGFX": [ + 50, + 31933 + ], + "TilesetBGPalette": [ + 2, + 30558 + ], + "Tilesets": [ + 5, + 22206 + ], + "TitleScreenGFX1": [ + 38, + 16384 + ], + "TitleScreenGFX2": [ + 38, + 17526 + ], + "TitleScreenGFX3": [ + 57, + 16864 + ], + "TitleScreenGFX4": [ + 57, + 16992 + ], + "TitleScreenTilemap": [ + 38, + 17942 + ], + "TogepiBackpic": [ + 29, + 20492 + ], + "TogepiFrontpic": [ + 30, + 31357 + ], + "TogepiPokedexEntry": [ + 106, + 21460 + ], + "TogeticBackpic": [ + 25, + 18270 + ], + "TogeticFrontpic": [ + 23, + 27064 + ], + "TogeticPokedexEntry": [ + 106, + 21575 + ], + "TotodileBackpic": [ + 27, + 24806 + ], + "TotodileFrontpic": [ + 28, + 28587 + ], + "TotodilePokedexEntry": [ + 106, + 19572 + ], + "TownMapGFX": [ + 62, + 19602 + ], + "TownMapPals.PalMap": [ + 36, + 24236 + ], + "TradeAnim_GetFrontpic": [ + 10, + 21301 + ], + "TradeAnim_ShowFrontpic": [ + 10, + 21351 + ], + "TradeAnim_ShowGetmonFrontpic": [ + 10, + 21348 + ], + "TradeAnim_ShowGivemonFrontpic": [ + 10, + 21343 + ], + "TradeArrowLeftGFX": [ + 10, + 22391 + ], + "TradeArrowRightGFX": [ + 10, + 22375 + ], + "TradeBallGFX": [ + 10, + 22823 + ], + "TradeBubbleGFX": [ + 10, + 22439 + ], + "TradeCableGFX": [ + 10, + 22407 + ], + "TradeGameBoyLZ": [ + 10, + 22503 + ], + "TradeGameBoyTilemap": [ + 10, + 22291 + ], + "TradeLinkTubeTilemap": [ + 10, + 22339 + ], + "TradePoofGFX": [ + 10, + 22919 + ], + "TradeTexts": [ + 63, + 19714 + ], + "TradedForText": [ + 63, + 19749 + ], + "TrainerCard_JohtoBadgesOAM": [ + 9, + 21541 + ], + "TrainerClassAttributes": [ + 14, + 21858 + ], + "TrainerClassNames": [ + 108, + 18773 + ], + "TrainerEncounterMusic": [ + 58, + 20519 + ], + "TrainerGroups": [ + 14, + 22846 + ], + "TrainerPalettes": [ + 2, + 30013 + ], + "TrainerPicPointers": [ + 32, + 16384 + ], + "Trainers": [ + 14, + 22978 + ], + "TreeMonMaps": [ + 46, + 25574 + ], + "TreeMons": [ + 46, + 25712 + ], + "TypeMatchups": [ + 13, + 19713 + ], + "TypeNames": [ + 20, + 18862 + ], + "TyphlosionBackpic": [ + 23, + 29481 + ], + "TyphlosionFrontpic": [ + 31, + 22263 + ], + "TyphlosionPokedexEntry": [ + 106, + 19461 + ], + "TyranitarBackpic": [ + 23, + 18464 + ], + "TyranitarFrontpic": [ + 31, + 17706 + ], + "TyranitarPokedexEntry": [ + 107, + 22435 + ], + "TyrogueBackpic": [ + 26, + 30135 + ], + "TyrogueFrontpic": [ + 28, + 30989 + ], + "TyroguePokedexEntry": [ + 107, + 21105 + ], + "UmbreonBackpic": [ + 25, + 25373 + ], + "UmbreonFrontpic": [ + 27, + 29411 + ], + "UmbreonPokedexEntry": [ + 107, + 16835 + ], + "UnownABackpic": [ + 46, + 20556 + ], + "UnownAFrontpic": [ + 46, + 20679 + ], + "UnownBBackpic": [ + 46, + 19540 + ], + "UnownBFrontpic": [ + 46, + 19142 + ], + "UnownCBackpic": [ + 31, + 32587 + ], + "UnownCFrontpic": [ + 30, + 31169 + ], + "UnownDBackpic": [ + 46, + 18005 + ], + "UnownDFrontpic": [ + 46, + 18155 + ], + "UnownEBackpic": [ + 46, + 18590 + ], + "UnownEFrontpic": [ + 46, + 18449 + ], + "UnownFBackpic": [ + 46, + 17852 + ], + "UnownFFrontpic": [ + 46, + 19277 + ], + "UnownFont": [ + 62, + 29454 + ], + "UnownGBackpic": [ + 46, + 17055 + ], + "UnownGFrontpic": [ + 30, + 32440 + ], + "UnownHBackpic": [ + 30, + 32088 + ], + "UnownHFrontpic": [ + 46, + 16384 + ], + "UnownIBackpic": [ + 46, + 22568 + ], + "UnownIFrontpic": [ + 46, + 22756 + ], + "UnownJBackpic": [ + 46, + 19409 + ], + "UnownJFrontpic": [ + 46, + 19931 + ], + "UnownKBackpic": [ + 46, + 21163 + ], + "UnownKFrontpic": [ + 46, + 20308 + ], + "UnownLBackpic": [ + 46, + 21620 + ], + "UnownLFrontpic": [ + 46, + 21835 + ], + "UnownMBackpic": [ + 46, + 20801 + ], + "UnownMFrontpic": [ + 46, + 21509 + ], + "UnownNBackpic": [ + 46, + 20433 + ], + "UnownNFrontpic": [ + 46, + 20183 + ], + "UnownOBackpic": [ + 46, + 19801 + ], + "UnownOFrontpic": [ + 46, + 21043 + ], + "UnownPBackpic": [ + 46, + 22662 + ], + "UnownPFrontpic": [ + 46, + 22363 + ], + "UnownPicPointers": [ + 31, + 16384 + ], + "UnownPokedexEntry": [ + 107, + 17262 + ], + "UnownPuzzleCursorGFX": [ + 56, + 24530 + ], + "UnownPuzzleStartCancelLZ": [ + 56, + 24594 + ], + "UnownQBackpic": [ + 46, + 22155 + ], + "UnownQFrontpic": [ + 46, + 22260 + ], + "UnownRBackpic": [ + 46, + 22847 + ], + "UnownRFrontpic": [ + 46, + 22937 + ], + "UnownSBackpic": [ + 46, + 18303 + ], + "UnownSFrontpic": [ + 46, + 17538 + ], + "UnownTBackpic": [ + 46, + 21280 + ], + "UnownTFrontpic": [ + 46, + 20922 + ], + "UnownUBackpic": [ + 46, + 21728 + ], + "UnownUFrontpic": [ + 23, + 32664 + ], + "UnownVBackpic": [ + 30, + 32612 + ], + "UnownVFrontpic": [ + 46, + 17696 + ], + "UnownWBackpic": [ + 46, + 20057 + ], + "UnownWFrontpic": [ + 46, + 19671 + ], + "UnownXBackpic": [ + 46, + 21396 + ], + "UnownXFrontpic": [ + 46, + 22049 + ], + "UnownYBackpic": [ + 46, + 22466 + ], + "UnownYFrontpic": [ + 46, + 21942 + ], + "UnownZBackpic": [ + 27, + 32641 + ], + "UnownZFrontpic": [ + 46, + 19007 + ], + "UrsaringBackpic": [ + 29, + 27566 + ], + "UrsaringFrontpic": [ + 32, + 31739 + ], + "UrsaringPokedexEntry": [ + 107, + 19050 + ], + "VaporeonBackpic": [ + 29, + 19040 + ], + "VaporeonFrontpic": [ + 24, + 27339 + ], + "VaporeonPokedexEntry": [ + 106, + 16938 + ], + "VenomothBackpic": [ + 21, + 29776 + ], + "VenomothFrontpic": [ + 21, + 27516 + ], + "VenomothPokedexEntry": [ + 104, + 21737 + ], + "VenonatBackpic": [ + 26, + 24217 + ], + "VenonatFrontpic": [ + 27, + 31674 + ], + "VenonatPokedexEntry": [ + 104, + 21633 + ], + "VenusaurBackpic": [ + 23, + 16384 + ], + "VenusaurFrontpic": [ + 18, + 30513 + ], + "VenusaurPokedexEntry": [ + 104, + 16597 + ], + "VictreebelBackpic": [ + 28, + 16706 + ], + "VictreebelFrontpic": [ + 31, + 31531 + ], + "VictreebelPokedexEntry": [ + 105, + 17050 + ], + "VileplumeBackpic": [ + 25, + 29419 + ], + "VileplumeFrontpic": [ + 24, + 21109 + ], + "VileplumePokedexEntry": [ + 104, + 21283 + ], + "VoltorbBackpic": [ + 30, + 27560 + ], + "VoltorbFrontpic": [ + 46, + 18730 + ], + "VoltorbPokedexEntry": [ + 105, + 20293 + ], + "VulpixBackpic": [ + 26, + 19255 + ], + "VulpixFrontpic": [ + 23, + 24224 + ], + "VulpixPokedexEntry": [ + 104, + 20426 + ], + "WaitTileAnimation": [ + 63, + 17061 + ], + "WartortleBackpic": [ + 26, + 18181 + ], + "WartortleFrontpic": [ + 22, + 29357 + ], + "WartortlePokedexEntry": [ + 104, + 17164 + ], + "WaveSamples": [ + 58, + 19890 + ], + "WeedleBackpic": [ + 30, + 19763 + ], + "WeedleFrontpic": [ + 30, + 27100 + ], + "WeedlePokedexEntry": [ + 104, + 17722 + ], + "WeepinbellBackpic": [ + 27, + 23141 + ], + "WeepinbellFrontpic": [ + 26, + 22103 + ], + "WeepinbellPokedexEntry": [ + 105, + 16936 + ], + "WeezingBackpic": [ + 27, + 17751 + ], + "WeezingFrontpic": [ + 18, + 29923 + ], + "WeezingPokedexEntry": [ + 105, + 21390 + ], + "WigglytuffBackpic": [ + 30, + 18736 + ], + "WigglytuffFrontpic": [ + 25, + 22770 + ], + "WigglytuffPokedexEntry": [ + 104, + 20752 + ], + "WobbuffetBackpic": [ + 29, + 29757 + ], + "WobbuffetFrontpic": [ + 26, + 30480 + ], + "WobbuffetPokedexEntry": [ + 107, + 17375 + ], + "WooperBackpic": [ + 30, + 26637 + ], + "WooperFrontpic": [ + 30, + 22534 + ], + "WooperPokedexEntry": [ + 107, + 16503 + ], + "WriteTileFromAnimBuffer": [ + 63, + 17585 + ], + "WrongNumber.script": [ + 36, + 16960 + ], + "XatuBackpic": [ + 28, + 18310 + ], + "XatuFrontpic": [ + 26, + 31855 + ], + "XatuPokedexEntry": [ + 106, + 21808 + ], + "YanmaBackpic": [ + 22, + 22100 + ], + "YanmaFrontpic": [ + 23, + 29882 + ], + "YanmaPokedexEntry": [ + 107, + 16384 + ], + "ZapdosBackpic": [ + 28, + 26148 + ], + "ZapdosFrontpic": [ + 31, + 18856 + ], + "ZapdosPokedexEntry": [ + 106, + 18147 + ], + "ZubatBackpic": [ + 23, + 32269 + ], + "ZubatFrontpic": [ + 29, + 26174 + ], + "ZubatPokedexEntry": [ + 104, + 20858 + ], + "_AreWeGeniusesText": [ + 100, + 22601 + ], + "_BackAlreadyText": [ + 100, + 22807 + ], + "_BargainShopComeAgainText": [ + 101, + 24234 + ], + "_BargainShopFinalPriceText": [ + 101, + 24084 + ], + "_BargainShopIntroText": [ + 101, + 23979 + ], + "_BargainShopNoFundsText": [ + 101, + 24204 + ], + "_BargainShopPackFullText": [ + 101, + 24122 + ], + "_BargainShopSoldOutText": [ + 101, + 24155 + ], + "_BargainShopThanksText": [ + 101, + 24113 + ], + "_BidsFarewellToMonText": [ + 100, + 18201 + ], + "_BreedAppearsToCareForText": [ + 101, + 18477 + ], + "_BreedAskNicknameText": [ + 101, + 18296 + ], + "_BreedBrimmingWithEnergyText": [ + 101, + 18420 + ], + "_BreedClearboxText": [ + 101, + 18265 + ], + "_BreedEggHatchText": [ + 101, + 18267 + ], + "_BreedFriendlyText": [ + 101, + 18508 + ], + "_BreedNoInterestText": [ + 101, + 18447 + ], + "_BreedShowsInterestText": [ + 101, + 18534 + ], + "_CGB_GSIntro.ShellderLaprasBGPalette": [ + 2, + 22241 + ], + "_CGB_GSIntro.ShellderLaprasOBPals": [ + 2, + 22249 + ], + "_CGB_PackPals.PackPals": [ + 2, + 22895 + ], + "_CantAcceptEggText": [ + 100, + 22429 + ], + "_ComeAgainText": [ + 100, + 22990 + ], + "_ComeBackLaterText": [ + 100, + 22576 + ], + "_DayCareLadyIntroEggText": [ + 100, + 22176 + ], + "_DayCareLadyIntroText": [ + 100, + 22122 + ], + "_DayCareManIntroEggText": [ + 100, + 21949 + ], + "_DayCareManIntroText": [ + 100, + 21902 + ], + "_DaycareDummyText": [ + 100, + 21900 + ], + "_EmptyMailboxText": [ + 101, + 18563 + ], + "_ForYourMonSendsText": [ + 100, + 18255 + ], + "_FoundAnEggText": [ + 100, + 23013 + ], + "_GotBackMonText": [ + 100, + 22788 + ], + "_HallOfFamePC.HOFMaster": [ + 33, + 26331 + ], + "_HallOfFamePC.TimeFamer": [ + 33, + 26347 + ], + "_HaveNoRoomText": [ + 100, + 22919 + ], + "_HerbShopLadyIntroText": [ + 101, + 23692 + ], + "_HerbalLadyComeAgainText": [ + 101, + 23950 + ], + "_HerbalLadyFinalPriceText": [ + 101, + 23826 + ], + "_HerbalLadyHowManyText": [ + 101, + 23815 + ], + "_HerbalLadyNoMoneyText": [ + 101, + 23916 + ], + "_HerbalLadyPackFullText": [ + 101, + 23886 + ], + "_HerbalLadyThanksText": [ + 101, + 23858 + ], + "_IllKeepItThanksText": [ + 100, + 23222 + ], + "_IllRaiseYourMonText": [ + 100, + 22549 + ], + "_LastHealthyMonText": [ + 100, + 22500 + ], + "_LeftWithDayCareLadyText": [ + 101, + 18323 + ], + "_LeftWithDayCareManText": [ + 101, + 18372 + ], + "_MailAlreadyHoldingItemText": [ + 101, + 18673 + ], + "_MailAskSendToPCText": [ + 101, + 21857 + ], + "_MailClearedPutAwayText": [ + 101, + 18585 + ], + "_MailDetachedText": [ + 101, + 21794 + ], + "_MailEggText": [ + 101, + 18705 + ], + "_MailLoseMessageText": [ + 101, + 21757 + ], + "_MailMessageLostText": [ + 101, + 18636 + ], + "_MailMovedFromBoxText": [ + 101, + 18733 + ], + "_MailNoSpaceText": [ + 101, + 21821 + ], + "_MailPackFullText": [ + 101, + 18617 + ], + "_MailSentToPCText": [ + 101, + 21919 + ], + "_MailboxFullText": [ + 101, + 21892 + ], + "_MartAskMoreText": [ + 101, + 24689 + ], + "_MartBoughtText": [ + 101, + 24722 + ], + "_MartCantBuyText": [ + 101, + 24635 + ], + "_MartComeAgainText": [ + 101, + 24669 + ], + "_MartFinalPriceText": [ + 101, + 23660 + ], + "_MartHowManyText": [ + 101, + 23649 + ], + "_MartNoMoneyText": [ + 101, + 24574 + ], + "_MartPackFullText": [ + 101, + 24603 + ], + "_MartSellHowManyText": [ + 101, + 24471 + ], + "_MartSellPriceText": [ + 101, + 24482 + ], + "_MartThanksText": [ + 101, + 24548 + ], + "_MartWelcomeText": [ + 101, + 24518 + ], + "_MonNameBidsFarewellText": [ + 100, + 18223 + ], + "_MonWasSentToText": [ + 100, + 18175 + ], + "_NewPokedexEntry": [ + 16, + 23170 + ], + "_NoRoomForEggText": [ + 100, + 23255 + ], + "_NotEnoughMoneyText": [ + 100, + 22945 + ], + "_NotYetText": [ + 100, + 23003 + ], + "_NothingToSellText": [ + 101, + 24438 + ], + "_OTSendsText": [ + 100, + 18274 + ], + "_OakText1": [ + 101, + 22052 + ], + "_OakText2": [ + 101, + 22163 + ], + "_OakText3": [ + 101, + 22225 + ], + "_OakText4": [ + 101, + 22227 + ], + "_OakText5": [ + 101, + 22335 + ], + "_OakText6": [ + 101, + 22455 + ], + "_OakText7": [ + 101, + 22493 + ], + "_OhFineThenText": [ + 100, + 22974 + ], + "_OnlyOneMonText": [ + 100, + 22396 + ], + "_PCMonHoldingMailText": [ + 101, + 18835 + ], + "_PerfectHeresYourMonText": [ + 100, + 22761 + ], + "_PharmacyComeAgainText": [ + 101, + 24410 + ], + "_PharmacyFinalPriceText": [ + 101, + 24301 + ], + "_PharmacyHowManyText": [ + 101, + 24290 + ], + "_PharmacyIntroText": [ + 101, + 24259 + ], + "_PharmacyNoMoneyText": [ + 101, + 24380 + ], + "_PharmacyPackFullText": [ + 101, + 24349 + ], + "_PharmacyThanksText": [ + 101, + 24335 + ], + "_PokemonRemoveMailText": [ + 101, + 21578 + ], + "_PrepMonFrontpic": [ + 0, + 14783 + ], + "_ReceivedEggText": [ + 100, + 23178 + ], + "_RemoveMailText": [ + 100, + 22463 + ], + "_TakeGoodCareOfEggText": [ + 100, + 23199 + ], + "_TakeGoodCareOfMonText": [ + 100, + 18229 + ], + "_WhatShouldIRaiseText": [ + 100, + 22366 + ], + "_YourMonHasGrownText": [ + 100, + 22651 + ], + "wBaseUnusedBackpic": [ + 1, + 53556 + ], + "wBaseUnusedFrontpic": [ + 1, + 53554 + ] + }, + "tilesets": { + "TILESET_CAVE": {}, + "TILESET_CHAMPIONS_ROOM": {}, + "TILESET_DARK_CAVE": {}, + "TILESET_ELITE_FOUR_ROOM": {}, + "TILESET_FACILITY": {}, + "TILESET_FOREST": {}, + "TILESET_GAME_CORNER": {}, + "TILESET_GATE": {}, + "TILESET_HOUSE": {}, + "TILESET_ICE_PATH": {}, + "TILESET_JOHTO": {}, + "TILESET_JOHTO_MODERN": {}, + "TILESET_KANTO": {}, + "TILESET_LAB": {}, + "TILESET_LIGHTHOUSE": {}, + "TILESET_MANSION": {}, + "TILESET_MART": {}, + "TILESET_PARK": {}, + "TILESET_PLAYERS_HOUSE": {}, + "TILESET_PLAYERS_ROOM": {}, + "TILESET_POKECENTER": {}, + "TILESET_PORT": {}, + "TILESET_RADIO_TOWER": {}, + "TILESET_RUINS_OF_ALPH": {}, + "TILESET_TOWER": {}, + "TILESET_TRADITIONAL_HOUSE": {}, + "TILESET_TRAIN_STATION": {}, + "TILESET_UNDERGROUND": {} + } +} diff --git a/tools/save_convert/convert.lua b/tools/save_convert/convert.lua index 88f4509a..4ee011c7 100644 --- a/tools/save_convert/convert.lua +++ b/tools/save_convert/convert.lua @@ -4,14 +4,20 @@ -- `raw_base64` field -- into this project's save.lua format, or export a -- save.lua back out to a raw .sav. Run from the repo root: -- --- luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua> --- luajit tools/save_convert/convert.lua export <in.lua> <out.sav> +-- luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua> [game] +-- luajit tools/save_convert/convert.lua export <in.lua> <out.sav> [game] -- -- This is a thin shell: all the actual work (size/checksum validation, the -- GenSave codec, crosswalk data loading, the merge over new-game defaults) -- lives in src/save_convert/SaveConvert.lua, shared with the runtime. This -- file only handles the filesystem + the JSON/base64 input framing. See -- src/save_convert/GenSave.lua for the codec and its documented scope. +-- +-- `game` names the game a save belongs to ("red"/"blue"/"yellow"). It is what +-- picks the crosswalk tables -- Yellow renumbers the event bits -- and what +-- SaveConvert answers "Gen 2 cart save" to, so it has to reach both entry +-- points or a Gold save.lua would be pushed straight through the Gen 1 SRAM +-- offsets and written out as a 32768-byte file that looks like a Red battery. package.path = "./?.lua;" .. package.path @@ -19,6 +25,23 @@ local SaveConvert = require("src.save_convert.SaveConvert") local SaveSerializer = require("src.core.SaveSerializer") local Version = require("src.core.Version") +-- A raw .sav carries no game name, and the un-named crosswalk set resolves to +-- Red's tables anyway, so that is what an unqualified run means. +local DEFAULT_GAME = "red" + +-- Which game a save.lua belongs to. Gen 2 stamps `version` at the top level +-- (src/core/gen2/Save.lua Save.normalize); a Gen 1 slot written by the game +-- carries the game name in meta.version, while one written by this CLI carries +-- the save-FORMAT number there instead, which names no game. +local function gameOf(save) + if type(save.version) == "string" then return save.version end + local meta = save.meta + if type(meta) == "table" and type(meta.version) == "string" then + return meta.version + end + return DEFAULT_GAME +end + local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local B64_LOOKUP = {} for i = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(i, i)] = i - 1 end @@ -49,7 +72,7 @@ local function readFile(path, mode) return content end -local function cmdImport(inPath, outPath) +local function cmdImport(inPath, outPath, game) local content = readFile(inPath, "rb") local bytes if #content == SaveConvert.SAVE_SIZE then @@ -62,7 +85,8 @@ local function cmdImport(inPath, outPath) ("decoded raw_base64 is %d bytes, want %d"):format(#bytes, SaveConvert.SAVE_SIZE)) end - local save, err = SaveConvert.importSav(bytes, Version.saveFormat) + local save, err = SaveConvert.importSav(bytes, Version.saveFormat, + game or DEFAULT_GAME) assert(save, err) local out = assert(io.open(outPath, "w")) @@ -74,10 +98,10 @@ local function cmdImport(inPath, outPath) (function() local n = 0 for _ in pairs(save.flags) do n = n + 1 end return n end)())) end -local function cmdExport(inPath, outPath) +local function cmdExport(inPath, outPath, game) local content = readFile(inPath) local save = assert(SaveSerializer.decode(content)) - local bytes, err = SaveConvert.exportSav(save) + local bytes, err = SaveConvert.exportSav(save, game or gameOf(save)) assert(bytes, err) local out = assert(io.open(outPath, "wb")) out:write(bytes) @@ -87,12 +111,13 @@ end local cmd = arg[1] if cmd == "import" and arg[2] and arg[3] then - cmdImport(arg[2], arg[3]) + cmdImport(arg[2], arg[3], arg[4]) elseif cmd == "export" and arg[2] and arg[3] then - cmdExport(arg[2], arg[3]) + cmdExport(arg[2], arg[3], arg[4]) else io.stderr:write( - "usage: luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua>\n" .. - " luajit tools/save_convert/convert.lua export <in.lua> <out.sav>\n") + "usage: luajit tools/save_convert/convert.lua import <in.json|in.sav> <out.lua> [game]\n" .. + " luajit tools/save_convert/convert.lua export <in.lua> <out.sav> [game]\n" .. + " game: red (default) | blue | yellow\n") os.exit(1) end